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
f23ff65dad
commit
47b63f2024
3 changed files with 52 additions and 49 deletions
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue