The previous article Solidity - Custom Error mentioned how to use custom error in smart contracts. This article continues this topic and explains how to use Web3.js to parse custom error when call, estimateGas and transaction failed. Web3.js version 4.0.1 is used here, tested at Arbitrum Goerli.

Example Contract

First, we prepare a contract as follows:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
error EmptyError();
error ErrorWithArgs(address user);

contract MyContract {
function sendEmptyError() external {
revert EmptyError();
}

function callEmptyError() external pure {
revert EmptyError();
}

function sendErrorWithArgs() external {
revert ErrorWithArgs(msg.sender);
}

function callErrorWithArgs() external view {
revert ErrorWithArgs(msg.sender);
}
}

It contains send and call functions, and also contains custom errors with and without parameters.

Parsing

The behavior of the built-in provider and the injected provider of the browser is different. Explain one by one below.

Built-in Provider

This section is a built-in provider created directly using the URL, for example:

1
new Web3('https://xxxx');

call

The library has already processed the call. If the node supports custom error, you can get the innerError object.

Without parameters

1
2
3
4
5
6
try {
await myContract.methods.callEmptyError({ from });
} catch (e) {
// { "name": "Eip838ExecutionError", "code": 3, "message": "execution reverted", "data": "0x4f3d7def", "errorName": "EmptyError", "errorSignature": "EmptyError()", "errorArgs": { "__length__": 0 } }
console.log(JSON.stringify(e.innerError));
}

With parameters

1
2
3
4
5
6
try {
await myContract.methods.callErrorWithArgs({ from });
} catch (e) {
// { "name": "Eip838ExecutionError", "code": 3, "message": "execution reverted", "data": "0x034da6bc000000000000000000000000dd980c315dfa75682f04381e98ea38bd2a151540","errorName": "ErrorWithArgs","errorSignature": "ErrorWithArgs(address)", "errorArgs": { "0": "0xdd980c315dfa75682f04381e98ea38bd2a151540", "__length__": 1, "user": "0xdd980c315dfa75682f04381e98ea38bd2a151540" } }
console.log(JSON.stringify(e.innerError));
}

estimateGas

In this case, although there is an innerError object, there is only raw data.

Without parameters

1
2
3
4
5
6
try {
await myContract.methods.sendEmptyError().estimateGas({ from });
} catch (e) {
// { "name": "Eip838ExecutionError", "code": 3, "message": "execution reverted", "data": "0x4f3d7def" }
console.log(JSON.stringify(e.innerError));
}

With parameters

1
2
3
4
5
6
try {
await myContract.methods.sendErrorWithArgs().estimateGas({ from });
} catch (e) {
// { "name": "Eip838ExecutionError", "code": 3, "message": "execution reverted", "data": "0x034da6bc000000000000000000000000dd980c315dfa75682f04381e98ea38bd2a151540" }
console.log(JSON.stringify(e.innerError));
}

I don’t know why the library does not handle it. We can only parse it by ourselves.

1
2
3
4
5
6
7
import { decodeContractErrorData } from 'web3-eth-abi';

// Parse errorName, errorSignature, errorArgs
decodeContractErrorData(myContract._errorsInterface, e.innerError);

// Parsed data will appear
console.log(JSON.stringify(e.innerError));

Transaction failed

Next is the processing after sending the transaction

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
try {
// Using private key to sign the transaction
const data = contract.methods.sendEmptyError().encodeABI();
const signedTx = await web3.eth.accounts.signTransaction({
// ....
}, privateKey);

// Since estimateGas will be executed first by default and can't send. Skip here for testing
await web3.eth.sendSignedTransaction(signedTx.rawTransaction, undefined, { checkRevertBeforeSending: false });
} catch (e: any) {
// transaction failed
if (e.receipt && !e.receipt.status) {
// start parsing...
}
}

At this time, we will find that neither the tx object nor the receipt has any available data. At this time, we can only use a strange way to achieve.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
if (e.receipt && !e.receipt.status) {
try {
const tx = await web3.eth.getTransaction(e.receipt.transactionHash);
const request: any = { };
['to', 'from', 'nonce', 'gas', 'gasPrice', 'maxPriorityFeePerGas', 'maxFeePerGas', 'data', 'value', 'chainId', 'type', 'accessList'].forEach((key) => {
request[key] = (tx as any)[key];
});
if (request.gasPrice) {
delete request.maxPriorityFeePerGas;
delete request.maxFeePerGas;
}
// Specify the node to simulate the state of a block number
await web3.eth.call(request, tx.blockNumber);
} catch (e2) {
// will give the same result as estimateGas

// { "name": "Eip838ExecutionError", "code": 3, "message": "execution reverted", "data": "0x4f3d7def" }
console.log(JSON.stringify(e2.innerError));

// processing can be done in the above way
decodeContractErrorData(contract._errorsInterface, e2.innerError);
console.log(JSON.stringify(e2.innerError));
}
}

