TimeCurve: refreshWarbowPodium / finalizeWarbowPodium do not scale past ~5k candidates — design required for high-participation sales #172
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#172
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?
Severity: Critical — design required, blocks any high-participation refresh path
split out of #160 follow-up per @PlasticDigits — "mark as severe, critical, and design required" — upper bound on candidate count cited as tens of millions.
architecture at HEAD
284c15crefreshWarbowPodium(address[] calldata candidates)atcontracts/src/TimeCurve.sol:1073-1082iterates_updateWarbowPodiumper address. frontendTimeCurveProtocolWarbowRefreshSection.loadFromIndexer(frontend/src/pages/timecurve/TimeCurveProtocolWarbowRefreshSection.tsx:87-138) pages from indexer withguard < 50 × limit 500 = 25,000ceiling and submits one tx with the full set. indexer caps SQL DISTINCT atREFRESH_CANDIDATES_DISTINCT_CAP = 10_000(indexer/src/api.rs).per-candidate gas cost (
_updateWarbowPodiumat:993-1023)battlePoints[c](~2,100 gas)p.winners[0..2]+p.values[0..2](~600 gas after first iteration)_sortPodiumTripletup to 3 iterations × pairwise swap readsrealistic average for the no-displacement case: ~5,000-8,000 gas per candidate. worst case with displacement + sort lands closer to ~25,000 gas per candidate.
block-gas math at the 10M upper bound (30M block limit baseline)
current 25,000 frontend ceiling already exceeds single-block capacity in the displacement-heavy case. MegaETH gas limit may differ — math holds qualitatively at any realistic block size.
why estimateGas alone does not close this
the separate estimateGas + 30% buffer ticket only refuses oversize txs — it does not enable the operation at scale. client-side chunking from the same UI works for thousands but not millions, and reintroduces ordering/idempotency concerns that the current design avoids only because everything fits in one tx.
design directions to evaluate
(A) onchain pagination with buffered accumulation —
refreshWarbowPodiumaccepts(address[] candidates, bool isLastChunk). multiple txs accumulate into transient state; finalization commits top-3. requires new storage but breaks the single-tx ceiling.(B) merkle root + claim — operator submits one
bytes32root of the candidate set offchain. top-3 determined offchain by indexer, claimed onchain with merkle proofs. submit cost O(1). verification cost O(log n) per proof. requires indexer to compute and host the root.(C) commit-reveal with chunked reveal — submit hash of full candidate list, reveal in chunks. similar to (A) but with hash-binding so the candidate list cannot drift between chunks.
(D) stake-claim model — candidates with claim-worthy BP self-submit
claimPodiumSlot()competing for the 3 slots directly. O(1) per claimant, naturally bounded by who actually has competitive BP. most direct migration from "refresh from outside" to "claim from inside".(E) hybrid — keep
refreshWarbowPodiumfor low-N sales, addsubmitPodiumChallenge(address candidate, bytes proof)for the high-N case. backwards compatible, opt-in.cross-impact
finalizeWarbowPodiumat:1086+has the same gas profile and same scaling cliff. owner-only does not change the gas math. whatever design closes this should closefinalizeWarbowPodiumtoo._updateWarbowPodiumis idempotent and already handles BP=0 / already-on-podium cleanly — useful invariant for any chunked design.REFRESH_CANDIDATES_DISTINCT_CAP = 10_000cap becomes pressure on whichever design lands.acceptance criteria
docs/finalizeWarbowPodiumcovered by the same design or has its own paired ticketorigin
split from #160 follow-up notes. supersedes the original P2 framing of "large-candidate gas-blowup unguarded" — dev escalated severity and scope.
cc @PlasticDigits
mentioned in issue #174
mentioned in issue #175
mentioned in issue #176
mentioned in issue #160
@PlasticDigits — finished the source-side audit of refreshWarbowPodium / finalizeWarbowPodium scaling. one structural finding reframes the problem, then 5 options with a recommendation. tagging you for direction on which path to take.
the reframing
every BP-changing path in TimeCurve already calls
_updateWarbowPodiumautomatically:_addBattlePointsat TimeCurve.sol:737, called from line 568_updateWarbowPodiumitself is O(1) per call — bounded loops over the 3 podium slots only, ~5-25k gas per call depending on warm/cold and displacement.so the podium stays consistent on every BP-change ACTION. the only true stale-state case:
_updateWarbowPodium(C, 800)correctly reduces C's slot valuethe gas cost isn't fundamental — it's purely an artifact of the "iterate the whole candidate set to find the rightful 3" recovery pattern in refreshWarbowPodium.
binding constraint isn't where i thought
layered ceilings today:
the 12M softCap is the strict binder. mean per-candidate gas ~5k (no-displacement case A, dominated by cold battlePoints SLOAD ~2.1k), so practical single-tx ceiling today is ~2k candidates — well below the 10k indexer cap and the 25k frontend guard.
gas profile per candidate
_updateWarbowPodium(TimeCurve.sol:993-1023) breaks into three cases:_sortPodiumTripletat TimeCurve.sol:1025-1037 is bubble-sort on 3 elements with early-exit (if (!swapped) break;line 1035)._clearWarbowPodiumat lines 1057-1065 is exactly six SSTOREs against the 6-slot Podium struct.block-gas math
at 30M block-gas (mainnet-class), assuming ~50k tx overhead, ~29.95M usable per tx:
at 60M block-gas (MegaETH-class):
with the current 12M softCap, all of these halve. so the practical bottleneck for high-N sales is the softCap, not the block. raising softCap or splitting into multi-tx are the two levers.
the 5 options
option A — chunked / resumable refresh and finalize with onchain cursor
replace single-shot refresh + finalize with chunk + commit entrypoints. an epoch (or per-call nonce) prevents two operators from interleaving partial submissions.
_updateWarbowPodiumis already idempotent against_warbowPodiumso chunk N+1 just continues mutating the same struct. finalize becomes two-phase:finalizeWarbowPodiumBegin(epoch)clears, then chunks accumulate, thenfinalizeWarbowPodiumCommit(epoch)sets the latch.storage: +2 slots per active epoch (one-time per cycle).
calldata: linear with N, +32 bytes epoch tag.
complexity: O(k) per chunk, O(1) commit.
migration: existing single-shot calls become legacy wrappers. frontend runRefresh becomes a sequence with progress UI. indexer needs new event decoders.
tests: new INV for chunk-idempotency, epoch-lock, no-interleave. ≥5k synthetic BP fixtures.
audit: low new surface — chunks are permissionless and idempotent like #129. only new gating is onlyOwner on FinalizeBegin.
backwards compat: yes, additive. operators only adopt chunked when N > ~1k.
option B — incremental top-N maintenance (onchain heap)
eliminate refresh entirely by keeping
_warbowPodiumalways consistent with battlePoints at every BP write. requires an onchain min-heap over top-K (K ≥ 3) with O(log n) insert / decrease-key. battlePoints mapping becomes heap-backed.storage: substantial. heap + index lookups = +2K + N slots (10-20k for a 5-10k corpus).
calldata: unchanged on user-facing methods.
complexity: O(log K) on every BP write — buy / steal / revenge / guard / claim / penalize. ~20-30k extra gas per write.
migration: refresh becomes redundant. all BP mutation sites need rework. frontend runRefresh + indexer endpoint go away.
tests: heap correctness invariants under all mutation sequences, fuzzing vs naive cross-check, reorg-rebuild equivalence.
audit: highest surface. heap operations are notorious for off-by-one and node-resurrection bugs (BP drops to 0, removed, then earned again).
backwards compat: NO — semantic change to refresh, ABI may shift.
option C — operator-signed snapshot accepted post-end
add
finalizeWarbowPodiumSigned(address[3], uint256[3], bytes sig)that verifies an ECDSA signature against a contract-set signerAddress. contract still re-validatesbattlePoints[winners[i]] == values[i]for each slot before flipping the latch — so signer can't fabricate values, only assert ordering.storage: +1 slot for signerAddress.
calldata: ~290 bytes ≈ ~5k gas.
complexity: O(1) — 3 SLOADs + 1 ecrecover. ~30k gas total.
migration: additive entrypoint. existing finalize stays for low-N / fallback. indexer adds signing endpoint over its existing aggregates.
tests: sig-valid, BP-match, replay-protection (chainId + contract address bound).
audit: NEW TRUST ASSUMPTION — signer trusted for ordering. compromise lets attacker promote low-BP wallet over high-BP one within the verified-values constraint. only addresses post-end finalize, does NOT help sale-time refresh.
backwards compat: yes, additive.
option D — merkle root + per-winner pull-claim
owner calls
finalizeWarbowPodiumRoot(bytes32 root)post-end; anyone can callclaimWarbowPodiumPosition(uint8 place, address winner, uint256 bp, bytes32[] proof)to register one of the three winners. leaves arekeccak256(abi.encode(place, winner, bp)).storage: +1 slot for root, +1 slot for 3-bit claim bitmap.
calldata: ~530 bytes per claim ≈ ~9k gas.
complexity: O(log N) per claim (~14 hashes + 2 SSTOREs ≈ 30k gas), 3 claims total ≈ 90k gas.
migration: additive. finalize stays. indexer adds /v1/timecurve/warbow/podium-root.
tests: root-immutable, no-dupe-place, BP-verify-at-claim.
audit: same trust profile as C if claim re-verifies battlePoints. no MEV between claimers (each place is fixed index). post-end only — does NOT help sale-time refresh.
backwards compat: yes, additive.
option E — no protocol change, ops-side multi-tx + indexer cap raise
raise REFRESH_CANDIDATES_DISTINCT_CAP from 10k to 50-100k. rework runRefresh to slice loadedCandidates into gas-bounded sub-arrays and submit sequentially. safety property:
_updateWarbowPodiumis already idempotent so re-applying chunks against the same_warbowPodiumis correct.storage: 0 onchain.
calldata: same total as A, no epoch overhead.
complexity: identical to today, capacity-per-tx bounded by softCap.
migration: indexer constant raise + integration test rerun. frontend runRefresh becomes a multi-batch loop with per-batch error handling. NO contract change.
tests: indexer integration for raised cap, frontend multi-batch with mid-flight failure handling, no new Foundry.
audit: lowest new surface — contract is unchanged. MEV concern: a buy/steal between batches shifts BPs, so a later batch sees a stale candidate set. but the "missed candidate" property is the SAME as today's single-tx — just shifted by batch boundary instead of refresh boundary.
backwards compat: trivially yes.
recommendation matrix
each axis 1 (best/cheapest) — 5 (worst/highest cost). high-N suitability inverts: 5 = best fit.
dominance:
C and D are post-end-only. they address finalize scaling but do nothing for sale-time refresh.
my read
the smallest move that gets us past 5k candidates is E — raise the indexer cap to 50-100k, rework runRefresh into a multi-batch loop. zero contract risk. bounded by ops discipline.
if sale-time refresh has real UX pain (button effectively unusable above ~2k candidates due to softCap), then either:
if only post-end finalize scales pain matters, C is the cheapest (~30k gas, 1 tx, additive). D is fine but strictly worse than C on impl cost.
B is overkill unless we have a separate product reason to want refresh removed from the protocol surface entirely.
my preferred order for #172: E first (zero contract change, ships fast). then C if/when post-end finalize on a 10k+ corpus becomes the operational pain point. A only if E's batch UX is unacceptable. B only if a separate product motivation lands.
open questions for you
idx_timecurve_buy WHERE battle_points_after > 0over a recent testnet sale would resolve this.happy to ship E once you confirm direction. flag if you'd rather start with C or A.
All 5 suggestions are too complex and gas inefficient. Instead:
(1) Remove the indexer cap completely, indexer should not be capped.
(2) Simplify the warbow podium, make it governance only, remove the onchain sorting and updating, and callable after sale ends by governance with first, second third only one qucik check: that battlePoints[first]>battlePoints[second]>battlePoints[third]. The benefits in reduced gas and complexity are worth the governance centralization. This does mean some invariants may need an exception for warbow podium.
mentioned in issue #182
mentioned in commit
a48a3539e4Shipped on
main(commita48a353)Implemented GitLab #172 per issue discussion: governance-only WarBow podium after
endSale— removed automatic on-chain podium maintenance andrefreshWarbowPodium; ownerfinalizeWarbowPodium(first, second, third)enforces livebattlePointswith left-packed ranks and strict ordering. IndexerGET /v1/timecurve/warbow/refresh-candidatesno longer applies a DISTINCT cap (schema 1.18.0); response dropsdistinct_sql_cap_hit. Frontend/timecurve/protocolloads indexer candidates as reference and submits finalize; Arena refresh snapshot CTA removed.Verification checklist
cd contracts && forge test --match-contract TimeCurveTest— all greencd indexer && cargo test— integration smoke incl. refresh-candidatescd frontend && npm ci && npm test -- --run— Vitest greenVITE_INDEXER_URL— protocol WarBow podium (governance) loads candidates; finalize disabled until sale ended; owner can finalize thendistributePrizeswhen pool rules applydistinct_sql_cap_hiton new indexersIssue left open for sign-off.
cc @brouie — please review / verify when you can.
verified at HEAD
5ca00ec— full pass.source spot-checks (11/11):
refreshWarbowPodiumremoved from TimeCurve.solfinalizeWarbowPodium(address,address,address) external onlyOwner nonReentrantat :1004 with strict-ordering require chain at :1015-1025require(second == address(0) || first != address(0))etcrequire(ended, "TimeCurve: not ended")require(!prizesDistributed, "TimeCurve: prizes done")tests:
live walk on fresh stack at HEAD with schema 1.18.0:
/v1/statusreturns schema_version "1.18.0"/v1/timecurve/warbow/refresh-candidatesresponse has nodistinct_sql_cap_hitfield, note copy references "unbounded DISTINCT list for operator tooling (GitLab #172)"TimeCurve: warbow podium BP orderingsurfaced cleanly in dapp UIready for close on your side @PlasticDigits
mentioned in commit
13f0ebe727