/** * Typed client for the lucidly-simulator service. Mirrors the v1 + v2 HTTP API exactly. * * The simulator runs Trail leaf calls (and multi-leaf plans) against anvil forks of the supported * chains — Ethereum (1), Base (8453), Arbitrum (42161), Optimism (10) — and returns receipts, * decoded revert reasons, and pre/post balance diffs. It never broadcasts to real chain: no * signing key is configured on the service. * * Typical usage: * * const client = new LucidlyClient({ chain: "base", rpcUrl: "...", simulatorUrl: "https://..." }); * const r = await client.simulator!.simulatePlan({ * chainId: 8453, * vault: "0x...", * plan: { steps: [ * { kind: "leaf", label: "approve USDC", call: { target: "0x...", calldata: "0x...", value: "0x0" } }, * { kind: "leaf", label: "supply on Aave", call: { target: "0x...", calldata: "0x...", value: "0x0" } }, * ] }, * watchTokens: [USDC, aUSDC], * }); * if (!r.success) console.warn("step", r.failedStepIndex, "reverted:", r.stepResults[r.failedStepIndex]?.revertReason); * * Wire types are mirrored from lucidly-simulator/src/types.ts. They're duplicated here intentionally * — re-importing across packages would couple the simulator's deploy to the SDK's publish cadence. * Schema changes that aren't backward-compatible bump the apiVersion ("1" → "2" → …). */ // ─── Wire types (mirrored from lucidly-simulator) ──────────────────────────── export type SimulatorChainId = 1 | 8453 | 42161 | 10; export interface SimLeafCall { /** Target contract address (40-char hex). */ target: `0x${string}`; /** Hex-encoded calldata. The simulator does NOT encode for you — encode upstream. */ calldata: `0x${string}`; /** Optional msg.value in wei, hex-encoded. Default '0x0'. */ value?: `0x${string}`; } export interface SimPlanStep { /** v2 currently supports only "leaf". Automation + api kinds land in v2.1. */ kind: "leaf"; /** Optional human label — flows into the response stepResults[i].label. */ label?: string; call: SimLeafCall; } export interface SimulatedReceipt { transactionHash: string; status: "success" | "reverted"; /** uint256 as decimal string. */ gasUsed: string; blockNumber: number; logs: Array<{ address: string; topics: string[]; data: string; }>; } export interface SimBalanceDelta { account: string; /** "native" for ETH, otherwise the token address. */ asset: "native" | string; decimals: number | null; symbol: string | null; /** uint256 as decimal string. */ before: string; after: string; /** Signed delta as decimal string. Negative means the account sent / had its balance reduced. */ delta: string; } export interface SimulationTiming { totalMs: number; snapshotMs: number; executeMs: number; diffMs: number; revertMs: number; } // ─── v1 request/response ───────────────────────────────────────────────────── export interface SimulateRequestV1 { chainId: SimulatorChainId; /** Vault address — the simulator impersonates this on the fork and funds it with 100 ETH. */ vault: `0x${string}`; call: SimLeafCall; /** ERC20 token addresses to snapshot pre/post. ETH/native is always snapshotted. */ watchTokens?: `0x${string}`[]; /** Extra accounts to snapshot beyond the vault. */ watchAccounts?: `0x${string}`[]; /** Caller-supplied correlation id (audit-only; does not affect simulation). */ requestId?: string; } export interface SimulateResponseV1 { apiVersion: "1"; requestId: string | null; chainId: SimulatorChainId; forkBlock: number; success: boolean; receipt: SimulatedReceipt | null; revertReason: string | null; balanceChanges: SimBalanceDelta[]; timing: SimulationTiming; warnings: string[]; } // ─── v2 request/response (multi-leaf plan) ────────────────────────────────── export interface SimulateRequestV2 { chainId: SimulatorChainId; vault: `0x${string}`; plan: { steps: SimPlanStep[] }; watchTokens?: `0x${string}`[]; watchAccounts?: `0x${string}`[]; requestId?: string; } export interface SimulateResponseV2 { apiVersion: "2"; requestId: string | null; chainId: SimulatorChainId; forkBlock: number; /** True iff every step succeeded. If any step reverted, false and execution stopped at that step. */ success: boolean; /** Per-step result. Length ≤ steps.length — entries after a failing step are NOT present. */ stepResults: Array<{ stepIndex: number; kind: "leaf"; label: string | null; ok: boolean; receipt: SimulatedReceipt | null; revertReason: string | null; balanceChanges: SimBalanceDelta[]; timingMs: number; }>; /** Net deltas across the entire plan (after-last-step vs. before-first-step). */ balanceChanges: SimBalanceDelta[]; /** Index of the failing step, or -1 on success. */ failedStepIndex: number; timing: SimulationTiming; warnings: string[]; } // ─── Status endpoint ───────────────────────────────────────────────────────── export interface SimulatorStatus { chains: Array<{ chainId: SimulatorChainId; ready: boolean; forkBlock: number | null; /** Anvil uptime in seconds. */ uptime: number | null; rpcUrl: string | null; }>; ts: string; } // ─── Error type ────────────────────────────────────────────────────────────── /** Thrown when the simulator returns a non-2xx response or when the request fails to reach it. */ export class SimulatorError extends Error { constructor( public readonly code: string, message: string, public readonly status?: number, public readonly requestId?: string, public readonly issues?: unknown[], ) { super(message); this.name = "SimulatorError"; } } // ─── Client ────────────────────────────────────────────────────────────────── export interface SimulatorClientOptions { /** Base URL of the simulator service, e.g. "https://simulator.rdv8.xyz" or "http://localhost:42071". */ url: string; /** Optional fetch implementation. Defaults to globalThis.fetch. Pass a custom one for retries/logging. */ fetch?: typeof fetch; /** Optional request timeout in ms. Default 30s — sims usually return in <1s but include headroom for cold anvil starts. */ timeoutMs?: number; } export class SimulatorClient { private readonly url: string; private readonly fetchImpl: typeof fetch; private readonly timeoutMs: number; constructor(opts: SimulatorClientOptions) { // Strip trailing slash so we can naively concatenate endpoint paths. this.url = opts.url.replace(/\/+$/, ""); // CRITICAL: bind to globalThis. `window.fetch` (and Node's global fetch via undici) check // their receiver — calling `this.fetchImpl(...)` where fetchImpl was a property assignment // would invoke fetch with `this === SimulatorClient instance` and throw "Illegal invocation". // Binding to globalThis pins the receiver to Window/globalThis regardless of how we invoke it. // If the caller passed their own fetch implementation, we trust them to have bound it correctly // (most fetch polyfills + custom wrappers are receiver-agnostic), so we don't re-bind. const baseFetch = opts.fetch ?? (globalThis.fetch as typeof fetch | undefined); if (!baseFetch) { throw new Error("SimulatorClient: no fetch available — pass `fetch` in options on Node 16 or older"); } this.fetchImpl = opts.fetch ? opts.fetch : baseFetch.bind(globalThis); this.timeoutMs = opts.timeoutMs ?? 30_000; } /** GET /health — returns true if the simulator HTTP service is up (anvils may still be warming). */ async health(): Promise { try { const res = await this.fetchWithTimeout(`${this.url}/health`, { method: "GET" }); return res.ok; } catch { return false; } } /** GET /status — per-chain anvil state. Useful for "is chain X ready to sim against?" checks. */ async status(): Promise { const res = await this.fetchWithTimeout(`${this.url}/status`, { method: "GET" }); if (!res.ok) throw new SimulatorError("status_failed", `GET /status → ${res.status}`, res.status); return (await res.json()) as SimulatorStatus; } /** * v1 — simulate a single leaf call. Convenience wrapper around `simulatePlan` for the common * "I just want to preview one call" case. Returns the v1 response shape (single receipt, single * revert reason, overall balance changes). */ async simulate(req: SimulateRequestV1): Promise { return (await this.post("/simulate", { apiVersion: "1", ...req })) as SimulateResponseV1; } /** * v2 — simulate a multi-step plan. Steps execute sequentially under ONE shared anvil snapshot * (so step N sees the state left by step N-1, just like a real multicall). Aborts on first revert. */ async simulatePlan(req: SimulateRequestV2): Promise { return (await this.post("/simulate", { apiVersion: "2", ...req })) as SimulateResponseV2; } // ─── Internal HTTP helpers ───────────────────────────────────────────────── private async post(path: string, body: unknown): Promise { const res = await this.fetchWithTimeout(`${this.url}${path}`, { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify(body), }); // Always try to parse body — even errors return JSON with {error, message, requestId}. let json: unknown; try { json = await res.json(); } catch { throw new SimulatorError("invalid_response", `${path} → HTTP ${res.status}, no JSON body`, res.status); } if (!res.ok) { const j = json as { error?: string; message?: string; requestId?: string; issues?: unknown[] }; throw new SimulatorError( j.error ?? "http_error", j.message ?? `${path} → HTTP ${res.status}`, res.status, j.requestId, j.issues, ); } return json; } private async fetchWithTimeout(input: string, init: RequestInit): Promise { const controller = new AbortController(); const timeout = setTimeout(() => controller.abort(), this.timeoutMs); try { return await this.fetchImpl(input, { ...init, signal: controller.signal }); } catch (e) { if (e instanceof Error && e.name === "AbortError") { throw new SimulatorError("timeout", `request to ${input} timed out after ${this.timeoutMs}ms`); } throw new SimulatorError("network_error", e instanceof Error ? e.message : String(e)); } finally { clearTimeout(timeout); } } }