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:
parent
bc39148b63
commit
dd84303885
11 changed files with 821 additions and 29 deletions
68
Cargo.lock
generated
68
Cargo.lock
generated
|
|
@ -87,6 +87,8 @@ dependencies = [
|
|||
"pallas-codec",
|
||||
"pallas-crypto",
|
||||
"pallas-primitives",
|
||||
"pallas-txbuilder",
|
||||
"pallas-wallet",
|
||||
"serde",
|
||||
"thiserror 1.0.69",
|
||||
"zeroize",
|
||||
|
|
@ -205,6 +207,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||
checksum = "90dbd31c98227229239363921e60fcf5e558e43ec69094d46fc4996f08d1d5bc"
|
||||
dependencies = [
|
||||
"bitcoin_hashes",
|
||||
"rand_core 0.6.4",
|
||||
"serde",
|
||||
"unicode-normalization",
|
||||
]
|
||||
|
|
@ -453,6 +456,12 @@ dependencies = [
|
|||
"cryptoxide",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "either"
|
||||
version = "1.15.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719"
|
||||
|
||||
[[package]]
|
||||
name = "equivalent"
|
||||
version = "1.0.2"
|
||||
|
|
@ -1078,6 +1087,15 @@ dependencies = [
|
|||
"serde",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "itertools"
|
||||
version = "0.13.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "413ee7dfc52ee1a4949ceeb7dbc8a33f2d6c088194d9f922fb8318faf1f01186"
|
||||
dependencies = [
|
||||
"either",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "itoa"
|
||||
version = "1.0.18"
|
||||
|
|
@ -1287,6 +1305,56 @@ dependencies = [
|
|||
"serde_json",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pallas-traverse"
|
||||
version = "0.32.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "be7fbb1db75a0b6b32d1808b2cc5c7ba6dd261f289491bb86998b987b4716883"
|
||||
dependencies = [
|
||||
"hex",
|
||||
"itertools",
|
||||
"pallas-addresses",
|
||||
"pallas-codec",
|
||||
"pallas-crypto",
|
||||
"pallas-primitives",
|
||||
"paste",
|
||||
"serde",
|
||||
"thiserror 1.0.69",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pallas-txbuilder"
|
||||
version = "0.32.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "fff83ae515a88b1ecf5354468d9fd3562d915e5eceb5c9467f6b1cdce60a3e9a"
|
||||
dependencies = [
|
||||
"hex",
|
||||
"pallas-addresses",
|
||||
"pallas-codec",
|
||||
"pallas-crypto",
|
||||
"pallas-primitives",
|
||||
"pallas-traverse",
|
||||
"pallas-wallet",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"thiserror 1.0.69",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pallas-wallet"
|
||||
version = "0.32.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "086f428e68ab513a0445c23a345cd462dc925e37626f72f1dbb7276919f68bfa"
|
||||
dependencies = [
|
||||
"bech32",
|
||||
"bip39",
|
||||
"cryptoxide",
|
||||
"ed25519-bip32",
|
||||
"pallas-crypto",
|
||||
"rand 0.8.6",
|
||||
"thiserror 1.0.69",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "parking_lot"
|
||||
version = "0.12.5"
|
||||
|
|
|
|||
|
|
@ -39,6 +39,7 @@ pallas-codec = "0.32"
|
|||
pallas-crypto = "0.32"
|
||||
pallas-addresses = "0.32"
|
||||
pallas-txbuilder = "0.32"
|
||||
pallas-wallet = "0.32"
|
||||
pallas-network = "0.32"
|
||||
|
||||
# Mnemonic + key derivation.
|
||||
|
|
|
|||
|
|
@ -25,7 +25,7 @@ use async_trait::async_trait;
|
|||
use reqwest::Client;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::{Balance, ChainBackend, ChainError, Utxo};
|
||||
use crate::{Balance, ChainBackend, ChainError, TxStatus, Utxo};
|
||||
|
||||
/// Default timeout for a single Koios HTTP call. 10 s covers the
|
||||
/// public mainnet endpoint's worst case.
|
||||
|
|
@ -64,6 +64,22 @@ struct KoiosAddressInfo {
|
|||
utxo_set: Vec<KoiosUtxo>,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
struct TxHashesBody<'a> {
|
||||
#[serde(rename = "_tx_hashes")]
|
||||
tx_hashes: Vec<&'a str>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct KoiosTxInfo {
|
||||
#[allow(dead_code)]
|
||||
tx_hash: String,
|
||||
#[serde(default)]
|
||||
block_height: Option<u64>,
|
||||
#[serde(default)]
|
||||
epoch_no: Option<u64>,
|
||||
}
|
||||
|
||||
pub struct KoiosClient {
|
||||
base_url: String,
|
||||
http: Client,
|
||||
|
|
@ -170,6 +186,40 @@ impl ChainBackend for KoiosClient {
|
|||
}
|
||||
Ok(Balance { lovelace, assets })
|
||||
}
|
||||
|
||||
async fn submit_tx(&self, raw_tx_cbor: &[u8]) -> Result<String, ChainError> {
|
||||
// /submittx is special: body is raw CBOR bytes, not JSON.
|
||||
// Returns the tx hash as plain text on success.
|
||||
let response = self
|
||||
.http
|
||||
.post(self.url("submittx"))
|
||||
.header(reqwest::header::CONTENT_TYPE, "application/cbor")
|
||||
.body(raw_tx_cbor.to_vec())
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| ChainError::Network(e.to_string()))?
|
||||
.error_for_status()
|
||||
.map_err(|e| ChainError::Network(e.to_string()))?;
|
||||
let body = response
|
||||
.text()
|
||||
.await
|
||||
.map_err(|e| ChainError::Decode(e.to_string()))?;
|
||||
// Koios returns the tx hash as a quoted JSON string. Strip the
|
||||
// surrounding quotes if present.
|
||||
Ok(body.trim().trim_matches('"').to_string())
|
||||
}
|
||||
|
||||
async fn tx_status(&self, tx_hash: &str) -> Result<TxStatus, ChainError> {
|
||||
let body = TxHashesBody { tx_hashes: vec![tx_hash] };
|
||||
let raw: Vec<KoiosTxInfo> = self.post_json("tx_info", &body).await?;
|
||||
match raw.into_iter().next() {
|
||||
Some(info) => Ok(TxStatus::Confirmed {
|
||||
block_height: info.block_height,
|
||||
epoch: info.epoch_no,
|
||||
}),
|
||||
None => Ok(TxStatus::NotFound),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
|
|
@ -310,6 +360,42 @@ mod tests {
|
|||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn deserializes_tx_info_response() {
|
||||
const SAMPLE: &str = r#"[
|
||||
{
|
||||
"tx_hash": "deadbeef0000000000000000000000000000000000000000000000000000aaaa",
|
||||
"block_height": 12345678,
|
||||
"epoch_no": 480
|
||||
}
|
||||
]"#;
|
||||
let raw: Vec<KoiosTxInfo> = serde_json::from_str(SAMPLE).unwrap();
|
||||
assert_eq!(raw.len(), 1);
|
||||
assert_eq!(raw[0].block_height, Some(12345678));
|
||||
assert_eq!(raw[0].epoch_no, Some(480));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn deserializes_empty_tx_info_response() {
|
||||
let raw: Vec<KoiosTxInfo> = serde_json::from_str("[]").unwrap();
|
||||
assert!(raw.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tx_status_serializes_with_tag() {
|
||||
let confirmed = TxStatus::Confirmed {
|
||||
block_height: Some(100),
|
||||
epoch: Some(5),
|
||||
};
|
||||
let json = serde_json::to_string(&confirmed).unwrap();
|
||||
assert!(json.contains("\"status\":\"confirmed\""));
|
||||
assert!(json.contains("\"block_height\":100"));
|
||||
|
||||
let pending = TxStatus::NotFound;
|
||||
let json = serde_json::to_string(&pending).unwrap();
|
||||
assert!(json.contains("\"status\":\"not_found\""));
|
||||
}
|
||||
|
||||
/// Live network test against the public Koios mainnet endpoint.
|
||||
/// Marked `#[ignore]` so `cargo test` skips it; run with
|
||||
/// `cargo test -- --ignored live_koios_round_trip` to exercise.
|
||||
|
|
|
|||
|
|
@ -55,11 +55,39 @@ pub struct Balance {
|
|||
pub assets: std::collections::BTreeMap<String, u64>,
|
||||
}
|
||||
|
||||
/// Confirmation status of a submitted transaction.
|
||||
///
|
||||
/// `NotFound` covers two cases the chain backend can't easily
|
||||
/// distinguish: the tx is in some mempool but not yet indexed by
|
||||
/// Koios, or it was never submitted / was rejected. Treat
|
||||
/// `NotFound` as "keep polling" up to a reasonable timeout, after
|
||||
/// which give up.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[serde(tag = "status", rename_all = "snake_case")]
|
||||
pub enum TxStatus {
|
||||
/// Confirmed on-chain. `block_height` and `epoch` are populated
|
||||
/// when Koios returns them.
|
||||
Confirmed {
|
||||
block_height: Option<u64>,
|
||||
epoch: Option<u64>,
|
||||
},
|
||||
/// Not (yet) seen by the chain backend.
|
||||
NotFound,
|
||||
}
|
||||
|
||||
#[async_trait::async_trait]
|
||||
pub trait ChainBackend: Send + Sync {
|
||||
/// All UTXOs at the given address.
|
||||
async fn get_utxos(&self, address: &str) -> Result<Vec<Utxo>, ChainError>;
|
||||
|
||||
/// Aggregated ADA + native-asset balance at the address.
|
||||
async fn get_balance(&self, address: &str) -> Result<Balance, ChainError>;
|
||||
// Phase 2:
|
||||
// async fn submit_tx(&self, raw_tx_cbor: &[u8]) -> Result<String, ChainError>;
|
||||
// async fn tx_status(&self, tx_hash: &str) -> Result<TxStatus, ChainError>;
|
||||
|
||||
/// Submit a signed transaction. `raw_tx_cbor` is the binary CBOR
|
||||
/// payload of the signed tx; on success returns the tx hash as
|
||||
/// a hex string.
|
||||
async fn submit_tx(&self, raw_tx_cbor: &[u8]) -> Result<String, ChainError>;
|
||||
|
||||
/// Poll the chain backend for a tx's current confirmation status.
|
||||
async fn tx_status(&self, tx_hash: &str) -> Result<TxStatus, ChainError>;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -29,6 +29,8 @@ pallas-primitives = { workspace = true }
|
|||
pallas-codec = { workspace = true }
|
||||
pallas-crypto = { workspace = true }
|
||||
pallas-addresses = { workspace = true }
|
||||
pallas-txbuilder = { workspace = true }
|
||||
pallas-wallet = { workspace = true }
|
||||
bip39 = { workspace = true }
|
||||
ed25519-bip32 = { workspace = true }
|
||||
cryptoxide = { workspace = true }
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
472
crates/aldabra-core/src/tx.rs
Normal file
472
crates/aldabra-core/src/tx.rs
Normal 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:?}"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -23,6 +23,11 @@ pub struct Config {
|
|||
pub account: u32,
|
||||
pub index: u32,
|
||||
pub data_dir: PathBuf,
|
||||
/// Hard cap on outbound `wallet.send` lovelace. Tools must
|
||||
/// reject sends above this unless the caller passes `force=true`.
|
||||
/// Default 100 ADA (100_000_000 lovelace). Override via TOML or
|
||||
/// `ALDABRA_MAX_SEND_LOVELACE`.
|
||||
pub max_send_lovelace: u64,
|
||||
}
|
||||
|
||||
#[derive(Debug, Default, Deserialize)]
|
||||
|
|
@ -35,6 +40,8 @@ struct FileConfig {
|
|||
account: Option<u32>,
|
||||
#[serde(default)]
|
||||
index: Option<u32>,
|
||||
#[serde(default)]
|
||||
max_send_lovelace: Option<u64>,
|
||||
}
|
||||
|
||||
fn parse_network(s: &str) -> Result<Network, ConfigError> {
|
||||
|
|
@ -134,12 +141,21 @@ impl Config {
|
|||
Err(_) => file_cfg.index.unwrap_or(0),
|
||||
};
|
||||
|
||||
let max_send_lovelace = match std::env::var("ALDABRA_MAX_SEND_LOVELACE") {
|
||||
Ok(s) => s.parse::<u64>().map_err(|_| ConfigError::EnvParse {
|
||||
var: "ALDABRA_MAX_SEND_LOVELACE",
|
||||
value: s,
|
||||
})?,
|
||||
Err(_) => file_cfg.max_send_lovelace.unwrap_or(100_000_000),
|
||||
};
|
||||
|
||||
Ok(Self {
|
||||
network,
|
||||
koios_base,
|
||||
account,
|
||||
index,
|
||||
data_dir,
|
||||
max_send_lovelace,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -79,6 +79,8 @@ async fn run() -> Result<()> {
|
|||
cfg.account,
|
||||
cfg.index,
|
||||
)?;
|
||||
let payment_key =
|
||||
aldabra_core::derive_payment_key(&root, cfg.account, cfg.index);
|
||||
tracing::info!(%address, "derived base address");
|
||||
|
||||
if bootstrap_only {
|
||||
|
|
@ -88,7 +90,13 @@ async fn run() -> Result<()> {
|
|||
|
||||
// Hand off to the MCP server. From this point on stdin/stdout
|
||||
// belong to the JSON-RPC transport — no more eprintln-prompting.
|
||||
let service = WalletService::new(cfg.network, address, cfg.koios_base);
|
||||
let service = WalletService::new(
|
||||
cfg.network,
|
||||
address,
|
||||
cfg.koios_base,
|
||||
payment_key,
|
||||
cfg.max_send_lovelace,
|
||||
);
|
||||
let server = service
|
||||
.serve(stdio())
|
||||
.await
|
||||
|
|
|
|||
|
|
@ -1,19 +1,33 @@
|
|||
//! MCP tool handlers — Phase 1 read-path tools.
|
||||
//! MCP tool handlers.
|
||||
//!
|
||||
//! Each `#[tool]` becomes a discoverable MCP tool. Tool names use
|
||||
//! dotted notation per the MCP convention; the underlying Rust fn
|
||||
//! names use snake_case.
|
||||
//!
|
||||
//! ## Phase 1 — read path
|
||||
//!
|
||||
//! - `wallet.address` — bech32 base address
|
||||
//! - `wallet.network` — mainnet | preview | preprod
|
||||
//! - `wallet.balance` — JSON `{lovelace, assets}`
|
||||
//! - `wallet.utxos` — JSON list of UTXOs
|
||||
//!
|
||||
//! ## Phase 2 — send path
|
||||
//!
|
||||
//! - `wallet.send` — build + sign + submit ADA payment, with hard
|
||||
//! cap guard (`max_send_lovelace`)
|
||||
//! - `wallet.tx_status` — poll a submitted tx hash
|
||||
//!
|
||||
//! Returns:
|
||||
//! - `String` results pass through `IntoContents` directly.
|
||||
//! - `Result<String, String>` lets us surface chain errors as MCP
|
||||
//! tool-call errors instead of crashing the daemon.
|
||||
//! - `Result<String, String>` lets us surface chain / build errors
|
||||
//! as MCP tool-call errors instead of crashing the daemon.
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use aldabra_chain::{ChainBackend, KoiosClient};
|
||||
use aldabra_core::Network;
|
||||
use rmcp::{model::ServerInfo, tool, ServerHandler};
|
||||
use aldabra_core::{build_signed_payment, InputUtxo, Network, PaymentKey, ProtocolParams};
|
||||
use rmcp::{model::ServerInfo, schemars, tool, ServerHandler};
|
||||
use serde::Deserialize;
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct WalletService {
|
||||
|
|
@ -24,20 +38,48 @@ struct WalletInner {
|
|||
network: Network,
|
||||
address: String,
|
||||
chain: KoiosClient,
|
||||
payment_key: PaymentKey,
|
||||
max_send_lovelace: u64,
|
||||
}
|
||||
|
||||
impl WalletService {
|
||||
pub fn new(network: Network, address: String, koios_base: String) -> Self {
|
||||
pub fn new(
|
||||
network: Network,
|
||||
address: String,
|
||||
koios_base: String,
|
||||
payment_key: PaymentKey,
|
||||
max_send_lovelace: u64,
|
||||
) -> Self {
|
||||
Self {
|
||||
inner: Arc::new(WalletInner {
|
||||
network,
|
||||
address,
|
||||
chain: KoiosClient::new(koios_base),
|
||||
payment_key,
|
||||
max_send_lovelace,
|
||||
}),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, schemars::JsonSchema)]
|
||||
pub struct SendArgs {
|
||||
/// Recipient bech32 address.
|
||||
pub to_address: String,
|
||||
/// Amount to send in lovelace (1 ADA = 1_000_000 lovelace).
|
||||
pub lovelace: u64,
|
||||
/// Bypass the configured `max_send_lovelace` hard cap. Only
|
||||
/// pass `true` for an intentional, user-confirmed large send.
|
||||
#[serde(default)]
|
||||
pub force: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, schemars::JsonSchema)]
|
||||
pub struct TxStatusArgs {
|
||||
/// Hex-encoded transaction hash returned by `wallet.send`.
|
||||
pub tx_hash: String,
|
||||
}
|
||||
|
||||
#[tool(tool_box)]
|
||||
impl WalletService {
|
||||
#[tool(
|
||||
|
|
@ -87,6 +129,83 @@ impl WalletService {
|
|||
.map_err(|e| e.to_string())?;
|
||||
serde_json::to_string(&utxos).map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
#[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."
|
||||
)]
|
||||
async fn wallet_send(
|
||||
&self,
|
||||
#[tool(aggr)] SendArgs { to_address, lovelace, force }: SendArgs,
|
||||
) -> Result<String, String> {
|
||||
if lovelace == 0 {
|
||||
return Err("lovelace must be > 0".into());
|
||||
}
|
||||
if lovelace > self.inner.max_send_lovelace && !force {
|
||||
return Err(format!(
|
||||
"lovelace {lovelace} exceeds max_send_lovelace {}; pass force=true to override",
|
||||
self.inner.max_send_lovelace
|
||||
));
|
||||
}
|
||||
|
||||
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,
|
||||
})
|
||||
.collect();
|
||||
|
||||
let cbor = build_signed_payment(
|
||||
&self.inner.payment_key,
|
||||
self.inner.network,
|
||||
&inputs,
|
||||
&self.inner.address,
|
||||
&to_address,
|
||||
lovelace,
|
||||
&ProtocolParams::default(),
|
||||
)
|
||||
.map_err(|e| format!("build/sign: {e}"))?;
|
||||
|
||||
let tx_hash = self
|
||||
.inner
|
||||
.chain
|
||||
.submit_tx(&cbor)
|
||||
.await
|
||||
.map_err(|e| format!("submit: {e}"))?;
|
||||
Ok(tx_hash)
|
||||
}
|
||||
|
||||
#[tool(
|
||||
name = "wallet.tx_status",
|
||||
description = "Poll a submitted transaction's confirmation status. Args: tx_hash (hex). Returns JSON {status: confirmed|not_found, block_height?, epoch?}."
|
||||
)]
|
||||
async fn wallet_tx_status(
|
||||
&self,
|
||||
#[tool(aggr)] TxStatusArgs { tx_hash }: TxStatusArgs,
|
||||
) -> Result<String, String> {
|
||||
let status = self
|
||||
.inner
|
||||
.chain
|
||||
.tx_status(&tx_hash)
|
||||
.await
|
||||
.map_err(|e| e.to_string())?;
|
||||
serde_json::to_string(&status).map_err(|e| e.to_string())
|
||||
}
|
||||
}
|
||||
|
||||
#[tool(tool_box)]
|
||||
|
|
@ -94,26 +213,9 @@ impl ServerHandler for WalletService {
|
|||
fn get_info(&self) -> ServerInfo {
|
||||
ServerInfo {
|
||||
instructions: Some(
|
||||
"aldabra — Cardano lite wallet over MCP. Phase 1 (read path): wallet.address, wallet.network, wallet.balance, wallet.utxos. Spending tools land in phase 2.".into(),
|
||||
"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(),
|
||||
),
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn service_constructs_and_clones() {
|
||||
let svc = WalletService::new(
|
||||
Network::Preprod,
|
||||
"addr_test1qxyz".into(),
|
||||
"https://preprod.koios.rest/api/v1".into(),
|
||||
);
|
||||
let cloned = svc.clone();
|
||||
// Arc<WalletInner> means clone is cheap and shares state.
|
||||
assert_eq!(svc.inner.address, cloned.inner.address);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue