Transient Storage Security: EIP-1153 and the New Attack Surface

EIP-1153 introduced two new opcodes — TSTORE and TLOAD — that write and read from a dedicated transient storage area. The pitch is compelling: you get key-value persistence that survives across internal calls within a single transaction, at a fraction of the gas cost of SSTORE/SLOAD, with no cleanup burden because the EVM wipes the entire space when the transaction ends.

That last clause contains both the feature and the footgun. Transient storage is not a cheap replacement for regular storage. It is not a scoped-per-call scratch pad either. It occupies a precise middle ground — surviving internal calls but dying with the transaction — and misidentifying that boundary has already generated a class of vulnerabilities that auditors need to recognize on sight.

This article is a complete technical reference for transient storage security. We examine the mechanics, the correct patterns, the dangerous anti-patterns, the interaction with delegatecall, and the gas economics, all supported by annotated Solidity examples.


1. The Three Memory Regions of the EVM

Before diving into transient storage specifically, it helps to fix in mind the full taxonomy of EVM data locations:

RegionScopePersistenceApprox. Cost (read/write)
StackCurrent execution frameCleared on returnNegligible
MemoryCurrent execution frameCleared on returnLow (quadratic expansion)
Storage (SSTORE/SLOAD)Contract accountPermanent (until overwritten)High (20 000 / 2 100 gas)
Transient storage (TSTORE/TLOAD)Contract accountCleared at end of transactionLow (100 gas flat)

Stack and memory vanish when an execution frame returns. Storage persists across transactions forever. Transient storage sits in between: it outlives individual call frames but is garbage-collected at the transaction boundary.

The practical implication is that any data you write with TSTORE inside an internal call is readable by every subsequent call frame in the same transaction, including frames invoked by a completely different, attacker-controlled contract — as long as they share the same storage context.


2. TSTORE and TLOAD: Opcode Mechanics

2.1 The Opcodes

TSTORE slot value   ; writes value to transient slot (address-scoped)
TLOAD  slot         ; reads value from transient slot (address-scoped)

Both opcodes accept a 256-bit key just like SSTORE/SLOAD. Each contract address maintains its own transient storage namespace: a write by contract A at slot 0 is invisible to contract B reading slot 0. This is identical to how persistent storage namespacing works.

2.2 Gas Cost

  • TSTORE: 100 gas (flat, no cold/warm distinction, no dirty/clean distinction)
  • TLOAD: 100 gas (flat)

Compare this to SSTORE on a cold slot: 22 100 gas for the first write to a zero slot, and SLOAD on a cold slot: 2 100 gas. For patterns that require many temporary key-value writes within a single transaction and were previously forced to clean up storage slots (paying the refund dance), the saving is dramatic.

2.3 Solidity Syntax

Solidity exposes transient storage through the transient storage location keyword:

// State variable declared as transient
uint256 transient private _lock;
mapping(address => uint256) transient private _allowanceTemp;

Inline assembly uses the opcodes directly:

assembly {
    tstore(slot, value)
    let v := tload(slot)
}

3. The Reset Boundary: Transaction, Not Call

This is the single most important thing to understand about transient storage security.

3.1 What “Transaction Boundary” Means

An Ethereum transaction can trigger an arbitrarily deep tree of internal calls (call, staticcall, delegatecall, create). All of those calls happen within one transaction. Transient storage is shared across every frame in that tree as long as the frames share the same contract address. It is zeroed only when the top-level transaction completes — whether it commits or reverts.

Transaction begins
  ├─ Contract A: TSTORE(0, 1)        ← written
  ├─ Contract A calls Contract B
  │    └─ Contract B calls Contract A  ← Contract A can TLOAD(0) and sees 1
  ├─ Contract A: TLOAD(0)             ← still 1
Transaction ends → transient storage zeroed

3.2 What Survives a Revert?

When a call frame reverts, transient storage writes made within that frame are rolled back, mirroring the behavior of regular storage. However, transient storage writes made before the reverting call are preserved in the parent frame. This is a subtle but important detail for security analysis.

function exploit(address target) external {
    // Write something to transient storage
    assembly { tstore(0, 99) }

    // Call a function that reverts
    (bool ok,) = target.call(abi.encodeWithSignature("alwaysReverts()"));
    // ok == false, but our tstore(0, 99) is still live

    assembly {
        let v := tload(0)
        // v == 99, not 0
    }
}

This means a reverting sub-call cannot be used to “undo” transient storage written by the caller — only the sub-call’s own writes are rolled back.


4. Correct Usage: Reentrancy Locks

The canonical and safest use of transient storage is a reentrancy guard that needs to hold across re-entry within the same transaction but must be cheap enough to not penalize normal flows.

4.1 The Old Pattern (Persistent Storage)

// Gas: ~20 000 to set, ~2 900 to clear (plus a refund)
contract ReentrancyGuardStorage {
    uint256 private _status;

    modifier nonReentrant() {
        require(_status != 2, "reentrant call");
        _status = 2;
        _;
        _status = 1;
    }
}

The persistent-storage guard works, but it costs meaningful gas every time.

4.2 The Transient Storage Pattern (Correct)

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.24;

contract TransientReentrancyGuard {
    // Slot for the lock. Could also use a keccak hash.
    uint256 private constant LOCK_SLOT = uint256(keccak256("reentrancy.lock"));

    modifier nonReentrant() {
        assembly {
            if tload(LOCK_SLOT) { revert(0, 0) }
            tstore(LOCK_SLOT, 1)
        }
        _;
        assembly {
            tstore(LOCK_SLOT, 0)
        }
    }

    // Example protected function
    function withdraw(uint256 amount) external nonReentrant {
        // Effects
        balances[msg.sender] -= amount;
        // Interaction — reentrancy cannot re-enter because lock is set
        (bool ok,) = msg.sender.call{value: amount}("");
        require(ok);
    }

    mapping(address => uint256) public balances;
}

Why this is correct:

  1. The lock is set at the start of withdraw and cleared at the end — within the same call frame.
  2. Any reentrancy attempt during the external call hits the tload check, which reads 1, and reverts.
  3. At the end of the transaction the lock is zero anyway (transient reset), so there is no residual dirty slot.

4.3 The Reset Implication for Reentrancy Guards

Because transient storage survives for the whole transaction, a reentrancy guard written with TSTORE correctly prevents reentrancy within the same transaction. But it also means: if your architecture relies on the lock being absent at the start of the next call (from a fresh transaction), that is already true — each new transaction starts with a clean slate. This is fine and expected. The guard does not need manual cleanup before the next transaction.


5. Dangerous Anti-Pattern: State That Should Persist Across Internal Calls

The most common misuse is treating transient storage as a per-call scratch pad — assuming its value is isolated to the current call frame the way memory is.

5.1 The Confused Mental Model

A developer wants to pass contextual data from a router to a callback without using storage. Transient storage looks appealing: “it’s temporary, so it must be scoped to this call.” It is not. It is scoped to the transaction.

// ❌ INCORRECT — assumes transient storage is cleared between internal calls
contract VulnerableRouter {
    address transient private _currentCaller;

    function route(address target, bytes calldata data) external {
        // Intend: store the caller for this specific call only
        _currentCaller = msg.sender;

        // Call target; target might call back into this router
        (bool ok,) = target.call(data);
        require(ok);

        // Developer assumes _currentCaller is gone after `target.call` returns
        // It is NOT — it is still msg.sender until the transaction ends
    }

    function getCurrentCaller() external view returns (address) {
        return _currentCaller; // Returns stale data from earlier in the tx
    }
}

If target triggers another invocation of route (or any function that reads _currentCaller) before the first call returns, it will see the outer caller’s address, not its own. This can be used to impersonate the original caller in permission checks.

5.2 A Flash Loan Callback Attack

