Compound 以 COMP 代币的形式向贷款人和借款人发放奖励,奖励比例取决于他们在市场借贷中所占的份额。
该算法与 MasterChef 质押算法 非常相似,因此读者应该先熟悉该算法。
Compound V3 奖励机制的高层级概览
与 MasterChef 类似,Compound V3 奖励合约会追踪假设的 1 个“质押的” USDC 从最初到现在赚取了多少收益。这里的“质押”可以指借入或贷出——这两种活动都会获得奖励。
类似于 Compound V3 的 baseSupplyIndex 或 MasterChef 的 rewardPerTokenAcc,Compound 奖励机制拥有 trackingSupplyIndex 和 trackingBorrowIndex,用于追踪 1 个 USDC(贷出或借入)从最初到现在所产生的奖励。
像 MasterChef 一样,当更多的 USDC 被“质押”时,单个 USDC 收取的奖励数量就会被“稀释”,反之亦然。
与 MasterChef 不同的是,单位时间的奖励是由不可变变量 baseSupplyTrackingSupplySpeed 和 baseTrackingBorrowSpeed 设置的。治理层可以选择“重新缩放(rescale)”所分配的奖励。
用户从 Comet Rewards(一个独立于主 Comet 借贷合约的单独合约)中领取奖励。
如果总借入量或总贷出量低于特定阈值,Compound 将不会发放奖励,具体原因我们稍后讨论。
与 MasterChef 一样,奖励不会自动分配,必须在单独的交易中领取。下面的截图展示了领取累积 COMP 代币的前端界面,在黄色圆圈中是领取代币的操作。

Comet Rewards 合约需要不时地进行补充
Comet Rewards 合约不铸造 COMP 代币,它依赖治理层向其转移代币。流通中共有 1000 万个 COMP 代币的总供应量,且所有代币已被铸造完毕。很大一部分供应量由治理层持有。COMP 代币会定期从治理金库转移到奖励合约中。你可以看到以下“补充”奖励合约的治理交易。
https://compound.finance/governance/proposals/194(2023 年 11 月 21 日)
https://compound.finance/governance/proposals/164(2023 年 6 月 29 日)
奖励合约的主网地址是
0x1B0e765F6224C21223AeA2af16c1C46E38885a40
由于供应量固定,除非治理层在公开市场上购买 COMP 代币,否则针对生态系统参与的 COMP 奖励无法无限期地持续下去。
在下面的 Etherscan 截图中,我们看到该合约的大多数交易都是为了领取 COMP 代币(蓝色方框),并且该合约目前持有约 73,000 个 COMP 代币(蓝色箭头)。

trackingSupplyIndex 和 trackingBorrowIndex 的行为类似于 rewardPerTokenAcc
大家对下面的图表应该在 MasterChef 中很熟悉了。被“质押”的 USDC 越多,每个代币获得的奖励就越少,因为每个周期只发放固定数量的奖励(由 trackingSupplyIndex 和 trackingBorrowIndex 决定)。
一个值得注意的变化是,如果贷出或借入的 USDC 数量(粉色线条)低于 baseMinForRewards(红色文字和虚线)阈值,那么 USDC 将不会累积奖励,并且 trackingSupplyIndex(或 trackingBorrowIndex)在状态更新时也不会增加。

这些变量不是公开(public)的,但可以通过 CometExt 中的 totalsBasic() 公开函数获取。由于 CometExt 是一个独立的合约,Comet 向其发出 delegatecall,因此我们无法通过 Etherscan 获取这些值。相反,我们使用 Foundry 中的 cast 来获取它们,如下面的截图所示。

baseMinForRewards
baseMinForRewards 定义在 Comet.sol 的第 86 行

如果贷出金额低于 100 万美元(由于 USDC 有 6 位小数,即 1e12 USDC),Compound 不会向贷款人或借款人发放奖励。同样,如果借出金额低于 100 万美元,被借入的 USDC 也不会累积 COMP 奖励。

baseMinRewards 存在的意义是为了防止累加器溢出
回想一下,每个代币累积的奖励与质押代币的数量成反比。如果质押的代币总供应量很小,那么累加器将会快速累加,可能会导致溢出过快。
如果你是一名审计员,这可能是一个容易被忽视的中危漏洞,因为测试通常很难捕捉到累加器溢出。你需要确保累加器在几年内都不会溢出,这意味着要么奖励率需要很小,要么质押金额需要很大。
回顾 accrueInternal()
每当调用 accrueInternal() 时,trackingSupplyIndex 和 trackingBorrowIndex 就会被更新。
下面的代码实现了上述部分描述的逻辑。红色方框中的 if 条件可防止当贷出或借入量低于 baseMinForRewards 时 trackingSupplyIndex 或 trackingBorrowIndex 累积更多奖励。baseTrackingSupplySpeed 和 baseTrackingBorrowSpeed(蓝色方框)是不可变变量,因此索引增加的数量仅取决于 timeElapsed 并且与 totalSupplyBase(或 totalBorrowBase)成反比。

