Alert Source Discuss
🚧 Stagnant Standards Track: ERC

ERC-5805: 带有委托的投票

一种用于跟踪投票权重的接口,支持委托

Authors Hadrien Croubois (@Amxx), Francisco Giordano (@frangio)
Created 2022-07-04
Discussion Link https://ethereum-magicians.org/t/eip-5805-voting-with-delegation/11407
Requires EIP-712, EIP-6372

摘要

许多 DAO (去中心化自治组织) 依赖于 token 来代表一个人的投票权。为了有效地执行此任务,token 合约需要包括特定的机制,例如检查点和委托。现有的实现没有标准化。此 ERC 建议标准化将选票从一个帐户委托到另一个帐户的方式,以及跟踪和查询当前和过去选票的方式。相应的行为与许多 token 类型兼容,包括但不限于 ERC-20ERC-721。此 ERC 还考虑了时间跟踪功能的多样性,允许投票 token(以及与之关联的任何合约)基于 block.numberblock.timestamp 或任何其他非递减函数来跟踪选票。

动机

除了简单的货币交易之外,去中心化自治组织可以说是区块链和智能合约技术最重要的用例之一。如今,许多社区围绕着一个允许用户投票的治理合约组织起来。在这些社区中,一些社区使用可转让的 token(ERC-20ERC-721,其他)来代表投票权。在这种情况下,一个人拥有的 token 越多,其投票权就越大。Governor 合约,例如 Compound 的 GovernorBravo,从这些“投票 token”合约中读取以获取用户的投票权。

不幸的是,简单地使用大多数 token 标准中存在的 balanceOf(address) 函数是不够的:

  • 这些值没有检查点,因此用户可以投票,将其 token 转移到一个新帐户,并使用相同的 token 再次投票。
  • 用户不能在不转移 token 的完整所有权的情况下将其投票权委托给其他人。

这些限制导致了具有委托的投票 token 的出现,其中包含以下逻辑:

  • 用户可以将其 token 的投票权委托给自己或第三方。这造成了余额和投票权重之间的区别。
  • 帐户的投票权重被检查点,允许在不同时间点查找过去的值。
  • 余额没有检查点。

此 ERC 建议标准化这些投票 token 的接口和行为。

此外,现有的(非标准化)实现仅限于基于 block.number 的检查点。这种选择在多链环境中会导致许多问题,在多链环境中,某些链(尤其是 L2)的块之间的时间不一致或不可预测。此 ERC 还通过允许投票 token 使用其想要的任何时间跟踪函数并公开它来解决此问题,以便其他合约(例如 Governor)可以与 token 检查点保持一致。

规范

本文档中的关键词“必须”,“禁止”,“必需”,“应”,“不应”,“推荐”,“可以”和“可选”应按照 RFC 2119 中的描述进行解释。

按照先前存在的(但未标准化的)实现,EIP 提出了以下机制。

每个用户帐户(地址)都可以委托给其选择的帐户。这可以是其本身、其他人或没有人(由 address(0) 表示)。除非用户持有的资产被委托,否则不能表达他们的投票权。

当“委托人”将其 token 投票权委托给“受委托人”时,其余额将添加到受委托人的投票权中。如果委托人更改其委托,则投票权将从旧的受委托人的投票权中减去,并添加到新的受委托人的投票权中。每个帐户的投票权都会随着时间的推移进行跟踪,以便可以查询其过去的值。由于在给定时间点,token 最多委托给一个受委托人,因此可以防止重复投票。

每当 token 从一个帐户转移到另一个帐户时,相关的投票权应从发送者的受委托人处扣除,并添加到接收者的受委托人处。

委托给 address(0) 的 token 不应被跟踪。这允许用户通过跳过其受委托人的检查点更新来优化其 token 转移的 gas 成本。

为了适应不同类型的链,我们希望投票检查点系统支持不同的时间跟踪形式。在以太坊主网上,使用区块号可以与历史上使用它的应用程序提供向后兼容性。另一方面,使用时间戳可以为最终用户提供更好的语义,并适应以秒为单位表达持续时间的用例。开发人员还可以根据未来应用程序和区块链的特性,认为其他单调函数是相关的。

时间戳、区块号和其他可能的模式都使用相同的外部接口。这允许将第三方合约(例如 governor 系统)透明地绑定到投票合约中内置的投票跟踪。为了使其生效,投票合约除了所有投票跟踪功能外,还必须公开用于时间跟踪的当前值。

方法

ERC-6372: clock 和 CLOCK_MODE

合规合约应实现 ERC-6372(合约时钟)以声明用于投票跟踪的时钟。

如果合约未实现 ERC-6372,则它必须按照区块号时钟运行,就像 ERC-6372 的 CLOCK_MODE 返回 mode=blocknumber&from=default 一样。

在以下规范中,“当前时钟”是指 ERC-6372 的 clock() 的结果,或者在没有 ERC-6372 的情况下默认的 block.number

getVotes

此函数返回帐户的当前投票权重。这对应于调用此函数时委托给它的所有投票权。

由于委托给 address(0) 的 token 不应被计算/快照,因此 getVotes(0) 应始终返回 0

必须实现此函数

- name: getVotes
  type: function
  stateMutability: view
  inputs:
    - name: account
      type: address
  outputs:
    - name: votingWeight
      type: uint256

getPastVotes

此函数返回帐户的历史投票权重。这对应于在特定时间点委托给它的所有投票权。timepoint 参数必须与合约的运行模式匹配。此函数应仅服务于过去的检查点,这些检查点应是不可变的。

  • 使用大于或等于当前时钟的时间点调用此函数应恢复。
  • 使用严格小于当前时钟的时间点调用此函数不应恢复。
  • 对于任何严格小于当前时钟的整数,getPastVotes 返回的值应是恒定的。这意味着对于任何返回值的对此函数的调用,重新执行相同的调用(在未来的任何时间)应返回相同的值。

由于委托给 address(0) 的 token 不应被计算/快照,因此 getPastVotes(0,x) 应始终返回 0(对于 x 的所有值)。

必须实现此函数

- name: getPastVotes
  type: function
  stateMutability: view
  inputs:
    - name: account
      type: address
    - name: timepoint
      type: uint256
  outputs:
    - name: votingWeight
      type: uint256

delegates

此函数返回帐户的投票权当前委托到的地址。

请注意,如果受委托人为 address(0),则不应检查点投票权,并且不应使用它进行投票。

必须实现此函数

- name: delegates
  type: function
  stateMutability: view
  inputs:
    - name: account
      type: address
  outputs:
    - name: delegatee
      type: address

delegate

此函数更改调用者的受委托人,同时更新投票委托。

必须实现此函数

- name: delegate
  type: function
  stateMutability: nonpayable
  inputs:
    - name: delegatee
      type: address
  outputs: []

delegateBySig

此函数使用签名更改帐户的受委托人,同时更新投票委托。

必须实现此函数

- name: delegateBySig
  type: function
  stateMutability: nonpayable
  inputs:
    - name: delegatee
      type: address
    - name: nonce
      type: uint256
    - name: expiry
      type: uint256
    - name: v
      type: uint8
    - name: r
      type: bytes32
    - name: s
      type: bytes32
  outputs: []

此签名应遵循 EIP-712 格式:

delegateBySig(delegatee, nonce, expiry, v, r, s) 的调用将签名者的受委托人更改为 delegatee,将签名者的 nonce 递增 1,并发出相应的 DelegateChanged 事件,以及可能为新旧受委托人帐户发出的 DelegateVotesChanged 事件,当且仅当满足以下条件时:

  • 当前时间戳小于或等于 expiry
  • nonces(signer)(在状态更新之前)等于 nonce

如果未满足任何这些条件,则 delegateBySig 调用必须恢复。这转化为以下 solidity 代码:

require(expiry <= block.timestamp)
bytes signer = ecrecover(
  keccak256(abi.encodePacked(
    hex"1901",
    DOMAIN_SEPARATOR,
    keccak256(abi.encode(
      keccak256("Delegation(address delegatee,uint256 nonce,uint256 expiry)"),
      delegatee,
      nonce,
      expiry)),
  v, r, s)
require(signer != address(0));
require(nounces[signer] == nonce);
// increment nonce
// set delegation of `signer` to `delegatee`

其中 DOMAIN_SEPARATOR 根据 EIP-712 定义。DOMAIN_SEPARATOR 应对合约和链是唯一的,以防止来自其他域的重放攻击, 并满足 EIP-712 的要求,但在其他方面不受约束。

DOMAIN_SEPARATOR 的常见选择是:

DOMAIN_SEPARATOR = keccak256(
    abi.encode(
        keccak256('EIP712Domain(string name,string version,uint256 chainId,address verifyingContract)'),
        keccak256(bytes(name)),
        keccak256(bytes(version)),
        chainid,
        address(this)
));

换句话说,消息是 EIP-712 类型化的结构:

{
  "types": {
    "EIP712Domain": [
      {
        "name": "name",
        "type": "string"
      },
      {
        "name": "version",
        "type": "string"
      },
      {
        "name": "chainId",
        "type": "uint256"
      },
      {
        "name": "verifyingContract",
        "type": "address"
      }
    ],
    "Delegation": [{
      "name": "delegatee",
      "type": "address"
      },
      {
        "name": "nonce",
        "type": "uint256"
      },
      {
        "name": "expiry",
        "type": "uint256"
      }
    ],
    "primaryType": "Permit",
    "domain": {
      "name": contractName,
      "version": version,
      "chainId": chainid,
      "verifyingContract": contractAddress
  },
  "message": {
    "delegatee": delegatee,
    "nonce": nonce,
    "expiry": expiry
  }
}}

请注意,在此定义中的任何地方我们都不会引用 msg.senderdelegateBySig 函数的调用者可以是任何地址。

成功执行此函数后,必须递增委托人的 nonce 以防止重放攻击。

nonces

此函数返回给定帐户的当前 nonce。

仅当 EIP-712 签名中使用的 nonce 与此函数的返回值匹配时,才接受签名委托(请参阅 delegateBySig)。每当代表 delegator 执行 delegateBySig 调用时,都应递增该值 nonce(delegator)

必须实现此函数

- name: nonces
  type: function
  stateMutability: view
  inputs:
    - name: account
      type: delegator
  outputs:
    - name: nonce
      type: uint256

事件

DelegateChanged

delegator 将其资产的委托从 fromDelegate 更改为 toDelegate

当帐户的委托由 delegate(address)delegateBySig(address,uint256,uint256,uint8,bytes32,bytes32) 修改时,必须发出。

- name: DelegateChanged
  type: event
  inputs:
    - name: delegator
      indexed: true
      type: address
    - name: fromDelegate
      indexed: true
      type: address
    - name: toDelegate
      indexed: true
      type: address

DelegateVotesChanged

delegate 可用的投票权从 previousBalance 更改为 newBalance

当以下情况时,必须发出此事件:

  • 一个帐户(持有超过 0 个资产)将其委托从或更新到 delegate
  • 从或向委托给 delegate 的帐户转移资产。
- name: DelegateVotesChanged
  type: event
  inputs:
    - name: delegate
      indexed: true
      type: address
    - name: previousBalance
      indexed: false
      type: uint256
    - name: newBalance
      indexed: false
      type: uint256

Solidity 接口

interface IERC5805 is IERC6372 /* (optional) */ {
  event DelegateChanged(address indexed delegator, address indexed fromDelegate, address indexed toDelegate);
  event DelegateVotesChanged(address indexed delegate, uint256 previousBalance, uint256 newBalance);

  function getVotes(address account) external view returns (uint256);
  function getPastVotes(address account, uint256 timepoint) external view returns (uint256);
  function delegates(address account) external view returns (address);
  function nonces(address owner) public view virtual returns (uint256)

  function delegate(address delegatee) external;
  function delegateBySig(address delegatee, uint256 nonce, uint256 expiry, uint8 v, bytes32 r, bytes32 s) external;
}

预期属性

clock 为当前时钟。

  • 对于所有时间点 t < clockgetVotes(address(0))getPastVotes(address(0), t) 应返回 0。
  • 对于所有帐户 a != 0getVotes(a) 应是委托给 a 的所有帐户的“余额”之和。
  • 对于所有帐户 a != 0 和所有时间戳 t < clockgetPastVotes(a, t) 应是当 clock 超过 t 时委托给 a 的所有帐户的“余额”之和。
  • 对于所有帐户 a,在达到 t < clock 之后,getPastVotes(a, t) 必须是恒定的。
  • 对于所有帐户 a,将委托从 b 更改为 c 的操作不得增加 bgetVotes(b))的当前投票权,也不得减少 cgetVotes(c))的当前投票权。

