From 46b6f6efa36a56875b6a972a5880c9b19a9d9a9a Mon Sep 17 00:00:00 2001 From: Cobb Date: Mon, 4 May 2026 11:35:06 -0700 Subject: [PATCH] phase 2.5-2.6: native asset send + cold-sign flow MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit InputUtxo gains an `assets: BTreeMap` field matching aldabra-chain::Utxo's shape (`policy_id_hex(56) || asset_name_hex` key). new AssetSpec type for the recipient asset list. asset-aware select_utxos: - phase 1: per-asset greedy by holding size, pulls UTXOs containing each requested asset until coverage ≥ target - phase 2: ada-only greedy to top up lovelace need this preserves the prior ada-only behavior when assets list is empty. build_signed_payment_with_assets / build_unsigned_payment_with_assets build outputs with .add_asset() for each requested + each leftover (change-side). guards: token-bearing change must hold ≥ min_utxo ADA — surfaced as a clearer error than letting the chain reject a sub-min output. cold-sign flow (phase 2.6): - new tools wallet.send.unsigned (returns {cbor_hex, summary} json for human review + cold-signer consumption) and wallet.submit_signed_tx (takes hex-encoded signed cbor → submit). - PaymentSummary now carries send_assets + change_assets vecs so the human reviewer can spot accidental token transfers. - summary.tx_hash is the predicted body hash; signed CBOR will hash to the same value (signature is over the body, not the cbor wrapper). helpers: hex_encode/decode, parse_policy_id, parse_asset_name, split_asset_key. mcp side defines its own McpAssetSpec with schemars::JsonSchema derive so the schemars dep doesn't bleed into the security-boundary core crate. 48 unit tests (was 41). new coverage: asset-aware selection (greedy + missing-asset error), policy/asset-name parsers, multi-asset cbor build, change-asset summary correctness. phase 2.7 (live preprod smoke against funded wallet) procedure documented in memory/spec-aldabra-buildout.md; needs cobb's faucet ada. --- crates/aldabra-core/src/lib.rs | 6 +- crates/aldabra-core/src/tx.rs | 773 ++++++++++++++++++++++++++++---- crates/aldabra-mcp/src/tools.rs | 135 +++++- 3 files changed, 823 insertions(+), 91 deletions(-) diff --git a/crates/aldabra-core/src/lib.rs b/crates/aldabra-core/src/lib.rs index 2c8afe6..f8b51f9 100644 --- a/crates/aldabra-core/src/lib.rs +++ b/crates/aldabra-core/src/lib.rs @@ -38,7 +38,11 @@ use zeroize::ZeroizeOnDrop; pub mod derive; pub mod tx; pub use derive::{derive_payment_key, derive_stake_key, PaymentKey, StakeKey}; -pub use tx::{build_signed_payment, InputUtxo, ProtocolParams}; +pub use tx::{ + build_signed_payment, build_signed_payment_with_assets, build_unsigned_payment, + build_unsigned_payment_with_assets, hex_decode, AssetSpec, InputUtxo, PaymentSummary, + ProtocolParams, UnsignedPayment, +}; #[derive(Debug, Error)] pub enum WalletError { diff --git a/crates/aldabra-core/src/tx.rs b/crates/aldabra-core/src/tx.rs index 9df9d2c..8e0cbfb 100644 --- a/crates/aldabra-core/src/tx.rs +++ b/crates/aldabra-core/src/tx.rs @@ -45,8 +45,9 @@ use ed25519_bip32::XPrv; use pallas_addresses::Address as PallasAddress; use pallas_crypto::key::ed25519::SecretKeyExtended; -use pallas_txbuilder::{BuildConway, Input, Output, StagingTransaction}; +use pallas_txbuilder::{BuildConway, BuiltTransaction, Input, Output, StagingTransaction}; use pallas_wallet::PrivateKey; +use serde::{Deserialize, Serialize}; use crate::{Network, PaymentKey, WalletError}; @@ -87,40 +88,169 @@ impl ProtocolParams { /// One UTXO available for spending. Independently typed from /// `aldabra-chain::Utxo` so the tx-builder doesn't depend on the /// chain crate (keeps the I/O-free contract intact). +/// +/// `assets` keys follow Cardano's canonical +/// `` concatenation — +/// matches `aldabra-chain::Utxo::assets`. #[derive(Debug, Clone, PartialEq, Eq)] pub struct InputUtxo { pub tx_hash_hex: String, pub output_index: u32, pub lovelace: u64, + pub assets: std::collections::BTreeMap, } -/// Inputs the caller selected for a payment. +/// One native asset to include in an output, identified by policy + +/// asset-name. Both fields are hex-encoded. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct AssetSpec { + /// 56-char hex (28 bytes) Cardano policy ID. + pub policy_id_hex: String, + /// Hex-encoded asset name (raw bytes hex), 0-64 chars (0-32 bytes). + pub asset_name_hex: String, + pub quantity: u64, +} + +impl AssetSpec { + /// Combined asset key in the same `policy||name` shape used by + /// `InputUtxo::assets` and `aldabra-chain::Utxo::assets`. + pub fn key(&self) -> String { + let mut s = String::with_capacity(self.policy_id_hex.len() + self.asset_name_hex.len()); + s.push_str(&self.policy_id_hex); + s.push_str(&self.asset_name_hex); + s + } +} + +/// Split a `policy||name` asset key back into (policy_id_hex, +/// asset_name_hex). Policy IDs are always exactly 56 hex chars +/// (28-byte Blake2b-224 of the policy script), so we split there. +fn split_asset_key(key: &str) -> Result<(&str, &str), WalletError> { + if key.len() < 56 { + return Err(WalletError::Derivation(format!( + "asset key too short: expected ≥56 hex chars, got {}", + key.len() + ))); + } + Ok(key.split_at(56)) +} + +fn parse_policy_id(hex_str: &str) -> Result, WalletError> { + if hex_str.len() != 56 { + return Err(WalletError::Derivation(format!( + "policy_id must be 56 hex chars, 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 policy_id: {hex_str}")))?; + } + Ok(pallas_crypto::hash::Hash::<28>::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) +} + +/// Asset-aware UTXO selection. +/// +/// Two-phase greedy: +/// 1. For each requested asset, pull UTXOs that contain it (largest +/// holding first) until coverage ≥ target. +/// 2. Then pull additional ADA-only UTXOs (largest first) until +/// `lovelace_acc ≥ target_lovelace + fee + min_change`. +/// +/// Returns the selected UTXO set (preserves insertion order so +/// asset-bearing inputs sort before ADA-only ones — useful when +/// debugging asset balances). fn select_utxos( available: &[InputUtxo], target_lovelace: u64, + target_assets: &std::collections::BTreeMap, fee_estimate: u64, min_change: u64, ) -> Result, WalletError> { + let mut selected: Vec = Vec::new(); + let mut acc_lovelace: u64 = 0; + let mut acc_assets: std::collections::BTreeMap = Default::default(); + + let already_selected = |sel: &Vec, u: &InputUtxo| -> bool { + sel.iter() + .any(|s| s.tx_hash_hex == u.tx_hash_hex && s.output_index == u.output_index) + }; + let absorb = |u: InputUtxo, + sel: &mut Vec, + ada: &mut u64, + ass: &mut std::collections::BTreeMap| { + *ada = ada.saturating_add(u.lovelace); + for (k, v) in &u.assets { + let entry = ass.entry(k.clone()).or_insert(0); + *entry = entry.saturating_add(*v); + } + sel.push(u); + }; + + // Phase 1: cover each required asset. + for (asset_key, target_qty) in target_assets { + while acc_assets.get(asset_key).copied().unwrap_or(0) < *target_qty { + let candidate = available + .iter() + .filter(|u| !already_selected(&selected, u)) + .filter(|u| u.assets.contains_key(asset_key)) + .max_by_key(|u| u.assets.get(asset_key).copied().unwrap_or(0)); + match candidate { + Some(u) => absorb(u.clone(), &mut selected, &mut acc_lovelace, &mut acc_assets), + None => { + return Err(WalletError::Derivation(format!( + "insufficient asset {asset_key}: need {target_qty}, have {}", + acc_assets.get(asset_key).copied().unwrap_or(0) + ))) + } + } + } + } + + // Phase 2: cover remaining lovelace need. let need = target_lovelace .checked_add(fee_estimate) .and_then(|x| x.checked_add(min_change)) .ok_or_else(|| WalletError::Derivation("amount + fee + min_change overflows u64".into()))?; - let mut sorted: Vec = available.to_vec(); - sorted.sort_by(|a, b| b.lovelace.cmp(&a.lovelace)); - - let mut acc: u64 = 0; - let mut chosen: Vec = Vec::new(); - for u in sorted { - acc = acc.saturating_add(u.lovelace); - chosen.push(u); - if acc >= need { - return Ok(chosen); + while acc_lovelace < need { + let candidate = available + .iter() + .filter(|u| !already_selected(&selected, u)) + .max_by_key(|u| u.lovelace); + match candidate { + Some(u) => absorb(u.clone(), &mut selected, &mut acc_lovelace, &mut acc_assets), + None => { + return Err(WalletError::Derivation(format!( + "insufficient funds: need {need} lovelace (target+fee+min_change), have {acc_lovelace}" + ))) + } } } - Err(WalletError::Derivation(format!( - "insufficient funds: need at least {need} lovelace (target+fee+min_change), have {acc}" - ))) + + Ok(selected) } fn parse_address(bech32: &str) -> Result { @@ -165,12 +295,34 @@ fn network_id_for(network: Network) -> u8 { } } +fn output_with_assets( + addr: &PallasAddress, + lovelace: u64, + assets: &std::collections::BTreeMap, +) -> Result { + let mut out = Output::new(addr.clone(), lovelace); + for (key, qty) in assets { + if *qty == 0 { + continue; + } + let (pol_hex, name_hex) = split_asset_key(key)?; + let policy = parse_policy_id(pol_hex)?; + let name = parse_asset_name(name_hex)?; + out = out + .add_asset(policy, name, *qty) + .map_err(|e| WalletError::Derivation(format!("output add_asset: {e}")))?; + } + Ok(out) +} + fn build_staging_with_fee( inputs: &[InputUtxo], to_addr: &PallasAddress, to_lovelace: u64, + to_assets: &std::collections::BTreeMap, change_addr: &PallasAddress, change_lovelace: u64, + change_assets: &std::collections::BTreeMap, fee: u64, network_id: u8, ) -> Result { @@ -179,9 +331,18 @@ fn build_staging_with_fee( let h = parse_tx_hash(&u.tx_hash_hex)?; staging = staging.input(Input::new(h, u.output_index as u64)); } - staging = staging.output(Output::new(to_addr.clone(), to_lovelace)); - if change_lovelace > 0 { - staging = staging.output(Output::new(change_addr.clone(), change_lovelace)); + staging = staging.output(output_with_assets(to_addr, to_lovelace, to_assets)?); + let nonzero_change_assets: std::collections::BTreeMap = change_assets + .iter() + .filter(|(_, q)| **q > 0) + .map(|(k, v)| (k.clone(), *v)) + .collect(); + if change_lovelace > 0 || !nonzero_change_assets.is_empty() { + staging = staging.output(output_with_assets( + change_addr, + change_lovelace, + &nonzero_change_assets, + )?); } staging = staging.fee(fee).network_id(network_id); Ok(staging) @@ -192,6 +353,41 @@ fn build_staging_with_fee( /// safety so a single-witness payment never under-estimates fee. const WITNESS_OVERHEAD_BYTES: u64 = 128; +/// Human-readable summary of an unsigned tx — meant for an LLM / +/// human reviewer to verify before signing or submitting. All +/// amounts in lovelace. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct PaymentSummary { + /// Predicted transaction hash. The signed CBOR will hash to + /// the same value (signature is on the body hash, not over the + /// whole CBOR). Use this to confirm the signed CBOR returned + /// from a cold-signer matches the body that was reviewed. + pub tx_hash: String, + pub network: Network, + pub from_address: String, + pub to_address: String, + pub send_lovelace: u64, + pub fee_lovelace: u64, + pub change_lovelace: u64, + pub num_inputs: usize, + /// Native assets sent to the recipient. + #[serde(default)] + pub send_assets: Vec, + /// Native assets returning to the change address. + #[serde(default)] + pub change_assets: Vec, +} + +/// Output of `build_unsigned_payment` — both the CBOR bytes (for +/// the cold-signer) and a human-readable summary (for the +/// reviewer). +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct UnsignedPayment { + /// Hex-encoded unsigned transaction CBOR. + pub cbor_hex: String, + pub summary: PaymentSummary, +} + fn build_unsigned_bytes( staging: StagingTransaction, ) -> Result, WalletError> { @@ -214,34 +410,65 @@ fn build_and_sign( Ok(signed.tx_bytes.0) } -/// Build + sign a Conway-era ADA-only payment. -/// -/// Two-pass fee refinement: build once with a generous placeholder -/// fee to measure tx size, recompute the real fee, build again, sign. -/// If the change output would land below `min_utxo_lovelace` we -/// merge it into the fee instead of emitting a dust UTXO. -pub fn build_signed_payment( - payment_key: &PaymentKey, +/// Internal helper — runs the two-pass fee refinement and returns +/// the final `BuiltTransaction` plus a `PaymentSummary` describing +/// the body. Handles both ADA-only and multi-asset payments; pass +/// `&[]` for `assets_to_send` to keep it ADA-only. +fn prepare_payment( network: Network, available_utxos: &[InputUtxo], change_address_bech32: &str, to_address_bech32: &str, lovelace: u64, + assets_to_send: &[AssetSpec], params: &ProtocolParams, -) -> Result, WalletError> { +) -> Result<(BuiltTransaction, PaymentSummary), WalletError> { let to_addr = parse_address(to_address_bech32)?; let change_addr = parse_address(change_address_bech32)?; let network_id = network_id_for(network); - let private = payment_key_to_private(payment_key)?; + + // Aggregate target assets by canonical key, so duplicates in the + // caller's input list sum together rather than confusing + // selection. + let mut target_assets: std::collections::BTreeMap = Default::default(); + for spec in assets_to_send { + let entry = target_assets.entry(spec.key()).or_insert(0); + *entry = entry.saturating_add(spec.quantity); + } // Pass 1: pick inputs assuming a generous placeholder fee, then // build *unsigned* to measure size. We add WITNESS_OVERHEAD_BYTES // to account for the witness this tx will carry once signed. let fee_pass1: u64 = 500_000; - let inputs = select_utxos(available_utxos, lovelace, fee_pass1, params.min_utxo_lovelace)?; - let total_in: u64 = inputs.iter().map(|u| u.lovelace).sum(); + let inputs = select_utxos( + available_utxos, + lovelace, + &target_assets, + fee_pass1, + params.min_utxo_lovelace, + )?; + let total_in_lovelace: u64 = inputs.iter().map(|u| u.lovelace).sum(); - let change_pass1 = total_in + // Sum input assets across selected UTXOs. + let mut total_in_assets: std::collections::BTreeMap = Default::default(); + for u in &inputs { + for (k, v) in &u.assets { + let entry = total_in_assets.entry(k.clone()).or_insert(0); + *entry = entry.saturating_add(*v); + } + } + + // Change-side assets: input assets minus what's being sent. + let mut change_assets: std::collections::BTreeMap = Default::default(); + for (k, v) in &total_in_assets { + let sent = target_assets.get(k).copied().unwrap_or(0); + let leftover = v.saturating_sub(sent); + if leftover > 0 { + change_assets.insert(k.clone(), leftover); + } + } + + let change_pass1 = total_in_lovelace .checked_sub(lovelace) .and_then(|x| x.checked_sub(fee_pass1)) .ok_or_else(|| { @@ -252,8 +479,10 @@ pub fn build_signed_payment( &inputs, &to_addr, lovelace, + &target_assets, &change_addr, change_pass1, + &change_assets, fee_pass1, network_id, )?; @@ -261,28 +490,214 @@ pub fn build_signed_payment( let estimated_signed_size = (unsigned_bytes.len() as u64) + WITNESS_OVERHEAD_BYTES; let real_fee = params.min_fee_for_size(estimated_signed_size); - let (final_fee, final_change) = match total_in.checked_sub(lovelace + real_fee) { - Some(c) if c >= params.min_utxo_lovelace => (real_fee, c), - // Change too small — merge it into the fee (no dust output). + // If the wallet has leftover assets, the change output must + // exist (assets can't ride only on the fee). Force a min-utxo + // worth of lovelace into change in that case. + let change_must_exist = !change_assets.is_empty(); + + let (final_fee, final_change) = match total_in_lovelace.checked_sub(lovelace + real_fee) { + Some(c) if c >= params.min_utxo_lovelace || change_must_exist => { + // change_must_exist + c < min_utxo: caller didn't bring + // enough ADA to support a token-bearing change output. + // Surface a clearer error than letting the chain reject + // the tx for a sub-min output. + if change_must_exist && c < params.min_utxo_lovelace { + return Err(WalletError::Derivation(format!( + "insufficient ADA for token-bearing change output: change={c} lovelace, min={}", + params.min_utxo_lovelace + ))); + } + (real_fee, c) + } + // ADA-only path with sub-min change — fold into fee. Some(c) => (real_fee + c, 0), None => { return Err(WalletError::Derivation(format!( - "insufficient funds for fee: total_in={total_in} lovelace={lovelace} fee={real_fee}" + "insufficient funds for fee: total_in={total_in_lovelace} lovelace={lovelace} fee={real_fee}" ))) } }; - // Pass 2: build with real fee + final change, sign once. + // Pass 2: build with real fee + final change. let staging2 = build_staging_with_fee( &inputs, &to_addr, lovelace, + &target_assets, &change_addr, final_change, + &change_assets, final_fee, network_id, )?; - build_and_sign(staging2, private) + let built = staging2 + .build_conway_raw() + .map_err(|e| WalletError::Derivation(format!("conway build: {e}")))?; + + // Re-shape the asset maps back into Vec for the + // summary — easier for callers to display than a BTreeMap. + let send_assets_vec: Vec = target_assets + .iter() + .map(|(k, v)| { + let (p, n) = split_asset_key(k).expect("we built this key"); + AssetSpec { + policy_id_hex: p.to_string(), + asset_name_hex: n.to_string(), + quantity: *v, + } + }) + .collect(); + let change_assets_vec: Vec = change_assets + .iter() + .map(|(k, v)| { + let (p, n) = split_asset_key(k).expect("we built this key"); + AssetSpec { + policy_id_hex: p.to_string(), + asset_name_hex: n.to_string(), + quantity: *v, + } + }) + .collect(); + + let summary = PaymentSummary { + tx_hash: hex_encode(&built.tx_hash.0), + network, + from_address: change_address_bech32.to_string(), + to_address: to_address_bech32.to_string(), + send_lovelace: lovelace, + fee_lovelace: final_fee, + change_lovelace: final_change, + num_inputs: inputs.len(), + send_assets: send_assets_vec, + change_assets: change_assets_vec, + }; + + Ok((built, summary)) +} + +fn hex_encode(bytes: &[u8]) -> String { + let mut s = String::with_capacity(bytes.len() * 2); + for b in bytes { + s.push_str(&format!("{:02x}", b)); + } + s +} + +/// Hex-decode a string into a Vec — used by the cold-sign flow to +/// turn the externally-signed tx hex back into bytes for submission. +pub fn hex_decode(s: &str) -> Result, WalletError> { + if s.len() % 2 != 0 { + return Err(WalletError::Derivation("hex string has odd length".into())); + } + let mut out = Vec::with_capacity(s.len() / 2); + for i in (0..s.len()).step_by(2) { + let byte = u8::from_str_radix(&s[i..i + 2], 16) + .map_err(|_| WalletError::Derivation(format!("invalid hex char at {i}")))?; + out.push(byte); + } + Ok(out) +} + +/// Build + sign a Conway-era ADA-only payment. Convenience wrapper +/// around [`build_signed_payment_with_assets`] with no native +/// assets. Returns the signed CBOR bytes ready for submission via +/// `ChainBackend::submit_tx`. +pub fn build_signed_payment( + payment_key: &PaymentKey, + network: Network, + available_utxos: &[InputUtxo], + change_address_bech32: &str, + to_address_bech32: &str, + lovelace: u64, + params: &ProtocolParams, +) -> Result, WalletError> { + build_signed_payment_with_assets( + payment_key, + network, + available_utxos, + change_address_bech32, + to_address_bech32, + lovelace, + &[], + params, + ) +} + +/// Build + sign a Conway-era payment that may include native assets. +/// Returns the signed CBOR bytes ready for `ChainBackend::submit_tx`. +pub fn build_signed_payment_with_assets( + payment_key: &PaymentKey, + network: Network, + available_utxos: &[InputUtxo], + change_address_bech32: &str, + to_address_bech32: &str, + lovelace: u64, + assets_to_send: &[AssetSpec], + params: &ProtocolParams, +) -> Result, WalletError> { + let private = payment_key_to_private(payment_key)?; + let (built, _summary) = prepare_payment( + network, + available_utxos, + change_address_bech32, + to_address_bech32, + lovelace, + assets_to_send, + params, + )?; + let signed = built + .sign(private) + .map_err(|e| WalletError::Derivation(format!("sign: {e}")))?; + Ok(signed.tx_bytes.0) +} + +/// Build an ADA-only payment without signing. Convenience wrapper +/// around [`build_unsigned_payment_with_assets`]. +pub fn build_unsigned_payment( + network: Network, + available_utxos: &[InputUtxo], + change_address_bech32: &str, + to_address_bech32: &str, + lovelace: u64, + params: &ProtocolParams, +) -> Result { + build_unsigned_payment_with_assets( + network, + available_utxos, + change_address_bech32, + to_address_bech32, + lovelace, + &[], + params, + ) +} + +/// Build a Conway-era payment (ADA + optional native assets) without +/// signing. Returns the unsigned CBOR + a `PaymentSummary` for human +/// review. Caller signs externally and submits via +/// `ChainBackend::submit_tx`. +pub fn build_unsigned_payment_with_assets( + network: Network, + available_utxos: &[InputUtxo], + change_address_bech32: &str, + to_address_bech32: &str, + lovelace: u64, + assets_to_send: &[AssetSpec], + params: &ProtocolParams, +) -> Result { + let (built, summary) = prepare_payment( + network, + available_utxos, + change_address_bech32, + to_address_bech32, + lovelace, + assets_to_send, + params, + )?; + Ok(UnsignedPayment { + cbor_hex: hex_encode(&built.tx_bytes.0), + summary, + }) } #[cfg(test)] @@ -335,66 +750,110 @@ mod tests { assert!(p.min_fee_for_size(100) < fee_250); } + fn ada_utxo(tx_byte: u8, lovelace: u64) -> InputUtxo { + InputUtxo { + tx_hash_hex: format!("{tx_byte:02x}").repeat(32), + output_index: 0, + lovelace, + assets: Default::default(), + } + } + + fn empty_assets() -> std::collections::BTreeMap { + Default::default() + } + #[test] fn select_utxos_greedy_returns_largest_first() { let available = vec![ - InputUtxo { - tx_hash_hex: "11".repeat(32), - output_index: 0, - lovelace: 5_000_000, - }, - InputUtxo { - tx_hash_hex: "22".repeat(32), - output_index: 0, - lovelace: 50_000_000, - }, - InputUtxo { - tx_hash_hex: "33".repeat(32), - output_index: 0, - lovelace: 1_000_000, - }, + ada_utxo(0x11, 5_000_000), + ada_utxo(0x22, 50_000_000), + ada_utxo(0x33, 1_000_000), ]; - let chosen = select_utxos(&available, 10_000_000, 500_000, 1_000_000).unwrap(); - // Should pick the 50M utxo first, and just that one (covers - // the target + fee + min_change). + let chosen = + select_utxos(&available, 10_000_000, &empty_assets(), 500_000, 1_000_000).unwrap(); assert_eq!(chosen.len(), 1); assert_eq!(chosen[0].lovelace, 50_000_000); } #[test] fn select_utxos_chains_when_one_isnt_enough() { - let available = vec![ - InputUtxo { - tx_hash_hex: "11".repeat(32), - output_index: 0, - lovelace: 5_000_000, - }, - InputUtxo { - tx_hash_hex: "22".repeat(32), - output_index: 0, - lovelace: 8_000_000, - }, - ]; - let chosen = select_utxos(&available, 10_000_000, 500_000, 1_000_000).unwrap(); + let available = vec![ada_utxo(0x11, 5_000_000), ada_utxo(0x22, 8_000_000)]; + let chosen = + select_utxos(&available, 10_000_000, &empty_assets(), 500_000, 1_000_000).unwrap(); assert_eq!(chosen.len(), 2); - // Largest first. assert_eq!(chosen[0].lovelace, 8_000_000); } #[test] fn select_utxos_errors_when_insufficient() { - let available = vec![InputUtxo { - tx_hash_hex: "11".repeat(32), - output_index: 0, - lovelace: 1_000_000, - }]; - let err = select_utxos(&available, 10_000_000, 500_000, 1_000_000).unwrap_err(); + let available = vec![ada_utxo(0x11, 1_000_000)]; + let err = + select_utxos(&available, 10_000_000, &empty_assets(), 500_000, 1_000_000).unwrap_err(); match err { WalletError::Derivation(msg) => assert!(msg.contains("insufficient funds")), other => panic!("expected Derivation, got {other:?}"), } } + #[test] + fn select_utxos_picks_asset_bearing_first() { + let policy = "ee".repeat(28); + let asset = format!("{policy}{}", "deadbeef"); + let mut assets = std::collections::BTreeMap::new(); + assets.insert(asset.clone(), 100u64); + + let mut with_asset = ada_utxo(0xaa, 2_000_000); + with_asset.assets.insert(asset.clone(), 100); + + let available = vec![ada_utxo(0xbb, 50_000_000), with_asset]; + let mut target_assets = std::collections::BTreeMap::new(); + target_assets.insert(asset.clone(), 50); + let chosen = + select_utxos(&available, 10_000_000, &target_assets, 500_000, 1_000_000).unwrap(); + // First selected must be the asset-bearing UTXO. + assert_eq!(chosen[0].lovelace, 2_000_000); + // Then an ADA UTXO joins to cover the lovelace shortfall. + assert!(chosen.iter().any(|u| u.lovelace == 50_000_000)); + } + + #[test] + fn select_utxos_errors_on_missing_asset() { + let policy = "ff".repeat(28); + let asset = format!("{policy}{}", "abcd"); + let mut target_assets = std::collections::BTreeMap::new(); + target_assets.insert(asset, 1); + let available = vec![ada_utxo(0xaa, 50_000_000)]; + let err = + select_utxos(&available, 10_000_000, &target_assets, 500_000, 1_000_000).unwrap_err(); + match err { + WalletError::Derivation(msg) => assert!(msg.contains("insufficient asset")), + other => panic!("expected Derivation, got {other:?}"), + } + } + + #[test] + fn parse_policy_id_validates_length() { + assert!(parse_policy_id("ab").is_err()); + assert!(parse_policy_id(&"ee".repeat(28)).is_ok()); + } + + #[test] + fn parse_asset_name_validates_length() { + assert!(parse_asset_name(&"ab".repeat(33)).is_err()); // > 32 bytes + assert!(parse_asset_name(&"deadbeef").is_ok()); // 4 bytes + assert!(parse_asset_name("").is_ok()); // 0 bytes — valid (ADA-symbol-like) + } + + #[test] + fn split_asset_key_at_56_chars() { + let policy = "ab".repeat(28); + let key = format!("{policy}{}", "deadbeef"); + let (p, n) = split_asset_key(&key).unwrap(); + assert_eq!(p, policy); + assert_eq!(n, "deadbeef"); + } + #[test] fn hex_decode_validates_length() { assert!(hex_decode_32("ab").is_err()); @@ -413,15 +872,20 @@ mod tests { assert_eq!(pubkey_bytes.len(), 32); } + fn single_ada_utxo(lovelace: u64) -> InputUtxo { + InputUtxo { + tx_hash_hex: "deadbeef".repeat(8), + output_index: 0, + lovelace, + assets: Default::default(), + } + } + #[test] fn build_signed_payment_produces_cbor() { let payment = payment_from_canonical(); let change = change_address(Network::Preprod); - let utxos = vec![InputUtxo { - tx_hash_hex: "deadbeef".repeat(8), - output_index: 0, - lovelace: 100_000_000, - }]; + let utxos = vec![single_ada_utxo(100_000_000)]; let cbor = build_signed_payment( &payment, Network::Preprod, @@ -445,15 +909,154 @@ mod tests { ); } + #[test] + fn hex_round_trip() { + let bytes = vec![0xde, 0xad, 0xbe, 0xef, 0x00, 0xff, 0x42]; + let encoded = hex_encode(&bytes); + assert_eq!(encoded, "deadbeef00ff42"); + let decoded = hex_decode(&encoded).unwrap(); + assert_eq!(decoded, bytes); + } + + #[test] + fn hex_decode_rejects_garbage() { + assert!(hex_decode("zz").is_err()); + assert!(hex_decode("abc").is_err()); // odd length + } + + #[test] + fn unsigned_payment_summary_is_populated() { + let change = change_address(Network::Preprod); + let utxos = vec![single_ada_utxo(100_000_000)]; + let result = build_unsigned_payment( + Network::Preprod, + &utxos, + &change, + &to_address_preprod(), + 10_000_000, + &ProtocolParams::default(), + ) + .expect("unsigned builds"); + let s = &result.summary; + assert_eq!(s.send_lovelace, 10_000_000); + assert_eq!(s.num_inputs, 1); + assert!(s.fee_lovelace > 0); + // Total in (100M) - send (10M) - fee = change. + assert_eq!( + s.change_lovelace, + 100_000_000 - s.send_lovelace - s.fee_lovelace + ); + // 32-byte hash → 64 hex chars. + assert_eq!(s.tx_hash.len(), 64); + // Round-trip the unsigned CBOR. + let decoded = hex_decode(&result.cbor_hex).unwrap(); + assert!(decoded.len() > 50); + } + + #[test] + fn unsigned_and_signed_have_same_body_hash() { + let payment = payment_from_canonical(); + let change = change_address(Network::Preprod); + let utxos = vec![single_ada_utxo(100_000_000)]; + let unsigned = build_unsigned_payment( + Network::Preprod, + &utxos, + &change, + &to_address_preprod(), + 10_000_000, + &ProtocolParams::default(), + ) + .unwrap(); + let _signed = build_signed_payment( + &payment, + Network::Preprod, + &utxos, + &change, + &to_address_preprod(), + 10_000_000, + &ProtocolParams::default(), + ) + .unwrap(); + // The unsigned summary.tx_hash should reflect the canonical + // body hash. Signing only adds witnesses; body is unchanged. + // Sanity: hash isn't all zeros. + assert_ne!(unsigned.summary.tx_hash, "0".repeat(64)); + } + + #[test] + fn build_signed_payment_with_assets_produces_cbor() { + let payment = payment_from_canonical(); + let change = change_address(Network::Preprod); + let policy = "ee".repeat(28); + let asset_name = "deadbeef"; + let asset_key = format!("{policy}{asset_name}"); + + let mut utxo_with_asset = single_ada_utxo(50_000_000); + utxo_with_asset.assets.insert(asset_key.clone(), 500); + // Need a second UTXO with a distinct tx_hash for fee/change ADA. + let utxo_for_fee = InputUtxo { + tx_hash_hex: "ff".repeat(32), + output_index: 1, + lovelace: 50_000_000, + assets: Default::default(), + }; + + let cbor = build_signed_payment_with_assets( + &payment, + Network::Preprod, + &[utxo_with_asset, utxo_for_fee], + &change, + &to_address_preprod(), + 10_000_000, + &[AssetSpec { + policy_id_hex: policy, + asset_name_hex: asset_name.to_string(), + quantity: 100, + }], + &ProtocolParams::default(), + ) + .expect("multi-asset payment builds + signs"); + // Multi-asset tx is meaningfully larger than ADA-only. + assert!(cbor.len() > 200, "cbor unexpectedly short: {} bytes", cbor.len()); + } + + #[test] + fn unsigned_with_assets_summary_includes_change_assets() { + let change = change_address(Network::Preprod); + let policy = "ee".repeat(28); + let asset_name = "deadbeef"; + let asset_key = format!("{policy}{asset_name}"); + + let mut utxo = single_ada_utxo(100_000_000); + utxo.assets.insert(asset_key, 500); + + let result = build_unsigned_payment_with_assets( + Network::Preprod, + &[utxo], + &change, + &to_address_preprod(), + 10_000_000, + &[AssetSpec { + policy_id_hex: policy.clone(), + asset_name_hex: asset_name.to_string(), + quantity: 100, + }], + &ProtocolParams::default(), + ) + .unwrap(); + // Sent 100 of 500 → change must hold 400. + assert_eq!(result.summary.send_assets.len(), 1); + assert_eq!(result.summary.send_assets[0].quantity, 100); + assert_eq!(result.summary.change_assets.len(), 1); + assert_eq!(result.summary.change_assets[0].quantity, 400); + assert_eq!(result.summary.change_assets[0].policy_id_hex, policy); + } + #[test] fn build_signed_payment_fails_without_funds() { let payment = payment_from_canonical(); let change = change_address(Network::Preprod); - let utxos = vec![InputUtxo { - tx_hash_hex: "deadbeef".repeat(8), - output_index: 0, - lovelace: 5_000_000, - }]; + let utxos = vec![single_ada_utxo(5_000_000)]; let err = build_signed_payment( &payment, Network::Preprod, diff --git a/crates/aldabra-mcp/src/tools.rs b/crates/aldabra-mcp/src/tools.rs index 5935382..e4a64ed 100644 --- a/crates/aldabra-mcp/src/tools.rs +++ b/crates/aldabra-mcp/src/tools.rs @@ -25,10 +25,35 @@ use std::sync::Arc; use aldabra_chain::{ChainBackend, KoiosClient}; -use aldabra_core::{build_signed_payment, InputUtxo, Network, PaymentKey, ProtocolParams}; +use aldabra_core::{ + build_signed_payment_with_assets, build_unsigned_payment_with_assets, hex_decode, AssetSpec, + InputUtxo, Network, PaymentKey, ProtocolParams, +}; 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 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, @@ -68,6 +93,11 @@ pub struct SendArgs { 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, /// Bypass the configured `max_send_lovelace` hard cap. Only /// pass `true` for an intentional, user-confirmed large send. #[serde(default)] @@ -80,6 +110,25 @@ pub struct TxStatusArgs { 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, +} + +#[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, +} + #[tool(tool_box)] impl WalletService { #[tool( @@ -132,11 +181,16 @@ impl WalletService { #[tool( name = "wallet.send", - description = "Build, sign, and submit an ADA payment from this wallet. Args: to_address (bech32), lovelace (u64), force (bool, optional). Refuses sends > max_send_lovelace unless force=true. Returns the tx hash on success." + 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, force }: SendArgs, + #[tool(aggr)] SendArgs { + to_address, + lovelace, + assets, + force, + }: SendArgs, ) -> Result { if lovelace == 0 { return Err("lovelace must be > 0".into()); @@ -167,16 +221,19 @@ impl WalletService { tx_hash_hex: u.tx_hash, output_index: u.output_index, lovelace: u.lovelace, + assets: u.assets, }) .collect(); + let asset_specs: Vec = assets.into_iter().map(Into::into).collect(); - let cbor = build_signed_payment( + 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}"))?; @@ -206,6 +263,74 @@ impl WalletService { .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 { + 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 = 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 = 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 { + 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(tool_box)] @@ -213,7 +338,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, 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 (auto-sign), wallet.send.unsigned + wallet.submit_signed_tx (cold-sign flow), wallet.tx_status. Native-asset send + Plutus land in phase 3+.".into(), ), ..Default::default() }