十二、从源码讲解compound v2 代理升级架构
代理升级架构
第十二讲继续:代理升级架构:Unitroller / Comptroller,CErc20Delegator / CErc20Delegate。
这一讲非常重要,因为 Compound v2 不是简单地“部署一堆不可变合约”。它用了代理升级模式,让核心逻辑可以升级,同时保留原来的合约地址和状态。
也就是说,这一讲解决的是:
合约已经部署了,用户资产和状态都在链上,
如果以后要修 bug、加逻辑、改风控代码,
Compound v2 怎么升级?
第十二讲:代理升级架构
简介
本文精读 Compound v2 的代理升级架构,梳理 Unitroller -> Comptroller 和 CErc20Delegator -> CErc20Delegate 两套代理如何通过 delegatecall 实现逻辑升级、地址不变和状态保留。读完可以理解代理合约与实现合约的分工、storage layout 为什么不能乱改,以及 Timelock / Governance 权限为什么是可升级协议的安全边界。
1. 为什么需要代理升级?
以太坊合约一旦部署,代码默认不可修改。
这对协议来说有两个选择:
方案一:完全不可升级
优点:用户信任代码不可变
缺点:出 bug 或要迭代时很麻烦
方案二:代理升级
优点:逻辑可以升级
缺点:治理/管理员权限更敏感
Compound v2 选择了代理模式。
核心思想是:
代理合约保存状态和地址
实现合约保存逻辑
用户始终调用代理合约
代理合约用 delegatecall 执行实现合约代码
这样升级时只需要更换实现合约地址,不需要迁移用户状态。
2. Compound v2 里有两套代理
Compound v2 里主要有两类代理架构:
Comptroller 代理:
Unitroller -> Comptroller
cToken 代理:
CErc20Delegator -> CErc20Delegate
也就是:
风控中心 Comptroller 有代理
ERC20 cToken 市场也有代理
其中 ETH 市场 CEther 通常比较特殊,不一定走同样的 Delegator / Delegate 架构。
3. Unitroller / Comptroller 总览
先看 Comptroller 这套:
用户 / CToken
|
v
Unitroller
|
| delegatecall
v
Comptroller implementation
可以理解成:
Unitroller:
代理合约
保存 Comptroller 的状态
保存当前 implementation 地址
Comptroller:
实现合约
保存业务逻辑
例如 borrowAllowed、redeemAllowed、getAccountLiquidity
这句话非常关键:
状态在 Unitroller,逻辑在 Comptroller。
4. delegatecall 是什么?
普通 call:
A call B
代码在 B 中执行
读写 B 的 storage
msg.sender 通常是 A
delegatecall:
A delegatecall B
代码使用 B 的代码
但读写 A 的 storage
msg.sender 保持原始调用者
所以 Unitroller delegatecall Comptroller 时:
执行的是 Comptroller 的代码
读写的是 Unitroller 的 storage
这就实现了:
换掉 Comptroller implementation
但 Unitroller 里的状态还在
这也是所有代理模式的核心。
5. 一个简单类比
你可以把代理模式想成:
Unitroller = 身体和记忆
Comptroller = 大脑插件
换大脑插件时,身体和记忆不变。
但这也很危险:
新大脑必须知道旧身体的器官位置。
翻译成 Solidity 就是:
新 implementation 的 storage layout 必须和旧 implementation 兼容。
否则新逻辑可能把旧状态读错、写坏。
6. Unitroller 里通常有什么状态?
Unitroller 会保存代理相关状态,比如:
address public admin;
address public pendingAdmin;
address public comptrollerImplementation;
address public pendingComptrollerImplementation;
这些变量负责升级流程。
大概含义:
admin:
当前管理员,通常后来由治理控制
pendingAdmin:
待接受的新管理员
comptrollerImplementation:
当前 Comptroller 逻辑合约
pendingComptrollerImplementation:
待接受的新 Comptroller 逻辑合约
还有一个关键点:
真正的 Comptroller 状态也在 Unitroller 的 storage 里。
例如:
markets
accountAssets
oracle
closeFactorMantissa
liquidationIncentiveMantissa
pauseGuardian
逻辑合约里声明 these 变量,但实际读写发生在代理合约 storage 上。
7. ComptrollerStorage:为什么有一堆 Storage 合约?
Compound v2 源码里你会看到一些类似:
UnitrollerAdminStorage
ComptrollerV1Storage
ComptrollerV2Storage
ComptrollerV3Storage
...
这不是装饰品。
这是为了控制存储布局。
代理升级最怕的是:
旧逻辑里 slot 0 是 admin
新逻辑里 slot 0 变成 oracle
那一升级,整个协议就开始胡言乱语。 链上 storage 不会因为你变量改名就自动迁移。
所以 Compound 用继承的 Storage 合约固定变量顺序。
像这样:
contract UnitrollerAdminStorage {
address public admin;
address public pendingAdmin;
address public comptrollerImplementation;
address public pendingComptrollerImplementation;
}
contract ComptrollerV1Storage is UnitrollerAdminStorage {
PriceOracle public oracle;
uint public closeFactorMantissa;
uint public liquidationIncentiveMantissa;
mapping(address => Market) public markets;
}
新版本只能在后面加变量:
contract ComptrollerV2Storage is ComptrollerV1Storage {
address public pauseGuardian;
}
不能随便插到中间,不能改已有变量类型。
8. 存储布局为什么不能乱改?
假设旧版本:
contract OldStorage {
address admin; // slot 0
address oracle; // slot 1
uint closeFactor; // slot 2
}
新版本错误地改成:
contract NewStorage {
address admin; // slot 0
uint closeFactor; // slot 1
address oracle; // slot 2
}
那升级后:
slot 1 原本存的是 oracle 地址
新逻辑却把它当 closeFactor 读
slot 2 原本存的是 closeFactor
新逻辑却把它当 oracle 地址读
结果就是:
价格预言机地址错了
抵押参数错了
风控直接爆炸
所以代理升级里有一条铁律:
只能追加 storage,不能重排、删除、改类型。
9. Unitroller 的升级流程
Compound 的升级通常不是管理员单方面直接切换,而是两步提交/接受。
大概流程:
1. admin 调用 Unitroller._setPendingImplementation(newImpl)
2. newImpl 调用 Unitroller._acceptImplementation()
3. Unitroller 把 comptrollerImplementation 设置为 newImpl
为什么要两步?
因为要确保新 implementation 合约知道自己要成为这个代理的实现,并且可以执行初始化或兼容性检查。
伪代码:
function _setPendingImplementation(address newPendingImplementation) public {
require(msg.sender == admin);
pendingComptrollerImplementation = newPendingImplementation;
}
function _acceptImplementation() public {
require(msg.sender == pendingComptrollerImplementation);
comptrollerImplementation = pendingComptrollerImplementation;
pendingComptrollerImplementation = address(0);
}
重点:
只有 pending implementation 自己可以 accept。
10. Unitroller fallback
用户调用 Unitroller 上不存在的函数时,会进 fallback。
fallback 大概做:
fallback() external payable {
address impl = comptrollerImplementation;
assembly {
calldatacopy(0, 0, calldatasize())
let result := delegatecall(
gas(),
impl,
0,
calldatasize(),
0,
0
)
returndatacopy(0, 0, returndatasize())
switch result
case 0 { revert(0, returndatasize()) }
default { return(0, returndatasize()) }
}
}
这段看着吓人,但逻辑就是:
把用户的 calldata 原样转给 implementation
用 delegatecall 执行
把返回值原样返回给用户
如果失败,就原样 revert
所以外部看起来像是:
用户直接调用 Comptroller
但实际地址是:
Unitroller
11. 为什么 CToken 调的是 Unitroller?
每个 CToken 里会保存:
ComptrollerInterface public comptroller;
这个地址通常指向:
Unitroller 地址
而不是某个具体 Comptroller implementation。
所以 CToken 调:
comptroller.borrowAllowed(...)
实际上是调用 Unitroller。
Unitroller fallback 再 delegatecall 当前 Comptroller implementation。
这样一来,Comptroller 升级后,所有 CToken 不需要改地址。
cDAI 仍然指向同一个 Unitroller
Unitroller 背后的逻辑换了
这就是代理的价值。
12. CErc20Delegator / CErc20Delegate 总览
除了 Comptroller,Compound v2 的 ERC20 cToken 市场也用了代理。
结构:
用户
|
v
CErc20Delegator
|
| delegatecall
v
CErc20Delegate implementation
可以理解成:
CErc20Delegator:
代理合约
是用户交互的 cToken 地址
保存该市场状态
也是 ERC20 token 地址
CErc20Delegate:
逻辑合约
实现 mint / redeem / borrow / repay / liquidate 等逻辑
比如:
cDAI 的地址 = CErc20Delegator 地址
用户持有的 cDAI ERC20 token 就是这个代理地址上的余额状态。
逻辑可以换,但 token 地址不变。
13. 为什么 cToken 也要代理?
因为每个 cToken 市场都有大量状态:
accountTokens
accountBorrows
totalSupply
totalBorrows
totalReserves
borrowIndex
accrualBlockNumber
interestRateModel
comptroller
underlying
如果某个市场逻辑要修 bug 或升级,不可能让所有用户迁移到新 cToken 地址。
那会非常痛苦:
用户余额要迁移
抵押关系要迁移
借款快照要迁移
集成协议要改地址
前端要改地址
流动性和信任都断裂
代理可以让:
cDAI 地址不变
用户 cDAI 余额不变
借款状态不变
只是背后逻辑升级
14. CErc20Delegator 里有什么?
CErc20Delegator 通常继承 CToken 存储结构,并额外保存 implementation:
address public implementation;
它的构造函数会初始化市场:
underlying
comptroller
interestRateModel
initialExchangeRateMantissa
name
symbol
decimals
admin
implementation
也就是说,部署一个 cToken 市场时,Delegator 就是最终用户看到的 cToken。
15. CErc20Delegate 里有什么?
CErc20Delegate 是逻辑实现。
它实现用户入口:
function mint(uint mintAmount) external returns (uint) {
mintInternal(mintAmount);
return NO_ERROR;
}
function redeem(uint redeemTokens) external returns (uint) {
redeemInternal(redeemTokens);
return NO_ERROR;
}
function borrow(uint borrowAmount) external returns (uint) {
borrowInternal(borrowAmount);
return NO_ERROR;
}
但由于是 delegatecall,执行这些逻辑时,读写的是 Delegator 的 storage。
所以:
CErc20Delegate 不是 cDAI 本体
CErc20Delegator 才是 cDAI 本体
Delegate 更像一套可替换的“代码模块”。
16. cToken 的 delegateToImplementation
Delegator 通常会有类似:
function delegateToImplementation(bytes memory data) public returns (bytes memory) {
(bool success, bytes memory returnData) =
implementation.delegatecall(data);
require(success);
return returnData;
}
或者用 assembly 做更底层的转发。
当用户调用 Delegator 上没有直接实现的函数时,fallback 会把调用转给 implementation。
也就是说:
用户调用 cDAI.mint()
-> CErc20Delegator fallback
-> delegatecall CErc20Delegate.mint()
-> mintInternal()
-> 修改 CErc20Delegator storage
17. _setImplementation:升级 cToken 逻辑
cToken 代理通常有管理员函数:
function _setImplementation(
address implementation_,
bool allowResign,
bytes memory becomeImplementationData
) public
大概流程:
1. 只有 admin 可以调用
2. 如果 allowResign,旧 implementation 执行 _resignImplementation
3. 设置新的 implementation
4. 新 implementation 执行 _becomeImplementation
为什么有 _becomeImplementation?
因为新逻辑可能需要初始化一些东西,或者检查参数。
比如:
新实现是否兼容这个市场?
是否需要设置额外参数?
是否要初始化新版本状态?
这也是一种安全握手。
18. _becomeImplementation / _resignImplementation
Delegate 合约里通常会有:
function _becomeImplementation(bytes memory data) public {
require(msg.sender == admin);
// initialization / checks
}
function _resignImplementation() public {
require(msg.sender == admin);
// cleanup if needed
}
因为 delegatecall 上下文里,admin 读的是 Delegator 的 storage。
这两个函数是 implementation 升级生命周期钩子:
_becomeImplementation:
新逻辑接任时调用
_resignImplementation:
旧逻辑卸任时调用
不是每次都需要复杂逻辑,但接口留着方便升级。
19. CErc20Delegator 和 Unitroller 的区别
两者都用代理,但风格略有不同.
Unitroller / Comptroller:
代理 Comptroller 风控中心
使用 pending / accept 两步升级
主要由治理控制
CErc20Delegator / CErc20Delegate:
代理单个 ERC20 cToken 市场
通过 _setImplementation 设置实现
每个市场都有自己的 Delegator
共同点:
代理保存状态
实现保存逻辑
delegatecall 执行
升级时换 implementation
storage layout 必须兼容
区别:
Unitroller 是全局风控代理
CErc20Delegator 是单市场资产代理
20. 为什么 CEther 可能不一样?
CEther 处理的是原生 ETH,不是 ERC20 underlying。
它有一些特殊逻辑:
msg.value
payable
直接发送 ETH
没有 underlying ERC20 地址
所以它的部署和代理模式可能不像 CErc20Delegator / CErc20Delegate 那样统一。
读源码时你会发现:
CErc20 市场通常走 Delegator / Delegate
CEther 更像单独实现
这不是设计混乱,而是 ETH 原生资产在 Solidity 里确实特殊。
21. 代理模式里的 msg.sender
这是 delegatecall 的一个关键点。
用户 Alice 调用:
cDAI.mint(100)
真实链路:
Alice
-> CErc20Delegator
-> delegatecall CErc20Delegate
在 Delegate 逻辑中:
msg.sender == Alice
不是 Delegator。
这是 delegatecall 的特性。
如果 msg.sender 变成代理合约,很多逻辑都会坏掉:
mint 会认为 minter 是代理
borrow 会认为 borrower 是代理
repay 会从代理那里拉钱
delegatecall 保留原始 msg.sender,正好适合代理模式。
22. 代理模式里的 address(this)
delegatecall 还有一个重要特性:
在 implementation 代码里:
address(this)
指的是代理地址,不是 implementation 地址。
所以在 CErc20Delegate 逻辑里:
address(this)
其实是 cDAI Delegator 地址。
这非常关键。
例如:
comptroller.borrowAllowed(address(this), borrower, borrowAmount)
如果是在 cDAI 代理上下文中执行,address(this) 就是 cDAI 市场地址。
这正是我们想要的。
23. 代理升级的最大风险:恶意实现
代理模式的灵活性也带来风险。
如果 admin / governance 设置了恶意 implementation,它可以:
改用户余额
偷 underlying
改借款状态
破坏风控
阻止用户操作
因为 implementation 通过 delegatecall 可以读写代理的全部 storage。
所以代理系统的真正安全边界是:
治理权限 / admin 权限
升级流程
Timelock 延迟
社区审查
代码审计
可升级协议不是“代码不可作恶”,而是“升级权力受治理约束”。
24. Timelock 和治理为什么重要?
Compound v2 后续治理体系里,关键权限通常会交给 Timelock / Governor。
升级流程一般不是某个 EOA 一键改。
理想路径是:
治理提案
-> 投票
-> Timelock 排队
-> 延迟期
-> 执行升级
Timelock 的意义是:
让用户和市场有时间看到即将发生的高权限操作。
如果治理要升级恶意实现,用户至少有时间退出。
当然,这依然依赖治理安全。 DeFi 里“可升级”这件事永远不是白送午餐。
25. 代理模式和 ABI
用户和前端通常拿的是 implementation 的 ABI,但调用地址是代理地址。
比如:
ABI: Comptroller ABI
Address: Unitroller address
或者:
ABI: CErc20Delegate / CToken ABI
Address: cDAI Delegator address
这是代理合约开发里很常见的模式。
因为代理本身可能只有 fallback,看起来没有那么多函数。 但 fallback 会把这些函数转给 implementation。
所以用 implementation ABI 调代理地址是合理的。
26. 如何从源码判断状态在哪里?
一个简单原则:
谁被用户长期持有地址,状态就在谁那里。
例如:
Comptroller 地址实际是 Unitroller
所以 Comptroller 状态在 Unitroller
cDAI 地址实际是 CErc20Delegator
所以 cDAI 状态在 CErc20Delegator
Implementation 合约可以看成“代码库”。
它自己即使也有 storage slot,从正常协议路径看,通常不是你关心的状态。
27. 代理模式下读源码的技巧
读代理源码时要在脑子里做替换。
看到 Comptroller 实现里的:
markets[cToken]
oracle
closeFactorMantissa
要想:
这些变量实际存储在 Unitroller 上。
看到 CErc20Delegate 里的:
accountTokens[account]
totalBorrows
borrowIndex
underlying
要想:
这些变量实际存储在 CErc20Delegator 上。
看到:
address(this)
要想:
这是代理地址。
看到:
msg.sender
要想:
这是原始调用者。
这四个替换做对,代理源码就不迷路。
28. 一次 cDAI.mint 的代理链路
把它完整串一下:
Alice 调用 cDAI.mint(100)
|
v
cDAI 地址 = CErc20Delegator
|
| fallback / delegateToImplementation
v
delegatecall CErc20Delegate.mint(100)
|
v
mintInternal(100)
|
|-- accrueInterest()
|-- mintFresh(Alice, 100)
|
|-- 读写的是 CErc20Delegator storage:
| totalSupply
| accountTokens[Alice]
| totalBorrows
| borrowIndex
|
|-- address(this) = cDAI Delegator 地址
|-- msg.sender = Alice
这就是代理下的真实执行模型。
29. 一次 cDAI.borrowAllowed 的双代理链路
更刺激一点,borrow 会跨两套代理:
Alice 调用 cDAI.borrow(100)
|
v
CErc20Delegator(cDAI)
|
| delegatecall
v
CErc20Delegate.borrow
|
v
borrowFresh
|
|-- comptroller.borrowAllowed(address(this), Alice, 100)
|
v
comptroller 地址 = Unitroller
|
| fallback / delegatecall
v
Comptroller implementation.borrowAllowed
|
|-- 读写 Unitroller storage:
| markets
| accountAssets
| oracle
| pauseGuardian
所以一次 borrow 实际涉及:
cToken 代理
cToken 实现
Comptroller 代理
Comptroller 实现
Oracle
InterestRateModel
Underlying ERC20
30. 代理模式下的存储布局规则
最后把规则钉牢:
1. 代理保存状态,实现保存逻辑。
2. delegatecall 执行实现代码,但读写代理 storage。
3. implementation 升级不能破坏 storage layout。
4. 旧变量不能删除。
5. 旧变量类型不能改。
6. 旧变量顺序不能改。
7. 新变量只能追加到后面。
8. 初始化逻辑要通过专门函数或升级 hook 执行。
9. admin / governance 权限是最高风险点。
10. ABI 通常用 implementation ABI,地址用 proxy 地址。
31. 第十二讲要记住的 8 个结论
第一,Compound v2 使用代理升级,让逻辑可替换、状态和地址保持不变。
第二,Comptroller 的代理是:
Unitroller -> Comptroller implementation
第三,ERC20 cToken 的代理是:
CErc20Delegator -> CErc20Delegate
第四,delegatecall 的核心特性是:
执行 implementation 代码
读写 proxy storage
保留原始 msg.sender
address(this) 是 proxy 地址
第五,代理升级最重要的安全规则是 storage layout 兼容。
第六,Unitroller 使用 pending / accept 模式升级 Comptroller implementation。
第七,CErc20Delegator 使用 _setImplementation 更换 cToken 逻辑,并可调用 _becomeImplementation / _resignImplementation。
第八,可升级性带来的最大风险是治理或 admin 权限,所以 Timelock / Governor 是安全边界的重要部分。
下一讲我们读 Governance:COMP / GovernorAlpha / Timelock。
它会回答:
COMP 代币怎么参与治理?
提案怎么创建?
投票权怎么计算?
为什么要 checkpoint?
Timelock 为什么重要?
治理如何升级 Comptroller 或调整市场参数?
这部分读完,你就能把 Compound v2 从“借贷合约”扩展到“链上治理协议”。