理由

委托允许 token 持有者信任委托人进行投票,同时保留对其 token 的完全保管权。这意味着只有少数委托人需要支付 gas 来进行投票。通过允许投票在不需要他们支付昂贵的 gas 费用的情况下进行,这导致了对小型 token 持有者的更好的代表。用户可以随时接管他们的投票权,并将其委托给其他人或他们自己。

检查点的使用可以防止重复投票。例如,在治理提案的上下文中,投票应依赖于由时间点定义的快照。只有在该时间点委托的 token 才能用于投票。这意味着在快照之后执行的任何 token 转移都不会影响发送者/接收者的受委托人的投票权。这也意味着为了投票,某人必须在拍摄快照之前获得 token 并委托它们。Governor 可以并且确实在提交提案和拍摄快照之间包括一个延迟,以便用户可以采取必要的措施(更改其委托、购买更多 token,…)。

虽然 ERC-6372 的 clock 产生的时间戳表示为 uint48,但 getPastVotes 的 timepoint 参数是 uint256,以实现向后兼容性。传递给 getPastVotes 的任何时间点 > = 2 ** 48 都会导致该函数恢复,因为它会在未来查找。

delegateBySig 是必要的,以便为不想为投票支付 gas 的 token 持有者提供无 gas 工作流程。

给出 nonces 映射是为了防止重放。

由于 EIP-712 类型化消息在许多钱包提供商中得到广泛采用,因此包含它们。

向后兼容性

Compound 和 OpenZeppelin 已经提供了投票 token 的实现。与委托相关的方法在两个实现和此 ERC 之间共享。对于投票查找,此 ERC 使用 OpenZeppelin 的实现(返回类型为 uint256),因为 Compound 的实现会导致对可接受值的重大限制(返回类型为 uint96)。

这两种实现都使用 block.number 作为其检查点,并且没有实现 ERC-6372,这与此 ERC 兼容。

当前与 OpenZeppelin 的实现兼容的现有 governor 将与此 ERC 的“区块号模式”兼容。

安全考虑

在进行查找之前,应检查 clock() 的返回值,并确保查找的参数是一致的。在使用区块号的合约上使用时间戳参数执行查找很可能会导致恢复。另一方面,在使用时间戳的合约上使用区块号参数执行查找可能会返回 0。

尽管 Delegation 的签名者可能希望某个特定方提交他们的交易,但另一方始终可以在预期方之前抢先执行此交易并调用 delegateBySig。但是,对于 Delegation 签名者来说,结果是相同的。

由于 ecrecover 预编译会静默失败,并且在给出格式错误的消息时仅将零地址作为 signer 返回,因此务必确保 signer != address(0),以避免 delegateBySig 委托属于零地址的“僵尸资金”。

签名的 Delegation 消息是可审查的。中继方始终可以选择在收到 Delegation 后不提交它,从而扣留提交它的选项。expiry 参数是对这种情况的一种缓解措施。如果签名方持有 ETH,他们也可以自己提交 Delegation,这可能会使先前签名的 Delegation 无效。

如果 DOMAIN_SEPARATOR 包含 chainId 并在合约部署时定义,而不是为每个签名重建,则在未来链拆分的情况下,存在链之间可能发生重放攻击的风险。

版权

版权和相关权利通过 CC0 放弃。

Citation

Please cite this document as:

Hadrien Croubois (@Amxx), Francisco Giordano (@frangio), "ERC-5805: 带有委托的投票 [DRAFT]," Ethereum Improvement Proposals, no. 5805, July 2022. [Online serial]. Available: https://eips.ethereum.org/EIPS/eip-5805.