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
957 lines
34 KiB
Rust
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()
|
|
}
|
|
}
|
|
}
|