/** * 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. * For example `` pushes the 33 byte compressed public key and `<1>` pushes the integer 1. * * `$()` is an evaluation. Runs the inner script in the VM and inserts the top stack * item as VM bytecode. * For example `$( OP_HASH160)` inserts the HASH160 of the public key. * * `<$()>` is a push of an evaluation result. It evaluates first then pushes. * For example a P2PKH locking script looks like * `OP_DUP OP_HASH160 <$( OP_HASH160)> OP_EQUALVERIFY OP_CHECKSIG`. * * `variableId.operation` is a variable with a compiler resolved operation. * For example `someKey.public_key` produces the public key bytes and * `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. For example 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. * * ## This compile path * * This file is a layer on CashAssembly. Text outside `$()` is kept. Only `$()` is compiled. * For example `Paid $(scriptA) of $(scriptB)` keeps the words Paid and of. * * `$()` is found by scanning left to right with parenthesis matching by depth count. Quoted strings * and comments are skipped so a `)` there cannot end `$()`. Nested `$()` stay inside the outer * evaluation. That text is never passed to parseScript. * * Empty `$()` still uses this mixed path. When the string contains no `$()` at all, the entire * string is compiled as CashAssembly so push opcodes remain. */ import type { CompilerBch } from '@bitauth/libauth'; import { binToHex, binToUtf8, createCompilerBch, vmNumberToBigInt } from '@bitauth/libauth'; import { convertValueToBytes } from './bytes.ts'; import { CashAssemblyCompilationFailedError, CashAssemblyRequiredVariableMissingError, CashAssemblyVariableTypeMismatchError, CashAssemblyVmNumberDecodeError, } from './errors.ts'; import { resolvePrimitiveMethodBytes } from './primitive-evaluations.ts'; import { assertNoIdentifierCollisions } from './identifier-collisions.ts'; import { collectVariablesUsingParseScript } from './collect-evaluations.ts'; import { EMPTY_CASHASSEMBLY_EVALUATION } from './defaults.ts'; import { scanCashAssemblyEvaluations } from './scan-evaluations.ts'; import type { CompileCashAssemblyEvaluationsParameters, CompileCashAssemblySourceToBytesParameters, CompileCashAssemblySourceToDecodedTextParameters, CompileCashAssemblyStringParameters, CompiledCashAssemblyDecodeMode, } from './types.ts'; /** * 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); }; /** * 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 - 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. * @param {string[]} requiredVariableNames - WalletData names from {@link collectVariablesUsingParseScript} that must be present. * @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, requiredVariableNames: string[], ): Uint8Array => { // Missing WalletData must fail here. Libauth would otherwise compile as if the value were empty bytes. const missingVariables = requiredVariableNames.filter((name: string) => Object.hasOwn(variables, name) === false); // Refuse invented defaults so a missing value cannot compile into incorrect bytecode. if (missingVariables.length > 0) { throw new CashAssemblyRequiredVariableMissingError(missingVariables); } const bytecode: Record = {}; // Pass only required names so extra keys in `variables` are not sent to Libauth. for (const variableName of requiredVariableNames) { const value = variables[variableName]; // Libauth WalletData is always bytes. A number would compile incorrectly if we trusted TypeScript alone. if (value instanceof Uint8Array === false) { throw new CashAssemblyVariableTypeMismatchError(variableName, 'Uint8Array', typeof value); } bytecode[variableName] = value; } // `scriptId` is the source string registered in compileCashAssemblyEvaluations. const compiledBytecode = compiler.generateBytecode({ data: { bytecode }, scriptId: evaluation, }); // A failed compile must not return partial bytecode. if (compiledBytecode.success === false) { // CashAssemblyCompilationFailedError carries one message. Libauth may report several errors. let compilationFailureMessage = 'unknown compilation failure'; // Prefer Libauth's own error text when the failure object includes an errors list. 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 source strings, setting required variables as 'WalletData'. * * Each evaluation string is registered as a script whose id and source are that string. Template * scripts are then copied onto the same table. If a source is a template id such as `scriptA`, that * id must compile the template source. Leaving the id as its own source would compile the text * `scriptA` and include itself. * * @param {CompileCashAssemblyEvaluationsParameters} parameters - Source strings, optional template scripts, and already collected WalletData names. * @returns {CompilerBch} - A Libauth compiler instance for use with these source strings. */ export const compileCashAssemblyEvaluations = (parameters: CompileCashAssemblyEvaluationsParameters): CompilerBch => { const { evaluations, templateScripts, variableNames } = parameters; const scripts: Record = {}; // Libauth looks up scripts by id. Using the source string as the id lets generateBytecode request that exact source. for (const evaluation of evaluations) { scripts[evaluation] = evaluation; } const knownScriptIdentifiers = new Set(); if (templateScripts !== undefined) { for (const [ scriptIdentifier, scriptDefinition ] of Object.entries(templateScripts)) { // Compiling `scriptA` must run the template source. Otherwise the id compiles as the text `scriptA` and includes itself. scripts[scriptIdentifier] = scriptDefinition; // Template ids compile as scripts. They must not also be registered as WalletData. knownScriptIdentifiers.add(scriptIdentifier); } } const variables: Record = {}; // Register caller collected names. This function does not parse or collect again. for (const variableName of variableNames) { // A name that is a template script id is already a compiler script, not WalletData. if (knownScriptIdentifiers.has(variableName) === true) { continue; } // Libauth WalletData is the name before the first `.`. `someKey.public_key` registers as `someKey`. variables[resolveIdentifierBaseName(variableName)] = { type: 'WalletData' as const }; } const compiler = createCompilerBch({ scripts, variables, }); return compiler; }; /** * Compiles one CashAssembly source string to bytecode. * * The source may be an evaluation such as `$(scriptA)` or CashAssembly such as * `scriptA`. WalletData names come from {@link collectVariablesUsingParseScript}. * * @param {CompileCashAssemblySourceToBytesParameters} parameters - Source text and compilation context. * @returns {Uint8Array} - Compiled bytecode for that source. * @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 CashAssemblyIdentifierCollisionError} - When one identifier matches two resolution types. * @throws {@link CashAssemblyCompilationFailedError} - When Libauth compilation fails. */ export const compileCashAssemblySourceToBytes = (parameters: CompileCashAssemblySourceToBytesParameters): Uint8Array => { const { cashAssemblySource, variables, templateVariables, templateScripts } = parameters; // Throw if there is a identifier collision between opcodes, variable, or template script. assertNoIdentifierCollisions({ variables, templateScripts }); // Collect WalletData names from the source and every reachable nested script source. const variableNames = collectVariablesUsingParseScript(cashAssemblySource, templateScripts); // Resolve supported primitive methods to bytes before treating remaining names as WalletData. const primitiveMethodBytes = resolvePrimitiveMethodBytes({ variableNames, templateVariables, variables, }); // Collect names that still need a caller supplied value after primitive resolution. const missingVariables: string[] = []; for (const variableName of variableNames) { // Primitive methods already produced bytes under the full name. if (Object.hasOwn(primitiveMethodBytes, variableName) === true) { continue; } // A present runtime value will be converted to bytes below. if (Object.hasOwn(variables, variableName) === true) { continue; } missingVariables.push(variableName); } // Refuse invented defaults so a missing value cannot compile into incorrect bytecode. if (missingVariables.length > 0) { throw new CashAssemblyRequiredVariableMissingError(missingVariables); } // Convert each variable to its bytes before compilation. const variableBytes: Record = {}; for (const variableName of variableNames) { // Prefer the primitive method result so CashAssembly sees bytes under `base.method`. if (Object.hasOwn(primitiveMethodBytes, variableName) === true) { variableBytes[variableName] = primitiveMethodBytes[variableName]; continue; } // Remaining names are caller WalletData that still needs a byte encoding. variableBytes[variableName] = convertValueToBytes(variables[variableName], variableName); } // Reuse the names collected above so this compiler is not built from a second parse. const compiler = compileCashAssemblyEvaluations({ evaluations: [ cashAssemblySource ], templateScripts, variableNames, }); return generateCashAssemblyBytecode(compiler, cashAssemblySource, variableBytes, variableNames); }; /** * Compiles one CashAssembly source to bytes and decodes those bytes to text. * * @param {CompileCashAssemblySourceToDecodedTextParameters} parameters - Source text, WalletData, optional template context, and decode mode. * @returns {string} - Decoded compilation result for that source. * @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 compiled bytes are not a VM number. * @throws {@link CashAssemblyIdentifierCollisionError} - When one identifier matches two resolution types. * @throws {@link CashAssemblyCompilationFailedError} - When Libauth compilation fails. */ const compileCashAssemblySourceToDecodedText = (parameters: CompileCashAssemblySourceToDecodedTextParameters): string => { const { cashAssemblySource, variables, templateVariables, templateScripts, evaluationDecodeMode } = parameters; const compilationResult = compileCashAssemblySourceToBytes({ cashAssemblySource, variables, templateVariables, templateScripts, }); return decodeCompiledCashAssemblyEvaluation(compilationResult, evaluationDecodeMode); }; /** * Compiles CashAssembly text. WalletData names come from parseScript. `$()` evaluations in text * are found by scanning the string and matching parentheses. * * When `cashAssemblyText` contains `$()`, each non empty evaluation is compiled and * replaced in place. Empty `$()` is copied through. Surrounding text is kept. Evaluation drops * push opcodes and keeps the stack payload. * * When `cashAssemblyText` contains no `$()` at all, the entire string is compiled as CashAssembly. * Push opcodes from `<...>` remain. Pass a template script id such as `scriptA` or * concatenated ids such as `scriptA scriptB`. * * Required WalletData names come from {@link collectVariablesUsingParseScript}. * * @param {CompileCashAssemblyStringParameters} parameters - Parameters for compiling the CashAssembly string. * @returns {string} - Compiled text with evaluations replaced, or decoded bytecode when the whole string is compiled as CashAssembly. * @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 compiled bytes are not a VM number. * @throws {@link CashAssemblyIdentifierCollisionError} - When one identifier matches two resolution types. * @throws {@link CashAssemblyCompilationFailedError} - When Libauth compilation fails. * @throws {@link CashAssemblyEvaluationUnclosedError} - When an evaluation never closes. * @throws {@link CashAssemblyQuotedLiteralUnclosedError} - When a quoted literal never closes. * @throws {@link CashAssemblyBlockCommentUnclosedError} - When a block comment never closes. */ export const compileCashAssemblyString = (parameters: CompileCashAssemblyStringParameters): string => { const { cashAssemblyText, variables, evaluationDecodeMode = 'utf8', templateVariables, templateScripts } = parameters; // Scan once so empty `$()` still copies text around `$()` instead of compiling as CashAssembly. const scannedEvaluations = scanCashAssemblyEvaluations(cashAssemblyText); if (scannedEvaluations.length === 0) { return compileCashAssemblySourceToDecodedText({ cashAssemblySource: cashAssemblyText, variables, templateVariables, templateScripts, evaluationDecodeMode, }); } // Copy text outside `$()` and compile each `$()`. let textWithCompiledEvaluations = ''; // Start of the next original text slice in cashAssemblyText. Gaps between evaluations must stay intact. let nextTextStartIndex = 0; // When every `$()` is empty, return cashAssemblyText so the original text is unchanged. let hasCompiledNonEmptyEvaluation = false; for (const scannedEvaluation of scannedEvaluations) { textWithCompiledEvaluations += cashAssemblyText.slice(nextTextStartIndex, scannedEvaluation.startIndex); // Empty `$()` has no contents to compile. if (scannedEvaluation.evaluationText === EMPTY_CASHASSEMBLY_EVALUATION) { textWithCompiledEvaluations += scannedEvaluation.evaluationText; } else { hasCompiledNonEmptyEvaluation = true; textWithCompiledEvaluations += compileCashAssemblySourceToDecodedText({ cashAssemblySource: scannedEvaluation.evaluationText, variables, templateVariables, templateScripts, evaluationDecodeMode, }); } // Advance past this `$()` so nested evaluations inside it are not copied again. nextTextStartIndex = scannedEvaluation.closeIndex + 1; } // Copy text after the last `$()` so the suffix is kept. textWithCompiledEvaluations += cashAssemblyText.slice(nextTextStartIndex); if (hasCompiledNonEmptyEvaluation === false) { return cashAssemblyText; } return textWithCompiledEvaluations; };