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

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

View file

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