feat(escrow_wip): MCP escrow spend-tool surface (deposit/agree/veto/settle/refund_timeout)

Five new MCP tools wrapping the four Plutus V3 spend builders shipped
earlier in this branch:

- escrow_deposit_unsigned     → Deposit redeemer (continuing-output state)
- escrow_agree_unsigned       → Agree redeemer (Open → Agreed{at=upper}, both sign)
- escrow_veto_unsigned        → Veto redeemer (Agreed → multi-output refund)
- escrow_settle_unsigned      → Settle redeemer (Agreed → recipient payout)
- escrow_refund_timeout_unsigned → Refund redeemer (Open after open_deadline → multi-output refund)

Each takes the existing escrow UTxO ref + lovelace + datum_cbor_hex
(caller pulls via chain_address_info), the V3 validator UPLC
(inline hex OR file-path to dodge the >4500-char MCP transport bug),
the redeemer-specific args, and fee_lovelace. Validity windows
default to 30 min from chain tip; agree's window auto-clamps to
open_deadline_ms when needed.

Helper additions:
- EscrowDatum::from_cbor_hex on aldabra-dao keeps pallas-codec /
  pallas-primitives direct deps OUT of aldabra-mcp.
- decode_pkh28, resolve_validator_required, build_escrow_spend_in,
  fetch_tip_slot_ms in tools.rs — small helpers shared by all 5
  spend tools.

Drops the [features] section on aldabra-mcp's Cargo.toml. rmcp 0.1.5's
#[tool(tool_box)] macro scans the impl AST and references every
#[tool]-annotated method's generated wrapper regardless of cfg
eligibility — cfg-on-method gating fails to compile when the feature
is off because the macro emits unresolved symbol references. Pivot:
always-pull aldabra-dao/escrow_wip via the dep itself. The runtime
gate is the "WIP — UNAUDITED:" prefix in every tool description plus
the "wip_warning" field in JSON responses; the dao crate's escrow_wip
feature still gates downstream Rust consumers that want source-level
opt-out.

Verified: aldabra-mcp builds clean (default + release). 132 aldabra-
dao tests pass under --features escrow_wip including all 35 escrow
builder tests. Release binary produced.
This commit is contained in:
Kayos 2026-05-09 13:36:44 -07:00
parent dba3d46271
commit ad2d17e3d0
3 changed files with 606 additions and 12 deletions

View file

