What does "execution reverted" mean and how do I find the real reason? · Jumpstart Blockchain
What does "execution reverted" mean and how do I find the real reason?
The EVM hit a REVERT and undid the call. The reason is in the revert data: decode Error(string), Panic codes and custom errors with ethers, or replay a failed transaction with cast run.
Error: execution reverted (no data present; likely require(false) occurred)
"Execution reverted" only tells you that the EVM hit a REVERT opcode (or an equivalent failure) and undid every state change in the call. The actual reason, if there is one, is in the revert data returned with it. Most of the time the reason is there and your tooling just isn't decoding it.
Where you see it
Before sending. Wallets and libraries call eth_estimateGas first. If the simulated call reverts, you get "execution reverted" and nothing is broadcast. This is the most common case, and the cheapest to debug.
After mining. The transaction was included but the receipt has status: 0. You paid for the gas used and nothing changed. The receipt doesn't contain the revert data, so you have to replay the transaction to see it.
On a read. An eth_call to a view function reverts, for example because of a bad argument or because there's no contract at that address on this chain.
What the revert data can contain
The data is ABI-encoded, and its first 4 bytes tell you which kind it is:
Revert data starts with
Produced by
Contains
0x08c379a0
require(cond, "message"), revert("message")
Error(string): your message
0x4e487b71
Compiler-inserted checks
Panic(uint256): a numeric code
any other 4 bytes
revert MyError(...), require(cond, MyError(...))
A custom error selector plus its arguments
empty (0x)
require(cond), revert(), out of gas, calling code that doesn't exist
Nothing
The panic codes are defined by Solidity. The ones you'll actually meet:
MEV is profit from ordering transactions. A sandwich attack front-runs and back-runs a public swap, capped by its slippage. Use tight minimum outputs from fresh quotes and private RPCs.
Arithmetic overflow or underflow outside unchecked
0x12
Division or modulo by zero
0x21
Invalid value converted to an enum
0x31
.pop() on an empty array
0x32
Array index out of bounds
0x41
Allocated too much memory
Decode it with ethers v6
ethers can decode all three forms, but it can only name a custom error if the error is in the ABI you gave the contract. Include errors from inherited contracts and libraries (for example OpenZeppelin v5's ERC20InsufficientBalance) in the ABI; Hardhat and Foundry artifacts already do.
Simulate the call with staticCall so you get the error without spending gas:
import { ethers, isError } from"ethers";
const provider = new ethers.JsonRpcProvider(process.env.RPC_URL);
const signer = new ethers.Wallet(process.env.PRIVATE_KEY!, provider);
const token = new ethers.Contract(tokenAddress, tokenAbi, signer);
try {
await token.transfer.staticCall(to, amount);
} catch (e) {
if (isError(e, "CALL_EXCEPTION")) {
console.log("reason:", e.reason); // string message or panic descriptionconsole.log("decoded:", e.revert); // { name, signature, args } or nullconsole.log("raw data:", e.data); // hex revert data
} else {
throw e;
}
}
If e.revert is null but e.data is not empty, the error isn't in your ABI. Decode it against an interface that has it:
Compute a selector from a signature, or look up an unknown selector in the public signature database:
cast sig "ERC20InsufficientAllowance(address,uint256,uint256)"
cast 4byte 0x<first-4-bytes-of-revert-data>
For a transaction that already failed on-chain, replay it locally with a full call trace. cast run re-executes the earlier transactions in the same block first, so the state matches what the transaction actually saw:
cast run 0xFailedTxHash --rpc-url $RPC_URL
The trace shows every internal call, and the innermost frame that reverted is usually the real culprit. Replaying an older transaction needs an archive node or a provider that serves historical state. Against a verified contract, add --etherscan-api-key so the trace shows function and error names instead of raw selectors.
In local tests, forge test -vvvv prints the same kind of trace for failing tests, and Hardhat prints Solidity stack traces for transactions on its built-in network.
When the data is empty
No data means there's no message to decode, so reason from the mechanism:
require(cond) with no message, or a bare revert(). Read the function's conditions one by one.
The address has no code on this chain. A high-level call to it reverts with no data. Check with cast code <address> --rpc-url $RPC_URL; 0x means nothing is deployed there. This is often a wrong address, a wrong network, or a token that hasn't been deployed on that L2.
An inner call ran out of gas. Only 63/64 of the remaining gas is passed to a subcall. A subcall that runs out fails without data, and the caller may then revert without data too. Try again with a higher gas limit and see if the error changes.
A low-level call returned false and the contract reverted without forwarding the reason.
The usual suspects
Once you can see the reason, most reverts come down to a handful of causes:
Missing or insufficient allowance before a transferFrom (e.g. a DEX router or vault deposit).
Wrong msg.sender: an onlyOwner function called from a different account, or through a contract when the check expects the user.
Wrong msg.value: sending ETH to a non-payable function, or too little for a mint.
Expired deadline or stale price in DeFi calls, e.g. slippage checks failing between simulation and inclusion.
Paused contract or a time lock that hasn't elapsed.
Wrong chain: the same address on a different network may hold a different contract, or nothing at all.
Checklist
Reproduce with staticCall / eth_call rather than sending a transaction.
Read e.revert, e.reason and e.data from the ethers CALL_EXCEPTION.
Put all custom errors, including inherited ones, into the ABI you decode with.
For a mined failure, use cast run <txhash> to get a trace of the exact execution.
For empty data, check cast code, gas limits and message-less requires.
Give your own requires messages or, better, custom errors with arguments, so the next person can see why.
Approve is safe; unlimited, forgotten allowances and unread permit signatures are not. Approve exact amounts, use permit with short deadlines, read every typed-data signature, and revoke old approvals.
Storage dominates gas costs: pack variables, cache storage reads, use constant, immutable, calldata and custom errors, emit events for off-chain data, and measure every change with forge snapshot.