Skip to content
Open
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
4 changes: 4 additions & 0 deletions .changes/unreleased/zebra-state-Fixed-20260819-000100.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
project: zebra-state
kind: Fixed
body: 'The non-finalized transparent `received` total (served by `getaddressbalance`) now saturates instead of overflowing. An address with enough non-finalized self-transfer churn could push its cumulative `received` counter past `u64::MAX`, panicking in debug builds and wrapping in release builds; it now matches the finalized path''s saturating accounting ([#10556](https://github.com/ZcashFoundation/zebra/issues/10556)).'
time: 2026-08-19T00:01:00.000000000Z
6 changes: 4 additions & 2 deletions zebra-state/src/service/non_finalized_state/chain.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1604,10 +1604,12 @@ impl Chain {
addresses: &HashSet<transparent::Address>,
) -> (Amount<NegativeAllowed>, u64) {
let (balance, received) = self.partial_transparent_indexes(addresses).fold(
(Ok(Amount::zero()), 0),
(Ok(Amount::zero()), 0u64),
|(balance, received), transfers| {
let balance = balance + transfers.balance();
(balance, received + transfers.received())
// Saturate: each per-address `received()` can already reach `u64::MAX`, so
// summing several of them could overflow. Matches the finalized path (#10556).
(balance, received.saturating_add(transfers.received()))
},
);

Expand Down
52 changes: 50 additions & 2 deletions zebra-state/src/service/non_finalized_state/chain/index.rs
Original file line number Diff line number Diff line change
Expand Up @@ -231,9 +231,17 @@ impl TransparentTransfers {
}

/// Returns the partial received balance for this address.
///
/// This is a cumulative total of every UTXO ever created for the address in this partial
/// chain (spent UTXOs are not removed from the count). An address that repeatedly receives
/// and re-sends can therefore push the total past `u64::MAX` even though its balance stays
/// bounded, so the sum saturates rather than overflowing, matching the finalized path. See
/// #10556.
pub fn received(&self) -> u64 {
let received_utxos = self.created_utxos.values();
received_utxos.map(|out| out.value()).map(u64::from).sum()
self.created_utxos
.values()
.map(|out| u64::from(out.value()))
.fold(0, u64::saturating_add)
}

/// Returns the [`transaction::Hash`]es of the transactions that sent or
Expand Down Expand Up @@ -305,3 +313,43 @@ impl Default for TransparentTransfers {
pub fn transaction_location(ordered_utxo: &transparent::OrderedUtxo) -> TransactionLocation {
TransactionLocation::from_usize(ordered_utxo.utxo.height, ordered_utxo.tx_index_in_block)
}

#[cfg(test)]
mod tests {
use zebra_chain::{
amount::MAX_MONEY,
transparent::{Output, Script},
};

use super::*;

/// Regression test for #10556: the cumulative non-finalized `received` total for a
/// transparent address must saturate at `u64::MAX` rather than overflow.
///
/// `received()` sums every UTXO ever created for the address in this partial chain, and
/// spent UTXOs are not removed from that set, so a high-churn self-transfer address can push
/// the total past `u64::MAX` while its balance stays bounded. Before the fix the plain
/// `.sum()` panicked in debug builds and wrapped in release builds.
#[test]
fn received_saturates_past_u64_max() {
let max_money = Amount::try_from(MAX_MONEY).expect("MAX_MONEY is a valid amount");
let output = Output::new(max_money, Script::new(&[]));

// `u64::MAX / MAX_MONEY` is ~8784, so 8786 max-money UTXOs overflow a plain sum.
let utxo_count = 8786;
assert!(
(utxo_count as u128) * (MAX_MONEY as u128) > u64::MAX as u128,
"test must actually exceed u64::MAX when summed exactly",
);

let mut transfers = TransparentTransfers::default();
for output_index in 0..utxo_count {
let output_location = OutputLocation::from_usize(Height(0), 0, output_index);
transfers
.created_utxos
.insert(output_location, output.clone());
}

assert_eq!(transfers.received(), u64::MAX);
}
}
Loading