Skip to content
Draft
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
114 changes: 114 additions & 0 deletions crates/tx3-resolver/src/inputs/canonical.rs
Original file line number Diff line number Diff line change
Expand Up @@ -99,3 +99,117 @@ impl TryFrom<tir::InputQuery> 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<CanonicalAssets>) -> 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));
}
}
16 changes: 14 additions & 2 deletions crates/tx3-resolver/src/inputs/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -38,8 +38,20 @@ impl ResolveJob {
) -> Result<AnyTir, Error> {
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);
Expand Down
150 changes: 150 additions & 0 deletions crates/tx3-resolver/src/inputs/tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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::{
Expand Down Expand Up @@ -447,3 +448,152 @@ 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<UtxoSet, Error> {
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"
);
}
}

/// 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_utxos_only();
let fees = 2_000_000;

for address in mock::KnownAddress::everyone() {
// 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 at 100%");

assert_eq!(utxos.len(), 1);
assert!(utxos.total_assets().naked_amount().unwrap() >= fees as i128);
}
}

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

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);
}
}
Loading
Loading