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 return self.union.clone().into();
133 };
134
135 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 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 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 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}