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 9 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 @@ -175,11 +175,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 @@ -443,6 +443,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;
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -34,5 +34,9 @@
"eth_mining": {
"request": "{\"id\":\"test_id\",\"jsonrpc\":\"2.0\",\"method\":\"eth_mining\",\"params\":[]}",
"response": "{\"result\":false,\"jsonrpc\":\"2.0\",\"id\":\"test_id\"}"
},
"eth_getBlockReceipts": {
"request": "{\"id\":\"test_id\",\"jsonrpc\":\"2.0\",\"method\":\"eth_getBlockReceipts\",\"params\":[\"0x20718245\"]}",
"response": "{\"result\":null,\"jsonrpc\":\"2.0\",\"id\":\"test_id\"}"
}
}
34 changes: 34 additions & 0 deletions packages/server/tests/acceptance/rpc_batch1.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -821,6 +821,40 @@ describe('@api-batch-1 RPC Server Acceptance Tests', function () {
['0x', requestIdPrefix],
]);
});

it('should execute "eth_getBlockReceipts" with contract deployment transaction showing null to field', async function () {
const contractDeployment = await Utils.deployContract(
basicContract.abi,
basicContract.bytecode,
accounts[0].wallet,
);
const deploymentTransaction = contractDeployment.deploymentTransaction();
if (!deploymentTransaction) {
throw new Error('Deployment transaction is null');
}

const deploymentReceipt = await relay.pollForValidTransactionReceipt(deploymentTransaction.hash);

const deploymentBlock = await relay.call(
RelayCalls.ETH_ENDPOINTS.ETH_GET_BLOCK_BY_HASH,
[deploymentReceipt.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 === deploymentTransaction.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).to.equal(contractDeployment.target);
});
});

describe('Transaction related RPC Calls', () => {
Expand Down
Loading