Refactor, Added documentation, moved from avj-cli to avj, update script and added tests

This commit is contained in:
Kuldeep
2026-04-19 09:33:48 +00:00
parent cfd4c6a43b
commit 013583e7f8
26 changed files with 4216 additions and 9970 deletions

View File

@@ -1,15 +0,0 @@
/* eslint-disable max-classes-per-file */
export class InvalidTemplateError extends Error {
constructor(errorMessage: string) {
const message = `Invalid template: ${errorMessage}`;
super(message);
}
}
export class FailedToParseTemplateError extends Error {
constructor(errorMessage: string) {
const message = `Failed to parse template: ${errorMessage}`;
super(message);
}
}

76
source/extended-json.ts Normal file
View File

@@ -0,0 +1,76 @@
import { binToHex, hexToBin } from '@bitauth/libauth';
/**
* Matches a bigint encoded in Extended JSON format: `<bigint: 123n>`.
*/
const EXTENDED_JSON_BIGINT_PATTERN = /^<bigint: (?<bigint>[+-]?[0-9]+)n>$/u;
/**
* Matches a Uint8Array encoded in Extended JSON format: `<uint8array: abcd>`.
*/
const EXTENDED_JSON_UINT8ARRAY_PATTERN = /^<uint8array: (?<hex>[0-9a-f]*)>$/u;
/**
* The JSON replacer that encodes `bigint` and `Uint8Array` values in Extended JSON format,
* compatible with the format expected by `extendedJsonReviver`.
*
* - BigInts are encoded as `<bigint: 123n>`.
* - Uint8Arrays are encoded as `<uint8array: abcd>`.
* All other values pass through unchanged and if any incompatible type is encountered, an error is thrown.
*
* Note: To use this function, pass it as the second argument to `JSON.stringify` when serializing data.
*
* Note to developers: Libauth's `stringify` is the replacer. It also serializes functions and symbols,
* which we do not support. Passing it would let templates include those values, but revival would then fail.
* This module provides a dedicated replacer and reviver so serialization and deserialization stay aligned.
*
* @param _propertyKey The property key being serialized, required by the `JSON.stringify` replacer but not used here.
* @param value The value to encode or pass through unchanged.
* @returns The encoded string
*/
export const extendedJsonReplacer = (_propertyKey: string, value: unknown): unknown => {
if (value instanceof Uint8Array) {
return `<uint8array: ${binToHex(value)}>`;
}
if (typeof value === 'bigint') {
return `<bigint: ${value.toString()}n>`;
}
return value;
};
/**
* The JSON reviver that reconstructs `bigint` and `Uint8Array` values encoded by `extendedJsonReplacer`.
*
* Note: To use this function, pass it as the second argument to `JSON.parse` when deserializing data.
*
* @param _propertyKey The property key being deserialized, required by the `JSON.parse` reviver but not used here.
* @param value The value to reconstruct or pass through unchanged.
* @returns The reconstructed value
*/
export const extendedJsonReviver = (_propertyKey: string, value: unknown): unknown => {
// If the value is not a string, return the original value
if (typeof value !== 'string') {
return value;
}
// Match the bigint pattern
const bigintPatternMatch = value.match(EXTENDED_JSON_BIGINT_PATTERN);
// If the value matches the bigint pattern, return the reconstructed bigint
if (bigintPatternMatch) {
return BigInt(bigintPatternMatch.groups!.bigint);
}
// Match the Uint8Array pattern
const uint8arrayPatternMatch = value.match(EXTENDED_JSON_UINT8ARRAY_PATTERN);
// If the value matches the Uint8Array pattern, return the reconstructed Uint8Array
if (uint8arrayPatternMatch) {
return hexToBin(uint8arrayPatternMatch.groups!.hex);
}
// If the value does not match either pattern, return the original value
return value;
};

View File

@@ -1,5 +1,9 @@
export * from './errors.ts';
export * from './parser/ajv/ajv-utils.ts';
export * from './parser/xo-template.ts';
export * from './extended-json.ts';
export * from './script.ts';
export * from './templates.ts';
export * from './template/errors.ts';
export * from './template/identifier.ts';
export * from './template/parser.ts';
export * from './template/schemas.ts';
// Only exporting serializeTemplate as deserializeTemplate is only used internally and parseTemplate should be used instead.
export { serializeTemplate } from './template/serialization.ts';

View File

@@ -1,33 +0,0 @@
/**
* This code is inspired by and adapted from:
* https://github.com/bitauth/libauth
*/
export type AjvError<Keyword = string, Params = { [paramName: string]: number | string }> = {
keyword: Keyword;
instancePath: string;
schemaPath: string;
params: Params;
message: string;
};
export type XOAjvError =
| AjvError<'additionalProperties', { additionalProperty: string }>
| AjvError<'required', { missingProperty: string }>
| AjvError<'type', { type: string }>;
/**
* Note: these types cover only XO use cases; other `ajv` error types are
* possible using other settings.
*/
export type AjvValidator<T = unknown> = {
(
data: unknown,
dataCxt?: {
instancePath?: string;
parentData: unknown;
parentDataProperty: unknown;
rootData?: unknown;
},
): data is T;
errors?: XOAjvError[] | null;
};

View File

@@ -1,60 +0,0 @@
/**
* This code is inspired from https://github.com/bitauth/libauth
*/
import { lossyNormalize } from '@bitauth/libauth';
import type { AjvValidator, XOAjvError } from './ajv-types.ts';
import { FailedToParseTemplateError } from '../../errors.ts';
const errorsToDescription = (errors: XOAjvError[]): string => {
// TODO: translate instancePath and make errors more descriptive.
const descriptions = errors.map((error) => `${error.instancePath}: ${error.message}`).join(',');
return descriptions;
};
/**
* Given an untrusted JSON string or object, verify that
* the untrusted value is of the expected shape. Note, this method first
* normalizes all characters in the input using `Normalization Form KC`
* (Compatibility Decomposition, followed by Canonical Composition).
*
* @param {unknown} jsonOrObject - The JSON string or object to parse
* @returns {T} The parsed object
* @throws {FailedToParseTemplateError} If JSON parsing fails
*/
export const parseJson = <T>(jsonOrObject: unknown): T => {
try {
// If the JSON object is a string, use it directly.
const stringified = typeof jsonOrObject === 'string' ? jsonOrObject : JSON.stringify(jsonOrObject);
// Normalize the JSON string using `Normalization Form KC` (Compatibility Decomposition, followed by Canonical Composition).
const normalized = lossyNormalize(stringified);
// Parse the normalized JSON string into the expected type.
const parsed = JSON.parse(normalized);
// Return the parsed object.
return parsed;
} catch (error) {
// Error wrapping. If the JSON parsing fails, throw an error.
throw new FailedToParseTemplateError(error instanceof Error ? error.message : String(error));
}
};
/**
* Validates a parsed object against an AJV schema.
*
* @param parsed - The parsed object to validate
* @param validator - The AJV validator function
* @returns The validated object if valid, or an error message string if invalid
*/
export const validateSchema = <T>(parsed: unknown, validator: AjvValidator<T>): T | string => {
if (validator(parsed)) {
return parsed;
}
// If the object is invalid, return the error message.
return errorsToDescription(validator.errors ?? []);
};

View File

@@ -1,51 +0,0 @@
/**
* This code is inspired by and adapted from:
* https://github.com/bitauth/libauth
*/
import type { XOTemplate } from '@xo-cash/types';
import type { AjvValidator } from './ajv/ajv-types.ts';
import { parseJson, validateSchema } from './ajv/ajv-utils.ts';
import { InvalidTemplateError } from '../errors.ts';
import xoTemplateValidator from './ajv/validate-xo-template.js';
/**
* Safely parse and validate a XO template, returning either an
* error message as a string or a valid {@link XOTemplate}. The
* template may be provided either as an untrusted JSON string or as a
* pre-parsed object.
*
* This method validates both the structure and the contents of a template:
* - All properties and sub-properties are verified to be of the expected type.
* - The template contains no unknown properties.
* - The ID of each entity, script, and scenario is confirmed to be unique.
* - Script IDs referenced by entities and other scripts (via `unlocks`) are
* confirmed to exist.
* - The derivation paths of each HdKey are validated against each other.
*
* This method does not perform the following:
* - TODO: Validate the CashAssembly contents of scripts (by
* attempting compilation, evaluating {@link XOTemplateScriptTest}s,
* or testing scenario generation).
* - TODO: Perform search to verify references.
*
* @param {unknown} untrustedJsonOrObject - the JSON string or object to validate as a
* XO template
* @returns {XOTemplate} The parsed and validated template
* @throws {InvalidTemplateError} If the template is invalid
*/
export const parseTemplate = (untrustedJsonOrObject: unknown): XOTemplate => {
// Parse the JSON string or object into an unknown object.
const parsed = parseJson<unknown>(untrustedJsonOrObject);
// Validate the unknown object against the XO template schema.
const templateOrError = validateSchema(parsed, xoTemplateValidator as AjvValidator<XOTemplate>);
// If the template is invalid, throw an error.
if (typeof templateOrError === 'string') {
throw new InvalidTemplateError(templateOrError);
}
// Return the validated template.
return templateOrError;
};

View File

@@ -3,11 +3,15 @@ import { binToHex, sha256 } from '@bitauth/libauth';
/**
* Converts a script to a scriptHash.
* @param {Uint8Array} script - The script to convert.
* @returns {string} The scriptHash as a hexadecimal string.
* @returns {string} The scriptHash as a reversed hex string.
*/
export const scriptToScriptHash = (script: Uint8Array): string => {
// Hash the script.
const hash = sha256.hash(script);
// Reverse the hash. (Electrum style, reverse switches to little endian representation)
const reversed = hash.reverse();
// Convert the reversed hash to hex.
return binToHex(reversed);
};

64
source/template/errors.ts Normal file
View File

@@ -0,0 +1,64 @@
/* eslint-disable max-classes-per-file */
import type { $ZodIssue } from 'zod/v4/core';
/**
* Formats the Zod validation failures into a single string with one line each: "- <field>: <message>" and top level failures
* with no field path show as "(root)" for better readability.
*
* @param issues The Zod validation failures to format.
* @returns A human readable error string for better debugging.
*/
export const buildErrorDescription = (issues: $ZodIssue[]): string => {
// Initialize an empty array to store the formatted lines.
const lines: string[] = [];
// Iterate over the issues and format them into a string.
for (const issue of issues) {
// Get the issue path.
const issuePath = issue.path.length > 0 ? issue.path.join('.') : '(root)';
// The prefix that Zod adds to messages.
const messagePrefix = 'Invalid input: ';
// Remove the prefix for better readability.
const issueMessage = issue.message.startsWith(messagePrefix) ? issue.message.slice(messagePrefix.length) : issue.message;
// Add the formatted line to the array.
lines.push(`- ${issuePath}: ${issueMessage}`);
}
// Return the formatted string.
return `\n${lines.join('\n')}`;
};
/**
* Thrown when the provided template does not satisfy the XOTemplate schema.
*/
export class TemplateInvalidError extends Error {
constructor(details: string) {
const message = `Template invalid: ${details}`;
super(message);
this.name = 'TemplateInvalidError';
}
}
/**
* Thrown when a string passed to `deserializeTemplate` cannot be parsed as JSON.
*/
export class TemplateJsonMalformedError extends Error {
constructor(reason: string) {
super(`Template JSON malformed, expected a valid JSON string: ${reason}`);
this.name = 'TemplateJsonMalformedError';
}
}
/**
* Thrown when `serializeTemplate` fails to produce a JSON string from the template.
*/
export class TemplateSerializationFailedError extends Error {
constructor(reason: string) {
super(`Template serialization failed: ${reason}`);
this.name = 'TemplateSerializationFailedError';
}
}

View File

@@ -0,0 +1,22 @@
import { binToHex, sha256, utf8ToBin } from '@bitauth/libauth';
import type { XOTemplate } from '@xo-cash/types';
import { serializeTemplate } from './serialization.ts';
/**
* Generates a deterministic template identifier by hashing the template.
*
* Note: This expects a template that has been validated by `parseTemplate`.
*
* @param template - The template to generate an identifier for.
* @returns The sha256 hex identifier for the template.
*/
export const generateTemplateIdentifier = (template: XOTemplate): string => {
// Serialize the template.
const serializedTemplate = serializeTemplate(template);
// Hash the serialized template.
const hash = sha256.hash(utf8ToBin(serializedTemplate));
// Convert the hash to hex and return it.
return binToHex(hash);
};

37
source/template/parser.ts Normal file
View File

@@ -0,0 +1,37 @@
import type { XOTemplate } from '@xo-cash/types';
import { xoTemplateSchema } from './schemas.ts';
import { TemplateInvalidError, buildErrorDescription } from './errors.ts';
import { deserializeTemplate, serializeTemplate } from './serialization.ts';
/**
* Accepts a template value and returns a validated XOTemplate object. The input may be
* either an Extended JSON string or a pre-parsed object. Both are validated
* against the XOTemplate schema.
*
* @param inputTemplate - The value to validate. May be an Extended JSON string or a pre-parsed object.
* @returns The validated template object
* @throws {TemplateSerializationFailedError} If a pre-parsed object input cannot be serialized to JSON for normalization.
* @throws {TemplateJsonMalformedError} If the string input is not valid JSON.
* @throws {TemplateInvalidError} If the value does not conform to the XOTemplate schema.
*/
export const parseTemplate = (inputTemplate: string | XOTemplate): XOTemplate => {
// Regardless of whether the inputTemplate is a string, an imported template or an in-memory object, serialize and then
// deserialize it to ensure the output shape is consistent. For example, optional fields explicitly set to 'undefined' would be passed through
// and then dropped on the string path, resulting in structurally different results for the same template.
const serializedTemplate = typeof inputTemplate === 'string' ? inputTemplate : serializeTemplate(inputTemplate);
const templateObject = deserializeTemplate(serializedTemplate);
// Validate the template against the schema.
const parseResult = xoTemplateSchema.safeParse(templateObject);
if (parseResult.success) {
// Return the validated template object
return parseResult.data as XOTemplate;
}
// Build a human-readable description of every validation failure
const errorDescription = buildErrorDescription(parseResult.error.issues);
// Throw a typed error with the description
throw new TemplateInvalidError(errorDescription);
};

895
source/template/schemas.ts Normal file
View File

