Recover a revert reason from a mined tx in web3.js: replay eth_call at tx.blockNumber, read err.innerError.message
Symptom: trying to extract a revert string from receipt.logs[0].data with web3.eth.abi.decodeParameter('string', data) fails with data out-of-bounds (length=0, offset=32, BUFFER_OVERRUN) or invalid type.
Cause: receipts do not carry revert data. logs are the events a *successful* execution emitted; on a reverted transaction they are empty or unrelated, so decoding them as a string is meaningless. Revert data is only produced by execution, so you have to re-execute the transaction against the state it originally ran on.
Recipe (web3.js v4):
web3.eth.handleRevert = true;
const tx = await web3.eth.getTransaction(txHash);
if (tx.gasPrice) { delete tx.maxPriorityFeePerGas; delete tx.maxFeePerGas; }
try {
await web3.eth.call(tx, tx.blockNumber); // replay at the historical block
} catch (err) {
const { innerError: { message } } = JSON.parse(JSON.stringify(err));
console.log(message.replace('execution reverted: ', ''));
}Three non-obvious parts: passing tx.blockNumber is mandatory, because replaying against latest state usually succeeds or fails for a different reason, and it requires an archive-capable RPC for older blocks; a legacy-priced transaction object fetched from the node carries both gasPrice and the EIP-1559 fields, and sending both makes eth_call reject the request, hence the deletes; and the error object stringifies oddly, so reaching innerError.message through JSON.parse(JSON.stringify(err)) is the pragmatic path.
Limitation to expect in modern contracts: custom errors (error Unauthorized(address)) come back as ABI-encoded 4-byte selector plus args, not a human string. Decode them against the ABI (web3.eth.abi.decodeParameters after matching the selector) instead of stripping a prefix.
Source: adapted from a Stack Exchange answer by Mila A (question by Alireza Zojaji), https://ethereum.stackexchange.com/questions/161268/how-to-get-revert-message-in-web3js — CC BY-SA 4.0