2024 Web3 Security ReportAccess control exploits account for nearly 80% of crypto hacks in 2024.
Discover report insights
  • Hacken
  • Blog
  • Discover
  • Mastering Transient Storage in Uniswap V4

Mastering Transient Storage in Uniswap V4

11 minutes

This article is part of a series exploring security considerations in Uniswap V4. In our previous article, Auditing Uniswap V4 Hooks: Risks, Exploits, and Secure Implementation, we analyzed potential vulnerabilities in hooks and best practices for secure integration. Here, we focus on the security implications of transient storage (EIP-1153) and its impact on Uniswap V4.

Why Transient Storage Is Needed And How It Works

Smart contracts often need to store temporary data that remains consistent throughout a transaction but is no longer needed once the transaction is complete. In Uniswap V4, this requirement is particularly relevant due to its new architecture, which introduces hooks. Hooks enable custom logic at key points in a swap or liquidity event, but they also consume gas, making efficient storage crucial.

Let’s explore transient storage (EIP-1153), how it differs from traditional storage and memory, and why it’s a key innovation for gas efficiency.

The Drawbacks of Memory

Typically, Solidity contracts use memory to store temporary data, e.g. for local variables. 

address spender = _msgSender();

However, memory only persists within a single execution frame (context of a function call). When a contract calls another contract externally, a new execution frame is created, meaning that any data stored in memory is lost between cross-contract calls.

Memory usage in cross-contract swap call

The High Cost of Persistent Storage

An alternative is to store the flag in storage, just like it’s done in reentrancy locks:

bool private locked;

However, using persistent storage (SSTORE and SLOAD opcodes) in the Uniswap V4 unlock mechanism incurs significant gas costs as it involves updating persistent blockchain state. Below is a step-by-step breakdown of gas consumption at each stage of the transaction.

  1. Call unlock() 

Operation: Writing to Storage (SSTORE) unlock flag = true

Writing to a cold storage slot: 20,000 gas

Reading the storage slot (SLOAD) before modifying it: 2,100 gas

  1. Call swap() onlyWhenUnlocked

Operation: Reading from Storage (SLOAD) unlock flag

Reading an existing storage slot: 2,100 gas

  1. End of Execution

Operation: Resetting the Unlock Flag (SSTORE)

Modifying an already written storage slot: 5,000 gas

Refund: 4,800 gas (since the value is reset to its original state)

Total Cost For unlock flag manipulations = 24,400 gas

Storage usage in cross-contract swap call

In this case, we do not need the data after the transaction completes, so paying the high gas cost of persistent storage is unnecessary.

From the discussion above, there are such requirements:

  • Data must persist across multiple contract calls within a single transaction.
  • Data should not persist after the transaction ends, to avoid unnecessary storage costs.

This is exactly what transient storage (EIP-1153) provides. Transient storage behaves like persistent storage (persists across calls) but is erased automatically at the end of the transaction (like memory storage does). 

EIP-1153: The Solution

EIP-1153 was first proposed in 2018 and implemented in the Cancun upgrade (March 13, 2024). Solidity added support for EIP-1153 in version 0.8.24. EIP-1153 introduces two new opcodes:

  • TSTORE (Transient Store) – Writes data into transient storage.
  • TLOAD (Transient Load) – Reads data from transient storage.

All interaction with transient storage is done through a hash table (key-value), where a 32-byte key points to a 32-byte value, similar to SSTORE/SLOAD. However, these two types of memory do not intersect; that is, transient storage is not a dedicated part of storage but a separate memory with slots from 0 to 2256 – 1.

Each contract has its own transient storage allocated; therefore, it can not be directly accessed or modified by other contracts, except for the explicit functions allowing that or via delegatecall.

Separate Transient Storages For Contracts

In the Uniswap V4 PoolRouter, the contract maintains an unlocked flag using these opcodes.

