はじめに

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

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

今回は「コントラクトのストレージ量に応じて SSTORE(コントラクトのストレージへ値を書き込むEVM命令)のガスコストを変えるEIP8032」についてまとめていきます。

EIP8032は、コントラクトが持つストレージスロット数を storage_count(そのコントラクトが使っている非ゼロのストレージ枠の数)としてアカウントデータに持たせ、その値を SSTORE のガス計算に反映する提案です。 ストレージを大量に持つコントラクトほど追加書き込みのコストを上げ、Ethereumの状態肥大化に経済的なブレーキをかけることを狙っています。

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

概要

EIP8032は、SSTORE(コントラクトのストレージへ値を書き込むEVM命令)のガスコストをコントラクト単位のストレージ量に連動させるコア提案です。 現在のストレージ書き込みコストは、コントラクトがすでにどれだけ大きいストレージを持っているかを強く反映しません。 そのため、大量のストレージを持つコントラクトでも、追加の書き込みがネットワークへ与える長期的な負担と比べて安く扱われます。

提案の中心は以下の3つです。

  • アカウントRLPに任意の storage_count(そのコントラクトが使っている非ゼロのストレージ枠の数)フィールドを追加する。
  • 既存コントラクトの非ゼロストレージスロット数を、フォーク後にブロック単位で少しずつ数える。
  • SSTORE のガス計算で、ブロック開始時点の storage_count を使って追加コストを計算する。

全体像は以下です。

EIP8032の全体像

storage_count(A) は、コントラクト A が持つ非ゼロストレージスロット数です。 S_pre(A) は、コントラクト A についてブロック開始時に読み取った storage_count(A) です。 A は対象のコントラクトアカウントを表します。 pre は、そのブロックを実行する前の状態を指します。 SSTORE の追加コストは、このブロック開始時の固定値を使って計算されます。 そのため、同じブロック内の書き込み順序によってガス見積もりが変わりません。

動機

Ethereumの状態サイズは、ノード同期時間、ハードウェア要件、状態ルート計算の負荷に直接影響します。 ストレージは一度オンチェーンに記録されると、ネットワーク参加者が長期にわたって保持し続ける必要があります。 しかし現在のガスモデルでは、コントラクトがすでに大量のストレージを持っていることが、追加の SSTORE コストに十分反映されません。

このずれにより、低コストのデータ記録やスパムに近い使い方が経済的に成立しやすくなります。 大きなストレージを持つコントラクトほど、クライアントが状態更新やデータベース読み書きで負担する作業は増えます。 そこで EIP8032 は、ストレージ量と追加書き込みコストを直接結びつけます。

重要なのは、小規模から中規模のコントラクトを一律に高くする提案ではない点です。 ACTIVATION_THRESHOLD(追加コストをかけ始める境目)を超える規模から追加コストを反映し、状態肥大化に大きく寄与するコントラクトへ強いインセンティブを向けます。

仕様

EIP8032の仕様は、アカウントデータ、既存状態の移行、ブロックごとの更新規則、SSTORE のガス式に分かれます。 storage_count は単に参照用のメタデータではなく、ガス計算へ入るコンセンサス上の値です。

定数とパラメータ

提案で使われる定数は以下です。

名前 値 役割
FORK_TIMESTAMP TBD 提案を有効化するフォーク時刻
LIN_FACTOR TBD 追加ガスの線形係数
ACTIVATION_THRESHOLD TBD 追加コストを反映し始めるしきい値
TRANSITION_REGISTRY_ADDRESS TBD 移行処理の進捗を保存するシステムコントラクト
TRANSITION_SLOTS_PER_BLOCK TBD 1ブロックで走査するストレージスロット数の上限
TRANSITION_MAX_ACCOUNTS TBD 1ブロックで完了扱いにできるアカウント数の上限

ACTIVATION_THRESHOLD は、追加コストをかけ始める境目です。 およそ8GBのデータ量に相当する水準を選ぶ想定です。 ただし、具体的な値はまだ決まっていません。

storage_count

Ethereumのアカウントは、アドレスに紐づく状態データです。 外部所有アカウントだけでなく、コントラクトアカウントも含みます。 ここで扱うのは、主にストレージを持つコントラクトアカウントです。

