From 2f3d975c0f1255419a9163b13edc8278e3c348e0 Mon Sep 17 00:00:00 2001 From: Cobb Date: Mon, 4 May 2026 11:44:16 -0700 Subject: [PATCH] phase 3.1, 3.4, 3.5: native policy + mint path (no metadata yet) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit new aldabra-core::mint module: - PolicySpec enum: SingleSig, SingleSigTimelock, NofK - SingleSig{pkh}: ScriptPubkey native script - SingleSigTimelock{pkh, slot}: ScriptAll[ScriptPubkey, InvalidHereafter(slot)] - NofK{n, [pkhs]}: ScriptNOfK - PolicySpec::single_sig(payment) + single_sig_timelock(payment, slot) convenience constructors that derive the pkh from a PaymentKey. - policy_id() = pallas_traverse::ComputeHash<28>::compute_hash, which is blake2b-224 of (0x00 || cbor) — the canonical native-script hash. - to_cbor() for callers that want the script bytes raw. build_signed_mint / build_unsigned_mint: - two-pass fee like the send path, plus a few extras specific to mint: staging.mint_asset(policy, name, qty), .script(Native, cbor), .disclosed_signer(payment_pkh) — the disclosed_signer surfaces the required signature in the tx body so the chain knows which witness to verify against the script. - positive qty mints (asset goes into dest output), negative qty burns (asset comes out of input holdings, change preserves leftover). - token-bearing change must hold ≥ min_utxo lovelace — same guard as the send path. mcp tools: - wallet.policy.create — args: invalid_after_slot? — returns {policy_id_hex, script_cbor_hex, type}. - wallet.mint — args: dest_address, dest_lovelace (≥ 1 ADA), asset_name_hex, quantity (i64), invalid_after_slot? — auto-generates a single-sig policy bound to the wallet's payment key, builds, signs, submits. 8 → 10 mcp tools. 48 → 56 unit tests. 3.2 (CIP-25 metadata) is BLOCKED on pallas-txbuilder 0.32/0.35 — both hardcode `auxiliary_data: None` in the conway builder. options for next session: (a) post-build CBOR injection, (b) assemble tx via pallas-primitives directly, (c) wait for upstream. flagged in the spec doc. 3.3 (CIP-68) depends on 3.2. 3.6 (MAP 2-of-2) needs the multi-key signing flow on the build side; PolicySpec::NofK variant is ready but build_signed_mint only sign with one key today. --- Cargo.lock | 1 + Cargo.toml | 1 + crates/aldabra-core/Cargo.toml | 1 + crates/aldabra-core/src/lib.rs | 2 + crates/aldabra-core/src/mint.rs | 725 ++++++++++++++++++++++++++++++++ crates/aldabra-mcp/src/tools.rs | 140 +++++- 6 files changed, 867 insertions(+), 3 deletions(-) create mode 100644 crates/aldabra-core/src/mint.rs diff --git a/Cargo.lock b/Cargo.lock index 0b79ae1..4d22bfe 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -87,6 +87,7 @@ dependencies = [ "pallas-codec", "pallas-crypto", "pallas-primitives", + "pallas-traverse", "pallas-txbuilder", "pallas-wallet", "serde", diff --git a/Cargo.toml b/Cargo.toml index ffda68e..3deccd1 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -40,6 +40,7 @@ pallas-crypto = "0.32" pallas-addresses = "0.32" pallas-txbuilder = "0.32" pallas-wallet = "0.32" +pallas-traverse = "0.32" pallas-network = "0.32" # Mnemonic + key derivation. diff --git a/crates/aldabra-core/Cargo.toml b/crates/aldabra-core/Cargo.toml index 1e6bae5..00538c8 100644 --- a/crates/aldabra-core/Cargo.toml +++ b/crates/aldabra-core/Cargo.toml @@ -31,6 +31,7 @@ pallas-crypto = { workspace = true } pallas-addresses = { workspace = true } pallas-txbuilder = { workspace = true } pallas-wallet = { workspace = true } +pallas-traverse = { workspace = true } bip39 = { workspace = true } ed25519-bip32 = { workspace = true } cryptoxide = { workspace = true } diff --git a/crates/aldabra-core/src/lib.rs b/crates/aldabra-core/src/lib.rs index f8b51f9..dd534bd 100644 --- a/crates/aldabra-core/src/lib.rs +++ b/crates/aldabra-core/src/lib.rs @@ -36,8 +36,10 @@ use thiserror::Error; use zeroize::ZeroizeOnDrop; pub mod derive; +pub mod mint; pub mod tx; pub use derive::{derive_payment_key, derive_stake_key, PaymentKey, StakeKey}; +pub use mint::{build_signed_mint, build_unsigned_mint, PolicySpec}; pub use tx::{ build_signed_payment, build_signed_payment_with_assets, build_unsigned_payment, build_unsigned_payment_with_assets, hex_decode, AssetSpec, InputUtxo, PaymentSummary, diff --git a/crates/aldabra-core/src/mint.rs b/crates/aldabra-core/src/mint.rs new file mode 100644 index 0000000..5056f3e --- /dev/null +++ b/crates/aldabra-core/src/mint.rs @@ -0,0 +1,725 @@ +//! Native-asset minting (Phase 3). +//! +//! ## What +//! +//! - Construct a Cardano native script (CIP-1854): single-sig, +//! single-sig-with-timelock, n-of-k multisig. +//! - Compute the script's policy ID (`blake2b-224(0x00 || cbor)`). +//! - Build a signed Conway-era mint transaction that issues N of an +//! asset under that policy and sends them to a destination +//! address. +//! +//! ## Phase 3 scope today +//! +//! - **Single-sig** + **single-sig with `invalid_after`** policy +//! shapes — the bread-and-butter wallet-controlled mint. +//! - **n-of-k multisig** policy shape (untested at the build-tx +//! layer; the script construction is solid but the mint flow +//! below assumes the wallet's payment key alone is sufficient +//! to satisfy the script). +//! - **No metadata.** pallas-txbuilder 0.32/0.35 doesn't surface +//! `auxiliary_data` through its public API yet (it's a hardcoded +//! `None` in the conway builder), so CIP-25 / CIP-68 metadata +//! needs either a post-build CBOR injection pass or a direct +//! pallas-primitives tx assembly. Both are follow-up work. +//! +//! ## Burn vs mint +//! +//! `quantity > 0` mints, `quantity < 0` burns. Burns require the +//! caller to actually hold the assets at one of the input UTXOs. + +use pallas_codec::minicbor; +use pallas_crypto::hash::Hash; +use pallas_primitives::alonzo::NativeScript; +use pallas_traverse::ComputeHash; +use pallas_txbuilder::{BuildConway, BuiltTransaction, Input, Output, ScriptKind, StagingTransaction}; +use pallas_wallet::PrivateKey; +use serde::{Deserialize, Serialize}; + +use crate::tx::{InputUtxo, PaymentSummary, ProtocolParams, UnsignedPayment}; +use crate::{Network, PaymentKey, WalletError}; + +/// High-level policy descriptor — abstracts the +/// `pallas_primitives::alonzo::NativeScript` enum so callers don't +/// need to touch pallas types directly. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(tag = "type", rename_all = "snake_case")] +pub enum PolicySpec { + /// Pure single-signature policy — anyone with the payment key + /// can mint or burn. + SingleSig { + /// 28-byte hex of the required signer's blake2b-224 + /// payment-key hash. + signer_pkh_hex: String, + }, + /// Single-sig that becomes invalid after a specific slot. + /// Stops minting / locks supply. Cardano's idiomatic "minted + /// supply is permanent" pattern. + SingleSigTimelock { + signer_pkh_hex: String, + invalid_after_slot: u64, + }, + /// n-of-k multisig — k signers, any n satisfy the script. + /// Smaller `n` = looser script. ADAMaps treasury minting is + /// the canonical use case (2-of-2). + NofK { + /// Required signature count. + n: u32, + /// Hex-encoded payment-key hashes of the k signers. + signer_pkhs_hex: Vec, + }, +} + +fn parse_pkh(hex_str: &str) -> Result, WalletError> { + if hex_str.len() != 56 { + return Err(WalletError::Derivation(format!( + "expected 56-char hex pubkey hash, got {}", + hex_str.len() + ))); + } + let mut out = [0u8; 28]; + for i in 0..28 { + out[i] = u8::from_str_radix(&hex_str[i * 2..i * 2 + 2], 16).map_err(|_| { + WalletError::Derivation(format!("invalid hex in pubkey hash: {hex_str}")) + })?; + } + Ok(Hash::<28>::new(out)) +} + +impl PolicySpec { + /// Convenience constructor — derive a single-sig policy from a + /// `PaymentKey` directly. + pub fn single_sig(payment: &PaymentKey) -> Self { + let h = payment.public_key_hash(); + Self::SingleSig { + signer_pkh_hex: hash_to_hex(&h), + } + } + + /// Convenience constructor — derive a single-sig-with-timelock + /// policy from a `PaymentKey` directly. + pub fn single_sig_timelock(payment: &PaymentKey, invalid_after_slot: u64) -> Self { + let h = payment.public_key_hash(); + Self::SingleSigTimelock { + signer_pkh_hex: hash_to_hex(&h), + invalid_after_slot, + } + } + + /// Lower the spec into a `pallas_primitives::alonzo::NativeScript`. + pub fn to_native_script(&self) -> Result { + match self { + Self::SingleSig { signer_pkh_hex } => { + let h = parse_pkh(signer_pkh_hex)?; + Ok(NativeScript::ScriptPubkey(h)) + } + Self::SingleSigTimelock { + signer_pkh_hex, + invalid_after_slot, + } => { + let h = parse_pkh(signer_pkh_hex)?; + // ScriptAll = require BOTH the signature AND the + // not-yet-expired condition. + Ok(NativeScript::ScriptAll(vec![ + NativeScript::ScriptPubkey(h), + NativeScript::InvalidHereafter(*invalid_after_slot), + ])) + } + Self::NofK { n, signer_pkhs_hex } => { + let mut subs: Vec = Vec::with_capacity(signer_pkhs_hex.len()); + for s in signer_pkhs_hex { + let h = parse_pkh(s)?; + subs.push(NativeScript::ScriptPubkey(h)); + } + Ok(NativeScript::ScriptNOfK(*n, subs)) + } + } + } + + /// CBOR encoding of the underlying native script. + pub fn to_cbor(&self) -> Result, WalletError> { + let script = self.to_native_script()?; + minicbor::to_vec(&script) + .map_err(|e| WalletError::Derivation(format!("encode native script: {e}"))) + } + + /// blake2b-224 of `0x00 || cbor` — the asset's policy ID. + pub fn policy_id(&self) -> Result, WalletError> { + let script = self.to_native_script()?; + Ok(script.compute_hash()) + } +} + +fn hash_to_hex(h: &Hash<28>) -> String { + let bytes: &[u8] = h.as_ref(); + let mut s = String::with_capacity(56); + for b in bytes { + s.push_str(&format!("{:02x}", b)); + } + s +} + +fn parse_address(bech32: &str) -> Result { + pallas_addresses::Address::from_bech32(bech32) + .map_err(|e| WalletError::Address(e.to_string())) +} + +fn parse_tx_hash(hex_str: &str) -> Result, 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 parse_asset_name(hex_str: &str) -> Result, WalletError> { + if hex_str.len() % 2 != 0 { + return Err(WalletError::Derivation( + "asset_name hex must have even length".into(), + )); + } + if hex_str.len() > 64 { + return Err(WalletError::Derivation(format!( + "asset_name too long: {} hex chars (>64)", + hex_str.len() + ))); + } + let mut out = Vec::with_capacity(hex_str.len() / 2); + for i in (0..hex_str.len()).step_by(2) { + out.push(u8::from_str_radix(&hex_str[i..i + 2], 16).map_err(|_| { + WalletError::Derivation(format!("invalid hex in asset_name: {hex_str}")) + })?); + } + Ok(out) +} + +fn payment_key_to_private(payment: &PaymentKey) -> Result { + use pallas_crypto::key::ed25519::SecretKeyExtended; + let extended: [u8; 64] = payment.xprv().extended_secret_key(); + let secret = SecretKeyExtended::from_bytes(extended) + .map_err(|e| WalletError::Derivation(format!("invalid extended secret: {e}")))?; + Ok(PrivateKey::Extended(secret)) +} + +fn network_id_for(network: Network) -> u8 { + match network { + Network::Mainnet => 1, + Network::Preview | Network::Preprod => 0, + } +} + +const WITNESS_OVERHEAD_BYTES: u64 = 128; + +/// Internal — runs the two-pass fee refinement and returns the +/// final BuiltTransaction + summary. Mint version of +/// `tx::prepare_payment`. Adds: mint-asset entry, native-script +/// witness, disclosed signer. +fn prepare_mint( + network: Network, + available_utxos: &[InputUtxo], + change_address_bech32: &str, + dest_address_bech32: &str, + dest_lovelace: u64, + policy: &PolicySpec, + asset_name_hex: &str, + mint_quantity: i64, + payment_pkh: Hash<28>, + params: &ProtocolParams, +) -> Result<(BuiltTransaction, PaymentSummary), WalletError> { + let dest_addr = parse_address(dest_address_bech32)?; + let change_addr = parse_address(change_address_bech32)?; + let network_id = network_id_for(network); + + let policy_id = policy.policy_id()?; + let asset_name_bytes = parse_asset_name(asset_name_hex)?; + let script_cbor = policy.to_cbor()?; + + // The destination output holds dest_lovelace + the freshly-minted + // asset (positive quantity). Burns subtract assets from change + // instead, so for a burn there's no asset on the dest output. + let mint_is_positive = mint_quantity > 0; + + // Pre-flight UTXO selection — we need enough lovelace for + // dest_lovelace + fee + min_change. No required input assets + // here (mint creates the supply). + let fee_pass1: u64 = 800_000; + let need = dest_lovelace + .checked_add(fee_pass1) + .and_then(|x| x.checked_add(params.min_utxo_lovelace)) + .ok_or_else(|| WalletError::Derivation("amount overflow".into()))?; + + let mut sorted: Vec = available_utxos.to_vec(); + sorted.sort_by(|a, b| b.lovelace.cmp(&a.lovelace)); + let mut acc: u64 = 0; + let mut selected: Vec = Vec::new(); + for u in sorted { + acc = acc.saturating_add(u.lovelace); + selected.push(u); + if acc >= need { + break; + } + } + if acc < need { + return Err(WalletError::Derivation(format!( + "insufficient funds: need {need} lovelace, have {acc}" + ))); + } + let total_in: u64 = selected.iter().map(|u| u.lovelace).sum(); + + // Aggregate input assets (other than the one being minted) so + // we can preserve them on the change output. + let mut input_assets: std::collections::BTreeMap = Default::default(); + for u in &selected { + for (k, v) in &u.assets { + let entry = input_assets.entry(k.clone()).or_insert(0); + *entry = entry.saturating_add(*v); + } + } + + let policy_id_hex = hash_to_hex(&policy_id); + let mint_asset_key = format!("{policy_id_hex}{asset_name_hex}"); + + // Adjust input_assets for burns (negative mint). + if mint_quantity < 0 { + let burn_qty = (-mint_quantity) as u64; + let entry = input_assets.entry(mint_asset_key.clone()).or_insert(0); + if *entry < burn_qty { + return Err(WalletError::Derivation(format!( + "insufficient {mint_asset_key} to burn: have {entry}, need {burn_qty}" + ))); + } + *entry -= burn_qty; + } + + // Build pass 1 with placeholder fee. + let dest_assets: std::collections::BTreeMap = if mint_is_positive { + let mut m = std::collections::BTreeMap::new(); + m.insert(mint_asset_key.clone(), mint_quantity as u64); + m + } else { + Default::default() + }; + + let change_pass1 = total_in + .checked_sub(dest_lovelace) + .and_then(|x| x.checked_sub(fee_pass1)) + .ok_or_else(|| WalletError::Derivation("pass1: insufficient lovelace".into()))?; + + let staging1 = build_mint_staging( + &selected, + &dest_addr, + dest_lovelace, + &dest_assets, + &change_addr, + change_pass1, + &input_assets, + fee_pass1, + network_id, + policy_id, + &asset_name_bytes, + mint_quantity, + &script_cbor, + payment_pkh, + )?; + let unsigned = staging1 + .build_conway_raw() + .map_err(|e| WalletError::Derivation(format!("conway build: {e}")))? + .tx_bytes + .0; + let est_signed = (unsigned.len() as u64) + WITNESS_OVERHEAD_BYTES; + let real_fee = params.min_fee_for_size(est_signed); + + // Token-bearing change output must hold ≥ min_utxo lovelace. + let token_change = !input_assets.is_empty(); + let (final_fee, final_change) = match total_in.checked_sub(dest_lovelace + real_fee) { + Some(c) if c >= params.min_utxo_lovelace || token_change => { + if token_change && c < params.min_utxo_lovelace { + return Err(WalletError::Derivation(format!( + "insufficient ADA for token-bearing change: change={c}, min={}", + params.min_utxo_lovelace + ))); + } + (real_fee, c) + } + Some(c) => (real_fee + c, 0), + None => { + return Err(WalletError::Derivation(format!( + "insufficient funds for fee: total_in={total_in} dest={dest_lovelace} fee={real_fee}" + ))) + } + }; + + let staging2 = build_mint_staging( + &selected, + &dest_addr, + dest_lovelace, + &dest_assets, + &change_addr, + final_change, + &input_assets, + final_fee, + network_id, + policy_id, + &asset_name_bytes, + mint_quantity, + &script_cbor, + payment_pkh, + )?; + let built = staging2 + .build_conway_raw() + .map_err(|e| WalletError::Derivation(format!("conway build: {e}")))?; + + let summary = PaymentSummary { + tx_hash: hash_to_hex_32(&built.tx_hash.0), + network, + from_address: change_address_bech32.to_string(), + to_address: dest_address_bech32.to_string(), + send_lovelace: dest_lovelace, + fee_lovelace: final_fee, + change_lovelace: final_change, + num_inputs: selected.len(), + send_assets: vec![crate::tx::AssetSpec { + policy_id_hex: policy_id_hex.clone(), + asset_name_hex: asset_name_hex.to_string(), + quantity: mint_quantity.unsigned_abs(), + }], + change_assets: input_assets + .iter() + .map(|(k, v)| crate::tx::AssetSpec { + policy_id_hex: k[..56].to_string(), + asset_name_hex: k[56..].to_string(), + quantity: *v, + }) + .collect(), + }; + + Ok((built, summary)) +} + +fn hash_to_hex_32(h: &[u8; 32]) -> String { + let mut s = String::with_capacity(64); + for b in h { + s.push_str(&format!("{:02x}", b)); + } + s +} + +#[allow(clippy::too_many_arguments)] +fn build_mint_staging( + inputs: &[InputUtxo], + dest_addr: &pallas_addresses::Address, + dest_lovelace: u64, + dest_assets: &std::collections::BTreeMap, + change_addr: &pallas_addresses::Address, + change_lovelace: u64, + change_assets: &std::collections::BTreeMap, + fee: u64, + network_id: u8, + policy_id: Hash<28>, + asset_name_bytes: &[u8], + mint_quantity: i64, + script_cbor: &[u8], + payment_pkh: Hash<28>, +) -> Result { + let mut staging = StagingTransaction::new(); + for u in inputs { + let h = parse_tx_hash(&u.tx_hash_hex)?; + staging = staging.input(Input::new(h, u.output_index as u64)); + } + + // Destination output (with mint asset if positive). + let mut dest_out = Output::new(dest_addr.clone(), dest_lovelace); + for (k, q) in dest_assets { + if *q == 0 { + continue; + } + // dest_assets keys are policy||name hex; decode parts. + if k.len() < 56 { + return Err(WalletError::Derivation( + "dest asset key shorter than 56 chars".into(), + )); + } + let pol_hex = &k[..56]; + let name_hex = &k[56..]; + let p = parse_pkh(pol_hex)?; // 28-byte hash, same parser works + let n = parse_asset_name(name_hex)?; + dest_out = dest_out + .add_asset(p, n, *q) + .map_err(|e| WalletError::Derivation(format!("dest add_asset: {e}")))?; + } + staging = staging.output(dest_out); + + // Change output (with leftover input assets, if any). + let nonzero_change: std::collections::BTreeMap = change_assets + .iter() + .filter(|(_, q)| **q > 0) + .map(|(k, v)| (k.clone(), *v)) + .collect(); + if change_lovelace > 0 || !nonzero_change.is_empty() { + let mut change_out = Output::new(change_addr.clone(), change_lovelace); + for (k, q) in &nonzero_change { + if k.len() < 56 { + return Err(WalletError::Derivation( + "change asset key shorter than 56 chars".into(), + )); + } + let pol_hex = &k[..56]; + let name_hex = &k[56..]; + let p = parse_pkh(pol_hex)?; + let n = parse_asset_name(name_hex)?; + change_out = change_out + .add_asset(p, n, *q) + .map_err(|e| WalletError::Derivation(format!("change add_asset: {e}")))?; + } + staging = staging.output(change_out); + } + + staging = staging + .mint_asset(policy_id, asset_name_bytes.to_vec(), mint_quantity) + .map_err(|e| WalletError::Derivation(format!("mint_asset: {e}")))? + .script(ScriptKind::Native, script_cbor.to_vec()) + .disclosed_signer(payment_pkh) + .fee(fee) + .network_id(network_id); + + Ok(staging) +} + +/// Build + sign a mint TX. +/// +/// `mint_quantity > 0` mints, `mint_quantity < 0` burns. Burning +/// requires the wallet's UTXOs hold ≥ |quantity| of the asset. +/// +/// The destination output gets `dest_lovelace` + the freshly-minted +/// supply (or just `dest_lovelace` for a burn). Change output picks +/// up any leftover ADA + non-burn assets from the wallet. +#[allow(clippy::too_many_arguments)] +pub fn build_signed_mint( + payment_key: &PaymentKey, + network: Network, + available_utxos: &[InputUtxo], + change_address_bech32: &str, + dest_address_bech32: &str, + dest_lovelace: u64, + policy: &PolicySpec, + asset_name_hex: &str, + mint_quantity: i64, + params: &ProtocolParams, +) -> Result, WalletError> { + let private = payment_key_to_private(payment_key)?; + let payment_pkh = payment_key.public_key_hash(); + let (built, _summary) = prepare_mint( + network, + available_utxos, + change_address_bech32, + dest_address_bech32, + dest_lovelace, + policy, + asset_name_hex, + mint_quantity, + payment_pkh, + params, + )?; + let signed = built + .sign(private) + .map_err(|e| WalletError::Derivation(format!("sign: {e}")))?; + Ok(signed.tx_bytes.0) +} + +/// Build a mint TX without signing — for cold-sign flows. Returns +/// the unsigned CBOR + summary. +#[allow(clippy::too_many_arguments)] +pub fn build_unsigned_mint( + network: Network, + payment_pkh_hex: &str, + available_utxos: &[InputUtxo], + change_address_bech32: &str, + dest_address_bech32: &str, + dest_lovelace: u64, + policy: &PolicySpec, + asset_name_hex: &str, + mint_quantity: i64, + params: &ProtocolParams, +) -> Result { + let payment_pkh = parse_pkh(payment_pkh_hex)?; + let (built, summary) = prepare_mint( + network, + available_utxos, + change_address_bech32, + dest_address_bech32, + dest_lovelace, + policy, + asset_name_hex, + mint_quantity, + payment_pkh, + params, + )?; + let mut cbor_hex = String::with_capacity(built.tx_bytes.0.len() * 2); + for b in &built.tx_bytes.0 { + cbor_hex.push_str(&format!("{:02x}", b)); + } + Ok(UnsignedPayment { cbor_hex, summary }) +} + +#[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 to_address_preprod() -> String { + let root = Mnemonic::from_phrase(ABANDON_ART) + .unwrap() + .into_root_key() + .unwrap(); + crate::derive_base_address(&root, Network::Preprod, 0, 1).unwrap() + } + + #[test] + fn single_sig_policy_round_trips() { + let payment = payment_from_canonical(); + let policy = PolicySpec::single_sig(&payment); + let script = policy.to_native_script().unwrap(); + match script { + NativeScript::ScriptPubkey(_) => {} + other => panic!("expected ScriptPubkey, got {other:?}"), + } + let id = policy.policy_id().unwrap(); + assert_eq!(id.as_ref().len(), 28); + } + + #[test] + fn timelock_policy_uses_script_all() { + let payment = payment_from_canonical(); + let policy = PolicySpec::single_sig_timelock(&payment, 100_000_000); + let script = policy.to_native_script().unwrap(); + match script { + NativeScript::ScriptAll(subs) => { + assert_eq!(subs.len(), 2); + assert!(matches!(subs[0], NativeScript::ScriptPubkey(_))); + assert!(matches!(subs[1], NativeScript::InvalidHereafter(_))); + } + other => panic!("expected ScriptAll, got {other:?}"), + } + } + + #[test] + fn nofk_policy_serializes() { + let payment = payment_from_canonical(); + let pkh1 = hash_to_hex(&payment.public_key_hash()); + let policy = PolicySpec::NofK { + n: 2, + signer_pkhs_hex: vec![pkh1.clone(), pkh1.clone(), pkh1], + }; + let id = policy.policy_id().unwrap(); + assert_eq!(id.as_ref().len(), 28); + } + + #[test] + fn policy_id_is_deterministic() { + let payment = payment_from_canonical(); + let p1 = PolicySpec::single_sig(&payment); + let p2 = PolicySpec::single_sig(&payment); + assert_eq!(p1.policy_id().unwrap(), p2.policy_id().unwrap()); + } + + #[test] + fn timelock_policy_id_changes_with_slot() { + let payment = payment_from_canonical(); + let p1 = PolicySpec::single_sig_timelock(&payment, 100); + let p2 = PolicySpec::single_sig_timelock(&payment, 200); + assert_ne!(p1.policy_id().unwrap(), p2.policy_id().unwrap()); + } + + #[test] + fn build_signed_mint_produces_cbor() { + let payment = payment_from_canonical(); + let change = change_address(Network::Preprod); + let policy = PolicySpec::single_sig(&payment); + let utxos = vec![InputUtxo { + tx_hash_hex: "deadbeef".repeat(8), + output_index: 0, + lovelace: 100_000_000, + assets: Default::default(), + }]; + let cbor = build_signed_mint( + &payment, + Network::Preprod, + &utxos, + &change, + &to_address_preprod(), + 2_000_000, + &policy, + "414c44414252415f54455354", // "ALDABRA_TEST" hex + 1, + &ProtocolParams::default(), + ) + .expect("mint builds + signs"); + assert!(cbor.len() > 200, "mint cbor too short: {} bytes", cbor.len()); + } + + #[test] + fn build_signed_mint_rejects_burn_without_holdings() { + let payment = payment_from_canonical(); + let change = change_address(Network::Preprod); + let policy = PolicySpec::single_sig(&payment); + let utxos = vec![InputUtxo { + tx_hash_hex: "deadbeef".repeat(8), + output_index: 0, + lovelace: 100_000_000, + assets: Default::default(), // no holdings of the burn asset + }]; + let err = build_signed_mint( + &payment, + Network::Preprod, + &utxos, + &change, + &to_address_preprod(), + 2_000_000, + &policy, + "414c44414252415f54455354", + -1, + &ProtocolParams::default(), + ) + .expect_err("burn without holdings should fail"); + match err { + WalletError::Derivation(m) => assert!(m.contains("insufficient")), + other => panic!("expected Derivation, got {other:?}"), + } + } + + #[test] + fn parse_pkh_validates_length() { + assert!(parse_pkh("ab").is_err()); + assert!(parse_pkh(&"ee".repeat(28)).is_ok()); + } +} diff --git a/crates/aldabra-mcp/src/tools.rs b/crates/aldabra-mcp/src/tools.rs index e4a64ed..3dd56a0 100644 --- a/crates/aldabra-mcp/src/tools.rs +++ b/crates/aldabra-mcp/src/tools.rs @@ -26,8 +26,8 @@ use std::sync::Arc; use aldabra_chain::{ChainBackend, KoiosClient}; use aldabra_core::{ - build_signed_payment_with_assets, build_unsigned_payment_with_assets, hex_decode, AssetSpec, - InputUtxo, Network, PaymentKey, ProtocolParams, + build_signed_mint, build_signed_payment_with_assets, build_unsigned_payment_with_assets, + hex_decode, AssetSpec, InputUtxo, Network, PaymentKey, PolicySpec, ProtocolParams, }; use rmcp::{model::ServerInfo, schemars, tool, ServerHandler}; use serde::Deserialize; @@ -129,6 +129,35 @@ pub struct SubmitSignedArgs { 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, +} + +#[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, +} + #[tool(tool_box)] impl WalletService { #[tool( @@ -331,6 +360,111 @@ impl WalletService { .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 { + 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. Args: dest_address, dest_lovelace (ADA to attach to the mint output, ≥ ~1.5 ADA for an asset-bearing utxo), asset_name_hex, quantity (positive=mint, negative=burn), invalid_after_slot (optional). Returns the tx hash on success. NB: this version does not attach CIP-25 metadata — pallas-txbuilder 0.32 doesn't surface auxiliary_data yet." + )] + async fn wallet_mint( + &self, + #[tool(aggr)] MintArgs { + dest_address, + dest_lovelace, + asset_name_hex, + quantity, + invalid_after_slot, + }: MintArgs, + ) -> Result { + 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 = 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( + &self.inner.payment_key, + self.inner.network, + &inputs, + &self.inner.address, + &dest_address, + dest_lovelace, + &policy, + &asset_name_hex, + quantity, + &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(tool_box)] @@ -338,7 +472,7 @@ 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 (auto-sign), wallet.send.unsigned + wallet.submit_signed_tx (cold-sign flow), wallet.tx_status. Native-asset send + Plutus land in phase 3+.".into(), + "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 support), wallet.send.unsigned + wallet.submit_signed_tx (cold-sign), wallet.tx_status. Phase 3 (mint): wallet.policy.create, wallet.mint. CIP-25 metadata + CIP-68 + Plutus land in follow-up.".into(), ), ..Default::default() }