@@ -0,0 +1,895 @@
/* eslint-disable @stylistic/newline-per-chained-call */
import { z } from 'zod';
// ============================================================
// Enums
// ============================================================
/**
* Validation schema for a BCH VM version identifier. Defines the set of known BCH VM versions
* that XO templates declare support for.
*
* Zod's `z.enum` requires the exact values to be defined inline because it needs to know each
* specific value at compile time to validate against them. Defining the versions here directly
* satisfies that requirement and allows `z.array(bchVmVersionSchema)` to be used elsewhere
*
* ```
* {
* "supported": [ "BCH_2025_05" ] ← each value
* }
* ```
*/
export const bchVmVersionSchema = z.enum([ 'BCH_2020_05', 'BCH_2021_05', 'BCH_2022_05', 'BCH_2023_05', 'BCH_2024_05', 'BCH_2025_05', 'BCH_2026_05' ]);
/**
* Validation schema for the capability of a non-fungible token. Defines the three capability
* types supported on BCH: minting tokens can create new NFTs, mutable tokens can update their
* commitment, and none tokens cannot be changed after creation.
*
* ```
* {
* "inputs|outputs": {
* "[id]": {
* "token": {
* "nft": {
* "capability": "minting" ← this schema
* }
* }
* }
* }
* }
* ```
*/
export const xoTemplateNftCapabilitySchema = z.enum([ 'minting', 'mutable', 'none' ]);
/**
* Validation schema for a BCH locking script type. Defines the standard locking script types
* supported on BCH.
*
* ```
* {
* "lockingScripts": {
* "[id]": {
* "lockingType": "p2pkh" ← this schema
* }
* }
* }
* ```
*/
export const xoTemplateLockingTypeSchema = z.enum([ 'p2s', 'p2pkh', 'p2sh' ]);
/**
* Validation schema for a primitive type identifier. Defines the set of primitive types
* that can be declared in an XO template. Used by constants, variables, and data fields.
*/
export const xoTemplatePrimitiveTypeSchema = z.enum([ 'boolean', 'bytes', 'integer', 'bigint', 'string', 'private_key', 'public_key' ]);
// ============================================================
// Primitives
// ============================================================
/**
* Validation schema for byte array fields i.e. Uint8Array instance.
*/
export const uint8ArraySchema = z.instanceof(Uint8Array).describe('A sequence of unsigned 8-bit integers expressed as a Uint8Array.');
/**
* Validation schema for the Satoshis type i.e. bigint.
*/
export const satoshisSchema = z.bigint().describe('A satoshi amount expressed as a bigint.');
// ============================================================
// Shared
// ============================================================
/** Maximum character length for name fields on view properties. */
export const VIEW_PROPERTIES_NAME_MAX_LENGTH = 200;
/** Maximum character length for description fields on view properties. */
export const VIEW_PROPERTIES_DESCRIPTION_MAX_LENGTH = 5000;
/** Maximum character length for icon fields on view properties. */
export const VIEW_PROPERTIES_ICON_MAX_LENGTH = 50;
/**
* Validation schema for view properties shared across many template elements i.e. name, description, icon.
* Extended by most other schemas in this file.
*/
export const xoTemplateViewPropertiesSchema = z
.object({
name: z.string().max(VIEW_PROPERTIES_NAME_MAX_LENGTH).describe('A short human-readable label for this element.'),
description: z
.string()
.max(VIEW_PROPERTIES_DESCRIPTION_MAX_LENGTH)
.describe('A human-readable explanation of what this element does and when it is relevant.'),
icon: z.string().max(VIEW_PROPERTIES_ICON_MAX_LENGTH).optional().describe('An optional icon identifier or URL for this element.'),
})
.strict();
// ============================================================
// Intents
// ============================================================
/**
* Validation schema for the base intent structure. Describes the common data parameters shared
* by all intent types regardless of what they target.
*
* An optional templateIdentifier allows the intent to reference a target defined in a different
* template, enabling cross-template interaction.
*
* Extended by: xoTemplateActionIntentSchema, xoTemplateOutputIntentSchema,
* xoTemplateLockingScriptIntentSchema.
*/
export const xoTemplateIntentSchema = z
.object({
templateIdentifier: z
.string()
.optional()
.describe('Optional identifier for the template used in this intent. If not provided, uses the current template.'),
role: z.string().optional().describe('Optional identifier for the role used in this intent.'),
generate: z.array(z.string()).optional().describe('Identifiers for items to generate when this intent is resolved, e.g. keys or secrets.'),
variables: z.array(z.record(z.string(), z.unknown())).optional().describe('Variable values to apply when this intent is resolved.'),
constants: z.array(z.record(z.string(), z.unknown())).optional().describe('Constant values to apply when this intent is resolved.'),
secrets: z.array(z.record(z.string(), z.unknown())).optional().describe('Secret values to apply when this intent is resolved.'),
})
.strict();
/**
* Validation schema for an action intent. Extends the base intent structure with an action
* identifier. Used in locking script action lists and in the template's start array.
*
* ```
* {
* "start": [
* { "action": "..." } ← this schema
* ],
* "lockingScripts": {
* "[id]": {
* "actions": [
* { "action": "..." } ← this schema
* ],
* "roles": {
* "[roleId]": {
* "actions": [
* { "action": "..." } ← this schema
* ]
* }
* }
* }
* }
* }
* ```
*/
export const xoTemplateActionIntentSchema = xoTemplateIntentSchema
.extend({
action: z.string().describe('The identifier for the intended action.'),
})
.strict();
/**
* Validation schema for an output intent. Extends the base intent structure with an output
* identifier. Used in the template's defaults block.
*
* ```
* {
* "defaults": {
* "change": { "output": "..." } ← this schema
* }
* }
* ```
*/
export const xoTemplateOutputIntentSchema = xoTemplateIntentSchema
.extend({
output: z.string().describe('The identifier for the intended output.'),
})
.strict();
/**
* Validation schema for a locking script intent. Extends the base intent structure with
* a locking script identifier.
*
* @todo The location of this schema in the template JSON is not yet determined.
*/
export const xoTemplateLockingScriptIntentSchema = xoTemplateIntentSchema
.extend({
lockingScript: z.string().describe('The identifier for the intended locking script.'),
})
.strict();
// ============================================================
// Actions
// ============================================================
/**
* Validation schema for the slot count configuration on a role requirement. Declares how many
* participants of a given role are needed. min sets the lower bound and max sets the upper bound.
* When max is absent, there is no upper limit.
*
* ```
* {
* "actions": {
* "[id]": {
* "requirements": {
* "participants": [
* { "slots": { "min": 1, "max": 1 } } ← this schema
* ]
* }
* }
* }
* }
* ```
*/
export const xoTemplateRoleSlotsRequirementsSchema = z
.object({
min: z.number().describe('Minimum number of participants required for this role.'),
max: z.number().optional().describe('Maximum number of participants allowed for this role. Undefined means unlimited.'),
})
.strict();
/**
* Validation schema for the capability requirements declared on a role within an action.
* Describes what data, secrets, or state the role is responsible for providing when participating in an action.
*
* ```
* {
* "actions": {
* "[id]": {
* "roles": {
* "[roleId]": {
* "requirements": { "variables": [], "secrets": [] } ← this schema
* }
* }
* }
* }
* }
* ```
*/
export const xoTemplateActionRoleRequirementsSchema = z
.object({
variables: z.array(z.string()).optional().describe('List of variable identifiers required for this role.'),
secrets: z.array(z.string()).optional().describe('List of secret identifiers required for this role.'),
})
.strict();
/**
* Validation schema for a role-specific definition within an action.
* All view properties are optional.
*
* ```
* {
* "actions": {
* "[id]": {
* "roles": {
* "[roleId]": { } ← this schema
* }
* }
* }
* }
* ```
*/
export const xoTemplateActionRoleSchema = xoTemplateViewPropertiesSchema
.partial()
.extend({
generate: z
.array(z.string())
.optional()
.describe('Identifiers for data items that should be generated for this role when participating in the action.'),
// Describes under what conditions this role can proceed with the action. All values listed
// under requirements must be populated for the action to work. This is a developer and
// author concern. It is not present on intents because intents are used to populate the
// action rather than to define it, and their fields are flattened accordingly.
requirements: xoTemplateActionRoleRequirementsSchema.optional().describe('The requirements for this role within this action.'),
})
.strict();
/**
* Validation schema for a role participation requirement in an action's requirements block.
*
* ```
* {
* "actions": {
* "[id]": {
* "requirements": {
* "participants": [
* { "role": "...", "slots": { } } ← this schema
* ]
* }
* }
* }
* }
* ```
*/
export const xoTemplateRoleSlotSchema = z
.object({
role: z.string().describe('The role identifier that this requirement applies to.'),
slots: xoTemplateRoleSlotsRequirementsSchema.describe('Slot configuration specifying how many participants of this role are required.'),
})
.strict();
/**
* Validation schema for the requirements of an action.
*
* ```
* {
* "actions": {
* "[id]": {
* "requirements": { "participants": [], "secrets": [] } ← this schema
* }
* }
* }
* ```
*/
export const xoTemplateActionRequirementsSchema = z
.object({
participants: z.array(xoTemplateRoleSlotSchema).optional().describe('The participants required for this action.'),
secrets: z.array(z.string()).optional().describe('The secrets required for this action.'),
})
.strict();
/**
* Validation schema for an action definition.
*
* ```
* {
* "actions": {
* "[id]": { } ← this schema
* }
* }
* ```
*/
export const xoTemplateActionSchema = xoTemplateViewPropertiesSchema
.extend({
roles: z.record(z.string(), xoTemplateActionRoleSchema).optional().describe('Specific context for each role participating in this action.'),
requirements: xoTemplateActionRequirementsSchema.optional().describe('The requirements for this action.'),
// This is a list of conditions that can influence how the action behaves.
// This needs more work to be done.
conditions: z.array(z.string()).optional().describe('Conditions that must be met for this action to be available.'),
// A single transaction produced by the action.
// In future this might be moved to a results block that can have multiple transactions.
transaction: z
.string()
.optional()
.describe("The identifier of the transaction this action produces, referencing an entry in the template's transactions."),
// The data that is produced by the action.
// In future this might be moved to a results block that can have multiple data fields.
data: z.string().optional().describe("The identifier of the data field this action produces, referencing an entry in the template's data."),
})
.strict();
// ============================================================
// Tokens & Amounts
// ============================================================
/**
* Validation schema for the non-fungible token configuration within a token field.
*
* ```
* {
* "inputs|outputs": {
* "[id]": {
* "token": {
* "nft": { } ← this schema
* }
* }
* }
* }
* ```
*/
export const xoTemplateNonFungibleTokenDetailsSchema = z
.object({
capability: z
.union([ xoTemplateNftCapabilitySchema, z.string() ])
.optional()
.describe('The capability of the NFT. May be a known capability value or a CashASM expression resolving to a capability.'),
commitment: z.string().optional().describe('The commitment data for the NFT, as a string or CashASM expression resolving to a commitment.'),
})
.strict();
/**
* Validation schema for the token configuration on inputs and outputs.
*
* ```
* {
* "inputs|outputs": {
* "[id]": {
* "token": { } ← this schema
* }
* }
* }
* ```
*/
export const xoTemplateTokenSchema = z
.object({
category: z.string().optional().describe('The category of the token, as a string or CashASM expression resolving to a category.'),
amount: z
.union([ z.bigint(), z.string(), z.null() ])
.optional()
.describe('The amount of fungible tokens as a bigint, a CashASM expression resolving to a bigint, or null indicating no FT is present.'),
nft: xoTemplateNonFungibleTokenDetailsSchema
.nullable()
.optional()
.describe('Non-fungible token configuration. Null indicates no NFT is present.'),
})
.strict();
/**
* Validation schema for the asset amounts configuration. Used by omitChangeAmounts on inputs
* and by balance on locking scripts, outputs, and their roles.
*/
export const xoTemplateAssetAmountsSchema = z
.object({
/**
* The satoshi amount.
* - `Satoshis`: A specific bigint amount.
* - `string`: A CashASM expression that resolves to the amount.
* - `true`: all, i.e. the entire amount
*/
satoshis: z
.union([ satoshisSchema, z.string(), z.literal(true) ])
.optional()
.describe('The satoshi amount. Accepts a bigint for a specific value, a CashASM expression, or true for the entire amount.'),
/**
* The fungible token amount.
* - `FungibleTokenAmount`: A specific bigint amount.
* - `string`: A CashASM expression that resolves to the amount.
* - `true`: all, i.e. the entire amount
*/
fungibleTokens: z
.union([ z.bigint(), z.string(), z.literal(true) ])
.optional()
.describe('The fungible token amount. Accepts a bigint for a specific value, a CashASM expression, or true for the entire amount.'),
/**
* Whether a non-fungible token is present (0 for absent, 1 for present),
* or a CashASM expression that evaluates to 0 or 1.
* - `true`: resolves to 1 if an NFT is present, or 0 if none is present. Use this when
* the NFT is optional, to express that the NFT is estimated to be part of the balance
* if present, or absent from it if not.
* - `0`: None, i.e. nothing is expected to be included
* - `1`: present and complete, as value > 1 does not make sense for non-fungible tokens
* - `string`: A CashASM expression that evaluates to 0 or 1.
*/
nonfungibleTokens: z
.union([ z.literal(0), z.literal(1), z.string(), z.literal(true) ])
.optional()
.describe('Whether an NFT is present: 0 for absent, 1 for present, a CashASM expression evaluating to 0 or 1, or true to estimate the NFT as part of the balance if present, or absent from it if not.'),
})
.strict();
// ============================================================
// Locking Scripts
// ============================================================
/**
* Validation schema for the state configuration shared by a locking script and its individual roles.
* Declares which variables and secrets are tracked in the on-chain state for a given participant.
*
* ```
* {
* "lockingScripts": {
* "[id]": {
* "state": { "variables": [], "secrets": [] } ← this schema
* "roles": {
* "[roleId]": {
* "state": { "variables": [], "secrets": [] } ← this schema
* }
* }
* }
* }
* }
* ```
*/
export const xoTemplateStateSchema = z
.object({
variables: z.array(z.string()).optional().describe('List of variable identifiers to track in state.'),
secrets: z.array(z.string()).optional().describe('List of secret identifiers to track in state.'),
})
.strict();
/**
* Validation schema for a role definition for a locking script.
*
* ```
* {
* "lockingScripts": {
* "[id]": {
* "roles": {
* "[roleId]": { } ← this schema
* }
* }
* }
* }
* ```
*/
export const xoTemplateLockingScriptRoleSchema = xoTemplateViewPropertiesSchema
.partial()
.extend({
state: xoTemplateStateSchema.optional().describe('List of items to track as state for this role.'),
actions: z.array(xoTemplateActionIntentSchema).optional().describe('List of action references available to this role.'),
balance: xoTemplateAssetAmountsSchema.partial().optional().describe('Estimated ownership in the optional set of asset amounts specified.'),
selectable: z.boolean().optional().describe('Whether outputs locked to this script should be available for coin selection.'),
privacy: z
.union([ z.number(), z.string() ])
.optional()
.describe('Privacy level for outputs locked to this script. A numeric level or a CashASM expression that evaluates to a privacy level.'),
})
.strict();
/**
* Validation schema for a locking script definition.
*
* ```
* {
* "lockingScripts": {
* "[id]": { } ← this schema
* }
* }
* ```
*/
export const xoTemplateLockingScriptSchema = xoTemplateViewPropertiesSchema
.extend({
lockingType: xoTemplateLockingTypeSchema.optional().describe('The type of locking mechanism. Defaults to p2s if not specified.'),
lockingBytecode: z.string().describe('The locking script bytecode.'),
unlockingBytecode: z.string().optional().describe('Optional default unlocking bytecode when used in automatic coin selection.'),
actions: z.array(xoTemplateActionIntentSchema).optional().describe('The actions available for this locking script.'),
state: xoTemplateStateSchema.optional().describe('List of items to track as state for all participants.'),
balance: xoTemplateAssetAmountsSchema.partial().optional().describe('Estimated ownership in the optional set of asset amounts specified.'),
selectable: z.boolean().optional().describe('Whether outputs locked to this script should be available for coin selection.'),
// Might be levels or tags
privacy: z
.union([ z.number(), z.string() ])
.optional()
.describe('Privacy level for outputs locked to this script. A numeric level or a CashASM expression that evaluates to a privacy level.'),
roles: z
.record(z.string(), xoTemplateLockingScriptRoleSchema)
.optional()
.describe('Specific context for each role participating in this locking script.'),
})
.strict();
// ============================================================
// Inputs
// ============================================================
/**
* Validation schema for an input definition in the template. Extends view properties with optional
* satoshi value, token configuration, and other transaction level fields.
*
* ```
* {
* "inputs": {
* "[id]": { } ← this schema
* }
* }
* ```
*/
export const xoTemplateInputSchema = xoTemplateViewPropertiesSchema
.extend({
valueSatoshis: z
.union([ satoshisSchema, z.string() ])
.optional()
.describe('The amount of satoshis for this input as a bigint or a CashASM expression resolving to the amount.'),
token: xoTemplateTokenSchema.nullable().optional().describe('Token configuration for this input.'),
sequenceNumber: z
.union([ z.number(), z.string() ])
.optional()
.describe('The sequence number of this input as a specific number or a CashASM expression.'),
unlockingScript: z.string().optional().describe('Identifier of the unlocking script to use for the UTXO provided for this input.'),
omitChangeAmounts: xoTemplateAssetAmountsSchema
.optional()
.describe('Amount of change that should be omitted from the automatic change handling. WARNING: Setting this can result in loss of funds!'),
})
.strict();
// ============================================================
// Outputs
// ============================================================
/**
* Validation schema for an output definition. Extends the locking script schema so that
* every output inherits the same locking script fields and adds output-specific fields.
*
* ```
* {
* "outputs": {
* "[id]": { } ← this schema
* }
* }
* ```
*/
export const xoTemplateOutputSchema = xoTemplateLockingScriptSchema
.omit({ lockingType: true, lockingBytecode: true, unlockingBytecode: true })
.extend({
lockingScript: z.string().describe('Identifier of the locking script to use for this output.'),
valueSatoshis: z
.union([ satoshisSchema, z.string() ])
.optional()
.describe('The amount of satoshis for this output as a bigint or a CashASM expression resolving to the amount.'),
token: xoTemplateTokenSchema.nullable().optional().describe('Token configuration for this output.'),
})
.strict();
// ============================================================
// Transactions
// ============================================================
/**
* Validation schema for a transaction input reference for a transaction definition.
*
* ```
* {
* "transactions": {
* "[id]": {
* "inputs": [
* { "input": "..." } ← this schema
* ]
* }
* }
* }
* ```
*/
export const xoTemplateTransactionInputSchema = z
.object({
input: z.string().describe('The input definition identifier.'),
inputIndex: z.number().optional().describe('Optional index of this input in the transaction.'),
})
.strict();
/**
* Validation schema for a transaction output reference for a transaction definition.
*
* ```
* {
* "transactions": {
* "[id]": {
* "outputs": [
* { "output": "..." } ← this schema
* ]
* }
* }
* }
* ```
*/
export const xoTemplateTransactionOutputSchema = z
.object({
output: z.string().describe('The output definition identifier.'),
outputIndex: z.number().optional().describe('Optional index of this output in the transaction.'),
})
.strict();
/**
* Validation schema for role-specific data for a transaction definition.
*
* ```
* {
* "transactions": {
* "[id]": {
* "roles": {
* "[roleId]": { } ← this schema
* }
* }
* }
* }
* ```
*/
export const xoTemplateTransactionRoleDataSchema = xoTemplateViewPropertiesSchema
.partial()
.extend({
inputs: z.array(xoTemplateTransactionInputSchema).optional().describe('The inputs required for this role.'),
outputs: z.array(xoTemplateTransactionOutputSchema).optional().describe('The outputs required for this role.'),
})
.strict();
/**
* Validation schema for a transaction template definition.
*
* ```
* {
* "transactions": {
* "[id]": { } ← this schema
* }
* }
* ```
*/
export const xoTemplateTransactionSchema = xoTemplateViewPropertiesSchema
.extend({
version: z.number().optional().describe('The version of the transaction.'),
locktime: z.number().optional().describe('The locktime for this transaction.'),
inputs: z.array(xoTemplateTransactionInputSchema).describe('The inputs for this transaction.'),
outputs: z.array(xoTemplateTransactionOutputSchema).describe('The outputs for this transaction.'),
roles: z
.record(z.string(), xoTemplateTransactionRoleDataSchema)
.optional()
.describe('Specific context for each role participating in this transaction.'),
composable: z.boolean().optional().describe('Whether this transaction can be composed with other transactions.'),
})
.strict();
// ============================================================
// Template Data
// ============================================================
/**
* Validation schema for a constant value definition.
*
* ```
* {
* "constants": {
* "[id]": { } ← this schema
* }
* }
* ```
*/
export const xoTemplateConstantSchema = xoTemplateViewPropertiesSchema
.extend({
type: xoTemplatePrimitiveTypeSchema.describe('The data type of this constant.'),
value: z.unknown().describe('The value of this constant.'),
hint: z.string().optional().describe('An optional hint to help apps and users understand what this constant represents.'),
})
.strict();
/**
* Validation schema for a data field definition.
*
* ```
* {
* "data": {
* "[id]": { } ← this schema
* }
* }
* ```
*/
export const xoTemplateDataSchema = z
.object({
type: xoTemplatePrimitiveTypeSchema.describe('The data type of this data field.'),
value: z.unknown().describe('The value for this data field.'),
hint: z.string().optional().describe('An optional hint to help apps and users understand this data field.'),
})
.strict();
/**
* Validation schema for an import default value intent. Extends the base intent with optional
* view properties that the engine evaluates at runtime to produce human-readable output.
*
* ```
* {
* "variables": {
* "[id]": {
* "importDefaultValue": { } ← this schema
* }
* }
* }
* ```
*/
export const xoTemplateImportDefaultValueSchema = xoTemplateIntentSchema
// .shape unwraps the ZodObject to the raw field map { name, description, icon } with each field made optional
.extend(xoTemplateViewPropertiesSchema.partial().shape)
.strict();
/**
* Validation schema for a variable definition.
*
* ```
* {
* "variables": {
* "[id]": { } ← this schema
* }
* }
* ```
*/
export const xoTemplateVariableSchema = xoTemplateViewPropertiesSchema
.extend({
type: xoTemplatePrimitiveTypeSchema.optional().describe('The data type of this variable.'),
hint: z.string().optional().describe('A hint to help users understand what value to provide.'),
// A neutral intent that the engine uses to populate the default value for this variable.
// View properties (name, description, icon) may contain CashASM expressions that the
// engine evaluates at runtime to produce human-readable output. The engine overrides
// whatever values are set here when resolving the variable for a participant.
importDefaultValue: xoTemplateImportDefaultValueSchema
.optional()
.describe('A neutral intent that the engine uses to populate the default value for this variable.'),
})
.strict();
// ============================================================
// Template Resources
// ============================================================
/**
* Validation schema for a resource reference attached to a template element. Extends view
* properties with a URL pointing to external documentation or tooling.
*
* ```
* {
* "resources": [
* { "name": "...", "description": "...", "url": "..." } ← this schema
* ]
* }
* ```
*/
export const xoTemplateResourceSchema = xoTemplateViewPropertiesSchema
.extend({
url: z.string().describe('The URL for this resource.'),
})
.strict();
/**
* Validation schema for an icon reference.
*
* ```
* {
* "icons": [
* { "name": "...", "hash": "..." } ← this schema
* ]
* }
* ```
*/
export const xoTemplateIconSchema = xoTemplateViewPropertiesSchema
.pick({ name: true })
.extend({
hash: z.string().describe('The identifier of the icon.'),
})
.strict();
// ============================================================
// Defaults
// ============================================================
/**
* Validation schema for the defaults block of a template.
*
* ```
* {
* "defaults": { } ← this schema
* }
* ```
*/
export const xoTemplateDefaultsSchema = z
.object({
change: xoTemplateOutputIntentSchema.optional().describe('Instructions for how to construct automated change output.'),
})
.strict();
// ============================================================
// Template
// ============================================================
/**
* Validation schema for the full XOTemplate type.
*/
export const xoTemplateSchema = xoTemplateViewPropertiesSchema
.extend({
$schema: z
.string()
.describe('The URI that identifies the JSON Schema used by this template. This enables documentation, autocompletion, and validation in JSON documents.'),
version: z.string().optional().describe('A string identifying the version of this template.'),
supported: z.array(bchVmVersionSchema).min(1).describe('The BCH VM versions that this template supports. At least one version is required.'),
defaults: xoTemplateDefaultsSchema.optional().describe('Optional default settings used in this template.'),
roles: z.record(z.string(), xoTemplateViewPropertiesSchema).describe('The roles defined in this template.'),
start: z.array(xoTemplateActionIntentSchema).describe('A list of entry points defining which actions are available at the start.'),
actions: z.record(z.string(), xoTemplateActionSchema).describe('The actions defined in this template.'),
data: z.record(z.string(), xoTemplateDataSchema).optional().describe('The data fields defined in this template.'),
transactions: z.record(z.string(), xoTemplateTransactionSchema).optional().describe('The transaction templates defined in this template.'),
inputs: z.record(z.string(), xoTemplateInputSchema).describe('The inputs defined in this template.'),
outputs: z.record(z.string(), xoTemplateOutputSchema).describe('The outputs defined in this template.'),
lockingScripts: z.record(z.string(), xoTemplateLockingScriptSchema).describe('The locking script templates defined in this template.'),
scripts: z
.record(z.string(), z.string())
.describe('Scripts used in this template. Keys are script identifiers, values are bytecode or CashASM expressions.'),
constants: z.record(z.string(), xoTemplateConstantSchema).optional().describe('The constants defined in this template.'),
variables: z
.record(z.string(), xoTemplateVariableSchema)
.optional()
.describe("The variables that must be provided for use in the template's scripts."),
resources: z.array(xoTemplateResourceSchema).optional().describe('Resource references providing external documentation or tooling links.'),
icons: z.array(xoTemplateIconSchema).optional().describe('The icons available for use throughout the template.'),
scenarios: z.unknown().optional().describe('The scenarios defined in this template.'),
})
.strict();