アカウントRLPには、任意フィールドとして storage_count(そのアカウントが使っている非ゼロのストレージ枠の数)が追加されます。 この値は、そのアカウントが持つ非ゼロストレージスロット数を表します。

storage_count は任意フィールドなので、値が存在しないアカウントは 0 として扱われます。 また、storage_count == 0 の時はフィールドを省略できます。 これにより、ストレージを持たないアカウントや小さいコントラクトへ不要なデータ増加を持ち込みません。

既存状態の移行

FORK_TIMESTAMP で提案が有効化されると、既存コントラクトのストレージスロット数を数える移行処理が始まります。 すべての既存ストレージを1ブロックで数えると処理が重すぎるため、クライアントは各ブロックの最後に少しずつ走査します。

移行処理の進捗は、TRANSITION_REGISTRY_ADDRESS にあるシステムコントラクトへ保存されます。 ここで保存するカーソルは、どのアカウントとどのストレージスロットまで数えたかを示すしおりです。 保存されるスロットは以下です。

スロット 値 役割
0x00 cursor_account_hash 現在走査しているアカウントハッシュ
0x01 cursor_slot_hash 現在走査しているストレージスロットハッシュ
0x02 cursor_accum 現在のアカウントで見つけた非ゼロスロット数

移行処理の流れは以下です。

storage_count移行処理

クライアントは、アカウントハッシュとスロットハッシュを辞書順で進めます。 途中で TRANSITION_SLOTS_PER_BLOCK または TRANSITION_MAX_ACCOUNTS に達した時は、カーソルを保存してブロック処理を終えます。 あるアカウントのストレージをすべて走査し終えた時だけ、そのアカウントの storage_count が確定します。

この移行処理は、EIP-7612の状態移行方式を参考にしています。 EIP-7612は、Verkle Tree への移行で既存状態を一度に変換せず、進捗を持ちながら段階的に処理する考え方を示しています。

EIP-7612: Verkle state transition via an overlay tree https://eips.ethereum.org/EIPS/eip-7612

リオーグ時の扱い

チェーンのリオーグでは、特別な処理は不要です。 storage_count と3つのカーソルは通常の状態書き込みとしてブロック後状態に含まれます。 そのため、リオーグが起きた時は新しい正規チェーンの祖先状態へ戻り、ブロックを再実行することで同じ進捗が再計算されます。

アカウント更新規則

各ブロックでトランザクションをすべて実行した後、クライアントは影響を受けたアカウントの storage_count を更新します。 更新では、ブロック前状態の値とブロック後状態の真の非ゼロスロット数を対応させます。

new を効率よく得るために、すべてのストレージを毎ブロック走査する必要はありません。 ブロック内で書き込まれたスロットだけを見て、非ゼロスロット数の差分を足し引きします。

差分計算は以下です。

python
def calculate_storage_delta(prestate_value, poststate_value):
    if prestate_value == 0 and poststate_value != 0:
        return 1
    elif prestate_value != 0 and poststate_value == 0:
        return -1
    else:
        return 0

0 から非ゼロへ変わったスロットは +1、非ゼロから 0 へ戻ったスロットは -1 です。 非ゼロ同士の書き換えや 0 のままの書き込みでは、占有スロット数は変わりません。

移行中のブロックでは、トランザクション実行後にまず移行処理を進め、その後で差分を適用します。 この順序により、そのブロックで走査されたアカウントのガス変化は同じブロックではなく、後続ブロックから反映されます。

移行中の差分適用は以下の扱いです。

アカウントの位置 差分の扱い
走査済みのアカウント すべてのストレージ差分を適用する
まだ走査していないアカウント ストレージ差分を適用しない
現在走査中のアカウント すでに数え終えたスロットだけ差分を適用する

この扱いにより、まだ数えていない過去スロットと、ブロック内の新しい差分を二重に数えることを避けます。

SSTORE のガス計算

コントラクトアカウント A について、ブロック開始時点の storage_count(A) を S_pre(A) とします。 ここで A は対象のコントラクトアカウントです。 ここでの S_pre(A) は、「このブロックが始まった瞬間に見えていた、コントラクト A のストレージスロット数」です。 ブロック中に A のストレージスロット数が増減しても、S_pre(A) はそのブロックの間は変わりません。

S_pre(A) の扱いは以下です。

S_pre(A)の意味

たとえば、ブロック開始時に storage_count(A) = 120 なら、そのブロック内の SSTORE は S_pre(A) = 120 として計算します。 同じブロック内でスロットが増えて storage_count(A) = 123 になっても、その更新後の値は同じブロックのガス計算には使いません。 更新後の 123 は、後続ブロックで新しい S_pre(A) として使われます。

storage_count(A) がブロック開始時に存在しない場合、S_pre(A) = 0 として扱います。

ガス式は以下です。

python
constant_sstore_gas(addr, slot) + LIN_FACTOR * ceil_log16(S_pre(addr)) // ACTIVATION_THRESHOLD

constant_sstore_gas(addr, slot) は従来の SSTORE コストに相当する部分です。 そこへ、S_pre(addr) に基づく追加コストが足されます。 S_pre(A) をブロック内で固定するため、eth_estimateGas(トランザクション実行前に必要なガス量を見積もるRPC)の見積もりが同じブロック内のトランザクション順序で揺れにくくなります。

状態サイズへの影響

storage_count は、先頭ゼロを持たない最小長のビッグエンディアンバイト列としてRLPエンコードされます。 固定32バイトではなく、値に応じた短いバイト列になるため、アカウントごとの追加サイズは小さく抑えられます。

値ごとのペイロード長は以下です。

storage_count ペイロード長
0 フィールドを省略できる
1 ... 255 1バイト
256 ... 65,535 2バイト
65,536 ... 16,777,215 3バイト
16,777,216 ... 4,294,967,295 4バイト

さらに、RLP文字列プレフィックスとして1バイト、アカウントリスト全体のサイズ境界をまたぐ時だけ最大1バイトが追加されます。 提案では、ブロック約23,000,000時点で最大のコントラクトが約8,000万スロットを持つ例を挙げています。 この規模でも storage_count のペイロード長は4バイトで、アカウントごとの追加は最大6バイトです。

かなり保守的に、2,300万個のコントラクトアカウントすべてが最大6バイトを追加で持つと仮定しても、上限は約138MBです。 実際には多くのコントラクトが1から3バイトの範囲に収まるため、状態サイズへの追加は限定的です。

設計根拠

EIP8032の狙いは、ストレージを増やし続けるコントラクトに摩擦を作ることです。 あるコントラクトがしきい値を超えるほど大きくなった時、新しいコントラクトへ分ければ追加コストを抑えられます。 ただしその場合でも、コントラクト作成コストや既存インスタンスへの呼び出しコストがかかります。 そのため、状態を無制限に増やす設計だけが安く残り続ける状況を避けられます。

ACTIVATION_THRESHOLD は、大きくても有用なコントラクトを罰するための値ではありません。 有用なサービスとして大きな状態を持つコントラクトと、スパム用途で肥大化するコントラクトを分けるための境界です。 Ethereum全体の状態サイズが増えた時は、このしきい値を引き上げる余地があります。

TRANSITION_SLOTS_PER_BLOCK は、1ブロックで読み取るストレージスロット数を制限します。 一方、TRANSITION_MAX_ACCOUNTS は、1ブロックで storage_count を書き込むアカウント数を制限します。 前者は読み取り負荷を抑え、後者は書き込み増幅や大きなデータベース圧縮を避けるために使われます。

深さベース課金との比較

別案として、ストレージツリーの深さに応じて課金する方法があります。 提案では、最大規模のコントラクト例としてXENを挙げ、関連するストレージキーがMPT上で約9ニブルの共通プレフィックスを持つと説明しています。

しかしストレージキーは keccak256(slot_key) でツリーに入るため、同じような深さになる入力をオフチェーンで探せます。 つまり、深さだけに基づく課金では、大きい状態を作っても同じコストに見せる余地が残ります。 EIP8032は、ツリー上の深さではなく、アカウントが持つ非ゼロスロット数そのものを使います。

移行処理が必要な理由

フォーク後の新しい書き込みだけを数える設計では、既存の大きなコントラクトが storage_count = 0 から始まります。 その場合、過去に作られた大きなストレージの負担がガス計算へ反映されるまで時間がかかります。 大きなコントラクトが過去スロットを書き換えない限り、実際の状態サイズに対して低いコストが続きます。

