跟踪贷方存款的直观方法是记录他们存入的 USDC 数量以及存款时间。Compound V3 并没有这样做。
相反,与 SushiSwap Masterchef Staking Algorithm 类似,Compound V3 跟踪自“创世之初”借出一美元的假设收益。(还不熟悉此算法的读者应阅读链接中的资源)。
自创世之初借出一美元的假设收益通过 baseSupplyIndex (位于 CometStorage.sol 中) 进行跟踪。它的行为与 Sushiswap 中的“rewardPerTokenAccumulator”非常相似。它从 1.0 开始,每当发生改变状态的操作(存款、取款、借款等)时,它都会按经过的时间和该期间的利率成比例增加。例如,如果经过了 100 秒,且每秒利率为 0.001(不切实际地高,但便于推理),那么 baseSupplyIndex 将更新为 1.1。具体来说,使用了以下公式:
baseSupplyIndex += supplyInterestRatePerSecond(utilization) × secondsElapsed
baseSupplyIndex 发生改变的唯一地方是在 Comet.sol 的第 403 行,位于 accruedInterestIndices 内部。

由于利率是资金利用率(utilization)的直接函数,因此 baseSupplyIndex 在高利用率时期增长得更快,而在低利用率时期增长得较慢。
下面的假设图表展示了 baseSupplyIndex 和 baseBorrowIndex 根据资金利用率以不同的速度增长。通常,借方支付的利息高于贷方获得的利息,因此 baseBorrowIndex 的增长速度更快。

以下示例说明了如何使用该变量。
示例与术语
Alice 在 baseSupplyIndex 为 2.5 时存入了 1,000 美元。系统并没有记录她存入了 1,000 美元,而是记录她存入了 400 美元,即她的存款除以当前的 baseSupplyIndex(1,000 美元 ÷ 2.5)。Alice 的账户中拥有 400 美元的“本金价值(principal value)”(黄框)。这是 Compound 在 CometStorage.sol 中为用户存储的值。

如果她立刻取款,Compound 会将她的余额计算为 400 美元乘以当前的 baseSupplyIndex(即 2.5),因此她将提取 1,000 美元。Compound 将本金价值乘以 baseSupplyIndex 称为“现值(present value)”。
Compound V3 并不“记得”她最初的实际存款是 1,000 美元。这是一种**隐式**的推导,因为当前的 baseSupplyIndex 为 2.5,而她的存款被记录为 400 美元。
Compound V3 存储的“缩减”或“向下缩放(scaled backward)”的美元价值被称为“本金价值(principal value)”。当我们将本金价值乘以当前的 baseSupplyIndex,即“向上缩放(scale forward)”时,我们得到了“现值(present value)”。
拥有传统金融背景的读者可能会觉得 Compound 对“本金价值”和“现值”这两个术语的使用感到困惑——我们建议不要试图将这些术语与它们的传统含义联系起来,直接接受 Compound 的用法即可。
如果她等到 baseSupplyIndex 增加到 3.0,本金价值仍将是 400 美元,但现值将增加到 1,200 美元(400 美元 x 3.0 = 1,200)。

在 CometCore.sol 中,我们可以看到:
-
“本金价值”是通过将“现值”除以
baseSupplyIndex计算得出的 -
“现值”是通过将“本金价值”乘以
baseSupplyIndex计算得出的。

总结这些重要术语:
本金价值
本金价值是指存入的 USDC 除以存款时的 baseSupplyIndex 值。它被保存在与用户账户关联的存储变量中,除非用户存款或取款,否则该值不会改变。本金价值通常小于实际存款,因为 baseSupplyIndex 总是大于或等于 1。
现值
现值是本金价值乘以 baseSupplyIndex 的**当前值**。这个值不会存储在任何地方,而是即时计算的。
本金价值和现值是理解 Compound V3 最关键的两个概念。
针对贷方跟踪的唯一变量是 Principal
让我们回顾一下上面截图中的结构体:

