1
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

NTTデータ先端技術株式会社 デジタルソリューション事業部Advent Calendar 2024

Day 3

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

Last updated at Posted at 2024-12-03

1.背景

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

本記事の主目的に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

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が入るためです)。

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

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

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

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

まずはeth_getTransactionReceiptを実行してみます。
image.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の記述を見つけました。

image.png

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

Sandboxでも対応していた(しかもAlchemy独自のオプションが追加されていた)ので、試してみます。
image.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するスマートコントラクトが本来は望ましいのですが、そうではなかった場合もデバッグはできる、ということを知れたのは大きな収穫でした。

1
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
1
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?