tx3_resolver/
inputs.rs

1//! Tx input selection algorithms
2
3use std::collections::{BTreeMap, HashSet};
4use tx3_lang::{
5    applying,
6    backend::{UtxoPattern, UtxoStore},
7    ir, CanonicalAssets, Utxo, UtxoRef, UtxoSet,
8};
9
10use crate::Error;
11
12macro_rules! data_or_bail {
13    ($expr:expr, bytes) => {
14        $expr
15            .as_bytes()
16            .ok_or(Error::ExpectedData("bytes".to_string(), $expr.clone()))?
17    };
18
19    ($expr:expr, number) => {
20        $expr
21            .as_number()
22            .ok_or(Error::ExpectedData("number".to_string(), $expr.clone()))?
23    };
24
25    ($expr:expr, assets) => {
26        $expr
27            .as_assets()
28            .ok_or(Error::ExpectedData("assets".to_string(), $expr.clone()))?
29    };
30
31    ($expr:expr, utxo_refs) => {
32        $expr
33            .as_utxo_refs()
34            .ok_or(Error::ExpectedData("utxo refs".to_string(), $expr.clone()))?
35    };
36}
37
38enum Subset {
39    All,
40    Specific(HashSet<UtxoRef>),
41}
42
43impl Subset {
44    #[allow(dead_code)]
45    fn union(a: Self, b: Self) -> Self {
46        match (a, b) {
47            (Self::All, _) => Self::All,
48            (_, Self::All) => Self::All,
49            (Self::Specific(s1), Self::Specific(s2)) => {
50                Self::Specific(s1.union(&s2).cloned().collect())
51            }
52        }
53    }
54
55    fn intersection(a: Self, b: Self) -> Self {
56        match (a, b) {
57            (Self::All, x) => x,
58            (x, Self::All) => x,
59            (Self::Specific(s1), Self::Specific(s2)) => {
60                Self::Specific(s1.intersection(&s2).cloned().collect())
61            }
62        }
63    }
64
65    fn intersection_of_all<const N: usize>(subsets: [Self; N]) -> Self {
66        let mut result = Subset::All;
67
68        for subset in subsets {
69            result = Self::intersection(result, subset);
70        }
71
72        result
73    }
74
75    fn is_empty(&self) -> bool {
76        match self {
77            Self::All => false,
78            Self::Specific(s) => s.is_empty(),
79        }
80    }
81}
82
83impl From<HashSet<UtxoRef>> for Subset {
84    fn from(value: HashSet<UtxoRef>) -> Self {
85        Self::Specific(value)
86    }
87}
88
89pub trait CoinSelection {
90    fn pick(search_space: UtxoSet, target: &CanonicalAssets) -> HashSet<Utxo>;
91}
92
93struct FirstFullMatch;
94
95impl CoinSelection for FirstFullMatch {
96    fn pick(search_space: UtxoSet, target: &CanonicalAssets) -> HashSet<Utxo> {
97        for utxo in search_space.iter() {
98            if utxo.assets.contains_total(target) {
99                return HashSet::from_iter(vec![utxo.clone()]);
100            }
101        }
102
103        HashSet::new()
104    }
105}
106
107struct NaiveAccumulator;
108
109impl CoinSelection for NaiveAccumulator {
110    fn pick(search_space: UtxoSet, target: &CanonicalAssets) -> HashSet<Utxo> {
111        let mut matched = HashSet::new();
112        let mut required = target.clone();
113
114        for utxo in search_space.iter() {
115            if utxo.assets.contains_some(target) {
116                matched.insert(utxo.clone());
117                required = required - utxo.assets.clone();
118            }
119
120            if required.is_empty_or_negative() {
121                return matched;
122            }
123        }
124
125        // if we didn't accumulate enough by the end of the search space,
126        // then we didn't find a match
127        HashSet::new()
128    }
129}
130
131struct CollateralMatch;
132
133impl CoinSelection for CollateralMatch {
134    fn pick(search_space: UtxoSet, target: &CanonicalAssets) -> HashSet<Utxo> {
135        for utxo in search_space.iter() {
136            let only_naked = utxo.assets.keys().all(|x| x.is_naked());
137            let has_enough = utxo.assets.contains_total(target);
138
139            if only_naked && has_enough {
140                return HashSet::from_iter(vec![utxo.clone()]);
141            }
142        }
143
144        HashSet::new()
145    }
146}
147
148const MAX_SEARCH_SPACE_SIZE: usize = 50;
149
150struct InputSelector<'a, S: UtxoStore> {
151    store: &'a S,
152}
153
154impl<'a, S: UtxoStore> InputSelector<'a, S> {
155    pub fn new(store: &'a S) -> Self {
156        Self { store }
157    }
158
159    async fn narrow_by_address(&self, expr: &ir::Expression) -> Result<Subset, Error> {
160        let address = data_or_bail!(expr, bytes);
161
162        let utxos = self
163            .store
164            .narrow_refs(UtxoPattern::by_address(address))
165            .await?;
166
167        Ok(Subset::Specific(utxos.into_iter().collect()))
168    }
169
170    async fn narrow_by_asset_presence(&self, expr: &ir::AssetExpr) -> Result<Subset, Error> {
171        let amount = data_or_bail!(&expr.amount, number);
172
173        // skip filtering if required amount is 0 since it's not adding any constraints
174        if amount == 0 {
175            return Ok(Subset::All);
176        }
177
178        // skip filtering lovelace since it's not an custom asset
179        if expr.policy.is_none() {
180            return Ok(Subset::All);
181        }
182
183        let policy_bytes = data_or_bail!(&expr.policy, bytes);
184        let name_bytes = data_or_bail!(&expr.asset_name, bytes);
185
186        let utxos = self
187            .store
188            .narrow_refs(UtxoPattern::by_asset(policy_bytes, name_bytes))
189            .await?;
190
191        Ok(Subset::Specific(utxos.into_iter().collect()))
192    }
193
194    async fn narrow_by_multi_asset_presence(&self, expr: &ir::Expression) -> Result<Subset, Error> {
195        let assets = data_or_bail!(expr, assets);
196
197        let mut matches = Subset::All;
198
199        for asset in assets {
200            let next = self.narrow_by_asset_presence(asset).await?;
201            matches = Subset::intersection(matches, next);
202        }
203
204        Ok(matches)
205    }
206
207    fn narrow_by_ref(&self, expr: &ir::Expression) -> Result<Subset, Error> {
208        let refs = data_or_bail!(expr, utxo_refs);
209
210        let refs = HashSet::from_iter(refs.iter().cloned());
211
212        Ok(Subset::Specific(refs))
213    }
214
215    async fn narrow_search_space(&self, criteria: &ir::InputQuery) -> Result<Subset, Error> {
216        let matching_address = if let Some(address) = &criteria.address.as_option() {
217            self.narrow_by_address(address).await?
218        } else {
219            Subset::All
220        };
221
222        if matching_address.is_empty() {
223            // TODO: track this as part of a detailed diagnostic
224        }
225
226        let matching_assets = if let Some(min_amount) = &criteria.min_amount.as_option() {
227            self.narrow_by_multi_asset_presence(min_amount).await?
228        } else {
229            Subset::All
230        };
231
232        if matching_assets.is_empty() {
233            // TODO: track this as part of a detailed diagnostic
234        }
235
236        let matching_refs = if let Some(refs) = &criteria.r#ref.as_option() {
237            self.narrow_by_ref(refs)?
238        } else {
239            Subset::All
240        };
241
242        if matching_refs.is_empty() {
243            // TODO: track this as part of a detailed diagnostic
244        }
245
246        Ok(Subset::intersection_of_all([
247            matching_address,
248            matching_assets,
249            matching_refs,
250        ]))
251    }
252
253    pub async fn select(&self, criteria: &ir::InputQuery) -> Result<UtxoSet, Error> {
254        let search_space = self.narrow_search_space(criteria).await?;
255
256        let refs = match search_space {
257            Subset::Specific(refs) if refs.len() <= MAX_SEARCH_SPACE_SIZE => refs,
258            Subset::Specific(_) => return Err(Error::InputQueryTooBroad),
259            Subset::All => return Err(Error::InputQueryTooBroad),
260        };
261
262        let refs = refs.into_iter().collect();
263
264        let utxos = self.store.fetch_utxos(refs).await?;
265
266        let target = data_or_bail!(&criteria.min_amount, assets);
267        let target = CanonicalAssets::from(Vec::from(target));
268
269        let matched = if criteria.collateral {
270            CollateralMatch::pick(utxos, &target)
271        } else if criteria.many {
272            NaiveAccumulator::pick(utxos, &target)
273        } else {
274            FirstFullMatch::pick(utxos, &target)
275        };
276
277        Ok(matched)
278    }
279}
280
281pub async fn resolve<T: UtxoStore>(tx: ir::Tx, utxos: &T) -> Result<ir::Tx, Error> {
282    let mut all_inputs = BTreeMap::new();
283
284    for (name, query) in applying::find_queries(&tx) {
285        let selector = InputSelector::new(utxos);
286
287        let utxos = selector.select(&query).await?;
288
289        if utxos.is_empty() {
290            return Err(Error::InputNotResolved(name, query));
291        }
292
293        all_inputs.insert(name, utxos);
294    }
295
296    let out = applying::apply_inputs(tx, &all_inputs)?;
297
298    Ok(out)
299}
300
301#[cfg(test)]
302mod tests {
303    use crate::mock;
304
305    use super::*;
306
307    pub fn new_input_query(
308        address: &mock::KnownAddress,
309        naked_amount: Option<u64>,
310        other_assets: Vec<(mock::KnownAsset, u64)>,
311        many: bool,
312        collateral: bool,
313    ) -> ir::InputQuery {
314        let naked_asset = naked_amount.map(|x| ir::AssetExpr {
315            policy: ir::Expression::None,
316            asset_name: ir::Expression::None,
317            amount: ir::Expression::Number(x as i128),
318        });
319
320        let other_assets: Vec<ir::AssetExpr> = other_assets
321            .into_iter()
322            .map(|(asset, amount)| ir::AssetExpr {
323                policy: ir::Expression::Bytes(asset.policy().as_slice().to_vec()),
324                asset_name: ir::Expression::Bytes(asset.name().to_vec()),
325                amount: ir::Expression::Number(amount as i128),
326            })
327            .collect();
328
329        let all_assets = naked_asset.into_iter().chain(other_assets).collect();
330
331        ir::InputQuery {
332            address: ir::Expression::Address(address.to_bytes()),
333            min_amount: ir::Expression::Assets(all_assets),
334            r#ref: ir::Expression::None,
335            many,
336            collateral,
337        }
338    }
339
340    #[pollster::test]
341    async fn test_select_by_address() {
342        let store = mock::seed_random_memory_store(
343            |_: &mock::FuzzTxoRef, x: &mock::KnownAddress, _: u64| {
344                mock::utxo_with_random_amount(x, 4_000_000..5_000_000)
345            },
346        );
347
348        let selector = InputSelector::new(&store);
349
350        for subject in mock::KnownAddress::everyone() {
351            let criteria = new_input_query(&subject, None, vec![], false, false);
352
353            let utxos = selector.select(&criteria).await.unwrap();
354
355            assert_eq!(utxos.len(), 1);
356
357            for utxo in utxos {
358                assert_eq!(utxo.address, subject.to_bytes());
359            }
360        }
361    }
362
363    #[pollster::test]
364    async fn test_select_by_naked_amount() {
365        let store = mock::seed_random_memory_store(
366            |_: &mock::FuzzTxoRef, x: &mock::KnownAddress, _: u64| {
367                mock::utxo_with_random_amount(x, 4_000_000..5_000_000)
368            },
369        );
370
371        let selector = InputSelector::new(&store);
372
373        let criteria = new_input_query(
374            &mock::KnownAddress::Alice,
375            Some(6_000_000),
376            vec![],
377            false,
378            false,
379        );
380
381        let utxos = selector.select(&criteria).await.unwrap();
382        assert!(utxos.is_empty());
383
384        let criteria = new_input_query(
385            &mock::KnownAddress::Alice,
386            Some(4_000_000),
387            vec![],
388            false,
389            false,
390        );
391
392        let utxos = selector.select(&criteria).await.unwrap();
393
394        let match_count = dbg!(utxos.len());
395        assert_eq!(match_count, 1);
396    }
397
398    #[pollster::test]
399    async fn test_select_by_asset_amount() {
400        let store = mock::seed_random_memory_store(
401            |_: &mock::FuzzTxoRef, x: &mock::KnownAddress, _: u64| {
402                mock::utxo_with_random_asset(x, mock::KnownAsset::Hosky, 500..1000)
403            },
404        );
405
406        let selector = InputSelector::new(&store);
407
408        for address in mock::KnownAddress::everyone() {
409            // test negative case where we ask for a single utxo with more than available
410            // amount
411
412            let criteria = new_input_query(
413                &address,
414                None,
415                vec![(mock::KnownAsset::Hosky, 1001)],
416                false,
417                false,
418            );
419
420            let utxos = selector.select(&criteria).await.unwrap();
421            assert!(utxos.is_empty());
422
423            // test positive case where we ask for any number of utxo adding to the target
424            // amount
425
426            let criteria = new_input_query(
427                &address,
428                None,
429                vec![(mock::KnownAsset::Hosky, 1001)],
430                true,
431                false,
432            );
433
434            let utxos = selector.select(&criteria).await.unwrap();
435            assert!(utxos.len() > 1);
436
437            // test negative case where we ask for any number of utxo adding to the target
438            // amount that is not possible
439
440            let criteria = new_input_query(
441                &address,
442                None,
443                vec![(mock::KnownAsset::Hosky, 4001)],
444                true,
445                false,
446            );
447
448            let utxos = selector.select(&criteria).await.unwrap();
449            assert!(utxos.is_empty());
450
451            // test negative case where we ask for a different asset
452
453            let criteria = new_input_query(
454                &address,
455                None,
456                vec![(mock::KnownAsset::Snek, 500)],
457                false,
458                false,
459            );
460
461            let utxos = selector.select(&criteria).await.unwrap();
462            assert!(utxos.is_empty());
463
464            // test positive case where we ask for the present asset and amount within range
465
466            let criteria = new_input_query(
467                &address,
468                None,
469                vec![(mock::KnownAsset::Hosky, 500)],
470                false,
471                false,
472            );
473
474            let utxos = selector.select(&criteria).await.unwrap();
475            assert_eq!(utxos.len(), 1);
476        }
477    }
478
479    #[pollster::test]
480    async fn test_select_by_collateral() {
481        let store = mock::seed_random_memory_store(
482            |_: &mock::FuzzTxoRef, x: &mock::KnownAddress, sequence: u64| {
483                if sequence % 2 == 0 {
484                    mock::utxo_with_random_amount(x, 4_000_000..5_000_000)
485                } else {
486                    mock::utxo_with_random_asset(x, mock::KnownAsset::Hosky, 500..1000)
487                }
488            },
489        );
490
491        let selector = InputSelector::new(&store);
492
493        for address in mock::KnownAddress::everyone() {
494            let criteria = new_input_query(&address, Some(1_000_000), vec![], false, true);
495
496            let utxos = selector.select(&criteria).await.unwrap();
497
498            assert_eq!(utxos.len(), 1);
499            let utxo = utxos.iter().next().unwrap();
500            assert_eq!(utxo.assets.keys().len(), 1);
501            assert_eq!(utxo.assets.keys().next().unwrap().is_naked(), true);
502        }
503    }
504}