import { parseUnits, type Address } from "viem"; import type { LucidlyClient } from "./client.js"; import type { Deployed } from "./types.js"; import { tokenDecimals } from "./chains.js"; /** * Reference natural-language router for single-prompt vault operations. * * The safety property: any strategy action is GUARDED by client.strategy.hasLeaf(...) BEFORE * a transaction is built. The agent physically cannot execute anything outside the vault's * pre-approved Merkle leaf set — so "run a strategy from one prompt" is acceptable. * * The intent parser here is intentionally simple (keyword/regex). In production you'd swap in * an LLM router that emits the same structured intent; the guard stays exactly as-is. */ export type AgentIntent = | { kind: "deploy"; name: string; symbol: string; baseAsset: string } | { kind: "deploy-v2"; name: string; symbol: string; baseAsset: string } | { kind: "supply"; protocol: "AaveV3"; asset: string; amount: string } | { kind: "withdraw"; protocol: "AaveV3"; asset: string; amount: string } | { kind: "redeem-queue"; asset: string } | { kind: "view" } // indexer intents (read-only) | { kind: "list-vaults" } | { kind: "list-deposits"; vault: Address } | { kind: "list-rates"; vault: Address } | { kind: "review-queue" } | { kind: "unknown" }; export interface AgentResult { action: string; ok: boolean; message: string; txHash?: string; data?: unknown; } export function parsePrompt(text: string, knownSymbols: string[]): AgentIntent { const t = text.toLowerCase(); const asset = knownSymbols.find((s) => new RegExp(`\\b${s.toLowerCase()}\\b`).test(t)) ?? "USDC"; const numMatch = t.match(/([0-9][0-9,]*\.?[0-9]*)/); const amount = numMatch ? numMatch[1].replace(/,/g, "") : "0"; const isAave = /\baave\b/.test(t); // Queued (async) redemptions: illiquid vaults, instant withdrawals failing, "how do users redeem". if ( !isAave && /(redeem|redemption|withdraw|cash out|exit|get .*out)/.test(t) && /(queue|atomicqueue|illiquid|instant|liquidity|pendle|locked|how do (users|holders|people)|users? (redeem|withdraw|exit)|can't|cannot|fail)/.test(t) ) { return { kind: "redeem-queue", asset }; } // Indexer intents take precedence over deploy/strategy keywords so "list vaults" doesn't trip the // deploy regex on the word "vault". const addr = (text.match(/0x[a-fA-F0-9]{40}/) || [])[0] as Address | undefined; if (/(review queue|policy requests?|pending policies|whitelist requests?)/.test(t)) return { kind: "review-queue" }; if (/(list|show|all|every).*vaults?|vaults? deployed|indexed vaults?/.test(t)) return { kind: "list-vaults" }; if (addr && /(deposits?|deposit ledger|deposit history)/.test(t)) return { kind: "list-deposits", vault: addr }; if (addr && /(rate history|nav history|share price history|rates? history)/.test(t)) return { kind: "list-rates", vault: addr }; if (/(deploy|create|launch|spin up|set up).*(vault|treasury)|(vault|treasury).*(deploy|create|launch)/.test(t)) { const nameMatch = text.match(/(?:called|named)\s+"?([^"]+?)"?\s*$/i); const name = nameMatch ? nameMatch[1].trim() : `Lucidly ${asset} Vault`; const symbol = `luc${asset}`.slice(0, 11); // v2.1 cues: "v2", "permissionless", "factory", "pro" → use the permissionless FactoryV2 path. if (/(v2\.?1?|permissionless|factory v2|factoryv2|lucidly pro|pro vault)/.test(t)) { return { kind: "deploy-v2", name, symbol, baseAsset: asset }; } return { kind: "deploy", name, symbol, baseAsset: asset }; } if (/(withdraw|redeem|remove|pull|take out|unwind)/.test(t) && (isAave || /from aave/.test(t))) { return { kind: "withdraw", protocol: "AaveV3", asset, amount }; } if (/(supply|lend|deposit|move|put|allocate|earn|invest)/.test(t) && (isAave || /into aave|to aave/.test(t))) { return { kind: "supply", protocol: "AaveV3", asset, amount }; } if (/(rate|nav|aum|status|view|how much|balance|worth)/.test(t)) return { kind: "view" }; return { kind: "unknown" }; } export class LucidlyAgent { constructor(private readonly client: LucidlyClient) {} async handle(prompt: string, opts: { vault?: Address } = {}): Promise { const symbols = Object.keys(this.client.chainConfig.tokens); const intent = parsePrompt(prompt, symbols); switch (intent.kind) { case "deploy": { // The prompter becomes the vault's strategist/rate-updater/solver so the // single-prompt strategy flow is self-contained. const me = this.client.account?.address; const d: Deployed = await this.client.vault.deploy({ name: intent.name, symbol: intent.symbol, baseAsset: intent.baseAsset, allowedDepositAssets: [intent.baseAsset], strategist: me, rateUpdater: me, solver: me, }); return { action: "deploy", ok: true, message: `Deployed ${intent.name} (${intent.symbol}). Vault: ${d.vault}`, data: d }; } case "supply": case "withdraw": { const vault = opts.vault ?? this.client.activeVault; if (!vault) return { action: intent.kind, ok: false, message: "No active vault. Deploy or select one first." }; // ---- SAFETY GUARD: confirm a matching leaf exists before building any tx ---- if (!this.client.strategy.hasLeaf(vault, { protocol: intent.protocol, action: intent.kind, asset: intent.asset })) { return { action: intent.kind, ok: false, message: `This vault hasn't whitelisted ${intent.protocol} for ${intent.asset}. Run "integration enable ${intent.protocol}" first.`, }; } const dec = tokenDecimals(this.client.chainConfig, intent.asset) ?? 18; const amount = parseUnits(intent.amount, dec); const tx = intent.kind === "supply" ? await this.client.strategy.aave.supply(vault, { asset: intent.asset, amount }) : await this.client.strategy.aave.withdraw(vault, { asset: intent.asset, amount }); await tx.wait(); return { action: intent.kind, ok: true, message: `${intent.kind} ${intent.amount} ${intent.asset} via Aave`, txHash: tx.hash }; } case "redeem-queue": { const want = intent.asset; const msg = `Instant Teller withdrawals (bulkWithdraw) only work when the vault holds enough free ${want} to cover the burn — ` + `a vault parked in Pendle or other illiquid positions won't, so the call reverts. ` + `Use the AtomicQueue instead: holders post a redemption request with ` + `"lucidly-cli queue request ${want} --price <${want}PerShare> --deadline ". ` + `The --price is their floor (limit order), so they're protected from a bad fill. ` + `A SOLVER then unwinds the positions to free ${want} and fulfills the batch atomically with ` + `"lucidly-cli queue solve ${want} " — every request in the batch is paid at its price in one transaction, or the whole solve reverts. ` + `Run "lucidly-cli queue deploy" once to set up the queue + solver.`; return { action: "redeem-queue", ok: true, message: msg }; } case "deploy-v2": { // Permissionless v2.1 factory path — deployer becomes STRATEGIST, registry owner is the // vault owner + streaming-fee recipient (no per-deploy signature from Lucidly). We fill the // unspecified fields with conservative defaults; tune via the CLI for production. const me = this.client.account?.address; if (!me) return { action: "deploy-v2", ok: false, message: "deploy-v2 needs a signer." }; const baseAddr = this.client.resolveToken(intent.baseAsset) as Address; const dec = tokenDecimals(this.client.chainConfig, intent.baseAsset) ?? 6; const r = await this.client.factoryV2.deploy({ name: intent.name, symbol: intent.symbol, baseAsset: baseAddr, startingRate: BigInt(10) ** BigInt(dec), // 1.0 in base decimals upperBoundBps: 10010, // +0.10% / update lowerBoundBps: 9990, // -0.10% / update minUpdateDelay: 300, performanceFeeBps: 0, shareLockSeconds: 0n, rateUpdater: me, solver: me, depositAssets: [baseAddr], allowPublicDeposits: false, }); return { action: "deploy-v2", ok: true, message: `Deployed v2.3 vault ${intent.name} (${intent.symbol}). Vault: ${r.vault} PolicyManager: ${r.policyManager} tx: ${r.txHash}`, txHash: r.txHash, data: r, }; } case "list-vaults": { if (!this.client.indexer) return { action: "list-vaults", ok: false, message: "No indexer configured. Set `indexerUrl` on the client." }; const items = await this.client.indexer.vaults(); if (items === null) return { action: "list-vaults", ok: false, message: "Indexer unreachable." }; const lines = items.map(v => ` #${v.nonce} ${v.vault} base ${v.baseAsset} policies ${v.policyCount} deposits ${v.depositCount}`); return { action: "list-vaults", ok: true, message: `Indexed vaults (${items.length}):\n${lines.join("\n") || " (none)"}`, data: items }; } case "list-deposits": { if (!this.client.indexer) return { action: "list-deposits", ok: false, message: "No indexer configured." }; const items = await this.client.indexer.deposits(intent.vault); if (items === null) return { action: "list-deposits", ok: false, message: "Indexer unreachable." }; return { action: "list-deposits", ok: true, message: `Deposits for ${intent.vault}: ${items.length}`, data: items }; } case "list-rates": { if (!this.client.indexer) return { action: "list-rates", ok: false, message: "No indexer configured." }; const items = await this.client.indexer.rateHistory(intent.vault); if (items === null) return { action: "list-rates", ok: false, message: "Indexer unreachable." }; return { action: "list-rates", ok: true, message: `Rate updates for ${intent.vault}: ${items.length}`, data: items }; } case "review-queue": { if (!this.client.indexer) return { action: "review-queue", ok: false, message: "No indexer configured." }; const items = await this.client.indexer.policyRequests(); if (items === null) return { action: "review-queue", ok: false, message: "Indexer unreachable." }; return { action: "review-queue", ok: true, message: `Open policy requests: ${items.length}`, data: items }; } case "view": { const v = await this.client.vault.view(); const aum = await this.client.vault.aum(); return { action: "view", ok: true, message: `${v.symbol}: rate ${v.rate.toString()}, supply ${v.totalSupply.toString()}, AUM ${aum.toString()}`, data: { ...v, totalSupply: v.totalSupply.toString(), rate: v.rate.toString(), aum: aum.toString() }, }; } default: return { action: "unknown", ok: false, message: 'I can deploy a vault, supply/withdraw via Aave, or report status. Try: "supply 1000 USDC into Aave".', }; } } }