はじめに
この記事では、Ethereum の NFT マーケットプレイス「miime」の開発・運用で遭遇したトラブルや、仕様上のつらみなどを、実際の例を交えて紹介します。
通常の web サービス開発では想像できないような、Ethereum ブロックチェーンならではの事象を体感いただければと思います。
ノードから取得できる情報はいつも同じじゃない
イベント情報が消える
Ethereum に刻まれた NFT の送付イベントを取得する際、下記のようなリクエストを Ethereum のノードに送ります。(実際のコードでは、これと同じことを web3.js や ethers.js などのライブラリを介して実行しています。)
curl https://<ノードの JSON-RPC URL> \
-X POST \
-H "Content-Type: application/json" \
-d '{
"jsonrpc": "2.0",
"method": "eth_getLogs",
"params": [
{
"address": "0x273f7f8e6489682df756151f5525576e322d51a3",
"fromBlock": "0xad5700",
"toBlock": "0xad62b8",
"topics": ["0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef"]
}
],
"id": 1
}'
この例では、コントラクトアドレス 0x273f7f8e6489682df756151f5525576e322d51a3
(MyCryptoHeroes: Hero)において、ブロック番号 0xad5700
から 0xad62b8
の間に発生した Transfer
イベント(topics
で指定した値が示すイベント)を取得しています。
これにより、下記のような結果が得られます。topics
の値をパースすることで、送付されたアセットのトークンID、送付元アドレス、送付先アドレスがわかります。
[
{
"address": "0x273f7f8e6489682df756151f5525576e322d51a3",
"blockHash": "0x15af7e1620811d04a91f7a5837839ada71f98144e4c41177934672ce3383e0f4",
"blockNumber": "0xad5d12",
"data": "0x",
"logIndex": "0x60",
"removed": false,
"topics": [
"0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef",
"0x000000000000000000000000285b257aa51fdc45176cf1ffac6a0bfb5cf28afd",
"0x000000000000000000000000ed5c4a6b18c697cc22241f9330e4f2ee1000d814",
"0x0000000000000000000000000000000000000000000000000000000002656742"
],
"transactionHash": "0x8f4b38a730da57d49619c7cef0df7ac8505e5fd3ead09e472c5b3f3fa2bb2aca",
"transactionIndex": "0x57"
},
:
]
さて、通常であれば、このリクエスト結果は常に同じであることが想定できますが、Ethereum の場合は注意が必要です。
比較的新しいブロックの情報をこの方法で取得すると、イベントを取りこぼしたり、逆に、一度発生したイベントがなかったことにされる場合があります。
これは、特に先頭付近のブロックでは、チェーンの小さな分岐が頻繁に発生しているためです。
対策として、イベント発生後、分岐が起きる可能性が十分に低くなるまで、ブロックが積み重なるのを待つ必要があります。ただし、これだとユーザーへの通知などにタイムラグが生じるため、よりよい設計としては、先頭ブロックの情報はあくまで参考情報として利用して、あとから正式な情報で訂正できる仕組みが望ましいかもしれません。
変数の値が元に戻る
コントラクトの変数の値を確認する場合も、同じことが起こります。つまり、一度変わったと思った変数のステートが元に戻る可能性があります。
例として、次のようなコントラクトの変数の値を取得することを考えます。これは、miime の Exchange コントラクトで使われているコードの一部で、filled
という mapping 型の変数には、指定した orderHash の取引において、約定した金額の値が格納されます。つまり、この値を確認することで、取引が完了したかどうかを判定できます。
contract MixinExchangeCore is DepositManager, ... {
// orderHash -> 約定した金額(wei) を格納する mapping
mapping (bytes32 => uint256) public filled;
:
この値を確認するにはいくつか方法がありますが、例えば Node.js で ethers.js の最近のバージョンを使うと、下記のような実装となります。
import { ethers } from 'ethers';
// コントラクトにアクセスするためのインスタンスを生成
const provider = new ethers.providers.JsonRpcProvider(<ノードの JSON-RPC URL>);
const contract = new ethers.Contract(<コントラクトのアドレス>, <コントラクトのABI>, provider);
// 指定した orderHash の取引結果を確認
const orderHash = '0xe50f69023b42004e0d8ccd97dce406de6add7029b4934c167a5e7618a5f0c4e6';
const filledAmount = await contract.filled(orderHash);
// -> e.g. 9750000000000 wei
このコードを実行すると、ノードが保持する最新ステートの結果が得られます。
しかし、先頭ブロックで分岐が起きると、得られる結果が
- 初回:
9750000000000
- 数秒後:
0
のように変わってしまう場合があります。
トランザクションはブロードキャストされているので、最終的には 9750000000000
に落ち着く可能性が高いですが(別のトランザクションが先に取り込まれて、なかったことになる可能性もあります)、短い時間間隔で判定していると、プログラムが誤作動を起こす場合があります。実際、miime ではこの事象が発生しました…
変数の場合、イベントと違って、どのブロック番号で値が変わったか(自分でノードを運用してステートの変化を追わない限りは)わからないので、とてもやっかいです。
対策として、重要な判定にはイベントを使うのが望ましいかもしれません。
トランザクションは消えることがある
Ethereum のステートを変更するには、トランザクションを送信する必要がありますが、送信されたトランザクションは、ブロックに取り込まれることなく消えることがあります。
トランザクションが消えてしまう理由として、設定した Gas 価格 が低すぎたり、同じ nonce の番号で複数のトランザクションを送信してしまった場合が考えられます。これは MetaMask など有名なウォレットでも普通に起こります。
そのため、ユーザーが送信したトランザクションの結果を知りたい場合、そのトランザクションハッシュだけ監視しても、結果が永久にわからない可能性があります。
具体例をみてみましょう。
トランザクションハッシュによる監視の限界
例えば、下記のフロントエンドのコードは、ユーザーに NFT 購入のためのトランザクションを送信させて、そのトランザクションハッシュを取得します。(わかりやすいようにコードは簡略化しています。)
import { ethers } from 'ethers';
// ユーザーに、アカウント読み取りのパーミッションを許可してもらう(ウォレットの承認画面が表示される)
await window.ethereum.request({ method: 'eth_requestAccounts' });
// コントラクトにアクセスするためのインスタンスを生成
const provider = new ethers.providers.Web3Provider(window.ethereum);
const contract = new ethers.Contract(<コントラクトのアドレス>, <コントラクトのABI>, provider.getSigner());
// ユーザーに、購入のためのトランザクションを送信させ、そのトランザクションハッシュを取得する(ウォレットの承認画面が表示される)
const tx = await contract.fillOrder(<出品者が作成したオーダー情報>);
const txHash = tx.hash;
// -> e.g. '0xd8ee7b6424d1027407a45f6b14361169f2b8ed9adc752834ec167e59ff05f69d'
その後、このトランザクションハッシュを使って、トランザクションが取り込まれたかどうかを監視できます。例えば、下記のコードで結果を取得できます。
const txReceipt = await provider.waitForTransaction(txHash);
これはフロントエンドのその場限りの表示用としては悪くない方法ですが、結果が永久に返ってこないことが普通に起こります。よくある操作として、MetaMask の Spped Up 機能を使った場合は、トランザクションハッシュが別のものに変わります。
そのため、厳密に結果を知るには、トランザクションハッシュを使うのではなく、このトランザクションによって変更された結果のイベントを監視することが望ましいです。
同じ nonce が設定されてしまう問題
もうひとつの例として、今度は、バックエンドで管理用アカウントからトランザクションを送信する際の課題について紹介します。
Ethereum では、適切な Gas 価格を設定すれば、通常は数十秒〜数分でトランザクションがブロックに取り込まれます。しかし、急激にトランザクションが増えたり、平均 Gas 価格が急に上がったりすると、いくら待ってもトランザクションが取り込まれない状況に陥ります。
トランザクション送信を自動化している場合、この対処はやっかいです。
特に、トランザクションがペンディング中のときに、別のトランザクションを送信しようとすると、かなりの確率で問題が発生します。
ここで、Ethereum の nonce について説明しておきます。
nonce は、トランザクションに設定される番号で、アドレスごとに 0 から始まる連番となっています。あるアドレスが送信したトランザクションは、nonce の順番どおりにしかブロックに取り込まれません。同じ nonce が設定されたトランザクションが複数あった場合、そのうちひとつだけがブロックに取り込まれます。
web3.js や ethers.js などのライブラリでは、トランザクション送信時に nonce を指定しなかった場合、自動的に適切な値を設定してくれます。
const tx = await contract.someFunction(...);
上記は ethers.js のコード例ですが、このようにトランザクションを送信した場合、内部的には、eth_getTransactionCount
という JSON PRC メソッドをノードに対して呼び出し、これまでこのアドレスから送信されブロックに取り込まれたトランザクションの数を取得して、これを nonce の値に設定しています。
ただし、ここで推定される nonce は、ペンディング中のトランザクションがあった場合、間違ったものになる可能性があります。つまり、実際は nonce = 100
で送信済みのトランザクションがあるにも関わらず、同じ nonce で別のトランザクションを送信してしまうことがあります。その場合、片方のトランザクションは消えてしまいます。
(注記:一応、eth_getTransactionCount
には、ペンディング中のトランザクションを考慮するオプションがあります。ただ、どこまで精度が高いものかは未検証です。)
対策としては、自分が送信した最後の nonce を覚えておき、次のトランザクションでは自分で値を増やして設定する必要があります。
平均 Gas 価格が急に上がってしまってなかなか取り込まれない場合は、同じ nonce で Gas 価格を上げたトランザクションを送信する必要がありますが、このあたりをどこまで自動で作り込むかは、費用対効果でありケースバイケースかなと思います。管理用アドレスを複数用意して、同じアドレスから複数のトランザクション送信しないようにする方法もありますが、その分、アドレスごとに ETH を補填しなければならないので、ここもケースバイケースです。
新興技術のライブラリは問題がいっぱい
Ethereum 関連のライブラリは、EIP で議論されている内容がすぐ変わったり、サードパーティが多いこともあって、何かと問題が多いです。
ライブラリはバグや間違いが多い、という心構えで挑むことが重要だと思います。
ここから先は、若干 愚痴のようになってしまっており、知見の共有という感じではないですが、これまでに遭遇した問題を少し紹介したいと思います。
トランザクションハッシュが取れない
初期の ethers.js V3 では、コントラクトの関数を実行する際、ユーザーにトランザクションを送信させたあと、ブロックに取り込まれるまで待たないとそのトランザクションハッシュを取得できませんでした。
トランザクションハッシュを記録したい場合、Mainnet だとこれは実用に耐えられず、結局、ethers.js から web3.js に切り替えました。
Ganache や Rinkeby でテストしていると、こういう問題に気づきにくいので注意が必要です。
構造体が引数になっているコントラクト関数を呼び出せない
Solidity で複雑な関数を作ると、すぐに stack too deep の問題に直面します。(詳細は、GBEC の解説動画がわかりやすくオススメです)
これを解決するために、miime の Exchange コントラクトでは関数の引数に構造体を使っています。
しかし、開発時点での web3.js 安定バージョンでは、構造体の引数がサポートされておらずエラーになりました。
自前でエンコードして、低レベルの API を使えばトランザクション送信できたと思いますが、パラメータの種類およびパターンが多くバグを生むリスクがあったので、別のライブラリを使う選択をしました。
コントラクトウォレットの署名のインターフェースが違う
miime のコントラクトのベースにしている 0x Protocol は、コントラクトウォレットの署名形式 ERC-1271 をサポートしています。
しかし、この署名形式は、これまで議論が右往左往しており、プロジェクトによってインターフェースおよびマジックナンバーの相違が発生しています。
Dapper ウォレットのサポートを検討していたとき、0x Protocol で実装されていたインターフェースの想定と、Dapper コントラクトで実装されていたインターフェースが違っていたため、署名が正しく検証できずに問題になりました。
0x Protocol で想定されていたインターフェースおよびマジックナンバーは下記です。
function isValidSignature(bytes32 hash, bytes signature) external view
returns (bytes4 magicValue); // 0xb0671381
function isValidSignature(bytes hash, bytes signature) external view
returns (bytes4 magicValue); // 0x20c13b0b
一方、Dapper コントラクトで実装されているインターフェースは下記でした。
function isValidSignature(bytes32 hash, bytes signature) external view
returns (bytes4 magicValue); // 0x1626ba7e
これはもう、コントラクトを作り直すしかないです。
署名に必要な引数がウォレットによって違う
この件は、ライブラリとウォレットどちらに問題があるのか最終的な原因特定まで至っていませんが(現在は web3 を利用しており、問題は起きていません)、当時、ethers.js / ethereumjs でユーザーに署名させる際、引数に渡す値に prefix をつけるかどうかがウォレットによって異なりました。現在はまた状況が変わっているので、詳細を説明してもあまり意味がないですが、下記に、当時の調査結果を紹介します。ウォレットだけでなく、Android か iOS かによっても結果が違うので、なかなかテストが大変でした。
また、いまだに Finalize されていない EIP-712(eth_signTypedData)という署名形式の対応状況も、当然のことながらウォレットによって異なります。
これらをテストする際につらいのは、ウォレットによってテストネットやカスタムのノード URL 指定ができないものがけっこう存在することです。
特定のバージョンに入り込んでいるバグ
このへんを挙げるとキリがないのですが、例えば、
- web3.js で、イベントのパースの引数がずれているバージョンがある
- 0x.js で、ドキュメントに書かれているユーティリティクラスが存在しないバージョンがある
- Ganache でどうしても特定のトランザクションが失敗するバージョンがある
- MetaMask でエラーメッセージがすべて消えて返ってくるバージョンがある
などなど。もちろん正式バージョンです。
間違ってることがよくある、という認識で対峙するのが良さそうです。
おわりに
Ethereum を使ったサービス開発の実際について、少しでも体感いただけたら幸いです。
各分野で特有の苦労はいろいろあると思いますが、一度取得した情報が、2回目の取得ではまったく別ものになっているといった挙動は、分岐が起こるブロックチェーンならではかなと思います。注意して設計する必要がある一方で、非常に面白い領域でもあるので、興味を持たれた方はぜひ開発してみてください。