|
| 1 | +import type { FunctionArtifactWithContractName } from '@aztec/stdlib/abi'; |
| 2 | + |
| 3 | +import type { ACIRCallback, ACIRExecutionResult } from '../acvm/acvm.js'; |
| 4 | +import type { ACVMWitness } from '../acvm/acvm_types.js'; |
| 5 | +import type { CircuitSimulator } from '../circuit_simulator.js'; |
| 6 | +import type { RecordingMetadata } from './circuit_recorder.js'; |
| 7 | +import { FileCircuitRecorder } from './file_circuit_recorder.js'; |
| 8 | +import { MemoryCircuitRecorder } from './memory_circuit_recorder.js'; |
| 9 | +import { SimulatorRecorderWrapper } from './simulator_recorder_wrapper.js'; |
| 10 | + |
| 11 | +const witness = () => new Map<number, string>() as ACVMWitness; |
| 12 | +const meta = (circuitName: string): RecordingMetadata => ({ |
| 13 | + input: witness(), |
| 14 | + bytecode: Buffer.from(circuitName), |
| 15 | + circuitName, |
| 16 | + functionName: 'main', |
| 17 | +}); |
| 18 | + |
| 19 | +describe('CircuitRecorder', () => { |
| 20 | + describe('recordCall outside an active recording', () => { |
| 21 | + // recordCall must never throw when there is no recording in context (e.g. a stray call after the scope closed): |
| 22 | + // it returns the entry it would have recorded without pushing it anywhere. |
| 23 | + const expectedEntry = { name: 'loadCapsule', inputs: [['0x01']], outputs: ['0x02'], time: 5, stackDepth: 0 }; |
| 24 | + |
| 25 | + it('MemoryCircuitRecorder.recordCall() returns the entry without pushing to an absent recording', async () => { |
| 26 | + const recorder = new MemoryCircuitRecorder(); |
| 27 | + |
| 28 | + await expect(recorder.recordCall('loadCapsule', [['0x01']], ['0x02'], 5)).resolves.toEqual(expectedEntry); |
| 29 | + }); |
| 30 | + |
| 31 | + it('FileCircuitRecorder.recordCall() returns the entry without touching the recording file', async () => { |
| 32 | + const recorder = new FileCircuitRecorder('/tmp/circuit-recorder-test-unused'); |
| 33 | + |
| 34 | + await expect(recorder.recordCall('loadCapsule', [['0x01']], ['0x02'], 5)).resolves.toEqual(expectedEntry); |
| 35 | + }); |
| 36 | + }); |
| 37 | + |
| 38 | + describe('record', () => { |
| 39 | + it('isolates oracle calls between concurrent recordings sharing one recorder', async () => { |
| 40 | + const recorder = new MemoryCircuitRecorder(); |
| 41 | + let releaseA: () => void; |
| 42 | + const gateA = new Promise<void>(resolve => (releaseA = resolve)); |
| 43 | + |
| 44 | + // A records a1, suspends so B interleaves, then records a2. With a single shared recording this corrupts; with |
| 45 | + // a per-recording context each recording sees only its own calls regardless of interleaving. |
| 46 | + const a = recorder.record(meta('A'), async () => { |
| 47 | + await recorder.recordCall('a1', [], [], 0); |
| 48 | + await gateA; |
| 49 | + await recorder.recordCall('a2', [], [], 0); |
| 50 | + return 'A'; |
| 51 | + }); |
| 52 | + const b = recorder.record(meta('B'), async () => { |
| 53 | + await recorder.recordCall('b1', [], [], 0); |
| 54 | + releaseA(); |
| 55 | + return 'B'; |
| 56 | + }); |
| 57 | + |
| 58 | + const [resultA, resultB] = await Promise.all([a, b]); |
| 59 | + expect(resultA.recording.oracleCalls.map(o => o.name)).toEqual(['a1', 'a2']); |
| 60 | + expect(resultB.recording.oracleCalls.map(o => o.name)).toEqual(['b1']); |
| 61 | + }); |
| 62 | + |
| 63 | + it('attaches the error to the recording and re-throws it unchanged', async () => { |
| 64 | + const recorder = new MemoryCircuitRecorder(); |
| 65 | + const underlyingError = new Error('schnorr_initializerless: capsule load failed'); |
| 66 | + |
| 67 | + await expect(recorder.record(meta('X'), () => Promise.reject(underlyingError))).rejects.toBe(underlyingError); |
| 68 | + }); |
| 69 | + }); |
| 70 | +}); |
| 71 | + |
| 72 | +describe('SimulatorRecorderWrapper', () => { |
| 73 | + const artifact = (name: string) => |
| 74 | + ({ bytecode: Buffer.from(name), contractName: 'TestContract', name }) as FunctionArtifactWithContractName; |
| 75 | + |
| 76 | + it('surfaces the original simulator error instead of masking it', async () => { |
| 77 | + const underlyingError = new Error('schnorr_initializerless: capsule load failed'); |
| 78 | + const simulator: CircuitSimulator = { |
| 79 | + executeUserCircuit: () => Promise.reject(underlyingError), |
| 80 | + executeProtocolCircuit: () => Promise.reject(new Error('not used in this test')), |
| 81 | + }; |
| 82 | + const wrapper = new SimulatorRecorderWrapper(simulator, new MemoryCircuitRecorder()); |
| 83 | + |
| 84 | + await expect(wrapper.executeUserCircuit(witness(), artifact('test_fn'), {})).rejects.toThrow( |
| 85 | + 'schnorr_initializerless: capsule load failed', |
| 86 | + ); |
| 87 | + }); |
| 88 | + |
| 89 | + // A top-level circuit makes a nested private call (via the aztec_prv_callPrivateFunction oracle, which re-enters |
| 90 | + // the simulator) followed by a utility execution (a direct re-entry, as utility_execution_oracle.ts does). Each |
| 91 | + // re-entry shares the same recorder, so oracle calls must stay attributed to the circuit that made them and the |
| 92 | + // parent recording must survive its children. |
| 93 | + it('attributes oracle calls to the right circuit across nested private and utility re-entries', async () => { |
| 94 | + const recorder = new MemoryCircuitRecorder(); |
| 95 | + const oracle = (): ReturnType<ACIRCallback[string]> => Promise.resolve([]); |
| 96 | + |
| 97 | + let childResult: ACIRExecutionResult | undefined; |
| 98 | + let utilityResult: ACIRExecutionResult | undefined; |
| 99 | + |
| 100 | + // Indirection so the scripted simulator and callbacks can re-enter through the recorder-wrapped simulator, which |
| 101 | + // is constructed below (it wraps `simulator`, so the two reference each other). |
| 102 | + const reentry: { wrapper?: CircuitSimulator } = {}; |
| 103 | + |
| 104 | + const childCallback: ACIRCallback = { childOracle: oracle }; |
| 105 | + const utilityCallback: ACIRCallback = { utilityOracle: oracle }; |
| 106 | + const parentCallback: ACIRCallback = { getNotes: oracle, getMore: oracle }; |
| 107 | + // The wrapped aztec_prv_callPrivateFunction triggers a nested circuit execution, mirroring private_execution.ts. |
| 108 | + parentCallback['aztec_prv_callPrivateFunction'] = async () => { |
| 109 | + childResult = await reentry.wrapper!.executeUserCircuit(witness(), artifact('child'), childCallback); |
| 110 | + return []; |
| 111 | + }; |
| 112 | + |
| 113 | + const simulator: CircuitSimulator = { |
| 114 | + executeProtocolCircuit: () => Promise.reject(new Error('not used in this test')), |
| 115 | + executeUserCircuit: async (_input, artifactArg, callback) => { |
| 116 | + switch (artifactArg.name) { |
| 117 | + case 'parent': |
| 118 | + await callback.getNotes(); |
| 119 | + await callback['aztec_prv_callPrivateFunction'](); |
| 120 | + // Utility re-entry: a direct nested executeUserCircuit, not via aztec_prv_callPrivateFunction. |
| 121 | + utilityResult = await reentry.wrapper!.executeUserCircuit(witness(), artifact('utility'), utilityCallback); |
| 122 | + await callback.getMore(); |
| 123 | + break; |
| 124 | + case 'child': |
| 125 | + await callback.childOracle(); |
| 126 | + break; |
| 127 | + case 'utility': |
| 128 | + await callback.utilityOracle(); |
| 129 | + break; |
| 130 | + } |
| 131 | + return { partialWitness: new Map(), returnWitness: new Map() } as ACIRExecutionResult; |
| 132 | + }, |
| 133 | + }; |
| 134 | + |
| 135 | + reentry.wrapper = new SimulatorRecorderWrapper(simulator, recorder); |
| 136 | + const parentResult = await reentry.wrapper.executeUserCircuit(witness(), artifact('parent'), parentCallback); |
| 137 | + |
| 138 | + // The parent recording survives both children, with only the parent's own oracle calls attributed to it. |
| 139 | + expect(parentResult.oracles).toBeDefined(); |
| 140 | + expect(Object.keys(parentResult.oracles!).sort()).toEqual(['aztec_prv_callPrivateFunction', 'getMore', 'getNotes']); |
| 141 | + // Each child circuit only sees its own oracle call, not the parent's. |
| 142 | + expect(Object.keys(childResult!.oracles!)).toEqual(['childOracle']); |
| 143 | + expect(Object.keys(utilityResult!.oracles!)).toEqual(['utilityOracle']); |
| 144 | + }); |
| 145 | +}); |
0 commit comments