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

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 (closed) (XP/level shipped). Epic: #238 (closed).


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.solxpForCharm, levelFromXp, xpToAdvance, xpToNextLevel
  • contracts/src/arena/TimeArena.solxp mapping, _finishBuy, level, xpToNextLevel, XpGained event
  • contracts/test/TimeArena.t.soltest_xp_levels (pure math only)
  • docs/product/arena-v2.md — XP/level product rules
  • docs/testing/invariants-and-business-logic.mdINV-TIME-ARENA-XP

Offchain (derived)

Piece Location Notes
Indexer indexer/src/decoder.rs, persist.rs XpGainedidx_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 (closed) / 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 (closed)).
  • 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 (closed).
  • 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 (closed) 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.