From dff5daf0801bd41a718d9280122c28ea50e9f1c2 Mon Sep 17 00:00:00 2001 From: Rosco Kalis Date: Tue, 28 Jul 2026 12:40:00 +0200 Subject: [PATCH] Add stack trace to failing require statements inside functions --- packages/cashscript/src/Errors.ts | 49 ++--- packages/cashscript/src/debug-frame.ts | 198 ++++++++++++++++-- packages/cashscript/src/debugging.ts | 23 +- packages/cashscript/test/debugging.test.ts | 90 +++++++- .../fixture/debugging/debugging_contracts.ts | 103 +++++++++ packages/utils/src/source-map.ts | 16 +- 6 files changed, 418 insertions(+), 61 deletions(-) diff --git a/packages/cashscript/src/Errors.ts b/packages/cashscript/src/Errors.ts index cd27b4a2..dd12a9f0 100644 --- a/packages/cashscript/src/Errors.ts +++ b/packages/cashscript/src/Errors.ts @@ -1,5 +1,11 @@ -import { Artifact, RequireStatement, sourceMapToLocationData, Type } from '@cashscript/utils'; -import { ResolvedFrame, resolveInlineAttribution, rootFrame } from './debug-frame.js'; +import { Artifact, RequireStatement, Type } from '@cashscript/utils'; +import { + CallStackEntry, + ResolvedFrame, + getLocationDataForFrame, + resolveInlineAttribution, + rootFrame, +} from './debug-frame.js'; export class TypeError extends Error { constructor(actual: string, expected: Type) { @@ -159,6 +165,7 @@ export class FailedRequireError extends FailedTransactionError { public bitauthUri: string, public libauthErrorMessage?: string, frame?: ResolvedFrame, + public callStack: CallStackEntry[] = [], ) { const resolvedFrame = frame ?? rootFrame(artifact); @@ -175,9 +182,12 @@ export class FailedRequireError extends FailedTransactionError { // Compiler-injected guards (e.g. the tx.locktime guard) have no user-written source, so the // extracted statement is empty — the require message fully describes the failure on its own. - const fullMessage = statement.trim() ? `${headline}\nFailing statement: ${statement}` : headline; + const statementMessage = statement.trim() ? `${headline}\nFailing statement: ${statement}` : headline; - super(fullMessage, bitauthUri); + // A single-entry call stack adds nothing over the headline, so it is only shown for nested calls + const callStackMessage = callStack.length >= 2 ? `\n${formatCallStack(callStack)}` : ''; + + super(statementMessage + callStackMessage, bitauthUri); } } @@ -189,28 +199,9 @@ const formatFrameContext = (frame: ResolvedFrame, contractName: string, lineNumb return `in contract ${contractName}.cash at line ${lineNumber}`; }; -const getLocationDataForFrame = ( - frame: ResolvedFrame, - instructionPointer: number, -): { lineNumber: number, statement: string } => { - const locationData = sourceMapToLocationData(frame.sourceMap); - - // 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 = 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 - failingLines[failingLines.length - 1] = failingLines[failingLines.length - 1].slice(0, location.end.column); - failingLines[0] = failingLines[0].slice(location.start.column); - - const statement = failingLines.join('\n'); - const lineNumber = location.start.line; - - return { statement, lineNumber }; -}; +const formatCallStack = (callStack: CallStackEntry[]): string => callStack + .map(({ functionName, sourceName, line, statement }) => { + const location = functionName ? `${functionName} (${sourceName}:${line})` : `${sourceName}:${line}`; + return ` at ${location} — ${statement}`; + }) + .join('\n'); diff --git a/packages/cashscript/src/debug-frame.ts b/packages/cashscript/src/debug-frame.ts index fd72b9c6..234131c9 100644 --- a/packages/cashscript/src/debug-frame.ts +++ b/packages/cashscript/src/debug-frame.ts @@ -1,5 +1,24 @@ -import { AuthenticationProgramStateCommon, binToHex, encodeAuthenticationInstructions } from '@bitauth/libauth'; -import { Artifact, DebugEntry, DebugFrame, LogEntry, RequireStatement, parseInlineRanges } from '@cashscript/utils'; +import { + AuthenticationInstruction, + AuthenticationProgramStackFrame, + AuthenticationProgramStateCommon, + binToHex, + encodeAuthenticationInstructions, + hexToBin, +} from '@bitauth/libauth'; +import { + Artifact, + DebugEntry, + DebugFrame, + InlineRange, + LogEntry, + Op, + RequireStatement, + Script, + bytecodeToScript, + parseAndResolveInlineRanges, + sourceMapToLocationData, +} from '@cashscript/utils'; export interface ResolvedFrame { sourceMap: string; @@ -12,6 +31,13 @@ export interface ResolvedFrame { functionName?: string; } +export interface CallStackEntry { + functionName?: string; // absent for the contract's own code + sourceName: string; + line: number; + statement: string; // flattened to a single line for display +} + export const rootFrame = (artifact: Artifact): ResolvedFrame => ({ sourceMap: artifact.debug?.sourceMap ?? '', source: artifact.source, @@ -22,22 +48,22 @@ export const rootFrame = (artifact: Artifact): ResolvedFrame => ({ inlineRanges: artifact.debug?.inlineRanges, }); -export const getActiveBytecode = (step: AuthenticationProgramStateCommon): string => - binToHex(encodeAuthenticationInstructions(step.instructions)); +export const getActiveBytecode = (instructions: AuthenticationInstruction[]): string => + binToHex(encodeAuthenticationInstructions(instructions)); export const resolveFrame = ( artifact: Artifact, step: AuthenticationProgramStateCommon, -): ResolvedFrame => { - // Only defined frames (id present) execute as standalone VM functions; an inlined callable's - // frame documents a body that only ever runs spliced into another program - const frames = (artifact.debug?.functions ?? []).filter((candidate) => candidate.id !== undefined); - const activeBytecode = frames.length > 0 ? getActiveBytecode(step) : undefined; - const frame = frames.find((candidate) => candidate.bytecode === activeBytecode); +): ResolvedFrame => resolveFrameByBytecode(artifact, getActiveBytecode(step.instructions)); - if (!frame) return rootFrame(artifact); +// Only defined frames (id present) execute as standalone VM programs; an inlined callable's +// frame documents a body that only ever runs spliced into another program +const resolveFrameByBytecode = (artifact: Artifact, activeBytecode: string): ResolvedFrame => { + const frame = (artifact.debug?.functions ?? []) + .filter((candidate) => candidate.id !== undefined) + .find((candidate) => candidate.bytecode === activeBytecode); - return resolveDebugFrame(artifact, frame); + return frame ? resolveDebugFrame(artifact, frame) : rootFrame(artifact); }; const resolveDebugFrame = (artifact: Artifact, frame: DebugFrame): ResolvedFrame => ({ @@ -48,7 +74,7 @@ const resolveDebugFrame = (artifact: Artifact, frame: DebugFrame): ResolvedFrame requires: frame.requires, logs: frame.logs, inlineRanges: frame.inlineRanges, - functionName: frame.sourceFile ? frame.name : undefined, + functionName: frame.name, }); export interface InlineAttribution { @@ -62,20 +88,31 @@ export const resolveInlineAttribution = ( entry: DebugEntry, kind: 'requires' | 'logs', ): InlineAttribution | undefined => { - const range = parseInlineRanges(containerFrame.inlineRanges ?? '') - .find((candidate) => entry.ip >= candidate.startIp && entry.ip <= candidate.endIp); - if (!range) return undefined; + const attributionStack = resolveInlineAttributionStack(artifact, containerFrame, entry, kind); + return attributionStack[0]; +}; - const inlinedFrame = artifact.debug?.functions?.find((candidate) => candidate.name === range.frameName); - if (!inlinedFrame) return undefined; +// The chain of inlined callables containing the entry, from the innermost to the outermost +const resolveInlineAttributionStack = ( + artifact: Artifact, + containerFrame: ResolvedFrame, + entry: DebugEntry, + kind: 'requires' | 'logs', +): InlineAttribution[] => { + const range = parseAndResolveInlineRanges(containerFrame.inlineRanges, artifact.debug?.functions) + .find((candidate) => entry.ip >= candidate.startIp && entry.ip <= candidate.endIp); + if (!range) return []; - const frameEntry = findMatchingFrameEntry(containerFrame[kind], inlinedFrame[kind], range, entry); - if (!frameEntry) return undefined; + const frameEntry = findMatchingFrameEntry(containerFrame[kind], range.frame[kind], range, entry); + if (!frameEntry) return []; - const frame = resolveDebugFrame(artifact, inlinedFrame); + const frame = resolveDebugFrame(artifact, range.frame); - // The callable may itself contain deeper inlined callables: attribute to the innermost one - return resolveInlineAttribution(artifact, frame, frameEntry, kind) ?? { frame, entry: frameEntry }; + // The callable may itself contain deeper inlined callables + return [ + ...resolveInlineAttributionStack(artifact, frame, frameEntry, kind), + { frame, entry: frameEntry }, + ]; }; // A log merged from an inlined callable is attributed to the callable's own source @@ -107,3 +144,118 @@ const findMatchingFrameEntry = ( if (position === -1) return undefined; return frameEntries[position]; }; + +export const buildCallStack = ( + artifact: Artifact, + failingStep: AuthenticationProgramStateCommon, + failingFrame: ResolvedFrame, + requireStatement: RequireStatement, + failingInstructionPointer: number, +): CallStackEntry[] => { + // These entries represent any called inlined functions at the top of the call stack. These are handled separately, + // because the failing debug step refers to the frameEntry below, so we manually need to handle "deeper" calls + const inlineEntries = resolveInlineAttributionStack(artifact, failingFrame, requireStatement, 'requires') + .map(({ frame, entry }) => toCallStackEntry(frame, entry.ip)); + + // This entry is the actual VM-level failing function + const frameEntry = toCallStackEntry(failingFrame, failingInstructionPointer); + + // These entries are the rest of the callstack, so all the intermediate (defined & inlined) functions that call + // the VM-level failing function + const runtimeCallers = failingStep.controlStack + .filter(isAuthenticationProgramStackFrame) + .reverse() + .flatMap((controlFrame) => { + const callerBytecode = getActiveBytecode(controlFrame.instructions); + const callerFrame = resolveFrameByBytecode(artifact, callerBytecode); + // The control frame stores the ip to resume at, which is one past the OP_INVOKE call site + return expandRuntimeCaller(artifact, callerFrame, bytecodeToScript(hexToBin(callerBytecode)), controlFrame.ip - 1); + }); + + return [...inlineEntries, frameEntry, ...runtimeCallers]; +}; + +const expandRuntimeCaller = ( + artifact: Artifact, + frame: ResolvedFrame, + script: Script, + invokeIp: number, +): CallStackEntry[] => [ + ...resolveInlinedCallerHops(artifact, frame, script, invokeIp), + toCallStackEntry(frame, invokeIp), +]; + +// Every defined function already gets expanded in buildCallStack, so this function exists to make sure any inlined +// callers also get added to the call stack +const resolveInlinedCallerHops = ( + artifact: Artifact, + frame: ResolvedFrame, + script: Script, + invokeIp: number, +): CallStackEntry[] => { + const inlineRange = parseAndResolveInlineRanges(frame.inlineRanges, artifact.debug?.functions) + .find((candidate) => invokeIp >= candidate.startIp && invokeIp <= candidate.endIp); + if (!inlineRange) return []; + + const frameScript = bytecodeToScript(hexToBin(inlineRange.frame.bytecode)); + const frameLocalIp = findMatchingFrameInvokeIp(script, frameScript, inlineRange, invokeIp); + if (frameLocalIp === undefined) return []; + + // The invoke is expanded again within the callable, since deeper inlined callables may wrap it + return expandRuntimeCaller(artifact, resolveDebugFrame(artifact, inlineRange.frame), frameScript, frameLocalIp); +}; + +// The container's n-th OP_INVOKE within the range corresponds to the n-th OP_INVOKE in the inlined +// callable's own bytecode, matching by position gives the invoke's exact frame-local ip. +const findMatchingFrameInvokeIp = ( + containerScript: Script, + frameScript: Script, + range: InlineRange, + invokeIp: number, +): number | undefined => { + const position = containerScript.slice(range.startIp, invokeIp).filter((op) => op === Op.OP_INVOKE).length; + const frameInvokeIps = frameScript.flatMap((op, ip) => (op === Op.OP_INVOKE ? [ip] : [])); + return frameInvokeIps[position]; +}; + +const isAuthenticationProgramStackFrame = ( + item: AuthenticationProgramStackFrame | boolean | number, +): item is AuthenticationProgramStackFrame => typeof item === 'object'; + +const toCallStackEntry = (frame: ResolvedFrame, instructionPointer: number): CallStackEntry => { + const { lineNumber, statement } = getLocationDataForFrame(frame, instructionPointer); + const flattenedStatement = statement.split('\n').map((line) => line.trim()).join(' '); + + return { + functionName: frame.functionName, + sourceName: frame.sourceName, + line: lineNumber, + statement: flattenedStatement, + }; +}; + +export const getLocationDataForFrame = ( + frame: ResolvedFrame, + instructionPointer: number, +): { lineNumber: number, statement: string } => { + const locationData = sourceMapToLocationData(frame.sourceMap); + + // 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 = 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 + failingLines[failingLines.length - 1] = failingLines[failingLines.length - 1].slice(0, location.end.column); + failingLines[0] = failingLines[0].slice(location.start.column); + + const statement = failingLines.join('\n'); + const lineNumber = location.start.line; + + return { statement, lineNumber }; +}; diff --git a/packages/cashscript/src/debugging.ts b/packages/cashscript/src/debugging.ts index ac5f8564..3ad2a5df 100644 --- a/packages/cashscript/src/debugging.ts +++ b/packages/cashscript/src/debugging.ts @@ -2,7 +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 { attributeLogEntry, getActiveBytecode, resolveFrame } from './debug-frame.js'; +import { attributeLogEntry, buildCallStack, getActiveBytecode, resolveFrame } from './debug-frame.js'; import { getBitauthUri } from './libauth-template/LibauthTemplate.js'; import { VmTarget } from './interfaces.js'; @@ -100,7 +100,7 @@ const debugSingleScenario = ( if (logEntries.length === 0) return []; const reversedPriorDebugSteps = executedDebugSteps.slice(0, index + 1).reverse(); - const frameBytecode = getActiveBytecode(debugStep); + const frameBytecode = getActiveBytecode(debugStep.instructions); return logEntries.map((logEntry) => { const decodedLogData = logEntry.data @@ -148,9 +148,11 @@ const debugSingleScenario = ( const requireStatement = frame.requires.find((statement) => statement.ip === requireStatementIp); if (requireStatement) { + const callStack = buildCallStack(artifact, lastExecutedDebugStep, frame, requireStatement, failingIp); + // Note that we use failingIp here rather than requireStatementIp, see comment above throw new FailedRequireError( - artifact, failingIp, requireStatement, inputIndex, getBitauthUri(template), error, frame, + artifact, failingIp, requireStatement, inputIndex, getBitauthUri(template), error, frame, callStack, ); } @@ -188,8 +190,19 @@ const debugSingleScenario = ( const requireStatement = frame.requires.find((message) => message.ip === finalExecutedVerifyIp); if (requireStatement) { + const callStack = buildCallStack( + artifact, lastExecutedDebugStep, frame, requireStatement, sourcemapInstructionPointer, + ); + throw new FailedRequireError( - artifact, sourcemapInstructionPointer, requireStatement, inputIndex, getBitauthUri(template), undefined, frame, + artifact, + sourcemapInstructionPointer, + requireStatement, + inputIndex, + getBitauthUri(template), + undefined, + frame, + callStack, ); } @@ -260,7 +273,7 @@ const decodeLogDataEntry = ( if (typeof dataEntry === 'string') return dataEntry; const dataEntryDebugStep = reversedPriorDebugSteps.find( - (step) => step.ip === dataEntry.ip && getActiveBytecode(step) === frameBytecode, + (step) => step.ip === dataEntry.ip && getActiveBytecode(step.instructions) === frameBytecode, ); if (!dataEntryDebugStep) { diff --git a/packages/cashscript/test/debugging.test.ts b/packages/cashscript/test/debugging.test.ts index dd9f5b0a..e023c666 100644 --- a/packages/cashscript/test/debugging.test.ts +++ b/packages/cashscript/test/debugging.test.ts @@ -21,6 +21,11 @@ import { artifactTestImportedFunctionDebuggingDefined, artifactTestMultiReturn, artifactTestMultilineFunctionRequire, + artifactTestNestedFunctions, + artifactTestNestedFunctionsDefined, + artifactTestNestedImportedFunctions, + artifactTestMixedNestedFunctions, + artifactTestInlinedCallingDefined, } from './fixture/debugging/debugging_contracts.js'; import { sha256 } from '@cashscript/utils'; @@ -852,7 +857,7 @@ describe('Debugging tests - user-defined function frames', () => { // The artifact's inline ranges tie the merged require back to the function's own frame, so // inlining is transparent: the failure reads like the defined variant below - 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('Test.cash:4 Require statement failed at input 0 in contract Test, function checkValue (Test.cash, line 4) with the following message: value must be positive.'); expect(transaction).toFailRequireWith('Failing statement: require(value > 0, "value must be positive")'); }); @@ -865,6 +870,85 @@ describe('Debugging tests - user-defined function frames', () => { expect(transaction).toFailRequireWith('Failing statement: require(x < 100, "x must be small")'); }); + it('shows a call stack when a require fails in a nested inlined function', () => { + const nestedContract = new Contract(artifactTestNestedFunctions, [], { provider }); + const nestedUtxo = provider.addUtxo(nestedContract.address, randomUtxo()); + + const transaction = new TransactionBuilder({ provider }) + .addInput(nestedUtxo, nestedContract.unlock.spend(0n)) + .addOutput({ to: nestedContract.address, amount: 10000n }); + + expect(transaction).toFailRequireWith('Test.cash:3 Require statement failed at input 0 in contract Test, function assertPositive (Test.cash, line 3) with the following message: value must be positive.'); + expect(transaction).toFailRequireWith(` at assertPositive (Test.cash:3) — require(value > 0, "value must be positive"); + at validate (Test.cash:7) — assertPositive(amount) + at Test.cash:13 — validate(x)`); + }); + + it('shows a call stack when a require fails in a nested defined function', () => { + const nestedContract = new Contract(artifactTestNestedFunctionsDefined, [], { provider }); + const nestedUtxo = provider.addUtxo(nestedContract.address, randomUtxo()); + + const transaction = new TransactionBuilder({ provider }) + .addInput(nestedUtxo, nestedContract.unlock.spend(0n)) + .addOutput({ to: nestedContract.address, amount: 10000n }); + + // The trace is identical to the inlined variant: runtime callers come from the VM's control + // stack instead of inline ranges, but the displayed stack is the same + expect(transaction).toFailRequireWith('Test.cash:3 Require statement failed at input 0 in contract Test, function assertPositive (Test.cash, line 3) with the following message: value must be positive.'); + expect(transaction).toFailRequireWith(` at assertPositive (Test.cash:3) — require(value > 0, "value must be positive"); + at validate (Test.cash:7) — assertPositive(amount) + at Test.cash:13 — validate(x)`); + }); + + it('shows a call stack across imported functions', () => { + const nestedContract = new Contract(artifactTestNestedImportedFunctions, [], { provider }); + const nestedUtxo = provider.addUtxo(nestedContract.address, randomUtxo()); + + const transaction = new TransactionBuilder({ provider }) + .addInput(nestedUtxo, nestedContract.unlock.spend(0n)) + .addOutput({ to: nestedContract.address, amount: 10000n }); + + expect(transaction).toFailRequireWith('nested_helpers.cash:3 Require statement failed at input 0 in contract Test, function assertPositive (nested_helpers.cash, line 3) with the following message: value must be positive.'); + expect(transaction).toFailRequireWith(` at assertPositive (nested_helpers.cash:3) — require(value > 0, "value must be positive"); + at validate (nested_helpers.cash:7) — assertPositive(amount) + at Test.cash:6 — validate(x)`); + }); + + it('shows a call stack when an inlined function calls a defined function', () => { + const inlinedCallingDefinedContract = new Contract(artifactTestInlinedCallingDefined, [], { provider }); + const inlinedCallingDefinedUtxo = provider.addUtxo(inlinedCallingDefinedContract.address, randomUtxo()); + + const transaction = new TransactionBuilder({ provider }) + .addInput(inlinedCallingDefinedUtxo, inlinedCallingDefinedContract.unlock.spend(0n)) + .addOutput({ to: inlinedCallingDefinedContract.address, amount: 10000n }); + + // The invoke of bigCheck sits inside the inlined wrapper's body within the contract; the + // inline range and the invoke's position within it give wrapper its own hop + expect(transaction).toFailRequireWith('Test.cash:3 Require statement failed at input 0 in contract Test, function bigCheck (Test.cash, line 3) with the following message: v must be positive.'); + expect(transaction).toFailRequireWith(` at bigCheck (Test.cash:3) — require(v > 0, "v must be positive"); + at wrapper (Test.cash:8) — bigCheck(v) + at Test.cash:13 — wrapper(x)`); + }); + + it('shows a call stack alternating between defined and inlined functions', () => { + const mixedContract = new Contract(artifactTestMixedNestedFunctions, [], { provider }); + const mixedUtxo = provider.addUtxo(mixedContract.address, randomUtxo()); + + const transaction = new TransactionBuilder({ provider }) + .addInput(mixedUtxo, mixedContract.unlock.spend(0n)) + .addOutput({ to: mixedContract.address, amount: 10000n }); + + // The inlined innerCheck attributes through deepHelper's inline ranges; the runtime hops come + // from the VM's control stack, with the inlined middle recovered from the position of the + // deepHelper invoke within middle's inline range in outerHelper + expect(transaction).toFailRequireWith('Test.cash:3 Require statement failed at input 0 in contract Test, function innerCheck (Test.cash, line 3) with the following message: v must be positive.'); + expect(transaction).toFailRequireWith(` at innerCheck (Test.cash:3) — require(v > 0, "v must be positive"); + at deepHelper (Test.cash:7) — innerCheck(v) + at middle (Test.cash:12) — deepHelper(v) + at outerHelper (Test.cash:16) — middle(v) + at Test.cash:21 — outerHelper(x)`); + }); + it('attributes a multiline require failing inside an inlined function with its full statement', () => { const multilineContract = new Contract(artifactTestMultilineFunctionRequire, [], { provider }); const multilineUtxo = provider.addUtxo(multilineContract.address, randomUtxo()); @@ -873,11 +957,13 @@ describe('Debugging tests - user-defined function frames', () => { .addInput(multilineUtxo, multilineContract.unlock.spend(0n)) .addOutput({ to: multilineContract.address, amount: 10000n }); - expect(transaction).toFailRequireWith('Test.cash:3 Require statement failed at input 0 in contract Test.cash at line 3 with the following message: value must be positive.'); + expect(transaction).toFailRequireWith('Test.cash:3 Require statement failed at input 0 in contract Test, function checkRange (Test.cash, line 3) with the following message: value must be positive.'); expect(transaction).toFailRequireWith(`Failing statement: require( value > 0, "value must be positive" )`); + // In the call stack display, the multiline statement is flattened to a single line + expect(transaction).toFailRequireWith('at checkRange (Test.cash:3) — require( value > 0, "value must be positive" )'); }); it('attributes a require failing inside an inlined imported function to the imported function', () => { diff --git a/packages/cashscript/test/fixture/debugging/debugging_contracts.ts b/packages/cashscript/test/fixture/debugging/debugging_contracts.ts index e556bd82..417f7619 100644 --- a/packages/cashscript/test/fixture/debugging/debugging_contracts.ts +++ b/packages/cashscript/test/fixture/debugging/debugging_contracts.ts @@ -29,6 +29,98 @@ contract Test(pubkey owner) { } `; +// Nested function calls, so a require failing in the innermost function produces a call stack +// through the intermediate function into the contract. The Defined variant exercises the same +// stack through the VM's control stack instead of through inline ranges. +const CONTRACT_TEST_NESTED_FUNCTIONS = ` +function assertPositive(int value) { + require(value > 0, "value must be positive"); +} + +function validate(int amount) { + assertPositive(amount); + require(amount < 1000, "amount too large"); +} + +contract Test() { + function spend(int x) { + validate(x); + require(x < 100); + } +} +`; + +// The same nested-call shape as CONTRACT_TEST_NESTED_FUNCTIONS, but with the functions imported +// from another file, so the call stack attributes its hops to the imported file. +const NESTED_HELPERS_SOURCE = ` +function assertPositive(int value) { + require(value > 0, "value must be positive"); +} + +function validate(int amount) { + assertPositive(amount); + require(amount < 1000, "amount too large"); +} +`; + +const CONTRACT_TEST_NESTED_IMPORTED_FUNCTIONS = ` +import "./nested_helpers.cash"; + +contract Test() { + function spend(int x) { + validate(x); + require(x < 100); + } +} +`; + +// The inlined wrapper invokes the defined bigCheck, where the require fails — the smallest shape +// where an inline range and the VM's control stack combine in one call stack. +const CONTRACT_TEST_INLINED_CALLING_DEFINED = ` +function bigCheck(int v) returns (int) { + require(v > 0, "v must be positive"); + return (v * 7 + 3) * (v + 11) - 5; +} + +function wrapper(int v) returns (int) { + return bigCheck(v) + bigCheck(v + 1); +} + +contract Test() { + function spend(int x) { + require(wrapper(x) > 0, "sum must be positive"); + } +} +`; + +// Alternates between shared and inlined callables: spend invokes the defined outerHelper, which +// contains the inlined middle, which invokes the defined deepHelper, which contains the inlined +// innerCheck where the require fails. +const CONTRACT_TEST_MIXED_NESTED_FUNCTIONS = ` +function innerCheck(int v) { + require(v > 0, "v must be positive"); +} + +function deepHelper(int v) returns (int) { + innerCheck(v); + return (v * 7 + 3) * (v + 11) - 5; +} + +function middle(int v) returns (int) { + return deepHelper(v) + deepHelper(v + 1); +} + +function outerHelper(int v) returns (int) { + return middle(v) * 2; +} + +contract Test() { + function spend(int x) { + require(outerHelper(x) + outerHelper(x + 1) > 0, "sum must be positive"); + } +} +`; + // The require statement inside the (inlined) function spans multiple lines, so its statement can // only be extracted through the function frame's source map rather than a single source line. const CONTRACT_TEST_MULTILINE_FUNCTION_REQUIRE = ` @@ -505,6 +597,17 @@ export const artifactTestFunctionDebugging = compileString(CONTRACT_TEST_FUNCTIO export const artifactTestFunctionIntermediateResults = compileString(CONTRACT_TEST_FUNCTION_INTERMEDIATE_RESULTS); export const artifactTestMultiReturn = compileString(CONTRACT_TEST_MULTI_RETURN); export const artifactTestMultilineFunctionRequire = compileString(CONTRACT_TEST_MULTILINE_FUNCTION_REQUIRE); +export const artifactTestNestedFunctions = compileString(CONTRACT_TEST_NESTED_FUNCTIONS); +export const artifactTestNestedFunctionsDefined = compileString( + CONTRACT_TEST_NESTED_FUNCTIONS, + { disableInlining: true }, +); +export const artifactTestNestedImportedFunctions = compileString( + CONTRACT_TEST_NESTED_IMPORTED_FUNCTIONS, + { files: { './nested_helpers.cash': NESTED_HELPERS_SOURCE } }, +); +export const artifactTestMixedNestedFunctions = compileString(CONTRACT_TEST_MIXED_NESTED_FUNCTIONS); +export const artifactTestInlinedCallingDefined = compileString(CONTRACT_TEST_INLINED_CALLING_DEFINED); // 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/utils/src/source-map.ts b/packages/utils/src/source-map.ts index 246feab0..e1a4720c 100644 --- a/packages/utils/src/source-map.ts +++ b/packages/utils/src/source-map.ts @@ -1,5 +1,5 @@ import { FullLocationData, PositionHint, SingleLocationData, SourceTagEntry, SourceTagKind } from './types.js'; -import { InlineRange } from './artifact.js'; +import { DebugFrame, InlineRange } from './artifact.js'; /* * The source mappings for the bytecode use the following notation (similar to Solidity): @@ -154,7 +154,19 @@ export function generateInlineRanges(entries: InlineRange[]): string { .join(';'); } -export function parseInlineRanges(inlineRanges: string): { startIp: number, endIp: number, frameName: string }[] { +export function parseAndResolveInlineRanges( + inlineRanges?: string, + frames?: readonly DebugFrame[], +): InlineRange[] { + if (!inlineRanges || !frames) return []; + + return parseInlineRanges(inlineRanges).flatMap(({ startIp, endIp, frameName }) => { + const frame = frames.find((candidate) => candidate.name === frameName); + return frame ? [{ startIp, endIp, frame }] : []; + }); +} + +function parseInlineRanges(inlineRanges: string): { startIp: number, endIp: number, frameName: string }[] { if (!inlineRanges) return []; return inlineRanges.split(';').map((segment) => { const [startStr, endStr, frameName] = segment.split(':');