From 702aae729fe2153dabfbb07238ccff3653ef82ee Mon Sep 17 00:00:00 2001 From: Kayos Date: Sat, 9 May 2026 12:40:06 -0700 Subject: [PATCH] feat(escrow_wip): build_unsigned_escrow_settle builder MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Plutus V3 spend that consumes an Agreed escrow whose lock window has elapsed and pays the entire in_value to the recipient's enterprise address. Validator gate: state==Agreed AND lower > agreed_at_ms + lock_period_ms (strict gt). No signer required by validator. Driver pays fee via funding utxo + collateral; doesn't need to be a party (test asserts this — anyone can push Settle once the lock elapses). Made enterprise_address_for pub(super) in escrow_veto so settle and refund_timeout can share it. Mirrors the validator's pkh_to_base_address byte-for-byte. 5 tests: not-Agreed reject, lock-not-elapsed reject (off-by-one strict gt), empty-escrow reject, happy-path pays full in_value, outsider driver works. --- .../aldabra-dao/src/builder/escrow_settle.rs | 441 ++++++++++++++++++ crates/aldabra-dao/src/builder/escrow_veto.rs | 8 +- crates/aldabra-dao/src/builder/mod.rs | 2 + 3 files changed, 448 insertions(+), 3 deletions(-) create mode 100644 crates/aldabra-dao/src/builder/escrow_settle.rs diff --git a/crates/aldabra-dao/src/builder/escrow_settle.rs b/crates/aldabra-dao/src/builder/escrow_settle.rs new file mode 100644 index 0000000..866be2f --- /dev/null +++ b/crates/aldabra-dao/src/builder/escrow_settle.rs @@ -0,0 +1,441 @@ +//! Build an unsigned `escrow_settle_unsigned` transaction. +//! +//! ⚠️ WIP / UNAUDITED. Feature-gated behind `escrow_wip`. +//! +//! ## What this tx does +//! +//! Plutus V3 spend that consumes an `Agreed` escrow whose lock window +//! has elapsed and pays the entire escrow value to the recipient's +//! enterprise (null-stake) address. +//! +//! - **Inputs**: +//! - The escrow UTxO (Plutus V3 spend, redeemer = `Settle`). +//! - One funding wallet UTxO from the driver (covers fee). +//! - **Collateral**: 1 ADA-only ≥5 ADA wallet UTxO from the driver. +//! - **Outputs**: +//! - Recipient payout at the recipient's enterprise address, lovelace +//! ≥ `escrow_in.lovelace`, native assets ≥ each (policy, name) +//! present in the escrow input. +//! - Wallet change to driver. +//! - **Disclosed signer**: driver pkh. Validator does NOT require a +//! signer, but the funding utxo and collateral need to be unlocked. +//! +//! ## Why no required signer? +//! +//! Settle is a "cash out" path: anyone can push it once the lock window +//! elapses. The state machine + time gate is sufficient — nobody can +//! fire Settle before `lower > agreed_at_ms + lock_period_ms`, and +//! the recipient is hard-coded in the datum so funds always land on +//! the same address. +//! +//! ## What the validator enforces (must match) +//! +//! From `aiken-escrow/validators/escrow.ak` Settle branch: +//! +//! 1. `d.state` is `Agreed { agreed_at_ms }`. +//! 2. Tx validity range lower bound `Some(lower)` (Finite). +//! 3. `lower > agreed_at_ms + d.lock_period_ms` — strict greater-than. +//! 4. Sum of outputs to `pkh_to_base_address(d.recipient)` ≥ `in_value` +//! component-wise (`value_geq_value`). +//! +//! All four preflighted client-side. + +use pallas_codec::minicbor; +use pallas_crypto::hash::Hash; +use pallas_txbuilder::{BuildConway, ExUnits, Input, Output, ScriptKind, StagingTransaction}; + +use crate::agora::escrow::{EscrowRedeemer, EscrowState, PKH_LEN}; +use crate::config::{DaoConfig, DaoNetwork}; +use crate::error::{DaoError, DaoResult}; + +use super::escrow_deposit::{EscrowUtxoIn, ESCROW_SPEND_EX_UNITS}; +use super::escrow_veto::enterprise_address_for; +use super::proposal_create::{ + parse_address, parse_script_hash, parse_tx_hash, WalletUtxo, MIN_COLLATERAL_LOVELACE, +}; + +const WALLET_CHANGE_MIN_LOVELACE: u64 = 1_000_000; + +/// Args bundle for [`build_unsigned_escrow_settle`]. +#[derive(Debug, Clone)] +pub struct EscrowSettleArgs { + pub cfg: DaoConfig, + pub escrow_script_address: String, + pub validator_script_cbor: Vec, + pub escrow_in: EscrowUtxoIn, + /// Whoever's driving the tx — pays fee, holds collateral, gets + /// change. Doesn't need to be a party. Validator doesn't require + /// this signer, but Cardano needs SOME signer to unlock the funding + /// + collateral utxos (a wallet's regular UTxOs are vkey-locked). + pub driver_pkh: [u8; PKH_LEN], + pub change_address: String, + pub wallet_utxos: Vec, + /// Slot to set `valid_from_slot(...)` on. Must encode a posix-ms + /// `> agreed_at_ms + lock_period_ms`. Caller does the slot↔ms conv. + pub validity_lower_slot: u64, + /// POSIX-ms equivalent of `validity_lower_slot`. Used for the + /// preflight `lower > agreed_at_ms + lock_period_ms` check; the + /// chain extracts `lower` from the slot anyway, so this is purely + /// a sanity gate. + pub validity_lower_ms: i64, + pub validity_upper_slot: u64, + pub fee_lovelace: u64, + pub ex_units: ExUnits, +} + +/// What [`build_unsigned_escrow_settle`] returns. +#[derive(Debug, Clone)] +pub struct UnsignedEscrowSettle { + pub tx_cbor_hex: String, + pub tx_hash_hex: String, + pub recipient_pkh_hex: String, + pub recipient_lovelace_paid: u64, + pub summary: String, +} + +/// Build the unsigned escrow_settle tx. +pub fn build_unsigned_escrow_settle(args: EscrowSettleArgs) -> DaoResult { + let datum_in = &args.escrow_in.datum; + + // ---- preflight ---------------------------------------------------------- + + // (1) state must be Agreed. + let agreed_at_ms = match datum_in.state { + EscrowState::Agreed { agreed_at_ms } => agreed_at_ms, + ref other => { + return Err(DaoError::State(format!( + "escrow state is {other:?}, must be Agreed{{..}} to Settle", + ))); + } + }; + + // (3) validity_lower_ms > agreed_at_ms + lock_period_ms (strict gt). + let earliest_settle_ms = agreed_at_ms + .checked_add(datum_in.lock_period_ms) + .ok_or_else(|| DaoError::State("agreed_at + lock_period overflow".into()))?; + if args.validity_lower_ms <= earliest_settle_ms { + return Err(DaoError::State(format!( + "validity_lower_ms {} must be strictly > agreed_at_ms + lock_period_ms ({}); \ + lock window has not elapsed", + args.validity_lower_ms, earliest_settle_ms + ))); + } + + if datum_in.deposits.is_empty() { + return Err(DaoError::State( + "escrow has no deposits — Settle of an empty escrow is a no-op the recipient \ + could just claim via Refund-timeout instead. Refusing to push fees on a no-op." + .into(), + )); + } + + // ---- build recipient output -------------------------------------------- + // + // Recipient gets at least `in_value`. We just pay exactly in_value — + // the validator's value_geq_value(paid, in_value) is component-wise. + + let recipient_addr = enterprise_address_for(datum_in.recipient, args.cfg.network)?; + let recipient_lovelace = args.escrow_in.lovelace; + + // ---- redeemer + collateral + funding ----------------------------------- + + let redeemer_pd = EscrowRedeemer::Settle.to_plutus_data()?; + let redeemer_cbor = minicbor::to_vec(&redeemer_pd) + .map_err(|e| DaoError::Cbor(format!("settle redeemer encode: {e}")))?; + + let mut ada_only: Vec = 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 Settle (separate from collateral)" + .into(), + ) + })?; + + // ---- balance + change --------------------------------------------------- + + let total_in = args + .escrow_in + .lovelace + .checked_add(funding.lovelace) + .ok_or_else(|| DaoError::State("input lovelace overflow".into()))?; + let total_out = recipient_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} \ + (recipient={recipient_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 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, + }; + + // Recipient output: in_value preserved bit-exact (lovelace + assets). + let mut recipient_output = Output::new(recipient_addr, recipient_lovelace); + 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}")))?; + recipient_output = recipient_output + .add_asset(policy, name, *qty) + .map_err(|e| DaoError::Backend(format!("recipient add_asset: {e}")))?; + } + + let mut staging = StagingTransaction::new(); + staging = staging.input(escrow_input.clone()); + staging = staging.input(funding_input); + staging = staging.collateral_input(collateral_input); + staging = staging.output(recipient_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); + } + + 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); + + // valid_from_slot encodes the lower bound the validator reads. + staging = staging.valid_from_slot(args.validity_lower_slot); + staging = staging.invalid_from_slot(args.validity_upper_slot); + + // Driver as disclosed signer — needed for unlocking funding + collateral + // (vkey witness). Validator doesn't enforce a signer. + staging = staging.disclosed_signer(Hash::<28>::from(args.driver_pkh)); + + 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_settle_unsigned: recipient={} payout={} lovelace driver={} fee={}", + hex::encode(datum_in.recipient), + recipient_lovelace, + hex::encode(args.driver_pkh), + args.fee_lovelace, + ); + + Ok(UnsignedEscrowSettle { + tx_cbor_hex, + tx_hash_hex, + recipient_pkh_hex: hex::encode(datum_in.recipient), + recipient_lovelace_paid: recipient_lovelace, + summary, + }) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::agora::escrow::{EscrowDatum, EscrowDeposit, EscrowValue}; + 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_test1wqlzsnytzs4qv0trmhvw5cuyxnxk0qjq68crn85jj4lhv7qn4wym4".into(), + stakes_addr: "addr_test1wqlzsnytzs4qv0trmhvw5cuyxnxk0qjq68crn85jj4lhv7qn4wym4".into(), + treasury_addr: "addr_test1wqlzsnytzs4qv0trmhvw5cuyxnxk0qjq68crn85jj4lhv7qn4wym4".into(), + 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(), + } + } + + fn stub_v3_script() -> Vec { + vec![0x46, 0x01, 0x00, 0x00, 0x32, 0x22] + } + + fn sample_args(state: EscrowState, validity_lower_ms: i64) -> EscrowSettleArgs { + let escrow_in = EscrowUtxoIn { + tx_hash_hex: "11".repeat(32), + output_index: 0, + lovelace: 12_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: vec![ + EscrowDeposit { + contributor: pkh(0xa1), + value: EscrowValue::ada(5_000_000), + }, + EscrowDeposit { + contributor: pkh(0xb2), + value: EscrowValue::ada(7_000_000), + }, + ], + }, + }; + EscrowSettleArgs { + cfg: sample_cfg(), + escrow_script_address: + "addr_test1wqlzsnytzs4qv0trmhvw5cuyxnxk0qjq68crn85jj4lhv7qn4wym4".into(), + validator_script_cbor: stub_v3_script(), + escrow_in, + driver_pkh: pkh(0xa1), + change_address: + "addr_test1qqqt0pru382hy9vjlsxv3ye02z50sfvt8xunscg5pgden77z73dpdfng2ctw2ekqplqgrljelz7h4dneac27nn3qx3rqqpavzj" + .into(), + wallet_utxos: vec![ + WalletUtxo { + tx_hash_hex: "22".repeat(32), + output_index: 0, + lovelace: 10_000_000, + assets: vec![], + }, + WalletUtxo { + tx_hash_hex: "33".repeat(32), + output_index: 0, + lovelace: 8_000_000, + assets: vec![], + }, + ], + validity_lower_slot: 50_001_000, + validity_lower_ms, + validity_upper_slot: 50_002_800, + fee_lovelace: 2_500_000, + ex_units: ESCROW_SPEND_EX_UNITS, + } + } + + #[test] + fn rejects_when_not_agreed() { + let args = sample_args(EscrowState::Open, 1_700_000_000_000); + let err = build_unsigned_escrow_settle(args).unwrap_err(); + assert!(err.to_string().contains("Agreed")); + } + + #[test] + fn rejects_when_lock_window_not_elapsed() { + // agreed_at + lock_period = 1_699_999_900_000 + 1_800_000 = 1_700_001_700_000 + let agreed_state = EscrowState::Agreed { + agreed_at_ms: 1_699_999_900_000, + }; + // validity_lower_ms = exactly equal to agreed + lock — must be STRICT gt. + let args = sample_args(agreed_state, 1_700_001_700_000); + let err = build_unsigned_escrow_settle(args).unwrap_err(); + assert!(err.to_string().contains("strictly >")); + } + + #[test] + fn rejects_empty_escrow() { + let mut args = sample_args( + EscrowState::Agreed { + agreed_at_ms: 1_699_999_900_000, + }, + 1_700_001_800_000, + ); + args.escrow_in.datum.deposits.clear(); + let err = build_unsigned_escrow_settle(args).unwrap_err(); + assert!(err.to_string().contains("no deposits")); + } + + #[test] + fn happy_path_pays_recipient_full_in_value() { + let agreed_state = EscrowState::Agreed { + agreed_at_ms: 1_699_999_900_000, + }; + // Pick lower well past agreed + lock. + let unsigned = + build_unsigned_escrow_settle(sample_args(agreed_state, 1_700_002_000_000)).unwrap(); + assert_eq!(unsigned.recipient_pkh_hex, hex::encode(pkh(0xb2))); + assert_eq!(unsigned.recipient_lovelace_paid, 12_000_000); + assert!(unsigned.summary.contains("recipient=")); + } + + #[test] + fn anyone_can_drive_settle_validator_doesnt_enforce_signer() { + // Driver isn't a party — that's allowed for Settle (validator + // doesn't require a party signer; only the time gate gates it). + let agreed_state = EscrowState::Agreed { + agreed_at_ms: 1_699_999_900_000, + }; + let mut args = sample_args(agreed_state, 1_700_002_000_000); + args.driver_pkh = pkh(0xff); + let unsigned = build_unsigned_escrow_settle(args).unwrap(); + assert_eq!(unsigned.recipient_lovelace_paid, 12_000_000); + } +} diff --git a/crates/aldabra-dao/src/builder/escrow_veto.rs b/crates/aldabra-dao/src/builder/escrow_veto.rs index 41ac3e9..aef95d2 100644 --- a/crates/aldabra-dao/src/builder/escrow_veto.rs +++ b/crates/aldabra-dao/src/builder/escrow_veto.rs @@ -99,9 +99,11 @@ pub struct UnsignedEscrowVeto { pub summary: String, } -/// Construct the enterprise (null-stake) address for a contributor PKH. -/// Mirrors the validator's `pkh_to_base_address`. -fn enterprise_address_for(pkh: [u8; PKH_LEN], network: DaoNetwork) -> DaoResult
{ +/// Construct the enterprise (null-stake) address for a PKH. Mirrors +/// the validator's `pkh_to_base_address`. Shared by sibling builders +/// (`escrow_settle`, `escrow_refund_timeout`) that need to pay an +/// enterprise address. +pub(super) fn enterprise_address_for(pkh: [u8; PKH_LEN], network: DaoNetwork) -> DaoResult
{ let pallas_net = match network { DaoNetwork::Mainnet => PallasNetwork::Mainnet, DaoNetwork::Preprod | DaoNetwork::Preview => PallasNetwork::Testnet, diff --git a/crates/aldabra-dao/src/builder/mod.rs b/crates/aldabra-dao/src/builder/mod.rs index 5dec88f..379c3b7 100644 --- a/crates/aldabra-dao/src/builder/mod.rs +++ b/crates/aldabra-dao/src/builder/mod.rs @@ -31,4 +31,6 @@ pub mod escrow_deposit; #[cfg(feature = "escrow_wip")] pub mod escrow_open; #[cfg(feature = "escrow_wip")] +pub mod escrow_settle; +#[cfg(feature = "escrow_wip")] pub mod escrow_veto;