ERC-5805 - Voting with delegation

Created 2022-07-04
Status Stagnant
Category ERC
Type Standards Track
Authors
Requires

Abstract

Many DAOs (decentralized autonomous organizations) rely on tokens to represent one's voting power. In order to perform this task effectively, the token contracts need to include specific mechanisms such as checkpoints and delegation. The existing implementations are not standardized. This ERC proposes to standardize the way votes are delegated from one account to another, and the way current and past votes are tracked and queried. The corresponding behavior is compatible with many token types, including but not limited to ERC-20 and ERC-721. This ERC also considers the diversity of time tracking functions, allowing the voting tokens (and any contract associated with it) to track the votes based on block.number, block.timestamp, or any other non-decreasing function.

Motivation

Beyond simple monetary transactions, decentralized autonomous organizations are arguably one of the most important use cases of blockchain and smart contract technologies. Today, many communities are organized around a governance contract that allows users to vote. Among these communities, some represent voting power using transferable tokens (ERC-20, ERC-721, other). In this context, the more tokens one owns, the more voting power one has. Governor contracts, such as Compound's GovernorBravo, read from these "voting token" contracts to get the voting power of the users.

Unfortunately, simply using the balanceOf(address) function present in most token standards is not good enough:

These constraints have led to the emergence of voting tokens with delegation that contain the following logic:

This ERC is proposing to standardize the interface and behavior of these voting tokens.

Additionally, the existing (non-standardized) implementations are limited to block.number based checkpoints. This choice causes many issues in a multichain environment, where some chains (particularly L2s) have an inconsistent or unpredictable time between blocks. This ERC also addresses this issue by allowing the voting token to use any time tracking function it wants, and exposing it so that other contracts (such as a Governor) can stay consistent with the token checkpoints.

Specification

The key words “MUST”, “MUST NOT”, “REQUIRED”, “SHALL”, “SHALL NOT”, “SHOULD”, “SHOULD NOT”, “RECOMMENDED”, “MAY”, and “OPTIONAL” in this document are to be interpreted as described in RFC 2119.

Following pre-existing (but not-standardized) implementation, the EIP proposes the following mechanism.

Each user account (address) can delegate to an account of its choice. This can be itself, someone else, or no one (represented by address(0)). Assets held by the user cannot express their voting power unless they are delegated.

When a "delegator" delegates its tokens voting power to a "delegatee", its balance is added to the voting power of the delegatee. If the delegator changes its delegation, the voting power is subtracted from the old delegatee's voting power and added to the new delegate's voting power. The voting power of each account is tracked through time so that it is possible to query its value in the past. With tokens being delegated to at most one delegate at a given point in time, double voting is prevented.

Whenever tokens are transferred from one account to another, the associated voting power should be deducted from the sender's delegate and added to the receiver's delegate.

Tokens that are delegated to address(0) should not be tracked. This allows users to optimize the gas cost of their token transfers by skipping the checkpoint update for their delegate.

To accommodate different types of chains, we want the voting checkpoint system to support different forms of time tracking. On the Ethereum mainnet, using block numbers provides backward compatibility with applications that historically use it. On the other hand, using timestamps provides better semantics for end users, and accommodates use cases where the duration is expressed in seconds. Other monotonic functions could also be deemed relevant by developers based on the characteristics of future applications and blockchains.

Both timestamps, block numbers, and other possible modes use the same external interfaces. This allows transparent binding of third-party contracts, such as governor systems, to the vote tracking built into the voting contracts. For this to be effective, the voting contracts must, in addition to all the vote-tracking functions, expose the current value used for time-tracking.

Methods

ERC-6372: clock and CLOCK_MODE

Compliant contracts SHOULD implement ERC-6372 (Contract clock) to announce the clock that is used for vote tracking.

If the contract does not implement ERC-6372, it MUST operate according to a block number clock, exactly as if ERC-6372's CLOCK_MODE returned mode=blocknumber&from=default.

In the following specification, "the current clock" refers to either the result of ERC-6372's clock(), or the default of block.number in its absence.

getVotes

This function returns the current voting weight of an account. This corresponds to all the voting power delegated to it at the moment this function is called.

As tokens delegated to address(0) should not be counted/snapshotted, getVotes(0) SHOULD always return 0.

This function MUST be implemented

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

getPastVotes

This function returns the historical voting weight of an account. This corresponds to all the voting power delegated to it at a specific timepoint. The timepoint parameter MUST match the operating mode of the contract. This function SHOULD only serve past checkpoints, which SHOULD be immutable.

As tokens delegated to address(0) should not be counted/snapshotted, getPastVotes(0,x) SHOULD always return 0 (for all values of x).

This function MUST be implemented

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

delegates

This function returns the address to which the voting power of an account is currently delegated.

Note that if the delegate is address(0) then the voting power SHOULD NOT be checkpointed, and it should not be possible to vote with it.

This function MUST be implemented

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

delegate

This function changes the caller's delegate, updating the vote delegation in the meantime.

This function MUST be implemented

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

delegateBySig

This function changes an account's delegate using a signature, updating the vote delegation in the meantime.

This function MUST be implemented

- 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: []

This signature should follow the EIP-712 format:

A call to delegateBySig(delegatee, nonce, expiry, v, r, s) changes the signer's delegate to delegatee, increment the signer's nonce by 1, and emits a corresponding DelegateChanged event, and possibly DelegateVotesChanged events for the old and the new delegate accounts, if and only if the following conditions are met:

If any of these conditions are not met, the delegateBySig call must revert. This translates to the following solidity code:

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`

where DOMAIN_SEPARATOR is defined according to EIP-712. The DOMAIN_SEPARATOR should be unique to the contract and chain to prevent replay attacks from other domains, and satisfy the requirements of EIP-712, but is otherwise unconstrained.

A common choice for DOMAIN_SEPARATOR is:

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

In other words, the message is the EIP-712 typed structure:

{
  "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
  }
}}

Note that nowhere in this definition do we refer to msg.sender. The caller of the delegateBySig function can be any address.

When this function is successfully executed, the delegator's nonce MUST be incremented to prevent replay attacks.

nonces

This function returns the current nonce for a given account.

Signed delegations (see delegateBySig) are only accepted if the nonce used in the EIP-712 signature matches the return of this function. This value of nonce(delegator) should be incremented whenever a call to delegateBySig is performed on behalf of delegator.

This function MUST be implemented

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

Events

DelegateChanged

delegator changes the delegation of its assets from fromDelegate to toDelegate.

MUST be emitted when the delegate for an account is modified by delegate(address) or 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 available voting power changes from previousBalance to newBalance.

This MUST be emitted when:

- 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

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

Expected properties

Let clock be the current clock.

Rationale

Delegation allows token holders to trust a delegate with their vote while keeping full custody of their token. This means that only a small-ish number of delegates need to pay gas for voting. This leads to better representation of small token holders by allowing their votes to be cast without requiring them to pay expensive gas fees. Users can take over their voting power at any point, and delegate it to someone else, or to themselves.

The use of checkpoints prevents double voting. Votes, for example in the context of a governance proposal, should rely on a snapshot defined by a timepoint. Only tokens delegated at that timepoint can be used for voting. This means any token transfer performed after the snapshot will not affect the voting power of the sender/receiver's delegate. This also means that in order to vote, someone must acquire tokens and delegate them before the snapshot is taken. Governors can, and do, include a delay between the proposal is submitted and the snapshot is taken so that users can take the necessary actions (change their delegation, buy more tokens, ...).

While timestamps produced by ERC-6372's clock are represented as uint48, getPastVotes's timepoint argument is uint256 for backward compatibility. Any timepoint >=2**48 passed to getPastVotes SHOULD cause the function to revert, as it would be a lookup in the future.

delegateBySig is necessary to offer a gasless workflow to token holders that do not want to pay gas for voting.

The nonces mapping is given for replay protection.

EIP-712 typed messages are included because of their widespread adoption in many wallet providers.

Backwards Compatibility

Compound and OpenZeppelin already provide implementations of voting tokens. The delegation-related methods are shared between the two implementations and this ERC. For the vote lookup, this ERC uses OpenZeppelin's implementation (with return type uint256) as Compound's implementation causes significant restrictions of the acceptable values (return type is uint96).

Both implementations use block.number for their checkpoints and do not implement ERC-6372, which is compatible with this ERC.

Existing governors, that are currently compatible with OpenZeppelin's implementation will be compatible with the "block number mode" of this ERC.

Security Considerations

Before doing a lookup, one should check the return value of clock() and make sure that the parameters of the lookup are consistent. Performing a lookup using a timestamp argument on a contract that uses block numbers will very likely cause a revert. On the other end, performing a lookup using a block number argument on a contract that uses timestamps will likely return 0.

Though the signer of a Delegation may have a certain party in mind to submit their transaction, another party can always front-run this transaction and call delegateBySig before the intended party. The result is the same for the Delegation signer, however.

Since the ecrecover precompile fails silently and just returns the zero address as signer when given malformed messages, it is important to ensure signer != address(0) to avoid delegateBySig from delegating "zombie funds" belonging to the zero address.

Signed Delegation messages are censorable. The relaying party can always choose to not submit the Delegation after having received it, withholding the option to submit it. The expiry parameter is one mitigation to this. If the signing party holds ETH they can also just submit the Delegation themselves, which can render previously signed Delegations invalid.

If the DOMAIN_SEPARATOR contains the chainId and is defined at contract deployment instead of reconstructed for every signature, there is a risk of possible replay attacks between chains in the event of a future chain split.

Copyright

Copyright and related rights waived via CC0.