DEV Community

ohmygod
ohmygod

Posted on

EIP-1153 Transient Storage Security Traps: How a Gas Optimization Killed SIR.trading and What Your Reentrancy Guard Is Missing

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;
    }
}
Enter fullscreen mode Exit fullscreen mode

An attacker contract receiving ETH via .transfer() can now execute TSTORE within the 2,300 gas budget to:

  1. Set flags for coordinating multi-step attacks
  2. Store intermediate values for price manipulation
  3. 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);
    }
}
Enter fullscreen mode Exit fullscreen mode

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);
    }
}
Enter fullscreen mode Exit fullscreen mode

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?
Enter fullscreen mode Exit fullscreen mode

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)
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

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:

  1. Transient storage slot collisions between protocols using the same slot keys
  2. State confusion where Protocol B reads Protocol A's transient values
  3. 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);
    }
}
Enter fullscreen mode Exit fullscreen mode

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)
);
Enter fullscreen mode Exit fullscreen mode

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();
}
Enter fullscreen mode Exit fullscreen mode

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);
}
Enter fullscreen mode Exit fullscreen mode

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;
    }
}
Enter fullscreen mode Exit fullscreen mode

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)