Skip to main content

tx3_resolver/inputs/
narrow.rs

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