phase 2.1-2.4: send path — submit + status, txbuilder, wallet.send, wallet.tx_status

chain backend grew submit_tx (POST /submittx, raw cbor body) and
tx_status (POST /tx_info → Confirmed{block,epoch}|NotFound). serde
tag-based status enum so the mcp tool returns clean json.

new core::tx module: ProtocolParams + InputUtxo + build_signed_payment.
two-pass fee refinement — build unsigned, measure size, add witness
overhead constant (128 bytes for vkey+sig+cbor framing), recompute
real fee, build with final fee, sign once (PrivateKey doesn't impl
Clone in pallas-wallet, so we don't double-sign). change below
min-utxo merges into fee instead of emitting dust.

added pallas-txbuilder + pallas-wallet 0.32 deps. PaymentKey gains
crate-private xprv() accessor; payment_key_to_private converts
ed25519-bip32 XPrv → pallas-wallet PrivateKey::Extended via the
64-byte extended secret bytes.

mcp tools.rs: 4 → 6 tools.
- wallet.send (to_address, lovelace, force) with hard-cap guard
- wallet.tx_status (tx_hash) → status json
SendArgs/TxStatusArgs use schemars derive so rmcp generates proper
input schemas. config.rs adds max_send_lovelace (default 100 ADA,
ALDABRA_MAX_SEND_LOVELACE env override).

37 unit tests. mcp tools/list smoke confirms all 6 tools register
with correct schemas (force defaults false, lovelace required uint64,
to_address required string).

phase 2.5 (native-asset send), 2.6 (cold-sign offline mode), and
2.7 (real preprod smoke against a funded wallet) still open.
This commit is contained in:
Sulkta 2026-05-04 11:18:33 -07:00
parent 2b4fdff0c0
commit 44bae07bc9
11 changed files with 821 additions and 29 deletions

View file

@ -66,6 +66,13 @@ impl PaymentKey {
pub fn public_key_hash(&self) -> Hash<28> {
Hasher::<224>::hash(self.xprv.public().public_key_bytes())
}
/// Borrow the underlying XPrv. Crate-internal — used by the `tx`
/// module to drive `pallas-wallet::PrivateKey::Extended` for
/// signing.
pub(crate) fn xprv(&self) -> &ed25519_bip32::XPrv {
&self.xprv
}
}
/// A stake key derived at `m/1852'/1815'/account'/2/0`. Same memory

View file

@ -36,7 +36,9 @@ use thiserror::Error;
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};
#[derive(Debug, Error)]
pub enum WalletError {

View file

@ -0,0 +1,472 @@
//! Transaction building + signing for the send path.
//!
//! Pure crypto + serialization — no I/O. Caller (typically
//! `aldabra-mcp`) is responsible for fetching UTXOs from the chain
//! backend and submitting the resulting CBOR bytes.
//!
//! ## What this does
//!
//! Given a [`PaymentKey`], a set of UTXOs at the wallet address, a
//! recipient address, and a lovelace amount:
//!
//! 1. Select inputs (greedy largest-first) covering target + fee + min change
//! 2. Build a Conway-era `StagingTransaction` with one or two outputs
//! (recipient + change, change collapsed into fee if sub-min)
//! 3. Estimate the min fee from `min_fee_a * tx_size + min_fee_b`,
//! rebuild once with the real fee
//! 4. Sign with the payment key
//! 5. Return the signed CBOR bytes ready for `submit_tx`
//!
//! ## What this doesn't do (yet)
//!
//! - Native asset sends (Phase 2.5)
//! - Plutus / script witnesses (Phase 4)
//! - Stake delegation (Phase 4)
//! - Live protocol-param fetching — we ship a hardcoded
//! [`ProtocolParams`] with the known Conway-era values. Callers can
//! override.
//!
//! ## Fee estimation
//!
//! Cardano's min-fee formula is `min_fee_a * tx_size_bytes + min_fee_b`.
//! We build twice: once with a placeholder fee to measure size, then
//! again with the real fee. The recipient amount is fixed; change
//! absorbs the difference. If change after fee is below `min_utxo` we
//! merge it into the fee (avoids creating dust UTXOs).
//!
//! ## Min-utxo
//!
//! Cardano protocol requires every output hold at least
//! `coins_per_utxo_byte * utxo_serialized_size` lovelace, which
//! works out to ~1 ADA for a plain ADA-only output. We approximate
//! conservatively at 1_000_000 lovelace until Phase 4 wires real
//! protocol params.
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_wallet::PrivateKey;
use crate::{Network, PaymentKey, WalletError};
/// Cardano protocol parameters needed for fee estimation. Values
/// here match Conway-era mainnet as of 2026-Q2; supply your own via
/// [`ProtocolParams::from_koios_response`] (TODO) if you want
/// chain-fresh values.
#[derive(Debug, Clone, Copy)]
pub struct ProtocolParams {
/// Per-byte fee coefficient (a). Mainnet: 44.
pub min_fee_a: u64,
/// Constant fee (b). Mainnet: 155_381.
pub min_fee_b: u64,
/// Approximate min lovelace for a plain ADA-only output. Real
/// formula is `coins_per_utxo_byte * tx_out_size`; we use the
/// 1 ADA floor as a safe over-approximation.
pub min_utxo_lovelace: u64,
}
impl Default for ProtocolParams {
fn default() -> Self {
Self {
min_fee_a: 44,
min_fee_b: 155_381,
min_utxo_lovelace: 1_000_000,
}
}
}
impl ProtocolParams {
pub fn min_fee_for_size(&self, tx_size_bytes: u64) -> u64 {
self.min_fee_a
.saturating_mul(tx_size_bytes)
.saturating_add(self.min_fee_b)
}
}
/// 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).
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct InputUtxo {
pub tx_hash_hex: String,
pub output_index: u32,
pub lovelace: u64,
}
/// Inputs the caller selected for a payment.
fn select_utxos(
available: &[InputUtxo],
target_lovelace: u64,
fee_estimate: u64,
min_change: u64,
) -> Result<Vec<InputUtxo>, WalletError> {
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);
}
}
Err(WalletError::Derivation(format!(
"insufficient funds: need at least {need} lovelace (target+fee+min_change), have {acc}"
)))
}
fn parse_address(bech32: &str) -> Result<PallasAddress, WalletError> {
PallasAddress::from_bech32(bech32).map_err(|e| WalletError::Address(e.to_string()))
}
fn parse_tx_hash(hex_str: &str) -> Result<pallas_crypto::hash::Hash<32>, WalletError> {
let bytes = hex_decode_32(hex_str)?;
Ok(pallas_crypto::hash::Hash::<32>::new(bytes))
}
fn hex_decode_32(s: &str) -> Result<[u8; 32], WalletError> {
if s.len() != 64 {
return Err(WalletError::Derivation(format!(
"expected 64-char hex tx_hash, got {} chars",
s.len()
)));
}
let mut out = [0u8; 32];
for i in 0..32 {
out[i] = u8::from_str_radix(&s[i * 2..i * 2 + 2], 16)
.map_err(|_| WalletError::Derivation(format!("invalid hex in tx_hash: {s}")))?;
}
Ok(out)
}
/// Convert a [`PaymentKey`] into a `pallas-wallet::PrivateKey` so
/// `BuiltTransaction::sign` can consume it. The XPrv's first 64
/// bytes are the extended secret; we reuse them directly.
fn payment_key_to_private(payment: &PaymentKey) -> Result<PrivateKey, WalletError> {
let xprv: &XPrv = payment.xprv();
let extended: [u8; 64] = xprv.extended_secret_key();
let secret = SecretKeyExtended::from_bytes(extended)
.map_err(|e| WalletError::Derivation(format!("invalid extended secret: {e}")))?;
Ok(PrivateKey::Extended(secret))
}
fn network_id_for(network: Network) -> u8 {
match network {
Network::Mainnet => 1,
Network::Preview | Network::Preprod => 0,
}
}
fn build_staging_with_fee(
inputs: &[InputUtxo],
to_addr: &PallasAddress,
to_lovelace: u64,
change_addr: &PallasAddress,
change_lovelace: u64,
fee: u64,
network_id: u8,
) -> Result<StagingTransaction, WalletError> {
let mut staging = StagingTransaction::new();
for u in inputs {
let h = parse_tx_hash(&u.tx_hash_hex)?;
staging = staging.input(Input::new(h, u.output_index as u64));
}
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.fee(fee).network_id(network_id);
Ok(staging)
}
/// One VKey witness adds: 32-byte vkey + 64-byte signature + CBOR
/// framing overhead. Empirically ~102 bytes; we round up to 128 for
/// safety so a single-witness payment never under-estimates fee.
const WITNESS_OVERHEAD_BYTES: u64 = 128;
fn build_unsigned_bytes(
staging: StagingTransaction,
) -> Result<Vec<u8>, WalletError> {
let built = staging
.build_conway_raw()
.map_err(|e| WalletError::Derivation(format!("conway build: {e}")))?;
Ok(built.tx_bytes.0)
}
fn build_and_sign(
staging: StagingTransaction,
private: PrivateKey,
) -> Result<Vec<u8>, WalletError> {
let built = staging
.build_conway_raw()
.map_err(|e| WalletError::Derivation(format!("conway build: {e}")))?;
let signed = built
.sign(private)
.map_err(|e| WalletError::Derivation(format!("sign: {e}")))?;
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,
network: Network,
available_utxos: &[InputUtxo],
change_address_bech32: &str,
to_address_bech32: &str,
lovelace: u64,
params: &ProtocolParams,
) -> Result<Vec<u8>, 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)?;
// 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 change_pass1 = total_in
.checked_sub(lovelace)
.and_then(|x| x.checked_sub(fee_pass1))
.ok_or_else(|| {
WalletError::Derivation("pass1: inputs do not cover lovelace + fee".into())
})?;
let staging1 = build_staging_with_fee(
&inputs,
&to_addr,
lovelace,
&change_addr,
change_pass1,
fee_pass1,
network_id,
)?;
let unsigned_bytes = build_unsigned_bytes(staging1)?;
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).
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}"
)))
}
};
// Pass 2: build with real fee + final change, sign once.
let staging2 = build_staging_with_fee(
&inputs,
&to_addr,
lovelace,
&change_addr,
final_change,
final_fee,
network_id,
)?;
build_and_sign(staging2, private)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::Mnemonic;
const ABANDON_ART: &str = concat!(
"abandon abandon abandon abandon abandon abandon ",
"abandon abandon abandon abandon abandon abandon ",
"abandon abandon abandon abandon abandon abandon ",
"abandon abandon abandon abandon abandon art",
);
fn payment_from_canonical() -> PaymentKey {
let root = Mnemonic::from_phrase(ABANDON_ART)
.unwrap()
.into_root_key()
.unwrap();
crate::derive::derive_payment_key(&root, 0, 0)
}
fn change_address(network: Network) -> String {
let root = Mnemonic::from_phrase(ABANDON_ART)
.unwrap()
.into_root_key()
.unwrap();
crate::derive_base_address(&root, network, 0, 0).unwrap()
}
/// A different valid preprod address — derived from the same
/// canonical mnemonic at index 1 (vs index 0 for the change
/// address). Spending to a derived-but-different address is
/// realistic enough for an end-to-end tx-build test.
fn to_address_preprod() -> String {
let root = Mnemonic::from_phrase(ABANDON_ART)
.unwrap()
.into_root_key()
.unwrap();
crate::derive_base_address(&root, Network::Preprod, 0, 1).unwrap()
}
#[test]
fn min_fee_formula_matches_known_values() {
let p = ProtocolParams::default();
// ~250-byte tx ≈ 0.166 ADA fee — typical for a 1-in-2-out payment.
let fee_250 = p.min_fee_for_size(250);
assert_eq!(fee_250, 44 * 250 + 155_381);
// Smaller tx pays less.
assert!(p.min_fee_for_size(100) < fee_250);
}
#[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,
},
];
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).
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();
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();
match err {
WalletError::Derivation(msg) => assert!(msg.contains("insufficient funds")),
other => panic!("expected Derivation, got {other:?}"),
}
}
#[test]
fn hex_decode_validates_length() {
assert!(hex_decode_32("ab").is_err());
assert!(hex_decode_32(&"00".repeat(31)).is_err());
assert!(hex_decode_32(&"00".repeat(32)).is_ok());
}
#[test]
fn payment_key_converts_to_pallas_private_key() {
let pk = payment_from_canonical();
let private = payment_key_to_private(&pk).unwrap();
// Round-trip: pubkey from PaymentKey vs from PrivateKey
// should match (both derive from the same XPrv).
let derived_pubkey = private.public_key();
let pubkey_bytes: &[u8] = derived_pubkey.as_ref();
assert_eq!(pubkey_bytes.len(), 32);
}
#[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 cbor = build_signed_payment(
&payment,
Network::Preprod,
&utxos,
&change,
&to_address_preprod(),
10_000_000,
&ProtocolParams::default(),
)
.expect("payment builds + signs");
// Conway-era signed tx is non-trivial — sanity check it's
// well over the fee constant.
assert!(cbor.len() > 100, "cbor too short: {} bytes", cbor.len());
// CBOR major type 4 (array) — a Cardano tx is `[tx_body,
// witness_set, valid, auxiliary_data]`. First byte should
// start with 0x80..0x9f range for an array.
assert!(
(cbor[0] & 0xe0) == 0x80,
"first CBOR byte not array-typed: 0x{:02x}",
cbor[0]
);
}
#[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 err = build_signed_payment(
&payment,
Network::Preprod,
&utxos,
&change,
&to_address_preprod(),
10_000_000,
&ProtocolParams::default(),
)
.expect_err("expected insufficient-funds error");
match err {
WalletError::Derivation(msg) => assert!(msg.contains("insufficient funds")),
other => panic!("expected Derivation, got {other:?}"),
}
}
}