/** * Trail schema — runnable strategy templates with parameter slots. * * A Trail is the unit a strategist thinks in: "open a Morpho leveraged position on syrupUSDC," * "delta-neutral via Pendle PT + HyperLiquid short," "unwind position X." It sits between two * other abstractions: * * Policy template (raw Merkle leaf — a single allowed call) * └── Trail (parameterized + composable — multiple steps, possibly atomic via flashloan callback) * └── Strategy (multi-trail composition assembled in the canvas — W3) * * Three step kinds reflect what strategies actually do: * • `leaf` — on-chain Merkle-gated call (the only kind the Manager actually executes) * • `api` — off-chain venue API call (HyperLiquid set_leverage, etc.) — needs credentials * • `automation` — off-chain compute (capital split, leverage sizing) — runs on the strategist's server * • `subtrail` — compose another trail inline (e.g. dn.pendle_hyperliquid USES spot.swap_and_buy_pt) * * Three execution modes reflect how the steps run: * • `atomic` — all leaves bundled into ONE manage() call, flashloan-bracketed. First leaf is * the flashloan; everything after is the callback payload that the flashloan * adapter executes inside the bracket. Final leaf typically repays the loan. * • `sequential` — multi-tx ordered. Each step's outputs flow into the next step's args via * $stepId.outputName substitution. Used by DN-style trails where the spot leg * confirms before the short leg sizes itself. * • `parallel` — independent legs that can fire concurrently (rare; usually `sequential`). * * Expression substitution: any string value starting with `$` is a reference resolved at * expansion time. Three reference forms: * • `$paramName` — from the Trail's `params` block * • `$stepId.outputName` — from a prior automation step's named outputs * • `$vault` — magic ref to the vault address being executed against * • `$strategist` — magic ref to the strategist EOA (config.strategist on the vault) */ import { z } from "zod"; // ─── Enums and basic types ────────────────────────────────────────────────── export const ChainName = z.enum([ "ethereum", "base", "arbitrum", "optimism", "hyperevm", "plasma", "monad", "katana", ]); export type ChainName = z.infer; export const TrailParamType = z.enum([ "address", // 0x-prefixed EVM address "uint256", // bigint as string in JSON "uint16", // small unsigned (e.g. bps) "int256", // signed bigint "string", // raw string "boolean", // true/false "morpho_market", // 5-tuple: { loanToken, collateralToken, oracle, irm, lltv } "trail_ref", // another trail id, for sub-trail params "asset_symbol", // resolved against ChainConfig.tokens (e.g. "USDC") ]); export type TrailParamType = z.infer; // ─── Expression values — substituted at expansion time ────────────────────── // // Anywhere a Trail step takes an `arg`, it can be a literal primitive OR a `$reference`. We don't // validate references' types statically — the expander does that at runtime against the actual // param/step-output types. Keeping the JSON schema loose here means strategists can author Trails // without fighting the schema for every reference. const ExprPrimitive = z.union([z.string(), z.number(), z.boolean(), z.null()]); const ExprValue: z.ZodType = z.lazy(() => z.union([ExprPrimitive, z.record(z.string(), ExprValue), z.array(ExprValue)]), ); export type ExprValue = string | number | boolean | null | { [k: string]: ExprValue } | ExprValue[]; // ─── Param descriptors ─────────────────────────────────────────────────────── export const TrailParam = z.object({ name: z.string().min(1).max(64).regex(/^[a-zA-Z][a-zA-Z0-9_]*$/, "param names must be JS-identifier-like"), type: TrailParamType, required: z.boolean(), description: z.string(), /** Optional default — only meaningful when `required: false`. */ default: ExprValue.optional(), /** Free-form constraint hint for the LLM (e.g. "between 1.5e18 and 5e18", "USD-stable asset"). */ hint: z.string().optional(), }); export type TrailParam = z.infer; // ─── Step kinds ────────────────────────────────────────────────────────────── const StepBase = z.object({ /** Optional step id — required if the step has outputs that later steps reference. */ id: z.string().min(1).optional(), /** Human-readable description for traces + UI. */ description: z.string().optional(), }); /** * On-chain Merkle-gated call. `(protocol, action, args)` are an ABSTRACT signature — the catalog * adapter at expansion time looks up the matching concrete leaf (with target/selector/decoder * filled in) for the specific vault + chain. * * `action` is a convention-driven verb describing what the call does at a semantic level: * `flashloan`, `approve`, `swap`, `supply`, `borrow`, `supply_collateral`, `repay`, `withdraw`, * `buy_pt`, `place_limit_order`, `cancel_order`, `set_position`, etc. The catalog adapter has the * `(protocol, action)` → on-chain (target, selector) mapping per chain. */ export const TrailLeafStep = StepBase.extend({ kind: z.literal("leaf"), protocol: z.string().min(1), action: z.string().min(1), args: z.record(z.string(), ExprValue).default({}), }); export type TrailLeafStep = z.infer; /** * Off-chain venue API call. Examples: HyperLiquid `set_leverage`, Binance `transfer_isolated`. * Doesn't go through the Manager — runs on the strategist's server with their credentials. * Credentials come from a `CredentialsProvider` keyed by (vault, venue). */ export const TrailApiStep = StepBase.extend({ kind: z.literal("api"), venue: z.string().min(1), // "hyperliquid", "binance" (future) method: z.string().min(1), // "set_leverage", "cancel_open_orders" params: z.record(z.string(), ExprValue).default({}), }); export type TrailApiStep = z.infer; /** * Off-chain compute step. Runs a registered automation script on the strategist's server. May * read chain state if the script needs it (the runner injects a LucidlyClient). Produces named * outputs accessible to later steps via `$stepId.outputName`. */ export const TrailAutomationStep = StepBase.extend({ kind: z.literal("automation"), scriptId: z.string().min(1), // must be registered in AutomationRegistry inputs: z.record(z.string(), ExprValue).default({}), /** Names of values the script produces (validated at registration time, not here). */ outputs: z.array(z.string()).default([]), }); export type TrailAutomationStep = z.infer; /** * Inline composition: call another Trail. Args from the outer trail flow into the inner trail's * params. Subtrail outputs are accessible via `$stepId.outputName`. * * Example: dn.pendle_hyperliquid composes spot.swap_and_buy_pt as its spot leg. */ export const TrailSubtrailStep = StepBase.extend({ kind: z.literal("subtrail"), trailId: z.string().min(1), args: z.record(z.string(), ExprValue).default({}), }); export type TrailSubtrailStep = z.infer; export const TrailStep = z.discriminatedUnion("kind", [ TrailLeafStep, TrailApiStep, TrailAutomationStep, TrailSubtrailStep, ]); export type TrailStep = z.infer; // ─── Execution block ───────────────────────────────────────────────────────── export const TrailExecution = z.object({ mode: z.enum(["atomic", "sequential", "parallel"]), steps: z.array(TrailStep).min(1, "execution.steps must have at least one step"), /** * For atomic mode: the index (into steps[]) of the flashloan-initiation step. Defaults to 0 * (first step). All subsequent steps are bundled into the flashloan callback payload. Set * explicitly only if there's a pre-flashloan setup step (rare). */ flashloanStep: z.number().int().min(0).optional(), }); export type TrailExecution = z.infer; // ─── The Trail itself ──────────────────────────────────────────────────────── export const Trail = z.object({ /** Stable id, dotted namespace. Convention: trail... */ id: z.string().regex(/^trail\.[a-z][a-z0-9_]*(\.[a-z][a-z0-9_]*)*$/, "id must match trail.namespace.name"), /** Schema version — bump when breaking changes land. */ schemaVersion: z.literal("0.1.0"), /** Human display name. */ name: z.string().min(1).max(80), /** One-sentence summary. */ summary: z.string().min(1).max(280), /** Optional family slug — links to STRATEGY_LIBRARY family classifications. */ family: z.string().optional(), /** Chains this trail can run on. Empty = chain-agnostic (rare). */ chains: z.array(ChainName).min(1), /** Parameter slots the caller fills in. */ params: z.array(TrailParam).default([]), /** Execution plan. */ execution: TrailExecution, /** Plain-language preconditions the strategist should verify before running. */ preconditions: z.array(z.string()).optional(), /** Plain-language risks (depeg, oracle staleness, etc.) — surfaced in UI confirmation step. */ risks: z.array(z.string()).optional(), /** * Documentation block for the LLM. The model uses this to pick which trail matches a user's * description. Be specific about WHEN to use this trail and WHAT it does. Plain English. * Keep under 600 chars to bound token cost in agent loops. */ llm_doc: z.string().min(20).max(600), }); export type Trail = z.infer; // ─── Helpers ───────────────────────────────────────────────────────────────── /** * Detect whether a value is an expression reference (starts with "$" and isn't escaped). * Returns the bare reference name (without "$") if so, else null. */ export function exprRef(v: unknown): string | null { if (typeof v !== "string") return null; if (!v.startsWith("$")) return null; if (v.startsWith("$$")) return null; // escape: $$foo → literal $foo return v.slice(1); } /** * Parse a JSON object into a validated Trail. Throws a zod error with field paths on invalid * input — pleasant authoring experience even before the file lands in a library. */ export function parseTrail(json: unknown): Trail { return Trail.parse(json); } /** Safe variant — returns { success, data | error }. */ export function safeParseTrail(json: unknown): { success: true; data: Trail } | { success: false; error: z.ZodError } { const r = Trail.safeParse(json); if (r.success) return { success: true, data: r.data }; return { success: false, error: r.error }; }