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,5 +1,6 @@
{
"version": "0.1",
"import": ["@generalprotocols/cspell-dictionary/cspell.json"],
"words": ["nonfungible", "lockscript"]
"words": ["nonfungible", "lockscript"],
"ignorePaths": ["source/template/xo-template.schema.json"]
}

6
.gitignore vendored
View File

@@ -17,8 +17,4 @@ coverage/
*.tsbuildinfo
# Generated files
source/parser/xo-template.schema.json
source/parser/ajv/validate-xo-template.js
# Exception: include generate-schema.js
!scripts/generate-schema.js
source/template/xo-template.schema.json

3
.gitlab-ci.yml Normal file
View File

@@ -0,0 +1,3 @@
# Use the default NPM publishing template for the `@xo-cash/*` packages.
include:
- component: $CI_SERVER_FQDN/GeneralProtocols/xo/pipelines/npm@v1.1.0

View File

@@ -1,6 +1,13 @@
# @xo-cash/utils
XO utilities and parser
Utility functions for XO
- Template parsing, serialization, validation and schema generation
- Script identifier generation
- Template identifier generation
- Extended JSON
> ⚠️ This project is in **early development phase**. Use it only if you understand what you are doing and accept the risks. **Do not use funds you are not willing to lose.** Minor version bumps may introduce **breaking changes**
## Installation
@@ -11,9 +18,48 @@ npm install @xo-cash/utils
## Usage
```typescript
import { scriptToScriptHash } from '@xo-cash/utils';
import { parseTemplate, scriptToScriptHash, generateTemplateIdentifier } from '@xo-cash/utils';
```
## Scripts
| Command | Description |
| ---------------------------------- | ------------------------------------------------------ |
| `npm run build` | Compile TypeScript to `dist/` |
| `npm run generate-schema` | Generate `xo-template.schema.json` from the Zod schema |
| `npm run test` | Run tests with coverage |
| `npm run style` | Lint with ESLint (read-only) |
| `npm run syntax` | Type-check without emitting (`tsc --noEmit`) |
| `npm run format` | Auto-fix formatting and linting |
| `npm run docs` | Generate TypeDoc API docs to `public/` |
| `npm run spellcheck` | Spell-check source and test files |
| `npm audit --audit-level=moderate` | Performs npm audit |
## Templates
### Parsing
`parseTemplate` accepts any string OR XOTemplate object and returns a validated `XOTemplate` object. It rejects unknown
properties, missing required fields, and incorrect value types. Native bigint is accepted in satoshi
fields; string encoding is also accepted for values sourced from JSON where bigint is unavailable.
### How validation works
Validation uses a Zod schema defined in `source/template/schemas.ts`. The schema mirrors the
`XOTemplate` TypeScript type and rejects unknown keys at every level. When validation fails, a
`TemplateInvalidError` is thrown with a message that lists every failing field and the reason it
failed, so all problems are visible in one pass.
### JSON Schema export
A JSON Schema representation of the template can be generated by running:
```bash
npm run generate-schema
```
This writes `source/template/xo-template.schema.json`, which can be consumed by other tools. However, it's recommended to use functions provided by this library for XO template development thanks to the extended JSON support.
## Links
- [Repository](https://gitlab.com/GeneralProtocols/xo/utils)
@@ -21,4 +67,4 @@ import { scriptToScriptHash } from '@xo-cash/utils';
## License
MIT
[MIT](LICENSE)

View File

@@ -2,7 +2,7 @@ import baseConfig from '@xo-cash/eslint-config';
export default [
{
ignores: [ 'source/parser/ajv/validate-xo-template.js', 'docs/**' ],
ignores: [ 'scripts/**', 'docs/**' ],
},
...baseConfig,
];

12208
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -10,13 +10,16 @@
"types": "./dist/index.d.mts",
"default": "./dist/index.mjs"
},
"publishConfig": {
"access": "public"
},
"sideEffects": false,
"scripts": {
"analyze": "tsdown --clean --no-fixed-extension --sourcemap source/index.ts && esbuild-analyzer dist/",
"build": "tsdown --clean --sourcemap source/index.ts",
"docs": "typedoc --hideGenerator --categorizeByGroup",
"format": "prettier --write . && eslint --fix",
"prebuild": "node scripts/generate-schema.js",
"generate-schema": "node --experimental-strip-types scripts/generate-schema.ts",
"spellcheck": "cspell 'source/**' 'test/**' 'playground/**'",
"style": "eslint",
"syntax": "tsc --noEmit",
@@ -41,17 +44,20 @@
],
"dependencies": {
"@bitauth/libauth": "^3.1.0-next.8",
"@xo-cash/types": "0.0.1"
"@xo-cash/types": "0.0.1",
"zod": "^4.3.6"
},
"devDependencies": {
"@chalp/eslint-airbnb": "^1.3.0",
"@generalprotocols/cspell-dictionary": "^1.0.1",
"@stylistic/eslint-plugin": "^5.7.0",
"@types/node": "^25.5.0",
"@typescript-eslint/eslint-plugin": "^8.53.1",
"@typescript-eslint/parser": "^8.53.1",
"@vitest/coverage-v8": "^4.0.17",
"@viz-kit/esbuild-analyzer": "^1.0.0",
"@xo-cash/eslint-config": "1.0.1",
"@xo-cash/templates": "0.0.1",
"cspell": "^9.6.0",
"eslint": "^9.39.2",
"prettier": "^3.6.2",
@@ -60,8 +66,6 @@
"typedoc-plugin-coverage": "^4.0.2",
"typescript": "^5.3.2",
"typescript-eslint": "^8.53.1",
"vitest": "^4.0.17",
"ajv-cli": "^5.0.0",
"ts-json-schema-generator": "^2.4.0"
"vitest": "^4.0.17"
}
}

