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}")]
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(&pending) {
117                matched.insert(utxo.clone());
118                let to_include = utxo.assets.clone();
119                pending = pending - to_include;
120            }
121
122            if pending.is_empty_or_negative() {
123                // break early if we already have enough
124                break;
125            }
126        }
127
128        if !pending.is_empty_or_negative() {
129            // if we didn't accumulate enough by the end of the search space,
130            // then we didn't find a match
131            return HashSet::new();
132        }
133
134        while let Some(utxo) = find_first_excess_utxo(&matched, target) {
135            matched.remove(&utxo);
136        }
137
138        matched
139    }
140}
141
142const MAX_SEARCH_SPACE_SIZE: usize = 50;
143
144struct InputSelector<'a, S: UtxoStore> {
145    store: &'a S,
146    ignore: HashSet<UtxoRef>,
147    ignore_collateral: HashSet<UtxoRef>,
148}
149
150impl<'a, S: UtxoStore> InputSelector<'a, S> {
151    pub fn new(store: &'a S) -> Self {
152        Self {
153            store,
154            ignore: HashSet::new(),
155            ignore_collateral: HashSet::new(),
156        }
157    }
158
159    fn pick_from_set(utxos: UtxoSet, criteria: &CanonicalQuery) -> UtxoSet {
160        let target = criteria
161            .min_amount
162            .clone()
163            .unwrap_or(CanonicalAssets::empty());
164
165        if criteria.support_many {
166            NaiveAccumulator::pick(utxos, &target)
167        } else {
168            FirstFullMatch::pick(utxos, &target)
169        }
170    }
171
172    pub async fn select_collateral(
173        &mut self,
174        search_space: &SearchSpace,
175        criteria: &CanonicalQuery,
176    ) -> Result<UtxoSet, Error> {
177        let refs = search_space
178            .take(Some(MAX_SEARCH_SPACE_SIZE))
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            .take(Some(MAX_SEARCH_SPACE_SIZE))
210            .into_iter()
211            .filter(|x| !self.ignore.contains(x))
212            .collect();
213
214        let utxos = self.store.fetch_utxos(refs).await?;
215
216        let matched = Self::pick_from_set(utxos, criteria);
217
218        self.ignore.extend(matched.iter().map(|x| x.r#ref.clone()));
219
220        Ok(matched)
221    }
222
223    pub async fn select(
224        &mut self,
225        search_space: &SearchSpace,
226        criteria: &CanonicalQuery,
227    ) -> Result<UtxoSet, Error> {
228        if criteria.collateral {
229            self.select_collateral(search_space, criteria).await
230        } else {
231            self.select_input(search_space, criteria).await
232        }
233    }
234}
235
236#[derive(Debug, Clone)]
237pub struct CanonicalQuery {
238    pub address: Option<Vec<u8>>,
239    pub min_amount: Option<CanonicalAssets>,
240    pub refs: HashSet<UtxoRef>,
241    pub support_many: bool,
242    pub collateral: bool,
243}
244
245impl std::fmt::Display for CanonicalQuery {
246    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
247        write!(f, "CanonicalQuery {{")?;
248
249        if let Some(address) = &self.address {
250            write!(f, "address: {}", hex::encode(address))?;
251        }
252
253        if let Some(min_amount) = &self.min_amount {
254            write!(f, "min_amount: {}", min_amount)?;
255        }
256
257        for (i, ref_) in self.refs.iter().enumerate() {
258            write!(f, "ref[{}]:{}#{}", i, hex::encode(&ref_.txid), ref_.index)?;
259        }
260
261        write!(f, "support_many: {:?}", self.support_many)?;
262        write!(f, "for_collateral: {:?}", self.collateral)?;
263        write!(f, "}}")
264    }
265}
266
267impl TryFrom<ir::InputQuery> for CanonicalQuery {
268    type Error = Error;
269
270    fn try_from(query: ir::InputQuery) -> Result<Self, Self::Error> {
271        let address = query
272            .address
273            .as_option()
274            .map(|x| data_or_bail!(x, bytes))
275            .transpose()?
276            .map(|x| Vec::from(x));
277
278        let min_amount = query
279            .min_amount
280            .as_option()
281            .map(|x| data_or_bail!(x, assets))
282            .transpose()?
283            .map(|x| CanonicalAssets::from(Vec::from(x)));
284
285        let refs = query
286            .r#ref
287            .as_option()
288            .map(|x| data_or_bail!(x, utxo_refs))
289            .transpose()?
290            .map(|x| HashSet::from_iter(x.iter().cloned()))
291            .unwrap_or_default();
292
293        Ok(Self {
294            address,
295            min_amount,
296            refs,
297            support_many: query.many,
298            collateral: query.collateral,
299        })
300    }
301}
302
303pub async fn resolve<T: UtxoStore>(tx: ir::Tx, utxos: &T) -> Result<ir::Tx, Error> {
304    let mut all_inputs = BTreeMap::new();
305
306    let mut selector = InputSelector::new(utxos);
307
308    for (name, query) in applying::find_queries(&tx) {
309        let query = CanonicalQuery::try_from(query)?;
310
311        let space = searching::narrow_search_space(utxos, &query).await?;
312
313        let utxos = selector.select(&space, &query).await?;
314
315        if utxos.is_empty() {
316            return Err(Error::InputNotResolved(name.to_string(), query, space));
317        }
318
319        all_inputs.insert(name, utxos);
320    }
321
322    let out = applying::apply_inputs(tx, &all_inputs)?;
323
324    Ok(out)
325}
326
327#[cfg(test)]
328mod tests {
329    use crate::mock;
330
331    use super::*;
332
333    pub fn new_input_query(
334        address: &mock::KnownAddress,
335        naked_amount: Option<u64>,
336        other_assets: Vec<(mock::KnownAsset, u64)>,
337        many: bool,
338        collateral: bool,
339    ) -> CanonicalQuery {
340        let naked_asset = naked_amount.map(|x| ir::AssetExpr {
341            policy: ir::Expression::None,
342            asset_name: ir::Expression::None,
343            amount: ir::Expression::Number(x as i128),
344        });
345
346        let other_assets: Vec<ir::AssetExpr> = other_assets
347            .into_iter()
348            .map(|(asset, amount)| ir::AssetExpr {
349                policy: ir::Expression::Bytes(asset.policy().as_slice().to_vec()),
350                asset_name: ir::Expression::Bytes(asset.name().to_vec()),
351                amount: ir::Expression::Number(amount as i128),
352            })
353            .collect();
354
355        let all_assets = naked_asset.into_iter().chain(other_assets).collect();
356
357        ir::InputQuery {
358            address: ir::Expression::Address(address.to_bytes()),
359            min_amount: ir::Expression::Assets(all_assets),
360            r#ref: ir::Expression::None,
361            many,
362            collateral,
363        }
364        .try_into()
365        .unwrap()
366    }
367
368    #[pollster::test]
369    async fn test_select_by_address() {
370        let store = mock::seed_random_memory_store(
371            |_: &mock::FuzzTxoRef, x: &mock::KnownAddress, _: u64| {
372                mock::utxo_with_random_amount(x, 4_000_000..5_000_000)
373            },
374            2..4,
375        );
376
377        let mut selector = InputSelector::new(&store);
378
379        for subject in mock::KnownAddress::everyone() {
380            let criteria = new_input_query(&subject, None, vec![], false, false);
381
382            let space = searching::narrow_search_space(&store, &criteria)
383                .await
384                .unwrap();
385
386            let utxos = selector.select(&space, &criteria).await.unwrap();
387
388            assert_eq!(utxos.len(), 1);
389
390            for utxo in utxos {
391                assert_eq!(utxo.address, subject.to_bytes());
392            }
393        }
394    }
395
396    #[pollster::test]
397    async fn test_input_query_too_broad() {
398        let store = mock::seed_random_memory_store(
399            |_: &mock::FuzzTxoRef, x: &mock::KnownAddress, _: u64| {
400                mock::utxo_with_random_amount(x, 4_000_000..5_000_000)
401            },
402            2..4,
403        );
404
405        let empty_criteria = ir::InputQuery {
406            address: ir::Expression::None,
407            min_amount: ir::Expression::None,
408            r#ref: ir::Expression::None,
409            many: false,
410            collateral: false,
411        }
412        .try_into()
413        .unwrap();
414
415        let space = searching::narrow_search_space(&store, &empty_criteria)
416            .await
417            .unwrap_err();
418
419        assert!(matches!(space, Error::InputQueryTooBroad));
420    }
421
422    #[pollster::test]
423    async fn test_select_anything() {
424        let store = mock::seed_random_memory_store(
425            |_: &mock::FuzzTxoRef, x: &mock::KnownAddress, seq: u64| {
426                if seq % 2 == 0 {
427                    mock::utxo_with_random_amount(x, 4_000_000..5_000_000)
428                } else {
429                    mock::utxo_with_random_asset(x, mock::KnownAsset::Hosky, 500..1000)
430                }
431            },
432            2..3, // exclusive range, this means always two utxos per address
433        );
434
435        let mut selector = InputSelector::new(&store);
436
437        let criteria = new_input_query(&mock::KnownAddress::Alice, None, vec![], true, false);
438
439        let space = searching::narrow_search_space(&store, &criteria)
440            .await
441            .unwrap();
442
443        let utxos = selector.select(&space, &criteria).await.unwrap();
444        assert_eq!(utxos.len(), 1);
445    }
446
447    #[pollster::test]
448    async fn test_select_by_naked_amount() {
449        let store = mock::seed_random_memory_store(
450            |_: &mock::FuzzTxoRef, x: &mock::KnownAddress, _: u64| {
451                mock::utxo_with_random_amount(x, 4_000_000..5_000_000)
452            },
453            2..4,
454        );
455
456        let mut selector = InputSelector::new(&store);
457
458        let criteria = new_input_query(
459            &mock::KnownAddress::Alice,
460            Some(6_000_000),
461            vec![],
462            false,
463            false,
464        );
465
466        let space = searching::narrow_search_space(&store, &criteria)
467            .await
468            .unwrap();
469
470        let utxos = selector.select(&space, &criteria).await.unwrap();
471        assert!(utxos.is_empty());
472
473        let criteria = new_input_query(
474            &mock::KnownAddress::Alice,
475            Some(4_000_000),
476            vec![],
477            false,
478            false,
479        );
480
481        let utxos = selector.select(&space, &criteria).await.unwrap();
482
483        let match_count = dbg!(utxos.len());
484        assert_eq!(match_count, 1);
485    }
486
487    #[pollster::test]
488    async fn test_select_by_asset_amount() {
489        let store = mock::seed_random_memory_store(
490            |_: &mock::FuzzTxoRef, x: &mock::KnownAddress, _: u64| {
491                mock::utxo_with_random_asset(x, mock::KnownAsset::Hosky, 500..1000)
492            },
493            2..4,
494        );
495
496        for address in mock::KnownAddress::everyone() {
497            // test negative case where we ask for a single utxo with more than available
498            // amount
499
500            let criteria = new_input_query(
501                &address,
502                None,
503                vec![(mock::KnownAsset::Hosky, 1001)],
504                false,
505                false,
506            );
507
508            let space = searching::narrow_search_space(&store, &criteria)
509                .await
510                .unwrap();
511
512            let mut selector = InputSelector::new(&store);
513            let utxos = selector.select(&space, &criteria).await.unwrap();
514            assert!(utxos.is_empty());
515
516            // test positive case where we ask for any number of utxo adding to the target
517            // amount
518
519            let criteria = new_input_query(
520                &address,
521                None,
522                vec![(mock::KnownAsset::Hosky, 1001)],
523                true,
524                false,
525            );
526
527            let space = searching::narrow_search_space(&store, &criteria)
528                .await
529                .unwrap();
530
531            let mut selector = InputSelector::new(&store);
532            let utxos = selector.select(&space, &criteria).await.unwrap();
533            assert!(utxos.len() > 1);
534
535            // test negative case where we ask for any number of utxo adding to the target
536            // amount that is not possible
537
538            let criteria = new_input_query(
539                &address,
540                None,
541                vec![(mock::KnownAsset::Hosky, 4001)],
542                true,
543                false,
544            );
545
546            let space = searching::narrow_search_space(&store, &criteria)
547                .await
548                .unwrap();
549
550            let mut selector = InputSelector::new(&store);
551            let utxos = selector.select(&space, &criteria).await.unwrap();
552            assert!(utxos.is_empty());
553
554            // test negative case where we ask for a different asset
555
556            let criteria = new_input_query(
557                &address,
558                None,
559                vec![(mock::KnownAsset::Snek, 500)],
560                false,
561                false,
562            );
563
564            let space = searching::narrow_search_space(&store, &criteria)
565                .await
566                .unwrap();
567
568            let mut selector = InputSelector::new(&store);
569            let utxos = selector.select(&space, &criteria).await.unwrap();
570            assert!(utxos.is_empty());
571
572            // test positive case where we ask for the present asset and amount within range
573
574            let criteria = new_input_query(
575                &address,
576                None,
577                vec![(mock::KnownAsset::Hosky, 500)],
578                false,
579                false,
580            );
581
582            let space = searching::narrow_search_space(&store, &criteria)
583                .await
584                .unwrap();
585
586            let mut selector = InputSelector::new(&store);
587            let utxos = selector.select(&space, &criteria).await.unwrap();
588            assert_eq!(utxos.len(), 1);
589        }
590    }
591
592    #[pollster::test]
593    async fn test_select_by_collateral() {
594        let store = mock::seed_random_memory_store(
595            |_: &mock::FuzzTxoRef, x: &mock::KnownAddress, sequence: u64| {
596                if sequence % 2 == 0 {
597                    mock::utxo_with_random_amount(x, 4_000_000..5_000_000)
598                } else {
599                    mock::utxo_with_random_asset(x, mock::KnownAsset::Hosky, 500..1000)
600                }
601            },
602            2..4,
603        );
604
605        let mut selector = InputSelector::new(&store);
606
607        for address in mock::KnownAddress::everyone() {
608            let criteria = new_input_query(&address, Some(1_000_000), vec![], false, true);
609
610            let space = searching::narrow_search_space(&store, &criteria)
611                .await
612                .unwrap();
613
614            let utxos = selector.select(&space, &criteria).await.unwrap();
615
616            assert_eq!(utxos.len(), 1);
617            let utxo = utxos.iter().next().unwrap();
618            assert_eq!(utxo.assets.keys().len(), 1);
619            assert_eq!(utxo.assets.keys().next().unwrap().is_naked(), true);
620        }
621    }
622
623    #[pollster::test]
624    async fn test_select_same_collateral_and_input() {
625        let store = mock::seed_random_memory_store(
626            |_: &mock::FuzzTxoRef, x: &mock::KnownAddress, _: u64| {
627                mock::utxo_with_random_amount(x, 4_000_000..5_000_000)
628            },
629            1..2, // exclusive range, this means only one utxo per address
630        );
631
632        let mut selector = InputSelector::new(&store);
633
634        for address in mock::KnownAddress::everyone() {
635            // we select the only utxo availabel as an input
636            let criteria = new_input_query(&address, Some(1_000_000), vec![], false, false);
637
638            let space = searching::narrow_search_space(&store, &criteria)
639                .await
640                .unwrap();
641
642            let utxos = selector.select(&space, &criteria).await.unwrap();
643            assert_eq!(utxos.len(), 1);
644
645            // try to select the same utxo as collateral
646            let criteria = new_input_query(&address, Some(1_000_000), vec![], false, true);
647
648            let space = searching::narrow_search_space(&store, &criteria)
649                .await
650                .unwrap();
651
652            let utxos = selector.select(&space, &criteria).await.unwrap();
653            assert_eq!(utxos.len(), 1);
654        }
655    }
656
657    #[pollster::test]
658    async fn test_resolve_ignores_selected_utxos() {
659        let store = mock::seed_random_memory_store(
660            |_: &mock::FuzzTxoRef, x: &mock::KnownAddress, seq: u64| {
661                if seq % 2 == 0 {
662                    mock::utxo_with_random_amount(x, 1_000..1_500)
663                } else {
664                    mock::utxo_with_random_amount(x, 100..150)
665                }
666            },
667            2..3, // exclusive range, this means only two utxos per address
668        );
669
670        for address in mock::KnownAddress::everyone() {
671            let mut selector = InputSelector::new(&store);
672
673            // we ask for a large amount utxo knowing that we have one of those
674            let query1 = new_input_query(&address, Some(1_000), vec![], true, false);
675
676            let space = searching::narrow_search_space(&store, &query1)
677                .await
678                .unwrap();
679
680            let utxos1 = selector.select(&space, &query1).await.unwrap();
681            assert_eq!(utxos1.len(), 1);
682
683            // we ask for a large amount utxo again knowing that we already selected one of
684            // those
685            let query2 = new_input_query(&address, Some(1_000), vec![], true, false);
686
687            let space = searching::narrow_search_space(&store, &query2)
688                .await
689                .unwrap();
690
691            let utxos2 = selector.select(&space, &query2).await.unwrap();
692            assert_eq!(utxos2.len(), 0);
693
694            // we ask for a small amount utxo knowing that we have one of those
695            let query3 = new_input_query(&address, Some(100), vec![], true, false);
696
697            let space = searching::narrow_search_space(&store, &query3)
698                .await
699                .unwrap();
700
701            let utxos3 = selector.select(&space, &query3).await.unwrap();
702            assert_eq!(utxos3.len(), 1);
703
704            // we ask for a small amount utxo again knowing that we already selected one of
705            // those
706            let query4 = new_input_query(&address, Some(100), vec![], true, false);
707
708            let space = searching::narrow_search_space(&store, &query4)
709                .await
710                .unwrap();
711
712            let utxos4 = selector.select(&space, &query4).await.unwrap();
713            assert_eq!(utxos4.len(), 0);
714
715            // we ensure that selected utxos are not the same
716            utxos1.is_disjoint(&utxos3);
717        }
718    }
719
720    #[pollster::test]
721    async fn test_select_by_naked_and_asset_amount() {
722        let store = mock::seed_random_memory_store(
723            |_: &mock::FuzzTxoRef, x: &mock::KnownAddress, sequence: u64| {
724                if sequence % 2 == 0 {
725                    mock::utxo_with_random_amount(x, 4_000_000..5_000_000)
726                } else {
727                    mock::utxo_with_random_asset(x, mock::KnownAsset::Hosky, 500..1000)
728                }
729            },
730            2..3,
731        );
732
733        let mut selector = InputSelector::new(&store);
734
735        let criteria = new_input_query(
736            &mock::KnownAddress::Alice,
737            Some(4_000_000),
738            vec![(mock::KnownAsset::Hosky, 500)],
739            true,
740            false,
741        );
742
743        let space = searching::narrow_search_space(&store, &criteria)
744            .await
745            .unwrap();
746
747        let utxos = selector.select(&space, &criteria).await.unwrap();
748
749        assert!(utxos.len() == 2);
750    }
751}