/** * AutomationRegistry — server-side compute scripts that Trails reference by id. * * Automation steps run on the strategist's server (not in the EVM, not in the browser). They: * • Take resolved inputs from the Trail's previous steps. * • Optionally call out to chain via an injected `LucidlyClient` for live state. * • Return named outputs that downstream steps in the same Trail can reference via * `$stepId.outputName`. * * The SDK ships a starter registry with the two scripts the W1 reference trails need: * • `split_capital_dn` — given total capital + target leverage + asset, output the * (spotLegAmount, shortLegMargin, idleMargin) split for a * delta-neutral position. * • `compute_morpho_leverage` — given target LTV bps + collateral price + initial deposit, * output the flashloan amount for a leveraged Morpho position. * * Users can register their own scripts: * const registry = new AutomationRegistry(); * registry.register("my_custom_split", async (inputs, ctx) => { … return { foo: …, bar: … }; }); * * The runner (W4) is responsible for calling these in the right order with the right inputs; * the registry is just a typed dispatch surface. */ import type { LucidlyClient } from "./client.js"; export interface AutomationContext { /** Optional chain client for scripts that need live state (Morpho oracle reads, etc.). */ client?: LucidlyClient; /** Vault the trail is running against — useful for vault-specific overrides. */ vault?: `0x${string}`; /** Strategist EOA, in case the script needs to authorize sub-calls. */ strategist?: `0x${string}`; } export type AutomationScript = ( inputs: Record, ctx: AutomationContext, ) => Promise>; export interface AutomationScriptDescriptor { id: string; description: string; /** Names of inputs the script expects. Used for documentation + validation. */ inputs: { name: string; description: string }[]; /** Names of outputs the script produces. */ outputs: { name: string; description: string }[]; fn: AutomationScript; } export class AutomationRegistry { private readonly scripts: Map = new Map(); constructor(initial: AutomationScriptDescriptor[] = []) { for (const s of initial) this.register(s); } register(descriptor: AutomationScriptDescriptor): void { if (this.scripts.has(descriptor.id)) { throw new Error(`AutomationRegistry: script id "${descriptor.id}" already registered`); } this.scripts.set(descriptor.id, descriptor); } has(id: string): boolean { return this.scripts.has(id); } get(id: string): AutomationScriptDescriptor | undefined { return this.scripts.get(id); } all(): AutomationScriptDescriptor[] { return Array.from(this.scripts.values()); } /** Run a registered script. Throws if id is unknown. */ async run(id: string, inputs: Record, ctx: AutomationContext): Promise> { const s = this.scripts.get(id); if (!s) throw new Error(`AutomationRegistry: no script with id "${id}"`); return s.fn(inputs, ctx); } } // ─── Starter scripts ───────────────────────────────────────────────────────── /** * Split total capital for a delta-neutral position into: * • spotLegAmount — used for spot exposure (e.g. buy PT, mint LST) * • shortLegMargin — collateral posted on the short-leg venue (HyperLiquid, etc.) * • idleMargin — buffer for liquidation safety * * Splits are dictated by `targetLeverage` (1e4 bps) and `marginBufferBps` — both are inputs. * No chain reads needed; pure arithmetic. * * Convention: * notional = spotLegAmount ← the spot position size * hl_size = notional ← short leg matches spot 1:1 (delta neutral) * hl_margin = notional / leverage ← collateral required for that short at target leverage * buffer = hl_margin * marginBufferBps / 1e4 * spotLegAmount + hl_margin + buffer = totalCapital * * Solving for spotLegAmount given leverage L and buffer multiplier B (= bufferBps/1e4): * spot + spot/L * (1 + B) = total * spot * (1 + (1+B)/L) = total * spot = total * L / (L + 1 + B) * * Units: all amounts are integer base units (no decimals applied here). */ const split_capital_dn: AutomationScriptDescriptor = { id: "split_capital_dn", description: "Compute the spot/margin/buffer split for a delta-neutral position. Pure arithmetic — needs " + "totalCapital (uint, base units), targetLeverage (e.g. 3.0 → '3' or '30000' for 1e4-scaled), and " + "marginBufferBps (e.g. '500' = 5% buffer). Outputs base-unit integers, suitable for direct use in " + "downstream leaf steps.", inputs: [ { name: "totalCapital", description: "Total capital to deploy, in base units of the underlying asset (string-encoded bigint)." }, { name: "targetLeverage", description: "Target leverage. Either decimal-as-string ('3.0' = 3x) or basis points ('30000' = 3x). Auto-detected." }, { name: "marginBufferBps", description: "Margin buffer above min-required, in bps. E.g. '500' = +5%. Optional; default '500'." }, ], outputs: [ { name: "spotLegAmount", description: "Capital allocated to spot leg (base units)." }, { name: "shortLegMargin", description: "Capital allocated as collateral on the short-leg venue (base units)." }, { name: "idleMargin", description: "Buffer above minimum margin (base units)." }, { name: "notional", description: "Spot+short notional (= spotLegAmount). Used by HL step for order size." }, ], fn: async (inputs) => { const total = asBigInt(inputs.totalCapital, "totalCapital"); const lev = parseLeverage(inputs.targetLeverage); const bufBps = asBigInt(inputs.marginBufferBps ?? "500", "marginBufferBps"); // All math in 1e4-scaled bps to avoid floating-point. // spot = total * lev / (lev + 10000 + buf) const num = total * lev; const den = lev + 10_000n + bufBps; if (den === 0n) throw new Error("split_capital_dn: degenerate denominator (leverage + buffer = 0)"); const spot = num / den; const remaining = total - spot; // Of the remaining: shortLegMargin = remaining * 10000 / (10000 + bufBps), idleMargin = the rest. const margin = (remaining * 10_000n) / (10_000n + bufBps); const idle = remaining - margin; return { spotLegAmount: spot.toString(), shortLegMargin: margin.toString(), idleMargin: idle.toString(), notional: spot.toString(), }; }, }; /** * Given a Morpho market and a target loan-to-value (LTV) for a leveraged position, compute the * flashloan amount: how much borrowedAsset to flashloan so that (after swap-to-collateral and * supply) the final position has the requested LTV. * * No chain reads (the runner is expected to have pre-resolved oracle prices and pass them in). * * Inputs: * initialDeposit — strategist's own capital (base units of collateralAsset) * targetLtvBps — desired LTV after position is open, in bps (e.g. "8500" = 85%) * marketLltvBps — market's max LLTV, in bps (must satisfy targetLtv < marketLltv) * collateralPrice1e18 — price of 1 unit collateral in 1e18-scaled borrowedAsset (oracle output) * slippageBps — assumed swap slippage in bps (e.g. "30" = 0.3%). Optional, default '30'. * * Math: * Let X = flashloan amount (in borrowedAsset units). * After swap (with slippage): collateral_added = X * (10000 - slippage) / 10000 / price * Final collateral_value = initialDeposit_value + collateral_added * price * Final debt = X (the flashloan is repaid by the borrow) * Required: debt / collateral_value = targetLtv / 10000 * * Solving for X: * X / (init * price + X * (10000 - slip) / 10000) = targetLtv / 10000 * X * 10000 = targetLtv * (init * price + X * (10000-slip)/10000) * X * 10000 - X * targetLtv * (10000-slip)/10000 = targetLtv * init * price * X * (10000 - targetLtv * (10000-slip)/10000) = targetLtv * init * price * X = targetLtv * init * price / (10000 - targetLtv * (10000-slip)/10000) * * Result is in base units of borrowedAsset. */ const compute_morpho_leverage: AutomationScriptDescriptor = { id: "compute_morpho_leverage", description: "Given an initial deposit, a target LTV, and the market's collateral price, compute the flashloan " + "amount of borrowedAsset such that after swap-and-supply the position lands at the target LTV. " + "Pure arithmetic, no chain reads. Caller resolves oracle prices upstream.", inputs: [ { name: "initialDeposit", description: "Strategist's own capital, in base units of collateralAsset (bigint string)." }, { name: "targetLtvBps", description: "Target LTV in bps. Must be < market LLTV." }, { name: "marketLltvBps", description: "Market's LLTV in bps. Used for safety check only." }, { name: "collateralPrice1e18", description: "Collateral price in 1e18-scaled borrowedAsset units (Morpho oracle convention)." }, { name: "slippageBps", description: "Assumed swap slippage in bps. Optional, default '30' (0.3%)." }, ], outputs: [ { name: "flashAmount", description: "Flashloan size in borrowedAsset base units." }, { name: "expectedCollateral", description: "Expected collateral added after swap, in collateralAsset base units." }, { name: "estimatedLtvBps", description: "LTV the position will land at, in bps (within slippage tolerance)." }, ], fn: async (inputs) => { const init = asBigInt(inputs.initialDeposit, "initialDeposit"); const ltv = asBigInt(inputs.targetLtvBps, "targetLtvBps"); const lltv = asBigInt(inputs.marketLltvBps, "marketLltvBps"); const price = asBigInt(inputs.collateralPrice1e18, "collateralPrice1e18"); const slip = asBigInt(inputs.slippageBps ?? "30", "slippageBps"); if (ltv >= lltv) throw new Error(`compute_morpho_leverage: targetLtvBps (${ltv}) must be < marketLltvBps (${lltv})`); // We use the 1e18-scaled price as given; all math in bigint via a *1e18 normalisation. // X = targetLtv * init * price / (10000 - targetLtv*(10000-slip)/10000) // Multiplied out to avoid intermediate truncation: // numerator = targetLtv * init * price (units: 1e18-scaled borrowedAsset) // denominator = (10000^2 - targetLtv * (10000-slip)) / 10000 (units: bps) // To keep precision, scale denominator to bps^2: const num = ltv * init * price; // 1e18-scaled const denBps2 = 10_000n * 10_000n - ltv * (10_000n - slip); if (denBps2 <= 0n) throw new Error("compute_morpho_leverage: degenerate (targetLtv too aggressive given slippage)"); const flashAmount = (num * 10_000n) / (denBps2 * (10n ** 18n)); // expected collateral added in collateralAsset units = flashAmount * (1 - slip) / price const expectedCollateral = (flashAmount * (10_000n - slip) * (10n ** 18n)) / (10_000n * price); const finalCollateral = init + expectedCollateral; const finalCollateralValue = (finalCollateral * price) / 10n ** 18n; const estimatedLtvBps = finalCollateralValue === 0n ? 0n : (flashAmount * 10_000n) / finalCollateralValue; return { flashAmount: flashAmount.toString(), expectedCollateral: expectedCollateral.toString(), estimatedLtvBps: estimatedLtvBps.toString(), }; }, }; // ─── Helpers ───────────────────────────────────────────────────────────────── function asBigInt(v: unknown, label: string): bigint { if (typeof v === "bigint") return v; if (typeof v === "number" && Number.isFinite(v) && Number.isInteger(v)) return BigInt(v); if (typeof v === "string" && /^-?\d+$/.test(v)) return BigInt(v); throw new Error(`expected integer-as-string for "${label}", got ${typeof v}: ${JSON.stringify(v)}`); } /** Accept "3", "3.0", "30000" (1e4-scaled). Returns leverage in 1e4-scaled bps. */ function parseLeverage(v: unknown): bigint { if (typeof v === "string" && /^\d+(\.\d+)?$/.test(v)) { if (v.includes(".")) { // "3.0" / "2.5" → multiply by 1e4 const [whole, frac = ""] = v.split("."); const fracPadded = (frac + "0000").slice(0, 4); // pad to 4 digits (bps precision) return BigInt(whole) * 10_000n + BigInt(fracPadded); } // Plain "3" — could be 3x or 3 bps. Heuristic: if < 1000, treat as decimal (3x); else as bps. const n = BigInt(v); return n < 1000n ? n * 10_000n : n; } if (typeof v === "number" && v > 0) { // Convert number → 1e4 scale. Lose precision past 4 fractional digits. return BigInt(Math.round(v * 10_000)); } throw new Error(`unparseable targetLeverage: ${JSON.stringify(v)}`); } // ─── Default registry — exported for convenience ───────────────────────────── export function defaultAutomationRegistry(): AutomationRegistry { return new AutomationRegistry([split_capital_dn, compute_morpho_leverage]); } // Direct exports so tests can call the scripts without wrapping. export const STARTER_AUTOMATION_SCRIPTS: AutomationScriptDescriptor[] = [ split_capital_dn, compute_morpho_leverage, ];