Consider a lending protocol that records the active borrower in transient storage during a flash loan:

// ❌ VULNERABLE flash loan contract
contract VulnerableFlashLoan {
    address transient private _activeBorrower;

    function flashLoan(address receiver, uint256 amount) external {
        _activeBorrower = receiver;

        // Transfer tokens, call receiver
        token.transfer(receiver, amount);
        IReceiver(receiver).executeOperation(amount);

        // Verify repayment
        require(token.balanceOf(address(this)) >= amount, "not repaid");
        _activeBorrower = address(0);
    }

    function privilegedCallback() external {
        // Only the active borrower should be able to call this
        require(msg.sender == _activeBorrower, "not borrower");
        // ... grant special access
    }
}

An attacker can craft a malicious executeOperation that itself initiates a second flash loan (for zero tokens or a trivial amount), which overwrites _activeBorrower with the attacker’s address. When privilegedCallback is called from within the second flash loan, msg.sender == _activeBorrower passes — not because the attacker is the legitimate borrower, but because the transient value was overwritten.


6. Interaction with delegatecall

delegatecall is the most treacherous surface for transient storage bugs because the storage context belongs to the caller, not the implementation.

6.1 The Rule

When contract A delegatecalls contract B:

  • B’s code runs in A’s storage context.
  • SSTORE/SLOAD in B read/write A’s persistent storage.
  • TSTORE/TLOAD in B also read/write A’s transient storage.

This is identical to how regular storage behaves with delegatecall, but developers often don’t internalize it because transient storage is new and the mental model is not yet muscle memory.

6.2 Proxy + Implementation Collision

// Proxy stores its own reentrancy lock in transient slot 0
contract Proxy {
    uint256 private constant LOCK_SLOT = 0;

    modifier nonReentrant() {
        assembly {
            if tload(LOCK_SLOT) { revert(0, 0) }
            tstore(LOCK_SLOT, 1)
        }
        _;
        assembly { tstore(LOCK_SLOT, 0) }
    }

    fallback() external payable nonReentrant {
        address impl = implementation();
        assembly {
            // delegatecall — impl code runs in Proxy's storage context
            let result := delegatecall(gas(), impl, 0, calldatasize(), 0, 0)
            returndatacopy(0, 0, returndatasize())
            switch result
            case 0 { revert(0, returndatasize()) }
            default { return(0, returndatasize()) }
        }
    }
}

// Implementation ALSO uses transient slot 0 for a different purpose
contract Implementation {
    uint256 private constant MY_SLOT = 0; // ← COLLISION

    function doSomething() external {
        uint256 val;
        assembly { val := tload(MY_SLOT) }
        // val is 1 because the Proxy set its reentrancy lock there
        // Logic depending on val == 0 is now broken
    }
}

The implementation reads 1 from slot 0 because the proxy already wrote its reentrancy lock there before the delegatecall. Any implementation-level logic that expected slot 0 to be zero will malfunction.

Mitigation: Use well-known namespacing (ERC-7201 style) for transient slot addresses:

// Derive transient slots from unique identifiers to avoid collision
uint256 private constant LOCK_SLOT =
    uint256(keccak256(abi.encode(uint256(keccak256("myprotocol.proxy.lock")) - 1)));

6.3 Cross-Contract Transient Reads via delegatecall

A subtler attack: an attacker deploys an implementation contract that reads transient storage slots they know the proxy or its other modules write to. By convincing the protocol to delegatecall their contract (e.g., via a module system, multicall, or upgrade), the attacker can exfiltrate transient state that was intended to be private.

// Attacker's malicious module
contract MaliciousModule {
    function steal(uint256 slot) external returns (uint256 val) {
        assembly { val := tload(slot) }
        // val is now whatever the calling proxy stored transiently
        // Attacker can use this to bypass permission checks, read flash loan state, etc.
    }
}

7. New Attack Surfaces Created by Misuse

7.1 Cross-Function Transient State Manipulation