View File

@@ -1,57 +0,0 @@
#!/usr/bin/env node
import { execSync } from 'child_process';
import { readFileSync, writeFileSync } from 'fs';
import { fileURLToPath } from 'url';
import { dirname, join } from 'path';
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
const rootDir = join(__dirname, '..');
// exec helper
const exec = (command, options = {}) => {
const defaultOptions = {
stdio: 'inherit',
cwd: rootDir,
shell: true,
...options,
};
return execSync(command, defaultOptions);
};
try {
const schemaPath = join(rootDir, 'source/parser/xo-template.schema.json');
const validatorPath = join(rootDir, 'source/parser/ajv/validate-xo-template.js');
const tsconfigPath = join(rootDir, 'tsconfig.json');
// Point directly to the dist file - the type is exported at the end via barrel export
const templatePath = join(rootDir, 'node_modules/@xo-cash/types/dist/index.d.ts');
// Generate schema JSON (write to file directly instead of shell redirection)
// Using --no-type-check to skip type checking and --expose all to find types exported via barrel exports
const schemaContent = execSync(
`ts-json-schema-generator --no-ref-encode --no-type-check --expose all --tsconfig "${tsconfigPath}" --path "${templatePath}" --type "XOTemplate"`,
{ encoding: 'utf8', cwd: rootDir },
);
writeFileSync(schemaPath, schemaContent, 'utf8');
// Compile validator with AJV
exec(`ajv compile -s "${schemaPath}" --allowUnionTypes --all-errors -o "${validatorPath}"`);
// Transform the generated validator code
let code = readFileSync(validatorPath, 'utf8');
code = code
.replace(/"use strict";module\.exports = [^;]+;module\.exports\.default = [^;]+;/, 'export default validate20;')
.replace(/;const /g, ';\nconst ')
.replace(/;function /g, ';\nfunction ')
.replace(/\}function /g, '}\nfunction ');
writeFileSync(validatorPath, code, 'utf8');
console.log('Schema generation complete!');
} catch (error) {
console.error('Error generating schema:', error.message);
process.exit(1);
}

View File

