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
2b4fdff0c0
commit
44bae07bc9
11 changed files with 821 additions and 29 deletions
|
|
@ -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