From 1268dc0beba34922d55183a0c899d3bfe10c8d79 Mon Sep 17 00:00:00 2001 From: TaprootFreak <142087526+TaprootFreak@users.noreply.github.com> Date: Mon, 1 Jun 2026 22:07:14 +0200 Subject: [PATCH] fix(api/history): include coin_queue in balance read so first mint surfaces as 50k delta MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `balance_from_account_blob` previously read only `Account.balance`, which is the *settled* balance after sends. The mint and receive paths push the credited coin into `coin_queue` without touching the `balance` field — `Account::get_balance()` is the only call that sums both. Reading just `balance` here made every first-mint history row collapse to `new_balance = 0, prev_balance = 0, amount = 0`, so the wire item reported `amount = 0` for a 50_000-sat credit. The existing unit test masked this because its fixture set `a.balance = 5_000` directly, a shape no production caller produces on the mint or receive path. That test now documents that it pins the settled-balance variant (a valid post-send shape), and a new sibling test `history_row_to_item_balance_from_coin_queue_only` in `account_node_tests` walks the real mint flow (`execute_send_coins` + `receive_coin`) to pin the previously- uncovered queue-only case end to end — including a direct assertion on `balance_from_account_blob` itself. E2E (api_remote::history_after_mint_records_mint_row) flagged this against dev-api on PR #166 (Release develop->main). --- node/src/account_node_tests.rs | 102 +++++++++++++++++++++++++++++++++ node/src/router.rs | 25 ++++++-- node/src/router_tests.rs | 8 +++ 3 files changed, 131 insertions(+), 4 deletions(-) diff --git a/node/src/account_node_tests.rs b/node/src/account_node_tests.rs index b578fa5a..5e959b20 100644 --- a/node/src/account_node_tests.rs +++ b/node/src/account_node_tests.rs @@ -1389,3 +1389,105 @@ fn warmup_prover_completes_successfully() { node.warmup_prover() .expect("warmup_prover must succeed on a fresh AccountNode"); } + +/// Pins the **queue-only** shape produced by the production mint / +/// receive paths: the credited coin lives in `Account.coin_queue` while +/// `Account.balance` remains `0` until a subsequent send drains the +/// queue. `router::balance_from_account_blob` must mirror +/// `Account::get_balance()` and surface the sum, otherwise the +/// `/api/history` row for a first mint reports `amount = 0` (the bug +/// the `history_after_mint_records_mint_row` E2E flagged on PR #166). +/// +/// Lives in `account_node_tests` because constructing a realistic +/// `CoinProof` requires the full prover + state fixtures — the lighter +/// settled-balance shape (`balance > 0, coin_queue == []`) is still +/// covered in `router_tests::history_row_to_item_handles_first_row_with_no_prev_data`. +#[test] +fn history_row_to_item_balance_from_coin_queue_only() { + let state_arc = Arc::new(Mutex::new(State::new())); + let mut node = AccountNode::new(Arc::clone(&state_arc)); + + let mut minting = TestAccountData::new_minting_account(); + node.import_account( + minting.address, + Account { + proof: None, + coin_queue: vec![], + coin_history: SparseMerkleTree::new(), + balance: 1_000_000, + num_sends: 0, + commitment_public_key: None, + }, + ); + + let recipient = TestAccountData::new_generic(&[42u8; 32], Network::Signet); + const MINT_AMOUNT: u64 = 50_000; + + // Mint flow: the minting account sends MINT_AMOUNT to a fresh + // recipient. `receive_coin` then pushes the resulting `CoinProof` + // into the recipient's `coin_queue` without touching `balance` — + // this is the exact write `commit_mint_tx` produces for a real + // first-mint history row. + let mut coin_proofs = minting + .execute_send_coins( + &mut node, + vec![Invoice::new(MINT_AMOUNT, recipient.address)], + ) + .expect("mint send_coins"); + state_arc + .lock() + .unwrap() + .update( + &coin_proofs + .iter() + .map(|x| x.commitment.clone().unwrap()) + .collect::>(), + ) + .expect("state.update"); + node.receive_coin(coin_proofs.pop().expect("at least one coin")) + .expect("recipient receive_coin"); + + let recipient_account = node + .accounts + .get(&recipient.address) + .expect("recipient account present after receive_coin"); + assert_eq!( + recipient_account.balance, 0, + "settled balance is still 0 — the credit sits in coin_queue" + ); + assert_eq!( + recipient_account.coin_queue.len(), + 1, + "exactly one queued coin" + ); + assert_eq!(recipient_account.coin_queue[0].coin.amount, MINT_AMOUNT); + + // Direct helper assertion: balance_from_account_blob must include + // the queue contribution. + let new_data = bincode::serialize(recipient_account).expect("bincode serialize"); + assert_eq!( + crate::router::balance_from_account_blob(&new_data), + Some(MINT_AMOUNT), + "balance_from_account_blob must sum balance + coin_queue (mirrors Account::get_balance)" + ); + + // End-to-end through history_row_to_item: a first mint row + // (prev_data = None) must surface `amount = MINT_AMOUNT`. + let row = crate::db::AccountHistoryRow { + id: 7, + timestamp_secs: 1_700_000_000, + source: "mint".to_string(), + prev_data: None, + new_data, + commit_txid: None, + block_height: None, + pending_status: None, + }; + let item = crate::router::history_row_to_item(&row).expect("item produced"); + assert_eq!(item.id, 7); + assert_eq!(item.direction, "mint"); + assert_eq!( + item.amount, MINT_AMOUNT, + "first mint must surface the full credit (regression: was 0 when balance_from_account_blob read only Account.balance)" + ); +} diff --git a/node/src/router.rs b/node/src/router.rs index 83821084..50ae31dd 100644 --- a/node/src/router.rs +++ b/node/src/router.rs @@ -474,15 +474,32 @@ pub(crate) fn map_history_direction(source: &str) -> Option<&'static str> { } } -/// Recover the `balance` field out of a bincode-serialised +/// Recover the usable balance out of a bincode-serialised /// [`crate::account_node::Account`] blob. Returns `None` if the bytes /// fail to round-trip — defensive, the handler treats a decode failure /// as a missing prior balance (so the delta collapses to the absolute /// new balance instead of producing a fabricated number). +/// +/// Mirrors [`crate::account_node::Account::get_balance`]: the settled +/// `balance` field plus pending receives sitting in `coin_queue`. +/// Mints and receives push the credited coin into `coin_queue` without +/// touching `balance` until a subsequent send drains the queue into +/// `coin_history`; reading only `a.balance` here would report `0` for +/// the very transactions the history endpoint is meant to surface (the +/// E2E suite catches this as `amount = 0` on first mint). +/// +/// `saturating_add` is used because the two summands come out of an +/// untrusted on-disk blob; in practice overflow is impossible (per-coin +/// amounts and `Account.balance` are both bounded by the minting +/// account's supply), but capping at `u64::MAX` is preferable to a +/// panic on a corrupted row. pub(crate) fn balance_from_account_blob(blob: &[u8]) -> Option { - bincode::deserialize::(blob) - .ok() - .map(|a| a.balance) + let a = bincode::deserialize::(blob).ok()?; + let queued: u64 = a + .coin_queue + .iter() + .fold(0u64, |acc, cp| acc.saturating_add(cp.coin.amount)); + Some(a.balance.saturating_add(queued)) } /// Typed mirror of the `pending_inscriptions.status` CHECK constraint diff --git a/node/src/router_tests.rs b/node/src/router_tests.rs index f2c395b0..f1e373da 100644 --- a/node/src/router_tests.rs +++ b/node/src/router_tests.rs @@ -6853,6 +6853,14 @@ fn balance_from_account_blob_round_trips() { assert!(balance_from_account_blob(&[0u8, 1, 2, 3]).is_none()); } +/// Covers the **settled-balance** shape of an `Account` blob: a post-send +/// account whose `coin_queue` has been drained into `coin_history` and +/// whose remaining funds sit in the `balance` field. The companion +/// **queue-only** shape (the actual production write produced by +/// `commit_mint_tx` / `receive_coin` for a credit) requires a real +/// `CoinProof` and is pinned in +/// `account_node_tests::history_row_to_item_balance_from_coin_queue_only` +/// where the prover fixtures live. #[test] fn history_row_to_item_handles_first_row_with_no_prev_data() { let mut a = Account::new();