1use std::collections::{BTreeMap, HashSet};
4use tx3_lang::{
5 applying,
6 backend::{self, UtxoStore},
7 ir, CanonicalAssets, Utxo, UtxoRef, UtxoSet,
8};
9
10pub use crate::inputs::searching::SearchSpace;
11
12mod searching;
13
14macro_rules! data_or_bail {
15 ($expr:expr, bytes) => {
16 $expr
17 .as_bytes()
18 .ok_or(Error::ExpectedData("bytes".to_string(), $expr.clone()))
19 };
20
21 ($expr:expr, number) => {
22 $expr
23 .as_number()
24 .ok_or(Error::ExpectedData("number".to_string(), $expr.clone()))?
25 };
26
27 ($expr:expr, assets) => {
28 $expr
29 .as_assets()
30 .ok_or(Error::ExpectedData("assets".to_string(), $expr.clone()))
31 };
32
33 ($expr:expr, utxo_refs) => {
34 $expr
35 .as_utxo_refs()
36 .ok_or(Error::ExpectedData("utxo refs".to_string(), $expr.clone()))
37 };
38}
39
40pub struct Diagnostic {
41 pub query: ir::InputQuery,
42 pub utxos: UtxoSet,
43 pub selected: UtxoSet,
44}
45
46#[derive(thiserror::Error, Debug)]
47pub enum Error {
48 #[error("expected {0}, got {1:?}")]
49 ExpectedData(String, ir::Expression),
50
51 #[error("input query too broad")]
52 InputQueryTooBroad,
53
54 #[error("input not resolved: {0}")]
55 InputNotResolved(String, CanonicalQuery, SearchSpace),
56
57 #[error("store error: {0}")]
58 StoreError(#[from] backend::Error),
59
60 #[error("apply error: {0}")]
61 ApplyError(#[from] applying::Error),
62}
63
64pub trait CoinSelection {
65 fn pick(search_space: UtxoSet, target: &CanonicalAssets) -> HashSet<Utxo>;
66}
67
68struct FirstFullMatch;
69
70impl CoinSelection for FirstFullMatch {
71 fn pick(search_space: UtxoSet, target: &CanonicalAssets) -> HashSet<Utxo> {
72 for utxo in search_space.iter() {
73 if utxo.assets.contains_total(target) {
74 return HashSet::from_iter(vec![utxo.clone()]);
75 }
76 }
77
78 HashSet::new()
79 }
80}
81
82fn find_first_excess_utxo(utxos: &HashSet<Utxo>, target: &CanonicalAssets) -> Option<Utxo> {
83 if utxos.len() == 1 {
86 return None;
87 }
88
89 let available = utxos
90 .iter()
91 .fold(CanonicalAssets::empty(), |acc, x| acc + x.assets.clone());
92
93 let excess = available - target.clone();
94
95 if excess.is_empty_or_negative() {
96 return None;
97 }
98
99 for utxo in utxos.iter() {
100 if excess.contains_total(&utxo.assets) {
101 return Some(utxo.clone());
102 }
103 }
104
105 None
106}
107
108struct NaiveAccumulator;
109
110impl CoinSelection for NaiveAccumulator {
111 fn pick(search_space: UtxoSet, target: &CanonicalAssets) -> HashSet<Utxo> {
112 let mut matched = HashSet::new();
113 let mut pending = target.clone();
114
115 for utxo in search_space.iter() {
116 if utxo.assets.contains_some(&pending) {
117 matched.insert(utxo.clone());
118 let to_include = utxo.assets.clone();
119 pending = pending - to_include;
120 }
121
122 if pending.is_empty_or_negative() {
123 break;
125 }
126 }
127
128 if !pending.is_empty_or_negative() {
129 return HashSet::new();
132 }
133
134 while let Some(utxo) = find_first_excess_utxo(&matched, target) {
135 matched.remove(&utxo);
136 }
137
138 matched
139 }
140}
141
142const MAX_SEARCH_SPACE_SIZE: usize = 50;
143
144struct InputSelector<'a, S: UtxoStore> {
145 store: &'a S,
146 ignore: HashSet<UtxoRef>,
147 ignore_collateral: HashSet<UtxoRef>,
148}
149
150impl<'a, S: UtxoStore> InputSelector<'a, S> {
151 pub fn new(store: &'a S) -> Self {
152 Self {
153 store,
154 ignore: HashSet::new(),
155 ignore_collateral: HashSet::new(),
156 }
157 }
158
159 fn pick_from_set(utxos: UtxoSet, criteria: &CanonicalQuery) -> UtxoSet {
160 let target = criteria
161 .min_amount
162 .clone()
163 .unwrap_or(CanonicalAssets::empty());
164
165 if criteria.support_many {
166 NaiveAccumulator::pick(utxos, &target)
167 } else {
168 FirstFullMatch::pick(utxos, &target)
169 }
170 }
171
172 pub async fn select_collateral(
173 &mut self,
174 search_space: &SearchSpace,
175 criteria: &CanonicalQuery,
176 ) -> Result<UtxoSet, Error> {
177 let refs = search_space
178 .take(Some(MAX_SEARCH_SPACE_SIZE))
179 .into_iter()
180 .filter(|x| !self.ignore_collateral.contains(x))
181 .collect();
182
183 let utxos = self.store.fetch_utxos(refs).await?;
184
185 let utxos = utxos
191 .into_iter()
192 .filter(|x| x.assets.is_only_naked())
193 .collect();
194
195 let matched = Self::pick_from_set(utxos, criteria);
196
197 self.ignore_collateral
198 .extend(matched.iter().map(|x| x.r#ref.clone()));
199
200 Ok(matched)
201 }
202
203 pub async fn select_input(
204 &mut self,
205 search_space: &SearchSpace,
206 criteria: &CanonicalQuery,
207 ) -> Result<UtxoSet, Error> {
208 let refs = search_space
209 .take(Some(MAX_SEARCH_SPACE_SIZE))
210 .into_iter()
211 .filter(|x| !self.ignore.contains(x))
212 .collect();
213
214 let utxos = self.store.fetch_utxos(refs).await?;
215
216 let matched = Self::pick_from_set(utxos, criteria);
217
218 self.ignore.extend(matched.iter().map(|x| x.r#ref.clone()));
219
220 Ok(matched)
221 }
222
223 pub async fn select(
224 &mut self,
225 search_space: &SearchSpace,
226 criteria: &CanonicalQuery,
227 ) -> Result<UtxoSet, Error> {
228 if criteria.collateral {
229 self.select_collateral(search_space, criteria).await
230 } else {
231 self.select_input(search_space, criteria).await
232 }
233 }
234}
235
236#[derive(Debug, Clone)]
237pub struct CanonicalQuery {
238 pub address: Option<Vec<u8>>,
239 pub min_amount: Option<CanonicalAssets>,
240 pub refs: HashSet<UtxoRef>,
241 pub support_many: bool,
242 pub collateral: bool,
243}
244
245impl std::fmt::Display for CanonicalQuery {
246 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
247 write!(f, "CanonicalQuery {{")?;
248
249 if let Some(address) = &self.address {
250 write!(f, "address: {}", hex::encode(address))?;
251 }
252
253 if let Some(min_amount) = &self.min_amount {
254 write!(f, "min_amount: {}", min_amount)?;
255 }
256
257 for (i, ref_) in self.refs.iter().enumerate() {
258 write!(f, "ref[{}]:{}#{}", i, hex::encode(&ref_.txid), ref_.index)?;
259 }
260
261 write!(f, "support_many: {:?}", self.support_many)?;
262 write!(f, "for_collateral: {:?}", self.collateral)?;
263 write!(f, "}}")
264 }
265}
266
267impl TryFrom<ir::InputQuery> for CanonicalQuery {
268 type Error = Error;
269
270 fn try_from(query: ir::InputQuery) -> Result<Self, Self::Error> {
271 let address = query
272 .address
273 .as_option()
274 .map(|x| data_or_bail!(x, bytes))
275 .transpose()?
276 .map(|x| Vec::from(x));
277
278 let min_amount = query
279 .min_amount
280 .as_option()
281 .map(|x| data_or_bail!(x, assets))
282 .transpose()?
283 .map(|x| CanonicalAssets::from(Vec::from(x)));
284
285 let refs = query
286 .r#ref
287 .as_option()
288 .map(|x| data_or_bail!(x, utxo_refs))
289 .transpose()?
290 .map(|x| HashSet::from_iter(x.iter().cloned()))
291 .unwrap_or_default();
292
293 Ok(Self {
294 address,
295 min_amount,
296 refs,
297 support_many: query.many,
298 collateral: query.collateral,
299 })
300 }
301}
302
303pub async fn resolve<T: UtxoStore>(tx: ir::Tx, utxos: &T) -> Result<ir::Tx, Error> {
304 let mut all_inputs = BTreeMap::new();
305
306 let mut selector = InputSelector::new(utxos);
307
308 for (name, query) in applying::find_queries(&tx) {
309 let query = CanonicalQuery::try_from(query)?;
310
311 let space = searching::narrow_search_space(utxos, &query).await?;
312
313 let utxos = selector.select(&space, &query).await?;
314
315 if utxos.is_empty() {
316 return Err(Error::InputNotResolved(name.to_string(), query, space));
317 }
318
319 all_inputs.insert(name, utxos);
320 }
321
322 let out = applying::apply_inputs(tx, &all_inputs)?;
323
324 Ok(out)
325}
326
327#[cfg(test)]
328mod tests {
329 use crate::mock;
330
331 use super::*;
332
333 pub fn new_input_query(
334 address: &mock::KnownAddress,
335 naked_amount: Option<u64>,
336 other_assets: Vec<(mock::KnownAsset, u64)>,
337 many: bool,
338 collateral: bool,
339 ) -> CanonicalQuery {
340 let naked_asset = naked_amount.map(|x| ir::AssetExpr {
341 policy: ir::Expression::None,
342 asset_name: ir::Expression::None,
343 amount: ir::Expression::Number(x as i128),
344 });
345
346 let other_assets: Vec<ir::AssetExpr> = other_assets
347 .into_iter()
348 .map(|(asset, amount)| ir::AssetExpr {
349 policy: ir::Expression::Bytes(asset.policy().as_slice().to_vec()),
350 asset_name: ir::Expression::Bytes(asset.name().to_vec()),
351 amount: ir::Expression::Number(amount as i128),
352 })
353 .collect();
354
355 let all_assets = naked_asset.into_iter().chain(other_assets).collect();
356
357 ir::InputQuery {
358 address: ir::Expression::Address(address.to_bytes()),
359 min_amount: ir::Expression::Assets(all_assets),
360 r#ref: ir::Expression::None,
361 many,
362 collateral,
363 }
364 .try_into()
365 .unwrap()
366 }
367
368 #[pollster::test]
369 async fn test_select_by_address() {
370 let store = mock::seed_random_memory_store(
371 |_: &mock::FuzzTxoRef, x: &mock::KnownAddress, _: u64| {
372 mock::utxo_with_random_amount(x, 4_000_000..5_000_000)
373 },
374 2..4,
375 );
376
377 let mut selector = InputSelector::new(&store);
378
379 for subject in mock::KnownAddress::everyone() {
380 let criteria = new_input_query(&subject, None, vec![], false, false);
381
382 let space = searching::narrow_search_space(&store, &criteria)
383 .await
384 .unwrap();
385
386 let utxos = selector.select(&space, &criteria).await.unwrap();
387
388 assert_eq!(utxos.len(), 1);
389
390 for utxo in utxos {
391 assert_eq!(utxo.address, subject.to_bytes());
392 }
393 }
394 }
395
396 #[pollster::test]
397 async fn test_input_query_too_broad() {
398 let store = mock::seed_random_memory_store(
399 |_: &mock::FuzzTxoRef, x: &mock::KnownAddress, _: u64| {
400 mock::utxo_with_random_amount(x, 4_000_000..5_000_000)
401 },
402 2..4,
403 );
404
405 let empty_criteria = ir::InputQuery {
406 address: ir::Expression::None,
407 min_amount: ir::Expression::None,
408 r#ref: ir::Expression::None,
409 many: false,
410 collateral: false,
411 }
412 .try_into()
413 .unwrap();
414
415 let space = searching::narrow_search_space(&store, &empty_criteria)
416 .await
417 .unwrap_err();
418
419 assert!(matches!(space, Error::InputQueryTooBroad));
420 }
421
422 #[pollster::test]
423 async fn test_select_anything() {
424 let store = mock::seed_random_memory_store(
425 |_: &mock::FuzzTxoRef, x: &mock::KnownAddress, seq: u64| {
426 if seq % 2 == 0 {
427 mock::utxo_with_random_amount(x, 4_000_000..5_000_000)
428 } else {
429 mock::utxo_with_random_asset(x, mock::KnownAsset::Hosky, 500..1000)
430 }
431 },
432 2..3, );
434
435 let mut selector = InputSelector::new(&store);
436
437 let criteria = new_input_query(&mock::KnownAddress::Alice, None, vec![], true, false);
438
439 let space = searching::narrow_search_space(&store, &criteria)
440 .await
441 .unwrap();
442
443 let utxos = selector.select(&space, &criteria).await.unwrap();
444 assert_eq!(utxos.len(), 1);
445 }
446
447 #[pollster::test]
448 async fn test_select_by_naked_amount() {
449 let store = mock::seed_random_memory_store(
450 |_: &mock::FuzzTxoRef, x: &mock::KnownAddress, _: u64| {
451 mock::utxo_with_random_amount(x, 4_000_000..5_000_000)
452 },
453 2..4,
454 );
455
456 let mut selector = InputSelector::new(&store);
457
458 let criteria = new_input_query(
459 &mock::KnownAddress::Alice,
460 Some(6_000_000),
461 vec![],
462 false,
463 false,
464 );
465
466 let space = searching::narrow_search_space(&store, &criteria)
467 .await
468 .unwrap();
469
470 let utxos = selector.select(&space, &criteria).await.unwrap();
471 assert!(utxos.is_empty());
472
473 let criteria = new_input_query(
474 &mock::KnownAddress::Alice,
475 Some(4_000_000),
476 vec![],
477 false,
478 false,
479 );
480
481 let utxos = selector.select(&space, &criteria).await.unwrap();
482
483 let match_count = dbg!(utxos.len());
484 assert_eq!(match_count, 1);
485 }
486
487 #[pollster::test]
488 async fn test_select_by_asset_amount() {
489 let store = mock::seed_random_memory_store(
490 |_: &mock::FuzzTxoRef, x: &mock::KnownAddress, _: u64| {
491 mock::utxo_with_random_asset(x, mock::KnownAsset::Hosky, 500..1000)
492 },
493 2..4,
494 );
495
496 for address in mock::KnownAddress::everyone() {
497 let criteria = new_input_query(
501 &address,
502 None,
503 vec![(mock::KnownAsset::Hosky, 1001)],
504 false,
505 false,
506 );
507
508 let space = searching::narrow_search_space(&store, &criteria)
509 .await
510 .unwrap();
511
512 let mut selector = InputSelector::new(&store);
513 let utxos = selector.select(&space, &criteria).await.unwrap();
514 assert!(utxos.is_empty());
515
516 let criteria = new_input_query(
520 &address,
521 None,
522 vec![(mock::KnownAsset::Hosky, 1001)],
523 true,
524 false,
525 );
526
527 let space = searching::narrow_search_space(&store, &criteria)
528 .await
529 .unwrap();
530
531 let mut selector = InputSelector::new(&store);
532 let utxos = selector.select(&space, &criteria).await.unwrap();
533 assert!(utxos.len() > 1);
534
535 let criteria = new_input_query(
539 &address,
540 None,
541 vec![(mock::KnownAsset::Hosky, 4001)],
542 true,
543 false,
544 );
545
546 let space = searching::narrow_search_space(&store, &criteria)
547 .await
548 .unwrap();
549
550 let mut selector = InputSelector::new(&store);
551 let utxos = selector.select(&space, &criteria).await.unwrap();
552 assert!(utxos.is_empty());
553
554 let criteria = new_input_query(
557 &address,
558 None,
559 vec![(mock::KnownAsset::Snek, 500)],
560 false,
561 false,
562 );
563
564 let space = searching::narrow_search_space(&store, &criteria)
565 .await
566 .unwrap();
567
568 let mut selector = InputSelector::new(&store);
569 let utxos = selector.select(&space, &criteria).await.unwrap();
570 assert!(utxos.is_empty());
571
572 let criteria = new_input_query(
575 &address,
576 None,
577 vec![(mock::KnownAsset::Hosky, 500)],
578 false,
579 false,
580 );
581
582 let space = searching::narrow_search_space(&store, &criteria)
583 .await
584 .unwrap();
585
586 let mut selector = InputSelector::new(&store);
587 let utxos = selector.select(&space, &criteria).await.unwrap();
588 assert_eq!(utxos.len(), 1);
589 }
590 }
591
592 #[pollster::test]
593 async fn test_select_by_collateral() {
594 let store = mock::seed_random_memory_store(
595 |_: &mock::FuzzTxoRef, x: &mock::KnownAddress, sequence: u64| {
596 if sequence % 2 == 0 {
597 mock::utxo_with_random_amount(x, 4_000_000..5_000_000)
598 } else {
599 mock::utxo_with_random_asset(x, mock::KnownAsset::Hosky, 500..1000)
600 }
601 },
602 2..4,
603 );
604
605 let mut selector = InputSelector::new(&store);
606
607 for address in mock::KnownAddress::everyone() {
608 let criteria = new_input_query(&address, Some(1_000_000), vec![], false, true);
609
610 let space = searching::narrow_search_space(&store, &criteria)
611 .await
612 .unwrap();
613
614 let utxos = selector.select(&space, &criteria).await.unwrap();
615
616 assert_eq!(utxos.len(), 1);
617 let utxo = utxos.iter().next().unwrap();
618 assert_eq!(utxo.assets.keys().len(), 1);
619 assert_eq!(utxo.assets.keys().next().unwrap().is_naked(), true);
620 }
621 }
622
623 #[pollster::test]
624 async fn test_select_same_collateral_and_input() {
625 let store = mock::seed_random_memory_store(
626 |_: &mock::FuzzTxoRef, x: &mock::KnownAddress, _: u64| {
627 mock::utxo_with_random_amount(x, 4_000_000..5_000_000)
628 },
629 1..2, );
631
632 let mut selector = InputSelector::new(&store);
633
634 for address in mock::KnownAddress::everyone() {
635 let criteria = new_input_query(&address, Some(1_000_000), vec![], false, false);
637
638 let space = searching::narrow_search_space(&store, &criteria)
639 .await
640 .unwrap();
641
642 let utxos = selector.select(&space, &criteria).await.unwrap();
643 assert_eq!(utxos.len(), 1);
644
645 let criteria = new_input_query(&address, Some(1_000_000), vec![], false, true);
647
648 let space = searching::narrow_search_space(&store, &criteria)
649 .await
650 .unwrap();
651
652 let utxos = selector.select(&space, &criteria).await.unwrap();
653 assert_eq!(utxos.len(), 1);
654 }
655 }
656
657 #[pollster::test]
658 async fn test_resolve_ignores_selected_utxos() {
659 let store = mock::seed_random_memory_store(
660 |_: &mock::FuzzTxoRef, x: &mock::KnownAddress, seq: u64| {
661 if seq % 2 == 0 {
662 mock::utxo_with_random_amount(x, 1_000..1_500)
663 } else {
664 mock::utxo_with_random_amount(x, 100..150)
665 }
666 },
667 2..3, );
669
670 for address in mock::KnownAddress::everyone() {
671 let mut selector = InputSelector::new(&store);
672
673 let query1 = new_input_query(&address, Some(1_000), vec![], true, false);
675
676 let space = searching::narrow_search_space(&store, &query1)
677 .await
678 .unwrap();
679
680 let utxos1 = selector.select(&space, &query1).await.unwrap();
681 assert_eq!(utxos1.len(), 1);
682
683 let query2 = new_input_query(&address, Some(1_000), vec![], true, false);
686
687 let space = searching::narrow_search_space(&store, &query2)
688 .await
689 .unwrap();
690
691 let utxos2 = selector.select(&space, &query2).await.unwrap();
692 assert_eq!(utxos2.len(), 0);
693
694 let query3 = new_input_query(&address, Some(100), vec![], true, false);
696
697 let space = searching::narrow_search_space(&store, &query3)
698 .await
699 .unwrap();
700
701 let utxos3 = selector.select(&space, &query3).await.unwrap();
702 assert_eq!(utxos3.len(), 1);
703
704 let query4 = new_input_query(&address, Some(100), vec![], true, false);
707
708 let space = searching::narrow_search_space(&store, &query4)
709 .await
710 .unwrap();
711
712 let utxos4 = selector.select(&space, &query4).await.unwrap();
713 assert_eq!(utxos4.len(), 0);
714
715 utxos1.is_disjoint(&utxos3);
717 }
718 }
719
720 #[pollster::test]
721 async fn test_select_by_naked_and_asset_amount() {
722 let store = mock::seed_random_memory_store(
723 |_: &mock::FuzzTxoRef, x: &mock::KnownAddress, sequence: u64| {
724 if sequence % 2 == 0 {
725 mock::utxo_with_random_amount(x, 4_000_000..5_000_000)
726 } else {
727 mock::utxo_with_random_asset(x, mock::KnownAsset::Hosky, 500..1000)
728 }
729 },
730 2..3,
731 );
732
733 let mut selector = InputSelector::new(&store);
734
735 let criteria = new_input_query(
736 &mock::KnownAddress::Alice,
737 Some(4_000_000),
738 vec![(mock::KnownAsset::Hosky, 500)],
739 true,
740 false,
741 );
742
743 let space = searching::narrow_search_space(&store, &criteria)
744 .await
745 .unwrap();
746
747 let utxos = selector.select(&space, &criteria).await.unwrap();
748
749 assert!(utxos.len() == 2);
750 }
751}