如何设置链上治理

在本指南中,我们将学习 OpenZeppelin 的 Governor 合约如何工作,如何设置它,以及如何使用它来创建提案、投票以及执行提案,使用 Ethers.js 和 Tally 提供的工具。

请在 治理 API 中查找详细的合约文档。

介绍

去中心化协议从公开发布的那一刻起就不断发展。通常,最初的团队在最初阶段保留对这种演变的控制权,但最终会将其委托给利益相关者社区。这个社区做出决定的过程称为链上治理,它已成为去中心化协议的核心组成部分,推动各种决策,例如参数调整、智能合约升级、与其他协议的集成、资金库管理、赠款等等。

此治理协议通常在称为“Governor”的专用合约中实现。Compound 设计的 GovernorAlpha 和 GovernorBravo 合约迄今为止非常成功和流行,但缺点是具有不同要求的项目必须 fork 代码以根据其需求进行自定义,这可能会带来引入安全问题的风险。对于 OpenZeppelin Contracts,我们着手构建一个模块化的 Governor 合约系统,以便不需要 forking,并且可以通过使用 Solidity 继承编写小型模块来满足不同的需求。您将在 OpenZeppelin Contracts 中找到最常见的开箱即用需求,但编写额外的需求很简单,并且我们将在未来的版本中添加社区要求的新功能。此外,OpenZeppelin Governor 的设计需要最少的存储使用,并能实现更高效的 gas 利用率。

兼容性

OpenZeppelin 的 Governor 系统在设计时考虑了与基于 Compound 的 GovernorAlpha 和 GovernorBravo 的现有系统的兼容性。因此,您会发现许多模块都以两种变体呈现,其中一种变体是为与这些系统兼容而构建的。

ERC20Votes & ERC20VotesComp

用于跟踪投票和投票委托的 ERC-20 扩展就是这种情况。较短的版本是更通用的版本,因为它支持大于 2^96 的 token 供应,而“Comp”变体在这方面受到限制,但完全符合 GovernorAlpha 和 Bravo 使用的 COMP token 的接口。两个合约变体都共享相同的事件,因此仅在查看事件时它们是完全兼容的。

Governor & GovernorStorage

OpenZeppelin Governor 合约与 Compound 的 GovernorAlpha 或 Bravo 不兼容接口。即使事件完全兼容,提案生命周期函数(创建、执行等)也具有不同的签名,旨在优化存储使用。GovernorAlpha 和 Bravo 的其他功能同样不可用。可以通过继承 GovernorStorage 模块来选择加入一些类似 Bravo 的行为。此模块提供提案可枚举性和 queueexecutecancel 函数的替代版本,这些函数仅采用提案 ID。此模块减少了某些操作所需的 calldata,从而增加了存储占用空间。对于某些 L2 链来说,这可能是一个很好的权衡。它还为无索引器前端提供原语。

请注意,即使使用此模块,与 Compound 的 GovernorBravo 的一个重要区别在于 proposalId 的计算方式。Governor 使用提案参数的哈希值,目的是通过事件索引将数据保留在链下,而原始 Bravo 实现使用连续的 proposalId

GovernorTimelockControl & GovernorTimelockCompound

将时间锁与您的 Governor 合约一起使用时,您可以使用 OpenZeppelin 的 TimelockController 或 Compound 的 Timelock。根据时间锁的选择,您应该选择相应的 Governor 模块:GovernorTimelockControl 或 GovernorTimelockCompound。这允许你将现有的 GovernorAlpha 实例迁移到基于 OpenZeppelin 的 Governor,而无需更改正在使用的时间锁。

Tally

Tally 是一个用于用户拥有的链上治理的成熟应用程序。它包括投票仪表板、提案创建向导、实时研究和分析以及教育内容。

对于所有这些选项,Governor 将与 Tally 兼容:用户将能够创建提案,查看遵循 IERC6372 的投票期和延迟,可视化投票权和倡导者,导航提案并进行投票。特别是对于提案创建,项目还可以使用 Defender 交易提案 作为替代接口。

在本指南的其余部分,我们将重点介绍全新部署的 vanilla OpenZeppelin Governor 功能,而不考虑与 GovernorAlpha 或 Bravo 的兼容性。

