Skip to content

Commit b57433d

Browse files
authored
fix(chain)!: validate the total amount when updating a value balance (#10817)
2 parents 441d0c7 + 4819af8 commit b57433d

4 files changed

Lines changed: 80 additions & 8 deletions

File tree

CHANGELOG.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,12 @@ and this project adheres to [Semantic Versioning](https://semver.org).
1919
- Peer-set, crawler-handshake, and address-book gauges now include a `network` label, so Mainnet
2020
and Testnet values no longer overwrite each other in processes that run both networks.
2121

22+
### Fixed
23+
24+
- Reject blocks whose total chain value pool balance would exceed `MAX_MONEY`,
25+
enforcing the cap on the total monetary base
26+
([#10817](https://github.com/ZcashFoundation/zebra/pull/10817))
27+
2228
## [Zebra 6.2.3](https://github.com/ZcashFoundation/zebra/releases/tag/v6.2.3) - 2026-07-27
2329

2430
This is an optional release with network hardenings for operators that experience issues with their nodes peer set connectivity or otherwise want to be proactive about avoiding such issues.

zebra-chain/CHANGELOG.md

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,16 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
77

88
## [Unreleased]
99

10+
### Breaking Changes
11+
12+
- Added the `ValueBalanceError::Total` variant returned when the sum of value
13+
pools is out of range
14+
([#10817](https://github.com/ZcashFoundation/zebra/pull/10817))
15+
16+
### Added
17+
18+
- `ValueBalance::total`, which returns the sum of all value pool balances
19+
1020
## [11.3.0] - 2026-07-27
1121

1222
### Added

zebra-chain/src/value_balance.rs

Lines changed: 37 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -160,6 +160,23 @@ where
160160
}
161161
}
162162

163+
/// Returns the sum of all value pool balances.
164+
pub fn total(self) -> Result<Amount<C>, amount::Error> {
165+
let total: i128 = [
166+
self.transparent,
167+
self.sprout,
168+
self.sapling,
169+
self.orchard,
170+
self.deferred,
171+
self.ironwood,
172+
]
173+
.into_iter()
174+
.map(|amount| i128::from(amount.zatoshis()))
175+
.sum();
176+
177+
Amount::try_from(total)
178+
}
179+
163180
/// Convert this value balance to a different ValueBalance type,
164181
/// if it satisfies the new constraint
165182
pub fn constrain<C2>(self) -> Result<ValueBalance<C2>, ValueBalanceError>
@@ -318,26 +335,35 @@ impl ValueBalance<NonNegative> {
318335
.expect("conversion from NonNegative to NegativeAllowed is always valid");
319336
chain_value_pool = (chain_value_pool + chain_value_pool_change)?;
320337

321-
chain_value_pool.constrain()
338+
let chain_value_pool = chain_value_pool.constrain::<NonNegative>()?;
339+
340+
// The sum of all chain value pools is the total monetary base, which consensus caps at
341+
// `MAX_MONEY`. Reject any change that would push the chain value pool total over that cap.
342+
chain_value_pool.total().map_err(ValueBalanceError::Total)?;
343+
344+
Ok(chain_value_pool)
322345
}
323346

324347
/// Create a fake value pool for testing purposes.
325348
///
326-
/// The resulting [`ValueBalance`] will have half of the MAX_MONEY amount on each pool.
349+
/// The resulting [`ValueBalance`] has `MAX_MONEY / 8` on the transparent, Sprout, Sapling,
350+
/// Orchard, and Ironwood pools; the deferred pool is zero. This keeps the total within the
351+
/// valid `Amount` range (see [`ValueBalance::total`]), while leaving headroom for value pool
352+
/// changes that tests commit on top of it.
327353
#[cfg(any(test, feature = "proptest-impl"))]
328354
pub fn fake_populated_pool() -> ValueBalance<NonNegative> {
329355
let mut fake_value_pool = ValueBalance::zero();
330356

331357
let fake_transparent_value_balance =
332-
ValueBalance::from_transparent_amount(Amount::try_from(MAX_MONEY / 2).unwrap());
358+
ValueBalance::from_transparent_amount(Amount::try_from(MAX_MONEY / 8).unwrap());
333359
let fake_sprout_value_balance =
334-
ValueBalance::from_sprout_amount(Amount::try_from(MAX_MONEY / 2).unwrap());
360+
ValueBalance::from_sprout_amount(Amount::try_from(MAX_MONEY / 8).unwrap());
335361
let fake_sapling_value_balance =
336-
ValueBalance::from_sapling_amount(Amount::try_from(MAX_MONEY / 2).unwrap());
362+
ValueBalance::from_sapling_amount(Amount::try_from(MAX_MONEY / 8).unwrap());
337363
let fake_orchard_value_balance =
338-
ValueBalance::from_orchard_amount(Amount::try_from(MAX_MONEY / 2).unwrap());
364+
ValueBalance::from_orchard_amount(Amount::try_from(MAX_MONEY / 8).unwrap());
339365
let fake_ironwood_value_balance =
340-
ValueBalance::from_ironwood_amount(Amount::try_from(MAX_MONEY / 2).unwrap());
366+
ValueBalance::from_ironwood_amount(Amount::try_from(MAX_MONEY / 8).unwrap());
341367

342368
fake_value_pool.set_transparent_value_balance(fake_transparent_value_balance);
343369
fake_value_pool.set_sprout_value_balance(fake_sprout_value_balance);
@@ -468,6 +494,9 @@ pub enum ValueBalanceError {
468494
/// ironwood amount error {0}
469495
Ironwood(amount::Error),
470496

497+
/// total amount error {0}
498+
Total(amount::Error),
499+
471500
/// ValueBalance is unparsable
472501
Unparsable,
473502
}
@@ -481,6 +510,7 @@ impl fmt::Display for ValueBalanceError {
481510
Orchard(e) => format!("orchard amount err: {e}"),
482511
Deferred(e) => format!("deferred amount err: {e}"),
483512
Ironwood(e) => format!("ironwood amount err: {e}"),
513+
Total(e) => format!("total amount err: {e}"),
484514
Unparsable => "value balance is unparsable".to_string(),
485515
})
486516
}

zebra-chain/src/value_balance/tests/vectors.rs

Lines changed: 27 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
//! Fixed test vectors for value balances.
22
33
use crate::{
4-
amount::{Amount, NegativeAllowed, NonNegative},
4+
amount::{Amount, NegativeAllowed, NonNegative, MAX_MONEY},
55
value_balance::{ValueBalance, ValueBalanceError},
66
};
77

@@ -35,6 +35,32 @@ fn ironwood_pool_enforces_non_negative_balance() {
3535
assert!(matches!(error, ValueBalanceError::Ironwood(_)));
3636
}
3737

38+
/// Check that `add_chain_value_pool_change` rejects a chain value pool whose
39+
/// individual pools are each within the valid `Amount` range, but whose total
40+
/// exceeds `MAX_MONEY` (the total monetary base cap).
41+
#[test]
42+
fn total_over_max_money_is_rejected() {
43+
let _init_guard = zebra_test::init();
44+
45+
// Start from a pool that already holds the maximum value in the transparent
46+
// pool. This is individually valid (`transparent` is within `0..=MAX_MONEY`).
47+
let mut chain = ValueBalance::<NonNegative>::zero();
48+
chain.set_transparent_value_balance(ValueBalance::from_transparent_amount(
49+
Amount::try_from(MAX_MONEY).expect("MAX_MONEY is a valid amount"),
50+
));
51+
52+
// Add the maximum value to the sprout pool. Each pool remains individually
53+
// valid (`sprout` is within `0..=MAX_MONEY`), but the total becomes
54+
// `2 * MAX_MONEY`, which exceeds the `MAX_MONEY` cap on the monetary base.
55+
let error = chain
56+
.add_chain_value_pool_change(ValueBalance::from_sprout_amount(
57+
Amount::<NegativeAllowed>::try_from(MAX_MONEY).expect("MAX_MONEY is a valid amount"),
58+
))
59+
.expect_err("a total exceeding MAX_MONEY must be rejected");
60+
61+
assert!(matches!(error, ValueBalanceError::Total(_)));
62+
}
63+
3864
/// Check that the ironwood value balance is included in a transaction's
3965
/// remaining value.
4066
#[test]

0 commit comments

Comments
 (0)