The Transient Storage Trap: Why EIP-1153's Gas Savings Are Creating a New Generation of Smart Contract Vulnerabilities
EIP-1153 promised cheaper gas. It delivered — along with an entirely new attack surface that most developers still don't understand.
The 100-Gas Problem That Changed Everything
For years, Solidity developers relied on a simple mental model: address.transfer() and address.send() forward only 2,300 gas. That's not enough for an SSTORE (5,000+ gas for a cold write), so basic ether transfers were considered inherently reentrancy-safe.
Then TSTORE arrived at 100 gas.
That's well within the 2,300 gas stipend. A single opcode just invalidated years of reentrancy assumptions. Code that was "safe" on March 12, 2024 (pre-Dencun) became exploitable on March 13.
// This was safe before EIP-1153. It isn't anymore.
contract VulnerableVault {
mapping(address => uint256) public balances;
function withdraw() external {
uint256 amount = balances[msg.sender];
balances[msg.sender] = 0;
// 2,300 gas stipend — "safe" from reentrancy, right?
payable(msg.sender).transfer(amount);
}
}
If the recipient is a contract that uses transient storage to coordinate state across calls within the same transaction, the 2,300 gas is plenty. The reentrancy guard you never thought you needed? You need it.
Persistence Within, Cleared Between: The Mental Model Gap
Here's where developers get confused. Transient storage:
-
Persists across
CALL,DELEGATECALL,STATICCALLwithin a transaction - Persists across callback hooks (Uniswap V3/V4, flash loans)
- Persists across re-entrant calls
- Clears only at the end of the transaction
This means transient storage behaves like a transaction-scoped global variable — not a function-scoped local. If you TSTORE a value in function A, it's still there when function B runs in the same transaction, even across contract boundaries.
// Dangerous pattern: reusing transient slots across callbacks
contract RiskyDEX {
// Slot 0: stores pool address for verification
// Slot 0: also stores swap amount in callback
// See the problem?
function swap(address pool, uint256 amount) external {
assembly {
tstore(0, pool) // Store pool for verification
}
IPool(pool).executeSwap(amount);
}
function swapCallback(uint256 amountOwed) external {
assembly {
let storedPool := tload(0)
// This SHOULD be the pool address...
// But if executeSwap triggered another call
// that overwrote slot 0, it's garbage now
}
}
}
The SIR.trading Postmortem: $355K Lost to a Single Slot
In March 2025, SIR.trading lost $355,000 because of exactly this pattern. Their Vault contract used transient storage to temporarily store a Uniswap V3 pool address in uniswapV3SwapCallback(). Within the same transaction, the same transient storage slot was overwritten with a user-controlled value.
The attacker:
- Used CREATE2 to deploy a contract at a vanity address that passed the pool verification check
- Triggered a swap that stored the legitimate pool address in transient storage
- Within the callback chain, overwrote that slot with their controlled value
- Bypassed authentication, minted synthetic assets, and drained the vault
The fix was trivial: don't reuse transient storage slots, and don't use transient storage for authentication. The cost of learning that lesson was $355,000.
The Smart Contract Wallet Amplifier
EIP-1153 was designed with an implicit assumption: one user per transaction (the EOA model). ERC-4337 account abstraction and Safe multisigs broke that assumption.
Smart contract wallets batch multiple user operations into a single transaction. If transient storage isn't namespaced by caller, you get cross-user contamination:
// UserOp 1: Alice sets a transient approval
assembly { tstore(APPROVAL_SLOT, 1) }
// UserOp 2: Bob's operation runs in the same transaction
// Bob can now read Alice's transient approval
assembly { let approved := tload(APPROVAL_SLOT) }
// approved == 1, even though Bob never approved anything
This isn't theoretical. Any protocol using transient storage for temporary approvals or locks in a system that processes batched operations is vulnerable.
Five Rules for Safe Transient Storage
1. Treat TSTORE Like SSTORE for Security Analysis
Every reentrancy check, every state ordering concern, every access control pattern that applies to persistent storage applies to transient storage. The only difference is the gas cost and the auto-clear at transaction end.
2. Explicit Cleanup, Always
Don't rely on end-of-transaction clearing. Clear transient slots immediately after use:
function safeCallback() external {
assembly {
let value := tload(0)
// Use value...
tstore(0, 0) // Clear immediately
}
}
3. Namespace by Caller
For any protocol that might be called within a batched transaction:
function getTransientSlot(address caller, bytes32 key) internal pure returns (bytes32) {
return keccak256(abi.encodePacked(caller, key));
}
function setTransientValue(bytes32 key, uint256 value) internal {
bytes32 slot = getTransientSlot(msg.sender, key);
assembly { tstore(slot, value) }
}
4. Never Use Transient Storage for Authentication
The SIR.trading exploit proved this definitively. Use persistent storage or immutable values for anything security-critical:
// BAD: transient auth
function callback() external {
assembly {
let authorizedPool := tload(AUTH_SLOT)
if iszero(eq(caller(), authorizedPool)) { revert(0, 0) }
}
}
// GOOD: persistent auth
mapping(address => bool) public authorizedPools;
function callback() external {
require(authorizedPools[msg.sender], "unauthorized");
}
5. Use OpenZeppelin's Transient Reentrancy Guards
OpenZeppelin has shipped production-ready transient reentrancy guards that cost ~100 gas instead of ~5,000:
import "@openzeppelin/contracts/utils/ReentrancyGuardTransient.sol";
contract SafeVault is ReentrancyGuardTransient {
function withdraw() external nonReentrant {
// Protected at 100 gas instead of 5,000
}
}
The Audit Checklist
When reviewing contracts that use EIP-1153:
- Slot reuse across callbacks (Critical) — Same slot written in both initiating function and callback
- Authentication via TLOAD (Critical) — Any require/if that depends on a transient value
- Missing cleanup (High) — TSTORE without corresponding TSTORE(key, 0)
- No caller namespacing (High) — Bare slot numbers without msg.sender prefix
- transfer()/send() + TSTORE (High) — Low-gas calls to contracts that use transient storage
- DELEGATECALL context (Medium) — Transient storage shared with calling contract's context
- Cross-operation contamination (Medium) — Protocols used within ERC-4337 bundles
The Bigger Picture
EIP-1153 is a powerful optimization. Uniswap V4 uses it extensively. OpenZeppelin built tooling around it. It's here to stay.
But "cheaper" doesn't mean "safer." The transition from persistent to transient storage mirrors the transition from synchronous to asynchronous programming — the basic operations look similar, but the execution model has fundamentally different safety properties.
Every smart contract developer needs to update their mental model. Transient storage is not memory. It's not storage. It's a new thing with new rules, and the $355,000 SIR.trading exploit was just the first lesson. The next one will be more expensive.
This article is part of an ongoing series on smart contract security. Follow for weekly deep-dives into vulnerabilities, audit techniques, and defensive patterns.
Top comments (0)