From d682149273da419b92b5c7d8d3bfbec82fc9132d Mon Sep 17 00:00:00 2001 From: Rosco Kalis Date: Tue, 30 Jun 2026 12:16:38 +0200 Subject: [PATCH 1/6] Add SDK debugging support for user-defined functions --- packages/cashc/src/ast/AST.ts | 4 ++ packages/cashc/src/compiler.ts | 1 + packages/cashc/src/dependency-resolution.ts | 10 +++- .../src/generation/GenerateTargetTraversal.ts | 19 ++++++- packages/cashc/test/generation/fixtures.ts | 14 +++++ packages/cashscript/src/Errors.ts | 28 ++++++---- packages/cashscript/src/debug-frame.ts | 43 +++++++++++++++ packages/cashscript/src/debugging.ts | 53 +++++++++++-------- .../cashscript/src/libauth-template/utils.ts | 4 +- packages/cashscript/test/debugging.test.ts | 47 ++++++++++++++++ .../fixture/debugging/debugging_contracts.ts | 20 ++++++- .../fixture/debugging/function_helpers.cash | 3 ++ .../fixture/debugging/function_importer.cash | 8 +++ packages/utils/src/artifact.ts | 12 +++++ 14 files changed, 229 insertions(+), 37 deletions(-) create mode 100644 packages/cashscript/src/debug-frame.ts create mode 100644 packages/cashscript/test/fixture/debugging/function_helpers.cash create mode 100644 packages/cashscript/test/fixture/debugging/function_importer.cash diff --git a/packages/cashc/src/ast/AST.ts b/packages/cashc/src/ast/AST.ts index 8d097023..fd43eca0 100644 --- a/packages/cashc/src/ast/AST.ts +++ b/packages/cashc/src/ast/AST.ts @@ -75,6 +75,10 @@ export class FunctionDefinitionNode extends Node implements Named { symbolTable?: SymbolTable; opRolls: Map = new Map(); + // Source provenance for debugging. Set on imported functions, left undefined for functions in the contract's own file. + sourceCode?: string; + sourceFile?: string; + constructor( public kind: FunctionKind, public name: string, diff --git a/packages/cashc/src/compiler.ts b/packages/cashc/src/compiler.ts index efdb9859..04a16425 100644 --- a/packages/cashc/src/compiler.ts +++ b/packages/cashc/src/compiler.ts @@ -99,6 +99,7 @@ export function compileString(code: string, compilerOptions: CompileOptions = {} logs: optimisationResult.logs, requires: optimisationResult.requires, ...(sourceTags ? { sourceTags } : {}), + ...(traversal.frames.length > 0 ? { functions: traversal.frames } : {}), }; const fingerprint = computeBytecodeFingerprintWithConstructorArgs(optimisationResult.script, constructorParamLength); diff --git a/packages/cashc/src/dependency-resolution.ts b/packages/cashc/src/dependency-resolution.ts index c182d509..7a0246b2 100644 --- a/packages/cashc/src/dependency-resolution.ts +++ b/packages/cashc/src/dependency-resolution.ts @@ -36,7 +36,15 @@ function collectImports( if (visitedPaths.has(absolutePath)) return []; visitedPaths.add(absolutePath); - const importedAst = parseCode(readImportedFile(importNode, absolutePath), options.errorListener); + const importedSource = readImportedFile(importNode, absolutePath); + const importedAst = parseCode(importedSource, options.errorListener); + + // Record source provenance so debug frames can attribute to the imported file + importedAst.functions.forEach((func) => { + func.sourceCode = importedSource; + func.sourceFile = path.basename(absolutePath); + }); + return [...collect(importedAst.imports, path.dirname(absolutePath)), ...importedAst.functions]; }); diff --git a/packages/cashc/src/generation/GenerateTargetTraversal.ts b/packages/cashc/src/generation/GenerateTargetTraversal.ts index 73e7e5e2..52829fd5 100644 --- a/packages/cashc/src/generation/GenerateTargetTraversal.ts +++ b/packages/cashc/src/generation/GenerateTargetTraversal.ts @@ -1,4 +1,4 @@ -import { hexToBin } from '@bitauth/libauth'; +import { binToHex, hexToBin } from '@bitauth/libauth'; import { asmToScript, encodeBool, @@ -13,6 +13,7 @@ import { optimiseBytecode, generateSourceMap, FullLocationData, + DebugFrame, LogEntry, RequireStatement, PositionHint, @@ -77,6 +78,7 @@ export default class GenerateTargetTraversal extends AstTraversal { consoleLogs: LogEntry[] = []; requires: RequireStatement[] = []; sourceTags: SourceTagEntry[] = []; + frames: DebugFrame[] = []; finalStackUsage: Record = {}; private scopeDepth = 0; @@ -183,7 +185,20 @@ export default class GenerateTargetTraversal extends AstTraversal { 0, ); - return scriptToBytecode(optimised.script); + const bodyBytecode = scriptToBytecode(optimised.script); + + this.frames.push({ + name: node.name, + inputs: node.parameters.map((parameter) => ({ name: parameter.name, type: parameter.type.toString() })), + bytecode: binToHex(bodyBytecode), + sourceMap: generateSourceMap(optimised.locationData), + logs: optimised.logs, + requires: optimised.requires, + ...(node.sourceCode !== undefined ? { source: node.sourceCode } : {}), + ...(node.sourceFile !== undefined ? { sourceFile: node.sourceFile } : {}), + }); + + return bodyBytecode; } cleanGlobalFunctionStack(node: FunctionDefinitionNode): void { diff --git a/packages/cashc/test/generation/fixtures.ts b/packages/cashc/test/generation/fixtures.ts index a8a6c2ec..6eea287c 100644 --- a/packages/cashc/test/generation/fixtures.ts +++ b/packages/cashc/test/generation/fixtures.ts @@ -1427,6 +1427,9 @@ export const fixtures: Fixture[] = [ { ip: 7, line: 7 }, ], sourceMap: '1::3:1;;::::1;7:16:7:25;;:29::30:0;:8::32:1', + functions: [ + { name: 'double', inputs: [{ name: 'a', type: 'int' }], bytecode: '5295', sourceMap: '2:15:2:16;:11:::1', logs: [], requires: [] }, + ], }, source: fs.readFileSync(new URL('../valid-contract-files/global_function_simple.cash', import.meta.url), { encoding: 'utf-8' }), compiler: { @@ -1461,6 +1464,9 @@ export const fixtures: Fixture[] = [ { ip: 8, line: 7 }, ], sourceMap: '1::3:1;;::::1;7:23:7:24:0;:16::25:1;;:29::30:0;:8::32:1', + functions: [ + { name: 'sub', inputs: [{ name: 'a', type: 'int' }, { name: 'b', type: 'int' }], bytecode: '94', sourceMap: '2:11:2:16:1', logs: [], requires: [] }, + ], }, source: fs.readFileSync(new URL('../valid-contract-files/global_function_multi_param.cash', import.meta.url), { encoding: 'utf-8' }), compiler: { @@ -1494,6 +1500,9 @@ export const fixtures: Fixture[] = [ { ip: 8, line: 8 }, ], sourceMap: '1::3:1;;::::1;7:24:7:25:0;:8::26:1;;8:20:8:23:0;:8::25:1', + functions: [ + { name: 'requirePositive', inputs: [{ name: 'a', type: 'int' }], bytecode: '00a069', sourceMap: '2:16:2:17;:12:::1;:4::19', logs: [], requires: [{ ip: 2, line: 2 }] }, + ], }, source: fs.readFileSync(new URL('../valid-contract-files/global_function_void.cash', import.meta.url), { encoding: 'utf-8' }), compiler: { @@ -1533,6 +1542,11 @@ export const fixtures: Fixture[] = [ { ip: 18, line: 6 }, ], sourceMap: '2::4:1;;::::1;1::3::0;;::::1;2::4::0;;::::1;6:19:6:20:0;:16::21:1;;:27::28:0;:24::29:1;;:16;:33::35:0;:8::37:1', + functions: [ + { name: 'm1', inputs: [{ name: 'a', type: 'int' }], bytecode: '518a5295', sourceMap: '3:11:3:18:1;;:21::22:0;:11:::1', logs: [], requires: [], source: fs.readFileSync(new URL('../import-fixtures/mid1.cash', import.meta.url), { encoding: 'utf-8' }), sourceFile: 'mid1.cash' }, + { name: 'leaf', inputs: [{ name: 'a', type: 'int' }], bytecode: '8b', sourceMap: '2:11:2:16:1', logs: [], requires: [], source: fs.readFileSync(new URL('../import-fixtures/leaf.cash', import.meta.url), { encoding: 'utf-8' }), sourceFile: 'leaf.cash' }, + { name: 'm2', inputs: [{ name: 'a', type: 'int' }], bytecode: '518a5393', sourceMap: '3:11:3:18:1;;:21::22:0;:11:::1', logs: [], requires: [], source: fs.readFileSync(new URL('../import-fixtures/mid2.cash', import.meta.url), { encoding: 'utf-8' }), sourceFile: 'mid2.cash' }, + ], }, source: fs.readFileSync(new URL('../import-fixtures/diamond.cash', import.meta.url), { encoding: 'utf-8' }), compiler: { diff --git a/packages/cashscript/src/Errors.ts b/packages/cashscript/src/Errors.ts index d23e45f9..c64a18a5 100644 --- a/packages/cashscript/src/Errors.ts +++ b/packages/cashscript/src/Errors.ts @@ -1,4 +1,5 @@ import { Artifact, RequireStatement, sourceMapToLocationData, Type } from '@cashscript/utils'; +import { ResolvedFrame, rootFrame } from './debug-frame.js'; export class TypeError extends Error { constructor(actual: string, expected: Type) { @@ -134,12 +135,14 @@ export class FailedTransactionEvaluationError extends FailedTransactionError { public inputIndex: number, public bitauthUri: string, public libauthErrorMessage: string, + frame?: ResolvedFrame, ) { let message = `${artifact.contractName}.cash Error in transaction at input ${inputIndex} in contract ${artifact.contractName}.cash.\nReason: ${libauthErrorMessage}`; if (artifact.debug) { - const { statement, lineNumber } = getLocationDataForInstructionPointer(artifact, failingInstructionPointer); - message = `${artifact.contractName}.cash:${lineNumber} Error in transaction at input ${inputIndex} in contract ${artifact.contractName}.cash at line ${lineNumber}.\nReason: ${libauthErrorMessage}\nFailing statement: ${statement}`; + const resolvedFrame = frame ?? rootFrame(artifact); + const { statement, lineNumber } = getLocationDataForFrame(resolvedFrame, failingInstructionPointer); + message = `${resolvedFrame.sourceName}:${lineNumber} Error in transaction at input ${inputIndex} in contract ${artifact.contractName}.cash at line ${lineNumber}.\nReason: ${libauthErrorMessage}\nFailing statement: ${statement}`; } super(message, bitauthUri); @@ -154,10 +157,12 @@ export class FailedRequireError extends FailedTransactionError { public inputIndex: number, public bitauthUri: string, public libauthErrorMessage?: string, + frame?: ResolvedFrame, ) { - const { statement, lineNumber } = getLocationDataForInstructionPointer(artifact, failingInstructionPointer); + const resolvedFrame = frame ?? rootFrame(artifact); + const { statement, lineNumber } = getLocationDataForFrame(resolvedFrame, failingInstructionPointer); - const baseMessage = `${artifact.contractName}.cash:${lineNumber} Require statement failed at input ${inputIndex} in contract ${artifact.contractName}.cash at line ${lineNumber}`; + const baseMessage = `${resolvedFrame.sourceName}:${lineNumber} Require statement failed at input ${inputIndex} in contract ${artifact.contractName}.cash at line ${lineNumber}`; const baseMessageWithRequireMessage = `${baseMessage} with the following message: ${requireStatement.message}`; const headline = `${requireStatement.message ? baseMessageWithRequireMessage : baseMessage}.`; @@ -169,19 +174,20 @@ export class FailedRequireError extends FailedTransactionError { } } -const getLocationDataForInstructionPointer = ( - artifact: Artifact, +const getLocationDataForFrame = ( + frame: ResolvedFrame, instructionPointer: number, ): { lineNumber: number, statement: string } => { - const locationData = sourceMapToLocationData(artifact.debug!.sourceMap); + const locationData = sourceMapToLocationData(frame.sourceMap); - // We subtract the constructor inputs because these are present in the evaluation (and thus the instruction pointer) - // but they are not present in the source code (and thus the location data) - const modifiedInstructionPointer = instructionPointer - artifact.constructorInputs.length; + // We subtract the frame's ip offset (the constructor-arg prefix for the root frame, 0 for helper + // frames) because those pushes are present in the evaluation (and thus the instruction pointer) but + // not in the source code (and thus the location data). + const modifiedInstructionPointer = instructionPointer - frame.ipOffset; const { location } = locationData[modifiedInstructionPointer]; - const failingLines = artifact.source.split('\n').slice(location.start.line - 1, location.end.line); + const failingLines = frame.source.split('\n').slice(location.start.line - 1, location.end.line); // Slice off the start and end of the statement's start and end lines to only return the failing part // Note that we first slice off the end, to avoid shifting the end column index diff --git a/packages/cashscript/src/debug-frame.ts b/packages/cashscript/src/debug-frame.ts new file mode 100644 index 00000000..1b0ae2ef --- /dev/null +++ b/packages/cashscript/src/debug-frame.ts @@ -0,0 +1,43 @@ +import { AuthenticationProgramStateCommon, binToHex, encodeAuthenticationInstructions } from '@bitauth/libauth'; +import { Artifact, LogEntry, RequireStatement } from '@cashscript/utils'; + +export interface ResolvedFrame { + sourceMap: string; + source: string; + sourceName: string; + ipOffset: number; + requires: readonly RequireStatement[]; + logs: readonly LogEntry[]; +} + +export const rootFrame = (artifact: Artifact): ResolvedFrame => ({ + sourceMap: artifact.debug?.sourceMap ?? '', + source: artifact.source, + sourceName: `${artifact.contractName}.cash`, + ipOffset: artifact.constructorInputs.length, + requires: artifact.debug?.requires ?? [], + logs: artifact.debug?.logs ?? [], +}); + +export const getActiveBytecode = (step: AuthenticationProgramStateCommon): string => + binToHex(encodeAuthenticationInstructions(step.instructions)); + +export const resolveFrame = ( + artifact: Artifact, + step: AuthenticationProgramStateCommon, +): ResolvedFrame => { + const frames = artifact.debug?.functions ?? []; + const activeBytecode = frames.length > 0 ? getActiveBytecode(step) : undefined; + const frame = frames.find((candidate) => candidate.bytecode === activeBytecode); + + if (!frame) return rootFrame(artifact); + + return { + sourceMap: frame.sourceMap, + source: frame.source ?? artifact.source, + sourceName: frame.sourceFile ?? `${artifact.contractName}.cash`, + ipOffset: 0, // function bodies have no constructor-arg prefix; their ips start at 0 + requires: frame.requires, + logs: frame.logs, + }; +}; diff --git a/packages/cashscript/src/debugging.ts b/packages/cashscript/src/debugging.ts index d5bf3991..cbbeb4bc 100644 --- a/packages/cashscript/src/debugging.ts +++ b/packages/cashscript/src/debugging.ts @@ -2,6 +2,7 @@ import { AuthenticationErrorCommon, AuthenticationInstruction, AuthenticationPro import { Artifact, LogData, LogEntry, Op, PrimitiveType, StackItem, asmToBytecode, bytecodeToAsm, decodeBool, decodeInt, decodeString } from '@cashscript/utils'; import { findLastIndex, toRegExp } from './utils.js'; import { FailedRequireError, FailedTransactionError, FailedTransactionEvaluationError } from './Errors.js'; +import { getActiveBytecode, resolveFrame } from './debug-frame.js'; import { getBitauthUri } from './libauth-template/LibauthTemplate.js'; import { VmTarget } from './interfaces.js'; @@ -72,7 +73,11 @@ const debugSingleScenario = ( // P2SH executions have 3 phases, we only want the last one (locking script execution) // https://libauth.org/types/AuthenticationVirtualMachine.html#__type.debug - const lockingScriptDebugResult = fullDebugSteps.slice(findLastIndex(fullDebugSteps, (state) => state.ip === 0)); + // We additionally require an empty control stack: an invoked function body (OP_INVOKE) also starts at + // ip 0, but always with a saved return frame on the control stack. + const lockingScriptDebugResult = fullDebugSteps.slice( + findLastIndex(fullDebugSteps, (state) => state.ip === 0 && state.controlStack.length === 0), + ); // The controlStack determines whether the current debug step is in the executed branch // It also tracks loop / function usage, but for the purpose of determining whether a step was executed, @@ -84,26 +89,29 @@ const debugSingleScenario = ( // P2PKH inputs do not have an artifact, so we skip the console.log handling if (artifact) { - // Try to match each executed debug step to a log entry if it exists. Note that inside loops, - // the same log statement may be executed multiple times in different debug steps - // Also note that multiple log statements may exist for the same ip, so we need to handle all of them + // Try to match each executed debug step to a log entry if it exists. Notes: + // - inside loops, the same log statement may be executed multiple times in different debug steps + // - the same ip may be executed by multiple function frames, so they are matched against the active frame's logs. + // - multiple log statements may exist for the same ip, so we need to handle all of them. const executedLogs = executedDebugSteps .flatMap((debugStep, index) => { - const logEntries = artifact.debug?.logs?.filter((log) => log.ip === debugStep.ip); - if (!logEntries || logEntries.length === 0) return []; + const frame = resolveFrame(artifact, debugStep); + const logEntries = frame.logs.filter((log) => log.ip === debugStep.ip); + if (logEntries.length === 0) return []; const reversedPriorDebugSteps = executedDebugSteps.slice(0, index + 1).reverse(); + const frameBytecode = getActiveBytecode(debugStep); return logEntries.map((logEntry) => { const decodedLogData = logEntry.data - .map((dataEntry) => decodeLogDataEntry(dataEntry, reversedPriorDebugSteps, vm)); - return { logEntry, decodedLogData }; + .map((dataEntry) => decodeLogDataEntry(dataEntry, reversedPriorDebugSteps, vm, frameBytecode)); + return { logEntry, decodedLogData, sourceName: frame.sourceName }; }); }); - for (const { logEntry, decodedLogData } of executedLogs) { + for (const { logEntry, decodedLogData, sourceName } of executedLogs) { const inputIndex = extractInputIndexFromScenario(scenarioId); - logConsoleLogStatement(logEntry, decodedLogData, artifact.contractName, inputIndex); + logConsoleLogStatement(logEntry, decodedLogData, sourceName, inputIndex); } } @@ -135,19 +143,19 @@ const debugSingleScenario = ( throw new FailedTransactionError(error, getBitauthUri(template)); } - const requireStatement = (artifact.debug?.requires ?? []) - .find((statement) => statement.ip === requireStatementIp); + const frame = resolveFrame(artifact, lastExecutedDebugStep); + const requireStatement = frame.requires.find((statement) => statement.ip === requireStatementIp); if (requireStatement) { // Note that we use failingIp here rather than requireStatementIp, see comment above throw new FailedRequireError( - artifact, failingIp, requireStatement, inputIndex, getBitauthUri(template), error, + artifact, failingIp, requireStatement, inputIndex, getBitauthUri(template), error, frame, ); } // Note that we use failingIp here rather than requireStatementIp, see comment above throw new FailedTransactionEvaluationError( - artifact, failingIp, inputIndex, getBitauthUri(template), error, + artifact, failingIp, inputIndex, getBitauthUri(template), error, frame, ); } @@ -175,17 +183,17 @@ const debugSingleScenario = ( throw new FailedTransactionError(evaluationResult, getBitauthUri(template)); } - const requireStatement = (artifact.debug?.requires ?? []) - .find((message) => message.ip === finalExecutedVerifyIp); + const frame = resolveFrame(artifact, lastExecutedDebugStep); + const requireStatement = frame.requires.find((message) => message.ip === finalExecutedVerifyIp); if (requireStatement) { throw new FailedRequireError( - artifact, sourcemapInstructionPointer, requireStatement, inputIndex, getBitauthUri(template), + artifact, sourcemapInstructionPointer, requireStatement, inputIndex, getBitauthUri(template), undefined, frame, ); } throw new FailedTransactionEvaluationError( - artifact, sourcemapInstructionPointer, inputIndex, getBitauthUri(template), evaluationResult, + artifact, sourcemapInstructionPointer, inputIndex, getBitauthUri(template), evaluationResult, frame, ); } @@ -236,20 +244,23 @@ const createProgram = (template: WalletTemplate, unlockingScriptId: string, scen const logConsoleLogStatement = ( log: LogEntry, decodedLogData: Array, - contractName: string, + sourceName: string, inputIndex: number, ): void => { - console.log(`[Input #${inputIndex}] ${contractName}.cash:${log.line} ${decodedLogData.join(' ')}`); + console.log(`[Input #${inputIndex}] ${sourceName}:${log.line} ${decodedLogData.join(' ')}`); }; const decodeLogDataEntry = ( dataEntry: LogData, reversedPriorDebugSteps: AuthenticationProgramStateCommon[], vm: VM, + frameBytecode: string, ): string | bigint | boolean => { if (typeof dataEntry === 'string') return dataEntry; - const dataEntryDebugStep = reversedPriorDebugSteps.find((step) => step.ip === dataEntry.ip); + const dataEntryDebugStep = reversedPriorDebugSteps.find( + (step) => step.ip === dataEntry.ip && getActiveBytecode(step) === frameBytecode, + ); if (!dataEntryDebugStep) { throw new Error(`Should not happen: corresponding data entry debug step not found for entry at ip ${dataEntry.ip}`); diff --git a/packages/cashscript/src/libauth-template/utils.ts b/packages/cashscript/src/libauth-template/utils.ts index df817f3a..ae131f62 100644 --- a/packages/cashscript/src/libauth-template/utils.ts +++ b/packages/cashscript/src/libauth-template/utils.ts @@ -77,7 +77,9 @@ export const formatParametersForDebugging = (types: readonly AbiInput[], args: E }; export const formatBytecodeForDebugging = (artifact: Artifact): string => { - if (!artifact.debug) { + // Render the bytecode in true execution order when there is no debug info, or when the contract uses + // user-defined functions (TODO: fix source mapping for functions). + if (!artifact.debug || (artifact.debug.functions?.length ?? 0) > 0) { return artifact.bytecode .split(' ') .map((asmElement) => (isHex(asmElement) ? `<0x${asmElement}>` : asmElement)) diff --git a/packages/cashscript/test/debugging.test.ts b/packages/cashscript/test/debugging.test.ts index 441afcab..8f6cf602 100644 --- a/packages/cashscript/test/debugging.test.ts +++ b/packages/cashscript/test/debugging.test.ts @@ -14,6 +14,8 @@ import { artifactTestZeroHandling, artifactTestRequireInsideLoop, artifactTestLogInsideLoop, + artifactTestFunctionDebugging, + artifactTestImportedFunctionDebugging, } from './fixture/debugging/debugging_contracts.js'; import { sha256 } from '@cashscript/utils'; @@ -814,3 +816,48 @@ describe('VM Resources', () => { expect(vmUsage[2]?.hashDigestIterations).toBeGreaterThan(0); }); }); + +describe('Debugging tests - user-defined function frames', () => { + const provider = new MockNetworkProvider(); + + const contract = new Contract(artifactTestFunctionDebugging, [], { provider }); + const contractUtxo = provider.addUtxo(contract.address, randomUtxo()); + + const importedContract = new Contract(artifactTestImportedFunctionDebugging, [], { provider }); + const importedUtxo = provider.addUtxo(importedContract.address, randomUtxo()); + + it('attributes a console.log inside a function to the function source line', () => { + const transaction = new TransactionBuilder({ provider }) + .addInput(contractUtxo, contract.unlock.spend(5n)) + .addOutput({ to: contract.address, amount: 10000n }); + + expect(transaction).toLog(new RegExp('^\\[Input #0] Test.cash:3 checking 5$')); + }); + + it('attributes a require failing inside a function to the function source line', () => { + const transaction = new TransactionBuilder({ provider }) + .addInput(contractUtxo, contract.unlock.spend(0n)) + .addOutput({ to: contract.address, amount: 10000n }); + + expect(transaction).toFailRequireWith('Test.cash:4 Require statement failed at input 0 in contract Test.cash at line 4 with the following message: value must be positive.'); + expect(transaction).toFailRequireWith('Failing statement: require(value > 0, "value must be positive")'); + }); + + it('still attributes a contract-level require to the contract source line', () => { + const transaction = new TransactionBuilder({ provider }) + .addInput(contractUtxo, contract.unlock.spend(100n)) + .addOutput({ to: contract.address, amount: 10000n }); + + expect(transaction).toFailRequireWith('Test.cash:10 Require statement failed at input 0 in contract Test.cash at line 10 with the following message: x must be small.'); + expect(transaction).toFailRequireWith('Failing statement: require(x < 100, "x must be small")'); + }); + + it('attributes a require failing inside an imported function to the imported file', () => { + const transaction = new TransactionBuilder({ provider }) + .addInput(importedUtxo, importedContract.unlock.spend(0n)) + .addOutput({ to: importedContract.address, amount: 10000n }); + + expect(transaction).toFailRequireWith('function_helpers.cash:2 Require statement failed at input 0 in contract Test.cash at line 2 with the following message: value must be positive.'); + expect(transaction).toFailRequireWith('Failing statement: require(value > 0, "value must be positive")'); + }); +}); diff --git a/packages/cashscript/test/fixture/debugging/debugging_contracts.ts b/packages/cashscript/test/fixture/debugging/debugging_contracts.ts index be048ef2..0bc7cd84 100644 --- a/packages/cashscript/test/fixture/debugging/debugging_contracts.ts +++ b/packages/cashscript/test/fixture/debugging/debugging_contracts.ts @@ -1,4 +1,18 @@ -import { compileString } from 'cashc'; +import { compileFile, compileString } from 'cashc'; + +const CONTRACT_TEST_FUNCTION_DEBUGGING = ` +function checkValue(int value) { + console.log("checking", value); + require(value > 0, "value must be positive"); +} + +contract Test() { + function spend(int x) { + checkValue(x); + require(x < 100, "x must be small"); + } +} +`; const CONTRACT_TEST_REQUIRES = ` contract Test() { @@ -437,3 +451,7 @@ export const artifactTestMultipleLogs = compileString(CONTRACT_TEST_MULTIPLE_LOG export const artifactTestMultipleConstructorParameters = compileString(CONTRACT_TEST_MULTIPLE_CONSTRUCTOR_PARAMETERS); export const artifactTestRequireInsideLoop = compileString(CONTRACT_TEST_REQUIRE_INSIDE_LOOP); export const artifactTestLogInsideLoop = compileString(CONTRACT_TEST_LOG_INSIDE_LOOP); +export const artifactTestFunctionDebugging = compileString(CONTRACT_TEST_FUNCTION_DEBUGGING); + +// Compiled from a file so the imported function (function_helpers.cash) keeps its own source provenance. +export const artifactTestImportedFunctionDebugging = compileFile(new URL('./function_importer.cash', import.meta.url)); diff --git a/packages/cashscript/test/fixture/debugging/function_helpers.cash b/packages/cashscript/test/fixture/debugging/function_helpers.cash new file mode 100644 index 00000000..f031f25c --- /dev/null +++ b/packages/cashscript/test/fixture/debugging/function_helpers.cash @@ -0,0 +1,3 @@ +function assertPositive(int value) { + require(value > 0, "value must be positive"); +} diff --git a/packages/cashscript/test/fixture/debugging/function_importer.cash b/packages/cashscript/test/fixture/debugging/function_importer.cash new file mode 100644 index 00000000..eac2e4b4 --- /dev/null +++ b/packages/cashscript/test/fixture/debugging/function_importer.cash @@ -0,0 +1,8 @@ +import "./function_helpers.cash"; + +contract Test() { + function spend(int x) { + assertPositive(x); + require(x < 100, "x must be small"); + } +} diff --git a/packages/utils/src/artifact.ts b/packages/utils/src/artifact.ts index 733a6761..9cf2e699 100644 --- a/packages/utils/src/artifact.ts +++ b/packages/utils/src/artifact.ts @@ -19,6 +19,18 @@ export interface DebugInformation { logs: readonly LogEntry[]; // log entries generated from `console.log` statements requires: readonly RequireStatement[]; // messages for failing `require` statements sourceTags?: string; // semantic tags for opcodes (e.g. loop update/condition ranges) + functions?: readonly DebugFrame[]; // Debug metadata for each user-defined function +} + +export interface DebugFrame { + name: string; // the function's name + inputs: readonly AbiInput[]; // the function's parameters (name and type), mirroring the ABI; for reference + bytecode: string; // hex of the function body bytecode (exactly what OP_DEFINE stores and the VM runs) + sourceMap: string; // frame-local source map (ips starting from 0) + logs: readonly LogEntry[]; // frame-local log entries + requires: readonly RequireStatement[]; // frame-local require statements + source?: string; // body source text; absent means the function lives in the contract's own source file + sourceFile?: string; // originating file name for imported functions; absent means the contract's file } export interface LogEntry { From fd5f877c7d999db6041cb5ae02c2731f04c1e518 Mon Sep 17 00:00:00 2001 From: Rosco Kalis Date: Thu, 2 Jul 2026 10:17:22 +0200 Subject: [PATCH 2/6] Fix file+function name for require failures from imported functions --- packages/cashscript/src/Errors.ts | 14 ++++++++++++-- packages/cashscript/src/debug-frame.ts | 2 ++ packages/cashscript/test/debugging.test.ts | 2 +- 3 files changed, 15 insertions(+), 3 deletions(-) diff --git a/packages/cashscript/src/Errors.ts b/packages/cashscript/src/Errors.ts index c64a18a5..85740c71 100644 --- a/packages/cashscript/src/Errors.ts +++ b/packages/cashscript/src/Errors.ts @@ -142,7 +142,8 @@ export class FailedTransactionEvaluationError extends FailedTransactionError { if (artifact.debug) { const resolvedFrame = frame ?? rootFrame(artifact); const { statement, lineNumber } = getLocationDataForFrame(resolvedFrame, failingInstructionPointer); - message = `${resolvedFrame.sourceName}:${lineNumber} Error in transaction at input ${inputIndex} in contract ${artifact.contractName}.cash at line ${lineNumber}.\nReason: ${libauthErrorMessage}\nFailing statement: ${statement}`; + const context = formatFrameContext(resolvedFrame, artifact.contractName, lineNumber); + message = `${resolvedFrame.sourceName}:${lineNumber} Error in transaction at input ${inputIndex} ${context}.\nReason: ${libauthErrorMessage}\nFailing statement: ${statement}`; } super(message, bitauthUri); @@ -161,8 +162,9 @@ export class FailedRequireError extends FailedTransactionError { ) { const resolvedFrame = frame ?? rootFrame(artifact); const { statement, lineNumber } = getLocationDataForFrame(resolvedFrame, failingInstructionPointer); + const context = formatFrameContext(resolvedFrame, artifact.contractName, lineNumber); - const baseMessage = `${resolvedFrame.sourceName}:${lineNumber} Require statement failed at input ${inputIndex} in contract ${artifact.contractName}.cash at line ${lineNumber}`; + const baseMessage = `${resolvedFrame.sourceName}:${lineNumber} Require statement failed at input ${inputIndex} ${context}`; const baseMessageWithRequireMessage = `${baseMessage} with the following message: ${requireStatement.message}`; const headline = `${requireStatement.message ? baseMessageWithRequireMessage : baseMessage}.`; @@ -174,6 +176,14 @@ export class FailedRequireError extends FailedTransactionError { } } +const formatFrameContext = (frame: ResolvedFrame, contractName: string, lineNumber: number): string => { + if (frame.functionName) { + return `in contract ${contractName}, function ${frame.functionName} (${frame.sourceName}, line ${lineNumber})`; + } + + return `in contract ${contractName}.cash at line ${lineNumber}`; +}; + const getLocationDataForFrame = ( frame: ResolvedFrame, instructionPointer: number, diff --git a/packages/cashscript/src/debug-frame.ts b/packages/cashscript/src/debug-frame.ts index 1b0ae2ef..5ec61c83 100644 --- a/packages/cashscript/src/debug-frame.ts +++ b/packages/cashscript/src/debug-frame.ts @@ -8,6 +8,7 @@ export interface ResolvedFrame { ipOffset: number; requires: readonly RequireStatement[]; logs: readonly LogEntry[]; + functionName?: string; } export const rootFrame = (artifact: Artifact): ResolvedFrame => ({ @@ -39,5 +40,6 @@ export const resolveFrame = ( ipOffset: 0, // function bodies have no constructor-arg prefix; their ips start at 0 requires: frame.requires, logs: frame.logs, + ...(frame.sourceFile !== undefined ? { functionName: frame.name } : {}), }; }; diff --git a/packages/cashscript/test/debugging.test.ts b/packages/cashscript/test/debugging.test.ts index 8f6cf602..f56d532d 100644 --- a/packages/cashscript/test/debugging.test.ts +++ b/packages/cashscript/test/debugging.test.ts @@ -857,7 +857,7 @@ describe('Debugging tests - user-defined function frames', () => { .addInput(importedUtxo, importedContract.unlock.spend(0n)) .addOutput({ to: importedContract.address, amount: 10000n }); - expect(transaction).toFailRequireWith('function_helpers.cash:2 Require statement failed at input 0 in contract Test.cash at line 2 with the following message: value must be positive.'); + expect(transaction).toFailRequireWith('function_helpers.cash:2 Require statement failed at input 0 in contract Test, function assertPositive (function_helpers.cash, line 2) with the following message: value must be positive.'); expect(transaction).toFailRequireWith('Failing statement: require(value > 0, "value must be positive")'); }); }); From e694ce7ef9d4ba665e391ae8cef4c92617d19952 Mon Sep 17 00:00:00 2001 From: Rosco Kalis Date: Thu, 2 Jul 2026 10:39:21 +0200 Subject: [PATCH 3/6] Add extra test case for intermediate result logging inside functions --- packages/cashscript/test/debugging.test.ts | 13 +++++++++++++ .../fixture/debugging/debugging_contracts.ts | 16 ++++++++++++++++ 2 files changed, 29 insertions(+) diff --git a/packages/cashscript/test/debugging.test.ts b/packages/cashscript/test/debugging.test.ts index f56d532d..016aeb33 100644 --- a/packages/cashscript/test/debugging.test.ts +++ b/packages/cashscript/test/debugging.test.ts @@ -15,6 +15,7 @@ import { artifactTestRequireInsideLoop, artifactTestLogInsideLoop, artifactTestFunctionDebugging, + artifactTestFunctionIntermediateResults, artifactTestImportedFunctionDebugging, } from './fixture/debugging/debugging_contracts.js'; import { sha256 } from '@cashscript/utils'; @@ -860,4 +861,16 @@ describe('Debugging tests - user-defined function frames', () => { expect(transaction).toFailRequireWith('function_helpers.cash:2 Require statement failed at input 0 in contract Test, function assertPositive (function_helpers.cash, line 2) with the following message: value must be positive.'); expect(transaction).toFailRequireWith('Failing statement: require(value > 0, "value must be positive")'); }); + + it('logs intermediate results that get optimised out inside a function', () => { + const intermediateContract = new Contract(artifactTestFunctionIntermediateResults, [alicePub], { provider }); + const intermediateUtxo = provider.addUtxo(intermediateContract.address, randomUtxo()); + + const transaction = new TransactionBuilder({ provider }) + .addInput(intermediateUtxo, intermediateContract.unlock.spend()) + .addOutput({ to: intermediateContract.address, amount: 10000n }); + + const expectedHash = binToHex(sha256(alicePub)); + expect(transaction).toLog(new RegExp(`^\\[Input #0] Test.cash:4 0x${expectedHash}$`)); + }); }); diff --git a/packages/cashscript/test/fixture/debugging/debugging_contracts.ts b/packages/cashscript/test/fixture/debugging/debugging_contracts.ts index 0bc7cd84..a54f7604 100644 --- a/packages/cashscript/test/fixture/debugging/debugging_contracts.ts +++ b/packages/cashscript/test/fixture/debugging/debugging_contracts.ts @@ -14,6 +14,21 @@ contract Test() { } `; +const CONTRACT_TEST_FUNCTION_INTERMEDIATE_RESULTS = ` +function hashTwice(pubkey pk) returns (bytes32) { + bytes32 singleHash = sha256(pk); + console.log(singleHash); + bytes32 doubleHash = sha256(singleHash); + return doubleHash; +} + +contract Test(pubkey owner) { + function spend() { + require(hashTwice(owner).length == 32, "should be 32 bytes"); + } +} +`; + const CONTRACT_TEST_REQUIRES = ` contract Test() { function test_logs() { @@ -452,6 +467,7 @@ export const artifactTestMultipleConstructorParameters = compileString(CONTRACT_ export const artifactTestRequireInsideLoop = compileString(CONTRACT_TEST_REQUIRE_INSIDE_LOOP); export const artifactTestLogInsideLoop = compileString(CONTRACT_TEST_LOG_INSIDE_LOOP); export const artifactTestFunctionDebugging = compileString(CONTRACT_TEST_FUNCTION_DEBUGGING); +export const artifactTestFunctionIntermediateResults = compileString(CONTRACT_TEST_FUNCTION_INTERMEDIATE_RESULTS); // Compiled from a file so the imported function (function_helpers.cash) keeps its own source provenance. export const artifactTestImportedFunctionDebugging = compileFile(new URL('./function_importer.cash', import.meta.url)); From 4c5f5f0623033482234806694a864d46c200ba07 Mon Sep 17 00:00:00 2001 From: Rosco Kalis Date: Thu, 2 Jul 2026 12:24:12 +0200 Subject: [PATCH 4/6] WIP --- .cspell.json | 3 + functions-todos.md | 165 ++++++++ .../src/generation/GenerateTargetTraversal.ts | 8 +- packages/cashc/test/generation/fixtures.ts | 66 ++- .../cashscript/src/libauth-template/utils.ts | 14 +- packages/cashscript/test/debugging.test.ts | 29 +- packages/utils/src/artifact.ts | 6 +- packages/utils/src/bitauth-script.ts | 391 ++++++++++++++---- packages/utils/test/bitauth-script.test.ts | 69 +++- .../test/fixtures/bitauth-script.fixture.ts | 158 +++++++ .../fixtures/function-imports/helpers.cash | 9 + .../fixtures/function-imports/importer.cash | 8 + 12 files changed, 814 insertions(+), 112 deletions(-) create mode 100644 functions-todos.md create mode 100644 packages/utils/test/fixtures/function-imports/helpers.cash create mode 100644 packages/utils/test/fixtures/function-imports/importer.cash diff --git a/.cspell.json b/.cspell.json index 56f78ca2..b38edf2f 100644 --- a/.cspell.json +++ b/.cspell.json @@ -48,6 +48,9 @@ "chipnet", "cleanstack", "cleanup", + "cleanups", + "remappings", + "codegen", "cryptocurrency", "collateralized", "datasig", diff --git a/functions-todos.md b/functions-todos.md new file mode 100644 index 00000000..72348e31 --- /dev/null +++ b/functions-todos.md @@ -0,0 +1,165 @@ +# User-defined functions — roadmap to a stable mainline release + +Status of the current branch (`v0.14.0-next.0`, compiler-only): top-level standalone +functions, single-value-or-void returns, grammar-native **relative** imports, always +`OP_DEFINE`/`OP_INVOKE` (no inlining), one flat global namespace. No SDK debugging, no +safety gates, no optimization passes for functions. + +This document lists what to close before calling the feature stable, drawing on the two +prototype branches (`feat/library-support`, `review/bch-functions-next`) and on gaps that +**all three** prototypes missed. + +Legend: _(port: branch)_ = adapt an existing prototype implementation · _(novel)_ = not +addressed by any prototype. + +--- + +## Tier 1 — correctness & debuggability (blockers for "stable") + +### 1. Frame-aware SDK debugging _(port: `review/bch-functions-next`)_ — **Done** +A `require` that fails inside a function, and `console.log` in a function body, now attribute to the +correct source line (and file, for imported functions). Implemented as self-contained frames rather +than the prototype's flat-and-stamped model: +- Artifact: `DebugInformation.functions?: DebugFrame[]` (`packages/utils/src/artifact.ts`), one + self-contained `{ name, inputs, bytecode, sourceMap, logs, requires, source?, sourceFile? }` per + function with frame-local ips. The top-level fields stay the implicit root frame, so function-less + contracts are + byte-identical and old artifacts keep working. The compiler just stops discarding the body's + already-computed optimised debug info (`compileGlobalFunctionBody`). +- SDK: `resolveFrame`/`getActiveBytecode` (`debugging.ts`) identify the active frame per step by + matching `state.instructions` bytecode (WeakMap-cached), then the existing ip attribution runs against + that frame's arrays; `getLocationDataForFrame` + a `ResolvedFrame` (`Errors.ts`) apply the constructor + offset only for the root. Phase detection now keys on `ip === 0 && controlStack.length === 0`. +- Imports get per-file source provenance via `FunctionDefinitionNode.sourceCode/sourceFile`, stamped in + `dependency-resolution.ts` (no text-level line remapping needed, unlike the prototype). +- Deviation from the proposal: skipped `assertDistinctHelperFrameBytecode`. Two functions with + byte-identical bodies are rare but valid, and a hard compile error for a debug-only concern is the + wrong trade; attribution degrades gracefully to the first matching frame instead. +- Tested in `debugging.test.ts` (require-in-function, log-in-function, contract-level require still + correct, imported-function require attributing to the imported file). + +### 2. BitAuth IDE source mapping with functions — **Done** +The template previously fell back to plain unmapped asm when frames were present. `formatBitAuthScript` +(`packages/utils/src/bitauth-script.ts`) was rewritten to walk the script in **bytecode order** (instead of +grouping opcodes by source line), so opcode order preservation — required because the IDE *executes* the +formatted text — is now structural rather than dependent on monotonic source maps. Function definitions +render as nested `<...>` BTL push groups (byte-identical via `encodeDataPush` on both sides) annotated with +the function's own source, sliced to just that function via the new `DebugFrame.location`; imported +functions get a `>>> function f (imported from file.cash)` header row. `DebugFrame` also gained +`sourceTags` (previously computed and discarded) so loop/cleanup `>>>` annotations render inside bodies. +Old artifacts degrade gracefully (frames without `location` render as an opaque `<0x...>` push; artifacts +without `debug` keep the plain-asm fallback). Function-less output is byte-identical to before (pinned +fixtures unchanged). Tested in `packages/utils/test/bitauth-script.test.ts` (function fixtures + a BTL +round-trip byte-equality check on every fixture) and `debugging.test.ts` (template text assertions). + +--- + +## Tier 2 — expressiveness (port/adapt from prototypes) + +### 4. Multi-value / tuple returns _(port: `feat/library-support`)_ +`returns (int, int)` + `return a, b`. Common need (return a value plus a derived check); we +intentionally cut it to a single value. + +### 5. Global `constant` definitions _(port: `feat/library-support`)_ +Compile-time-folded top-level constants, shareable via imports. A frequent request +independent of functions, and a natural companion to them. + +### 6. `library {}` containers + namespaced / aliased imports _(port: both branches)_ +`import "./math.cash" as Math;` + `Math.double(x)`. Solves the **single flat global +namespace** limitation we shipped (today a name may exist only once across the whole import +graph — collisions are unavoidable at ecosystem scale). Libraries also give a unit to +publish and audit. + +### 8. Size-gated inlining for tiny bodies _(port: `feat/library-support`, ≤6 bytes)_ +For trivial helpers the `OP_DEFINE`/`OP_INVOKE` overhead exceeds the body; inline those. +Pure size/cost optimization, safe to add later since it doesn't change semantics. (Note: +inlining reintroduces the recursion-expansion problem, so it must be gated to +non-recursive functions — keep the always-`OP_INVOKE` path for recursive ones.) + +--- + +## Tier 3 — optimization & ecosystem + +### 9. Pluggable import resolver _(novel for us; hook exists in `review/bch-functions-next`)_ +Confirmed gap: `dependency-resolution.ts` calls `fs.readFileSync` directly and +`CompileOptions` exposes only `errorListener`/`basePath`. Let `compileString` resolve +imports via a caller-supplied `(path) => source` callback and/or a virtual file map. +Required for the **Playground / any browser compile** to use imports at all — today it's +Node-fs-only. + +### 10. Registry / package / URL imports + remappings _(novel — all prototypes are relative-path only)_ +No prototype has a notion of importing from a published package or a remapped alias +(cf. Solidity `node_modules` / remappings). For a real contract ecosystem this is how +shared, versioned, audited code circulates. Depends on #9. + +--- + +## Code quality revisits + +Non-blocking internal cleanups to revisit before a stable release. No behaviour change intended. + +### `optimiseBytecode` signature +`optimiseBytecode` (`packages/utils/src/script.ts`) takes seven positional parameters +(`script`, `locationData`, `logs`, `requires`, `sourceTags`, `constructorParamLength`, `runs`), four +of which are parallel debug arrays passed positionally at both call sites (`compiler.ts` and +`GenerateTargetTraversal.compileGlobalFunctionBody`). Verbose and easy to get wrong. Collapse the +debug arguments into a single object (or an options arg). + +### `ensureSingleTailReturn` +`ensureSingleTailReturn` in `packages/cashc/src/semantic/EnsureFinalRequireTraversal.ts` carries a +self described "a bit convoluted" TODO (final-statement check plus a recursive `findReturn` stray +scan). Tidy it up. Likely rewritten when early/conditional returns land (see the Extension section). + +### Inject locktime guard analysis +`InjectLocktimeGuardTraversal` (`packages/cashc/src/semantic/InjectLocktimeGuardTraversal.ts`) uses a +second traversal class (`LocktimeGuardRequirementAnalyser`) constructed per function with a callback, +plus a memoised map with seed-false cycle breaking. Revisit whether the two-traversal plus callback +structure can be simplified. + +--- + +## Extension (post-mainline) — early / conditional (nested & multiple) return statements + +Distinct from #4 (multi-**value** returns): this is about multiple/nested return *statements* +— early returns, guard clauses, branch-specific returns — i.e. control flow, not the number +of returned values. **Not targeted for the mainline release** — neither the structured subset +nor the loop/general case. Captured here as a future extension. Today a value-returning +function must end with exactly one tail `return` +(`packages/cashc/src/semantic/EnsureFinalRequireTraversal.ts`, `ensureSingleTailReturn`, which +already carries a TODO anticipating this work). + +**Core constraint.** The BCH VM has no return/jump opcode: a function returns only by running +off the end of its body (`opInvoke` pushes the caller frame onto the control stack; the +`continue` handler pops it when `ip >= instructions.length`). All control flow is structured +(`OP_IF`/`OP_ENDIF`, `OP_BEGIN`/`OP_UNTIL`) — there is no "jump to function end." So `return` +is not a primitive; every non-tail return must be compiled away by restructuring the body so +that all paths converge to leaving the value on top at the end. + +Two sub-parts, very different cost: + +- **Structured returns (tail + guard clauses)** — e.g. `if (a > 10) { return 10; } return a;`. + Semantically `a > 10 ? 10 : a`; a guard clause hoists the remaining statements into the else + branch, and nested tail returns recurse into nested if/else. Compiles to the same bytecode as + the hand-written single-`result`-variable equivalent — ~zero extra cost. The "every path + terminates correctly" analysis already exists in the analogous `ensureFinalStatementIsRequire` + branch recursion for contract `require`s. +- **Loop / fully-general returns** — e.g. a `return` inside a `for` that must break the loop + *and* the function. No structured transform expresses "break to function end"; needs a runtime + `returned` flag OR'd into the loop condition plus guarding every subsequent statement with + `if (!returned)`. Extra opcodes + a memory slot, pressing on the 100-slot control-stack / + op-cost budget. Viable but expensive. + +**Implementation approach.** Do it as an AST **normalization-to-single-exit** pass *before* +codegen (reduce a multi-return body to one tail return / convergent if/else), so +`GenerateTargetTraversal` and the existing single-exit `cleanStack` / +`removeFinalVerifyFromFunction` keep working unchanged. Emitting unstructured returns directly +would instead force fragile per-return-site stack cleanup. Add the supporting analysis: +definite-return on all paths, unreachable-code-after-return, and return-type consistency across +all return sites (scaffolding partly present: `MissingReturnError`, `MisplacedReturnError`). + +**Suggested phasing if/when picked up:** structured returns first (normalization + path +analysis, disallow `return` inside loops with a clear error), then loop/general returns via +flag-guarding only if there is demand. + + +Also perhaps it makes sense to have a separate importedSources so that multiple functions from the same source don't have to repeat the full source code multiple times. diff --git a/packages/cashc/src/generation/GenerateTargetTraversal.ts b/packages/cashc/src/generation/GenerateTargetTraversal.ts index 52829fd5..5e6eca88 100644 --- a/packages/cashc/src/generation/GenerateTargetTraversal.ts +++ b/packages/cashc/src/generation/GenerateTargetTraversal.ts @@ -12,6 +12,7 @@ import { scriptToBytecode, optimiseBytecode, generateSourceMap, + generateSourceTags, FullLocationData, DebugFrame, LogEntry, @@ -186,16 +187,19 @@ export default class GenerateTargetTraversal extends AstTraversal { ); const bodyBytecode = scriptToBytecode(optimised.script); + const sourceTags = generateSourceTags(optimised.sourceTags); this.frames.push({ name: node.name, inputs: node.parameters.map((parameter) => ({ name: parameter.name, type: parameter.type.toString() })), bytecode: binToHex(bodyBytecode), sourceMap: generateSourceMap(optimised.locationData), - logs: optimised.logs, - requires: optimised.requires, + ...(sourceTags ? { sourceTags } : {}), + location: generateSourceMap([{ location: node.location, positionHint: PositionHint.START }]), ...(node.sourceCode !== undefined ? { source: node.sourceCode } : {}), ...(node.sourceFile !== undefined ? { sourceFile: node.sourceFile } : {}), + logs: optimised.logs, + requires: optimised.requires, }); return bodyBytecode; diff --git a/packages/cashc/test/generation/fixtures.ts b/packages/cashc/test/generation/fixtures.ts index 6eea287c..def63805 100644 --- a/packages/cashc/test/generation/fixtures.ts +++ b/packages/cashc/test/generation/fixtures.ts @@ -1428,7 +1428,15 @@ export const fixtures: Fixture[] = [ ], sourceMap: '1::3:1;;::::1;7:16:7:25;;:29::30:0;:8::32:1', functions: [ - { name: 'double', inputs: [{ name: 'a', type: 'int' }], bytecode: '5295', sourceMap: '2:15:2:16;:11:::1', logs: [], requires: [] }, + { + name: 'double', + inputs: [{ name: 'a', type: 'int' }], + bytecode: '5295', + sourceMap: '2:15:2:16;:11:::1', + logs: [], + requires: [], + location: '1::3:1', + }, ], }, source: fs.readFileSync(new URL('../valid-contract-files/global_function_simple.cash', import.meta.url), { encoding: 'utf-8' }), @@ -1465,7 +1473,15 @@ export const fixtures: Fixture[] = [ ], sourceMap: '1::3:1;;::::1;7:23:7:24:0;:16::25:1;;:29::30:0;:8::32:1', functions: [ - { name: 'sub', inputs: [{ name: 'a', type: 'int' }, { name: 'b', type: 'int' }], bytecode: '94', sourceMap: '2:11:2:16:1', logs: [], requires: [] }, + { + name: 'sub', + inputs: [{ name: 'a', type: 'int' }, { name: 'b', type: 'int' }], + bytecode: '94', + sourceMap: '2:11:2:16:1', + logs: [], + requires: [], + location: '1::3:1', + }, ], }, source: fs.readFileSync(new URL('../valid-contract-files/global_function_multi_param.cash', import.meta.url), { encoding: 'utf-8' }), @@ -1501,7 +1517,15 @@ export const fixtures: Fixture[] = [ ], sourceMap: '1::3:1;;::::1;7:24:7:25:0;:8::26:1;;8:20:8:23:0;:8::25:1', functions: [ - { name: 'requirePositive', inputs: [{ name: 'a', type: 'int' }], bytecode: '00a069', sourceMap: '2:16:2:17;:12:::1;:4::19', logs: [], requires: [{ ip: 2, line: 2 }] }, + { + name: 'requirePositive', + inputs: [{ name: 'a', type: 'int' }], + bytecode: '00a069', + sourceMap: '2:16:2:17;:12:::1;:4::19', + logs: [], + requires: [{ ip: 2, line: 2 }], + location: '1::3:1', + }, ], }, source: fs.readFileSync(new URL('../valid-contract-files/global_function_void.cash', import.meta.url), { encoding: 'utf-8' }), @@ -1543,9 +1567,39 @@ export const fixtures: Fixture[] = [ ], sourceMap: '2::4:1;;::::1;1::3::0;;::::1;2::4::0;;::::1;6:19:6:20:0;:16::21:1;;:27::28:0;:24::29:1;;:16;:33::35:0;:8::37:1', functions: [ - { name: 'm1', inputs: [{ name: 'a', type: 'int' }], bytecode: '518a5295', sourceMap: '3:11:3:18:1;;:21::22:0;:11:::1', logs: [], requires: [], source: fs.readFileSync(new URL('../import-fixtures/mid1.cash', import.meta.url), { encoding: 'utf-8' }), sourceFile: 'mid1.cash' }, - { name: 'leaf', inputs: [{ name: 'a', type: 'int' }], bytecode: '8b', sourceMap: '2:11:2:16:1', logs: [], requires: [], source: fs.readFileSync(new URL('../import-fixtures/leaf.cash', import.meta.url), { encoding: 'utf-8' }), sourceFile: 'leaf.cash' }, - { name: 'm2', inputs: [{ name: 'a', type: 'int' }], bytecode: '518a5393', sourceMap: '3:11:3:18:1;;:21::22:0;:11:::1', logs: [], requires: [], source: fs.readFileSync(new URL('../import-fixtures/mid2.cash', import.meta.url), { encoding: 'utf-8' }), sourceFile: 'mid2.cash' }, + { + name: 'm1', + inputs: [{ name: 'a', type: 'int' }], + bytecode: '518a5295', + sourceMap: '3:11:3:18:1;;:21::22:0;:11:::1', + logs: [], + requires: [], + location: '2::4:1', + source: fs.readFileSync(new URL('../import-fixtures/mid1.cash', import.meta.url), { encoding: 'utf-8' }), + sourceFile: 'mid1.cash', + }, + { + name: 'leaf', + inputs: [{ name: 'a', type: 'int' }], + bytecode: '8b', + sourceMap: '2:11:2:16:1', + logs: [], + requires: [], + location: '1::3:1', + source: fs.readFileSync(new URL('../import-fixtures/leaf.cash', import.meta.url), { encoding: 'utf-8' }), + sourceFile: 'leaf.cash', + }, + { + name: 'm2', + inputs: [{ name: 'a', type: 'int' }], + bytecode: '518a5393', + sourceMap: '3:11:3:18:1;;:21::22:0;:11:::1', + logs: [], + requires: [], + location: '2::4:1', + source: fs.readFileSync(new URL('../import-fixtures/mid2.cash', import.meta.url), { encoding: 'utf-8' }), + sourceFile: 'mid2.cash', + }, ], }, source: fs.readFileSync(new URL('../import-fixtures/diamond.cash', import.meta.url), { encoding: 'utf-8' }), diff --git a/packages/cashscript/src/libauth-template/utils.ts b/packages/cashscript/src/libauth-template/utils.ts index ae131f62..a987e61d 100644 --- a/packages/cashscript/src/libauth-template/utils.ts +++ b/packages/cashscript/src/libauth-template/utils.ts @@ -1,4 +1,4 @@ -import { AbiFunction, AbiInput, Artifact, bytecodeToScript, formatBitAuthScript, sha256 } from '@cashscript/utils'; +import { AbiFunction, AbiInput, Artifact, formatBitAuthScript, sha256 } from '@cashscript/utils'; import { HashType, LibauthTokenDetails, SignatureAlgorithm, TokenDetails, VmTarget } from '../interfaces.js'; import { hexToBin, binToHex, isHex, decodeCashAddress, Input, assertSuccess, decodeAuthenticationInstructions, AuthenticationInstructionPush } from '@bitauth/libauth'; import { EncodedFunctionArgument } from '../Argument.js'; @@ -77,21 +77,15 @@ export const formatParametersForDebugging = (types: readonly AbiInput[], args: E }; export const formatBytecodeForDebugging = (artifact: Artifact): string => { - // Render the bytecode in true execution order when there is no debug info, or when the contract uses - // user-defined functions (TODO: fix source mapping for functions). - if (!artifact.debug || (artifact.debug.functions?.length ?? 0) > 0) { + // Old artifacts carry no debug information, so we render the raw bytecode in execution order + if (!artifact.debug) { return artifact.bytecode .split(' ') .map((asmElement) => (isHex(asmElement) ? `<0x${asmElement}>` : asmElement)) .join('\n'); } - return formatBitAuthScript( - bytecodeToScript(hexToBin(artifact.debug.bytecode)), - artifact.debug.sourceMap, - artifact.source, - artifact.debug.sourceTags, - ); + return formatBitAuthScript(artifact.debug, artifact.source); }; export const serialiseTokenDetails = ( diff --git a/packages/cashscript/test/debugging.test.ts b/packages/cashscript/test/debugging.test.ts index 016aeb33..40810929 100644 --- a/packages/cashscript/test/debugging.test.ts +++ b/packages/cashscript/test/debugging.test.ts @@ -1,5 +1,5 @@ import { Contract, FailedTransactionError, MockNetworkProvider, SignatureAlgorithm, SignatureTemplate, TransactionBuilder, VmTarget } from '../src/index.js'; -import { DEFAULT_VM_TARGET } from '../src/libauth-template/utils.js'; +import { DEFAULT_VM_TARGET, getLockScriptName } from '../src/libauth-template/utils.js'; import { aliceAddress, alicePriv, alicePub, bobPriv, bobPub } from './fixture/vars.js'; import { randomUtxo } from '../src/utils.js'; import { AuthenticationErrorCommon, binToHex, hexToBin } from '@bitauth/libauth'; @@ -873,4 +873,31 @@ describe('Debugging tests - user-defined function frames', () => { const expectedHash = binToHex(sha256(alicePub)); expect(transaction).toLog(new RegExp(`^\\[Input #0] Test.cash:4 0x${expectedHash}$`)); }); + + it('renders source-mapped function definitions in the BitAuth IDE template', () => { + const transaction = new TransactionBuilder({ provider }) + .addInput(contractUtxo, contract.unlock.spend(5n)) + .addOutput({ to: contract.address, amount: 10000n }); + + const template = transaction.getLibauthTemplate(); + const lockScript = template.scripts[getLockScriptName(contract)].script; + + // The function body is rendered as a `<...>` push group annotated with its own source lines + expect(lockScript).toContain('/* function checkValue(int value) {'); + expect(lockScript).toContain('> OP_0 OP_DEFINE'); + expect(lockScript).toContain('OP_0 OP_INVOKE'); + }); + + it('renders imported function definitions with their import provenance in the BitAuth IDE template', () => { + const transaction = new TransactionBuilder({ provider }) + .addInput(importedUtxo, importedContract.unlock.spend(5n)) + .addOutput({ to: importedContract.address, amount: 10000n }); + + const template = transaction.getLibauthTemplate(); + const lockScript = template.scripts[getLockScriptName(importedContract)].script; + + expect(lockScript).toContain('>>> function assertPositive (imported from function_helpers.cash)'); + expect(lockScript).toContain('/* function assertPositive(int value) {'); + expect(lockScript).toContain('> OP_0 OP_DEFINE'); + }); }); diff --git a/packages/utils/src/artifact.ts b/packages/utils/src/artifact.ts index 9cf2e699..e41c65a6 100644 --- a/packages/utils/src/artifact.ts +++ b/packages/utils/src/artifact.ts @@ -27,10 +27,12 @@ export interface DebugFrame { inputs: readonly AbiInput[]; // the function's parameters (name and type), mirroring the ABI; for reference bytecode: string; // hex of the function body bytecode (exactly what OP_DEFINE stores and the VM runs) sourceMap: string; // frame-local source map (ips starting from 0) + sourceTags?: string; // frame-local semantic tags for opcodes (e.g. loop update/condition ranges) + source?: string; // full text of the defining file; absent means the function lives in the contract's own source file + sourceFile?: string; // originating file name for imported functions; absent means the contract's file + location: string; // single-entry source map covering the full function definition in its defining file logs: readonly LogEntry[]; // frame-local log entries requires: readonly RequireStatement[]; // frame-local require statements - source?: string; // body source text; absent means the function lives in the contract's own source file - sourceFile?: string; // originating file name for imported functions; absent means the contract's file } export interface LogEntry { diff --git a/packages/utils/src/bitauth-script.ts b/packages/utils/src/bitauth-script.ts index 9cd3aab5..007af0dc 100644 --- a/packages/utils/src/bitauth-script.ts +++ b/packages/utils/src/bitauth-script.ts @@ -1,11 +1,15 @@ +import { binToHex, hexToBin } from '@bitauth/libauth'; +import { DebugFrame, DebugInformation } from './artifact.js'; import { range } from './data.js'; -import { Script, scriptToBitAuthAsm } from './script.js'; +import { Op, OpOrData, Script, bytecodeToScript, scriptToBitAuthAsm } from './script.js'; import { parseSourceTags, sourceMapToLocationData } from './source-map.js'; -import { FullLocationData, PositionHint, SingleLocationData, SourceTagEntry, SourceTagKind } from './types.js'; +import { FullLocationData, LocationI, PositionHint, SingleLocationData, SourceTagEntry, SourceTagKind } from './types.js'; export type LineToOpcodesMap = Record; export type LineToAsmMap = Record; +// Public utility for line-based tooling — not used by formatBitAuthScript itself, which walks the script +// in bytecode order instead (grouping by line can reorder opcodes for non-monotonic source maps) export function buildLineToOpcodesMap( bytecode: Script, sourceMapOrLocationData: string | FullLocationData, @@ -31,27 +35,299 @@ export function buildLineToAsmMap(bytecode: Script, sourceMapOrLocationData: str ); } -export function formatBitAuthScript(bytecode: Script, sourceMap: string, sourceCode: string, sourceTags?: string): string { - const locationData = sourceMapToLocationData(sourceMap); +// Formats the debug bytecode as BitAuth Script with the matching source code in trailing comments. The output +// is not just displayed but *executed* by the BitAuth IDE (and by the SDK's debug evaluation), so the script +// is walked in bytecode order — opcodes are grouped into output rows, never reordered. User-defined function +// bodies are rendered as nested `<...>` push groups annotated with their own frame's source code. +export function formatBitAuthScript(debug: DebugInformation, sourceCode: string): string { const sourceLines = sourceCode.split('\n'); - // Splice synthetic annotation lines (e.g. for-loop updates) into source and remap opcode lines - const insertions = buildInsertions(locationData, sourceLines, sourceTags); - const splicedSourceLines = spliceSyntheticSourceLines(sourceLines, insertions); - const splicedLocationData = updateLocationData(locationData, insertions); + const rows = walkScript({ + script: bytecodeToScript(hexToBin(debug.bytecode)), + sourceMap: debug.sourceMap, + sourceTags: debug.sourceTags, + functions: debug.functions, + sourceLines, + startLine: 1, + endLine: sourceLines.length, + asmIndent: '', + }); + + return renderRows(rows); +} + +interface WalkParams { + script: Script; + sourceMap: string; + sourceTags?: string; + functions?: readonly DebugFrame[]; // function definitions only occur in the root (contract-level) script + sourceLines: string[]; // the source document that this script's location data refers to + startLine: number; // first source line owned by this walk (inclusive) + endLine: number; // last source line owned by this walk (inclusive) + asmIndent: string; +} + +// Walks the script in bytecode order: first splits it into segments (function definitions, tagged opcode +// ranges, and per-source-line opcode groups), then renders those segments into rows with comment-only +// filler rows in between, so the full source code reads top-to-bottom next to the bytecode. +function walkScript(params: WalkParams): Row[] { + return renderSegments(segmentScript(params), params); +} + +// --- Script segmentation --- + +type Segment = + | { kind: 'lineGroup'; asm: string; line: number } + | { kind: 'annotation'; asm: string; comment: string; fillThroughLine: number } + | { kind: 'functionDefinition'; frame: DebugFrame; defineAsm: string }; + +function segmentScript(params: WalkParams): Segment[] { + const { script, sourceLines } = params; + const locationData = sourceMapToLocationData(params.sourceMap); + const tags = parseSourceTags(params.sourceTags ?? ''); + const defineSites = findDefineSites(script, params.functions ?? []); + + const segments: Segment[] = []; + let index = 0; + + while (index < script.length) { + const frame = defineSites.get(index); + const tag = findTagAt(tags, index); + + if (frame !== undefined) { + const defineAsm = scriptToBitAuthAsm([script[index + 1], Op.OP_DEFINE]); + segments.push({ kind: 'functionDefinition', frame, defineAsm }); + index += 3; // OP_DEFINE + } else if (tag !== undefined) { + segments.push(annotationSegment(script, tag, tags, locationData, sourceLines)); + index = tag.endIndex + 1; + } else { + const line = getDisplayLine(locationData[index]); + const belongsToGroup = (candidate: number): boolean => ( + !defineSites.has(candidate) && findTagAt(tags, candidate) === undefined + && getDisplayLine(locationData[candidate]) === line + ); + const end = range(index + 1, script.length - 1).find((candidate) => !belongsToGroup(candidate)) ?? script.length; + + segments.push({ kind: 'lineGroup', asm: scriptToBitAuthAsm(script.slice(index, end)), line }); + index = end; + } + } + + return segments; +} + +function annotationSegment( + script: Script, + tag: SourceTagEntry, + tags: SourceTagEntry[], + locationData: FullLocationData, + sourceLines: string[], +): Segment { + const { fillThroughLine, indent } = deriveTagAnchor(tag, tags, locationData, sourceLines); + + return { + kind: 'annotation', + asm: scriptToBitAuthAsm(script.slice(tag.startIndex, tag.endIndex + 1)), + comment: `${indent}${tagDescription(tag, locationData, sourceLines)}`, + fillThroughLine, + }; +} + +// Matches every ` OP_DEFINE` site to its debug frame. Frames are stored +// in the same order as the definitions are emitted, so the n-th define site belongs to `frames[n]`. We verify +// that the pushed bytes equal the frame's bytecode, so a mismatch can never render the wrong function's body. +function findDefineSites(script: Script, frames: readonly DebugFrame[]): Map { + const defineSites = new Map(); + let frameIndex = 0; + + for (let index = 0; index + 2 < script.length && frameIndex < frames.length; index += 1) { + if (script[index + 2] !== Op.OP_DEFINE) continue; + + const bodyPushData = elementAsPushData(script[index]); + if (bodyPushData === undefined || binToHex(bodyPushData) !== frames[frameIndex].bytecode) continue; + + defineSites.set(index, frames[frameIndex]); + frameIndex += 1; + index += 2; // skip the id push and OP_DEFINE + } + + return defineSites; +} + +// The compiler minimally encodes the body push, so a single-byte 0x81 body (a lone OP_BIN2NUM) decodes back +// as the opcode OP_1NEGATE rather than as a data push +function elementAsPushData(element: OpOrData): Uint8Array | undefined { + if (element instanceof Uint8Array) return element; + if (element === Op.OP_1NEGATE) return Uint8Array.of(0x81); + return undefined; +} + +function findTagAt(tags: SourceTagEntry[], index: number): SourceTagEntry | undefined { + return tags.find((tag) => index >= tag.startIndex && index <= tag.endIndex); +} + +// --- Segment rendering --- + +// A single output line: bytecode ASM on the left, the source code it belongs to as a trailing comment +interface Row { + asm: string; + comment: string; +} + +interface RenderState { + rows: Row[]; + renderedLine: number; // the last source line that has been rendered +} + +type RenderContext = WalkParams & { + skipLines: Set; // source lines rendered by function sections rather than as filler + headerLineLimit: number; // last source line above the contract's own opcodes, eligible for pre-filling +}; + +const BLANK_ROW: Row = { asm: '', comment: '' }; + +function renderSegments(segments: Segment[], params: WalkParams): Row[] { + const context: RenderContext = { + ...params, + skipLines: getLocalFunctionLines(params.functions ?? []), + headerLineLimit: deriveHeaderLineLimit(segments), + }; + + const initialState: RenderState = { rows: [], renderedLine: params.startLine - 1 }; + const finalState = segments.reduce((state, segment) => renderSegment(state, segment, context), initialState); + + return [...finalState.rows, ...fillerRows(finalState.renderedLine, params.endLine, context)]; +} + +function renderSegment(state: RenderState, segment: Segment, context: RenderContext): RenderState { + if (segment.kind === 'functionDefinition') return renderFunctionDefinition(state, segment, context); + + const fillThroughLine = segment.kind === 'lineGroup' ? segment.line - 1 : segment.fillThroughLine; + const comment = segment.kind === 'lineGroup' ? context.sourceLines[segment.line - 1] : segment.comment; + const reachedLine = segment.kind === 'lineGroup' ? segment.line : segment.fillThroughLine; + + return { + rows: [ + ...state.rows, + ...fillerRows(state.renderedLine, fillThroughLine, context), + { asm: context.asmIndent + segment.asm, comment }, + ], + renderedLine: Math.max(state.renderedLine, Math.min(reachedLine, context.endLine)), + }; +} + +function renderFunctionDefinition( + state: RenderState, + segment: Segment & { kind: 'functionDefinition' }, + context: RenderContext, +): RenderState { + const { frame, defineAsm } = segment; + const location = parseFrameLocation(frame); + + if (frame.sourceFile !== undefined) return renderImportedFunction(state, frame, location, defineAsm); + + // Functions defined above the contract get the source lines above them (pragma, imports, blank lines) + // rendered first, so their section lands at its natural source position. Functions defined below the + // contract render before the contract's rows regardless (bytecode order), so nothing is filled in. + const fillThroughLine = location.start.line <= context.headerLineLimit ? location.start.line - 1 : state.renderedLine; + + return { + rows: [ + ...state.rows, + ...fillerRows(state.renderedLine, fillThroughLine, context), + ...buildFunctionSection(frame, location, defineAsm, context.sourceLines), + ], + renderedLine: Math.max(state.renderedLine, Math.min(fillThroughLine, context.endLine)), + }; +} + +function renderImportedFunction(state: RenderState, frame: DebugFrame, location: LocationI, defineAsm: string): RenderState { + const headerRow = { asm: '', comment: `>>> function ${frame.name} (imported from ${frame.sourceFile})` }; + const sectionRows = buildFunctionSection(frame, location, defineAsm, frame.source!.split('\n')); + + // Blank rows around the section keep consecutive imported functions visually separated + const previousRow = state.rows[state.rows.length - 1]; + const leadingBlankRows = previousRow === undefined || isBlankRow(previousRow) ? [] : [BLANK_ROW]; + + return { + rows: [...state.rows, ...leadingBlankRows, headerRow, ...sectionRows, BLANK_ROW], + renderedLine: state.renderedLine, + }; +} + +// Renders a function definition as a nested `<...>` push group (BitAuth IDE compiles a push group to the +// exact same bytes as the original body push), annotated with the function's own source code. +function buildFunctionSection(frame: DebugFrame, location: LocationI, defineAsm: string, sourceLines: string[]): Row[] { + const bodyScript = bytecodeToScript(hexToBin(frame.bytecode)); + const { start, end } = location; - // Group opcodes by display line and convert to ASM - const lineToAsm = buildLineToAsmMap(bytecode, splicedLocationData); + if (end.line === start.line) { + const asm = ['<', scriptToBitAuthAsm(bodyScript), '>', defineAsm].filter((part) => part !== '').join(' '); + return [{ asm, comment: sourceLines[start.line - 1] }]; + } - // Format output - const escapedLines = splicedSourceLines.map(escapeCommentChars); - const maxAsmLen = Math.max(...escapedLines.map((_, i) => (lineToAsm[i + 1] || '').length)); - const maxSrcLen = Math.max(...escapedLines.map((l) => l.length)); + const bodyRows = walkScript({ + script: bodyScript, + sourceMap: frame.sourceMap, + sourceTags: frame.sourceTags, + sourceLines, + startLine: start.line + 1, + endLine: end.line - 1, + asmIndent: ' ', + }); + + return [ + { asm: '<', comment: sourceLines[start.line - 1] }, + ...bodyRows, + { asm: `> ${defineAsm}`, comment: sourceLines[end.line - 1] }, + ]; +} + +// Comment-only rows for the source lines between the last rendered line and `throughLine` +function fillerRows(afterLine: number, throughLine: number, context: RenderContext): Row[] { + return range(afterLine + 1, Math.min(throughLine, context.endLine)) + .filter((line) => !context.skipLines.has(line)) + .map((line) => ({ asm: '', comment: context.sourceLines[line - 1] })); +} + +// The last source line above the contract's own opcodes; lines up to this limit (pragma, imports, blank +// lines) can only ever be rendered as filler +function deriveHeaderLineLimit(segments: Segment[]): number { + const groupLines = segments.flatMap((segment) => (segment.kind === 'lineGroup' ? [segment.line] : [])); + return Math.min(...groupLines) - 1; +} + +// Source lines of same-file functions are rendered by their function section, not as filler rows +function getLocalFunctionLines(frames: readonly DebugFrame[]): Set { + const lines = frames + .filter((frame) => frame.source === undefined) + .flatMap((frame) => { + const { start, end } = parseFrameLocation(frame); + return range(start.line, end.line); + }); - return escapedLines.map((src, i) => { - const asm = lineToAsm[i + 1] || ''; - return `${asm.padEnd(maxAsmLen)} /* ${src.padEnd(maxSrcLen)} */`; - }).join('\n'); + return new Set(lines); +} + +function parseFrameLocation(frame: DebugFrame): LocationI { + return sourceMapToLocationData(frame.location)[0].location; +} + +function isBlankRow(row: Row): boolean { + return row.asm === '' && row.comment === ''; +} + +// --- Output rendering --- + +function renderRows(rows: Row[]): string { + const escapedRows = rows.map((row) => ({ asm: row.asm, comment: escapeCommentChars(row.comment) })); + const maxAsmLength = Math.max(...escapedRows.map((row) => row.asm.length)); + const maxCommentLength = Math.max(...escapedRows.map((row) => row.comment.length)); + + return escapedRows + .map((row) => `${row.asm.padEnd(maxAsmLength)} /* ${row.comment.padEnd(maxCommentLength)} */`) + .join('\n'); } // --- Helpers --- @@ -65,18 +341,11 @@ function escapeCommentChars(text: string): string { return text.replaceAll('/*', '\\/*').replaceAll('*/', '*\\/'); } -// --- Source tag handling (for-loop update annotations) --- - -interface Insertion { - insertAfterLine: number; - annotation: string; - startIndex: number; - endIndex: number; -} +// --- Source tag handling (annotations for compiler-injected opcodes) --- -// Where a synthetic annotation line is spliced, and the indentation it's rendered with. -interface Anchor { - insertAfterLine: number; +// Where a tag's annotation row lands: the source lines rendered before it, and the indentation it gets +interface TagAnchor { + fillThroughLine: number; indent: string; } @@ -92,26 +361,12 @@ const EPILOGUE_KINDS = [ SourceTagKind.LOOP_CONDITION, ]; -function buildInsertions( - locationData: FullLocationData, - sourceLines: string[], - sourceTags?: string, -): Insertion[] { - const tags = (sourceTags ? parseSourceTags(sourceTags) : []); - - return tags.map((tag) => { - const { insertAfterLine, indent } = deriveAnchor(tag, tags, locationData, sourceLines); - const annotation = `${indent}${tagDescription(tag, locationData, sourceLines)}`; - return { insertAfterLine, annotation, startIndex: tag.startIndex, endIndex: tag.endIndex }; - }); -} - -function deriveAnchor( +function deriveTagAnchor( tag: SourceTagEntry, tags: SourceTagEntry[], locationData: FullLocationData, sourceLines: string[], -): Anchor { +): TagAnchor { if (PROLOGUE_KINDS.includes(tag.kind)) { const prologueTags = tags.filter((t) => PROLOGUE_KINDS.includes(t.kind)); @@ -120,63 +375,29 @@ function deriveAnchor( const firstBodyOpcode = lastPrologueOpcode + 1; const firstBodyLine = getDisplayLine(locationData[firstBodyOpcode]); + // Filling through `firstBodyLine - 1` lands all the prologue annotations directly above the first + // body statement, at its indentation. return { - // `insertAfterLine` splices *after* a line, so `firstBodyLine - 1` lands all the prologue - // annotations directly above the first body statement, at its indentation. - insertAfterLine: firstBodyLine - 1, + fillThroughLine: firstBodyLine - 1, indent: lineIndent(sourceLines, firstBodyLine), }; } - // Scope cleanup and loop-back condition tags always get inserted right before the scope's closing brace. + // Scope cleanup and loop-back condition tags always land right before the scope's closing brace. if (EPILOGUE_KINDS.includes(tag.kind)) { const braceLine = getDisplayLine(locationData[tag.startIndex]); return { - insertAfterLine: braceLine - 1, + fillThroughLine: braceLine - 1, indent: deriveIndent(locationData[tag.startIndex].location.start.line, sourceLines), }; } return { - insertAfterLine: getDisplayLine(locationData[Math.max(tag.startIndex - 1, 0)]), + fillThroughLine: getDisplayLine(locationData[Math.max(tag.startIndex - 1, 0)]), indent: deriveIndent(getDisplayLine(locationData[tag.startIndex]), sourceLines), }; } -function spliceSyntheticSourceLines(sourceLines: string[], insertions: Insertion[]): string[] { - return insertions.reduceRight( - (lines, ins) => [...lines.slice(0, ins.insertAfterLine), ins.annotation, ...lines.slice(ins.insertAfterLine)], - sourceLines, - ); -} - -function updateLocationData(locationData: FullLocationData, insertions: Insertion[]): FullLocationData { - return insertions.reduceRight((location, insertion) => { - return location.map((entry, opcodeIndex) => { - const currentLineNumber = getDisplayLine(location[opcodeIndex]); - const updatedLineNumber = getUpdatedLineNumber(currentLineNumber, insertion, opcodeIndex); - if (updatedLineNumber === currentLineNumber) return entry; - - return { - location: { - start: { line: updatedLineNumber, column: 0 }, - end: { line: updatedLineNumber, column: 0 }, - }, - positionHint: PositionHint.START, - }; - }); - }, locationData); -} - -const getUpdatedLineNumber = (currentLineNumber: number, insertion: Insertion, opcodeIndex: number): number => { - const newLineNumber = insertion.insertAfterLine + 1; - const inTagRange = opcodeIndex >= insertion.startIndex && opcodeIndex <= insertion.endIndex; - - if (inTagRange) return newLineNumber; - if (currentLineNumber > insertion.insertAfterLine) return currentLineNumber + 1; - return currentLineNumber; -}; - // e.g. ">>> for-loop update (i = i + 1)" function tagDescription(tag: SourceTagEntry, locationData: FullLocationData, sourceLines: string[]): string { switch (tag.kind) { diff --git a/packages/utils/test/bitauth-script.test.ts b/packages/utils/test/bitauth-script.test.ts index 97d59b24..0ebb9cb4 100644 --- a/packages/utils/test/bitauth-script.test.ts +++ b/packages/utils/test/bitauth-script.test.ts @@ -1,7 +1,9 @@ -import { asmToScript } from '../src/script.js'; +import { binToHex, createCompilerBch } from '@bitauth/libauth'; +import { Artifact } from '../src/artifact.js'; +import { asmToScript, scriptToBytecode } from '../src/script.js'; import { buildLineToAsmMap, formatBitAuthScript } from '../src/bitauth-script.js'; -import { fixtures } from './fixtures/bitauth-script.fixture.js'; -import { compileString } from 'cashc'; +import { FunctionFixture, fixtures, functionFixtures } from './fixtures/bitauth-script.fixture.js'; +import { compileFile, compileString } from 'cashc'; describe('Libauth Script formatting', () => { fixtures.forEach((fixture) => { @@ -22,11 +24,66 @@ describe('Libauth Script formatting', () => { it('should format script as debugging output for BitAuth IDE', () => { const expectedBitAuthScript = fixture.expectedBitAuthScript.replace(/^\n+/, '').replace(/\n+$/, ''); - const formattedBitAuthScript = formatBitAuthScript( - scriptBytecode, fixture.sourceMap, fixture.sourceCode, fixture.sourceTags, - ); + const debugInformation = { + bytecode: binToHex(scriptToBytecode(scriptBytecode)), + sourceMap: fixture.sourceMap, + logs: [], + requires: [], + ...(fixture.sourceTags ? { sourceTags: fixture.sourceTags } : {}), + }; + const formattedBitAuthScript = formatBitAuthScript(debugInformation, fixture.sourceCode); expect(formattedBitAuthScript).toBe(expectedBitAuthScript); + expectBitAuthScriptToCompileTo(formattedBitAuthScript, debugInformation.bytecode); + }); + }); + }); + + describe('Bytecode order preservation', () => { + it('should emit opcodes in bytecode order even for non-monotonic source maps', () => { + // OP_3 maps back to line 3 after line-4 opcodes — grouping by source line would reorder execution. + // The formatted output is executed, so bytecode order must win over source-line grouping. + const scriptBytecode = asmToScript('OP_1 OP_2 OP_ADD OP_3 OP_NUMEQUAL'); + const debugInformation = { + bytecode: binToHex(scriptToBytecode(scriptBytecode)), + sourceMap: '3:8:3:9;4:8:4:9;:12::13;3:12:3:13;5:8:5:9', + logs: [], + requires: [], + }; + const sourceCode = 'line 1\nline 2\nline 3\nline 4\nline 5'; + + const formattedBitAuthScript = formatBitAuthScript(debugInformation, sourceCode); + expectBitAuthScriptToCompileTo(formattedBitAuthScript, debugInformation.bytecode); + }); + }); + + describe('User-defined function formatting', () => { + const compileFixture = (fixture: FunctionFixture): Artifact => (fixture.file + ? compileFile(new URL(`./fixtures/${fixture.file}`, import.meta.url)) + : compileString(fixture.sourceCode!)); + + functionFixtures.forEach((fixture) => { + describe(fixture.name, () => { + it('should format function definitions as source-mapped push groups', () => { + const artifact = compileFixture(fixture); + const expectedBitAuthScript = fixture.expectedBitAuthScript.replace(/^\n+/, '').replace(/\n+$/, ''); + expect(formatBitAuthScript(artifact.debug!, artifact.source)).toBe(expectedBitAuthScript); + }); + + it('should compile back to the exact original bytecode', () => { + const artifact = compileFixture(fixture); + const formattedBitAuthScript = formatBitAuthScript(artifact.debug!, artifact.source); + expectBitAuthScriptToCompileTo(formattedBitAuthScript, artifact.debug!.bytecode); + }); }); }); }); }); + +// The formatted output is executed by the BitAuth IDE (and hashed into the P2SH address), so it must +// compile back to the exact bytecode it was generated from +function expectBitAuthScriptToCompileTo(bitAuthScript: string, expectedBytecodeHex: string): void { + const compiler = createCompilerBch({ scripts: { formatted: bitAuthScript } }); + const result = compiler.generateBytecode({ scriptId: 'formatted', data: {} }); + if (!result.success) throw new Error(`BitAuth Script failed to compile: ${JSON.stringify(result.errors)}`); + expect(binToHex(result.bytecode)).toBe(expectedBytecodeHex); +} diff --git a/packages/utils/test/fixtures/bitauth-script.fixture.ts b/packages/utils/test/fixtures/bitauth-script.fixture.ts index 3150831c..1fd10815 100644 --- a/packages/utils/test/fixtures/bitauth-script.fixture.ts +++ b/packages/utils/test/fixtures/bitauth-script.fixture.ts @@ -493,3 +493,161 @@ OP_0 OP_GREATERTHAN /* require(x > 0); */ `.replace(/^\n+/, '').replace(/\n+$/, ''), }, ]; + +// Contracts with user-defined functions render each definition as a source-mapped `<...>` push group. +// These fixtures are compiled at test time (compileString for same-file functions, compileFile for imports). +export interface FunctionFixture { + name: string; + sourceCode?: string; // compiled with compileString when set + file?: string; // compiled with compileFile, relative to this fixtures directory (used for imports) + expectedBitAuthScript: string; +} + +export const functionFixtures: FunctionFixture[] = [ + { + name: 'LocalFunctions (same-file functions with loop + recursion)', + sourceCode: ` +function sumTo(int n) returns (int) { + int sum = 0; + for (int i = 0; i < n; i = i + 1) { + sum = sum + i; + } + return sum; +} + +function fib(int n) returns (int) { + int result = n; + if (n >= 2) { + result = fib(n - 1) + fib(n - 2); + } + return result; +} + +contract LocalFunctions() { + function spend() { + require(sumTo(5) == 10, 'sum mismatch'); + require(fib(7) == 13, 'fib mismatch'); + } +} +`.replace(/^\n+/, '').replace(/\n+$/, ''), + expectedBitAuthScript: ` +< /* function sumTo(int n) returns (int) { */ + OP_0 /* int sum = 0; */ + OP_0 OP_BEGIN OP_DUP OP_3 OP_PICK OP_LESSTHAN OP_DUP OP_TOALTSTACK OP_IF /* for (int i = 0; i < n; i = i + 1) { */ + OP_2DUP OP_ADD OP_ROT OP_DROP OP_SWAP /* sum = sum + i; */ + OP_DUP OP_1ADD OP_NIP /* >>> for-loop update (i = i + 1) */ + OP_ENDIF OP_FROMALTSTACK OP_NOT OP_UNTIL /* >>> loop condition check */ + OP_DROP /* >>> scope cleanup */ + /* } */ + /* return sum; */ + OP_NIP /* >>> scope cleanup */ +> OP_0 OP_DEFINE /* } */ + /* */ +< /* function fib(int n) returns (int) { */ + OP_DUP OP_DUP /* int result = n; */ + OP_2 OP_GREATERTHANOREQUAL OP_IF /* if (n >= 2) { */ + OP_OVER OP_1SUB OP_1 OP_INVOKE OP_2 OP_PICK OP_2 OP_SUB OP_1 OP_INVOKE OP_ADD OP_NIP /* result = fib(n - 1) + fib(n - 2); */ + OP_ENDIF /* } */ + /* return result; */ + OP_NIP /* >>> scope cleanup */ +> OP_1 OP_DEFINE /* } */ + /* */ + /* contract LocalFunctions() { */ + /* function spend() { */ +OP_5 OP_0 OP_INVOKE OP_10 OP_NUMEQUALVERIFY /* require(sumTo(5) == 10, 'sum mismatch'); */ +OP_7 OP_1 OP_INVOKE OP_13 OP_NUMEQUAL /* require(fib(7) == 13, 'fib mismatch'); */ + /* } */ + /* } */ +`.replace(/^\n+/, '').replace(/\n+$/, ''), + }, + { + name: 'ImportedFunctions (two imported functions from one file)', + file: 'function-imports/importer.cash', + expectedBitAuthScript: ` + /* >>> function double (imported from helpers.cash) */ +< /* function double(int x) returns (int) { */ + OP_2 OP_MUL /* return x * 2; */ +> OP_0 OP_DEFINE /* } */ + /* */ + /* >>> function addChecked (imported from helpers.cash) */ +< /* function addChecked(int a, int b) returns (int) { */ + OP_OVER OP_ADD /* int sum = a + b; */ + OP_DUP OP_ROT OP_GREATERTHANOREQUAL OP_VERIFY /* require(sum >= a, "overflow"); */ + /* return sum; */ +> OP_1 OP_DEFINE /* } */ + /* */ + /* import "./helpers.cash"; */ + /* */ + /* contract ImportedFunctions() { */ + /* function spend(int x) { */ +OP_DUP OP_0 OP_INVOKE /* int doubled = double(x); */ +OP_SWAP OP_1 OP_INVOKE OP_15 OP_NUMEQUAL /* require(addChecked(doubled, x) == 15, "sum mismatch"); */ + /* } */ + /* } */ + /* */ +`.replace(/^\n+/, '').replace(/\n+$/, ''), + }, + { + // The single-byte 0x81 body (lone OP_BIN2NUM) gets minimally encoded as the opcode OP_1NEGATE at the + // define site, so it must be matched to its frame by push-data equality rather than element shape. + name: 'MinimalBody (single-byte function body, minimally encoded define site)', + sourceCode: ` +function toInt(bytes b) returns (int) { + return int(b); +} + +function double(int x) returns (int) { + return x * 2; +} + +contract MinimalBody() { + function spend(bytes8 b) { + require(toInt(b) > 0, 'not positive'); + require(double(3) == 6, 'bad double'); + } +} +`.replace(/^\n+/, '').replace(/\n+$/, ''), + expectedBitAuthScript: ` +< /* function toInt(bytes b) returns (int) { */ + OP_BIN2NUM /* return int(b); */ +> OP_0 OP_DEFINE /* } */ + /* */ +< /* function double(int x) returns (int) { */ + OP_2 OP_MUL /* return x * 2; */ +> OP_1 OP_DEFINE /* } */ + /* */ + /* contract MinimalBody() { */ + /* function spend(bytes8 b) { */ +OP_SIZE OP_8 OP_EQUALVERIFY /* >>> parameter type check (bytes8 b) */ +OP_0 OP_INVOKE OP_0 OP_GREATERTHAN OP_VERIFY /* require(toInt(b) > 0, 'not positive'); */ +OP_3 OP_1 OP_INVOKE OP_6 OP_NUMEQUAL /* require(double(3) == 6, 'bad double'); */ + /* } */ + /* } */ +`.replace(/^\n+/, '').replace(/\n+$/, ''), + }, + { + name: 'AfterContract (function defined below the contract in the same file)', + sourceCode: ` +contract AfterContract() { + function spend(int x) { + require(double(x) == 10, 'mismatch'); + } +} + +function double(int a) returns (int) { + return a * 2; +} +`.replace(/^\n+/, '').replace(/\n+$/, ''), + expectedBitAuthScript: ` +< /* function double(int a) returns (int) { */ + OP_2 OP_MUL /* return a * 2; */ +> OP_0 OP_DEFINE /* } */ + /* contract AfterContract() { */ + /* function spend(int x) { */ +OP_0 OP_INVOKE OP_10 OP_NUMEQUAL /* require(double(x) == 10, 'mismatch'); */ + /* } */ + /* } */ + /* */ +`.replace(/^\n+/, '').replace(/\n+$/, ''), + }, +]; diff --git a/packages/utils/test/fixtures/function-imports/helpers.cash b/packages/utils/test/fixtures/function-imports/helpers.cash new file mode 100644 index 00000000..9ca385c1 --- /dev/null +++ b/packages/utils/test/fixtures/function-imports/helpers.cash @@ -0,0 +1,9 @@ +function double(int x) returns (int) { + return x * 2; +} + +function addChecked(int a, int b) returns (int) { + int sum = a + b; + require(sum >= a, "overflow"); + return sum; +} diff --git a/packages/utils/test/fixtures/function-imports/importer.cash b/packages/utils/test/fixtures/function-imports/importer.cash new file mode 100644 index 00000000..c2834589 --- /dev/null +++ b/packages/utils/test/fixtures/function-imports/importer.cash @@ -0,0 +1,8 @@ +import "./helpers.cash"; + +contract ImportedFunctions() { + function spend(int x) { + int doubled = double(x); + require(addChecked(doubled, x) == 15, "sum mismatch"); + } +} From 16c55d7c9abd3134af8b385036566d121ed0fe8b Mon Sep 17 00:00:00 2001 From: Rosco Kalis Date: Tue, 7 Jul 2026 10:27:46 +0200 Subject: [PATCH 5/6] Fix code quality of new bitauth script formatting --- .../src/generation/GenerateTargetTraversal.ts | 6 +- packages/cashc/test/ast/Location.test.ts | 68 ---- packages/cashc/test/generation/fixtures.ts | 12 +- packages/utils/src/artifact.ts | 2 +- packages/utils/src/bitauth-script.ts | 296 +++++++----------- packages/utils/test/bitauth-script.test.ts | 6 +- .../test/fixtures/bitauth-script.fixture.ts | 94 +----- 7 files changed, 122 insertions(+), 362 deletions(-) diff --git a/packages/cashc/src/generation/GenerateTargetTraversal.ts b/packages/cashc/src/generation/GenerateTargetTraversal.ts index 5e6eca88..07654a0c 100644 --- a/packages/cashc/src/generation/GenerateTargetTraversal.ts +++ b/packages/cashc/src/generation/GenerateTargetTraversal.ts @@ -154,7 +154,7 @@ export default class GenerateTargetTraversal extends AstTraversal { private defineGlobalFunctions(node: SourceFileNode): void { node.functions.forEach((func) => { const { functionId } = node.symbolTable!.getFromThis(func.name)!; - const bodyBytecode = this.compileGlobalFunctionBody(func); + const bodyBytecode = this.compileGlobalFunctionBody(func, functionId!); const locationData = { location: func.location, positionHint: PositionHint.START }; this.emit(bodyBytecode, locationData); // @@ -163,7 +163,7 @@ export default class GenerateTargetTraversal extends AstTraversal { }); } - private compileGlobalFunctionBody(node: FunctionDefinitionNode): Uint8Array { + private compileGlobalFunctionBody(node: FunctionDefinitionNode, functionId: number): Uint8Array { const bodyTraversal = new GenerateTargetTraversal(this.compilerOptions); bodyTraversal.currentFunction = node; bodyTraversal.constructorParameterCount = 0; @@ -190,12 +190,12 @@ export default class GenerateTargetTraversal extends AstTraversal { const sourceTags = generateSourceTags(optimised.sourceTags); this.frames.push({ + id: functionId, name: node.name, inputs: node.parameters.map((parameter) => ({ name: parameter.name, type: parameter.type.toString() })), bytecode: binToHex(bodyBytecode), sourceMap: generateSourceMap(optimised.locationData), ...(sourceTags ? { sourceTags } : {}), - location: generateSourceMap([{ location: node.location, positionHint: PositionHint.START }]), ...(node.sourceCode !== undefined ? { source: node.sourceCode } : {}), ...(node.sourceFile !== undefined ? { sourceFile: node.sourceFile } : {}), logs: optimised.logs, diff --git a/packages/cashc/test/ast/Location.test.ts b/packages/cashc/test/ast/Location.test.ts index 65894d30..ebae658f 100644 --- a/packages/cashc/test/ast/Location.test.ts +++ b/packages/cashc/test/ast/Location.test.ts @@ -1,9 +1,6 @@ import fs from 'fs'; import { URL } from 'url'; -import { compileString } from '../../src/compiler.js'; import { parseCode } from '../../src/parser.js'; -import { buildLineToAsmMap, bytecodeToAsm, bytecodeToScript } from '@cashscript/utils'; -import { hexToBin } from '@bitauth/libauth'; describe('Location', () => { it('should retrieve correct text from location', () => { @@ -16,71 +13,6 @@ describe('Location', () => { expect((f.location).text(code)).toEqual('function hello(sig s, pubkey pk) {\n require(checkSig(s, pk));\n }'); }); - const wrap = (code: string): string => { - return ` -contract test() { - function test() { - require(${code}); - } -}`; - }; - - describe('Line to ASM map generation', () => { - const blocks = [ - '1 < 1', '1 <= 1', '1 == 1', '1 != 1', '1 > 1', '1 >= 1', - '(1 - 1) == 1', '(1 + 1) == 1', '(1 * 1) == 1', '(1 / 1) == 1', - '(true && true) == true', '(true || true) == true', - '(0x01 & 0x01) == 0x01', '(0x01 | 0x01) == 0x01', '(0x01 ^ 0x01) == 0x01', - '"1" + "1" == "1"', '"1" + "1" != "1"', '"11".split(1)[0] == "1"', '"11".split(1)[1] == "1"', - '"1".reverse() == "1"', '"1".length == 1', '0x01.length == 1', '-333 == 1', - 'tx.inputs[0].tokenAmount == 1', - 'this.activeInputIndex == 1', 'tx.version == 1', - 'abs(-1) == 1', 'within(1,1,1) == true', 'bytes(sha256(1)) == bytes(0x01)', - 'checkSig(sig(0x00), pubkey(0x00))', 'checkMultiSig([sig(0x00), sig(0x00)], [pubkey(0x00), pubkey(0x00)])', - 'checkDataSig(datasig(0x00), 0x00, pubkey(0x00))', - 'tx.time >= 1', 'this.age >= 1', - 'bytes(1) == 0x01', 'int(0x01) == 1', - ]; - - blocks.forEach(block => { - it(`it should generate the same bytecode using line-to-asm map as the regular compiler for: ${block}`, () => { - { - const source = wrap(block); - - // Compile the source code using regular CashScript compilation - const artifact = compileString(source); - const expected = bytecodeToAsm(hexToBin(artifact.debug!.bytecode)); - - // Generate the line-to-asm map from the source code - const opCodeMap = buildLineToAsmMap( - bytecodeToScript(hexToBin(artifact.debug!.bytecode)), artifact.debug!.sourceMap, - ); - - // Convert the line-to-asm map to CashScript bytecode to make sure that the generated opcode map matches the - // bytecode generated by CashScript - const received = Object.values(opCodeMap).join(' ') - .replaceAll('<0x', '').replaceAll('>', '').replace(/\s+/g, ' '); - expect(received).toBe(expected); - } - - // Repeat the tests with the source code modified to test the position hint functionality - { - const source = wrap(block.replaceAll(' ', '\n').replaceAll(')', '\n)')) - .replaceAll('(\n)', '()').replace(/\((?!\))/g, '(\n'); - const artifact = compileString(source); - const expected = bytecodeToAsm(hexToBin(artifact.debug!.bytecode)); - const opCodeMap = buildLineToAsmMap( - bytecodeToScript(hexToBin(artifact.debug!.bytecode)), artifact.debug!.sourceMap, - ); - - const received = Object.values(opCodeMap).join(' ') - .replaceAll('<0x', '').replaceAll('>', '').replace(/\s+/g, ' '); - expect(received).toBe(expected); - } - }); - }); - }); - it('should set the correct location points', () => { const code = fs.readFileSync(new URL('../valid-contract-files/simple_functions.cash', import.meta.url), { encoding: 'utf-8' }); const ast = parseCode(code); diff --git a/packages/cashc/test/generation/fixtures.ts b/packages/cashc/test/generation/fixtures.ts index def63805..a22d7743 100644 --- a/packages/cashc/test/generation/fixtures.ts +++ b/packages/cashc/test/generation/fixtures.ts @@ -1429,13 +1429,13 @@ export const fixtures: Fixture[] = [ sourceMap: '1::3:1;;::::1;7:16:7:25;;:29::30:0;:8::32:1', functions: [ { + id: 0, name: 'double', inputs: [{ name: 'a', type: 'int' }], bytecode: '5295', sourceMap: '2:15:2:16;:11:::1', logs: [], requires: [], - location: '1::3:1', }, ], }, @@ -1474,13 +1474,13 @@ export const fixtures: Fixture[] = [ sourceMap: '1::3:1;;::::1;7:23:7:24:0;:16::25:1;;:29::30:0;:8::32:1', functions: [ { + id: 0, name: 'sub', inputs: [{ name: 'a', type: 'int' }, { name: 'b', type: 'int' }], bytecode: '94', sourceMap: '2:11:2:16:1', logs: [], requires: [], - location: '1::3:1', }, ], }, @@ -1518,13 +1518,13 @@ export const fixtures: Fixture[] = [ sourceMap: '1::3:1;;::::1;7:24:7:25:0;:8::26:1;;8:20:8:23:0;:8::25:1', functions: [ { + id: 0, name: 'requirePositive', inputs: [{ name: 'a', type: 'int' }], bytecode: '00a069', sourceMap: '2:16:2:17;:12:::1;:4::19', logs: [], requires: [{ ip: 2, line: 2 }], - location: '1::3:1', }, ], }, @@ -1568,35 +1568,35 @@ export const fixtures: Fixture[] = [ sourceMap: '2::4:1;;::::1;1::3::0;;::::1;2::4::0;;::::1;6:19:6:20:0;:16::21:1;;:27::28:0;:24::29:1;;:16;:33::35:0;:8::37:1', functions: [ { + id: 0, name: 'm1', inputs: [{ name: 'a', type: 'int' }], bytecode: '518a5295', sourceMap: '3:11:3:18:1;;:21::22:0;:11:::1', logs: [], requires: [], - location: '2::4:1', source: fs.readFileSync(new URL('../import-fixtures/mid1.cash', import.meta.url), { encoding: 'utf-8' }), sourceFile: 'mid1.cash', }, { + id: 1, name: 'leaf', inputs: [{ name: 'a', type: 'int' }], bytecode: '8b', sourceMap: '2:11:2:16:1', logs: [], requires: [], - location: '1::3:1', source: fs.readFileSync(new URL('../import-fixtures/leaf.cash', import.meta.url), { encoding: 'utf-8' }), sourceFile: 'leaf.cash', }, { + id: 2, name: 'm2', inputs: [{ name: 'a', type: 'int' }], bytecode: '518a5393', sourceMap: '3:11:3:18:1;;:21::22:0;:11:::1', logs: [], requires: [], - location: '2::4:1', source: fs.readFileSync(new URL('../import-fixtures/mid2.cash', import.meta.url), { encoding: 'utf-8' }), sourceFile: 'mid2.cash', }, diff --git a/packages/utils/src/artifact.ts b/packages/utils/src/artifact.ts index e41c65a6..040c3b47 100644 --- a/packages/utils/src/artifact.ts +++ b/packages/utils/src/artifact.ts @@ -23,6 +23,7 @@ export interface DebugInformation { } export interface DebugFrame { + id: number; // the function's id, as used with OP_DEFINE and OP_INVOKE in the bytecode name: string; // the function's name inputs: readonly AbiInput[]; // the function's parameters (name and type), mirroring the ABI; for reference bytecode: string; // hex of the function body bytecode (exactly what OP_DEFINE stores and the VM runs) @@ -30,7 +31,6 @@ export interface DebugFrame { sourceTags?: string; // frame-local semantic tags for opcodes (e.g. loop update/condition ranges) source?: string; // full text of the defining file; absent means the function lives in the contract's own source file sourceFile?: string; // originating file name for imported functions; absent means the contract's file - location: string; // single-entry source map covering the full function definition in its defining file logs: readonly LogEntry[]; // frame-local log entries requires: readonly RequireStatement[]; // frame-local require statements } diff --git a/packages/utils/src/bitauth-script.ts b/packages/utils/src/bitauth-script.ts index 007af0dc..b2aee008 100644 --- a/packages/utils/src/bitauth-script.ts +++ b/packages/utils/src/bitauth-script.ts @@ -1,44 +1,10 @@ -import { binToHex, hexToBin } from '@bitauth/libauth'; +import { hexToBin } from '@bitauth/libauth'; import { DebugFrame, DebugInformation } from './artifact.js'; -import { range } from './data.js'; -import { Op, OpOrData, Script, bytecodeToScript, scriptToBitAuthAsm } from './script.js'; +import { encodeInt, range } from './data.js'; +import { Op, Script, bytecodeToScript, scriptToBitAuthAsm } from './script.js'; import { parseSourceTags, sourceMapToLocationData } from './source-map.js'; import { FullLocationData, LocationI, PositionHint, SingleLocationData, SourceTagEntry, SourceTagKind } from './types.js'; -export type LineToOpcodesMap = Record; -export type LineToAsmMap = Record; - -// Public utility for line-based tooling — not used by formatBitAuthScript itself, which walks the script -// in bytecode order instead (grouping by line can reorder opcodes for non-monotonic source maps) -export function buildLineToOpcodesMap( - bytecode: Script, - sourceMapOrLocationData: string | FullLocationData, -): LineToOpcodesMap { - const locationData = typeof sourceMapOrLocationData === 'string' ? sourceMapToLocationData(sourceMapOrLocationData) : sourceMapOrLocationData; - - return locationData.reduce((lineToOpcodeMap, singleLocation, index) => { - const opcode = bytecode[index]; - const line = getDisplayLine(singleLocation); - - return { - ...lineToOpcodeMap, - [line]: [...(lineToOpcodeMap[line] || []), opcode], - }; - }, {}); -} - -export function buildLineToAsmMap(bytecode: Script, sourceMapOrLocationData: string | FullLocationData): LineToAsmMap { - const lineToOpcodesMap = buildLineToOpcodesMap(bytecode, sourceMapOrLocationData); - - return Object.fromEntries( - Object.entries(lineToOpcodesMap).map(([lineNumber, opcodeList]) => [lineNumber, scriptToBitAuthAsm(opcodeList)]), - ); -} - -// Formats the debug bytecode as BitAuth Script with the matching source code in trailing comments. The output -// is not just displayed but *executed* by the BitAuth IDE (and by the SDK's debug evaluation), so the script -// is walked in bytecode order — opcodes are grouped into output rows, never reordered. User-defined function -// bodies are rendered as nested `<...>` push groups annotated with their own frame's source code. export function formatBitAuthScript(debug: DebugInformation, sourceCode: string): string { const sourceLines = sourceCode.split('\n'); @@ -60,58 +26,66 @@ interface WalkParams { script: Script; sourceMap: string; sourceTags?: string; - functions?: readonly DebugFrame[]; // function definitions only occur in the root (contract-level) script - sourceLines: string[]; // the source document that this script's location data refers to - startLine: number; // first source line owned by this walk (inclusive) - endLine: number; // last source line owned by this walk (inclusive) + functions?: readonly DebugFrame[]; + sourceLines: string[]; + startLine: number; + endLine: number; asmIndent: string; } -// Walks the script in bytecode order: first splits it into segments (function definitions, tagged opcode -// ranges, and per-source-line opcode groups), then renders those segments into rows with comment-only -// filler rows in between, so the full source code reads top-to-bottom next to the bytecode. function walkScript(params: WalkParams): Row[] { - return renderSegments(segmentScript(params), params); + const segments = segmentScript(params); + return renderSegments(segments, params); } -// --- Script segmentation --- +interface LineGroupSegment { + kind: 'lineGroup'; + asm: string; + line: number; +} -type Segment = - | { kind: 'lineGroup'; asm: string; line: number } - | { kind: 'annotation'; asm: string; comment: string; fillThroughLine: number } - | { kind: 'functionDefinition'; frame: DebugFrame; defineAsm: string }; +interface AnnotationSegment { + kind: 'annotation'; + asm: string; + comment: string; + insertAfterLine: number; +} + +interface FunctionDefinitionSegment { + kind: 'functionDefinition'; + frame: DebugFrame; + location: LocationI; +} + +type Segment = LineGroupSegment | AnnotationSegment | FunctionDefinitionSegment; function segmentScript(params: WalkParams): Segment[] { const { script, sourceLines } = params; const locationData = sourceMapToLocationData(params.sourceMap); const tags = parseSourceTags(params.sourceTags ?? ''); - const defineSites = findDefineSites(script, params.functions ?? []); + const frames = params.functions ?? []; const segments: Segment[] = []; let index = 0; while (index < script.length) { - const frame = defineSites.get(index); - const tag = findTagAt(tags, index); + // Function definitions are always at the start of the script in sets of three: ` OP_DEFINE` per frame + if (index < frames.length * 3) { + segments.push({ kind: 'functionDefinition', frame: frames[index / 3], location: locationData[index].location }); + index += 3; + continue; + } - if (frame !== undefined) { - const defineAsm = scriptToBitAuthAsm([script[index + 1], Op.OP_DEFINE]); - segments.push({ kind: 'functionDefinition', frame, defineAsm }); - index += 3; // OP_DEFINE - } else if (tag !== undefined) { + const tag = findTagAt(tags, index); + if (tag) { segments.push(annotationSegment(script, tag, tags, locationData, sourceLines)); index = tag.endIndex + 1; - } else { - const line = getDisplayLine(locationData[index]); - const belongsToGroup = (candidate: number): boolean => ( - !defineSites.has(candidate) && findTagAt(tags, candidate) === undefined - && getDisplayLine(locationData[candidate]) === line - ); - const end = range(index + 1, script.length - 1).find((candidate) => !belongsToGroup(candidate)) ?? script.length; - - segments.push({ kind: 'lineGroup', asm: scriptToBitAuthAsm(script.slice(index, end)), line }); - index = end; + continue; } + + const endIndex = findLineGroupEnd(script, index, locationData, tags); + segments.push({ kind: 'lineGroup', asm: scriptToBitAuthAsm(script.slice(index, endIndex)), line: getDisplayLine(locationData[index]) }); + index = endIndex; } return segments; @@ -123,53 +97,31 @@ function annotationSegment( tags: SourceTagEntry[], locationData: FullLocationData, sourceLines: string[], -): Segment { - const { fillThroughLine, indent } = deriveTagAnchor(tag, tags, locationData, sourceLines); +): AnnotationSegment { + const { insertAfterLine, indent } = deriveAnchor(tag, tags, locationData, sourceLines); return { kind: 'annotation', asm: scriptToBitAuthAsm(script.slice(tag.startIndex, tag.endIndex + 1)), comment: `${indent}${tagDescription(tag, locationData, sourceLines)}`, - fillThroughLine, + insertAfterLine, }; } -// Matches every ` OP_DEFINE` site to its debug frame. Frames are stored -// in the same order as the definitions are emitted, so the n-th define site belongs to `frames[n]`. We verify -// that the pushed bytes equal the frame's bytecode, so a mismatch can never render the wrong function's body. -function findDefineSites(script: Script, frames: readonly DebugFrame[]): Map { - const defineSites = new Map(); - let frameIndex = 0; - - for (let index = 0; index + 2 < script.length && frameIndex < frames.length; index += 1) { - if (script[index + 2] !== Op.OP_DEFINE) continue; - - const bodyPushData = elementAsPushData(script[index]); - if (bodyPushData === undefined || binToHex(bodyPushData) !== frames[frameIndex].bytecode) continue; - - defineSites.set(index, frames[frameIndex]); - frameIndex += 1; - index += 2; // skip the id push and OP_DEFINE - } - - return defineSites; -} +// A line group runs until the next opcode that belongs to a tagged range or maps to a different source line +function findLineGroupEnd(script: Script, start: number, locationData: FullLocationData, tags: SourceTagEntry[]): number { + const line = getDisplayLine(locationData[start]); + const nextBoundary = range(start + 1, script.length - 1).find((index) => ( + getDisplayLine(locationData[index]) !== line || findTagAt(tags, index) !== undefined + )); -// The compiler minimally encodes the body push, so a single-byte 0x81 body (a lone OP_BIN2NUM) decodes back -// as the opcode OP_1NEGATE rather than as a data push -function elementAsPushData(element: OpOrData): Uint8Array | undefined { - if (element instanceof Uint8Array) return element; - if (element === Op.OP_1NEGATE) return Uint8Array.of(0x81); - return undefined; + return nextBoundary ?? script.length; } function findTagAt(tags: SourceTagEntry[], index: number): SourceTagEntry | undefined { return tags.find((tag) => index >= tag.startIndex && index <= tag.endIndex); } -// --- Segment rendering --- - -// A single output line: bytecode ASM on the left, the source code it belongs to as a trailing comment interface Row { asm: string; comment: string; @@ -177,12 +129,11 @@ interface Row { interface RenderState { rows: Row[]; - renderedLine: number; // the last source line that has been rendered + lastRenderedLine: number; // the last source line that has been rendered } type RenderContext = WalkParams & { - skipLines: Set; // source lines rendered by function sections rather than as filler - headerLineLimit: number; // last source line above the contract's own opcodes, eligible for pre-filling + functionSectionLines: Set; // source lines rendered inside function sections, never as filler }; const BLANK_ROW: Row = { asm: '', comment: '' }; @@ -190,76 +141,80 @@ const BLANK_ROW: Row = { asm: '', comment: '' }; function renderSegments(segments: Segment[], params: WalkParams): Row[] { const context: RenderContext = { ...params, - skipLines: getLocalFunctionLines(params.functions ?? []), - headerLineLimit: deriveHeaderLineLimit(segments), + functionSectionLines: deriveFunctionSectionLines(segments, params.sourceLines), }; - const initialState: RenderState = { rows: [], renderedLine: params.startLine - 1 }; + const initialState: RenderState = { rows: [], lastRenderedLine: params.startLine - 1 }; const finalState = segments.reduce((state, segment) => renderSegment(state, segment, context), initialState); - return [...finalState.rows, ...fillerRows(finalState.renderedLine, params.endLine, context)]; + // After the last opcode, fill in the remaining source lines (e.g. the contract's closing braces) + return [...finalState.rows, ...fillerRows(finalState.lastRenderedLine, params.endLine, context)]; +} + +function deriveFunctionSectionLines(segments: Segment[], sourceLines: string[]): Set { + const localFunctionSegments = segments.filter( + (segment): segment is FunctionDefinitionSegment => segment.kind === 'functionDefinition' && segment.frame.source === undefined, + ); + + return new Set(localFunctionSegments.flatMap(({ location }) => { + let lastLine = location.end.line; + while (lastLine < sourceLines.length && sourceLines[lastLine].trim() === '') lastLine += 1; + return range(location.start.line, lastLine); + })); } function renderSegment(state: RenderState, segment: Segment, context: RenderContext): RenderState { if (segment.kind === 'functionDefinition') return renderFunctionDefinition(state, segment, context); + if (segment.kind === 'annotation') return renderAnnotation(state, segment, context); + return renderLineGroup(state, segment, context); +} - const fillThroughLine = segment.kind === 'lineGroup' ? segment.line - 1 : segment.fillThroughLine; - const comment = segment.kind === 'lineGroup' ? context.sourceLines[segment.line - 1] : segment.comment; - const reachedLine = segment.kind === 'lineGroup' ? segment.line : segment.fillThroughLine; - +// The group's row renders its own source line, after filling in the opcode-less lines above it +function renderLineGroup(state: RenderState, segment: LineGroupSegment, context: RenderContext): RenderState { return { rows: [ ...state.rows, - ...fillerRows(state.renderedLine, fillThroughLine, context), - { asm: context.asmIndent + segment.asm, comment }, + ...fillerRows(state.lastRenderedLine, segment.line - 1, context), + { asm: context.asmIndent + segment.asm, comment: context.sourceLines[segment.line - 1] }, ], - renderedLine: Math.max(state.renderedLine, Math.min(reachedLine, context.endLine)), + lastRenderedLine: advanceTo(state.lastRenderedLine, segment.line, context), }; } -function renderFunctionDefinition( - state: RenderState, - segment: Segment & { kind: 'functionDefinition' }, - context: RenderContext, -): RenderState { - const { frame, defineAsm } = segment; - const location = parseFrameLocation(frame); - - if (frame.sourceFile !== undefined) return renderImportedFunction(state, frame, location, defineAsm); - - // Functions defined above the contract get the source lines above them (pragma, imports, blank lines) - // rendered first, so their section lands at its natural source position. Functions defined below the - // contract render before the contract's rows regardless (bytecode order), so nothing is filled in. - const fillThroughLine = location.start.line <= context.headerLineLimit ? location.start.line - 1 : state.renderedLine; - +// The `>>>` annotation row lands right after its anchor line +function renderAnnotation(state: RenderState, segment: AnnotationSegment, context: RenderContext): RenderState { return { rows: [ ...state.rows, - ...fillerRows(state.renderedLine, fillThroughLine, context), - ...buildFunctionSection(frame, location, defineAsm, context.sourceLines), + ...fillerRows(state.lastRenderedLine, segment.insertAfterLine, context), + { asm: context.asmIndent + segment.asm, comment: segment.comment }, ], - renderedLine: Math.max(state.renderedLine, Math.min(fillThroughLine, context.endLine)), + lastRenderedLine: advanceTo(state.lastRenderedLine, segment.insertAfterLine, context), }; } -function renderImportedFunction(state: RenderState, frame: DebugFrame, location: LocationI, defineAsm: string): RenderState { - const headerRow = { asm: '', comment: `>>> function ${frame.name} (imported from ${frame.sourceFile})` }; - const sectionRows = buildFunctionSection(frame, location, defineAsm, frame.source!.split('\n')); +function renderFunctionDefinition( + state: RenderState, + segment: FunctionDefinitionSegment, + context: RenderContext, +): RenderState { + const { frame, location } = segment; + const isImported = frame.sourceFile !== undefined; + const sourceLines = isImported ? frame.source!.split('\n') : context.sourceLines; - // Blank rows around the section keep consecutive imported functions visually separated - const previousRow = state.rows[state.rows.length - 1]; - const leadingBlankRows = previousRow === undefined || isBlankRow(previousRow) ? [] : [BLANK_ROW]; + const headerRows = isImported + ? [{ asm: '', comment: `>>> function ${frame.name} (imported from ${frame.sourceFile})` }] + : []; return { - rows: [...state.rows, ...leadingBlankRows, headerRow, ...sectionRows, BLANK_ROW], - renderedLine: state.renderedLine, + rows: [...state.rows, ...headerRows, ...buildFunctionSection(frame, location, sourceLines), BLANK_ROW], + lastRenderedLine: state.lastRenderedLine, }; } -// Renders a function definition as a nested `<...>` push group (BitAuth IDE compiles a push group to the -// exact same bytes as the original body push), annotated with the function's own source code. -function buildFunctionSection(frame: DebugFrame, location: LocationI, defineAsm: string, sourceLines: string[]): Row[] { +function buildFunctionSection(frame: DebugFrame, location: LocationI, sourceLines: string[]): Row[] { const bodyScript = bytecodeToScript(hexToBin(frame.bytecode)); + const defineAsm = scriptToBitAuthAsm([encodeInt(BigInt(frame.id)), Op.OP_DEFINE]); const { start, end } = location; if (end.line === start.line) { @@ -284,42 +239,16 @@ function buildFunctionSection(frame: DebugFrame, location: LocationI, defineAsm: ]; } -// Comment-only rows for the source lines between the last rendered line and `throughLine` function fillerRows(afterLine: number, throughLine: number, context: RenderContext): Row[] { return range(afterLine + 1, Math.min(throughLine, context.endLine)) - .filter((line) => !context.skipLines.has(line)) + .filter((line) => !context.functionSectionLines.has(line)) .map((line) => ({ asm: '', comment: context.sourceLines[line - 1] })); } -// The last source line above the contract's own opcodes; lines up to this limit (pragma, imports, blank -// lines) can only ever be rendered as filler -function deriveHeaderLineLimit(segments: Segment[]): number { - const groupLines = segments.flatMap((segment) => (segment.kind === 'lineGroup' ? [segment.line] : [])); - return Math.min(...groupLines) - 1; -} - -// Source lines of same-file functions are rendered by their function section, not as filler rows -function getLocalFunctionLines(frames: readonly DebugFrame[]): Set { - const lines = frames - .filter((frame) => frame.source === undefined) - .flatMap((frame) => { - const { start, end } = parseFrameLocation(frame); - return range(start.line, end.line); - }); - - return new Set(lines); -} - -function parseFrameLocation(frame: DebugFrame): LocationI { - return sourceMapToLocationData(frame.location)[0].location; -} - -function isBlankRow(row: Row): boolean { - return row.asm === '' && row.comment === ''; +function advanceTo(renderedLine: number, line: number, context: RenderContext): number { + return Math.max(renderedLine, Math.min(line, context.endLine)); } -// --- Output rendering --- - function renderRows(rows: Row[]): string { const escapedRows = rows.map((row) => ({ asm: row.asm, comment: escapeCommentChars(row.comment) })); const maxAsmLength = Math.max(...escapedRows.map((row) => row.asm.length)); @@ -330,8 +259,6 @@ function renderRows(rows: Row[]): string { .join('\n'); } -// --- Helpers --- - function getDisplayLine(singleLocation: SingleLocationData): number { const { location, positionHint } = singleLocation; return positionHint === PositionHint.END ? location.end.line : location.start.line; @@ -341,11 +268,8 @@ function escapeCommentChars(text: string): string { return text.replaceAll('/*', '\\/*').replaceAll('*/', '*\\/'); } -// --- Source tag handling (annotations for compiler-injected opcodes) --- - -// Where a tag's annotation row lands: the source lines rendered before it, and the indentation it gets -interface TagAnchor { - fillThroughLine: number; +interface Anchor { + insertAfterLine: number; indent: string; } @@ -361,12 +285,12 @@ const EPILOGUE_KINDS = [ SourceTagKind.LOOP_CONDITION, ]; -function deriveTagAnchor( +function deriveAnchor( tag: SourceTagEntry, tags: SourceTagEntry[], locationData: FullLocationData, sourceLines: string[], -): TagAnchor { +): Anchor { if (PROLOGUE_KINDS.includes(tag.kind)) { const prologueTags = tags.filter((t) => PROLOGUE_KINDS.includes(t.kind)); @@ -375,10 +299,10 @@ function deriveTagAnchor( const firstBodyOpcode = lastPrologueOpcode + 1; const firstBodyLine = getDisplayLine(locationData[firstBodyOpcode]); - // Filling through `firstBodyLine - 1` lands all the prologue annotations directly above the first - // body statement, at its indentation. + // `insertAfterLine` of `firstBodyLine - 1` lands all the prologue annotations directly above the + // first body statement, at its indentation. return { - fillThroughLine: firstBodyLine - 1, + insertAfterLine: firstBodyLine - 1, indent: lineIndent(sourceLines, firstBodyLine), }; } @@ -387,13 +311,13 @@ function deriveTagAnchor( if (EPILOGUE_KINDS.includes(tag.kind)) { const braceLine = getDisplayLine(locationData[tag.startIndex]); return { - fillThroughLine: braceLine - 1, + insertAfterLine: braceLine - 1, indent: deriveIndent(locationData[tag.startIndex].location.start.line, sourceLines), }; } return { - fillThroughLine: getDisplayLine(locationData[Math.max(tag.startIndex - 1, 0)]), + insertAfterLine: getDisplayLine(locationData[Math.max(tag.startIndex - 1, 0)]), indent: deriveIndent(getDisplayLine(locationData[tag.startIndex]), sourceLines), }; } diff --git a/packages/utils/test/bitauth-script.test.ts b/packages/utils/test/bitauth-script.test.ts index 0ebb9cb4..06e1a782 100644 --- a/packages/utils/test/bitauth-script.test.ts +++ b/packages/utils/test/bitauth-script.test.ts @@ -1,7 +1,7 @@ import { binToHex, createCompilerBch } from '@bitauth/libauth'; import { Artifact } from '../src/artifact.js'; import { asmToScript, scriptToBytecode } from '../src/script.js'; -import { buildLineToAsmMap, formatBitAuthScript } from '../src/bitauth-script.js'; +import { formatBitAuthScript } from '../src/bitauth-script.js'; import { FunctionFixture, fixtures, functionFixtures } from './fixtures/bitauth-script.fixture.js'; import { compileFile, compileString } from 'cashc'; @@ -18,10 +18,6 @@ describe('Libauth Script formatting', () => { expect(artifact.bytecode).toEqual(fixture.asmBytecode); }); - it('should build a line-to-asm map', () => { - expect(buildLineToAsmMap(scriptBytecode, fixture.sourceMap)).toEqual(fixture.expectedLineToAsmMap); - }); - it('should format script as debugging output for BitAuth IDE', () => { const expectedBitAuthScript = fixture.expectedBitAuthScript.replace(/^\n+/, '').replace(/\n+$/, ''); const debugInformation = { diff --git a/packages/utils/test/fixtures/bitauth-script.fixture.ts b/packages/utils/test/fixtures/bitauth-script.fixture.ts index 1fd10815..97bbb9b9 100644 --- a/packages/utils/test/fixtures/bitauth-script.fixture.ts +++ b/packages/utils/test/fixtures/bitauth-script.fixture.ts @@ -6,7 +6,6 @@ export interface Fixture { asmBytecode: string; sourceMap: string; sourceTags?: string; - expectedLineToAsmMap: Record; expectedBitAuthScript: string; } @@ -33,22 +32,6 @@ contract TransferWithTimeout(bytes20 senderPkh, bytes20 recipientPkh, int timeou asmBytecode: 'OP_3 OP_PICK OP_0 OP_NUMEQUAL OP_IF OP_5 OP_ROLL OP_5 OP_PICK OP_CHECKSIGVERIFY OP_4 OP_ROLL OP_HASH160 OP_ROT OP_EQUAL OP_NIP OP_NIP OP_NIP OP_ELSE OP_3 OP_ROLL OP_1 OP_NUMEQUALVERIFY OP_DUP deadbeefdeadbeefdeadbeefdeadbeefdeadbeef OP_EQUALVERIFY OP_2 OP_PICK OP_3 OP_PICK OP_NUMEQUALVERIFY OP_4 OP_PICK OP_5 OP_PICK OP_EQUALVERIFY OP_3 OP_PICK OP_4 OP_PICK OP_EQUALVERIFY OP_4 OP_ROLL OP_4 OP_PICK OP_CHECKSIGVERIFY OP_3 OP_ROLL OP_HASH160 OP_EQUALVERIFY OP_SWAP OP_CHECKLOCKTIMEVERIFY OP_2DROP OP_1 OP_ENDIF', sourceMap: '3:2:6:3;;;;;4:21:4:22;;:24::33;;:4::36:1;5:20:5:29:0;;:12::30:1;:34::46:0;:4::48:1;3:45:6:3;;;:2;8::16::0;;;;9:12:9:21;:25::67;:4::69:1;10:12:10:19:0;;:23::30;;:4::32:1;11:12:11:13:0;;:17::18;;:4::20:1;12:12:12:21:0;;:25::34;;:4::36:1;13:21:13:22:0;;:24::33;;:4::36:1;14:20:14:29:0;;:12::30:1;:4::45;15:23:15:30:0;:4::32:1;8:44:16:3;;2:0:17:1', sourceTags: '15:17:sc', - expectedLineToAsmMap: { - 3: 'OP_3 OP_PICK OP_0 OP_NUMEQUAL OP_IF', - 4: 'OP_5 OP_ROLL OP_5 OP_PICK OP_CHECKSIGVERIFY', - 5: 'OP_4 OP_ROLL OP_HASH160 OP_ROT OP_EQUAL', - 6: 'OP_NIP OP_NIP OP_NIP OP_ELSE', - 8: 'OP_3 OP_ROLL OP_1 OP_NUMEQUALVERIFY', - 9: 'OP_DUP <0xdeadbeefdeadbeefdeadbeefdeadbeefdeadbeef> OP_EQUALVERIFY', - 10: 'OP_2 OP_PICK OP_3 OP_PICK OP_NUMEQUALVERIFY', - 11: 'OP_4 OP_PICK OP_5 OP_PICK OP_EQUALVERIFY', - 12: 'OP_3 OP_PICK OP_4 OP_PICK OP_EQUALVERIFY', - 13: 'OP_4 OP_ROLL OP_4 OP_PICK OP_CHECKSIGVERIFY', - 14: 'OP_3 OP_ROLL OP_HASH160 OP_EQUALVERIFY', - 15: 'OP_SWAP OP_CHECKLOCKTIMEVERIFY', - 16: 'OP_2DROP OP_1', - 17: 'OP_ENDIF', - }, expectedBitAuthScript: ` /* */ /* contract TransferWithTimeout(bytes20 senderPkh, bytes20 recipientPkh, int timeout) { */ @@ -110,26 +93,6 @@ contract Mecenas(bytes20 recipient, bytes20 funder, int pledge/*, int period */) asmBytecode: 'OP_3 OP_PICK OP_0 OP_NUMEQUAL OP_IF OP_0 OP_OUTPUTBYTECODE 76a914 OP_ROT OP_CAT 88ac OP_CAT OP_EQUALVERIFY e803 OP_INPUTINDEX OP_UTXOVALUE OP_DUP OP_4 OP_PICK OP_SUB OP_2 OP_PICK OP_SUB OP_DUP OP_5 OP_PICK OP_4 OP_PICK OP_ADD OP_LESSTHANOREQUAL OP_IF OP_0 OP_OUTPUTVALUE OP_2OVER OP_SWAP OP_SUB OP_NUMEQUALVERIFY OP_ELSE OP_0 OP_OUTPUTVALUE OP_5 OP_PICK OP_NUMEQUALVERIFY OP_1 OP_OUTPUTBYTECODE OP_INPUTINDEX OP_UTXOBYTECODE OP_EQUALVERIFY OP_1 OP_OUTPUTVALUE OP_OVER OP_NUMEQUALVERIFY OP_ENDIF OP_2DROP OP_2DROP OP_2DROP OP_1 OP_ELSE OP_3 OP_ROLL OP_1 OP_NUMEQUALVERIFY OP_3 OP_PICK OP_HASH160 OP_ROT OP_EQUALVERIFY OP_2SWAP OP_CHECKSIG OP_NIP OP_NIP OP_ENDIF', sourceMap: '9:4:28:5;;;;;13:27:13:28;:16::45:1;:49::84:0;:74::83;:49::84:1;;;:8::86;15:23:15:27:0;16:37:16:58;:27::65:1;17:26:17:38:0;:41::47;;:26:::1;:50::58:0;;:26:::1;21:12:21:23:0;:27::33;;:36::44;;:27:::1;:12;:46:23:9:0;22:31:22:32;:20::39:1;:43::66:0;;::::1;:12::68;23:15:27:9:0;24:31:24:32;:20::39:1;:43::49:0;;:12::51:1;25:31:25:32:0;:20::49:1;:63::84:0;:53::101:1;:12::103;26:31:26:32:0;:20::39:1;:43::54:0;:12::56:1;23:15:27:9;9:23:28:5;;;;:4;30::33::0;;;;31:24:31:26;;:16::27:1;:31::37:0;:8::39:1;32:25:32:30:0;:8::33:1;30:39:33:5;;8:0:34:1', sourceTags: '69:70:sc', - expectedLineToAsmMap: { - 9: 'OP_3 OP_PICK OP_0 OP_NUMEQUAL OP_IF', - 13: 'OP_0 OP_OUTPUTBYTECODE <0x76a914> OP_ROT OP_CAT <0x88ac> OP_CAT OP_EQUALVERIFY', - 15: '<0xe803>', - 16: 'OP_INPUTINDEX OP_UTXOVALUE', - 17: 'OP_DUP OP_4 OP_PICK OP_SUB OP_2 OP_PICK OP_SUB', - 21: 'OP_DUP OP_5 OP_PICK OP_4 OP_PICK OP_ADD OP_LESSTHANOREQUAL OP_IF', - 22: 'OP_0 OP_OUTPUTVALUE OP_2OVER OP_SWAP OP_SUB OP_NUMEQUALVERIFY', - 23: 'OP_ELSE', - 24: 'OP_0 OP_OUTPUTVALUE OP_5 OP_PICK OP_NUMEQUALVERIFY', - 25: 'OP_1 OP_OUTPUTBYTECODE OP_INPUTINDEX OP_UTXOBYTECODE OP_EQUALVERIFY', - 26: 'OP_1 OP_OUTPUTVALUE OP_OVER OP_NUMEQUALVERIFY', - 27: 'OP_ENDIF', - 28: 'OP_2DROP OP_2DROP OP_2DROP OP_1 OP_ELSE', - 30: 'OP_3 OP_ROLL OP_1 OP_NUMEQUALVERIFY', - 31: 'OP_3 OP_PICK OP_HASH160 OP_ROT OP_EQUALVERIFY', - 32: 'OP_2SWAP OP_CHECKSIG', - 33: 'OP_NIP OP_NIP', - 34: 'OP_ENDIF', - }, expectedBitAuthScript: ` /* pragma cashscript >=0.8.0; */ /* */ @@ -208,17 +171,6 @@ contract HodlVault( asmBytecode: 'OP_6 OP_ROLL OP_SIZE OP_8 OP_EQUALVERIFY OP_DUP OP_4 OP_SPLIT OP_SWAP OP_BIN2NUM OP_SWAP OP_BIN2NUM OP_OVER OP_6 OP_ROLL OP_GREATERTHANOREQUAL OP_VERIFY OP_SWAP OP_CHECKLOCKTIMEVERIFY OP_DROP OP_4 OP_ROLL OP_GREATERTHANOREQUAL OP_VERIFY OP_4 OP_ROLL OP_SWAP OP_3 OP_ROLL OP_CHECKDATASIGVERIFY OP_CHECKSIG', sourceMap: '15:8:15:28;;;;;18:49:18:62;:69::70;:49::71:1;19:30:19:44:0;:26::45:1;20:24:20:32:0;:20::33:1;23:16:23:27:0;:31::39;;:16:::1;:8::41;24:27:24:38:0;:8::40:1;;27:25:27:36:0;;:16:::1;:8::38;30:29:30::0;;:40::53;:55::63;;:8::66:1;31::31:45', sourceTags: '0:4:pv', - expectedLineToAsmMap: { - 15: 'OP_6 OP_ROLL OP_SIZE OP_8 OP_EQUALVERIFY', - 18: 'OP_DUP OP_4 OP_SPLIT', - 19: 'OP_SWAP OP_BIN2NUM', - 20: 'OP_SWAP OP_BIN2NUM', - 23: 'OP_OVER OP_6 OP_ROLL OP_GREATERTHANOREQUAL OP_VERIFY', - 24: 'OP_SWAP OP_CHECKLOCKTIMEVERIFY OP_DROP', - 27: 'OP_4 OP_ROLL OP_GREATERTHANOREQUAL OP_VERIFY', - 30: 'OP_4 OP_ROLL OP_SWAP OP_3 OP_ROLL OP_CHECKDATASIGVERIFY', - 31: 'OP_CHECKSIG', - }, expectedBitAuthScript: ` /* // This contract forces HODLing until a certain price target has been reached */ /* // A minimum block is provided to ensure that oracle price entries from before this block are disregarded */ @@ -280,17 +232,6 @@ contract ForWhileNested() { asmBytecode: 'OP_0 OP_0 OP_BEGIN OP_DUP OP_2 OP_LESSTHAN OP_DUP OP_TOALTSTACK OP_IF OP_0 OP_BEGIN OP_DUP OP_2 OP_LESSTHAN OP_DUP OP_TOALTSTACK OP_IF OP_2 OP_PICK OP_2 OP_PICK OP_ADD OP_OVER OP_ADD OP_3 OP_ROLL OP_DROP OP_SWAP OP_TOALTSTACK OP_SWAP OP_FROMALTSTACK OP_DUP OP_1ADD OP_NIP OP_ENDIF OP_FROMALTSTACK OP_NOT OP_UNTIL OP_OVER OP_1ADD OP_ROT OP_DROP OP_NIP OP_ENDIF OP_FROMALTSTACK OP_NOT OP_UNTIL OP_DROP OP_4 OP_NUMEQUAL', sourceMap: '3:18:3:19;5:21:5:22;:8:13:9;:24:5:25;:28::29;:24:::1;;;:42:13:9:0;6:20:6:21;8:12:12:13;:19:8:20;:23::24;:19:::1;;;:26:12:13:0;9:22:9:25;;:28::29;;:22:::1;:32::33:0;:22:::1;:16::34;;;;;;;10:20:10:21:0;:::25:1;:16::26;8:26:12:13;;:12;;5:35:5:36:0;:::40:1;:31;;::13:9;:42;;:8;;;15:23:15:24:0;:8::26:1', sourceTags: '34:37:lc;38:42:fu;43:46:lc;47:47:sc', - expectedLineToAsmMap: { - 3: 'OP_0', - 5: 'OP_0 OP_BEGIN OP_DUP OP_2 OP_LESSTHAN OP_DUP OP_TOALTSTACK OP_IF OP_OVER OP_1ADD OP_ROT OP_DROP', - 6: 'OP_0', - 8: 'OP_BEGIN OP_DUP OP_2 OP_LESSTHAN OP_DUP OP_TOALTSTACK OP_IF', - 9: 'OP_2 OP_PICK OP_2 OP_PICK OP_ADD OP_OVER OP_ADD OP_3 OP_ROLL OP_DROP OP_SWAP OP_TOALTSTACK OP_SWAP OP_FROMALTSTACK', - 10: 'OP_DUP OP_1ADD OP_NIP', - 12: 'OP_ENDIF OP_FROMALTSTACK OP_NOT OP_UNTIL', - 13: 'OP_NIP OP_ENDIF OP_FROMALTSTACK OP_NOT OP_UNTIL OP_DROP', - 15: 'OP_4 OP_NUMEQUAL', - }, expectedBitAuthScript: ` /* contract ForWhileNested() { */ /* function spend() { */ @@ -339,20 +280,6 @@ OP_4 OP_NUMEQUAL asmBytecode: 'OP_0 OP_0 OP_BEGIN OP_DUP OP_3 OP_LESSTHAN OP_DUP OP_TOALTSTACK OP_IF OP_0 OP_BEGIN OP_DUP OP_2 OP_LESSTHAN OP_DUP OP_TOALTSTACK OP_IF OP_2 OP_PICK OP_2 OP_PICK OP_ADD OP_OVER OP_ADD OP_3 OP_ROLL OP_DROP OP_SWAP OP_TOALTSTACK OP_SWAP OP_FROMALTSTACK OP_DUP OP_1ADD OP_NIP OP_ENDIF OP_FROMALTSTACK OP_NOT OP_UNTIL OP_DROP OP_DUP OP_1ADD OP_NIP OP_ENDIF OP_FROMALTSTACK OP_NOT OP_UNTIL OP_DROP OP_0 OP_BEGIN OP_DUP OP_1ADD OP_NIP OP_DUP OP_3 OP_GREATERTHANOREQUAL OP_UNTIL OP_ADD OP_12 OP_NUMEQUAL', sourceMap: '3:20:3:21;5:21:5:22;:8:10:9;:24:5:25;:28::29;:24:::1;;;6:19:10:9:0;7:25:7:26;:12:9:13;:28:7:29;:32::33;:28:::1;;;:46:9:13:0;8:24:8:29;;:32::33;;:24:::1;:36::37:0;:24:::1;:16::38;;;;;;;7:39:7:40:0;:::44:1;:35;:46:9:13;;:12;;;6::6::0;:::17:1;5:31;6:19:10:9;;5:8;;;12:20:12:21:0;13:8:15:28;14:20:14:25;:::29:1;:12::30;15:17:15:22:0;:25::26;13:8::28:1;;17:16:17:29;:33::35:0;:8::37:1', sourceTags: '31:33:fu;34:37:lc;38:38:sc;39:41:fu;42:45:lc;46:46:sc', - expectedLineToAsmMap: { - 3: 'OP_0', - 5: 'OP_0 OP_BEGIN OP_DUP OP_3 OP_LESSTHAN OP_DUP OP_TOALTSTACK', - 6: 'OP_IF OP_DUP OP_1ADD OP_NIP', - 7: 'OP_0 OP_BEGIN OP_DUP OP_2 OP_LESSTHAN OP_DUP OP_TOALTSTACK OP_IF OP_DUP OP_1ADD OP_NIP', - 8: 'OP_2 OP_PICK OP_2 OP_PICK OP_ADD OP_OVER OP_ADD OP_3 OP_ROLL OP_DROP OP_SWAP OP_TOALTSTACK OP_SWAP OP_FROMALTSTACK', - 9: 'OP_ENDIF OP_FROMALTSTACK OP_NOT OP_UNTIL OP_DROP', - 10: 'OP_ENDIF OP_FROMALTSTACK OP_NOT OP_UNTIL OP_DROP', - 12: 'OP_0', - 13: 'OP_BEGIN', - 14: 'OP_DUP OP_1ADD OP_NIP', - 15: 'OP_DUP OP_3 OP_GREATERTHANOREQUAL OP_UNTIL', - 17: 'OP_ADD OP_12 OP_NUMEQUAL', - }, expectedBitAuthScript: ` /* contract NestedForWithDoWhile() { */ /* function spend() { */ @@ -393,10 +320,6 @@ OP_ADD OP_12 OP_NUMEQUAL asmBytecode: 'OP_SIZE OP_8 OP_EQUALVERIFY OP_SIZE OP_NIP OP_8 OP_NUMEQUAL', sourceMap: '3:8:3:18;;;5:16:5:26:1;;:30::31:0;:8::33:1', sourceTags: '0:2:pv', - expectedLineToAsmMap: { - 3: 'OP_SIZE OP_8 OP_EQUALVERIFY', - 5: 'OP_SIZE OP_NIP OP_8 OP_NUMEQUAL', - }, expectedBitAuthScript: ` /* contract ParameterCheck() { */ /* function spend( */ @@ -418,10 +341,6 @@ OP_SIZE OP_NIP OP_8 OP_NUMEQUAL /* require(tag.length == 8); asmBytecode: 'OP_TXLOCKTIME OP_CHECKLOCKTIMEVERIFY OP_DROP OP_TXLOCKTIME OP_1 OP_GREATERTHANOREQUAL', sourceMap: '2:21:2:21;::::1;;3:16:3:27:0;:31::32;:8::34:1', sourceTags: '0:2:lg', - expectedLineToAsmMap: { - 2: 'OP_TXLOCKTIME OP_CHECKLOCKTIMEVERIFY OP_DROP', - 3: 'OP_TXLOCKTIME OP_1 OP_GREATERTHANOREQUAL', - }, expectedBitAuthScript: ` /* contract LocktimeGuard() { */ /* function spend() { */ @@ -442,11 +361,6 @@ OP_TXLOCKTIME OP_1 OP_GREATERTHANOREQUAL /* require(tx.locktime >= 1 asmBytecode: 'OP_SIZE OP_8 OP_EQUALVERIFY OP_TXLOCKTIME OP_CHECKLOCKTIMEVERIFY OP_DROP OP_SIZE OP_NIP OP_8 OP_NUMEQUALVERIFY OP_TXLOCKTIME OP_1 OP_GREATERTHANOREQUAL', sourceMap: '2:19:2:29;;;:31::31;::::1;;3:16:3:26;;:30::31:0;:8::33:1;4:16:4:27:0;:31::32;:8::34:1', sourceTags: '0:2:pv;3:5:lg', - expectedLineToAsmMap: { - 2: 'OP_SIZE OP_8 OP_EQUALVERIFY OP_TXLOCKTIME OP_CHECKLOCKTIMEVERIFY OP_DROP', - 3: 'OP_SIZE OP_NIP OP_8 OP_NUMEQUALVERIFY', - 4: 'OP_TXLOCKTIME OP_1 OP_GREATERTHANOREQUAL', - }, expectedBitAuthScript: ` /* contract ParameterLocktimeGuard() { */ /* function spend(bytes8 tag) { */ @@ -472,13 +386,6 @@ OP_TXLOCKTIME OP_1 OP_GREATERTHANOREQUAL /* require(tx.locktime >= 1 asmBytecode: 'OP_DUP OP_0 OP_GREATERTHAN OP_IF OP_DUP OP_1ADD OP_DUP OP_0 OP_GREATERTHAN OP_VERIFY OP_DROP OP_ENDIF OP_0 OP_GREATERTHAN', sourceMap: '3:12:3:13;:16::17;:12:::1;:19:6:9:0;4:20:4:21;:::25:1;5::5:21:0;:24::25;:20:::1;:12::27;3:19:6:9;;7:20:7:21:0;:8::23:1', sourceTags: '10:10:sc', - expectedLineToAsmMap: { - 3: 'OP_DUP OP_0 OP_GREATERTHAN OP_IF', - 4: 'OP_DUP OP_1ADD', - 5: 'OP_DUP OP_0 OP_GREATERTHAN OP_VERIFY', - 6: 'OP_DROP OP_ENDIF', - 7: 'OP_0 OP_GREATERTHAN', - }, expectedBitAuthScript: ` /* contract ScopeCleanup() { */ /* function spend(int x) { */ @@ -642,6 +549,7 @@ function double(int a) returns (int) { < /* function double(int a) returns (int) { */ OP_2 OP_MUL /* return a * 2; */ > OP_0 OP_DEFINE /* } */ + /* */ /* contract AfterContract() { */ /* function spend(int x) { */ OP_0 OP_INVOKE OP_10 OP_NUMEQUAL /* require(double(x) == 10, 'mismatch'); */ From 78c43358356d3a79c961b770636ba90debb4fda2 Mon Sep 17 00:00:00 2001 From: Rosco Kalis Date: Tue, 7 Jul 2026 10:28:37 +0200 Subject: [PATCH 6/6] Delete functions-todos.md --- functions-todos.md | 165 --------------------------------------------- 1 file changed, 165 deletions(-) delete mode 100644 functions-todos.md diff --git a/functions-todos.md b/functions-todos.md deleted file mode 100644 index 72348e31..00000000 --- a/functions-todos.md +++ /dev/null @@ -1,165 +0,0 @@ -# User-defined functions — roadmap to a stable mainline release - -Status of the current branch (`v0.14.0-next.0`, compiler-only): top-level standalone -functions, single-value-or-void returns, grammar-native **relative** imports, always -`OP_DEFINE`/`OP_INVOKE` (no inlining), one flat global namespace. No SDK debugging, no -safety gates, no optimization passes for functions. - -This document lists what to close before calling the feature stable, drawing on the two -prototype branches (`feat/library-support`, `review/bch-functions-next`) and on gaps that -**all three** prototypes missed. - -Legend: _(port: branch)_ = adapt an existing prototype implementation · _(novel)_ = not -addressed by any prototype. - ---- - -## Tier 1 — correctness & debuggability (blockers for "stable") - -### 1. Frame-aware SDK debugging _(port: `review/bch-functions-next`)_ — **Done** -A `require` that fails inside a function, and `console.log` in a function body, now attribute to the -correct source line (and file, for imported functions). Implemented as self-contained frames rather -than the prototype's flat-and-stamped model: -- Artifact: `DebugInformation.functions?: DebugFrame[]` (`packages/utils/src/artifact.ts`), one - self-contained `{ name, inputs, bytecode, sourceMap, logs, requires, source?, sourceFile? }` per - function with frame-local ips. The top-level fields stay the implicit root frame, so function-less - contracts are - byte-identical and old artifacts keep working. The compiler just stops discarding the body's - already-computed optimised debug info (`compileGlobalFunctionBody`). -- SDK: `resolveFrame`/`getActiveBytecode` (`debugging.ts`) identify the active frame per step by - matching `state.instructions` bytecode (WeakMap-cached), then the existing ip attribution runs against - that frame's arrays; `getLocationDataForFrame` + a `ResolvedFrame` (`Errors.ts`) apply the constructor - offset only for the root. Phase detection now keys on `ip === 0 && controlStack.length === 0`. -- Imports get per-file source provenance via `FunctionDefinitionNode.sourceCode/sourceFile`, stamped in - `dependency-resolution.ts` (no text-level line remapping needed, unlike the prototype). -- Deviation from the proposal: skipped `assertDistinctHelperFrameBytecode`. Two functions with - byte-identical bodies are rare but valid, and a hard compile error for a debug-only concern is the - wrong trade; attribution degrades gracefully to the first matching frame instead. -- Tested in `debugging.test.ts` (require-in-function, log-in-function, contract-level require still - correct, imported-function require attributing to the imported file). - -### 2. BitAuth IDE source mapping with functions — **Done** -The template previously fell back to plain unmapped asm when frames were present. `formatBitAuthScript` -(`packages/utils/src/bitauth-script.ts`) was rewritten to walk the script in **bytecode order** (instead of -grouping opcodes by source line), so opcode order preservation — required because the IDE *executes* the -formatted text — is now structural rather than dependent on monotonic source maps. Function definitions -render as nested `<...>` BTL push groups (byte-identical via `encodeDataPush` on both sides) annotated with -the function's own source, sliced to just that function via the new `DebugFrame.location`; imported -functions get a `>>> function f (imported from file.cash)` header row. `DebugFrame` also gained -`sourceTags` (previously computed and discarded) so loop/cleanup `>>>` annotations render inside bodies. -Old artifacts degrade gracefully (frames without `location` render as an opaque `<0x...>` push; artifacts -without `debug` keep the plain-asm fallback). Function-less output is byte-identical to before (pinned -fixtures unchanged). Tested in `packages/utils/test/bitauth-script.test.ts` (function fixtures + a BTL -round-trip byte-equality check on every fixture) and `debugging.test.ts` (template text assertions). - ---- - -## Tier 2 — expressiveness (port/adapt from prototypes) - -### 4. Multi-value / tuple returns _(port: `feat/library-support`)_ -`returns (int, int)` + `return a, b`. Common need (return a value plus a derived check); we -intentionally cut it to a single value. - -### 5. Global `constant` definitions _(port: `feat/library-support`)_ -Compile-time-folded top-level constants, shareable via imports. A frequent request -independent of functions, and a natural companion to them. - -### 6. `library {}` containers + namespaced / aliased imports _(port: both branches)_ -`import "./math.cash" as Math;` + `Math.double(x)`. Solves the **single flat global -namespace** limitation we shipped (today a name may exist only once across the whole import -graph — collisions are unavoidable at ecosystem scale). Libraries also give a unit to -publish and audit. - -### 8. Size-gated inlining for tiny bodies _(port: `feat/library-support`, ≤6 bytes)_ -For trivial helpers the `OP_DEFINE`/`OP_INVOKE` overhead exceeds the body; inline those. -Pure size/cost optimization, safe to add later since it doesn't change semantics. (Note: -inlining reintroduces the recursion-expansion problem, so it must be gated to -non-recursive functions — keep the always-`OP_INVOKE` path for recursive ones.) - ---- - -## Tier 3 — optimization & ecosystem - -### 9. Pluggable import resolver _(novel for us; hook exists in `review/bch-functions-next`)_ -Confirmed gap: `dependency-resolution.ts` calls `fs.readFileSync` directly and -`CompileOptions` exposes only `errorListener`/`basePath`. Let `compileString` resolve -imports via a caller-supplied `(path) => source` callback and/or a virtual file map. -Required for the **Playground / any browser compile** to use imports at all — today it's -Node-fs-only. - -### 10. Registry / package / URL imports + remappings _(novel — all prototypes are relative-path only)_ -No prototype has a notion of importing from a published package or a remapped alias -(cf. Solidity `node_modules` / remappings). For a real contract ecosystem this is how -shared, versioned, audited code circulates. Depends on #9. - ---- - -## Code quality revisits - -Non-blocking internal cleanups to revisit before a stable release. No behaviour change intended. - -### `optimiseBytecode` signature -`optimiseBytecode` (`packages/utils/src/script.ts`) takes seven positional parameters -(`script`, `locationData`, `logs`, `requires`, `sourceTags`, `constructorParamLength`, `runs`), four -of which are parallel debug arrays passed positionally at both call sites (`compiler.ts` and -`GenerateTargetTraversal.compileGlobalFunctionBody`). Verbose and easy to get wrong. Collapse the -debug arguments into a single object (or an options arg). - -### `ensureSingleTailReturn` -`ensureSingleTailReturn` in `packages/cashc/src/semantic/EnsureFinalRequireTraversal.ts` carries a -self described "a bit convoluted" TODO (final-statement check plus a recursive `findReturn` stray -scan). Tidy it up. Likely rewritten when early/conditional returns land (see the Extension section). - -### Inject locktime guard analysis -`InjectLocktimeGuardTraversal` (`packages/cashc/src/semantic/InjectLocktimeGuardTraversal.ts`) uses a -second traversal class (`LocktimeGuardRequirementAnalyser`) constructed per function with a callback, -plus a memoised map with seed-false cycle breaking. Revisit whether the two-traversal plus callback -structure can be simplified. - ---- - -## Extension (post-mainline) — early / conditional (nested & multiple) return statements - -Distinct from #4 (multi-**value** returns): this is about multiple/nested return *statements* -— early returns, guard clauses, branch-specific returns — i.e. control flow, not the number -of returned values. **Not targeted for the mainline release** — neither the structured subset -nor the loop/general case. Captured here as a future extension. Today a value-returning -function must end with exactly one tail `return` -(`packages/cashc/src/semantic/EnsureFinalRequireTraversal.ts`, `ensureSingleTailReturn`, which -already carries a TODO anticipating this work). - -**Core constraint.** The BCH VM has no return/jump opcode: a function returns only by running -off the end of its body (`opInvoke` pushes the caller frame onto the control stack; the -`continue` handler pops it when `ip >= instructions.length`). All control flow is structured -(`OP_IF`/`OP_ENDIF`, `OP_BEGIN`/`OP_UNTIL`) — there is no "jump to function end." So `return` -is not a primitive; every non-tail return must be compiled away by restructuring the body so -that all paths converge to leaving the value on top at the end. - -Two sub-parts, very different cost: - -- **Structured returns (tail + guard clauses)** — e.g. `if (a > 10) { return 10; } return a;`. - Semantically `a > 10 ? 10 : a`; a guard clause hoists the remaining statements into the else - branch, and nested tail returns recurse into nested if/else. Compiles to the same bytecode as - the hand-written single-`result`-variable equivalent — ~zero extra cost. The "every path - terminates correctly" analysis already exists in the analogous `ensureFinalStatementIsRequire` - branch recursion for contract `require`s. -- **Loop / fully-general returns** — e.g. a `return` inside a `for` that must break the loop - *and* the function. No structured transform expresses "break to function end"; needs a runtime - `returned` flag OR'd into the loop condition plus guarding every subsequent statement with - `if (!returned)`. Extra opcodes + a memory slot, pressing on the 100-slot control-stack / - op-cost budget. Viable but expensive. - -**Implementation approach.** Do it as an AST **normalization-to-single-exit** pass *before* -codegen (reduce a multi-return body to one tail return / convergent if/else), so -`GenerateTargetTraversal` and the existing single-exit `cleanStack` / -`removeFinalVerifyFromFunction` keep working unchanged. Emitting unstructured returns directly -would instead force fragile per-return-site stack cleanup. Add the supporting analysis: -definite-return on all paths, unreachable-code-after-return, and return-type consistency across -all return sites (scaffolding partly present: `MissingReturnError`, `MisplacedReturnError`). - -**Suggested phasing if/when picked up:** structured returns first (normalization + path -analysis, disallow `return` inside loops with a clear error), then loop/general returns via -flag-guarding only if there is demand. - - -Also perhaps it makes sense to have a separate importedSources so that multiple functions from the same source don't have to repeat the full source code multiple times.