258 lines
9.6 KiB
TypeScript
258 lines
9.6 KiB
TypeScript
import { describeExpectedInput, parseScript } from '@bitauth/libauth';
|
|
import type { CashAssemblyScriptSegment } from '@bitauth/libauth';
|
|
import { CashAssemblyCompilationFailedError } from './errors.ts';
|
|
import type { CashAssemblyTemplateScripts } from './types.ts';
|
|
|
|
/**
|
|
* Shared state while scanning a parse tree and visiting nested template scripts.
|
|
*/
|
|
type CollectFromParseTreeContext = {
|
|
|
|
/**
|
|
* Script ids present in the template scripts map.
|
|
*/
|
|
knownScriptIdentifiers: ReadonlySet<string>;
|
|
|
|
/**
|
|
* Script ids whose sources were already visited.
|
|
*/
|
|
visitedScriptIdentifiers: ReadonlySet<string>;
|
|
|
|
/**
|
|
* Pending script ids queued to visit.
|
|
*/
|
|
scriptIdentifiersToVisit: string[];
|
|
|
|
/**
|
|
* WalletData variable names.
|
|
*/
|
|
variableNames: Set<string>;
|
|
};
|
|
|
|
/**
|
|
* Parameters for scanning one parsed Script node.
|
|
*/
|
|
type CollectFromParsedScriptParameters = CollectFromParseTreeContext & {
|
|
|
|
/**
|
|
* Parsed Script whose children are Push, Evaluation, Identifier, or literals.
|
|
*/
|
|
scriptSegment: CashAssemblyScriptSegment;
|
|
|
|
/**
|
|
* True when this Script is the value of a Push.
|
|
* Direct Identifier children are WalletData unless they are template script ids.
|
|
*/
|
|
isDirectPushContent: boolean;
|
|
};
|
|
|
|
/**
|
|
* Parameters for handling one Identifier node.
|
|
*/
|
|
type CollectFromParsedIdentifierParameters = CollectFromParseTreeContext & {
|
|
|
|
/**
|
|
* Identifier text from parseScript, which may include `.` and `_`.
|
|
*/
|
|
identifier: string;
|
|
|
|
/**
|
|
* True when this Identifier is a direct child of a Push Script.
|
|
*/
|
|
isDirectPushContent: boolean;
|
|
};
|
|
|
|
/**
|
|
* Enqueues a template script id so its source can be visited when it exists in the scripts map.
|
|
*
|
|
* @param {string} scriptIdentifier - Script id from an evaluation or nested script source.
|
|
* @param {ReadonlySet<string>} knownScriptIdentifiers - Script ids present in the template scripts map.
|
|
* @param {ReadonlySet<string>} visitedScriptIdentifiers - Script ids whose sources were already visited.
|
|
* @param {string[]} scriptIdentifiersToVisit - Pending script ids queued to visit.
|
|
*/
|
|
const enqueueReachableTemplateScriptIdentifier = (
|
|
scriptIdentifier: string,
|
|
knownScriptIdentifiers: ReadonlySet<string>,
|
|
visitedScriptIdentifiers: ReadonlySet<string>,
|
|
scriptIdentifiersToVisit: string[],
|
|
): void => {
|
|
// Return when this id is not a template script.
|
|
if (knownScriptIdentifiers.has(scriptIdentifier) === false) {
|
|
return;
|
|
}
|
|
|
|
// Skip ids whose sources were already visited.
|
|
if (visitedScriptIdentifiers.has(scriptIdentifier) === true) {
|
|
return;
|
|
}
|
|
|
|
// Avoid duplicate queue entries when the same id is referenced more than once.
|
|
if (scriptIdentifiersToVisit.includes(scriptIdentifier) === true) {
|
|
return;
|
|
}
|
|
|
|
// Queue this id so its source is parsed after the current scan finishes.
|
|
scriptIdentifiersToVisit.push(scriptIdentifier);
|
|
};
|
|
|
|
/**
|
|
* Parses CashAssembly source with parseScript and throws when the parse fails.
|
|
*
|
|
* @param {string} cashAssemblyText - Evaluation or script source to parse.
|
|
* @returns {CashAssemblyScriptSegment} - Parsed Script root.
|
|
* @throws {@link CashAssemblyCompilationFailedError} - When parseScript rejects the source.
|
|
*/
|
|
const parseCashAssemblySource = (cashAssemblyText: string): CashAssemblyScriptSegment => {
|
|
// Parse so Push and Identifier nodes are available for collection.
|
|
const parseResult = parseScript(cashAssemblyText);
|
|
|
|
// parseScript returns a union. status false is the failure arm and must not be read as a Script.
|
|
if (parseResult.status === false) {
|
|
// describeExpectedInput turns the expected input list into readable text for the typed error.
|
|
const expectedInputDescription = describeExpectedInput(parseResult.expected);
|
|
|
|
throw new CashAssemblyCompilationFailedError(`${expectedInputDescription} Line ${String(parseResult.index.line)}, column ${String(parseResult.index.column)}.`);
|
|
}
|
|
|
|
return parseResult.value;
|
|
};
|
|
|
|
/**
|
|
* Identifier visitor for the parseScript tree.
|
|
* Records WalletData or queues a nested script from one Identifier node.
|
|
*
|
|
* @param {CollectFromParsedIdentifierParameters} parameters - Identifier text and the parse tree context.
|
|
*/
|
|
const collectFromParsedIdentifier = (parameters: CollectFromParsedIdentifierParameters): void => {
|
|
const { identifier, isDirectPushContent, knownScriptIdentifiers, visitedScriptIdentifiers, scriptIdentifiersToVisit, variableNames } = parameters;
|
|
|
|
// Script ids are visited whether they appear in a Push or beside opcodes.
|
|
if (knownScriptIdentifiers.has(identifier) === true) {
|
|
enqueueReachableTemplateScriptIdentifier(identifier, knownScriptIdentifiers, visitedScriptIdentifiers, scriptIdentifiersToVisit);
|
|
|
|
return;
|
|
}
|
|
|
|
// Direct Push names that are not script ids are WalletData, including dotted names.
|
|
if (isDirectPushContent === true) {
|
|
variableNames.add(identifier);
|
|
}
|
|
};
|
|
|
|
/**
|
|
* Visitor for one Script node in the parseScript tree.
|
|
* Recurses into Push and Evaluation children and dispatches Identifier nodes.
|
|
*
|
|
* @param {CollectFromParsedScriptParameters} parameters - Parsed Script, Push depth flag, and parse tree context.
|
|
*/
|
|
const collectFromParsedScript = (parameters: CollectFromParsedScriptParameters): void => {
|
|
const { scriptSegment, isDirectPushContent, knownScriptIdentifiers, visitedScriptIdentifiers, scriptIdentifiersToVisit, variableNames } =
|
|
parameters;
|
|
|
|
for (const child of scriptSegment.value) {
|
|
if (child.name === 'Push') {
|
|
// Inner Script of a Push is where `<name>` WalletData lives, including nested angle brackets.
|
|
collectFromParsedScript({
|
|
scriptSegment: child.value,
|
|
isDirectPushContent: true,
|
|
knownScriptIdentifiers,
|
|
visitedScriptIdentifiers,
|
|
scriptIdentifiersToVisit,
|
|
variableNames,
|
|
});
|
|
|
|
continue;
|
|
}
|
|
|
|
if (child.name === 'Evaluation') {
|
|
// Evaluation contents are opcodes and nested Pushes, not WalletData by themselves.
|
|
collectFromParsedScript({
|
|
scriptSegment: child.value,
|
|
isDirectPushContent: false,
|
|
knownScriptIdentifiers,
|
|
visitedScriptIdentifiers,
|
|
scriptIdentifiersToVisit,
|
|
variableNames,
|
|
});
|
|
|
|
continue;
|
|
}
|
|
|
|
if (child.name === 'Identifier') {
|
|
collectFromParsedIdentifier({
|
|
identifier: child.value,
|
|
isDirectPushContent,
|
|
knownScriptIdentifiers,
|
|
visitedScriptIdentifiers,
|
|
scriptIdentifiersToVisit,
|
|
variableNames,
|
|
});
|
|
}
|
|
}
|
|
};
|
|
|
|
/**
|
|
* Collects WalletData names from the parseScript tree using the visitor pattern.
|
|
*
|
|
* parseScript returns a tree of Script, Push, Evaluation, and Identifier nodes. Nested Push and
|
|
* Evaluation nodes are entered immediately. A template script id is queued whether it appears inside a Push or next to
|
|
* opcodes. Queued script sources are visited after the current tree finishes, in the order they were
|
|
* first seen.
|
|
* A visited id is skipped on later encounters. Opcodes and literals are ignored. Unrelated scripts
|
|
* in the map are not visited. An Identifier inside a nested Push is collected from that inner Push,
|
|
* for example `<$(<someKey.public_key> OP_HASH160)>` collects `someKey.public_key`.
|
|
*
|
|
* @param {string} evaluation - CashAssembly evaluation or script id text to scan.
|
|
* @param {CashAssemblyTemplateScripts} [templateScripts] - Optional template scripts map used to visit nested script ids.
|
|
* @returns {string[]} - WalletData names from reachable Pushes. Script ids are omitted when
|
|
* `templateScripts` is provided.
|
|
* @throws {@link CashAssemblyCompilationFailedError} - When parseScript rejects the starting text or a visited script source.
|
|
*/
|
|
export const collectVariablesUsingParseScript = (evaluation: string, templateScripts: CashAssemblyTemplateScripts = {}): string[] => {
|
|
// Create a set of the script identifiers from the template scripts map.
|
|
const knownScriptIdentifiers = new Set(Object.keys(templateScripts));
|
|
|
|
const visitedScriptIdentifiers = new Set<string>();
|
|
const scriptIdentifiersToVisit: string[] = [];
|
|
const variableNames = new Set<string>();
|
|
|
|
// Share by reference context.
|
|
const parseTreeContext: CollectFromParseTreeContext = {
|
|
knownScriptIdentifiers,
|
|
visitedScriptIdentifiers,
|
|
scriptIdentifiersToVisit,
|
|
variableNames,
|
|
};
|
|
|
|
// Parse the starting text so nested Pushes are visible before visiting nested script sources.
|
|
const startingScriptSegment = parseCashAssemblySource(evaluation);
|
|
|
|
// Scan the starting parse tree before visiting nested script sources.
|
|
collectFromParsedScript({
|
|
...parseTreeContext,
|
|
scriptSegment: startingScriptSegment,
|
|
isDirectPushContent: false,
|
|
});
|
|
|
|
for (const scriptIdentifier of scriptIdentifiersToVisit) {
|
|
if (visitedScriptIdentifiers.has(scriptIdentifier) === true) {
|
|
continue;
|
|
}
|
|
|
|
visitedScriptIdentifiers.add(scriptIdentifier);
|
|
|
|
// Get the script definition from the template scripts map.
|
|
const scriptDefinition = templateScripts[scriptIdentifier];
|
|
|
|
const nestedScriptSegment = parseCashAssemblySource(scriptDefinition);
|
|
|
|
collectFromParsedScript({
|
|
...parseTreeContext,
|
|
scriptSegment: nestedScriptSegment,
|
|
isDirectPushContent: false,
|
|
});
|
|
}
|
|
|
|
return [ ...variableNames ];
|
|
};
|