【Ethereum】revertしたトランザクションのデバッグ方法

# 1.背景
業務の中で、過去にrevertしたTxについて詳細を調査する機会があったのがきっかけです。
一般的なweb3アプリのデバッグであれば共通して使えるノウハウなので、備忘録としても記載します。

:::note info
本記事の主目的にEthereumの学習があるため、デバッグではJSON-RPCで得られたデータに対し、RLPエンコード/デコードを行っています。
あくまで実装が目的であれば、ethers.js等のライブラリを使用して、RLP等の内部処理を意識する必要なく実装できます。
:::

# 2. 開発環境
- OS: Windows10
- 言語(実行環境): node.js 20.11.0
- フレームワーク: Hardhat 2.19.4 
# 3.コントラクトのデプロイ
今回使用するスマートコントラクトのソースです。
``` Hello.sol
// SPDX-License-Identifier: UNLICENSED
pragma solidity ^0.8.2 <0.9.0;

contract Hello {
    uint256 response = 0;
    function hello(string memory word) public returns (uint256) {
        require(
            keccak256(abi.encodePacked(word)) ==
                keccak256(abi.encodePacked("hello")),
            "word is not 'hello'"
        );
        response = 1;
        return response;
    }
}
```
関数hello()を実行するときに、引数として「hello」を渡せば正常終了します。
それ以外の文字列を渡せばRequireに引っ掛かり、Revertが発生します。


これをSepoliaテストネットにデプロイします。
参考までですが、以下がデプロイに使用したソースです。ノードはAlchemyのものを使用しています。

``` deploy.js
const { ethers } = require("hardhat");
const abiFile = require("./artifacts/contracts/Hello.sol/Hello.json")
async function deploy() {
    const provider = ethers.getDefaultProvider(process.env.ALCHEMY_URL)
    const wallet = new ethers.Wallet(process.env.ACCOUNT_PRIVATE_KEY, provider);
    const contractFactory = new ethers.ContractFactory(abiFile.abi, abiFile.bytecode, wallet);
    const contract = await contractFactory.deploy();
    console.log(contract);
}
deploy()
```
コントラクトアドレスは `0xe13dc4318f77ebb2d70ef52a643d34b56b9692b2`となりました。



# 4.コントラクトのデバッグ
では、デプロイしたコントラクトをデバッグしていきます。
以下、デバッグ用のコードです。Hardhatに内蔵されたethers.jsを使用しています。
```debug.sol
const { ethers } = require("hardhat");
const abiFile = require("./artifacts/contracts/Hello.sol/Hello.json")

const deployedAddress = "0xE13Dc4318F77ebB2D70eF52A643D34B56B9692b2";
const provider = ethers.getDefaultProvider(process.env.ALCHEMY_URL)
const wallet = new ethers.Wallet(process.env.ACCOUNT_PRIVATE_KEY, provider);
const abi = new ethers.Interface(abiFile.abi)

async function callTx() {
    const data = abi.encodeFunctionData("hello", ["hello"]);
    console.log("callTx data: \n" + data);
    const unsignedTx = {
        to: deployedAddress,
        data
    }
    const tx = await wallet.call(unsignedTx);
    console.log("callTx result: \n" + tx);
}

async function sendTx() {
    const data = abi.encodeFunctionData("hello", ["hello"]);
    console.log("sendTx data: \n" + data);
    const unsignedTx = {
        to: deployedAddress,
        data
    }
    const tx = await wallet.sendTransaction(unsignedTx);
    const txReceipt = await tx.wait()
    console.log("sendTx request: \n" + JSON.stringify(tx, null, 1));
    console.log("sendTx receipt: \n" + JSON.stringify(txReceipt, null, 1));
}

async function callRevertTx() {
    const data = abi.encodeFunctionData("hello", ["world"]);
    console.log("callRevertTx data: \n" + data);
    const unsignedTx = {
        to: deployedAddress,
        data
    }
    const tx = await wallet.call(unsignedTx);
    console.log("callRevertTx result: \n" + tx);
}

async function sendRevertTx() {
    const data = abi.encodeFunctionData("hello", ["hello"]);
    const sender = await wallet.getAddress();
    const unsignedTx = {
        from: sender,
        to: deployedAddress,
        data: data
    }
    // populateTransactionでunsignerTxで未設定のパラメータを自動設定する
    const populatedTx = await wallet.populateTransaction(unsignedTx)
    // populateTransactionは内部でcallを行い、問題があれば例外を投げる
    // 引数が正しくないTxを投げて無理やりRevertさせるために、populateTransactionのあとにdataをRevert用の値に書き換える
    populatedTx.data = abi.encodeFunctionData("hello", ["world"]);
    console.log("sendRevertTx data: \n" + populatedTx.data);
    const signedTx = await wallet.signTransaction(populatedTx);
    console.log("sendRevertTx signedTx: \n" + signedTx);
    const tx = await wallet.sendTransaction(signedTx);
    console.log("sendRevertTx request: \n" + JSON.stringify(tx, null, 1));
    console.log("---------------------------------------------")
    const txReceipt = await tx.wait()
    console.log("sendRevertTx tx receipt: \n" + JSON.stringify(txReceipt, null, 1));
}
```

また、ethers.jsを挟まない生のJSON-RPC APIリクエストには、Alchemyのコンソールから選択できる「Sandbox」という機能を使います（curlを使っても問題はありません）。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3673338/06ea9750-e17b-ccbb-0460-c6ac9c6de154.png)
## 4.1.Tx成功時
### 4.1.1.Txのシミュレート
実際にTx送信を行う前に、`eth_call`をリクエストしてみます。

```
async function callTx() {
    const data = abi.encodeFunctionData("hello", ["hello"]);
    console.log("callTx data: \n" + data);
    const unsignedTx = {
        to: deployedAddress,
        data
    }
    const tx = await wallet.call(unsignedTx);
    console.log("callTx result: \n" + tx);
}
```
出力結果
```
callTx data: 
0xa777d0dc0000000000000000000000000000000000000000000000000000000000000020000000000000000000000000000000000000000000000000000000000000000568656c6c6f000000000000000000000000000000000000000000000000000000
callTx result: 
0x0000000000000000000000000000000000000000000000000000000000000001
```
関数hello()は、正常終了すると1を返すように定義されていました。
resultに注目すると、想定通りの結果が返却されていることを確認できます(0が多いのは、dataが32バイトごとに整形され、余った部分には0が入るためです)。



:::note info
**dataの内容**
dataは、先頭4バイトの**Function selector** (どの関数を呼び出すかを表す)と、それ以降の**Argument Encoding**（関数に渡す引数を表す）から成ります。
Function selectorは、呼び出したい関数のFunction Signatureをkecchack256でハッシュ化した値の先頭4バイトが入ります。今回は、`a777d0dc`が該当します。
Argument Encodingは、渡す引数の順番で、32バイトごとで表されます。
dataから上記のFunction selecterを抜いた、`0000000000000000000000000000000000000000000000000000000000000020000000000000000000000000000000000000000000000000000000000000000568656c6c6f000000000000000000000000000000000000000000000000000000`が該当します。
Argument Encodingは32バイトごとに区切られます。また、引数の型によって(可変長の型か固定長の型かによって)表現方式が変わります。
今回は"Hello"という文字列を渡しています。文字列は以下の順で表現されます。
・値を展開するオフセット値
・値のデータ数(バイト単位)
・値の本体
Argument Encodingを32バイトで区切り、上記のルールで読み解くと、以下のようになります。
```
0000000000000000000000000000000000000000000000000000000000000020 ←値を展開するオフセット値
0000000000000000000000000000000000000000000000000000000000000005 ←値のデータ数(バイト単位)
68656c6c6f000000000000000000000000000000000000000000000000000000 ←値の本体
```
例えば以下のサイトで値をデコードすると、"hello"という文字列が渡されていることを確認できます。

https://dencode.com/ja/string/hex

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3673338/f1c0c155-9f00-c019-3bff-ec3a89400a11.png)
:::

