Transient Storage. A game changer for token approvals?

Introduction

With the EVM Cancun hardfork coming up and the introduction of EIP-1153’s Transient Storage, a wide range of potential use cases are introduced. This article will explore one particular use case: transient token approvals.

What is Transient Storage?

EIP-1153 introduces transient storage which like the EVM’s memory, calldata and storage is a data location. It works similar to storage being a key-value store but the key difference is that transient storage only exists for the duration of the transaction in which it is created. This means transient storage is automatically cleared after every transaction.

Transient storage is accessible via two new opcodes (T standing for transient):

  • TLOAD (0x5c) - pops a 32-byte word of the stack, uses this as an address to retrieve the 32-byte word from transient storage and pushes it onto the stack.
  • TSTORE (0x5d) - pops two 32-byte words of the stack, uses the first value as an address and the second as the value to store at that address location in transient storage.

Note that (for now) Solidity only supports transient storage usage through Yul (assembly).

As you may have noticed, transient storage works very similarly to persistent storage. However, the cost for TSTORE is 100 gas and also 100 gas for TLOAD, much cheaper than SSTORE and SLOAD. This is one of the main benefits of using transient storage, in addition to TSTORE not being subject to the gas stipend as defined in EIP-2200.

What does it mean for devs?

Transient storage enables many new use cases for developers. For example, the Uniswap team is planning to use transient storage in their flash accounting system for Uniswap V4, which abstracts the individual swaps in a transaction and only transfers tokens on net balances. You can read more about that here:

Our Vision for Uniswap v4

What does it mean for security researchers?

Security researchers need to be vigilant with the introduction of transient storage, as it can not only introduce new attack vectors but also amplify existing ones. There has been research into potential reentrancy vulnerabilities that arise from transient storage, more can be read about that here:

TSTORE Low Gas Reentrancy

What does it mean for token approvals?

There have been many exploits involving token approvals, however because of the “transient” nature of transient storage, could one implement token approvals which are guaranteed to only last for the transaction in which they are created?

This potential use case was actually mentioned in the EIP in 2018:

EIP-1153 by Alexey Akhunov and Moody Salem EIP-1153 by Alexey Akhunov and Moody Salem

If users are able to approve their tokens transiently, this could improve the security of token approvals and ensure that approvals only last for the transaction in which they are created, all while keeping gas costs low.

After playing around with this concept for a while, I came up with an important invariant of the system: Any approvals made transiently should not leak into persistent storage directly or indirectly. This invariant will be explored further in the proof of concept.

Case Study: Seneca Exploit

Recently, an exploit occurred which supported this idea. Seneca Protocol was recently exploited for $6.5M due to a malicious approval consumption. Similar exploits happened this year such as Socket ($3.3M) and Basket DAO ($107K). The Seneca attacker targeted a specific function which allowed arbitrary calls that transfer tokens from wallets that had maximum approvals to Seneca contracts.

Theoretically, if the collateral tokens contained transient functionality and Seneca incorporated this feature into their smart contracts, this exploit could have been prevented. Even if users had approved Seneca to use the maximum amount of tokens in their wallet, these approvals would only persist within the transaction in which they were created and would not allow access to the tokens once the transaction has been executed.

More can be read about the exploit here:

Seneca Exploit - Post Mortem

CharlesWang on Twitter / X

Proof Of Concept

I have implemented transient approvals for three of the most widely used token standards on Ethereum: ERC20, ERC721 and ERC1155.

The Github repository is available here for reference. I have also implemented a TransientWETH contract, which I don’t go over here (as it just inherits from TransientERC20) but is available in the Github repo here.

For each transient token implementation, I have made them backwards compatible with their standard implementations. These token standards are widely used and restricting users to one type of token approval would not be realistic.

I will also show the gas cost comparison between using persistent approvals and transient approvals for each token standard.

TransientERC20

Transient Approvals

