feat(escrow_wip): MCP escrow_open_unsigned tool + Cargo feature

Adds the first MCP tool surface for the escrow validator: takes
party_a/b/recipient pkh hex + open_deadline + lock_period + optional
initial_contributor + initial_lovelace, calls
build_unsigned_escrow_open, returns CBOR-hex of the unsigned tx.

Tool description prefixed with "WIP — UNAUDITED:" per the handoff
discipline. Body includes a wip_warning field reminding callers this
is preprod-only.

Also propagates the escrow_wip feature from aldabra-mcp to
aldabra-dao so building the MCP binary with --features escrow_wip
correctly enables the dao crate's escrow surface.

Cleaned up unused-import warnings (ESCROW_SPEND_EX_UNITS pulled to
test-only scope in agree/settle/veto/refund_timeout — the impl bodies
take ex_units explicitly via args, only tests reference the constant).
35/35 escrow builder tests still pass; aldabra-mcp builds clean with
--features escrow_wip.

The remaining 4 unsigned-write tools (deposit/agree/veto/settle/
refund_timeout) follow the same pattern: pull wallet utxos, optionally
discover the escrow UTxO from chain at script_address, decode datum,
call the corresponding builder, return CBOR-hex JSON.
This commit is contained in:
Kayos 2026-05-09 12:49:46 -07:00
parent 705acdac0c
commit dba3d46271
6 changed files with 158 additions and 6 deletions

View file

