Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
66 changes: 66 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand All @@ -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 }}
80 changes: 80 additions & 0 deletions docs/LEDGER.md
Original file line number Diff line number Diff line change
Expand Up @@ -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).
11 changes: 5 additions & 6 deletions src/lib/api/vaults.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
*
Expand Down Expand Up @@ -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):
Expand Down Expand Up @@ -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
5 changes: 4 additions & 1 deletion vitest.setup.ts
Original file line number Diff line number Diff line change
@@ -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());