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:
Kayos 2026-05-04 20:45:10 -07:00
parent f23ff65dad
commit 47b63f2024
3 changed files with 52 additions and 49 deletions

View file

@ -89,14 +89,17 @@ struct TxHashesBody<'a> {
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)]
struct KoiosTxInfo {
struct KoiosTxStatusResp {
#[allow(dead_code)]
tx_hash: String,
#[serde(default)]
block_height: Option<u64>,
#[serde(default)]
epoch_no: Option<u64>,
num_confirmations: Option<u64>,
}
pub struct KoiosClient {
@ -265,14 +268,11 @@ impl ChainBackend for KoiosClient {
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?;
let raw: Vec<KoiosTxStatusResp> = self.post_json("tx_status", &body).await?;
match raw.into_iter().next() {
Some(info) => match info.block_height {
Some(h) => Ok(TxStatus::Confirmed {
block_height: h,
epoch: info.epoch_no,
}),
None => Ok(TxStatus::Pending),
Some(info) => match info.num_confirmations {
Some(n) if n > 0 => Ok(TxStatus::Confirmed { num_confirmations: n }),
Some(_) | None => Ok(TxStatus::Pending),
},
None => Ok(TxStatus::NotFound),
}
@ -452,36 +452,17 @@ 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());
}
// (the deserializes_tx_info_response / deserializes_empty_tx_info_response
// tests were dropped along with KoiosTxInfo when AUDIT4-1 swapped tx_status
// from /tx_info to /tx_status. Coverage is now in
// parses_koios_tx_status_shapes below.)
#[test]
fn tx_status_serializes_with_tag() {
let confirmed = TxStatus::Confirmed {
block_height: 100,
epoch: Some(5),
};
let confirmed = TxStatus::Confirmed { num_confirmations: 17 };
let json = serde_json::to_string(&confirmed).unwrap();
assert!(json.contains("\"status\":\"confirmed\""));
assert!(json.contains("\"block_height\":100"));
assert!(json.contains("\"num_confirmations\":17"));
let pending = TxStatus::Pending;
let json = serde_json::to_string(&pending).unwrap();
@ -492,6 +473,31 @@ mod tests {
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.
/// Marked `#[ignore]` so `cargo test` skips it; run with
/// `cargo test -- --ignored live_koios_round_trip` to exercise.

View file

@ -65,19 +65,16 @@ pub struct Balance {
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(tag = "status", rename_all = "snake_case")]
pub enum TxStatus {
/// Confirmed on-chain with a block height.
Confirmed {
block_height: u64,
epoch: Option<u64>,
},
/// Koios returned a record but no block_height — the tx is in
/// the chain backend's mempool but not yet in a confirmed block.
/// (L-3 audit fix: previously this case was lumped in with
/// Confirmed and rendered as `Confirmed { block_height: None }`,
/// which is misleading.)
/// Confirmed on-chain with `num_confirmations` blocks built on
/// top of it (1 = just landed, ~15 = practically final on
/// preprod, ≥3 stake-pool epochs = absolute finality on mainnet).
Confirmed { num_confirmations: u64 },
/// Koios's `/tx_status` returned a record for this tx but with
/// `num_confirmations: null` — the tx is known to the backend
/// (some node accepted it) but is not yet in a block.
Pending,
/// Not seen by the chain backend (not in mempool, not confirmed,
/// possibly never submitted or rejected).
/// Not seen by the chain backend (not in any node's pool,
/// not confirmed, possibly never submitted or rejected).
NotFound,
}

View file

@ -466,7 +466,7 @@ impl WalletService {
#[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?}."
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(
&self,