设置

Token

我们的治理设置中每个帐户的投票权将由 ERC-20 token 决定。该 token 必须实现 ERC20Votes 扩展。此扩展将跟踪历史余额,以便从过去的快照中检索投票权,而不是当前的余额,这是一种重要的保护措施,可防止重复投票。

Unresolved include directive in modules/ROOT/pages/governance.adoc - include::api:example$governance/MyToken.sol[]

如果你的项目已经拥有一个不包含 ERC20Votes 且不可升级的 live token,你可以使用 ERC20Wrapper 将其包装在治理 token 中。这将允许 token 持有者通过 1 对 1 包装其 token 来参与治理。

Unresolved include directive in modules/ROOT/pages/governance.adoc - include::api:example$governance/MyTokenWrapped.sol[]
OpenZeppelin Contracts 中目前唯一可用的其他投票权来源是 ERC721Votes。不提供此功能的 ERC-721 token 可以使用 ERC721VotesERC721Wrapper 的组合包装到投票 token 中。
token 用于存储投票余额的内部时钟将决定附加到它的 Governor 合约的运行模式。默认情况下,使用区块编号。自 v4.9 以来,开发人员可以覆盖 IERC6372 时钟以使用时间戳而不是区块编号。

Governor

最初,我们将构建一个没有时间锁的 Governor。核心逻辑由 Governor 合约给出,但我们仍然需要选择:1) 如何确定投票权,2) 达到法定人数需要多少票数,3) 人们在投票时有哪些选择以及如何计算这些票数,以及 4) 应该使用什么类型的 token 进行投票。通过编写你自己的模块,或者更轻松地从 OpenZeppelin Contracts 中选择一个模块,可以自定义这些方面中的每一个。

对于 1),我们将使用 GovernorVotes 模块,该模块连接到 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 个区块。

我们还可以选择设置提案阈值。这会将提案创建限制为具有足够投票权的帐户。

Unresolved include directive in modules/ROOT/pages/governance.adoc - include::api:example$governance/MyGovernor.sol[]

时间锁

最好在治理决策中添加时间锁。这允许用户在决策执行之前退出系统(如果他们不同意该决策)。我们将 OpenZeppelin 的 TimelockController 与 GovernorTimelockControl 模块结合使用。

使用时间锁时,将执行提案的是时间锁,因此应持有任何资金、所有权和访问控制角色的是时间锁。在 4.5 版本之前,使用时间锁时无法在 Governor 合约中恢复资金!在 4.3 版本之前,使用 Compound Timelock 时,时间锁中的 ETH 无法轻松访问。

TimelockController 使用访问控制设置,我们需要了解该设置才能设置角色。

  • 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 中调用此函数。

在 Tally 中投票

执行提案

一旦投票期结束,如果达到法定人数(参与的投票权足够)并且大多数人投了赞成票,则该提案被认为是成功的,并且可以继续执行。提案通过后,可以从您投票的同一位置进行排队和执行。

Tally 中的管理面板

我们现在将看到如何使用 Ethers.js 手动执行此操作。

如果设置了时间锁,则执行的第一步是排队。您会注意到,queue 和 execute 函数都需要传入整个提案参数,而不是仅传入提案 ID。这是必要的,因为此数据未存储在链上,这是一种节省 gas 的措施。请注意,这些参数始终可以在合约发出的事件中找到。唯一未完整发送的参数是描述,因为仅需要以哈希形式计算提案 ID。

要排队,我们调用 queue 函数:

const descriptionHash = ethers.utils.id(“Proposal #1: Give grant to team”); // 提案 #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。

Token

自 v4.9 以来,所有投票合约(包括 ERC20VotesERC721Votes)都依赖于 IERC6372 进行时钟管理。为了从使用区块编号切换到使用时间戳,只需要覆盖 clock()CLOCK_MODE() 函数即可。

Unresolved include directive in modules/ROOT/pages/governance.adoc - include::api:example$governance/MyTokenTimestampBased.sol[]

Governor

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

    function votingPeriod() public pure virtual override returns (uint256) {
        return 1 weeks;
    }

    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 代码。