TimeCurve: distributePrizes zero-prizePool early return — indexer semantics, events, replay (Finding 3 from #131) #133

Closed
opened 2026-05-04 08:36:05 +00:00 by PlasticDigits · 9 comments
PlasticDigits commented 2026-05-04 08:36:05 +00:00 (Migrated from gitlab.com)

Parent

Split from #131 (Finding 3).

Problem

distributePrizes does:

uint256 prizePool = acceptedAsset.balanceOf(address(podiumPool));
if (prizePool == 0) {
    return;
}

Effects:

  1. prizesDistributed stays false — no PrizesDistributed event. Downstream actors (indexer UX, ops runbooks treat distribution as canonical “reserve round closed”—see docs/operations/final-signoff-and-value-movement.md) can wait indefinitely without a distinguishing signal between “not yet attempted / empty vault retry” vs “intentionally zero pool forever”.
  2. Early return skips reservePodiumPayoutsEnabled — behavior differs from the non-empty path (policy/consistency gap).
  3. Post-sale donations into PodiumPool let the owner call distributePrizes again later and route funds through category splits (owner-only, but unsolicited donations create a repeatable routing surface called out in the review).

Constraint from existing tests

test_distributePrizes_empty_vault_is_retryable requires that an empty podium vault does NOT set prizesDistributed so ops can refill and retry. Do not “fix” Finding 3 by blindly setting prizesDistributed = true on zero without resolving this contradiction.

Recommended fix (pick a coherent product story)

Deliver explicit on-chain signalling plus closed replay semantics without bricking refill:

  1. Emit a dedicated event when prizePool == 0 after ended (e.g. PrizePoolEmpty(address podiumPool) or PodiumSettlementSkipped(reason)) indexed fields as needed — distinct from PrizesDistributed.
  2. Document indexer + ops: PrizesDistributed = vault drained successfully; new event / state = “attempted settlement with zero balance” vs “distribution complete with zero disbursement”.
  3. Anti-replay for donations: e.g. one-way podiumSettlementFinalized (ornonce) once owner acknowledges zero pool after finalizeWarbowPodium/preconditions OR block second successful distribution path — exact design requires product/legal signoff (must preserve retry when vault was griefed empty then restored per existing test).

Alternative documentation-only path (if contracts unchanged): indexer treats SaleEnded + persisted distributedPrizes attempts + subgraph rules — weaker on-chain guarantee.

Acceptance criteria

  • No silent no-op from onlyOwner distributePrizes when prizePool == 0: observer-visible outcome (event and/or documented state machine).
  • Retry-after-empty-vault flow in tests remains valid or is intentionally replaced by a migrated flow with parity tests updated in the same MR.
  • docs/operations/final-signoff-and-value-movement.md stays aligned with the chosen canonical signal (PrizesDistributed vs supplemental events).

Verification checklist

  • forge test — extend/replace test_distributePrizes_empty_vault_is_retryable / dust cases for new semantics.
  • Indexer:** if new event**, decode + persist (INV-INDEXER-112 matrix cross-check).
  • Frontend: footer / protocol page only if surfaced (optional).
  • docs/testing/invariants-and-business-logic.md + strategy pointers per docs/testing/strategy.md.
  • yieldomega-guardrails skill: link if invariant added for prize distribution signals (#55 corridor).

Repo touch list

Area Change
Code contracts/src/TimeCurve.sol, possibly PodiumPool interface
Tests contracts/test/TimeCurve.t.sol (prize griefing / retry section)
Indexer indexer/ event decode + schema if new event
Docs docs/operations/final-signoff-and-value-movement.md, invariants-and-business-logic.md, docs/indexer/design.md if needed
## Parent Split from **#131** (Finding 3). ## Problem [`distributePrizes`](contracts/src/TimeCurve.sol) does: ```solidity uint256 prizePool = acceptedAsset.balanceOf(address(podiumPool)); if (prizePool == 0) { return; } ``` Effects: 1. **`prizesDistributed` stays false** — no **`PrizesDistributed`** event. Downstream actors (indexer UX, ops runbooks treat distribution as canonical “reserve round closed”—see **`docs/operations/final-signoff-and-value-movement.md`**) can wait indefinitely without a distinguishing signal between “not yet attempted / empty vault retry” vs “intentionally zero pool forever”. 2. **Early return skips** `reservePodiumPayoutsEnabled` — behavior differs from the non-empty path (policy/consistency gap). 3. **Post-sale donations** into `PodiumPool` let the owner call `distributePrizes` again later and route funds through category splits (**owner-only**, but unsolicited donations create a repeatable routing surface called out in the review). ## Constraint from existing tests [`test_distributePrizes_empty_vault_is_retryable`](contracts/test/TimeCurve.t.sol) **requires** that an **empty podium vault does NOT set `prizesDistributed`** so ops can refill and retry. **Do not** “fix” Finding 3 by blindly setting `prizesDistributed = true` on zero without resolving this contradiction. ## Recommended fix (pick a coherent product story) Deliver **explicit on-chain signalling** plus **closed replay** semantics without bricking refill: 1. **Emit** a dedicated event when `prizePool == 0` after `ended` (e.g. `PrizePoolEmpty(address podiumPool)` or `PodiumSettlementSkipped(reason)`) indexed fields as needed — **distinct from** `PrizesDistributed`. 2. **Document** indexer + ops: **`PrizesDistributed` = vault drained successfully**; **new event / state** = “attempted settlement with zero balance” vs “distribution complete with zero disbursement”. 3. **Anti-replay for donations:** e.g. one-way **`podiumSettlementFinalized`** (ornonce) once owner acknowledges zero pool after `finalizeWarbowPodium`/preconditions OR block second successful distribution path — exact design requires product/legal signoff (**must preserve retry** when vault was griefed empty then restored per existing test). Alternative **documentation-only path** (if contracts unchanged): indexer treats `SaleEnded` + persisted `distributedPrizes` attempts + subgraph rules — weaker on-chain guarantee. ## Acceptance criteria - No silent no-op from `onlyOwner distributePrizes` when `prizePool == 0`: **observer-visible** outcome (event and/or documented state machine). - **Retry-after-empty-vault** flow in tests remains valid **or** is intentionally replaced by a migrated flow with parity tests updated in the same MR. - `docs/operations/final-signoff-and-value-movement.md` stays aligned with the chosen canonical signal (`PrizesDistributed` vs supplemental events). ## Verification checklist - [ ] `forge test` — extend/replace **`test_distributePrizes_empty_vault_is_retryable`** / **`dust`** cases for new semantics. - [ ] Indexer:** if new event**, decode + persist (**`INV-INDEXER-112`** matrix cross-check). - [ ] **Frontend:** footer / protocol page only if surfaced (optional). - [ ] **`docs/testing/invariants-and-business-logic.md`** + **strategy** pointers per `docs/testing/strategy.md`. - [ ] **`yieldomega-guardrails` skill**: link if invariant added for prize distribution signals (#55 corridor). ## Repo touch list | Area | Change | |------|--------| | **Code** | `contracts/src/TimeCurve.sol`, possibly `PodiumPool` interface | | **Tests** | `contracts/test/TimeCurve.t.sol` (prize griefing / retry section) | | **Indexer** | `indexer/` event decode + schema if new event | | **Docs** | `docs/operations/final-signoff-and-value-movement.md`, `invariants-and-business-logic.md`, `docs/indexer/design.md` if needed |
PlasticDigits commented 2026-05-04 08:36:45 +00:00 (Migrated from gitlab.com)

mentioned in issue #131

mentioned in issue #131
PlasticDigits commented 2026-05-04 10:56:48 +00:00 (Migrated from gitlab.com)

mentioned in commit aa4907475e

mentioned in commit aa4907475e4234960b9ae4d47f89e56625b0c7c3
PlasticDigits commented 2026-05-04 10:57:05 +00:00 (Migrated from gitlab.com)

mentioned in issue #129

mentioned in issue #129
PlasticDigits commented 2026-05-04 11:37:29 +00:00 (Migrated from gitlab.com)

mentioned in commit 15240cd774

mentioned in commit 15240cd774019a0d2daae1a2e8778e254aee1c5d
PlasticDigits commented 2026-05-04 12:46:20 +00:00 (Migrated from gitlab.com)

mentioned in commit 170d7b7823

mentioned in commit 170d7b782350ae91b47ce3024e38fb1dff194143
PlasticDigits commented 2026-05-04 12:46:21 +00:00 (Migrated from gitlab.com)

Verification update (agent)

On-chain (#133) status: Already on main prior to this pass — distributePrizes with zero PodiumPool balance requires reservePodiumPayoutsEnabled, emits PrizesSettledEmptyPodiumPool, sets prizesDistributed = true (no PrizesDistributed, no refill/retry). See TimeCurve.sol around distributePrizes.

This MR: documentation / agent-facing consistency only (commit on main): stale invariant rows that still described retryable empty vault, incorrect INV-WARBOW-129-DIST, and verify-timecurve-post-end-gates-anvil.sh messaging were aligned with the shipped semantics.

Checklist for @brouie

  1. cast abi-event "PrizesSettledEmptyPodiumPool(address)" / receipt shows indexed podiumPool on empty settlement txs.
  2. After empty settlement: prizesDistributed() is true; second distributePrizes() reverts TimeCurve: prizes done.
  3. forge test --match-test test_distributePrizes_empty - — includes test_distributePrizes_empty_vault_emits_empty_settlement_and_locks and gate revert test.
  4. Indexer: cargo test roundtrip_prizes_settled_empty_podium_pool (decoder round-trip).
  5. Read docs/testing/invariants-and-business-logic.md § Empty PodiumPool (#133) anchor timecurve-empty-podium-pool-settlement-gitlab-133.

Issue left open per maintainer request.

## Verification update (agent) **On-chain (#133) status:** Already on `main` prior to this pass — `distributePrizes` with zero `PodiumPool` balance requires `reservePodiumPayoutsEnabled`, emits **`PrizesSettledEmptyPodiumPool`**, sets **`prizesDistributed = true`** (no `PrizesDistributed`, no refill/retry). See `TimeCurve.sol` around `distributePrizes`. **This MR:** documentation / agent-facing consistency only ([commit on main](https://gitlab.com/PlasticDigits/yieldomega/-/commits/main)): stale invariant rows that still described retryable empty vault, incorrect `INV-WARBOW-129-DIST`, and `verify-timecurve-post-end-gates-anvil.sh` messaging were aligned with the shipped semantics. ### Checklist for @brouie 1. [ ] `cast abi-event "PrizesSettledEmptyPodiumPool(address)"` / receipt shows indexed `podiumPool` on empty settlement txs. 2. [ ] After empty settlement: `prizesDistributed()` is `true`; second `distributePrizes()` reverts `TimeCurve: prizes done`. 3. [ ] `forge test --match-test test_distributePrizes_empty -` — includes `test_distributePrizes_empty_vault_emits_empty_settlement_and_locks` and gate revert test. 4. [ ] Indexer: `cargo test roundtrip_prizes_settled_empty_podium_pool` (decoder round-trip). 5. [ ] Read [docs/testing/invariants-and-business-logic.md § Empty PodiumPool (#133)](https://gitlab.com/PlasticDigits/yieldomega/-/blob/main/docs/testing/invariants-and-business-logic.md) anchor **timecurve-empty-podium-pool-settlement-gitlab-133**. Issue left **open** per maintainer request.
Brouie commented 2026-05-05 01:57:49 +00:00 (Migrated from gitlab.com)

@PlasticDigits — verification PASS. walked your checklist:

  • PrizesSettledEmptyPodiumPool(address) event present in TimeCurve.sol L199-200 with indexed podiumPool recipient. distributePrizes empty-pool path at L841-870 sets prizesDistributed = true, requires reservePodiumPayoutsEnabled (so it does NOT short-circuit the gate anymore), and emits cleanly without pulling slices.
  • second distributePrizes() after empty settlement reverts TimeCurve: prizes done — locked correctly.
  • forge test --match-test test_distributePrizes_empty - green: 4/4 PASS — test_distributePrizes_empty_vault_emits_empty_settlement_and_locks, test_distributePrizes_empty_pool_reverts_when_reserve_podium_payouts_disabled (gate revert), plus empty_defended_forwards_20pct_to_protocol and empty_time_booster_forwards_time_slice_to_protocol covering the partial-empty paths.
  • indexer: cargo test roundtrip_prizes_settled_empty_podium_pool green. decoder picks up the event, persist branch inserts into idx_timecurve_prizes_settled_empty_podium_pool (migration 20260504193000), reorg.rs includes the table in rollback list.
  • §133 anchor in docs/testing/invariants-and-business-logic.md reads cleanly — empty-pool settlement is now terminal (no retry-on-refill), distinct from PrizesDistributed for non-empty path. INV-WARBOW-129-DIST + post-end-gates anvil script messaging are aligned.
    the previous empty_vault_is_retryable semantic is gone — replaced by terminal lock + dedicated event. cleaner indexer story than the old retry-on-refill model. donation-griefing surface closed.
    ready for close on your side.
@PlasticDigits — verification PASS. walked your checklist: - `PrizesSettledEmptyPodiumPool(address)` event present in `TimeCurve.sol` L199-200 with indexed `podiumPool` recipient. `distributePrizes` empty-pool path at L841-870 sets `prizesDistributed = true`, requires `reservePodiumPayoutsEnabled` (so it does NOT short-circuit the gate anymore), and emits cleanly without pulling slices. - second `distributePrizes()` after empty settlement reverts `TimeCurve: prizes done` — locked correctly. - `forge test --match-test test_distributePrizes_empty -` green: 4/4 PASS — `test_distributePrizes_empty_vault_emits_empty_settlement_and_locks`, `test_distributePrizes_empty_pool_reverts_when_reserve_podium_payouts_disabled` (gate revert), plus `empty_defended_forwards_20pct_to_protocol` and `empty_time_booster_forwards_time_slice_to_protocol` covering the partial-empty paths. - indexer: `cargo test roundtrip_prizes_settled_empty_podium_pool` green. decoder picks up the event, persist branch inserts into `idx_timecurve_prizes_settled_empty_podium_pool` (migration `20260504193000`), `reorg.rs` includes the table in rollback list. - §133 anchor in `docs/testing/invariants-and-business-logic.md` reads cleanly — empty-pool settlement is now terminal (no retry-on-refill), distinct from `PrizesDistributed` for non-empty path. INV-WARBOW-129-DIST + post-end-gates anvil script messaging are aligned. the previous `empty_vault_is_retryable` semantic is gone — replaced by terminal lock + dedicated event. cleaner indexer story than the old retry-on-refill model. donation-griefing surface closed. ready for close on your side.
PlasticDigits (Migrated from gitlab.com) closed this issue 2026-05-05 02:33:19 +00:00
PlasticDigits commented 2026-05-05 08:52:57 +00:00 (Migrated from gitlab.com)

mentioned in issue #160

mentioned in issue #160
PlasticDigits commented 2026-05-13 16:07:18 +00:00 (Migrated from gitlab.com)

mentioned in issue #207

mentioned in issue #207
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#133
No description provided.