import { type Address, type Hex } from "viem"; import type { LucidlyClient } from "./client.js"; import type { IndexedVault } from "./indexerClient.js"; import { POLICY_MANAGER_ABI } from "./policyManager.js"; /** * NavPipeline — daily NAV automation for every vault deployed via Lucidly Pro. * * discover (indexer) → per-vault NAV (idle + Σ tvlAdapter.valueInBase) → safety filter → push * * Composable from existing primitives: IndexerClient (vaults), PolicyManagerModule (usedProtocols * + clones), Accountant.accountantState + updateExchangeRate. No new on-chain contract; this is * pure off-chain orchestration that the strategist OR a shared Lucidly NAV bot can run. * * SIGNER MODEL (hybrid): * • Pass a wallet-bearing LucidlyClient and the pipeline signs with that key. * • Pipeline silently skips any vault whose RolesAuthority says canCall(signer, accountant, * updateExchangeRate) = false. So a strategist running their own bot only pushes their own * vaults; a Lucidly-operated bot pushes any vault whose rateUpdater was set to its address at * deploy time (or that later granted it UPDATE_EXCHANGE_RATE_ROLE). * • `dryRun: true` runs the math + safety filter but skips the push tx (good for the CLI default). * * SAFETY (in order; first-hit short-circuits): * 1. accountant.isPaused → skip "paused" * 2. now - lastUpdate < minDelay → skip "too soon" * 3. proposedRate < current * (10_000 - lowerBoundBps) / 10_000 → skip "below lower bound" * 4. proposedRate > current * (10_000 + upperBoundBps) / 10_000 → skip "above upper bound" * 5. canCall(signer, accountant, updateExchangeRate)? → push, else skip "unauthorized" * * #3/#4 protect against pushing a NAV that the on-chain Accountant would revert on, AND surface * a real signal: if a strategy's TVL adapter says the vault is suddenly worth +50%, that's almost * certainly an oracle bug — we skip + alert rather than push a rate that triggers a redemption * rush. */ // ── ABIs ───────────────────────────────────────────────────────────────────── const ACCOUNTANT_STATE_TUPLE = { name: "state", type: "tuple", components: [ { name: "payoutAddress", type: "address" }, { 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 ACCOUNTANT_ABI = [ { type: "function", name: "accountantState", stateMutability: "view", inputs: [], outputs: [ACCOUNTANT_STATE_TUPLE] }, { type: "function", name: "updateExchangeRate", stateMutability: "nonpayable", inputs: [{ name: "newExchangeRate", type: "uint96" }], outputs: [] }, ] as const; const TVL_ADAPTER_ABI = [ { type: "function", name: "valueInBase", stateMutability: "view", inputs: [], outputs: [{ type: "uint256" }] }, ] as const; const ERC20_VIEW_ABI = [ { type: "function", name: "decimals", stateMutability: "view", inputs: [], outputs: [{ type: "uint8" }] }, { type: "function", name: "balanceOf", stateMutability: "view", inputs: [{ name: "a", type: "address" }], outputs: [{ type: "uint256" }] }, { type: "function", name: "totalSupply", stateMutability: "view", inputs: [], outputs: [{ type: "uint256" }] }, ] as const; const ROLES_AUTHORITY_ABI = [ { type: "function", name: "canCall", stateMutability: "view", inputs: [{ name: "user", type: "address" }, { name: "target", type: "address" }, { name: "functionSig", type: "bytes4" }], outputs: [{ type: "bool" }] }, ] as const; /** updateExchangeRate(uint96) — verified via `cast sig` and pinned here to avoid runtime keccak. */ const UPDATE_EXCHANGE_RATE_SELECTOR = "0x3458113d" as Hex; // ── Default rate-updater registry ──────────────────────────────────────────── // The HYBRID signer model: callers can leave the signer as-is (uses whatever the LucidlyClient // was constructed with), OR opt into the shared Lucidly NAV bot by setting their vault's // rateUpdater to one of these per-chain addresses at deploy time. Strategists who don't want a // central convenience opt out by leaving rateUpdater = themselves. // // TBD: real bot addresses will replace the zero placeholders once the bot key is generated. Until // then, the constants exist so the SDK/UI/CLI can reference them without a follow-up edit. export const LUCIDLY_NAV_BOT_PER_CHAIN: Record = { base: "0x0000000000000000000000000000000000000000", ethereum: "0x0000000000000000000000000000000000000000", arbitrum: "0x0000000000000000000000000000000000000000", optimism: "0x0000000000000000000000000000000000000000", }; // ── Types ──────────────────────────────────────────────────────────────────── export type NavRowStatus = "pushed" | "skipped" | "error" | "dry-run-ok"; export interface NavRow { chainId: number; vault: Address; nonce?: number; baseAsset: Address; baseDecimals: number; idle: bigint; // base asset balance in the vault protocols: { protocolId: Hex; tvlAdapter: Address; valueInBase: bigint }[]; aum: bigint; // idle + Σ tvlAdapter.valueInBase() totalSupply: bigint; currentRate: bigint; proposedRate: bigint; withinBounds: boolean; status: NavRowStatus; reason?: string; // for skipped/error txHash?: Hex; // when pushed } export interface NavRunReport { ranAt: number; // unix seconds signer?: Address; chainId: number; totalVaults: number; pushed: number; skipped: number; errors: number; rows: NavRow[]; } export interface NavPipelineConfig { /** Don't push the tx; compute + safety-filter only. CLI default = true. */ dryRun?: boolean; /** Restrict to specific vault addresses (matched case-insensitive). */ vaults?: Address[]; /** Restrict to specific per-chain nonces (from the indexer). */ nonces?: number[]; /** Hard cap on vaults processed in one pass. */ limit?: number; } // ── Module ─────────────────────────────────────────────────────────────────── export class NavPipelineModule { constructor(private readonly client: LucidlyClient) {} /** Run one pass on the client's active chain. */ async runOnce(cfg: NavPipelineConfig = {}): Promise { if (!this.client.indexer) { throw new Error("NavPipeline requires an indexer (set LucidlyClientOptions.indexerUrl)."); } const chainId = this.client.chainConfig.id; const signer = this.client.account?.address as Address | undefined; const dryRun = cfg.dryRun ?? false; const all = (await this.client.indexer.vaults()) ?? []; let vaults = all.filter((v) => v.chainId === chainId); if (cfg.vaults && cfg.vaults.length) { const set = new Set(cfg.vaults.map((a) => a.toLowerCase())); vaults = vaults.filter((v) => set.has(v.vault.toLowerCase())); } if (cfg.nonces && cfg.nonces.length) { const set = new Set(cfg.nonces); vaults = vaults.filter((v) => set.has(v.nonce)); } if (cfg.limit) vaults = vaults.slice(0, cfg.limit); const rows: NavRow[] = []; let pushed = 0, skipped = 0, errors = 0; for (const v of vaults) { try { const row = await this._processVault(v, signer, dryRun); rows.push(row); if (row.status === "pushed") pushed++; else if (row.status === "error") errors++; else skipped++; // "skipped" + "dry-run-ok" both count as not-pushed } catch (e: any) { rows.push({ chainId, vault: v.vault, nonce: v.nonce, baseAsset: v.baseAsset, baseDecimals: 0, idle: 0n, protocols: [], aum: 0n, totalSupply: 0n, currentRate: 0n, proposedRate: 0n, withinBounds: false, status: "error", reason: e?.shortMessage || e?.message || String(e), }); errors++; } } return { ranAt: Math.floor(Date.now() / 1000), signer, chainId, totalVaults: vaults.length, pushed, skipped, errors, rows, }; } /** Per-vault: read state, compute, safety-filter, maybe push. */ private async _processVault(v: IndexedVault, signer: Address | undefined, dryRun: boolean): Promise { // 1) base-asset decimals const baseDecimals = Number(await this.client.read({ address: v.baseAsset, abi: ERC20_VIEW_ABI, functionName: "decimals", })); // 2) on-chain reads in parallel: accountant state, idle, supply, used protocols const [stateTup, idle, totalSupply, used] = await Promise.all([ this.client.read({ address: v.accountant, abi: ACCOUNTANT_ABI, functionName: "accountantState", }), this.client.read({ address: v.baseAsset, abi: ERC20_VIEW_ABI, functionName: "balanceOf", args: [v.vault], }), this.client.read({ address: v.vault, abi: ERC20_VIEW_ABI, functionName: "totalSupply", }), this.client.read({ address: v.policyManager, abi: POLICY_MANAGER_ABI, functionName: "usedProtocolsList", }), ]); // accountantState fields (positional access to the tuple) const currentRate = stateTup[3]; const upperBps = stateTup[4]; const lowerBps = stateTup[5]; const lastUpdate = stateTup[6]; const isPaused = stateTup[7]; const minDelay = stateTup[8]; // 3) per-protocol TVL const protocols: NavRow["protocols"] = []; for (const protocolId of used) { const clones = await this.client.read({ address: v.policyManager, abi: POLICY_MANAGER_ABI, functionName: "clonesOf", args: [protocolId], }); const tvlAdapter = clones[1]; if (tvlAdapter === "0x0000000000000000000000000000000000000000") continue; // no adapter registered for this protocol try { const value = await this.client.read({ address: tvlAdapter, abi: TVL_ADAPTER_ABI, functionName: "valueInBase", }); protocols.push({ protocolId, tvlAdapter, valueInBase: value }); } catch (e: any) { // Single protocol failure shouldn't poison the whole NAV; treat as 0 and surface in reason later. protocols.push({ protocolId, tvlAdapter, valueInBase: 0n }); } } const aum = idle + protocols.reduce((s, p) => s + p.valueInBase, 0n); const proposedRate = totalSupply === 0n ? currentRate // empty vault — keep rate unchanged : (aum * (10n ** BigInt(baseDecimals))) / totalSupply; // 4) bounds check (mirrors the AccountantWithRateProviders pre-flight) const lowerOk = proposedRate >= (currentRate * BigInt(10_000 - Number(lowerBps))) / 10_000n; const upperOk = proposedRate <= (currentRate * BigInt(10_000 + Number(upperBps))) / 10_000n; const withinBounds = lowerOk && upperOk; const base: NavRow = { chainId: v.chainId, vault: v.vault, nonce: v.nonce, baseAsset: v.baseAsset, baseDecimals, idle, protocols, aum, totalSupply, currentRate, proposedRate, withinBounds, status: "skipped", }; // 5) safety filter — short-circuit, first hit wins. if (isPaused) return { ...base, reason: "accountant paused" }; const now = BigInt(Math.floor(Date.now() / 1000)); if (now - lastUpdate < BigInt(minDelay)) { return { ...base, reason: `min update delay not elapsed (need ${minDelay}s, elapsed ${now - lastUpdate}s)` }; } if (!withinBounds) { return { ...base, reason: `proposed rate out of bounds (current=${currentRate}, proposed=${proposedRate}, ±bps=${lowerBps}/${upperBps})` }; } if (proposedRate === currentRate) { return { ...base, reason: "no change" }; } // 6) authorization if (!signer) { return { ...base, status: dryRun ? "dry-run-ok" : "skipped", reason: dryRun ? "would push" : "no signer" }; } const authorized = await this.client.read({ address: v.rolesAuthority, abi: ROLES_AUTHORITY_ABI, functionName: "canCall", args: [signer, v.accountant, UPDATE_EXCHANGE_RATE_SELECTOR], }); if (!authorized) return { ...base, reason: `signer ${signer} not authorised to call updateExchangeRate on this accountant` }; if (dryRun) return { ...base, status: "dry-run-ok", reason: `would push ${proposedRate}` }; // 7) push const hash = await this.client.send({ address: v.accountant, abi: ACCOUNTANT_ABI, functionName: "updateExchangeRate", args: [proposedRate], }); await this.client.publicClient.waitForTransactionReceipt({ hash }); return { ...base, status: "pushed", txHash: hash }; } }