Transient storage was supposed to be simple: cheap scratch space that vanishes after each transaction. EIP-1153 introduced TSTORE and TLOAD opcodes in Ethereum's Dencun upgrade, and developers immediately started replacing expensive SSTORE/SLOAD patterns with their gas-efficient counterparts. Uniswap V4 built its entire hook architecture around it. OpenZeppelin shipped transient reentrancy guards. Everything looked great.
Then SIR.trading lost $355,000 in March 2025 because someone forgot to clear a transient storage slot.
This article dissects the security model of transient storage — not the happy path, but the edge cases that break your contracts when you assume TSTORE is just a cheaper SSTORE.
The Mental Model That Gets Developers Killed
Here's the dangerous assumption:
"Transient storage clears automatically at the end of every transaction, so I don't need to manage its lifecycle."
This is technically true and practically lethal. The problem isn't end-of-transaction behavior — it's mid-transaction behavior. Transient storage persists across:
- Internal calls (
CALL,DELEGATECALL,STATICCALL) - Cross-contract interactions
- Callback hooks
- Flash loan callbacks
- Re-entrant calls within the same transaction
If your mental model is "transient = function-scoped temporary variable," you're building on quicksand.
Attack Vector #1: Low-Gas Reentrancy
This is the big one, and it fundamentally breaks a 9-year-old security assumption.
The Old World
Since The DAO hack, developers relied on a simple truth: address.transfer() and address.send() forward only 2,300 gas — not enough to execute a meaningful SSTORE (which costs 5,000+ gas for a cold write). This made basic Ether transfers "reentrancy-safe" by gas limitation.
The New Reality
TSTORE costs only 100 gas. That's well within the 2,300 gas stipend.
// VULNERABLE: "Safe" pattern that is no longer safe
contract VulnerableVault {
mapping(address => uint256) public balances;
function withdraw() external {
uint256 amount = balances[msg.sender];
require(amount > 0, "No balance");
// Transfer with 2300 gas stipend
payable(msg.sender).transfer(amount);
// State update after transfer — classic CEI violation
// In the old world, transfer's 2300 gas limit saved you
// With TSTORE available, attacker's receive() can now
// write to THEIR OWN transient storage to coordinate state
balances[msg.sender] = 0;
}
}
An attacker contract receiving ETH via .transfer() can now execute TSTORE within the 2,300 gas budget to:
- Set flags for coordinating multi-step attacks
- Store intermediate values for price manipulation
- Signal other contracts within the same transaction
The SIR.trading Exploit: Anatomy of a $355K Kill
The SIR.trading protocol used transient storage for gas optimization in their vault logic. The critical mistake: they used TSTORE to temporarily cache a value during execution but didn't clear it before a callback. Here's the simplified pattern:
contract SIRVault {
bytes32 constant TEMP_BALANCE_SLOT = keccak256("temp.balance");
function processWithdrawal(address user, uint256 amount) external {
// Cache current balance in transient storage
uint256 currentBalance = getBalance(user);
assembly {
tstore(TEMP_BALANCE_SLOT, currentBalance)
}
// External call — here's where it breaks
ICallback(msg.sender).onWithdrawal(user, amount);
// Uses cached value — but it may have been manipulated
uint256 cached;
assembly {
cached := tload(TEMP_BALANCE_SLOT)
}
_updateBalance(user, cached - amount);
}
}
The attacker re-entered during onWithdrawal, manipulated the execution flow, and because the transient storage slot still held the stale cached value, the subsequent balance update was incorrect — effectively allowing them to withdraw more than their balance.
Defense: The TSTORE-Aware CEI Pattern
contract SecureVault {
bytes32 constant REENTRANCY_LOCK = keccak256("reentrancy.lock");
modifier nonReentrantTransient() {
assembly {
if tload(REENTRANCY_LOCK) { revert(0, 0) }
tstore(REENTRANCY_LOCK, 1)
}
_;
assembly {
tstore(REENTRANCY_LOCK, 0) // EXPLICIT clear
}
}
function processWithdrawal(
address user,
uint256 amount
) external nonReentrantTransient {
uint256 currentBalance = getBalance(user);
// EFFECTS FIRST
_updateBalance(user, currentBalance - amount);
// INTERACTION LAST
ICallback(msg.sender).onWithdrawal(user, amount);
}
}
Attack Vector #2: Smart Contract Wallet Batching Conflicts
EIP-1153's design assumes one user per transaction — the EOA model. But smart contract wallets (ERC-4337, Safe, Sequence) batch multiple UserOperations into a single transaction.
The Conflict
Transaction (submitted by bundler):
├── UserOp 1: Alice calls Protocol.deposit()
│ └── Protocol sets tstore(LOCK, 1) ... tstore(LOCK, 0)
├── UserOp 2: Bob calls Protocol.deposit()
│ └── Protocol sets tstore(LOCK, 1) ... tstore(LOCK, 0) ← Works IF Op1 cleared
└── UserOp 3: Carol calls Protocol.withdraw()
└── What if UserOp 2 REVERTED and didn't clear LOCK?
If a UserOperation reverts mid-execution (after tstore(LOCK, 1) but before tstore(LOCK, 0)), the transient storage state leaks into subsequent operations within the same transaction.
Defense: Namespace Your Transient Storage
contract SafeProtocol {
function _transientSlot(
address user,
bytes32 key
) internal pure returns (bytes32) {
return keccak256(abi.encodePacked(user, key));
}
function temporaryApprove(uint256 amount) external {
bytes32 slot = _transientSlot(msg.sender, "approved");
assembly {
tstore(slot, amount)
}
}
function executeWithApproval() external {
bytes32 slot = _transientSlot(msg.sender, "approved");
uint256 approved;
assembly {
approved := tload(slot)
}
require(approved > 0, "Not approved");
_execute(msg.sender, approved);
assembly {
tstore(slot, 0)
}
}
}
Attack Vector #3: Cross-Contract State Confusion
When Protocol A calls Protocol B via a callback, and both use transient storage, they share the same transient storage context (within DELEGATECALL chains). This creates opportunities for:
- Transient storage slot collisions between protocols using the same slot keys
- State confusion where Protocol B reads Protocol A's transient values
- Griefing attacks where a malicious callback writes to known transient slots
contract MaliciousCallback {
function onFlashLoan(address initiator, uint256 amount) external {
bytes32 KNOWN_LOCK = keccak256("reentrancy.lock");
assembly {
tstore(KNOWN_LOCK, 0) // Disable their reentrancy guard!
}
IVulnerableProtocol(msg.sender).withdraw(amount);
}
}
Important caveat: This attack only works with DELEGATECALL, not regular CALL. With CALL, each contract has its own storage context. But many proxy patterns and diamond proxies use DELEGATECALL, making this a real concern.
Defense: Use Unique, Unpredictable Slot Keys
// Instead of:
bytes32 constant LOCK = keccak256("reentrancy.lock"); // Guessable!
// Use contract-specific, address-derived slots:
bytes32 immutable LOCK = keccak256(
abi.encodePacked(address(this), "reentrancy.lock", block.chainid)
);
The Five Commandments of Transient Storage Security
1. Never Cache State Across External Calls
// ❌ WRONG
function process() external {
uint256 val = computeExpensiveValue();
assembly { tstore(CACHE_SLOT, val) }
externalCall(); // Attacker can read/manipulate
assembly { val := tload(CACHE_SLOT) } // Stale!
useValue(val);
}
// ✅ CORRECT
function process() external {
uint256 val = computeExpensiveValue();
useValue(val); // Use before external call
externalCall();
}
2. Always Clear After Use, Not Just "Eventually"
Explicit lifecycle management beats relying on end-of-transaction auto-clear.
3. Namespace All Transient Slots by Caller
Prevent cross-user contamination in batched transactions.
4. Treat TSTORE Like SSTORE for Security Analysis
Apply the same scrutiny to transient storage writes as persistent storage writes. "Temporary" doesn't mean "safe."
5. Test with Foundry's Transient Storage Support
function test_reentrancy_with_transient() public {
vm.prank(attacker);
bytes32 slot = keccak256("reentrancy.lock");
bytes32 value = vm.load(address(vault), slot);
assertEq(uint256(value), 1);
}
Audit Checklist: Transient Storage Review
When reviewing contracts that use TSTORE/TLOAD:
- 🔴 Critical: Transient values read after external calls (stale/manipulated)
- 🔴 Critical: Missing explicit clear in reentrancy guard (bypass on re-entry)
- 🟡 High: Predictable transient slot keys with DELEGATECALL (callback overwrite)
- 🟡 High: No caller namespacing (batched tx cross-user leaks)
- 🟡 High: TSTORE in low-gas contexts — receive/fallback (2300 gas attacks)
- 🟠 Medium: No transient storage documentation (auditors miss implicit state)
What's Next: Solidity 0.8.28's transient Keyword
contract ModernVault {
uint256 transient _locked;
uint256 transient _tempBalance;
modifier nonReentrant() {
require(_locked == 0, "Locked");
_locked = 1;
_;
_locked = 0;
}
}
Cleaner syntax, but the compiler doesn't warn you about reading transient variables after external calls. Doesn't namespace slots. Doesn't prevent cross-contract collisions in proxy patterns.
The tooling is better. The traps remain identical.
Conclusion
EIP-1153 transient storage is a powerful gas optimization that breaks fundamental security assumptions Ethereum developers have relied on for nearly a decade. The 2,300 gas stipend is no longer a reentrancy barrier. Automatic end-of-transaction clearing doesn't protect you mid-transaction. And the growing adoption of smart contract wallets means your single-user-per-transaction assumptions are already wrong.
The SIR.trading exploit wasn't sophisticated — it was a developer treating transient storage as "just a cheaper variable." Don't make the same mistake. Treat TSTORE with the same respect as SSTORE, apply the CEI pattern religiously, and audit every transient storage access that crosses an external call boundary.
Your gas savings mean nothing if your protocol's TVL goes to zero.
This article is part of the DeFi Security Deep Dives series. Follow for weekly analysis of smart contract vulnerabilities, audit methodologies, and security best practices across EVM and Solana ecosystems.
Top comments (0)