diff --git a/docs/vault-surfaces.md b/docs/vault-surfaces.md new file mode 100644 index 0000000..c5dc442 --- /dev/null +++ b/docs/vault-surfaces.md @@ -0,0 +1,118 @@ +# Vault Surfaces — Disambiguation Reference + +Two entirely separate vault surfaces exist in this codebase. They share no +code, no env vars, no routes, and no contract addresses. This document is the +single authoritative reference for telling them apart. + +--- + +## Quick-reference table + +| Dimension | Margin-Collateral Vault | Phase 2 Options Vaults | +|-----------|------------------------|------------------------| +| **Purpose** | Holds collateral that backs open option positions | Share-based, epoch-driven vaults that *write* options | +| **Instances** | One per network (global) | Many (registry) | +| **Source file** | `src/lib/api/vault.ts` (**singular**) | `src/lib/api/vaults.ts` (**plural**) | +| **Env var** | `NEXT_PUBLIC_VAULT_CONTRACT_ID` | `NEXT_PUBLIC_VAULTS=mock\|live` | +| **Contract** | Deployed Soroban contract (C…) | `contracts/vault` — not yet deployed | +| **BFF base path** | `/api/v1/onchain/*` | `/api/v1/vaults/*` | +| **App route** | `/vault` (balance panel) | `/vaults` (listing + detail) | +| **Signers** | Freighter → VaultWalletAdapter (#252) | VaultWalletAdapter (#252) | +| **Data mode** | Always live (or throws if unconfigured) | `NEXT_PUBLIC_VAULTS=mock` (default) / `live` | +| **Contract crate** | N/A (pre-existing) | `zenith-options-vault` (`contracts/vault/`) | + +--- + +## 1. Margin-Collateral Vault (`vault.ts` — singular) + +This vault is **already deployed**. It accepts user collateral and tracks how +much of it is locked against open positions. + +### What it does + +- `depositToVault(address, amount, session, idempotencyKey)` — builds an XDR + via `POST /api/v1/onchain/build-deposit`, signs it, submits, and confirms. +- `withdrawFromVault(address, amount, session, freeCollateral, idempotencyKey)` — + same flow via `POST /api/v1/onchain/build-withdraw`. Validates amount ≤ + free collateral before building. +- `fetchVaultHistory(address)` — reads deposit/withdraw events from Horizon's + contract-events endpoint. + +### Env vars + +| Var | Required | Purpose | +|-----|----------|---------| +| `NEXT_PUBLIC_VAULT_CONTRACT_ID` | **Yes** | Soroban contract address (C…) | +| `NEXT_PUBLIC_STELLAR_NETWORK` | No (defaults `testnet`) | Picks network passphrase | +| `NEXT_PUBLIC_HORIZON_URL` | No (defaults testnet URL) | Horizon for event history | + +> **Do not** use `NEXT_PUBLIC_CONTRACT_VAULT_TESTNET` / `NEXT_PUBLIC_CONTRACT_VAULT_MAINNET` +> for this module. Those keys are wired to the governance contract registry +> (`src/env.ts → governableContractIds.vault`) and select the Phase 2 vault +> contract at the relevant network boundary. The margin-collateral vault has +> its own key (`NEXT_PUBLIC_VAULT_CONTRACT_ID`) to keep the two paths +> independently configurable. Tracking issue: F-10 in +> `docs/security/vault-threat-model.md`. + +--- + +## 2. Phase 2 Options Vaults (`vaults.ts` — plural) + +These vaults are **not yet deployed**. The frontend adapter is wired to mock +data by default; setting `NEXT_PUBLIC_VAULTS=live` switches to the real BFF +(and will throw until the backend is live). + +### What it does + +- Exposes a typed registry of option-writing vaults: `fetchVaults()`, + `fetchVaultDetail(id)`. +- Epoch-driven: each vault cycles through `deposit → active → settlement` + phases; `EpochRecord` tracks strike, premium, outcome, and P&L. +- Share-based NAV: depositors hold shares priced at `sharePrice` (USD/share); + fees are taken as performance and management basis points. +- Multi-asset collateral: each vault declares one `CollateralAsset` at + creation (USDC, XLM, etc.). + +### Env vars + +| Var | Required | Purpose | +|-----|----------|---------| +| `NEXT_PUBLIC_VAULTS` | No (defaults `mock`) | `mock` = fixture data; `live` = real BFF | + +### Mock vs live + +``` +NEXT_PUBLIC_VAULTS=mock → VAULT_FIXTURE_DATA from src/lib/fixtures/vaults.ts +NEXT_PUBLIC_VAULTS=live → GET /api/v1/vaults (requires deployed backend) +``` + +Mock data is defined in `src/lib/fixtures/vaults.ts` (extracted from the +inline blob — see issue #254). It is the single source of truth for local dev, +unit tests, Storybook stories, and the Playwright mock backend. + +--- + +## How to avoid common mistakes + +| Mistake | How to catch it | +|---------|----------------| +| Importing from `vault` instead of `vaults` (or vice versa) | Check the use-case: collateral balance = `vault.ts`; epoch/share product = `vaults.ts` | +| Using `NEXT_PUBLIC_VAULT_CONTRACT_ID` for the options vault | Wrong — options vaults do not have a single contract ID in the frontend | +| Hardcoding vault addresses | Use env vars or the fixture layer; never commit a contract address into logic | +| Adding mock data inline to a page | Put it in `src/lib/fixtures/vaults.ts` instead (issue #254) | + +--- + +## Contract scaffold (`contracts/vault/`) + +The Rust/Soroban contract for Phase 2 options vaults lives in `contracts/vault/` +(crate: `zenith-options-vault`). Shared types are in `contracts/vault-common/` +(crate: `zenith-options-vault-common`). + +Current scope: `initialize`, `pause`, `unpause`, `admin_transfer`, `upgrade`, +`advance_epoch`. Deposits, withdrawals, NAV math, and the backend API are **out +of scope** for the current scaffold — see `contracts/README.md`. + +--- + +*Last updated: issues #251, #252, #253, #254* diff --git a/src/lib/api/vault.ts b/src/lib/api/vault.ts index cedec79..db10b2f 100644 --- a/src/lib/api/vault.ts +++ b/src/lib/api/vault.ts @@ -1,24 +1,47 @@ /** - * Vault on-chain operations: deposit collateral into the Zenith vault - * contract and withdraw free collateral back to the wallet. + * @module vault (MARGIN-COLLATERAL VAULT — single instance) * - * This is the margin-collateral vault (NEXT_PUBLIC_VAULT_CONTRACT_ID). It is - * unrelated to the Phase 2 options vault scaffolded in `contracts/vault` - * (wired later via `./vaults.ts`). + * On-chain operations for the Zenith **margin-collateral vault**: deposit + * collateral and withdraw free collateral back to the wallet. * - * Both flows use the same pattern: - * 1. Ask the backend to build a Soroban transaction XDR. - * 2. Ask Freighter to sign it. - * 3. Submit to Horizon/Soroban RPC. - * 4. Return the confirmed transaction hash. + * ┌──────────────────────────────────────────────────────────────────────────┐ + * │ SURFACE DISAMBIGUATION — READ THIS BEFORE TOUCHING EITHER FILE │ + * │ │ + * │ There are TWO unrelated vault surfaces in this codebase: │ + * │ │ + * │ 1. MARGIN-COLLATERAL VAULT ← YOU ARE HERE │ + * │ • File : src/lib/api/vault.ts (singular) │ + * │ • Env : NEXT_PUBLIC_VAULT_CONTRACT_ID │ + * │ • Purpose: Holds user collateral that backs open option positions. │ + * │ One contract, one global instance per network. │ + * │ • BFF : POST /api/v1/onchain/build-deposit │ + * │ POST /api/v1/onchain/build-withdraw │ + * │ │ + * │ 2. PHASE 2 OPTIONS VAULTS ← src/lib/api/vaults.ts (plural) │ + * │ • File : src/lib/api/vaults.ts (plural) │ + * │ • Env : NEXT_PUBLIC_VAULTS=mock|live │ + * │ • Purpose: Share-based, epoch-driven option-writing vaults. │ + * │ Multiple instances; registry exposed via /api/v1/vaults. │ + * │ • BFF : GET/POST /api/v1/vaults/* │ + * │ │ + * │ Full reference: docs/vault-surfaces.md │ + * └──────────────────────────────────────────────────────────────────────────┘ + * + * Signing: currently uses Freighter directly (`freighterApi.signTransaction`). + * For multi-wallet support (xBull, Ledger, WalletConnect) see the + * `VaultWalletAdapter` in src/lib/api/vaultWalletAdapter.ts and issue #252. + * + * Transaction flow: + * 1. Call the BFF to build a Soroban transaction XDR (amounts computed + * server-side; client never constructs the transaction itself). + * 2. Sign the XDR via the wallet adapter. + * 3. Submit to Horizon/Soroban RPC via submitTransactionXdr. + * 4. Ask the BFF to confirm ledger inclusion and re-read state. * * The contract address lives in NEXT_PUBLIC_VAULT_CONTRACT_ID; if not * set the helpers throw so the UI can surface a clear error rather than * sending garbage. * - * History comes from Horizon contract-event filtering; we translate those - * into a lightweight VaultEvent shape for the UI. - * * Resilience (#282): the BFF owns the Soroban RPC provider pool, failover, * batching, and read-after-write confirmation. This module surfaces the * freshness/confirmation status the BFF returns so the UI can render @@ -26,10 +49,13 @@ */ import { submitTransactionXdr } from "./stellar"; -import freighterApi from "@stellar/freighter-api"; import { Networks, TransactionBuilder } from "@stellar/stellar-sdk"; import { bffFetch } from "./client"; import { IDEMPOTENCY_HEADER } from "./resilience/idempotency"; +import { + createFreighterAdapter, + type VaultWalletAdapter, +} from "./vaultWalletAdapter"; export const VAULT_CONTRACT_ID = process.env.NEXT_PUBLIC_VAULT_CONTRACT_ID ?? null; @@ -171,18 +197,25 @@ async function submitSignedVaultTx( } /** - * Asks the backend to build an XDR for a vault deposit, has Freighter sign - * it, submits to Horizon, and returns the tx hash. + * Asks the backend to build an XDR for a vault deposit, signs it via the + * provided wallet adapter (Freighter, xBull, Ledger, or WalletConnect), and + * submits it to Horizon. * * `idempotencyKey` identifies this user intent (see useIdempotencyKey). Reusing * it on a retry makes the backend return the original build, so a resubmit * can't create a second deposit. + * + * `adapter` defaults to a Freighter adapter for backward compatibility. Pass + * the result of `createXBullAdapter`, `createLedgerAdapter`, or + * `createWalletConnectAdapter` (all from `./vaultWalletAdapter`) to sign with + * a different wallet. */ export async function depositToVault( address: string, amount: number, _session: string, - idempotencyKey: string + idempotencyKey: string, + adapter?: VaultWalletAdapter ): Promise { if (!VAULT_CONTRACT_ID) { throw new Error( @@ -190,6 +223,9 @@ export async function depositToVault( ); } + const signer = + adapter ?? createFreighterAdapter(address, NETWORK_PASSPHRASE); + // Authenticated by the BFF session cookie (#118). const buildRes = await bffFetch("/api/v1/onchain/build-deposit", { method: "POST", @@ -208,9 +244,7 @@ export async function depositToVault( } const { xdr } = (await buildRes.json()) as { xdr: string }; - const signed = await freighterApi.signTransaction(xdr, { - accountToSign: address, - }); + const signed = await signer.signTransaction(xdr); const { hash, confirmation } = await submitSignedVaultTx(signed, "deposit"); return { @@ -228,13 +262,17 @@ export async function depositToVault( * Validates against the backend's free-collateral figure before building the * transaction so the user gets an early rejection rather than an on-chain * failure. `idempotencyKey` works as in `depositToVault`. + * + * `adapter` defaults to a Freighter adapter for backward compatibility. See + * `depositToVault` for the multi-wallet pattern. */ export async function withdrawFromVault( address: string, amount: number, _session: string, freeCollateral: number, - idempotencyKey: string + idempotencyKey: string, + adapter?: VaultWalletAdapter ): Promise { if (!VAULT_CONTRACT_ID) { throw new Error( @@ -249,6 +287,9 @@ export async function withdrawFromVault( ); } + const signer = + adapter ?? createFreighterAdapter(address, NETWORK_PASSPHRASE); + // Authenticated by the BFF session cookie (#118). const buildRes = await bffFetch("/api/v1/onchain/build-withdraw", { method: "POST", @@ -267,9 +308,7 @@ export async function withdrawFromVault( } const { xdr } = (await buildRes.json()) as { xdr: string }; - const signed = await freighterApi.signTransaction(xdr, { - accountToSign: address, - }); + const signed = await signer.signTransaction(xdr); const { hash, confirmation } = await submitSignedVaultTx(signed, "withdraw"); return { diff --git a/src/lib/api/vaultBootstrap.ts b/src/lib/api/vaultBootstrap.ts new file mode 100644 index 0000000..d064753 --- /dev/null +++ b/src/lib/api/vaultBootstrap.ts @@ -0,0 +1,481 @@ +/** + * @module vaultBootstrap + * + * Treasury bootstrap, genesis epoch funding, and minimum-liquidity policy + * for new option-writing vaults (issue #253). + * + * Problem statement + * ----------------- + * A vault with zero or dust TVL cannot write a meaningful position: + * - It cannot cover assignment. + * - Its NAV-per-share math is dominated by rounding error. + * - The first depositor carries outsized vault-creation risk. + * + * Without an explicit bootstrap policy, operators improvise funding, which + * blurs the line between treasury capital and depositor capital and makes + * fee/NAV accounting ambiguous from epoch one. + * + * Design + * ------ + * Bootstrap has three distinct phases: + * + * PHASE 1 — TREASURY_SEEDING + * The treasury seeds the vault with initial capital. This capital is + * tracked separately from depositor capital via `TreasuryContribution` + * records so it can be excluded from depositor fee calculations and + * unwound cleanly at any point. + * + * PHASE 2 — GENESIS_EPOCH + * Epoch zero runs with no strategy execution. It exists purely to: + * a) Allow public deposits to reach the minimum funding threshold. + * b) Establish the initial NAV-per-share baseline (1.000). + * c) Give the operator time to configure strategy parameters before + * real capital is at risk. + * `advance_epoch` is blocked until `minFundingThresholdUsd` is met. + * + * PHASE 3 — LIVE + * The vault begins writing options. Treasury seed is either: + * a) Withdrawn (if it was temporary "priming" capital). + * b) Retained as a permanent buffer (operator decision). + * + * All state is stored per-vault and mutable only via the timelocked operator + * path. No bootstrap parameters are hardcoded. + * + * This module is frontend-only: it defines the types the BFF exposes and + * the functions that call those endpoints. On-chain enforcement lives in + * the `zenith-options-vault` contract. + */ + +// --------------------------------------------------------------------------- +// Bootstrap phases +// --------------------------------------------------------------------------- + +/** + * Lifecycle phase of a vault's bootstrap process. + * + * treasury_seeding — vault created; treasury is depositing seed capital. + * Public deposits are accepted but strategy is locked. + * genesis_epoch — seed committed; epoch zero running with no strategy. + * Deposits open; waiting for minFundingThreshold. + * threshold_met — minFundingThreshold reached; operator may advance epoch. + * live — first strategy epoch started; bootstrap complete. + */ +export type BootstrapPhase = + | "treasury_seeding" + | "genesis_epoch" + | "threshold_met" + | "live"; + +// --------------------------------------------------------------------------- +// Treasury contribution +// --------------------------------------------------------------------------- + +/** + * One treasury seed deposit into a vault. + * + * Treasury contributions are accounted separately from depositor deposits so + * that: + * - Performance fees are not charged on treasury capital. + * - NAV-per-share dilution caused by the seed is clearly attributable. + * - The seed can be unwound without affecting depositor accounting. + */ +export interface TreasuryContribution { + /** Client-generated idempotency key. */ + idempotencyKey: string; + /** Vault this contribution belongs to. */ + vaultId: string; + /** Stellar address of the treasury account (G…). */ + treasuryAddress: string; + /** Amount in USD (decimal string, e.g. "50000.00"). */ + amountUsd: string; + /** Collateral asset code, e.g. "USDC". */ + assetCode: string; + /** ISO timestamp of the on-chain deposit. */ + depositedAt: string; + /** Stellar transaction hash. */ + txHash: string; + /** + * Withdrawal intent: null while the seed is retained. + * Set when the operator schedules a seed withdrawal. + */ + scheduledWithdrawalAt: string | null; + /** Whether the treasury seed has been fully withdrawn. */ + withdrawn: boolean; +} + +// --------------------------------------------------------------------------- +// Bootstrap configuration +// --------------------------------------------------------------------------- + +/** + * Per-vault bootstrap policy. Stored at vault creation and mutable only via + * the timelocked operator path (`submitParameterChange`). + * + * None of these values are hardcoded. Operators set them at vault + * initialization; the frontend surfaces them so depositors can see the + * bootstrap terms before committing capital. + */ +export interface VaultBootstrapConfig { + /** + * Minimum total TVL (USD) required before the genesis epoch can end + * and the vault can begin writing options. + * + * The BFF's `advance_epoch` route checks this before building the rollover + * transaction and returns `below_minimum_funding` if unmet. + */ + minFundingThresholdUsd: number; + + /** + * Amount of treasury seed capital committed to this vault (USD). + * Included in TVL but tracked separately for fee/NAV accounting. + */ + treasurySeedUsd: number; + + /** + * Whether the treasury seed is temporary (withdrawn after `minFundingThresholdUsd` + * is met by public deposits) or permanent (retained as an operator buffer). + */ + seedRetentionPolicy: "temporary" | "permanent"; + + /** + * ISO timestamp after which the genesis epoch is force-closed even if + * `minFundingThresholdUsd` has not been met, and the vault moves to + * `winding_down`. Prevents indefinite limbo. + */ + genesisEpochDeadline: string; + + /** + * Number of ledgers the BFF waits before retrying a failed genesis epoch + * rollover (e.g. when the Soroban RPC is congested). + */ + rolloverRetryWindowLedgers: number; + + /** + * Initial share price at vault creation (USD). Almost always 1.000000. + * Stored here so the UI can show depositors what they are buying into + * before any NAV movement occurs. + */ + genesisSharePrice: number; +} + +// --------------------------------------------------------------------------- +// Bootstrap status (live read from BFF) +// --------------------------------------------------------------------------- + +/** + * Current bootstrap status for a vault. Returned by + * `GET /api/v1/vaults/{id}/bootstrap`. + */ +export interface VaultBootstrapStatus { + vaultId: string; + phase: BootstrapPhase; + config: VaultBootstrapConfig; + /** Current TVL including treasury seed (USD). */ + currentTvlUsd: number; + /** Public (non-treasury) TVL (USD). */ + publicTvlUsd: number; + /** + * Funding progress as a fraction 0–1. + * `currentTvlUsd / config.minFundingThresholdUsd`, capped at 1. + */ + fundingProgress: number; + /** + * Whether the vault can currently advance to the first strategy epoch. + * True only when phase === "threshold_met" and no blocking conditions exist. + */ + canAdvanceEpoch: boolean; + /** + * Human-readable reason why `canAdvanceEpoch` is false, or null when it + * is true. Surfaced directly in the operator console. + */ + blockingReason: string | null; + /** Treasury contributions recorded for this vault. */ + treasuryContributions: TreasuryContribution[]; + /** ISO timestamp of the last status read. */ + fetchedAt: string; +} + +// --------------------------------------------------------------------------- +// Blocking condition codes (machine-readable) +// --------------------------------------------------------------------------- + +/** + * Structured reason codes returned when `advance_epoch` is blocked during + * the bootstrap phase. + * + * below_minimum_funding — TVL has not yet reached minFundingThresholdUsd. + * genesis_epoch_active — The genesis epoch is still running and has not + * been manually advanced by the operator. + * treasury_seed_pending — Treasury seed transaction is still pending + * on-chain confirmation. + * vault_paused — Vault is paused; unpausing requires guardian auth. + * deadline_exceeded — genesisEpochDeadline has passed; vault must be + * wound down rather than promoted. + */ +export type BootstrapBlockingCode = + | "below_minimum_funding" + | "genesis_epoch_active" + | "treasury_seed_pending" + | "vault_paused" + | "deadline_exceeded"; + +export interface BootstrapBlockingCondition { + code: BootstrapBlockingCode; + message: string; + /** For `below_minimum_funding`: how much more is needed in USD. */ + shortfallUsd?: number; + /** For `deadline_exceeded`: how long ago the deadline passed. */ + deadlineExceededBy?: string; +} + +// --------------------------------------------------------------------------- +// API request / response shapes +// --------------------------------------------------------------------------- + +/** + * Request body for `POST /api/v1/vaults/{id}/treasury-seed`. + * + * Initiates a treasury seed deposit. The BFF builds a Soroban XDR for + * the deposit; the treasury signer signs and submits it through the + * standard vault signing flow (see `vault.ts` / `vaultWalletAdapter.ts`). + */ +export interface TreasurySeedRequest { + /** Client-generated idempotency key. */ + idempotencyKey: string; + /** Amount in USD to seed. */ + amountUsd: number; + /** Collateral asset code (must match the vault's declared collateral). */ + assetCode: string; + /** Treasury wallet address — must be authorized in `SIGNER_KEY_TREASURY`. */ + treasuryAddress: string; +} + +export interface TreasurySeedResponse { + /** XDR to be signed by the treasury wallet. */ + xdr: string; + /** Idempotency key echoed back. */ + idempotencyKey: string; + /** Projected TVL after this seed (USD). */ + projectedTvlUsd: number; + /** Whether this seed alone meets the minimum funding threshold. */ + meetsThreshold: boolean; +} + +/** + * Request body for `POST /api/v1/vaults/{id}/advance-genesis`. + * + * Advances the vault from genesis epoch to the first live epoch. + * Blocked if any `BootstrapBlockingCondition` is active. + */ +export interface AdvanceGenesisRequest { + /** Operator idempotency key. */ + idempotencyKey: string; + /** + * Explicit operator acknowledgement that public deposits will be at risk + * from the next epoch onward. + */ + operatorAck: true; +} + +export interface AdvanceGenesisResponse { + /** New epoch number (always 1 for the first live epoch). */ + epochNumber: number; + /** XDR to be signed by the operator key. */ + xdr: string; + /** Projected share price at epoch start (should equal genesisSharePrice). */ + sharePrice: number; +} + +// --------------------------------------------------------------------------- +// Pure helpers +// --------------------------------------------------------------------------- + +/** + * Computes the funding progress fraction for display. + * + * Always returns a value in [0, 1]; caps at 1 even when TVL exceeds the + * threshold (over-funding is allowed and not shown as > 100%). + */ +export function computeFundingProgress( + currentTvlUsd: number, + minFundingThresholdUsd: number +): number { + if (minFundingThresholdUsd <= 0) return 1; + return Math.min(1, currentTvlUsd / minFundingThresholdUsd); +} + +/** + * Returns true when the genesis deadline has passed. + * + * @param deadline ISO string from `VaultBootstrapConfig.genesisEpochDeadline` + * @param now Optional override for the current time (defaults to `Date.now()`). + * Inject in tests to keep them deterministic. + */ +export function isGenesisDeadlineExceeded( + deadline: string, + now: number = Date.now() +): boolean { + const ts = Date.parse(deadline); + if (Number.isNaN(ts)) return false; // malformed date — do not block + return now > ts; +} + +/** + * Derives the blocking conditions that apply to the current bootstrap status. + * + * Used by the operator console to explain why "Advance to Live" is greyed out, + * and by the BFF adapter layer to translate wire-format `blockingReason` + * strings into structured codes. + */ +export function deriveBlockingConditions( + status: Pick< + VaultBootstrapStatus, + "phase" | "currentTvlUsd" | "config" + > +): BootstrapBlockingCondition[] { + const conditions: BootstrapBlockingCondition[] = []; + const { phase, currentTvlUsd, config } = status; + + if (phase === "treasury_seeding") { + conditions.push({ + code: "treasury_seed_pending", + message: "Waiting for treasury seed transaction to confirm on-chain.", + }); + } + + if (phase === "genesis_epoch" || phase === "treasury_seeding") { + conditions.push({ + code: "genesis_epoch_active", + message: + "Genesis epoch is active. Advance the epoch once minimum funding is reached.", + }); + } + + if (currentTvlUsd < config.minFundingThresholdUsd) { + const shortfallUsd = config.minFundingThresholdUsd - currentTvlUsd; + conditions.push({ + code: "below_minimum_funding", + message: `TVL is $${currentTvlUsd.toLocaleString()} — need $${config.minFundingThresholdUsd.toLocaleString()} to advance (shortfall: $${shortfallUsd.toLocaleString()}).`, + shortfallUsd, + }); + } + + if (isGenesisDeadlineExceeded(config.genesisEpochDeadline)) { + conditions.push({ + code: "deadline_exceeded", + message: `Genesis epoch deadline (${config.genesisEpochDeadline}) has passed. The vault must be wound down.`, + deadlineExceededBy: new Date().toISOString(), + }); + } + + return conditions; +} + +// --------------------------------------------------------------------------- +// BFF adapter functions +// --------------------------------------------------------------------------- + +/** + * Fetches the current bootstrap status for a vault. + * + * Returns `null` when the vault has already passed the bootstrap phase + * (`phase === "live"`) and the bootstrap endpoint is no longer available. + */ +export async function fetchBootstrapStatus( + vaultId: string +): Promise { + const res = await fetch(`/api/v1/vaults/${encodeURIComponent(vaultId)}/bootstrap`); + if (res.status === 404) return null; // vault is live; no bootstrap state + if (!res.ok) { + const body = await res.json().catch(() => null); + throw new Error( + (body as { error?: string } | null)?.error ?? + `fetchBootstrapStatus failed (${res.status})` + ); + } + return res.json() as Promise; +} + +/** + * Initiates a treasury seed deposit for a vault. + * + * Returns the unsigned XDR that the treasury wallet must sign and submit + * through the standard vault signing flow (`depositToVault` in `vault.ts` + * or the equivalent in `vaults.ts`). + */ +export async function buildTreasurySeed( + vaultId: string, + request: TreasurySeedRequest +): Promise { + const res = await fetch( + `/api/v1/vaults/${encodeURIComponent(vaultId)}/treasury-seed`, + { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify(request), + } + ); + if (!res.ok) { + const body = await res.json().catch(() => null); + throw new Error( + (body as { error?: string } | null)?.error ?? + `buildTreasurySeed failed (${res.status})` + ); + } + return res.json() as Promise; +} + +/** + * Builds the genesis epoch rollover transaction. + * + * The BFF validates all blocking conditions before building. If any condition + * is active it throws with the `blockingReason` from the response. + */ +export async function buildAdvanceGenesis( + vaultId: string, + request: AdvanceGenesisRequest +): Promise { + const res = await fetch( + `/api/v1/vaults/${encodeURIComponent(vaultId)}/advance-genesis`, + { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify(request), + } + ); + if (!res.ok) { + const body = await res.json().catch(() => null); + throw new Error( + (body as { error?: string } | null)?.error ?? + `buildAdvanceGenesis failed (${res.status})` + ); + } + return res.json() as Promise; +} + +// --------------------------------------------------------------------------- +// Default bootstrap config (used by the vault creation wizard) +// --------------------------------------------------------------------------- + +/** + * Sensible defaults for a new vault's bootstrap config. + * + * These are starting points for the operator wizard UI — all values must be + * explicitly confirmed before the vault is created. Nothing is hardcoded in + * contract storage; these defaults are frontend-only convenience values. + */ +export const DEFAULT_BOOTSTRAP_CONFIG: Omit< + VaultBootstrapConfig, + "genesisEpochDeadline" +> = { + /** $10,000 minimum — enough to write a meaningful position on most underlyings. */ + minFundingThresholdUsd: 10_000, + /** $5,000 seed — half the minimum, so public deposits can meet the other half. */ + treasurySeedUsd: 5_000, + /** Temporary: withdraw seed once public deposits exceed the threshold. */ + seedRetentionPolicy: "temporary", + /** Initial share price of exactly $1.00. */ + genesisSharePrice: 1.0, + /** 10 ledgers (~50 seconds) retry window for rollover congestion. */ + rolloverRetryWindowLedgers: 10, +}; diff --git a/src/lib/api/vaultWalletAdapter.ts b/src/lib/api/vaultWalletAdapter.ts new file mode 100644 index 0000000..4d44b22 --- /dev/null +++ b/src/lib/api/vaultWalletAdapter.ts @@ -0,0 +1,331 @@ +/** + * @module vaultWalletAdapter + * + * Multi-wallet signing adapter for vault transactions (issue #252). + * + * Abstracts the wallet signing path behind a single `VaultWalletAdapter` + * interface so vault flows (both the margin-collateral vault in `vault.ts` + * and the Phase 2 options vaults in `vaults.ts`) can work with any connected + * wallet without caring about the underlying SDK. + * + * Supported wallets: + * • Freighter — browser extension; signs via `@stellar/freighter-api` + * • xBull — browser extension; signs via `@creit.tech/stellar-wallets-kit` + * • WalletConnect — mobile / remote; signs via `@creit.tech/stellar-wallets-kit` + * • Ledger — hardware; signs via `@creit.tech/stellar-wallets-kit` + * + * Usage: + * const adapter = createFreighterAdapter(address, networkPassphrase); + * const signedXdr = await adapter.signTransaction(unsignedXdr); + * + * The `WalletSigner` interface from `src/lib/soroban/types.ts` is the + * canonical extension point for the governance pipeline. `VaultWalletAdapter` + * is a narrower, vault-specific surface that maps onto it. + * + * Ledger note: Ledger signing is synchronous from the app's perspective + * (the kit handles the device handshake internally), but it can be slow. + * The vault UI should surface a "Confirm on device" banner while awaiting. + */ + +import type { WalletSigner } from "../soroban/types"; + +// --------------------------------------------------------------------------- +// Wallet type enum +// --------------------------------------------------------------------------- + +export type VaultWalletType = "freighter" | "xbull" | "walletconnect" | "ledger"; + +// --------------------------------------------------------------------------- +// Adapter interface +// --------------------------------------------------------------------------- + +/** + * Minimal signing surface consumed by the vault transaction builders. + * + * This is intentionally narrower than `WalletSigner`: vault flows only need + * `signTransaction` and the signer's address; message signing is handled by + * the sign-in layer, not the vault layer. + */ +export interface VaultWalletAdapter { + /** Which wallet this adapter wraps. */ + readonly walletType: VaultWalletType; + /** Stellar public key (G…) of the connected account. */ + readonly address: string; + /** Network passphrase (needed by some wallets to scope the signature). */ + readonly networkPassphrase: string; + /** + * Sign a Soroban transaction XDR. Returns the signed XDR. + * + * Implementations MUST: + * - Return the signed XDR with exactly one new signature appended. + * - NOT modify the transaction structure in any other way. + * - Throw a `WalletSigningError` on user rejection or device failure. + */ + signTransaction(xdr: string): Promise; +} + +// --------------------------------------------------------------------------- +// Error types +// --------------------------------------------------------------------------- + +/** + * Thrown when the user rejects a signing request or the wallet/device + * returns an error. `walletType` lets callers show a wallet-specific message. + */ +export class WalletSigningError extends Error { + readonly walletType: VaultWalletType; + readonly cause: unknown; + + constructor(walletType: VaultWalletType, message: string, cause?: unknown) { + super(message); + this.name = "WalletSigningError"; + this.walletType = walletType; + this.cause = cause; + } +} + +/** + * Thrown when `createAdapterFromSigner` receives a `WalletSigner` whose + * wallet type cannot be determined. In practice this means the signer was + * constructed outside the factory and lacks the `__walletType` tag. + */ +export class UnknownWalletTypeError extends Error { + constructor() { + super( + "Cannot determine wallet type from WalletSigner. Use a factory function " + + "(createFreighterAdapter, createXBullAdapter, etc.) to build adapters." + ); + this.name = "UnknownWalletTypeError"; + } +} + +// --------------------------------------------------------------------------- +// Freighter adapter +// --------------------------------------------------------------------------- + +/** + * Creates a VaultWalletAdapter backed by Freighter (`@stellar/freighter-api`). + * + * Freighter must be installed and the user must have already granted permission + * to this origin. The adapter throws a `WalletSigningError` when the user + * rejects the signing request or when Freighter is not available. + */ +export function createFreighterAdapter( + address: string, + networkPassphrase: string +): VaultWalletAdapter { + return { + walletType: "freighter", + address, + networkPassphrase, + async signTransaction(xdr: string): Promise { + // Dynamic import keeps the Freighter SDK out of bundles that never use it. + const freighterApi = await import("@stellar/freighter-api").then( + (m) => m.default ?? m + ); + try { + const signed = await freighterApi.signTransaction(xdr, { + accountToSign: address, + }); + // freighterApi.signTransaction returns the signed XDR string directly. + if (typeof signed !== "string" || !signed) { + throw new WalletSigningError( + "freighter", + "Freighter returned an empty or invalid signed XDR." + ); + } + return signed; + } catch (err) { + if (err instanceof WalletSigningError) throw err; + const msg = + err instanceof Error ? err.message : "Freighter signing failed."; + // Freighter rejection messages include "User declined" — surface that + // so the UI can show a friendly "You rejected the request" message. + throw new WalletSigningError("freighter", msg, err); + } + }, + }; +} + +// --------------------------------------------------------------------------- +// StellarWalletsKit-backed adapters (xBull, WalletConnect, Ledger) +// --------------------------------------------------------------------------- + +/** + * Internal factory: creates a VaultWalletAdapter using `@creit.tech/stellar-wallets-kit` + * with the specified wallet module ID already set. + * + * The kit must already have been initialised with the correct wallet module + * (`kit.setWallet(walletId)`) before transactions can be signed. The factory + * calls `setWallet` internally so callers do not need to manage that step. + */ +async function createKitAdapter( + walletType: VaultWalletType, + kitWalletId: string, + address: string, + networkPassphrase: string +): Promise { + // Dynamic import: stellar-wallets-kit is only loaded when actually needed. + const kit = await import("@creit.tech/stellar-wallets-kit").then( + (m) => m.StellarWalletsKit ?? m.default?.StellarWalletsKit + ); + + if (!kit) { + throw new WalletSigningError( + walletType, + "@creit.tech/stellar-wallets-kit is not available. Make sure it is installed." + ); + } + + // Activate the correct wallet module in the kit instance. + // `setWallet` is synchronous in the kit; it only changes the active slot. + if (typeof kit.setWallet === "function") { + kit.setWallet(kitWalletId); + } + + return { + walletType, + address, + networkPassphrase, + async signTransaction(xdr: string): Promise { + try { + const result = await kit.signTransaction(xdr, { + address, + networkPassphrase, + }); + // Kit returns { signedTxXdr: string } or the XDR string directly. + const signed = + typeof result === "string" + ? result + : (result as { signedTxXdr?: string }).signedTxXdr; + + if (typeof signed !== "string" || !signed) { + throw new WalletSigningError( + walletType, + `${walletType} returned an empty or invalid signed XDR.` + ); + } + return signed; + } catch (err) { + if (err instanceof WalletSigningError) throw err; + const msg = + err instanceof Error + ? err.message + : `${walletType} signing failed.`; + throw new WalletSigningError(walletType, msg, err); + } + }, + }; +} + +/** + * Creates a VaultWalletAdapter backed by xBull. + * + * xBull must be installed as a browser extension. The kit's `xBullModule` + * handles the cross-origin signing handshake. + */ +export async function createXBullAdapter( + address: string, + networkPassphrase: string +): Promise { + const { XBULL_ID } = await import("@creit.tech/stellar-wallets-kit"); + return createKitAdapter("xbull", XBULL_ID, address, networkPassphrase); +} + +/** + * Creates a VaultWalletAdapter backed by WalletConnect. + * + * WalletConnect requires a prior `connect()` call to establish the session. + * This factory assumes the session is already established and a wallet address + * has been resolved. + */ +export async function createWalletConnectAdapter( + address: string, + networkPassphrase: string +): Promise { + // WalletConnect uses the generic kit interface; the kit's WalletConnect + // module handles the session lifecycle. The wallet ID varies by kit version — + // use the string literal "wallet_connect" as the canonical slot identifier. + return createKitAdapter( + "walletconnect", + "wallet_connect", + address, + networkPassphrase + ); +} + +/** + * Creates a VaultWalletAdapter backed by a Ledger hardware wallet. + * + * The kit's Ledger module communicates with the device over WebUSB/WebHID. + * The device must be unlocked, have the Stellar app open, and be connected + * before `signTransaction` is called. + * + * Because device interactions can be slow (user must confirm on the device), + * the UI should display a "Confirm on your Ledger" prompt before awaiting + * this adapter's `signTransaction` call. + */ +export async function createLedgerAdapter( + address: string, + networkPassphrase: string +): Promise { + // Ledger uses the generic kit interface; the kit's Ledger module handles + // the WebUSB/WebHID transport and the Stellar app protocol. + return createKitAdapter( + "ledger", + "ledger", + address, + networkPassphrase + ); +} + +// --------------------------------------------------------------------------- +// Bridge: WalletSigner → VaultWalletAdapter +// --------------------------------------------------------------------------- + +/** + * Wraps an existing `WalletSigner` (from the Soroban governance pipeline) in + * a `VaultWalletAdapter`. This lets vault flows reuse a signer that was + * already constructed for governance transactions without re-prompting the + * user to connect. + * + * The `walletType` falls back to `"freighter"` when the signer was not built + * by one of the kit factories — this is safe because Freighter is the only + * wallet currently wired into the governance pipeline. + */ +export function createAdapterFromSigner( + signer: WalletSigner +): VaultWalletAdapter { + // Tag injected by the kit factories; absent for hand-constructed signers. + const walletType: VaultWalletType = + (signer as { __walletType?: VaultWalletType }).__walletType ?? "freighter"; + + return { + walletType, + address: signer.address, + networkPassphrase: signer.networkPassphrase, + signTransaction: (xdr: string) => signer.signTransaction(xdr), + }; +} + +// --------------------------------------------------------------------------- +// Human-readable wallet labels (for UI display) +// --------------------------------------------------------------------------- + +export const WALLET_LABELS: Record = { + freighter: "Freighter", + xbull: "xBull", + walletconnect: "WalletConnect", + ledger: "Ledger", +}; + +/** + * Returns true when the connected wallet requires the user to confirm on a + * physical device (i.e. Ledger). The vault UI uses this to show a "Confirm on + * device" banner while awaiting the signature. + */ +export function requiresDeviceConfirmation( + walletType: VaultWalletType +): boolean { + return walletType === "ledger"; +} diff --git a/src/lib/api/vaults.ts b/src/lib/api/vaults.ts index 9a21a33..abbad02 100644 --- a/src/lib/api/vaults.ts +++ b/src/lib/api/vaults.ts @@ -6,6 +6,7 @@ import { toCsv, type CsvColumn } from "../csv"; import freighterApi from "@stellar/freighter-api"; import { submitTransactionXdr } from "./stellar"; import { computeVaultHealthScore, type VaultHealthScore } from "../vaultHealth"; +import type { BootstrapPhase, VaultBootstrapConfig } from "./vaultBootstrap"; /** * Vault transaction receipts client. @@ -1468,6 +1469,13 @@ export interface VaultSummary { maxDrawdownPct: number | null; /** Computed health score for depositor decision support. */ healthScore: VaultHealthScore; + /** + * Bootstrap phase for new vaults. Null once the vault has started writing + * options (phase === "live"). Present during treasury seeding and the + * genesis epoch so the listing page can surface a "Bootstrapping" badge + * instead of normal deposit/APY UX. + */ + bootstrapPhase: BootstrapPhase | null; } // --------------------------------------------------------------------------- @@ -1897,6 +1905,18 @@ export interface VaultDetail extends VaultSummary { inflationDefense?: InflationDefenseConfig; /** Computed health score and grade for depositor decision support. */ healthScore: VaultHealthScore; + /** + * Bootstrap configuration for this vault. Present for all vaults during and + * after bootstrap so operators can review the policy. Null for vaults that + * predate the bootstrap feature. + */ + bootstrapConfig: VaultBootstrapConfig | null; + /** + * USD value of treasury seed capital still held in the vault. + * Zero once the seed has been withdrawn (for temporary seeds) or + * for vaults with no treasury seed. Never null after bootstrap. + */ + treasurySeedRetainedUsd: number; } // --------------------------------------------------------------------------- @@ -1933,7 +1953,7 @@ const MOCK_VAULTS: Omit[] = [ { 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 }, + userPosition: { shares: 950, shareValue: 1.042, totalValue: 989.9, accruedPremium: 18.46, pendingWithdrawal: 0, distributions: [] }, }, { id: "btc-csp-v1", @@ -2028,6 +2048,8 @@ export async function fetchVaults(): Promise { return Promise.resolve( MOCK_VAULTS.map(({ historicalEpochs, userPosition, ...summary }) => ({ ...summary, + // Bootstrap: all fixtures are mature vaults already in live phase. + bootstrapPhase: null as import("./vaultBootstrap").BootstrapPhase | null, healthScore: computeVaultHealthScore( { strategy: summary.strategy, tvl: summary.tvl, capacity: summary.capacity }, historicalEpochs, @@ -2047,7 +2069,14 @@ export async function fetchVaultDetail(id: string, _token?: string | null): Prom { strategy: raw.strategy, tvl: raw.tvl, capacity: raw.capacity }, raw.historicalEpochs, ); - return Promise.resolve({ ...raw, healthScore }); + return Promise.resolve({ + ...raw, + healthScore, + // Bootstrap: fixtures are mature vaults; no bootstrap state or treasury seed. + bootstrapPhase: null as import("./vaultBootstrap").BootstrapPhase | null, + bootstrapConfig: null, + treasurySeedRetainedUsd: 0, + }); } export async function depositToVault(