Transient Storage Security: How EIP-1153 Created DeFi's Newest and Most Misunderstood Attack Surface
A practical security guide for developers using TLOAD/TSTORE — with the SIR.trading exploit dissected and defense patterns for Uniswap V4 hooks, flash accounting, and beyond.
Why Transient Storage Is a Ticking Time Bomb
EIP-1153 landed with the Dencun upgrade in March 2024, giving Solidity developers two new opcodes: TLOAD and TSTORE. The pitch was simple — temporary storage that lives for one transaction then vanishes, at ~100 gas instead of ~20,000 gas for SSTORE. Everyone celebrated the gas savings. Almost nobody talked about the security implications.
Then in March 2025, SIR.trading lost its entire TVL — $355,000 — to a transient storage exploit. The attack wasn't exotic. It was a misunderstanding of how transient storage actually works, and it's a mistake that's hiding in protocols shipping today.
This article breaks down what makes transient storage dangerous, dissects the SIR.trading exploit step-by-step, and provides concrete defense patterns for developers building with EIP-1153 in production.
Transient Storage: The Mental Model That Gets Developers Hacked
Here's the critical distinction most developers get wrong:
Memory (MLOAD/MSTORE):
├── Scope: Single execution frame
├── Cleared: When function returns
└── Cost: ~3 gas
Storage (SLOAD/SSTORE):
├── Scope: Permanent
├── Cleared: Never (unless explicit)
└── Cost: ~2,100-20,000 gas
Transient Storage (TLOAD/TSTORE):
├── Scope: ENTIRE TRANSACTION ← This is what kills you
├── Cleared: End of transaction
└── Cost: ~100 gas
The trap: developers treat transient storage like memory (cleared per call), but it behaves like storage (persists across calls) — except it vanishes at the end of the transaction. This hybrid lifetime creates a category of bugs that didn't exist before Dencun.
The Three Dangerous Assumptions
// DANGEROUS ASSUMPTION #1: "Transient storage resets between calls"
// Reality: It persists across ALL internal calls in the same transaction.
// DANGEROUS ASSUMPTION #2: "If I set it in function A, only function A reads it"
// Reality: Any contract called within the same transaction can read it via TLOAD.
// DANGEROUS ASSUMPTION #3: "I can use it for authentication since it's temporary"
// Reality: Attackers within the same transaction can overwrite it.
The SIR.trading Exploit: A Step-by-Step Dissection
In March 2025, an attacker drained SIR.trading (Synthetics Implemented Right) of its entire $355,000 TVL by exploiting transient storage slot reuse in the Vault contract.
The Vulnerable Code Pattern
// SIR.trading Vault contract (simplified, vulnerable version)
contract Vault {
// Transient storage slot 0x01 used for TWO different purposes:
// 1. Storing the legitimate Uniswap V3 pool address during swaps
// 2. Storing the mint amount during token minting
function swap(address pool) internal {
// Store the legitimate pool address in transient slot 0x01
assembly {
tstore(0x01, pool) // ← Stores trusted pool address
}
// Perform swap — Uniswap calls back to uniswapV3SwapCallback()
IUniswapV3Pool(pool).swap(...);
}
function mint(uint256 amount) external {
// ... swap logic happens first, setting slot 0x01 to pool address ...
// Later in the same function:
assembly {
tstore(0x01, amount) // ← OVERWRITES pool address with mint amount!
}
}
function uniswapV3SwapCallback(
int256 amount0Delta,
int256 amount1Delta,
bytes calldata data
) external {
// Verify caller is the legitimate Uniswap pool
address expectedPool;
assembly {
expectedPool := tload(0x01) // ← Loads whatever is in slot 0x01
}
// This check is supposed to verify msg.sender == legitimate pool
require(msg.sender == expectedPool, "Unauthorized callback");
// Transfer tokens to the "pool" (actually attacker's contract)
IERC20(token).transfer(msg.sender, uint256(amount0Delta));
}
}
The Attack Flow
Transaction (single tx, transient storage persists throughout):
│
├─ Step 1: Attacker calls mint() with amount = uint256(attackerContract)
│ └─ This stores attackerContract address in transient slot 0x01
│ (overwriting the legitimate Uniswap pool address)
│
├─ Step 2: Attacker's contract calls uniswapV3SwapCallback()
│ └─ Callback loads slot 0x01 → gets attackerContract address
│ └─ require(msg.sender == attackerContract) → PASSES ✓
│ └─ Transfers tokens to attacker
│
├─ Step 3: Repeat until vault is drained
│ └─ Each callback iteration steals more tokens
│
└─ End of transaction: transient storage cleared (evidence gone)
Why This Bug Exists
The root cause is transient storage slot collision. Two unrelated pieces of data — a trusted pool address and a mint amount — shared the same transient storage slot (0x01). Because transient storage persists across all calls within a transaction, the mint amount (crafted to be an attacker-controlled address) overwrote the authentication data.
This is equivalent to storing your house key and your ATM PIN in the same pocket — except in transient storage, someone else can reach into that pocket during the same transaction.
The Five Transient Storage Anti-Patterns
Based on the SIR.trading exploit and our audit research, here are the five most dangerous patterns:
Anti-Pattern 1: Slot Reuse for Different Data Types
// ❌ VULNERABLE: Same slot, different meanings
contract BadVault {
bytes32 constant SLOT = bytes32(uint256(1));
function setTrustedCaller(address caller) internal {
assembly { tstore(SLOT, caller) }
}
function setAmount(uint256 amount) internal {
assembly { tstore(SLOT, amount) } // Overwrites trusted caller!
}
}
// ✅ SAFE: Namespaced slots with type separation
contract SafeVault {
// Use keccak256 of unique identifiers for slot separation
bytes32 constant TRUSTED_CALLER_SLOT =
keccak256("SafeVault.trustedCaller");
bytes32 constant AMOUNT_SLOT =
keccak256("SafeVault.amount");
function setTrustedCaller(address caller) internal {
assembly { tstore(TRUSTED_CALLER_SLOT, caller) }
}
function setAmount(uint256 amount) internal {
assembly { tstore(AMOUNT_SLOT, amount) } // Different slot, no collision
}
}
Anti-Pattern 2: Using Transient Storage for Authentication
// ❌ VULNERABLE: Transient storage as access control
contract BadCallback {
function initiateSwap(address pool) external {
assembly { tstore(0x00, pool) }
IPool(pool).swap(...);
}
function callback() external {
address expected;
assembly { expected := tload(0x00) }
require(msg.sender == expected); // Can be manipulated!
// ... transfer tokens ...
}
}
// ✅ SAFE: Combine transient storage with persistent validation
contract SafeCallback {
mapping(address => bool) public whitelistedPools;
function initiateSwap(address pool) external {
require(whitelistedPools[pool], "Unknown pool");
assembly {
tstore(0x00, pool)
tstore(0x01, 1) // "swap in progress" flag
}
IPool(pool).swap(...);
assembly {
tstore(0x01, 0) // Clear flag after swap
}
}
function callback() external {
// Layer 1: Check persistent whitelist
require(whitelistedPools[msg.sender], "Not a whitelisted pool");
// Layer 2: Check transient state
uint256 inProgress;
assembly { inProgress := tload(0x01) }
require(inProgress == 1, "No swap in progress");
// Layer 3: Verify consistency
address expected;
assembly { expected := tload(0x00) }
require(msg.sender == expected, "Pool mismatch");
// ... transfer tokens ...
}
}
Anti-Pattern 3: Not Clearing Transient Storage After Use
// ❌ DANGEROUS: Stale transient data persists in complex transactions
contract BadReentrancyGuard {
bytes32 constant LOCK_SLOT = keccak256("reentrancy.lock");
modifier nonReentrant() {
uint256 locked;
assembly { locked := tload(LOCK_SLOT) }
require(locked == 0, "Reentrant call");
assembly { tstore(LOCK_SLOT, 1) }
_;
// ❌ Never clears the lock!
// In a batched transaction, ALL subsequent calls will revert
}
}
// ✅ SAFE: Always clear transient storage in finally-style pattern
contract SafeReentrancyGuard {
bytes32 constant LOCK_SLOT = keccak256("reentrancy.lock");
modifier nonReentrant() {
uint256 locked;
assembly { locked := tload(LOCK_SLOT) }
require(locked == 0, "Reentrant call");
assembly { tstore(LOCK_SLOT, 1) }
_;
assembly { tstore(LOCK_SLOT, 0) } // ✅ Always clear
}
}
Anti-Pattern 4: Cross-Contract Transient Storage Assumptions
// ❌ DANGEROUS: Assuming another contract's transient state
contract ProtocolA {
function doSomething() external {
// Sets transient data, then calls ProtocolB
assembly { tstore(0x42, 1) }
IProtocolB(protocolB).execute();
}
}
contract ProtocolB {
function execute() external {
// ❌ Reads ProtocolA's transient storage?
// NO — transient storage is per-contract!
// Each contract has its own transient storage space
uint256 val;
assembly { val := tload(0x42) }
// val == 0, not 1! Transient storage is contract-scoped.
}
}
// Note: This is actually SAFE by EVM design — transient storage
// is isolated per contract address. But developers sometimes
// expect shared state and build broken protocols around that assumption.
Anti-Pattern 5: Transient Storage in Delegatecall Context
// ⚠️ CRITICAL: delegatecall shares transient storage of the CALLER
contract Implementation {
function setData(uint256 val) external {
assembly { tstore(0x00, val) }
}
}
contract Proxy {
address immutable implementation;
fallback() external {
// delegatecall means Implementation's tstore writes to
// PROXY's transient storage, not Implementation's
(bool success,) = implementation.delegatecall(msg.data);
require(success);
}
function readData() external view returns (uint256) {
uint256 val;
assembly { val := tload(0x00) }
return val; // Returns the value set by Implementation
// because delegatecall shares the caller's storage context
}
}
// This is by design, but creates risks when:
// 1. Multiple implementations share the proxy's transient storage
// 2. Slot collisions between different implementation contracts
// 3. Upgrade paths that change transient storage layout
Uniswap V4: A Masterclass in Safe Transient Storage Usage
Uniswap V4 is the largest production deployment of EIP-1153, using transient storage for its flash accounting system. Here's how they avoid the pitfalls:
Flash Accounting Architecture
// Simplified Uniswap V4 PoolManager pattern
contract PoolManager {
using TransientStateLibrary for *;
// Key insight: transient storage tracks NET deltas per token
// All operations accumulate deltas; only final settlement moves tokens
function unlock(bytes calldata data) external returns (bytes memory) {
// Set the lock (transient storage)
// Only one unlock() can be active at a time
_setUnlocked();
// Callback to the caller — they perform swaps, adds, etc.
bytes memory result = IUnlockCallback(msg.sender).unlockCallback(data);
// CRITICAL: Verify all deltas are settled to zero
// If any token has non-zero delta, the entire transaction reverts
_verifyAllDeltasSettled();
_setLocked();
return result;
}
function swap(PoolKey memory key, SwapParams memory params)
external
onlyWhenUnlocked // Must be within unlock() context
returns (BalanceDelta delta)
{
// Swap logic...
// Updates transient storage deltas for both tokens
_accountDelta(key.currency0, delta.amount0());
_accountDelta(key.currency1, delta.amount1());
}
}
Why Uniswap V4's Pattern Is Secure
Uniswap V4's Transient Storage Safety Properties:
1. SINGLE ENTRY POINT: All operations go through unlock()
└── Prevents unauthorized transient state manipulation
2. ATOMIC SETTLEMENT: _verifyAllDeltasSettled() at the end
└── Any imbalance reverts the entire transaction
3. SCOPED LIFECYCLE: lock/unlock bracket all operations
└── Transient state only exists during the unlock window
4. NO AUTHENTICATION IN TRANSIENT STORAGE
└── msg.sender checks use call stack, not transient data
5. NAMESPACED SLOTS: TransientStateLibrary uses unique slot derivation
└── No slot collisions between different currencies/pools
Writing Secure Uniswap V4 Hooks
If you're building V4 hooks, here's the security checklist:
// ✅ SECURE HOOK PATTERN
contract SecureHook is BaseHook {
// 1. Always verify the caller is the PoolManager
modifier onlyPoolManager() {
require(msg.sender == address(poolManager), "Not PoolManager");
_;
}
// 2. Validate pool keys to prevent malicious pool injection
function beforeSwap(
address sender,
PoolKey calldata key,
IPoolManager.SwapParams calldata params,
bytes calldata hookData
) external override onlyPoolManager returns (bytes4, BeforeSwapDelta, uint24) {
// Validate the pool is one we expect
require(isWhitelistedPool(key), "Unknown pool");
// 3. If using transient storage in hooks, namespace your slots
bytes32 slot = keccak256(
abi.encodePacked("SecureHook.swapCount", PoolId.unwrap(key.toId()))
);
uint256 count;
assembly {
count := tload(slot)
tstore(slot, add(count, 1))
}
return (this.beforeSwap.selector, BeforeSwapDeltaLibrary.ZERO_DELTA, 0);
}
// 4. Clear transient storage in afterSwap
function afterSwap(
address sender,
PoolKey calldata key,
IPoolManager.SwapParams calldata params,
BalanceDelta delta,
bytes calldata hookData
) external override onlyPoolManager returns (bytes4, int128) {
// Clean up any hook-specific transient state
bytes32 slot = keccak256(
abi.encodePacked("SecureHook.swapCount", PoolId.unwrap(key.toId()))
);
assembly { tstore(slot, 0) }
return (this.afterSwap.selector, 0);
}
}
The Transient Storage Security Audit Checklist
Use this checklist when auditing any contract that uses TLOAD/TSTORE:
Slot Management
- [ ] Every transient storage slot has a unique, namespaced identifier
- [ ] No slot is reused for different data types or purposes
- [ ] Slot derivation uses
keccak256with descriptive prefixes - [ ]
delegatecallcontexts are analyzed for slot collisions
Lifecycle Management
- [ ] All transient storage slots are explicitly cleared after use
- [ ] Clearing happens in both success and revert paths (or uses finally-style patterns)
- [ ] No stale transient data can affect subsequent calls in batched transactions
- [ ] Lock patterns (reentrancy guards) properly reset at function exit
Authentication & Access Control
- [ ] No authentication decisions rely solely on transient storage
- [ ] Transient values used in access checks are validated against persistent storage
- [ ] Callback verification uses
msg.senderchecks, not just transient state - [ ] Whitelists/allowlists are stored in persistent storage, not transient
Cross-Call Safety
- [ ] Analyzed what happens if transient state persists across external calls
- [ ] No assumption that transient storage resets between internal calls
- [ ] Flash loan interaction paths audited for transient state manipulation
- [ ] Multi-step transactions (batchers, relayers, AA wallets) tested for state leakage
Uniswap V4 Hook-Specific
- [ ] All hook functions verify
msg.sender == poolManager - [ ] Pool keys are validated against a whitelist
- [ ] Hook-specific transient storage uses unique namespaced slots
- [ ]
afterSwap/afterModifyLiquidityclean up transient state frombefore*hooks - [ ] Delta modifications in hooks are consistent and don't create settlement imbalances
Foundry Test Template: Catching Transient Storage Bugs
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.24;
import "forge-std/Test.sol";
contract TransientStorageSecurityTest is Test {
TargetContract target;
function setUp() public {
target = new TargetContract();
}
// Test 1: Slot collision detection
function test_noSlotCollision() public {
// Call function A that sets transient slot
target.functionA(address(0xBEEF));
// Call function B that sets a DIFFERENT transient slot
target.functionB(12345);
// Verify function A's data is unchanged
assertEq(
target.readSlotA(),
address(0xBEEF),
"Slot collision: functionB overwrote functionA's transient data"
);
}
// Test 2: Stale state after external call
function test_noStaleTransientState() public {
// Simulate a batched transaction
target.operationWithCallback();
// Second operation in same tx should not see stale state
target.operationWithCallback();
// If this reverts with "Reentrant call" unexpectedly,
// the reentrancy guard wasn't cleared properly
}
// Test 3: Authentication bypass via transient manipulation
function test_cannotBypassAuthViaTransient() public {
AttackerContract attacker = new AttackerContract(address(target));
// Attacker tries to manipulate transient storage
// then call a privileged function
vm.expectRevert("Unauthorized");
attacker.attack();
}
// Test 4: Verify cleanup in all paths
function test_transientCleanupOnRevert() public {
// Call a function that reverts mid-way
try target.revertingFunction() {} catch {}
// Transient storage should be clean for next call
// (Note: in practice, a revert undoes all state changes
// including transient storage within that call frame.
// But test the outer frame's transient state.)
assertTrue(target.isTransientClean(), "Transient storage not cleaned after revert");
}
}
Key Takeaways
Transient storage persists across ALL calls within a transaction — treat it like storage that auto-deletes, not like memory that's call-scoped.
Never use transient storage as the sole authentication mechanism. The SIR.trading exploit proved this decisively. Always layer persistent storage checks.
Namespace your slots. Use
keccak256("ContractName.variableName")instead of magic numbers like0x01. This prevents the slot collision that destroyed SIR.trading.Always clean up. Clear transient storage after use, even though the EVM clears it at transaction end. Complex transactions (batchers, AA wallets, multicalls) can create unexpected persistence within the transaction.
Uniswap V4 got it right. Study their pattern: single entry point (
unlock), atomic settlement verification, no auth in transient storage, namespaced slots.Test specifically for transient storage bugs. Standard test suites don't catch cross-call state persistence. Write explicit tests for slot collisions, stale state, and authentication bypasses.
The gas savings from EIP-1153 are real — up to 97% reduction for reentrancy guards. But those savings mean nothing if your protocol gets drained because you treated TSTORE like MSTORE. Understand the lifetime, namespace your slots, and never authenticate with transient data.
DreamWork Security publishes weekly DeFi security research. Follow for vulnerability analyses, audit tool comparisons, and defense playbooks.
Top comments (0)