你可以将 baseTrackingSupplySpeed 和 baseTrackingBorrowSpeed 视为“单位时间的奖励”。当乘以 timeElapsed 时,就计算出了单个参与的 USDC 所累积的奖励金额。最后,将该结果除以 totalSupplyBase 或 totalBorrowBase 即可根据总金额稀释该 USDC 的奖励。
baseSupplyTrackingSpeed 和 baseTrackingBorrowSpeed
这些变量类似于 MasterChef 的 rewardPerBlock。它们规定了上一节所述的累加器增长的速度。
我们可以从 Comet Etherscan Proxy 合约中获取它们的值。

这两个变量均使用 trackingIndexScale 作为其精度(decimals),根据 Etherscan 的数据,trackingIndexScale 为 1e15:

由于它们是 15 位小数的定点数(fixed point numbers),其值如下:
baseTrackingSupplySpeed = 2.979166666666e-03
baseTrackingBorrowSpeed = 4.414467592592e-03
来自 Comet.sol 的变量定义(包含关于精度的注释)截图如下:

追踪用户级奖励:baseTrackingAccrued 和 baseTrackingIndex
像 MasterChef 一样,当某个账户执行改变状态的交易时,Compound 奖励机制会向该账户累积奖励。并且同样与 MasterChef 类似,用户累积的奖励与他们的余额以及自用户上一次执行状态改变操作以来“指数(index)”或“累加器(accumulator)”的变化量成正比。
让我们再来看一下 user(用户)结构体:

baseTrackingIndex 是 UserBasic 存储结构体上一次更新时 trackingSupplyIndex 或 trackingBorrowIndex 的值,具体取决于账户是贷款人还是借款人。当前的 trackingSupplyIndex(或 trackingBorrowIndex)与用户存储的 baseTrackingIndex 值之间的差额(delta)决定了他们将在该笔交易中累积多少奖励。请参考下面的图表:

每当用户执行将改变其本金的操作时,都会调用内部函数 updateBasePrincipal()。该函数将确定自上次更新以来 trackingSupplyIndex 或 trackingBorrowIndex 发生了多少变化,并相应地将奖励累积到用户的 baseTrackingAccrued 中。该函数如下所示:

总而言之,baseTrackingIndex 是用户上一次更新时的指数值。baseTrackingAccrued 是自用户参与协议以来应付给用户的总奖励,它不考虑过去已被领取的金额,这些已领取的数额会通过奖励合约中追踪的奖励债务(reward debt)进行抵消。
accrualDescaleFactor 是什么?
在上述代码中,我们看到用户累积的奖励除以了 accrualDescaleFactor。
这使得 ETH 和 USDC 能够按照相同的尺度进行追踪。因为 ETH 有 18 位小数,而 USDC 有 6 位小数,所以 ETH 的 baseTrackingAccrued 会除以 1e12,使其有效地具有与 USDC 相同的“小数位数”。这使得 baseTrackingAccrued 能够在同一尺度上追踪这两种资产。
领取奖励
要领取奖励,用户只需调用 CometReward.sol 中的 claim() 函数。rewardsClaimed 映射(红色方框)的行为与 MasterChef 中的 rewardDebt 类似。

shouldAccrue 参数的作用是什么?
如果某人领取奖励是交易中的唯一操作,那么 shouldAccrue(绿色方框)应设为 true。然而,如果它是在调用了其他函数之后进行的,那么其他改变状态的函数调用就已经调用过 accrueAccount(),从而使此处再进行调用变得没有必要。
getRewardAccrued() (CometRewards.sol)
在上述蓝色方框中,getRewardAccrued 决定了向用户支付多少金额。这只需查询 Comet 里的用户结构体中的 baseTrackingAccrued 即可。然后 CometRewards 会将其减去他们的奖励债务(rewardsClaimed),并将差额支付给用户。

COMP 代币本身的怪异之处(Quirks)
奖励合约分发的 COMP 代币 不像大多数 ERC 20 代币那样将余额存储为 uint256,而是存储为 uint96。

如果你尝试 transfer 或 approve 大于 uint96 最大值的金额,交易将会回退(revert)。

在 RareSkills 了解更多
请参阅我们的 Solidity 训练营 了解更多高级智能合约开发知识。
原载于 2024 年 1 月 10 日