はじめに

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

今回は、Transient Storage(一時ストレージ)のTLOAD/TSTORE操作のガスコストを大幅に引き下げつつ、トランザクション全体で使えるスロット数にハードリミットを設けてDoS攻撃を防ぐ仕組みを提案しているEIP7971についてまとめていきます!

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

他にも様々なEIP・BIP・SLIP・CAIP・ENSIP・RFC・ACPについてまとめています。

概要

EIP7971とは、EIP1153で導入されたTransient Storage(トランザクション実行中だけ存在する一時的なストレージ)のガスコストを引き下げ、同時にトランザクション全体で使用できるスロット数に上限(ハードリミット)を設ける提案です。

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

Transient Storageは永続的なストレージとは異なり、ディスクへの書き込みもステートルートの更新も必要ありません。 にもかかわらず、現在のガスコストは永続ストレージのウォームアクセスと同じ100ガスに設定されています。 EIP7971はこの価格の不整合を解消し、TLOADを5ガス、TSTOREを12ガスまで引き下げます。

ガスコストを下げるとDoS攻撃(大量のスロットを確保してメモリを枯渇させる攻撃)のリスクが高まるため、トランザクション全体で使用できるユニークスロット数を131,072に制限します。 この制限により、最大メモリ使用量は約8MBに抑えられます。

動機

リエントランシー保護のコスト問題

リエントランシー攻撃(外部コントラクトの呼び出し中に再度自身の関数が呼ばれる攻撃)は、スマートコントラクトの脆弱性として最も一般的なものの一つです。 Transient Storageを使えばリエントランシーロック(再入を防ぐフラグ)を実装できますが、ロック取得(TSTORE)+ チェック(TLOAD)+ 解除(TSTORE)で最低300ガスかかります。

リエントランシー攻撃

リエントランシー攻撃とは、コントラクトが外部のコントラクトを呼び出している最中に、呼び出し先から元のコントラクトの関数が再度呼び出される攻撃パターンです。 残高の更新前に外部呼び出しが行われると、攻撃者は残高が更新される前に何度も引き出しを実行できてしまいます。

このコストは「すべてのコントラクトにデフォルトでリエントランシー保護を組み込む」という言語レベルの対応を妨げる要因になっています。 EIP7971ではTSTOREが12ガス、TLOADが5ガスになるため、リエントランシーロックの合計コストは29ガス(12+5+12)まで下がります。 これにより、言語コンパイラがデフォルトでリエントランシー保護を有効にすることが現実的になります。

Transient Storageの活用が進まない理由

リエントランシーロック以外にも、Transient Storageには以下のような有用なユースケースがあります。

ユースケース 説明
一時的な承認 Transfer時に一時的なapprovalを設定し、コールバック完了後に自動消滅させる。
コールバックメタデータ コールバック関数に渡すメタデータをTransient Storageに保存する。
フレーム間通信 同一トランザクション内の異なるメッセージコール間でデータを受け渡す。

しかし、1操作100ガスというコストは、これらのユースケースでの利用を躊躇させるほど高い設定です。 EIP7971のコスト削減により、開発者がTransient Storageを積極的に活用しやすくなります。

ディスクI/Oがないのに同じ価格設定は不整合

永続ストレージ(SLOAD/SSTORE)のウォームアクセスが100ガスなのは、ディスクI/Oやキャッシュ管理のコストを反映しています。 一方、Transient Storageはトランザクション終了時に破棄されるメモリ上のデータであり、ディスクへの書き込みもステートルートの更新も発生しません。 実際に消費するリソースが少ないにもかかわらず同じ価格が設定されているのは、技術的な実態と乖離しています。

この提案による解決策

EIP7971は「定額の低コスト化」と「ハードリミット」を組み合わせることで、上記の課題を解決します。 ガスコストを実態に合わせて引き下げつつ、スロット数の上限を設けることでDoS攻撃を防止します。 これにより「安くて安全」な一時ストレージが実現します。

仕様

パラメータ定義

EIP7971で導入されるパラメータは以下の通りです。

パラメータ 値 説明
GAS_TLOAD 5 TLOADのガスコスト。メモリからの読み取りのみのため低コスト。
GAS_TSTORE 12 TSTOREの基本ガスコスト。メモリへの書き込みとrevert用のジャーナリングを含む。
GAS_TSTORE_ALLOCATE 24 新規スロット割り当て時の追加ガスコスト。既存スロットへの書き込みより高い。
MAX_TRANSIENT_SLOTS 131,072 トランザクション全体で使用可能なユニークスロットの上限。

ガスコスト変更

TLOAD(オペコード0x5c)のガスコストが100ガスから5ガスに、TSTORE(オペコード0x5d)の基本ガスコストが100ガスから12ガスに引き下げられます。

新規スロットに初めて書き込む時は、基本コスト(12ガス)に加えてスロット割り当てコスト(24ガス)がかかるため、合計36ガスになります。 同じスロットに2回目以降に書き込む時は基本コストの12ガスのみです。

以下の図は、現行のガスコストとEIP7971のガスコストを比較したものです。

fig1-gas-cost-comparison.png

操作 現在のコスト EIP7971のコスト
TLOAD(読み取り) 100ガス 5ガス
TSTORE(既存スロットへの書き込み) 100ガス 12ガス
TSTORE(新規スロットへの書き込み) 100ガス 36ガス(12 + 24)

トランザクション全体のスロット制限

EIP7971の核となる仕組みが、トランザクション全体で共有されるスロットカウンターです。 以下のフローで動作します。

flowchart TD
    A[トランザクション開始] --> B["transient_slots_used = 0\nに初期化"]
    B --> C[TSTORE実行]
    C --> D{"このスロットは\n初回書き込み?"}
    D -- はい --> E["transient_slots_used++"]
    E --> F{"131,072を\n超えた?"}
    F -- はい --> G[トランザクション異常停止]
    F -- いいえ --> H[書き込み実行]
    D -- いいえ --> H
    H --> I[トランザクション終了]
    I --> J["カウンターを0にリセット"]

フローの各ステップを説明します。 まず、トランザクション開始時にカウンターtransient_slots_usedを0に初期化します。 TSTOREが実行されるたびに、そのスロットがトランザクション内で初めて書き込まれるものかどうかを判定します。 初回書き込みの場合はカウンターをインクリメントし、131,072を超えるとトランザクションが異常停止します。 トランザクション終了時にカウンターは0にリセットされ、Transient Storageの内容もすべて破棄されます。

このカウンターがトランザクション全体で共有される理由は、コントラクト単位で制限すると、コールスタックの形状によってリソース消費の推論が複雑になるためです。 トランザクション全体で1つのカウンターを使うことで、クライアントはトランザクション開始時に必要な全メモリを事前に確保できます。

スロットのユニーク判定は (contract_address, storage_key) のタプルで行います。 つまり、異なるコントラクトの同じキーは別のスロットとしてカウントされます。 同一スロットへの複数回書き込みは、最初の1回だけカウンターをインクリメントします。

このカウンターはトランザクション内のすべてのメッセージコール(内部呼び出し)を通じて共有されます。 あるコントラクトが使ったスロット数は、そのトランザクション内で呼び出される他のコントラクトにも影響します。

revert時の挙動

コントラクト呼び出しがrevertした場合、そのコール内で割り当てたスロットは解放されます。 そのため、revert後に同じスロットに再度書き込むと、再びカウンターがインクリメントされます。

補足

なぜ定額制+ハードリミットなのか

EIP7971が「定額の低コスト」と「ハードリミット」の組み合わせを選んだ理由は、他の設計との比較で明確になります。

設計アプローチ メリット デメリット
定額制+ハードリミット(この提案) リソース消費の上限が明確で推論しやすい。一般的なユースケースのコストが低い。 スロット数上限に達するとトランザクションが失敗する。
コントラクト単位の制限 コントラクトごとに独立した制限を設けられる。 コールスタックの形状に依存するため、リソース消費の推論が複雑になる。
超線形価格設定 大量使用時に自然にコストが上がる。 一般的なユースケースでもペナルティを受ける可能性がある。
制限なし 実装が最もシンプル。 ガスリミットや価格の変更時にメモリベースのDoS攻撃が可能になる。

ハードリミットの利点は、プロトコルの他のパラメータ(ガスリミットなど)が変更されても、リソース消費の上限が予測可能であることです。 クライアント実装はトランザクション開始時にTransient Storageに必要な全メモリを事前に確保できるため、実行中のメモリ割り当てによるオーバーヘッドを避けられます。

ガスコストの根拠

各操作のガスコストは、必要な処理の種類に基づいて設定されています。

  • GAS_TLOAD(5ガス) メモリからの読み取りのみで、ディスクI/Oもジャーナリングも不要です。 永続ストレージのウォームアクセスよりも軽い操作であるため、低いコストが適切です。
  • GAS_TSTORE(12ガス) メモリへの書き込みに加え、revert時に元の値に戻すためのジャーナリング(変更履歴の記録)が必要です。 読み取りより複雑な処理が含まれるため、TLOADよりも高い設定になっています。
  • GAS_TSTORE_ALLOCATE(24ガス) 新規スロットの割り当ては、既存スロットへの書き込みよりも高コストな処理です。 メモリの新規確保が発生するため、追加コストとして課金されます。

ベンチマーク未確定

この提案のガスコスト値はまだ最終確定していません。 Transient Storage操作のベンチマークが現在開発中であり、測定データに基づいて最終的な値が決定される予定です。 キャッシュからのウォームストレージ読み取りはTransient Storage読み取りと同程度のパフォーマンス特性を持つと考えられているため、最終値はEIP8038と整合させる必要があります。

メモリ使用量の比較

スロット数制限の効果を数値で見てみます。 以下の図は、現行とEIP7971のメモリ使用量の比較です。

fig2-memory-comparison.png

項目 現行(100ガス/操作) EIP7971
60Mガスで確保可能なスロット数 600,000 131,072(ハードリミット)
1スロットあたりのメモリ 64バイト 64バイト
最大メモリ使用量 約38.4MB 約8MB
削減率 - 79%削減

現行の仕組みでは60Mガスを使い切ることで最大約38.4MBのメモリを消費できてしまいます。 EIP7971ではハードリミットにより、ガスをいくら投入しても最大8MBまでしか使えません。 メモリベースのDoS攻撃に対する耐性が大幅に向上します。

参考実装

実装例

以下はトランザクション実行時のTransient Storageスロット制限を含む疑似コード(Python記法)です。

python
GAS_TLOAD = 5
GAS_TSTORE = 12
GAS_TSTORE_ALLOCATE = 24
MAX_TRANSIENT_SLOTS = 131072

class TransactionContext:
    def __init__(self):
        self.transient_storage = {}  # (address, key) -> value
        self.unique_slots = set()    # set of (address, key) tuples
        self.transient_slots_used = 0

    def tload(self, address, key):
        # ガスを課金
        self.charge_gas(GAS_TLOAD)

        # 値を返す(未設定ならゼロ)
        return self.transient_storage.get((address, key), 0)

    def tstore(self, address, key, value):
        # 基本ガスを課金
        self.charge_gas(GAS_TSTORE)

        # 新規スロットかチェック
        slot_id = (address, key)
        if slot_id not in self.unique_slots:
            # 新規割り当てコストを追加課金
            self.charge_gas(GAS_TSTORE_ALLOCATE)

            self.unique_slots.add(slot_id)

            # スロット上限チェック
            if len(self.unique_slots) > MAX_TRANSIENT_SLOTS:
                raise ExceptionalHalt("Transient storage limit exceeded")

        # 値を保存
        self.transient_storage[slot_id] = value

この実装では、tstoreが呼ばれるたびにスロットが新規かどうかをunique_slotsセットで判定しています。 新規スロットの場合は追加のガスコスト(GAS_TSTORE_ALLOCATE)が課金され、スロットカウンターが上限を超えるとトランザクションが異常停止します。 tloadは単純にメモリから値を読み取るだけで、スロットカウンターには影響しません。

互換性

この提案による互換性の問題は特に知られていません。 影響があるのはガスコストの変化のみです。 既存のTLOAD/TSTOREを使用するコントラクトは修正なしで動作しますが、ガスコストをハードコードしている場合は更新が必要になります。 ガスコストが下がる方向の変更であるため、既存のトランザクションがガス不足で失敗するような問題は発生しません。

セキュリティ

メモリ制限によるDoS防止効果

MAX_TRANSIENT_SLOTSを131,072に設定することで、トランザクションあたりの最大メモリ確保量は約8MB(131,072 × 64バイト)に制限されます。 現行のガスコスト(100ガス/操作)では60Mガスのトランザクションで最大600,000スロット(約38.4MB)を確保できるため、EIP7971は最大確保量を79%削減します。

ガスコストの引き下げだけではDoSリスクが増大しますが、ハードリミットの導入によりガスコストとは独立してリソース消費の上限が保証されます。 将来のプロトコル変更(ガスリミットの引き上げなど)があっても、メモリ使用量の上限は変わりません。

引用

最後に

今回は「EIP7971によるTransient Storageのガスコスト削減とハードリミット導入」についてまとめてきました。 いかがだったでしょうか?

リエントランシー保護のコストが200ガスから17ガスに下がることで、Solidityコンパイラがデフォルトでリエントランシーガードを有効にすることが現実的になります。 同時にハードリミットでメモリベースのDoS攻撃を防止するという、「安くて安全」を両立した設計が特徴です。

質問などがある方は以下のTwitterのDMなどからお気軽に質問してください!

他の媒体でも情報発信しているのでぜひ他も見ていってください!