2
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?

[EIP7002] ステーキングしたETHの出金要求を送る新しい仕組みを理解しよう!

2
Posted at

はじめに

初めまして。
『DApps開発入門』という本や色々記事を書いているかるでねです。

以下でも情報発信しているので、興味ある記事があればぜひ読んでみてください!

今回は、ステーキングしたETHの出金先を示す情報であるwithdrawal credentialを使って、実行レイヤー側から退出や出金要求を起動できるようにするEIP7002についてまとめていきます!

以下にまとめられているものを解説しながらまとめていきます。

実行レイヤーからコンセンサスレイヤーへ出金要求を渡すことで、退出開始の主導権をwithdrawal credential、つまり出金先を示す側へ寄せます。
元のEIPの動機は0x01を中心に書かれていますが、ElectraのCL処理では0x010x02execution-layer withdrawal credentialが送信元アドレス認証の対象になります。
ただしpartial withdrawalの経路はさらに条件があり、amount > 0を送れば何でも部分出金になるわけではありません。

概要

EIP7002 は、バリデータの退出や出金要求を、実行レイヤー側からリクエストできるようにする提案です。
リクエスト自体は実行レイヤーのコントラクトに送られますが、最終的な処理はコンセンサスレイヤーで行われます。

いまのEthereumでは、バリデータには大きく2つの鍵があります。
1つは日常的に署名を行うactive keyで、もう1つは出金先を表すwithdrawal credentialです。
このうちactive keyはオンラインで使われやすい「ホット」な鍵ですが、withdrawal credentialは資産の最終的な所有権に近い役割を持つため、より安全に管理される「コールド」な情報として扱えます。

この図では、active key日常運用のための鍵で、withdrawal credential最終的な受取先を示す情報であることを並べています。
つまり従来は「運用に使う側」が退出開始を握っていたのに対し、EIP7002は「出金先を示す側」にも要求の入口を持たせる提案です。

EIP7002がやりたいことは、このwithdrawal credential側にも「退出や出金要求を始める入口」を与えることです。
これによって、active keyを預けているオペレーターや別管理者が動かなくても、最終的な所有者が退出開始を進めやすくなります。

EIP4895 との違い

似た文脈の仕様としてEIP4895がありますが、役割は異なります。
EIP4895は、コンセンサスレイヤーで確定した出金をEVM側へ「反映する」仕組みです。
一方のEIP7002は、その前段である「出金や退出を要求する」入口を実行レイヤーに作る提案です。

eip4895-vs-eip7002.png

この図では、左側のEIP4895が「CLで確定した出金結果をELに反映する」流れであるのに対し、右側のEIP7002は「ELからCLへ出金リクエストを渡す」流れであることを並べて示しています。
つまり両者は似た出金文脈でも向きが逆で、EIP4895 は結果の反映、EIP7002 は要求の入口という役割分担になります。

EIP4895 については以下の記事を参考にしてください。

EIP7685 との関係

EIP7002 は単独で新しいブロック形式を定義するのではなく、EIP7685 の汎用リクエスト枠組みに乗る形で設計されています。
つまりEIP7002は「出金要求という具体的なリクエスト種別」を定義し、EIP7685は「その要求データをELからCLへ運ぶ共通バス」を提供します。
Electraでは、これらはblock.body.execution_requestsに入るExecutionRequestsのうちwithdrawalsリストとして扱われます。
その後、get_execution_requests_list()によってtype byte + SSZ listのバイト列に変換され、リクエスト種別の昇順でengine側へ渡されます。

EIP7685 については以下の記事を参考にしてください。

動機

現在の仕様では、バリデータの退出開始を指示できるのはactive key側です。
Ethereumの仕様ではこの退出開始手続きをvoluntary exitと呼びます。
しかし、ステークされたETHの所有権に近いのはwithdrawal credential側です。

このずれがあると、以下のような問題が起こります。

