TimeCurve: WarBow podium can become stale when victim BP drops below 3rd-place threshold (legitimate top-3 holders not auto-promoted)
found during the post-audit security review pass (followup to #126). ## Summary `_updateWarbowPodium(address candidate, uint256 candidateValue)` only updates the warbow podium based on the SPECIFIC candidate it's called with. WarBow podium tracks top-3 by BP. BP can DECREASE via `warbowSteal` (victim loses BP). When a current podium member loses BP and falls below an off-podium wallet's BP, the off-podium wallet is NOT auto-promoted into the podium. The podium becomes stale and stays stale until that off-podium wallet happens to call `_addBattlePoints` or `_subBattlePoints` themselves. At sale end, `distributePrizes` pays from the recorded `_warbowPodium`, which may not match the actual top-3 BP holders. ## Code `TimeCurve.sol:670, 677`: ```solidity function _addBattlePoints(address a, uint256 v) internal { if (v == 0) return; battlePoints[a] += v; _updateWarbowPodium(a, battlePoints[a]); // updates only `a` } function _subBattlePoints(address a, uint256 v) internal { if (v == 0) return; uint256 b = battlePoints[a]; battlePoints[a] = b > v ? b - v : 0; _updateWarbowPodium(a, battlePoints[a]); // updates only `a` } ``` `TimeCurve.sol:881-915` (`_updateWarbowPodium` body): The function's logic: 1. If `candidate` is already in podium → update its value, re-sort, zero out empty slots 2. If `candidate` is NOT in podium AND `candidateValue > 0` → try to bump the lowest podium member if candidate beats them But it never iterates other wallets to check if THEY should now occupy the slot vacated by the dropping podium member. ## Concrete scenario Initial state: BP rankings - alice: 10000 BP (podium #1) - bob: 8000 BP (podium #2) - carol: 6000 BP (podium #3) - dave: 5000 BP (off-podium) Now bob gets stolen from heavily, his BP drops to 4000 via `_subBattlePoints(bob, 4000)`: - alice: 10000 BP (podium #1) ✓ - bob: 4000 BP (podium #2) ← stale, should not be on podium - carol: 6000 BP (podium #3) ✓ - dave: 5000 BP (off-podium, SHOULD be #2 but isn't) At sale end, `distributePrizes`: - alice gets sh0 (correct) - bob gets sh1 (WRONG — dave should have it) - carol gets sh2 (correct) dave's legitimate 2nd-place share gets paid to bob despite dave having higher BP than bob. ## Severity **Medium**. Real correctness bug at prize distribution. Affects WarBow category specifically because BP is the only category where values can decrease (other categories — defended streak, time booster, last buy — are monotonic-only and don't have this issue). The audit M-01 finding (#116) addressed empty-slot stranding (slot is `address(0)`, slice routes to protocol). This is a different shape: slot is a STALE wallet, slice goes to wrong winner. Not extraction-from-protocol but is extraction-from-rightful-winner. ## Mitigations 1. **`refreshWarbowPodium(address[] candidates)` permissionless function** — anyone passes a list of BP holders; contract reads each `battlePoints[c]` and reinserts into podium via `_updateWarbowPodium`. "Helpful third-party" pattern. Gas-bounded by candidate list size. 2. **Pre-distribution refresh requirement** — before `distributePrizes`, owner must run `finalizeWarbowPodium(address[] candidates)` that takes a list of all wallets that ever had BP and rebuilds the podium from current state. 3. **At-claim verification** — instead of pre-recording the podium, let claimants prove they're top-3 via a Merkle proof or off-chain attestation submitted at `distributePrizes` time. 4. **Track all BP holders in a set** — add an `EnumerableSet` of all addresses that ever earned BP, iterate at distribution. Gas-heavy but eliminates the issue. option 1 is most pragmatic. anyone can call it — front-runs by attacker still produce correct podium since it reads on-chain BP values directly. ## Pre-deploy relevance **TimeCurve ships tonight.** worth a decision before mainnet — without a fix, prize allocation can be incorrect on any sale where stealing changes the BP rankings. cc @PlasticDigits
issue