现代DEX - Balancer V3 是如何构建的

  • mixbytes
  • 发布于 2024-10-25 19:12
  • 阅读 42

本文深入分析了Balancer V3协议的设计与实现,介绍了其三层架构、核心功能、流动性管理、交易逻辑及安全机制,包括动态费用计算和防重入攻击设计。文章通过丰富的代码示例与技术细节,为了解去中心化交易所(DEX)提供了全面的视角,适合希望深入了解现代DEX设计的开发者和审计人员。

简介

要完全理解本文及后续有关DEX的文章,你应该熟悉自动化做市商(Automated Market Maker,AMM)概念,包括Uniswap V2/V3的技术设计(在许多地方都有描述),包括Router<->Pool构造和回调机制。我们之前关于Uniswap V3和Uniswap V4的文章可以在这里这里找到。

今天深入分析的对象是Balancer V3,最新的去中心化交易协议之一。

高层设计

核心交易流程在Balancer V3文档中描述这里,可以分为三个主要组成部分:Router、Vault和Pool。

(来自Balancer V3文档的图片)

在Balancer V3中,兑换逻辑分为两个独立的部分:Vault和Pool,其中Vault负责账户管理和持有代币余额,而Pool部分负责不变量和兑换数量的计算。这种设计使得Balancer能够拥有多种类型的池(在这里有描述),并处理不同类型的逻辑,利用其数学模型,而“结算”层仍然保持一致。让我们继续实施。

核心

Vault

Vault中的核心操作是_supplyCredit()_takeDebt()函数。它们跟踪每种代币的协议债务和信用,并参与几乎所有操作。Balancer允许外部路由执行一系列操作,其中每个操作可以承担债务、提供信用,并在后续操作中使用这些信用。每个单独的操作可以收取多余的代币,将额外的代币提供给池,但在所有操作结束后,债务/供应余额的变化必须归零。这就是为什么协议中存在这样的会计。

为了实现这一点,_supplyCredit()和_takeDebt()函数使用了一个非常重要的函数:_accountDelta()。该函数计算给定代币余额的累积变化(正或负),并递增/递减计数器_nonZeroDeltaCount()。该计数器随后用于transient()修饰符,如果至少有一个代币的变化不为零,则会撤回整个操作包。

[注意] 这部分逻辑需要更详细的解释。 DEX用户不仅希望进行简单的代币兑换,还希望执行更复杂的操作,例如在同一交易中兑换代币或向多个池提供流动性。当单独执行这些操作包时,它们可能非常昂贵,而将它们组合成一个大的原子操作可以节省大量的Gas(为每个代币执行单个“最终”转移,而不是多个“中间”转移)。这使Vault能够简单地跟踪每个池的代币变化,并一次性最终化多个操作,以检查协议是否没有非零的信用或债务。

在这些场景中增加的复杂性是需要通过回调和Hook在不同合约中执行操作,这使得不能使用公共内存变量(由于执行框架的切换),而使用存储来跟踪中间余额变化则成本过高。然而,在Ethereum的Dencun升级中引入的EIP-1153引入了一种新的内存类型——瞬态存储。新的TLOAD/TSTORE操作码的成本远低于常规的SLOAD/SSTORE,只在当前交易期间存储数据。这种类型的内存在DEX中尤其有用,因为所有DEX都需要重入保护、处理代币许可并根据复杂的构造参数创建确定性池地址。所有这些情况都非常适合TLOAD/TSTORE。

这些操作在Balancer的瞬态修饰符中用于unlock()来解锁Vault。unlock()函数“包装”了路由中的任何操作(可以在Router.sol中找到,BatchRouter.sol和其他路由),确保无论路由中发生什么,最终的“所有代币的变化都为零”检查都会始终执行。这个函数的操作类似于瞬态重入锁,但它使用瞬态存储来保存每个代币的deltaIsZero标志。

使用unlock()来解锁Vault并将控制权返回给路由的示例可以在swapExactIn()中找到,位于BatchRouter.sol中。“解锁”Vault的另一个重要用途是,外部协议拒绝查询“解锁”Vault的状态,使其无法在重入场景中操纵流动性和价格。如我们所记得的,基于回调的代币转移方案在协议内引入了“自然”的重入矢量。此外,Balancer的自定义池可以使用外部Hook、费率提供商和池实现,这些都可能重入Vault。在多个DeFi攻击中,这种行为被利用,涉及只读重入和预言机价格操纵。

Balancer V3中的下一个重要核心函数是settle()sendTo()函数。它们处理代币在协议内外的转移,计算余额差异,更新协议储备,并执行supplyCredit/takeDebt操作,修改当前操作包中的代币变化。

settle()函数包含一个有趣的参数:amountHint,用于防止“捐赠”攻击,当代币直接发送到Vault时,改变Vault余额以操纵信用金额。通过使用这个条件来缓解,该条件使用amountHint代币作为信用,除此之外不会有其他额外的代币。任何额外的代币将被简单地添加到Vault的余额中。

Vault本身是一个ERC20MultiToken,持有池中流动性提供者代币的余额/授权。所有代币函数都将池的地址作为第一个参数,因此Vault以ERC20代币的形式跟踪用户在每个池中的余额。此外,每个池也都是ERC20代币,如在BalancePoolToken.sol中所述,但该池代币的所有函数均代理回Vault的ERC20MultiToken。这使得池能够拥有完全合规的ERC20代币,而Vault则保持对多个池代币余额管理的完全控制。

Vault包含了大量代码,超过以太坊主网上允许的最大合约大小。因此,Balancer V3由三个主要合约组成:

  • Vault.sol,主要合约,具有核心功能,如兑换或提供流动性。该合约还充当代理,将“非拥有”调用路由到附加扩展:VaultExtension:

  • VaultExtension.sol - 包含额外的、无需权限的功能,使用频率较低(如注册新池、多代币接口、只读查询等)。此合约也是一个代理,将调用转发到下一个合约:VaultAdmin

  • VaultAdmin.sol - 包含有权限的管理功能(暂停合约和池、设置收取费用等)

值得注意的是,使用ensureVaultDelegateCall()函数,用于检查某个扩展函数是否从Vault调用(并且仅通过delegate调用)。

Balancer核心功能的另一个关键方面是其费用系统。在Balancer V3中,费用直接在兑换代币时收取(类似于Uniswap V3/V4)。在Balancer V3中,费用分配的逻辑与Vault分开,Vault只知道全局兑换和收益费用,并简单地收集它们,使兑换和收益操作更便宜。

用于LP提供者兑换费用的收集在这里中完成,在SwapKind.EXACT_IN的情况下增加tokenIn数量,在其他情况下减少tokenOut数量。收取的费用简单地保留在池余额中,转化为LP提供者的收益。

与收益型代币相关的费用部分则更为复杂,如前所述,这些代币的实施总是颇具挑战性。与常规代币相比,这些代币余额的增加是以某种费率为准的,常规代币的费率为“1”,而收益型代币则由rateProvider控制。尽管这些代币的余额持续增加,但每次需要池中实际可用储备的操作都需要根据费率更新这些余额。这是在\_computeAndChargeAggregateSwapFees()函数中进行的,在兑换和添加/移除流动性功能中呈现。

协议与池创建者之间的费用分配在ProtocolFeeController.sol合约中进行,并具有一个公共的、无需权限的名为collectAggregateFees()的函数,附带有回调Hook。该函数同样利用Vault的瞬态“解锁”,在费用操作期间避免对Vault的代币余额进行操纵。

给定池的主要费用分配函数(这里)循环遍历所有池的代币(这里),并设置相应的_protocolFeeAmounts[pool][token]和_poolCreatorFeeAmounts[pool][token]值。

路由

在Balancer中,路由的角色与Uniswap的周边合约相似。路由合约充当通往Balancer V3协议的主要入口,负责接受用户的代币和兑换路径,解锁Vault并执行兑换和流动性管理。

RouterCommon.sol(路由的公共代码部分)中执行代币转移的关键函数为_takeTokenIn()_sendTokenOut()函数,处理代币转移;以及permitBatchAndCall(),执行带有用户授权的一系列操作。

值得注意的是,可能有许多种路由实现的变体,开发者可以创建自己的自定义路由。但是,特定的池和Hook可以拒绝不必要的路由,从而在协议内确保一定的控制和安全性。

Balancer V3的一个主要思想是将池的逻辑与代币操作分离。当“一体化”方法在单一池设计中良好工作时,当你希望允许用户添加自己的池、路由,并通过Vault与它们组合操作时,这种方法是有限的。这种“分层”方法对于灵活性至关重要,同时也意味着V3中的池与V2相比要轻量得多。实际上,V3中的池简单地是对不变量曲线的实现。即使池代币实际上也只是对Vault的ERC20MultiToken的一个接口。

目前有两个主要合约实现了池的逻辑:StablePoolWeightedPool,可用于根据起始参数部署不同类型的池。任何Balancer池都必须实现IBasePool接口,其关键函数为onSwap()。StablePool中的示例在这里,此变体使用了从Curve项目实现的StableSwap不变量,位于StableMath.sol库中。