問題 何が困るか
active keyの管理者と資産の所有者が別 所有者が退出したくても、active key側が協力しないと動けない
custody関係が複雑 オペレーターが止まると、資産が実質的に「人質」になりうる
active keyを紛失した 退出したくても通常フローを始められない

ここで重要なのは、withdrawal credentialはすでに「そのステーク資産を最終的に受け取る先」を表していることです。
それなのに退出開始だけはできない、という状態は役割分担として不自然です。

EIP7002はこの不一致を解消し、execution-layer withdrawal credentialを持つEOAやスマートコントラクトが、自分で退出や出金要求を始められるようにします。

さらにEIP本文では、0x00withdrawal credentialしか持っていない場合でも、一度だけ署名して0x01へ切り替えれば、この仕組みを利用できると説明しています。
そのため、0x01だけの特殊機能というより、withdrawal credential側へ制御権を寄せる方向の仕様と考えると分かりやすいです。

この図では、最初から0x01である必要はなく、0x00しか持っていない場合でも一度切り替えればこの仕組みに入れることを示しています。
そのため、EIP7002を「一部の特殊な出金先だけの話」として見るより、出金先側へ制御権を寄せる流れとして捉えた方が全体像をつかみやすいです。

仕様

全体の流れ

先に流れを掴むと読みやすいです。
この提案では、実行レイヤー上の決まったアドレスにデプロイされる出金要求用コントラクトが出金リクエストを受け取り、それをブロック末尾で取り出してEIP7685requests listに載せ、コンセンサスレイヤーがそれを処理します。

el-cl-responsibility-split.png

この図では、左側の実行レイヤーでwithdrawal credentialを持つ側が出金要求用コントラクトへ要求を送り、そこで手数料付きのキューに積まれる様子を表しています。
その後、ブロック末尾のシステムコールがキューから要求を取り出し、EIP7685 のリクエストとして右側のコンセンサスレイヤーへ受け渡します。
つまりEIP7002の本質は、退出そのものをELが完結させることではなく、ELが「退出開始の入口」を持ち、最終判断と実処理はCLが担うように責務分離する点にあります。

  1. リクエスト送信
    execution-layer withdrawal credentialに対応するアドレスが、決まったアドレスにデプロイされる出金要求用コントラクトに対して、バリデータの公開鍵とamountを送ります。
  2. キュー格納
    コントラクトは手数料を確認し、リクエストをチェーン上のキューに積みます。
  3. ブロック末尾のシステムコール
    ブロック処理の最後にSYSTEM_ADDRESSからコントラクトが呼ばれ、そのブロックでCLに渡すリクエスト群を取り出します。
  4. CL側処理
    取り出されたリクエストはEIP7685のリクエストとしてblock.body.execution_requests.withdrawalsに入り、CL側で退出や部分出金候補として評価されます。

設定値

EIP本文で定義されている主要な定数は以下です。

項目 役割
WITHDRAWAL_REQUEST_PREDEPLOY_ADDRESS 0x00000961Ef480Eb55e80D19ad83579A64c007002 出金要求を受け取ってキューに保存する、決まったアドレスのコントラクト
WITHDRAWAL_REQUEST_TYPE 0x01 EIP7685 上でのリクエスト種別
SYSTEM_ADDRESS 0xfffffffffffffffffffffffffffffffffffffffe ブロック末尾のシステムコールに使う特別アドレス
MAX_WITHDRAWAL_REQUESTS_PER_BLOCK 16 1ブロックでCLに渡せるリクエスト上限
TARGET_WITHDRAWAL_REQUESTS_PER_BLOCK 2 手数料調整が目標とする平均リクエスト数
MIN_WITHDRAWAL_REQUEST_FEE 1 最低手数料
WITHDRAWAL_REQUEST_FEE_UPDATE_FRACTION 17 動的手数料の変化率を制御する係数

この提案の肝は、1ブロックあたりの処理上限を 16 件に抑えつつ、実行レイヤー側ではそれ以上の送信をキューで受け止められる点にあります。
ここでキューを挟むことで、利用者のtransactionを即座に失敗させず、後続ブロックで順に消化できるようにしています。

出金リクエストのデータ構造

EIP7002 のリクエストは EIP76850x01 型リクエストとして定義され、データ本体は以下の3つのフィールドを持ちます。

フィールド 説明
source_address Bytes20 リクエストを送った実行レイヤー側アドレス
validator_pubkey Bytes48 対象バリデータを特定するBLS公開鍵
amount uint64 引き出したい量。CL側ではGwei単位で解釈される

validator_pubkeyを使うのは、同じwithdrawal credentialに複数のバリデータが紐づくことがあるからです。
アドレスだけでは「どのバリデータを退出させたいか」が分からないため、公開鍵で対象を一意に絞ります。

この図では、1つの出金先情報に複数のバリデータがぶら下がりうることを示しています。
だからこそ、単に「この出金先から要求した」だけでは足りず、どのバリデータに対する要求なのかvalidator_pubkeyで明示する必要があります。

ここで実装上かなり重要なのが amount の扱いです。
コントラクトへ入れるcalldatavalidator_pubkey || big-endian uint64 amountですが、キューから組み立てて返されるリクエストではamountlittle-endianで並びます。
さらにCL側ではこのamountGweiとして解釈します。
そのため、単にuint64が入るだけではなく「入力と出力でbyte orderが違う」「単位はGwei」まで含めて覚えておく必要があります。

byte-layout-and-endianness.png

この図では、利用者が送る56 bytecalldata、キュー上の3 slot配置、そしてコントラクトが返す出金要求データの並びを横に並べています。
特に重要なのは、入力時のamountbig-endianなのに対し、キューから組み立てた返却値ではlittle-endianになる点と、CL側ではそれをGweiとして解釈する点です。

コントラクトの3つのコードパス

出金リクエスト用コントラクトには、大きく3つの入り口があります。

コードパス 入力条件 役割
Add Withdrawal Request calldata56 byte キューにリクエストを追加する
Fee Getter calldata長が0 現在の必要手数料を返す
System Process SYSTEM_ADDRESSから呼ばれる キューからリクエストを取り出して内部状態を更新する

ここで56 byteというのは、validator_pubkey48 byteと**big-endian**のamount 8 byteをつなげた長さです。
送信元アドレスはcalldataに含めず、EVMのCALLER、つまりmsg.senderから取得します。

contract-code-paths.png

この図では、同じ出金要求用コントラクトが56 byte calldatacalldataなしSYSTEM_ADDRESSからのブロック末尾呼び出しという3種類の入口で別々に振る舞うことをまとめています。
利用者が普段触るのはリクエスト追加と手数料取得ですが、プロトコルとして重要なのは3つ目のシステム処理で、ここがキューから要求を取り出してCLへ渡す中継点になります。

リクエスト追加処理

Add Withdrawal Requestでは、手数料を確認したうえでキューの末尾へリクエストを積みます。
EIP本文の擬似コードは以下です。

def add_withdrawal_request(Bytes48: validator_pubkey, uint64: amount):
    fee = get_fee()
    require(msg.value >= fee, 'Insufficient value for fee')

    count = sload(WITHDRAWAL_REQUEST_PREDEPLOY_ADDRESS, WITHDRAWAL_REQUEST_COUNT_STORAGE_SLOT)
    sstore(WITHDRAWAL_REQUEST_PREDEPLOY_ADDRESS, WITHDRAWAL_REQUEST_COUNT_STORAGE_SLOT, count + 1)

    queue_tail_index = sload(WITHDRAWAL_REQUEST_PREDEPLOY_ADDRESS, WITHDRAWAL_REQUEST_QUEUE_TAIL_STORAGE_SLOT)
    queue_storage_slot = WITHDRAWAL_REQUEST_QUEUE_STORAGE_OFFSET + queue_tail_index * 3
    sstore(WITHDRAWAL_REQUEST_PREDEPLOY_ADDRESS, queue_storage_slot, msg.sender)
    sstore(WITHDRAWAL_REQUEST_PREDEPLOY_ADDRESS, queue_storage_slot + 1, validator_pubkey[0:32])
    sstore(WITHDRAWAL_REQUEST_PREDEPLOY_ADDRESS, queue_storage_slot + 2, validator_pubkey[32:48] ++ uint64_to_little_endian(amount))
    sstore(WITHDRAWAL_REQUEST_PREDEPLOY_ADDRESS, WITHDRAWAL_REQUEST_QUEUE_TAIL_STORAGE_SLOT, queue_tail_index + 1)

この処理で見ておきたい点は以下です。

  1. 手数料確認
    msg.value がその時点の必要手数料以上でないとリクエストは受け付けられません。
  2. ブロック内カウント更新
    そのブロックで何件リクエストが追加されたかを WITHDRAWAL_REQUEST_COUNT_STORAGE_SLOT に蓄積します。
  3. キューへの格納
    msg.senderpubkey前半32 bytepubkey後半16 byteamount 8 byteを3スロットに分けて保存します。
  4. tail 更新
    キューの末尾ポインタを 1 つ進めます。

この設計だと、実行ブロック中にはいくらでもリクエストを受け付けられます。
ただしCLに渡す件数は後段のdequeueで上限がかかるため、受け付け速度と処理速度を分離できます。

手数料取得と動的手数料

calldata長が0の呼び出しでは、コントラクトは現在の必要手数料を返します。
利用者は先に手数料を読み、その額を付けてリクエストを送る想定です。

手数料の計算は get_fee()fake_exponential() で行われます。

def get_fee() -> int:
    excess = sload(WITHDRAWAL_REQUEST_PREDEPLOY_ADDRESS, EXCESS_WITHDRAWAL_REQUESTS_STORAGE_SLOT)
    require(excess != EXCESS_INHIBITOR, 'Inhibitor still active')
    return fake_exponential(
        MIN_WITHDRAWAL_REQUEST_FEE,
        excess,
        WITHDRAWAL_REQUEST_FEE_UPDATE_FRACTION
    )

考え方としては EIP1559 に近く、平均で 1ブロックあたり 2 件程度なら安く、需要が集中すると指数的に高くなります。
そのため、悪意ある利用者が安いgasだけでキューを埋め尽くし続けることを防ぎやすくなります。

queue-and-dynamic-fee.png

この図では、利用者からの要求をいったんキューで受け止め、1 ブロックあたりの処理上限だけを後段でかける構造を表しています。
同時に、需要が強い時ほど必要手数料が上がるため、単に超過分を即失敗させるのではなく、呼び出し成功と後段処理を分けながら混雑とgriefingを抑える設計になっています。

EIP1559 については以下の記事を参考にしてください。

システムコールとdequeue処理

各実行ブロックの最後には、SYSTEM_ADDRESSから、その決まったアドレスにデプロイされる出金要求用コントラクトへcalldataなしのシステムコールが行われます。
この時にコントラクトはキュー先頭からリクエストを取り出し、内部状態を更新して、CLに渡すリクエスト群を返します。

EIP本文の中核ロジックは以下です。

def read_withdrawal_requests():
    reqs = dequeue_withdrawal_requests()
    update_excess_withdrawal_requests()
    reset_withdrawal_requests_count()
    return ssz.serialize(reqs)

このシステムコールにはいくつか強い制約があります。

項目 内容
gas limit 専用で30_000_000
gas accounting この呼び出しで消費したgasblock全体のgas使用量に含めない
fee burn EIP1559burn対象にしない
codeが無い場合 blockinvalidとする
call failure blockinvalidとする

ここはかなり重要です。
単なる補助的なcallではなく、fork後のblock validityに直接関わるシステム操作になっています。
つまりチェーンが有効化の準備を終えていること、そしてそのアドレスで出金要求用コントラクトが正しく動くことが前提です。

CL側での処理

EIP本文では、CL側の詳細仕様はconsensus-specsを参照する形になっています。
Electraでは、実際にはblock.body.execution_requests.withdrawalsに最大16件までのWithdrawalRequestが入り、process_withdrawal_request()で1件ずつ評価されます。

execution-requests-transport.png

この図では、EL側の順番待ちとシステムコールから返る出金要求の一覧が、0x01 + SSZ listのバイト列として整形され、ElectraのBeaconBlockBody.execution_requests.withdrawalsまで運ばれる流れをまとめています。
ここで重要なのは、EIP7685がリクエスト受け渡しのルールを与え、Electra側がそれをexecution_requestswithdrawalsスロットとして受け取る、という役割分担です。

ここで重要なのは、CL側がまず「このリクエストは有効な実行レイヤー側の出金先情報から来たか」を見てから、その後でfull exitpartial pathを分岐することです。
Electraのpredicateではhas_execution_withdrawal_credential()が使われるため、送信元アドレス認証の対象は0x01だけでなく0x02compounding credentialも含みます。

cl-withdrawal-branching.png

この図では、CL側がまず出金先の種類と送信元アドレスを確認し、その後でamount == 0amount > 0を分岐させる流れを表しています。
amount == 0full exitのシグナルですが、amount > 0はあくまでpartial candidateであり、0x02compounding credentialexcess balanceなどの条件を満たした時だけpending partial withdrawalに積まれます。

分岐の骨子は以下です。

  1. amount == 0
    FULL_EXIT_REQUEST_AMOUNT として扱われます。
    この場合はfull exitのシグナルで、pending partial withdrawalが残っていなければvalidator exitを開始します。
  2. amount > 0
    partial withdrawalの候補として扱われます。
    ただし実際にpending partial withdrawalへ積まれるのは、0x02compounding credentialであること、十分なeffective balanceがあること、excess balanceがあることなどを満たした時だけです。
  3. 条件を満たさないrequest
    pubkeyが存在しない、送信元アドレスが一致しない、すでにexit済み、partial queueが満杯、といったケースではそのリクエストだけが無視されます。

つまりamount > 0を送ったから自動的に部分出金になるわけではありません。
ELはリクエストを渡すだけで、実際にfull exitになるか、pending partial withdrawalに積まれるか、何も起こらないかはCLの条件判定に委ねられます。

補足

validator_pubkey を使う理由

一見するとvalidator indexでも対象を指定できそうですが、ステーキングプールなどのスマートコントラクトはvalidator作成前のindexを知りません。
一方でpubkeyは事前に分かるため、EL側からリクエストを組み立てるにはpubkeyの方が扱いやすいです。

この図では、validator index後から決まる番号であるのに対し、validator pubkey事前に分かる識別子であることを並べています。
そのため、実行レイヤー側から要求を組み立てる入口としては、indexよりpubkeyの方が自然です。

この仕様は「CLに詳しい主体だけが使える」形を避け、スマートコントラクトが自然に扱える入力へ寄せています。

キューを持つ理由

代替案として、そのブロック内でMAX_WITHDRAWAL_REQUESTS_PER_BLOCK件を超えたらcontract call自体を失敗させる設計も考えられます。
ただしそれだと、混雑時にhonestな利用者のtransactionまで失敗しやすくなります。

キューを持てば、利用者のcallは成功させつつ、CL側への受け渡しだけをrate limitできます。
EIP本文でも、このUXの方が望ましいとしてキュー方式を選んでいます。

手数料によるgriefing対策

この提案が防ぎたい攻撃の1つは、安価に大量の出金リクエストを送りつけて、他の利用者の退出を遅らせることです。
validatorごとの厳密な回数制限をCL状態証明つきでやる方法もありますが、それにはEIP4788のような別のcross-layer前提や、追加のstate trackingが必要になります。

EIP7002 はそこまで複雑な仕組みを持ち込まず、需要が高い時は手数料を上げるという経済的な手法を選んでいます。
平常時は安く、混雑時だけ高くすることで、実装複雑性を増やさずabuseを抑える狙いです。

EIP7251 との関係

EIP7002 は単体でも重要ですが、後続仕様とのつながりを見るとさらに分かりやすいです。
特にEIP7251では、バリデータの最大effective balanceを引き上げると同時に、部分出金や統合の仕組みが重要になります。

EIP7251 の中でも、部分出金は EIP7002 で定義される実行レイヤーメッセージによってトリガーされます。
つまりEIP7002は、Electra系ステーキング改善の土台の1つです。

EIP7251 については以下の記事を参考にしてください。

互換性

EIP7002block structureblock validation ruleを変更するため、後方互換な変更ではありません。
fork後は、execution clientconsensus clientも、この新しいリクエスト処理を理解していなければ正しいblockを検証できません。

ただし、既存ユーザーの通常のETH送受信やスマートコントラクト利用を壊す種類の変更ではありません。
影響があるのは、主にバリデータの退出と部分出金を支えるプロトコルルールの側です。

セキュリティ

既存custody関係への影響

この提案が入ると、withdrawal credential側が退出リクエストを始められるようになります。
そのため、もし既存のcustody関係が「withdrawal credentialでは退出開始できない」という前提に依存していた場合、期待していた責務分担が変わる可能性があります。

EIP本文では、それでもwithdrawal credentialは最終的に資産を所有する側なのだから、退出権限を持つのは自然だと整理しています。
もし旧来の振る舞いを維持したいなら、0x01withdrawal credentialの先をスマートコントラクトにして、その中で制約を再現する方法が考えられます。

手数料の払い過ぎ

このコントラクトは払い過ぎた手数料を返しません。
さらに、transaction作成時とinclusion時でfeeが変わることもあります。
そのため、リクエストを送る側は「いま必要なfeeを先に読む」「想定より高ければrevertする」というチェックを入れる必要があります。

特にEOAから直接リクエストする場合は、厳密にちょうどの額を払うことが難しく、払い過ぎが起こりやすいです。
この点はgas feeと別の注意点として見ておく必要があります。

システムコール失敗時の扱い

fork後は、その出金要求用コントラクトへのシステムコール失敗が無効なブロックにつながります。
問題のtransactionmempoolに残ったままだと、同じ失敗を次スロットでも繰り返す可能性があります。

EIP本文では、block producer実装がtransaction集合をシャッフルするなどして、問題のtransactionを除いたvalid blockを作りやすくすることを推奨しています。
単に「失敗したblockを捨てれば終わり」ではなく、mempool側の再試行も考慮する必要がある点が重要です。

コード未配置時の失敗

WITHDRAWAL_REQUEST_PREDEPLOY_ADDRESScodeが無い状態でこのEIPを有効化すると、最初のblock以降がすべてinvalidになります。
この仕様は「そのアドレスにcontractがあること」をプロトコルの前提にしているからです。

本文では回復策として、2つを挙げています。

  1. 先にcontract codeを配置する
    transactioncodeを配置し、そのblockを最初のvalid blockにする方法です。
  2. activationを延期する
    client実装のfork timestampblock numberを後ろへずらし、contractを準備してから有効化する方法です。

invalid-block-failure-paths.png

この図では、fork有効化後にそのコントラクトのcodeが無い場合や、システムコール自体が失敗した場合に、そのblockvalid blockへ進めずinvalid blockになることを表しています。
つまりこのcontractは単なる補助コントラクトではなく、fork後のblock validityを支える前提要素です。

最後に

今回は「EIP7002」についてまとめてきました。

EIP7002は、withdrawal credential側に退出や部分出金の開始権限を寄せることで、ステーク資産の最終所有者がより自然に制御できるようにする提案です。
実行レイヤーの出金要求用コントラクト、キュー、動的fee、そしてEIP7685経由のCL受け渡しまで含めて設計されており、EIP7251のような後続仕様の土台としても重要です。

元の仕様もぜひ読んでみてください。

他でも色々記事を書いているのでぜひよろしければ読んでいってください!

2
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
2
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?