Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
102 changes: 102 additions & 0 deletions node/src/account_node_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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::<Vec<_>>(),
)
.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)"
);
}
25 changes: 21 additions & 4 deletions node/src/router.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<u64> {
bincode::deserialize::<crate::account_node::Account>(blob)
.ok()
.map(|a| a.balance)
let a = bincode::deserialize::<crate::account_node::Account>(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
Expand Down
8 changes: 8 additions & 0 deletions node/src/router_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down
Loading