之前的文章 Solidity 智能合約 - Custom Error 中提到如何在智能合約中使用 Custom Error,這篇文章延續這個主題,說明使用 Web3.js 在 call、estimateGas 和交易失敗時,如何解析 Custom Error。這裡使用 Web3.js 4.0.1 版本,在 Arbitrum Goerli 進行測試。

範例合約

首先我們先準備一個合約如下:

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);
}
}

其中包含 send 和 call 的函式,也分別包含有參數和無參數的 Custom Error。

解析

內建 Provider 和瀏覽器的注入的 Provider 行為不同,以下分開說明。

內建 Provider

這個章節是直接使用網址建立的內建 Provider,例如

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

call

call 的時候內建已經處理好,如果節點有支援錯誤訊息,可以取得 innerError 物件。

無參數

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));
}

有參數

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

這個情況雖然有 innerError 物件,但只有 data,沒有解析

無參數

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));
}

有參數

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));
}

不知道為什麼內建不處理,我們只能自己進一步解析

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

// 解析出 errorName, errorSignature, errorArgs
decodeContractErrorData(myContract._errorsInterface, e.innerError);

// 會出現解析的資料
console.log(JSON.stringify(e.innerError));

交易失敗

再來是送出交易後的處理

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
try {
// 這邊使用私鑰的方式來簽署交易
const data = contract.methods.sendEmptyError().encodeABI();
const signedTx = await web3.eth.accounts.signTransaction({
// ....
}, privateKey);

// 由於預設會先執行 estimateGas 會送不出去,這邊為了測試讓他略過
await web3.eth.sendSignedTransaction(signedTx.rawTransaction, undefined, { checkRevertBeforeSending: false });
} catch (e: any) {
// 交易失敗
if (e.receipt && !e.receipt.status) {
// 開始解析
}
}

這時候我們會發現不管是 tx 物件還是 receipt 都沒有可用的資料,這時候只能用一個比較奇怪的方式達成

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;
}
// 指定節點模擬某個塊的當下狀態
await web3.eth.call(request, tx.blockNumber);
} catch (e2) {
// 會得到和 estimateGas 相同的結果

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

// 就可以用上面的方式處理
decodeContractErrorData(contract._errorsInterface, e2.innerError);
console.log(JSON.stringify(e2.innerError));
}
}

要注意的是,如果指定為某個區塊,有一個時間限制,太久以前的區塊是無法執行的。

瀏覽器 Provider

這個章節是使用 Metamask 之類的方式,例如

1
new Web3(window.ethereum);

call

和內建 Provider 不同,沒有 innerError 物件可以使用

無參數

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));
}

有參數

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));
}

不過 data 中有我們需要的資料可以使用

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

// 先產生 innerError
e.innerError = new Eip838ExecutionError(e.data);

// 解析出 errorName, errorSignature, errorArgs
decodeContractErrorData(myContract._errorsInterface, e.innerError);

// 會出現解析的資料
console.log(JSON.stringify(e.innerError));

estimateGas

estimateGas 的行為和處理方式,與上面 call 完全一樣,就不重複贅述。

交易失敗

再來是送出交易後的處理

1
2
3
4
5
6
7
8
9
try {
// 直接用 send, 由於無法執行 estimateGas,設定一個 gas 讓他可以送出
await contract.methods.sendEmptyError().send({ from, gas: '1000000' });
} catch (e: any) {
// 交易失敗
if (e.receipt && !e.receipt.status) {
// 開始解析
}
}

進入 catch 後使用上一個交易失敗章節的方式處理,但在多加上上面產生 innerError 的動作

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

// ...
}

總結

把上面的程式整理成函式如下

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);
}
}

然後可以這樣使用

call 或 estimateGas

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

交易失敗

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);
}
}

延伸閱讀

Solidity 智能合約 - Custom Error
使用 Ethers.js 解析 Custom Error