View File

@@ -0,0 +1,41 @@
import type { XOTemplate } from '@xo-cash/types';
import { extendedJsonReplacer, extendedJsonReviver } from '../extended-json.ts';
import { TemplateJsonMalformedError, TemplateSerializationFailedError } from './errors.ts';
/**
* Serializes an XOTemplate to a JSON string. Encodes `bigint` and `Uint8Array` fields in
* Extended JSON format so they can be reconstructed by `deserializeTemplate`.
*
* @param template The template to serialize.
* @returns A JSON string representation of the template.
* @throws {TemplateSerializationFailedError} If the template cannot be serialized to JSON.
*/
export const serializeTemplate = (template: XOTemplate): string => {
try {
// Serialize the template to a JSON string.
return JSON.stringify(template, extendedJsonReplacer);
} catch (serializationError) {
const reason = serializationError instanceof Error ? serializationError.message : 'unknown error while serializing template';
throw new TemplateSerializationFailedError(reason);
}
};
/**
* Deserializes a JSON string back into an XOTemplate object. Restores `bigint` and
* `Uint8Array` fields encoded in Extended JSON format by `serializeTemplate`.
*
* @param serializedTemplate - A JSON string of an XOTemplate object.
* @returns The reconstructed XOTemplate object.
* @throws {TemplateJsonMalformedError} If the serialized template is not valid JSON.
*/
export const deserializeTemplate = (serializedTemplate: string): XOTemplate => {
try {
// Parse the serialized template using the extended JSON reviver.
return JSON.parse(serializedTemplate, extendedJsonReviver);
} catch (parsingError) {
const reason = parsingError instanceof Error ? parsingError.message : 'unknown error while deserializing template';
throw new TemplateJsonMalformedError(reason);
}
};

View File

@@ -1,14 +0,0 @@
import { binToHex, sha256, utf8ToBin, stringify } from '@bitauth/libauth';
import type { XOTemplate } from '@xo-cash/types';
/**
* Generates a deterministic template identifier by hashing the template.
*
* @param template - The template to generate an identifier for.
* @returns The template identifier for the template.
*/
export const generateTemplateIdentifier = (template: XOTemplate): string => {
// TODO: Finalize on the stringify method and ensure unique identifier generation is sufficient.
return binToHex(sha256.hash(utf8ToBin(stringify(template))));
};