phase 2.5-2.6: native asset send + cold-sign flow

InputUtxo gains an `assets: BTreeMap<String, u64>` 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 internal notes; needs sulkta's faucet ada.
This commit is contained in:
Sulkta 2026-05-04 11:35:06 -07:00
parent 44bae07bc9
commit a472695558
3 changed files with 823 additions and 91 deletions

View file

@ -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<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>,
@ -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<McpAssetSpec>,
/// 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<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,
}
#[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<String, String> {
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<AssetSpec> = 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<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(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()
}