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:
parent
78ed92304e
commit
6f260bda8d
2 changed files with 231 additions and 0 deletions
228
crates/aldabra-dao/src/builder/escrow_open.rs
Normal file
228
crates/aldabra-dao/src/builder/escrow_open.rs
Normal 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(_))));
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue