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 memory/spec-aldabra-buildout.md; needs cobb's faucet ada.
This commit is contained in:
parent
dd84303885
commit
46b6f6efa3
3 changed files with 823 additions and 91 deletions
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
/// `<policy_id_hex(56)><asset_name_hex(0-64)>` 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<String, u64>,
|
||||
}
|
||||
|
||||
/// 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<pallas_crypto::hash::Hash<28>, 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<Vec<u8>, 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<String, u64>,
|
||||
fee_estimate: u64,
|
||||
min_change: u64,
|
||||
) -> Result<Vec<InputUtxo>, WalletError> {
|
||||
let mut selected: Vec<InputUtxo> = Vec::new();
|
||||
let mut acc_lovelace: u64 = 0;
|
||||
let mut acc_assets: std::collections::BTreeMap<String, u64> = Default::default();
|
||||
|
||||
let already_selected = |sel: &Vec<InputUtxo>, 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<InputUtxo>,
|
||||
ada: &mut u64,
|
||||
ass: &mut std::collections::BTreeMap<String, u64>| {
|
||||
*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<InputUtxo> = available.to_vec();
|
||||
sorted.sort_by(|a, b| b.lovelace.cmp(&a.lovelace));
|
||||
|
||||
let mut acc: u64 = 0;
|
||||
let mut chosen: Vec<InputUtxo> = 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<PallasAddress, WalletError> {
|
||||
|
|
@ -165,12 +295,34 @@ fn network_id_for(network: Network) -> u8 {
|
|||
}
|
||||
}
|
||||
|
||||
fn output_with_assets(
|
||||
addr: &PallasAddress,
|
||||
lovelace: u64,
|
||||
assets: &std::collections::BTreeMap<String, u64>,
|
||||
) -> Result<Output, WalletError> {
|
||||
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<String, u64>,
|
||||
change_addr: &PallasAddress,
|
||||
change_lovelace: u64,
|
||||
change_assets: &std::collections::BTreeMap<String, u64>,
|
||||
fee: u64,
|
||||
network_id: u8,
|
||||
) -> Result<StagingTransaction, WalletError> {
|
||||
|
|
@ -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<String, u64> = 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<AssetSpec>,
|
||||
/// Native assets returning to the change address.
|
||||
#[serde(default)]
|
||||
pub change_assets: Vec<AssetSpec>,
|
||||
}
|
||||
|
||||
/// 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<Vec<u8>, 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<Vec<u8>, 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<String, u64> = 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<String, u64> = 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<String, u64> = 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<AssetSpec> for the
|
||||
// summary — easier for callers to display than a BTreeMap.
|
||||
let send_assets_vec: Vec<AssetSpec> = 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<AssetSpec> = 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<Vec<u8>, 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<Vec<u8>, 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<Vec<u8>, 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<UnsignedPayment, WalletError> {
|
||||
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<UnsignedPayment, WalletError> {
|
||||
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<String, u64> {
|
||||
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,
|
||||
|
|
|
|||
|
|
@ -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