cUSDC V3(Comet)作为非标准 Rebasing 代币与 CometExt.sol

理解余额随利息无声增长、二元授权、现值计价与 CometExt 扩展模式的非标准 ERC20 设计

6 分钟阅读
cUSDC V3(Comet)作为非标准 Rebasing 代币与 CometExt.sol

cUSDC V3(Comet)作为非标准 Rebasing 代币与 CometExt.sol

目录


一、核心概念:Comet 是个 rebasing 代币

Compound V3(Comet)本身是一个 rebasing(变基)ERC-20 代币——供应量随累积的利息自动调整。它不像 ERC-4626 金库那样用”份额”记账,而是直接管理现值(present value)

用户可能存了 100 USDC,但因为利息累积,账上记着 110 USDC 的额度——这个 110 就是现值。

也就是说,你持有的 cUSDCv3 余额数字本身会随时间变大,代表你存款 + 利息的实时总价值。

二、余额无声增长(没有 Transfer 事件)

这是它与传统 ERC-20 最关键的区别:

  • balanceOf() 返回现值,随利息持续增大;
  • 这种增长不触发 Transfer 事件
  • 用户余额每秒都在数学上变大(利率指数在涨);
  • 余额变化靠指数计算,而不是调整代币供应或转账。

后果:依赖 Transfer 事件追踪余额的外部工具(某些索引器、钱包)会”看不到”利息增长——这正是”非标准”的体现。同样,totalSupply 也在没有任何 mint 事件的情况下增长。

三、关键函数的非标准行为

3.1 totalSupply 与 totalBorrow

两者返回的都是现值而非本金:

  • totalSupply():所有存款的现值,随时间增长;
  • totalBorrow():债务的现值,含累积利息;
  • 都靠”本金 × 当前利率指数”算出(上一篇的指数机制)。

3.2 balanceOf

返回出借人存款的现值,随算法增长,无需显式转账交易。

3.3 transfer 与 transferFrom

都按现值操作。一个关键设计:用户要转走全部余额必须传 type(uint256).max,因为”余额每秒都在增加”——你无法在交易确认时精确写出”全部”是多少。

合约内部有 transferInternal()。借款人不能直接转移抵押品(transferCollateral() 是 internal-only,仅用于清算)。

四、非标准的授权机制

4.1 二元 approve

授权系统故意是二元的,因为”不可能给某人恰好等于全部余额的额度,因为余额随利息不断增长”。approve() 只接受:

  • type(uint256).max(完全授权)
  • 0(取消授权)

传任何中间值都会 revert

4.2 布尔 allowance 存储与 allow

合约不存数值额度,而是在 CometStorage 里用一个布尔 mapping isAllowedhasPermission() 返回某地址是”无限授权”还是”无授权”——纯二元逻辑,没有分级额度。

allow() 是个非标准函数,效果同 approve,但接受布尔参数而非 uint256,意图更清晰:

comet.allow(spender, true);  // 等价于 approve(spender, type(uint256).max)
comet.allow(spender, false); // 等价于 approve(spender, 0)

五、CometExt.sol 架构

因为 24KB 字节码上限(架构篇讲过),Comet 把 ERC-20 相关功能委托给 CometExt.sol,用 fallback 扩展模式。

CometExt 里的大部分函数都和 ERC-20 功能相关。

CometExt 包含的函数:

  • approve()
  • allow() / allowInternal()
  • allowance()
  • allowBySig()(非标准 permit 变体)
  • name()symbol()

这些函数不在 Etherscan 的标准 ABI 里直接可见,而是通过 Comet 的 fallback 机制执行(fallback → delegatecall CometExt)。CometExt 必须保持与 Comet 相同的存储布局,否则 delegatecall 读错插槽。

六、name 与 symbol 的 Gas 优化实现

为省 Gas,name()symbol() 把值存在 immutable 的 bytes32 变量里,而不是 string。合约手动把 bytes 转成 string——逐字节从 name32symbol32 拷进内存数组再转 string 类型。这种不寻常的写法展示了生产级 DeFi 代码的优化取舍(string 是动态类型,存储和读取都比 bytes32 贵)。

七、permit 的偏离