### 4.1.2.Txの送信と結果取得
次に、実際にTxをブロックチェーンに送信し、Tx完了後にその結果を取得します。
```
async function sendTx() {
    const data = abi.encodeFunctionData("hello", ["hello"]);
    console.log("sendTx data: \n" + data);
    const unsignedTx = {
        to: deployedAddress,
        data
    }
    const tx = await wallet.sendTransaction(unsignedTx);
    const txReceipt = await tx.wait()
    console.log("sendTx request: \n" + JSON.stringify(tx, null, 1));
    console.log("sendTx receipt: \n" + JSON.stringify(txReceipt, null, 1));
}
```
出力結果
```
sendTx data:
0xa777d0dc0000000000000000000000000000000000000000000000000000000000000020000000000000000000000000000000000000000000000000000000000000000568656c6c6f000000000000000000000000000000000000000000000000000000
sendTx request: 
{
 "_type": "TransactionResponse",
 "accessList": [],
 "blockNumber": null,
 "blockHash": null,
 "blobVersionedHashes": null,
 "chainId": "11155111",
 "data": "0xa777d0dc0000000000000000000000000000000000000000000000000000000000000020000000000000000000000000000000000000000000000000000000000000000568656c6c6f000000000000000000000000000000000000000000000000000000",
 "from": "0x3268Af767738d0608B1416AE2f83e5975b73d69C",
 "gasLimit": "26117",
 "gasPrice": null,
 "hash": "0x04ed883d4e0b16aa612adc25f37052d8eab100a51ae88cee96bf4a2bd76ef6d4",
 "maxFeePerGas": "500000033",
 "maxPriorityFeePerGas": "499999977",
 "maxFeePerBlobGas": null,
 "nonce": 18,
 "signature": {
  "_type": "signature",
  "networkV": null,
  "r": "0x24776ec444cadc4a893f058034f4693dd2871ecefec6e5dd668e63f6edae5ef4",
  "s": "0x3db274373e1e367302a5f81938afa11214ec3deadd36f97634cd3083c3156edd",
  "v": 27
 },
 "to": "0xE13Dc4318F77ebB2D70eF52A643D34B56B9692b2",
 "type": 2,
 "value": "0"
}
sendTx receipt: 
{
 "_type": "TransactionReceipt",
 "blockHash": "0xb4e5e976c613c399dcb5e4632e8be53c5b480409096a51cffc5a344bb569f378",
 "blockNumber": 5758025,
 "contractAddress": null,
 "cumulativeGasUsed": "7352204",
 "from": "0x3268Af767738d0608B1416AE2f83e5975b73d69C",
 "gasPrice": "500000003",
 "blobGasUsed": null,
 "blobGasPrice": null,
 "gasUsed": "25775",
 "hash": "0x04ed883d4e0b16aa612adc25f37052d8eab100a51ae88cee96bf4a2bd76ef6d4",
 "index": 76,
 "logs": [],
 "logsBloom": "0x00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000",
 "status": 1,
 "to": "0xE13Dc4318F77ebB2D70eF52A643D34B56B9692b2"
}
```

`sendTx receipt`以下の部分で、Txレシートを表示しています。`status`が1となっていることから、Txが成功していることがわかります(0=失敗 1=成功)。


## 4.2.Tx失敗時
### 4.2.1.Txのシミュレート
次に、Revertが想定されたTxのシミュレートを行います。
処理としては、関数hello()に引数として「world」を渡します。
```
async function callRevertTx() {
    const data = abi.encodeFunctionData("hello", ["world"]);
    console.log("callRevertTx data: \n" + data);
    const unsignedTx = {
        to: deployedAddress,
        data
    }
    const tx = await wallet.call(unsignedTx);
    console.log("callRevertTx result: \n" + tx);
}
```
出力結果
```
callRevertTx data: 
0xa777d0dc00000000000000000000000000000000000000000000000000000000000000200000000000000000000000000000000000000000000000000000000000000005776f726c64000000000000000000000000000000000000000000000000000000
C:\develop\tutorial_debug_contract\node_modules\ethers\lib.commonjs\utils\errors.js:129
            error = new Error(message);
                    ^

Error: execution reverted: "word is not 'hello'" (action="call", data="0x08c379a000000000000000000000000000000000000000000000000000000000000000200000000000000000000000000000000000000000000000000000000000000013776f7264206973206e6f74202768656c6c6f2700000000000000000000000000", reason="word is not 'hello'", transaction={ "data": "0xa777d0dc00000000000000000000000000000000000000000000000000000000000000200000000000000000000000000000000000000000000000000000000000000005776f726c64000000000000000000000000000000000000000000000000000000", "from": "0x3268Af767738d0608B1416AE2f83e5975b73d69C", "to": "0xE13Dc4318F77ebB2D70eF52A643D34B56B9692b2" }, invocation=null, revert={ "args": [ "word is not 'hello'" ], "name": "Error", "signature": "Error(string)" }, code=CALL_EXCEPTION, version=6.12.0)
 ```
