Transient storage (EIP-1153) has gone from a niche Cancun opcode to a load-bearing primitive: OpenZeppelin ships ReentrancyGuardTransient, Uniswap V4 uses it for its lock and balance deltas, and gas-sensitive protocols are migrating their reentrancy guards to TSTORE/TLOAD to save ~5,000 gas per protected call.
The catch: transient storage does not behave like memory, and it does not behave like regular storage. It persists for the entire transaction and is cleared only when the transaction ends. That single semantic detail is where the bugs live. This post shows exactly how a transient reentrancy guard can silently stop protecting you, with a working exploit and the safe pattern.
What transient storage actually does
TSTORE/TLOAD (available in inline assembly since Solidity 0.8.24, and via the transient state-variable location since 0.8.28) read and write a key-value store that:
- is per contract address,
- is shared across every call frame to that address within one transaction, and
- is wiped to zero only at the end of the transaction (not at the end of a call).
That last point is the trap. Developers coming from memory assume transient values reset between calls. They do not. A value written during one external call is still there during the next call to the same contract — in the same transaction.
A transient reentrancy guard that looks correct
Here is a guard that compiles cleanly and looks like a drop-in replacement for the classic storage-based lock:
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.28;
contract Vault {
// transient: cleared automatically at end of transaction
bool transient private _locked;
modifier nonReentrant() {
require(!_locked, "REENTRANCY");
_locked = true;
_;
_locked = false; // released at end of function
}
mapping(address => uint256) public balance;
function withdraw(uint256 amount) external nonReentrant {
require(balance[msg.sender] >= amount, "INSUFFICIENT");
balance[msg.sender] -= amount; // effects before interaction
(bool ok, ) = msg.sender.call{value: amount}("");
require(ok, "SEND_FAIL");
}
function deposit() external payable {
balance[msg.sender] += msg.value;
}
receive() external payable {}
}
For single-entry reentrancy this is fine. The danger appears the moment the lock interacts with code paths that revert and get caught, or with multiple protected calls in one transaction.
Exploit 1: the lock that never releases
Consider a function that wraps an external call in try/catch so a single failing sub-call does not revert the whole batch — a very common relayer/aggregator pattern:
function batchPull(address[] calldata targets) external nonReentrant {
for (uint256 i; i < targets.length; ++i) {
// intent: tolerate individual failures
try IPullable(targets[i]).pull() {} catch {}
}
}
This is safe on its own. But now imagine a different protected function that calls back into the contract, and a path where _; does not run to completion because of an explicit early return inside the modifier body in a refactor, or an assembly block that sets _locked = true and relies on a later branch to clear it. If any reachable path sets the transient lock and then exits the transaction-level call tree without clearing it, the lock stays true for the rest of the transaction.
The result is not a fund loss — it is a denial of service: every subsequent nonReentrant call in the same transaction reverts with REENTRANCY, even though no attacker is reentering. For a contract that is composed inside multicalls, routers, or account-abstraction bundles, this turns "one of my actions failed" into "all of my actions in this bundle fail."
The root cause is the semantic difference from memory: with a memory or storage lock plus a normal modifier { ...; _; ...; }, the release always runs because Solidity guarantees the post-_ code executes on the normal path. But hand-rolled assembly guards, or guards combined with low-level control flow, can leave a transient slot dirty — and unlike a storage slot, you may not notice in a single-call unit test because a fresh transaction always starts clean.
Exploit 2: cross-call state leakage
Because transient storage survives between calls in a transaction, using it for anything other than a strict, always-released lock is dangerous. Suppose a contract caches a "current caller context" in transient storage to save gas:
address transient private _activeUser;
function start() external {
_activeUser = msg.sender; // set context
// ... do work, call out ...
}
function settle() external {
// assumes _activeUser is "this call's" user
_credit(_activeUser);
}
In one transaction an attacker calls start() (setting _activeUser to themselves), then routes execution — through a router, a callback, or a second protocol that calls back — into settle(). Because _activeUser was never cleared, settle() runs with stale context the attacker controls. Transient storage makes this class of "context confusion" cheap to introduce and easy to miss, because each function looks correct in isolation.
The safe pattern
- Prefer the audited primitive. Use OpenZeppelin's
ReentrancyGuardTransientrather than rolling your own. It guarantees the release.
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.28;
import {ReentrancyGuardTransient} from
"@openzeppelin/contracts/utils/ReentrancyGuardTransient.sol";
contract Vault is ReentrancyGuardTransient {
mapping(address => uint256) public balance;
function withdraw(uint256 amount) external nonReentrant {
require(balance[msg.sender] >= amount, "INSUFFICIENT");
balance[msg.sender] -= amount;
(bool ok, ) = msg.sender.call{value: amount}("");
require(ok, "SEND_FAIL");
}
receive() external payable {}
}
-
Only store always-cleared, transaction-scoped data. If you write a transient slot, there must be exactly one owner of its lifecycle that clears it on every exit path, including reverts you catch. If you cannot prove that, use
memory(per call) or regular storage instead. -
Never use transient storage for authorization context (
_activeUser-style). Pass the value explicitly as a function argument so it cannot leak across calls. -
Clear after use, not just "at end of tx." Relying on the EVM's end-of-transaction wipe is fine for a lock that is released in the same function, but it does not protect you from another call to the same contract later in the same transaction.
Pre-deployment checklist
- [ ] Every
TSTOREhas a matching clear on all exit paths (success and caught revert). - [ ] No transient slot is read by a function other than the one that set it in the same logical operation.
- [ ] Reentrancy guard is OpenZeppelin
ReentrancyGuardTransient, not a hand-rolled assembly lock. - [ ] No authorization or "current user/context" data lives in transient storage.
- [ ] Tests exercise multiple calls to the contract within a single transaction (multicall/router/bundle), not just one call per test.
FAQ
Is transient storage cleared between external calls?
No. It is cleared only at the end of the transaction. Within a transaction, all call frames to the same contract address share the same transient storage.
Why did my transient reentrancy guard pass all unit tests but break in production?
Most unit tests use one call per transaction, so the slot always starts clean. Bugs appear when a contract is called multiple times in one transaction (routers, multicall, AA bundles). Add tests that batch several calls into a single transaction.
Is ReentrancyGuardTransient safe to use?
Yes — it is designed to always release the lock on the normal return path. The risk is in hand-rolled guards or using transient storage for non-lock data.
Does this affect Uniswap V4 hooks?
V4 relies heavily on transient storage for its lock and currency deltas. Custom hooks that read or write transient state must respect V4's settlement lifecycle; leaving deltas unsettled is a documented hazard. Treat any transient read in a hook as security-critical.
Transient storage is a genuine gas win, but it trades a familiar mental model for a subtle one: transaction-scoped, not call-scoped. If you only ever use it for a guard that releases in the same function — ideally via OpenZeppelin's audited implementation — you get the savings without the footguns.
Want to know if your contract leaves a transient slot dirty or misuses TSTORE/TLOAD? Run it through ContractScan — free QuickScan, no signup required, with AI-augmented findings and fix guidance.