feat(escrow_wip): build_unsigned_escrow_refund_timeout builder

Plutus V3 spend that consumes an Open escrow whose open_deadline has
elapsed and refunds every contributor. Same multi-output refund shape
as Veto, different validator gates: state==Open AND lower >
open_deadline_ms (strict gt). No required signer — time gate is the
only gate.

Reuses enterprise_address_for from escrow_veto. 5 tests: not-Open
reject, open-window-not-elapsed reject (off-by-one strict gt),
empty-escrow reject, two-contributor full refund happy path,
outsider driver works.

Closes the 5-redeemer escrow surface: open + deposit + agree + veto +
settle + refund_timeout. 35/35 escrow builder tests pass.
This commit is contained in:
Kayos 2026-05-09 12:42:16 -07:00
parent 702aae729f
commit 705acdac0c
2 changed files with 472 additions and 0 deletions

View file

@ -0,0 +1,470 @@
//! Build an unsigned `escrow_refund_timeout_unsigned` transaction.
//!
//! ⚠️ WIP / UNAUDITED. Feature-gated behind `escrow_wip`.
//!
//! ## What this tx does
//!
//! Plutus V3 spend that consumes an `Open` escrow whose `open_deadline`
//! has elapsed and refunds every contributor to their enterprise
//! (null-stake) address. No signer required — the time gate is
//! sufficient. This is the "no agreement reached → bail out" path.
//!
//! Structurally identical to `escrow_veto` (multi-output refund per
//! deposit) but with different validator gates:
//!
//! | Branch | State req. | Time gate | Signer req. |
//! |------------|------------|---------------------------------|-------------------|
//! | Veto | Agreed | none | party_a OR party_b|
//! | Refund | Open | `lower > open_deadline_ms` | none |
//!
//! - **Inputs**:
//! - The escrow UTxO (Plutus V3 spend, redeemer = `Refund`).
//! - One funding wallet UTxO from the driver.
//! - **Collateral**: 1 ADA-only ≥5 ADA wallet UTxO from the driver.
//! - **Outputs**: One per deposit entry → contributor enterprise address.
//! + wallet change.
//! - **Disclosed signer**: driver pkh — needed to unlock funding +
//! collateral. Validator does NOT require a party signer.
//!
//! ## What the validator enforces (must match)
//!
//! From `aiken-escrow/validators/escrow.ak` Refund branch:
//!
//! 1. `d.state == Open`.
//! 2. Tx validity range lower bound `Some(lower)` (Finite).
//! 3. `lower > d.open_deadline_ms` — strict greater-than.
//! 4. `refund_outputs_satisfy(self.outputs, d.deposits)`.
//!
//! All four preflighted client-side.
use pallas_addresses::Address;
use pallas_codec::minicbor;
use pallas_crypto::hash::Hash;
use pallas_txbuilder::{BuildConway, ExUnits, Input, Output, ScriptKind, StagingTransaction};
use crate::agora::escrow::{EscrowRedeemer, EscrowState, PKH_LEN};
use crate::config::{DaoConfig, DaoNetwork};
use crate::error::{DaoError, DaoResult};
use super::escrow_deposit::{EscrowUtxoIn, ESCROW_SPEND_EX_UNITS};
use super::escrow_veto::enterprise_address_for;
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;
/// Same below-floor topup floor as escrow_veto. Validator's
/// value_geq_flat permits paying more than the deposit's value, so
/// topping up keeps below-min-utxo deposits submittable.
const REFUND_OUTPUT_MIN_LOVELACE: u64 = 1_000_000;
/// Args bundle for [`build_unsigned_escrow_refund_timeout`].
#[derive(Debug, Clone)]
pub struct EscrowRefundTimeoutArgs {
pub cfg: DaoConfig,
pub escrow_script_address: String,
pub validator_script_cbor: Vec<u8>,
pub escrow_in: EscrowUtxoIn,
/// Whoever's driving — pays fee, holds collateral, gets change.
/// Doesn't need to be a party. Validator doesn't enforce a signer
/// for Refund — the time gate is the gate.
pub driver_pkh: [u8; PKH_LEN],
pub change_address: String,
pub wallet_utxos: Vec<WalletUtxo>,
/// Slot to set `valid_from_slot(...)`. Must encode a posix-ms
/// `> open_deadline_ms`.
pub validity_lower_slot: u64,
/// POSIX-ms equivalent of `validity_lower_slot`. Used for the
/// preflight `lower > open_deadline_ms` check.
pub validity_lower_ms: i64,
pub validity_upper_slot: u64,
pub fee_lovelace: u64,
pub ex_units: ExUnits,
}
/// What [`build_unsigned_escrow_refund_timeout`] returns.
#[derive(Debug, Clone)]
pub struct UnsignedEscrowRefundTimeout {
pub tx_cbor_hex: String,
pub tx_hash_hex: String,
pub refunds: Vec<(String, u64)>,
pub summary: String,
}
/// Build the unsigned escrow_refund_timeout tx.
pub fn build_unsigned_escrow_refund_timeout(
args: EscrowRefundTimeoutArgs,
) -> DaoResult<UnsignedEscrowRefundTimeout> {
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 Refund-timeout",
datum_in.state
)));
}
// (3) validity_lower_ms > open_deadline_ms (strict gt).
if args.validity_lower_ms <= datum_in.open_deadline_ms {
return Err(DaoError::State(format!(
"validity_lower_ms {} must be strictly > open_deadline_ms ({}); \
open window has not elapsed",
args.validity_lower_ms, datum_in.open_deadline_ms
)));
}
if datum_in.deposits.is_empty() {
return Err(DaoError::State(
"escrow has no deposits — Refund-timeout of an empty escrow is a no-op".into(),
));
}
// ---- compute refund outputs (mirrors escrow_veto) ----------------------
let mut refund_lovelace_total: u64 = 0;
let mut topup_total: u64 = 0;
let mut refund_summaries: Vec<(String, u64)> = Vec::with_capacity(datum_in.deposits.len());
let mut refund_outputs: Vec<(Address, u64, Vec<(Vec<u8>, Vec<u8>, i128)>)> =
Vec::with_capacity(datum_in.deposits.len());
for deposit in &datum_in.deposits {
let deposit_lovelace = deposit.value.lovelace();
let pay_lovelace = deposit_lovelace.max(REFUND_OUTPUT_MIN_LOVELACE);
if pay_lovelace > deposit_lovelace {
topup_total = topup_total
.checked_add(pay_lovelace - deposit_lovelace)
.ok_or_else(|| DaoError::State("refund topup overflow".into()))?;
}
refund_lovelace_total = refund_lovelace_total
.checked_add(pay_lovelace)
.ok_or_else(|| DaoError::State("refund lovelace total overflow".into()))?;
let mut assets: Vec<(Vec<u8>, Vec<u8>, i128)> = Vec::new();
for (policy, entries) in &deposit.value.policies {
if policy.is_empty() {
continue;
}
for (name, qty) in entries {
assets.push((policy.clone(), name.clone(), *qty));
}
}
let addr = enterprise_address_for(deposit.contributor, args.cfg.network)?;
refund_outputs.push((addr, pay_lovelace, assets));
refund_summaries.push((hex::encode(deposit.contributor), pay_lovelace));
}
// ---- redeemer + collateral + funding -----------------------------------
let redeemer_pd = EscrowRedeemer::Refund.to_plutus_data()?;
let redeemer_cbor = minicbor::to_vec(&redeemer_pd)
.map_err(|e| DaoError::Cbor(format!("refund redeemer encode: {e}")))?;
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 Refund (separate from collateral)"
.into(),
)
})?;
// ---- balance + change ---------------------------------------------------
let total_in = args
.escrow_in
.lovelace
.checked_add(funding.lovelace)
.ok_or_else(|| DaoError::State("input lovelace overflow".into()))?;
let total_out = refund_lovelace_total
.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} \
(refunds={refund_lovelace_total} + fee={}; topup={topup_total})",
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 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,
};
let mut staging = StagingTransaction::new();
staging = staging.input(escrow_input.clone());
staging = staging.input(funding_input);
staging = staging.collateral_input(collateral_input);
for (addr, lovelace, assets) in refund_outputs {
let mut out = Output::new(addr, lovelace);
for (policy, name, qty) in assets {
let policy_hash = if policy.len() == 28 {
let mut a = [0u8; 28];
a.copy_from_slice(&policy);
Hash::<28>::from(a)
} else {
return Err(DaoError::State(format!(
"deposit policy id has wrong length {} (expected 28)",
policy.len()
)));
};
out = out
.add_asset(policy_hash, name, qty as u64)
.map_err(|e| DaoError::Backend(format!("refund add_asset: {e}")))?;
}
staging = staging.output(out);
}
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.validity_lower_slot);
staging = staging.invalid_from_slot(args.validity_upper_slot);
staging = staging.disclosed_signer(Hash::<28>::from(args.driver_pkh));
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_refund_timeout_unsigned: driver={} refunds={} ({} lovelace total, topup={}, fee={})",
hex::encode(args.driver_pkh),
refund_summaries.len(),
refund_lovelace_total,
topup_total,
args.fee_lovelace,
);
Ok(UnsignedEscrowRefundTimeout {
tx_cbor_hex,
tx_hash_hex,
refunds: refund_summaries,
summary,
})
}
#[cfg(test)]
mod tests {
use super::*;
use crate::agora::escrow::{EscrowDatum, 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_lower_ms: i64,
deposits: Vec<EscrowDeposit>,
) -> EscrowRefundTimeoutArgs {
let escrow_in = EscrowUtxoIn {
tx_hash_hex: "11".repeat(32),
output_index: 0,
lovelace: 12_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,
},
};
EscrowRefundTimeoutArgs {
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: 30_000_000,
assets: vec![],
},
WalletUtxo {
tx_hash_hex: "33".repeat(32),
output_index: 0,
lovelace: 8_000_000,
assets: vec![],
},
],
validity_lower_slot: 50_001_000,
validity_lower_ms,
validity_upper_slot: 50_002_800,
fee_lovelace: 2_500_000,
ex_units: ESCROW_SPEND_EX_UNITS,
}
}
#[test]
fn rejects_when_not_open() {
let agreed = EscrowState::Agreed {
agreed_at_ms: 1_699_999_900_000,
};
let args = sample_args(agreed, 1_700_000_000_001, vec![]);
let err = build_unsigned_escrow_refund_timeout(args).unwrap_err();
assert!(err.to_string().contains("must be Open"));
}
#[test]
fn rejects_when_open_window_not_elapsed() {
// open_deadline_ms = 1_700_000_000_000; equal-to is NOT past.
let deposits = vec![EscrowDeposit {
contributor: pkh(0xa1),
value: EscrowValue::ada(5_000_000),
}];
let args = sample_args(EscrowState::Open, 1_700_000_000_000, deposits);
let err = build_unsigned_escrow_refund_timeout(args).unwrap_err();
assert!(err.to_string().contains("strictly >"));
}
#[test]
fn rejects_empty_escrow() {
let args = sample_args(EscrowState::Open, 1_700_000_000_001, vec![]);
let err = build_unsigned_escrow_refund_timeout(args).unwrap_err();
assert!(err.to_string().contains("no deposits"));
}
#[test]
fn happy_path_two_contributors_full_refund() {
let deposits = vec![
EscrowDeposit {
contributor: pkh(0xa1),
value: EscrowValue::ada(5_000_000),
},
EscrowDeposit {
contributor: pkh(0xb2),
value: EscrowValue::ada(7_000_000),
},
];
let unsigned = build_unsigned_escrow_refund_timeout(sample_args(
EscrowState::Open,
1_700_000_000_001,
deposits,
))
.unwrap();
assert_eq!(unsigned.refunds.len(), 2);
assert_eq!(unsigned.refunds[0], (hex::encode(pkh(0xa1)), 5_000_000));
assert_eq!(unsigned.refunds[1], (hex::encode(pkh(0xb2)), 7_000_000));
}
#[test]
fn anyone_can_drive_refund_validator_doesnt_enforce_signer() {
// Driver is a third party — that's allowed for Refund.
let deposits = vec![EscrowDeposit {
contributor: pkh(0xa1),
value: EscrowValue::ada(5_000_000),
}];
let mut args = sample_args(EscrowState::Open, 1_700_000_000_001, deposits);
args.driver_pkh = pkh(0xff);
let unsigned = build_unsigned_escrow_refund_timeout(args).unwrap();
assert!(unsigned.summary.contains(&hex::encode(pkh(0xff))));
}
}

View file

@ -31,6 +31,8 @@ pub mod escrow_deposit;
#[cfg(feature = "escrow_wip")]
pub mod escrow_open;
#[cfg(feature = "escrow_wip")]
pub mod escrow_refund_timeout;
#[cfg(feature = "escrow_wip")]
pub mod escrow_settle;
#[cfg(feature = "escrow_wip")]
pub mod escrow_veto;