1603 lines
68 KiB
TypeScript
1603 lines
68 KiB
TypeScript
import { expect, test } from 'vitest';
|
|
import {
|
|
bigIntToVmNumber,
|
|
binToHex,
|
|
createCompilerBch,
|
|
createVirtualMachineBch,
|
|
encodeDataPush,
|
|
generatePrivateKey,
|
|
hash160,
|
|
hash256,
|
|
secp256k1,
|
|
utf8ToBin,
|
|
} from '@bitauth/libauth';
|
|
import { XOTemplatePrimitiveTypes } from '@xo-cash/types';
|
|
import {
|
|
isCashAssemblyExpression,
|
|
extractCashAssemblyEvaluations,
|
|
collectVariablesUsingParseScript,
|
|
compileCashAssemblyString,
|
|
} from '../source/cash-assembly/index.ts';
|
|
import {
|
|
decodeCompiledCashAssemblyEvaluation,
|
|
generateCashAssemblyBytecode,
|
|
compileCashAssemblyEvaluations,
|
|
} from '../source/cash-assembly/evaluations.ts';
|
|
import { resolvePrimitiveMethodBytes } from '../source/cash-assembly/primitive-evaluations.ts';
|
|
import {
|
|
CashAssemblyRequiredVariableMissingError,
|
|
CashAssemblyCompilationFailedError,
|
|
CashAssemblyQuotedLiteralUnclosedError,
|
|
CashAssemblyBlockCommentUnclosedError,
|
|
CashAssemblyEvaluationUnclosedError,
|
|
CashAssemblyVariableTypeMismatchError,
|
|
CashAssemblyPrimitiveMethodMissingError,
|
|
CashAssemblyPrimitiveVariableMissingError,
|
|
CashAssemblyVmNumberDecodeError,
|
|
CashAssemblyNumberNotSafeIntegerError,
|
|
CashAssemblyIdentifierCollisionError,
|
|
} from '../source/cash-assembly/errors.ts';
|
|
|
|
/**
|
|
* Tests that isCashAssemblyExpression recognizes a string made up entirely of one evaluation.
|
|
*/
|
|
const testIsCashAssemblyExpressionMatchesFullExpression = (): void => {
|
|
expect(isCashAssemblyExpression('$(<requestedSatoshis>)')).toBe(true);
|
|
};
|
|
|
|
/**
|
|
* Tests that isCashAssemblyExpression rejects a string that contains an evaluation plus other text.
|
|
*/
|
|
const testIsCashAssemblyExpressionRejectsSurroundingText = (): void => {
|
|
expect(isCashAssemblyExpression('Received $(<requestedSatoshis>)')).toBe(false);
|
|
};
|
|
|
|
/**
|
|
* Tests that isCashAssemblyExpression rejects plain text with no evaluation at all.
|
|
*/
|
|
const testIsCashAssemblyExpressionRejectsPlainText = (): void => {
|
|
expect(isCashAssemblyExpression('Received funds')).toBe(false);
|
|
};
|
|
|
|
/**
|
|
* Tests that isCashAssemblyExpression rejects an empty evaluation, since it references no variables.
|
|
*/
|
|
const testIsCashAssemblyExpressionRejectsEmptyEvaluation = (): void => {
|
|
expect(isCashAssemblyExpression('$()')).toBe(false);
|
|
};
|
|
|
|
/**
|
|
* Tests that isCashAssemblyExpression rejects non-string input rather than coercing it.
|
|
*/
|
|
const testIsCashAssemblyExpressionRejectsNonStringInput = (): void => {
|
|
// A number can never be a CashAssembly expression, regardless of its value
|
|
expect(isCashAssemblyExpression(5000)).toBe(false);
|
|
};
|
|
|
|
/**
|
|
* Tests that isCashAssemblyExpression returns false for unclosed `$()` instead of throwing.
|
|
*/
|
|
const testIsCashAssemblyExpressionRejectsUnclosedEvaluation = (): void => {
|
|
expect(isCashAssemblyExpression('$(<requestedSatoshis>')).toBe(false);
|
|
};
|
|
|
|
/**
|
|
* Tests that extractCashAssemblyEvaluations finds a single evaluation embedded in a larger string.
|
|
*/
|
|
const testExtractCashAssemblyEvaluationsFindsSingleEvaluation = (): void => {
|
|
const satoshisDescription = 'Received $(<requestedSatoshis>) satoshis from sender.';
|
|
|
|
// Only the evaluation substring is returned, not the surrounding text
|
|
expect(extractCashAssemblyEvaluations(satoshisDescription)).toStrictEqual([ '$(<requestedSatoshis>)' ]);
|
|
};
|
|
|
|
/**
|
|
* Tests that extractCashAssemblyEvaluations returns an empty array when the text has no evaluations.
|
|
*/
|
|
const testExtractCashAssemblyEvaluationsReturnsEmptyArrayWhenNoneFound = (): void => {
|
|
expect(extractCashAssemblyEvaluations('Received funds')).toStrictEqual([]);
|
|
};
|
|
|
|
/**
|
|
* Tests that collectVariablesUsingParseScript returns each referenced variable name once,
|
|
* even when it appears in more than one evaluation.
|
|
*/
|
|
const testCollectVariablesUsingParseScriptDeduplicatesAcrossEvaluations = (): void => {
|
|
// Two evaluations share the same WalletData names so collection must not require them twice.
|
|
const firstEvaluation = '$(<requestedTokenAmount> <decimalsFactor> OP_DIV)';
|
|
const secondEvaluation = '$(<requestedTokenAmount> <decimalsFactor> OP_MOD)';
|
|
|
|
// requestedTokenAmount and decimalsFactor each appear in both evaluations but are listed once
|
|
const uniqueVariableNames = [
|
|
...new Set([ ...collectVariablesUsingParseScript(firstEvaluation), ...collectVariablesUsingParseScript(secondEvaluation) ]),
|
|
];
|
|
|
|
expect(uniqueVariableNames).toStrictEqual([ 'requestedTokenAmount', 'decimalsFactor' ]);
|
|
};
|
|
|
|
/**
|
|
* Tests that collectVariablesUsingParseScript excludes numeric and quoted string literals,
|
|
* returning only the true variable reference.
|
|
*/
|
|
const testCollectVariablesUsingParseScriptExcludesLiterals = (): void => {
|
|
const evaluation = '$(<tokenCapability> <0x02> OP_EQUAL OP_IF <"minting"> OP_ELSE <"immutable"> OP_ENDIF)';
|
|
|
|
// <0x02>, <"minting">, and <"immutable"> are literals, not variables, so only tokenCapability is returned
|
|
expect(collectVariablesUsingParseScript(evaluation)).toStrictEqual([ 'tokenCapability' ]);
|
|
};
|
|
|
|
/**
|
|
* Tests that decodeCompiledCashAssemblyEvaluation converts VM number bytes to their decimal string
|
|
* when decode mode is bigint.
|
|
*/
|
|
const testDecodeCompiledCashAssemblyEvaluationDecodesBigint = (): void => {
|
|
const compiledResult = bigIntToVmNumber(1234n);
|
|
|
|
expect(decodeCompiledCashAssemblyEvaluation(compiledResult, 'bigint')).toBe('1234');
|
|
};
|
|
|
|
/**
|
|
* Tests that decodeCompiledCashAssemblyEvaluation throws when bigint mode receives bytes that are not a VM number.
|
|
*/
|
|
const testDecodeCompiledCashAssemblyEvaluationThrowsWhenBigintIsNotAVmNumber = (): void => {
|
|
const decodeNonMinimalZero = (): string => decodeCompiledCashAssemblyEvaluation(new Uint8Array([ 0x00 ]), 'bigint');
|
|
const expectedMessage =
|
|
'CashAssembly evaluation could not be decoded as a VM number: Failed to decode VM Number: the number is not minimally-encoded.';
|
|
|
|
expect(decodeNonMinimalZero).toThrow(CashAssemblyVmNumberDecodeError);
|
|
expect(decodeNonMinimalZero).toThrow(expectedMessage);
|
|
};
|
|
|
|
/**
|
|
* Tests that decodeCompiledCashAssemblyEvaluation converts bytes to a hex string when decode mode is hex.
|
|
*/
|
|
const testDecodeCompiledCashAssemblyEvaluationDecodesHex = (): void => {
|
|
const compiledResult = new Uint8Array([ 0xab, 0xcd ]);
|
|
|
|
expect(decodeCompiledCashAssemblyEvaluation(compiledResult, 'hex')).toBe('abcd');
|
|
};
|
|
|
|
/**
|
|
* Tests that decodeCompiledCashAssemblyEvaluation reports an empty byte array as 'false' and any
|
|
* non-empty byte array as 'true' when decode mode is boolean.
|
|
*/
|
|
const testDecodeCompiledCashAssemblyEvaluationDecodesBoolean = (): void => {
|
|
// An empty byte array is falsy on the BCH VM
|
|
expect(decodeCompiledCashAssemblyEvaluation(new Uint8Array(0), 'boolean')).toBe('false');
|
|
|
|
// Any non-empty byte array is truthy on the BCH VM
|
|
expect(decodeCompiledCashAssemblyEvaluation(new Uint8Array([ 1 ]), 'boolean')).toBe('true');
|
|
};
|
|
|
|
/**
|
|
* Tests that decodeCompiledCashAssemblyEvaluation defaults to utf8 decoding when no mode is given.
|
|
*/
|
|
const testDecodeCompiledCashAssemblyEvaluationDefaultsToUtf8 = (): void => {
|
|
const compiledResult = utf8ToBin('hello');
|
|
|
|
expect(decodeCompiledCashAssemblyEvaluation(compiledResult)).toBe('hello');
|
|
};
|
|
|
|
/**
|
|
* Tests that decodeCompiledCashAssemblyEvaluation returns the comma-separated decimal byte values
|
|
* when decode mode is uint8array.
|
|
*/
|
|
const testDecodeCompiledCashAssemblyEvaluationDecodesUint8Array = (): void => {
|
|
const compiledResult = new Uint8Array([ 1, 2, 255 ]);
|
|
|
|
expect(decodeCompiledCashAssemblyEvaluation(compiledResult, 'uint8array')).toBe('1,2,255');
|
|
};
|
|
|
|
/**
|
|
* Tests that compileCashAssemblyString compiles a single evaluation and decodes it as a bigint.
|
|
*/
|
|
const testCompileCashAssemblyStringDecodesSingleEvaluationAsBigint = (): void => {
|
|
const compiledSatoshisText = compileCashAssemblyString({
|
|
cashAssemblyText: '$(<requestedSatoshis>)',
|
|
variables: { requestedSatoshis: 5000n },
|
|
evaluationDecodeMode: 'bigint',
|
|
});
|
|
|
|
expect(compiledSatoshisText).toBe('5000');
|
|
};
|
|
|
|
/**
|
|
* Tests that compileCashAssemblyString compiles multiple evaluations embedded in the same string.
|
|
*
|
|
*/
|
|
const testCompileCashAssemblyStringCompilesMultipleEvaluationsInOneString = (): void => {
|
|
const tokenDescription =
|
|
'Transferred $(<requestedTokenAmount> <decimalsFactor> OP_DIV).$(<requestedTokenAmount> <decimalsFactor> OP_MOD) tokens.';
|
|
|
|
expect(extractCashAssemblyEvaluations(tokenDescription)).toStrictEqual([
|
|
'$(<requestedTokenAmount> <decimalsFactor> OP_DIV)',
|
|
'$(<requestedTokenAmount> <decimalsFactor> OP_MOD)',
|
|
]);
|
|
|
|
const compiledTokenText = compileCashAssemblyString({
|
|
cashAssemblyText: tokenDescription,
|
|
variables: { requestedTokenAmount: 1050n, decimalsFactor: 100n },
|
|
evaluationDecodeMode: 'bigint',
|
|
});
|
|
|
|
expect(compiledTokenText).toBe('Transferred 10.50 tokens.');
|
|
};
|
|
|
|
/**
|
|
* Tests that a variable referenced twice in one evaluation uses the same supplied value both times.
|
|
*/
|
|
const testCompileCashAssemblyStringReusesTheSameVariableTwiceInOneEvaluation = (): void => {
|
|
const evaluation = '$(<amount> <amount> OP_ADD)';
|
|
|
|
// The name appears twice in the script source but is required only once in the variables map.
|
|
expect(collectVariablesUsingParseScript(evaluation)).toStrictEqual([ 'amount' ]);
|
|
|
|
const compiledText = compileCashAssemblyString({
|
|
cashAssemblyText: evaluation,
|
|
variables: { amount: 21n },
|
|
evaluationDecodeMode: 'bigint',
|
|
});
|
|
|
|
expect(compiledText).toBe('42');
|
|
};
|
|
|
|
/**
|
|
* Tests that compileCashAssemblyString respects OP_IF/OP_ELSE branching driven by a variable.
|
|
*/
|
|
const testCompileCashAssemblyStringEvaluatesConditionalLiterals = (): void => {
|
|
const conditionalExpression = '$(<tokenCapability> <0x02> OP_EQUAL OP_IF <"minting"> OP_ELSE <"immutable"> OP_ENDIF)';
|
|
|
|
// tokenCapability equal to 2 takes the OP_IF branch
|
|
const mintingResult = compileCashAssemblyString({
|
|
cashAssemblyText: conditionalExpression,
|
|
variables: { tokenCapability: 2n },
|
|
evaluationDecodeMode: 'utf8',
|
|
});
|
|
expect(mintingResult).toBe('minting');
|
|
|
|
// tokenCapability not equal to 2 takes the OP_ELSE branch
|
|
const immutableResult = compileCashAssemblyString({
|
|
cashAssemblyText: conditionalExpression,
|
|
variables: { tokenCapability: 0n },
|
|
evaluationDecodeMode: 'utf8',
|
|
});
|
|
expect(immutableResult).toBe('immutable');
|
|
};
|
|
|
|
/**
|
|
* Tests that compileCashAssemblyString resolves nested template script identifiers by following them.
|
|
*
|
|
* Reachable script sources may reference WalletData identifiers that do not appear in the evaluation
|
|
* text itself. The caller must supply every collected identifier.
|
|
*/
|
|
const testCompileCashAssemblyStringResolvesNestedTemplateScriptIdentifiers = (): void => {
|
|
const templateScripts = {
|
|
nestedSum: '<valueA> <valueB> OP_ADD',
|
|
outerSum: 'nestedSum',
|
|
};
|
|
|
|
const compiledSum = compileCashAssemblyString({
|
|
cashAssemblyText: '$(outerSum)',
|
|
variables: {
|
|
valueA: 3n,
|
|
valueB: 4n,
|
|
},
|
|
evaluationDecodeMode: 'bigint',
|
|
templateScripts,
|
|
templateVariables: {
|
|
valueA: { name: 'Value A', description: 'first amount', type: 'integer' as const },
|
|
valueB: { name: 'Value B', description: 'second amount', type: 'integer' as const },
|
|
},
|
|
});
|
|
|
|
expect(compiledSum).toBe('7');
|
|
};
|
|
|
|
/**
|
|
* Tests that unrelated template scripts are not scanned when resolving a nested script evaluation.
|
|
*
|
|
* An unused script may push WalletData that would fail if collected. Only reachable scripts are followed.
|
|
*/
|
|
const testCompileCashAssemblyStringIgnoresUnreachableTemplateScripts = (): void => {
|
|
const templateScripts = {
|
|
used: '<needed>',
|
|
unused: '<notNeeded>',
|
|
};
|
|
|
|
const templateVariables = {
|
|
needed: { name: 'Needed', description: 'reachable WalletData', type: 'integer' as const },
|
|
notNeeded: { name: 'Not needed', description: 'only referenced by unused', type: 'integer' as const },
|
|
};
|
|
|
|
// Collection must demand needed and must not demand notNeeded from unused.
|
|
expect(collectVariablesUsingParseScript('$(used)', templateScripts)).toEqual([ 'needed' ]);
|
|
|
|
const compiled = compileCashAssemblyString({
|
|
cashAssemblyText: '$(used)',
|
|
variables: { needed: 1n },
|
|
evaluationDecodeMode: 'bigint',
|
|
templateScripts,
|
|
templateVariables,
|
|
});
|
|
|
|
expect(compiled).toBe('1');
|
|
};
|
|
|
|
/**
|
|
* Tests that one CashAssembly evaluation can name several template scripts and still follow nested sources.
|
|
*
|
|
* WalletData may live only in those nested scripts. Collection must follow every identifier that
|
|
* exists in the scripts map, including identifiers that appear beside opcodes and on both OP_IF arms.
|
|
* Unrelated scripts in the same map must not be demanded.
|
|
*/
|
|
const testCompileCashAssemblyStringFollowsMultipleNestedScriptsInOneEvaluation = (): void => {
|
|
const templateScripts = {
|
|
flag: '<hasFee>',
|
|
feePush: '<fee>',
|
|
withFee: '<value> feePush OP_ADD',
|
|
changePush: '<change>',
|
|
minusChange: '<value> changePush OP_SUB',
|
|
unusedLock: '<pubkeyHash>',
|
|
};
|
|
|
|
const evaluation = '$(flag OP_IF withFee OP_ELSE minusChange OP_ENDIF)';
|
|
|
|
// Assert collection before compile so unusedLock cannot hide behind a coincidental success.
|
|
const collectedNames = collectVariablesUsingParseScript(evaluation, templateScripts);
|
|
|
|
expect([ ...collectedNames ].sort()).toEqual([ 'change', 'fee', 'hasFee', 'value' ]);
|
|
|
|
// Compile with the fee branch selected. change is still supplied because the unused arm is followed.
|
|
const compiledSatoshis = compileCashAssemblyString({
|
|
cashAssemblyText: evaluation,
|
|
variables: {
|
|
hasFee: 1n,
|
|
value: 1000n,
|
|
fee: 10n,
|
|
change: 0n,
|
|
},
|
|
evaluationDecodeMode: 'bigint',
|
|
templateScripts,
|
|
templateVariables: {
|
|
hasFee: { name: 'Has fee', description: '1 includes the fee', type: 'integer' as const },
|
|
value: { name: 'Value', description: 'base amount', type: 'integer' as const },
|
|
fee: { name: 'Fee', description: 'fee satoshis', type: 'integer' as const },
|
|
change: { name: 'Change', description: 'change amount', type: 'integer' as const },
|
|
pubkeyHash: { name: 'Public key hash', description: 'unused lock hash', type: 'bytes' as const },
|
|
},
|
|
});
|
|
|
|
expect(compiledSatoshis).toBe('1010');
|
|
};
|
|
|
|
/**
|
|
* Tests that a nested script reached only from the second identifier in an evaluation still requires WalletData.
|
|
*/
|
|
const testCompileCashAssemblyStringThrowsWhenNestedScriptOfSecondIdentifierMissesTax = (): void => {
|
|
const compileWithoutTax = (): string =>
|
|
compileCashAssemblyString({
|
|
cashAssemblyText: '$(scriptA scriptB OP_ADD)',
|
|
variables: {
|
|
valueA: 3n,
|
|
valueB: 4n,
|
|
},
|
|
evaluationDecodeMode: 'bigint',
|
|
templateScripts: {
|
|
scriptA: '<valueA>',
|
|
nestedB: '<valueB> <tax> OP_ADD',
|
|
scriptB: 'nestedB',
|
|
},
|
|
templateVariables: {
|
|
valueA: { name: 'Value A', description: 'first amount', type: 'integer' as const },
|
|
valueB: { name: 'Value B', description: 'second amount', type: 'integer' as const },
|
|
tax: { name: 'Tax', description: 'added tax', type: 'integer' as const },
|
|
},
|
|
});
|
|
|
|
// tax appears only under scriptB through nestedB, so following only the first identifier would miss it.
|
|
expect(compileWithoutTax).toThrow(CashAssemblyRequiredVariableMissingError);
|
|
expect(compileWithoutTax).toThrow('Missing required variable: variableNames [tax]');
|
|
};
|
|
|
|
/**
|
|
* Tests that every combination of resolution types claiming one identifier throws an error.
|
|
*/
|
|
const testCompileCashAssemblyStringThrowsOnIdentifierCollisions = (): void => {
|
|
const compileWithVariableAndScript = (): string =>
|
|
compileCashAssemblyString({
|
|
cashAssemblyText: '$(<fee>)',
|
|
variables: { fee: 99n },
|
|
evaluationDecodeMode: 'bigint',
|
|
templateScripts: { fee: '<7>' },
|
|
});
|
|
|
|
expect(compileWithVariableAndScript).toThrow(CashAssemblyIdentifierCollisionError);
|
|
expect(compileWithVariableAndScript).toThrow('CashAssembly identifier exists for more than one resolution type: identifier "fee", resolutionTypes [variable, script]');
|
|
|
|
const compileWithOpcodeAndVariable = (): string =>
|
|
compileCashAssemblyString({
|
|
cashAssemblyText: '$(<OP_ADD>)',
|
|
variables: { OP_ADD: 99n },
|
|
evaluationDecodeMode: 'hex',
|
|
});
|
|
|
|
expect(compileWithOpcodeAndVariable).toThrow(CashAssemblyIdentifierCollisionError);
|
|
expect(compileWithOpcodeAndVariable).toThrow('CashAssembly identifier exists for more than one resolution type: identifier "OP_ADD", resolutionTypes [opcode, variable]');
|
|
|
|
const compileWithOpcodeAndScript = (): string =>
|
|
compileCashAssemblyString({
|
|
cashAssemblyText: '$(<firstAmount> <secondAmount> OP_ADD)',
|
|
variables: { firstAmount: 2n, secondAmount: 3n },
|
|
evaluationDecodeMode: 'bigint',
|
|
templateScripts: { OP_ADD: '<7>' },
|
|
});
|
|
|
|
expect(compileWithOpcodeAndScript).toThrow(CashAssemblyIdentifierCollisionError);
|
|
expect(compileWithOpcodeAndScript).toThrow('CashAssembly identifier exists for more than one resolution type: identifier "OP_ADD", resolutionTypes [opcode, script]');
|
|
|
|
const compileWithAllThreeTypes = (): string =>
|
|
compileCashAssemblyString({
|
|
cashAssemblyText: '$(<OP_ADD>)',
|
|
variables: { OP_ADD: 99n },
|
|
evaluationDecodeMode: 'hex',
|
|
templateScripts: { OP_ADD: '<7>' },
|
|
});
|
|
|
|
expect(compileWithAllThreeTypes).toThrow(CashAssemblyIdentifierCollisionError);
|
|
expect(compileWithAllThreeTypes).toThrow('CashAssembly identifier exists for more than one resolution type: identifier "OP_ADD", resolutionTypes [opcode, variable, script]');
|
|
};
|
|
|
|
/**
|
|
* Template scripts used to compare evaluation, script id compile, and concatenated locking source.
|
|
*/
|
|
const LOCKING_AND_UNLOCKING_TEMPLATE_SCRIPTS = {
|
|
enforceEqualBytecodes: 'OP_INPUTINDEX OP_OUTPUTBYTECODE OP_INPUTINDEX OP_UTXOBYTECODE OP_EQUALVERIFY',
|
|
enforceEqualCategories: 'OP_INPUTINDEX OP_OUTPUTTOKENCATEGORY OP_INPUTINDEX OP_UTXOTOKENCATEGORY OP_EQUALVERIFY',
|
|
enforceEqualSums:
|
|
'OP_INPUTINDEX OP_UTXOTOKENAMOUNT OP_INPUTINDEX OP_UTXOVALUE OP_ADD OP_INPUTINDEX OP_OUTPUTTOKENAMOUNT OP_INPUTINDEX OP_OUTPUTVALUE OP_ADD OP_NUMEQUAL',
|
|
lockingBytecode: 'enforceEqualBytecodes enforceEqualCategories enforceEqualSums',
|
|
unlockingBytecode: '<lockingBytecode>',
|
|
};
|
|
|
|
/**
|
|
* Tests that compileCashAssemblyString compiles a script id as provided and keeps push opcodes.
|
|
*
|
|
* Evaluating `$(unlockingBytecode)` returns only the pushed locking payload. Compiling the
|
|
* script id itself keeps the leading push byte that unlocking bytecode requires.
|
|
*/
|
|
const testCompileCashAssemblyStringKeepsPushOpcodesForUnlockingScript = (): void => {
|
|
// Evaluate unlockingBytecode so the payload hex can be compared against a compile that keeps the push.
|
|
const evaluatedUnlockingPayloadHex = compileCashAssemblyString({
|
|
cashAssemblyText: '$(unlockingBytecode)',
|
|
variables: {},
|
|
evaluationDecodeMode: 'hex',
|
|
templateScripts: LOCKING_AND_UNLOCKING_TEMPLATE_SCRIPTS,
|
|
templateVariables: {},
|
|
});
|
|
|
|
// Compile the bare script id so the push prefix remains in the bytecode.
|
|
const compiledUnlockingBytecodeHex = compileCashAssemblyString({
|
|
cashAssemblyText: 'unlockingBytecode',
|
|
variables: {},
|
|
evaluationDecodeMode: 'hex',
|
|
templateScripts: LOCKING_AND_UNLOCKING_TEMPLATE_SCRIPTS,
|
|
templateVariables: {},
|
|
});
|
|
|
|
// Compiling the script id includes the push length prefix that evaluation drops.
|
|
expect(compiledUnlockingBytecodeHex).toBe(`15${evaluatedUnlockingPayloadHex}`);
|
|
};
|
|
|
|
/**
|
|
* Tests that compileCashAssemblyString compiles concatenated script ids the same as the locking script id.
|
|
*
|
|
* `lockingBytecode` is defined as those three ids in order. Compiling the source string must match
|
|
* compiling the named script.
|
|
*/
|
|
const testCompileCashAssemblyStringCompilesConcatenatedScriptIdsAsLockingBytecode = (): void => {
|
|
// Compile the named locking script so the concatenated source has a known hex result.
|
|
const compiledFromLockingScriptId = compileCashAssemblyString({
|
|
cashAssemblyText: 'lockingBytecode',
|
|
variables: {},
|
|
evaluationDecodeMode: 'hex',
|
|
templateScripts: LOCKING_AND_UNLOCKING_TEMPLATE_SCRIPTS,
|
|
templateVariables: {},
|
|
});
|
|
|
|
// Compile the locking source as written to prove a script id is not required.
|
|
const compiledFromConcatenatedScriptIds = compileCashAssemblyString({
|
|
cashAssemblyText: 'enforceEqualBytecodes enforceEqualCategories enforceEqualSums',
|
|
variables: {},
|
|
evaluationDecodeMode: 'hex',
|
|
templateScripts: LOCKING_AND_UNLOCKING_TEMPLATE_SCRIPTS,
|
|
templateVariables: {},
|
|
});
|
|
|
|
// Both spellings compile the same locking script source.
|
|
expect(compiledFromConcatenatedScriptIds).toBe(compiledFromLockingScriptId);
|
|
};
|
|
|
|
/**
|
|
* Tests that compileCashAssemblyString throws when a chained fee script needs a missing WalletData push.
|
|
*/
|
|
const testCompileCashAssemblyStringThrowsWhenChainedFeeScriptMissesRelayFee = (): void => {
|
|
const compileWithoutRelayFee = (): string =>
|
|
compileCashAssemblyString({
|
|
cashAssemblyText: '$(totalFee)',
|
|
variables: {
|
|
baseFee: 1000n,
|
|
},
|
|
evaluationDecodeMode: 'bigint',
|
|
templateScripts: {
|
|
totalFee: 'baseFeeAndRelay',
|
|
baseFeeAndRelay: '<baseFee> <relayFee> OP_ADD',
|
|
},
|
|
templateVariables: {
|
|
baseFee: { name: 'Base fee', description: 'base fee satoshis', type: 'integer' as const },
|
|
relayFee: { name: 'Relay fee', description: 'relay fee satoshis', type: 'integer' as const },
|
|
},
|
|
});
|
|
|
|
expect(compileWithoutRelayFee).toThrow(CashAssemblyRequiredVariableMissingError);
|
|
expect(compileWithoutRelayFee).toThrow('Missing required variable: variableNames [relayFee]');
|
|
};
|
|
|
|
/**
|
|
* Tests that compileCashAssemblyString throws when a label composition script needs a missing suffix.
|
|
*/
|
|
const testCompileCashAssemblyStringThrowsWhenLabelScriptMissesSuffix = (): void => {
|
|
const compileWithoutSuffix = (): string =>
|
|
compileCashAssemblyString({
|
|
cashAssemblyText: '$(labelScript)',
|
|
variables: {
|
|
prefix: 'xo',
|
|
},
|
|
evaluationDecodeMode: 'utf8',
|
|
templateScripts: {
|
|
labelScript: '$(joinParts)',
|
|
joinParts: '<prefix> <suffix>',
|
|
},
|
|
templateVariables: {
|
|
prefix: { name: 'Prefix', description: 'label prefix', type: 'string' as const },
|
|
suffix: { name: 'Suffix', description: 'label suffix', type: 'string' as const },
|
|
},
|
|
});
|
|
|
|
expect(compileWithoutSuffix).toThrow(CashAssemblyRequiredVariableMissingError);
|
|
expect(compileWithoutSuffix).toThrow('Missing required variable: variableNames [suffix]');
|
|
};
|
|
|
|
/**
|
|
* Tests that compileCashAssemblyString throws when an angle bracket nested script needs a missing hash.
|
|
*/
|
|
const testCompileCashAssemblyStringThrowsWhenNestedLockScriptMissesPubkeyHash = (): void => {
|
|
const compileWithoutPubkeyHash = (): string =>
|
|
compileCashAssemblyString({
|
|
cashAssemblyText: '$(p2pkhPush)',
|
|
variables: {},
|
|
evaluationDecodeMode: 'hex',
|
|
templateScripts: {
|
|
p2pkhPush: '<p2pkhLock>',
|
|
p2pkhLock: 'OP_DUP OP_HASH160 <pubkeyHash> OP_EQUALVERIFY OP_CHECKSIG',
|
|
},
|
|
templateVariables: {
|
|
pubkeyHash: { name: 'Public key hash', description: 'hash160 of the public key', type: 'bytes' as const },
|
|
},
|
|
});
|
|
|
|
expect(compileWithoutPubkeyHash).toThrow(CashAssemblyRequiredVariableMissingError);
|
|
expect(compileWithoutPubkeyHash).toThrow('Missing required variable: variableNames [pubkeyHash]');
|
|
};
|
|
|
|
/**
|
|
* Tests that compileCashAssemblyString throws when a multi hop amount script is missing every WalletData push.
|
|
*/
|
|
const testCompileCashAssemblyStringThrowsWhenScaledAmountScriptMissesAllVariables = (): void => {
|
|
const compileWithNoVariables = (): string =>
|
|
compileCashAssemblyString({
|
|
cashAssemblyText: '$(scaledAmount)',
|
|
variables: {},
|
|
evaluationDecodeMode: 'bigint',
|
|
templateScripts: {
|
|
scaledAmount: 'applyBonus',
|
|
applyBonus: 'doublePrincipal',
|
|
doublePrincipal: '<principal> <2> OP_MUL <bonus> OP_ADD',
|
|
},
|
|
templateVariables: {
|
|
principal: { name: 'Principal', description: 'starting amount', type: 'integer' as const },
|
|
bonus: { name: 'Bonus', description: 'added bonus', type: 'integer' as const },
|
|
},
|
|
});
|
|
|
|
expect(compileWithNoVariables).toThrow(CashAssemblyRequiredVariableMissingError);
|
|
expect(compileWithNoVariables).toThrow('Missing required variable: variableNames [principal, bonus]');
|
|
};
|
|
|
|
/**
|
|
* Tests that compileCashAssemblyString defaults to utf8 decoding when evaluationDecodeMode is omitted.
|
|
*/
|
|
const testCompileCashAssemblyStringDefaultsToUtf8DecodeMode = (): void => {
|
|
const compiledLabel = compileCashAssemblyString({
|
|
cashAssemblyText: '$(<label>)',
|
|
variables: { label: 'hello' },
|
|
});
|
|
|
|
expect(compiledLabel).toBe('hello');
|
|
};
|
|
|
|
/**
|
|
* Tests that compileCashAssemblyString accepts a Uint8Array variable value directly,
|
|
* and decodes the result as hex.
|
|
*/
|
|
const testCompileCashAssemblyStringAcceptsUint8ArrayVariable = (): void => {
|
|
const compiledHash = compileCashAssemblyString({
|
|
cashAssemblyText: '$(<hashBytes>)',
|
|
variables: { hashBytes: new Uint8Array([ 0xde, 0xad, 0xbe, 0xef ]) },
|
|
evaluationDecodeMode: 'hex',
|
|
});
|
|
|
|
expect(compiledHash).toBe('deadbeef');
|
|
};
|
|
|
|
/**
|
|
* Tests that compileCashAssemblyString throws CashAssemblyRequiredVariableMissingError, naming the missing
|
|
* variable, when the variables map does not contain a variable referenced by the text.
|
|
*/
|
|
const testCompileCashAssemblyStringThrowsForMissingVariable = (): void => {
|
|
const compileWithMissingVariable = (): string =>
|
|
compileCashAssemblyString({
|
|
cashAssemblyText: '$(<requestedSatoshis>)',
|
|
variables: {},
|
|
evaluationDecodeMode: 'bigint',
|
|
});
|
|
|
|
expect(compileWithMissingVariable).toThrow(CashAssemblyRequiredVariableMissingError);
|
|
|
|
// The message states the reason (missing from the variables map) and names the specific variable
|
|
expect(compileWithMissingVariable).toThrow('Missing required variable: variableNames [requestedSatoshis]');
|
|
};
|
|
|
|
/**
|
|
* Tests that compileCashAssemblyString throws for a non integer number, and that the same display text
|
|
* can be produced with integer DIV and MOD instead of a float variable.
|
|
*/
|
|
const testCompileCashAssemblyStringThrowsForNonIntegerNumberAndProducesSameDisplayWithIntegerDivMod = (): void => {
|
|
const compileWithNonInteger = (): string =>
|
|
compileCashAssemblyString({
|
|
cashAssemblyText: '$(<amount>)',
|
|
variables: { amount: 12.5 },
|
|
evaluationDecodeMode: 'utf8',
|
|
});
|
|
const expectedMessage = 'CashAssembly number is not a safe integer: identifier "amount", got 12.5';
|
|
|
|
expect(compileWithNonInteger).toThrow(CashAssemblyNumberNotSafeIntegerError);
|
|
expect(compileWithNonInteger).toThrow(expectedMessage);
|
|
|
|
// Floats are rejected. Format decimals from integer quantity and scale with OP_DIV and OP_MOD.
|
|
const compiledText = compileCashAssemblyString({
|
|
cashAssemblyText: '$(<amount> <base> OP_DIV).$(<amount> <base> OP_MOD)',
|
|
variables: { amount: 125n, base: 10n },
|
|
evaluationDecodeMode: 'bigint',
|
|
});
|
|
|
|
expect(compiledText).toBe('12.5');
|
|
};
|
|
|
|
/**
|
|
* Tests that compileCashAssemblyString throws when a number variable is outside the safe integer range.
|
|
*/
|
|
const testCompileCashAssemblyStringThrowsForUnsafeIntegerNumber = (): void => {
|
|
const unsafeInteger = Number.MAX_SAFE_INTEGER + 2;
|
|
const compileWithUnsafeInteger = (): string =>
|
|
compileCashAssemblyString({
|
|
cashAssemblyText: '$(<amount>)',
|
|
variables: { amount: unsafeInteger },
|
|
evaluationDecodeMode: 'utf8',
|
|
});
|
|
const expectedMessage = `CashAssembly number is not a safe integer: identifier "amount", got ${String(unsafeInteger)}`;
|
|
|
|
expect(compileWithUnsafeInteger).toThrow(CashAssemblyNumberNotSafeIntegerError);
|
|
expect(compileWithUnsafeInteger).toThrow(expectedMessage);
|
|
};
|
|
|
|
/**
|
|
* Tests that compileCashAssemblyString throws for non finite number values.
|
|
*/
|
|
const testCompileCashAssemblyStringThrowsForNonFiniteNumber = (): void => {
|
|
const compileWithNaN = (): string =>
|
|
compileCashAssemblyString({
|
|
cashAssemblyText: '$(<amount>)',
|
|
variables: { amount: Number.NaN },
|
|
evaluationDecodeMode: 'utf8',
|
|
});
|
|
const compileWithInfinity = (): string =>
|
|
compileCashAssemblyString({
|
|
cashAssemblyText: '$(<amount>)',
|
|
variables: { amount: Number.POSITIVE_INFINITY },
|
|
evaluationDecodeMode: 'utf8',
|
|
});
|
|
|
|
expect(compileWithNaN).toThrow(CashAssemblyNumberNotSafeIntegerError);
|
|
expect(compileWithNaN).toThrow('CashAssembly number is not a safe integer: identifier "amount", got NaN');
|
|
expect(compileWithInfinity).toThrow(CashAssemblyNumberNotSafeIntegerError);
|
|
expect(compileWithInfinity).toThrow('CashAssembly number is not a safe integer: identifier "amount", got Infinity');
|
|
};
|
|
|
|
/**
|
|
* Tests that a decoded variable value which happens to contain text shaped like a CashAssembly
|
|
* evaluation (e.g. "$(<real>)") is left alone as plain output text
|
|
*/
|
|
const testCompileCashAssemblyStringDoesNotReinterpretInjectedEvaluationLookingText = (): void => {
|
|
const compiledText = compileCashAssemblyString({
|
|
cashAssemblyText: '$(<label>)$(<real>)',
|
|
variables: { label: '$(<real>)', real: '999' },
|
|
evaluationDecodeMode: 'utf8',
|
|
});
|
|
|
|
expect(compiledText).toBe('$(<real>)999');
|
|
};
|
|
|
|
/**
|
|
* Tests that omitting templateVariables leaves method shaped evaluations for CashAssembly.
|
|
*/
|
|
const testCompileCashAssemblyStringLeavesMethodEvaluationForCashAssemblyWithoutTemplateVariables = (): void => {
|
|
const compileWithoutTemplateVariables = (): string =>
|
|
compileCashAssemblyString({
|
|
cashAssemblyText: '$(<expiry.toIso8601>)',
|
|
variables: { expiry: Date.parse('2024-01-15T10:30:00.000Z') },
|
|
});
|
|
|
|
expect(compileWithoutTemplateVariables).toThrow(CashAssemblyRequiredVariableMissingError);
|
|
expect(compileWithoutTemplateVariables).toThrow('Missing required variable: variableNames [expiry.toIso8601]');
|
|
};
|
|
|
|
/**
|
|
* Tests that a type without a mapped primitive class leaves the evaluation for CashAssembly instead of throwing.
|
|
*/
|
|
const testCompileCashAssemblyStringSkipsWhenTypeIsNotAResolvablePrimitive = (): void => {
|
|
const compileWithNonPrimitiveType = (): string =>
|
|
compileCashAssemblyString({
|
|
cashAssemblyText: '$(<label.toIso8601>)',
|
|
variables: { label: 'hello' },
|
|
templateVariables: {
|
|
label: {
|
|
name: 'Label',
|
|
description: 'A text label',
|
|
type: XOTemplatePrimitiveTypes.STRING,
|
|
},
|
|
},
|
|
});
|
|
|
|
expect(compileWithNonPrimitiveType).toThrow(CashAssemblyRequiredVariableMissingError);
|
|
expect(compileWithNonPrimitiveType).toThrow('Missing required variable: variableNames [label.toIso8601]');
|
|
};
|
|
|
|
/**
|
|
* Tests that a method resolvable type with an unknown method throws CashAssemblyPrimitiveMethodMissingError.
|
|
*
|
|
* Once the type maps to a primitive class for method resolution, a missing method is a primitive resolution
|
|
* failure rather than a CashAssembly missing variable.
|
|
*/
|
|
const testCompileCashAssemblyStringThrowsWhenMethodDoesNotExistOnPrimitive = (): void => {
|
|
const compileWithUnknownMethod = (): string =>
|
|
compileCashAssemblyString({
|
|
cashAssemblyText: '$(<expiry.notARealMethod>)',
|
|
variables: { expiry: Date.parse('2024-01-15T10:30:00.000Z') },
|
|
templateVariables: {
|
|
expiry: {
|
|
name: 'Expiry',
|
|
description: 'Invitation expiry time',
|
|
type: XOTemplatePrimitiveTypes.TIMESTAMP,
|
|
},
|
|
},
|
|
});
|
|
|
|
expect(compileWithUnknownMethod).toThrow(CashAssemblyPrimitiveMethodMissingError);
|
|
expect(compileWithUnknownMethod).toThrow('CashAssembly primitive method does not exist: identifier "expiry.notARealMethod", methodName "notARealMethod", type "timestamp"');
|
|
};
|
|
|
|
/**
|
|
* Tests that a committed supported transform throws when the runtime value is missing.
|
|
*/
|
|
const testCompileCashAssemblyStringThrowsWhenSupportedTransformIsMissingRuntimeValue = (): void => {
|
|
const compileWithMissingValue = (): string =>
|
|
compileCashAssemblyString({
|
|
cashAssemblyText: '$(<expiry.toIso8601>)',
|
|
variables: {},
|
|
templateVariables: {
|
|
expiry: {
|
|
name: 'Expiry',
|
|
description: 'Invitation expiry time',
|
|
type: XOTemplatePrimitiveTypes.TIMESTAMP,
|
|
},
|
|
},
|
|
});
|
|
|
|
expect(compileWithMissingValue).toThrow(CashAssemblyPrimitiveVariableMissingError);
|
|
expect(compileWithMissingValue).toThrow('CashAssembly primitive variable is missing from the variables map: identifier "expiry.toIso8601", variableName "expiry"');
|
|
};
|
|
|
|
/**
|
|
* Tests that multiple supported `<base.method>` pushes inside one evaluation.
|
|
*/
|
|
const testCompileCashAssemblyStringResolvesMultiplePrimitiveMethodPushesInOneEvaluation = (): void => {
|
|
const compiledText = compileCashAssemblyString({
|
|
cashAssemblyText: '$(<amount.toSatoshis> <fee.toSatoshis> OP_SUB)',
|
|
variables: {
|
|
amount: 5000n,
|
|
fee: 1000n,
|
|
},
|
|
templateVariables: {
|
|
amount: {
|
|
name: 'Amount',
|
|
description: 'Payment in satoshis',
|
|
type: XOTemplatePrimitiveTypes.SATOSHIS,
|
|
},
|
|
fee: {
|
|
name: 'Fee',
|
|
description: 'Network fee in satoshis',
|
|
type: XOTemplatePrimitiveTypes.SATOSHIS,
|
|
},
|
|
},
|
|
evaluationDecodeMode: 'bigint',
|
|
});
|
|
|
|
expect(compiledText).toBe('4000');
|
|
};
|
|
|
|
/**
|
|
* Tests that lock and display evaluations are extracted from one string and that the lock
|
|
* hash evaluation compiles to the HASH256 of the redeem script.
|
|
*/
|
|
const testCompileCashAssemblyStringHexCompilesP2sLockHashFromMixedLockAndDisplayText = (): void => {
|
|
// Test only key material. Never use with real funds.
|
|
const privateKeyBytes = generatePrivateKey();
|
|
const publicKeyResult = secp256k1.derivePublicKeyCompressed(privateKeyBytes);
|
|
|
|
expect(typeof publicKeyResult).not.toBe('string');
|
|
const publicKeyBytes = publicKeyResult as Uint8Array;
|
|
|
|
// Minimal P2S redeem source. Push the compressed public key, then OP_CHECKSIG.
|
|
const redeemScript = Uint8Array.from([ ...encodeDataPush(publicKeyBytes), 0xac ]);
|
|
const expectedRedeemHashHex = binToHex(hash256(redeemScript));
|
|
|
|
const lockText = 'Lock OP_HASH256 <$(<redeemScript> OP_HASH256)> OP_EQUAL. ';
|
|
const displayTextSource = 'Expires $(<expiry.toIso8601>). Amount $(<amount.toBCH>) BCH ($(<amount.toString>) satoshis).';
|
|
const cashAssemblyText = `${lockText}${displayTextSource}`;
|
|
|
|
const evaluations = extractCashAssemblyEvaluations(cashAssemblyText);
|
|
expect(evaluations).toStrictEqual([ '$(<redeemScript> OP_HASH256)', '$(<expiry.toIso8601>)', '$(<amount.toBCH>)', '$(<amount.toString>)' ]);
|
|
|
|
// Collect from each evaluation so dotted method names stay WalletData identifiers.
|
|
const uniqueVariableNames = [ ...new Set(evaluations.flatMap((evaluation) => collectVariablesUsingParseScript(evaluation))) ];
|
|
expect(uniqueVariableNames).toStrictEqual([ 'redeemScript', 'expiry.toIso8601', 'amount.toBCH', 'amount.toString' ]);
|
|
|
|
const redeemHashHex = compileCashAssemblyString({
|
|
cashAssemblyText: evaluations[0],
|
|
variables: { redeemScript },
|
|
evaluationDecodeMode: 'hex',
|
|
});
|
|
|
|
expect(redeemHashHex).toBe(expectedRedeemHashHex);
|
|
};
|
|
|
|
/**
|
|
* Tests a P2SH32 spend whose redeem script mixes primitive method evaluations with a real Schnorr checksig.
|
|
*
|
|
* Redeem requires:
|
|
* 1. A P2PKH style signature check against owner.public_key.
|
|
* 2. UTXO value equal to price + fee, where price and fee are bound via Satoshis.toSatoshis then OP_ADD.
|
|
*/
|
|
const testP2sh32RedeemWithPrimitiveAddAndSchnorrSignatureSpendsSuccessfully = (): void => {
|
|
// Test only key material. Never use with real funds.
|
|
const privateKeyBytes = generatePrivateKey();
|
|
const priceSatoshis = 5000n;
|
|
const feeSatoshis = 1000n;
|
|
const totalSatoshis = 6000n;
|
|
|
|
const redeemScriptSource =
|
|
'OP_DUP OP_HASH160 <$(<owner.public_key> OP_HASH160)> OP_EQUALVERIFY OP_CHECKSIGVERIFY '
|
|
+ 'OP_INPUTINDEX OP_UTXOVALUE <$(<price.toSatoshis> <fee.toSatoshis> OP_ADD)> OP_EQUAL';
|
|
|
|
// Resolve primitive method pushes so CashAssembly receives VM number bytes under the full identifiers.
|
|
const primitiveMethodBytes = resolvePrimitiveMethodBytes({
|
|
variableNames: [ 'price.toSatoshis', 'fee.toSatoshis' ],
|
|
variables: {
|
|
price: priceSatoshis,
|
|
fee: feeSatoshis,
|
|
},
|
|
templateVariables: {
|
|
price: {
|
|
name: 'Price',
|
|
description: 'Base payment in satoshis',
|
|
type: XOTemplatePrimitiveTypes.SATOSHIS,
|
|
},
|
|
fee: {
|
|
name: 'Fee',
|
|
description: 'Network fee in satoshis',
|
|
type: XOTemplatePrimitiveTypes.SATOSHIS,
|
|
},
|
|
},
|
|
});
|
|
|
|
expect(Object.keys(primitiveMethodBytes)).toStrictEqual([ 'price.toSatoshis', 'fee.toSatoshis' ]);
|
|
|
|
const redeemCompiler = createCompilerBch({
|
|
scripts: {
|
|
redeem: redeemScriptSource,
|
|
},
|
|
variables: {
|
|
owner: { type: 'Key' },
|
|
price: { type: 'WalletData' },
|
|
fee: { type: 'WalletData' },
|
|
},
|
|
});
|
|
|
|
const redeemResult = redeemCompiler.generateBytecode({
|
|
data: {
|
|
keys: { privateKeys: { owner: privateKeyBytes } },
|
|
bytecode: primitiveMethodBytes,
|
|
},
|
|
scriptId: 'redeem',
|
|
});
|
|
|
|
expect(redeemResult.success).toBe(true);
|
|
if (redeemResult.success !== true) {
|
|
return;
|
|
}
|
|
|
|
const redeemBytecode = redeemResult.bytecode;
|
|
|
|
// P2SH32 lock is OP_HASH256 <hash256(redeem)> OP_EQUAL.
|
|
const lockingBytecode = Uint8Array.from([ 0xaa, ...encodeDataPush(hash256(redeemBytecode)), 0x87 ]);
|
|
|
|
// Unlock compiler signs against the same redeem source used as the covered bytecode for P2SH.
|
|
const unlockCompiler = createCompilerBch({
|
|
scripts: {
|
|
lock: redeemScriptSource,
|
|
unlock: '<owner.schnorr_signature.all_outputs> <owner.public_key>',
|
|
},
|
|
unlockingScripts: {
|
|
unlock: 'lock',
|
|
},
|
|
variables: {
|
|
owner: { type: 'Key' },
|
|
price: { type: 'WalletData' },
|
|
fee: { type: 'WalletData' },
|
|
},
|
|
});
|
|
|
|
const program = {
|
|
inputIndex: 0,
|
|
sourceOutputs: [{ lockingBytecode, valueSatoshis: totalSatoshis }],
|
|
transaction: {
|
|
inputs: [
|
|
{
|
|
outpointIndex: 0,
|
|
outpointTransactionHash: new Uint8Array(32),
|
|
sequenceNumber: 0,
|
|
unlockingBytecode: new Uint8Array(),
|
|
},
|
|
],
|
|
locktime: 0,
|
|
outputs: [{ lockingBytecode: new Uint8Array(), valueSatoshis: totalSatoshis }],
|
|
version: 2,
|
|
},
|
|
};
|
|
|
|
const unlockResult = unlockCompiler.generateBytecode({
|
|
data: {
|
|
keys: { privateKeys: { owner: privateKeyBytes } },
|
|
bytecode: primitiveMethodBytes,
|
|
compilationContext: program,
|
|
},
|
|
scriptId: 'unlock',
|
|
});
|
|
|
|
expect(unlockResult.success).toBe(true);
|
|
if (unlockResult.success !== true) {
|
|
return;
|
|
}
|
|
|
|
// P2SH unlock pushes signature and public key, then the redeem script itself.
|
|
program.transaction.inputs[0].unlockingBytecode = Uint8Array.from([ ...unlockResult.bytecode, ...encodeDataPush(redeemBytecode) ]);
|
|
|
|
const virtualMachine = createVirtualMachineBch();
|
|
const evaluationResult = virtualMachine.evaluate(program);
|
|
|
|
expect(virtualMachine.stateSuccess(evaluationResult)).toBe(true);
|
|
|
|
// Wrong UTXO value must fail the baked price + fee equality check.
|
|
const wrongValueProgram = {
|
|
...program,
|
|
sourceOutputs: [{ lockingBytecode, valueSatoshis: totalSatoshis - 1n }],
|
|
transaction: {
|
|
...program.transaction,
|
|
inputs: [
|
|
{
|
|
...program.transaction.inputs[0],
|
|
unlockingBytecode: new Uint8Array(),
|
|
},
|
|
],
|
|
},
|
|
};
|
|
|
|
const wrongValueUnlockResult = unlockCompiler.generateBytecode({
|
|
data: {
|
|
keys: { privateKeys: { owner: privateKeyBytes } },
|
|
bytecode: primitiveMethodBytes,
|
|
compilationContext: wrongValueProgram,
|
|
},
|
|
scriptId: 'unlock',
|
|
});
|
|
|
|
expect(wrongValueUnlockResult.success).toBe(true);
|
|
if (wrongValueUnlockResult.success !== true) {
|
|
return;
|
|
}
|
|
|
|
wrongValueProgram.transaction.inputs[0].unlockingBytecode = Uint8Array.from([
|
|
...wrongValueUnlockResult.bytecode,
|
|
...encodeDataPush(redeemBytecode),
|
|
]);
|
|
|
|
expect(virtualMachine.stateSuccess(virtualMachine.evaluate(wrongValueProgram))).not.toBe(true);
|
|
};
|
|
|
|
/**
|
|
* Tests that generateCashAssemblyBytecode produces the pushed bytes for a variable evaluation.
|
|
*/
|
|
const testGenerateCashAssemblyBytecodeProducesRawBytesForVariable = (): void => {
|
|
const evaluation = '$(<inputValue>)';
|
|
|
|
// Collect once so the compiler and generateBytecode share the same WalletData names.
|
|
const variableNames = collectVariablesUsingParseScript(evaluation);
|
|
const compiler = compileCashAssemblyEvaluations({
|
|
evaluations: [ evaluation ],
|
|
variableNames,
|
|
});
|
|
|
|
const bytecode = generateCashAssemblyBytecode(compiler, evaluation, { inputValue: new Uint8Array([ 4 ]) }, variableNames);
|
|
|
|
expect(bytecode).toStrictEqual(new Uint8Array([ 4 ]));
|
|
};
|
|
|
|
/**
|
|
* Tests that generateCashAssemblyBytecode throws CashAssemblyVariableTypeMismatchError when a variable value is not a Uint8Array.
|
|
*/
|
|
const testGenerateCashAssemblyBytecodeThrowsForNonUint8ArrayVariable = (): void => {
|
|
const evaluation = '$(<inputValue>)';
|
|
|
|
// Collect inputValue so the runtime type guard runs instead of a missing variable error.
|
|
const variableNames = collectVariablesUsingParseScript(evaluation);
|
|
const compiler = compileCashAssemblyEvaluations({
|
|
evaluations: [ evaluation ],
|
|
variableNames,
|
|
});
|
|
|
|
const generateWithTypeMismatch = (): Uint8Array =>
|
|
generateCashAssemblyBytecode(
|
|
compiler,
|
|
evaluation,
|
|
// @ts-expect-error intentional non Uint8Array input to exercise the runtime guard
|
|
{ inputValue: 4 },
|
|
variableNames,
|
|
);
|
|
|
|
expect(generateWithTypeMismatch).toThrow(CashAssemblyVariableTypeMismatchError);
|
|
|
|
expect(generateWithTypeMismatch).toThrow('Variable type mismatch: variableKey "inputValue", expected Uint8Array, got number');
|
|
};
|
|
|
|
/**
|
|
* Tests that generateCashAssemblyBytecode throws CashAssemblyCompilationFailedError when the
|
|
* evaluation references an identifier the compiler cannot resolve as an opcode, variable, or script.
|
|
*/
|
|
const testGenerateCashAssemblyBytecodeThrowsForUnresolvedIdentifier = (): void => {
|
|
const evaluation = '$(<inputValue> unresolvedIdentifier)';
|
|
|
|
// Collect only Push WalletData. unresolvedIdentifier sits beside opcodes and must fail in Libauth.
|
|
const variableNames = collectVariablesUsingParseScript(evaluation);
|
|
const compiler = compileCashAssemblyEvaluations({
|
|
evaluations: [ evaluation ],
|
|
variableNames,
|
|
});
|
|
|
|
const variables = { inputValue: new Uint8Array([ 1 ]) };
|
|
|
|
const generateWithUnresolvedIdentifier = (): Uint8Array => generateCashAssemblyBytecode(compiler, evaluation, variables, variableNames);
|
|
|
|
expect(generateWithUnresolvedIdentifier).toThrow(CashAssemblyCompilationFailedError);
|
|
|
|
expect(generateWithUnresolvedIdentifier).toThrow('Cash assembly compilation failed: Unknown identifier "unresolvedIdentifier".');
|
|
};
|
|
|
|
/**
|
|
* P2PKH locking source with a nested evaluation Push around the public key hash.
|
|
*/
|
|
const LOCK_P2PKH_SCRIPT_SOURCE = 'OP_DUP OP_HASH160 <$(<ownerKey.public_key> OP_HASH160)> OP_EQUALVERIFY OP_CHECKSIG';
|
|
|
|
/**
|
|
* Tests that parse collection reads a dotted WalletData name from an inner Push.
|
|
*/
|
|
const testCollectVariablesUsingParseScriptCollectsNestedPublicKeyPush = (): void => {
|
|
// Walk the nested P2PKH Push so ownerKey.public_key is collected as WalletData.
|
|
const parseCollectedNames = collectVariablesUsingParseScript(LOCK_P2PKH_SCRIPT_SOURCE);
|
|
|
|
// The inner Push Identifier is WalletData. OP_HASH160 stays an opcode.
|
|
expect(parseCollectedNames).toStrictEqual([ 'ownerKey.public_key' ]);
|
|
};
|
|
|
|
/**
|
|
* Tests that parse collection keeps underscore identifiers from a Push.
|
|
*/
|
|
const testCollectVariablesUsingParseScriptCollectsUnderscoreIdentifier = (): void => {
|
|
// parseScript treats `_` as an identifier character so WalletData names can use it.
|
|
const collectedNames = collectVariablesUsingParseScript('$(<a_variable>)');
|
|
|
|
// Underscore is part of the identifier, not a separator that would drop the name.
|
|
expect(collectedNames).toStrictEqual([ 'a_variable' ]);
|
|
};
|
|
|
|
/**
|
|
* Tests that parse collection throws when parseScript rejects the source.
|
|
*/
|
|
const testCollectVariablesUsingParseScriptThrowsWhenParseFails = (): void => {
|
|
// An unclosed Push is invalid CashAssembly so collection must fail before inventing WalletData names.
|
|
const collectUnclosedPush = (): string[] => collectVariablesUsingParseScript('OP_DUP <');
|
|
|
|
// The typed error must be raised so callers cannot treat a parse failure as an empty variable list.
|
|
expect(collectUnclosedPush).toThrow(CashAssemblyCompilationFailedError);
|
|
expect(collectUnclosedPush).toThrow('Encountered unexpected input while parsing script');
|
|
};
|
|
|
|
/**
|
|
* Tests that compileCashAssemblyString compiles a nested P2PKH lock when ownerKey is supplied.
|
|
*
|
|
* The starting text is the script id so surrounding opcodes stay in the compiler rather than in a label.
|
|
*/
|
|
const testCompileCashAssemblyStringCompilesNestedP2pkhLockFromPublicKey = (): void => {
|
|
// Test only key material. Never use with real funds.
|
|
const privateKeyBytes = generatePrivateKey();
|
|
|
|
// Derive the compressed public key that the lock script hashes inside `$()`.
|
|
const publicKeyResult = secp256k1.derivePublicKeyCompressed(privateKeyBytes);
|
|
|
|
// Libauth returns an error string when derivation fails. Tests require the compressed key bytes.
|
|
expect(typeof publicKeyResult).not.toBe('string');
|
|
|
|
// Narrow after the string error arm so later hashing receives a Uint8Array.
|
|
const publicKeyBytes = publicKeyResult as Uint8Array;
|
|
|
|
// HASH160 of the compressed public key is what the locking script pushes after OP_HASH160.
|
|
const publicKeyHashHex = binToHex(hash160(publicKeyBytes));
|
|
|
|
// Compile the script id so nested `$()` inside the source is handled by Libauth, not by label interpolation.
|
|
const compiledLockHex = compileCashAssemblyString({
|
|
cashAssemblyText: 'lockP2PKH',
|
|
variables: {
|
|
'ownerKey.public_key': publicKeyBytes,
|
|
},
|
|
evaluationDecodeMode: 'hex',
|
|
templateScripts: {
|
|
lockP2PKH: LOCK_P2PKH_SCRIPT_SOURCE,
|
|
},
|
|
});
|
|
|
|
// OP_DUP OP_HASH160 PUSH20 hash160(publicKey) OP_EQUALVERIFY OP_CHECKSIG.
|
|
expect(compiledLockHex).toBe(`76a914${publicKeyHashHex}88ac`);
|
|
};
|
|
|
|
/**
|
|
* Tests that compileCashAssemblyString compiles an underscore WalletData push.
|
|
*/
|
|
const testCompileCashAssemblyStringCompilesUnderscoreIdentifier = (): void => {
|
|
// Compile the same underscore identifier collected above so the WalletData path is exercised.
|
|
const compiledText = compileCashAssemblyString({
|
|
cashAssemblyText: '$(<a_variable>)',
|
|
variables: { a_variable: 7n },
|
|
evaluationDecodeMode: 'bigint',
|
|
});
|
|
|
|
// The underscore identifier compiles as ordinary WalletData, not as two names.
|
|
expect(compiledText).toBe('7');
|
|
};
|
|
|
|
/**
|
|
* Tests that a `)` inside a quoted UTF8 literal does not end the `$()` evaluation.
|
|
*/
|
|
const testExtractCashAssemblyEvaluationsKeepsClosingParenInsideQuotedLiteral = (): void => {
|
|
// Quote a `)` inside the first evaluation so it is payload text, not a closer.
|
|
const mixedLabel = 'Paid $(<"x)y">) of $(<z>)';
|
|
|
|
// The quoted `)` is payload text. Both evaluations must be returned complete.
|
|
expect(extractCashAssemblyEvaluations(mixedLabel)).toStrictEqual([ '$(<"x)y">)', '$(<z>)' ]);
|
|
};
|
|
|
|
/**
|
|
* Tests that a nested `$()` stays inside the outer evaluation instead of becoming a second match.
|
|
*/
|
|
const testExtractCashAssemblyEvaluationsKeepsNestedEvaluationInsideOuterEvaluation = (): void => {
|
|
// Nest `$()` so extract must return one outer evaluation rather than two matches.
|
|
const nestedEvaluation = '$(<outer> $(<inner>))';
|
|
|
|
// The outer evaluation includes the nested evaluation.
|
|
expect(extractCashAssemblyEvaluations(nestedEvaluation)).toStrictEqual([ '$(<outer> $(<inner>))' ]);
|
|
};
|
|
|
|
/**
|
|
* Tests that an unclosed `$()` throws rather than compiling the remaining text as CashAssembly.
|
|
*/
|
|
const testExtractCashAssemblyEvaluationsThrowsWhenEvaluationIsUnclosed = (): void => {
|
|
// The fee evaluation never reaches a closer after the text prefix.
|
|
const unclosedText = 'Paid $(<fee>';
|
|
|
|
try {
|
|
extractCashAssemblyEvaluations(unclosedText);
|
|
expect.unreachable();
|
|
} catch (thrownError: unknown) {
|
|
// The error must name the `$` index and leftover text so the unclosed evaluation can be found.
|
|
expect(thrownError).toBeInstanceOf(CashAssemblyEvaluationUnclosedError);
|
|
|
|
if (thrownError instanceof CashAssemblyEvaluationUnclosedError) {
|
|
expect(thrownError.evaluationStartIndex).toBe(5);
|
|
expect(thrownError.remainingOpenEvaluations).toBe(1);
|
|
expect(thrownError.unclosedText).toBe('$(<fee>');
|
|
expect(thrownError.message).toBe('CashAssembly evaluation is unclosed: evaluationStartIndex 5, remainingOpenEvaluations 1, unclosedText "$(<fee>"');
|
|
}
|
|
}
|
|
};
|
|
|
|
/**
|
|
* Tests that compile keeps a `)` that belongs to a quoted UTF8 literal.
|
|
*/
|
|
const testCompileCashAssemblyStringCompilesQuotedLiteralContainingCloser = (): void => {
|
|
// A quoted `)` is payload text and must remain inside `$()`.
|
|
const compiledText = compileCashAssemblyString({
|
|
cashAssemblyText: '$(<"hello)world">)',
|
|
variables: {},
|
|
evaluationDecodeMode: 'utf8',
|
|
});
|
|
|
|
// The `)` is payload text, not the end of the evaluation.
|
|
expect(compiledText).toBe('hello)world');
|
|
};
|
|
|
|
/**
|
|
* Tests that extract omits empty `$()` while still returning a later non empty evaluation.
|
|
*/
|
|
const testExtractCashAssemblyEvaluationsOmitsEmptyEvaluationBesideNonEmpty = (): void => {
|
|
// Include empty `$()` so extract must omit it without dropping the later evaluation.
|
|
const mixedLabel = 'Paid $() of $(<z>)';
|
|
|
|
// Empty `$()` names no WalletData. Extract must not list it beside the later evaluation.
|
|
expect(extractCashAssemblyEvaluations(mixedLabel)).toStrictEqual([ '$(<z>)' ]);
|
|
};
|
|
|
|
/**
|
|
* Tests that a `)` inside a line comment does not end the `$()` evaluation.
|
|
*/
|
|
const testExtractCashAssemblyEvaluationsKeepsClosingParenInsideLineComment = (): void => {
|
|
// Put a `)` in a line comment so the tax push stays inside `$()`.
|
|
const evaluationWithLineComment = `$(
|
|
<valueSatoshis>
|
|
// Miner fee is pushed next. A closer in this note ) is not the evaluation closer.
|
|
<fee>
|
|
OP_ADD
|
|
)`;
|
|
|
|
// The scan skips the comment so the closer after OP_ADD is the evaluation closer.
|
|
expect(extractCashAssemblyEvaluations(evaluationWithLineComment)).toStrictEqual([ evaluationWithLineComment ]);
|
|
};
|
|
|
|
/**
|
|
* Tests that a `)` inside a block comment does not end the `$()` evaluation.
|
|
*/
|
|
const testExtractCashAssemblyEvaluationsKeepsClosingParenInsideBlockComment = (): void => {
|
|
// Put a `)` in a block comment so both pushes stay inside one evaluation.
|
|
const evaluationWithBlockComment = '$(<fee> /* ) */ <tax>)';
|
|
|
|
// The scan skips the block comment so both pushes stay inside one evaluation.
|
|
expect(extractCashAssemblyEvaluations(evaluationWithBlockComment)).toStrictEqual([ '$(<fee> /* ) */ <tax>)' ]);
|
|
};
|
|
|
|
/**
|
|
* Tests that an unclosed quoted literal throws.
|
|
*/
|
|
const testExtractCashAssemblyEvaluationsThrowsWhenQuotedLiteralIsUnclosed = (): void => {
|
|
try {
|
|
extractCashAssemblyEvaluations('$(<"hello)');
|
|
expect.unreachable();
|
|
} catch (thrownError: unknown) {
|
|
expect(thrownError).toBeInstanceOf(CashAssemblyQuotedLiteralUnclosedError);
|
|
|
|
if (thrownError instanceof CashAssemblyQuotedLiteralUnclosedError) {
|
|
expect(thrownError.quoteCharacter).toBe('"');
|
|
expect(thrownError.openingQuoteIndex).toBe(3);
|
|
expect(thrownError.unclosedText).toBe('"hello)');
|
|
expect(thrownError.message).toBe('Quoted literal in a CashAssembly evaluation is unclosed: quoteCharacter "\\"", openingQuoteIndex 3, unclosedText "\\"hello)"');
|
|
}
|
|
}
|
|
|
|
try {
|
|
extractCashAssemblyEvaluations("$(<'hello)");
|
|
expect.unreachable();
|
|
} catch (thrownError: unknown) {
|
|
expect(thrownError).toBeInstanceOf(CashAssemblyQuotedLiteralUnclosedError);
|
|
|
|
if (thrownError instanceof CashAssemblyQuotedLiteralUnclosedError) {
|
|
expect(thrownError.quoteCharacter).toBe("'");
|
|
expect(thrownError.openingQuoteIndex).toBe(3);
|
|
expect(thrownError.unclosedText).toBe("'hello)");
|
|
expect(thrownError.message).toBe('Quoted literal in a CashAssembly evaluation is unclosed: quoteCharacter "\'", openingQuoteIndex 3, unclosedText "\'hello)"');
|
|
}
|
|
}
|
|
};
|
|
|
|
/**
|
|
* Tests that an unclosed block comment throws.
|
|
*/
|
|
const testExtractCashAssemblyEvaluationsThrowsWhenBlockCommentIsUnclosed = (): void => {
|
|
const unclosedBlockCommentText = '$(<fee> /* still open';
|
|
|
|
try {
|
|
extractCashAssemblyEvaluations(unclosedBlockCommentText);
|
|
expect.unreachable();
|
|
} catch (thrownError: unknown) {
|
|
expect(thrownError).toBeInstanceOf(CashAssemblyBlockCommentUnclosedError);
|
|
|
|
if (thrownError instanceof CashAssemblyBlockCommentUnclosedError) {
|
|
expect(thrownError.commentStartIndex).toBe(8);
|
|
expect(thrownError.unclosedText).toBe('/* still open');
|
|
expect(thrownError.message).toBe('Block comment in a CashAssembly evaluation is unclosed: commentStartIndex 8, unclosedText "/* still open"');
|
|
}
|
|
}
|
|
};
|
|
|
|
/**
|
|
* Tests that compile keeps text that contains only empty `$()` instead of compiling it as CashAssembly.
|
|
*/
|
|
const testCompileCashAssemblyStringKeepsTextWithOnlyEmptyEvaluation = (): void => {
|
|
// Text with only empty `$()` must not be parsed as CashAssembly.
|
|
const compiledText = compileCashAssemblyString({
|
|
cashAssemblyText: 'Paid $()',
|
|
variables: {},
|
|
evaluationDecodeMode: 'utf8',
|
|
});
|
|
|
|
// Text plus empty `$()` is not CashAssembly. Copy the characters through.
|
|
expect(compiledText).toBe('Paid $()');
|
|
};
|
|
|
|
/**
|
|
* Tests that compile copies empty `$()` through while still compiling a later evaluation.
|
|
*/
|
|
const testCompileCashAssemblyStringCopiesEmptyEvaluationBesideCompiledEvaluation = (): void => {
|
|
// Empty `$()` must stay in the label while the later evaluation still compiles.
|
|
const compiledText = compileCashAssemblyString({
|
|
cashAssemblyText: 'Paid $() of $(<"ok">)',
|
|
variables: {},
|
|
evaluationDecodeMode: 'utf8',
|
|
});
|
|
|
|
// Empty `$()` stays. The later evaluation is decoded into the text.
|
|
expect(compiledText).toBe('Paid $() of ok');
|
|
};
|
|
|
|
/**
|
|
* Tests that compile skips a line comment `)` and still evaluates the remaining push.
|
|
*/
|
|
const testCompileCashAssemblyStringCompilesEvaluationWithLineCommentCloser = (): void => {
|
|
// A line comment `)` must not stop compilation of the fee push that follows on the next line.
|
|
const compiledText = compileCashAssemblyString({
|
|
cashAssemblyText: `$(
|
|
<valueSatoshis>
|
|
// Miner fee is pushed next. A closer in this note ) is not the evaluation closer.
|
|
<fee>
|
|
OP_ADD
|
|
)`,
|
|
variables: {
|
|
valueSatoshis: 1000n,
|
|
fee: 10n,
|
|
},
|
|
evaluationDecodeMode: 'bigint',
|
|
});
|
|
|
|
// The `)` in the comment is not the evaluation closer. The evaluation adds fee to valueSatoshis.
|
|
expect(compiledText).toBe('1010');
|
|
};
|
|
|
|
/**
|
|
* Tests that compile skips a block comment `)` and still evaluates the remaining push.
|
|
*/
|
|
const testCompileCashAssemblyStringCompilesEvaluationWithBlockCommentCloser = (): void => {
|
|
// A block comment `)` must not stop compilation of the fee push that follows the comment.
|
|
const compiledText = compileCashAssemblyString({
|
|
cashAssemblyText: '$(<fee> /* ) */ )',
|
|
variables: { fee: 5n },
|
|
evaluationDecodeMode: 'bigint',
|
|
});
|
|
|
|
// The `)` inside the block comment is not the evaluation closer.
|
|
expect(compiledText).toBe('5');
|
|
};
|
|
|
|
const runTests = async (): Promise<void> => {
|
|
test('isCashAssemblyExpression: matches a full expression', testIsCashAssemblyExpressionMatchesFullExpression);
|
|
test('isCashAssemblyExpression: rejects surrounding text', testIsCashAssemblyExpressionRejectsSurroundingText);
|
|
test('isCashAssemblyExpression: rejects plain text', testIsCashAssemblyExpressionRejectsPlainText);
|
|
test('isCashAssemblyExpression: rejects an empty evaluation', testIsCashAssemblyExpressionRejectsEmptyEvaluation);
|
|
test('isCashAssemblyExpression: rejects non-string input', testIsCashAssemblyExpressionRejectsNonStringInput);
|
|
test('isCashAssemblyExpression: rejects an unclosed evaluation', testIsCashAssemblyExpressionRejectsUnclosedEvaluation);
|
|
|
|
test('extractCashAssemblyEvaluations: finds a single evaluation', testExtractCashAssemblyEvaluationsFindsSingleEvaluation);
|
|
test(
|
|
'extractCashAssemblyEvaluations: returns an empty array when none are found',
|
|
testExtractCashAssemblyEvaluationsReturnsEmptyArrayWhenNoneFound,
|
|
);
|
|
|
|
test('collectVariablesUsingParseScript: deduplicates across evaluations', testCollectVariablesUsingParseScriptDeduplicatesAcrossEvaluations);
|
|
test('collectVariablesUsingParseScript: excludes literals', testCollectVariablesUsingParseScriptExcludesLiterals);
|
|
|
|
test('decodeCompiledCashAssemblyEvaluation: decodes bigint', testDecodeCompiledCashAssemblyEvaluationDecodesBigint);
|
|
test(
|
|
'decodeCompiledCashAssemblyEvaluation: throws when bigint bytes are not a VM number',
|
|
testDecodeCompiledCashAssemblyEvaluationThrowsWhenBigintIsNotAVmNumber,
|
|
);
|
|
test('decodeCompiledCashAssemblyEvaluation: decodes hex', testDecodeCompiledCashAssemblyEvaluationDecodesHex);
|
|
test('decodeCompiledCashAssemblyEvaluation: decodes boolean', testDecodeCompiledCashAssemblyEvaluationDecodesBoolean);
|
|
test('decodeCompiledCashAssemblyEvaluation: defaults to utf8', testDecodeCompiledCashAssemblyEvaluationDefaultsToUtf8);
|
|
test('decodeCompiledCashAssemblyEvaluation: decodes uint8array', testDecodeCompiledCashAssemblyEvaluationDecodesUint8Array);
|
|
|
|
test('compileCashAssemblyString: decodes a single evaluation as bigint', testCompileCashAssemblyStringDecodesSingleEvaluationAsBigint);
|
|
test(
|
|
'compileCashAssemblyString: compiles multiple evaluations in one string',
|
|
testCompileCashAssemblyStringCompilesMultipleEvaluationsInOneString,
|
|
);
|
|
test(
|
|
'compileCashAssemblyString: reuses the same variable twice in one evaluation',
|
|
testCompileCashAssemblyStringReusesTheSameVariableTwiceInOneEvaluation,
|
|
);
|
|
test('compileCashAssemblyString: evaluates conditional literals', testCompileCashAssemblyStringEvaluatesConditionalLiterals);
|
|
test(
|
|
'compileCashAssemblyString: resolves nested template script identifiers',
|
|
testCompileCashAssemblyStringResolvesNestedTemplateScriptIdentifiers,
|
|
);
|
|
test('compileCashAssemblyString: ignores unreachable template scripts', testCompileCashAssemblyStringIgnoresUnreachableTemplateScripts);
|
|
test(
|
|
'compileCashAssemblyString: follows multiple nested scripts in one evaluation',
|
|
testCompileCashAssemblyStringFollowsMultipleNestedScriptsInOneEvaluation,
|
|
);
|
|
test(
|
|
'compileCashAssemblyString: throws when a nested script of the second identifier misses tax',
|
|
testCompileCashAssemblyStringThrowsWhenNestedScriptOfSecondIdentifierMissesTax,
|
|
);
|
|
test('compileCashAssemblyString: throws on identifier collisions', testCompileCashAssemblyStringThrowsOnIdentifierCollisions);
|
|
test('compileCashAssemblyString: keeps push opcodes for unlocking script', testCompileCashAssemblyStringKeepsPushOpcodesForUnlockingScript);
|
|
test(
|
|
'compileCashAssemblyString: compiles concatenated script ids as locking bytecode',
|
|
testCompileCashAssemblyStringCompilesConcatenatedScriptIdsAsLockingBytecode,
|
|
);
|
|
test(
|
|
'compileCashAssemblyString: throws when a chained fee script misses relayFee',
|
|
testCompileCashAssemblyStringThrowsWhenChainedFeeScriptMissesRelayFee,
|
|
);
|
|
test('compileCashAssemblyString: throws when a label script misses suffix', testCompileCashAssemblyStringThrowsWhenLabelScriptMissesSuffix);
|
|
test(
|
|
'compileCashAssemblyString: throws when a nested lock script misses pubkeyHash',
|
|
testCompileCashAssemblyStringThrowsWhenNestedLockScriptMissesPubkeyHash,
|
|
);
|
|
test(
|
|
'compileCashAssemblyString: throws when a scaled amount script misses all variables',
|
|
testCompileCashAssemblyStringThrowsWhenScaledAmountScriptMissesAllVariables,
|
|
);
|
|
test('compileCashAssemblyString: defaults to utf8 decode mode', testCompileCashAssemblyStringDefaultsToUtf8DecodeMode);
|
|
test('compileCashAssemblyString: accepts a Uint8Array variable', testCompileCashAssemblyStringAcceptsUint8ArrayVariable);
|
|
test('compileCashAssemblyString: throws for a missing variable', testCompileCashAssemblyStringThrowsForMissingVariable);
|
|
test(
|
|
'compileCashAssemblyString: throws for a non-integer number and produces the same display with integer DIV and MOD',
|
|
testCompileCashAssemblyStringThrowsForNonIntegerNumberAndProducesSameDisplayWithIntegerDivMod,
|
|
);
|
|
test('compileCashAssemblyString: throws for an unsafe integer number', testCompileCashAssemblyStringThrowsForUnsafeIntegerNumber);
|
|
test('compileCashAssemblyString: throws for a non-finite number', testCompileCashAssemblyStringThrowsForNonFiniteNumber);
|
|
test(
|
|
'compileCashAssemblyString: does not reinterpret injected evaluation-looking text',
|
|
testCompileCashAssemblyStringDoesNotReinterpretInjectedEvaluationLookingText,
|
|
);
|
|
test(
|
|
'compileCashAssemblyString: leaves method evaluations for CashAssembly without templateVariables',
|
|
testCompileCashAssemblyStringLeavesMethodEvaluationForCashAssemblyWithoutTemplateVariables,
|
|
);
|
|
test(
|
|
'compileCashAssemblyString: skips when type is not a resolvable primitive',
|
|
testCompileCashAssemblyStringSkipsWhenTypeIsNotAResolvablePrimitive,
|
|
);
|
|
test(
|
|
'compileCashAssemblyString: throws when method does not exist on the primitive',
|
|
testCompileCashAssemblyStringThrowsWhenMethodDoesNotExistOnPrimitive,
|
|
);
|
|
test(
|
|
'compileCashAssemblyString: throws when a supported transform is missing its runtime value',
|
|
testCompileCashAssemblyStringThrowsWhenSupportedTransformIsMissingRuntimeValue,
|
|
);
|
|
test(
|
|
'compileCashAssemblyString: resolves multiple primitive method pushes in one evaluation',
|
|
testCompileCashAssemblyStringResolvesMultiplePrimitiveMethodPushesInOneEvaluation,
|
|
);
|
|
test(
|
|
'compileCashAssemblyString: hex-compiles a P2S lock hash extracted from mixed lock and display text',
|
|
testCompileCashAssemblyStringHexCompilesP2sLockHashFromMixedLockAndDisplayText,
|
|
);
|
|
test(
|
|
'P2SH32 redeem: primitive OP_ADD and Schnorr signature spend successfully',
|
|
testP2sh32RedeemWithPrimitiveAddAndSchnorrSignatureSpendsSuccessfully,
|
|
);
|
|
|
|
test('generateCashAssemblyBytecode: produces raw bytes for a variable', testGenerateCashAssemblyBytecodeProducesRawBytesForVariable);
|
|
test('generateCashAssemblyBytecode: throws for a non-Uint8Array variable', testGenerateCashAssemblyBytecodeThrowsForNonUint8ArrayVariable);
|
|
test('generateCashAssemblyBytecode: throws for an unresolved identifier', testGenerateCashAssemblyBytecodeThrowsForUnresolvedIdentifier);
|
|
|
|
test(
|
|
'collectVariablesUsingParseScript: collects ownerKey.public_key from a nested P2PKH Push',
|
|
testCollectVariablesUsingParseScriptCollectsNestedPublicKeyPush,
|
|
);
|
|
test('collectVariablesUsingParseScript: collects an underscore identifier', testCollectVariablesUsingParseScriptCollectsUnderscoreIdentifier);
|
|
test('collectVariablesUsingParseScript: throws when parseScript rejects the source', testCollectVariablesUsingParseScriptThrowsWhenParseFails);
|
|
test(
|
|
'compileCashAssemblyString: compiles a nested P2PKH lock from ownerKey.public_key',
|
|
testCompileCashAssemblyStringCompilesNestedP2pkhLockFromPublicKey,
|
|
);
|
|
test('compileCashAssemblyString: compiles an underscore identifier', testCompileCashAssemblyStringCompilesUnderscoreIdentifier);
|
|
test(
|
|
'extractCashAssemblyEvaluations: keeps a closing parenthesis inside a quoted literal',
|
|
testExtractCashAssemblyEvaluationsKeepsClosingParenInsideQuotedLiteral,
|
|
);
|
|
test(
|
|
'extractCashAssemblyEvaluations: keeps a nested evaluation inside the outer evaluation',
|
|
testExtractCashAssemblyEvaluationsKeepsNestedEvaluationInsideOuterEvaluation,
|
|
);
|
|
test('extractCashAssemblyEvaluations: throws when an evaluation is unclosed', testExtractCashAssemblyEvaluationsThrowsWhenEvaluationIsUnclosed);
|
|
test(
|
|
'compileCashAssemblyString: compiles a quoted literal that contains a closer',
|
|
testCompileCashAssemblyStringCompilesQuotedLiteralContainingCloser,
|
|
);
|
|
test(
|
|
'extractCashAssemblyEvaluations: omits an empty evaluation beside a later evaluation',
|
|
testExtractCashAssemblyEvaluationsOmitsEmptyEvaluationBesideNonEmpty,
|
|
);
|
|
test(
|
|
'extractCashAssemblyEvaluations: keeps a closing parenthesis inside a line comment',
|
|
testExtractCashAssemblyEvaluationsKeepsClosingParenInsideLineComment,
|
|
);
|
|
test(
|
|
'extractCashAssemblyEvaluations: keeps a closing parenthesis inside a block comment',
|
|
testExtractCashAssemblyEvaluationsKeepsClosingParenInsideBlockComment,
|
|
);
|
|
test(
|
|
'extractCashAssemblyEvaluations: throws when a quoted literal is unclosed',
|
|
testExtractCashAssemblyEvaluationsThrowsWhenQuotedLiteralIsUnclosed,
|
|
);
|
|
test(
|
|
'extractCashAssemblyEvaluations: throws when a block comment is unclosed',
|
|
testExtractCashAssemblyEvaluationsThrowsWhenBlockCommentIsUnclosed,
|
|
);
|
|
test(
|
|
'compileCashAssemblyString: keeps text that contains only an empty evaluation',
|
|
testCompileCashAssemblyStringKeepsTextWithOnlyEmptyEvaluation,
|
|
);
|
|
test(
|
|
'compileCashAssemblyString: copies an empty evaluation beside a compiled evaluation',
|
|
testCompileCashAssemblyStringCopiesEmptyEvaluationBesideCompiledEvaluation,
|
|
);
|
|
test(
|
|
'compileCashAssemblyString: compiles an evaluation that comments out a closer',
|
|
testCompileCashAssemblyStringCompilesEvaluationWithLineCommentCloser,
|
|
);
|
|
test(
|
|
'compileCashAssemblyString: compiles an evaluation with a closer inside a block comment',
|
|
testCompileCashAssemblyStringCompilesEvaluationWithBlockCommentCloser,
|
|
);
|
|
};
|
|
|
|
await runTests();
|