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:
parent
dba3d46271
commit
ad2d17e3d0
3 changed files with 606 additions and 12 deletions
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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).
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue