Ethereum/Polygon のトランザクションを再送するアーキテクチャ
背景
When you submit a transaction with a gas price that is too low to be included in the block, the transaction can be pending for a very long time. You might then want to update the transaction's gas price in order to get it mined. This concept becomes a bit more complex when it comes to EIP 1559.
https://docs.alchemy.com/docs/retrying-an-eip-1559-transaction
にある通り、トランザクションはガス代が安いとマイニングされないことがあります。
また Polygon は頻繁にソフトフォーク (reorg) を起こすため、送信したはずのトランザクションが消失する、ということがあります
ソフトフォーク一覧: https://polygonscan.com/blocks_forked
なのでバックエンドでトランザクションを送信するサービスの場合、トランザクションは投げっぱなしにはせず、問題があったときにリカバリーする仕組みを構築する必要があります
前提知識
Ethereum のトランザクションのデータ構造は3つのパターンがありますが、簡単のため TransactionLegacy 形式を前提にします。他の形式でも同じ方針で対応が可能です。
トランザクションの送信には、トランザクションの内容に対してハッシュ値を計算し、そのハッシュ値に対して電子署名を行う必要があります。
ハッシュ値の計算は以下のフィールドを RLP と呼ばれる方式でエンコードしたバイト列(以下トランザクションのバイト列)に対して行われます。
- signer_nonce: int = 0
- gas_price: int = 0
- gas_limit: int = 0
- destination: int = 0
- amount: int = 0
- payload: bytes = bytes()
この中でも signer_nonce
と gas_price
はトランザクションの再送信を行ったときに変更になる可能性のある値です。
データベース
トランザクションの内容とそのステータスを管理するテーブルを用意します。
- id
- 主キー
- transaction_bytes
- トランザクションのバイト列
- transaction_hash
- トランザクションのハッシュ値
- mined_block_height
- トランザクションが取り込まれたブロックの高さ
- status
- トランザクションの送信ステータスを表す
- 送信予約済み/送信済み/レシート受け取り済み/合意済みの3値
- 「送信予約済み」はトランザクション送信直前の状態
- 「送信済み」はトランザクション送信直後の状態
- 「レシート受け取り済み」はトランザクションのレシートが発行された後の状態
- 「合意済み」は「レシート受け取り済み」になってからNブロック後の状態
status 更新の流れ
status を「送信予約済み」に更新
この遷移はトランザクションの送信を予約します。トランザクションの送信は外部サービス(Ethereum)への HTTP リクエストを伴うので2相コミットと似た流れでまずは仮登録を行います。
サービスの特性によっては「送信予約済み」と「送信済み」への遷移を同じ API 呼び出しの中で行っても良いかもしれません。
また ethers.js では、コントラクトのメソッド呼び出しなどのトランザクションの送信のハッシュ値は populateTransaction を使用して計算することができます。
場合によっては、このフェーズではトランザクションハッシュは計算せずステータスを管理するテーブルとトークン情報を持つテーブルにリレーションをもたせる方針のほうがシンプルな実装になる可能性があります。
status を「送信済み」に更新 (Worker A)
この遷移は単純に signer.sendTransaction を呼び出してトランザクションを投げるだけです。レシートを同期的に待たないメリットは、ブロックチェーンの調子が悪いときに HTTP リクエストがタイムアウトする可能性をケアできる点です。
status を「レシート受け取り済み」に更新 (Worker B)
投げたトランザクションがブロックに取り込まれるか確認する Worker です。
ほとんどの場合はブロックに取り込まれます。しかし、トランザクションに設定したガス代が相場より安い場合はいつまで経ってもブロックに取り込まれないので、ガス代を更新してトランザクションを再送する必要があります。
status を「合意済み」に更新 (Worker C)
ブロックに取り込まれたトランザクションが reorg でなかったことにされていなか確認する Worker です。
運悪く reorg に巻き込まれると送信したトランザクションがなかったことになるので再送する必要があります。
まとめ
結果整合性の考え方をベースにした、ガス代の不足と reorg に対応できるトランザクションの再送の仕組みについて考えてみました
Discussion