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

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です。
通常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)とTSTORE(0x5d)を定義します。どちらも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側です。
ローカル再現では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、採用判断から分離します。
確認した一次情報
- EIP-1153 Transient Storage Opcodes確認日: 2026/08/31
- Solidity Transient Storage確認日: 2026/08/31
- Solidity Inline Assembly確認日: 2026/08/31
- OpenZeppelin ReentrancyGuardTransient確認日: 2026/08/31
- OpenZeppelin TransientSlot確認日: 2026/08/31
- Foundry v1.8.0確認日: 2026/08/31