ethers.jsから、Revertしたことを伝えるExceptionが投げられました。エラーメッセージの中に、Revert時のメッセージ(revertReason)も含まれています。

ethers.js以外のライブラリにも応用するため、JSON-RPC APIを使用しても同様の結果が得られるか確認します

リクエスト
```
{
  "id": 1,
  "jsonrpc": "2.0",
  "method": "eth_call",
  "params": [
    {
      "from": "0x3268Af767738d0608B1416AE2f83e5975b73d69C",
      "to": "0xE13Dc4318F77ebB2D70eF52A643D34B56B9692b2",
      "data": "0xa777d0dc00000000000000000000000000000000000000000000000000000000000000200000000000000000000000000000000000000000000000000000000000000005776f726c64000000000000000000000000000000000000000000000000000000"
    }
  ]
}
```
レスポンス
```
{
jsonrpc:"2.0",
id:1,
error:{
code:3,
message:"execution reverted: word is not 'hello'",
data:"0x08c379a000000000000000000000000000000000000000000000000000000000000000200000000000000000000000000000000000000000000000000000000000000013776f7264206973206e6f74202768656c6c6f2700000000000000000000000000"
}
}

```
レスポンスの`message`から、revertReason取得を確認できました。

また、`data`をデコードしても、同様に取得できます。
```
// data
0x08c379a000000000000000000000000000000000000000000000000000000000000000200000000000000000000000000000000000000000000000000000000000000013776f7264206973206e6f74202768656c6c6f2700000000000000000000000000

// dataのFunction selector
0x08c379a0

// dataのFunction　Encording
0000000000000000000000000000000000000000000000000000000000000020 ←値を展開するオフセット値
0000000000000000000000000000000000000000000000000000000000000013 ←値のデータ数(バイト単位)
776f7264206973206e6f74202768656c6c6f2700000000000000000000000000 ←値の本体
```
値本体のデコード
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3673338/09a6b2c8-3ed8-2107-0f1c-0c316be5ab22.png)



### 4.2.2.Txの送信と結果取得
最後に、Revertが想定されたTxをブロックチェーンに送信し、Tx完了後に結果を取得します。

```
async function sendRevertTx() {
    const data = abi.encodeFunctionData("hello", ["hello"]);
    const sender = await wallet.getAddress();
    const unsignedTx = {
        from: sender,
        to: deployedAddress,
        data: data
    }
    // populateTransactionで、unsignedTxで未設定のパラメータを自動設定する
    const populatedTx = await wallet.populateTransaction(unsignedTx)
    // populateTransactionは内部でcallを行い、問題があれば例外を投げる
    // 引数が正しくないTxを投げて無理やりRevertさせるために、populateTransactionのあとにRevert用の値に書き換える
    populatedTx.data = abi.encodeFunctionData("hello", ["world"]);
    console.log("sendRevertTx data: \n" + populatedTx.data);
    const signedTx = await wallet.signTransaction(populatedTx);
    console.log("sendRevertTx signedTx: \n" + signedTx);
    const tx = await wallet.sendTransaction(signedTx);
    console.log("sendRevertTx request: \n" + JSON.stringify(tx, null, 1));
    console.log("---------------------------------------------")
    const txReceipt = await tx.wait()
    console.log("sendRevertTx tx receipt: \n" + JSON.stringify(txReceipt, null, 1));
}
```


こちらを実行したところ、 `wallet.sendTransaction(signedTx)`でエラーが出てしまい、Tx送信ができませんでした(Revertが確実なTxはethers.jsでは送信できないということなので、むしろ安心できる機能ですが)
 
 Sandboxから署名済みdataを用いてTx送信してみたところ、Revertさせることができました。以下はSepolia scanでみたTx情報です。
 ![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3673338/2892ff9f-158d-ffd1-3c3f-9ed01dd8b0ad.png)

