TimeCurve: WarBow podium can become stale when victim BP drops below 3rd-place threshold (legitimate top-3 holders not auto-promoted) #129
Labels
No labels
agent:fix_bugfix
agent:fix_conflicts
agent:fix_security
agent:gap_analysis
agent:implement
agent:open_issues
agents
agent:security_audit
agent:verify
area
timecurve
arena
arena-v2
audit
block:feature_review
block:security
bug
contracts
design
devops
docs
documentation
enhancement
epic
feature
frontend
indexer
low priority
megaeth
operations
ops
performance
QA
qa checklist
referrals
removal
security
smart-contracts
tech-debt
testing
time-arena
timecurve
visual-qa
No milestone
No project
No assignees
1 participant
Notifications
Due date
No due date set.
Dependencies
No dependencies set
Reference
code/yieldomega#129
Loading…
Reference in a new issue
No description provided.
Delete branch "%!s()"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
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 viawarbowSteal(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
_addBattlePointsor_subBattlePointsthemselves.At sale end,
distributePrizespays from the recorded_warbowPodium, which may not match the actual top-3 BP holders.Code
TimeCurve.sol:670, 677:TimeCurve.sol:881-915(_updateWarbowPodiumbody):The function's logic:
candidateis already in podium → update its value, re-sort, zero out empty slotscandidateis NOT in podium ANDcandidateValue > 0→ try to bump the lowest podium member if candidate beats themBut 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
Now bob gets stolen from heavily, his BP drops to 4000 via
_subBattlePoints(bob, 4000):At sale end,
distributePrizes: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
refreshWarbowPodium(address[] candidates)permissionless function — anyone passes a list of BP holders; contract reads eachbattlePoints[c]and reinserts into podium via_updateWarbowPodium. "Helpful third-party" pattern. Gas-bounded by candidate list size.Pre-distribution refresh requirement — before
distributePrizes, owner must runfinalizeWarbowPodium(address[] candidates)that takes a list of all wallets that ever had BP and rebuilds the podium from current state.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
distributePrizestime.Track all BP holders in a set — add an
EnumerableSetof 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
mentioned in issue #131
mentioned in commit
aa4907475eImplementation for #129 (recommendations 1 + 2) and related plumbing is on main (commit
aa49074).What changed (short)
TimeCurve:refreshWarbowPodium,finalizeWarbowPodium,warbowPodiumFinalized; non-emptydistributePrizesrequires finalize when the pool is funded; GitLab #133 — zero pool +reservePodiumPayoutsEnabledemitsPrizesSettledEmptyPodiumPooland locksprizesDistributedwithoutPrizesDistributed.timeCurveWarbowSnapshotClaim+ “Claim your WarBow position (refresh snapshot)” under Top rivals (callsrefreshWarbowPodium; not blocked bybuyFeeRoutingEnabled); ownerdistributeHintmentionsfinalizeWarbowPodiumwhen reserve payouts are on and the latch is false.SimulateAnvilRichStatePart 2 runsfinalizeWarbowPodiumbeforedistributePrizes;scripts/verify-timecurve-post-end-gates-anvil.shrow 4 does the same cast sequence.skills/: invariants §129,timecurve-views.md§129,play-timecurve-warbow/SKILL.md,skills/README.md.Verification checklist for @brouie
forge test— expect 297 tests green (includesfinalizeWarbowPodium/refreshWarbowPodiumpaths)./timecurve/arena, live sale + wallet connected; ifbattlePointsvswarbowLadderPodium()matches the heuristic, the snapshot refresh CTA appears under Top rivals and submitsrefreshWarbowPodium(viewer ∪ podium…). Wrong-chain gate still applies.reservePodiumPayoutsEnabledtrue, non-empty pool,warbowPodiumFinalizedfalse →distributePrizesrevertswarbow podium not finalized; afterfinalizeWarbowPodium(candidates), distribution succeeds (SimulateAnvilRichState/ Anvil#79script).distributePrizeswhen pool balance 0 and reserve enabled →prizesDistributedbecomes true, eventPrizesSettledEmptyPodiumPool; refill does not allow a second payout pass (seetest_distributePrizes_after_empty_settlement_refill_does_not_retry).cargo testinindexer/).Leaving the issue open for your sign-off.
/cc @brouie
mentioned in commit
1966f6d314@PlasticDigits — late on this one, missed your checklist when the spawned #133/#135 work consumed the verification queue. catching up now at HEAD
e12ddab(aa49074plus the subsequent merges through #148):forge test: 312/312 PASS, 0 failed, 0 skipped — 15 more than your 297 baseline (gl135 seven-stealer + fuzz from16deec2, plus #148 hygiene tests).finalizeWarbowPodium/refreshWarbowPodiumpaths green.SimulateAnvilRichStatePart 2 +scripts/verify-timecurve-post-end-gates-anvil.shrow 4. forge tests cover the contract surface (test_finalizeWarbowPodium_reverts_before_endSale,test_refreshWarbowPodium_invalidates_finalize_latch, etc.).test_distributePrizes_after_empty_settlement_refill_does_not_retryPASS — refill after empty-settlement does NOT allow a second payout pass. previously verified on the spawned #133 (note 3313447648).what's left as live UX (item 2 — heuristic + snapshot refresh CTA on /timecurve/arena):
local stack on QA server is currently dev-blocked on the indexer migration timestamp collision filed as #150 / MR !374. once you land that merge I can pull, bring up the full stack, and walk:
battlePointsvswarbowLadderPodium()mismatch → snapshot refresh CTA appears under Top rivalsrefreshWarbowPodium(viewer ∪ podium…)with correct candidate setdistributeHintmentionsfinalizeWarbowPodiumwhenreservePodiumPayoutsEnabled=trueandwarbowPodiumFinalized=falserelated note: today's #149 finding 2 flags that
refreshWarbowPodiumis permissionless and clearswarbowPodiumFinalized = falseunconditionally — including post-endSale — which lets anyone griefdistributePrizeson a public mempool by frontrunning the owner with an empty-array refresh. dev's TG indicated you're working on it. closing the #129 surface separately even with that follow-up still in flight, since the core #129 fix is correct in shape — the grief is a permission scope issue on the same function, not a logic bug in the refresh / finalize / distribute relationship.source-side ready for close on your end. live UX walk caveat above.
@Brouie Live UX walk required
mentioned in issue #160
mentioned in issue #164
@PlasticDigits — live UX walk done at HEAD
390f9be. closing the loop on item 2 from your checklist.setup: anvil stack live, sale active (deadline at timer cap), connected as anvil #0 deployer
0xf39Fd6e5...92266, swarm bots running.ground truth via cast at the time of the walk:
battlePoints(0xf39fd6...2266)= 14,000warbowLadderPodium()=[0x02484c, 0x5e661b, 0x8626f6]all 50,250 BPheuristic should return
falsebecause viewer is not on podium andviewerBp 14000 < podiumValues[2] 50250. confirmed visually on /timecurve/arena:Contract snapshot 50,250 BPnegative-case PASS. heuristic correctly suppresses the CTA when viewer is not podium-eligible.
source-side cross-check on the gate logic at
frontend/src/lib/timeCurveWarbowSnapshotClaim.tsviewerShouldSuggestWarBowPodiumRefresh: returns true only when (viewer in podium AND shown != viewerBp) OR (viewer not in podium AND viewerBp > podiumValues[2]) OR (viewerBp == podiumValues[2] AND BigInt(viewer) < BigInt(podium[2]) — the contract's uint160 tie-break) OR podium[2] isaddress(0). all gated by!saleEnded,viewerBp > 0, and 3-entry podium. the live render confirms the expected branch fires.positive-case (CTA actually rendering when viewer IS eligible) not walked separately — would require either swarm shutdown to drain podium values below the connected wallet's 14k BP, or owning a swarm bot key. both have collateral on other walks. source flow + 312/312 forge + indexer event coverage + the visible chasing-pack 50,000 vs contract 50,250 250-BP indexer-vs-snapshot lag (exactly the kind of delta the heuristic catches when it matters) give strong evidence the path works.
items 1 (forge), 3 (owner post-end gates), 4 (empty-pool #133), 5 (indexer cargo) PASSed yesterday. item 2 covered today.
ready for close on your side.
mentioned in issue #172
mentioned in issue #263