feat(escrow_wip): build_unsigned_escrow_settle builder
Plutus V3 spend that consumes an Agreed escrow whose lock window has elapsed and pays the entire in_value to the recipient's enterprise address. Validator gate: state==Agreed AND lower > agreed_at_ms + lock_period_ms (strict gt). No signer required by validator. Driver pays fee via funding utxo + collateral; doesn't need to be a party (test asserts this — anyone can push Settle once the lock elapses). Made enterprise_address_for pub(super) in escrow_veto so settle and refund_timeout can share it. Mirrors the validator's pkh_to_base_address byte-for-byte. 5 tests: not-Agreed reject, lock-not-elapsed reject (off-by-one strict gt), empty-escrow reject, happy-path pays full in_value, outsider driver works.
This commit is contained in:
parent
683d82639d
commit
702aae729f
3 changed files with 448 additions and 3 deletions
441
crates/aldabra-dao/src/builder/escrow_settle.rs
Normal file
441
crates/aldabra-dao/src/builder/escrow_settle.rs
Normal file
|
|
@ -0,0 +1,441 @@
|
|||
//! Build an unsigned `escrow_settle_unsigned` transaction.
|
||||
//!
|
||||
//! ⚠️ WIP / UNAUDITED. Feature-gated behind `escrow_wip`.
|
||||
//!
|
||||
//! ## What this tx does
|
||||
//!
|
||||
//! Plutus V3 spend that consumes an `Agreed` escrow whose lock window
|
||||
//! has elapsed and pays the entire escrow value to the recipient's
|
||||
//! enterprise (null-stake) address.
|
||||
//!
|
||||
//! - **Inputs**:
|
||||
//! - The escrow UTxO (Plutus V3 spend, redeemer = `Settle`).
|
||||
//! - One funding wallet UTxO from the driver (covers fee).
|
||||
//! - **Collateral**: 1 ADA-only ≥5 ADA wallet UTxO from the driver.
|
||||
//! - **Outputs**:
|
||||
//! - Recipient payout at the recipient's enterprise address, lovelace
|
||||
//! ≥ `escrow_in.lovelace`, native assets ≥ each (policy, name)
|
||||
//! present in the escrow input.
|
||||
//! - Wallet change to driver.
|
||||
//! - **Disclosed signer**: driver pkh. Validator does NOT require a
|
||||
//! signer, but the funding utxo and collateral need to be unlocked.
|
||||
//!
|
||||
//! ## Why no required signer?
|
||||
//!
|
||||
//! Settle is a "cash out" path: anyone can push it once the lock window
|
||||
//! elapses. The state machine + time gate is sufficient — nobody can
|
||||
//! fire Settle before `lower > agreed_at_ms + lock_period_ms`, and
|
||||
//! the recipient is hard-coded in the datum so funds always land on
|
||||
//! the same address.
|
||||
//!
|
||||
//! ## What the validator enforces (must match)
|
||||
//!
|
||||
//! From `aiken-escrow/validators/escrow.ak` Settle branch:
|
||||
//!
|
||||
//! 1. `d.state` is `Agreed { agreed_at_ms }`.
|
||||
//! 2. Tx validity range lower bound `Some(lower)` (Finite).
|
||||
//! 3. `lower > agreed_at_ms + d.lock_period_ms` — strict greater-than.
|
||||
//! 4. Sum of outputs to `pkh_to_base_address(d.recipient)` ≥ `in_value`
|
||||
//! component-wise (`value_geq_value`).
|
||||
//!
|
||||
//! All four 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::{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;
|
||||
|
||||
/// Args bundle for [`build_unsigned_escrow_settle`].
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct EscrowSettleArgs {
|
||||
pub cfg: DaoConfig,
|
||||
pub escrow_script_address: String,
|
||||
pub validator_script_cbor: Vec<u8>,
|
||||
pub escrow_in: EscrowUtxoIn,
|
||||
/// Whoever's driving the tx — pays fee, holds collateral, gets
|
||||
/// change. Doesn't need to be a party. Validator doesn't require
|
||||
/// this signer, but Cardano needs SOME signer to unlock the funding
|
||||
/// + collateral utxos (a wallet's regular UTxOs are vkey-locked).
|
||||
pub driver_pkh: [u8; PKH_LEN],
|
||||
pub change_address: String,
|
||||
pub wallet_utxos: Vec<WalletUtxo>,
|
||||
/// Slot to set `valid_from_slot(...)` on. Must encode a posix-ms
|
||||
/// `> agreed_at_ms + lock_period_ms`. Caller does the slot↔ms conv.
|
||||
pub validity_lower_slot: u64,
|
||||
/// POSIX-ms equivalent of `validity_lower_slot`. Used for the
|
||||
/// preflight `lower > agreed_at_ms + lock_period_ms` check; the
|
||||
/// chain extracts `lower` from the slot anyway, so this is purely
|
||||
/// a sanity gate.
|
||||
pub validity_lower_ms: i64,
|
||||
pub validity_upper_slot: u64,
|
||||
pub fee_lovelace: u64,
|
||||
pub ex_units: ExUnits,
|
||||
}
|
||||
|
||||
/// What [`build_unsigned_escrow_settle`] returns.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct UnsignedEscrowSettle {
|
||||
pub tx_cbor_hex: String,
|
||||
pub tx_hash_hex: String,
|
||||
pub recipient_pkh_hex: String,
|
||||
pub recipient_lovelace_paid: u64,
|
||||
pub summary: String,
|
||||
}
|
||||
|
||||
/// Build the unsigned escrow_settle tx.
|
||||
pub fn build_unsigned_escrow_settle(args: EscrowSettleArgs) -> DaoResult<UnsignedEscrowSettle> {
|
||||
let datum_in = &args.escrow_in.datum;
|
||||
|
||||
// ---- preflight ----------------------------------------------------------
|
||||
|
||||
// (1) state must be Agreed.
|
||||
let agreed_at_ms = match datum_in.state {
|
||||
EscrowState::Agreed { agreed_at_ms } => agreed_at_ms,
|
||||
ref other => {
|
||||
return Err(DaoError::State(format!(
|
||||
"escrow state is {other:?}, must be Agreed{{..}} to Settle",
|
||||
)));
|
||||
}
|
||||
};
|
||||
|
||||
// (3) validity_lower_ms > agreed_at_ms + lock_period_ms (strict gt).
|
||||
let earliest_settle_ms = agreed_at_ms
|
||||
.checked_add(datum_in.lock_period_ms)
|
||||
.ok_or_else(|| DaoError::State("agreed_at + lock_period overflow".into()))?;
|
||||
if args.validity_lower_ms <= earliest_settle_ms {
|
||||
return Err(DaoError::State(format!(
|
||||
"validity_lower_ms {} must be strictly > agreed_at_ms + lock_period_ms ({}); \
|
||||
lock window has not elapsed",
|
||||
args.validity_lower_ms, earliest_settle_ms
|
||||
)));
|
||||
}
|
||||
|
||||
if datum_in.deposits.is_empty() {
|
||||
return Err(DaoError::State(
|
||||
"escrow has no deposits — Settle of an empty escrow is a no-op the recipient \
|
||||
could just claim via Refund-timeout instead. Refusing to push fees on a no-op."
|
||||
.into(),
|
||||
));
|
||||
}
|
||||
|
||||
// ---- build recipient output --------------------------------------------
|
||||
//
|
||||
// Recipient gets at least `in_value`. We just pay exactly in_value —
|
||||
// the validator's value_geq_value(paid, in_value) is component-wise.
|
||||
|
||||
let recipient_addr = enterprise_address_for(datum_in.recipient, args.cfg.network)?;
|
||||
let recipient_lovelace = args.escrow_in.lovelace;
|
||||
|
||||
// ---- redeemer + collateral + funding -----------------------------------
|
||||
|
||||
let redeemer_pd = EscrowRedeemer::Settle.to_plutus_data()?;
|
||||
let redeemer_cbor = minicbor::to_vec(&redeemer_pd)
|
||||
.map_err(|e| DaoError::Cbor(format!("settle 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 Settle (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 = recipient_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} \
|
||||
(recipient={recipient_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 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,
|
||||
};
|
||||
|
||||
// Recipient output: in_value preserved bit-exact (lovelace + assets).
|
||||
let mut recipient_output = Output::new(recipient_addr, recipient_lovelace);
|
||||
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}")))?;
|
||||
recipient_output = recipient_output
|
||||
.add_asset(policy, name, *qty)
|
||||
.map_err(|e| DaoError::Backend(format!("recipient add_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(recipient_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);
|
||||
|
||||
// valid_from_slot encodes the lower bound the validator reads.
|
||||
staging = staging.valid_from_slot(args.validity_lower_slot);
|
||||
staging = staging.invalid_from_slot(args.validity_upper_slot);
|
||||
|
||||
// Driver as disclosed signer — needed for unlocking funding + collateral
|
||||
// (vkey witness). Validator doesn't enforce a 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_settle_unsigned: recipient={} payout={} lovelace driver={} fee={}",
|
||||
hex::encode(datum_in.recipient),
|
||||
recipient_lovelace,
|
||||
hex::encode(args.driver_pkh),
|
||||
args.fee_lovelace,
|
||||
);
|
||||
|
||||
Ok(UnsignedEscrowSettle {
|
||||
tx_cbor_hex,
|
||||
tx_hash_hex,
|
||||
recipient_pkh_hex: hex::encode(datum_in.recipient),
|
||||
recipient_lovelace_paid: recipient_lovelace,
|
||||
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) -> EscrowSettleArgs {
|
||||
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: vec![
|
||||
EscrowDeposit {
|
||||
contributor: pkh(0xa1),
|
||||
value: EscrowValue::ada(5_000_000),
|
||||
},
|
||||
EscrowDeposit {
|
||||
contributor: pkh(0xb2),
|
||||
value: EscrowValue::ada(7_000_000),
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
EscrowSettleArgs {
|
||||
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: 10_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_agreed() {
|
||||
let args = sample_args(EscrowState::Open, 1_700_000_000_000);
|
||||
let err = build_unsigned_escrow_settle(args).unwrap_err();
|
||||
assert!(err.to_string().contains("Agreed"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_when_lock_window_not_elapsed() {
|
||||
// agreed_at + lock_period = 1_699_999_900_000 + 1_800_000 = 1_700_001_700_000
|
||||
let agreed_state = EscrowState::Agreed {
|
||||
agreed_at_ms: 1_699_999_900_000,
|
||||
};
|
||||
// validity_lower_ms = exactly equal to agreed + lock — must be STRICT gt.
|
||||
let args = sample_args(agreed_state, 1_700_001_700_000);
|
||||
let err = build_unsigned_escrow_settle(args).unwrap_err();
|
||||
assert!(err.to_string().contains("strictly >"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_empty_escrow() {
|
||||
let mut args = sample_args(
|
||||
EscrowState::Agreed {
|
||||
agreed_at_ms: 1_699_999_900_000,
|
||||
},
|
||||
1_700_001_800_000,
|
||||
);
|
||||
args.escrow_in.datum.deposits.clear();
|
||||
let err = build_unsigned_escrow_settle(args).unwrap_err();
|
||||
assert!(err.to_string().contains("no deposits"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn happy_path_pays_recipient_full_in_value() {
|
||||
let agreed_state = EscrowState::Agreed {
|
||||
agreed_at_ms: 1_699_999_900_000,
|
||||
};
|
||||
// Pick lower well past agreed + lock.
|
||||
let unsigned =
|
||||
build_unsigned_escrow_settle(sample_args(agreed_state, 1_700_002_000_000)).unwrap();
|
||||
assert_eq!(unsigned.recipient_pkh_hex, hex::encode(pkh(0xb2)));
|
||||
assert_eq!(unsigned.recipient_lovelace_paid, 12_000_000);
|
||||
assert!(unsigned.summary.contains("recipient="));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn anyone_can_drive_settle_validator_doesnt_enforce_signer() {
|
||||
// Driver isn't a party — that's allowed for Settle (validator
|
||||
// doesn't require a party signer; only the time gate gates it).
|
||||
let agreed_state = EscrowState::Agreed {
|
||||
agreed_at_ms: 1_699_999_900_000,
|
||||
};
|
||||
let mut args = sample_args(agreed_state, 1_700_002_000_000);
|
||||
args.driver_pkh = pkh(0xff);
|
||||
let unsigned = build_unsigned_escrow_settle(args).unwrap();
|
||||
assert_eq!(unsigned.recipient_lovelace_paid, 12_000_000);
|
||||
}
|
||||
}
|
||||
|
|
@ -99,9 +99,11 @@ pub struct UnsignedEscrowVeto {
|
|||
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> {
|
||||
/// Construct the enterprise (null-stake) address for a PKH. Mirrors
|
||||
/// the validator's `pkh_to_base_address`. Shared by sibling builders
|
||||
/// (`escrow_settle`, `escrow_refund_timeout`) that need to pay an
|
||||
/// enterprise address.
|
||||
pub(super) 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,
|
||||
|
|
|
|||
|
|
@ -31,4 +31,6 @@ pub mod escrow_deposit;
|
|||
#[cfg(feature = "escrow_wip")]
|
||||
pub mod escrow_open;
|
||||
#[cfg(feature = "escrow_wip")]
|
||||
pub mod escrow_settle;
|
||||
#[cfg(feature = "escrow_wip")]
|
||||
pub mod escrow_veto;
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue