diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index b48faa66..099cbb68 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -131,49 +131,15 @@ jobs: # 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 + 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" @@ -225,6 +191,20 @@ jobs: test-results retention-days: 14 + contract-drift: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: 20 + cache: npm + - run: npm ci + - name: Verify contract registry against on-chain code hashes + env: + STELLAR_RPC_URL: ${{ secrets.STELLAR_RPC_URL }} + run: npm run verify:contracts + lighthouse: name: Lighthouse (mobile perf ≥ 0.85, CLS < 0.05) runs-on: ubuntu-latest diff --git a/src/env.ts b/src/env.ts index b31c5db1..b17f0971 100644 --- a/src/env.ts +++ b/src/env.ts @@ -101,6 +101,44 @@ 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 }; +} + /** * Soroban RPC resilience configuration (issue #282). * diff --git a/src/lib/api/vaults.ts b/src/lib/api/vaults.ts index 181c8110..0fd12b33 100644 --- a/src/lib/api/vaults.ts +++ b/src/lib/api/vaults.ts @@ -8,6 +8,17 @@ import { apiPost, type RequestOptions } from "./client"; import { submitTransactionXdr } from "./stellar"; /** + * Vault transaction receipts client. + * + * 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. + * * Vaults REST contract — v1 (frozen). * * Owner: vaults. Counterpart: Zenith-options/backend. Any change to the wire @@ -169,6 +180,7 @@ import { submitTransactionXdr } from "./stellar"; * or "mock") selects the mock client backed by MOCK_VAULTS. Pages import only * from this module, so flipping the flag needs no page changes. */ +import { useWalletStore } from '@/lib/store/wallet'; export const VAULTS_API_VERSION = "v1" as const; const API = `/api/${VAULTS_API_VERSION}/vaults`; @@ -177,6 +189,41 @@ const DEFAULT_LIMIT = 50; const MAX_LIMIT = 100; const MAX_PAGES = 20; +export type VaultTxType = + | 'deposit' + | 'redemption_request' + | 'redemption' + | 'epoch_payout' + | 'fee_charge'; + +export type VaultTxStatus = + | 'pending' + | 'submitted' + | 'confirmed' + | 'failed' + | 'reconciled'; + +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; +} + // --------------------------------------------------------------------------- // Wire schemas (source of truth for the frozen contract) // --------------------------------------------------------------------------- @@ -639,145 +686,97 @@ export function fmtStrike(n: number) { : `$${n.toFixed(4)}`; } -/** One epoch record. */ -export interface EpochRecord { - epochNumber: number; - depositStart: string; // ISO date - activeStart: string; - settlementDate: string; - strike: number; - expiryDays: number; - premiumCollected: number; // per share, in USD - outcome: z.infer; - pnlPct: number; // epoch return, positive = profit for depositors +export interface VaultHistoryFilters { + vaultId?: string; + account?: 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 escrowed in unprocessed withdrawal requests +export interface VaultHistoryResult { + receipts: VaultReceipt[]; + /** True when the backend could not be reached and data is degraded. */ + degraded: boolean; } -export type WithdrawalStatus = "queued" | "processed" | "cancelled"; +const RECEIPTS_STORAGE_KEY = 'zenith.vault.receipts.v1'; -/** One redemption request in a vault's withdrawal queue. */ -export interface WithdrawalRequest { - id: string; - vaultId: string; - /** Epoch the request was made in. Earlier epochs are served first. */ - requestEpoch: number; - /** Monotonic tie-break within an epoch, assigned at request time. */ - seq: number; - /** ISO time of the original request. Kept when unfilled requests carry forward. */ - requestedAt: string; - requestedShares: number; - /** Shares still escrowed and not yet redeemed. */ - escrowedShares: number; - filledShares: number; - /** filledShares / requestedShares, 0–1. Updated on every processing pass. */ - fillFraction: number; - /** USD paid out, summed over every pass at that pass's processing NAV. */ - payoutUsd: number; - status: WithdrawalStatus; +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 []; + } } -/** State of the vault at the point where the withdrawal queue is processed. */ -export interface ProcessingPoint { - /** NAV per share at processing. Requests settle at this price. */ - navPerShare: number; - /** USD of liquid capital available to pay out. */ - liquidityUsd: number; +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. + } } /** - * One point in a hypothetical NAV-per-share series produced by a shadow - * run. Kept in its own namespace so it can never be read as live NAV. + * Persist a receipt keyed by its idempotency key. Re-submitting the same key + * updates the existing record instead of creating a duplicate. */ -export interface ShadowNavPoint { - epochNumber: number; - /** Hypothetical NAV per share in USD (paper trading only). */ - navPerShare: number; - /** NAV per share the strategy model expected for this epoch. */ - expectedNavPerShare: number; - /** ISO timestamp the shadow epoch settled. */ - recordedAt: string; +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); + } + writeLocalReceipts(existing); } /** - * Side-by-side comparison of a shadow run against live and expected - * performance. All shadow figures are paper trading and must be labeled - * as such in the UI. + * 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 interface ShadowComparison { - /** Latest hypothetical NAV per share from the shadow run. */ - shadowNavPerShare: number; - /** Latest realized NAV per share of the live vault (null if none). */ - liveNavPerShare: number | null; - /** NAV per share the strategy model expected for the latest epoch. */ - expectedNavPerShare: number; - /** Shadow minus live NAV per share (null when no live history). */ - shadowVsLiveDelta: number | null; - /** Shadow minus expected NAV per share. */ - shadowVsExpectedDelta: number; - /** Number of complete shadow epochs recorded so far. */ - completedEpochs: number; -} +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), + ); -/** - * Shadow (paper-trading) state for a vault strategy. Tracked in its own - * namespace, with its own hypothetical NAV series and epoch stream, so - * the registry, indexer, and this read model never conflate it with real - * balances. - */ -export interface ShadowState { - /** Always `shadow` — makes the paper-trading label explicit in payloads. */ - mode: "shadow"; - /** Hypothetical NAV-per-share series, one point per complete epoch. */ - navSeries: ShadowNavPoint[]; - /** Shadow epoch record stream (never merged into `historicalEpochs`). */ - epochs: EpochRecord[]; - /** Side-by-side comparison data for the operator console/analytics. */ - comparison: ShadowComparison; + 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 }; + } } +// --------------------------------------------------------------------------- +// Read-only diagnostics client (internal depositor support console) +// --------------------------------------------------------------------------- + /** - * Promotion criteria that must be met before a shadow strategy can be - * moved to live allocation. Evaluated by the backend promotion gate; the - * adapter surfaces the thresholds so the operator console can display - * progress and block promotion when unmet. + * The only lookup keys the diagnostics console accepts. Each maps to a single + * consolidated diagnostic view returned by the read-only backend endpoint. */ -export interface PromotionCriteria { - /** Minimum number of complete shadow epochs required. */ - minCompleteEpochs: number; - /** Max allowed |shadow − expected| NAV deviation per share (USD). */ - maxNavDeviation: number; - /** Whether any invariant violations were recorded during the run. */ - invariantViolations: number; -} - -export interface QueueState { - depositPendingUsd: number; - withdrawPendingShares: number; - settlesAt: string; // ISO -} - -export interface FeeAccrual { - managementAccruedUsd: number; - performanceAccruedUsd: number; - accruedThrough: string; // ISO -} - -export interface NavPoint { - at: string; // ISO - sharePrice: number; - totalShares: number; -} - -/** Full detail for a single vault. */ -export interface VaultDetail extends VaultSummary { - currentEpoch: number; - currentPhase: EpochPhase; - phaseEndsAt: string; // ISO — when the current phase +export type DiagnosticsLookup = + | { 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;