Big changes and fixes. Uses action history. Improve role selection. Remove unused logs
This commit is contained in:
376
src/utils/templates.ts
Normal file
376
src/utils/templates.ts
Normal file
@@ -0,0 +1,376 @@
|
||||
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>;
|
||||
}
|
||||
Reference in New Issue
Block a user