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
10use 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    let available = utxos
84        .iter()
85        .fold(CanonicalAssets::empty(), |acc, x| acc + x.assets.clone());
86
87    let excess = available - target.clone();
88
89    if excess.is_empty_or_negative() {
90        return None;
91    }
92
93    for utxo in utxos.iter() {
94        if excess.contains_total(&utxo.assets) {
95            return Some(utxo.clone());
96        }
97    }
98
99    None
100}
101
102struct NaiveAccumulator;
103
104impl CoinSelection for NaiveAccumulator {
105    fn pick(search_space: UtxoSet, target: &CanonicalAssets) -> HashSet<Utxo> {
106        let mut matched = HashSet::new();
107        let mut pending = target.clone();
108
109        for utxo in search_space.iter() {
110            if utxo.assets.contains_some(target) {
111                matched.insert(utxo.clone());
112                pending = pending - utxo.assets.clone();
113            }
114
115            if pending.is_empty_or_negative() {
116                // break early if we already have enough
117                break;
118            }
119        }
120
121        if !pending.is_empty_or_negative() {
122            // if we didn't accumulate enough by the end of the search space,
123            // then we didn't find a match
124            return HashSet::new();
125        }
126
127        while let Some(utxo) = find_first_excess_utxo(&matched, target) {
128            matched.remove(&utxo);
129        }
130
131        matched
132    }
133}
134
135struct CollateralMatch;
136
137impl CoinSelection for CollateralMatch {
138    fn pick(search_space: UtxoSet, target: &CanonicalAssets) -> HashSet<Utxo> {
139        for utxo in search_space.iter() {
140            let only_naked = utxo.assets.keys().all(|x| x.is_naked());
141            let has_enough = utxo.assets.contains_total(target);
142
143            if only_naked && has_enough {
144                return HashSet::from_iter(vec![utxo.clone()]);
145            }
146        }
147
148        HashSet::new()
149    }
150}
151
152const MAX_SEARCH_SPACE_SIZE: usize = 50;
153
154struct InputSelector<'a, S: UtxoStore> {
155    store: &'a S,
156    ignore: HashSet<UtxoRef>,
157}
158
159impl<'a, S: UtxoStore> InputSelector<'a, S> {
160    pub fn new(store: &'a S) -> Self {
161        Self {
162            store,
163            ignore: HashSet::new(),
164        }
165    }
166
167    pub async fn select(
168        &mut self,
169        search_space: &SearchSpace,
170        criteria: &CanonicalQuery,
171    ) -> Result<UtxoSet, Error> {
172        let refs = search_space
173            .matched
174            .clone()
175            .into_iter()
176            .filter(|x| !self.ignore.contains(x))
177            .collect();
178
179        let utxos = self.store.fetch_utxos(refs).await?;
180
181        let target = criteria
182            .min_amount
183            .clone()
184            .unwrap_or(CanonicalAssets::empty());
185
186        let matched = if criteria.collateral {
187            CollateralMatch::pick(utxos, &target)
188        } else if criteria.support_many {
189            NaiveAccumulator::pick(utxos, &target)
190        } else {
191            FirstFullMatch::pick(utxos, &target)
192        };
193
194        self.ignore.extend(matched.iter().map(|x| x.r#ref.clone()));
195
196        Ok(matched)
197    }
198}
199
200#[derive(Debug, Clone)]
201pub struct CanonicalQuery {
202    pub address: Option<Vec<u8>>,
203    pub min_amount: Option<CanonicalAssets>,
204    pub refs: HashSet<UtxoRef>,
205    pub support_many: bool,
206    pub collateral: bool,
207}
208
209impl std::fmt::Display for CanonicalQuery {
210    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
211        write!(f, "CanonicalQuery {{")?;
212
213        if let Some(address) = &self.address {
214            write!(f, "address: {}", hex::encode(address))?;
215        }
216
217        if let Some(min_amount) = &self.min_amount {
218            write!(f, "min_amount: {}", min_amount)?;
219        }
220
221        for (i, ref_) in self.refs.iter().enumerate() {
222            write!(f, "ref[{}]:{}#{}", i, hex::encode(&ref_.txid), ref_.index)?;
223        }
224
225        write!(f, "support_many: {:?}", self.support_many)?;
226        write!(f, "for_collateral: {:?}", self.collateral)?;
227        write!(f, "}}")
228    }
229}
230
231impl TryFrom<ir::InputQuery> for CanonicalQuery {
232    type Error = Error;
233
234    fn try_from(query: ir::InputQuery) -> Result<Self, Self::Error> {
235        let address = query
236            .address
237            .as_option()
238            .map(|x| data_or_bail!(x, bytes))
239            .transpose()?
240            .map(|x| Vec::from(x));
241
242        let min_amount = query
243            .min_amount
244            .as_option()
245            .map(|x| data_or_bail!(x, assets))
246            .transpose()?
247            .map(|x| CanonicalAssets::from(Vec::from(x)));
248
249        let refs = query
250            .r#ref
251            .as_option()
252            .map(|x| data_or_bail!(x, utxo_refs))
253            .transpose()?
254            .map(|x| HashSet::from_iter(x.iter().cloned()))
255            .unwrap_or_default();
256
257        Ok(Self {
258            address,
259            min_amount,
260            refs,
261            support_many: query.many,
262            collateral: query.collateral,
263        })
264    }
265}
266
267pub async fn resolve<T: UtxoStore>(tx: ir::Tx, utxos: &T) -> Result<ir::Tx, Error> {
268    let mut all_inputs = BTreeMap::new();
269
270    let mut selector = InputSelector::new(utxos);
271
272    for (name, query) in applying::find_queries(&tx) {
273        let query = CanonicalQuery::try_from(query)?;
274
275        let space = searching::narrow_search_space(utxos, &query, MAX_SEARCH_SPACE_SIZE).await?;
276
277        let utxos = selector.select(&space, &query).await?;
278
279        if utxos.is_empty() {
280            return Err(Error::InputNotResolved(name.to_string(), query, space));
281        }
282
283        all_inputs.insert(name, utxos);
284    }
285
286    let out = applying::apply_inputs(tx, &all_inputs)?;
287
288    Ok(out)
289}
290
291#[cfg(test)]
292mod tests {
293    use crate::mock;
294
295    use super::*;
296
297    pub fn new_input_query(
298        address: &mock::KnownAddress,
299        naked_amount: Option<u64>,
300        other_assets: Vec<(mock::KnownAsset, u64)>,
301        many: bool,
302        collateral: bool,
303    ) -> CanonicalQuery {
304        let naked_asset = naked_amount.map(|x| ir::AssetExpr {
305            policy: ir::Expression::None,
306            asset_name: ir::Expression::None,
307            amount: ir::Expression::Number(x as i128),
308        });
309
310        let other_assets: Vec<ir::AssetExpr> = other_assets
311            .into_iter()
312            .map(|(asset, amount)| ir::AssetExpr {
313                policy: ir::Expression::Bytes(asset.policy().as_slice().to_vec()),
314                asset_name: ir::Expression::Bytes(asset.name().to_vec()),
315                amount: ir::Expression::Number(amount as i128),
316            })
317            .collect();
318
319        let all_assets = naked_asset.into_iter().chain(other_assets).collect();
320
321        ir::InputQuery {
322            address: ir::Expression::Address(address.to_bytes()),
323            min_amount: ir::Expression::Assets(all_assets),
324            r#ref: ir::Expression::None,
325            many,
326            collateral,
327        }
328        .try_into()
329        .unwrap()
330    }
331
332    #[pollster::test]
333    async fn test_select_by_address() {
334        let store = mock::seed_random_memory_store(
335            |_: &mock::FuzzTxoRef, x: &mock::KnownAddress, _: u64| {
336                mock::utxo_with_random_amount(x, 4_000_000..5_000_000)
337            },
338            2..4,
339        );
340
341        let mut selector = InputSelector::new(&store);
342
343        for subject in mock::KnownAddress::everyone() {
344            let criteria = new_input_query(&subject, None, vec![], false, false);
345
346            let space = searching::narrow_search_space(&store, &criteria, MAX_SEARCH_SPACE_SIZE)
347                .await
348                .unwrap();
349
350            let utxos = selector.select(&space, &criteria).await.unwrap();
351
352            assert_eq!(utxos.len(), 1);
353
354            for utxo in utxos {
355                assert_eq!(utxo.address, subject.to_bytes());
356            }
357        }
358    }
359
360    #[pollster::test]
361    async fn test_select_by_naked_amount() {
362        let store = mock::seed_random_memory_store(
363            |_: &mock::FuzzTxoRef, x: &mock::KnownAddress, _: u64| {
364                mock::utxo_with_random_amount(x, 4_000_000..5_000_000)
365            },
366            2..4,
367        );
368
369        let mut selector = InputSelector::new(&store);
370
371        let criteria = new_input_query(
372            &mock::KnownAddress::Alice,
373            Some(6_000_000),
374            vec![],
375            false,
376            false,
377        );
378
379        let space = searching::narrow_search_space(&store, &criteria, MAX_SEARCH_SPACE_SIZE)
380            .await
381            .unwrap();
382
383        let utxos = selector.select(&space, &criteria).await.unwrap();
384        assert!(utxos.is_empty());
385
386        let criteria = new_input_query(
387            &mock::KnownAddress::Alice,
388            Some(4_000_000),
389            vec![],
390            false,
391            false,
392        );
393
394        let utxos = selector.select(&space, &criteria).await.unwrap();
395
396        let match_count = dbg!(utxos.len());
397        assert_eq!(match_count, 1);
398    }
399
400    #[pollster::test]
401    async fn test_select_by_asset_amount() {
402        let store = mock::seed_random_memory_store(
403            |_: &mock::FuzzTxoRef, x: &mock::KnownAddress, _: u64| {
404                mock::utxo_with_random_asset(x, mock::KnownAsset::Hosky, 500..1000)
405            },
406            2..4,
407        );
408
409        for address in mock::KnownAddress::everyone() {
410            // test negative case where we ask for a single utxo with more than available
411            // amount
412
413            let criteria = new_input_query(
414                &address,
415                None,
416                vec![(mock::KnownAsset::Hosky, 1001)],
417                false,
418                false,
419            );
420
421            let space = searching::narrow_search_space(&store, &criteria, MAX_SEARCH_SPACE_SIZE)
422                .await
423                .unwrap();
424
425            let mut selector = InputSelector::new(&store);
426            let utxos = selector.select(&space, &criteria).await.unwrap();
427            assert!(utxos.is_empty());
428
429            // test positive case where we ask for any number of utxo adding to the target
430            // amount
431
432            let criteria = new_input_query(
433                &address,
434                None,
435                vec![(mock::KnownAsset::Hosky, 1001)],
436                true,
437                false,
438            );
439
440            let space = searching::narrow_search_space(&store, &criteria, MAX_SEARCH_SPACE_SIZE)
441                .await
442                .unwrap();
443
444            let mut selector = InputSelector::new(&store);
445            let utxos = selector.select(&space, &criteria).await.unwrap();
446            assert!(utxos.len() > 1);
447
448            // test negative case where we ask for any number of utxo adding to the target
449            // amount that is not possible
450
451            let criteria = new_input_query(
452                &address,
453                None,
454                vec![(mock::KnownAsset::Hosky, 4001)],
455                true,
456                false,
457            );
458
459            let space = searching::narrow_search_space(&store, &criteria, MAX_SEARCH_SPACE_SIZE)
460                .await
461                .unwrap();
462
463            let mut selector = InputSelector::new(&store);
464            let utxos = selector.select(&space, &criteria).await.unwrap();
465            assert!(utxos.is_empty());
466
467            // test negative case where we ask for a different asset
468
469            let criteria = new_input_query(
470                &address,
471                None,
472                vec![(mock::KnownAsset::Snek, 500)],
473                false,
474                false,
475            );
476
477            let space = searching::narrow_search_space(&store, &criteria, MAX_SEARCH_SPACE_SIZE)
478                .await
479                .unwrap();
480
481            let mut selector = InputSelector::new(&store);
482            let utxos = selector.select(&space, &criteria).await.unwrap();
483            assert!(utxos.is_empty());
484
485            // test positive case where we ask for the present asset and amount within range
486
487            let criteria = new_input_query(
488                &address,
489                None,
490                vec![(mock::KnownAsset::Hosky, 500)],
491                false,
492                false,
493            );
494
495            let space = searching::narrow_search_space(&store, &criteria, MAX_SEARCH_SPACE_SIZE)
496                .await
497                .unwrap();
498
499            let mut selector = InputSelector::new(&store);
500            let utxos = selector.select(&space, &criteria).await.unwrap();
501            assert_eq!(utxos.len(), 1);
502        }
503    }
504
505    #[pollster::test]
506    async fn test_select_by_collateral() {
507        let store = mock::seed_random_memory_store(
508            |_: &mock::FuzzTxoRef, x: &mock::KnownAddress, sequence: u64| {
509                if sequence % 2 == 0 {
510                    mock::utxo_with_random_amount(x, 4_000_000..5_000_000)
511                } else {
512                    mock::utxo_with_random_asset(x, mock::KnownAsset::Hosky, 500..1000)
513                }
514            },
515            2..4,
516        );
517
518        let mut selector = InputSelector::new(&store);
519
520        for address in mock::KnownAddress::everyone() {
521            let criteria = new_input_query(&address, Some(1_000_000), vec![], false, true);
522
523            let space = searching::narrow_search_space(&store, &criteria, MAX_SEARCH_SPACE_SIZE)
524                .await
525                .unwrap();
526
527            let utxos = selector.select(&space, &criteria).await.unwrap();
528
529            assert_eq!(utxos.len(), 1);
530            let utxo = utxos.iter().next().unwrap();
531            assert_eq!(utxo.assets.keys().len(), 1);
532            assert_eq!(utxo.assets.keys().next().unwrap().is_naked(), true);
533        }
534    }
535
536    #[pollster::test]
537    async fn test_resolve_ignores_selected_utxos() {
538        let store = mock::seed_random_memory_store(
539            |_: &mock::FuzzTxoRef, x: &mock::KnownAddress, seq: u64| {
540                if seq % 2 == 0 {
541                    mock::utxo_with_random_amount(x, 1_000..1_500)
542                } else {
543                    mock::utxo_with_random_amount(x, 100..150)
544                }
545            },
546            2..3, // exclusive range, this means only two utxos per address
547        );
548
549        for address in mock::KnownAddress::everyone() {
550            let mut selector = InputSelector::new(&store);
551
552            // we ask for a large amount utxo knowing that we have one of those
553            let query1 = new_input_query(&address, Some(1_000), vec![], true, false);
554
555            let space = searching::narrow_search_space(&store, &query1, MAX_SEARCH_SPACE_SIZE)
556                .await
557                .unwrap();
558
559            let utxos1 = selector.select(&space, &query1).await.unwrap();
560            assert_eq!(utxos1.len(), 1);
561
562            // we ask for a large amount utxo again knowing that we already selected one of
563            // those
564            let query2 = new_input_query(&address, Some(1_000), vec![], true, false);
565
566            let space = searching::narrow_search_space(&store, &query2, MAX_SEARCH_SPACE_SIZE)
567                .await
568                .unwrap();
569
570            let utxos2 = selector.select(&space, &query2).await.unwrap();
571            assert_eq!(utxos2.len(), 0);
572
573            // we ask for a small amount utxo knowing that we have one of those
574            let query3 = new_input_query(&address, Some(100), vec![], true, false);
575
576            let space = searching::narrow_search_space(&store, &query3, MAX_SEARCH_SPACE_SIZE)
577                .await
578                .unwrap();
579
580            let utxos3 = selector.select(&space, &query3).await.unwrap();
581            assert_eq!(utxos3.len(), 1);
582
583            // we ask for a small amount utxo again knowing that we already selected one of
584            // those
585            let query4 = new_input_query(&address, Some(100), vec![], true, false);
586
587            let space = searching::narrow_search_space(&store, &query4, MAX_SEARCH_SPACE_SIZE)
588                .await
589                .unwrap();
590
591            let utxos4 = selector.select(&space, &query4).await.unwrap();
592            assert_eq!(utxos4.len(), 0);
593
594            // we ensure that selected utxos are not the same
595            utxos1.is_disjoint(&utxos3);
596        }
597    }
598}