feat(escrow_wip): build_unsigned_escrow_open builder

⚠ WIP — UNAUDITED. Feature-gated.

The simplest of the five escrow paths: create a fresh escrow UTxO at
the validator script address with an inline EscrowDatum + initial
deposit (or empty deposits for two-step funding). No Plutus spend
involved — this is just a wallet send to the script address with the
typed datum encoded as inline CBOR.

Wraps aldabra-core::build_unsigned_payment_extras to add:
- Typed EscrowDatum construction (avoids hand-encoding CBOR)
- Preflight: initial_contributor must be party_a or party_b
- Preflight: initial_lovelace clears protocol min_utxo floor
- Standard summary string for MCP wrappers

Tests: 2 preflight rejections (unauthorized contributor, sub-min-utxo
funding) plus the 10 codec roundtrips. All 12 pass under
`cargo test -p aldabra-dao --features escrow_wip escrow`.

Workspace also builds clean with default features (escrow_wip disabled);
the entire escrow surface compiles out of release builds.

Remaining builders (deposit / agree / veto / settle / refund) all
involve script spends, continuing-output datum diffs, and time-bound
validity — deferred to next session for proper care.
This commit is contained in:
Kayos 2026-05-09 11:42:39 -07:00
parent 78ed92304e
commit 6f260bda8d
2 changed files with 231 additions and 0 deletions

View file

@ -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<AssetSpec>,
/// Caller's bech32 base address (for change).
pub change_address: String,
/// Caller's spendable wallet UTxOs.
pub wallet_utxos: Vec<InputUtxo>,
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<UnsignedEscrowOpen> {
// ---- 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(_))));
}
}

View file

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