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:
parent
0ba95c1709
commit
7ea4c4cd33
3 changed files with 564 additions and 3 deletions
|
|
@ -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,
|
||||
|
|
|
|||
424
crates/aldabra-core/src/plutus.rs
Normal file
424
crates/aldabra-core/src/plutus.rs
Normal 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.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<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(®ular));
|
||||
}
|
||||
}
|
||||
|
|
@ -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."
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue