Compound DAO治理投票的流程如下:
Compound有2个版本的治理合约,分别是Alpha和Bravo。治理合约和时间锁timelock配合使用,以提升治理的安全性。治理代币是Comp,用户持有Comp代币即拥有投票权,也可以将投票权委托给别人。
Comp是Compound的治理代币,在DAO治理的提案、投票过程过都发挥着重要的作用。
Comp首先是一个ERC20代币,因此它像其他ERC20代币那样会有transfer、approve等功能,不过这里主要探讨和治理投票相关的部分。
首先看一下_delegate函数,这个函数只是简单记录了一下委托关系,主要的投票权转移在_moveDelegates。
function _delegate(address delegator, address delegatee) internal {
address currentDelegate = delegates[delegator];
uint96 delegatorBalance = balances[delegator];
delegates[delegator] = delegatee;
emit DelegateChanged(delegator, currentDelegate, delegatee);
_moveDelegates(currentDelegate, delegatee, delegatorBalance);
}
在解释_moveDelegates的代码之前,我们需要先了解一个结构——checkpoints。为什么这里要设计得这么复杂呢?因为在启动投票的时候,我们一般会指定一个快照时间,而不是在投票人投票的瞬间去查询它的投票权重,以防止有攻击者临时购买大量代币发起攻击。为了实现这个功能,我们不仅需要记录当下某个用户的投票权重,还需要记录用户的历史投票权重。
numCheckpoints和checkpoints就是用来记录历史投票权重的数据结构,每次用户的投票权重发生变化的时候(修改委托人或代币转移),都需要更新这个结构。
我们以用户user1来举例,最初的时候,ta的投票权重记录在checkpoints[user1][0]中,此时numCheckpoints[user1]=1; 当user1的投票权重发生第一次变更时,新的投票权重会记录在checkpoints[user1][1],同时numCheckpoints[user1]会被更新为2.
因为我们在快照的时候一般会指定到具体区块,所以checkpoints中存储的不仅仅是当时的投票权重,还有当时的blocknumber。
下面的_writeCheckpoint就是投票权重发生变化时会调用的函数。里面有一个if判断,如果if成立说明是同一个区块发生了多次投票权变更,那么直接以后一次的为准即可;如果if不成立则需要在map里增添一条记录,并把numCheckpoints增加1.
/// @notice A checkpoint for marking number of votes from a given block
struct Checkpoint {
uint32 fromBlock;
uint96 votes;
}
/// @notice A record of votes checkpoints for each account, by index
mapping (address => mapping (uint32 => Checkpoint)) public checkpoints;
/// @notice The number of checkpoints for each account
.mapping (address => uint32) public numCheckpoints;
function _writeCheckpoint(address delegatee, uint32 nCheckpoints, uint96 oldVotes, uint96 newVotes) internal {
uint32 blockNumber = safe32(block.number, "Comp::_writeCheckpoint: block number exceeds 32 bits");
if (nCheckpoints > 0 && checkpoints[delegatee][nCheckpoints - 1].fromBlock == blockNumber) {
checkpoints[delegatee][nCheckpoints - 1].votes = newVotes;
} else {
checkpoints[delegatee][nCheckpoints] = Checkpoint(blockNumber, newVotes);
numCheckpoints[delegatee] = nCheckpoints + 1;
}
emit DelegateVotesChanged(delegatee, oldVotes, newVotes);
}
我们回到_moveDelegates,在委托的时候,委托人的投票权重会减小,被委托人的投票权重会增大,因此需要同时处理两方。在处理的时候,要先从checkpoints查询上次更新后的投票权重RepOld,然后加/减本次的amount,再调用_writeCheckpoint写入map。
这个函数不仅在委托变更的时候都会被调用,代币转移(transfer、transferfrom)也会调用。
function _moveDelegates(address srcRep, address dstRep, uint96 amount) internal {
if (srcRep != dstRep && amount > 0) {
if (srcRep != address(0)) {
uint32 srcRepNum = numCheckpoints[srcRep];
uint96 srcRepOld = srcRepNum > 0 ? checkpoints[srcRep][srcRepNum - 1].votes : 0;
uint96 srcRepNew = sub96(srcRepOld, amount, "Comp::_moveVotes: vote amount underflows");
_writeCheckpoint(srcRep, srcRepNum, srcRepOld, srcRepNew);
}
if (dstRep != address(0)) {
uint32 dstRepNum = numCheckpoints[dstRep];
uint96 dstRepOld = dstRepNum > 0 ? checkpoints[dstRep][dstRepNum - 1].votes : 0;
uint96 dstRepNew = add96(dstRepOld, amount, "Comp::_moveVotes: vote amount overflows");
_writeCheckpoint(dstRep, dstRepNum, dstRepOld, dstRepNew);
}
}
}
我们需要查询某个用户在某个快照区块的投票权重。刚才说到checkpoints按照时间顺序记录了历史投票记录变更,如果我们把历史记录遍历一边,就可以确认它在某个区块的权重了。不过因为记录是有序的,所以我们不必线性查找,而是可以二分查找以提升效率。
function getPriorVotes(address account, uint blockNumber) public view returns (uint96) {
require(blockNumber < block.number, "Comp::getPriorVotes: not yet determined");
uint32 nCheckpoints = numCheckpoints[account];
if (nCheckpoints == 0) {
return 0;
}
// First check most recent balance
if (checkpoints[account][nCheckpoints - 1].fromBlock <= blockNumber) {
return checkpoints[account][nCheckpoints - 1].votes;
}
// Next check implicit zero balance
if (checkpoints[account][0].fromBlock > blockNumber) {
return 0;
}
uint32 lower = 0;
uint32 upper = nCheckpoints - 1;
while (upper > lower) {
uint32 center = upper - (upper - lower) / 2; // ceil, avoiding overflow
Checkpoint memory cp = checkpoints[account][center];
if (cp.fromBlock == blockNumber) {
return cp.votes;
} else if (cp.fromBlock < blockNumber) {
lower = center;
} else {
upper = center - 1;
}
}
return checkpoints[account][lower].votes;
}
当某个提案通过投票后,如果没有时间锁,意味着它可以立刻被执行,但这是有一定风险的。因为可能大部分治理代币持有者并不会积极参与每个提案,所以攻击者操纵代币执行恶意操纵的难度比大多数人想象的要低。增加时间锁机制正是为了尽可能避免这样的风险,这使得提案通过之后,大家还有一个时间窗口可以用来对恶意提案紧急叫停。
在之前的介绍Compound的借贷合约时,我们提到过很多次管理员账户。实际上管理员账户就是这个时间锁,一切修改借贷合约的行为最终都要通过这个时间锁来执行。
admin:后面即将介绍的Compound治理(Governor)合约。
delay:投票通过之后,需要等待多久才可以执行。目前这个值的设定时2天。(这个数字可以在2-30天之间调整)。
GRACE_PERIOD:过期时间。等待期完毕后,如果再超过14day,那么提案将不再能执行。
admin和dalay本身也是可以通过DAO治理投票修改的。
uint public constant GRACE_PERIOD = 14 days;
uint public constant MINIMUM_DELAY = 2 days;
uint public constant MAXIMUM_DELAY = 30 days;
address public admin;
address public pendingAdmin;
uint public delay;
mapping (bytes32 => bool) public queuedTransactions;
这个函数在投票通过之后可以被调用。它并没有很复杂的操作,只是简单地在queuedTransactions这个map里做了记录,包括target, value, signature, data, eta,前几个变量时执行提案需要的参数,最后一个时可以执行的时间(queue时间+2天)。
function queueTransaction(address target, uint value, string memory signature, bytes memory data, uint eta) public returns (bytes32) {
require(msg.sender == admin, "Timelock::queueTransaction: Call must come from admin.");
require(eta >= getBlockTimestamp().add(delay), "Timelock::queueTransaction: Estimated execution block must satisfy delay.");
bytes32 txHash = keccak256(abi.encode(target, value, signature, data, eta));
queuedTransactions[txHash] = true;
emit QueueTransaction(txHash, target, value, signature, data, eta);
return txHash;
}
取消queue。这部分判断逻辑在Governor合约中,后面会讲到,主要是为了防止有攻击者故意发起恶意提案。
function cancelTransaction(address target, uint value, string memory signature, bytes memory data, uint eta) public {
require(msg.sender == admin, "Timelock::cancelTransaction: Call must come from admin.");
bytes32 txHash = keccak256(abi.encode(target, value, signature, data, eta));
queuedTransactions[txHash] = false;
emit CancelTransaction(txHash, target, value, signature, data, eta);
}
提案执行。我们可以看到会判断当前时间是否在eta~eta+GRACE_PERIOD之间,即提案通过后等待期完成,并且没有过期。真正的执行语句是target.call,这里使用low level call的方式完成了提案的执行。
因为timelock合约是最终执行提案的合约,所以compound协议的资产也都应该放在timelock合约中,以便提案可以方便地对资产做出处置。
function executeTransaction(address target, uint value, string memory signature, bytes memory data, uint eta) public payable returns (bytes memory) {
require(msg.sender == admin, "Timelock::executeTransaction: Call must come from admin.");
bytes32 txHash = keccak256(abi.encode(target, value, signature, data, eta));
require(queuedTransactions[txHash], "Timelock::executeTransaction: Transaction hasn't been queued.");
require(getBlockTimestamp() >= eta, "Timelock::executeTransaction: Transaction hasn't surpassed time lock.");
require(getBlockTimestamp() <= eta.add(GRACE_PERIOD), "Timelock::executeTransaction: Transaction is stale.");
queuedTransactions[txHash] = false;
bytes memory callData;
if (bytes(signature).length == 0) {
callData = data;
} else {
callData = abi.encodePacked(bytes4(keccak256(bytes(signature))), data);
}
// solium-disable-next-line security/no-call-value
(bool success, bytes memory returnData) = target.call{value: value}(callData);
require(success, "Timelock::executeTransaction: Transaction execution reverted.");
emit ExecuteTransaction(txHash, target, value, signature, data, eta);
return returnData;
}
这是早期版本的治理合约。
观察函数的参数,我们可以发现propose的前4个参数都是数组,这是为了支持一个提案包含多笔交易,数组中的每个元素都对应一笔交易。
target: 目标交互合约
value: 交易的msg.value.
signature: 希望交互的函数签名
calldata:函数参数
description:提案描述
这个函数的逻辑非常简单,首先是判断提案人是否具备资格。阈值是proposalThreshold(),目前的设置是25000 Comp。
接下来判断了提案的执行函数是否合法,以及提案人是否同时提了多个提案。
最后是把提案的内容记录在了proposals之中,这里不仅记录了执行参数,还有和投票相关的参数,如投票状态forVotes、againstVotes,提案状态canceled、executed,投票的起止时间startBlock、endBlock。目前,votingDelay()大约是2天,votingPeriod()大约是3天,即发起提案后2天后可以开始投票,投票时间为3天。
function propose(address[] memory targets, uint[] memory values, string[] memory signatures, bytes[] memory calldatas, string memory description) public returns (uint) {
require(comp.getPriorVotes(msg.sender, sub256(block.number, 1)) > proposalThreshold(), "GovernorAlpha::propose: proposer votes below proposal threshold");
require(targets.length == values.length && targets.length == signatures.length && targets.length == calldatas.length, "GovernorAlpha::propose: proposal function information arity mismatch");
require(targets.length != 0, "GovernorAlpha::propose: must provide actions");
require(targets.length <= proposalMaxOperations(), "GovernorAlpha::propose: too many actions");
uint latestProposalId = latestProposalIds[msg.sender];
if (latestProposalId != 0) {
ProposalState proposersLatestProposalState = state(latestProposalId);
require(proposersLatestProposalState != ProposalState.Active, "GovernorAlpha::propose: one live proposal per proposer, found an already active proposal");
require(proposersLatestProposalState != ProposalState.Pending, "GovernorAlpha::propose: one live proposal per proposer, found an already pending proposal");
}
uint startBlock = add256(block.number, votingDelay());
uint endBlock = add256(startBlock, votingPeriod());
proposalCount++;
uint proposalId = proposalCount;
Proposal storage newProposal = proposals[proposalId];
// This should never happen but add a check in case.
require(newProposal.id == 0, "GovernorAlpha::propose: ProposalID collsion");
newProposal.id = proposalId;
newProposal.proposer = msg.sender;
newProposal.eta = 0;
newProposal.targets = targets;
newProposal.values = values;
newProposal.signatures = signatures;
newProposal.calldatas = calldatas;
newProposal.startBlock = startBlock;
newProposal.endBlock = endBlock;
newProposal.forVotes = 0;
newProposal.againstVotes = 0;
newProposal.canceled = false;
newProposal.executed = false;
latestProposalIds[newProposal.proposer] = newProposal.id;
emit ProposalCreated(newProposal.id, msg.sender, targets, values, signatures, calldatas, startBlock, endBlock, description);
return newProposal.id;
}
这里提供了两种方式,一种是自己发起交易投票,一种是签名后让别人帮自己发交易。后一种可以帮助小户节省gas。
function castVote(uint proposalId, bool support) public;
function castVoteBySig(uint proposalId, bool support, uint8 v, bytes32 r, bytes32 s) public;
投票函数的主要逻辑也很简单,在进行基本校验(判断提案是否处于投票期、投票人是否重复投票)之后,首先计算投票人的权重,然后更新proposal.forVotes或proposal.againstVotes的值。
function _castVote(address voter, uint proposalId, bool support) internal {
require(state(proposalId) == ProposalState.Active, "GovernorAlpha::_castVote: voting is closed");
Proposal storage proposal = proposals[proposalId];
Receipt storage receipt = proposal.receipts[voter];
require(receipt.hasVoted == false, "GovernorAlpha::_castVote: voter already voted");
uint96 votes = comp.getPriorVotes(voter, proposal.startBlock);
if (support) {
proposal.forVotes = add256(proposal.forVotes, votes);
} else {
proposal.againstVotes = add256(proposal.againstVotes, votes);
}
receipt.hasVoted = true;
receipt.support = support;
receipt.votes = votes;
emit VoteCast(voter, proposalId, support, votes);
}
该函数在投票通过后可以执行。
在判断投票通过之后,会调用_queueOrRevert,把提案中的每一个交易都通过 timelock.queueTransaction丢给时间锁函数,时间锁部分上一小节已经看过了。
function queue(uint proposalId) public {
require(state(proposalId) == ProposalState.Succeeded, "GovernorAlpha::queue: proposal can only be queued if it is succeeded");
Proposal storage proposal = proposals[proposalId];
uint eta = add256(block.timestamp, timelock.delay());
for (uint i = 0; i < proposal.targets.length; i++) {
_queueOrRevert(proposal.targets[i], proposal.values[i], proposal.signatures[i], proposal.calldatas[i], eta);
}
proposal.eta = eta;
emit ProposalQueued(proposalId, eta);
}
function _queueOrRevert(address target, uint value, string memory signature, bytes memory data, uint eta) internal {
require(!timelock.queuedTransactions(keccak256(abi.encode(target, value, signature, data, eta))), "GovernorAlpha::_queueOrRevert: proposal action already queued at eta");
timelock.queueTransaction(target, value, signature, data, eta);
}
那么如何判断投票投过呢?我们看到判断提案状态的函数如下。
判断提案Defeated还是Succeeded的关键逻辑是:
proposal.forVotes <= proposal.againstVotes || proposal.forVotes < quorumVotes()
这意味着如果提案成功,需要有2个条件,赞成数>反对数并且赞成票数超过法定数。目前的法定数是400000 Comp。Comp的总量是10000000,目前流通量为7267152。
function state(uint proposalId) public view returns (ProposalState) {
require(proposalCount >= proposalId && proposalId > 0, "GovernorAlpha::state: invalid proposal id");
Proposal storage proposal = proposals[proposalId];
if (proposal.canceled) {
return ProposalState.Canceled;
} else if (block.number <= proposal.startBlock) {
return ProposalState.Pending;
} else if (block.number <= proposal.endBlock) {
return ProposalState.Active;
} else if (proposal.forVotes <= proposal.againstVotes || proposal.forVotes < quorumVotes()) {
return ProposalState.Defeated;
} else if (proposal.eta == 0) {
return ProposalState.Succeeded;
} else if (proposal.executed) {
return ProposalState.Executed;
} else if (block.timestamp >= add256(proposal.eta, timelock.GRACE_PERIOD())) {
return ProposalState.Expired;
} else {
return ProposalState.Queued;
}
}
该函数在提案进入排队状态后才可以执行。会通过timelock.executeTransaction调用时间锁,时间锁的execute我们上一小节也已经说明,会判断时间是否超过2天,否则不会执行。
这个函数对发起者没有任何限制,即一旦Compound的提案通过并通过等待期,任何人都可以执行提案。
function execute(uint proposalId) public payable {
require(state(proposalId) == ProposalState.Queued, "GovernorAlpha::execute: proposal can only be executed if it is queued");
Proposal storage proposal = proposals[proposalId];
proposal.executed = true;
for (uint i = 0; i < proposal.targets.length; i++) {
timelock.executeTransaction{value: proposal.values[i]}(proposal.targets[i], proposal.values[i], proposal.signatures[i], proposal.calldatas[i], proposal.eta);
}
emit ProposalExecuted(proposalId);
}
提案发起之后、执行之前都可以取消。取消的条件需要满足以下条件中的任意一个:
comp.getPriorVotes(proposal.proposer, sub256(block.number, 1)) < proposalThreshold(),即提案人当前的投票权小于提案门槛。这应该是为了防止有攻击者短期购买大量Comp代币,发起恶意投票,然后立刻卖出代币的行为。
msg.sender == guardian,这个guardian时Compound项目的多签钱包。这可以看成是紧急情况下的紧急停止按钮。
如果上面的判断通过,会进入timelock的cancel函数。
function cancel(uint proposalId) public {
ProposalState state = state(proposalId);
require(state != ProposalState.Executed, "GovernorAlpha::cancel: cannot cancel executed proposal");
Proposal storage proposal = proposals[proposalId];
require(msg.sender == guardian || comp.getPriorVotes(proposal.proposer, sub256(block.number, 1)) < proposalThreshold(), "GovernorAlpha::cancel: proposer above threshold");
proposal.canceled = true;
for (uint i = 0; i < proposal.targets.length; i++) {
timelock.cancelTransaction(proposal.targets[i], proposal.values[i], proposal.signatures[i], proposal.calldatas[i], proposal.eta);
}
emit ProposalCanceled(proposalId);
}
GovernorBravo是升级后的治理合约,和GovernorAlpha的功能大体类似,因此只介绍两者差异的部分。
和GovernorAlpha不可升级不同,GovernorBravo是一个可升级代理。
contract GovernorBravoDelegator is GovernorBravoDelegatorStorage, GovernorBravoEvents {
......
function _setImplementation(address implementation_) public {
require(msg.sender == admin, "GovernorBravoDelegator::_setImplementation: admin only");
require(implementation_ != address(0), "GovernorBravoDelegator::_setImplementation: invalid implementation address");
address oldImplementation = implementation;
implementation = implementation_;
emit NewImplementation(oldImplementation, implementation);
}
fallback () external payable {
// delegate all other functions to current implementation
(bool success, ) = implementation.delegatecall(msg.data);
assembly {
let free_mem_ptr := mload(0x40)
returndatacopy(free_mem_ptr, 0, returndatasize())
switch success
case 0 { revert(free_mem_ptr, returndatasize()) }
default { return(free_mem_ptr, returndatasize()) }
}
}
}
这项修改之后,Compound升级治理合约更方便了。之前GovernorAlpha本身不可升级,如果像升级治理模块,需要重新部署,此时合约地址会改变,因此需要改变timelock的管理员。
现在,升级的时候,timelock的管理员不再需要变更。
不仅如此,如果你是一个Compound治理活跃用户,如果是alpha的升级,为了应对governor地址的修改,你投票的相关参数也需要修改。特别是如果用户使用托管钱包,可能还需要托管方支持相关修改,这也给用户带来了一些麻烦。
另外,在alpha版本,因为升级之后地址会变更,之前的提案计数会丢失,需要外多一些设置才能和之前的编号接上。而bravo版本,升级的时候合约地址不变、storage保持不变,因此各种计数会天然和之前接上。
不仅是持有40000Comp投票权的人可以提案,白名单用户也可以提案。
function propose(address[] memory targets, uint[] memory values, string[] memory signatures, bytes[] memory calldatas, string memory description) public returns (uint) {
require(initialProposalId != 0, "GovernorBravo::propose: Governor Bravo not active");
// Allow addresses above proposal threshold and whitelisted addresses to propose
require(comp.getPriorVotes(msg.sender, sub256(block.number, 1)) > proposalThreshold || isWhitelisted(msg.sender), "GovernorBravo::propose: proposer votes below proposal threshold");
......
}
增加了投票时的reason字段。
function castVoteWithReason(uint proposalId, uint8 support, string calldata reason) external {
emit VoteCast(msg.sender, proposalId, support, castVoteInternal(msg.sender, proposalId, support), reason);
}
alpla版本只有for和against两个选项,bravo增加abstain。判定提案通过的逻辑保持不变,依然是for>against且for超过法定数。
if (support == 0) {
proposal.againstVotes = add256(proposal.againstVotes, votes);
} else if (support == 1) {
proposal.forVotes = add256(proposal.forVotes, votes);
} else if (support == 2) {
proposal.abstainVotes = add256(proposal.abstainVotes, votes);
}