/** * Trail expander — turn an abstract Trail + params + vault context into a concrete ExecutionPlan. * * What expansion DOES: * • Validates supplied params against the Trail's param schema. * • Substitutes `$paramName` references everywhere they appear in step args. * • Resolves `subtrail` steps by recursively expanding the referenced Trail and splicing its * steps inline (with the outer trail's args/binding scope). * • Looks up each `leaf` step against the Catalog to resolve the abstract (protocol, action) * into a concrete (target, selector, decoder, templateId) entry — the data the on-chain * Manager actually needs. * • For atomic mode: verifies the flashloan step exists, marks the bracket, and notes which * leaves get bundled into the callback payload. * * What expansion does NOT do: * • RUN automation scripts — outputs are deferred; the runner (W4) calls them. * • CALL venue APIs — same, the runner handles those. * • BROADCAST anything — pure synchronous compute; no chain or network IO. * * That separation matters: the expander is fast, pure, and testable. The runner is async, * side-effectful, and the place where simulation / abort / monitoring concerns land. */ import { exprRef, type Trail, type TrailStep, type ChainName } from "./trail.js"; import type { TrailLibrary } from "./trailLibrary.js"; // ─── Catalog adapter ───────────────────────────────────────────────────────── /** * A catalog adapter resolves the abstract `(protocol, action, chain, args)` of a leaf step into * a concrete on-chain leaf — the (target, selector, decoder, …) tuple a Manager call needs. * * The SDK ships with the interface; concrete adapters live alongside the surfaces that have a * catalog source: * • UI (lucidly-pro): `StrategyLibraryCatalog` wraps the in-page `STRATEGY_LIBRARY` array. * • Server (future): `OnChainCatalog` queries the `LucidlyRegistry` directly for the latest * whitelisted templates per chain. * • Tests: `InMemoryCatalog` with hand-crafted entries. */ export interface CatalogEntry { /** Stable id from whatever catalog source. UI-side this matches STRATEGY_LIBRARY[i].id. */ policyId: string; /** Friendly description (often the policy name) — useful for traces + LLM context. */ name?: string; /** Chain this entry belongs to. */ chain: ChainName; /** The protocol the leaf calls into (e.g. "morphoBlue"). */ protocol: string; /** Action verb (e.g. "supply"). */ action: string; /** On-chain Manager-call fields. */ target: `0x${string}`; selector: `0x${string}`; decoder: `0x${string}`; /** Optional — non-zero msg.value flag for the leaf. */ valueIsNonZero?: boolean; /** Optional — argsHash if the template is FIXED-ARGS rather than VAULT-BOUND. */ argsHash?: `0x${string}`; } export interface CatalogQuery { protocol: string; action: string; chain: ChainName; /** Resolved args — adapters MAY use these to disambiguate when multiple leaves match the * same (protocol, action) but differ in args (e.g. per-asset approve, per-market supply). */ args: Record; } export interface Catalog { resolve(q: CatalogQuery): CatalogEntry | null; } // ─── Execution plan — what expansion produces ──────────────────────────────── export interface ResolvedLeafStep { kind: "leaf"; /** Optional step id from the source Trail — used by sub-trail output references. */ id?: string; description?: string; /** Concrete catalog entry the abstract (protocol, action) resolved to. */ entry: CatalogEntry; /** Args with all `$refs` substituted. May still contain object/array structures. */ args: Record; } export interface ResolvedApiStep { kind: "api"; id?: string; description?: string; venue: string; method: string; params: Record; } export interface ResolvedAutomationStep { kind: "automation"; id?: string; description?: string; scriptId: string; inputs: Record; outputs: string[]; } export type ResolvedStep = ResolvedLeafStep | ResolvedApiStep | ResolvedAutomationStep; export interface ExecutionPlan { trailId: string; trailName: string; chain: ChainName; vault: `0x${string}`; mode: "atomic" | "sequential" | "parallel"; steps: ResolvedStep[]; /** * For atomic mode: the index (in `steps`) of the flashloan-initiation leaf. Steps after this * are bundled into the flashloan callback payload by the byte-encoder downstream (W4). */ flashloanStepIndex?: number; /** Resolved param values used for this expansion — handy for traces + audit. */ resolvedParams: Record; /** Diagnostics — warnings the caller may want to surface but didn't block expansion. */ warnings: string[]; } // ─── Errors ────────────────────────────────────────────────────────────────── export class TrailExpansionError extends Error { constructor(public readonly trailId: string, public readonly reason: string) { super(`TrailExpansionError [${trailId}]: ${reason}`); this.name = "TrailExpansionError"; } } // ─── Public expander ───────────────────────────────────────────────────────── export interface ExpandOpts { trail: Trail; params: Record; chain: ChainName; vault: `0x${string}`; catalog: Catalog; /** Required when the trail (or any nested sub-trail) contains `subtrail` steps. */ library?: TrailLibrary; /** Strategist EOA — substituted for `$strategist` references. */ strategist?: `0x${string}`; /** Max recursion depth for sub-trail nesting. Default 4 (deep enough for realistic strategies). */ maxSubtrailDepth?: number; } export function expandTrail(opts: ExpandOpts): ExecutionPlan { const warnings: string[] = []; const params = validateAndDefaultParams(opts.trail, opts.params); if (!opts.trail.chains.includes(opts.chain)) { throw new TrailExpansionError(opts.trail.id, `chain "${opts.chain}" not declared in trail.chains (${opts.trail.chains.join(", ")})`); } const ctx: ExpandContext = { params, vault: opts.vault, strategist: opts.strategist, chain: opts.chain, catalog: opts.catalog, library: opts.library, warnings, /** stepOutputs holds named outputs from automation steps (only populated when the runner * later executes them — at expansion time we record them as unresolved markers). */ stepOutputs: new Map(), depth: 0, maxDepth: opts.maxSubtrailDepth ?? 4, }; const steps = expandSteps(opts.trail, ctx); let flashloanStepIndex: number | undefined; if (opts.trail.execution.mode === "atomic") { // Atomic mode = single tx via the Manager. TWO sub-shapes: // 1. Flashloan-bracketed: explicit flashloanStep set, OR step[0] is naturally a flashloan // leaf. The downstream byte-encoder bundles all subsequent leaves as the callback payload. // 2. Plain batch: all leaves go into ONE manage() call, no bracket. flashloanStepIndex stays // undefined; the encoder emits a normal multi-leaf manage call. if (opts.trail.execution.flashloanStep !== undefined) { flashloanStepIndex = opts.trail.execution.flashloanStep; const flStep = steps[flashloanStepIndex]; if (!flStep || flStep.kind !== "leaf" || flStep.entry.action !== "flashloan") { throw new TrailExpansionError( opts.trail.id, `atomic mode with flashloanStep=${flashloanStepIndex} requires that step to be a "flashloan" leaf; got ${flStep ? flStep.kind : ""}`, ); } } else if (steps[0]?.kind === "leaf" && steps[0].entry.action === "flashloan") { // Auto-detect — if the first leaf is a flashloan, treat as flashloan-bracketed. flashloanStepIndex = 0; } // Either sub-shape: every step in `steps` must be a leaf — automation and api steps can't // run inside an EVM call frame. for (let i = 0; i < steps.length; i++) { if (steps[i].kind !== "leaf") { throw new TrailExpansionError( opts.trail.id, `atomic mode disallows non-leaf steps; step[${i}].kind=${steps[i].kind}. Use mode="sequential" for trails that mix on-chain leaves with off-chain steps.`, ); } } } return { trailId: opts.trail.id, trailName: opts.trail.name, chain: opts.chain, vault: opts.vault, mode: opts.trail.execution.mode, steps, flashloanStepIndex, resolvedParams: params, warnings, }; } // ─── Internals ─────────────────────────────────────────────────────────────── interface ExpandContext { params: Record; vault: `0x${string}`; strategist?: `0x${string}`; chain: ChainName; catalog: Catalog; library?: TrailLibrary; warnings: string[]; stepOutputs: Map>; depth: number; maxDepth: number; } function expandSteps(trail: Trail, ctx: ExpandContext): ResolvedStep[] { if (ctx.depth > ctx.maxDepth) { throw new TrailExpansionError(trail.id, `subtrail nesting exceeded maxDepth=${ctx.maxDepth} (cycle? overly-deep composition?)`); } const out: ResolvedStep[] = []; for (const [idx, step] of trail.execution.steps.entries()) { expandOneStep(step, idx, trail, ctx, out); } return out; } function expandOneStep( step: TrailStep, idx: number, trail: Trail, ctx: ExpandContext, out: ResolvedStep[], ): void { switch (step.kind) { case "leaf": { const args = resolveValue(step.args, ctx) as Record; const entry = ctx.catalog.resolve({ protocol: step.protocol, action: step.action, chain: ctx.chain, args, }); if (!entry) { throw new TrailExpansionError( trail.id, `step[${idx}] (${step.protocol}.${step.action}) — no catalog entry for chain=${ctx.chain}, args=${JSON.stringify(args)}. ` + `Either the vault doesn't have this leaf enabled, or the catalog doesn't know about it. ` + `Hint: surface this gap to the strategist via the "Request new policy" flow.`, ); } out.push({ kind: "leaf", id: step.id, description: step.description, entry, args }); break; } case "api": { const params = resolveValue(step.params, ctx) as Record; out.push({ kind: "api", id: step.id, description: step.description, venue: step.venue, method: step.method, params }); break; } case "automation": { const inputs = resolveValue(step.inputs, ctx) as Record; if (step.id) { // Pre-register output binding markers so later steps that reference `$id.output` see a // recognisable placeholder. The runner will replace these with real values at run time. const placeholders: Record = {}; for (const o of step.outputs) placeholders[o] = { __unresolvedAutomationOutput: `${step.id}.${o}` }; ctx.stepOutputs.set(step.id, placeholders); } out.push({ kind: "automation", id: step.id, description: step.description, scriptId: step.scriptId, inputs, outputs: step.outputs }); break; } case "subtrail": { if (!ctx.library) { throw new TrailExpansionError(trail.id, `step[${idx}] subtrail "${step.trailId}" but no TrailLibrary was provided to expandTrail()`); } const inner = ctx.library.get(step.trailId); if (!inner) { throw new TrailExpansionError(trail.id, `step[${idx}] subtrail "${step.trailId}" not found in library`); } // Build the inner ctx — its `params` come from the outer's args (after $-substitution). const innerParams = validateAndDefaultParams(inner, resolveValue(step.args, ctx) as Record); const innerCtx: ExpandContext = { ...ctx, params: innerParams, depth: ctx.depth + 1, // Inherit stepOutputs map so post-subtrail steps in the outer can reference the subtrail's // step ids (rare but possible — keeps the binding model uniform). }; const innerSteps = expandSteps(inner, innerCtx); out.push(...innerSteps); break; } } } /** * Validate `provided` against the trail's param spec, filling in defaults for absent optional * params. Throws on missing required params. Doesn't deeply type-check primitives (that's the * runner's job — the trail expander stays loose so partial automation outputs flow through). */ function validateAndDefaultParams(trail: Trail, provided: Record): Record { const out: Record = {}; const seen = new Set(); for (const p of trail.params) { seen.add(p.name); if (provided[p.name] !== undefined) { out[p.name] = provided[p.name]; } else if (p.required) { throw new TrailExpansionError(trail.id, `missing required param "${p.name}" (${p.type}) — ${p.description}`); } else if (p.default !== undefined) { out[p.name] = p.default; } } // Extra params are allowed but warned — callers that prep more than the trail uses are common // (e.g. UI form fields that match the union of related trails' params). for (const k of Object.keys(provided)) { if (!seen.has(k)) out[k] = provided[k]; // pass through (silently) — could surface as warning } return out; } /** * Walk a value and substitute $refs. Handles strings ("$paramName" / "$id.field" / "$vault" / * "$strategist"), plain objects, and arrays. Primitives and unrecognised refs pass through. */ function resolveValue(v: unknown, ctx: ExpandContext): unknown { if (typeof v === "string") { const ref = exprRef(v); if (ref === null) return v; // Magic refs first. if (ref === "vault") return ctx.vault; if (ref === "strategist") return ctx.strategist ?? null; // Step-output ref: "$stepId.outputName" if (ref.includes(".")) { const [stepId, ...rest] = ref.split("."); const key = rest.join("."); const bindings = ctx.stepOutputs.get(stepId); if (bindings && key in bindings) return bindings[key]; // Unrecognised step-output: leave a marker so the runner can resolve it dynamically. return { __unresolvedStepOutput: `${stepId}.${key}` }; } // Param ref. if (ref in ctx.params) return ctx.params[ref]; ctx.warnings.push(`unresolved reference "$${ref}" — neither a magic ref, a step output, nor a known param`); return v; } if (Array.isArray(v)) return v.map((x) => resolveValue(x, ctx)); if (v && typeof v === "object") { const out: Record = {}; for (const [k, val] of Object.entries(v as Record)) out[k] = resolveValue(val, ctx); return out; } return v; }