DEV Community

Fagbemi Michael
Fagbemi Michael

Posted on

Why Transaction Fail (and how to actually debug them)

NB: please use AI tool or a code editor for the code snippets in this article to structure the code for better understanding.

Short steps up front: when you see “TX failed” you don’t need to guess ,follow a small repeatable checklist:

  1. fetch receipt → 2) decode revert → 3) check gas/nonce/balance → 4) reproduce locally (fork) → 5) trace and fix state/ABI/permission problems.

Below is a plain, step-by-step article you can use to debug your transactions.


*TL;DR (one-minute checklist)
*

  • Get the tx receipt (eth_getTransactionReceipt). Look at status, gasUsed, logs.

  • Try the same call with eth_call (or provider.call) to get a revert reason.

  • eth_estimateGas vs submitted gas → Out-of-gas?

  • Check eth_getTransactionByHash + txpool for nonce/pending issues.

  • Reproduce on a local fork (anvil/hardhat) and use debug_traceTransaction to find the failing opcode.

Fix: allowance, approvals, owner/paused guards, correct ABI/address, or bump gas/nonce.


*Who This is for & quick setup
*

Target: Solidity engineers, integrators, infra/devops, and anyone sending transactions from wallets or bots.

You need:

A JSON-RPC URL (Infura, Alchemy, QuickNode, or your running node).

Tools (pick any): curl + node’s RPC, Foundry cast/anvil, Hardhat, ethers.js.

Optional but useful: access to node with debug_traceTransaction enabled (geth or anvil do this).

*Quick setup commands (examples):
*


*Quick triage (first 60 seconds)
*

Run these checks immediately to eliminate common mistakes:

  1. Are you on the right chain / network id?

  2. Is the contract address correct and actually a contract? (eth_getCode)

  3. Does the sender have enough ETH? (eth_getBalance)

  4. Is your nonce correct / any pending tx? (eth_getTransactionByHash + txpool)

  5. Is there a missing token approve / allowance?

  6. Was the gas limit too low?

*Commands to run right away:
*

  • Receipt
    curl -s -X POST -H "Content-Type:application/json" \
      --data '{"jsonrpc":"2.0","method":"eth_getTransactionReceipt","params":[""],"id":1}' $RPC_URL

  • Balance
    curl -s -X POST -H "Content-Type:application/json" \
      --data '{"jsonrpc":"2.0","method":"eth_getBalance","params":["", "latest"],"id":1}' $RPC_URL

  • Check if address has code (is a contract)
    curl -s -X POST -H "Content-Type:application/json" \
      --data '{"jsonrpc":"2.0","method":"eth_getCode","params":["

    ","latest"],"id":1}' $RPC_URL

*Step 0 — Capture the exact tx data (always do this)
*

Before changing anything, copy the full original tx fields: from, to, data, value, gas, gasPrice or maxFeePerGas & maxPriorityFeePerGas, and nonce. You’ll replay this exact set in a local fork.

You can fetch the on-chain transaction:

curl -s -X POST -H "Content-Type:application/json" \
  --data '{"jsonrpc":"2.0","method":"eth_getTransactionByHash","params":[""],"id":1}' $RPC_URL

Store those fields — they are the canonical reproduction input.


*Step 1 — Inspect the receipt & quick interpretation
*

Look at the transaction receipt fields of interest:

status — 0 means revert; 1 means success.

gasUsed — compare with what you set in gas. If gasUsed ≈ gas limit, likely OOG.

logs — if events you expected are missing, the code likely reverted before emitting them.

blockNumber — helpful to choose a fork block close to the original.

Example interpretation:

status: 0, gasUsed: 21000 → revert early (require/owner check or immediate revert)

status: 0, gasUsed ~= gasLimit → likely ran out of gas


*Step 2 — Decode the revert reason
*

  • If the node returns revert data, decode it. Use eth_call (simulation) because many providers return the revert string when simulating a call.

Ethers.js quick pattern:

try {
  await provider.call({ to: tx.to, data: tx.data, from: tx.from, value: tx.value });
} catch (err) {
  console.error("revert data:", err.error || err.data || err);
  // If you have the ABI, use new ethers.utils.Interface(abi).parseError(revertData)
}

  • If the revert data is ABI-encoded custom error, decode it with the contract ABI:

const iface = new ethers.utils.Interface(contractAbi);
const parsed = iface.parseError(revertData); // works for custom errors

  • If you only have raw bytes, first check for the standard Error selector (0x08c379a0 = "Error(string)"):

0x08c379a0 + encoded string → normal require("message").
If another selector appears, it’s a custom error — parse with the ABI.

  • If the RPC doesn’t return a revert string, use local fork + provider.call or debug_traceTransaction to see more.

*Step 3 — Gas and out-of-gas checks
*

Compare submitted gas to eth_estimateGas.

If eth_estimateGas >> submitted gas → raise gas limit. If gasUsed is very close to gas limit, increase it.

Commands:

Estimate gas for the same transaction

curl -s -X POST -H "Content-Type:application/json" \
--data '{"jsonrpc":"2.0","method":"eth_estimateGas","params":[{ "to":"","data":"0x...","from":"0x..." }],"id":1}' $RPC_URL

Notes:

eth_estimateGas can fail if the call always reverts. Use a local fork and manipulate state to get a meaningful estimate if needed.

On EIP-1559 chains, ensure maxFeePerGas and maxPriorityFeePerGas are set correctly.


*Step 4 — Nonce / pending / replacement issues
*

Symptoms: tx stays pending, or you get replacement transaction underpriced.

Quick checks:

  • eth_getTransactionByHash — looks at tx status.

  • txpool_content (node-dependent) — inspect pending transactions.

  • Replace a stuck tx by resending with same nonce and a higher gas price / tip.

Commands:

txpool (Geth/Parity specific)

curl -s -X POST -H "Content-Type:application/json" \
  --data '{"jsonrpc":"2.0","method":"txpool_content","params":[],"id":1}' $RPC_URL

If you control the key, common fix is to resend a new transaction with same nonce + higher fee.


Step 5 — Reproduce the failure locally (forking)

Forking is the single most powerful debugging step. Start a local node at a block near the tx and replay the exact tx.

Anvil (Foundry) example:

anvil --fork-url $RPC_URL --fork-block-number -p 8545

Then use ethers or cast to call the tx or send the exact transaction fields to see the same failure locally. On a fork you can also change balances, allowances, or contract storage to test state-dependent failures.

Replaying call with cast:

cast call "methodName(type...)" params --rpc-url http://127.0.0.1:8545

Replaying raw via ethers:

await provider.call({ to, data, from, value, gasLimit: gas });


Step 6 — Trace the transaction to find the failing opcode

Use debug_traceTransaction to get a call-tree and the point of revert. This helps identify whether a particular external call reverts, a require fails, or an out-of-gas occurs.

Example RPC:

curl -s -X POST -H "Content-Type:application/json" \
--data '{"jsonrpc":"2.0","method":"debug_traceTransaction","params":["", {"tracer":"callTracer"}],"id":1}' $RPC_URL

Interpreting traces:

  • Look for the last call in the call tree with error or revert.

  • The trace may show the failing internal call (for example CALL to token contract failed).

  • If you run traces locally with anvil/hardhat you’ll often get richer data than public nodes.


*Step 7 — State-dependent issues (allowance, balances, timestamps)
*

Many reverts depend on the exact on-chain state at execution time:

Common checks:

  • ERC20 allowance(owner, spender) and balanceOf(owner).

  • Contract paused() status.

  • Role/owner checks: owner() or hasRole(...).

  • Block/time dependence: block.timestamp or block.number used in logic.

On your fork you can mutate state before replaying:

  • Transfer tokens into the account.

  • Call a helper to set oracle price or mock contract responses.

  • Increase allowance.

Example cast calls:

check allowance (foundry cast)

cast call "allowance(address,address)(uint256)" $OWNER $SPENDER --rpc-url http://127.0.0.1:8545


Step 8 — External calls, oracles, and non-determinism

If your function calls other contracts or oracles, those external calls can revert or return unexpected values (zero price, stale timestamp, etc).

How to handle:

  • Trace to find which external call failed.

  • On a fork, patch oracle storage or deploy a mock and point the caller to it.

  • For production fixes, add sanity checks (e.g., require(price > 0) or fallback behaviors).


Step 9 — Permission, pausability, initializer mistakes

Typical access issues:

  • Contract still paused.

  • Function restricted to onlyOwner or role-based guard.

  • Missing initialize() or setup steps after deployment (proxy patterns).

Quick checks:

const c = await ethers.getContractAt("MyContract", "

");
await c.owner();
await c.paused();

If you see revert: Ownable: caller is not the owner — adjust the caller or correct owner assignment.


Step 10 — ABI, address, and chain mismatch

If you call the wrong address or wrong ABI, functions can revert or behave unexpectedly.

Checks:

  • eth_getCode(address) should return non-empty hex for a contract.

  • Verified contract ABI from Etherscan / block explorer should match what you use locally.

  • For proxies, ensure you’re interacting with the proxy address with the implementation ABI (or use the proxy’s interface).

Command:

curl -s -X POST -H "Content-Type:application/json" \
  --data '{"jsonrpc":"2.0","method":"eth_getCode","params":["","latest"],"id":1}' $RPC_URL


*Tooling quick-reference :
*

  • anvil (Foundry) — fast local fork and tracing. anvil --fork-url --fork-block-number

  • hardhat — local fork + console: npx hardhat node --fork

  • cast — quick read/write: cast call / cast send

  • ethers.js — provider.call() to simulate, provider.getTransaction() to fetch txs

  • debug_traceTransaction — use for opcode-level trace (requires node support)

  • Tenderly / Blocknative / Etherscan TX viewer — nice UI for replays/traces


*Ready scripts: fetch + replay + decode (copy/paste)
*

1) replay-tx.js (node + ethers) — simulate and show revert data

// node replay-tx.js
const { ethers } = require("ethers");
(async()=>{
  const [txHash, rpc] = process.argv.slice(2);
  if(!txHash || !rpc) { console.error("Usage: node replay-tx.js "); process.exit(1); }
  const provider = new ethers.providers.JsonRpcProvider(rpc);
  const tx = await provider.getTransaction(txHash);
  console.log("tx:", tx);
  try {
    const res = await provider.call({
      to: tx.to,
      data: tx.data,
      from: tx.from,
      value: tx.value,
      gasLimit: tx.gasLimit
    });
    console.log("call success (returned data):", res);
  } catch (e) {
    console.error("call reverted. raw:", e.error || e.data || e);
  }
})();

2) quick-receipt.sh

!/usr/bin/env bash

RPC=$1; TX=$2
curl -s -X POST -H "Content-Type:application/json" --data \
"{\"jsonrpc\":\"2.0\",\"method\":\"eth_getTransactionReceipt\",\"params\":[\"$TX\"],\"id\":1}" $RPC | jq .


Short case studies :

Case A — Missing ERC20 allowance

Symptom: call to a marketplace contract reverts. Receipt shows status:0, no logs.

Debug steps:

  1. Check allowance(owner,market) → returns 0.

  2. Approve correct amount and re-run the tx.

Fix: erc20.approve(market, amount) or use permit() flow if available.

Case B — Oracle returns 0 (price = 0)

Symptom: Action reverts because division by zero or require(price > 0).

Debug steps:

  1. Trace shows external call to PriceFeed.latestAnswer() returned 0.

  2. On a local fork set the oracle storage to a non-zero price or deploy a mock oracle.

Fix: Update oracle, add guards to your contract, and add alerting on price feed changes.

Case C — Out-of-gas because client used too small gasLimit

Symptom: gasUsed equals the submitted gas and tx reverted.

Debug steps:

  1. eth_estimateGas returns higher value.

  2. Resend with increased gas limit or allow miner to pick gas.

Fix: Use estimation in client or set a safe multiplier (e.g., 1.3 * estimateGas).


*Advanced gotchas (short list)
*

  • Reentrancy / ordering: race conditions cause different behavior under MEV/front-running.

  • Constructor/initializer mismatch in proxies: calling implementation methods before initialization.

  • Non-standard ERC20s: tokens that don’t return bool on transfer — use wrappers like OpenZeppelin SafeERC20.

  • State shadowing / wrong storage slot: particularly when using low-level assembly or incorrect inheritance.

  • Chain reorganizations: rare, but can cause txs to reappear or reorder relative to observed state.


One-page troubleshooting flow (copyable)

  1. Get receipt (eth_getTransactionReceipt). If status == 1, the tx succeeded — check downstream logic.

  2. If status == 0 -> run provider.call()/eth_call with same fields to capture revert reason.

  3. eth_estimateGas vs gas limit → OOG? Increase gas if estimate > gas.

  4. Check eth_getBalance(from), allowance, and owner/paused flags.

  5. Fork locally (anvil/hardhat) at a nearby block and replay transaction. Mutate state if needed and rerun.

  6. Use debug_traceTransaction to see which internal call/opcode failed.

  7. Fix source (approve, set owner, update oracle, correct ABI/address, bump gas) and re-test on fork before sending live.


Summary

When you see “TX failed”, don’t panic. Capture the tx fields, fetch the receipt, simulate the call to get the revert reason, reproduce on a local fork, and use traces to find the failing unit. Most failures are one of: permission, missing approval, out-of-gas, wrong ABI/address, or state-dependent external data.

*How helpful was this article ?
*

Follow me on X : @mykereckon
Connect with me on Zora : https://zora.co/invite/myke_027

Top comments (0)