Merge branch 'feat/cash-assembly-reimplement' into 'development'
Change implementation strategy of cashassembly, add scripts, comments support and tests See merge request GeneralProtocols/xo/utils!19
This commit is contained in:
+18
-1
@@ -1,6 +1,23 @@
|
||||
{
|
||||
"version": "0.1",
|
||||
"import": ["@generalprotocols/cspell-dictionary/cspell.json"],
|
||||
"words": ["nonfungible", "lockscript", "cashassembly", "checksigverify", "inputindex", "utxovalue", "bivariance"],
|
||||
"words": [
|
||||
"nonfungible",
|
||||
"lockscript",
|
||||
"cashassembly",
|
||||
"checksigverify",
|
||||
"inputindex",
|
||||
"utxovalue",
|
||||
"bivariance",
|
||||
"bytecodes",
|
||||
"outputbytecode",
|
||||
"utxobytecode",
|
||||
"outputtokencategory",
|
||||
"utxotokencategory",
|
||||
"utxotokenamount",
|
||||
"outputtokenamount",
|
||||
"outputvalue",
|
||||
"numequal"
|
||||
],
|
||||
"ignorePaths": ["source/template/xo-template.schema.json"]
|
||||
}
|
||||
|
||||
Generated
+47
-47
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "@xo-cash/utils",
|
||||
"version": "0.0.6",
|
||||
"version": "0.0.7",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "@xo-cash/utils",
|
||||
"version": "0.0.6",
|
||||
"version": "0.0.7",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@bitauth/libauth": "^3.1.0-next.8",
|
||||
@@ -532,16 +532,16 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@cspell/dict-companies": {
|
||||
"version": "3.2.12",
|
||||
"resolved": "https://registry.npmjs.org/@cspell/dict-companies/-/dict-companies-3.2.12.tgz",
|
||||
"integrity": "sha512-mjiz/N3zWOCsz5VfwMUydSl7uW0OU9H2PnbCNc3RV44Vj6Q59CSp6EYGSGZQxrXU1gpsuZUrwr6QCjNjFOOg5A==",
|
||||
"version": "3.2.13",
|
||||
"resolved": "https://registry.npmjs.org/@cspell/dict-companies/-/dict-companies-3.2.13.tgz",
|
||||
"integrity": "sha512-mBJeYzCkiiWgNQxKg7zyQOpFPwcXO4IAzXGbOEFfcQ4Q0G5Fa7Kqd8z+gvnvSK7NPWKJaFmiApC6I0cka02qmQ==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@cspell/dict-cpp": {
|
||||
"version": "7.1.0",
|
||||
"resolved": "https://registry.npmjs.org/@cspell/dict-cpp/-/dict-cpp-7.1.0.tgz",
|
||||
"integrity": "sha512-rcjycobioQUd9Jm/Y1n8U+sq6ytpZ0iV8AYIo+vyEFfutC5x7g6hL6Fh3bCdh6IporGHRqfEutGK/4sfopD2ZA==",
|
||||
"version": "7.1.1",
|
||||
"resolved": "https://registry.npmjs.org/@cspell/dict-cpp/-/dict-cpp-7.1.1.tgz",
|
||||
"integrity": "sha512-VCGYOU9Ni7vsNp2DPlU79RqN5y2EWAkKzVox/32Wm6Qa8eIgwyQLT6oV66F+qYigPbQUrd1C8uKa4aAzcjfl1g==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
@@ -595,9 +595,9 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@cspell/dict-dotnet": {
|
||||
"version": "5.0.13",
|
||||
"resolved": "https://registry.npmjs.org/@cspell/dict-dotnet/-/dict-dotnet-5.0.13.tgz",
|
||||
"integrity": "sha512-xPp7jMnFpOri7tzmqmm/dXMolXz1t2bhNqxYkOyMqXhvs08oc7BFs+EsbDY0X7hqiISgeFZGNqn0dOCr+ncPYw==",
|
||||
"version": "5.0.14",
|
||||
"resolved": "https://registry.npmjs.org/@cspell/dict-dotnet/-/dict-dotnet-5.0.14.tgz",
|
||||
"integrity": "sha512-gu2cZTlUA6A23yZdRvMnXOPmVpRjF0Xef0O353xrvtlStc/czZzIz546R4zV7F3duehRlkglBWcxJ52sYxTbsg==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
@@ -609,23 +609,23 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@cspell/dict-en_us": {
|
||||
"version": "4.4.37",
|
||||
"resolved": "https://registry.npmjs.org/@cspell/dict-en_us/-/dict-en_us-4.4.37.tgz",
|
||||
"integrity": "sha512-rMKyPrk2SKmDU+Bj0U0ixsv4Du93oijwovc8XlUin0zwKOJZb0/PFG5Df4P8CrM2CrLeovpaFvhoI98DigxBLw==",
|
||||
"version": "4.4.38",
|
||||
"resolved": "https://registry.npmjs.org/@cspell/dict-en_us/-/dict-en_us-4.4.38.tgz",
|
||||
"integrity": "sha512-6AmHc3BV/paM9M2TKo/Spophp3CKXgR3Un9OekwUpfVLxVRcYd4EOZGexiVK8e7ugPNzfBFGXjO8bIr6pvW3Ow==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@cspell/dict-en-common-misspellings": {
|
||||
"version": "2.2.0",
|
||||
"resolved": "https://registry.npmjs.org/@cspell/dict-en-common-misspellings/-/dict-en-common-misspellings-2.2.0.tgz",
|
||||
"integrity": "sha512-5PmCHv+AhY0LVNo3bE1FdRKMmw1esKj83GPwLymnVPYNtlI2Jf6y27EjC0azg4zny5keWmku0GQiMf55Fi+qeA==",
|
||||
"version": "2.2.1",
|
||||
"resolved": "https://registry.npmjs.org/@cspell/dict-en-common-misspellings/-/dict-en-common-misspellings-2.2.1.tgz",
|
||||
"integrity": "sha512-9pql9P+cfne3+UiR5IUwyLdlHShIckgo9o2F/HhkMIE9LMb8/15JHytufqJv76E2XBH1aW/AHUSRNwom5w9eDA==",
|
||||
"dev": true,
|
||||
"license": "CC BY-SA 4.0"
|
||||
},
|
||||
"node_modules/@cspell/dict-en-gb-mit": {
|
||||
"version": "3.1.26",
|
||||
"resolved": "https://registry.npmjs.org/@cspell/dict-en-gb-mit/-/dict-en-gb-mit-3.1.26.tgz",
|
||||
"integrity": "sha512-F9Y/45+1oIPzfL/pyWLp4kzX+vddHPAYpIpIF+45bdc65AebeEiQHKzSmKgGGTd3qbQc3fATw2kAFdLryiQpjQ==",
|
||||
"version": "3.1.27",
|
||||
"resolved": "https://registry.npmjs.org/@cspell/dict-en-gb-mit/-/dict-en-gb-mit-3.1.27.tgz",
|
||||
"integrity": "sha512-vJjW5uQHok7nDoINLRk9G970CpQG8PIEysXambN8txaozx/Y+HZZeo1otI/waaBaAntWI/0YWfc0joTKrCQLPg==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
@@ -679,9 +679,9 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@cspell/dict-golang": {
|
||||
"version": "6.0.27",
|
||||
"resolved": "https://registry.npmjs.org/@cspell/dict-golang/-/dict-golang-6.0.27.tgz",
|
||||
"integrity": "sha512-rRDb2JL8EefaezHGTEclKXCs4eMOS0q/datk94Lm7KQOhlGlzS9FDVZiR1/guh0o+iJCmejQuf5NR2FQeQSWsg==",
|
||||
"version": "6.0.28",
|
||||
"resolved": "https://registry.npmjs.org/@cspell/dict-golang/-/dict-golang-6.0.28.tgz",
|
||||
"integrity": "sha512-SlKnNJvdjKS+jqfvLavz7zFri69Nwi6OQTW9NGSoShWaG6ZTSGYByUNKKE393EHFT1nSYWgcD7rdHDKO68F8DQ==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
@@ -783,9 +783,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@cspell/dict-monkeyc": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/@cspell/dict-monkeyc/-/dict-monkeyc-1.1.0.tgz",
|
||||
"integrity": "sha512-mc/hgvSy/emOIYtc8kuVEaLUlnaERunfAubfJ5plUXq6s0A69bcukJzUzSt9C0UTCwS28641ILRPv80m3L9QbA==",
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/@cspell/dict-monkeyc/-/dict-monkeyc-1.1.1.tgz",
|
||||
"integrity": "sha512-AJDsxnFCMDuteMyqWKGy+CkFl9gCKj8QRqTScO3QwjUs8UG+msHJDUGeJwSvzwG35w6RGD49ev8DVcgkJv/QDQ==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
@@ -797,23 +797,23 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@cspell/dict-npm": {
|
||||
"version": "5.2.48",
|
||||
"resolved": "https://registry.npmjs.org/@cspell/dict-npm/-/dict-npm-5.2.48.tgz",
|
||||
"integrity": "sha512-R5dDnXTxZFOdb/4XSugCjsb3gXTHcEURGFAVOThg5QVu2YRI+yYj5vGgj9gSNffbgoQsNnaukPoB5v1tjF+tRA==",
|
||||
"version": "5.2.49",
|
||||
"resolved": "https://registry.npmjs.org/@cspell/dict-npm/-/dict-npm-5.2.49.tgz",
|
||||
"integrity": "sha512-7NoDyLcuzQKVA7pNt5vdtqpok9XJyYPxeL7FGzUTbYWi7SuTCk3QbFhoMiYoHSCc6C5G+NCI9xxWHMZnDflXlA==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@cspell/dict-php": {
|
||||
"version": "4.1.1",
|
||||
"resolved": "https://registry.npmjs.org/@cspell/dict-php/-/dict-php-4.1.1.tgz",
|
||||
"integrity": "sha512-EXelI+4AftmdIGtA8HL8kr4WlUE11OqCSVlnIgZekmTkEGSZdYnkFdiJ5IANSALtlQ1mghKjz+OFqVs6yowgWA==",
|
||||
"version": "4.1.2",
|
||||
"resolved": "https://registry.npmjs.org/@cspell/dict-php/-/dict-php-4.1.2.tgz",
|
||||
"integrity": "sha512-XjPOglaEG2sOyImpVtnmAAsXv7saJevoP5FBGT+JyiS9UclIpw01CkiZU0I0JJ08pdXh51SiPXxx0yzw/q2jdw==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@cspell/dict-powershell": {
|
||||
"version": "5.0.15",
|
||||
"resolved": "https://registry.npmjs.org/@cspell/dict-powershell/-/dict-powershell-5.0.15.tgz",
|
||||
"integrity": "sha512-l4S5PAcvCFcVDMJShrYD0X6Huv9dcsQPlsVsBGbH38wvuN7gS7+GxZFAjTNxDmTY1wrNi1cCatSg6Pu2BW4rgg==",
|
||||
"version": "5.0.16",
|
||||
"resolved": "https://registry.npmjs.org/@cspell/dict-powershell/-/dict-powershell-5.0.16.tgz",
|
||||
"integrity": "sha512-tDaC+lHpI9hjedyQ6foJWiM8b4a5FAqXJerYvH7q7lhOgFNaK+BYtvmG85jQnDLg0cgLZzSSVixauTvQ6+nf6w==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
@@ -825,9 +825,9 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@cspell/dict-python": {
|
||||
"version": "4.5.0",
|
||||
"resolved": "https://registry.npmjs.org/@cspell/dict-python/-/dict-python-4.5.0.tgz",
|
||||
"integrity": "sha512-O1lSuFXoX8p+4hvJMbqvg3blbn0Zx8LHqdPQSNaOxFZGf7hmq6pc8sfta+98E7Q/WtDooAnU3XpBxBXcn0r94g==",
|
||||
"version": "4.5.1",
|
||||
"resolved": "https://registry.npmjs.org/@cspell/dict-python/-/dict-python-4.5.1.tgz",
|
||||
"integrity": "sha512-rWMFJrmlSyMcGuIjB3/VHEj2gN9ou/AR/vyk3224w0TzMqDtjR+PtGM/NUo1Xd4APutUX4O95EpPz+0E7WO8Zg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
@@ -842,9 +842,9 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@cspell/dict-ruby": {
|
||||
"version": "5.1.2",
|
||||
"resolved": "https://registry.npmjs.org/@cspell/dict-ruby/-/dict-ruby-5.1.2.tgz",
|
||||
"integrity": "sha512-f6Slf11N91ittf71AWlfVVG9GZPezVRBfcMPCTNTWhUsY/VEspkBRZYDhPXjV4df3jqHy5YJs8nlsFcFVF/bMA==",
|
||||
"version": "5.1.3",
|
||||
"resolved": "https://registry.npmjs.org/@cspell/dict-ruby/-/dict-ruby-5.1.3.tgz",
|
||||
"integrity": "sha512-XRc8xSKkn0mx8ES5TxbahG0qSiCGB71JzkgO9J1jWfOrNDTlDJ04glosWEQ5vuwweVXnT0H1q44/A66BZebH4w==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
@@ -856,9 +856,9 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@cspell/dict-scala": {
|
||||
"version": "5.0.9",
|
||||
"resolved": "https://registry.npmjs.org/@cspell/dict-scala/-/dict-scala-5.0.9.tgz",
|
||||
"integrity": "sha512-AjVcVAELgllybr1zk93CJ5wSUNu/Zb5kIubymR/GAYkMyBdYFCZ3Zbwn4Zz8GJlFFAbazABGOu0JPVbeY59vGg==",
|
||||
"version": "5.0.10",
|
||||
"resolved": "https://registry.npmjs.org/@cspell/dict-scala/-/dict-scala-5.0.10.tgz",
|
||||
"integrity": "sha512-OOmw2zt1N0ZmOj3zBIzF+qdk0SDXwaXpFtPa0yWvA8EsghnN1tEpHOMYMVFgGipAkBS1uhPbi00Gj6YkcW4cKA==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
@@ -870,9 +870,9 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@cspell/dict-software-terms": {
|
||||
"version": "5.4.2",
|
||||
"resolved": "https://registry.npmjs.org/@cspell/dict-software-terms/-/dict-software-terms-5.4.2.tgz",
|
||||
"integrity": "sha512-C2tS1JjxqHef9TpPRm08a6n+PJCrKrD6FsmPEyXb2J6TFef4FFnKRcGOf3pFnd+a6/oyQgXZWeKiECH6lBFjQA==",
|
||||
"version": "5.4.3",
|
||||
"resolved": "https://registry.npmjs.org/@cspell/dict-software-terms/-/dict-software-terms-5.4.3.tgz",
|
||||
"integrity": "sha512-f5vD6tNKIt0S6z+eJzTJTlNqefTnNakN9frl5eBvitHOzY5joWDaYTA77dtQ8krh9YMqob03NoQqpjwh64imag==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
|
||||
+6
-3
@@ -1,14 +1,17 @@
|
||||
{
|
||||
"name": "@xo-cash/utils",
|
||||
"version": "0.0.6",
|
||||
"version": "0.0.7",
|
||||
"description": "XO Cash utilities",
|
||||
"type": "module",
|
||||
"types": "./dist/index.d.mts",
|
||||
"source": "./source/index.ts",
|
||||
"module": "./dist/index.mjs",
|
||||
"exports": {
|
||||
"types": "./dist/index.d.mts",
|
||||
"default": "./dist/index.mjs"
|
||||
".": {
|
||||
"types": "./dist/index.d.mts",
|
||||
"import": "./dist/index.mjs",
|
||||
"default": "./dist/index.mjs"
|
||||
}
|
||||
},
|
||||
"publishConfig": {
|
||||
"access": "public"
|
||||
|
||||
@@ -6,9 +6,9 @@ import { CashAssemblyNumberNotSafeIntegerError, CashAssemblyUnsupportedValueType
|
||||
*
|
||||
* @param {unknown} value - Value to encode, should be one of: Uint8Array, bigint, boolean, string, or a safe integer number.
|
||||
* @param {string} valueIdentifier - Identifier used in error messages.
|
||||
* @returns {Uint8Array} Bytes representation of the value.
|
||||
* @throws {@link CashAssemblyNumberNotSafeIntegerError} When a number is not a safe integer.
|
||||
* @throws {@link CashAssemblyUnsupportedValueTypeError} When the value type cannot be resolved.
|
||||
* @returns {Uint8Array} - Bytes representation of the value.
|
||||
* @throws {@link CashAssemblyNumberNotSafeIntegerError} - When a number is not a safe integer.
|
||||
* @throws {@link CashAssemblyUnsupportedValueTypeError} - When the value type cannot be resolved.
|
||||
*/
|
||||
export const convertValueToBytes = (value: unknown, valueIdentifier: string): Uint8Array => {
|
||||
if (value instanceof Uint8Array) {
|
||||
|
||||
@@ -0,0 +1,257 @@
|
||||
import { describeExpectedInput, parseScript } from '@bitauth/libauth';
|
||||
import type { CashAssemblyScriptSegment } from '@bitauth/libauth';
|
||||
import { CashAssemblyCompilationFailedError } from './errors.ts';
|
||||
import type { CashAssemblyTemplateScripts } from './types.ts';
|
||||
|
||||
/**
|
||||
* Shared state while scanning a parse tree and visiting nested template scripts.
|
||||
*/
|
||||
type CollectFromParseTreeContext = {
|
||||
|
||||
/**
|
||||
* Script ids present in the template scripts map.
|
||||
*/
|
||||
knownScriptIdentifiers: ReadonlySet<string>;
|
||||
|
||||
/**
|
||||
* Script ids whose sources were already visited.
|
||||
*/
|
||||
visitedScriptIdentifiers: ReadonlySet<string>;
|
||||
|
||||
/**
|
||||
* Pending script ids queued to visit.
|
||||
*/
|
||||
scriptIdentifiersToVisit: string[];
|
||||
|
||||
/**
|
||||
* WalletData variable names.
|
||||
*/
|
||||
variableNames: Set<string>;
|
||||
};
|
||||
|
||||
/**
|
||||
* Parameters for scanning one parsed Script node.
|
||||
*/
|
||||
type CollectFromParsedScriptParameters = CollectFromParseTreeContext & {
|
||||
|
||||
/**
|
||||
* Parsed Script whose children are Push, Evaluation, Identifier, or literals.
|
||||
*/
|
||||
scriptSegment: CashAssemblyScriptSegment;
|
||||
|
||||
/**
|
||||
* True when this Script is the value of a Push.
|
||||
* Direct Identifier children are WalletData unless they are template script ids.
|
||||
*/
|
||||
isDirectPushContent: boolean;
|
||||
};
|
||||
|
||||
/**
|
||||
* Parameters for handling one Identifier node.
|
||||
*/
|
||||
type CollectFromParsedIdentifierParameters = CollectFromParseTreeContext & {
|
||||
|
||||
/**
|
||||
* Identifier text from parseScript, which may include `.` and `_`.
|
||||
*/
|
||||
identifier: string;
|
||||
|
||||
/**
|
||||
* True when this Identifier is a direct child of a Push Script.
|
||||
*/
|
||||
isDirectPushContent: boolean;
|
||||
};
|
||||
|
||||
/**
|
||||
* Enqueues a template script id so its source can be visited when it exists in the scripts map.
|
||||
*
|
||||
* @param {string} scriptIdentifier - Script id from an evaluation or nested script source.
|
||||
* @param {ReadonlySet<string>} knownScriptIdentifiers - Script ids present in the template scripts map.
|
||||
* @param {ReadonlySet<string>} visitedScriptIdentifiers - Script ids whose sources were already visited.
|
||||
* @param {string[]} scriptIdentifiersToVisit - Pending script ids queued to visit.
|
||||
*/
|
||||
const enqueueReachableTemplateScriptIdentifier = (
|
||||
scriptIdentifier: string,
|
||||
knownScriptIdentifiers: ReadonlySet<string>,
|
||||
visitedScriptIdentifiers: ReadonlySet<string>,
|
||||
scriptIdentifiersToVisit: string[],
|
||||
): void => {
|
||||
// Return when this id is not a template script.
|
||||
if (knownScriptIdentifiers.has(scriptIdentifier) === false) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Skip ids whose sources were already visited.
|
||||
if (visitedScriptIdentifiers.has(scriptIdentifier) === true) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Avoid duplicate queue entries when the same id is referenced more than once.
|
||||
if (scriptIdentifiersToVisit.includes(scriptIdentifier) === true) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Queue this id so its source is parsed after the current scan finishes.
|
||||
scriptIdentifiersToVisit.push(scriptIdentifier);
|
||||
};
|
||||
|
||||
/**
|
||||
* Parses CashAssembly source with parseScript and throws when the parse fails.
|
||||
*
|
||||
* @param {string} cashAssemblyText - Evaluation or script source to parse.
|
||||
* @returns {CashAssemblyScriptSegment} - Parsed Script root.
|
||||
* @throws {@link CashAssemblyCompilationFailedError} - When parseScript rejects the source.
|
||||
*/
|
||||
const parseCashAssemblySource = (cashAssemblyText: string): CashAssemblyScriptSegment => {
|
||||
// Parse so Push and Identifier nodes are available for collection.
|
||||
const parseResult = parseScript(cashAssemblyText);
|
||||
|
||||
// parseScript returns a union. status false is the failure arm and must not be read as a Script.
|
||||
if (parseResult.status === false) {
|
||||
// describeExpectedInput turns the expected input list into readable text for the typed error.
|
||||
const expectedInputDescription = describeExpectedInput(parseResult.expected);
|
||||
|
||||
throw new CashAssemblyCompilationFailedError(`${expectedInputDescription} Line ${String(parseResult.index.line)}, column ${String(parseResult.index.column)}.`);
|
||||
}
|
||||
|
||||
return parseResult.value;
|
||||
};
|
||||
|
||||
/**
|
||||
* Identifier visitor for the parseScript tree.
|
||||
* Records WalletData or queues a nested script from one Identifier node.
|
||||
*
|
||||
* @param {CollectFromParsedIdentifierParameters} parameters - Identifier text and the parse tree context.
|
||||
*/
|
||||
const collectFromParsedIdentifier = (parameters: CollectFromParsedIdentifierParameters): void => {
|
||||
const { identifier, isDirectPushContent, knownScriptIdentifiers, visitedScriptIdentifiers, scriptIdentifiersToVisit, variableNames } = parameters;
|
||||
|
||||
// Script ids are visited whether they appear in a Push or beside opcodes.
|
||||
if (knownScriptIdentifiers.has(identifier) === true) {
|
||||
enqueueReachableTemplateScriptIdentifier(identifier, knownScriptIdentifiers, visitedScriptIdentifiers, scriptIdentifiersToVisit);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
// Direct Push names that are not script ids are WalletData, including dotted names.
|
||||
if (isDirectPushContent === true) {
|
||||
variableNames.add(identifier);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Visitor for one Script node in the parseScript tree.
|
||||
* Recurses into Push and Evaluation children and dispatches Identifier nodes.
|
||||
*
|
||||
* @param {CollectFromParsedScriptParameters} parameters - Parsed Script, Push depth flag, and parse tree context.
|
||||
*/
|
||||
const collectFromParsedScript = (parameters: CollectFromParsedScriptParameters): void => {
|
||||
const { scriptSegment, isDirectPushContent, knownScriptIdentifiers, visitedScriptIdentifiers, scriptIdentifiersToVisit, variableNames } =
|
||||
parameters;
|
||||
|
||||
for (const child of scriptSegment.value) {
|
||||
if (child.name === 'Push') {
|
||||
// Inner Script of a Push is where `<name>` WalletData lives, including nested angle brackets.
|
||||
collectFromParsedScript({
|
||||
scriptSegment: child.value,
|
||||
isDirectPushContent: true,
|
||||
knownScriptIdentifiers,
|
||||
visitedScriptIdentifiers,
|
||||
scriptIdentifiersToVisit,
|
||||
variableNames,
|
||||
});
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
if (child.name === 'Evaluation') {
|
||||
// Evaluation contents are opcodes and nested Pushes, not WalletData by themselves.
|
||||
collectFromParsedScript({
|
||||
scriptSegment: child.value,
|
||||
isDirectPushContent: false,
|
||||
knownScriptIdentifiers,
|
||||
visitedScriptIdentifiers,
|
||||
scriptIdentifiersToVisit,
|
||||
variableNames,
|
||||
});
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
if (child.name === 'Identifier') {
|
||||
collectFromParsedIdentifier({
|
||||
identifier: child.value,
|
||||
isDirectPushContent,
|
||||
knownScriptIdentifiers,
|
||||
visitedScriptIdentifiers,
|
||||
scriptIdentifiersToVisit,
|
||||
variableNames,
|
||||
});
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Collects WalletData names from the parseScript tree using the visitor pattern.
|
||||
*
|
||||
* parseScript returns a tree of Script, Push, Evaluation, and Identifier nodes. Nested Push and
|
||||
* Evaluation nodes are entered immediately. A template script id is queued whether it appears inside a Push or next to
|
||||
* opcodes. Queued script sources are visited after the current tree finishes, in the order they were
|
||||
* first seen.
|
||||
* A visited id is skipped on later encounters. Opcodes and literals are ignored. Unrelated scripts
|
||||
* in the map are not visited. An Identifier inside a nested Push is collected from that inner Push,
|
||||
* for example `<$(<ownerKey.public_key> OP_HASH160)>` collects `ownerKey.public_key`.
|
||||
*
|
||||
* @param {string} evaluation - CashAssembly evaluation or script id text to scan.
|
||||
* @param {CashAssemblyTemplateScripts} [templateScripts] - Optional template scripts map used to visit nested script ids.
|
||||
* @returns {string[]} - WalletData names from reachable Pushes. Script ids are omitted when
|
||||
* `templateScripts` is provided.
|
||||
* @throws {@link CashAssemblyCompilationFailedError} - When parseScript rejects the starting text or a visited script source.
|
||||
*/
|
||||
export const collectVariablesUsingParseScript = (evaluation: string, templateScripts: CashAssemblyTemplateScripts = {}): string[] => {
|
||||
// Create a set of the script identifiers from the template scripts map.
|
||||
const knownScriptIdentifiers = new Set(Object.keys(templateScripts));
|
||||
|
||||
const visitedScriptIdentifiers = new Set<string>();
|
||||
const scriptIdentifiersToVisit: string[] = [];
|
||||
const variableNames = new Set<string>();
|
||||
|
||||
// Share by reference context.
|
||||
const parseTreeContext: CollectFromParseTreeContext = {
|
||||
knownScriptIdentifiers,
|
||||
visitedScriptIdentifiers,
|
||||
scriptIdentifiersToVisit,
|
||||
variableNames,
|
||||
};
|
||||
|
||||
// Parse the starting text so nested Pushes are visible before visiting nested script sources.
|
||||
const startingScriptSegment = parseCashAssemblySource(evaluation);
|
||||
|
||||
// Scan the starting parse tree before visiting nested script sources.
|
||||
collectFromParsedScript({
|
||||
...parseTreeContext,
|
||||
scriptSegment: startingScriptSegment,
|
||||
isDirectPushContent: false,
|
||||
});
|
||||
|
||||
for (const scriptIdentifier of scriptIdentifiersToVisit) {
|
||||
if (visitedScriptIdentifiers.has(scriptIdentifier) === true) {
|
||||
continue;
|
||||
}
|
||||
|
||||
visitedScriptIdentifiers.add(scriptIdentifier);
|
||||
|
||||
// Get the script definition from the template scripts map.
|
||||
const scriptDefinition = templateScripts[scriptIdentifier];
|
||||
|
||||
const nestedScriptSegment = parseCashAssemblySource(scriptDefinition);
|
||||
|
||||
collectFromParsedScript({
|
||||
...parseTreeContext,
|
||||
scriptSegment: nestedScriptSegment,
|
||||
isDirectPushContent: false,
|
||||
});
|
||||
}
|
||||
|
||||
return [ ...variableNames ];
|
||||
};
|
||||
@@ -1,61 +1,41 @@
|
||||
/**
|
||||
* Detects whether a string is a pure CashAssembly expression.
|
||||
*
|
||||
* CashAssembly expressions look like `$(<variable>)` or `$(<a> <b>)`. This pattern checks
|
||||
* that the entire string is one such expression and nothing else. It will not match if
|
||||
* there is other text surrounding the expression.
|
||||
*
|
||||
* For example:
|
||||
* `$(<fee>)` matches (a full expression)
|
||||
* `OP_DUP $(<fee>)` does not match (extra text before it)
|
||||
* `$()` does not match (empty expression)
|
||||
*/
|
||||
export const CASHASSEMBLY_EXPRESSION_PATTERN = /^\$\([^)]+\)$/;
|
||||
|
||||
/**
|
||||
* Finds all CashAssembly evaluations embedded in a larger string.
|
||||
*
|
||||
* An evaluation looks like `$(...)`, for example `$(<fee>)` or `$(<a> <b>)`. This pattern
|
||||
* locates every occurrence in the input and returns them all (global flag `g`).
|
||||
* Empty evaluations `$()` are intentionally excluded because they reference no variables.
|
||||
*
|
||||
* For example, scanning `"OP_DUP <$(<pubkeyHash>)> OP_HASH160 $(<fee>)"` would return
|
||||
* `['$(<pubkeyHash>)', '$(<fee>)']`.
|
||||
*/
|
||||
export const CASHASSEMBLY_EVALUATION_PATTERN = /\$\([^)]+\)/g;
|
||||
|
||||
/**
|
||||
* Extracts variable names from angle-bracket references inside a CashAssembly evaluation.
|
||||
*
|
||||
* Inside an evaluation like `$(<pubkeyHash> <fee>)`, variables are referenced as `<name>`.
|
||||
* This pattern captures the name between the brackets. The global flag `g` allows iterating
|
||||
* over every variable reference in a single evaluation string.
|
||||
*
|
||||
* For example, running this against `$(<pubkeyHash> <fee>)` would return the variable names
|
||||
* `["pubkeyHash", "fee"]`.
|
||||
*/
|
||||
export const CASHASSEMBLY_VARIABLE_PATTERN = /<([^>]+)>/g;
|
||||
|
||||
/**
|
||||
* Identifies CashAssembly literal tokens that appear inside angle-bracket push statements.
|
||||
*
|
||||
* Inside an evaluation, not everything between `<` and `>` is a variable name. Literals are
|
||||
* also valid push contents: numeric literals (e.g. `<0>`, `<32>`), hex literals (`<0x02>`),
|
||||
* binary literals (`<0b1010>`), and string literals (`<"minting">`, `<'hello'>`). This pattern
|
||||
* matches any captured token that starts with a digit or a quote character.
|
||||
*/
|
||||
export const CASHASSEMBLY_LITERAL_TOKEN_PATTERN = /^[0-9"']/;
|
||||
|
||||
/**
|
||||
* Matches a single dot variable method reference inside an angle-bracket identifier.
|
||||
* Matches a single dot variable method reference inside an angle bracket identifier.
|
||||
*
|
||||
* Used to detect primitive method references such as `expiry.toIso8601`.
|
||||
*
|
||||
* For example:
|
||||
* `expiry.toIso8601` matches (base `expiry`, method `toIso8601`)
|
||||
* `requestedSatoshis` does not match (no method)
|
||||
* `key.schnorr_signature.all_outputs` does not match (more than one dot)
|
||||
* `key.public_key` matches the pattern shape but it is only resolved
|
||||
* when `type` maps to a primitive
|
||||
* For example `expiry.toIso8601` matches with base expiry and method toIso8601.
|
||||
* `requestedSatoshis` does not match because it has no method.
|
||||
* `key.schnorr_signature.all_outputs` does not match because it has more than one dot.
|
||||
*/
|
||||
export const CASHASSEMBLY_VARIABLE_METHOD_REFERENCE_PATTERN = /^([^.]+)\.([^.]+)$/;
|
||||
|
||||
/**
|
||||
* Character count of the `$(` evaluation opener, which is two characters long.
|
||||
*
|
||||
* Adding this length moves the read position to the first character after that sequence so scanning
|
||||
* starts on the evaluation contents rather than on the opening parenthesis.
|
||||
*/
|
||||
export const CASHASSEMBLY_EVALUATION_START_LENGTH = 2;
|
||||
|
||||
/**
|
||||
* Character count of the `//` and `/*` comment openers, which are both two characters long.
|
||||
*
|
||||
* Adding this length moves the read position to the first character after that sequence so the '*'
|
||||
* of a block comment opener cannot be reused as the '*' of its closer.
|
||||
*/
|
||||
export const CASHASSEMBLY_COMMENT_START_LENGTH = 2;
|
||||
|
||||
/**
|
||||
* Character count of the block comment closer, which is two characters long.
|
||||
*
|
||||
* Adding this length moves the read position to the first character after that sequence so the
|
||||
* closing slash cannot pair with the next character and open another comment.
|
||||
*/
|
||||
export const CASHASSEMBLY_BLOCK_COMMENT_END_LENGTH = 2;
|
||||
|
||||
/**
|
||||
* `$()` with nothing between the parentheses.
|
||||
*
|
||||
* Callers use this to detect an evaluation that has no contents to compile.
|
||||
*/
|
||||
export const EMPTY_CASHASSEMBLY_EVALUATION = '$()';
|
||||
|
||||
@@ -1,16 +1,24 @@
|
||||
/* eslint-disable max-classes-per-file */
|
||||
import type { IdentifierResolutionType } from '@bitauth/libauth';
|
||||
|
||||
/**
|
||||
* Error thrown when a required variable is missing.
|
||||
*/
|
||||
export class CashAssemblyRequiredVariableMissingError extends Error {
|
||||
constructor(variableNames?: string[]) {
|
||||
/**
|
||||
* Variable names that were required but absent from the variables map.
|
||||
*/
|
||||
readonly variableNames: string[];
|
||||
|
||||
constructor(variableNames: string[] = []) {
|
||||
const defaultMessage = 'Missing required variable';
|
||||
if (variableNames !== undefined && variableNames.length > 0) {
|
||||
if (variableNames.length > 0) {
|
||||
super(`${defaultMessage}: variableNames [${variableNames.join(', ')}]`);
|
||||
} else {
|
||||
super(defaultMessage);
|
||||
}
|
||||
|
||||
this.variableNames = variableNames;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -24,6 +32,104 @@ export class CashAssemblyCompilationFailedError extends Error {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Error thrown when a quoted string inside `$()` never closes.
|
||||
*/
|
||||
export class CashAssemblyQuotedLiteralUnclosedError extends Error {
|
||||
/**
|
||||
* Index of the opening quote that never found a closer.
|
||||
*/
|
||||
readonly openingQuoteIndex: number;
|
||||
|
||||
/**
|
||||
* Quote character that opened the literal.
|
||||
*/
|
||||
readonly quoteCharacter: string;
|
||||
|
||||
/**
|
||||
* Text from the opening quote through the end of the scanned string.
|
||||
*/
|
||||
readonly unclosedText: string;
|
||||
|
||||
constructor(openingQuoteIndex: number, quoteCharacter: string, cashAssemblyText: string) {
|
||||
const unclosedText = cashAssemblyText.slice(openingQuoteIndex);
|
||||
const defaultMessage = 'Quoted literal in a CashAssembly evaluation is unclosed';
|
||||
const details = [
|
||||
`quoteCharacter ${JSON.stringify(quoteCharacter)}`,
|
||||
`openingQuoteIndex ${String(openingQuoteIndex)}`,
|
||||
`unclosedText ${JSON.stringify(unclosedText)}`,
|
||||
].join(', ');
|
||||
|
||||
super(`${defaultMessage}: ${details}`);
|
||||
|
||||
this.openingQuoteIndex = openingQuoteIndex;
|
||||
this.quoteCharacter = quoteCharacter;
|
||||
this.unclosedText = unclosedText;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Error thrown when a block comment inside `$()` never closes.
|
||||
*/
|
||||
export class CashAssemblyBlockCommentUnclosedError extends Error {
|
||||
/**
|
||||
* Index of the first slash of the block comment opener.
|
||||
*/
|
||||
readonly commentStartIndex: number;
|
||||
|
||||
/**
|
||||
* Text from the block comment opener through the end of the scanned string.
|
||||
*/
|
||||
readonly unclosedText: string;
|
||||
|
||||
constructor(commentStartIndex: number, cashAssemblyText: string) {
|
||||
const unclosedText = cashAssemblyText.slice(commentStartIndex);
|
||||
const defaultMessage = 'Block comment in a CashAssembly evaluation is unclosed';
|
||||
const details = [ `commentStartIndex ${String(commentStartIndex)}`, `unclosedText ${JSON.stringify(unclosedText)}` ].join(', ');
|
||||
|
||||
super(`${defaultMessage}: ${details}`);
|
||||
|
||||
this.commentStartIndex = commentStartIndex;
|
||||
this.unclosedText = unclosedText;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Error thrown when a `$()` evaluation never finds its matching closer.
|
||||
*/
|
||||
export class CashAssemblyEvaluationUnclosedError extends Error {
|
||||
/**
|
||||
* Index of the `$` that opened the evaluation.
|
||||
*/
|
||||
readonly evaluationStartIndex: number;
|
||||
|
||||
/**
|
||||
* How many `$()` remain open at the end of the string, including nested evaluations.
|
||||
*/
|
||||
readonly remainingOpenEvaluations: number;
|
||||
|
||||
/**
|
||||
* Text from the opening `$` through the end of the scanned string.
|
||||
*/
|
||||
readonly unclosedText: string;
|
||||
|
||||
constructor(evaluationStartIndex: number, remainingOpenEvaluations: number, cashAssemblyText: string) {
|
||||
const unclosedText = cashAssemblyText.slice(evaluationStartIndex);
|
||||
const defaultMessage = 'CashAssembly evaluation is unclosed';
|
||||
const details = [
|
||||
`evaluationStartIndex ${String(evaluationStartIndex)}`,
|
||||
`remainingOpenEvaluations ${String(remainingOpenEvaluations)}`,
|
||||
`unclosedText ${JSON.stringify(unclosedText)}`,
|
||||
].join(', ');
|
||||
|
||||
super(`${defaultMessage}: ${details}`);
|
||||
|
||||
this.evaluationStartIndex = evaluationStartIndex;
|
||||
this.remainingOpenEvaluations = remainingOpenEvaluations;
|
||||
this.unclosedText = unclosedText;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Error thrown when a variable's runtime type does not match the type required for compilation.
|
||||
*/
|
||||
@@ -74,6 +180,30 @@ export class CashAssemblyPrimitiveVariableMissingError extends Error {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Error thrown when one identifier would resolve as more than one {@link IdentifierResolutionType}.
|
||||
*/
|
||||
export class CashAssemblyIdentifierCollisionError extends Error {
|
||||
/**
|
||||
* Identifier that more than one resolution type would match.
|
||||
*/
|
||||
readonly identifier: string;
|
||||
|
||||
/**
|
||||
* The list of resolution types that the identifier matches.
|
||||
*/
|
||||
readonly resolutionTypes: IdentifierResolutionType[];
|
||||
|
||||
constructor(identifier: string, resolutionTypes: IdentifierResolutionType[]) {
|
||||
const defaultMessage = 'CashAssembly identifier exists for more than one resolution type';
|
||||
|
||||
super(`${defaultMessage}: identifier "${identifier}", resolutionTypes [${resolutionTypes.join(', ')}]`);
|
||||
|
||||
this.identifier = identifier;
|
||||
this.resolutionTypes = resolutionTypes;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Error thrown when compiled evaluation bytes cannot be decoded as a VM number.
|
||||
*/
|
||||
|
||||
+259
-175
@@ -7,26 +7,26 @@
|
||||
* ## Syntax (CashAssembly)
|
||||
*
|
||||
* `<expression>` is a push statement. Compiles the contents and pushes the result onto the VM stack.
|
||||
* `<someKey.public_key>` pushes the 33 byte compressed public key. `<1>` pushes the integer 1.
|
||||
* For example `<someKey.public_key>` pushes the 33 byte compressed public key and `<1>` pushes the integer 1.
|
||||
*
|
||||
* `$(<expression>)` is an evaluation. Runs the inner script in the VM and inserts the top stack
|
||||
* item as VM bytecode.
|
||||
* `$(<someKey.public_key> OP_HASH160)` inserts the HASH160 of the public key.
|
||||
* For example `$(<someKey.public_key> OP_HASH160)` inserts the HASH160 of the public key.
|
||||
*
|
||||
* `<$(<expression>)>` is a push of an evaluation result. It evaluates first then pushes.
|
||||
* For a P2PKH locking script example see
|
||||
* For example a P2PKH locking script looks like
|
||||
* `OP_DUP OP_HASH160 <$(<someKey.public_key> OP_HASH160)> OP_EQUALVERIFY OP_CHECKSIG`.
|
||||
*
|
||||
* `variableId.operation` is a variable with a compiler resolved operation. `someKey.public_key`
|
||||
* produces the public key bytes. `someKey.schnorr_signature.all_outputs` produces a Schnorr
|
||||
* signature.
|
||||
* `variableId.operation` is a variable with a compiler resolved operation.
|
||||
* For example `someKey.public_key` produces the public key bytes and
|
||||
* `someKey.schnorr_signature.all_outputs` produces a Schnorr signature.
|
||||
*
|
||||
* Opcodes (`OP_DUP`, `OP_HASH160`, and similar) are inserted as their bytecode equivalent directly.
|
||||
*
|
||||
* ## Name resolution priority (CashAssembly)
|
||||
*
|
||||
* When the compiler encounters an identifier it resolves it in this order.
|
||||
* 1. Opcode always wins. Naming a variable or script `OP_ADD` will not shadow it.
|
||||
* 1. Opcode always wins. For example naming a variable or script `OP_ADD` will not shadow it.
|
||||
* 2. Variable shadows scripts of the same name.
|
||||
* 3. Script is the script's bytecode.
|
||||
*
|
||||
@@ -34,80 +34,40 @@
|
||||
*
|
||||
* Supported `<base.method>` pushes are resolved to bytes before CashAssembly compiles.
|
||||
* Inside CashAssembly the order is Opcode then Variable then Script.
|
||||
*
|
||||
* ## This compile path
|
||||
*
|
||||
* This file is a layer on CashAssembly. Text outside `$()` is kept. Only `$()` is compiled.
|
||||
* For example `Paid $(scriptA) of $(scriptB)` keeps the words Paid and of.
|
||||
*
|
||||
* `$()` is found by scanning left to right with parenthesis matching by depth count. Quoted strings
|
||||
* and comments are skipped so a `)` there cannot end `$()`. Nested `$()` stay inside the outer
|
||||
* evaluation. That text is never passed to parseScript.
|
||||
*
|
||||
* Empty `$()` still uses this mixed path. When the string contains no `$()` at all, the entire
|
||||
* string is compiled as CashAssembly so push opcodes remain.
|
||||
*/
|
||||
import type { CompilerBch } from '@bitauth/libauth';
|
||||
import { binToHex, binToUtf8, createCompilerBch, vmNumberToBigInt } from '@bitauth/libauth';
|
||||
import type { XOInvitationVariableValue, XOTemplate } from '@xo-cash/types';
|
||||
import { convertValueToBytes } from './bytes.ts';
|
||||
import {
|
||||
CashAssemblyCompilationFailedError,
|
||||
CashAssemblyVariableTypeMismatchError,
|
||||
CashAssemblyRequiredVariableMissingError,
|
||||
CashAssemblyVariableTypeMismatchError,
|
||||
CashAssemblyVmNumberDecodeError,
|
||||
} from './errors.ts';
|
||||
import {
|
||||
CASHASSEMBLY_EVALUATION_PATTERN,
|
||||
CASHASSEMBLY_EXPRESSION_PATTERN,
|
||||
CASHASSEMBLY_LITERAL_TOKEN_PATTERN,
|
||||
CASHASSEMBLY_VARIABLE_PATTERN,
|
||||
} from './defaults.ts';
|
||||
import { convertValueToBytes } from './bytes.ts';
|
||||
import { resolvePrimitiveMethodBytes } from './primitive-evaluations.ts';
|
||||
|
||||
/**
|
||||
* Supported decode modes for compiled CashAssembly evaluation bytes.
|
||||
*/
|
||||
export type CompiledCashAssemblyDecodeMode = 'utf8' | 'hex' | 'boolean' | 'bigint' | 'uint8array';
|
||||
|
||||
/**
|
||||
* Parameters for compiling CashAssembly string.
|
||||
*/
|
||||
export type CompileCashAssemblyStringParameters = {
|
||||
|
||||
/**
|
||||
* Text that may embed CashAssembly evaluations such as `$(<fee>)` or `$(<expiry.toIso8601>)`.
|
||||
*/
|
||||
cashAssemblyText: string;
|
||||
|
||||
/**
|
||||
* Used for both primitive method evaluations and normal CashAssembly compilation.
|
||||
*/
|
||||
variables: Record<string, XOInvitationVariableValue | Uint8Array>;
|
||||
|
||||
/**
|
||||
* The mode to decode compiled evaluation bytes into a string.
|
||||
*/
|
||||
evaluationDecodeMode?: CompiledCashAssemblyDecodeMode;
|
||||
|
||||
/**
|
||||
* Optional template variable definitions. When provided, each `<name.method>` push whose `type`
|
||||
* maps to a method resolvable primitive class is resolved to bytes before CashAssembly compilation.
|
||||
*/
|
||||
templateVariables?: XOTemplate['variables'];
|
||||
};
|
||||
|
||||
/**
|
||||
* Checks if the expression is a CashAssembly expression.
|
||||
*
|
||||
* @param {unknown} expression - The expression to check.
|
||||
* @returns {boolean} True if the expression is a CashAssembly expression, false otherwise.
|
||||
*/
|
||||
export const isCashAssemblyExpression = (expression: unknown): boolean => {
|
||||
return typeof expression === 'string' && CASHASSEMBLY_EXPRESSION_PATTERN.test(expression);
|
||||
};
|
||||
|
||||
/**
|
||||
* Extracts all CashAssembly evaluations (i.e., substrings like $(...)) from the input text.
|
||||
*
|
||||
* @param {string} text - The input string to scan for CashAssembly evaluations.
|
||||
* @returns {string[]} An array of evaluation strings found in the input.
|
||||
*
|
||||
* @example
|
||||
* extractCashAssemblyEvaluations("OP_DUP <$(<foo>)> OP_HASH160 $(<bar>)");
|
||||
* // returns ['$(<foo>)', '$(<bar>)']
|
||||
*/
|
||||
export const extractCashAssemblyEvaluations = (text: string): string[] => {
|
||||
return text.match(CASHASSEMBLY_EVALUATION_PATTERN) ?? [];
|
||||
};
|
||||
import { assertNoIdentifierCollisions } from './identifier-collisions.ts';
|
||||
import { collectVariablesUsingParseScript } from './collect-evaluations.ts';
|
||||
import { EMPTY_CASHASSEMBLY_EVALUATION } from './defaults.ts';
|
||||
import { scanCashAssemblyEvaluations } from './scan-evaluations.ts';
|
||||
import type {
|
||||
CompileCashAssemblyEvaluationsParameters,
|
||||
CompileCashAssemblySourceToBytesParameters,
|
||||
CompileCashAssemblySourceToDecodedTextParameters,
|
||||
CompileCashAssemblyStringParameters,
|
||||
CompiledCashAssemblyDecodeMode,
|
||||
} from './types.ts';
|
||||
|
||||
/**
|
||||
* Returns the segment of `identifier` before the first `.`.
|
||||
@@ -116,7 +76,7 @@ export const extractCashAssemblyEvaluations = (text: string): string[] => {
|
||||
* Multi segment identifiers such as `foo.bar.baz` resolve to `foo`.
|
||||
*
|
||||
* @param {string} identifier - Identifier that may contain a dot.
|
||||
* @returns {string} The base name before the first `.`.
|
||||
* @returns {string} - The base name before the first `.`.
|
||||
*/
|
||||
const resolveIdentifierBaseName = (identifier: string): string => {
|
||||
const firstDotIndex = identifier.indexOf('.');
|
||||
@@ -128,31 +88,6 @@ const resolveIdentifierBaseName = (identifier: string): string => {
|
||||
return identifier.slice(0, firstDotIndex);
|
||||
};
|
||||
|
||||
/**
|
||||
* Extracts unique variable identifiers enclosed in angle brackets from each evaluation string.
|
||||
*
|
||||
* CashAssembly literal tokens such as hex bytes, numbers, and quoted strings are excluded via
|
||||
* {@link CASHASSEMBLY_LITERAL_TOKEN_PATTERN}. For example, `<0x02>` and `<"minting">` are not returned.
|
||||
*
|
||||
* @param {string[]} evaluations - An array of evaluation strings from which to extract variable names.
|
||||
* @returns {string[]} An array of variable names.
|
||||
*/
|
||||
export const extractVariablesFromEvaluations = (evaluations: string[]): string[] => {
|
||||
const uniqueVariables = new Set<string>();
|
||||
|
||||
for (const evaluation of evaluations) {
|
||||
for (const [ , extractedIdentifier ] of evaluation.matchAll(CASHASSEMBLY_VARIABLE_PATTERN)) {
|
||||
if (CASHASSEMBLY_LITERAL_TOKEN_PATTERN.test(extractedIdentifier)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
uniqueVariables.add(extractedIdentifier);
|
||||
}
|
||||
}
|
||||
|
||||
return [ ...uniqueVariables ];
|
||||
};
|
||||
|
||||
/**
|
||||
* Decodes compiled CashAssembly evaluation bytes into a string representation.
|
||||
*
|
||||
@@ -161,10 +96,9 @@ export const extractVariablesFromEvaluations = (evaluations: string[]): string[]
|
||||
* such as hashes, and `boolean` to represent boolean values.
|
||||
*
|
||||
* @param {Uint8Array} compiledResult - The compiled evaluation bytecode.
|
||||
* @param {CompiledCashAssemblyDecodeMode} [evaluationDecodeMode='utf8'] - The decode mode used to convert
|
||||
* bytes to text.
|
||||
* @returns {string} The decoded value as a string suitable for inline replacement.
|
||||
* @throws {@link CashAssemblyVmNumberDecodeError} When `evaluationDecodeMode` is `bigint` and the bytes are not a VM number.
|
||||
* @param {CompiledCashAssemblyDecodeMode} evaluationDecodeMode - The decode mode used to convert bytes to text.
|
||||
* @returns {string} - The decoded value as a string suitable for inline replacement.
|
||||
* @throws {@link CashAssemblyVmNumberDecodeError} - When `evaluationDecodeMode` is `bigint` and the bytes are not a VM number.
|
||||
*/
|
||||
export const decodeCompiledCashAssemblyEvaluation = (
|
||||
compiledResult: Uint8Array,
|
||||
@@ -206,43 +140,52 @@ export const decodeCompiledCashAssemblyEvaluation = (
|
||||
* @param {CompilerBch} compiler - The libauth compiler from {@link compileCashAssemblyEvaluations}.
|
||||
* @param {string} evaluation - The specific evaluation string to compile.
|
||||
* @param {Record<string, Uint8Array>} variables - A record mapping variable names to their values.
|
||||
* @returns {Uint8Array} The compiled bytecode.
|
||||
* @throws {@link CashAssemblyRequiredVariableMissingError} If a required variable is not present.
|
||||
* @throws {@link CashAssemblyVariableTypeMismatchError} If a variable value is not a Uint8Array.
|
||||
* @throws {@link CashAssemblyCompilationFailedError} If libauth compilation fails.
|
||||
* @param {string[]} requiredVariableNames - WalletData names from {@link collectVariablesUsingParseScript} that must be present.
|
||||
* @returns {Uint8Array} - The compiled bytecode.
|
||||
* @throws {@link CashAssemblyRequiredVariableMissingError} - If a required variable is not present.
|
||||
* @throws {@link CashAssemblyVariableTypeMismatchError} - If a variable value is not a Uint8Array.
|
||||
* @throws {@link CashAssemblyCompilationFailedError} - If libauth compilation fails.
|
||||
*/
|
||||
export const generateCashAssemblyBytecode = (compiler: CompilerBch, evaluation: string, variables: Record<string, Uint8Array>): Uint8Array => {
|
||||
const variableNames = extractVariablesFromEvaluations([ evaluation ]);
|
||||
export const generateCashAssemblyBytecode = (
|
||||
compiler: CompilerBch,
|
||||
evaluation: string,
|
||||
variables: Record<string, Uint8Array>,
|
||||
requiredVariableNames: string[],
|
||||
): Uint8Array => {
|
||||
// Missing WalletData must fail here. Libauth would otherwise compile as if the value were empty bytes.
|
||||
const missingVariables = requiredVariableNames.filter((name: string) => Object.hasOwn(variables, name) === false);
|
||||
|
||||
// Validate that all required variables are provided
|
||||
const missingVariables = variableNames.filter((name: string) => !Object.hasOwn(variables, name));
|
||||
// Refuse invented defaults so a missing value cannot compile into incorrect bytecode.
|
||||
if (missingVariables.length > 0) {
|
||||
throw new CashAssemblyRequiredVariableMissingError(missingVariables);
|
||||
}
|
||||
|
||||
// Construct the bytecode object using the keys in variableNames, mapping to the values in variables
|
||||
const bytecode: Record<string, Uint8Array> = {};
|
||||
for (const variableName of variableNames) {
|
||||
|
||||
// Pass only required names so extra keys in `variables` are not sent to Libauth.
|
||||
for (const variableName of requiredVariableNames) {
|
||||
const value = variables[variableName];
|
||||
|
||||
// By the time execution reaches this point, the value should be a Uint8Array.
|
||||
if (!(value instanceof Uint8Array)) {
|
||||
// Libauth WalletData is always bytes. A number would compile incorrectly if we trusted TypeScript alone.
|
||||
if (value instanceof Uint8Array === false) {
|
||||
throw new CashAssemblyVariableTypeMismatchError(variableName, 'Uint8Array', typeof value);
|
||||
}
|
||||
|
||||
bytecode[variableName] = value;
|
||||
}
|
||||
|
||||
// `scriptId` is the source string registered in compileCashAssemblyEvaluations.
|
||||
const compiledBytecode = compiler.generateBytecode({
|
||||
data: { bytecode },
|
||||
scriptId: evaluation,
|
||||
});
|
||||
|
||||
if (!compiledBytecode.success) {
|
||||
// Collapse libauth's full errors list into one string because
|
||||
// CashAssemblyCompilationFailedError only carries a single message.
|
||||
// A failed compile must not return partial bytecode.
|
||||
if (compiledBytecode.success === false) {
|
||||
// CashAssemblyCompilationFailedError carries one message. Libauth may report several errors.
|
||||
let compilationFailureMessage = 'unknown compilation failure';
|
||||
|
||||
// Prefer Libauth's own error text when the failure object includes an errors list.
|
||||
if ('errors' in compiledBytecode && compiledBytecode.errors.length > 0) {
|
||||
compilationFailureMessage = compiledBytecode.errors.map((compilationError) => compilationError.error).join('; ');
|
||||
}
|
||||
@@ -254,28 +197,50 @@ export const generateCashAssemblyBytecode = (compiler: CompilerBch, evaluation:
|
||||
};
|
||||
|
||||
/**
|
||||
* Prepares a compiler for the provided CashAssembly evaluations, setting required variables as 'WalletData'.
|
||||
* Prepares a compiler for the provided CashAssembly source strings, setting required variables as 'WalletData'.
|
||||
*
|
||||
* @param {string[]} evaluations - Array of evaluation strings (e.g., ['$(<var1>)', '$(<var2> <var3>)']).
|
||||
* @returns {CompilerBch} A Libauth compiler instance for use with these evaluations.
|
||||
* Each evaluation string is registered as a script whose id and source are that string. Template
|
||||
* scripts are then copied onto the same table. If a source is a template id such as `scriptA`, that
|
||||
* id must compile the template source. Leaving the id as its own source would compile the text
|
||||
* `scriptA` and include itself.
|
||||
*
|
||||
* @param {CompileCashAssemblyEvaluationsParameters} parameters - Source strings, optional template scripts, and already collected WalletData names.
|
||||
* @returns {CompilerBch} - A Libauth compiler instance for use with these source strings.
|
||||
*/
|
||||
export const compileCashAssemblyEvaluations = (evaluations: string[]): CompilerBch => {
|
||||
// Create a scripts object where each key is the evaluation and the value is also the evaluation
|
||||
export const compileCashAssemblyEvaluations = (parameters: CompileCashAssemblyEvaluationsParameters): CompilerBch => {
|
||||
const { evaluations, templateScripts, variableNames } = parameters;
|
||||
const scripts: Record<string, string> = {};
|
||||
|
||||
// Libauth looks up scripts by id. Using the source string as the id lets generateBytecode request that exact source.
|
||||
for (const evaluation of evaluations) {
|
||||
scripts[evaluation] = evaluation;
|
||||
}
|
||||
|
||||
// Get the variable names from the evaluations.
|
||||
const variableNames = extractVariablesFromEvaluations(evaluations);
|
||||
const knownScriptIdentifiers = new Set<string>();
|
||||
|
||||
if (templateScripts !== undefined) {
|
||||
for (const [ scriptIdentifier, scriptDefinition ] of Object.entries(templateScripts)) {
|
||||
// Compiling `scriptA` must run the template source. Otherwise the id compiles as the text `scriptA` and includes itself.
|
||||
scripts[scriptIdentifier] = scriptDefinition;
|
||||
|
||||
// Template ids compile as scripts. They must not also be registered as WalletData.
|
||||
knownScriptIdentifiers.add(scriptIdentifier);
|
||||
}
|
||||
}
|
||||
|
||||
// Register each base name once. Dotted pushes share one WalletData entry under the base name.
|
||||
const variables: Record<string, { type: 'WalletData' }> = {};
|
||||
|
||||
// Register caller collected names. This function does not parse or collect again.
|
||||
for (const variableName of variableNames) {
|
||||
// A name that is a template script id is already a compiler script, not WalletData.
|
||||
if (knownScriptIdentifiers.has(variableName) === true) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Libauth WalletData is the name before the first `.`. `ownerKey.public_key` registers as `ownerKey`.
|
||||
variables[resolveIdentifierBaseName(variableName)] = { type: 'WalletData' as const };
|
||||
}
|
||||
|
||||
// Create the libauth compiler.
|
||||
const compiler = createCompilerBch({
|
||||
scripts,
|
||||
variables,
|
||||
@@ -285,69 +250,188 @@ export const compileCashAssemblyEvaluations = (evaluations: string[]): CompilerB
|
||||
};
|
||||
|
||||
/**
|
||||
* Compiles all CashAssembly evaluations in a text string and replaces each evaluation
|
||||
* with a decoded string representation.
|
||||
* Compiles one CashAssembly source string to bytecode.
|
||||
*
|
||||
* The source may be an evaluation such as `$(scriptA)` or CashAssembly such as
|
||||
* `scriptA`. WalletData names come from {@link collectVariablesUsingParseScript}.
|
||||
*
|
||||
* @param {CompileCashAssemblySourceToBytesParameters} parameters - Source text and compilation context.
|
||||
* @returns {Uint8Array} - Compiled bytecode for that source.
|
||||
* @throws {@link CashAssemblyRequiredVariableMissingError} - When a required variable is not present in the variables map.
|
||||
* @throws {@link CashAssemblyPrimitiveMethodMissingError} - When a supported primitive hint has an unknown method.
|
||||
* @throws {@link CashAssemblyPrimitiveVariableMissingError} - When a supported primitive method is missing its runtime value.
|
||||
* @throws {@link CashAssemblyUnsupportedValueTypeError} - When a primitive method return type cannot be embedded as bytes.
|
||||
* @throws {@link CashAssemblyNumberNotSafeIntegerError} - When a number variable is not a safe integer.
|
||||
* @throws {@link CashAssemblyIdentifierCollisionError} - When one identifier matches two resolution types.
|
||||
* @throws {@link CashAssemblyCompilationFailedError} - When Libauth compilation fails.
|
||||
*/
|
||||
export const compileCashAssemblySourceToBytes = (parameters: CompileCashAssemblySourceToBytesParameters): Uint8Array => {
|
||||
const { cashAssemblySource, variables, templateVariables, templateScripts } = parameters;
|
||||
|
||||
// Throw if there is a identifier collision between opcodes, variable, or template script.
|
||||
assertNoIdentifierCollisions({ variables, templateScripts });
|
||||
|
||||
// Collect WalletData names from the source and every reachable nested script source.
|
||||
const variableNames = collectVariablesUsingParseScript(cashAssemblySource, templateScripts);
|
||||
|
||||
// Resolve supported primitive methods to bytes before treating remaining names as WalletData.
|
||||
const primitiveMethodBytes = resolvePrimitiveMethodBytes({
|
||||
variableNames,
|
||||
templateVariables,
|
||||
variables,
|
||||
});
|
||||
|
||||
// Collect names that still need a caller supplied value after primitive resolution.
|
||||
const missingVariables: string[] = [];
|
||||
for (const variableName of variableNames) {
|
||||
// Primitive methods already produced bytes under the full name.
|
||||
if (Object.hasOwn(primitiveMethodBytes, variableName) === true) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// A present runtime value will be converted to bytes below.
|
||||
if (Object.hasOwn(variables, variableName) === true) {
|
||||
continue;
|
||||
}
|
||||
|
||||
missingVariables.push(variableName);
|
||||
}
|
||||
|
||||
// Refuse invented defaults so a missing value cannot compile into incorrect bytecode.
|
||||
if (missingVariables.length > 0) {
|
||||
throw new CashAssemblyRequiredVariableMissingError(missingVariables);
|
||||
}
|
||||
|
||||
// Convert each variable to its bytes before compilation.
|
||||
const variableBytes: Record<string, Uint8Array> = {};
|
||||
for (const variableName of variableNames) {
|
||||
// Prefer the primitive method result so CashAssembly sees bytes under `base.method`.
|
||||
if (Object.hasOwn(primitiveMethodBytes, variableName) === true) {
|
||||
variableBytes[variableName] = primitiveMethodBytes[variableName];
|
||||
continue;
|
||||
}
|
||||
|
||||
// Remaining names are caller WalletData that still needs a byte encoding.
|
||||
variableBytes[variableName] = convertValueToBytes(variables[variableName], variableName);
|
||||
}
|
||||
|
||||
// Reuse the names collected above so this compiler is not built from a second parse.
|
||||
const compiler = compileCashAssemblyEvaluations({
|
||||
evaluations: [ cashAssemblySource ],
|
||||
templateScripts,
|
||||
variableNames,
|
||||
});
|
||||
|
||||
return generateCashAssemblyBytecode(compiler, cashAssemblySource, variableBytes, variableNames);
|
||||
};
|
||||
|
||||
/**
|
||||
* Compiles one CashAssembly source to bytes and decodes those bytes to text.
|
||||
*
|
||||
* @param {CompileCashAssemblySourceToDecodedTextParameters} parameters - Source text, WalletData, optional template context, and decode mode.
|
||||
* @returns {string} - Decoded compilation result for that source.
|
||||
* @throws {@link CashAssemblyRequiredVariableMissingError} - When a required variable is not present in the variables map.
|
||||
* @throws {@link CashAssemblyPrimitiveMethodMissingError} - When a supported primitive hint has an unknown method.
|
||||
* @throws {@link CashAssemblyPrimitiveVariableMissingError} - When a supported primitive method is missing its runtime value.
|
||||
* @throws {@link CashAssemblyUnsupportedValueTypeError} - When a primitive method return type cannot be embedded as bytes.
|
||||
* @throws {@link CashAssemblyNumberNotSafeIntegerError} - When a number variable is not a safe integer.
|
||||
* @throws {@link CashAssemblyVmNumberDecodeError} - When `evaluationDecodeMode` is `bigint` and compiled bytes are not a VM number.
|
||||
* @throws {@link CashAssemblyIdentifierCollisionError} - When one identifier matches two resolution types.
|
||||
* @throws {@link CashAssemblyCompilationFailedError} - When Libauth compilation fails.
|
||||
*/
|
||||
const compileCashAssemblySourceToDecodedText = (parameters: CompileCashAssemblySourceToDecodedTextParameters): string => {
|
||||
const { cashAssemblySource, variables, templateVariables, templateScripts, evaluationDecodeMode } = parameters;
|
||||
|
||||
const compilationResult = compileCashAssemblySourceToBytes({
|
||||
cashAssemblySource,
|
||||
variables,
|
||||
templateVariables,
|
||||
templateScripts,
|
||||
});
|
||||
|
||||
return decodeCompiledCashAssemblyEvaluation(compilationResult, evaluationDecodeMode);
|
||||
};
|
||||
|
||||
/**
|
||||
* Compiles CashAssembly text. WalletData names come from parseScript. `$()` evaluations in text
|
||||
* are found by scanning the string and matching parentheses.
|
||||
*
|
||||
* When `cashAssemblyText` contains `$()`, each non empty evaluation is compiled and
|
||||
* replaced in place. Empty `$()` is copied through. Surrounding text is kept. Evaluation drops
|
||||
* push opcodes and keeps the stack payload.
|
||||
*
|
||||
* When `cashAssemblyText` contains no `$()` at all, the entire string is compiled as CashAssembly.
|
||||
* Push opcodes from `<...>` remain. Pass a template script id such as `scriptA` or
|
||||
* concatenated ids such as `scriptA scriptB`.
|
||||
*
|
||||
* Required WalletData names come from {@link collectVariablesUsingParseScript}.
|
||||
*
|
||||
* @param {CompileCashAssemblyStringParameters} parameters - Parameters for compiling the CashAssembly string.
|
||||
* @param {string} parameters.cashAssemblyText - The string with CashAssembly evaluations.
|
||||
* @param {Record<string, XOInvitationVariableValue | Uint8Array>} parameters.variables - Object mapping
|
||||
* variable names to values for compilation.
|
||||
* @param {CompiledCashAssemblyDecodeMode} [parameters.evaluationDecodeMode='utf8'] - The decode mode used
|
||||
* after each evaluation is compiled. See {@link decodeCompiledCashAssemblyEvaluation}.
|
||||
* @param {XOTemplate['variables']} [parameters.templateVariables] - Optional template variable definitions
|
||||
* used to resolve method resolvable `<name.method>` pushes via each variable's `type`.
|
||||
* @returns {string} Compiled text with all evaluations replaced by decoded string values.
|
||||
* @throws {@link CashAssemblyRequiredVariableMissingError} When a required variable is not present in the variables map.
|
||||
* @throws {@link CashAssemblyPrimitiveMethodMissingError} When a method resolvable primitive type has an unknown method.
|
||||
* @throws {@link CashAssemblyPrimitiveVariableMissingError} When a method resolvable primitive method is missing its runtime value.
|
||||
* @throws {@link CashAssemblyUnsupportedValueTypeError} When a primitive method return type cannot be embedded as bytes.
|
||||
* @throws {@link CashAssemblyNumberNotSafeIntegerError} When a number variable is not a safe integer.
|
||||
* @throws {@link CashAssemblyVmNumberDecodeError} When `evaluationDecodeMode` is `bigint` and an evaluation is not a VM number.
|
||||
* @returns {string} - Compiled text with evaluations replaced, or decoded bytecode when the whole string is compiled as CashAssembly.
|
||||
* @throws {@link CashAssemblyRequiredVariableMissingError} - When a required variable is not present in the variables map.
|
||||
* @throws {@link CashAssemblyPrimitiveMethodMissingError} - When a supported primitive hint has an unknown method.
|
||||
* @throws {@link CashAssemblyPrimitiveVariableMissingError} - When a supported primitive method is missing its runtime value.
|
||||
* @throws {@link CashAssemblyUnsupportedValueTypeError} - When a primitive method return type cannot be embedded as bytes.
|
||||
* @throws {@link CashAssemblyNumberNotSafeIntegerError} - When a number variable is not a safe integer.
|
||||
* @throws {@link CashAssemblyVmNumberDecodeError} - When `evaluationDecodeMode` is `bigint` and compiled bytes are not a VM number.
|
||||
* @throws {@link CashAssemblyIdentifierCollisionError} - When one identifier matches two resolution types.
|
||||
* @throws {@link CashAssemblyCompilationFailedError} - When Libauth compilation fails.
|
||||
* @throws {@link CashAssemblyEvaluationUnclosedError} - When an evaluation never closes.
|
||||
* @throws {@link CashAssemblyQuotedLiteralUnclosedError} - When a quoted literal never closes.
|
||||
* @throws {@link CashAssemblyBlockCommentUnclosedError} - When a block comment never closes.
|
||||
*/
|
||||
export const compileCashAssemblyString = (parameters: CompileCashAssemblyStringParameters): string => {
|
||||
const { cashAssemblyText, variables, evaluationDecodeMode = 'utf8', templateVariables } = parameters;
|
||||
const { cashAssemblyText, variables, evaluationDecodeMode = 'utf8', templateVariables, templateScripts } = parameters;
|
||||
|
||||
return cashAssemblyText.replace(CASHASSEMBLY_EVALUATION_PATTERN, (evaluation) => {
|
||||
// Extract variable identifiers required by the current evaluation.
|
||||
const variableNames = extractVariablesFromEvaluations([ evaluation ]);
|
||||
// Scan once so empty `$()` still copies text around `$()` instead of compiling as CashAssembly.
|
||||
const scannedEvaluations = scanCashAssemblyEvaluations(cashAssemblyText);
|
||||
|
||||
const primitiveMethodBytes = resolvePrimitiveMethodBytes({
|
||||
identifiers: variableNames,
|
||||
templateVariables,
|
||||
if (scannedEvaluations.length === 0) {
|
||||
return compileCashAssemblySourceToDecodedText({
|
||||
cashAssemblySource: cashAssemblyText,
|
||||
variables,
|
||||
templateVariables,
|
||||
templateScripts,
|
||||
evaluationDecodeMode,
|
||||
});
|
||||
}
|
||||
|
||||
// Prefer resolved method bytes. Fall back to converting the raw variable value.
|
||||
const missingVariables = variableNames.filter((variableName) => {
|
||||
if (Object.hasOwn(primitiveMethodBytes, variableName) === true) {
|
||||
return false;
|
||||
}
|
||||
// Copy text outside `$()` and compile each `$()`.
|
||||
let textWithCompiledEvaluations = '';
|
||||
|
||||
return Object.hasOwn(variables, variableName) === false;
|
||||
});
|
||||
// Start of the next original text slice in cashAssemblyText. Gaps between evaluations must stay intact.
|
||||
let nextTextStartIndex = 0;
|
||||
|
||||
if (missingVariables.length > 0) {
|
||||
throw new CashAssemblyRequiredVariableMissingError(missingVariables);
|
||||
// When every `$()` is empty, return cashAssemblyText so the original text is unchanged.
|
||||
let hasCompiledNonEmptyEvaluation = false;
|
||||
|
||||
for (const scannedEvaluation of scannedEvaluations) {
|
||||
textWithCompiledEvaluations += cashAssemblyText.slice(nextTextStartIndex, scannedEvaluation.startIndex);
|
||||
|
||||
// Empty `$()` has no contents to compile.
|
||||
if (scannedEvaluation.evaluationText === EMPTY_CASHASSEMBLY_EVALUATION) {
|
||||
textWithCompiledEvaluations += scannedEvaluation.evaluationText;
|
||||
} else {
|
||||
hasCompiledNonEmptyEvaluation = true;
|
||||
textWithCompiledEvaluations += compileCashAssemblySourceToDecodedText({
|
||||
cashAssemblySource: scannedEvaluation.evaluationText,
|
||||
variables,
|
||||
templateVariables,
|
||||
templateScripts,
|
||||
evaluationDecodeMode,
|
||||
});
|
||||
}
|
||||
|
||||
// Convert each variable to its bytes before compilation.
|
||||
const variableBytes: Record<string, Uint8Array> = {};
|
||||
for (const variableName of variableNames) {
|
||||
if (Object.hasOwn(primitiveMethodBytes, variableName) === true) {
|
||||
variableBytes[variableName] = primitiveMethodBytes[variableName];
|
||||
continue;
|
||||
}
|
||||
// Advance past this `$()` so nested evaluations inside it are not copied again.
|
||||
nextTextStartIndex = scannedEvaluation.closeIndex + 1;
|
||||
}
|
||||
|
||||
variableBytes[variableName] = convertValueToBytes(variables[variableName], variableName);
|
||||
}
|
||||
// Copy text after the last `$()` so the suffix is kept.
|
||||
textWithCompiledEvaluations += cashAssemblyText.slice(nextTextStartIndex);
|
||||
|
||||
// Compile the evaluation in isolation.
|
||||
const compiler = compileCashAssemblyEvaluations([ evaluation ]);
|
||||
if (hasCompiledNonEmptyEvaluation === false) {
|
||||
return cashAssemblyText;
|
||||
}
|
||||
|
||||
// Generate the bytes for the evaluation.
|
||||
const compilationResult: Uint8Array = generateCashAssemblyBytecode(compiler, evaluation, variableBytes);
|
||||
|
||||
// Decode the bytes into a string as per the decode mode.
|
||||
return decodeCompiledCashAssemblyEvaluation(compilationResult, evaluationDecodeMode);
|
||||
});
|
||||
return textWithCompiledEvaluations;
|
||||
};
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
import { generateBytecodeMap, IdentifierResolutionType, OpcodesBchSpec } from '@bitauth/libauth';
|
||||
import { CashAssemblyIdentifierCollisionError } from './errors.ts';
|
||||
import type { CompileCashAssemblyContext } from './types.ts';
|
||||
|
||||
/**
|
||||
* Set of opcode names that the compiler recognizes.
|
||||
*/
|
||||
const COMPILER_OPCODE_NAMES: ReadonlySet<string> = new Set(Object.keys(generateBytecodeMap(OpcodesBchSpec)));
|
||||
|
||||
/**
|
||||
* Parameters for {@link assertNoIdentifierCollisions} function.
|
||||
*/
|
||||
export type AssertNoIdentifierCollisionsParameters = Pick<CompileCashAssemblyContext, 'variables' | 'templateScripts'>;
|
||||
|
||||
/**
|
||||
* Libauth resolves opcodes, then variables, then scripts, and the first match silently hides the
|
||||
* rest, leading to unexpected compilation results and no errors. This function throws when one identifier matches more than one resolution type.
|
||||
*
|
||||
* @param {AssertNoIdentifierCollisionsParameters} parameters - Provided variables and optional template scripts.
|
||||
* @throws {@link CashAssemblyIdentifierCollisionError} - When one identifier matches more than one resolution type.
|
||||
*/
|
||||
export const assertNoIdentifierCollisions = (parameters: AssertNoIdentifierCollisionsParameters): void => {
|
||||
const { variables, templateScripts } = parameters;
|
||||
|
||||
// Create a set of the script identifiers from the template scripts map.
|
||||
const scriptIdentifiers = new Set(Object.keys(templateScripts ?? {}));
|
||||
|
||||
const variableNames = new Set(Object.keys(variables));
|
||||
|
||||
// Combine both, the variableNames and the scriptIdentifiers, to get a set of all identifiers.
|
||||
const declaredIdentifiers = new Set([ ...variableNames, ...scriptIdentifiers ]);
|
||||
|
||||
// This loop will check for more than one occurrence of an identifier in OP_CODES, variables, and scripts
|
||||
for (const identifier of declaredIdentifiers) {
|
||||
// Resolution types is a native construct from libauth.
|
||||
const resolutionTypes: IdentifierResolutionType[] = [];
|
||||
|
||||
// If the identifier is an opcode, then push it to the resolution types array, this will also be
|
||||
// detected by either or both of the variableNames and scriptIdentifiers checks.
|
||||
if (COMPILER_OPCODE_NAMES.has(identifier) === true) {
|
||||
resolutionTypes.push(IdentifierResolutionType.opcode);
|
||||
}
|
||||
|
||||
// If the identifier is in variables, then push it to the resolution types array.
|
||||
if (variableNames.has(identifier) === true) {
|
||||
resolutionTypes.push(IdentifierResolutionType.variable);
|
||||
}
|
||||
|
||||
// If the identifier is a script identifier, then push it to the resolution types array.
|
||||
if (scriptIdentifiers.has(identifier) === true) {
|
||||
resolutionTypes.push(IdentifierResolutionType.script);
|
||||
}
|
||||
|
||||
// If there are more than one resolution types, then throw an error.
|
||||
if (resolutionTypes.length > 1) {
|
||||
throw new CashAssemblyIdentifierCollisionError(identifier, resolutionTypes);
|
||||
}
|
||||
}
|
||||
};
|
||||
@@ -1,5 +1,5 @@
|
||||
export * from './evaluations.ts';
|
||||
export * from './primitive-evaluations.ts';
|
||||
export * from './bytes.ts';
|
||||
export * from './defaults.ts';
|
||||
export { collectVariablesUsingParseScript } from './collect-evaluations.ts';
|
||||
export * from './errors.ts';
|
||||
export { compileCashAssemblyString } from './evaluations.ts';
|
||||
export { extractCashAssemblyEvaluations, isCashAssemblyExpression } from './scan-evaluations.ts';
|
||||
export type { CompiledCashAssemblyDecodeMode, CompileCashAssemblyStringParameters } from './types.ts';
|
||||
|
||||
@@ -64,15 +64,15 @@ type CallPrimitiveMethodParameters = {
|
||||
};
|
||||
|
||||
/**
|
||||
* Inputs needed to resolve primitive method pushes from extracted CashAssembly identifiers.
|
||||
* Inputs needed to resolve primitive method pushes from collected CashAssembly variable names.
|
||||
*/
|
||||
export type ResolvePrimitiveMethodBytesParameters = {
|
||||
|
||||
/**
|
||||
* Variable identifiers from {@link extractVariablesFromEvaluations}, for example
|
||||
* Variable names from {@link collectVariablesUsingParseScript}, for example
|
||||
* `['amount.toSatoshis', 'fee.toSatoshis']`.
|
||||
*/
|
||||
identifiers: string[];
|
||||
variableNames: string[];
|
||||
|
||||
/**
|
||||
* Variable names and values object.
|
||||
@@ -101,7 +101,7 @@ const isResolvablePrimitiveType = (type: XOTemplatePrimitiveType | undefined): t
|
||||
*
|
||||
* @param {ResolvablePrimitiveType} type - Method resolvable template type.
|
||||
* @param {string} methodName - Method name from the evaluation text, for example `toSatoshis`.
|
||||
* @returns {boolean} True when that class exposes the named method.
|
||||
* @returns {boolean} - True when that class exposes the named method.
|
||||
*/
|
||||
const canResolvePrimitiveMethod = (type: ResolvablePrimitiveType, methodName: string): boolean => {
|
||||
const PrimitiveClass = RESOLVABLE_PRIMITIVE_CLASS_BY_TYPE[type];
|
||||
@@ -159,7 +159,7 @@ const callPrimitiveMethod = (parameters: CallPrimitiveMethodParameters): unknown
|
||||
* @throws {@link CashAssemblyNumberNotSafeIntegerError} When a method return value is a number that is not a safe integer.
|
||||
*/
|
||||
export const resolvePrimitiveMethodBytes = (parameters: ResolvePrimitiveMethodBytesParameters): Record<string, Uint8Array> => {
|
||||
const { identifiers, templateVariables, variables } = parameters;
|
||||
const { variableNames, templateVariables, variables } = parameters;
|
||||
|
||||
// Without template metadata there is no type to select a primitive class.
|
||||
if (templateVariables === undefined) {
|
||||
@@ -168,14 +168,14 @@ export const resolvePrimitiveMethodBytes = (parameters: ResolvePrimitiveMethodBy
|
||||
|
||||
const resolvedBytes: Record<string, Uint8Array> = {};
|
||||
|
||||
for (const identifier of identifiers) {
|
||||
// The same identifier can appear more than once. Resolve it only once.
|
||||
if (Object.hasOwn(resolvedBytes, identifier) === true) {
|
||||
for (const variableName of variableNames) {
|
||||
// The same name can appear more than once. Resolve it only once.
|
||||
if (Object.hasOwn(resolvedBytes, variableName) === true) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Find the primitive method reference in the identifier.
|
||||
const methodReferenceMatch = identifier.match(CASHASSEMBLY_VARIABLE_METHOD_REFERENCE_PATTERN);
|
||||
// Find the primitive method reference in the name.
|
||||
const methodReferenceMatch = variableName.match(CASHASSEMBLY_VARIABLE_METHOD_REFERENCE_PATTERN);
|
||||
|
||||
if (methodReferenceMatch === null) {
|
||||
continue;
|
||||
@@ -183,7 +183,7 @@ export const resolvePrimitiveMethodBytes = (parameters: ResolvePrimitiveMethodBy
|
||||
|
||||
const [ , baseName, methodName ] = methodReferenceMatch;
|
||||
|
||||
// Unknown identifiers and CashAssembly native operations such as someKey.schnorr_signature.all_outputs
|
||||
// Unknown names and CashAssembly native operations such as someKey.schnorr_signature.all_outputs
|
||||
// must be left for CashAssembly rather than treated as primitive failures.
|
||||
if (Object.hasOwn(templateVariables, baseName) === false) {
|
||||
continue;
|
||||
@@ -198,23 +198,23 @@ export const resolvePrimitiveMethodBytes = (parameters: ResolvePrimitiveMethodBy
|
||||
|
||||
// Method resolvable type with an unknown method should throw.
|
||||
if (canResolvePrimitiveMethod(type, methodName) === false) {
|
||||
throw new CashAssemblyPrimitiveMethodMissingError(identifier, methodName, type);
|
||||
throw new CashAssemblyPrimitiveMethodMissingError(variableName, methodName, type);
|
||||
}
|
||||
|
||||
// If the method is known but the runtime value is missing, throw an error.
|
||||
if (Object.hasOwn(variables, baseName) === false) {
|
||||
throw new CashAssemblyPrimitiveVariableMissingError(identifier, baseName);
|
||||
throw new CashAssemblyPrimitiveVariableMissingError(variableName, baseName);
|
||||
}
|
||||
|
||||
const methodResult = callPrimitiveMethod({
|
||||
identifier,
|
||||
identifier: variableName,
|
||||
methodName,
|
||||
value: variables[baseName],
|
||||
type,
|
||||
});
|
||||
|
||||
// Store the result under identifier.
|
||||
resolvedBytes[identifier] = convertValueToBytes(methodResult, identifier);
|
||||
// CashAssembly looks up the full `base.method` name as WalletData bytes.
|
||||
resolvedBytes[variableName] = convertValueToBytes(methodResult, variableName);
|
||||
}
|
||||
|
||||
return resolvedBytes;
|
||||
|
||||
@@ -0,0 +1,272 @@
|
||||
import {
|
||||
CASHASSEMBLY_BLOCK_COMMENT_END_LENGTH,
|
||||
CASHASSEMBLY_COMMENT_START_LENGTH,
|
||||
CASHASSEMBLY_EVALUATION_START_LENGTH,
|
||||
EMPTY_CASHASSEMBLY_EVALUATION,
|
||||
} from './defaults.ts';
|
||||
import { CashAssemblyBlockCommentUnclosedError, CashAssemblyEvaluationUnclosedError, CashAssemblyQuotedLiteralUnclosedError } from './errors.ts';
|
||||
|
||||
/**
|
||||
* Skips a quoted CashAssembly string so a `)` inside it cannot end `$()`.
|
||||
*
|
||||
* CashAssembly UTF8 literals use `"..."` or `'...'`. Everything between the quotes is payload,
|
||||
* including parentheses.
|
||||
* For example `$(<"hello)world">)` must close after the literal, not at the `)` inside the quotes.
|
||||
*
|
||||
* @param {string} cashAssemblyText - Text that contains the quoted literal.
|
||||
* @param {number} openingQuoteIndex - Index of the opening `"` or `'`.
|
||||
* @returns {number} - Index of the character after the closing quote.
|
||||
* @throws {@link CashAssemblyQuotedLiteralUnclosedError} - When the quoted literal never closes.
|
||||
*/
|
||||
const skipQuotedCashAssemblyLiteral = (cashAssemblyText: string, openingQuoteIndex: number): number => {
|
||||
const quoteCharacter = cashAssemblyText[openingQuoteIndex];
|
||||
let currentIndex = openingQuoteIndex + 1;
|
||||
|
||||
// CashAssembly puts payload `)` inside the quotes. Stop only at the matching quote.
|
||||
while (currentIndex < cashAssemblyText.length) {
|
||||
if (cashAssemblyText[currentIndex] === quoteCharacter) {
|
||||
return currentIndex + 1;
|
||||
}
|
||||
|
||||
currentIndex += 1;
|
||||
}
|
||||
|
||||
throw new CashAssemblyQuotedLiteralUnclosedError(openingQuoteIndex, quoteCharacter, cashAssemblyText);
|
||||
};
|
||||
|
||||
/**
|
||||
* This function skips a `//` comment so a `)` written in the comment cannot end `$()`.
|
||||
* A line comment runs from `//` to the newline, or to the end of the string when there is no newline.
|
||||
* A `)` in that span is comment text, not the closer of `$()`.
|
||||
*
|
||||
* @param {string} cashAssemblyText - Text that contains the line comment.
|
||||
* @param {number} commentStartIndex - Index of the first `/` of `//`.
|
||||
* @returns {number} - Index after the newline that ends the comment, or the end of the string if there is no newline.
|
||||
*/
|
||||
const skipSingleLineCashAssemblyComment = (cashAssemblyText: string, commentStartIndex: number): number => {
|
||||
let currentIndex = commentStartIndex + CASHASSEMBLY_COMMENT_START_LENGTH;
|
||||
|
||||
// CashAssembly line comments hide `)` until the newline. End of string also ends them.
|
||||
while (currentIndex < cashAssemblyText.length) {
|
||||
if (cashAssemblyText[currentIndex] === '\n') {
|
||||
return currentIndex + 1;
|
||||
}
|
||||
|
||||
currentIndex += 1;
|
||||
}
|
||||
|
||||
return currentIndex;
|
||||
};
|
||||
|
||||
/**
|
||||
* This function skips a block comment so a `)` written in the comment cannot end `$()`.
|
||||
* A `)` inside a block comment is comment text, not the closer of `$()`.
|
||||
*
|
||||
* @param {string} cashAssemblyText - Text that contains the block comment.
|
||||
* @param {number} commentStartIndex - Index of the first slash of the block comment opener.
|
||||
* @returns {number} - Index of the character after the comment closer.
|
||||
* @throws {@link CashAssemblyBlockCommentUnclosedError} - When the block comment never closes.
|
||||
*/
|
||||
const skipBlockCashAssemblyComment = (cashAssemblyText: string, commentStartIndex: number): number => {
|
||||
let currentIndex = commentStartIndex + CASHASSEMBLY_COMMENT_START_LENGTH;
|
||||
|
||||
// CashAssembly block comments hide `)` until the closer is found.
|
||||
while (currentIndex + 1 < cashAssemblyText.length) {
|
||||
if (cashAssemblyText[currentIndex] === '*' && cashAssemblyText[currentIndex + 1] === '/') {
|
||||
return currentIndex + CASHASSEMBLY_BLOCK_COMMENT_END_LENGTH;
|
||||
}
|
||||
|
||||
currentIndex += 1;
|
||||
}
|
||||
|
||||
throw new CashAssemblyBlockCommentUnclosedError(commentStartIndex, cashAssemblyText);
|
||||
};
|
||||
|
||||
/**
|
||||
* Parenthesis matching with a depth count.
|
||||
*
|
||||
* Returns the index of the `)` that closes the `$()` starting at `evaluationStartIndex`.
|
||||
* Nested `$()` raise the depth so an inner closer cannot finish the outer evaluation.
|
||||
*
|
||||
* @param {string} cashAssemblyText - Text that contains the evaluation.
|
||||
* @param {number} evaluationStartIndex - Index of the `$` that opens this evaluation.
|
||||
* @returns {number} - Index of the matching closing parenthesis.
|
||||
* @throws {@link CashAssemblyEvaluationUnclosedError} - When the evaluation never closes.
|
||||
* @throws {@link CashAssemblyQuotedLiteralUnclosedError} - When a quoted literal inside the evaluation never closes.
|
||||
* @throws {@link CashAssemblyBlockCommentUnclosedError} - When a block comment inside the evaluation never closes.
|
||||
*/
|
||||
const findCashAssemblyEvaluationCloseIndex = (cashAssemblyText: string, evaluationStartIndex: number): number => {
|
||||
let currentIndex = evaluationStartIndex + CASHASSEMBLY_EVALUATION_START_LENGTH;
|
||||
let remainingOpenEvaluations = 1;
|
||||
|
||||
// Scan the evaluation contents until the matching closer at depth zero.
|
||||
while (currentIndex < cashAssemblyText.length) {
|
||||
const currentCharacter = cashAssemblyText[currentIndex];
|
||||
const nextCharacter = cashAssemblyText[currentIndex + 1];
|
||||
|
||||
// CashAssembly allows `)` inside `"..."` and `'...'`. Skip the literal so that closer is not structural.
|
||||
if (currentCharacter === '"' || currentCharacter === "'") {
|
||||
currentIndex = skipQuotedCashAssemblyLiteral(cashAssemblyText, currentIndex);
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
// CashAssembly `//` comments may mention `)`. Skip them so that `)` is not treated as a closer.
|
||||
if (currentCharacter === '/' && nextCharacter === '/') {
|
||||
currentIndex = skipSingleLineCashAssemblyComment(cashAssemblyText, currentIndex);
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
// CashAssembly block comments may mention `)`. Skip them so that `)` is not treated as a closer.
|
||||
if (currentCharacter === '/' && nextCharacter === '*') {
|
||||
currentIndex = skipBlockCashAssemblyComment(cashAssemblyText, currentIndex);
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
// Nested `$()` must close before this evaluation can close.
|
||||
if (currentCharacter === '$' && nextCharacter === '(') {
|
||||
// Since all previous checks have passed then it means this is start of another evaluation, it we must
|
||||
// increment the remainingOpenEvaluations counter and move the currentIndex to the next character.
|
||||
remainingOpenEvaluations += 1;
|
||||
currentIndex += CASHASSEMBLY_EVALUATION_START_LENGTH;
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
// A closer at depth one ends this evaluation. Inner closers only reduce nested depth.
|
||||
if (currentCharacter === ')') {
|
||||
// Since all previous checks have passed then it means this is a closer for the current evaluation, we must
|
||||
// decrement the remainingOpenEvaluations counter and check if it is now zero. If it is then we have found the
|
||||
// matching closer and we can return the currentIndex.
|
||||
remainingOpenEvaluations -= 1;
|
||||
|
||||
if (remainingOpenEvaluations === 0) {
|
||||
return currentIndex;
|
||||
}
|
||||
}
|
||||
|
||||
// Move the currentIndex to the next character.
|
||||
currentIndex += 1;
|
||||
}
|
||||
|
||||
// The matching closer was never found.
|
||||
throw new CashAssemblyEvaluationUnclosedError(evaluationStartIndex, remainingOpenEvaluations, cashAssemblyText);
|
||||
};
|
||||
|
||||
/**
|
||||
* One `$()` found in text that is not nested inside another `$()`.
|
||||
*/
|
||||
type CashAssemblyEvaluationScanMatch = {
|
||||
|
||||
/**
|
||||
* Complete `$()` text including the opening `$` and the matching closer.
|
||||
*/
|
||||
evaluationText: string;
|
||||
|
||||
/**
|
||||
* Index of the `$` that opens this evaluation.
|
||||
*/
|
||||
startIndex: number;
|
||||
|
||||
/**
|
||||
* Index of the matching closing parenthesis.
|
||||
*/
|
||||
closeIndex: number;
|
||||
};
|
||||
|
||||
/**
|
||||
* Scans text and returns every `$()` that starts in this string, including empty `$()`.
|
||||
*
|
||||
* Nested `$()` stay inside the outer evaluation.
|
||||
*
|
||||
* @param {string} cashAssemblyText - Text that may contain `$()`.
|
||||
* @returns {CashAssemblyEvaluationScanMatch[]} - Evaluations in left to right order.
|
||||
* @throws {@link CashAssemblyEvaluationUnclosedError} - When an evaluation never closes.
|
||||
* @throws {@link CashAssemblyQuotedLiteralUnclosedError} - When a quoted literal never closes.
|
||||
* @throws {@link CashAssemblyBlockCommentUnclosedError} - When a block comment never closes.
|
||||
*/
|
||||
export const scanCashAssemblyEvaluations = (cashAssemblyText: string): CashAssemblyEvaluationScanMatch[] => {
|
||||
const scannedEvaluations: CashAssemblyEvaluationScanMatch[] = [];
|
||||
let currentIndex = 0;
|
||||
|
||||
// Find `$()` that starts in this string. Nested evaluations stay inside the outer evaluation.
|
||||
while (currentIndex < cashAssemblyText.length) {
|
||||
const currentCharacter = cashAssemblyText[currentIndex];
|
||||
const nextCharacter = cashAssemblyText[currentIndex + 1];
|
||||
|
||||
// A `$` that is followed by `(`.
|
||||
if (currentCharacter === '$' && nextCharacter === '(') {
|
||||
// Count parentheses so quotes, comments, and nested `$()` cannot close this evaluation early.
|
||||
const closeIndex = findCashAssemblyEvaluationCloseIndex(cashAssemblyText, currentIndex);
|
||||
// Slice the complete `$()` so later steps do not re-scan these characters.
|
||||
const evaluationText = cashAssemblyText.slice(currentIndex, closeIndex + 1);
|
||||
|
||||
// Store the outer `$()`. Nested evaluations are already inside evaluationText.
|
||||
scannedEvaluations.push({
|
||||
closeIndex,
|
||||
evaluationText,
|
||||
startIndex: currentIndex,
|
||||
});
|
||||
|
||||
// Skip to after this `$()` so nested evaluations are not scanned as their own matches.
|
||||
currentIndex = closeIndex + 1;
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
currentIndex += 1;
|
||||
}
|
||||
|
||||
return scannedEvaluations;
|
||||
};
|
||||
|
||||
/**
|
||||
* This function returns each `$()` that starts in this string.
|
||||
* Empty `$()` is omitted.
|
||||
*
|
||||
* @param {string} cashAssemblyText - Text that may contain `$()`.
|
||||
* @returns {string[]} - Complete non empty evaluation strings in left to right order.
|
||||
* @throws {@link CashAssemblyEvaluationUnclosedError} - When an evaluation never closes.
|
||||
* @throws {@link CashAssemblyQuotedLiteralUnclosedError} - When a quoted literal never closes.
|
||||
* @throws {@link CashAssemblyBlockCommentUnclosedError} - When a block comment never closes.
|
||||
*/
|
||||
export const extractCashAssemblyEvaluations = (cashAssemblyText: string): string[] => {
|
||||
const scannedEvaluations = scanCashAssemblyEvaluations(cashAssemblyText);
|
||||
const evaluations: string[] = [];
|
||||
|
||||
// Omit empty `$()` so callers that compile extracted text do not treat it as an evaluation.
|
||||
for (const scannedEvaluation of scannedEvaluations) {
|
||||
if (scannedEvaluation.evaluationText !== EMPTY_CASHASSEMBLY_EVALUATION) {
|
||||
evaluations.push(scannedEvaluation.evaluationText);
|
||||
}
|
||||
}
|
||||
|
||||
return evaluations;
|
||||
};
|
||||
|
||||
/**
|
||||
* This function returns true when the value is exactly one CashAssembly `$()`.
|
||||
* Text with characters outside `$()`, empty `$()`, or a failed scan returns false.
|
||||
*
|
||||
* @param {unknown} expression - Value to test.
|
||||
* @returns {boolean} - True when the value is a string that is one complete evaluation and nothing else.
|
||||
*/
|
||||
export const isCashAssemblyExpression = (expression: unknown): boolean => {
|
||||
// Numbers and other types are never CashAssembly evaluations.
|
||||
if (typeof expression !== 'string') {
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
const evaluations = extractCashAssemblyEvaluations(expression);
|
||||
|
||||
// If there is only one evaluation extracted and the returned value matches to that of the expression passed in the parameter,
|
||||
// then return true.
|
||||
return evaluations.length === 1 && evaluations[0] === expression;
|
||||
} catch {
|
||||
// A failed scan is not a complete evaluation.
|
||||
return false;
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,108 @@
|
||||
import type { XOInvitationVariableValue, XOTemplate } from '@xo-cash/types';
|
||||
|
||||
/**
|
||||
* Supported decode modes for compiled CashAssembly evaluation bytes.
|
||||
*/
|
||||
export type CompiledCashAssemblyDecodeMode = 'utf8' | 'hex' | 'boolean' | 'bigint' | 'uint8array';
|
||||
|
||||
/**
|
||||
* Template script map passed into CashAssembly compilation so evaluations can resolve bare script ids.
|
||||
*/
|
||||
export type CashAssemblyTemplateScripts = NonNullable<XOTemplate['scripts']>;
|
||||
|
||||
/**
|
||||
* WalletData and template context shared by CashAssembly compilation.
|
||||
*/
|
||||
export type CompileCashAssemblyContext = {
|
||||
|
||||
/**
|
||||
* Runtime WalletData keyed by variable name.
|
||||
* {@link compileCashAssemblySourceToBytes} collects required names with
|
||||
* {@link collectVariablesUsingParseScript} then looks them up here for
|
||||
* primitive method resolution and CashAssembly compilation. A collected name that is absent throws
|
||||
* {@link CashAssemblyRequiredVariableMissingError}. Missing keys are never replaced with defaults.
|
||||
*/
|
||||
variables: Record<string, XOInvitationVariableValue | Uint8Array>;
|
||||
|
||||
/**
|
||||
* Template variable definitions. When provided, each `<name.method>` push whose `hint` maps to
|
||||
* a supported primitive class is resolved to bytes before CashAssembly compilation.
|
||||
*/
|
||||
templateVariables?: XOTemplate['variables'] | undefined;
|
||||
|
||||
/**
|
||||
* Named CashAssembly scripts from {@link XOTemplate.scripts}.
|
||||
* Each key is a script id such as `scriptA`. Each value is that script's source.
|
||||
* Pass this when compilation refers to those ids, for example `$(scriptA)`, `scriptA`, or
|
||||
* `scriptA scriptB OP_ADD`. The compiler looks up those ids here. The visitor collects
|
||||
* WalletData from those sources and from nested ids.
|
||||
* When omitted, named scripts are not given to the compiler and only WalletData pushes in the
|
||||
* starting text are collected.
|
||||
*/
|
||||
templateScripts?: CashAssemblyTemplateScripts | undefined;
|
||||
};
|
||||
|
||||
/**
|
||||
* Parameters for compiling CashAssembly text.
|
||||
*/
|
||||
export type CompileCashAssemblyStringParameters = CompileCashAssemblyContext & {
|
||||
|
||||
/**
|
||||
* Text to compile, which may contain `$()`.
|
||||
*
|
||||
* When this text contains `$()`, each CashAssembly evaluation is compiled and replaced with its
|
||||
* decoded payload. Surrounding text is kept.
|
||||
* When this text contains no `$()`, the entire string is compiled as CashAssembly so push
|
||||
* opcodes remain.
|
||||
*/
|
||||
cashAssemblyText: string;
|
||||
|
||||
/**
|
||||
* The mode to decode compiled bytecode into a string.
|
||||
*/
|
||||
evaluationDecodeMode?: CompiledCashAssemblyDecodeMode;
|
||||
};
|
||||
|
||||
/**
|
||||
* Parameters for compiling one CashAssembly source string to bytecode.
|
||||
*/
|
||||
export type CompileCashAssemblySourceToBytesParameters = CompileCashAssemblyContext & {
|
||||
|
||||
/**
|
||||
* Evaluation or CashAssembly source passed to the compiler as a script id.
|
||||
*/
|
||||
cashAssemblySource: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* Parameters for compiling one CashAssembly source string to decoded text.
|
||||
*/
|
||||
export type CompileCashAssemblySourceToDecodedTextParameters = CompileCashAssemblySourceToBytesParameters & {
|
||||
|
||||
/**
|
||||
* The mode to decode compiled bytecode into a string.
|
||||
*/
|
||||
evaluationDecodeMode: CompiledCashAssemblyDecodeMode;
|
||||
};
|
||||
|
||||
/**
|
||||
* Parameters for preparing a Libauth compiler for CashAssembly source strings.
|
||||
*/
|
||||
export type CompileCashAssemblyEvaluationsParameters = {
|
||||
|
||||
/**
|
||||
* CashAssembly source strings such as `$(<var1>)` or `scriptA`.
|
||||
*/
|
||||
evaluations: string[];
|
||||
|
||||
/**
|
||||
* Optional template scripts so nested ids compile to the template sources.
|
||||
*/
|
||||
templateScripts?: CashAssemblyTemplateScripts | undefined;
|
||||
|
||||
/**
|
||||
* WalletData names already collected with {@link collectVariablesUsingParseScript}.
|
||||
* This function does not collect again.
|
||||
*/
|
||||
variableNames: string[];
|
||||
};
|
||||
@@ -9,8 +9,6 @@ export * from './template/identifier.ts';
|
||||
export * from './template/parser.ts';
|
||||
export * from './template/schemas.ts';
|
||||
export * from './cash-assembly/index.ts';
|
||||
export * from './cash-assembly/errors.ts';
|
||||
export * from './cash-assembly/defaults.ts';
|
||||
|
||||
// Only exporting serializeTemplate as deserializeTemplate is only used internally and parseTemplate should be used instead.
|
||||
export { serializeTemplate } from './template/serialization.ts';
|
||||
|
||||
+819
-24
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user