Logo
New RPC users get 35% off their first monthView the offer
OnFinality Learn
Network & Protocol Guides13 min read

Ethereum Transaction Object vs Receipt: Resolving a Hash

Learn how the signed transaction object differs from the execution receipt, and how to resolve any hash into not found, pending, mined, reverted, or replaced with raw JSON-RPC.

TL;DR

A transaction object is the signed input to the EVM: sender, recipient, value, nonce, gas parameters, calldata and signature. A receipt is the output record produced by executing that transaction, and the receipt is what the block's receiptsRoot commits to. The asymmetry that matters in practice is that eth_getTransactionByHash can return a transaction before it is mined, with blockNumber, blockHash and transactionIndex set to null, while eth_getTransactionReceipt keeps returning null until the transaction is included in a block. That pair of results is enough to classify a hash as pending, mined, reverted or replaced, but a null from both calls is ambiguous and must be disambiguated with chain height, the sender's pending nonce or a second endpoint. Because reorgs can make a receipt disappear, reconciliation should be keyed on blockHash as well as transactionHash.

The Transaction Object and the Receipt as Two Different Records

A transaction object is what a key signed and what someone broadcast. Every field in it exists because the protocol needs it to verify the signature or to execute the payload: from (recovered from the signature, not transmitted), to (null for contract creation), value, nonce, gas, the fee fields (gasPrice on legacy transactions, maxFeePerGas and maxPriorityFeePerGas on type-2 transactions), the calldata in input with data kept as a legacy alias, type, and the three signature components v, r and s. The Ethereum Execution APIs specification for eth_getTransactionByHash defines this return object, and the ethereum.org JSON-RPC documentation describes the same shape in its method reference.

The critical property of the transaction object is that it describes intent, not outcome. It says what should happen and who authorised it. It does not say whether execution succeeded, how much gas was actually consumed, what events were emitted, or what address a deployment created. Those are all execution results, and they live in a different object that is only produced once a block includes the transaction. An application that treats a returned transaction object as proof of success is reading the wrong half of the protocol.

That other object is the receipt. It carries status (0x1 success or 0x0 reverted), cumulativeGasUsed (gas used by all transactions up to and including this one in the block), gasUsed (gas used by this transaction alone), effectiveGasPrice, logs and logsBloom, contractAddress (non-null only for successful deployments), type, and the locators transactionHash, blockHash, blockNumber and transactionIndex. The Execution APIs entry for eth_getTransactionReceipt lists the fields and their encodings.

The block header commits to the receipts of all its transactions through receiptsRoot. That is the structural reason the receipt dominates reconciliation design: the block hash anchors the exact receipt content, so a receipt fetched together with a block hash can be verified later, while a bare transaction object cannot. It also explains why a reverted transaction still has a receipt and still consumed gas. The execution happened, its effects were rolled back, and the protocol still recorded the attempt at a price.

Field-by-Field Mapping Between Transaction and Receipt

The two objects overlap on only a handful of fields. Everything else belongs to exactly one of them, which is a useful shortcut when you are deciding which call to make for a given question.

Shared between both: hash/transactionHash (same 32-byte value), blockNumber, blockHash, transactionIndex and type. Present only on the transaction: from, to, value, nonce, gas (the limit), gasPrice, maxFeePerGas, maxPriorityFeePerGas, input, data, accessList, v, r, s and chainId where the client includes it. Present only on the receipt: status, gasUsed, cumulativeGasUsed, effectiveGasPrice, logs, logsBloom, contractAddress, and blobGasUsed/blobGasPrice on blob-carrying type-3 transactions.

The overlap is exactly the locator set. That is deliberate: the locator fields are what let you join a transaction to its receipt and to the block, and both objects report null for them until inclusion. On a pending transaction, blockNumber, blockHash and transactionIndex are null in the transaction object and there is no receipt at all. Any code that assumes transactionIndex is an integer has a latent null-dereference on pending rows.

The Pending-versus-Mined Asymmetry in Practice

The most useful operational difference is how the two calls behave before inclusion. eth_getTransactionByHash returns the full transaction as soon as a node knows about it, whether it came from the mempool or from a mined block. eth_getTransactionReceipt returns null until the transaction is mined. So the pair (transaction present, receipt null) means pending, and the receipt itself is the only positive proof of inclusion.

This is not a provider quirk. It follows from JSON-RPC 2.0 semantics where a null result is a valid success response, not an error. A node that does not know a hash answers {"result": null} with no error object, which means the same null can mean pending, unknown, pruned, or served by a node that is behind. The classifier below treats null as a fact about the node's view, never as a verdict about the chain.

One edge case deserves attention: to is null for contract creation while contractAddress on the receipt is populated only if the deployment succeeded. A revert during the constructor produces a receipt with status: 0x0 and contractAddress: null, so neither field alone tells you whether a deployment address exists.

A Runnable Classifier for Six Hash States

The script below resolves one hash against one endpoint and prints a single classified state. It deliberately avoids JSON-RPC batch arrays, since some providers reject or rate-limit them, and it issues the two lookups concurrently so the pending check costs one round trip rather than two. Run it against your own endpoint with a real hash.

The nonce comparison is what separates pending from replaced_or_dropped. If the sender's pending nonce has advanced past this transaction's nonce, another transaction filled that slot and this one will never be mined. The check is not perfect: a node with a shallow mempool may report a pending nonce that has not advanced yet, and after a dropped transaction the pending nonce settles rather than staying ahead. Treat the result as a strong signal, and confirm with a second endpoint before acting on it.

  • pending — transaction known, receipt null, sender's pending nonce not yet past the transaction nonce.
  • replaced_or_dropped — transaction known, receipt null, sender's pending nonce already past the transaction nonce.
  • mined_success / mined_reverted — receipt present with status 0x1 / 0x0.
  • not_found — both calls return null.
  • inconsistent_view — the two calls disagree, for example a receipt without a transaction object.
// classify-tx.mjs — Node.js 18+
// Usage: node classify-tx.mjs <RPC_URL> <TX_HASH>
// Classifies a transaction hash into: not_found | pending | replaced_or_dropped |
// mined_success | mined_reverted | inconsistent_view
const [rpcUrl, txHash] = process.argv.slice(2);
if (!rpcUrl || !txHash) {
  console.error('Usage: node classify-tx.mjs <RPC_URL> <TX_HASH>');
  process.exit(1);
}

// Single JSON-RPC call. Batch arrays are not supported by every provider,
// so the classifier stays on plain single requests and runs them in parallel.
async function rpc(method, params) {
  const res = await fetch(rpcUrl, {
    method: 'POST',
    headers: { 'content-type': 'application/json' },
    body: JSON.stringify({ jsonrpc: '2.0', id: 1, method, params })
  });
  const json = await res.json();
  if (json.error) throw new Error(`${method}: ${json.error.code} ${json.error.message}`);
  return json.result;
}

const toInt = (hex) => (hex === null || hex === undefined ? null : Number(BigInt(hex)));

const [tx, receipt] = await Promise.all([
  rpc('eth_getTransactionByHash', [txHash]),
  rpc('eth_getTransactionReceipt', [txHash])
]);

if (!tx && !receipt) {
  console.log(JSON.stringify({ state: 'not_found', hash: txHash }));
  process.exit(0);
}

if (tx && receipt) {
  const state = receipt.status === '0x1' ? 'mined_success'
    : receipt.status === '0x0' ? 'mined_reverted'
    : 'mined_unknown_status';
  console.log(JSON.stringify({
    state, hash: txHash, blockNumber: toInt(receipt.blockNumber),
    blockHash: receipt.blockHash, transactionIndex: toInt(receipt.transactionIndex),
    gasUsed: toInt(receipt.gasUsed), effectiveGasPrice: toInt(receipt.effectiveGasPrice),
    logs: (receipt.logs || []).length, contractAddress: receipt.contractAddress
  }));
  process.exit(0);
}

if (tx && !receipt) {
  // Transaction is known but not yet mined: blockNumber/blockHash/transactionIndex are null.
  if (tx.blockNumber !== null) {
    console.log(JSON.stringify({ state: 'inconsistent_view', hash: txHash,
      detail: 'transaction has a blockNumber but no receipt yet' }));
    process.exit(0);
  }
  // Compare the account's pending nonce with the transaction nonce. If the account
  // has moved past this nonce, the slot was filled by another transaction.
  const pendingNonce = toInt(await rpc('eth_getTransactionCount', [tx.from, 'pending']));
  const txNonce = toInt(tx.nonce);
  const state = pendingNonce !== null && pendingNonce > txNonce ? 'replaced_or_dropped' : 'pending';
  console.log(JSON.stringify({ state, hash: txHash, from: tx.from, nonce: txNonce, pendingNonce }));
  process.exit(0);
}

console.log(JSON.stringify({ state: 'inconsistent_view', hash: txHash,
  detail: 'receipt present without transaction object' }));

Measured Runs Against a Public Ethereum Endpoint

