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:
parent
44bae07bc9
commit
a472695558
3 changed files with 823 additions and 91 deletions
|
|
@ -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()
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue