From 05292f182eba64f931721f5897fda06aa8effebe Mon Sep 17 00:00:00 2001 From: Cobb Date: Mon, 4 May 2026 16:57:40 -0700 Subject: [PATCH] preprod live-test fixes: 4 real bugs surfaced in real-koios + chain integration MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit discovered during preprod smoke 2026-05-04 — 7 txs submitted (3 sends, 2 mints, 1 cip68 nft mint, 1 burn). all confirmed on chain. unit-test coverage missed these because hand-crafted koios fixtures didn't match real-world response shapes. bugs: PREPROD-1 (HIGH) — KoiosUtxo::asset_list deserializer rejected `null`. real /address_utxos returns asset_list:null for ada-only utxos (vs /address_info which returns []). Vec can't deserialize null, killing the entire utxo response. Option>.unwrap_or_default fixes it + new regression test deserializes_utxo_with_null_asset_list locks it in. PREPROD-2 (HIGH) — /address_utxos needs `_extended: true` to populate asset_list. without it, koios returns asset_list:[] (or null) for asset-bearing utxos, making the wallet think it has zero of its own tokens. native-asset send fails with "insufficient asset". new AddressesExtendedBody serializer; get_utxos sets _extended=true. PREPROD-3 (MEDIUM) — wallet_mint_cip68_nft default lovelace was 1.5 ADA but the babbage min-utxo formula for inline-datum-bearing outputs clears ~1.79 ADA. chain rejected with BabbageOutputTooSmallUTxO. bumped default_token_lovelace 1_500_000 → 2_500_000 (covers typical cip-68 metadata; large metadata still requires caller override). PREPROD-4 (LOW, audit-process) — submit_tx error path called .error_for_status() which discards koios's response body. chain-rule rejections came through as bare HTTP codes, no diagnostic. now we capture status + body before checking; rejections include the actual ledger error (e.g. BabbageOutputTooSmallUTxO with the offending coin amounts) so future debugging is one-shot. 7 successful preprod txs: - e3e52cf9 self-send 3 ADA - 397fe6b7 self-send 5 ADA via cold-sign flow (build_unsigned → tx_summary → sign_partial → submit_signed_tx; predicted tx_hash matched submitted tx_hash, body invariant under signing confirmed) - d23e4c60 mint 100 ALDABRA_TEST with CIP-25 metadata - 25cc489c mint cip-68 nft pair (ref label 100 + user label 222) - 2ce72b6f mint 50 more ALDABRA_TEST via unsigned-mint flow - 19a909df native-asset send (25 ALDABRA_TEST + 5 ADA) - f949d29c burn 10 ALDABRA_TEST (negative-quantity mint) guards verified: - max_send_lovelace cap rejects 200 ADA without force ✓ - mint with insufficient holdings rejected with clear error ✓ - mcp tool names with dots silently dropped by Claude Code validator (already fixed in previous commit by renaming to underscore-only) 94 unit tests pass. --- crates/aldabra-chain/src/koios.rs | 77 ++++++++++++++++++++++++++++--- crates/aldabra-mcp/src/tools.rs | 9 +++- 2 files changed, 78 insertions(+), 8 deletions(-) diff --git a/crates/aldabra-chain/src/koios.rs b/crates/aldabra-chain/src/koios.rs index b400844..6bcd4d9 100644 --- a/crates/aldabra-chain/src/koios.rs +++ b/crates/aldabra-chain/src/koios.rs @@ -37,6 +37,20 @@ struct AddressesBody<'a> { addresses: Vec<&'a str>, } +/// Same as [`AddressesBody`] but with the `_extended` flag set. +/// Koios's `/address_utxos` returns `asset_list: null` (or empty) +/// without it; with it, the per-utxo asset bundles come through +/// reliably. Discovered preprod 2026-05-04 — without this flag the +/// wallet sees its own asset-bearing UTXOs as ada-only and refuses +/// to construct a multi-asset send. +#[derive(Serialize)] +struct AddressesExtendedBody<'a> { + #[serde(rename = "_addresses")] + addresses: Vec<&'a str>, + #[serde(rename = "_extended")] + extended: bool, +} + #[derive(Deserialize)] struct KoiosAsset { policy_id: String, @@ -52,8 +66,13 @@ struct KoiosUtxo { tx_index: u32, /// Lovelace at this UTXO, uint64 in a string. value: String, + /// `Option>` because Koios's `/address_utxos` returns + /// `asset_list: null` for ADA-only UTXOs (vs `/address_info` + /// which returns `[]`). `Vec` rejects `null`; `Option>` + /// accepts both. Found at integration time on live preprod + /// 2026-05-04 — our hand-crafted test fixtures all used `[]`. #[serde(default)] - asset_list: Vec, + asset_list: Option>, } #[derive(Deserialize)] @@ -147,7 +166,7 @@ fn asset_key(policy_id: &str, asset_name_hex: &str) -> String { fn convert_utxo(k: KoiosUtxo) -> Result { let lovelace = parse_u64(&k.value, "utxo.value")?; let mut assets = BTreeMap::new(); - for a in k.asset_list { + for a in k.asset_list.unwrap_or_default() { let qty = parse_u64(&a.quantity, "utxo.asset.quantity")?; assets.insert(asset_key(&a.policy_id, &a.asset_name), qty); } @@ -162,7 +181,15 @@ fn convert_utxo(k: KoiosUtxo) -> Result { #[async_trait] impl ChainBackend for KoiosClient { async fn get_utxos(&self, address: &str) -> Result, ChainError> { - let body = AddressesBody { addresses: vec![address] }; + // `_extended: true` is required for the per-utxo asset_list + // to populate. Without it Koios returns `asset_list: null` or + // `[]` even when the utxo carries native assets, which makes + // the wallet's selection algorithm think it has zero of any + // token. + let body = AddressesExtendedBody { + addresses: vec![address], + extended: true, + }; let raw: Vec = self.post_json("address_utxos", &body).await?; raw.into_iter().map(convert_utxo).collect() } @@ -184,7 +211,7 @@ impl ChainBackend for KoiosClient { let lovelace = parse_u64(&info.balance, "address_info.balance")?; let mut assets: BTreeMap = BTreeMap::new(); for u in info.utxo_set { - for a in u.asset_list { + for a in u.asset_list.unwrap_or_default() { let qty = parse_u64(&a.quantity, "address_info.utxo.asset.quantity")?; let key = asset_key(&a.policy_id, &a.asset_name); let entry = assets.entry(key).or_insert(0); @@ -204,13 +231,24 @@ impl ChainBackend for KoiosClient { .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()))?; + // Capture status + body BEFORE bubbling up — koios's chain-rule + // rejection messages live in the response body and are + // otherwise eaten by `.error_for_status()`. Discovered during + // preprod cip-68 mint debugging 2026-05-04: a 400 with no + // surfaced body left us guessing at why the chain rejected the tx. + let status = response.status(); let body = response .text() .await .map_err(|e| ChainError::Decode(e.to_string()))?; + if !status.is_success() { + return Err(ChainError::Network(format!( + "submittx HTTP {}: {}", + status.as_u16(), + body.trim() + ))); + } // Koios returns the tx hash as a quoted JSON string. Strip the // surrounding quotes if present, then validate the result is // exactly 64 hex chars. @@ -297,6 +335,31 @@ mod tests { } ]"#; + /// Real Koios `/address_utxos` returns `asset_list: null` for + /// ada-only utxos (vs `/address_info` which returns `[]`). + /// Regression test — caught at preprod integration time + /// 2026-05-04 after our hand-crafted fixtures all used `[]`. + #[test] + fn deserializes_utxo_with_null_asset_list() { + const SAMPLE: &str = r#"[ + { + "tx_hash": "c22c9ccc165091819673101e8e49e7daed559fad838bbf08fd8e5b9305cf1e60", + "tx_index": 0, + "value": "10000000000", + "asset_list": null + } + ]"#; + let raw: Vec = serde_json::from_str(SAMPLE).unwrap(); + let utxos: Vec = raw + .into_iter() + .map(convert_utxo) + .collect::>() + .unwrap(); + assert_eq!(utxos.len(), 1); + assert_eq!(utxos[0].lovelace, 10_000_000_000); + assert!(utxos[0].assets.is_empty()); + } + #[test] fn deserializes_utxo_response() { let raw: Vec = serde_json::from_str(SAMPLE_UTXOS).unwrap(); @@ -351,7 +414,7 @@ mod tests { let info = raw.into_iter().next().unwrap(); let mut assets: BTreeMap = BTreeMap::new(); for u in info.utxo_set { - for a in u.asset_list { + for a in u.asset_list.unwrap_or_default() { let qty = parse_u64(&a.quantity, "test").unwrap(); let key = asset_key(&a.policy_id, &a.asset_name); let entry = assets.entry(key).or_insert(0); diff --git a/crates/aldabra-mcp/src/tools.rs b/crates/aldabra-mcp/src/tools.rs index 6351d00..a381964 100644 --- a/crates/aldabra-mcp/src/tools.rs +++ b/crates/aldabra-mcp/src/tools.rs @@ -298,7 +298,14 @@ pub struct Cip68NftArgs { } fn default_token_lovelace() -> u64 { - 1_500_000 + // 2.5 ADA — Babbage min-utxo for an inline-datum-bearing + // multi-asset output is ~1.79 ADA (depends on datum size). + // 1.5 was too low; 2.5 gives comfortable margin for typical + // CIP-68 metadata (~150 bytes). Larger metadata still requires + // the caller to override. + // Discovered preprod 2026-05-04 via + // BabbageOutputTooSmallUTxO chain rejection. + 2_500_000 } #[derive(Debug, Deserialize, schemars::JsonSchema)]