EIP-1153: Transient storage opcodes 簡介
2024-12-04 09:31
Taipei Ethereum Meetup
2024-12-04 09:31
订阅此专栏
收藏此文章

簡介

EIP-1153新增了 transient storage,是 EVM 除了 storage、memory、calldata 之外新的儲存空間, 可以透過兩個新的 EVM opcode tstore、tload 做儲存和讀取,儲存的行為和 storage 相同,是將 boolean、uint、bytes、array、mapping 等不同的資料型態透過 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 的位置,tload 和 tstore 去存取和覆寫 storage slot 0 位置的值。

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

消耗的 gas 因爲 tload 和 tstore 都是消耗 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

sstore 和 sload 的 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

tload 和 tstore 都只需要消耗 100gas。

注意事項

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

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

結論

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

補充資料


EIP-1153: Transient storage opcodes 簡介 was originally published in Taipei Ethereum Meetup on Medium, where people are continuing the conversation by highlighting and responding to this story.

【免责声明】市场有风险,投资需谨慎。本文不构成投资建议,用户应考虑本文中的任何意见、观点或结论是否符合其特定状况。据此投资,责任自负。

Taipei Ethereum Meetup
数据请求中
查看更多

推荐专栏

数据请求中
在 App 打开