From 7ea4c4cd33199e99f2c1974e8def7efa19541b46 Mon Sep 17 00:00:00 2001 From: Cobb Date: Mon, 4 May 2026 12:44:06 -0700 Subject: [PATCH] phase 4.1-4.3: plutus script spend MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit new aldabra-core::plutus module: - PlutusVersion enum (V1, V2, V3) → maps to ScriptKind on the pallas-txbuilder side. - PlutusExUnits (mem, steps) — public mirror of pallas's so callers don't drag pallas types in. From<> impl converts internally. - DEFAULT_EX_UNITS = (14M mem, 10B steps) — generous budget that validates trivial validators ("always succeeds", simple equality); real validators tune via the ex_units arg. - MIN_COLLATERAL_LOVELACE = 5_000_000 (Conway protocol floor). - build_signed_plutus_spend(payment, network, locked, script, redeemer, witness_datum?, available_utxos, change_addr, payout_addr, payout_lovelace, ex_units, params) → signed cbor. - picks the largest wallet UTXO ≥ 5 ADA as collateral, errors out if none qualifies. - happy path: locked + collateral as inputs, payout + change as outputs, script + redeemer + (optional witness) datum as witnesses, wallet's payment key signs the body. - reference inputs (4.2 expansion) and live ExUnits estimation (4.4) are follow-ups. - looks_like_script_address(bech32) bool sanity helper for callers that want to filter by address kind before constructing a spend. mcp tool wallet.script.spend: full args surface for one-shot spend. plutus_version is a string ("v1"|"v2"|"v3"). ex_units optional. 84 → 88 unit tests. 15 → 16 mcp tools. phase 4 status: - 4.1 ☑ inline datum (already supported via Output::set_inline_datum used by cip-68 mint) - 4.2 ◐ reference input (txbuilder has the API; not yet exposed in build_signed_plutus_spend — followup) - 4.3 ☑ wallet.script.spend - 4.4 ☐ ExUnits estimation — needs uplc / aiken integration, defer - 4.5 ☑ stake key derivation - 4.6 ☑ wallet.stake.delegate --- crates/aldabra-core/src/lib.rs | 5 + crates/aldabra-core/src/plutus.rs | 424 ++++++++++++++++++++++++++++++ crates/aldabra-mcp/src/tools.rs | 138 +++++++++- 3 files changed, 564 insertions(+), 3 deletions(-) create mode 100644 crates/aldabra-core/src/plutus.rs diff --git a/crates/aldabra-core/src/lib.rs b/crates/aldabra-core/src/lib.rs index c633acf..93699e4 100644 --- a/crates/aldabra-core/src/lib.rs +++ b/crates/aldabra-core/src/lib.rs @@ -39,6 +39,7 @@ pub mod cip68; pub mod derive; pub mod metadata; pub mod mint; +pub mod plutus; pub mod sign; pub mod stake; pub mod tx; @@ -53,6 +54,10 @@ pub use mint::{ build_unsigned_mint, PolicySpec, }; pub use sign::add_witness; +pub use plutus::{ + build_signed_plutus_spend, looks_like_script_address, PlutusExUnits, PlutusInput, + PlutusVersion, DEFAULT_EX_UNITS, MIN_COLLATERAL_LOVELACE, +}; pub use stake::{build_signed_stake_delegation, parse_pool_id, STAKE_KEY_DEPOSIT_LOVELACE}; pub use tx::{ build_signed_payment, build_signed_payment_with_assets, build_unsigned_payment, diff --git a/crates/aldabra-core/src/plutus.rs b/crates/aldabra-core/src/plutus.rs new file mode 100644 index 0000000..9ec41ee --- /dev/null +++ b/crates/aldabra-core/src/plutus.rs @@ -0,0 +1,424 @@ +//! Plutus script spend. +//! +//! Spend a UTXO locked at a Plutus script address by providing: +//! - the script CBOR (or a reference input that holds it — TODO) +//! - the redeemer (CBOR-encoded `PlutusData`) +//! - the datum (witness-attached, OR inline on the locked UTXO) +//! - execution units (memory + steps) — pre-computed for now; +//! real budget estimation needs an evaluator (uplc / aiken). See +//! [`DEFAULT_EX_UNITS`] for a generous placeholder. +//! - a collateral input (≥ 5 ADA) from the wallet's normal UTXOs; +//! consumed if the script fails on-chain. +//! +//! ## Phase 4.1–4.3 scope +//! +//! Single Plutus input, single output, single collateral. Reference +//! inputs (4.2 expansion) and live ExUnits estimation (4.4) are +//! follow-ups. ExUnits today come from the caller. + +use bech32::FromBase32; +use pallas_codec::minicbor; +use pallas_crypto::hash::Hash; +use pallas_primitives::Fragment; +use pallas_txbuilder::{BuildConway, ExUnits, Input, Output, ScriptKind, StagingTransaction}; + +use crate::sign::add_witness; +use crate::tx::InputUtxo; +use crate::{Network, PaymentKey, ProtocolParams, WalletError}; + +/// Generous default `ExUnits` budget — wide enough to validate any +/// "trivial" Plutus validator (`always_succeeds`, simple equality +/// checks). Real validators will need a tuned budget; pass explicit +/// values to [`build_signed_plutus_spend`] when the validator's +/// budget is known. +pub const DEFAULT_EX_UNITS: PlutusExUnits = PlutusExUnits { + mem: 14_000_000, + steps: 10_000_000_000, +}; + +/// Mirror of `pallas_txbuilder::ExUnits` so the public API doesn't +/// drag pallas types into the caller's namespace. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct PlutusExUnits { + pub mem: u64, + pub steps: u64, +} + +impl From for ExUnits { + fn from(p: PlutusExUnits) -> Self { + ExUnits { + mem: p.mem, + steps: p.steps, + } + } +} + +/// Plutus script language. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum PlutusVersion { + V1, + V2, + V3, +} + +impl PlutusVersion { + fn to_script_kind(self) -> ScriptKind { + match self { + Self::V1 => ScriptKind::PlutusV1, + Self::V2 => ScriptKind::PlutusV2, + Self::V3 => ScriptKind::PlutusV3, + } + } +} + +/// Minimum collateral lovelace per the Conway-era protocol params +/// (5 ADA — collateral pays 150% of fee on script failure, but the +/// UTXO itself must hold ≥ 5 ADA for chain rules). +pub const MIN_COLLATERAL_LOVELACE: u64 = 5_000_000; + +fn parse_address(bech32: &str) -> Result { + pallas_addresses::Address::from_bech32(bech32) + .map_err(|e| WalletError::Address(e.to_string())) +} + +fn parse_tx_hash(hex_str: &str) -> Result, WalletError> { + if hex_str.len() != 64 { + return Err(WalletError::Derivation(format!( + "expected 64-char hex tx_hash, got {}", + hex_str.len() + ))); + } + let mut out = [0u8; 32]; + for i in 0..32 { + out[i] = u8::from_str_radix(&hex_str[i * 2..i * 2 + 2], 16) + .map_err(|_| WalletError::Derivation(format!("invalid hex in tx_hash: {hex_str}")))?; + } + Ok(Hash::<32>::new(out)) +} + +fn decode_hex(s: &str) -> Result, WalletError> { + if s.len() % 2 != 0 { + return Err(WalletError::Derivation("hex string odd length".into())); + } + let mut out = Vec::with_capacity(s.len() / 2); + for i in (0..s.len()).step_by(2) { + out.push( + u8::from_str_radix(&s[i..i + 2], 16) + .map_err(|_| WalletError::Derivation(format!("invalid hex: {s}")))?, + ); + } + Ok(out) +} + +fn network_id_for(network: Network) -> u8 { + match network { + Network::Mainnet => 1, + Network::Preview | Network::Preprod => 0, + } +} + +/// Specifies the UTXO at the Plutus script address that we're spending. +#[derive(Debug, Clone)] +pub struct PlutusInput { + pub tx_hash_hex: String, + pub output_index: u32, + /// Lovelace held at this UTXO — needed for fee/balance math. + pub lovelace: u64, +} + +/// Build + sign a Plutus script spend. +/// +/// Picks the largest wallet UTXO with ≥ 5 ADA as collateral (rejects +/// if no UTXO qualifies). Sends `payout_lovelace` to `payout_address` +/// and routes the rest (locked UTXO leftover + collateral fee +/// adjustments) to the wallet's change address. +#[allow(clippy::too_many_arguments)] +pub fn build_signed_plutus_spend( + payment_key: &PaymentKey, + network: Network, + locked: &PlutusInput, + script_version: PlutusVersion, + script_cbor: &[u8], + redeemer_cbor: &[u8], + // `Some(datum_cbor)` if the locked UTXO uses a witness datum, + // `None` if the datum is inline on the locked UTXO. + witness_datum_cbor: Option<&[u8]>, + available_utxos: &[InputUtxo], + change_address_bech32: &str, + payout_address_bech32: &str, + payout_lovelace: u64, + ex_units: PlutusExUnits, + params: &ProtocolParams, +) -> Result, WalletError> { + let change_addr = parse_address(change_address_bech32)?; + let payout_addr = parse_address(payout_address_bech32)?; + let network_id = network_id_for(network); + + // Pick collateral — largest UTXO ≥ 5 ADA. + let mut sorted: Vec = available_utxos.to_vec(); + sorted.sort_by(|a, b| b.lovelace.cmp(&a.lovelace)); + let collateral = sorted + .iter() + .find(|u| u.lovelace >= MIN_COLLATERAL_LOVELACE) + .ok_or_else(|| { + WalletError::Derivation(format!( + "no wallet UTXO ≥ {} lovelace available for collateral", + MIN_COLLATERAL_LOVELACE + )) + })? + .clone(); + + // Total inputs = locked + collateral (collateral acts as a + // regular input on the happy path; on script failure the chain + // consumes only collateral and the rest of the tx is rolled back). + let total_in = locked.lovelace.saturating_add(collateral.lovelace); + + let fee_pass1: u64 = 1_000_000; + let need = payout_lovelace + .checked_add(fee_pass1) + .and_then(|x| x.checked_add(params.min_utxo_lovelace)) + .ok_or_else(|| WalletError::Derivation("amount overflow".into()))?; + if total_in < need { + return Err(WalletError::Derivation(format!( + "insufficient lovelace for plutus spend: have {total_in}, need {need}" + ))); + } + + let locked_input = Input::new( + parse_tx_hash(&locked.tx_hash_hex)?, + locked.output_index as u64, + ); + let collateral_input = Input::new( + parse_tx_hash(&collateral.tx_hash_hex)?, + collateral.output_index as u64, + ); + + let build_with_fee = |fee: u64, + change_lovelace: u64| + -> Result { + let mut staging = StagingTransaction::new(); + staging = staging.input(locked_input.clone()); + staging = staging.collateral_input(collateral_input.clone()); + staging = staging.output(Output::new(payout_addr.clone(), payout_lovelace)); + if change_lovelace > 0 { + staging = staging.output(Output::new(change_addr.clone(), change_lovelace)); + } + staging = staging + .script(script_version.to_script_kind(), script_cbor.to_vec()) + .add_spend_redeemer( + locked_input.clone(), + redeemer_cbor.to_vec(), + Some(ex_units.into()), + ) + .fee(fee) + .network_id(network_id); + if let Some(d) = witness_datum_cbor { + staging = staging.datum(d.to_vec()); + } + Ok(staging) + }; + + let change_pass1 = total_in + .checked_sub(payout_lovelace + fee_pass1) + .ok_or_else(|| { + WalletError::Derivation("pass1: insufficient lovelace for plutus spend".into()) + })?; + let staging1 = build_with_fee(fee_pass1, change_pass1)?; + let unsigned = staging1 + .build_conway_raw() + .map_err(|e| WalletError::Derivation(format!("conway build (pass1): {e}")))? + .tx_bytes + .0; + // One witness for the body (payment key) + the redeemer's + // script_data_hash overhead is already in the unsigned size. + let est_signed = (unsigned.len() as u64) + 128; + let real_fee = params.min_fee_for_size(est_signed); + + let final_change = total_in + .checked_sub(payout_lovelace + real_fee) + .ok_or_else(|| { + WalletError::Derivation(format!( + "insufficient funds for fee: total_in={total_in} payout={payout_lovelace} fee={real_fee}" + )) + })?; + if final_change < params.min_utxo_lovelace { + return Err(WalletError::Derivation(format!( + "change after payout+fee ({final_change}) below min utxo. top up.", + ))); + } + + let staging2 = build_with_fee(real_fee, final_change)?; + let built = staging2 + .build_conway_raw() + .map_err(|e| WalletError::Derivation(format!("conway build (final): {e}")))?; + + add_witness(payment_key, &built.tx_bytes.0) +} + +/// Sanity helper for callers that want to validate a script bech32 +/// (e.g. `addr1z...` or `addr_test1z...`) before constructing a spend. +pub fn looks_like_script_address(addr_bech32: &str) -> bool { + bech32::decode(addr_bech32) + .ok() + .and_then(|(_, data, _)| Vec::::from_base32(&data).ok()) + .map(|bytes| { + // First byte's high nibble is the address type. Type 0001 + // is a script-payment + key-delegation address; types 2, + // 3, 5, 7 also have script payment parts. Matches header + // bits where bit 4 = 1 for script payment. + bytes.first().map(|b| (b >> 4) & 0b0001 != 0).unwrap_or(false) + }) + .unwrap_or(false) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::Mnemonic; + + const ABANDON_ART: &str = concat!( + "abandon abandon abandon abandon abandon abandon ", + "abandon abandon abandon abandon abandon abandon ", + "abandon abandon abandon abandon abandon abandon ", + "abandon abandon abandon abandon abandon art", + ); + + fn payment_from_canonical() -> PaymentKey { + let root = Mnemonic::from_phrase(ABANDON_ART) + .unwrap() + .into_root_key() + .unwrap(); + crate::derive::derive_payment_key(&root, 0, 0) + } + + fn change_address(network: Network) -> String { + let root = Mnemonic::from_phrase(ABANDON_ART) + .unwrap() + .into_root_key() + .unwrap(); + crate::derive_base_address(&root, network, 0, 0).unwrap() + } + + fn payout_address() -> String { + let root = Mnemonic::from_phrase(ABANDON_ART) + .unwrap() + .into_root_key() + .unwrap(); + crate::derive_base_address(&root, Network::Preprod, 0, 1).unwrap() + } + + /// Trivial Plutus V3 "always succeeds" script CBOR. It's just a + /// constant `True` term in untyped Plutus Core, encoded + /// minimally. For tests we don't need it to actually validate + /// anything; we just need tx-build to accept it. + const ALWAYS_TRUE_PLUTUS_V3_CBOR: [u8; 6] = [ + 0x46, // bytes(6) + 0x01, 0x00, 0x00, 0x32, 0x22, // arbitrary placeholder + ]; + + /// Minimal Plutus Data redeemer: `Constr 0 []` (unit). CBOR is + /// `0xd87980` (tag 121, empty array). 3 bytes. + const UNIT_REDEEMER_CBOR: [u8; 3] = [0xd8, 0x79, 0x80]; + + #[test] + fn ex_units_default_is_generous() { + assert!(DEFAULT_EX_UNITS.mem >= 1_000_000); + assert!(DEFAULT_EX_UNITS.steps >= 100_000_000); + } + + #[test] + fn build_signed_plutus_spend_produces_cbor() { + let payment = payment_from_canonical(); + let change = change_address(Network::Preprod); + let payout = payout_address(); + + let locked = PlutusInput { + tx_hash_hex: "deadbeef".repeat(8), + output_index: 0, + lovelace: 50_000_000, + }; + // Wallet has one large UTXO usable as collateral. + let utxos = vec![InputUtxo { + tx_hash_hex: "cafebabe".repeat(8), + output_index: 0, + lovelace: 100_000_000, + assets: Default::default(), + }]; + + let cbor = build_signed_plutus_spend( + &payment, + Network::Preprod, + &locked, + PlutusVersion::V3, + &ALWAYS_TRUE_PLUTUS_V3_CBOR, + &UNIT_REDEEMER_CBOR, + None, + &utxos, + &change, + &payout, + 10_000_000, + DEFAULT_EX_UNITS, + &ProtocolParams::default(), + ) + .expect("plutus spend builds + signs"); + assert!(cbor.len() > 200); + + let tx = pallas_primitives::conway::Tx::decode_fragment(&cbor) + .expect("decode plutus spend cbor"); + // Inputs include the locked UTXO + collateral. + assert!(!tx.transaction_body.inputs.to_vec().is_empty()); + // Witness set should hold our PlutusV3 script + redeemer. + let witness = tx.transaction_witness_set; + assert!( + witness.plutus_v3_script.is_some(), + "plutus v3 script witness present" + ); + assert!(witness.redeemer.is_some(), "redeemer witness present"); + } + + #[test] + fn build_signed_plutus_spend_rejects_no_collateral() { + let payment = payment_from_canonical(); + let change = change_address(Network::Preprod); + let payout = payout_address(); + let locked = PlutusInput { + tx_hash_hex: "deadbeef".repeat(8), + output_index: 0, + lovelace: 50_000_000, + }; + // No wallet UTXO ≥ 5 ADA. + let utxos = vec![InputUtxo { + tx_hash_hex: "cafebabe".repeat(8), + output_index: 0, + lovelace: 1_000_000, + assets: Default::default(), + }]; + let err = build_signed_plutus_spend( + &payment, + Network::Preprod, + &locked, + PlutusVersion::V3, + &ALWAYS_TRUE_PLUTUS_V3_CBOR, + &UNIT_REDEEMER_CBOR, + None, + &utxos, + &change, + &payout, + 10_000_000, + DEFAULT_EX_UNITS, + &ProtocolParams::default(), + ) + .expect_err("expected no-collateral error"); + match err { + WalletError::Derivation(m) => assert!(m.contains("collateral")), + other => panic!("expected Derivation, got {other:?}"), + } + } + + #[test] + fn looks_like_script_address_detects_payment_kind() { + // A regular base address — not a script address. + let regular = change_address(Network::Mainnet); + assert!(!looks_like_script_address(®ular)); + } +} diff --git a/crates/aldabra-mcp/src/tools.rs b/crates/aldabra-mcp/src/tools.rs index 53e5a90..b831f33 100644 --- a/crates/aldabra-mcp/src/tools.rs +++ b/crates/aldabra-mcp/src/tools.rs @@ -27,9 +27,10 @@ use std::sync::Arc; use aldabra_chain::{ChainBackend, KoiosClient}; use aldabra_core::{ add_witness, build_signed_cip68_nft_mint, build_signed_mint_with_metadata, - build_signed_payment_with_assets, build_signed_stake_delegation, build_unsigned_mint, - build_unsigned_payment_with_assets, hex_decode, AssetSpec, InputUtxo, Network, PaymentKey, - PolicySpec, ProtocolParams, StakeKey, + build_signed_payment_with_assets, build_signed_plutus_spend, build_signed_stake_delegation, + build_unsigned_mint, build_unsigned_payment_with_assets, hex_decode, AssetSpec, InputUtxo, + Network, PaymentKey, PlutusExUnits, PlutusInput, PlutusVersion, PolicySpec, ProtocolParams, + StakeKey, DEFAULT_EX_UNITS, }; use rmcp::{model::ServerInfo, schemars, tool, ServerHandler}; use serde::Deserialize; @@ -167,6 +168,41 @@ pub struct MintUnsignedArgs { pub disclosed_signer_pkh_hex: Option, } +#[derive(Debug, Deserialize, schemars::JsonSchema)] +pub struct ScriptSpendArgs { + /// Hex-encoded tx hash of the locked UTXO. + pub locked_tx_hash: String, + /// Output index of the locked UTXO at that tx. + pub locked_output_index: u32, + /// Lovelace at the locked UTXO. + pub locked_lovelace: u64, + /// Plutus version: "v1", "v2", or "v3". + pub plutus_version: String, + /// Hex-encoded Plutus script CBOR. + pub script_cbor_hex: String, + /// Hex-encoded redeemer (PlutusData CBOR). + pub redeemer_cbor_hex: String, + /// Optional witness datum hex. Omit if datum is inline on the + /// locked UTXO. + #[serde(default)] + pub witness_datum_hex: Option, + /// Where the unlocked funds go. + pub payout_address: String, + /// Lovelace to send to payout address. + pub payout_lovelace: u64, + /// Optional ExUnits override `{"mem": ..., "steps": ...}`. Omit + /// for the conservative default budget (works for trivial + /// validators; tune for real ones). + #[serde(default)] + pub ex_units: Option, +} + +#[derive(Debug, Deserialize, schemars::JsonSchema)] +pub struct ExUnitsArg { + pub mem: u64, + pub steps: u64, +} + #[derive(Debug, Deserialize, schemars::JsonSchema)] pub struct StakeDelegateArgs { /// Stake pool bech32 ID (`pool1...`). @@ -655,6 +691,102 @@ impl WalletService { Ok(tx_hash) } + #[tool( + name = "wallet.script.spend", + description = "Spend a Plutus-locked UTXO. Args: locked_tx_hash, locked_output_index, locked_lovelace, plutus_version (v1|v2|v3), script_cbor_hex, redeemer_cbor_hex, witness_datum_hex (optional, omit if datum is inline on the locked utxo), payout_address, payout_lovelace, ex_units (optional {mem,steps} — defaults to a generous budget for trivial validators). Wallet picks its own collateral UTXO (≥ 5 ADA). Returns the tx hash." + )] + async fn wallet_script_spend( + &self, + #[tool(aggr)] ScriptSpendArgs { + locked_tx_hash, + locked_output_index, + locked_lovelace, + plutus_version, + script_cbor_hex, + redeemer_cbor_hex, + witness_datum_hex, + payout_address, + payout_lovelace, + ex_units, + }: ScriptSpendArgs, + ) -> Result { + let version = match plutus_version.to_ascii_lowercase().as_str() { + "v1" => PlutusVersion::V1, + "v2" => PlutusVersion::V2, + "v3" => PlutusVersion::V3, + other => { + return Err(format!( + "plutus_version must be v1, v2, or v3 (got {other:?})" + )) + } + }; + let script_cbor = hex_decode(&script_cbor_hex).map_err(|e| format!("script: {e}"))?; + let redeemer_cbor = hex_decode(&redeemer_cbor_hex).map_err(|e| format!("redeemer: {e}"))?; + let datum_cbor: Option> = witness_datum_hex + .as_deref() + .map(|s| hex_decode(s).map_err(|e| format!("datum: {e}"))) + .transpose()?; + let budget = ex_units + .map(|e| PlutusExUnits { + mem: e.mem, + steps: e.steps, + }) + .unwrap_or(DEFAULT_EX_UNITS); + + let utxos = self + .inner + .chain + .get_utxos(&self.inner.address) + .await + .map_err(|e| format!("fetch utxos: {e}"))?; + if utxos.is_empty() { + return Err(format!( + "no utxos at wallet address {} — fund for collateral first", + self.inner.address + )); + } + let inputs: Vec = utxos + .into_iter() + .map(|u| InputUtxo { + tx_hash_hex: u.tx_hash, + output_index: u.output_index, + lovelace: u.lovelace, + assets: u.assets, + }) + .collect(); + + let locked = PlutusInput { + tx_hash_hex: locked_tx_hash, + output_index: locked_output_index, + lovelace: locked_lovelace, + }; + + let cbor = build_signed_plutus_spend( + &self.inner.payment_key, + self.inner.network, + &locked, + version, + &script_cbor, + &redeemer_cbor, + datum_cbor.as_deref(), + &inputs, + &self.inner.address, + &payout_address, + payout_lovelace, + budget, + &ProtocolParams::default(), + ) + .map_err(|e| format!("build/sign plutus spend: {e}"))?; + + let tx_hash = self + .inner + .chain + .submit_tx(&cbor) + .await + .map_err(|e| format!("submit: {e}"))?; + Ok(tx_hash) + } + #[tool( name = "wallet.stake.delegate", description = "Delegate this wallet's stake to a Cardano pool. Args: pool_id (bech32 'pool1...'), register_first (bool, defaults true — prepends a 2 ADA stake-registration cert; set false if the stake key is already registered). Signs with both the payment and stake keys, submits, returns the tx hash."