TimeCurve: refreshWarbowPodium / finalizeWarbowPodium do not scale past ~5k candidates — design required for high-participation sales #172

Closed
opened 2026-05-07 01:53:34 +00:00 by Brouie · 11 comments
Brouie commented 2026-05-07 01:53:34 +00:00 (Migrated from gitlab.com)

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 284c15c

refreshWarbowPodium(address[] calldata candidates) at contracts/src/TimeCurve.sol:1073-1082 iterates _updateWarbowPodium per address. frontend TimeCurveProtocolWarbowRefreshSection.loadFromIndexer (frontend/src/pages/timecurve/TimeCurveProtocolWarbowRefreshSection.tsx:87-138) pages from indexer with guard < 50 × limit 500 = 25,000 ceiling and submits one tx with the full set. indexer caps SQL DISTINCT at REFRESH_CANDIDATES_DISTINCT_CAP = 10_000 (indexer/src/api.rs).

per-candidate gas cost (_updateWarbowPodium at :993-1023)

  • 1 cold SLOAD on battlePoints[c] (~2,100 gas)
  • 6 warm SLOADs on p.winners[0..2] + p.values[0..2] (~600 gas after first iteration)
  • _sortPodiumTriplet up to 3 iterations × pairwise swap reads
  • calldata: 32 bytes per address ≈ 256 gas non-zero bytes

realistic 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)

  • 10M × 5k = ~50,000,000,000 gas aggregate
  • ~1,667 full blocks of refresh tx at 30M
  • 100k candidates (1% of upper bound) = ~20+ coordinated txs
  • single-tx ceiling on a 30M-gas chain: ~5,000 candidates (no-displacement avg) down to ~1,200 (displacement avg)

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 — refreshWarbowPodium accepts (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 bytes32 root 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 refreshWarbowPodium for low-N sales, add submitPodiumChallenge(address candidate, bytes proof) for the high-N case. backwards compatible, opt-in.

cross-impact

  • finalizeWarbowPodium at :1086+ has the same gas profile and same scaling cliff. owner-only does not change the gas math. whatever design closes this should close finalizeWarbowPodium too.
  • _updateWarbowPodium is idempotent and already handles BP=0 / already-on-podium cleanly — useful invariant for any chunked design.
  • indexer REFRESH_CANDIDATES_DISTINCT_CAP = 10_000 cap becomes pressure on whichever design lands.

acceptance criteria

  • design doc reviewed and chosen direction documented in docs/
  • single-tx submit path retired or soft-capped to protect callers from sending >5k candidates accidentally
  • path exists for podium refresh at >100k participants without coordinated multi-tx fragility
  • gas profile of new design documented with per-candidate or per-claimant numbers
  • finalizeWarbowPodium covered by the same design or has its own paired ticket

origin

split from #160 follow-up notes. supersedes the original P2 framing of "large-candidate gas-blowup unguarded" — dev escalated severity and scope.

cc @PlasticDigits

**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 `284c15c` `refreshWarbowPodium(address[] calldata candidates)` at `contracts/src/TimeCurve.sol:1073-1082` iterates `_updateWarbowPodium` per address. frontend `TimeCurveProtocolWarbowRefreshSection.loadFromIndexer` (`frontend/src/pages/timecurve/TimeCurveProtocolWarbowRefreshSection.tsx:87-138`) pages from indexer with `guard < 50 × limit 500 = 25,000` ceiling and submits one tx with the full set. indexer caps SQL DISTINCT at `REFRESH_CANDIDATES_DISTINCT_CAP = 10_000` (`indexer/src/api.rs`). ## per-candidate gas cost (`_updateWarbowPodium` at `:993-1023`) - 1 cold SLOAD on `battlePoints[c]` (~2,100 gas) - 6 warm SLOADs on `p.winners[0..2]` + `p.values[0..2]` (~600 gas after first iteration) - `_sortPodiumTriplet` up to 3 iterations × pairwise swap reads - calldata: 32 bytes per address ≈ 256 gas non-zero bytes realistic 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) - 10M × 5k = ~50,000,000,000 gas aggregate - ~1,667 full blocks of refresh tx at 30M - 100k candidates (1% of upper bound) = ~20+ coordinated txs - single-tx ceiling on a 30M-gas chain: ~5,000 candidates (no-displacement avg) down to ~1,200 (displacement avg) 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 — `refreshWarbowPodium` accepts `(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 `bytes32` root 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 `refreshWarbowPodium` for low-N sales, add `submitPodiumChallenge(address candidate, bytes proof)` for the high-N case. backwards compatible, opt-in. ## cross-impact - `finalizeWarbowPodium` at `:1086+` has the same gas profile and same scaling cliff. owner-only does not change the gas math. whatever design closes this should close `finalizeWarbowPodium` too. - `_updateWarbowPodium` is idempotent and already handles BP=0 / already-on-podium cleanly — useful invariant for any chunked design. - indexer `REFRESH_CANDIDATES_DISTINCT_CAP = 10_000` cap becomes pressure on whichever design lands. ## acceptance criteria - [ ] design doc reviewed and chosen direction documented in `docs/` - [ ] single-tx submit path retired or soft-capped to protect callers from sending >5k candidates accidentally - [ ] path exists for podium refresh at >100k participants without coordinated multi-tx fragility - [ ] gas profile of new design documented with per-candidate or per-claimant numbers - [ ] `finalizeWarbowPodium` covered by the same design or has its own paired ticket ## origin split from #160 follow-up notes. supersedes the original P2 framing of "large-candidate gas-blowup unguarded" — dev escalated severity and scope. cc @PlasticDigits
Brouie commented 2026-05-07 02:01:05 +00:00 (Migrated from gitlab.com)

mentioned in issue #174

mentioned in issue #174
Brouie commented 2026-05-07 02:02:52 +00:00 (Migrated from gitlab.com)

mentioned in issue #175

mentioned in issue #175
Brouie commented 2026-05-07 02:06:00 +00:00 (Migrated from gitlab.com)

mentioned in issue #176

mentioned in issue #176
Brouie commented 2026-05-07 02:09:58 +00:00 (Migrated from gitlab.com)

mentioned in issue #160

mentioned in issue #160
Brouie commented 2026-05-08 00:58:06 +00:00 (Migrated from gitlab.com)

@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 _updateWarbowPodium automatically:

  • buy BP awards: _addBattlePoints at TimeCurve.sol:737, called from line 568
  • flag claim: line 613
  • steal: lines 662-663 (both victim AND stealer auto-updated)
  • revenge: lines 710-711 (both sides auto-updated)
  • flag-silence penalty: line 514

_updateWarbowPodium itself 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:

  • wallet C is at podium rank 3 with 1010 BP
  • C gets stolen from, BP drops to 800
  • _updateWarbowPodium(C, 800) correctly reduces C's slot value
  • but if some non-podium wallet X has 1000 BP, the contract can't promote X without iterating non-podium wallets to find them

the 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:

  • frontend pagination guard: 50 × 500 = 25k candidates max (TimeCurveProtocolWarbowRefreshSection.tsx:101+103)
  • indexer cap: REFRESH_CANDIDATES_DISTINCT_CAP = 10_000 (indexer/src/api.rs:64) — frontend ceiling is non-binding
  • single-tx gas cap: writeContractWithGasBuffer softCapGas = 12_000_000n (TimeCurveProtocolWarbowRefreshSection.tsx:183)

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:

  • case A — no displacement (early break, BP doesn't beat min): ~3.5-6.5k gas, dominated by the cold battlePoints[c] SLOAD
  • case B — partial displacement (already on podium, new BP, 1 swap): ~25-35k gas (1 SSTORE warm + 4 SSTOREs from sort)
  • case C — full displacement (new entry, displaces a slot): ~50-95k gas (2 SSTOREs + up to 8 SSTOREs from bubble sort, upper bound when displacing a zero slot)

_sortPodiumTriplet at TimeCurve.sol:1025-1037 is bubble-sort on 3 elements with early-exit (if (!swapped) break; line 1035). _clearWarbowPodium at 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:

  • case A mean: ~6k candidates per block
  • case B: ~1k candidates per block
  • case C: ~370 candidates per block

at 60M block-gas (MegaETH-class):

  • case A mean: ~12k candidates per block
  • case A best (all warm): ~24k
  • case B: ~2k
  • case C: ~750

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. _updateWarbowPodium is already idempotent against _warbowPodium so chunk N+1 just continues mutating the same struct. finalize becomes two-phase: finalizeWarbowPodiumBegin(epoch) clears, then chunks accumulate, then finalizeWarbowPodiumCommit(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 _warbowPodium always 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-validates battlePoints[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 call claimWarbowPodiumPosition(uint8 place, address winner, uint256 bp, bytes32[] proof) to register one of the three winners. leaves are keccak256(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: _updateWarbowPodium is already idempotent so re-applying chunks against the same _warbowPodium is 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.

option impl cost runtime cost sec/audit cost bwd compat high-N suit
A — chunked refresh+finalize 3 2 2 5 4
B — onchain heap 5 4 (perpetual gas tax) 5 1 5
C — signed snapshot 2 1 3 (signer trust) 5 5 (post-end only)
D — merkle + claim 3 2 3 5 5 (post-end only)
E — ops-side multi-tx 1 2 1 5 3

dominance:

  • E dominates A on impl + sec cost; loses on UX (operator waits through N submits + tolerates mid-flight failures). for corpora ≤10k and sale-only refresh, E is the right first move.
  • C dominates D on every axis unless you specifically want the social property "anyone can finalize a place" (D) vs "only signature-holder can" (C). C is strictly less code, lower calldata, equivalent trust.
  • B is a strict superset of every other option's CAPABILITY (removes the need for refresh entirely) but pays for it with permanent per-write gas overhead and largest audit surface. only justified if a follow-up ticket demands "podium is always live without indexer dependency".
  • A ⊇ E (A is E plus onchain epoch enforcement). choice is whether epoch-locking + no-interleave needs contract enforcement or can rest on operator hygiene.

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:

  • E alone with operator hygiene (cheapest, most fragile)
  • A added on top for onchain epoch safety (mid-cost, robust)

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

  1. is the scaling pain point sale-time refresh, post-end finalize, or both? if only finalize, C alone is sufficient. if also refresh, E or A must be in the mix.
  2. is operator-signed-ordering acceptable for prize distribution trust model? the contract re-verifies BP values, but signer can mis-rank wallets within close BP margins, and distributePrizes is irreversible. if no, C and D are off the table.
  3. what's the realistic N upper bound per sale? the indexer cap is 10k. concrete numbers from idx_timecurve_buy WHERE battle_points_after > 0 over a recent testnet sale would resolve this.
  4. is MegaETH 60-100M block gas a load-bearing assumption or nice-to-have? several options' viability flips between 30M and 60M. if MegaETH-only, E covers a much wider N window than the 30M case suggests.
  5. is permanent +20-30k gas per buy (option B) acceptable to remove the refresh path entirely? buy is the hottest path. if no, B is off the table regardless of its high-N elegance.

happy to ship E once you confirm direction. flag if you'd rather start with C or A.

@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 `_updateWarbowPodium` automatically: - buy BP awards: `_addBattlePoints` at TimeCurve.sol:737, called from line 568 - flag claim: line 613 - steal: lines 662-663 (both victim AND stealer auto-updated) - revenge: lines 710-711 (both sides auto-updated) - flag-silence penalty: line 514 `_updateWarbowPodium` itself 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: - wallet C is at podium rank 3 with 1010 BP - C gets stolen from, BP drops to 800 - `_updateWarbowPodium(C, 800)` correctly reduces C's slot value - but if some non-podium wallet X has 1000 BP, the contract can't promote X without iterating non-podium wallets to find them the 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: - frontend pagination guard: 50 × 500 = 25k candidates max (TimeCurveProtocolWarbowRefreshSection.tsx:101+103) - indexer cap: REFRESH_CANDIDATES_DISTINCT_CAP = 10_000 (indexer/src/api.rs:64) — frontend ceiling is non-binding - single-tx gas cap: writeContractWithGasBuffer softCapGas = 12_000_000n (TimeCurveProtocolWarbowRefreshSection.tsx:183) 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: - **case A — no displacement** (early break, BP doesn't beat min): ~3.5-6.5k gas, dominated by the cold battlePoints[c] SLOAD - **case B — partial displacement** (already on podium, new BP, 1 swap): ~25-35k gas (1 SSTORE warm + 4 SSTOREs from sort) - **case C — full displacement** (new entry, displaces a slot): ~50-95k gas (2 SSTOREs + up to 8 SSTOREs from bubble sort, upper bound when displacing a zero slot) `_sortPodiumTriplet` at TimeCurve.sol:1025-1037 is bubble-sort on 3 elements with early-exit (`if (!swapped) break;` line 1035). `_clearWarbowPodium` at 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: - case A mean: ~6k candidates per block - case B: ~1k candidates per block - case C: ~370 candidates per block at 60M block-gas (MegaETH-class): - case A mean: ~12k candidates per block - case A best (all warm): ~24k - case B: ~2k - case C: ~750 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. `_updateWarbowPodium` is already idempotent against `_warbowPodium` so chunk N+1 just continues mutating the same struct. finalize becomes two-phase: `finalizeWarbowPodiumBegin(epoch)` clears, then chunks accumulate, then `finalizeWarbowPodiumCommit(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 `_warbowPodium` always 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-validates `battlePoints[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 call `claimWarbowPodiumPosition(uint8 place, address winner, uint256 bp, bytes32[] proof)` to register one of the three winners. leaves are `keccak256(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: `_updateWarbowPodium` is already idempotent so re-applying chunks against the same `_warbowPodium` is 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. | option | impl cost | runtime cost | sec/audit cost | bwd compat | high-N suit | |---|---|---|---|---|---| | A — chunked refresh+finalize | 3 | 2 | 2 | 5 | 4 | | B — onchain heap | 5 | 4 (perpetual gas tax) | 5 | 1 | 5 | | C — signed snapshot | 2 | 1 | 3 (signer trust) | 5 | 5 (post-end only) | | D — merkle + claim | 3 | 2 | 3 | 5 | 5 (post-end only) | | E — ops-side multi-tx | 1 | 2 | 1 | 5 | 3 | dominance: - E dominates A on impl + sec cost; loses on UX (operator waits through N submits + tolerates mid-flight failures). for corpora ≤10k and sale-only refresh, E is the right first move. - C dominates D on every axis unless you specifically want the social property "anyone can finalize a place" (D) vs "only signature-holder can" (C). C is strictly less code, lower calldata, equivalent trust. - B is a strict superset of every other option's CAPABILITY (removes the need for refresh entirely) but pays for it with permanent per-write gas overhead and largest audit surface. only justified if a follow-up ticket demands "podium is always live without indexer dependency". - A ⊇ E (A is E plus onchain epoch enforcement). choice is whether epoch-locking + no-interleave needs contract enforcement or can rest on operator hygiene. 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: - E alone with operator hygiene (cheapest, most fragile) - A added on top for onchain epoch safety (mid-cost, robust) **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 1. **is the scaling pain point sale-time refresh, post-end finalize, or both?** if only finalize, C alone is sufficient. if also refresh, E or A must be in the mix. 2. **is operator-signed-ordering acceptable for prize distribution trust model?** the contract re-verifies BP values, but signer can mis-rank wallets within close BP margins, and distributePrizes is irreversible. if no, C and D are off the table. 3. **what's the realistic N upper bound per sale?** the indexer cap is 10k. concrete numbers from `idx_timecurve_buy WHERE battle_points_after > 0` over a recent testnet sale would resolve this. 4. **is MegaETH 60-100M block gas a load-bearing assumption or nice-to-have?** several options' viability flips between 30M and 60M. if MegaETH-only, E covers a much wider N window than the 30M case suggests. 5. **is permanent +20-30k gas per buy (option B) acceptable to remove the refresh path entirely?** buy is the hottest path. if no, B is off the table regardless of its high-N elegance. happy to ship E once you confirm direction. flag if you'd rather start with C or A.
PlasticDigits commented 2026-05-09 04:53:35 +00:00 (Migrated from gitlab.com)

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.

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.
PlasticDigits commented 2026-05-09 05:07:13 +00:00 (Migrated from gitlab.com)

mentioned in issue #182

mentioned in issue #182
PlasticDigits commented 2026-05-09 05:23:04 +00:00 (Migrated from gitlab.com)

mentioned in commit a48a3539e4

mentioned in commit a48a3539e4f15d41c8c45b32ab0148b9e7e140bf
PlasticDigits commented 2026-05-09 05:24:55 +00:00 (Migrated from gitlab.com)

Shipped on main (commit a48a353)

Implemented GitLab #172 per issue discussion: governance-only WarBow podium after endSale — removed automatic on-chain podium maintenance and refreshWarbowPodium; owner finalizeWarbowPodium(first, second, third) enforces live battlePoints with left-packed ranks and strict ordering. Indexer GET /v1/timecurve/warbow/refresh-candidates no longer applies a DISTINCT cap (schema 1.18.0); response drops distinct_sql_cap_hit. Frontend /timecurve/protocol loads indexer candidates as reference and submits finalize; Arena refresh snapshot CTA removed.

Verification checklist

  • cd contracts && forge test --match-contract TimeCurveTest — all green
  • cd indexer && cargo test — integration smoke incl. refresh-candidates
  • cd frontend && npm ci && npm test -- --run — Vitest green
  • Local stack: indexer ≥ 1.18.0 + app with VITE_INDEXER_URL — protocol WarBow podium (governance) loads candidates; finalize disabled until sale ended; owner can finalize then distributePrizes when pool rules apply
  • Confirm API JSON has no distinct_sql_cap_hit on new indexers

Issue left open for sign-off.

cc @brouie — please review / verify when you can.

## Shipped on `main` (commit a48a353) Implemented **GitLab #172** per issue discussion: **governance-only** WarBow podium after `endSale` — removed automatic on-chain podium maintenance and `refreshWarbowPodium`; **owner** `finalizeWarbowPodium(first, second, third)` enforces live `battlePoints` with left-packed ranks and strict ordering. Indexer **`GET /v1/timecurve/warbow/refresh-candidates`** no longer applies a DISTINCT cap (schema **1.18.0**); response drops `distinct_sql_cap_hit`. Frontend `/timecurve/protocol` loads indexer candidates as **reference** and submits finalize; Arena **refresh snapshot** CTA removed. ### Verification checklist - [ ] `cd contracts && forge test --match-contract TimeCurveTest` — all green - [ ] `cd indexer && cargo test` — integration smoke incl. refresh-candidates - [ ] `cd frontend && npm ci && npm test -- --run` — Vitest green - [ ] Local stack: indexer **≥ 1.18.0** + app with `VITE_INDEXER_URL` — protocol **WarBow podium (governance)** loads candidates; **finalize** disabled until sale ended; owner can finalize then `distributePrizes` when pool rules apply - [ ] Confirm API JSON has **no** `distinct_sql_cap_hit` on new indexers **Issue left open** for sign-off. cc @brouie — please review / verify when you can.
Brouie commented 2026-05-11 02:38:24 +00:00 (Migrated from gitlab.com)

verified at HEAD 5ca00ec — full pass.

source spot-checks (11/11):

  • refreshWarbowPodium removed from TimeCurve.sol
  • finalizeWarbowPodium(address,address,address) external onlyOwner nonReentrant at :1004 with strict-ordering require chain at :1015-1025
  • gap guards (left-packed): require(second == address(0) || first != address(0)) etc
  • ended gate: require(ended, "TimeCurve: not ended")
  • prizesDistributed gate: require(!prizesDistributed, "TimeCurve: prizes done")
  • warbowPodiumFinalized latch + event emit at :1034-1035
  • indexer REFRESH_CANDIDATES_DISTINCT_CAP removed
  • indexer distinct_sql_cap_hit response field removed
  • indexer SCHEMA_VERSION = "1.18.0" (api.rs:24)
  • frontend Arena refresh CTA retired (timeCurveWarbowSnapshotClaim.ts now a 16-line stub returning false)
  • frontend protocol page repurposed for governance finalize, indexerApi.ts doccomments updated

tests:

  • forge --match-contract TimeCurveTest: 116/116 pass (includes test_warbow_steal_bps_then_governance_finalize_orders_podium)
  • vitest: 307/307 pass

live walk on fresh stack at HEAD with schema 1.18.0:

  • /v1/status returns schema_version "1.18.0"
  • /v1/timecurve/warbow/refresh-candidates response has no distinct_sql_cap_hit field, note copy references "unbounded DISTINCT list for operator tooling (GitLab #172)"
  • protocol page WarBow Podium (governance) section renders with explainer + finalize form
  • form properly gated: Submit button disabled while ended==false, enabled after endSale()
  • strict-ordering revert path: submitted 3 tied 9250 BP wallets → reverted with TimeCurve: warbow podium BP ordering surfaced cleanly in dapp UI
  • success path: submitted 9250/8750/7500 BP strict-decreasing → tx succeeded, warbowPodiumFinalized latch flipped true (confirmed via cast call)
  • WarbowPodiumFinalized event emitted

ready for close on your side @PlasticDigits

verified at HEAD 5ca00ec — full pass. source spot-checks (11/11): - `refreshWarbowPodium` removed from TimeCurve.sol - `finalizeWarbowPodium(address,address,address) external onlyOwner nonReentrant` at :1004 with strict-ordering require chain at :1015-1025 - gap guards (left-packed): `require(second == address(0) || first != address(0))` etc - ended gate: `require(ended, "TimeCurve: not ended")` - prizesDistributed gate: `require(!prizesDistributed, "TimeCurve: prizes done")` - warbowPodiumFinalized latch + event emit at :1034-1035 - indexer REFRESH_CANDIDATES_DISTINCT_CAP removed - indexer distinct_sql_cap_hit response field removed - indexer SCHEMA_VERSION = "1.18.0" (api.rs:24) - frontend Arena refresh CTA retired (timeCurveWarbowSnapshotClaim.ts now a 16-line stub returning false) - frontend protocol page repurposed for governance finalize, indexerApi.ts doccomments updated tests: - forge --match-contract TimeCurveTest: 116/116 pass (includes test_warbow_steal_bps_then_governance_finalize_orders_podium) - vitest: 307/307 pass live walk on fresh stack at HEAD with schema 1.18.0: - `/v1/status` returns schema_version "1.18.0" - `/v1/timecurve/warbow/refresh-candidates` response has no `distinct_sql_cap_hit` field, note copy references "unbounded DISTINCT list for operator tooling (GitLab #172)" - protocol page WarBow Podium (governance) section renders with explainer + finalize form - form properly gated: Submit button disabled while ended==false, enabled after endSale() - strict-ordering revert path: submitted 3 tied 9250 BP wallets → reverted with `TimeCurve: warbow podium BP ordering` surfaced cleanly in dapp UI - success path: submitted 9250/8750/7500 BP strict-decreasing → tx succeeded, warbowPodiumFinalized latch flipped true (confirmed via cast call) - WarbowPodiumFinalized event emitted ready for close on your side @PlasticDigits
PlasticDigits (Migrated from gitlab.com) closed this issue 2026-05-11 08:58:51 +00:00
PlasticDigits commented 2026-05-13 15:13:34 +00:00 (Migrated from gitlab.com)

mentioned in commit 13f0ebe727

mentioned in commit 13f0ebe727d064f1ed8210313ddaa31628e83bff
Sign in to join this conversation.
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set

Reference
code/yieldomega#172
No description provided.