Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -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})
175 changes: 175 additions & 0 deletions crates/tx3-resolver/src/inputs/approximate/filter.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,175 @@
//! 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 proptest::prelude::*;
use tx3_tir::model::{assets::CanonicalAssets, core::UtxoRef};

use crate::inputs::test_utils::{
self, any_address, any_utxo, any_utxo_at, query, utxo, utxo_with_asset, utxo_with_ref,
};

use super::*;

// -- hard constraints: scenario tests --

#[test]
fn hard_no_constraints_matches_anything() {
let q = query(None, None, HashSet::new(), false, false);
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(0, 0, b"alice", 1)));
assert!(!matches_hard_constraints(&q, &utxo(0, 0, 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(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));
}

#[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);

assert!(matches_hard_constraints(&q, &utxo_with_ref(b"alice", 1, 1, 0)));
assert!(!matches_hard_constraints(&q, &utxo_with_ref(b"bob", 1, 1, 0)));
assert!(!matches_hard_constraints(&q, &utxo_with_ref(b"alice", 1, 2, 0)));
}

// -- 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(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, &not_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(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));
}
}
}
}
55 changes: 55 additions & 0 deletions crates/tx3-resolver/src/inputs/approximate/mod.rs
Original file line number Diff line number Diff line change
@@ -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<UtxoRef, Utxo>,
queries: Vec<(String, CanonicalQuery)>,
) -> Vec<PreparedQuery> {
queries
.into_iter()
.map(|(name, query)| {
let candidates = approximate_candidates(pool, &query);
PreparedQuery { name, query, candidates }
})
.collect()
}

fn approximate_candidates(
pool: &HashMap<UtxoRef, Utxo>,
query: &CanonicalQuery,
) -> Vec<Utxo> {
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()
}
23 changes: 23 additions & 0 deletions crates/tx3-resolver/src/inputs/approximate/rank/mod.rs
Original file line number Diff line number Diff line change
@@ -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<Utxo>;
}

#[cfg(not(feature = "naive_selector"))]
pub type Ranker = vector::VectorRanker;

#[cfg(feature = "naive_selector")]
pub type Ranker = naive::NaiveRanker;
15 changes: 15 additions & 0 deletions crates/tx3-resolver/src/inputs/approximate/rank/naive.rs
Original file line number Diff line number Diff line change
@@ -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<Utxo> {
Vec::from_iter(search_space)
}
}
Loading
Loading