Compound学习——DAO治理

Compound DAO治理投票的流程如下:

Compound有2个版本的治理合约,分别是Alpha和Bravo。治理合约和时间锁timelock配合使用,以提升治理的安全性。治理代币是Comp,用户持有Comp代币即拥有投票权,也可以将投票权委托给别人。

1 Comp代币

Comp是Compound的治理代币,在DAO治理的提案、投票过程过都发挥着重要的作用。

Comp首先是一个ERC20代币,因此它像其他ERC20代币那样会有transfer、approve等功能,不过这里主要探讨和治理投票相关的部分。

1.1 投票权委托

首先看一下_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);
        }
    }
}

1.2 查询投票权重

我们需要查询某个用户在某个快照区块的投票权重。刚才说到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;
}

2 Timelock时间锁

当某个提案通过投票后,如果没有时间锁,意味着它可以立刻被执行,但这是有一定风险的。因为可能大部分治理代币持有者并不会积极参与每个提案,所以攻击者操纵代币执行恶意操纵的难度比大多数人想象的要低。增加时间锁机制正是为了尽可能避免这样的风险,这使得提案通过之后,大家还有一个时间窗口可以用来对恶意提案紧急叫停。

在之前的介绍Compound的借贷合约时,我们提到过很多次管理员账户。实际上管理员账户就是这个时间锁,一切修改借贷合约的行为最终都要通过这个时间锁来执行。

2.1 Timelock的几个重要参数

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;

2.2 queue

这个函数在投票通过之后可以被调用。它并没有很复杂的操作,只是简单地在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;
}

2.3 cancel

取消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);
}

2.4 execute

提案执行。我们可以看到会判断当前时间是否在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;
}

3 GovernorAlpha

这是早期版本的治理合约。

3.1 发起提案

观察函数的参数,我们可以发现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;
}

3.2 投票

这里提供了两种方式,一种是自己发起交易投票,一种是签名后让别人帮自己发交易。后一种可以帮助小户节省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);
}

3.3 排队

该函数在投票通过后可以执行。

在判断投票通过之后,会调用_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;
    }
}

3.4 执行

该函数在提案进入排队状态后才可以执行。会通过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);
}

3.5 取消

提案发起之后、执行之前都可以取消。取消的条件需要满足以下条件中的任意一个:

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);
}

4 GovernorBravo

GovernorBravo是升级后的治理合约,和GovernorAlpha的功能大体类似,因此只介绍两者差异的部分。

4.1 可升级

和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保持不变,因此各种计数会天然和之前接上。

4.2 提案,增加白名单

不仅是持有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");
        ......
}

4.3 投票时可增添reason

增加了投票时的reason字段。

function castVoteWithReason(uint proposalId, uint8 support, string calldata reason) external {
    emit VoteCast(msg.sender, proposalId, support, castVoteInternal(msg.sender, proposalId, support), reason);
}

4.4 投票增加abstain选项

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);
}
Subscribe to rbtree
Receive the latest updates directly to your inbox.
Mint this entry as an NFT to add it to your collection.
Verification
This entry has been permanently stored onchain and signed by its creator.