diff --git a/acvm-repo/acvm_js/src/execute.rs b/acvm-repo/acvm_js/src/execute.rs index 5d55d96b20b..32c7c5f5c2e 100644 --- a/acvm-repo/acvm_js/src/execute.rs +++ b/acvm-repo/acvm_js/src/execute.rs @@ -1,6 +1,7 @@ use std::{future::Future, pin::Pin}; use acvm::acir::circuit::brillig::BrilligBytecode; +use acvm::acir::circuit::opcodes::AcirFunctionId; use acvm::{BlackBoxFunctionSolver, FieldElement}; use acvm::{ acir::circuit::{Circuit, Program}, @@ -88,7 +89,7 @@ async fn execute_circuit_with_return_witness_pedantic( console_error_panic_hook::set_once(); let program: Program = Program::deserialize_program(&program) - .map_err(|_| JsExecutionError::new("Failed to deserialize circuit. This is likely due to differing serialization formats between ACVM_JS and your compiler".to_string(), None, None, None))?; + .map_err(|_| JsExecutionError::new("Failed to deserialize circuit. This is likely due to differing serialization formats between ACVM_JS and your compiler".to_string(), None, None, None, None))?; let mut witness_stack = execute_program_with_native_program_and_return( &program, @@ -103,7 +104,7 @@ async fn execute_circuit_with_return_witness_pedantic( let main_circuit = &program.functions[0]; let return_witness = extract_indices(&solved_witness, main_circuit.return_values.0.iter().copied().collect()) - .map_err(|err| JsExecutionError::new(err, None, None, None))?; + .map_err(|err| JsExecutionError::new(err, None, None, None, None))?; Ok((solved_witness, return_witness).into()) } @@ -155,7 +156,7 @@ async fn execute_program_with_native_type_return( "Failed to deserialize circuit. This is likely due to differing serialization formats between ACVM_JS and your compiler".to_string(), None, None, - None))?; + None, None))?; execute_program_with_native_program_and_return( &program, @@ -216,7 +217,9 @@ impl<'a, B: BlackBoxFunctionSolver> ProgramExecutor<'a, B> { let main = &self.functions[0]; let mut witness_stack = WitnessStack::default(); - let main_witness = self.execute_circuit(main, initial_witness, &mut witness_stack).await?; + let main_witness = self + .execute_circuit(main, AcirFunctionId(0), initial_witness, &mut witness_stack) + .await?; witness_stack.push(0, main_witness); Ok(witness_stack) } @@ -224,10 +227,11 @@ impl<'a, B: BlackBoxFunctionSolver> ProgramExecutor<'a, B> { fn execute_circuit( &'a self, circuit: &'a Circuit, + acir_function_id: AcirFunctionId, initial_witness: WitnessMap, witness_stack: &'a mut WitnessStack, ) -> Pin, Error>> + 'a>> { - Box::pin(async { + Box::pin(async move { let mut acvm = ACVM::new( self.blackbox_solver, &circuit.opcodes, @@ -297,6 +301,7 @@ impl<'a, B: BlackBoxFunctionSolver> ProgramExecutor<'a, B> { message, call_stack, raw_assertion_payload, + Some(acir_function_id), brillig_function_id, ) .into()); @@ -311,7 +316,12 @@ impl<'a, B: BlackBoxFunctionSolver> ProgramExecutor<'a, B> { let acir_to_call = &self.functions[call_info.id.as_usize()]; let initial_witness = call_info.initial_witness; let call_solved_witness = self - .execute_circuit(acir_to_call, initial_witness, witness_stack) + .execute_circuit( + acir_to_call, + call_info.id, + initial_witness, + witness_stack, + ) .await?; let mut call_resolved_outputs = Vec::new(); for return_witness_index in acir_to_call.return_values.indices() { @@ -321,7 +331,7 @@ impl<'a, B: BlackBoxFunctionSolver> ProgramExecutor<'a, B> { call_resolved_outputs.push(*return_value); } else { // TODO: look at changing this call stack from None - return Err(JsExecutionError::new(format!("Failed to read from solved witness of ACIR call at witness {}", return_witness_index), None, None, None).into()); + return Err(JsExecutionError::new(format!("Failed to read from solved witness of ACIR call at witness {}", return_witness_index), None, None, None, None).into()); } } acvm.resolve_pending_acir_call(call_resolved_outputs); diff --git a/acvm-repo/acvm_js/src/js_execution_error.rs b/acvm-repo/acvm_js/src/js_execution_error.rs index 37799e31683..ba743b43b23 100644 --- a/acvm-repo/acvm_js/src/js_execution_error.rs +++ b/acvm-repo/acvm_js/src/js_execution_error.rs @@ -1,6 +1,6 @@ use acvm::{ FieldElement, - acir::circuit::{OpcodeLocation, brillig::BrilligFunctionId}, + acir::circuit::{OpcodeLocation, brillig::BrilligFunctionId, opcodes::AcirFunctionId}, pwg::RawAssertionPayload, }; use gloo_utils::format::JsValueSerdeExt; @@ -17,6 +17,7 @@ export type RawAssertionPayload = { export type ExecutionError = Error & { callStack?: string[]; rawAssertionPayload?: RawAssertionPayload; + acirFunctionId?: number; brilligFunctionId?: number; }; "#; @@ -41,6 +42,7 @@ impl JsExecutionError { message: String, call_stack: Option>, assertion_payload: Option>, + acir_function_id: Option, brillig_function_id: Option, ) -> Self { let mut error = JsExecutionError::constructor(JsString::from(message)); @@ -60,6 +62,12 @@ impl JsExecutionError { None => JsValue::UNDEFINED, }; + let acir_function_id = match acir_function_id { + Some(function_id) => ::from_serde(&function_id) + .expect("Cannot serialize ACIR function id"), + None => JsValue::UNDEFINED, + }; + let brillig_function_id = match brillig_function_id { Some(function_id) => ::from_serde(&function_id) .expect("Cannot serialize Brillig function id"), @@ -68,6 +76,7 @@ impl JsExecutionError { error.set_property("callStack", js_call_stack); error.set_property("rawAssertionPayload", assertion_payload); + error.set_property("acirFunctionId", acir_function_id); error.set_property("brilligFunctionId", brillig_function_id); error diff --git a/tooling/noir_codegen/src/main.ts b/tooling/noir_codegen/src/main.ts index 835b24a9e48..20116016992 100644 --- a/tooling/noir_codegen/src/main.ts +++ b/tooling/noir_codegen/src/main.ts @@ -19,9 +19,9 @@ function main() { const programs = files.map((file_path): [string, CompiledCircuit] => { const program_name = path.parse(file_path).name; const file_contents = fs.readFileSync(file_path).toString(); - const { abi, bytecode } = JSON.parse(file_contents); + const { abi, bytecode, debug_symbols, file_map } = JSON.parse(file_contents); - return [program_name, { abi, bytecode }]; + return [program_name, { abi, bytecode, debug_symbols, file_map }]; }); const result = codegen(programs, !cliConfig.externalArtifact, cliConfig.useFixedLengthArrays); diff --git a/tooling/noir_js/package.json b/tooling/noir_js/package.json index f9f91610c3c..9fd7754e1b7 100644 --- a/tooling/noir_js/package.json +++ b/tooling/noir_js/package.json @@ -19,7 +19,8 @@ "dependencies": { "@noir-lang/acvm_js": "workspace:*", "@noir-lang/noirc_abi": "workspace:*", - "@noir-lang/types": "workspace:*" + "@noir-lang/types": "workspace:*", + "pako": "^2.1.0" }, "files": [ "lib", @@ -52,6 +53,7 @@ "@types/chai": "^4", "@types/mocha": "^10.0.10", "@types/node": "^22.13.10", + "@types/pako": "^2", "@types/prettier": "^3.0.0", "chai": "^4.4.1", "eslint": "^9.24.0", diff --git a/tooling/noir_js/src/debug.ts b/tooling/noir_js/src/debug.ts new file mode 100644 index 00000000000..df78eaa8e73 --- /dev/null +++ b/tooling/noir_js/src/debug.ts @@ -0,0 +1,121 @@ +import { BrilligFunctionId, DebugFileMap, DebugInfo, OpcodeLocation } from '@noir-lang/types'; +import { inflate } from 'pako'; +import { base64Decode } from './base64_decode'; +import { ExecutionError } from '@noir-lang/acvm_js'; + +/** + * A stack of calls, resolved or not + */ +type CallStack = SourceCodeLocation[] | OpcodeLocation[]; + +/** + * A resolved pointer to a failing section of the noir source code. + */ +interface SourceCodeLocation { + /** + * The path to the source file. + */ + filePath: string; + /** + * The line number of the location. + */ + line: number; + /** + * The column number of the location. + */ + column: number; + /** + * The source code text of the location. + */ + locationText: string; +} + +export function parseDebugSymbols(debugSymbols: string): DebugInfo[] { + return JSON.parse(inflate(base64Decode(debugSymbols), { to: 'string', raw: true })).debug_infos; +} + +/** + * Extracts the call stack from an thrown by the acvm. + * @param error - The error to extract from. + * @param debug - The debug metadata of the program called. + * @param files - The files used for compilation of the program. + * @returns The call stack, if available. + */ +export function extractCallStack(error: ExecutionError, debug: DebugInfo, files: DebugFileMap): CallStack | undefined { + if (!('callStack' in error) || !error.callStack) { + return undefined; + } + const { callStack, brilligFunctionId } = error; + if (!debug) { + return callStack; + } + + try { + return resolveOpcodeLocations(callStack, debug, files, brilligFunctionId); + } catch (_err) { + return callStack; + } +} + +/** + * Resolves the source code locations from an array of opcode locations + */ +function resolveOpcodeLocations( + opcodeLocations: OpcodeLocation[], + debug: DebugInfo, + files: DebugFileMap, + brilligFunctionId?: BrilligFunctionId, +): SourceCodeLocation[] { + return opcodeLocations.flatMap((opcodeLocation) => + getSourceCodeLocationsFromOpcodeLocation(opcodeLocation, debug, files, brilligFunctionId), + ); +} + +/** + * Extracts the call stack from the location of a failing opcode and the debug metadata. + * One opcode can point to multiple calls due to inlining. + */ +function getSourceCodeLocationsFromOpcodeLocation( + opcodeLocation: string, + debug: DebugInfo, + files: DebugFileMap, + brilligFunctionId?: BrilligFunctionId, +): SourceCodeLocation[] { + let callStack = debug.locations[opcodeLocation] || []; + if (callStack.length === 0) { + const brilligLocation = extractBrilligLocation(opcodeLocation); + if (brilligFunctionId !== undefined && brilligLocation !== undefined) { + callStack = debug.brillig_locations[brilligFunctionId][brilligLocation] || []; + } + } + return callStack.map((call) => { + const { file: fileId, span } = call; + + const { path, source } = files[fileId]; + + const locationText = source.substring(span.start, span.end); + const precedingText = source.substring(0, span.start); + const previousLines = precedingText.split('\n'); + // Lines and columns in stacks are one indexed. + const line = previousLines.length; + const column = previousLines[previousLines.length - 1].length + 1; + + return { + filePath: path, + line, + column, + locationText, + }; + }); +} + +/** + * Extracts a brillig location from an opcode location. + */ +function extractBrilligLocation(opcodeLocation: string): string | undefined { + const splitted = opcodeLocation.split('.'); + if (splitted.length === 2) { + return splitted[1]; + } + return undefined; +} diff --git a/tooling/noir_js/src/witness_generation.ts b/tooling/noir_js/src/witness_generation.ts index c84cb2e83b3..38f9a7ef6cd 100644 --- a/tooling/noir_js/src/witness_generation.ts +++ b/tooling/noir_js/src/witness_generation.ts @@ -1,7 +1,8 @@ import { abiDecodeError, abiEncode, InputMap } from '@noir-lang/noirc_abi'; import { base64Decode } from './base64_decode.js'; import { WitnessStack, ForeignCallHandler, ForeignCallInput, ExecutionError, executeProgram } from '@noir-lang/acvm_js'; -import { Abi, CompiledCircuit } from '@noir-lang/types'; +import { CompiledCircuit } from '@noir-lang/types'; +import { extractCallStack, parseDebugSymbols } from './debug.js'; const defaultForeignCallHandler: ForeignCallHandler = async (name: string, args: ForeignCallInput[]) => { if (name == 'print') { @@ -16,26 +17,45 @@ const defaultForeignCallHandler: ForeignCallHandler = async (name: string, args: // Payload is any since it can be of any type defined by the circuit dev. // eslint-disable-next-line @typescript-eslint/no-explicit-any -export type ErrorWithPayload = ExecutionError & { decodedAssertionPayload?: any }; +export type ErrorWithPayload = ExecutionError & { decodedAssertionPayload?: any; noirCallStack?: string[] }; -function parseErrorPayload(abi: Abi, originalError: ExecutionError): Error { - const payload = originalError.rawAssertionPayload; - if (!payload) return originalError; +function enrichExecutionError(artifact: CompiledCircuit, originalError: ExecutionError): Error { const enrichedError = originalError as ErrorWithPayload; - try { - // Decode the payload - const decodedPayload = abiDecodeError(abi, payload); + if (originalError.rawAssertionPayload) { + try { + // Decode the payload + const decodedPayload = abiDecodeError(artifact.abi, originalError.rawAssertionPayload); - if (typeof decodedPayload === 'string') { - // If it's a string, just add it to the error message - enrichedError.message = `Circuit execution failed: ${decodedPayload}`; - } else { - // If not, attach the payload to the original error - enrichedError.decodedAssertionPayload = decodedPayload; + if (typeof decodedPayload === 'string') { + // If it's a string, just add it to the error message + enrichedError.message = `Circuit execution failed: ${decodedPayload}`; + } else { + // If not, attach the payload to the original error + enrichedError.decodedAssertionPayload = decodedPayload; + } + } catch (_errorDecoding) { + // Ignore errors decoding the payload } - } catch (_errorDecoding) { - // Ignore errors decoding the payload + } + + try { + // Decode the callstack + const callStack = extractCallStack( + originalError, + parseDebugSymbols(artifact.debug_symbols)[originalError.acirFunctionId!], + artifact.file_map, + ); + + enrichedError.noirCallStack = callStack?.map((errorLocation) => { + if (typeof errorLocation === 'string') { + return `at opcode ${errorLocation}`; + } else { + return `at ${errorLocation.locationText} (${errorLocation.filePath}:${errorLocation.line}:${errorLocation.column})`; + } + }); + } catch (_errorResolving) { + // Ignore errors resolving the callstack } return enrichedError; @@ -58,7 +78,7 @@ export async function generateWitness( } catch (err) { // Typescript types catched errors as unknown or any, so we need to narrow its type to check if it has raw assertion payload. if (typeof err === 'object' && err !== null && 'rawAssertionPayload' in err) { - throw parseErrorPayload(compiledProgram.abi, err as ExecutionError); + throw enrichExecutionError(compiledProgram, err as ExecutionError); } throw new Error(`Circuit execution failed: ${err}`); } diff --git a/tooling/noir_js/test/node/execute.test.ts b/tooling/noir_js/test/node/execute.test.ts index ceac21b2860..f657adefd95 100644 --- a/tooling/noir_js/test/node/execute.test.ts +++ b/tooling/noir_js/test/node/execute.test.ts @@ -30,7 +30,7 @@ it('successfully executes a program with multiple acir circuits', async () => { expect(() => new Noir(fold_fibonacci_program).execute(inputs)).to.not.throw(); }); -it('circuit with a fmt string assert message should fail with the resolved assertion message', async () => { +it('circuit with a fmt string assert message should fail with the resolved assertion information', async () => { const inputs = { x: '10', y: '5', @@ -38,8 +38,10 @@ it('circuit with a fmt string assert message should fail with the resolved asser try { await new Noir(assert_msg_runtime).execute(inputs); } catch (error) { - const knownError = error as Error; + const knownError = error as ErrorWithPayload; expect(knownError.message).to.equal('Circuit execution failed: Expected x < y but got 10 < 5'); + expect(knownError.noirCallStack).to.have.lengthOf(1); + expect(knownError.noirCallStack![0]).to.match(/^at x < y \(.*assert_msg_runtime\/src\/main.nr:3:12\)$/); } }); diff --git a/tooling/noir_js_types/src/types.ts b/tooling/noir_js_types/src/types.ts index 0cfe5d05a17..a032a5e2046 100644 --- a/tooling/noir_js_types/src/types.ts +++ b/tooling/noir_js_types/src/types.ts @@ -34,6 +34,47 @@ export type RawAssertionPayload = { data: string[]; }; +/** An id for a file. It's assigned during compilation. */ +export type FileId = number; + +/** Maps a file ID to its source code for debugging purposes. */ +export type DebugFileMap = Record< + FileId, + { + /** The source code of the file. */ + source: string; + /** The path of the file. */ + path: string; + } +>; + +export type OpcodeLocation = string; + +export type BrilligFunctionId = number; + +/** A pointer to a specific section of the source code. */ +export interface SourceCodeLocation { + /** The section of the source code. */ + span: { + /** The byte where the section starts. */ + start: number; + /** The byte where the section ends. */ + end: number; + }; + /** The source code file pointed to. */ + file: FileId; +} + +export type OpcodeToLocationsMap = Record; + +/** The debug information for a given function. */ +export interface DebugInfo { + /** A map of the opcode location to the source code location. */ + locations: OpcodeToLocationsMap; + /** For each Brillig function, we have a map of the opcode location to the source code location. */ + brillig_locations: Record; +} + // Map from witness index to hex string value of witness. export type WitnessMap = Map; @@ -95,4 +136,8 @@ export type CompiledCircuit = { bytecode: string; /** @description ABI representation of the circuit */ abi: Abi; + /** @description The debug information, compressed and base64 encoded. */ + debug_symbols: string; + /** @description The map of file ID to the source code and path of the file. */ + file_map: DebugFileMap; }; diff --git a/yarn.lock b/yarn.lock index 581a4091e50..22625cf2b7b 100644 --- a/yarn.lock +++ b/yarn.lock @@ -7635,11 +7635,13 @@ __metadata: "@types/chai": "npm:^4" "@types/mocha": "npm:^10.0.10" "@types/node": "npm:^22.13.10" + "@types/pako": "npm:^2" "@types/prettier": "npm:^3.0.0" chai: "npm:^4.4.1" eslint: "npm:^9.24.0" eslint-plugin-prettier: "npm:^5.2.6" mocha: "npm:^11.1.0" + pako: "npm:^2.1.0" prettier: "npm:3.5.3" ts-node: "npm:^10.9.2" tsc-multi: "npm:^1.1.0"