438 lines
21 KiB
TypeScript
438 lines
21 KiB
TypeScript
/**
|
|
* 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.
|
|
* For example `<someKey.public_key>` pushes the 33 byte compressed public key and `<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.
|
|
* For example `$(<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 example a P2PKH locking script looks like
|
|
* `OP_DUP OP_HASH160 <$(<someKey.public_key> 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 `<base.method>` 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<string, Uint8Array>} 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<string, Uint8Array>,
|
|
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<string, Uint8Array> = {};
|
|
|
|
// 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<string, string> = {};
|
|
|
|
// 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<string>();
|
|
|
|
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<string, { type: 'WalletData' }> = {};
|
|
|
|
// 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<string, Uint8Array> = {};
|
|
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;
|
|
};
|