Merge branch 'development' into event-emitter
This commit is contained in:
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"version": "0.1",
|
||||
"import": ["@generalprotocols/cspell-dictionary/cspell.json"],
|
||||
"words": ["nonfungible", "lockscript", "bivariance"],
|
||||
"words": ["nonfungible", "lockscript", "cashassembly", "checksigverify", "inputindex", "utxovalue", "bivariance"],
|
||||
"ignorePaths": ["source/template/xo-template.schema.json"]
|
||||
}
|
||||
|
||||
Generated
+716
-554
File diff suppressed because it is too large
Load Diff
+7
-10
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@xo-cash/utils",
|
||||
"version": "0.0.2",
|
||||
"version": "0.0.3",
|
||||
"description": "XO Cash utilities",
|
||||
"type": "module",
|
||||
"types": "./dist/index.d.mts",
|
||||
@@ -44,23 +44,21 @@
|
||||
],
|
||||
"dependencies": {
|
||||
"@bitauth/libauth": "^3.1.0-next.8",
|
||||
"@xo-cash/types": "0.0.3",
|
||||
"@xo-cash/primitives": "0.0.2",
|
||||
"@xo-cash/types": "0.0.4",
|
||||
"zod": "^4.3.6"
|
||||
},
|
||||
"overrides": {
|
||||
"echarts": "6.1.0"
|
||||
"echarts": "6.1.0",
|
||||
"minimatch": "10.2.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",
|
||||
"@xo-cash/eslint-config": "1.0.2",
|
||||
"@xo-cash/templates": "0.0.3",
|
||||
"cspell": "^9.6.0",
|
||||
"eslint": "^9.39.2",
|
||||
"prettier": "^3.6.2",
|
||||
@@ -68,7 +66,6 @@
|
||||
"typedoc": "^0.28.16",
|
||||
"typedoc-plugin-coverage": "^4.0.2",
|
||||
"typescript": "^5.3.2",
|
||||
"typescript-eslint": "^8.53.1",
|
||||
"vitest": "^4.0.17"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
import { bigIntToVmNumber, utf8ToBin } from '@bitauth/libauth';
|
||||
import { CashAssemblyNumberNotSafeIntegerError, CashAssemblyUnsupportedValueTypeError } from './errors.ts';
|
||||
|
||||
/**
|
||||
* Converts a value into bytes representation.
|
||||
*
|
||||
* @param {unknown} value - Value to encode, should be one of: Uint8Array, bigint, boolean, string, or a safe integer number.
|
||||
* @param {string} valueIdentifier - Identifier used in error messages.
|
||||
* @returns {Uint8Array} Bytes representation of the value.
|
||||
* @throws {@link CashAssemblyNumberNotSafeIntegerError} When a number is not a safe integer.
|
||||
* @throws {@link CashAssemblyUnsupportedValueTypeError} When the value type cannot be resolved.
|
||||
*/
|
||||
export const convertValueToBytes = (value: unknown, valueIdentifier: string): Uint8Array => {
|
||||
if (value instanceof Uint8Array) {
|
||||
return value;
|
||||
}
|
||||
|
||||
if (typeof value === 'bigint') {
|
||||
return bigIntToVmNumber(value);
|
||||
}
|
||||
|
||||
if (typeof value === 'boolean') {
|
||||
// The BCH VM treats an empty byte array as false and any nonempty byte array as true.
|
||||
return new Uint8Array(value ? [ 1 ] : []);
|
||||
}
|
||||
|
||||
if (typeof value === 'string') {
|
||||
return utf8ToBin(value);
|
||||
}
|
||||
|
||||
if (typeof value === 'number') {
|
||||
if (Number.isSafeInteger(value) === true) {
|
||||
return bigIntToVmNumber(BigInt(value));
|
||||
}
|
||||
|
||||
throw new CashAssemblyNumberNotSafeIntegerError(valueIdentifier, value);
|
||||
}
|
||||
|
||||
throw new CashAssemblyUnsupportedValueTypeError(valueIdentifier, typeof value);
|
||||
};
|
||||
@@ -0,0 +1,61 @@
|
||||
/**
|
||||
* Detects whether a string is a pure CashAssembly expression.
|
||||
*
|
||||
* CashAssembly expressions look like `$(<variable>)` or `$(<a> <b>)`. This pattern checks
|
||||
* that the entire string is one such expression and nothing else. It will not match if
|
||||
* there is other text surrounding the expression.
|
||||
*
|
||||
* For example:
|
||||
* `$(<fee>)` matches (a full expression)
|
||||
* `OP_DUP $(<fee>)` does not match (extra text before it)
|
||||
* `$()` does not match (empty expression)
|
||||
*/
|
||||
export const CASHASSEMBLY_EXPRESSION_PATTERN = /^\$\([^)]+\)$/;
|
||||
|
||||
/**
|
||||
* Finds all CashAssembly evaluations embedded in a larger string.
|
||||
*
|
||||
* An evaluation looks like `$(...)`, for example `$(<fee>)` or `$(<a> <b>)`. This pattern
|
||||
* locates every occurrence in the input and returns them all (global flag `g`).
|
||||
* Empty evaluations `$()` are intentionally excluded because they reference no variables.
|
||||
*
|
||||
* For example, scanning `"OP_DUP <$(<pubkeyHash>)> OP_HASH160 $(<fee>)"` would return
|
||||
* `['$(<pubkeyHash>)', '$(<fee>)']`.
|
||||
*/
|
||||
export const CASHASSEMBLY_EVALUATION_PATTERN = /\$\([^)]+\)/g;
|
||||
|
||||
/**
|
||||
* Extracts variable names from angle-bracket references inside a CashAssembly evaluation.
|
||||
*
|
||||
* Inside an evaluation like `$(<pubkeyHash> <fee>)`, variables are referenced as `<name>`.
|
||||
* This pattern captures the name between the brackets. The global flag `g` allows iterating
|
||||
* over every variable reference in a single evaluation string.
|
||||
*
|
||||
* For example, running this against `$(<pubkeyHash> <fee>)` would return the variable names
|
||||
* `["pubkeyHash", "fee"]`.
|
||||
*/
|
||||
export const CASHASSEMBLY_VARIABLE_PATTERN = /<([^>]+)>/g;
|
||||
|
||||
/**
|
||||
* Identifies CashAssembly literal tokens that appear inside angle-bracket push statements.
|
||||
*
|
||||
* Inside an evaluation, not everything between `<` and `>` is a variable name. Literals are
|
||||
* also valid push contents: numeric literals (e.g. `<0>`, `<32>`), hex literals (`<0x02>`),
|
||||
* binary literals (`<0b1010>`), and string literals (`<"minting">`, `<'hello'>`). This pattern
|
||||
* matches any captured token that starts with a digit or a quote character.
|
||||
*/
|
||||
export const CASHASSEMBLY_LITERAL_TOKEN_PATTERN = /^[0-9"']/;
|
||||
|
||||
/**
|
||||
* Matches a single dot variable method reference inside an angle-bracket identifier.
|
||||
*
|
||||
* Used to detect primitive method references such as `expiry.toIso8601`.
|
||||
*
|
||||
* For example:
|
||||
* `expiry.toIso8601` matches (base `expiry`, method `toIso8601`)
|
||||
* `requestedSatoshis` does not match (no method)
|
||||
* `key.schnorr_signature.all_outputs` does not match (more than one dot)
|
||||
* `key.public_key` matches the pattern shape but it is only resolved
|
||||
* when `hint` maps to a primitive
|
||||
*/
|
||||
export const CASHASSEMBLY_VARIABLE_METHOD_REFERENCE_PATTERN = /^([^.]+)\.([^.]+)$/;
|
||||
@@ -0,0 +1,85 @@
|
||||
/* eslint-disable max-classes-per-file */
|
||||
|
||||
/**
|
||||
* Error thrown when a required variable is missing.
|
||||
*/
|
||||
export class CashAssemblyRequiredVariableMissingError extends Error {
|
||||
constructor(variableNames?: string[]) {
|
||||
const defaultMessage = 'Missing required variable';
|
||||
if (variableNames !== undefined && variableNames.length > 0) {
|
||||
super(`${defaultMessage}: variableNames [${variableNames.join(', ')}]`);
|
||||
} else {
|
||||
super(defaultMessage);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Error thrown when cash assembly compilation fails.
|
||||
*/
|
||||
export class CashAssemblyCompilationFailedError extends Error {
|
||||
constructor(message?: string) {
|
||||
const defaultMessage = 'Cash assembly compilation failed';
|
||||
super(message ? `${defaultMessage}: ${message}` : defaultMessage);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Error thrown when a variable's runtime type does not match the type required for compilation.
|
||||
*/
|
||||
export class CashAssemblyVariableTypeMismatchError extends Error {
|
||||
constructor(variableKey: string, expectedType: string, actualType: string) {
|
||||
const defaultMessage = 'Variable type mismatch';
|
||||
super(`${defaultMessage}: variableKey "${variableKey}", expected ${expectedType}, got ${actualType}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Error thrown when a supported primitive hint does not expose the requested method.
|
||||
*/
|
||||
export class CashAssemblyPrimitiveMethodMissingError extends Error {
|
||||
constructor(identifier: string, methodName: string, hint: string) {
|
||||
const defaultMessage = 'CashAssembly primitive method does not exist';
|
||||
super(`${defaultMessage}: identifier "${identifier}", methodName "${methodName}", hint "${hint}"`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Error thrown when a value cannot be resolved as bytes.
|
||||
*/
|
||||
export class CashAssemblyUnsupportedValueTypeError extends Error {
|
||||
constructor(identifier: string, returnedType: string) {
|
||||
const defaultMessage = 'CashAssembly value type is unsupported for byte resolution';
|
||||
super(`${defaultMessage}: identifier "${identifier}", returnedType "${returnedType}"`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Error thrown when a number cannot be safely encoded as a CashAssembly VM number.
|
||||
*/
|
||||
export class CashAssemblyNumberNotSafeIntegerError extends Error {
|
||||
constructor(identifier: string, value: number) {
|
||||
const defaultMessage = 'CashAssembly number is not a safe integer';
|
||||
super(`${defaultMessage}: identifier "${identifier}", got ${String(value)}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Error thrown when a supported primitive is selected but its value is missing when provided in the variables map.
|
||||
*/
|
||||
export class CashAssemblyPrimitiveVariableMissingError extends Error {
|
||||
constructor(identifier: string, variableName: string) {
|
||||
const defaultMessage = 'CashAssembly primitive variable is missing from the variables map';
|
||||
super(`${defaultMessage}: identifier "${identifier}", variableName "${variableName}"`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Error thrown when compiled evaluation bytes cannot be decoded as a VM number.
|
||||
*/
|
||||
export class CashAssemblyVmNumberDecodeError extends Error {
|
||||
constructor(reason: string) {
|
||||
const defaultMessage = 'CashAssembly evaluation could not be decoded as a VM number';
|
||||
super(`${defaultMessage}: ${reason}`);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,353 @@
|
||||
/**
|
||||
* Utilities for parsing, extracting, and compiling CashAssembly expressions.
|
||||
*
|
||||
* CashAssembly is the scripting language used by Bitauth templates to describe Bitcoin Cash
|
||||
* locking and unlocking scripts.
|
||||
*
|
||||
* ## Syntax (CashAssembly)
|
||||
*
|
||||
* `<expression>` is a push statement. Compiles the contents and pushes the result onto the VM stack.
|
||||
* `<someKey.public_key>` pushes the 33 byte compressed public key. `<1>` pushes the integer 1.
|
||||
*
|
||||
* `$(<expression>)` is an evaluation. Runs the inner script in the VM and inserts the top stack
|
||||
* item as VM bytecode.
|
||||
* `$(<someKey.public_key> OP_HASH160)` inserts the HASH160 of the public key.
|
||||
*
|
||||
* `<$(<expression>)>` is a push of an evaluation result. It evaluates first then pushes.
|
||||
* For a P2PKH locking script example see
|
||||
* `OP_DUP OP_HASH160 <$(<someKey.public_key> OP_HASH160)> OP_EQUALVERIFY OP_CHECKSIG`.
|
||||
*
|
||||
* `variableId.operation` is a variable with a compiler resolved operation. `someKey.public_key`
|
||||
* produces the public key bytes. `someKey.schnorr_signature.all_outputs` produces a Schnorr
|
||||
* signature.
|
||||
*
|
||||
* Opcodes (`OP_DUP`, `OP_HASH160`, and similar) are inserted as their bytecode equivalent directly.
|
||||
*
|
||||
* ## Name resolution priority (CashAssembly)
|
||||
*
|
||||
* When the compiler encounters an identifier it resolves it in this order.
|
||||
* 1. Opcode always wins. Naming a variable or script `OP_ADD` will not shadow it.
|
||||
* 2. Variable shadows scripts of the same name.
|
||||
* 3. Script is the script's bytecode.
|
||||
*
|
||||
* ## Resolution Order (CashAssembly + Primitive Method Resolution)
|
||||
*
|
||||
* Supported `<base.method>` pushes are resolved to bytes before CashAssembly compiles.
|
||||
* Inside CashAssembly the order is Opcode then Variable then Script.
|
||||
*/
|
||||
import type { CompilerBch } from '@bitauth/libauth';
|
||||
import { binToHex, binToUtf8, createCompilerBch, vmNumberToBigInt } from '@bitauth/libauth';
|
||||
import type { XOInvitationVariableValue, XOTemplate } from '@xo-cash/types';
|
||||
import {
|
||||
CashAssemblyCompilationFailedError,
|
||||
CashAssemblyVariableTypeMismatchError,
|
||||
CashAssemblyRequiredVariableMissingError,
|
||||
CashAssemblyVmNumberDecodeError,
|
||||
} from './errors.ts';
|
||||
import {
|
||||
CASHASSEMBLY_EVALUATION_PATTERN,
|
||||
CASHASSEMBLY_EXPRESSION_PATTERN,
|
||||
CASHASSEMBLY_LITERAL_TOKEN_PATTERN,
|
||||
CASHASSEMBLY_VARIABLE_PATTERN,
|
||||
} from './defaults.ts';
|
||||
import { convertValueToBytes } from './bytes.ts';
|
||||
import { resolvePrimitiveMethodBytes } from './primitive-evaluations.ts';
|
||||
|
||||
/**
|
||||
* Supported decode modes for compiled CashAssembly evaluation bytes.
|
||||
*/
|
||||
export type CompiledCashAssemblyDecodeMode = 'utf8' | 'hex' | 'boolean' | 'bigint' | 'uint8array';
|
||||
|
||||
/**
|
||||
* Parameters for compiling CashAssembly string.
|
||||
*/
|
||||
export type CompileCashAssemblyStringParameters = {
|
||||
|
||||
/**
|
||||
* Text that may embed CashAssembly evaluations such as `$(<fee>)` or `$(<expiry.toIso8601>)`.
|
||||
*/
|
||||
cashAssemblyText: string;
|
||||
|
||||
/**
|
||||
* Used for both primitive method evaluations and normal CashAssembly compilation.
|
||||
*/
|
||||
variables: Record<string, XOInvitationVariableValue | Uint8Array>;
|
||||
|
||||
/**
|
||||
* The mode to decode compiled evaluation bytes into a string.
|
||||
*/
|
||||
evaluationDecodeMode?: CompiledCashAssemblyDecodeMode;
|
||||
|
||||
/**
|
||||
* Optional template variable definitions. When provided, each `<name.method>` push whose `hint`
|
||||
* maps to a supported primitive class is resolved to bytes before CashAssembly compilation.
|
||||
*/
|
||||
templateVariables?: XOTemplate['variables'];
|
||||
};
|
||||
|
||||
/**
|
||||
* Checks if the expression is a CashAssembly expression.
|
||||
*
|
||||
* @param {unknown} expression - The expression to check.
|
||||
* @returns {boolean} True if the expression is a CashAssembly expression, false otherwise.
|
||||
*/
|
||||
export const isCashAssemblyExpression = (expression: unknown): boolean => {
|
||||
return typeof expression === 'string' && CASHASSEMBLY_EXPRESSION_PATTERN.test(expression);
|
||||
};
|
||||
|
||||
/**
|
||||
* Extracts all CashAssembly evaluations (i.e., substrings like $(...)) from the input text.
|
||||
*
|
||||
* @param {string} text - The input string to scan for CashAssembly evaluations.
|
||||
* @returns {string[]} An array of evaluation strings found in the input.
|
||||
*
|
||||
* @example
|
||||
* extractCashAssemblyEvaluations("OP_DUP <$(<foo>)> OP_HASH160 $(<bar>)");
|
||||
* // returns ['$(<foo>)', '$(<bar>)']
|
||||
*/
|
||||
export const extractCashAssemblyEvaluations = (text: string): string[] => {
|
||||
return text.match(CASHASSEMBLY_EVALUATION_PATTERN) ?? [];
|
||||
};
|
||||
|
||||
/**
|
||||
* Returns the segment of `identifier` before the first `.`.
|
||||
*
|
||||
* When there is no `.`, returns `identifier` unchanged.
|
||||
* Multi segment identifiers such as `foo.bar.baz` resolve to `foo`.
|
||||
*
|
||||
* @param {string} identifier - Identifier that may contain a dot.
|
||||
* @returns {string} The base name before the first `.`.
|
||||
*/
|
||||
const resolveIdentifierBaseName = (identifier: string): string => {
|
||||
const firstDotIndex = identifier.indexOf('.');
|
||||
|
||||
if (firstDotIndex === -1) {
|
||||
return identifier;
|
||||
}
|
||||
|
||||
return identifier.slice(0, firstDotIndex);
|
||||
};
|
||||
|
||||
/**
|
||||
* Extracts unique variable identifiers enclosed in angle brackets from each evaluation string.
|
||||
*
|
||||
* CashAssembly literal tokens such as hex bytes, numbers, and quoted strings are excluded via
|
||||
* {@link CASHASSEMBLY_LITERAL_TOKEN_PATTERN}. For example, `<0x02>` and `<"minting">` are not returned.
|
||||
*
|
||||
* @param {string[]} evaluations - An array of evaluation strings from which to extract variable names.
|
||||
* @returns {string[]} An array of variable names.
|
||||
*/
|
||||
export const extractVariablesFromEvaluations = (evaluations: string[]): string[] => {
|
||||
const uniqueVariables = new Set<string>();
|
||||
|
||||
for (const evaluation of evaluations) {
|
||||
for (const [ , extractedIdentifier ] of evaluation.matchAll(CASHASSEMBLY_VARIABLE_PATTERN)) {
|
||||
if (CASHASSEMBLY_LITERAL_TOKEN_PATTERN.test(extractedIdentifier)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
uniqueVariables.add(extractedIdentifier);
|
||||
}
|
||||
}
|
||||
|
||||
return [ ...uniqueVariables ];
|
||||
};
|
||||
|
||||
/**
|
||||
* Decodes compiled CashAssembly evaluation bytes into a string representation.
|
||||
*
|
||||
* 'evaluationDecodeMode' determines how the evaluation bytes are interpreted and presented.
|
||||
* Use `bigint` for numeric values like satoshis, `utf8` for text labels, `hex` for binary data
|
||||
* such as hashes, and `boolean` to represent boolean values.
|
||||
*
|
||||
* @param {Uint8Array} compiledResult - The compiled evaluation bytecode.
|
||||
* @param {CompiledCashAssemblyDecodeMode} [evaluationDecodeMode='utf8'] - The decode mode used to convert
|
||||
* bytes to text.
|
||||
* @returns {string} The decoded value as a string suitable for inline replacement.
|
||||
* @throws {@link CashAssemblyVmNumberDecodeError} When `evaluationDecodeMode` is `bigint` and the bytes are not a VM number.
|
||||
*/
|
||||
export const decodeCompiledCashAssemblyEvaluation = (
|
||||
compiledResult: Uint8Array,
|
||||
evaluationDecodeMode: CompiledCashAssemblyDecodeMode = 'utf8',
|
||||
): string => {
|
||||
// Converts the byte array to a string.
|
||||
if (evaluationDecodeMode === 'uint8array') {
|
||||
return String(compiledResult);
|
||||
}
|
||||
|
||||
// Converts the byte array to a boolean string, converting the evaluation result into a true or a false.
|
||||
if (evaluationDecodeMode === 'boolean') {
|
||||
return compiledResult.length === 0 ? 'false' : 'true';
|
||||
}
|
||||
|
||||
// Converts the byte array to a hex string.
|
||||
if (evaluationDecodeMode === 'hex') {
|
||||
return binToHex(compiledResult);
|
||||
}
|
||||
|
||||
// Converts the byte array to a bigint string.
|
||||
if (evaluationDecodeMode === 'bigint') {
|
||||
const vmNumberResult = vmNumberToBigInt(compiledResult);
|
||||
|
||||
if (typeof vmNumberResult === 'bigint') {
|
||||
return vmNumberResult.toString();
|
||||
}
|
||||
|
||||
throw new CashAssemblyVmNumberDecodeError(vmNumberResult);
|
||||
}
|
||||
|
||||
// Converts the byte array to a utf8 string.
|
||||
return binToUtf8(compiledResult);
|
||||
};
|
||||
|
||||
/**
|
||||
* Generates bytecode for a specific CashAssembly evaluation using given variable values and a prepared compiler.
|
||||
*
|
||||
* @param {CompilerBch} compiler - The libauth compiler from {@link compileCashAssemblyEvaluations}.
|
||||
* @param {string} evaluation - The specific evaluation string to compile.
|
||||
* @param {Record<string, Uint8Array>} variables - A record mapping variable names to their values.
|
||||
* @returns {Uint8Array} The compiled bytecode.
|
||||
* @throws {@link CashAssemblyRequiredVariableMissingError} If a required variable is not present.
|
||||
* @throws {@link CashAssemblyVariableTypeMismatchError} If a variable value is not a Uint8Array.
|
||||
* @throws {@link CashAssemblyCompilationFailedError} If libauth compilation fails.
|
||||
*/
|
||||
export const generateCashAssemblyBytecode = (compiler: CompilerBch, evaluation: string, variables: Record<string, Uint8Array>): Uint8Array => {
|
||||
const variableNames = extractVariablesFromEvaluations([ evaluation ]);
|
||||
|
||||
// Validate that all required variables are provided
|
||||
const missingVariables = variableNames.filter((name: string) => !Object.hasOwn(variables, name));
|
||||
if (missingVariables.length > 0) {
|
||||
throw new CashAssemblyRequiredVariableMissingError(missingVariables);
|
||||
}
|
||||
|
||||
// Construct the bytecode object using the keys in variableNames, mapping to the values in variables
|
||||
const bytecode: Record<string, Uint8Array> = {};
|
||||
for (const variableName of variableNames) {
|
||||
const value = variables[variableName];
|
||||
|
||||
// By the time execution reaches this point, the value should be a Uint8Array.
|
||||
if (!(value instanceof Uint8Array)) {
|
||||
throw new CashAssemblyVariableTypeMismatchError(variableName, 'Uint8Array', typeof value);
|
||||
}
|
||||
|
||||
bytecode[variableName] = value;
|
||||
}
|
||||
|
||||
const compiledBytecode = compiler.generateBytecode({
|
||||
data: { bytecode },
|
||||
scriptId: evaluation,
|
||||
});
|
||||
|
||||
if (!compiledBytecode.success) {
|
||||
// Collapse libauth's full errors list into one string because
|
||||
// CashAssemblyCompilationFailedError only carries a single message.
|
||||
let compilationFailureMessage = 'unknown compilation failure';
|
||||
|
||||
if ('errors' in compiledBytecode && compiledBytecode.errors.length > 0) {
|
||||
compilationFailureMessage = compiledBytecode.errors.map((compilationError) => compilationError.error).join('; ');
|
||||
}
|
||||
|
||||
throw new CashAssemblyCompilationFailedError(compilationFailureMessage);
|
||||
}
|
||||
|
||||
return compiledBytecode.bytecode;
|
||||
};
|
||||
|
||||
/**
|
||||
* Prepares a compiler for the provided CashAssembly evaluations, setting required variables as 'WalletData'.
|
||||
*
|
||||
* @param {string[]} evaluations - Array of evaluation strings (e.g., ['$(<var1>)', '$(<var2> <var3>)']).
|
||||
* @returns {CompilerBch} A Libauth compiler instance for use with these evaluations.
|
||||
*/
|
||||
export const compileCashAssemblyEvaluations = (evaluations: string[]): CompilerBch => {
|
||||
// Create a scripts object where each key is the evaluation and the value is also the evaluation
|
||||
const scripts: Record<string, string> = {};
|
||||
for (const evaluation of evaluations) {
|
||||
scripts[evaluation] = evaluation;
|
||||
}
|
||||
|
||||
// Get the variable names from the evaluations.
|
||||
const variableNames = extractVariablesFromEvaluations(evaluations);
|
||||
|
||||
// Register each base name once. Dotted pushes share one WalletData entry under the base name.
|
||||
const variables: Record<string, { type: 'WalletData' }> = {};
|
||||
for (const variableName of variableNames) {
|
||||
variables[resolveIdentifierBaseName(variableName)] = { type: 'WalletData' as const };
|
||||
}
|
||||
|
||||
// Create the libauth compiler.
|
||||
const compiler = createCompilerBch({
|
||||
scripts,
|
||||
variables,
|
||||
});
|
||||
|
||||
return compiler;
|
||||
};
|
||||
|
||||
/**
|
||||
* Compiles all CashAssembly evaluations in a text string and replaces each evaluation
|
||||
* with a decoded string representation.
|
||||
*
|
||||
* @param {CompileCashAssemblyStringParameters} parameters - Parameters for compiling the CashAssembly string.
|
||||
* @param {string} parameters.cashAssemblyText - The string with CashAssembly evaluations.
|
||||
* @param {Record<string, XOInvitationVariableValue | Uint8Array>} parameters.variables - Object mapping
|
||||
* variable names to values for compilation.
|
||||
* @param {CompiledCashAssemblyDecodeMode} [parameters.evaluationDecodeMode='utf8'] - The decode mode used
|
||||
* after each evaluation is compiled. See {@link decodeCompiledCashAssemblyEvaluation}.
|
||||
* @param {XOTemplate['variables']} [parameters.templateVariables] - Optional template variable definitions
|
||||
* used to resolve supported `<name.method>` pushes via each variable's `hint`.
|
||||
* @returns {string} Compiled text with all evaluations replaced by decoded string values.
|
||||
* @throws {@link CashAssemblyRequiredVariableMissingError} When a required variable is not present in the variables map.
|
||||
* @throws {@link CashAssemblyPrimitiveMethodMissingError} When a supported primitive hint has an unknown method.
|
||||
* @throws {@link CashAssemblyPrimitiveVariableMissingError} When a supported primitive method is missing its runtime value.
|
||||
* @throws {@link CashAssemblyUnsupportedValueTypeError} When a primitive method return type cannot be embedded as bytes.
|
||||
* @throws {@link CashAssemblyNumberNotSafeIntegerError} When a number variable is not a safe integer.
|
||||
* @throws {@link CashAssemblyVmNumberDecodeError} When `evaluationDecodeMode` is `bigint` and an evaluation is not a VM number.
|
||||
*/
|
||||
export const compileCashAssemblyString = (parameters: CompileCashAssemblyStringParameters): string => {
|
||||
const { cashAssemblyText, variables, evaluationDecodeMode = 'utf8', templateVariables } = parameters;
|
||||
|
||||
return cashAssemblyText.replace(CASHASSEMBLY_EVALUATION_PATTERN, (evaluation) => {
|
||||
// Extract variable identifiers required by the current evaluation.
|
||||
const variableNames = extractVariablesFromEvaluations([ evaluation ]);
|
||||
|
||||
const primitiveMethodBytes = resolvePrimitiveMethodBytes({
|
||||
identifiers: variableNames,
|
||||
templateVariables,
|
||||
variables,
|
||||
});
|
||||
|
||||
// Prefer resolved method bytes. Fall back to converting the raw variable value.
|
||||
const missingVariables = variableNames.filter((variableName) => {
|
||||
if (Object.hasOwn(primitiveMethodBytes, variableName) === true) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return Object.hasOwn(variables, variableName) === false;
|
||||
});
|
||||
|
||||
if (missingVariables.length > 0) {
|
||||
throw new CashAssemblyRequiredVariableMissingError(missingVariables);
|
||||
}
|
||||
|
||||
// Convert each variable to its bytes before compilation.
|
||||
const variableBytes: Record<string, Uint8Array> = {};
|
||||
for (const variableName of variableNames) {
|
||||
if (Object.hasOwn(primitiveMethodBytes, variableName) === true) {
|
||||
variableBytes[variableName] = primitiveMethodBytes[variableName];
|
||||
continue;
|
||||
}
|
||||
|
||||
variableBytes[variableName] = convertValueToBytes(variables[variableName], variableName);
|
||||
}
|
||||
|
||||
// Compile the evaluation in isolation.
|
||||
const compiler = compileCashAssemblyEvaluations([ evaluation ]);
|
||||
|
||||
// Generate the bytes for the evaluation.
|
||||
const compilationResult: Uint8Array = generateCashAssemblyBytecode(compiler, evaluation, variableBytes);
|
||||
|
||||
// Decode the bytes into a string as per the decode mode.
|
||||
return decodeCompiledCashAssemblyEvaluation(compilationResult, evaluationDecodeMode);
|
||||
});
|
||||
};
|
||||
@@ -0,0 +1,5 @@
|
||||
export * from './evaluations.ts';
|
||||
export * from './primitive-evaluations.ts';
|
||||
export * from './bytes.ts';
|
||||
export * from './defaults.ts';
|
||||
export * from './errors.ts';
|
||||
@@ -0,0 +1,216 @@
|
||||
import {
|
||||
FungibleTokenAmount,
|
||||
NFTCommitment,
|
||||
PublicKey,
|
||||
Satoshis,
|
||||
SchnorrSignature,
|
||||
TemplateIdentifier,
|
||||
Timestamp,
|
||||
TokenCategory,
|
||||
TransactionHash,
|
||||
} from '@xo-cash/primitives';
|
||||
import { XOTemplatePrimitiveTypes } from '@xo-cash/types';
|
||||
import type { XOTemplate, XOTemplatePrimitiveType } from '@xo-cash/types';
|
||||
import { CashAssemblyPrimitiveMethodMissingError, CashAssemblyPrimitiveVariableMissingError } from './errors.ts';
|
||||
import { CASHASSEMBLY_VARIABLE_METHOD_REFERENCE_PATTERN } from './defaults.ts';
|
||||
import { convertValueToBytes } from './bytes.ts';
|
||||
|
||||
/**
|
||||
* Template hint values mapped to a primitive class for resolving primitive method evaluations.
|
||||
* Keys are values from XOTemplatePrimitiveTypes.
|
||||
*/
|
||||
const PRIMITIVE_BY_TEMPLATE_HINT = {
|
||||
[XOTemplatePrimitiveTypes.FUNGIBLE_TOKEN_AMOUNT]: FungibleTokenAmount,
|
||||
[XOTemplatePrimitiveTypes.NFT_COMMITMENT]: NFTCommitment,
|
||||
[XOTemplatePrimitiveTypes.PUBLIC_KEY]: PublicKey,
|
||||
[XOTemplatePrimitiveTypes.SATOSHIS]: Satoshis,
|
||||
[XOTemplatePrimitiveTypes.SCHNORR_SIGNATURE]: SchnorrSignature,
|
||||
[XOTemplatePrimitiveTypes.TEMPLATE_IDENTIFIER]: TemplateIdentifier,
|
||||
[XOTemplatePrimitiveTypes.TIMESTAMP]: Timestamp,
|
||||
[XOTemplatePrimitiveTypes.TOKEN_CATEGORY]: TokenCategory,
|
||||
[XOTemplatePrimitiveTypes.TRANSACTION_HASH]: TransactionHash,
|
||||
} as const;
|
||||
|
||||
type SupportedPrimitiveHint = keyof typeof PRIMITIVE_BY_TEMPLATE_HINT;
|
||||
|
||||
/**
|
||||
* Inputs needed to call a primitive method for a `name.method` push.
|
||||
*/
|
||||
type CallPrimitiveMethodParameters = {
|
||||
|
||||
/**
|
||||
* Full push identifier from the evaluation, for example `amount.toSatoshis`.
|
||||
*/
|
||||
identifier: string;
|
||||
|
||||
/**
|
||||
* Method name to call on the constructed primitive, for example `toIso8601`.
|
||||
*/
|
||||
methodName: string;
|
||||
|
||||
/**
|
||||
* Value for the variable.
|
||||
*/
|
||||
value: unknown;
|
||||
|
||||
/**
|
||||
* Supported template hint that selects the primitive class.
|
||||
*/
|
||||
hint: SupportedPrimitiveHint;
|
||||
};
|
||||
|
||||
/**
|
||||
* Inputs needed to resolve primitive method pushes from extracted CashAssembly identifiers.
|
||||
*/
|
||||
export type ResolvePrimitiveMethodBytesParameters = {
|
||||
|
||||
/**
|
||||
* Variable identifiers from {@link extractVariablesFromEvaluations}, for example
|
||||
* `['amount.toSatoshis', 'fee.toSatoshis']`.
|
||||
*/
|
||||
identifiers: string[];
|
||||
|
||||
/**
|
||||
* Variable names and values object.
|
||||
*/
|
||||
variables: Record<string, unknown>;
|
||||
|
||||
/**
|
||||
* Template variable definitions. When omitted, no primitive methods are resolved.
|
||||
* The `hint` on each entry selects the primitive class.
|
||||
*/
|
||||
templateVariables?: XOTemplate['variables'];
|
||||
};
|
||||
|
||||
/**
|
||||
* Returns true when `hint` maps to a supported primitive class in `PRIMITIVE_BY_TEMPLATE_HINT`.
|
||||
*
|
||||
* @param {XOTemplatePrimitiveType | undefined} hint - Template variable hint.
|
||||
* @returns {boolean} True when the hint selects a supported primitive class.
|
||||
*/
|
||||
const isSupportedPrimitiveHint = (hint: XOTemplatePrimitiveType | undefined): hint is SupportedPrimitiveHint => {
|
||||
return hint !== undefined && Object.hasOwn(PRIMITIVE_BY_TEMPLATE_HINT, hint) === true;
|
||||
};
|
||||
|
||||
/**
|
||||
* Returns true when `methodName` is an own function on the primitive class for `hint`.
|
||||
*
|
||||
* @param {SupportedPrimitiveHint} hint - Supported template hint.
|
||||
* @param {string} methodName - Method name from the evaluation text, for example `toSatoshis`.
|
||||
* @returns {boolean} True when that class exposes the named method.
|
||||
*/
|
||||
const canResolvePrimitiveMethod = (hint: SupportedPrimitiveHint, methodName: string): boolean => {
|
||||
const PrimitiveClass = PRIMITIVE_BY_TEMPLATE_HINT[hint];
|
||||
|
||||
// Check own properties only so inherited Object.prototype names are rejected without needing a value.
|
||||
if (Object.hasOwn(PrimitiveClass.prototype, methodName) === false) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return typeof Reflect.get(PrimitiveClass.prototype, methodName) === 'function';
|
||||
};
|
||||
|
||||
/**
|
||||
* Constructs a primitive from a raw value and calls one instance method on it.
|
||||
*
|
||||
* Call only after `canResolvePrimitiveMethod` is true for the same hint and method.
|
||||
*
|
||||
* @param {CallPrimitiveMethodParameters} parameters - Identifier, method, value, and supported hint.
|
||||
* @returns {unknown} Method return value, later encoded as CashAssembly push bytes.
|
||||
* @throws {@link CashAssemblyPrimitiveMethodMissingError} When the prototype member is not a function.
|
||||
* @throws When the primitive constructor rejects the raw value (validation errors from `@xo-cash/primitives`).
|
||||
*/
|
||||
const callPrimitiveMethod = (parameters: CallPrimitiveMethodParameters): unknown => {
|
||||
const { identifier, methodName, value, hint } = parameters;
|
||||
|
||||
const PrimitiveClass = PRIMITIVE_BY_TEMPLATE_HINT[hint];
|
||||
|
||||
// Constructing runs each primitive's own input validation (range checks, hex length, etc).
|
||||
// `as never` satisfies TypeScript across constructors that accept different input shapes.
|
||||
const primitiveInstance = new PrimitiveClass(value as never);
|
||||
|
||||
// Same prototype member canResolvePrimitiveMethod already verified as an own function.
|
||||
const primitiveMethod = Reflect.get(PrimitiveClass.prototype, methodName);
|
||||
|
||||
if (typeof primitiveMethod !== 'function') {
|
||||
throw new CashAssemblyPrimitiveMethodMissingError(identifier, methodName, hint);
|
||||
}
|
||||
|
||||
return primitiveMethod.call(primitiveInstance);
|
||||
};
|
||||
|
||||
/**
|
||||
* Resolves supported `base.method` identifiers to CashAssembly variable bytes.
|
||||
*
|
||||
* Each single dot identifier whose `hint` maps to a supported primitive is resolved and stored under
|
||||
* the full identifier (`base.method`). Unsupported or multi dot identifiers are left for CashAssembly.
|
||||
*
|
||||
* When `templateVariables` is omitted, returns an empty map.
|
||||
*
|
||||
* @param {ResolvePrimitiveMethodBytesParameters} parameters - Identifiers, values, and optional template metadata.
|
||||
* @returns {Record<string, Uint8Array>} Resolved method identifiers mapped to bytes for CashAssembly pushes.
|
||||
* @throws {@link CashAssemblyPrimitiveMethodMissingError} When the hint is a supported primitive but the method is missing.
|
||||
* @throws {@link CashAssemblyPrimitiveVariableMissingError} When the runtime value is missing from the variables map.
|
||||
* @throws {@link CashAssemblyUnsupportedValueTypeError} When the method return type cannot be embedded.
|
||||
* @throws {@link CashAssemblyNumberNotSafeIntegerError} When a method return value is a number that is not a safe integer.
|
||||
*/
|
||||
export const resolvePrimitiveMethodBytes = (parameters: ResolvePrimitiveMethodBytesParameters): Record<string, Uint8Array> => {
|
||||
const { identifiers, templateVariables, variables } = parameters;
|
||||
|
||||
// Without template metadata there is no hint to select a primitive class.
|
||||
if (templateVariables === undefined) {
|
||||
return {};
|
||||
}
|
||||
|
||||
const resolvedBytes: Record<string, Uint8Array> = {};
|
||||
|
||||
for (const identifier of identifiers) {
|
||||
// The same identifier can appear more than once. Resolve it only once.
|
||||
if (Object.hasOwn(resolvedBytes, identifier) === true) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Find the primitive method reference in the identifier.
|
||||
const methodReferenceMatch = identifier.match(CASHASSEMBLY_VARIABLE_METHOD_REFERENCE_PATTERN);
|
||||
|
||||
if (methodReferenceMatch === null) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const [ , baseName, methodName ] = methodReferenceMatch;
|
||||
|
||||
// Unknown identifiers and CashAssembly native operations such as someKey.schnorr_signature.all_outputs
|
||||
// must be left for CashAssembly rather than treated as primitive failures.
|
||||
if (Object.hasOwn(templateVariables, baseName) === false) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const hint = templateVariables[baseName].hint;
|
||||
|
||||
if (isSupportedPrimitiveHint(hint) === false) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Supported hint with an unknown method should be thrown as an error.
|
||||
if (canResolvePrimitiveMethod(hint, methodName) === false) {
|
||||
throw new CashAssemblyPrimitiveMethodMissingError(identifier, methodName, hint);
|
||||
}
|
||||
|
||||
// If the method is known but the runtime value is missing, throw an error.
|
||||
if (Object.hasOwn(variables, baseName) === false) {
|
||||
throw new CashAssemblyPrimitiveVariableMissingError(identifier, baseName);
|
||||
}
|
||||
|
||||
const methodResult = callPrimitiveMethod({
|
||||
identifier,
|
||||
methodName,
|
||||
value: variables[baseName],
|
||||
hint,
|
||||
});
|
||||
|
||||
// Store the result under identifier.
|
||||
resolvedBytes[identifier] = convertValueToBytes(methodResult, identifier);
|
||||
}
|
||||
|
||||
return resolvedBytes;
|
||||
};
|
||||
@@ -74,3 +74,23 @@ export const extendedJsonReviver = (_propertyKey: string, value: unknown): unkno
|
||||
// If the value does not match either pattern, return the original value
|
||||
return value;
|
||||
};
|
||||
|
||||
/**
|
||||
* Serializes an object to a string using the {@link extendedJsonReplacer}.
|
||||
*
|
||||
* @param object The object to serialize.
|
||||
* @returns The string representation of the object in Extended JSON format.
|
||||
*/
|
||||
export const toExtendedJson = (object: unknown): string => {
|
||||
return JSON.stringify(object, extendedJsonReplacer);
|
||||
};
|
||||
|
||||
/**
|
||||
* Deserializes a string to an object using the {@link extendedJsonReviver}.
|
||||
*
|
||||
* @param serializedObject The string to deserialize.
|
||||
* @returns The object reconstructed from the string.
|
||||
*/
|
||||
export const fromExtendedJson = (serializedObject: string): unknown => {
|
||||
return JSON.parse(serializedObject, extendedJsonReviver);
|
||||
};
|
||||
|
||||
@@ -1,10 +1,14 @@
|
||||
export * from './event-emitter.ts';
|
||||
export * from './extended-json.ts';
|
||||
export * from './script.ts';
|
||||
export * from './sse-session/index.ts';
|
||||
export * from './template/errors.ts';
|
||||
export * from './template/identifier.ts';
|
||||
export * from './template/parser.ts';
|
||||
export * from './template/schemas.ts';
|
||||
export * from './cash-assembly/index.ts';
|
||||
export * from './cash-assembly/errors.ts';
|
||||
export * from './cash-assembly/defaults.ts';
|
||||
|
||||
// Only exporting serializeTemplate as deserializeTemplate is only used internally and parseTemplate should be used instead.
|
||||
export { serializeTemplate } from './template/serialization.ts';
|
||||
|
||||
@@ -0,0 +1,105 @@
|
||||
/**
|
||||
* An async iterable queue that bridges push-based producers and pull-based consumers.
|
||||
*
|
||||
* Composes an internal {@link ReadableStream} instead of extending it, so producers
|
||||
* call {@link push} while consumers use standard async iteration (`for await...of`).
|
||||
*
|
||||
* ```ts
|
||||
* const messages = new AsyncPushIterator<SSEvent>();
|
||||
*
|
||||
* // Producer (elsewhere)
|
||||
* messages.push(event);
|
||||
*
|
||||
* // Consumer
|
||||
* for await (const event of messages) {
|
||||
* handle(event);
|
||||
* }
|
||||
* ```
|
||||
*
|
||||
* {@link Symbol.asyncIterator} returns `stream.values({ preventCancel: true })` so
|
||||
* breaking out of `for await...of` does not cancel the underlying stream. That
|
||||
* matters for long-lived sessions where the producer keeps pushing after a consumer
|
||||
* stops reading early (for example, test helpers that only collect a fixed count).
|
||||
*/
|
||||
export class AsyncPushIterator<T> {
|
||||
/** ReadableStream backing the async iterator returned from {@link Symbol.asyncIterator}. */
|
||||
#stream: ReadableStream<T>;
|
||||
|
||||
/** Controller used to enqueue values and close the stream from {@link push} and {@link close}. */
|
||||
#controller: ReadableStreamDefaultController<T> | undefined;
|
||||
|
||||
/** When true, no more values are accepted and iteration eventually completes. */
|
||||
#closed = false;
|
||||
|
||||
public constructor() {
|
||||
// `start`'s `this` is the underlying source object when using a plain method.
|
||||
// An arrow function captures the class instance so the controller is stored here.
|
||||
this.#stream = new ReadableStream({
|
||||
start: (controller: ReadableStreamDefaultController<T>): void => {
|
||||
this.#controller = controller;
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Flag indicating if the iterator is closed.
|
||||
*/
|
||||
public get closed(): boolean {
|
||||
return this.#closed;
|
||||
}
|
||||
|
||||
/**
|
||||
* Enqueues a value for the consumer.
|
||||
*
|
||||
* After {@link close}, pushes are silently dropped.
|
||||
*
|
||||
* @param value - The next value to yield from the iterator.
|
||||
*/
|
||||
push(value: T): void {
|
||||
if (this.#closed) return;
|
||||
|
||||
this.#controller?.enqueue(value);
|
||||
}
|
||||
|
||||
/**
|
||||
* Causes any future interactions with the associated stream to error with {@link error}.
|
||||
* Calling this will also clear the pending values immediately, so iterators that were listening will not receive them.
|
||||
*
|
||||
* @param error - The error to throw from the stream.
|
||||
*/
|
||||
error(error: Error): void {
|
||||
if (this.#closed) return;
|
||||
|
||||
this.#closed = true;
|
||||
this.#controller?.error(error);
|
||||
}
|
||||
|
||||
/**
|
||||
* Ends the stream.
|
||||
*
|
||||
* Marks the iterator closed so future {@link push} calls are ignored.
|
||||
* Buffered values are still yielded before iteration completes.
|
||||
*/
|
||||
close(): void {
|
||||
this.#closed = true;
|
||||
|
||||
try {
|
||||
this.#controller?.close();
|
||||
} catch {
|
||||
// The reader may already have released or cancelled the stream.
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns an async iterator over the composed stream.
|
||||
*
|
||||
* Uses `preventCancel: true` so early `break` from `for await...of` does not
|
||||
* close the stream and block later pushes.
|
||||
*
|
||||
* Because values are discarded after being read, only a single consumer is supported.
|
||||
* Additional consumers will receive a stream lock error. Unread values will be preserved until {@link close} is called.
|
||||
*/
|
||||
[Symbol.asyncIterator](): AsyncIterableIterator<T> {
|
||||
return this.#stream.values({ preventCancel: true });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
/**
|
||||
* Regex that splits decoded SSE text into lines.
|
||||
*
|
||||
* The SSE wire format is line-oriented (`field: value` per line). Servers may
|
||||
* send `\r\n` (HTTP default), `\n` (Unix), or `\r` (legacy Mac). Matching all
|
||||
* three keeps parsing correct regardless of platform or server implementation.
|
||||
*/
|
||||
export const SSE_LINE_ENDINGS = /\r\n|\r|\n/;
|
||||
|
||||
/**
|
||||
* Regex that matches the single optional leading space in an SSE field value.
|
||||
*
|
||||
* Per the SSE spec, `field: value` may include one space immediately after the
|
||||
* colon; that space is not part of the value. Used with `.replace()` to strip
|
||||
* it when parsing lines such as `data: hello` → `hello`.
|
||||
*/
|
||||
export const SSE_FIELD_VALUE_REGEX = /^ /;
|
||||
|
||||
/**
|
||||
* Regex that matches a trailing newline at the end of a string.
|
||||
*
|
||||
* Multiple `data:` lines in one event are joined with `\n`. When the event is
|
||||
* completed, this removes any stray trailing newline so callers receive the
|
||||
* payload without an extra line break at the end.
|
||||
*/
|
||||
export const SSE_TRAILING_NEWLINE_REGEX = /\n$/;
|
||||
|
||||
/**
|
||||
* The newline character used when normalizing SSE text internally.
|
||||
*
|
||||
* Used to join consecutive `data:` lines into one payload and to reassemble
|
||||
* buffered partial lines between streamed chunks before the next parse call.
|
||||
*/
|
||||
export const NEW_LINE = '\n';
|
||||
@@ -0,0 +1,2 @@
|
||||
export * from './async-push-iterator.ts';
|
||||
export * from './sse-event-parser.ts';
|
||||
@@ -0,0 +1,194 @@
|
||||
import type { SSEvent } from './types.ts';
|
||||
import { NEW_LINE, SSE_FIELD_VALUE_REGEX, SSE_LINE_ENDINGS, SSE_TRAILING_NEWLINE_REGEX } from './constants.ts';
|
||||
|
||||
/**
|
||||
* Optional encoders used when decoding incoming SSE bytes and re-encoding
|
||||
* any buffered remainder between chunks.
|
||||
*/
|
||||
export interface SSEEventParserOptions {
|
||||
|
||||
/** Decodes raw stream bytes into text. Defaults to a new `TextDecoder`. */
|
||||
textDecoder: TextDecoder;
|
||||
}
|
||||
|
||||
/**
|
||||
* Incrementally parses Server-Sent Events (SSE) from streamed byte chunks.
|
||||
*
|
||||
* SSE payloads are line-oriented: each event is a sequence of `field: value`
|
||||
* lines terminated by a blank line. This parser accepts arbitrary chunk
|
||||
* boundaries from a live HTTP response body and emits only complete events.
|
||||
*
|
||||
* Typical usage is one parser instance per connection, calling {@link parseEvents}
|
||||
* for each chunk received from the stream:
|
||||
*
|
||||
* ```ts
|
||||
* const parser = new SSEEventParser();
|
||||
*
|
||||
* for await (const chunk of response.body) {
|
||||
* for (const event of parser.parseEvents(chunk)) {
|
||||
* // handle event.data, event.event, event.id, event.retry
|
||||
* }
|
||||
* }
|
||||
* ```
|
||||
*
|
||||
* Supported fields follow the SSE spec: `data`, `event`, `id`, and `retry`.
|
||||
* Multiple `data:` lines in one event are joined with `\n`. An event is only
|
||||
* emitted once a blank line is seen and at least one `data` field was collected.
|
||||
*/
|
||||
export class SSEEventParser {
|
||||
readonly #textDecoder: TextDecoder;
|
||||
|
||||
/** Bytes from a partial line or incomplete event, carried over to the next chunk. */
|
||||
#messageBuffer: string = '';
|
||||
|
||||
/**
|
||||
* Creates a parser for one SSE stream.
|
||||
*
|
||||
* Inject custom encoders in tests or when a non-default character encoding
|
||||
* is required; production callers can rely on the defaults.
|
||||
*
|
||||
* @param options - Optional text encoders for decode/encode of stream bytes.
|
||||
*/
|
||||
constructor(options: Partial<SSEEventParserOptions> = {}) {
|
||||
this.#textDecoder = options.textDecoder ?? new TextDecoder();
|
||||
}
|
||||
|
||||
/**
|
||||
* Clears any buffered bytes from a partial line or incomplete event.
|
||||
*
|
||||
* Call when abandoning a transport so the next connection does not prepend
|
||||
* stale bytes to incoming chunks.
|
||||
*/
|
||||
public reset(): void {
|
||||
// Clear the message buffer
|
||||
this.#messageBuffer = '';
|
||||
|
||||
// Reset the decoder to clear any buffered bytes
|
||||
this.#textDecoder.decode();
|
||||
}
|
||||
|
||||
/**
|
||||
* Parses all complete SSE events contained in a newly received chunk.
|
||||
*
|
||||
* The chunk is appended to any bytes buffered from earlier calls. Complete
|
||||
* events (blank-line delimited blocks with at least one `data` field) are
|
||||
* returned immediately; any trailing partial line or in-progress event stays
|
||||
* in the internal buffer until a later chunk completes it.
|
||||
*
|
||||
* @param chunk - Newly received SSE stream bytes.
|
||||
* @returns Zero or more complete parsed SSE events from this chunk.
|
||||
*/
|
||||
public parseEvents(chunk: Uint8Array): SSEvent[] {
|
||||
const lines = this.getBufferedLines(chunk);
|
||||
|
||||
const eventLines = lines.slice(0, -1);
|
||||
|
||||
const events: SSEvent[] = [];
|
||||
let event: Partial<SSEvent> = {};
|
||||
let processedLineCount = 0;
|
||||
|
||||
for (const [ index, line ] of eventLines.entries()) {
|
||||
// A blank line indicates the end of an event. If we have received data, we can complete the event
|
||||
if (line === '') {
|
||||
if (event.data !== undefined) {
|
||||
events.push(this.completeEvent(event));
|
||||
event = {};
|
||||
processedLineCount = index + 1;
|
||||
}
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
this.parseLine(line, event);
|
||||
}
|
||||
|
||||
this.storeRemainingLines(lines, processedLineCount);
|
||||
|
||||
return events;
|
||||
}
|
||||
|
||||
/**
|
||||
* Appends a new chunk to the buffered bytes and splits the combined payload
|
||||
* into lines.
|
||||
*
|
||||
* Accepts `\r\n`, `\r`, and `\n` line endings so events parse correctly
|
||||
* regardless of server or platform conventions.
|
||||
*/
|
||||
private getBufferedLines(chunk: Uint8Array): string[] {
|
||||
this.#messageBuffer += this.#textDecoder.decode(chunk, { stream: true });
|
||||
|
||||
return this.#messageBuffer.split(SSE_LINE_ENDINGS);
|
||||
}
|
||||
|
||||
/**
|
||||
* Parses one SSE field line into an in-progress event.
|
||||
*
|
||||
* Lines without a colon are ignored. A single optional space after the colon
|
||||
* is stripped from the field value, per the SSE spec.
|
||||
*/
|
||||
private parseLine(line: string, event: Partial<SSEvent>): void {
|
||||
const colonIndex = line.indexOf(':');
|
||||
if (colonIndex === -1) return;
|
||||
|
||||
const field = line.slice(0, colonIndex);
|
||||
const value = line.slice(colonIndex + 1).replace(SSE_FIELD_VALUE_REGEX, '');
|
||||
|
||||
switch (field) {
|
||||
case 'data':
|
||||
event.data = event.data ? `${event.data}${NEW_LINE}${value}` : value;
|
||||
|
||||
return;
|
||||
|
||||
case 'event':
|
||||
event.event = value;
|
||||
|
||||
return;
|
||||
|
||||
case 'id':
|
||||
event.id = value;
|
||||
|
||||
return;
|
||||
|
||||
case 'retry':
|
||||
this.parseRetry(value, event);
|
||||
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Applies a numeric `retry:` field to an in-progress event.
|
||||
*
|
||||
* Non-numeric values are ignored rather than failing the parse.
|
||||
*/
|
||||
private parseRetry(value: string, event: Partial<SSEvent>): void {
|
||||
const retry = parseInt(value, 10);
|
||||
|
||||
if (!isNaN(retry)) {
|
||||
event.retry = retry;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Constructs a completed SSE event from accumulated fields.
|
||||
*
|
||||
* Trims a trailing newline from multi-line `data` values so callers receive
|
||||
* the payload without an extra line break at the end.
|
||||
*/
|
||||
private completeEvent(event: Partial<SSEvent>): SSEvent {
|
||||
return {
|
||||
...event,
|
||||
data: event.data?.replace(SSE_TRAILING_NEWLINE_REGEX, ''),
|
||||
} as SSEvent;
|
||||
}
|
||||
|
||||
/**
|
||||
* Preserves incomplete trailing lines for the next received chunk.
|
||||
*
|
||||
* Only lines that were fully processed (through a completed event boundary)
|
||||
* are discarded; the remainder is re-encoded into {@link messageBuffer}.
|
||||
*/
|
||||
private storeRemainingLines(lines: string[], processedLineCount: number): void {
|
||||
this.#messageBuffer = lines.slice(processedLineCount).join(NEW_LINE);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
/**
|
||||
* Represents a Server-Sent Event.
|
||||
*/
|
||||
export interface SSEvent {
|
||||
|
||||
/**
|
||||
* Event data.
|
||||
*/
|
||||
data: string;
|
||||
|
||||
/**
|
||||
* Event type.
|
||||
* This value is optionally sent by the server. Traditional EventSource allows listeners for specific event types.
|
||||
* The SSE Session collapses all event types into "message" event.
|
||||
*/
|
||||
event?: string;
|
||||
|
||||
/**
|
||||
* Event ID.
|
||||
* This value is optionally sent by the server as a "checkpoint" the client can use to resume from using the Last-Event-ID header.
|
||||
*/
|
||||
id?: string;
|
||||
|
||||
/**
|
||||
* Reconnection time in milliseconds.
|
||||
* This value is optionally sent by the server to indicate the server's preferred time before reconnecting
|
||||
*/
|
||||
retry?: number;
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
/* eslint-disable @stylistic/newline-per-chained-call */
|
||||
import { BchVmVersions, XOTemplateLockingTypes, XOTemplateNftCapabilities, XOTemplatePrimitiveTypes } from '@xo-cash/types';
|
||||
import { BchVmVersions, XOTemplateBaseTypes, XOTemplatePrimitiveTypes, XOTemplateLockingTypes, XOTemplateNftCapabilities } from '@xo-cash/types';
|
||||
import { z } from 'zod';
|
||||
|
||||
// ============================================================
|
||||
@@ -59,8 +59,14 @@ export const xoTemplateNftCapabilitySchema = z.enum(XOTemplateNftCapabilities);
|
||||
export const xoTemplateLockingTypeSchema = z.enum(XOTemplateLockingTypes);
|
||||
|
||||
/**
|
||||
* 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.
|
||||
* Validation schema for a base type identifier.
|
||||
* Accepts values from XOTemplateBaseTypes for the `type` field on constants, variables, and data.
|
||||
*/
|
||||
export const xoTemplateBaseTypeSchema = z.enum(XOTemplateBaseTypes);
|
||||
|
||||
/**
|
||||
* Validation schema for a primitive type identifier.
|
||||
* Accepts values from XOTemplatePrimitiveTypes for the `hint` field on constants, variables, and data.
|
||||
*/
|
||||
export const xoTemplatePrimitiveTypeSchema = z.enum(XOTemplatePrimitiveTypes);
|
||||
|
||||
@@ -732,9 +738,9 @@ export const xoTemplateTransactionSchema = xoTemplateViewPropertiesSchema
|
||||
*/
|
||||
export const xoTemplateConstantSchema = xoTemplateViewPropertiesSchema
|
||||
.extend({
|
||||
type: xoTemplatePrimitiveTypeSchema.describe('The data type of this constant.'),
|
||||
type: xoTemplateBaseTypeSchema.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.'),
|
||||
hint: xoTemplatePrimitiveTypeSchema.optional().describe('An optional hint to help apps and users understand what this constant represents.'),
|
||||
})
|
||||
.strict();
|
||||
|
||||
@@ -751,9 +757,9 @@ export const xoTemplateConstantSchema = xoTemplateViewPropertiesSchema
|
||||
*/
|
||||
export const xoTemplateDataSchema = z
|
||||
.object({
|
||||
type: xoTemplatePrimitiveTypeSchema.describe('The data type of this data field.'),
|
||||
type: xoTemplateBaseTypeSchema.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.'),
|
||||
hint: xoTemplatePrimitiveTypeSchema.optional().describe('An optional hint to help apps and users understand this data field.'),
|
||||
})
|
||||
.strict();
|
||||
|
||||
@@ -789,8 +795,8 @@ export const xoTemplateImportDefaultValueSchema = xoTemplateIntentSchema
|
||||
*/
|
||||
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.'),
|
||||
type: xoTemplateBaseTypeSchema.optional().describe('The data type of this variable.'),
|
||||
hint: xoTemplatePrimitiveTypeSchema.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
|
||||
|
||||
@@ -0,0 +1,813 @@
|
||||
import { expect, test } from 'vitest';
|
||||
import {
|
||||
bigIntToVmNumber,
|
||||
binToHex,
|
||||
createCompilerBch,
|
||||
createVirtualMachineBch,
|
||||
encodeDataPush,
|
||||
generatePrivateKey,
|
||||
hash256,
|
||||
secp256k1,
|
||||
utf8ToBin,
|
||||
} from '@bitauth/libauth';
|
||||
import { XOTemplateBaseTypes, XOTemplatePrimitiveTypes } from '@xo-cash/types';
|
||||
import {
|
||||
isCashAssemblyExpression,
|
||||
extractCashAssemblyEvaluations,
|
||||
extractVariablesFromEvaluations,
|
||||
decodeCompiledCashAssemblyEvaluation,
|
||||
compileCashAssemblyString,
|
||||
generateCashAssemblyBytecode,
|
||||
compileCashAssemblyEvaluations,
|
||||
resolvePrimitiveMethodBytes,
|
||||
} from '../source/cash-assembly/index.ts';
|
||||
import {
|
||||
CashAssemblyRequiredVariableMissingError,
|
||||
CashAssemblyCompilationFailedError,
|
||||
CashAssemblyVariableTypeMismatchError,
|
||||
CashAssemblyPrimitiveMethodMissingError,
|
||||
CashAssemblyPrimitiveVariableMissingError,
|
||||
CashAssemblyVmNumberDecodeError,
|
||||
CashAssemblyNumberNotSafeIntegerError,
|
||||
} from '../source/cash-assembly/errors.ts';
|
||||
|
||||
/**
|
||||
* Tests that isCashAssemblyExpression recognizes a string made up entirely of one evaluation.
|
||||
*/
|
||||
const testIsCashAssemblyExpressionMatchesFullExpression = (): void => {
|
||||
expect(isCashAssemblyExpression('$(<requestedSatoshis>)')).toBe(true);
|
||||
};
|
||||
|
||||
/**
|
||||
* Tests that isCashAssemblyExpression rejects a string that contains an evaluation plus other text.
|
||||
*/
|
||||
const testIsCashAssemblyExpressionRejectsSurroundingText = (): void => {
|
||||
expect(isCashAssemblyExpression('Received $(<requestedSatoshis>)')).toBe(false);
|
||||
};
|
||||
|
||||
/**
|
||||
* Tests that isCashAssemblyExpression rejects plain text with no evaluation at all.
|
||||
*/
|
||||
const testIsCashAssemblyExpressionRejectsPlainText = (): void => {
|
||||
expect(isCashAssemblyExpression('Received funds')).toBe(false);
|
||||
};
|
||||
|
||||
/**
|
||||
* Tests that isCashAssemblyExpression rejects an empty evaluation, since it references no variables.
|
||||
*/
|
||||
const testIsCashAssemblyExpressionRejectsEmptyEvaluation = (): void => {
|
||||
expect(isCashAssemblyExpression('$()')).toBe(false);
|
||||
};
|
||||
|
||||
/**
|
||||
* Tests that isCashAssemblyExpression rejects non-string input rather than coercing it.
|
||||
*/
|
||||
const testIsCashAssemblyExpressionRejectsNonStringInput = (): void => {
|
||||
// A number can never be a CashAssembly expression, regardless of its value
|
||||
expect(isCashAssemblyExpression(5000)).toBe(false);
|
||||
};
|
||||
|
||||
/**
|
||||
* Tests that extractCashAssemblyEvaluations finds a single evaluation embedded in a larger string.
|
||||
*/
|
||||
const testExtractCashAssemblyEvaluationsFindsSingleEvaluation = (): void => {
|
||||
const satoshisDescription = 'Received $(<requestedSatoshis>) satoshis from sender.';
|
||||
|
||||
// Only the evaluation substring is returned, not the surrounding text
|
||||
expect(extractCashAssemblyEvaluations(satoshisDescription)).toStrictEqual([ '$(<requestedSatoshis>)' ]);
|
||||
};
|
||||
|
||||
/**
|
||||
* Tests that extractCashAssemblyEvaluations returns an empty array when the text has no evaluations.
|
||||
*/
|
||||
const testExtractCashAssemblyEvaluationsReturnsEmptyArrayWhenNoneFound = (): void => {
|
||||
expect(extractCashAssemblyEvaluations('Received funds')).toStrictEqual([]);
|
||||
};
|
||||
|
||||
/**
|
||||
* Tests that extractVariablesFromEvaluations returns each referenced variable name once,
|
||||
* even when it appears in more than one evaluation.
|
||||
*/
|
||||
const testExtractVariablesFromEvaluationsDeduplicatesAcrossEvaluations = (): void => {
|
||||
const evaluations = [ '$(<requestedTokenAmount> <decimalsFactor> OP_DIV)', '$(<requestedTokenAmount> <decimalsFactor> OP_MOD)' ];
|
||||
|
||||
// requestedTokenAmount and decimalsFactor each appear in both evaluations but are listed once
|
||||
expect(extractVariablesFromEvaluations(evaluations)).toStrictEqual([ 'requestedTokenAmount', 'decimalsFactor' ]);
|
||||
};
|
||||
|
||||
/**
|
||||
* Tests that extractVariablesFromEvaluations excludes numeric and quoted-string literal tokens,
|
||||
* returning only the true variable reference.
|
||||
*/
|
||||
const testExtractVariablesFromEvaluationsExcludesLiteralTokens = (): void => {
|
||||
const evaluation = '$(<tokenCapability> <0x02> OP_EQUAL OP_IF <"minting"> OP_ELSE <"immutable"> OP_ENDIF)';
|
||||
|
||||
// <0x02>, <"minting">, and <"immutable"> are literals, not variables, so only tokenCapability is returned
|
||||
expect(extractVariablesFromEvaluations([ evaluation ])).toStrictEqual([ 'tokenCapability' ]);
|
||||
};
|
||||
|
||||
/**
|
||||
* Tests that decodeCompiledCashAssemblyEvaluation converts VM number bytes to their decimal string
|
||||
* when decode mode is bigint.
|
||||
*/
|
||||
const testDecodeCompiledCashAssemblyEvaluationDecodesBigint = (): void => {
|
||||
const compiledResult = bigIntToVmNumber(1234n);
|
||||
|
||||
expect(decodeCompiledCashAssemblyEvaluation(compiledResult, 'bigint')).toBe('1234');
|
||||
};
|
||||
|
||||
/**
|
||||
* Tests that decodeCompiledCashAssemblyEvaluation throws when bigint mode receives bytes that are not a VM number.
|
||||
*/
|
||||
const testDecodeCompiledCashAssemblyEvaluationThrowsWhenBigintIsNotAVmNumber = (): void => {
|
||||
const decodeNonMinimalZero = (): string => decodeCompiledCashAssemblyEvaluation(new Uint8Array([ 0x00 ]), 'bigint');
|
||||
const expectedMessage =
|
||||
'CashAssembly evaluation could not be decoded as a VM number: Failed to decode VM Number: the number is not minimally-encoded.';
|
||||
|
||||
expect(decodeNonMinimalZero).toThrow(CashAssemblyVmNumberDecodeError);
|
||||
expect(decodeNonMinimalZero).toThrow(expectedMessage);
|
||||
};
|
||||
|
||||
/**
|
||||
* Tests that decodeCompiledCashAssemblyEvaluation converts bytes to a hex string when decode mode is hex.
|
||||
*/
|
||||
const testDecodeCompiledCashAssemblyEvaluationDecodesHex = (): void => {
|
||||
const compiledResult = new Uint8Array([ 0xab, 0xcd ]);
|
||||
|
||||
expect(decodeCompiledCashAssemblyEvaluation(compiledResult, 'hex')).toBe('abcd');
|
||||
};
|
||||
|
||||
/**
|
||||
* Tests that decodeCompiledCashAssemblyEvaluation reports an empty byte array as 'false' and any
|
||||
* non-empty byte array as 'true' when decode mode is boolean.
|
||||
*/
|
||||
const testDecodeCompiledCashAssemblyEvaluationDecodesBoolean = (): void => {
|
||||
// An empty byte array is falsy on the BCH VM
|
||||
expect(decodeCompiledCashAssemblyEvaluation(new Uint8Array(0), 'boolean')).toBe('false');
|
||||
|
||||
// Any non-empty byte array is truthy on the BCH VM
|
||||
expect(decodeCompiledCashAssemblyEvaluation(new Uint8Array([ 1 ]), 'boolean')).toBe('true');
|
||||
};
|
||||
|
||||
/**
|
||||
* Tests that decodeCompiledCashAssemblyEvaluation defaults to utf8 decoding when no mode is given.
|
||||
*/
|
||||
const testDecodeCompiledCashAssemblyEvaluationDefaultsToUtf8 = (): void => {
|
||||
const compiledResult = utf8ToBin('hello');
|
||||
|
||||
expect(decodeCompiledCashAssemblyEvaluation(compiledResult)).toBe('hello');
|
||||
};
|
||||
|
||||
/**
|
||||
* Tests that decodeCompiledCashAssemblyEvaluation returns the comma-separated decimal byte values
|
||||
* when decode mode is uint8array.
|
||||
*/
|
||||
const testDecodeCompiledCashAssemblyEvaluationDecodesUint8Array = (): void => {
|
||||
const compiledResult = new Uint8Array([ 1, 2, 255 ]);
|
||||
|
||||
expect(decodeCompiledCashAssemblyEvaluation(compiledResult, 'uint8array')).toBe('1,2,255');
|
||||
};
|
||||
|
||||
/**
|
||||
* Tests that compileCashAssemblyString compiles a single evaluation and decodes it as a bigint.
|
||||
*/
|
||||
const testCompileCashAssemblyStringDecodesSingleEvaluationAsBigint = (): void => {
|
||||
const compiledSatoshisText = compileCashAssemblyString({
|
||||
cashAssemblyText: '$(<requestedSatoshis>)',
|
||||
variables: { requestedSatoshis: 5000n },
|
||||
evaluationDecodeMode: 'bigint',
|
||||
});
|
||||
|
||||
expect(compiledSatoshisText).toBe('5000');
|
||||
};
|
||||
|
||||
/**
|
||||
* Tests that compileCashAssemblyString compiles multiple evaluations embedded in the same string.
|
||||
*
|
||||
*/
|
||||
const testCompileCashAssemblyStringCompilesMultipleEvaluationsInOneString = (): void => {
|
||||
const tokenDescription =
|
||||
'Transferred $(<requestedTokenAmount> <decimalsFactor> OP_DIV).$(<requestedTokenAmount> <decimalsFactor> OP_MOD) tokens.';
|
||||
|
||||
expect(extractCashAssemblyEvaluations(tokenDescription)).toStrictEqual([
|
||||
'$(<requestedTokenAmount> <decimalsFactor> OP_DIV)',
|
||||
'$(<requestedTokenAmount> <decimalsFactor> OP_MOD)',
|
||||
]);
|
||||
|
||||
const compiledTokenText = compileCashAssemblyString({
|
||||
cashAssemblyText: tokenDescription,
|
||||
variables: { requestedTokenAmount: 1050n, decimalsFactor: 100n },
|
||||
evaluationDecodeMode: 'bigint',
|
||||
});
|
||||
|
||||
expect(compiledTokenText).toBe('Transferred 10.50 tokens.');
|
||||
};
|
||||
|
||||
/**
|
||||
* Tests that a variable referenced twice in one evaluation uses the same supplied value both times.
|
||||
*/
|
||||
const testCompileCashAssemblyStringReusesTheSameVariableTwiceInOneEvaluation = (): void => {
|
||||
const evaluation = '$(<amount> <amount> OP_ADD)';
|
||||
|
||||
// The name appears twice in the script body but is required only once in the variables map.
|
||||
expect(extractVariablesFromEvaluations([ evaluation ])).toStrictEqual([ 'amount' ]);
|
||||
|
||||
const compiledText = compileCashAssemblyString({
|
||||
cashAssemblyText: evaluation,
|
||||
variables: { amount: 21n },
|
||||
evaluationDecodeMode: 'bigint',
|
||||
});
|
||||
|
||||
expect(compiledText).toBe('42');
|
||||
};
|
||||
|
||||
/**
|
||||
* Tests that compileCashAssemblyString respects OP_IF/OP_ELSE branching driven by a variable.
|
||||
*/
|
||||
const testCompileCashAssemblyStringEvaluatesConditionalLiterals = (): void => {
|
||||
const conditionalExpression = '$(<tokenCapability> <0x02> OP_EQUAL OP_IF <"minting"> OP_ELSE <"immutable"> OP_ENDIF)';
|
||||
|
||||
// tokenCapability equal to 2 takes the OP_IF branch
|
||||
const mintingResult = compileCashAssemblyString({
|
||||
cashAssemblyText: conditionalExpression,
|
||||
variables: { tokenCapability: 2n },
|
||||
evaluationDecodeMode: 'utf8',
|
||||
});
|
||||
expect(mintingResult).toBe('minting');
|
||||
|
||||
// tokenCapability not equal to 2 takes the OP_ELSE branch
|
||||
const immutableResult = compileCashAssemblyString({
|
||||
cashAssemblyText: conditionalExpression,
|
||||
variables: { tokenCapability: 0n },
|
||||
evaluationDecodeMode: 'utf8',
|
||||
});
|
||||
expect(immutableResult).toBe('immutable');
|
||||
};
|
||||
|
||||
/**
|
||||
* Tests that compileCashAssemblyString defaults to utf8 decoding when evaluationDecodeMode is omitted.
|
||||
*/
|
||||
const testCompileCashAssemblyStringDefaultsToUtf8DecodeMode = (): void => {
|
||||
const compiledLabel = compileCashAssemblyString({
|
||||
cashAssemblyText: '$(<label>)',
|
||||
variables: { label: 'hello' },
|
||||
});
|
||||
|
||||
expect(compiledLabel).toBe('hello');
|
||||
};
|
||||
|
||||
/**
|
||||
* Tests that compileCashAssemblyString accepts a Uint8Array variable value directly,
|
||||
* and decodes the result as hex.
|
||||
*/
|
||||
const testCompileCashAssemblyStringAcceptsUint8ArrayVariable = (): void => {
|
||||
const compiledHash = compileCashAssemblyString({
|
||||
cashAssemblyText: '$(<hashBytes>)',
|
||||
variables: { hashBytes: new Uint8Array([ 0xde, 0xad, 0xbe, 0xef ]) },
|
||||
evaluationDecodeMode: 'hex',
|
||||
});
|
||||
|
||||
expect(compiledHash).toBe('deadbeef');
|
||||
};
|
||||
|
||||
/**
|
||||
* Tests that compileCashAssemblyString throws CashAssemblyRequiredVariableMissingError, naming the missing
|
||||
* variable, when the variables map does not contain a variable referenced by the text.
|
||||
*/
|
||||
const testCompileCashAssemblyStringThrowsForMissingVariable = (): void => {
|
||||
const compileWithMissingVariable = (): string =>
|
||||
compileCashAssemblyString({
|
||||
cashAssemblyText: '$(<requestedSatoshis>)',
|
||||
variables: {},
|
||||
evaluationDecodeMode: 'bigint',
|
||||
});
|
||||
|
||||
expect(compileWithMissingVariable).toThrow(CashAssemblyRequiredVariableMissingError);
|
||||
|
||||
// The message states the reason (missing from the variables map) and names the specific variable
|
||||
expect(compileWithMissingVariable).toThrow('Missing required variable: variableNames [requestedSatoshis]');
|
||||
};
|
||||
|
||||
/**
|
||||
* Tests that compileCashAssemblyString throws for a non integer number, and that the same display text
|
||||
* can be produced with integer DIV and MOD instead of a float variable.
|
||||
*/
|
||||
const testCompileCashAssemblyStringThrowsForNonIntegerNumberAndProducesSameDisplayWithIntegerDivMod = (): void => {
|
||||
const compileWithNonInteger = (): string =>
|
||||
compileCashAssemblyString({
|
||||
cashAssemblyText: '$(<amount>)',
|
||||
variables: { amount: 12.5 },
|
||||
evaluationDecodeMode: 'utf8',
|
||||
});
|
||||
const expectedMessage = 'CashAssembly number is not a safe integer: identifier "amount", got 12.5';
|
||||
|
||||
expect(compileWithNonInteger).toThrow(CashAssemblyNumberNotSafeIntegerError);
|
||||
expect(compileWithNonInteger).toThrow(expectedMessage);
|
||||
|
||||
// Floats are rejected. Format decimals from integer quantity and scale with OP_DIV and OP_MOD.
|
||||
const compiledText = compileCashAssemblyString({
|
||||
cashAssemblyText: '$(<amount> <base> OP_DIV).$(<amount> <base> OP_MOD)',
|
||||
variables: { amount: 125n, base: 10n },
|
||||
evaluationDecodeMode: 'bigint',
|
||||
});
|
||||
|
||||
expect(compiledText).toBe('12.5');
|
||||
};
|
||||
|
||||
/**
|
||||
* Tests that compileCashAssemblyString throws when a number variable is outside the safe integer range.
|
||||
*/
|
||||
const testCompileCashAssemblyStringThrowsForUnsafeIntegerNumber = (): void => {
|
||||
const unsafeInteger = Number.MAX_SAFE_INTEGER + 2;
|
||||
const compileWithUnsafeInteger = (): string =>
|
||||
compileCashAssemblyString({
|
||||
cashAssemblyText: '$(<amount>)',
|
||||
variables: { amount: unsafeInteger },
|
||||
evaluationDecodeMode: 'utf8',
|
||||
});
|
||||
const expectedMessage = `CashAssembly number is not a safe integer: identifier "amount", got ${String(unsafeInteger)}`;
|
||||
|
||||
expect(compileWithUnsafeInteger).toThrow(CashAssemblyNumberNotSafeIntegerError);
|
||||
expect(compileWithUnsafeInteger).toThrow(expectedMessage);
|
||||
};
|
||||
|
||||
/**
|
||||
* Tests that compileCashAssemblyString throws for non finite number values.
|
||||
*/
|
||||
const testCompileCashAssemblyStringThrowsForNonFiniteNumber = (): void => {
|
||||
const compileWithNaN = (): string =>
|
||||
compileCashAssemblyString({
|
||||
cashAssemblyText: '$(<amount>)',
|
||||
variables: { amount: Number.NaN },
|
||||
evaluationDecodeMode: 'utf8',
|
||||
});
|
||||
const compileWithInfinity = (): string =>
|
||||
compileCashAssemblyString({
|
||||
cashAssemblyText: '$(<amount>)',
|
||||
variables: { amount: Number.POSITIVE_INFINITY },
|
||||
evaluationDecodeMode: 'utf8',
|
||||
});
|
||||
|
||||
expect(compileWithNaN).toThrow(CashAssemblyNumberNotSafeIntegerError);
|
||||
expect(compileWithNaN).toThrow('CashAssembly number is not a safe integer: identifier "amount", got NaN');
|
||||
expect(compileWithInfinity).toThrow(CashAssemblyNumberNotSafeIntegerError);
|
||||
expect(compileWithInfinity).toThrow('CashAssembly number is not a safe integer: identifier "amount", got Infinity');
|
||||
};
|
||||
|
||||
/**
|
||||
* Tests that a decoded variable value which happens to contain text shaped like a CashAssembly
|
||||
* evaluation (e.g. "$(<real>)") is left alone as plain output text
|
||||
*/
|
||||
const testCompileCashAssemblyStringDoesNotReinterpretInjectedEvaluationLookingText = (): void => {
|
||||
const compiledText = compileCashAssemblyString({
|
||||
cashAssemblyText: '$(<label>)$(<real>)',
|
||||
variables: { label: '$(<real>)', real: '999' },
|
||||
evaluationDecodeMode: 'utf8',
|
||||
});
|
||||
|
||||
expect(compiledText).toBe('$(<real>)999');
|
||||
};
|
||||
|
||||
/**
|
||||
* Tests that omitting templateVariables leaves method shaped evaluations for CashAssembly.
|
||||
*/
|
||||
const testCompileCashAssemblyStringLeavesMethodEvaluationForCashAssemblyWithoutTemplateVariables = (): void => {
|
||||
const compileWithoutTemplateVariables = (): string =>
|
||||
compileCashAssemblyString({
|
||||
cashAssemblyText: '$(<expiry.toIso8601>)',
|
||||
variables: { expiry: Date.parse('2024-01-15T10:30:00.000Z') },
|
||||
});
|
||||
|
||||
expect(compileWithoutTemplateVariables).toThrow(CashAssemblyRequiredVariableMissingError);
|
||||
expect(compileWithoutTemplateVariables).toThrow('Missing required variable: variableNames [expiry.toIso8601]');
|
||||
};
|
||||
|
||||
/**
|
||||
* Tests that an unsupported or missing hint leaves the evaluation for CashAssembly instead of throwing.
|
||||
*/
|
||||
const testCompileCashAssemblyStringSkipsWhenHintIsNotASupportedPrimitive = (): void => {
|
||||
const compileWithNonPrimitiveHint = (): string =>
|
||||
compileCashAssemblyString({
|
||||
cashAssemblyText: '$(<label.toIso8601>)',
|
||||
variables: { label: 'hello' },
|
||||
templateVariables: {
|
||||
label: {
|
||||
name: 'Label',
|
||||
description: 'A text label',
|
||||
type: XOTemplateBaseTypes.STRING,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(compileWithNonPrimitiveHint).toThrow(CashAssemblyRequiredVariableMissingError);
|
||||
expect(compileWithNonPrimitiveHint).toThrow('Missing required variable: variableNames [label.toIso8601]');
|
||||
};
|
||||
|
||||
/**
|
||||
* Tests that a supported hint with an unknown method throws CashAssemblyPrimitiveMethodMissingError.
|
||||
*
|
||||
* Once the hint maps to a supported primitive class, a missing method is a primitive resolution
|
||||
* failure rather than a CashAssembly missing variable.
|
||||
*/
|
||||
const testCompileCashAssemblyStringThrowsWhenMethodDoesNotExistOnPrimitive = (): void => {
|
||||
const compileWithUnknownMethod = (): string =>
|
||||
compileCashAssemblyString({
|
||||
cashAssemblyText: '$(<expiry.notARealMethod>)',
|
||||
variables: { expiry: Date.parse('2024-01-15T10:30:00.000Z') },
|
||||
templateVariables: {
|
||||
expiry: {
|
||||
name: 'Expiry',
|
||||
description: 'Invitation expiry time',
|
||||
type: XOTemplateBaseTypes.INTEGER,
|
||||
hint: XOTemplatePrimitiveTypes.TIMESTAMP,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(compileWithUnknownMethod).toThrow(CashAssemblyPrimitiveMethodMissingError);
|
||||
expect(compileWithUnknownMethod).toThrow('CashAssembly primitive method does not exist: identifier "expiry.notARealMethod", methodName "notARealMethod", hint "timestamp"');
|
||||
};
|
||||
|
||||
/**
|
||||
* Tests that a committed supported transform throws when the runtime value is missing.
|
||||
*/
|
||||
const testCompileCashAssemblyStringThrowsWhenSupportedTransformIsMissingRuntimeValue = (): void => {
|
||||
const compileWithMissingValue = (): string =>
|
||||
compileCashAssemblyString({
|
||||
cashAssemblyText: '$(<expiry.toIso8601>)',
|
||||
variables: {},
|
||||
templateVariables: {
|
||||
expiry: {
|
||||
name: 'Expiry',
|
||||
description: 'Invitation expiry time',
|
||||
type: XOTemplateBaseTypes.INTEGER,
|
||||
hint: XOTemplatePrimitiveTypes.TIMESTAMP,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(compileWithMissingValue).toThrow(CashAssemblyPrimitiveVariableMissingError);
|
||||
expect(compileWithMissingValue).toThrow('CashAssembly primitive variable is missing from the variables map: identifier "expiry.toIso8601", variableName "expiry"');
|
||||
};
|
||||
|
||||
/**
|
||||
* Tests that multiple supported `<base.method>` pushes inside one evaluation.
|
||||
*/
|
||||
const testCompileCashAssemblyStringResolvesMultiplePrimitiveMethodPushesInOneEvaluation = (): void => {
|
||||
const compiledText = compileCashAssemblyString({
|
||||
cashAssemblyText: '$(<amount.toSatoshis> <fee.toSatoshis> OP_SUB)',
|
||||
variables: {
|
||||
amount: 5000n,
|
||||
fee: 1000n,
|
||||
},
|
||||
templateVariables: {
|
||||
amount: {
|
||||
name: 'Amount',
|
||||
description: 'Payment in satoshis',
|
||||
type: XOTemplateBaseTypes.BIGINT,
|
||||
hint: XOTemplatePrimitiveTypes.SATOSHIS,
|
||||
},
|
||||
fee: {
|
||||
name: 'Fee',
|
||||
description: 'Network fee in satoshis',
|
||||
type: XOTemplateBaseTypes.BIGINT,
|
||||
hint: XOTemplatePrimitiveTypes.SATOSHIS,
|
||||
},
|
||||
},
|
||||
evaluationDecodeMode: 'bigint',
|
||||
});
|
||||
|
||||
expect(compiledText).toBe('4000');
|
||||
};
|
||||
|
||||
/**
|
||||
* Tests that lock and display evaluations are extracted from one string and that the lock
|
||||
* hash evaluation compiles to the HASH256 of the redeem script.
|
||||
*/
|
||||
const testCompileCashAssemblyStringHexCompilesP2sLockHashFromMixedLockAndDisplayText = (): void => {
|
||||
// Test only key material. Never use with real funds.
|
||||
const privateKeyBytes = generatePrivateKey();
|
||||
const publicKeyResult = secp256k1.derivePublicKeyCompressed(privateKeyBytes);
|
||||
|
||||
expect(typeof publicKeyResult).not.toBe('string');
|
||||
const publicKeyBytes = publicKeyResult as Uint8Array;
|
||||
|
||||
// Minimal P2S redeem body. Push the compressed public key, then OP_CHECKSIG.
|
||||
const redeemScript = Uint8Array.from([ ...encodeDataPush(publicKeyBytes), 0xac ]);
|
||||
const expectedRedeemHashHex = binToHex(hash256(redeemScript));
|
||||
|
||||
const lockText = 'Lock OP_HASH256 <$(<redeemScript> OP_HASH256)> OP_EQUAL. ';
|
||||
const displayTextSource = 'Expires $(<expiry.toIso8601>). Amount $(<amount.toBCH>) BCH ($(<amount.toString>) satoshis).';
|
||||
const cashAssemblyText = `${lockText}${displayTextSource}`;
|
||||
|
||||
const evaluations = extractCashAssemblyEvaluations(cashAssemblyText);
|
||||
expect(evaluations).toStrictEqual([ '$(<redeemScript> OP_HASH256)', '$(<expiry.toIso8601>)', '$(<amount.toBCH>)', '$(<amount.toString>)' ]);
|
||||
expect(extractVariablesFromEvaluations(evaluations)).toStrictEqual([ 'redeemScript', 'expiry.toIso8601', 'amount.toBCH', 'amount.toString' ]);
|
||||
|
||||
const redeemHashHex = compileCashAssemblyString({
|
||||
cashAssemblyText: evaluations[0],
|
||||
variables: { redeemScript },
|
||||
evaluationDecodeMode: 'hex',
|
||||
});
|
||||
|
||||
expect(redeemHashHex).toBe(expectedRedeemHashHex);
|
||||
};
|
||||
|
||||
/**
|
||||
* Tests a P2SH32 spend whose redeem script mixes primitive method evaluations with a real Schnorr checksig.
|
||||
*
|
||||
* Redeem requires:
|
||||
* 1. A P2PKH style signature check against owner.public_key.
|
||||
* 2. UTXO value equal to price + fee, where price and fee are bound via Satoshis.toSatoshis then OP_ADD.
|
||||
*/
|
||||
const testP2sh32RedeemWithPrimitiveAddAndSchnorrSignatureSpendsSuccessfully = (): void => {
|
||||
// Test only key material. Never use with real funds.
|
||||
const privateKeyBytes = generatePrivateKey();
|
||||
const priceSatoshis = 5000n;
|
||||
const feeSatoshis = 1000n;
|
||||
const totalSatoshis = 6000n;
|
||||
|
||||
const redeemScriptSource =
|
||||
'OP_DUP OP_HASH160 <$(<owner.public_key> OP_HASH160)> OP_EQUALVERIFY OP_CHECKSIGVERIFY '
|
||||
+ 'OP_INPUTINDEX OP_UTXOVALUE <$(<price.toSatoshis> <fee.toSatoshis> OP_ADD)> OP_EQUAL';
|
||||
|
||||
// Resolve primitive method pushes so CashAssembly receives VM number bytes under the full identifiers.
|
||||
const primitiveMethodBytes = resolvePrimitiveMethodBytes({
|
||||
identifiers: [ 'price.toSatoshis', 'fee.toSatoshis' ],
|
||||
variables: {
|
||||
price: priceSatoshis,
|
||||
fee: feeSatoshis,
|
||||
},
|
||||
templateVariables: {
|
||||
price: {
|
||||
name: 'Price',
|
||||
description: 'Base payment in satoshis',
|
||||
type: XOTemplateBaseTypes.BIGINT,
|
||||
hint: XOTemplatePrimitiveTypes.SATOSHIS,
|
||||
},
|
||||
fee: {
|
||||
name: 'Fee',
|
||||
description: 'Network fee in satoshis',
|
||||
type: XOTemplateBaseTypes.BIGINT,
|
||||
hint: XOTemplatePrimitiveTypes.SATOSHIS,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(Object.keys(primitiveMethodBytes)).toStrictEqual([ 'price.toSatoshis', 'fee.toSatoshis' ]);
|
||||
|
||||
const redeemCompiler = createCompilerBch({
|
||||
scripts: {
|
||||
redeem: redeemScriptSource,
|
||||
},
|
||||
variables: {
|
||||
owner: { type: 'Key' },
|
||||
price: { type: 'WalletData' },
|
||||
fee: { type: 'WalletData' },
|
||||
},
|
||||
});
|
||||
|
||||
const redeemResult = redeemCompiler.generateBytecode({
|
||||
data: {
|
||||
keys: { privateKeys: { owner: privateKeyBytes } },
|
||||
bytecode: primitiveMethodBytes,
|
||||
},
|
||||
scriptId: 'redeem',
|
||||
});
|
||||
|
||||
expect(redeemResult.success).toBe(true);
|
||||
if (redeemResult.success !== true) {
|
||||
return;
|
||||
}
|
||||
|
||||
const redeemBytecode = redeemResult.bytecode;
|
||||
|
||||
// P2SH32 lock is OP_HASH256 <hash256(redeem)> OP_EQUAL.
|
||||
const lockingBytecode = Uint8Array.from([ 0xaa, ...encodeDataPush(hash256(redeemBytecode)), 0x87 ]);
|
||||
|
||||
// Unlock compiler signs against the same redeem body used as the covered bytecode for P2SH.
|
||||
const unlockCompiler = createCompilerBch({
|
||||
scripts: {
|
||||
lock: redeemScriptSource,
|
||||
unlock: '<owner.schnorr_signature.all_outputs> <owner.public_key>',
|
||||
},
|
||||
unlockingScripts: {
|
||||
unlock: 'lock',
|
||||
},
|
||||
variables: {
|
||||
owner: { type: 'Key' },
|
||||
price: { type: 'WalletData' },
|
||||
fee: { type: 'WalletData' },
|
||||
},
|
||||
});
|
||||
|
||||
const program = {
|
||||
inputIndex: 0,
|
||||
sourceOutputs: [{ lockingBytecode, valueSatoshis: totalSatoshis }],
|
||||
transaction: {
|
||||
inputs: [
|
||||
{
|
||||
outpointIndex: 0,
|
||||
outpointTransactionHash: new Uint8Array(32),
|
||||
sequenceNumber: 0,
|
||||
unlockingBytecode: new Uint8Array(),
|
||||
},
|
||||
],
|
||||
locktime: 0,
|
||||
outputs: [{ lockingBytecode: new Uint8Array(), valueSatoshis: totalSatoshis }],
|
||||
version: 2,
|
||||
},
|
||||
};
|
||||
|
||||
const unlockResult = unlockCompiler.generateBytecode({
|
||||
data: {
|
||||
keys: { privateKeys: { owner: privateKeyBytes } },
|
||||
bytecode: primitiveMethodBytes,
|
||||
compilationContext: program,
|
||||
},
|
||||
scriptId: 'unlock',
|
||||
});
|
||||
|
||||
expect(unlockResult.success).toBe(true);
|
||||
if (unlockResult.success !== true) {
|
||||
return;
|
||||
}
|
||||
|
||||
// P2SH unlock pushes signature and public key, then the redeem script itself.
|
||||
program.transaction.inputs[0].unlockingBytecode = Uint8Array.from([ ...unlockResult.bytecode, ...encodeDataPush(redeemBytecode) ]);
|
||||
|
||||
const virtualMachine = createVirtualMachineBch();
|
||||
const evaluationResult = virtualMachine.evaluate(program);
|
||||
|
||||
expect(virtualMachine.stateSuccess(evaluationResult)).toBe(true);
|
||||
|
||||
// Wrong UTXO value must fail the baked price + fee equality check.
|
||||
const wrongValueProgram = {
|
||||
...program,
|
||||
sourceOutputs: [{ lockingBytecode, valueSatoshis: totalSatoshis - 1n }],
|
||||
transaction: {
|
||||
...program.transaction,
|
||||
inputs: [
|
||||
{
|
||||
...program.transaction.inputs[0],
|
||||
unlockingBytecode: new Uint8Array(),
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
const wrongValueUnlockResult = unlockCompiler.generateBytecode({
|
||||
data: {
|
||||
keys: { privateKeys: { owner: privateKeyBytes } },
|
||||
bytecode: primitiveMethodBytes,
|
||||
compilationContext: wrongValueProgram,
|
||||
},
|
||||
scriptId: 'unlock',
|
||||
});
|
||||
|
||||
expect(wrongValueUnlockResult.success).toBe(true);
|
||||
if (wrongValueUnlockResult.success !== true) {
|
||||
return;
|
||||
}
|
||||
|
||||
wrongValueProgram.transaction.inputs[0].unlockingBytecode = Uint8Array.from([
|
||||
...wrongValueUnlockResult.bytecode,
|
||||
...encodeDataPush(redeemBytecode),
|
||||
]);
|
||||
|
||||
expect(virtualMachine.stateSuccess(virtualMachine.evaluate(wrongValueProgram))).not.toBe(true);
|
||||
};
|
||||
|
||||
/**
|
||||
* Tests that generateCashAssemblyBytecode produces the pushed bytes for a variable evaluation.
|
||||
*/
|
||||
const testGenerateCashAssemblyBytecodeProducesRawBytesForVariable = (): void => {
|
||||
const evaluation = '$(<inputValue>)';
|
||||
const compiler = compileCashAssemblyEvaluations([ evaluation ]);
|
||||
|
||||
const bytecode = generateCashAssemblyBytecode(compiler, evaluation, { inputValue: new Uint8Array([ 4 ]) });
|
||||
|
||||
expect(bytecode).toStrictEqual(new Uint8Array([ 4 ]));
|
||||
};
|
||||
|
||||
/**
|
||||
* Tests that generateCashAssemblyBytecode throws CashAssemblyVariableTypeMismatchError when a variable value is not a Uint8Array.
|
||||
*/
|
||||
const testGenerateCashAssemblyBytecodeThrowsForNonUint8ArrayVariable = (): void => {
|
||||
const evaluation = '$(<inputValue>)';
|
||||
const compiler = compileCashAssemblyEvaluations([ evaluation ]);
|
||||
|
||||
const generateWithTypeMismatch = (): Uint8Array =>
|
||||
generateCashAssemblyBytecode(
|
||||
compiler,
|
||||
evaluation,
|
||||
// @ts-expect-error intentional non Uint8Array input to exercise the runtime guard
|
||||
{ inputValue: 4 },
|
||||
);
|
||||
|
||||
expect(generateWithTypeMismatch).toThrow(CashAssemblyVariableTypeMismatchError);
|
||||
|
||||
expect(generateWithTypeMismatch).toThrow('Variable type mismatch: variableKey "inputValue", expected Uint8Array, got number');
|
||||
};
|
||||
|
||||
/**
|
||||
* Tests that generateCashAssemblyBytecode throws CashAssemblyCompilationFailedError when the
|
||||
* evaluation references an identifier the compiler cannot resolve as an opcode, variable, or script.
|
||||
*/
|
||||
const testGenerateCashAssemblyBytecodeThrowsForUnresolvedIdentifier = (): void => {
|
||||
const evaluation = '$(<inputValue> unresolvedIdentifier)';
|
||||
const compiler = compileCashAssemblyEvaluations([ evaluation ]);
|
||||
|
||||
const variables = { inputValue: new Uint8Array([ 1 ]) };
|
||||
|
||||
const generateWithUnresolvedIdentifier = (): Uint8Array => generateCashAssemblyBytecode(compiler, evaluation, variables);
|
||||
|
||||
expect(generateWithUnresolvedIdentifier).toThrow(CashAssemblyCompilationFailedError);
|
||||
|
||||
expect(generateWithUnresolvedIdentifier).toThrow('Cash assembly compilation failed: Unknown identifier "unresolvedIdentifier".');
|
||||
};
|
||||
|
||||
const runTests = async (): Promise<void> => {
|
||||
test('isCashAssemblyExpression: matches a full expression', testIsCashAssemblyExpressionMatchesFullExpression);
|
||||
test('isCashAssemblyExpression: rejects surrounding text', testIsCashAssemblyExpressionRejectsSurroundingText);
|
||||
test('isCashAssemblyExpression: rejects plain text', testIsCashAssemblyExpressionRejectsPlainText);
|
||||
test('isCashAssemblyExpression: rejects an empty evaluation', testIsCashAssemblyExpressionRejectsEmptyEvaluation);
|
||||
test('isCashAssemblyExpression: rejects non-string input', testIsCashAssemblyExpressionRejectsNonStringInput);
|
||||
|
||||
test('extractCashAssemblyEvaluations: finds a single evaluation', testExtractCashAssemblyEvaluationsFindsSingleEvaluation);
|
||||
test(
|
||||
'extractCashAssemblyEvaluations: returns an empty array when none are found',
|
||||
testExtractCashAssemblyEvaluationsReturnsEmptyArrayWhenNoneFound,
|
||||
);
|
||||
|
||||
test('extractVariablesFromEvaluations: deduplicates across evaluations', testExtractVariablesFromEvaluationsDeduplicatesAcrossEvaluations);
|
||||
test('extractVariablesFromEvaluations: excludes literal tokens', testExtractVariablesFromEvaluationsExcludesLiteralTokens);
|
||||
|
||||
test('decodeCompiledCashAssemblyEvaluation: decodes bigint', testDecodeCompiledCashAssemblyEvaluationDecodesBigint);
|
||||
test(
|
||||
'decodeCompiledCashAssemblyEvaluation: throws when bigint bytes are not a VM number',
|
||||
testDecodeCompiledCashAssemblyEvaluationThrowsWhenBigintIsNotAVmNumber,
|
||||
);
|
||||
test('decodeCompiledCashAssemblyEvaluation: decodes hex', testDecodeCompiledCashAssemblyEvaluationDecodesHex);
|
||||
test('decodeCompiledCashAssemblyEvaluation: decodes boolean', testDecodeCompiledCashAssemblyEvaluationDecodesBoolean);
|
||||
test('decodeCompiledCashAssemblyEvaluation: defaults to utf8', testDecodeCompiledCashAssemblyEvaluationDefaultsToUtf8);
|
||||
test('decodeCompiledCashAssemblyEvaluation: decodes uint8array', testDecodeCompiledCashAssemblyEvaluationDecodesUint8Array);
|
||||
|
||||
test('compileCashAssemblyString: decodes a single evaluation as bigint', testCompileCashAssemblyStringDecodesSingleEvaluationAsBigint);
|
||||
test(
|
||||
'compileCashAssemblyString: compiles multiple evaluations in one string',
|
||||
testCompileCashAssemblyStringCompilesMultipleEvaluationsInOneString,
|
||||
);
|
||||
test(
|
||||
'compileCashAssemblyString: reuses the same variable twice in one evaluation',
|
||||
testCompileCashAssemblyStringReusesTheSameVariableTwiceInOneEvaluation,
|
||||
);
|
||||
test('compileCashAssemblyString: evaluates conditional literals', testCompileCashAssemblyStringEvaluatesConditionalLiterals);
|
||||
test('compileCashAssemblyString: defaults to utf8 decode mode', testCompileCashAssemblyStringDefaultsToUtf8DecodeMode);
|
||||
test('compileCashAssemblyString: accepts a Uint8Array variable', testCompileCashAssemblyStringAcceptsUint8ArrayVariable);
|
||||
test('compileCashAssemblyString: throws for a missing variable', testCompileCashAssemblyStringThrowsForMissingVariable);
|
||||
test(
|
||||
'compileCashAssemblyString: throws for a non-integer number and produces the same display with integer DIV and MOD',
|
||||
testCompileCashAssemblyStringThrowsForNonIntegerNumberAndProducesSameDisplayWithIntegerDivMod,
|
||||
);
|
||||
test('compileCashAssemblyString: throws for an unsafe integer number', testCompileCashAssemblyStringThrowsForUnsafeIntegerNumber);
|
||||
test('compileCashAssemblyString: throws for a non-finite number', testCompileCashAssemblyStringThrowsForNonFiniteNumber);
|
||||
test(
|
||||
'compileCashAssemblyString: does not reinterpret injected evaluation-looking text',
|
||||
testCompileCashAssemblyStringDoesNotReinterpretInjectedEvaluationLookingText,
|
||||
);
|
||||
test(
|
||||
'compileCashAssemblyString: leaves method evaluations for CashAssembly without templateVariables',
|
||||
testCompileCashAssemblyStringLeavesMethodEvaluationForCashAssemblyWithoutTemplateVariables,
|
||||
);
|
||||
test(
|
||||
'compileCashAssemblyString: skips when hint is not a supported primitive',
|
||||
testCompileCashAssemblyStringSkipsWhenHintIsNotASupportedPrimitive,
|
||||
);
|
||||
test(
|
||||
'compileCashAssemblyString: throws when method does not exist on the primitive',
|
||||
testCompileCashAssemblyStringThrowsWhenMethodDoesNotExistOnPrimitive,
|
||||
);
|
||||
test(
|
||||
'compileCashAssemblyString: throws when a supported transform is missing its runtime value',
|
||||
testCompileCashAssemblyStringThrowsWhenSupportedTransformIsMissingRuntimeValue,
|
||||
);
|
||||
test(
|
||||
'compileCashAssemblyString: resolves multiple primitive method pushes in one evaluation',
|
||||
testCompileCashAssemblyStringResolvesMultiplePrimitiveMethodPushesInOneEvaluation,
|
||||
);
|
||||
test(
|
||||
'compileCashAssemblyString: hex-compiles a P2S lock hash extracted from mixed lock and display text',
|
||||
testCompileCashAssemblyStringHexCompilesP2sLockHashFromMixedLockAndDisplayText,
|
||||
);
|
||||
test(
|
||||
'P2SH32 redeem: primitive OP_ADD and Schnorr signature spend successfully',
|
||||
testP2sh32RedeemWithPrimitiveAddAndSchnorrSignatureSpendsSuccessfully,
|
||||
);
|
||||
|
||||
test('generateCashAssemblyBytecode: produces raw bytes for a variable', testGenerateCashAssemblyBytecodeProducesRawBytesForVariable);
|
||||
test('generateCashAssemblyBytecode: throws for a non-Uint8Array variable', testGenerateCashAssemblyBytecodeThrowsForNonUint8ArrayVariable);
|
||||
test('generateCashAssemblyBytecode: throws for an unresolved identifier', testGenerateCashAssemblyBytecodeThrowsForUnresolvedIdentifier);
|
||||
};
|
||||
|
||||
await runTests();
|
||||
@@ -1,5 +1,5 @@
|
||||
import { expect, test } from 'vitest';
|
||||
import { extendedJsonReviver } from '../source/index.ts';
|
||||
import { extendedJsonReviver, toExtendedJson, fromExtendedJson } from '../source/index.ts';
|
||||
|
||||
/**
|
||||
* Tests that extendedJsonReviver reconstructs a positive bigint.
|
||||
@@ -64,6 +64,34 @@ const testReviverPassesThroughPlainString = (): void => {
|
||||
expect(reconstructed).toBe('just a string');
|
||||
};
|
||||
|
||||
/**
|
||||
* Tests that toExtendedJson serializes an object to a string.
|
||||
*/
|
||||
const testToExtendedJsonSerializesObject = (): void => {
|
||||
// Define an object with a Uint8Array
|
||||
const extendedObject = { bytes: new Uint8Array([ 0xab, 0xcd ]) };
|
||||
|
||||
// Serialize the object to a string
|
||||
const serialized = toExtendedJson(extendedObject);
|
||||
|
||||
// The serialized string should contain the Uint8Array in Extended JSON format (Uint8Arrays are encoded as hex strings)
|
||||
expect(serialized).toBe('{"bytes":"<uint8array: abcd>"}');
|
||||
};
|
||||
|
||||
/**
|
||||
* Tests that fromExtendedJson deserializes a string to an object.
|
||||
*/
|
||||
const testFromExtendedJsonDeserializesString = (): void => {
|
||||
// Define a string in Extended JSON format that contains a Uint8Array
|
||||
const serializedObject = '{"bytes":"<uint8array: abcd>"}';
|
||||
|
||||
// Deserialize the string to an object
|
||||
const deserialized = fromExtendedJson(serializedObject);
|
||||
|
||||
// The deserialized object should contain the Uint8Array
|
||||
expect(deserialized).toStrictEqual({ bytes: new Uint8Array([ 0xab, 0xcd ]) });
|
||||
};
|
||||
|
||||
/**
|
||||
* Tests that extendedJsonReviver passes through non-string values.
|
||||
*/
|
||||
@@ -85,6 +113,8 @@ const runTests = async (): Promise<void> => {
|
||||
test('extendedJsonReviver: reconstructs an empty Uint8Array', testReviverReconstructsEmptyUint8Array);
|
||||
test('extendedJsonReviver: passes through plain strings', testReviverPassesThroughPlainString);
|
||||
test('extendedJsonReviver: passes through non-string values', testReviverPassesThroughNonStringValues);
|
||||
test('toExtendedJson: serializes an object to a string', testToExtendedJsonSerializesObject);
|
||||
test('fromExtendedJson: deserializes a string to an object', testFromExtendedJsonDeserializesString);
|
||||
};
|
||||
|
||||
await runTests();
|
||||
|
||||
@@ -0,0 +1,214 @@
|
||||
import { expect, test, vi } from 'vitest';
|
||||
|
||||
import { AsyncPushIterator } from '../../source/sse-session/async-push-iterator.ts';
|
||||
|
||||
/**
|
||||
* Collects every value from the iterator into an array.
|
||||
*
|
||||
* @param iterator - Iterator under test.
|
||||
*/
|
||||
const collectAll = async <T>(iterator: AsyncPushIterator<T>): Promise<T[]> => {
|
||||
const results: T[] = [];
|
||||
|
||||
for await (const value of iterator) {
|
||||
results.push(value);
|
||||
}
|
||||
|
||||
return results;
|
||||
};
|
||||
|
||||
/**
|
||||
* Tests that values pushed while a consumer is already waiting are delivered in order.
|
||||
*/
|
||||
const testPushComposedPushAndConsume = async (): Promise<void> => {
|
||||
const iterator = new AsyncPushIterator<number>();
|
||||
|
||||
const result = (): Promise<number[]> => collectAll(iterator);
|
||||
|
||||
iterator.push(1);
|
||||
iterator.push(2);
|
||||
iterator.push(3);
|
||||
iterator.close();
|
||||
|
||||
await expect(result()).resolves.toEqual([ 1, 2, 3 ]);
|
||||
};
|
||||
|
||||
/**
|
||||
* Tests that values pushed before `for await...of` starts are buffered and yielded
|
||||
* once the consumer begins reading.
|
||||
*/
|
||||
const testPushComposedBuffersValuesPushedBeforeLoopStarts = async (): Promise<void> => {
|
||||
const iterator = new AsyncPushIterator<number>();
|
||||
|
||||
iterator.push(1);
|
||||
iterator.push(2);
|
||||
iterator.push(3);
|
||||
|
||||
const result = collectAll(iterator);
|
||||
|
||||
iterator.close();
|
||||
|
||||
await expect(result).resolves.toEqual([ 1, 2, 3 ]);
|
||||
};
|
||||
|
||||
/**
|
||||
* Tests that the iterator completes with no values when nothing was pushed.
|
||||
*/
|
||||
const testPushComposedResolvesWithNoValues = async (): Promise<void> => {
|
||||
const iterator = new AsyncPushIterator<number>();
|
||||
|
||||
const result = async (): Promise<number[]> => collectAll(iterator);
|
||||
|
||||
iterator.close();
|
||||
|
||||
await expect(result()).resolves.toEqual([]);
|
||||
};
|
||||
|
||||
/**
|
||||
* Tests that values pushed after {@link AsyncPushIterator.close} are ignored.
|
||||
*/
|
||||
const testPushComposedIgnoresValuesAfterClose = async (): Promise<void> => {
|
||||
const iterator = new AsyncPushIterator<number>();
|
||||
|
||||
const result = async (): Promise<number[]> => collectAll(iterator);
|
||||
|
||||
iterator.push(1);
|
||||
iterator.push(2);
|
||||
iterator.push(3);
|
||||
iterator.close();
|
||||
iterator.push(4);
|
||||
|
||||
await expect(result()).resolves.toEqual([ 1, 2, 3 ]);
|
||||
};
|
||||
|
||||
/**
|
||||
* Tests that only one async consumer can read from the composed ReadableStream at a time.
|
||||
*
|
||||
* Unlike the hand-rolled async-push-iterator, the second consumer fails with a
|
||||
* stream lock error rather than TooManyAsyncIteratorsError.
|
||||
*/
|
||||
const testPushComposedRejectsMultipleConsumers = async (): Promise<void> => {
|
||||
const iterator = new AsyncPushIterator<number>();
|
||||
|
||||
const failureFlag = vi.fn();
|
||||
|
||||
const successfulIterator = (): Promise<number[]> => collectAll(iterator);
|
||||
|
||||
const failedIterator = async (): Promise<void> => {
|
||||
try {
|
||||
/* eslint-disable-next-line */
|
||||
for await (const _value of iterator) {
|
||||
}
|
||||
} catch {
|
||||
failureFlag();
|
||||
}
|
||||
};
|
||||
|
||||
const promises = [ successfulIterator(), failedIterator() ];
|
||||
|
||||
iterator.close();
|
||||
|
||||
await Promise.all(promises);
|
||||
|
||||
expect(failureFlag).toHaveBeenCalledOnce();
|
||||
};
|
||||
|
||||
/**
|
||||
* Tests that closing before iteration starts lets the loop finish immediately.
|
||||
*/
|
||||
const testPushComposedResolvesWhenClosedBeforeLoop = async (): Promise<void> => {
|
||||
const iterator = new AsyncPushIterator<number>();
|
||||
|
||||
iterator.close();
|
||||
|
||||
await expect(collectAll(iterator)).resolves.toEqual([]);
|
||||
};
|
||||
|
||||
/**
|
||||
* Tests that breaking out of `for await...of` early does not cancel the stream.
|
||||
*
|
||||
* {@link AsyncPushIterator} uses `preventCancel: true` so producers can keep pushing
|
||||
* and a later consumer can read the remaining values.
|
||||
*/
|
||||
const testPushComposedAllowsPushingAfterEarlyBreak = async (): Promise<void> => {
|
||||
const iterator = new AsyncPushIterator<number>();
|
||||
|
||||
iterator.push(1);
|
||||
|
||||
const firstPass: number[] = [];
|
||||
|
||||
for await (const value of iterator) {
|
||||
firstPass.push(value);
|
||||
break;
|
||||
}
|
||||
|
||||
iterator.push(2);
|
||||
iterator.push(3);
|
||||
iterator.close();
|
||||
|
||||
const secondPass = await collectAll(iterator);
|
||||
|
||||
expect(firstPass).toEqual([ 1 ]);
|
||||
expect(secondPass).toEqual([ 2, 3 ]);
|
||||
};
|
||||
|
||||
/**
|
||||
* Tests that the iterator rejects after {@link AsyncPushIterator.error} is called.
|
||||
*/
|
||||
const testPushIteratorRejectsAfterError = async (): Promise<void> => {
|
||||
const iterator = new AsyncPushIterator<number>();
|
||||
|
||||
iterator.error(new Error('Stream has been closed for a test'));
|
||||
|
||||
await expect(collectAll(iterator)).rejects.toThrow('Stream has been closed for a test');
|
||||
};
|
||||
|
||||
/**
|
||||
* Tests that the iterator closes the stream when error() is called.
|
||||
*/
|
||||
const testPushIteratorClosesWhenErrorIsCalled = async (): Promise<void> => {
|
||||
const iterator = new AsyncPushIterator<number>();
|
||||
|
||||
iterator.error(new Error('Stream has been closed for a test'));
|
||||
expect(iterator.closed).toBe(true);
|
||||
};
|
||||
|
||||
/**
|
||||
* Tests that subsequent calls to error() are ignored.
|
||||
*/
|
||||
const testPushIteratorIgnoresSubsequentErrorCalls = async (): Promise<void> => {
|
||||
const iterator = new AsyncPushIterator<number>();
|
||||
|
||||
iterator.error(new Error('Stream has been closed for a test'));
|
||||
expect(iterator.closed).toBe(true);
|
||||
|
||||
expect(iterator.error(new Error('Second error'))).toBe(undefined);
|
||||
};
|
||||
|
||||
/**
|
||||
* Tests that a consumer can check if the iterator is closed.
|
||||
*/
|
||||
const testPushIteratorCanCheckIfClosed = async (): Promise<void> => {
|
||||
const iterator = new AsyncPushIterator<number>();
|
||||
|
||||
expect(iterator.closed).toBe(false);
|
||||
|
||||
iterator.close();
|
||||
expect(iterator.closed).toBe(true);
|
||||
};
|
||||
|
||||
const runTests = async (): Promise<void> => {
|
||||
test('AsyncPushIterator: pushes and consumes values', testPushComposedPushAndConsume);
|
||||
test('AsyncPushIterator: buffers values pushed before the for-await loop starts', testPushComposedBuffersValuesPushedBeforeLoopStarts);
|
||||
test('AsyncPushIterator: resolves with no values when nothing was pushed', testPushComposedResolvesWithNoValues);
|
||||
test('AsyncPushIterator: ignores values pushed after close', testPushComposedIgnoresValuesAfterClose);
|
||||
test('AsyncPushIterator: rejects multiple consumers', testPushComposedRejectsMultipleConsumers);
|
||||
test('AsyncPushIterator: resolves immediately when closed before the loop starts', testPushComposedResolvesWhenClosedBeforeLoop);
|
||||
test('AsyncPushIterator: keeps the stream open after an early break', testPushComposedAllowsPushingAfterEarlyBreak);
|
||||
test('AsyncPushIterator: rejects after error', testPushIteratorRejectsAfterError);
|
||||
test('AsyncPushIterator: closes the stream when error() is called', testPushIteratorClosesWhenErrorIsCalled);
|
||||
test('AsyncPushIterator: ignores subsequent error() calls', testPushIteratorIgnoresSubsequentErrorCalls);
|
||||
test('AsyncPushIterator: can check if closed', testPushIteratorCanCheckIfClosed);
|
||||
};
|
||||
|
||||
await runTests();
|
||||
@@ -0,0 +1,155 @@
|
||||
import type { SSEvent } from '../../../source/sse-session/types.ts';
|
||||
|
||||
type EventFixture = {
|
||||
raw: string;
|
||||
parsed?: SSEvent[];
|
||||
};
|
||||
|
||||
/** Combines all the raw strings into a single chunk and flattens the parsed arrays into a single array to simulate multi-event chunks. */
|
||||
const withCombinedChunk = (fixtures: EventFixture[]): EventFixture => {
|
||||
return {
|
||||
raw: fixtures.map(({ raw }) => raw).join(''),
|
||||
parsed: fixtures.flatMap(({ parsed }) => parsed ?? []),
|
||||
};
|
||||
};
|
||||
|
||||
export const priceOracleEvents: EventFixture[] = [
|
||||
{
|
||||
raw: 'retry: 1000\nevent: 02664276fb7513f838f505c221680a9d963479ffb45452b0c744ddb6bd19ecacb3\ndata: {"message":"411d396aa53516008f35160049a46100","signature":"936ee3de4c179a1c23d5227c6cadd7ccee11aa1a5a48624ff59ff6954dc607752be8d069610a342ed7610444741bc5fa5dd9bd438fa6c1f1307a7b50a663e17c"}\n\n',
|
||||
parsed: [
|
||||
{
|
||||
retry: 1000,
|
||||
event: '02664276fb7513f838f505c221680a9d963479ffb45452b0c744ddb6bd19ecacb3',
|
||||
data: '{"message":"411d396aa53516008f35160049a46100","signature":"936ee3de4c179a1c23d5227c6cadd7ccee11aa1a5a48624ff59ff6954dc607752be8d069610a342ed7610444741bc5fa5dd9bd438fa6c1f1307a7b50a663e17c"}',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
raw: 'event: 0336f13d65e3bd6a521bf582f22b74f50edab7c278d38b80e319673b859f95d830\ndata: {"message":"471d396ac4571200b057120047e80000","signature":"4fb94f8a46475ebac87d49860372c67dae9e89412ff704b14e5248c4911228ad4c6429ae3771bf33122ebbfac3084c6a13efdbff9cb987af55032364ad6e1816"}\n\n',
|
||||
parsed: [
|
||||
{
|
||||
event: '0336f13d65e3bd6a521bf582f22b74f50edab7c278d38b80e319673b859f95d830',
|
||||
data: '{"message":"471d396ac4571200b057120047e80000","signature":"4fb94f8a46475ebac87d49860372c67dae9e89412ff704b14e5248c4911228ad4c6429ae3771bf33122ebbfac3084c6a13efdbff9cb987af55032364ad6e1816"}',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
raw: 'event: 021f8338ccd45a7790025de198a266f252ac43c95bf81d2469feff110beeac89dd\ndata: {"message":"491d396aad341a0092341a0064120000","signature":"dde76381753edd39beaeef0a413da54e906c9a2e2bb2ae482c14e72702cdff43e1ad26354663c15236ad9ec04e054ef39b4047c24c859d4f5aef1f58502b4686"}\n\n',
|
||||
parsed: [
|
||||
{
|
||||
event: '021f8338ccd45a7790025de198a266f252ac43c95bf81d2469feff110beeac89dd',
|
||||
data: '{"message":"491d396aad341a0092341a0064120000","signature":"dde76381753edd39beaeef0a413da54e906c9a2e2bb2ae482c14e72702cdff43e1ad26354663c15236ad9ec04e054ef39b4047c24c859d4f5aef1f58502b4686"}',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
raw: 'event: 02e82ad82eb88fcdfd02fd5e2e0a67bc6ef4139bbcb63ce0b107a7604deb9f7ce1\ndata: {"message":"4b1d396ab5341a009a341a00a0490000","signature":"3bbd83943e3cad352c3346fe6fa68913f66529551dab5aef574b73b5c276f9d860011dfb2d7ecae7f12b92b53b1dac4b94d15a3d78adcc70133bfef4c45e13c9"}\n\n',
|
||||
parsed: [
|
||||
{
|
||||
event: '02e82ad82eb88fcdfd02fd5e2e0a67bc6ef4139bbcb63ce0b107a7604deb9f7ce1',
|
||||
data: '{"message":"4b1d396ab5341a009a341a00a0490000","signature":"3bbd83943e3cad352c3346fe6fa68913f66529551dab5aef574b73b5c276f9d860011dfb2d7ecae7f12b92b53b1dac4b94d15a3d78adcc70133bfef4c45e13c9"}',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
raw: 'event: 038ab22e37cf020f6bbef40111ddc51083a936f0821de56ac01f799cf15b87904d\ndata: {"message":"4f1d396abd341a00a2341a00722c0000","signature":"3b771dc4490010066ccca9e02aa467b950d6fb5cd0000e3f59ec4fadffc5aedb6369b7a62b9c08695d6fa114cfbd62f3e98286ac98e276f66e481a89764a3716"}\n\n',
|
||||
parsed: [
|
||||
{
|
||||
event: '038ab22e37cf020f6bbef40111ddc51083a936f0821de56ac01f799cf15b87904d',
|
||||
data: '{"message":"4f1d396abd341a00a2341a00722c0000","signature":"3b771dc4490010066ccca9e02aa467b950d6fb5cd0000e3f59ec4fadffc5aedb6369b7a62b9c08695d6fa114cfbd62f3e98286ac98e276f66e481a89764a3716"}',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
raw: 'event: 030654b9598186fe4bc9e1b0490c6b85b13991cdb9a7afa34af1bbeee22a35487a\ndata: {"message":"531d396abc341a00a1341a001f0f0200","signature":"380e5954336b855736e22e602b543c4cb2918f0c0bb67d8723573f9cabb4dcf14714ebbfffb7687f97cad21115659d28d493b4e83014a7b62e36dd277d1c44c3"}\n\n',
|
||||
parsed: [
|
||||
{
|
||||
event: '030654b9598186fe4bc9e1b0490c6b85b13991cdb9a7afa34af1bbeee22a35487a',
|
||||
data: '{"message":"531d396abc341a00a1341a001f0f0200","signature":"380e5954336b855736e22e602b543c4cb2918f0c0bb67d8723573f9cabb4dcf14714ebbfffb7687f97cad21115659d28d493b4e83014a7b62e36dd277d1c44c3"}',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
raw: 'event: 03e980928f14fc98e1f9d75d15f0b67dc58cdd3f5c641b8f825b146bcc04bd232c\ndata: {"message":"531d396aa6952100e791210064120000","signature":"fc9acd0b9a0f09e1a5f48d6b61b27a445529cf36b88b39d5df5ed83edbc6b4469ede6933f001f9202f2b590dd8e0f016b9175157fbc5d6189a5fc78cc0443e4d"}\n\n',
|
||||
parsed: [
|
||||
{
|
||||
event: '03e980928f14fc98e1f9d75d15f0b67dc58cdd3f5c641b8f825b146bcc04bd232c',
|
||||
data: '{"message":"531d396aa6952100e791210064120000","signature":"fc9acd0b9a0f09e1a5f48d6b61b27a445529cf36b88b39d5df5ed83edbc6b4469ede6933f001f9202f2b590dd8e0f016b9175157fbc5d6189a5fc78cc0443e4d"}',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
raw: 'event: 02bb9b3324df889a66a57bc890b3452b84a2a74ba753f8842b06bba03e0fa0dfc5\ndata: {"message":"541d396adc191800c419180060440000","signature":"ed1e37324b58815cf448d16b653654c9759817461460fb929893b8afb763ea2aa8ad566149cc2e429590308f4c17f4c1cc74ba0384dff01fc039f941238d8590"}\n\n',
|
||||
parsed: [
|
||||
{
|
||||
event: '02bb9b3324df889a66a57bc890b3452b84a2a74ba753f8842b06bba03e0fa0dfc5',
|
||||
data: '{"message":"541d396adc191800c419180060440000","signature":"ed1e37324b58815cf448d16b653654c9759817461460fb929893b8afb763ea2aa8ad566149cc2e429590308f4c17f4c1cc74ba0384dff01fc039f941238d8590"}',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
raw: 'event: 02d3c1de9d4bc77d6c3608cbe44d10138c7488e592dc2b1e10a6cf0e92c2ecb047\ndata: {"message":"551d396a17952100d2912100474e0000","signature":"74d737547c4ee207d1bcf43eba0ae3c73264f1f477a2947ecf8685cdf0c79a408b22e8df49fe487c34ba0efe8cc520745d45b6338d69c493db36bdbf511b72fb"}\n\n',
|
||||
parsed: [
|
||||
{
|
||||
event: '02d3c1de9d4bc77d6c3608cbe44d10138c7488e592dc2b1e10a6cf0e92c2ecb047',
|
||||
data: '{"message":"551d396a17952100d2912100474e0000","signature":"74d737547c4ee207d1bcf43eba0ae3c73264f1f477a2947ecf8685cdf0c79a408b22e8df49fe487c34ba0efe8cc520745d45b6338d69c493db36bdbf511b72fb"}',
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
export const storageEvents: EventFixture[] = [
|
||||
{
|
||||
raw: 'id: 1234\ndata: { "hello": "world" }\n\n',
|
||||
parsed: [
|
||||
{
|
||||
id: '1234',
|
||||
data: '{ "hello": "world" }',
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
export const edgeCases: EventFixture[] = [
|
||||
// Multiple data lines
|
||||
{
|
||||
raw: 'data: { "hello": "world" }\ndata: { "hello": "world" }\n\n',
|
||||
parsed: [
|
||||
{
|
||||
data: '{ "hello": "world" }\n{ "hello": "world" }',
|
||||
},
|
||||
],
|
||||
},
|
||||
// Message without any colons
|
||||
{
|
||||
raw: 'message without any colons\n\n',
|
||||
parsed: [],
|
||||
},
|
||||
// Retry without a number
|
||||
{
|
||||
raw: 'retry: not a number\n\n',
|
||||
parsed: [],
|
||||
},
|
||||
// Data that contains a string with a new line in it
|
||||
{
|
||||
raw: 'data: { "hello": "world\\n" }\n\n',
|
||||
parsed: [
|
||||
{
|
||||
data: '{ "hello": "world\\n" }',
|
||||
},
|
||||
],
|
||||
},
|
||||
// Emoji character support (mostly to test partial chunks)
|
||||
{
|
||||
raw: 'data: Hello 😀 world\n\n',
|
||||
parsed: [
|
||||
{
|
||||
data: 'Hello 😀 world',
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
export const priceOracleEventsCombined = withCombinedChunk(priceOracleEvents);
|
||||
export const storageEventsCombined = withCombinedChunk(storageEvents);
|
||||
export const edgeCasesCombined = withCombinedChunk(edgeCases);
|
||||
@@ -0,0 +1,147 @@
|
||||
import { expect, test } from 'vitest';
|
||||
import { SSEEventParser } from '../../source/sse-session/sse-event-parser.ts';
|
||||
import type { SSEvent } from '../../source/sse-session/types.ts';
|
||||
|
||||
import {
|
||||
edgeCases,
|
||||
priceOracleEvents,
|
||||
storageEvents,
|
||||
priceOracleEventsCombined,
|
||||
storageEventsCombined,
|
||||
edgeCasesCombined,
|
||||
} from './fixtures/events.fixtures.ts';
|
||||
|
||||
/** Shared encoder for turning fixture strings into stream bytes. */
|
||||
const textEncoder = new TextEncoder();
|
||||
|
||||
/**
|
||||
* Tests that SSEEventParser parses a simple data event.
|
||||
*/
|
||||
const testSseEventParserParsesSimpleEvent = (): void => {
|
||||
const parser = new SSEEventParser();
|
||||
|
||||
const events = parser.parseEvents(textEncoder.encode('data: test\n\n'));
|
||||
|
||||
expect(events).toEqual([{ data: 'test' }]);
|
||||
};
|
||||
|
||||
/**
|
||||
* Tests that SSEEventParser parses all fixture events correctly.
|
||||
*/
|
||||
const testSseEventParserParsesAllFixtures = (): void => {
|
||||
const parser = new SSEEventParser();
|
||||
|
||||
// Combine all individual event fixtures from each domain.
|
||||
const combinedEvents = [ ...priceOracleEvents, ...storageEvents, ...edgeCases ];
|
||||
|
||||
// Iterate over each combined fixture and test that the parser parses all events correctly.
|
||||
for (const { raw, parsed } of combinedEvents) {
|
||||
const events = parser.parseEvents(textEncoder.encode(raw));
|
||||
|
||||
expect(events).toEqual(parsed);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Tests that SSEEventParser handles multiple events in the same chunk.
|
||||
*/
|
||||
const testSseEventParserHandlesMultipleEventsInOneChunk = (): void => {
|
||||
const parser = new SSEEventParser();
|
||||
|
||||
// Each combined fixture packs several events into one raw payload.
|
||||
const allEvents = [ priceOracleEventsCombined, storageEventsCombined, edgeCasesCombined ];
|
||||
|
||||
// Iterate over each combined fixture and test that the parser handles the multiple events in one chunk correctly.
|
||||
for (const { raw, parsed } of allEvents) {
|
||||
const bytes = textEncoder.encode(raw);
|
||||
const events = parser.parseEvents(bytes);
|
||||
|
||||
expect(events).toEqual(parsed);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Tests that SSEEventParser handles partial chunks delivered one character at a time.
|
||||
*/
|
||||
const testSseEventParserHandlesPartialChunks = (): void => {
|
||||
const fixtures = [ ...priceOracleEvents, ...storageEvents, ...edgeCases ];
|
||||
|
||||
// Iterate over each fixture and test that the parser handles the partial chunks correctly.
|
||||
for (const { raw, parsed } of fixtures) {
|
||||
const parser = new SSEEventParser();
|
||||
const finalEvents: SSEvent[] = [];
|
||||
|
||||
// Iterate over each character in the raw string and try to parse the events in its buffer
|
||||
for (const character of raw) {
|
||||
const bytes = textEncoder.encode(character);
|
||||
const events = parser.parseEvents(bytes);
|
||||
|
||||
finalEvents.push(...events);
|
||||
}
|
||||
|
||||
// Verify the events match the expected events.
|
||||
expect(finalEvents).toEqual(parsed ?? []);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Tests that SSEEventParser handles partial byte chunks delivered one byte at a time.
|
||||
* This tests that unicode characters (like emojis) are still parsed correctly despite being split between two "chunks".
|
||||
*/
|
||||
const testSseEventParserHandlesPartialByteChunks = (): void => {
|
||||
// Combine all individual event fixtures from each domain.
|
||||
const fixtures = [ ...priceOracleEvents, ...storageEvents, ...edgeCases ];
|
||||
|
||||
// Iterate over each fixture and test that the parser handles the partial byte chunks correctly.
|
||||
for (const { raw, parsed } of fixtures) {
|
||||
const parser = new SSEEventParser();
|
||||
const finalEvents: SSEvent[] = [];
|
||||
const bytes = textEncoder.encode(raw);
|
||||
|
||||
// Iterate over each byte in the raw string and try to parse the events in its buffer
|
||||
for (const byte of bytes) {
|
||||
const uint8Bytes = Uint8Array.of(byte);
|
||||
const events = parser.parseEvents(uint8Bytes);
|
||||
|
||||
finalEvents.push(...events);
|
||||
}
|
||||
|
||||
// Verify the events match the expected events.
|
||||
expect(finalEvents).toEqual(parsed ?? []);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Tests that SSEEventParser clears its buffer when reset is called.
|
||||
*/
|
||||
const testSseEventParserClearsBufferOnReset = (): void => {
|
||||
// Create a new parser.
|
||||
const parser = new SSEEventParser();
|
||||
|
||||
// Parse the events in the buffer.
|
||||
parser.parseEvents(textEncoder.encode('data: stale'));
|
||||
|
||||
// Reset the parser.
|
||||
parser.reset();
|
||||
|
||||
// Parse the events in the buffer.
|
||||
const events = parser.parseEvents(textEncoder.encode('data: fresh\n\n'));
|
||||
|
||||
// Verify the events match the expected events.
|
||||
expect(events).toEqual([
|
||||
{
|
||||
data: 'fresh',
|
||||
},
|
||||
]);
|
||||
};
|
||||
|
||||
const runTests = async (): Promise<void> => {
|
||||
test('SSEEventParser: parses a simple data event', testSseEventParserParsesSimpleEvent);
|
||||
test('SSEEventParser: parses all fixture events', testSseEventParserParsesAllFixtures);
|
||||
test('SSEEventParser: handles multiple events in one chunk', testSseEventParserHandlesMultipleEventsInOneChunk);
|
||||
test('SSEEventParser: handles partial chunks', testSseEventParserHandlesPartialChunks);
|
||||
test('SSEEventParser: handles partial byte chunks', testSseEventParserHandlesPartialByteChunks);
|
||||
test('SSEEventParser: clears the buffer on reset', testSseEventParserClearsBufferOnReset);
|
||||
};
|
||||
|
||||
await runTests();
|
||||
Reference in New Issue
Block a user