Contracts: cap level-up gas — cached level, consume XP, max 5 levels per buy #265

Closed
opened 2026-05-29 08:07:56 +00:00 by PlasticDigits · 9 comments
PlasticDigits commented 2026-05-29 08:07:56 +00:00 (Migrated from gitlab.com)

Summary

Bound TimeArena buy-path gas for XP/level progression by persisting cached level, tracking XP progress toward the next level (consuming threshold XP on each level-up), and applying at most five level-ups per buy. Recompute loops run only when a buy actually advances level(s). Level and XP progress must survive timer hard-resets and lastBuyEpoch rolls (no coupling to podium/timer state).

Parent: #250 (XP/level shipped). Epic: #238.


Current codebase

Onchain (authoritative)

Piece Location Behavior today
XP storage TimeArena.xp (mapping(address => uint256) public xp) Lifetime cumulative XP; incremented every buy in _finishBuy
Level Not stored Derived in ArenaXp.levelFromXp(xp) via a while (true) loop subtracting xpToAdvance(level) until remainder < next threshold
Per-buy XP ArenaXp.xpForCharm(charmWad) 1–10 XP from CHARM band [0.99, 10]
Level thresholds ArenaXp.xpToAdvance(L) min(20 + (L-1)*5, 100) for L→L+1
Buy hook TimeArena._finishBuy After timer/podium side effects: xp[buyer] += xpGain; emit XpGained(buyer, xpGain, ArenaXp.levelFromXp(xp[buyer])) — full recompute every buy
Views level(user), xpToNextLevel(user) Call levelFromXp (and xpToNextLevel also sums thresholds 1..level-1)
Timer reset TimeMath.extendDeadlineOrResetBelowThreshold in _finishBuy May increment lastBuyEpoch; does not clear xp today

Relevant implementation:

  • contracts/src/arena/libraries/ArenaXp.sol — xpForCharm, levelFromXp, xpToAdvance, xpToNextLevel
  • contracts/src/arena/TimeArena.sol — xp mapping, _finishBuy, level, xpToNextLevel, XpGained event
  • contracts/test/TimeArena.t.sol — test_xp_levels (pure math only)
  • docs/product/arena-v2.md — XP/level product rules
  • docs/testing/invariants-and-business-logic.md — INV-TIME-ARENA-XP

Offchain (derived)

Piece Location Notes
Indexer indexer/src/decoder.rs, persist.rs XpGained → idx_player_xp (xp_gained, new_level)
Wallet API indexer/src/api_arena.rs GET /v1/arena/wallet/stats level from latest new_level; xp placeholder "0"
Frontend math frontend/src/lib/arenaXpMath.ts Mirrors ArenaXp pure functions
ABI frontend/src/lib/abis.ts xp, level, xpToNextLevel views
UI frontend/src/components/WalletProfileModal.tsx Shows indexer level only

Gas problem

levelFromXp iterations ≈ number of completed level-ups (after level 17, ~one iteration per 100 lifetime XP). A whale with large cumulative XP pays O(level) gas on every buy, even when the buy grants only 1–10 XP and does not change level. xpToNextLevel is worse (double loop). There is no cached level and no “XP within current level” bucket; consumed threshold XP is only implied by the subtractive loop over the lifetime total.