Because transient storage persists across the entire transaction, an attacker who can execute code before the victim function runs (e.g., in a multicall, a batch executor, or a same-transaction callback) can pre-seed transient slots.

// Attacker uses a multicall to pre-set transient state, then call victim
function attack(address victim) external {
    // Step 1: Pre-seed a transient slot the victim reads
    assembly { tstore(TARGET_SLOT, ATTACKER_VALUE) }

    // Step 2: Call victim function that trusts this slot
    IVictim(victim).privilegedAction(); // reads TARGET_SLOT, gets ATTACKER_VALUE
}

This only works if the victim and attacker share the same storage context (i.e., the victim delegates to attacker-controlled code, or the attacker IS the calling contract in a multicall that runs them together).

7.2 Multicall and Batch Executor Risks

Protocols that implement a generic multicall — where arbitrary external calls are batched and executed sequentially — create a surface where transient state written by call N is readable by call N+1 through call N+k. If one of those calls is attacker-controlled, they can observe or manipulate transient state that other calls in the batch expected to be isolated.

// ❌ Dangerous pattern: multicall that does not isolate transient context
contract UnsafeMulticall {
    function multicall(Call[] calldata calls) external {
        for (uint256 i = 0; i < calls.length; i++) {
            // Each call runs in the same transaction — transient state bleeds through
            (bool ok,) = calls[i].target.call(calls[i].data);
            require(ok);
        }
    }
}

If calls[0] is a flash loan that sets _activeBorrower in transient storage, and calls[1] is an attacker-crafted call that reads that slot (via delegatecall into a shared implementation), the attacker can steal the borrower context.

7.3 Create2 Front-Running with Transient Storage

A contract deployed mid-transaction (via CREATE2) inherits a fresh transient namespace for its own address. However, if the deployer pre-stores a value in transient storage and then deploys an implementation that delegatecalls back to the deployer, the deployed contract can read the deployer’s transient state. This creates an obscure but valid information channel that protocol designers must account for.

7.4 Incomplete Cleanup as a Latent Bug

Unlike persistent storage, where a developer might SSTORE(slot, 0) as cleanup, transient storage cleans itself. However, if a developer explicitly resets a transient slot in a try/catch or conditional path but not all paths, they may create inconsistent state within the same transaction:

// ❌ Asymmetric cleanup creates latent state
function maybeReset(bool shouldReset) external {
    if (shouldReset) {
        assembly { tstore(LOCK_SLOT, 0) }
    }
    // If shouldReset == false, the lock persists for the rest of the tx
    // Any subsequent call that checks LOCK_SLOT sees stale data
}

8. Gas Implications

8.1 Cost Comparison

OperationGas (cold)Gas (warm)
SLOAD2 100100
SSTORE (zero → non-zero)22 1002 900
SSTORE (non-zero → zero, refund era)2 900 (partial refund)
TLOAD100100 (always warm)
TSTORE100100 (always warm)

Transient storage has no cold/warm distinction. Every TLOAD and TSTORE costs exactly 100 gas regardless of access history. There is also no refund mechanism because no cleanup is needed — the EVM zeroes the space automatically.

8.2 When the Gas Saving Is Real

  • Reentrancy locks: Replace a ~22 100 gas SSTORE on entry + 2 900 gas SSTORE on exit with two 100-gas TSTORE calls. Saving: ~24 800 gas per guarded call.
  • Temporary approval patterns (e.g., ERC-20 transient approvals as described in ERC-7674): Replace a persistent approve + transfer + approve(0) pattern with transient TSTORE.
  • Intra-transaction context passing: Replacing storage slots used as call context (e.g., flash loan borrower address, router context flags) with transient storage — as long as the context is intentionally shared across the transaction.

8.3 When Gas Saving Is Not Worth the Risk

  • Any value that has access-control semantics and must not be observable or manipulable by other call frames in the same transaction.
  • Any value where the developer’s mental model is “this is my own call frame’s data” — use memory instead.
  • Slots used in proxy/implementation pairs without explicit collision-resistant namespacing.

