A user clicks a button, the wallet pops up, they confirm, and a red toast says the transaction failed. You have a hash and nothing else.
One command turns that into an answer:
cast run $TX --rpc-url $RPC_URL
Traces:
[5259] 0xe7f1725E7734CE288F8367e1Bb143E90bb3F0512::withdraw(500000000000000000000 [5e20])
└─ ← [Revert] InsufficientBalance(0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266, 100000000000000000000 [1e20], 500000000000000000000 [5e20])
Gas used: 26535
The contract, the function, the arguments, the error and the two numbers that disagree. No ABI, no source, no block explorer.
That is the happy path, and it is worth knowing how far it extends. So I built a small vault contract that can fail thirteen distinct ways — custom errors with and without arguments, require with and without a string, four of Solidity's Panic codes, an error bubbled up from a token, an error no public database has ever seen, out of gas, and a failure that does not revert at all — and put every one of them through the same four layers: simulateContract in the browser, the mined receipt, cast run, and forge test -vvvv.
The receipt named zero of the thirteen. cast run named twelve. And for one failure it gave two different answers depending on a flag.
Everything below was measured on 2026-09-15 with viem 2.56.5, Foundry 1.8.3 (published the same day), Node.js 22.17.0 and Solidity 0.8.36, against a local Anvil on an Apple M3 Pro. The contracts, harness and raw results are in this repository under examples/articles/how-do-i-debug-a-solidity-revert-with-foundry-and-viem. Nothing here broadcasts to a public network or moves real value; the only outbound requests are selector lookups against openchain.xyz.
What the browser actually hands you
viem throws in two quite different places, and confusing them is the first way people lose an afternoon.
simulateContract runs the call as eth_call and throws before any transaction exists. That error carries the revert data. writeContract sends a transaction. Whether it throws depends on the account, as the measurements below show; in the browser-wallet case it does not, so you get a hash and waitForTransactionReceipt later returns status: "reverted" with no explanation attached.
The simulate error is deeply nested. For every reverting case I measured, err.constructor.name was ContractFunctionExecutionError and the useful information sat one level down, six levels deep in total:
ContractFunctionExecutionError
└─ ContractFunctionRevertedError ← the revert data lives here
└─ CallExecutionError
└─ ExecutionRevertedError
└─ RpcRequestError
└─ the raw JSON-RPC error object
viem's own documentation gives the retrieval pattern: catch a BaseError and walk the chain for the level you want. That is the whole trick, and it is worth writing once as a function rather than at every call site:
import { BaseError, ContractFunctionRevertedError } from "viem";
export function explain(err) {
if (!(err instanceof BaseError)) return `not a viem error: ${err}`;
const revert = err.walk((e) => e instanceof ContractFunctionRevertedError);
if (!(revert instanceof ContractFunctionRevertedError)) {
// Out of gas, a rejected signature, a dropped connection: no revert data.
return `no revert data — ${err.shortMessage}`;
}
if (revert.data?.errorName) {
const args = (revert.data.args ?? []).join(", ");
return `${revert.data.errorName}(${args})`;
}
if (revert.signature) {
// Four bytes the ABI you passed does not declare.
return `undeclared error ${revert.signature} — data ${revert.raw}`;
}
return `reverted with no data — ${revert.reason ?? "no reason"}`;
}
Run against the fixture, that prints:
withdraw(500 shares the account does not have)
-> InsufficientBalance(0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266, 100000000000000000000, 500000000000000000000)
settle(7) with the current ABI
-> VaultDebugUnknownCondition(7, 0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266)
settle(7) with an ABI that predates the error
-> undeclared error 0xa1f1f0ad — data 0xa1f1f0ad0000000000000000000000000000000000000000000000000000000000000007000000000000000000000000f39fd6e51aad88f6f4ce6ab8827279cfffb92266
withdraw(0)
-> reverted with no data — execution reverted
deposit(950) — the token reverts
-> Error(ERC20: transfer amount exceeds balance)
sweep() — an unbounded loop
-> no revert data — Transaction creation failed.
Three fields on that ContractFunctionRevertedError carry the answer, and which one is populated depends on how the contract failed:
| The contract did | data.errorName |
data.args |
reason |
|---|---|---|---|
revert CustomError(a, b) |
the error's name | its arguments | empty |
require(cond, "…") or revert("…") |
Error |
the string | the string |
assert, overflow, division by zero, bad index |
Panic |
the code as a number | the plain-English text |
require(cond) with no message |
empty | empty | "execution reverted" |
| revert an error your ABI does not declare | empty | empty | empty — signature holds four bytes |
The Panic row is the one worth internalising, because the codes are a fixed list in the language, not a library convention. Solidity's documentation enumerates them: 0x01 for a failed assert, 0x11 for overflow or underflow outside an unchecked block, 0x12 for division or modulo by zero, 0x32 for an out-of-bounds index, and six more. viem renders each one as a sentence — "Arithmetic operation resulted in underflow or overflow." — which is enough to skip the lookup.
The last row is a bare require(cond), and the Solidity docs are blunt about it: that form "will revert without any data (not even an error selector)". Nothing downstream can recover a reason that was never encoded. It is the one failure that none of the four layers could name.
Four bytes and nothing else
Two of the thirteen failures reached the frontend as a selector with no name, and both are ordinary rather than exotic.
The first is an error thrown by a contract you did not pass an ABI for. The vault's previewWithdraw calls into the token, the token reverts with its own TransferToBlockedAddress(address), and the vault bubbles it up unchanged. viem is reading the vault's ABI, which has never heard of it, so the thrown error carries an AbiErrorSignatureNotFoundError in its chain and signature: "0x36359be9".
The second is a stale ABI. Deploy a contract, add an error, redeploy, and forget to regenerate the artifact the frontend imports. I simulated every case a second time through an ABI with the error entries stripped out, which is what that mismatch looks like in practice. Custom errors that had resolved cleanly degraded to selectors; Error(string) and Panic were unaffected, because those two are part of the ABI specification rather than per-contract declarations. The count went from 9 named of 13 to 6.

