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
|
|
@ -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>;
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue