feat(escrow_wip): build_unsigned_escrow_deposit builder
Plutus V3 spend with continuing-output state transition. Validator runs
Deposit { contributor } redeemer.
v1 limitation: ADA-only deposits. Multi-asset deposits are deferred —
Aiken's flat_merge uses right-fold which produces REVERSED appended-
policy ordering vs naive forward iteration. Single-entry net_added
(lovelace-only) trivially collapses any ordering ambiguity, so Rust's
existing value_merge matches Aiken's flat_merge byte-for-byte for v1.
Mirrors all six validator checks client-side as preflight (state==Open,
contributor in {a,b}, signed_by, continuing-output exists, datum equal
except deposits, deposits canonicality). 9 unit tests cover both
negative paths and happy-path datum mutations (new entry append,
existing entry merge, immutable fields preserved).
Inlines V3 validator CBOR in the tx witness for v1; reference-script
optimization is a v2 follow-up. Sets V3 cost model via language_view
to satisfy script_data_hash.
This commit is contained in:
parent
23dfbbae80
commit
ec3e98a1c5
2 changed files with 626 additions and 0 deletions
624
crates/aldabra-dao/src/builder/escrow_deposit.rs
Normal file
624
crates/aldabra-dao/src/builder/escrow_deposit.rs
Normal file
|
|
@ -0,0 +1,624 @@
|
||||||
|
//! Build an unsigned `escrow_deposit_unsigned` transaction.
|
||||||
|
//!
|
||||||
|
//! ⚠️ WIP / UNAUDITED. Feature-gated behind `escrow_wip`.
|
||||||
|
//!
|
||||||
|
//! ## What this tx does
|
||||||
|
//!
|
||||||
|
//! Plutus V3 spend of an existing escrow UTxO with a continuing-output
|
||||||
|
//! state transition (only `deposits` field mutated). Validator runs
|
||||||
|
//! `Deposit { contributor }` redeemer.
|
||||||
|
//!
|
||||||
|
//! - **Inputs**:
|
||||||
|
//! - The escrow UTxO at `escrow_script_address` (Plutus V3 spend,
|
||||||
|
//! redeemer = `Deposit { contributor }`).
|
||||||
|
//! - One funding wallet UTxO covering `add_lovelace + fee + change_min`.
|
||||||
|
//! - **Collateral**: 1 ADA-only ≥5 ADA wallet UTxO (separate from funding).
|
||||||
|
//! - **Outputs**:
|
||||||
|
//! - Continuing escrow UTxO at the same script address. Inline datum =
|
||||||
|
//! old datum with `deposits` mutated; lovelace = old + add_lovelace;
|
||||||
|
//! pre-existing native assets preserved bit-exact.
|
||||||
|
//! - Wallet change.
|
||||||
|
//! - **Disclosed signer**: `contributor_pkh` (validator's `signed_by` check).
|
||||||
|
//!
|
||||||
|
//! ## v1 limitation: ADA-only deposits
|
||||||
|
//!
|
||||||
|
//! The validator enforces canonicality via:
|
||||||
|
//!
|
||||||
|
//! ```text
|
||||||
|
//! cbor.serialise(expected_deposits_after) == cbor.serialise(new_d.deposits)
|
||||||
|
//! ```
|
||||||
|
//!
|
||||||
|
//! where `expected_deposits_after` is computed in Aiken via right-fold
|
||||||
|
//! over `flat_merge`. Right-fold + multi-entry `net_added` produces
|
||||||
|
//! REVERSED appended-policy ordering. Mirroring that exactly off-chain
|
||||||
|
//! is doable but a v2 enhancement.
|
||||||
|
//!
|
||||||
|
//! For v1 we restrict each Deposit tx to lovelace-only (`net_added` is
|
||||||
|
//! a single-entry `EscrowValue` — `[(empty_policy, [(empty_name, qty)])]`).
|
||||||
|
//! Single-entry foldr / forward-iter produce identical results, so
|
||||||
|
//! Rust's `value_merge` matches Aiken's `flat_merge` byte-for-byte
|
||||||
|
//! regardless of iteration direction.
|
||||||
|
//!
|
||||||
|
//! Multi-asset deposit shapes (token + ADA, multiple distinct tokens,
|
||||||
|
//! etc.) are deferred until we mirror the foldr ordering exactly AND
|
||||||
|
//! verify byte-equality via golden CBOR captured from the on-chain
|
||||||
|
//! validator.
|
||||||
|
//!
|
||||||
|
//! ## What the validator enforces (must match)
|
||||||
|
//!
|
||||||
|
//! From `aiken-escrow/validators/escrow.ak` Deposit branch:
|
||||||
|
//!
|
||||||
|
//! 1. `d.state == Open` (escrow not yet agreed/finalised).
|
||||||
|
//! 2. `contributor == d.party_a || contributor == d.party_b`.
|
||||||
|
//! 3. `signed_by(self, contributor)`.
|
||||||
|
//! 4. Continuing output exists at `script_addr` with parsable inline datum.
|
||||||
|
//! 5. New datum equals old datum in every field except `deposits`
|
||||||
|
//! (party_a/b/recipient/open_deadline_ms/lock_period_ms/state preserved).
|
||||||
|
//! 6. New datum's `deposits` matches `expected_deposits_after(d.deposits,
|
||||||
|
//! contributor, value_to_flat(new_value - in_value))` byte-for-byte
|
||||||
|
//! via `cbor.serialise`.
|
||||||
|
//!
|
||||||
|
//! All six are preflighted client-side here so a misshaped tx never
|
||||||
|
//! reaches the chain (and thus never burns collateral).
|
||||||
|
|
||||||
|
use pallas_codec::minicbor;
|
||||||
|
use pallas_crypto::hash::Hash;
|
||||||
|
use pallas_txbuilder::{BuildConway, ExUnits, Input, Output, ScriptKind, StagingTransaction};
|
||||||
|
|
||||||
|
use crate::agora::escrow::{
|
||||||
|
value_merge, EscrowDatum, EscrowDeposit, EscrowRedeemer, EscrowState, EscrowValue, PKH_LEN,
|
||||||
|
};
|
||||||
|
use crate::config::{DaoConfig, DaoNetwork};
|
||||||
|
use crate::error::{DaoError, DaoResult};
|
||||||
|
|
||||||
|
use super::proposal_create::{
|
||||||
|
parse_address, parse_script_hash, parse_tx_hash, WalletUtxo, MIN_COLLATERAL_LOVELACE,
|
||||||
|
};
|
||||||
|
|
||||||
|
/// Generous-but-bounded ExUnits budget for the escrow Deposit redeemer.
|
||||||
|
/// Refine via Koios `tx_evaluate` once we have a live tx to evaluate.
|
||||||
|
pub const ESCROW_SPEND_EX_UNITS: ExUnits = ExUnits {
|
||||||
|
mem: 5_000_000,
|
||||||
|
steps: 2_000_000_000,
|
||||||
|
};
|
||||||
|
|
||||||
|
/// Conway-era min UTxO floor we apply to the continuing escrow output.
|
||||||
|
/// Real value depends on serialized output size; this is a generous
|
||||||
|
/// bound that covers the escrow datum + token shapes we expect.
|
||||||
|
pub const ESCROW_OUTPUT_MIN_LOVELACE: u64 = 2_000_000;
|
||||||
|
|
||||||
|
/// Wallet-change min-UTxO floor.
|
||||||
|
const WALLET_CHANGE_MIN_LOVELACE: u64 = 1_000_000;
|
||||||
|
|
||||||
|
/// On-chain escrow state being spent.
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct EscrowUtxoIn {
|
||||||
|
pub tx_hash_hex: String,
|
||||||
|
pub output_index: u32,
|
||||||
|
pub lovelace: u64,
|
||||||
|
/// Native assets currently locked alongside the lovelace. Preserved
|
||||||
|
/// bit-exact onto the continuing output (Deposit doesn't move tokens
|
||||||
|
/// in v1 — only lovelace). Each tuple: `(policy_hex, name_hex, qty)`.
|
||||||
|
pub assets: Vec<(String, String, u64)>,
|
||||||
|
/// Current EscrowDatum decoded from the inline datum. Caller fetches
|
||||||
|
/// via `escrow_show` / Koios.
|
||||||
|
pub datum: EscrowDatum,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Args bundle for [`build_unsigned_escrow_deposit`].
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct EscrowDepositArgs {
|
||||||
|
pub cfg: DaoConfig,
|
||||||
|
/// Bech32 address of the deployed escrow validator script. Continuing
|
||||||
|
/// output goes here. Must match `escrow_in`'s on-chain address.
|
||||||
|
pub escrow_script_address: String,
|
||||||
|
/// Compiled Plutus V3 UPLC bytecode of the escrow validator. Inlined
|
||||||
|
/// in the tx witness for v1; reference-script optimization is a v2
|
||||||
|
/// follow-up.
|
||||||
|
pub validator_script_cbor: Vec<u8>,
|
||||||
|
pub escrow_in: EscrowUtxoIn,
|
||||||
|
/// Depositing party's payment-credential hash. Must equal
|
||||||
|
/// `escrow_in.datum.party_a` or `escrow_in.datum.party_b`.
|
||||||
|
pub contributor_pkh: [u8; PKH_LEN],
|
||||||
|
/// Lovelace being added to the escrow on this tx. v1 restriction:
|
||||||
|
/// ADA-only deposits. Must be > 0. Output min-utxo floor still
|
||||||
|
/// applies after the merge.
|
||||||
|
pub add_lovelace: u64,
|
||||||
|
/// Caller's bech32 base address (for change).
|
||||||
|
pub change_address: String,
|
||||||
|
/// Caller's spendable wallet UTxOs (must include 1 ADA-only utxo
|
||||||
|
/// ≥5 ADA for collateral and 1 ADA-only funding utxo).
|
||||||
|
pub wallet_utxos: Vec<WalletUtxo>,
|
||||||
|
/// Chain tip slot (sets `valid_from_slot`).
|
||||||
|
pub tip_slot: u64,
|
||||||
|
/// Tx upper-bound slot (sets `invalid_from_slot`). Validator does NOT
|
||||||
|
/// enforce time bounds on Deposit — caller can set freely (commonly
|
||||||
|
/// `tip_slot + 1800` for a 30-minute window).
|
||||||
|
pub validity_upper_slot: u64,
|
||||||
|
/// Estimated total fee. Caller-supplied for v1; refine via real
|
||||||
|
/// evaluator + size measurement later.
|
||||||
|
pub fee_lovelace: u64,
|
||||||
|
/// ExUnits budget for the spend redeemer. Defaults to
|
||||||
|
/// [`ESCROW_SPEND_EX_UNITS`].
|
||||||
|
pub ex_units: ExUnits,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// What [`build_unsigned_escrow_deposit`] returns.
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct UnsignedEscrowDeposit {
|
||||||
|
/// Hex-encoded CBOR of the unsigned tx body.
|
||||||
|
pub tx_cbor_hex: String,
|
||||||
|
/// Blake2b-256 hash of the tx body.
|
||||||
|
pub tx_hash_hex: String,
|
||||||
|
/// The script address where the continuing escrow lives.
|
||||||
|
pub escrow_script_address: String,
|
||||||
|
/// Hex-encoded CBOR of the new (post-deposit) escrow datum.
|
||||||
|
pub new_datum_cbor_hex: String,
|
||||||
|
/// Lovelace locked at the continuing output (= old + add_lovelace).
|
||||||
|
pub new_escrow_lovelace: u64,
|
||||||
|
/// Human-readable summary for MCP wrappers.
|
||||||
|
pub summary: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Build the unsigned escrow_deposit tx.
|
||||||
|
pub fn build_unsigned_escrow_deposit(args: EscrowDepositArgs) -> DaoResult<UnsignedEscrowDeposit> {
|
||||||
|
let datum_in = &args.escrow_in.datum;
|
||||||
|
|
||||||
|
// ---- preflight ----------------------------------------------------------
|
||||||
|
//
|
||||||
|
// Rules numbered against the validator's Deposit branch (see module docstring).
|
||||||
|
|
||||||
|
// (1) state must be Open.
|
||||||
|
if datum_in.state != EscrowState::Open {
|
||||||
|
return Err(DaoError::State(format!(
|
||||||
|
"escrow state is {:?}, must be Open to accept deposits",
|
||||||
|
datum_in.state
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
|
||||||
|
// (2) contributor must be a or b.
|
||||||
|
if args.contributor_pkh != datum_in.party_a && args.contributor_pkh != datum_in.party_b {
|
||||||
|
return Err(DaoError::State(
|
||||||
|
"contributor_pkh is neither party_a nor party_b — only escrow parties can deposit"
|
||||||
|
.into(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
// v1 ADA-only restriction: add_lovelace > 0, no native assets being added.
|
||||||
|
if args.add_lovelace == 0 {
|
||||||
|
return Err(DaoError::State(
|
||||||
|
"add_lovelace must be > 0 — Deposit tx with zero net value would still pay fees"
|
||||||
|
.into(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
// The continuing output's lovelace must clear the floor.
|
||||||
|
let new_escrow_lovelace = args
|
||||||
|
.escrow_in
|
||||||
|
.lovelace
|
||||||
|
.checked_add(args.add_lovelace)
|
||||||
|
.ok_or_else(|| DaoError::State("escrow output lovelace overflow".into()))?;
|
||||||
|
if new_escrow_lovelace < ESCROW_OUTPUT_MIN_LOVELACE {
|
||||||
|
return Err(DaoError::State(format!(
|
||||||
|
"continuing escrow output lovelace {new_escrow_lovelace} < min {ESCROW_OUTPUT_MIN_LOVELACE}"
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- compute new datum --------------------------------------------------
|
||||||
|
//
|
||||||
|
// For v1 ADA-only deposits, single-entry `net_added` makes Rust
|
||||||
|
// `value_merge` byte-equivalent to Aiken `flat_merge` regardless of
|
||||||
|
// fold direction (only one b_entry to process).
|
||||||
|
|
||||||
|
let net_added = EscrowValue::ada(args.add_lovelace);
|
||||||
|
let mut new_deposits = datum_in.deposits.clone();
|
||||||
|
let pos = new_deposits
|
||||||
|
.iter()
|
||||||
|
.position(|d| d.contributor == args.contributor_pkh);
|
||||||
|
match pos {
|
||||||
|
Some(i) => {
|
||||||
|
new_deposits[i].value = value_merge(&new_deposits[i].value, &net_added);
|
||||||
|
}
|
||||||
|
None => {
|
||||||
|
new_deposits.push(EscrowDeposit {
|
||||||
|
contributor: args.contributor_pkh,
|
||||||
|
value: net_added,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let new_datum = EscrowDatum {
|
||||||
|
party_a: datum_in.party_a,
|
||||||
|
party_b: datum_in.party_b,
|
||||||
|
recipient: datum_in.recipient,
|
||||||
|
open_deadline_ms: datum_in.open_deadline_ms,
|
||||||
|
lock_period_ms: datum_in.lock_period_ms,
|
||||||
|
state: EscrowState::Open,
|
||||||
|
deposits: new_deposits,
|
||||||
|
};
|
||||||
|
let new_datum_pd = new_datum.to_plutus_data()?;
|
||||||
|
let new_datum_cbor = minicbor::to_vec(&new_datum_pd)
|
||||||
|
.map_err(|e| DaoError::Cbor(format!("new escrow datum encode: {e}")))?;
|
||||||
|
|
||||||
|
// ---- redeemer -----------------------------------------------------------
|
||||||
|
|
||||||
|
let redeemer_pd = EscrowRedeemer::Deposit {
|
||||||
|
contributor: args.contributor_pkh,
|
||||||
|
}
|
||||||
|
.to_plutus_data()?;
|
||||||
|
let redeemer_cbor = minicbor::to_vec(&redeemer_pd)
|
||||||
|
.map_err(|e| DaoError::Cbor(format!("deposit redeemer encode: {e}")))?;
|
||||||
|
|
||||||
|
// ---- pick funding + collateral -----------------------------------------
|
||||||
|
|
||||||
|
let mut ada_only: Vec<WalletUtxo> = args
|
||||||
|
.wallet_utxos
|
||||||
|
.iter()
|
||||||
|
.filter(|u| u.is_ada_only())
|
||||||
|
.cloned()
|
||||||
|
.collect();
|
||||||
|
ada_only.sort_by_key(|u| std::cmp::Reverse(u.lovelace));
|
||||||
|
|
||||||
|
let collateral = ada_only
|
||||||
|
.iter()
|
||||||
|
.rev()
|
||||||
|
.find(|u| u.lovelace >= MIN_COLLATERAL_LOVELACE)
|
||||||
|
.ok_or_else(|| {
|
||||||
|
DaoError::State(format!(
|
||||||
|
"no ada-only wallet UTxO ≥ {MIN_COLLATERAL_LOVELACE} lovelace for collateral"
|
||||||
|
))
|
||||||
|
})?
|
||||||
|
.clone();
|
||||||
|
|
||||||
|
let funding = ada_only
|
||||||
|
.iter()
|
||||||
|
.find(|u| {
|
||||||
|
!(u.tx_hash_hex == collateral.tx_hash_hex && u.output_index == collateral.output_index)
|
||||||
|
})
|
||||||
|
.cloned()
|
||||||
|
.ok_or_else(|| {
|
||||||
|
DaoError::State(
|
||||||
|
"need a SECOND ada-only wallet UTxO to fund the deposit (separate from collateral)"
|
||||||
|
.into(),
|
||||||
|
)
|
||||||
|
})?;
|
||||||
|
|
||||||
|
// ---- balance + change ---------------------------------------------------
|
||||||
|
//
|
||||||
|
// total_in = escrow_in.lovelace + funding.lovelace
|
||||||
|
// outputs = new_escrow_lovelace + change + fee
|
||||||
|
// change = total_in - new_escrow_lovelace - fee
|
||||||
|
|
||||||
|
let total_in = args
|
||||||
|
.escrow_in
|
||||||
|
.lovelace
|
||||||
|
.checked_add(funding.lovelace)
|
||||||
|
.ok_or_else(|| DaoError::State("input lovelace overflow".into()))?;
|
||||||
|
let total_out = new_escrow_lovelace
|
||||||
|
.checked_add(args.fee_lovelace)
|
||||||
|
.ok_or_else(|| DaoError::State("output lovelace overflow".into()))?;
|
||||||
|
let change_lovelace = total_in.checked_sub(total_out).ok_or_else(|| {
|
||||||
|
DaoError::State(format!(
|
||||||
|
"insufficient input: total_in={total_in} need={total_out} \
|
||||||
|
(escrow_out={new_escrow_lovelace} + fee={})",
|
||||||
|
args.fee_lovelace
|
||||||
|
))
|
||||||
|
})?;
|
||||||
|
if change_lovelace > 0 && change_lovelace < WALLET_CHANGE_MIN_LOVELACE {
|
||||||
|
return Err(DaoError::State(format!(
|
||||||
|
"change lovelace {change_lovelace} below min UTxO ({WALLET_CHANGE_MIN_LOVELACE}); top up wallet"
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- assemble pallas StagingTransaction --------------------------------
|
||||||
|
|
||||||
|
let escrow_addr = parse_address(&args.escrow_script_address)?;
|
||||||
|
let change_addr = parse_address(&args.change_address)?;
|
||||||
|
|
||||||
|
let escrow_input = Input::new(
|
||||||
|
parse_tx_hash(&args.escrow_in.tx_hash_hex)?,
|
||||||
|
args.escrow_in.output_index as u64,
|
||||||
|
);
|
||||||
|
let funding_input = Input::new(
|
||||||
|
parse_tx_hash(&funding.tx_hash_hex)?,
|
||||||
|
funding.output_index as u64,
|
||||||
|
);
|
||||||
|
let collateral_input = Input::new(
|
||||||
|
parse_tx_hash(&collateral.tx_hash_hex)?,
|
||||||
|
collateral.output_index as u64,
|
||||||
|
);
|
||||||
|
|
||||||
|
let network_id = match args.cfg.network {
|
||||||
|
DaoNetwork::Mainnet => 1u8,
|
||||||
|
DaoNetwork::Preprod | DaoNetwork::Preview => 0u8,
|
||||||
|
};
|
||||||
|
|
||||||
|
// Continuing escrow output: same address, new lovelace, preserve any
|
||||||
|
// pre-existing native assets bit-exact, new inline datum.
|
||||||
|
let mut new_escrow_output = Output::new(escrow_addr, new_escrow_lovelace)
|
||||||
|
.set_inline_datum(new_datum_cbor.clone());
|
||||||
|
for (policy_hex, name_hex, qty) in &args.escrow_in.assets {
|
||||||
|
let policy = parse_script_hash(policy_hex)?;
|
||||||
|
let name = hex::decode(name_hex)
|
||||||
|
.map_err(|e| DaoError::Config(format!("escrow_in asset name hex: {e}")))?;
|
||||||
|
new_escrow_output = new_escrow_output
|
||||||
|
.add_asset(policy, name, *qty)
|
||||||
|
.map_err(|e| DaoError::Backend(format!("preserve escrow asset: {e}")))?;
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut staging = StagingTransaction::new();
|
||||||
|
// Two regular inputs: escrow (script) + funding (wallet).
|
||||||
|
staging = staging.input(escrow_input.clone());
|
||||||
|
staging = staging.input(funding_input);
|
||||||
|
staging = staging.collateral_input(collateral_input);
|
||||||
|
// Continuing escrow output, then change.
|
||||||
|
staging = staging.output(new_escrow_output);
|
||||||
|
if change_lovelace > 0 {
|
||||||
|
let mut change_output = Output::new(change_addr, change_lovelace);
|
||||||
|
for (policy_hex, name_hex, qty) in &funding.assets {
|
||||||
|
let policy = parse_script_hash(policy_hex)?;
|
||||||
|
let name = hex::decode(name_hex)
|
||||||
|
.map_err(|e| DaoError::Config(format!("funding asset name hex: {e}")))?;
|
||||||
|
change_output = change_output
|
||||||
|
.add_asset(policy, name, *qty)
|
||||||
|
.map_err(|e| DaoError::Backend(format!("add asset to change: {e}")))?;
|
||||||
|
}
|
||||||
|
staging = staging.output(change_output);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Inline the V3 validator script (v1 — no ref-script).
|
||||||
|
staging = staging.script(ScriptKind::PlutusV3, args.validator_script_cbor);
|
||||||
|
staging = staging.add_spend_redeemer(escrow_input, redeemer_cbor, Some(args.ex_units));
|
||||||
|
|
||||||
|
staging = staging.fee(args.fee_lovelace).network_id(network_id);
|
||||||
|
|
||||||
|
// Validity range — Deposit doesn't enforce time bounds, but we set
|
||||||
|
// them so the tx isn't valid forever.
|
||||||
|
staging = staging.valid_from_slot(args.tip_slot);
|
||||||
|
staging = staging.invalid_from_slot(args.validity_upper_slot);
|
||||||
|
|
||||||
|
// Disclosed signer: contributor pkh (validator's `signed_by` check).
|
||||||
|
staging = staging.disclosed_signer(Hash::<28>::from(args.contributor_pkh));
|
||||||
|
|
||||||
|
// V3 cost model — required for script_data_hash. Without it the
|
||||||
|
// chain rejects with PPViewHashesDontMatch.
|
||||||
|
staging = staging.language_view(
|
||||||
|
ScriptKind::PlutusV3,
|
||||||
|
aldabra_core::plutus_cost_models::PLUTUS_V3_COST_MODEL_PREPROD.to_vec(),
|
||||||
|
);
|
||||||
|
|
||||||
|
let built = staging
|
||||||
|
.build_conway_raw()
|
||||||
|
.map_err(|e| DaoError::Backend(format!("build_conway_raw: {e}")))?;
|
||||||
|
|
||||||
|
let tx_cbor_hex = hex::encode(&built.tx_bytes.0);
|
||||||
|
let tx_hash_hex = hex::encode(built.tx_hash.0);
|
||||||
|
|
||||||
|
let summary = format!(
|
||||||
|
"escrow_deposit_unsigned: contributor={} +{} lovelace → {} lovelace at {} (fee={})",
|
||||||
|
hex::encode(args.contributor_pkh),
|
||||||
|
args.add_lovelace,
|
||||||
|
new_escrow_lovelace,
|
||||||
|
args.escrow_script_address,
|
||||||
|
args.fee_lovelace,
|
||||||
|
);
|
||||||
|
|
||||||
|
Ok(UnsignedEscrowDeposit {
|
||||||
|
tx_cbor_hex,
|
||||||
|
tx_hash_hex,
|
||||||
|
escrow_script_address: args.escrow_script_address,
|
||||||
|
new_datum_cbor_hex: hex::encode(&new_datum_cbor),
|
||||||
|
new_escrow_lovelace,
|
||||||
|
summary,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use crate::config::ScriptRefs;
|
||||||
|
|
||||||
|
fn pkh(seed: u8) -> [u8; PKH_LEN] {
|
||||||
|
[seed; PKH_LEN]
|
||||||
|
}
|
||||||
|
|
||||||
|
fn sample_cfg() -> DaoConfig {
|
||||||
|
DaoConfig {
|
||||||
|
name: "sulkta-escrow".into(),
|
||||||
|
description: None,
|
||||||
|
governor_addr: "addr_test1wpyt48l".repeat(3),
|
||||||
|
stakes_addr: "addr_test1wpyt48l".repeat(3),
|
||||||
|
treasury_addr: "addr_test1wpyt48l".repeat(3),
|
||||||
|
gov_token_policy: "00".repeat(28),
|
||||||
|
gov_token_name_hex: "".into(),
|
||||||
|
initial_spend: format!("{}#0", "00".repeat(32)),
|
||||||
|
max_cosigners: 5,
|
||||||
|
treasury_ref_config: "00".repeat(28),
|
||||||
|
network: DaoNetwork::Preprod,
|
||||||
|
proposal_addr: None,
|
||||||
|
stake_st_policy: None,
|
||||||
|
proposal_st_policy: None,
|
||||||
|
script_refs: ScriptRefs::default(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Stub script CBOR — V3 always-true (`\46\01\00\00\32\22`). Real builds
|
||||||
|
/// pass the compiled escrow validator UPLC.
|
||||||
|
fn stub_v3_script() -> Vec<u8> {
|
||||||
|
vec![0x46, 0x01, 0x00, 0x00, 0x32, 0x22]
|
||||||
|
}
|
||||||
|
|
||||||
|
fn sample_args(
|
||||||
|
state: EscrowState,
|
||||||
|
deposits: Vec<EscrowDeposit>,
|
||||||
|
contributor: [u8; PKH_LEN],
|
||||||
|
) -> EscrowDepositArgs {
|
||||||
|
let escrow_in = EscrowUtxoIn {
|
||||||
|
tx_hash_hex: "11".repeat(32),
|
||||||
|
output_index: 0,
|
||||||
|
lovelace: 5_000_000,
|
||||||
|
assets: vec![],
|
||||||
|
datum: EscrowDatum {
|
||||||
|
party_a: pkh(0xa1),
|
||||||
|
party_b: pkh(0xb2),
|
||||||
|
recipient: pkh(0xb2),
|
||||||
|
open_deadline_ms: 1_700_000_000_000,
|
||||||
|
lock_period_ms: 30 * 60 * 1000,
|
||||||
|
state,
|
||||||
|
deposits,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
EscrowDepositArgs {
|
||||||
|
cfg: sample_cfg(),
|
||||||
|
escrow_script_address:
|
||||||
|
"addr_test1wqlzsnytzs4qv0trmhvw5cuyxnxk0qjq68crn85jj4lhv7qn4wym4".into(),
|
||||||
|
validator_script_cbor: stub_v3_script(),
|
||||||
|
escrow_in,
|
||||||
|
contributor_pkh: contributor,
|
||||||
|
add_lovelace: 5_000_000,
|
||||||
|
change_address:
|
||||||
|
"addr_test1qqqt0pru382hy9vjlsxv3ye02z50sfvt8xunscg5pgden77z73dpdfng2ctw2ekqplqgrljelz7h4dneac27nn3qx3rqqpavzj"
|
||||||
|
.into(),
|
||||||
|
wallet_utxos: vec![
|
||||||
|
WalletUtxo {
|
||||||
|
tx_hash_hex: "22".repeat(32),
|
||||||
|
output_index: 0,
|
||||||
|
lovelace: 20_000_000,
|
||||||
|
assets: vec![],
|
||||||
|
},
|
||||||
|
WalletUtxo {
|
||||||
|
tx_hash_hex: "33".repeat(32),
|
||||||
|
output_index: 0,
|
||||||
|
lovelace: 8_000_000,
|
||||||
|
assets: vec![],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
tip_slot: 50_000_000,
|
||||||
|
validity_upper_slot: 50_001_800,
|
||||||
|
fee_lovelace: 2_500_000,
|
||||||
|
ex_units: ESCROW_SPEND_EX_UNITS,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn rejects_non_open_state() {
|
||||||
|
let args = sample_args(
|
||||||
|
EscrowState::Agreed { agreed_at_ms: 0 },
|
||||||
|
vec![],
|
||||||
|
pkh(0xa1),
|
||||||
|
);
|
||||||
|
let err = build_unsigned_escrow_deposit(args).unwrap_err();
|
||||||
|
assert!(err.to_string().contains("must be Open"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn rejects_outsider_contributor() {
|
||||||
|
let args = sample_args(EscrowState::Open, vec![], pkh(0xff));
|
||||||
|
let err = build_unsigned_escrow_deposit(args).unwrap_err();
|
||||||
|
assert!(err.to_string().contains("party_a nor party_b"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn rejects_zero_add_lovelace() {
|
||||||
|
let mut args = sample_args(EscrowState::Open, vec![], pkh(0xa1));
|
||||||
|
args.add_lovelace = 0;
|
||||||
|
let err = build_unsigned_escrow_deposit(args).unwrap_err();
|
||||||
|
assert!(err.to_string().contains("add_lovelace"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn rejects_when_no_collateral_utxo() {
|
||||||
|
let mut args = sample_args(EscrowState::Open, vec![], pkh(0xa1));
|
||||||
|
args.wallet_utxos = vec![WalletUtxo {
|
||||||
|
tx_hash_hex: "44".repeat(32),
|
||||||
|
output_index: 0,
|
||||||
|
lovelace: 1_000_000, // way under collateral floor
|
||||||
|
assets: vec![],
|
||||||
|
}];
|
||||||
|
let err = build_unsigned_escrow_deposit(args).unwrap_err();
|
||||||
|
assert!(err.to_string().contains("collateral"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn rejects_when_only_one_ada_utxo() {
|
||||||
|
let mut args = sample_args(EscrowState::Open, vec![], pkh(0xa1));
|
||||||
|
args.wallet_utxos = vec![WalletUtxo {
|
||||||
|
tx_hash_hex: "55".repeat(32),
|
||||||
|
output_index: 0,
|
||||||
|
lovelace: 50_000_000, // qualifies for collateral but no funding
|
||||||
|
assets: vec![],
|
||||||
|
}];
|
||||||
|
let err = build_unsigned_escrow_deposit(args).unwrap_err();
|
||||||
|
assert!(err.to_string().contains("SECOND ada-only"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn first_deposit_appends_new_entry() {
|
||||||
|
// Empty deposits → new entry created.
|
||||||
|
let args = sample_args(EscrowState::Open, vec![], pkh(0xa1));
|
||||||
|
let unsigned = build_unsigned_escrow_deposit(args).unwrap();
|
||||||
|
assert!(unsigned.summary.contains("contributor="));
|
||||||
|
assert_eq!(unsigned.new_escrow_lovelace, 10_000_000);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn second_deposit_merges_into_existing_entry() {
|
||||||
|
// Existing deposit by party_a — second deposit must merge in place.
|
||||||
|
let existing = vec![EscrowDeposit {
|
||||||
|
contributor: pkh(0xa1),
|
||||||
|
value: EscrowValue::ada(3_000_000),
|
||||||
|
}];
|
||||||
|
let args = sample_args(EscrowState::Open, existing, pkh(0xa1));
|
||||||
|
let unsigned = build_unsigned_escrow_deposit(args).unwrap();
|
||||||
|
// Decode new datum and check
|
||||||
|
let pd: pallas_primitives::PlutusData =
|
||||||
|
minicbor::decode(&hex::decode(&unsigned.new_datum_cbor_hex).unwrap()).unwrap();
|
||||||
|
let new_datum = EscrowDatum::from_plutus_data(&pd).unwrap();
|
||||||
|
assert_eq!(new_datum.deposits.len(), 1);
|
||||||
|
// 3 ADA existing + 5 ADA added = 8 ADA
|
||||||
|
assert_eq!(
|
||||||
|
new_datum.deposits[0].value.lovelace(),
|
||||||
|
8_000_000,
|
||||||
|
"value_merge should sum lovelace in place"
|
||||||
|
);
|
||||||
|
assert_eq!(new_datum.deposits[0].contributor, pkh(0xa1));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn second_party_deposit_appends_new_entry() {
|
||||||
|
// party_a already deposited; party_b deposits next.
|
||||||
|
let existing = vec![EscrowDeposit {
|
||||||
|
contributor: pkh(0xa1),
|
||||||
|
value: EscrowValue::ada(5_000_000),
|
||||||
|
}];
|
||||||
|
let args = sample_args(EscrowState::Open, existing, pkh(0xb2));
|
||||||
|
let unsigned = build_unsigned_escrow_deposit(args).unwrap();
|
||||||
|
let pd: pallas_primitives::PlutusData =
|
||||||
|
minicbor::decode(&hex::decode(&unsigned.new_datum_cbor_hex).unwrap()).unwrap();
|
||||||
|
let new_datum = EscrowDatum::from_plutus_data(&pd).unwrap();
|
||||||
|
assert_eq!(new_datum.deposits.len(), 2);
|
||||||
|
assert_eq!(new_datum.deposits[0].contributor, pkh(0xa1));
|
||||||
|
assert_eq!(new_datum.deposits[0].value.lovelace(), 5_000_000);
|
||||||
|
assert_eq!(new_datum.deposits[1].contributor, pkh(0xb2));
|
||||||
|
assert_eq!(new_datum.deposits[1].value.lovelace(), 5_000_000);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn datum_immutable_fields_preserved() {
|
||||||
|
// Ensure party_a/b/recipient/deadline/lock_period are bit-exact
|
||||||
|
// on the new datum. Validator's "datum equal except deposits"
|
||||||
|
// check fails otherwise.
|
||||||
|
let args = sample_args(EscrowState::Open, vec![], pkh(0xa1));
|
||||||
|
let original = args.escrow_in.datum.clone();
|
||||||
|
let unsigned = build_unsigned_escrow_deposit(args).unwrap();
|
||||||
|
let pd: pallas_primitives::PlutusData =
|
||||||
|
minicbor::decode(&hex::decode(&unsigned.new_datum_cbor_hex).unwrap()).unwrap();
|
||||||
|
let new_datum = EscrowDatum::from_plutus_data(&pd).unwrap();
|
||||||
|
assert_eq!(new_datum.party_a, original.party_a);
|
||||||
|
assert_eq!(new_datum.party_b, original.party_b);
|
||||||
|
assert_eq!(new_datum.recipient, original.recipient);
|
||||||
|
assert_eq!(new_datum.open_deadline_ms, original.open_deadline_ms);
|
||||||
|
assert_eq!(new_datum.lock_period_ms, original.lock_period_ms);
|
||||||
|
assert_eq!(new_datum.state, EscrowState::Open);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -24,5 +24,7 @@ pub mod proposal_retract_votes;
|
||||||
pub mod proposal_vote;
|
pub mod proposal_vote;
|
||||||
pub mod stake_destroy;
|
pub mod stake_destroy;
|
||||||
|
|
||||||
|
#[cfg(feature = "escrow_wip")]
|
||||||
|
pub mod escrow_deposit;
|
||||||
#[cfg(feature = "escrow_wip")]
|
#[cfg(feature = "escrow_wip")]
|
||||||
pub mod escrow_open;
|
pub mod escrow_open;
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue