Compound V3 本金、现值与利率指数详解
目录
- 一、核心思想:追踪假想收益而非逐笔存款
- 二、指数系统:一个增长的乘数
- 三、本金价值 vs 现值
- 四、为什么这样设计省 Gas
- 五、累积机制 accruedInterestIndices
- 六、baseBorrowIndex 借款指数
- 七、存储需求与 balanceOf
- 八、溢出安全
- 九、总结
- 十、动手练习项目:指数记账金库 IndexVault
一、核心思想:追踪假想收益而非逐笔存款
传统做法:每个用户存了多少就记多少,每次计息要更新每个人的余额——成千上万账户,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 避免了每次计息更新每个用户余额:
- 指数每次状态变更交易只更新一次;
- 所有用户自动享受指数上涨;
- 个人余额靠乘法实时算,不是存储写入。
相比逐账户更新,Gas 消耗大幅降低。这和 Module 7 的快照”惰性记录”、Uniswap 的”懒结算”是同一类思想:把 O(n) 的逐账户更新,变成 O(1) 的全局指数更新。
五、累积机制 accruedInterestIndices
函数 accruedInterestIndices() 根据以下因素重算两个指数:
- 距上次更新经过的时间;
- 当前利用率;
- 对应的利率曲线。
由于利用率直接影响利率,高需求期指数增长更陡,安静期更平缓。
六、baseBorrowIndex 借款指数
与 baseSupplyIndex 平行,baseBorrowIndex 追踪借款债务的累积。借贷双方利率曲线不同,所以需要两个独立指数。同样的本金/现值逻辑适用于借款,只不过借款的本金价值存储为负数(区分存款的正数)。
所以一个用户的 principal:正数 = 净出借,负数 = 净借款,用同一个字段表达。
七、存储需求与 balanceOf
出借账户的 UserBasic 结构体含:
principal:缩放后的存款(有符号整数);baseTrackingAccrued:COMP 奖励追踪;- 其他借款和抵押管理字段。
对出借记账而言,principal 是唯一必需的变量。
balanceOf() 函数演示了整个系统:
- 读取当前
baseSupplyIndex(view,不修改); - 把存储的 principal 乘以这个指数;
- 返回结果作为现值。
由于利用率通常非零,每次调用 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 * FACTOR,baseBorrowIndex同理; 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 推进时间)
test_PrincipalStoredScaled:在 index=2.5 时存 1000e6,断言存储的 principal == 400e6(按例子);test_PresentValueGrows:vm.warp 推进时间使 index 涨,断言 balanceOf 增大而 principal 不变;test_AddDeposit:复现 3.3 的”指数 20、现值 20、追加 10 → 新本金 1.5”;test_IndexUpdatedOncePerTx:一笔交易内 accrue 只更新一次指数,多用户共享;test_BorrowPrincipalNegative:借款使 principal 为负,现值(债务)随借款指数增长;test_GasVsPerAccount:对比”指数法”与”假想的逐账户更新”在 100 个账户计息时的 Gas(注释论证 O(1) vs O(n));test_OverflowTimeline:注释/计算验证现实利率下数百年才溢出。
Sepolia 部署与验证步骤
- 部署一个测试 USDC + IndexVault;
- 两个账户分别 supply,记下各自 principal;
- 隔一段时间(或多笔交易推进)后调
balanceOf观察现值增长、principal 不变; - 在 Etherscan 多次调
balanceOf看返回值递增(利息实时累积); - 读
baseSupplyIndex确认它在增长。
进阶挑战(可选)
- 接上一篇的 RateModel,让指数增长由真实利用率驱动(supply/borrow 改变利用率 → 改变指数增速);
- 写注释回答:为什么 principal 用有符号整数同时表达存和借?balanceOf 用 view 计算时如何拿到”最新但未落盘”的指数?