Merge branch 'development' into sse-and-backoff
This commit is contained in:
@@ -0,0 +1,813 @@
|
||||
import { expect, test } from 'vitest';
|
||||
import {
|
||||
bigIntToVmNumber,
|
||||
binToHex,
|
||||
createCompilerBch,
|
||||
createVirtualMachineBch,
|
||||
encodeDataPush,
|
||||
generatePrivateKey,
|
||||
hash256,
|
||||
secp256k1,
|
||||
utf8ToBin,
|
||||
} from '@bitauth/libauth';
|
||||
import { XOTemplateBaseTypes, XOTemplatePrimitiveTypes } from '@xo-cash/types';
|
||||
import {
|
||||
isCashAssemblyExpression,
|
||||
extractCashAssemblyEvaluations,
|
||||
extractVariablesFromEvaluations,
|
||||
decodeCompiledCashAssemblyEvaluation,
|
||||
compileCashAssemblyString,
|
||||
generateCashAssemblyBytecode,
|
||||
compileCashAssemblyEvaluations,
|
||||
resolvePrimitiveMethodBytes,
|
||||
} from '../source/cash-assembly/index.ts';
|
||||
import {
|
||||
CashAssemblyRequiredVariableMissingError,
|
||||
CashAssemblyCompilationFailedError,
|
||||
CashAssemblyVariableTypeMismatchError,
|
||||
CashAssemblyPrimitiveMethodMissingError,
|
||||
CashAssemblyPrimitiveVariableMissingError,
|
||||
CashAssemblyVmNumberDecodeError,
|
||||
CashAssemblyNumberNotSafeIntegerError,
|
||||
} 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 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 extractVariablesFromEvaluations 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)' ];
|
||||
|
||||
// requestedTokenAmount and decimalsFactor each appear in both evaluations but are listed once
|
||||
expect(extractVariablesFromEvaluations(evaluations)).toStrictEqual([ 'requestedTokenAmount', 'decimalsFactor' ]);
|
||||
};
|
||||
|
||||
/**
|
||||
* Tests that extractVariablesFromEvaluations excludes numeric and quoted-string literal tokens,
|
||||
* returning only the true variable reference.
|
||||
*/
|
||||
const testExtractVariablesFromEvaluationsExcludesLiteralTokens = (): 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' ]);
|
||||
};
|
||||
|
||||
/**
|
||||
* 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 body but is required only once in the variables map.
|
||||
expect(extractVariablesFromEvaluations([ 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 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 an unsupported or missing hint leaves the evaluation for CashAssembly instead of throwing.
|
||||
*/
|
||||
const testCompileCashAssemblyStringSkipsWhenHintIsNotASupportedPrimitive = (): void => {
|
||||
const compileWithNonPrimitiveHint = (): string =>
|
||||
compileCashAssemblyString({
|
||||
cashAssemblyText: '$(<label.toIso8601>)',
|
||||
variables: { label: 'hello' },
|
||||
templateVariables: {
|
||||
label: {
|
||||
name: 'Label',
|
||||
description: 'A text label',
|
||||
type: XOTemplateBaseTypes.STRING,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(compileWithNonPrimitiveHint).toThrow(CashAssemblyRequiredVariableMissingError);
|
||||
expect(compileWithNonPrimitiveHint).toThrow('Missing required variable: variableNames [label.toIso8601]');
|
||||
};
|
||||
|
||||
/**
|
||||
* Tests that a supported hint with an unknown method throws CashAssemblyPrimitiveMethodMissingError.
|
||||
*
|
||||
* Once the hint maps to a supported primitive class, 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: XOTemplateBaseTypes.INTEGER,
|
||||
hint: XOTemplatePrimitiveTypes.TIMESTAMP,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(compileWithUnknownMethod).toThrow(CashAssemblyPrimitiveMethodMissingError);
|
||||
expect(compileWithUnknownMethod).toThrow('CashAssembly primitive method does not exist: identifier "expiry.notARealMethod", methodName "notARealMethod", hint "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: XOTemplateBaseTypes.INTEGER,
|
||||
hint: 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: XOTemplateBaseTypes.BIGINT,
|
||||
hint: XOTemplatePrimitiveTypes.SATOSHIS,
|
||||
},
|
||||
fee: {
|
||||
name: 'Fee',
|
||||
description: 'Network fee in satoshis',
|
||||
type: XOTemplateBaseTypes.BIGINT,
|
||||
hint: 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 body. 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>)' ]);
|
||||
expect(extractVariablesFromEvaluations(evaluations)).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({
|
||||
identifiers: [ 'price.toSatoshis', 'fee.toSatoshis' ],
|
||||
variables: {
|
||||
price: priceSatoshis,
|
||||
fee: feeSatoshis,
|
||||
},
|
||||
templateVariables: {
|
||||
price: {
|
||||
name: 'Price',
|
||||
description: 'Base payment in satoshis',
|
||||
type: XOTemplateBaseTypes.BIGINT,
|
||||
hint: XOTemplatePrimitiveTypes.SATOSHIS,
|
||||
},
|
||||
fee: {
|
||||
name: 'Fee',
|
||||
description: 'Network fee in satoshis',
|
||||
type: XOTemplateBaseTypes.BIGINT,
|
||||
hint: 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 body 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>)';
|
||||
const compiler = compileCashAssemblyEvaluations([ evaluation ]);
|
||||
|
||||
const bytecode = generateCashAssemblyBytecode(compiler, evaluation, { inputValue: new Uint8Array([ 4 ]) });
|
||||
|
||||
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>)';
|
||||
const compiler = compileCashAssemblyEvaluations([ evaluation ]);
|
||||
|
||||
const generateWithTypeMismatch = (): Uint8Array =>
|
||||
generateCashAssemblyBytecode(
|
||||
compiler,
|
||||
evaluation,
|
||||
// @ts-expect-error intentional non Uint8Array input to exercise the runtime guard
|
||||
{ inputValue: 4 },
|
||||
);
|
||||
|
||||
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)';
|
||||
const compiler = compileCashAssemblyEvaluations([ evaluation ]);
|
||||
|
||||
const variables = { inputValue: new Uint8Array([ 1 ]) };
|
||||
|
||||
const generateWithUnresolvedIdentifier = (): Uint8Array => generateCashAssemblyBytecode(compiler, evaluation, variables);
|
||||
|
||||
expect(generateWithUnresolvedIdentifier).toThrow(CashAssemblyCompilationFailedError);
|
||||
|
||||
expect(generateWithUnresolvedIdentifier).toThrow('Cash assembly compilation failed: Unknown identifier "unresolvedIdentifier".');
|
||||
};
|
||||
|
||||
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('extractCashAssemblyEvaluations: finds a single evaluation', testExtractCashAssemblyEvaluationsFindsSingleEvaluation);
|
||||
test(
|
||||
'extractCashAssemblyEvaluations: returns an empty array when none are found',
|
||||
testExtractCashAssemblyEvaluationsReturnsEmptyArrayWhenNoneFound,
|
||||
);
|
||||
|
||||
test('extractVariablesFromEvaluations: deduplicates across evaluations', testExtractVariablesFromEvaluationsDeduplicatesAcrossEvaluations);
|
||||
test('extractVariablesFromEvaluations: excludes literal tokens', testExtractVariablesFromEvaluationsExcludesLiteralTokens);
|
||||
|
||||
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: 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 hint is not a supported primitive',
|
||||
testCompileCashAssemblyStringSkipsWhenHintIsNotASupportedPrimitive,
|
||||
);
|
||||
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);
|
||||
};
|
||||
|
||||
await runTests();
|
||||
@@ -99,7 +99,7 @@ const testPushComposedRejectsMultipleConsumers = async (): Promise<void> => {
|
||||
/* eslint-disable-next-line */
|
||||
for await (const _value of iterator) {
|
||||
}
|
||||
} catch (error) {
|
||||
} catch {
|
||||
failureFlag();
|
||||
}
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user