phase 4.1-4.3: plutus script spend

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
This commit is contained in:
Cobb 2026-05-04 12:44:06 -07:00
parent 0ba95c1709
commit 7ea4c4cd33
3 changed files with 564 additions and 3 deletions

View file

@ -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,

View file

@ -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.14.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<PlutusExUnits> 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, WalletError> {
pallas_addresses::Address::from_bech32(bech32)
.map_err(|e| WalletError::Address(e.to_string()))
}
fn parse_tx_hash(hex_str: &str) -> Result<Hash<32>, 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<Vec<u8>, 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<Vec<u8>, 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<InputUtxo> = 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<StagingTransaction, WalletError> {
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::<u8>::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(&regular));
}
}

View file

@ -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<String>,
}
#[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<String>,
/// 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<ExUnitsArg>,
}
#[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<String, String> {
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<Vec<u8>> = 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<InputUtxo> = 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."