Files
xo-cli/src/utils/templates.ts

378 lines
12 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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<string, definition>`.
*/
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<string, any>,
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<string, unknown> | undefined;
const record = obj as Record<string, unknown>;
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<string, definition>
if (Array.isArray(value)) {
const resolved: Record<string, unknown> = {};
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<string, unknown>)) {
applyRule(
(obj as Record<string, unknown>)[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<string, unknown>)[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<string, VariableDefinition>;
secrets?: Record<string, VariableDefinition>;
generate?: Record<string, VariableDefinition>;
}
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<string, unknown>;
inputs: Record<string, ResolvedInputDefinition>; // was string[]
outputs: Record<string, ResolvedOutputDefinition>; // was string[]
version: number;
locktime: number;
composable?: boolean;
}
interface ResolvedActionDefinition {
name: string;
description: string;
icon: string;
roles: Record<string, ResolvedActionRole>;
requirements: {
roles: ResolvedRoleSlot[];
};
transaction?: ResolvedTransactionDefinition; // was string
data?: Record<string, DataDefinition>; // was string[]
condition?: string;
}
interface ResolvedLockingScriptRoleAction {
action: ResolvedActionDefinition; // was string
role: RoleDefinition; // was string
secrets: VariableDefinition; // was string
}
interface ResolvedLockingScriptRole {
state?: {
variables: Record<string, VariableDefinition>; // was string[]
secrets: Record<string, VariableDefinition>; // 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<string, ResolvedLockingScriptRole>;
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<string, ResolvedActionDefinition>;
transactions: Record<string, ResolvedTransactionDefinition>;
outputs: Record<string, ResolvedOutputDefinition>;
inputs: Record<string, ResolvedInputDefinition>;
lockingScripts: Record<string, ResolvedLockingScriptDefinition>;
}