library Lock {
    // The slot holding the unlocked state, transiently. bytes32(uint256(keccak256("Unlocked")) - 1)
    bytes32 internal constant IS_UNLOCKED_SLOT = 0xc090fc4683624cfc3884e9d8de5eca132f2d0ec062aff75d43c0465d5ceeab23;

    function unlock() internal {
        assembly ("memory-safe") {
            // unlock
            tstore(IS_UNLOCKED_SLOT, true)
        }
    }

    function lock() internal {
        assembly ("memory-safe") {
            tstore(IS_UNLOCKED_SLOT, false)
        }
    }

    function isUnlocked() internal view returns (bool unlocked) {
        assembly ("memory-safe") {
            unlocked := tload(IS_UNLOCKED_SLOT)
        }
    }
}

Source: Lock.sol

The gas cost for each TSTORE and TLOAD is 100. Below is a step-by-step breakdown of gas consumption at each stage of the transaction when using transient storage.

  1. Call unlock() 

Operation: Writing to Storage (TSTORE) unlock flag = true

Writing to a cold storage slot: 100 gas

  1. Call swap() onlyWhenUnlocked

Operation: Reading from Transient Storage (TLOAD) unlock flag

Reading an existing transient storage slot: 100 gas

  1. End of Execution

Operation: Resetting the Unlock Flag (TSTORE)

Modifying transient storage slot: 100 gas

Total Cost For unlock flag manipulations = 300 gas

Transient Storage usage in cross-contract swap call

Flash Accounting and Transient Storage in Uniswap V4

In Uniswap V2 and V3, every swap operation involved immediate token transfers between pools. This design had several inefficiencies:

  • High Gas Costs for Multi-Hop Swaps: Every intermediate step in a multi-hop swap requires an external call to transfer tokens.
  • Solvency Requirements: Since each pool was a separate contract, token balances needed to be updated after every step to ensure solvency.

Multi-hop swap In Uniswap V2 and V3

In the given diagram, the user wants to receive TokenD via a Multihop Swap, meaning that each swap step occurs separately, leading to four mandatory token transfers.

In Uniswap V4, thanks to the singleton design, Multihop Swaps are no longer necessary. Instead of transferring tokens at each step, the protocol allows passing a Pool Key that contains both currency token1 and token2, allowing the entire swap operation to be handled more efficiently:

Multi-hop swap In Uniswap V2 and V3

This streamlined process reduces the number of required transfers from four (in V2/V3 multihop swaps) to just two, leading to lower gas costs and increased efficiency.

The Role of Transient Storage in Uniswap V4 Flash Accounting

In Uniswap V4, interactions with the Pool Manager start with the unlock function. At the end of this function, the protocol verifies the number of non-zero deltas, which represent the net changes in pool balances resulting from user operations within a single transaction.

In previous Uniswap versions, each swap or liquidity modification was a separate transaction.

Example:

  • A swap of 100 TokenA → 70 TokenB required 2 token transfers.
  • Adding 50 TokenB as liquidity required 1 additional transfer.

This results in 3 separate transfers in total.

Swap and Liquidity Modification in Previous Uniswap versions

In Uniswap V4, unlock enables a batched execution model, where a single transaction can include multiple operations without intermediate transfers.

When unlock is executed, it triggers an unlock callback in the user’s contract. This callback allows the user to perform multiple swaps and liquidity modifications before finalizing the transaction.

Example:

Within the unlockCallback, the user performs:

  • Swap: 100 TokenA → 70 TokenB.
  • Add liquidity: 50 TokenB.

This results in the following delta changes:

After swap:

  • TokenA delta: -100
  • TokenB delta: +70

After adding liquidity:

  • TokenA delta: -100
  • TokenB delta: +70 – 50 = +20

These deltas are stored in transient storage, making storage updates cheaper, as they do not require immediate on-chain state changes.

  • Tracking Nonzero Deltas
library NonzeroDeltaCount {
    // Slot for storing the number of nonzero deltas
    bytes32 internal constant NONZERO_DELTA_COUNT_SLOT =
        0x7d4b3164c6e45b97e7d87b7125a44c5828d005af88f9d751cfd78729c5d99a0b;

    function read() internal view returns (uint256 count) {
        assembly ("memory-safe") {
            count := tload(NONZERO_DELTA_COUNT_SLOT)
        }
    }

    function increment() internal {
        assembly ("memory-safe") {
            let count := tload(NONZERO_DELTA_COUNT_SLOT)
            count := add(count, 1)
            tstore(NONZERO_DELTA_COUNT_SLOT, count)
        }
    }

    function decrement() internal {
        assembly ("memory-safe") {
            let count := tload(NONZERO_DELTA_COUNT_SLOT)
            count := sub(count, 1)
            tstore(NONZERO_DELTA_COUNT_SLOT, count)
        }
    }
}

Source: NonzeroDeltaCount.sol

This library tracks how many balance-changing operations (deltas) are currently nonzero. Nonzero delta tracking is only relevant within the transaction. No need to store this count persistently, as it resets after execution.

  • Managing Currency Deltas
library CurrencyDelta {
    function _computeSlot(address target, Currency currency) internal pure returns (bytes32 hashSlot) {
        assembly ("memory-safe") {
            mstore(0, and(target, 0xffffffffffffffffffffffffffffffffffffffff))
            mstore(32, and(currency, 0xffffffffffffffffffffffffffffffffffffffff))
            hashSlot := keccak256(0, 64)
        }
    }

    function getDelta(Currency currency, address target) internal view returns (int256 delta) {
        bytes32 hashSlot = _computeSlot(target, currency);
        assembly ("memory-safe") {
            delta := tload(hashSlot)
        }
    }

    function applyDelta(Currency currency, address target, int128 delta)
        internal
        returns (int256 previous, int256 next)
    {
        bytes32 hashSlot = _computeSlot(target, currency);

        assembly ("memory-safe") {
            previous := tload(hashSlot)
        }
        next = previous + delta;
        assembly ("memory-safe") {
            tstore(hashSlot, next)
        }
    }
}

Source: CurrencyDelta.sol

This library stores temporary net balance changes (deltas) instead of token transfers. This ensures correct accounting without modifying ERC-20 balances mid-transaction.

Final Settlement and Token Transfers

Inside the callback, take and settle functions are called to finalize the transaction:

  • Settle transfers 100 TokenA from the user to the Pool Manager.
  • Take transfers 20 TokenB from the Pool Manager to the user.

Thus, instead of 3 separate transfers (as in Uniswap V2/V3), only 2 token transfers occur in Uniswap V4.

Swap and Liquidity Modification in Uniswap v4

Security Considerations of Uniswap V4 Using Transient Storage

❌ The Risk: Transient Storage Persists Within a Transaction

In all the transient storage-based libraries we have reviewed, such as Lock.sol, NonzeroDeltaCount.sol, CurrencyDelta.sol, and CurrencyReserves.sol, we observe a common security pattern:

Explicitly Resetting Transient Storage Flags

function lock() internal {
    assembly ("memory-safe") {
        tstore(IS_UNLOCKED_SLOT, false)
    }
}

Source: Lock.sol

At first glance, this step might seem redundant since transient storage automatically resets at the end of a transaction. However, the key reason for this explicit update is that not all contract calls are part of an isolated transaction.

Transient storage clears only at the end of a transaction, not after every contract call. If a contract call is part of a larger batched transaction (e.g., via a relayer, smart contract wallet, or composable DeFi protocol), the transient storage state may persist longer than expected. This can lead to incorrect assumptions about state validity, potentially allowing unintended consequences.

❌Incorrect Use: Transient Storage Mapping Without Cleanup

