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:
parent
36bbd8033f
commit
05292f182e
2 changed files with 78 additions and 8 deletions
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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)]
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue