EIP-1153: Transient storage opcodes 簡介

  • EthTaipei
  • 发布于 2024-12-05 13:17
  • 阅读 63

EIP-1153 提供了一种新的 transient storage 存储方式,可以优化智能合约的 gas 消耗,尤其在处理 Reentrancy locks 和 ERC20 额度管理等场景中具有显著优势。该文章深入分析了 transient storage 的应用示例、gas 消耗对比以及使用时的注意事项。

簡介

EIP-1153 新增了transient storage,是EVM除了 storagememorycalldata 之外新的儲存空間, 可以透過兩個新的EVM opcode tstoretload 做儲存和讀取,儲存的行為和storage相同,是將 booleanuintbytesarraymapping 等不同的資料型態透過key-value的鍵值方式做存取, 差別在於transient storage在transaction結束後就會被清除, 屬於暫存空間。 也因為transient storage不會真正被寫入disk內增加節點的負擔,所以在儲存和讀取時所需的gas和storage相比大幅降低。

使用情境

舉幾個常見的例子可以使用EIP-1153做優化:

Reentrancy locks

以往如果要設計一個Reentrancy lock,會在function做一個modifier設置一個uint256 storage的lock,可以參考solmate的 ReentrancyGuard.sol 寫法如下:

pragma solidity >=0.8.0;

/// @notice Gas optimized reentrancy protection for smart contracts.
/// @author Solmate (https://github.com/transmissions11/solmate/blob/main/src/utils/ReentrancyGuard.sol)
/// @author Modified from OpenZeppelin (https://github.com/OpenZeppelin/openzeppelin-contracts/blob/master/contracts/security/ReentrancyGuard.sol)
abstract contract ReentrancyGuard {
    uint256 private locked = 1;

    modifier nonReentrant() virtual {
        require(locked == 1, "REENTRANCY");

        locked = 2;

        _;

        locked = 1;
    }
}

可以注意到locked會從1 → 2 → 1,當function在執行後如果callback到其他contract上,而其他contract想要再次執行function就會被lock給鎖住。

至於為什麼要從1開始而不將lock從0開始0 → 1 → 0,主要的原因是為了省gas,因為將uint256從0改寫成其他數值相比非0改寫成其他數值消耗的gas會增加許多。

大約消耗的gas可以看到上面的例子,會做一次 sload 和兩次 sstore ,第一次 sload 會消耗2100gas, 1->2的 sstore 消耗2900gas, 2->1的 sstore 消耗100gas並拿到2800的gas refund(因為是覆寫為原本的數值), 但要注意 EIP-3529 生效後 gas refund 最多只能退整筆交易 gas 消耗的 1/5,所以如果交易總消耗 gas 不多,那就可能無法拿到完成的 2800 gas refund,這部分大約需要消耗2100+2900+100-2800 = 2300gas。

使用transient storage 改寫的寫法可以分成使用assembly以及加上transient修飾符,assembly寫法適用於solidity ^0.8.24,transient修飾符適用於solidity^0.8.28。

transient

pragma solidity ^0.8.28;

abstract contract ReentrancyGuard {
    uint256 private transient locked;
    modifier nonReentrant() virtual {
        require(locked == 0, "REENTRANCY");
        locked = 1;
        _;
        locked = 0;
    }
}

這是在0.8.28版時支援的寫法,只需要在變數前面加上transient修飾符即可宣告成transient storage,需要注意的是transient storage不能加上初始值,在這段code裡即使我們沒有將locked = 0,在transaction結束後locked也會自動被清除,至於仍然選擇清除的原因在後面的注意事項會提到。

assembly

pragma solidity ^0.8.24;

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

這裡我們寫的比較簡單,寫法和上面的差別是我們把lock放在storage slot 0 的位置, tloadtstore 去存取和覆寫storage slot 0位置的值。

補充一下,因為permanent storage和transient storage是分開的儲存空間,所以不會有用到同一個storage slot的問題。

消耗的gas因爲 tloadtstore 都是消耗100gas,所以總共為一次 tload 和兩次 tstore 共300gas。

可以看出上面使用storage的方式和下面使用transient storage大約差了2300–300 = 2000 gas,而Reentrancy locks只是做了少量讀取和覆寫(1次read和2次write),所以可以預期在更複雜的操作需要多次讀取及覆寫storage時即可以省更多的gas。

Single transaction ERC20 approvals

在ERC20新增一個 temporaryApprove function,允許其他帳戶動用使用者固定的ERC20數量並且在交易結束時自動還原回原本的 allowance 金額,此舉可以避免合約在交易結束後仍然可以動用使用者的ERC20而造成不必要的風險,詳情可以參考 ERC-7674, OpenZepplin也已經有 對應實作

On-chain computable CREATE2 addresses:

UniswapV3在建立新合約時會透過factory contract中的storage去讀取constructor內需要帶的變數,這樣做的原因是為了要保持init code相同, 因為 create2 創造合約的方式是根據sender、自定義的salt、deploy contract bytecode三者hash生成,所以在init code和sender(factory contract)保持不變的情況下,合約地址就只會根據salt做變化,這樣讓預測地址的計算變得更簡單, 在UniswapV3裡面就有用到這樣的技巧。

UniswapV3PoolDeployer.sol

// SPDX-License-Identifier: BUSL-1.1
pragma solidity =0.7.6;

import './interfaces/IUniswapV3PoolDeployer.sol';

import './UniswapV3Pool.sol';

contract UniswapV3PoolDeployer is IUniswapV3PoolDeployer {
    struct Parameters {
        address factory;
        address token0;
        address token1;
        uint24 fee;
        int24 tickSpacing;
    }

    /// @inherUniswapV3Poolitdoc IUniswapV3PoolDeployer
    Parameters public override parameters;

    /// @dev Deploys a pool with the given parameters by transiently setting the parameters storage slot and then
    /// clearing it after deploying the pool.
    /// @param factory The contract address of the Uniswap V3 factory
    /// @param token0 The first token of the pool by address sort order
    /// @param token1 The second token of the pool by address sort order
    /// @param fee The fee collected upon every swap in the pool, denominated in hundredths of a bip
    /// @param tickSpacing The spacing between usable ticks
    function deploy(
        address factory,
        address token0,
        address token1,
        uint24 fee,
        int24 tickSpacing
    ) internal returns (address pool) {
        parameters = Parameters({factory: factory, token0: token0, token1: token1, fee: fee, tickSpacing: tickSpacing});
        pool = address(new UniswapV3Pool{salt: keccak256(abi.encode(token0, token1, fee))}());
        delete parameters;
    }
}

UniswapV3Pool.sol

constructor() {
    int24 _tickSpacing;
    (factory, token0, token1, fee, _tickSpacing) = IUniswapV3PoolDeployer(msg.sender).parameters();
    tickSpacing = _tickSpacing;

    maxLiquidityPerTick = Tick.tickSpacingToMaxLiquidityPerTick(_tickSpacing);
}

PoolAddress.sol

function computeAddress(address factory, PoolKey memory key) internal pure returns (address pool) {
    require(key.token0 < key.token1);
    pool = address(
        uint256(
            keccak256(
                abi.encodePacked(
                    hex'ff',
                    factory,
                    keccak256(abi.encode(key.token0, key.token1, key.fee)),
                    POOL_INIT_CODE_HASH
                )
            )
        )
    );
}

可以看到UniswapV3作法如下

  1. UniswapV3PoolDeployer在deploy pool之前會把pool的constructor variables存在Parameters裡。
  2. 在deploy時pool會去讀取poolDeployer的parameters當作constructor。
  3. deploy pool結束後UniswapV3PoolDeployer會把Parameters給刪除。

由上面UniswapV3的做法也可以看出Parameters只有在創建新合約時會使用到,使用完後就會被刪除,在此處也可以將其用transient storage改寫。

至於預測address時因為init code是固定的所以只要將init code hash預先儲存在合約裡就可以透過factory address和定義好的salt算出pool address。

UniswapV4 Flash Accounting

UniswapV4裡做操作(Ex. swap, add liquidity)採用類似記帳本的方式,以swap而言,當User call swap 之後pool內的token並不會真正產生改變,而是會先計算出池子內tokenA,tokenB的變化量,並且記錄在不同的storage variable中,接著使用者可以透過call take 來提領他應該拿到的token,call settle 來放入他應該要給的token數量,此兩個舉動會根據之前記錄的token變化量來算出使用者可以拿取或需給予多少token,並在結束後將storage variable做對應的變化,最後只需要驗證storage variable有歸零即可知道swap已經完成,而此種storage variable也是使用transient storage的方式實作,有興趣的人可以去看 UniswapV4的實作

Gas消耗比較

Storage

sstoresload 的gas消耗量的規則比較複雜,大致上分成無論讀寫第一次access時會消耗2100 gas,之後 sload 會另外消耗100gas, sstore 第一次覆寫如果是非0值覆寫會另外消耗2900 gas,如果是0值第一次覆寫會消耗20000gas,之後的覆寫都是消耗100gas。

還有另外的規則像是如果將變數寫回原本的值或0時會得到不同的gas refund,而gas refund也有限制最多只能退回整體transaction的20% gas,有興趣了解完整的storage gas消耗可以參考 EIP-2200, EIP-2929, EIP-2930, EIP-3529

Transient storage

tloadtstore 都只需要消耗100gas。

注意事項

EIP-1153裡面也有提到需要注意transient storage的生命週期,因為transient storage會在transaction結束時自動被清除,有些開發者可能就不會特別在程式裡將其清除以節省gas,這樣有可能會發生不預期的行為。

以Reentrancy lock為例,如果沒有在function最後將lock歸0的話,如果是透過multicall合約做重複call function的動作,之後的function也會因為transaction尚未結束且lock仍然鎖著而failed,而這並不是預期的行為。

結論

transient storage提供了一種新的儲存方式,讓我們在一些使用情境時可以節省大量gas,但新的儲存方式也有可能造成新的安全風險,開發者在使用時務必要了解清楚以及僅慎使用。

補充資料

Solidity 0.8.28 Release Announcement | Solidity Programming Language \ \ Posted by Solidity Team on October 9, 2024\ \ soliditylang.org

Contracts - Solidity 0.8.28 documentation \ \ Edit description\ \ docs.soliditylang.org

Transient Storage Opcodes in Solidity 0.8.24 | Solidity Programming Language \ \ Posted by Solidity Team on January 26, 2024\ \ soliditylang.org

点赞 0
收藏 0
分享
本文参与登链社区写作激励计划 ,好文好收益,欢迎正在阅读的你也加入。

0 条评论

请先 登录 后评论
EthTaipei
EthTaipei
Taipei Ethereum Meetup