Skip to content

feat(indexer): orphaned flag + canonical PDA-join in reconciler (#234)#321

Merged
alrimarleskovar merged 1 commit into
mainfrom
feat/indexer-reconciler-234
May 15, 2026
Merged

feat(indexer): orphaned flag + canonical PDA-join in reconciler (#234)#321
alrimarleskovar merged 1 commit into
mainfrom
feat/indexer-reconciler-234

Conversation

@alrimarleskovar
Copy link
Copy Markdown
Owner

Closes #234.

Summary

The reconciler at services/indexer/src/reconciler.ts was a half-shipped feature: the orphan-marking logic referenced an orphaned column that didn't exist on the schema, the canonical PDA-join was a TODO, and only ContributeEvent was looped (claim + default skipped). This PR finishes the wiring.

What ships

Schema (prisma/schema.prisma)

  • orphaned: Boolean @default(false) + resolvedAt: DateTime? on all 3 event tables.
  • contributorWallet: String (ContributeEvent) + recipientWallet: String (ClaimEvent) — the subject wallet from the on-chain log was previously discarded; the reconciler needs it to resolve canonical Member FKs.
  • @@index([orphaned, slot]) on all 3 event tables — keeps both the reconciler scan and the runbook's orphan-triage SQL fast.

Reconciler (reconciler.ts)

  • New resolveCanonicalIds() helper:

    • Fetches the tx with commitment: "finalized".
    • Walks staticAccountKeys + meta.loadedAddresses.{writable,readonly} (covers Address Lookup Table-using txs).
    • Intersects with pools.pda in the DB → canonical Pool row.
    • Joins to Member via (poolId, wallet).
    • Returns null (caller leaves row unresolved + retries next pass) for the three failure modes: RPC miss / Pool not in DB yet / Member not in DB yet.
  • Three table-specific loops (was: 1, scaffold-only):

    • reconcileContributeEvents — resolves {poolId, memberId} from evt.contributorWallet.
    • reconcileClaimEvents — resolves {poolId, memberId} from evt.recipientWallet.
    • reconcileDefaultEvents — resolves {poolId, slotIndex} from evt.defaultedWallet; slotIndex recovered from the canonical Member row when present (falls back to placeholder when the Member was closed by escape_valve_buy).
  • Both the success and orphan paths actually UPDATE the row (was: log-only). orphaned = true after the 256-slot grace; resolvedAt stamped on success.

  • Skip already-orphaned rows in subsequent passes — terminal flag, no recovery here (backfill is the recovery path).

Operator surface

  • docs/operations/indexer-reorg-recovery.md — quick-triage SQL now uses the orphaned column instead of grep-by-slot heuristics; new step in P1 reorg playbook chains backfill + reconcile:once + diff validation; ledger-diff query filters NOT orphaned.
  • docs/security/indexer-threat-model.md — R1 (reorg orphans event) flips from 🟡 partial → ✅ shipped; § 4.1 reflects the full set of reconciler hardening that's now in repo.

Validation

  • pnpm typecheck (workspace + indexer) → green ✅
  • pnpm lint (prettier) → green ✅
  • pnpm exec prisma generate → fresh client generated, type-checks against new fields
  • No indexer tests existed pre-PR; not in scope of this change

Trust-path posture (unchanged)

The indexer is off the fund-movement trust path per AUDIT_SCOPE.md and docs/security/self-audit.md § 2. A buggy reconciler can misreport scores to a future B2B oracle but cannot move funds. This PR tightens the off-chain correctness story for B2B Phase 3.

Follow-ups (deliberately out of scope)

Item Why not here
MAINNET_READINESS.md § 5.2 status flip 🟡 → ✅ PR #320 also touches that file; doing it here would conflict. Will land in a small doc sync after both this PR and #320 merge.
Webhook auth (HMAC) + rate-limit + program-id allow-list Threat-model § 4.2; separate concern (ingestion, not reconciliation)
roundfi-reputation program event ingestion + reconciliation Mainnet not on critical path until B2B oracle endpoint design lands

Test plan

  • pnpm typecheck + pnpm lint green locally
  • CI passes (markdown + TS lanes)
  • Manual review: reconciler loop semantics + index choice
  • When deployed: smoke test against a devnet program — write a few events via webhook, run reconcile:once, verify rows transition _unresolved → canonical IDs with resolvedAt stamped

Generated by Claude Code

Closes the half-implemented reconciler hardening: schema columns the
reconciler already referenced now exist, the canonical-PDA join is
wired, and all 3 event tables are covered (not just contribute).

Schema
------
- Add `orphaned: Boolean @default(false)` + `resolvedAt: DateTime?`
  to `ContributeEvent`, `ClaimEvent`, `DefaultEvent`.
- Add `contributorWallet: String` to `ContributeEvent` and
  `recipientWallet: String` to `ClaimEvent` — the subject wallet from
  the on-chain log was previously discarded by the webhook; the
  reconciler needs it to resolve canonical Member FKs.
- Add `@@index([orphaned, slot])` on all 3 event tables — keeps both
  the reconciler's `WHERE NOT orphaned` scan and the runbook's orphan-
  triage SQL fast.

Reconciler
----------
- New `resolveCanonicalIds()` helper: fetches the tx with
  `commitment: "finalized"`, walks static + lookup-table account keys,
  intersects with `pools.pda` to find the canonical Pool row, then
  joins to Member via `(poolId, wallet)`. Returns `null` (caller
  leaves row unresolved + retries next pass) for the three failure
  modes: RPC miss / Pool not in DB yet / Member not in DB yet.
- Three table-specific reconcile loops (was 1, scaffold-only):
  contribute + claim resolve `{poolId, memberId}`, default resolves
  `{poolId, slotIndex}` from the canonical Member row (defaults
  don't carry `memberId` per the existing schema — wallet is the
  authoritative subject because escape_valve_buy can close the row).
- Both success and orphan paths now actually `UPDATE` the row (was:
  log-only). `orphaned = true` after the 256-slot grace; `resolvedAt`
  stamped on success.
- Skip already-`orphaned` rows in subsequent passes — terminal flag,
  no recovery path here (backfill is the recovery).

Operator surface
----------------
- `docs/operations/indexer-reorg-recovery.md` — quick-triage SQL now
  uses the `orphaned` column instead of grep-by-slot heuristics; new
  step in P1 reorg playbook chains backfill + reconcile:once + diff
  validation; ledger-diff query filters `NOT orphaned`.
- `docs/security/indexer-threat-model.md` — R1 (reorg orphans event)
  flips from 🟡 partial → ✅ shipped; § 4.1 reflects the full set of
  reconciler hardening that's now in repo.

Validation
----------
- `pnpm typecheck` (workspace + indexer) — green
- `pnpm lint` (prettier) — green
- No indexer tests existed pre-PR; not in scope of this change

Follow-ups (not in this PR)
---------------------------
- `MAINNET_READINESS.md` § 5.2 still says 🟡 with "Outstanding: Prisma
  migration + canonical PDA-join (tracked in #234)". That should flip
  to ✅ after this lands. Leaving it for a separate doc sync to avoid
  conflicting with PR #320 (also touching MAINNET_READINESS).
- Webhook auth (HMAC) + rate-limit + program-id allow-list remain
  unshipped (threat-model § 4.2).
- `roundfiReputation`-program event ingestion + reconciliation: not
  in this PR; mainnet not on critical path until B2B oracle endpoint.

https://claude.ai/code/session_01YapZy1Z5gzbV5EammBkSQm
@vercel
Copy link
Copy Markdown

vercel Bot commented May 15, 2026

The latest updates on your projects. Learn more about Vercel for GitHub.

1 Skipped Deployment
Project Deployment Actions Updated (UTC)
round_financial Ignored Ignored May 15, 2026 10:12am

@alrimarleskovar alrimarleskovar merged commit 9f475f3 into main May 15, 2026
6 checks passed
@alrimarleskovar alrimarleskovar deleted the feat/indexer-reconciler-234 branch May 17, 2026 09:12
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants