aldabra/crates/aldabra-mcp/src/tools.rs
Cobb 7ea4c4cd33 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
2026-05-04 12:44:06 -07:00

957 lines
34 KiB
Rust

//! MCP tool handlers.
//!
//! Each `#[tool]` becomes a discoverable MCP tool. Tool names use
//! dotted notation per the MCP convention; the underlying Rust fn
//! names use snake_case.
//!
//! ## Phase 1 — read path
//!
//! - `wallet.address` — bech32 base address
//! - `wallet.network` — mainnet | preview | preprod
//! - `wallet.balance` — JSON `{lovelace, assets}`
//! - `wallet.utxos` — JSON list of UTXOs
//!
//! ## Phase 2 — send path
//!
//! - `wallet.send` — build + sign + submit ADA payment, with hard
//! cap guard (`max_send_lovelace`)
//! - `wallet.tx_status` — poll a submitted tx hash
//!
//! Returns:
//! - `String` results pass through `IntoContents` directly.
//! - `Result<String, String>` lets us surface chain / build errors
//! as MCP tool-call errors instead of crashing the daemon.
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_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;
/// MCP-facing asset spec — separate from `aldabra_core::AssetSpec`
/// so the JsonSchema derive doesn't bleed schemars into the
/// security-boundary crate.
#[derive(Debug, Deserialize, schemars::JsonSchema, Clone)]
pub struct McpAssetSpec {
/// 56-char hex (28 bytes) Cardano policy ID.
pub policy_id_hex: String,
/// Hex-encoded asset name, 0-64 hex chars (0-32 bytes).
pub asset_name_hex: String,
pub quantity: u64,
}
impl From<McpAssetSpec> for AssetSpec {
fn from(m: McpAssetSpec) -> Self {
Self {
policy_id_hex: m.policy_id_hex,
asset_name_hex: m.asset_name_hex,
quantity: m.quantity,
}
}
}
#[derive(Clone)]
pub struct WalletService {
inner: Arc<WalletInner>,
}
struct WalletInner {
network: Network,
address: String,
chain: KoiosClient,
payment_key: PaymentKey,
stake_key: StakeKey,
max_send_lovelace: u64,
}
impl WalletService {
pub fn new(
network: Network,
address: String,
koios_base: String,
payment_key: PaymentKey,
stake_key: StakeKey,
max_send_lovelace: u64,
) -> Self {
Self {
inner: Arc::new(WalletInner {
network,
address,
chain: KoiosClient::new(koios_base),
payment_key,
stake_key,
max_send_lovelace,
}),
}
}
}
#[derive(Debug, Deserialize, schemars::JsonSchema)]
pub struct SendArgs {
/// Recipient bech32 address.
pub to_address: String,
/// Amount to send in lovelace (1 ADA = 1_000_000 lovelace).
pub lovelace: u64,
/// Optional native assets to include in the payment output.
/// Each entry needs the policy_id (56 hex chars) + asset_name
/// (hex of raw bytes, 0-64 chars) + quantity.
#[serde(default)]
pub assets: Vec<McpAssetSpec>,
/// Bypass the configured `max_send_lovelace` hard cap. Only
/// pass `true` for an intentional, user-confirmed large send.
#[serde(default)]
pub force: bool,
}
#[derive(Debug, Deserialize, schemars::JsonSchema)]
pub struct TxStatusArgs {
/// Hex-encoded transaction hash returned by `wallet.send`.
pub tx_hash: String,
}
#[derive(Debug, Deserialize, schemars::JsonSchema)]
pub struct UnsignedSendArgs {
/// Recipient bech32 address.
pub to_address: String,
/// Amount to send in lovelace.
pub lovelace: u64,
/// Optional native assets to include in the payment output.
#[serde(default)]
pub assets: Vec<McpAssetSpec>,
}
#[derive(Debug, Deserialize, schemars::JsonSchema)]
pub struct SubmitSignedArgs {
/// Hex-encoded signed transaction CBOR — produced by an external
/// cold-signer that consumed the unsigned CBOR returned by
/// `wallet.send.unsigned`.
pub signed_cbor_hex: String,
}
#[derive(Debug, Deserialize, schemars::JsonSchema)]
pub struct PolicyCreateArgs {
/// Optional slot after which the policy becomes invalid. Use
/// to lock supply (Cardano idiom: mint then expire). Omit for
/// an open-ended policy that allows mint/burn forever.
#[serde(default)]
pub invalid_after_slot: Option<u64>,
}
#[derive(Debug, Deserialize, schemars::JsonSchema)]
pub struct MintUnsignedArgs {
pub dest_address: String,
pub dest_lovelace: u64,
pub asset_name_hex: String,
pub quantity: i64,
/// PolicySpec as a JSON object with `type`: `single_sig` |
/// `single_sig_timelock` | `nofk`. If omitted, defaults to a
/// single-sig policy bound to this wallet's payment key (same as
/// `wallet.mint`).
#[serde(default)]
pub policy: Option<serde_json::Value>,
/// Optional CIP-25 v2 metadata.
#[serde(default)]
pub metadata: Option<serde_json::Value>,
/// Hex of the pkh to disclose as a required signer in the tx
/// body. Defaults to this wallet's payment key hash. For
/// multi-sig flows where you want a different signer hint, pass
/// it explicitly. For native-script-only mints this field is
/// optional metadata for downstream signers.
#[serde(default)]
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...`).
pub pool_id: String,
/// If true, prepends a stake-registration certificate (one-time
/// 2 ADA deposit, refunded on deregistration). Set to false if
/// this wallet's stake key is already registered (re-delegation).
/// Defaults to true (most users delegating for the first time).
#[serde(default = "default_register_first")]
pub register_first: bool,
}
fn default_register_first() -> bool {
true
}
#[derive(Debug, Deserialize, schemars::JsonSchema)]
pub struct SignPartialArgs {
/// Hex-encoded Conway-era tx CBOR — unsigned, or already
/// partially signed by another party. The wallet's payment key
/// gets appended as an additional VKeyWitness.
pub cbor_hex: String,
}
#[derive(Debug, Deserialize, schemars::JsonSchema)]
pub struct Cip68NftArgs {
/// Recipient address — receives the user NFT (label 222).
pub user_address: String,
/// ADA to attach to the user NFT output (≥ 1.5 ADA min).
/// Defaults to 1_500_000 lovelace if omitted.
#[serde(default = "default_token_lovelace")]
pub user_lovelace: u64,
/// Hex-encoded asset name body (0-28 bytes; the 4-byte CIP-68
/// prefix gets prepended automatically). Same body is shared
/// between the ref NFT (100) and the user NFT (222).
pub name_body_hex: String,
/// CIP-68 metadata JSON object (`name`, `image`, `description`,
/// `mediaType`, `files`, etc.). Encoded as Plutus Data and
/// attached as the inline datum on the ref-NFT output.
pub metadata: serde_json::Value,
/// Optional address where the reference NFT lives. Defaults to
/// the wallet's own address — keeps the NFT *mutable* (the
/// wallet's payment key can later spend the ref UTXO and update
/// metadata). Pass an "always-fails" script address for
/// immutable NFTs.
#[serde(default)]
pub ref_address: Option<String>,
/// ADA to attach to the ref NFT output. Defaults to 1_500_000.
#[serde(default = "default_token_lovelace")]
pub ref_lovelace: u64,
/// Optional invalid-after slot for the auto-generated policy.
/// Omit for an open-ended single-sig policy bound to this
/// wallet's payment key.
#[serde(default)]
pub invalid_after_slot: Option<u64>,
}
fn default_token_lovelace() -> u64 {
1_500_000
}
#[derive(Debug, Deserialize, schemars::JsonSchema)]
pub struct MintArgs {
/// Recipient bech32 address. Receives `dest_lovelace` ADA + the
/// freshly-minted asset. Often the wallet's own address.
pub dest_address: String,
/// ADA to attach to the mint output (must be ≥ min_utxo —
/// typically 1_500_000 lovelace for an asset-bearing output).
pub dest_lovelace: u64,
/// Hex-encoded asset name (raw bytes, 0-32 bytes).
pub asset_name_hex: String,
/// Mint quantity. Positive = mint, negative = burn (caller must
/// hold the assets to burn).
pub quantity: i64,
/// Optional invalid-after slot for the auto-generated policy.
/// If omitted, generates an open-ended single-sig policy bound
/// to the wallet's payment key.
#[serde(default)]
pub invalid_after_slot: Option<u64>,
/// Optional CIP-25 v2 metadata: a JSON object with the asset's
/// per-attributes (`name`, `image`, `description`, `mediaType`,
/// `files`, etc.). Wallets and explorers display this when
/// rendering the asset.
#[serde(default)]
pub metadata: Option<serde_json::Value>,
}
#[tool(tool_box)]
impl WalletService {
#[tool(
name = "wallet.address",
description = "Return the wallet's primary base address (CIP-1852, account 0, index 0) as a bech32 string"
)]
async fn wallet_address(&self) -> String {
self.inner.address.clone()
}
#[tool(
name = "wallet.stake.address",
description = "Return the wallet's reward (stake) address as bech32 — `stake1...` on mainnet, `stake_test1...` on testnet. This is what gets pointed at a stake pool when delegating."
)]
async fn wallet_stake_address(&self) -> Result<String, String> {
self.inner
.stake_key
.stake_address(self.inner.network)
.map_err(|e| e.to_string())
}
#[tool(
name = "wallet.network",
description = "Return the configured Cardano network: mainnet, preview, or preprod"
)]
async fn wallet_network(&self) -> String {
match self.inner.network {
Network::Mainnet => "mainnet".into(),
Network::Preview => "preview".into(),
Network::Preprod => "preprod".into(),
}
}
#[tool(
name = "wallet.balance",
description = "Query ADA + native-asset balance at the wallet address. Returns JSON {lovelace, assets}."
)]
async fn wallet_balance(&self) -> Result<String, String> {
let bal = self
.inner
.chain
.get_balance(&self.inner.address)
.await
.map_err(|e| e.to_string())?;
serde_json::to_string(&bal).map_err(|e| e.to_string())
}
#[tool(
name = "wallet.utxos",
description = "List UTXOs at the wallet address as a JSON array of {tx_hash, output_index, lovelace, assets}."
)]
async fn wallet_utxos(&self) -> Result<String, String> {
let utxos = self
.inner
.chain
.get_utxos(&self.inner.address)
.await
.map_err(|e| e.to_string())?;
serde_json::to_string(&utxos).map_err(|e| e.to_string())
}
#[tool(
name = "wallet.send",
description = "Build, sign, and submit a payment (ADA + optional native assets) from this wallet. Args: to_address (bech32), lovelace (u64), assets (optional array of {policy_id_hex, asset_name_hex, quantity}), force (bool, optional). Refuses sends > max_send_lovelace unless force=true. Returns the tx hash on success."
)]
async fn wallet_send(
&self,
#[tool(aggr)] SendArgs {
to_address,
lovelace,
assets,
force,
}: SendArgs,
) -> Result<String, String> {
if lovelace == 0 {
return Err("lovelace must be > 0".into());
}
if lovelace > self.inner.max_send_lovelace && !force {
return Err(format!(
"lovelace {lovelace} exceeds max_send_lovelace {}; pass force=true to override",
self.inner.max_send_lovelace
));
}
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 the wallet 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 asset_specs: Vec<AssetSpec> = assets.into_iter().map(Into::into).collect();
let cbor = build_signed_payment_with_assets(
&self.inner.payment_key,
self.inner.network,
&inputs,
&self.inner.address,
&to_address,
lovelace,
&asset_specs,
&ProtocolParams::default(),
)
.map_err(|e| format!("build/sign: {e}"))?;
let tx_hash = self
.inner
.chain
.submit_tx(&cbor)
.await
.map_err(|e| format!("submit: {e}"))?;
Ok(tx_hash)
}
#[tool(
name = "wallet.tx_status",
description = "Poll a submitted transaction's confirmation status. Args: tx_hash (hex). Returns JSON {status: confirmed|not_found, block_height?, epoch?}."
)]
async fn wallet_tx_status(
&self,
#[tool(aggr)] TxStatusArgs { tx_hash }: TxStatusArgs,
) -> Result<String, String> {
let status = self
.inner
.chain
.tx_status(&tx_hash)
.await
.map_err(|e| e.to_string())?;
serde_json::to_string(&status).map_err(|e| e.to_string())
}
#[tool(
name = "wallet.send.unsigned",
description = "Build a payment without signing or submitting. Returns JSON {cbor_hex, summary}: the unsigned tx CBOR for a cold-signer + a human-readable summary (predicted tx_hash, send/fee/change amounts). For high-value flows where the daemon must not auto-sign. After review + offline signing, submit the signed bytes via wallet.submit_signed_tx."
)]
async fn wallet_send_unsigned(
&self,
#[tool(aggr)] UnsignedSendArgs {
to_address,
lovelace,
assets,
}: UnsignedSendArgs,
) -> Result<String, String> {
if lovelace == 0 {
return Err("lovelace must be > 0".into());
}
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 the wallet 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 asset_specs: Vec<AssetSpec> = assets.into_iter().map(Into::into).collect();
let unsigned = build_unsigned_payment_with_assets(
self.inner.network,
&inputs,
&self.inner.address,
&to_address,
lovelace,
&asset_specs,
&ProtocolParams::default(),
)
.map_err(|e| format!("build: {e}"))?;
serde_json::to_string(&unsigned).map_err(|e| e.to_string())
}
#[tool(
name = "wallet.submit_signed_tx",
description = "Submit a pre-signed transaction. Args: signed_cbor_hex (hex-encoded signed tx CBOR from a cold-signer). Returns the on-chain tx hash on success. Use after wallet.send.unsigned + offline signing."
)]
async fn wallet_submit_signed_tx(
&self,
#[tool(aggr)] SubmitSignedArgs { signed_cbor_hex }: SubmitSignedArgs,
) -> Result<String, String> {
let bytes = hex_decode(&signed_cbor_hex).map_err(|e| format!("decode: {e}"))?;
self.inner
.chain
.submit_tx(&bytes)
.await
.map_err(|e| format!("submit: {e}"))
}
#[tool(
name = "wallet.policy.create",
description = "Generate a single-sig native policy bound to this wallet's payment key. Args: invalid_after_slot (optional u64 — omit for open-ended, supply for time-locked supply). Returns JSON {policy_id_hex, script_cbor_hex, type}."
)]
async fn wallet_policy_create(
&self,
#[tool(aggr)] PolicyCreateArgs { invalid_after_slot }: PolicyCreateArgs,
) -> Result<String, String> {
let policy = match invalid_after_slot {
Some(slot) => PolicySpec::single_sig_timelock(&self.inner.payment_key, slot),
None => PolicySpec::single_sig(&self.inner.payment_key),
};
let policy_id = policy.policy_id().map_err(|e| e.to_string())?;
let cbor = policy.to_cbor().map_err(|e| e.to_string())?;
let mut policy_id_hex = String::with_capacity(56);
for b in policy_id.as_ref() {
policy_id_hex.push_str(&format!("{:02x}", b));
}
let mut cbor_hex = String::with_capacity(cbor.len() * 2);
for b in &cbor {
cbor_hex.push_str(&format!("{:02x}", b));
}
let kind = match invalid_after_slot {
Some(_) => "single_sig_timelock",
None => "single_sig",
};
Ok(format!(
"{{\"policy_id_hex\":\"{policy_id_hex}\",\"script_cbor_hex\":\"{cbor_hex}\",\"type\":\"{kind}\"}}"
))
}
#[tool(
name = "wallet.mint",
description = "Mint or burn a native asset under a wallet-generated single-sig policy, optionally with CIP-25 v2 metadata. Args: dest_address, dest_lovelace (≥ 1.5 ADA for asset-bearing UTXO), asset_name_hex, quantity (positive=mint, negative=burn), invalid_after_slot (optional), metadata (optional CIP-25 JSON object: {name, image, description, mediaType, files, ...}). Returns the tx hash on success."
)]
async fn wallet_mint(
&self,
#[tool(aggr)] MintArgs {
dest_address,
dest_lovelace,
asset_name_hex,
quantity,
invalid_after_slot,
metadata,
}: MintArgs,
) -> Result<String, String> {
if quantity == 0 {
return Err("quantity must be nonzero (positive=mint, negative=burn)".into());
}
if dest_lovelace < 1_000_000 {
return Err(format!(
"dest_lovelace {dest_lovelace} below 1 ADA min — token-bearing UTXO will be rejected"
));
}
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 the wallet 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 policy = match invalid_after_slot {
Some(slot) => PolicySpec::single_sig_timelock(&self.inner.payment_key, slot),
None => PolicySpec::single_sig(&self.inner.payment_key),
};
let cbor = build_signed_mint_with_metadata(
&self.inner.payment_key,
self.inner.network,
&inputs,
&self.inner.address,
&dest_address,
dest_lovelace,
&policy,
&asset_name_hex,
quantity,
metadata.as_ref(),
&ProtocolParams::default(),
)
.map_err(|e| format!("build/sign mint: {e}"))?;
let tx_hash = self
.inner
.chain
.submit_tx(&cbor)
.await
.map_err(|e| format!("submit: {e}"))?;
Ok(tx_hash)
}
#[tool(
name = "wallet.mint.cip68_nft",
description = "Mint a CIP-68 NFT pair (label 100 ref + label 222 user) under a wallet-generated single-sig policy. Args: user_address, name_body_hex (raw asset-name body, ≤28 bytes), metadata (JSON object: name, image, description, mediaType, files, ...), user_lovelace (defaults 1.5 ADA), ref_address (defaults wallet — mutable NFT), ref_lovelace (defaults 1.5 ADA), invalid_after_slot? Returns the tx hash."
)]
async fn wallet_mint_cip68_nft(
&self,
#[tool(aggr)] Cip68NftArgs {
user_address,
user_lovelace,
name_body_hex,
metadata,
ref_address,
ref_lovelace,
invalid_after_slot,
}: Cip68NftArgs,
) -> Result<String, String> {
if user_lovelace < 1_000_000 || ref_lovelace < 1_000_000 {
return Err("user_lovelace and ref_lovelace must each be ≥ 1 ADA".into());
}
let name_body = hex_decode(&name_body_hex).map_err(|e| format!("name_body_hex: {e}"))?;
if name_body.len() > 28 {
return Err(format!(
"name_body too long: {} bytes (max 28; CIP-68 prefix needs 4)",
name_body.len()
));
}
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 the wallet 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 policy = match invalid_after_slot {
Some(slot) => PolicySpec::single_sig_timelock(&self.inner.payment_key, slot),
None => PolicySpec::single_sig(&self.inner.payment_key),
};
let ref_addr = ref_address.unwrap_or_else(|| self.inner.address.clone());
let cbor = build_signed_cip68_nft_mint(
&self.inner.payment_key,
self.inner.network,
&inputs,
&self.inner.address,
&user_address,
user_lovelace,
&ref_addr,
ref_lovelace,
&name_body,
&metadata,
&policy,
&ProtocolParams::default(),
)
.map_err(|e| format!("build/sign cip68 mint: {e}"))?;
let tx_hash = self
.inner
.chain
.submit_tx(&cbor)
.await
.map_err(|e| format!("submit: {e}"))?;
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."
)]
async fn wallet_stake_delegate(
&self,
#[tool(aggr)] StakeDelegateArgs {
pool_id,
register_first,
}: StakeDelegateArgs,
) -> Result<String, String> {
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 the wallet 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 cbor = build_signed_stake_delegation(
&self.inner.payment_key,
&self.inner.stake_key,
self.inner.network,
&inputs,
&self.inner.address,
&pool_id,
register_first,
&ProtocolParams::default(),
)
.map_err(|e| format!("build/sign delegation: {e}"))?;
let tx_hash = self
.inner
.chain
.submit_tx(&cbor)
.await
.map_err(|e| format!("submit: {e}"))?;
Ok(tx_hash)
}
#[tool(
name = "wallet.mint.unsigned",
description = "Build a mint TX without signing — for cold-sign or multi-sig flows. Args: dest_address, dest_lovelace, asset_name_hex, quantity, policy (optional, defaults to wallet single-sig; pass {type:'nofk',n:2,signer_pkhs_hex:[..]} for multi-sig treasury), metadata (optional CIP-25), disclosed_signer_pkh_hex (optional, defaults to wallet's pkh). Returns JSON {cbor_hex, summary}. Pass through wallet.sign_partial chain, then wallet.submit_signed_tx."
)]
async fn wallet_mint_unsigned(
&self,
#[tool(aggr)] MintUnsignedArgs {
dest_address,
dest_lovelace,
asset_name_hex,
quantity,
policy,
metadata,
disclosed_signer_pkh_hex,
}: MintUnsignedArgs,
) -> Result<String, String> {
if quantity == 0 {
return Err("quantity must be nonzero".into());
}
if dest_lovelace < 1_000_000 {
return Err(format!(
"dest_lovelace {dest_lovelace} below 1 ADA min for asset-bearing UTXO"
));
}
// Resolve PolicySpec — caller-supplied JSON or wallet default.
let policy_spec: PolicySpec = match policy {
Some(v) => serde_json::from_value(v)
.map_err(|e| format!("policy: {e}"))?,
None => PolicySpec::single_sig(&self.inner.payment_key),
};
// Resolve disclosed signer pkh.
let pkh_hex = match disclosed_signer_pkh_hex {
Some(h) => h,
None => {
let h = self.inner.payment_key.public_key_hash();
let mut s = String::with_capacity(56);
for b in h.as_ref() {
s.push_str(&format!("{:02x}", b));
}
s
}
};
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 {}",
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 unsigned = build_unsigned_mint(
self.inner.network,
&pkh_hex,
&inputs,
&self.inner.address,
&dest_address,
dest_lovelace,
&policy_spec,
&asset_name_hex,
quantity,
metadata.as_ref(),
&ProtocolParams::default(),
)
.map_err(|e| format!("build unsigned mint: {e}"))?;
serde_json::to_string(&unsigned).map_err(|e| e.to_string())
}
#[tool(
name = "wallet.sign_partial",
description = "Append this wallet's VKeyWitness to a Conway-era tx (unsigned or partially-signed). Args: cbor_hex (hex-encoded tx CBOR). Returns the updated CBOR hex with our signature added. For multi-sig flows (e.g. ADAMaps treasury 2-of-2): each party calls this in turn, then any party submits via wallet.submit_signed_tx."
)]
async fn wallet_sign_partial(
&self,
#[tool(aggr)] SignPartialArgs { cbor_hex }: SignPartialArgs,
) -> Result<String, String> {
let bytes = hex_decode(&cbor_hex).map_err(|e| format!("decode: {e}"))?;
let updated = add_witness(&self.inner.payment_key, &bytes)
.map_err(|e| format!("sign: {e}"))?;
let mut hex = String::with_capacity(updated.len() * 2);
for b in &updated {
hex.push_str(&format!("{:02x}", b));
}
Ok(hex)
}
}
#[tool(tool_box)]
impl ServerHandler for WalletService {
fn get_info(&self) -> ServerInfo {
ServerInfo {
instructions: Some(
"aldabra — Cardano lite wallet over MCP. Phase 1 (read): wallet.address, wallet.network, wallet.balance, wallet.utxos. Phase 2 (send): wallet.send (with native-asset bundle), wallet.send.unsigned + wallet.submit_signed_tx + wallet.sign_partial (cold-sign + multi-sig), wallet.tx_status. Phase 3 (mint): wallet.policy.create, wallet.mint (with CIP-25 metadata), wallet.mint.cip68_nft (ref + user NFT pair w/ inline datum). Plutus + stake delegation land in Phase 4.".into(),
),
..Default::default()
}
}
}