diff --git a/crates/aldabra-dao/src/agora/escrow.rs b/crates/aldabra-dao/src/agora/escrow.rs index 7aab1e4..c36000b 100644 --- a/crates/aldabra-dao/src/agora/escrow.rs +++ b/crates/aldabra-dao/src/agora/escrow.rs @@ -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 { + 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) diff --git a/crates/aldabra-mcp/Cargo.toml b/crates/aldabra-mcp/Cargo.toml index da2cb83..2c4ac60 100644 --- a/crates/aldabra-mcp/Cargo.toml +++ b/crates/aldabra-mcp/Cargo.toml @@ -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). diff --git a/crates/aldabra-mcp/src/tools.rs b/crates/aldabra-mcp/src/tools.rs index 4d77a50..289ea24 100644 --- a/crates/aldabra-mcp/src/tools.rs +++ b/crates/aldabra-mcp/src/tools.rs @@ -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 { + 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 { + 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 { + 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::>(), + "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 { + 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 { + 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::>(), + "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 { + 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, 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 { + 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, + /// 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, + /// 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, +} + +#[derive(Debug, Deserialize, schemars::JsonSchema)] +pub struct EscrowAgreeUnsignedArgs { + pub escrow_script_address: String, + #[serde(default)] + pub validator_script_cbor_hex: Option, + #[serde(default)] + pub validator_script_path: Option, + 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, +} + +#[derive(Debug, Deserialize, schemars::JsonSchema)] +pub struct EscrowVetoUnsignedArgs { + pub escrow_script_address: String, + #[serde(default)] + pub validator_script_cbor_hex: Option, + #[serde(default)] + pub validator_script_path: Option, + 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, +} + +#[derive(Debug, Deserialize, schemars::JsonSchema)] +pub struct EscrowSettleUnsignedArgs { + pub escrow_script_address: String, + #[serde(default)] + pub validator_script_cbor_hex: Option, + #[serde(default)] + pub validator_script_path: Option, + 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, +} + +#[derive(Debug, Deserialize, schemars::JsonSchema)] +pub struct EscrowRefundTimeoutUnsignedArgs { + pub escrow_script_address: String, + #[serde(default)] + pub validator_script_cbor_hex: Option, + #[serde(default)] + pub validator_script_path: Option, + 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, +} + /// Mainnet Shelley genesis constants for slot↔POSIX-ms conversion. /// /// Per-network Shelley genesis constants for slot↔POSIX-ms conversion.