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 Ethers.js to parse custom error when call, estimateGas and transaction failed. Ethers.js version 6.5.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

call

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

Without parameters

1
2
3
4
5
6
try {
await myContract.callEmptyError();
} catch (e) {
// { "name": "EmptyError", "signature": "EmptyError()", "args": [] }
console.log(e?.revert);
}

With parameters

1
2
3
4
5
6
try {
await myContract.callErrorWithArgs();
} catch (e) {
// { "name": "ErrorWithArgs", "signature": "ErrorWithArgs(address)", "args": ["0xdd980c315dfa75682f04381e98ea38bd2a151540"] }
console.log(e?.revert);
}

estimateGas

In this case, the revert object cannot be obtained, only the raw data

Without parameters

1
2
3
4
5
6
7
8
9
try {
await myContract.sendEmptyError.estimateGas();
} catch (e) {
// null
console.log(e?.revert);

// 0x4f3d7def
console.log(e.data);
}

With parameters

1
2
3
4
5
6
try {
await myContract.sendErrorWithArgs.estimateGas();
} catch (e) {
// 0x034da6bc000000000000000000000000dd980c315dfa75682f04381e98ea38bd2a151540
console.log(e.data);
}

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
8
9
const selector = e.data.substring(0, 10);
const fragment = myContract.interface.fragments.find((fragment) => fragment.selector === selector);
if (fragment) {
const revert = {
name: fragment.name,
signature: fragment.format(),
args: myContract.interface.decodeErrorResult(fragment, e.data)
};
}

Transaction failed

Next is the processing after sending the transaction

1
2
3
4
5
6
7
8
9
10
// If the gasLimit is not set, it will execute estimateGas first, and can't send
const tx = await myContract.sendEmptyError({ gasLimit: 1000000 });

// It is not known whether the transaction was successful until the transaction is confirmed, so it won't throw error. Wait for confirmation and check.
const receipt = await tx.wait();

// if fail
if (!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
if (!receipt.status) {
try {
// Specify the node to simulate the state of a block number
const request: any = { blockTag: receipt.blockNumber };
['to', 'from', 'nonce', 'gasLimit', 'gasPrice', 'maxPriorityFeePerGas', 'maxFeePerGas', 'data', 'value', 'chainId', 'type', 'accessList'].forEach((key) => {
request[key] = tx[key];
});
// Execute a simulated transaction
await provider.call(request);
} catch (e) {
// will give the same result as estimateGas
// null
console.log(e?.revert);

// 0x4f3d7def
console.log(e.data);

// processing can be done in the above way
}
}

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.

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
function parseCustomError(error, contract) {
if (error?.revert) {
return error.revert;
}
const data = error.data;
if (typeof data !== 'string' || !data.startsWith('0x')) {
return null;
}
const selector = data.substring(0, 10);
const fragment = contract.interface.fragments.find((fragment) => fragment.selector === selector);
if (!fragment) {
return null;
}
return {
name: fragment.name,
signature: fragment.format(),
args: contract.interface.decodeErrorResult(fragment, data)
};
}

const KEYS = ['to', 'from', 'nonce', 'gasLimit', 'gasPrice', 'maxPriorityFeePerGas', 'maxFeePerGas', 'data', 'value', 'chainId', 'type', 'accessList'];
async function parseTxCustomError(tx, provider, contract) {
const request = { blockTag: tx.blockNumber };
KEYS.forEach((key) => {
request[key] = tx[key];
});
if (request.gasPrice) {
delete request.maxPriorityFeePerGas;
delete request.maxFeePerGas;
}
try {
await provider.call(request);
return null;
} catch (e) {
return parseCustomError(e, contract);
}
}

and the usage can be

call or estimateGas

1
2
3
4
5
try {
await myContract.callEmptyError();
} catch (e) {
const customError = parseCustomError(e, myContract);
}

transaction failed

1
2
3
4
5
6
7
8
9
10
const tx = await myContract.sendEmptyError({ gasLimit: 1000000 });

// or tx hash
// const tx = await provider.getTransaction(txHash);

const receipt = await tx.wait();
if (!receipt.status) {
tx.blockNumber = receipt.blockNumber;
const customError = await parseTxCustomError(tx, provider, myContract);
}

Further Reading

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