本文深入探讨了 Uniswap V4 中瞬时存储(EIP-1153)的安全影响,瞬时存储通过在交易过程中存储临时数据,在交易结束时自动清除,从而优化 gas 消耗,提高效率。文章分析了瞬时存储在 Uniswap V4 中的应用,如 Flash Accounting,但也强调了错误使用瞬时存储可能导致的安全风险,并给出了正确使用瞬时存储的建议。
本文是探讨 Uniswap V4 中安全考量因素系列文章的一部分。在我们之前的文章审计 Uniswap V4 Hooks:风险、利用和安全实施中,我们分析了 hooks 中潜在的漏洞以及安全集成的最佳实践。在这里,我们重点关注暂态存储(transient storage,EIP-1153)的安全影响及其对 Uniswap V4 的影响。
智能合约通常需要存储在整个交易过程中保持一致,但交易完成后不再需要的临时数据。在 Uniswap V4 中,由于其引入 hooks 的新架构,此要求尤其重要。 Hooks 可以在交换 (swap) 或流动性事件的关键点启用自定义逻辑,但它们也会消耗 gas,因此高效的存储至关重要。
让我们来探讨暂态存储(EIP-1153),它与传统存储和内存有何不同,以及为什么它是gas 效率的关键创新。
通常,Solidity 合约使用内存来存储临时数据,例如用于局部变量。
address spender = _msgSender();
但是,内存仅在单个执行帧(函数调用的上下文)中持续存在。当合约外部调用另一个合约时,会创建一个新的执行帧,这意味着存储在内存中的任何数据都会在跨合约调用之间丢失。
跨合约交换调用中的内存使用
另一种方法是将标志存储在存储中,就像在重入锁中所做的那样:
bool private locked;
但是,在 Uniswap V4 解锁机制中使用持久存储(SSTORE 和 SLOAD 操作码)会产生大量的 gas 成本,因为它涉及更新持久区块链状态。以下是交易每个阶段 gas 消耗的逐步细分。
操作:写入存储(SSTORE) unlock flag = true
写入冷存储槽:20,000 gas
在修改存储槽之前先读取它(SLOAD):2,100 gas
操作:从存储读取(SLOAD) unlock flag
读取现有存储槽:2,100 gas
操作:重置 Unlock Flag (SSTORE)
修改已写入的存储槽:5,000 gas
退款:4,800 gas(因为该值已重置为其原始状态)
操作 unlock flag 的总成本 = 24,400 gas
跨合约交换调用中的存储使用
在这种情况下,我们不需要交易完成后的数据,因此支付持久存储的高 gas 成本是不必要的。
从上面的讨论中,有以下要求:
这正是暂态存储(EIP-1153)所提供的。暂态存储的行为类似于持久存储(在调用之间保持持久性),但在交易结束时会自动擦除(就像内存存储一样)。
EIP-1153 于 2018 年首次提出,并在 Cancun 升级(2024 年 3 月 13 日)中实施。Solidity 在 0.8.24 版本中增加了对 EIP-1153 的支持。EIP-1153 引入了两个新的操作码:
与暂态存储的所有交互都是通过哈希表(键值对)完成的,其中 32 字节的键指向 32 字节的值,类似于 SSTORE/SLOAD。但是,这两种类型的存储器不相交;也就是说,暂态存储不是存储的专用部分,而是具有从 0 到 2256 - 1 的槽的单独存储器。
每个合约都有自己分配的暂态存储;因此,除了允许这样做的显式函数或通过 delegatecall 之外,它不能被其他合约直接访问或修改。
合约的独立暂态存储
在 Uniswap V4 PoolRouter 中,合约使用这些操作码维护一个解锁标志。
library Lock {
// The slot holding the unlocked state, transiently. bytes32(uint256(keccak256("Unlocked")) - 1)
// 用于瞬时地保存未解锁状态的槽。bytes32(uint256(keccak256("Unlocked")) - 1)
bytes32 internal constant IS_UNLOCKED_SLOT = 0xc090fc4683624cfc3884e9d8de5eca132f2d0ec062aff75d43c0465d5ceeab23;
function unlock() internal {
assembly ("memory-safe") {
// unlock
// 解锁
tstore(IS_UNLOCKED_SLOT, true)
}
}
function lock() internal {
assembly ("memory-safe") {
tstore(IS_UNLOCKED_SLOT, false)
}
}
function isUnlocked() internal view returns (bool unlocked) {
assembly ("memory-safe") {
unlocked := tload(IS_UNLOCKED_SLOT)
}
}
}
来源:Lock.sol
每个 TSTORE 和 TLOAD 的 gas 成本为 100。以下是使用暂态存储时,交易每个阶段 gas 消耗的逐步细分。
操作:写入存储(TSTORE) unlock flag = true
写入冷存储槽:100 gas
操作:从暂态存储读取(TLOAD) unlock flag
读取现有的暂态存储槽:100 gas
操作:重置 Unlock Flag (TSTORE)
修改暂态存储槽:100 gas
操作 unlock flag 的总成本 = 300 gas
跨合约交换调用中的暂态存储使用
在 Uniswap V2 和 V3 中,每个交换操作都涉及池之间的立即代币转账。这种设计存在一些低效率:
Uniswap V2 和 V3 中的多跳交换
在给定的图中,用户希望通过多跳交换接收 TokenD,这意味着每个交换步骤都是单独发生的,从而导致四个强制性的代币转账。
在 Uniswap V4 中,由于单例设计,不再需要多跳交换。协议允许传递包含货币 token1 和 token2 两者的池密钥 (Pool Key),而不是在每个步骤转移代币,从而可以更有效地处理整个交换操作:
Uniswap V2 和 V3 中的多跳交换
这种简化的流程将所需的转移次数从四次(在 V2/V3 多跳交换中)减少到仅两次,从而降低了 gas 成本并提高了效率。
在 Uniswap V4 中,与池管理器 (Pool Manager) 的交互从 unlock 函数开始。在此函数结束时,协议会验证非零增量 (delta) 的数量,这些增量表示单个交易中用户操作导致的池余额的净变化。
在之前的 Uniswap 版本中,每个交换或流动性修改都是一个单独的交易。
例子:
这总共导致 3 次单独的转移。
先前 Uniswap 版本中的交换和流动性修改
在 Uniswap V4 中,unlock 启用了一种批处理执行模型,其中单个交易可以包括多个操作,而无需中间转移。
当执行 unlock 时,它会在用户的合约中触发一个 unlock 回调。此回调允许用户在完成交易之前执行多个交换和流动性修改。
例子:
在 unlockCallback 中,用户执行:
这会导致以下增量变化:
交换后:
添加流动性后:
这些增量存储在暂态存储中,使存储更新更便宜,因为它们不需要立即进行链上状态更改。
library NonzeroDeltaCount {
// Slot for storing the number of nonzero deltas
// 用于存储非零增量数量的槽
bytes32 internal constant NONZERO_DELTA_COUNT_SLOT =
0x7d4b3164c6e45b97e7d87b7125a44c5828d005af88f9d751cfd78729c5d99a0b;
function read() internal view returns (uint256 count) {
assembly ("memory-safe") {
count := tload(NONZERO_DELTA_COUNT_SLOT)
}
}
function increment() internal {
assembly ("memory-safe") {
let count := tload(NONZERO_DELTA_COUNT_SLOT)
count := add(count, 1)
tstore(NONZERO_DELTA_COUNT_SLOT, count)
}
}
function decrement() internal {
assembly ("memory-safe") {
let count := tload(NONZERO_DELTA_COUNT_SLOT)
count := sub(count, 1)
tstore(NONZERO_DELTA_COUNT_SLOT, count)
}
}
}
此库跟踪当前有多少余额变化的运算(增量)为非零。非零增量跟踪仅在交易中相关。无需持久存储此计数,因为它会在执行后重置。
library CurrencyDelta {
function _computeSlot(address target, Currency currency) internal pure returns (bytes32 hashSlot) {
assembly ("memory-safe") {
mstore(0, and(target, 0xffffffffffffffffffffffffffffffffffffffff))
mstore(32, and(currency, 0xffffffffffffffffffffffffffffffffffffffff))
hashSlot := keccak256(0, 64)
}
}
function getDelta(Currency currency, address target) internal view returns (int256 delta) {
bytes32 hashSlot = _computeSlot(target, currency);
assembly ("memory-safe") {
delta := tload(hashSlot)
}
}
function applyDelta(Currency currency, address target, int128 delta)
internal
returns (int256 previous, int256 next)
{
bytes32 hashSlot = _computeSlot(target, currency);
assembly ("memory-safe") {
previous := tload(hashSlot)
}
next = previous + delta;
assembly ("memory-safe") {
tstore(hashSlot, next)
}
}
}
此库存储临时的净余额变化(增量),而不是代币转账。这确保了正确的会计处理,而无需在交易中间修改 ERC-20 余额。
在回调内部,调用 take 和 settle 函数来完成交易:
因此,在 Uniswap V4 中,只发生 2 次代币转账,而不是像在 Uniswap V2/V3 中那样发生 3 次单独的转移。
Uniswap v4 中的交换和流动性修改
❌ 风险:暂态存储在交易中保持持久性
在我们审查的所有基于暂态存储的库中,例如 Lock.sol、NonzeroDeltaCount.sol、CurrencyDelta.sol 和 CurrencyReserves.sol,我们观察到一个共同的安全模式:
显式重置暂态存储标志
function lock() internal {
assembly ("memory-safe") {
tstore(IS_UNLOCKED_SLOT, false)
}
}
来源:Lock.sol
乍一看,这一步似乎是多余的,因为暂态存储会在交易结束时自动重置。但是,这种显式更新的关键原因是并非所有合约调用都是孤立交易的一部分。
暂态存储仅在交易结束时清除,而不是在每次合约调用后清除。如果合约调用是更大的批量交易的一部分(例如,通过中继器、智能合约钱包或可组合的 DeFi 协议),则暂态存储状态可能会保持比预期更长的时间。这可能会导致对状态有效性的不正确假设,从而可能导致意外的后果。
❌不正确的使用:暂态存储映射没有清理
在单个交易中需要临时状态管理的情况下,可以使用暂态存储(TSTORE/TLOAD)来代替内存映射。但是,用于暂态存储的映射也需要显式的清理机制。
这意味着还必须跟踪存储在暂态存储映射中的所有键,以便可以在适当的时间重置它们。如果不这样做,可能会导致不正确的行为或在交易执行期间意外地持久保存数据。
contract TransientMappingExample {
function processData(uint256 key, uint256 value) external {
assembly {
tstore(key, value) // Storing value in transient storage mapping
// 将值存储在暂态存储映射中
}
}
function readData(uint256 key) external view returns (uint256) {
assembly {
let value := tload(key) // Retrieving value from transient storage
// 从暂态存储检索值
return(value, 32)
}
}
}
❌ 不正确的使用:将持久数据存储在暂态存储中
使用暂态存储的下一个关键安全注意事项是,它应该仅在单个交易中临时需要数据时使用。如果错误地使用暂态存储来存储用户余额、批准或其他持久数据,则这些值将在交易结束时丢失。
这是一个危险用例的示例,其中用户余额存储在暂态存储 (TSTORE) 中,而不是持久存储 (SSTORE) 中:
mapping(address => uint256) private balances;
function deposit() external payable {
assembly {
tstore(caller(), add(tload(caller()), callvalue())) // Storing balance in transient storage
// 将余额存储在暂态存储中
}
}
function withdraw(uint256 amount) external {
assembly {
let balance := tload(caller()) // Load transient balance
// 加载暂态余额
if lt(balance, amount) {
revert(0, 0)
}
tstore(caller(), sub(balance, amount)) // Update balance
// 更新余额
}
payable(msg.sender).transfer(amount);
}
❌ 不正确的使用:在需要内存时使用暂态存储****
同样,使用暂态存储的一个假设是数据仅存在于单个执行帧中是不正确的。如果合约逻辑涉及应重置内存但反而使用暂态存储的内部调用,则数据可能会意外地跨不同的执行帧保持持久性。
这是一个示例,其中 found 标志错误地存储在暂态存储 (TSTORE) 中,而不是使用内存,导致在多个数组中搜索时出现不正确的行为:
contract SearchExample {
uint256[] private array1 = [1, 2, 3, 4, 5];
uint256[] private array2 = [6, 7, 8, 9, 10];
function findInArrays(uint256 target) external returns (bool, bool) {
bool foundInArray1 = searchArray(array1, target);
bool foundInArray2 = searchArray(array2, target);
return (foundInArray1, foundInArray2);
}
function searchArray(uint256[] storage arr, uint256 target) internal returns (bool) {
assembly {
let found := tload(0) // Load found flag from transient storage (WRONG)
// 从暂态存储加载 found 标志 (错误)
let len := sload(arr.slot)
for { let i := 0 } lt(i, len) { i := add(i, 1) } {
let val := sload(add(arr.slot, i))
if eq(val, target) {
tstore(0, 1) // Store found flag in transient storage
// 将 found 标志存储在暂态存储中
found := 1
break
}
}
return(found, 32)
}
}
}
Uniswap V4 正在改写 DeFi 效率的规则,而暂态存储 (EIP-1153) 是这种转变的核心。通过用临时状态跟踪取代昂贵的存储写入,Uniswap V4 降低了成本并消除了不必要的代币转账,从而使多跳交换和流动性更新更加便宜。
资源:
文档:
代码:
- 原文链接: hacken.io/discover/unisw...
- 登链社区 AI 助手,为大家转译优秀英文文章,如有翻译不通的地方,还请包涵~
如果觉得我的文章对您有用,请随意打赏。你的支持将鼓励我继续创作!