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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
118 changes: 118 additions & 0 deletions docs/vault-surfaces.md
Original file line number Diff line number Diff line change
@@ -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*
87 changes: 63 additions & 24 deletions src/lib/api/vault.ts
Original file line number Diff line number Diff line change
@@ -1,35 +1,61 @@
/**
* 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
* degraded states explicitly instead of showing stale balances.
*/

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;
Expand Down Expand Up @@ -171,25 +197,35 @@ 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<VaultOperationResult> {
if (!VAULT_CONTRACT_ID) {
throw new Error(
"NEXT_PUBLIC_VAULT_CONTRACT_ID is not configured. Vault operations are unavailable."
);
}

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",
Expand All @@ -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 {
Expand All @@ -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<VaultOperationResult> {
if (!VAULT_CONTRACT_ID) {
throw new Error(
Expand All @@ -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",
Expand All @@ -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 {
Expand Down
Loading