@@ -0,0 +1,71 @@
#!/usr/bin/env node
/**
* Generates a JSON Schema file from the template schema, see source/template/schemas.ts for the schema definition.
*
* To provide support for types that JSON Schema cannot natively represent (e.g. bigint, Uint8Array), this script uses
* Zod's override functionality.
*
* The generated schema file (source/template/xo-template.schema.json) is not directly used anywhere in the xo project, but can be consumed by other JSON Schema validators
* to perform validations and help development of xo templates but it is important to note that they do not guarantee the support for bigint and
* other types support in the future. Hence, for XO compatible templates, it's a requirement that the provided template (json or typescript) is validated against
* the current template schema (source/template/schemas.ts) and validator.
*/
import { writeFileSync } from 'node:fs';
import { fileURLToPath } from 'node:url';
import { dirname, join } from 'node:path';
import { ZodBigInt } from 'zod';
import { xoTemplateSchema, uint8ArraySchema } from '../source/template/schemas.ts';
// Gets the directory of this script file.
const scriptDirectory = dirname(fileURLToPath(import.meta.url));
// Gets the root directory of the package.
const rootDirectory = join(scriptDirectory, '..');
// The output path for the generated JSON Schema file, placed alongside the template schemas.
const schemaOutputPath = join(rootDirectory, 'source/template/xo-template.schema.json');
/**
* Generates the JSON Schema from the zod's template validation schema.
*
* - Bigint fields are mapped to `{ "type": "integer", "format": "bigint" }` because JSON Schema has no native bigint type.
* - Uint8Array fields are mapped to `{ "type": "string", "format": "uint8array" }` because JSON Schema has no byte array type.
*
* @returns The generated JSON Schema object.
*/
const generateSchema = (): Record<string, unknown> => {
return xoTemplateSchema.toJSONSchema({
// With "throw", Zod throws for any type it cannot represent in JSON Schema natively, unless the
// override below handles it first. This ensures new unrepresentable types added to the schema are
// caught immediately rather than silently producing an empty schema node.
unrepresentable: 'throw',
override: ({ zodSchema, jsonSchema: schemaNode }): void => {
// Override for bigint
if (zodSchema instanceof ZodBigInt) {
Object.assign(schemaNode, { type: 'integer', format: 'bigint' });
}
// Override for Uint8Array
if (zodSchema === uint8ArraySchema) {
Object.assign(schemaNode, { type: 'string', format: 'uint8array' });
}
},
});
};
try {
const schema = generateSchema();
writeFileSync(schemaOutputPath, JSON.stringify(schema, null, 4) + '\n', 'utf8');
console.log('Schema generated:', schemaOutputPath);
} catch (thrownValue) {
const errorMessage = thrownValue instanceof Error ? thrownValue.message : String(thrownValue);
console.error('Schema generation failed:', errorMessage);
process.exit(1);
}

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))));
};

View File