Four bytes are still a lead. cast 4byte asks openchain.xyz for signatures matching a selector, and it resolved three of the four custom errors this fixture declares, including the token's. The fourth — an error I invented for this article and deployed nowhere public — returned Error: No matching function signatures found for selector 0xa1f1f0ad. That is the honest boundary of a public signature database: it covers what has been verified somewhere, not what is on your staging chain.
cast decode-error goes further, because it takes the whole returndata rather than just the selector:
$ cast decode-error 0xdb42144d000000000000000000000000f39fd6e51aad88f6f4ce6ab8827279cfffb922660000000000000000000000000000000000000000000000056bc75e2d6310000000000000000000000000000000000000000000000000001b1ae4d6e2ef500000
InsufficientBalance(address,uint256,uint256)
0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266
100000000000000000000 [1e20]
500000000000000000000 [5e20]
And when nothing knows the selector, --sig lets you supply it yourself, which is the fallback for an error that exists only in your own source tree.
Why it decodes on your laptop and not in CI
Here is the part that is not obvious. cast run named my invented error too — VaultDebugUnknownCondition(7, 0xf39F…2266) — from a directory with no Foundry project in it, for a contract on a throwaway chain, using a selector openchain has never seen.
The names come from a machine-local file. forge build writes every selector it compiles into ~/.foundry/cache/signatures, and cast reads that before it asks the network. cast decode-error --help says so directly: with no --sig it "tries to decode from local cache or https://api.openchain.xyz". On this machine that file holds 1,226 function signatures, 115 errors and 55 events, accumulated from every project ever built here.
Point HOME at an empty directory and the same commands lose the name:
| Command | With this machine's cache | With an empty HOME |
|---|---|---|
cast run on the unpublished error |
VaultDebugUnknownCondition(7, 0xf39F…) |
custom error 0xa1f1f0ad: 0000…0007… |
cast run on an error openchain knows |
InsufficientBalance(0xf39F…, 1e20, 5e20) |
InsufficientBalance(0xf39F…, 1e20, 5e20) |
cast decode-error on the unpublished error |
decoded | Error: No matching error signature found |
cast 4byte on the unpublished error |
no match | no match |
So a trace that reads perfectly for you can come back as raw hex for a teammate who has not built that repository, or in a CI job with a fresh home directory. If you paste a decoded trace into an issue, paste the selector too.
The receipt is the worst place to look
Once a reverting transaction is mined, the reason is gone. The receipt viem returns has fifteen fields — status, gasUsed, logs, blockNumber and so on — and not one of them carries revert data. That is a property of the receipt itself, not of viem.
Which makes it worth avoiding that state. viem's documentation is explicit that writeContract "does not validate if the contract write will succeed", and recommends pairing it with simulateContract. I sent the same doomed withdraw four ways to see what each costs:
| How it was sent | Outcome | Spent |
|---|---|---|
| JSON-RPC account (an address, as a browser wallet supplies) | mined, status: "reverted" |
0.0000360 ETH |
the same, with an explicit gas limit |
mined, status: "reverted" |
0.0000348 ETH |
| local account (viem holds the key, so viem estimates gas) | threw InsufficientBalance(…) before signing |
nothing |
simulateContract first |
threw InsufficientBalance(…) |
nothing |
The difference between rows three and one is gas estimation, not care. A local account makes viem call eth_estimateGas itself, and that call reverts with the same data eth_call would have returned. An address-only account — the shape you get from a browser wallet — hands the whole transaction to the node, and the revert surfaces as a mined failure. The docs note the same trap from the other direction: passing a gas limit "also skips the gas estimation step".
The money at stake per failed transaction is small. Across the eleven reverting cases the transaction burned between 21,973 and 38,408 gas — the EVM refunds whatever the call did not use. The exception is running out of gas, which consumed the entire 2,000,000 limit, about 80 times the median revert. The real cost of skipping the simulation is not the fee, it is that you have thrown the reason away and now need the rest of this article.
The failure that only exists inside its block
This is the case that made the whole exercise worth doing.
The account simulates a withdrawal of 50 shares. It succeeds. The transaction is sent. In the same block, an operator's setPaused(true) lands in front of it. The withdrawal reverts.
The three obvious ways to investigate give three different answers:
Replay the call at the parent block. This is the natural instinct — take the failed transaction's to and input, run publicClient.call against the block before it landed. It did not throw. The state at the parent block was still fine; the thing that broke it was a transaction in the same block.
Replay it at latest. This throws, but as a CallExecutionError rather than a contract error, with Execution reverted with reason: custom error 0x9e87fac8. A raw call has no ABI, so you are back to a selector. It also answers a different question — whether the call fails now — which happens to coincide here and will not always.
cast run. This is the one that is right:
$ cast run $TX --rpc-url $RPC_URL
Executing previous transactions from the block.
Error: Transaction failed.
Traces:
[2611] 0xe7f1725E7734CE288F8367e1Bb143E90bb3F0512::withdraw(50000000000000000000 [5e19])
└─ ← [Revert] Paused()
Gas used: 23887
That first stderr line is the whole mechanism. cast run re-executes every earlier transaction in the block before running yours, so it reproduces the state your transaction actually met. Foundry's own debugging guide is where this command lives, under "tracing a failed transaction".
There is a flag that turns it off, and it is tempting because it is faster. --quick is documented as executing "the transaction only with the state from the previous block. May result in different results than the live execution!" Here is what that warning looks like on the same hash:
Traces:
[39202] 0xe7f1725E7734CE288F8367e1Bb143E90bb3F0512::withdraw(50000000000000000000 [5e19])
├─ [15885] 0x5FbDB2315678afecb367f032d93F642f64180aa3::transfer(0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266, 50000000000000000000 [5e19])
│ ├─ emit Transfer(from: 0xe7f1725E7734CE288F8367e1Bb143E90bb3F0512, to: 0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266, amount: 50000000000000000000 [5e19])
│ └─ ← [Return] true
├─ emit Withdraw(account: 0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266, amount: 50000000000000000000 [5e19])
└─ ← [Stop]
Transaction successfully executed.
Same transaction hash. Same node. The tool says it worked, including the transfer and the event that never happened. --quick is a fine optimisation for a failure that depends only on the account's own state, and it is actively misleading for anything involving a pause, an oracle update, a shared limit or another user's transaction — which is most of the failures worth debugging.
One more detail if you script this: cast run exits 0 even when the transaction failed. The Error: Transaction failed. line goes to stderr. Grep for it rather than checking $?.
Then reproduce it in Solidity
Once you know the condition, the loop tightens. A Forge test that reproduces the failure runs in milliseconds and stays in the repository as a regression test — this is the part of the workflow Foundry made routine.
-vvvv prints the full call tree, which matters most when the string you saw did not come from the contract you called:
Traces:
[49626] RevertTest::test_depositTooMuch_bubblesTheTokenString()
├─ [0] VM::prank(0x00000000000000000000000000000000000A11cE)
│ └─ ← [Return]
├─ [0] VM::expectRevert(ERC20: transfer amount exceeds balance)
│ └─ ← [Return]
├─ [38408] Vault::deposit(950000000000000000000 [9.5e20])
│ ├─ [11365] Token::transferFrom(0x00000000000000000000000000000000000A11cE, Vault: [0x2e234DAe75C793f67A35089C9d99245E1C58470b], 950000000000000000000 [9.5e20])
│ │ └─ ← [Revert] ERC20: transfer amount exceeds balance
│ └─ ← [Revert] ERC20: transfer amount exceeds balance
└─ ← [Stop]
The string appears twice. The nested line is where it was created; the outer line is the vault passing it along. In the browser you only ever see the outer one, which is how "ERC20: transfer amount exceeds balance" gets filed as a bug in the vault.
There is one cheatcode trap here that is easy to get backwards. vm.expectRevert(SomeError.selector) looks like "match the selector, ignore the arguments". It is not. Foundry's cheatcode reference lists the bytes4 variant as asserting an exact match — the whole revert data must be those four bytes — so it only ever passes against an error that carries no arguments. Written against one that does, the test fails:
[FAIL: Error != expected error: InsufficientBalance(0x00000000000000000000000000000000000A11cE, 100000000000000000000 [1e20], 500000000000000000000 [5e20]) != custom error 0xdb42144d] test_selectorOnlyAgainstAnErrorWithArguments()
Which is a good failure message: it prints the arguments it actually got, so the fix is usually visible in the error. Two ways to write it correctly, and they assert different things:
// Matches the selector and the arguments. Fails if either changes.
vm.expectRevert(abi.encodeWithSelector(Vault.InsufficientBalance.selector, user, 100e18, 500e18));
vault.withdraw(500e18);
// Matches the selector only, which is what the bare bytes4 form is often assumed to do.
vm.expectPartialRevert(Vault.InsufficientBalance.selector);
vault.withdraw(500e18);
Prefer the first. A revert that carries the available balance and the requested amount is carrying them for a reason, and a test that ignores them passes while the frontend renders the wrong number.
Two more things changed in recent Foundry releases and will bite an older test file. testFail* is gone in 1.8.3 — a function with that prefix now fails with `testFail*` has been removed. Consider changing to test_Revert[If|When]_Condition and expecting a revert. And gas figures inside traces moved: the same test reported 16,405 gas on one and 37,681 on the other. That is the isolate default flipping from off to on, not a change in the contract — running 1.5.1 with --isolate lands on 37,681 exactly. Quote revert data from traces, not gas.
The failure that never reverts
The last of the thirteen is the one that should worry you most, because none of the machinery above triggers.
Solidity's documentation states the rule plainly: the low-level operations — call, send, delegatecall, callcode, staticcall — "never throw exceptions but indicate failures by returning false". A vault that calls a token through address(token).call(...) and discards the result, against a token that returns false instead of reverting, does this:
Traces:
[13950] 0xCf7Ed3AccA5a467e9e704C703E8D87F634fB0Fc9::withdraw(10000000000000000000 [1e19])
├─ [3106] 0x9fE46736679d2D9a65F0992F2272dE9f3c7fa6e0::transfer(0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266, 10000000000000000000 [1e19])
│ └─ ← [Return] false
├─ emit Withdraw(account: 0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266, amount: 10000000000000000000 [1e19])
└─ ← [Stop]
Transaction successfully executed.
Measured against the chain: status: "success", 35,214 gas, one log. The account's shares went from 100 to 90. The tokens it received: zero. The Withdraw event fired with an amount that never moved, so every indexer and every subgraph downstream now records a withdrawal that did not happen.
simulateContract returns cleanly. There is no error to walk, no hash to trace, no revert to decode. The only signal is the ← [Return] false in the middle of a trace that says "successfully executed", and you only look at that trace if someone already told you something was wrong.
The tool that does catch it is the compiler-adjacent one. forge build lints by default and flagged the exact line, on both releases I tried:
warning[unchecked-call]: low-level call does not check the success return value
╭▸ src/Vault.sol:139:9
│
139 │ address(token).call(abi.encodeWithSelector(Token.transfer.selector, msg.sender, amount));
Foundry 1.5.1 reported unchecked-call and erc20-unchecked-transfer on this contract; 1.8.3 reported those two plus reentrancy-events three times and missing-events-access-control three times. The rule set has grown, and this class of bug has been in it the whole time. It costs nothing to read the warnings you are already generating.
Where to look first
The order that fell out of the measurements:
- Simulate before you write.
simulateContractcosts oneeth_calland is the only layer that still has the revert data at the moment a user could be told something useful. Nothing downstream recovers what a mined receipt threw away. - Pass the whole ABI, and regenerate it. Custom errors are decoded from the ABI you hand viem, not from the chain. A stale artifact quietly turns three of this fixture's named errors into four-byte selectors.
- Take the hash to
cast run, without--quick. It named twelve of thirteen failures with no ABI and no source, and it is the only layer that reproduces a failure caused by another transaction in the same block. - Keep the selector when you paste a trace. The decoded name may be coming from your own machine's
~/.foundry/cache/signaturesand may not survive the trip to anyone else. - Reproduce it in a Forge test with the full
abi.encodeWithSelectorexpectation. Not the barebytes4, which does not mean what it looks like it means. - Read the lints. The failure that produces no revert at all is a static warning in both Foundry releases tested here, and nothing at runtime will tell you about it.
What this does not settle: it is one contract on one local chain. Real debugging adds a proxy between the caller and the logic, where a trace shows the delegatecall and a stale implementation ABI decodes nothing; it adds verified contracts on a public network, where cast run identifies traces through Sourcify, Etherscan and OpenChain — sources a local chain has nothing in, and which --disable-external-identification turns off; and it adds wallets, which rewrite error messages on their way to the user in ways no library controls. The gas figures are from a local chain with no competition for block space, and Foundry's isolate default moves them between releases, so treat them as shape rather than as numbers to compare against.
The thing that generalises is narrower and more useful: the revert reason exists for exactly one moment, in eth_call, and every layer after that is reconstruction. Two ways of replaying the same transaction disagree with each other by default. Know which one replays the block.