十三、从源码讲解compound v2 Governance:COMP / GovernorAlpha / Timelock
第十三讲继续:Governance:COMP/GovernorAlpha/Timelock。前面我们讲代理升级时说过一句很关键的话:可升级协议的真正安全边界,不只是代码本身,还包括谁有权升级代码、谁有权改参数、升级前有没有延迟。Compoundv2的答案就是:COMP代币
第十三讲继续:Governance:COMP / GovernorAlpha / Timelock。
前面我们讲代理升级时说过一句很关键的话:
可升级协议的真正安全边界,不只是代码本身,
还包括谁有权升级代码、谁有权改参数、升级前有没有延迟。
Compound v2 的答案就是:
COMP 代币
GovernorAlpha
Timelock
这一讲我们把 Compound v2 从“借贷协议”推进到“链上治理协议”。
第十三讲:Governance 治理源码精读
简介
本文精读 Compound v2 的治理系统,梳理 COMP、GovernorAlpha 和 Timelock 如何共同完成投票权委托、提案创建、投票、排队和延迟执行。读完可以理解 checkpoints 为什么能防止重复投票,提案的 targets / signatures / calldatas 才是真正执行内容,以及治理权限如何成为协议升级和参数调整的安全边界。
1. 为什么 Compound v2 需要治理?
Compound v2 里很多参数会影响整个协议安全:
collateralFactor
closeFactor
liquidationIncentive
reserveFactor
interestRateModel
priceOracle
pauseGuardian
Comptroller implementation
cToken implementation
新增市场
暂停市场操作
这些参数不能随便给某个私钥控制。 否则这个私钥一旦作恶或被盗,协议可能瞬间爆炸。
所以 Compound 后续把核心权限交给治理系统。
治理系统大概负责:
谁能提案
谁能投票
投票怎么算
提案通过后怎么排队
提案什么时候执行
执行时调用哪些合约
2. 三个核心组件
Compound v2 治理主线有三个合约:
COMP
GovernorAlpha
Timelock
它们分工是:
COMP:
治理代币,记录投票权
GovernorAlpha:
提案与投票系统
Timelock:
延迟执行器,真正持有协议管理权限
关系图:
COMP holders
|
| delegate votes
v
GovernorAlpha
|
| proposal passed
v
Timelock
|
| delay
v
execute target calls
|
v
Comptroller / cToken / Oracle / InterestRateModel / etc.
一句话:
COMP 决定谁有票;
GovernorAlpha 决定提案是否通过;
Timelock 决定通过后的提案什么时候真正执行。
3. 治理不是直接调用协议
很多人一开始会误解:
提案通过后,是 GovernorAlpha 直接改 Comptroller 参数吗?
不是。
通常是:
GovernorAlpha 把通过的操作 queue 到 Timelock
Timelock 等待 delay
Timelock 执行具体调用
为什么要 Timelock?
因为治理提案可能非常危险,比如:
更换 Comptroller implementation
更换 PriceOracle
把某资产 collateralFactor 调到很高
升级 cToken implementation
如果提案一通过就立刻执行,用户没有逃跑窗口。
Timelock 给市场一个缓冲时间:
大家可以看到即将执行什么;
如果觉得危险,可以退出或反应。
4. COMP 是什么?
COMP 是 Compound 的治理代币。
它不是借贷市场里的 cToken。 它的核心用途是治理投票。
COMP 合约通常是一个 ERC20,但比普通 ERC20 多了治理投票相关逻辑:
delegate
delegateBySig
getCurrentVotes
getPriorVotes
checkpoints
普通 ERC20 只关心:
谁有多少 token
治理代币还要关心:
谁有多少投票权
某个历史区块时谁有多少投票权
用户把投票权委托给了谁
5. 为什么需要 delegate?
持有 COMP 不等于自动用自己的地址投票。
用户需要把投票权委托给某个地址:
delegate(address delegatee)
可以委托给自己:
Alice 持有 COMP
Alice 调用 delegate(Alice)
Alice 自己获得投票权
也可以委托给别人:
Alice 持有 COMP
Alice 调用 delegate(Bob)
Bob 获得 Alice 那部分投票权
注意:
delegate 转移的是投票权,不是 token 所有权。
Alice 的 COMP 还在 Alice 钱包里,只是投票权归 Bob 使用。
6. 为什么不让 token balance 直接等于 votes?
因为治理要支持委托。
很多 token 持有人不想自己研究提案,但愿意把票权交给代表。
比如:
普通用户把 COMP 委托给治理代表
基金把 COMP 委托给安全委员会
团队成员委托给某个多签
所以治理投票权不是简单:
votes = balanceOf[user]
而是:
votes = 被委托给这个地址的 COMP 总量
源码里会维护:
mapping(address => address) public delegates;
表示:
delegates[delegator] = delegatee
7. checkpoints:为什么需要历史投票权?
治理投票最怕一个问题:
用户在投票期间来回转 token,重复投票怎么办?
举个攻击:
Alice 有 100 COMP
Alice 给提案投票
Alice 把 100 COMP 转给 Bob
Bob 再投票
Bob 转给 Carol
Carol 再投票
如果投票权按实时余额算,就可以刷票。
所以治理系统必须使用某个固定区块的投票权快照。
Compound 的做法是 checkpoint。
用户投票权变化时,写入检查点:
struct Checkpoint {
uint32 fromBlock;
uint96 votes;
}
每个地址有一组 checkpoints:
mapping(address => mapping(uint32 => Checkpoint)) public checkpoints;
mapping(address => uint32) public numCheckpoints;
意思是:
在某个区块,这个地址的投票权是多少。
8. getPriorVotes
投票时经常用:
getPriorVotes(address account, uint blockNumber)
它返回:
account 在 blockNumber 那个区块时的投票权
这是治理系统的核心。
提案创建时会确定一个:
startBlock
投票时看的是:
用户在 startBlock 或某个历史区块的投票权
而不是当前余额。
这样用户就不能在投票期间搬 token 刷票。
9. checkpoint 如何更新?
当 COMP 转账、mint、burn 或 delegate 变化时,投票权可能变化。
比如 Alice 把投票权委托给 Bob:
Alice delegatee 从 Alice 变成 Bob
那么:
Alice 原 delegatee 的 votes 减少
Bob 的 votes 增加
源码里会有类似:
_moveDelegates(srcRep, dstRep, amount)
逻辑:
如果 srcRep 不是零地址:
srcRep votes -= amount
写 checkpoint
如果 dstRep 不是零地址:
dstRep votes += amount
写 checkpoint
所以 token 转移时,如果转出方和接收方的 delegatee 不同,也要移动投票权。
10. delegateBySig
COMP 还支持:
delegateBySig(...)
意思是:
用户不用自己发链上交易,
只需要签名;
别人可以替他提交委托交易。
这类似 EIP-712 风格的签名授权。
作用是降低治理参与门槛:
用户可以离线签名,把投票权委托出去;
提交签名的人帮他付 gas。
这在治理系统里很常见。
11. GovernorAlpha 是什么?
GovernorAlpha 是提案和投票合约。
它负责:
创建提案
记录提案状态
记录投票
判断投票是否通过
把通过的提案 queue 到 Timelock
执行已排队且到期的提案
取消恶意或失效提案
核心结构通常是:
struct Proposal {
uint id;
address proposer;
uint eta;
address[] targets;
uint[] values;
string[] signatures;
bytes[] calldatas;
uint startBlock;
uint endBlock;
uint forVotes;
uint againstVotes;
bool canceled;
bool executed;
mapping(address => Receipt) receipts;
}
这个结构很重要,我们拆开看。
12. Proposal 里有什么?
一个提案不是一句话,而是一组链上调用。
比如提案要做:
把 cETH collateralFactor 从 75% 调到 80%
把 cDAI reserveFactor 从 10% 调到 12%
更换某市场 InterestRateModel
那它会包含一组 target call:
targets:
[Comptroller, cDAI, cUSDC]
values:
[0, 0, 0]
signatures:
["_setCollateralFactor(address,uint256)",
"_setReserveFactor(uint256)",
"_setInterestRateModel(address)"]
calldatas:
[encoded args, encoded args, encoded args]
执行时,Timelock 会逐个调用这些 target。
所以治理提案本质是:
一批延迟执行的合约调用。
13. Proposal 字段解释
id:
提案编号
proposer:
提案发起人
eta:
提案在 Timelock 中预计可执行的时间
targets:
要调用的合约地址列表
values:
每个调用附带的 ETH 数量
signatures:
函数签名列表
calldatas:
函数参数编码
startBlock:
投票开始区块
endBlock:
投票结束区块
forVotes:
赞成票数量
againstVotes:
反对票数量
canceled:
是否已取消
executed:
是否已执行
receipts:
每个投票人的投票记录
14. Receipt:投票记录
每个用户对某个提案只能投一次。
所以有:
struct Receipt {
bool hasVoted;
bool support;
uint96 votes;
}
含义:
hasVoted:
是否已经投过票
support:
支持还是反对
votes:
这次投票使用了多少票权
投票后写入:
proposal.receipts[voter] = Receipt(...)
这样可以防止重复投票。
15. 创建提案:propose
提案入口通常是:
propose(
address[] memory targets,
uint[] memory values,
string[] memory signatures,
bytes[] memory calldatas,
string memory description
)
不是任何人都能提案。 必须达到提案门槛:
proposalThreshold
也就是:
proposer 的历史投票权 >= proposalThreshold
为什么要门槛?
防止垃圾提案刷屏。 如果任何地址 0 votes 都能提案,治理会被 spam 打爆。
16. 为什么用历史 votes 判断 proposer?
创建提案时,也会看历史区块的投票权,而不是当前。
通常会用:
comp.getPriorVotes(msg.sender, block.number - 1)
意思是:
看 proposer 在上一个区块的投票权。
为什么不是当前区块?
因为当前区块内可能存在复杂顺序操纵。 用前一个区块更稳定。
如果票权不足:
不能创建提案。
17. proposalThreshold 和 quorumVotes
治理里有两个很重要的参数:
proposalThreshold:
创建提案所需最低票权
quorumVotes:
提案通过所需最低赞成票数量
区别:
proposalThreshold:
防 spam,决定谁能提案
quorumVotes:
防低参与度通过,决定提案是否有足够治理共识
一个提案想通过,通常需要:
forVotes > againstVotes
并且
forVotes >= quorumVotes
只比反对票多还不够,还要达到法定参与量。
18. 投票周期
创建提案后,不会立刻投票。
通常有:
votingDelay
例如:
提案创建后,等待若干区块才开始投票。
然后进入投票期:
votingPeriod
例如:
从 startBlock 到 endBlock。
状态流:
Pending
-> Active
-> Succeeded / Defeated
其中:
Pending:
提案已创建,但还没到 startBlock
Active:
正在投票
Succeeded:
投票结束,赞成票足够且超过反对票
Defeated:
投票失败
19. castVote
投票入口:
castVote(uint proposalId, bool support)
内部会:
1. 检查提案状态必须是 Active
2. 检查 voter 没有投过
3. 获取 voter 在 startBlock 的投票权
4. 根据 support 增加 forVotes 或 againstVotes
5. 写入 Receipt
6. emit VoteCast
关键是第 3 步:
uint96 votes = comp.getPriorVotes(voter, proposal.startBlock);
这确保投票权以提案开始时为准。
20. 为什么投票权不是投票瞬间余额?
还是为了防止刷票。
如果按实时余额:
Alice 投完转给 Bob
Bob 投完转给 Carol
同一批 COMP 可以重复投。
用 startBlock 快照后:
投票期间转账不会改变这个提案的投票权。
它可能影响未来提案,但不影响当前提案。
21. queue:把成功提案放进 Timelock
提案投票成功后,还不能立刻执行。
需要调用:
queue(uint proposalId)
它会把提案里的每个 action 放进 Timelock。
每个 action 由这些东西唯一确定:
target
value
signature
calldata
eta
其中:
eta = block.timestamp + timelock.delay()
也就是最早可执行时间。
queue 后,提案状态变成:
Queued
22. Timelock 是什么?
Timelock 是真正持有协议 admin 权限的合约。
比如 Comptroller 的 admin 可能是 Timelock,而不是 GovernorAlpha。
这意味着:
只有 Timelock 才能调用 Comptroller 的高权限函数。
例如:
Comptroller._setCollateralFactor(...)
Comptroller._setPriceOracle(...)
Unitroller._setPendingImplementation(...)
cToken._setReserveFactor(...)
cToken._setInterestRateModel(...)
GovernorAlpha 只是投票系统。 真正执行高权限调用的是 Timelock。
23. Timelock 的核心参数
Timelock 里通常有:
address public admin;
uint public delay;
mapping(bytes32 => bool) public queuedTransactions;
含义:
admin:
谁能 queue / cancel / execute,一般是 GovernorAlpha
delay:
排队后必须等待多久才能执行
queuedTransactions:
哪些交易已经排队
Timelock 会限制:
交易必须先 queue
等待 delay
到 eta 后才能 execute
超过 grace period 可能过期
24. Timelock 的 queueTransaction
提案成功后,GovernorAlpha 调:
timelock.queueTransaction(
target,
value,
signature,
data,
eta
)
Timelock 检查:
msg.sender 必须是 admin
eta 必须 >= 当前时间 + delay
然后生成 txHash:
txHash = keccak256(target, value, signature, data, eta)
并记录:
queuedTransactions[txHash] = true;
这表示:
这笔治理调用已经排队。
25. Timelock 的 executeTransaction
等到时间到了,GovernorAlpha 可以调用:
timelock.executeTransaction(
target,
value,
signature,
data,
eta
)
Timelock 检查:
这笔交易已经 queued
当前时间 >= eta
当前时间 <= eta + gracePeriod
然后执行调用。
如果 signature 非空,会把函数选择器和 data 拼起来:
callData = abi.encodePacked(bytes4(keccak256(signature)), data)
然后:
target.call{value: value}(callData)
这就真正修改协议参数或升级实现。
26. execute:执行提案
GovernorAlpha 里的:
execute(uint proposalId)
会对提案里的每个 action 调 Timelock execute。
流程:
1. 检查提案状态是 Queued
2. 标记 proposal.executed = true
3. 遍历 targets / values / signatures / calldatas
4. 对每个 action 调 timelock.executeTransaction(...)
执行成功后,提案状态变成:
Executed
27. cancel:取消提案
提案可以被取消。
典型场景:
proposer 的票权跌破 proposalThreshold
提案还没执行
为什么 proposer 票权降低后可以取消?
为了防止某人短暂借票或获得委托后创建提案,之后失去治理支持,但提案仍继续推进。
cancel 会:
1. 标记 proposal.canceled = true
2. 取消 Timelock 中已 queue 的 action
如果提案还没 queue,就只标记 canceled。
28. proposal state 状态机
GovernorAlpha 通常有一个 state(proposalId) 函数。
常见状态:
Pending:
当前区块 <= startBlock
Active:
当前区块在 startBlock 和 endBlock 之间
Canceled:
已取消
Defeated:
投票结束,但反对票 >= 赞成票,或赞成票没达到 quorum
Succeeded:
投票通过,但还没 queue
Queued:
已进入 Timelock,等待执行
Expired:
queue 后超过 gracePeriod 没执行
Executed:
已执行
状态流大概是:
Pending
-> Active
-> Succeeded
-> Queued
-> Executed
Pending / Active / Succeeded / Queued
-> Canceled
Active
-> Defeated
Queued
-> Expired
29. 治理如何升级 Comptroller?
现在把第十二讲和这一讲串起来。
如果治理想升级 Comptroller implementation,大致提案 action 可能是:
target: Unitroller
signature: "_setPendingImplementation(address)"
calldata: newComptrollerImplementation
执行后:
Unitroller.pendingComptrollerImplementation = newImpl
然后还需要新 implementation 接受:
target: newComptrollerImplementation
signature: "_become(address)"
calldata: Unitroller address
或者调用对应 _acceptImplementation 流程,具体看版本实现。
完整治理链路:
COMP holders vote
-> GovernorAlpha proposal succeeds
-> GovernorAlpha queues actions in Timelock
-> delay passes
-> Timelock calls Unitroller / Comptroller implementation
-> Unitroller switches implementation
所以升级不是 admin 随手一改,而是治理延迟执行。
30. 治理如何修改市场参数?
比如治理想把某市场 collateralFactor 改成 80%。
提案 action 可能是:
target:
Comptroller / Unitroller address
signature:
"_setCollateralFactor(address,uint256)"
calldata:
cToken address
0.8e18
执行路径:
Timelock
-> Unitroller
-> delegatecall Comptroller implementation
-> 修改 Unitroller storage 里的 markets[cToken].collateralFactorMantissa
看到了吗?
这里同时涉及:
GovernorAlpha
Timelock
Unitroller proxy
Comptroller implementation
Comptroller storage layout
markets mapping
DeFi 治理的一个参数修改,背后是一整套机器在转。
31. 治理如何更换利率模型?
某个 cToken 市场的利率模型可以通过治理修改。
提案 action 可能是:
target:
cToken address, 例如 cDAI
signature:
"_setInterestRateModel(address)"
calldata:
newInterestRateModel address
执行路径:
Timelock
-> cDAI Delegator
-> delegatecall CErc20Delegate
-> 修改 cDAI storage 中的 interestRateModel
然后之后 accrueInterest() 时就会调用新模型。
注意,通常修改前会先:
accrueInterest()
确保旧模型下利息先结清到当前区块,再切新模型。
32. 治理如何新增市场?
新增市场通常会涉及:
部署新的 cToken
设置 interestRateModel
设置 initialExchangeRate
设置 reserveFactor
在 Comptroller 中 supportMarket
设置 collateralFactor
设置 priceOracle 支持
配置 COMP 分发
治理 action 可能包括:
Comptroller._supportMarket(cToken)
Comptroller._setCollateralFactor(cToken, factor)
Comptroller._setCompSpeed(cToken, speed)
Oracle 设置资产价格来源
新增市场很敏感,因为如果:
价格源错了
collateralFactor 太高
underlying token 有恶意行为
transfer 逻辑不标准
都可能给协议带来风险。
33. Governance 的安全边界
治理系统本身有几个安全点:
proposalThreshold 防垃圾提案
quorumVotes 防低参与度通过
votingDelay 给市场观察提案时间
votingPeriod 给投票时间
Timelock delay 给执行前缓冲时间
checkpoints 防止重复投票
cancel 防止失去支持的 proposer 推进提案
但它也有风险:
大户或委托集中导致治理捕获
借贷市场可借到治理代币时可能出现治理攻击
恶意提案伪装成正常参数调整
Timelock delay 太短,用户反应不及
投票参与率低
前端展示不完整,用户看不懂 calldata
所以治理不是银弹。 它只是把“单点管理员风险”换成“公开治理与延迟执行风险”。
34. COMP 治理源码骨架
COMP
|
|-- balances
|-- delegates[delegator] = delegatee
|-- checkpoints[account][index]
|-- numCheckpoints[account]
|
|-- delegate(delegatee)
|-- delegateBySig(...)
|-- getCurrentVotes(account)
|-- getPriorVotes(account, blockNumber)
|
|-- _moveDelegates(srcRep, dstRep, amount)
|-- _writeCheckpoint(delegatee, oldVotes, newVotes)
35. GovernorAlpha 源码骨架
GovernorAlpha
|
|-- comp
|-- timelock
|-- proposalCount
|-- proposals[id]
|-- latestProposalIds[proposer]
|
|-- propose(...)
|-- castVote(proposalId, support)
|-- queue(proposalId)
|-- execute(proposalId)
|-- cancel(proposalId)
|-- state(proposalId)
|
|-- proposalThreshold()
|-- quorumVotes()
|-- votingDelay()
|-- votingPeriod()
36. Timelock 源码骨架
Timelock
|
|-- admin
|-- pendingAdmin
|-- delay
|-- queuedTransactions[txHash]
|
|-- queueTransaction(target, value, signature, data, eta)
|-- cancelTransaction(target, value, signature, data, eta)
|-- executeTransaction(target, value, signature, data, eta)
|-- setDelay(delay)
|-- setPendingAdmin(pendingAdmin)
|-- acceptAdmin()
37. 一次完整治理流程
假设治理要把 cDAI reserveFactor 改成 15%。
流程:
1. Proposer 拥有足够 delegated COMP votes
2. Proposer 调用 GovernorAlpha.propose(...)
target = cDAI
signature = "_setReserveFactor(uint256)"
calldata = 0.15e18
3. 提案进入 Pending
4. 到 startBlock 后进入 Active
5. COMP voters 调用 castVote
6. 到 endBlock 后:
如果 forVotes > againstVotes
且 forVotes >= quorumVotes
提案变成 Succeeded
7. 任意人可调用 queue(proposalId)
8. GovernorAlpha 把 action queue 到 Timelock
9. 等待 delay
10. 任意人可调用 execute(proposalId)
11. Timelock 调用 cDAI._setReserveFactor(0.15e18)
12. cDAI 更新 reserveFactorMantissa
13. 之后 accrueInterest 时,新 reserveFactor 生效
38. 治理和用户的关系
普通用户最直接接触的是:
delegate 投票权
投票
查看提案
理解提案 calldata
协议开发者和安全研究员更关心:
提案调用了哪些 targets
修改了哪些参数
是否经过 Timelock
是否可能破坏 storage layout
是否修改 oracle / implementation / admin 权限
是否引入恶意市场
治理提案不能只看 description。 真正要看的是:
targets
signatures
calldatas
values
因为 description 可以写得很美,calldata 才是真动作。链上不会执行小作文,它只执行 bytes。
39. 第十三讲要记住的 8 个结论
第一,Compound v2 治理核心是:
COMP + GovernorAlpha + Timelock
第二,COMP 是治理代币,投票权通过 delegate 产生。
第三,checkpoints 记录历史投票权,防止投票期间转 token 重复投票。
第四,GovernorAlpha 负责提案、投票、排队和执行状态管理。
第五,Timelock 才是真正持有协议管理权限的延迟执行器。
第六,提案本质是一组链上调用:
targets + values + signatures + calldatas
第七,提案通过后必须先 queue 到 Timelock,等待 delay 后才能 execute。
第八,治理可以修改市场参数、升级实现、支持新市场、切换利率模型和预言机,因此治理权限本身就是协议安全边界。
下一讲进入 COMP 分发机制 / Flywheel。
也就是:
为什么供应和借款会获得 COMP?
compSupplyState / compBorrowState 是什么?
index 如何累计奖励?
supplierIndex / borrowerIndex 怎么避免遍历用户?
为什么它和 borrowIndex 思想很像?
claimComp 怎么算用户未领取奖励?
这部分很有意思,因为它是另一套“全局 index + 用户 snapshot”的设计,和我们前面讲的 borrowIndex 有异曲同工之妙。