A look into transient storage’s possible security and coding mistakes.

Table of contents:
1. What is Transient storage (in short)
2. Applications of transient storage (in short)
3. Usage with examples
4. Security considerations


What is Transient storage (in short) :

The Transient storage is the dedicated data location in EVM that behaves like a storage but this data location is discarded after each transaction. Transient storage is cheaper than storage.

Solidity 0.8.24 supports TSTORE and TLOAD opcodes included in the Cancun hardfork which can be used to store and load/access the transient storage.

For more info about background and motivation please read:
https://eips.ethereum.org/EIPS/eip-1153 and https://soliditylang.org/blog/2024/01/26/transient-storage/


Applications of transient storage:

(Some applications/use cases are listed in the eip doc. are:)

  1. Reentrancy locks (which we will see below)
  2. On-chain computable CREATE2 addresses: constructor arguments are read from the factory contract instead of passed as part of init code hash
  3. Single transaction ERC-20 approvals, e.g. #temporaryApprove(address spender, uint256 amount)
  4. Fee-on-transfer contracts: pay a fee to a token contract to unlock transfers for the duration of a transaction
  5. “Till” pattern: allowing users to perform all actions as part of a callback, and checking the “till” is balanced at the end
  6. Proxy call metadata: pass additional metadata to an implementation contract without using calldata, e.g. values of immutable proxy constructor arguments

Additionally, check Applications of Transient Storage (EIP-1153) By Moody Salem.


Usage with examples:

Reentrancy lock with storage:

contract ReentrancyGuard {
    bool public entered;
    mapping(address => uint) values;

    modifier nonreentrant {
        require(!entered,"Reentrancy");
        entered = true;
        _;
        entered = false;
    }

    receive() external payable{
        values[msg.sender] = msg.value;
    }

    function withdraw() nonreentrant public {
        uint balance = values[msg.sender];
        (bool success,) = msg.sender.call{value: balance}("");
        require(success);
        values[msg.sender]=0;
    }
}

Reentrancy lock with transient storage:

contract TransientReentrancyGuard {
    uint256 public theStorageForNoReason = 1 ; // Yes i can still use the storage!
    mapping(address => uint) public values;

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

        assembly {
            tstore(0, 0)
        }
    }

    receive() external payable{
        values[msg.sender] = msg.value;
    }

    function withdraw() nonreentrant public {
        uint balance = values[msg.sender];
        (bool success,) = msg.sender.call{value: balance}("");
        require(success);
        values[msg.sender]=0;
    }
}

It can be seen from the second example that the values can be stored in transient storage with tstore and the transient storage can be accessed with tload. for more check both tstore and tload.

If we deploy both the contracts and call the withdraw function on each to check the gas usage, It can be seen that the one that uses storage, uses more gas than the one that uses transient storage.


Security considerations:

EIP 1153’s security consideration section highlights some interesting considerations that can lead to security risks or can break some functionality if not tested well.

Not setting the used transient storage slot back to default:

Because transient storage is automatically cleared at the end of the transaction, smart contract developers may be tempted to avoid clearing slots as part of a call in order to save gas. However, this could prevent further interactions with the contract in the same transaction (e.g. in the case of re-entrancy locks) or cause other bugs — EIP 1153 Doc.

Below are some examples that highlight this scenario more practically:

  1. Not setting the used slot again to default (Reentrancy guard example):
contract TransientReentrancyGuardWithOrderExecution {
        struct Order{
            uint pendingPayment;
            address toAddress;
        }
        mapping(address => uint) public values;
        mapping(address => Order) public pendingOrder;

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

            // Assumption: the transient storage will be cleared at the end of this call so no tstore(0,0) is needed. And -
            // if someone reenters to the transaction because the value for 0th slot would be non zero, it would revert.
        }

        receive() external payable{
            values[msg.sender] = msg.value;
        }

        // This function gets called in the batch call.
        function executeOrder(address _address, bytes32 _addressSignature) nonReentrant public {
            // Signature check
            // ...

            uint pendingPayment = pendingOrder[_address].pendingPayment;
            (bool success,) = pendingOrder[_address].toAddress.call{value: pendingPayment}("");
            require(success);
            pendingOrder[_address].pendingPayment = 0;
        }
}

