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 return self.union.clone().into();
139 };
140
141 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 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 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 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 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}