Cash Assembly: Support for native cash assembly evaluations and primitive method resolution
This commit is contained in:
@@ -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);
|
||||
});
|
||||
};
|
||||
Reference in New Issue
Block a user