@ -264,6 +264,29 @@ impl EscrowDatum {
acc
}
/// Decode an EscrowDatum from a hex-encoded CBOR string. The
/// caller's source is typically the inline_datum field returned by
/// `chain_address_info` / Koios. Whitespace is stripped before
/// parsing.
///
/// Surfaced so MCP layers can stay free of pallas-codec /
/// pallas-primitives direct deps — they pass hex through, this
/// crate owns the decoding.
pub fn from_cbor_hex(hex_str: &str) -> DaoResult<Self> {
use pallas_codec::minicbor;
use pallas_primitives::PlutusData;
let cleaned: String = hex_str.chars().filter(|c| !c.is_whitespace()).collect();
let bytes = hex::decode(&cleaned).map_err(|e| {
DaoError::Datum(format!("EscrowDatum::from_cbor_hex hex decode: {e}"))
})?;
let pd: PlutusData = minicbor::decode(&bytes).map_err(|e| {
DaoError::Datum(format!(
"EscrowDatum::from_cbor_hex parse as PlutusData: {e}"
))
})?;
Self::from_plutus_data(&pd)
}
/// Look up a deposit entry by contributor PKH.
pub fn deposit_for(&self, pkh: &[u8; PKH_LEN]) -> Option<&EscrowDeposit> {
self.deposits.iter().find(|d| &d.contributor == pkh)

View file

@ -17,17 +17,21 @@ 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" }
aldabra-dao = { path = "../aldabra-dao" }
# Always pulls aldabra-dao/escrow_wip so the MCP binary can expose
# the escrow_* tool surface unconditionally. The "WIP — UNAUDITED:"
# prefix in every tool's description is the runtime gate; the dao
# crate's escrow_wip feature stays gated for downstream Rust consumers
# that want to opt out at the source level.
#
# Rationale: rmcp 0.1.5's #[tool(tool_box)] macro doesn't compose with
# #[cfg] on individual methods (it scans the impl AST and references
# every #[tool]-tagged method's generated wrapper, regardless of cfg
# eligibility). Force-pulling the feature at the dep level avoids the
# macro/cfg conflict.
aldabra-dao = { path = "../aldabra-dao", features = ["escrow_wip"] }
# Used directly in tools.rs to decode the wallet's bech32 address into a
# payment-credential hash (so `dao_my_stake` can match against StakeDatum.owner).

View file

@ -54,10 +54,26 @@ 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::agora::escrow::EscrowDatum;
use aldabra_dao::builder::escrow_agree::{
build_unsigned_escrow_agree, EscrowAgreeArgs,
};
use aldabra_dao::builder::escrow_deposit::{
build_unsigned_escrow_deposit, EscrowDepositArgs, EscrowUtxoIn as EscrowSpendUtxoIn,
ESCROW_SPEND_EX_UNITS,
};
use aldabra_dao::builder::escrow_open::{
build_unsigned_escrow_open, EscrowOpenArgs,
};
use aldabra_dao::builder::escrow_refund_timeout::{
build_unsigned_escrow_refund_timeout, EscrowRefundTimeoutArgs,
};
use aldabra_dao::builder::escrow_settle::{
build_unsigned_escrow_settle, EscrowSettleArgs,
};
use aldabra_dao::builder::escrow_veto::{
build_unsigned_escrow_veto, EscrowVetoArgs,
};
use aldabra_dao::config::{DaoConfig, DaoNetwork, DaoStore, ScriptRefs};
use aldabra_dao::discovery::{
apply_discovery, discover_scripts, KoiosDiscoveryClient, MAINNET_AGORA_SHARED_DEPLOYER,
@ -3553,7 +3569,6 @@ impl WalletService {
// 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."
@ -3643,11 +3658,389 @@ impl WalletService {
})
.to_string())
}
#[tool(
name = "escrow_deposit_unsigned",
description = "WIP — UNAUDITED: Build (but DO NOT submit) an unsigned escrow_deposit tx. Plutus V3 spend with continuing-output state transition (only `deposits` field mutated). Validator runs `Deposit{contributor}` redeemer. v1 ADA-only deposits — multi-asset deferred until cbor-canonicality of Aiken Pairs is golden-tested. Args: escrow_script_address, validator_script_cbor_hex OR validator_script_path (V3 UPLC), escrow_in_tx_hash_hex + escrow_in_output_index (the existing escrow UTxO), escrow_in_lovelace, escrow_in_datum_cbor_hex (the current EscrowDatum encoded as Plutus Data CBOR — pull via chain_address_info), contributor_pkh_hex (must equal party_a or party_b), add_lovelace (>0), fee_lovelace, validity_window_seconds (optional, default 1800). Returns CBOR-hex of the unsigned tx body."
)]
async fn escrow_deposit_unsigned(
&self,
#[tool(aggr)] EscrowDepositUnsignedArgs {
escrow_script_address,
validator_script_cbor_hex,
validator_script_path,
escrow_in_tx_hash_hex,
escrow_in_output_index,
escrow_in_lovelace,
escrow_in_datum_cbor_hex,
contributor_pkh_hex,
add_lovelace,
fee_lovelace,
validity_window_seconds,
}: EscrowDepositUnsignedArgs,
) -> Result<String, String> {
let validator_cbor = resolve_validator_required(
validator_script_cbor_hex.as_deref(),
validator_script_path.as_deref(),
)?;
let escrow_in = build_escrow_spend_in(
&escrow_in_tx_hash_hex,
escrow_in_output_index,
escrow_in_lovelace,
&escrow_in_datum_cbor_hex,
)?;
let contributor = decode_pkh28(&contributor_pkh_hex, "contributor_pkh_hex")?;
let cfg = self.dao_cfg_for_escrow()?;
let wallet_utxos = pull_wallet_utxos(&self.inner.chain, &self.inner.address).await?;
let (tip_slot, tip_ms) = fetch_tip_slot_ms(self).await?;
let validity_upper_slot = tip_slot + validity_window_seconds.unwrap_or(1800);
let _ = tip_ms;
let unsigned = build_unsigned_escrow_deposit(EscrowDepositArgs {
cfg,
escrow_script_address: escrow_script_address.clone(),
validator_script_cbor: validator_cbor,
escrow_in,
contributor_pkh: contributor,
add_lovelace,
change_address: self.inner.address.clone(),
wallet_utxos,
tip_slot,
validity_upper_slot,
fee_lovelace,
ex_units: ESCROW_SPEND_EX_UNITS,
})
.map_err(|e| format!("build_unsigned_escrow_deposit: {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,
"new_datum_cbor_hex": unsigned.new_datum_cbor_hex,
"new_escrow_lovelace": unsigned.new_escrow_lovelace,
"summary": unsigned.summary,
"next_step": "sign via wallet_sign_partial (contributor must sign), submit via wallet_submit_signed_tx. After submission, the new escrow UTxO at script address has tx_hash = tx_hash_hex above, output_index = 0; pass these to subsequent escrow tools.",
"wip_warning": "⚠️ UNAUDITED escrow validator. Use preprod testnet only.",
})
.to_string())
}
#[tool(
name = "escrow_agree_unsigned",
description = "WIP — UNAUDITED: Build an unsigned escrow_agree tx — flips state Open → Agreed{at=upper}. BOTH party_a + party_b must sign (this tool is run by the driver, who signs first; co-signer adds witness via wallet_sign_partial). Validator enforces validity_upper ≤ open_deadline_ms. Args: escrow_script_address, validator_script_cbor_hex OR validator_script_path, escrow_in_tx_hash_hex + escrow_in_output_index + escrow_in_lovelace + escrow_in_datum_cbor_hex, driver_pkh_hex (party_a or party_b), fee_lovelace, validity_window_seconds (optional default 1800; clamps to open_deadline if needed). Returns CBOR-hex requiring co-signer's witness."
)]
async fn escrow_agree_unsigned(
&self,
#[tool(aggr)] EscrowAgreeUnsignedArgs {
escrow_script_address,
validator_script_cbor_hex,
validator_script_path,
escrow_in_tx_hash_hex,
escrow_in_output_index,
escrow_in_lovelace,
escrow_in_datum_cbor_hex,
driver_pkh_hex,
fee_lovelace,
validity_window_seconds,
}: EscrowAgreeUnsignedArgs,
) -> Result<String, String> {
let validator_cbor = resolve_validator_required(
validator_script_cbor_hex.as_deref(),
validator_script_path.as_deref(),
)?;
let escrow_in = build_escrow_spend_in(
&escrow_in_tx_hash_hex,
escrow_in_output_index,
escrow_in_lovelace,
&escrow_in_datum_cbor_hex,
)?;
let driver = decode_pkh28(&driver_pkh_hex, "driver_pkh_hex")?;
let cfg = self.dao_cfg_for_escrow()?;
let wallet_utxos = pull_wallet_utxos(&self.inner.chain, &self.inner.address).await?;
let (tip_slot, _tip_ms) = fetch_tip_slot_ms(self).await?;
// Default to tip+window. Clamp to (open_deadline_ms - 1ms-equivalent slot)
// if we'd otherwise overshoot — validator's `upper ≤ open_deadline_ms`
// is hard, and clamping client-side avoids burning a fee on an
// overshot window.
let proposed_upper_slot = tip_slot + validity_window_seconds.unwrap_or(1800);
let proposed_upper_ms = slot_to_posix_ms(cfg.network, proposed_upper_slot)?;
let open_deadline_ms = escrow_in.datum.open_deadline_ms;
let (validity_upper_slot, validity_upper_ms) = if proposed_upper_ms > open_deadline_ms {
let clamped_slot = posix_ms_to_slot(cfg.network, open_deadline_ms)?;
if clamped_slot <= tip_slot + 5 {
return Err(format!(
"open_deadline_ms {open_deadline_ms} only {} slots away — too narrow for an Agree tx",
clamped_slot.saturating_sub(tip_slot)
));
}
(clamped_slot, slot_to_posix_ms(cfg.network, clamped_slot)?)
} else {
(proposed_upper_slot, proposed_upper_ms)
};
let unsigned = build_unsigned_escrow_agree(EscrowAgreeArgs {
cfg,
escrow_script_address: escrow_script_address.clone(),
validator_script_cbor: validator_cbor,
escrow_in,
driver_pkh: driver,
change_address: self.inner.address.clone(),
wallet_utxos,
tip_slot,
validity_upper_slot,
validity_upper_ms,
fee_lovelace,
ex_units: ESCROW_SPEND_EX_UNITS,
})
.map_err(|e| format!("build_unsigned_escrow_agree: {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,
"new_datum_cbor_hex": unsigned.new_datum_cbor_hex,
"agreed_at_ms": unsigned.agreed_at_ms,
"co_signer_pkh_hex": unsigned.co_signer_pkh_hex,
"summary": unsigned.summary,
"next_step": "this tx requires BOTH parties' signatures. Driver signs via wallet_sign_partial, then sends the partial-signed CBOR to the co-signer (pkh = co_signer_pkh_hex) who calls wallet_sign_partial again, then wallet_submit_signed_tx.",
"wip_warning": "⚠️ UNAUDITED escrow validator. Use preprod testnet only.",
})
.to_string())
}
#[tool(
name = "escrow_veto_unsigned",
description = "WIP — UNAUDITED: Build an unsigned escrow_veto tx — consumes an Agreed escrow and refunds every contributor to their enterprise (null-stake) address. Either party can fire (validator: signed_by(a) || signed_by(b)). Args: escrow_script_address, validator_script_cbor_hex OR validator_script_path, escrow_in_tx_hash_hex + escrow_in_output_index + escrow_in_lovelace + escrow_in_datum_cbor_hex, driver_pkh_hex (party_a or party_b), fee_lovelace, validity_window_seconds (optional)."
)]
async fn escrow_veto_unsigned(
&self,
#[tool(aggr)] EscrowVetoUnsignedArgs {
escrow_script_address,
validator_script_cbor_hex,
validator_script_path,
escrow_in_tx_hash_hex,
escrow_in_output_index,
escrow_in_lovelace,
escrow_in_datum_cbor_hex,
driver_pkh_hex,
fee_lovelace,
validity_window_seconds,
}: EscrowVetoUnsignedArgs,
) -> Result<String, String> {
let validator_cbor = resolve_validator_required(
validator_script_cbor_hex.as_deref(),
validator_script_path.as_deref(),
)?;
let escrow_in = build_escrow_spend_in(
&escrow_in_tx_hash_hex,
escrow_in_output_index,
escrow_in_lovelace,
&escrow_in_datum_cbor_hex,
)?;
let driver = decode_pkh28(&driver_pkh_hex, "driver_pkh_hex")?;
let cfg = self.dao_cfg_for_escrow()?;
let wallet_utxos = pull_wallet_utxos(&self.inner.chain, &self.inner.address).await?;
let (tip_slot, _tip_ms) = fetch_tip_slot_ms(self).await?;
let validity_upper_slot = tip_slot + validity_window_seconds.unwrap_or(1800);
let unsigned = build_unsigned_escrow_veto(EscrowVetoArgs {
cfg,
escrow_script_address: escrow_script_address.clone(),
validator_script_cbor: validator_cbor,
escrow_in,
driver_pkh: driver,
change_address: self.inner.address.clone(),
wallet_utxos,
tip_slot,
validity_upper_slot,
fee_lovelace,
ex_units: ESCROW_SPEND_EX_UNITS,
})
.map_err(|e| format!("build_unsigned_escrow_veto: {e}"))?;
Ok(serde_json::json!({
"tx_cbor_hex": unsigned.tx_cbor_hex,
"tx_hash_hex": unsigned.tx_hash_hex,
"refunds": unsigned.refunds.iter().map(|(p,l)| serde_json::json!({"contributor_pkh_hex": p, "lovelace": l})).collect::<Vec<_>>(),
"summary": unsigned.summary,
"next_step": "driver signs via wallet_sign_partial then submits via wallet_submit_signed_tx.",
"wip_warning": "⚠️ UNAUDITED escrow validator. Use preprod testnet only.",
})
.to_string())
}
#[tool(
name = "escrow_settle_unsigned",
description = "WIP — UNAUDITED: Build an unsigned escrow_settle tx — 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 party signer required by validator — anyone with funding + collateral can push it. Args: escrow_script_address, validator_script_cbor_hex OR validator_script_path, escrow_in_tx_hash_hex + escrow_in_output_index + escrow_in_lovelace + escrow_in_datum_cbor_hex, driver_pkh_hex (whoever's paying fees), fee_lovelace, validity_window_seconds (optional)."
)]
async fn escrow_settle_unsigned(
&self,
#[tool(aggr)] EscrowSettleUnsignedArgs {
escrow_script_address,
validator_script_cbor_hex,
validator_script_path,
escrow_in_tx_hash_hex,
escrow_in_output_index,
escrow_in_lovelace,
escrow_in_datum_cbor_hex,
driver_pkh_hex,
fee_lovelace,
validity_window_seconds,
}: EscrowSettleUnsignedArgs,
) -> Result<String, String> {
let validator_cbor = resolve_validator_required(
validator_script_cbor_hex.as_deref(),
validator_script_path.as_deref(),
)?;
let escrow_in = build_escrow_spend_in(
&escrow_in_tx_hash_hex,
escrow_in_output_index,
escrow_in_lovelace,
&escrow_in_datum_cbor_hex,
)?;
let driver = decode_pkh28(&driver_pkh_hex, "driver_pkh_hex")?;
let cfg = self.dao_cfg_for_escrow()?;
let wallet_utxos = pull_wallet_utxos(&self.inner.chain, &self.inner.address).await?;
let (tip_slot, tip_ms) = fetch_tip_slot_ms(self).await?;
// lower bound: caller-driven from tip; we sanity-check it satisfies
// the lock-window elapsed gate. Validator extracts `lower` from
// valid_from_slot anyway, so we anchor lower at tip_slot.
let validity_lower_slot = tip_slot;
let validity_lower_ms = tip_ms;
let validity_upper_slot = tip_slot + validity_window_seconds.unwrap_or(1800);
let unsigned = build_unsigned_escrow_settle(EscrowSettleArgs {
cfg,
escrow_script_address: escrow_script_address.clone(),
validator_script_cbor: validator_cbor,
escrow_in,
driver_pkh: driver,
change_address: self.inner.address.clone(),
wallet_utxos,
validity_lower_slot,
validity_lower_ms,
validity_upper_slot,
fee_lovelace,
ex_units: ESCROW_SPEND_EX_UNITS,
})
.map_err(|e| format!("build_unsigned_escrow_settle: {e}"))?;
Ok(serde_json::json!({
"tx_cbor_hex": unsigned.tx_cbor_hex,
"tx_hash_hex": unsigned.tx_hash_hex,
"recipient_pkh_hex": unsigned.recipient_pkh_hex,
"recipient_lovelace_paid": unsigned.recipient_lovelace_paid,
"summary": unsigned.summary,
"next_step": "driver signs via wallet_sign_partial then submits via wallet_submit_signed_tx.",
"wip_warning": "⚠️ UNAUDITED escrow validator. Use preprod testnet only.",
})
.to_string())
}
#[tool(
name = "escrow_refund_timeout_unsigned",
description = "WIP — UNAUDITED: Build an unsigned escrow_refund_timeout tx — consumes an Open escrow whose open_deadline has elapsed and refunds every contributor. No party signer required. Validator gate: state==Open AND lower > open_deadline_ms (strict gt). Args: escrow_script_address, validator_script_cbor_hex OR validator_script_path, escrow_in_tx_hash_hex + escrow_in_output_index + escrow_in_lovelace + escrow_in_datum_cbor_hex, driver_pkh_hex, fee_lovelace, validity_window_seconds (optional)."
)]
async fn escrow_refund_timeout_unsigned(
&self,
#[tool(aggr)] EscrowRefundTimeoutUnsignedArgs {
escrow_script_address,
validator_script_cbor_hex,
validator_script_path,
escrow_in_tx_hash_hex,
escrow_in_output_index,
escrow_in_lovelace,
escrow_in_datum_cbor_hex,
driver_pkh_hex,
fee_lovelace,
validity_window_seconds,
}: EscrowRefundTimeoutUnsignedArgs,
) -> Result<String, String> {
let validator_cbor = resolve_validator_required(
validator_script_cbor_hex.as_deref(),
validator_script_path.as_deref(),
)?;
let escrow_in = build_escrow_spend_in(
&escrow_in_tx_hash_hex,
escrow_in_output_index,
escrow_in_lovelace,
&escrow_in_datum_cbor_hex,
)?;
let driver = decode_pkh28(&driver_pkh_hex, "driver_pkh_hex")?;
let cfg = self.dao_cfg_for_escrow()?;
let wallet_utxos = pull_wallet_utxos(&self.inner.chain, &self.inner.address).await?;
let (tip_slot, tip_ms) = fetch_tip_slot_ms(self).await?;
let validity_lower_slot = tip_slot;
let validity_lower_ms = tip_ms;
let validity_upper_slot = tip_slot + validity_window_seconds.unwrap_or(1800);
let unsigned = build_unsigned_escrow_refund_timeout(EscrowRefundTimeoutArgs {
cfg,
escrow_script_address: escrow_script_address.clone(),
validator_script_cbor: validator_cbor,
escrow_in,
driver_pkh: driver,
change_address: self.inner.address.clone(),
wallet_utxos,
validity_lower_slot,
validity_lower_ms,
validity_upper_slot,
fee_lovelace,
ex_units: ESCROW_SPEND_EX_UNITS,
})
.map_err(|e| format!("build_unsigned_escrow_refund_timeout: {e}"))?;
Ok(serde_json::json!({
"tx_cbor_hex": unsigned.tx_cbor_hex,
"tx_hash_hex": unsigned.tx_hash_hex,
"refunds": unsigned.refunds.iter().map(|(p,l)| serde_json::json!({"contributor_pkh_hex": p, "lovelace": l})).collect::<Vec<_>>(),
"summary": unsigned.summary,
"next_step": "driver signs via wallet_sign_partial then submits via wallet_submit_signed_tx.",
"wip_warning": "⚠️ UNAUDITED escrow validator. Use preprod testnet only.",
})
.to_string())
}
/// Build a minimal DaoConfig stub for escrow tools — only the
/// `network` field is read by the escrow builders. Full DAO
/// resolution would require an active DAO, which escrow shouldn't
/// depend on.
fn dao_cfg_for_escrow(&self) -> Result<DaoConfig, String> {
let network = match self.inner.network {
Network::Mainnet => DaoNetwork::Mainnet,
Network::Preprod => DaoNetwork::Preprod,
Network::Preview => DaoNetwork::Preview,
};
Ok(DaoConfig {
name: "escrow".into(),
description: None,
governor_addr: String::new(),
stakes_addr: String::new(),
treasury_addr: String::new(),
gov_token_policy: String::new(),
gov_token_name_hex: String::new(),
initial_spend: String::new(),
max_cosigners: 5,
treasury_ref_config: String::new(),
network,
proposal_addr: None,
stake_st_policy: None,
proposal_st_policy: None,
script_refs: ScriptRefs::default(),
})
}
}
/// 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}"))?;
@ -3662,6 +4055,69 @@ fn decode_pkh28(s: &str, field: &str) -> Result<[u8; 28], String> {
Ok(out)
}
/// Resolve the escrow V3 validator CBOR from inline hex OR file path.
/// Required (not optional) — every escrow spend tool needs the script.
/// Wraps `resolve_ref_script_bytes` adding the "must be set" gate.
fn resolve_validator_required(
cbor_hex: Option<&str>,
path: Option<&str>,
) -> Result<Vec<u8>, String> {
let resolved = resolve_ref_script_bytes(cbor_hex, path)?;
resolved.ok_or_else(|| {
"validator_script_cbor_hex or validator_script_path must be set — escrow spend \
requires the V3 validator UPLC bytes (extract from aiken-escrow/plutus.json's \
validators[].compiledCode field)"
.into()
})
}
/// Build an EscrowSpendUtxoIn from raw caller-provided fields. Uses
/// the dao crate's CBOR-hex decoder so this MCP module stays free of
/// pallas-codec / pallas-primitives direct deps.
fn build_escrow_spend_in(
tx_hash_hex: &str,
output_index: u32,
lovelace: u64,
datum_cbor_hex: &str,
) -> Result<EscrowSpendUtxoIn, String> {
let datum = EscrowDatum::from_cbor_hex(datum_cbor_hex).map_err(|e| {
format!("escrow_in_datum_cbor_hex decode as EscrowDatum: {e}")
})?;
Ok(EscrowSpendUtxoIn {
tx_hash_hex: tx_hash_hex.to_string(),
output_index,
lovelace,
assets: vec![], // v1: ADA-only escrows
datum,
})
}
/// Fetch the chain tip's absolute slot + posix-ms.
async fn fetch_tip_slot_ms(svc: &WalletService) -> Result<(u64, i64), String> {
let tip_resp = svc
.inner
.chain
.get_raw_json("tip", &[])
.await
.map_err(|e| format!("koios tip: {e}"))?;
let tip: serde_json::Value =
serde_json::from_str(&tip_resp).map_err(|e| format!("tip parse: {e}"))?;
let tip_slot = tip
.as_array()
.and_then(|a| a.first())
.and_then(|t| t.get("abs_slot"))
.and_then(|s| s.as_u64())
.ok_or_else(|| format!("tip response missing abs_slot: {tip_resp}"))?;
let tip_ms = tip
.as_array()
.and_then(|a| a.first())
.and_then(|t| t.get("block_time"))
.and_then(|s| s.as_i64())
.map(|s| s * 1000)
.ok_or_else(|| format!("tip response missing block_time: {tip_resp}"))?;
Ok((tip_slot, tip_ms))
}
// ─── DAO arg structs ────────────────────────────────────────────────────────
#[derive(Debug, Deserialize, schemars::JsonSchema)]
@ -3815,7 +4271,6 @@ 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.
@ -3840,6 +4295,118 @@ pub struct EscrowOpenUnsignedArgs {
pub fee_lovelace: u64,
}
// ─── escrow_wip spend-tool args (deposit / agree / veto / settle / refund) ──
#[derive(Debug, Deserialize, schemars::JsonSchema)]
pub struct EscrowDepositUnsignedArgs {
pub escrow_script_address: String,
/// V3 validator CBOR bytecode as hex. Set this OR validator_script_path.
/// Hex strings >~4500 chars hit a known MCP transport bug — prefer
/// the path option for the full 7.9 KB validator.
#[serde(default)]
pub validator_script_cbor_hex: Option<String>,
/// Path on disk to a file holding the V3 validator CBOR-hex (one
/// long hex string; whitespace is stripped). Bypasses the MCP
/// hex-arg truncation bug. Set this OR validator_script_cbor_hex.
#[serde(default)]
pub validator_script_path: Option<String>,
/// tx_hash of the existing escrow UTxO to spend.
pub escrow_in_tx_hash_hex: String,
pub escrow_in_output_index: u32,
/// Lovelace currently locked at the escrow UTxO.
pub escrow_in_lovelace: u64,
/// Hex-encoded CBOR of the existing inline EscrowDatum on the
/// UTxO. Pull this via `chain_address_info` (look for the UTxO at
/// escrow_script_address with the right tx_hash#index, copy its
/// inline_datum field).
pub escrow_in_datum_cbor_hex: String,
/// Depositing party's pkh. Must equal datum.party_a or party_b.
pub contributor_pkh_hex: String,
/// Lovelace to add (>0). v1 ADA-only.
pub add_lovelace: u64,
pub fee_lovelace: u64,
/// Tx validity window in seconds from tip. Default 1800 (30 min).
#[serde(default)]
pub validity_window_seconds: Option<u64>,
}
#[derive(Debug, Deserialize, schemars::JsonSchema)]
pub struct EscrowAgreeUnsignedArgs {
pub escrow_script_address: String,
#[serde(default)]
pub validator_script_cbor_hex: Option<String>,
#[serde(default)]
pub validator_script_path: Option<String>,
pub escrow_in_tx_hash_hex: String,
pub escrow_in_output_index: u32,
pub escrow_in_lovelace: u64,
pub escrow_in_datum_cbor_hex: String,
/// Driver's pkh. Must equal datum.party_a or party_b. The OTHER
/// party adds their witness via wallet_sign_partial after this
/// tool returns the partial-signed CBOR.
pub driver_pkh_hex: String,
pub fee_lovelace: u64,
/// Tx validity window in seconds. Default 1800. Tool clamps to
/// open_deadline_ms if window would overshoot.
#[serde(default)]
pub validity_window_seconds: Option<u64>,
}
#[derive(Debug, Deserialize, schemars::JsonSchema)]
pub struct EscrowVetoUnsignedArgs {
pub escrow_script_address: String,
#[serde(default)]
pub validator_script_cbor_hex: Option<String>,
#[serde(default)]
pub validator_script_path: Option<String>,
pub escrow_in_tx_hash_hex: String,
pub escrow_in_output_index: u32,
pub escrow_in_lovelace: u64,
pub escrow_in_datum_cbor_hex: String,
/// Driver's pkh. Must equal datum.party_a or party_b.
pub driver_pkh_hex: String,
pub fee_lovelace: u64,
#[serde(default)]
pub validity_window_seconds: Option<u64>,
}
#[derive(Debug, Deserialize, schemars::JsonSchema)]
pub struct EscrowSettleUnsignedArgs {
pub escrow_script_address: String,
#[serde(default)]
pub validator_script_cbor_hex: Option<String>,
#[serde(default)]
pub validator_script_path: Option<String>,
pub escrow_in_tx_hash_hex: String,
pub escrow_in_output_index: u32,
pub escrow_in_lovelace: u64,
pub escrow_in_datum_cbor_hex: String,
/// Driver's pkh — anyone with funding + collateral can fire Settle.
/// Validator doesn't enforce a party signer.
pub driver_pkh_hex: String,
pub fee_lovelace: u64,
#[serde(default)]
pub validity_window_seconds: Option<u64>,
}
#[derive(Debug, Deserialize, schemars::JsonSchema)]
pub struct EscrowRefundTimeoutUnsignedArgs {
pub escrow_script_address: String,
#[serde(default)]
pub validator_script_cbor_hex: Option<String>,
#[serde(default)]
pub validator_script_path: Option<String>,
pub escrow_in_tx_hash_hex: String,
pub escrow_in_output_index: u32,
pub escrow_in_lovelace: u64,
pub escrow_in_datum_cbor_hex: String,
/// Driver's pkh — anyone can fire Refund-timeout once open_deadline elapses.
pub driver_pkh_hex: String,
pub fee_lovelace: u64,
#[serde(default)]
pub validity_window_seconds: Option<u64>,
}
/// Mainnet Shelley genesis constants for slot↔POSIX-ms conversion.
///
/// Per-network Shelley genesis constants for slot↔POSIX-ms conversion.