From cba50a198074524c26e064ee15daf2231be2bba1 Mon Sep 17 00:00:00 2001 From: Santiago Date: Tue, 18 Aug 2026 16:08:30 -0300 Subject: [PATCH 1/2] feat(resolver): size collateral inputs from the collateral percentage MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A `.tx3` collateral block declares what the author knows — typically `min_amount: fees`. The ledger asks for `collateralPercentage` of the fee (150% on every Cardano network since Alonzo), so a UTxO that satisfies the declared query is a third short of what the ledger accepts, and the tx is rejected for insufficient collateral (seen on Asteria create-ship). Nothing in the resolution path sized collateral: `compile_collateral` only passes through whatever the TIR named. Raise the lovelace floor of every collateral query to `ceil(fees * collateral_percentage / 100)` before the narrow → approximate → assign pipeline runs, so selection picks a UTxO that actually covers the requirement — or fails loudly with `InputNotResolved` rather than emitting a tx the ledger will reject. The percentage is a knob on the new `ResolveOptions`, defaulting to 150. `resolve_tx` keeps its signature and picks up the default; callers holding live protocol parameters can pass their own through `resolve_tx_with_options`. Ref: plans/lang-collateral-sizing-min-ada.md Co-Authored-By: Claude Opus 5 --- crates/tx3-resolver/src/inputs/canonical.rs | 114 +++++++++++++++ crates/tx3-resolver/src/inputs/mod.rs | 16 ++- crates/tx3-resolver/src/inputs/tests.rs | 148 ++++++++++++++++++++ crates/tx3-resolver/src/job.rs | 72 +++++++++- crates/tx3-resolver/src/lib.rs | 3 +- 5 files changed, 347 insertions(+), 6 deletions(-) diff --git a/crates/tx3-resolver/src/inputs/canonical.rs b/crates/tx3-resolver/src/inputs/canonical.rs index 99481b7..018c4be 100644 --- a/crates/tx3-resolver/src/inputs/canonical.rs +++ b/crates/tx3-resolver/src/inputs/canonical.rs @@ -99,3 +99,117 @@ impl TryFrom for CanonicalQuery { }) } } + +impl CanonicalQuery { + /// Raise the query's lovelace floor to `min_lovelace`, leaving every other + /// asset requirement untouched. A floor at or below what the query already + /// asks for is a no-op — this only ever widens the requirement. + pub fn raise_lovelace_floor(&mut self, min_lovelace: i128) { + if min_lovelace <= 0 { + return; + } + + let current = self + .min_amount + .as_ref() + .and_then(|x| x.naked_amount()) + .unwrap_or(0); + + if current >= min_lovelace { + return; + } + + let delta = CanonicalAssets::from_naked_amount(min_lovelace - current); + + self.min_amount = Some(match self.min_amount.take() { + Some(existing) => existing + delta, + None => delta, + }); + } +} + +/// Lovelace that the collateral inputs of a tx paying `fees` must hold, given +/// the ledger's collateral percentage protocol parameter: `ceil(fees * pct / +/// 100)`. The ceiling matters — the ledger rejects a collateral balance that is +/// short by even one lovelace. +pub fn required_collateral(fees: u64, percentage: u64) -> i128 { + let required = (fees as u128 * percentage as u128).div_ceil(100); + required as i128 +} + +#[cfg(test)] +mod tests { + use super::*; + + fn naked(amount: i128) -> CanonicalAssets { + CanonicalAssets::from_naked_amount(amount) + } + + fn collateral_query(min_amount: Option) -> CanonicalQuery { + CanonicalQuery { + address: None, + min_amount, + refs: HashSet::new(), + support_many: false, + collateral: true, + } + } + + #[test] + fn required_collateral_scales_by_percentage() { + assert_eq!(required_collateral(200_000, 150), 300_000); + assert_eq!(required_collateral(1_000_000, 100), 1_000_000); + assert_eq!(required_collateral(0, 150), 0); + } + + #[test] + fn required_collateral_rounds_up() { + // 3 * 150 / 100 = 4.5 — the ledger rejects a short collateral balance, + // so the fractional lovelace has to round up. + assert_eq!(required_collateral(3, 150), 5); + assert_eq!(required_collateral(1, 150), 2); + } + + #[test] + fn raise_lovelace_floor_widens_a_short_requirement() { + let mut query = collateral_query(Some(naked(200_000))); + query.raise_lovelace_floor(300_000); + + assert_eq!(query.min_amount.unwrap().naked_amount(), Some(300_000)); + } + + #[test] + fn raise_lovelace_floor_never_narrows() { + let mut query = collateral_query(Some(naked(5_000_000))); + query.raise_lovelace_floor(300_000); + + assert_eq!(query.min_amount.unwrap().naked_amount(), Some(5_000_000)); + } + + #[test] + fn raise_lovelace_floor_sets_an_absent_requirement() { + let mut query = collateral_query(None); + query.raise_lovelace_floor(300_000); + + assert_eq!(query.min_amount.unwrap().naked_amount(), Some(300_000)); + } + + #[test] + fn raise_lovelace_floor_is_a_noop_for_a_zero_fee() { + let mut query = collateral_query(None); + query.raise_lovelace_floor(0); + + assert!(query.min_amount.is_none()); + } + + #[test] + fn raise_lovelace_floor_leaves_other_assets_alone() { + let hosky = CanonicalAssets::from_defined_asset(b"policy", b"hosky", 42); + let mut query = collateral_query(Some(naked(200_000) + hosky.clone())); + query.raise_lovelace_floor(300_000); + + let out = query.min_amount.unwrap(); + assert_eq!(out.naked_amount(), Some(300_000)); + assert_eq!(out.asset_amount2(b"policy", b"hosky"), Some(42)); + } +} diff --git a/crates/tx3-resolver/src/inputs/mod.rs b/crates/tx3-resolver/src/inputs/mod.rs index 72a2603..07deb55 100644 --- a/crates/tx3-resolver/src/inputs/mod.rs +++ b/crates/tx3-resolver/src/inputs/mod.rs @@ -18,7 +18,7 @@ mod narrow; #[cfg(test)] mod tests; -pub use canonical::CanonicalQuery; +pub use canonical::{required_collateral, CanonicalQuery}; impl ResolveJob { /// Run the full input resolution pipeline: narrow, approximate, assign. @@ -38,8 +38,20 @@ impl ResolveJob { ) -> Result { let mut queries: Vec<(String, CanonicalQuery)> = Vec::new(); + // What the ledger will demand of the collateral inputs for the fee this + // pass is resolving against. The TIR only carries what the `.tx3` + // source declared — typically `min_amount: fees`, which is a full + // `collateralPercentage - 100` short of what the ledger accepts. + let min_collateral = required_collateral(self.fees, self.collateral_percentage); + for (name, query) in tx3_tir::reduce::find_queries(&tx) { - queries.push((name, CanonicalQuery::try_from(query)?)); + let mut query = CanonicalQuery::try_from(query)?; + + if query.collateral { + query.raise_lovelace_floor(min_collateral); + } + + queries.push((name, query)); } self.set_input_queries(queries); diff --git a/crates/tx3-resolver/src/inputs/tests.rs b/crates/tx3-resolver/src/inputs/tests.rs index 32ef170..0639020 100644 --- a/crates/tx3-resolver/src/inputs/tests.rs +++ b/crates/tx3-resolver/src/inputs/tests.rs @@ -2,6 +2,7 @@ //! (narrow → approximate → assign). use chainfuzz::utxos::UtxoBuilder; +use tx3_tir::encoding::AnyTir; use tx3_tir::model::{assets::CanonicalAssets, core::UtxoSet, v1beta0 as tir}; use crate::{ @@ -447,3 +448,150 @@ async fn test_cross_query_pool_doesnt_leak_wrong_address() { assert!(result.is_err()); } + +// --------------------------------------------------------------------------- +// Collateral sizing +// --------------------------------------------------------------------------- + +/// A TIR whose only input query is a collateral block declaring +/// `min_amount: fees` — the shape every real `.tx3` collateral block lowers to. +fn collateral_only_tir(address: &mock::KnownAddress, declared_min: i128) -> AnyTir { + let query = tir::InputQuery { + address: tir::Expression::Address(address.to_bytes()), + min_amount: tir::Expression::Assets(vec![tir::AssetExpr { + policy: tir::Expression::None, + asset_name: tir::Expression::None, + amount: tir::Expression::Number(declared_min), + }]), + r#ref: tir::Expression::None, + many: false, + collateral: true, + }; + + AnyTir::V1Beta0(tir::Tx { + fees: tir::Expression::Number(declared_min), + references: vec![], + inputs: vec![], + outputs: vec![], + validity: None, + mints: vec![], + burns: vec![], + adhoc: vec![], + collateral: vec![tir::Collateral { + utxos: tir::Expression::EvalParam(Box::new(tir::Param::ExpectInput( + "collateral".to_string(), + query, + ))), + }], + signers: None, + metadata: vec![], + }) +} + +/// Two naked UTxOs per address: one that covers the fee but not 150% of it, and +/// one that covers both. +fn store_with_tight_and_ample_utxos() -> mock::MockStore { + mock::seed_random_memory_store( + |_: &mock::FuzzTxoRef, x: &mock::KnownAddress, sequence: u64| { + if sequence.is_multiple_of(2) { + mock::utxo_with_random_amount(x, 2_500_000..2_500_001) + } else { + mock::utxo_with_random_amount(x, 4_000_000..4_000_001) + } + }, + 2..3, + ) +} + +async fn resolve_collateral( + store: &mock::MockStore, + address: &mock::KnownAddress, + fees: u64, + collateral_percentage: u64, +) -> Result { + let mut job = mock::stub_job_with_queries(Vec::new()); + job.fees = fees; + job.collateral_percentage = collateral_percentage; + + let tir = collateral_only_tir(address, fees as i128); + job.resolve_inputs(tir, store).await?; + + Ok(job.to_input_map().remove("collateral").unwrap_or_default()) +} + +#[pollster::test] +async fn test_collateral_sized_from_percentage_of_fee() { + let store = store_with_tight_and_ample_utxos(); + let fees = 2_000_000; + + for address in mock::KnownAddress::everyone() { + // The TIR only asks for `fees`, so a 2.5 ADA UTxO would satisfy it — + // and the ledger would then reject the tx for insufficient collateral. + let utxos = resolve_collateral(&store, &address, fees, 150) + .await + .expect("collateral should resolve"); + + assert_eq!(utxos.len(), 1); + assert!( + utxos.total_assets().naked_amount().unwrap() >= 3_000_000, + "selected collateral must cover 150% of the fee" + ); + } +} + +#[pollster::test] +async fn test_collateral_percentage_is_configurable() { + let store = store_with_tight_and_ample_utxos(); + let fees = 2_000_000; + + for address in mock::KnownAddress::everyone() { + // At 100% the tight UTxO is enough, and the ranker prefers it as the + // closest fit to the target. + let utxos = resolve_collateral(&store, &address, fees, 100) + .await + .expect("collateral should resolve"); + + assert_eq!(utxos.len(), 1); + assert_eq!( + utxos.total_assets().naked_amount().unwrap(), + 2_500_000, + "at 100% the tight UTxO is the closest fit" + ); + } +} + +#[pollster::test] +async fn test_collateral_short_of_percentage_does_not_resolve() { + // Every candidate covers the fee but none covers 150% of it: resolution + // must fail loudly rather than emit a tx the ledger will reject. + let store = mock::seed_random_memory_store( + |_: &mock::FuzzTxoRef, x: &mock::KnownAddress, _: u64| { + mock::utxo_with_random_amount(x, 2_500_000..2_500_001) + }, + 2..3, + ); + + for address in mock::KnownAddress::everyone() { + let result = resolve_collateral(&store, &address, 2_000_000, 150).await; + + assert!( + matches!(result, Err(Error::InputNotResolved(..))), + "expected InputNotResolved, got {result:?}" + ); + } +} + +#[pollster::test] +async fn test_collateral_untouched_on_the_zero_fee_pass() { + // The first eval pass runs with fee 0; sizing must not narrow the query + // there, otherwise the loop never gets a fee to size against. + let store = store_with_tight_and_ample_utxos(); + + for address in mock::KnownAddress::everyone() { + let utxos = resolve_collateral(&store, &address, 0, 150) + .await + .expect("collateral should resolve on the zero-fee pass"); + + assert_eq!(utxos.len(), 1); + } +} diff --git a/crates/tx3-resolver/src/job.rs b/crates/tx3-resolver/src/job.rs index 5f36e16..e68264e 100644 --- a/crates/tx3-resolver/src/job.rs +++ b/crates/tx3-resolver/src/job.rs @@ -13,6 +13,44 @@ use tx3_tir::Node as _; use crate::inputs::CanonicalQuery; use crate::{Error, InputNotResolvedError, UtxoStore}; +/// The Cardano ledger's `collateralPercentage` protocol parameter, unchanged on +/// every network since Alonzo. Callers that read live protocol parameters +/// should override it through [`ResolveOptions`]. +pub const DEFAULT_COLLATERAL_PERCENTAGE: u64 = 150; + +/// Knobs for a resolution run. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ResolveOptions { + /// How many extra passes the fee/input fixpoint loop may take before + /// giving up on convergence. Floored at 3. + pub max_optimize_rounds: usize, + + /// Percentage of the fee that the collateral inputs must cover. + pub collateral_percentage: u64, +} + +impl Default for ResolveOptions { + fn default() -> Self { + Self { + max_optimize_rounds: 3, + collateral_percentage: DEFAULT_COLLATERAL_PERCENTAGE, + } + } +} + +impl ResolveOptions { + pub fn with_max_optimize_rounds(max_optimize_rounds: usize) -> Self { + Self { + max_optimize_rounds, + ..Default::default() + } + } +} + +fn default_collateral_percentage() -> u64 { + DEFAULT_COLLATERAL_PERCENTAGE +} + #[derive(Debug, Clone, Serialize, Deserialize)] pub enum ResolveLog { ArgsApplied(AnyTir), @@ -43,6 +81,15 @@ pub struct ResolveJob { pub last_eval: Option, pub converged: bool, + /// Fee the current pass is resolving against — 0 on the first pass, the + /// previous pass's fee afterwards. Collateral sizing reads it. + #[serde(default)] + pub fees: u64, + + /// `collateralPercentage`, as supplied by the caller's protocol params. + #[serde(default = "default_collateral_percentage")] + pub collateral_percentage: u64, + // Timeline of state changes across all rounds pub log: Vec, @@ -86,6 +133,8 @@ impl ResolveJob { round: 0, last_eval: None, converged: false, + fees: 0, + collateral_percentage: DEFAULT_COLLATERAL_PERCENTAGE, log: Vec::new(), input_queries: Vec::new(), input_pool: None, @@ -165,6 +214,7 @@ impl ResolveJob { { let base_tir = self.resolved_tir().clone(); let fees = self.last_eval.as_ref().map(|e| e.fee).unwrap_or(0); + self.fees = fees; let attempt = tx3_tir::reduce::apply_fees(base_tir, fees)?; self.record(ResolveLog::FeesApplied(attempt.clone())); @@ -206,13 +256,14 @@ impl ResolveJob { &mut self, compiler: &mut C, utxos: &S, - max_optimize_rounds: usize, + options: &ResolveOptions, ) -> Result where C: Compiler, S: UtxoStore, { - let max_optimize_rounds = max_optimize_rounds.max(3); + let max_optimize_rounds = options.max_optimize_rounds.max(3); + self.collateral_percentage = options.collateral_percentage; self.compiler = match serde_json::to_value(&*compiler) { Ok(value) => value, @@ -243,13 +294,28 @@ pub async fn resolve_tx( utxos: &S, max_optimize_rounds: usize, ) -> Result +where + C: Compiler, + S: UtxoStore, +{ + let options = ResolveOptions::with_max_optimize_rounds(max_optimize_rounds); + resolve_tx_with_options(tx, args, compiler, utxos, &options).await +} + +pub async fn resolve_tx_with_options( + tx: AnyTir, + args: &ArgMap, + compiler: &mut C, + utxos: &S, + options: &ResolveOptions, +) -> Result where C: Compiler, S: UtxoStore, { let mut job = ResolveJob::new(tx, args.clone()); - let result = job.execute(compiler, utxos, max_optimize_rounds).await; + let result = job.execute(compiler, utxos, options).await; if let Ok(dir) = std::env::var("TX3_DIAGNOSTIC_DUMP") { let _ = crate::dump::dump_to_dir(&job, Path::new(&dir)); diff --git a/crates/tx3-resolver/src/lib.rs b/crates/tx3-resolver/src/lib.rs index 6b9637c..8bbf58a 100644 --- a/crates/tx3-resolver/src/lib.rs +++ b/crates/tx3-resolver/src/lib.rs @@ -11,7 +11,8 @@ pub mod trp; #[cfg(test)] pub(crate) mod test_utils; -pub use job::resolve_tx; +pub use inputs::required_collateral; +pub use job::{resolve_tx, resolve_tx_with_options, ResolveOptions, DEFAULT_COLLATERAL_PERCENTAGE}; pub use tx3_tir::model::assets::CanonicalAssets; pub use tx3_tir::model::core::{Type, Utxo, UtxoRef, UtxoSet}; From 047a8067700dd302deb67d88790fcad7a3d8acf5 Mon Sep 17 00:00:00 2001 From: Santiago Date: Tue, 18 Aug 2026 16:11:06 -0300 Subject: [PATCH 2/2] test(resolver): make the collateral percentage test ranking-independent MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The configurability test asserted which of two candidates the ranker picks at 100%, which the mock store's randomized refs make flaky — green locally, red on CI. Assert the option's actual effect instead: the same pool that fails at 150% resolves at 100%. Co-Authored-By: Claude Opus 5 --- crates/tx3-resolver/src/inputs/tests.rs | 32 +++++++++++++------------ 1 file changed, 17 insertions(+), 15 deletions(-) diff --git a/crates/tx3-resolver/src/inputs/tests.rs b/crates/tx3-resolver/src/inputs/tests.rs index 0639020..df09113 100644 --- a/crates/tx3-resolver/src/inputs/tests.rs +++ b/crates/tx3-resolver/src/inputs/tests.rs @@ -539,24 +539,31 @@ async fn test_collateral_sized_from_percentage_of_fee() { } } +/// Every UTxO holds 2.5 ADA: enough for a 2 ADA fee at 100%, short of it at +/// 150%. +fn store_with_tight_utxos_only() -> mock::MockStore { + mock::seed_random_memory_store( + |_: &mock::FuzzTxoRef, x: &mock::KnownAddress, _: u64| { + mock::utxo_with_random_amount(x, 2_500_000..2_500_001) + }, + 2..3, + ) +} + #[pollster::test] async fn test_collateral_percentage_is_configurable() { - let store = store_with_tight_and_ample_utxos(); + let store = store_with_tight_utxos_only(); let fees = 2_000_000; for address in mock::KnownAddress::everyone() { - // At 100% the tight UTxO is enough, and the ranker prefers it as the - // closest fit to the target. + // The same pool that fails at 150% resolves at 100%, so the floor + // really is driven by the option rather than hardcoded. let utxos = resolve_collateral(&store, &address, fees, 100) .await - .expect("collateral should resolve"); + .expect("collateral should resolve at 100%"); assert_eq!(utxos.len(), 1); - assert_eq!( - utxos.total_assets().naked_amount().unwrap(), - 2_500_000, - "at 100% the tight UTxO is the closest fit" - ); + assert!(utxos.total_assets().naked_amount().unwrap() >= fees as i128); } } @@ -564,12 +571,7 @@ async fn test_collateral_percentage_is_configurable() { async fn test_collateral_short_of_percentage_does_not_resolve() { // Every candidate covers the fee but none covers 150% of it: resolution // must fail loudly rather than emit a tx the ledger will reject. - let store = mock::seed_random_memory_store( - |_: &mock::FuzzTxoRef, x: &mock::KnownAddress, _: u64| { - mock::utxo_with_random_amount(x, 2_500_000..2_500_001) - }, - 2..3, - ); + let store = store_with_tight_utxos_only(); for address in mock::KnownAddress::everyone() { let result = resolve_collateral(&store, &address, 2_000_000, 150).await;