Compound V3 本金、现值与利率指数详解

理解用一个增长的乘数指数追踪利息,避免逐账户更新余额的省 Gas 记账机制

6 分钟阅读
Compound V3 本金、现值与利率指数详解

Compound V3 本金、现值与利率指数详解

目录


一、核心思想:追踪假想收益而非逐笔存款

传统做法:每个用户存了多少就记多少,每次计息要更新每个人的余额——成千上万账户,Gas 爆炸。

Compound V3 的做法:不记每个人存了多少,而是维护一个全局增长的”指数”,配合每个用户的”本金”,靠乘法实时算出每个人现在值多少。利息累积只更新一个全局指数,所有用户自动受益。

二、指数系统:一个增长的乘数

核心变量 baseSupplyIndex 代表”自开天辟地以来,借出 1 美元的假想累计收益”。它:

  • 从 1.0 开始,随时间增长;
  • 在状态变更操作时更新(存、取、借);
  • 增长正比于 利率 × 经过的时间。

更新公式:

baseSupplyIndex += 每秒供应利率(利用率) × 经过的秒数

因为利率取决于利用率,指数在高需求期加速增长、低活跃期减速。

三、本金价值 vs 现值

这两个概念是 V3 记账的基石。

3.1 定义

本金价值(Principal Value):存款金额 ÷ 存款当时的 baseSupplyIndex。这是链上唯一为每个用户存储的余额变量。除非用户追加存取,否则它永不改变

现值(Present Value):本金价值 × 当前 baseSupplyIndex。这是账户的实际价值,随利息累积自动增长。它是实时算出来的,不存储

3.2 转换公式

现值   = 本金价值 × 当前 baseSupplyIndex
本金价值 = 现值 ÷ 当前 baseSupplyIndex

3.3 具体数值例子

Alice 在 baseSupplyIndex = 2.5 时存入 $1,000:

  • 存储的本金价值:$1,000 ÷ 2.5 = $400
  • 现值:$400 × 2.5 = $1,000 ✓

后来指数涨到 3.0:

  • 本金价值:仍是 $400(不变)
  • 现值:$400 × 3.0 = $1,200(赚了 $200 利息,自动算出,没动任何存储)

如果 Alice 在指数为 20、现值为 $20 时再追加存 $10:

  • 新现值:$20 + $10 = $30
  • 新本金价值:$30 ÷ 20 = $1.5

可见追加存取时才重算本金,平时纹丝不动。

四、为什么这样设计省 Gas

只存本金 + 共享指数乘数,Compound 避免了每次计息更新每个用户余额:

  1. 指数每次状态变更交易只更新一次
  2. 所有用户自动享受指数上涨;
  3. 个人余额靠乘法实时算,不是存储写入。

相比逐账户更新,Gas 消耗大幅降低。这和 Module 7 的快照”惰性记录”、Uniswap 的”懒结算”是同一类思想:把 O(n) 的逐账户更新,变成 O(1) 的全局指数更新

五、累积机制 accruedInterestIndices

函数 accruedInterestIndices() 根据以下因素重算两个指数:

  • 距上次更新经过的时间;
  • 当前利用率;
  • 对应的利率曲线。

由于利用率直接影响利率,高需求期指数增长更陡,安静期更平缓。

六、baseBorrowIndex 借款指数

baseSupplyIndex 平行,baseBorrowIndex 追踪借款债务的累积。借贷双方利率曲线不同,所以需要两个独立指数。同样的本金/现值逻辑适用于借款,只不过借款的本金价值存储为负数(区分存款的正数)。

所以一个用户的 principal:正数 = 净出借,负数 = 净借款,用同一个字段表达。

七、存储需求与 balanceOf

出借账户的 UserBasic 结构体含:

  • principal:缩放后的存款(有符号整数);
  • baseTrackingAccrued:COMP 奖励追踪;
  • 其他借款和抵押管理字段。

对出借记账而言,principal 是唯一必需的变量。

