feat(escrow_wip): EscrowDatum / EscrowRedeemer / EscrowValue codecs

⚠ WIP — UNAUDITED. Feature-gated behind `escrow_wip`; out of default builds.

Mirrors aiken validator at aiken-escrow/escrow/validators/escrow.ak (also
WIP). Spec at audits/2026-05-09-escrow-spec.md.

Five-redeemer two-party agreement-with-veto escrow:
- Open → both sign Agree → Agreed{at} → Settle (after lock_period)
- Agreed → either party Veto → per-contributor refund
- Open → Refund (after open_deadline) → per-contributor refund

Datum: ProductIsData (Constr 0 [a, b, recipient, deadline, lock, state, deposits]).
EscrowState: enum Open | Agreed{at} encoded as Constr 0/1 (with payload).
EscrowDeposit: per-contributor (pkh, Value) entry, Constr 0.
EscrowValue: Plutus Map<PolicyId, Map<AssetName, Int>>; preserves on-chain
ordering for cbor-equality checks the validator does on continuing-output
deposits diff.

Tests: 10 codec roundtrips (Open/Agreed states, ada-only/multi-asset values,
deposit lookup by pkh, value-merge sum). All pass under
`cargo test -p aldabra-dao --features escrow_wip escrow::`.

Builders + MCP tools land in follow-up commits on this branch.
This commit is contained in:
Kayos 2026-05-09 11:32:26 -07:00
parent 29dc6c8443
commit f89877bf8e
3 changed files with 488 additions and 0 deletions

View file

@ -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"

View file

@ -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<u8>, Vec<(Vec<u8>, 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<PlutusData> {
// Map<bytes, Map<bytes, int>>
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<Self> {
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<PlutusData> {
Ok(constr(
0,
vec![bytes(&self.contributor), self.value.to_plutus_data()?],
))
}
pub fn from_plutus_data(pd: &PlutusData) -> DaoResult<Self> {
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<PlutusData> {
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<Self> {
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<EscrowDeposit>,
}
impl EscrowDatum {
pub fn to_plutus_data(&self) -> DaoResult<PlutusData> {
let deposits_pd = product(
self.deposits
.iter()
.map(|d| d.to_plutus_data())
.collect::<DaoResult<Vec<_>>>()?,
);
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<Self> {
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::<DaoResult<Vec<_>>>()?;
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<PlutusData> {
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());
}
}

View file

@ -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,