变量 baseTrackingAccrued 被 CometRewards 用来决定应奖励该账户多少 COMP 以表彰其参与协议。变量 baseTrackingIndex 与此相关,但目前未使用。变量 assetsIn 仅供借方使用,作为是否使用了某些抵押资产的指示器。变量 _reserved 未被使用。
因此,对于贷方记账来说,principal 是唯一必不可少的变量。请注意,这是一个有符号变量——对于借方,它是负数。对于借方,我们还需要跟踪他们存入了多少抵押品(collateral),但这将是另一篇文章的内容。
balanceOf() — 检查贷方的正余额
为了说明 Compound V3 存储的是本金价值,但以现值评估账户余额,请考虑下面所示的 Comet.sol 中的 balanceOf() 函数。
首先,它将读取更新后的 baseSupplyIndex 而不进行更新,因为这是一个 view 函数。然后,它读取出贷方的本金余额,并将其乘以 baseSupplyIndex。
应计利息是时间和资金利用率的函数,因此只要利用率不为零,每次查询 balanceOf 时,它都会返回一个更高的值。

以下截图展示了贷方在 Compound V3 dapp 上的余额与同一地址在 Etherscan 上调用 balanceOf 返回值之间的关系。这两个值都在橙色框中高亮显示。

baseSupplyIndex 的图解示例
当贷方存款时,其存款的 USDC 余额会被除以 baseSupplyIndex,以得出将要被存储的本金价值。他们存款的时间越晚,baseSupplyIndex 就会越大,记入他们账户的本金价值也就越低。
本金价值是静态的(除非他们进行存款或取款),但现值是本金价值 * baseSupplyIndex,而这个值在不断增加。
在下图中,Alice 在 baseSupplyIndex 等于 1.01 时存入了 1 美元,因此她的本金价值是 0.99(1 ÷ 1.01)。Bob 同样存入了 1 美元,但时间较晚,此时 baseSupplyIndex 的值为 1.03(1 ÷ 0.97)。因此,他的本金价值为 0.97。
Bob 和 Alice 两人都存入了相同数量的金额:1 美元。但因为 Bob 存款较晚,他的本金价值相对较低。

supplyBase() 与 withdrawBase()
当用户存入(或提取)基础资产时,本金价值会被重置。例如,如果 Alice 在 baseSupplyIndex 为 10 时存入了 10 美元,她的本金价值将是 1 美元。现在假设当 baseSupplyIndex 增加到 20 时,她账户的现值将变为 20 美元。她又追加了 10 美元存款。她新的_现值_应该为 30 美元。
那么她的本金价值应该是多少?(稍微思考一下!)
目前的 baseSupplyIndex 为 20,现值为 30 美元,所以她的本金价值应该是 1.5 美元(30 美元 ÷ 20)。
记住这个示例后,我们来看看在贷方存款时被调用的 Comet.sol 第 829 行中的 supplyBase() 函数。

当 Alice 存款时,我们读取她的本金价值(橙色框),将其转换为现值(绿色框),并将刚刚存入的金额加到现值上(蓝色框)。然后将该现值转换回本金价值并存储在 dstPrincipalNew(黄色框)中,最终将其保存为该用户新的本金价值(红色框)。
上方图中的 updateBasePrincipal() 函数(红色框)仅仅更新与用户相关的 UserBasic 结构体,用 dstNewPrincipal 覆盖旧的本金。
对应的相反函数,即 withdrawBase()(Comet.sol 第 1051 行),以相反的逻辑应用相同的原理。
那么 baseBorrowIndex 呢?
与 baseSupplyIndex 跟踪自创世之初借出一美元的收益类似,baseBorrowIndex 跟踪自创世之初借入一美元的债务累积情况。贷方和借方有不同的利率曲线,因此需要两个独立的变量来分别跟踪。
指数何时会溢出?
baseSupplyIndex 和 baseBorrowIndex 均被视作具有 15 位小数的定点数(fixed point numbers),因此 1e15 被视作 1.0。有符号的 104 位数字所能容纳的最大数字为 1.014e31。因此,累加器所能容纳的最大数字为 1.014e16(带有 15 位小数)。
假设借贷协议的年化利率(APR)永远不会超过 100%。那么该指数需要 53 年才会溢出。如果使用更符合实际的 10% 利率,一美元复利增长到 10 万亿美元需要 386 年。
完全有理由相信,在那一天到来之前,Compound 会先升级到 V4 版本。
在 RareSkills 了解更多
请查看我们的 Solidity Bootcamp 以了解更高级的 Solidity 概念。
初次发布于 2024 年 1 月 5 日