3MIKAN
仮想通貨直コン

transient storage入門:EIP-1153のTSTORE・TLOADとreentrancy guardを試す

EIP-1153 transient storageのtransaction寿命、CALL・DELEGATECALLの所有context、revertとdirty lock、storage guardとのgas差をFoundryで検証します。

3MIKANのブランドキャラクターが作業終了時に光る再利用ボードを消し、同じ机の永久帳簿を残すEIP-1153 transient storageの記事画像

transient storageは、同じtransactionの複数callから共有でき、transactionが終わると消えるcontract-owned stateです。memoryのようにcallごとに作り直されるわけでも、通常storageのように次のtransactionへ残るわけでもありません。

この寿命はreentrancy guardや一時的なcallback flagに向きます。ただし「安いstorage」へ一括置換したり、function returnで自動cleanupされると思ったりすると、同じtransaction内の後続callをdirty lockが止めます。この記事ではEIP-1153のTSTORE / TLOADを、CALL、DELEGATECALL、revert、STATICCALL、guardのgasまでローカルで再現します。

memory・transient・storageの違いは寿命と所有者で決まる

最初に3つを「どこに属し、いつまで残るか」で分けます。

state 所有単位 同じtransactionの別call 次のtransaction 主な用途
memory call frame 原則共有しない 残らない 1回のfunction実行中の計算
transient storage contract addressのtransaction-scoped store 同じ所有contextなら共有 すべて0へ戻る lock、callback flag、一時承認
storage contract addressのpersistent store 共有 残る balance、owner、設定、会計state

「一時的だからmemory」は、nested callやcallbackから同じ値を読む必要があると成立しません。「複数callから読むからstorage」は、transaction後に不要な値の書込み・cleanupをpersistent stateへ持ち込みます。その中間がtransient storageです。

memory、transient storage、storageの所有単位とcall・transactionを越える寿命の比較

通常storageのslot layoutとtransient storageのslot layoutは別です。同じslot番号でも同じ領域ではありません。ERC-1967 proxyのslotとdelegatecall contextを調べるときも、persistent storageとtransient storeを混ぜずに追います。

TSTOREとTLOADの基本

EIP-1153はTLOAD(opcode 0x5c)とTSTORE0x5d)を定義します。どちらもword-addressedで、EIPの現在の規定では各100 gasです。ただしcontract全体のgas差は、周辺のSLOAD / SSTORE、calldata、分岐、refund、warm / cold accessを含むため、opcode単価だけから決めません。

Solidity 0.8.36ではvalue typeのstate variableへtransientを指定できます。今回の最小例は次の形です。

contract LifetimeStore {
    uint256 private transient _temporary;
    uint256 private _persistent;

    function setTransient(uint256 value) external {
        _temporary = value;
    }

    function transientValue() external view returns (uint256) {
        return _temporary;
    }
}

宣言時の初期化は使わず、必要なentrypointで値を書きます。配列、mapping、structなどreference typeのtransient state variableは、この確認環境のSolidityでは対象にしません。任意slotを明示したい場合はOpenZeppelin TransientSlot、または監査可能な定数slotとinline assemblyを使います。

CALLはcallee、DELEGATECALLはcallerのcontextを使う

transient storeは「transaction全体で1個」ではありません。通常のCALL / STATICCALLではcallee contractが所有者です。同じcontractを別frameから呼べば共有しますが、別contractの同じslotへは漏れません。

DELEGATECALL / CALLCODEでは、persistent storageと同様に呼出元の所有contextを使います。implementation codeがTSTOREしても、値が入るのはimplementation自身ではなくhost / proxy側です。

CALLでcalleeごとに分かれ、DELEGATECALLでhost側へ保存され、transaction終了時に全transient値が消える流れ

ローカル再現ではkeccak256("3mikan.eip1153.delegate.value")を共通slotにしました。hostがlogicをdelegatecallして29を書くと、同じcall中のhost readは29、logic addressへ通常CALLしたreadは0でした。slot定数が同じでも所有contractが違うためです。

操作 書込み先 同じtransaction内の観測
A → store AをCALL store Aのtransient store store Aの別frameから読める
A → store BをCALL store Bのtransient store store Aの同じslotとは別
host → logicをDELEGATECALL hostのtransient store hostで読める。logic自身では0
transaction終了 全contractのtransient store 次のtransactionは0から開始

proxyで使う場合はimplementationごとではなくproxy contextのslot衝突をreviewします。upgrade前後のlogicが同じtransient slotを別の意味に使えば、同一transaction内で干渉できます。

revertはそのcall frame以降のtransient writeも戻す

inner callがrevertすると、そのframeと内側で行ったtransient writeはpersistent storageと同じようにrollbackされます。外側で先に書いた値までtransaction全体から消えるわけではありません。

ローカル再現では外側が7を書き、inner callが99を書いてrevertしました。low-level callでrevertをcatchした後に読むと7です。

store.setTransient(7);
(bool success,) = address(store).call(
    abi.encodeCall(LifetimeStore.writeTransientThenRevert, (99))
);
require(!success);
require(store.transientValue() == 7);

一方、inner callがnormal returnした値はtransaction終了まで残ります。「functionを抜けたから0」は誤りです。cleanupする設計なら成功pathで明示的に0へ戻します。

STATICCALL中のTLOADは許可されますが、TSTOREはexceptionになります。ローカル再現はstatic readが5を返し、static writeが失敗し、元の5が変わらないことを確認しました。view functionから読むことと、static contextでlockを立てることは別です。

storage guardとtransient guardを同条件で比較する

