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/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..07654a0c 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, @@ -12,7 +12,9 @@ import { scriptToBytecode, optimiseBytecode, generateSourceMap, + generateSourceTags, FullLocationData, + DebugFrame, LogEntry, RequireStatement, PositionHint, @@ -77,6 +79,7 @@ export default class GenerateTargetTraversal extends AstTraversal { consoleLogs: LogEntry[] = []; requires: RequireStatement[] = []; sourceTags: SourceTagEntry[] = []; + frames: DebugFrame[] = []; finalStackUsage: Record = {}; private scopeDepth = 0; @@ -151,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); // @@ -160,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; @@ -183,7 +186,23 @@ export default class GenerateTargetTraversal extends AstTraversal { 0, ); - return scriptToBytecode(optimised.script); + const bodyBytecode = scriptToBytecode(optimised.script); + 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 } : {}), + ...(node.sourceCode !== undefined ? { source: node.sourceCode } : {}), + ...(node.sourceFile !== undefined ? { sourceFile: node.sourceFile } : {}), + logs: optimised.logs, + requires: optimised.requires, + }); + + return bodyBytecode; } cleanGlobalFunctionStack(node: FunctionDefinitionNode): void { 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 a8a6c2ec..a22d7743 100644 --- a/packages/cashc/test/generation/fixtures.ts +++ b/packages/cashc/test/generation/fixtures.ts @@ -1427,6 +1427,17 @@ export const fixtures: Fixture[] = [ { ip: 7, line: 7 }, ], 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: [], + }, + ], }, source: fs.readFileSync(new URL('../valid-contract-files/global_function_simple.cash', import.meta.url), { encoding: 'utf-8' }), compiler: { @@ -1461,6 +1472,17 @@ 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: [ + { + id: 0, + 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 +1516,17 @@ 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: [ + { + 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 }], + }, + ], }, source: fs.readFileSync(new URL('../valid-contract-files/global_function_void.cash', import.meta.url), { encoding: 'utf-8' }), compiler: { @@ -1533,6 +1566,41 @@ 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: [ + { + id: 0, + 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', + }, + { + id: 1, + 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', + }, + { + id: 2, + 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..85740c71 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,15 @@ 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); + 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); @@ -154,10 +158,13 @@ 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 context = formatFrameContext(resolvedFrame, artifact.contractName, lineNumber); - 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} ${context}`; const baseMessageWithRequireMessage = `${baseMessage} with the following message: ${requireStatement.message}`; const headline = `${requireStatement.message ? baseMessageWithRequireMessage : baseMessage}.`; @@ -169,19 +176,28 @@ export class FailedRequireError extends FailedTransactionError { } } -const getLocationDataForInstructionPointer = ( - artifact: Artifact, +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, ): { 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..5ec61c83 --- /dev/null +++ b/packages/cashscript/src/debug-frame.ts @@ -0,0 +1,45 @@ +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[]; + functionName?: string; +} + +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, + ...(frame.sourceFile !== undefined ? { functionName: frame.name } : {}), + }; +}; 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..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,6 +77,7 @@ export const formatParametersForDebugging = (types: readonly AbiInput[], args: E }; export const formatBytecodeForDebugging = (artifact: Artifact): string => { + // Old artifacts carry no debug information, so we render the raw bytecode in execution order if (!artifact.debug) { return artifact.bytecode .split(' ') @@ -84,12 +85,7 @@ export const formatBytecodeForDebugging = (artifact: Artifact): string => { .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 441afcab..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'; @@ -14,6 +14,9 @@ import { artifactTestZeroHandling, artifactTestRequireInsideLoop, artifactTestLogInsideLoop, + artifactTestFunctionDebugging, + artifactTestFunctionIntermediateResults, + artifactTestImportedFunctionDebugging, } from './fixture/debugging/debugging_contracts.js'; import { sha256 } from '@cashscript/utils'; @@ -814,3 +817,87 @@ 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, 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}$`)); + }); + + 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/cashscript/test/fixture/debugging/debugging_contracts.ts b/packages/cashscript/test/fixture/debugging/debugging_contracts.ts index be048ef2..a54f7604 100644 --- a/packages/cashscript/test/fixture/debugging/debugging_contracts.ts +++ b/packages/cashscript/test/fixture/debugging/debugging_contracts.ts @@ -1,4 +1,33 @@ -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_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() { @@ -437,3 +466,8 @@ 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); +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)); 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..040c3b47 100644 --- a/packages/utils/src/artifact.ts +++ b/packages/utils/src/artifact.ts @@ -19,6 +19,20 @@ 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 { + 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) + 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 + logs: readonly LogEntry[]; // frame-local log entries + requires: readonly RequireStatement[]; // frame-local require statements } export interface LogEntry { diff --git a/packages/utils/src/bitauth-script.ts b/packages/utils/src/bitauth-script.ts index 9cd3aab5..b2aee008 100644 --- a/packages/utils/src/bitauth-script.ts +++ b/packages/utils/src/bitauth-script.ts @@ -1,60 +1,263 @@ -import { range } from './data.js'; -import { Script, scriptToBitAuthAsm } from './script.js'; +import { hexToBin } from '@bitauth/libauth'; +import { DebugFrame, DebugInformation } from './artifact.js'; +import { encodeInt, range } from './data.js'; +import { Op, 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; +export function formatBitAuthScript(debug: DebugInformation, sourceCode: string): string { + const sourceLines = sourceCode.split('\n'); -export function buildLineToOpcodesMap( - bytecode: Script, - sourceMapOrLocationData: string | FullLocationData, -): LineToOpcodesMap { - const locationData = typeof sourceMapOrLocationData === 'string' ? sourceMapToLocationData(sourceMapOrLocationData) : sourceMapOrLocationData; + 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 locationData.reduce((lineToOpcodeMap, singleLocation, index) => { - const opcode = bytecode[index]; - const line = getDisplayLine(singleLocation); + return renderRows(rows); +} - return { - ...lineToOpcodeMap, - [line]: [...(lineToOpcodeMap[line] || []), opcode], - }; - }, {}); +interface WalkParams { + script: Script; + sourceMap: string; + sourceTags?: string; + functions?: readonly DebugFrame[]; + sourceLines: string[]; + startLine: number; + endLine: number; + asmIndent: string; +} + +function walkScript(params: WalkParams): Row[] { + const segments = segmentScript(params); + return renderSegments(segments, params); +} + +interface LineGroupSegment { + kind: 'lineGroup'; + asm: string; + line: number; +} + +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 frames = params.functions ?? []; + + const segments: Segment[] = []; + let index = 0; + + while (index < script.length) { + // 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; + } + + const tag = findTagAt(tags, index); + if (tag) { + segments.push(annotationSegment(script, tag, tags, locationData, sourceLines)); + index = tag.endIndex + 1; + 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; +} + +function annotationSegment( + script: Script, + tag: SourceTagEntry, + tags: SourceTagEntry[], + locationData: FullLocationData, + sourceLines: string[], +): 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)}`, + insertAfterLine, + }; +} + +// 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 + )); + + return nextBoundary ?? script.length; +} + +function findTagAt(tags: SourceTagEntry[], index: number): SourceTagEntry | undefined { + return tags.find((tag) => index >= tag.startIndex && index <= tag.endIndex); +} + +interface Row { + asm: string; + comment: string; } -export function buildLineToAsmMap(bytecode: Script, sourceMapOrLocationData: string | FullLocationData): LineToAsmMap { - const lineToOpcodesMap = buildLineToOpcodesMap(bytecode, sourceMapOrLocationData); +interface RenderState { + rows: Row[]; + lastRenderedLine: number; // the last source line that has been rendered +} + +type RenderContext = WalkParams & { + functionSectionLines: Set; // source lines rendered inside function sections, never as filler +}; + +const BLANK_ROW: Row = { asm: '', comment: '' }; + +function renderSegments(segments: Segment[], params: WalkParams): Row[] { + const context: RenderContext = { + ...params, + functionSectionLines: deriveFunctionSectionLines(segments, params.sourceLines), + }; + + const initialState: RenderState = { rows: [], lastRenderedLine: params.startLine - 1 }; + const finalState = segments.reduce((state, segment) => renderSegment(state, segment, context), initialState); + + // 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)]; +} - return Object.fromEntries( - Object.entries(lineToOpcodesMap).map(([lineNumber, opcodeList]) => [lineNumber, scriptToBitAuthAsm(opcodeList)]), +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); + })); } -export function formatBitAuthScript(bytecode: Script, sourceMap: string, sourceCode: string, sourceTags?: string): string { - const locationData = sourceMapToLocationData(sourceMap); - const sourceLines = sourceCode.split('\n'); +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); +} + +// 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.lastRenderedLine, segment.line - 1, context), + { asm: context.asmIndent + segment.asm, comment: context.sourceLines[segment.line - 1] }, + ], + lastRenderedLine: advanceTo(state.lastRenderedLine, segment.line, context), + }; +} + +// The `>>>` annotation row lands right after its anchor line +function renderAnnotation(state: RenderState, segment: AnnotationSegment, context: RenderContext): RenderState { + return { + rows: [ + ...state.rows, + ...fillerRows(state.lastRenderedLine, segment.insertAfterLine, context), + { asm: context.asmIndent + segment.asm, comment: segment.comment }, + ], + lastRenderedLine: advanceTo(state.lastRenderedLine, segment.insertAfterLine, context), + }; +} + +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; + + const headerRows = isImported + ? [{ asm: '', comment: `>>> function ${frame.name} (imported from ${frame.sourceFile})` }] + : []; + + return { + rows: [...state.rows, ...headerRows, ...buildFunctionSection(frame, location, sourceLines), BLANK_ROW], + lastRenderedLine: state.lastRenderedLine, + }; +} + +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; - // 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); + if (end.line === start.line) { + const asm = ['<', scriptToBitAuthAsm(bodyScript), '>', defineAsm].filter((part) => part !== '').join(' '); + return [{ asm, comment: sourceLines[start.line - 1] }]; + } + + const bodyRows = walkScript({ + script: bodyScript, + sourceMap: frame.sourceMap, + sourceTags: frame.sourceTags, + sourceLines, + startLine: start.line + 1, + endLine: end.line - 1, + asmIndent: ' ', + }); - // Group opcodes by display line and convert to ASM - const lineToAsm = buildLineToAsmMap(bytecode, splicedLocationData); + return [ + { asm: '<', comment: sourceLines[start.line - 1] }, + ...bodyRows, + { asm: `> ${defineAsm}`, comment: sourceLines[end.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)); +function fillerRows(afterLine: number, throughLine: number, context: RenderContext): Row[] { + return range(afterLine + 1, Math.min(throughLine, context.endLine)) + .filter((line) => !context.functionSectionLines.has(line)) + .map((line) => ({ asm: '', comment: context.sourceLines[line - 1] })); +} - return escapedLines.map((src, i) => { - const asm = lineToAsm[i + 1] || ''; - return `${asm.padEnd(maxAsmLen)} /* ${src.padEnd(maxSrcLen)} */`; - }).join('\n'); +function advanceTo(renderedLine: number, line: number, context: RenderContext): number { + return Math.max(renderedLine, Math.min(line, context.endLine)); } -// --- Helpers --- +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'); +} function getDisplayLine(singleLocation: SingleLocationData): number { const { location, positionHint } = singleLocation; @@ -65,16 +268,6 @@ 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; -} - -// Where a synthetic annotation line is spliced, and the indentation it's rendered with. interface Anchor { insertAfterLine: number; indent: string; @@ -92,20 +285,6 @@ 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( tag: SourceTagEntry, tags: SourceTagEntry[], @@ -120,15 +299,15 @@ function deriveAnchor( const firstBodyOpcode = lastPrologueOpcode + 1; const firstBodyLine = getDisplayLine(locationData[firstBodyOpcode]); + // `insertAfterLine` of `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, 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 { @@ -143,40 +322,6 @@ function deriveAnchor( }; } -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..06e1a782 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 { buildLineToAsmMap, formatBitAuthScript } from '../src/bitauth-script.js'; -import { fixtures } from './fixtures/bitauth-script.fixture.js'; -import { compileString } from 'cashc'; +import { binToHex, createCompilerBch } from '@bitauth/libauth'; +import { Artifact } from '../src/artifact.js'; +import { asmToScript, scriptToBytecode } from '../src/script.js'; +import { formatBitAuthScript } from '../src/bitauth-script.js'; +import { FunctionFixture, fixtures, functionFixtures } from './fixtures/bitauth-script.fixture.js'; +import { compileFile, compileString } from 'cashc'; describe('Libauth Script formatting', () => { fixtures.forEach((fixture) => { @@ -16,17 +18,68 @@ 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 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..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) { */ @@ -493,3 +400,162 @@ 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"); + } +}