@@ -0,0 +1,90 @@
import { expect, test } from 'vitest';
import { extendedJsonReviver } from '../source/index.ts';
/**
* Tests that extendedJsonReviver reconstructs a positive bigint.
*/
const testReviverReconstructsPositiveBigint = (): void => {
// A positive bigint
const reconstructed = extendedJsonReviver('_', '<bigint: 42n>');
// The value is reconstructed as a bigint, not left as a string
expect(reconstructed).toBe(42n);
};
/**
* Tests that extendedJsonReviver reconstructs a negative bigint.
*/
const testReviverReconstructsNegativeBigint = (): void => {
// A negative bigint
const reconstructed = extendedJsonReviver('_', '<bigint: -7n>');
// The sign is preserved
expect(reconstructed).toBe(-7n);
};
/**
* Tests that extendedJsonReviver reconstructs zero as a bigint.
*/
const testReviverReconstructsZeroBigint = (): void => {
const reconstructed = extendedJsonReviver('_', '<bigint: 0n>');
// Zero is reconstructed as bigint 0n, not the number 0 or the string '0'
expect(reconstructed).toBe(0n);
};
/**
* Tests that extendedJsonReviver reconstructs a Uint8Array.
*/
const testReviverReconstructsUint8Array = (): void => {
// A Uint8Array
const reconstructed = extendedJsonReviver('_', '<uint8array: abcd>');
// The value is a Uint8Array with the correct bytes
expect(reconstructed).toStrictEqual(new Uint8Array([ 0xab, 0xcd ]));
};
/**
* Tests that extendedJsonReviver reconstructs an empty Uint8Array when the hex string is empty.
*/
const testReviverReconstructsEmptyUint8Array = (): void => {
const reconstructed = extendedJsonReviver('_', '<uint8array: >');
// An empty Uint8Array is returned, not null, undefined, or an empty string
expect(reconstructed).toStrictEqual(new Uint8Array(0));
};
/**
* Tests that extendedJsonReviver passes through plain strings.
*/
const testReviverPassesThroughPlainString = (): void => {
const reconstructed = extendedJsonReviver('_', 'just a string');
// The value is passed through unchanged
expect(reconstructed).toBe('just a string');
};
/**
* Tests that extendedJsonReviver passes through non-string values.
*/
const testReviverPassesThroughNonStringValues = (): void => {
// Numbers, booleans, null, and objects pass through unchanged
expect(extendedJsonReviver('_', 42)).toBe(42);
expect(extendedJsonReviver('_', true)).toBe(true);
expect(extendedJsonReviver('_', false)).toBe(false);
expect(extendedJsonReviver('_', null)).toBe(null);
expect(extendedJsonReviver('_', undefined)).toBe(undefined);
expect(extendedJsonReviver('_', { foo: 'bar' })).toStrictEqual({ foo: 'bar' });
};
const runTests = async (): Promise<void> => {
test('extendedJsonReviver: reconstructs a positive bigint', testReviverReconstructsPositiveBigint);
test('extendedJsonReviver: reconstructs a negative bigint', testReviverReconstructsNegativeBigint);
test('extendedJsonReviver: reconstructs zero as bigint', testReviverReconstructsZeroBigint);
test('extendedJsonReviver: reconstructs a Uint8Array from hex', testReviverReconstructsUint8Array);
test('extendedJsonReviver: reconstructs an empty Uint8Array', testReviverReconstructsEmptyUint8Array);
test('extendedJsonReviver: passes through plain strings', testReviverPassesThroughPlainString);
test('extendedJsonReviver: passes through non-string values', testReviverPassesThroughNonStringValues);
};
await runTests();

234
test/parse-template.test.ts Normal file
View File