OpenZeppelin Contracts 5.6.1にはpersistent storage版ReentrancyGuardと、EIP-1153対応chain専用のReentrancyGuardTransientがあります。今回の2 contractはguard以外を同じにしました。

contract TransientGuardCounter is ReentrancyGuardTransient {
    uint256 public count;

    function increment(address callback) external nonReentrant {
        count += 1;
        if (callback != address(0)) {
            IGuardCallback(callback).onGuardEntered(address(this));
        }
    }
}

callbackから同じincrementへ再入すると、storage版とtransient版の両方がnested callを拒否し、outer callだけが1回完了しました。guardの保存方式が違っても、protected external entrypointをprivate implementationへ分離する、checks-effects-interactionsを守る、callback surfaceを把握するといったreviewは残ります。

Anvilでincrement(address(0))をそれぞれ新しいtop-level transactionから1回だけ呼びました。

固定条件 storage guard transient guard 観測差
gas used 46,090 44,185 transientが1,905少ない

環境はFoundry / Anvil 1.8.0、Solidity 0.8.36、OpenZeppelin 5.6.1、viem 2.56.0、Prague EVM、optimizer有効、runs 200、chain ID 31337です。公開再現データへexact version、commit、contract address、gas receiptの集計を固定しました。

この1,905 gasはこのbytecodeと環境の観測値であり、全contract、全chain、全compiler設定の削減保証ではありません。導入候補では実際のentrypoint、optimizer、call sequence、chain hardforkを固定して再計測します。

dirty transient lockは同じtransactionの後続callを壊す

最も危険な誤解は「returnすればtransient stateも消える」です。次のunsafe functionはnormal returnしてもlockを残します。

function enterWithoutCleanup() external {
    if (_locked) revert Locked();
    _locked = true;
}

batcherがこれを呼んだ後、同じtransactionで通常のprotected functionを呼ぶとLockedで失敗しました。最初のcallがrevertしていないため、writeはrollbackされません。router、multicall、account abstraction、hook、callbackなど、1 transactionに複数interactionが入る環境ではcomposability failureになります。

正常なguardは成功pathでcleanupします。

modifier cleanGuard() {
    if (_locked) revert Locked();
    _locked = true;
    _;
    _locked = false;
}

このローカル再現では同じtransactionのcleanな2 callが成功しました。意図的なdirty returnの後は2回目が失敗し、dirty writeを行ったinner callがrevertした場合はrollbackされ、そのrevertをcatchした後のclean callが成功しました。さらにdirty lockだけを書いてtop-level transactionを終えると、次のtransactionではfalseへ戻りました。

path transaction内のlock 後続call
set → body → clear → normal return false 成功できる
set → clearなしでnormal return true 同じtransactionでは失敗
inner set → revertをcatch inner writeをrollback outerの以前の値から継続
dirtyのままtransaction終了 次transactionで0 / false 新しいtransactionは開始できる

cleanupを「gasを節約するため不要」と省かないでください。EIP-1153も、composabilityのため利用者がtransient slotを明示的にclearする設計を推奨しています。

temporary approvalと一時stateの使いどころ

transaction中だけ必要なtoken permissionはtransient storageの代表例です。ERC-7674 temporary approvalを含む権限比較では、grantとpullが同じtransactionなら使え、transaction境界後はunused amountも0になることを別のローカル再現で確認しました。

ほかに、次のような値が候補になります。

  • reentrancy lockやcallback中フラグ
  • 一連のnested callで共有する一時的な計算結果
  • transaction内のtill / balance-delta整合
  • routerやhookの一時context
  • 同一transactionだけ有効なauthorization

ただし1 function内だけで完結する値はmemoryの方が単純です。次のtransactionで読む必要があるnonce、balance、owner、configurationはstorageへ置きます。「削除し忘れてもtransactionで消える」ことを主目的にせず、所有contextと複数callでの共有が本当に要件かを先に決めます。

採用前のreview checklist

  • deployment chainがEIP-1153を有効化したhardfork以降か確認した
  • compiler、EVM version、library、optimizer設定を固定した
  • 値がcall frame、transaction、複数transactionのどこまで必要か決めた
  • CALLならcallee、DELEGATECALLならcaller / proxyがownerになると確認した
  • proxyとimplementationのtransient slot衝突をreviewした
  • success、revert、catch、nested callback、STATICCALLをtestした
  • normal returnで0へ戻すcleanup pathがある
  • cleanup前に外部callして再入されるpathをtestした
  • multicall / router / hookで同じtransactionの2回目が成功する
  • dirty stateを意図的に残すfailure testがある
  • next transactionで0へ戻ることをAnvil等の実transaction境界で確認した
  • storage guardとのgas比較を同じfunction、calldata、compiler条件で行った
  • benchmark値を普遍的な削減率や安全保証として広告していない
  • transient storage非対応chainへ同じbytecodeを配置しない

次の検証段階では、unit testの例を増やすだけでなく、任意の操作列でも「clean return後のlockはfalse」「revert後に以前の値へ戻る」といった性質を守るかをstatefulに調べます。Foundry invariant testの後続記事で、この検証例から機械的な不変条件へつなげます。

transient storageの採用判断で重要なのは、opcodeが安いという一点ではありません。値の所有context、同じtransactionで共有するcall、normal returnのcleanup、revert rollback、次transactionの0を一つの操作列で確認することです。

この記事にスポンサー、affiliate、wallet接続、署名要求、transaction送信、資産操作のCTAはありません。将来compiler、testing、audit、RPC、developer toolingの広告を置く場合も広告であることを明示し、再現データ、gas report、failure test、採用判断から分離します。

確認した一次情報