function transientApprove(address spender, uint256 amount) public virtual returns (bool) {
    bytes32 location = _transientApprovalLocation(msg.sender, spender);

    assembly {
        tstore(location, amount)
    }

    emit Approval(msg.sender, spender, amount);

    return true;
}
function transientPermit(
    address owner,
    address spender,
    uint256 value,
    uint256 deadline,
    uint8 v,
    bytes32 r,
    bytes32 s
) public virtual {
    if (deadline < block.timestamp) revert PermitExpired();

    // Unchecked because the only math done is incrementing
    // the owner's nonce which cannot realistically overflow.
    unchecked {
        address recoveredAddress = ecrecover(
            keccak256(
                abi.encodePacked(
                    "\x19\x01",
                    DOMAIN_SEPARATOR(),
                    keccak256(
                        abi.encode(
                            keccak256(
                                "TransientPermit(address owner,address spender,uint256 value,uint256 nonce,uint256 deadline)"
                            ),
                            owner,
                            spender,
                            value,
                            nonces[owner]++,
                            deadline
                        )
                    )
                )
            ),
            v,
            r,
            s
        );

        if (recoveredAddress == address(0) || recoveredAddress != owner) revert InvalidPermit();

        bytes32 location = _transientApprovalLocation(recoveredAddress, spender);
        assembly {
            tstore(location, value)
        }
    }

    emit Approval(owner, spender, value);
}
function transientAllowance(address owner, address spender) public virtual returns (uint256 approval) {
    bytes32 location = _transientApprovalLocation(owner, spender);

    assembly {
        approval := tload(location)
    }
}

An interesting thing to note is in transientPermit(), the nonces mapping variable for the owner is incremented, and not a separate transientNonces mapping variable in transient storage. You may be thinking that this shouldn’t be the case, but this would introduce a issue.

Suppose we introduce a separate transientNonces variable into the system. When data is signed for transient use, it is passed as an argument to transientPermit() in the form of v, r and s, and the transientNonces variable would increment for the owner, preventing the signature’s reuse within the same transaction. However, since transient storage is automatically cleared at the transaction’s end, the transientNonces variable effectively resets afterward. Consequently, someone could easily utilise the same signature with another transientPermit() call in a separate transaction.

Transient Transfers

function transientTransferFrom(
    address from,
    address to,
    uint256 amount
) public virtual returns (bool) {
    uint256 allowed = transientAllowance(from, msg.sender);
    bytes32 location = _transientApprovalLocation(from, msg.sender);

    if (allowed != type(uint256).max) {
        if (allowed >= amount) {
            uint newAllowance = allowed - amount;
            assembly {
                tstore(location, newAllowance)
            }
        } else {
            revert InsufficientAllowance();
        }
    }

    balanceOf[from] -= amount;

    // Cannot overflow because the sum of all user
    // balances can't exceed the max uint256 value.
    unchecked {
        balanceOf[to] += amount;
    }

    emit Transfer(from, to, amount);

    return true;
}

The code for TransientERC20 is available here.

Gas cost comparison

Screenshot 2024-03-11 at 1.47.29 pm.png

Using the values in the avg column for each function, there is about a 49% reduction in gas costs for approving ERC20 tokens transiently.

TransientERC721

Transient Approvals

function transientApprove(address spender, uint256 id) public virtual {
    address owner = _ownerOf[id];

    if (msg.sender != owner && !isApprovedForAll[owner][msg.sender] && !isTransientApprovedForAll(owner, msg.sender)) revert NotOwnerNorApproved();

    bytes32 locationApproved = _transientApprovalLocation(id);

    assembly {
        tstore(locationApproved, spender)
    }

    emit Approval(owner, spender, id);
}
function getTransientApproved(uint256 id) public virtual returns (address spender) {
    bytes32 location = _transientApprovalLocation(id);

    assembly {
        spender := tload(location)
    }
    
}
function setTransientApprovalForAll(address operator, bool approved) public virtual {
    bytes32 location = _transientApprovalLocationForAll(msg.sender, operator);

    assembly {
        tstore(location, approved)
    }


    emit ApprovalForAll(msg.sender, operator, approved);
}
function isTransientApprovedForAll(address owner, address operator) public virtual returns (bool approved) {
    bytes32 location = _transientApprovalLocationForAll(owner, operator);

    assembly {
        approved := tload(location)
    }
    
}

An interesting thing here is looking at the conventional approve() function in TransientERC721:

function approve(address spender, uint256 id) public virtual {
    address owner = _ownerOf[id];

    if (msg.sender != owner && !isApprovedForAll[owner][msg.sender]) revert NotOwnerNorApproved();

    getApproved[id] = spender;

    emit Approval(owner, spender, id);
}

A logical assumption is that if a user has the isTransientApprovedForAll privilege, granting approvals for all of another user’s tokens, they should not only be capable of transiently approving a single token but also conventionally approving one. However, this comes back to the important invariant mentioned earlier: Any approvals made transiently should not leak into persistent storage directly or indirectly. Consequently, allowing a user with the isTransientApprovedForAll privilege to execute the approve() function would undermine the purpose of transient approvals. In such a scenario, individuals could exploit their isTransientApprovedForAll privilege to approve token access not bound to a single transaction.

Transient Transfers

function transientTransferFrom(
    address from,
    address to,
    uint256 id
) public virtual {
    if (from != _ownerOf[id]) revert TransferFromIncorrectOwner();

    if (to == address(0)) revert TransferToZeroAddress();

    if (msg.sender != from && !isTransientApprovedForAll(from, msg.sender) && msg.sender != getTransientApproved(id)) revert NotOwnerNorApproved();

    // Underflow of the sender's balance is impossible because we check for
    // ownership above and the recipient's balance can't realistically overflow.
    unchecked {
        _balanceOf[from]--;

        _balanceOf[to]++;
    }

    _ownerOf[id] = to;

    _deleteTransientApprovalLocation(id);

    emit Transfer(from, to, id);
}
function safeTransientTransferFrom(
    address from,
    address to,
    uint256 id
) public virtual {
    transientTransferFrom(from, to, id);

    if (to.code.length != 0 && ERC721TokenReceiver(to).onERC721Received(msg.sender, from, id, "") !=
            ERC721TokenReceiver.onERC721Received.selector) revert TransferToNonERC721ReceiverImplementer();
}
function safeTransientTransferFrom(
    address from,
    address to,
    uint256 id,
    bytes calldata data
) public virtual {
    transientTransferFrom(from, to, id);

    if (to.code.length != 0 && ERC721TokenReceiver(to).onERC721Received(msg.sender, from, id, data) !=
            ERC721TokenReceiver.onERC721Received.selector) revert TransferToNonERC721ReceiverImplementer();
}

The code for TransientERC721 is available here.

Gas cost comparison

Screenshot 2024-03-11 at 1.49.04 pm.png

Using the values in the avg column for each function, there is about a 65% reduction in gas costs for approving ERC721 tokens transiently.

TransientERC1155

Transient Approvals

function setTransientApprovalForAll(address operator, bool approved) public virtual {
    bytes32 location = _transientApprovalLocationForAll(msg.sender, operator);

    assembly {
        tstore(location, approved)
    }


    emit ApprovalForAll(msg.sender, operator, approved);
}
function isTransientApprovedForAll(address owner, address operator) public virtual returns (bool approved) {
    bytes32 location = _transientApprovalLocationForAll(owner, operator);

    assembly {
        approved := tload(location)
    }
    
}

Transient Transfers

