Post-#131/#138 review pass 3: 2 medium + 4 low findings (revenge perf, podium grief, test/UI cosmetics) #149
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#149
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?
third-pass review on the broader stack at HEAD
a421ba1, after the #131 children (#132-#137) and #138 children (#139-#146) shipped clean. dedup'd against everything filed/verified earlier in the week and against today's hygiene ticket #148.ordered by severity descending. findings #1 and #2 stack with each other under MegaETH sub-second block load — pre-mainnet attention recommended even though severity reads low on a strict-funds-loss reading.
Finding 1 — Frontend: pending-revenge query fires on every chain block (Medium)
frontend/src/pages/timeCurveArena/useTimeCurveArenaModel.tsx:ledgerSecInt = Math.floor(blockChainSec)advances on every new head block reported byuseBlock({watch: true})(line 224). BecauseledgerSecIntis in the callback's dependency array, the callback identity changes every block, which makes the effect re-fire every block and re-issueGET /v1/timecurve/warbow/pending-revenge?victim=…&now_sec=….concrete consequence on MegaETH: at sub-second block times, every connected arena viewer issues ~100 indexer requests/second per user. revenge windows are 24h, not seconds — the freshness budget for this query is wildly over-spent. drains indexer rate budget and per-user RPC quota for zero correctness benefit.
stacks with finding #4 below (the indexer-side query is also seq-scan-per-call due to a separate index defeat). compounded effect: every connected viewer triggers a seq scan over
idx_timecurve_warbow_revenge_windowonce per block.mitigation: pin the callback to
[address, saleActive]and captureledgerSecIntvia a ref read inside the closure; or replace the effect with a boundedsetInterval(5-10s) plus an immediate refresh on address/saleActive change and on user actions via the existingrefetchAllpath. revenge windows are 24h — no UX benefit to sub-second freshness.Finding 2 — Contract:
refreshWarbowPodiumis permissionless and clears finalize latch unconditionally — griefsdistributePrizes(Medium, may be reframed Low)contracts/src/TimeCurve.sol:1070-1078:no
onlyOwner, norequire(!ended), norequire(saleStart > 0), no minimum candidates length. anyone can call this afterendSalewith an empty array (~25k gas — single SSTORE clearing the bool + one event emit) and forcewarbowPodiumFinalized = false.attack path on a public mempool:
finalizeWarbowPodium([…])post-endSale — setswarbowPodiumFinalized = truedistributePrizes()txrefreshWarbowPodium(new address[](0))— legal, ~25k gaswarbowPodiumFinalized = falseagaindistributePrizesreverts at L854 withTimeCurve: warbow podium not finalizedowner can recover via multicall / atomic bundle (or a future
finalizeAndDistributebundling function), but on a public mempool this forces ops into bundling rather than two simple txs.not a fund-loss in the strict sense — owner can still complete via a contract that bundles finalize + distribute. that's why a strict reading is Low. but it forces a deploy/ops change to a public-mempool grief vector that didn't exist before, on a contract going to mainnet. dev's pattern on #131 was to bump severity on adversarial findings so flagging this as Medium-leaning rather than Low.
mitigation options:
require(!ended)— simplest, cleanest. refresh is a sale-time UX nicety; once ended, only owner-controlledfinalizeWarbowPodiumshould mutate the snapshot. matches the function's NatSpec intent.candidates.length > 0— still permissionless post-end but at least requires one address. weaker — attacker just picks a known BP holder.onlyOwner finalizeAndDistribute(address[] candidates)— bundles both ops behind a singlenonReentrantcall. solves the racing window without changing existing function semantics.(A) is what I'd lean. (C) is a useful add either way for ops ergonomics.
Finding 3 — Test script:
verify-timecurve-post-end-gates-anvil.shempty-check silently drops ADDR_ALICE (Low)scripts/verify-timecurve-post-end-gates-anvil.sh:30-33:then L41-43 validates ADDR_BOB / ADDR_CAROL / ADDR_DEPLOYER are non-empty:
ADDR_ALICE is missing from the empty-check even though it's interpolated downstream at L161:
failure modes:
cast wallet address --private-key "$PK_ALICE"fails (cast missing in subshell PATH, malformed PK_ALICE env override, etc.) →ADDR_ALICE=""andFIN_WB="[,0x...,0x...,0x...]"address(0)which the contract silently skips at TimeCurve.sol:1075 — alice never participates in finalize, the script appears to PASS, and the test's coverage is silently smaller than advertisedmitigation: re-add ADDR_ALICE to the empty-check at L41:
or fail-fast immediately after each
cast wallet addressderive if the result is empty.Finding 4 — Indexer: pending-revenge query wraps both victim columns in
LOWER(...)and defeats the b-tree index (Low — stacks with #1 to Medium under load)indexer/src/api.rs:877and889-891:migration
20260504200000_warbow_revenge_window_gl135.up.sql:19-20:every address insertion goes through
addr_hex(a) = format!("{:#x}", a)(indexer/src/persist.rs:17-18). Rust's{:#x}format for the alloyAddresstype produces lowercase hex with0xprefix. all addresses in the table are guaranteed lowercase by construction.wrapping the indexed column in
LOWER(...)defeats the b-tree index — postgres can only use the index if the query expression matches the index expression.LOWER(victim) = ...doesn't match an index on plainvictim, so the planner falls back to seq scan.concrete consequence: every
/v1/timecurve/warbow/pending-revengerequest triggers a seq scan overidx_timecurve_warbow_revenge_window. at low traffic this is fine. stacked with finding #1 (~100 req/sec/user during arena viewing on MegaETH), every connected viewer triggers a seq scan per block over a table that grows linearly with WarBow activity. cliff under sustained PvP.mitigation: drop the column-side LOWER (rely on the lowercase invariant established by
addr_hex):or add a functional index on
LOWER(victim)if the codebase wants to keep the case-insensitive guarantee defensively. dropping is cheaper.Finding 5 — Frontend:
pendingRevengeStealer(alphabetical first) andrevengeDeadlineSec(min expiry) can refer to different stealers (Low)frontend/src/pages/timeCurveArena/useTimeCurveArenaModel.tsx:884-887:L888-899:
pendingRevengeStealerpicks the first item inpendingRevengeTargets(whose order is whatever the API returned). perindexer/src/api.rs:896the APIORDER BY l.stealer(alphabetical hex).revengeDeadlineSecreduces all targets to the minimum expiry.with three open windows
{0xA: T+12h, 0xB: T+6h, 0xC: T+24h}:pendingRevengeStealer = 0xA(alphabetical first)revengeDeadlineSec = T+6h(0xB's expiry)downstream consumers:
runWarBowRevengesimulation hook at L2151-2163 firesargs: [pendingRevengeStealer]— simulates the wrong stealerconst stealer = stealerArg ?? pendingRevengeStealer— falls back to the legacy field if no arg passed, fires the tx against the wrong stealerdotMega.ts:87already marks the field@deprecated single-target; prefer pendingRevengeStealers for multi-window (#135)— dev knows it's stale, hasn't fully removedactive per-stealer buttons in
WarbowHeroActions.tsxpass an explicit stealer arg, so the visible UI path is safe. but the deprecated singular field is still wired into hot paths and could be invoked by mistake (headless calls, future consumers, anywhere the optionalstealerArgis omitted).mitigation: drop the legacy single-target field outright — every consumer already has access to
pendingRevengeTargetsvia the model export. or define both fields as the soonest-expiring entry (sortpendingRevengeTargetsbyexpiry_exclusiveASC once and re-export the head as both fields).Finding 6 — Frontend:
viewerShouldSuggestWarBowPodiumRefreshheuristic misses the contract's equal-BP tie-break (Low — cosmetic)frontend/src/lib/timeCurveWarbowSnapshotClaim.ts:42:contract's
_shouldSwapPodium(contracts/src/TimeCurve.sol:1037-1042):contract treats equal BP as a tie broken by lower-numerical-address wins. heuristic only suggests refresh on strict-greater BP and misses the equal-BP-with-lower-address case where the viewer would actually displace slot 3 after a refresh.
concrete case:
viewerBp == podiumValues[2]anduint160(viewerAddress) < uint160(podiumWallets[2])— viewer would displace slot 2 after refresh, but heuristic returnsfalseand UI doesn't surface the suggestion.not blocking — viewer can still click refresh manually. the heuristic's promise of "suggest when refresh would help" is just incomplete on the tie-break edge.
mitigation: when not on the podium, suggest if
viewerBp > podiumValues[2] || (viewerBp == podiumValues[2] && BigInt(viewer) < BigInt(podiumWallets[2]))— JS equivalent of the contract's tie-break.Pre-deploy relevance
cc @PlasticDigits
mentioned in issue #129
@PlasticDigits — flagging a second instance of finding 4 (LOWER(...) index defeat) on a separate endpoint we missed in the original sweep:
indexer/src/api.rs:728(timecurve_warbow_steals_by_victim_day):idx_timecurve_warbow_steal(migration20260403150000_timecurve_warbow_actions.up.sql:22-23):same anti-pattern as finding 4 on
idx_timecurve_warbow_revenge_window(api.rs:877) — column wrapped inLOWER(...)defeats plain b-tree index. addr_hex guarantees lowercase already so the wrap is unnecessary AND defeats the idx.drop both
LOWER()calls (or normalize at the validator before binding) — same fix shape as finding 4. likely a one-line patch on each endpoint plus a regression test, or normalize once at the input boundary.worth a repo-wide
grep -rn 'LOWER(.*) = LOWER(\$' indexer/src/sweep before patching to catch any third instance — there may be others onidx_timecurve_warbow_revenge(avenger / stealer columns) where the same #149 finding 4 fix should apply.mentioned in issue #151
mentioned in commit
e552a213d4Implementation merged (main @
e552a21)Ships the full #149 review pass: (1) Arena
pending-revengeno longer re-fires on every block (ref + 8s interval), (2)refreshWarbowPodiumis sale-only (require(!ended)) so post-end grief vsfinalizeWarbowPodium→distributePrizesis blocked, (3) indexer WarBow + related endpoints use plain indexed columns + lowercase-bound params (bind_addr_lower), (4)pending-revengeordering expiry ASC, stealer ASC, (5)pendingRevengeStealeraligns with soonest-expiring row, (6) podium refresh heuristic adds equal-BPuint160tie-break; (7)verify-timecurve-post-end-gates-anvil.shvalidates ADDR_ALICE.Docs:
docs/testing/invariants-and-business-logic.md§#gitlab-149-warbow-arena-indexer-hardening;skills/README.md,skills/play-timecurve-warbow/SKILL.md,.cursor/skills/yieldomega-guardrails/SKILL.mditem 51.Verification checklist for @brouie
forge test --match-test test_refreshWarbowPodium_reverts_after_endSale— expectsTimeCurve: endedafterendSale; post-finalizedistributePrizesstill succeeds.GET /v1/timecurve/warbow/pending-revenge,…/steals-by-victim-day,…/guard-latest,GET /v1/timecurve/buyer-stats— responses unchanged vs lowercase inputs; Postgres plans use btree indexes on victim/player/buyer where applicable (noLOWER(column)on indexed cols).loadPendingRevengeshould not issue a new request every new block head; expect ~8s cadence + mount/refetch.verify-timecurve-post-end-gates-anvil.shfails fast ifADDR_ALICEcannot be derived (same as other addrs).viewerShouldSuggestWarBowPodiumRefresh— off-podium, equal BP to 3rd, loweruint160than 3rd wallet → suggests refresh; higheruint160→ does not.Issue left open for your sign-off.
mentioned in commit
53e713659aGitLab #149 implementation (for @brouie verification)
Shipped on
main:loadPendingRevengeno longer depends on per-blockledgerSecInt(stableuseCallback+ledgerSecIntRef); 8s interval + immediate run on address/saleActive/refetchAll.refreshWarbowPodiumgrief:require(!ended, "TimeCurve: ended")— sale-time refresh only; Forgetest_refreshWarbowPodium_reverts_after_endSale.verify-timecurve-post-end-gates-anvil.shnow fails ifADDR_ALICEis empty.column = $nwith lowercase-bound params (bind_addr_lower);pending-revengeordered byexpiry_exclusive ASC, stealer ASC; leaderboardDISTINCT ON (buyer).pendingRevengeTargetssorted by soonest expiry;pendingRevengeStealeris the head row (aligned with min expiry).viewerShouldSuggestWarBowPodiumRefreshcovers equal-BP + loweruint160viewer edge.Docs / agent cross-links:
docs/testing/invariants-and-business-logic.md§ GitLab #149,docs/frontend/timecurve-views.md§129,.cursor/skills/yieldomega-guardrails/SKILL.md,skills/README.md,skills/play-timecurve-warbow/SKILL.md.Verification checklist
cd contracts && forge test --match-contract TimeCurveTest— all green (includestest_refreshWarbowPodium_reverts_after_endSale).cd frontend && npx vitest run src/lib/timeCurveWarbowSnapshotClaim.test.ts.cd indexer && cargo clippy --all-targets(andcargo testfor unit tests).integration_stage2WarBow routes if DATABASE_URL available.Leaving issue open per instructions.
@PlasticDigits — verification PASS on
e552a21at HEAD435a446. walked your checklist:forge test --match-test test_refreshWarbowPodium_reverts_after_endSalePASS — TimeCurve.sol nowrequire(!ended, "TimeCurve: ended")onrefreshWarbowPodium. post-end grief vector closed; finalize → distributePrizes path remains operational.vitest run timeCurveWarbowSnapshotClaim.test.ts8/8 PASS — added 2 new tests covering equal-BP + lower-uint160 viewer + higher-uint160 viewer at slot 3.bind_addr_lower()helper atapi.rs:963is now applied at 7 call sites: L726steals-by-victim-day(the second instance I flagged), L800guard-latest, L870pending-revenge(original finding 4), L983buyer-stats, L1048 + L1152 rabbit user filters. zeroLOWER(column) = LOWER($n)instances remain inapi.rs. exactly the input-boundary normalization shape suggested.useTimeCurveArenaModel.tsxL225-226ledgerSecIntRef = useRef(ledgerSecInt)taken outside callback, read inside closure at L868 (nowSec = ledgerSecIntRef.current).loadPendingRevengedeps now[address, saleActive]only —ledgerSecIntno longer triggers callback identity changes. 8s interval + immediate run on address/saleActive/refetchAll per your commit notes.pendingRevengeTargetsnow sorted byexpiry_exclusive ASCwithstealer ASCtiebreaker. L893-895pendingRevengeStealertakes head row of sorted array — aligned with min-expiry that drivesrevengeDeadlineSec. legacy single-target path no longer mismatched against displayed countdown.scripts/verify-timecurve-post-end-gates-anvil.shupdated per commit, fail-fast added.other test surface:
cargo clippy --all-targets -- -D warningsclean.cargo testindexer 31/31 PASS (29 unit + 2 integration).skipped item 4 of your checklist (live network-tab cadence walk) — source-side dep removal is sufficient evidence the per-block storm is gone since the callback identity no longer depends on
ledgerSecInt. happy to verify on the live stack if you want a confirmatory observation, but not required for sign-off shape.source-side ready for close on your end.
mentioned in issue #160
mentioned in commit
a6482837c9mentioned in issue #170
mentioned in issue #175
mentioned in commit
f2b56806fbmentioned in commit
08a95bd913mentioned in commit
ddeebee7bc