そこで提案は、コンセンサスルールとして既存ストレージを段階的に数えます。 進捗カーソルを状態ルート配下に保存するため、クライアントごとの差やリオーグ時の特別処理を避けられます。

Verkleとバイナリ統合ツリーとの関係

現在の2層MPTでは、コントラクトストレージの書き込みはアカウントパスとストレージパスの両方に関わります。 そのため、コントラクトが持つストレージ量と、クライアントが負担するツリー更新やデータベース処理のコストには関係があります。

一方、将来の統合ツリーでは、1回の SSTORE コストが特定コントラクトの総スロット数と同じようには連動しません。 ツリー全体の分岐数、高さ、状態キーの分布が効いてくるためです。 そのため、統合ツリーが導入された後は、アカウントごとのスロット数に基づく段階的課金が実際の処理負荷とずれます。

EIP-2926との関係

EIP-2926は、コントラクトコードをチャンク化してマークル化する提案です。 この提案でも、既存アカウントを走査して追加フィールドを入れる移行処理が必要になります。 EIP8032と同じフォークで実施する場合、移行処理をまとめることで、重複作業とクライアント実装の複雑さを減らせます。

EIP-2926: Chunk-Based Code Merkleization https://eips.ethereum.org/EIPS/eip-2926

互換性

コントラクトのインターフェースや通常のウォレット操作は変わりません。 storage_count が存在しないアカウントは 0 として扱われるため、移行処理でまだ到達していないコントラクトには追加ガスがかかりません。

一方で、これはガス再価格付けを伴う変更です。 そのため、ネットワークアップグレードとして導入する必要があります。 ノード運用者とRPC(ウォレットやアプリがノードへ問い合わせる仕組み)実装は、eth_estimateGas(トランザクション実行前に必要なガス量を見積もるRPC)が SSTORE の新しい式を反映するように更新する必要があります。

テストケース

提案では、テストケースはまだ TODO とされています。 今後は少なくとも、以下の観点がテスト対象になります。

  • storage_count が存在しないアカウントを 0 として扱う。
  • 0 から非ゼロへの書き込みで storage_count が +1 される。
  • 非ゼロから 0 への書き込みで storage_count が -1 される。
  • 同じブロック内の SSTORE がブロック開始時の S_pre(A) を使う。
  • 移行中のカーソルと差分適用がリオーグ後の再実行で一致する。

参考実装

提案では、参考実装もまだ TODO とされています。 仕様上の実装対象は、storage_count のアカウントRLPエンコード、移行カーソルの状態管理、ブロック後の差分更新、SSTORE ガス計算の4つです。

クライアント実装では、毎ブロックすべてのストレージを読み直すのではなく、ブロック前状態とブロック後状態の差分から storage_count を更新する必要があります。 また、ガス計算で使う値は更新後の値ではなく、ブロック開始時点の S_pre(A) です。

セキュリティ

提案のセキュリティ考察は、まだ議論が必要な状態です。 ただし、仕様から見える主な確認点はあります。

まず、移行処理はコンセンサスに入るため、クライアント間でアカウントハッシュとスロットハッシュの走査順が一致する必要があります。 順序やカーソル保存に差が出ると、storage_count と状態ルートが一致しません。

また、eth_estimateGas(トランザクション実行前に必要なガス量を見積もるRPC)が古い式のままだと、ストレージの大きいコントラクトで見積もり不足が起きます。 S_pre(A) をブロック内で固定する設計は、この見積もりの安定性を守るために重要です。

さらに、TRANSITION_SLOTS_PER_BLOCK と TRANSITION_MAX_ACCOUNTS は、移行処理の負荷を直接左右します。 値が大きすぎるとブロック末尾の処理が重くなり、値が小さすぎると移行完了までの期間が長くなります。 この2つの値は、クライアントの最小ハードウェア要件と状態サイズを踏まえて決める必要があります。

最後に

今回は「コントラクトのストレージ量に応じて SSTORE のガスコストを変えるEIP8032」についてまとめてきました。

EIP8032は、状態肥大化のコストを SSTORE のガスに反映する提案です。 storage_count をアカウントデータへ追加し、既存状態を段階的に数え、ブロック開始時点の値を使って追加コストを決めます。 具体的な定数、テストケース、参考実装は未確定ですが、Ethereumの長期的な状態管理とガス設計に関わる重要な提案です。

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