import type { Address } from "viem"; import type { LucidlyClient } from "./client.js"; import type { TxResult } from "./types.js"; import { BoringVault, AccountantWithRateProviders } from "./artifacts.js"; /** * NAV engine (M2). Computes a vault's NAV off-chain from its holdings, derives the share rate, and * pushes Accountant.updateExchangeRate within the per-update bounds — the same model the strats * `pricer.ts` keeper uses, generalised + config-driven. * * Valuation uses SHARED, pre-deployed TVL adapters (called with the vault as an argument), NOT * per-vault clones — so existing audited adapters (e.g. the syUSD/Aave `getUserTvl(vault)` adapter) * drop in by address. A vault's NAV config lists its holding sources: * * - idle: a base-asset balance sitting in the vault (USDC not yet deployed) * - shareToken: a Boring-Vault share the vault holds (e.g. syUSD) — valued via that vault's own * accountant.getRate() (base units per share) × balance / 10^shareDecimals * - adapter: a shared TVL adapter exposing getUserTvl(vault) → value already in base units * * compute() never sends a tx. push() refuses to broadcast if the new rate would breach the bounds * (which would trip the Accountant's circuit-breaker pause), exactly like pricer.ts. */ const ERC20_ABI = [ { type: "function", name: "balanceOf", stateMutability: "view", inputs: [{ type: "address" }], outputs: [{ type: "uint256" }] }, { type: "function", name: "decimals", stateMutability: "view", inputs: [], outputs: [{ type: "uint8" }] }, ] as const; const ADAPTER_ABI = [ { type: "function", name: "getUserTvl", stateMutability: "view", inputs: [{ type: "address" }], outputs: [{ type: "uint256" }] }, ] as const; const RATE_ABI = [ { type: "function", name: "getRate", stateMutability: "view", inputs: [], outputs: [{ type: "uint256" }] }, ] as const; export type NavHolding = | { kind: "idle"; token: Address; label?: string } | { kind: "shareToken"; token: Address; accountant: Address; decimals?: number; label?: string } | { kind: "adapter"; adapter: Address; label?: string }; export interface NavConfig { vault: Address; accountant: Address; // the vault's accountant (read getRate, push updateExchangeRate) baseAsset: Address; // accountant unit of account; vault share decimals == base decimals (v2) baseDecimals?: number; // default: read from baseAsset.decimals() holdings: NavHolding[]; /** Refuse-to-push bounds; MUST match the Accountant's allowedExchangeRateChange*. */ maxUpBps?: number; // default 50 (+0.50%) maxDownBps?: number; // default 50 (-0.50%) } export interface NavBreakdownItem { label: string; valueInBase: bigint; } export interface NavResult { aum: bigint; // total value in base units totalSupply: bigint; currentRate: bigint; // accountant.getRate() now (base units per share) newRate: bigint; // aum * 10^dec / totalSupply withinBounds: boolean; // newRate within [currentRate*(1-down), currentRate*(1+up)] breakdown: NavBreakdownItem[]; baseDecimals: number; } export class NavModule { constructor(private readonly c: LucidlyClient) {} private async decimalsOf(token: Address): Promise { return Number(await this.c.read({ address: token, abi: ERC20_ABI, functionName: "decimals" })); } private balanceOf(token: Address, who: Address): Promise { return this.c.read({ address: token, abi: ERC20_ABI, functionName: "balanceOf", args: [who] }); } /** Value one holding in base-asset units. */ private async valueHolding(h: NavHolding, vault: Address, baseDecimals: number): Promise { if (h.kind === "idle") { const bal = await this.balanceOf(h.token, vault); return { label: h.label ?? "idle", valueInBase: bal }; } if (h.kind === "adapter") { const v = await this.c.read({ address: h.adapter, abi: ADAPTER_ABI, functionName: "getUserTvl", args: [vault] }); return { label: h.label ?? `adapter ${h.adapter.slice(0, 10)}`, valueInBase: v }; } // shareToken: balance * (base per share) / 10^shareDecimals const [bal, rate] = await Promise.all([ this.balanceOf(h.token, vault), this.c.read({ address: h.accountant, abi: RATE_ABI, functionName: "getRate" }), ]); const sdec = h.decimals ?? (await this.decimalsOf(h.token)); const value = (bal * rate) / 10n ** BigInt(sdec); return { label: h.label ?? `share ${h.token.slice(0, 10)}`, valueInBase: value }; } /** Compute NAV + the rate it implies. Read-only; never sends a transaction. */ async compute(cfg: NavConfig): Promise { const baseDecimals = cfg.baseDecimals ?? (await this.decimalsOf(cfg.baseAsset)); const breakdown: NavBreakdownItem[] = []; for (const h of cfg.holdings) breakdown.push(await this.valueHolding(h, cfg.vault, baseDecimals)); const aum = breakdown.reduce((s, b) => s + b.valueInBase, 0n); const [totalSupply, currentRate] = await Promise.all([ this.c.read({ address: cfg.vault, abi: BoringVault.abi, functionName: "totalSupply" }), this.c.read({ address: cfg.accountant, abi: AccountantWithRateProviders.abi, functionName: "getRate" }), ]); const newRate = totalSupply > 0n ? (aum * 10n ** BigInt(baseDecimals)) / totalSupply : currentRate; const up = BigInt(cfg.maxUpBps ?? 50); const down = BigInt(cfg.maxDownBps ?? 50); const upper = currentRate + (currentRate * up) / 10_000n; const lower = currentRate - (currentRate * down) / 10_000n; const withinBounds = currentRate === 0n ? true : newRate <= upper && newRate >= lower; return { aum, totalSupply, currentRate, newRate, withinBounds, breakdown, baseDecimals }; } /** * Compute then push the new rate via Accountant.updateExchangeRate — but only if within bounds. * Returns {pushed:false} when out of bounds (the caller should investigate; pushing would PAUSE * the vault). Requires the client to hold a signer with UPDATE_EXCHANGE_RATE_ROLE. */ async push(cfg: NavConfig): Promise<{ pushed: boolean; result: NavResult; tx?: TxResult; reason?: string }> { const result = await this.compute(cfg); if (!result.withinBounds) { return { pushed: false, result, reason: "new rate breaches per-update bounds — refusing (would PAUSE the vault)" }; } if (result.newRate === result.currentRate) { return { pushed: false, result, reason: "rate unchanged" }; } const hash = await this.c.send({ address: cfg.accountant, abi: AccountantWithRateProviders.abi, functionName: "updateExchangeRate", args: [result.newRate], }); return { pushed: true, result, tx: this.c.wrapTx(hash) }; } }