Refactor, Added documentation, moved from avj-cli to avj, update script and added tests
This commit is contained in:
@@ -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
6
.gitignore
vendored
@@ -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
3
.gitlab-ci.yml
Normal 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
|
||||
52
README.md
52
README.md
@@ -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)
|
||||
|
||||
@@ -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
12208
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
14
package.json
14
package.json
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
71
scripts/generate-schema.ts
Normal file
71
scripts/generate-schema.ts
Normal 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);
|
||||
}
|
||||
@@ -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
76
source/extended-json.ts
Normal 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;
|
||||
};
|
||||
@@ -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';
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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 ?? []);
|
||||
};
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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
64
source/template/errors.ts
Normal 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';
|
||||
}
|
||||
}
|
||||
22
source/template/identifier.ts
Normal file
22
source/template/identifier.ts
Normal 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
37
source/template/parser.ts
Normal 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
895
source/template/schemas.ts
Normal 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();
|
||||
41
source/template/serialization.ts
Normal file
41
source/template/serialization.ts
Normal 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);
|
||||
}
|
||||
};
|
||||
@@ -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))));
|
||||
};
|
||||
90
test/extended-json.test.ts
Normal file
90
test/extended-json.test.ts
Normal 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
234
test/parse-template.test.ts
Normal 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
48
test/script.test.ts
Normal 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
72
test/templates.test.ts
Normal 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();
|
||||
Reference in New Issue
Block a user