/** * Tool catalog the Strategy Decomposer agent calls during reasoning. * * Layers: * 1. Lucidly research tools — catalog, policies, verify_policy_id, addresses, families. * These let the agent confirm leaves exist on the live policy registry. The non-negotiable * "never invent a leaf" rule depends on verify_policy_id. * * 2. Strategy corpus tools — find_corpus_strategy + corpus_by_archetype + corpus_by_chain. * Nearest-neighbour lookup over the 4 reference archetypes. * * 3. Onchain read tools — read_contract proxies a JSON-RPC eth_call so the agent can inspect * pool prices, oracle staleness, market liquidity, etc. Read-only; the agent can't send tx. * * 4. Simulator dry-run tool — dry_run_plan posts to SimulatorClient.simulatePlan so the agent * can verify its candidate plan against an anvil fork BEFORE finalising. The simulator * itself has no signing key, so this is genuinely read-only at the broadcast level. * * Tool names use dotted notation (lucidly.policies); the SDK's OpenAICompatProvider already * sanitizes dots → underscores for DeepSeek's strict pattern. No special handling needed here. */ import type { Address } from "viem"; import { STRATEGY_CORPUS, STRATEGY_CORPUS_ENTRIES, findStrategyById, findStrategiesByArchetype, findStrategiesByChain, findStrategiesByProtocol, findCrossChainStrategies, type CorpusStrategy, type StrategyArchetype, } from "./strategyCorpus.js"; import { LUCIDLY_RESEARCH_TOOLS, dispatchLucidlyTool, type ResearchToolsModule, type ToolDescriptor } from "./researchTools.js"; import { SimulatorClient, type SimulateRequestV2 } from "./simulatorClient.js"; import tokenRegistryJson from "./data/token-registry.json" with { type: "json" }; const ADDRESS_SCHEMA = { type: "string", pattern: "^0x[a-fA-F0-9]{40}$", description: "EVM address (0x-prefixed)." }; const CHAIN_ID_SCHEMA = { type: "integer", description: "EVM chain id (e.g., 1 = Ethereum, 8453 = Base, 42161 = Arbitrum, 10 = Optimism, 999 = HyperEVM)." }; const HEX_SCHEMA = { type: "string", pattern: "^0x[a-fA-F0-9]*$", description: "Hex-encoded bytes (0x-prefixed)." }; // ─── Corpus lookup tools ───────────────────────────────────────────────────── export const CORPUS_TOOLS: ToolDescriptor[] = [ { name: "lucidly.corpus_overview", description: "Return a high-level overview of the reference strategy corpus — archetype families, " + "total strategy count, the topology note (homeChain vs positionChains), and the ids of every " + "indexed strategy. Use this FIRST when starting a decomposition to understand the landscape " + "before searching for specific examples.", input_schema: { type: "object", properties: {} }, }, { name: "lucidly.find_corpus_strategy", description: "Fetch the full corpus entry for a specific reference strategy by id. Returns the topology " + "(homeChain, positionChains, bridgeEdges), per-chain legs with leafFamilies + rationale, " + "decision triggers, risk factors, and the source repo path. Use this when you've identified " + "the nearest archetype and need its actual leaf-by-leaf playbook.", input_schema: { type: "object", properties: { id: { type: "string", description: "Corpus strategy id, e.g. 'delta_neutral.pendle_pt_sena_hyperliquid_short'." } }, required: ["id"], }, }, { name: "lucidly.corpus_by_archetype", description: "List every corpus strategy in a given archetype family. Use this when the user's idea fits " + "an archetype but you want to compare multiple reference examples (e.g., all leveraged_carry " + "strategies to find the one whose protocols are closest to what the user wants).", input_schema: { type: "object", properties: { archetype: { type: "string", enum: ["leveraged_carry", "lp_hedge", "yield_basket", "delta_neutral"], description: "Archetype family." }, }, required: ["archetype"], }, }, { name: "lucidly.corpus_by_chain", description: "List corpus strategies that touch a given chain (either positionChain or a bridge endpoint). " + "Useful for 'what strategies have been built on Base?' or 'which strategies bridge to HyperEVM?'.", input_schema: { type: "object", properties: { chainId: CHAIN_ID_SCHEMA }, required: ["chainId"], }, }, { name: "lucidly.corpus_by_protocol", description: "List corpus strategies that interact with a given protocol (case-insensitive substring match). " + "Examples: 'Morpho' returns both leveraged_carry + yield_basket strategies; 'Pendle' returns " + "only the DN strategy.", input_schema: { type: "object", properties: { protocol: { type: "string", description: "Protocol name fragment, e.g. 'Morpho', 'Aave', 'Pendle'." } }, required: ["protocol"], }, }, { name: "lucidly.corpus_cross_chain", description: "Return every corpus strategy that crosses chains (positionChains > 1 or non-empty bridgeEdges). " + "Useful when the user's idea spans chains and you need a cross-chain reference example.", input_schema: { type: "object", properties: {} }, }, ]; // ─── Onchain read tools ────────────────────────────────────────────────────── export const ONCHAIN_TOOLS: ToolDescriptor[] = [ { name: "lucidly.read_contract", description: "Read-only eth_call against any EVM chain the operator has an RPC for. Use this to inspect " + "live contract state when the indexer doesn't carry it: pool slot0(), oracle latestRoundData(), " + "Pendle market YT/PT info, Aave reserve config, etc. Returns the raw hex result; the agent " + "decodes it from there. Never submits transactions.", input_schema: { type: "object", properties: { chainId: CHAIN_ID_SCHEMA, target: ADDRESS_SCHEMA, calldata: { ...HEX_SCHEMA, description: "ABI-encoded function call (selector + args)." }, blockTag: { type: "string", description: "Optional — 'latest' (default), 'safe', 'finalized', or hex block number.", default: "latest" }, }, required: ["chainId", "target", "calldata"], }, }, { name: "lucidly.read_balance", description: "Read native (ETH) balance for an address on a given chain. Use to confirm a vault has gas " + "for upcoming txs, or to check pre-deposit state on a fork. Returns the balance as a uint256 " + "decimal string.", input_schema: { type: "object", properties: { chainId: CHAIN_ID_SCHEMA, address: ADDRESS_SCHEMA, blockTag: { type: "string", default: "latest" } }, required: ["chainId", "address"], }, }, { name: "lucidly.read_erc20_balance", description: "Read an ERC20 token's balanceOf(address) on a given chain. Convenience wrapper around " + "read_contract for the most common read pattern.", input_schema: { type: "object", properties: { chainId: CHAIN_ID_SCHEMA, token: ADDRESS_SCHEMA, address: ADDRESS_SCHEMA }, required: ["chainId", "token", "address"], }, }, ]; // ─── Global discovery tools (DC-2.5) ───────────────────────────────────────── /** * These let the agent answer "which deployed vault already has the policies I need?" instead of * always recommending the user deploy a new one. Useful when the user's idea matches an existing * production vault's capability set — the agent can suggest reuse + a one-click "I want to use * this vault" path. */ export const DISCOVERY_TOOLS: ToolDescriptor[] = [ { name: "lucidly.list_vaults", description: "List every Lucidly Pro vault the indexer knows about across all chains. Returns " + "{chainId, vault, strategist, baseAsset, policyCount, depositCount, createdAt} per row. " + "Use this to find candidate vaults the user might already be able to use for their idea. " + "Filter by chainId or minPolicies to narrow down.", input_schema: { type: "object", properties: { chainId: { ...CHAIN_ID_SCHEMA, description: "Filter to a single chain (e.g. 1, 8453). Omit for all chains." }, minPolicies: { type: "integer", minimum: 0, default: 1, description: "Filter out vaults with fewer than this many enabled policies. Default 1 (skip empty placeholders)." }, limit: { type: "integer", minimum: 1, maximum: 500, default: 100 }, }, }, }, { name: "lucidly.find_vaults_with_protocol", description: "Find deployed vaults that already have at least one Merkle-gated leaf for a given protocol — " + "e.g. 'which vaults have Pendle enabled?' or 'who already has Aerodrome LP whitelisted?'. " + "Walks lucidly.list_vaults, then calls lucidly.policies on each non-empty vault and matches " + "by protocol name (case-insensitive substring on the per-protocol clones returned by " + "lucidly.policies). Capped to ~10 vault probes to keep token usage sane.", input_schema: { type: "object", properties: { protocol: { type: "string", description: "Protocol name fragment, e.g. 'Morpho', 'Pendle', 'Aerodrome'." }, chainId: { ...CHAIN_ID_SCHEMA, description: "Optional — restrict probing to one chain." }, maxProbe: { type: "integer", minimum: 1, maximum: 20, default: 10, description: "Max vaults to probe with lucidly.policies. Probing is the expensive part of this tool." }, }, required: ["protocol"], }, }, ]; // ─── Novel-protocol research tools (DC-2.6) ────────────────────────────────── /** * These let the decomposer reach beyond the corpus when the user's idea touches a protocol that * isn't in our 4 reference strategies (e.g., Euler V2, Fluid, Spark, Hyperlend, Drift). The * pattern: web_search for the protocol's docs, find_token for canonical addresses, read_protocol_abi * for verified selectors against deployed bytecode. With these, the agent stops guessing from * training data and starts grounding on current reality. */ export const RESEARCH_TOOLS: ToolDescriptor[] = [ { name: "lucidly.web_search", description: "Search the web for protocol documentation, contract addresses, ABIs, current APYs, or any other DeFi knowledge the corpus doesn't cover. Returns top results with {title, url, snippet}. Use this when the user mentions a protocol not in the corpus (Euler, Fluid, Spark, etc.) or when you need current state (e.g., 'what's the current Pendle PT-USDe maturity', 'Euler V2 eVault factory address'). Limit ≤ 3 queries per decomposition — each call costs tokens and wall-clock.", input_schema: { type: "object", properties: { query: { type: "string", description: "Search query, e.g. 'Euler V2 eVault deposit function signature' or 'syrupUSDC contract address mainnet'." }, maxResults: { type: "integer", minimum: 1, maximum: 10, default: 5 }, }, required: ["query"], }, }, { name: "lucidly.find_token", description: "Resolve a token symbol to its canonical address on a given chain. Returns {address, decimals, name, source}. The SDK ships a curated registry of ~150 top DeFi tokens (USDC, WETH, wstETH, sUSDe, PYUSD, syrupUSDC, ENA, sENA, Aerodrome AERO, etc.). For tokens not in the registry, falls back to an onchain ERC20 symbol() lookup if rpcCall is configured. ALWAYS use this rather than guessing addresses from training data — addresses change, training data goes stale.", input_schema: { type: "object", properties: { symbol: { type: "string", description: "Token symbol, e.g. 'USDC', 'syrupUSDC', 'PYUSD'. Case-insensitive." }, chainId: CHAIN_ID_SCHEMA, }, required: ["symbol", "chainId"], }, }, { name: "lucidly.read_protocol_abi", description: "Fetch the verified ABI for a deployed contract from Etherscan v2 (multichain unified API). Returns the parsed ABI array — use it to confirm exact function selectors when training data may be stale. Particularly useful for newer protocols (Euler V2 eVaults, Pendle V3 markets, etc.) where the on-chain interface differs from what an LLM might guess. Requires etherscanApiKey to be configured on the decomposer.", input_schema: { type: "object", properties: { chainId: CHAIN_ID_SCHEMA, address: ADDRESS_SCHEMA, }, required: ["chainId", "address"], }, }, ]; // ─── Simulator dry-run tool ────────────────────────────────────────────────── export const SIMULATOR_TOOLS: ToolDescriptor[] = [ { name: "lucidly.dry_run_plan", description: "Run a candidate execution plan against a fresh anvil fork of the target chain. Returns " + "per-step success/revert + balance diffs. Use this BEFORE finalising your decomposition " + "to confirm the leaves you've chosen actually execute. The simulator has no signing key — " + "it physically cannot broadcast to mainnet. v2 multi-leaf plans only.", input_schema: { type: "object", properties: { chainId: CHAIN_ID_SCHEMA, vault: ADDRESS_SCHEMA, steps: { type: "array", description: "Ordered leaf calls. Each step is one transaction the boring vault would execute.", items: { type: "object", properties: { label: { type: "string" }, target: ADDRESS_SCHEMA, calldata: HEX_SCHEMA, value: { ...HEX_SCHEMA, description: "Native value attached, default '0x0'." }, }, required: ["target", "calldata"], }, }, watchTokens: { type: "array", items: ADDRESS_SCHEMA, default: [] }, }, required: ["chainId", "vault", "steps"], }, }, ]; // ─── Combined catalog ──────────────────────────────────────────────────────── /** * Full tool catalog the decomposer advertises to the LLM. Combines: * - LUCIDLY_RESEARCH_TOOLS (catalog, policies, verify_policy_id, addresses, families, etc.) * - CORPUS_TOOLS (nearest-neighbour archetype lookup) * - ONCHAIN_TOOLS (raw eth_call, balance reads) * - SIMULATOR_TOOLS (dry_run_plan) * * The agent is free to call any of these in any order. The system prompt enforces the * "never-invent-a-leaf" rule: every leaf must be confirmed via verify_policy_id before * being included in the final answer. */ export const DECOMPOSER_TOOLS: ToolDescriptor[] = [ ...LUCIDLY_RESEARCH_TOOLS, ...CORPUS_TOOLS, ...DISCOVERY_TOOLS, ...RESEARCH_TOOLS, ...ONCHAIN_TOOLS, ...SIMULATOR_TOOLS, ]; // ─── Dispatcher ────────────────────────────────────────────────────────────── export interface DecomposerToolContext { /** ResearchToolsModule (catalog/policies/verify_policy_id/etc.). Required. */ research: ResearchToolsModule; /** Optional simulator client — when set, lucidly.dry_run_plan is functional. When unset, the * dispatcher returns a clear error telling the agent the tool isn't wired up so it can fall * back to text-only validation. */ simulator?: SimulatorClient; /** Optional onchain reader — a function that does eth_call against the requested chain. When * unset, lucidly.read_contract returns an error and the agent must rely on catalog reads. */ rpcCall?: (chainId: number, target: Address, calldata: `0x${string}`, blockTag?: string) => Promise<`0x${string}`>; /** Optional balance reader. Same shape — when unset, lucidly.read_balance errors clearly. */ rpcBalance?: (chainId: number, address: Address, blockTag?: string) => Promise; /** Optional web search adapter — injectable so callers wire Brave / Tavily / DuckDuckGo / etc. * Function shape: (query, maxResults?) → array of {title, url, snippet}. When unset, the * lucidly.web_search tool returns an error and the agent falls back to corpus + training. */ webSearch?: (query: string, maxResults?: number) => Promise>; /** Optional Etherscan v2 unified API key. When set, lucidly.read_protocol_abi works across * every chain on the same key. Get one at https://etherscan.io/apis (free tier 5 req/s). */ etherscanApiKey?: string; } /** * Dispatch a tool call from the decomposer agent's loop. Returns whatever the underlying tool * returns (already JSON-serialisable). Never throws on tool-mechanic errors — instead returns * a clear { error, hint } shape so the agent can recover (e.g., "I should call verify_policy_id * with a different policyId" rather than crashing the conversation). */ export async function dispatchDecomposerTool( ctx: DecomposerToolContext, name: string, input: Record, ): Promise { // Corpus tools — these are pure JS, no network. Safe to call freely. switch (name) { case "lucidly.corpus_overview": return { apiVersion: STRATEGY_CORPUS.apiVersion, archetypeFamilies: STRATEGY_CORPUS.archetypeFamilies, topologyNote: STRATEGY_CORPUS.topologyNote, strategies: STRATEGY_CORPUS_ENTRIES.map((s) => ({ id: s.id, displayName: s.displayName, archetype: s.archetype, summary: s.summary, homeChain: s.topology.homeChain, positionChains: s.topology.positionChains, isCrossChain: s.topology.bridgeEdges.length > 0 || s.topology.positionChains.length > 1, })), }; case "lucidly.find_corpus_strategy": { const id = String(input.id ?? ""); const hit = findStrategyById(id); if (!hit) { return { error: "not_found", hint: `No corpus entry with id "${id}". Try lucidly.corpus_overview to list all ids.` }; } return hit; } case "lucidly.corpus_by_archetype": return findStrategiesByArchetype(input.archetype as StrategyArchetype); case "lucidly.corpus_by_chain": return findStrategiesByChain(input.chainId as number); case "lucidly.corpus_by_protocol": return findStrategiesByProtocol(String(input.protocol ?? "")); case "lucidly.corpus_cross_chain": return findCrossChainStrategies(); } // Onchain reads if (name === "lucidly.read_contract") { if (!ctx.rpcCall) { return { error: "rpc_not_configured", hint: "Onchain reader not wired. Rely on catalog reads + verify_policy_id." }; } try { const result = await ctx.rpcCall( input.chainId as number, input.target as Address, input.calldata as `0x${string}`, input.blockTag as string | undefined, ); return { result }; } catch (e) { return { error: "rpc_error", hint: e instanceof Error ? e.message : String(e) }; } } if (name === "lucidly.read_balance") { if (!ctx.rpcBalance) { return { error: "rpc_not_configured", hint: "Native balance reader not wired." }; } try { const balance = await ctx.rpcBalance(input.chainId as number, input.address as Address, input.blockTag as string | undefined); return { balance }; } catch (e) { return { error: "rpc_error", hint: e instanceof Error ? e.message : String(e) }; } } if (name === "lucidly.read_erc20_balance") { if (!ctx.rpcCall) { return { error: "rpc_not_configured", hint: "Onchain reader not wired." }; } // balanceOf(address) selector = 0x70a08231; arg = padded address const addr = String(input.address ?? "").toLowerCase().replace(/^0x/, ""); const calldata = `0x70a08231${addr.padStart(64, "0")}` as `0x${string}`; try { const raw = await ctx.rpcCall(input.chainId as number, input.token as Address, calldata); // Decode uint256 from hex. const big = raw === "0x" ? 0n : BigInt(raw); return { balance: big.toString() }; } catch (e) { return { error: "rpc_error", hint: e instanceof Error ? e.message : String(e) }; } } // Global discovery — DC-2.5 if (name === "lucidly.list_vaults") { // ResearchToolsModule doesn't expose vaults() directly. Reach into the indexer client. // research.indexer is typed as optional; if it isn't wired, return a clear error. const indexer = (ctx.research as unknown as { indexer?: { vaults?: (limit?: number) => Promise } }).indexer; if (!indexer?.vaults) { return { error: "indexer_not_configured", hint: "No indexer client on the research module. Wire LucidlyClient with indexerUrl." }; } try { const limit = (input.limit as number | undefined) ?? 100; const all = (await indexer.vaults(limit)) as Array> | null; if (!all) return { vaults: [], note: "Indexer returned null (unreachable or query failed)." }; const chainFilter = input.chainId as number | undefined; const minPolicies = (input.minPolicies as number | undefined) ?? 1; const filtered = all.filter((v) => { if (chainFilter !== undefined && v.chainId !== chainFilter) return false; if (typeof v.policyCount === "number" && v.policyCount < minPolicies) return false; return true; }); return { vaults: filtered.map((v) => ({ chainId: v.chainId, vault: v.vault, strategist: v.strategist, baseAsset: v.baseAsset, policyCount: v.policyCount, depositCount: v.depositCount, createdAt: v.createdAt, })), totalKnown: all.length, filtered: filtered.length, }; } catch (e) { return { error: "indexer_error", hint: e instanceof Error ? e.message : String(e) }; } } if (name === "lucidly.find_vaults_with_protocol") { const indexer = (ctx.research as unknown as { indexer?: { vaults?: (limit?: number) => Promise } }).indexer; if (!indexer?.vaults) { return { error: "indexer_not_configured", hint: "No indexer client on the research module. Wire LucidlyClient with indexerUrl." }; } const protocolNeedle = String(input.protocol ?? "").toLowerCase().trim(); if (!protocolNeedle) { return { error: "missing_protocol", hint: "Provide a protocol name fragment, e.g. 'Pendle'." }; } const chainFilter = input.chainId as number | undefined; const maxProbe = Math.min((input.maxProbe as number | undefined) ?? 10, 20); try { const all = (await indexer.vaults(300)) as Array> | null; if (!all) return { matches: [], note: "Indexer returned null." }; // Sort by policyCount desc — vaults with more policies are more likely to have what we want. const candidates = all .filter((v) => (chainFilter === undefined || v.chainId === chainFilter) && (v.policyCount as number) > 0) .sort((a, b) => (b.policyCount as number) - (a.policyCount as number)) .slice(0, maxProbe); const matches: Array> = []; let probed = 0; for (const v of candidates) { probed++; try { const policiesResp = await ctx.research.policies({ vault: v.vault as Address }); // policiesResp.perProtocol is an array — substring-match each protocol name field. // Cast through unknown — the real ProtocolPolicyStatus shape varies; we only need the `protocol` string field. const perProtocol = ((policiesResp as unknown) as { perProtocol?: Array> }).perProtocol ?? []; const matchedClones = perProtocol.filter((pp) => String(pp.protocol ?? "").toLowerCase().includes(protocolNeedle)); if (matchedClones.length > 0) { matches.push({ chainId: v.chainId, vault: v.vault, strategist: v.strategist, baseAsset: v.baseAsset, policyCount: v.policyCount, depositCount: v.depositCount, matchedClones, }); } } catch { // Skip this vault on policies-fetch failure — don't let one bad vault blow up the search. } } return { matches, protocolSearched: input.protocol, candidatesProbed: probed, candidatesAvailable: all.length, note: probed >= maxProbe ? `Probed first ${maxProbe} vaults by policyCount. Raise maxProbe to search wider.` : "Exhaustive probe within filters.", }; } catch (e) { return { error: "indexer_error", hint: e instanceof Error ? e.message : String(e) }; } } // Simulator if (name === "lucidly.dry_run_plan") { if (!ctx.simulator) { return { error: "simulator_not_configured", hint: "Simulator client not wired. The agent must rely on catalog + onchain reads to validate." }; } try { const req: SimulateRequestV2 = { chainId: input.chainId as 1 | 8453 | 42161 | 10, vault: input.vault as `0x${string}`, plan: { steps: (input.steps as Array>).map((s) => ({ kind: "leaf", label: typeof s.label === "string" ? s.label : undefined, call: { target: s.target as `0x${string}`, calldata: s.calldata as `0x${string}`, value: (s.value as `0x${string}` | undefined) ?? "0x0", }, })), }, watchTokens: (input.watchTokens as `0x${string}`[] | undefined) ?? [], }; const res = await ctx.simulator.simulatePlan(req); return res; } catch (e) { return { error: "simulator_error", hint: e instanceof Error ? e.message : String(e) }; } } // DC-2.6 — Novel-protocol research if (name === "lucidly.web_search") { if (!ctx.webSearch) { return { error: "web_search_not_configured", hint: "No webSearch adapter wired. The decomposer can still answer from corpus + training; web research is unavailable. Configure decomposer.webSearch with Brave/Tavily/DuckDuckGo to enable.", }; } try { const query = String(input.query ?? "").trim(); if (!query) return { error: "missing_query" }; const maxResults = (input.maxResults as number | undefined) ?? 5; const results = await ctx.webSearch(query, maxResults); return { query, results: results.slice(0, maxResults) }; } catch (e) { return { error: "web_search_error", hint: e instanceof Error ? e.message : String(e) }; } } if (name === "lucidly.find_token") { const symbol = String(input.symbol ?? "").trim(); const chainId = input.chainId as number | undefined; if (!symbol || !chainId) return { error: "missing_args", hint: "Both symbol and chainId are required." }; const reg = tokenRegistryJson as { chains: Record }> }; const chainEntry = reg.chains[String(chainId)]; if (!chainEntry) { return { error: "chain_not_in_registry", hint: `No token registry entries for chain ${chainId}. Try lucidly.read_contract with the ERC20 symbol() selector (0x95d89b41).` }; } // Case-insensitive symbol match. const symbolUpper = symbol.toUpperCase(); const matched = Object.entries(chainEntry.tokens).find(([k]) => k.toUpperCase() === symbolUpper); if (matched) { const [matchedSymbol, info] = matched; const isPlaceholder = info.address === "0x0000000000000000000000000000000000000000" || info.name.includes("placeholder"); return { symbol: matchedSymbol, address: info.address, decimals: info.decimals, name: info.name, chainId, chainName: chainEntry.name, source: "registry", warning: isPlaceholder ? "Registry entry is a placeholder — VERIFY via lucidly.web_search or lucidly.read_protocol_abi before emitting in a verified leaf." : undefined, }; } // Fallback — onchain ERC20 symbol() if rpcCall is wired. if (!ctx.rpcCall) { return { error: "token_not_in_registry", hint: `Symbol "${symbol}" not in the curated registry for chain ${chainId}. Wire rpcCall to enable onchain symbol() fallback, or use lucidly.web_search.` }; } // Without an address to call we can't probe — fallback is only useful when caller knows the address. // Surface a clearer hint so the agent reaches for web_search instead. return { error: "token_not_in_registry", hint: `Symbol "${symbol}" not in the curated registry. Use lucidly.web_search to find the canonical address, then lucidly.read_contract with selector 0x95d89b41 to confirm symbol matches.` }; } if (name === "lucidly.read_protocol_abi") { if (!ctx.etherscanApiKey) { return { error: "etherscan_not_configured", hint: "No etherscanApiKey wired. Configure decomposer.etherscanApiKey to enable verified-ABI lookups (free tier at etherscan.io)." }; } const chainId = input.chainId as number | undefined; const address = String(input.address ?? "").trim(); if (!chainId || !address) return { error: "missing_args" }; // Helper — fetch a slimmed ABI for one address. Returns null if Etherscan didn't return a usable ABI. const fetchAbi = async (addr: string): Promise<{ abi: Array>; raw: string } | null> => { const u = `https://api.etherscan.io/v2/api?chainid=${chainId}&module=contract&action=getabi&address=${addr}&apikey=${ctx.etherscanApiKey}`; const r = await fetch(u); if (!r.ok) return null; const j = (await r.json()) as { status: string; result: string }; if (j.status !== "1") return null; try { const parsed = JSON.parse(j.result); if (!Array.isArray(parsed)) return null; const slim = (parsed as Array>).filter((x) => x.type === "function" || x.type === "event"); return { abi: slim, raw: j.result }; } catch { return null; } }; try { // ── Step 1: getsourcecode — reveals proxy implementation address if present. ───────────── const srcUrl = `https://api.etherscan.io/v2/api?chainid=${chainId}&module=contract&action=getsourcecode&address=${address}&apikey=${ctx.etherscanApiKey}`; const srcRes = await fetch(srcUrl); if (!srcRes.ok) return { error: "etherscan_http_error", hint: `HTTP ${srcRes.status}` }; const srcJ = (await srcRes.json()) as { status: string; message: string; result: Array> }; if (srcJ.status !== "1" || !srcJ.result?.[0]) { return { error: "etherscan_not_verified", hint: `getsourcecode failed: ${srcJ.message}. Contract may not be verified on Etherscan for chain ${chainId}.` }; } const meta = srcJ.result[0]!; const proxyFlag = String(meta.Proxy ?? "0") === "1"; const implRaw = String(meta.Implementation ?? "").toLowerCase().trim(); const implAddr = proxyFlag && implRaw && implRaw !== "0x" && implRaw !== "0x0000000000000000000000000000000000000000" ? implRaw : null; // ── Step 2: fetch ABI from the implementation if it's a proxy, else from the original. ─── const primary = implAddr ?? address; const primaryAbi = await fetchAbi(primary); // ── Step 3: if implementation ABI is empty/missing, fall back to the proxy's own ABI. ──── let abi: Array> = []; let abiSource = primary; let note = ""; if (primaryAbi && primaryAbi.abi.length > 0) { abi = primaryAbi.abi; if (implAddr) note = `Followed proxy ${address} → implementation ${implAddr}. ABI fetched from implementation.`; } else if (implAddr) { // Implementation didn't yield ABI — fall back to proxy. const proxyAbi = await fetchAbi(address); if (proxyAbi) { abi = proxyAbi.abi; abiSource = address; note = `Proxy implementation ${implAddr} returned no usable ABI (likely unverified). Falling back to proxy's own ABI — which is usually limited (Genesis event only). Recommend lucidly.web_search(' source code github').`; } else { return { error: "abi_unavailable", hint: `Both proxy (${address}) and implementation (${implAddr}) returned no usable ABI. Try web_search for the protocol's GitHub.` }; } } else { // Not a proxy and no ABI — uncommon, surface clearly. return { error: "abi_unavailable", hint: `${address} returned no usable ABI and is not flagged as a proxy. Contract may be unverified — try web_search.` }; } const functionCount = abi.filter((x) => x.type === "function").length; const eventCount = abi.filter((x) => x.type === "event").length; return { chainId, requestedAddress: address, abiSource, isProxy: !!implAddr, implementation: implAddr, functionCount, eventCount, contractName: String(meta.ContractName ?? ""), note: note || undefined, abi, }; } catch (e) { return { error: "etherscan_error", hint: e instanceof Error ? e.message : String(e) }; } } // Fall through to the existing research-tools dispatcher for everything else. return dispatchLucidlyTool(ctx.research, name, input); } // Re-export common helpers callers will want. export type { CorpusStrategy };