diff --git a/packages/relay/src/lib/debug.ts b/packages/relay/src/lib/debug.ts index c741e352b6..e41b71406d 100644 --- a/packages/relay/src/lib/debug.ts +++ b/packages/relay/src/lib/debug.ts @@ -410,6 +410,13 @@ export class DebugImpl implements Debug { requestDetails, options, ); + + if (!response) { + throw predefined.RESOURCE_NOT_FOUND( + `Failed to retrieve contract results for transaction ${transactionIdOrHash}`, + ); + } + return await this.formatOpcodesResult(response, options); } catch (e) { throw this.common.genericErrorHandler(e); diff --git a/packages/server/tests/acceptance/debug.spec.ts b/packages/server/tests/acceptance/debug.spec.ts index 4ef29c17dc..482ac0d91c 100644 --- a/packages/server/tests/acceptance/debug.spec.ts +++ b/packages/server/tests/acceptance/debug.spec.ts @@ -16,7 +16,9 @@ import MirrorClient from '../clients/mirrorClient'; import RelayClient from '../clients/relayClient'; import ServicesClient from '../clients/servicesClient'; import basicContractJson from '../contracts/Basic.json'; +import parentContractJson from '../contracts/Parent.json'; import reverterContractJson from '../contracts/Reverter.json'; +import Assertions from '../helpers/assertions'; import { Utils } from '../helpers/utils'; import { AliasAccount } from '../types/AliasAccount'; @@ -37,6 +39,10 @@ describe('@debug API Acceptance Tests', function () { let reverterContract: ethers.Contract; let reverterContractAddress: string; let deploymentBlockNumber: number; + let parentContract: ethers.Contract; + let parentContractAddress: string; + let createChildTx: ethers.ContractTransactionResponse; + let mirrorContractDetails: any; const PURE_METHOD_CALL_DATA = '0xb2e0100c'; const BASIC_CONTRACT_PING_CALL_DATA = '0x5c36b186'; @@ -44,6 +50,27 @@ describe('@debug API Acceptance Tests', function () { const ONE_TINYBAR = Utils.add0xPrefix(Utils.toHex(ethers.parseUnits('1', 10))); const CHAIN_ID = ConfigService.get('CHAIN_ID'); const DEBUG_TRACE_BLOCK_BY_NUMBER = 'debug_traceBlockByNumber'; + const DEBUG_TRACE_TRANSACTION = 'debug_traceTransaction'; + + const TRACER_CONFIGS = { + CALL_TRACER_TOP_ONLY_FALSE: { tracer: TracerType.CallTracer, tracerConfig: { onlyTopCall: false } }, + CALL_TRACER_TOP_ONLY: { tracer: TracerType.CallTracer, tracerConfig: { onlyTopCall: true } }, + PRESTATE_TRACER: { tracer: TracerType.PrestateTracer }, + PRESTATE_TRACER_TOP_ONLY: { tracer: TracerType.PrestateTracer, tracerConfig: { onlyTopCall: true } }, + PRESTATE_TRACER_TOP_ONLY_FALSE: { tracer: TracerType.PrestateTracer, onlyTopCall: false }, + OPCODE_LOGGER: { tracer: TracerType.OpcodeLogger }, + OPCODE_WITH_MEMORY: { tracer: TracerType.OpcodeLogger, tracerConfig: { enableMemory: true } }, + OPCODE_WITH_MEMORY_AND_STACK: { + tracer: TracerType.OpcodeLogger, + tracerConfig: { enableMemory: true, enableStack: true }, + }, + OPCODE_WITH_STACK: { tracer: TracerType.OpcodeLogger, tracerConfig: { disableStack: true } }, + OPCODE_WITH_STORAGE: { tracer: TracerType.OpcodeLogger, tracerConfig: { disableStorage: true } }, + OPCODE_WITH_MEMORY_AND_STORAGE: { + tracer: TracerType.OpcodeLogger, + tracerConfig: { enableMemory: true, disableStorage: true }, + }, + }; before(async () => { requestId = Utils.generateRequestId(); @@ -124,10 +151,12 @@ describe('@debug API Acceptance Tests', function () { const txTrace = result.find((trace) => trace.txHash === transactionHash); expect(txTrace).to.exist; expect(txTrace.result).to.exist; - expect(txTrace.result.type).to.equal('CALL'); - expect(txTrace.result.from.toLowerCase()).to.equal(accounts[0].address.toLowerCase()); - expect(txTrace.result.to.toLowerCase()).to.equal(basicContractAddress.toLowerCase()); - expect(txTrace.result.input).to.equal(BASIC_CONTRACT_PING_CALL_DATA); + Assertions.validateCallTracerResult( + txTrace.result, + BASIC_CONTRACT_PING_CALL_DATA, + accounts[0].address, + basicContractAddress, + ); }); it('@release should trace a block containing a failing transaction using CallTracer', async function () { @@ -162,12 +191,12 @@ describe('@debug API Acceptance Tests', function () { // Find our transaction in the result const txTrace = result.find((trace) => trace.txHash === transactionHash); - expect(txTrace).to.exist; - expect(txTrace.result).to.exist; - expect(txTrace.result.type).to.equal('CALL'); - expect(txTrace.result.from.toLowerCase()).to.equal(accounts[0].address.toLowerCase()); - expect(txTrace.result.to.toLowerCase()).to.equal(reverterContractAddress.toLowerCase()); - expect(txTrace.result.input).to.equal(PURE_METHOD_CALL_DATA); + Assertions.validateCallTracerResult( + txTrace.result, + PURE_METHOD_CALL_DATA, + accounts[0].address, + reverterContractAddress, + ); expect(txTrace.result.error).to.exist; // There should be an error field for the reverted transaction expect(txTrace.result.revertReason).to.exist; // There should be a revert reason }); @@ -197,8 +226,11 @@ describe('@debug API Acceptance Tests', function () { const blockNumber = receipt.blockNumber; // Call debug_traceBlockByNumber with PrestateTracer - const tracerConfig = { tracer: TracerType.PrestateTracer }; - const result = await relay.call(DEBUG_TRACE_BLOCK_BY_NUMBER, [blockNumber, tracerConfig], requestId); + const result = await relay.call( + DEBUG_TRACE_BLOCK_BY_NUMBER, + [blockNumber, TRACER_CONFIGS.PRESTATE_TRACER], + requestId, + ); expect(result).to.be.an('array'); expect(result.length).to.be.at.least(1); @@ -215,10 +247,7 @@ describe('@debug API Acceptance Tests', function () { // For each address in the result, check it has the expected fields for (const address of keys) { const state = txTrace.result[address]; - expect(state).to.have.property('balance'); - expect(state).to.have.property('nonce'); - expect(state).to.have.property('code'); - expect(state).to.have.property('storage'); + Assertions.validatePrestateTracerResult(state); } }); @@ -245,14 +274,16 @@ describe('@debug API Acceptance Tests', function () { const blockNumber = receipt.blockNumber; // First trace with onlyTopCall=false (default) - const fullTracerConfig = { tracer: TracerType.PrestateTracer, onlyTopCall: false }; - const fullResult = await relay.call(DEBUG_TRACE_BLOCK_BY_NUMBER, [blockNumber, fullTracerConfig], requestId); + const fullResult = await relay.call( + DEBUG_TRACE_BLOCK_BY_NUMBER, + [blockNumber, TRACER_CONFIGS.PRESTATE_TRACER_TOP_ONLY_FALSE], + requestId, + ); // Then trace with onlyTopCall=true - const topCallTracerConfig = { tracer: TracerType.PrestateTracer, onlyTopCall: true }; const topCallResult = await relay.call( DEBUG_TRACE_BLOCK_BY_NUMBER, - [blockNumber, topCallTracerConfig], + [blockNumber, TRACER_CONFIGS.PRESTATE_TRACER_TOP_ONLY], requestId, ); @@ -284,10 +315,7 @@ describe('@debug API Acceptance Tests', function () { // Each address should have the standard fields for (const address of topCallAddresses) { const state = topCallTxTrace.result[address]; - expect(state).to.have.property('balance'); - expect(state).to.have.property('nonce'); - expect(state).to.have.property('code'); - expect(state).to.have.property('storage'); + Assertions.validatePrestateTracerResult(state); } }); @@ -319,10 +347,9 @@ describe('@debug API Acceptance Tests', function () { if (!hasTransactions) { // Found a block without transactions - const tracerConfig = { tracer: TracerType.CallTracer, onlyTopCall: false }; const result = await relay.call( DEBUG_TRACE_BLOCK_BY_NUMBER, - [numberTo0x(blockNumberToTest), tracerConfig], + [numberTo0x(blockNumberToTest), TRACER_CONFIGS.CALL_TRACER_TOP_ONLY_FALSE], requestId, ); @@ -342,12 +369,10 @@ describe('@debug API Acceptance Tests', function () { ConfigServiceTestHelper.dynamicOverride('DEBUG_API_ENABLED', false); try { - const tracerConfig = { tracer: TracerType.CallTracer, onlyTopCall: false }; - // Should return UNSUPPORTED_METHOD error await relay.callFailing( DEBUG_TRACE_BLOCK_BY_NUMBER, - [numberTo0x(deploymentBlockNumber), tracerConfig], + [numberTo0x(deploymentBlockNumber), TRACER_CONFIGS.CALL_TRACER_TOP_ONLY_FALSE], predefined.UNSUPPORTED_METHOD, requestId, ); @@ -358,12 +383,10 @@ describe('@debug API Acceptance Tests', function () { }); it('should fail with INVALID_PARAMETER when given an invalid block number', async function () { - const tracerConfig = { tracer: TracerType.CallTracer, onlyTopCall: false }; - // Invalid block number format await relay.callFailing( DEBUG_TRACE_BLOCK_BY_NUMBER, - ['invalidBlockNumber', tracerConfig], + ['invalidBlockNumber', TRACER_CONFIGS.CALL_TRACER_TOP_ONLY_FALSE], predefined.INVALID_PARAMETER( '0', 'Expected 0x prefixed hexadecimal block number, or the string "latest", "earliest" or "pending"', @@ -382,4 +405,301 @@ describe('@debug API Acceptance Tests', function () { ); }); }); + + describe('debug_traceTransaction', () => { + const PARENT_CONTRACT_CREATE_CHILD_CALL_DATA = + '0x0419eca50000000000000000000000000000000000000000000000000000000000000001'; + before(async () => { + // Deploy the Parent contract for testing transactions with internal calls + parentContract = await Utils.deployContract( + parentContractJson.abi, + parentContractJson.bytecode, + accounts[0].wallet, + ); + parentContractAddress = parentContract.target as string; + + // Send some ether to the parent contract + const response = await accounts[0].wallet.sendTransaction({ + to: parentContractAddress, + value: ethers.parseEther('1'), + }); + await relay.pollForValidTransactionReceipt(response.hash); + + // Call createChild to create a transaction with internal calls + // @ts-ignore + createChildTx = await parentContract.createChild(1); + + await relay.pollForValidTransactionReceipt(createChildTx.hash); + + // Get contract result details from mirror node + mirrorContractDetails = await mirrorNode.get(`/contracts/results/${createChildTx.hash}`, requestId); + mirrorContractDetails.from = accounts[0].address; + }); + + describe('Call Tracer', () => { + it('should trace a transaction using CallTracer with onlyTopCall=false', async function () { + // Call debug_traceTransaction with CallTracer (default config) + const result = await relay.call( + DEBUG_TRACE_TRANSACTION, + [createChildTx.hash, TRACER_CONFIGS.CALL_TRACER_TOP_ONLY_FALSE], + requestId, + ); + + Assertions.validateCallTracerResult( + result, + PARENT_CONTRACT_CREATE_CHILD_CALL_DATA, + accounts[0].address, + parentContractAddress, + ); + expect(result).to.have.property('calls'); + }); + + it('should trace a transaction using CallTracer with onlyTopCall=true', async function () { + // Call debug_traceTransaction with CallTracer (default config) + const result = await relay.call( + DEBUG_TRACE_TRANSACTION, + [createChildTx.hash, TRACER_CONFIGS.CALL_TRACER_TOP_ONLY], + requestId, + ); + + Assertions.validateCallTracerResult( + result, + PARENT_CONTRACT_CREATE_CHILD_CALL_DATA, + accounts[0].address, + parentContractAddress, + ); + expect(result).to.not.have.property('calls'); + }); + }); + + describe('OpcodeLogger', () => { + it('@release should trace a successful transaction using OpcodeLogger (default when no tracer specified)', async function () { + const result = await relay.call(DEBUG_TRACE_TRANSACTION, [createChildTx.hash], requestId); + + // Validate response structure for OpcodeLogger + Assertions.validateOpcodeLoggerResult(result); + + // Check that structLogs contains opcode information + if (result.structLogs.length > 0) { + const firstLog = result.structLogs[0]; + expect(firstLog).to.have.property('pc'); + expect(firstLog).to.have.property('op'); + expect(firstLog).to.have.property('gas'); + expect(firstLog).to.have.property('gasCost'); + expect(firstLog).to.have.property('depth'); + } + }); + + it('@release should trace a successful transaction using OpcodeLogger explicitly', async function () { + const result = await relay.call( + DEBUG_TRACE_TRANSACTION, + [createChildTx.hash, TRACER_CONFIGS.OPCODE_LOGGER], + requestId, + ); + + Assertions.validateOpcodeLoggerResult(result); + }); + + it('@release should trace using OpcodeLogger with custom config (enableMemory=true)', async function () { + const result = await relay.call( + DEBUG_TRACE_TRANSACTION, + [createChildTx.hash, TRACER_CONFIGS.OPCODE_WITH_MEMORY], + requestId, + ); + + expect(result).to.be.an('object'); + expect(result).to.have.property('structLogs'); + + // With enableMemory=true, memory field should be present in struct logs + if (result.structLogs.length > 0) { + const logsWithMemory = result.structLogs.filter((log) => log.memory); + expect(logsWithMemory.length).to.be.greaterThan(0); + } + }); + + it('@release should trace using OpcodeLogger with custom config (disableStack=true)', async function () { + const result = await relay.call( + DEBUG_TRACE_TRANSACTION, + [createChildTx.hash, TRACER_CONFIGS.OPCODE_WITH_STACK], + requestId, + ); + + expect(result).to.be.an('object'); + expect(result).to.have.property('structLogs'); + + // With disableStack=true, stack field should not be present in struct logs + if (result.structLogs.length > 0) { + const logsWithStack = result.structLogs.filter((log) => log.stack); + expect(logsWithStack.length).to.equal(0); + } + }); + + it('@release should trace using OpcodeLogger with custom config (disableStorage=true)', async function () { + const result = await relay.call( + DEBUG_TRACE_TRANSACTION, + [createChildTx.hash, TRACER_CONFIGS.OPCODE_WITH_STORAGE], + requestId, + ); + + expect(result).to.be.an('object'); + expect(result).to.have.property('structLogs'); + + // With disableStorage=true, storage field should not be present in struct logs + if (result.structLogs.length > 0) { + const logsWithStorage = result.structLogs.filter((log) => log.storage); + expect(logsWithStorage.length).to.equal(0); + } + }); + + it('@release should trace using OpcodeLogger with custom config (enableMemory=true, disableStorage=true)', async function () { + const result = await relay.call( + DEBUG_TRACE_TRANSACTION, + [createChildTx.hash, TRACER_CONFIGS.OPCODE_WITH_MEMORY_AND_STORAGE], + requestId, + ); + + expect(result).to.be.an('object'); + expect(result).to.have.property('structLogs'); + }); + }); + + describe('Edge Cases - Parameter Validation', () => { + it('should fail with MISSING_REQUIRED_PARAMETER when transaction hash is missing', async function () { + await relay.callFailing(DEBUG_TRACE_TRANSACTION, [], predefined.MISSING_REQUIRED_PARAMETER(0), requestId); + }); + + it('should fail with INVALID_PARAMETER when given an invalid transaction hash format', async function () { + const invalidHash = '0xinvalidhash'; + await relay.callFailing( + DEBUG_TRACE_TRANSACTION, + [invalidHash], + predefined.INVALID_PARAMETER( + 0, + 'The value passed is not valid: 0xinvalidhash. Expected Expected 0x prefixed string representing the hash (32 bytes) of a transaction OR Expected a transaction ID string in the format "shard.realm.num-sss-nnn" where sss are seconds and nnn are nanoseconds', + ), + requestId, + ); + }); + + it('should fail with RESOURCE_NOT_FOUND for non-existent transaction hash and no tracer', async function () { + const nonExistentHash = '0x1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef'; + await relay.callFailing( + DEBUG_TRACE_TRANSACTION, + [nonExistentHash], + predefined.RESOURCE_NOT_FOUND(`Failed to retrieve contract results for transaction ${nonExistentHash}`), + requestId, + ); + }); + + it('should fail with RESOURCE_NOT_FOUND for non-existent transaction hash with tracer', async function () { + const nonExistentHash = '0x1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdee'; + await relay.callFailing( + DEBUG_TRACE_TRANSACTION, + [nonExistentHash, TRACER_CONFIGS.CALL_TRACER_TOP_ONLY], + predefined.RESOURCE_NOT_FOUND(`Failed to retrieve contract results for transaction ${nonExistentHash}`), + requestId, + ); + }); + + it('should fail with INVALID_PARAMETER when using PrestateTracer', async function () { + await relay.callFailing( + DEBUG_TRACE_TRANSACTION, + [createChildTx.hash, TRACER_CONFIGS.PRESTATE_TRACER], + predefined.INVALID_PARAMETER(1, 'Prestate tracer is not yet supported on debug_traceTransaction'), + requestId, + ); + }); + + it('should fail with INVALID_PARAMETER when given an invalid tracer type', async function () { + const invalidTracerConfig = { tracer: 'InvalidTracer' }; + await relay.callFailing( + DEBUG_TRACE_TRANSACTION, + [createChildTx.hash, invalidTracerConfig], + predefined.INVALID_PARAMETER("'tracer' for TracerConfigWrapper", 'Expected TracerType, value: InvalidTracer'), + requestId, + ); + }); + + it('should fail with INVALID_PARAMETER when given invalid TracerConfig for CallTracer', async function () { + const invalidTracerConfig = { + tracer: TracerType.CallTracer, + tracerConfig: { onlyTopCall: 'invalid' }, + }; + await relay.callFailing( + DEBUG_TRACE_TRANSACTION, + [createChildTx.hash, invalidTracerConfig], + predefined.INVALID_PARAMETER("'tracerConfig' for TracerConfigWrapper", 'Expected TracerConfig'), + requestId, + ); + }); + + it('should fail with INVALID_PARAMETER when given invalid TracerConfig for OpcodeLogger', async function () { + const invalidTracerConfig = { + tracer: TracerType.OpcodeLogger, + tracerConfig: { enableMemory: 'invalid' }, + }; + await relay.callFailing( + DEBUG_TRACE_TRANSACTION, + [createChildTx.hash, invalidTracerConfig], + predefined.INVALID_PARAMETER("'tracerConfig' for TracerConfigWrapper", 'Expected TracerConfig'), + requestId, + ); + }); + + it('should fail with INVALID_PARAMETER when using CallTracer config with OpcodeLogger tracer', async function () { + const invalidTracerConfig = { + tracer: TracerType.OpcodeLogger, + tracerConfig: { onlyTopCall: true }, // CallTracer config with OpcodeLogger tracer + }; + await relay.callFailing( + DEBUG_TRACE_TRANSACTION, + [createChildTx.hash, invalidTracerConfig], + predefined.INVALID_PARAMETER( + 1, + "callTracer 'tracerConfig' for TracerConfigWrapper is only valid when tracer=callTracer", + ), + requestId, + ); + }); + + it('should fail with INVALID_PARAMETER when using OpcodeLogger config with CallTracer tracer', async function () { + const invalidTracerConfig = { + tracer: TracerType.CallTracer, + tracerConfig: { enableMemory: true }, // OpcodeLogger config with CallTracer tracer + }; + await relay.callFailing( + DEBUG_TRACE_TRANSACTION, + [createChildTx.hash, invalidTracerConfig], + predefined.INVALID_PARAMETER( + 1, + "opcodeLogger 'tracerConfig' for TracerConfigWrapper is only valid when tracer=opcodeLogger", + ), + requestId, + ); + }); + + it('should fail with UNSUPPORTED_METHOD error when DEBUG_API_ENABLED is false', async function () { + // Store original value + const originalDebugApiEnabled = ConfigService.get('DEBUG_API_ENABLED'); + + // Set DEBUG_API_ENABLED to false + ConfigServiceTestHelper.dynamicOverride('DEBUG_API_ENABLED', false); + + try { + const tracerConfig = { tracer: TracerType.CallTracer, tracerConfig: { onlyTopCall: false } }; + + // Should return UNSUPPORTED_METHOD error + await relay.callFailing( + DEBUG_TRACE_TRANSACTION, + [createChildTx.hash, tracerConfig], + predefined.UNSUPPORTED_METHOD, + requestId, + ); + } finally { + // Restore original value + ConfigServiceTestHelper.dynamicOverride('DEBUG_API_ENABLED', originalDebugApiEnabled); + } + }); + }); + }); }); diff --git a/packages/server/tests/helpers/assertions.ts b/packages/server/tests/helpers/assertions.ts index 6973039461..7d9042ebd3 100644 --- a/packages/server/tests/helpers/assertions.ts +++ b/packages/server/tests/helpers/assertions.ts @@ -454,4 +454,44 @@ export default class Assertions { const delta = tolerance * expected; expect(actual).to.be.approximately(expected, delta); } + + /** + * Validates the result from a Call Tracer debug trace + * @param {any} result - The result object from the Call Tracer + * @param {string} expectedInput - The expected input data for the call + * @param {string} expectedFrom - The expected 'from' address of the call + * @param {string} expectedTo - The expected 'to' address of the call + */ + static validateCallTracerResult(result: any, expectedInput: string, expectedFrom: string, expectedTo: string) { + expect(result).to.be.an('object'); + expect(result).to.have.property('type', 'CALL'); + expect(result.from.toLowerCase()).to.equal(expectedFrom.toLowerCase()); + expect(result.to.toLowerCase()).to.equal(expectedTo.toLowerCase()); + expect(result).to.have.property('value'); + expect(result).to.have.property('gas'); + expect(result).to.have.property('gasUsed'); + expect(result).to.have.property('input', expectedInput); + expect(result).to.have.property('output'); + } + + /** + * Validates the result from an Opcode Logger debug trace + * @param {any} result - The result object from the Opcode Logger + */ + static validateOpcodeLoggerResult(result: any) { + expect(result).to.be.an('object'); + expect(result).to.have.property('gas'); + expect(result).to.have.property('failed'); + expect(result).to.have.property('returnValue'); + expect(result).to.have.property('structLogs'); + expect(result.structLogs).to.be.an('array'); + } + + static validatePrestateTracerResult(state: any) { + expect(state).to.be.an('object'); + expect(state).to.have.property('balance'); + expect(state).to.have.property('nonce'); + expect(state).to.have.property('code'); + expect(state).to.have.property('storage'); + } }