WeightedPool使用WeightedMath.sol库,允许创建具有“加权”代币分布的池,从而减少权重较大代币的无常损失(同时,由于权重较小代币的流动性较少,可能会导致更高的滑点)。

在Vault中利用StableMath曲线实现的添加/移除流动性逻辑。因此,如果池使用不同的数学模型,就必须实现具有自定义逻辑的IPoolLiquidity接口。这些自定义逻辑将在Vault中被用于这部分添加流动性和这一部分移除流动性。

Hook

Hook接口是Balancer协议的重要组成部分,在V3中具有众多潜在影响。可以分配给新注册池的Hook有多种类型(有关Hook名称的配置可以在这里找到),如果设置了相应的Hook,则会在适当的时机被调用(示例在“兑换之前”)。

Hook使Balancer V3能够在协议中添加几乎任何逻辑。在同一时间,Hook可以重入协议,改变其储备和费率。因此,在每个“以前”Hook之后,必须立即重新计算余额和费率(如这里这里)。

除了初始化Hook和“之前/之后”Hook用于兑换和流动性外,还有一个名为onComputeDynamicSwapFeePercentage的Hook,该Hook可以根据输入参数和池状态动态修改兑换费用。

需要指出的是,由外部开发者创建的Hook可能“中毒”或包含漏洞,特别是在使用可升级合约时。因此,有必要对集成到Balancer中的池和Hook进行全面的安全检查。

ERC4626 缓冲区

池及其不变量无法直接与收益型资产(如Aave的ATokens)进行操作,因为其余额随时间而变化。然而,在进行兑换时,强烈希望使用这些资产,而不依赖于外部借贷协议,从而为交易者节省大量Gas。同时,使Vault能够从这些代币中赚取收益也是有益的。

为此,Vault操作与包装收益型资产(如aDAI、stETH等)相关的代币。这种方法在DeFi协议中尤为有用,因为它简化了与收益型代币的交互,操作的余额保持恒定(非收益型),并在需要时将其重新包装回基础代币。

Balancer V3中的缓冲区是VaultStorage的一部分(描述在这里)。该代币拥有自己的LP余额总供应量,以及Vault自身的LP股份和基础代币的余额(_bufferTokenBalances)。这些额外的余额使得在执行兑换时计算正确的包装/解包金额成为可能。

添加包装资产的操作由initializeBuffer()函数执行,该函数保存基础代币地址,存储包装和基础代币的余额,并发行初始的缓冲股份(包括最小部分给零地址,以避免通货膨胀攻击)。可以通过addLiquidityToBuffer()函数将包装代币添加到池流动性。

简单地说,你可以使用waDAI或aDAI进行兑换,但是在使用aDAI的情况下,Vault将包装aDAI代币,因为只有包装的ERC4626 waDAI代币才能在兑换池中使用。包装功能在Vault中实现这里,最终调用_takeDebt(underlyingToken, ...)和_supplyCredit(wrappedToken, ...)。反之,解包功能也在这里实现,且对应调用_takeDebt()和_supplyCredit()。

还有一个特例,是缓冲区没有足够的流动性(例如不够waDAI以便从aDAI解包,或反之亦然)。在这种情况下,除了额外的存入/提取操作以弥补短缺外,缓冲区还会进行再平衡(例如在_unwrapWithBuffer()函数中)。这一步再平衡是为了在缓冲区内保持包装和基础代币的50/50比例,从而避免未来的兑换中对包装/基础代币进行额外的存入/提取操作。

兑换

现在,让我们来看看兑换过程。作为一个众所周知的标准,执行兑换使用两个主要函数:“确切输入”和“确切输出”,分别设置输入或输出代币的确切数量。

在路由中(例如BatchRouter),兑换的最终确定使用_settlePaths()函数,该函数使用整个操作期间保存的代币数量,并调用_takeTokenIn()和_sendTokenOut()。这执行了Vault的结算,并发送实际的代币进出。此外,兑换结束时多余的ETH将通过_returnEth()函数退还给发送者。

路由在Balancer中的主要功能是“解开”用户传递的兑换路径,在每个步骤中执行:

  • 包装/解包“缓冲”代币(例如在这里
  • 如果当前步骤的代币为BPT(Balancer Pool Tokens)代币,则添加/移除流动性(例如在这里在这里
  • 在Vault中执行直接兑换(例如在这里

在每个阶段,路由将所有对“入/出”操作的余额更改组合在瞬态插槽中(如在这里在这里)。

现在,让我们移动到核心兑换 - Vault中的swap()函数,该函数在一个特定的池中执行兑换。Hook的使用增加了复杂性,因为它们可以重入并修改池的储备和费率。为了确保准确性,必须更新所有池的兑换参数(见这里)。动态费用随后会被计算(如果适用),然后调用核心_swap()函数在这里。最后,处理“兑换后”Hook并计算最终金额。

现在,让我们检查主要的_swap()函数,在此函数中首个onSwap处理程序首先在池中调用。这里是来自StablePool的此处理程序示例,该示例使用池的恒定不变体在computeBalance()函数中计算目标代币的数量,适用于两种兑换变体。在“确切输入”/“确切输出”分支中,两项关键值被计算:amountInRaw和amountOutRaw。这些数值通过_takeDebt()和_supplyCredit()将其保存在当前兑换步骤中。然后计算费用,更新池余额在这里,并最终触发兑换事件。

提供流动性

在Balancer V3中提供流动性从Vault中的addLiquidity()函数开始。在任何内部池操作之前,Balancer会重新加载池的余额和费率,以确保准确性,因为HookbeforeAddLiquidity可能会修改它们。核心_addLiquidity()函数接着会被结算。

在Balancer V3中,流动性可以通过多种逻辑类型进行添加:平衡、失衡、自定义池的逻辑等。与每种流动性提供类型对应的目标代币金额计算,在BasePoolMath.sol中实现(例如在这里,或在这里)。所有这些函数为计算三个关键值准备金额:

  • amountsInScaled18 - 要提供的代币金额(对于每个代币)
  • bptAmountOut - 流动性提供者收到的BPT代币金额
  • swapFeeAmountsScaled18 - 费用金额(也适用于每个代币)在计算 amountsInScaled18 之后,协议 执行 每个代币的“反缩放”。然后,它 获取债务、收取费用、更新所有余额(包括内存中和存储中的),并向流动性提供者铸造目标 BPT 数量。

去除流动性遵循类似的逻辑步骤,经过调整以适应过程的逆转。

滑点保护

滑点保护是任何去中心化交易所(DEX)中重要的一部分。在 Balancer V3 中,这种保护是通过在易受到 MEV(最大可提取价值)抢跑的操作中使用额外的兑换参数来实现的。

这是在兑换中使用的 limitRaw 参数(查看 用于“确切输入”, 查看 用于“确切输出”)和流动性提供中的 maxBptAmountIn 和 minBptAmoutOut 参数(这里这里这里这里)。

在执行兑换或流动性管理时,正确设置这些限额是至关重要的,以防止抢跑并确保安全交易体验。

查询接口

支持多个具有不同代币、不变性和兑换路线的池的去中心化交易所(DEX)面临的一个挑战是兑换过程的复杂性。通过外部软件模拟协议,设置滑点限额并寻找最佳的兑换路径可能是一项艰巨的任务。此外,这需要不断支持交易软件以跟上协议的变化。

Balancer V3 通过引入协议的特殊“只读”模式来解决此问题——“查询”模式。在此模式下,所有必要的操作(例如复杂的兑换)都可以使用 Vault 和池的真实状态进行模拟。这使得用户能够模拟他们的兑换,计算兑换的结果。虽然这不能保证他们的交易不会被抢跑,但它使用户能够设置适当的滑点限额,允许抢跑者仅从兑换和流动性管理操作中提取可控的利润。

在 Balancer V3 中实现查询的函数位于 Router.sol 合约中。与兑换相关的查询函数示例包括 querySwapSingleTokenExactIn()querySwapSingleTokenExactOut()。此外,Balancer V3 还提供流动性管理的查询函数。

查询过程以 调用 quote() 函数在 Vault 中开始。该函数通过 query() 修饰符进行包装,该修饰符执行以下检查:

  • 通过检查 tx.origin == 0 条件来验证这是“静态”调用,在 isStaticCall() 函数中。这一检查意味着当前事务没有签名者,并且该调用是作为某些 RPC 节点上的“只读”执行的(例如,使用 eth_call)。这个检查可能比较棘手,在某些情况下可能无法正常工作(例如,在 Remix 中 tx.origin != 0)。最好查看不同平台上交易参数是如何设置的(例如,这里
  • 如果“查询”模式被禁用则回滚(例如,如果某个平台未正确处理 tx.origin == 0 条件)
  • 解锁 Vault(因为大多数 Balancer 函数在没有解锁的 Vault 时无法工作)

使用“查询”模式提供的这个“只读”上下文,可以调用常规的 Balancer 函数,返回与常规调用完全相同的值。然而,在某些情况下,这些函数可能无法正常工作或在查询上下文中执行不必要的操作。为了解决这个问题,Balancer 在某些地方(例如 这里)修改这些函数在查询上下文中激活时的执行。例如,它可能仅仅返回计算的值,而不再执行进一步的状态更新。

在一些专业情况下,查询上下文需要专业的模拟。例如,在 _removeLiquidtity 这里,需要通过增加其余额来模拟代币的燃烧。为此,Balancer 中的 ERC20MultiToken 有一个特殊的 _queryModeBalanceIncrease() 函数,该函数增加代币余额,但仅在“只读”模式中进行,从而允许模拟代币燃烧。

实现细节

在处理池中的不变量时,协议在计算不同精度的代币的兑换金额和费用时需要小心处理舍入。在某些情况下,具有不同精度的值可能会出现奇怪情况:第一个“原始”(全精度)值被增加(例如,一个不变量),第二个值大于第一个。然而,在对不同精度的代币金额进行舍入和应用后,第二个值意外地可能变得等于甚至小于(!)第一个舍入值。

这种复杂性需要对舍入采取非常谨慎的方法。避免潜在攻击的策略是始终代表协议对目标数量进行“舍入”,而不是用户。这意味着:

  • 在计算 amountOut 时,需要向下舍入此值(以避免发送额外的代币“出去”)
  • 在计算 amountIn 时,需要向上舍入此值(以避免损失“进来的”代币)
  • 还有一些特殊情况,如此 评论 中所解释。

不同舍入的示例在 这里 中提出,表明在“输出代币”(SwapKind.EXACT_IN)的情况下舍入向下,而在其他情况下舍入向上。在 _swap() 函数中的另一个类似案例在 这里这里(也针对不同的“输入/输出”场景)中展示。

在操作涉及多种资产且精度不同的协议中,应谨慎对待舍入,以缓解舍入引发的问题。此外,最好使用模糊测试和形式验证方法测试这些协议,以确保它们的可靠性和安全性。

在 Balancer 中使用的另一个与安全相关的机制是最小交易金额,这减少了可以从舍入、费用计算和“归零”过小目标值中受益的小规模操作的问题。在 Balancer V3 中,有 两个 这样的值:_MINIMUM_TRADE_AMOUNT 和 _MINIMUM_WRAP_AMOUNT,这些值用于 兑换、流动性 操作 和与 股份 的操作。

此外,Balancer 在某些函数中使用“提示金额”,例如 这里。这些附加安全值的目的是向潜在易受重入或捐赠攻击的函数传递“期望”数量。该函数在收到“提示金额”时,如果目标数量不匹配“提示”值则回滚(如 这里)。另一个具有“提示”值的示例是 settle() 函数的情况,其中要结算的目标金额受到提示值的限制。

总结

我们回顾了 Balancer V3,这是一个复杂的 DEX 协议,允许操作多种不同类型的资产。Balancer V3 的主要特点包括:

  • 三层架构:将池逻辑层、结算层和外部路由层分离
  • 多代币池:能够处理包含多种代币的池(最多 8 种不同代币)
  • 可定制池:能够添加不同类型的池,具有不同的不变性和内部逻辑
  • 非标准代币支持:能够处理非标准代币(收益型、可重置)的功能,使用 ERC4626 封装协议代币
  • 丰富的Hook系统:允许为兑换和流动性提供添加更多功能
  • 查询接口:允许在协议中模拟操作

Balancer V3 包含许多与安全相关的机制,包括 Vault 的锁/unlock 逻辑、临时存储、操作限制和提示值,保护协议免受重入和捐赠攻击。在不同精度的代币操作中,对数学和舍入给予了大量关注。以上所有攻击向量对于 DEX 项目都是常见的,而 Balancer则能够很好地应对它们。

Balancer V3 在 DeFi 中引入了多个值得注意的进展,例如其模块化架构、多代币池支持和全面的Hook系统。这些功能增强了它的灵活性和在去中心化交易协议中的创新潜力。开发者和审计人员可能会发现研究 Balancer V3 对理解现代 DEX 设计和实现有价值。

在我们的下一篇文章中再见!

  • 谁是 MixBytes?

MixBytes 是一支专家区块链审计和安全研究团队,专注于为 EVM 兼容和 Substrate 基础项目提供全面的智能合约审计和技术顾问服务。加入我们在 X 上,以跟上最新的行业趋势和见解。

  • 原文链接: mixbytes.io/blog/modern-...
  • 登链社区 AI 助手,为大家转译优秀英文文章,如有翻译不通的地方,还请包涵~
点赞 0
收藏 0
分享
本文参与登链社区写作激励计划 ,好文好收益,欢迎正在阅读的你也加入。

0 条评论

请先 登录 后评论
mixbytes
mixbytes
Empowering Web3 businesses to build hack-resistant projects.