diff --git a/crates/aldabra-dao/src/builder/escrow_open.rs b/crates/aldabra-dao/src/builder/escrow_open.rs new file mode 100644 index 0000000..d904999 --- /dev/null +++ b/crates/aldabra-dao/src/builder/escrow_open.rs @@ -0,0 +1,228 @@ +//! Build an unsigned `escrow_open_unsigned` transaction. +//! +//! ⚠️ WIP / UNAUDITED. Feature-gated behind `escrow_wip`. +//! +//! ## What this tx does +//! +//! Creates a new escrow UTxO at the escrow script address, optionally +//! funded by an initial deposit from the caller (party_a or party_b). +//! +//! - **Inputs**: caller's wallet UTxOs (no script spend — fresh escrow). +//! - **Outputs**: +//! - Escrow output at `escrow_script_address`. Inline datum = +//! `EscrowDatum { state: Open, deposits: [(initial_contributor, +//! initial_value)] }` (or empty deposits if no initial deposit). +//! Value = `initial_lovelace` + any native assets in the deposit. +//! - Wallet change. +//! - **No collateral / no Plutus spend** — opening doesn't spend the +//! script, just creates a UTxO at it. +//! +//! ## Why a wrapper +//! +//! `aldabra_core::build_unsigned_payment_extras` already does +//! send-to-address-with-inline-datum. This module adds: +//! 1. Typed `EscrowDatum` construction (vs hand-encoding CBOR). +//! 2. Preflight: contributor ∈ {party_a, party_b} match check. +//! 3. Min-utxo guard for the escrow output (must clear min-UTxO floor +//! AND min-utxo for any native assets attached). +//! 4. Standard summary string for MCP wrappers. + +use pallas_codec::minicbor; + +use aldabra_core::{ + build_unsigned_payment_extras, AssetSpec, InputUtxo, Network, ProtocolParams, UnsignedPayment, +}; + +use crate::agora::escrow::{ + EscrowDatum, EscrowDeposit, EscrowState, EscrowValue, PKH_LEN, +}; +use crate::error::{DaoError, DaoResult}; + +/// Args bundle for [`build_unsigned_escrow_open`]. +#[derive(Debug, Clone)] +pub struct EscrowOpenArgs { + pub network: Network, + /// Bech32 address of the deployed escrow validator script. + pub escrow_script_address: String, + pub party_a_pkh: [u8; PKH_LEN], + pub party_b_pkh: [u8; PKH_LEN], + /// Recipient who receives funds on Settle. Often equals party_b. + pub recipient_pkh: [u8; PKH_LEN], + /// POSIX-ms after which Refund (open-timeout) becomes valid. + pub open_deadline_ms: i64, + /// Veto-window length after Agree, in ms. + pub lock_period_ms: i64, + /// If `Some`, attribute initial funding to this contributor pkh + /// (must equal party_a or party_b). If `None`, opens with empty + /// deposits — both parties top up later via Deposit. + pub initial_contributor: Option<[u8; PKH_LEN]>, + /// Lovelace to lock into the escrow output. Must clear min-utxo + /// floor for the inline-datum-bearing output. If + /// `initial_contributor` is `None`, this still forms the output's + /// min-utxo padding but is recorded as zero deposits. + pub initial_lovelace: u64, + /// Native assets to lock alongside the lovelace, if any. + pub initial_assets: Vec, + /// Caller's bech32 base address (for change). + pub change_address: String, + /// Caller's spendable wallet UTxOs. + pub wallet_utxos: Vec, + pub params: ProtocolParams, +} + +/// What [`build_unsigned_escrow_open`] returns. +#[derive(Debug, Clone)] +pub struct UnsignedEscrowOpen { + /// 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 new escrow UTxO is created. + pub escrow_script_address: String, + /// Hex-encoded CBOR of the encoded datum (for caller / audit). + pub escrow_datum_cbor_hex: String, + /// Human-readable summary. + pub summary: String, +} + +/// Build the unsigned escrow_open tx. +pub fn build_unsigned_escrow_open(args: EscrowOpenArgs) -> DaoResult { + // ---- preflight ---- + if let Some(c) = args.initial_contributor { + if c != args.party_a_pkh && c != args.party_b_pkh { + return Err(DaoError::State( + "initial_contributor must be party_a or party_b".to_string(), + )); + } + } + // The output must clear the validator's min-utxo for an inline-datum + // + asset-bearing output. We use the protocol-param floor as a lower + // bound; the actual min depends on serialized output size and is + // computed by pallas-txbuilder when building the tx. + if args.initial_lovelace < args.params.min_utxo_lovelace { + return Err(DaoError::State(format!( + "initial_lovelace {} below min_utxo_lovelace {}", + args.initial_lovelace, args.params.min_utxo_lovelace + ))); + } + + // ---- build the datum ---- + let deposits = match args.initial_contributor { + Some(c) => { + // Build the EscrowValue mirroring what's actually paid into + // the script output: lovelace + any native assets. + let mut value = EscrowValue::ada(args.initial_lovelace); + for a in &args.initial_assets { + let policy = hex::decode(&a.policy_id_hex) + .map_err(|e| DaoError::Config(format!("policy_id_hex parse: {e}")))?; + let name = hex::decode(&a.asset_name_hex) + .map_err(|e| DaoError::Config(format!("asset_name_hex parse: {e}")))?; + value.policies.push((policy, vec![(name, a.quantity as i128)])); + } + vec![EscrowDeposit { contributor: c, value }] + } + None => vec![], + }; + let datum = EscrowDatum { + party_a: args.party_a_pkh, + party_b: args.party_b_pkh, + recipient: args.recipient_pkh, + open_deadline_ms: args.open_deadline_ms, + lock_period_ms: args.lock_period_ms, + state: EscrowState::Open, + deposits, + }; + let datum_pd = datum.to_plutus_data()?; + let mut datum_cbor = Vec::new(); + minicbor::encode(&datum_pd, &mut datum_cbor) + .map_err(|e| DaoError::Datum(format!("escrow datum cbor encode: {e}")))?; + + // ---- delegate to core's payment builder ---- + let payment: UnsignedPayment = build_unsigned_payment_extras( + args.network, + &args.wallet_utxos, + &args.change_address, + &args.escrow_script_address, + args.initial_lovelace, + &args.initial_assets, + Some(&datum_cbor), + None, // no reference script attached on open + &args.params, + ) + .map_err(|e| DaoError::State(format!("escrow_open payment builder: {e}")))?; + + let summary = match args.initial_contributor { + Some(c) => format!( + "escrow_open: lock {} lovelace + {} assets at {} (initial contributor {})", + args.initial_lovelace, + args.initial_assets.len(), + args.escrow_script_address, + hex::encode(c), + ), + None => format!( + "escrow_open: lock {} lovelace + {} assets at {} (no initial contributor)", + args.initial_lovelace, + args.initial_assets.len(), + args.escrow_script_address, + ), + }; + + Ok(UnsignedEscrowOpen { + tx_cbor_hex: payment.cbor_hex, + tx_hash_hex: payment.summary.tx_hash.clone(), + escrow_script_address: args.escrow_script_address, + escrow_datum_cbor_hex: hex::encode(&datum_cbor), + summary, + }) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn pkh(seed: u8) -> [u8; PKH_LEN] { + [seed; PKH_LEN] + } + + #[test] + fn rejects_unauthorized_initial_contributor() { + let args = EscrowOpenArgs { + network: Network::Preprod, + escrow_script_address: "addr_test1wpyt48l...".to_string(), + party_a_pkh: pkh(0xa1), + party_b_pkh: pkh(0xb2), + recipient_pkh: pkh(0xb2), + open_deadline_ms: 1_700_000_000_000, + lock_period_ms: 30 * 60 * 1000, + initial_contributor: Some(pkh(0xff)), // not party a or b + initial_lovelace: 5_000_000, + initial_assets: vec![], + change_address: "addr_test1...".to_string(), + wallet_utxos: vec![], + params: ProtocolParams::default(), + }; + let r = build_unsigned_escrow_open(args); + assert!(matches!(r, Err(DaoError::State(_)))); + } + + #[test] + fn rejects_initial_lovelace_below_min_utxo() { + let args = EscrowOpenArgs { + network: Network::Preprod, + escrow_script_address: "addr_test1wpyt48l...".to_string(), + party_a_pkh: pkh(0xa1), + party_b_pkh: pkh(0xb2), + recipient_pkh: pkh(0xb2), + open_deadline_ms: 1_700_000_000_000, + lock_period_ms: 30 * 60 * 1000, + initial_contributor: Some(pkh(0xa1)), + initial_lovelace: 100, // way below min + initial_assets: vec![], + change_address: "addr_test1...".to_string(), + wallet_utxos: vec![], + params: ProtocolParams::default(), + }; + let r = build_unsigned_escrow_open(args); + assert!(matches!(r, Err(DaoError::State(_)))); + } +} diff --git a/crates/aldabra-dao/src/builder/mod.rs b/crates/aldabra-dao/src/builder/mod.rs index ec36732..b7edcdd 100644 --- a/crates/aldabra-dao/src/builder/mod.rs +++ b/crates/aldabra-dao/src/builder/mod.rs @@ -23,3 +23,6 @@ pub mod proposal_create; pub mod proposal_retract_votes; pub mod proposal_vote; pub mod stake_destroy; + +#[cfg(feature = "escrow_wip")] +pub mod escrow_open;