Skip to content

feat: fix contract creation tx receipt compatibility with Ethereum spec #3825

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 14 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
32 changes: 30 additions & 2 deletions packages/relay/src/lib/factories/transactionReceiptFactory.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@ interface IRegularTransactionReceiptParams {
from: string;
logs: Log[];
receiptResponse: any;
to: string;
to: string | null;
}

/**
Expand Down Expand Up @@ -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),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand All @@ -19,7 +19,7 @@ export interface ITransactionService {

getTransactionByHash(hash: string, requestDetails: RequestDetails): Promise<Transaction | null>;

getTransactionReceipt(hash: string, requestDetails: RequestDetails): Promise<any>;
getTransactionReceipt(hash: string, requestDetails: RequestDetails): Promise<ITransactionReceipt | null>;

sendRawTransaction(transaction: string, requestDetails: RequestDetails): Promise<string | JsonRpcError>;

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
2 changes: 1 addition & 1 deletion packages/relay/src/lib/types/ITransactionReceipt.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
102 changes: 102 additions & 0 deletions packages/relay/tests/lib/eth/eth_getBlockReceipts.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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', () => {
Expand Down
23 changes: 21 additions & 2 deletions packages/relay/tests/lib/eth/eth_getTransactionReceipt.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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;
});
});
60 changes: 60 additions & 0 deletions packages/server/tests/acceptance/rpc_batch1.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand Down