@ -58,9 +58,7 @@ 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::escrow_deposit::{EscrowUtxoIn, ESCROW_OUTPUT_MIN_LOVELACE};
use super::proposal_create::{
parse_address, parse_script_hash, parse_tx_hash, WalletUtxo, MIN_COLLATERAL_LOVELACE,
};
@ -356,6 +354,7 @@ pub fn build_unsigned_escrow_agree(args: EscrowAgreeArgs) -> DaoResult<UnsignedE
#[cfg(test)]
mod tests {
use super::*;
use super::super::escrow_deposit::ESCROW_SPEND_EX_UNITS;
use crate::agora::escrow::{EscrowDeposit, EscrowValue};
use crate::config::ScriptRefs;

View file

@ -46,7 +46,7 @@ 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_deposit::EscrowUtxoIn;
use super::escrow_veto::enterprise_address_for;
use super::proposal_create::{
parse_address, parse_script_hash, parse_tx_hash, WalletUtxo, MIN_COLLATERAL_LOVELACE,
@ -320,6 +320,7 @@ pub fn build_unsigned_escrow_refund_timeout(
#[cfg(test)]
mod tests {
use super::*;
use super::super::escrow_deposit::ESCROW_SPEND_EX_UNITS;
use crate::agora::escrow::{EscrowDatum, EscrowDeposit, EscrowValue};
use crate::config::ScriptRefs;

View file

@ -48,7 +48,7 @@ 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_deposit::EscrowUtxoIn;
use super::escrow_veto::enterprise_address_for;
use super::proposal_create::{
parse_address, parse_script_hash, parse_tx_hash, WalletUtxo, MIN_COLLATERAL_LOVELACE,
@ -293,6 +293,7 @@ pub fn build_unsigned_escrow_settle(args: EscrowSettleArgs) -> DaoResult<Unsigne
#[cfg(test)]
mod tests {
use super::*;
use super::super::escrow_deposit::ESCROW_SPEND_EX_UNITS;
use crate::agora::escrow::{EscrowDatum, EscrowDeposit, EscrowValue};
use crate::config::ScriptRefs;

View file

@ -58,7 +58,7 @@ 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_deposit::EscrowUtxoIn;
use super::proposal_create::{
parse_address, parse_script_hash, parse_tx_hash, WalletUtxo, MIN_COLLATERAL_LOVELACE,
};
@ -362,6 +362,7 @@ pub fn build_unsigned_escrow_veto(args: EscrowVetoArgs) -> DaoResult<UnsignedEsc
#[cfg(test)]
mod tests {
use super::*;
use super::super::escrow_deposit::ESCROW_SPEND_EX_UNITS;
use crate::agora::escrow::{EscrowDatum, EscrowDeposit, EscrowValue};
use crate::config::ScriptRefs;

View file

@ -17,6 +17,13 @@ authors.workspace = true
name = "aldabra"
path = "src/main.rs"
[features]
default = []
# Enable the escrow_wip feature surface (Aiken V3 escrow validator + 6 builders
# + MCP tools). Mirrors aldabra-dao's escrow_wip — propagates the feature so
# the MCP binary actually exposes escrow_* tools. WIP / UNAUDITED — preprod only.
escrow_wip = ["aldabra-dao/escrow_wip"]
[dependencies]
aldabra-core = { path = "../aldabra-core" }
aldabra-chain = { path = "../aldabra-chain" }

View file

@ -54,6 +54,10 @@ use aldabra_dao::builder::proposal_vote::{
build_unsigned_proposal_vote, ProposalUtxoIn, ProposalVoteArgs,
};
use aldabra_dao::builder::stake_destroy::{build_unsigned_stake_destroy, StakeDestroyArgs};
#[cfg(feature = "escrow_wip")]
use aldabra_dao::builder::escrow_open::{
build_unsigned_escrow_open, EscrowOpenArgs,
};
use aldabra_dao::config::{DaoConfig, DaoNetwork, DaoStore, ScriptRefs};
use aldabra_dao::discovery::{
apply_discovery, discover_scripts, KoiosDiscoveryClient, MAINNET_AGORA_SHARED_DEPLOYER,
@ -3542,6 +3546,120 @@ impl WalletService {
})
.to_string())
}
// ─── escrow_wip — WIP/UNAUDITED two-party agreement-with-veto escrow ───
//
// Feature-gated (`--features escrow_wip`) so the default release build
// doesn't surface tools whose validator hasn't been externally audited.
// Enable for preprod E2E only.
#[cfg(feature = "escrow_wip")]
#[tool(
name = "escrow_open_unsigned",
description = "WIP — UNAUDITED: Build (but DO NOT submit) an unsigned escrow_open tx. Locks lovelace at the escrow validator script address with an inline EscrowDatum (state=Open, optional initial deposit). Two-party agreement-with-veto: party_a + party_b must both sign Agree to flip the state; either can fire Veto from Agreed; recipient is settled-to after the lock period. ⚠️ v1 WIP feature — the validator has NOT been externally audited. Do not route mainnet value through it. Args: escrow_script_address (bech32), party_a/b/recipient_pkh_hex (28-byte hex), open_deadline_ms (after which Refund-timeout becomes valid), lock_period_ms (veto window after Agree), initial_contributor_pkh_hex (optional — party_a or party_b — funds initial deposit), initial_lovelace (lovelace to lock at the script), fee_lovelace (~2.5 ADA reasonable). Returns CBOR-hex of the unsigned tx body. Caller signs via wallet_sign_partial then submits via wallet_submit_signed_tx."
)]
async fn escrow_open_unsigned(
&self,
#[tool(aggr)] EscrowOpenUnsignedArgs {
escrow_script_address,
party_a_pkh_hex,
party_b_pkh_hex,
recipient_pkh_hex,
open_deadline_ms,
lock_period_ms,
initial_contributor_pkh_hex,
initial_lovelace,
fee_lovelace,
}: EscrowOpenUnsignedArgs,
) -> Result<String, String> {
let _ = fee_lovelace; // accepted for forward compat; payment builder is fee-estimating
let party_a = decode_pkh28(&party_a_pkh_hex, "party_a_pkh_hex")?;
let party_b = decode_pkh28(&party_b_pkh_hex, "party_b_pkh_hex")?;
let recipient = decode_pkh28(&recipient_pkh_hex, "recipient_pkh_hex")?;
let initial_contributor = match initial_contributor_pkh_hex {
Some(s) => Some(decode_pkh28(&s, "initial_contributor_pkh_hex")?),
None => None,
};
// Pull wallet UTxOs as core::InputUtxo (escrow_open builder takes core's shape).
let raw = self
.inner
.chain
.get_utxos(&self.inner.address)
.await
.map_err(|e| format!("fetch utxos: {e}"))?;
if raw.is_empty() {
return Err(format!(
"no utxos at wallet address {} — fund the wallet first",
self.inner.address
));
}
let wallet_utxos: Vec<InputUtxo> = raw
.into_iter()
.map(|u| InputUtxo {
tx_hash_hex: u.tx_hash,
output_index: u.output_index,
lovelace: u.lovelace,
assets: u.assets,
})
.collect();
// V3 cost model + populate ProtocolParams. The escrow validator
// is V3, so the ProtocolParams must carry the V3 cost model
// for the chain to verify script_data_hash on subsequent
// Plutus-spend txs against this escrow. open itself doesn't
// run a script, but downstream tools assume the same params.
let params = ProtocolParams {
plutus_v3_cost_model: Some(PLUTUS_V3_COST_MODEL_PREPROD.to_vec()),
..ProtocolParams::default()
};
let unsigned = build_unsigned_escrow_open(EscrowOpenArgs {
network: self.inner.network,
escrow_script_address: escrow_script_address.clone(),
party_a_pkh: party_a,
party_b_pkh: party_b,
recipient_pkh: recipient,
open_deadline_ms,
lock_period_ms,
initial_contributor,
initial_lovelace,
initial_assets: vec![], // ADA-only deposits in v1
change_address: self.inner.address.clone(),
wallet_utxos,
params,
})
.map_err(|e| format!("build_unsigned_escrow_open: {e}"))?;
Ok(serde_json::json!({
"tx_cbor_hex": unsigned.tx_cbor_hex,
"tx_hash_hex": unsigned.tx_hash_hex,
"escrow_script_address": unsigned.escrow_script_address,
"escrow_datum_cbor_hex": unsigned.escrow_datum_cbor_hex,
"summary": unsigned.summary,
"next_step": "review tx_cbor_hex (decode + audit), sign via wallet_sign_partial, submit via wallet_submit_signed_tx. After submission, query the script address to discover the new escrow UTxO + its tx_hash#index for follow-up deposit/agree/veto/settle/refund tools.",
"wip_warning": "⚠️ UNAUDITED escrow validator. Use preprod testnet only.",
})
.to_string())
}
}
/// Decode a 28-byte payment-key-hash from hex. Shared helper for
/// escrow MCP tools.
#[cfg(feature = "escrow_wip")]
fn decode_pkh28(s: &str, field: &str) -> Result<[u8; 28], String> {
let cleaned: String = s.chars().filter(|c| !c.is_whitespace()).collect();
let bytes = hex_decode(&cleaned).map_err(|e| format!("{field} hex decode: {e}"))?;
if bytes.len() != 28 {
return Err(format!(
"{field} expects 28 bytes (56 hex chars), got {} bytes",
bytes.len()
));
}
let mut out = [0u8; 28];
out.copy_from_slice(&bytes);
Ok(out)
}
// ─── DAO arg structs ────────────────────────────────────────────────────────
@ -3697,6 +3815,31 @@ pub struct DaoProposalVoteArgs {
pub fee_lovelace: u64,
}
#[cfg(feature = "escrow_wip")]
#[derive(Debug, Deserialize, schemars::JsonSchema)]
pub struct EscrowOpenUnsignedArgs {
/// Bech32 address of the deployed escrow validator script.
pub escrow_script_address: String,
/// party_a payment-key-hash (28 bytes hex / 56 chars).
pub party_a_pkh_hex: String,
/// party_b payment-key-hash (28 bytes hex / 56 chars).
pub party_b_pkh_hex: String,
/// recipient payment-key-hash (28 bytes hex / 56 chars). Often equals party_b.
pub recipient_pkh_hex: String,
/// POSIX-ms after which Refund-timeout becomes valid.
pub open_deadline_ms: i64,
/// Veto-window length after Agree, in ms.
pub lock_period_ms: i64,
/// Optional pkh of the initial contributor (must equal party_a or party_b).
/// If unset, opens with empty deposits — both parties top up later.
#[serde(default)]
pub initial_contributor_pkh_hex: Option<String>,
/// Lovelace to lock at the escrow output. Must clear min-utxo floor (~1 ADA).
pub initial_lovelace: u64,
/// Estimated total fee in lovelace. ~2_500_000 reasonable for v1.
pub fee_lovelace: u64,
}
/// Mainnet Shelley genesis constants for slot↔POSIX-ms conversion.
///
/// Per-network Shelley genesis constants for slot↔POSIX-ms conversion.