精通 Uniswap V4 中的瞬时存储

  • hacken
  • 发布于 2025-06-27 13:53
  • 阅读 14

本文深入探讨了 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 消耗的逐步细分。

  1. 调用 unlock()

操作:写入存储(SSTORE) unlock flag = true

写入冷存储槽:20,000 gas

在修改存储槽之前先读取它(SLOAD):2,100 gas

  1. 调用 swap() onlyWhenUnlocked

操作:从存储读取(SLOAD) unlock flag

读取现有存储槽:2,100 gas

  1. 执行结束

操作:重置 Unlock Flag (SSTORE)

修改已写入的存储槽:5,000 gas

退款:4,800 gas(因为该值已重置为其原始状态)

操作 unlock flag 的总成本 = 24,400 gas

跨合约交换调用中的存储使用

在这种情况下,我们不需要交易完成后的数据,因此支付持久存储的高 gas 成本是不必要的。

从上面的讨论中,有以下要求:

  • 数据必须在单个交易中的多个合约调用之间保持持久性。
  • 数据不应在交易结束后继续存在,以避免不必要的存储成本。

这正是暂态存储(EIP-1153)所提供的。暂态存储的行为类似于持久存储(在调用之间保持持久性),但在交易结束时会自动擦除(就像内存存储一样)。

EIP-1153:解决方案

EIP-1153 于 2018 年首次提出,并在 Cancun 升级(2024 年 3 月 13 日)中实施。Solidity 在 0.8.24 版本中增加了对 EIP-1153 的支持。EIP-1153 引入了两个新的操作码:

  • TSTORE (Transient Store) - 将数据写入暂态存储
  • TLOAD (Transient Load) - 从暂态存储读取数据。

暂态存储的所有交互都是通过哈希表(键值对)完成的,其中 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 消耗的逐步细分。

  1. 调用 unlock()

操作:写入存储(TSTORE) unlock flag = true

写入冷存储槽:100 gas

  1. 调用 swap() onlyWhenUnlocked

操作:从暂态存储读取(TLOAD) unlock flag

读取现有的暂态存储槽:100 gas

  1. 执行结束

操作:重置 Unlock Flag (TSTORE)

修改暂态存储槽:100 gas

操作 unlock flag 的总成本 = 300 gas

跨合约交换调用中的暂态存储使用

Uniswap V4 中的闪电会计和暂态存储

在 Uniswap V2 和 V3 中,每个交换操作都涉及池之间的立即代币转账。这种设计存在一些低效率:

  • 多跳交换的高 Gas 成本:多跳交换中的每个中间步骤都需要外部调用来转移代币。
  • 偿付能力要求:由于每个池都是一个单独的合约,因此需要在每个步骤之后更新代币余额以确保偿付能力。

Uniswap V2 和 V3 中的多跳交换

在给定的图中,用户希望通过多跳交换接收 TokenD,这意味着每个交换步骤都是单独发生的,从而导致四个强制性的代币转账。

在 Uniswap V4 中,由于单例设计,不再需要多跳交换。协议允许传递包含货币 token1 和 token2 两者的池密钥 (Pool Key),而不是在每个步骤转移代币,从而可以更有效地处理整个交换操作:

Uniswap V2 和 V3 中的多跳交换

这种简化的流程将所需的转移次数从四次(在 V2/V3 多跳交换中)减少到仅两次,从而降低了 gas 成本并提高了效率。

暂态存储在 Uniswap V4 闪电会计中的作用

在 Uniswap V4 中,与池管理器 (Pool Manager) 的交互从 unlock 函数开始。在此函数结束时,协议会验证非零增量 (delta) 的数量,这些增量表示单个交易中用户操作导致的池余额的净变化。

在之前的 Uniswap 版本中,每个交换或流动性修改都是一个单独的交易。

例子:

  • 交换 100 TokenA → 70 TokenB 需要 2 次代币转账。
  • 添加 50 TokenB 作为流动性需要 1 次额外的转移。

这总共导致 3 次单独的转移。

先前 Uniswap 版本中的交换和流动性修改

在 Uniswap V4 中,unlock 启用了一种批处理执行模型,其中单个交易可以包括多个操作,而无需中间转移。

当执行 unlock 时,它会在用户的合约中触发一个 unlock 回调。此回调允许用户在完成交易之前执行多个交换和流动性修改。

例子:

unlockCallback 中,用户执行:

  • 交换:100 TokenA → 70 TokenB。
  • 添加流动性:50 TokenB。

这会导致以下增量变化:

交换后:

  • TokenA 增量:-100
  • TokenB 增量:+70

添加流动性后:

  • TokenA 增量:-100
  • TokenB 增量:+70 - 50 = +20

这些增量存储在暂态存储中,使存储更新更便宜,因为它们不需要立即进行链上状态更改。

  • 跟踪非零增量
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)
        }
    }
}

来源:NonzeroDeltaCount.sol

此库跟踪当前有多少余额变化的运算(增量)为非零。非零增量跟踪仅在交易中相关。无需持久存储此计数,因为它会在执行后重置。

  • 管理货币增量
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)
        }
    }
}

来源:CurrencyDelta.sol

此库存储临时的净余额变化(增量),而不是代币转账。这确保了正确的会计处理,而无需在交易中间修改 ERC-20 余额。

最终结算和代币转账

在回调内部,调用 take 和 settle 函数来完成交易:

  • Settle 将 100 TokenA 从用户转移到池管理器。
  • Take 将 20 TokenB 从池管理器转移到用户。

因此,在 Uniswap V4 中,只发生 2 次代币转账,而不是像在 Uniswap V2/V3 中那样发生 3 次单独的转移。

Uniswap v4 中的交换和流动性修改

使用暂态存储的 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 助手,为大家转译优秀英文文章,如有翻译不通的地方,还请包涵~
点赞 0
收藏 0
分享
本文参与登链社区写作激励计划 ,好文好收益,欢迎正在阅读的你也加入。

0 条评论

请先 登录 后评论
hacken
hacken
江湖只有他的大名,没有他的介绍。