/** * 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) * * `` is a push statement. Compiles the contents and pushes the result onto the VM stack. * `` pushes the 33 byte compressed public key. `<1>` pushes the integer 1. * * `$()` is an evaluation. Runs the inner script in the VM and inserts the top stack * item as VM bytecode. * `$( OP_HASH160)` inserts the HASH160 of the public key. * * `<$()>` is a push of an evaluation result. It evaluates first then pushes. * For a P2PKH locking script example see * `OP_DUP OP_HASH160 <$( 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 `` 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 `$()` or `$()`. */ cashAssemblyText: string; /** * Used for both primitive method evaluations and normal CashAssembly compilation. */ variables: Record; /** * The mode to decode compiled evaluation bytes into a string. */ evaluationDecodeMode?: CompiledCashAssemblyDecodeMode; /** * Optional template variable definitions. When provided, each `` 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 <$()> OP_HASH160 $()"); * // returns ['$()', '$()'] */ 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(); 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} 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): 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 = {}; 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., ['$()', '$( )']). * @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 = {}; 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 = {}; 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} 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 `` 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 = {}; 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); }); };