TransientReentrancyGuardWithOrderExecution contract overview:
The executeOrder() function will be called by anyone with the signature to get the pending payment for the _address entered.

In the case if toAddress tries to reenter while receiving the pendingPayment to call executeOrder(), the transaction will revert because nonReentrant will check that if tload(0) is non-zero, if yes then it will revert.

After the first successful transaction, the transient storage will be erased so that for the next transaction to the executeOrder() the nonReentrant modifier will execute normally without reverting as the tload(0) is zero so there’s no need to revert.

In the above example, the assumption is made that it’s unnecessary to set the storage again to default because it would eventually be erased at the end of the transaction.

But this assumption is incorrect in the case when the executeOrder() will be executed in the batch, there would be multiple executeOrder() calls involved in the one transaction. But for the second executeOrder() call the nonReentrant will revert because the tload(0) isn’t set to default at the end of the first call.

This shows how it can break the functionality and create problems.

2. Not setting the used slot again to default (Order executor example):

contract OrderExecutorWithTransientStorage {
    struct Order{
        uint[] numberOfTokenTransferredForIds;
        address from;
        address to;
    }
    event numberOfTokensTransferred( uint);
    
    function executeOrder(Order calldata order) public {
        uint numberOfTokenTransferred;

        for(uint i=0; i<order.numberOfTokenTransferredForIds.length;i++){
            numberOfTokenTransferred = order.numberOfTokenTransferredForIds[i];
            assembly {
                tstore(0,add(tload(0),numberOfTokenTransferred))
            }

        }

        // Use of numberOfTokenTransferred in important logic.
        // ...

        assembly {
            numberOfTokenTransferred := tload(0)
        }

        // Use of numberOfTokenTransferred in important event.
        emit numberOfTokensTransferred(numberOfTokenTransferred); 

        // Assumption: the transient storage will be cleared at the end of this call so no tstore(0,0) is needed.
        // assembly {
        //     tstore(0, 0)
        // }
    }
}

OrderExecutorWithTransientStorage contract overview:
This sample contract executes order with executeOrder() function. The executeOrder() adds elements from numberOfTokenTransferredForIds array to the transient storage at slot 0. We can notice that the value is not getting assigned but it's getting added to the previous value. Let’s assume that the value stored in transient storage is being used as a part of further function logic and may be stored in transient storage again.

The value is then loaded and assigned to the numberOfTokenTransferred variable again so that it can be used while emitting the numberOfTokensTransferred event.

In this example, the same assumption is being made that it’s unnecessary to set the storage again to default because it would eventually be erased at the end of the transaction.

But this assumption is incorrect as the previous one. Let’s say the executeOrder() function will be executed in the batch (say 2 times) then because the transient storage value stored in the first call is not yet cleared, In the for loop it will get added to the previously stored value. Which makes the assumption incorrect again.

The above scenario can create security risks depending on the situation. So in this case for the second batch call, the emitted numberOfTokenTransferred value will be an addition of the previous value plus the one that got calculated in the for loop for this call. And depending on the usage of this value the impact will vary.

Using transient storage to store mapped values ( similar to Not setting the used storage slot back to default as discussed above):

Smart contract developers may also be tempted to use transient storage as an alternative to in-memory mappings. They should be aware that transient storage is not discarded when a call returns or reverts, as is memory, and should prefer memory for these use cases so as not to create unexpected behavior on reentrancy in the same transaction — EIP 1153 Doc.

The quoted statement highlights a similar thing regarding using transient storage for mapping values without keeping track and without clearing them up which can lead to unexpected behavior in complex transactions.

So in general it is good practice to clear the transient storage:

We recommend to generally always clear transient storage completely at the end of a call into your smart contract to avoid these kinds of issues and to simplify the analysis of the behaviour of your contract within complex transactions. — Solidity lang blogpost

Overall the scenario where the transient storage is not getting cleared before the next call can create security risks when it comes to the usage of the transient state in critical logic where the value stored before will still be accounted for in the new call which can lead to security risks depending on the logic.

Additionally, it’s worth taking a look at Low-gas reentrancy attack here and here.


Resources:

EIP-1153: Transient storage opcodes

Transient Storage Opcodes in Solidity 0.8.24

What is transient storage? It's applications and security considerations

TSTORE Low Gas Reentrancy