The classifier was exercised on 2026-10-18 against a public Ethereum mainnet endpoint. A syntactically valid hash that was never broadcast returned not_found with both results null. A real transaction taken from block 26,218,014 returned mined_success with blockNumber 26218014, transactionIndex 3, gasUsed 21000, effectiveGasPrice 2036108547 and zero logs, matching a plain ETH transfer. Both runs completed in under a second on a warmed connection.

The same script against a public Base endpoint failed rather than misreported: the receipt call returned JSON-RPC error -32602 with the message that archive requests require a personal token, while the transaction call succeeded. That is exactly the ambiguity the error branch exists for. A naive client that treats a null or an error as pending would have reported a state that was never observed; the script itself throws and exits non-zero, which the transcript below shows verbatim.

The raw run below is unedited apart from the hash argument. Treat it as evidence of the call shapes, not as a performance claim about any provider.

$ node classify-tx.mjs https://ethereum-rpc.publicnode.com 0x5c504ed432cb51138bcf09aa5e8a410dd4a1e204ef84bfed1be16dfba1b22060
{"state":"not_found","hash":"0x5c504ed432cb51138bcf09aa5e8a410dd4a1e204ef84bfed1be16dfba1b22060"}

$ node classify-tx.mjs https://ethereum-rpc.publicnode.com 0x9d07584175e77cc5c7ba7249a6b5322ff171b71208ec4966975f5d7c5483a97c
{"state":"mined_success","hash":"0x9d07584175e77cc5c7ba7249a6b5322ff171b71208ec4966975f5d7c5483a97c","blockNumber":26218014,"blockHash":"0x52e4f099f235ce78bc66bf51a5a98d8c7a7ec53bbe274bbf41385799948aba22","transactionIndex":3,"gasUsed":21000,"effectiveGasPrice":2036108547,"logs":0,"contractAddress":null}

$ node classify-tx.mjs https://base-rpc.publicnode.com <base-tx-hash>
Error: eth_getTransactionReceipt: -32602 Archive requests require a personal token.
    at rpc (classify-tx.mjs:20:25) ... exit code 1

Classifying Null Results, Errors, and Reorg Races

The three transcripts differ in kind, and that difference is the design lesson. A null result is a valid JSON-RPC success that says something about the node's view. A JSON-RPC error object says the request was rejected, usually because of a capability or access constraint. A transport failure says nothing about the chain at all. Map them to three different outcomes in your application, and never let an error or a timeout fall into the pending bucket.

Batch arrays deserve the same care. If you send several lookups in one array, a provider may return fewer results than you sent, or return an error object in a per-request slot. Index results by id rather than by position before joining them to hashes, and treat a missing id as an error for that hash only.

The fourth input to classification is time. A hash can pass through pending, then mined_success, then a reorg, then pending again. The classifier answers what is true now, so persist the state with the block height at which it was observed and re-run the classification during your confirmation sweep rather than assuming monotonic progress.

Selecting the endpoint is a separate decision from classifying the result: a node that answers consistently, exposes the namespaces you need and does not rate-limit your sweep is worth more than one with occasional lower latency. The Ethereum RPC node guide in RPC Assistant covers that choice.

Disambiguating a Not-Found Hash

When both calls return null, the honest answer is that this node cannot see the transaction. Resolve it in four steps before concluding anything. First, check the hash itself: 32 bytes, 0x-prefixed, 66 characters, and no whitespace or truncated middle from a UI copy. Second, compare the node's eth_blockNumber with a second source; a node that is hours behind will not have a recent transaction even though the network does. Third, ask a second, independent endpoint. A provider that prunes or does not index the range you need can return null for a transaction that other nodes hold.

Fourth, for a transaction you believe you just sent, look at the sender's eth_getTransactionCount with the pending tag and the latest tag. If pending equals the nonce you assigned, the transaction has not been accepted into any pool this node can see. If pending is greater than your nonce, the slot has already been consumed by something else. If latest equals your nonce and pending has not moved, the account has not advanced at all.

Only after those checks does a conclusion make sense, and even then the correct statement is scoped: not found on this endpoint at this height, not absent from the network. If you need the mempool view, txpool_content on a node with the namespace enabled answers it directly, as covered in reading the Ethereum mempool with the txpool namespace.

Receipt Logs, logsBloom, and Why You Should Not Filter Manually

A receipt's logs array holds the event records emitted during execution, each with address, topics and data. logsBloom is a 256-byte probabilistic summary of the addresses and topics in that receipt, and it exists so a node can cheaply skip receipts that cannot possibly match a filter. The block header has its own logsBloom, and the bloom across the block's receipts is what a eth_getLogs request is tested against before any receipt is opened.