9. Correct vs. Incorrect: Side-by-Side Reference

9.1 Reentrancy Lock (Correct)

// ✅ CORRECT: transient storage as a reentrancy guard
pragma solidity ^0.8.24;

contract SafeVault {
    uint256 private constant _LOCK =
        uint256(keccak256(abi.encode(uint256(keccak256("safevault.lock")) - 1)));

    mapping(address => uint256) public balances;

    modifier nonReentrant() {
        assembly {
            if tload(_LOCK) { revert(0, 0) }
            tstore(_LOCK, 1)
        }
        _;
        assembly { tstore(_LOCK, 0) }
    }

    function deposit() external payable {
        balances[msg.sender] += msg.value;
    }

    function withdraw(uint256 amount) external nonReentrant {
        require(balances[msg.sender] >= amount, "insufficient");
        balances[msg.sender] -= amount;
        (bool ok,) = msg.sender.call{value: amount}("");
        require(ok, "transfer failed");
    }
}

9.2 Reentrancy Lock (Incorrect — Same Slot as Implementation)

// ❌ INCORRECT: slot collision between proxy lock and implementation
contract BadProxy {
    uint256 private constant LOCK = 0; // ← naive slot

    modifier nonReentrant() {
        assembly {
            if tload(LOCK) { revert(0, 0) }
            tstore(LOCK, 1)
        }
        _;
        assembly { tstore(LOCK, 0) }
    }

    fallback() external payable nonReentrant {
        address impl = _implementation();
        assembly {
            delegatecall(gas(), impl, 0, calldatasize(), 0, 0)
        }
    }
}

contract BadImpl {
    uint256 private constant FLAG = 0; // ← same slot, different intent

    function check() external view returns (bool) {
        uint256 v;
        assembly { v := tload(FLAG) }
        return v == 0; // Always returns false while proxy lock is active — broken
    }
}

9.3 Transient Context Passing (Correct)

// ✅ CORRECT: transient context passed to a known callback, cleared afterwards
contract SafeFlashLoan {
    uint256 private constant BORROWER_SLOT =
        uint256(keccak256(abi.encode(uint256(keccak256("flashloan.borrower")) - 1)));

    function flashLoan(address receiver, uint256 amount) external {
        require(_getBorrower() == address(0), "loan in progress");

        _setBorrower(receiver);
        token.transfer(receiver, amount);
        IReceiver(receiver).executeOperation(amount);
        require(token.balanceOf(address(this)) >= amount, "not repaid");
        _setBorrower(address(0)); // explicit reset — defensive
    }

    function _setBorrower(address b) private {
        assembly { tstore(BORROWER_SLOT, b) }
    }

    function _getBorrower() private view returns (address b) {
        assembly { b := tload(BORROWER_SLOT) }
    }

    // Only callable during an active flash loan by the registered borrower
    function borrowerCallback() external {
        require(msg.sender == _getBorrower(), "not borrower");
        // ...
    }

    IERC20 immutable token;
    constructor(IERC20 t) { token = t; }
}

9.4 Transient Context Passing (Incorrect — No Guard Against Re-Entry)

// ❌ INCORRECT: nested flash loan overwrites borrower slot
contract VulnFlashLoan {
    address transient private _borrower;

    function flashLoan(address receiver, uint256 amount) external {
        _borrower = receiver;
        token.transfer(receiver, amount);
        IReceiver(receiver).executeOperation(amount);
        // Attacker's executeOperation calls flashLoan again with amount=0
        // _borrower is now overwritten with attacker's address
        require(token.balanceOf(address(this)) >= amount);
        _borrower = address(0);
    }

    function borrowerCallback() external {
        require(msg.sender == _borrower, "not borrower"); // Passes for attacker
        // ... grants undeserved privilege
    }

    IERC20 immutable token;
    constructor(IERC20 t) { token = t; }
}