@@ -0,0 +1,234 @@
import { expect, test } from 'vitest';
import type { XOTemplate } from '@xo-cash/types';
import { p2pkhTemplate } from '@xo-cash/templates';
import { TemplateInvalidError, parseTemplate, serializeTemplate } from '../source/index.ts';
/**
* Tests that parseTemplate accepts a valid XOTemplate object and returns it unchanged.
*/
const testParseTemplateAcceptsValidTemplate = (): void => {
// Parse the template
const parsedObjectTemplate: XOTemplate = parseTemplate(p2pkhTemplate);
// Parse the template from a string
const parsedStringTemplate = parseTemplate(serializeTemplate(p2pkhTemplate));
// The parsed template should be equal to the original template
expect(parsedObjectTemplate).toEqual(p2pkhTemplate);
expect(parsedStringTemplate).toEqual(p2pkhTemplate);
};
/**
* Tests that parseTemplate accepts a simple bigint value in a satoshi field and preserves it
* through the serialize/deserialize round-trip.
*/
const testParseTemplateAcceptsBigintSatoshis = (): void => {
// Get the first output key
const firstOutputKey = Object.keys(p2pkhTemplate.outputs)[0];
if (firstOutputKey === undefined) {
throw new Error('p2pkhTemplate has no outputs, test fixture is invalid');
}
const firstOutput = p2pkhTemplate.outputs[firstOutputKey];
if (firstOutput === undefined) {
throw new Error('p2pkhTemplate first output is undefined, test fixture is invalid');
}
const templateWithBigint = {
...p2pkhTemplate,
outputs: {
...p2pkhTemplate.outputs,
[firstOutputKey]: { ...firstOutput, valueSatoshis: 1000n },
},
};
// Parse the template
const parsedTemplate = parseTemplate(templateWithBigint);
// The parsed template should have the bigint value
expect(parsedTemplate.outputs[firstOutputKey]!.valueSatoshis).toBe(1000n);
};
/**
* Tests that parseTemplate preserves bigint precision for values exceeding Number.MAX_SAFE_INTEGER.
* Uses 2^54 + 1, so any naive Number conversion in the serialize/deserialize round-trip would produce the wrong value
* but BigInt conversion would preserve the exact value.
*/
const testParseTemplatePreservesBigintPrecisionBeyondMaxSafeInteger = (): void => {
// Get the first output key
const firstOutputKey = Object.keys(p2pkhTemplate.outputs)[0];
if (firstOutputKey === undefined) {
throw new Error('p2pkhTemplate has no outputs, test fixture is invalid');
}
const firstOutput = p2pkhTemplate.outputs[firstOutputKey];
if (firstOutput === undefined) {
throw new Error('p2pkhTemplate first output is undefined, test fixture is invalid');
}
const templateWithBigint = {
...p2pkhTemplate,
outputs: {
...p2pkhTemplate.outputs,
[firstOutputKey]: { ...firstOutput, valueSatoshis: 18014398509481985n },
},
};
// Parse the template
const parsedTemplate = parseTemplate(templateWithBigint);
// The parsed template should have the exact bigint value
expect(parsedTemplate.outputs[firstOutputKey]!.valueSatoshis).toBe(18014398509481985n);
};
/**
* Tests that parseTemplate throws TemplateInvalidError listing all missing required fields
*/
const testParseTemplateThrowsOnMissingRequiredFields = (): void => {
let thrownError: unknown;
try {
parseTemplate({} as XOTemplate);
} catch (caughtError) {
thrownError = caughtError;
}
const isTemplateInvalidError = thrownError instanceof TemplateInvalidError;
expect(isTemplateInvalidError).toBe(true);
const expectedMessage =
'Template invalid: \n'
+ '- name: expected string, received undefined\n'
+ '- description: expected string, received undefined\n'
+ '- $schema: expected string, received undefined\n'
+ '- supported: expected array, received undefined\n'
+ '- roles: expected record, received undefined\n'
+ '- start: expected array, received undefined\n'
+ '- actions: expected record, received undefined\n'
+ '- inputs: expected record, received undefined\n'
+ '- outputs: expected record, received undefined\n'
+ '- lockingScripts: expected record, received undefined\n'
+ '- scripts: expected record, received undefined';
expect((thrownError as TemplateInvalidError).message).toBe(expectedMessage);
};
/**
* Tests that parseTemplate throws TemplateInvalidError naming the exact field path when a
* single required field is removed from a valid template.
*/
const testParseTemplateThrowsOnMissingScriptsField = (): void => {
// Set scripts to undefined to test the field path for a missing record
const templateWithoutScripts = { ...p2pkhTemplate, scripts: undefined };
let thrownError: unknown;
try {
parseTemplate(templateWithoutScripts);
} catch (caughtError) {
thrownError = caughtError;
}
const isTemplateInvalidError = thrownError instanceof TemplateInvalidError;
expect(isTemplateInvalidError).toBe(true);
const expectedMessage = 'Template invalid: \n' + '- scripts: expected record, received undefined';
expect((thrownError as TemplateInvalidError).message).toBe(expectedMessage);
};
/**
* Tests that parseTemplate throws TemplateInvalidError reporting both field paths for type violations
*/
const testParseTemplateThrowsOnFieldTypeViolations = (): void => {
// Replace version with a number and name with a number to produce two simultaneous type errors.
// @ts-expect-error - version and name are intentionally wrong types for this test case
const templateWithWrongFieldTypes: XOTemplate = { ...p2pkhTemplate, version: 42, name: 42 };
let thrownError: unknown;
try {
parseTemplate(templateWithWrongFieldTypes);
} catch (caughtError) {
thrownError = caughtError;
}
const isTemplateInvalidError = thrownError instanceof TemplateInvalidError;
expect(isTemplateInvalidError).toBe(true);
const expectedMessage = 'Template invalid: \n' + '- name: expected string, received number\n' + '- version: expected string, received number';
expect((thrownError as TemplateInvalidError).message).toBe(expectedMessage);
};
/**
* Tests that parseTemplate throws TemplateInvalidError with the unknown key path for an unrecognized property at the top level
*/
const testParseTemplateThrowsOnUnknownProperties = (): void => {
const templateWithUnknownProperty = { ...p2pkhTemplate, unknownProperty: 'unexpected' };
let thrownError: unknown;
try {
parseTemplate(templateWithUnknownProperty);
} catch (caughtError) {
thrownError = caughtError;
}
const isTemplateInvalidError = thrownError instanceof TemplateInvalidError;
expect(isTemplateInvalidError).toBe(true);
const expectedMessage = 'Template invalid: \n' + '- (root): Unrecognized key: "unknownProperty"';
expect((thrownError as TemplateInvalidError).message).toBe(expectedMessage);
};
/**
* Tests that parseTemplate throws TemplateInvalidError reporting both unrecognized keys for unknown properties nested inside an action definition
*/
const testParseTemplateThrowsOnDeepUnknownProperties = (): void => {
if (p2pkhTemplate.actions.receive === undefined) {
throw new Error('p2pkhTemplate has no "receive" action, test fixture is invalid');
}
const templateWithDeepUnknown = {
...p2pkhTemplate,
actions: {
...p2pkhTemplate.actions,
receive: { ...p2pkhTemplate.actions.receive, new: 42, new2: 'test' },
},
};
let thrownError: unknown;
try {
parseTemplate(templateWithDeepUnknown);
} catch (caughtError) {
thrownError = caughtError;
}
const isTemplateInvalidError = thrownError instanceof TemplateInvalidError;
expect(isTemplateInvalidError).toBe(true);
const expectedMessage = 'Template invalid: \n' + '- actions.receive: Unrecognized keys: "new", "new2"';
expect((thrownError as TemplateInvalidError).message).toBe(expectedMessage);
};
const runTests = async (): Promise<void> => {
test('parseTemplate: accepts a valid template', testParseTemplateAcceptsValidTemplate);
test('parseTemplate: accepts native bigint in satoshi fields', testParseTemplateAcceptsBigintSatoshis);
test('parseTemplate: preserves bigint precision beyond Number.MAX_SAFE_INTEGER', testParseTemplatePreservesBigintPrecisionBeyondMaxSafeInteger);
test('parseTemplate: throws TemplateInvalidError on missing required fields', testParseTemplateThrowsOnMissingRequiredFields);
test('parseTemplate: throws TemplateInvalidError on missing scripts field', testParseTemplateThrowsOnMissingScriptsField);
test('parseTemplate: throws TemplateInvalidError on field type violations', testParseTemplateThrowsOnFieldTypeViolations);
test('parseTemplate: throws TemplateInvalidError on unknown top-level property', testParseTemplateThrowsOnUnknownProperties);
test('parseTemplate: throws TemplateInvalidError on deep unknown properties', testParseTemplateThrowsOnDeepUnknownProperties);
};
await runTests();

