diff --git a/yarn-project/pxe/src/entrypoints/server/utils.ts b/yarn-project/pxe/src/entrypoints/server/utils.ts index 1d4e1594e760..0b4dbb0b2678 100644 --- a/yarn-project/pxe/src/entrypoints/server/utils.ts +++ b/yarn-project/pxe/src/entrypoints/server/utils.ts @@ -3,7 +3,8 @@ import type { L1ContractAddresses } from '@aztec/ethereum/l1-contract-addresses' import { createLogger } from '@aztec/foundation/log'; import { createStore } from '@aztec/kv-store/lmdb-v2'; import { BundledProtocolContractsProvider } from '@aztec/protocol-contracts/providers/bundle'; -import { MemoryCircuitRecorder, SimulatorRecorderWrapper, WASMSimulator } from '@aztec/simulator/client'; +import { WASMSimulator } from '@aztec/simulator/client'; +import { MemoryCircuitRecorder, SimulatorRecorderWrapper } from '@aztec/simulator/server'; import { FileCircuitRecorder } from '@aztec/simulator/testing'; import { getStandardAuthRegistry } from '@aztec/standard-contracts/auth-registry'; import { getStandardHandshakeRegistry } from '@aztec/standard-contracts/handshake-registry'; diff --git a/yarn-project/simulator/src/client.ts b/yarn-project/simulator/src/client.ts index 55b01e351d27..bcd552e2eb70 100644 --- a/yarn-project/simulator/src/client.ts +++ b/yarn-project/simulator/src/client.ts @@ -1,6 +1,4 @@ export * from './private/acvm/index.js'; export { WASMSimulator } from './private/acvm_wasm.js'; -export { SimulatorRecorderWrapper } from './private/circuit_recording/simulator_recorder_wrapper.js'; -export { MemoryCircuitRecorder } from './private/circuit_recording/memory_circuit_recorder.js'; export { type CircuitSimulator, type DecodedError } from './private/circuit_simulator.js'; export * from './common/index.js'; diff --git a/yarn-project/simulator/src/private/circuit_recording/circuit_recorder.test.ts b/yarn-project/simulator/src/private/circuit_recording/circuit_recorder.test.ts new file mode 100644 index 000000000000..36eb303be3b9 --- /dev/null +++ b/yarn-project/simulator/src/private/circuit_recording/circuit_recorder.test.ts @@ -0,0 +1,145 @@ +import type { FunctionArtifactWithContractName } from '@aztec/stdlib/abi'; + +import type { ACIRCallback, ACIRExecutionResult } from '../acvm/acvm.js'; +import type { ACVMWitness } from '../acvm/acvm_types.js'; +import type { CircuitSimulator } from '../circuit_simulator.js'; +import type { RecordingMetadata } from './circuit_recorder.js'; +import { FileCircuitRecorder } from './file_circuit_recorder.js'; +import { MemoryCircuitRecorder } from './memory_circuit_recorder.js'; +import { SimulatorRecorderWrapper } from './simulator_recorder_wrapper.js'; + +const witness = () => new Map() as ACVMWitness; +const meta = (circuitName: string): RecordingMetadata => ({ + input: witness(), + bytecode: Buffer.from(circuitName), + circuitName, + functionName: 'main', +}); + +describe('CircuitRecorder', () => { + describe('recordCall outside an active recording', () => { + // recordCall must never throw when there is no recording in context (e.g. a stray call after the scope closed): + // it returns the entry it would have recorded without pushing it anywhere. + const expectedEntry = { name: 'loadCapsule', inputs: [['0x01']], outputs: ['0x02'], time: 5, stackDepth: 0 }; + + it('MemoryCircuitRecorder.recordCall() returns the entry without pushing to an absent recording', async () => { + const recorder = new MemoryCircuitRecorder(); + + await expect(recorder.recordCall('loadCapsule', [['0x01']], ['0x02'], 5)).resolves.toEqual(expectedEntry); + }); + + it('FileCircuitRecorder.recordCall() returns the entry without touching the recording file', async () => { + const recorder = new FileCircuitRecorder('/tmp/circuit-recorder-test-unused'); + + await expect(recorder.recordCall('loadCapsule', [['0x01']], ['0x02'], 5)).resolves.toEqual(expectedEntry); + }); + }); + + describe('record', () => { + it('isolates oracle calls between concurrent recordings sharing one recorder', async () => { + const recorder = new MemoryCircuitRecorder(); + let releaseA: () => void; + const gateA = new Promise(resolve => (releaseA = resolve)); + + // A records a1, suspends so B interleaves, then records a2. With a single shared recording this corrupts; with + // a per-recording context each recording sees only its own calls regardless of interleaving. + const a = recorder.record(meta('A'), async () => { + await recorder.recordCall('a1', [], [], 0); + await gateA; + await recorder.recordCall('a2', [], [], 0); + return 'A'; + }); + const b = recorder.record(meta('B'), async () => { + await recorder.recordCall('b1', [], [], 0); + releaseA(); + return 'B'; + }); + + const [resultA, resultB] = await Promise.all([a, b]); + expect(resultA.recording.oracleCalls.map(o => o.name)).toEqual(['a1', 'a2']); + expect(resultB.recording.oracleCalls.map(o => o.name)).toEqual(['b1']); + }); + + it('attaches the error to the recording and re-throws it unchanged', async () => { + const recorder = new MemoryCircuitRecorder(); + const underlyingError = new Error('schnorr_initializerless: capsule load failed'); + + await expect(recorder.record(meta('X'), () => Promise.reject(underlyingError))).rejects.toBe(underlyingError); + }); + }); +}); + +describe('SimulatorRecorderWrapper', () => { + const artifact = (name: string) => + ({ bytecode: Buffer.from(name), contractName: 'TestContract', name }) as FunctionArtifactWithContractName; + + it('surfaces the original simulator error instead of masking it', async () => { + const underlyingError = new Error('schnorr_initializerless: capsule load failed'); + const simulator: CircuitSimulator = { + executeUserCircuit: () => Promise.reject(underlyingError), + executeProtocolCircuit: () => Promise.reject(new Error('not used in this test')), + }; + const wrapper = new SimulatorRecorderWrapper(simulator, new MemoryCircuitRecorder()); + + await expect(wrapper.executeUserCircuit(witness(), artifact('test_fn'), {})).rejects.toThrow( + 'schnorr_initializerless: capsule load failed', + ); + }); + + // A top-level circuit makes a nested private call (via the aztec_prv_callPrivateFunction oracle, which re-enters + // the simulator) followed by a utility execution (a direct re-entry, as utility_execution_oracle.ts does). Each + // re-entry shares the same recorder, so oracle calls must stay attributed to the circuit that made them and the + // parent recording must survive its children. + it('attributes oracle calls to the right circuit across nested private and utility re-entries', async () => { + const recorder = new MemoryCircuitRecorder(); + const oracle = (): ReturnType => Promise.resolve([]); + + let childResult: ACIRExecutionResult | undefined; + let utilityResult: ACIRExecutionResult | undefined; + + // Indirection so the scripted simulator and callbacks can re-enter through the recorder-wrapped simulator, which + // is constructed below (it wraps `simulator`, so the two reference each other). + const reentry: { wrapper?: CircuitSimulator } = {}; + + const childCallback: ACIRCallback = { childOracle: oracle }; + const utilityCallback: ACIRCallback = { utilityOracle: oracle }; + const parentCallback: ACIRCallback = { getNotes: oracle, getMore: oracle }; + // The wrapped aztec_prv_callPrivateFunction triggers a nested circuit execution, mirroring private_execution.ts. + parentCallback['aztec_prv_callPrivateFunction'] = async () => { + childResult = await reentry.wrapper!.executeUserCircuit(witness(), artifact('child'), childCallback); + return []; + }; + + const simulator: CircuitSimulator = { + executeProtocolCircuit: () => Promise.reject(new Error('not used in this test')), + executeUserCircuit: async (_input, artifactArg, callback) => { + switch (artifactArg.name) { + case 'parent': + await callback.getNotes(); + await callback['aztec_prv_callPrivateFunction'](); + // Utility re-entry: a direct nested executeUserCircuit, not via aztec_prv_callPrivateFunction. + utilityResult = await reentry.wrapper!.executeUserCircuit(witness(), artifact('utility'), utilityCallback); + await callback.getMore(); + break; + case 'child': + await callback.childOracle(); + break; + case 'utility': + await callback.utilityOracle(); + break; + } + return { partialWitness: new Map(), returnWitness: new Map() } as ACIRExecutionResult; + }, + }; + + reentry.wrapper = new SimulatorRecorderWrapper(simulator, recorder); + const parentResult = await reentry.wrapper.executeUserCircuit(witness(), artifact('parent'), parentCallback); + + // The parent recording survives both children, with only the parent's own oracle calls attributed to it. + expect(parentResult.oracles).toBeDefined(); + expect(Object.keys(parentResult.oracles!).sort()).toEqual(['aztec_prv_callPrivateFunction', 'getMore', 'getNotes']); + // Each child circuit only sees its own oracle call, not the parent's. + expect(Object.keys(childResult!.oracles!)).toEqual(['childOracle']); + expect(Object.keys(utilityResult!.oracles!)).toEqual(['utilityOracle']); + }); +}); diff --git a/yarn-project/simulator/src/private/circuit_recording/circuit_recorder.ts b/yarn-project/simulator/src/private/circuit_recording/circuit_recorder.ts index e30993fbbaf5..9692ac3c1c7a 100644 --- a/yarn-project/simulator/src/private/circuit_recording/circuit_recorder.ts +++ b/yarn-project/simulator/src/private/circuit_recording/circuit_recorder.ts @@ -3,6 +3,8 @@ import { type Logger, type LoggerBindings, resolveLogger } from '@aztec/foundati import { Timer } from '@aztec/foundation/timer'; import type { ForeignCallHandler, ForeignCallInput, ForeignCallOutput } from '@aztec/noir-acvm_js'; +import { AsyncLocalStorage } from 'node:async_hooks'; + import type { ACIRCallback } from '../acvm/acvm.js'; import type { ACVMWitness } from '../acvm/acvm_types.js'; @@ -43,10 +45,23 @@ export class CircuitRecording { } } +/** Inputs needed to open a recording for a single circuit execution. */ +export type RecordingMetadata = { + input: ACVMWitness; + bytecode: Buffer; + circuitName: string; + functionName: string; +}; + /** * Class responsible for recording circuit inputs necessary to replay the circuit. These inputs are the initial witness * map and the oracle calls made during the circuit execution/witness generation. * + * The active recording for an execution lives in `AsyncLocalStorage`, so each (possibly nested) circuit execution owns + * its own recording and concurrent or re-entrant executions cannot corrupt one another's state. Nested executions + * (`aztec_prv_callPrivateFunction`, utility calls) re-enter {@link record}, which links the child to the recording + * active in the enclosing async context and lets ALS restore the parent automatically when the child completes. + * * Example recording object: * ```json * { @@ -91,37 +106,44 @@ export class CircuitRecording { export class CircuitRecorder { protected readonly logger: Logger; - protected recording?: CircuitRecording; - - private stackDepth: number = 0; - private newCircuit: boolean = true; + readonly #recordings = new AsyncLocalStorage(); protected constructor(loggerOrBindings?: Logger | LoggerBindings) { this.logger = resolveLogger('simulator:acvm:recording', loggerOrBindings); } /** - * Initializes a new circuit recording session. - * @param recordDir - Directory to store the recording - * @param input - Circuit input witness - * @param circuitBytecode - Compiled circuit bytecode - * @param circuitName - Name of the circuit - * @param functionName - Name of the circuit function (defaults to 'main'). This is meaningful only for - * contracts as protocol circuits artifacts always contain a single entrypoint function called 'main'. + * Records a single circuit execution. Opens a recording for the circuit (linked as a child of the recording active + * in the current async context, if any), runs `fn` within that recording's context, and finalizes it. The recording + * is returned alongside the result so callers can derive per-circuit stats (e.g. oracle timings). + * + * Recorder bookkeeping never alters execution: if `fn` throws, the error is attached to the recording and re-thrown + * unchanged. + * @param metadata - Identifies the circuit and its initial witness. + * @param fn - Runs the circuit execution; its oracle calls are recorded into this recording. */ - start(input: ACVMWitness, circuitBytecode: Buffer, circuitName: string, functionName: string): Promise { - if (this.newCircuit) { - const parentRef = this.recording; - this.recording = new CircuitRecording( - circuitName, - functionName, - sha512(circuitBytecode).toString('hex'), - Object.fromEntries(input), - ); - this.recording.setParent(parentRef); - } + record(metadata: RecordingMetadata, fn: () => Promise): Promise<{ result: T; recording: CircuitRecording }> { + const parent = this.#recordings.getStore(); + const recording = new CircuitRecording( + metadata.circuitName, + metadata.functionName, + sha512(metadata.bytecode).toString('hex'), + Object.fromEntries(metadata.input), + ); + recording.setParent(parent); - return Promise.resolve(); + return this.#recordings.run(recording, async () => { + await this.onStart(recording); + try { + const result = await fn(); + await this.onFinish(recording); + return { result, recording }; + } catch (error) { + recording.error = JSON.stringify(error); + await this.onError(recording, error); + throw error; + } + }); } /** @@ -147,7 +169,9 @@ export class CircuitRecorder { } /** - * Wraps a user circuit callback to record all oracle calls. + * Wraps a user circuit callback to record all oracle calls. A nested circuit entered via an oracle (e.g. + * `aztec_prv_callPrivateFunction`) re-enters {@link record}, so its own oracle calls land on the child recording and + * this circuit's calls (including the entering oracle call itself) land on this recording once the child completes. * @param callback - The original circuit callback. * @returns A wrapped callback that records all oracle interactions which is to be provided to the ACVM. */ @@ -161,38 +185,16 @@ export class CircuitRecorder { throw new Error(`Oracle method ${name} not found when setting up recording callback`); } - const isExternalCall = (name as keyof ACIRCallback) === 'aztec_prv_callPrivateFunction'; - recordingCallback[name as keyof ACIRCallback] = (...args: ForeignCallInput[]): ReturnType => { const timer = new Timer(); - // If we're entering another circuit via `aztec_prv_callPrivateFunction`, we increase the stack depth and set the - // newCircuit variable to ensure we are creating a new recording object. - if (isExternalCall) { - this.stackDepth++; - this.newCircuit = true; - } const result = fn.call(callback, ...args); if (result instanceof Promise) { return result.then(async r => { - // Once we leave the nested circuit, we decrease the stack depth and set newCircuit to false - // so that the parent circuit continues with its existing recording - // Note: recording restoration is handled by finish() - if (isExternalCall) { - this.stackDepth--; - this.newCircuit = false; - } - await this.recordCall(name, args, r, timer.ms(), this.stackDepth); + await this.recordCall(name, args, r, timer.ms()); return r; }) as ReturnType; } - // Once we leave the nested circuit, we decrease the stack depth and set newCircuit to false - // so that the parent circuit continues with its existing recording - // Note: recording restoration is handled by finish() - if (isExternalCall) { - this.stackDepth--; - this.newCircuit = false; - } - void this.recordCall(name, args, result, timer.ms(), this.stackDepth); + void this.recordCall(name, args, result, timer.ms()); return result; }; } @@ -209,55 +211,59 @@ export class CircuitRecorder { return async (name: string, inputs: ForeignCallInput[]): Promise => { const timer = new Timer(); const result = await callback(name, inputs); - await this.recordCall(name, inputs, result, timer.ms(), 0); + await this.recordCall(name, inputs, result, timer.ms()); return result; }; } /** - * Records a single oracle/foreign call with its inputs and outputs. + * Records a single oracle/foreign call with its inputs and outputs against the recording active in the current + * async context. * @param name - Name of the call * @param inputs - Input arguments * @param outputs - Output results */ - recordCall(name: string, inputs: unknown[], outputs: unknown, time: number, stackDepth: number): Promise { + recordCall(name: string, inputs: unknown[], outputs: unknown, time: number): Promise { + const recording = this.#recordings.getStore(); const entry = { name, inputs, outputs, time, - stackDepth, + stackDepth: depthOf(recording), }; - this.recording!.oracleCalls.push(entry); + // Outside any active recording context (e.g. a stray call after the scope closed, or a direct unit-test call) + // there is nowhere to record; return the entry without throwing into the execution path. + recording?.oracleCalls.push(entry); return Promise.resolve(entry); } - /** - * Finalizes the recording by resetting the state and returning the recording object. - */ - finish(): Promise { - const result = this.recording; - // If this is the top-level circuit recording, we reset the state for the next simulator call - if (!result!.parent) { - this.newCircuit = true; - this.recording = undefined; - } else { - // For nested circuits (utility calls, nested contract calls), restore to parent recording - // Note: we don't set newCircuit=false here because: - // - For privateCallPrivateFunction, the callback wrapper will set it to false - // - For utility calls, we want newCircuit to remain true so the next circuit creates its own recording - this.recording = result!.parent; - } - return Promise.resolve(result!); + /** The recording active in the current async context, if any. */ + protected currentRecording(): CircuitRecording | undefined { + return this.#recordings.getStore(); } - /** - * Finalizes the recording by resetting the state and returning the recording object with an attached error. - * @param error - The error that occurred during circuit execution - */ - async finishWithError(error: unknown): Promise { - const result = await this.finish(); - result.error = JSON.stringify(error); - return result; + /** Hook invoked when a recording opens, within the recording's context. Overridden to persist recordings. */ + protected onStart(_recording: CircuitRecording): Promise { + return Promise.resolve(); + } + + /** Hook invoked when a recording completes successfully, within the recording's context. */ + protected onFinish(_recording: CircuitRecording): Promise { + return Promise.resolve(); + } + + /** Hook invoked when a recording's execution throws, within the recording's context. */ + protected onError(_recording: CircuitRecording, _error: unknown): Promise { + return Promise.resolve(); + } +} + +/** Depth of a recording in the call tree: 0 for a top-level circuit, incremented per nested circuit. */ +function depthOf(recording: CircuitRecording | undefined): number { + let depth = 0; + for (let ancestor = recording?.parent; ancestor; ancestor = ancestor.parent) { + depth++; } + return depth; } diff --git a/yarn-project/simulator/src/private/circuit_recording/file_circuit_recorder.ts b/yarn-project/simulator/src/private/circuit_recording/file_circuit_recorder.ts index 9d83d126a171..369af7a5909e 100644 --- a/yarn-project/simulator/src/private/circuit_recording/file_circuit_recorder.ts +++ b/yarn-project/simulator/src/private/circuit_recording/file_circuit_recorder.ts @@ -3,11 +3,13 @@ import type { Logger } from '@aztec/foundation/log'; import fs from 'fs/promises'; import path from 'path'; -import type { ACVMWitness } from '../acvm/acvm_types.js'; import { CircuitRecorder, type CircuitRecording } from './circuit_recorder.js'; +/** Per-recording file state, keyed by recording so concurrent/nested executions don't share it. */ +type RecordingFileState = { filePath: string; isFirstCall: boolean }; + export class FileCircuitRecorder extends CircuitRecorder { - declare recording?: CircuitRecording & { filePath: string; isFirstCall: boolean }; + readonly #fileState = new WeakMap(); constructor( private readonly recordDir: string, @@ -16,16 +18,9 @@ export class FileCircuitRecorder extends CircuitRecorder { super(logger); } - override async start( - input: ACVMWitness, - circuitBytecode: Buffer, - circuitName: string, - functionName: string = 'main', - ) { - await super.start(input, circuitBytecode, circuitName, functionName); - + protected override async onStart(recording: CircuitRecording): Promise { const recordingStringWithoutClosingBracket = JSON.stringify( - { ...this.recording, isFirstCall: undefined, parent: undefined, oracleCalls: undefined, filePath: undefined }, + { ...recording, parent: undefined, oracleCalls: undefined }, null, 2, ).slice(0, -2); @@ -45,13 +40,13 @@ export class FileCircuitRecorder extends CircuitRecorder { } } - this.recording!.isFirstCall = true; - this.recording!.filePath = await FileCircuitRecorder.#computeFilePathAndStoreInitialRecording( + const filePath = await FileCircuitRecorder.#computeFilePathAndStoreInitialRecording( this.recordDir, - this.recording!.circuitName, - this.recording!.functionName, + recording.circuitName, + recording.functionName, recordingStringWithoutClosingBracket, ); + this.#fileState.set(recording, { filePath, isFirstCall: true }); } /** @@ -95,53 +90,48 @@ export class FileCircuitRecorder extends CircuitRecorder { * @param inputs - Input arguments * @param outputs - Output results */ - override async recordCall(name: string, inputs: unknown[], outputs: unknown, time: number, stackDepth: number) { - const entry = await super.recordCall(name, inputs, outputs, time, stackDepth); - try { - const prefix = this.recording!.isFirstCall ? ' ' : ' ,'; - this.recording!.isFirstCall = false; - await fs.appendFile(this.recording!.filePath, prefix + JSON.stringify(entry) + '\n'); - } catch (err) { - this.logger.error('Failed to log circuit call', { error: err }); + override async recordCall(name: string, inputs: unknown[], outputs: unknown, time: number) { + const entry = await super.recordCall(name, inputs, outputs, time); + const recording = this.currentRecording(); + const state = recording && this.#fileState.get(recording); + if (state) { + try { + const prefix = state.isFirstCall ? ' ' : ' ,'; + state.isFirstCall = false; + await fs.appendFile(state.filePath, prefix + JSON.stringify(entry) + '\n'); + } catch (err) { + this.logger.error('Failed to log circuit call', { error: err }); + } } return entry; } - /** - * Finalizes the recording file by adding closing brackets. Without calling this method, the recording file is - * incomplete and it fails to parse. - */ - override async finish(): Promise { - // Finish sets the recording to undefined if we are at the topmost circuit, - // so we save the current file path before that - const filePath = this.recording!.filePath; - const result = await super.finish(); + /** Closes the recording file with the trailing brackets so the JSON parses. */ + protected override async onFinish(recording: CircuitRecording): Promise { + const state = this.#fileState.get(recording); + if (!state) { + return; + } try { - await fs.appendFile(filePath, ' ]\n}\n'); + await fs.appendFile(state.filePath, ' ]\n}\n'); } catch (err) { this.logger.error('Failed to finalize recording file', { error: err }); } - return result!; } - /** - * Finalizes the recording file by adding the error and closing brackets. Without calling this method or `finish`, - * the recording file is incomplete and it fails to parse. - * @param error - The error that occurred during circuit execution - */ - override async finishWithError(error: unknown): Promise { - // Finish sets the recording to undefined if we are at the topmost circuit, - // so we save the current file path before that - const filePath = this.recording!.filePath; - const result = await super.finishWithError(error); + /** Closes the recording file with the execution error and trailing brackets so the JSON parses. */ + protected override async onError(recording: CircuitRecording, error: unknown): Promise { + const state = this.#fileState.get(recording); + if (!state) { + return; + } try { - await fs.appendFile(filePath, ' ],\n'); - await fs.appendFile(filePath, ` "error": ${JSON.stringify(error)}\n`); - await fs.appendFile(filePath, '}\n'); + await fs.appendFile(state.filePath, ' ],\n'); + await fs.appendFile(state.filePath, ` "error": ${JSON.stringify(error)}\n`); + await fs.appendFile(state.filePath, '}\n'); } catch (err) { this.logger.error('Failed to finalize recording file with error', { error: err }); } - return result!; } } diff --git a/yarn-project/simulator/src/private/circuit_recording/simulator_recorder_wrapper.ts b/yarn-project/simulator/src/private/circuit_recording/simulator_recorder_wrapper.ts index 9e39245c90cb..019d9c22b890 100644 --- a/yarn-project/simulator/src/private/circuit_recording/simulator_recorder_wrapper.ts +++ b/yarn-project/simulator/src/private/circuit_recording/simulator_recorder_wrapper.ts @@ -58,24 +58,18 @@ export class SimulatorRecorderWrapper implements CircuitSimulator { functionName: string, callback: C, ): Promise { - // Start recording circuit execution - await this.recorder.start(input, bytecode, contractName, functionName); - - // If callback was provided, we wrap it in a circuit recorder callback wrapper + // If a callback was provided, we wrap it so that its oracle calls are recorded. The wrapped callback reads the + // active recording lazily, so it picks up the recording opened by record() below. const wrappedCallback = this.recorder.wrapCallback(callback); - let result: T; - try { - result = await simulateFn(wrappedCallback as C); - } catch (error) { - // If an error occurs, we finalize the recording file with the error - await this.recorder.finishWithError(error); - throw error; - } - // Witness generation is complete so we finish the circuit recorder - const recording = await this.recorder.finish(); + // record() opens a recording for this circuit, runs the simulation within it, and finalizes it. A simulation + // failure is re-thrown unchanged, so recorder bookkeeping never masks the underlying error. + const { result, recording } = await this.recorder.record( + { input, bytecode, circuitName: contractName, functionName }, + () => simulateFn(wrappedCallback as C), + ); - (result as ACIRExecutionResult).oracles = recording.oracleCalls?.reduce( + (result as ACIRExecutionResult).oracles = recording.oracleCalls.reduce( (acc, { time, name }) => { if (!acc[name]) { acc[name] = { times: [] };