diff --git a/Cargo.lock b/Cargo.lock index 9284ea2..0b79ae1 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -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" diff --git a/Cargo.toml b/Cargo.toml index 49f77cd..ffda68e 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -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. diff --git a/crates/aldabra-chain/src/koios.rs b/crates/aldabra-chain/src/koios.rs index 0b21c59..0bc00c2 100644 --- a/crates/aldabra-chain/src/koios.rs +++ b/crates/aldabra-chain/src/koios.rs @@ -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, } +#[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, + #[serde(default)] + epoch_no: Option, +} + 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 { + // /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 { + let body = TxHashesBody { tx_hashes: vec![tx_hash] }; + let raw: Vec = 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 = 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 = 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. diff --git a/crates/aldabra-chain/src/lib.rs b/crates/aldabra-chain/src/lib.rs index eecb81b..f5dfe7f 100644 --- a/crates/aldabra-chain/src/lib.rs +++ b/crates/aldabra-chain/src/lib.rs @@ -55,11 +55,39 @@ pub struct Balance { pub assets: std::collections::BTreeMap, } +/// 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, + epoch: Option, + }, + /// 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, ChainError>; + + /// Aggregated ADA + native-asset balance at the address. async fn get_balance(&self, address: &str) -> Result; - // Phase 2: - // async fn submit_tx(&self, raw_tx_cbor: &[u8]) -> Result; - // async fn tx_status(&self, tx_hash: &str) -> Result; + + /// 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; + + /// Poll the chain backend for a tx's current confirmation status. + async fn tx_status(&self, tx_hash: &str) -> Result; } diff --git a/crates/aldabra-core/Cargo.toml b/crates/aldabra-core/Cargo.toml index fd3aabe..1e6bae5 100644 --- a/crates/aldabra-core/Cargo.toml +++ b/crates/aldabra-core/Cargo.toml @@ -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 } diff --git a/crates/aldabra-core/src/derive.rs b/crates/aldabra-core/src/derive.rs index c430ab2..b20e135 100644 --- a/crates/aldabra-core/src/derive.rs +++ b/crates/aldabra-core/src/derive.rs @@ -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 diff --git a/crates/aldabra-core/src/lib.rs b/crates/aldabra-core/src/lib.rs index c3f8d19..2c8afe6 100644 --- a/crates/aldabra-core/src/lib.rs +++ b/crates/aldabra-core/src/lib.rs @@ -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 { diff --git a/crates/aldabra-core/src/tx.rs b/crates/aldabra-core/src/tx.rs new file mode 100644 index 0000000..9df9d2c --- /dev/null +++ b/crates/aldabra-core/src/tx.rs @@ -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, 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 = available.to_vec(); + sorted.sort_by(|a, b| b.lovelace.cmp(&a.lovelace)); + + let mut acc: u64 = 0; + let mut chosen: Vec = 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::from_bech32(bech32).map_err(|e| WalletError::Address(e.to_string())) +} + +fn parse_tx_hash(hex_str: &str) -> Result, 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 { + 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 { + 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, 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, 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, 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:?}"), + } + } +} diff --git a/crates/aldabra-mcp/src/config.rs b/crates/aldabra-mcp/src/config.rs index b2dfbbc..76f10b6 100644 --- a/crates/aldabra-mcp/src/config.rs +++ b/crates/aldabra-mcp/src/config.rs @@ -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, #[serde(default)] index: Option, + #[serde(default)] + max_send_lovelace: Option, } fn parse_network(s: &str) -> Result { @@ -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::().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, }) } } diff --git a/crates/aldabra-mcp/src/main.rs b/crates/aldabra-mcp/src/main.rs index 90df55d..09c00b3 100644 --- a/crates/aldabra-mcp/src/main.rs +++ b/crates/aldabra-mcp/src/main.rs @@ -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 diff --git a/crates/aldabra-mcp/src/tools.rs b/crates/aldabra-mcp/src/tools.rs index 2c87ceb..5935382 100644 --- a/crates/aldabra-mcp/src/tools.rs +++ b/crates/aldabra-mcp/src/tools.rs @@ -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` lets us surface chain errors as MCP -//! tool-call errors instead of crashing the daemon. +//! - `Result` 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 { + 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 = 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 { + 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 means clone is cheap and shares state. - assert_eq!(svc.inner.address, cloned.inner.address); - } -}