From 055c2f58714f1d4fe6aa93e2d4f5ffabedfcc665 Mon Sep 17 00:00:00 2001 From: Santiago Date: Wed, 1 Apr 2026 09:05:14 -0300 Subject: [PATCH 1/2] refactor(resolver): decouple input selection into stages --- .../src/inputs/approximate/filter.rs | 188 +++++++++++++++ .../src/inputs/approximate/mod.rs | 55 +++++ .../src/inputs/approximate/rank/mod.rs | 23 ++ .../src/inputs/approximate/rank/naive.rs | 15 ++ .../{select => approximate/rank}/vector.rs | 65 +----- crates/tx3-resolver/src/inputs/assign/mod.rs | 158 +++++++++++++ .../src/inputs/{select => assign}/tests.rs | 216 ++++++++++-------- crates/tx3-resolver/src/inputs/canonical.rs | 107 +++++++++ crates/tx3-resolver/src/inputs/mod.rs | 137 +++-------- crates/tx3-resolver/src/inputs/narrow.rs | 52 ++++- crates/tx3-resolver/src/inputs/select/mod.rs | 195 ---------------- .../tx3-resolver/src/inputs/select/naive.rs | 53 ----- 12 files changed, 747 insertions(+), 517 deletions(-) create mode 100644 crates/tx3-resolver/src/inputs/approximate/filter.rs create mode 100644 crates/tx3-resolver/src/inputs/approximate/mod.rs create mode 100644 crates/tx3-resolver/src/inputs/approximate/rank/mod.rs create mode 100644 crates/tx3-resolver/src/inputs/approximate/rank/naive.rs rename crates/tx3-resolver/src/inputs/{select => approximate/rank}/vector.rs (79%) create mode 100644 crates/tx3-resolver/src/inputs/assign/mod.rs rename crates/tx3-resolver/src/inputs/{select => assign}/tests.rs (69%) create mode 100644 crates/tx3-resolver/src/inputs/canonical.rs delete mode 100644 crates/tx3-resolver/src/inputs/select/mod.rs delete mode 100644 crates/tx3-resolver/src/inputs/select/naive.rs diff --git a/crates/tx3-resolver/src/inputs/approximate/filter.rs b/crates/tx3-resolver/src/inputs/approximate/filter.rs new file mode 100644 index 00000000..1e52ae87 --- /dev/null +++ b/crates/tx3-resolver/src/inputs/approximate/filter.rs @@ -0,0 +1,188 @@ +//! Constraint filters for UTxO candidate selection. +//! +//! Hard constraints must be satisfied by each UTxO individually (address, refs, +//! collateral). Aggregate constraints (asset amounts) can be met by combining +//! multiple UTxOs when the query supports it. + +use tx3_tir::model::{ + assets::CanonicalAssets, + core::Utxo, +}; + +use crate::inputs::canonical::CanonicalQuery; + +fn matches_collateral_constraint(query: &CanonicalQuery, utxo: &Utxo) -> bool { + !query.collateral || utxo.assets.is_only_naked() +} + +fn matches_address_constraint(query: &CanonicalQuery, utxo: &Utxo) -> bool { + query.address.as_ref().map_or(true, |addr| utxo.address == *addr) +} + +fn matches_ref_constraint(query: &CanonicalQuery, utxo: &Utxo) -> bool { + query.refs.is_empty() || query.refs.contains(&utxo.r#ref) +} + +/// Checks non-negotiable constraints that a UTxO must satisfy individually. +/// These cannot be met by combining multiple UTxOs. +pub fn matches_hard_constraints(query: &CanonicalQuery, utxo: &Utxo) -> bool { + matches_collateral_constraint(query, utxo) + && matches_address_constraint(query, utxo) + && matches_ref_constraint(query, utxo) +} + +/// Checks asset-amount constraints that support aggregation across UTxOs. +/// For single inputs, each UTxO must fully cover the target (`contains_total`). +/// For many inputs, partial coverage is enough (`contains_some`). +pub fn matches_aggregate_constraints( + query: &CanonicalQuery, + utxo: &Utxo, + target: &CanonicalAssets, +) -> bool { + if query.support_many { + utxo.assets.contains_some(target) + } else { + utxo.assets.contains_total(target) + } +} + +#[cfg(test)] +mod tests { + use std::collections::HashSet; + + use tx3_tir::model::{ + assets::CanonicalAssets, + core::{Utxo, UtxoRef}, + }; + + use crate::inputs::canonical::CanonicalQuery; + + use super::*; + + fn utxo(address: &[u8], naked: i128) -> Utxo { + Utxo { + r#ref: UtxoRef { + txid: vec![0; 32], + index: 0, + }, + address: address.to_vec(), + assets: CanonicalAssets::from_naked_amount(naked), + datum: None, + script: None, + } + } + + fn utxo_with_ref(address: &[u8], naked: i128, txid: u8, index: u32) -> Utxo { + Utxo { + r#ref: UtxoRef { + txid: vec![txid; 32], + index, + }, + address: address.to_vec(), + assets: CanonicalAssets::from_naked_amount(naked), + datum: None, + script: None, + } + } + + fn utxo_with_asset(address: &[u8], naked: i128, policy: &[u8], name: &[u8], amount: i128) -> Utxo { + let assets = CanonicalAssets::from_naked_amount(naked) + + CanonicalAssets::from_asset(Some(policy), Some(name), amount); + + Utxo { + r#ref: UtxoRef { + txid: vec![0; 32], + index: 0, + }, + address: address.to_vec(), + assets, + datum: None, + script: None, + } + } + + fn query( + address: Option<&[u8]>, + min_amount: Option, + refs: HashSet, + many: bool, + collateral: bool, + ) -> CanonicalQuery { + CanonicalQuery { + address: address.map(|a| a.to_vec()), + min_amount, + refs, + support_many: many, + collateral, + } + } + + // -- hard constraints -- + + #[test] + fn hard_no_constraints_matches_anything() { + let q = query(None, None, HashSet::new(), false, false); + let u = utxo(b"alice", 5_000_000); + assert!(matches_hard_constraints(&q, &u)); + } + + #[test] + fn hard_address_match() { + let q = query(Some(b"alice"), None, HashSet::new(), false, false); + assert!(matches_hard_constraints(&q, &utxo(b"alice", 1))); + assert!(!matches_hard_constraints(&q, &utxo(b"bob", 1))); + } + + #[test] + fn hard_ref_match() { + let target_ref = UtxoRef { txid: vec![1; 32], index: 0 }; + let q = query(None, None, HashSet::from([target_ref.clone()]), false, false); + assert!(matches_hard_constraints(&q, &utxo_with_ref(b"alice", 1, 1, 0))); + assert!(!matches_hard_constraints(&q, &utxo_with_ref(b"alice", 1, 2, 0))); + } + + #[test] + fn hard_collateral_requires_naked() { + let q = query(Some(b"alice"), None, HashSet::new(), false, true); + let naked = utxo(b"alice", 5_000_000); + let with_token = utxo_with_asset(b"alice", 5_000_000, b"policy", b"name", 100); + assert!(matches_hard_constraints(&q, &naked)); + assert!(!matches_hard_constraints(&q, &with_token)); + } + + #[test] + fn hard_all_constraints_combined() { + let target_ref = UtxoRef { txid: vec![1; 32], index: 0 }; + let q = query(Some(b"alice"), None, HashSet::from([target_ref]), false, true); + + // right address, right ref, naked → pass + assert!(matches_hard_constraints(&q, &utxo_with_ref(b"alice", 1, 1, 0))); + // wrong address → fail + assert!(!matches_hard_constraints(&q, &utxo_with_ref(b"bob", 1, 1, 0))); + // wrong ref → fail + assert!(!matches_hard_constraints(&q, &utxo_with_ref(b"alice", 1, 2, 0))); + } + + // -- aggregate constraints -- + + #[test] + fn aggregate_single_requires_total() { + let target = CanonicalAssets::from_naked_amount(5_000_000); + let q = query(None, Some(target.clone()), HashSet::new(), false, false); + + let enough = utxo(b"alice", 5_000_000); + let not_enough = utxo(b"alice", 3_000_000); + + assert!(matches_aggregate_constraints(&q, &enough, &target)); + assert!(!matches_aggregate_constraints(&q, ¬_enough, &target)); + } + + #[test] + fn aggregate_many_allows_partial() { + let target = CanonicalAssets::from_naked_amount(5_000_000); + let q = query(None, Some(target.clone()), HashSet::new(), true, false); + + let partial = utxo(b"alice", 3_000_000); + assert!(matches_aggregate_constraints(&q, &partial, &target)); + } +} diff --git a/crates/tx3-resolver/src/inputs/approximate/mod.rs b/crates/tx3-resolver/src/inputs/approximate/mod.rs new file mode 100644 index 00000000..c590ed11 --- /dev/null +++ b/crates/tx3-resolver/src/inputs/approximate/mod.rs @@ -0,0 +1,55 @@ +//! Approximation stage: produces a ranked list of viable UTxO candidates for +//! each query independently, without cross-query awareness. + +use std::collections::HashMap; + +use tx3_tir::model::{ + assets::CanonicalAssets, + core::{Utxo, UtxoRef}, +}; + +use crate::inputs::{assign::PreparedQuery, canonical::CanonicalQuery}; + +pub mod filter; +pub mod rank; + +use rank::{Rank as _, Ranker}; + +/// For each query, filter the pool by hard constraints, rank by closeness +/// to the target asset composition, then filter by aggregate constraints. +/// Returns a `PreparedQuery` per input, ready for the assignment stage. +pub fn approximate_queries( + pool: &HashMap, + queries: Vec<(String, CanonicalQuery)>, +) -> Vec { + queries + .into_iter() + .map(|(name, query)| { + let candidates = approximate_candidates(pool, &query); + PreparedQuery { name, query, candidates } + }) + .collect() +} + +fn approximate_candidates( + pool: &HashMap, + query: &CanonicalQuery, +) -> Vec { + let target = query + .min_amount + .clone() + .unwrap_or(CanonicalAssets::empty()); + + let pool_set = pool + .values() + .filter(|utxo| filter::matches_hard_constraints(query, utxo)) + .cloned() + .collect(); + + let ordered = Ranker::sorted_candidates(pool_set, &target); + + ordered + .into_iter() + .filter(|utxo| filter::matches_aggregate_constraints(query, utxo, &target)) + .collect() +} diff --git a/crates/tx3-resolver/src/inputs/approximate/rank/mod.rs b/crates/tx3-resolver/src/inputs/approximate/rank/mod.rs new file mode 100644 index 00000000..20461ab7 --- /dev/null +++ b/crates/tx3-resolver/src/inputs/approximate/rank/mod.rs @@ -0,0 +1,23 @@ +//! Ranking strategies for UTxO candidates. +//! +//! A ranker sorts candidates by how well they match a target asset composition. +//! Different strategies trade off precision for simplicity. + +use tx3_tir::model::{ + assets::CanonicalAssets, + core::{Utxo, UtxoSet}, +}; + +pub mod naive; +pub mod vector; + +/// Rank candidates by relevance to a target asset composition. +pub trait Rank { + fn sorted_candidates(search_space: UtxoSet, target: &CanonicalAssets) -> Vec; +} + +#[cfg(not(feature = "naive_selector"))] +pub type Ranker = vector::VectorRanker; + +#[cfg(feature = "naive_selector")] +pub type Ranker = naive::NaiveRanker; diff --git a/crates/tx3-resolver/src/inputs/approximate/rank/naive.rs b/crates/tx3-resolver/src/inputs/approximate/rank/naive.rs new file mode 100644 index 00000000..003a9343 --- /dev/null +++ b/crates/tx3-resolver/src/inputs/approximate/rank/naive.rs @@ -0,0 +1,15 @@ +use tx3_tir::model::{ + assets::CanonicalAssets, + core::{Utxo, UtxoSet}, +}; + +use super::Rank; + +#[allow(dead_code)] +pub struct NaiveRanker; + +impl Rank for NaiveRanker { + fn sorted_candidates(search_space: UtxoSet, _target: &CanonicalAssets) -> Vec { + Vec::from_iter(search_space) + } +} diff --git a/crates/tx3-resolver/src/inputs/select/vector.rs b/crates/tx3-resolver/src/inputs/approximate/rank/vector.rs similarity index 79% rename from crates/tx3-resolver/src/inputs/select/vector.rs rename to crates/tx3-resolver/src/inputs/approximate/rank/vector.rs index f7d6cd21..36d8b4f8 100644 --- a/crates/tx3-resolver/src/inputs/select/vector.rs +++ b/crates/tx3-resolver/src/inputs/approximate/rank/vector.rs @@ -4,7 +4,7 @@ use tx3_tir::model::{ core::{Utxo, UtxoSet}, }; -use super::CoinSelection; +use super::Rank; const MISMATCH_PENALTY: f64 = 3.0; @@ -133,10 +133,10 @@ impl VectorSpace for UtxoSet { } } -pub struct VectorSelector; +pub struct VectorRanker; -impl VectorSelector { - pub(crate) fn sorted_candidates(search_space: UtxoSet, target: &CanonicalAssets) -> Vec { +impl Rank for VectorRanker { + fn sorted_candidates(search_space: UtxoSet, target: &CanonicalAssets) -> Vec { let classes: Vec<_> = class_union!(search_space, target); let mut candidates = Vec::from_iter(search_space); @@ -146,50 +146,6 @@ impl VectorSelector { } } -impl CoinSelection for VectorSelector { - fn pick_many(search_space: UtxoSet, target: &CanonicalAssets) -> HashSet { - let mut matched = HashSet::new(); - let mut pending = target.clone(); - - let candidates = Self::sorted_candidates(search_space, target); - - for candidate in candidates { - if candidate.assets.contains_some(&pending) { - matched.insert(candidate.clone()); - let to_include = candidate.assets.clone(); - pending = pending - to_include; - } - - if pending.is_empty_or_negative() { - break; - } - } - - if !pending.is_empty_or_negative() { - // if we didn't accumulate enough by the end of the search space, - // then we didn't find a match - return HashSet::new(); - } - - while let Some(utxo) = super::find_first_excess_utxo(&matched, target) { - matched.remove(&utxo); - } - - matched - } - - fn pick_single(search_space: UtxoSet, target: &CanonicalAssets) -> UtxoSet { - let candidates = Self::sorted_candidates(search_space, target); - - let first_match = candidates - .iter() - .filter(|utxo| utxo.assets.contains_total(target)) - .next(); - - HashSet::from_iter(first_match.into_iter().cloned()) - } -} - #[cfg(test)] mod tests { use super::*; @@ -255,19 +211,6 @@ mod tests { } } - pub struct AssetConstraint { - range: std::ops::RangeInclusive, - class: AssetClass, - } - - prop_compose! { - fn any_constrained_asset(constraint: AssetConstraint) ( - amount in constraint.range, - ) -> CanonicalAssets { - CanonicalAssets::from_class_and_amount(constraint.class.clone(), amount) - } - } - proptest! { #[test] fn distance_is_symmetric(x in any_composite_asset(), y in any_composite_asset()) { diff --git a/crates/tx3-resolver/src/inputs/assign/mod.rs b/crates/tx3-resolver/src/inputs/assign/mod.rs new file mode 100644 index 00000000..3037579b --- /dev/null +++ b/crates/tx3-resolver/src/inputs/assign/mod.rs @@ -0,0 +1,158 @@ +//! Assignment stage: given all queries with their ranked candidates, find an +//! allocation that satisfies everything simultaneously. + +use std::collections::{BTreeMap, HashSet}; + +use tx3_tir::model::{ + assets::CanonicalAssets, + core::{Utxo, UtxoRef, UtxoSet}, +}; + +use crate::inputs::canonical::CanonicalQuery; + +#[cfg(test)] +mod tests; + +/// How tightly constrained a query is, from most to least specific. +/// Lower values get priority during assignment so that the most constrained +/// queries pick UTxOs first. +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)] +enum Specificity { + SingleCollateral = 0, + Single = 1, + ManyCollateral = 2, + Many = 3, +} + +pub struct PreparedQuery { + pub name: String, + pub query: CanonicalQuery, + pub candidates: Vec, +} + +impl PreparedQuery { + fn specificity(&self) -> Specificity { + match (self.query.support_many, self.query.collateral) { + (false, true) => Specificity::SingleCollateral, + (false, false) => Specificity::Single, + (true, true) => Specificity::ManyCollateral, + (true, false) => Specificity::Many, + } + } + + fn constraint_tightness(&self) -> (usize, Specificity, &str) { + (self.candidates.len(), self.specificity(), &self.name) + } + + fn pick(self, used: &HashSet) -> (String, CanonicalQuery, UtxoSet) { + let available: Vec = self + .candidates + .into_iter() + .filter(|utxo| !used.contains(&utxo.r#ref)) + .collect(); + + let target = self + .query + .min_amount + .clone() + .unwrap_or(CanonicalAssets::empty()); + + let selection = if self.query.support_many { + pick_many(available, &target) + } else { + pick_single(available, &target) + }; + + (self.name, self.query, selection) + } +} + +/// Select the first candidate that fully covers the target. +fn pick_single(candidates: Vec, target: &CanonicalAssets) -> UtxoSet { + candidates + .into_iter() + .find(|utxo| utxo.assets.contains_total(target)) + .into_iter() + .collect() +} + +/// Accumulate candidates greedily until the target is fully covered, +/// then trim any excess UTxOs that aren't needed. +fn pick_many(candidates: Vec, target: &CanonicalAssets) -> UtxoSet { + let mut matched = HashSet::new(); + let mut pending = target.clone(); + + for candidate in candidates { + if candidate.assets.contains_some(&pending) { + matched.insert(candidate.clone()); + let to_include = candidate.assets.clone(); + pending = pending - to_include; + } + + if pending.is_empty_or_negative() { + break; + } + } + + if !pending.is_empty_or_negative() { + return HashSet::new(); + } + + while let Some(utxo) = find_first_excess_utxo(&matched, target) { + matched.remove(&utxo); + } + + matched +} + +fn find_first_excess_utxo(utxos: &HashSet, target: &CanonicalAssets) -> Option { + if utxos.len() == 1 { + return None; + } + + let available = utxos + .iter() + .fold(CanonicalAssets::empty(), |acc, x| acc + x.assets.clone()); + + let excess = available - target.clone(); + + if excess.is_empty_or_negative() { + return None; + } + + for utxo in utxos.iter() { + if excess.contains_total(&utxo.assets) { + return Some(utxo.clone()); + } + } + + None +} + +pub struct Assignment { + pub name: String, + pub query: CanonicalQuery, + pub selection: UtxoSet, +} + +/// Given prepared queries (each with their ranked candidates from the +/// approximation stage), find an allocation that satisfies all queries +/// simultaneously using greedy assignment. +pub fn assign_all(mut queries: Vec) -> Vec { + queries.sort_by(|a, b| a.constraint_tightness().cmp(&b.constraint_tightness())); + + let mut used: HashSet = HashSet::new(); + + queries + .into_iter() + .map(|pq| { + let (name, query, selection) = pq.pick(&used); + + for utxo in selection.iter() { + used.insert(utxo.r#ref.clone()); + } + + Assignment { name, query, selection } + }) + .collect() +} diff --git a/crates/tx3-resolver/src/inputs/select/tests.rs b/crates/tx3-resolver/src/inputs/assign/tests.rs similarity index 69% rename from crates/tx3-resolver/src/inputs/select/tests.rs rename to crates/tx3-resolver/src/inputs/assign/tests.rs index 6e843afa..313df02a 100644 --- a/crates/tx3-resolver/src/inputs/select/tests.rs +++ b/crates/tx3-resolver/src/inputs/assign/tests.rs @@ -1,7 +1,10 @@ use chainfuzz::utxos::UtxoBuilder; -use tx3_tir::model::{assets::CanonicalAssets, v1beta0 as tir}; +use tx3_tir::model::{assets::CanonicalAssets, core::UtxoRef, v1beta0 as tir}; -use crate::{inputs::narrow, mock}; +use crate::{ + inputs::{approximate, canonical::CanonicalQuery, narrow}, + mock, Error, UtxoStore, +}; use super::*; @@ -40,18 +43,35 @@ pub fn new_input_query( .unwrap() } -async fn select_single( +/// Run the full pipeline (narrow → approximate → assign) for a list of named +/// queries, mirroring what `inputs::resolve` does without the TIR layer. +async fn run_pipeline( + store: &S, + queries: Vec<(String, CanonicalQuery)>, +) -> Result, Error> { + let pool = narrow::build_utxo_pool(store, &queries).await?; + let prepared = approximate::approximate_queries(&pool, queries); + let assignments = assign_all(prepared); + + let pool_refs: Vec = pool.keys().cloned().collect(); + let mut result = std::collections::BTreeMap::new(); + + for entry in assignments { + if entry.selection.is_empty() { + return Err(Error::InputNotResolved(entry.name, entry.query, pool_refs)); + } + result.insert(entry.name, entry.selection); + } + + Ok(result) +} + +async fn resolve_single( store: &S, name: &str, criteria: &CanonicalQuery, ) -> UtxoSet { - let mut selector = InputSelector::new(store); - selector - .add(name.to_string(), criteria.clone()) - .await - .unwrap(); - - match selector.select_all() { + match run_pipeline(store, vec![(name.to_string(), criteria.clone())]).await { Ok(selected) => selected.get(name).cloned().unwrap_or_default(), Err(Error::InputNotResolved(..)) => UtxoSet::default(), Err(e) => panic!("unexpected error: {e:?}"), @@ -70,7 +90,7 @@ async fn test_select_by_address() { for subject in mock::KnownAddress::everyone() { let criteria = new_input_query(&subject, None, vec![], false, false); - let utxos = select_single(&store, "q", &criteria).await; + let utxos = resolve_single(&store, "q", &criteria).await; assert_eq!(utxos.len(), 1); @@ -89,7 +109,7 @@ async fn test_input_query_too_broad() { 2..4, ); - let empty_criteria = tir::InputQuery { + let empty_criteria: CanonicalQuery = tir::InputQuery { address: tir::Expression::None, min_amount: tir::Expression::None, r#ref: tir::Expression::None, @@ -99,11 +119,13 @@ async fn test_input_query_too_broad() { .try_into() .unwrap(); - let space = narrow::narrow_search_space(&store, &empty_criteria) - .await - .unwrap_err(); + let result = narrow::build_utxo_pool( + &store, + &[("q".to_string(), empty_criteria)], + ) + .await; - assert!(matches!(space, Error::InputQueryTooBroad)); + assert!(matches!(result, Err(Error::InputQueryTooBroad))); } #[pollster::test] @@ -116,12 +138,12 @@ async fn test_select_anything() { mock::utxo_with_random_asset(x, mock::KnownAsset::Hosky, 500..1000) } }, - 2..3, // exclusive range, this means always two utxos per address + 2..3, ); let criteria = new_input_query(&mock::KnownAddress::Alice, None, vec![], true, false); - let utxos = select_single(&store, "q", &criteria).await; + let utxos = resolve_single(&store, "q", &criteria).await; assert_eq!(utxos.len(), 1); } @@ -142,7 +164,7 @@ async fn test_select_by_naked_amount() { false, ); - let utxos = select_single(&store, "q", &criteria).await; + let utxos = resolve_single(&store, "q", &criteria).await; assert!(utxos.is_empty()); let criteria = new_input_query( @@ -153,7 +175,7 @@ async fn test_select_by_naked_amount() { false, ); - let utxos = select_single(&store, "q", &criteria).await; + let utxos = resolve_single(&store, "q", &criteria).await; let match_count = dbg!(utxos.len()); assert_eq!(match_count, 1); @@ -169,9 +191,6 @@ async fn test_select_by_asset_amount() { ); for address in mock::KnownAddress::everyone() { - // test negative case where we ask for a single utxo with more than available - // amount - let criteria = new_input_query( &address, None, @@ -180,12 +199,9 @@ async fn test_select_by_asset_amount() { false, ); - let utxos = select_single(&store, "q", &criteria).await; + let utxos = resolve_single(&store, "q", &criteria).await; assert!(utxos.is_empty()); - // test positive case where we ask for any number of utxo adding to the target - // amount - let criteria = new_input_query( &address, None, @@ -194,12 +210,9 @@ async fn test_select_by_asset_amount() { false, ); - let utxos = select_single(&store, "q", &criteria).await; + let utxos = resolve_single(&store, "q", &criteria).await; assert!(utxos.len() > 1); - // test negative case where we ask for any number of utxo adding to the target - // amount that is not possible - let criteria = new_input_query( &address, None, @@ -208,11 +221,9 @@ async fn test_select_by_asset_amount() { false, ); - let utxos = select_single(&store, "q", &criteria).await; + let utxos = resolve_single(&store, "q", &criteria).await; assert!(utxos.is_empty()); - // test negative case where we ask for a different asset - let criteria = new_input_query( &address, None, @@ -221,11 +232,9 @@ async fn test_select_by_asset_amount() { false, ); - let utxos = select_single(&store, "q", &criteria).await; + let utxos = resolve_single(&store, "q", &criteria).await; assert!(utxos.is_empty()); - // test positive case where we ask for the present asset and amount within range - let criteria = new_input_query( &address, None, @@ -234,7 +243,7 @@ async fn test_select_by_asset_amount() { false, ); - let utxos = select_single(&store, "q", &criteria).await; + let utxos = resolve_single(&store, "q", &criteria).await; assert_eq!(utxos.len(), 1); } } @@ -255,7 +264,7 @@ async fn test_select_by_collateral() { for address in mock::KnownAddress::everyone() { let criteria = new_input_query(&address, Some(1_000_000), vec![], false, true); - let utxos = select_single(&store, "q", &criteria).await; + let utxos = resolve_single(&store, "q", &criteria).await; assert_eq!(utxos.len(), 1); let utxo = utxos.iter().next().unwrap(); @@ -265,38 +274,33 @@ async fn test_select_by_collateral() { } #[pollster::test] -async fn test_select_same_collateral_and_input() { +async fn test_assign_same_collateral_and_input() { let store = mock::seed_random_memory_store( |_: &mock::FuzzTxoRef, x: &mock::KnownAddress, _: u64| { mock::utxo_with_random_amount(x, 4_000_000..5_000_000) }, - 1..2, // exclusive range, this means only one utxo per address + 1..2, ); for address in mock::KnownAddress::everyone() { let input_query = new_input_query(&address, Some(1_000_000), vec![], false, false); let collateral_query = new_input_query(&address, Some(1_000_000), vec![], false, true); - let mut selector = InputSelector::new(&store); - selector - .add("input".to_string(), input_query) - .await - .unwrap(); - selector - .add("collateral".to_string(), collateral_query) - .await - .unwrap(); - - let selected = selector.select_all(); - - // With only one UTxO, collateral (more constrained) gets it, input gets nothing - // select_all now returns an error when any query resolves empty - assert!(selected.is_err()); + let result = run_pipeline( + &store, + vec![ + ("input".to_string(), input_query), + ("collateral".to_string(), collateral_query), + ], + ) + .await; + + assert!(result.is_err()); } } #[pollster::test] -async fn test_select_all_exclusive_assignments() { +async fn test_assign_all_exclusive_assignments() { let store = mock::seed_random_memory_store( |_: &mock::FuzzTxoRef, x: &mock::KnownAddress, seq: u64| { if seq % 2 == 0 { @@ -305,24 +309,22 @@ async fn test_select_all_exclusive_assignments() { mock::utxo_with_random_amount(x, 100..150) } }, - 2..3, // exclusive range, this means only two utxos per address + 2..3, ); for address in mock::KnownAddress::everyone() { let large_query = new_input_query(&address, Some(1_000), vec![], false, false); let small_query = new_input_query(&address, Some(100), vec![], false, false); - let mut selector = InputSelector::new(&store); - selector - .add("large".to_string(), large_query) - .await - .unwrap(); - selector - .add("small".to_string(), small_query) - .await - .unwrap(); - - let selected = selector.select_all().unwrap(); + let selected = run_pipeline( + &store, + vec![ + ("large".to_string(), large_query), + ("small".to_string(), small_query), + ], + ) + .await + .unwrap(); let large_utxos = selected.get("large").cloned().unwrap_or_default(); let small_utxos = selected.get("small").cloned().unwrap_or_default(); @@ -334,7 +336,7 @@ async fn test_select_all_exclusive_assignments() { } #[pollster::test] -async fn test_select_all_competing_queries() { +async fn test_assign_all_competing_queries() { let store = mock::seed_random_memory_store( |_: &mock::FuzzTxoRef, x: &mock::KnownAddress, seq: u64| { if seq % 2 == 0 { @@ -359,17 +361,15 @@ async fn test_select_all_competing_queries() { new_input_query(&address, None, vec![(mock::KnownAsset::Hosky, 1)], false, false); let naked_query = new_input_query(&address, Some(1), vec![], false, false); - let mut selector = InputSelector::new(&store); - selector - .add("asset".to_string(), asset_query) - .await - .unwrap(); - selector - .add("naked".to_string(), naked_query) - .await - .unwrap(); - - let selected = selector.select_all().unwrap(); + let selected = run_pipeline( + &store, + vec![ + ("asset".to_string(), asset_query), + ("naked".to_string(), naked_query), + ], + ) + .await + .unwrap(); let asset_utxos = selected.get("asset").cloned().unwrap_or_default(); let naked_utxos = selected.get("naked").cloned().unwrap_or_default(); @@ -389,7 +389,7 @@ async fn test_select_all_competing_queries() { } #[pollster::test] -async fn test_select_all_competing_queries_no_solution() { +async fn test_assign_all_competing_queries_no_solution() { let store = mock::seed_random_memory_store( |_: &mock::FuzzTxoRef, x: &mock::KnownAddress, _seq: u64| { UtxoBuilder::new() @@ -407,17 +407,20 @@ async fn test_select_all_competing_queries_no_solution() { let query_b = new_input_query(&address, None, vec![(mock::KnownAsset::Hosky, 1)], false, false); - let mut selector = InputSelector::new(&store); - selector.add("a".to_string(), query_a).await.unwrap(); - selector.add("b".to_string(), query_b).await.unwrap(); + let result = run_pipeline( + &store, + vec![ + ("a".to_string(), query_a), + ("b".to_string(), query_b), + ], + ) + .await; - // With only one UTxO, the second query must fail - let result = selector.select_all(); assert!(result.is_err()); } #[pollster::test] -async fn test_select_by_naked_and_asset_amount() { +async fn test_assign_by_naked_and_asset_amount() { let store = mock::seed_random_memory_store( |_: &mock::FuzzTxoRef, x: &mock::KnownAddress, sequence: u64| { if sequence % 2 == 0 { @@ -437,7 +440,42 @@ async fn test_select_by_naked_and_asset_amount() { false, ); - let utxos = select_single(&store, "q", &criteria).await; + let utxos = resolve_single(&store, "q", &criteria).await; assert!(utxos.len() == 2); } + +#[pollster::test] +async fn test_cross_query_pool_doesnt_leak_wrong_address() { + let store = mock::seed_random_memory_store( + |_: &mock::FuzzTxoRef, x: &mock::KnownAddress, seq: u64| { + if x.to_bytes() == mock::KnownAddress::Bob.to_bytes() && seq % 2 == 0 { + UtxoBuilder::new() + .with_address(x) + .with_naked_value(4_000_000) + .with_random_asset(mock::KnownAsset::Hosky, 1..2) + .build() + } else { + mock::utxo_with_random_amount(x, 4_000_000..5_000_000) + } + }, + 2..3, + ); + + let addr_a = mock::KnownAddress::Alice; + let addr_b = mock::KnownAddress::Bob; + + let query_a = new_input_query(&addr_a, None, vec![(mock::KnownAsset::Hosky, 1)], false, false); + let query_b = new_input_query(&addr_b, Some(1_000_000), vec![], false, false); + + let result = run_pipeline( + &store, + vec![ + ("a".to_string(), query_a), + ("b".to_string(), query_b), + ], + ) + .await; + + assert!(result.is_err()); +} diff --git a/crates/tx3-resolver/src/inputs/canonical.rs b/crates/tx3-resolver/src/inputs/canonical.rs new file mode 100644 index 00000000..8688924b --- /dev/null +++ b/crates/tx3-resolver/src/inputs/canonical.rs @@ -0,0 +1,107 @@ +//! Canonical representation of input queries. + +use std::collections::HashSet; + +use tx3_tir::model::v1beta0 as tir; +use tx3_tir::model::{assets::CanonicalAssets, core::UtxoRef, core::UtxoSet}; + +use crate::Error; + +macro_rules! data_or_bail { + ($expr:expr, bytes) => { + $expr + .as_bytes() + .ok_or(Error::ExpectedData("bytes".to_string(), $expr.clone())) + }; + + ($expr:expr, number) => { + $expr + .as_number() + .ok_or(Error::ExpectedData("number".to_string(), $expr.clone()))? + }; + + ($expr:expr, assets) => { + $expr + .as_assets() + .ok_or(Error::ExpectedData("assets".to_string(), $expr.clone())) + }; + + ($expr:expr, utxo_refs) => { + $expr + .as_utxo_refs() + .ok_or(Error::ExpectedData("utxo refs".to_string(), $expr.clone())) + }; +} + +pub struct Diagnostic { + pub query: tir::InputQuery, + pub utxos: UtxoSet, + pub selected: UtxoSet, +} + +#[derive(Debug, Clone)] +pub struct CanonicalQuery { + pub address: Option>, + pub min_amount: Option, + pub refs: HashSet, + pub support_many: bool, + pub collateral: bool, +} + +impl std::fmt::Display for CanonicalQuery { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "CanonicalQuery {{")?; + + if let Some(address) = &self.address { + write!(f, "address: {}", hex::encode(address))?; + } + + if let Some(min_amount) = &self.min_amount { + write!(f, "min_amount: {}", min_amount)?; + } + + for (i, ref_) in self.refs.iter().enumerate() { + write!(f, "ref[{}]:{}#{}", i, hex::encode(&ref_.txid), ref_.index)?; + } + + write!(f, "support_many: {:?}", self.support_many)?; + write!(f, "for_collateral: {:?}", self.collateral)?; + write!(f, "}}") + } +} + +impl TryFrom for CanonicalQuery { + type Error = Error; + + fn try_from(query: tir::InputQuery) -> Result { + let address = query + .address + .as_option() + .map(|x| data_or_bail!(x, bytes)) + .transpose()? + .map(Vec::from); + + let min_amount = query + .min_amount + .as_option() + .map(|x| data_or_bail!(x, assets)) + .transpose()? + .map(|x| CanonicalAssets::from(Vec::from(x))); + + let refs = query + .r#ref + .as_option() + .map(|x| data_or_bail!(x, utxo_refs)) + .transpose()? + .map(|x| HashSet::from_iter(x.iter().cloned())) + .unwrap_or_default(); + + Ok(Self { + address, + min_amount, + refs, + support_many: query.many, + collateral: query.collateral, + }) + } +} diff --git a/crates/tx3-resolver/src/inputs/mod.rs b/crates/tx3-resolver/src/inputs/mod.rs index 2c210389..b3e8ac7f 100644 --- a/crates/tx3-resolver/src/inputs/mod.rs +++ b/crates/tx3-resolver/src/inputs/mod.rs @@ -1,128 +1,51 @@ -//! Tx input selection algorithms +//! Tx input resolution pipeline. +//! +//! Orchestrates three stages: +//! 1. **Narrow**: query the UTxO store to build a pool of candidate UTxOs +//! 2. **Approximate**: filter and rank candidates for each query independently +//! 3. **Assign**: allocate UTxOs across all queries simultaneously -use std::collections::HashSet; +use std::collections::BTreeMap; use tx3_tir::encoding::AnyTir; -use tx3_tir::model::core::UtxoSet; -use tx3_tir::model::v1beta0 as tir; -use tx3_tir::model::{assets::CanonicalAssets, core::UtxoRef}; +use tx3_tir::model::core::UtxoRef; use crate::{Error, UtxoStore}; +mod approximate; +mod assign; +mod canonical; mod narrow; -mod select; -macro_rules! data_or_bail { - ($expr:expr, bytes) => { - $expr - .as_bytes() - .ok_or(Error::ExpectedData("bytes".to_string(), $expr.clone())) - }; +pub use canonical::CanonicalQuery; - ($expr:expr, number) => { - $expr - .as_number() - .ok_or(Error::ExpectedData("number".to_string(), $expr.clone()))? - }; - - ($expr:expr, assets) => { - $expr - .as_assets() - .ok_or(Error::ExpectedData("assets".to_string(), $expr.clone())) - }; - - ($expr:expr, utxo_refs) => { - $expr - .as_utxo_refs() - .ok_or(Error::ExpectedData("utxo refs".to_string(), $expr.clone())) - }; -} - -pub struct Diagnostic { - pub query: tir::InputQuery, - pub utxos: UtxoSet, - pub selected: UtxoSet, -} - -const MAX_SEARCH_SPACE_SIZE: usize = 50; - -#[derive(Debug, Clone)] -pub struct CanonicalQuery { - pub address: Option>, - pub min_amount: Option, - pub refs: HashSet, - pub support_many: bool, - pub collateral: bool, -} - -impl std::fmt::Display for CanonicalQuery { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - write!(f, "CanonicalQuery {{")?; - - if let Some(address) = &self.address { - write!(f, "address: {}", hex::encode(address))?; - } - - if let Some(min_amount) = &self.min_amount { - write!(f, "min_amount: {}", min_amount)?; - } - - for (i, ref_) in self.refs.iter().enumerate() { - write!(f, "ref[{}]:{}#{}", i, hex::encode(&ref_.txid), ref_.index)?; - } +pub async fn resolve(tx: AnyTir, utxos: &T) -> Result { + let mut queries: Vec<(String, CanonicalQuery)> = Vec::new(); - write!(f, "support_many: {:?}", self.support_many)?; - write!(f, "for_collateral: {:?}", self.collateral)?; - write!(f, "}}") + for (name, query) in tx3_tir::reduce::find_queries(&tx) { + queries.push((name, CanonicalQuery::try_from(query)?)); } -} -impl TryFrom for CanonicalQuery { - type Error = Error; + // 1. Narrow: build pool of candidate UTxOs from all queries + let pool = narrow::build_utxo_pool(utxos, &queries).await?; - fn try_from(query: tir::InputQuery) -> Result { - let address = query - .address - .as_option() - .map(|x| data_or_bail!(x, bytes)) - .transpose()? - .map(Vec::from); + // 2. Approximate: rank candidates for each query independently + let prepared = approximate::approximate_queries(&pool, queries); - let min_amount = query - .min_amount - .as_option() - .map(|x| data_or_bail!(x, assets)) - .transpose()? - .map(|x| CanonicalAssets::from(Vec::from(x))); + // 3. Assign: allocate UTxOs across all queries + let assignments = assign::assign_all(prepared); - let refs = query - .r#ref - .as_option() - .map(|x| data_or_bail!(x, utxo_refs)) - .transpose()? - .map(|x| HashSet::from_iter(x.iter().cloned())) - .unwrap_or_default(); + // 4. Validate: ensure all queries were resolved + let pool_refs: Vec = pool.keys().cloned().collect(); + let mut all_inputs = BTreeMap::new(); - Ok(Self { - address, - min_amount, - refs, - support_many: query.many, - collateral: query.collateral, - }) - } -} - -pub async fn resolve(tx: AnyTir, utxos: &T) -> Result { - let mut selector = select::InputSelector::new(utxos); - - for (name, query) in tx3_tir::reduce::find_queries(&tx) { - let query = CanonicalQuery::try_from(query)?; - selector.add(name, query).await?; + for entry in assignments { + if entry.selection.is_empty() { + return Err(Error::InputNotResolved(entry.name, entry.query, pool_refs)); + } + all_inputs.insert(entry.name, entry.selection); } - let all_inputs = selector.select_all()?; - let out = tx3_tir::reduce::apply_inputs(tx, &all_inputs)?; Ok(out) diff --git a/crates/tx3-resolver/src/inputs/narrow.rs b/crates/tx3-resolver/src/inputs/narrow.rs index 48f32797..59243147 100644 --- a/crates/tx3-resolver/src/inputs/narrow.rs +++ b/crates/tx3-resolver/src/inputs/narrow.rs @@ -1,13 +1,18 @@ -use std::collections::HashSet; +use std::collections::{HashMap, HashSet}; -use tx3_tir::model::{assets::AssetClass, core::UtxoRef}; +use tx3_tir::model::{ + assets::AssetClass, + core::{Utxo, UtxoRef}, +}; -use crate::{inputs::CanonicalQuery, UtxoPattern, UtxoStore}; +use crate::{inputs::canonical::CanonicalQuery, UtxoPattern, UtxoStore}; use super::Error; +const MAX_SEARCH_SPACE_SIZE: usize = 50; + #[derive(Debug, Clone)] -pub enum Subset { +enum Subset { NotSet, All, Specific(HashSet), @@ -74,12 +79,12 @@ impl From> for Subset { } #[derive(Debug, Clone)] -pub struct SearchSpace { - pub union: Subset, - pub intersection: Subset, - pub by_address_count: Option, - pub by_asset_class_count: Option, - pub by_ref_count: Option, +struct SearchSpace { + union: Subset, + intersection: Subset, + by_address_count: Option, + by_asset_class_count: Option, + by_ref_count: Option, } impl SearchSpace { @@ -126,7 +131,7 @@ impl SearchSpace { self.include_matches(utxos); } - pub fn take(&self, take: Option) -> HashSet { + fn take(&self, take: Option) -> HashSet { let Some(take) = take else { // if there's no limit, return everything we have return self.union.clone().into(); @@ -150,6 +155,29 @@ impl SearchSpace { } } +/// Query the UTxO store for all queries and return a shared pool of candidate +/// UTxOs. Each query's constraints are used to narrow the store, and the +/// results are merged into a single pool. +pub async fn build_utxo_pool( + store: &T, + queries: &[(String, CanonicalQuery)], +) -> Result, Error> { + let mut pool: HashMap = HashMap::new(); + + for (_name, query) in queries { + let space = narrow_search_space(store, query).await?; + let refs = space.take(Some(MAX_SEARCH_SPACE_SIZE)); + let fetched = store.fetch_utxos(refs).await?; + + for utxo in fetched.iter() { + pool.entry(utxo.r#ref.clone()) + .or_insert_with(|| utxo.clone()); + } + } + + Ok(pool) +} + async fn narrow_by_asset_class( store: &T, parent: Subset, @@ -171,7 +199,7 @@ async fn narrow_by_asset_class( Ok(Subset::intersection(parent, Subset::Specific(utxos))) } -pub async fn narrow_search_space( +async fn narrow_search_space( store: &T, criteria: &CanonicalQuery, ) -> Result { diff --git a/crates/tx3-resolver/src/inputs/select/mod.rs b/crates/tx3-resolver/src/inputs/select/mod.rs deleted file mode 100644 index 78121d69..00000000 --- a/crates/tx3-resolver/src/inputs/select/mod.rs +++ /dev/null @@ -1,195 +0,0 @@ -use std::collections::{BTreeMap, HashMap, HashSet}; - -use tx3_tir::model::{ - assets::CanonicalAssets, - core::{Utxo, UtxoRef, UtxoSet}, -}; - -use crate::{ - inputs::{narrow, CanonicalQuery}, - Error, UtxoStore, -}; - -pub mod naive; -pub mod vector; - -#[cfg(test)] -mod tests; - -pub trait CoinSelection { - fn pick_many(search_space: UtxoSet, target: &CanonicalAssets) -> UtxoSet; - fn pick_single(search_space: UtxoSet, target: &CanonicalAssets) -> UtxoSet; -} - -pub fn find_first_excess_utxo(utxos: &HashSet, target: &CanonicalAssets) -> Option { - // if there is only one utxo, then we can't remove them. This is to avoid the - // edge case where the target is empty (eg: 0 fees gas on a testnet or L2) - if utxos.len() == 1 { - return None; - } - - let available = utxos - .iter() - .fold(CanonicalAssets::empty(), |acc, x| acc + x.assets.clone()); - - let excess = available - target.clone(); - - if excess.is_empty_or_negative() { - return None; - } - - for utxo in utxos.iter() { - if excess.contains_total(&utxo.assets) { - return Some(utxo.clone()); - } - } - - None -} - -#[cfg(not(feature = "naive_selector"))] -pub type Strategy = vector::VectorSelector; - -#[cfg(feature = "naive_selector")] -pub type Strategy = naive::NaiveSelector; - -/// How tightly constrained a query is, from most to least specific. -/// Lower values get priority during selection so that the most constrained -/// queries pick UTxOs first. -#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)] -enum Specificity { - SingleCollateral = 0, - Single = 1, - ManyCollateral = 2, - Many = 3, -} - -struct PendingQuery { - name: String, - query: CanonicalQuery, - candidates: Vec, -} - -impl PendingQuery { - fn specificity(&self) -> Specificity { - match (self.query.support_many, self.query.collateral) { - (false, true) => Specificity::SingleCollateral, - (false, false) => Specificity::Single, - (true, true) => Specificity::ManyCollateral, - (true, false) => Specificity::Many, - } - } - - fn build_candidates(&mut self, pool: &HashMap) { - let target = self.query.min_amount.clone().unwrap_or(CanonicalAssets::empty()); - - let pool_set: UtxoSet = pool - .values() - .filter(|utxo| !self.query.collateral || utxo.assets.is_only_naked()) - .cloned() - .collect(); - - let ordered = vector::VectorSelector::sorted_candidates(pool_set, &target); - - self.candidates = if self.query.support_many { - ordered - .into_iter() - .filter(|utxo| utxo.assets.contains_some(&target)) - .collect() - } else { - ordered - .into_iter() - .filter(|utxo| utxo.assets.contains_total(&target)) - .collect() - }; - } - - fn constraint_tightness(&self) -> (usize, Specificity, &str) { - (self.candidates.len(), self.specificity(), &self.name) - } - - fn pick(self, used: &HashSet) -> (String, CanonicalQuery, UtxoSet) { - let available: UtxoSet = self - .candidates - .into_iter() - .filter(|utxo| !used.contains(&utxo.r#ref)) - .collect(); - - let target = self - .query - .min_amount - .clone() - .unwrap_or(CanonicalAssets::empty()); - - let selection = if self.query.support_many { - Strategy::pick_many(available, &target) - } else { - Strategy::pick_single(available, &target) - }; - - (self.name, self.query, selection) - } -} - -pub struct InputSelector<'a, S: UtxoStore> { - store: &'a S, - pending: Vec, - pool: HashMap, -} - -impl<'a, S: UtxoStore> InputSelector<'a, S> { - pub fn new(store: &'a S) -> Self { - Self { - store, - pending: Vec::new(), - pool: HashMap::new(), - } - } - - pub async fn add(&mut self, name: String, query: CanonicalQuery) -> Result<(), Error> { - let space = narrow::narrow_search_space(self.store, &query).await?; - let refs = space.take(Some(super::MAX_SEARCH_SPACE_SIZE)); - let utxos = self.store.fetch_utxos(refs).await?; - for utxo in utxos.iter() { - self.pool - .entry(utxo.r#ref.clone()) - .or_insert_with(|| utxo.clone()); - } - self.pending.push(PendingQuery { - name, - query, - candidates: Vec::new(), - }); - Ok(()) - } - - pub fn select_all(mut self) -> Result, Error> { - let pool_refs: Vec = self.pool.keys().cloned().collect(); - - for pq in &mut self.pending { - pq.build_candidates(&self.pool); - } - - self.pending - .sort_by(|a, b| a.constraint_tightness().cmp(&b.constraint_tightness())); - - let mut assignments: BTreeMap = BTreeMap::new(); - let mut used: HashSet = HashSet::new(); - - for pq in self.pending { - let (name, query, selection) = pq.pick(&used); - - if selection.is_empty() { - return Err(Error::InputNotResolved(name, query, pool_refs)); - } - - for utxo in selection.iter() { - used.insert(utxo.r#ref.clone()); - } - - assignments.insert(name, selection); - } - - Ok(assignments) - } -} diff --git a/crates/tx3-resolver/src/inputs/select/naive.rs b/crates/tx3-resolver/src/inputs/select/naive.rs deleted file mode 100644 index 13a488db..00000000 --- a/crates/tx3-resolver/src/inputs/select/naive.rs +++ /dev/null @@ -1,53 +0,0 @@ -use std::collections::HashSet; - -use tx3_tir::model::{ - assets::CanonicalAssets, - core::{Utxo, UtxoSet}, -}; - -use super::CoinSelection; - -#[allow(dead_code)] -pub struct NaiveSelector; - -impl CoinSelection for NaiveSelector { - fn pick_many(search_space: UtxoSet, target: &CanonicalAssets) -> HashSet { - let mut matched = HashSet::new(); - let mut pending = target.clone(); - - for utxo in search_space.iter() { - if utxo.assets.contains_some(&pending) { - matched.insert(utxo.clone()); - let to_include = utxo.assets.clone(); - pending = pending - to_include; - } - - if pending.is_empty_or_negative() { - // break early if we already have enough - break; - } - } - - if !pending.is_empty_or_negative() { - // if we didn't accumulate enough by the end of the search space, - // then we didn't find a match - return HashSet::new(); - } - - while let Some(utxo) = super::find_first_excess_utxo(&matched, target) { - matched.remove(&utxo); - } - - matched - } - - fn pick_single(search_space: UtxoSet, target: &CanonicalAssets) -> UtxoSet { - for utxo in search_space.iter() { - if utxo.assets.contains_total(target) { - return HashSet::from_iter(vec![utxo.clone()]); - } - } - - HashSet::new() - } -} From ddd6543c59491212a992ae522250d7fee9109ec2 Mon Sep 17 00:00:00 2001 From: Santiago Date: Wed, 1 Apr 2026 09:42:26 -0300 Subject: [PATCH 2/2] more test coverage --- .../inputs/assign/tests.txt | 8 + .../src/inputs/approximate/filter.rs | 139 ++-- .../src/inputs/approximate/rank/vector.rs | 62 +- .../tx3-resolver/src/inputs/assign/tests.rs | 705 +++++++----------- crates/tx3-resolver/src/inputs/mod.rs | 35 +- crates/tx3-resolver/src/inputs/narrow.rs | 2 +- crates/tx3-resolver/src/inputs/test_utils.rs | 366 +++++++++ crates/tx3-resolver/src/inputs/tests.rs | 398 ++++++++++ crates/tx3-resolver/src/lib.rs | 3 - crates/tx3-resolver/src/mock.rs | 178 ----- 10 files changed, 1137 insertions(+), 759 deletions(-) create mode 100644 crates/tx3-resolver/proptest-regressions/inputs/assign/tests.txt create mode 100644 crates/tx3-resolver/src/inputs/test_utils.rs create mode 100644 crates/tx3-resolver/src/inputs/tests.rs delete mode 100644 crates/tx3-resolver/src/mock.rs diff --git a/crates/tx3-resolver/proptest-regressions/inputs/assign/tests.txt b/crates/tx3-resolver/proptest-regressions/inputs/assign/tests.txt new file mode 100644 index 00000000..5d03847d --- /dev/null +++ b/crates/tx3-resolver/proptest-regressions/inputs/assign/tests.txt @@ -0,0 +1,8 @@ +# Seeds for failure cases proptest has generated in the past. It is +# automatically read and these particular cases re-run before any +# novel cases are generated. +# +# It is recommended to check this file in to source control so that +# everyone who runs the test benefits from these saved cases. +cc a6775af9bfab030aa6e7aae5c0238175422e6d923122b1ae6858a13bc7091423 # shrinks to candidates = [Utxo { ref: UtxoRef { txid: [0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0], index: 0 }, address: [0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0], assets: CanonicalAssets({Naked: 158045119345854349338856753667742279237, Defined([0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0], [0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0]): 1}), datum: None, script: None }, Utxo { ref: UtxoRef { txid: [0, 0, 88, 45, 126, 248, 129, 209, 207, 113, 154, 121, 205, 47, 1, 188, 8, 253, 50, 237, 79, 114, 50, 41, 102, 73, 59, 235, 99, 41, 168, 146], index: 10 }, address: [155, 216, 129, 71, 71, 235, 177, 80, 225, 108, 36, 166, 91, 173, 241, 127, 147, 21, 82, 166, 137, 101, 199, 208, 87, 12, 185, 84], assets: CanonicalAssets({Defined([130, 60, 189, 215, 175, 219, 53, 21, 223, 2, 29, 120, 24, 190, 152, 71, 200, 42, 127, 185, 198, 130, 138, 105, 10, 125, 199, 221, 194, 125, 60, 43], [159, 2, 246, 112, 121, 172, 24, 241, 97, 108, 105, 170, 38, 235, 73, 49]): 156103305430815218953020158121962247163, Naked: 136961490049589324451419970048729374489}), datum: None, script: None }], target = CanonicalAssets({Naked: 124865425934974442058589420000587547997, Defined([24, 61, 28, 232, 247, 16, 86, 111, 33, 170, 62, 7, 94, 152, 133, 227, 85, 217, 93, 88, 75, 149, 15, 89, 253, 58, 27, 68, 128, 191, 96, 153], [204, 233, 57, 166, 239, 74, 52, 165, 60, 30, 235, 144, 2, 214, 251, 254]): 22998913446057830998255097906875123987}) +cc 672086eafec50fb0944c345c372f78f5373dedfd80f4e6f6523b0524e2bd9082 # shrinks to candidates = [Utxo { ref: UtxoRef { txid: [0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0], index: 0 }, address: [0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0], assets: CanonicalAssets({Naked: 53562105071688137343575211766511489633, Defined([0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0], [0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0]): 1}), datum: None, script: None }, Utxo { ref: UtxoRef { txid: [0, 0, 0, 0, 144, 101, 77, 180, 66, 162, 133, 69, 232, 51, 170, 43, 211, 95, 125, 63, 80, 148, 40, 91, 143, 7, 209, 252, 179, 84, 18, 141], index: 6 }, address: [123, 214, 60, 36, 123, 125, 28, 104, 79, 205, 157, 16, 214, 149, 39, 100, 151, 64, 150, 123, 38, 63, 156, 29, 67, 131, 246, 252], assets: CanonicalAssets({Defined([144, 177, 155, 87, 91, 132, 119, 240, 242, 69, 28, 7, 75, 112, 37, 23, 195, 239, 106, 82, 153, 242, 179, 248, 46, 216, 27, 237, 119, 82, 210, 221], [43, 227, 197, 12, 219, 42, 218, 60, 99, 234, 217, 187, 243, 168, 29, 246]): 167179264436483224471461671448985802214, Naked: 49816774887369447695113900276229391867}), datum: None, script: None }, Utxo { ref: UtxoRef { txid: [144, 121, 149, 244, 152, 222, 190, 241, 75, 126, 70, 10, 108, 61, 185, 98, 114, 126, 89, 59, 107, 85, 150, 148, 234, 8, 125, 126, 45, 91, 19, 173], index: 12 }, address: [120, 177, 55, 136, 139, 159, 212, 93, 7, 181, 139, 88, 77, 151, 94, 213, 115, 175, 174, 83, 143, 240, 211, 238, 244, 132, 6, 2], assets: CanonicalAssets({Defined([0, 80, 71, 91, 147, 139, 226, 187, 104, 185, 27, 60, 220, 242, 231, 48, 229, 38, 39, 116, 162, 95, 35, 4, 87, 92, 19, 44, 24, 235, 67, 179], [1, 0, 104, 42, 229, 236, 100, 10, 119, 63, 62, 175, 44, 124, 64, 66]): 163443326384517228867264890342703697822, Naked: 162632747121480762527653746551629262682}), datum: None, script: None }], target = CanonicalAssets({Naked: 53562105071688137343575211766511489633}) diff --git a/crates/tx3-resolver/src/inputs/approximate/filter.rs b/crates/tx3-resolver/src/inputs/approximate/filter.rs index 1e52ae87..bb7a890e 100644 --- a/crates/tx3-resolver/src/inputs/approximate/filter.rs +++ b/crates/tx3-resolver/src/inputs/approximate/filter.rs @@ -50,87 +50,29 @@ pub fn matches_aggregate_constraints( mod tests { use std::collections::HashSet; - use tx3_tir::model::{ - assets::CanonicalAssets, - core::{Utxo, UtxoRef}, - }; + use proptest::prelude::*; + use tx3_tir::model::{assets::CanonicalAssets, core::UtxoRef}; - use crate::inputs::canonical::CanonicalQuery; + use crate::inputs::test_utils::{ + self, any_address, any_utxo, any_utxo_at, query, utxo, utxo_with_asset, utxo_with_ref, + }; use super::*; - fn utxo(address: &[u8], naked: i128) -> Utxo { - Utxo { - r#ref: UtxoRef { - txid: vec![0; 32], - index: 0, - }, - address: address.to_vec(), - assets: CanonicalAssets::from_naked_amount(naked), - datum: None, - script: None, - } - } - - fn utxo_with_ref(address: &[u8], naked: i128, txid: u8, index: u32) -> Utxo { - Utxo { - r#ref: UtxoRef { - txid: vec![txid; 32], - index, - }, - address: address.to_vec(), - assets: CanonicalAssets::from_naked_amount(naked), - datum: None, - script: None, - } - } - - fn utxo_with_asset(address: &[u8], naked: i128, policy: &[u8], name: &[u8], amount: i128) -> Utxo { - let assets = CanonicalAssets::from_naked_amount(naked) - + CanonicalAssets::from_asset(Some(policy), Some(name), amount); - - Utxo { - r#ref: UtxoRef { - txid: vec![0; 32], - index: 0, - }, - address: address.to_vec(), - assets, - datum: None, - script: None, - } - } - - fn query( - address: Option<&[u8]>, - min_amount: Option, - refs: HashSet, - many: bool, - collateral: bool, - ) -> CanonicalQuery { - CanonicalQuery { - address: address.map(|a| a.to_vec()), - min_amount, - refs, - support_many: many, - collateral, - } - } - - // -- hard constraints -- + // -- hard constraints: scenario tests -- #[test] fn hard_no_constraints_matches_anything() { let q = query(None, None, HashSet::new(), false, false); - let u = utxo(b"alice", 5_000_000); + let u = utxo(0, 0, b"alice", 5_000_000); assert!(matches_hard_constraints(&q, &u)); } #[test] fn hard_address_match() { let q = query(Some(b"alice"), None, HashSet::new(), false, false); - assert!(matches_hard_constraints(&q, &utxo(b"alice", 1))); - assert!(!matches_hard_constraints(&q, &utxo(b"bob", 1))); + assert!(matches_hard_constraints(&q, &utxo(0, 0, b"alice", 1))); + assert!(!matches_hard_constraints(&q, &utxo(0, 0, b"bob", 1))); } #[test] @@ -144,8 +86,8 @@ mod tests { #[test] fn hard_collateral_requires_naked() { let q = query(Some(b"alice"), None, HashSet::new(), false, true); - let naked = utxo(b"alice", 5_000_000); - let with_token = utxo_with_asset(b"alice", 5_000_000, b"policy", b"name", 100); + let naked = utxo(0, 0, b"alice", 5_000_000); + let with_token = utxo_with_asset(0, 0, b"alice", 5_000_000, b"policy", b"name", 100); assert!(matches_hard_constraints(&q, &naked)); assert!(!matches_hard_constraints(&q, &with_token)); } @@ -155,23 +97,20 @@ mod tests { let target_ref = UtxoRef { txid: vec![1; 32], index: 0 }; let q = query(Some(b"alice"), None, HashSet::from([target_ref]), false, true); - // right address, right ref, naked → pass assert!(matches_hard_constraints(&q, &utxo_with_ref(b"alice", 1, 1, 0))); - // wrong address → fail assert!(!matches_hard_constraints(&q, &utxo_with_ref(b"bob", 1, 1, 0))); - // wrong ref → fail assert!(!matches_hard_constraints(&q, &utxo_with_ref(b"alice", 1, 2, 0))); } - // -- aggregate constraints -- + // -- aggregate constraints: scenario tests -- #[test] fn aggregate_single_requires_total() { let target = CanonicalAssets::from_naked_amount(5_000_000); let q = query(None, Some(target.clone()), HashSet::new(), false, false); - let enough = utxo(b"alice", 5_000_000); - let not_enough = utxo(b"alice", 3_000_000); + let enough = utxo(0, 0, b"alice", 5_000_000); + let not_enough = utxo(0, 0, b"alice", 3_000_000); assert!(matches_aggregate_constraints(&q, &enough, &target)); assert!(!matches_aggregate_constraints(&q, ¬_enough, &target)); @@ -182,7 +121,55 @@ mod tests { let target = CanonicalAssets::from_naked_amount(5_000_000); let q = query(None, Some(target.clone()), HashSet::new(), true, false); - let partial = utxo(b"alice", 3_000_000); + let partial = utxo(0, 0, b"alice", 3_000_000); assert!(matches_aggregate_constraints(&q, &partial, &target)); } + + // -- property-based tests -- + + proptest! { + #[test] + fn hard_address_mismatch_always_rejects( + addr_a in any_address(), + addr_b in any_address(), + u in any_utxo(), + ) { + prop_assume!(addr_a != addr_b); + + let mut u = u; + u.address = addr_a.clone(); + + let q = query(Some(&addr_b), None, HashSet::new(), false, false); + prop_assert!(!matches_hard_constraints(&q, &u)); + } + + #[test] + fn hard_no_constraints_accepts_any_utxo(u in any_utxo()) { + let q = query(None, None, HashSet::new(), false, false); + prop_assert!(matches_hard_constraints(&q, &u)); + } + + #[test] + fn hard_address_match_accepts_same_address( + addr in any_address(), + u in any_utxo(), + ) { + let mut u = u; + u.address = addr.clone(); + // non-collateral so custom assets don't matter + let q = query(Some(&addr), None, HashSet::new(), false, false); + prop_assert!(matches_hard_constraints(&q, &u)); + } + + #[test] + fn aggregate_total_implies_some( + u in any_utxo(), + target in test_utils::any_composite_asset(), + ) { + // if a UTxO passes contains_total, it must also pass contains_some + if u.assets.contains_total(&target) { + prop_assert!(u.assets.contains_some(&target)); + } + } + } } diff --git a/crates/tx3-resolver/src/inputs/approximate/rank/vector.rs b/crates/tx3-resolver/src/inputs/approximate/rank/vector.rs index 36d8b4f8..b883d9cb 100644 --- a/crates/tx3-resolver/src/inputs/approximate/rank/vector.rs +++ b/crates/tx3-resolver/src/inputs/approximate/rank/vector.rs @@ -151,65 +151,9 @@ mod tests { use super::*; use proptest::prelude::*; - prop_compose! { - fn any_positive_amount() ( - amount in 1..=i128::MAX, - ) -> i128 { - amount as i128 - } - } - - prop_compose! { - fn any_policy() ( - policy in any::<[u8; 32]>(), - ) -> Vec { - Vec::from(policy) - } - } - - prop_compose! { - fn any_name() ( - name in any::<[u8; 16]>(), - ) -> Vec { - Vec::from(name) - } - } - - prop_compose! { - fn any_asset_class() ( - policy in any_policy(), - name in any_name(), - ) -> AssetClass { - AssetClass::Defined(policy, name) - } - } - - prop_compose! { - fn any_defined_asset() ( - policy in any_policy(), - name in any_name(), - amount in any_positive_amount(), - ) -> CanonicalAssets { - CanonicalAssets::from_defined_asset(&policy, &name, amount) - } - } - - prop_compose! { - fn any_naked_asset() ( - amount in any_positive_amount(), - ) -> CanonicalAssets { - CanonicalAssets::from_naked_amount(amount) - } - } - - prop_compose! { - fn any_composite_asset() ( - naked in any_naked_asset(), - defined in any_defined_asset(), - ) -> CanonicalAssets { - naked + defined - } - } + use crate::inputs::test_utils::{ + any_composite_asset, any_defined_asset, any_naked_asset, + }; proptest! { #[test] diff --git a/crates/tx3-resolver/src/inputs/assign/tests.rs b/crates/tx3-resolver/src/inputs/assign/tests.rs index 313df02a..5054ea11 100644 --- a/crates/tx3-resolver/src/inputs/assign/tests.rs +++ b/crates/tx3-resolver/src/inputs/assign/tests.rs @@ -1,481 +1,320 @@ -use chainfuzz::utxos::UtxoBuilder; -use tx3_tir::model::{assets::CanonicalAssets, core::UtxoRef, v1beta0 as tir}; +use std::collections::HashSet; -use crate::{ - inputs::{approximate, canonical::CanonicalQuery, narrow}, - mock, Error, UtxoStore, -}; +use proptest::prelude::*; +use tx3_tir::model::{assets::CanonicalAssets, core::UtxoRef}; + +use crate::inputs::test_utils::{self, utxo, utxo_with_asset}; use super::*; -pub fn new_input_query( - address: &mock::KnownAddress, - naked_amount: Option, - other_assets: Vec<(mock::KnownAsset, u64)>, - many: bool, - collateral: bool, -) -> CanonicalQuery { - let naked_asset = naked_amount.map(|x| tir::AssetExpr { - policy: tir::Expression::None, - asset_name: tir::Expression::None, - amount: tir::Expression::Number(x as i128), - }); - - let other_assets: Vec = other_assets - .into_iter() - .map(|(asset, amount)| tir::AssetExpr { - policy: tir::Expression::Bytes(asset.policy().as_slice().to_vec()), - asset_name: tir::Expression::Bytes(asset.name().to_vec()), - amount: tir::Expression::Number(amount as i128), - }) - .collect(); - - let all_assets = naked_asset.into_iter().chain(other_assets).collect(); - - tir::InputQuery { - address: tir::Expression::Address(address.to_bytes()), - min_amount: tir::Expression::Assets(all_assets), - r#ref: tir::Expression::None, - many, - collateral, - } - .try_into() - .unwrap() +// --------------------------------------------------------------------------- +// Helpers for unit tests +// --------------------------------------------------------------------------- + +fn simple_query(many: bool, collateral: bool, min_amount: Option) -> CanonicalQuery { + test_utils::query(None, min_amount, HashSet::new(), many, collateral) } -/// Run the full pipeline (narrow → approximate → assign) for a list of named -/// queries, mirroring what `inputs::resolve` does without the TIR layer. -async fn run_pipeline( - store: &S, - queries: Vec<(String, CanonicalQuery)>, -) -> Result, Error> { - let pool = narrow::build_utxo_pool(store, &queries).await?; - let prepared = approximate::approximate_queries(&pool, queries); - let assignments = assign_all(prepared); - - let pool_refs: Vec = pool.keys().cloned().collect(); - let mut result = std::collections::BTreeMap::new(); - - for entry in assignments { - if entry.selection.is_empty() { - return Err(Error::InputNotResolved(entry.name, entry.query, pool_refs)); - } - result.insert(entry.name, entry.selection); - } +fn prepared(name: &str, q: CanonicalQuery, candidates: Vec) -> PreparedQuery { + PreparedQuery { name: name.to_string(), query: q, candidates } +} - Ok(result) +// --------------------------------------------------------------------------- +// pick_single +// --------------------------------------------------------------------------- + +#[test] +fn pick_single_returns_first_sufficient_match() { + let target = CanonicalAssets::from_naked_amount(3_000_000); + let candidates = vec![ + utxo(1, 0, b"a", 2_000_000), // insufficient + utxo(2, 0, b"a", 5_000_000), // sufficient — should be picked + utxo(3, 0, b"a", 9_000_000), // also sufficient but later + ]; + + let result = pick_single(candidates, &target); + assert_eq!(result.len(), 1); + assert_eq!(result.iter().next().unwrap().r#ref.txid[0], 2); } -async fn resolve_single( - store: &S, - name: &str, - criteria: &CanonicalQuery, -) -> UtxoSet { - match run_pipeline(store, vec![(name.to_string(), criteria.clone())]).await { - Ok(selected) => selected.get(name).cloned().unwrap_or_default(), - Err(Error::InputNotResolved(..)) => UtxoSet::default(), - Err(e) => panic!("unexpected error: {e:?}"), - } +#[test] +fn pick_single_returns_empty_when_none_sufficient() { + let target = CanonicalAssets::from_naked_amount(10_000_000); + let candidates = vec![ + utxo(1, 0, b"a", 3_000_000), + utxo(2, 0, b"a", 5_000_000), + ]; + + let result = pick_single(candidates, &target); + assert!(result.is_empty()); } -#[pollster::test] -async fn test_select_by_address() { - let store = mock::seed_random_memory_store( - |_: &mock::FuzzTxoRef, x: &mock::KnownAddress, _: u64| { - mock::utxo_with_random_amount(x, 4_000_000..5_000_000) - }, - 2..4, - ); +#[test] +fn pick_single_empty_candidates() { + let target = CanonicalAssets::from_naked_amount(1); + assert!(pick_single(vec![], &target).is_empty()); +} - for subject in mock::KnownAddress::everyone() { - let criteria = new_input_query(&subject, None, vec![], false, false); +// --------------------------------------------------------------------------- +// pick_many +// --------------------------------------------------------------------------- - let utxos = resolve_single(&store, "q", &criteria).await; +#[test] +fn pick_many_accumulates_until_target_met() { + let target = CanonicalAssets::from_naked_amount(7_000_000); + let candidates = vec![ + utxo(1, 0, b"a", 3_000_000), + utxo(2, 0, b"a", 3_000_000), + utxo(3, 0, b"a", 3_000_000), + ]; - assert_eq!(utxos.len(), 1); + let result = pick_many(candidates, &target); + assert!(result.len() >= 2); - for utxo in utxos { - assert_eq!(utxo.address, subject.to_bytes()); - } - } + let total: i128 = result.iter().map(|u| u.assets.naked_amount().unwrap_or(0)).sum(); + assert!(total >= 7_000_000); } -#[pollster::test] -async fn test_input_query_too_broad() { - let store = mock::seed_random_memory_store( - |_: &mock::FuzzTxoRef, x: &mock::KnownAddress, _: u64| { - mock::utxo_with_random_amount(x, 4_000_000..5_000_000) - }, - 2..4, - ); - - let empty_criteria: CanonicalQuery = tir::InputQuery { - address: tir::Expression::None, - min_amount: tir::Expression::None, - r#ref: tir::Expression::None, - many: false, - collateral: false, - } - .try_into() - .unwrap(); +#[test] +fn pick_many_returns_empty_when_insufficient() { + let target = CanonicalAssets::from_naked_amount(100_000_000); + let candidates = vec![ + utxo(1, 0, b"a", 3_000_000), + utxo(2, 0, b"a", 3_000_000), + ]; + + let result = pick_many(candidates, &target); + assert!(result.is_empty()); +} - let result = narrow::build_utxo_pool( - &store, - &[("q".to_string(), empty_criteria)], - ) - .await; +#[test] +fn pick_many_trims_excess() { + let target = CanonicalAssets::from_naked_amount(5_000_000); + let candidates = vec![ + utxo(1, 0, b"a", 5_000_000), // alone covers target + utxo(2, 0, b"a", 3_000_000), // would be excess + utxo(3, 0, b"a", 3_000_000), // would be excess + ]; + + let result = pick_many(candidates, &target); + assert_eq!(result.len(), 1); +} - assert!(matches!(result, Err(Error::InputQueryTooBroad))); +#[test] +fn pick_many_empty_candidates() { + let target = CanonicalAssets::from_naked_amount(1); + assert!(pick_many(vec![], &target).is_empty()); } -#[pollster::test] -async fn test_select_anything() { - let store = mock::seed_random_memory_store( - |_: &mock::FuzzTxoRef, x: &mock::KnownAddress, seq: u64| { - if seq % 2 == 0 { - mock::utxo_with_random_amount(x, 4_000_000..5_000_000) - } else { - mock::utxo_with_random_asset(x, mock::KnownAsset::Hosky, 500..1000) - } - }, - 2..3, - ); +#[test] +fn pick_many_with_multi_asset_target() { + let policy = b"policy01"; + let name = b"token"; + let target = CanonicalAssets::from_naked_amount(2_000_000) + + CanonicalAssets::from_asset(Some(policy), Some(name), 100); - let criteria = new_input_query(&mock::KnownAddress::Alice, None, vec![], true, false); + let candidates = vec![ + utxo(1, 0, b"a", 3_000_000), // has ADA only + utxo_with_asset(2, 0, b"a", 1_000_000, policy, name, 100), // has token only + ]; - let utxos = resolve_single(&store, "q", &criteria).await; - assert_eq!(utxos.len(), 1); + let result = pick_many(candidates, &target); + assert_eq!(result.len(), 2); } -#[pollster::test] -async fn test_select_by_naked_amount() { - let store = mock::seed_random_memory_store( - |_: &mock::FuzzTxoRef, x: &mock::KnownAddress, _: u64| { - mock::utxo_with_random_amount(x, 4_000_000..5_000_000) - }, - 2..4, - ); - - let criteria = new_input_query( - &mock::KnownAddress::Alice, - Some(6_000_000), - vec![], - false, - false, - ); - - let utxos = resolve_single(&store, "q", &criteria).await; - assert!(utxos.is_empty()); - - let criteria = new_input_query( - &mock::KnownAddress::Alice, - Some(4_000_000), - vec![], - false, - false, - ); - - let utxos = resolve_single(&store, "q", &criteria).await; - - let match_count = dbg!(utxos.len()); - assert_eq!(match_count, 1); +// --------------------------------------------------------------------------- +// find_first_excess_utxo +// --------------------------------------------------------------------------- + +#[test] +fn excess_returns_none_for_single_utxo() { + let target = CanonicalAssets::from_naked_amount(1_000_000); + let utxos = HashSet::from([utxo(1, 0, b"a", 5_000_000)]); + assert!(find_first_excess_utxo(&utxos, &target).is_none()); } -#[pollster::test] -async fn test_select_by_asset_amount() { - let store = mock::seed_random_memory_store( - |_: &mock::FuzzTxoRef, x: &mock::KnownAddress, _: u64| { - mock::utxo_with_random_asset(x, mock::KnownAsset::Hosky, 500..1000) - }, - 2..4, - ); - - for address in mock::KnownAddress::everyone() { - let criteria = new_input_query( - &address, - None, - vec![(mock::KnownAsset::Hosky, 1001)], - false, - false, - ); - - let utxos = resolve_single(&store, "q", &criteria).await; - assert!(utxos.is_empty()); - - let criteria = new_input_query( - &address, - None, - vec![(mock::KnownAsset::Hosky, 1001)], - true, - false, - ); - - let utxos = resolve_single(&store, "q", &criteria).await; - assert!(utxos.len() > 1); - - let criteria = new_input_query( - &address, - None, - vec![(mock::KnownAsset::Hosky, 4001)], - true, - false, - ); - - let utxos = resolve_single(&store, "q", &criteria).await; - assert!(utxos.is_empty()); - - let criteria = new_input_query( - &address, - None, - vec![(mock::KnownAsset::Snek, 500)], - false, - false, - ); - - let utxos = resolve_single(&store, "q", &criteria).await; - assert!(utxos.is_empty()); - - let criteria = new_input_query( - &address, - None, - vec![(mock::KnownAsset::Hosky, 500)], - false, - false, - ); - - let utxos = resolve_single(&store, "q", &criteria).await; - assert_eq!(utxos.len(), 1); - } +#[test] +fn excess_returns_none_when_exactly_covered() { + let target = CanonicalAssets::from_naked_amount(6_000_000); + let utxos = HashSet::from([ + utxo(1, 0, b"a", 3_000_000), + utxo(2, 0, b"a", 3_000_000), + ]); + assert!(find_first_excess_utxo(&utxos, &target).is_none()); } -#[pollster::test] -async fn test_select_by_collateral() { - let store = mock::seed_random_memory_store( - |_: &mock::FuzzTxoRef, x: &mock::KnownAddress, sequence: u64| { - if sequence % 2 == 0 { - mock::utxo_with_random_amount(x, 4_000_000..5_000_000) - } else { - mock::utxo_with_random_asset(x, mock::KnownAsset::Hosky, 500..1000) - } - }, - 2..4, - ); +#[test] +fn excess_finds_removable_utxo() { + let target = CanonicalAssets::from_naked_amount(3_000_000); + let utxos = HashSet::from([ + utxo(1, 0, b"a", 5_000_000), + utxo(2, 0, b"a", 2_000_000), + ]); + // 5M + 2M = 7M, excess = 4M, utxo(2) with 2M fits in excess + let excess = find_first_excess_utxo(&utxos, &target); + assert!(excess.is_some()); +} - for address in mock::KnownAddress::everyone() { - let criteria = new_input_query(&address, Some(1_000_000), vec![], false, true); +// --------------------------------------------------------------------------- +// assign_all +// --------------------------------------------------------------------------- - let utxos = resolve_single(&store, "q", &criteria).await; +#[test] +fn assign_all_single_query_resolved() { + let q = simple_query(false, false, Some(CanonicalAssets::from_naked_amount(1_000_000))); + let candidates = vec![utxo(1, 0, b"a", 5_000_000)]; - assert_eq!(utxos.len(), 1); - let utxo = utxos.iter().next().unwrap(); - assert_eq!(utxo.assets.keys().len(), 1); - assert_eq!(utxo.assets.keys().next().unwrap().is_naked(), true); - } + let result = assign_all(vec![prepared("input", q, candidates)]); + assert_eq!(result.len(), 1); + assert_eq!(result[0].selection.len(), 1); } -#[pollster::test] -async fn test_assign_same_collateral_and_input() { - let store = mock::seed_random_memory_store( - |_: &mock::FuzzTxoRef, x: &mock::KnownAddress, _: u64| { - mock::utxo_with_random_amount(x, 4_000_000..5_000_000) - }, - 1..2, - ); - - for address in mock::KnownAddress::everyone() { - let input_query = new_input_query(&address, Some(1_000_000), vec![], false, false); - let collateral_query = new_input_query(&address, Some(1_000_000), vec![], false, true); - - let result = run_pipeline( - &store, - vec![ - ("input".to_string(), input_query), - ("collateral".to_string(), collateral_query), - ], - ) - .await; - - assert!(result.is_err()); - } -} +#[test] +fn assign_all_single_query_unresolved() { + let q = simple_query(false, false, Some(CanonicalAssets::from_naked_amount(10_000_000))); + let candidates = vec![utxo(1, 0, b"a", 5_000_000)]; // insufficient -#[pollster::test] -async fn test_assign_all_exclusive_assignments() { - let store = mock::seed_random_memory_store( - |_: &mock::FuzzTxoRef, x: &mock::KnownAddress, seq: u64| { - if seq % 2 == 0 { - mock::utxo_with_random_amount(x, 1_000..1_500) - } else { - mock::utxo_with_random_amount(x, 100..150) - } - }, - 2..3, - ); - - for address in mock::KnownAddress::everyone() { - let large_query = new_input_query(&address, Some(1_000), vec![], false, false); - let small_query = new_input_query(&address, Some(100), vec![], false, false); - - let selected = run_pipeline( - &store, - vec![ - ("large".to_string(), large_query), - ("small".to_string(), small_query), - ], - ) - .await - .unwrap(); - - let large_utxos = selected.get("large").cloned().unwrap_or_default(); - let small_utxos = selected.get("small").cloned().unwrap_or_default(); - - assert_eq!(large_utxos.len(), 1); - assert_eq!(small_utxos.len(), 1); - assert!(large_utxos.is_disjoint(&small_utxos)); - } + let result = assign_all(vec![prepared("input", q, candidates)]); + assert_eq!(result.len(), 1); + assert!(result[0].selection.is_empty()); } -#[pollster::test] -async fn test_assign_all_competing_queries() { - let store = mock::seed_random_memory_store( - |_: &mock::FuzzTxoRef, x: &mock::KnownAddress, seq: u64| { - if seq % 2 == 0 { - UtxoBuilder::new() - .with_address(x) - .with_naked_value(4_000_000) - .with_random_asset(mock::KnownAsset::Hosky, 500..501) - .build() - } else { - UtxoBuilder::new() - .with_address(x) - .with_naked_value(4_000_000) - .with_random_asset(mock::KnownAsset::Snek, 500..501) - .build() - } - }, - 2..3, - ); - - let address = mock::KnownAddress::Alice; - let asset_query = - new_input_query(&address, None, vec![(mock::KnownAsset::Hosky, 1)], false, false); - let naked_query = new_input_query(&address, Some(1), vec![], false, false); - - let selected = run_pipeline( - &store, - vec![ - ("asset".to_string(), asset_query), - ("naked".to_string(), naked_query), - ], - ) - .await - .unwrap(); - - let asset_utxos = selected.get("asset").cloned().unwrap_or_default(); - let naked_utxos = selected.get("naked").cloned().unwrap_or_default(); - - assert_eq!(asset_utxos.len(), 1); - assert_eq!(naked_utxos.len(), 1); - assert!(asset_utxos.is_disjoint(&naked_utxos)); - - let target_asset = CanonicalAssets::from_asset( - Some(mock::KnownAsset::Hosky.policy().as_ref()), - Some(mock::KnownAsset::Hosky.name().as_ref()), - 1, - ); - - let asset_utxo = asset_utxos.iter().next().unwrap(); - assert!(asset_utxo.assets.contains_total(&target_asset)); +#[test] +fn assign_all_tighter_query_picks_first() { + // "tight" has 1 candidate, "loose" has 2 — tight should pick first + let u1 = utxo(1, 0, b"a", 5_000_000); + let u2 = utxo(2, 0, b"a", 5_000_000); + let target = CanonicalAssets::from_naked_amount(1_000_000); + + let tight = prepared("tight", simple_query(false, false, Some(target.clone())), vec![u1.clone()]); + let loose = prepared("loose", simple_query(false, false, Some(target)), vec![u1, u2]); + + let result = assign_all(vec![loose, tight]); // order shouldn't matter + let by_name: std::collections::HashMap<_, _> = + result.iter().map(|a| (a.name.as_str(), &a.selection)).collect(); + + assert_eq!(by_name["tight"].len(), 1); + assert_eq!(by_name["loose"].len(), 1); + // tight got u1, loose got u2 (the only remaining) + assert!(by_name["tight"].is_disjoint(by_name["loose"])); } -#[pollster::test] -async fn test_assign_all_competing_queries_no_solution() { - let store = mock::seed_random_memory_store( - |_: &mock::FuzzTxoRef, x: &mock::KnownAddress, _seq: u64| { - UtxoBuilder::new() - .with_address(x) - .with_naked_value(4_000_000) - .with_random_asset(mock::KnownAsset::Hosky, 500..501) - .build() - }, - 1..2, - ); - - let address = mock::KnownAddress::Alice; - let query_a = - new_input_query(&address, None, vec![(mock::KnownAsset::Hosky, 1)], false, false); - let query_b = - new_input_query(&address, None, vec![(mock::KnownAsset::Hosky, 1)], false, false); - - let result = run_pipeline( - &store, - vec![ - ("a".to_string(), query_a), - ("b".to_string(), query_b), - ], - ) - .await; - - assert!(result.is_err()); +#[test] +fn assign_all_exclusivity_second_query_fails_when_utxo_taken() { + let u1 = utxo(1, 0, b"a", 5_000_000); + let target = CanonicalAssets::from_naked_amount(1_000_000); + + let q1 = prepared("a", simple_query(false, false, Some(target.clone())), vec![u1.clone()]); + let q2 = prepared("b", simple_query(false, false, Some(target)), vec![u1]); // same sole candidate + + let result = assign_all(vec![q1, q2]); + let resolved: Vec<_> = result.iter().filter(|a| !a.selection.is_empty()).collect(); + let unresolved: Vec<_> = result.iter().filter(|a| a.selection.is_empty()).collect(); + + assert_eq!(resolved.len(), 1); + assert_eq!(unresolved.len(), 1); } -#[pollster::test] -async fn test_assign_by_naked_and_asset_amount() { - let store = mock::seed_random_memory_store( - |_: &mock::FuzzTxoRef, x: &mock::KnownAddress, sequence: u64| { - if sequence % 2 == 0 { - mock::utxo_with_random_amount(x, 4_000_000..5_000_000) - } else { - mock::utxo_with_random_asset(x, mock::KnownAsset::Hosky, 500..1000) - } - }, - 2..3, - ); +#[test] +fn assign_all_collateral_has_priority_over_regular() { + // Both want the same UTxO, collateral should win (higher priority) + let u1 = utxo(1, 0, b"a", 5_000_000); + let target = CanonicalAssets::from_naked_amount(1_000_000); - let criteria = new_input_query( - &mock::KnownAddress::Alice, - Some(4_000_000), - vec![(mock::KnownAsset::Hosky, 500)], - true, - false, - ); + let regular = prepared("regular", simple_query(false, false, Some(target.clone())), vec![u1.clone()]); + let collateral = prepared("collateral", simple_query(false, true, Some(target)), vec![u1]); - let utxos = resolve_single(&store, "q", &criteria).await; + let result = assign_all(vec![regular, collateral]); + let by_name: std::collections::HashMap<_, _> = + result.iter().map(|a| (a.name.as_str(), &a.selection)).collect(); - assert!(utxos.len() == 2); + assert_eq!(by_name["collateral"].len(), 1); + assert!(by_name["regular"].is_empty()); } -#[pollster::test] -async fn test_cross_query_pool_doesnt_leak_wrong_address() { - let store = mock::seed_random_memory_store( - |_: &mock::FuzzTxoRef, x: &mock::KnownAddress, seq: u64| { - if x.to_bytes() == mock::KnownAddress::Bob.to_bytes() && seq % 2 == 0 { - UtxoBuilder::new() - .with_address(x) - .with_naked_value(4_000_000) - .with_random_asset(mock::KnownAsset::Hosky, 1..2) - .build() - } else { - mock::utxo_with_random_amount(x, 4_000_000..5_000_000) +#[test] +fn assign_all_many_query_accumulates() { + let target = CanonicalAssets::from_naked_amount(8_000_000); + let candidates = vec![ + utxo(1, 0, b"a", 3_000_000), + utxo(2, 0, b"a", 3_000_000), + utxo(3, 0, b"a", 3_000_000), + ]; + + let result = assign_all(vec![prepared("input", simple_query(true, false, Some(target)), candidates)]); + assert_eq!(result.len(), 1); + assert!(result[0].selection.len() >= 2); +} + +// --------------------------------------------------------------------------- +// Property-based tests +// --------------------------------------------------------------------------- + +proptest! { + #[test] + fn pick_single_returns_at_most_one( + candidates in proptest::collection::vec(test_utils::any_utxo(), 0..10), + target in test_utils::any_composite_asset(), + ) { + let result = pick_single(candidates, &target); + prop_assert!(result.len() <= 1); + } + + #[test] + fn pick_single_result_covers_target( + candidates in proptest::collection::vec(test_utils::any_utxo(), 0..10), + target in test_utils::any_composite_asset(), + ) { + let result = pick_single(candidates, &target); + if let Some(selected) = result.iter().next() { + prop_assert!(selected.assets.contains_total(&target)); + } + } + + #[test] + fn pick_many_result_covers_target( + candidates in proptest::collection::vec(test_utils::any_utxo(), 0..10), + target in test_utils::any_composite_asset(), + ) { + let result = pick_many(candidates, &target); + if !result.is_empty() { + let total = result + .iter() + .fold(CanonicalAssets::empty(), |acc, u| acc + u.assets.clone()); + prop_assert!(total.contains_total(&target)); + } + } + + #[test] + fn assign_all_preserves_exclusivity( + candidates in proptest::collection::vec(test_utils::any_utxo(), 1..8), + target in test_utils::any_naked_asset(), + ) { + let q1 = prepared("a", simple_query(false, false, Some(target.clone())), candidates.clone()); + let q2 = prepared("b", simple_query(false, false, Some(target)), candidates); + + let result = assign_all(vec![q1, q2]); + + let mut all_refs: Vec = Vec::new(); + for a in &result { + for u in a.selection.iter() { + all_refs.push(u.r#ref.clone()); } - }, - 2..3, - ); - - let addr_a = mock::KnownAddress::Alice; - let addr_b = mock::KnownAddress::Bob; - - let query_a = new_input_query(&addr_a, None, vec![(mock::KnownAsset::Hosky, 1)], false, false); - let query_b = new_input_query(&addr_b, Some(1_000_000), vec![], false, false); - - let result = run_pipeline( - &store, - vec![ - ("a".to_string(), query_a), - ("b".to_string(), query_b), - ], - ) - .await; - - assert!(result.is_err()); + } + + let unique: HashSet<_> = all_refs.iter().collect(); + prop_assert_eq!(all_refs.len(), unique.len(), "UTxO refs must be exclusive across assignments"); + } + + #[test] + fn assign_all_returns_entry_per_query( + candidates in proptest::collection::vec(test_utils::any_utxo(), 0..5), + target in test_utils::any_naked_asset(), + ) { + let q1 = prepared("a", simple_query(false, false, Some(target.clone())), candidates.clone()); + let q2 = prepared("b", simple_query(true, false, Some(target)), candidates); + + let result = assign_all(vec![q1, q2]); + prop_assert_eq!(result.len(), 2, "every query gets an entry"); + } } diff --git a/crates/tx3-resolver/src/inputs/mod.rs b/crates/tx3-resolver/src/inputs/mod.rs index b3e8ac7f..25d0a877 100644 --- a/crates/tx3-resolver/src/inputs/mod.rs +++ b/crates/tx3-resolver/src/inputs/mod.rs @@ -8,24 +8,28 @@ use std::collections::BTreeMap; use tx3_tir::encoding::AnyTir; -use tx3_tir::model::core::UtxoRef; +use tx3_tir::model::core::{UtxoRef, UtxoSet}; use crate::{Error, UtxoStore}; mod approximate; -mod assign; +pub(crate) mod assign; mod canonical; mod narrow; -pub use canonical::CanonicalQuery; +#[cfg(test)] +pub(crate) mod test_utils; +#[cfg(test)] +mod tests; -pub async fn resolve(tx: AnyTir, utxos: &T) -> Result { - let mut queries: Vec<(String, CanonicalQuery)> = Vec::new(); - - for (name, query) in tx3_tir::reduce::find_queries(&tx) { - queries.push((name, CanonicalQuery::try_from(query)?)); - } +pub use canonical::CanonicalQuery; +/// Resolve input queries against a UTxO store, returning a map of +/// query name → selected UTxOs. +pub async fn resolve_queries( + utxos: &T, + queries: Vec<(String, CanonicalQuery)>, +) -> Result, Error> { // 1. Narrow: build pool of candidate UTxOs from all queries let pool = narrow::build_utxo_pool(utxos, &queries).await?; @@ -46,6 +50,19 @@ pub async fn resolve(tx: AnyTir, utxos: &T) -> Result(tx: AnyTir, utxos: &T) -> Result { + let mut queries: Vec<(String, CanonicalQuery)> = Vec::new(); + + for (name, query) in tx3_tir::reduce::find_queries(&tx) { + queries.push((name, CanonicalQuery::try_from(query)?)); + } + + let all_inputs = resolve_queries(utxos, queries).await?; + let out = tx3_tir::reduce::apply_inputs(tx, &all_inputs)?; Ok(out) diff --git a/crates/tx3-resolver/src/inputs/narrow.rs b/crates/tx3-resolver/src/inputs/narrow.rs index 59243147..7ff69024 100644 --- a/crates/tx3-resolver/src/inputs/narrow.rs +++ b/crates/tx3-resolver/src/inputs/narrow.rs @@ -243,7 +243,7 @@ mod tests { use super::*; - use crate::mock; + use crate::inputs::test_utils as mock; fn assets_for(asset: mock::KnownAsset, amount: i128) -> CanonicalAssets { CanonicalAssets::from_asset( diff --git a/crates/tx3-resolver/src/inputs/test_utils.rs b/crates/tx3-resolver/src/inputs/test_utils.rs new file mode 100644 index 00000000..a4ec3554 --- /dev/null +++ b/crates/tx3-resolver/src/inputs/test_utils.rs @@ -0,0 +1,366 @@ +//! Shared test utilities for the inputs module: proptest strategies and builders. + +use std::collections::HashSet; + +use proptest::prelude::*; +use tx3_tir::model::{ + assets::{AssetClass, CanonicalAssets}, + core::{Utxo, UtxoRef}, +}; + +use super::canonical::CanonicalQuery; + +// --------------------------------------------------------------------------- +// proptest strategies — primitives +// --------------------------------------------------------------------------- + +prop_compose! { + /// Generates a positive asset amount in a realistic range. + /// Capped to avoid overflow in arithmetic operations (e.g. subtraction). + pub fn any_positive_amount()(amount in 1..=i64::MAX as i128) -> i128 { + amount + } +} + +prop_compose! { + pub fn any_policy()(policy in any::<[u8; 32]>()) -> Vec { + Vec::from(policy) + } +} + +prop_compose! { + pub fn any_asset_name()(name in any::<[u8; 16]>()) -> Vec { + Vec::from(name) + } +} + +prop_compose! { + pub fn any_address()(addr in any::<[u8; 28]>()) -> Vec { + Vec::from(addr) + } +} + +prop_compose! { + pub fn any_utxo_ref()(txid in any::<[u8; 32]>(), index in 0u32..16) -> UtxoRef { + UtxoRef { txid: Vec::from(txid), index } + } +} + +// --------------------------------------------------------------------------- +// proptest strategies — assets +// --------------------------------------------------------------------------- + +prop_compose! { + pub fn any_asset_class()(policy in any_policy(), name in any_asset_name()) -> AssetClass { + AssetClass::Defined(policy, name) + } +} + +prop_compose! { + pub fn any_naked_asset()(amount in any_positive_amount()) -> CanonicalAssets { + CanonicalAssets::from_naked_amount(amount) + } +} + +prop_compose! { + pub fn any_defined_asset()( + policy in any_policy(), + name in any_asset_name(), + amount in any_positive_amount(), + ) -> CanonicalAssets { + CanonicalAssets::from_defined_asset(&policy, &name, amount) + } +} + +prop_compose! { + pub fn any_composite_asset()( + naked in any_naked_asset(), + defined in any_defined_asset(), + ) -> CanonicalAssets { + naked + defined + } +} + +// --------------------------------------------------------------------------- +// proptest strategies — UTxOs +// --------------------------------------------------------------------------- + +prop_compose! { + pub fn any_utxo()( + r#ref in any_utxo_ref(), + address in any_address(), + assets in any_composite_asset(), + ) -> Utxo { + Utxo { r#ref, address, assets, datum: None, script: None } + } +} + +prop_compose! { + pub fn any_utxo_at(address: Vec)( + r#ref in any_utxo_ref(), + assets in any_composite_asset(), + ) -> Utxo { + Utxo { r#ref, address: address.clone(), assets, datum: None, script: None } + } +} + +prop_compose! { + pub fn any_utxo_with(assets: CanonicalAssets)( + r#ref in any_utxo_ref(), + address in any_address(), + ) -> Utxo { + Utxo { r#ref, address, assets: assets.clone(), datum: None, script: None } + } +} + +// --------------------------------------------------------------------------- +// proptest strategies — queries +// --------------------------------------------------------------------------- + +prop_compose! { + pub fn any_query()( + address in proptest::option::of(any_address()), + min_amount in proptest::option::of(any_composite_asset()), + many in any::(), + collateral in any::(), + ) -> CanonicalQuery { + CanonicalQuery { + address, + min_amount, + refs: HashSet::new(), + support_many: many, + collateral, + } + } +} + +// --------------------------------------------------------------------------- +// Builders for hand-crafted scenarios +// --------------------------------------------------------------------------- + +pub fn utxo(txid: u8, index: u32, address: &[u8], naked: i128) -> Utxo { + Utxo { + r#ref: UtxoRef { + txid: vec![txid; 32], + index, + }, + address: address.to_vec(), + assets: CanonicalAssets::from_naked_amount(naked), + datum: None, + script: None, + } +} + +pub fn utxo_with_ref(address: &[u8], naked: i128, txid: u8, index: u32) -> Utxo { + utxo(txid, index, address, naked) +} + +pub fn utxo_with_asset( + txid: u8, + index: u32, + address: &[u8], + naked: i128, + policy: &[u8], + name: &[u8], + amount: i128, +) -> Utxo { + Utxo { + r#ref: UtxoRef { + txid: vec![txid; 32], + index, + }, + address: address.to_vec(), + assets: CanonicalAssets::from_naked_amount(naked) + + CanonicalAssets::from_asset(Some(policy), Some(name), amount), + datum: None, + script: None, + } +} + +pub fn query( + address: Option<&[u8]>, + min_amount: Option, + refs: HashSet, + many: bool, + collateral: bool, +) -> CanonicalQuery { + CanonicalQuery { + address: address.map(|a| a.to_vec()), + min_amount, + refs, + support_many: many, + collateral, + } +} + +// --------------------------------------------------------------------------- +// Mock UTxO store +// --------------------------------------------------------------------------- + +use std::ops::{Deref, DerefMut, Range}; + +use crate::{Error, UtxoPattern, UtxoStore}; + +pub use chainfuzz::addresses::KnownAddress; +pub use chainfuzz::assets::KnownAsset; +pub use chainfuzz::utxos::UtxoMap; +pub use chainfuzz::utxos::{utxo_with_random_amount, utxo_with_random_asset, UtxoGenerator}; +pub use chainfuzz::{TxoRef as FuzzTxoRef, Utxo as FuzzUtxo}; + +fn from_fuzz_utxo(txo: &chainfuzz::TxoRef, fuzz_utxo: &chainfuzz::Utxo) -> Utxo { + let address = fuzz_utxo.address.to_vec(); + + let assets: CanonicalAssets = fuzz_utxo + .assets + .iter() + .map(|x| { + let policy = x.class.policy; + let name = x.class.name.as_slice(); + let amount = x.amount; + + CanonicalAssets::from_asset(Some(policy.as_slice()), Some(name), amount as i128) + }) + .fold(CanonicalAssets::empty(), |acc, x| acc + x); + + let assets = assets + CanonicalAssets::from_naked_amount(fuzz_utxo.naked_value as i128); + + Utxo { + address, + assets, + r#ref: UtxoRef { + txid: txo.tx_hash.to_vec(), + index: txo.ordinal as u32, + }, + datum: None, + script: None, + } +} + +pub struct MockStore { + utxos: chainfuzz::UtxoMap, +} + +impl Default for MockStore { + fn default() -> Self { + Self { + utxos: UtxoMap::new(), + } + } +} + +impl From for MockStore { + fn from(utxos: chainfuzz::UtxoMap) -> Self { + Self { utxos } + } +} + +impl Deref for MockStore { + type Target = chainfuzz::UtxoMap; + + fn deref(&self) -> &Self::Target { + &self.utxos + } +} + +impl DerefMut for MockStore { + fn deref_mut(&mut self) -> &mut Self::Target { + &mut self.utxos + } +} + +impl UtxoStore for MockStore { + async fn narrow_refs(&self, pattern: UtxoPattern<'_>) -> Result, Error> { + match pattern { + UtxoPattern::ByAddress(address) => { + let address = chainfuzz::Address::try_from(address).unwrap(); + + let narrow = self + .utxos + .find_by_address(&address) + .map(|(x, _)| x) + .map(|x| UtxoRef { + txid: x.tx_hash.to_vec(), + index: x.ordinal as u32, + }); + + Ok(HashSet::from_iter(narrow)) + } + UtxoPattern::ByAssetPolicy(policy) => { + let policy = chainfuzz::AssetPolicy::try_from(policy).unwrap(); + + let narrow = self + .utxos + .find_by_asset_policy(&policy) + .map(|(x, _)| x) + .map(|x| UtxoRef { + txid: x.tx_hash.to_vec(), + index: x.ordinal as u32, + }); + + Ok(HashSet::from_iter(narrow)) + } + UtxoPattern::ByAsset(policy, name) => { + let policy = chainfuzz::AssetPolicy::try_from(policy).unwrap(); + let name = chainfuzz::AssetName::try_from(name).unwrap(); + let asset_class = chainfuzz::AssetClass::new(policy, name); + + let narrow = self + .utxos + .find_by_asset_class(&asset_class) + .map(|(x, _)| x) + .map(|x| UtxoRef { + txid: x.tx_hash.to_vec(), + index: x.ordinal as u32, + }); + + Ok(HashSet::from_iter(narrow)) + } + } + } + + async fn fetch_utxos( + &self, + refs: HashSet, + ) -> Result { + let utxos = refs + .iter() + .map(|txo| { + let tx_hash = chainfuzz::TxHash::try_from(txo.txid.as_slice()).unwrap(); + let ordinal = txo.index as u16; + chainfuzz::TxoRef::new(tx_hash, ordinal) + }) + .map(|txo| (txo, self.utxos.get(&txo).unwrap())) + .map(|(txo, fuzz_utxo)| from_fuzz_utxo(&txo, &fuzz_utxo)) + .collect::>(); + + Ok(utxos.into_iter().collect()) + } +} + +impl MockStore { + pub async fn by_known_address(&self, address: &KnownAddress) -> HashSet { + let bytes = address.to_bytes(); + let pattern = UtxoPattern::ByAddress(bytes.as_slice()); + + self.narrow_refs(pattern).await.unwrap() + } + + pub async fn by_known_asset(&self, asset: &KnownAsset) -> HashSet { + let policy = asset.policy(); + let name = asset.name(); + let pattern = UtxoPattern::ByAsset(policy.as_ref(), name.as_ref()); + + self.narrow_refs(pattern).await.unwrap() + } +} + +pub fn seed_random_memory_store( + f: G, + utxos_per_address: Range, +) -> MockStore { + let everyone = KnownAddress::everyone(); + + let map = chainfuzz::utxos::make_custom_utxo_map(everyone, utxos_per_address, f); + + MockStore { utxos: map } +} diff --git a/crates/tx3-resolver/src/inputs/tests.rs b/crates/tx3-resolver/src/inputs/tests.rs new file mode 100644 index 00000000..81e3e0e3 --- /dev/null +++ b/crates/tx3-resolver/src/inputs/tests.rs @@ -0,0 +1,398 @@ +//! Integration tests for the full input resolution pipeline +//! (narrow → approximate → assign). + +use chainfuzz::utxos::UtxoBuilder; +use tx3_tir::model::{assets::CanonicalAssets, core::UtxoSet, v1beta0 as tir}; + +use crate::{ + inputs::{canonical::CanonicalQuery, resolve_queries, test_utils as mock}, + Error, UtxoStore, +}; + +fn new_input_query( + address: &mock::KnownAddress, + naked_amount: Option, + other_assets: Vec<(mock::KnownAsset, u64)>, + many: bool, + collateral: bool, +) -> CanonicalQuery { + let naked_asset = naked_amount.map(|x| tir::AssetExpr { + policy: tir::Expression::None, + asset_name: tir::Expression::None, + amount: tir::Expression::Number(x as i128), + }); + + let other_assets: Vec = other_assets + .into_iter() + .map(|(asset, amount)| tir::AssetExpr { + policy: tir::Expression::Bytes(asset.policy().as_slice().to_vec()), + asset_name: tir::Expression::Bytes(asset.name().to_vec()), + amount: tir::Expression::Number(amount as i128), + }) + .collect(); + + let all_assets = naked_asset.into_iter().chain(other_assets).collect(); + + tir::InputQuery { + address: tir::Expression::Address(address.to_bytes()), + min_amount: tir::Expression::Assets(all_assets), + r#ref: tir::Expression::None, + many, + collateral, + } + .try_into() + .unwrap() +} + +async fn resolve_single( + store: &S, + name: &str, + criteria: &CanonicalQuery, +) -> UtxoSet { + match resolve_queries(store, vec![(name.to_string(), criteria.clone())]).await { + Ok(selected) => selected.get(name).cloned().unwrap_or_default(), + Err(Error::InputNotResolved(..)) => UtxoSet::default(), + Err(e) => panic!("unexpected error: {e:?}"), + } +} + +#[pollster::test] +async fn test_resolve_by_address() { + let store = mock::seed_random_memory_store( + |_: &mock::FuzzTxoRef, x: &mock::KnownAddress, _: u64| { + mock::utxo_with_random_amount(x, 4_000_000..5_000_000) + }, + 2..4, + ); + + for subject in mock::KnownAddress::everyone() { + let criteria = new_input_query(&subject, None, vec![], false, false); + let utxos = resolve_single(&store, "q", &criteria).await; + + assert_eq!(utxos.len(), 1); + for utxo in utxos { + assert_eq!(utxo.address, subject.to_bytes()); + } + } +} + +#[pollster::test] +async fn test_input_query_too_broad() { + let store = mock::seed_random_memory_store( + |_: &mock::FuzzTxoRef, x: &mock::KnownAddress, _: u64| { + mock::utxo_with_random_amount(x, 4_000_000..5_000_000) + }, + 2..4, + ); + + let empty_criteria: CanonicalQuery = tir::InputQuery { + address: tir::Expression::None, + min_amount: tir::Expression::None, + r#ref: tir::Expression::None, + many: false, + collateral: false, + } + .try_into() + .unwrap(); + + let result = + resolve_queries(&store, vec![("q".to_string(), empty_criteria)]).await; + + assert!(matches!(result, Err(Error::InputQueryTooBroad))); +} + +#[pollster::test] +async fn test_resolve_anything() { + let store = mock::seed_random_memory_store( + |_: &mock::FuzzTxoRef, x: &mock::KnownAddress, seq: u64| { + if seq % 2 == 0 { + mock::utxo_with_random_amount(x, 4_000_000..5_000_000) + } else { + mock::utxo_with_random_asset(x, mock::KnownAsset::Hosky, 500..1000) + } + }, + 2..3, + ); + + let criteria = new_input_query(&mock::KnownAddress::Alice, None, vec![], true, false); + let utxos = resolve_single(&store, "q", &criteria).await; + assert_eq!(utxos.len(), 1); +} + +#[pollster::test] +async fn test_resolve_by_naked_amount() { + let store = mock::seed_random_memory_store( + |_: &mock::FuzzTxoRef, x: &mock::KnownAddress, _: u64| { + mock::utxo_with_random_amount(x, 4_000_000..5_000_000) + }, + 2..4, + ); + + // too much — no single UTxO covers it + let criteria = new_input_query( + &mock::KnownAddress::Alice, Some(6_000_000), vec![], false, false, + ); + let utxos = resolve_single(&store, "q", &criteria).await; + assert!(utxos.is_empty()); + + // within range + let criteria = new_input_query( + &mock::KnownAddress::Alice, Some(4_000_000), vec![], false, false, + ); + let utxos = resolve_single(&store, "q", &criteria).await; + assert_eq!(dbg!(utxos.len()), 1); +} + +#[pollster::test] +async fn test_resolve_by_asset_amount() { + let store = mock::seed_random_memory_store( + |_: &mock::FuzzTxoRef, x: &mock::KnownAddress, _: u64| { + mock::utxo_with_random_asset(x, mock::KnownAsset::Hosky, 500..1000) + }, + 2..4, + ); + + for address in mock::KnownAddress::everyone() { + // single, too much + let criteria = new_input_query(&address, None, vec![(mock::KnownAsset::Hosky, 1001)], false, false); + assert!(resolve_single(&store, "q", &criteria).await.is_empty()); + + // many, accumulates + let criteria = new_input_query(&address, None, vec![(mock::KnownAsset::Hosky, 1001)], true, false); + assert!(resolve_single(&store, "q", &criteria).await.len() > 1); + + // many, still not enough + let criteria = new_input_query(&address, None, vec![(mock::KnownAsset::Hosky, 4001)], true, false); + assert!(resolve_single(&store, "q", &criteria).await.is_empty()); + + // wrong asset + let criteria = new_input_query(&address, None, vec![(mock::KnownAsset::Snek, 500)], false, false); + assert!(resolve_single(&store, "q", &criteria).await.is_empty()); + + // right asset, within range + let criteria = new_input_query(&address, None, vec![(mock::KnownAsset::Hosky, 500)], false, false); + assert_eq!(resolve_single(&store, "q", &criteria).await.len(), 1); + } +} + +#[pollster::test] +async fn test_resolve_by_collateral() { + let store = mock::seed_random_memory_store( + |_: &mock::FuzzTxoRef, x: &mock::KnownAddress, sequence: u64| { + if sequence % 2 == 0 { + mock::utxo_with_random_amount(x, 4_000_000..5_000_000) + } else { + mock::utxo_with_random_asset(x, mock::KnownAsset::Hosky, 500..1000) + } + }, + 2..4, + ); + + for address in mock::KnownAddress::everyone() { + let criteria = new_input_query(&address, Some(1_000_000), vec![], false, true); + let utxos = resolve_single(&store, "q", &criteria).await; + + assert_eq!(utxos.len(), 1); + let utxo = utxos.iter().next().unwrap(); + assert_eq!(utxo.assets.keys().len(), 1); + assert!(utxo.assets.keys().next().unwrap().is_naked()); + } +} + +#[pollster::test] +async fn test_resolve_same_collateral_and_input() { + let store = mock::seed_random_memory_store( + |_: &mock::FuzzTxoRef, x: &mock::KnownAddress, _: u64| { + mock::utxo_with_random_amount(x, 4_000_000..5_000_000) + }, + 1..2, + ); + + for address in mock::KnownAddress::everyone() { + let input_query = new_input_query(&address, Some(1_000_000), vec![], false, false); + let collateral_query = new_input_query(&address, Some(1_000_000), vec![], false, true); + + let result = resolve_queries( + &store, + vec![ + ("input".to_string(), input_query), + ("collateral".to_string(), collateral_query), + ], + ) + .await; + + assert!(result.is_err()); + } +} + +#[pollster::test] +async fn test_resolve_exclusive_assignments() { + let store = mock::seed_random_memory_store( + |_: &mock::FuzzTxoRef, x: &mock::KnownAddress, seq: u64| { + if seq % 2 == 0 { + mock::utxo_with_random_amount(x, 1_000..1_500) + } else { + mock::utxo_with_random_amount(x, 100..150) + } + }, + 2..3, + ); + + for address in mock::KnownAddress::everyone() { + let large_query = new_input_query(&address, Some(1_000), vec![], false, false); + let small_query = new_input_query(&address, Some(100), vec![], false, false); + + let selected = resolve_queries( + &store, + vec![ + ("large".to_string(), large_query), + ("small".to_string(), small_query), + ], + ) + .await + .unwrap(); + + let large_utxos = selected.get("large").cloned().unwrap_or_default(); + let small_utxos = selected.get("small").cloned().unwrap_or_default(); + + assert_eq!(large_utxos.len(), 1); + assert_eq!(small_utxos.len(), 1); + assert!(large_utxos.is_disjoint(&small_utxos)); + } +} + +#[pollster::test] +async fn test_resolve_competing_queries() { + let store = mock::seed_random_memory_store( + |_: &mock::FuzzTxoRef, x: &mock::KnownAddress, seq: u64| { + if seq % 2 == 0 { + UtxoBuilder::new() + .with_address(x) + .with_naked_value(4_000_000) + .with_random_asset(mock::KnownAsset::Hosky, 500..501) + .build() + } else { + UtxoBuilder::new() + .with_address(x) + .with_naked_value(4_000_000) + .with_random_asset(mock::KnownAsset::Snek, 500..501) + .build() + } + }, + 2..3, + ); + + let address = mock::KnownAddress::Alice; + let asset_query = + new_input_query(&address, None, vec![(mock::KnownAsset::Hosky, 1)], false, false); + let naked_query = new_input_query(&address, Some(1), vec![], false, false); + + let selected = resolve_queries( + &store, + vec![ + ("asset".to_string(), asset_query), + ("naked".to_string(), naked_query), + ], + ) + .await + .unwrap(); + + let asset_utxos = selected.get("asset").cloned().unwrap_or_default(); + let naked_utxos = selected.get("naked").cloned().unwrap_or_default(); + + assert_eq!(asset_utxos.len(), 1); + assert_eq!(naked_utxos.len(), 1); + assert!(asset_utxos.is_disjoint(&naked_utxos)); + + let target_asset = CanonicalAssets::from_asset( + Some(mock::KnownAsset::Hosky.policy().as_ref()), + Some(mock::KnownAsset::Hosky.name().as_ref()), + 1, + ); + assert!(asset_utxos.iter().next().unwrap().assets.contains_total(&target_asset)); +} + +#[pollster::test] +async fn test_resolve_competing_queries_no_solution() { + let store = mock::seed_random_memory_store( + |_: &mock::FuzzTxoRef, x: &mock::KnownAddress, _seq: u64| { + UtxoBuilder::new() + .with_address(x) + .with_naked_value(4_000_000) + .with_random_asset(mock::KnownAsset::Hosky, 500..501) + .build() + }, + 1..2, + ); + + let address = mock::KnownAddress::Alice; + let query_a = + new_input_query(&address, None, vec![(mock::KnownAsset::Hosky, 1)], false, false); + let query_b = + new_input_query(&address, None, vec![(mock::KnownAsset::Hosky, 1)], false, false); + + let result = resolve_queries( + &store, + vec![("a".to_string(), query_a), ("b".to_string(), query_b)], + ) + .await; + + assert!(result.is_err()); +} + +#[pollster::test] +async fn test_resolve_by_naked_and_asset_amount() { + let store = mock::seed_random_memory_store( + |_: &mock::FuzzTxoRef, x: &mock::KnownAddress, sequence: u64| { + if sequence % 2 == 0 { + mock::utxo_with_random_amount(x, 4_000_000..5_000_000) + } else { + mock::utxo_with_random_asset(x, mock::KnownAsset::Hosky, 500..1000) + } + }, + 2..3, + ); + + let criteria = new_input_query( + &mock::KnownAddress::Alice, + Some(4_000_000), + vec![(mock::KnownAsset::Hosky, 500)], + true, + false, + ); + + let utxos = resolve_single(&store, "q", &criteria).await; + assert!(utxos.len() == 2); +} + +#[pollster::test] +async fn test_cross_query_pool_doesnt_leak_wrong_address() { + let store = mock::seed_random_memory_store( + |_: &mock::FuzzTxoRef, x: &mock::KnownAddress, seq: u64| { + if x.to_bytes() == mock::KnownAddress::Bob.to_bytes() && seq % 2 == 0 { + UtxoBuilder::new() + .with_address(x) + .with_naked_value(4_000_000) + .with_random_asset(mock::KnownAsset::Hosky, 1..2) + .build() + } else { + mock::utxo_with_random_amount(x, 4_000_000..5_000_000) + } + }, + 2..3, + ); + + let addr_a = mock::KnownAddress::Alice; + let addr_b = mock::KnownAddress::Bob; + + let query_a = new_input_query(&addr_a, None, vec![(mock::KnownAsset::Hosky, 1)], false, false); + let query_b = new_input_query(&addr_b, Some(1_000_000), vec![], false, false); + + let result = resolve_queries( + &store, + vec![("a".to_string(), query_a), ("b".to_string(), query_b)], + ) + .await; + + assert!(result.is_err()); +} diff --git a/crates/tx3-resolver/src/lib.rs b/crates/tx3-resolver/src/lib.rs index 4767ca1d..e2a362f6 100644 --- a/crates/tx3-resolver/src/lib.rs +++ b/crates/tx3-resolver/src/lib.rs @@ -18,9 +18,6 @@ pub use tx3_tir::model::core::{Type, Utxo, UtxoRef, UtxoSet}; // TODO: we need to re-export this because some of the UtxoStore interface depends ond them, but this is tech debt. We should remove any dependency to versioned IR artifacts. pub use tx3_tir::model::v1beta0::{Expression, StructExpr}; -#[cfg(test)] -pub mod mock; - #[derive(Debug, thiserror::Error)] pub enum Error { #[error("can't compile non-constant tir")] diff --git a/crates/tx3-resolver/src/mock.rs b/crates/tx3-resolver/src/mock.rs deleted file mode 100644 index bb396793..00000000 --- a/crates/tx3-resolver/src/mock.rs +++ /dev/null @@ -1,178 +0,0 @@ -use std::collections::HashSet; -use std::ops::{Deref, DerefMut, Range}; - -pub use chainfuzz::addresses::KnownAddress; -pub use chainfuzz::assets::KnownAsset; -pub use chainfuzz::utxos::UtxoMap; -pub use chainfuzz::utxos::{utxo_with_random_amount, utxo_with_random_asset, UtxoGenerator}; -pub use chainfuzz::{TxoRef as FuzzTxoRef, Utxo as FuzzUtxo}; - -use crate::{Error, UtxoPattern, UtxoStore}; - -use tx3_tir::model::{ - assets::CanonicalAssets, - core::{Utxo, UtxoRef, UtxoSet}, -}; - -use crate::mock; - -fn from_fuzz_utxo(txo: &chainfuzz::TxoRef, utxo: &chainfuzz::Utxo) -> Utxo { - let address = utxo.address.to_vec(); - - let assets: CanonicalAssets = utxo - .assets - .iter() - .map(|x| { - let policy = x.class.policy; - let name = x.class.name.as_slice(); - let amount = x.amount; - - CanonicalAssets::from_asset(Some(policy.as_slice()), Some(name), amount as i128) - }) - .fold(CanonicalAssets::empty(), |acc, x| acc + x); - - let assets = assets + CanonicalAssets::from_naked_amount(utxo.naked_value as i128); - - Utxo { - address, - assets, - r#ref: UtxoRef { - txid: txo.tx_hash.to_vec(), - index: txo.ordinal as u32, - }, - datum: None, - script: None, - } -} - -pub struct MockStore { - utxos: chainfuzz::UtxoMap, -} - -impl Default for MockStore { - fn default() -> Self { - Self { - utxos: UtxoMap::new(), - } - } -} - -impl From for MockStore { - fn from(utxos: chainfuzz::UtxoMap) -> Self { - Self { utxos } - } -} - -impl Deref for MockStore { - type Target = chainfuzz::UtxoMap; - - fn deref(&self) -> &Self::Target { - &self.utxos - } -} - -impl DerefMut for MockStore { - fn deref_mut(&mut self) -> &mut Self::Target { - &mut self.utxos - } -} - -impl UtxoStore for MockStore { - async fn narrow_refs(&self, pattern: UtxoPattern<'_>) -> Result, Error> { - match pattern { - UtxoPattern::ByAddress(address) => { - let address = chainfuzz::Address::try_from(address).unwrap(); - - let narrow = self - .utxos - .find_by_address(&address) - .map(|(x, _)| x) - .map(|x| UtxoRef { - txid: x.tx_hash.to_vec(), - index: x.ordinal as u32, - }); - - let narrow = HashSet::from_iter(narrow); - - Ok(narrow) - } - UtxoPattern::ByAssetPolicy(policy) => { - let policy = chainfuzz::AssetPolicy::try_from(policy).unwrap(); - - let narrow = self - .utxos - .find_by_asset_policy(&policy) - .map(|(x, _)| x) - .map(|x| UtxoRef { - txid: x.tx_hash.to_vec(), - index: x.ordinal as u32, - }); - - let narrow = HashSet::from_iter(narrow); - - Ok(narrow) - } - UtxoPattern::ByAsset(policy, name) => { - let policy = chainfuzz::AssetPolicy::try_from(policy).unwrap(); - let name = chainfuzz::AssetName::try_from(name).unwrap(); - let asset_class = chainfuzz::AssetClass::new(policy, name); - - let narrow = self - .utxos - .find_by_asset_class(&asset_class) - .map(|(x, _)| x) - .map(|x| UtxoRef { - txid: x.tx_hash.to_vec(), - index: x.ordinal as u32, - }); - - let narrow = HashSet::from_iter(narrow); - - Ok(narrow) - } - } - } - - async fn fetch_utxos(&self, refs: HashSet) -> Result { - let utxos = refs - .iter() - .map(|txo| { - let tx_hash = chainfuzz::TxHash::try_from(txo.txid.as_slice()).unwrap(); - let ordinal = txo.index as u16; - chainfuzz::TxoRef::new(tx_hash, ordinal) - }) - .map(|txo| (txo, self.utxos.get(&txo).unwrap())) - .map(|(txo, utxo)| from_fuzz_utxo(&txo, &utxo)) - .collect::>(); - - Ok(utxos.into_iter().collect()) - } -} - -impl MockStore { - pub async fn by_known_address(&self, address: &KnownAddress) -> HashSet { - let bytes = address.to_bytes(); - let pattern = UtxoPattern::ByAddress(bytes.as_slice()); - - self.narrow_refs(pattern).await.unwrap() - } - - pub async fn by_known_asset(&self, asset: &KnownAsset) -> HashSet { - let policy = asset.policy(); - let name = asset.name(); - let pattern = UtxoPattern::ByAsset(policy.as_ref(), name.as_ref()); - - self.narrow_refs(pattern).await.unwrap() - } -} - -pub fn seed_random_memory_store( - f: G, - utxos_per_address: Range, -) -> MockStore { - let everyone = KnownAddress::everyone(); - - let map = chainfuzz::utxos::make_custom_utxo_map(everyone, utxos_per_address, f); - - MockStore { utxos: map } -}