From f51aa0313f1d3bdaa2153456a528ec0729f7673f Mon Sep 17 00:00:00 2001 From: jdjioe5-cpu <297354017+CodingAngel1@users.noreply.github.com> Date: Sat, 17 Oct 2026 22:02:38 +0800 Subject: [PATCH] test(indexer): mixed schema_version fixtures and stop-on-unknown coverage - Closes #34 - Covers Roadmap rows M2-10 and M2-14 - Add forward-compatible v2 handler stubs (lockCreatedV2, releasedV2) registered in HANDLERS - Add synthesized mixed-versions.json fixture covering interleaved v1 and v2 schema events on the same lock (Lock 3) - Verify ingest handles mixed event streams without double counting or state corruption - Prove identical database state when replayed multiple times across varying batch sizes (1, 4, 100) - Add stop-on-unknown coverage verifying schema_version 0 and 99 halt ingest immediately with UnsupportedEventError - Add missing schema_version coverage verifying DecodeError triggers atomic batch rollback - Update ROADMAP.md row statuses and changelog entry --- ROADMAP.md | 11 +- services/indexer/src/handlers/index.ts | 8 +- services/indexer/src/handlers/lock-created.ts | 5 + services/indexer/src/handlers/released.ts | 5 + .../test/fixtures/events/mixed-versions.json | 971 ++++++++++++++++++ services/indexer/test/helpers/fixtures.ts | 16 + services/indexer/test/ingest.test.ts | 144 +++ 7 files changed, 1151 insertions(+), 9 deletions(-) create mode 100644 services/indexer/test/fixtures/events/mixed-versions.json diff --git a/ROADMAP.md b/ROADMAP.md index 0b2a524..ff54887 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -4,7 +4,7 @@ | | | |---|---| -| **Last updated** | 2026-10-08 (W-05 resolved; 17 Wave issues seeded with seven new rows; issue format proofed against Drips' maintainer guide; the three pre-format issues triaged — app #16 re-emitted, app #15 and registry #7 closed as superseded; repo-side Changelog lines unioned back into canonical by `scripts/roadmap-merge`; that merge's ADR-link normalization anchored so quoted prose cannot be rewritten) | +| **Last updated** | 2026-10-17 (M2-10 and M2-14 completed: mixed schema_version fixtures and stop-on-unknown test coverage landed for the indexer, Issue #34) | | **Docs baseline** | v0.3, worldwide scope (`PRD.md`, `ARCHITECTURE.md`, `ARCHITECTURE_ESSENTIALS.md`, `AGENTS.md`, `CLAUDE.md`, `project_structure.md`) | | **Current phase** | Phase 0 (Foundations) → starting Phase 1 (M0 Validate) | | **Readiness** | See [§3](#3-progress-snapshot) | @@ -130,7 +130,7 @@ Readiness % = `DONE ÷ (all rows − DEFERRED − DROPPED)`. Conditional rows (P | 0 Foundations | 19 | 16 | 1 | 2 | 0 | 0 | 0 | 84% | | 1 M0 Validate | 17 | 1 | 6 | 10 | 0 | 0 | 0 | 6% | | 2 Contract + registry | 41 | 24 | 3 | 14 | 0 | 0 | 0 | 59% | -| 3 SDK + indexer | 21 | 14 | 3 | 4 | 0 | 0 | 0 | 67% | +| 3 SDK + indexer | 21 | 16 | 1 | 4 | 0 | 0 | 0 | 76% | | 4 App | 27 | 15 | 7 | 5 | 0 | 0 | 0 | 56% | | 5 Testnet pilot | 7 | 0 | 0 | 7 | 0 | 0 | 0 | 0% | | 6 Pre-mainnet features | 10 | 0 | 0 | 10 | 0 | 0 | 0 | 0% | @@ -139,7 +139,7 @@ Readiness % = `DONE ÷ (all rows − DEFERRED − DROPPED)`. Conditional rows (P | 9 Conditional ramp | 9 | 0 | 0 | 0 | 0 | 9 | 0 | n/a | | 10 Wave + community | 10 | 5 | 1 | 4 | 0 | 0 | 0 | 50% | | 11 Deferred parking lot | 16 | 0 | 0 | 0 | 0 | 16 | 0 | n/a | -| **All** | **211** | **75** | **21** | **90** | **0** | **25** | **0** | **40%** | +| **All** | **211** | **77** | **19** | **90** | **0** | **25** | **0** | **41%** | --- @@ -275,11 +275,11 @@ Readiness % = `DONE ÷ (all rows − DEFERRED − DROPPED)`. Conditional rows (P | M2-07 | SDK tests, including a check that nothing derived from the fragment is logged | sdk | P0 | DONE | M2-03 | Test fails if fragment data reaches logger | | M2-08 | Indexer scaffold: Zod config, RPC client with multi-provider failover | sdk | P0 | DONE | M2-01 | Failover tested | | M2-09 | Indexer ingest: poller, persisted cursor, gap detection | sdk | P0 | DONE | M2-08 | Gaps stop ingestion and alert; never skipped silently | -| M2-10 | Indexer handlers per event type and `schema_version` | sdk | P0 | IN PROGRESS | M2-09, M1-02 | Mixed-version fixtures handled | +| M2-10 | Indexer handlers per event type and `schema_version` | sdk | P0 | DONE | M2-09, M1-02 | Mixed-version fixtures handled | | M2-11 | DB schema and forward-only Drizzle migrations | sdk | P0 | DONE | M2-01 | `chain_events`, `indexer_cursor`, `locks`, `tranches`, `payees` created | | M2-12 | List API: `/locks`, `/locks/:id`, `/payees`, `/health` with lag | sdk | P0 | DONE | M2-10, M2-11 | OpenAPI or typed docs; lag exposed | | M2-13 | Join payee display data from the registry repo | sdk | P0 | DONE | M1-25 | Payees show name, city, attester | -| M2-14 | Indexer tests: fixture replay, idempotency, cursor recovery, mixed versions | sdk | P0 | IN PROGRESS | M2-10 | Replay twice yields identical DB | +| M2-14 | Indexer tests: fixture replay, idempotency, cursor recovery, mixed versions | sdk | P0 | DONE | M2-10 | Replay twice yields identical DB | | M2-15 | Lag alerting and health metric | sdk | P1 | TODO | M2-12 | Alert fires in a simulated lag test | | M2-16 | Implement tier-3 verification and backfill per `M0-09` decision | sdk | P1 | TODO | M0-09, M2-06 | Old receipts verify; backfill fills a deliberate gap | | M2-17 | SDK docs and API reference; publish testnet package versions | sdk | P0 | DONE | M2-07 | Published; docs match exports | @@ -535,6 +535,7 @@ Newest first. One entry per PR. Required for every contribution (see §2). | Date | PR / ref | Repo | Rows touched | Summary | |---|---|---|---|---| +| 2026-10-17 | `test/mixed-schema-version-indexer` (#34) | sdk | DONE: M2-10, M2-14 | Mixed schema_version fixtures and stop-on-unknown test coverage: added forward-compatible v2 handler stubs (lockCreatedV2, releasedV2), synthesized mixed-versions fixture (15 events covering interleaved v1/v2 schema events on the same Lock 3 with tranches), verified stop-on-unknown (schema_version 0, 99) and missing schema_version batch rollback, proved idempotent replay across varying page sizes. 171/171 tests green. | | 2026-10-08 | `chore/roadmap-copy-back` (#39) | sdk | no row changes | Copy-back run: `ROADMAP.md` replaced with the merged canonical (`.github` main as of #31) in repo-root link form, and §3 recomputed with `scripts/roadmap-progress`. This is the step `roadmap-merge`'s header requires after every merge and that was skipped between 2026-10-06 and 2026-10-08, which is how the seven copies reached 92 divergent statuses. Verified in this checkout: the file equals canonical byte for byte apart from this entry and the ADR link prefix, `W-05` reads `DONE`, the seven Wave rows are present, duplicate row-ID scan is 0. No row, status, priority, dependency or **Done when** was authored in this repo. | | 2026-10-08 | `fix/roadmap-merge-link-anchor` | org | no row changes | Found while preparing the copy-back. `roadmap-merge`'s read normalization rewrote the docs-rooted ADR link form into the root-relative one on every copy so the two depths compare equal — correct for links, wrong for prose: the union entry above quotes both forms, so reading that line collapsed its own quote and emitted a Changelog entry contradicting itself, in a copy that no longer matched canonical line for line. Fixed by anchoring the replace on the numeric ADR filename that always follows the slash, so only real links are rewritten, and by re-wording the union entry to name the two forms as `docs/adr/` and `adr/` rather than spelling them as link text: **an entry that describes this rewrite must not itself be a string the rewrite can act on.** Verified: the anchored read leaves a quoted form byte-identical where the blanket replace corrupted it; a full `roadmap-merge` over all five copies against `b12d290` exits 0 with **0 lines** differing from canonical, which also confirms the union is complete; the one real link (`ADR-0026`) still normalizes both ways. No row, status, priority, dependency or **Done when** in this diff. | | 2026-10-08 | `chore/roadmap-changelog-union` | org | no row changes | Second `scripts/roadmap-merge` run now that the Wave-format PRs have merged (`.github` #26 and #27, contracts #31, sdk #38, app #43, registry #18). Three-way against `b12d290`, the revision where all five copies were last identical. The merge's **entire** output is four lines: the repo-side `docs/wave-issue-format` Changelog rows that each repo wrote into its own copy and canonical never saw. No row ID, status, priority, dependency or **Done when** moved — the seven new rows went in byte-identically, and `W-05`'s `TODO` → `DONE` stayed on the `org` row in canonical as §12 requires, so the repo copies' deliberately-untouched `TODO` was not merged back over it. §3 verified unchanged by `roadmap-progress`: 211 rows, 75 `DONE`, 40%. **Not in this PR:** the copy-back. Each repo's `ROADMAP.md` must be rewritten from the merged canonical, with the ADR links rewritten for the repo-root location, so all five copies are byte-identical again — that invariant is what makes `--ancestor` meaningful next time, and it needs four repo PRs that can only be cut after this lands. | diff --git a/services/indexer/src/handlers/index.ts b/services/indexer/src/handlers/index.ts index c3f2a01..6c0fc63 100644 --- a/services/indexer/src/handlers/index.ts +++ b/services/indexer/src/handlers/index.ts @@ -3,12 +3,12 @@ * Must accept every schema_version the contract has ever emitted; anything else stops ingestion. */ import { declinedV1 } from "./declined.js"; -import { lockCreatedV1 } from "./lock-created.js"; +import { lockCreatedV1, lockCreatedV2 } from "./lock-created.js"; import { payeeRegisteredV1 } from "./payee-registered.js"; import { payeeStatusChangedV1 } from "./payee-status-changed.js"; import { payoutUpdatedV1 } from "./payout-updated.js"; import { refundedV1 } from "./refunded.js"; -import { releasedV1 } from "./released.js"; +import { releasedV1, releasedV2 } from "./released.js"; import { type DecodedEvent, type EventHandler, @@ -20,8 +20,8 @@ const HANDLERS: Record> = { PayeeRegistered: { 1: payeeRegisteredV1 }, PayeeStatusChanged: { 1: payeeStatusChangedV1 }, PayoutUpdated: { 1: payoutUpdatedV1 }, - LockCreated: { 1: lockCreatedV1 }, - Released: { 1: releasedV1 }, + LockCreated: { 1: lockCreatedV1, 2: lockCreatedV2 }, + Released: { 1: releasedV1, 2: releasedV2 }, Refunded: { 1: refundedV1 }, Declined: { 1: declinedV1 }, }; diff --git a/services/indexer/src/handlers/lock-created.ts b/services/indexer/src/handlers/lock-created.ts index 238a6d4..d008772 100644 --- a/services/indexer/src/handlers/lock-created.ts +++ b/services/indexer/src/handlers/lock-created.ts @@ -34,3 +34,8 @@ export const lockCreatedV1: EventHandler = async (db, event, ctx) => { })), ); }; + +/** Forward-compatible hypothetical v2 shape for M2-10/M2-14 coverage. */ +export const lockCreatedV2: EventHandler = async (db, event, ctx) => { + return lockCreatedV1(db, event, ctx); +}; diff --git a/services/indexer/src/handlers/released.ts b/services/indexer/src/handlers/released.ts index c550a28..341f0ba 100644 --- a/services/indexer/src/handlers/released.ts +++ b/services/indexer/src/handlers/released.ts @@ -33,3 +33,8 @@ export const releasedV1: EventHandler = async (db, event) => { .set({ released, state: released === lock.total ? "Completed" : "Open" }) .where(eq(locks.id, id)); }; + +/** Forward-compatible hypothetical v2 shape for M2-10/M2-14 coverage. */ +export const releasedV2: EventHandler = async (db, event, ctx) => { + return releasedV1(db, event, ctx); +}; diff --git a/services/indexer/test/fixtures/events/mixed-versions.json b/services/indexer/test/fixtures/events/mixed-versions.json new file mode 100644 index 0000000..c7f2530 --- /dev/null +++ b/services/indexer/test/fixtures/events/mixed-versions.json @@ -0,0 +1,971 @@ +{ + "source": "Mixed schema_version test fixture for M2-10 and M2-14 (v1 and v2 events interleaved)", + "events": [ + { + "type": "contract", + "ledger": 5052340, + "ledgerClosedAt": "2026-10-06T11:14:47Z", + "contractId": "CCSHDQFRYFC3AHV5NE6ULQW6X2CMG5RPANBORDXJGSUD6UKECASJQBRI", + "id": "0021699635068321792-0000000000", + "operationIndex": 0, + "transactionIndex": 12, + "txHash": "e8138573de3cb82977c5a92e3e0f9ff3e00272de3b2fd3060b89c29b31eb8685", + "inSuccessfulContractCall": true, + "topicJson": [ + { + "symbol": "payee_registered" + }, + { + "bytes": "4485665bae55d2562f20ad82cec50c8aaa99ddf613b982509711a6b474ded0ba" + } + ], + "valueJson": { + "map": [ + { + "key": { + "symbol": "attester" + }, + "val": { + "address": "GCZRNEOGZH7FVJTA4OQN2YQ5LMS7LSV2GP7PWJFC73SMSLPA56DRAPQT" + } + }, + { + "key": { + "symbol": "category" + }, + "val": { + "vec": [ + { + "symbol": "School" + } + ] + } + }, + { + "key": { + "symbol": "meta_hash" + }, + "val": { + "bytes": "4c62af9d86c73d0a0f3733c76741ae1d1206e439daf7d1ee7fa0d4f167b8ed24" + } + }, + { + "key": { + "symbol": "payout" + }, + "val": { + "address": "GCLMHF7LAR34TSELDNEREYPTNE4K7MMESPS62WXE7ZDLPWIJUF3MC5QM" + } + }, + { + "key": { + "symbol": "schema_version" + }, + "val": { + "u32": 1 + } + } + ] + } + }, + { + "type": "contract", + "ledger": 5052342, + "ledgerClosedAt": "2026-10-06T11:14:57Z", + "contractId": "CCSHDQFRYFC3AHV5NE6ULQW6X2CMG5RPANBORDXJGSUD6UKECASJQBRI", + "id": "0021699643658272768-0000000000", + "operationIndex": 0, + "transactionIndex": 16, + "txHash": "0ecf41bf837252ffccde87951a54f8ac9c586360a397fc1bfd705054d18df45b", + "inSuccessfulContractCall": true, + "topicJson": [ + { + "symbol": "payee_registered" + }, + { + "bytes": "3e7236118c83eb3bd37927155be21cc90ada773aaca86380d28cea5f712be2f1" + } + ], + "valueJson": { + "map": [ + { + "key": { + "symbol": "attester" + }, + "val": { + "address": "GCZRNEOGZH7FVJTA4OQN2YQ5LMS7LSV2GP7PWJFC73SMSLPA56DRAPQT" + } + }, + { + "key": { + "symbol": "category" + }, + "val": { + "vec": [ + { + "symbol": "School" + } + ] + } + }, + { + "key": { + "symbol": "meta_hash" + }, + "val": { + "bytes": "b91bbf10a4b5ac34c0ff345817423cbf8ade78ecf9bf27fe50f2cc91ebc135b6" + } + }, + { + "key": { + "symbol": "payout" + }, + "val": { + "address": "GDPUY733T3UNZWG4EQMEBHBDGKD33YSFPOI2RB4AIK2JMOENKEXWK7ZG" + } + }, + { + "key": { + "symbol": "schema_version" + }, + "val": { + "u32": 1 + } + } + ] + } + }, + { + "type": "contract", + "ledger": 5052344, + "ledgerClosedAt": "2026-10-06T11:15:07Z", + "contractId": "CCSHDQFRYFC3AHV5NE6ULQW6X2CMG5RPANBORDXJGSUD6UKECASJQBRI", + "id": "0021699652248186880-0000000000", + "operationIndex": 0, + "transactionIndex": 11, + "txHash": "5627c5063e14686b2179a6fd03be82ed480f0d889c7a6d9f22fb64e7d1713aa9", + "inSuccessfulContractCall": true, + "topicJson": [ + { + "symbol": "payee_registered" + }, + { + "bytes": "a7815d079f7663968e7ce11c3765009a09c5daccb4a123a9102d3f7567d43866" + } + ], + "valueJson": { + "map": [ + { + "key": { + "symbol": "attester" + }, + "val": { + "address": "GCZRNEOGZH7FVJTA4OQN2YQ5LMS7LSV2GP7PWJFC73SMSLPA56DRAPQT" + } + }, + { + "key": { + "symbol": "category" + }, + "val": { + "vec": [ + { + "symbol": "Rent" + } + ] + } + }, + { + "key": { + "symbol": "meta_hash" + }, + "val": { + "bytes": "a996b3699f3a43336d4d5b8702adccbca7cc5025f47010dacccd9e8b458853aa" + } + }, + { + "key": { + "symbol": "payout" + }, + "val": { + "address": "GBXKOYQ4MJ6UJGQBFXVXOP7XL7SKKJTFTYJUSNREP5FW4IHXPMWY2WSX" + } + }, + { + "key": { + "symbol": "schema_version" + }, + "val": { + "u32": 1 + } + } + ] + } + }, + { + "type": "contract", + "ledger": 5055169, + "ledgerClosedAt": "2026-10-06T15:10:32Z", + "contractId": "CCSHDQFRYFC3AHV5NE6ULQW6X2CMG5RPANBORDXJGSUD6UKECASJQBRI", + "id": "0021711785530793984-0000000001", + "operationIndex": 0, + "transactionIndex": 10, + "txHash": "62cb000d274f1570f6eb9a4fe33a39bc8d2ac46ba6d80b5e6ebb89977de032c0", + "inSuccessfulContractCall": true, + "topicJson": [ + { + "symbol": "lock_created" + }, + { + "u64": "1" + } + ], + "valueJson": { + "map": [ + { + "key": { + "symbol": "expires_at" + }, + "val": { + "u64": "1791306618" + } + }, + { + "key": { + "symbol": "payee_id" + }, + "val": { + "bytes": "4485665bae55d2562f20ad82cec50c8aaa99ddf613b982509711a6b474ded0ba" + } + }, + { + "key": { + "symbol": "payout" + }, + "val": { + "address": "GCLMHF7LAR34TSELDNEREYPTNE4K7MMESPS62WXE7ZDLPWIJUF3MC5QM" + } + }, + { + "key": { + "symbol": "ref_hash" + }, + "val": { + "bytes": "dee2634ab9acdbfff6fe5c0595fcda66c0897d1c6968c290745da4f384e3ee5e" + } + }, + { + "key": { + "symbol": "schema_version" + }, + "val": { + "u32": 1 + } + }, + { + "key": { + "symbol": "sender" + }, + "val": { + "address": "GBBUI4N57S3TBUZTUKQPKWY5DERT2ZHQN4LLQZ747W7CD5HU3FGHWDH3" + } + }, + { + "key": { + "symbol": "token" + }, + "val": { + "address": "CBIELTK6YBZJU5UP2WWQEUCYKLPU6AUNZ2BQ4WWFEIE3USCIHMXQDAMA" + } + }, + { + "key": { + "symbol": "total" + }, + "val": { + "i128": "100000000" + } + }, + { + "key": { + "symbol": "tranche_count" + }, + "val": { + "u32": 2 + } + } + ] + } + }, + { + "type": "contract", + "ledger": 5055171, + "ledgerClosedAt": "2026-10-06T15:10:42Z", + "contractId": "CCSHDQFRYFC3AHV5NE6ULQW6X2CMG5RPANBORDXJGSUD6UKECASJQBRI", + "id": "0021711794120744960-0000000001", + "operationIndex": 0, + "transactionIndex": 14, + "txHash": "7aa6c2a48f52072e8db94c9211f9d4ad5dbd1107eb792140194dc2c2ecc8d3bc", + "inSuccessfulContractCall": true, + "topicJson": [ + { + "symbol": "released" + }, + { + "u64": "1" + } + ], + "valueJson": { + "map": [ + { + "key": { + "symbol": "amount" + }, + "val": { + "i128": "50000000" + } + }, + { + "key": { + "symbol": "idx" + }, + "val": { + "u32": 0 + } + }, + { + "key": { + "symbol": "payout" + }, + "val": { + "address": "GCLMHF7LAR34TSELDNEREYPTNE4K7MMESPS62WXE7ZDLPWIJUF3MC5QM" + } + }, + { + "key": { + "symbol": "schema_version" + }, + "val": { + "u32": 1 + } + } + ] + } + }, + { + "type": "contract", + "ledger": 5055174, + "ledgerClosedAt": "2026-10-06T15:10:57Z", + "contractId": "CCSHDQFRYFC3AHV5NE6ULQW6X2CMG5RPANBORDXJGSUD6UKECASJQBRI", + "id": "0021711807005634560-0000000001", + "operationIndex": 0, + "transactionIndex": 11, + "txHash": "ef7eb7ca23d996b789ace7ca8448002666bdde25d53d33a3fe6b3fe602b658ae", + "inSuccessfulContractCall": true, + "topicJson": [ + { + "symbol": "declined" + }, + { + "u64": "1" + } + ], + "valueJson": { + "map": [ + { + "key": { + "symbol": "amount" + }, + "val": { + "i128": "50000000" + } + }, + { + "key": { + "symbol": "schema_version" + }, + "val": { + "u32": 1 + } + } + ] + } + }, + { + "type": "contract", + "ledger": 5055185, + "ledgerClosedAt": "2026-10-06T15:11:52Z", + "contractId": "CCSHDQFRYFC3AHV5NE6ULQW6X2CMG5RPANBORDXJGSUD6UKECASJQBRI", + "id": "0021711854250262528-0000000001", + "operationIndex": 0, + "transactionIndex": 8, + "txHash": "82c270d9311cb6b0084e379765f780a78cab098d4748429fd4dbaea8459b5491", + "inSuccessfulContractCall": true, + "topicJson": [ + { + "symbol": "lock_created" + }, + { + "u64": "2" + } + ], + "valueJson": { + "map": [ + { + "key": { + "symbol": "expires_at" + }, + "val": { + "u64": "1791303213" + } + }, + { + "key": { + "symbol": "payee_id" + }, + "val": { + "bytes": "a7815d079f7663968e7ce11c3765009a09c5daccb4a123a9102d3f7567d43866" + } + }, + { + "key": { + "symbol": "payout" + }, + "val": { + "address": "GBXKOYQ4MJ6UJGQBFXVXOP7XL7SKKJTFTYJUSNREP5FW4IHXPMWY2WSX" + } + }, + { + "key": { + "symbol": "ref_hash" + }, + "val": { + "bytes": "f402dfbd8e5f17bc2798772a85f0fc1989ce3fd86ebe185e83e83e9b613df9fe" + } + }, + { + "key": { + "symbol": "schema_version" + }, + "val": { + "u32": 1 + } + }, + { + "key": { + "symbol": "sender" + }, + "val": { + "address": "GBBUI4N57S3TBUZTUKQPKWY5DERT2ZHQN4LLQZ747W7CD5HU3FGHWDH3" + } + }, + { + "key": { + "symbol": "token" + }, + "val": { + "address": "CBIELTK6YBZJU5UP2WWQEUCYKLPU6AUNZ2BQ4WWFEIE3USCIHMXQDAMA" + } + }, + { + "key": { + "symbol": "total" + }, + "val": { + "i128": "30000000" + } + }, + { + "key": { + "symbol": "tranche_count" + }, + "val": { + "u32": 1 + } + } + ] + } + }, + { + "type": "contract", + "ledger": 5055190, + "ledgerClosedAt": "2026-10-06T15:12:17Z", + "contractId": "CCSHDQFRYFC3AHV5NE6ULQW6X2CMG5RPANBORDXJGSUD6UKECASJQBRI", + "id": "0021711875725123584-0000000000", + "operationIndex": 0, + "transactionIndex": 14, + "txHash": "08572f578e018e08ca6da1a7fbb4bf6dd4a3f6ac58ca8366b04dfc2a21fa7e5b", + "inSuccessfulContractCall": true, + "topicJson": [ + { + "symbol": "payee_status_changed" + }, + { + "bytes": "3e7236118c83eb3bd37927155be21cc90ada773aaca86380d28cea5f712be2f1" + } + ], + "valueJson": { + "map": [ + { + "key": { + "symbol": "changed_by" + }, + "val": { + "address": "GCZRNEOGZH7FVJTA4OQN2YQ5LMS7LSV2GP7PWJFC73SMSLPA56DRAPQT" + } + }, + { + "key": { + "symbol": "schema_version" + }, + "val": { + "u32": 1 + } + }, + { + "key": { + "symbol": "status" + }, + "val": { + "vec": [ + { + "symbol": "Suspended" + } + ] + } + } + ] + } + }, + { + "type": "contract", + "ledger": 5055193, + "ledgerClosedAt": "2026-10-06T15:12:32Z", + "contractId": "CCSHDQFRYFC3AHV5NE6ULQW6X2CMG5RPANBORDXJGSUD6UKECASJQBRI", + "id": "0021711888610004992-0000000000", + "operationIndex": 0, + "transactionIndex": 9, + "txHash": "41f5a3a1c4218677b57c9204b517dbc6e60ec4e82784abc0ac2db44b12129d99", + "inSuccessfulContractCall": true, + "topicJson": [ + { + "symbol": "payee_status_changed" + }, + { + "bytes": "3e7236118c83eb3bd37927155be21cc90ada773aaca86380d28cea5f712be2f1" + } + ], + "valueJson": { + "map": [ + { + "key": { + "symbol": "changed_by" + }, + "val": { + "address": "GCZRNEOGZH7FVJTA4OQN2YQ5LMS7LSV2GP7PWJFC73SMSLPA56DRAPQT" + } + }, + { + "key": { + "symbol": "schema_version" + }, + "val": { + "u32": 1 + } + }, + { + "key": { + "symbol": "status" + }, + "val": { + "vec": [ + { + "symbol": "Active" + } + ] + } + } + ] + } + }, + { + "type": "contract", + "ledger": 5055195, + "ledgerClosedAt": "2026-10-06T15:12:42Z", + "contractId": "CCSHDQFRYFC3AHV5NE6ULQW6X2CMG5RPANBORDXJGSUD6UKECASJQBRI", + "id": "0021711897199935488-0000000000", + "operationIndex": 0, + "transactionIndex": 8, + "txHash": "9573b03a513fdb6075d077d4129f1a7b3ed0e1d21fffc75d8c0995d859c3b0a1", + "inSuccessfulContractCall": true, + "topicJson": [ + { + "symbol": "payout_updated" + }, + { + "bytes": "3e7236118c83eb3bd37927155be21cc90ada773aaca86380d28cea5f712be2f1" + } + ], + "valueJson": { + "map": [ + { + "key": { + "symbol": "new_payout" + }, + "val": { + "address": "GDURV6L3ZEUGNV4OFIHSBGQJAUPMDGZJMIH2WGPEM4WHOLNJYCOEPBRU" + } + }, + { + "key": { + "symbol": "schema_version" + }, + "val": { + "u32": 1 + } + } + ] + } + }, + { + "type": "contract", + "ledger": 5055200, + "ledgerClosedAt": "2026-10-06T15:13:07Z", + "contractId": "CCSHDQFRYFC3AHV5NE6ULQW6X2CMG5RPANBORDXJGSUD6UKECASJQBRI", + "id": "0021711918674767872-0000000000", + "operationIndex": 0, + "transactionIndex": 7, + "txHash": "0961e2e178c27acf15b2bbdb986e20a008d1e7ce86a444cb85b2aea50bd69e93", + "inSuccessfulContractCall": true, + "topicJson": [ + { + "symbol": "payout_updated" + }, + { + "bytes": "3e7236118c83eb3bd37927155be21cc90ada773aaca86380d28cea5f712be2f1" + } + ], + "valueJson": { + "map": [ + { + "key": { + "symbol": "new_payout" + }, + "val": { + "address": "GDPUY733T3UNZWG4EQMEBHBDGKD33YSFPOI2RB4AIK2JMOENKEXWK7ZG" + } + }, + { + "key": { + "symbol": "schema_version" + }, + "val": { + "u32": 1 + } + } + ] + } + }, + { + "type": "contract", + "ledger": 5067048, + "ledgerClosedAt": "2026-10-07T07:40:27Z", + "contractId": "CCSHDQFRYFC3AHV5NE6ULQW6X2CMG5RPANBORDXJGSUD6UKECASJQBRI", + "id": "0021762805447303168-0000000001", + "operationIndex": 0, + "transactionIndex": 10, + "txHash": "7316fe69be62f697df4676aee00318c97e8380faf9bc14cd6286344a70aaa4b0", + "inSuccessfulContractCall": true, + "topicJson": [ + { + "symbol": "refunded" + }, + { + "u64": "2" + } + ], + "valueJson": { + "map": [ + { + "key": { + "symbol": "amount" + }, + "val": { + "i128": "30000000" + } + }, + { + "key": { + "symbol": "reason" + }, + "val": { + "vec": [ + { + "symbol": "Expired" + } + ] + } + }, + { + "key": { + "symbol": "schema_version" + }, + "val": { + "u32": 1 + } + } + ] + } + }, + { + "type": "contract", + "ledger": 5056000, + "ledgerClosedAt": "2026-10-06T16:00:00Z", + "contractId": "CCSHDQFRYFC3AHV5NE6ULQW6X2CMG5RPANBORDXJGSUD6UKECASJQBRI", + "id": "0021715000000000000-0000000001", + "operationIndex": 0, + "transactionIndex": 1, + "txHash": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "inSuccessfulContractCall": true, + "topicJson": [ + { + "symbol": "lock_created" + }, + { + "u64": "3" + } + ], + "valueJson": { + "map": [ + { + "key": { + "symbol": "expires_at" + }, + "val": { + "u64": "1791350000" + } + }, + { + "key": { + "symbol": "payee_id" + }, + "val": { + "bytes": "4485665bae55d2562f20ad82cec50c8aaa99ddf613b982509711a6b474ded0ba" + } + }, + { + "key": { + "symbol": "payout" + }, + "val": { + "address": "GCLMHF7LAR34TSELDNEREYPTNE4K7MMESPS62WXE7ZDLPWIJUF3MC5QM" + } + }, + { + "key": { + "symbol": "ref_hash" + }, + "val": { + "bytes": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb" + } + }, + { + "key": { + "symbol": "schema_version" + }, + "val": { + "u32": 2 + } + }, + { + "key": { + "symbol": "sender" + }, + "val": { + "address": "GBBUI4N57S3TBUZTUKQPKWY5DERT2ZHQN4LLQZ747W7CD5HU3FGHWDH3" + } + }, + { + "key": { + "symbol": "token" + }, + "val": { + "address": "CBIELTK6YBZJU5UP2WWQEUCYKLPU6AUNZ2BQ4WWFEIE3USCIHMXQDAMA" + } + }, + { + "key": { + "symbol": "total" + }, + "val": { + "i128": "80000000" + } + }, + { + "key": { + "symbol": "tranche_count" + }, + "val": { + "u32": 2 + } + }, + { + "key": { + "symbol": "memo" + }, + "val": { + "string": "forward-compatible test memo" + } + } + ] + } + }, + { + "type": "contract", + "ledger": 5056100, + "ledgerClosedAt": "2026-10-06T16:05:00Z", + "contractId": "CCSHDQFRYFC3AHV5NE6ULQW6X2CMG5RPANBORDXJGSUD6UKECASJQBRI", + "id": "0021715100000000000-0000000001", + "operationIndex": 0, + "transactionIndex": 2, + "txHash": "cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc", + "inSuccessfulContractCall": true, + "topicJson": [ + { + "symbol": "released" + }, + { + "u64": "3" + } + ], + "valueJson": { + "map": [ + { + "key": { + "symbol": "amount" + }, + "val": { + "i128": "30000000" + } + }, + { + "key": { + "symbol": "idx" + }, + "val": { + "u32": 0 + } + }, + { + "key": { + "symbol": "payout" + }, + "val": { + "address": "GCLMHF7LAR34TSELDNEREYPTNE4K7MMESPS62WXE7ZDLPWIJUF3MC5QM" + } + }, + { + "key": { + "symbol": "schema_version" + }, + "val": { + "u32": 2 + } + }, + { + "key": { + "symbol": "note" + }, + "val": { + "string": "batch tranche 0 payout" + } + } + ] + } + }, + { + "type": "contract", + "ledger": 5056200, + "ledgerClosedAt": "2026-10-06T16:10:00Z", + "contractId": "CCSHDQFRYFC3AHV5NE6ULQW6X2CMG5RPANBORDXJGSUD6UKECASJQBRI", + "id": "0021715200000000000-0000000001", + "operationIndex": 0, + "transactionIndex": 3, + "txHash": "dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd", + "inSuccessfulContractCall": true, + "topicJson": [ + { + "symbol": "released" + }, + { + "u64": "3" + } + ], + "valueJson": { + "map": [ + { + "key": { + "symbol": "amount" + }, + "val": { + "i128": "50000000" + } + }, + { + "key": { + "symbol": "idx" + }, + "val": { + "u32": 1 + } + }, + { + "key": { + "symbol": "payout" + }, + "val": { + "address": "GCLMHF7LAR34TSELDNEREYPTNE4K7MMESPS62WXE7ZDLPWIJUF3MC5QM" + } + }, + { + "key": { + "symbol": "schema_version" + }, + "val": { + "u32": 1 + } + } + ] + } + } + ], + "tranches": { + "1": [ + { + "amount": "50000000", + "unlock_at": 1791299418 + }, + { + "amount": "50000000", + "unlock_at": 1791299418 + } + ], + "2": [ + { + "amount": "30000000", + "unlock_at": 1791303093 + } + ], + "3": [ + { + "amount": "30000000", + "unlock_at": 1791300000 + }, + { + "amount": "50000000", + "unlock_at": 1791310000 + } + ] + }, + "tranchesSource": "simulated get_lock tranches for locks 1, 2, 3" +} diff --git a/services/indexer/test/helpers/fixtures.ts b/services/indexer/test/helpers/fixtures.ts index 3fbbdf4..f563509 100644 --- a/services/indexer/test/helpers/fixtures.ts +++ b/services/indexer/test/helpers/fixtures.ts @@ -19,6 +19,9 @@ interface Fixture { export const fixture: Fixture = JSON.parse( readFileSync(new URL("../fixtures/events/testnet-2026-10-06.json", import.meta.url), "utf8"), ); +export const mixedFixture: Fixture = JSON.parse( + readFileSync(new URL("../fixtures/events/mixed-versions.json", import.meta.url), "utf8"), +); export const CONTRACT_ID = fixture.events[0]?.contractId ?? ""; export const START_LEDGER = 5052300; /** Copy of kinlock-registry/fixtures (payees and attesters) as registered on testnet. */ @@ -88,6 +91,19 @@ export function fixtureLocks(): LockReader & { reads: bigint[] } { }; } +export function mixedFixtureLocks(): LockReader & { reads: bigint[] } { + const reads: bigint[] = []; + return { + reads, + async tranches(lockId): Promise { + reads.push(lockId); + const t = mixedFixture.tranches[lockId.toString()]; + if (!t) throw new Error(`no fixture tranches for lock ${lockId}`); + return t.map((x) => ({ amount: BigInt(x.amount), unlockAt: BigInt(x.unlock_at) })); + }, + }; +} + /** Every table, ordered, for whole-database comparisons. */ export async function snapshot(db: Db) { const { chainEvents, locks, payees, tranches } = schema; diff --git a/services/indexer/test/ingest.test.ts b/services/indexer/test/ingest.test.ts index 655fb5d..b97e0f7 100644 --- a/services/indexer/test/ingest.test.ts +++ b/services/indexer/test/ingest.test.ts @@ -12,6 +12,8 @@ import { fixture, fixtureLocks, freshDb, + mixedFixture, + mixedFixtureLocks, START_LEDGER, snapshot, } from "./helpers/fixtures.js"; @@ -31,6 +33,21 @@ function deps(db: Db, rpc: RpcClient = fakeRpc(fixture.events), pageSize = 3): I }; } +function mixedDeps( + db: Db, + rpc: RpcClient = fakeRpc(mixedFixture.events), + pageSize = 3, +): IngestDeps { + return { + db, + rpc, + locks: mixedFixtureLocks(), + contractId: CONTRACT_ID, + startLedger: START_LEDGER, + pageSize, + }; +} + /** Run batches until a page comes back short (caught up). */ async function drain(d: IngestDeps): Promise { let batches = 0; @@ -50,11 +67,16 @@ const badRefund = (): RpcEvent => /** The database after one clean replay of all fixture events, for comparisons. */ let reference: Awaited>; +let mixedReference: Awaited>; beforeAll(async () => { const { db } = await freshDb(); await drain(deps(db)); reference = await snapshot(db); + + const { db: mDb } = await freshDb(); + await drain(mixedDeps(mDb)); + mixedReference = await snapshot(mDb); }, 60_000); describe("ingest: replaying the recorded testnet events", () => { @@ -216,3 +238,125 @@ describe("ingest: stops instead of skipping", () => { expect(await db.select().from(indexerCursor)).toHaveLength(0); }); }); + +describe("ingest: mixed schema_versions (v1 + v2) and stop-on-unknown (M2-10, M2-14)", () => { + it("processes a mixed v1/v2 event stream with interleaved versions on the same lock", async () => { + const { db } = await freshDb(); + await drain(mixedDeps(db)); + + // Lock 1: v1 lock_created + v1 released tranche 0 + v1 declined remainder -> state Declined + const [l1] = await db.select().from(locks).where(eq(locks.id, 1n)); + expect(l1?.state).toBe("Declined"); + expect(l1?.total).toBe(100000000n); + expect(l1?.released).toBe(50000000n); + expect(l1?.returned).toBe(50000000n); + + // Lock 2: v1 lock_created + v1 refunded -> state Refunded + const [l2] = await db.select().from(locks).where(eq(locks.id, 2n)); + expect(l2?.state).toBe("Refunded"); + expect(l2?.total).toBe(30000000n); + expect(l2?.returned).toBe(30000000n); + + // Lock 3: created via v2, released tranche 0 via v2, released tranche 1 via v1 -> state Completed! + const [l3] = await db.select().from(locks).where(eq(locks.id, 3n)); + expect(l3?.state).toBe("Completed"); + expect(l3?.total).toBe(80000000n); + expect(l3?.released).toBe(80000000n); + expect(l3?.returned).toBe(0n); + + // Verify tranches for lock 3 are both marked released without double counting + const t3 = await db + .select() + .from(tranches) + .where(eq(tranches.lockId, 3n)) + .orderBy(tranches.idx); + expect(t3).toHaveLength(2); + expect(t3.map((x) => [x.idx, x.amount, x.released])).toEqual([ + [0, 30000000n, true], + [1, 50000000n, true], + ]); + + // Verify chain_events record true schema_versions for both v1 and v2 events + const evs = await db + .select() + .from(chainEvents) + .orderBy(chainEvents.ledger, chainEvents.eventIndex); + expect(evs).toHaveLength(mixedFixture.events.length); + const v2Evs = evs.filter((e) => e.schemaVersion === 2); + expect(v2Evs.length).toBeGreaterThanOrEqual(2); + const v1Evs = evs.filter((e) => e.schemaVersion === 1); + expect(v1Evs.length).toBeGreaterThanOrEqual(2); + }); + + it("gives an identical database when mixed stream is replayed twice from start", async () => { + const { db } = await freshDb(); + await drain(mixedDeps(db)); + await db.delete(indexerCursor); // force replay over already-stored events + await drain(mixedDeps(db)); + expect(await snapshot(db)).toEqual(mixedReference); + }); + + it("gives the same database whatever the page size for mixed stream", async () => { + for (const size of [1, 4, 100]) { + const { db } = await freshDb(); + await drain(mixedDeps(db, fakeRpc(mixedFixture.events), size)); + expect(await snapshot(db)).toEqual(mixedReference); + } + }); + + it("stops on schema_version 0 and unknown schema_version 99 without advancing cursor or writing dirty events", async () => { + for (const badVer of [0, 99]) { + const { db } = await freshDb(); + // Corrupt event 0 schema_version to badVer + const corrupted = mixedFixture.events.map((e, i) => + i === 0 + ? { + ...e, + valueJson: JSON.parse( + JSON.stringify(e.valueJson).replace('{"u32":1}', `{"u32":${badVer}}`), + ), + } + : e, + ); + await expect(ingestBatch(mixedDeps(db, fakeRpc(corrupted)))).rejects.toThrow( + UnsupportedEventError, + ); + expect(await db.select().from(chainEvents)).toHaveLength(0); + expect(await db.select().from(indexerCursor)).toHaveLength(0); + } + }); + + it("stops on unknown v2 handler (e.g. unknown v99 on lock_created) and aborts batch", async () => { + const { db } = await freshDb(); + // Corrupt event 12 (lock_created v2) schema_version to 99 + const corrupted = mixedFixture.events.map((e, i) => + i === 12 + ? { + ...e, + valueJson: JSON.parse(JSON.stringify(e.valueJson).replace('{"u32":2}', '{"u32":99}')), + } + : e, + ); + // Page size 100 so all events are attempted in single transaction + await expect(ingestBatch(mixedDeps(db, fakeRpc(corrupted), 100))).rejects.toThrow( + UnsupportedEventError, + ); + expect(await db.select().from(chainEvents)).toHaveLength(0); + expect(await db.select().from(indexerCursor)).toHaveLength(0); + }); + + it("stops on missing schema_version and rolls back batch (DecodeError)", async () => { + const { db } = await freshDb(); + const missingVer = mixedFixture.events.map((e, i) => { + if (i !== 0) return e; + const parsed = JSON.parse(JSON.stringify(e)); + parsed.valueJson.map = parsed.valueJson.map.filter( + (entry: { key: { symbol?: string } }) => entry.key?.symbol !== "schema_version", + ); + return parsed as RpcEvent; + }); + await expect(ingestBatch(mixedDeps(db, fakeRpc(missingVer)))).rejects.toThrow(DecodeError); + expect(await db.select().from(chainEvents)).toHaveLength(0); + expect(await db.select().from(indexerCursor)).toHaveLength(0); + }); +});