1.背景
業務の中で、過去にrevertしたTxについて詳細を調査する機会があったのがきっかけです。
一般的なweb3アプリのデバッグであれば共通して使えるノウハウなので、備忘録としても記載します。
本記事の主目的にEthereumの学習があるため、デバッグではJSON-RPCで得られたデータに対し、RLPエンコード/デコードを行っています。
あくまで実装が目的であれば、ethers.js等のライブラリを使用して、RLP等の内部処理を意識する必要なく実装できます。
2. 開発環境
- OS: Windows10
- 言語(実行環境): node.js 20.11.0
- フレームワーク: Hardhat 2.19.4
3.コントラクトのデプロイ
今回使用するスマートコントラクトのソースです。
// 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のものを使用しています。
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を使用しています。
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を使っても問題はありません)。
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"という文字列が渡されていることを確認できます。
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 ←値の本体
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情報です。
etherscanは親切なのですでにrevertReason表示してくれていますが、プライベートチェーン等ではそう簡単には見つけられないはずです。Txハッシュをもとに、JSON-RPC経由でrevertReasonを探れないか試します。
まずはeth_getTransactionReceipt
を実行してみます。
レスポンス
{
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の記述を見つけました。
debug_traceTransactionでは、実行されたTxを再現したcallを行うようです。
ただし標準ではないAPIとのことで、ethereum foundationのリファレンスに載っていなかったのはそのためかもしれません。
とはいえGethやBesu、Nethermindといった主要なクライアントにも実装されているので、基本的には使用できると考えてよさそうです。
Sandboxでも対応していた(しかもAlchemy独自のオプションが追加されていた)ので、試してみます。
レスポンス
{
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するスマートコントラクトが本来は望ましいのですが、そうではなかった場合もデバッグはできる、ということを知れたのは大きな収穫でした。