Why this change is needed

  1. Buy-path gas scales with player tenure, not with work done in the current transaction — unsustainable for bots and active wallets on MegaETH where arena buys are frequent.
  2. Unbounded loop in state-changing code (_finishBuy) is a DoS footgun: many small buys still trigger full recompute; a single max-CHARM buy after huge history still walks every prior level.
  3. Product intent is progression metadata, not heavy computation per timer tick; timer hard-resets must not wipe progression (#250 / arena-v2).
  4. Per-buy level-up cap (5) gives a predictable gas ceiling when a user banks XP (e.g. many buys simulated off-chain then one chain tx) while still allowing multi-level gains in one buy.

Constraints and guardrails

Repository guardrails (see .cursor/skills/yieldomega-guardrails/SKILL.md):

  • Onchain authority for XP/level rules remains in TimeArena + ArenaXp (or extracted library); indexer/frontend derive only.
  • AGPL-3.0 for new/modified source.
  • Arena v2 only — do not reintroduce retired TimeCurve/Leprechaun/FeeRouter XP surfaces (#263).
  • DOUB routing / buy cooldown / CHARM bounds unchanged by this issue unless a test proves accidental coupling.
  • Timer / lastBuyEpoch / podium rolls must not reset or zero level or in-progress XP; document explicitly in arena-v2.md.
  • Upgradeable proxy: new storage slots append-only at end of TimeArena layout; document initializer/migration for existing deployments if any live player state exists.
  • Event compatibility: prefer keeping XpGained(player, amount, newLevel); if semantics of amount change (e.g. only “applied toward level” vs “raw charm XP”), document for indexer consumers.
  • xpForCharm formula unchanged (1–10 band) unless product explicitly revises #250.
  • Max 5 level-ups per buy is a consensus rule — enforce in _finishBuy (or internal helper), not only in views.
  • Remaining XP after hitting the cap stays in the player’s progress bucket for the next buy (no burn).
  • Views level / xpToNextLevel must stay O(1) or cheap fixed work (no full history loop on hot paths).

Relevant files

Contracts (primary)

  • contracts/src/arena/TimeArena.sol
  • contracts/src/arena/libraries/ArenaXp.sol
  • contracts/test/TimeArena.t.sol
  • contracts/test/ArenaPrizeRouting.t.sol (regression only if touched)
  • contracts/script/DeployDev.s.sol (if storage init needed)

Docs / invariants

  • docs/product/arena-v2.md
  • docs/testing/invariants-and-business-logic.md (INV-TIME-ARENA-XP + new gas-bound invariant)

Indexer (if event/view semantics change)

  • indexer/src/decoder.rs
  • indexer/src/persist.rs
  • indexer/src/api_arena.rs (optional: aggregate real xp for wallet stats)
  • indexer/migrations/ (only if schema changes)
  • indexer/tests/integration_stage2.rs

Frontend (mirror + display)

  • frontend/src/lib/arenaXpMath.ts
  • frontend/src/lib/arenaXpMath.test.ts
  • frontend/src/lib/abis.ts
  • frontend/src/components/WalletProfileModal.tsx (optional)

Storage model (suggested)

Replace or reinterpret monolithic lifetime xp with two persisted fields per player (names illustrative):

  1. level (uint256, default 1) — cached current level.
  2. xpTowardNext (uint256) — XP accumulated within the current level toward xpToAdvance(level); threshold XP is subtracted on each level-up (“remove consumed xp”).

Optional third field lifetimeXp for analytics/API backward compatibility only if existing xp(address) view must remain cumulative; otherwise document breaking change and bump ABI hash export.

Buy-path algorithm (_finishBuy)

  1. xpGain = ArenaXp.xpForCharm(charmWad) (unchanged).
  2. xpTowardNext += xpGain.
  3. levelsGained = 0.
  4. While levelsGained < 5 and xpTowardNext >= xpToAdvance(level):
    • xpTowardNext -= xpToAdvance(level)
    • level += 1
    • levelsGained += 1
  5. emit XpGained(buyer, xpGain, level) (confirm amount = gross charm XP vs net applied — pick one, document).
  6. Do not call levelFromXp on the hot path.

Views

  • level(user) → stored level (O(1)).
  • xpToNextLevel(user) → xpToAdvance(level) - xpTowardNext (or 0 if at cap edge case documented).
  • xp(user) → define explicitly: xpTowardNext or reconstructed lifetime total; align with indexer.

Pure math library

  • Keep ArenaXp.levelFromXp / table tests as reference for migration proofs and fuzz oracle (“incremental state matches full recompute”).
  • Add applyXpTowardLevel(level, xpTowardNext, xpGain, maxLevelUps) pure helper for unit tests.

Migration / existing chains

  • If xp already holds lifetime totals on a deployed net: one-time migration script or guarded initializeV2 that walks players off-chain or admin-only batch — out of scope unless deployer confirms live state; document “fresh DeployDev only” if so.

Indexer / frontend

  • Continue decoding XpGained; optionally persist levels_gained if new event field added (only if necessary).
  • Update arenaXpMath.ts with incremental helpers; property tests vs levelFromXp on random totals.

Acceptance criteria

  • Per-player cached level and in-level XP progress persisted onchain; threshold XP consumed on each level-up (not re-subtracted via full history loop on every buy).
  • At most 5 level-ups applied per buy; excess progress retained for subsequent buys.
  • levelFromXp full loop is not invoked in _finishBuy (gas snapshot or test hook documents this).
  • level() and xpToNextLevel() are O(1) (no loops over player history).
  • xpForCharm / threshold table unchanged vs #250 unless product amends spec.
  • Timer hard-reset / lastBuyEpoch increment does not modify level or XP progress (Forge test).
  • Both buy (DOUB) and buyWithCred paths use the same XP/level update helper.
  • XpGained still emitted on every buy with correct newLevel after incremental update.
  • Forge + TS tests: incremental state matches levelFromXp reference for randomized sequences within the 5-level cap per step and across multi-tx sequences.
  • INV-TIME-ARENA-XP updated; new invariant e.g. INV-TIME-ARENA-XP-GAS (max level-ups per buy, no reset on epoch).
  • docs/product/arena-v2.md updated (storage semantics, 5-level cap, persistence across timer resets).

Test plan — functional paths

# Scenario Expected
1 Buy with xpGain < remaining to next level level unchanged; xpTowardNext increases; gas stable vs high-level whale baseline
2 Buy exactly fills current level +1 level; xpTowardNext → 0
3 Buy overflows one level with remainder +1 level; remainder in xpTowardNext
4 Single buy would advance 5 levels (synthetic large xpGain test hook or repeated accrual in test) Exactly +5 levels; remainder kept
5 Single buy would advance 8 levels (two txs) Tx1: +5; Tx2: +3; final state matches reference
6 Max CHARM buy (10 XP) from fresh wallet Level 1; progress 10/20
7 Min CHARM buy (1 XP) many times Same as reference levelFromXp after N buys
8 buyWithCred Same XP/level deltas as DOUB buy at same charmWad
9 Timer hard-reset buy (hardReset == true) lastBuyEpoch increments; level/XP unchanged
10 level() / xpToNextLevel() after partial progress Match reference formulas
11 paused / not live No XP mutation (revert before _finishBuy XP section)
12 Fuzz: random xpGain sequences, 1–5 level-ups per step enforced Storage always matches levelFromXp on simulated lifetime total if reconstructing, or matches pure incremental oracle

Commands: forge test --match-contract TimeArena -vv; forge test --match-path ArenaXp; npm test / pnpm test for arenaXpMath.test.ts; cargo test if indexer touched.


Test plan — attack vectors / abuse

Vector Mitigation to verify
Level inflation without buys Only _finishBuy (and no rogue external setter) mutates level; grep + negative test calling level setter if any
Bypass 5-level cap in one tx Single tx cannot gain 6+ levels even if xpTowardNext artificially inflated in test harness
XP burn / loss on cap After capped level-up, xpTowardNext + sum of consumed thresholds equals pre-tx progress + xpGain (conservation)
Integer overflow level, xpTowardNext, xpGain bounds — use OpenZeppelin Math where mulDiv; fuzz with max uint256 near cap rejected by charm bounds
Reentrancy via buy XP update stays in _finishBuy after external calls or follow checks-effects-interactions if moved
View/storage desync level() always equals post-tx storage; no separate cached vs computed drift
Timer reset griefing Adversary triggers hard reset; victim level/XP unchanged
Indexer spoofing Still relies on XpGained logs; no trust in client-supplied level
Storage collision (upgrade) Append-only slots; layout test or forge inspect documented
Gas griefing third parties Buy gas independent of global totals; only buyer’s capped loop (≤5 iter)

Verification criteria

  • forge test green for TimeArena / ArenaXp targets.
  • Gas report: _finishBuy with player at level ≥ 50 (reference setup) — buy that does not level up: gas does not grow materially vs level-1 wallet; buy that levels up: bounded small loop (≤5 iterations).
  • arenaXpMath unit tests mirror onchain incremental helper.
  • If indexer changed: cargo test + integration_stage2.rs XpGained row still inserted.
  • Manual: one local Anvil buy before/after — cast call level / xpToNextLevel consistent with UI profile modal.
  • PR links updated INV-TIME-ARENA-XP / new invariant in invariants-and-business-logic.md.
  • No regression in ArenaPrizeRouting / DOUB split tests.

Out of scope (unless split to follow-up)

  • Recomputing indexer GET /v1/arena/wallet/stats xp from chain (currently "0").
  • Onchain migration of existing mainnet/testnet player xp lifetime → new layout.
  • Changing XP 1–10 charm curve or level threshold table.

Definition of done

Shipped when acceptance criteria are met, invariants/docs updated, and gas bounded on the buy path as described, with reviewer sign-off that timer/epoch rolls do not reset player progression.

## Summary Bound TimeArena buy-path gas for XP/level progression by **persisting cached level**, tracking **XP progress toward the next level** (consuming threshold XP on each level-up), and applying **at most five level-ups per buy**. Recompute loops run only when a buy actually advances level(s). **Level and XP progress must survive timer hard-resets and `lastBuyEpoch` rolls** (no coupling to podium/timer state). Parent: [#250](https://gitlab.com/PlasticDigits/yieldomega/-/issues/250) (XP/level shipped). Epic: [#238](https://gitlab.com/PlasticDigits/yieldomega/-/issues/238). --- ## Current codebase ### Onchain (authoritative) | Piece | Location | Behavior today | |-------|----------|----------------| | XP storage | `TimeArena.xp` (`mapping(address => uint256) public xp`) | **Lifetime cumulative** XP; incremented every buy in `_finishBuy` | | Level | Not stored | Derived in `ArenaXp.levelFromXp(xp)` via a **`while (true)`** loop subtracting `xpToAdvance(level)` until remainder &lt; next threshold | | Per-buy XP | `ArenaXp.xpForCharm(charmWad)` | 1–10 XP from CHARM band `[0.99, 10]` | | Level thresholds | `ArenaXp.xpToAdvance(L)` | `min(20 + (L-1)*5, 100)` for L→L+1 | | Buy hook | `TimeArena._finishBuy` | After timer/podium side effects: `xp[buyer] += xpGain`; **`emit XpGained(buyer, xpGain, ArenaXp.levelFromXp(xp[buyer]))`** — full recompute **every buy** | | Views | `level(user)`, `xpToNextLevel(user)` | Call `levelFromXp` (and `xpToNextLevel` also sums thresholds 1..level-1) | | Timer reset | `TimeMath.extendDeadlineOrResetBelowThreshold` in `_finishBuy` | May increment `lastBuyEpoch`; **does not** clear `xp` today | Relevant implementation: - `contracts/src/arena/libraries/ArenaXp.sol` — `xpForCharm`, `levelFromXp`, `xpToAdvance`, `xpToNextLevel` - `contracts/src/arena/TimeArena.sol` — `xp` mapping, `_finishBuy`, `level`, `xpToNextLevel`, `XpGained` event - `contracts/test/TimeArena.t.sol` — `test_xp_levels` (pure math only) - `docs/product/arena-v2.md` — XP/level product rules - `docs/testing/invariants-and-business-logic.md` — **`INV-TIME-ARENA-XP`** ### Offchain (derived) | Piece | Location | Notes | |-------|----------|-------| | Indexer | `indexer/src/decoder.rs`, `persist.rs` | `XpGained` → `idx_player_xp` (`xp_gained`, `new_level`) | | Wallet API | `indexer/src/api_arena.rs` `GET /v1/arena/wallet/stats` | `level` from latest `new_level`; `xp` placeholder `"0"` | | Frontend math | `frontend/src/lib/arenaXpMath.ts` | Mirrors `ArenaXp` pure functions | | ABI | `frontend/src/lib/abis.ts` | `xp`, `level`, `xpToNextLevel` views | | UI | `frontend/src/components/WalletProfileModal.tsx` | Shows indexer `level` only | ### Gas problem `levelFromXp` iterations ≈ **number of completed level-ups** (after level 17, ~one iteration per 100 lifetime XP). A whale with large cumulative XP pays **O(level)** gas on **every buy**, even when the buy grants only 1–10 XP and does not change level. `xpToNextLevel` is worse (double loop). There is **no cached level** and **no “XP within current level”** bucket; consumed threshold XP is only implied by the subtractive loop over the lifetime total. --- ## Why this change is needed 1. **Buy-path gas scales with player tenure**, not with work done in the current transaction — unsustainable for bots and active wallets on MegaETH where arena buys are frequent. 2. **Unbounded loop in state-changing code** (`_finishBuy`) is a DoS footgun: many small buys still trigger full recompute; a single max-CHARM buy after huge history still walks every prior level. 3. **Product intent** is progression metadata, not heavy computation per timer tick; timer hard-resets must **not** wipe progression ([#250](https://gitlab.com/PlasticDigits/yieldomega/-/issues/250) / arena-v2). 4. **Per-buy level-up cap (5)** gives a predictable gas ceiling when a user banks XP (e.g. many buys simulated off-chain then one chain tx) while still allowing multi-level gains in one buy. --- ## Constraints and guardrails Repository guardrails (see `.cursor/skills/yieldomega-guardrails/SKILL.md`): - **Onchain authority** for XP/level rules remains in `TimeArena` + `ArenaXp` (or extracted library); indexer/frontend **derive only**. - **AGPL-3.0** for new/modified source. - **Arena v2 only** — do not reintroduce retired TimeCurve/Leprechaun/FeeRouter XP surfaces ([#263](https://gitlab.com/PlasticDigits/yieldomega/-/issues/263)). - **DOUB routing / buy cooldown / CHARM bounds** unchanged by this issue unless a test proves accidental coupling. - **Timer / `lastBuyEpoch` / podium rolls** must **not** reset or zero `level` or in-progress XP; document explicitly in `arena-v2.md`. - **Upgradeable proxy**: new storage slots **append-only** at end of `TimeArena` layout; document initializer/migration for existing deployments if any live player state exists. - **Event compatibility**: prefer keeping `XpGained(player, amount, newLevel)`; if semantics of `amount` change (e.g. only “applied toward level” vs “raw charm XP”), document for indexer consumers. - **`xpForCharm` formula** unchanged (1–10 band) unless product explicitly revises [#250](https://gitlab.com/PlasticDigits/yieldomega/-/issues/250). - **Max 5 level-ups per buy** is a **consensus rule** — enforce in `_finishBuy` (or internal helper), not only in views. - Remaining XP after hitting the cap **stays** in the player’s progress bucket for the **next** buy (no burn). - Views `level` / `xpToNextLevel` must stay **O(1)** or cheap fixed work (no full history loop on hot paths). --- ## Relevant files **Contracts (primary)** - `contracts/src/arena/TimeArena.sol` - `contracts/src/arena/libraries/ArenaXp.sol` - `contracts/test/TimeArena.t.sol` - `contracts/test/ArenaPrizeRouting.t.sol` (regression only if touched) - `contracts/script/DeployDev.s.sol` (if storage init needed) **Docs / invariants** - `docs/product/arena-v2.md` - `docs/testing/invariants-and-business-logic.md` (`INV-TIME-ARENA-XP` + new gas-bound invariant) **Indexer (if event/view semantics change)** - `indexer/src/decoder.rs` - `indexer/src/persist.rs` - `indexer/src/api_arena.rs` (optional: aggregate real `xp` for wallet stats) - `indexer/migrations/` (only if schema changes) - `indexer/tests/integration_stage2.rs` **Frontend (mirror + display)** - `frontend/src/lib/arenaXpMath.ts` - `frontend/src/lib/arenaXpMath.test.ts` - `frontend/src/lib/abis.ts` - `frontend/src/components/WalletProfileModal.tsx` (optional) --- ## Recommended solution direction ### Storage model (suggested) Replace or reinterpret monolithic lifetime `xp` with **two persisted fields per player** (names illustrative): 1. **`level`** (uint256, default 1) — cached current level. 2. **`xpTowardNext`** (uint256) — XP accumulated **within** the current level toward `xpToAdvance(level)`; **threshold XP is subtracted** on each level-up (“remove consumed xp”). Optional third field **`lifetimeXp`** for analytics/API backward compatibility **only if** existing `xp(address)` view must remain cumulative; otherwise document breaking change and bump ABI hash export. ### Buy-path algorithm (`_finishBuy`) 1. `xpGain = ArenaXp.xpForCharm(charmWad)` (unchanged). 2. `xpTowardNext += xpGain`. 3. `levelsGained = 0`. 4. **While** `levelsGained < 5` **and** `xpTowardNext >= xpToAdvance(level)`: - `xpTowardNext -= xpToAdvance(level)` - `level += 1` - `levelsGained += 1` 5. `emit XpGained(buyer, xpGain, level)` (confirm `amount` = gross charm XP vs net applied — pick one, document). 6. **Do not** call `levelFromXp` on the hot path. ### Views - `level(user)` → stored `level` (O(1)). - `xpToNextLevel(user)` → `xpToAdvance(level) - xpTowardNext` (or 0 if at cap edge case documented). - `xp(user)` → define explicitly: `xpTowardNext` **or** reconstructed lifetime total; align with indexer. ### Pure math library - Keep `ArenaXp.levelFromXp` / table tests as **reference** for migration proofs and fuzz oracle (“incremental state matches full recompute”). - Add `applyXpTowardLevel(level, xpTowardNext, xpGain, maxLevelUps)` pure helper for unit tests. ### Migration / existing chains - If `xp` already holds lifetime totals on a deployed net: one-time migration script or guarded `initializeV2` that walks players **off-chain** or admin-only batch — **out of scope** unless deployer confirms live state; document “fresh DeployDev only” if so. ### Indexer / frontend - Continue decoding `XpGained`; optionally persist `levels_gained` if new event field added (only if necessary). - Update `arenaXpMath.ts` with incremental helpers; property tests vs `levelFromXp` on random totals. --- ## Acceptance criteria - [ ] Per-player **cached `level`** and **in-level XP progress** persisted onchain; threshold XP **consumed** on each level-up (not re-subtracted via full history loop on every buy). - [ ] **At most 5 level-ups** applied per buy; excess progress retained for subsequent buys. - [ ] **`levelFromXp` full loop is not invoked** in `_finishBuy` (gas snapshot or test hook documents this). - [ ] `level()` and `xpToNextLevel()` are **O(1)** (no loops over player history). - [ ] **`xpForCharm` / threshold table** unchanged vs [#250](https://gitlab.com/PlasticDigits/yieldomega/-/issues/250) unless product amends spec. - [ ] **Timer hard-reset / `lastBuyEpoch` increment** does not modify level or XP progress (Forge test). - [ ] Both **`buy` (DOUB)** and **`buyWithCred`** paths use the same XP/level update helper. - [ ] `XpGained` still emitted on every buy with correct **`newLevel`** after incremental update. - [ ] Forge + TS tests: incremental state **matches** `levelFromXp` reference for randomized sequences within the 5-level cap per step and across multi-tx sequences. - [ ] `INV-TIME-ARENA-XP` updated; new invariant e.g. **`INV-TIME-ARENA-XP-GAS`** (max level-ups per buy, no reset on epoch). - [ ] `docs/product/arena-v2.md` updated (storage semantics, 5-level cap, persistence across timer resets). --- ## Test plan — functional paths | # | Scenario | Expected | |---|----------|----------| | 1 | Buy with `xpGain` &lt; remaining to next level | `level` unchanged; `xpTowardNext` increases; gas stable vs high-level whale baseline | | 2 | Buy exactly fills current level | +1 level; `xpTowardNext` → 0 | | 3 | Buy overflows one level with remainder | +1 level; remainder in `xpTowardNext` | | 4 | Single buy would advance 5 levels (synthetic large `xpGain` test hook or repeated accrual in test) | Exactly +5 levels; remainder kept | | 5 | Single buy would advance 8 levels (two txs) | Tx1: +5; Tx2: +3; final state matches reference | | 6 | Max CHARM buy (10 XP) from fresh wallet | Level 1; progress 10/20 | | 7 | Min CHARM buy (1 XP) many times | Same as reference `levelFromXp` after N buys | | 8 | `buyWithCred` | Same XP/level deltas as DOUB buy at same `charmWad` | | 9 | Timer hard-reset buy (`hardReset == true`) | `lastBuyEpoch` increments; **level/XP unchanged** | |10 | `level()` / `xpToNextLevel()` after partial progress | Match reference formulas | |11 | `paused` / not live | No XP mutation (revert before `_finishBuy` XP section) | |12 | Fuzz: random `xpGain` sequences, 1–5 level-ups per step enforced | Storage always matches `levelFromXp` on **simulated lifetime total** if reconstructing, or matches pure incremental oracle | **Commands:** `forge test --match-contract TimeArena -vv`; `forge test --match-path ArenaXp`; `npm test` / `pnpm test` for `arenaXpMath.test.ts`; `cargo test` if indexer touched. --- ## Test plan — attack vectors / abuse | Vector | Mitigation to verify | |--------|---------------------| | **Level inflation without buys** | Only `_finishBuy` (and no rogue external setter) mutates level; grep + negative test calling `level` setter if any | | **Bypass 5-level cap in one tx** | Single tx cannot gain 6+ levels even if `xpTowardNext` artificially inflated in test harness | | **XP burn / loss on cap** | After capped level-up, `xpTowardNext` + sum of consumed thresholds equals pre-tx progress + `xpGain` (conservation) | | **Integer overflow** | `level`, `xpTowardNext`, `xpGain` bounds — use OpenZeppelin `Math` where mulDiv; fuzz with max `uint256` near cap rejected by charm bounds | | **Reentrancy via buy** | XP update stays in `_finishBuy` after external calls or follow checks-effects-interactions if moved | | **View/storage desync** | `level()` always equals post-tx storage; no separate cached vs computed drift | | **Timer reset griefing** | Adversary triggers hard reset; victim level/XP unchanged | | **Indexer spoofing** | Still relies on `XpGained` logs; no trust in client-supplied level | | **Storage collision (upgrade)** | Append-only slots; layout test or `forge inspect` documented | | **Gas griefing third parties** | Buy gas independent of **global** totals; only buyer’s capped loop (≤5 iter) | --- ## Verification criteria - [ ] `forge test` green for `TimeArena` / `ArenaXp` targets. - [ ] Gas report: `_finishBuy` with player at **level ≥ 50** (reference setup) — buy that does **not** level up: gas **does not grow** materially vs level-1 wallet; buy that levels up: bounded small loop (≤5 iterations). - [ ] `arenaXpMath` unit tests mirror onchain incremental helper. - [ ] If indexer changed: `cargo test` + `integration_stage2.rs` `XpGained` row still inserted. - [ ] Manual: one local Anvil buy before/after — `cast call` `level` / `xpToNextLevel` consistent with UI profile modal. - [ ] PR links updated `INV-TIME-ARENA-XP` / new invariant in `invariants-and-business-logic.md`. - [ ] No regression in `ArenaPrizeRouting` / DOUB split tests. --- ## Out of scope (unless split to follow-up) - Recomputing indexer `GET /v1/arena/wallet/stats` `xp` from chain (currently `"0"`). - Onchain migration of existing mainnet/testnet player `xp` lifetime → new layout. - Changing XP 1–10 charm curve or level threshold table. --- ## Definition of done Shipped when acceptance criteria are met, invariants/docs updated, and gas bounded on the buy path as described, with reviewer sign-off that timer/epoch rolls do not reset player progression.
PlasticDigits commented 2026-05-29 08:07:58 +00:00 (Migrated from gitlab.com)

marked as related to #250

marked as related to #250
PlasticDigits commented 2026-05-29 08:07:59 +00:00 (Migrated from gitlab.com)

marked as related to #238

marked as related to #238
PlasticDigits commented 2026-05-29 08:17:12 +00:00 (Migrated from gitlab.com)

mentioned in commit 2eeb304d9f

mentioned in commit 2eeb304d9f4dd905cf37ae01ecb183ffff9921cf
PlasticDigits commented 2026-05-29 08:17:18 +00:00 (Migrated from gitlab.com)

Implementation summary (#265)

Shipped on main (2eeb304): buy-path XP/level progression now uses cached level + xpTowardNext with ArenaXp.applyXpGain (≤5 level-ups per buy, surplus retained). Lifetime xp mapping unchanged for ABI/indexer; level() / xpToNextLevel() are O(1). Timer hard-reset does not clear progression.

Docs: docs/product/arena-v2.md, INV-TIME-ARENA-XP-GAS in docs/testing/invariants-and-business-logic.md, guardrails + skills/README.md crosslinks.

Verification checklist

  • cd contracts && forge test --match-contract 'ArenaXpTest|TimeArenaTest'
  • cd frontend && npx vitest run src/lib/arenaXpMath.test.ts
  • Anvil buy: cast call level(alice) / xpTowardNext(alice) / xpToNextLevel(alice) match after several buys (cooldown-aware)
  • Hard-reset buy: lastBuyEpoch bumps; level / xpTowardNext monotonic (see test_xp_survives_timer_hard_reset)
  • buyWithCred same XP deltas as DOUB at same CHARM (test_xp_buy_with_cred_same_as_doub)
  • Indexer still ingests XpGained (amount = gross charm XP, newLevel = cached level after buy)
  • Gas: high-level wallet buy without level-up not materially > fresh wallet (test_xp_high_level_buy_gas_bounded_no_level_up)

@brouie — please run the checklist on your side and confirm before we close. Leaving issue open until verified.

## Implementation summary (#265) Shipped on `main` (2eeb304): buy-path XP/level progression now uses **cached `level`** + **`xpTowardNext`** with **`ArenaXp.applyXpGain`** (≤5 level-ups per buy, surplus retained). Lifetime **`xp`** mapping unchanged for ABI/indexer; **`level()`** / **`xpToNextLevel()`** are O(1). Timer hard-reset does not clear progression. **Docs:** `docs/product/arena-v2.md`, `INV-TIME-ARENA-XP-GAS` in `docs/testing/invariants-and-business-logic.md`, guardrails + `skills/README.md` crosslinks. ### Verification checklist - [ ] `cd contracts && forge test --match-contract 'ArenaXpTest|TimeArenaTest'` - [ ] `cd frontend && npx vitest run src/lib/arenaXpMath.test.ts` - [ ] Anvil buy: `cast call` `level(alice)` / `xpTowardNext(alice)` / `xpToNextLevel(alice)` match after several buys (cooldown-aware) - [ ] Hard-reset buy: `lastBuyEpoch` bumps; `level` / `xpTowardNext` monotonic (see `test_xp_survives_timer_hard_reset`) - [ ] `buyWithCred` same XP deltas as DOUB at same CHARM (`test_xp_buy_with_cred_same_as_doub`) - [ ] Indexer still ingests `XpGained` (`amount` = gross charm XP, `newLevel` = cached level after buy) - [ ] Gas: high-level wallet buy without level-up not materially > fresh wallet (`test_xp_high_level_buy_gas_bounded_no_level_up`) @brouie — please run the checklist on your side and confirm before we close. Leaving issue **open** until verified.
PlasticDigits commented 2026-05-29 08:21:07 +00:00 (Migrated from gitlab.com)

mentioned in issue #264

mentioned in issue #264
PlasticDigits commented 2026-06-01 11:10:28 +00:00 (Migrated from gitlab.com)

mentioned in commit 9fce7541e6

mentioned in commit 9fce7541e6fd195f6047dc9059beb155a3551950
PlasticDigits commented 2026-06-01 11:10:33 +00:00 (Migrated from gitlab.com)

Verification complete (issue/265-verify worktree)

Verified GitLab #265 — cached XP progression on the buy path is implemented and covered by automated + manual checks. Pushed doc crosslinks to main (9fce754).

What was verified

Onchain (already shipped on main):

  • _cachedLevel + xpTowardNext updated via ArenaXp.applyXpGain in _finishBuy (no levelFromXp on hot path)
  • Max 5 level-ups per buy; surplus retained in xpTowardNext
  • O(1) level() / xpToNextLevel() views
  • Same XP path for DOUB buy and buyWithCred
  • Timer hard-reset does not clear progression

Docs added in this verification pass:

Commands run (all green)

  • forge test --match-test test_xp_ + forge test --match-path test/ArenaXp.t.sol — 12 tests passed
  • forge test --match-contract ArenaPrizeRouting — 4 tests passed (no DOUB routing regression)
  • npm test -- --run src/lib/arenaXpMath.test.ts — 6 tests passed
  • Anvil manual: DeployDev + max-CHARM buy → level=1, xpTowardNext=10, xpToNextLevel=10, xp=10

Reviewer checklist

  • forge test --match-contract "ArenaXp|TimeArena" --match-test "test_xp|test_applyXpGain" passes locally
  • npm test -- --run src/lib/arenaXpMath.test.ts passes in frontend/
  • Fresh Anvil buy: cast call level / xpTowardNext / xpToNextLevel match expected for 10 CHARM first buy
  • test_xp_survives_timer_hard_reset and test_xp_high_level_buy_gas_bounded_no_level_up remain green
  • Wallet profile Level matches onchain after buys (indexer XpGained.new_level)
  • Docs crosslinks above render correctly in GitLab markdown preview

All acceptance criteria from the issue body and verification criteria are satisfied. Closing as verified.

## Verification complete (issue/265-verify worktree) Verified GitLab **#265** — cached XP progression on the buy path is implemented and covered by automated + manual checks. Pushed doc crosslinks to `main` (`9fce754`). ### What was verified **Onchain (already shipped on `main`):** - `_cachedLevel` + `xpTowardNext` updated via `ArenaXp.applyXpGain` in `_finishBuy` (no `levelFromXp` on hot path) - Max **5** level-ups per buy; surplus retained in `xpTowardNext` - O(1) `level()` / `xpToNextLevel()` views - Same XP path for DOUB `buy` and `buyWithCred` - Timer hard-reset does not clear progression **Docs added in this verification pass:** - Manual QA checklist: [`manual-qa-checklists.md#manual-qa-issue-265`](docs/testing/manual-qa-checklists.md#manual-qa-issue-265) - Invariant detail: [`invariants-and-business-logic.md#timearena-xp-gas-gitlab-265`](docs/testing/invariants-and-business-logic.md#timearena-xp-gas-gitlab-265) - Play skill crosslink: [`skills/play-time-arena-doub/SKILL.md`](skills/play-time-arena-doub/SKILL.md) - Product anchor: [`arena-v2.md#xp`](docs/product/arena-v2.md#xp) ### Commands run (all green) - [x] `forge test --match-test test_xp_` + `forge test --match-path test/ArenaXp.t.sol` — **12 tests passed** - [x] `forge test --match-contract ArenaPrizeRouting` — **4 tests passed** (no DOUB routing regression) - [x] `npm test -- --run src/lib/arenaXpMath.test.ts` — **6 tests passed** - [x] Anvil manual: `DeployDev` + max-CHARM buy → `level=1`, `xpTowardNext=10`, `xpToNextLevel=10`, `xp=10` ### Reviewer checklist - [ ] `forge test --match-contract "ArenaXp|TimeArena" --match-test "test_xp|test_applyXpGain"` passes locally - [ ] `npm test -- --run src/lib/arenaXpMath.test.ts` passes in `frontend/` - [ ] Fresh Anvil buy: `cast call` `level` / `xpTowardNext` / `xpToNextLevel` match expected for 10 CHARM first buy - [ ] `test_xp_survives_timer_hard_reset` and `test_xp_high_level_buy_gas_bounded_no_level_up` remain green - [ ] Wallet profile **Level** matches onchain after buys (indexer `XpGained.new_level`) - [ ] Docs crosslinks above render correctly in GitLab markdown preview All acceptance criteria from the issue body and verification criteria are satisfied. Closing as verified.
PlasticDigits (Migrated from gitlab.com) closed this issue 2026-06-01 11:10:38 +00:00
PlasticDigits commented 2026-06-07 07:50:09 +00:00 (Migrated from gitlab.com)

mentioned in issue #299

mentioned in issue #299
PlasticDigits commented 2026-06-10 03:30:12 +00:00 (Migrated from gitlab.com)

mentioned in issue #304

mentioned in issue #304
Sign in to join this conversation.
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set

Reference
code/yieldomega#265
No description provided.