Skip to main content

tx3_resolver/inputs/
narrow.rs

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