Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 27 additions & 3 deletions contracts/escrow/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand All @@ -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, BalanceSnapshot>(&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);

Expand Down
54 changes: 54 additions & 0 deletions contracts/escrow/src/tests/snapshots.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
6 changes: 5 additions & 1 deletion docs/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down