diff --git a/apps/api/src/app.ts b/apps/api/src/app.ts index 2fed02d..6ebe9c4 100644 --- a/apps/api/src/app.ts +++ b/apps/api/src/app.ts @@ -37,6 +37,7 @@ import { enterpriseOrgsRoutes } from "./routes/enterprise-orgs.js"; import { enterprisePoliciesRoutes } from "./routes/enterprise-policies.js"; import { enterpriseApprovalsRoutes } from "./routes/enterprise-approvals.js"; import { stateChannelRoutes } from "./routes/state-channels.js"; +import { yieldVaultRoutes } from "./routes/yield-vaults.js"; import { getChatInfrastructure } from "./lib/chat-infrastructure.js"; const MAX_PAYMENTS_CACHE = 10000; @@ -415,3 +416,4 @@ app.register(stateChannelRoutes, { db: pgPool, redis: undefined, }); +app.register(yieldVaultRoutes, { prefix: "/api/v1" }); diff --git a/apps/api/src/db/migrations/010_add_yield_aggregation_vaults.sql b/apps/api/src/db/migrations/010_add_yield_aggregation_vaults.sql new file mode 100644 index 0000000..79ae1ef --- /dev/null +++ b/apps/api/src/db/migrations/010_add_yield_aggregation_vaults.sql @@ -0,0 +1,34 @@ +-- Automated Liquidity Reserve Rebalancing & Cross-Asset Yield Aggregation Vault (#408) +-- Sequential next migration after 009_add_enterprise_tenant_rbac.sql. +-- Issue description referenced 026_add_yield_aggregation_vaults.sql; this file +-- is intentionally 010 to keep the migrator sequential (same precedent as #401, +-- where the issue-referenced 019 became 009). See PR description. +-- +-- yield_vault_configs : one row per settlement asset routed into an +-- external Soroban yield vault. liquid_buffer_ratio +-- is the dynamic instant-withdrawal reserve target. +-- provider_vault_shares : share-token position per provider per vault. +-- Share BALANCES only change on deposit/withdraw — +-- harvested yield raises the exchange rate instead +-- (invariant: rate never decreases during harvest). + +CREATE TABLE IF NOT EXISTS yield_vault_configs ( + vault_id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + asset_address VARCHAR(56) NOT NULL UNIQUE, + liquid_buffer_ratio NUMERIC(3,2) NOT NULL DEFAULT 0.20 CHECK ( + liquid_buffer_ratio >= 0 AND liquid_buffer_ratio <= 1 + ), + current_tvl_stroops BIGINT NOT NULL DEFAULT 0 CHECK (current_tvl_stroops >= 0), + created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP +); + +CREATE TABLE IF NOT EXISTS provider_vault_shares ( + provider_id VARCHAR(64) NOT NULL, + vault_id UUID NOT NULL REFERENCES yield_vault_configs(vault_id) ON DELETE CASCADE, + share_balance BIGINT NOT NULL DEFAULT 0 CHECK (share_balance >= 0), + updated_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (provider_id, vault_id) +); + +CREATE INDEX IF NOT EXISTS idx_provider_vault_shares_vault ON provider_vault_shares (vault_id); \ No newline at end of file diff --git a/apps/api/src/index.ts b/apps/api/src/index.ts index 4916c2c..99409fe 100644 --- a/apps/api/src/index.ts +++ b/apps/api/src/index.ts @@ -14,6 +14,8 @@ import { broadcastCircuitBreakerPause, readEscrowTokenBalance } from "./lib/stel import { evaluateReserveConservation } from "./lib/invariant-checker.js"; import { createEnterpriseStore } from "./lib/enterprise-store.js"; import { startApprovalTimeoutWorker } from "./lib/workers/approvalTimeoutWorker.js"; +import { startYieldRebalanceWorker } from "./lib/workers/yieldRebalanceWorker.js"; +import { InMemoryStrategyAdapter } from "./lib/yield/strategy-adapter.js"; const port = Number(process.env.PORT ?? 3000); @@ -108,6 +110,30 @@ async function startServer() { } catch (error) { app.log.error(error, "approval timeout worker failed to start"); } + + // (#408) Yield rebalance: compound deployed collateral and keep the + // dynamic 20% liquid buffer topped up for instant settlements. Runs on + // the in-memory store until the pg-backed vault store is provisioned; + // disable with YIELD_REBALANCE_DISABLED=true. + if (process.env.YIELD_REBALANCE_DISABLED !== "true") { + try { + startYieldRebalanceWorker( + { + adapter: new InMemoryStrategyAdapter(), + logger: { + info: (obj, msg) => app.log.info(obj, msg), + warn: (obj, msg) => app.log.warn(obj, msg), + error: (obj, msg) => app.log.error(obj, msg), + }, + }, + process.env.YIELD_REBALANCE_POLL_MS + ? { pollIntervalMs: Number(process.env.YIELD_REBALANCE_POLL_MS) } + : undefined, + ); + } catch (error) { + app.log.error(error, "yield rebalance worker failed to start"); + } + } } catch (err) { app.log.error(err); process.exit(1); diff --git a/apps/api/src/lib/liquidity-netting.ts b/apps/api/src/lib/liquidity-netting.ts index 4b913a0..3237e61 100644 --- a/apps/api/src/lib/liquidity-netting.ts +++ b/apps/api/src/lib/liquidity-netting.ts @@ -284,4 +284,74 @@ export function verifyNoUnbackedValue( } } return true; +} + +/* ------------------------------------------------------------------ */ +/* Instant settlement draws against the yield-aggregated reserve */ +/* (#408) */ +/* ------------------------------------------------------------------ */ + +export interface InstantSettlementDrawParams { + /** Underlying the settlement needs right now (stroops). */ + requiredStroops: bigint; + /** Unallocated balance held directly by the escrow (stroops). */ + liquidReserveStroops: bigint; + /** Escrow underlying currently deployed into the yield vault (stroops). */ + deployedToVaultStroops: bigint; +} + +export interface InstantSettlementDrawPlan { + source: "BUFFER_ONLY" | "BUFFER_PLUS_VAULT_RECALL" | "INSUFFICIENT"; + requiredStroops: bigint; + liquidReserveStroops: bigint; + /** Portion that must be recalled from the yield vault first. */ + recallFromVaultStroops: bigint; + /** > 0 only when even a full recall cannot cover the requirement. */ + shortfallStroops: bigint; +} + +const ZERO = 0n; + +/** + * Plan how an instant cash-trade settlement is funded without ever delaying + * the hand-off: draw from the liquid buffer first; whatever it lacks is + * flagged as a permissionless `recall_from_vault()` leg sized exactly to the + * gap (never more). Mirrors the on-chain recall in + * contracts/escrow/src/yield_vault.rs and is pure so the concurrency stress + * test can hammer it alongside the HTTP layer. + */ +export function planInstantSettlementDraw( + params: InstantSettlementDrawParams, +): InstantSettlementDrawPlan { + const { requiredStroops, liquidReserveStroops, deployedToVaultStroops } = + params; + if (requiredStroops < ZERO) { + throw new RangeError("requiredStroops must be non-negative"); + } + if (liquidReserveStroops < ZERO || deployedToVaultStroops < ZERO) { + throw new RangeError("reserves must be non-negative"); + } + + if (liquidReserveStroops >= requiredStroops) { + return { + source: "BUFFER_ONLY", + requiredStroops, + liquidReserveStroops, + recallFromVaultStroops: ZERO, + shortfallStroops: ZERO, + }; + } + + const gap = requiredStroops - liquidReserveStroops; + const recallable = + deployedToVaultStroops < gap ? deployedToVaultStroops : gap; + const shortfall = gap - recallable; + + return { + source: shortfall > ZERO ? "INSUFFICIENT" : "BUFFER_PLUS_VAULT_RECALL", + requiredStroops, + liquidReserveStroops, + recallFromVaultStroops: recallable, + shortfallStroops: shortfall, + }; } \ No newline at end of file diff --git a/apps/api/src/lib/stellar.ts b/apps/api/src/lib/stellar.ts index 9ae2d40..80bcb55 100644 --- a/apps/api/src/lib/stellar.ts +++ b/apps/api/src/lib/stellar.ts @@ -94,6 +94,12 @@ export const RPC_TIMEOUTS = { genericBuildSim: 15_000, /** Generic poll budget used by submitSignedEnvelope. */ genericPoll: 30_000, + /** (#408) Quote / position read against a yield strategy adapter. */ + vaultQuote: 10_000, + /** (#408) Deploy idle reserves into a yield vault (build+simulate). */ + vaultDeploy: 15_000, + /** (#408) Instant recall from a yield vault (build+simulate). */ + vaultRecall: 15_000, } as const; export const NETWORK_PASSPHRASE = IS_PUBLIC @@ -106,6 +112,15 @@ export async function getLatestLedgerSequence(): Promise { return (await server.getLatestLedger()).sequence; } +/** + * (#408) Contract ID of the escrow's external yield vault for the settlement + * token, when one has been deployed and configured. Placeholder-free: absent + * config means yield aggregation stays dormant rather than guessing. + */ +export function escrowYieldVaultContractId(): string | null { + return process.env.YIELD_VAULT_CONTRACT_ID ?? null; +} + /** * Loads the deployer/buyer keypair — testnet-only. * diff --git a/apps/api/src/lib/store.ts b/apps/api/src/lib/store.ts index 68d2781..171e787 100644 --- a/apps/api/src/lib/store.ts +++ b/apps/api/src/lib/store.ts @@ -81,6 +81,8 @@ export function clearStore() { providersStore.clear(); globalH3SpatialIndex.clear(); globalOrderAllocator.clear(); + // (#408) Reset yield-vault config/share mirrors too. + clearYieldStores(); } export function saveProvider(record: ProviderRecord) { @@ -307,3 +309,85 @@ export function logTimeoutIncident( export function getTimeoutLogs(): TimeoutIncidentLog[] { return Array.from(timeoutLogs); } + +/* ------------------------------------------------------------------ */ +/* Yield aggregation vaults (#408) — in-memory dual of migration 010 */ +/* ------------------------------------------------------------------ */ + +/** + * In-memory mirror of `yield_vault_configs`. `currentTvlStroops` is a string + * for JSON safety, matching amountStroops elsewhere in this store; the pg + * column is BIGINT. + */ +export interface YieldVaultConfigRecord { + vaultId: string; + assetAddress: string; + liquidBufferRatio: number; + currentTvlStroops: string; + /** Instantly withdrawable portion tracked by the route/worker layer. */ + liquidStroops: string; + /** Last settled share exchange rate, scaled per YIELD_VAULT.EXCHANGE_RATE_SCALE. */ + lastExchangeRateScaled: string; +} + +/** In-memory mirror of `provider_vault_shares`. */ +export interface ProviderVaultShareRecord { + providerId: string; + vaultId: string; + shareBalance: string; +} + +const yieldVaultConfigs = new Map(); +const providerVaultShares = new Map(); + +function providerShareKey(providerId: string, vaultId: string): string { + return `${providerId}\u0000${vaultId}`; +} + +export function saveYieldVaultConfig(record: YieldVaultConfigRecord): void { + yieldVaultConfigs.set(record.vaultId, record); +} + +export function getYieldVaultConfig(vaultId: string): YieldVaultConfigRecord | undefined { + return yieldVaultConfigs.get(vaultId); +} + +export function getYieldVaultConfigByAsset(assetAddress: string): YieldVaultConfigRecord | undefined { + for (const config of yieldVaultConfigs.values()) { + if (config.assetAddress === assetAddress) return config; + } + return undefined; +} + +export function listYieldVaultConfigs(): YieldVaultConfigRecord[] { + return Array.from(yieldVaultConfigs.values()); +} + +export function upsertProviderVaultShare(record: ProviderVaultShareRecord): void { + providerVaultShares.set(providerShareKey(record.providerId, record.vaultId), record); +} + +export function getProviderVaultShare( + providerId: string, + vaultId: string, +): ProviderVaultShareRecord | undefined { + return providerVaultShares.get(providerShareKey(providerId, vaultId)); +} + +export function listProviderVaultShares(vaultId?: string): ProviderVaultShareRecord[] { + const all = Array.from(providerVaultShares.values()); + return vaultId ? all.filter((share) => share.vaultId === vaultId) : all; +} + +/** Total shares outstanding for a vault — the denominator of the exchange rate. */ +export function totalSharesForVault(vaultId: string): bigint { + return listProviderVaultShares(vaultId).reduce( + (sum, share) => sum + BigInt(share.shareBalance), + 0n, + ); +} + +export function clearYieldStores(): void { + yieldVaultConfigs.clear(); + providerVaultShares.clear(); +} diff --git a/apps/api/src/lib/workers/yieldRebalanceWorker.ts b/apps/api/src/lib/workers/yieldRebalanceWorker.ts new file mode 100644 index 0000000..8877550 --- /dev/null +++ b/apps/api/src/lib/workers/yieldRebalanceWorker.ts @@ -0,0 +1,204 @@ +/** + * Yield Rebalance Worker (#408). + * + * Drives the two loops the issue specifies, once per poll interval: + * 1. Compounding — accrued strategy APY is folded into each vault's TVL. + * Share BALANCES never change here; the exchange-rate ratchet does the + * work (the #408 invariant: rates never decrease during harvesting). + * 2. Buffer optimization — sizes and executes deploy / recall legs around + * the dynamic 20% liquid reserve via lib/yield/buffer-optimizer.ts, so + * instant cash-trade settlements always have buffer capacity while idle + * collateral earns 4–8% APY. + * + * Shape follows payout-batcher.ts: a pure `runRebalanceTick(deps)` the tests + * can drive deterministically, plus `startYieldRebalanceWorker(deps)` which + * owns the interval and returns a stop handle. + */ + +import { YIELD_VAULT, type BufferDecision } from "@velo/shared"; +import { optimizeBuffer } from "../yield/buffer-optimizer.js"; +import { + assertExchangeRateNeverDecreases, + type YieldStrategyAdapter, +} from "../yield/strategy-adapter.js"; +import { + listYieldVaultConfigs, + saveYieldVaultConfig, + totalSharesForVault, + type YieldVaultConfigRecord, +} from "../store.js"; + +const YEAR_MS = 365n * 24n * 60n * 60n * 1000n; + +export interface YieldRebalanceDeps { + adapter: YieldStrategyAdapter; + /** Trailing-window settlement demand per vault id, stroops (optional). */ + recentDemand?(vaultId: string): bigint | undefined; + logger?: { + info(obj: Record, msg?: string): void; + warn(obj: Record, msg?: string): void; + error(obj: Record, msg?: string): void; + }; +} + +export interface RebalanceTickResult { + vaultId: string; + decision: BufferDecision; + appliedAmountStroops: string; + harvestedYieldStroops: string; + exchangeRateScaled: string; +} + +function scaledRate(tvlStroops: bigint, totalShares: bigint): bigint { + if (totalShares <= 0n) return YIELD_VAULT.EXCHANGE_RATE_SCALE; + return (tvlStroops * YIELD_VAULT.EXCHANGE_RATE_SCALE) / totalShares; +} + +async function applyDecision( + deps: YieldRebalanceDeps, + config: YieldVaultConfigRecord, + decision: BufferDecision, +): Promise { + const amount = BigInt(decision.amountStroops); + if (amount <= 0n || decision.action === "HOLD") return "0"; + + const asset = { assetAddress: config.assetAddress, amountStroops: amount }; + + if (decision.action === "DEPLOY_TO_VAULT") { + const receipt = await deps.adapter.deposit(asset); + if (!receipt.ok) { + deps.logger?.warn( + { vaultId: config.vaultId, detail: receipt.detail }, + "deploy leg rejected by strategy", + ); + return "0"; + } + // Liquid leaves the escrow into the strategy; TVL composition shifts. + config.liquidStroops = ( + BigInt(config.liquidStroops) - amount + ).toString(); + return amount.toString(); + } + + // RECALL_FROM_VAULT — instant top-up of the settlement buffer. + const receipt = await deps.adapter.withdraw(asset); + if (!receipt.ok) { + deps.logger?.warn( + { vaultId: config.vaultId, detail: receipt.detail }, + "recall leg rejected by strategy", + ); + return "0"; + } + config.liquidStroops = (BigInt(config.liquidStroops) + amount).toString(); + return amount.toString(); +} +/** + * One full pass over every configured vault: quote → optimize → execute → + * compound. Mutates the shared in-memory vault configs (a pg-backed store + * plugs in at this same boundary once DATABASE_URL is provisioned). + */ +export async function runRebalanceTick( + deps: YieldRebalanceDeps, + opts: { pollIntervalMs?: number; vaultId?: string } = {}, +): Promise { + const pollMs = opts.pollIntervalMs ?? YIELD_VAULT.REBALANCE_POLL_MS; + const results: RebalanceTickResult[] = []; + + for (const config of listYieldVaultConfigs()) { + if (opts.vaultId && config.vaultId !== opts.vaultId) continue; + + try { + const position = await deps.adapter.position(config.assetAddress); + const deployed = BigInt(position.deployedStroops); + const liquid = BigInt(config.liquidStroops); + const tvlBefore = liquid + deployed; + + const decision = optimizeBuffer({ + vaultId: config.vaultId, + currentTvlStroops: tvlBefore, + currentLiquidStroops: liquid, + configuredRatio: config.liquidBufferRatio, + recentSettlementDemandStroops: deps.recentDemand?.(config.vaultId), + }); + + const applied = await applyDecision(deps, config, decision); + + // Compound this tick's slice of the annual APY onto whatever remains + // deployed. Pure bigint math — sub-stroop dust truncates away. + const deployedAfter = + deployed + + (decision.action === "DEPLOY_TO_VAULT" ? BigInt(applied) : 0n) - + (decision.action === "RECALL_FROM_VAULT" ? BigInt(applied) : 0n); + const harvested = + (deployedAfter * BigInt(position.apyBps) * BigInt(pollMs)) / + (10_000n * YEAR_MS); + + const tvlAfter = + BigInt(config.liquidStroops) + deployedAfter + harvested; + const previousRate = BigInt( + config.lastExchangeRateScaled || + YIELD_VAULT.EXCHANGE_RATE_SCALE.toString(), + ); + const nextRate = scaledRate( + tvlAfter, + totalSharesForVault(config.vaultId), + ); + + // Fail LOUDLY rather than persist a regression (#408 invariant). + assertExchangeRateNeverDecreases( + previousRate, + nextRate, + `rebalance:${config.vaultId}`, + ); + + saveYieldVaultConfig({ + ...config, + currentTvlStroops: tvlAfter.toString(), + lastExchangeRateScaled: nextRate.toString(), + }); + + results.push({ + vaultId: config.vaultId, + decision, + appliedAmountStroops: applied, + harvestedYieldStroops: harvested.toString(), + exchangeRateScaled: nextRate.toString(), + }); + } catch (error) { + deps.logger?.error( + { err: error, vaultId: config.vaultId }, + "rebalance tick failed for vault", + ); + } + } + + return results; +} +/** + * Interval owner. Returns a stop handle; the timer is unref'd so a stray + * worker never keeps a short-lived process alive. + */ +export function startYieldRebalanceWorker( + deps: YieldRebalanceDeps, + opts: { pollIntervalMs?: number } = {}, +): () => void { + const pollIntervalMs = opts.pollIntervalMs ?? YIELD_VAULT.REBALANCE_POLL_MS; + let running = true; + + const tick = (): void => { + if (!running) return; + void runRebalanceTick(deps, { pollIntervalMs }).catch((error) => + deps.logger?.error({ err: error }, "yield rebalance tick crashed"), + ); + }; + + tick(); + const timer = setInterval(tick, pollIntervalMs); + timer.unref?.(); + + return () => { + running = false; + clearInterval(timer); + }; +} + diff --git a/apps/api/src/lib/yield/__tests__/buffer-optimizer.test.ts b/apps/api/src/lib/yield/__tests__/buffer-optimizer.test.ts new file mode 100644 index 0000000..0b50e9c --- /dev/null +++ b/apps/api/src/lib/yield/__tests__/buffer-optimizer.test.ts @@ -0,0 +1,111 @@ +import { describe, expect, it } from "vitest"; +import { YIELD_VAULT } from "@velo/shared"; +import { + clampBufferRatio, + optimizeBuffer, + targetLiquidFor, +} from "../buffer-optimizer.js"; + +/** + * Unit coverage for the dynamic liquidity-buffer optimizer (#408). + * + * Note on shortfall: the optimizer derives recall capacity as + * TVL − liquid, and targets are always ≤ TVL (ratios clamp to ≤ 1), so a + * shortfall is unreachable by construction — the branch in code is purely + * defensive against future input shapes. + */ +describe("buffer optimizer (#408)", () => { + const base = { + vaultId: "vault-1", + currentTvlStroops: 1_000_000n, + currentLiquidStroops: 900_000n, + configuredRatio: 0.2, + }; + + it("deploys idle cash beyond the hysteresis band", () => { + // target = ceil(1M × 20%) = 200k; band top = ceil(1M × 22.5%) = 225k. + const decision = optimizeBuffer({ ...base, minDeployStroops: 1n }); + expect(decision.action).toBe("DEPLOY_TO_VAULT"); + expect(decision.targetLiquidStroops).toBe("200000"); + expect(decision.amountStroops).toBe("675000"); // 900k − 225k + expect(decision.shortfallStroops).toBe("0"); + }); + + it("holds inside the hysteresis band to avoid churn", () => { + const decision = optimizeBuffer({ + ...base, + currentLiquidStroops: 210_000n, + minDeployStroops: 1n, + }); + expect(decision.action).toBe("HOLD"); + expect(decision.amountStroops).toBe("0"); + }); + + it("respects the minimum-deploy floor", () => { + const decision = optimizeBuffer({ + ...base, + minDeployStroops: 800_001n, // excess is only 675k + }); + expect(decision.action).toBe("HOLD"); + }); + + it("blocks tiny vaults under the shared default floor", () => { + const decision = optimizeBuffer(base); // default floor: 1_000_000 stroops + expect(decision.action).toBe("HOLD"); + expect(BigInt(YIELD_VAULT.MIN_DEPLOY_STROOPS)).toBe(1_000_000n); + }); + + it("recalls when trailing settlement demand outgrows the buffer", () => { + const decision = optimizeBuffer({ + ...base, + currentLiquidStroops: 150_000n, + recentSettlementDemandStroops: 400_000n, + minDeployStroops: 1n, + }); + // Demand×1.5 = 600k → 60% coverage ratio beats the 20% config. + expect(decision.recommendedRatio).toBe(0.6); + expect(decision.targetLiquidStroops).toBe("600000"); + expect(decision.action).toBe("RECALL_FROM_VAULT"); + expect(decision.amountStroops).toBe("450000"); // 600k target − 150k liquid + expect(decision.shortfallStroops).toBe("0"); + }); + + it("caps the recommended ratio at 100% of TVL", () => { + const decision = optimizeBuffer({ + ...base, + currentLiquidStroops: 0n, + recentSettlementDemandStroops: 10_000_000n, + minDeployStroops: 1n, + }); + expect(decision.recommendedRatio).toBe(1); + expect(decision.targetLiquidStroops).toBe("1000000"); + expect(decision.action).toBe("RECALL_FROM_VAULT"); + expect(decision.amountStroops).toBe("1000000"); + }); + + it("rounds the liquid target UP so the buffer is never under-funded", () => { + expect(targetLiquidFor(10_000_001n, 0.2)).toBe(2_000_001n); + expect(targetLiquidFor(10_000_000n, 0.2)).toBe(2_000_000n); + expect(targetLiquidFor(0n, 0.9)).toBe(0n); + }); + + it("clamps ratios into the shared bounds", () => { + expect(clampBufferRatio(0.01)).toBe(YIELD_VAULT.MIN_LIQUID_BUFFER_RATIO); + expect(clampBufferRatio(5)).toBe(YIELD_VAULT.MAX_LIQUID_BUFFER_RATIO); + expect(clampBufferRatio(Number.NaN)).toBe( + YIELD_VAULT.DEFAULT_LIQUID_BUFFER_RATIO, + ); + expect(clampBufferRatio(0.42)).toBe(0.42); + }); + + it("ignores negative or inconsistent reserve inputs defensively", () => { + const decision = optimizeBuffer({ + ...base, + currentLiquidStroops: -5n, + minDeployStroops: 1n, + }); + // Negative liquid clamps to zero → full recall up to the target. + expect(decision.action).toBe("RECALL_FROM_VAULT"); + expect(decision.amountStroops).toBe("200000"); + }); +}); diff --git a/apps/api/src/lib/yield/buffer-optimizer.ts b/apps/api/src/lib/yield/buffer-optimizer.ts new file mode 100644 index 0000000..1e6a81b --- /dev/null +++ b/apps/api/src/lib/yield/buffer-optimizer.ts @@ -0,0 +1,148 @@ +/** + * Dynamic liquidity-buffer optimizer (#408). + * + * The escrow keeps a fraction of every vault's TVL instantly withdrawable + * (20% by default) so cash-trade settlements never wait on a strategy + * unwind; everything above that buffer is deployed into external Soroban + * yield strategies earning 4–8% APY. This module sizes the deploy / recall + * legs for one rebalance tick. It is pure bigint math — no IO — so the unit + * tests and the concurrency stress suite can hammer it directly, mirroring + * the on-chain entry points in contracts/escrow/src/yield_vault.rs + * (`deploy_idle_to_vault` / `recall_from_vault`). + * + * Policy summary (all ratios handled internally in basis points to avoid + * float drift): + * 1. recommendedRatio = max(configuredRatio, demandCoverage) where + * demandCoverage covers trailing settlement demand × safety multiplier. + * 2. targetLiquid = ceil(TVL × recommendedRatio). + * 3. Liquid below target → RECALL exactly the gap (≤ deployed). + * 4. Liquid above target×(1+hy) → DEPLOY the excess when ≥ minDeploy. + * 5. Otherwise → HOLD (hysteresis prevents churn). + */ + +import { YIELD_VAULT, type BufferDecision } from "@velo/shared"; + +const BPS_DENOMINATOR = 10_000n; + +export interface BufferOptimizerInput { + vaultId: string; + /** Total value locked including deployed-to-strategy funds (stroops). */ + currentTvlStroops: bigint; + /** Unallocated balance held directly by the escrow (stroops). */ + currentLiquidStroops: bigint; + /** Configured buffer fraction (e.g. 0.2); clamped to the shared bounds. */ + configuredRatio: number; + /** Settlement demand observed over the trailing window (stroops). */ + recentSettlementDemandStroops?: bigint; + /** Safety factor over trailing demand (default 1.5×). */ + demandMultiplier?: number; + /** Deploy only past buffer×(1+hysteresis) — default YIELD_VAULT value. */ + hysteresisBps?: number; + /** Floor under which deploying costs more than it earns. */ + minDeployStroops?: bigint; +} + +export function clampBufferRatio(ratio: number): number { + if (!Number.isFinite(ratio)) { + return YIELD_VAULT.DEFAULT_LIQUID_BUFFER_RATIO; + } + return Math.min( + YIELD_VAULT.MAX_LIQUID_BUFFER_RATIO, + Math.max(YIELD_VAULT.MIN_LIQUID_BUFFER_RATIO, ratio), + ); +} + +function ratioToBps(ratio: number): bigint { + return BigInt(Math.round(clampBufferRatio(ratio) * 10_000)); +} + +function ceilDiv(numer: bigint, denom: bigint): bigint { + if (denom <= 0n) throw new RangeError("denominator must be positive"); + return (numer + denom - 1n) / denom; +} + +/** Liquid target implied by a ratio, rounded UP (never under-buffered). */ +export function targetLiquidFor(tvlStroops: bigint, ratio: number): bigint { + if (tvlStroops <= 0n) return 0n; + return ceilDiv(tvlStroops * ratioToBps(ratio), BPS_DENOMINATOR); +} + +export function optimizeBuffer(input: BufferOptimizerInput): BufferDecision { + const tvl = + input.currentTvlStroops > 0n ? input.currentTvlStroops : 0n; + const liquid = + input.currentLiquidStroops < 0n + ? 0n + : input.currentLiquidStroops > tvl + ? tvl + : input.currentLiquidStroops; + + const hysteresisBps = + input.hysteresisBps ?? YIELD_VAULT.BUFFER_HYSTERESIS_BPS; + const minDeploy = input.minDeployStroops ?? YIELD_VAULT.MIN_DEPLOY_STROOPS; + + // 1. Recommended ratio: configured floor vs. demand coverage. + let recommendedBps = ratioToBps(input.configuredRatio); + const demand = input.recentSettlementDemandStroops ?? 0n; + if (tvl > 0n && demand > 0n) { + const multiplier = Math.max(1, input.demandMultiplier ?? 1.5); + // bigint-safe ×1.5 style scaling: multiply by 1500‰ then divide. + const scaledDemand = (demand * BigInt(Math.round(multiplier * 1_000))) / 1_000n; + const coverageBps = ceilDiv(scaledDemand * BPS_DENOMINATOR, tvl); + const capped = coverageBps > BPS_DENOMINATOR ? BPS_DENOMINATOR : coverageBps; + if (capped > recommendedBps) recommendedBps = capped; + } + + // 2. Targets. + const targetLiquid = ceilDiv(tvl * recommendedBps, BPS_DENOMINATOR); + const hysteresisTarget = ceilDiv( + tvl * (recommendedBps + BigInt(hysteresisBps)), + BPS_DENOMINATOR, + ); + const deployedCapacity = tvl - liquid; + + // 3./4./5. Decide. + if (liquid < targetLiquid) { + const gap = targetLiquid - liquid; + const recall = deployedCapacity < gap ? deployedCapacity : gap; + const shortfall = gap - recall; + if (recall === 0n) { + return decision("HOLD", 0n, shortfall); + } + return decision(shortfall > 0n ? "HOLD" : "RECALL_FROM_VAULT", recall, shortfall); + } + + if (liquid > hysteresisTarget) { + const excess = liquid - hysteresisTarget; + if (excess < minDeploy) { + return decision("HOLD", 0n, 0n); + } + return decision("DEPLOY_TO_VAULT", excess, 0n); + } + + return decision("HOLD", 0n, 0n); + + function decision( + action: BufferDecision["action"], + amount: bigint, + shortfall: bigint, + ): BufferDecision { + const reason = + action === "RECALL_FROM_VAULT" + ? "liquid buffer below target — recalling to restore instant-settlement capacity" + : action === "DEPLOY_TO_VAULT" + ? "idle cash exceeds hysteresis band — deploying surplus to yield strategy" + : shortfall > 0n + ? "buffer short but nothing left deployed to recall — shortfall reported" + : "within hysteresis band — no rebalance needed"; + return { + vaultId: input.vaultId, + recommendedRatio: Number(recommendedBps) / 10_000, + targetLiquidStroops: targetLiquid.toString(), + action, + amountStroops: amount.toString(), + shortfallStroops: shortfall.toString(), + reason, + }; + } +} \ No newline at end of file diff --git a/apps/api/src/lib/yield/strategy-adapter.ts b/apps/api/src/lib/yield/strategy-adapter.ts new file mode 100644 index 0000000..b77f05a --- /dev/null +++ b/apps/api/src/lib/yield/strategy-adapter.ts @@ -0,0 +1,224 @@ +/** + * Strategy adapter boundary between the API and external Soroban yield + * vaults (#408). + * + * `InMemoryStrategyAdapter` is a deterministic simulator used by the worker, + * the route layer, and tests — balances and APY live in process memory. + * `SorobanYieldAdapter` is the production shape: it wraps the same + * accounting but stamps receipts through the RPC timeout plumbing in + * lib/stellar.ts. Vault contract addresses come from + * `escrowYieldVaultContractId()` (YIELD_VAULT_CONTRACT_ID) once deployments + * land per docs/mainnet-deployment.md; until then every receipt is clearly + * marked simulated instead of pretending to be on-chain. + * + * Every adapter honours the #408 contributor invariant at this boundary too: + * share exchange rates may only ratchet up, enforced by + * `assertExchangeRateNeverDecreases` before any persisted state changes. + */ + +import type { StrategyPosition } from "@velo/shared"; +import { YIELD_VAULT } from "@velo/shared"; +import { + escrowYieldVaultContractId, + getLatestLedgerSequence, + RPC_TIMEOUTS, + rpcTimeout, +} from "../stellar.js"; + +export interface StrategyQuote { + assetAddress: string; + apyBps: number; + tvlStroops: bigint; +} + +export interface DepositRequest { + assetAddress: string; + amountStroops: bigint; +} + +export interface WithdrawRequest { + assetAddress: string; + amountStroops: bigint; +} + +export interface StrategyReceipt { + ok: boolean; + strategyName: string; + /** On-chain hash when the movement actually settled; null while simulated. */ + txHash: string | null; + /** Machine-readable failure detail (e.g. INSUFFICIENT_STRATEGY_BALANCE). */ + detail?: string; +} + +export interface YieldStrategyAdapter { + readonly name: string; + quoteApy(assetAddress: string): Promise; + deposit(request: DepositRequest): Promise; + withdraw(request: WithdrawRequest): Promise; + position(assetAddress: string): Promise; +} + +function clampApy(apyBps: number): number { + if (!Number.isFinite(apyBps)) return YIELD_VAULT.MAX_APY_BPS; + return Math.min( + YIELD_VAULT.MAX_APY_BPS, + Math.max(YIELD_VAULT.MIN_APY_BPS, Math.round(apyBps)), + ); +} + +/** Deterministic in-process strategy — default compounding at 6% APY. */ +export class InMemoryStrategyAdapter implements YieldStrategyAdapter { + readonly name = "in-memory-soroban-vault-sim"; + private readonly deployed = new Map(); + private apy: number; + + constructor(apyBps = (YIELD_VAULT.MIN_APY_BPS + YIELD_VAULT.MAX_APY_BPS) / 2) { + this.apy = clampApy(apyBps); + } + + setApyBps(apyBps: number): void { + this.apy = clampApy(apyBps); + } + + async quoteApy(assetAddress: string): Promise { + return { + assetAddress, + apyBps: this.apy, + tvlStroops: this.deployed.get(assetAddress) ?? 0n, + }; + } + + async deposit({ + assetAddress, + amountStroops, + }: DepositRequest): Promise { + if (amountStroops <= 0n) { + throw new RangeError("amountStroops must be positive"); + } + this.deployed.set( + assetAddress, + (this.deployed.get(assetAddress) ?? 0n) + amountStroops, + ); + return { ok: true, strategyName: this.name, txHash: null }; + } + + async withdraw({ + assetAddress, + amountStroops, + }: WithdrawRequest): Promise { + if (amountStroops <= 0n) { + throw new RangeError("amountStroops must be positive"); + } + const current = this.deployed.get(assetAddress) ?? 0n; + // Instant-recall legs are sized to available deployment upstream + // (buffer-optimizer + planInstantSettlementDraw), so an overdraft here + // signals a sizing bug rather than a user-facing condition. + if (amountStroops > current) { + return { + ok: false, + strategyName: this.name, + txHash: null, + detail: "INSUFFICIENT_STRATEGY_BALANCE", + }; + } + this.deployed.set(assetAddress, current - amountStroops); + return { ok: true, strategyName: this.name, txHash: null }; + } + + async position(assetAddress: string): Promise { + return { + assetAddress, + strategyName: this.name, + deployedStroops: (this.deployed.get(assetAddress) ?? 0n).toString(), + apyBps: this.apy, + }; + } +} +/** + * Production-shaped adapter against a deployed YieldVaultContract + * (contracts/escrow/src/yield_vault.rs). Quotes are timestamped through the + * live Soroban RPC with the same deadline discipline as every other ledger + * read; movements are recorded by the wrapped simulator and clearly marked + * `simulated: true` until per-asset vault contract IDs are deployed. + */ +export class SorobanYieldAdapter implements YieldStrategyAdapter { + readonly name = "soroban-yield-vault"; + private readonly inner = new InMemoryStrategyAdapter(); + /** Ledger height the most recent quote was validated against. */ + lastQuoteLedger: number | null = null; + + constructor( + private readonly opts: { + timeoutMs?: number; + /** Per-asset overrides; falls back to YIELD_VAULT_CONTRACT_ID. */ + vaultContractIdByAsset?: Map; + } = {}, + ) {} + + vaultContractIdFor(assetAddress: string): string | null { + return ( + this.opts.vaultContractIdByAsset?.get(assetAddress) ?? + escrowYieldVaultContractId() + ); + } + + async quoteApy(assetAddress: string): Promise { + const quote = await this.inner.quoteApy(assetAddress); + // Timestamp every quote against real ledger state so staleness is + // observable; a dead RPC surfaces as RpcTimeoutError like elsewhere. + this.lastQuoteLedger = await rpcTimeout( + "vaultQuote", + this.opts.timeoutMs ?? RPC_TIMEOUTS.vaultQuote, + getLatestLedgerSequence, + ); + return quote; + } + + async deposit(request: DepositRequest): Promise { + const receipt = await this.inner.deposit(request); + return this.stamp(receipt, request.assetAddress, request.amountStroops); + } + + async withdraw(request: WithdrawRequest): Promise { + const receipt = await this.inner.withdraw(request); + return receipt.ok + ? this.stamp(receipt, request.assetAddress, request.amountStroops) + : receipt; + } + + async position(assetAddress: string): Promise { + return this.inner.position(assetAddress); + } + + private stamp( + receipt: StrategyReceipt, + assetAddress: string, + amountStroops: bigint, + ): StrategyReceipt { + return { + ...receipt, + txHash: null, + detail: + `simulated movement of ${amountStroops} stroops for ${assetAddress}` + + `${this.vaultContractIdFor(assetAddress) ? ` via ${this.vaultContractIdFor(assetAddress)}` : ""}`, + }; + } +} + +/** + * The #408 invariant, enforced wherever an exchange rate is about to be + * persisted or published: the scaled rate may rise (harvest) or hold + * (proportional withdrawal), never fall. + */ +export function assertExchangeRateNeverDecreases( + previousScaled: bigint, + nextScaled: bigint, + context: string, +): void { + if (nextScaled < previousScaled) { + throw new Error( + `yield invariant violated (${context}): exchange rate regressed from ` + + `${previousScaled} to ${nextScaled}`, + ); + } +} \ No newline at end of file diff --git a/apps/api/src/routes/yield-vaults.ts b/apps/api/src/routes/yield-vaults.ts new file mode 100644 index 0000000..a30d336 --- /dev/null +++ b/apps/api/src/routes/yield-vaults.ts @@ -0,0 +1,532 @@ +/** + * Yield aggregation vault routes (#408). + * + * GET /api/v1/yield/vaults — public snapshot + * GET /api/v1/yield/vaults/:vaultId/providers/:providerId — share position + * POST /api/v1/yield/vaults/config — admin upsert + * POST /api/v1/yield/harvest — admin harvest + * POST /api/v1/yield/vaults/rebalance — admin optimizer tick + * POST /api/v1/yield/vaults/:vaultId/withdraw — provider exit + * + * Harvest folds strategy yield into TVL without minting shares, so provider + * share balances appreciate via the exchange rate — and the #408 invariant + * (the rate NEVER decreases during harvesting) is enforced before any state + * is persisted. Withdrawals draw from the 20% liquid buffer first and flag + * an instant `recall_from_vault` leg for the gap, so settlements never wait + * on a strategy unwind. + * + * State lives in the in-memory mirrors in lib/store.ts (dual of migration + * 010); a pg-backed store slots in behind the same helpers. + */ + +import { randomUUID } from "crypto"; +import type { FastifyInstance } from "fastify"; +import { z } from "zod"; +import { YIELD_VAULT } from "@velo/shared"; +import { ApiError } from "../lib/errors.js"; +import { requireAdminApiKeyHeader } from "../lib/admin-auth.js"; +import { + getProviderVaultShare, + getYieldVaultConfig, + getYieldVaultConfigByAsset, + listProviderVaultShares, + listYieldVaultConfigs, + saveYieldVaultConfig, + totalSharesForVault, + upsertProviderVaultShare, +} from "../lib/store.js"; +import { planInstantSettlementDraw } from "../lib/liquidity-netting.js"; +import { optimizeBuffer } from "../lib/yield/buffer-optimizer.js"; +import { runRebalanceTick } from "../lib/workers/yieldRebalanceWorker.js"; +import { + InMemoryStrategyAdapter, + assertExchangeRateNeverDecreases, + type YieldStrategyAdapter, +} from "../lib/yield/strategy-adapter.js"; + +const SCALE = YIELD_VAULT.EXCHANGE_RATE_SCALE; + +/** Shared adapter instance — tests swap this via setDefaultStrategyAdapter. */ +export let defaultStrategyAdapter: YieldStrategyAdapter = + new InMemoryStrategyAdapter(); + +export function setDefaultStrategyAdapter(adapter: YieldStrategyAdapter): void { + defaultStrategyAdapter = adapter; +} + +export interface YieldVaultRoutesOptions { + /** Overridable in tests; defaults to the shared in-memory simulator. */ + adapter?: YieldStrategyAdapter; +} + +/** Stellar asset contract address (C…) — 56 chars. */ +const STELLAR_C_ADDRESS = /^C[1-9A-HJ-NP-Za-km-z]{55}$/; +const UINT_STRING = /^\d+$/; + +const BufferRatioSchema = z + .number() + .min(YIELD_VAULT.MIN_LIQUID_BUFFER_RATIO) + .max(YIELD_VAULT.MAX_LIQUID_BUFFER_RATIO); + +const ConfigSchema = z.object({ + assetAddress: z.string().regex(STELLAR_C_ADDRESS), + liquidBufferRatio: BufferRatioSchema.optional(), +}); + +const HarvestSchema = z + .object({ + vaultId: z.string().uuid().optional(), + assetAddress: z.string().regex(STELLAR_C_ADDRESS).optional(), + yieldStroops: z.string().regex(UINT_STRING), + }) + .refine((body) => body.vaultId !== undefined || body.assetAddress !== undefined, { + message: "vaultId or assetAddress is required", + }); + +const RebalanceSchema = z.object({ + vaultId: z.string().uuid().optional(), + demandMultiplier: z.number().min(1).max(10).optional(), +}); + +const WithdrawSchema = z.object({ + providerId: z.string().min(1).max(64), + shareAmount: z.string().regex(UINT_STRING), +}); + +function scaledRate(tvlStroops: bigint, totalShares: bigint): bigint { + if (totalShares <= 0n) return SCALE; + return (tvlStroops * SCALE) / totalShares; +} + +function requireVault(vaultId: string) { + const config = getYieldVaultConfig(vaultId); + if (!config) { + throw new ApiError(404, "NOT_FOUND", `Yield vault ${vaultId} not found`); + } + return config; +} + +function resolveVault(query: { vaultId?: string; assetAddress?: string }) { + const config = query.vaultId + ? getYieldVaultConfig(query.vaultId) + : query.assetAddress + ? getYieldVaultConfigByAsset(query.assetAddress) + : undefined; + if (!config) { + throw new ApiError(404, "NOT_FOUND", "Yield vault not found"); + } + return config; +} + +function apyHistoryBps(seed: string, currentApyBps: number): number[] { + // Deterministic placeholder series around the live quote until the worker + // accumulates real per-tick observations; shape only, never persisted. + let hash = 0; + for (const ch of seed) hash = (hash * 31 + ch.charCodeAt(0)) | 0; + return Array.from({ length: 24 }, (_, i) => { + const wobble = Math.sin((i + (hash % 7)) / 3) * 40; + return Math.max( + YIELD_VAULT.MIN_APY_BPS - 50, + Math.round(currentApyBps + wobble), + ); + }); +} + +function vaultView( + config: ReturnType, + currentApyBps = (YIELD_VAULT.MIN_APY_BPS + YIELD_VAULT.MAX_APY_BPS) / 2, +) { + if (!config) throw new ApiError(404, "NOT_FOUND", "Yield vault not found"); + const tvl = BigInt(config.currentTvlStroops); + const liquid = BigInt(config.liquidStroops); + const decision = optimizeBuffer({ + vaultId: config.vaultId, + currentTvlStroops: tvl, + currentLiquidStroops: liquid, + configuredRatio: config.liquidBufferRatio, + }); + const storedRate = config.lastExchangeRateScaled; + return { + ...config, + exchangeRateScaled: BigInt( + storedRate && storedRate.length > 0 ? storedRate : SCALE.toString(), + ).toString(), + buffer: { + liquidStroops: liquid.toString(), + targetLiquidStroops: decision.targetLiquidStroops, + action: decision.action, + shortfallStroops: decision.shortfallStroops, + ratioNowScaled: + tvl > 0n ? ((liquid * SCALE) / tvl).toString() : SCALE.toString(), + }, + apyHistoryBps: apyHistoryBps(config.vaultId, Math.round(currentApyBps)), + deployedStroops: (tvl - liquid).toString(), + }; +} +export async function yieldVaultRoutes( + app: FastifyInstance, + opts: YieldVaultRoutesOptions = {}, +) { + const resolveAdapter = (): YieldStrategyAdapter => + opts.adapter ?? defaultStrategyAdapter; + + /** Serialize all mutating work per vault so concurrent requests and the + * rebalance worker can never interleave balance transitions. */ + const vaultLocks = new Map>(); + function withVaultLock(vaultId: string, fn: () => Promise): Promise { + const previous = vaultLocks.get(vaultId) ?? Promise.resolve(); + const next = previous.then(fn, fn); + vaultLocks.set( + vaultId, + next.then( + () => undefined, + () => undefined, + ), + ); + return next; + } + + // GET /yield/vaults — public snapshot for the provider portal. + app.get( + "/yield/vaults", + { config: { rateLimit: { max: 60, timeWindow: "1 minute" } } }, + async () => { + const views: Array & { strategyName: string }> = []; + for (const config of listYieldVaultConfigs()) { + const quote = await resolveAdapter().quoteApy(config.assetAddress); + views.push({ ...vaultView(config, quote.apyBps), strategyName: resolveAdapter().name }); + } + return { status: "success", count: views.length, data: views }; + }, + ); + + // GET /yield/vaults/:vaultId/providers/:providerId — share position. + app.get<{ Params: { vaultId: string; providerId: string } }>( + "/yield/vaults/:vaultId/providers/:providerId", + async (req) => { + const config = requireVault(req.params.vaultId); + const share = getProviderVaultShare(req.params.providerId, config.vaultId); + if (!share) { + throw new ApiError(404, "NOT_FOUND", "Provider has no position in this vault"); + } + const tvl = BigInt(config.currentTvlStroops); + const shares = totalSharesForVault(config.vaultId); + const balance = BigInt(share.shareBalance); + const valueStroops = + shares > 0n ? (balance * tvl) / shares : balance; // sole depositor: 1:1 + return { + status: "success", + data: { + ...share, + valueStroops: valueStroops.toString(), + exchangeRateScaled: scaledRate(tvl, shares).toString(), + }, + }; + }, + ); + + // POST /yield/vaults/config — admin upsert (creates or tunes buffer ratio). + app.post<{ Body: z.infer }>( + "/yield/vaults/config", + async (req, reply) => { + try { + requireAdminApiKeyHeader(req); + } catch (error) { + if (error instanceof ApiError) { + return reply.status(error.statusCode).send(error.toJSON(req.id)); + } + throw error; + } + + const parsed = ConfigSchema.safeParse(req.body); + if (!parsed.success) { + throw new ApiError(400, "VALIDATION_ERROR", "Invalid vault config", { + detail: parsed.error.issues.map((i) => i.message).join("; "), + }); + } + + const { assetAddress, liquidBufferRatio } = parsed.data; + const existing = getYieldVaultConfigByAsset(assetAddress); + const config = existing ?? { + vaultId: randomUUID(), + assetAddress, + liquidBufferRatio: YIELD_VAULT.DEFAULT_LIQUID_BUFFER_RATIO, + currentTvlStroops: "0", + liquidStroops: "0", + lastExchangeRateScaled: SCALE.toString(), + }; + if (liquidBufferRatio !== undefined) { + config.liquidBufferRatio = liquidBufferRatio; + } + saveYieldVaultConfig(config); + return reply.status(existing ? 200 : 201).send({ + status: "success", + data: vaultView(config), + }); + }, + ); + + // POST /yield/harvest — fold accrued strategy yield into TVL. Shares are + // untouched, so the exchange rate ratchets up; a regression is rejected + // BEFORE any state changes (#408 contributor invariant). + app.post<{ Body: z.infer }>( + "/yield/harvest", + async (req, reply) => { + try { + requireAdminApiKeyHeader(req); + } catch (error) { + if (error instanceof ApiError) { + return reply.status(error.statusCode).send(error.toJSON(req.id)); + } + throw error; + } + + const parsed = HarvestSchema.safeParse(req.body); + if (!parsed.success) { + throw new ApiError(400, "VALIDATION_ERROR", "Invalid harvest request", { + detail: parsed.error.issues.map((i) => i.message).join("; "), + }); + } + const { vaultId, assetAddress } = parsed.data; + const yieldStroops = BigInt(parsed.data.yieldStroops); + if (yieldStroops <= 0n) { + throw new ApiError(400, "INVALID_PARAMETER", "yieldStroops must be positive"); + } + + const config = resolveVault({ vaultId, assetAddress }); + return withVaultLock(config.vaultId, async () => { + // Re-read under the lock — a concurrent tick may have moved TVL. + const fresh = requireVault(config.vaultId); + const shares = totalSharesForVault(fresh.vaultId); + if (shares <= 0n) { + throw new ApiError( + 409, + "CONFLICT", + "Cannot harvest a vault with no shares outstanding", + ); + } + + const tvlBefore = BigInt(fresh.currentTvlStroops); + const previousRate = scaledRate(tvlBefore, shares); + const tvlAfter = tvlBefore + yieldStroops; + const nextRate = scaledRate(tvlAfter, shares); + + if (nextRate < previousRate) { + throw new ApiError( + 409, + "RATE_REGRESSION", + "Harvest would decrease the share exchange rate — rejected", + { extra: { previousRateScaled: previousRate.toString(), nextRateScaled: nextRate.toString() } }, + ); + } + assertExchangeRateNeverDecreases(previousRate, nextRate, `harvest:${fresh.vaultId}`); + + saveYieldVaultConfig({ + ...fresh, + currentTvlStroops: tvlAfter.toString(), + lastExchangeRateScaled: nextRate.toString(), + }); + + return reply.status(200).send({ + status: "success", + data: { + vaultId: fresh.vaultId, + assetAddress: fresh.assetAddress, + yieldStroops: yieldStroops.toString(), + tvlAfterStroops: tvlAfter.toString(), + exchangeRateScaled: nextRate.toString(), + previousExchangeRateScaled: previousRate.toString(), + harvestedAt: new Date().toISOString(), + }, + }); + }); + }, + ); + + // POST /yield/vaults/:vaultId/withdraw — provider exit at the current + // exchange rate. Funded instantly: liquid buffer first, sized recall leg + // for the gap. Serialized per-vault so concurrent withdrawals can never + // double-spend a balance (see the stress suite in tests/concurrency/). + app.post<{ Params: { vaultId: string }; Body: z.infer }>( + "/yield/vaults/:vaultId/withdraw", + async (req, reply) => { + const parsed = WithdrawSchema.safeParse(req.body); + if (!parsed.success) { + throw new ApiError(400, "VALIDATION_ERROR", "Invalid withdrawal", { + detail: parsed.error.issues.map((i) => i.message).join("; "), + }); + } + const { providerId, shareAmount } = parsed.data; + const amount = BigInt(shareAmount); + if (amount <= 0n) { + throw new ApiError(400, "INVALID_PARAMETER", "shareAmount must be positive"); + } + requireVault(req.params.vaultId); + + return withVaultLock(req.params.vaultId, async () => { + const config = requireVault(req.params.vaultId); + + const share = getProviderVaultShare(providerId, config.vaultId); + if (!share || BigInt(share.shareBalance) < amount) { + throw new ApiError( + 409, + "INSUFFICIENT_SHARES", + "Provider share balance is lower than the requested amount", + ); + } + + const shares = totalSharesForVault(config.vaultId); + if (shares <= 0n) { + throw new ApiError(409, "CONFLICT", "Vault has no shares outstanding"); + } + + const tvl = BigInt(config.currentTvlStroops); + const assetsOut = (amount * tvl) / shares; + if (assetsOut <= 0n) { + throw new ApiError( + 409, + "ZERO_PAYOUT", + "Requested shares convert to zero underlying at this pool size", + ); + } + + const liquid = BigInt(config.liquidStroops); + const plan = planInstantSettlementDraw({ + requiredStroops: assetsOut, + liquidReserveStroops: liquid, + deployedToVaultStroops: tvl - liquid, + }); + if (plan.shortfallStroops > 0n) { + throw new ApiError( + 409, + "LIQUIDITY_SHORTFALL", + "Buffer plus full strategy recall cannot cover this withdrawal", + { + extra: { + drawPlan: { + source: plan.source, + requiredStroops: plan.requiredStroops.toString(), + liquidReserveStroops: plan.liquidReserveStroops.toString(), + recallFromVaultStroops: + plan.recallFromVaultStroops.toString(), + shortfallStroops: plan.shortfallStroops.toString(), + }, + }, + }, + ); + } + + let recalled = 0n; + // Rate check FIRST — nothing external may move before every + // fallible step succeeds. A fully-exited pool (no shares left) + // legitimately resets to the fresh-vault sentinel, so the + // monotonicity rule only binds while depositors remain. + const previousRate = scaledRate(tvl, shares); + const nextRate = scaledRate(tvl - assetsOut, shares - amount); + if (shares - amount > 0n) { + assertExchangeRateNeverDecreases( + previousRate, + nextRate, + `withdraw:${config.vaultId}`, + ); + } + + if (plan.recallFromVaultStroops > 0n) { + const receipt = await resolveAdapter().withdraw({ + assetAddress: config.assetAddress, + amountStroops: plan.recallFromVaultStroops, + }); + if (!receipt.ok) { + throw new ApiError( + 502, + "SERVICE_UNAVAILABLE", + "Instant strategy recall failed — withdrawal not settled", + { detail: receipt.detail }, + ); + } + recalled = plan.recallFromVaultStroops; + } + + // Mutations only happen after every fallible step above succeeded. + upsertProviderVaultShare({ + ...share, + shareBalance: (BigInt(share.shareBalance) - amount).toString(), + }); + saveYieldVaultConfig({ + ...config, + currentTvlStroops: (tvl - assetsOut).toString(), + liquidStroops: (liquid - (assetsOut - recalled)).toString(), + lastExchangeRateScaled: nextRate.toString(), + }); + + return reply.status(200).send({ + status: "success", + data: { + providerId, + vaultId: config.vaultId, + shareAmount: amount.toString(), + paidStroops: assetsOut.toString(), + drawPlan: { + source: plan.source, + recallFromVaultStroops: recalled.toString(), + }, + }, + }); + }); + }, + ); + + // POST /yield/vaults/rebalance — manual optimizer tick (admin): executes + // deploy/recall legs and compounds accrued yield exactly like the worker. + app.post<{ Body: z.infer }>( + "/yield/vaults/rebalance", + async (req, reply) => { + try { + requireAdminApiKeyHeader(req); + } catch (error) { + if (error instanceof ApiError) { + return reply.status(error.statusCode).send(error.toJSON(req.id)); + } + throw error; + } + + const parsed = RebalanceSchema.safeParse(req.body ?? {}); + if (!parsed.success) { + throw new ApiError(400, "VALIDATION_ERROR", "Invalid rebalance request", { + detail: parsed.error.issues.map((i) => i.message).join("; "), + }); + } + + const results = await runRebalanceTick( + { + adapter: resolveAdapter(), + logger: { + info: (obj, msg) => app.log.info(obj, msg), + warn: (obj, msg) => app.log.warn(obj, msg), + error: (obj, msg) => app.log.error(obj, msg), + }, + }, + { + pollIntervalMs: YIELD_VAULT.REBALANCE_POLL_MS, + vaultId: parsed.data.vaultId, + }, + ); + return reply.status(200).send({ + status: "success", + count: results.length, + data: results.map((r) => ({ + ...r, + decision: { + ...r.decision, + targetLiquidStroops: r.decision.targetLiquidStroops.toString(), + amountStroops: r.decision.amountStroops.toString(), + shortfallStroops: r.decision.shortfallStroops.toString(), + }, + })), + }); + }, + ); +} + + + diff --git a/contracts/escrow/src/lib.rs b/contracts/escrow/src/lib.rs index 8ca7d11..dcdd027 100644 --- a/contracts/escrow/src/lib.rs +++ b/contracts/escrow/src/lib.rs @@ -21,6 +21,12 @@ use soroban_sdk::{ BytesN, Env, Symbol, Vec, }; +/// (#408) Cross-asset yield aggregation vault + escrow-side rebalancing +/// entry points. See docs in the module itself. +pub mod yield_vault; + +use yield_vault::YieldVaultContractClient; + #[contracttype] #[derive(Clone, Debug, Eq, PartialEq)] pub struct ArbitratorSet { @@ -95,6 +101,11 @@ enum DataKey { /// Maximum escrow USD value allowed at lock time, in oracle base units /// (same scale as `price / 10^decimals`). `0` disables the limit. MaxUsdLimit, + /// (#408) Cross-asset yield aggregation: external yield-vault contract + /// holding idle escrow reserves above the liquid buffer. + YieldVaultAddress, + /// (#408) Total escrow underlying currently deployed into the yield vault. + DeployedToVault, } /// Ledgers that must elapse after `pause()` before `lock()` is rejected. @@ -2324,6 +2335,154 @@ impl EscrowContract { Ok(payout) } + + /* ------------------------------------------------------------------ */ + /* (#408) Cross-asset yield aggregation: reserve rebalancing */ + /* ------------------------------------------------------------------ */ + + /// Point the escrow at a deployed [`yield_vault::YieldVaultContract`] + /// instance for its settlement token. Multisig-gated like every treasury + /// movement; falls back to single-admin before migration. + pub fn set_yield_vault(env: Env, vault: Address, signers: Vec
) -> Result<(), Error> { + require_multisig(&env, &signers)?; + env.storage() + .instance() + .set(&DataKey::YieldVaultAddress, &vault); + Ok(()) + } + + /// Configured external yield vault, if any. + pub fn get_yield_vault(env: Env) -> Option
{ + env.storage().instance().get(&DataKey::YieldVaultAddress) + } + + /// Underlying currently deployed into the yield vault. + pub fn deployed_to_vault(env: Env) -> i128 { + env.storage() + .instance() + .get(&DataKey::DeployedToVault) + .unwrap_or(0) + } + + /// Instantly spendable liquid reserve: unallocated token balance held + /// directly by the escrow. Instant cash-trade settlements draw from here + /// first; `recall_from_vault` tops it back up when it runs low. + pub fn liquid_reserve(env: Env) -> i128 { + let Some(token_addr) = env + .storage() + .instance() + .get::(&DataKey::Token) + else { + return 0; + }; + token::Client::new(&env, &token_addr).balance(&env.current_contract_address()) + } + + /// Deploy idle reserves above the liquid buffer into the yield vault. + /// Multisig-gated; sizing comes from the off-chain buffer optimizer + /// (apps/api/src/lib/yield/buffer-optimizer.ts). Returns the new total + /// deployed amount. + pub fn deploy_idle_to_vault( + env: Env, + amount: i128, + signers: Vec
, + ) -> Result { + require_multisig(&env, &signers)?; + if amount <= 0 { + return Err(Error::InvalidAmount); + } + let vault: Address = env + .storage() + .instance() + .get(&DataKey::YieldVaultAddress) + .ok_or(Error::NotInitialized)?; + + // Push-model deposit: the escrow sends its own tokens into the vault + // first, and the vault credits them against a balance-delta check + // (no SAC allowance dance needed between contracts). + let token_addr: Address = env + .storage() + .instance() + .get(&DataKey::Token) + .ok_or(Error::NotInitialized)?; + token::Client::new(&env, &token_addr).transfer( + &env.current_contract_address(), + &vault, + &amount, + ); + + let client = YieldVaultContractClient::new(&env, &vault); + let minted = client.deposit(&env.current_contract_address(), &amount); + + let deployed = Self::deployed_to_vault(env.clone()) + amount; + env.storage() + .instance() + .set(&DataKey::DeployedToVault, &deployed); + + env.events().publish( + (symbol_short(&env, "vlt_deploy"), minted), + (amount, deployed), + ); + Ok(deployed) + } + + /// Permissionless instant recall: pull at least `min_amount` of + /// underlying back from the vault (everything deployed when `min_amount` + /// is 0), restoring the liquid buffer without trade-settlement latency. + /// Anyone may call it — recall only moves escrow-owned funds from the + /// vault back to the escrow. Returns the amount actually recalled. + pub fn recall_from_vault(env: Env, min_amount: i128) -> Result { + if min_amount < 0 { + return Err(Error::InvalidAmount); + } + let vault: Address = env + .storage() + .instance() + .get(&DataKey::YieldVaultAddress) + .ok_or(Error::NotInitialized)?; + let deployed = Self::deployed_to_vault(env.clone()); + if deployed == 0 { + // Nothing deployed: the buffer is already fully liquid. + return Ok(0); + } + let wanted = if min_amount == 0 || min_amount > deployed { + deployed + } else { + min_amount + }; + + let client = YieldVaultContractClient::new(&env, &vault); + let total_assets = client.total_assets(); + let total_shares = client.total_shares(); + // Full-drain recalls withdraw the escrow's ENTIRE share position so + // accrued yield rides home too; partial recalls round the share + // requirement UP so the payout can never undershoot the buffer + // top-up a waiting settlement depends on. + let shares = if min_amount == 0 || min_amount >= deployed { + client.share_balance(&env.current_contract_address()) + } else if total_assets <= 0 || total_shares <= 0 { + wanted + } else { + // ceil(wanted * shares / assets) + (wanted * total_shares + total_assets - 1) / total_assets + }; + + let received = client.withdraw(&env.current_contract_address(), &shares); + + // Tracking floors at zero: a full-drain recall legitimately pays out + // accrued yield ON TOP of the tracked deployment figure. + let tracked = Self::deployed_to_vault(env.clone()); + let remaining = if received >= tracked { 0 } else { tracked - received }; + env.storage() + .instance() + .set(&DataKey::DeployedToVault, &remaining); + + env.events().publish( + (symbol_short(&env, "vlt_recall"), received), + remaining, + ); + Ok(received) + } } /// Derives the id for a trade created via `chain_release_to_lock()`: @@ -4323,3 +4482,7 @@ mod unit_tests; #[cfg(test)] mod tranche_tests; + +/// (#408) Cross-asset yield vault share-math + escrow rebalance integration. +#[cfg(test)] +mod yield_vault_tests; diff --git a/contracts/escrow/src/yield_vault.rs b/contracts/escrow/src/yield_vault.rs new file mode 100644 index 0000000..30d6f3d --- /dev/null +++ b/contracts/escrow/src/yield_vault.rs @@ -0,0 +1,318 @@ +//! Cross-asset yield aggregation vault (#408). +//! +//! Idle escrow collateral earns nothing while it waits for a cash hand-off. +//! This module closes that gap with two pieces: +//! +//! 1. [`YieldVaultContract`] — a standalone Soroban share-accounting vault +//! (ERC-4626-style). Depositors receive shares minted pro-rata at the +//! current exchange rate; harvested yield is added to `total_assets` +//! WITHOUT minting shares, so the share price ratchets up. The +//! contributor-note invariant — the exchange rate must NEVER decrease +//! during harvesting (or any other operation) — is enforced arithmetically +//! (every rounding step favours existing depositors) and re-checked +//! defensively with an explicit `YieldError::RateWouldDecrease` guard. +//! +//! 2. Escrow-side rebalancing entry points live on `EscrowContract` itself +//! (see the `set_yield_vault` / `deploy_idle_to_vault` / +//! `recall_from_vault` / `deployed_to_vault` / `liquid_reserve` methods +//! in lib.rs). The multisig points the escrow at a deployed vault with +//! `set_yield_vault`, deploys idle reserves above the liquid buffer via +//! `deploy_idle_to_vault`, and anyone can permissionlessly top the liquid +//! buffer back up with `recall_from_vault` the instant trade-settlement +//! demand eats into it — recall only moves escrow-owned funds back to the +//! escrow, so it can never be used to attack a live trade. +//! +//! Off-chain, apps/api/src/lib/yield/buffer-optimizer.ts sizes the deploy / +//! recall legs and apps/api/src/lib/workers/yieldRebalanceWorker.ts drives +//! them periodically; these on-chain entry points stay deliberately dumb. + +use soroban_sdk::{ + contract, contracterror, contractimpl, contracttype, token, Address, Env, Symbol, +}; + +/// Fixed-point scale for share exchange rates: rates are reported as +/// `assets * RATE_SCALE / shares`, leaving 12 decimal digits of precision +/// before truncation. Must match EXCHANGE_RATE_SCALE in @velo/shared +/// (packages/shared/src/types/yield.ts). +pub const RATE_SCALE: i128 = 1_000_000_000_000; + +#[contracterror] +#[derive(Copy, Clone, Debug, Eq, PartialEq, PartialOrd, Ord)] +pub enum YieldError { + AlreadyInitialized = 1, + NotInitialized = 2, + /// Deposit / withdraw amount must be strictly positive. + InvalidAmount = 3, + /// Harvest amount must be strictly positive. + InvalidYield = 4, + /// Burning more shares than the provider holds. + InsufficientShares = 5, + /// Defensive guard: an operation attempted to lower the share exchange + /// rate. Normal math cannot produce this (rounding always favours + /// depositors), so hitting it means a bug — fail closed rather than + /// silently diluting every depositor. + RateWouldDecrease = 6, +} + +#[contracttype] +#[derive(Clone, Debug, Eq, PartialEq)] +enum VaultDataKey { + Admin, + Token, + TotalShares, + TotalAssets, + Share(Address), +} + +/// External yield strategy that idle escrow reserves are deployed into. +#[contract] +pub struct YieldVaultContract; + +#[contractimpl] +impl YieldVaultContract { + /* ------------------------------ views ----------------------------- */ + + pub fn total_shares(env: Env) -> i128 { + env.storage() + .instance() + .get(&VaultDataKey::TotalShares) + .unwrap_or(0) + } + + pub fn total_assets(env: Env) -> i128 { + env.storage() + .instance() + .get(&VaultDataKey::TotalAssets) + .unwrap_or(0) + } + + pub fn share_balance(env: Env, provider: Address) -> i128 { + env.storage() + .instance() + .get(&VaultDataKey::Share(provider)) + .unwrap_or(0) + } + + /// Current share exchange rate scaled by RATE_SCALE. A fresh vault + /// (no shares outstanding) reports 1:1. + pub fn exchange_rate(env: Env) -> i128 { + let shares = Self::total_shares(env.clone()); + let assets = Self::total_assets(env); + rate_scaled(assets, shares) + } + + /* ---------------------------- mutative ---------------------------- */ + + pub fn initialize(env: Env, admin: Address, token: Address) -> Result<(), YieldError> { + if env.storage().instance().has(&VaultDataKey::Admin) { + return Err(YieldError::AlreadyInitialized); + } + env.storage().instance().set(&VaultDataKey::Admin, &admin); + env.storage().instance().set(&VaultDataKey::Token, &token); + env.storage().instance().set(&VaultDataKey::TotalShares, &0i128); + env.storage().instance().set(&VaultDataKey::TotalAssets, &0i128); + Ok(()) + } + + /// Deposit `amount` underlying tokens that the provider has ALREADY + /// pushed to this vault, minting pro-rata shares. + /// + /// Push-model with a balance-delta guard: the function verifies the + /// vault's actual token balance covers the newly attributed assets, so + /// shares can never be minted against phantom inflows. Pushing (rather + /// than pulling) is what lets ANY owner deposit — including another + /// contract like the escrow, which cannot be forced through a bare + /// `transfer` initiated by someone else, and spares EOAs from managing + /// SAC allowances. + pub fn deposit(env: Env, provider: Address, amount: i128) -> Result { + if amount <= 0 { + return Err(YieldError::InvalidAmount); + } + provider.require_auth(); + + let token_addr: Address = env + .storage() + .instance() + .get(&VaultDataKey::Token) + .ok_or(YieldError::NotInitialized)?; + let shares_before = Self::total_shares(env.clone()); + let assets_before = Self::total_assets(env.clone()); + let rate_before = rate_scaled(assets_before, shares_before); + + // The pool only credits inflows it can actually see in its balance. + let untracked_inflow = + token::Client::new(&env, &token_addr).balance(&env.current_contract_address()) + - assets_before; + if untracked_inflow < amount { + // Nothing (or not enough) was pushed — refuse to mint air. + return Err(YieldError::InvalidAmount); + } + + let minted = assets_to_shares(amount, assets_before, shares_before); + let shares_after = shares_before + minted; + let assets_after = assets_before + amount; + ensure_rate_not_decreasing(rate_before, rate_scaled(assets_after, shares_after))?; + + env.storage() + .instance() + .set(&VaultDataKey::TotalShares, &shares_after); + env.storage() + .instance() + .set(&VaultDataKey::TotalAssets, &assets_after); + let balance = Self::share_balance(env.clone(), provider.clone()); + env.storage() + .instance() + .set(&VaultDataKey::Share(provider.clone()), &(balance + minted)); + + env.events().publish( + (Symbol::new(&env, "vlt_dep"), provider), + (amount, minted, rate_scaled(assets_after, shares_after)), + ); + Ok(minted) + } + +/// Burn `shares` and receive the corresponding underlying assets at the +/// current rate. Doubles as the instant-recall leg of the liquidity buffer: +/// settlement demand invokes it against the escrow's own position (wrapped +/// permissionlessly by `EscrowContract::recall_from_vault` below). +pub fn withdraw(env: Env, provider: Address, shares: i128) -> Result { + if shares <= 0 { + return Err(YieldError::InvalidAmount); + } + provider.require_auth(); + + let token_addr: Address = env + .storage() + .instance() + .get(&VaultDataKey::Token) + .ok_or(YieldError::NotInitialized)?; + let shares_before = Self::total_shares(env.clone()); + let assets_before = Self::total_assets(env.clone()); + let rate_before = rate_scaled(assets_before, shares_before); + + let balance = Self::share_balance(env.clone(), provider.clone()); + if balance < shares { + return Err(YieldError::InsufficientShares); + } + + let payout = shares_to_assets(shares, assets_before, shares_before); + if payout <= 0 { + // Burning shares for a zero payout silently destroys value. + return Err(YieldError::InvalidAmount); + } + + let shares_after = shares_before - shares; + let assets_after = assets_before - payout; + // A fully-exited pool legitimately resets to the fresh-vault sentinel + // (1:1); the monotonicity rule only binds while depositors remain to be + // protected by it. + if shares_after > 0 { + ensure_rate_not_decreasing(rate_before, rate_scaled(assets_after, shares_after))?; + } + + env.storage() + .instance() + .set(&VaultDataKey::TotalShares, &shares_after); + env.storage() + .instance() + .set(&VaultDataKey::TotalAssets, &assets_after); + env.storage() + .instance() + .set(&VaultDataKey::Share(provider.clone()), &(balance - shares)); + + token::Client::new(&env, &token_addr).transfer( + &env.current_contract_address(), + &provider, + &payout, + ); + + env.events().publish( + (Symbol::new(&env, "vlt_wdr"), provider), + (shares, payout), + ); + Ok(payout) +} + +/// Settle one harvest: pull `amount` of accrued strategy yield into the +/// vault. Shares outstanding are unchanged, so the exchange rate rises and +/// every depositor's claim appreciates proportionally — the exact operation +/// the "rate must never decrease" invariant protects. Returns the new +/// scaled rate. +pub fn harvest(env: Env, strategy: Address, amount: i128) -> Result { + if amount <= 0 { + return Err(YieldError::InvalidYield); + } + strategy.require_auth(); + + let token_addr: Address = env + .storage() + .instance() + .get(&VaultDataKey::Token) + .ok_or(YieldError::NotInitialized)?; + let shares_before = Self::total_shares(env.clone()); + let assets_before = Self::total_assets(env.clone()); + let rate_before = rate_scaled(assets_before, shares_before); + + token::Client::new(&env, &token_addr).transfer( + &strategy, + &env.current_contract_address(), + &amount, + ); + + let assets_after = assets_before + amount; + ensure_rate_not_decreasing(rate_before, rate_scaled(assets_after, shares_before))?; + + env.storage() + .instance() + .set(&VaultDataKey::TotalAssets, &assets_after); + + let new_rate = rate_scaled(assets_after, shares_before); + env.events().publish( + (Symbol::new(&env, "vlt_hrv"), strategy), + (amount, new_rate), + ); + Ok(new_rate) +} +} + +fn rate_scaled(total_assets: i128, total_shares: i128) -> i128 { + if total_shares <= 0 { + RATE_SCALE + } else { + (total_assets * RATE_SCALE) / total_shares + } +} + +/// Round DOWN when converting assets to shares so a depositor can never +/// receive more than their assets are worth — rounding dust accrues to the +/// vault and nudges the rate up instead of down. +fn assets_to_shares(assets: i128, total_assets: i128, total_shares: i128) -> i128 { + if total_assets <= 0 || total_shares <= 0 { + // Fresh vault: 1:1. + assets + } else { + (assets * total_shares) / total_assets + } +} + +/// Round DOWN payouts so a withdrawing depositor can never take more than +/// their pro-rata slice — residual dust stays and pushes the rate up. +fn shares_to_assets(shares: i128, total_assets: i128, total_shares: i128) -> i128 { + if total_shares <= 0 { + shares + } else { + (shares * total_assets) / total_shares + } +} + +fn ceil_div(numer: i128, denom: i128) -> i128 { + (numer + denom - 1) / denom +} + +fn ensure_rate_not_decreasing(before: i128, after: i128) -> Result<(), YieldError> { + if after < before { + Err(YieldError::RateWouldDecrease) + } else { + Ok(()) + } +} diff --git a/contracts/escrow/src/yield_vault_tests.rs b/contracts/escrow/src/yield_vault_tests.rs new file mode 100644 index 0000000..c4d8539 --- /dev/null +++ b/contracts/escrow/src/yield_vault_tests.rs @@ -0,0 +1,366 @@ +//! Share-math tests for the cross-asset yield aggregation vault (#408). +//! +//! Coverage map from the issue's test plan: +//! - "Share Math Test": pro-rata minting, harvest raising the exchange rate, +//! and the contributor-note invariant that the rate NEVER decreases. +//! - Escrow integration: idle-reserve deployment + instant recall for +//! settlement buffer top-ups. + +use crate::yield_vault::{YieldVaultContract, YieldVaultContractClient, RATE_SCALE}; +use crate::{ArbitratorSet, EscrowContract, EscrowContractClient}; +use soroban_sdk::{ + testutils::Address as _, + token::{Client as TokenClient, StellarAssetClient}, + Address, Env, Vec, +}; + +/// Vault + token fixture. `mock_all_auths` stands in for provider and +/// strategy signatures; production auth paths are exercised on-chain. +struct VaultFixture { + env: Env, + client: YieldVaultContractClient<'static>, + token: TokenClient<'static>, + token_admin: StellarAssetClient<'static>, + admin: Address, + strategy: Address, + vault_id: Address, +} + +/// Push-then-deposit convenience mirroring the real user flow: transfer +/// underlying into the vault, then claim the pro-rata shares. +fn deposit_for(f: &VaultFixture, holder: &Address, amount: i128) -> i128 { + f.token + .transfer(holder, &f.vault_id, &amount); + f.client.deposit(holder, &amount) +} + +fn vault_setup() -> VaultFixture { + let env = Env::default(); + env.mock_all_auths(); + + let admin = Address::generate(&env); + let token_admin = Address::generate(&env); + let sac = env.register_stellar_asset_contract_v2(token_admin.clone()); + let token = TokenClient::new(&env, &sac.address()); + let token_admin_client = StellarAssetClient::new(&env, &sac.address()); + let strategy = Address::generate(&env); + + let contract_id = env.register_contract(None, YieldVaultContract); + let client = YieldVaultContractClient::new(&env, &contract_id); + client.initialize(&admin, &token.address); + + // Fund the simulated external yield strategy so `harvest()` transfers + // settle against real balances. + token_admin_client.mint(&strategy, &1_000_000); + + VaultFixture { + env, + client, + token, + token_admin: token_admin_client, + admin, + strategy, + vault_id: contract_id.clone(), + } +} + +/// Full escrow fixture (mirrors tranche_tests setup) plus a registered yield +/// vault and idle reserves minted straight to the escrow contract address. +struct EscrowFixture { + env: Env, + client: EscrowContractClient<'static>, + vault_client: YieldVaultContractClient<'static>, + token: TokenClient<'static>, + token_admin: StellarAssetClient<'static>, +} + +fn escrow_with_yield_vault(initial_escrow_balance: i128) -> EscrowFixture { + let env = Env::default(); + env.mock_all_auths(); + + let admin = Address::generate(&env); + let token_admin = Address::generate(&env); + let sac = env.register_stellar_asset_contract_v2(token_admin.clone()); + let token = TokenClient::new(&env, &sac.address()); + let token_admin_client = StellarAssetClient::new(&env, &sac.address()); + + let contract_id = env.register_contract(None, EscrowContract); + let client = EscrowContractClient::new(&env, &contract_id); + + let arb_set = ArbitratorSet { + keys: Vec::new(&env), + threshold_epoch1: 1, + threshold_epoch2: 2, + t1_ledgers: 100, + t2_ledgers: 200, + }; + client.initialize(&admin, &token.address, &0u32, &arb_set); + + // Seed idle reserves directly at the escrow contract address. + token_admin_client.mint(&contract_id, &initial_escrow_balance); + + // Deploy the external yield vault and point the escrow at it. + let vault_id = env.register_contract(None, YieldVaultContract); + let vault_client = YieldVaultContractClient::new(&env, &vault_id); + vault_client.initialize(&admin, &token.address); + client.set_yield_vault(&vault_id, &Vec::new(&env)); + + EscrowFixture { + env, + client, + vault_client, + token, + token_admin: token_admin_client, + } +} + +fn assert_rate_holds(before: i128, after: i128) { + assert!( + after >= before, + "exchange rate decreased: {before} -> {after}" + ); +} + +/// Core share-math scenario from the issue's test plan: pro-rata minting, +/// harvest raising the exchange rate without minting shares, and every +/// depositor exiting at their appreciated slice. +#[test] +fn deposit_mints_proportional_shares_and_harvest_raises_rate() { + let f = vault_setup(); + let alice = Address::generate(&f.env); + let bob = Address::generate(&f.env); + f.token_admin.mint(&alice, &10_000); + f.token_admin.mint(&bob, &10_000); + + // First deposit seeds the pool at 1:1; second is exactly pro-rata. + let alice_shares = deposit_for(&f, &alice, 1_000); + assert_eq!(alice_shares, 1_000); + let bob_shares = deposit_for(&f, &bob, 1_000); + assert_eq!(bob_shares, 1_000); + + assert_eq!(f.client.total_shares(), 2_000); + assert_eq!(f.client.total_assets(), 2_000); + assert_eq!(f.client.exchange_rate(), RATE_SCALE); + + // Harvest 80 of strategy yield: assets rise to 2_080 with shares still + // 2_000 → the rate ratchets to 1.04 and NO new shares are minted. + let new_rate = f.client.harvest(&f.strategy, &80); + assert_eq!(f.client.total_shares(), 2_000); + let expected = 1_040 * RATE_SCALE / 1_000; + assert_eq!(new_rate, expected); + assert_eq!(f.client.exchange_rate(), expected); + + // Each provider exits at their appreciated pro-rata slice of the pool. + let payout = f.client.withdraw(&alice, &alice_shares); + assert_eq!(payout, 1_040); + assert_eq!(f.token.balance(&alice), 10_040); + + let bob_payout = f.client.withdraw(&bob, &bob_shares); + assert_eq!(bob_payout, 1_040); + assert_eq!(f.client.total_assets(), 0); + assert_eq!(f.client.total_shares(), 0); +} + +/// The contributor-note invariant exercised over an adversarial interleaving +/// of deposits, harvests and partial withdrawals: the scaled exchange rate is +/// monotonic non-decreasing at EVERY step. +#[test] +fn exchange_rate_never_decreases_through_full_lifecycle() { + let f = vault_setup(); + let alice = Address::generate(&f.env); + let bob = Address::generate(&f.env); + f.token_admin.mint(&alice, &100_000); + f.token_admin.mint(&bob, &100_000); + + let mut prev_rate = f.client.exchange_rate(); + + let actions: [(&str, i128); 8] = [ + ("dep_alice", 5_000), + ("dep_bob", 7_500), + ("harvest", 120), + ("dep_alice", 3_333), + ("harvest", 611), + ("wd_alice", 9_999), + ("harvest", 97), + ("wd_bob", 15_001), + ]; + for (kind, amount) in actions { + match kind { + "dep_alice" => { + let _ = deposit_for(&f, &alice, amount); + } + "dep_bob" => { + let _ = deposit_for(&f, &bob, amount); + } + "harvest" => { + let _ = f.client.harvest(&f.strategy, &amount); + } + _ => { + let who = if kind == "wd_alice" { &alice } else { &bob }; + let balance = f.client.share_balance(who); + if balance > 0 { + f.client.withdraw(who, &amount.min(balance)); + } + } + } + let rate = if f.client.total_shares() > 0 { + // Live pool: the ratchet must hold. + f.client.exchange_rate() + } else { + // Pool fully exited: the sentinel 1:1 reset is by design, not a + // regression — freeze comparison at the last live rate. + prev_rate + }; + assert_rate_holds(prev_rate, rate); + prev_rate = rate; + } + assert!(prev_rate >= RATE_SCALE); +} + +/// A sub-unit-rate pool makes a 1-stroop deposit mint floor(1·S/S') = 0 +/// shares while still adding its asset — rounding dust can only push the +/// rate UP, never down. +#[test] +fn dust_deposit_never_lowers_the_rate() { + let f = vault_setup(); + let whale = Address::generate(&f.env); + let dust = Address::generate(&f.env); + f.token_admin.mint(&whale, &1_000_000); + f.token_admin.mint(&dust, &10); + + deposit_for(&f, &whale, 1_000); + // Skew the pool above 1:1 (rate = 2) so fresh shares cost 2 assets. + f.client.harvest(&f.strategy, &1_000); + + let before = f.client.exchange_rate(); + let minted = deposit_for(&f, &dust, 1); + assert_eq!(minted, 0); // floor(1 × 1_000 / 2_000) + assert_rate_holds(before, f.client.exchange_rate()); +} + +/// Zero / negative amounts and over-spends must fail closed. Error +/// discriminants surface as `Error(Contract, #N)` panics (repo convention). +#[test] +#[should_panic(expected = "3")] // YieldError::InvalidAmount +fn zero_deposit_panics() { + let f = vault_setup(); + let alice = Address::generate(&f.env); + deposit_for(&f, &alice, 0); +} + +#[test] +#[should_panic(expected = "4")] // YieldError::InvalidYield +fn zero_harvest_panics() { + let f = vault_setup(); + f.client.harvest(&f.strategy, &0); +} + +#[test] +#[should_panic(expected = "5")] // YieldError::InsufficientShares +fn overdraft_withdrawal_panics() { + let f = vault_setup(); + let alice = Address::generate(&f.env); + f.token_admin.mint(&alice, &1_000); + deposit_for(&f, &alice, 1_000); + f.client.withdraw(&alice, &1_001); +} + +// NOTE: a dedicated zero-payout-withdrawal case is unnecessary — the share +// rate starts at 1:1 and can only ratchet up, so while any shares exist +// their floor payout is ≥ 1 stroop; the defensive branch stays in the +// contract for future pool shapes. + +#[test] +#[should_panic(expected = "1")] // YieldError::AlreadyInitialized +fn double_initialization_panics() { + let f = vault_setup(); + f.client.initialize(&f.admin, &f.token.address); +} +/* --------------------- escrow integration tests ----------------------- */ + +#[test] +fn escrow_deploys_idle_reserves_and_recalls_for_settlements() { + let f = escrow_with_yield_vault(10_000); + + // Nothing deployed yet: the whole balance sits in the liquid buffer. + assert_eq!(f.client.deployed_to_vault(), 0); + assert_eq!(f.client.liquid_reserve(), 10_000); + + // Deploy 6_000 idle reserves above the buffer into the yield strategy. + let deployed = f.client.deploy_idle_to_vault(&6_000, &Vec::new(&f.env)); + assert_eq!(deployed, 6_000); + assert_eq!(f.client.deployed_to_vault(), 6_000); + assert_eq!(f.client.liquid_reserve(), 4_000); + assert_eq!(f.vault_client.total_assets(), 6_000); + assert_eq!( + f.vault_client.share_balance(&f.client.address), + 6_000 + ); + + // Yield accrues while funds are deployed (fund the strategy first so its + // harvest transfer settles against a real balance)… + let strategy = Address::generate(&f.env); + f.token_admin.mint(&strategy, &1_000); + f.vault_client.harvest(&strategy, &60); + assert!(f.vault_client.exchange_rate() > RATE_SCALE); + + // …and an instant settlement draw recalls ≥ the required amount even at + // the higher rate (the share requirement rounds up). + let recalled = f.client.recall_from_vault(&2_500); + assert!(recalled >= 2_500); + assert_eq!(f.client.deployed_to_vault(), 6_000 - recalled); + assert_eq!(f.client.liquid_reserve(), 4_000 + recalled); + + // Recall with 0 drains the escrow's ENTIRE share position, so every + // stroop — original deployment AND accrued yield — rides home. + let rest = f.client.recall_from_vault(&0); + assert!(rest > 0); + assert_eq!(f.client.deployed_to_vault(), 0); + assert_eq!(f.vault_client.total_shares(), 0); + assert_eq!(f.vault_client.total_assets(), 0); + // Escrow ends whole: original balance + the full harvested yield. + assert_eq!(f.client.liquid_reserve(), 10_000 + 60); +} + +#[test] +fn recall_with_nothing_deployed_is_a_no_op() { + let f = escrow_with_yield_vault(5_000); + assert_eq!(f.client.recall_from_vault(&1_234), 0); + assert_eq!(f.client.deployed_to_vault(), 0); + assert_eq!(f.client.liquid_reserve(), 5_000); +} + +#[test] +#[should_panic(expected = "2")] // Error::NotInitialized — no vault configured +fn recall_before_set_yield_vault_panics() { + let env = Env::default(); + env.mock_all_auths(); + let token_admin = Address::generate(&env); + let sac = env.register_stellar_asset_contract_v2(token_admin); + let contract_id = env.register_contract(None, EscrowContract); + let client = EscrowContractClient::new(&env, &contract_id); + client.initialize( + &Address::generate(&env), + &sac.address(), + &0u32, + &ArbitratorSet { + keys: Vec::new(&env), + threshold_epoch1: 1, + threshold_epoch2: 2, + t1_ledgers: 100, + t2_ledgers: 200, + }, + ); + client.recall_from_vault(&100); +} + +#[test] +#[should_panic(expected = "8")] // Error::InvalidAmount +fn deploy_rejects_non_positive_amounts() { + let f = escrow_with_yield_vault(1_000); + f.client + .deploy_idle_to_vault(&0, &Vec::new(&f.env)); +} + + + diff --git a/mobile/frontend/src/components/BufferRatioSlider.tsx b/mobile/frontend/src/components/BufferRatioSlider.tsx new file mode 100644 index 0000000..3d20683 --- /dev/null +++ b/mobile/frontend/src/components/BufferRatioSlider.tsx @@ -0,0 +1,77 @@ +/** + * Controlled slider for tuning a vault's dynamic liquid-buffer ratio (#408). + * + * The value is a fraction (0..1); bounds default to the shared YIELD_VAULT + * constants so the UI can never propose a ratio the optimizer would clamp. + * All display strings arrive as props/translations from the parent page. + */ + +import React from "react"; +import { YIELD_VAULT } from "@velo/shared"; + +export interface BufferRatioSliderProps { + value: number; + onChange: (next: number) => void; + onCommit?: () => void; + min?: number; + max?: number; + step?: number; + disabled?: boolean; + /** Translated accessible label (attribute is flagged when literal). */ + ariaLabel: string; +} + +export function BufferRatioSlider({ + value, + onChange, + onCommit, + min = YIELD_VAULT.MIN_LIQUID_BUFFER_RATIO, + max = YIELD_VAULT.MAX_LIQUID_BUFFER_RATIO, + step = 0.01, + disabled = false, + ariaLabel, +}: BufferRatioSliderProps): React.ReactElement { + const clamped = Math.min(max, Math.max(min, value)); + const percent = ((clamped - min) / (max - min)) * 100; + + return ( +
+ onChange(Number(event.target.value))} + onMouseUp={onCommit} + onTouchEnd={onCommit} + onKeyUp={onCommit} + style={{ width: "100%", accentColor: "#16a34a" }} + /> +
+ {`${Math.round(min * 100)}%`} + + {`${(clamped * 100).toFixed(0)}%`} + + {`${Math.round(max * 100)}%`} +
+
+ ); +} + +export default BufferRatioSlider; diff --git a/mobile/frontend/src/components/YieldApyChart.tsx b/mobile/frontend/src/components/YieldApyChart.tsx new file mode 100644 index 0000000..9c6c0fb --- /dev/null +++ b/mobile/frontend/src/components/YieldApyChart.tsx @@ -0,0 +1,99 @@ +/** + * APY history sparkline for the provider yield portal (#408). + * + * Pure inline SVG — no chart dependency exists in this package, so the + * component renders a polyline + gradient area over normalized points. + * All display strings arrive as props so the localization validator sees no + * raw literals here; the page passes t()-translated labels. + */ + +import React from "react"; + +export interface YieldApyChartProps { + /** APY observations in basis points, oldest → newest. */ + apyBps: number[]; + width?: number; + height?: number; + /** Accessible description (translated by the parent). */ + ariaLabel: string; + /** Formatters translated by the parent, e.g. `4.2%` / `600 bps`. */ + formatBps?: (bps: number) => string; +} + +export function YieldApyChart({ + apyBps, + width = 320, + height = 96, + ariaLabel, + formatBps = (bps) => `${(bps / 100).toFixed(1)}%`, +}: YieldApyChartProps): React.ReactElement { + if (apyBps.length === 0) { + return ; + } + + const min = Math.min(...apyBps); + const max = Math.max(...apyBps); + const span = max > min ? max - min : 1; + const pad = 6; + + const pointAt = (value: number, index: number): string => { + const x = + apyBps.length === 1 + ? width / 2 + : pad + (index * (width - 2 * pad)) / (apyBps.length - 1); + const y = height - pad - ((value - min) / span) * (height - 2 * pad); + return `${x.toFixed(1)},${y.toFixed(1)}`; + }; + + const linePoints = apyBps.map(pointAt).join(" "); + const areaPoints = [ + `${pad},${height - pad}`, + ...apyBps.map(pointAt), + `${width - pad},${height - pad}`, + ].join(" "); + + return ( + + + + + + + + + + + {formatBps(max)} + + + {formatBps(min)} + + + + ); +} + +export default YieldApyChart; diff --git a/mobile/frontend/src/i18n/locales/en.json b/mobile/frontend/src/i18n/locales/en.json index 602e144..b92ef23 100644 --- a/mobile/frontend/src/i18n/locales/en.json +++ b/mobile/frontend/src/i18n/locales/en.json @@ -315,5 +315,33 @@ "copyAttestation": "Copy Attestation", "download": "Download", "id": "ID:" + }, + "yieldPortal": { + "title": "Provider Yield Portal", + "subtitle": "Idle collateral earns 4-8% APY while a dynamic 20% liquid buffer keeps cash trades instant.", + "refresh": "Refresh", + "loading": "Loading vaults…", + "noVaults": "No yield vaults configured yet.", + "loadFailed": "Could not reach the yield service.", + "dismissError": "Dismiss", + "adminKeyNeeded": "An admin API key is required for this action.", + "harvest": "Harvest yield", + "harvesting": "Harvesting…", + "harvestDone": "Yield harvested and compounded.", + "applyRatio": "Apply buffer", + "applying": "Applying…", + "ratioApplied": "Liquid buffer target set to {{percent}}%.", + "bufferSliderLabel": "Liquid buffer ratio", + "chartAlt": "APY history chart", + "tvl": "TVL", + "deployed": "Deployed to strategy", + "liquidBuffer": "Liquid buffer", + "apy": "APY", + "rateLabel": "Share rate", + "vaultSection": "Yield vault", + "bufferAction": "Buffer action", + "yourShares": "Your shares", + "yourValueUsdc": "Your value", + "checkPosition": "Check my position" } } diff --git a/mobile/frontend/src/i18n/locales/es.json b/mobile/frontend/src/i18n/locales/es.json index d092655..0343abd 100644 --- a/mobile/frontend/src/i18n/locales/es.json +++ b/mobile/frontend/src/i18n/locales/es.json @@ -315,5 +315,33 @@ "copyAttestation": "Copiar atestación", "download": "Descargar", "id": "ID:" + }, + "yieldPortal": { + "title": "Portal de Rendimiento para Proveedores", + "subtitle": "El colateral inactivo gana 4-8% APY mientras un búfer líquido dinámico del 20% mantiene los retiros al instante.", + "refresh": "Actualizar", + "loading": "Cargando bóvedas…", + "noVaults": "Aún no hay bóvedas de rendimiento configuradas.", + "loadFailed": "No se pudo contactar el servicio de rendimiento.", + "dismissError": "Cerrar", + "adminKeyNeeded": "Se requiere una clave de API de administrador para esta acción.", + "harvest": "Cosechar rendimiento", + "harvesting": "Cosechando…", + "harvestDone": "Rendimiento cosechado y capitalizado.", + "applyRatio": "Aplicar búfer", + "applying": "Aplicando…", + "ratioApplied": "Objetivo del búfer líquido fijado en {{percent}}%.", + "bufferSliderLabel": "Proporción del búfer líquido", + "chartAlt": "Gráfico del historial de APY", + "tvl": "TVL", + "deployed": "Asignado a estrategia", + "liquidBuffer": "Búfer líquido", + "apy": "APY", + "rateLabel": "Tasa de participación", + "vaultSection": "Bóveda de rendimiento", + "bufferAction": "Acción del búfer", + "yourShares": "Tus participaciones", + "yourValueUsdc": "Tu valor", + "checkPosition": "Ver mi posición" } } diff --git a/mobile/frontend/src/main.tsx b/mobile/frontend/src/main.tsx index e9dbdfb..0944d9b 100644 --- a/mobile/frontend/src/main.tsx +++ b/mobile/frontend/src/main.tsx @@ -14,6 +14,7 @@ import AdminDashboard from "./pages/AdminDashboard.js"; import AdminCircuitBreakerDashboard from "./pages/AdminCircuitBreakerDashboard.js"; import EnterpriseDashboard from "./pages/EnterpriseDashboard.js"; import EnterpriseApprovals from "./pages/EnterpriseApprovals.js"; +import ProviderYieldPortal from "./pages/ProviderYieldPortal.js"; import NotFound from "./pages/NotFound.js"; import { ErrorBoundary } from "./components/ErrorBoundary.js"; @@ -42,6 +43,8 @@ ReactDOM.createRoot(document.getElementById("root")!).render( } /> } /> } /> + {/* (#408) Provider-facing yield aggregation dashboard. */} + } /> } /> diff --git a/mobile/frontend/src/pages/ProviderYieldPortal.tsx b/mobile/frontend/src/pages/ProviderYieldPortal.tsx new file mode 100644 index 0000000..636c79d --- /dev/null +++ b/mobile/frontend/src/pages/ProviderYieldPortal.tsx @@ -0,0 +1,285 @@ +/** + * Provider Yield Portal (#408). + * + * React dashboard for the automated liquidity-reserve rebalancing & + * cross-asset yield-aggregation vaults: live APY history chart, TVL / + * buffer health per settlement asset, one-tap yield harvest, and the + * dynamic 20% liquid-buffer slider that retunes the optimizer target. + * + * Talks to the public/admin endpoints under /api/v1/yield/*; every visible + * string flows through i18n so the localization gate stays green. + */ + +import React, { useCallback, useEffect, useState } from "react"; +import { useTranslation } from "react-i18next"; +import type { YieldVaultConfig } from "@velo/shared"; +import { YIELD_VAULT } from "@velo/shared"; +import { YieldApyChart } from "../components/YieldApyChart.js"; +import { BufferRatioSlider } from "../components/BufferRatioSlider.js"; + +interface BufferHealth { + liquidStroops: string; + targetLiquidStroops: string; + action: string; + shortfallStroops: string; + ratioNowScaled: string; +} + +interface VaultView extends YieldVaultConfig { + exchangeRateScaled: string; + deployedStroops: string; + strategyName?: string; + apyHistoryBps?: number[]; + buffer: BufferHealth; +} + +interface PositionView { + shareBalance: string; + valueStroops: string; +} + +export interface ProviderYieldPortalProps { + /** API origin; defaults to same-origin relative paths. */ + apiBaseUrl?: string; + /** Linked provider identity; position panel stays idle when absent. */ + providerId?: string; + /** Required for the harvest / ratio actions; read-only view without it. */ + adminApiKey?: string; + pollIntervalMs?: number; +} + +function usdc(stroops: string | bigint): string { + return (Number(BigInt(stroops)) / 1e7).toFixed(2); +} + +const SCALE = YIELD_VAULT.EXCHANGE_RATE_SCALE; + +export function ProviderYieldPortal({ + apiBaseUrl = "", + providerId = "", + adminApiKey, + pollIntervalMs = 30_000, +}: ProviderYieldPortalProps): React.ReactElement { + const { t } = useTranslation(); + const [vaults, setVaults] = useState([]); + const [position, setPosition] = useState(null); + const [error, setError] = useState(null); + const [notice, setNotice] = useState(null); + const [busy, setBusy] = useState<"harvest" | "ratio" | null>(null); + const [pendingRatios, setPendingRatios] = useState>({}); + + const requireAdmin = useCallback((): Record | null => { + if (!adminApiKey) { + setError(t("yieldPortal.adminKeyNeeded")); + return null; + } + return { "x-admin-api-key": adminApiKey }; + }, [adminApiKey, t]); + + const refresh = useCallback(async (): Promise => { + try { + const res = await fetch(`${apiBaseUrl}/api/v1/yield/vaults`); + if (!res.ok) throw new Error(`HTTP ${res.status}`); + const body = (await res.json()) as { data: VaultView[] }; + setVaults(body.data ?? []); + setError(null); + } catch (err) { + setError(err instanceof Error ? err.message : t("yieldPortal.loadFailed")); + } + }, [apiBaseUrl, t]); + + const loadPosition = useCallback( + async (vaultId: string): Promise => { + try { + const res = await fetch( + `${apiBaseUrl}/api/v1/yield/vaults/${vaultId}/providers/${encodeURIComponent(providerId)}`, + ); + if (!res.ok) throw new Error(`HTTP ${res.status}`); + const body = (await res.json()) as { data: PositionView }; + setPosition(body.data); + } catch { + setPosition(null); + } + }, + [apiBaseUrl, providerId], + ); + + useEffect(() => { + void refresh(); + const timer = setInterval(() => void refresh(), pollIntervalMs); + return () => clearInterval(timer); + }, [refresh, pollIntervalMs]); + + const handleHarvest = async (vault: VaultView): Promise => { + const headers = requireAdmin(); + if (!headers) return; + try { + setBusy("harvest"); + // The server-side tick folds accrued strategy APY into TVL (the + // harvest) and re-runs the buffer optimizer in one atomic pass. + const res = await fetch(`${apiBaseUrl}/api/v1/yield/vaults/rebalance`, { + method: "POST", + headers: { "content-type": "application/json", ...headers }, + body: JSON.stringify({ vaultId: vault.vaultId }), + }); + if (!res.ok) { + const body = await res.json().catch(() => ({ code: `HTTP ${res.status}` })); + throw new Error(String(body.code)); + } + setNotice(t("yieldPortal.harvestDone")); + await refresh(); + await loadPosition(vault.vaultId); + } catch (err) { + setError(err instanceof Error ? err.message : t("yieldPortal.loadFailed")); + } finally { + setBusy(null); + } + }; + + const handleApplyRatio = async (vault: VaultView): Promise => { + const headers = requireAdmin(); + if (!headers) return; + const ratio = pendingRatios[vault.vaultId]; + if (ratio === undefined) return; + try { + setBusy("ratio"); + const res = await fetch(`${apiBaseUrl}/api/v1/yield/vaults/config`, { + method: "POST", + headers: { "content-type": "application/json", ...headers }, + body: JSON.stringify({ + assetAddress: vault.assetAddress, + liquidBufferRatio: ratio, + }), + }); + if (!res.ok) { + const body = await res.json().catch(() => ({ code: `HTTP ${res.status}` })); + throw new Error(String(body.code)); + } + setNotice(t("yieldPortal.ratioApplied", { percent: (ratio * 100).toFixed(0) })); + setPendingRatios((prev) => { + const next = { ...prev }; + delete next[vault.vaultId]; + return next; + }); + await refresh(); + } catch (err) { + setError(err instanceof Error ? err.message : t("yieldPortal.loadFailed")); + } finally { + setBusy(null); + } + }; + + return ( +
+
+
+

{t("yieldPortal.title")}

+

{t("yieldPortal.subtitle")}

+
+ +
+ + {error && ( +
+

{error}

+ +
+ )} + {notice && ( +
+

{notice}

+ +
+ )} + + {vaults.length === 0 ? ( +

{busy === null ? t("yieldPortal.noVaults") : t("yieldPortal.loading")}

+ ) : ( + vaults.map((vault) => { + const pending = pendingRatios[vault.vaultId] ?? vault.liquidBufferRatio; + const rate = BigInt(vault.exchangeRateScaled || SCALE.toString()); + const apyNow = vault.apyHistoryBps?.[vault.apyHistoryBps.length - 1] ?? 0; + return ( +
+

+ {`${vault.assetAddress.slice(0, 10)}… · ${vault.strategyName ?? ""}`} +

+ +
+
{t("yieldPortal.tvl")}
{`$${usdc(vault.currentTvlStroops)}`}
+
{t("yieldPortal.deployed")}
{`$${usdc(vault.deployedStroops)}`}
+
{t("yieldPortal.liquidBuffer")}
{`$${usdc(vault.buffer.liquidStroops)}`}
+
{t("yieldPortal.apy")}
{`${(apyNow / 100).toFixed(2)}%`}
+
{t("yieldPortal.rateLabel")}
{(Number(rate) / Number(SCALE)).toFixed(4)}
+
+ + + +
+ {t("yieldPortal.bufferSliderLabel")} + + setPendingRatios((prev) => ({ ...prev, [vault.vaultId]: next })) + } + disabled={busy !== null} + ariaLabel={t("yieldPortal.bufferSliderLabel")} + /> + {pendingRatios[vault.vaultId] !== undefined && + pendingRatios[vault.vaultId] !== vault.liquidBufferRatio && ( + + )} +
+ +
+ + + {`${t("yieldPortal.bufferAction")}: ${vault.buffer.action}`} + +
+ +
+ {position ? ( + + {`${t("yieldPortal.yourShares")}: ${position.shareBalance} · ${t("yieldPortal.yourValueUsdc")}: $${usdc(position.valueStroops)}`} + + ) : ( + + )} +
+
+ ); + }) + )} +
+ ); +} + +export default ProviderYieldPortal; + diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index 7edbe6e..dedebe8 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -266,3 +266,20 @@ export type { } from "./types/zk-range.js"; export { RANGE_PROOF_PARAMS, ATTRIBUTE_RANGES } from "./types/zk-range.js"; + +/* ------------------------------------------------------------------ */ +/* Automated Liquidity Reserve Rebalancing & Cross-Asset Yield */ +/* Aggregation Vault (#408) */ +/* ------------------------------------------------------------------ */ + +export type { + YieldVaultConfig, + ProviderVaultShare, + ApySample, + HarvestResult, + StrategyPosition, + BufferDecision, + LiquidityDrawPlan, +} from "./types/yield.js"; + +export { YIELD_VAULT } from "./types/yield.js"; diff --git a/packages/shared/src/types/yield.ts b/packages/shared/src/types/yield.ts new file mode 100644 index 0000000..0012f38 --- /dev/null +++ b/packages/shared/src/types/yield.ts @@ -0,0 +1,103 @@ +/** + * Automated Liquidity Reserve Rebalancing & Cross-Asset Yield + * Aggregation Vault types (#408). + * + * Single source of truth for the API route layer + * (apps/api/src/routes/yield-vaults.ts), the rebalance worker + * (apps/api/src/lib/workers/yieldRebalanceWorker.ts) and the provider + * portal UI (mobile/frontend/src/pages/ProviderYieldPortal.tsx). + */ + +/** A configured yield vault for one settlement asset. Mirrors `yield_vault_configs`. */ +export interface YieldVaultConfig { + vaultId: string; + /** Stellar asset contract address (C…) of the vaulted asset. */ + assetAddress: string; + /** Fraction (0..1) of TVL kept instantly withdrawable, e.g. 0.20. */ + liquidBufferRatio: number; + /** Total value locked including funds deployed into strategies (stroops). */ + currentTvlStroops: string; +} + +/** Per-provider share position. Mirrors `provider_vault_shares`. */ +export interface ProviderVaultShare { + providerId: string; + vaultId: string; + shareBalance: string; +} + +/** One APY observation used to draw the portal chart. */ +export interface ApySample { + timestamp: string; + apyBps: number; +} + +/** Result of POST /api/v1/yield/harvest. */ +export interface HarvestResult { + vaultId: string; + assetAddress: string; + yieldStroops: string; + tvlAfterStroops: string; + /** Scaled exchange rate after harvest: assets * SCALE / shares. */ + exchangeRateScaled: string; + previousExchangeRateScaled: string; + harvestedAt: string; +} + +/** Where a strategy adapter currently holds funds for one asset. */ +export interface StrategyPosition { + assetAddress: string; + strategyName: string; + deployedStroops: string; + /** Strategy-reported APY in basis points (400 = 4%). */ + apyBps: number; +} + +/** Output of one buffer-optimizer pass for a vault. */ +export interface BufferDecision { + vaultId: string; + recommendedRatio: number; + targetLiquidStroops: string; + action: "DEPLOY_TO_VAULT" | "RECALL_FROM_VAULT" | "HOLD"; + amountStroops: string; + shortfallStroops: string; + reason: string; +} + +/** Plan for funding an instant cash-trade settlement (#408 acceptance #2). */ +export interface LiquidityDrawPlan { + requiredStroops: string; + liquidReserveStroops: string; + recallFromVaultStroops: string; + shortfallStroops: string; + source: "BUFFER_ONLY" | "BUFFER_PLUS_VAULT_RECALL" | "INSUFFICIENT"; +} + +/** + * Shared constants for the yield aggregation stack so the migration SQL, + * contract (`RATE_SCALE` in contracts/escrow/src/yield_vault.rs), API, + * worker, and frontend can never drift apart. + */ +export const YIELD_VAULT = { + /** Dynamic liquid reserve target from the issue spec (20% of TVL). */ + DEFAULT_LIQUID_BUFFER_RATIO: 0.2, + MIN_LIQUID_BUFFER_RATIO: 0.05, + MAX_LIQUID_BUFFER_RATIO: 1, + /** + * Idle cash is only deployed once it exceeds buffer*(1+hysteresis) so the + * rebalance worker does not churn the vault on every tick. + */ + BUFFER_HYSTERESIS_BPS: 250, + /** + * Fixed-point scale for share exchange rates — MUST match `RATE_SCALE` + * in contracts/escrow/src/yield_vault.rs. + */ + EXCHANGE_RATE_SCALE: 1_000_000_000_000n, + /** Below this the deploy leg costs more gas than it earns. */ + MIN_DEPLOY_STROOPS: 1_000_000n, + /** Worker cadence: drives compounding + buffer optimization. */ + REBALANCE_POLL_MS: 5 * 60 * 1000, + /** Expected external strategy APY band from the issue (4–8%). */ + MIN_APY_BPS: 400, + MAX_APY_BPS: 800, +} as const; diff --git a/tests/concurrency/yield_withdrawal_stress.test.ts b/tests/concurrency/yield_withdrawal_stress.test.ts new file mode 100644 index 0000000..dd54db4 --- /dev/null +++ b/tests/concurrency/yield_withdrawal_stress.test.ts @@ -0,0 +1,217 @@ +import { afterAll, beforeAll, beforeEach, describe, expect, it } from "vitest"; +import Fastify from "fastify"; +import { YIELD_VAULT } from "@velo/shared"; +import { + yieldVaultRoutes, + setDefaultStrategyAdapter, +} from "../../apps/api/src/routes/yield-vaults.js"; +import { InMemoryStrategyAdapter } from "../../apps/api/src/lib/yield/strategy-adapter.js"; +import { + clearYieldStores, + getYieldVaultConfig, + listProviderVaultShares, + saveYieldVaultConfig, + upsertProviderVaultShare, +} from "../../apps/api/src/lib/store.js"; +import { planInstantSettlementDraw } from "../../apps/api/src/lib/liquidity-netting.js"; + +/** + * Instant-withdrawal concurrency stress (#408 acceptance #2): trade-matching + * demand hits the 20% liquid buffer all at once. Withdrawals must never + * double-spend a share balance, payouts plus remaining TVL must reconcile to + * the stroop even while a harvest races the storm, and any buffer gap must + * be covered by an instant strategy recall — with the share exchange rate + * ratcheting monotonically throughout. + */ + +const VAULT_ID = "11111111-1111-4111-8111-111111111111"; +const ASSET_ADDRESS = `C${"A".repeat(55)}`; +const ADMIN_KEY = "stress-admin-key"; +const PROVIDER_COUNT = 25; +const SHARES_PER_PROVIDER = 1_000_000n; +// Rate starts at exactly 2 stroops per share. +const INITIAL_TVL = BigInt(PROVIDER_COUNT) * SHARES_PER_PROVIDER * 2n; +const INITIAL_LIQUID = INITIAL_TVL / 5n; // the 20% liquid buffer +const SCALE = YIELD_VAULT.EXCHANGE_RATE_SCALE; +const HARVEST_YIELD = 1_000_000n; + +let app: ReturnType; + +beforeAll(async () => { + process.env.ADMIN_API_KEY = ADMIN_KEY; + app = Fastify(); + await app.register(yieldVaultRoutes, { prefix: "/api/v1" }); + await app.ready(); +}); + +afterAll(async () => { + await app.close(); + delete process.env.ADMIN_API_KEY; +}); + +async function seedVault(): Promise { + clearYieldStores(); + const adapter = new InMemoryStrategyAdapter(); + // The config below implies TVL − liquid is already deployed into the + // external strategy — mirror that inside the simulator so instant-recall + // legs have real funds to pull back. + await adapter.deposit({ + assetAddress: ASSET_ADDRESS, + amountStroops: INITIAL_TVL - INITIAL_LIQUID, + }); + setDefaultStrategyAdapter(adapter); + saveYieldVaultConfig({ + vaultId: VAULT_ID, + assetAddress: ASSET_ADDRESS, + liquidBufferRatio: YIELD_VAULT.DEFAULT_LIQUID_BUFFER_RATIO, + currentTvlStroops: INITIAL_TVL.toString(), + liquidStroops: INITIAL_LIQUID.toString(), + lastExchangeRateScaled: SCALE.toString(), + }); + for (let i = 0; i < PROVIDER_COUNT; i++) { + upsertProviderVaultShare({ + providerId: `provider-${i}`, + vaultId: VAULT_ID, + shareBalance: SHARES_PER_PROVIDER.toString(), + }); + } +} + +function withdraw(providerId: string, shareAmount: string) { + return app.inject({ + method: "POST", + url: `/api/v1/yield/vaults/${VAULT_ID}/withdraw`, + payload: { providerId, shareAmount }, + }); +} + +describe("yield withdrawal stress (#408)", () => { + beforeEach(async () => { + await seedVault(); + }); + + it("drains every provider concurrently: exact payouts, no double-spend, buffer + recall cover everything", async () => { + // Two full-balance attempts per provider fire simultaneously — only the + // first wave can succeed, the second must all bounce. + const requests: Array> = []; + for (let round = 0; round < 2; round++) { + for (let i = 0; i < PROVIDER_COUNT; i++) { + requests.push( + withdraw(`provider-${i}`, SHARES_PER_PROVIDER.toString()), + ); + } + } + const responses = await Promise.all(requests); + + const ok = responses.filter((r) => r.statusCode === 200); + const conflicted = responses.filter((r) => r.statusCode === 409); + expect(ok.length).toBe(PROVIDER_COUNT); + expect(conflicted.length).toBe(PROVIDER_COUNT); + for (const r of conflicted) { + expect(r.json().code).toBe("INSUFFICIENT_SHARES"); + } + + // Payouts sum to exactly the initial TVL (constant 2 stroops/share). + const totalPaid = ok.reduce( + (sum, r) => sum + BigInt(r.json().data.paidStroops), + 0n, + ); + expect(totalPaid).toBe(INITIAL_TVL); + + // Every draw plan was honoured instantly: buffer-only early, then a + // sized instant recall once the 20% buffer ran dry. + const sources = new Set(ok.map((r) => r.json().data.drawPlan.source)); + expect(sources.has("BUFFER_ONLY")).toBe(true); + expect(sources.has("BUFFER_PLUS_VAULT_RECALL")).toBe(true); + + // Terminal state: fully drained, nothing negative anywhere. + const config = getYieldVaultConfig(VAULT_ID)!; + expect(config.currentTvlStroops).toBe("0"); + expect(config.liquidStroops).toBe("0"); + for (const share of listProviderVaultShares(VAULT_ID)) { + expect(share.shareBalance).toBe("0"); + } + }); + + it("never lets two concurrent withdrawals of one balance both succeed", async () => { + const [a, b] = await Promise.all([ + withdraw("provider-7", SHARES_PER_PROVIDER.toString()), + withdraw("provider-7", SHARES_PER_PROVIDER.toString()), + ]); + expect([a.statusCode, b.statusCode].sort()).toEqual([200, 409]); + const balance = listProviderVaultShares(VAULT_ID).find( + (s) => s.providerId === "provider-7", + )!.shareBalance; + expect(balance).toBe("0"); + }); + + it("keeps the exchange rate monotonic while a harvest races the storm", async () => { + const before = await app.inject({ + method: "GET", + url: "/api/v1/yield/vaults", + }); + const rateBefore = BigInt(before.json().data[0].exchangeRateScaled); + + const responses = await Promise.all([ + app.inject({ + method: "POST", + url: "/api/v1/yield/harvest", + headers: { "x-admin-api-key": ADMIN_KEY }, + payload: { vaultId: VAULT_ID, yieldStroops: HARVEST_YIELD.toString() }, + }), + ...Array.from({ length: PROVIDER_COUNT }, (_, i) => + withdraw(`provider-${i}`, "400000"), + ), + ]); + + for (const res of responses) { + expect([200, 409]).toContain(res.statusCode); + } + + // Conservation, to the stroop: everything paid out plus what remains in + // the pool equals the initial TVL plus whatever the harvest injected. + const withdrawals = responses.slice(1); + const totalPaid = withdrawals + .filter((r) => r.statusCode === 200) + .reduce((sum, r) => sum + BigInt(r.json().data.paidStroops), 0n); + const harvested = responses[0].statusCode === 200 ? HARVEST_YIELD : 0n; + + const after = await app.inject({ + method: "GET", + url: "/api/v1/yield/vaults", + }); + const view = after.json().data[0]; + expect(BigInt(view.exchangeRateScaled)).toBeGreaterThanOrEqual(rateBefore); + expect(totalPaid + BigInt(view.currentTvlStroops)).toBe( + INITIAL_TVL + harvested, + ); + }); + + it("plans instant draws deterministically across random splits", async () => { + for (let i = 0; i < 200; i++) { + const required = BigInt(Math.floor(Math.random() * 10_000_000)); + const liquid = BigInt(Math.floor(Math.random() * 5_000_000)); + const deployed = BigInt(Math.floor(Math.random() * 8_000_000)); + const plan = planInstantSettlementDraw({ + requiredStroops: required, + liquidReserveStroops: liquid, + deployedToVaultStroops: deployed, + }); + + if (required <= liquid) { + expect(plan.source).toBe("BUFFER_ONLY"); + expect(plan.recallFromVaultStroops).toBe(0n); + } else if (required <= liquid + deployed) { + expect(plan.source).toBe("BUFFER_PLUS_VAULT_RECALL"); + expect(plan.recallFromVaultStroops).toBe(required - liquid); + } else { + expect(plan.source).toBe("INSUFFICIENT"); + expect(plan.shortfallStroops).toBe(required - (liquid + deployed)); + } + // The plan never promises more than exists. + expect(plan.recallFromVaultStroops).toBeLessThanOrEqual(deployed); + } + }); +}); + +