48
test/script.test.ts Normal file
View File

@@ -0,0 +1,48 @@
import { expect, test } from 'vitest';
import { isHex } from '@bitauth/libauth';
import { scriptToScriptHash } from '../source/index.ts';
/**
* Tests that scriptToScriptHash produces the correct reversed SHA256 for a standard P2PKH locking script
* and returns a 64 character hex string.
*/
const testScriptHashForP2pkhScript = (): void => {
// Standard P2PKH locking script: OP_DUP OP_HASH160 <20 zero bytes> OP_EQUALVERIFY OP_CHECKSIG
const p2pkhScript = new Uint8Array([ 0x76, 0xa9, 0x14, ...new Uint8Array(20).fill(0), 0x88, 0xac ]);
// Precomputed reversed SHA256 of the above script bytes
const expectedHash = 'acb87996319dca2c2e2afd6c0f7514b18e72e204069718976e1abdc8fcf5de75';
// Generate the script hash
const scriptHash = scriptToScriptHash(p2pkhScript);
// The script hash must match the known-good precomputed value exactly
expect(scriptHash).toBe(expectedHash);
// The script hash is 64 characters long
expect(scriptHash).toHaveLength(64);
// The script hash is a valid hex string
expect(isHex(scriptHash)).toBe(true);
};
/**
* Tests that two different scripts produce different script hashes.
*/
const testScriptHashIsDifferentForDifferentScripts = (): void => {
// Generate the first script hash
const hashA = scriptToScriptHash(new Uint8Array([ 0x01 ]));
// Generate the second script hash
const hashB = scriptToScriptHash(new Uint8Array([ 0x02 ]));
// The different scripts produce different script hashes
expect(hashA).not.toBe(hashB);
};
const runTests = async (): Promise<void> => {
test('scriptToScriptHash: produces reversed SHA256 for a P2PKH locking script', testScriptHashForP2pkhScript);
test('scriptToScriptHash: produces different hashes for different scripts', testScriptHashIsDifferentForDifferentScripts);
};
await runTests();

