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.sol—xpForCharm,levelFromXp,xpToAdvance,xpToNextLevelcontracts/src/arena/TimeArena.sol—xpmapping,_finishBuy,level,xpToNextLevel,XpGainedeventcontracts/test/TimeArena.t.sol—test_xp_levels(pure math only)docs/product/arena-v2.md— XP/level product rulesdocs/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
- 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.
- 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. - Product intent is progression metadata, not heavy computation per timer tick; timer hard-resets must not wipe progression (#250 (closed) / arena-v2).
- 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 zerolevelor in-progress XP; document explicitly inarena-v2.md. - Upgradeable proxy: new storage slots append-only at end of
TimeArenalayout; document initializer/migration for existing deployments if any live player state exists. - Event compatibility: prefer keeping
XpGained(player, amount, newLevel); if semantics ofamountchange (e.g. only “applied toward level” vs “raw charm XP”), document for indexer consumers. xpForCharmformula 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/xpToNextLevelmust stay O(1) or cheap fixed work (no full history loop on hot paths).
Relevant files
Contracts (primary)
contracts/src/arena/TimeArena.solcontracts/src/arena/libraries/ArenaXp.solcontracts/test/TimeArena.t.solcontracts/test/ArenaPrizeRouting.t.sol(regression only if touched)contracts/script/DeployDev.s.sol(if storage init needed)
Docs / invariants
docs/product/arena-v2.mddocs/testing/invariants-and-business-logic.md(INV-TIME-ARENA-XP+ new gas-bound invariant)
Indexer (if event/view semantics change)
indexer/src/decoder.rsindexer/src/persist.rsindexer/src/api_arena.rs(optional: aggregate realxpfor wallet stats)indexer/migrations/(only if schema changes)indexer/tests/integration_stage2.rs
Frontend (mirror + display)
frontend/src/lib/arenaXpMath.tsfrontend/src/lib/arenaXpMath.test.tsfrontend/src/lib/abis.tsfrontend/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):
level(uint256, default 1) — cached current level.xpTowardNext(uint256) — XP accumulated within the current level towardxpToAdvance(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)
xpGain = ArenaXp.xpForCharm(charmWad)(unchanged).xpTowardNext += xpGain.levelsGained = 0.- While
levelsGained < 5andxpTowardNext >= xpToAdvance(level):xpTowardNext -= xpToAdvance(level)level += 1levelsGained += 1
emit XpGained(buyer, xpGain, level)(confirmamount= gross charm XP vs net applied — pick one, document).- Do not call
levelFromXpon the hot path.
Views
level(user)→ storedlevel(O(1)).xpToNextLevel(user)→xpToAdvance(level) - xpTowardNext(or 0 if at cap edge case documented).xp(user)→ define explicitly:xpTowardNextor 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
xpalready holds lifetime totals on a deployed net: one-time migration script or guardedinitializeV2that 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 persistlevels_gainedif new event field added (only if necessary). - Update
arenaXpMath.tswith incremental helpers; property tests vslevelFromXpon random totals.
Acceptance criteria
- Per-player cached
leveland 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.
-
levelFromXpfull loop is not invoked in_finishBuy(gas snapshot or test hook documents this). -
level()andxpToNextLevel()are O(1) (no loops over player history). -
xpForCharm/ threshold table unchanged vs #250 (closed) unless product amends spec. - Timer hard-reset /
lastBuyEpochincrement does not modify level or XP progress (Forge test). - Both
buy(DOUB) andbuyWithCredpaths use the same XP/level update helper. -
XpGainedstill emitted on every buy with correctnewLevelafter incremental update. - Forge + TS tests: incremental state matches
levelFromXpreference for randomized sequences within the 5-level cap per step and across multi-tx sequences. -
INV-TIME-ARENA-XPupdated; new invariant e.g.INV-TIME-ARENA-XP-GAS(max level-ups per buy, no reset on epoch). -
docs/product/arena-v2.mdupdated (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 testgreen forTimeArena/ArenaXptargets. - Gas report:
_finishBuywith 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). -
arenaXpMathunit tests mirror onchain incremental helper. - If indexer changed:
cargo test+integration_stage2.rsXpGainedrow still inserted. - Manual: one local Anvil buy before/after —
cast calllevel/xpToNextLevelconsistent with UI profile modal. - PR links updated
INV-TIME-ARENA-XP/ new invariant ininvariants-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/statsxpfrom chain (currently"0"). - Onchain migration of existing mainnet/testnet player
xplifetime → 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.