diff --git a/docs/openrpc.json b/docs/openrpc.json index 2e36170fa9..3ee456619d 100644 --- a/docs/openrpc.json +++ b/docs/openrpc.json @@ -1429,6 +1429,30 @@ } } }, + { + "name": "debug_getRawReceipts", + "summary": "Returns an array of EIP-2718 binary-encoded receipts.", + "description": "![](https://raw.githubusercontent.com/hiero-ledger/hiero-json-rpc-relay/main/docs/images/http_label.png)", + "params": [ + { + "name": "block", + "required": true, + "schema": { + "$ref": "#/components/schemas/BlockNumberOrTagOrHash" + } + } + ], + "result": { + "name": "debug_getRawReceipts result", + "description": "An array of EIP-2718 binary-encoded receipts.", + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/bytes" + } + } + } + }, { "name": "eth_getProof", "summary": "Always returns UNSUPPORTED_METHOD error.", diff --git a/docs/rpc-api.md b/docs/rpc-api.md index 004f1bad74..c840e8fcb9 100644 --- a/docs/rpc-api.md +++ b/docs/rpc-api.md @@ -131,7 +131,7 @@ These methods are extensions provided by various Ethereum clients but are not pa | [debug_getBadBlocks](https://geth.ethereum.org/docs/interacting-with-geth/rpc/ns-debug#debuggetbadblocks) | **Implemented** - Requires `DEBUG_API_ENABLED=true` | N/A | Always returns empty array | | [debug_getRawBlock](https://geth.ethereum.org/docs/interacting-with-geth/rpc/ns-debug#debuggetrawblock) | **Implemented** - Requires `DEBUG_API_ENABLED=true` | N/A | | | [debug_getRawHeader](https://geth.ethereum.org/docs/interacting-with-geth/rpc/ns-debug#debuggetrawheader) | **Not Implemented** | N/A | | -| [debug_getRawReceipts](https://geth.ethereum.org/docs/interacting-with-geth/rpc/ns-debug#debuggetrawreceipts) | **Not Implemented** | N/A | | +| [debug_getRawReceipts](https://geth.ethereum.org/docs/interacting-with-geth/rpc/ns-debug#debuggetrawreceipts) | **Implemented** - Requires `DEBUG_API_ENABLED=true` | N/A | | | [debug_getRawTransaction](https://geth.ethereum.org/docs/interacting-with-geth/rpc/ns-debug#debuggetrawtransaction) | **Not Implemented** | N/A | | | [debug_traceBlockByHash](https://geth.ethereum.org/docs/interacting-with-geth/rpc/ns-debug#debugtraceblockbyhash) | **Implemented** - Requires `DEBUG_API_ENABLED=true` | Mirror Node | Supports CallTracer and PrestateTracer, caches results | | [debug_traceBlockByNumber](https://geth.ethereum.org/docs/interacting-with-geth/rpc/ns-debug#debugtraceblockbynumber) | **Implemented** - Requires `DEBUG_API_ENABLED=true` | Mirror Node | Supports CallTracer and PrestateTracer, caches results | diff --git a/packages/relay/src/index.ts b/packages/relay/src/index.ts index 8e68dc9c8f..21efa4ef49 100644 --- a/packages/relay/src/index.ts +++ b/packages/relay/src/index.ts @@ -36,6 +36,8 @@ export interface Debug { getRawBlock(blockNrOrHash: string, requestDetails: RequestDetails): Promise; traceBlockByHash(blockHash: string, tracerObject: BlockTracerConfig, requestDetails: RequestDetails): Promise; + + getRawReceipts(blockHashOrNumber: string, requestDetails: RequestDetails): Promise; } export interface Web3 { diff --git a/packages/relay/src/lib/debug.ts b/packages/relay/src/lib/debug.ts index 41fa5d465d..98c645a4eb 100644 --- a/packages/relay/src/lib/debug.ts +++ b/packages/relay/src/lib/debug.ts @@ -298,6 +298,34 @@ export class DebugImpl implements Debug { return []; } + /** + * Returns an array of EIP-2718 binary-encoded receipts. + * + * @async + * @rpcMethod Exposed as debug_getRawReceipts RPC endpoint + * @rpcParamValidationRules Applies JSON-RPC parameter validation according to the API specification + * + * @param {string} blockHashOrNumber - The block hash or block number. + * @param {RequestDetails} requestDetails - The request details for logging and tracking. + * @throws {Error} Throws an error if the debug API is not enabled or if an exception occurs. + * @returns {Promise} A Promise that resolves to an array of EIP-2718 binary-encoded receipts or empty array if block not found. + * + * @example + * const result = await getRawReceipts('0x1234', requestDetails); + * // result: ["0xe6808...", "0xe6809..."] + */ + @rpcMethod + @rpcParamValidationRules({ + 0: { type: ['blockNumber', 'blockHash'], required: true }, + }) + @cache({ + skipParams: [{ index: '0', value: constants.NON_CACHABLE_BLOCK_PARAMS }], + }) + async getRawReceipts(blockHashOrNumber: string, requestDetails: RequestDetails): Promise { + DebugImpl.requireDebugAPIEnabled(); + return await this.blockService.getRawReceipts(blockHashOrNumber, requestDetails); + } + /** * Formats the result from the actions endpoint to the expected response * diff --git a/packages/relay/src/lib/receiptSerialization.ts b/packages/relay/src/lib/receiptSerialization.ts new file mode 100644 index 0000000000..1d7fd2dd07 --- /dev/null +++ b/packages/relay/src/lib/receiptSerialization.ts @@ -0,0 +1,78 @@ +// SPDX-License-Identifier: Apache-2.0 + +/** + * Receipt serialization per Ethereum Yellow Paper and EIP-2718. + * + * Yellow Paper: receipt is RLP of the 4-tuple + * (receipt_root_or_status, cumulative_gas_used, logs_bloom, logs). + * Post-Byzantium: first field is status (empty for 0, 0x01 for 1) or 32-byte state root. + * Each log: RLP([address, topics[], data]). + * + * EIP-2718: for typed txs (type !== 0), wire format is type_byte || RLP(above 4-tuple). + */ + +import { RLP } from '@ethereumjs/rlp'; +import { bytesToInt, concatBytes, hexToBytes, intToBytes } from '@ethereumjs/util'; + +import { prepend0x, toHexString } from '../formatters'; +import constants from './constants'; +import type { Log } from './model'; +import { IReceiptRlpInput } from './types/IReceiptRlpInput'; + +/** + * Converts receipt logs into the RLP encoded log structure. + * + * Each log becomes a 3-tuple [address, topics[], data] per the Yellow Paper + * (address and data as bytes; topics as array of 32-byte topic hashes). + * + * @param logs - The logs array from the transaction receipt (see {@link Log}). + * @returns Array of [address, topics, data] as Uint8Arrays for RLP encoding. + */ +function encodeLogsForReceipt(logs: Log[]): [Uint8Array, Uint8Array[], Uint8Array][] { + return logs.map((log) => [hexToBytes(log.address), log.topics.map((t) => hexToBytes(t)), hexToBytes(log.data)]); +} + +/** + * Encodes a single transaction receipt to EIP-2718 binary form. + * + * Produces the RLP-encoded 4-tuple (receipt_root_or_status, cumulative_gas_used, + * logs_bloom, logs) per the Ethereum Yellow Paper. For typed transactions (type !== 0), + * the output is the single-byte type prefix followed by that RLP payload (EIP-2718). + * + * Based on section 4.4.1 (Transaction Receipt) from the Ethereum Yellow Paper: https://ethereum.github.io/yellowpaper/paper.pdf + * + * @param receipt - The transaction receipt to encode (see {@link ITransactionReceipt}). + * @returns Hex string (0x-prefixed) of the encoded receipt, suitable for receipts root hashing. + */ +export function encodeReceiptToHex(receipt: IReceiptRlpInput): string { + const txType = receipt.type !== null ? bytesToInt(hexToBytes(receipt.type)) : 0; + + // First field: receipt root or status (post-Byzantium) + let receiptRootOrStatus: Uint8Array; + if (receipt.root && receipt.root.length > 2) { + receiptRootOrStatus = hexToBytes(receipt.root); + } else if (receipt.status && bytesToInt(hexToBytes(receipt.status)) === 0) { + receiptRootOrStatus = new Uint8Array(0); + } else { + receiptRootOrStatus = hexToBytes(constants.ONE_HEX); + } + + const cumulativeGasUsed = receipt.cumulativeGasUsed; + const cumulativeGasUsedBytes = + BigInt(cumulativeGasUsed) === BigInt(0) + ? new Uint8Array(0) + : hexToBytes(prepend0x(BigInt(cumulativeGasUsed).toString(16))); // canonical RLP encoding (no leading zeros) + + const encodedList = RLP.encode([ + receiptRootOrStatus, + cumulativeGasUsedBytes, + hexToBytes(receipt.logsBloom), + encodeLogsForReceipt(receipt.logs), + ]); + + if (txType === 0) { + return prepend0x(toHexString(encodedList)); + } + const withPrefix = concatBytes(intToBytes(txType), encodedList); + return prepend0x(toHexString(withPrefix)); +} diff --git a/packages/relay/src/lib/services/ethService/blockService/BlockService.ts b/packages/relay/src/lib/services/ethService/blockService/BlockService.ts index 8d02344157..445244dc60 100644 --- a/packages/relay/src/lib/services/ethService/blockService/BlockService.ts +++ b/packages/relay/src/lib/services/ethService/blockService/BlockService.ts @@ -3,13 +3,13 @@ import { Logger } from 'pino'; import { numberTo0x } from '../../../../formatters'; +import { ICacheClient } from '../../../clients/cache/ICacheClient'; import { MirrorNodeClient } from '../../../clients/mirrorNodeClient'; import constants from '../../../constants'; import { Block } from '../../../model'; import { ITransactionReceipt, MirrorNodeBlock, RequestDetails } from '../../../types'; import { IBlockService, ICommonService } from '../../index'; import { WorkersPool } from '../../workersService/WorkersPool'; -import { ICacheClient } from '../../../clients/cache/ICacheClient'; export class BlockService implements IBlockService { /** @@ -115,6 +115,25 @@ export class BlockService implements IBlockService { ); } + /** + * Gets the raw transaction receipts for a block by block hash or block number. + * + * @param {string} blockHashOrBlockNumber The block hash or block number + * @param {RequestDetails} requestDetails The request details for logging and tracking + * @returns {Promise} Array of raw block receipts for the block + */ + public async getRawReceipts(blockHashOrBlockNumber: string, requestDetails: RequestDetails): Promise { + return WorkersPool.run( + { + type: 'getRawReceipts', + blockHashOrBlockNumber, + requestDetails, + }, + this.mirrorNodeClient, + this.cacheService, + ); + } + /** * Gets the number of transaction in a block by its block hash. * diff --git a/packages/relay/src/lib/services/ethService/blockService/IBlockService.ts b/packages/relay/src/lib/services/ethService/blockService/IBlockService.ts index 66dac968cf..26ee35b3b2 100644 --- a/packages/relay/src/lib/services/ethService/blockService/IBlockService.ts +++ b/packages/relay/src/lib/services/ethService/blockService/IBlockService.ts @@ -13,6 +13,7 @@ export interface IBlockService { getBlockTransactionCountByHash: (hash: string, requestDetails: RequestDetails) => Promise; getBlockTransactionCountByNumber: (blockNum: string, requestDetails: RequestDetails) => Promise; getBlockReceipts: (blockHash: string, requestDetails: RequestDetails) => Promise; + getRawReceipts: (blockHashOrBlockNumber: string, requestDetails: RequestDetails) => Promise; getUncleByBlockHashAndIndex: (blockHash: string, index: string) => null; getUncleByBlockNumberAndIndex: (blockNumOrTag: string, index: string) => null; getUncleCountByBlockHash: (blockHash: string) => string; diff --git a/packages/relay/src/lib/services/ethService/blockService/blockWorker.ts b/packages/relay/src/lib/services/ethService/blockService/blockWorker.ts index c69aa6360d..9646dee257 100644 --- a/packages/relay/src/lib/services/ethService/blockService/blockWorker.ts +++ b/packages/relay/src/lib/services/ethService/blockService/blockWorker.ts @@ -21,7 +21,9 @@ import { TransactionReceiptFactory, } from '../../../factories/transactionReceiptFactory'; import { Block, Log, Transaction } from '../../../model'; +import { encodeReceiptToHex } from '../../../receiptSerialization'; import { IContractResultsParams, ITransactionReceipt, MirrorNodeBlock, RequestDetails } from '../../../types'; +import { IReceiptRlpInput } from '../../../types/IReceiptRlpInput'; import { WorkersPool } from '../../workersService/WorkersPool'; import { CommonService } from '../ethCommonService/CommonService'; @@ -336,27 +338,10 @@ export async function getBlockReceipts( requestDetails: RequestDetails, ): Promise { try { - const block = await commonService.getHistoricalBlockResponse(requestDetails, blockHashOrBlockNumber); + const { block, contractResults, logsByHash } = await loadBlockExecutionData(blockHashOrBlockNumber, requestDetails); + if (!block) return null; - if (block == null) { - return null; - } - - const paramTimestamp: IContractResultsParams = { - timestamp: [`lte:${block.timestamp.to}`, `gte:${block.timestamp.from}`], - }; - - // Calculate slice count based on actual transaction count for optimized parallel log retrieval - const calculatedSliceCount = Math.ceil( - block.count / ConfigService.get('MIRROR_NODE_TIMESTAMP_SLICING_MAX_LOGS_PER_SLICE'), - ); - - const [contractResults, logs] = await Promise.all([ - mirrorNodeClient.getContractResults(requestDetails, paramTimestamp), - commonService.getLogsWithParams(null, paramTimestamp, requestDetails, calculatedSliceCount), - ]); - - if ((!contractResults || contractResults.length === 0) && logs.length == 0) { + if ((!contractResults || contractResults.length === 0) && logsByHash.size === 0) { return []; } @@ -365,13 +350,6 @@ export async function getBlockReceipts( await commonService.getGasPriceInWeibars(requestDetails, block.timestamp.from.split('.')[0]), ); - const logsByHash = new Map(); - for (const log of logs) { - const existingLogs = logsByHash.get(log.transactionHash) || []; - existingLogs.push(log); - logsByHash.set(log.transactionHash, existingLogs); - } - const receiptPromises = contractResults.map(async (contractResult) => { if (Utils.isRejectedDueToHederaSpecificValidation(contractResult)) { logger.debug( @@ -399,7 +377,7 @@ export async function getBlockReceipts( }); const resolvedReceipts = await Promise.all(receiptPromises); - receipts.push(...resolvedReceipts.filter(Boolean)); + receipts.push(...resolvedReceipts.filter((r): r is ITransactionReceipt => r !== null)); const regularTxHashes = new Set(contractResults.map((result) => result.hash)); @@ -420,6 +398,169 @@ export async function getBlockReceipts( } } +/** + * Returns RLP-encoded transaction receipts for a block as hex strings. + * + * Loads block execution data (contract results and logs), then for each contract result + * builds a receipt (with logs and cumulative gas), encodes it to RLP hex, and skips + * results that fail Hedera-specific validation. Also appends synthetic receipts for + * log groups that have no matching contract result. + * + * @param blockHashOrBlockNumber - Block hash (0x-prefixed) or block number string + * @param requestDetails - The request details for logging and tracking + * @returns Promise of an array of hex-encoded receipt strings (RLP), or empty array if + * the block has no contract results and no logs. Re-throws errors wrapped with + * {@link WorkersPool.wrapError}. + */ +export async function getRawReceipts( + blockHashOrBlockNumber: string, + requestDetails: RequestDetails, +): Promise { + try { + const { block, contractResults, logsByHash } = await loadBlockExecutionData(blockHashOrBlockNumber, requestDetails); + if (!block || ((!contractResults || contractResults.length === 0) && logsByHash.size === 0)) { + return []; + } + + const encodedReceipts: string[] = []; + + let blockGasUsedBeforeTransaction = 0; + const encodedReceiptPromises = contractResults.map(async (contractResult) => { + if (Utils.isRejectedDueToHederaSpecificValidation(contractResult)) { + logger.debug( + `Transaction with hash %s is skipped due to hedera-specific validation failure (%s)`, + contractResult.hash, + contractResult.result, + ); + return null; + } + + contractResult.logs = logsByHash.get(contractResult.hash) || []; + + const receiptRlpInput = createReceiptRlpInput(contractResult.logs, contractResult, blockGasUsedBeforeTransaction); + blockGasUsedBeforeTransaction += contractResult.gas_used; + return encodeReceiptToHex(receiptRlpInput); + }); + + const resolvedEncodedReceipts = await Promise.all(encodedReceiptPromises); + encodedReceipts.push( + ...resolvedEncodedReceipts.filter((encodedReceipt): encodedReceipt is string => encodedReceipt !== null), + ); + + const regularTxHashes = new Set(contractResults.map((result) => result.hash)); + + // filtering out the synthetic tx hashes and creating the synthetic receipt + for (const [txHash, logGroup] of logsByHash.entries()) { + if (!regularTxHashes.has(txHash)) { + const syntheticReceiptRlpInput = createSyntheticReceiptRlpInput(logGroup); + encodedReceipts.push(encodeReceiptToHex(syntheticReceiptRlpInput)); + } + } + + return encodedReceipts; + } catch (e: unknown) { + throw WorkersPool.wrapError(e); + } +} + +/** + * Loads block metadata plus execution data (contract results and logs) for a given block. + * + * Fetches the block by hash or number, then in parallel loads contract results and logs + * for the block's timestamp range. Logs are grouped by transaction hash for quick lookup. + * + * @param blockHashOrBlockNumber - Block hash (0x-prefixed) or block number string + * @param requestDetails - The request details for logging and tracking + * @returns Promise resolving to `{ block, contractResults, logsByHash }`. If the block is + * not found, returns `{ block: null, contractResults: [], logsByHash: new Map() }`. + * - `block`: The mirror node block or null + * - `contractResults`: Contract results in the block's time range + * - `logsByHash`: Map of transaction hash → log entries for that tx + */ +async function loadBlockExecutionData( + blockHashOrBlockNumber: string, + requestDetails: RequestDetails, +): Promise<{ + block: MirrorNodeBlock | null; + contractResults: any[]; + logsByHash: Map; +}> { + const block = await commonService.getHistoricalBlockResponse(requestDetails, blockHashOrBlockNumber); + if (!block) return { block: null, contractResults: [], logsByHash: new Map() }; + + const paramTimestamp: IContractResultsParams = { + timestamp: [`lte:${block.timestamp.to}`, `gte:${block.timestamp.from}`], + }; + + const sliceCount = Math.ceil(block.count / ConfigService.get('MIRROR_NODE_TIMESTAMP_SLICING_MAX_LOGS_PER_SLICE')); + + const [contractResults, logs] = await Promise.all([ + mirrorNodeClient.getContractResults(requestDetails, paramTimestamp), + commonService.getLogsWithParams(null, paramTimestamp, requestDetails, sliceCount), + ]); + + const logsByHash = new Map(); + for (const log of logs) { + const existingLogs = logsByHash.get(log.transactionHash) || []; + existingLogs.push(log); + logsByHash.set(log.transactionHash, existingLogs); + } + return { block, contractResults, logsByHash }; +} + +/** + * Creates a minimal receipt payload for RLP-encoding of a regular transaction. + * + * Builds an `IReceiptRlpInput` from mirror node contract result data and the + * running cumulative gas used before this transaction. The returned shape + * contains only the fields required for Yellow Paper receipt encoding, including the updated cumulative gas used, + * logs and bloom, root and status, transaction index, and normalized type. + * @param params - Parameters required to build the RLP input, including + * contract result data, associated logs, and the cumulative gas used prior + * to this transaction. + * @returns Minimal receipt data suitable for RLP encoding. + */ +function createReceiptRlpInput( + logs: Log[], + receiptResponse: any, + blockGasUsedBeforeTransaction: number, +): IReceiptRlpInput { + return { + cumulativeGasUsed: numberTo0x(blockGasUsedBeforeTransaction + receiptResponse.gas_used), + logs: logs, + logsBloom: receiptResponse.bloom === constants.EMPTY_HEX ? constants.EMPTY_BLOOM : receiptResponse.bloom, + root: receiptResponse.root || constants.DEFAULT_ROOT_HASH, + status: receiptResponse.status, + transactionIndex: numberTo0x(receiptResponse.transaction_index), + type: nanOrNumberTo0x(receiptResponse.type), + }; +} + +/** + * Creates a minimal receipt payload for RLP-encoding of a synthetic transaction. + * + * Builds an `IReceiptRlpInput` from synthetic logs only, without resolving any + * addresses or constructing a full `ITransactionReceipt`. The returned shape + * contains the fields required for Yellow Paper receipt encoding, including a zero + * cumulative gas used, zero gas used, a logs bloom computed from the first + * synthetic log, default root and status values, the transaction index from + * the first log, and a fallback type of `0x0`. + * + * @param syntheticLogs - Logs belonging to the synthetic transaction. + * @returns Minimal receipt data suitable for RLP encoding. + */ +function createSyntheticReceiptRlpInput(syntheticLogs: Log[]): IReceiptRlpInput { + return { + cumulativeGasUsed: constants.ZERO_HEX, + logs: syntheticLogs, + logsBloom: LogsBloomUtils.buildLogsBloom(syntheticLogs[0].address, syntheticLogs[0].topics), + root: constants.DEFAULT_ROOT_HASH, + status: constants.ONE_HEX, + transactionIndex: syntheticLogs[0].transactionIndex, + type: constants.ZERO_HEX, // fallback to 0x0 from HAPI transactions + }; +} + // export private methods under __test__ "namespace" but using const // due to `ES2015 module syntax is preferred over namespaces` eslint warning export const __test__ = { diff --git a/packages/relay/src/lib/services/workersService/workers.ts b/packages/relay/src/lib/services/workersService/workers.ts index 45af329187..c878af178b 100644 --- a/packages/relay/src/lib/services/workersService/workers.ts +++ b/packages/relay/src/lib/services/workersService/workers.ts @@ -1,7 +1,7 @@ // SPDX-License-Identifier: Apache-2.0 import { RequestDetails } from '../../types'; -import { getBlock, getBlockReceipts } from '../ethService/blockService/blockWorker'; +import { getBlock, getBlockReceipts, getRawReceipts } from '../ethService/blockService/blockWorker'; import { getLogs } from '../ethService/ethCommonService/commonWorker'; interface GetBlockTask { @@ -28,7 +28,13 @@ interface GetLogsTask { requestDetails: RequestDetails; } -type WorkerTask = GetLogsTask | GetBlockTask | GetBlockReceiptsTask; +interface GetRawReceiptsTask { + type: 'getRawReceipts'; + blockHashOrBlockNumber: string; + requestDetails: RequestDetails; +} + +type WorkerTask = GetLogsTask | GetBlockTask | GetBlockReceiptsTask | GetRawReceiptsTask; /** * Main worker export - handles different task types. @@ -40,6 +46,8 @@ export default async function handleTask(task: WorkerTask): Promise { return await getBlock(task.blockHashOrNumber, task.showDetails, task.requestDetails, task.chain); case 'getBlockReceipts': return await getBlockReceipts(task.blockHashOrBlockNumber, task.requestDetails); + case 'getRawReceipts': + return await getRawReceipts(task.blockHashOrBlockNumber, task.requestDetails); case 'getLogs': return await getLogs( task.blockHash, diff --git a/packages/relay/src/lib/types/IReceiptRlpInput.ts b/packages/relay/src/lib/types/IReceiptRlpInput.ts new file mode 100644 index 0000000000..fe1d6c6906 --- /dev/null +++ b/packages/relay/src/lib/types/IReceiptRlpInput.ts @@ -0,0 +1,24 @@ +// SPDX-License-Identifier: Apache-2.0 + +import { Log } from '../model'; + +/** + * Input shape used when building RLP-encoded transaction receipt data. + * + * @property cumulativeGasUsed - Cumulative gas used up to and including this transaction (hex). + * @property logs - Log entries emitted by this transaction. + * @property logsBloom - Bloom filter for logs (hex). + * @property root - Post-state root (legacy pre-byzantium); use empty string when using status. + * @property status - Transaction status: `"0x1"` success, `"0x0"` reverted (hex). + * @property transactionIndex - Index of the transaction in the block (hex), or null. + * @property type - Transaction type (e.g. `"0x0"`, `"0x2"`) or null for legacy. + */ +export interface IReceiptRlpInput { + cumulativeGasUsed: string; + logs: Log[]; + logsBloom: string; + root: string; + status: string; + transactionIndex: string | null; + type: string | null; +} diff --git a/packages/relay/tests/helpers.ts b/packages/relay/tests/helpers.ts index 7240710471..ab75ddab65 100644 --- a/packages/relay/tests/helpers.ts +++ b/packages/relay/tests/helpers.ts @@ -1155,6 +1155,8 @@ export const mockWorkersPool = async (mirrorNodeInstance, commonService, cacheSe return await blockWorker.getBlock(task.blockHashOrNumber, task.showDetails, task.requestDetails, task.chain); case 'getBlockReceipts': return await blockWorker.getBlockReceipts(task.blockHashOrBlockNumber, task.requestDetails); + case 'getRawReceipts': + return await blockWorker.getRawReceipts(task.blockHashOrBlockNumber, task.requestDetails); case 'getLogs': return await commonWorker.getLogs( task.blockHash, diff --git a/packages/relay/tests/lib/debug.spec.ts b/packages/relay/tests/lib/debug.spec.ts index 42c456a5ba..b9c19941ec 100644 --- a/packages/relay/tests/lib/debug.spec.ts +++ b/packages/relay/tests/lib/debug.spec.ts @@ -2087,6 +2087,65 @@ describe('Debug API Test Suite', async function () { }); }); + describe('debug_getRawReceipts', async function () { + beforeEach(() => { + sinon.restore(); + }); + + withOverriddenEnvsInMochaTest({ DEBUG_API_ENABLED: true }, () => { + it('should return an empty array if block not found', async function () { + sinon.stub(debugService['blockService'], 'getRawReceipts').resolves([]); + const result = await debugService.getRawReceipts('0x1234', requestDetails); + expect(result).to.be.an('array').with.lengthOf(0); + }); + + it('should return an empty array if no receipts are found', async function () { + sinon.stub(debugService['blockService'], 'getRawReceipts').resolves([]); + const result = await debugService.getRawReceipts('0x32026E', requestDetails); + expect(result).to.be.an('array').with.lengthOf(0); + }); + + it('should return raw receipts that match pre-captured debug_getRawReceipts payload for block 0x23592c0 from Monad Quicknode RPC', async function () { + const expectedRawReceipts = [ + '0xf901c50180b9010000000000000000000000001000000000000000000000000000000000000000000000000000000000000000000000000400000000000000000000200000000000000000000000000000000000000000002000000000008000000000000000000000000020000000000000000000000000000000000000010000000000000000000000000000000000004000000000000000000400000000000000000000000000000000000000000000000000004000000010000000000000000000000000000000200000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000f8bef8bc940000000000000000000000000000000000001000f863a03a420a01486b6b28d6ae89c51f5c3bde3e0e74eecbb646a0c481ccba3aae3754a00000000000000000000000000000000000000000000000000000000000000056a00000000000000000000000006f49a8f621353f12378d0046e7d7e4b9b249dc9eb840000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000002e6', + '0x02f90241018303a980b9010000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000400000000000000000000000000000000000000000000008000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000100000000000000000000000000000000000000000000000000000000000000400000000000000000000000000000000000000000042000000000000000000f90136f89994368ee51e47a594fe1e9908b48228748a30bc7ca4e1a0f36866d965ee70c8632ff558f5cf8d41ee9ca1d0d0bc7700786e57be60747390b8600000000000000000000000000000000000000000000000000000003fb482bae245544800000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000069208301f89994368ee51e47a594fe1e9908b48228748a30bc7ca4e1a0f36866d965ee70c8632ff558f5cf8d41ee9ca1d0d0bc7700786e57be60747390b860000000000000000000000000000000000000000000000000000007a4dfeb5fe242544300000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000069208301', + ]; + + sinon.stub(debugService['blockService'], 'getRawReceipts').resolves(expectedRawReceipts); + const result = await debugService.getRawReceipts('0x23592c0', requestDetails); + + expect(result).to.not.be.null; + expect(result).to.have.lengthOf(2); + expect(result).to.deep.equal(expectedRawReceipts); + }); + + ['earliest', 'latest', 'pending', 'finalized', 'safe'].forEach((blockTag) => { + const expectedRawReceipts = ['0x1234', '0x5678']; + + it(`should return receipts if block tag ${blockTag} is passed`, async function () { + sinon.stub(debugService['blockService'], 'getRawReceipts').resolves(expectedRawReceipts); + const result = await debugService.getRawReceipts(blockTag, requestDetails); + expect(result).to.be.an('array').with.lengthOf(2); + expect(result).to.deep.equal(expectedRawReceipts); + }); + }); + }); + + [undefined, false].forEach((debugApiEnabled) => + withOverriddenEnvsInMochaTest({ DEBUG_API_ENABLED: debugApiEnabled }, () => { + it('should throw UNSUPPORTED_METHOD', async function () { + await RelayAssertions.assertRejection( + predefined.UNSUPPORTED_METHOD, + debugService.getRawReceipts, + true, + debugService, + ['0x1234'], + ); + }); + }), + ); + }); + describe('prestateTracer', async function () { const mockTimestamp = '1696438011.462526383'; const contractId = '0.0.1033'; diff --git a/packages/relay/tests/lib/debug/debug_getRawReceipts.spec.ts b/packages/relay/tests/lib/debug/debug_getRawReceipts.spec.ts new file mode 100644 index 0000000000..427161a43a --- /dev/null +++ b/packages/relay/tests/lib/debug/debug_getRawReceipts.spec.ts @@ -0,0 +1,134 @@ +// SPDX-License-Identifier: Apache-2.0 + +import { ConfigService } from '@hashgraph/json-rpc-config-service/dist/services'; +import { expect } from 'chai'; +import * as sinon from 'sinon'; + +import { DebugImpl } from '../../../src/lib/debug'; +import { RequestDetails } from '../../../src/lib/types'; +import { mockWorkersPool } from '../../helpers'; +import { generateEthTestEnv } from '../eth/eth-helpers'; + +describe('debug_getRawReceipts', function () { + this.timeout(10000); + + const { cacheService, mirrorNodeInstance, commonService, logger } = generateEthTestEnv(true); + + const requestDetails = new RequestDetails({ requestId: 'debug_getRawReceipts', ipAddress: '0.0.0.0' }); + + let debugService: DebugImpl; + + // Pre‑captured Quicknode debug_getRawReceipts payload for Monad block 0x23592c0 + const expectedRawReceipts = [ + '0xf901c50180b9010000000000000000000000001000000000000000000000000000000000000000000000000000000000000000000000000400000000000000000000200000000000000000000000000000000000000000002000000000008000000000000000000000000020000000000000000000000000000000000000010000000000000000000000000000000000004000000000000000000400000000000000000000000000000000000000000000000000004000000010000000000000000000000000000000200000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000f8bef8bc940000000000000000000000000000000000001000f863a03a420a01486b6b28d6ae89c51f5c3bde3e0e74eecbb646a0c481ccba3aae3754a00000000000000000000000000000000000000000000000000000000000000056a00000000000000000000000006f49a8f621353f12378d0046e7d7e4b9b249dc9eb840000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000002e6', + '0x02f90241018303a980b9010000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000400000000000000000000000000000000000000000000008000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000100000000000000000000000000000000000000000000000000000000000000400000000000000000000000000000000000000000042000000000000000000f90136f89994368ee51e47a594fe1e9908b48228748a30bc7ca4e1a0f36866d965ee70c8632ff558f5cf8d41ee9ca1d0d0bc7700786e57be60747390b8600000000000000000000000000000000000000000000000000000003fb482bae245544800000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000069208301f89994368ee51e47a594fe1e9908b48228748a30bc7ca4e1a0f36866d965ee70c8632ff558f5cf8d41ee9ca1d0d0bc7700786e57be60747390b860000000000000000000000000000000000000000000000000000007a4dfeb5fe242544300000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000069208301', + ]; + + // Minimal “mirror-node-like” execution data that should lead to the above + // raw receipts when passed through blockWorker.getRawReceipts. + const QUICKNODE_BLOCK_HASH = '0x0aae422316a0c19edb48cbb72e26614202a8b56daefb21c3e9920ede19c7670c'; + const QUICKNODE_BLOCK_NUMBER_HEX = '0x23592c0'; + + const quicknodeBlock = { + count: 2, + timestamp: { + from: '1690000000.000000000', + to: '1690000001.000000000', + }, + }; + + const quicknodeContractResults = [ + { + hash: '0x6ae0bb89e2b7170f243f7e48fe413e46ba26e6d1d24f6bb53746967168474ea3', + gas_used: 0, + bloom: + '0x00000000000000000000001000000000000000000000000000000000000000000000000000000000000000000000000400000000000000000000200000000000000000000000000000000000000000002000000000008000000000000000000000000020000000000000000000000000000000000000010000000000000000000000000000000000004000000000000000000400000000000000000000000000000000000000000000000000004000000010000000000000000000000000000000200000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000', + root: '0x01', + status: '0x1', + transaction_index: 0, + type: 0, + block_gas_used: 0, + result: 'SUCCESS', + }, + { + hash: '0x92f28ebdf21ba386757b8a17292791ec70edce1dd0828f9202ccead768006e6a', + gas_used: parseInt('0x3a980', 16), + bloom: + '0x00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000400000000000000000000000000000000000000000000008000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000100000000000000000000000000000000000000000000000000000000000000400000000000000000000000000000000000000000042000000000000000000', + root: '0x01', + status: '0x1', + transaction_index: 1, + type: 2, + block_gas_used: parseInt('0x3a980', 16), + result: 'SUCCESS', + }, + ]; + + const quicknodeLogs = [ + { + address: '0x0000000000000000000000000000000000001000', + topics: [ + '0x3a420a01486b6b28d6ae89c51f5c3bde3e0e74eecbb646a0c481ccba3aae3754', + '0x0000000000000000000000000000000000000000000000000000000000000056', + '0x0000000000000000000000006f49a8f621353f12378d0046e7d7e4b9b249dc9e', + ], + data: '0x000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000002e6', + blockHash: QUICKNODE_BLOCK_HASH, + blockNumber: QUICKNODE_BLOCK_NUMBER_HEX, + blockTimestamp: '0x69208301', + transactionHash: '0x6ae0bb89e2b7170f243f7e48fe413e46ba26e6d1d24f6bb53746967168474ea3', + transactionIndex: '0x0', + logIndex: '0x0', + removed: false, + }, + { + address: '0x368ee51e47a594fe1e9908b48228748a30bc7ca4', + topics: ['0xf36866d965ee70c8632ff558f5cf8d41ee9ca1d0d0bc7700786e57be60747390'], + data: '0x0000000000000000000000000000000000000000000000000000003fb482bae245544800000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000069208301', + blockHash: QUICKNODE_BLOCK_HASH, + blockNumber: QUICKNODE_BLOCK_NUMBER_HEX, + blockTimestamp: '0x69208301', + transactionHash: '0x92f28ebdf21ba386757b8a17292791ec70edce1dd0828f9202ccead768006e6a', + transactionIndex: '0x1', + logIndex: '0x1', + removed: false, + }, + { + address: '0x368ee51e47a594fe1e9908b48228748a30bc7ca4', + topics: ['0xf36866d965ee70c8632ff558f5cf8d41ee9ca1d0d0bc7700786e57be60747390'], + data: '0x000000000000000000000000000000000000000000000000000007a4dfeb5fe242544300000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000069208301', + blockHash: QUICKNODE_BLOCK_HASH, + blockNumber: QUICKNODE_BLOCK_NUMBER_HEX, + blockTimestamp: '0x69208301', + transactionHash: '0x92f28ebdf21ba386757b8a17292791ec70edce1dd0828f9202ccead768006e6a', + transactionIndex: '0x1', + logIndex: '0x2', + removed: false, + }, + ]; + + before(async function () { + await mockWorkersPool(mirrorNodeInstance, commonService, cacheService); + + debugService = new DebugImpl(mirrorNodeInstance, logger, cacheService, ConfigService.get('CHAIN_ID')); + }); + + beforeEach(function () { + sinon.restore(); + cacheService.clear(); + }); + + it('produces raw receipts matching the pre-captured Quicknode payload for Monad block 0x23592c0', async function () { + sinon.stub(commonService, 'getHistoricalBlockResponse').resolves(quicknodeBlock as any); + + sinon.stub(mirrorNodeInstance, 'getContractResults').resolves(quicknodeContractResults as any[]); + + sinon.stub(commonService, 'getLogsWithParams').resolves(quicknodeLogs as any[]); + + const result = await debugService.getRawReceipts(QUICKNODE_BLOCK_NUMBER_HEX, requestDetails); + + expect(result).to.not.be.null; + expect(result).to.have.lengthOf(2); + expect(result).to.deep.equal(expectedRawReceipts); + }); +}); diff --git a/packages/relay/tests/lib/receiptSerialization.spec.ts b/packages/relay/tests/lib/receiptSerialization.spec.ts new file mode 100644 index 0000000000..1d9f24b035 --- /dev/null +++ b/packages/relay/tests/lib/receiptSerialization.spec.ts @@ -0,0 +1,241 @@ +// SPDX-License-Identifier: Apache-2.0 + +import { RLP } from '@ethereumjs/rlp'; +import { hexToBytes } from '@ethereumjs/util'; +import { expect } from 'chai'; + +import { prepend0x, toHexString } from '../../src/formatters'; +import constants from '../../src/lib/constants'; +import { encodeReceiptToHex } from '../../src/lib/receiptSerialization'; +import type { ITransactionReceipt } from '../../src/lib/types'; + +export type DecodedLog = [Uint8Array, Uint8Array[], Uint8Array]; +export type DecodedReceipt = [Uint8Array, Uint8Array, Uint8Array, DecodedLog[]]; + +function decodeEncodedReceipt(encoded: string) { + const bytes = hexToBytes(encoded as `0x${string}`); + + const isTyped = bytes.length > 0 && (bytes[0] === 0x01 || bytes[0] === 0x02); + const txType = isTyped ? bytes[0] : 0; + const payload = isTyped ? bytes.slice(1) : bytes; + + const decoded = RLP.decode(payload) as DecodedReceipt; + const [rootOrStatus, cumulativeGasUsed, logsBloom, logs] = decoded; + + return { txType, rootOrStatus, cumulativeGasUsed, logsBloom, logs }; +} + +describe('encodeReceiptToHex', () => { + it('encodes a legacy (type 0) receipt with state root (pre-Byzantium style)', () => { + const receipt: ITransactionReceipt = { + blockHash: '0x8af70e7f281dd721a9fa61d9437a5f1b0ca0cb449ef65be98a70b7cbac2ef40e', + blockNumber: '0x1', + from: '0x1111111111111111111111111111111111111111', + to: '0x2222222222222222222222222222222222222222', + cumulativeGasUsed: '0x1234', + gasUsed: '0x1234', + contractAddress: '0x3333333333333333333333333333333333333333', + logs: [], + logsBloom: '0x' + '00'.repeat(256), + root: '0x01', + status: '0x1', + transactionHash: '0xe494b1bb298216f2f6c97b3aa04be60e456c5e8d401e041e6da371c06bcad1d2', + transactionIndex: '0x0', + effectiveGasPrice: '0x1', + type: '0x0', + }; + + const encoded = encodeReceiptToHex(receipt); + const { txType, rootOrStatus, cumulativeGasUsed, logsBloom, logs } = decodeEncodedReceipt(encoded); + + expect(txType).to.equal(0); // no EIP‑2718 prefix + expect(prepend0x(toHexString(rootOrStatus))).to.equal(receipt.root); + expect(BigInt(prepend0x(toHexString(cumulativeGasUsed)))).to.equal(BigInt(receipt.cumulativeGasUsed)); + expect(prepend0x(toHexString(logsBloom))).to.equal(receipt.logsBloom); + expect(logs).to.have.lengthOf(0); + }); + + it('encodes a post-Byzantium success receipt with status=1 and cumulativeGasUsed=0 as RLP-empty (0x80)', () => { + const receipt: ITransactionReceipt = { + blockHash: '0x8af70e7f281dd721a9fa61d9437a5f1b0ca0cb449ef65be98a70b7cbac2ef40e', + blockNumber: '0x1', + from: '0xc37f417fa09933335240fca72dd257bfbde9c275', + to: '0x637a6a8e5a69c087c24983b05261f63f64ed7e9b', + cumulativeGasUsed: '0x0', + gasUsed: '0x0', + contractAddress: '0x3333333333333333333333333333333333333333', + logs: [], + logsBloom: '0x' + '00'.repeat(256), + root: '0x', + status: constants.ONE_HEX, + transactionHash: '0xe494b1bb298216f2f6c97b3aa04be60e456c5e8d401e041e6da371c06bcad1d2', + transactionIndex: '0x0', + effectiveGasPrice: '0x1', + type: '0x0', + }; + + const encoded = encodeReceiptToHex(receipt); + const { txType, rootOrStatus, cumulativeGasUsed } = decodeEncodedReceipt(encoded); + + expect(txType).to.equal(0); + expect(rootOrStatus).to.have.lengthOf(1); + expect(rootOrStatus[0]).to.equal(0x01); + + // cumulativeGasUsed=0 must be encoded as empty string → RLP 0x80. + // After RLP decode we see an empty byte array: + expect(cumulativeGasUsed.length).to.equal(0); + }); + + it('encodes a post-Byzantium receipt cumulativeGasUsed=0x0001', () => { + const receipt: ITransactionReceipt = { + blockHash: '0x8af70e7f281dd721a9fa61d9437a5f1b0ca0cb449ef65be98a70b7cbac2ef40e', + blockNumber: '0x1', + from: '0xc37f417fa09933335240fca72dd257bfbde9c275', + to: '0x637a6a8e5a69c087c24983b05261f63f64ed7e9b', + cumulativeGasUsed: '0x0001', + gasUsed: '0x0', + contractAddress: '0x3333333333333333333333333333333333333333', + logs: [], + logsBloom: '0x' + '00'.repeat(256), + root: '0x', + status: constants.ONE_HEX, + transactionHash: '0xe494b1bb298216f2f6c97b3aa04be60e456c5e8d401e041e6da371c06bcad1d2', + transactionIndex: '0x0', + effectiveGasPrice: '0x1', + type: '0x0', + }; + + const encoded = encodeReceiptToHex(receipt); + const { cumulativeGasUsed } = decodeEncodedReceipt(encoded); + + const decodedHex = prepend0x(toHexString(cumulativeGasUsed)); + expect(BigInt(decodedHex)).to.equal(BigInt(receipt.cumulativeGasUsed)); + expect(cumulativeGasUsed.length).to.be.greaterThan(0); + expect(cumulativeGasUsed[0]).to.not.equal(0x00); + expect(BigInt(decodedHex)).to.equal(1n); + }); + + it('encodes logs as [address, topics[], data] per Yellow Paper', () => { + const receipt = { + blockHash: '0x8af70e7f281dd721a9fa61d9437a5f1b0ca0cb449ef65be98a70b7cbac2ef40e', + blockNumber: '0x1', + from: '0xc37f417fa09933335240fca72dd257bfbde9c275', + to: '0x637a6a8e5a69c087c24983b05261f63f64ed7e9b', + cumulativeGasUsed: '0x10', + logsBloom: '0x' + '00'.repeat(256), + root: '0x', + status: constants.ONE_HEX, + effectiveGasPrice: '0x1', + type: '0x0', + logs: [ + { + address: '0x0000000000000000000000000000000000001000', + topics: [ + '0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa', + '0xbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb', + ], + data: '0x000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000002e6', + blockHash: '0x8af70e7f281dd721a9fa61d9437a5f1b0ca0cb449ef65be98a70b7cbac2ef40e', + blockNumber: '0x1', + blockTimestamp: '0x1', + transactionHash: '0xe494b1bb298216f2f6c97b3aa04be60e456c5e8d401e041e6da371c06bcad1d2', + transactionIndex: '0x0', + logIndex: '0x0', + removed: false, + }, + ], + } as ITransactionReceipt; + + const encoded = encodeReceiptToHex(receipt); + const { logs } = decodeEncodedReceipt(encoded); + + expect(logs).to.have.lengthOf(1); + const [addr, topics, data] = logs[0]; + + expect(prepend0x(toHexString(addr))).to.equal(receipt.logs[0].address); + expect(topics).to.have.lengthOf(receipt.logs[0].topics.length); + receipt.logs[0].topics.forEach((t, i) => { + expect(prepend0x(toHexString(topics[i]))).to.equal(t); + }); + expect(prepend0x(toHexString(data))).to.equal(receipt.logs[0].data); + }); + + it('adds EIP-2718 type byte for typed (e.g. type 0x2) receipts', () => { + const receipt: ITransactionReceipt = { + blockHash: '0x8af70e7f281dd721a9fa61d9437a5f1b0ca0cb449ef65be98a70b7cbac2ef40e', + blockNumber: '0x1', + from: '0xc37f417fa09933335240fca72dd257bfbde9c275', + to: '0x637a6a8e5a69c087c24983b05261f63f64ed7e9b', + cumulativeGasUsed: '0x10', + gasUsed: '0x10', + contractAddress: '0x3333333333333333333333333333333333333333', + logs: [], + logsBloom: '0x' + '00'.repeat(256), + root: '0x', + status: constants.ONE_HEX, + transactionHash: '0xe494b1bb298216f2f6c97b3aa04be60e456c5e8d401e041e6da371c06bcad1d2', + transactionIndex: '0x0', + effectiveGasPrice: '0x1', + type: '0x2', + }; + + const encoded = encodeReceiptToHex(receipt); + const { txType, rootOrStatus } = decodeEncodedReceipt(encoded); + + expect(txType).to.equal(0x02); + expect(rootOrStatus).to.have.lengthOf(1); + expect(rootOrStatus[0]).to.equal(0x01); + }); + + it('encodes a failed receipt (status=0) with empty first field', () => { + const receipt: ITransactionReceipt = { + blockHash: '0x8af70e7f281dd721a9fa61d9437a5f1b0ca0cb449ef65be98a70b7cbac2ef40e', + blockNumber: '0x1', + from: '0xc37f417fa09933335240fca72dd257bfbde9c275', + to: '0x637a6a8e5a69c087c24983b05261f63f64ed7e9b', + cumulativeGasUsed: '0x1234', + gasUsed: '0x1234', + contractAddress: '0x3333333333333333333333333333333333333333', + logs: [], + logsBloom: '0x' + '00'.repeat(256), + root: '0x', + status: '0x0', + transactionHash: '0xe494b1bb298216f2f6c97b3aa04be60e456c5e8d401e041e6da371c06bcad1d2', + transactionIndex: '0x0', + effectiveGasPrice: '0x1', + type: '0x0', + }; + + const encoded = encodeReceiptToHex(receipt); + const { rootOrStatus } = decodeEncodedReceipt(encoded); + + // status=0 → first field is encoded as empty string (RLP 0x80), so decode gives empty bytes + expect(rootOrStatus.length).to.equal(0); + }); + + it('does not add a type prefix when receipt.type is null', () => { + const receipt: ITransactionReceipt = { + blockHash: '0x8af70e7f281dd721a9fa61d9437a5f1b0ca0cb449ef65be98a70b7cbac2ef40e', + blockNumber: '0x1', + from: '0xc37f417fa09933335240fca72dd257bfbde9c275', + to: '0x637a6a8e5a69c087c24983b05261f63f64ed7e9b', + cumulativeGasUsed: '0x10', + gasUsed: '0x10', + contractAddress: '0x3333333333333333333333333333333333333333', + logs: [], + logsBloom: '0x' + '00'.repeat(256), + root: '0x', + status: constants.ONE_HEX, + transactionHash: '0xe494b1bb298216f2f6c97b3aa04be60e456c5e8d401e041e6da371c06bcad1d2', + transactionIndex: '0x0', + effectiveGasPrice: '0x1', + type: null, + }; + + const encoded = encodeReceiptToHex(receipt); + const { txType } = decodeEncodedReceipt(encoded); + + // No EIP‑2718 prefix should be added + expect(txType).to.equal(0); + }); +}); diff --git a/packages/server/tests/acceptance/data/conformity/overwrites/debug_getRawReceipts/get-block-n.io b/packages/server/tests/acceptance/data/conformity/overwrites/debug_getRawReceipts/get-block-n.io new file mode 100644 index 0000000000..f3a29255d0 --- /dev/null +++ b/packages/server/tests/acceptance/data/conformity/overwrites/debug_getRawReceipts/get-block-n.io @@ -0,0 +1,13 @@ +// Reason for override: This test uses data included in the chain.rlp +// https://github.com/ethereum/execution-apis/blob/main/tests/chain.rlp +// +// Note: This is the original test file, modified for our test purposes: +// https://github.com/ethereum/execution-apis/blob/main/tests/debug_getRawReceipts/get-block-n.io +// +// In the wildcard collection, there are fields that depend on the current state of the network, +// which changes with each test run. + +## wildcard: result + +>> {"jsonrpc":"2.0","id":1,"method":"debug_getRawReceipts","params":["0x3"]} +<< {"jsonrpc":"2.0","id":1,"result":["0x01","0x02","0x03"]} \ No newline at end of file diff --git a/packages/server/tests/acceptance/debug.spec.ts b/packages/server/tests/acceptance/debug.spec.ts index 89cb6ab91a..8d8973f372 100644 --- a/packages/server/tests/acceptance/debug.spec.ts +++ b/packages/server/tests/acceptance/debug.spec.ts @@ -2,10 +2,13 @@ // External resources import { RLP } from '@ethereumjs/rlp'; +import { hexToBytes } from '@ethereumjs/util'; import { ConfigService } from '@hashgraph/json-rpc-config-service/dist/services'; import { predefined } from '@hashgraph/json-rpc-relay'; -import { numberTo0x, prepend0x, strip0x } from '@hashgraph/json-rpc-relay/src/formatters'; +import { numberTo0x, prepend0x, strip0x, toHexString } from '@hashgraph/json-rpc-relay/src/formatters'; import constants, { TracerType } from '@hashgraph/json-rpc-relay/src/lib/constants'; +import { ITransactionReceipt } from '@hashgraph/json-rpc-relay/src/lib/types'; +import { BLOCK_NUMBER_ERROR, HASH_ERROR } from '@hashgraph/json-rpc-relay/src/lib/validators/constants'; import { TransferTransaction } from '@hashgraph/sdk'; import chai, { expect } from 'chai'; import chaiExclude from 'chai-exclude'; @@ -65,6 +68,7 @@ describe('@debug API Acceptance Tests', function () { const DEBUG_TRACE_BLOCK_BY_HASH = 'debug_traceBlockByHash'; const DEBUG_TRACE_TRANSACTION = 'debug_traceTransaction'; const DEBUG_GET_RAW_BLOCK = 'debug_getRawBlock'; + const DEBUG_GET_RAW_RECEIPTS = 'debug_getRawReceipts'; const TRACER_CONFIGS = { CALL_TRACER_TOP_ONLY_FALSE: { tracer: TracerType.CallTracer, tracerConfig: { onlyTopCall: false } }, @@ -1326,4 +1330,99 @@ describe('@debug API Acceptance Tests', function () { }); }); }); + + describe('debug_getRawReceipts', () => { + it('should return EIP-2718 binary-encoded receipts for a block with a transaction', async function () { + const transaction = await Utils.buildTransaction( + relay, + basicContractAddress, + accounts[0].address, + BASIC_CONTRACT_PING_CALL_DATA, + ); + const receipt = await Utils.getReceipt(relay, transaction, accounts[0].wallet); + + const blockNumber = receipt.blockNumber; + + const result = await relay.call(DEBUG_GET_RAW_RECEIPTS, [blockNumber]); + + expect(result).to.be.an('array'); + expect(result.length).to.be.at.least(1); + + // Each element should be a hex-encoded receipt + result.forEach((raw: unknown, index: number) => { + expect(raw, `receipt at index ${index}`).to.be.a('string'); + expect(raw as string, `receipt at index ${index} should be 0x-prefixed hex`).to.match(/^0x[0-9a-f]+$/i); + }); + + // Find the receipt for the transaction and assert it decodes to match the expected receipt + expect(result as string[]).to.satisfy( + (arr: string[]) => + arr.some((raw) => { + try { + assertRawReceiptDecodesToMatchReceipt(raw, receipt); + return true; + } catch { + return false; + } + }), + 'block should contain raw receipt for test tx', + ); + }); + + it('should fail with INVALID_PARAMETER when given an invalid block number', async function () { + await relay.callFailing( + DEBUG_GET_RAW_RECEIPTS, + ['invalidBlockNumber'], + predefined.INVALID_PARAMETER( + '0', + `The value passed is not valid: invalidBlockNumber. ${BLOCK_NUMBER_ERROR} OR Expected ${HASH_ERROR} of a block`, + ), + ); + }); + + /** + * Asserts that a raw EIP-2718 receipt hex string decodes (RLP) to the 4-field receipt + * and that the decoded fields match the expected receipt (Yellow Paper structure). + */ + function assertRawReceiptDecodesToMatchReceipt(encodedHex: string, expectedReceipt: ITransactionReceipt): void { + expect(encodedHex) + .to.be.a('string') + .that.matches(/^0x[0-9a-f]+$/i); + + const bytes = hexToBytes(encodedHex as `0x${string}`); + const isTyped = bytes.length > 0 && (bytes[0] === 0x01 || bytes[0] === 0x02); + const payload = isTyped ? bytes.slice(1) : bytes; + + const decoded = RLP.decode(payload); + // 4-field receipt structure + expect(decoded).to.be.an('array').with.lengthOf(4); + + // Field 0: root/status + const decodedRootHex = prepend0x(toHexString(decoded[0] as Uint8Array)); + expect(decodedRootHex).to.equal(expectedReceipt.root); + + // Field 1: cumulativeGasUsed (compare numerically to handle any leading-zero differences) + const decodedCumulativeGasUsedHex = prepend0x(toHexString(decoded[1] as Uint8Array)); + expect(BigInt(decodedCumulativeGasUsedHex)).to.equal(BigInt(expectedReceipt.cumulativeGasUsed)); + + // Field 2: logsBloom + const decodedLogsBloomHex = prepend0x(toHexString(decoded[2] as Uint8Array)); + expect(decodedLogsBloomHex).to.equal(expectedReceipt.logsBloom); + + // Field 3: logs + expect(decoded[3]).to.be.an('array').with.lengthOf(expectedReceipt.logs.length); + expectedReceipt.logs.forEach((log, i) => { + const [addr, topics, data] = decoded[3][i]; + const decodedAddrHex = prepend0x(toHexString(addr as Uint8Array)); + expect(decodedAddrHex).to.equal(log.address); + expect(topics).to.be.an('array').with.lengthOf(log.topics.length); + log.topics.forEach((topic, j) => { + const decodedTopicHex = prepend0x(toHexString(topics[j] as Uint8Array)); + expect(decodedTopicHex).to.equal(topic); + }); + const decodedDataHex = prepend0x(toHexString(data as Uint8Array)); + expect(decodedDataHex).to.equal(log.data); + }); + } + }); }); diff --git a/scripts/openrpc-json-updater/config.js b/scripts/openrpc-json-updater/config.js index 6f685a9ae4..6eea13ce27 100644 --- a/scripts/openrpc-json-updater/config.js +++ b/scripts/openrpc-json-updater/config.js @@ -29,7 +29,6 @@ export const UNSUPPORTED_METHODS = [ export const NOT_IMPLEMENTED_METHODS = [ 'debug_getRawHeader', - 'debug_getRawReceipts', 'debug_getRawTransaction', ];