EIP-1153's transient storage is the best thing to happen to gas optimization since the London hard fork. It's also quietly breaking reentrancy assumptions that the entire ecosystem has relied on for years.
If you're still using address.transfer() as a reentrancy barrier, or you think your ReentrancyGuard is bulletproof because "it worked before Dencun" — this article is your wake-up call.
The Promise: 97% Cheaper Reentrancy Guards
Let's start with the good news. Traditional reentrancy guards using SSTORE/SLOAD cost roughly 7,100 gas per protected function call. Transient storage (TSTORE/TLOAD) drops that to ~200 gas.
// Traditional: ~7,100 gas per guarded call
contract OldGuard {
uint256 private _status;
modifier nonReentrant() {
require(_status != 2, "ReentrancyGuard: reentrant call");
_status = 2; // SSTORE: ~5,000 gas (cold)
_;
_status = 1; // SSTORE: ~2,100 gas (warm → original)
}
}
// Transient: ~200 gas per guarded call
contract TransientGuard {
modifier nonReentrant() {
assembly {
if tload(0) { revert(0, 0) }
tstore(0, 1) // ~100 gas
}
_;
assembly {
tstore(0, 0) // ~100 gas
}
}
}
Over 50% of all transient storage usage on mainnet today is reentrancy guards. Uniswap V4, Balancer, and dozens of major protocols have already migrated. The savings are real.
But so are the new attack surfaces.
Trap #1: Low-Gas Reentrancy (The Big One)
This is the vulnerability that security firms keep flagging and developers keep ignoring.
The old assumption: address.transfer() and send() forward only 2,300 gas to the recipient. That's not enough to execute a meaningful SSTORE (minimum ~5,000 gas cold), so reentrant calls through these functions effectively can't modify storage. Many contracts relied on this as an implicit reentrancy barrier.
What changed: TSTORE costs ~100 gas. With 2,300 gas, an attacker can execute roughly 20 transient storage writes. If your contract uses transient storage for its reentrancy lock but sends ETH via transfer(), the callee can now:
- Receive the callback with 2,300 gas
- Call back into your contract
- Your transient guard is already set — but only if you set it before the external call
The real danger is when developers partially migrate: they upgrade the guard to transient storage but leave transfer() calls in place, assuming the 2,300 gas limit still provides secondary protection. It doesn't.
// VULNERABLE: Mixed assumptions
contract DangerousVault {
modifier nonReentrant() {
assembly {
if tload(0) { revert(0, 0) }
tstore(0, 1)
}
_;
assembly { tstore(0, 0) }
}
// Developer thinks: "transfer() is safe because gas limit"
// Reality: if this function ISN'T guarded, the attacker
// can reenter through it with 2,300 gas and still
// interact with transient storage
function withdrawSmall(address payable to) external {
// No nonReentrant modifier — "transfer is safe"
uint256 amount = balances[msg.sender];
balances[msg.sender] = 0;
to.transfer(amount); // 2,300 gas is enough for TSTORE
}
}
Fix: Apply nonReentrant to every function that makes external calls, regardless of gas forwarding. Better yet, use call{} with proper checks-effects-interactions and stop relying on gas limits as a security mechanism entirely.
Trap #2: Transient ≠ Temporary (Per-Call)
Developers coming from memory semantics often assume transient storage is scoped to the current call frame. It's not.
Transient storage persists for the entire transaction. This means:
- Values survive across
CALL,DELEGATECALL, andSTATICCALLboundaries - Values persist even after the callee reverts (since EIP-1153 state is per-transaction, not per-frame)
- Critically: Values set in one internal call are visible in subsequent internal calls within the same transaction
contract SubtleBug {
// Transient flag intended to be "per-operation"
function processA() external {
assembly { tstore(0x42, 1) } // Set flag
_doSomething();
// Developer expects 0x42 to be cleared here
// But they forgot to clear it!
}
function processB() external {
assembly {
// Expects 0x42 to be 0 (fresh)
// But if called in same tx after processA,
// it's still 1!
if tload(0x42) { revert(0, 0) }
}
}
}
In a single transaction (e.g., through a multicall router), processA() followed by processB() will cause processB to revert unexpectedly — or worse, skip a critical check if the logic is inverted.
Fix: Always explicitly clear transient storage slots at the end of each function. Use the pattern: set → execute → clear. Never assume a slot starts at zero within a transaction.
Trap #3: Slot Collisions in Proxy Patterns
Traditional storage slot collisions in proxy patterns are well-understood. EIP-1967 gave us standard slots, and the community learned the hard way about storage layout conflicts.
Transient storage introduces the same problem in a new dimension. If your implementation contract uses tstore(0, 1) for a reentrancy lock, and a library called via DELEGATECALL also uses tstore(0, ...) for its own purposes — they'll collide silently.
// Implementation contract
contract Vault {
modifier nonReentrant() {
assembly {
if tload(0) { revert(0, 0) } // Slot 0
tstore(0, 1)
}
_;
assembly { tstore(0, 0) }
}
}
// Library called via delegatecall
library PriceOracle {
function getPrice() internal returns (uint256) {
assembly {
// Also uses slot 0 for caching!
let cached := tload(0)
if cached { mstore(0, cached) return(0, 32) }
// ...
}
}
}
The reentrancy guard writes 1 to slot 0. The oracle reads slot 0 and interprets 1 as a cached price. Congratulations, your oracle now returns 1 wei as the asset price.
Fix: Use namespaced slot constants:
// Derive unique slots from meaningful identifiers
bytes32 constant REENTRANCY_SLOT = keccak256("myprotocol.reentrancy.lock");
bytes32 constant ORACLE_CACHE_SLOT = keccak256("myprotocol.oracle.cache");
modifier nonReentrant() {
assembly {
if tload(REENTRANCY_SLOT) { revert(0, 0) }
tstore(REENTRANCY_SLOT, 1)
}
_;
assembly { tstore(REENTRANCY_SLOT, 0) }
}
This mirrors EIP-1967's approach but for transient storage. OpenZeppelin's ReentrancyGuardTransient already does this — use it.
Trap #4: Read-Only Reentrancy Still Works
Transient storage guards protect against write reentrancy (calling back into state-modifying functions). But read-only reentrancy — where an attacker reads stale state during a callback — is unaffected.
Consider a lending protocol that updates its exchange rate in transient storage during a deposit:
function deposit(uint256 assets) external nonReentrant {
uint256 shares = previewDeposit(assets);
// Exchange rate updated in transient storage
assembly { tstore(RATE_SLOT, newRate) }
// External call: transfer tokens from user
token.transferFrom(msg.sender, address(this), assets);
// Mint shares based on new rate
_mint(msg.sender, shares);
}
// Anyone can read the exchange rate
function getExchangeRate() public view returns (uint256) {
// Reads from PERSISTENT storage — not transient!
return totalAssets() / totalSupply();
}
During the transferFrom callback, getExchangeRate() still returns the pre-deposit value because persistent storage hasn't been updated yet. Any protocol composing with this rate (e.g., for collateral valuation) gets a stale price.
Fix: Update persistent state before external calls (standard checks-effects-interactions). Transient storage is not a substitute for proper state ordering.
The Secure Migration Checklist
Moving to ReentrancyGuardTransient? Follow this:
-
Audit every function with external calls — apply
nonReentrantto all of them, not just the "obvious" ones -
Kill
transfer()andsend()— usecall{}with checks-effects-interactions. The gas limit "protection" is dead -
Namespace your transient slots —
keccak256("protocol.purpose"), not magic numbers - Clear transient storage explicitly — don't rely on transaction-end cleanup for intra-transaction correctness
- Test multicall paths — transient storage persistence across calls is the #1 source of unexpected behavior
-
Review all
DELEGATECALLtargets — check for transient slot collisions in libraries and modules - Don't mix guard types — if migrating, migrate the entire contract. Half-transient, half-persistent guards create confusing security assumptions
-
Use OpenZeppelin's
ReentrancyGuardTransient— it handles namespacing and cleanup correctly. Don't roll your own unless you have a specific reason
Detection: What to Look For in Audits
If you're auditing contracts using transient storage, prioritize these patterns:
| Red Flag | Why It Matters |
|---|---|
transfer() or send() in contracts using TSTORE
|
Low-gas reentrancy vector |
tstore without corresponding tstore(slot, 0) cleanup |
Cross-call state leakage |
Hardcoded transient slot numbers (0, 1, 2) |
Collision risk with libraries |
| Transient guard on some functions but not others | Inconsistent protection surface |
DELEGATECALL to libraries that also use TSTORE
|
Namespace collision |
Transient storage read in view functions |
Likely stale/zero — view calls don't inherit tx context |
The Bottom Line
EIP-1153 is a net positive for the ecosystem. The gas savings are substantial, and transient storage opens up patterns that were previously impractical. But it's not a drop-in replacement for persistent storage in security-critical contexts.
The 2,300 gas barrier is dead. The assumption that transfer() prevents meaningful state changes is dead. And if you're building a reentrancy guard with raw TSTORE at slot 0, you're one DELEGATECALL away from a very bad day.
Use the standard libraries. Namespace your slots. Guard every external call. And stop treating gas limits as security features — they never were, and now they definitely aren't.
This article is part of the DeFi Security Research series. Follow for deep dives into smart contract vulnerabilities, audit techniques, and security best practices.
Top comments (0)