allowBySig() 偏离了 EIP-2612 标准。CometStorage 暴露的是 mapping(address => uint256) public userNonce,而不是 EIP-2612 规定的 nonces(address owner) 函数——这导致它和标准 permit 工作流不兼容,集成时要特别注意。

八、非标准特性总结

  1. 无声 rebasing:余额靠指数计算增长,不发 Transfer 事件;
  2. 二元授权:只能全额(max)或零,无分级额度,用布尔存储;
  3. 现值计价:所有余额和转账都按现值而非本金;
  4. 扩展模式委托:ERC-20 函数拆分在 Comet 和 CometExt;
  5. 非标准 permit:allowBySig 不同于 EIP-2612;
  6. Gas 优化字符串:name/symbol 用 bytes32 转换而非原生 string。

这套架构体现了 Compound 优先 Gas 效率和简洁、而非严格 ERC-20 合规的选择。

九、动手练习项目:rebasing 存款凭证 RebaseShare

项目目标

实现一个类 Comet 的非标准 rebasing 代币:余额随指数增长、二元授权、转全部用 max、ERC-20 函数拆到扩展合约。部署到 Sepolia,亲眼看 balanceOf 随时间增长却无 Transfer 事件。

合约要求

1. RebaseStorage.sol:集中存储 mapping(address=>uint104) principaluint64 supplyIndexmapping(address=>mapping(address=>bool)) isAlloweduint40 lastAccrual

2. RebaseShare.sol(主合约)

  • accrue():按时间推进 supplyIndex(复用上一篇指数逻辑);
  • balanceOf(address) view returns (uint)principal × 当前 supplyIndex / SCALE(实时算,含未落盘利息);
  • totalSupply() view:总本金 × 指数;
  • supply(uint) / withdraw(uint):现值进出,max 表示全部;
  • transfer(address to, uint amount):按现值转,amount == type(uint256).max 时转全部余额;不为利息增长发 Transfer 事件(只在显式 transfer 时发);
  • fallback():delegatecall 到 RebaseShareExt(处理授权类函数)。

3. RebaseShareExt.sol(扩展,保持相同存储布局)

  • approve(address spender, uint amount):只接受 max 或 0,否则 revert BadApprove()
  • allow(address spender, bool isAllowed_):布尔授权;
  • allowance(address,address) view returns (uint):返回 max 或 0;
  • name() / symbol():从 immutable bytes32 转 string。

测试要求(Foundry,重点 vm.warp)

  1. test_BalanceGrowsWithoutTransferEvent:supply 后 vm.warp 推进时间,断言 balanceOf 增大,且 vm.recordLogs 确认期间没有 Transfer 事件
  2. test_TransferMaxMovesAll:transfer(to, type(uint256).max) 转走全部当前现值,自己归零;
  3. test_ApproveBinary:approve(spender, max) 成功,approve(spender, 100) revert,approve(spender, 0) 成功;
  4. test_AllowBoolean:allow(spender, true) 后 allowance 返回 max;
  5. test_ExtFallbackDelegation:通过主合约调 approve(命中 fallback → Ext),验证授权写入主合约存储;
  6. test_NameSymbolFromBytes32:name()/symbol() 返回正确字符串;
  7. test_TotalSupplyIsPresentValue:totalSupply 随时间增长。

Sepolia 部署与验证步骤

  1. 部署 RebaseStorage 布局相关合约、RebaseShareExt、RebaseShare(fallback 指向 Ext);
  2. supply 一些底层代币;
  3. 隔一段时间多次在 Etherscan 调 balanceOf,看数字递增;查交易历史确认无 Transfer 事件;
  4. 试 approve(spender, 100) 观察 revert,再 allow(spender, true) 成功;
  5. transfer(to, max) 转走全部。

进阶挑战(可选)

  • 实现 allowBySig(EIP-712 签名授权),故意用 userNonce 而非 nonces 复现与 EIP-2612 的不兼容;
  • 写注释回答:rebasing 代币不发 Transfer 事件会给哪些下游系统(钱包、索引器、其他 DeFi 协议组合)带来问题?为什么 Compound 仍这样设计?

💬 评论