tx3_resolver/inputs/
mod.rs

1//! Tx input selection algorithms
2
3use std::collections::{BTreeMap, HashSet};
4use tx3_lang::{
5    applying,
6    backend::{self, UtxoStore},
7    ir, CanonicalAssets, Utxo, UtxoRef, UtxoSet,
8};
9
10pub use crate::inputs::searching::SearchSpace;
11
12mod searching;
13
14macro_rules! data_or_bail {
15    ($expr:expr, bytes) => {
16        $expr
17            .as_bytes()
18            .ok_or(Error::ExpectedData("bytes".to_string(), $expr.clone()))
19    };
20
21    ($expr:expr, number) => {
22        $expr
23            .as_number()
24            .ok_or(Error::ExpectedData("number".to_string(), $expr.clone()))?
25    };
26
27    ($expr:expr, assets) => {
28        $expr
29            .as_assets()
30            .ok_or(Error::ExpectedData("assets".to_string(), $expr.clone()))
31    };
32
33    ($expr:expr, utxo_refs) => {
34        $expr
35            .as_utxo_refs()
36            .ok_or(Error::ExpectedData("utxo refs".to_string(), $expr.clone()))
37    };
38}
39
40pub struct Diagnostic {
41    pub query: ir::InputQuery,
42    pub utxos: UtxoSet,
43    pub selected: UtxoSet,
44}
45
46#[derive(thiserror::Error, Debug)]
47pub enum Error {
48    #[error("expected {0}, got {1:?}")]
49    ExpectedData(String, ir::Expression),
50
51    #[error("input query too broad")]
52    InputQueryTooBroad,
53
54    #[error("input not resolved: {0} {1} {2}")]
55    InputNotResolved(String, CanonicalQuery, SearchSpace),
56
57    #[error("store error: {0}")]
58    StoreError(#[from] backend::Error),
59
60    #[error("apply error: {0}")]
61    ApplyError(#[from] applying::Error),
62}
63
64pub trait CoinSelection {
65    fn pick(search_space: UtxoSet, target: &CanonicalAssets) -> HashSet<Utxo>;
66}
67
68struct FirstFullMatch;
69
70impl CoinSelection for FirstFullMatch {
71    fn pick(search_space: UtxoSet, target: &CanonicalAssets) -> HashSet<Utxo> {
72        for utxo in search_space.iter() {
73            if utxo.assets.contains_total(target) {
74                return HashSet::from_iter(vec![utxo.clone()]);
75            }
76        }
77
78        HashSet::new()
79    }
80}
81
82fn find_first_excess_utxo(utxos: &HashSet<Utxo>, target: &CanonicalAssets) -> Option<Utxo> {
83    // if there is only one utxo, then we can't remove them. This is to avoid the
84    // edge case where the target is empty (eg: 0 fees gas on a testnet or L2)
85    if utxos.len() == 1 {
86        return None;
87    }
88
89    let available = utxos
90        .iter()
91        .fold(CanonicalAssets::empty(), |acc, x| acc + x.assets.clone());
92
93    let excess = available - target.clone();
94
95    if excess.is_empty_or_negative() {
96        return None;
97    }
98
99    for utxo in utxos.iter() {
100        if excess.contains_total(&utxo.assets) {
101            return Some(utxo.clone());
102        }
103    }
104
105    None
106}
107
108struct NaiveAccumulator;
109
110impl CoinSelection for NaiveAccumulator {
111    fn pick(search_space: UtxoSet, target: &CanonicalAssets) -> HashSet<Utxo> {
112        let mut matched = HashSet::new();
113        let mut pending = target.clone();
114
115        for utxo in search_space.iter() {
116            if utxo.assets.contains_some(target) {
117                matched.insert(utxo.clone());
118                pending = pending - utxo.assets.clone();
119            }
120
121            if pending.is_empty_or_negative() {
122                // break early if we already have enough
123                break;
124            }
125        }
126
127        if !pending.is_empty_or_negative() {
128            // if we didn't accumulate enough by the end of the search space,
129            // then we didn't find a match
130            return HashSet::new();
131        }
132
133        while let Some(utxo) = find_first_excess_utxo(&matched, target) {
134            matched.remove(&utxo);
135        }
136
137        matched
138    }
139}
140
141const MAX_SEARCH_SPACE_SIZE: usize = 50;
142
143struct InputSelector<'a, S: UtxoStore> {
144    store: &'a S,
145    ignore: HashSet<UtxoRef>,
146    ignore_collateral: HashSet<UtxoRef>,
147}
148
149impl<'a, S: UtxoStore> InputSelector<'a, S> {
150    pub fn new(store: &'a S) -> Self {
151        Self {
152            store,
153            ignore: HashSet::new(),
154            ignore_collateral: HashSet::new(),
155        }
156    }
157
158    fn pick_from_set(utxos: UtxoSet, criteria: &CanonicalQuery) -> UtxoSet {
159        let target = criteria
160            .min_amount
161            .clone()
162            .unwrap_or(CanonicalAssets::empty());
163
164        if criteria.support_many {
165            NaiveAccumulator::pick(utxos, &target)
166        } else {
167            FirstFullMatch::pick(utxos, &target)
168        }
169    }
170
171    pub async fn select_collateral(
172        &mut self,
173        search_space: &SearchSpace,
174        criteria: &CanonicalQuery,
175    ) -> Result<UtxoSet, Error> {
176        let refs = search_space
177            .matched
178            .clone()
179            .into_iter()
180            .filter(|x| !self.ignore_collateral.contains(x))
181            .collect();
182
183        let utxos = self.store.fetch_utxos(refs).await?;
184
185        // Collateral has the extra constraint that it must be a naked utxo
186        // so we need to filter out any utxos that are not naked.
187        //
188        // TODO: this seems like a ledger specific constraint, we should somehow
189        // abstract it away. Maybe as a different call in the UtxoStore trait.
190        let utxos = utxos
191            .into_iter()
192            .filter(|x| x.assets.is_only_naked())
193            .collect();
194
195        let matched = Self::pick_from_set(utxos, criteria);
196
197        self.ignore_collateral
198            .extend(matched.iter().map(|x| x.r#ref.clone()));
199
200        Ok(matched)
201    }
202
203    pub async fn select_input(
204        &mut self,
205        search_space: &SearchSpace,
206        criteria: &CanonicalQuery,
207    ) -> Result<UtxoSet, Error> {
208        let refs = search_space
209            .matched
210            .clone()
211            .into_iter()
212            .filter(|x| !self.ignore.contains(x))
213            .collect();
214
215        let utxos = self.store.fetch_utxos(refs).await?;
216
217        let matched = Self::pick_from_set(utxos, criteria);
218
219        self.ignore.extend(matched.iter().map(|x| x.r#ref.clone()));
220
221        Ok(matched)
222    }
223
224    pub async fn select(
225        &mut self,
226        search_space: &SearchSpace,
227        criteria: &CanonicalQuery,
228    ) -> Result<UtxoSet, Error> {
229        if criteria.collateral {
230            self.select_collateral(search_space, criteria).await
231        } else {
232            self.select_input(search_space, criteria).await
233        }
234    }
235}
236
237#[derive(Debug, Clone)]
238pub struct CanonicalQuery {
239    pub address: Option<Vec<u8>>,
240    pub min_amount: Option<CanonicalAssets>,
241    pub refs: HashSet<UtxoRef>,
242    pub support_many: bool,
243    pub collateral: bool,
244}
245
246impl std::fmt::Display for CanonicalQuery {
247    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
248        write!(f, "CanonicalQuery {{")?;
249
250        if let Some(address) = &self.address {
251            write!(f, "address: {}", hex::encode(address))?;
252        }
253
254        if let Some(min_amount) = &self.min_amount {
255            write!(f, "min_amount: {}", min_amount)?;
256        }
257
258        for (i, ref_) in self.refs.iter().enumerate() {
259            write!(f, "ref[{}]:{}#{}", i, hex::encode(&ref_.txid), ref_.index)?;
260        }
261
262        write!(f, "support_many: {:?}", self.support_many)?;
263        write!(f, "for_collateral: {:?}", self.collateral)?;
264        write!(f, "}}")
265    }
266}
267
268impl TryFrom<ir::InputQuery> for CanonicalQuery {
269    type Error = Error;
270
271    fn try_from(query: ir::InputQuery) -> Result<Self, Self::Error> {
272        let address = query
273            .address
274            .as_option()
275            .map(|x| data_or_bail!(x, bytes))
276            .transpose()?
277            .map(|x| Vec::from(x));
278
279        let min_amount = query
280            .min_amount
281            .as_option()
282            .map(|x| data_or_bail!(x, assets))
283            .transpose()?
284            .map(|x| CanonicalAssets::from(Vec::from(x)));
285
286        let refs = query
287            .r#ref
288            .as_option()
289            .map(|x| data_or_bail!(x, utxo_refs))
290            .transpose()?
291            .map(|x| HashSet::from_iter(x.iter().cloned()))
292            .unwrap_or_default();
293
294        Ok(Self {
295            address,
296            min_amount,
297            refs,
298            support_many: query.many,
299            collateral: query.collateral,
300        })
301    }
302}
303
304pub async fn resolve<T: UtxoStore>(tx: ir::Tx, utxos: &T) -> Result<ir::Tx, Error> {
305    let mut all_inputs = BTreeMap::new();
306
307    let mut selector = InputSelector::new(utxos);
308
309    for (name, query) in applying::find_queries(&tx) {
310        let query = CanonicalQuery::try_from(query)?;
311
312        let space = searching::narrow_search_space(utxos, &query, MAX_SEARCH_SPACE_SIZE).await?;
313
314        let utxos = selector.select(&space, &query).await?;
315
316        if utxos.is_empty() {
317            return Err(Error::InputNotResolved(name.to_string(), query, space));
318        }
319
320        all_inputs.insert(name, utxos);
321    }
322
323    let out = applying::apply_inputs(tx, &all_inputs)?;
324
325    Ok(out)
326}
327
328#[cfg(test)]
329mod tests {
330    use crate::mock;
331
332    use super::*;
333
334    pub fn new_input_query(
335        address: &mock::KnownAddress,
336        naked_amount: Option<u64>,
337        other_assets: Vec<(mock::KnownAsset, u64)>,
338        many: bool,
339        collateral: bool,
340    ) -> CanonicalQuery {
341        let naked_asset = naked_amount.map(|x| ir::AssetExpr {
342            policy: ir::Expression::None,
343            asset_name: ir::Expression::None,
344            amount: ir::Expression::Number(x as i128),
345        });
346
347        let other_assets: Vec<ir::AssetExpr> = other_assets
348            .into_iter()
349            .map(|(asset, amount)| ir::AssetExpr {
350                policy: ir::Expression::Bytes(asset.policy().as_slice().to_vec()),
351                asset_name: ir::Expression::Bytes(asset.name().to_vec()),
352                amount: ir::Expression::Number(amount as i128),
353            })
354            .collect();
355
356        let all_assets = naked_asset.into_iter().chain(other_assets).collect();
357
358        ir::InputQuery {
359            address: ir::Expression::Address(address.to_bytes()),
360            min_amount: ir::Expression::Assets(all_assets),
361            r#ref: ir::Expression::None,
362            many,
363            collateral,
364        }
365        .try_into()
366        .unwrap()
367    }
368
369    #[pollster::test]
370    async fn test_select_by_address() {
371        let store = mock::seed_random_memory_store(
372            |_: &mock::FuzzTxoRef, x: &mock::KnownAddress, _: u64| {
373                mock::utxo_with_random_amount(x, 4_000_000..5_000_000)
374            },
375            2..4,
376        );
377
378        let mut selector = InputSelector::new(&store);
379
380        for subject in mock::KnownAddress::everyone() {
381            let criteria = new_input_query(&subject, None, vec![], false, false);
382
383            let space = searching::narrow_search_space(&store, &criteria, MAX_SEARCH_SPACE_SIZE)
384                .await
385                .unwrap();
386
387            let utxos = selector.select(&space, &criteria).await.unwrap();
388
389            assert_eq!(utxos.len(), 1);
390
391            for utxo in utxos {
392                assert_eq!(utxo.address, subject.to_bytes());
393            }
394        }
395    }
396
397    #[pollster::test]
398    async fn test_input_query_too_broad() {
399        let store = mock::seed_random_memory_store(
400            |_: &mock::FuzzTxoRef, x: &mock::KnownAddress, _: u64| {
401                mock::utxo_with_random_amount(x, 4_000_000..5_000_000)
402            },
403            2..4,
404        );
405
406        let empty_criteria = ir::InputQuery {
407            address: ir::Expression::None,
408            min_amount: ir::Expression::None,
409            r#ref: ir::Expression::None,
410            many: false,
411            collateral: false,
412        }
413        .try_into()
414        .unwrap();
415
416        let space = searching::narrow_search_space(&store, &empty_criteria, MAX_SEARCH_SPACE_SIZE)
417            .await
418            .unwrap_err();
419
420        assert!(matches!(space, Error::InputQueryTooBroad));
421    }
422
423    #[pollster::test]
424    async fn test_select_anything() {
425        let store = mock::seed_random_memory_store(
426            |_: &mock::FuzzTxoRef, x: &mock::KnownAddress, seq: u64| {
427                if seq % 2 == 0 {
428                    mock::utxo_with_random_amount(x, 4_000_000..5_000_000)
429                } else {
430                    mock::utxo_with_random_asset(x, mock::KnownAsset::Hosky, 500..1000)
431                }
432            },
433            2..3, // exclusive range, this means always two utxos per address
434        );
435
436        let mut selector = InputSelector::new(&store);
437
438        let criteria = new_input_query(&mock::KnownAddress::Alice, None, vec![], true, false);
439
440        let space = searching::narrow_search_space(&store, &criteria, MAX_SEARCH_SPACE_SIZE)
441            .await
442            .unwrap();
443
444        let utxos = selector.select(&space, &criteria).await.unwrap();
445        assert_eq!(utxos.len(), 1);
446    }
447
448    #[pollster::test]
449    async fn test_select_by_naked_amount() {
450        let store = mock::seed_random_memory_store(
451            |_: &mock::FuzzTxoRef, x: &mock::KnownAddress, _: u64| {
452                mock::utxo_with_random_amount(x, 4_000_000..5_000_000)
453            },
454            2..4,
455        );
456
457        let mut selector = InputSelector::new(&store);
458
459        let criteria = new_input_query(
460            &mock::KnownAddress::Alice,
461            Some(6_000_000),
462            vec![],
463            false,
464            false,
465        );
466
467        let space = searching::narrow_search_space(&store, &criteria, MAX_SEARCH_SPACE_SIZE)
468            .await
469            .unwrap();
470
471        let utxos = selector.select(&space, &criteria).await.unwrap();
472        assert!(utxos.is_empty());
473
474        let criteria = new_input_query(
475            &mock::KnownAddress::Alice,
476            Some(4_000_000),
477            vec![],
478            false,
479            false,
480        );
481
482        let utxos = selector.select(&space, &criteria).await.unwrap();
483
484        let match_count = dbg!(utxos.len());
485        assert_eq!(match_count, 1);
486    }
487
488    #[pollster::test]
489    async fn test_select_by_asset_amount() {
490        let store = mock::seed_random_memory_store(
491            |_: &mock::FuzzTxoRef, x: &mock::KnownAddress, _: u64| {
492                mock::utxo_with_random_asset(x, mock::KnownAsset::Hosky, 500..1000)
493            },
494            2..4,
495        );
496
497        for address in mock::KnownAddress::everyone() {
498            // test negative case where we ask for a single utxo with more than available
499            // amount
500
501            let criteria = new_input_query(
502                &address,
503                None,
504                vec![(mock::KnownAsset::Hosky, 1001)],
505                false,
506                false,
507            );
508
509            let space = searching::narrow_search_space(&store, &criteria, MAX_SEARCH_SPACE_SIZE)
510                .await
511                .unwrap();
512
513            let mut selector = InputSelector::new(&store);
514            let utxos = selector.select(&space, &criteria).await.unwrap();
515            assert!(utxos.is_empty());
516
517            // test positive case where we ask for any number of utxo adding to the target
518            // amount
519
520            let criteria = new_input_query(
521                &address,
522                None,
523                vec![(mock::KnownAsset::Hosky, 1001)],
524                true,
525                false,
526            );
527
528            let space = searching::narrow_search_space(&store, &criteria, MAX_SEARCH_SPACE_SIZE)
529                .await
530                .unwrap();
531
532            let mut selector = InputSelector::new(&store);
533            let utxos = selector.select(&space, &criteria).await.unwrap();
534            assert!(utxos.len() > 1);
535
536            // test negative case where we ask for any number of utxo adding to the target
537            // amount that is not possible
538
539            let criteria = new_input_query(
540                &address,
541                None,
542                vec![(mock::KnownAsset::Hosky, 4001)],
543                true,
544                false,
545            );
546
547            let space = searching::narrow_search_space(&store, &criteria, MAX_SEARCH_SPACE_SIZE)
548                .await
549                .unwrap();
550
551            let mut selector = InputSelector::new(&store);
552            let utxos = selector.select(&space, &criteria).await.unwrap();
553            assert!(utxos.is_empty());
554
555            // test negative case where we ask for a different asset
556
557            let criteria = new_input_query(
558                &address,
559                None,
560                vec![(mock::KnownAsset::Snek, 500)],
561                false,
562                false,
563            );
564
565            let space = searching::narrow_search_space(&store, &criteria, MAX_SEARCH_SPACE_SIZE)
566                .await
567                .unwrap();
568
569            let mut selector = InputSelector::new(&store);
570            let utxos = selector.select(&space, &criteria).await.unwrap();
571            assert!(utxos.is_empty());
572
573            // test positive case where we ask for the present asset and amount within range
574
575            let criteria = new_input_query(
576                &address,
577                None,
578                vec![(mock::KnownAsset::Hosky, 500)],
579                false,
580                false,
581            );
582
583            let space = searching::narrow_search_space(&store, &criteria, MAX_SEARCH_SPACE_SIZE)
584                .await
585                .unwrap();
586
587            let mut selector = InputSelector::new(&store);
588            let utxos = selector.select(&space, &criteria).await.unwrap();
589            assert_eq!(utxos.len(), 1);
590        }
591    }
592
593    #[pollster::test]
594    async fn test_select_by_collateral() {
595        let store = mock::seed_random_memory_store(
596            |_: &mock::FuzzTxoRef, x: &mock::KnownAddress, sequence: u64| {
597                if sequence % 2 == 0 {
598                    mock::utxo_with_random_amount(x, 4_000_000..5_000_000)
599                } else {
600                    mock::utxo_with_random_asset(x, mock::KnownAsset::Hosky, 500..1000)
601                }
602            },
603            2..4,
604        );
605
606        let mut selector = InputSelector::new(&store);
607
608        for address in mock::KnownAddress::everyone() {
609            let criteria = new_input_query(&address, Some(1_000_000), vec![], false, true);
610
611            let space = searching::narrow_search_space(&store, &criteria, MAX_SEARCH_SPACE_SIZE)
612                .await
613                .unwrap();
614
615            let utxos = selector.select(&space, &criteria).await.unwrap();
616
617            assert_eq!(utxos.len(), 1);
618            let utxo = utxos.iter().next().unwrap();
619            assert_eq!(utxo.assets.keys().len(), 1);
620            assert_eq!(utxo.assets.keys().next().unwrap().is_naked(), true);
621        }
622    }
623
624    #[pollster::test]
625    async fn test_select_same_collateral_and_input() {
626        let store = mock::seed_random_memory_store(
627            |_: &mock::FuzzTxoRef, x: &mock::KnownAddress, _: u64| {
628                mock::utxo_with_random_amount(x, 4_000_000..5_000_000)
629            },
630            1..2, // exclusive range, this means only one utxo per address
631        );
632
633        let mut selector = InputSelector::new(&store);
634
635        for address in mock::KnownAddress::everyone() {
636            // we select the only utxo availabel as an input
637            let criteria = new_input_query(&address, Some(1_000_000), vec![], false, false);
638
639            let space = searching::narrow_search_space(&store, &criteria, MAX_SEARCH_SPACE_SIZE)
640                .await
641                .unwrap();
642
643            let utxos = selector.select(&space, &criteria).await.unwrap();
644            assert_eq!(utxos.len(), 1);
645
646            // try to select the same utxo as collateral
647            let criteria = new_input_query(&address, Some(1_000_000), vec![], false, true);
648
649            let space = searching::narrow_search_space(&store, &criteria, MAX_SEARCH_SPACE_SIZE)
650                .await
651                .unwrap();
652
653            let utxos = selector.select(&space, &criteria).await.unwrap();
654            assert_eq!(utxos.len(), 1);
655        }
656    }
657
658    #[pollster::test]
659    async fn test_resolve_ignores_selected_utxos() {
660        let store = mock::seed_random_memory_store(
661            |_: &mock::FuzzTxoRef, x: &mock::KnownAddress, seq: u64| {
662                if seq % 2 == 0 {
663                    mock::utxo_with_random_amount(x, 1_000..1_500)
664                } else {
665                    mock::utxo_with_random_amount(x, 100..150)
666                }
667            },
668            2..3, // exclusive range, this means only two utxos per address
669        );
670
671        for address in mock::KnownAddress::everyone() {
672            let mut selector = InputSelector::new(&store);
673
674            // we ask for a large amount utxo knowing that we have one of those
675            let query1 = new_input_query(&address, Some(1_000), vec![], true, false);
676
677            let space = searching::narrow_search_space(&store, &query1, MAX_SEARCH_SPACE_SIZE)
678                .await
679                .unwrap();
680
681            let utxos1 = selector.select(&space, &query1).await.unwrap();
682            assert_eq!(utxos1.len(), 1);
683
684            // we ask for a large amount utxo again knowing that we already selected one of
685            // those
686            let query2 = new_input_query(&address, Some(1_000), vec![], true, false);
687
688            let space = searching::narrow_search_space(&store, &query2, MAX_SEARCH_SPACE_SIZE)
689                .await
690                .unwrap();
691
692            let utxos2 = selector.select(&space, &query2).await.unwrap();
693            assert_eq!(utxos2.len(), 0);
694
695            // we ask for a small amount utxo knowing that we have one of those
696            let query3 = new_input_query(&address, Some(100), vec![], true, false);
697
698            let space = searching::narrow_search_space(&store, &query3, MAX_SEARCH_SPACE_SIZE)
699                .await
700                .unwrap();
701
702            let utxos3 = selector.select(&space, &query3).await.unwrap();
703            assert_eq!(utxos3.len(), 1);
704
705            // we ask for a small amount utxo again knowing that we already selected one of
706            // those
707            let query4 = new_input_query(&address, Some(100), vec![], true, false);
708
709            let space = searching::narrow_search_space(&store, &query4, MAX_SEARCH_SPACE_SIZE)
710                .await
711                .unwrap();
712
713            let utxos4 = selector.select(&space, &query4).await.unwrap();
714            assert_eq!(utxos4.len(), 0);
715
716            // we ensure that selected utxos are not the same
717            utxos1.is_disjoint(&utxos3);
718        }
719    }
720}