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:
Sulkta 2026-05-04 12:44:06 -07:00
parent 49986d2864
commit 5888d37df6
3 changed files with 564 additions and 3 deletions

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."