diff --git a/crates/aldabra-dao/src/builder/escrow_agree.rs b/crates/aldabra-dao/src/builder/escrow_agree.rs new file mode 100644 index 0000000..5b638b1 --- /dev/null +++ b/crates/aldabra-dao/src/builder/escrow_agree.rs @@ -0,0 +1,520 @@ +//! Build an unsigned `escrow_agree_unsigned` transaction. +//! +//! ⚠️ WIP / UNAUDITED. Feature-gated behind `escrow_wip`. +//! +//! ## What this tx does +//! +//! Plutus V3 spend that flips an escrow's state from `Open` to +//! `Agreed { agreed_at_ms = validity_upper_ms }`. Both party_a and +//! party_b must sign — the returned CBOR is partially-signed by the +//! driver, the co-signer adds their witness via `wallet_sign_partial`, +//! and the resulting signed tx is submitted. +//! +//! - **Inputs**: +//! - The escrow UTxO (Plutus V3 spend, redeemer = `Agree`). +//! - One funding wallet UTxO from the driver. +//! - **Collateral**: 1 ADA-only ≥5 ADA wallet UTxO from the driver. +//! - **Outputs**: +//! - Continuing escrow UTxO at the same script address. Inline datum = +//! old datum with `state = Agreed { agreed_at_ms: validity_upper_ms }`; +//! all other fields including `deposits` preserved bit-exact. +//! Lovelace + native assets preserved bit-exact (validator enforces +//! `value_geq_value` both directions = equality). +//! - Wallet change to driver. +//! - **Disclosed signers**: BOTH `party_a` and `party_b`. +//! - **Validity range**: `valid_from_slot(tip_slot)` / +//! `invalid_from_slot(validity_upper_slot)`. The latter MUST encode +//! the same posix-ms as `validity_upper_ms` — validator extracts +//! `upper` from the chain's reconstructed `validityRange.upperBound` +//! and writes it into `Agreed { agreed_at_ms: upper }`. If the +//! embedded ms diverges from what the chain reconstructs, the +//! validator's `new_d.state == Agreed { agreed_at_ms: upper }` check +//! fails. +//! +//! ## What the validator enforces (must match) +//! +//! From `aiken-escrow/validators/escrow.ak` Agree branch: +//! +//! 1. `d.state == Open`. +//! 2. `signed_by(self, d.party_a)` AND `signed_by(self, d.party_b)`. +//! 3. Tx validity range upper bound `Some(upper)` (Finite) AND +//! `upper <= d.open_deadline_ms`. +//! 4. Continuing output exists at `script_addr` with parsable datum. +//! 5. New datum's value equal to old (both `value_geq_value` directions). +//! 6. New datum equal to old in every field except `state`: +//! party_a / party_b / recipient / open_deadline_ms / lock_period_ms +//! preserved. +//! 7. `cbor.serialise(new_d.deposits) == cbor.serialise(d.deposits)` — +//! deposits preserved bit-exact. +//! 8. `new_d.state == Agreed { agreed_at_ms: upper }`. +//! +//! All eight are 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::{EscrowDatum, EscrowRedeemer, EscrowState, PKH_LEN}; +use crate::config::{DaoConfig, DaoNetwork}; +use crate::error::{DaoError, DaoResult}; + +use super::escrow_deposit::{ + EscrowUtxoIn, ESCROW_OUTPUT_MIN_LOVELACE, ESCROW_SPEND_EX_UNITS, +}; +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_agree`]. +#[derive(Debug, Clone)] +pub struct EscrowAgreeArgs { + pub cfg: DaoConfig, + pub escrow_script_address: String, + /// Compiled Plutus V3 UPLC bytecode of the escrow validator. Inlined + /// in the tx witness for v1. + pub validator_script_cbor: Vec, + pub escrow_in: EscrowUtxoIn, + /// The party driving the tx (pays fees, receives change). Must equal + /// `escrow_in.datum.party_a` or `escrow_in.datum.party_b`. The OTHER + /// party adds their witness via `wallet_sign_partial` after the + /// driver returns the unsigned CBOR. + pub driver_pkh: [u8; PKH_LEN], + pub change_address: String, + pub wallet_utxos: Vec, + pub tip_slot: u64, + /// Slot to set `invalid_from_slot(...)` on. Must encode the same + /// posix-ms as `validity_upper_ms` (caller does the slot↔ms conv). + pub validity_upper_slot: u64, + /// POSIX-ms equivalent of `validity_upper_slot`. Embedded as + /// `agreed_at_ms` on the new `Agreed` state. Validator extracts + /// `upper` from the chain-reconstructed validity range and the + /// equality check `new_d.state == Agreed { agreed_at_ms: upper }` + /// is the source of truth. + pub validity_upper_ms: i64, + pub fee_lovelace: u64, + pub ex_units: ExUnits, +} + +/// What [`build_unsigned_escrow_agree`] returns. +#[derive(Debug, Clone)] +pub struct UnsignedEscrowAgree { + pub tx_cbor_hex: String, + pub tx_hash_hex: String, + pub escrow_script_address: String, + /// Hex-encoded CBOR of the new (post-Agree) escrow datum. + pub new_datum_cbor_hex: String, + /// `agreed_at_ms` baked into the new datum. + pub agreed_at_ms: i64, + /// PKH of the co-signer who must sign next. + pub co_signer_pkh_hex: String, + pub summary: String, +} + +/// Build the unsigned escrow_agree tx. Caller signs (as driver), passes +/// the partial-signed CBOR to the co-signer, who completes signing and +/// submits. +pub fn build_unsigned_escrow_agree(args: EscrowAgreeArgs) -> DaoResult { + let datum_in = &args.escrow_in.datum; + + // ---- preflight ---------------------------------------------------------- + + // (1) state must be Open. + if datum_in.state != EscrowState::Open { + return Err(DaoError::State(format!( + "escrow state is {:?}, must be Open to Agree", + datum_in.state + ))); + } + + // Driver must be a or b. (Validator enforces both signers; we infer + // the co-signer from "the other party.") + let (driver_is_a, driver_is_b) = ( + args.driver_pkh == datum_in.party_a, + args.driver_pkh == datum_in.party_b, + ); + if !(driver_is_a || driver_is_b) { + return Err(DaoError::State( + "driver_pkh is neither party_a nor party_b — only escrow parties can drive Agree" + .into(), + )); + } + let co_signer_pkh = if driver_is_a { + datum_in.party_b + } else { + datum_in.party_a + }; + + // (3) validity upper ≤ open_deadline_ms. + if args.validity_upper_ms > datum_in.open_deadline_ms { + return Err(DaoError::State(format!( + "validity_upper_ms {} > open_deadline_ms {} — Agree must be inside the open window", + args.validity_upper_ms, datum_in.open_deadline_ms + ))); + } + + // Output value must equal input value — preserved bit-exact. + if args.escrow_in.lovelace < ESCROW_OUTPUT_MIN_LOVELACE { + return Err(DaoError::State(format!( + "escrow input lovelace {} < min {ESCROW_OUTPUT_MIN_LOVELACE} — \ + Agree preserves value, so the input must already clear the floor", + args.escrow_in.lovelace + ))); + } + + // ---- compute new datum -------------------------------------------------- + // + // Only `state` mutates. Everything else (deposits included) is copied + // bit-exact. Validator's deposits-cbor-equality check passes because + // we use the SAME Vec — round-trip through to_plutus_data emits the + // same KeyValuePairs ordering. + + 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::Agreed { + agreed_at_ms: args.validity_upper_ms, + }, + deposits: datum_in.deposits.clone(), + }; + 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::Agree.to_plutus_data()?; + let redeemer_cbor = minicbor::to_vec(&redeemer_pd) + .map_err(|e| DaoError::Cbor(format!("agree redeemer encode: {e}")))?; + + // ---- pick funding + collateral ----------------------------------------- + + 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 Agree (separate from collateral)" + .into(), + ) + })?; + + // ---- balance + change --------------------------------------------------- + // + // total_in = escrow_in.lovelace + funding.lovelace + // outputs = escrow_in.lovelace (preserved) + change + fee + // change = funding.lovelace - fee + // + // Since the escrow output preserves its lovelace, the driver's fee + // comes entirely out of funding. Change is funding - fee. + + let new_escrow_lovelace = args.escrow_in.lovelace; + 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: preserve value bit-exact, update 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(); + staging = staging.input(escrow_input.clone()); + staging = staging.input(funding_input); + staging = staging.collateral_input(collateral_input); + 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); + } + + 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); + + staging = staging.valid_from_slot(args.tip_slot); + staging = staging.invalid_from_slot(args.validity_upper_slot); + + // BOTH parties as disclosed signers — validator checks both. + staging = staging.disclosed_signer(Hash::<28>::from(datum_in.party_a)); + staging = staging.disclosed_signer(Hash::<28>::from(datum_in.party_b)); + + 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_agree_unsigned: driver={} co_signer={} agreed_at_ms={} (open_deadline={}, fee={})", + hex::encode(args.driver_pkh), + hex::encode(co_signer_pkh), + args.validity_upper_ms, + datum_in.open_deadline_ms, + args.fee_lovelace, + ); + + Ok(UnsignedEscrowAgree { + tx_cbor_hex, + tx_hash_hex, + escrow_script_address: args.escrow_script_address, + new_datum_cbor_hex: hex::encode(&new_datum_cbor), + agreed_at_ms: args.validity_upper_ms, + co_signer_pkh_hex: hex::encode(co_signer_pkh), + summary, + }) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::agora::escrow::{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_upper_ms: i64) -> EscrowAgreeArgs { + let escrow_in = EscrowUtxoIn { + tx_hash_hex: "11".repeat(32), + output_index: 0, + lovelace: 10_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(5_000_000), + }, + ], + }, + }; + EscrowAgreeArgs { + 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: 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, + validity_upper_ms, + fee_lovelace: 2_500_000, + ex_units: ESCROW_SPEND_EX_UNITS, + } + } + + #[test] + fn rejects_when_state_not_open() { + let args = sample_args( + EscrowState::Agreed { agreed_at_ms: 0 }, + 1_699_999_900_000, + ); + let err = build_unsigned_escrow_agree(args).unwrap_err(); + assert!(err.to_string().contains("must be Open")); + } + + #[test] + fn rejects_when_driver_not_a_party() { + let mut args = sample_args(EscrowState::Open, 1_699_999_900_000); + args.driver_pkh = pkh(0xff); + let err = build_unsigned_escrow_agree(args).unwrap_err(); + assert!(err.to_string().contains("party_a nor party_b")); + } + + #[test] + fn rejects_when_validity_upper_past_deadline() { + // open_deadline_ms = 1_700_000_000_000; pick upper just past it. + let args = sample_args(EscrowState::Open, 1_700_000_000_001); + let err = build_unsigned_escrow_agree(args).unwrap_err(); + assert!(err.to_string().contains("open_deadline_ms")); + } + + #[test] + fn happy_path_a_drives_b_is_co_signer() { + let args = sample_args(EscrowState::Open, 1_699_999_900_000); + let unsigned = build_unsigned_escrow_agree(args).unwrap(); + assert_eq!(unsigned.agreed_at_ms, 1_699_999_900_000); + assert_eq!(unsigned.co_signer_pkh_hex, hex::encode(pkh(0xb2))); + assert!(unsigned.summary.contains("agreed_at_ms")); + } + + #[test] + fn happy_path_b_drives_a_is_co_signer() { + let mut args = sample_args(EscrowState::Open, 1_699_999_900_000); + args.driver_pkh = pkh(0xb2); + let unsigned = build_unsigned_escrow_agree(args).unwrap(); + assert_eq!(unsigned.co_signer_pkh_hex, hex::encode(pkh(0xa1))); + } + + #[test] + fn datum_state_flips_to_agreed_with_validity_upper_ms() { + let args = sample_args(EscrowState::Open, 1_699_998_000_000); + let unsigned = build_unsigned_escrow_agree(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(); + match new_datum.state { + EscrowState::Agreed { agreed_at_ms } => { + assert_eq!(agreed_at_ms, 1_699_998_000_000); + } + other => panic!("expected Agreed state, got {other:?}"), + } + } + + #[test] + fn datum_immutable_fields_and_deposits_preserved() { + let args = sample_args(EscrowState::Open, 1_699_999_900_000); + let original = args.escrow_in.datum.clone(); + let unsigned = build_unsigned_escrow_agree(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.deposits, original.deposits); + } +} diff --git a/crates/aldabra-dao/src/builder/mod.rs b/crates/aldabra-dao/src/builder/mod.rs index c86a5b5..6bb71aa 100644 --- a/crates/aldabra-dao/src/builder/mod.rs +++ b/crates/aldabra-dao/src/builder/mod.rs @@ -24,6 +24,8 @@ pub mod proposal_retract_votes; pub mod proposal_vote; pub mod stake_destroy; +#[cfg(feature = "escrow_wip")] +pub mod escrow_agree; #[cfg(feature = "escrow_wip")] pub mod escrow_deposit; #[cfg(feature = "escrow_wip")]