Web3.py Patterns: Revert Reason Lookups

When writing smart contracts, you're encouraged to include human-readable error messages. In Solidity, these can be declared within require or revert statements. For example:

function subtract(uint256 a, uint256 b) public pure returns (uint256) {
    require(b <= a, "(a) must be larger than (b)!");
    uint256 c = a - b;
    return c;
}

Error messages are collected and intelligently displayed within block explorers like Etherscan, but if you need to programmatically find out why a transaction failed, there's a little more legwork required.

If you fetch an Ethereum transaction, you'll find all the relevant information included in the sending of that transaction, e.g., to, from, value, and data. A transaction receipt, on the other hand, will indicate that a transaction failed, but the revert reason itself is not included.

One way to fetch a revert reason is to replay the transaction using the eth_call method. This RPC method is used to execute a transaction locally – nothing will be broadcasted to the network, but you'll see the outcome as if the transaction were really mined.

In order to replay a transaction, you need the context that the transaction was originally executed in, e.g., the balances in the accounts at the time of the transaction. eth_call makes this simple* by allowing you to pass in a block number that you want a transaction to be replayed at. Note that if the transaction was mined in block n, you'll want to replay it with the context in block n-1. The pseudocode looks something like this:

from web3 import Web3

w3 = Web3(<your-provider>)
w3.eth.call(replay_tx, block_number - 1)

*Note an important limitation here: the replayed transaction will be executed in isolation. This means that transactions that occurred prior to your original transaction within the same block will not be accounted for. If that's a deal-breaker for you, skip to the update at the end of this post.

There is a catch. By default, Ethereum clients don't store all the historical context required to do lookups like this for older transactions. This role is performed by "archive" nodes, which require significantly more storage space. If you aren't running your own archive node, you'll need to use a provider that does support this capability.

Putting it all together – a transaction hash is all that's required to prepare the replay transaction. Fetching that transaction provides the inputs of that transaction, as well as the block it was mined in. See the full code sample, using a randomly selected Uniswap transaction failure, below:

import os
from web3 import Web3, HTTPProvider 

# instantiate your (archive-capable) provider:
w3 = Web3(HTTPProvider(os.environ['MAINNET_URL']))

# fetch a reverted transaction:
tx = w3.eth.get_transaction('0x2e7aa4314eeb171d4...')

# build a new transaction to replay:
replay_tx = {
    'to': tx['to'],
    'from': tx['from'],
    'value': tx['value'],
    'data': tx['input'],
}

# replay the transaction locally:
try:
    w3.eth.call(replay_tx, tx.blockNumber - 1)
except Exception as e: 
    print(e)
    # execution reverted: UniswapV2Router: INSUFFICIENT_OUTPUT_AMOUNT

Update: if this solution does not satisfy your use case due to a need to account for earlier transactions in the same block, you may consider tools like Tenderly or custom RPC methods like Otterscan's ots_getTransactionError using Erigon.

Thanks to pintail for valuable feedback on this post.