本文档介绍了如何使用 OpenZeppelin 的 Governor 合约搭建链上治理系统。包括 Governor 合约的工作原理、设置方法,以及如何使用 Ethers.js 和 Tally 创建、投票和执行提案。同时,还讨论了与 Compound 的 GovernorAlpha 和 GovernorBravo 系统的兼容性,并介绍了基于时间戳的治理方式。
在本指南中,我们将学习 OpenZeppelin 的 Governor 合约如何工作,如何设置它,以及如何使用它来创建提案、投票和执行提案,使用 Ethers.js 和 Tally 提供的工具。
在 Governance API 查找详细的合约文档。 |
去中心化协议从公开发布的那一刻起就处于不断演变中。通常,最初的团队在最初阶段保留对此演变的控制权,但最终将其委托给利益相关者社区。这个社区做出决策的过程称为链上治理,它已成为去中心化协议的核心组成部分,推动着各种决策,例如参数调整、智能合约升级、与其他协议的集成、资金管理、赠款等。
这种治理协议通常在一种称为“Governor”的专用合约中实现。Compound 设计的 GovernorAlpha 和 GovernorBravo 合约到目前为止非常成功和流行,但缺点是具有不同要求的项目必须 fork 代码以根据自己的需求进行自定义,这可能会带来引入安全问题的高风险。对于 OpenZeppelin Contracts,我们着手构建一个模块化的 Governor 合约系统,这样就不需要 fork,并且可以通过使用 Solidity 继承编写小型模块来满足不同的需求。你将在 OpenZeppelin Contracts 中找到最常见的现成需求,但编写额外的需求很简单,并且我们将在未来的版本中添加社区要求的新功能。此外,OpenZeppelin Governor 的设计需要最少地使用存储,从而提高了 gas 效率。
OpenZeppelin 的 Governor 系统在设计时考虑了与基于 Compound 的 GovernorAlpha 和 GovernorBravo 的现有系统的兼容性。因此,你会发现许多模块以两种变体呈现,其中一种是为与这些系统兼容而构建的。
用于跟踪投票和投票委托的 ERC-20 扩展就是其中一个例子。较短的一个是更通用的版本,因为它支持大于 2^96 的 token 供应,而“Comp”变体在这方面受到限制,但完全符合 GovernorAlpha 和 Bravo 使用的 COMP token 的接口。两种合约变体都共享相同的事件,因此仅在查看事件时它们是完全兼容的。
OpenZeppelin Governor 合约与 Compound 的 GovernorAlpha 或 Bravo 在接口上不兼容。即使事件完全兼容,提案生命周期函数(创建、执行等)也具有不同的签名,旨在优化存储使用。GovernorAlpha 和 Bravo 的其他函数同样不可用。可以通过继承 GovernorStorage 模块来选择加入一些类似 Bravo 的行为。此模块提供提案可枚举性和 `queue`、`execute` 和 `cancel` 函数的替代版本,这些函数仅采用提案 ID。此模块减少了一些操作所需的 calldata,以换取增加的存储空间。对于某些 L2 链,这可能是一个很好的权衡。它还为无需 indexer 的前端提供原语。
请注意,即使使用此模块,与 Compound 的 GovernorBravo 的一个重要区别在于计算 `proposalId` 的方式。Governor 使用提案参数的哈希,目的是通过事件索引将数据保存在链下,而原始 Bravo 实现使用连续的 `proposalId`。
将时间锁与你的 Governor 合约一起使用时,你可以使用 OpenZeppelin 的 TimelockController 或 Compound 的 Timelock。根据时间锁的选择,你应该选择相应的 Governor 模块:分别是 GovernorTimelockControl 或 GovernorTimelockCompound。这允许你将现有的 GovernorAlpha 实例迁移到基于 OpenZeppelin 的 Governor,而无需更改正在使用的时间锁。
Tally 是一个功能齐全的应用程序,用于用户拥有的链上治理。它包括一个投票仪表板、提案创建向导、实时研究和分析以及教育内容。
对于所有这些选项,Governor 将与 Tally 兼容:用户将能够创建提案,查看符合 IERC6372 的投票期和延迟,可视化投票权和倡导者,浏览提案以及进行投票。特别是对于提案创建,项目还可以使用 Defender Transaction Proposals 作为替代界面。
在本指南的其余部分,我们将专注于全新部署的 vanillaOpenZeppelin Governor 功能,而不考虑与 GovernorAlpha 或 Bravo 的兼容性。
我们治理设置中每个帐户的投票权将由 ERC-20 token 确定。该 token 必须实现 ERC20Votes 扩展。此扩展将跟踪历史余额,以便从过去的快照而不是当前余额中检索投票权,这是一项重要的保护措施,可以防止重复投票。
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;
import {ERC20} from "@openzeppelin/contracts/token/ERC20/ERC20.sol";
import {ERC20Permit} from "@openzeppelin/contracts/token/ERC20/extensions/ERC20Permit.sol";
import {ERC20Votes} from "@openzeppelin/contracts/token/ERC20/extensions/ERC20Votes.sol";
import {Nonces} from "@openzeppelin/contracts/utils/Nonces.sol";
contract MyToken is ERC20, ERC20Permit, ERC20Votes {
constructor() ERC20("MyToken", "MTK") ERC20Permit("MyToken") {}
// The functions below are overrides required by Solidity.
// 以下函数是 Solidity 要求的重写。
function _update(address from, address to, uint256 amount) internal override(ERC20, ERC20Votes) {
super._update(from, to, amount);
}
function nonces(address owner) public view virtual override(ERC20Permit, Nonces) returns (uint256) {
return super.nonces(owner);
}
}
如果你的项目已经有了一个没有包含 ERC20Votes 并且不可升级的实时 token,你可以使用 ERC20Wrapper 将其包装在一个治理 token 中。这将允许 token 持有者通过 1 比 1 包装他们的 token 来参与治理。
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;
import {IERC20, ERC20} from "@openzeppelin/contracts/token/ERC20/ERC20.sol";
import {ERC20Permit} from "@openzeppelin/contracts/token/ERC20/extensions/ERC20Permit.sol";
import {ERC20Votes} from "@openzeppelin/contracts/token/ERC20/extensions/ERC20Votes.sol";
import {ERC20Wrapper} from "@openzeppelin/contracts/token/ERC20/extensions/ERC20Wrapper.sol";
import {Nonces} from "@openzeppelin/contracts/utils/Nonces.sol";
contract MyTokenWrapped is ERC20, ERC20Permit, ERC20Votes, ERC20Wrapper {
constructor(
IERC20 wrappedToken
) ERC20("MyTokenWrapped", "MTK") ERC20Permit("MyTokenWrapped") ERC20Wrapper(wrappedToken) {}
// The functions below are overrides required by Solidity.
// 以下函数是 Solidity 要求的重写。
function decimals() public view override(ERC20, ERC20Wrapper) returns (uint8) {
return super.decimals();
}
function _update(address from, address to, uint256 amount) internal override(ERC20, ERC20Votes) {
super._update(from, to, amount);
}
function nonces(address owner) public view virtual override(ERC20Permit, Nonces) returns (uint256) {
return super.nonces(owner);
}
}
OpenZeppelin Contracts 中目前唯一可用的投票权来源是 ERC721Votes 。不提供此功能的 ERC-721 token 可以使用 ERC721Votes 和 ERC721Wrapper 的组合包装到投票 token 中。 |
token 用于存储投票余额的内部时钟将决定附加到它的 Governor 合约的运行模式。默认情况下,使用区块号。自 v4.9 以来,开发人员可以覆盖 IERC6372 时钟以使用时间戳而不是区块号。 |
最初,我们将构建一个没有时间锁的 Governor。核心逻辑由 Governor 合约给出,但我们仍然需要选择:1) 如何确定投票权,2) 达到法定人数需要多少票数,3) 人们在投票时有哪些选择以及如何计算这些票数,以及 4) 应使用哪种类型的 token 进行投票。通过编写你自己的模块,或者更轻松地从 OpenZeppelin Contracts 中选择一个模块,可以自定义这些方面的每一个。
对于 1),我们将使用 GovernorVotes 模块,该模块Hook到一个 IVotes 实例,以根据帐户在提案生效时持有的 token 余额来确定帐户的投票权。此模块需要 token 的地址作为构造函数参数。此模块还会发现 token 使用的时钟模式 (ERC-6372) 并将其应用于 Governor。
对于 2),我们将使用 GovernorVotesQuorumFraction,它与 ERC20Votes 一起工作,以将法定人数定义为检索提案投票权时总供应量的百分比。这需要一个构造函数参数来设置百分比。现在大多数 Governor 使用 4%,因此我们将使用参数 4 初始化模块(这表示百分比,导致 4%)。
对于 3),我们将使用 GovernorCountingSimple,该模块为投票者提供 3 个选项:赞成、反对和弃权,并且只有赞成和弃权票计入法定人数。
除了这些模块之外,Governor 本身还有一些我们必须设置的参数。
votingDelay:提案创建后多久应固定投票权。较大的投票延迟让用户有时间在必要时取消 token 的抵押。
votingPeriod:提案保持开放投票的时间。
这些参数以 token 时钟中定义的单位指定。假设 token 使用区块号,并且假设区块时间约为 12 秒,我们将设置 votingDelay = 1 天 = 7200 个区块,votingPeriod = 1 周 = 50400 个区块。
我们还可以选择设置提案阈值。这会将提案创建限制为具有足够投票权的帐户。
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;
import {IGovernor, Governor} from "@openzeppelin/contracts/governance/Governor.sol";
import {GovernorCountingSimple} from "@openzeppelin/contracts/governance/extensions/GovernorCountingSimple.sol";
import {GovernorVotes} from "@openzeppelin/contracts/governance/extensions/GovernorVotes.sol";
import {GovernorVotesQuorumFraction} from "@openzeppelin/contracts/governance/extensions/GovernorVotesQuorumFraction.sol";
import {GovernorTimelockControl} from "@openzeppelin/contracts/governance/extensions/GovernorTimelockControl.sol";
import {TimelockController} from "@openzeppelin/contracts/governance/TimelockController.sol";
import {IVotes} from "@openzeppelin/contracts/governance/utils/IVotes.sol";
import {IERC165} from "@openzeppelin/contracts/interfaces/IERC165.sol";
contract MyGovernor is
Governor,
GovernorCountingSimple,
GovernorVotes,
GovernorVotesQuorumFraction,
GovernorTimelockControl
{
constructor(
IVotes _token,
TimelockController _timelock
) Governor("MyGovernor") GovernorVotes(_token) GovernorVotesQuorumFraction(4) GovernorTimelockControl(_timelock) {}
function votingDelay() public pure override returns (uint256) {
return 7200; // 1 day
// 返回 7200;// 1 天
}
function votingPeriod() public pure override returns (uint256) {
return 50400; // 1 week
// 返回 50400;// 1 周
}
function proposalThreshold() public pure override returns (uint256) {
return 0;
}
// The functions below are overrides required by Solidity.
// 以下函数是 Solidity 要求的重写。
function state(uint256 proposalId) public view override(Governor, GovernorTimelockControl) returns (ProposalState) {
return super.state(proposalId);
}
function proposalNeedsQueuing(
uint256 proposalId
) public view virtual override(Governor, GovernorTimelockControl) returns (bool) {
return super.proposalNeedsQueuing(proposalId);
}
function _queueOperations(
uint256 proposalId,
address[] memory targets,
uint256[] memory values,
bytes[] memory calldatas,
bytes32 descriptionHash
) internal override(Governor, GovernorTimelockControl) returns (uint48) {
return super._queueOperations(proposalId, targets, values, calldatas, descriptionHash);
}
function _executeOperations(
uint256 proposalId,
address[] memory targets,
uint256[] memory values,
bytes[] memory calldatas,
bytes32 descriptionHash
) internal override(Governor, GovernorTimelockControl) {
super._executeOperations(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();
}
}
最好为治理决策添加一个时间锁。这允许用户在决策执行之前退出系统(如果他们不同意)。我们将 OpenZeppelin 的 TimelockController 与 GovernorTimelockControl 模块结合使用。
使用时间锁时,将由时间锁来执行提案,因此时间锁应持有任何资金、所有权和访问控制角色。在 4.5 版本之前,当使用时间锁时,无法在 Governor 合约中恢复资金!在 4.3 版本之前,当使用 Compound Timelock 时,时间锁中的 ETH 不易访问。 |
TimelockController 使用一种 AccessControl 设置,我们需要了解该设置才能设置角色。
Proposer 角色负责将操作排队:这是应授予 Governor 实例的角色,并且它可能应该是系统中唯一的 proposer。
Executor 角色负责执行已可用的操作:我们可以将此角色分配给特殊的零地址,以允许任何人执行(如果操作可能特别时间敏感,则应将 Governor 设置为 Executor)。
最后,有一个 Admin 角色,它可以授予和撤销前两个角色:这是一个非常敏感的角色,它将自动授予时间锁本身,并且可以选择授予给第二个帐户,该帐户可用于简化设置,但应立即放弃该角色。
让我们逐步了解如何在最新部署的 Governor 上创建和执行提案。
提案是 Governor 合约在通过后将执行的一系列操作。每个操作都包括一个目标地址、一个编码函数调用的 calldata 和一个要包含的 ETH 数量。此外,提案还包括一个人类可读的描述。
假设我们要创建一个提案,以赠款的形式向一个团队提供来自治理资金的 ERC-20 token。此提案将包含一个单一的操作,其中目标是 ERC-20 token,calldata 是编码的函数调用 `transfer(<team wallet>, <grant amount>)`,并且附加了 0 个 ETH。
通常,将借助诸如 Tally 或 Defender Proposals 之类的界面来创建提案。在这里,我们将展示如何使用 Ethers.js 创建提案。
首先,我们获得提案操作所需的所有参数。
const tokenAddress = ...;
const token = await ethers.getContractAt(‘ERC20’, tokenAddress);
const teamAddress = ...;
const grantAmount = ...;
const transferCalldata = token.interface.encodeFunctionData(‘transfer’, [teamAddress, grantAmount]);
现在我们准备好调用 Governor 的 propose 函数。请注意,我们没有传入一个操作数组,而是传入三个数组,分别对应于目标列表、值列表和 calldata 列表。在这种情况下,它是一个单一的操作,因此很简单:
await governor.propose(
[tokenAddress],
[0],
[transferCalldata],
“Proposal #1: Give grant to team”,
// “提案 #1:向团队授予赠款”,
);
这将创建一个新提案,其提案 ID 通过将提案数据哈希在一起获得,并且也将在事务日志中的事件中找到。
一旦提案生效,委托人就可以投票。请注意,是委托人拥有投票权:如果 token 持有者想要参与,他们可以将受信任的代表设置为其委托人,或者他们可以通过自行委托其投票权来成为委托人。
通过与 Governor 合约进行交互,使用 `castVote` 系列函数来进行投票。投票者通常会从诸如 Tally 之类的治理 UI 调用此函数。
一旦投票期结束,如果达到法定人数(参与了足够的投票权)并且大多数人投了赞成票,则该提案被认为是成功的,可以继续执行。提案通过后,可以从你投票的同一位置进行排队和执行。
现在我们将看到如何使用 Ethers.js 手动执行此操作。
如果设置了时间锁,则执行的第一步是排队。你会注意到,排队和执行函数都需要传入整个提案参数,而不是仅传入提案 ID。这是必要的,因为这些数据未存储在链上,这是一种节省 gas 的措施。请注意,这些参数始终可以在合约发出的事件中找到。唯一未完整发送的参数是描述,因为仅需要其哈希形式来计算提案 ID。
要排队,我们调用 queue 函数:
const descriptionHash = ethers.utils.id(“Proposal #1: Give grant to team”);
// const descriptionHash = ethers.utils.id(“提案 #1:向团队授予赠款”);
await governor.queue(
[tokenAddress],
[0],
[transferCalldata],
descriptionHash,
);
这将导致 Governor 与时间锁合约进行交互,并将操作排队以便在所需的延迟后执行。
在经过足够的时间(根据时间锁参数)之后,可以执行该提案。如果一开始没有时间锁,则可以在提案成功后立即运行此步骤。
await governor.execute(
[tokenAddress],
[0],
[transferCalldata],
descriptionHash,
);
执行提案会将 ERC-20 token 转移给选定的接收者。总结:我们设置了一个系统,其中资金由项目 token 持有者的集体决策控制,并且所有操作都通过链上投票强制执行的提案来执行。
有时很难处理以区块数量表示的持续时间,因为区块之间的时间不一致或不可预测。对于某些 L2 网络尤其如此,在这些网络中,区块是根据区块链使用情况生成的。使用区块数量还可能导致治理规则受到修改区块之间预期时间的网络升级的影响。
用时间戳替换区块号的困难在于,在查询过去的投票时,Governor 和 token 都必须使用相同的格式。如果 token 是围绕区块号设计的,则 Governor 无法可靠地进行基于时间戳的查找。
因此,设计基于时间戳的投票系统从 token 开始。
自 v4.9 以来,所有投票合约(包括 ERC20Votes
和 ERC721Votes
)都依赖于 IERC6372 进行时钟管理。为了从使用区块号转换为使用时间戳,所需的全部操作是覆盖 `clock()` 和 `CLOCK_MODE()` 函数。
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;
import {ERC20} from "@openzeppelin/contracts/token/ERC20/ERC20.sol";
import {ERC20Permit} from "@openzeppelin/contracts/token/ERC20/extensions/ERC20Permit.sol";
import {ERC20Votes} from "@openzeppelin/contracts/token/ERC20/extensions/ERC20Votes.sol";
import {Nonces} from "@openzeppelin/contracts/utils/Nonces.sol";
contract MyTokenTimestampBased is ERC20, ERC20Permit, ERC20Votes {
constructor() ERC20("MyTokenTimestampBased", "MTK") ERC20Permit("MyTokenTimestampBased") {}
// Overrides IERC6372 functions to make the token & governor timestamp-based
// 覆盖 IERC6372 函数以使 token 和 governor 基于时间戳
function clock() public view override returns (uint48) {
return uint48(block.timestamp);
}
// solhint-disable-next-line func-name-mixedcase
function CLOCK_MODE() public pure override returns (string memory) {
return "mode=timestamp";
// 返回 "mode=timestamp"
}
// The functions below are overrides required by Solidity.
// 以下函数是 Solidity 要求的重写。
function _update(address from, address to, uint256 amount) internal override(ERC20, ERC20Votes) {
super._update(from, to, amount);
}
function nonces(address owner) public view virtual override(ERC20Permit, Nonces) returns (uint256) {
return super.nonces(owner);
}
}
Governor 将自动检测 token 使用的时钟模式并适应它。无需在 Governor 合约中覆盖任何内容。但是,时钟模式确实会影响某些值的解释方式。因此,有必要相应地设置 `votingDelay()` 和 `votingPeriod()`。
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;
import {Governor} from "@openzeppelin/contracts/governance/Governor.sol";
import {GovernorCountingSimple} from "@openzeppelin/contracts/governance/compatibility/GovernorCountingSimple.sol";
import {GovernorVotes} from "@openzeppelin/contracts/governance/extensions/GovernorVotes.sol";
import {GovernorVotesQuorumFraction} from "@openzeppelin/contracts/governance/extensions/GovernorVotesQuorumFraction.sol";
import {GovernorTimelockControl} from "@openzeppelin/contracts/governance/extensions/GovernorTimelockControl.sol";
import {TimelockController} from "@openzeppelin/contracts/governance/TimelockController.sol";
import {IVotes} from "@openzeppelin/contracts/governance/utils/IVotes.sol";
contract MyGovernor is Governor, GovernorCountingSimple, GovernorVotes, GovernorVotesQuorumFraction, GovernorTimelockControl {
constructor(IVotes _token, TimelockController _timelock)
Governor("MyGovernor")
GovernorVotes(_token)
GovernorVotesQuorumFraction(4)
GovernorTimelockControl(_timelock)
{}
function votingDelay() public pure virtual override returns (uint256) {
return 1 days;
// 返回 1 天;
}
function votingPeriod() public pure virtual override returns (uint256) {
return 1 weeks;
// 返回 1 周;
}
function proposalThreshold() public pure virtual override returns (uint256) {
return 0;
}
// ...
// ……
}
基于时间戳的投票是一项最新功能,已在 ERC-6372 和 ERC-5805 中进行正式化,并在 v4.9 中引入。在发布此功能时,某些治理工具可能尚不支持它。如果该工具无法解释 ERC6372 时钟,则用户可能会预期截止日期和持续时间的无效报告。链下工具的这种无效报告并不影响治理合约的链上安全性和功能。
具有时间戳支持的 Governor(v4.9 及更高版本)与旧 token(v4.9 之前的版本)兼容,并将以“区块号”模式运行(这是所有旧 token 都以其运行的模式)。另一方面,旧的 Governor 实例(v4.9 之前的版本)与使用时间戳运行的新 token 不兼容。如果你更新 token 代码以使用时间戳,请确保也更新你的 Governor 代码。
- 原文链接: docs.openzeppelin.com/co...
- 登链社区 AI 助手,为大家转译优秀英文文章,如有翻译不通的地方,还请包涵~
如果觉得我的文章对您有用,请随意打赏。你的支持将鼓励我继续创作!