AUDIT4-1 fix: switch tx_status from Koios /tx_info to /tx_status
The old impl called Koios /tx_info to learn confirmation state. For
confirmed txs that endpoint streams the full tx body — multi-MB on
complex txs, hundreds of KB on trivial ones — and the public Koios
endpoint either rate-limits or chunks slowly enough to escape our
10s reqwest timeout. Result: wallet_tx_status hung 120s+ and the
container subprocess died, surfaced 2026-05-04 audit-4 phase C7.
Fix: call the lighter /tx_status endpoint, which returns a single
{tx_hash, num_confirmations} record per tx — bytes, not MB.
API change: TxStatus::Confirmed { block_height, epoch } becomes
TxStatus::Confirmed { num_confirmations }. The endpoint doesn't
return block_height / epoch anyway; num_confirmations is what
callers actually want for polling-until-final flows. wallet_tx_status
docstring updated to spell out the three returnable shapes.
Tests: drops the KoiosTxInfo-shape unit tests, adds
parses_koios_tx_status_shapes covering the three live response
shapes we observed (confirmed-with-count, known-but-no-confs,
empty array).
This commit is contained in:
parent
6ed378a486
commit
7cb70db12b
3 changed files with 52 additions and 49 deletions
|
|
@ -89,14 +89,17 @@ struct TxHashesBody<'a> {
|
||||||
tx_hashes: Vec<&'a str>,
|
tx_hashes: Vec<&'a str>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Response shape from Koios `/api/v1/tx_status`. Tiny — only a
|
||||||
|
/// confirmations counter per requested tx — vs `/tx_info` which
|
||||||
|
/// streams the full tx body (multi-MB for complex confirmed txs).
|
||||||
|
/// AUDIT4-1: switching to `/tx_status` resolves the 120s+ hang on
|
||||||
|
/// confirmed-tx queries surfaced 2026-05-04.
|
||||||
#[derive(Deserialize)]
|
#[derive(Deserialize)]
|
||||||
struct KoiosTxInfo {
|
struct KoiosTxStatusResp {
|
||||||
#[allow(dead_code)]
|
#[allow(dead_code)]
|
||||||
tx_hash: String,
|
tx_hash: String,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
block_height: Option<u64>,
|
num_confirmations: Option<u64>,
|
||||||
#[serde(default)]
|
|
||||||
epoch_no: Option<u64>,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
pub struct KoiosClient {
|
pub struct KoiosClient {
|
||||||
|
|
@ -265,14 +268,11 @@ impl ChainBackend for KoiosClient {
|
||||||
|
|
||||||
async fn tx_status(&self, tx_hash: &str) -> Result<TxStatus, ChainError> {
|
async fn tx_status(&self, tx_hash: &str) -> Result<TxStatus, ChainError> {
|
||||||
let body = TxHashesBody { tx_hashes: vec![tx_hash] };
|
let body = TxHashesBody { tx_hashes: vec![tx_hash] };
|
||||||
let raw: Vec<KoiosTxInfo> = self.post_json("tx_info", &body).await?;
|
let raw: Vec<KoiosTxStatusResp> = self.post_json("tx_status", &body).await?;
|
||||||
match raw.into_iter().next() {
|
match raw.into_iter().next() {
|
||||||
Some(info) => match info.block_height {
|
Some(info) => match info.num_confirmations {
|
||||||
Some(h) => Ok(TxStatus::Confirmed {
|
Some(n) if n > 0 => Ok(TxStatus::Confirmed { num_confirmations: n }),
|
||||||
block_height: h,
|
Some(_) | None => Ok(TxStatus::Pending),
|
||||||
epoch: info.epoch_no,
|
|
||||||
}),
|
|
||||||
None => Ok(TxStatus::Pending),
|
|
||||||
},
|
},
|
||||||
None => Ok(TxStatus::NotFound),
|
None => Ok(TxStatus::NotFound),
|
||||||
}
|
}
|
||||||
|
|
@ -452,36 +452,17 @@ mod tests {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
// (the deserializes_tx_info_response / deserializes_empty_tx_info_response
|
||||||
fn deserializes_tx_info_response() {
|
// tests were dropped along with KoiosTxInfo when AUDIT4-1 swapped tx_status
|
||||||
const SAMPLE: &str = r#"[
|
// from /tx_info to /tx_status. Coverage is now in
|
||||||
{
|
// parses_koios_tx_status_shapes below.)
|
||||||
"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]
|
#[test]
|
||||||
fn tx_status_serializes_with_tag() {
|
fn tx_status_serializes_with_tag() {
|
||||||
let confirmed = TxStatus::Confirmed {
|
let confirmed = TxStatus::Confirmed { num_confirmations: 17 };
|
||||||
block_height: 100,
|
|
||||||
epoch: Some(5),
|
|
||||||
};
|
|
||||||
let json = serde_json::to_string(&confirmed).unwrap();
|
let json = serde_json::to_string(&confirmed).unwrap();
|
||||||
assert!(json.contains("\"status\":\"confirmed\""));
|
assert!(json.contains("\"status\":\"confirmed\""));
|
||||||
assert!(json.contains("\"block_height\":100"));
|
assert!(json.contains("\"num_confirmations\":17"));
|
||||||
|
|
||||||
let pending = TxStatus::Pending;
|
let pending = TxStatus::Pending;
|
||||||
let json = serde_json::to_string(&pending).unwrap();
|
let json = serde_json::to_string(&pending).unwrap();
|
||||||
|
|
@ -492,6 +473,31 @@ mod tests {
|
||||||
assert!(json.contains("\"status\":\"not_found\""));
|
assert!(json.contains("\"status\":\"not_found\""));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// AUDIT4-1 regression: parse the three live Koios `/tx_status`
|
||||||
|
/// shapes we observed during the 2026-05-04 preprod test —
|
||||||
|
/// confirmed-with-count, known-but-no-confs (mempool), and
|
||||||
|
/// nothing-to-report (truly unknown).
|
||||||
|
#[test]
|
||||||
|
fn parses_koios_tx_status_shapes() {
|
||||||
|
let confirmed = r#"[{"tx_hash":"abcd","num_confirmations":7}]"#;
|
||||||
|
let v: Vec<KoiosTxStatusResp> = serde_json::from_str(confirmed).unwrap();
|
||||||
|
assert_eq!(v[0].num_confirmations, Some(7));
|
||||||
|
|
||||||
|
let pending = r#"[{"tx_hash":"abcd","num_confirmations":null}]"#;
|
||||||
|
let v: Vec<KoiosTxStatusResp> = serde_json::from_str(pending).unwrap();
|
||||||
|
assert_eq!(v[0].num_confirmations, None);
|
||||||
|
|
||||||
|
// Some Koios deployments omit num_confirmations entirely
|
||||||
|
// for unknown txs rather than emitting null.
|
||||||
|
let omitted = r#"[{"tx_hash":"abcd"}]"#;
|
||||||
|
let v: Vec<KoiosTxStatusResp> = serde_json::from_str(omitted).unwrap();
|
||||||
|
assert_eq!(v[0].num_confirmations, None);
|
||||||
|
|
||||||
|
let empty = r#"[]"#;
|
||||||
|
let v: Vec<KoiosTxStatusResp> = serde_json::from_str(empty).unwrap();
|
||||||
|
assert!(v.is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
/// Live network test against the public Koios mainnet endpoint.
|
/// Live network test against the public Koios mainnet endpoint.
|
||||||
/// Marked `#[ignore]` so `cargo test` skips it; run with
|
/// Marked `#[ignore]` so `cargo test` skips it; run with
|
||||||
/// `cargo test -- --ignored live_koios_round_trip` to exercise.
|
/// `cargo test -- --ignored live_koios_round_trip` to exercise.
|
||||||
|
|
|
||||||
|
|
@ -65,19 +65,16 @@ pub struct Balance {
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||||
#[serde(tag = "status", rename_all = "snake_case")]
|
#[serde(tag = "status", rename_all = "snake_case")]
|
||||||
pub enum TxStatus {
|
pub enum TxStatus {
|
||||||
/// Confirmed on-chain with a block height.
|
/// Confirmed on-chain with `num_confirmations` blocks built on
|
||||||
Confirmed {
|
/// top of it (1 = just landed, ~15 = practically final on
|
||||||
block_height: u64,
|
/// preprod, ≥3 stake-pool epochs = absolute finality on mainnet).
|
||||||
epoch: Option<u64>,
|
Confirmed { num_confirmations: u64 },
|
||||||
},
|
/// Koios's `/tx_status` returned a record for this tx but with
|
||||||
/// Koios returned a record but no block_height — the tx is in
|
/// `num_confirmations: null` — the tx is known to the backend
|
||||||
/// the chain backend's mempool but not yet in a confirmed block.
|
/// (some node accepted it) but is not yet in a block.
|
||||||
/// (L-3 audit fix: previously this case was lumped in with
|
|
||||||
/// Confirmed and rendered as `Confirmed { block_height: None }`,
|
|
||||||
/// which is misleading.)
|
|
||||||
Pending,
|
Pending,
|
||||||
/// Not seen by the chain backend (not in mempool, not confirmed,
|
/// Not seen by the chain backend (not in any node's pool,
|
||||||
/// possibly never submitted or rejected).
|
/// not confirmed, possibly never submitted or rejected).
|
||||||
NotFound,
|
NotFound,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -466,7 +466,7 @@ impl WalletService {
|
||||||
|
|
||||||
#[tool(
|
#[tool(
|
||||||
name = "wallet_tx_status",
|
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?}."
|
description = "Poll a submitted transaction's confirmation status. Args: tx_hash (hex). Returns JSON {status: confirmed|pending|not_found, num_confirmations?}. `confirmed` means the tx has at least 1 block built on top; `pending` means a node has accepted it but it's not yet in a block; `not_found` means no node knows about it (rejected, never submitted, or still propagating)."
|
||||||
)]
|
)]
|
||||||
async fn wallet_tx_status(
|
async fn wallet_tx_status(
|
||||||
&self,
|
&self,
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue