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