10. Transient Storage Security Checklist

Use this checklist when auditing or writing any contract that touches transient storage.

Lifetime and Scope

  • I understand that transient storage persists for the entire transaction, not just the current call frame. I have not confused it with memory.
  • I have verified that transient writes survive sub-call returns. Data written in a parent frame is visible in all child frames and subsequent sibling frames.
  • I have verified that reverts in sub-calls roll back only that sub-call’s transient writes, leaving the parent’s transient state intact.
  • I have not assumed transient storage is zeroed between internal calls. If I need per-call isolation, I use memory or function-local variables.

Access Control and Context

  • Access-controlled values stored transiently are protected against being pre-seeded by an attacker who can execute code before the protected function in the same transaction (multicall, batch, callback).
  • Context slots (e.g., active borrower, router origin) are guarded against overwrite by nested calls. A second invocation of the same function must not silently overwrite the outer context.
  • I have audited every function that reads transient storage for access-control decisions to confirm the value cannot be manipulated by a caller-controlled sequence of operations within the same transaction.

delegatecall

  • All transient storage slots are namespaced using a collision-resistant derivation (e.g., ERC-7201 pattern) so proxy and implementation slots do not collide.
  • I have listed every contract that runs in my proxy’s storage context (via delegatecall) and verified none of them write to the same transient slots as the proxy.
  • Module systems and plugin architectures that delegatecall untrusted code have audited what transient slots that code may read or write in the host contract’s context.

Multicall and Batch Executors

  • Transient state written by one batch entry is not unsafely visible to subsequent entries unless that is explicitly intentional and documented.
  • Generic multicall contracts that accept user-supplied call targets are treated as transient-state-leaking surfaces and reviewed accordingly.

Reentrancy Guards

  • Transient reentrancy locks use a unique, namespaced slot to avoid collision with any other contract running in the same storage context.
  • The lock is set before any external call and cleared after, with no code path that returns without clearing the lock.
  • The lock is not the only reentrancy defense in high-value contracts — checks-effects-interactions is still applied.

Gas and Economics

  • I have not used transient storage for data that must persist beyond the transaction (e.g., user balances, cumulative counters, approved addresses for future transactions).
  • I have not used transient storage where memory would suffice (i.e., data that is only needed within a single call frame). This is not a security issue but wastes marginal gas and adds surface area.
  • I have not assumed TSTORE costs zero gas. At 100 gas each, a tight loop of TSTORE operations is still measurable.

Code Review

  • Every tstore has a corresponding audit note explaining: what the value represents, which functions read it, and whether any of those reads are used for access control.
  • Transient variable names are distinct from both storage and memory variables to prevent mental model confusion in code reviews.
  • Test suite includes a test where an attacker attempts to pre-seed each transient slot used in access-control decisions before calling the guarded function.
  • Test suite includes a re-entrancy test via a malicious callback for every function that writes transient state and then performs an external call.

Conclusion

Transient storage is a well-designed primitive that delivers real gas savings for a specific class of problems: data that is intentionally shared across the internal call tree of a single transaction and discarded at the transaction boundary. The reentrancy lock is its ideal use case: cheap to set, survives re-entry attempts, and automatically released.

The vulnerability class emerges not from a flaw in the opcode design but from a mismatch between developer intuition and actual semantics. Developers used to memory expect per-call isolation. Developers used to storage expect permanence. Transient storage is neither, and every contract that uses it must be designed with the full transaction call tree in mind, not just the immediate function.

The delegatecall interaction amplifies the risk: any contract that shares a storage context with another contract now shares its transient namespace too, making slot collision a concrete security concern rather than a theoretical one. Namespacing is not optional.

Treat every transient read that feeds an access-control decision as a high-risk operation. Ask: can any call that executes before this read, in the same transaction, write a value that changes the outcome in the attacker’s favor? If yes, the design has a vulnerability. This single question, applied consistently, will catch the majority of transient storage security bugs before they reach production.