diff --git a/crates/aldabra-chain/src/koios.rs b/crates/aldabra-chain/src/koios.rs index 6bcd4d9..2157f00 100644 --- a/crates/aldabra-chain/src/koios.rs +++ b/crates/aldabra-chain/src/koios.rs @@ -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, - #[serde(default)] - epoch_no: Option, + num_confirmations: Option, } pub struct KoiosClient { @@ -265,14 +268,11 @@ impl ChainBackend for KoiosClient { async fn tx_status(&self, tx_hash: &str) -> Result { let body = TxHashesBody { tx_hashes: vec![tx_hash] }; - let raw: Vec = self.post_json("tx_info", &body).await?; + let raw: Vec = 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 = 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 = 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 = 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 = 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 = serde_json::from_str(omitted).unwrap(); + assert_eq!(v[0].num_confirmations, None); + + let empty = r#"[]"#; + let v: Vec = 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. diff --git a/crates/aldabra-chain/src/lib.rs b/crates/aldabra-chain/src/lib.rs index 82657d4..c1f2d30 100644 --- a/crates/aldabra-chain/src/lib.rs +++ b/crates/aldabra-chain/src/lib.rs @@ -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, - }, - /// 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, } diff --git a/crates/aldabra-mcp/src/tools.rs b/crates/aldabra-mcp/src/tools.rs index e04f2d5..256bb6b 100644 --- a/crates/aldabra-mcp/src/tools.rs +++ b/crates/aldabra-mcp/src/tools.rs @@ -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,