diff --git a/packages/relay/src/lib/factories/transactionReceiptFactory.ts b/packages/relay/src/lib/factories/transactionReceiptFactory.ts index c7d3eaa65a..33e66272c9 100644 --- a/packages/relay/src/lib/factories/transactionReceiptFactory.ts +++ b/packages/relay/src/lib/factories/transactionReceiptFactory.ts @@ -30,7 +30,7 @@ interface IRegularTransactionReceiptParams { from: string; logs: Log[]; receiptResponse: any; - to: string; + to: string | null; } /** @@ -68,16 +68,44 @@ class TransactionReceiptFactory { /** * Creates a regular transaction receipt from mirror node contract result data * + * Handles the correction of transaction receipt `to` field for contract creation transactions. + * + * This logic addresses a discrepancy between Hedera and standard Ethereum behavior regarding + * the `to` field in transaction receipts. When a smart contract is deployed: + * + * 1. In standard Ethereum JSON-RPC, if the original transaction had a null `to` field + * (contract creation), the transaction receipt also reports a null `to` field. + * + * 2. Hedera Mirror Node, however, automatically populates the `to` field with the + * address of the newly created contract. + * + * The code checks if a contract was directly created by the transaction (rather than created by + * another contract) by checking if the contract's ID appears in the `created_contract_ids` array. + * If so, it resets the `to` field to null to match standard Ethereum JSON-RPC behavior. + * + * This ensures compatibility with Ethereum tooling that expects standard transaction receipt formats. + * The handling covers various scenarios: + * + * - Direct contract deployment (empty `to` field) + * - Contract creation via factory contracts + * - Method calls that don't create contracts + * - Transactions with populated `to` fields that create child contracts + * * @param params Parameters required to create a regular transaction receipt * @param resolveEvmAddressFn Function to resolve EVM addresses * @returns {ITransactionReceipt} Transaction receipt for the regular transaction */ public static createRegularReceipt(params: IRegularTransactionReceiptParams): ITransactionReceipt { - const { receiptResponse, effectiveGas, from, logs, to } = params; + const { receiptResponse, effectiveGas, from, logs } = params; + let { to } = params; // Determine contract address if it exists const contractAddress = TransactionReceiptFactory.getContractAddressFromReceipt(receiptResponse); + if (receiptResponse.created_contract_ids.includes(receiptResponse.contract_id)) { + to = null; + } + // Create the receipt object const receipt: ITransactionReceipt = { blockHash: toHash32(receiptResponse.block_hash), diff --git a/packages/relay/src/lib/services/ethService/blockService/BlockService.ts b/packages/relay/src/lib/services/ethService/blockService/BlockService.ts index e2f1d6cd67..d97b20840e 100644 --- a/packages/relay/src/lib/services/ethService/blockService/BlockService.ts +++ b/packages/relay/src/lib/services/ethService/blockService/BlockService.ts @@ -168,11 +168,13 @@ export class BlockService implements IBlockService { } return null; } + contractResult.logs = logsByHash.get(contractResult.hash) || []; const [from, to] = await Promise.all([ this.common.resolveEvmAddress(contractResult.from, requestDetails), - this.common.resolveEvmAddress(contractResult.to, requestDetails), + contractResult.to === null ? null : this.common.resolveEvmAddress(contractResult.to, requestDetails), ]); + const transactionReceiptParams: IRegularTransactionReceiptParams = { effectiveGas, from, diff --git a/packages/relay/src/lib/services/ethService/transactionService/ITransactionService.ts b/packages/relay/src/lib/services/ethService/transactionService/ITransactionService.ts index 72d4a8a33f..53ee72af89 100644 --- a/packages/relay/src/lib/services/ethService/transactionService/ITransactionService.ts +++ b/packages/relay/src/lib/services/ethService/transactionService/ITransactionService.ts @@ -2,7 +2,7 @@ import { JsonRpcError } from '../../../errors/JsonRpcError'; import { Transaction } from '../../../model'; -import { RequestDetails } from '../../../types'; +import { ITransactionReceipt, RequestDetails } from '../../../types'; export interface ITransactionService { getTransactionByBlockHashAndIndex( @@ -19,7 +19,7 @@ export interface ITransactionService { getTransactionByHash(hash: string, requestDetails: RequestDetails): Promise; - getTransactionReceipt(hash: string, requestDetails: RequestDetails): Promise; + getTransactionReceipt(hash: string, requestDetails: RequestDetails): Promise; sendRawTransaction(transaction: string, requestDetails: RequestDetails): Promise; diff --git a/packages/relay/src/lib/services/ethService/transactionService/TransactionService.ts b/packages/relay/src/lib/services/ethService/transactionService/TransactionService.ts index 643c2e76a8..d81d32fb9e 100644 --- a/packages/relay/src/lib/services/ethService/transactionService/TransactionService.ts +++ b/packages/relay/src/lib/services/ethService/transactionService/TransactionService.ts @@ -432,6 +432,7 @@ export class TransactionService implements ITransactionService { this.common.resolveEvmAddress(receiptResponse.from, requestDetails), this.common.resolveEvmAddress(receiptResponse.to, requestDetails), ]); + const transactionReceiptParams: IRegularTransactionReceiptParams = { effectiveGas, from, diff --git a/packages/relay/src/lib/types/ITransactionReceipt.ts b/packages/relay/src/lib/types/ITransactionReceipt.ts index 90808b48b9..48ff299efd 100644 --- a/packages/relay/src/lib/types/ITransactionReceipt.ts +++ b/packages/relay/src/lib/types/ITransactionReceipt.ts @@ -14,7 +14,7 @@ export interface ITransactionReceipt { logsBloom: string; root: string; status: string; - to: string; + to: string | null; transactionHash: string; transactionIndex: string | null; type: string | null; diff --git a/packages/relay/tests/lib/eth/eth_getBlockReceipts.spec.ts b/packages/relay/tests/lib/eth/eth_getBlockReceipts.spec.ts index 0f90ff6514..b3b0a90c00 100644 --- a/packages/relay/tests/lib/eth/eth_getBlockReceipts.spec.ts +++ b/packages/relay/tests/lib/eth/eth_getBlockReceipts.spec.ts @@ -214,6 +214,108 @@ describe('@ethGetBlockReceipts using MirrorNode', async function () { expect(receipts[1].transactionHash).to.equal(defaultLogs1[0].transaction_hash); expect(receipts[1].transactionHash).to.equal(defaultLogs1[1].transaction_hash); }); + + it('should handle null to field for contract creation transactions', async function () { + const contractCreationResults = { + results: [ + { + ...results[0], + to: null, + created_contract_ids: ['0.0.1234'], + contract_id: '0.0.1234', + address: '0xnewlyCreatedContractAddress', + }, + ], + links: { next: null }, + }; + + setupStandardResponses({ + [CONTRACT_RESULTS_WITH_FILTER_URL_2]: contractCreationResults, + }); + + const resolveEvmAddressStub = sinon.stub(ethImpl['common'], 'resolveEvmAddress'); + resolveEvmAddressStub.withArgs(results[0].from, sinon.match.any).resolves('0xresolvedFromAddress'); + + const receipts = await ethImpl.getBlockReceipts(BLOCK_HASH, requestDetails); + + expect(receipts).to.exist; + expect(receipts.length).to.equal(1); + expect(receipts[0].from).to.equal('0xresolvedFromAddress'); + expect(receipts[0].to).to.equal(null); + expect(receipts[0].contractAddress).to.not.equal(null); + + expect(resolveEvmAddressStub.calledWith(undefined, sinon.match.any)).to.be.false; + + resolveEvmAddressStub.restore(); + }); + + it('should set to field to null when contract is in created_contract_ids', async function () { + const contractId = '0.0.1234'; + const contractCreationResults = { + results: [ + { + ...results[0], + to: '0xoriginalToAddress', + created_contract_ids: [contractId], + contract_id: contractId, + }, + ], + links: { next: null }, + }; + + setupStandardResponses({ + [CONTRACT_RESULTS_WITH_FILTER_URL_2]: contractCreationResults, + }); + + const resolveEvmAddressStub = sinon.stub(ethImpl['common'], 'resolveEvmAddress'); + resolveEvmAddressStub.withArgs(results[0].from, sinon.match.any).resolves('0xresolvedFromAddress'); + resolveEvmAddressStub.withArgs(undefined, sinon.match.any).resolves(null); + + const receipts = await ethImpl.getBlockReceipts(BLOCK_HASH, requestDetails); + + expect(receipts).to.exist; + expect(receipts.length).to.equal(1); + expect(receipts[0].from).to.equal('0xresolvedFromAddress'); + expect(receipts[0].to).to.equal(null); + + resolveEvmAddressStub.restore(); + }); + + it('should keep original to field when contract is not in created_contract_ids', async function () { + const contractId = '0.0.1234'; + const differentContractId = '0.0.5678'; + const originalToAddress = '0xoriginalToAddress'; + const resolvedToAddress = '0xresolvedToAddress'; + + const contractResults = { + results: [ + { + ...results[0], + to: originalToAddress, + created_contract_ids: [differentContractId], + contract_id: contractId, + }, + ], + links: { next: null }, + }; + + setupStandardResponses({ + [CONTRACT_RESULTS_WITH_FILTER_URL_2]: contractResults, + }); + + const resolveEvmAddressStub = sinon.stub(ethImpl['common'], 'resolveEvmAddress'); + resolveEvmAddressStub.withArgs(results[0].from, sinon.match.any).resolves('0xresolvedFromAddress'); + resolveEvmAddressStub.withArgs(originalToAddress, sinon.match.any).resolves(resolvedToAddress); + + const receipts = await ethImpl.getBlockReceipts(BLOCK_HASH, requestDetails); + + expect(receipts).to.exist; + expect(receipts.length).to.equal(1); + expect(receipts[0].from).to.equal('0xresolvedFromAddress'); + expect(receipts[0].to).to.equal(resolvedToAddress); + + resolveEvmAddressStub.restore(); + }); }); describe('Error cases', () => { diff --git a/packages/relay/tests/lib/eth/eth_getTransactionReceipt.spec.ts b/packages/relay/tests/lib/eth/eth_getTransactionReceipt.spec.ts index 4ab8c4c9ce..a32cc7a8a1 100644 --- a/packages/relay/tests/lib/eth/eth_getTransactionReceipt.spec.ts +++ b/packages/relay/tests/lib/eth/eth_getTransactionReceipt.spec.ts @@ -315,8 +315,10 @@ describe('@ethGetTransactionReceipt eth_getTransactionReceipt tests', async func } }); - it('valid receipt on cache match', async function() { - const cacheKey = `${constants.CACHE_KEY.ETH_GET_TRANSACTION_RECEIPT.replace('eth_', '')}_${defaultDetailedContractResultByHash.hash}`; + it('valid receipt on cache match', async function () { + const cacheKey = `${constants.CACHE_KEY.ETH_GET_TRANSACTION_RECEIPT.replace('eth_', '')}_${ + defaultDetailedContractResultByHash.hash + }`; const cacheReceipt = { blockHash: defaultDetailedContractResultByHash.block_hash, blockNumber: defaultDetailedContractResultByHash.block_number, @@ -352,4 +354,21 @@ describe('@ethGetTransactionReceipt eth_getTransactionReceipt tests', async func expect(receipt.transactionHash).to.eq(cacheReceipt.transactionHash); expect(receipt.transactionIndex).to.eq(cacheReceipt.transactionIndex); }); + + it('should handle receipt with null "to" field', async function () { + const contractResultWithNullTo = { + ...defaultDetailedContractResultByHash, + to: null, + }; + + const uniqueTxHash = '0x17cad7b827375d12d73af57b6a3e84353645fd31305ea58ff52dda53ec640533'; + + restMock.onGet(`contracts/results/${uniqueTxHash}`).reply(200, JSON.stringify(contractResultWithNullTo)); + restMock.onGet(`contracts/${defaultDetailedContractResultByHash.created_contract_ids[0]}`).reply(404); + stubBlockAndFeesFunc(sandbox); + + const receipt = await ethImpl.getTransactionReceipt(uniqueTxHash, requestDetails); + expect(receipt).to.exist; + expect(receipt?.to).to.be.null; + }); }); diff --git a/packages/server/tests/acceptance/rpc_batch1.spec.ts b/packages/server/tests/acceptance/rpc_batch1.spec.ts index 97ebd233d5..13fd49e415 100644 --- a/packages/server/tests/acceptance/rpc_batch1.spec.ts +++ b/packages/server/tests/acceptance/rpc_batch1.spec.ts @@ -27,6 +27,7 @@ import RelayCalls from '../../tests/helpers/constants'; import MirrorClient from '../clients/mirrorClient'; import RelayClient from '../clients/relayClient'; import ServicesClient from '../clients/servicesClient'; +import basicContractJson from '../contracts/Basic.json'; import logsContractJson from '../contracts/Logs.json'; // Local resources from contracts directory import parentContractJson from '../contracts/Parent.json'; @@ -822,6 +823,41 @@ describe('@api-batch-1 RPC Server Acceptance Tests', function () { ]); }); + it('should execute "eth_getBlockReceipts" with contract deployment transaction showing null to field', async function () { + const contractDeployment = await Utils.deployContract( + basicContractJson.abi, + basicContractJson.bytecode, + accounts[0].wallet, + ); + const basicContractTx = contractDeployment.deploymentTransaction(); + if (!basicContractTx) { + throw new Error('Deployment transaction is null'); + } + const receipt = await relay.pollForValidTransactionReceipt(basicContractTx.hash); + + const deploymentBlock = await relay.call( + RelayCalls.ETH_ENDPOINTS.ETH_GET_BLOCK_BY_HASH, + [receipt.blockHash, false], + requestIdPrefix, + ); + + const res = await relay.call( + RelayCalls.ETH_ENDPOINTS.ETH_GET_BLOCK_RECEIPTS, + [deploymentBlock.hash], + requestIdPrefix, + ); + + const deploymentReceiptInBlock = res.find((receipt) => receipt.transactionHash === basicContractTx.hash); + + expect(deploymentReceiptInBlock).to.exist; + expect(deploymentReceiptInBlock).to.have.property('to'); + expect(deploymentReceiptInBlock.to).to.be.null; + expect(deploymentReceiptInBlock.contractAddress).to.not.be.null; + expect(deploymentReceiptInBlock.contractAddress.toLowerCase()).to.equal( + contractDeployment.target.toString().toLowerCase(), + ); + }); + it('should return null for "eth_getBlockReceipts" when block is not found', async function () { const res = await relay.call( RelayCalls.ETH_ENDPOINTS.ETH_GET_BLOCK_RECEIPTS, @@ -1087,6 +1123,30 @@ describe('@api-batch-1 RPC Server Acceptance Tests', function () { expect(res).to.be.null; }); + it('should execute "eth_getTransactionReceipt" and set "to" field to null for direct contract deployment', async function () { + const basicContract = await Utils.deployContract( + basicContractJson.abi, + basicContractJson.bytecode, + accounts[0].wallet, + ); + + const contractDeploymentTx = basicContract.deploymentTransaction(); + if (!contractDeploymentTx) { + throw new Error('Deployment transaction is null'); + } + await relay.pollForValidTransactionReceipt(contractDeploymentTx.hash); + + const contractDeploymentReceipt = await relay.call( + RelayCalls.ETH_ENDPOINTS.ETH_GET_TRANSACTION_RECEIPT, + [contractDeploymentTx.hash], + requestIdPrefix, + ); + + expect(contractDeploymentReceipt).to.exist; + expect(contractDeploymentReceipt.contractAddress).to.not.be.null; + expect(contractDeploymentReceipt.to).to.be.null; + }); + it('should fail "eth_sendRawTransaction" for transaction with incorrect chain_id', async function () { const transaction = { ...default155TransactionData,