Skip to main content

tx3_resolver/inputs/
narrow.rs

1use std::collections::{HashMap, HashSet};
2
3use tx3_tir::model::{
4    assets::AssetClass,
5    core::{CanonicalOrd, Utxo, UtxoRef},
6};
7
8use crate::job::ResolveJob;
9use crate::{inputs::canonical::CanonicalQuery, UtxoPattern, UtxoStore};
10
11use super::Error;
12
13const MAX_SEARCH_SPACE_SIZE: usize = 50;
14
15#[derive(Debug, Clone)]
16enum Subset {
17    NotSet,
18    All,
19    Specific(HashSet<UtxoRef>),
20}
21
22impl Subset {
23    fn count(&self) -> Option<usize> {
24        match self {
25            Self::NotSet => None,
26            Self::All => None,
27            Self::Specific(s) => Some(s.len()),
28        }
29    }
30
31    #[allow(dead_code)]
32    fn union(a: Self, b: Self) -> Self {
33        match (a, b) {
34            (Self::NotSet, x) => x,
35            (x, Self::NotSet) => x,
36            (Self::All, _) => Self::All,
37            (_, Self::All) => Self::All,
38            (Self::Specific(s1), Self::Specific(s2)) => {
39                Self::Specific(s1.union(&s2).cloned().collect())
40            }
41        }
42    }
43
44    fn intersection(a: Self, b: Self) -> Self {
45        match (a, b) {
46            (Self::NotSet, x) => x,
47            (x, Self::NotSet) => x,
48            (Self::All, x) => x,
49            (x, Self::All) => x,
50            (Self::Specific(s1), Self::Specific(s2)) => {
51                Self::Specific(s1.intersection(&s2).cloned().collect())
52            }
53        }
54    }
55
56    #[allow(dead_code)]
57    fn is_empty(&self) -> bool {
58        match self {
59            Self::NotSet => true,
60            Self::All => false,
61            Self::Specific(s) => s.is_empty(),
62        }
63    }
64}
65
66impl From<Subset> for HashSet<UtxoRef> {
67    fn from(value: Subset) -> Self {
68        match value {
69            Subset::Specific(s) => s,
70            Subset::NotSet => HashSet::new(),
71            Subset::All => HashSet::new(),
72        }
73    }
74}
75
76impl From<HashSet<UtxoRef>> for Subset {
77    fn from(value: HashSet<UtxoRef>) -> Self {
78        Self::Specific(value)
79    }
80}
81
82#[derive(Debug, Clone)]
83struct SearchSpace {
84    union: Subset,
85    intersection: Subset,
86    by_address_count: Option<usize>,
87    by_asset_class_count: Option<usize>,
88    by_ref_count: Option<usize>,
89}
90
91impl SearchSpace {
92    fn new() -> Self {
93        Self {
94            union: Subset::NotSet,
95            intersection: Subset::NotSet,
96            by_address_count: None,
97            by_asset_class_count: None,
98            by_ref_count: None,
99        }
100    }
101
102    fn is_constrained(&self) -> bool {
103        match &self.intersection {
104            Subset::NotSet => false,
105            Subset::All => false,
106            Subset::Specific(_) => true,
107        }
108    }
109
110    fn include_subset(&mut self, subset: Subset) {
111        self.union = Subset::union(self.union.clone(), subset.clone());
112        self.intersection = Subset::intersection(self.intersection.clone(), subset);
113    }
114
115    fn include_matches(&mut self, utxos: HashSet<UtxoRef>) {
116        let matches = Subset::Specific(utxos);
117        self.include_subset(matches);
118    }
119
120    fn include_address_matches(&mut self, subset: Subset) {
121        *self.by_address_count.get_or_insert(0) += subset.count().unwrap_or(0);
122        self.include_subset(subset);
123    }
124
125    fn include_asset_class_matches(&mut self, subset: Subset) {
126        *self.by_asset_class_count.get_or_insert(0) += subset.count().unwrap_or(0);
127        self.include_subset(subset);
128    }
129
130    fn add_ref_matches(&mut self, utxos: HashSet<UtxoRef>) {
131        *self.by_ref_count.get_or_insert(0) += utxos.len();
132        self.include_matches(utxos);
133    }
134
135    fn take(&self, take: Option<usize>) -> HashSet<UtxoRef> {
136        let Some(take) = take else {
137            // if there's no limit, return everything we have
138            return self.union.clone().into();
139        };
140
141        // if we have a specific limit, we need to pick the best options. The
142        // intersection are the best matches since they are the most specific, so we
143        // take from them first. If we don't have enough, we take the remaining from the
144        // union.
145
146        let best: HashSet<_> = self.intersection.clone().into();
147
148        if best.len() < take {
149            let others: HashSet<_> = self.union.clone().into();
150            let diff: HashSet<_> = others.difference(&best).cloned().collect();
151            let mut sorted_diff: Vec<_> = diff.into_iter().collect();
152            sorted_diff.sort_by(|a, b| a.cmp_canonical(b));
153            let remaining: HashSet<_> = sorted_diff.into_iter().take(take - best.len()).collect();
154            best.union(&remaining).cloned().collect()
155        } else {
156            best
157        }
158    }
159}
160
161impl ResolveJob {
162    /// Query the UTxO store for all queries and write the shared pool of
163    /// candidate UTxOs into the job.
164    pub async fn build_utxo_pool<T: UtxoStore>(&mut self, store: &T) -> Result<(), Error> {
165        let mut pool: HashMap<UtxoRef, Utxo> = HashMap::new();
166
167        for qr in &self.input_queries {
168            let query = &qr.query;
169            let space = narrow_search_space(store, query).await?;
170            let refs = space.take(Some(MAX_SEARCH_SPACE_SIZE));
171            let fetched = store.fetch_utxos(refs).await?;
172
173            for utxo in fetched.iter() {
174                pool.entry(utxo.r#ref.clone())
175                    .or_insert_with(|| utxo.clone());
176            }
177        }
178
179        self.input_pool = Some(pool);
180
181        Ok(())
182    }
183}
184
185async fn narrow_by_asset_class<T: UtxoStore>(
186    store: &T,
187    parent: Subset,
188    class: &AssetClass,
189) -> Result<Subset, Error> {
190    // skip filtering lovelace since it's not an custom asset
191    if matches!(class, AssetClass::Naked) {
192        return Ok(parent);
193    }
194
195    let AssetClass::Defined(policy, name) = class else {
196        return Ok(parent);
197    };
198
199    let utxos = store
200        .narrow_refs(UtxoPattern::by_asset(policy, name))
201        .await?;
202
203    Ok(Subset::intersection(parent, Subset::Specific(utxos)))
204}
205
206async fn narrow_search_space<T: UtxoStore>(
207    store: &T,
208    criteria: &CanonicalQuery,
209) -> Result<SearchSpace, Error> {
210    let mut search_space = SearchSpace::new();
211
212    let parent_subset = if let Some(address) = &criteria.address {
213        let utxos = store.narrow_refs(UtxoPattern::by_address(address)).await?;
214        Subset::Specific(utxos)
215    } else {
216        Subset::All
217    };
218
219    search_space.include_address_matches(parent_subset.clone());
220
221    if let Some(assets) = &criteria.min_amount {
222        for (class, amount) in assets.iter() {
223            if *amount > 0 {
224                let subset = narrow_by_asset_class(store, parent_subset.clone(), class).await?;
225                search_space.include_asset_class_matches(subset);
226            }
227        }
228    }
229
230    if !criteria.refs.is_empty() {
231        search_space.add_ref_matches(criteria.refs.clone());
232    }
233
234    if !search_space.is_constrained() {
235        dbg!(&search_space);
236        return Err(Error::InputQueryTooBroad);
237    }
238
239    Ok(search_space)
240}
241
242#[cfg(test)]
243mod tests {
244    use std::collections::HashSet;
245
246    use tx3_tir::model::assets::CanonicalAssets;
247
248    use super::*;
249
250    use crate::test_utils as mock;
251
252    fn assets_for(asset: mock::KnownAsset, amount: i128) -> CanonicalAssets {
253        CanonicalAssets::from_asset(
254            Some(asset.policy().as_ref()),
255            Some(asset.name().as_ref()),
256            amount,
257        )
258    }
259
260    fn cq(
261        address: Option<&mock::KnownAddress>,
262        min_assets: Option<CanonicalAssets>,
263        refs: HashSet<UtxoRef>,
264    ) -> CanonicalQuery {
265        CanonicalQuery {
266            address: address.map(|a| a.to_bytes()),
267            min_amount: min_assets,
268            refs,
269            support_many: true,
270            collateral: false,
271        }
272    }
273
274    fn prepare_store() -> mock::MockStore {
275        mock::seed_random_memory_store(
276            |_: &mock::FuzzTxoRef, x: &mock::KnownAddress, seq: u64| {
277                if seq % 2 == 0 {
278                    mock::utxo_with_random_asset(x, mock::KnownAsset::Hosky, 500..1000)
279                } else {
280                    mock::utxo_with_random_amount(x, 4_000_000..5_000_000)
281                }
282            },
283            2..3,
284        )
285    }
286
287    async fn assert_space_matches<T: UtxoStore>(
288        store: &T,
289        criteria: CanonicalQuery,
290        expected: HashSet<UtxoRef>,
291    ) {
292        let space = narrow_search_space(store, &criteria).await.unwrap();
293        let got = space.take(Some(expected.len()));
294        assert_eq!(got, expected);
295    }
296
297    #[pollster::test]
298    async fn test_address_only() {
299        let store = prepare_store();
300
301        let addr = mock::KnownAddress::Alice;
302        let expected = store.by_known_address(&addr).await;
303
304        let criteria = cq(Some(&addr), None, HashSet::new());
305        assert_space_matches(&store, criteria, expected).await;
306    }
307
308    #[pollster::test]
309    async fn test_asset_only() {
310        let store = prepare_store();
311
312        let asset = mock::KnownAsset::Hosky;
313        let expected = store.by_known_asset(&asset).await;
314
315        let min_assets = assets_for(asset, 1);
316        let criteria = cq(None, Some(min_assets), HashSet::new());
317        assert_space_matches(&store, criteria, expected).await;
318    }
319
320    #[pollster::test]
321    async fn test_refs_only() {
322        let store = prepare_store();
323
324        let alice = mock::KnownAddress::Alice;
325        let bob = mock::KnownAddress::Bob;
326
327        let alice_refs = store.by_known_address(&alice).await;
328        let bob_refs = store.by_known_address(&bob).await;
329
330        let pick_one = |set: &HashSet<UtxoRef>| set.iter().next().unwrap().clone();
331        let refs: HashSet<UtxoRef> =
332            HashSet::from_iter(vec![pick_one(&alice_refs), pick_one(&bob_refs)]);
333
334        let criteria = cq(None, None, refs.clone());
335        assert_space_matches(&store, criteria, refs).await;
336    }
337
338    #[pollster::test]
339    async fn test_address_and_asset_intersection() {
340        let store = prepare_store();
341
342        let addr = mock::KnownAddress::Alice;
343        let asset = mock::KnownAsset::Hosky;
344
345        let by_addr = store.by_known_address(&addr).await;
346        let by_asset = store.by_known_asset(&asset).await;
347
348        let expected_best: HashSet<_> = by_addr.intersection(&by_asset).cloned().collect();
349
350        let min_assets = assets_for(asset, 1);
351        let criteria = cq(Some(&addr), Some(min_assets), HashSet::new());
352        assert_space_matches(&store, criteria, expected_best).await;
353    }
354
355    #[pollster::test]
356    async fn test_address_and_refs_intersection() {
357        let store = prepare_store();
358
359        let alice = mock::KnownAddress::Alice;
360        let bob = mock::KnownAddress::Bob;
361
362        let alice_refs = store.by_known_address(&alice).await;
363        let bob_refs = store.by_known_address(&bob).await;
364
365        let pick_one = |set: &HashSet<UtxoRef>| set.iter().next().unwrap().clone();
366        let refs: HashSet<UtxoRef> =
367            HashSet::from_iter(vec![pick_one(&alice_refs), pick_one(&bob_refs)]);
368
369        let expected_best: HashSet<_> = alice_refs.intersection(&refs).cloned().collect();
370
371        let criteria = cq(Some(&alice), None, refs);
372        assert_space_matches(&store, criteria, expected_best).await;
373    }
374
375    #[pollster::test]
376    async fn test_asset_and_refs_intersection() {
377        let store = prepare_store();
378
379        let asset = mock::KnownAsset::Hosky;
380
381        let by_asset = store.by_known_asset(&asset).await;
382
383        // pick one ref that surely has the asset, and another arbitrary ref from
384        // someone else
385        let alice = mock::KnownAddress::Alice;
386        let bob = mock::KnownAddress::Bob;
387
388        let alice_any = store.by_known_address(&alice).await;
389        let bob_any = store.by_known_address(&bob).await;
390
391        let pick_one = |set: &HashSet<UtxoRef>| set.iter().next().unwrap().clone();
392        let one_with_asset = pick_one(&by_asset);
393        let other_ref = pick_one(&bob_any.union(&alice_any).cloned().collect());
394
395        let refs: HashSet<UtxoRef> = HashSet::from_iter(vec![one_with_asset.clone(), other_ref]);
396        let expected_best: HashSet<_> = by_asset.intersection(&refs).cloned().collect();
397
398        let min_assets = assets_for(asset, 1);
399        let criteria = cq(None, Some(min_assets), refs);
400        assert_space_matches(&store, criteria, expected_best).await;
401    }
402
403    #[pollster::test]
404    async fn test_address_asset_and_refs_intersection() {
405        let store = prepare_store();
406
407        let addr = mock::KnownAddress::Alice;
408        let asset = mock::KnownAsset::Hosky;
409
410        let by_addr = store.by_known_address(&addr).await;
411        let by_asset = store.by_known_asset(&asset).await;
412
413        let both: HashSet<_> = by_addr.intersection(&by_asset).cloned().collect();
414        assert!(!both.is_empty());
415
416        let one_ref = both.iter().next().unwrap().clone();
417        let mut refs = HashSet::new();
418        refs.insert(one_ref.clone());
419
420        // include a distractor ref that does not satisfy all dims
421        let bob = mock::KnownAddress::Bob;
422        let bob_refs = store
423            .narrow_refs(UtxoPattern::by_address(&bob.to_bytes()))
424            .await
425            .unwrap();
426        let distractor = bob_refs.iter().next().unwrap().clone();
427        refs.insert(distractor);
428
429        let expected_best: HashSet<_> = both.intersection(&refs).cloned().collect();
430
431        let min_assets = assets_for(asset, 1);
432        let criteria = cq(Some(&addr), Some(min_assets), refs);
433        assert_space_matches(&store, criteria, expected_best).await;
434    }
435
436    #[test]
437    fn test_take_prefers_canonical_refs_when_filling_from_union() {
438        let mkref = |b: u8| UtxoRef::new(&[b], 0);
439
440        let best_ref = mkref(3);
441        let mut best = HashSet::new();
442        best.insert(best_ref.clone());
443
444        let mut union = HashSet::new();
445        union.insert(best_ref);
446        union.insert(mkref(9));
447        union.insert(mkref(1));
448        union.insert(mkref(5));
449
450        let space = SearchSpace {
451            union: Subset::Specific(union),
452            intersection: Subset::Specific(best),
453            by_address_count: None,
454            by_asset_class_count: None,
455            by_ref_count: None,
456        };
457
458        let got = space.take(Some(3));
459
460        assert!(got.contains(&mkref(3)));
461        assert!(got.contains(&mkref(1)));
462        assert!(got.contains(&mkref(5)));
463        assert!(!got.contains(&mkref(9)));
464    }
465}