EIP-1153 提供了一种新的 transient storage 存储方式,可以优化智能合约的 gas 消耗,尤其在处理 Reentrancy locks 和 ERC20 额度管理等场景中具有显著优势。该文章深入分析了 transient storage 的应用示例、gas 消耗对比以及使用时的注意事项。
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 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。
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也會自動被清除,至於仍然選擇清除的原因在後面的注意事項會提到。
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。
在ERC20新增一個 temporaryApprove
function,允許其他帳戶動用使用者固定的ERC20數量並且在交易結束時自動還原回原本的 allowance 金額,此舉可以避免合約在交易結束後仍然可以動用使用者的ERC20而造成不必要的風險,詳情可以參考 ERC-7674, OpenZepplin也已經有 對應實作。
CREATE2
addresses:UniswapV3在建立新合約時會透過factory contract中的storage去讀取constructor內需要帶的變數,這樣做的原因是為了要保持init code相同, 因為 create2
創造合約的方式是根據sender、自定義的salt、deploy contract bytecode三者hash生成,所以在init code和sender(factory contract)保持不變的情況下,合約地址就只會根據salt做變化,這樣讓預測地址的計算變得更簡單, 在UniswapV3裡面就有用到這樣的技巧。
// 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;
}
}
constructor() {
int24 _tickSpacing;
(factory, token0, token1, fee, _tickSpacing) = IUniswapV3PoolDeployer(msg.sender).parameters();
tickSpacing = _tickSpacing;
maxLiquidityPerTick = Tick.tickSpacingToMaxLiquidityPerTick(_tickSpacing);
}
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作法如下
由上面UniswapV3的做法也可以看出Parameters只有在創建新合約時會使用到,使用完後就會被刪除,在此處也可以將其用transient storage改寫。
至於預測address時因為init code是固定的所以只要將init code hash預先儲存在合約裡就可以透過factory address和定義好的salt算出pool address。
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的實作。
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。
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,但新的儲存方式也有可能造成新的安全風險,開發者在使用時務必要了解清楚以及僅慎使用。
Contracts - Solidity 0.8.28 documentation \ \ Edit description\ \ docs.soliditylang.org
- 本文转载自: medium.com/taipei-ethere...
如果觉得我的文章对您有用,请随意打赏。你的支持将鼓励我继续创作!