The practical rule is to never implement bloom testing in application code. Blooms have false positives by design, so a positive match does not prove an event is present, and you would still have to scan the receipts. If you need events, ask for them with eth_getLogs, whose filtering semantics are covered in eth_getLogs event and topic filtering. Use logs.length and logsBloom only as cheap metadata on a receipt you already fetched, for example to skip fetching event bodies for transactions that emitted nothing.

A related trap is assuming logs exist for reverted transactions. They do not: a reverted transaction's receipt has status: 0x0 and an empty logs array, because the state changes and events were rolled back together. An indexer that keys its event table on transaction hash alone and never checks status will silently miss the distinction between no events and rolled-back events.

Reorg Behaviour and BlockHash-Keyed Reconciliation

A receipt is only as durable as the block that produced it. If that block is orphaned, the transaction may return to the mempool and be re-included in a different block, or it may disappear entirely. In that window eth_getTransactionReceipt can go from a populated receipt back to null, and later to a receipt with a different blockHash and blockNumber. Any reconciliation that stores only transactionHash and status will treat these as the same row and never notice.

Store blockHash alongside the receipt and re-verify it during confirmation sweeps, especially for blocks inside your confirmation depth. Treat a changed blockHash for a known transaction as a reorg signal rather than an update, and roll back derived state before applying the new receipt. The depth at which you stop checking is a product decision, and Ethereum block reorg detection with RPC depth covers how to measure it.

Note that the transaction object itself carries the same locator fields, so the same rule applies if you are reconciling from transaction objects: the blockHash in the transaction is a promise that a receipt for that block should exist. If it does not, you are looking at a mid-reorg read or an inconsistent node.

Per-Hash Lookups versus Block-Level Bulk Retrieval

Per-hash lookup is the right tool when you already hold a hash and want one answer: a user submitted it, a webhook fired, or a wallet is polling. It costs one request per hash and scales linearly, which becomes a rate-limit problem when you are reconciling thousands of hashes after an outage.

When you are processing a block, invert the direction. Fetch the block once, then fetch all of its receipts in a single eth_getBlockReceipts call and join them to the transaction list by transactionIndex or hash. That replaces N per-hash calls with one or two calls per block and guarantees a consistent snapshot of the same block, which per-hash calls cannot do if a reorg lands between them. The mechanics, including the fallback for nodes that do not implement the method, are in eth_getBlockReceipts for bulk receipts.

A hybrid works well in practice: bulk retrieval for forward indexing, per-hash lookup for user-initiated checks, and the classifier above as the single place that turns the raw pair of results into a state your application can act on.

Results Table to Fill Against Your Own Endpoint

Provider behaviour varies on the details that matter most for classification, so measure it rather than assume it. Run the four probes below against each endpoint you depend on, against the same fixture set, and record the answers. Empty cells are unmeasured, not false.

  • Pending transaction: does eth_getTransactionByHash return the object with blockNumber: null, and does eth_getTransactionReceipt return null? Record the HTTP status and the raw result.
  • Unknown hash: is the response {"result": null} with no error, or a JSON-RPC error object? Record the code.
  • Old transaction beyond the node's retention: null result or an error mentioning pruning or archive? Record both.
  • Reorg window: after a reorg, does a receipt for an orphaned block return null, or the old receipt, or an error? Record the observed behaviour and the block depth at which it changed.

Limitations, Troubleshooting, and Next Steps

The classifier is a starting point, not a complete transaction service. It does not handle reorgs by itself, it trusts a single provider, and its nonce heuristic can misreport replaced_or_dropped when the provider's mempool view is stale or when the sender is using multiple nodes. It also does not tell you why a transaction reverted: the receipt gives you status: 0x0 and the consumed gas, and diagnosing the cause needs a trace, which is a different namespace with its own cost profile.

Common failures and their meaning: a -32601 Method not found on either call means the endpoint does not expose that namespace, not that the transaction is missing. A -32602 on a receipt frequently means the request needs archive access, as the Base run above showed. A timeout that ends in an exception should be classified as an error, never mapped to pending. And a receipt that appears with an unexpected blockHash is a reorg event, not a data refresh.

For the next step, wire the classifier into the endpoint you actually use, run the results table, and only then decide whether you need block-level bulk retrieval. eth_getTransactionReceipt returns null covers the polling loop that should sit on top of it, and the Ethereum network page plus RPC pricing cover the infrastructure side if you would rather run against a managed endpoint than a public one. More guides like this are in the OnFinality Learn hub, and the API service covers managed access for teams that need consistent behaviour across chains.

Never Worry about Infrastructure Again

OnFinality takes away the heavy lifting of DevOps so you can build smarter and faster.

Get Started