diff --git a/crates/aldabra-dao/Cargo.toml b/crates/aldabra-dao/Cargo.toml index 1fe0df3..5687b29 100644 --- a/crates/aldabra-dao/Cargo.toml +++ b/crates/aldabra-dao/Cargo.toml @@ -71,6 +71,13 @@ thiserror = { workspace = true } # Logging. tracing = { workspace = true } +[features] +default = [] +# WIP / unaudited two-party escrow validator + builders + types. Compiled +# out of default builds until external audit lands. Enable with +# --features escrow_wip from the workspace root. +escrow_wip = [] + [dev-dependencies] # DaoStore tests use a temp dir as the data root. tempfile = "3" diff --git a/crates/aldabra-dao/src/agora/escrow.rs b/crates/aldabra-dao/src/agora/escrow.rs new file mode 100644 index 0000000..7aab1e4 --- /dev/null +++ b/crates/aldabra-dao/src/agora/escrow.rs @@ -0,0 +1,478 @@ +//! Escrow datum + redeemer encoding. +//! +//! ⚠️ WIP / UNAUDITED. Feature-gated behind `escrow_wip`. Preprod-only. +//! +//! Mirrors the on-chain validator at `aiken-escrow/escrow/validators/escrow.ak`. +//! See `audits/2026-05-09-escrow-spec.md` for the full state machine. +//! +//! ## Datum shape +//! +//! ```text +//! EscrowDatum Constr 0 +//! party_a: bytes(28) +//! party_b: bytes(28) +//! recipient: bytes(28) +//! open_deadline_ms: Int +//! lock_period_ms: Int +//! state: EscrowState +//! deposits: [Deposit] +//! +//! EscrowState +//! Open Constr 0 [] +//! Agreed { agreed_at_ms } Constr 1 [Int] +//! +//! Deposit Constr 0 +//! contributor: bytes(28) +//! value: Value (Map PolicyId (Map AssetName Int)) +//! ``` +//! +//! ## Redeemer shape +//! +//! ```text +//! Deposit { contributor } Constr 0 [bytes(28)] +//! Agree Constr 1 [] +//! Veto Constr 2 [] +//! Settle Constr 3 [] +//! Refund Constr 4 [] +//! ``` +//! +//! `Value` (the Plutus Value type) is encoded as `Map PolicyId (Map AssetName +//! Int)`. ADA appears as `policy = empty bytes` and `asset_name = empty +//! bytes`. Order isn't strictly canonical on chain but our encoding emits +//! ADA first within a policy, then asset names lexicographically. + +use pallas_codec::utils::KeyValuePairs; +use pallas_primitives::PlutusData; + +use crate::agora::plutus_data::{as_array, as_bytes, as_constr, as_int, as_map, bytes, constr, int, product}; +use crate::error::{DaoError, DaoResult}; + +/// Length of a Cardano payment-credential hash (Blake2b-224). +pub const PKH_LEN: usize = 28; + +/// Length of an asset PolicyId (also Blake2b-224 of the minting policy script). +pub const POLICY_LEN: usize = 28; + +/// Plutus `Value` — `Map PolicyId (Map AssetName Int)`. +/// +/// Outer Vec preserves on-chain ordering when re-encoding. ADA is encoded +/// as `policy = []` with a single `asset_name = []` entry. +/// +/// We don't bother with a HashMap-based representation because escrows are +/// tiny (typically <10 distinct (policy, asset) pairs) and exact byte +/// reproduction matters for validator equality checks via `cbor.serialise`. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct EscrowValue { + /// Each entry: `(policy_id_28b_or_empty_for_ada, [(asset_name, qty)])`. + pub policies: Vec<(Vec, Vec<(Vec, i128)>)>, +} + +impl EscrowValue { + /// Empty value (no assets). + pub fn empty() -> Self { + Self { policies: vec![] } + } + + /// Helper: pure-ADA value with given lovelace amount. + pub fn ada(lovelace: u64) -> Self { + Self { + policies: vec![(Vec::new(), vec![(Vec::new(), lovelace as i128)])], + } + } + + /// Total lovelace in this value (0 if no ADA entry). + pub fn lovelace(&self) -> u64 { + for (policy, assets) in &self.policies { + if policy.is_empty() { + for (name, qty) in assets { + if name.is_empty() { + return (*qty).max(0) as u64; + } + } + } + } + 0 + } + + pub fn to_plutus_data(&self) -> DaoResult { + // Map> + let mut outer = Vec::with_capacity(self.policies.len()); + for (policy, assets) in &self.policies { + let mut inner = Vec::with_capacity(assets.len()); + for (name, qty) in assets { + inner.push((bytes(name), int(*qty)?)); + } + outer.push((bytes(policy), PlutusData::Map(KeyValuePairs::from(inner)))); + } + Ok(PlutusData::Map(KeyValuePairs::from(outer))) + } + + pub fn from_plutus_data(pd: &PlutusData) -> DaoResult { + let outer = as_map(pd)?; + let mut policies = Vec::with_capacity(outer.len()); + for (policy_pd, inner_pd) in outer { + let policy = as_bytes(policy_pd)?; + let inner = as_map(inner_pd)?; + let mut assets = Vec::with_capacity(inner.len()); + for (name_pd, qty_pd) in inner { + let name = as_bytes(name_pd)?; + let qty = as_int(qty_pd)?; + assets.push((name, qty)); + } + policies.push((policy, assets)); + } + Ok(Self { policies }) + } +} + +/// Per-contributor accounting entry. Mirrors aiken `Deposit { contributor, value }`. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct EscrowDeposit { + pub contributor: [u8; PKH_LEN], + pub value: EscrowValue, +} + +impl EscrowDeposit { + pub fn to_plutus_data(&self) -> DaoResult { + Ok(constr( + 0, + vec![bytes(&self.contributor), self.value.to_plutus_data()?], + )) + } + + pub fn from_plutus_data(pd: &PlutusData) -> DaoResult { + let (idx, fields) = as_constr(pd)?; + if idx != 0 || fields.len() != 2 { + return Err(DaoError::Datum(format!( + "EscrowDeposit expects Constr 0 with 2 fields, got Constr {idx} with {} fields", + fields.len() + ))); + } + let contributor_bytes = as_bytes(&fields[0])?; + let contributor = pkh_from_bytes(&contributor_bytes, "EscrowDeposit.contributor")?; + let value = EscrowValue::from_plutus_data(&fields[1])?; + Ok(Self { contributor, value }) + } +} + +/// Escrow lifecycle state. Mirrors aiken `EscrowState`. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum EscrowState { + Open, + /// Both parties have signed Agree. `agreed_at_ms` = the validity range + /// upper bound of the Agree tx, in POSIX ms. + Agreed { agreed_at_ms: i64 }, +} + +impl EscrowState { + pub fn to_plutus_data(self) -> DaoResult { + match self { + EscrowState::Open => Ok(constr(0, vec![])), + EscrowState::Agreed { agreed_at_ms } => Ok(constr(1, vec![int(agreed_at_ms as i128)?])), + } + } + + pub fn from_plutus_data(pd: &PlutusData) -> DaoResult { + let (idx, fields) = as_constr(pd)?; + match (idx, fields.len()) { + (0, 0) => Ok(EscrowState::Open), + (1, 1) => { + let n = as_int(&fields[0])?; + let agreed_at_ms = i64::try_from(n).map_err(|_| { + DaoError::Datum(format!("EscrowState::Agreed agreed_at_ms {n} overflows i64")) + })?; + Ok(EscrowState::Agreed { agreed_at_ms }) + } + (i, n) => Err(DaoError::Datum(format!( + "EscrowState expects Constr 0 [] or 1 [Int], got Constr {i} with {n} fields" + ))), + } + } +} + +/// Full datum living at the escrow script. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct EscrowDatum { + pub party_a: [u8; PKH_LEN], + pub party_b: [u8; PKH_LEN], + pub recipient: [u8; PKH_LEN], + pub open_deadline_ms: i64, + pub lock_period_ms: i64, + pub state: EscrowState, + pub deposits: Vec, +} + +impl EscrowDatum { + pub fn to_plutus_data(&self) -> DaoResult { + let deposits_pd = product( + self.deposits + .iter() + .map(|d| d.to_plutus_data()) + .collect::>>()?, + ); + Ok(constr( + 0, + vec![ + bytes(&self.party_a), + bytes(&self.party_b), + bytes(&self.recipient), + int(self.open_deadline_ms as i128)?, + int(self.lock_period_ms as i128)?, + self.state.to_plutus_data()?, + deposits_pd, + ], + )) + } + + pub fn from_plutus_data(pd: &PlutusData) -> DaoResult { + let (idx, fields) = as_constr(pd)?; + if idx != 0 || fields.len() != 7 { + return Err(DaoError::Datum(format!( + "EscrowDatum expects Constr 0 with 7 fields, got Constr {idx} with {} fields", + fields.len() + ))); + } + let party_a = pkh_from_bytes(&as_bytes(&fields[0])?, "EscrowDatum.party_a")?; + let party_b = pkh_from_bytes(&as_bytes(&fields[1])?, "EscrowDatum.party_b")?; + let recipient = pkh_from_bytes(&as_bytes(&fields[2])?, "EscrowDatum.recipient")?; + let open_deadline_ms = i64::try_from(as_int(&fields[3])?) + .map_err(|_| DaoError::Datum("EscrowDatum.open_deadline_ms overflows i64".into()))?; + let lock_period_ms = i64::try_from(as_int(&fields[4])?) + .map_err(|_| DaoError::Datum("EscrowDatum.lock_period_ms overflows i64".into()))?; + let state = EscrowState::from_plutus_data(&fields[5])?; + let deposits = as_array(&fields[6])? + .iter() + .map(EscrowDeposit::from_plutus_data) + .collect::>>()?; + Ok(Self { + party_a, + party_b, + recipient, + open_deadline_ms, + lock_period_ms, + state, + deposits, + }) + } + + /// Convenience: total value held across all deposit entries. + pub fn total_value(&self) -> EscrowValue { + let mut acc = EscrowValue::empty(); + for d in &self.deposits { + acc = value_merge(&acc, &d.value); + } + acc + } + + /// Look up a deposit entry by contributor PKH. + pub fn deposit_for(&self, pkh: &[u8; PKH_LEN]) -> Option<&EscrowDeposit> { + self.deposits.iter().find(|d| &d.contributor == pkh) + } +} + +/// Redeemer cases. Mirrors aiken `EscrowRedeemer`. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum EscrowRedeemer { + Deposit { contributor: [u8; PKH_LEN] }, + Agree, + Veto, + Settle, + Refund, +} + +impl EscrowRedeemer { + pub fn to_plutus_data(&self) -> DaoResult { + Ok(match self { + EscrowRedeemer::Deposit { contributor } => constr(0, vec![bytes(contributor)]), + EscrowRedeemer::Agree => constr(1, vec![]), + EscrowRedeemer::Veto => constr(2, vec![]), + EscrowRedeemer::Settle => constr(3, vec![]), + EscrowRedeemer::Refund => constr(4, vec![]), + }) + } +} + +// ---------- helpers ---------- + +fn pkh_from_bytes(bs: &[u8], field: &str) -> DaoResult<[u8; PKH_LEN]> { + if bs.len() != PKH_LEN { + return Err(DaoError::Datum(format!( + "{field} expects {PKH_LEN} bytes, got {}", + bs.len() + ))); + } + let mut out = [0u8; PKH_LEN]; + out.copy_from_slice(bs); + Ok(out) +} + +/// Component-wise add of two EscrowValues (preserves first-seen order of +/// policies and asset names). Used for tracking total value held. +pub fn value_merge(a: &EscrowValue, b: &EscrowValue) -> EscrowValue { + let mut out = a.clone(); + for (b_policy, b_assets) in &b.policies { + let policy_idx = out.policies.iter().position(|(p, _)| p == b_policy); + match policy_idx { + Some(i) => { + for (b_name, b_qty) in b_assets { + let name_idx = out.policies[i].1.iter().position(|(n, _)| n == b_name); + match name_idx { + Some(j) => out.policies[i].1[j].1 += *b_qty, + None => out.policies[i].1.push((b_name.clone(), *b_qty)), + } + } + } + None => out.policies.push((b_policy.clone(), b_assets.clone())), + } + } + out +} + +#[cfg(test)] +mod tests { + use super::*; + + fn pkh(seed: u8) -> [u8; PKH_LEN] { + [seed; PKH_LEN] + } + + #[test] + fn escrow_value_ada_roundtrip() { + let v = EscrowValue::ada(1_500_000); + let pd = v.to_plutus_data().unwrap(); + let v2 = EscrowValue::from_plutus_data(&pd).unwrap(); + assert_eq!(v, v2); + assert_eq!(v2.lovelace(), 1_500_000); + } + + #[test] + fn escrow_value_multi_asset_roundtrip() { + let v = EscrowValue { + policies: vec![ + (Vec::new(), vec![(Vec::new(), 2_000_000)]), + ( + vec![0xaa; POLICY_LEN], + vec![(b"TRP".to_vec(), 100), (b"MAP".to_vec(), 5)], + ), + ], + }; + let pd = v.to_plutus_data().unwrap(); + let v2 = EscrowValue::from_plutus_data(&pd).unwrap(); + assert_eq!(v, v2); + assert_eq!(v2.lovelace(), 2_000_000); + } + + #[test] + fn escrow_state_open_roundtrip() { + let s = EscrowState::Open; + let pd = s.to_plutus_data().unwrap(); + assert_eq!(EscrowState::from_plutus_data(&pd).unwrap(), s); + } + + #[test] + fn escrow_state_agreed_roundtrip() { + let s = EscrowState::Agreed { agreed_at_ms: 1_700_000_000_000 }; + let pd = s.to_plutus_data().unwrap(); + assert_eq!(EscrowState::from_plutus_data(&pd).unwrap(), s); + } + + #[test] + fn escrow_deposit_roundtrip() { + let d = EscrowDeposit { + contributor: pkh(0xa1), + value: EscrowValue::ada(3_000_000), + }; + let pd = d.to_plutus_data().unwrap(); + assert_eq!(EscrowDeposit::from_plutus_data(&pd).unwrap(), d); + } + + #[test] + fn escrow_datum_open_empty_deposits_roundtrip() { + let d = EscrowDatum { + party_a: pkh(0xa1), + party_b: pkh(0xb2), + recipient: pkh(0xb2), + open_deadline_ms: 1_700_000_000_000, + lock_period_ms: 60 * 60 * 1000, + state: EscrowState::Open, + deposits: vec![], + }; + let pd = d.to_plutus_data().unwrap(); + let d2 = EscrowDatum::from_plutus_data(&pd).unwrap(); + assert_eq!(d, d2); + } + + #[test] + fn escrow_datum_agreed_with_deposits_roundtrip() { + let d = EscrowDatum { + party_a: pkh(0xa1), + party_b: pkh(0xb2), + recipient: pkh(0xc3), + open_deadline_ms: 1_700_000_000_000, + lock_period_ms: 30 * 60 * 1000, + state: EscrowState::Agreed { agreed_at_ms: 1_700_000_500_000 }, + deposits: vec![ + EscrowDeposit { + contributor: pkh(0xa1), + value: EscrowValue::ada(5_000_000), + }, + EscrowDeposit { + contributor: pkh(0xb2), + value: EscrowValue::ada(7_000_000), + }, + ], + }; + let pd = d.to_plutus_data().unwrap(); + let d2 = EscrowDatum::from_plutus_data(&pd).unwrap(); + assert_eq!(d, d2); + assert_eq!(d2.total_value().lovelace(), 12_000_000); + } + + #[test] + fn escrow_redeemer_all_constrs() { + for r in [ + EscrowRedeemer::Deposit { contributor: pkh(0xa1) }, + EscrowRedeemer::Agree, + EscrowRedeemer::Veto, + EscrowRedeemer::Settle, + EscrowRedeemer::Refund, + ] { + // Just ensure encoding doesn't panic and yields a Constr. + let pd = r.to_plutus_data().unwrap(); + let (_idx, _fields) = as_constr(&pd).unwrap(); + } + } + + #[test] + fn value_merge_sums_components() { + let a = EscrowValue::ada(1_000_000); + let b = EscrowValue { + policies: vec![ + (Vec::new(), vec![(Vec::new(), 2_500_000)]), + (vec![0x11; POLICY_LEN], vec![(b"TRP".to_vec(), 50)]), + ], + }; + let merged = value_merge(&a, &b); + assert_eq!(merged.lovelace(), 3_500_000); + assert_eq!(merged.policies.len(), 2); + assert_eq!(merged.policies[1].1[0], (b"TRP".to_vec(), 50)); + } + + #[test] + fn deposit_for_finds_match() { + let d = EscrowDatum { + party_a: pkh(0xa1), + party_b: pkh(0xb2), + recipient: pkh(0xb2), + open_deadline_ms: 0, + lock_period_ms: 0, + state: EscrowState::Open, + deposits: vec![EscrowDeposit { + contributor: pkh(0xa1), + value: EscrowValue::ada(1_000_000), + }], + }; + assert!(d.deposit_for(&pkh(0xa1)).is_some()); + assert!(d.deposit_for(&pkh(0xff)).is_none()); + } +} diff --git a/crates/aldabra-dao/src/agora/mod.rs b/crates/aldabra-dao/src/agora/mod.rs index ab2394a..9d2f805 100644 --- a/crates/aldabra-dao/src/agora/mod.rs +++ b/crates/aldabra-dao/src/agora/mod.rs @@ -43,6 +43,9 @@ pub mod authority_token; pub mod plutus_data; pub mod reference_scripts; +#[cfg(feature = "escrow_wip")] +pub mod escrow; + pub use governor::{GovernorDatum, GovernorRedeemer}; pub use proposal::{ ProposalDatum, ProposalRedeemer, ProposalStatus, ProposalThresholds, ProposalTimingConfig,