Skip to content

Commit 25d0c1b

Browse files
authored
fix(rpc): two-slot coinbase cache prevents eviction between fake and real coinbase (#10954)
2 parents dcf12fe + 19f12af commit 25d0c1b

3 files changed

Lines changed: 199 additions & 23 deletions

File tree

zebra-rpc/CHANGELOG.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
1818
- Clarified the error message returned by `getrawtransaction` for transactions
1919
that are not in the mempool or best chain
2020
([#11014](https://github.com/ZcashFoundation/zebra/pull/11014)).
21+
- Fixed coinbase cache eviction that rebuilt shielded proofs on every
22+
`getblocktemplate` poll when the mempool had fee-paying transactions
23+
([#10954](https://github.com/ZcashFoundation/zebra/pull/10954)).
2124

2225
## [15.0.0] - 2026-07-27
2326

zebra-rpc/src/methods/types/get_block_template.rs

Lines changed: 40 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ pub mod zip317;
99
mod tests;
1010

1111
use std::{
12+
collections::HashMap,
1213
fmt::{self},
1314
sync::{Arc, Mutex},
1415
};
@@ -558,22 +559,24 @@ impl From<zcash_address::ConversionError<&'static str>> for MinerParamsError {
558559
}
559560
}
560561

561-
/// Caches the most recently built coinbase transaction for the next block, keyed on its height and
562-
/// total transaction fees.
562+
/// Caches recently built coinbase transactions for the next block, keyed on `(height, fee)`.
563563
///
564564
/// `getblocktemplate` clients commonly short-poll (re-request without long polling), and building
565565
/// the coinbase to a shielded address re-runs an expensive Sapling/Orchard proof. The coinbase only
566566
/// depends on `(height, fees)` for a given miner configuration, so repeated requests within the
567567
/// same block can reuse the cached transaction instead of re-proving it on every call.
568+
///
569+
/// Each `getblocktemplate` call needs two coinbase transactions at the same height: a zero-fee
570+
/// "fake" coinbase for ZIP-317 weight estimation, and the real coinbase with actual fees. Entries
571+
/// from previous heights are cleared on insert to bound memory.
568572
#[derive(Clone, Default)]
569573
pub(crate) struct CoinbaseCache(
570574
Arc<
571575
Mutex<
572-
Option<(
573-
block::Height,
574-
Amount<NonNegative>,
576+
HashMap<
577+
(block::Height, Amount<NonNegative>),
575578
TransactionTemplate<amount::NegativeOrZero>,
576-
)>,
579+
>,
577580
>,
578581
>,
579582
);
@@ -585,18 +588,11 @@ impl CoinbaseCache {
585588
height: block::Height,
586589
fee: Amount<NonNegative>,
587590
) -> Option<TransactionTemplate<amount::NegativeOrZero>> {
588-
let cache = self
589-
.0
591+
self.0
590592
.lock()
591-
.unwrap_or_else(|poisoned| poisoned.into_inner());
592-
match &*cache {
593-
Some((cached_height, cached_fee, coinbase))
594-
if *cached_height == height && *cached_fee == fee =>
595-
{
596-
Some(coinbase.clone())
597-
}
598-
_ => None,
599-
}
593+
.unwrap_or_else(|poisoned| poisoned.into_inner())
594+
.get(&(height, fee))
595+
.cloned()
600596
}
601597

602598
/// Stores `coinbase` as the cached transaction for `height` and `fee`.
@@ -606,18 +602,35 @@ impl CoinbaseCache {
606602
fee: Amount<NonNegative>,
607603
coinbase: TransactionTemplate<amount::NegativeOrZero>,
608604
) {
609-
*self
605+
let mut map = self
610606
.0
611607
.lock()
612-
.unwrap_or_else(|poisoned| poisoned.into_inner()) = Some((height, fee, coinbase));
608+
.unwrap_or_else(|poisoned| poisoned.into_inner());
609+
610+
// Evict entries from previous heights so the map stays bounded.
611+
map.retain(|&(h, _), _| h == height);
612+
// Only 2 entries are ever useful (zero-fee fake + current real-fee coinbase), but mempool
613+
// fee churn can accumulate stale entries within a block. Cap at 4 to stay well above the
614+
// useful set while preventing unbounded growth. When evicting, preserve the zero-fee sizing
615+
// coinbase — losing it recreates the churn this cache exists to prevent.
616+
if !map.contains_key(&(height, fee)) && map.len() >= 4 {
617+
let evict_key = map
618+
.keys()
619+
.copied()
620+
.find(|&(_, f)| f != Amount::<NonNegative>::zero());
621+
if let Some(key) = evict_key {
622+
map.remove(&key);
623+
}
624+
}
625+
map.insert((height, fee), coinbase);
613626
}
614627

615-
/// Discards the cached coinbase, forcing the next request to rebuild it.
628+
/// Discards all cached coinbases, forcing the next request to rebuild them.
616629
fn clear(&self) {
617-
*self
618-
.0
630+
self.0
619631
.lock()
620-
.unwrap_or_else(|poisoned| poisoned.into_inner()) = None;
632+
.unwrap_or_else(|poisoned| poisoned.into_inner())
633+
.clear();
621634
}
622635
}
623636

@@ -680,6 +693,10 @@ where
680693
/// address on a cloned handler, without changing the configured default.
681694
pub fn set_miner_params(&mut self, miner_params: MinerParams) {
682695
self.miner_params = Some(miner_params);
696+
// Cached coinbases pay the previous miner address, and this handler shares
697+
// its cache with the handler it was cloned from. Detach to a fresh cache so
698+
// neither handler can serve a coinbase built for the other's address.
699+
self.coinbase_cache = CoinbaseCache::default();
683700
}
684701

685702
/// Returns a handle to the coinbase transaction cache.

zebra-rpc/src/methods/types/get_block_template/tests.rs

Lines changed: 156 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -201,6 +201,162 @@ fn coinbase_cache_reuses_built_coinbase() {
201201
assert!(cache.get(height, fee).is_none(), "a cleared cache misses");
202202
}
203203

204+
/// Verifies the fix for #10907: the multi-entry coinbase cache retains both the zero-fee fake
205+
/// coinbase (used for ZIP-317 weight sizing) and the real-fee coinbase simultaneously, so
206+
/// `getblocktemplate` doesn't rebuild shielded proofs on every short-poll.
207+
#[test]
208+
fn coinbase_cache_retains_both_fake_and_real_fee_entries() {
209+
use super::CoinbaseCache;
210+
211+
let height = Height(1_000_000);
212+
let zero_fee = Amount::zero();
213+
let real_fee: Amount<zebra_chain::amount::NonNegative> =
214+
Amount::try_from(10_000).expect("valid amount");
215+
216+
let cache = CoinbaseCache::default();
217+
218+
// Simulate what getblocktemplate does: store a fake coinbase at zero fee (ZIP-317 sizing),
219+
// then store the real coinbase at the actual fee.
220+
let fake_coinbase = TransactionTemplate::new_coinbase(
221+
&Network::Mainnet,
222+
height,
223+
&MinerParams::from(
224+
Address::decode(
225+
&Network::Mainnet,
226+
default_miner_address(
227+
zebra_chain::parameters::NetworkKind::Mainnet,
228+
&MinerAddressType::Sapling,
229+
),
230+
)
231+
.unwrap(),
232+
),
233+
zero_fee,
234+
)
235+
.unwrap();
236+
237+
let real_coinbase = TransactionTemplate::new_coinbase(
238+
&Network::Mainnet,
239+
height,
240+
&MinerParams::from(
241+
Address::decode(
242+
&Network::Mainnet,
243+
default_miner_address(
244+
zebra_chain::parameters::NetworkKind::Mainnet,
245+
&MinerAddressType::Sapling,
246+
),
247+
)
248+
.unwrap(),
249+
),
250+
real_fee,
251+
)
252+
.unwrap();
253+
254+
cache.store(height, zero_fee, fake_coinbase.clone());
255+
cache.store(height, real_fee, real_coinbase.clone());
256+
257+
// Both entries coexist — the zero-fee sizing coinbase survives the real-fee store.
258+
assert_eq!(
259+
cache.get(height, zero_fee),
260+
Some(fake_coinbase),
261+
"zero-fee fake coinbase should still be cached after storing real-fee coinbase"
262+
);
263+
assert_eq!(
264+
cache.get(height, real_fee),
265+
Some(real_coinbase),
266+
"real-fee coinbase should be cached"
267+
);
268+
269+
// Height transition: storing at a new height evicts the stale entries.
270+
let next_height = Height(height.0 + 1);
271+
let next_coinbase = TransactionTemplate::new_coinbase(
272+
&Network::Mainnet,
273+
next_height,
274+
&MinerParams::from(
275+
Address::decode(
276+
&Network::Mainnet,
277+
default_miner_address(
278+
zebra_chain::parameters::NetworkKind::Mainnet,
279+
&MinerAddressType::Sapling,
280+
),
281+
)
282+
.unwrap(),
283+
),
284+
zero_fee,
285+
)
286+
.unwrap();
287+
288+
cache.store(next_height, zero_fee, next_coinbase.clone());
289+
assert_eq!(
290+
cache.get(next_height, zero_fee),
291+
Some(next_coinbase),
292+
"new-height entry should be cached"
293+
);
294+
assert!(
295+
cache.get(height, zero_fee).is_none(),
296+
"old-height entry should be evicted"
297+
);
298+
}
299+
300+
/// Verifies that fee churn beyond the cache cap (4 entries) evicts stale nonzero-fee entries
301+
/// while preserving the zero-fee sizing coinbase. Without this, the cap would clear the
302+
/// entire map — including the zero-fee entry — recreating the original #10907 churn.
303+
#[test]
304+
fn coinbase_cache_preserves_zero_fee_entry_at_capacity() {
305+
use super::CoinbaseCache;
306+
307+
let height = Height(2_000_000);
308+
let zero_fee = Amount::zero();
309+
let cache = CoinbaseCache::default();
310+
311+
let miner_params = MinerParams::from(
312+
Address::decode(
313+
&Network::Mainnet,
314+
default_miner_address(
315+
zebra_chain::parameters::NetworkKind::Mainnet,
316+
&MinerAddressType::Sapling,
317+
),
318+
)
319+
.unwrap(),
320+
);
321+
322+
let make_coinbase = |fee: Amount<zebra_chain::amount::NonNegative>| {
323+
TransactionTemplate::new_coinbase(&Network::Mainnet, height, &miner_params, fee).unwrap()
324+
};
325+
326+
// Store the zero-fee sizing coinbase first.
327+
let fake_coinbase = make_coinbase(zero_fee);
328+
cache.store(height, zero_fee, fake_coinbase.clone());
329+
330+
// Fill to capacity with distinct fee values (simulating mempool fee churn).
331+
for i in 1..=5u64 {
332+
let fee = Amount::try_from(i * 1_000).expect("valid amount");
333+
cache.store(height, fee, make_coinbase(fee));
334+
}
335+
336+
// The zero-fee entry must survive eviction at capacity.
337+
assert_eq!(
338+
cache.get(height, zero_fee),
339+
Some(fake_coinbase.clone()),
340+
"zero-fee sizing coinbase must survive fee churn at capacity"
341+
);
342+
343+
// Updating an existing key at capacity should not trigger eviction.
344+
let fee_1k: Amount<zebra_chain::amount::NonNegative> =
345+
Amount::try_from(1_000).expect("valid amount");
346+
let updated_coinbase = make_coinbase(fee_1k);
347+
cache.store(height, fee_1k, updated_coinbase.clone());
348+
assert_eq!(
349+
cache.get(height, fee_1k),
350+
Some(updated_coinbase),
351+
"updating an existing key should replace in place"
352+
);
353+
assert_eq!(
354+
cache.get(height, zero_fee),
355+
Some(fake_coinbase),
356+
"zero-fee entry must still be present after in-place update"
357+
);
358+
}
359+
204360
/// From NU6.3 onward, a shielded coinbase paid to a Unified miner address with an Orchard
205361
/// receiver routes newly minted value into the Ironwood pool, not the Orchard pool, and remains
206362
/// recoverable with the consensus-required all-zero outgoing viewing key.

0 commit comments

Comments
 (0)