## 概述 在 [上一篇文章](https://blog.wssh.dev/posts/aave-v4/) 内,我们仔细考察了 AAVE v4 的代码细节,这导致上一篇文章的篇幅极其惊人,且由于上一篇文章本质上是笔者在阅读 AAVE v4 的文档和源代码记录的阅读笔记,所以存在一些上下文不连贯的问题。笔者最近刚好在参与 [AAVE v4 审计竞赛](https://audits.sherlock.xyz/contests/1209),近期又整体阅读了 AAVE v4 的源代码,所以相比于上一篇文章,本文将更加简洁,且更加连贯,但是本文会忽略一些具体的代码实现。本文仍只关注 Hub 和 Spoke 的实现,而不关注 `src/position-manager` 等外围合约实现。 当然,笔者不太建议读者参加 AAVE v4 的审计竞赛,因为当你参加审计竞赛后,你会在审计竞赛仓库内看到一份已知问题的清单, 内部包含 96 个已知问题,从内容上看,AAVE v4 至少经过了 3 轮审计,找到漏洞的概率极低。 另外上一篇文章编写时,笔者只看到了 v0.5.5 版本,该版本也是目前正在被审计的版本,但 AAVE v4 在 2025 年 12 月 1 日发布了 v0.5.6 版本,本文内容就与 v0.5.6 编写,但是相比于 v0.5.5 版本,v0.5.6 并没有特别大的更新,主要更新为(: 1. 在 Asset 内增加了 `ReserveFlagsMap` 打包多种配置,除了之前的 `Paused` / `Frozen` 和 `Borrowable` 配置外,额外增加了 `liquidatable` 和 `receiveSharesEnabled` 属性,这些属性被用于清算 2. 删除了 `repay` 内的的 Premium 更新逻辑。实际上,在 0.5.6 版本内,Premium 计算逻辑被大幅度更新了,删除了 `UserPosition` 内的 `realizedPremiumRay` 数据,废除了历史 Premium 累计机制 ## 整体架构 在 AAVE v4 内,引入了 Hub 和 Spoke 的分离架构。Hub 是系统的核心,且是一个不可变的合约,该合约用于存储所有资产的流动性,用户存入的资产会最终存入 Hub,而用户借出的资产最终也来自 Hub。除了存储流动性外,Hub 的另外的核心功能包括: 1. 计算债务利息和对应的存款利息 2. 允许管理员配置 Spoke 的存款限额和借款限额等信息 除了上述核心功能外,不太核心的功能还包括再投资(reinvestment)。Hub 允许特定的用户以无利息的方式从 Hub 内提取流动性用于其他投资。以及坏账的报告和消除功能,所谓的报告是指 Spoke 内具体的用户债务出现坏账时,Spoke 可以直接将坏账上报给 Hub,Hub 会将其记录到内部状态中,然后 Spoke 就可以将坏账勾销,而消除是指任何用户都可以向 Hub 内提供资产来消除债务。 > 与 AAVE v3 系统一致,AAVE v4 仍是一个强调治理的系统,不同于将坏账直接社会化的 Morpho 系统,AAVE v4 认为坏账的产生是治理存在问题,所以所有产生的坏账都是由 DAO 承担 但 Hub 不处理具体借贷头寸健康度检查、清算等具体流程,这些流程都委派给了 Spoke 执行。简单来说,在 AAVE v4 内,Hub 完全信任 Spoke,而 Spoke 可以实现无担保品的从 Hub 内借出资产,但此处需要注意,Spoke 仍需要为债务支付利息。用户不可以直接与 Hub 交互,而只可以与 Spoke 交互,Spoke 会在系统内判断用户的操作是否合法,假如操作合法,就会调用 Hub 内的一些函数帮助用户完成操作。此处举一个简单例子,用户调用 Spoke 内的借款函数(`borrow`),Spoke 会调用 Hub 的 `draw` 函数代用户从 Hub 内借出资产,但是 Spoke 会在后续函数(`_refreshAndValidateUserAccountData`) 内判断用户在 Spoke 内的担保品是否可以覆盖债务(即计算健康因子),假如健康因子不满足需求,那么 Spoke 会 revert 这笔交易。 所以 Spoke 最核心的功能是以下三部分: 1. 计算用户借贷头寸的整体健康度 2. 对处于可清算区间的头寸进行清算 3. 配置担保品的参数 除了上述核心功能外,AAVE v4 引入了一些新概念,这些概念也主要在 Spoke 内实现: 1. Premium Debt 指的是 AAVE v4 内用户支付的利率与担保品质量有关,假如担保品质量较差,那么用户会按比例相较于基础利率(drawn rate)多缴纳一些利息。Hub 内也会记录 Premium Debt,但其目的是记录总体的 Premium Debt 来计算合理的利息 2. Dynamic Config 指的是用户借贷头寸内会记录当前担保品配置的快照号,计算健康因子和清算等行为必须按照快照内的配置情况进行执行,主要是为了避免由于配置调整导致用户头寸被爆仓的情况。当然,该机制存在一定复杂性,我们会在后文讨论 简单来说,Hub 内最重要的功能就是存储流动性并计算总帐为存款人和借款人计算利息情况,而 Spoke 被允许无担保品的从 Hub 进行借贷,Spoke 的核心功能是统计每一个用户的担保品和债务情况,判断用户是否可以继续借出资产,以及处理复杂的清算流程。 ## 基础行为的实现 在本节中,我们强调的是 **基础行为** 的实现,所谓的基础行为是指借贷内最常被使用的 5 种行为: 1. 存款(Hub 内的 `add` 和 Spoke 内的 `supply`) 2. 提款(Hub 内的 `remove` 和 Spoke 内的 `withdraw`) 3. 借款(Hub 内的 `draw` 和 Spoke 内的 `borrow`) 4. 还款(Hub 内的 `restore` 和 Spoke 内的 `repay`) 5. 清算,该行为只发生在 Spoke 内部,对应 Spoke 内的 `liquidationCall` 函数,但是假如清算过程中发现坏账,则会调用 `reportDeficit` 函数报告坏账 在本节中,我们其实只会聚焦于前 4 种操作,对于清算操作,由于清算操作过于复杂(实际上清算逻辑的代码篇幅和 Spoke 的核心代码篇幅几乎一样多),我们会在后文介绍。同时,本部分主要强调基础的借贷实现,我们会一起介绍 Premiun Debt 和 Dynamic Config 机制。 由于 Hub-Spoke 机制的存在,我们在此处将首先介绍 Hub 内的机制,然后介绍 Spoke 内的机制实现,而不是分别介绍每一个操作的实现。 ### Hub 在 Hub 内,我们核心关注如下内容: ```solidity /// @notice Returns the total added assets for the specified asset. function totalAddedAssets(IHub.Asset storage asset) internal view returns (uint256) { uint256 drawnIndex = asset.getDrawnIndex(); uint256 aggregatedOwedRay = _calculateAggregatedOwedRay({ drawnShares: asset.drawnShares, premiumShares: asset.premiumShares, premiumOffsetRay: asset.premiumOffsetRay, deficitRay: asset.deficitRay, drawnIndex: drawnIndex }); return asset.liquidity + asset.swept + aggregatedOwedRay.fromRayUp() - asset.realizedFees - asset.getUnrealizedFees(drawnIndex); } ``` 这其实是 Hub 内最核心的计算逻辑,该逻辑计算了用户存入的总资产的数量。对于 `add` 和 `remove` 的逻辑,用户都是使用数量与 `totalAddedAssets` 计算获得对应的 shares,我们着重分析 `totalAddedAssets` 的构成: `asset.liquidity` 当前资产在 Hub 内的流动性,当 Spoke 调用 `add` / `restore` 函数时会增加该数值,因为这些操作都会将资产发送给 Hub,反之,`remove` / `draw` 都会降低系统流动性,因为 Hub 需要向外发送资产。当然,还有两种特殊的再投资操作会影响 `asset.liquidity` 的数值,我们会在后续介绍 `asset.swept` 时分析。在 `add` 函数内,我们可以看到如下实现: ```solidity uint256 liquidity = asset.liquidity + amount; uint256 balance = asset.underlying.balanceOf(address(this)); require(balance >= liquidity, InsufficientTransferred(liquidity.uncheckedSub(balance))); uint120 shares = asset.toAddedSharesDown(amount).toUint120(); require(shares > 0, InvalidShares()); asset.addedShares += shares; spoke.addedShares += shares; asset.liquidity = liquidity.toUint120(); ``` 此处我们可以看到 AAVE v4 对 `amount` 的处理,类似 Uniswap v2,AAVE v4 没有使用 `transferFrom` 函数,而是要求用户将资产先转入 Hub,然后再基于余额判断用户转让的资产数量是否充足。但此处没有直接使用 `balanceOf` 的返回值修改流动性,而是使用 `asset.liquidity + amount` 对 `asset.liquidity` 数值进行修改,避免了潜在的攻击。 另外,正如上文所说,`addedShares` 的数值取决于 `totalAddedAssets`,我们可以在 `toAddedSharesDown` 函数内看到如下代码: ```solidity function toAddedSharesDown( IHub.Asset storage asset, uint256 assets ) internal view returns (uint256) { return assets.toSharesDown(asset.totalAddedAssets(), asset.addedShares); } ``` 简单来说,我们会使用如下数学公式计算给定数量对应的 shares 数量: $$ \text{shares} = \text{amount} \cdot \frac{\text{addedShares}}{\text{totalAddedAssets}} $$ 不难证明,使用 `add` 函数不会影响此处的 `addedShares / totalAddedAssets` 的数值,读者可以自行证明该结论。 与 `add` 函数类似,`restore` 的核心实现如下: ```solidity uint256 premiumAmount = premiumDelta.restoredPremiumRay.fromRayUp(); uint256 liquidity = asset.liquidity + drawnAmount + premiumAmount; uint256 balance = asset.underlying.balanceOf(address(this)); require(balance >= liquidity, InsufficientTransferred(liquidity.uncheckedSub(balance))); asset.liquidity = liquidity.toUint120(); ``` 上述函数将用户偿还的债务增加到 `asset.liquidity` 内部,我们可以注意到此处的债务被分为: 1. `drawnAmount` 基础债务 2. `premiumAmount` 由于用户的担保品质量产生的额外利息 当然,`restore` 函数另一部分与 Hub 内利息记录有关,我们会在后文介绍 Hub 内利息统计实现逻辑时对该部分代码分析。对于减少 `asset.liquidity` 的方法,我们可以首先看 `remove` 的影响,该函数的实现如下: ```solidity uint256 liquidity = asset.liquidity; require(amount <= liquidity, InsufficientLiquidity(liquidity)); uint120 shares = asset.toAddedSharesUp(amount).toUint120(); asset.addedShares -= shares; spoke.addedShares -= shares; asset.liquidity = liquidity.uncheckedSub(amount).toUint120(); ``` 在 `remove` 函数内,我们可以实现提取利息,这是因为当使用 `add` 函数存入资产后,Hub 会记录用户存入资产数量对应的 shares,shares 本质上代表用户占据 `totalAddedAssets` 的比例。随着时间的推移,`totalAddedAssets` 内的代表债务的 `asset.totalOwed(drawnIndex)` 会不断累积利息,所以在一段时间后,shares 代表的资产数量会增加。`draw` 函数与 `remove` 类似,但 `draw` 函数内还包括一部分用于债务统计的函数,这部分代码记录了债务情况。 > 关于 `totalOwed` 如何累计利息,我们会在后文介绍,假如读者阅读过笔者编写的 [AAVE v3 的机制介绍](https://blog.wssh.dev/posts/aave-interactive/) 时,读者可以会知道 `drawnIndx` 的工作原理。 继续回到 `totalAddedAssets` 实现内, `asset.swept` 是再投资操作会使用的变量。该变量主要受 `sweep` 和 `reclaim` 函数影响。其中 `sweep` 的实现如下: ```solidity uint256 liquidity = asset.liquidity; require(amount <= liquidity, InsufficientLiquidity(liquidity)); asset.liquidity = liquidity.uncheckedSub(amount).toUint120(); asset.swept += amount.toUint120(); asset.updateDrawnRate(assetId); asset.underlying.safeTransfer(msg.sender, amount); ``` 我们可以看到该函数只是将 `asset.liquidity` 内的 `amount` 移动到 `asset.swept` 内,所以该函数完全不影响 `totalAddedAssets` 的数值。反之,`reclaim` 函数是将 `asset.swept` 内的数量移动到 `asset.liquidity` 内,对应的代码如下: ```solidity asset.liquidity += amount.toUint120(); asset.swept -= amount.toUint120(); asset.updateDrawnRate(assetId); asset.underlying.safeTransferFrom(msg.sender, address(this), amount); ``` `totalAddedAssets` 内的第三部分 `aggregatedOwedRay` 是一个由多个项目共同构成的部分,其中包括: 1. `asset.deficitRay` 代表坏账损失 2. `asset.drawnShares` 和 `drawnIndex` 计算后可以获得系统当前的基础债务(Drawn Debt) 3. `asset.premiumShares` / `asset.premiumOffsetRay` 和 `drawnIndex` 联合可以计算获得当前系统的 Premium Debt 此处对应的代码如下。我们可以看到 `asset.deficitRay` 等都使用了 Ray 精度(1e27),而 `totalAddedAssets` 内的其他部分使用了 WAD 精度(1e18),所以在最终的计算中,我们使用了 `aggregatedOwedRay.fromRayUp()` 进行精度转换。 ```solidity /// @notice Calculates the aggregated owed amount for a specified asset, expressed in asset units and scaled by RAY. function _calculateAggregatedOwedRay( uint256 drawnShares, uint256 premiumShares, int256 premiumOffsetRay, uint256 deficitRay, uint256 drawnIndex ) internal pure returns (uint256) { uint256 premiumRay = Premium.calculatePremiumRay({ premiumShares: premiumShares, premiumOffsetRay: premiumOffsetRay, drawnIndex: drawnIndex }); return (drawnShares * drawnIndex) + premiumRay + deficitRay; } ``` 接下来,我们会介绍 `aggregatedOwedRay` 内的每一项的含义和计算方法。第一项是 `asset.deficitRay` ,与该变量有关的 Hub 内两个函数是 `reportDeficit` 和 `eliminateDeficit` 函数。`reportDeficit` 用于 Spoke 向 Hub 进行坏账汇报,该函数会直接偿还债务,并将偿还的债务数量计入到 `deficitRay` 内部,在 `reportDeficit` 内,我们可以看到如下实现: ```solidity uint256 deficitAmountRay = uint256(drawnShares) * asset.drawnIndex + premiumDelta.restoredPremiumRay; asset.deficitRay += deficitAmountRay.toUint200(); spoke.deficitRay += deficitAmountRay.toUint200(); ``` 此处我们第一次看到了与利息计算有关的 `asset.drawnIndex` 的使用。假如读者具有金融背景,一定对 **贴现** 概念非常熟悉。所谓贴现,是将债券的未来价值与现在价值的比值。AAVE 内使用了类似的概念,`drawnIndex` 可以被视为单位资产的贴现率,而贴现的目标时期是 AAVE 被部署时。另一种解释方法是我们可以视 `drawIndex` 是单位资产从协议被部署开始记录利息时,单位资产直到当前时间累计的总利息。我们可以通过下图简单理解 `drawIndex` 的功能。在 time 0 时,债务会被贴现到 init 时,贴现后的数值其实就是 Hub 内的 `drawnShares` 变量。在 time 1 时,需要偿还的债务数量为 `drawShares * drawIndex`。 ![AAVE DrawIndex](https://img.gopic.xyz/AAVEDrawIndex.webp) 在上述介绍的 `reportDeficit` 函数内,我们需要知道当前坏账部分 `drawnShares` 对应的具体的债务数量,所以此处会使用 `uint256(drawnShares) * asset.drawnIndex` 计算债务数量。在此处,我们可以看一下 `drawnShares` 的计算方法,如下: ```solidity /// @notice Converts an amount of drawn assets to the equivalent amount of shares, rounding up. function toDrawnSharesUp( IHub.Asset storage asset, uint256 assets ) internal view returns (uint256) { return assets.rayDivUp(asset.getDrawnIndex()); } ``` 实际上就是直接将 `assets / drawnIndex` 计算结果。正如上文所述,`drawnIndex` 可以被视为单位资产的利息累计,所以我们会使用以下方法计算 `drawnIndex` 的数值: ```solidity /// @notice Calculates the drawn index of a specified asset based on the existing drawn rate and index. function getDrawnIndex(IHub.Asset storage asset) internal view returns (uint256) { uint256 previousIndex = asset.drawnIndex; uint40 lastUpdateTimestamp = asset.lastUpdateTimestamp; if ( lastUpdateTimestamp == block.timestamp || (asset.drawnShares == 0 && asset.premiumShares == 0) ) { return previousIndex; } return previousIndex.rayMulUp( MathUtils.calculateLinearInterest(asset.drawnRate, lastUpdateTimestamp) ); } ``` 此处我们也可以注意到 AAVE v4 与 AAVE v3 的区别,在 AAVE v4 内,我们使用了单利的计算方法,而在 AAVE v3 内,我们使用了连续复利的方法。有趣的是,在 AAVE v4 审计中,有审计工程师给出了以下问题: > Triggering interest accrual on every block can lead to an actual interest rate up to 2,000x greater than intended due to the compounding effect > > 由于复利效应,假如每一个区块都触发利息累计(即 `drawnIndex` 的计算),实际利息会比预期高 2000 倍 继续阅读 `aggregatedOwedRay` 内的组成,我们分析 Drawn Debt 部分。该部分计算了当前所有的基础债务数量,此处我们在 `_calculateAggregatedOwedRay` 内使用 `drawnShares * drawnIndex` 计算出在当前时间 `drawnShares` 对应的债务数量。 此处我们忽略 `aggregatedOwedRay` 内的 `premiumRay` 的计算,因为这部分计算从 Spoke 角度理解更加方便,通过 Hub 很难理解 premium 的计算过程。但此处我们可以介绍一下 Premium 是对 drawn debt 基础债务利息的按比例的额外补充,比如基础债务的利息是 100 单位,Premium 会产生相对于基础债务利息的 3% 的额外利息,即 Premium 可以贡献 3 单位的额外利息。我们会在后文介绍 Spoke 时给出统计 Premium 的方法。 最后,我们将介绍 `totalAddedAssets` 内被减去的两部分 `asset.realizedFees` 和 `asset.getUnrealizedFees(drawnIndex)`。这两部分是被分配给 `asset.feeReceiver` 的手续费。与 AAVE v3 相同,这些手续费本质上来自利息收入,即全部利息收入的一部分会被分配给 `feeReceiver`。在 `getUnrealizedFees` 函数内,我们可以看到手续费的计算过程: ```solidity uint256 aggregatedOwedRayAfter = _calculateAggregatedOwedRay({ drawnShares: drawnShares, premiumShares: premiumShares, premiumOffsetRay: premiumOffsetRay, deficitRay: deficitRay, drawnIndex: drawnIndex }); uint256 aggregatedOwedRayBefore = _calculateAggregatedOwedRay({ drawnShares: drawnShares, premiumShares: premiumShares, premiumOffsetRay: premiumOffsetRay, deficitRay: deficitRay, drawnIndex: previousIndex }); return (aggregatedOwedRayAfter.fromRayUp() - aggregatedOwedRayBefore.fromRayUp()).percentMulDown( liquidityFee ); ``` 在每次计算手续费时,我们都会利用前后 `_calculateAggregatedOwedRay` 的差值计算在两次调用时间间隔内的利息累计情况,并使用利息累计数据计算手续费。 我们最后可以获得如下图: ![AAVE v4 interest round](https://img.gopic.xyz/AAVEV4Round.webp) 从机制上来说,`add` / `withdraw` 过程中没有直接的利息相关部分,而是通过 `totalOwed` 与债务进行联系,而 `draw` / `restore` 操作主要与 `drawnIndex` 有关,与 `totalAddedAssets` 并没有直接关系。而其他操作,比如 `sweep` 和 `reclaim` 只涉及到 `totalAddedAssets` 内部的组分转化,并不影响正常的存款和借款部分的计算。 至此,读者应该可以自行阅读 Hub 内核心函数的实现,比如 `draw` 函数: ```solidity /// @inheritdoc IHubBase function draw(uint256 assetId, uint256 amount, address to) external returns (uint256) { Asset storage asset = _assets[assetId]; SpokeData storage spoke = _spokes[assetId][msg.sender]; asset.accrue(); _validateDraw(asset, spoke, amount, to); uint256 liquidity = asset.liquidity; require(amount <= liquidity, InsufficientLiquidity(liquidity)); uint120 drawnShares = asset.toDrawnSharesUp(amount).toUint120(); asset.drawnShares += drawnShares; spoke.drawnShares += drawnShares; asset.liquidity = liquidity.uncheckedSub(amount).toUint120(); asset.updateDrawnRate(assetId); asset.underlying.safeTransfer(to, amount); emit Draw(assetId, msg.sender, drawnShares, amount); return drawnShares; } ``` 对于 `drawnShares` 的计算,我们可以直接使用 `amount / drawnIndex` 进行计算,即上文中的 `toDrawnSharesUp` 函数,同时由于 `draw` 函数需要向外支付流动性,所以此处对 `asset.liquidity` 进行了调整。当然,上述函数执行完成后,会发现 `totalAddedAssets` 的数值其实是不变的,虽然 `asset.liquidity` 降低了,但是 `asset.totalOwed(drawnIndex)` 增加了债务。实际上,只要发现除了 `drawnIndex` 外的因素导致 `totalAddedAssets` 增加,其实就意味着发现了一个漏洞,但目前来看除了可能存在的舍入攻击外,其他方法似乎都无法实现 `totalAddedAssets` 的增加。 我们忽略了 Hub 内的其他功能,比如 Hub 内 Asset 配置(`addAsset` 和 `updateAssetConfig` 函数)和 Spoke 配置(`addSpoke` 和 `updateSpokeConfig` 函数),以及 Hub 内对 Spoke 的检查(各种 `_validate` 开头的函数,比如 `_validateAdd` 函数等)。读者可以自行阅读剩余的代码。 ### Spoke 在最初介绍架构时,我们介绍了 Spoke 的特权是可以无担保的直接从 Hub 内借出资产,以及 Spoke 充当了用户与 Hub 调用的中间层。我们在本节中,主要介绍 Spoke 内的基础操作,以及介绍 Spoke 内如何计算头寸的 Health Factor 和 Premium Debt 信息。 在基础操作中,大部分 Spoke 的操作都是直接将用户的请求转发给 Hub,然后在本地状态内记录状态。`supply` 函数是一个典型函数: ```solidity function supply( uint256 reserveId, uint256 amount, address onBehalfOf ) external onlyPositionManager(onBehalfOf) returns (uint256, uint256) { Reserve storage reserve = _getReserve(reserveId); UserPosition storage userPosition = _userPositions[onBehalfOf][reserveId]; _validateSupply(reserve); reserve.underlying.safeTransferFrom(msg.sender, address(reserve.hub), amount); uint256 suppliedShares = reserve.hub.add(reserve.assetId, amount); userPosition.suppliedShares += suppliedShares.toUint120(); emit Supply(reserveId, msg.sender, onBehalfOf, suppliedShares, amount); return (suppliedShares, amount); } ``` 首先将用户资产转发给 Hub,然后调用 Hub 的 `add` 函数将资产真正存入 Hub 内部,最后使用 `add` 函数的返回值 `suppliedShares` 记录到用户头寸 `userPosition`。与之类似,我们可以看到 `withdraw` 函数的实现: ```solidity function withdraw( uint256 reserveId, uint256 amount, address onBehalfOf ) external onlyPositionManager(onBehalfOf) returns (uint256, uint256) { Reserve storage reserve = _getReserve(reserveId); UserPosition storage userPosition = _userPositions[onBehalfOf][reserveId]; _validateWithdraw(reserve); IHubBase hub = reserve.hub; uint256 assetId = reserve.assetId; uint256 withdrawnAmount = MathUtils.min( amount, hub.previewRemoveByShares(assetId, userPosition.suppliedShares) ); uint256 withdrawnShares = hub.remove(assetId, withdrawnAmount, msg.sender); userPosition.suppliedShares -= withdrawnShares.toUint120(); if (_positionStatus[onBehalfOf].isUsingAsCollateral(reserveId)) { uint256 newRiskPremium = _refreshAndValidateUserAccountData(onBehalfOf).riskPremium; _notifyRiskPremiumUpdate(onBehalfOf, newRiskPremium); } emit Withdraw(reserveId, msg.sender, onBehalfOf, withdrawnShares, withdrawnAmount); return (withdrawnShares, withdrawnAmount); } ``` 在 Hub 内部,Hub 是以 Spoke 为单位记录数据的,所以此处为了避免用户 withdraw 出大于自身应得的资产,我们使用 `MathUtils.min` 函数避免了用户 withdraw 出大于自身的 `suppliedShares` 对应的资产。通过该函数确定真正的 `withdrawnAmount` 后,Spoke 会调用 `hub.remove` 从 Hub 内提取资产。 但相比于简单的 `supply` 函数,假如用户提取正在作为担保品(`isUsingAsCollateral`) 的资产时可能导致头寸不健康,同时在 Premium Debt 机制下,用户的担保品集合的质量会影响 Premium 利率,所以此处使用 `_refreshAndValidateUserAccountData` 函数重新计算了用户当前的 `riskPremium` 并将 Premium Debt 的更新情况通过 `_notifyRiskPremiumUpdate` 上报给 Hub。 此处的 `_refreshAndValidateUserAccountData` 是本节最核心的函数,该函数的作用是刷新 premium risk 和避免头寸的 Health Factor 不满足要求。另外,该函数还会提供 `UserAccountData` 数据,该数据主要被用于清算过程。`_refreshAndValidateUserAccountData` 函数的实现如下: ```solidity struct UserAccountData { uint256 riskPremium; uint256 avgCollateralFactor; uint256 healthFactor; uint256 totalCollateralValue; uint256 totalDebtValue; uint256 activeCollateralCount; uint256 borrowedCount; } function _refreshAndValidateUserAccountData( address user ) internal returns (UserAccountData memory) { UserAccountData memory accountData = _processUserAccountData(user, true); emit RefreshAllUserDynamicConfig(user); require( accountData.healthFactor >= HEALTH_FACTOR_LIQUIDATION_THRESHOLD, HealthFactorBelowThreshold() ); return accountData; } ``` 在此处,我们可以看到 `_processUserAccountData(user, true)` 函数是核心,参数中的 `true` 代表该函数会刷新 Dynamic Config。Dynamic Config 是 AAVE v4 引入的新机制,在 Spoke 内部,我们使用 `_reserves` 存储所有 Reserve 的配置,但是需要注意的,`Reserve` 结构体内并没有包含配置的具体内容,而只包含配置的版本号 `dynamicConfigKey`。而具体的配置内容需要使用 `_dynamicConfig` 来查询。 ```solidity struct Reserve { ... uint24 dynamicConfigKey; ... } struct ReserveConfig { bool paused; bool frozen; bool borrowable; uint24 collateralRisk; } mapping(uint256 reserveId => Reserve) internal _reserves; mapping(uint256 reserveId => mapping(uint24 dynamicConfigKey => DynamicReserveConfig)) internal _dynamicConfig; ``` 在用户的 `UserPosition` 结构体内,我们可以看到用户的头寸内也包含 `dynamicConfigKey` 数据项用来指向该头寸使用的配置项。在用户执行增加头寸危险程度的函数时,比如 `withdraw` 和 `borrow` 函数时,我们会要求使用当前 `Reserve` 配置内指定的最新的 `DynamicReserveConfig` 进行计算。但是在 `supply` / `repay` 和 `liquidationCall` 函数中,我们会使用 `UserPosition` 内的数据进行计算。 ```solidity struct UserPosition { uint120 drawnShares; uint120 premiumShares; // int200 premiumOffsetRay; // uint120 suppliedShares; uint24 dynamicConfigKey; } mapping(address user => mapping(uint256 reserveId => UserPosition)) internal _userPositions; ``` 这就是 Dynamic Config 的原理,读者可以自行阅读 `addDynamicReserveConfig` 和 `updateDynamicReserveConfig` 函数了解更多信息。此处的 `updateDynamicReserveConfig` 函数给了 Spoke 管理者补救错误的机会,假如 Spoke 管理者引入了非常不安全的配置,且当前配置已经被部分头寸使用,那么 Spoke 管理者可以调用 `updateDynamicReserveConfig` 直接更新该配置。但是在大部分情况下,Spoke 管理者应该使用 `addDynamicReserveConfig` 增加配置而不是直接更新配置。 接下来,我们需要补充一些 AAVE v4 的基础知识,第一个是 Health Factor 的计算方法,所谓的 Health Factor 是一个比值,其数值为: $$ \text{HF} = \frac{\sum_{i=1}^n \text{CollateralValue} \times \text{collateralFactor}}{\text{TotalDebtValue}} $$ 此处的 `collateralFactor` 指的是担保品的折价情况,由于担保品的价值存在波动性,在计算担保品价值时,我们会在原有价值上进行折价,即此处的 `collateralFactor` 因子。 接下来,我们要介绍 Risk Premium 的计算算法。首先,我们需要知道在 Spoke 内每一个资产都存在 `collateralRisk` 参数。该参数表示资产的额外风险,参数越大代表用户需要支付的额外利息越多,保证金集合中的 Collateral Risk 的加权平均数就是 User Risk Premium。但具体算法如下: 1. 按照保证金资产的风险进行升序排序: 按照 Collateral Risk 计算担保品的 risk value,并且按照最低风险到最高风险进行排序,在代码内我们使用 `collateralInfo.sortByKey();` 方法排序 2. 计算当前头寸的 total debt: 计算用户的包含利息的总债务情况,可以直接使用 `accountData.totalDebtValue` 3. 迭代担保品资产来计算可以覆盖用户头寸总债务的担保品资产的数量,在此迭代过程中,我们会使用辅助变量 `debtValueLeftToCover`,该变量会被初始化为 `totalDebtValue`: 1. 计算 `debtValueLeftToCover = debtValueLeftToCover - userCollateralValue` 2. 如果 `debtValueLeftToCover = 0` 意味着所有的债务都被覆盖,所以我们可以直接跳出循环 3. 如果 `debtValueLeftToCover > 0` 意味着还有债务没有覆盖,继续循环 4. 基于迭代过程中担保品种类和数量计算担保品的 Collateral Risk 的加权平均数 计算 User Risk Premium 会使用如下公式: $$ RP_u =\frac{\sum_{i=1}^n \text{collateralRisk} \times \text{CollateralValue}}{\sum_{i=1}^n\text{CollateralValue}} $$ 有了上述知识后,我们开始进行分析 `_processUserAccountData` 函数,该函数输出的 `UserAccountData` 内的 `healthFactor` 和 `riskPremium` 就是上文介绍的两个概念。我们首先介绍 `_processUserAccountData` 用到的几个有趣的数据结构。 第一个数据结构是 `PositionStatus`,这个数据结构与 AAVE v3 内用于记录资产借贷情况的 Bitmap 是一致的,其定义如下: ```solidity struct PositionStatus { mapping(uint256 bucket => uint256) map; bool hasPositiveRiskPremium; } ``` 我们使用 `map` 其实构成了一个无限长的 bitmap。对于用户需要查询的 reserveId 的借贷情况,我们会首先使用 `bucketId` 算法确定该 reserveId 所在的 `bucket`,然后在 `map` 内进行检索。假如读者阅读过 Uniswap v3 代码,会发现该机制与 Uniswap v3 内的 `TickBitmap` 的原理类似。在具体每一个 bucket 对应的 256bit 数据中,我们使用如下方法记录每一个 bit 对应的借贷情况: ![Asset Bitmap](https://img.gopic.xyz/49cbebfeffedb13722f3fa5049e7c269.png) 每 2 bit 对应一个资产,其中低位代表是否已经被借出,而高位代表是否作为担保品。读者可以通过 `BORROWING_MASK` 和 `COLLATERAL_MASK` 获得该信息。对于具体的如何写入和检索,读者可以自行阅读相关源代码,基本都是 and 和 or 的操作。值得一提的是,该函数存在 `next` 方法用于迭代遍历整个 `PositionStatus` 系统,遍历会在从高位向低位进行。 第二个在此处被使用的数据结构是 `KeyValueList.List` 类型,这其实是一种特殊的数组类型,该类型内每一个元素都是由 `key` 和 `value` 打包获得的: ```solidity function add(List memory self, uint256 idx, uint256 key, uint256 value) internal pure { require(key < _MAX_KEY && value < _MAX_VALUE, MaxDataSizeExceeded()); self._inner[idx] = pack(key, value); } ``` `KeyValueList.List` 也提供了排序算法,我们可以调用 `sortByKey` 方法按照 key 自低向高进行升序排列。在 `_processUserAccountData` 函数内,我们使用 `collateralRisk` 作为 key,使用 `userCollateralValue` 作为 value。调用 `sortByKey` 后,我们就实现了上述计算 User Risk Premium 中的按 Collateral Risk 升序的目的。 有了上述知识后,我们可以直接阅读 `_processUserAccountData` 内部的循环计算部分。该部分篇幅很长,但理解难度不大,唯一需要注意的是以下代码内的 `avgCollateralFactor` 在当前代码内代表的是 `collateralFactor * userCollateralValue` 的总和,而不是代表平均的 CollateralFactor,在后续代码中,我们才会将其计算为最终的结果。AAVE v4 代码内有不少部分都使用了类似技巧,即结构体的变量会被多次使用,但在某些情况下,该变量会出现名不副实的情况。 ```solidity PositionStatus storage positionStatus = _positionStatus[user]; uint256 reserveId = _reserveCount; KeyValueList.List memory collateralInfo = KeyValueList.init( positionStatus.collateralCount(reserveId) ); bool borrowing; bool collateral; while (true) { (reserveId, borrowing, collateral) = positionStatus.next(reserveId); if (reserveId == PositionStatusMap.NOT_FOUND) break; UserPosition storage userPosition = _userPositions[user][reserveId]; Reserve storage reserve = _reserves[reserveId]; uint256 assetPrice = IAaveOracle(ORACLE).getReservePrice(reserveId); uint256 assetUnit = MathUtils.uncheckedExp(10, reserve.decimals); if (collateral) { uint256 collateralFactor = _dynamicConfig[reserveId][ refreshConfig ? (userPosition.dynamicConfigKey = reserve.dynamicConfigKey) : userPosition.dynamicConfigKey ].collateralFactor; // collateralFactor == 0 not exists, but this is in known issue if (collateralFactor > 0) { uint256 suppliedShares = userPosition.suppliedShares; if (suppliedShares > 0) { // cannot round down to zero uint256 userCollateralValue = (reserve.hub.previewRemoveByShares( reserve.assetId, suppliedShares ) * assetPrice).wadDivDown(assetUnit); accountData.totalCollateralValue += userCollateralValue; // add(idx, key, value) collateralInfo.add( accountData.activeCollateralCount, reserve.collateralRisk, userCollateralValue ); // avgCollateralFactor => sum of collateralFactor * collateralValue(sumCollateralValue) accountData.avgCollateralFactor += collateralFactor * userCollateralValue; accountData.activeCollateralCount = accountData.activeCollateralCount.uncheckedAdd(1); } } } if (borrowing) { (uint256 drawnDebt, uint256 premiumDebtRay) = userPosition.getDebt( reserve.hub, reserve.assetId ); // we can simplify since there is no precision loss due to the division here accountData.totalDebtValue += ((drawnDebt + premiumDebt) * assetPrice).wadDivUp(assetUnit); accountData.borrowedCount = accountData.borrowedCount.uncheckedAdd(1); } } ``` 对于上述代码内的 `userPosition.getDebt` 函数,我们会在后文介绍。上述循环中,我们拿到了用户担保品在 collateralFactor 影响下的价值(`avgCollateralFactor`),即 $\sum_{i=1}^n \text{CollateralValue} \times \text{collateralFactor}$ 的数值,以及债务的总价值 `totalDebtValue`。除此外,我们还获得了 `collateralInfo` 数据,该数据包含用于计算 User Risk Premium 的有关数据。另外,此处需要简单说明,AAVE v4 使用了存在本位的计价系统,所有的担保品和债务都是通过预言机报告的 `assetPrice` 被转化为本位代币计价,比如使用 USD 作为本位进行计价。这样就避免了复杂的担保品和债务的价值换算。假如读者了解过 Morpho 的智能合约,就会知道非本位计价下我们需要推导换算公式。 有了上述累计数据后,我们可以真正计算 Health Factor 和 User Risk Premium。我们首先介绍 Health Factor 的计算: ```solidity if (accountData.totalDebtValue > 0) { // at this point, `avgCollateralFactor` is the collateral-weighted sum (scaled by `collateralFactor` in BPS) // health factor uses this directly for simplicity // the division by `totalCollateralValue` to compute the weighted average is done later accountData.healthFactor = accountData .avgCollateralFactor .wadDivDown(accountData.totalDebtValue) .fromBpsDown(); } else { accountData.healthFactor = type(uint256).max; } if (accountData.totalCollateralValue > 0) { accountData.avgCollateralFactor = accountData .avgCollateralFactor .wadDivDown(accountData.totalCollateralValue) .fromBpsDown(); } ``` 上述数学计算是简单的,读者可以自行阅读。接下来,我们需要计算 `riskPremium` 的数值。我们首先使用 `collateralInfo.sortByKey();` 按照 `collateralRisk` 进行排序。然后使用 `debtValueLeftToCover` 判断是否需要跳出循环。在 `for` 循环中,我们会使用 `accountData.riskPremium` 不断累积 $\text{collateralRisk} \times \text{CollateralValue}$ 的数值,所以此处的 `riskPremium` 在 `for` 循环内部名不副实。 ```solidity // runs until either the collateral or debt is exhausted uint256 debtValueLeftToCover = accountData.totalDebtValue; for (uint256 index = 0; index < collateralInfo.length(); ++index) { if (debtValueLeftToCover == 0) { break; } (uint256 collateralRisk, uint256 userCollateralValue) = collateralInfo.get(index); userCollateralValue = userCollateralValue.min(debtValueLeftToCover); accountData.riskPremium += userCollateralValue * collateralRisk; debtValueLeftToCover = debtValueLeftToCover.uncheckedSub(userCollateralValue); } if (debtValueLeftToCover < accountData.totalDebtValue) { accountData.riskPremium /= accountData.totalDebtValue.uncheckedSub(debtValueLeftToCover); } return accountData; ``` 在计算完成 User Risk Premium 的分子部分后,我们使用除法计算出最终的 risk premium 数值。在完成了 Health Factor 和 Risk Premium 计算后,我们开始介绍上文一直回避的 Premium Debt 的计算。所谓 Premium Debt 的计算,从原理上分析,Risk Premium 的数值直接作用于债务的利息部分,要求用户支付相比于无风险利率高一定比例的额外的风险溢价。 $$ \begin{align*} \text{Premium} &= \text{interest} \times \text{RiskPremium}\\ &= \text{DrawnDebtShares} \times \Delta\text{DrawnIndex} \times \text{RiskPremium}\\ &= \text{DrawnDebtShares} \times \text{RiskPremium} \times \Delta\text{DrawnIndex}\\ &= \text{PremiumShares} \times \Delta\text{DrawnIndex}\\ &= \text{PremiumShares} \times (\text{DrawIndex}_{\text{now}} - \text{DrawIndex}_\text{init})\\ &= \text{PremiumShares} \times \text{DrawIndex}_{\text{now}} - \text{PremiumShares} \times \text{DrawIndex}_\text{init}\\ &= \text{PremiumShares} \times \text{DrawIndex}_{\text{now}} - \text{PremiumOffsetRay} \end{align*} $$ 在上述推导中,我们额外注意 `PremiumOffsetRay` 的引入,该变量的引入是因为与普通的债务统一使用 `drawnIndex` 贴现到期初不同,Premium 债务只需要记录某一个时间段的额外利息,所以此处我们需要记录 Premium 产生的 PremiumOffsetRay。在具体代码实现中,我们可以看到如下实现: ```solidity function calculatePremiumRay( uint256 premiumShares, int256 premiumOffsetRay, uint256 drawnIndex ) internal pure returns (uint256) { return ((premiumShares * drawnIndex).toInt256() - premiumOffsetRay).toUint256(); } ``` 另一个与 Premium 高度相关的函数是 `getPremiumDelta` 函数,该函数可以获得用于用于修改 Hub 和 Spoke 内 Premium 修改的 `PremiumDelta` 结构体,该结构体的定义如下: ```solidity struct PremiumDelta { int256 sharesDelta; int256 offsetRayDelta; uint256 restoredPremiumRay; } ``` 其中,`sharesDelta` 代表对 Premium Shares 的调整,而 `offsetRayDelta` 代表对 Premium Offset 的调整,最后的 `restoredPremiumRay` 代表对 Premium Debt 的偿还,该变量用于 Hub 内部的 Premium 总体计算,并不会在 Spoke 内使用。在 Spoke 内,与 `PremiumDelta` 配置的是 `applyPremiumDelta` 函数: ```solidity function applyPremiumDelta( ISpoke.UserPosition storage userPosition, IHubBase.PremiumDelta memory premiumDelta ) internal { userPosition.premiumShares = userPosition .premiumShares .add(premiumDelta.sharesDelta) .toUint120(); userPosition.premiumOffsetRay = (userPosition.premiumOffsetRay + premiumDelta.offsetRayDelta) .toInt200(); } ``` 继续回到 `getPremiumDelta` 函数,该函数的实现如下,大部分代码都较为简单,读者可以自行阅读。这里我们可以注意到 `newPremiumOffsetRay` 更新时减去了 `premiumDebtRay`,该操作的作用是在 `newPremiumOffsetRay` 累计过去的 Premium: ```solidity function getPremiumDelta( ISpoke.UserPosition storage userPosition, uint256 drawnSharesTaken, uint256 drawnIndex, uint256 riskPremium, uint256 restoredPremiumRay ) internal view returns (IHubBase.PremiumDelta memory) { uint256 oldPremiumShares = userPosition.premiumShares; int256 oldPremiumOffsetRay = userPosition.premiumOffsetRay; uint256 premiumDebtRay = Premium.calculatePremiumRay({ premiumShares: oldPremiumShares, premiumOffsetRay: oldPremiumOffsetRay, drawnIndex: drawnIndex }); uint256 newPremiumShares = (userPosition.drawnShares - drawnSharesTaken).percentMulUp( riskPremium ); int256 newPremiumOffsetRay = (newPremiumShares * drawnIndex).signedSub( premiumDebtRay - restoredPremiumRay ); return IHubBase.PremiumDelta({ sharesDelta: newPremiumShares.signedSub(oldPremiumShares), offsetRayDelta: newPremiumOffsetRay - oldPremiumOffsetRay, restoredPremiumRay: restoredPremiumRay }); } ``` 在 Spoke 内部,存在 `_notifyRiskPremiumUpdate` 函数处理 Premium 的计算,另外该函数还调用 Hub 的 `refreshPremium` 函数将 Premium 的更新推送给 Hub。此处我们调用了 `getPremiumDelta` 获得每一个 reserve 的 Premium 情况,然后调用 `applyPremiumDelta` 将其写入 Spoke 内的状态,此处也使用 `hub.refreshPremium` 将 delta 同步到 Hub 内部。 ```solidity function _notifyRiskPremiumUpdate(address user, uint256 newRiskPremium) internal { PositionStatus storage positionStatus = _positionStatus[user]; if (newRiskPremium == 0 && positionStatus.riskPremium == 0) { return; } positionStatus.riskPremium = newRiskPremium.toUint24(); uint256 reserveId = _reserveCount; while ((reserveId = positionStatus.nextBorrowing(reserveId)) != PositionStatusMap.NOT_FOUND) { UserPosition storage userPosition = _userPositions[user][reserveId]; Reserve storage reserve = _reserves[reserveId]; uint256 assetId = reserve.assetId; IHubBase hub = reserve.hub; IHubBase.PremiumDelta memory premiumDelta = userPosition.getPremiumDelta({ drawnSharesTaken: 0, drawnIndex: hub.getAssetDrawnIndex(assetId), riskPremium: newRiskPremium, restoredPremiumRay: 0 }); hub.refreshPremium(assetId, premiumDelta); userPosition.applyPremiumDelta(premiumDelta); emit RefreshPremiumDebt(reserveId, user, premiumDelta); } emit UpdateUserRiskPremium(user, newRiskPremium); } ``` 此处为了全面介绍 Premium Debt 机制,我们再次将视角转回到 Hub 内部。Hub 内的数据可以被视为一份总帐本,内部包括按 Assets 汇总和按 Spoke 汇总的 Premium Debt 情况。该函数的实现如下: ```solidity function refreshPremium(uint256 assetId, PremiumDelta calldata premiumDelta) external { Asset storage asset = _assets[assetId]; SpokeData storage spoke = _spokes[assetId][msg.sender]; asset.accrue(); require(spoke.active, SpokeNotActive()); // no premium change allowed require(premiumDelta.restoredPremiumRay == 0, InvalidPremiumChange()); _applyPremiumDelta(asset, spoke, premiumDelta); asset.updateDrawnRate(assetId); emit RefreshPremium(assetId, msg.sender, premiumDelta); } ``` 我们可以看到 Hub 内部也存在 `_applyPremiumDelta` 函数,并且该函数其实也是 `refreshPremium` 内最核心的部分。该函数的实现非常简单,读者可以自行查找源代码。不难发现 `_applyPremiumDelta` 的核心部分是 `_validateApplyPremiumDelta` 函数,该函数的实现如下: ```solidity /// @dev Validates applied premium delta for given premium data and returns updated premium data. function _validateApplyPremiumDelta( uint256 drawnIndex, uint256 premiumShares, int256 premiumOffsetRay, PremiumDelta calldata premiumDelta ) internal pure returns (uint120, int200) { uint256 premiumRayBefore = Premium.calculatePremiumRay({ premiumShares: premiumShares, premiumOffsetRay: premiumOffsetRay, drawnIndex: drawnIndex }); uint256 newPremiumShares = premiumShares.add(premiumDelta.sharesDelta); int256 newPremiumOffsetRay = premiumOffsetRay + premiumDelta.offsetRayDelta; uint256 premiumRayAfter = Premium.calculatePremiumRay({ premiumShares: newPremiumShares, premiumOffsetRay: newPremiumOffsetRay, drawnIndex: drawnIndex }); require( premiumRayAfter + premiumDelta.restoredPremiumRay == premiumRayBefore, InvalidPremiumChange() ); return (newPremiumShares.toUint120(), newPremiumOffsetRay.toInt200()); } ``` `_validateApplyPremiumDelta` 的入参中 `drawnIndex` / `premiumShares` 和 `premiumOffsetRay` 都是从 Hub 内部检索获得的,而 `premiumDelta` 是 Spoke 向 Hub 汇报的。Hub 内的操作本身可以视为对 Spoke 内的计算进行再次验算。由于加法具有可组合性和可交换性,所以 `premiumRayBefore` 本质上可以被视为当前 Hub 内所有头寸的 Premium 总和,推导如下: $$ \begin{align*} \text{PremiumRayBefore} &= \sum (\text{PremiumShares} \times \text{DrawIndex}_{\text{now}} - \text{PremiumOffsetRay})\\ &= \sum \text{Premium}\\ &= \text{Premium}_i + \sum_{\text{other}} \text{Premium} \end{align*} $$ 此处我们再次给出 `premiumRayAfter` 对应的数学推导: $$ \begin{align*} \text{PremiumRayAfter} &= \text{NewPremiumShares} \times \text{DrawIndex}_{\text{now}} - \text{NewPremiumOffsetRay} + \sum_{\text{other}}\\ &= \text{NewPremium} - (\text{NewPremium} - \text{Premium} + \text{restorePremium}) + \sum_{\text{other}}\\ &= \text{Premium} - \text{restorePremium} + \sum_{\text{other}} \end{align*} $$ 上述推导中,我们利用了 Spoke 内的 `getPremiumDelta` 的如下代码: ```solidity int256 newPremiumOffsetRay = (newPremiumShares * drawnIndex).signedSub( premiumDebtRay - restoredPremiumRay ); ``` 通过以上推导,我们最终可以获得如下结论: ```solidity require( premiumRayAfter + premiumDelta.restoredPremiumRay == premiumRayBefore, InvalidPremiumChange() ); ``` 至此,我们就彻底解释了 `_validateApplyPremiumDelta` 最核心的不变量检查部分,对于 Hub 内其他与 Premium 有关的代码,读者可以自行阅读,这些位于 Hub 内的函数基本都使用了 `calculatePremiumRay` 获得 Premium 的相关信息。 在 v0.5.5 版本的 AAVE v4 内,Premium 的计算稍有不同,在该版本内,存在 `realizedPremiumRay` 变量,该变量的目的是在每次 Premium Risk 更新时,结算本次更新时间段内的 Premium 并将其累积到 `realizedPremiumRay`,但在 v0.5.6 版本,即上文介绍的版本中,AAVE v4 开发者删除了 `realizedPremiumRay` 变量,而是使用 `premiumOffsetRay` 作为 Premium 累积的目的地。 既然刚刚我们聊到了 repay 函数,所以此处我们开始分析 Spoke 内的 `repay` 函数的实现。在 Repay 过程中,我们优先偿还 Premium Debt 然后再偿还 Drawn Debt。`calculateRestoreAmount` 函数内展示了此过程,此处的 `amount` 代表用户偿还的债务数量,我们可以看到假如用户偿还的债务数量只可以覆盖 `premiumDebt`,那么 `calculateRestoreAmount` 将优先偿还 `premiumDebt`。 ```solidity function calculateRestoreAmount( ISpoke.UserPosition storage userPosition, uint256 drawnIndex, uint256 amount ) internal view returns (uint256, uint256) { (uint256 drawnDebt, uint256 premiumDebtRay) = userPosition.getDebt(drawnIndex); uint256 premiumDebt = premiumDebtRay.fromRayUp(); if (amount >= drawnDebt + premiumDebt) { return (drawnDebt, premiumDebtRay); } if (amount < premiumDebt) { // amount.toRay() cannot overflow here uint256 amountRay = amount.toRay(); return (0, amountRay); } return (amount - premiumDebt, premiumDebtRay); } ``` Repay 的核心是调用 Hub 的 `restore` 函数,该函数参数内包括 `premiumDelta` 参数,因为用户偿还债务时,会偿还 Premium 部分。Spoke 的 repay 函数使用了如下方法计算 `premiumDelta`,然后调用 `restore` 函数通知 Hub 处理: ```solidity (uint256 drawnDebtRestored, uint256 premiumDebtRayRestored) = userPosition .calculateRestoreAmount(drawnIndex, amount); uint256 restoredShares = drawnDebtRestored.rayDivDown(drawnIndex); IHubBase.PremiumDelta memory premiumDelta = userPosition.getPremiumDelta({ drawnSharesTaken: restoredShares, drawnIndex: drawnIndex, riskPremium: _positionStatus[onBehalfOf].riskPremium, restoredPremiumRay: premiumDebtRayRestored }); uint256 totalDebtRestored = drawnDebtRestored + premiumDebtRayRestored.fromRayUp(); IERC20(reserve.underlying).safeTransferFrom( msg.sender, address(reserve.hub), totalDebtRestored ); reserve.hub.restore(reserve.assetId, drawnDebtRestored, premiumDelta); ``` 在 repay 函数的最后部分,我们会进行一些 Spoke 状态更新工作,第一个是将 `premiumDelta` 和 `restoredShares` 用于 Spoke 内记录的用户头寸,第二个是假如发现用户已偿还了所有债务,更新 `positionStatus` 内的数据: ```solidity userPosition.applyPremiumDelta(premiumDelta); userPosition.drawnShares -= restoredShares.toUint120(); if (userPosition.drawnShares == 0) { PositionStatus storage positionStatus = _positionStatus[onBehalfOf]; positionStatus.setBorrowing(reserveId, false); } emit Repay(reserveId, msg.sender, onBehalfOf, restoredShares, totalDebtRestored, premiumDelta); ``` 有趣的是,repay 函数不会使用 `_refreshAndValidateUserAccountData` 重新计算账户的 risk premium。假如用户认为 repay 后,自己的 risk premium 降低,那么此时用户需要自己调用 `updateUserRiskPremium` 来更新相关数据。 最后,对于基础行为,我们只有 `borrow` 没有进行介绍。对于 `borrow` 函数,其实现相对简单,核心是利用 `_refreshAndValidateUserAccountData` 判断当前的借出资产是否符合要求,读者可以自行阅读相关代码。 ## 清算 清算是借贷协议内最复杂的部分,清算部分往往也是借贷协议内体量最大的代码。在 AAVE v4 内,由于清算部分的体量过于庞大,所以清算部分的代码是作为一个 public library 存在的,所有清算有关的代码都位于 `LiquidationLogic` 内部。在本节中,我们介绍清算的具体环节和流程,特别是 AAVE v4 如何处理粉尘问题。在 AAVE v3 中,后期多次合约升级都与粉尘有关,在 AAVE v3 内,很多清算都留下微量的债务,这些债务在 gas 成本上不值得被清算,但总体上这些债务存在不断累积的情况,很有可能在累积到某一个阶段后危及总体的健康,所以屏蔽这些粉尘债务成为了 AAVE v3 后续升级的问题。AAVE v4 注意到了该问题,所以对于粉尘处理是清算代码的核心之一。在 AAVE v4 的默认 Spoke 实现内,价值低于 1000 美金的债务都属于粉尘债务。 我们首先分析 `liquidationCall` 的入参,如下: ```solidity function liquidationCall( uint256 collateralReserveId, uint256 debtReserveId, address user, uint256 debtToCover, bool receiveShares ) external { ``` 其中,`collateralReserveId` 代表清算者希望获得的担保品,所以在本节中,所以提到的担保品并不是指头寸的担保品组合而是指清算者指定的某种担保品,而 `debtReserveId` 代表清算者希望清算的债务代币,`user` 指定被清算的地址, `debtToCover` 代表清算者希望清算的债务数量,而 `receiveShares` 代表清算者希望接收的担保品的类型。清算者可以选择以 ERC20 代币的形式直接获得担保品,也可以选择直接获得 Spoke 内的担保品对应的 shares,这种只希望获得 shares 的行为类似 AAVE v3 内,清算者获得 aToken 而不将其转化为底层资产的行为。 我们首先分析 `liquidationCall` 内的清算准备部分的代码,这部分代码主要是将各种数据提取到 `LiquidationLogic.LiquidateUserParams` 结构体内,然后使用该结构体调用 `LiquidationLogic` 库内的 `liquidateUser` 函数。这部分代码如下: ```solidity Reserve storage collateralReserve = _getReserve(collateralReserveId); Reserve storage debtReserve = _getReserve(debtReserveId); DynamicReserveConfig storage collateralDynConfig = _dynamicConfig[collateralReserveId][ _userPositions[user][collateralReserveId].dynamicConfigKey ]; UserAccountData memory userAccountData = _calculateUserAccountData(user); uint256 drawnIndex = debtReserve.hub.getAssetDrawnIndex(debtReserve.assetId); (uint256 drawnDebt, uint256 premiumDebtRay) = _userPositions[user][debtReserveId].getDebt( drawnIndex ); LiquidationLogic.LiquidateUserParams memory params = LiquidationLogic.LiquidateUserParams({ collateralReserveId: collateralReserveId, debtReserveId: debtReserveId, oracle: ORACLE, user: user, debtToCover: debtToCover, healthFactor: userAccountData.healthFactor, drawnDebt: drawnDebt, premiumDebtRay: premiumDebtRay, drawnIndex: drawnIndex, totalDebtValue: userAccountData.totalDebtValue, activeCollateralCount: userAccountData.activeCollateralCount, borrowedCount: userAccountData.borrowedCount, liquidator: msg.sender, receiveShares: receiveShares }); ``` 我们可以看到在清算中,我们会服从用户头寸(UserPosition) 内记录的 `dynamicConfigKey` 在 `_dynamicConfig` 内提取配置信息,然后我们会调用 `_calculateUserAccountData` 计算出 `UserAccountData`,该结构体在上文已有所介绍。实际上,`_calculateUserAccountData` 底层就是调用了上文介绍的 `_processUserAccountData` 函数,此处不再赘述。其他部分计算了用户的 Drawn Debt 和 Premium Debt,这些代码也都在上文有所介绍。 在获得 `params` 参数后,我们会将该参数与存储指针作为参数调用 `liquidateUser` 函数,该函数会执行复杂的逻辑,这些逻辑是我们接下来介绍的重点。 ```solidity bool isUserInDeficit = LiquidationLogic.liquidateUser( collateralReserve, debtReserve, _userPositions, _positionStatus, _liquidationConfig, collateralDynConfig, params ); ``` `liquidateUser` 函数内主要按顺序进行了以下操作: 1. 调用 `_validateLiquidationCall` 验证当前是否可以清算,核心是验证健康因子满足要求,以及一些资产的配置是否允许清算 2. 调用 `_calculateLiquidationAmounts` 计算当前清算的各种数量,包括 `debtToLiquidate` 清算的债务(drawn debt 与 premium debt)、`collateralToLiquidate` 清算过程中担保品的数量、`collateralToLiquidator` 发松给清算者的担保品数量。`collateralToLiquidate` 与 `collateralToLiquidator` 存在的差值与清算手续费(`liquidationFee`)有关,这部分的费用会使用 Hub 的 `payFeeShares` 发送给接受手续费的 Spoke 3. 调用 `_liquidateCollateral` 函数向清算者支付或者划转担保品,具体取决于 `receiveShares` 的设置 4. 调用 `_liquidateDebt` 函数清算债务,从清算者地址内将债务代币划转到 Hub 内部,并更新状态 5. 调用 `_evaluateDeficit` 评估债务的坏账情况,假如发现当前被清算的头寸已经不包含任何担保品,那么当前头寸内存在的债务会被记为坏账损失 对于 `_validateLiquidationCall` 函数,限于篇幅,本节不再介绍。读者可以自行阅读,内部都是一些 `require` 条件判断,逻辑较为简单。 `_calculateLiquidationAmounts` 函数是本节重点介绍的函数,该函数内部较为复杂,其逻辑主要可以被视为以下操作: 1. 调用 `calculateLiquidationBonus` 函数计算清算激励,我们会在后文马上给出清算激励计算的数学公式 2. 调用 `_calculateDebtToLiquidate` 计算可供清算的债务,这也是一个复杂函数,其内部会基于清算到目标健康度所需要清算的债务数量以及残留的债务是否属于粉尘债务,假如用户残留的债务属于粉尘债务,那么会允许清算者额外清算掉粉尘债务 3. 计算当前清算者指定的担保品可以覆盖的债务数量,并以此调整上一步计算出的债务数量 4. 使用 `liquidationFee` 计算出清算者可以获得的担保品数量 `collateralToLiquidator` 我们首先讨论 AAVE v4 内的清算激励(Liquidation Bonus),所谓清算激励指的是清算者可以获得稍多于清算债务的担保品,比如清算 100 美金的债务,但清算者可以获得价值 101 美金的担保品,此处多的 1 美金被称为清算激励。在 AAVE v4 内使用了一套公式计算清算激励。简单来说,当用户头寸的 Health Factor 越低时,清算者获得的清算激励越多。 我们先给出最小清算激励的计算公式: $$ \text{minLB} = (\text{maxLB} - 100\%) \times \text{lbFactor} + 100\% $$ 此处的 $\text{maxLB}$ 和 $\text{lbFactor}$ 都是由 Spoke 管理者配置的,分别对应 `maxLiquidationBonus` 和 `liquidationBonusFactor` 参数。举例说明,在 $\text{maxLB} = 103 \%$ 的情况下,$\text{lbFactor} = 50\%$ 意味着清算者最小获得 $101.5\%$ 的担保品。 在清算激励计算中,我们还会使用一个参数被称为 `maxLiquidationBonus`,该参数代表获得最大激励 `maxLiquidationBonus` 时的最低健康度数值。 $$ \text{lb} = \begin{cases} \text{maxLB} & \text{if } \text{HF} \le \text{hfForMaxBonus} \\ \text{minLB} + (\text{maxLB} - \text{minLB}) \times \frac{\text{LIQ\_THRESHOLD} - \text{HF}}{\text{LIQ\_THRESHOLD} - \text{hfForMaxBonus}} & \text{if } \text{HF} > \text{hfForMaxBonus} \end{cases} $$ 此处的 $\text{LIQ\_THRESHOLD}$ 指的是清算阈值,一旦低于此阈值,用户的头寸就可以被清算,此时清算后清算者只可以获得最低的清算激励 $\text{minLB}$,但随着用户头寸健康度的进一步下降,上述计算出的清算激励会越来越大,直到健康度低于 $\text{hfForMaxBonus}$,即上文给出的 `maxLiquidationBonus` 参数后,清算者可以获得最大水平的清算激励。具体代码实现如下: ```solidity function calculateLiquidationBonus( uint256 healthFactorForMaxBonus, uint256 liquidationBonusFactor, uint256 healthFactor, uint256 maxLiquidationBonus ) internal pure returns (uint256) { if (healthFactor <= healthFactorForMaxBonus) { return maxLiquidationBonus; } uint256 minLiquidationBonus = (maxLiquidationBonus - PercentageMath.PERCENTAGE_FACTOR) .percentMulDown(liquidationBonusFactor) + PercentageMath.PERCENTAGE_FACTOR; // linear interpolation between min and max // denominator cannot be zero as healthFactorForMaxBonus is always < HEALTH_FACTOR_LIQUIDATION_THRESHOLD return minLiquidationBonus + (maxLiquidationBonus - minLiquidationBonus).mulDivDown( HEALTH_FACTOR_LIQUIDATION_THRESHOLD - healthFactor, HEALTH_FACTOR_LIQUIDATION_THRESHOLD - healthFactorForMaxBonus ); } ``` 在有了清算激励后,我们开始着手分析 `_calculateDebtToLiquidate` 函数,该函数内部会首先使用 `_calculateDebtToTargetHealthFactor` 计算清算到目标健康度(`targetHealthFactor`),清算者所需要偿还的债务。为了优化读者阅读体验,我们先暂时跳过该函数的分析,我们继续分析 `_calculateDebtToLiquidate` 后续用于避免粉尘债务的代码。简单来说,当用户剩余的债务低于某一个阈值(默认为 1000 USD),那么我们会允许清算者清算债务数量增加: ```solidity if (debtToTarget < debtToLiquidate) { debtToLiquidate = debtToTarget; } bool leavesDebtDust = debtToLiquidate < params.debtReserveBalance && (params.debtReserveBalance - debtToLiquidate).mulDivDown( params.debtAssetPrice.toWad(), params.debtAssetUnit ) < DUST_LIQUIDATION_THRESHOLD; if (leavesDebtDust) { // target health factor is bypassed to prevent leaving dust debtToLiquidate = params.debtReserveBalance; } return debtToLiquidate; ``` 上述代码内 `debtToTarget < debtToLiquidate` 分支是为了避免清算债务数量大于用户在清算时给定的 `debtToCover`。然后我们判断清算后剩余的债务是否属于粉尘债务,注意假如清算者可以清算全部债务,那么就不需要粉尘债务的判定。假如存在粉尘债务,那么我们就额外允许清算者多清算一部分债务,换言之,允许清算者清算掉所有的债务。 回到上文没有介绍的 `_calculateDebtToTargetHealthFactor` 函数,我们首先推导清算到目标健康度需要偿还债务的数量数学公式。假如清算者选择偿还 $\Delta D$ 部分债务,那么清算后,Health Factor 的计算如下: $$ \text{HF}_{\text{target}} = \frac{(\text{Collateral Value} - \Delta D \times LB) \times CF}{D - \Delta D} $$ 此处的 $LB$ 指的是 Liquidation Bonus,即上文介绍的清算激励。而 $CF$ 指的是 Collateral Factor,即上文介绍的 `collateralFactor` 代表担保品的折价情况。我们的目标是求解出上述 $\Delta D$ 的公式: $$ \begin{align*} \text{HF}_{\text{target}}({D - \Delta D}) &= \text{Collateral Value} \times CF - \Delta D \times LB \times CF\\ \text{HF}_{\text{target}}D - \text{HF}_{\text{target}}\Delta D &= \text{HF}_{\text{current}}D - \Delta D \times LB \times CF\\ \Delta D \times LB \times CF - \text{HF}_{\text{target}}\Delta D &= (\text{HF}_{\text{current}} - \text{HF}_{\text{target}})D\\ \Delta D &= \frac{(\text{HF}_{\text{target}} - \text{HF}_{\text{current}}) D}{\text{HF}_{\text{target}} - LB \times CF} \end{align*} $$ 由此,我们获得如下代码: ```solidity function _calculateDebtToTargetHealthFactor( CalculateDebtToTargetHealthFactorParams memory params ) internal pure returns (uint256) { uint256 liquidationPenalty = params.liquidationBonus.bpsToWad().percentMulUp( params.collateralFactor ); // denominator cannot be zero as `liquidationPenalty` is always < PercentageMath.PERCENTAGE_FACTOR // `liquidationBonus.percentMulUp(collateralFactor) < PercentageMath.PERCENTAGE_FACTOR` is enforced in `_validateDynamicReserveConfig` // and targetHealthFactor is always >= HEALTH_FACTOR_LIQUIDATION_THRESHOLD return params.totalDebtValue.mulDivUp( params.debtAssetUnit * (params.targetHealthFactor - params.healthFactor), (params.targetHealthFactor - liquidationPenalty) * params.debtAssetPrice.toWad() ); } ``` 至此,我们就完成了 `_calculateDebtToLiquidate` 的代码分析,继续回到 `_calculateLiquidationAmounts` 函数的分析,下一步我们需要根据用户担保品数量调整 `_calculateDebtToLiquidate` 返回的 `debtToLiquidate` 变量。这一步是为了避免清算超过担保品额度的债务的情况。所以第一步我们是计算用户的担保价值(`collateralToLiquidate`),此处也需要注意粉尘检查(`leavesCollateralDust`),假如清算后担保品的余额较低,那么类似清算债务时的粉尘处理,我们会允许清算者清算更多债务(前提是当前清算者未清算所有债务,假如清算者清算了所有债务,那么担保品粉尘只能保留): ```solidity uint256 collateralToLiquidate = debtToLiquidate.mulDivDown( params.debtAssetPrice * params.collateralAssetUnit * liquidationBonus, params.debtAssetUnit * params.collateralAssetPrice * PercentageMath.PERCENTAGE_FACTOR ); bool leavesCollateralDust = collateralToLiquidate < params.collateralReserveBalance && (params.collateralReserveBalance - collateralToLiquidate).mulDivDown( params.collateralAssetPrice.toWad(), params.collateralAssetUnit ) < DUST_LIQUIDATION_THRESHOLD; if ( collateralToLiquidate > params.collateralReserveBalance || (leavesCollateralDust && debtToLiquidate < params.debtReserveBalance) ) { collateralToLiquidate = params.collateralReserveBalance; debtToLiquidate = collateralToLiquidate.mulDivUp( params.collateralAssetPrice * params.debtAssetUnit * PercentageMath.PERCENTAGE_FACTOR, params.debtAssetPrice * params.collateralAssetUnit * liquidationBonus ); } // revert if the liquidator does not cover the necessary debt to prevent dust from remaining require(params.debtToCover >= debtToLiquidate, ISpoke.MustNotLeaveDust()); ``` 我们可以看到上述环节中的调整会发生在以下两种情况下: 1. `collateralToLiquidate > params.collateralReserveBalance` 需要清算的担保品数量大于用户的担保品余额 2. 在 `leavesCollateralDust` 情况下,我们需要清算用户的全部担保品头寸,但是显然清算所有担保品的前提是用户存在更多债务以供清算,所以此处额外施加了 `debtToLiquidate < params.debtReserveBalance` 条件 上述调整的方法都是直接按照用户当前担保品余额 `collateralReserveBalance` 计算清算债务的数量。最后我们需要检查清算的债务数量是否超过了清算者预期(`params.debtToCover >= debtToLiquidate`)。 最后,我们进入了 `_calculateLiquidationAmounts` 最后环节,在该环节中我们需要处理手续费问题。此处我们需要注意,我们的手续费主要针对清算奖励(`liquidationBonus` 部分)进行征收。我们使用 `collateralToLiquidate` 代表清算的总担保品数量,而 `collateralToLiquidator` 代表真正支付给清算者的资金,注意支付给清算者的资金需要扣除手续费(`liquidationFee`)。我们可以获得如下计算方法,其中等式两侧计算的都是手续费的数值: $$ \text{collateralToLiquidate} - \text{collateralToLiquidator} = \text{collateralToLiquidate}\times\frac{(\text{LB} - 1)\times\text{liquidationFee}}{\text{LB}} $$ 由此,我们可以获得 `collateralToLiquidator` 的计算方法: ``` uint256 collateralToLiquidator = collateralToLiquidate - collateralToLiquidate.mulDivDown( params.liquidationFee * (liquidationBonus - PercentageMath.PERCENTAGE_FACTOR), liquidationBonus * PercentageMath.PERCENTAGE_FACTOR ); ``` 接下来,我们回到清算流程的分析,当完成清算债务和担保品的数量计算后,我们会真正的对担保品和债务进行清算。担保品的清算使用了 `_liquidateCollateral` 函数。该函数的执行流程较为简单,我们可以看到假如清算者没有选择 `receiveShares` 模式,那么我们会使用 `hub.remove` 为清算者发送资产,在 Hub 内流动性稀缺情况下,该调用会触发 `InsufficientLiquidity` 错误,所以在流动性稀缺的极端情况下,使用 `receiveShares` 模式可以保证清算成功。 ```solidity function _liquidateCollateral( ISpoke.Reserve storage collateralReserve, ISpoke.UserPosition storage collateralPosition, ISpoke.UserPosition storage liquidatorCollateralPosition, LiquidateCollateralParams memory params ) internal returns (uint256, uint256, bool) { IHubBase hub = collateralReserve.hub; uint256 assetId = collateralReserve.assetId; uint256 sharesToLiquidate = hub.previewRemoveByAssets(assetId, params.collateralToLiquidate); uint120 userSuppliedShares = collateralPosition.suppliedShares - sharesToLiquidate.toUint120(); uint256 sharesToLiquidator; if (params.collateralToLiquidator > 0) { if (params.receiveShares) { sharesToLiquidator = hub.previewAddByAssets(assetId, params.collateralToLiquidator); if (sharesToLiquidator > 0) { liquidatorCollateralPosition.suppliedShares += sharesToLiquidator.toUint120(); } } else { sharesToLiquidator = hub.remove(assetId, params.collateralToLiquidator, params.liquidator); } } collateralPosition.suppliedShares = userSuppliedShares; if (sharesToLiquidate > sharesToLiquidator) { hub.payFeeShares(assetId, sharesToLiquidate.uncheckedSub(sharesToLiquidator)); } return (sharesToLiquidate, sharesToLiquidator, userSuppliedShares == 0); } ``` 额外注意的是 `_liquidateCollateral` 返回值内包含一个代表当前被清算的头寸是否还存在担保品的布尔值,我们会使用该布尔值用于判断被清算头寸是否存在坏账,我们会在清算流程的最后的评估坏账环节使用该变量。 债务清算使用 `_liquidateDebt` 函数,该函数也很简单,逻辑与 Spoke 内的 `repay` 函数基本是一致的。第一步确定清算的 premium debt 和 drawn debt 的数量,第二步给出 `premiumDelta` 结构体,此处我们直接使用了 `positionStatus` 内的 `riskPremium`,这是因为在清算前,清算函数中的 `_calculateUserAccountData` 内已经刷新了 `riskPremium` 数值,所以此处可以直接使用。此处也会返回一个布尔值代表头寸内的债务是否被清空,该布尔值会被用于最后的坏账评估。 ```solidity function _liquidateDebt( ISpoke.Reserve storage debtReserve, ISpoke.UserPosition storage debtPosition, ISpoke.PositionStatus storage positionStatus, LiquidateDebtParams memory params ) internal returns (uint256, IHubBase.PremiumDelta memory, bool) { uint256 premiumDebtToLiquidateRay = params.debtToLiquidate.toRay().min(params.premiumDebtRay); uint256 drawnDebtLiquidated = params.debtToLiquidate - premiumDebtToLiquidateRay.fromRayUp(); uint256 drawnSharesLiquidated = drawnDebtLiquidated.rayDivDown(params.drawnIndex); IHubBase.PremiumDelta memory premiumDelta = debtPosition.getPremiumDelta({ drawnSharesTaken: drawnSharesLiquidated, drawnIndex: params.drawnIndex, riskPremium: positionStatus.riskPremium, restoredPremiumRay: premiumDebtToLiquidateRay }); IERC20(debtReserve.underlying).safeTransferFrom( params.liquidator, address(debtReserve.hub), params.debtToLiquidate ); debtReserve.hub.restore(debtReserve.assetId, drawnDebtLiquidated, premiumDelta); debtPosition.applyPremiumDelta(premiumDelta); debtPosition.drawnShares -= drawnSharesLiquidated.toUint120(); if (debtPosition.drawnShares == 0) { positionStatus.setBorrowing(params.debtReserveId, false); return (drawnSharesLiquidated, premiumDelta, true); } return (drawnSharesLiquidated, premiumDelta, false); } ``` 最后,在清算的最后环节,我们会处理坏账问题,坏账的处理一部分位于 `LiquidationLogic.liquidateUser` 内,另一部分位于 `liquidationCall` 内部,其中 `LiquidationLogic.liquidateUser` 内主要使用 `_evaluateDeficit` 判断头寸内是否存在坏账,而 Spoke 内的 `liquidationCall` 执行最终的 `_reportDeficit` 操作。 我们首先关注 `_evaluateDeficit` 函数,该函数十分简单,其本质逻辑是存在担保品那么意味着一定没有坏账,假如担保品不存在了还存在债务那么一定有坏账。我们可以通过 `!isCollateralPositionEmpty || activeCollateralCount > 1` 判断担保品是否存在,其中 `isCollateralPositionEmpty` 是 `_liquidateCollateral` 函数的返回值。我们也可以通过 `!isDebtPositionEmpty || borrowedCount > 1` 判断债务是否存在。 ```solidity /// @notice Returns if the liquidation results in deficit. function _evaluateDeficit( bool isCollateralPositionEmpty, bool isDebtPositionEmpty, uint256 activeCollateralCount, uint256 borrowedCount ) internal pure returns (bool) { if (!isCollateralPositionEmpty || activeCollateralCount > 1) { return false; } return !isDebtPositionEmpty || borrowedCount > 1; } ``` 至此,我们就完成了 `LiquidationLogic.liquidateUser` 的代码分析,接下来,Spoke 内的剩余代码在出现坏账情况下会完成坏账的报告,假如没有坏账,那么会更新用户头寸的 risk premium。最后,我们会执行 `_notifyRiskPremiumUpdate` 上报 Risk Premium 的变化情况。 ```solidity bool isUserInDeficit = LiquidationLogic.liquidateUser( collateralReserve, debtReserve, _userPositions, _positionStatus, _liquidationConfig, collateralDynConfig, params ); uint256 newRiskPremium = 0; if (isUserInDeficit) { _reportDeficit(user); } else { newRiskPremium = _calculateUserAccountData(user).riskPremium; } _notifyRiskPremiumUpdate(user, newRiskPremium); ``` `_reportDeficit` 核心代码是迭代当前头寸内所有被借出的资产,然后逐一报告坏账。由于此时用户的担保品已经全部消失,所以此处直接使用了数值为 0 的 `riskPremium`。但我们注意到此处并没有修改 `positionStatus.riskPremium`,因为该任务会被 `_notifyRiskPremiumUpdate` 执行。 ```solidity function _reportDeficit(address user) internal { PositionStatus storage positionStatus = _positionStatus[user]; uint256 reserveId = _reserveCount; while ((reserveId = positionStatus.nextBorrowing(reserveId)) != PositionStatusMap.NOT_FOUND) { UserPosition storage userPosition = _userPositions[user][reserveId]; Reserve storage reserve = _reserves[reserveId]; IHubBase hub = reserve.hub; uint256 assetId = reserve.assetId; uint256 drawnIndex = hub.getAssetDrawnIndex(assetId); (uint256 drawnDebtReported, uint256 premiumDebtRay) = userPosition.getDebt(drawnIndex); uint256 deficitShares = drawnDebtReported.rayDivDown(drawnIndex); IHubBase.PremiumDelta memory premiumDelta = userPosition.getPremiumDelta({ drawnSharesTaken: deficitShares, drawnIndex: drawnIndex, riskPremium: 0, restoredPremiumRay: premiumDebtRay }); hub.reportDeficit(assetId, drawnDebtReported, premiumDelta); userPosition.applyPremiumDelta(premiumDelta); userPosition.drawnShares -= deficitShares.toUint120(); positionStatus.setBorrowing(reserveId, false); emit ReportDeficit(reserveId, user, deficitShares, premiumDelta); } } ``` 至此,我们完成了清算逻辑的完整分析。本节没有介绍清算的配置问题,这部分代码读者可以自行阅读,或者阅读文档。 ## 总结 在本文中,我们介绍了 Hub-Spoke 分离架构、Premium 机制、Dynamic Config 机制以及清算行为的具体逻辑。本文忽略了大量非核心逻辑,比如各种配置的更新等。另外,本文也没有介绍和 Spoke 交互的 Position Manager 系统,对于这些代码,读者可以自行阅读。