1use std::collections::{BTreeMap, HashSet};
4use tx3_lang::{
5 applying,
6 backend::{self, UtxoStore},
7 ir, CanonicalAssets, Utxo, UtxoRef, UtxoSet,
8};
9
10use 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} {1:?} {2:?}")]
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 let available = utxos
84 .iter()
85 .fold(CanonicalAssets::empty(), |acc, x| acc + x.assets.clone());
86
87 let excess = available - target.clone();
88
89 if excess.is_empty_or_negative() {
90 return None;
91 }
92
93 for utxo in utxos.iter() {
94 if excess.contains_total(&utxo.assets) {
95 return Some(utxo.clone());
96 }
97 }
98
99 None
100}
101
102struct NaiveAccumulator;
103
104impl CoinSelection for NaiveAccumulator {
105 fn pick(search_space: UtxoSet, target: &CanonicalAssets) -> HashSet<Utxo> {
106 let mut matched = HashSet::new();
107 let mut pending = target.clone();
108
109 for utxo in search_space.iter() {
110 if utxo.assets.contains_some(target) {
111 matched.insert(utxo.clone());
112 pending = pending - utxo.assets.clone();
113 }
114
115 if pending.is_empty_or_negative() {
116 break;
118 }
119 }
120
121 if !pending.is_empty_or_negative() {
122 return HashSet::new();
125 }
126
127 while let Some(utxo) = find_first_excess_utxo(&matched, target) {
128 matched.remove(&utxo);
129 }
130
131 matched
132 }
133}
134
135struct CollateralMatch;
136
137impl CoinSelection for CollateralMatch {
138 fn pick(search_space: UtxoSet, target: &CanonicalAssets) -> HashSet<Utxo> {
139 for utxo in search_space.iter() {
140 let only_naked = utxo.assets.keys().all(|x| x.is_naked());
141 let has_enough = utxo.assets.contains_total(target);
142
143 if only_naked && has_enough {
144 return HashSet::from_iter(vec![utxo.clone()]);
145 }
146 }
147
148 HashSet::new()
149 }
150}
151
152const MAX_SEARCH_SPACE_SIZE: usize = 50;
153
154struct InputSelector<'a, S: UtxoStore> {
155 store: &'a S,
156 ignore: HashSet<UtxoRef>,
157}
158
159impl<'a, S: UtxoStore> InputSelector<'a, S> {
160 pub fn new(store: &'a S) -> Self {
161 Self {
162 store,
163 ignore: HashSet::new(),
164 }
165 }
166
167 pub async fn select(
168 &mut self,
169 search_space: &SearchSpace,
170 criteria: &CanonicalQuery,
171 ) -> Result<UtxoSet, Error> {
172 let refs = search_space
173 .matched
174 .clone()
175 .into_iter()
176 .filter(|x| !self.ignore.contains(x))
177 .collect();
178
179 let utxos = self.store.fetch_utxos(refs).await?;
180
181 let target = criteria
182 .min_amount
183 .clone()
184 .unwrap_or(CanonicalAssets::empty());
185
186 let matched = if criteria.collateral {
187 CollateralMatch::pick(utxos, &target)
188 } else if criteria.support_many {
189 NaiveAccumulator::pick(utxos, &target)
190 } else {
191 FirstFullMatch::pick(utxos, &target)
192 };
193
194 self.ignore.extend(matched.iter().map(|x| x.r#ref.clone()));
195
196 Ok(matched)
197 }
198}
199
200#[derive(Debug, Clone)]
201pub struct CanonicalQuery {
202 pub address: Option<Vec<u8>>,
203 pub min_amount: Option<CanonicalAssets>,
204 pub refs: HashSet<UtxoRef>,
205 pub support_many: bool,
206 pub collateral: bool,
207}
208
209impl std::fmt::Display for CanonicalQuery {
210 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
211 write!(f, "CanonicalQuery {{")?;
212
213 if let Some(address) = &self.address {
214 write!(f, "address: {}", hex::encode(address))?;
215 }
216
217 if let Some(min_amount) = &self.min_amount {
218 write!(f, "min_amount: {}", min_amount)?;
219 }
220
221 for (i, ref_) in self.refs.iter().enumerate() {
222 write!(f, "ref[{}]:{}#{}", i, hex::encode(&ref_.txid), ref_.index)?;
223 }
224
225 write!(f, "support_many: {:?}", self.support_many)?;
226 write!(f, "for_collateral: {:?}", self.collateral)?;
227 write!(f, "}}")
228 }
229}
230
231impl TryFrom<ir::InputQuery> for CanonicalQuery {
232 type Error = Error;
233
234 fn try_from(query: ir::InputQuery) -> Result<Self, Self::Error> {
235 let address = query
236 .address
237 .as_option()
238 .map(|x| data_or_bail!(x, bytes))
239 .transpose()?
240 .map(|x| Vec::from(x));
241
242 let min_amount = query
243 .min_amount
244 .as_option()
245 .map(|x| data_or_bail!(x, assets))
246 .transpose()?
247 .map(|x| CanonicalAssets::from(Vec::from(x)));
248
249 let refs = query
250 .r#ref
251 .as_option()
252 .map(|x| data_or_bail!(x, utxo_refs))
253 .transpose()?
254 .map(|x| HashSet::from_iter(x.iter().cloned()))
255 .unwrap_or_default();
256
257 Ok(Self {
258 address,
259 min_amount,
260 refs,
261 support_many: query.many,
262 collateral: query.collateral,
263 })
264 }
265}
266
267pub async fn resolve<T: UtxoStore>(tx: ir::Tx, utxos: &T) -> Result<ir::Tx, Error> {
268 let mut all_inputs = BTreeMap::new();
269
270 let mut selector = InputSelector::new(utxos);
271
272 for (name, query) in applying::find_queries(&tx) {
273 let query = CanonicalQuery::try_from(query)?;
274
275 let space = searching::narrow_search_space(utxos, &query, MAX_SEARCH_SPACE_SIZE).await?;
276
277 let utxos = selector.select(&space, &query).await?;
278
279 if utxos.is_empty() {
280 return Err(Error::InputNotResolved(name.to_string(), query, space));
281 }
282
283 all_inputs.insert(name, utxos);
284 }
285
286 let out = applying::apply_inputs(tx, &all_inputs)?;
287
288 Ok(out)
289}
290
291#[cfg(test)]
292mod tests {
293 use crate::mock;
294
295 use super::*;
296
297 pub fn new_input_query(
298 address: &mock::KnownAddress,
299 naked_amount: Option<u64>,
300 other_assets: Vec<(mock::KnownAsset, u64)>,
301 many: bool,
302 collateral: bool,
303 ) -> CanonicalQuery {
304 let naked_asset = naked_amount.map(|x| ir::AssetExpr {
305 policy: ir::Expression::None,
306 asset_name: ir::Expression::None,
307 amount: ir::Expression::Number(x as i128),
308 });
309
310 let other_assets: Vec<ir::AssetExpr> = other_assets
311 .into_iter()
312 .map(|(asset, amount)| ir::AssetExpr {
313 policy: ir::Expression::Bytes(asset.policy().as_slice().to_vec()),
314 asset_name: ir::Expression::Bytes(asset.name().to_vec()),
315 amount: ir::Expression::Number(amount as i128),
316 })
317 .collect();
318
319 let all_assets = naked_asset.into_iter().chain(other_assets).collect();
320
321 ir::InputQuery {
322 address: ir::Expression::Address(address.to_bytes()),
323 min_amount: ir::Expression::Assets(all_assets),
324 r#ref: ir::Expression::None,
325 many,
326 collateral,
327 }
328 .try_into()
329 .unwrap()
330 }
331
332 #[pollster::test]
333 async fn test_select_by_address() {
334 let store = mock::seed_random_memory_store(
335 |_: &mock::FuzzTxoRef, x: &mock::KnownAddress, _: u64| {
336 mock::utxo_with_random_amount(x, 4_000_000..5_000_000)
337 },
338 2..4,
339 );
340
341 let mut selector = InputSelector::new(&store);
342
343 for subject in mock::KnownAddress::everyone() {
344 let criteria = new_input_query(&subject, None, vec![], false, false);
345
346 let space = searching::narrow_search_space(&store, &criteria, MAX_SEARCH_SPACE_SIZE)
347 .await
348 .unwrap();
349
350 let utxos = selector.select(&space, &criteria).await.unwrap();
351
352 assert_eq!(utxos.len(), 1);
353
354 for utxo in utxos {
355 assert_eq!(utxo.address, subject.to_bytes());
356 }
357 }
358 }
359
360 #[pollster::test]
361 async fn test_select_by_naked_amount() {
362 let store = mock::seed_random_memory_store(
363 |_: &mock::FuzzTxoRef, x: &mock::KnownAddress, _: u64| {
364 mock::utxo_with_random_amount(x, 4_000_000..5_000_000)
365 },
366 2..4,
367 );
368
369 let mut selector = InputSelector::new(&store);
370
371 let criteria = new_input_query(
372 &mock::KnownAddress::Alice,
373 Some(6_000_000),
374 vec![],
375 false,
376 false,
377 );
378
379 let space = searching::narrow_search_space(&store, &criteria, MAX_SEARCH_SPACE_SIZE)
380 .await
381 .unwrap();
382
383 let utxos = selector.select(&space, &criteria).await.unwrap();
384 assert!(utxos.is_empty());
385
386 let criteria = new_input_query(
387 &mock::KnownAddress::Alice,
388 Some(4_000_000),
389 vec![],
390 false,
391 false,
392 );
393
394 let utxos = selector.select(&space, &criteria).await.unwrap();
395
396 let match_count = dbg!(utxos.len());
397 assert_eq!(match_count, 1);
398 }
399
400 #[pollster::test]
401 async fn test_select_by_asset_amount() {
402 let store = mock::seed_random_memory_store(
403 |_: &mock::FuzzTxoRef, x: &mock::KnownAddress, _: u64| {
404 mock::utxo_with_random_asset(x, mock::KnownAsset::Hosky, 500..1000)
405 },
406 2..4,
407 );
408
409 for address in mock::KnownAddress::everyone() {
410 let criteria = new_input_query(
414 &address,
415 None,
416 vec![(mock::KnownAsset::Hosky, 1001)],
417 false,
418 false,
419 );
420
421 let space = searching::narrow_search_space(&store, &criteria, MAX_SEARCH_SPACE_SIZE)
422 .await
423 .unwrap();
424
425 let mut selector = InputSelector::new(&store);
426 let utxos = selector.select(&space, &criteria).await.unwrap();
427 assert!(utxos.is_empty());
428
429 let criteria = new_input_query(
433 &address,
434 None,
435 vec![(mock::KnownAsset::Hosky, 1001)],
436 true,
437 false,
438 );
439
440 let space = searching::narrow_search_space(&store, &criteria, MAX_SEARCH_SPACE_SIZE)
441 .await
442 .unwrap();
443
444 let mut selector = InputSelector::new(&store);
445 let utxos = selector.select(&space, &criteria).await.unwrap();
446 assert!(utxos.len() > 1);
447
448 let criteria = new_input_query(
452 &address,
453 None,
454 vec![(mock::KnownAsset::Hosky, 4001)],
455 true,
456 false,
457 );
458
459 let space = searching::narrow_search_space(&store, &criteria, MAX_SEARCH_SPACE_SIZE)
460 .await
461 .unwrap();
462
463 let mut selector = InputSelector::new(&store);
464 let utxos = selector.select(&space, &criteria).await.unwrap();
465 assert!(utxos.is_empty());
466
467 let criteria = new_input_query(
470 &address,
471 None,
472 vec![(mock::KnownAsset::Snek, 500)],
473 false,
474 false,
475 );
476
477 let space = searching::narrow_search_space(&store, &criteria, MAX_SEARCH_SPACE_SIZE)
478 .await
479 .unwrap();
480
481 let mut selector = InputSelector::new(&store);
482 let utxos = selector.select(&space, &criteria).await.unwrap();
483 assert!(utxos.is_empty());
484
485 let criteria = new_input_query(
488 &address,
489 None,
490 vec![(mock::KnownAsset::Hosky, 500)],
491 false,
492 false,
493 );
494
495 let space = searching::narrow_search_space(&store, &criteria, MAX_SEARCH_SPACE_SIZE)
496 .await
497 .unwrap();
498
499 let mut selector = InputSelector::new(&store);
500 let utxos = selector.select(&space, &criteria).await.unwrap();
501 assert_eq!(utxos.len(), 1);
502 }
503 }
504
505 #[pollster::test]
506 async fn test_select_by_collateral() {
507 let store = mock::seed_random_memory_store(
508 |_: &mock::FuzzTxoRef, x: &mock::KnownAddress, sequence: u64| {
509 if sequence % 2 == 0 {
510 mock::utxo_with_random_amount(x, 4_000_000..5_000_000)
511 } else {
512 mock::utxo_with_random_asset(x, mock::KnownAsset::Hosky, 500..1000)
513 }
514 },
515 2..4,
516 );
517
518 let mut selector = InputSelector::new(&store);
519
520 for address in mock::KnownAddress::everyone() {
521 let criteria = new_input_query(&address, Some(1_000_000), vec![], false, true);
522
523 let space = searching::narrow_search_space(&store, &criteria, MAX_SEARCH_SPACE_SIZE)
524 .await
525 .unwrap();
526
527 let utxos = selector.select(&space, &criteria).await.unwrap();
528
529 assert_eq!(utxos.len(), 1);
530 let utxo = utxos.iter().next().unwrap();
531 assert_eq!(utxo.assets.keys().len(), 1);
532 assert_eq!(utxo.assets.keys().next().unwrap().is_naked(), true);
533 }
534 }
535
536 #[pollster::test]
537 async fn test_resolve_ignores_selected_utxos() {
538 let store = mock::seed_random_memory_store(
539 |_: &mock::FuzzTxoRef, x: &mock::KnownAddress, seq: u64| {
540 if seq % 2 == 0 {
541 mock::utxo_with_random_amount(x, 1_000..1_500)
542 } else {
543 mock::utxo_with_random_amount(x, 100..150)
544 }
545 },
546 2..3, );
548
549 for address in mock::KnownAddress::everyone() {
550 let mut selector = InputSelector::new(&store);
551
552 let query1 = new_input_query(&address, Some(1_000), vec![], true, false);
554
555 let space = searching::narrow_search_space(&store, &query1, MAX_SEARCH_SPACE_SIZE)
556 .await
557 .unwrap();
558
559 let utxos1 = selector.select(&space, &query1).await.unwrap();
560 assert_eq!(utxos1.len(), 1);
561
562 let query2 = new_input_query(&address, Some(1_000), vec![], true, false);
565
566 let space = searching::narrow_search_space(&store, &query2, MAX_SEARCH_SPACE_SIZE)
567 .await
568 .unwrap();
569
570 let utxos2 = selector.select(&space, &query2).await.unwrap();
571 assert_eq!(utxos2.len(), 0);
572
573 let query3 = new_input_query(&address, Some(100), vec![], true, false);
575
576 let space = searching::narrow_search_space(&store, &query3, MAX_SEARCH_SPACE_SIZE)
577 .await
578 .unwrap();
579
580 let utxos3 = selector.select(&space, &query3).await.unwrap();
581 assert_eq!(utxos3.len(), 1);
582
583 let query4 = new_input_query(&address, Some(100), vec![], true, false);
586
587 let space = searching::narrow_search_space(&store, &query4, MAX_SEARCH_SPACE_SIZE)
588 .await
589 .unwrap();
590
591 let utxos4 = selector.select(&space, &query4).await.unwrap();
592 assert_eq!(utxos4.len(), 0);
593
594 utxos1.is_disjoint(&utxos3);
596 }
597 }
598}