From 2e1e7d6382fd0b4429ef528e45d06a00b05dd4b6 Mon Sep 17 00:00:00 2001 From: walexjnr Date: Tue, 18 Aug 2026 22:26:57 +0100 Subject: [PATCH 1/2] test(earn-quest): property tests for incremental user-stat counters (#2153) get_user_stats reads the incrementally-maintained UserCore counters (updated in place by award_xp on the completion path) as an O(1) storage lookup rather than scanning and recomputing over a user's award history. Add a property/fuzz suite (test_incremental_stats) that proves the invariant this relies on: for any sequence of XP awards, the incrementally-folded counters (xp, level, quests_completed) always equal a full recompute from the complete history. The tests are pure (no storage/ledger), so they add no gas to the hot path. Document the O(1) incremental guarantee on get_user_stats. --- contracts/earn-quest/src/lib.rs | 3 + contracts/earn-quest/src/reputation.rs | 8 ++ .../earn-quest/src/test_incremental_stats.rs | 80 +++++++++++++++++++ 3 files changed, 91 insertions(+) create mode 100644 contracts/earn-quest/src/test_incremental_stats.rs diff --git a/contracts/earn-quest/src/lib.rs b/contracts/earn-quest/src/lib.rs index f7ba411eb..3570fe006 100644 --- a/contracts/earn-quest/src/lib.rs +++ b/contracts/earn-quest/src/lib.rs @@ -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}; diff --git a/contracts/earn-quest/src/reputation.rs b/contracts/earn-quest/src/reputation.rs index 488bf2e0f..b71ebee25 100644 --- a/contracts/earn-quest/src/reputation.rs +++ b/contracts/earn-quest/src/reputation.rs @@ -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. diff --git a/contracts/earn-quest/src/test_incremental_stats.rs b/contracts/earn-quest/src/test_incremental_stats.rs new file mode 100644 index 000000000..56d95c5ba --- /dev/null +++ b/contracts/earn-quest/src/test_incremental_stats.rs @@ -0,0 +1,80 @@ +//! 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; + + for _ in 0..2_000 { + let len = (next_rand(&mut state) % 64) as usize; + // Keep XP awards small so summing a full sequence cannot overflow. + let awards: Vec = (0..len).map(|_| next_rand(&mut state) % 500).collect(); + + assert_eq!(apply_incremental(&awards), full_recompute(&awards)); + } +} + +#[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); +} From f3b9f303a61f160b948038670ed433fb91397348 Mon Sep 17 00:00:00 2001 From: walexjnr Date: Tue, 18 Aug 2026 23:19:36 +0100 Subject: [PATCH 2/2] fix(earn-quest): use a fixed-size array in the stats test (no_std has no Vec) The earn-quest contract is `#![no_std]`, so `std::vec::Vec` is not in scope. Generate the fuzzed award sequences into a fixed-size `[u64; 64]` buffer and slice it, keeping the property test dependency-free and no_std-compatible. --- contracts/earn-quest/src/test_incremental_stats.rs | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/contracts/earn-quest/src/test_incremental_stats.rs b/contracts/earn-quest/src/test_incremental_stats.rs index 56d95c5ba..aaee75019 100644 --- a/contracts/earn-quest/src/test_incremental_stats.rs +++ b/contracts/earn-quest/src/test_incremental_stats.rs @@ -54,12 +54,18 @@ fn full_recompute(awards: &[u64]) -> UserCore { 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) % 64) as usize; + let len = (next_rand(&mut state) % (awards.len() as u64)) as usize; // Keep XP awards small so summing a full sequence cannot overflow. - let awards: Vec = (0..len).map(|_| next_rand(&mut state) % 500).collect(); + for slot in awards.iter_mut().take(len) { + *slot = next_rand(&mut state) % 500; + } + let seq = &awards[..len]; - assert_eq!(apply_incremental(&awards), full_recompute(&awards)); + assert_eq!(apply_incremental(seq), full_recompute(seq)); } }