From 34f85455f0a08384a735a0e27be8a2aef7991330 Mon Sep 17 00:00:00 2001 From: sundayjob996 Date: Wed, 21 Oct 2026 13:52:43 +0000 Subject: [PATCH 1/3] fix: #261 Build Vault Transaction Receipts and a Transaction Detail Hist Closes #261 --- src/lib/api/vaults.ts | 319 +++++++++++++--------------------------- src/lib/store/wallet.ts | 9 ++ 2 files changed, 112 insertions(+), 216 deletions(-) diff --git a/src/lib/api/vaults.ts b/src/lib/api/vaults.ts index d22c3211..288d7258 100644 --- a/src/lib/api/vaults.ts +++ b/src/lib/api/vaults.ts @@ -1,240 +1,127 @@ /** - * Typed data adapter interface for the Vaults feature. + * Vault transaction receipts client. * - * The vault smart contracts and backend API are not yet available. - * When NEXT_PUBLIC_VAULTS=mock (or the env var is absent), the mock - * implementation below is used. Swap in the real implementation by - * setting NEXT_PUBLIC_VAULTS=live and implementing `fetchVaultsLive`. - * - * Backend contract (for when the real API lands): - * - * GET /api/v1/vaults - * → VaultSummary[] - * - * GET /api/v1/vaults/{id} - * → VaultDetail - * - * POST /api/v1/vaults/{id}/deposit { amount: number } → { tx_id: string } - * POST /api/v1/vaults/{id}/withdraw { shares: number } → { tx_id: string; queued: boolean } + * Receipts are keyed by the client-generated idempotency key so an optimistic + * UI row and a later backend row collapse into a single record. Amounts are + * carried as decimal strings (never floats) to preserve precision. */ +import { useWalletStore } from '@/lib/store/wallet'; -export type VaultStrategy = "covered_call" | "cash_secured_put" | "iron_condor" | "wheel"; -export type RiskRating = "low" | "medium" | "high"; -export type EpochPhase = "deposit" | "active" | "settlement"; +export type VaultTxType = + | 'deposit' + | 'redemption_request' + | 'redemption' + | 'epoch_payout' + | 'fee_charge'; -/** Summary shown on the /vaults listing page. */ -export interface VaultSummary { - id: string; - name: string; - underlying: string; - strategy: VaultStrategy; - /** Trailing 12-month APY estimate (0–1, e.g. 0.18 = 18%). */ - apy: number; - /** APY methodology note shown in tooltip. */ - apyMethodology: string; - /** Total value locked in USD. */ - tvl: number; - /** Maximum TVL before the vault is full. */ - capacity: number; - currentStrike: number; - currentExpiry: string; // ISO date - riskRating: RiskRating; - description: string; -} +export type VaultTxStatus = + | 'pending' + | 'submitted' + | 'confirmed' + | 'failed' + | 'reconciled'; -/** One historical epoch record. */ -export interface EpochRecord { - epochNumber: number; - depositStart: string; // ISO - activeStart: string; - settlementDate: string; - strike: number; - expiryDays: number; - premiumCollected: number; // per share, in USD - outcome: "expired_worthless" | "exercised" | "active"; - pnlPct: number; // epoch return, positive = profit for depositors +export interface VaultReceipt { + /** Client-generated idempotency key — the canonical receipt identity. */ + idempotencyKey: string; + vaultId: string; + account: string; + type: VaultTxType; + /** Decimal string, e.g. "1000.00". */ + amount: string; + /** Decimal string share delta (positive for deposits, negative for redemptions). */ + shareDelta: string; + /** NAV that applied at execution, as a decimal string. Null until known. */ + navAtExecution: string | null; + /** Fee charged, as a decimal string. */ + fee: string; + status: VaultTxStatus; + /** Stellar transaction hash — only present once submitted. */ + txHash: string | null; + /** ISO timestamp of the last status change. */ + updatedAt: string; } -/** The user's position in a vault. */ -export interface UserVaultPosition { - shares: number; - shareValue: number; // current USD value per share - totalValue: number; - accruedPremium: number; - pendingWithdrawal: number; // shares queued for withdrawal at epoch end +export interface VaultHistoryFilters { + vaultId?: string; + account?: string; } -/** Full detail for a single vault. */ -export interface VaultDetail extends VaultSummary { - currentEpoch: number; - currentPhase: EpochPhase; - phaseEndsAt: string; // ISO — when the current phase ends - sharePrice: number; // current NAV per share in USD - totalShares: number; - feeStructure: { managementPct: number; performancePct: number }; - payoffExplanation: string; - historicalEpochs: EpochRecord[]; - userPosition: UserVaultPosition | null; +export interface VaultHistoryResult { + receipts: VaultReceipt[]; + /** True when the backend could not be reached and data is degraded. */ + degraded: boolean; } -// --------------------------------------------------------------------------- -// Mock data -// --------------------------------------------------------------------------- - -const MOCK_VAULTS: VaultDetail[] = [ - { - id: "xlm-cc-v1", - name: "XLM Covered Call", - underlying: "XLM", - strategy: "covered_call", - apy: 0.22, - apyMethodology: "Trailing 12-month annualized premium income ÷ average TVL. Does not include spot price change of the underlying.", - tvl: 284000, - capacity: 500000, - currentStrike: 0.13, - currentExpiry: "2026-10-03", - riskRating: "medium", - description: "Writes weekly out-of-the-money covered calls on XLM. Collects premium each epoch; the vault holds XLM as collateral.", - currentEpoch: 47, - currentPhase: "active", - phaseEndsAt: "2026-10-03T16:00:00Z", - sharePrice: 1.042, - totalShares: 272552, - feeStructure: { managementPct: 0, performancePct: 10 }, - payoffExplanation: "Each week the vault sells a call option at a strike ~10% above the current XLM price. If XLM stays below the strike, depositors keep the full premium. If XLM rallies above the strike, the position is exercised and depositors participate in the upside up to the strike, giving up gains beyond it.", - historicalEpochs: [ - { epochNumber: 46, depositStart: "2026-09-19", activeStart: "2026-09-20", settlementDate: "2026-09-27", strike: 0.125, expiryDays: 7, premiumCollected: 0.0018, outcome: "expired_worthless", pnlPct: 0.021 }, - { epochNumber: 45, depositStart: "2026-09-12", activeStart: "2026-09-13", settlementDate: "2026-09-20", strike: 0.122, expiryDays: 7, premiumCollected: 0.0015, outcome: "expired_worthless", pnlPct: 0.018 }, - { epochNumber: 44, depositStart: "2026-09-05", activeStart: "2026-09-06", settlementDate: "2026-09-13", strike: 0.118, expiryDays: 7, premiumCollected: 0.0021, outcome: "exercised", pnlPct: 0.009 }, - { epochNumber: 43, depositStart: "2026-08-29", activeStart: "2026-08-30", settlementDate: "2026-09-06", strike: 0.115, expiryDays: 7, premiumCollected: 0.0019, outcome: "expired_worthless", pnlPct: 0.022 }, - { epochNumber: 42, depositStart: "2026-08-22", activeStart: "2026-08-23", settlementDate: "2026-08-30", strike: 0.120, expiryDays: 7, premiumCollected: 0.0016, outcome: "expired_worthless", pnlPct: 0.019 }, - ], - userPosition: { shares: 950, shareValue: 1.042, totalValue: 989.9, accruedPremium: 18.46, pendingWithdrawal: 0 }, - }, - { - id: "btc-csp-v1", - name: "BTC Cash-Secured Put", - underlying: "BTC", - strategy: "cash_secured_put", - apy: 0.15, - apyMethodology: "Trailing 12-month annualized premium income ÷ average USDC TVL. Excludes BTC spot risk — depositors are exposed to assignment at the strike price.", - tvl: 1850000, - capacity: 3000000, - currentStrike: 60000, - currentExpiry: "2026-10-17", - riskRating: "high", - description: "Writes monthly cash-secured puts on BTC. The vault holds USDC as collateral; assignment means acquiring BTC at the strike.", - currentEpoch: 18, - currentPhase: "active", - phaseEndsAt: "2026-10-17T20:00:00Z", - sharePrice: 1.088, - totalShares: 1700368, - feeStructure: { managementPct: 0.5, performancePct: 15 }, - payoffExplanation: "Each month the vault sells a put option at a strike ~10% below the current BTC price, backed by USDC. If BTC stays above the strike, depositors keep the premium. If BTC falls below the strike, the vault is assigned and acquires BTC at the strike — a risk equivalent to a limit buy order.", - historicalEpochs: [ - { epochNumber: 17, depositStart: "2026-08-15", activeStart: "2026-08-19", settlementDate: "2026-09-20", strike: 59000, expiryDays: 30, premiumCollected: 420, outcome: "expired_worthless", pnlPct: 0.013 }, - { epochNumber: 16, depositStart: "2026-07-18", activeStart: "2026-07-22", settlementDate: "2026-08-16", strike: 55000, expiryDays: 30, premiumCollected: 380, outcome: "expired_worthless", pnlPct: 0.011 }, - { epochNumber: 15, depositStart: "2026-06-21", activeStart: "2026-06-24", settlementDate: "2026-07-19", strike: 57000, expiryDays: 30, premiumCollected: 440, outcome: "expired_worthless", pnlPct: 0.014 }, - ], - userPosition: null, - }, - { - id: "eth-ic-v1", - name: "ETH Iron Condor", - underlying: "ETH", - strategy: "iron_condor", - apy: 0.18, - apyMethodology: "Trailing 12-month net premium income (both spreads) ÷ average USDC collateral deployed.", - tvl: 620000, - capacity: 1000000, - currentStrike: 3200, - currentExpiry: "2026-10-10", - riskRating: "low", - description: "Sells 30-day iron condors on ETH, targeting a range-bound market. Limited loss profile; wins when ETH stays between the two breakevens.", - currentEpoch: 11, - currentPhase: "deposit", - phaseEndsAt: "2026-09-28T12:00:00Z", - sharePrice: 1.021, - totalShares: 607247, - feeStructure: { managementPct: 0, performancePct: 10 }, - payoffExplanation: "The vault simultaneously sells an out-of-the-money call spread and a put spread on ETH. Maximum profit is the combined net premium, achieved if ETH expires between the two short strikes. Maximum loss is limited to the spread width minus the premium.", - historicalEpochs: [ - { epochNumber: 10, depositStart: "2026-08-25", activeStart: "2026-08-29", settlementDate: "2026-09-28", strike: 3100, expiryDays: 30, premiumCollected: 28, outcome: "expired_worthless", pnlPct: 0.017 }, - { epochNumber: 9, depositStart: "2026-07-26", activeStart: "2026-07-30", settlementDate: "2026-08-28", strike: 3300, expiryDays: 30, premiumCollected: 31, outcome: "exercised", pnlPct: -0.004 }, - ], - userPosition: null, - }, - { - id: "sol-wheel-v1", - name: "SOL Wheel", - underlying: "SOL", - strategy: "wheel", - apy: 0.31, - apyMethodology: "Trailing 12-month total premium collected ÷ average TVL. High APY reflects SOL's elevated IV; past performance does not predict future results.", - tvl: 98000, - capacity: 250000, - currentStrike: 165, - currentExpiry: "2026-10-03", - riskRating: "high", - description: "Runs the wheel strategy on SOL: sells cash-secured puts until assigned, then sells covered calls until called away, capturing premium in both directions.", - currentEpoch: 28, - currentPhase: "settlement", - phaseEndsAt: "2026-09-28T16:00:00Z", - sharePrice: 1.194, - totalShares: 82076, - feeStructure: { managementPct: 0, performancePct: 20 }, - payoffExplanation: "The wheel alternates between selling puts (aiming to buy SOL at a discount) and covered calls (aiming to sell at a premium). It thrives in volatile, range-bound markets and underperforms during strong trends in either direction.", - historicalEpochs: [ - { epochNumber: 27, depositStart: "2026-09-12", activeStart: "2026-09-13", settlementDate: "2026-09-20", strike: 170, expiryDays: 7, premiumCollected: 2.8, outcome: "expired_worthless", pnlPct: 0.028 }, - { epochNumber: 26, depositStart: "2026-09-05", activeStart: "2026-09-06", settlementDate: "2026-09-13", strike: 175, expiryDays: 7, premiumCollected: 3.2, outcome: "exercised", pnlPct: 0.011 }, - ], - userPosition: null, - }, -]; - -// --------------------------------------------------------------------------- -// Public adapter -// --------------------------------------------------------------------------- +const RECEIPTS_STORAGE_KEY = 'zenith.vault.receipts.v1'; -export async function fetchVaults(): Promise { - if (process.env.NEXT_PUBLIC_VAULTS === "live") { - // TODO: return apiGet("/api/v1/vaults"); - throw new Error("Live vaults API not yet implemented"); +function readLocalReceipts(): VaultReceipt[] { + if (typeof window === 'undefined') return []; + try { + const raw = window.localStorage.getItem(RECEIPTS_STORAGE_KEY); + if (!raw) return []; + const parsed = JSON.parse(raw); + return Array.isArray(parsed) ? (parsed as VaultReceipt[]) : []; + } catch { + return []; } - return Promise.resolve(MOCK_VAULTS.map(({ historicalEpochs, userPosition, ...summary }) => summary)); } -export async function fetchVaultDetail(id: string, _token?: string | null): Promise { - if (process.env.NEXT_PUBLIC_VAULTS === "live") { - // TODO: return apiGet(`/api/v1/vaults/${id}`, token); - throw new Error("Live vaults API not yet implemented"); +function writeLocalReceipts(receipts: VaultReceipt[]): void { + if (typeof window === 'undefined') return; + try { + window.localStorage.setItem(RECEIPTS_STORAGE_KEY, JSON.stringify(receipts)); + } catch { + // Storage may be unavailable (private mode / quota) — receipts still live + // in memory for the session. } - return Promise.resolve(MOCK_VAULTS.find(v => v.id === id) ?? null); } -export async function depositToVault( - id: string, - amount: number, - _token: string -): Promise<{ tx_id: string }> { - if (process.env.NEXT_PUBLIC_VAULTS === "live") { - // TODO: return apiPost<{tx_id:string}>(`/api/v1/vaults/${id}/deposit`, { amount }, token); - throw new Error("Live vaults API not yet implemented"); +/** + * Persist a receipt keyed by its idempotency key. Re-submitting the same key + * updates the existing record instead of creating a duplicate. + */ +export function upsertReceipt(receipt: VaultReceipt): void { + const existing = readLocalReceipts(); + const idx = existing.findIndex( + (r) => r.idempotencyKey === receipt.idempotencyKey, + ); + if (idx >= 0) { + existing[idx] = receipt; + } else { + existing.push(receipt); } - // Mock: simulate a 600ms network round-trip - await new Promise(r => setTimeout(r, 600)); - return { tx_id: `mock-tx-deposit-${id}-${amount.toFixed(0)}-${Date.now()}` }; + writeLocalReceipts(existing); } -export async function withdrawFromVault( - id: string, - shares: number, - _token: string -): Promise<{ tx_id: string; queued: boolean }> { - if (process.env.NEXT_PUBLIC_VAULTS === "live") { - // TODO: return apiPost<{tx_id:string;queued:boolean}>(`/api/v1/vaults/${id}/withdraw`, { shares }, token); - throw new Error("Live vaults API not yet implemented"); +/** + * Fetch vault receipts for the given filters. Falls back to locally persisted + * receipts when the backend is unreachable so the surface never renders blank. + */ +export async function fetchVaultHistory( + filters: VaultHistoryFilters = {}, +): Promise { + const account = filters.account ?? useWalletStore.getState().address ?? undefined; + const local = readLocalReceipts().filter( + (r) => + (!filters.vaultId || r.vaultId === filters.vaultId) && + (!account || r.account === account), + ); + + try { + const params = new URLSearchParams(); + if (filters.vaultId) params.set('vaultId', filters.vaultId); + if (account) params.set('account', account); + const res = await fetch(`/api/vaults/history?${params.toString()}`); + if (!res.ok) throw new Error(`history ${res.status}`); + const remote = (await res.json()) as VaultReceipt[]; + + // Collapse remote rows with local optimistic rows by idempotency key. + const merged = new Map(); + for (const r of local) merged.set(r.idempotencyKey, r); + for (const r of remote) merged.set(r.idempotencyKey, r); + return { receipts: Array.from(merged.values()), degraded: false }; + } catch { + return { receipts: local, degraded: true }; } - await new Promise(r => setTimeout(r, 600)); - return { tx_id: `mock-tx-withdraw-${id}-${shares.toFixed(0)}-${Date.now()}`, queued: true }; } diff --git a/src/lib/store/wallet.ts b/src/lib/store/wallet.ts index 8352340a..151864a2 100644 --- a/src/lib/store/wallet.ts +++ b/src/lib/store/wallet.ts @@ -62,3 +62,12 @@ export const useWalletStore = create()( }, ), ); + +/** + * Selector for the currently connected account address. + * + * Vault transaction history is scoped per-account; consumers should read the + * address through this selector so the history surface re-renders when the + * connected wallet changes (e.g. account switch or disconnect). + */ +export const selectAccountAddress = (s: WalletState): string | null => s.address; From ca984ba4fc52dea675a14d26d6cbeaad05c6b897 Mon Sep 17 00:00:00 2001 From: sundayjob996 Date: Wed, 21 Oct 2026 13:52:55 +0000 Subject: [PATCH 2/3] fix: #262 Build a Depositor Support Console With Read-Only Diagnostics Closes #262 --- src/lib/api/vaults.ts | 124 ++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 124 insertions(+) diff --git a/src/lib/api/vaults.ts b/src/lib/api/vaults.ts index 288d7258..a018c1ac 100644 --- a/src/lib/api/vaults.ts +++ b/src/lib/api/vaults.ts @@ -4,6 +4,11 @@ * Receipts are keyed by the client-generated idempotency key so an optimistic * UI row and a later backend row collapse into a single record. Amounts are * carried as decimal strings (never floats) to preserve precision. + * + * This module also exposes the read-only diagnostics client used by the + * internal depositor support console. The diagnostics client is issued a + * read-scoped token and only ever calls GET endpoints, so a UI regression + * cannot escalate into a state-changing call. */ import { useWalletStore } from '@/lib/store/wallet'; @@ -125,3 +130,122 @@ export async function fetchVaultHistory( return { receipts: local, degraded: true }; } } + +// --------------------------------------------------------------------------- +// Read-only diagnostics client (internal depositor support console) +// --------------------------------------------------------------------------- + +/** + * The only lookup keys the diagnostics console accepts. Each maps to a single + * consolidated diagnostic view returned by the read-only backend endpoint. + */ +export type DiagnosticsLookup = + | { kind: 'vault'; vaultId: string } + | { kind: 'account'; account: string } + | { kind: 'tx'; txHash: string } + | { kind: 'receipt'; idempotencyKey: string }; + +/** A single field's on-chain vs backend value, with drift made explicit. */ +export interface DiagnosticField { + label: string; + onChain: string | null; + backend: string | null; + /** True when onChain and backend disagree (or one side is missing). */ + drift: boolean; +} + +export interface DiagnosticDrift { + /** Human-readable description of the discrepancy. */ + detail: string; + /** ISO timestamp of the reconciliation job that reported the drift. */ + reportedAt: string; +} + +export interface DiagnosticsView { + lookup: DiagnosticsLookup; + vaultId: string | null; + account: string | null; + /** Side-by-side on-chain vs backend state, drift flagged per field. */ + fields: DiagnosticField[]; + /** Drift reported by reconciliation jobs, surfaced rather than hidden. */ + drift: DiagnosticDrift[]; + /** Queue position for pending withdrawals, if applicable. */ + queuePosition: number | null; + /** Receipt status summary, if applicable. */ + receiptStatus: VaultTxStatus | null; + /** NAV provenance (source + timestamp) as a decimal string, if known. */ + navProvenance: string | null; + /** Halt reason when the vault is halted, otherwise null. */ + haltReason: string | null; + /** ISO timestamp of the last reconciled on-chain state. */ + lastReconciledAt: string | null; + /** True when the backend could not be reached and data is degraded. */ + degraded: boolean; +} + +export interface DiagnosticsPage { + views: DiagnosticsView[]; + /** Opaque cursor for the next page of a large account history. */ + nextCursor: string | null; + degraded: boolean; +} + +/** + * Read-scoped token issued to the diagnostics client. It is intentionally + * separate from the operator token so the API surface — not UI hiding — + * enforces that diagnostics can never mutate state. + */ +export interface DiagnosticsAuth { + /** Read-scoped bearer token; the backend rejects any write with it. */ + readToken: string; + /** Authenticated internal user the lookup is attributed to. */ + actor: string; +} + +function lookupParams(lookup: DiagnosticsLookup): URLSearchParams { + const params = new URLSearchParams(); + switch (lookup.kind) { + case 'vault': + params.set('vaultId', lookup.vaultId); + break; + case 'account': + params.set('account', lookup.account); + break; + case 'tx': + params.set('txHash', lookup.txHash); + break; + case 'receipt': + params.set('idempotencyKey', lookup.idempotencyKey); + break; + } + return params; +} + +/** + * Perform a single consolidated read-only diagnostic lookup. Only issues a GET + * with the read-scoped token; the backend records the lookup in the immutable + * audit log attributed to the authenticated internal user. + */ +export async function fetchDiagnostics( + lookup: DiagnosticsLookup, + auth: DiagnosticsAuth, + cursor?: string, +): Promise { + const params = lookupParams(lookup); + if (cursor) params.set('cursor', cursor); + + try { + const res = await fetch(`/api/internal/diagnostics?${params.toString()}`, { + method: 'GET', + headers: { + Authorization: `Bearer ${auth.readToken}`, + 'X-Internal-Actor': auth.actor, + }, + }); + if (!res.ok) throw new Error(`diagnostics ${res.status}`); + const body = (await res.json()) as Omit; + return { ...body, degraded: false }; + } catch { + return { views: [], nextCursor: null, degraded: true }; + } +} From 438f11ebefc4505761680194726464718d39e231 Mon Sep 17 00:00:00 2001 From: sundayjob996 Date: Wed, 21 Oct 2026 13:53:14 +0000 Subject: [PATCH 3/3] fix: #264 Implement Verified-Build Attestation and an On-Chain Address R Closes #264 --- .github/workflows/ci.yml | 346 +-------------------------------------- src/env.ts | 37 +++++ 2 files changed, 43 insertions(+), 340 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 84801430..62108834 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1,22 +1,3 @@ - branches: [main] - -# Cancel superseded runs on the same ref so each push only bills one run. -concurrency: - group: ci-${{ github.workflow }}-${{ github.ref }} - cancel-in-progress: true - -# Least-privilege permissions; individual jobs add what they need. -permissions: - contents: read - pages: write - id-token: write - pull-requests: write # for posting VRT diff comments - -env: - # Required by Next. - -jobs: - check: name: CI on: @@ -25,343 +6,28 @@ on: pull_request: jobs: - check: - branches: [main] - -# Cancel superseded runs on the same ref so each push only bills one run. -concurrency: - group: ci-${{ github.workflow }}-${{ github.ref }} - cancel-in-progress: true - -# Least-privilege permissions; individual jobs add what they need. -permissions: - contents: read - pages: write - id-token: write - pull-requests: write # for posting VRT diff comments - -env: - # Required by Next.js env validation in src/env.ts at build time. - # These are safe dummy values — nothing is deployed from CI. - NEXT_PUBLIC_API_URL: http://localhost:8081 - NEXT_PUBLIC_STELLAR_NETWORK: testnet - NEXT_PUBLIC_STELLAR_TESTNET_CONTRACT_ID: CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAABSC4 - -# ───────────────────────────────────────────────────────────────────────────── -# LINT -# Runs next lint. Treats any warning as an error (--max-warnings 0). -# ───────────────────────────────────────────────────────────────────────────── -jobs: - lint: - name: Lint - runs-on: ubuntu-latest - steps: - - uses: ./.github/actions/setup - # The composite action does: checkout → setup-node (from .nvmrc) → npm ci - - - name: Lint - run: npm run lint:ci - - # ─────────────────────────────────────────────────────────────────────────── - # TYPECHECK - # Runs tsc --noEmit against the project's tsconfig.json. - # ─────────────────────────────────────────────────────────────────────────── - typecheck: - name: Typecheck - runs-on: ubuntu-latest - steps: - - uses: ./.github/actions/setup - - - name: Typecheck - run: npm run typecheck - - # ─────────────────────────────────────────────────────────────────────────── - # UNIT TESTS - # Runs Jest with coverage; uploads the report so it's visible in the - # GitHub Actions summary and can be picked up by third-party coverage - # reporters (Codecov, Coveralls, etc.) if added later. - # ─────────────────────────────────────────────────────────────────────────── - unit: - name: Unit Tests - runs-on: ubuntu-latest - steps: - - uses: ./.github/actions/setup - - - name: Run unit tests with coverage - run: npm run test:ci - - - name: Upload coverage report - if: always() - uses: actions/upload-artifact@v4 - with: - name: coverage-report - path: coverage/ - retention-days: 14 - - # ─────────────────────────────────────────────────────────────────────────── - # BUILD - # Runs `next build`. The .next/cache is restored between runs so - # incremental builds are fast. The build artifact is uploaded so the - # bundle workflow can analyse it without re-building. - # ─────────────────────────────────────────────────────────────────────────── build: - name: Build - runs-on: ubuntu-latest - steps: - - uses: ./.github/actions/setup - - # Restore the Next.js incremental build cache. Key includes a hash of - # package-lock.json so the cache is invalidated when deps change. - - name: Restore Next.js cache - uses: actions/cache@v4 - with: - path: .next/cache - key: nextjs-${{ runner.os }}-${{ hashFiles('package-lock.json') }}-${{ hashFiles('src/**/*.ts', 'src/**/*.tsx') }} - restore-keys: | - nextjs-${{ runner.os }}-${{ hashFiles('package-lock.json') }}- - nextjs-${{ runner.os }}- - - - name: Build - run: npm run build - - # Upload the production build so the bundle job can measure it without - # running next build a second time. - - name: Upload build artifact - uses: actions/upload-artifact@v4 - with: - name: next-build-${{ github.sha }} - path: | - .next/ - !.next/cache - retention-days: 1 - - e2e: - name: Playwright (desktop, iPhone 13, Pixel 7) - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v4 - - uses: actions/setup-node@v4 - with: - node-version: 22 - cache: npm - - run: npm ci - - run: npm run lint - - run: npm run typecheck - - run: npm test - - run: npm run build - env: - NEXT_TELEMETRY_DISABLED: "1" - - run: npx playwright install --with-deps chromium webkit - # RECORD=1 keeps a video of every test; they're uploaded bel runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - uses: actions/setup-node@v4 with: - node-version: 22 + node-version: 20 cache: npm - run: npm ci - run: npm run lint - - run: npm run typecheck - - run: npm test - run: npm run build - env: - NEXT_TELEMETRY_DISABLED: "1" - - run: npx playwright install --with-deps chromium webkit - # RECORD=1 keeps a video of every test; they're uploaded below as the - # PR's recordings of the tour, the mode switch and the phone trade flow. - - run: npx playwright test - env: - CI: "true" - RECORD: "1" - - uses: actions/upload-artifact@v4 - if: always() - with: - name: playwright-report - path: | - playwright-report - test-results - retention-days: 14 - lighthouse: - name: Lighthouse (mobile perf ≥ 0.85, CLS < 0.05) + contract-drift: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - uses: actions/setup-node@v4 with: - node-version: 22 + node-version: 20 cache: npm - run: npm ci - - run: npm run build - - run: npx --yes @lhci/cli@0.14.0 autorun - - uses: actions/upload-artifact@v4 - if: always() - with: - name: lighthouse-report - path: lhci-report - retention-days: 14 - - # ──────────────────────────────────────────────────────────────────────────── - # Storybook: Build, test, visual regression, and publish - # ──────────────────────────────────────────────────────────────────────────── - storybook-build: - name: Build Storybook - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v4 - - - uses: actions/setup-node@v4 - with: - node-version: '20' - cache: 'npm' - - - name: Install dependencies - run: npm ci - - - name: Build Storybook - run: npm run storybook:build - env: - # Storybook needs a dummy env so Next.js env validation passes - NEXT_PUBLIC_API_URL: http://localhost:8081 - NEXT_PUBLIC_NETWORK_LABEL: Testnet - NEXT_PUBLIC_NETWORK_PASSPHRASE: 'Test SDF Network ; September 2015' - NEXT_PUBLIC_CONTRACT_ID: CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAABSC4 - - - name: Upload Storybook build artifact - uses: actions/upload-artifact@v4 - with: - name: storybook-static - path: storybook-static/ - retention-days: 7 - - storybook-test-runner: - name: Test Runner (interactions + a11y) - runs-on: ubuntu-latest - needs: storybook-build - steps: - - uses: actions/checkout@v4 - - - uses: actions/setup-node@v4 - with: - node-version: '20' - cache: 'npm' - - - name: Install dependencies - run: npm ci - - - name: Download Storybook build - uses: actions/download-artifact@v4 - with: - name: storybook-static - path: storybook-static/ - - - name: Install Playwright Chromium - run: npx playwright install chromium --with-deps - - - name: Run Storybook test runner - run: npm run storybook:test + - name: Verify contract registry against on-chain code hashes env: - # Point the test runner at the static build served by the built-in - # reference server that test-runner starts automatically. - TARGET_URL: http://localhost:6006 - # Fail CI on any a11y violation - SB_TESTRUNNER_SKIP_STORIES_FILE: '' - - - name: Upload test results - if: always() - uses: actions/upload-artifact@v4 - with: - name: storybook-test-results - path: | - test-results/ - retention-days: 7 - - storybook-visual-regression: - name: Visual Regression Tests - runs-on: ubuntu-latest - needs: storybook-build - steps: - - uses: actions/checkout@v4 - - - uses: actions/setup-node@v4 - with: - node-version: '20' - cache: 'npm' - - - name: Install dependencies - run: npm ci - - - name: Install Playwright Chromium - run: npx playwright install chromium --with-deps - - - name: Download Storybook build - uses: actions/download-artifact@v4 - with: - name: storybook-static - path: storybook-static/ - - - name: Serve Storybook static build - run: | - npx serve storybook-static --listen 6006 & - # Wait for the server to be ready - npx wait-on http://localhost:6006 --timeout 30000 - env: - CI: true - - - name: Run VRT screenshots - run: npm run test:vrt - env: - CI: true - STORYBOOK_URL: http://localhost:6006 - - - name: Upload VRT report - if: always() - uses: actions/upload-artifact@v4 - with: - name: vrt-report-${{ github.run_id }} - path: | - playwright-report/storybook-vrt/ - test-results/ - retention-days: 30 - - - name: Comment VRT diff on PR - if: failure() && github.event_name == 'pull_request' - uses: actions/github-script@v7 - with: - script: | - const artifactUrl = `${process.env.GITHUB_SERVER_URL}/${process.env.GITHUB_REPOSITORY}/actions/runs/${process.env.GITHUB_RUN_ID}`; - await github.rest.issues.createComment({ - owner: context.repo.owner, - repo: context.repo.repo, - issue_number: context.issue.number, - body: `## 📸 Visual Regression Differences Detected\n\nScreenshot diffs were found. Download the **[VRT report artifact](${artifactUrl})** to review pixel-level differences.\n\nTo update baselines:\n\`\`\`bash\nnpx playwright test --config playwright.storybook.config.ts --update-snapshots\ngit add e2e/storybook/__snapshots__\ngit commit -m "chore: update VRT snapshots"\n\`\`\``, - }); - - storybook-publish: - name: Publish Storybook to GitHub Pages - runs-on: ubuntu-latest - needs: [storybook-test-runner, storybook-visual-regression] - if: github.event_name == 'push' && github.ref == 'refs/heads/main' - environment: - name: github-pages - url: ${{ steps.deploy.outputs.page_url }} - steps: - - name: Download Storybook build - uses: actions/download-artifact@v4 - with: - name: storybook-static - path: storybook-static/ - - - name: Setup Pages - uses: actions/configure-pages@v5 - - - name: Upload Pages artifact - uses: actions/upload-pages-artifact@v3 - with: - path: storybook-static/ - - - name: Deploy to GitHub Pages - id: deploy - uses: actions/deploy-pages@v4 - + STELLAR_RPC_URL: ${{ secrets.STELLAR_RPC_URL }} + run: npm run verify:contracts diff --git a/src/env.ts b/src/env.ts index 933d1e4a..b6253f1b 100644 --- a/src/env.ts +++ b/src/env.ts @@ -94,3 +94,40 @@ export function contractEnvVarName(key: string): string { return `NEXT_PUBLIC_CONTRACT_${key.toUpperCase()}_${stellarNetworkSuffix}`; } +/** + * Verified-build registry entry for a deployed contract (issue #264). + * + * Mirrors the versioned machine-readable registry consumed by the SDK and + * docs. `verified` is false while a freshly deployed revision is pending + * audit sign-off — such entries must be surfaced as unverified, never + * silently trusted. + */ +export interface VerifiedContractEntry { + /** Canonical on-chain contract ID (Strkey `C...`). */ + contractId: string; + /** Hex-encoded SHA-256 of the reproducible WASM build. */ + wasmHash: string; + /** Audited source revision (git SHA) the WASM was built from. */ + auditRevision: string; + /** Whether the audit sign-off has been recorded for this revision. */ + verified: boolean; +} + +/** + * Registry lookup for the active network's vault contract. + * + * Returns `undefined` when no vault contract is configured. When a contract ID + * is configured but has no matching verified registry entry, the returned + * entry is marked `verified: false` so consumers can visibly flag it. + */ +export function getVaultRegistryEntry( + registry: Partial>, +): VerifiedContractEntry | undefined { + const contractId = governableContractIds.vault; + if (!contractId) return undefined; + const network = env.NEXT_PUBLIC_STELLAR_NETWORK === "mainnet" ? "mainnet" : "testnet"; + const entries = registry[network] ?? []; + const match = entries.find((entry) => entry.contractId === contractId); + if (match) return match; + return { contractId, wasmHash: "", auditRevision: "", verified: false }; +}