Skip to main content

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}