function safeTransientTransferFrom(
    address from,
    address to,
    uint256 id,
    uint256 amount,
    bytes calldata data
) public virtual {
    if (msg.sender != from && !isTransientApprovedForAll(from, msg.sender)) revert NotOwnerNorApproved();

    balanceOf[from][id] -= amount;
    balanceOf[to][id] += amount;

    emit TransferSingle(msg.sender, from, to, id, amount);

    if (to.code.length == 0) {
        if (to == address(0)) revert TransferToNonERC1155ReceiverImplementer();
    } else {
        if (ERC1155TokenReceiver(to).onERC1155Received(msg.sender, from, id, amount, data) !=
                ERC1155TokenReceiver.onERC1155Received.selector) revert TransferToNonERC1155ReceiverImplementer();
    }
}
function safeBatchTransientTransferFrom(
    address from,
    address to,
    uint256[] calldata ids,
    uint256[] calldata amounts,
    bytes calldata data
) public virtual {
    if (ids.length != amounts.length) revert ArrayLengthsMismatch();

    if (msg.sender != from && !isTransientApprovedForAll(from, msg.sender)) revert NotOwnerNorApproved();

    // Storing these outside the loop saves ~15 gas per iteration.
    uint256 id;
    uint256 amount;

    for (uint256 i = 0; i < ids.length; ) {
        id = ids[i];
        amount = amounts[i];

        balanceOf[from][id] -= amount;
        balanceOf[to][id] += amount;

        // An array can't have a total length
        // larger than the max uint256 value.
        unchecked {
            ++i;
        }
    }

    emit TransferBatch(msg.sender, from, to, ids, amounts);

    if (to.code.length == 0) {
        if (to == address(0)) revert TransferToNonERC1155ReceiverImplementer();
    } else {
        if (ERC1155TokenReceiver(to).onERC1155BatchReceived(msg.sender, from, ids, amounts, data) !=
                ERC1155TokenReceiver.onERC1155Received.selector) revert TransferToNonERC1155ReceiverImplementer();
    }
}

The code for TransientERC1155 is available here.

Gas cost comparison

Screenshot 2024-03-11 at 1.50.55 pm.png

Using the values in the avg column for each function, there is about a 59% reduction in gas costs for approving ERC1155 tokens transiently.

User Experience

ERC20 Tokens

To be most effective, transient approvals must occur within the same transaction in which the approval is made and utilised. In the case of ERC20 tokens, approvals are typically granted in a separate transaction before being utilised in another transaction. However, this process can be simplified through a permit mechanism.

In TransientERC20, the transientPermit() function can be invoked within an external function, such as in a staking contract, which then proceeds with the subsequent action, such as staking, within the same function call. This approach simplifies the user experience, as users only need to execute a single function call to complete the operation. Here’s an example:

function transientPermitAndStake(
    uint256 _amount,
    uint256 _deadline,
    uint8 _v,
    bytes32 _r,
    bytes32 _s
) external {
    STAKE_TOKEN.transientPermit(msg.sender, address(this), _amount, _deadline, _v, _r, _s);
    _stake(msg.sender, _amount);
}

ERC721 and ERC1155 Tokens

For NFTs, we typically see NFT marketplaces for example, allow sellers to approve their NFTs beforehand. When a buyer matches the order, the pre-approval from a previous transaction allows seamless transaction execution. This is why it is important to have backwards compatibility with standard token implementations. Nonetheless, transient functionality remains valuable for various NFT use cases.

Conclusion

In summary, transient storage will inevitability unlock new use cases on Ethereum but also introduce new attack vectors and vulnerabilities. One of the new use cases is utilising transient storage for token approvals. The benefit of this is that token users can have the extra reassurance that any approvals they make will not be accessible after a transaction is executed. Furthermore, transient approvals bring on an average of a 58% reduction in gas costs compared to using conventional token approvals. The Dencun upgrade and EIP-1153 is a significant milestone in Ethereum’s development and I’m excited to see what builders come up with!

Resources

Demystifying EIP-1153: Transient Storage

EIP-1153: Transient storage opcodes

Transient Storage: Ethereum’s Game-Changing Feature | HackerNoon

Transient Storage Opcodes in Solidity 0.8.24 | Solidity Programming Language