@@ -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