feat(escrow_wip): build_unsigned_escrow_veto builder
Plutus V3 spend that consumes an Agreed escrow and refunds every contributor to their enterprise (null-stake) address. Either party can fire — validator gate is signed_by(a) || signed_by(b). Off-chain reconstructs the validator's pkh_to_base_address via ShelleyAddress::new(net, payment=key_hash(pkh), delegation=Null). Below-floor deposits are topped up from the driver's funding utxo to keep refund outputs submittable (validator's value_geq_flat permits paying more than the deposit value). 7 tests: not-Agreed reject, outsider-driver reject, no-deposits reject, two-contributor full refund happy path, below-floor topup, either-party-drives, enterprise-addr construction is testnet for preprod (sanity check on the network bit).
This commit is contained in:
parent
415c099240
commit
aeac3e8e0a
2 changed files with 529 additions and 0 deletions
527
crates/aldabra-dao/src/builder/escrow_veto.rs
Normal file
527
crates/aldabra-dao/src/builder/escrow_veto.rs
Normal file
|
|
@ -0,0 +1,527 @@
|
||||||
|
//! Build an unsigned `escrow_veto_unsigned` transaction.
|
||||||
|
//!
|
||||||
|
//! ⚠️ WIP / UNAUDITED. Feature-gated behind `escrow_wip`.
|
||||||
|
//!
|
||||||
|
//! ## What this tx does
|
||||||
|
//!
|
||||||
|
//! Plutus V3 spend that consumes an `Agreed` escrow UTxO and refunds
|
||||||
|
//! every contributor to their per-PKH null-stake (enterprise) address.
|
||||||
|
//! Either party_a or party_b can fire — the validator's `signed_by(a)
|
||||||
|
//! || signed_by(b)` is the gate.
|
||||||
|
//!
|
||||||
|
//! - **Inputs**:
|
||||||
|
//! - The escrow UTxO (Plutus V3 spend, redeemer = `Veto`).
|
||||||
|
//! - One funding wallet UTxO from the driver (covers fee + per-output
|
||||||
|
//! min-utxo top-ups when a deposit's lovelace is below the floor).
|
||||||
|
//! - **Collateral**: 1 ADA-only ≥5 ADA wallet UTxO from the driver.
|
||||||
|
//! - **Outputs**:
|
||||||
|
//! - **One refund output per deposit entry**, each at the contributor's
|
||||||
|
//! enterprise (null-stake) address. Lovelace = `max(deposit.value.lovelace,
|
||||||
|
//! min_utxo)`. Native assets from the deposit value attached when present.
|
||||||
|
//! - Wallet change to driver.
|
||||||
|
//! - **Disclosed signer**: driver pkh (must equal party_a or party_b).
|
||||||
|
//!
|
||||||
|
//! ## Why null-stake (enterprise) addresses?
|
||||||
|
//!
|
||||||
|
//! The validator's `pkh_to_base_address` constructs `Address {
|
||||||
|
//! payment_credential: VerificationKey(pkh), stake_credential: None }`
|
||||||
|
//! — that's an enterprise (type 6/7) Cardano address. Refunds land at
|
||||||
|
//! the bare payment credential of each contributor, with no delegation
|
||||||
|
//! routing.
|
||||||
|
//!
|
||||||
|
//! Off-chain we reconstruct that via
|
||||||
|
//! `ShelleyAddress::new(net, ShelleyPaymentPart::key_hash(pkh),
|
||||||
|
//! ShelleyDelegationPart::Null)`. Header byte `0b0110` (mainnet) or
|
||||||
|
//! `0b0111` (testnet). The validator's `o.address == target` check is
|
||||||
|
//! a structural address equality, so the off-chain enterprise address
|
||||||
|
//! must match byte-for-byte.
|
||||||
|
//!
|
||||||
|
//! ## What the validator enforces (must match)
|
||||||
|
//!
|
||||||
|
//! From `aiken-escrow/validators/escrow.ak` Veto branch:
|
||||||
|
//!
|
||||||
|
//! 1. `d.state` is `Agreed { .. }`.
|
||||||
|
//! 2. `signed_by(self, d.party_a) || signed_by(self, d.party_b)` —
|
||||||
|
//! EITHER party can fire.
|
||||||
|
//! 3. `refund_outputs_satisfy(self.outputs, d.deposits)` — for each
|
||||||
|
//! deposit, an output to that contributor's enterprise address must
|
||||||
|
//! pay at least the deposit's value (component-wise).
|
||||||
|
//!
|
||||||
|
//! All three preflighted client-side.
|
||||||
|
|
||||||
|
use pallas_addresses::{Address, Network as PallasNetwork, ShelleyAddress, ShelleyDelegationPart, ShelleyPaymentPart};
|
||||||
|
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::proposal_create::{
|
||||||
|
parse_address, parse_script_hash, parse_tx_hash, WalletUtxo, MIN_COLLATERAL_LOVELACE,
|
||||||
|
};
|
||||||
|
|
||||||
|
const WALLET_CHANGE_MIN_LOVELACE: u64 = 1_000_000;
|
||||||
|
|
||||||
|
/// Min-utxo floor we apply to each refund output. Validator only checks
|
||||||
|
/// `value_geq_flat(paid, deposit.value)` — paying more than the deposit
|
||||||
|
/// value is fine — so we top up below-floor refund outputs from the
|
||||||
|
/// driver's funding utxo to keep them submittable.
|
||||||
|
const REFUND_OUTPUT_MIN_LOVELACE: u64 = 1_000_000;
|
||||||
|
|
||||||
|
/// Args bundle for [`build_unsigned_escrow_veto`].
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct EscrowVetoArgs {
|
||||||
|
pub cfg: DaoConfig,
|
||||||
|
pub escrow_script_address: String,
|
||||||
|
pub validator_script_cbor: Vec<u8>,
|
||||||
|
pub escrow_in: EscrowUtxoIn,
|
||||||
|
/// The party firing the veto (pays fees, gets change). Must equal
|
||||||
|
/// `escrow_in.datum.party_a` or `escrow_in.datum.party_b`.
|
||||||
|
pub driver_pkh: [u8; PKH_LEN],
|
||||||
|
pub change_address: String,
|
||||||
|
pub wallet_utxos: Vec<WalletUtxo>,
|
||||||
|
pub tip_slot: u64,
|
||||||
|
pub validity_upper_slot: u64,
|
||||||
|
pub fee_lovelace: u64,
|
||||||
|
pub ex_units: ExUnits,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// What [`build_unsigned_escrow_veto`] returns.
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct UnsignedEscrowVeto {
|
||||||
|
pub tx_cbor_hex: String,
|
||||||
|
pub tx_hash_hex: String,
|
||||||
|
/// Per-contributor refund summary: `(pkh_hex, lovelace_paid)`.
|
||||||
|
pub refunds: Vec<(String, u64)>,
|
||||||
|
pub summary: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Construct the enterprise (null-stake) address for a contributor PKH.
|
||||||
|
/// Mirrors the validator's `pkh_to_base_address`.
|
||||||
|
fn enterprise_address_for(pkh: [u8; PKH_LEN], network: DaoNetwork) -> DaoResult<Address> {
|
||||||
|
let pallas_net = match network {
|
||||||
|
DaoNetwork::Mainnet => PallasNetwork::Mainnet,
|
||||||
|
DaoNetwork::Preprod | DaoNetwork::Preview => PallasNetwork::Testnet,
|
||||||
|
};
|
||||||
|
let shelley = ShelleyAddress::new(
|
||||||
|
pallas_net,
|
||||||
|
ShelleyPaymentPart::key_hash(Hash::<28>::from(pkh)),
|
||||||
|
ShelleyDelegationPart::Null,
|
||||||
|
);
|
||||||
|
Ok(Address::Shelley(shelley))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Build the unsigned escrow_veto tx.
|
||||||
|
pub fn build_unsigned_escrow_veto(args: EscrowVetoArgs) -> DaoResult<UnsignedEscrowVeto> {
|
||||||
|
let datum_in = &args.escrow_in.datum;
|
||||||
|
|
||||||
|
// ---- preflight ----------------------------------------------------------
|
||||||
|
|
||||||
|
// (1) state must be Agreed { .. }.
|
||||||
|
if !matches!(datum_in.state, EscrowState::Agreed { .. }) {
|
||||||
|
return Err(DaoError::State(format!(
|
||||||
|
"escrow state is {:?}, must be Agreed{{..}} to Veto",
|
||||||
|
datum_in.state
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
|
||||||
|
// (2) driver must be a party.
|
||||||
|
if args.driver_pkh != datum_in.party_a && args.driver_pkh != datum_in.party_b {
|
||||||
|
return Err(DaoError::State(
|
||||||
|
"driver_pkh is neither party_a nor party_b — only escrow parties can Veto"
|
||||||
|
.into(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
if datum_in.deposits.is_empty() {
|
||||||
|
return Err(DaoError::State(
|
||||||
|
"escrow has no deposits — Veto with empty deposits is a no-op refund"
|
||||||
|
.into(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- compute refund outputs --------------------------------------------
|
||||||
|
//
|
||||||
|
// For each deposit, pay at least the deposit's value to the
|
||||||
|
// contributor's enterprise address. Top up to REFUND_OUTPUT_MIN_LOVELACE
|
||||||
|
// when below floor. Track the top-up cost so we can sanity-check funding.
|
||||||
|
|
||||||
|
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()))?;
|
||||||
|
|
||||||
|
// Collect non-ADA assets from the deposit value (skip the
|
||||||
|
// empty-policy ADA entry). Validator's value_geq_flat is component-
|
||||||
|
// wise so each (policy, name) must be paid at least its qty.
|
||||||
|
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 -----------------------------------------------------------
|
||||||
|
|
||||||
|
let redeemer_pd = EscrowRedeemer::Veto.to_plutus_data()?;
|
||||||
|
let redeemer_cbor = minicbor::to_vec(&redeemer_pd)
|
||||||
|
.map_err(|e| DaoError::Cbor(format!("veto 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 Veto (separate from collateral)"
|
||||||
|
.into(),
|
||||||
|
)
|
||||||
|
})?;
|
||||||
|
|
||||||
|
// ---- balance + change ---------------------------------------------------
|
||||||
|
//
|
||||||
|
// total_in = escrow_in.lovelace + funding.lovelace
|
||||||
|
// outputs = sum(refund_outputs) + change + fee
|
||||||
|
// change = total_in - sum(refunds) - fee
|
||||||
|
|
||||||
|
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);
|
||||||
|
|
||||||
|
// Refund outputs in deposit-list order. The validator iterates
|
||||||
|
// deposits and looks for matching outputs by address so order between
|
||||||
|
// deposit-iter and tx-output-iter doesn't matter — but emitting in
|
||||||
|
// deposit order is the most legible audit trail.
|
||||||
|
for (addr, lovelace, assets) in refund_outputs {
|
||||||
|
let mut out = Output::new(addr, lovelace);
|
||||||
|
for (policy, name, qty) in assets {
|
||||||
|
// policy is already a 28-byte vec — wrap in Hash<28>.
|
||||||
|
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.tip_slot);
|
||||||
|
staging = staging.invalid_from_slot(args.validity_upper_slot);
|
||||||
|
|
||||||
|
// Disclosed signer: driver pkh. Validator only requires ONE party
|
||||||
|
// signature for Veto, so we don't add the co-signer.
|
||||||
|
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_veto_unsigned: driver={} refunds={} ({} lovelace total, topup={}, fee={})",
|
||||||
|
hex::encode(args.driver_pkh),
|
||||||
|
refund_summaries.len(),
|
||||||
|
refund_lovelace_total,
|
||||||
|
topup_total,
|
||||||
|
args.fee_lovelace,
|
||||||
|
);
|
||||||
|
|
||||||
|
Ok(UnsignedEscrowVeto {
|
||||||
|
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 agreed_state() -> EscrowState {
|
||||||
|
EscrowState::Agreed {
|
||||||
|
agreed_at_ms: 1_699_999_900_000,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn sample_args(state: EscrowState, deposits: Vec<EscrowDeposit>) -> EscrowVetoArgs {
|
||||||
|
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,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
EscrowVetoArgs {
|
||||||
|
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![],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
tip_slot: 50_000_000,
|
||||||
|
validity_upper_slot: 50_001_800,
|
||||||
|
fee_lovelace: 2_500_000,
|
||||||
|
ex_units: ESCROW_SPEND_EX_UNITS,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn rejects_when_not_agreed() {
|
||||||
|
let args = sample_args(EscrowState::Open, vec![]);
|
||||||
|
let err = build_unsigned_escrow_veto(args).unwrap_err();
|
||||||
|
assert!(err.to_string().contains("Agreed"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn rejects_when_driver_not_a_party() {
|
||||||
|
let mut args = sample_args(
|
||||||
|
agreed_state(),
|
||||||
|
vec![EscrowDeposit {
|
||||||
|
contributor: pkh(0xa1),
|
||||||
|
value: EscrowValue::ada(5_000_000),
|
||||||
|
}],
|
||||||
|
);
|
||||||
|
args.driver_pkh = pkh(0xff);
|
||||||
|
let err = build_unsigned_escrow_veto(args).unwrap_err();
|
||||||
|
assert!(err.to_string().contains("party_a nor party_b"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn rejects_when_no_deposits() {
|
||||||
|
let args = sample_args(agreed_state(), vec![]);
|
||||||
|
let err = build_unsigned_escrow_veto(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_veto(sample_args(agreed_state(), 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));
|
||||||
|
assert!(unsigned.summary.contains("refunds=2"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn tops_up_below_floor_deposit() {
|
||||||
|
// Deposit of 0.5 ADA — below floor; top up to 1 ADA.
|
||||||
|
let deposits = vec![EscrowDeposit {
|
||||||
|
contributor: pkh(0xa1),
|
||||||
|
value: EscrowValue::ada(500_000),
|
||||||
|
}];
|
||||||
|
let unsigned = build_unsigned_escrow_veto(sample_args(agreed_state(), deposits)).unwrap();
|
||||||
|
assert_eq!(unsigned.refunds[0].1, REFUND_OUTPUT_MIN_LOVELACE);
|
||||||
|
assert!(unsigned.summary.contains("topup=500000"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn either_party_can_drive_veto() {
|
||||||
|
let deposits = vec![EscrowDeposit {
|
||||||
|
contributor: pkh(0xa1),
|
||||||
|
value: EscrowValue::ada(5_000_000),
|
||||||
|
}];
|
||||||
|
// party_b drives — also valid.
|
||||||
|
let mut args = sample_args(agreed_state(), deposits);
|
||||||
|
args.driver_pkh = pkh(0xb2);
|
||||||
|
let unsigned = build_unsigned_escrow_veto(args).unwrap();
|
||||||
|
assert!(unsigned.summary.contains(&hex::encode(pkh(0xb2))));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn enterprise_address_construction_is_testnet_for_preprod() {
|
||||||
|
// Sanity: enterprise address for preprod uses testnet network.
|
||||||
|
let addr = enterprise_address_for(pkh(0xa1), DaoNetwork::Preprod).unwrap();
|
||||||
|
let bech32 = addr.to_bech32().unwrap();
|
||||||
|
// Testnet enterprise addresses use prefix "addr_test1v..."
|
||||||
|
assert!(bech32.starts_with("addr_test1v"), "got {bech32}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -30,3 +30,5 @@ pub mod escrow_agree;
|
||||||
pub mod escrow_deposit;
|
pub mod escrow_deposit;
|
||||||
#[cfg(feature = "escrow_wip")]
|
#[cfg(feature = "escrow_wip")]
|
||||||
pub mod escrow_open;
|
pub mod escrow_open;
|
||||||
|
#[cfg(feature = "escrow_wip")]
|
||||||
|
pub mod escrow_veto;
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue