M-01: distributePrizes must not strand PodiumPool — route empty category slices to protocol #116
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#116
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?
Summary
Audit: M-01 in
audits/audit_smartcontract_1777813071.md—TimeCurve.distributePrizescan setprizesDistributed = truewhile non-zero category slices for empty podiums (alladdress(0)winners) never leavePodiumPool, stranding CL8Y and blocking any retry.Product decision (this issue): For any category slice that has no payable winners, the full slice for that category (after the existing 40/25/20/15 podium-pool split) must be forwarded to the protocol (canonical CL8Y protocol treasury / ops sink aligned with existing surplus routing), not left in
PodiumPool.Background / root cause
In
contracts/src/TimeCurve.sol,distributePrizes:prizePool = acceptedAsset.balanceOf(podiumPool).sLast,sWar,sDef,sTime.prizesDistributed = truebefore paying categories._payPodiumFromper category._payPodiumFromonly callspodiumPool.payPodiumPayoutwhenp.winners[i] != address(0)and the placement share is non-zero. If every winner slot for a category is zero, the entire category slice is skipped with no alternate destination.contracts/src/sinks/PodiumPool.solcorrectly rejectspayPodiumPayout(..., winner = address(0), ...)— the bug is not inPodiumPool; it is the absence of a policy for “slice computed but no winners.”Why this is realistic
Per the audit and game rules (see
docs/product/primitives.md):_finalizeLastBuyers/ ladder state.So empty categories are not purely theoretical.
Impact
PodiumPoolwhileprizesDistributedis true.distributePrizeswithout upgrade or migration / privileged rescue.Recommended fix (protocol routing for empty categories)
Define “unpaid category slice”
For each category, after computing that category’s slice from
prizePool, determinepaidOut(sum of successfulpayPodiumPayoutamounts for that category) vsslice. Anyslice - paidOut(or, equivalently,slicewhen there are zero payable placements) is residual.Route residual to protocol
Transfer
acceptedAssetresidual for that category fromPodiumPoolto the designated CL8Y protocol treasury (same economic bucket as other “surplus to protocol” paths — align withTimeCurveBuyRoutercl8yProtocolTreasuryand deploy/registry expectations such asCL8YProtocolTreasury).PodiumPoolAPIToday only
payPodiumPayout(winner != 0)exists. Add aprizePusher-only path such asforwardResidual(IERC20 token, address to, uint256 amount)(names per repo conventions) withto != address(0),amount > 0, and anEmitsevent (e.g.PodiumResidualForwarded: token, recipient, amount, category uint8) so indexers and operators can reconcile flows.Set
prizesDistributedonly when the podium pool is fully resolvedEither:
prizesDistributed = trueonly after all four category slices are fully allocated (winners + protocol residual), and assertacceptedAsset.balanceOf(podiumPool) == 0(or document intentional dust tolerance if any rounding remains — ideally zero).UUPS / storage
If the protocol sink is not yet on
TimeCurve, add an initializer-safe storage slot or one-time setter (owner-gated, consistent with other wiring) forpodiumResidualRecipient/ reuse an existing registry read — must be non-zero before firstdistributePrizesthat pays residuals, or hardcode the same sink as productionFeeRoutertopology documents.Regression tests (Forge)
Add / extend tests in
contracts/test/TimeCurve.t.sol(and split files if preferred) to cover:distributePrizes,podiumPoolbalance is 0 (or documented dust); protocol treasury balance increases by exactly the 20% slice (of pre-call pool) minus rounding;prizesDistributed == true._totalBuys == 0/ edge setup)prizePool(± 0 wei policy).test_distributePrizes_empty_vault_is_retryable; ensure it still passes.test_distributePrizes_dust_pool_is_retryablebehavior consistent with new semantics.PodiumPoolauthprizePusher(or legacy tests path if applicable); unauthorized reverts.Fuzz is optional: random subset of categories forced empty via winner slots.
Files and surfaces likely to change
Solidity (authoritative)
contracts/src/TimeCurve.sol—distributePrizes,_payPodiumFromor successor; ordering ofprizesDistributed; protocol recipient wiring.contracts/src/sinks/PodiumPool.sol— residual forward helper + event.contracts/test/TimeCurve.t.sol— new cases; possiblycontracts/test/FeeSinks.t.solforPodiumPoolbehavior.contracts/script/UUPSDeployLib.sol/contracts/script/DeployDev.s.sol— if initializer or post-deploy wire must set protocol recipient.prizePusher/ registry JSON.Documentation
docs/product/primitives.md— podium pool: empty category → protocol.docs/onchain/fee-routing-and-governance.md— cross-link residual policy.docs/onchain/cl8y-flow-audit.md— add row for M-01 / empty podium residual.docs/operations/final-signoff-and-value-movement.md—distributePrizesnow drains pool including protocol sweeps.docs/testing/invariants-and-business-logic.md— new invariant + test map rows.docs/qa/YO-TimeCurve-QA-Checklist.md— manual check for stranded pool = 0 after distribution.Indexer (if new events / tables)
indexer/src/decoder.rs,indexer/src/persist.rs, migrations underindexer/migrations/,indexer/src/reorg.rsif new tables — follow INV-INDEXER-112.Frontend (optional / policy display)
frontend/src/lib/timeCurvePodiumMath.ts, TimeCurve protocol pages — only if copy must state unallocated slice → protocol.Audit artifact
audits/audit_smartcontract_1777813071.md— add status line for M-01 when resolved (optional footnote in same file per team practice).Acceptance criteria
distributePrizeswhenprizePool > 0at entry,PodiumPoolaccepted-asset balance is 0 (unless an explicitly documented wei-level rounding exception is approved — default no strands).prizesDistributedsemantics: The flag is set only when all category slices from that distribution pass have been fully assigned (winners and/or protocol per policy).payPodiumPayoutwithwinner == address(0).Verification checklist
Engineering (pre-merge)
forge testpasses forcontracts/including new tests.distributePrizespath changed materially.TimeCurve(if storage added).PodiumPoolforward path reviewed: onlyprizePusher, reentrancy not applicable if nonReentrant ondistributePrizesand CEI respected.Protocol / economics
CL8YProtocolTreasuryor successor) and matchesTimeCurveBuyRouter/ ops runbooks.Indexer / integrations
PodiumPaid): document why protocol transfers are visible only via ERC20 Transfer fromPodiumPool(may be insufficient for first-class indexing — prefer dedicated event).Manual / Anvil (post-deploy pattern)
endSalewith no defended-streak qualifying activity; fundPodiumPool; calldistributePrizes; assert pool empty and treasury gained 20% slice of pool.prizesDistributedprevents re-entry and no second call is required to “unstick” funds.References: Internal audit Markdown
audits/audit_smartcontract_1777813071.md§ M-01; GitLab #70 / #55 context fordistributePrizesgating andPodiumPoolprizePusher.mentioned in commit
6144dd30d2mentioned in commit
68dda7f083verified on commits
6144dd3(feat: ERC20 ingress balance deltas + podium residual hardening) +0ca8907(test: full conservation check in empty time booster).19 podium/prize tests pass clean covering all your acceptance criteria:
empty-category coverage (your AC #5):
test_distributePrizes_empty_defended_forwards_20pct_to_protocol— empty defended-streak slice routes to protocoltest_distributePrizes_empty_time_booster_forwards_time_slice_to_protocol— empty time booster slice routes to protocoltest_distributePrizes_multi_empty_categories_sum_to_protocol— multi-empty case sums correctlyPodiumPool no-stranding (your AC #1):
test_podiumPool_forwardPodiumResidual_happy_path— residual forwards cleanlytest_podiumPool_forwardPodiumResidual_prize_pusher_wins_over_distributor_role— role precedence preservedtest_podiumPool_forwardPodiumResidual_unauthorized_reverts— only authorized callers can forwardtest_podiumPool_forwardPodiumResidual_zero_recipient_reverts— zero-address guarddistributePrizes semantics (your AC #2 + #3):
test_distributePrizes_reduces_vault_and_sets_flag— flag set only after full distributiontest_distributePrizes_reverts_when_residual_recipient_unset— config sanitytest_distributePrizes_partial_last_buy_forwards_unfilled_placements— last-buy partial coveragetest_distributePrizes_dust_pool_is_retryable+test_distributePrizes_empty_vault_is_retryable— retry semanticsnew
PodiumResidualForwardedevent (or equivalent) wired to indexer migration20260504143000_podium_pool_residual_forwardedper your AC #4. forge unit suite passes for this surface.indexer surface for the new
PodiumResidualForwardedevent also verified end-to-end.migration apply on fresh DB:
20260504143000_podium_pool_residual_forwarded.up.sqlapplies clean, tableidx_podium_pool_residual_forwardedcreated withcategorySMALLINT (tracks Last Buy / WarBow / Defended / Time Booster as the empty bucket source)idx_podium_pool_paid,idx_podium_pool_prize_pusher_set)full plumbing wired:
event PodiumResidualForwarded(address indexed token, address indexed recipient, uint256 amount, uint8 category)ABI + dispatch at line 891idx_podium_pool_residual_forwardedcloses your AC #4 ("emit a clear onchain event for protocol forwards (category + amount + token + recipient) so ops and indexers can reconcile") — fully indexed, queryable, and reorg-safe.
mentioned in issue #128
mentioned in issue #129
mentioned in issue #131
mentioned in issue #138