72
test/templates.test.ts Normal file
View File

@@ -0,0 +1,72 @@
import { expect, test } from 'vitest';
import { isHex } from '@bitauth/libauth';
import { p2pkhTemplate } from '@xo-cash/templates';
import { generateTemplateIdentifier, parseTemplate } from '../source/index.ts';
/**
* Tests that generateTemplateIdentifier returns a valid hex string of 64 characters.
*/
const testGenerateTemplateIdentifierOutputFormat = (): void => {
// Parse the template
const parsedTemplate = parseTemplate(p2pkhTemplate);
// Generate the template identifier
const identifier = generateTemplateIdentifier(parsedTemplate);
// The identifier is 64 characters long
expect(identifier).toHaveLength(64);
// The identifier is a valid hex string
expect(isHex(identifier)).toBe(true);
};
/**
* Tests that generateTemplateIdentifier is deterministic.
*/
const testGenerateTemplateIdentifierIsDeterministic = (): void => {
// Parse the template
const parsedTemplate = parseTemplate(p2pkhTemplate);
// Generate the template identifier once
const firstTemplateIdentifier = generateTemplateIdentifier(parsedTemplate);
// Generate the template identifier again
const secondTemplateIdentifier = generateTemplateIdentifier(parsedTemplate);
// The same template produces the same identifier
expect(firstTemplateIdentifier).toBe(secondTemplateIdentifier);
};
/**
* Tests that two templates with different content produce different identifiers.
*/
const testGenerateTemplateIdentifierDiffersForDifferentTemplates = (): void => {
// Parse the original template
const parsedTemplate = parseTemplate(p2pkhTemplate);
// Generate the original template identifier
const originalTemplateIdentifier = generateTemplateIdentifier(parsedTemplate);
// Create a modified template with a different name
const modifiedTemplate = { ...p2pkhTemplate, name: `${p2pkhTemplate.name} (modified)` };
// Parse the modified template
const parsedModifiedTemplate = parseTemplate(modifiedTemplate);
// Generate the modified template identifier
const modifiedTemplateIdentifier = generateTemplateIdentifier(parsedModifiedTemplate);
// The original and modified templates produce different identifiers
expect(originalTemplateIdentifier).not.toBe(modifiedTemplateIdentifier);
};
const runTests = async (): Promise<void> => {
test('generateTemplateIdentifier: returns a 64-character valid hex string', testGenerateTemplateIdentifierOutputFormat);
test('generateTemplateIdentifier: is deterministic', testGenerateTemplateIdentifierIsDeterministic);
test(
'generateTemplateIdentifier: produces different identifiers for different templates',
testGenerateTemplateIdentifierDiffersForDifferentTemplates,
);
};
await runTests();