From c3dd0162d9072b3dbc5eb927e6160314fc129f60 Mon Sep 17 00:00:00 2001 From: Kuldeep Date: Fri, 11 Sep 2026 10:02:20 +0000 Subject: [PATCH] Change implementation strategy of cashassembly, add scripts, comments support and tests --- .cspell.json | 19 +- package-lock.json | 94 +- package.json | 9 +- source/cash-assembly/bytes.ts | 6 +- source/cash-assembly/collect-evaluations.ts | 257 ++++++ source/cash-assembly/defaults.ts | 90 +- source/cash-assembly/errors.ts | 134 ++- source/cash-assembly/evaluations.ts | 434 +++++---- source/cash-assembly/identifier-collisions.ts | 59 ++ source/cash-assembly/index.ts | 8 +- source/cash-assembly/primitive-evaluations.ts | 32 +- source/cash-assembly/scan-evaluations.ts | 272 ++++++ source/cash-assembly/types.ts | 108 +++ source/index.ts | 2 - test/cash-assembly.test.ts | 843 +++++++++++++++++- 15 files changed, 2035 insertions(+), 332 deletions(-) create mode 100644 source/cash-assembly/collect-evaluations.ts create mode 100644 source/cash-assembly/identifier-collisions.ts create mode 100644 source/cash-assembly/scan-evaluations.ts create mode 100644 source/cash-assembly/types.ts diff --git a/.cspell.json b/.cspell.json index ad5d018..6302496 100644 --- a/.cspell.json +++ b/.cspell.json @@ -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"] } diff --git a/package-lock.json b/package-lock.json index eacd769..a3f163f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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" }, diff --git a/package.json b/package.json index 3f6e04c..d7fce66 100644 --- a/package.json +++ b/package.json @@ -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" diff --git a/source/cash-assembly/bytes.ts b/source/cash-assembly/bytes.ts index 30435ca..579773a 100644 --- a/source/cash-assembly/bytes.ts +++ b/source/cash-assembly/bytes.ts @@ -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) { diff --git a/source/cash-assembly/collect-evaluations.ts b/source/cash-assembly/collect-evaluations.ts new file mode 100644 index 0000000..8a3a3ad --- /dev/null +++ b/source/cash-assembly/collect-evaluations.ts @@ -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; + + /** + * Script ids whose sources were already visited. + */ + visitedScriptIdentifiers: ReadonlySet; + + /** + * Pending script ids queued to visit. + */ + scriptIdentifiersToVisit: string[]; + + /** + * WalletData variable names. + */ + variableNames: Set; +}; + +/** + * 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} knownScriptIdentifiers - Script ids present in the template scripts map. + * @param {ReadonlySet} visitedScriptIdentifiers - Script ids whose sources were already visited. + * @param {string[]} scriptIdentifiersToVisit - Pending script ids queued to visit. + */ +const enqueueReachableTemplateScriptIdentifier = ( + scriptIdentifier: string, + knownScriptIdentifiers: ReadonlySet, + visitedScriptIdentifiers: ReadonlySet, + 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 `` 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 `<$( 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(); + const scriptIdentifiersToVisit: string[] = []; + const variableNames = new Set(); + + // 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 ]; +}; diff --git a/source/cash-assembly/defaults.ts b/source/cash-assembly/defaults.ts index a2e520c..6b382a5 100644 --- a/source/cash-assembly/defaults.ts +++ b/source/cash-assembly/defaults.ts @@ -1,61 +1,41 @@ /** - * Detects whether a string is a pure CashAssembly expression. - * - * CashAssembly expressions look like `$()` or `$( )`. 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: - * `$()` matches (a full expression) - * `OP_DUP $()` 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 `$()` or `$( )`. 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 <$()> OP_HASH160 $()"` would return - * `['$()', '$()']`. - */ -export const CASHASSEMBLY_EVALUATION_PATTERN = /\$\([^)]+\)/g; - -/** - * Extracts variable names from angle-bracket references inside a CashAssembly evaluation. - * - * Inside an evaluation like `$( )`, variables are referenced as ``. - * 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 `$( )` 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 = '$()'; diff --git a/source/cash-assembly/errors.ts b/source/cash-assembly/errors.ts index bfeaf62..552eecf 100644 --- a/source/cash-assembly/errors.ts +++ b/source/cash-assembly/errors.ts @@ -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. */ diff --git a/source/cash-assembly/evaluations.ts b/source/cash-assembly/evaluations.ts index 9f64a74..4d1bcc1 100644 --- a/source/cash-assembly/evaluations.ts +++ b/source/cash-assembly/evaluations.ts @@ -7,26 +7,26 @@ * ## Syntax (CashAssembly) * * `` is a push statement. Compiles the contents and pushes the result onto the VM stack. - * `` pushes the 33 byte compressed public key. `<1>` pushes the integer 1. + * For example `` pushes the 33 byte compressed public key and `<1>` pushes the integer 1. * * `$()` is an evaluation. Runs the inner script in the VM and inserts the top stack * item as VM bytecode. - * `$( OP_HASH160)` inserts the HASH160 of the public key. + * For example `$( OP_HASH160)` inserts the HASH160 of the public key. * * `<$()>` 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 <$( 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 `` 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 `$()` or `$()`. - */ - cashAssemblyText: string; - - /** - * Used for both primitive method evaluations and normal CashAssembly compilation. - */ - variables: Record; - - /** - * The mode to decode compiled evaluation bytes into a string. - */ - evaluationDecodeMode?: CompiledCashAssemblyDecodeMode; - - /** - * Optional template variable definitions. When provided, each `` 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 <$()> OP_HASH160 $()"); - * // returns ['$()', '$()'] - */ -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(); - - 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} 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): Uint8Array => { - const variableNames = extractVariablesFromEvaluations([ evaluation ]); +export const generateCashAssemblyBytecode = ( + compiler: CompilerBch, + evaluation: string, + variables: Record, + 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 = {}; - 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., ['$()', '$( )']). - * @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 = {}; + + // 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(); + + 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 = {}; + + // 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 = {}; + 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} 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 `` 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 = {}; - 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; }; diff --git a/source/cash-assembly/identifier-collisions.ts b/source/cash-assembly/identifier-collisions.ts new file mode 100644 index 0000000..184bc8c --- /dev/null +++ b/source/cash-assembly/identifier-collisions.ts @@ -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 = new Set(Object.keys(generateBytecodeMap(OpcodesBchSpec))); + +/** + * Parameters for {@link assertNoIdentifierCollisions} function. + */ +export type AssertNoIdentifierCollisionsParameters = Pick; + +/** + * 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); + } + } +}; diff --git a/source/cash-assembly/index.ts b/source/cash-assembly/index.ts index 39b293b..5bce312 100644 --- a/source/cash-assembly/index.ts +++ b/source/cash-assembly/index.ts @@ -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'; diff --git a/source/cash-assembly/primitive-evaluations.ts b/source/cash-assembly/primitive-evaluations.ts index 1009a7d..a85b927 100644 --- a/source/cash-assembly/primitive-evaluations.ts +++ b/source/cash-assembly/primitive-evaluations.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 => { - 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 = {}; - 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; diff --git a/source/cash-assembly/scan-evaluations.ts b/source/cash-assembly/scan-evaluations.ts new file mode 100644 index 0000000..c3c73f1 --- /dev/null +++ b/source/cash-assembly/scan-evaluations.ts @@ -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; + } +}; diff --git a/source/cash-assembly/types.ts b/source/cash-assembly/types.ts new file mode 100644 index 0000000..50827e2 --- /dev/null +++ b/source/cash-assembly/types.ts @@ -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; + +/** + * 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; + + /** + * Template variable definitions. When provided, each `` 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 `$()` 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[]; +}; diff --git a/source/index.ts b/source/index.ts index c64cbf9..3c809ad 100644 --- a/source/index.ts +++ b/source/index.ts @@ -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'; diff --git a/test/cash-assembly.test.ts b/test/cash-assembly.test.ts index 7f473b7..445e530 100644 --- a/test/cash-assembly.test.ts +++ b/test/cash-assembly.test.ts @@ -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('$(')).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 = [ '$( OP_DIV)', '$( OP_MOD)' ]; +const testCollectVariablesUsingParseScriptDeduplicatesAcrossEvaluations = (): void => { + // Two evaluations share the same WalletData names so collection must not require them twice. + const firstEvaluation = '$( OP_DIV)'; + const secondEvaluation = '$( OP_MOD)'; // requestedTokenAmount and decimalsFactor each appear in both evaluations but are listed once - 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 = '$( <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 = '$( 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: ' OP_ADD', + outerSum: 'nestedSum', + }; + + const compiledSum = compileCashAssemblyString({ + cashAssemblyText: '$(outerSum)', + variables: { + valueA: 3n, + valueB: 4n, + }, + evaluationDecodeMode: 'bigint', + templateScripts, + templateVariables: { + valueA: { name: 'Value A', description: 'first amount', type: 'integer' as const }, + valueB: { name: 'Value B', description: 'second amount', type: 'integer' as const }, + }, + }); + + expect(compiledSum).toBe('7'); +}; + +/** + * Tests that unrelated template scripts are not scanned when resolving a nested script evaluation. + * + * An unused script may push WalletData that would fail if collected. Only reachable scripts are followed. + */ +const testCompileCashAssemblyStringIgnoresUnreachableTemplateScripts = (): void => { + const templateScripts = { + used: '', + unused: '', + }; + + const templateVariables = { + needed: { name: 'Needed', description: 'reachable WalletData', type: 'integer' as const }, + notNeeded: { name: 'Not needed', description: 'only referenced by unused', type: 'integer' as const }, + }; + + // Collection must demand needed and must not demand notNeeded from unused. + expect(collectVariablesUsingParseScript('$(used)', templateScripts)).toEqual([ 'needed' ]); + + const compiled = compileCashAssemblyString({ + cashAssemblyText: '$(used)', + variables: { needed: 1n }, + evaluationDecodeMode: 'bigint', + templateScripts, + templateVariables, + }); + + expect(compiled).toBe('1'); +}; + +/** + * Tests that one CashAssembly evaluation can name several template scripts and still follow nested sources. + * + * WalletData may live only in those nested scripts. Collection must follow every identifier that + * exists in the scripts map, including identifiers that appear beside opcodes and on both OP_IF arms. + * Unrelated scripts in the same map must not be demanded. + */ +const testCompileCashAssemblyStringFollowsMultipleNestedScriptsInOneEvaluation = (): void => { + const templateScripts = { + flag: '', + feePush: '', + withFee: ' feePush OP_ADD', + changePush: '', + minusChange: ' changePush OP_SUB', + unusedLock: '', + }; + + const evaluation = '$(flag OP_IF withFee OP_ELSE minusChange OP_ENDIF)'; + + // Assert collection before compile so unusedLock cannot hide behind a coincidental success. + const collectedNames = collectVariablesUsingParseScript(evaluation, templateScripts); + + expect([ ...collectedNames ].sort()).toEqual([ 'change', 'fee', 'hasFee', 'value' ]); + + // Compile with the fee branch selected. change is still supplied because the unused arm is followed. + const compiledSatoshis = compileCashAssemblyString({ + cashAssemblyText: evaluation, + variables: { + hasFee: 1n, + value: 1000n, + fee: 10n, + change: 0n, + }, + evaluationDecodeMode: 'bigint', + templateScripts, + templateVariables: { + hasFee: { name: 'Has fee', description: '1 includes the fee', type: 'integer' as const }, + value: { name: 'Value', description: 'base amount', type: 'integer' as const }, + fee: { name: 'Fee', description: 'fee satoshis', type: 'integer' as const }, + change: { name: 'Change', description: 'change amount', type: 'integer' as const }, + pubkeyHash: { name: 'Public key hash', description: 'unused lock hash', type: 'bytes' as const }, + }, + }); + + expect(compiledSatoshis).toBe('1010'); +}; + +/** + * Tests that a nested script reached only from the second identifier in an evaluation still requires WalletData. + */ +const testCompileCashAssemblyStringThrowsWhenNestedScriptOfSecondIdentifierMissesTax = (): void => { + const compileWithoutTax = (): string => + compileCashAssemblyString({ + cashAssemblyText: '$(scriptA scriptB OP_ADD)', + variables: { + valueA: 3n, + valueB: 4n, + }, + evaluationDecodeMode: 'bigint', + templateScripts: { + scriptA: '', + nestedB: ' OP_ADD', + scriptB: 'nestedB', + }, + templateVariables: { + valueA: { name: 'Value A', description: 'first amount', type: 'integer' as const }, + valueB: { name: 'Value B', description: 'second amount', type: 'integer' as const }, + tax: { name: 'Tax', description: 'added tax', type: 'integer' as const }, + }, + }); + + // tax appears only under scriptB through nestedB, so following only the first identifier would miss it. + expect(compileWithoutTax).toThrow(CashAssemblyRequiredVariableMissingError); + expect(compileWithoutTax).toThrow('Missing required variable: variableNames [tax]'); +}; + +/** + * Tests that every combination of resolution types claiming one identifier throws an error. + */ +const testCompileCashAssemblyStringThrowsOnIdentifierCollisions = (): void => { + const compileWithVariableAndScript = (): string => + compileCashAssemblyString({ + cashAssemblyText: '$()', + variables: { fee: 99n }, + evaluationDecodeMode: 'bigint', + templateScripts: { fee: '<7>' }, + }); + + expect(compileWithVariableAndScript).toThrow(CashAssemblyIdentifierCollisionError); + expect(compileWithVariableAndScript).toThrow('CashAssembly identifier exists for more than one resolution type: identifier "fee", resolutionTypes [variable, script]'); + + const compileWithOpcodeAndVariable = (): string => + compileCashAssemblyString({ + cashAssemblyText: '$()', + variables: { OP_ADD: 99n }, + evaluationDecodeMode: 'hex', + }); + + expect(compileWithOpcodeAndVariable).toThrow(CashAssemblyIdentifierCollisionError); + expect(compileWithOpcodeAndVariable).toThrow('CashAssembly identifier exists for more than one resolution type: identifier "OP_ADD", resolutionTypes [opcode, variable]'); + + const compileWithOpcodeAndScript = (): string => + compileCashAssemblyString({ + cashAssemblyText: '$( OP_ADD)', + variables: { firstAmount: 2n, secondAmount: 3n }, + evaluationDecodeMode: 'bigint', + templateScripts: { OP_ADD: '<7>' }, + }); + + expect(compileWithOpcodeAndScript).toThrow(CashAssemblyIdentifierCollisionError); + expect(compileWithOpcodeAndScript).toThrow('CashAssembly identifier exists for more than one resolution type: identifier "OP_ADD", resolutionTypes [opcode, script]'); + + const compileWithAllThreeTypes = (): string => + compileCashAssemblyString({ + cashAssemblyText: '$()', + variables: { OP_ADD: 99n }, + evaluationDecodeMode: 'hex', + templateScripts: { OP_ADD: '<7>' }, + }); + + expect(compileWithAllThreeTypes).toThrow(CashAssemblyIdentifierCollisionError); + expect(compileWithAllThreeTypes).toThrow('CashAssembly identifier exists for more than one resolution type: identifier "OP_ADD", resolutionTypes [opcode, variable, script]'); +}; + +/** + * Template scripts used to compare evaluation, script id compile, and concatenated locking source. + */ +const LOCKING_AND_UNLOCKING_TEMPLATE_SCRIPTS = { + enforceEqualBytecodes: 'OP_INPUTINDEX OP_OUTPUTBYTECODE OP_INPUTINDEX OP_UTXOBYTECODE OP_EQUALVERIFY', + enforceEqualCategories: 'OP_INPUTINDEX OP_OUTPUTTOKENCATEGORY OP_INPUTINDEX OP_UTXOTOKENCATEGORY OP_EQUALVERIFY', + enforceEqualSums: + 'OP_INPUTINDEX OP_UTXOTOKENAMOUNT OP_INPUTINDEX OP_UTXOVALUE OP_ADD OP_INPUTINDEX OP_OUTPUTTOKENAMOUNT OP_INPUTINDEX OP_OUTPUTVALUE OP_ADD OP_NUMEQUAL', + lockingBytecode: 'enforceEqualBytecodes enforceEqualCategories enforceEqualSums', + unlockingBytecode: '', +}; + +/** + * Tests that compileCashAssemblyString compiles a script id as provided and keeps push opcodes. + * + * Evaluating `$(unlockingBytecode)` returns only the pushed locking payload. Compiling the + * script id itself keeps the leading push byte that unlocking bytecode requires. + */ +const testCompileCashAssemblyStringKeepsPushOpcodesForUnlockingScript = (): void => { + // Evaluate unlockingBytecode so the payload hex can be compared against a compile that keeps the push. + const evaluatedUnlockingPayloadHex = compileCashAssemblyString({ + cashAssemblyText: '$(unlockingBytecode)', + variables: {}, + evaluationDecodeMode: 'hex', + templateScripts: LOCKING_AND_UNLOCKING_TEMPLATE_SCRIPTS, + templateVariables: {}, + }); + + // Compile the bare script id so the push prefix remains in the bytecode. + const compiledUnlockingBytecodeHex = compileCashAssemblyString({ + cashAssemblyText: 'unlockingBytecode', + variables: {}, + evaluationDecodeMode: 'hex', + templateScripts: LOCKING_AND_UNLOCKING_TEMPLATE_SCRIPTS, + templateVariables: {}, + }); + + // Compiling the script id includes the push length prefix that evaluation drops. + expect(compiledUnlockingBytecodeHex).toBe(`15${evaluatedUnlockingPayloadHex}`); +}; + +/** + * Tests that compileCashAssemblyString compiles concatenated script ids the same as the locking script id. + * + * `lockingBytecode` is defined as those three ids in order. Compiling the source string must match + * compiling the named script. + */ +const testCompileCashAssemblyStringCompilesConcatenatedScriptIdsAsLockingBytecode = (): void => { + // Compile the named locking script so the concatenated source has a known hex result. + const compiledFromLockingScriptId = compileCashAssemblyString({ + cashAssemblyText: 'lockingBytecode', + variables: {}, + evaluationDecodeMode: 'hex', + templateScripts: LOCKING_AND_UNLOCKING_TEMPLATE_SCRIPTS, + templateVariables: {}, + }); + + // Compile the locking source as written to prove a script id is not required. + const compiledFromConcatenatedScriptIds = compileCashAssemblyString({ + cashAssemblyText: 'enforceEqualBytecodes enforceEqualCategories enforceEqualSums', + variables: {}, + evaluationDecodeMode: 'hex', + templateScripts: LOCKING_AND_UNLOCKING_TEMPLATE_SCRIPTS, + templateVariables: {}, + }); + + // Both spellings compile the same locking script source. + expect(compiledFromConcatenatedScriptIds).toBe(compiledFromLockingScriptId); +}; + +/** + * Tests that compileCashAssemblyString throws when a chained fee script needs a missing WalletData push. + */ +const testCompileCashAssemblyStringThrowsWhenChainedFeeScriptMissesRelayFee = (): void => { + const compileWithoutRelayFee = (): string => + compileCashAssemblyString({ + cashAssemblyText: '$(totalFee)', + variables: { + baseFee: 1000n, + }, + evaluationDecodeMode: 'bigint', + templateScripts: { + totalFee: 'baseFeeAndRelay', + baseFeeAndRelay: ' OP_ADD', + }, + templateVariables: { + baseFee: { name: 'Base fee', description: 'base fee satoshis', type: 'integer' as const }, + relayFee: { name: 'Relay fee', description: 'relay fee satoshis', type: 'integer' as const }, + }, + }); + + expect(compileWithoutRelayFee).toThrow(CashAssemblyRequiredVariableMissingError); + expect(compileWithoutRelayFee).toThrow('Missing required variable: variableNames [relayFee]'); +}; + +/** + * Tests that compileCashAssemblyString throws when a label composition script needs a missing suffix. + */ +const testCompileCashAssemblyStringThrowsWhenLabelScriptMissesSuffix = (): void => { + const compileWithoutSuffix = (): string => + compileCashAssemblyString({ + cashAssemblyText: '$(labelScript)', + variables: { + prefix: 'xo', + }, + evaluationDecodeMode: 'utf8', + templateScripts: { + labelScript: '$(joinParts)', + joinParts: ' ', + }, + templateVariables: { + prefix: { name: 'Prefix', description: 'label prefix', type: 'string' as const }, + suffix: { name: 'Suffix', description: 'label suffix', type: 'string' as const }, + }, + }); + + expect(compileWithoutSuffix).toThrow(CashAssemblyRequiredVariableMissingError); + expect(compileWithoutSuffix).toThrow('Missing required variable: variableNames [suffix]'); +}; + +/** + * Tests that compileCashAssemblyString throws when an angle bracket nested script needs a missing hash. + */ +const testCompileCashAssemblyStringThrowsWhenNestedLockScriptMissesPubkeyHash = (): void => { + const compileWithoutPubkeyHash = (): string => + compileCashAssemblyString({ + cashAssemblyText: '$(p2pkhPush)', + variables: {}, + evaluationDecodeMode: 'hex', + templateScripts: { + p2pkhPush: '', + p2pkhLock: 'OP_DUP OP_HASH160 OP_EQUALVERIFY OP_CHECKSIG', + }, + templateVariables: { + pubkeyHash: { name: 'Public key hash', description: 'hash160 of the public key', type: 'bytes' as const }, + }, + }); + + expect(compileWithoutPubkeyHash).toThrow(CashAssemblyRequiredVariableMissingError); + expect(compileWithoutPubkeyHash).toThrow('Missing required variable: variableNames [pubkeyHash]'); +}; + +/** + * Tests that compileCashAssemblyString throws when a multi hop amount script is missing every WalletData push. + */ +const testCompileCashAssemblyStringThrowsWhenScaledAmountScriptMissesAllVariables = (): void => { + const compileWithNoVariables = (): string => + compileCashAssemblyString({ + cashAssemblyText: '$(scaledAmount)', + variables: {}, + evaluationDecodeMode: 'bigint', + templateScripts: { + scaledAmount: 'applyBonus', + applyBonus: 'doublePrincipal', + doublePrincipal: ' <2> OP_MUL OP_ADD', + }, + templateVariables: { + principal: { name: 'Principal', description: 'starting amount', type: 'integer' as const }, + bonus: { name: 'Bonus', description: 'added bonus', type: 'integer' as const }, + }, + }); + + expect(compileWithNoVariables).toThrow(CashAssemblyRequiredVariableMissingError); + expect(compileWithNoVariables).toThrow('Missing required variable: variableNames [principal, bonus]'); +}; + /** * Tests that compileCashAssemblyString defaults to utf8 decoding when evaluationDecodeMode is omitted. */ @@ -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([ '$( OP_HASH256)', '$()', '$()', '$()' ]); - 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 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 = '$()'; - 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 = '$()'; - 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 = '$( 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 <$( 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('$()'); + + // 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: '$()', + 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 $()'; + + // The quoted `)` is payload text. Both evaluations must be returned complete. + expect(extractCashAssemblyEvaluations(mixedLabel)).toStrictEqual([ '$(<"x)y">)', '$()' ]); +}; + +/** + * 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 = '$( $())'; + + // The outer evaluation includes the nested evaluation. + expect(extractCashAssemblyEvaluations(nestedEvaluation)).toStrictEqual([ '$( $())' ]); +}; + +/** + * 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 $('; + + 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('$('); + expect(thrownError.message).toBe('CashAssembly evaluation is unclosed: evaluationStartIndex 5, remainingOpenEvaluations 1, unclosedText "$("'); + } + } +}; + +/** + * 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 $()'; + + // Empty `$()` names no WalletData. Extract must not list it beside the later evaluation. + expect(extractCashAssemblyEvaluations(mixedLabel)).toStrictEqual([ '$()' ]); +}; + +/** + * 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 = `$( + +// Miner fee is pushed next. A closer in this note ) is not the evaluation closer. + +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 = '$( /* ) */ )'; + + // The scan skips the block comment so both pushes stay inside one evaluation. + expect(extractCashAssemblyEvaluations(evaluationWithBlockComment)).toStrictEqual([ '$( /* ) */ )' ]); +}; + +/** + * 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 = '$( /* 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: `$( + +// Miner fee is pushed next. A closer in this note ) is not the evaluation closer. + +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: '$( /* ) */ )', + variables: { fee: 5n }, + evaluationDecodeMode: 'bigint', + }); + + // The `)` inside the block comment is not the evaluation closer. + expect(compiledText).toBe('5'); +}; + const runTests = async (): Promise => { 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 => { 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 => { 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 => { 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();