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('$()')).toBe(true); }; /** * Tests that isCashAssemblyExpression rejects a string that contains an evaluation plus other text. */ const testIsCashAssemblyExpressionRejectsSurroundingText = (): void => { expect(isCashAssemblyExpression('Received $()')).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('$(')).toBe(false); }; /** * Tests that extractCashAssemblyEvaluations finds a single evaluation embedded in a larger string. */ const testExtractCashAssemblyEvaluationsFindsSingleEvaluation = (): void => { const satoshisDescription = 'Received $() satoshis from sender.'; // Only the evaluation substring is returned, not the surrounding text expect(extractCashAssemblyEvaluations(satoshisDescription)).toStrictEqual([ '$()' ]); }; /** * 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 = '$( OP_DIV)'; const secondEvaluation = '$( 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 = '$( <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: '$()', 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 $( OP_DIV).$( OP_MOD) tokens.'; expect(extractCashAssemblyEvaluations(tokenDescription)).toStrictEqual([ '$( OP_DIV)', '$( 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 = '$( 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 = '$( <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: ' 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: '', unused: '', }; 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: '', feePush: '', withFee: ' feePush OP_ADD', changePush: '', minusChange: ' changePush OP_SUB', unusedLock: '', }; 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: '', nestedB: ' 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: '$()', 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: '$()', 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: '$( 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: '$()', 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: '', }; /** * 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: ' 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: ' ', }, 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: 'OP_DUP OP_HASH160 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: ' <2> OP_MUL 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: '$(