feat(escrow_wip): build_unsigned_escrow_agree builder

Plutus V3 spend that flips state Open → Agreed { agreed_at_ms = upper }.
Both party_a and party_b sign — driver returns partially-signed CBOR,
co-signer adds witness via wallet_sign_partial, signed tx submitted.

Validator enforces validity_upper ≤ open_deadline_ms (preflighted),
value preserved bit-exact, deposits preserved bit-exact via cbor.serialise
equality (passes naturally since we copy the Vec verbatim and
to_plutus_data emits deterministic KeyValuePairs).

7 tests cover negative paths (non-Open state, outsider driver,
post-deadline upper) and happy paths (a-drives, b-drives, datum
state flips correctly, immutable fields + deposits preserved).
This commit is contained in:
Sulkta 2026-05-09 12:34:57 -07:00
parent ec3e98a1c5
commit 415c099240
2 changed files with 522 additions and 0 deletions

View file

@ -0,0 +1,520 @@
//! Build an unsigned `escrow_agree_unsigned` transaction.
//!
//! ⚠️ WIP / UNAUDITED. Feature-gated behind `escrow_wip`.
//!
//! ## What this tx does
//!
//! Plutus V3 spend that flips an escrow's state from `Open` to
//! `Agreed { agreed_at_ms = validity_upper_ms }`. Both party_a and
//! party_b must sign — the returned CBOR is partially-signed by the
//! driver, the co-signer adds their witness via `wallet_sign_partial`,
//! and the resulting signed tx is submitted.
//!
//! - **Inputs**:
//! - The escrow UTxO (Plutus V3 spend, redeemer = `Agree`).
//! - One funding wallet UTxO from the driver.
//! - **Collateral**: 1 ADA-only ≥5 ADA wallet UTxO from the driver.
//! - **Outputs**:
//! - Continuing escrow UTxO at the same script address. Inline datum =
//! old datum with `state = Agreed { agreed_at_ms: validity_upper_ms }`;
//! all other fields including `deposits` preserved bit-exact.
//! Lovelace + native assets preserved bit-exact (validator enforces
//! `value_geq_value` both directions = equality).
//! - Wallet change to driver.
//! - **Disclosed signers**: BOTH `party_a` and `party_b`.
//! - **Validity range**: `valid_from_slot(tip_slot)` /
//! `invalid_from_slot(validity_upper_slot)`. The latter MUST encode
//! the same posix-ms as `validity_upper_ms` — validator extracts
//! `upper` from the chain's reconstructed `validityRange.upperBound`
//! and writes it into `Agreed { agreed_at_ms: upper }`. If the
//! embedded ms diverges from what the chain reconstructs, the
//! validator's `new_d.state == Agreed { agreed_at_ms: upper }` check
//! fails.
//!
//! ## What the validator enforces (must match)
//!
//! From `aiken-escrow/validators/escrow.ak` Agree branch:
//!
//! 1. `d.state == Open`.
//! 2. `signed_by(self, d.party_a)` AND `signed_by(self, d.party_b)`.
//! 3. Tx validity range upper bound `Some(upper)` (Finite) AND
//! `upper <= d.open_deadline_ms`.
//! 4. Continuing output exists at `script_addr` with parsable datum.
//! 5. New datum's value equal to old (both `value_geq_value` directions).
//! 6. New datum equal to old in every field except `state`:
//! party_a / party_b / recipient / open_deadline_ms / lock_period_ms
//! preserved.
//! 7. `cbor.serialise(new_d.deposits) == cbor.serialise(d.deposits)` —
//! deposits preserved bit-exact.
//! 8. `new_d.state == Agreed { agreed_at_ms: upper }`.
//!
//! All eight are preflighted client-side.
use pallas_codec::minicbor;
use pallas_crypto::hash::Hash;
use pallas_txbuilder::{BuildConway, ExUnits, Input, Output, ScriptKind, StagingTransaction};
use crate::agora::escrow::{EscrowDatum, EscrowRedeemer, EscrowState, PKH_LEN};
use crate::config::{DaoConfig, DaoNetwork};
use crate::error::{DaoError, DaoResult};
use super::escrow_deposit::{
EscrowUtxoIn, ESCROW_OUTPUT_MIN_LOVELACE, 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;
/// Args bundle for [`build_unsigned_escrow_agree`].
#[derive(Debug, Clone)]
pub struct EscrowAgreeArgs {
pub cfg: DaoConfig,
pub escrow_script_address: String,
/// Compiled Plutus V3 UPLC bytecode of the escrow validator. Inlined
/// in the tx witness for v1.
pub validator_script_cbor: Vec<u8>,
pub escrow_in: EscrowUtxoIn,
/// The party driving the tx (pays fees, receives change). Must equal
/// `escrow_in.datum.party_a` or `escrow_in.datum.party_b`. The OTHER
/// party adds their witness via `wallet_sign_partial` after the
/// driver returns the unsigned CBOR.
pub driver_pkh: [u8; PKH_LEN],
pub change_address: String,
pub wallet_utxos: Vec<WalletUtxo>,
pub tip_slot: u64,
/// Slot to set `invalid_from_slot(...)` on. Must encode the same
/// posix-ms as `validity_upper_ms` (caller does the slot↔ms conv).
pub validity_upper_slot: u64,
/// POSIX-ms equivalent of `validity_upper_slot`. Embedded as
/// `agreed_at_ms` on the new `Agreed` state. Validator extracts
/// `upper` from the chain-reconstructed validity range and the
/// equality check `new_d.state == Agreed { agreed_at_ms: upper }`
/// is the source of truth.
pub validity_upper_ms: i64,
pub fee_lovelace: u64,
pub ex_units: ExUnits,
}
/// What [`build_unsigned_escrow_agree`] returns.
#[derive(Debug, Clone)]
pub struct UnsignedEscrowAgree {
pub tx_cbor_hex: String,
pub tx_hash_hex: String,
pub escrow_script_address: String,
/// Hex-encoded CBOR of the new (post-Agree) escrow datum.
pub new_datum_cbor_hex: String,
/// `agreed_at_ms` baked into the new datum.
pub agreed_at_ms: i64,
/// PKH of the co-signer who must sign next.
pub co_signer_pkh_hex: String,
pub summary: String,
}
/// Build the unsigned escrow_agree tx. Caller signs (as driver), passes
/// the partial-signed CBOR to the co-signer, who completes signing and
/// submits.
pub fn build_unsigned_escrow_agree(args: EscrowAgreeArgs) -> DaoResult<UnsignedEscrowAgree> {
let datum_in = &args.escrow_in.datum;
// ---- preflight ----------------------------------------------------------
// (1) state must be Open.
if datum_in.state != EscrowState::Open {
return Err(DaoError::State(format!(
"escrow state is {:?}, must be Open to Agree",
datum_in.state
)));
}
// Driver must be a or b. (Validator enforces both signers; we infer
// the co-signer from "the other party.")
let (driver_is_a, driver_is_b) = (
args.driver_pkh == datum_in.party_a,
args.driver_pkh == datum_in.party_b,
);
if !(driver_is_a || driver_is_b) {
return Err(DaoError::State(
"driver_pkh is neither party_a nor party_b — only escrow parties can drive Agree"
.into(),
));
}
let co_signer_pkh = if driver_is_a {
datum_in.party_b
} else {
datum_in.party_a
};
// (3) validity upper ≤ open_deadline_ms.
if args.validity_upper_ms > datum_in.open_deadline_ms {
return Err(DaoError::State(format!(
"validity_upper_ms {} > open_deadline_ms {} — Agree must be inside the open window",
args.validity_upper_ms, datum_in.open_deadline_ms
)));
}
// Output value must equal input value — preserved bit-exact.
if args.escrow_in.lovelace < ESCROW_OUTPUT_MIN_LOVELACE {
return Err(DaoError::State(format!(
"escrow input lovelace {} < min {ESCROW_OUTPUT_MIN_LOVELACE} — \
Agree preserves value, so the input must already clear the floor",
args.escrow_in.lovelace
)));
}
// ---- compute new datum --------------------------------------------------
//
// Only `state` mutates. Everything else (deposits included) is copied
// bit-exact. Validator's deposits-cbor-equality check passes because
// we use the SAME Vec — round-trip through to_plutus_data emits the
// same KeyValuePairs ordering.
let new_datum = EscrowDatum {
party_a: datum_in.party_a,
party_b: datum_in.party_b,
recipient: datum_in.recipient,
open_deadline_ms: datum_in.open_deadline_ms,
lock_period_ms: datum_in.lock_period_ms,
state: EscrowState::Agreed {
agreed_at_ms: args.validity_upper_ms,
},
deposits: datum_in.deposits.clone(),
};
let new_datum_pd = new_datum.to_plutus_data()?;
let new_datum_cbor = minicbor::to_vec(&new_datum_pd)
.map_err(|e| DaoError::Cbor(format!("new escrow datum encode: {e}")))?;
// ---- redeemer -----------------------------------------------------------
let redeemer_pd = EscrowRedeemer::Agree.to_plutus_data()?;
let redeemer_cbor = minicbor::to_vec(&redeemer_pd)
.map_err(|e| DaoError::Cbor(format!("agree redeemer encode: {e}")))?;
// ---- pick funding + collateral -----------------------------------------
let mut ada_only: Vec<WalletUtxo> = 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 Agree (separate from collateral)"
.into(),
)
})?;
// ---- balance + change ---------------------------------------------------
//
// total_in = escrow_in.lovelace + funding.lovelace
// outputs = escrow_in.lovelace (preserved) + change + fee
// change = funding.lovelace - fee
//
// Since the escrow output preserves its lovelace, the driver's fee
// comes entirely out of funding. Change is funding - fee.
let new_escrow_lovelace = args.escrow_in.lovelace;
let total_in = args
.escrow_in
.lovelace
.checked_add(funding.lovelace)
.ok_or_else(|| DaoError::State("input lovelace overflow".into()))?;
let total_out = new_escrow_lovelace
.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} \
(escrow_out={new_escrow_lovelace} + fee={})",
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 escrow_addr = parse_address(&args.escrow_script_address)?;
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,
};
// Continuing escrow output: preserve value bit-exact, update datum.
let mut new_escrow_output = Output::new(escrow_addr, new_escrow_lovelace)
.set_inline_datum(new_datum_cbor.clone());
for (policy_hex, name_hex, qty) in &args.escrow_in.assets {
let policy = parse_script_hash(policy_hex)?;
let name = hex::decode(name_hex)
.map_err(|e| DaoError::Config(format!("escrow_in asset name hex: {e}")))?;
new_escrow_output = new_escrow_output
.add_asset(policy, name, *qty)
.map_err(|e| DaoError::Backend(format!("preserve escrow asset: {e}")))?;
}
let mut staging = StagingTransaction::new();
staging = staging.input(escrow_input.clone());
staging = staging.input(funding_input);
staging = staging.collateral_input(collateral_input);
staging = staging.output(new_escrow_output);
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);
// BOTH parties as disclosed signers — validator checks both.
staging = staging.disclosed_signer(Hash::<28>::from(datum_in.party_a));
staging = staging.disclosed_signer(Hash::<28>::from(datum_in.party_b));
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_agree_unsigned: driver={} co_signer={} agreed_at_ms={} (open_deadline={}, fee={})",
hex::encode(args.driver_pkh),
hex::encode(co_signer_pkh),
args.validity_upper_ms,
datum_in.open_deadline_ms,
args.fee_lovelace,
);
Ok(UnsignedEscrowAgree {
tx_cbor_hex,
tx_hash_hex,
escrow_script_address: args.escrow_script_address,
new_datum_cbor_hex: hex::encode(&new_datum_cbor),
agreed_at_ms: args.validity_upper_ms,
co_signer_pkh_hex: hex::encode(co_signer_pkh),
summary,
})
}
#[cfg(test)]
mod tests {
use super::*;
use crate::agora::escrow::{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<u8> {
vec![0x46, 0x01, 0x00, 0x00, 0x32, 0x22]
}
fn sample_args(state: EscrowState, validity_upper_ms: i64) -> EscrowAgreeArgs {
let escrow_in = EscrowUtxoIn {
tx_hash_hex: "11".repeat(32),
output_index: 0,
lovelace: 10_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: vec![
EscrowDeposit {
contributor: pkh(0xa1),
value: EscrowValue::ada(5_000_000),
},
EscrowDeposit {
contributor: pkh(0xb2),
value: EscrowValue::ada(5_000_000),
},
],
},
};
EscrowAgreeArgs {
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: 20_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,
validity_upper_ms,
fee_lovelace: 2_500_000,
ex_units: ESCROW_SPEND_EX_UNITS,
}
}
#[test]
fn rejects_when_state_not_open() {
let args = sample_args(
EscrowState::Agreed { agreed_at_ms: 0 },
1_699_999_900_000,
);
let err = build_unsigned_escrow_agree(args).unwrap_err();
assert!(err.to_string().contains("must be Open"));
}
#[test]
fn rejects_when_driver_not_a_party() {
let mut args = sample_args(EscrowState::Open, 1_699_999_900_000);
args.driver_pkh = pkh(0xff);
let err = build_unsigned_escrow_agree(args).unwrap_err();
assert!(err.to_string().contains("party_a nor party_b"));
}
#[test]
fn rejects_when_validity_upper_past_deadline() {
// open_deadline_ms = 1_700_000_000_000; pick upper just past it.
let args = sample_args(EscrowState::Open, 1_700_000_000_001);
let err = build_unsigned_escrow_agree(args).unwrap_err();
assert!(err.to_string().contains("open_deadline_ms"));
}
#[test]
fn happy_path_a_drives_b_is_co_signer() {
let args = sample_args(EscrowState::Open, 1_699_999_900_000);
let unsigned = build_unsigned_escrow_agree(args).unwrap();
assert_eq!(unsigned.agreed_at_ms, 1_699_999_900_000);
assert_eq!(unsigned.co_signer_pkh_hex, hex::encode(pkh(0xb2)));
assert!(unsigned.summary.contains("agreed_at_ms"));
}
#[test]
fn happy_path_b_drives_a_is_co_signer() {
let mut args = sample_args(EscrowState::Open, 1_699_999_900_000);
args.driver_pkh = pkh(0xb2);
let unsigned = build_unsigned_escrow_agree(args).unwrap();
assert_eq!(unsigned.co_signer_pkh_hex, hex::encode(pkh(0xa1)));
}
#[test]
fn datum_state_flips_to_agreed_with_validity_upper_ms() {
let args = sample_args(EscrowState::Open, 1_699_998_000_000);
let unsigned = build_unsigned_escrow_agree(args).unwrap();
let pd: pallas_primitives::PlutusData =
minicbor::decode(&hex::decode(&unsigned.new_datum_cbor_hex).unwrap()).unwrap();
let new_datum = EscrowDatum::from_plutus_data(&pd).unwrap();
match new_datum.state {
EscrowState::Agreed { agreed_at_ms } => {
assert_eq!(agreed_at_ms, 1_699_998_000_000);
}
other => panic!("expected Agreed state, got {other:?}"),
}
}
#[test]
fn datum_immutable_fields_and_deposits_preserved() {
let args = sample_args(EscrowState::Open, 1_699_999_900_000);
let original = args.escrow_in.datum.clone();
let unsigned = build_unsigned_escrow_agree(args).unwrap();
let pd: pallas_primitives::PlutusData =
minicbor::decode(&hex::decode(&unsigned.new_datum_cbor_hex).unwrap()).unwrap();
let new_datum = EscrowDatum::from_plutus_data(&pd).unwrap();
assert_eq!(new_datum.party_a, original.party_a);
assert_eq!(new_datum.party_b, original.party_b);
assert_eq!(new_datum.recipient, original.recipient);
assert_eq!(new_datum.open_deadline_ms, original.open_deadline_ms);
assert_eq!(new_datum.lock_period_ms, original.lock_period_ms);
assert_eq!(new_datum.deposits, original.deposits);
}
}

View file

@ -24,6 +24,8 @@ pub mod proposal_retract_votes;
pub mod proposal_vote;
pub mod stake_destroy;
#[cfg(feature = "escrow_wip")]
pub mod escrow_agree;
#[cfg(feature = "escrow_wip")]
pub mod escrow_deposit;
#[cfg(feature = "escrow_wip")]