balanceOf() 函数演示了整个系统:

  1. 读取当前 baseSupplyIndex(view,不修改);
  2. 把存储的 principal 乘以这个指数;
  3. 返回结果作为现值。

由于利用率通常非零,每次调用 balanceOf() 都比上次返回更高的值——利息在实时累积。

八、溢出安全

两个指数用 15 位小数定点(1e15 = 1.0)。有符号 104 位能存的最大值约 1.014e31,允许指数涨到约 1.014e16。

  • 在 100% APR 下,约 53 年才溢出;
  • 在现实的 10% APR 下,1 美元复利到 10 万亿美元需要 386 年

这个时间足够长,协议早就升级了,溢出在实践中不是问题。

九、总结

  • V3 用”全局增长指数 + 用户本金”代替逐账户余额更新;
  • 本金 = 存款 ÷ 存入时指数(存储、不变);现值 = 本金 × 当前指数(实时算、不存);
  • 指数每笔状态变更更新一次,所有用户自动受益——O(1) 省 Gas;
  • 供应、借款各一个指数;借款 principal 存为负数;
  • 15 位定点 + 104 位有符号,几百年才溢出,实践安全。

十、动手练习项目:指数记账金库 IndexVault

项目目标

亲手实现 baseSupplyIndex 指数记账,验证”只存本金、现值实时算、利息累积只更新一个指数”,对比逐账户更新的 Gas。部署到 Sepolia。

合约要求

IndexVault.sol

  • 常量 FACTOR = 1e15(指数精度),baseSupplyIndex 初始化为 1 * FACTORbaseBorrowIndex 同理;
  • mapping(address => int104) public principal;(有符号,正=存、负=借);
  • uint40 public lastAccrualTime;
  • accrue() public:按 (now - lastAccrualTime) * ratePerSecond 增长两个指数(ratePerSecond 可先用固定值,或接上一篇 RateModel);更新 lastAccrualTime;
  • supply(uint amount):先 accrue(),把现有现值 + amount 换算回新本金存储(参考 3.3 的追加存款逻辑);
  • withdraw(uint amount):accrue 后减少现值、重算本金;
  • balanceOf(address) public view returns (uint):返回 principal × 当前 baseSupplyIndex / FACTOR(view,先用一个不写状态的 accruedInterestIndices 视图算最新指数);
  • presentValue(int104 p) / principalValue(int104 pv) 转换辅助函数。

测试要求(Foundry,重点 vm.warp 推进时间)

  1. test_PrincipalStoredScaled:在 index=2.5 时存 1000e6,断言存储的 principal == 400e6(按例子);
  2. test_PresentValueGrows:vm.warp 推进时间使 index 涨,断言 balanceOf 增大而 principal 不变;
  3. test_AddDeposit:复现 3.3 的”指数 20、现值 20、追加 10 → 新本金 1.5”;
  4. test_IndexUpdatedOncePerTx:一笔交易内 accrue 只更新一次指数,多用户共享;
  5. test_BorrowPrincipalNegative:借款使 principal 为负,现值(债务)随借款指数增长;
  6. test_GasVsPerAccount:对比”指数法”与”假想的逐账户更新”在 100 个账户计息时的 Gas(注释论证 O(1) vs O(n));
  7. test_OverflowTimeline:注释/计算验证现实利率下数百年才溢出。

Sepolia 部署与验证步骤

  1. 部署一个测试 USDC + IndexVault;
  2. 两个账户分别 supply,记下各自 principal;
  3. 隔一段时间(或多笔交易推进)后调 balanceOf 观察现值增长、principal 不变;
  4. 在 Etherscan 多次调 balanceOf 看返回值递增(利息实时累积);
  5. baseSupplyIndex 确认它在增长。

进阶挑战(可选)

  • 接上一篇的 RateModel,让指数增长由真实利用率驱动(supply/borrow 改变利用率 → 改变指数增速);
  • 写注释回答:为什么 principal 用有符号整数同时表达存和借?balanceOf 用 view 计算时如何拿到”最新但未落盘”的指数?

💬 评论