etherscanは親切なのですでにrevertReason表示してくれていますが、プライベートチェーン等ではそう簡単には見つけられないはずです。Txハッシュをもとに、JSON-RPC経由でrevertReasonを探れないか試します。

まずは`eth_getTransactionReceipt`を実行してみます。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3673338/9649a738-ba48-715b-ab10-8f000ee81da9.png)

レスポンス
```
{
jsonrpc:"2.0",
id:1,
result:{
transactionHash:"0x7229f9d8000a477c4b682ad9b64eca05a457859290d3d24f00166124dda9aa6a",
blockHash:"0xeb60cfc89c26336dd02bc53cb6df7daa61e4be39ed19d00b92155409083d460b",
blockNumber:"0x57dc9a",
logsBloom:"0x00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000",
gasUsed:"0x5c00",
contractAddress:null,
cumulativeGasUsed:"0x45f461",
transactionIndex:"0x34",
from:"0x3268af767738d0608b1416ae2f83e5975b73d69c",
to:"0xe13dc4318f77ebb2d70ef52a643d34b56b9692b2",
type:"0x2",
effectiveGasPrice:"0x1dcd64ff",
logs:[
],
status:"0x0"
}
}
```
`status`が0なのでTxが失敗していることはわかりますが、Revertメッセージは取得できませんでした。

他にデバッグに使えそうなJSON-RPC APIがないかethereum foundationのリファレンスを探るも、有用なものはなく・・・と思ったのですが、リファレンスのリンクから飛べる以下のサイトで、`debug_traceTransaction`なるAPIの記述を見つけました。

https://ethereum-json-rpc.com/

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3673338/a95f91e0-b6cb-0749-752d-2ba97b560fb4.png)

debug_traceTransactionでは、**実行されたTxを再現したcallを行う**ようです。
ただし標準ではないAPIとのことで、ethereum foundationのリファレンスに載っていなかったのはそのためかもしれません。
とはいえGethやBesu、Nethermindといった主要なクライアントにも実装されているので、基本的には使用できると考えてよさそうです。

https://geth.ethereum.org/docs/interacting-with-geth/rpc/ns-debug

https://besu.hyperledger.org/stable/public-networks/reference/api

https://docs.nethermind.io/interacting/json-rpc-ns/debug/#debug_tracetransaction


 Sandboxでも対応していた(しかもAlchemy独自のオプションが追加されていた)ので、試してみます。
 ![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3673338/95c18dd2-2ec5-2bf4-4ec0-2a73b17f3b56.png)

レスポンス
```
{
jsonrpc:"2.0",
id:1,
result:{
from:"0x3268af767738d0608b1416ae2f83e5975b73d69c",
gas:"0x6605",
gasUsed:"0x5c00",
to:"0xe13dc4318f77ebb2d70ef52a643d34b56b9692b2",
input:"0xa777d0dc00000000000000000000000000000000000000000000000000000000000000200000000000000000000000000000000000000000000000000000000000000005776f726c64000000000000000000000000000000000000000000000000000000",
output:"0x08c379a000000000000000000000000000000000000000000000000000000000000000200000000000000000000000000000000000000000000000000000000000000013776f7264206973206e6f74202768656c6c6f2700000000000000000000000000",
error:"execution reverted",
revertReason:"word is not 'hello'",
value:"0x0",
type:"CALL"
}
}
```
無事にrevertReasonを取得することができました。


# 5.まとめ
本記事での実践を通じて以下のことを学びました。
- Txの`data`プロパティから、スマートコントラクトの関数に渡した引数を確認できる
- Txのシミュレート(call)であればrevertReasonは取得できる
- Tx送信(send)後の場合は、`debug_traceTransaction`により、revertしたTxを再現したcallを行うことで、revertReasonは取得できる

そもそもrevertと同時にeventをemitするスマートコントラクトが本来は望ましいのですが、そうではなかった場合もデバッグはできる、ということを知れたのは大きな収穫でした。