Contributor coordination and questions: https://t.me/+DOylgFv1jyJlNzM0
Task Classification: Reliability and data-integrity defect (event ingestion recovery); not security-relevant
Affected Layers: backend (primary), frontend, contracts
Affected Paths: backend/src/services/eventIndexer.ts, backend/migrations/*, backend/src/services/defaultChecker.ts (consumer), frontend TanStack Query keys and SSE consumer, contracts/* event publish sites
Severity: High
Estimated Window: 48-72 hours
Technical Context & Monorepo Integration Failure
backend/src/services/eventIndexer.ts calls Soroban RPC getEvents with a startLedger and a filter, then advances a single opaque cursor (the RPC pagingToken) across pages until latestLedger is reached. The cursor is treated as sufficient durable state. Two failure classes are undetected:
- Gap: a ledger range between the last confirmed-scanned ledger and the first event of the next page is never verified as scanned. On a crash-restart, if
startLedger is derived from a stale in-memory value or clamped to the RPC retention floor, the indexer resumes past a range it never read. Empty ledgers legitimately produce no events, so a jump in event ledger sequence alone cannot distinguish "empty" from "skipped."
- Rollback: Stellar reaches finality through SCP and does not fork like proof-of-work chains, but the RPC source can still present a rolled-back view. Captive-core catchup, a lagging replica swapped for a fresher one, or a node re-sync can surface different event content for ledgers already ingested. The current code re-applies whatever a page returns with no comparison against prior state.
The failure spans layers because a single-layer fix is incomplete. The backend cannot reconcile a suspect range unless each event carries stable identity (ledger_seq, tx_hash, event_index); that identity originates in the contract event envelope and must be preserved through ingestion. The frontend holds derived state (loan status, pool balances) keyed in TanStack Query; after a backend resync rewrites persistent rows, cached client state is stale until the affected keys are invalidated. A backend-only patch leaves the UI serving pre-rollback values over SSE with no re-pull signal.
Concrete drift walkthrough:
- Indexer confirms ledgers up to
1000; ledger_checkpoints.last_contiguous_ledger = 1000.
- The backend process restarts.
startLedger is computed from an unpersisted counter and clamped to the RPC retention floor 1040.
getEvents returns events from ledger 1040 onward; the cursor advances normally.
- Ledgers
1001..1039 are never scanned. A LoanDefaulted event emitted at ledger 1022 is missed.
defaultChecker.ts never observes the default; no NFT claim path fires; the frontend shows the loan as Active indefinitely.
- No error is raised because the cursor is monotonic and
latestLedger is satisfied.
Core Component Invariants & Code Paths
Smart Contract Infrastructure
- Crates
loan_manager, lending_pool, multisig_governance, remittance_nft publish via env.events().publish. Add an application-level monotonic identifier to each event topic tuple (for example a per-contract event_seq: u64 bumped in instance storage, alongside the domain topic such as ("loan","defaulted", loan_id)).
- Ledger sequence and transaction hash are supplied by the RPC envelope (
event.ledger, event.txHash, and the composite event.id encoding ledger/tx/op/event index); the contract does not set them. The added event_seq gives the backend an ordering key that is stable across a re-read of the same ledger.
- Invariant: every emitted event is uniquely addressable by
(ledger_seq, tx_hash, event_index) and carries a contract-local event_seq. Typed errors (LoanError, PoolError, GovernanceError, NftError) are unaffected; no state-transition logic changes. Persistent and instance TTLs remain unchanged.
Backend/API Layer
- New table via
backend/migrations (migrate:create), name ledger_checkpoints: columns id, last_contiguous_ledger BIGINT, cursor TEXT, range_start BIGINT, range_end BIGINT, range_digest TEXT, status TEXT (verified|suspect|backfilling), updated_at TIMESTAMPTZ. range_digest is a hash over the ordered (ledger_seq, tx_hash, event_index) set for a checkpointed window.
indexed_events table gains a UNIQUE (ledger_seq, tx_hash, event_index) constraint to make re-application idempotent under INSERT ... ON CONFLICT DO NOTHING (or DO UPDATE when payload reconciliation is required).
eventIndexer.ts refactor and invariants:
ingestPage(page) writes events and, per closed window, records a checkpoint. The contiguous-cursor invariant: last_contiguous_ledger only advances when every ledger from last_contiguous_ledger + 1 through the page boundary is confirmed scanned against latestLedger, not merely when the cursor moves.
detectGap(page) flags status='suspect' and records range_start/range_end when the confirmed scan floor exceeds last_contiguous_ledger + 1.
detectReorg(range) re-reads a bounded window, recomputes range_digest, and compares against the stored digest; a mismatch marks the range suspect.
backfillRange(start, end) performs a bounded re-read (capped window, refuse ranges below RPC retention and emit a typed IndexerError), re-applies idempotently, recomputes the digest, and advances last_contiguous_ledger only on a match.
- On any resync, emit an SSE
resync frame identifying affected contract ids and ledger range for downstream consumers.
defaultChecker.ts reads only committed, reconciled rows; it must not act on status='suspect' windows until backfill completes.
Frontend Client
- The SSE consumer handles the new
resync frame. On receipt, call queryClient.invalidateQueries for affected keys (['loans', ...], ['pool', poolId], ['events', contractId]) scoped by the reported contract ids, forcing a re-pull rather than trusting cached derived state.
- Invariant: after a
resync frame, no rendered view reflects pre-rollback data; Playwright coverage asserts the re-pull. Public config unchanged (NEXT_PUBLIC_*).
Root Configuration & Orchestration
- No root workspace exists; changes remain per-layer. Extend
.github/workflows/ci.yml: the migration-check job (service postgres:16) must run the new migration up and down with no residual objects; the backend job runs the reorg/gap integration suite; the frontend job runs the resync Playwright spec.
docker-compose.yml gains a fixture RPC stub (or recorded fixtures) so integration tests can inject a gap and a rollback deterministically.
Verification & Acceptance Criteria
Suggested Execution Path
- Phase 1: Contract event identity (6-10h). Add
event_seq to publish sites across the four crates; extend contract tests. Deliverable: updated events plus tests. Exit check: cargo test green; identity fields present in emitted topics.
- Phase 2: Checkpoint schema (8-12h). Author the
ledger_checkpoints migration and the indexed_events unique constraint. Deliverable: reversible migration. Exit check: migrate:up/migrate:down succeed against postgres:16 with no residual objects.
- Phase 3: Indexer detection and backfill (14-20h). Implement the contiguous-cursor invariant,
detectGap, detectReorg, backfillRange, digesting, idempotent re-apply, and the SSE resync emit; gate defaultChecker.ts on non-suspect windows. Deliverable: refactored eventIndexer.ts with unit and integration tests. Exit check: injected gap and rollback both reconcile deterministically.
- Phase 4: Frontend resync (8-12h). Handle the
resync frame; invalidate scoped query keys; add Playwright coverage. Deliverable: updated SSE consumer and spec. Exit check: e2e proves re-pull after resync.
- Phase 5: Root CI and container integration (12-18h). Wire the fixture RPC into
docker-compose.yml; extend ci.yml jobs; assemble PR state dumps. Deliverable: passing CI across backend, migration-check, frontend. Exit check: all three jobs green with attachments included.
Contributor coordination and questions: https://t.me/+DOylgFv1jyJlNzM0
Task Classification: Reliability and data-integrity defect (event ingestion recovery); not security-relevant
Affected Layers: backend (primary), frontend, contracts
Affected Paths:
backend/src/services/eventIndexer.ts,backend/migrations/*,backend/src/services/defaultChecker.ts(consumer),frontendTanStack Query keys and SSE consumer,contracts/*eventpublishsitesSeverity: High
Estimated Window: 48-72 hours
Technical Context & Monorepo Integration Failure
backend/src/services/eventIndexer.tscalls Soroban RPCgetEventswith astartLedgerand a filter, then advances a single opaquecursor(the RPCpagingToken) across pages untillatestLedgeris reached. The cursor is treated as sufficient durable state. Two failure classes are undetected:startLedgeris derived from a stale in-memory value or clamped to the RPC retention floor, the indexer resumes past a range it never read. Empty ledgers legitimately produce no events, so a jump in eventledgersequence alone cannot distinguish "empty" from "skipped."The failure spans layers because a single-layer fix is incomplete. The backend cannot reconcile a suspect range unless each event carries stable identity (
ledger_seq,tx_hash,event_index); that identity originates in the contract event envelope and must be preserved through ingestion. The frontend holds derived state (loan status, pool balances) keyed in TanStack Query; after a backend resync rewrites persistent rows, cached client state is stale until the affected keys are invalidated. A backend-only patch leaves the UI serving pre-rollback values over SSE with no re-pull signal.Concrete drift walkthrough:
1000;ledger_checkpoints.last_contiguous_ledger = 1000.startLedgeris computed from an unpersisted counter and clamped to the RPC retention floor1040.getEventsreturns events from ledger1040onward; the cursor advances normally.1001..1039are never scanned. ALoanDefaultedevent emitted at ledger1022is missed.defaultChecker.tsnever observes the default; no NFT claim path fires; the frontend shows the loan asActiveindefinitely.latestLedgeris satisfied.Core Component Invariants & Code Paths
Smart Contract Infrastructure
loan_manager,lending_pool,multisig_governance,remittance_nftpublish viaenv.events().publish. Add an application-level monotonic identifier to each event topic tuple (for example a per-contractevent_seq: u64bumped in instance storage, alongside the domain topic such as("loan","defaulted", loan_id)).event.ledger,event.txHash, and the compositeevent.idencoding ledger/tx/op/event index); the contract does not set them. The addedevent_seqgives the backend an ordering key that is stable across a re-read of the same ledger.(ledger_seq, tx_hash, event_index)and carries a contract-localevent_seq. Typed errors (LoanError,PoolError,GovernanceError,NftError) are unaffected; no state-transition logic changes. Persistent and instance TTLs remain unchanged.Backend/API Layer
backend/migrations(migrate:create), nameledger_checkpoints: columnsid,last_contiguous_ledger BIGINT,cursor TEXT,range_start BIGINT,range_end BIGINT,range_digest TEXT,status TEXT(verified|suspect|backfilling),updated_at TIMESTAMPTZ.range_digestis a hash over the ordered(ledger_seq, tx_hash, event_index)set for a checkpointed window.indexed_eventstable gains aUNIQUE (ledger_seq, tx_hash, event_index)constraint to make re-application idempotent underINSERT ... ON CONFLICT DO NOTHING(orDO UPDATEwhen payload reconciliation is required).eventIndexer.tsrefactor and invariants:ingestPage(page)writes events and, per closed window, records a checkpoint. The contiguous-cursor invariant:last_contiguous_ledgeronly advances when every ledger fromlast_contiguous_ledger + 1through the page boundary is confirmed scanned againstlatestLedger, not merely when the cursor moves.detectGap(page)flagsstatus='suspect'and recordsrange_start/range_endwhen the confirmed scan floor exceedslast_contiguous_ledger + 1.detectReorg(range)re-reads a bounded window, recomputesrange_digest, and compares against the stored digest; a mismatch marks the rangesuspect.backfillRange(start, end)performs a bounded re-read (capped window, refuse ranges below RPC retention and emit a typedIndexerError), re-applies idempotently, recomputes the digest, and advanceslast_contiguous_ledgeronly on a match.resyncframe identifying affected contract ids and ledger range for downstream consumers.defaultChecker.tsreads only committed, reconciled rows; it must not act onstatus='suspect'windows until backfill completes.Frontend Client
resyncframe. On receipt, callqueryClient.invalidateQueriesfor affected keys (['loans', ...],['pool', poolId],['events', contractId]) scoped by the reported contract ids, forcing a re-pull rather than trusting cached derived state.resyncframe, no rendered view reflects pre-rollback data; Playwright coverage asserts the re-pull. Public config unchanged (NEXT_PUBLIC_*).Root Configuration & Orchestration
.github/workflows/ci.yml: themigration-checkjob (servicepostgres:16) must run the new migration up and down with no residual objects; thebackendjob runs the reorg/gap integration suite; thefrontendjob runs the resync Playwright spec.docker-compose.ymlgains a fixture RPC stub (or recorded fixtures) so integration tests can inject a gap and a rollback deterministically.Verification & Acceptance Criteria
cargo buildincontracts/(overflow-checks on),npm run buildandnpm run typecheckinbackend/andfrontend/.event_seqand that identity(ledger_seq, tx_hash, event_index)is resolvable from the RPC envelope in a test harness.detectGap,detectReorg,backfillRange, digest computation, andON CONFLICTidempotency (re-apply yields zero net row change).status='suspect', backfill closes it,last_contiguous_ledgeradvances only after a digest match; injected rollback triggers re-read and reconciliation.up/downverified in themigration-checkjob with no residual objects.resyncSSE frame invalidates the correct query keys and the view re-pulls reconciled values.ledger_checkpointsstate dump before and after an injected gap and an injected rollback; indexer logs showing thesuspecttoverifiedtransition; CI run links for all three jobs.Suggested Execution Path
event_seqto publish sites across the four crates; extend contract tests. Deliverable: updated events plus tests. Exit check:cargo testgreen; identity fields present in emitted topics.ledger_checkpointsmigration and theindexed_eventsunique constraint. Deliverable: reversible migration. Exit check:migrate:up/migrate:downsucceed againstpostgres:16with no residual objects.detectGap,detectReorg,backfillRange, digesting, idempotent re-apply, and the SSEresyncemit; gatedefaultChecker.tson non-suspect windows. Deliverable: refactoredeventIndexer.tswith unit and integration tests. Exit check: injected gap and rollback both reconcile deterministically.resyncframe; invalidate scoped query keys; add Playwright coverage. Deliverable: updated SSE consumer and spec. Exit check: e2e proves re-pull after resync.docker-compose.yml; extendci.ymljobs; assemble PR state dumps. Deliverable: passing CI acrossbackend,migration-check,frontend. Exit check: all three jobs green with attachments included.