Change implementation strategy of cashassembly, add scripts, comments support and tests

This commit is contained in:
Kuldeep
2026-09-11 10:02:20 +00:00
parent bd2f1044e7
commit c3dd0162d9
15 changed files with 2035 additions and 332 deletions
+3 -3
View File
@@ -6,9 +6,9 @@ import { CashAssemblyNumberNotSafeIntegerError, CashAssemblyUnsupportedValueType
*
* @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.
* @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) {
+257
View File
@@ -0,0 +1,257 @@
import { describeExpectedInput, parseScript } from '@bitauth/libauth';
import type { CashAssemblyScriptSegment } from '@bitauth/libauth';
import { CashAssemblyCompilationFailedError } from './errors.ts';
import type { CashAssemblyTemplateScripts } from './types.ts';
/**
* Shared state while scanning a parse tree and visiting nested template scripts.
*/
type CollectFromParseTreeContext = {
/**
* Script ids present in the template scripts map.
*/
knownScriptIdentifiers: ReadonlySet<string>;
/**
* Script ids whose sources were already visited.
*/
visitedScriptIdentifiers: ReadonlySet<string>;
/**
* Pending script ids queued to visit.
*/
scriptIdentifiersToVisit: string[];
/**
* WalletData variable names.
*/
variableNames: Set<string>;
};
/**
* Parameters for scanning one parsed Script node.
*/
type CollectFromParsedScriptParameters = CollectFromParseTreeContext & {
/**
* Parsed Script whose children are Push, Evaluation, Identifier, or literals.
*/
scriptSegment: CashAssemblyScriptSegment;
/**
* True when this Script is the value of a Push.
* Direct Identifier children are WalletData unless they are template script ids.
*/
isDirectPushContent: boolean;
};
/**
* Parameters for handling one Identifier node.
*/
type CollectFromParsedIdentifierParameters = CollectFromParseTreeContext & {
/**
* Identifier text from parseScript, which may include `.` and `_`.
*/
identifier: string;
/**
* True when this Identifier is a direct child of a Push Script.
*/
isDirectPushContent: boolean;
};
/**
* Enqueues a template script id so its source can be visited when it exists in the scripts map.
*
* @param {string} scriptIdentifier - Script id from an evaluation or nested script source.
* @param {ReadonlySet<string>} knownScriptIdentifiers - Script ids present in the template scripts map.
* @param {ReadonlySet<string>} visitedScriptIdentifiers - Script ids whose sources were already visited.
* @param {string[]} scriptIdentifiersToVisit - Pending script ids queued to visit.
*/
const enqueueReachableTemplateScriptIdentifier = (
scriptIdentifier: string,
knownScriptIdentifiers: ReadonlySet<string>,
visitedScriptIdentifiers: ReadonlySet<string>,
scriptIdentifiersToVisit: string[],
): void => {
// Return when this id is not a template script.
if (knownScriptIdentifiers.has(scriptIdentifier) === false) {
return;
}
// Skip ids whose sources were already visited.
if (visitedScriptIdentifiers.has(scriptIdentifier) === true) {
return;
}
// Avoid duplicate queue entries when the same id is referenced more than once.
if (scriptIdentifiersToVisit.includes(scriptIdentifier) === true) {
return;
}
// Queue this id so its source is parsed after the current scan finishes.
scriptIdentifiersToVisit.push(scriptIdentifier);
};
/**
* Parses CashAssembly source with parseScript and throws when the parse fails.
*
* @param {string} cashAssemblyText - Evaluation or script source to parse.
* @returns {CashAssemblyScriptSegment} - Parsed Script root.
* @throws {@link CashAssemblyCompilationFailedError} - When parseScript rejects the source.
*/
const parseCashAssemblySource = (cashAssemblyText: string): CashAssemblyScriptSegment => {
// Parse so Push and Identifier nodes are available for collection.
const parseResult = parseScript(cashAssemblyText);
// parseScript returns a union. status false is the failure arm and must not be read as a Script.
if (parseResult.status === false) {
// describeExpectedInput turns the expected input list into readable text for the typed error.
const expectedInputDescription = describeExpectedInput(parseResult.expected);
throw new CashAssemblyCompilationFailedError(`${expectedInputDescription} Line ${String(parseResult.index.line)}, column ${String(parseResult.index.column)}.`);
}
return parseResult.value;
};
/**
* Identifier visitor for the parseScript tree.
* Records WalletData or queues a nested script from one Identifier node.
*
* @param {CollectFromParsedIdentifierParameters} parameters - Identifier text and the parse tree context.
*/
const collectFromParsedIdentifier = (parameters: CollectFromParsedIdentifierParameters): void => {
const { identifier, isDirectPushContent, knownScriptIdentifiers, visitedScriptIdentifiers, scriptIdentifiersToVisit, variableNames } = parameters;
// Script ids are visited whether they appear in a Push or beside opcodes.
if (knownScriptIdentifiers.has(identifier) === true) {
enqueueReachableTemplateScriptIdentifier(identifier, knownScriptIdentifiers, visitedScriptIdentifiers, scriptIdentifiersToVisit);
return;
}
// Direct Push names that are not script ids are WalletData, including dotted names.
if (isDirectPushContent === true) {
variableNames.add(identifier);
}
};
/**
* Visitor for one Script node in the parseScript tree.
* Recurses into Push and Evaluation children and dispatches Identifier nodes.
*
* @param {CollectFromParsedScriptParameters} parameters - Parsed Script, Push depth flag, and parse tree context.
*/
const collectFromParsedScript = (parameters: CollectFromParsedScriptParameters): void => {
const { scriptSegment, isDirectPushContent, knownScriptIdentifiers, visitedScriptIdentifiers, scriptIdentifiersToVisit, variableNames } =
parameters;
for (const child of scriptSegment.value) {
if (child.name === 'Push') {
// Inner Script of a Push is where `<name>` WalletData lives, including nested angle brackets.
collectFromParsedScript({
scriptSegment: child.value,
isDirectPushContent: true,
knownScriptIdentifiers,
visitedScriptIdentifiers,
scriptIdentifiersToVisit,
variableNames,
});
continue;
}
if (child.name === 'Evaluation') {
// Evaluation contents are opcodes and nested Pushes, not WalletData by themselves.
collectFromParsedScript({
scriptSegment: child.value,
isDirectPushContent: false,
knownScriptIdentifiers,
visitedScriptIdentifiers,
scriptIdentifiersToVisit,
variableNames,
});
continue;
}
if (child.name === 'Identifier') {
collectFromParsedIdentifier({
identifier: child.value,
isDirectPushContent,
knownScriptIdentifiers,
visitedScriptIdentifiers,
scriptIdentifiersToVisit,
variableNames,
});
}
}
};
/**
* Collects WalletData names from the parseScript tree using the visitor pattern.
*
* parseScript returns a tree of Script, Push, Evaluation, and Identifier nodes. Nested Push and
* Evaluation nodes are entered immediately. A template script id is queued whether it appears inside a Push or next to
* opcodes. Queued script sources are visited after the current tree finishes, in the order they were
* first seen.
* A visited id is skipped on later encounters. Opcodes and literals are ignored. Unrelated scripts
* in the map are not visited. An Identifier inside a nested Push is collected from that inner Push,
* for example `<$(<ownerKey.public_key> OP_HASH160)>` collects `ownerKey.public_key`.
*
* @param {string} evaluation - CashAssembly evaluation or script id text to scan.
* @param {CashAssemblyTemplateScripts} [templateScripts] - Optional template scripts map used to visit nested script ids.
* @returns {string[]} - WalletData names from reachable Pushes. Script ids are omitted when
* `templateScripts` is provided.
* @throws {@link CashAssemblyCompilationFailedError} - When parseScript rejects the starting text or a visited script source.
*/
export const collectVariablesUsingParseScript = (evaluation: string, templateScripts: CashAssemblyTemplateScripts = {}): string[] => {
// Create a set of the script identifiers from the template scripts map.
const knownScriptIdentifiers = new Set(Object.keys(templateScripts));
const visitedScriptIdentifiers = new Set<string>();
const scriptIdentifiersToVisit: string[] = [];
const variableNames = new Set<string>();
// Share by reference context.
const parseTreeContext: CollectFromParseTreeContext = {
knownScriptIdentifiers,
visitedScriptIdentifiers,
scriptIdentifiersToVisit,
variableNames,
};
// Parse the starting text so nested Pushes are visible before visiting nested script sources.
const startingScriptSegment = parseCashAssemblySource(evaluation);
// Scan the starting parse tree before visiting nested script sources.
collectFromParsedScript({
...parseTreeContext,
scriptSegment: startingScriptSegment,
isDirectPushContent: false,
});
for (const scriptIdentifier of scriptIdentifiersToVisit) {
if (visitedScriptIdentifiers.has(scriptIdentifier) === true) {
continue;
}
visitedScriptIdentifiers.add(scriptIdentifier);
// Get the script definition from the template scripts map.
const scriptDefinition = templateScripts[scriptIdentifier];
const nestedScriptSegment = parseCashAssemblySource(scriptDefinition);
collectFromParsedScript({
...parseTreeContext,
scriptSegment: nestedScriptSegment,
isDirectPushContent: false,
});
}
return [ ...variableNames ];
};
+35 -55
View File
@@ -1,61 +1,41 @@
/**
* 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.
* 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 `type` maps to a primitive
* For example `expiry.toIso8601` matches with base expiry and method toIso8601.
* `requestedSatoshis` does not match because it has no method.
* `key.schnorr_signature.all_outputs` does not match because it has more than one dot.
*/
export const CASHASSEMBLY_VARIABLE_METHOD_REFERENCE_PATTERN = /^([^.]+)\.([^.]+)$/;
/**
* Character count of the `$(` evaluation opener, which is two characters long.
*
* Adding this length moves the read position to the first character after that sequence so scanning
* starts on the evaluation contents rather than on the opening parenthesis.
*/
export const CASHASSEMBLY_EVALUATION_START_LENGTH = 2;
/**
* Character count of the `//` and `/*` comment openers, which are both two characters long.
*
* Adding this length moves the read position to the first character after that sequence so the '*'
* of a block comment opener cannot be reused as the '*' of its closer.
*/
export const CASHASSEMBLY_COMMENT_START_LENGTH = 2;
/**
* Character count of the block comment closer, which is two characters long.
*
* Adding this length moves the read position to the first character after that sequence so the
* closing slash cannot pair with the next character and open another comment.
*/
export const CASHASSEMBLY_BLOCK_COMMENT_END_LENGTH = 2;
/**
* `$()` with nothing between the parentheses.
*
* Callers use this to detect an evaluation that has no contents to compile.
*/
export const EMPTY_CASHASSEMBLY_EVALUATION = '$()';
+132 -2
View File
@@ -1,16 +1,24 @@
/* eslint-disable max-classes-per-file */
import type { IdentifierResolutionType } from '@bitauth/libauth';
/**
* Error thrown when a required variable is missing.
*/
export class CashAssemblyRequiredVariableMissingError extends Error {
constructor(variableNames?: string[]) {
/**
* Variable names that were required but absent from the variables map.
*/
readonly variableNames: string[];
constructor(variableNames: string[] = []) {
const defaultMessage = 'Missing required variable';
if (variableNames !== undefined && variableNames.length > 0) {
if (variableNames.length > 0) {
super(`${defaultMessage}: variableNames [${variableNames.join(', ')}]`);
} else {
super(defaultMessage);
}
this.variableNames = variableNames;
}
}
@@ -24,6 +32,104 @@ export class CashAssemblyCompilationFailedError extends Error {
}
}
/**
* Error thrown when a quoted string inside `$()` never closes.
*/
export class CashAssemblyQuotedLiteralUnclosedError extends Error {
/**
* Index of the opening quote that never found a closer.
*/
readonly openingQuoteIndex: number;
/**
* Quote character that opened the literal.
*/
readonly quoteCharacter: string;
/**
* Text from the opening quote through the end of the scanned string.
*/
readonly unclosedText: string;
constructor(openingQuoteIndex: number, quoteCharacter: string, cashAssemblyText: string) {
const unclosedText = cashAssemblyText.slice(openingQuoteIndex);
const defaultMessage = 'Quoted literal in a CashAssembly evaluation is unclosed';
const details = [
`quoteCharacter ${JSON.stringify(quoteCharacter)}`,
`openingQuoteIndex ${String(openingQuoteIndex)}`,
`unclosedText ${JSON.stringify(unclosedText)}`,
].join(', ');
super(`${defaultMessage}: ${details}`);
this.openingQuoteIndex = openingQuoteIndex;
this.quoteCharacter = quoteCharacter;
this.unclosedText = unclosedText;
}
}
/**
* Error thrown when a block comment inside `$()` never closes.
*/
export class CashAssemblyBlockCommentUnclosedError extends Error {
/**
* Index of the first slash of the block comment opener.
*/
readonly commentStartIndex: number;
/**
* Text from the block comment opener through the end of the scanned string.
*/
readonly unclosedText: string;
constructor(commentStartIndex: number, cashAssemblyText: string) {
const unclosedText = cashAssemblyText.slice(commentStartIndex);
const defaultMessage = 'Block comment in a CashAssembly evaluation is unclosed';
const details = [ `commentStartIndex ${String(commentStartIndex)}`, `unclosedText ${JSON.stringify(unclosedText)}` ].join(', ');
super(`${defaultMessage}: ${details}`);
this.commentStartIndex = commentStartIndex;
this.unclosedText = unclosedText;
}
}
/**
* Error thrown when a `$()` evaluation never finds its matching closer.
*/
export class CashAssemblyEvaluationUnclosedError extends Error {
/**
* Index of the `$` that opened the evaluation.
*/
readonly evaluationStartIndex: number;
/**
* How many `$()` remain open at the end of the string, including nested evaluations.
*/
readonly remainingOpenEvaluations: number;
/**
* Text from the opening `$` through the end of the scanned string.
*/
readonly unclosedText: string;
constructor(evaluationStartIndex: number, remainingOpenEvaluations: number, cashAssemblyText: string) {
const unclosedText = cashAssemblyText.slice(evaluationStartIndex);
const defaultMessage = 'CashAssembly evaluation is unclosed';
const details = [
`evaluationStartIndex ${String(evaluationStartIndex)}`,
`remainingOpenEvaluations ${String(remainingOpenEvaluations)}`,
`unclosedText ${JSON.stringify(unclosedText)}`,
].join(', ');
super(`${defaultMessage}: ${details}`);
this.evaluationStartIndex = evaluationStartIndex;
this.remainingOpenEvaluations = remainingOpenEvaluations;
this.unclosedText = unclosedText;
}
}
/**
* Error thrown when a variable's runtime type does not match the type required for compilation.
*/
@@ -74,6 +180,30 @@ export class CashAssemblyPrimitiveVariableMissingError extends Error {
}
}
/**
* Error thrown when one identifier would resolve as more than one {@link IdentifierResolutionType}.
*/
export class CashAssemblyIdentifierCollisionError extends Error {
/**
* Identifier that more than one resolution type would match.
*/
readonly identifier: string;
/**
* The list of resolution types that the identifier matches.
*/
readonly resolutionTypes: IdentifierResolutionType[];
constructor(identifier: string, resolutionTypes: IdentifierResolutionType[]) {
const defaultMessage = 'CashAssembly identifier exists for more than one resolution type';
super(`${defaultMessage}: identifier "${identifier}", resolutionTypes [${resolutionTypes.join(', ')}]`);
this.identifier = identifier;
this.resolutionTypes = resolutionTypes;
}
}
/**
* Error thrown when compiled evaluation bytes cannot be decoded as a VM number.
*/
+259 -175
View File
@@ -7,26 +7,26 @@
* ## 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.
* 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.
* `$(<someKey.public_key> OP_HASH160)` inserts the HASH160 of the public key.
* 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 a P2PKH locking script example see
* 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. `someKey.public_key`
* produces the public key bytes. `someKey.schnorr_signature.all_outputs` produces a Schnorr
* signature.
* `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. Naming a variable or script `OP_ADD` will not shadow it.
* 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.
*
@@ -34,80 +34,40 @@
*
* 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 type { XOInvitationVariableValue, XOTemplate } from '@xo-cash/types';
import { convertValueToBytes } from './bytes.ts';
import {
CashAssemblyCompilationFailedError,
CashAssemblyVariableTypeMismatchError,
CashAssemblyRequiredVariableMissingError,
CashAssemblyVariableTypeMismatchError,
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 `type`
* maps to a method resolvable 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) ?? [];
};
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 `.`.
@@ -116,7 +76,7 @@ export const extractCashAssemblyEvaluations = (text: string): string[] => {
* 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 `.`.
* @returns {string} - The base name before the first `.`.
*/
const resolveIdentifierBaseName = (identifier: string): string => {
const firstDotIndex = identifier.indexOf('.');
@@ -128,31 +88,6 @@ const resolveIdentifierBaseName = (identifier: string): string => {
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.
*
@@ -161,10 +96,9 @@ export const extractVariablesFromEvaluations = (evaluations: string[]): string[]
* 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.
* @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,
@@ -206,43 +140,52 @@ export const decodeCompiledCashAssemblyEvaluation = (
* @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.
* @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>): Uint8Array => {
const variableNames = extractVariablesFromEvaluations([ evaluation ]);
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);
// Validate that all required variables are provided
const missingVariables = variableNames.filter((name: string) => !Object.hasOwn(variables, name));
// Refuse invented defaults so a missing value cannot compile into incorrect bytecode.
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) {
// Pass only required names so extra keys in `variables` are not sent to Libauth.
for (const variableName of requiredVariableNames) {
const value = variables[variableName];
// By the time execution reaches this point, the value should be a Uint8Array.
if (!(value instanceof Uint8Array)) {
// 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,
});
if (!compiledBytecode.success) {
// Collapse libauth's full errors list into one string because
// CashAssemblyCompilationFailedError only carries a single message.
// 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('; ');
}
@@ -254,28 +197,50 @@ export const generateCashAssemblyBytecode = (compiler: CompilerBch, evaluation:
};
/**
* Prepares a compiler for the provided CashAssembly evaluations, setting required variables as 'WalletData'.
* Prepares a compiler for the provided CashAssembly source strings, 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.
* 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 = (evaluations: string[]): CompilerBch => {
// Create a scripts object where each key is the evaluation and the value is also the evaluation
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;
}
// Get the variable names from the evaluations.
const variableNames = extractVariablesFromEvaluations(evaluations);
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);
}
}
// Register each base name once. Dotted pushes share one WalletData entry under the base name.
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 `.`. `ownerKey.public_key` registers as `ownerKey`.
variables[resolveIdentifierBaseName(variableName)] = { type: 'WalletData' as const };
}
// Create the libauth compiler.
const compiler = createCompilerBch({
scripts,
variables,
@@ -285,69 +250,188 @@ export const compileCashAssemblyEvaluations = (evaluations: string[]): CompilerB
};
/**
* Compiles all CashAssembly evaluations in a text string and replaces each evaluation
* with a decoded string representation.
* 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.
* @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 method resolvable `<name.method>` pushes via each variable's `type`.
* @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 method resolvable primitive type has an unknown method.
* @throws {@link CashAssemblyPrimitiveVariableMissingError} When a method resolvable 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.
* @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 } = parameters;
const { cashAssemblyText, variables, evaluationDecodeMode = 'utf8', templateVariables, templateScripts } = parameters;
return cashAssemblyText.replace(CASHASSEMBLY_EVALUATION_PATTERN, (evaluation) => {
// Extract variable identifiers required by the current evaluation.
const variableNames = extractVariablesFromEvaluations([ evaluation ]);
// Scan once so empty `$()` still copies text around `$()` instead of compiling as CashAssembly.
const scannedEvaluations = scanCashAssemblyEvaluations(cashAssemblyText);
const primitiveMethodBytes = resolvePrimitiveMethodBytes({
identifiers: variableNames,
templateVariables,
if (scannedEvaluations.length === 0) {
return compileCashAssemblySourceToDecodedText({
cashAssemblySource: cashAssemblyText,
variables,
templateVariables,
templateScripts,
evaluationDecodeMode,
});
}
// 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;
}
// Copy text outside `$()` and compile each `$()`.
let textWithCompiledEvaluations = '';
return Object.hasOwn(variables, variableName) === false;
});
// Start of the next original text slice in cashAssemblyText. Gaps between evaluations must stay intact.
let nextTextStartIndex = 0;
if (missingVariables.length > 0) {
throw new CashAssemblyRequiredVariableMissingError(missingVariables);
// 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,
});
}
// 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;
}
// Advance past this `$()` so nested evaluations inside it are not copied again.
nextTextStartIndex = scannedEvaluation.closeIndex + 1;
}
variableBytes[variableName] = convertValueToBytes(variables[variableName], variableName);
}
// Copy text after the last `$()` so the suffix is kept.
textWithCompiledEvaluations += cashAssemblyText.slice(nextTextStartIndex);
// Compile the evaluation in isolation.
const compiler = compileCashAssemblyEvaluations([ evaluation ]);
if (hasCompiledNonEmptyEvaluation === false) {
return cashAssemblyText;
}
// 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);
});
return textWithCompiledEvaluations;
};
@@ -0,0 +1,59 @@
import { generateBytecodeMap, IdentifierResolutionType, OpcodesBchSpec } from '@bitauth/libauth';
import { CashAssemblyIdentifierCollisionError } from './errors.ts';
import type { CompileCashAssemblyContext } from './types.ts';
/**
* Set of opcode names that the compiler recognizes.
*/
const COMPILER_OPCODE_NAMES: ReadonlySet<string> = new Set(Object.keys(generateBytecodeMap(OpcodesBchSpec)));
/**
* Parameters for {@link assertNoIdentifierCollisions} function.
*/
export type AssertNoIdentifierCollisionsParameters = Pick<CompileCashAssemblyContext, 'variables' | 'templateScripts'>;
/**
* Libauth resolves opcodes, then variables, then scripts, and the first match silently hides the
* rest, leading to unexpected compilation results and no errors. This function throws when one identifier matches more than one resolution type.
*
* @param {AssertNoIdentifierCollisionsParameters} parameters - Provided variables and optional template scripts.
* @throws {@link CashAssemblyIdentifierCollisionError} - When one identifier matches more than one resolution type.
*/
export const assertNoIdentifierCollisions = (parameters: AssertNoIdentifierCollisionsParameters): void => {
const { variables, templateScripts } = parameters;
// Create a set of the script identifiers from the template scripts map.
const scriptIdentifiers = new Set(Object.keys(templateScripts ?? {}));
const variableNames = new Set(Object.keys(variables));
// Combine both, the variableNames and the scriptIdentifiers, to get a set of all identifiers.
const declaredIdentifiers = new Set([ ...variableNames, ...scriptIdentifiers ]);
// This loop will check for more than one occurrence of an identifier in OP_CODES, variables, and scripts
for (const identifier of declaredIdentifiers) {
// Resolution types is a native construct from libauth.
const resolutionTypes: IdentifierResolutionType[] = [];
// If the identifier is an opcode, then push it to the resolution types array, this will also be
// detected by either or both of the variableNames and scriptIdentifiers checks.
if (COMPILER_OPCODE_NAMES.has(identifier) === true) {
resolutionTypes.push(IdentifierResolutionType.opcode);
}
// If the identifier is in variables, then push it to the resolution types array.
if (variableNames.has(identifier) === true) {
resolutionTypes.push(IdentifierResolutionType.variable);
}
// If the identifier is a script identifier, then push it to the resolution types array.
if (scriptIdentifiers.has(identifier) === true) {
resolutionTypes.push(IdentifierResolutionType.script);
}
// If there are more than one resolution types, then throw an error.
if (resolutionTypes.length > 1) {
throw new CashAssemblyIdentifierCollisionError(identifier, resolutionTypes);
}
}
};
+4 -4
View File
@@ -1,5 +1,5 @@
export * from './evaluations.ts';
export * from './primitive-evaluations.ts';
export * from './bytes.ts';
export * from './defaults.ts';
export { collectVariablesUsingParseScript } from './collect-evaluations.ts';
export * from './errors.ts';
export { compileCashAssemblyString } from './evaluations.ts';
export { extractCashAssemblyEvaluations, isCashAssemblyExpression } from './scan-evaluations.ts';
export type { CompiledCashAssemblyDecodeMode, CompileCashAssemblyStringParameters } from './types.ts';
+16 -16
View File
@@ -64,15 +64,15 @@ type CallPrimitiveMethodParameters = {
};
/**
* Inputs needed to resolve primitive method pushes from extracted CashAssembly identifiers.
* Inputs needed to resolve primitive method pushes from collected CashAssembly variable names.
*/
export type ResolvePrimitiveMethodBytesParameters = {
/**
* Variable identifiers from {@link extractVariablesFromEvaluations}, for example
* Variable names from {@link collectVariablesUsingParseScript}, for example
* `['amount.toSatoshis', 'fee.toSatoshis']`.
*/
identifiers: string[];
variableNames: string[];
/**
* Variable names and values object.
@@ -101,7 +101,7 @@ const isResolvablePrimitiveType = (type: XOTemplatePrimitiveType | undefined): t
*
* @param {ResolvablePrimitiveType} type - Method resolvable template type.
* @param {string} methodName - Method name from the evaluation text, for example `toSatoshis`.
* @returns {boolean} True when that class exposes the named method.
* @returns {boolean} - True when that class exposes the named method.
*/
const canResolvePrimitiveMethod = (type: ResolvablePrimitiveType, methodName: string): boolean => {
const PrimitiveClass = RESOLVABLE_PRIMITIVE_CLASS_BY_TYPE[type];
@@ -159,7 +159,7 @@ const callPrimitiveMethod = (parameters: CallPrimitiveMethodParameters): unknown
* @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;
const { variableNames, templateVariables, variables } = parameters;
// Without template metadata there is no type to select a primitive class.
if (templateVariables === undefined) {
@@ -168,14 +168,14 @@ export const resolvePrimitiveMethodBytes = (parameters: ResolvePrimitiveMethodBy
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) {
for (const variableName of variableNames) {
// The same name can appear more than once. Resolve it only once.
if (Object.hasOwn(resolvedBytes, variableName) === true) {
continue;
}
// Find the primitive method reference in the identifier.
const methodReferenceMatch = identifier.match(CASHASSEMBLY_VARIABLE_METHOD_REFERENCE_PATTERN);
// Find the primitive method reference in the name.
const methodReferenceMatch = variableName.match(CASHASSEMBLY_VARIABLE_METHOD_REFERENCE_PATTERN);
if (methodReferenceMatch === null) {
continue;
@@ -183,7 +183,7 @@ export const resolvePrimitiveMethodBytes = (parameters: ResolvePrimitiveMethodBy
const [ , baseName, methodName ] = methodReferenceMatch;
// Unknown identifiers and CashAssembly native operations such as someKey.schnorr_signature.all_outputs
// Unknown names 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;
@@ -198,23 +198,23 @@ export const resolvePrimitiveMethodBytes = (parameters: ResolvePrimitiveMethodBy
// Method resolvable type with an unknown method should throw.
if (canResolvePrimitiveMethod(type, methodName) === false) {
throw new CashAssemblyPrimitiveMethodMissingError(identifier, methodName, type);
throw new CashAssemblyPrimitiveMethodMissingError(variableName, methodName, type);
}
// 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);
throw new CashAssemblyPrimitiveVariableMissingError(variableName, baseName);
}
const methodResult = callPrimitiveMethod({
identifier,
identifier: variableName,
methodName,
value: variables[baseName],
type,
});
// Store the result under identifier.
resolvedBytes[identifier] = convertValueToBytes(methodResult, identifier);
// CashAssembly looks up the full `base.method` name as WalletData bytes.
resolvedBytes[variableName] = convertValueToBytes(methodResult, variableName);
}
return resolvedBytes;
+272
View File
@@ -0,0 +1,272 @@
import {
CASHASSEMBLY_BLOCK_COMMENT_END_LENGTH,
CASHASSEMBLY_COMMENT_START_LENGTH,
CASHASSEMBLY_EVALUATION_START_LENGTH,
EMPTY_CASHASSEMBLY_EVALUATION,
} from './defaults.ts';
import { CashAssemblyBlockCommentUnclosedError, CashAssemblyEvaluationUnclosedError, CashAssemblyQuotedLiteralUnclosedError } from './errors.ts';
/**
* Skips a quoted CashAssembly string so a `)` inside it cannot end `$()`.
*
* CashAssembly UTF8 literals use `"..."` or `'...'`. Everything between the quotes is payload,
* including parentheses.
* For example `$(<"hello)world">)` must close after the literal, not at the `)` inside the quotes.
*
* @param {string} cashAssemblyText - Text that contains the quoted literal.
* @param {number} openingQuoteIndex - Index of the opening `"` or `'`.
* @returns {number} - Index of the character after the closing quote.
* @throws {@link CashAssemblyQuotedLiteralUnclosedError} - When the quoted literal never closes.
*/
const skipQuotedCashAssemblyLiteral = (cashAssemblyText: string, openingQuoteIndex: number): number => {
const quoteCharacter = cashAssemblyText[openingQuoteIndex];
let currentIndex = openingQuoteIndex + 1;
// CashAssembly puts payload `)` inside the quotes. Stop only at the matching quote.
while (currentIndex < cashAssemblyText.length) {
if (cashAssemblyText[currentIndex] === quoteCharacter) {
return currentIndex + 1;
}
currentIndex += 1;
}
throw new CashAssemblyQuotedLiteralUnclosedError(openingQuoteIndex, quoteCharacter, cashAssemblyText);
};
/**
* This function skips a `//` comment so a `)` written in the comment cannot end `$()`.
* A line comment runs from `//` to the newline, or to the end of the string when there is no newline.
* A `)` in that span is comment text, not the closer of `$()`.
*
* @param {string} cashAssemblyText - Text that contains the line comment.
* @param {number} commentStartIndex - Index of the first `/` of `//`.
* @returns {number} - Index after the newline that ends the comment, or the end of the string if there is no newline.
*/
const skipSingleLineCashAssemblyComment = (cashAssemblyText: string, commentStartIndex: number): number => {
let currentIndex = commentStartIndex + CASHASSEMBLY_COMMENT_START_LENGTH;
// CashAssembly line comments hide `)` until the newline. End of string also ends them.
while (currentIndex < cashAssemblyText.length) {
if (cashAssemblyText[currentIndex] === '\n') {
return currentIndex + 1;
}
currentIndex += 1;
}
return currentIndex;
};
/**
* This function skips a block comment so a `)` written in the comment cannot end `$()`.
* A `)` inside a block comment is comment text, not the closer of `$()`.
*
* @param {string} cashAssemblyText - Text that contains the block comment.
* @param {number} commentStartIndex - Index of the first slash of the block comment opener.
* @returns {number} - Index of the character after the comment closer.
* @throws {@link CashAssemblyBlockCommentUnclosedError} - When the block comment never closes.
*/
const skipBlockCashAssemblyComment = (cashAssemblyText: string, commentStartIndex: number): number => {
let currentIndex = commentStartIndex + CASHASSEMBLY_COMMENT_START_LENGTH;
// CashAssembly block comments hide `)` until the closer is found.
while (currentIndex + 1 < cashAssemblyText.length) {
if (cashAssemblyText[currentIndex] === '*' && cashAssemblyText[currentIndex + 1] === '/') {
return currentIndex + CASHASSEMBLY_BLOCK_COMMENT_END_LENGTH;
}
currentIndex += 1;
}
throw new CashAssemblyBlockCommentUnclosedError(commentStartIndex, cashAssemblyText);
};
/**
* Parenthesis matching with a depth count.
*
* Returns the index of the `)` that closes the `$()` starting at `evaluationStartIndex`.
* Nested `$()` raise the depth so an inner closer cannot finish the outer evaluation.
*
* @param {string} cashAssemblyText - Text that contains the evaluation.
* @param {number} evaluationStartIndex - Index of the `$` that opens this evaluation.
* @returns {number} - Index of the matching closing parenthesis.
* @throws {@link CashAssemblyEvaluationUnclosedError} - When the evaluation never closes.
* @throws {@link CashAssemblyQuotedLiteralUnclosedError} - When a quoted literal inside the evaluation never closes.
* @throws {@link CashAssemblyBlockCommentUnclosedError} - When a block comment inside the evaluation never closes.
*/
const findCashAssemblyEvaluationCloseIndex = (cashAssemblyText: string, evaluationStartIndex: number): number => {
let currentIndex = evaluationStartIndex + CASHASSEMBLY_EVALUATION_START_LENGTH;
let remainingOpenEvaluations = 1;
// Scan the evaluation contents until the matching closer at depth zero.
while (currentIndex < cashAssemblyText.length) {
const currentCharacter = cashAssemblyText[currentIndex];
const nextCharacter = cashAssemblyText[currentIndex + 1];
// CashAssembly allows `)` inside `"..."` and `'...'`. Skip the literal so that closer is not structural.
if (currentCharacter === '"' || currentCharacter === "'") {
currentIndex = skipQuotedCashAssemblyLiteral(cashAssemblyText, currentIndex);
continue;
}
// CashAssembly `//` comments may mention `)`. Skip them so that `)` is not treated as a closer.
if (currentCharacter === '/' && nextCharacter === '/') {
currentIndex = skipSingleLineCashAssemblyComment(cashAssemblyText, currentIndex);
continue;
}
// CashAssembly block comments may mention `)`. Skip them so that `)` is not treated as a closer.
if (currentCharacter === '/' && nextCharacter === '*') {
currentIndex = skipBlockCashAssemblyComment(cashAssemblyText, currentIndex);
continue;
}
// Nested `$()` must close before this evaluation can close.
if (currentCharacter === '$' && nextCharacter === '(') {
// Since all previous checks have passed then it means this is start of another evaluation, it we must
// increment the remainingOpenEvaluations counter and move the currentIndex to the next character.
remainingOpenEvaluations += 1;
currentIndex += CASHASSEMBLY_EVALUATION_START_LENGTH;
continue;
}
// A closer at depth one ends this evaluation. Inner closers only reduce nested depth.
if (currentCharacter === ')') {
// Since all previous checks have passed then it means this is a closer for the current evaluation, we must
// decrement the remainingOpenEvaluations counter and check if it is now zero. If it is then we have found the
// matching closer and we can return the currentIndex.
remainingOpenEvaluations -= 1;
if (remainingOpenEvaluations === 0) {
return currentIndex;
}
}
// Move the currentIndex to the next character.
currentIndex += 1;
}
// The matching closer was never found.
throw new CashAssemblyEvaluationUnclosedError(evaluationStartIndex, remainingOpenEvaluations, cashAssemblyText);
};
/**
* One `$()` found in text that is not nested inside another `$()`.
*/
type CashAssemblyEvaluationScanMatch = {
/**
* Complete `$()` text including the opening `$` and the matching closer.
*/
evaluationText: string;
/**
* Index of the `$` that opens this evaluation.
*/
startIndex: number;
/**
* Index of the matching closing parenthesis.
*/
closeIndex: number;
};
/**
* Scans text and returns every `$()` that starts in this string, including empty `$()`.
*
* Nested `$()` stay inside the outer evaluation.
*
* @param {string} cashAssemblyText - Text that may contain `$()`.
* @returns {CashAssemblyEvaluationScanMatch[]} - Evaluations in left to right order.
* @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 scanCashAssemblyEvaluations = (cashAssemblyText: string): CashAssemblyEvaluationScanMatch[] => {
const scannedEvaluations: CashAssemblyEvaluationScanMatch[] = [];
let currentIndex = 0;
// Find `$()` that starts in this string. Nested evaluations stay inside the outer evaluation.
while (currentIndex < cashAssemblyText.length) {
const currentCharacter = cashAssemblyText[currentIndex];
const nextCharacter = cashAssemblyText[currentIndex + 1];
// A `$` that is followed by `(`.
if (currentCharacter === '$' && nextCharacter === '(') {
// Count parentheses so quotes, comments, and nested `$()` cannot close this evaluation early.
const closeIndex = findCashAssemblyEvaluationCloseIndex(cashAssemblyText, currentIndex);
// Slice the complete `$()` so later steps do not re-scan these characters.
const evaluationText = cashAssemblyText.slice(currentIndex, closeIndex + 1);
// Store the outer `$()`. Nested evaluations are already inside evaluationText.
scannedEvaluations.push({
closeIndex,
evaluationText,
startIndex: currentIndex,
});
// Skip to after this `$()` so nested evaluations are not scanned as their own matches.
currentIndex = closeIndex + 1;
continue;
}
currentIndex += 1;
}
return scannedEvaluations;
};
/**
* This function returns each `$()` that starts in this string.
* Empty `$()` is omitted.
*
* @param {string} cashAssemblyText - Text that may contain `$()`.
* @returns {string[]} - Complete non empty evaluation strings in left to right order.
* @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 extractCashAssemblyEvaluations = (cashAssemblyText: string): string[] => {
const scannedEvaluations = scanCashAssemblyEvaluations(cashAssemblyText);
const evaluations: string[] = [];
// Omit empty `$()` so callers that compile extracted text do not treat it as an evaluation.
for (const scannedEvaluation of scannedEvaluations) {
if (scannedEvaluation.evaluationText !== EMPTY_CASHASSEMBLY_EVALUATION) {
evaluations.push(scannedEvaluation.evaluationText);
}
}
return evaluations;
};
/**
* This function returns true when the value is exactly one CashAssembly `$()`.
* Text with characters outside `$()`, empty `$()`, or a failed scan returns false.
*
* @param {unknown} expression - Value to test.
* @returns {boolean} - True when the value is a string that is one complete evaluation and nothing else.
*/
export const isCashAssemblyExpression = (expression: unknown): boolean => {
// Numbers and other types are never CashAssembly evaluations.
if (typeof expression !== 'string') {
return false;
}
try {
const evaluations = extractCashAssemblyEvaluations(expression);
// If there is only one evaluation extracted and the returned value matches to that of the expression passed in the parameter,
// then return true.
return evaluations.length === 1 && evaluations[0] === expression;
} catch {
// A failed scan is not a complete evaluation.
return false;
}
};
+108
View File
@@ -0,0 +1,108 @@
import type { XOInvitationVariableValue, XOTemplate } from '@xo-cash/types';
/**
* Supported decode modes for compiled CashAssembly evaluation bytes.
*/
export type CompiledCashAssemblyDecodeMode = 'utf8' | 'hex' | 'boolean' | 'bigint' | 'uint8array';
/**
* Template script map passed into CashAssembly compilation so evaluations can resolve bare script ids.
*/
export type CashAssemblyTemplateScripts = NonNullable<XOTemplate['scripts']>;
/**
* WalletData and template context shared by CashAssembly compilation.
*/
export type CompileCashAssemblyContext = {
/**
* Runtime WalletData keyed by variable name.
* {@link compileCashAssemblySourceToBytes} collects required names with
* {@link collectVariablesUsingParseScript} then looks them up here for
* primitive method resolution and CashAssembly compilation. A collected name that is absent throws
* {@link CashAssemblyRequiredVariableMissingError}. Missing keys are never replaced with defaults.
*/
variables: Record<string, XOInvitationVariableValue | Uint8Array>;
/**
* 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'] | undefined;
/**
* Named CashAssembly scripts from {@link XOTemplate.scripts}.
* Each key is a script id such as `scriptA`. Each value is that script's source.
* Pass this when compilation refers to those ids, for example `$(scriptA)`, `scriptA`, or
* `scriptA scriptB OP_ADD`. The compiler looks up those ids here. The visitor collects
* WalletData from those sources and from nested ids.
* When omitted, named scripts are not given to the compiler and only WalletData pushes in the
* starting text are collected.
*/
templateScripts?: CashAssemblyTemplateScripts | undefined;
};
/**
* Parameters for compiling CashAssembly text.
*/
export type CompileCashAssemblyStringParameters = CompileCashAssemblyContext & {
/**
* Text to compile, which may contain `$()`.
*
* When this text contains `$()`, each CashAssembly evaluation is compiled and replaced with its
* decoded payload. Surrounding text is kept.
* When this text contains no `$()`, the entire string is compiled as CashAssembly so push
* opcodes remain.
*/
cashAssemblyText: string;
/**
* The mode to decode compiled bytecode into a string.
*/
evaluationDecodeMode?: CompiledCashAssemblyDecodeMode;
};
/**
* Parameters for compiling one CashAssembly source string to bytecode.
*/
export type CompileCashAssemblySourceToBytesParameters = CompileCashAssemblyContext & {
/**
* Evaluation or CashAssembly source passed to the compiler as a script id.
*/
cashAssemblySource: string;
};
/**
* Parameters for compiling one CashAssembly source string to decoded text.
*/
export type CompileCashAssemblySourceToDecodedTextParameters = CompileCashAssemblySourceToBytesParameters & {
/**
* The mode to decode compiled bytecode into a string.
*/
evaluationDecodeMode: CompiledCashAssemblyDecodeMode;
};
/**
* Parameters for preparing a Libauth compiler for CashAssembly source strings.
*/
export type CompileCashAssemblyEvaluationsParameters = {
/**
* CashAssembly source strings such as `$(<var1>)` or `scriptA`.
*/
evaluations: string[];
/**
* Optional template scripts so nested ids compile to the template sources.
*/
templateScripts?: CashAssemblyTemplateScripts | undefined;
/**
* WalletData names already collected with {@link collectVariablesUsingParseScript}.
* This function does not collect again.
*/
variableNames: string[];
};