Indexer: persist LastBuyEpochStarted and assign exact last_buy_epoch on arena buys #278

Closed
opened 2026-06-02 13:30:35 +00:00 by ghost1 · 24 comments
ghost1 commented 2026-06-02 13:30:35 +00:00 (Migrated from gitlab.com)

Context

Parent: #238 (Arena v2 epic). Related: #246 (onchain LastBuyEpochStarted + lastBuyEpoch), #255 (wallet stats HTTP), #273 (live podiums ingest on LastBuyEpochStarted).


Current codebase

Onchain (source of truth)

  • TimeArena.lastBuyEpoch is a global counter (not per-wallet). It increments only on Last Buy hard reset (remaining < threshold → snap to 900s), then emits LastBuyEpochStarted(uint256 indexed epoch, uint256 deadline) before the same tx’s Buy event.
  • CHARM accrual, CRED pools, first-buy bonus scheduling, and claimCred(epoch) all key off this global epoch at buy/claim time — see docs/product/arena-v2.md, docs/product/time-arena.md.
  • Forge coverage: test_timer_hard_reset_increments_epoch, test_emits_LastBuyEpochStarted_on_hard_reset — INV-TIME-ARENA-EPOCH-EVENT (#246).

Indexer today

Area Behavior
Decode LastBuyEpochStarted → DecodedEvent::ArenaLastBuyEpochStarted { epoch, deadline } in indexer/src/decoder.rs.
Persist No-op — DecodedEvent::ArenaLastBuyEpochStarted { .. } => {} in indexer/src/persist.rs (line ~327).
Live podiums arena_podium_live.rs already reacts to ArenaLastBuyEpochStarted for cat-0 RPC snapshots (ingest path).
idx_arena_buy Stores timer_hard_reset but no last_buy_epoch column (migrations/20240601000000_arena_v2.up.sql).
Wallet stats / derived metrics arena_wallet_stats.rs infers epoch per buy via SQL window: SUM(CASE WHEN timer_hard_reset THEN 1 ELSE 0 END) OVER (ORDER BY block_number, log_index …) on idx_arena_buy rows only (per-wallet queries filter buyer = $1).
Reorg New table must be added to ARENA_INDEX_TABLES and covered by rollback_after.
Emitted-event coverage INV-INDEXER-112: every deployed TimeArena event must map to decode + persist + rollback — LastBuyEpochStarted is currently a gap.

Why inference is wrong

  1. lastBuyEpoch is global; per-wallet cumulative timer_hard_reset counts only that wallet’s hard resets → mis-tags CHARM/CRED epoch for buyers who never reset but participate in later global epochs (#255 follow-up noted in QA).
  2. Log ordering: in the same tx, LastBuyEpochStarted is emitted before Buy — inference from timer_hard_reset on the buy row alone cannot assign the post-reset epoch without reading the event stream order.
  3. epochs_participated and highest_scores[].epoch, podium_win_rate, WarBow peak epoch in wallet stats all depend on correct per-buy epoch assignment.

Why this implementation is needed

  • Correct participant profiles (GET /v1/arena/wallet/{address}/stats, #255) and any future epoch-scoped indexer APIs.
  • Indexer parity with chain for CRED/CHARM epoch boundaries without RPC eth_call per buy at read time.
  • Close INV-INDEXER-112 gap and align docs/invariants with actual persist behavior.
  • Foundation for epoch-scoped analytics (leaderboards, CRED history, reorg-safe epoch timelines).

Constraints and guardrails

  • Onchain authority — do not change epoch rules in contracts; indexer is derived only (AGENTS.md, guardrails).
  • One SQL transaction per block (#140); use persist_decoded_log_conn inside block ingest.
  • Reorg safety — new rows keyed by (block_number, tx_hash, log_index); include table in rollback_after / ARENA_INDEX_TABLES.
  • Idempotency — ON CONFLICT DO NOTHING on natural keys (match existing idx_arena_* patterns).
  • No new /v1/timecurve/* (#266).
  • Schema version — bump x-schema-version only if HTTP response shapes change; prefer internal-only migration if wallet stats JSON fields stay stable but values become correct.
  • AGPL — indexer remains AGPL-3.0.

Relevant files

File Role
contracts/src/arena/TimeArena.sol LastBuyEpochStarted, lastBuyEpoch, hard-reset branch
contracts/test/TimeArena.t.sol Reference behavior / log order
indexer/src/decoder.rs ArenaLastBuyEpochStarted variant
indexer/src/persist.rs Implement persist (replace no-op)
indexer/src/ingestion.rs Per-block transactional ingest order
indexer/src/reorg.rs Rollback table list
indexer/src/arena_wallet_stats.rs Replace inference with stored epoch
indexer/src/arena_podium_live.rs Optional: use persisted epoch head vs extra RPC
indexer/src/warbow_score.rs last_buy_epoch_at_block — compare with persisted head
indexer/tests/integration_stage2.rs Persist-all-events + wallet-stats fixtures
indexer/migrations/ New table + idx_arena_buy.last_buy_epoch (recommended)
docs/indexer/design.md Document epoch persistence
docs/testing/invariants-and-business-logic.md New INV-INDEXER-* row
.cursor/skills/yieldomega-guardrails/SKILL.md Cross-link after merge

1. Migration

  • idx_arena_last_buy_epoch_started: (block_number, block_timestamp, tx_hash, log_index, epoch, deadline) — PK (tx_hash, log_index); index (epoch), (block_number).
  • idx_arena_buy.last_buy_epoch BIGINT NOT NULL DEFAULT 0 (backfill strategy below).

2. Ingest (ordered log processing)

Maintain a running head_last_buy_epoch (starts at 0) while processing logs in block order (same order as persist_decoded_log_conn today):

  1. On ArenaLastBuyEpochStarted { epoch, .. }: insert row; set head_last_buy_epoch = epoch.
  2. On ArenaBuy: persist buy with last_buy_epoch = head_last_buy_epoch (the onchain epoch after any earlier LastBuyEpochStarted logs in the same block/tx).

Do not use per-wallet timer_hard_reset sums for epoch assignment.

3. Backfill (deployed indexers)

  • One-off migration script or SQL: replay idx_arena_buy + decoded logs from indexed_blocks or document “wipe and reindex” for dev only.
  • Production: coordinate reindex from deployBlock (document in ops note).

4. Wallet stats / consumers

  • Replace window-function inference in fetch_wallet_buys / fetch_all_buys_ordered / epochs_participated query with last_buy_epoch column.
  • epochs_participated = COUNT(DISTINCT last_buy_epoch) for wallet buys (still “epochs with ≥1 buy”).
  • bash scripts/verify-last-buy-epoch-anvil.sh: hard-reset buy → assert DB epoch rows match cast call TimeArena.lastBuyEpoch() and wallet stats epochs.

Acceptance criteria

  • LastBuyEpochStarted persisted to idx_arena_last_buy_epoch_started with correct epoch and deadline (wei/sec as numeric).
  • Every idx_arena_buy row has last_buy_epoch equal to onchain TimeArena.lastBuyEpoch() at that buy’s log position (verified on Anvil fixture: pre-reset buys → epoch N; first buy after reset in same tx → epoch N+1).
  • epochs_participated for wallet W matches distinct last_buy_epoch on W’s buys, not distinct per-wallet hard-reset count.
  • Wallet with buys only in global epochs it did not reset still gets correct highest_scores[].epoch and CHARM-by-epoch peaks (#255).
  • persist_decoded_log_conn no longer no-ops ArenaLastBuyEpochStarted; INV-INDEXER-112 table list updated.
  • Reorg: rollback removes epoch rows and buy epoch values above ancestor; re-ingest restores consistency.
  • Idempotency: duplicate log insert is a no-op.
  • Docs: INV-INDEXER-*-LAST-BUY-EPOCH in invariants + indexer design §; guardrails cross-link.

Test plan (all paths)

Unit / module

  • Persist helper: insert LastBuyEpochStarted + following Buy in one synthetic block → buy row epoch matches event.
  • Head epoch monotonicity across multiple starts in one block.
  • Buy without intervening start uses previous head (extension-only buy).

Integration (YIELDOMEGA_PG_TEST_URL)

  • Extend postgres_stage2_persist_all_events_and_rollback_after: assert row in idx_arena_last_buy_epoch_started when fixture includes ArenaLastBuyEpochStarted.
  • arena_wallet_stats_two_epochs_and_bonus_fields: add case — wallet B buys in epoch 1 without ever hard-resetting; epochs_participated and highest_scores epochs match global epoch (regression for inference bug).
  • Reorg test: insert epoch + buys above block K, rollback_after(K), rows gone; re-persist restores.

Anvil smoke (manual / script)

  • start-local-anvil-stack.sh → hard-reset buy → psql / HTTP wallet stats vs cast call lastBuyEpoch().
  • Two wallets: A triggers reset, B buys in new epoch → both have correct last_buy_epoch on B’s row only for B.

Regression

  • cargo test (indexer), arena_podium_live ingest still passes (#273).
  • No change required to Forge TimeArena tests unless contract event shape changes (not expected).

Test plan — attack, hack, and abuse vectors

Vector Risk Test / mitigation
Reorg depth Stale epoch head served after shallow reorg rollback_after integration; HTTP reads only above finalized pointer if applicable
Log injection / wrong contract Decode only registry TimeArena proxy (#259) Registry-filtered ingest; no persist from non-registry address
Duplicate / replayed logs Inflated epoch rows ON CONFLICT DO NOTHING; idempotency test
Out-of-order logs (same block) Wrong last_buy_epoch on buy Fixture with LastBuyEpochStarted immediately before Buy in same tx; assert order-sensitive assignment
Malicious RPC (ingest-time eth_call) Prefer event-driven head, not per-buy eth_call to untrusted RPC Design avoids extra calls; if any RPC used, same provider as chain ingest
Wallet stats DoS Heavy window queries Column + index on (buyer, last_buy_epoch); drop global SUM() OVER on full buy table where possible
Epoch boundary gaming User times buy to skew stats Stats reflect chain truth; product accepts onchain epoch rules — document no off-chain “fairness” override
Integer / epoch overflow Absurd epoch in DB Store NUMERIC(78,0) like other u256 fields; HTTP serializes as decimal string

Verification criteria (QA / Agent)

  • cd indexer && YIELDOMEGA_PG_TEST_URL=… cargo test — all integration tests green including new epoch fixtures.
  • Anvil: curl -s http://127.0.0.1:3100/v1/arena/wallet/0x…/stats | jq '.epochs_participated,.highest_scores' matches scenario script (two global epochs, non-resetting participant).
  • psql: SELECT last_buy_epoch, timer_hard_reset FROM idx_arena_buy ORDER BY block_number, log_index matches event replay.
  • Invariants doc lists INV-INDEXER-*-LAST-BUY-EPOCH with file pointers.
  • Optional: bash scripts/verify-last-buy-epoch-anvil.sh exits 0.

Leave issue open until QA signs off on Anvil + integration matrix above.

## Context Parent: [#238](https://gitlab.com/PlasticDigits/yieldomega/-/issues/238) (Arena v2 epic). Related: [#246](https://gitlab.com/PlasticDigits/yieldomega/-/issues/246) (onchain `LastBuyEpochStarted` + `lastBuyEpoch`), [#255](https://gitlab.com/PlasticDigits/yieldomega/-/issues/255) (wallet stats HTTP), [#273](https://gitlab.com/PlasticDigits/yieldomega/-/issues/273) (live podiums ingest on `LastBuyEpochStarted`). --- ## Current codebase ### Onchain (source of truth) - **`TimeArena.lastBuyEpoch`** is a **global** counter (not per-wallet). It increments **only** on Last Buy **hard reset** (remaining &lt; threshold → snap to 900s), then emits **`LastBuyEpochStarted(uint256 indexed epoch, uint256 deadline)`** before the same tx’s **`Buy`** event. - CHARM accrual, CRED pools, first-buy bonus scheduling, and `claimCred(epoch)` all key off this **global** epoch at buy/claim time — see [`docs/product/arena-v2.md`](docs/product/arena-v2.md), [`docs/product/time-arena.md`](docs/product/time-arena.md). - Forge coverage: `test_timer_hard_reset_increments_epoch`, `test_emits_LastBuyEpochStarted_on_hard_reset` — **`INV-TIME-ARENA-EPOCH-EVENT`** ([#246](https://gitlab.com/PlasticDigits/yieldomega/-/issues/246)). ### Indexer today | Area | Behavior | |------|----------| | **Decode** | `LastBuyEpochStarted` → `DecodedEvent::ArenaLastBuyEpochStarted { epoch, deadline }` in [`indexer/src/decoder.rs`](indexer/src/decoder.rs). | | **Persist** | **No-op** — `DecodedEvent::ArenaLastBuyEpochStarted { .. } => {}` in [`indexer/src/persist.rs`](indexer/src/persist.rs) (line ~327). | | **Live podiums** | [`arena_podium_live.rs`](indexer/src/arena_podium_live.rs) already reacts to `ArenaLastBuyEpochStarted` for cat-0 RPC snapshots (ingest path). | | **`idx_arena_buy`** | Stores `timer_hard_reset` but **no `last_buy_epoch` column** ([`migrations/20240601000000_arena_v2.up.sql`](indexer/migrations/20240601000000_arena_v2.up.sql)). | | **Wallet stats / derived metrics** | [`arena_wallet_stats.rs`](indexer/src/arena_wallet_stats.rs) **infers** epoch per buy via SQL window: `SUM(CASE WHEN timer_hard_reset THEN 1 ELSE 0 END) OVER (ORDER BY block_number, log_index …)` on **`idx_arena_buy` rows only** (per-wallet queries filter `buyer = $1`). | | **Reorg** | New table must be added to [`ARENA_INDEX_TABLES`](indexer/src/reorg.rs) and covered by `rollback_after`. | | **Emitted-event coverage** | **`INV-INDEXER-112`**: every deployed `TimeArena` event must map to decode + persist + rollback — `LastBuyEpochStarted` is currently a gap. | ### Why inference is wrong 1. **`lastBuyEpoch` is global**; per-wallet cumulative `timer_hard_reset` counts **only that wallet’s** hard resets → mis-tags CHARM/CRED epoch for buyers who never reset but participate in later global epochs ([#255](https://gitlab.com/PlasticDigits/yieldomega/-/issues/255) follow-up noted in QA). 2. **Log ordering**: in the same tx, **`LastBuyEpochStarted` is emitted before `Buy`** — inference from `timer_hard_reset` on the buy row alone cannot assign the post-reset epoch without reading the event stream order. 3. **`epochs_participated`** and **`highest_scores[].epoch`**, **`podium_win_rate`**, WarBow peak epoch in wallet stats all depend on correct per-buy epoch assignment. --- ## Why this implementation is needed - **Correct participant profiles** (`GET /v1/arena/wallet/{address}/stats`, [#255](https://gitlab.com/PlasticDigits/yieldomega/-/issues/255)) and any future epoch-scoped indexer APIs. - **Indexer parity with chain** for CRED/CHARM epoch boundaries without RPC `eth_call` per buy at read time. - **Close `INV-INDEXER-112` gap** and align docs/invariants with actual persist behavior. - **Foundation** for epoch-scoped analytics (leaderboards, CRED history, reorg-safe epoch timelines). --- ## Constraints and guardrails - **Onchain authority** — do not change epoch rules in contracts; indexer is derived only ([`AGENTS.md`](AGENTS.md), [guardrails](.cursor/skills/yieldomega-guardrails/SKILL.md)). - **One SQL transaction per block** ([#140](https://gitlab.com/PlasticDigits/yieldomega/-/issues/140)); use `persist_decoded_log_conn` inside block ingest. - **Reorg safety** — new rows keyed by `(block_number, tx_hash, log_index)`; include table in `rollback_after` / `ARENA_INDEX_TABLES`. - **Idempotency** — `ON CONFLICT DO NOTHING` on natural keys (match existing `idx_arena_*` patterns). - **No new `/v1/timecurve/*`** ([#266](https://gitlab.com/PlasticDigits/yieldomega/-/issues/266)). - **Schema version** — bump `x-schema-version` only if HTTP response shapes change; prefer internal-only migration if wallet stats JSON fields stay stable but values become correct. - **AGPL** — indexer remains AGPL-3.0. --- ## Relevant files | File | Role | |------|------| | [`contracts/src/arena/TimeArena.sol`](contracts/src/arena/TimeArena.sol) | `LastBuyEpochStarted`, `lastBuyEpoch`, hard-reset branch | | [`contracts/test/TimeArena.t.sol`](contracts/test/TimeArena.t.sol) | Reference behavior / log order | | [`indexer/src/decoder.rs`](indexer/src/decoder.rs) | `ArenaLastBuyEpochStarted` variant | | [`indexer/src/persist.rs`](indexer/src/persist.rs) | Implement persist (replace no-op) | | [`indexer/src/ingestion.rs`](indexer/src/ingestion.rs) | Per-block transactional ingest order | | [`indexer/src/reorg.rs`](indexer/src/reorg.rs) | Rollback table list | | [`indexer/src/arena_wallet_stats.rs`](indexer/src/arena_wallet_stats.rs) | Replace inference with stored epoch | | [`indexer/src/arena_podium_live.rs`](indexer/src/arena_podium_live.rs) | Optional: use persisted epoch head vs extra RPC | | [`indexer/src/warbow_score.rs`](indexer/src/warbow_score.rs) | `last_buy_epoch_at_block` — compare with persisted head | | [`indexer/tests/integration_stage2.rs`](indexer/tests/integration_stage2.rs) | Persist-all-events + wallet-stats fixtures | | [`indexer/migrations/`](indexer/migrations/) | New table + `idx_arena_buy.last_buy_epoch` (recommended) | | [`docs/indexer/design.md`](docs/indexer/design.md) | Document epoch persistence | | [`docs/testing/invariants-and-business-logic.md`](docs/testing/invariants-and-business-logic.md) | New **`INV-INDEXER-*`** row | | [`.cursor/skills/yieldomega-guardrails/SKILL.md`](.cursor/skills/yieldomega-guardrails/SKILL.md) | Cross-link after merge | --- ## Recommended direction ### 1. Migration - **`idx_arena_last_buy_epoch_started`**: `(block_number, block_timestamp, tx_hash, log_index, epoch, deadline)` — PK `(tx_hash, log_index)`; index `(epoch)`, `(block_number)`. - **`idx_arena_buy.last_buy_epoch BIGINT NOT NULL DEFAULT 0`** (backfill strategy below). ### 2. Ingest (ordered log processing) Maintain a running **`head_last_buy_epoch`** (starts at `0`) while processing logs **in block order** (same order as `persist_decoded_log_conn` today): 1. On **`ArenaLastBuyEpochStarted { epoch, .. }`**: insert row; set `head_last_buy_epoch = epoch`. 2. On **`ArenaBuy`**: persist buy with **`last_buy_epoch = head_last_buy_epoch`** (the onchain epoch **after** any earlier `LastBuyEpochStarted` logs in the same block/tx). Do **not** use per-wallet `timer_hard_reset` sums for epoch assignment. ### 3. Backfill (deployed indexers) - One-off migration script or SQL: replay `idx_arena_buy` + decoded logs from `indexed_blocks` **or** document “wipe and reindex” for dev only. - Production: coordinate reindex from `deployBlock` (document in ops note). ### 4. Wallet stats / consumers - Replace window-function inference in `fetch_wallet_buys` / `fetch_all_buys_ordered` / `epochs_participated` query with **`last_buy_epoch` column**. - `epochs_participated` = `COUNT(DISTINCT last_buy_epoch)` for wallet buys (still “epochs with ≥1 buy”). ### 5. Verification script (optional but recommended) - `bash scripts/verify-last-buy-epoch-anvil.sh`: hard-reset buy → assert DB epoch rows match `cast call TimeArena.lastBuyEpoch()` and wallet stats epochs. --- ## Acceptance criteria - [ ] **`LastBuyEpochStarted`** persisted to **`idx_arena_last_buy_epoch_started`** with correct `epoch` and `deadline` (wei/sec as numeric). - [ ] Every **`idx_arena_buy`** row has **`last_buy_epoch`** equal to onchain `TimeArena.lastBuyEpoch()` **at that buy’s log position** (verified on Anvil fixture: pre-reset buys → epoch N; first buy after reset in same tx → epoch N+1). - [ ] **`epochs_participated`** for wallet W matches **distinct `last_buy_epoch`** on W’s buys, not distinct per-wallet hard-reset count. - [ ] Wallet with buys only in global epochs it did not reset still gets correct **`highest_scores[].epoch`** and CHARM-by-epoch peaks ([#255](https://gitlab.com/PlasticDigits/yieldomega/-/issues/255)). - [ ] **`persist_decoded_log_conn`** no longer no-ops `ArenaLastBuyEpochStarted`; **`INV-INDEXER-112`** table list updated. - [ ] **Reorg**: rollback removes epoch rows and buy epoch values above ancestor; re-ingest restores consistency. - [ ] **Idempotency**: duplicate log insert is a no-op. - [ ] Docs: **`INV-INDEXER-*-LAST-BUY-EPOCH`** in invariants + indexer design §; guardrails cross-link. --- ## Test plan (all paths) ### Unit / module - [ ] Persist helper: insert `LastBuyEpochStarted` + following `Buy` in one synthetic block → buy row epoch matches event. - [ ] Head epoch monotonicity across multiple starts in one block. - [ ] Buy without intervening start uses previous head (extension-only buy). ### Integration (`YIELDOMEGA_PG_TEST_URL`) - [ ] Extend `postgres_stage2_persist_all_events_and_rollback_after`: assert row in `idx_arena_last_buy_epoch_started` when fixture includes `ArenaLastBuyEpochStarted`. - [ ] **`arena_wallet_stats_two_epochs_and_bonus_fields`**: add case — wallet B buys in epoch 1 without ever hard-resetting; `epochs_participated` and `highest_scores` epochs match global epoch (regression for inference bug). - [ ] Reorg test: insert epoch + buys above block K, `rollback_after(K)`, rows gone; re-persist restores. ### Anvil smoke (manual / script) - [ ] `start-local-anvil-stack.sh` → hard-reset buy → `psql` / HTTP wallet stats vs `cast call lastBuyEpoch()`. - [ ] Two wallets: A triggers reset, B buys in new epoch → both have correct `last_buy_epoch` on B’s row only for B. ### Regression - [ ] `cargo test` (indexer), `arena_podium_live` ingest still passes ([#273](https://gitlab.com/PlasticDigits/yieldomega/-/issues/273)). - [ ] No change required to Forge `TimeArena` tests unless contract event shape changes (not expected). --- ## Test plan — attack, hack, and abuse vectors | Vector | Risk | Test / mitigation | |--------|------|-------------------| | **Reorg depth** | Stale epoch head served after shallow reorg | `rollback_after` integration; HTTP reads only above finalized pointer if applicable | | **Log injection / wrong contract** | Decode only registry `TimeArena` proxy ([#259](https://gitlab.com/PlasticDigits/yieldomega/-/issues/259)) | Registry-filtered ingest; no persist from non-registry address | | **Duplicate / replayed logs** | Inflated epoch rows | `ON CONFLICT DO NOTHING`; idempotency test | | **Out-of-order logs (same block)** | Wrong `last_buy_epoch` on buy | Fixture with `LastBuyEpochStarted` immediately before `Buy` in same tx; assert order-sensitive assignment | | **Malicious RPC (ingest-time eth_call)** | Prefer event-driven head, not per-buy `eth_call` to untrusted RPC | Design avoids extra calls; if any RPC used, same provider as chain ingest | | **Wallet stats DoS** | Heavy window queries | Column + index on `(buyer, last_buy_epoch)`; drop global `SUM() OVER` on full buy table where possible | | **Epoch boundary gaming** | User times buy to skew stats | Stats reflect chain truth; product accepts onchain epoch rules — document no off-chain “fairness” override | | **Integer / epoch overflow** | Absurd epoch in DB | Store `NUMERIC(78,0)` like other u256 fields; HTTP serializes as decimal string | --- ## Verification criteria (QA / Agent) - [ ] `cd indexer && YIELDOMEGA_PG_TEST_URL=… cargo test` — all integration tests green including new epoch fixtures. - [ ] Anvil: `curl -s http://127.0.0.1:3100/v1/arena/wallet/0x…/stats | jq '.epochs_participated,.highest_scores'` matches scenario script (two global epochs, non-resetting participant). - [ ] `psql`: `SELECT last_buy_epoch, timer_hard_reset FROM idx_arena_buy ORDER BY block_number, log_index` matches event replay. - [ ] Invariants doc lists **`INV-INDEXER-*-LAST-BUY-EPOCH`** with file pointers. - [ ] Optional: `bash scripts/verify-last-buy-epoch-anvil.sh` exits 0. **Leave issue open** until QA signs off on Anvil + integration matrix above.
ghost1 commented 2026-06-03 11:33:37 +00:00 (Migrated from gitlab.com)

mentioned in commit d640b713fca7892f9bec98506284efec4616ba08

mentioned in commit d640b713fca7892f9bec98506284efec4616ba08
ghost1 commented 2026-06-03 11:33:37 +00:00 (Migrated from gitlab.com)

mentioned in commit 649b35b3a8b99bc6be83df49b16572f1484f3583

mentioned in commit 649b35b3a8b99bc6be83df49b16572f1484f3583
ghost1 commented 2026-06-03 11:34:10 +00:00 (Migrated from gitlab.com)

mentioned in commit f9ba6052a6

mentioned in commit f9ba6052a6d1f3c94f56b0f425f2814d072aae59
ghost1 commented 2026-06-03 11:34:11 +00:00 (Migrated from gitlab.com)

mentioned in commit 0899de7d27

mentioned in commit 0899de7d27c5a949e9d1667364b21a8295c8b6c4
PlasticDigits commented 2026-06-03 11:34:19 +00:00 (Migrated from gitlab.com)

mentioned in merge request !407

mentioned in merge request !407
PlasticDigits commented 2026-06-03 11:34:23 +00:00 (Migrated from gitlab.com)

mentioned in commit ae9653fc5b

mentioned in commit ae9653fc5b8d8d39187361e7e94997ae5c1e95c1
PlasticDigits commented 2026-06-03 11:34:39 +00:00 (Migrated from gitlab.com)

Implementation complete (MR !407 merged)

Indexer now persists global Last Buy epoch boundaries and assigns the correct last_buy_epoch on every arena buy — replacing the incorrect per-wallet timer_hard_reset window inference.

What changed

  • Migration 20260602120000_idx_arena_last_buy_epoch_gl278: new idx_arena_last_buy_epoch_started table + idx_arena_buy.last_buy_epoch column (index on (buyer, last_buy_epoch)).
  • Ingest last_buy_epoch_head.rs: running global head loaded at block start; updated on LastBuyEpochStarted, applied to each Buy in log order.
  • Persist persist.rs: LastBuyEpochStarted no longer a no-op; closes INV-INDEXER-112 gap.
  • Wallet stats arena_wallet_stats.rs: epochs_participated and buy queries use stored last_buy_epoch.
  • Reorg reorg.rs: epoch table in ARENA_INDEX_TABLES.
  • Tests: extended postgres_stage2_persist_all_events_and_rollback_after; new last_buy_epoch_global_assignment_non_resetting_participant regression.
  • Smoke: bash scripts/verify-last-buy-epoch-anvil.sh
  • Docs: INV-INDEXER-278-LAST-BUY-EPOCH in invariants, indexer design §278, guardrails + play skills cross-links.

Agent verification (PASS)

Item Command Result
Clippy cd indexer && cargo clippy --all-targets -- -D warnings PASS
Unit + integration YIELDOMEGA_PG_TEST_URL=postgres://yieldomega:password@127.0.0.1:5433/yieldomega_indexer_test cargo test PASS (35 tests)
Anvil smoke bash scripts/verify-last-buy-epoch-anvil.sh PASS

QA verification checklist

  • Run migration on staging/test DB (or wipe + reindex from deploy block for existing rows).
  • YIELDOMEGA_PG_TEST_URL=… cargo test in indexer/ — all integration tests green.
  • bash scripts/verify-last-buy-epoch-anvil.sh exits 0.
  • After hard-reset buy on Anvil stack: psql shows row in idx_arena_last_buy_epoch_started with epoch matching cast call TimeArena.lastBuyEpoch().
  • Two wallets: A triggers reset, B buys in new epoch → B's idx_arena_buy.last_buy_epoch = global epoch (not 0).
  • curl /v1/arena/wallet/{B}/stats → epochs_participated matches distinct stored epochs.
  • Reorg test: rollback removes epoch rows above ancestor; re-ingest restores consistency.

Follow-ups

  • Production reindex: deployed indexers with pre-migration buy rows need reindex from deploy block for correct historical last_buy_epoch (documented in migration SQL).

Requesting verification from the QA agent team. Issue left open pending Anvil + integration sign-off.

## Implementation complete (MR !407 merged) Indexer now persists global Last Buy epoch boundaries and assigns the correct `last_buy_epoch` on every arena buy — replacing the incorrect per-wallet `timer_hard_reset` window inference. ### What changed - **Migration** `20260602120000_idx_arena_last_buy_epoch_gl278`: new `idx_arena_last_buy_epoch_started` table + `idx_arena_buy.last_buy_epoch` column (index on `(buyer, last_buy_epoch)`). - **Ingest** [`last_buy_epoch_head.rs`](indexer/src/last_buy_epoch_head.rs): running global head loaded at block start; updated on `LastBuyEpochStarted`, applied to each `Buy` in log order. - **Persist** [`persist.rs`](indexer/src/persist.rs): `LastBuyEpochStarted` no longer a no-op; closes `INV-INDEXER-112` gap. - **Wallet stats** [`arena_wallet_stats.rs`](indexer/src/arena_wallet_stats.rs): `epochs_participated` and buy queries use stored `last_buy_epoch`. - **Reorg** [`reorg.rs`](indexer/src/reorg.rs): epoch table in `ARENA_INDEX_TABLES`. - **Tests**: extended `postgres_stage2_persist_all_events_and_rollback_after`; new `last_buy_epoch_global_assignment_non_resetting_participant` regression. - **Smoke**: `bash scripts/verify-last-buy-epoch-anvil.sh` - **Docs**: `INV-INDEXER-278-LAST-BUY-EPOCH` in [invariants](docs/testing/invariants-and-business-logic.md#indexer-last-buy-epoch-gitlab-278), [indexer design §278](docs/indexer/design.md#arena-last-buy-epoch-gitlab-278), guardrails + play skills cross-links. ### Agent verification (PASS) | Item | Command | Result | |------|---------|--------| | Clippy | `cd indexer && cargo clippy --all-targets -- -D warnings` | PASS | | Unit + integration | `YIELDOMEGA_PG_TEST_URL=postgres://yieldomega:password@127.0.0.1:5433/yieldomega_indexer_test cargo test` | PASS (35 tests) | | Anvil smoke | `bash scripts/verify-last-buy-epoch-anvil.sh` | PASS | ### QA verification checklist - [ ] Run migration on staging/test DB (or wipe + reindex from deploy block for existing rows). - [ ] `YIELDOMEGA_PG_TEST_URL=… cargo test` in `indexer/` — all integration tests green. - [ ] `bash scripts/verify-last-buy-epoch-anvil.sh` exits 0. - [ ] After hard-reset buy on Anvil stack: `psql` shows row in `idx_arena_last_buy_epoch_started` with `epoch` matching `cast call TimeArena.lastBuyEpoch()`. - [ ] Two wallets: A triggers reset, B buys in new epoch → B's `idx_arena_buy.last_buy_epoch` = global epoch (not 0). - [ ] `curl /v1/arena/wallet/{B}/stats` → `epochs_participated` matches distinct stored epochs. - [ ] Reorg test: rollback removes epoch rows above ancestor; re-ingest restores consistency. ### Follow-ups - **Production reindex**: deployed indexers with pre-migration buy rows need reindex from deploy block for correct historical `last_buy_epoch` (documented in migration SQL). --- Requesting verification from the QA agent team. Issue left open pending Anvil + integration sign-off.
PlasticDigits commented 2026-06-03 12:06:15 +00:00 (Migrated from gitlab.com)

mentioned in merge request !406

mentioned in merge request !406
PlasticDigits commented 2026-06-03 13:26:59 +00:00 (Migrated from gitlab.com)

Verification complete (Cloud Agent)

Re-verified GitLab #278 after MR !407 merge on main (6d520e7). No repository changes required.

What was verified

Area Item Result How
Indexer lint cargo clippy --all-targets -- -D warnings PASS cd indexer && cargo clippy …
Unit tests 31 unit tests PASS same run
Integration postgres_stage2_persist_all_events_and_rollback_after (epoch table + buy last_buy_epoch) PASS YIELDOMEGA_PG_TEST_URL=postgres://yieldomega:password@127.0.0.1:5433/yieldomega_indexer_test cargo test — 35 tests total
Regression last_buy_epoch_global_assignment_non_resetting_participant (wallet B in epoch 1 without hard-reset) PASS integration test
Regression arena_wallet_stats_two_epochs_and_bonus_fields PASS integration test
Anvil smoke bash scripts/verify-last-buy-epoch-anvil.sh PASS Fresh Anvil :8547, indexer :3102, native Postgres :5433
AC: epoch row idx_arena_last_buy_epoch_started epoch 1 after hard reset PASS script asserts COUNT(*) >= 1 for epoch 1
AC: onchain parity cast call TimeArena.lastBuyEpoch() = 1 PASS script
AC: two wallets Alice resets; Bob last_buy_epoch = 1 PASS psql in script
AC: wallet stats Bob epochs_participated = 1 PASS curl /v1/arena/wallet/{bob}/stats
AC: epoch-0 buys pre-reset rows last_buy_epoch = 0 PASS script
AC: persist ArenaLastBuyEpochStarted not a no-op PASS code review + integration persist fixture
AC: reorg epoch table in ARENA_INDEX_TABLES; rollback integration PASS integration_stage2::postgres_stage2_persist_all_events_and_rollback_after
AC: docs INV-INDEXER-278-LAST-BUY-EPOCH, invariants §278, indexer design §278, guardrails §6g PASS doc grep
QA: staging migration Run migration on staging / production reindex SKIP No staging env on Cloud VM; dev path uses fresh DB wipe in verify script (matches local QA)

Acceptance criteria mapping (issue body)

  • LastBuyEpochStarted → idx_arena_last_buy_epoch_started
  • idx_arena_buy.last_buy_epoch matches onchain epoch at log position (same-tx reset → N+1 for following buy)
  • epochs_participated = distinct last_buy_epoch (non-resetting participant regression)
  • Wallet stats highest_scores[].epoch / global epoch correctness covered by integration + Anvil
  • INV-INDEXER-112 gap closed for LastBuyEpochStarted
  • Reorg + idempotency covered in stage2 integration
  • Docs + guardrails cross-links present

Follow-ups

  • Production / staging: apply migration 20260602120000_idx_arena_last_buy_epoch_gl278 and reindex from deploy block for historical last_buy_epoch (noted in migration SQL).
  • Doc nit: skills/README.md play-time-arena-doub row has a broken markdown link fragment for GitLab #258 (functional cross-links in guardrails/invariants are correct).

Closing #278 — implementation and verification matrix are green on main.

## Verification complete (Cloud Agent) Re-verified GitLab #278 after MR !407 merge on `main` (`6d520e7`). No repository changes required. ### What was verified | Area | Item | Result | How | |------|------|--------|-----| | **Indexer lint** | `cargo clippy --all-targets -- -D warnings` | **PASS** | `cd indexer && cargo clippy …` | | **Unit tests** | 31 unit tests | **PASS** | same run | | **Integration** | `postgres_stage2_persist_all_events_and_rollback_after` (epoch table + buy `last_buy_epoch`) | **PASS** | `YIELDOMEGA_PG_TEST_URL=postgres://yieldomega:password@127.0.0.1:5433/yieldomega_indexer_test cargo test` — 35 tests total | | **Regression** | `last_buy_epoch_global_assignment_non_resetting_participant` (wallet B in epoch 1 without hard-reset) | **PASS** | integration test | | **Regression** | `arena_wallet_stats_two_epochs_and_bonus_fields` | **PASS** | integration test | | **Anvil smoke** | `bash scripts/verify-last-buy-epoch-anvil.sh` | **PASS** | Fresh Anvil :8547, indexer :3102, native Postgres :5433 | | **AC: epoch row** | `idx_arena_last_buy_epoch_started` epoch 1 after hard reset | **PASS** | script asserts `COUNT(*) >= 1` for epoch 1 | | **AC: onchain parity** | `cast call TimeArena.lastBuyEpoch()` = 1 | **PASS** | script | | **AC: two wallets** | Alice resets; Bob `last_buy_epoch` = 1 | **PASS** | `psql` in script | | **AC: wallet stats** | Bob `epochs_participated` = 1 | **PASS** | `curl /v1/arena/wallet/{bob}/stats` | | **AC: epoch-0 buys** | pre-reset rows `last_buy_epoch = 0` | **PASS** | script | | **AC: persist** | `ArenaLastBuyEpochStarted` not a no-op | **PASS** | code review + integration persist fixture | | **AC: reorg** | epoch table in `ARENA_INDEX_TABLES`; rollback integration | **PASS** | `integration_stage2::postgres_stage2_persist_all_events_and_rollback_after` | | **AC: docs** | `INV-INDEXER-278-LAST-BUY-EPOCH`, invariants §278, indexer design §278, guardrails §6g | **PASS** | doc grep | | **QA: staging migration** | Run migration on staging / production reindex | **SKIP** | No staging env on Cloud VM; dev path uses fresh DB wipe in verify script (matches local QA) | ### Acceptance criteria mapping (issue body) - [x] `LastBuyEpochStarted` → `idx_arena_last_buy_epoch_started` - [x] `idx_arena_buy.last_buy_epoch` matches onchain epoch at log position (same-tx reset → N+1 for following buy) - [x] `epochs_participated` = distinct `last_buy_epoch` (non-resetting participant regression) - [x] Wallet stats `highest_scores[].epoch` / global epoch correctness covered by integration + Anvil - [x] `INV-INDEXER-112` gap closed for `LastBuyEpochStarted` - [x] Reorg + idempotency covered in stage2 integration - [x] Docs + guardrails cross-links present ### Follow-ups - **Production / staging**: apply migration `20260602120000_idx_arena_last_buy_epoch_gl278` and reindex from deploy block for historical `last_buy_epoch` (noted in migration SQL). - **Doc nit**: `skills/README.md` play-time-arena-doub row has a broken markdown link fragment for GitLab #258 (functional cross-links in guardrails/invariants are correct). Closing #278 — implementation and verification matrix are green on `main`.
PlasticDigits (Migrated from gitlab.com) closed this issue 2026-06-03 13:27:02 +00:00
PlasticDigits commented 2026-06-03 13:29:33 +00:00 (Migrated from gitlab.com)

mentioned in issue #285

mentioned in issue #285
ghost1 commented 2026-06-03 13:36:30 +00:00 (Migrated from gitlab.com)

mentioned in commit 05dac2661e

mentioned in commit 05dac2661e02f0464a94357cdc27015139fee5d8
PlasticDigits commented 2026-06-03 13:37:20 +00:00 (Migrated from gitlab.com)

mentioned in merge request !414

mentioned in merge request !414
ghost1 commented 2026-06-03 13:54:12 +00:00 (Migrated from gitlab.com)

mentioned in commit 0d408509b1

mentioned in commit 0d408509b18af25b2f23cac382e55fb6062d216e
PlasticDigits commented 2026-06-03 13:54:44 +00:00 (Migrated from gitlab.com)

mentioned in merge request !416

mentioned in merge request !416
PlasticDigits commented 2026-06-03 14:01:02 +00:00 (Migrated from gitlab.com)

mentioned in commit 9baac170e5

mentioned in commit 9baac170e50cf851e863740ae135912782e3772e
PlasticDigits commented 2026-06-08 08:45:13 +00:00 (Migrated from gitlab.com)

mentioned in commit 9f376dced2

mentioned in commit 9f376dced29da3a676dad42dcf5a826e5025a01f
PlasticDigits commented 2026-06-08 08:45:13 +00:00 (Migrated from gitlab.com)

mentioned in commit 315455e43c

mentioned in commit 315455e43c4959af7b359ac3f7c912cc5b117cd9
PlasticDigits commented 2026-06-08 08:45:13 +00:00 (Migrated from gitlab.com)

mentioned in commit 9ce7a78686

mentioned in commit 9ce7a786868720b596870a42dc8022300f81ffc4
PlasticDigits commented 2026-06-10 09:09:15 +00:00 (Migrated from gitlab.com)

mentioned in issue #305

mentioned in issue #305
PlasticDigits commented 2026-06-10 09:09:16 +00:00 (Migrated from gitlab.com)

marked as related to #305

marked as related to #305
PlasticDigits commented 2026-06-25 14:28:40 +00:00 (Migrated from gitlab.com)

mentioned in issue #342

mentioned in issue #342
PlasticDigits commented 2026-06-25 20:07:18 +00:00 (Migrated from gitlab.com)

mentioned in issue #346

mentioned in issue #346
PlasticDigits commented 2026-06-25 22:17:17 +00:00 (Migrated from gitlab.com)

mentioned in merge request !521

mentioned in merge request !521
PlasticDigits commented 2026-07-07 02:38:41 +00:00 (Migrated from gitlab.com)

mentioned in issue #364

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