diff --git a/crates/aldabra-dao/src/builder/escrow_veto.rs b/crates/aldabra-dao/src/builder/escrow_veto.rs new file mode 100644 index 0000000..41ac3e9 --- /dev/null +++ b/crates/aldabra-dao/src/builder/escrow_veto.rs @@ -0,0 +1,527 @@ +//! Build an unsigned `escrow_veto_unsigned` transaction. +//! +//! ⚠️ WIP / UNAUDITED. Feature-gated behind `escrow_wip`. +//! +//! ## What this tx does +//! +//! Plutus V3 spend that consumes an `Agreed` escrow UTxO and refunds +//! every contributor to their per-PKH null-stake (enterprise) address. +//! Either party_a or party_b can fire — the validator's `signed_by(a) +//! || signed_by(b)` is the gate. +//! +//! - **Inputs**: +//! - The escrow UTxO (Plutus V3 spend, redeemer = `Veto`). +//! - One funding wallet UTxO from the driver (covers fee + per-output +//! min-utxo top-ups when a deposit's lovelace is below the floor). +//! - **Collateral**: 1 ADA-only ≥5 ADA wallet UTxO from the driver. +//! - **Outputs**: +//! - **One refund output per deposit entry**, each at the contributor's +//! enterprise (null-stake) address. Lovelace = `max(deposit.value.lovelace, +//! min_utxo)`. Native assets from the deposit value attached when present. +//! - Wallet change to driver. +//! - **Disclosed signer**: driver pkh (must equal party_a or party_b). +//! +//! ## Why null-stake (enterprise) addresses? +//! +//! The validator's `pkh_to_base_address` constructs `Address { +//! payment_credential: VerificationKey(pkh), stake_credential: None }` +//! — that's an enterprise (type 6/7) Cardano address. Refunds land at +//! the bare payment credential of each contributor, with no delegation +//! routing. +//! +//! Off-chain we reconstruct that via +//! `ShelleyAddress::new(net, ShelleyPaymentPart::key_hash(pkh), +//! ShelleyDelegationPart::Null)`. Header byte `0b0110` (mainnet) or +//! `0b0111` (testnet). The validator's `o.address == target` check is +//! a structural address equality, so the off-chain enterprise address +//! must match byte-for-byte. +//! +//! ## What the validator enforces (must match) +//! +//! From `aiken-escrow/validators/escrow.ak` Veto branch: +//! +//! 1. `d.state` is `Agreed { .. }`. +//! 2. `signed_by(self, d.party_a) || signed_by(self, d.party_b)` — +//! EITHER party can fire. +//! 3. `refund_outputs_satisfy(self.outputs, d.deposits)` — for each +//! deposit, an output to that contributor's enterprise address must +//! pay at least the deposit's value (component-wise). +//! +//! All three preflighted client-side. + +use pallas_addresses::{Address, Network as PallasNetwork, ShelleyAddress, ShelleyDelegationPart, ShelleyPaymentPart}; +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::proposal_create::{ + parse_address, parse_script_hash, parse_tx_hash, WalletUtxo, MIN_COLLATERAL_LOVELACE, +}; + +const WALLET_CHANGE_MIN_LOVELACE: u64 = 1_000_000; + +/// Min-utxo floor we apply to each refund output. Validator only checks +/// `value_geq_flat(paid, deposit.value)` — paying more than the deposit +/// value is fine — so we top up below-floor refund outputs from the +/// driver's funding utxo to keep them submittable. +const REFUND_OUTPUT_MIN_LOVELACE: u64 = 1_000_000; + +/// Args bundle for [`build_unsigned_escrow_veto`]. +#[derive(Debug, Clone)] +pub struct EscrowVetoArgs { + pub cfg: DaoConfig, + pub escrow_script_address: String, + pub validator_script_cbor: Vec, + pub escrow_in: EscrowUtxoIn, + /// The party firing the veto (pays fees, gets change). Must equal + /// `escrow_in.datum.party_a` or `escrow_in.datum.party_b`. + pub driver_pkh: [u8; PKH_LEN], + pub change_address: String, + pub wallet_utxos: Vec, + pub tip_slot: u64, + pub validity_upper_slot: u64, + pub fee_lovelace: u64, + pub ex_units: ExUnits, +} + +/// What [`build_unsigned_escrow_veto`] returns. +#[derive(Debug, Clone)] +pub struct UnsignedEscrowVeto { + pub tx_cbor_hex: String, + pub tx_hash_hex: String, + /// Per-contributor refund summary: `(pkh_hex, lovelace_paid)`. + pub refunds: Vec<(String, u64)>, + 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
{ + let pallas_net = match network { + DaoNetwork::Mainnet => PallasNetwork::Mainnet, + DaoNetwork::Preprod | DaoNetwork::Preview => PallasNetwork::Testnet, + }; + let shelley = ShelleyAddress::new( + pallas_net, + ShelleyPaymentPart::key_hash(Hash::<28>::from(pkh)), + ShelleyDelegationPart::Null, + ); + Ok(Address::Shelley(shelley)) +} + +/// Build the unsigned escrow_veto tx. +pub fn build_unsigned_escrow_veto(args: EscrowVetoArgs) -> DaoResult { + let datum_in = &args.escrow_in.datum; + + // ---- preflight ---------------------------------------------------------- + + // (1) state must be Agreed { .. }. + if !matches!(datum_in.state, EscrowState::Agreed { .. }) { + return Err(DaoError::State(format!( + "escrow state is {:?}, must be Agreed{{..}} to Veto", + datum_in.state + ))); + } + + // (2) driver must be a party. + if args.driver_pkh != datum_in.party_a && args.driver_pkh != datum_in.party_b { + return Err(DaoError::State( + "driver_pkh is neither party_a nor party_b — only escrow parties can Veto" + .into(), + )); + } + + if datum_in.deposits.is_empty() { + return Err(DaoError::State( + "escrow has no deposits — Veto with empty deposits is a no-op refund" + .into(), + )); + } + + // ---- compute refund outputs -------------------------------------------- + // + // For each deposit, pay at least the deposit's value to the + // contributor's enterprise address. Top up to REFUND_OUTPUT_MIN_LOVELACE + // when below floor. Track the top-up cost so we can sanity-check funding. + + let mut refund_lovelace_total: u64 = 0; + let mut topup_total: u64 = 0; + let mut refund_summaries: Vec<(String, u64)> = Vec::with_capacity(datum_in.deposits.len()); + let mut refund_outputs: Vec<(Address, u64, Vec<(Vec, Vec, i128)>)> = + Vec::with_capacity(datum_in.deposits.len()); + + for deposit in &datum_in.deposits { + let deposit_lovelace = deposit.value.lovelace(); + let pay_lovelace = deposit_lovelace.max(REFUND_OUTPUT_MIN_LOVELACE); + if pay_lovelace > deposit_lovelace { + topup_total = topup_total + .checked_add(pay_lovelace - deposit_lovelace) + .ok_or_else(|| DaoError::State("refund topup overflow".into()))?; + } + refund_lovelace_total = refund_lovelace_total + .checked_add(pay_lovelace) + .ok_or_else(|| DaoError::State("refund lovelace total overflow".into()))?; + + // Collect non-ADA assets from the deposit value (skip the + // empty-policy ADA entry). Validator's value_geq_flat is component- + // wise so each (policy, name) must be paid at least its qty. + let mut assets: Vec<(Vec, Vec, i128)> = Vec::new(); + for (policy, entries) in &deposit.value.policies { + if policy.is_empty() { + continue; + } + for (name, qty) in entries { + assets.push((policy.clone(), name.clone(), *qty)); + } + } + + let addr = enterprise_address_for(deposit.contributor, args.cfg.network)?; + refund_outputs.push((addr, pay_lovelace, assets)); + refund_summaries.push((hex::encode(deposit.contributor), pay_lovelace)); + } + + // ---- redeemer ----------------------------------------------------------- + + let redeemer_pd = EscrowRedeemer::Veto.to_plutus_data()?; + let redeemer_cbor = minicbor::to_vec(&redeemer_pd) + .map_err(|e| DaoError::Cbor(format!("veto 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 Veto (separate from collateral)" + .into(), + ) + })?; + + // ---- balance + change --------------------------------------------------- + // + // total_in = escrow_in.lovelace + funding.lovelace + // outputs = sum(refund_outputs) + change + fee + // change = total_in - sum(refunds) - fee + + let total_in = args + .escrow_in + .lovelace + .checked_add(funding.lovelace) + .ok_or_else(|| DaoError::State("input lovelace overflow".into()))?; + let total_out = refund_lovelace_total + .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} \ + (refunds={refund_lovelace_total} + fee={}; topup={topup_total})", + 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, + }; + + let mut staging = StagingTransaction::new(); + staging = staging.input(escrow_input.clone()); + staging = staging.input(funding_input); + staging = staging.collateral_input(collateral_input); + + // Refund outputs in deposit-list order. The validator iterates + // deposits and looks for matching outputs by address so order between + // deposit-iter and tx-output-iter doesn't matter — but emitting in + // deposit order is the most legible audit trail. + for (addr, lovelace, assets) in refund_outputs { + let mut out = Output::new(addr, lovelace); + for (policy, name, qty) in assets { + // policy is already a 28-byte vec — wrap in Hash<28>. + let policy_hash = if policy.len() == 28 { + let mut a = [0u8; 28]; + a.copy_from_slice(&policy); + Hash::<28>::from(a) + } else { + return Err(DaoError::State(format!( + "deposit policy id has wrong length {} (expected 28)", + policy.len() + ))); + }; + out = out + .add_asset(policy_hash, name, qty as u64) + .map_err(|e| DaoError::Backend(format!("refund add_asset: {e}")))?; + } + staging = staging.output(out); + } + + 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); + + // Disclosed signer: driver pkh. Validator only requires ONE party + // signature for Veto, so we don't add the co-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_veto_unsigned: driver={} refunds={} ({} lovelace total, topup={}, fee={})", + hex::encode(args.driver_pkh), + refund_summaries.len(), + refund_lovelace_total, + topup_total, + args.fee_lovelace, + ); + + Ok(UnsignedEscrowVeto { + tx_cbor_hex, + tx_hash_hex, + refunds: refund_summaries, + 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 agreed_state() -> EscrowState { + EscrowState::Agreed { + agreed_at_ms: 1_699_999_900_000, + } + } + + fn sample_args(state: EscrowState, deposits: Vec) -> EscrowVetoArgs { + 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, + }, + }; + EscrowVetoArgs { + 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: 30_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_when_not_agreed() { + let args = sample_args(EscrowState::Open, vec![]); + let err = build_unsigned_escrow_veto(args).unwrap_err(); + assert!(err.to_string().contains("Agreed")); + } + + #[test] + fn rejects_when_driver_not_a_party() { + let mut args = sample_args( + agreed_state(), + vec![EscrowDeposit { + contributor: pkh(0xa1), + value: EscrowValue::ada(5_000_000), + }], + ); + args.driver_pkh = pkh(0xff); + let err = build_unsigned_escrow_veto(args).unwrap_err(); + assert!(err.to_string().contains("party_a nor party_b")); + } + + #[test] + fn rejects_when_no_deposits() { + let args = sample_args(agreed_state(), vec![]); + let err = build_unsigned_escrow_veto(args).unwrap_err(); + assert!(err.to_string().contains("no deposits")); + } + + #[test] + fn happy_path_two_contributors_full_refund() { + let deposits = vec![ + EscrowDeposit { + contributor: pkh(0xa1), + value: EscrowValue::ada(5_000_000), + }, + EscrowDeposit { + contributor: pkh(0xb2), + value: EscrowValue::ada(7_000_000), + }, + ]; + let unsigned = build_unsigned_escrow_veto(sample_args(agreed_state(), deposits)).unwrap(); + assert_eq!(unsigned.refunds.len(), 2); + assert_eq!(unsigned.refunds[0], (hex::encode(pkh(0xa1)), 5_000_000)); + assert_eq!(unsigned.refunds[1], (hex::encode(pkh(0xb2)), 7_000_000)); + assert!(unsigned.summary.contains("refunds=2")); + } + + #[test] + fn tops_up_below_floor_deposit() { + // Deposit of 0.5 ADA — below floor; top up to 1 ADA. + let deposits = vec![EscrowDeposit { + contributor: pkh(0xa1), + value: EscrowValue::ada(500_000), + }]; + let unsigned = build_unsigned_escrow_veto(sample_args(agreed_state(), deposits)).unwrap(); + assert_eq!(unsigned.refunds[0].1, REFUND_OUTPUT_MIN_LOVELACE); + assert!(unsigned.summary.contains("topup=500000")); + } + + #[test] + fn either_party_can_drive_veto() { + let deposits = vec![EscrowDeposit { + contributor: pkh(0xa1), + value: EscrowValue::ada(5_000_000), + }]; + // party_b drives — also valid. + let mut args = sample_args(agreed_state(), deposits); + args.driver_pkh = pkh(0xb2); + let unsigned = build_unsigned_escrow_veto(args).unwrap(); + assert!(unsigned.summary.contains(&hex::encode(pkh(0xb2)))); + } + + #[test] + fn enterprise_address_construction_is_testnet_for_preprod() { + // Sanity: enterprise address for preprod uses testnet network. + let addr = enterprise_address_for(pkh(0xa1), DaoNetwork::Preprod).unwrap(); + let bech32 = addr.to_bech32().unwrap(); + // Testnet enterprise addresses use prefix "addr_test1v..." + assert!(bech32.starts_with("addr_test1v"), "got {bech32}"); + } +} diff --git a/crates/aldabra-dao/src/builder/mod.rs b/crates/aldabra-dao/src/builder/mod.rs index 6bb71aa..5dec88f 100644 --- a/crates/aldabra-dao/src/builder/mod.rs +++ b/crates/aldabra-dao/src/builder/mod.rs @@ -30,3 +30,5 @@ pub mod escrow_agree; pub mod escrow_deposit; #[cfg(feature = "escrow_wip")] pub mod escrow_open; +#[cfg(feature = "escrow_wip")] +pub mod escrow_veto;