SharkTeam:UniswapV4 Hook最佳安全实践
近期Uniswap Lab官宣了下一代 AMM Uniswap V4 的开发进展,并公开了白皮书和代码仓库。这次 V4 的白皮书仅仅只有 3 页,原因是 V4并没有对 AMM 的核心算法逻辑做太多修改,而是在 V3 的基础上,增加了一些新的特性,以满足更多的场景需求。SharkTeam将基于目前已开源的代码,来看看 V4 带来了哪些新的特性,并针对V4推出的重要特性Hook分析最佳应用实践。
一、V4与V3的差别
1.1 AMM在 AMM 算法层面,Uniswap V4 并没有对 V3 做修改,依然使用基于恒定乘积 x*y=k 的流动性算法。
在 Uniswap V3,每一个交易对可以有 4 个池子(原本是 3 个,后来增加了一个新的 1bp pool),分别代表 0.01%, 0.05%, 0.3%, 1% 费率的池子,这些池子对应的 tick space 也各不相同,在创建池子的时候,只能选择这 4 种中的任一个。
在 Uniswap V4,每一个交易对理论上可以有任意数量的 pool,并且每一个 pool 的 fee rate 也可以是任意值,这些 pool 的 tick space 也可以是任意值。
这同时也带来了一个问题:Uniswap V4 中交易对的流动性将被碎片化,因此需要一个更有效的 router/aggregator 来帮助用户找到最优的交易路径。
1.2 Hooks
Hooks 是一组由第三方或者 Uniswap 官方开发的合约,在创建 pool 的时候,pool 可以选择绑定一个 hook. 之后在交易的特定阶段,pool 都会自动调用与之绑定的 Hook 合约。Uniswap V4 一共定义了这些可以执行 hook 合约代码的阶段:
beforeInitialize
afterInitialize
beforeModifyPosition
afterModifyPosition
beforeSwap
afterSwap
beforeDonate
afterDonate
分别表示在初始化 pool,添加/移除流动性,交易,捐赠等操作的前后,都可以调用 hook 合约。
Hook 合约需要显式指定在上述的哪些阶段进行执行,而 pool 则需要知道对应的 Hook 在某个阶段是否需要执行,为了节省 gas,这些 flag 都没有在合约中进行存储,而是需要 Hook 使用特定的地址来标明。具体判断的代码如下:
可以看出,Hook 地址的前 8bit 都别用来标记在特定阶段此 Hook 是否需要执行的 flag.
因此,Hook 的开发者需要在部署合约的时候,产生出满足 Pool 要求的地址,这通常需要使用 Create2 + 计算随机Salt 来实现。
以下是白皮书中关于Hook执行的一个例子:
可以看到在执行 swap 的前后,pool 会先检查 pool 对应的 Hook 是否开启了相应的 flag,如果开启了,就会自动调用 Hook 合约的相应函数。
1.3 动态 fee ratio
除了可以在特定阶段执行代码之外,Hook 还可以决定某一个 pool 的 swap fee 费率,和 withdraw 费率。withdraw 费率指的是用户在移除流动性时需要向 Hook 支付的费率。除此之外,Hook 还可以指定在 swap fee 中抽成一部分给自己。
在创建 pool 时,需要使用 fee 参数(uint24)前 4个 bit 来标记此 pool 是否使用动态 fee,以及是否启动 hook swap fee 和 withdraw fee:
如果启动了动态 fee,那么 pool 会在每次 swap 之前,调用 Hook 合约来获取当前的 swap fee ratio. Hook 合约需要实现 getFee() 函数,返回当前的 swap fee ratio.
Hooks 让 Uniswap V4 成为了一个开发者平台,给了 AMM 更多的可能性。一些可以用 Hooks 来实现的功能包括TWAMM(时间加权自动做市商)、Limit Order(限价订单)、LP复投等将在后续的章节中详细介绍。
1.4 Singleton 合约
Uniswap V3 中每次创建新的 pool 都需要部署一个新的合约,这会消耗大量 gas,但是其实这些 pool 使用的代码是相同的,只是初始化参数不相同而已。Uniswap V4 引入了 Singleton 合约,用来管理所有的 pool,这样创建新的 pool 不再需要部署新的合约了,节省了部署合约的 gas.
另外,使用 Singleton 合约的好处是,可以减少交易过程中 token 的转账,因为所有的 pool 都在同一个合约中,所以可以直接在合约内部完成跨 pool 的 swap,而在 V3 中,跨 pool 的 swap 会需要将 token 在不同 pool 中转来转去,这会增加 gas.
同时,在 V4 中,所有 pool 使用同一个合约,并且合约内部的 token 记账也被简化为每种 token 按 token 来记账,而不是按 pool 来记账,这样一来如果想使用闪电贷借大量 token 也会更方便。
1.5 extload
为了方便 Hook 和其他合约的 integration,V4 合约增加了 extload 函数,这样合约所有内部 states 都变成外部可读了,所有 pool 的状态将对外部完全透明。
1.6 Flash Accounting
为了减少跨 pool swap 的 token 转账,V4 同时使用被称为 Flash Accounting 的方法,将 swap, add/remove liquidity/flash loan 的过程都标准化成一种类似闪电贷的过程:
(1)用户获取一个 lock
(2)用户进行任何操作,例如在多个 pool 中 swap,add/remove liquidity,或者通过闪电贷向 pool 借 token
(3)用户所有操作所产生的 token 转账都会被记录在 lock 中
(4)所有操作结束后,用户可以取走他获得的 token,同时需要支付 lock 中记录他需要支付的 token.
这些过程需要发生在一个交易中。
这样一来,如果一个交易中需要跨多个 pool 进行 swap,在结算时只需要两笔转账就够了。例如,在一次 ETH->USDC-BTC 这样的 swap 中,USDC 作为中间 token 完全不需要任何转账。
1.7 ERC1155 mint/burn
Flash Accounting 可以减少同一笔交易中 swap 的 token 转账,通过使用 ERC1155 token ,可以进一步减少多个交易的 token 转账。
V4 允许通过 ERC1155 mint,将属于你的 token 保存在 V4 合约中,这样你就可以在多个交易中使用这些 token,而不需要每次都将 token 转账到 V4 合约中。
使用 ERC1155 burn 可以将保存在 V4 合约中的 token 取出。
ERC1155 适合频繁 swap 或 add/remove liquidity 的用户,这些用户可以将常用的 token 直接保存在 V4 合约中,这样可以减少 token 转账的 gas 开销。
二、Hooks的最佳实践举例
2.1 TWAMM(时间加权自动做市商)
Alice想在区块链上购买价值1亿美元的以太币。在现有的自动做市商(AMM)平台(例如Uniswap)上执行这么大规模的订单将非常昂贵,因为这些平台可能会向Alice收取高昂的费用,以防止她利用内幕消息获取更好的价格。
为了获得更好的价格,Alice的最佳选择是手动将订单拆分成几个较小的子订单,并在几个小时内逐步执行它们。这样做的目的是让市场有足够的时间来意识到她没有内幕消息,从而给予她更好的价格。但是,即使她发送几个较大的子订单,每个子订单仍然会对价格产生重大影响,同时还容易受到敌对交易者的“三明治攻击”。
TWAMM通过代表Alice进行交易来解决这个问题。它将她的订单分解为无限多个微小的虚拟订单,以确保在时间上平滑地执行。同时,TWAMM利用嵌入式AMM协议的特殊数学关系,能够在这些虚拟订单中分摊Gas成本。由于TWAMM在区块之间处理交易,因此也不容易受到“三明治攻击”。
总的来说,TWAMM为Alice提供了一种更高效的方式来进行大规模交易,避免了高昂的手续费和潜在的市场操纵。
2.1.1 原理
TWAMM 有一个内置的 AMM,这个 AMM 和其他的 AMM 并没有什么不同,用户可以通过这个 AMM 直接进行现货交易,也可以向其中添加流动性。但是 TWAMM 同时还有两个 TWAP order pool,分别用来执行两个方向的 TWAP order,用户提交 order 时,指定交易的 token input 数量和时长,TWAMM 会将相同交易方向的 order 放入对应的 pool 中,并按照指定的交易速度自动进行交易。当用户的 order 被完全执行后,用户就可以拿出交易得到的 token。当然,在用户的 order 在被执行完成之前,用户也可以提前取消 order 或者修改 order 需要交易的 token 数量。
在以太坊中,智能合约只能由 EOA 地址主动发起交易触发执行,而不能自动执行。因此 TWAMM 需要由 EOA 账户定期发送交易来结算其 order pool 中待交易的 token,这样就需要一个 keeper 账号来执行这些交易。
当然,也可以让 TWAMM 在每次有用户与其交互时,自动结算 order pool,这样就省去了 keeper 的开销,这也是 DeFi 协议处理流式数据常用的方式。
2.1.2 为什么说这种交易模式很难被三明治攻击?
这种攻击很难实施,由于一个区块内 timestamp 不会改变,攻击者必须要在一个区块的最后一个交易中,将 pool 的价格拉高,这样下一个区块中的 TWAMM 结算才会受到影响。这就要求三明治攻击发生在多个区块中,这无疑会给攻击者带来很大的风险,因为其他套利者有可能在中间介入,导致攻击者遭受损失。
同时,因为套利者的存在,这样的价格操纵注定无法持久,由于 TWAP order 的特性它并不会在短时间内交易太多的 token,因此大部分情况损失也一定是有限的。
2.1.3 V4中的TWAMM工作流程
(1)此 Hook 维护两个 TWAP order pool,分别表示两个交易方向的 TWAP order
(2)用户可以通过此 Hook 提交 TWAP order,需要指定交易的 token,数量以及时间长度
(3)此 Hook 注册 beforeSwap 和 beforeModifyPosition,每次用户交易或者调整仓位时,都会触发此 Hook
(4)被触发后,Hook 负责对 2个 TWAP order pool 进行结算
(5)用户也可以在任意时刻手动触发结算
(6)用户可以取消或者修改 TWAP order 中的 token 数量
2.1.4 实例详解
TWAMM注册三个阶段来进行hooks的逻辑调用,在pool初始化之前对TWAMM进行初始化,并在每次用户交易或者调整仓位时触发此hook。
用户可手动调用TWAMM中的submitOrder函数来提交自己需要执行的order到合约中。
在用户将自己需要执行的订单添加进合约后,在pool每次进行swap和modifyPosition操作时,都会自动进行order的执行。
每有用户调用v4的swap函数进行交易或modifyPosition函数更改仓位,都会触发TWAMM中的执行函数,函数中会调用内部函数_executeTWAMMOrders继续进行此前未完成order的执行。
_executeTWAMMOrders函数
以上为更新订单的执行流程,在执行结束之后,会更新当前twamm的订单执行时间。
2.2 Limit Order
与立即按照最后的市场价格执行的市价单不同,限价单在达到预定价格后立即执行。基于自动做市商(AMM)的 DEX 大多默认选择市价单系统。对新人来说简单易懂。市价单要么被执行,要么因参数(如最大价格影响)而失败。而在限价单中,只有当资产价格达到限价时,订单才会成交,否则订单将保持未平仓状态。
例如,假设 ETH 目前在 ETH/DAI 池中的交易价格为 1 ETH = 1500 DAI。用户可以下一个止盈订单,主要内容是 "如果 1 ETH = 2000 DAI,卖出我所有的 ETH"。如果达到这个价格,用户的 ETH 将自动以去中心化的方式完全在链上交换为 DAI。
在 Uniswap 以前的版本中,限价订单实际上是不可能的。大多数 AMM 只允许市场买入和卖出。而在V4版本中,由于hook的强大特性和可扩展性,让限价单在v4中存在了实现的基础。
2.2.1 原理
limit order的设计原理相比于twamm来说,更加简单一些,目前实现了有关添加流动性的限价单,交易的限价单实现起来也会比较容易。
由于v4中存在tickLower和tickUpper,并根据池子的交易情况变化,lower和upper会发生改变,用户在进行添加流动性时并不想在当前价格进行添加,这时就可以利用limit Order hook来执行此需求,在hook中设定对应的价格,在每次swap结束后,hook会对当前pool的价格做判断,若达到了设定的价格,则将对应的流动性进行添加获取收益。
2.2.2 V4 中的Limit Order工作流程
1. limit维护着多个epoch,作为不同lower和交易方向的限价单。
2. 用户通过这个hook,提交自己的价格lower和交易方向,将限价单加入合约。
3. 此 Hook 注册 afterSwap,只在每次swap结束后价格发生变动时触发
4. 被触发后,Hook 校验当前价格区间,并从epoch中查验当前价格是否存在需要进行添加流动性的限价单。
5. 用户可以在任何时刻提款或直接添加流动性
2.2.3 实例详解
合约注册两个阶段触发hook,在Pool初始化后对hook初始化,并在每次兑换结束后触发hook逻辑。
用户调用place函数传入想要添加流动性的数量,价格以及交易方向,hook会先将用户想要添加的流动性加入pool中保存,随后为用户创建对应的限价单。
在每次swap结束后都会触发此hook,根据当前价格区间将区间价格内存在的限价单完成添加流动性的操作。
用户可在限价单完成之前调用kill函数取消限价单并获得此次添加流动性获得的收益。
用户可在想要移除流动性时调用withdraw函数,即可提取想要的流动性。
总的来说,此limit order hook为用户提供了一个更便捷的途径,用户可设定自己想要添加流动性时的价格,在池子中价格区间到达该价格后,hook自动为用户到pool中执行添加流动性的操作,且用户可以随时取消和提取流动性。
除TWAMM和Limit Order外,基于Hook还可以实现LP复投、动态手续费变化等功能,因为篇幅原因,我们将在后续的分析中展开介绍:
LP复投:用户可利用hook来进行流动性的添加,修改和移除,hook可注册afterSwap和afterModifyPosition来进行调用。由于用户统一使用hook来进行流动性的添加,所以在poolManager中添加流动性的地址只有hook,hook可在每次触发时查验当前时间,每经过一定时间段后,选择提取流动性奖励并将获得的lp代币再次到pool中添加流动性,从而自动化优化用户收益。
动态手续费变化:hook可注册beforeSwap等接口,来进行交换之前的动态手续费修改。动态手续费可以不是简单地根据时间线性变化,可以根据单笔 swap 产生的 tick 跳跃数量来量化波动率,从而动态改变手续费,实现对于 LP 无常风险的对冲。从而减少因为交易产生的无常损失对流动性提供者造成的影响。
三、Hooks 安全最佳实践
减少require和revert的使用
在有关pool对hooks调用的函数逻辑中,尽量减少回退语句的使用,由于pool合约与hooks合约存在共通关系,在hooks中发生交易回滚后,会导致pool中的交易同样回退,如果在hooks中出现与pool中正常交易不相关的回退语句,可能会导致用户无法正常使用pool中的功能。
避免使用自毁函数
避免在hook中使用selfdestruct函数,如果hook中调用到了自毁函数,不仅会导致hook中的逻辑出现问题,并且会导致pool中的功能也无法正常进行,使整个pool池中的资产丢失并且功能无法正常进行。
严格进行权限控制
严格控制hook合约中的权限,避免出现权限过大的角色,并对特权角色进行多签管理,防止出现单点攻击。避免存在特权角色可任意修改合约状态变量的情况,可能会出现逻辑错误导致整个交易发生回滚,影响pool的正常使用。验证最小权限原则,我们应当使用openzeppelin的AccessControl合约来实现控制访问更细粒度的权限,因为该实践限制每个系统组件遵循最小权限原则。
做好防重入限制
hooks作为Pool的外部扩展代码,同样应该注意合约中可能出现的重入攻击,例如限价单中在进行原生代币转账时可能出现的重入,导致合约资产出现损失。在调用外部合约或所谓的“检查-生效-交互”模式之前检查并尝试更新所有状态。这样,即使重入,也不会产生任何影响,因为所有状态都已完成更新。.
合约升级控制
有些开发者可能会使用代理合约以便后续对hook的逻辑进行变动和升级,但也因此需要注意到合约升级方面可能存在的问题。首先,hook中即使采取代理模式,代理合约中也需要声明对应的阶段,例如beforeSwap等,否则pool无法校验到正确的返回值。其次,在调用 delegatecall 之前检查目标合约是否存在,Solidity 不会替我们执行此检查,忽略检查可能会导致意外行为和安全问题,仔细考虑变量声明的顺序,因为会出现变量打包存储同一个插槽、影响gas成本、内存布局、delegate调用结果等问题。
About Us
SharkTeam的愿景是全面保护Web3世界的安全。团队由来自世界各地的经验丰富的安全专业人士和高级研究人员组成,精通区块链和智能合约的底层理论,提供包括智能合约审计、链上分析、应急响应等服务。已与区块链生态系统各个领域的关键参与者,如Polkadot、Moonbeam、polygon、OKC、Huobi Global、imToken、ChainIDE等建立长期合作关系
微信掃描關注公眾號,及時掌握新動向
2.本文版權歸屬原作所有,僅代表作者本人觀點,不代表比特範的觀點或立場
2.本文版權歸屬原作所有,僅代表作者本人觀點,不代表比特範的觀點或立場