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
+819 -24
View File
@@ -6,6 +6,7 @@ import {
createVirtualMachineBch,
encodeDataPush,
generatePrivateKey,
hash160,
hash256,
secp256k1,
utf8ToBin,
@@ -14,21 +15,27 @@ import { XOTemplatePrimitiveTypes } from '@xo-cash/types';
import {
isCashAssemblyExpression,
extractCashAssemblyEvaluations,
extractVariablesFromEvaluations,
decodeCompiledCashAssemblyEvaluation,
collectVariablesUsingParseScript,
compileCashAssemblyString,
} from '../source/cash-assembly/index.ts';
import {
decodeCompiledCashAssemblyEvaluation,
generateCashAssemblyBytecode,
compileCashAssemblyEvaluations,
resolvePrimitiveMethodBytes,
} from '../source/cash-assembly/index.ts';
} 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';
/**
@@ -67,6 +74,13 @@ const testIsCashAssemblyExpressionRejectsNonStringInput = (): void => {
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.
*/
@@ -85,25 +99,31 @@ const testExtractCashAssemblyEvaluationsReturnsEmptyArrayWhenNoneFound = (): voi
};
/**
* Tests that extractVariablesFromEvaluations returns each referenced variable name once,
* Tests that collectVariablesUsingParseScript returns each referenced variable name once,
* even when it appears in more than one evaluation.
*/
const testExtractVariablesFromEvaluationsDeduplicatesAcrossEvaluations = (): void => {
const evaluations = [ '$(<requestedTokenAmount> <decimalsFactor> OP_DIV)', '$(<requestedTokenAmount> <decimalsFactor> OP_MOD)' ];
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
expect(extractVariablesFromEvaluations(evaluations)).toStrictEqual([ 'requestedTokenAmount', 'decimalsFactor' ]);
const uniqueVariableNames = [
...new Set([ ...collectVariablesUsingParseScript(firstEvaluation), ...collectVariablesUsingParseScript(secondEvaluation) ]),
];
expect(uniqueVariableNames).toStrictEqual([ 'requestedTokenAmount', 'decimalsFactor' ]);
};
/**
* Tests that extractVariablesFromEvaluations excludes numeric and quoted-string literal tokens,
* Tests that collectVariablesUsingParseScript excludes numeric and quoted string literals,
* returning only the true variable reference.
*/
const testExtractVariablesFromEvaluationsExcludesLiteralTokens = (): void => {
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(extractVariablesFromEvaluations([ evaluation ])).toStrictEqual([ 'tokenCapability' ]);
expect(collectVariablesUsingParseScript(evaluation)).toStrictEqual([ 'tokenCapability' ]);
};
/**
@@ -209,8 +229,8 @@ const testCompileCashAssemblyStringCompilesMultipleEvaluationsInOneString = ():
const testCompileCashAssemblyStringReusesTheSameVariableTwiceInOneEvaluation = (): void => {
const evaluation = '$(<amount> <amount> OP_ADD)';
// The name appears twice in the script body but is required only once in the variables map.
expect(extractVariablesFromEvaluations([ evaluation ])).toStrictEqual([ 'amount' ]);
// 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,
@@ -244,6 +264,355 @@ const testCompileCashAssemblyStringEvaluatesConditionalLiterals = (): void => {
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.
*/
@@ -488,7 +857,7 @@ const testCompileCashAssemblyStringHexCompilesP2sLockHashFromMixedLockAndDisplay
expect(typeof publicKeyResult).not.toBe('string');
const publicKeyBytes = publicKeyResult as Uint8Array;
// Minimal P2S redeem body. Push the compressed public key, then OP_CHECKSIG.
// Minimal P2S redeem source. Push the compressed public key, then OP_CHECKSIG.
const redeemScript = Uint8Array.from([ ...encodeDataPush(publicKeyBytes), 0xac ]);
const expectedRedeemHashHex = binToHex(hash256(redeemScript));
@@ -498,7 +867,10 @@ const testCompileCashAssemblyStringHexCompilesP2sLockHashFromMixedLockAndDisplay
const evaluations = extractCashAssemblyEvaluations(cashAssemblyText);
expect(evaluations).toStrictEqual([ '$(<redeemScript> OP_HASH256)', '$(<expiry.toIso8601>)', '$(<amount.toBCH>)', '$(<amount.toString>)' ]);
expect(extractVariablesFromEvaluations(evaluations)).toStrictEqual([ 'redeemScript', '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],
@@ -529,7 +901,7 @@ const testP2sh32RedeemWithPrimitiveAddAndSchnorrSignatureSpendsSuccessfully = ()
// Resolve primitive method pushes so CashAssembly receives VM number bytes under the full identifiers.
const primitiveMethodBytes = resolvePrimitiveMethodBytes({
identifiers: [ 'price.toSatoshis', 'fee.toSatoshis' ],
variableNames: [ 'price.toSatoshis', 'fee.toSatoshis' ],
variables: {
price: priceSatoshis,
fee: feeSatoshis,
@@ -579,7 +951,7 @@ const testP2sh32RedeemWithPrimitiveAddAndSchnorrSignatureSpendsSuccessfully = ()
// 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 body used as the covered bytecode for P2SH.
// Unlock compiler signs against the same redeem source used as the covered bytecode for P2SH.
const unlockCompiler = createCompilerBch({
scripts: {
lock: redeemScriptSource,
@@ -677,9 +1049,15 @@ const testP2sh32RedeemWithPrimitiveAddAndSchnorrSignatureSpendsSuccessfully = ()
*/
const testGenerateCashAssemblyBytecodeProducesRawBytesForVariable = (): void => {
const evaluation = '$(<inputValue>)';
const compiler = compileCashAssemblyEvaluations([ evaluation ]);
const bytecode = generateCashAssemblyBytecode(compiler, evaluation, { inputValue: new Uint8Array([ 4 ]) });
// 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 ]));
};
@@ -689,7 +1067,13 @@ const testGenerateCashAssemblyBytecodeProducesRawBytesForVariable = (): void =>
*/
const testGenerateCashAssemblyBytecodeThrowsForNonUint8ArrayVariable = (): void => {
const evaluation = '$(<inputValue>)';
const compiler = compileCashAssemblyEvaluations([ evaluation ]);
// 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(
@@ -697,6 +1081,7 @@ const testGenerateCashAssemblyBytecodeThrowsForNonUint8ArrayVariable = (): void
evaluation,
// @ts-expect-error intentional non Uint8Array input to exercise the runtime guard
{ inputValue: 4 },
variableNames,
);
expect(generateWithTypeMismatch).toThrow(CashAssemblyVariableTypeMismatchError);
@@ -710,23 +1095,340 @@ const testGenerateCashAssemblyBytecodeThrowsForNonUint8ArrayVariable = (): void
*/
const testGenerateCashAssemblyBytecodeThrowsForUnresolvedIdentifier = (): void => {
const evaluation = '$(<inputValue> unresolvedIdentifier)';
const compiler = compileCashAssemblyEvaluations([ evaluation ]);
// 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);
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(
@@ -734,8 +1436,8 @@ const runTests = async (): Promise<void> => {
testExtractCashAssemblyEvaluationsReturnsEmptyArrayWhenNoneFound,
);
test('extractVariablesFromEvaluations: deduplicates across evaluations', testExtractVariablesFromEvaluationsDeduplicatesAcrossEvaluations);
test('extractVariablesFromEvaluations: excludes literal tokens', testExtractVariablesFromEvaluationsExcludesLiteralTokens);
test('collectVariablesUsingParseScript: deduplicates across evaluations', testCollectVariablesUsingParseScriptDeduplicatesAcrossEvaluations);
test('collectVariablesUsingParseScript: excludes literals', testCollectVariablesUsingParseScriptExcludesLiterals);
test('decodeCompiledCashAssemblyEvaluation: decodes bigint', testDecodeCompiledCashAssemblyEvaluationDecodesBigint);
test(
@@ -757,6 +1459,38 @@ const runTests = async (): Promise<void> => {
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);
@@ -802,6 +1536,67 @@ const runTests = async (): Promise<void> => {
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();