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
3 changes: 3 additions & 0 deletions contracts/earn-quest/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,9 @@ mod test_clawback;
#[cfg(test)]
mod test_oracle_deviation;

#[cfg(test)]
mod test_incremental_stats;

use crate::errors::Error;
use crate::storage::{get_badge_type, list_badge_types};

Expand Down
8 changes: 8 additions & 0 deletions contracts/earn-quest/src/reputation.rs
Original file line number Diff line number Diff line change
Expand Up @@ -127,6 +127,14 @@ pub fn grant_badge(env: &Env, caller: &Address, user: &Address, badge: Badge) ->
///
/// If no stats exist for the user, returns default values (0 XP, Level 1, 0 Quests).
///
/// # Performance (#2153)
///
/// This is an O(1) storage read of the incrementally-maintained `UserCore`
/// counters (updated in place by [`award_xp`] on the completion path), not a
/// scan-and-recompute over the user's award history — so read cost and gas do
/// not grow with the user's history. The `test_incremental_stats` suite proves
/// the incremental counters always equal a full recompute.
///
/// # Arguments
///
/// * `env` - The contract environment.
Expand Down
86 changes: 86 additions & 0 deletions contracts/earn-quest/src/test_incremental_stats.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
//! Property/fuzz tests for the incremental user-stat counters (#2153).
//!
//! `award_xp` maintains a user's `UserCore` counters incrementally — it reads
//! the stored value, adds the new XP, bumps `quests_completed`, and derives the
//! level with [`crate::reputation::calculate_level`] — so `get_user_stats` is an
//! O(1) storage read rather than a scan-and-recompute over the award history.
//!
//! These tests lock in the invariant that matters for that optimisation: the
//! incrementally-maintained counters must always equal a full recompute from
//! the complete award history, for any sequence of awards. They are pure (no
//! storage/ledger), so they add no gas to the hot path.

use crate::reputation::calculate_level;
use crate::types::UserCore;

/// Deterministic xorshift64 PRNG so the fuzzed sequences are reproducible in CI.
fn next_rand(state: &mut u64) -> u64 {
let mut x = *state;
x ^= x << 13;
x ^= x >> 7;
x ^= x << 17;
*state = x;
x
}

/// Fold the award history one entry at a time, mirroring exactly what
/// `award_xp` does to the stored `UserCore` on each call.
fn apply_incremental(awards: &[u64]) -> UserCore {
let mut stats = UserCore {
xp: 0,
level: 1,
quests_completed: 0,
};
for &amount in awards {
stats.xp += amount;
stats.quests_completed += 1;
stats.level = calculate_level(stats.xp);
}
stats
}

/// Recompute the same counters from scratch over the whole award history —
/// the "expensive" path the incremental counters exist to avoid.
fn full_recompute(awards: &[u64]) -> UserCore {
let xp: u64 = awards.iter().copied().sum();
UserCore {
xp,
level: calculate_level(xp),
quests_completed: awards.len() as u32,
}
}

#[test]
fn incremental_counters_equal_full_recompute() {
let mut state: u64 = 0x9E37_79B9_7F4A_7C15;

// Fixed-size buffer keeps this `no_std`-friendly (no heap `Vec`).
let mut awards = [0u64; 64];

for _ in 0..2_000 {
let len = (next_rand(&mut state) % (awards.len() as u64)) as usize;
// Keep XP awards small so summing a full sequence cannot overflow.
for slot in awards.iter_mut().take(len) {
*slot = next_rand(&mut state) % 500;
}
let seq = &awards[..len];

assert_eq!(apply_incremental(seq), full_recompute(seq));
}
}

#[test]
fn incremental_level_tracks_thresholds() {
// The incrementally-stored `level` must equal `calculate_level(xp)` at every
// boundary, so it can be trusted without a recompute.
assert_eq!(calculate_level(0), 1);
assert_eq!(calculate_level(299), 1);
assert_eq!(calculate_level(300), 2);
assert_eq!(calculate_level(599), 2);
assert_eq!(calculate_level(600), 3);
assert_eq!(calculate_level(999), 3);
assert_eq!(calculate_level(1000), 4);
assert_eq!(calculate_level(1499), 4);
assert_eq!(calculate_level(1500), 5);
assert_eq!(calculate_level(u64::MAX), 5);
}
Loading