378 lines
12 KiB
TypeScript
378 lines
12 KiB
TypeScript
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>;
|
||
}
|