import type { Address, AbiEvent } from "viem"; import type { LucidlyClient } from "./client.js"; import { AccountantWithRateProviders, TellerWithMultiAssetSupport, BoringVault } from "./artifacts.js"; import { AtomicQueue } from "./queueArtifacts.js"; /** Point-in-time, read-only view of a vault's health. Works without a signer (dashboards/alerts). */ export interface VaultSnapshot { timestamp: number; vault: Address; name: string; symbol: string; decimals: number; /** NAV: base-asset units per 1.0 share (Accountant exchange rate). */ rate: bigint; totalSupply: bigint; /** AUM in base-asset units = totalSupply * rate / 10**decimals. */ aum: bigint; accountant: { address: Address; isPaused: boolean; exchangeRate: bigint; highwaterMark: bigint; feesOwedInBase: bigint; lastUpdateTimestamp: number; minimumUpdateDelaySec: number; upperBoundBps: number; lowerBoundBps: number; platformFeeBps: number; performanceFeeBps: number; }; teller: { address: Address; isPaused: boolean; shareLockPeriodSec: number }; queue?: { address: Address; isPaused: boolean }; } /** Aggregated deposit/withdraw/queue flows over a block window. */ export interface FlowWindow { fromBlock: bigint; toBlock: bigint; deposits: { count: number; assets: bigint; shares: bigint }; withdrawals: { count: number; shares: bigint }; queue: { requested: number; fulfilled: number; offerSharesFulfilled: bigint; wantPaid: bigint; pending: number }; /** shares minted minus shares burned/redeemed across the window. */ netShareFlow: bigint; } export interface Alert { level: "info" | "warn" | "critical"; code: string; message: string; } export interface AlertThresholds { /** Flag if |rate change| vs the previous snapshot exceeds this (bps of the prior rate). */ maxRateMoveBps?: number; /** Flag if AUM dropped vs the previous snapshot by more than this (bps). */ maxAumDropBps?: number; /** Flag if the rate hasn't updated within this many seconds. */ maxRateStalenessSec?: number; /** Flag if accrued fees owed (base units) exceed this. */ maxFeesOwedBase?: bigint; } const ACCOUNTANT_STATE_ABI = [ { type: "function", name: "accountantState", stateMutability: "view", inputs: [], outputs: [ { name: "payoutAddress", type: "address" }, { name: "highwaterMark", type: "uint96" }, { name: "feesOwedInBase", type: "uint128" }, { name: "totalSharesLastUpdate", type: "uint128" }, { name: "exchangeRate", type: "uint96" }, { name: "allowedExchangeRateChangeUpper", type: "uint16" }, { name: "allowedExchangeRateChangeLower", type: "uint16" }, { name: "lastUpdateTimestamp", type: "uint64" }, { name: "isPaused", type: "bool" }, { name: "minimumUpdateDelayInSeconds", type: "uint24" }, { name: "platformFee", type: "uint16" }, { name: "performanceFee", type: "uint16" }, ], }, ] as const; const ev = (abi: readonly unknown[], name: string): AbiEvent => (abi as AbiEvent[]).find((x) => x.type === "event" && x.name === name)!; /** * Read-only monitoring for a deployed vault: point-in-time snapshots (NAV/AUM/supply, pause and fee * state), event-derived flows (deposits, withdrawals, queue requests/fills), and threshold alerts. * Designed to feed a dashboard or a scheduled health check — no signer required. */ export class MonitorModule { constructor(private readonly c: LucidlyClient) {} /** Capture a full point-in-time snapshot of the active (or supplied) vault. */ async snapshot(opts: { vault?: Address; accountant?: Address; teller?: Address; queue?: Address } = {}): Promise { const vault = this.c.requireVault("monitor.snapshot", opts.vault); const accountant = this.c.requireAccountant("monitor.snapshot", opts.accountant); const teller = this.c.requireTeller("monitor.snapshot", opts.teller); const queueAddr = opts.queue ?? this.c.activeDeployment?.queue; const [name, symbol, decimals, totalSupply] = await Promise.all([ this.c.read({ address: vault, abi: BoringVault.abi, functionName: "name" }), this.c.read({ address: vault, abi: BoringVault.abi, functionName: "symbol" }), this.c.read({ address: vault, abi: BoringVault.abi, functionName: "decimals" }), this.c.read({ address: vault, abi: BoringVault.abi, functionName: "totalSupply" }), ]); // `accountantState` is a public struct getter: viem returns its 12 fields as a positional tuple. const raw = await this.c.read({ address: accountant, abi: ACCOUNTANT_STATE_ABI, functionName: "accountantState" }); const st = { payoutAddress: raw[0] as Address, highwaterMark: raw[1] as bigint, feesOwedInBase: raw[2] as bigint, totalSharesLastUpdate: raw[3] as bigint, exchangeRate: raw[4] as bigint, allowedExchangeRateChangeUpper: raw[5] as number, allowedExchangeRateChangeLower: raw[6] as number, lastUpdateTimestamp: raw[7] as bigint, isPaused: raw[8] as boolean, minimumUpdateDelayInSeconds: raw[9] as number, platformFee: raw[10] as number, performanceFee: raw[11] as number, }; const rate = BigInt(st.exchangeRate); const dec = Number(decimals); const aum = (totalSupply * rate) / 10n ** BigInt(dec); const [tellerPaused, shareLock] = await Promise.all([ this.c.read({ address: teller, abi: TellerWithMultiAssetSupport.abi, functionName: "isPaused" }), this.c.read({ address: teller, abi: TellerWithMultiAssetSupport.abi, functionName: "shareLockPeriod" }), ]); let queue: VaultSnapshot["queue"]; if (queueAddr) { const qPaused = await this.c.read({ address: queueAddr, abi: AtomicQueue.abi, functionName: "isPaused" }); queue = { address: queueAddr, isPaused: qPaused }; } return { timestamp: Math.floor(Date.now() / 1000), vault, name, symbol, decimals: dec, rate, totalSupply, aum, accountant: { address: accountant, isPaused: st.isPaused, exchangeRate: rate, highwaterMark: BigInt(st.highwaterMark), feesOwedInBase: BigInt(st.feesOwedInBase), lastUpdateTimestamp: Number(st.lastUpdateTimestamp), minimumUpdateDelaySec: Number(st.minimumUpdateDelayInSeconds), upperBoundBps: Number(st.allowedExchangeRateChangeUpper) - 10_000, lowerBoundBps: 10_000 - Number(st.allowedExchangeRateChangeLower), platformFeeBps: Number(st.platformFee), performanceFeeBps: Number(st.performanceFee), }, teller: { address: teller, isPaused: tellerPaused, shareLockPeriodSec: Number(shareLock) }, queue, }; } /** Aggregate deposit/withdraw/queue flows over [fromBlock, toBlock] (toBlock defaults to latest). */ async flows(opts: { fromBlock: bigint; toBlock?: bigint; vault?: Address; teller?: Address; queue?: Address } = { fromBlock: 0n }): Promise { const teller = this.c.requireTeller("monitor.flows", opts.teller); const queueAddr = opts.queue ?? this.c.activeDeployment?.queue; const toBlock = opts.toBlock ?? (await this.c.publicClient.getBlockNumber()); const fromBlock = opts.fromBlock; const depositLogs = await this.c.publicClient.getLogs({ address: teller, event: ev(TellerWithMultiAssetSupport.abi, "Deposit"), fromBlock, toBlock, }); const withdrawLogs = await this.c.publicClient.getLogs({ address: teller, event: ev(TellerWithMultiAssetSupport.abi, "BulkWithdraw"), fromBlock, toBlock, }); let depAssets = 0n; let depShares = 0n; for (const l of depositLogs) { const a = l.args as { depositAmount?: bigint; shareAmount?: bigint }; depAssets += a.depositAmount ?? 0n; depShares += a.shareAmount ?? 0n; } let wShares = 0n; for (const l of withdrawLogs) wShares += ((l.args as { shareAmount?: bigint }).shareAmount ?? 0n); const queue = { requested: 0, fulfilled: 0, offerSharesFulfilled: 0n, wantPaid: 0n, pending: 0 }; if (queueAddr) { const reqLogs = await this.c.publicClient.getLogs({ address: queueAddr, event: ev(AtomicQueue.abi, "AtomicRequestUpdated"), fromBlock, toBlock, }); const fillLogs = await this.c.publicClient.getLogs({ address: queueAddr, event: ev(AtomicQueue.abi, "AtomicRequestFulfilled"), fromBlock, toBlock, }); queue.requested = reqLogs.length; queue.fulfilled = fillLogs.length; for (const l of fillLogs) { const a = l.args as { offerAmountSpent?: bigint; wantAmountReceived?: bigint }; queue.offerSharesFulfilled += a.offerAmountSpent ?? 0n; queue.wantPaid += a.wantAmountReceived ?? 0n; } // Pending = open requests (last update has amount>0) not since fulfilled, keyed by user+offer+want. const open = new Map(); for (const l of reqLogs) { const a = l.args as { user: Address; offerToken: Address; wantToken: Address; amount: bigint }; open.set(`${a.user}-${a.offerToken}-${a.wantToken}`, a.amount); } for (const l of fillLogs) { const a = l.args as { user: Address; offerToken: Address; wantToken: Address }; open.set(`${a.user}-${a.offerToken}-${a.wantToken}`, 0n); } for (const amt of open.values()) if (amt > 0n) queue.pending++; } return { fromBlock, toBlock, deposits: { count: depositLogs.length, assets: depAssets, shares: depShares }, withdrawals: { count: withdrawLogs.length, shares: wShares }, queue, netShareFlow: depShares - wShares - queue.offerSharesFulfilled, }; } /** * Evaluate alerts on a snapshot. Pass the previous snapshot to enable delta checks (rate move, * AUM drop). Pause states and a stale rate are flagged from the single snapshot alone. */ checkAlerts(snap: VaultSnapshot, prev?: VaultSnapshot, thresholds: AlertThresholds = {}): Alert[] { const t: Required = { maxRateMoveBps: thresholds.maxRateMoveBps ?? 100, maxAumDropBps: thresholds.maxAumDropBps ?? 1_000, maxRateStalenessSec: thresholds.maxRateStalenessSec ?? 7 * 24 * 3600, maxFeesOwedBase: thresholds.maxFeesOwedBase ?? (1n << 255n), }; const alerts: Alert[] = []; if (snap.accountant.isPaused) alerts.push({ level: "critical", code: "ACCOUNTANT_PAUSED", message: "Accountant is paused — NAV updates and rate-dependent ops are halted (likely a circuit-breaker trip)." }); if (snap.teller.isPaused) alerts.push({ level: "critical", code: "TELLER_PAUSED", message: "Teller is paused — deposits and instant withdrawals are halted." }); if (snap.queue?.isPaused) alerts.push({ level: "warn", code: "QUEUE_PAUSED", message: "AtomicQueue is paused — queued redemptions cannot be posted or solved." }); const ageSec = snap.timestamp - snap.accountant.lastUpdateTimestamp; if (snap.accountant.lastUpdateTimestamp > 0 && ageSec > t.maxRateStalenessSec) alerts.push({ level: "warn", code: "RATE_STALE", message: `NAV last updated ${Math.floor(ageSec / 3600)}h ago (> ${Math.floor(t.maxRateStalenessSec / 3600)}h threshold).` }); if (snap.accountant.feesOwedInBase > t.maxFeesOwedBase) alerts.push({ level: "info", code: "FEES_OWED_HIGH", message: `Accrued fees owed (${snap.accountant.feesOwedInBase} base units) exceed the alert threshold.` }); if (prev) { if (prev.rate > 0n) { const moveBps = Number(((snap.rate - prev.rate) * 10_000n) / prev.rate); if (Math.abs(moveBps) > t.maxRateMoveBps) alerts.push({ level: Math.abs(moveBps) > t.maxRateMoveBps * 2 ? "critical" : "warn", code: "RATE_MOVE", message: `NAV moved ${(moveBps / 100).toFixed(2)}% since the last snapshot (> ${(t.maxRateMoveBps / 100).toFixed(2)}% threshold).` }); } if (prev.aum > 0n) { const dropBps = Number(((prev.aum - snap.aum) * 10_000n) / prev.aum); if (dropBps > t.maxAumDropBps) alerts.push({ level: dropBps > t.maxAumDropBps * 2 ? "critical" : "warn", code: "AUM_DROP", message: `AUM dropped ${(dropBps / 100).toFixed(2)}% since the last snapshot (> ${(t.maxAumDropBps / 100).toFixed(2)}% threshold).` }); } } return alerts; } }