preprod live-test fixes: 4 real bugs surfaced in real-koios + chain integration

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<T> can't deserialize null, killing
the entire utxo response. Option<Vec<T>>.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.
This commit is contained in:
Cobb 2026-05-04 16:57:40 -07:00
parent 36bbd8033f
commit 05292f182e
2 changed files with 78 additions and 8 deletions

View file

@ -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<Vec<...>>` because Koios's `/address_utxos` returns
/// `asset_list: null` for ADA-only UTXOs (vs `/address_info`
/// which returns `[]`). `Vec<T>` rejects `null`; `Option<Vec<T>>`
/// accepts both. Found at integration time on live preprod
/// 2026-05-04 — our hand-crafted test fixtures all used `[]`.
#[serde(default)]
asset_list: Vec<KoiosAsset>,
asset_list: Option<Vec<KoiosAsset>>,
}
#[derive(Deserialize)]
@ -147,7 +166,7 @@ fn asset_key(policy_id: &str, asset_name_hex: &str) -> String {
fn convert_utxo(k: KoiosUtxo) -> Result<Utxo, ChainError> {
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<Utxo, ChainError> {
#[async_trait]
impl ChainBackend for KoiosClient {
async fn get_utxos(&self, address: &str) -> Result<Vec<Utxo>, 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<KoiosUtxo> = 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<String, u64> = 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<KoiosUtxo> = serde_json::from_str(SAMPLE).unwrap();
let utxos: Vec<Utxo> = raw
.into_iter()
.map(convert_utxo)
.collect::<Result<_, _>>()
.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<KoiosUtxo> = serde_json::from_str(SAMPLE_UTXOS).unwrap();
@ -351,7 +414,7 @@ mod tests {
let info = raw.into_iter().next().unwrap();
let mut assets: BTreeMap<String, u64> = 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);

View file

@ -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)]