import type { XOTemplate } from "@xo-cash/types"; /** * List of helper functions to make templates easy to develop with. * * Most of these are centered around the fact that the templates are very disjointed and sporadic in where the information lies. * * I.e. required variables ** names ** are stored in actions.roles.requirements.variables, but the variable definitions are stored in the template.variables object. * so to make a UI out of that, you first need to iterate over the actions.roles.requirements.variables and then lookup the variable definition in the template.variables object. * this is a pain, so these functions are here to help. * * Simiarly for inputs, outputs, locking scripts, etc. The get referenced in the actions, but then the actual lookup of what is actually is becomes a pain. */ /** * Deepens a templates object by append the actual definitions for the objects as they are referenced in the template. * NOTE: Whether this is better as part of the template or not can be debated endlessly. * The decision for separating the defintions from where they are used is likely to reduce template size... * This could be fruitless though, as its easily compressible (gzip, msgpack, etc) will yield similar results to the separated approach. */ type ResolutionMode = "single" | "map"; interface ResolutionRule { /** * Dot-separated path pattern. * - `*` matches any key in an object. * - `[]` iterates items in an array. */ path: string; /** Root-level collection key to resolve references from. */ from: string; /** * - `single`: replaces a string reference with its definition. * - `map`: converts a `string[]` into a `Record`. */ mode: ResolutionMode; } /** * Rules are ordered by dependency so that each phase reads * collections already enriched by earlier phases. * * Dependency graph (leaf → root): * scripts (no deps) * variables / roles / data / constants (no deps) * lockingScripts ← scripts, variables * inputs ← scripts * outputs ← lockingScripts * transactions ← inputs, outputs * actions ← variables, transactions, data, roles * lockingScripts.roles.actions ← actions, roles, variables (2nd pass) * start ← actions, roles */ const RESOLUTION_RULES: ResolutionRule[] = [ // ── Phase 1: lockingScripts ← scripts, variables ────────── { path: "lockingScripts.*.lockingScript", from: "scripts", mode: "single", }, { path: "lockingScripts.*.unlockingScript", from: "scripts", mode: "single", }, { path: "lockingScripts.*.roles.*.state.secrets", from: "variables", mode: "map", }, { path: "lockingScripts.*.roles.*.state.variables", from: "variables", mode: "map", }, // ── Phase 2: inputs ← scripts ───────────────────────────── { path: "inputs.*.unlockingScript", from: "scripts", mode: "single" }, // ── Phase 3: outputs ← lockingScripts (now enriched) ────── { path: "outputs.*.lockscript", from: "lockingScripts", mode: "single" }, // ── Phase 4: transactions ← inputs, outputs ─────────────── { path: "transactions.*.inputs", from: "inputs", mode: "map" }, { path: "transactions.*.outputs", from: "outputs", mode: "map" }, // ── Phase 5: actions ← variables, transactions, data, roles { path: "actions.*.roles.*.requirements.variables", from: "variables", mode: "map", }, { path: "actions.*.roles.*.requirements.secrets", from: "variables", mode: "map", }, { path: "actions.*.roles.*.requirements.generate", from: "variables", mode: "map", }, { path: "actions.*.transaction", from: "transactions", mode: "single" }, { path: "actions.*.data", from: "data", mode: "map" }, { path: "actions.*.requirements.roles.[].role", from: "roles", mode: "single", }, // ── Phase 6: lockingScripts.roles.actions ← actions (now enriched), roles, variables { path: "lockingScripts.*.roles.*.actions.[].action", from: "actions", mode: "single", }, { path: "lockingScripts.*.roles.*.actions.[].role", from: "roles", mode: "single", }, { path: "lockingScripts.*.roles.*.actions.[].secrets", from: "variables", mode: "single", }, // ── Phase 7: start ← actions, roles ─────────────────────── { path: "start.[].action", from: "actions", mode: "single" }, { path: "start.[].role", from: "roles", mode: "single" }, ]; /** * Recursively walks `obj` following the path pattern described by `parts`, * and resolves the leaf reference(s) from `root[from]`. */ function applyRule( obj: unknown, root: Record, parts: string[], depth: number, from: string, mode: ResolutionMode, ): void { if (obj == null || typeof obj !== "object") return; const part = parts[depth]!; const isLast = depth === parts.length - 1; // ── Leaf: perform the resolution ────────────────────────── if (isLast) { const collection = root[from] as Record | undefined; const record = obj as Record; if (!collection || !(part in record)) return; const value = record[part]; if (mode === "single") { if (typeof value === "string" && value in collection) { record[part] = structuredClone(collection[value]); } } else { // "map" – convert string[] → Record if (Array.isArray(value)) { const resolved: Record = {}; for (const ref of value) { if (typeof ref === "string" && ref in collection) { resolved[ref] = structuredClone(collection[ref]); } } record[part] = resolved; } } return; } // ── Intermediate path segments ──────────────────────────── if (part === "*") { // Wildcard: iterate every key of the current object for (const key of Object.keys(obj as Record)) { applyRule( (obj as Record)[key], root, parts, depth + 1, from, mode, ); } } else if (part === "[]") { // Array wildcard: iterate every item if (Array.isArray(obj)) { for (const item of obj) { applyRule(item, root, parts, depth + 1, from, mode); } } } else { // Exact key: descend const next = (obj as Record)[part]; if (next !== undefined) { applyRule(next, root, parts, depth + 1, from, mode); } } } /** * Returns a deep clone of `template` with every string reference replaced * by the full definition it points to. * * References are resolved in dependency order so that embedded objects * themselves contain resolved (not string) references wherever possible. * * The only place resolution deliberately stops is at the circular edge * `lockingScripts → actions → transactions → outputs → lockingScripts`: * the lockingScript copies embedded inside output→transaction→action chains * will have their script/variable refs resolved but will *not* re-embed * actions (which would cause infinite nesting). */ export function resolveTemplateReferences( template: XOTemplate, ): ResolvedXOTemplate { const resolved = structuredClone(template); for (const rule of RESOLUTION_RULES) { applyRule( resolved, resolved, rule.path.split("."), 0, rule.from, rule.mode, ); } return resolved as unknown as ResolvedXOTemplate; } // ─── Base definition types (inferred from your template) ───────── // Adjust these to match your actual @xo-cash/types definitions. interface VariableDefinition { name: string; description: string; type: string; hint?: string; } interface RoleDefinition { name: string; description: string; icon: string; } interface ScriptDefinition { // scripts are raw strings in your template script: string; } interface DataDefinition { value: string; type: string; hint?: string; } // ─── Resolved sub-types ────────────────────────────────────────── interface ResolvedActionRoleRequirements { variables?: Record; secrets?: Record; generate?: Record; } interface ResolvedActionRole { name?: string; description?: string; icon?: string; requirements?: ResolvedActionRoleRequirements; } interface ResolvedRoleSlot { role: RoleDefinition; // was string slots: { min: number; max: number | undefined }; } interface ResolvedOutputDefinition { name: string; description: string; icon: string; lockscript: ResolvedLockingScriptDefinition; // was string valueSatoshis?: string | null; token?: unknown; } interface ResolvedInputDefinition { name: string; description: string; icon: string; unlockingScript: string; // resolved from scripts (string → string) token?: unknown; omitChangeAmounts?: unknown; } interface ResolvedTransactionDefinition { name: string; description: string; icon?: string; roles?: Record; inputs: Record; // was string[] outputs: Record; // was string[] version: number; locktime: number; composable?: boolean; } interface ResolvedActionDefinition { name: string; description: string; icon: string; roles: Record; requirements: { roles: ResolvedRoleSlot[]; }; transaction?: ResolvedTransactionDefinition; // was string data?: Record; // was string[] condition?: string; } interface ResolvedLockingScriptRoleAction { action: ResolvedActionDefinition; // was string role: RoleDefinition; // was string secrets: VariableDefinition; // was string } interface ResolvedLockingScriptRole { state?: { variables: Record; // was string[] secrets: Record; // was string[] }; actions?: ResolvedLockingScriptRoleAction[]; selectable?: boolean; privacy?: boolean; } interface ResolvedLockingScriptDefinition { name: string; description: string; icon: string; lockingType: string; lockingScript: string; // resolved from scripts (string → string) unlockingScript?: string; roles?: Record; actions?: unknown[]; state?: unknown[]; secrets?: unknown[]; balance?: boolean; selectable?: boolean; privacy?: boolean; } interface ResolvedStartEntry { action: ResolvedActionDefinition; // was string role: RoleDefinition; // was string } // ─── The full resolved template ────────────────────────────────── interface ResolvedXOTemplate extends Omit< XOTemplate, "actions" | "transactions" | "outputs" | "inputs" | "lockingScripts" | "start" > { start: ResolvedStartEntry[]; actions: Record; transactions: Record; outputs: Record; inputs: Record; lockingScripts: Record; }