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

@ -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,
})
}
}

View file

@ -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

View file

@ -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);
}
}