Transient storage (TSTORE/TLOAD) can be used as an alternative to in-memory mappings in scenarios where temporary state management is required within a single transaction. However, mappings used in transient storage also require explicit cleanup mechanisms.

This means that all keys stored in a transient mapping must also be tracked, so they can be reset at the appropriate time. Failing to do so can lead to incorrect behavior or unintended persistence of data during transaction execution.

contract TransientMappingExample {
    function processData(uint256 key, uint256 value) external {
        assembly {
            tstore(key, value) // Storing value in transient storage mapping
        }
    }

    function readData(uint256 key) external view returns (uint256) {
        assembly {
            let value := tload(key) // Retrieving value from transient storage
            return(value, 32)
        }
    }
}

❌ Incorrect Use: Storing Persistent Data in Transient Storage

The next key security consideration when using transient storage is that it should be used only when data is needed temporarily within a single transaction. If transient storage is mistakenly used to store user balances, approvals, or other persistent data, the values will be lost when the transaction ends.

This is an example of a dangerous use case where user balances are stored in transient storage (TSTORE) instead of persistent storage (SSTORE):

mapping(address => uint256) private balances;

function deposit() external payable {
    assembly {
        tstore(caller(), add(tload(caller()), callvalue())) // Storing balance in transient storage
    }
}

function withdraw(uint256 amount) external {
    assembly {
        let balance := tload(caller())  // Load transient balance
        if lt(balance, amount) {
            revert(0, 0)
        }
        tstore(caller(), sub(balance, amount)) // Update balance
    }
    payable(msg.sender).transfer(amount);
}

❌ Incorrect Use: Using Transient Storage When Memory Is Needed

In the same way, an assumption when using transient storage is that the data exists only within a single execution frame is incorrect. If the contract logic involves internal calls that should reset memory but instead use transient storage, the data may unexpectedly persist across different execution frames.

Here’s an example where a found flag is mistakenly stored in transient storage (TSTORE) instead of using memory, leading to incorrect behavior when searching in multiple arrays:

contract SearchExample {
    uint256[] private array1 = [1, 2, 3, 4, 5];
    uint256[] private array2 = [6, 7, 8, 9, 10];

    function findInArrays(uint256 target) external returns (bool, bool) {
        bool foundInArray1 = searchArray(array1, target);
        bool foundInArray2 = searchArray(array2, target);

        return (foundInArray1, foundInArray2);
    }

    function searchArray(uint256[] storage arr, uint256 target) internal returns (bool) {
        assembly {
            let found := tload(0) // Load found flag from transient storage (WRONG)
            let len := sload(arr.slot)

            for { let i := 0 } lt(i, len) { i := add(i, 1) } {
                let val := sload(add(arr.slot, i))
                if eq(val, target) {
                    tstore(0, 1) // Store found flag in transient storage
                    found := 1
                    break
                }
            }
            return(found, 32)
        }
    }
}

Conclusion

Uniswap V4 is rewriting the rules of DeFi efficiency, and transient storage (EIP-1153) is at the core of this transformation. By replacing expensive storage writes with temporary state tracking, Uniswap V4 slashes costs and eliminates unnecessary token transfers, making multi-hop swaps and liquidity updates cheaper.

  • Smart use of transient storage enables Flash Accounting, cutting down token transfers to just one final settlement.
  • Explicitly resetting transient storage ensures state consistency, preventing unexpected behavior in complex transactions.
  • Transient storage should be used only when data is needed to be stored across one transaction.

Resources:

Docs:

Code: 

Subscribe
to our newsletter

Be the first to receive our latest company updates, Web3 security insights, and exclusive content curated for the blockchain enthusiasts.

Speaker Img

Table of contents

  • Why Transient Storage Is Needed And How It Works
  • The Drawbacks of Memory
  • The High Cost of Persistent Storage
  • EIP-1153: The Solution

Tell us about your project

Follow Us

Read next:

More related

Trusted Web3 Security Partner