It should be noted that if blockTag is specified as a block number, there is a time limit. Bblocks that are too long ago cannot be executed.

Browser Provider

This section is to use something like Metamask. For example:

1
new Web3(window.ethereum);

call

Unlike the built-in provider, there is no innerError object available.

Without parameters

1
2
3
4
5
6
try {
await myContract.methods.callEmptyError({ from });
} catch (e) {
// {"name":"ResponseError","code":100,"message":"Returned error: Internal JSON-RPC error.","data":{"code":3,"message":"execution reverted","data":"0x4f3d7def"}}
console.log(JSON.stringify(e));
}

With parameters

1
2
3
4
5
6
try {
await myContract.methods.callErrorWithArgs({ from });
} catch (e) {
// {"name":"ResponseError","code":100,"message":"Returned error: Internal JSON-RPC error.","data":{"code":3,"message":"execution reverted","data":"0x034da6bc000000000000000000000000dd980c315dfa75682f04381e98ea38bd2a151540"}}
console.log(JSON.stringify(e));
}

However, there are data we need that can be used

1
2
3
4
5
6
7
8
9
10
11
import { Eip838ExecutionError } from 'web3';
import { decodeContractErrorData } from 'web3-eth-abi';

// create innerError object first
e.innerError = new Eip838ExecutionError(e.data);

// Parse errorName, errorSignature, errorArgs
decodeContractErrorData(myContract._errorsInterface, e.innerError);

// Parsed data will appear
console.log(JSON.stringify(e.innerError));

estimateGas

The behavior and processing method of estimateGas is exactly the same as that of the call above.

Transaction failed

Next is the processing after sending the transaction

1
2
3
4
5
6
7
8
9
try {
// Use send directly. Since estimateGas cannot be executed, set a gas so that it can send.
await contract.methods.sendEmptyError().send({ from, gas: '1000000' });
} catch (e: any) {
// transaction failed
if (e.receipt && !e.receipt.status) {
// start parsing...
}
}

After entering the catch, use the method of the previous transaction failed section, and create innerError object first.

1
2
3
4
5
6
7
8
9
try {
// ...
await web3.eth.call(request, tx.blockNumber);
} catch (e2) {
// create innerError object first
e2.innerError = new Eip838ExecutionError(e2.data);

// ...
}

Summarize

Organize the above code into functions as follows:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
import { Eip838ExecutionError } from 'web3';
import { decodeContractErrorData } from 'web3-eth-abi';

function parseInnerError(e, contract) {
if (e?.innerError?.errorSignature) {
return e.innerError;
}

if (!e.innerError) {
if (e?.data?.data.startsWith('0x')) {
e.innerError = new Eip838ExecutionError(e.data);
}
}
if (e?.innerError?.data?.startsWith('0x')) {
decodeContractErrorData(contract._errorsInterface, e.innerError);
}
return e.innerError;
}

const KEYS = ['to', 'from', 'nonce', 'gas', 'gasPrice', 'maxPriorityFeePerGas', 'maxFeePerGas', 'data', 'value', 'chainId', 'type', 'accessList'];
async function parseTxError(txHash, web3, contract) {
const tx = await web3.eth.getTransaction(txHash);
const request = { };
KEYS.forEach((key) => {
request[key] = tx[key];
});
if (request.gasPrice) {
delete request.maxPriorityFeePerGas;
delete request.maxFeePerGas;
}
try {
await web3.eth.call(request, tx.blockNumber);
return null;
} catch (e) {
return parseInnerError(e, contract);
}
}

and the usage can be

call or estimateGas

1
2
3
4
5
try {
await myContract.methods.callEmptyError({ from });
} catch (e) {
const innerError = parseInnerError(e, myContract);
}

transaction failed

1
2
3
4
5
6
7
try {
await contract.methods.sendEmptyError().send({ from, gas: '1000000' });
} catch (e) {
if (e.receipt && !e.receipt.status) {
const innerError = await parseTxError(e.receipt.transactionHash, web3, contract);
}
}

Further Reading

Solidity - Custom Error
Using Ethers.js to Parse Custom Error