Skip to content
On this page

自治化管理

如何建立链上管理

在这里教程中,我们会展示OpenZeppelin的管理合约是如何工作的,包括它是如何创建,如何创建一个提案,如何为提案投票,使用Ethers.js和Tally的工具。

介绍

去中心化提案从它公开发行开始就在不断演变,通常,初创团队在最初阶段掌控这一演变,但是最终会把它委派给token持有者所建立的组织。这个组织进行表决的过程称其为链上管理,它成为了去中心化协议中的重要组件,包括各种决议例如参数调整,智能合约升级,与其他协议的整合,资金管理,捐赠等等。

这个管理协议通常是用一个有特殊目的的合约来实现的,称为“Governor”。目前为止,Alpha和Bravo版的“Governor”设计的很成功,它们被广泛的使用,随着发展,各种项目会有更多不同的需求出现,这就要对原版协议进行分叉来满足他们的新需求,同时可以解决更高风险的安全问题。对于OpenZeppelin来说,我们将建立模块化的管理合约,这样使用者就不需要用分叉来实现,使用者可以通过继承来编写简单合约以满足他们的需求。你可以从OpenZeppelin获取各种合约满足常用需求,通过简单的编写就可以实现附加功能,根据社区的需求,我们也将在未来发布的版本中加入更多的功能。此外,OpenZeppelin Governor的设计是为了使用更小的存储空间和更高效的Gas使用率。

兼容性

OpenZeppelin的Governor系统设计之初,就考虑了现有基于Alpha和Bravo版本开发的系统。因此,你会发现很多模块会存在两个变量,它们是为了兼容其他系统而设定的。

ERC20Votes和ERC20VotesComp

有这种情况,ERC20位了追踪投票和表决进行了扩展。ERC20Votes是一般版本,它可以支持token提供数量超过2^96,ERC20VotesComp对这种情况进行了限制,ERC20VotesComp实现了基于Alpha和Bravo的COMP Token接口。两种合约使用了同样的事件(Event),所以对于事件来说它们是完全兼容的。

Governor 和 GovernorCompatibilityBravo

默认状态下,OpenZeppelin的Governor合约与Alpha和Bravo的接口是不兼容的,因为它们的方法是不同的,或者缺失的,虽然说它们的事件完全一致。不管怎样,也可以通过继承Alpha和Bravo的模块进行完全兼容。合约如果不进行继承,将会获得更加便宜的部署费用。

GovernorTimelockControl 和 GovernorTimelockCompound

当你在Governor合约中使用时间锁时,你可以使用OpenZeppelin的TimelockController或者混合的时间锁。基于选择不同的时间锁,需要调用不同的模块,分别是GovernorTimelockControl 和 GovernorTimelockCompound。这样做你就可以在不改变时间锁的情况下,把Alpha版本的实例迁移到OpenZeppelin版本的Governor。

Tally

对用户来说,Tally是一个成熟的链上管理应用。它包括投票显示板,提议创建工具,实时查询和分析,以及使用教程。 对于这些内容,Governor和Tally相比,有这些区别:用户可以创建提案,设置投票权和主题内容,浏览提案或者投票。某种特殊情况下,还可以使用管理员保护(Defender Admin)。 在剩余的教程中,我们将会专注于OpenZeppelin版本Governor本身的特性,不再更多的描述它与Alpha和Bravo版本的兼容性问题。

结构

Token

在我们的管理设置中,每个账户的投票权取决于ERC20 token的持有。这个token需要实现ERC20Votes扩展。这个扩展会追踪历史余额,这样投票权就可以通过历史快照和现在的余额对比进行回溯,这在防止双重投票中是非常重要的。

solidity
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.2;

import "@openzeppelin/contracts/token/ERC20/ERC20.sol";
import "@openzeppelin/contracts/token/ERC20/extensions/draft-ERC20Permit.sol";
import "@openzeppelin/contracts/token/ERC20/extensions/ERC20Votes.sol";

contract MyToken is ERC20, ERC20Permit, ERC20Votes {
    constructor() ERC20("MyToken", "MTK") ERC20Permit("MyToken") {}

    // The functions below are overrides required by Solidity.

    function _afterTokenTransfer(address from, address to, uint256 amount)
        internal
        override(ERC20, ERC20Votes)
    {
        super._afterTokenTransfer(from, to, amount);
    }

    function _mint(address to, uint256 amount)
        internal
        override(ERC20, ERC20Votes)
    {
        super._mint(to, amount);
    }

    function _burn(address account, uint256 amount)
        internal
        override(ERC20, ERC20Votes)
    {
        super._burn(account, amount);
    }
}

如果你的项目已经使用了现存的token并且它没有包含ERC20Votes,也不支持升级,这样的情况你可以使用ERC20Wrapper对它进行包装,从而得到了治理token。这样Token持有者就可以使用包装后的Token进行链上管理,包装后的Token与原Token是一对一的。

solidity
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.2;

import "@openzeppelin/contracts/token/ERC20/ERC20.sol";
import "@openzeppelin/contracts/token/ERC20/extensions/draft-ERC20Permit.sol";
import "@openzeppelin/contracts/token/ERC20/extensions/ERC20Votes.sol";
import "@openzeppelin/contracts/token/ERC20/extensions/ERC20Wrapper.sol";

contract MyToken is ERC20, ERC20Permit, ERC20Votes, ERC20Wrapper {
    constructor(IERC20 wrappedToken)
        ERC20("MyToken", "MTK")
        ERC20Permit("MyToken")
        ERC20Wrapper(wrappedToken)
    {}

    // The functions below are overrides required by Solidity.

    function _afterTokenTransfer(address from, address to, uint256 amount)
        internal
        override(ERC20, ERC20Votes)
    {
        super._afterTokenTransfer(from, to, amount);
    }

    function _mint(address to, uint256 amount)
        internal
        override(ERC20, ERC20Votes)
    {
        super._mint(to, amount);
    }

    function _burn(address account, uint256 amount)
        internal
        override(ERC20, ERC20Votes)
    {
        super._burn(account, amount);
    }
}

注意

投票可以取决于不同路径,比如:ERC20 token,ERC721token等等。所有这些都可以通过定制一个投票模块来支持Governor。其他的OpenZeppelin合约具有投票功能的,就是ERC721Votes。

Governor

首先,我们创建一个没有时间锁的Governor。核心逻辑已经在Governor合约中,但是我们仍然需要选择:1)怎样决定投票权 2)规定人数下可以投多少票 3)投票中有哪些选项,这些投票如何计算 4)何种类型的Token可以投票。这些方面都需要编写你自己的模块,或者简单点,从OpenZeppelin的合约中选择。
1)我们会使用GovernorVotes模块,它会包含一个IVotes实例,用来决定每个账户当提案激活时,基于Token余额的投票权。模块的构造函数中需要传入Token的地址。
2)我们会使用GovernorVotesQuorumFraction和ERC20Votes一起来定义在区块中总量百分比在提案投票权的检索。这个需要在构造函数中传入百分比参数。大多数Governor设置4%,因此在初始化的使用我们会使用这个数值 4(代表4%)。
3)我们会使用GovernorCountingSimple提供三种投票选项:For(同意),Against( 反对),Abstain(弃权),其中For和Abstain投票将计算入法定人数。 除了这些模块,Governor也需要设置一些参数:
votingDelay:提案创建后多久确定投票权。这个数值设置很大的话,就可以让用户有时间取消Token抵押用来投票。
votingPeriod:提案持续多久用来投票。
这些参数使用区块数来进行设置。假设产生一个区块需要13.14秒,我们设置votingDelay为1天的话,就是6570个区块,votingPeriod是一周,也就是45992个区块。

我们还可以设置投票门槛。它可以限制什么样的账户才能具有投票权。

solidity
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.2;

import "/openzeppelin/governance/Governor.sol";
import "/openzeppelin/governance/compatibility/GovernorCompatibilityBravo.sol";
import "/openzeppelin/governance/extensions/GovernorVotes.sol";
import "/openzeppelin/governance/extensions/GovernorVotesQuorumFraction.sol";
import "/openzeppelin/governance/extensions/GovernorTimelockControl.sol";

contract MyGovernor is Governor, GovernorCompatibilityBravo, GovernorVotes, GovernorVotesQuorumFraction, GovernorTimelockControl {
    constructor(IVotes _token, TimelockController _timelock)
        Governor("MyGovernor")
        GovernorVotes(_token)
        GovernorVotesQuorumFraction(4)
        GovernorTimelockControl(_timelock)
    {}

    function votingDelay() public pure override returns (uint256) {
        return 6575; // 1 day
    }

    function votingPeriod() public pure override returns (uint256) {
        return 46027; // 1 week
    }

    function proposalThreshold() public pure override returns (uint256) {
        return 0;
    }

    // The functions below are overrides required by Solidity.

    function quorum(uint256 blockNumber)
        public
        view
        override(IGovernor, GovernorVotesQuorumFraction)
        returns (uint256)
    {
        return super.quorum(blockNumber);
    }

    function getVotes(address account, uint256 blockNumber)
        public
        view
        override(IGovernor, GovernorVotes)
        returns (uint256)
    {
        return super.getVotes(account, blockNumber);
    }

    function state(uint256 proposalId)
        public
        view
        override(Governor, IGovernor, GovernorTimelockControl)
        returns (ProposalState)
    {
        return super.state(proposalId);
    }

    function propose(address[] memory targets, uint256[] memory values, bytes[] memory calldatas, string memory description)
        public
        override(Governor, GovernorCompatibilityBravo, IGovernor)
        returns (uint256)
    {
        return super.propose(targets, values, calldatas, description);
    }

    function _execute(uint256 proposalId, address[] memory targets, uint256[] memory values, bytes[] memory calldatas, bytes32 descriptionHash)
        internal
        override(Governor, GovernorTimelockControl)
    {
        super._execute(proposalId, targets, values, calldatas, descriptionHash);
    }

    function _cancel(address[] memory targets, uint256[] memory values, bytes[] memory calldatas, bytes32 descriptionHash)
        internal
        override(Governor, GovernorTimelockControl)
        returns (uint256)
    {
        return super._cancel(targets, values, calldatas, descriptionHash);
    }

    function _executor()
        internal
        view
        override(Governor, GovernorTimelockControl)
        returns (address)
    {
        return super._executor();
    }

    function supportsInterface(bytes4 interfaceId)
        public
        view
        override(Governor, IERC165, GovernorTimelockControl)
        returns (bool)
    {
        return super.supportsInterface(interfaceId);
    }
}

时间锁

给管理加上时间锁是一个不错的选择。这允许用户可以在不同意某个提案时,在它执行之前退出系统。我们会使用OpenZeppelin的TimelockController和GovernorTimelockControl模块一起来实现这一功能。

重点:

当使用时间锁后,时间锁就会执行提案,并且将持有资金,所有权和角色访问权。在4.5版本之前,Governor合约使用时间锁还无法恢复资金,在4.3版本之前,使用混合时间锁,ETH在时间锁中并不容易获取。

TimelockController使用AccessControl设置,我们需要理解以下角色:
1.Proposer角色用来掌管排队操作:这是个角色。Governor实例应该被授权,并且应该是系统中唯一的提案。
2.Executor角色用来掌管已经可以使用的操作:我们可以赋予这个角色一个空地址,这样所有的人都可以执行操作(如果操作是时间敏感的,Governor应该来扮演Executor这个角色)
3.Admin角色,可以授权或取消授权以上两种角色:这个角色可以自动给部署者和时间锁进行授权,同时应该在部署后取消这个角色。

提案的生命周期

接下来我们将讨论如何创建和执行我们的Governor提案。如果提案被通过,那么Governor将执行提案中一系列的操作指令。每个操作都会包含有目标地址,功能调用,还包含一定量的ETH。此外提案还会包含我们可以阅读的一些描述。

创建提案

假设我们要创建一个提案,从管理金库中,提供给某个团队一笔ERC20 Token捐款。这个提案会以ERC20 Token为操作对象,调用Transfer(team wallet,grant amount)方法,不会包含ETH。
通常提案的创建会通过Tally或Defender接口来实现。这里我们将展示如果用Ethers.js来创建提案。
首先我们将获取提案执行时所需的所有参数。

js
const tokenAddress = ...;
const token = await ethers.getContractAt(‘ERC20’, tokenAddress);

const teamAddress = ...;
const grantAmount = ...;
const transferCalldata = token.interface.encodeFunctionData(‘transfer’, [teamAddress, grantAmount]);

现在我们已经准备了Governor的执行方法。注意我们不是通过一个数组来给操作传值,而是通过三个数组来指定响应的目标,包含一系列值,一系列数据。以下情况是一个操作,可以这样简单实现:

js
await governor.propose(
[tokenAddress],
[0],
[transferCalldata],
“Proposal #1: Give grant to team”,
);

这会创建一个新的提案,包括通过提案数据的HASH算法生成的提案ID,这些信息在交易日志中也可以找到。

投票

当一个提案被激活后,代表们可以进行投票。注意这决定于谁拥有投票权:如果Token持有者想参与,它们可以委托一个值得信赖的代表来进行,或者可以自行代表行使投票权。
投票是通过执行Governor合约的castVote方法来进行的。投票者会调用类似如下的用户界面,比如Tally:
投票

执行合约

当投票完成后,如果达到了规定人数(行使了足够的投票权)并且多数支持提案,那么提案就会认定为成功的,进入执行阶段。这可以通过Tally的管理员面板来完成:
合约管理

我们现在可以看看如果通过Ethers.js来手工实现。
如果设置了时间锁,第一步是要执行队列。你会发现无论队列还是执行方法都需要传递全部的提案参数,用来判断提案ID是否一致。这很有必要,因为出于节约Gas的目的,这些数据没有储存在链上。注意这些参数通常可以在合约的事件提交中找到。描述是唯一不需要传递完整值的参数,只是用来进行提案ID的Hash运算。
进行队列操作,我们调用如下方法:

js
const descriptionHash = ethers.utils.id(“Proposal #1: Give grant to team”);

await governor.queue(
[tokenAddress],
[0],
[transferCalldata],
descriptionHash,
);

这样就会让Governor触发时间锁合约,并且在一定的延时后,对操作进行队列操作。
在过去足够的时间后(取决于时间锁参数),合约将会被执行。如果开始阶段没有时间锁,那么这个步骤将会变成,在提案通过后直接执行。

js
    await governor.execute(
    [tokenAddress],
    [0],
    [transferCalldata],
    descriptionHash,
    );

执行提案会讲ERC20 Token转给指定的接收者。总结一些,就是我们建立了一个系统,可以由Token的持有者来控制资金使用,并且一切的操作,都是通过链上的提案来执行的。

Released under the MIT License.