diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index b48faa66..19c1b629 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -303,6 +303,10 @@ jobs: - name: Run Storybook test runner run: npm run storybook:test + env: + # Point the test runner at the static build served by the built-in + # reference server. + TARGET_URL: http://localhost:6006 - name: Upload test runner report if: always() @@ -311,3 +315,65 @@ jobs: name: storybook-test-report path: storybook-test-report/ retention-days: 14 + + # ──────────────────────────────────────────────────────────────────────────── + # POST-DEPLOY VERIFICATION (issue #288) + # Read-only smoke suite + canary gate for vault contract/backend releases. + # Runs only when a deployed environment URL is provided (e.g. via a + # workflow_dispatch input or a release event). Never submits transactions. + # ──────────────────────────────────────────────────────────────────────────── + post-deploy-verify: + name: Post-deploy verification (smoke + canary) + runs-on: ubuntu-latest + # Only run when a target environment is supplied; skip on plain PRs. + if: ${{ vars.DEPLOY_ENV_URL != '' }} + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-node@v4 + with: + node-version: 22 + cache: npm + + - name: Install dependencies + run: npm ci + + # Read-only smoke suite: registry, NAV/share price, simulated deposit & + # withdrawal, epoch ledger entry, and indexer ledger-lag bound. Fails the + # job (triggering rollback) if any check fails. + - name: Run post-deploy smoke suite + run: npm run verify:smoke + env: + DEPLOY_ENV_URL: ${{ vars.DEPLOY_ENV_URL }} + CANARY_BASE_URL: ${{ vars.CANARY_BASE_URL }} + INDEXER_MAX_LAG_LEDGERS: ${{ vars.INDEXER_MAX_LAG_LEDGERS || '30' }} + VERIFY_RELEASE: ${{ github.ref_name }} + + # Canary gate: percentage/shadow traffic with structural comparison of + # responses to the stable version; promotes only when error rate and + # latency are within budget. + - name: Run canary comparison gate + run: npm run verify:canary + env: + DEPLOY_ENV_URL: ${{ vars.DEPLOY_ENV_URL }} + CANARY_BASE_URL: ${{ vars.CANARY_BASE_URL }} + CANARY_ERROR_BUDGET: ${{ vars.CANARY_ERROR_BUDGET || '0.01' }} + CANARY_LATENCY_BUDGET_MS: ${{ vars.CANARY_LATENCY_BUDGET_MS || '500' }} + VERIFY_RELEASE: ${{ github.ref_name }} + + - name: Upload verification results + if: always() + uses: actions/upload-artifact@v4 + with: + name: post-deploy-verification-${{ github.ref_name }} + path: verification-results/ + retention-days: 30 + + # Automatic rollback when any gate fails. Manual override is documented + # in docs/DEPLOY_ROLLBACK.md (set ROLLBACK_OVERRIDE=true to skip). + - name: Automatic rollback on gate failure + if: failure() && vars.ROLLBACK_OVERRIDE != 'true' + run: npm run deploy:rollback + env: + DEPLOY_ENV_URL: ${{ vars.DEPLOY_ENV_URL }} + VERIFY_RELEASE: ${{ github.ref_name }} diff --git a/docs/LEDGER.md b/docs/LEDGER.md index 3374a463..86b92aa0 100644 --- a/docs/LEDGER.md +++ b/docs/LEDGER.md @@ -66,3 +66,83 @@ a hardware-backed signing path with no seed phrase in the browser. - Soroban auth entries that require hash signing are documented here because the device cannot display arbitrary Soroban contract details. Always review the transaction hash on the Ledger screen before confirming. + +## Vault Event Indexer: Checkpoint, Backfill & Replay Runbook + +This section is the operator runbook for the vault event indexer (issue #287). +It covers durable cursor checkpoints, safe backfill from an arbitrary ledger, +and replay into a shadow dataset so a schema change or an indexer outage can +be corrected by re-reading the chain rather than by hand-patching rows. + +### Cursor Checkpointing + +The indexer persists a cursor of `(ledger_sequence, ledger_hash)` durably. The +cursor is written **in the same transaction** as the events it describes, so +the cursor can never advance ahead of the data it accounts for. On restart the +indexer resumes from the last committed cursor; if the stored `ledger_hash` +does not match the chain's hash for that sequence (a reorg or a bad write), the +indexer refuses to advance and surfaces a gap alert instead of silently +skipping. + +### Idempotent Upserts + +All event writes are upserts keyed by +`(contract_id, ledger_sequence, transaction_hash, event_index)`. Re-ingesting +an overlapping range — including a range that overlaps already-promoted data — +updates the existing row in place and cannot double-count share issuance, epoch +transitions, fee accrual, or redemptions. + +### Backfill + +Backfill ingests a ledger range with configurable concurrency and rate +limiting, and resumes safely if interrupted (it re-reads the persisted cursor +and continues from the next unprocessed ledger). The write path and the +backfill path share one pure `event → row` function; only the +reading/checkpointing layer differs. + +``` +# Ingest ledgers 1000..2000 with bounded concurrency and rate limiting +indexer backfill --from 1000 --to 2000 --concurrency 4 --rate-limit 10/s +``` + +Edge cases handled: a ledger containing several events for the same position +(each gets a distinct `event_index`), partially closed ledgers (the cursor only +advances past a ledger once all its events are committed), and a mid-backfill +schema deploy (the run aborts cleanly and resumes from the last committed +cursor). + +### Replay into a Shadow Dataset + +Replay re-derives events from chain and writes them into a shadow table/schema +for comparison before promotion. Because upserts are idempotent, replaying a +range that overlaps promoted data is safe. + +``` +# Re-derive ledgers 1000..2000 into the shadow schema +indexer replay --from 1000 --to 2000 --target shadow +``` + +Compare re-derived aggregates (share totals, fee totals) against the live +tables before promoting. A replay test asserts the re-derived aggregates match +pre-replay values, and a duplicate-event test asserts re-ingestion is a no-op. + +### Gap Detection & Lag Alerting + +A contiguity check verifies the ingested range has no holes, and an ingestion +lag check alerts when the cursor falls more than a threshold behind the chain +head. Both raise alerts rather than failing silently. + +### Promotion / Rebuild Runbook + +1. **Quiesce** — pause the live indexer so no new writes land during the run. +2. **Backfill** — ingest the target range (or the full history) with the + backfill command above; it resumes safely if interrupted. +3. **Verify aggregate invariants** — compare share totals, fee totals, and + epoch transitions against the shadow dataset and chain state. +4. **Promote** — swap the shadow dataset into the live tables once invariants + hold. +5. **Resume** — restart the live indexer; it resumes from the persisted + cursor and continues ingesting. + +Out of scope for this runbook: the initial event ingest implementation and the +event schema versioning policy (tracked in separate issues). diff --git a/src/lib/api/vaults.ts b/src/lib/api/vaults.ts index 181c8110..b6eda2b2 100644 --- a/src/lib/api/vaults.ts +++ b/src/lib/api/vaults.ts @@ -36,6 +36,9 @@ import { submitTransactionXdr } from "./stellar"; * POST /{id}/deposit { amount } → { tx_id } * POST /{id}/withdraw { shares } → { tx_id, queued } * + * GET /api/v1/vaults/{id}/epoch-schedule?count=N + * → EpochBoundaryPreview (dry-run: next N boundaries, no side effects) + * * GET /api/v1/vaults/{id}/analytics * → VaultAnalytics * @@ -112,7 +115,7 @@ import { submitTransactionXdr } from "./stellar"; * POST /api/v1/vaults/privacy/deletion-request { wallet: string; reason?: string } * → DeletionRequestReceipt * - * GET /api/v1/vaults/privacy/deletion-request/{id} + * GET /api/v1/vaults/privacy/deletion-request/{id} * → DeletionRequestReceipt * * Public read-only partner API (issue #233): @@ -776,8 +779,4 @@ export interface NavPoint { totalShares: number; } -/** Full detail for a single vault. */ -export interface VaultDetail extends VaultSummary { - currentEpoch: number; - currentPhase: EpochPhase; - phaseEndsAt: string; // ISO — when the current phase +/** Full detail for a diff --git a/vitest.setup.ts b/vitest.setup.ts index 0ecbed36..c9c064fc 100644 --- a/vitest.setup.ts +++ b/vitest.setup.ts @@ -1,5 +1,8 @@ import "@testing-library/jest-dom/vitest"; import { cleanup } from "@testing-library/react"; -import { afterEach } from "vitest"; +import { afterEach, expect } from "vitest"; +import { toHaveNoViolations } from "jest-axe"; + +expect.extend(toHaveNoViolations); afterEach(() => cleanup());