From 81df9505a5ee33c42055a266b7fdf6456efd0f22 Mon Sep 17 00:00:00 2001 From: Joshua13-stack <295895856+Joshua13-stack@users.noreply.github.com> Date: Tue, 20 Oct 2026 14:16:39 +0000 Subject: [PATCH] simple: fix #1768 Co-authored-by: Simple Agent --- contracts/escrow/src/lib.rs | 30 ++++++++++++-- contracts/escrow/src/tests/snapshots.rs | 54 +++++++++++++++++++++++++ docs/architecture.md | 6 ++- 3 files changed, 86 insertions(+), 4 deletions(-) diff --git a/contracts/escrow/src/lib.rs b/contracts/escrow/src/lib.rs index 6dbce6d5..39df60f9 100644 --- a/contracts/escrow/src/lib.rs +++ b/contracts/escrow/src/lib.rs @@ -4832,9 +4832,14 @@ impl EscrowContract { /// Snapshots are stored in a fixed-size ring buffer keyed by /// `DataKey::Snapshot(match_id, slot)` where `slot = index % /// MAX_SNAPSHOTS_PER_MATCH`. Once a match's snapshot count exceeds the - /// buffer capacity, the oldest entry is silently overwritten — this is - /// the storage-pruning mechanism. `DataKey::SnapshotCount` tracks the - /// total ever recorded so callers can detect that pruning occurred. + /// buffer capacity, the oldest entry is overwritten — this is the + /// storage-pruning mechanism. `DataKey::SnapshotCount` tracks the total + /// ever recorded so callers can detect that pruning occurred. + /// + /// Overwrites are no longer silent: when a slot is recycled, a + /// `("match", "snapshot_overwritten")` event is published with + /// `(match_id, overwritten_index, new_index)` before the slot is + /// rewritten, so off-chain indexers can detect the data loss. fn record_snapshot(env: &Env, m: &Match, reason: SnapshotReason) { let token_symbol = Self::fetch_token_symbol(env, &m.token); let escrow_balance = Self::escrow_balance_of(m); @@ -4846,6 +4851,25 @@ impl EscrowContract { .unwrap_or(0); let slot = index % MAX_SNAPSHOTS_PER_MATCH; + // Detect ring-buffer wrap-around: if this slot already holds a + // snapshot, it is about to be recycled. Report the overwritten + // entry's index so indexers can account for the lost snapshot. + if index >= MAX_SNAPSHOTS_PER_MATCH { + let overwritten_index = env + .storage() + .persistent() + .get::(&DataKey::Snapshot(m.id, slot)) + .map(|old| old.index) + .unwrap_or(index.saturating_sub(MAX_SNAPSHOTS_PER_MATCH)); + env.events().publish( + ( + Symbol::new(env, "match"), + symbol_short!("snap_ovr"), + ), + (m.id, overwritten_index, index), + ); + } + let nonce: BytesN<32> = env.prng().gen(); let commitment = Self::compute_commitment(env, m.stake_amount, escrow_balance, &nonce); diff --git a/contracts/escrow/src/tests/snapshots.rs b/contracts/escrow/src/tests/snapshots.rs index c0c0b4a3..e84efbe8 100644 --- a/contracts/escrow/src/tests/snapshots.rs +++ b/contracts/escrow/src/tests/snapshots.rs @@ -383,6 +383,60 @@ fn test_snapshot_ring_buffer_prunes_oldest_entries() { assert_eq!(snaps.get(snaps.len() - 1).unwrap().index, total_written - 1); } +#[test] +fn test_snapshot_ring_buffer_emits_overwrite_event() { + let (env, contract_id, _oracle, player1, player2, token, _admin) = setup(); + let client = EscrowContractClient::new(&env, &contract_id); + + let id = client.create_match( + &player1, + &player2, + &100, + &token, + &String::from_str(&env, "e7c4a2b1"), + &Platform::Lichess, + ); + + // Fill the ring buffer exactly to capacity (the Created snapshot already + // occupies slot 0), then perform one more write to force a wrap-around. + env.as_contract(&contract_id, || { + let m: Match = env.storage().persistent().get(&DataKey::Match(id)).unwrap(); + for _ in 1..MAX_SNAPSHOTS_PER_MATCH { + EscrowContract::record_snapshot(&env, &m, SnapshotReason::Deposit); + } + }); + + let events_before = env.events().all().len(); + + env.as_contract(&contract_id, || { + let m: Match = env.storage().persistent().get(&DataKey::Match(id)).unwrap(); + EscrowContract::record_snapshot(&env, &m, SnapshotReason::Deposit); + }); + + let all_events = env.events().all(); + assert_eq!( + all_events.len(), + events_before + 1, + "exactly one event must be emitted on the wrap-around write" + ); + + let (emitting_contract, topics, _data) = all_events.get(all_events.len() - 1).unwrap(); + assert_eq!(emitting_contract, contract_id); + + let expected_topics = ( + Symbol::new(&env, "match"), + Symbol::new(&env, "snapshot_overwritten"), + ) + .into_val(&env); + assert_eq!(topics, expected_topics); + + // The overwritten slot is index 0 (the Created snapshot); the new + // snapshot lands at index MAX_SNAPSHOTS_PER_MATCH. + let (overwritten_index, new_index): (u32, u32) = TryFromVal::try_from_val(&env, &_data).unwrap(); + assert_eq!(overwritten_index, 0); + assert_eq!(new_index, MAX_SNAPSHOTS_PER_MATCH); +} + #[test] fn test_get_balance_snapshots_empty_for_match_with_no_recorded_history() { // Defensive case: if SnapshotCount were ever absent for an existing match, diff --git a/docs/architecture.md b/docs/architecture.md index 5cee9102..7cd6d6ef 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -372,7 +372,11 @@ Snapshots are recorded automatically at key lifecycle transitions: - **`Completed`** — when `submit_result` executes the payout - **`Cancelled`** — when cancellation occurs (before or after activation) -The ring buffer has a fixed capacity of `MAX_SNAPSHOTS_PER_MATCH = 8` slots per match. Snapshots are stored at keys `DataKey::Snapshot(match_id, slot)` where `slot = index % MAX_SNAPSHOTS_PER_MATCH`. When the buffer fills, the oldest entry is silently overwritten — this is the storage-pruning mechanism. +The ring buffer has a fixed capacity of `MAX_SNAPSHOTS_PER_MATCH = 8` slots per match. Snapshots are stored at keys `DataKey::Snapshot(match_id, slot)` where `slot = index % MAX_SNAPSHOTS_PER_MATCH`. + +**Wrap-around behaviour:** Once all 8 slots for a match are occupied, recording a new snapshot recycles the slot holding the oldest entry (`slot = new_index % MAX_SNAPSHOTS_PER_MATCH`), overwriting it. The overwritten snapshot is permanently lost from on-chain storage; only the monotonically increasing `index` values of the retained snapshots reveal that pruning occurred. This is the storage-pruning mechanism. + +**Detecting data loss off-chain:** Every time a slot is recycled, the contract emits a `match/snapshot_overwritten` event with the topics `("match", "snapshot_overwritten")` and the payload `(match_id: u64, overwritten_index: u32, new_index: u32)`, where `overwritten_index` is the `index` of the snapshot that was discarded and `new_index` is the `index` of the snapshot that replaced it. Off-chain indexers should subscribe to this event to detect that a previously observed snapshot is no longer retrievable via `get_balance_snapshots` and to reconcile their own retained history. No event is emitted while the buffer still has free slots. **Interpreting the `index` field:** The `index` is monotonically increasing and never resets, enabling callers to detect when pruning has occurred. If `get_balance_snapshots` returns snapshots with indices like `[5, 6, 7, 8]`, you know snapshots `0` through `4` were pruned because only 8 slots are retained. The `SnapshotCount(match_id)` tracks the total ever recorded, allowing calculation of the actual sequence range.