EIP-1153 shipped with the Dencun upgrade in March 2024, promising 97% gas savings on reentrancy guards. Two years later, it has delivered on that promise — and created an entirely new class of vulnerabilities that auditors are still catching up with.
The SIR.trading exploit in March 2025 was the canary in the coal mine: $355K drained because a transient storage slot wasn't cleared mid-transaction. But that was just the beginning. As protocols adopt transient storage for everything from flash loan accounting to ERC-4337 wallet guards, the attack surface has expanded far beyond what most teams anticipate.
This article breaks down five concrete patterns where transient storage introduces security risks, with code examples and mitigations for each.
Pattern 1: The 2,300-Gas Reentrancy Revival
The Old Assumption
For years, address.transfer() and address.send() were considered reentrancy-safe. The 2,300 gas stipend wasn't enough to execute a meaningful SSTORE (which costs ~5,000–20,000 gas), so any callback was effectively neutered.
What Changed
TSTORE costs 100 gas. That fits comfortably within the 2,300 gas stipend.
// VULNERABLE: assumes transfer() callback can't modify state
contract VulnerableVault {
mapping(address => uint256) public balances;
function withdraw() external {
uint256 amount = balances[msg.sender];
balances[msg.sender] = 0;
// "Safe" because only 2,300 gas... right?
payable(msg.sender).transfer(amount);
}
}
// ATTACKER: uses transient storage within 2,300 gas budget
contract Attacker {
receive() external payable {
// TSTORE costs 100 gas — fits in 2,300 stipend
assembly {
tstore(0, 1) // Set flag for later use in same tx
}
}
}
The Risk
While the attacker can't re-enter the same function with only 2,300 gas, they can set transient storage flags that influence later execution within the same transaction. If your protocol chains multiple calls — withdraw then swap, withdraw then stake — the attacker can manipulate transient state between steps.
Mitigation
Never rely on gas stipends as a security boundary. Use explicit reentrancy guards (preferably with transient storage for gas efficiency) and the Checks-Effects-Interactions pattern:
contract SecureVault {
// Transient reentrancy guard
modifier nonReentrant() {
assembly {
if tload(0) { revert(0, 0) }
tstore(0, 1)
}
_;
assembly {
tstore(0, 0) // Always clear
}
}
function withdraw() external nonReentrant {
uint256 amount = balances[msg.sender];
balances[msg.sender] = 0; // Effects before interactions
(bool ok,) = msg.sender.call{value: amount}("");
require(ok);
}
}
Pattern 2: The Forgotten Cleanup — What Killed SIR.trading
The Trap
Transient storage auto-clears at transaction end. This creates a false sense of safety: "I don't need to manually clear it because the EVM does it for me."
True — but only at the end of the entire transaction. Within a transaction, transient storage persists across all internal and external calls.
What Happened at SIR.trading
SIR.trading's Vault contract stored a Uniswap pool address in transient storage during a swap callback. The attacker exploited the uniswapV3SwapCallback by substituting a malicious contract address, causing the transient slot to be overwritten. Because the slot wasn't cleared between callback invocations, the attacker could drain the vault by repeatedly triggering the callback with their own address.
The Pattern
// VULNERABLE: transient slot not cleared between external calls
contract VulnerableCallback {
function executeSwap(address pool, uint256 amount) internal {
assembly {
tstore(0, pool) // Store expected callback source
}
IUniswapV3Pool(pool).swap(...);
// Transient slot still holds 'pool' — what if there's
// another swap in this transaction?
}
function uniswapV3SwapCallback(...) external {
assembly {
let expectedPool := tload(0)
// Validates caller... but slot can be overwritten
if iszero(eq(caller(), expectedPool)) { revert(0, 0) }
}
// Process callback...
}
}
Mitigation
Explicitly clear transient storage after every external call boundary:
function executeSwap(address pool, uint256 amount) internal {
assembly {
tstore(0, pool)
}
IUniswapV3Pool(pool).swap(...);
assembly {
tstore(0, 0) // Clear immediately after use
}
}
Rule of thumb: treat transient storage like memory that leaks across calls. If you wouldn't leave sensitive data in an uncleared memory buffer, don't leave it in transient storage.
Pattern 3: ERC-4337 Batching Collisions
The Setup
ERC-4337 bundles multiple UserOperations from different users into a single transaction. The EntryPoint validates all operations before executing any of them.
This creates a subtle but critical problem: transient storage set during validation of UserOp[0] is still readable during validation of UserOp[1].
The Attack
contract SmartAccount {
function validateUserOp(UserOperation calldata op, ...) external {
// Store nonce in transient storage for gas efficiency
assembly {
tstore(0, mload(add(op, 0x40))) // Store nonce
}
// ... validation logic
}
function executeUserOp(bytes calldata data) external {
assembly {
let nonce := tload(0) // Read back nonce
// But this might be UserOp[1]'s nonce, not ours!
}
}
}
A malicious bundler — or a bundler that doesn't simulate carefully — can arrange operations so that one account's transient state pollutes another's execution.
Mitigation
The ERC-4337 spec explicitly warns about this: "Contracts using EIP-1153 transient storage MUST take into account that ERC-4337 allows multiple UserOperations from different unrelated sender addresses to be included in the same underlying transaction."
Use sender-scoped transient storage keys:
function validateUserOp(UserOperation calldata op, ...) external {
bytes32 slot = keccak256(abi.encodePacked(msg.sender, "nonce"));
assembly {
tstore(slot, mload(add(op, 0x40)))
}
}
Or better yet, explicitly clear all transient slots at the end of each operation's execution phase.
Pattern 4: DELEGATECALL Storage Context Sharing
The Mechanics
When Contract A DELEGATECALLs Contract B, they share the same storage context — including transient storage. This is by design for persistent storage (it's how proxies work), but it creates unexpected collisions with transient storage.
The Scenario
// Proxy contract sets a transient reentrancy lock
contract Proxy {
modifier nonReentrant() {
assembly {
if tload(0) { revert(0, 0) }
tstore(0, 1)
}
_;
assembly { tstore(0, 0) }
}
function execute(address impl, bytes calldata data)
external nonReentrant
{
impl.delegatecall(data);
}
}
// Implementation also uses slot 0 for its own transient flag
contract Implementation {
function doSomething() external {
assembly {
tstore(0, 42) // Overwrites proxy's reentrancy lock!
}
// When this returns, proxy clears slot 0...
// but it was already overwritten
}
}
If the implementation writes to the same transient slot the proxy uses for its reentrancy guard, the guard can be bypassed or permanently locked.
Mitigation
Namespace your transient storage slots using contract-specific prefixes or EIP-7201-style storage layout:
// Use a deterministic, unique slot based on purpose
bytes32 constant REENTRANCY_SLOT =
keccak256("proxy.reentrancy.guard") - 1;
modifier nonReentrant() {
assembly {
if tload(REENTRANCY_SLOT) { revert(0, 0) }
tstore(REENTRANCY_SLOT, 1)
}
_;
assembly { tstore(REENTRANCY_SLOT, 0) }
}
This mirrors the EIP-7201 namespaced storage pattern that the community adopted for persistent storage in proxy contracts — and it's equally critical for transient storage.
Pattern 5: Read-Only Reentrancy Through Transient State
The Blind Spot
Read-only reentrancy occurs when a view function reads protocol state that's temporarily inconsistent during a transaction. Transient storage amplifies this because it's designed to hold mid-transaction state.
The Pattern
contract LendingPool {
function deposit(address token, uint256 amount) external {
assembly {
// Track total deposits in transient storage
let current := tload(0)
tstore(0, add(current, amount))
}
IERC20(token).transferFrom(msg.sender, address(this), amount);
// External call before transient state is finalized
_updateOracle();
}
// View function reads transient storage
function getTotalDeposits() external view returns (uint256) {
assembly {
mstore(0, tload(0))
return(0, 32)
}
}
}
If _updateOracle() triggers a callback (directly or via a token hook like ERC-777), an attacker can call getTotalDeposits() and see a partially-updated, inflated value — then use that to manipulate a dependent protocol (e.g., an oracle or a lending market that reads this value).
Mitigation
Apply reentrancy guards to view functions that read transient storage, or ensure transient state is only written after all external calls complete:
modifier nonReentrantView() {
assembly {
if tload(LOCK_SLOT) { revert(0, 0) }
}
_;
}
function getTotalDeposits() external view nonReentrantView returns (uint256) {
// Safe: reverts if called during reentrancy
}
The Audit Checklist
When reviewing contracts that use EIP-1153, check for:
| # | Check | Severity |
|---|---|---|
| 1 | Are all transient slots explicitly cleared after external calls? | Critical |
| 2 | Does any code assume 2,300-gas callbacks can't modify state? | High |
| 3 | Is the contract used in ERC-4337 bundles? Are slots sender-scoped? | High |
| 4 | Does DELEGATECALL create transient slot collisions with implementation contracts? | High |
| 5 | Do view functions read transient storage without reentrancy protection? | Medium |
| 6 | Is the CEI pattern strictly followed for all functions using TSTORE? | Critical |
Conclusion
Transient storage is a genuine improvement — reentrancy guards at 200 gas instead of 7,100 is a real win for composability. But every gas optimization changes the security equation. The 2,300-gas boundary that protected protocols for years is gone. The "auto-cleanup" behavior creates false confidence. And multi-operation contexts like ERC-4337 bundles surface collision vectors that don't exist in single-EOA transactions.
The fix isn't to avoid transient storage — it's to treat it with the same rigor we apply to persistent storage. Namespace your slots. Clear after external calls. Guard your view functions. And never, ever assume the EVM will clean up your security boundaries for you.
This article is part of an ongoing series covering smart contract security patterns, vulnerability analysis, and audit tooling for EVM and Solana ecosystems.
Top comments (0)