fix(zebra-state): saturate non-finalized transparent received totals - #11295
fix(zebra-state): saturate non-finalized transparent received totals#11295natalieesk wants to merge 2 commits into
Conversation
TransparentTransfers::received() sums every UTXO ever created for an address in the partial chain; spent UTXOs are not removed from that set, so the cumulative total grows with self-transfer churn while the balance stays bounded. With enough churn the total crosses u64::MAX, panicking in debug builds and wrapping in release. Sum with saturating_add, and saturate the per-address fold in partial_transparent_balance_change too, matching the finalized state's saturating accounting. Closes #10556.
And two more auto-invalidated findings. Analyzed two files, diff |
Motivation
Closes #10556.
getaddressbalancecombines finalized transparent address accounting with non-finalized chain accounting. The finalized path treatsreceivedas a saturating counter, but the non-finalized path used ordinaryu64addition.TransparentTransfers::received()sums every UTXO ever created for the address in the partial chain. Spent UTXOs are not removed fromcreated_utxos(only block-revert removes them), soreceived()is a cumulative total that grows with self-transfer churn while the address balance stays bounded. With enough churn inside the non-finalized window (~8785 max-money receipts to the same address) the total crossesu64::MAX: debug/test builds panic, release builds wrap andgetaddressbalancereturns a wrappedreceiveduntil those blocks finalize.This is not a consensus issue — it is address-index RPC correctness plus debug-build availability hardening for nodes serving address-index RPCs over recent non-finalized state. Reported by @zmanian during post-v4.4.0 audit follow-up.
Solution
Use saturating arithmetic consistently for non-finalized transparent
receivedtotals, matching the finalized path:TransparentTransfers::received()folds the created-UTXO values withu64::saturating_add;Chain::partial_transparent_balance_change()combines per-address totals withreceived.saturating_add(transfers.received()).The finalized state already saturates at each combine point (per-output, per-
Add, and cross-address); the non-finalized path now does too, and the final finalized + non-finalized combine inread/address/balance.rswas already saturating.Tests
Added
received_saturates_past_u64_max, a unit test that inserts enough max-money created UTXOs (8786) into aTransparentTransfersto exceedu64::MAXwhen summed exactly, and assertsreceived()returnsu64::MAX. Against the old.sum()code it panics with "attempt to add with overflow" in this debug build (and would wrap in release), so it fails without the fix and passes with it. The test asserts inline that the chosen count really does exceedu64::MAX.cargo test -p zebra-state --lib non_finalized(33 passed),cargo fmt --all -- --check, andcargo clippy -p zebra-state --lib --all-featuresare clean.Specifications & References
zebra-state/src/service/read/address/balance.rs(an address can receive more than the max money supply by sending to itself).Follow-up Work
The cross-address fold in
partial_transparent_balance_changeis asserted by inspection (it is structurally identical to the finalized cross-address fold and to the coveredreceived()site); a dedicated multi-addressChain-level test for that combine point could be added for symmetry but is not included here.AI Disclosure
PR Checklist
type(scope): description