chia_query/peer/light_client/cache.rs
1//! [`CoinStateCache`] — the local mirror of the coin/puzzle-hash state a light client has subscribed
2//! to, kept current by the drive-loop from the peer's `CoinStateUpdate` stream and consulted first by
3//! reads.
4//!
5//! ## Reorg handling (the subtle part)
6//!
7//! A `CoinStateUpdate` carries a `fork_height`: the last block the new peak still agrees with. Any
8//! coin state the cache learned *above* that fork is now suspect:
9//! - a coin **created** above the fork that the update does not re-assert no longer exists → drop it;
10//! - a coin **spent** above the fork had its spend rolled back → clear its spent height (the coin is
11//! unspent again) unless the update says otherwise.
12//!
13//! The update's own `items` are authoritative for every coin they mention, so they overwrite the
14//! cache last. This keeps a read after a reorg from returning a coin state that the reorg erased.
15//!
16//! ## The confirmation-accuracy invariant (enforced by construction)
17//!
18//! No cached coin ever has `created_height > peak_height` — otherwise a consumer computing
19//! confirmations as `peak_height - created_height` (u32) would UNDERFLOW into a spurious
20//! ~4.29-billion "hyper-confirmed" value. This is not enforced per call site (that repeatedly missed
21//! a path); it is structural, enforced at two boundaries:
22//! - the **add boundary** — every coin enters via [`CoinStateCache::cache_coin`], which refuses a
23//! coin created above the current peak (used by `apply_update`'s insert AND by `seed`);
24//! - the **peak boundary** — every peak change runs through [`CoinStateCache::update_peak`], which
25//! sweeps out any coin now above the (possibly-lowered) peak.
26//!
27//! Together they make the invariant hold after every public mutation regardless of ordering.
28
29use std::collections::{HashMap, HashSet};
30
31use chia_protocol::{Bytes32, CoinState};
32
33/// Max cached coins admitted per subscribed puzzle hash, bounding the memory a puzzle-hash
34/// subscription can pull in from an untrusted peer's discovery stream.
35const MAX_COINS_PER_PUZZLE_HASH: usize = 10_000;
36
37/// A light client's local view of subscribed coin/puzzle-hash state plus the current peak.
38#[derive(Debug, Default)]
39pub struct CoinStateCache {
40 /// Latest known state of every cached coin, keyed by coin id.
41 coins: HashMap<Bytes32, CoinState>,
42 /// Coin ids the client has an active subscription for.
43 subscribed_coins: HashSet<Bytes32>,
44 /// Puzzle hashes the client has an active subscription for.
45 subscribed_puzzle_hashes: HashSet<Bytes32>,
46 /// The current peak `(height, header_hash)` learned from `NewPeakWallet`/`CoinStateUpdate`.
47 peak: Option<(u32, Bytes32)>,
48}
49
50impl CoinStateCache {
51 /// A fresh, empty cache.
52 pub fn new() -> Self {
53 Self::default()
54 }
55
56 /// The current peak `(height, header_hash)`, if one has been observed.
57 pub fn peak(&self) -> Option<(u32, Bytes32)> {
58 self.peak
59 }
60
61 /// Records a new peak observed from a bare `NewPeakWallet` message. ADVANCE-ONLY: a stale,
62 /// out-of-order, or hostile lower peak never regresses a higher one.
63 pub fn set_peak(&mut self, height: u32, header_hash: Bytes32) {
64 self.update_peak(height, header_hash, false);
65 }
66
67 /// The cached state of `coin_id`, if the client holds one.
68 pub fn get(&self, coin_id: Bytes32) -> Option<CoinState> {
69 self.coins.get(&coin_id).cloned()
70 }
71
72 /// Seeds the cache with the coin states returned by an initial subscribe response.
73 ///
74 /// Routes through [`cache_coin`](Self::cache_coin), so a seeded coin created above the current
75 /// peak is refused (the invariant is enforced on this path too).
76 pub fn seed(&mut self, states: impl IntoIterator<Item = CoinState>) {
77 for state in states {
78 self.cache_coin(state);
79 }
80 }
81
82 /// Records that `coin_ids` are now subscribed.
83 pub fn track_coins(&mut self, coin_ids: impl IntoIterator<Item = Bytes32>) {
84 self.subscribed_coins.extend(coin_ids);
85 }
86
87 /// Records that `puzzle_hashes` are now subscribed.
88 pub fn track_puzzle_hashes(&mut self, puzzle_hashes: impl IntoIterator<Item = Bytes32>) {
89 self.subscribed_puzzle_hashes.extend(puzzle_hashes);
90 }
91
92 /// Drops `coin_ids` from the subscription set (their cached state is retained until overwritten).
93 pub fn untrack_coins(&mut self, coin_ids: &[Bytes32]) {
94 for id in coin_ids {
95 self.subscribed_coins.remove(id);
96 }
97 }
98
99 /// Whether `coin_id` is currently subscribed.
100 pub fn is_subscribed_coin(&self, coin_id: Bytes32) -> bool {
101 self.subscribed_coins.contains(&coin_id)
102 }
103
104 /// The set of currently-subscribed coin ids (used to re-arm subscriptions after a reconnect).
105 pub fn subscribed_coins(&self) -> Vec<Bytes32> {
106 self.subscribed_coins.iter().copied().collect()
107 }
108
109 /// The set of currently-subscribed puzzle hashes (used to re-arm after a reconnect).
110 pub fn subscribed_puzzle_hashes(&self) -> Vec<Bytes32> {
111 self.subscribed_puzzle_hashes.iter().copied().collect()
112 }
113
114 /// Applies a `CoinStateUpdate` from an UNTRUSTED peer: rolls the cache back across the reported
115 /// `fork_height`, then inserts the update's `items` that belong to the subscribed set (bounded),
116 /// and advances the peak.
117 ///
118 /// Only items in the subscribed set are accepted: an item whose coin id is a subscribed coin OR
119 /// whose puzzle hash is a subscribed puzzle hash (the latter preserves puzzle-hash-subscription
120 /// DISCOVERY — a new coin under a watched puzzle hash is legitimate). An unsolicited item
121 /// matching neither is DROPPED, so a hostile peer cannot inject coins that later answer a
122 /// cache-first read. Inserts are further bounded by [`max_cached_coins`](Self::max_cached_coins)
123 /// so a puzzle-hash subscription cannot be used to exhaust memory.
124 ///
125 /// The peak ADVANCES on a normal update, and is set DOWN ONLY for a GENUINE authoritative reorg —
126 /// one where the rollback actually changed subscribed state, `fork_height` is below the current
127 /// peak, and the update is well-formed (`height >= fork_height`). An empty/garbage update that
128 /// rolls back nothing, or one with `height < fork_height`, cannot lower the peak.
129 ///
130 /// The invariant — no cached coin has `created_height > peak_height` — is enforced BY
131 /// CONSTRUCTION, not per-call-site: every add routes through [`cache_coin`](Self::cache_coin)
132 /// (which refuses an above-peak coin), and every peak change runs a sweep via
133 /// [`update_peak`](Self::update_peak) (which drops any coin now above the peak). So the property
134 /// holds on all paths — forward, reorg, and seed — regardless of ordering.
135 ///
136 /// See the module docs for the reorg-rollback rules.
137 pub fn apply_update(
138 &mut self,
139 items: &[CoinState],
140 height: u32,
141 fork_height: u32,
142 peak_hash: Bytes32,
143 ) {
144 let reasserted: HashSet<Bytes32> = items
145 .iter()
146 .filter(|s| self.is_subscribed(s))
147 .map(|s| s.coin.coin_id())
148 .collect();
149
150 // Track whether the rollback ACTUALLY changed subscribed state — peak-down is gated on this
151 // so it can never fire with a weaker precondition than the rollback itself.
152 let mut rolled_back = false;
153 self.coins.retain(|id, state| {
154 if reasserted.contains(id) {
155 return true; // re-inserted with authoritative state below (or swept by update_peak)
156 }
157 if state.created_height.is_some_and(|h| h > fork_height) {
158 rolled_back = true;
159 return false; // created in a block the reorg erased and not re-asserted
160 }
161 if state.spent_height.is_some_and(|h| h > fork_height) {
162 state.spent_height = None; // its spend was rolled back — unspend it
163 rolled_back = true;
164 }
165 true
166 });
167
168 // A peak-down is honoured ONLY for a GENUINE authoritative reorg: the rollback above changed
169 // real subscribed state, the fork is below the current peak, AND the update is well-formed
170 // (a real reorg tip is never below its own fork point). Otherwise the peak stays advance-only,
171 // so a hostile empty/garbage update cannot pin the peak arbitrarily low.
172 let is_genuine_reorg = rolled_back
173 && height >= fork_height
174 && self.peak.is_some_and(|(current, _)| fork_height < current);
175
176 // Set the peak FIRST (sweeping any survivor now above it — e.g. a reasserted coin kept by
177 // `retain` above a lowered peak), then admit the items against that peak via `cache_coin`.
178 self.update_peak(height, peak_hash, is_genuine_reorg);
179
180 for state in items {
181 if !self.is_subscribed(state) {
182 continue; // drop unsolicited coins from a hostile/noisy peer
183 }
184 self.cache_coin(*state);
185 }
186 }
187
188 /// The ONLY path by which a coin enters the cache. Enforces the two structural bounds:
189 /// - **Invariant:** refuses a coin whose `created_height` is above the current peak (which would
190 /// underflow a consumer's `peak - created` confirmation count).
191 /// - **Memory:** refuses a NEW coin once the cache is at [`max_cached_coins`](Self::max_cached_coins)
192 /// (re-asserting an already-cached coin never grows the map).
193 fn cache_coin(&mut self, state: CoinState) {
194 if let Some(created) = state.created_height {
195 if self
196 .peak
197 .is_some_and(|(peak_height, _)| created > peak_height)
198 {
199 return; // never cache a coin created above the peak
200 }
201 }
202 let id = state.coin.coin_id();
203 if !self.coins.contains_key(&id) && self.coins.len() >= self.max_cached_coins() {
204 log::warn!("chia-peer cache at cap; dropping overflow coin state");
205 return;
206 }
207 self.coins.insert(id, state);
208 }
209
210 /// Sets the peak, then sweeps out every cached coin now above it — so the invariant holds after
211 /// ANY peak change. Advances unconditionally; lowers ONLY when `allow_lower` (a genuine
212 /// authoritative reorg). A bare `NewPeakWallet` (via [`set_peak`](Self::set_peak)) passes
213 /// `allow_lower = false`, so it can never regress the peak.
214 fn update_peak(&mut self, height: u32, header_hash: Bytes32, allow_lower: bool) {
215 let changed = match self.peak {
216 None => true,
217 Some((current, _)) => height >= current || allow_lower,
218 };
219 if !changed {
220 return;
221 }
222 self.peak = Some((height, header_hash));
223 // Drop any coin the (possibly-lowered) peak now sits below — e.g. a reasserted survivor kept
224 // across a reorg, or a coin seeded before the first peak arrived.
225 self.coins
226 .retain(|_, state| state.created_height.is_none_or(|h| h <= height));
227 }
228
229 /// Whether `state` belongs to the subscribed set: its coin id is subscribed, or its puzzle hash
230 /// is a subscribed puzzle hash (discovery).
231 fn is_subscribed(&self, state: &CoinState) -> bool {
232 self.subscribed_coins.contains(&state.coin.coin_id())
233 || self
234 .subscribed_puzzle_hashes
235 .contains(&state.coin.puzzle_hash)
236 }
237
238 /// The upper bound on cached coins: one per subscribed coin plus a per-puzzle-hash allowance for
239 /// discovery. Bounds memory an untrusted peer can pull in via a puzzle-hash subscription.
240 fn max_cached_coins(&self) -> usize {
241 self.subscribed_coins.len().saturating_add(
242 self.subscribed_puzzle_hashes
243 .len()
244 .saturating_mul(MAX_COINS_PER_PUZZLE_HASH),
245 )
246 }
247}
248
249#[cfg(test)]
250mod tests {
251 use super::*;
252 use chia_protocol::Coin;
253
254 fn coin(seed: u8, amount: u64) -> Coin {
255 Coin::new(
256 Bytes32::new([seed; 32]),
257 Bytes32::new([seed ^ 0xff; 32]),
258 amount,
259 )
260 }
261
262 fn state(seed: u8, created: Option<u32>, spent: Option<u32>) -> CoinState {
263 CoinState {
264 coin: coin(seed, 1),
265 created_height: created,
266 spent_height: spent,
267 }
268 }
269
270 #[test]
271 fn seed_then_get_returns_state() {
272 let mut cache = CoinStateCache::new();
273 let s = state(1, Some(100), None);
274 let id = s.coin.coin_id();
275 cache.track_coins([id]); // production always subscribes before seeding the response
276 cache.seed([s]);
277 assert_eq!(cache.get(id), Some(s));
278 }
279
280 #[test]
281 fn peak_only_advances() {
282 let mut cache = CoinStateCache::new();
283 cache.set_peak(100, Bytes32::new([1; 32]));
284 cache.set_peak(90, Bytes32::new([2; 32])); // stale lower peak
285 assert_eq!(cache.peak().map(|(h, _)| h), Some(100));
286 }
287
288 #[test]
289 fn subscription_tracking_roundtrips() {
290 let mut cache = CoinStateCache::new();
291 let id = coin(5, 1).coin_id();
292 cache.track_coins([id]);
293 assert!(cache.is_subscribed_coin(id));
294 assert_eq!(cache.subscribed_coins(), vec![id]);
295 cache.untrack_coins(&[id]);
296 assert!(!cache.is_subscribed_coin(id));
297 cache.track_puzzle_hashes([Bytes32::new([7; 32])]);
298 assert_eq!(cache.subscribed_puzzle_hashes().len(), 1);
299 }
300
301 /// Test #3 (the reorg crux): a `CoinStateUpdate` at a forked, numerically-lower peak overwrites
302 /// re-asserted coins, drops coins created above the fork, and un-spends coins spent above it.
303 #[test]
304 fn reorg_update_rolls_cache_back_across_fork() {
305 let mut cache = CoinStateCache::new();
306
307 // X exists in the pre-reorg chain, spent at 92.
308 let x_pre = state(1, Some(80), Some(92));
309 let x_id = x_pre.coin.coin_id();
310 // Y was created at 95 (above the fork) and is not re-asserted by the update.
311 let y_pre = state(2, Some(95), None);
312 let y_id = y_pre.coin.coin_id();
313 // Z was created at 50 but SPENT at 93 (above the fork).
314 let z_pre = state(3, Some(50), Some(93));
315 let z_id = z_pre.coin.coin_id();
316 cache.track_coins([x_id, y_id, z_id]); // subscribed before the seed response
317 cache.seed([x_pre, y_pre, z_pre]);
318 cache.set_peak(100, Bytes32::new([0xaa; 32]));
319
320 // Reorg to a lower peak (90), fork point 89. The update re-asserts X as UNSPENT.
321 let x_post = state(1, Some(80), None);
322 cache.apply_update(&[x_post], 90, 89, Bytes32::new([0xbb; 32]));
323
324 // X was overwritten with the authoritative (unspent) state.
325 assert_eq!(cache.get(x_id), Some(x_post));
326 // Y (created above the fork, not re-asserted) was dropped.
327 assert_eq!(cache.get(y_id), None);
328 // Z stays but its rolled-back spend is cleared.
329 assert_eq!(cache.get(z_id).and_then(|s| s.spent_height), None);
330 // This is an authoritative reorg (fork 89 < peak 100), so the peak is set DOWN to 90.
331 assert_eq!(cache.peak(), Some((90, Bytes32::new([0xbb; 32]))));
332 }
333
334 #[test]
335 fn normal_update_inserts_without_dropping_lower_coins() {
336 let mut cache = CoinStateCache::new();
337 let old = state(1, Some(10), None);
338 let old_id = old.coin.coin_id();
339 let fresh = state(2, Some(200), None);
340 let fresh_id = fresh.coin.coin_id();
341 cache.track_coins([old_id, fresh_id]); // both subscribed before seeding
342 cache.seed([old]);
343 cache.apply_update(&[fresh], 200, 199, Bytes32::new([0xcc; 32]));
344
345 assert!(
346 cache.get(old_id).is_some(),
347 "coin below the fork is retained"
348 );
349 assert!(cache.get(fresh_id).is_some(), "new coin is inserted");
350 }
351
352 #[test]
353 fn unsolicited_update_item_is_dropped_not_cached() {
354 let mut cache = CoinStateCache::new();
355 // Nothing is subscribed. A hostile peer streams an unsolicited coin.
356 let injected = state(9, Some(10), None);
357 let injected_id = injected.coin.coin_id();
358 cache.apply_update(&[injected], 10, 9, Bytes32::new([1; 32]));
359 assert_eq!(
360 cache.get(injected_id),
361 None,
362 "an unsubscribed coin must never be cached (nor served on a read)"
363 );
364 }
365
366 #[test]
367 fn update_item_matching_a_subscribed_puzzle_hash_is_accepted() {
368 let mut cache = CoinStateCache::new();
369 let watched = state(4, Some(20), None);
370 let ph = watched.coin.puzzle_hash;
371 cache.track_puzzle_hashes([ph]);
372 // A freshly-discovered coin under the watched puzzle hash is legitimate.
373 cache.apply_update(&[watched], 20, 19, Bytes32::new([2; 32]));
374 assert_eq!(
375 cache.get(watched.coin.coin_id()),
376 Some(watched),
377 "a coin discovered under a subscribed puzzle hash is accepted"
378 );
379 }
380
381 // ---- #1311: peak-down on an authoritative reorg, advance-only otherwise ----
382
383 /// A GENUINE authoritative reorg — one that actually drops a subscribed coin created above the
384 /// fork — LOWERS the peak to the update's height/hash, so confirmation counts do not overstate.
385 #[test]
386 fn genuine_reorg_rollback_lowers_peak() {
387 let mut cache = CoinStateCache::new();
388 let orphaned = state(1, Some(95), None); // created above the fork
389 cache.track_coins([orphaned.coin.coin_id()]);
390 cache.seed([orphaned]);
391 cache.set_peak(100, Bytes32::new([0xaa; 32]));
392
393 // fork 90 < peak 100, height 92 >= fork, and the rollback drops the coin created at 95.
394 cache.apply_update(&[], 92, 90, Bytes32::new([0xbb; 32]));
395 assert_eq!(cache.peak(), Some((92, Bytes32::new([0xbb; 32]))));
396 }
397
398 /// Alias kept for the historical name: an authoritative reorg lowers the peak (same as
399 /// [`genuine_reorg_rollback_lowers_peak`]).
400 #[test]
401 fn authoritative_reorg_lowers_peak() {
402 let mut cache = CoinStateCache::new();
403 let orphaned = state(2, Some(95), None);
404 cache.track_coins([orphaned.coin.coin_id()]);
405 cache.seed([orphaned]);
406 cache.set_peak(100, Bytes32::new([0xaa; 32]));
407 cache.apply_update(&[], 90, 89, Bytes32::new([0xbb; 32]));
408 assert_eq!(cache.peak(), Some((90, Bytes32::new([0xbb; 32]))));
409 }
410
411 /// A `CoinStateUpdate` that rolls back NOTHING (empty items, no subscribed coin above the fork)
412 /// must NOT lower the peak, even though `fork_height < peak` — a hostile empty update cannot pin
413 /// the peak arbitrarily low.
414 #[test]
415 fn empty_update_with_low_fork_does_not_lower_peak() {
416 let mut cache = CoinStateCache::new();
417 cache.set_peak(1_000_000, Bytes32::new([0xaa; 32]));
418 cache.apply_update(&[], 7, 5, Bytes32::new([0xbb; 32]));
419 assert_eq!(cache.peak(), Some((1_000_000, Bytes32::new([0xaa; 32]))));
420 }
421
422 /// A malformed reorg whose `height < fork_height` (impossible on a real chain) must NOT lower the
423 /// peak, even if the rollback changed state.
424 #[test]
425 fn peak_height_below_fork_height_is_rejected() {
426 let mut cache = CoinStateCache::new();
427 let orphaned = state(3, Some(60), None); // created above the (malformed) fork
428 cache.track_coins([orphaned.coin.coin_id()]);
429 cache.seed([orphaned]);
430 cache.set_peak(100, Bytes32::new([0xaa; 32]));
431
432 // height 40 < fork 50 → malformed; rollback may drop the coin but the peak must not lower.
433 cache.apply_update(&[], 40, 50, Bytes32::new([0xbb; 32]));
434 assert_eq!(cache.peak(), Some((100, Bytes32::new([0xaa; 32]))));
435 }
436
437 /// A bare `NewPeakWallet` (the `set_peak` path) with a LOWER height must NOT lower the peak —
438 /// this is the hostile-low-peak resistance and stays advance-only.
439 #[test]
440 fn bare_new_peak_lower_does_not_lower_peak() {
441 let mut cache = CoinStateCache::new();
442 cache.set_peak(100, Bytes32::new([0xaa; 32]));
443 cache.set_peak(90, Bytes32::new([0xbb; 32])); // bare NewPeakWallet, lower
444 assert_eq!(cache.peak(), Some((100, Bytes32::new([0xaa; 32]))));
445 }
446
447 /// A normal forward `CoinStateUpdate` (no rollback: `fork_height` at/above the current peak)
448 /// still advances the peak.
449 #[test]
450 fn normal_forward_update_advances_peak() {
451 let mut cache = CoinStateCache::new();
452 cache.set_peak(100, Bytes32::new([0xaa; 32]));
453 cache.apply_update(&[], 101, 100, Bytes32::new([0xcc; 32]));
454 assert_eq!(cache.peak(), Some((101, Bytes32::new([0xcc; 32]))));
455 }
456
457 /// A plain FORWARD update (rolls back nothing) carrying an item that claims creation above the
458 /// update's own tip must refuse that item — the above-tip guard applies on the forward path too,
459 /// not only on a reorg.
460 #[test]
461 fn forward_update_with_item_created_above_tip_is_refused() {
462 let mut cache = CoinStateCache::new();
463 cache.set_peak(1_000_000, Bytes32::new([0xaa; 32]));
464
465 // A forward update to tip 1_000_001 carrying a coin lying about created_height = 5_000_000.
466 let liar = state(1, Some(5_000_000), None);
467 let liar_id = liar.coin.coin_id();
468 cache.track_coins([liar_id]);
469 cache.apply_update(&[liar], 1_000_001, 1_000_000, Bytes32::new([0xbb; 32]));
470
471 assert_eq!(
472 cache.get(liar_id),
473 None,
474 "above-tip coin refused on forward path"
475 );
476 }
477
478 /// Invariant on the FORWARD path: after such an update, the peak is >= every cached coin's
479 /// created_height (no underflow surface for a `peak - created` confirmation count).
480 #[test]
481 fn invariant_no_cached_coin_above_peak_on_forward_path() {
482 let mut cache = CoinStateCache::new();
483 cache.set_peak(1_000_000, Bytes32::new([0xaa; 32]));
484
485 let honest = state(1, Some(999_999), None); // legitimately below the tip
486 let liar = state(2, Some(5_000_000), None); // above the tip → refused
487 cache.track_coins([honest.coin.coin_id(), liar.coin.coin_id()]);
488 cache.apply_update(
489 &[honest, liar],
490 1_000_001,
491 1_000_000,
492 Bytes32::new([0xbb; 32]),
493 );
494
495 let (peak_height, _) = cache.peak().expect("peak set");
496 for id in [honest.coin.coin_id(), liar.coin.coin_id()] {
497 if let Some(cs) = cache.get(id) {
498 assert!(
499 cs.created_height.is_none_or(|h| h <= peak_height),
500 "cached coin {id:?} created above peak {peak_height}"
501 );
502 }
503 }
504 }
505
506 /// Invariant: after an authoritative peak-down, no cached coin has a `created_height` above the
507 /// new peak — coins created above the fork are dropped, and an item claimed above the new tip is
508 /// refused.
509 #[test]
510 fn invariant_no_cached_coin_created_above_peak_after_reorg() {
511 let mut cache = CoinStateCache::new();
512 let below = state(1, Some(50), None); // survives the reorg
513 let orphaned = state(2, Some(95), None); // created above the fork → dropped
514 cache.track_coins([below.coin.coin_id(), orphaned.coin.coin_id()]);
515 cache.seed([below, orphaned]);
516 cache.set_peak(100, Bytes32::new([0xaa; 32]));
517
518 // A hostile item claims to be created ABOVE the new tip (99 > 92); it must be refused.
519 let above_tip = state(3, Some(99), None);
520 let above_tip_id = above_tip.coin.coin_id();
521 cache.track_coins([above_tip_id]);
522 cache.apply_update(&[above_tip], 92, 90, Bytes32::new([0xbb; 32]));
523
524 let (peak_height, _) = cache.peak().expect("peak set");
525 assert_eq!(peak_height, 92);
526 assert_eq!(cache.get(above_tip_id), None, "above-tip coin is refused");
527 for id in [below.coin.coin_id(), orphaned.coin.coin_id(), above_tip_id] {
528 if let Some(cs) = cache.get(id) {
529 assert!(
530 cs.created_height.is_none_or(|h| h <= peak_height),
531 "cached coin {id:?} created above peak {peak_height}"
532 );
533 }
534 }
535 }
536
537 /// The exact 2-push exploit both gate legs used: a coin cached below the peak is re-asserted by a
538 /// reorg push that also drops another coin, lowering the peak below the re-asserted coin — the
539 /// survivor must be swept, not left above the peak.
540 #[test]
541 fn reassert_above_new_tip_during_peak_down_is_swept() {
542 let mut cache = CoinStateCache::new();
543 let c = state(1, Some(100), None); // will be re-asserted
544 let d = state(2, Some(150), None); // dropped by the reorg → triggers rolled_back
545 cache.track_coins([c.coin.coin_id(), d.coin.coin_id()]);
546 cache.seed([c, d]);
547 cache.set_peak(200, Bytes32::new([0xaa; 32]));
548
549 // Reorg to tip 50 (fork 40): D (created 150 > fork) drops → genuine peak-down to 50.
550 // C is re-asserted but created at 100 > new tip 50, so it must NOT remain cached.
551 cache.apply_update(&[c], 50, 40, Bytes32::new([0xbb; 32]));
552
553 assert_eq!(cache.peak(), Some((50, Bytes32::new([0xbb; 32]))));
554 assert_eq!(
555 cache.get(c.coin.coin_id()),
556 None,
557 "re-asserted coin above the lowered peak must be swept"
558 );
559 }
560
561 /// A coin seeded above an existing peak is refused; a coin seeded before any peak, then found
562 /// above the first peak, is swept when that peak arrives.
563 #[test]
564 fn seed_above_peak_is_refused_or_swept() {
565 // (a) seed above an existing peak → refused at the add boundary.
566 let mut cache = CoinStateCache::new();
567 cache.set_peak(100, Bytes32::new([0xaa; 32]));
568 let high = state(1, Some(200), None);
569 cache.track_coins([high.coin.coin_id()]);
570 cache.seed([high]);
571 assert_eq!(
572 cache.get(high.coin.coin_id()),
573 None,
574 "refused at add boundary"
575 );
576
577 // (b) seed before any peak, then a lower first peak sweeps it.
578 let mut cache = CoinStateCache::new();
579 let early = state(2, Some(200), None);
580 cache.track_coins([early.coin.coin_id()]);
581 cache.seed([early]); // peak is None → admitted (vacuously)
582 assert!(cache.get(early.coin.coin_id()).is_some());
583 cache.set_peak(100, Bytes32::new([0xaa; 32])); // first peak below it → swept
584 assert_eq!(
585 cache.get(early.coin.coin_id()),
586 None,
587 "seeded coin above the first peak must be swept"
588 );
589 }
590
591 /// Property/fuzz: across many random sequences of `apply_update`/`seed`/`set_peak` with arbitrary
592 /// inputs, the invariant "no cached coin has created_height > peak_height" holds after EVERY
593 /// operation. This exercises the input SPACE, not just hand-picked scenarios.
594 #[test]
595 fn property_invariant_holds_across_random_op_sequences() {
596 use rand::{rngs::StdRng, Rng, SeedableRng};
597
598 fn random_state(rng: &mut StdRng) -> CoinState {
599 let seed: u8 = rng.gen_range(0..8); // small id space → frequent re-asserts
600 let created = rng.gen_bool(0.85).then(|| rng.gen_range(0..1_000u32));
601 let spent = rng.gen_bool(0.3).then(|| rng.gen_range(0..1_000u32));
602 CoinState {
603 coin: Coin::new(Bytes32::new([seed; 32]), Bytes32::new([seed ^ 0xff; 32]), 1),
604 created_height: created,
605 spent_height: spent,
606 }
607 }
608
609 const SEQUENCES: usize = 3_000;
610 const OPS_PER_SEQUENCE: usize = 8;
611 let mut rng = StdRng::seed_from_u64(1311);
612
613 for _ in 0..SEQUENCES {
614 let mut cache = CoinStateCache::new();
615 for _ in 0..OPS_PER_SEQUENCE {
616 match rng.gen_range(0..3) {
617 0 => {
618 let s = random_state(&mut rng);
619 cache.track_coins([s.coin.coin_id()]);
620 cache.seed([s]);
621 }
622 1 => {
623 let items: Vec<CoinState> = (0..rng.gen_range(0..4))
624 .map(|_| random_state(&mut rng))
625 .collect();
626 for it in &items {
627 cache.track_coins([it.coin.coin_id()]);
628 }
629 let height = rng.gen_range(0..1_000u32);
630 let fork = rng.gen_range(0..1_000u32);
631 cache.apply_update(&items, height, fork, Bytes32::new([rng.gen(); 32]));
632 }
633 _ => cache.set_peak(rng.gen_range(0..1_000u32), Bytes32::new([rng.gen(); 32])),
634 }
635
636 // The invariant, checked after every single operation.
637 if let Some((peak_height, _)) = cache.peak() {
638 for state in cache.coins.values() {
639 assert!(
640 state.created_height.is_none_or(|h| h <= peak_height),
641 "invariant violated: coin created {:?} > peak {peak_height}",
642 state.created_height
643 );
644 }
645 }
646 }
647 }
648 }
649}