Alert Source Discuss
⚠️ Draft Standards Track: ERC

ERC-5805: Voting with delegation

An interface for voting weight tracking, with delegation support

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

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 EIP 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 EIP-20 and EIP-721. This EIP 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 (EIP-20, EIP-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:

  • The values are not checkpointed, so a user can vote, transfer its tokens to a new account, and vote again with the same tokens.
  • A user cannot delegate their voting power to someone else without transferring full ownership of the tokens.

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

  • Users can delegate the voting power of their tokens to themselves or a third party. This creates a distinction between balance and voting weight.
  • The voting weights of accounts are checkpointed, allowing lookups for past values at different points in time.
  • The balances are not checkpointed.

This EIP 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 EIP 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

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

clock

This function returns the current timepoint. It could be block.timestamp, block.number (or any other non-decreasing function) depending on the mode the contract is operating on.

  • If operating using block number, then this function SHOULD be implemented.
  • If operating using timestamp, then this function MUST be implemented.
  • If operating using any other mode, then this function MUST be implemented.

This function is thus optional, and its absence should be considered as a marker of the contract operating using block number. (This makes this EIP compatible with pre-existing voting contracts).

- name: clock
  type: function
  stateMutability: view
  inputs: []
  outputs:
    - name: timepoint
      type: uint48

CLOCK_MODE

This function returns a string describing the clock the contract is operating on.

  • If operating using block number:
    • If the block numbers are those of the NUMBER opcode (0x43), then this function SHOULD be implemented and return mode=blocknumber&from=default.
    • If it is any other block number, then this function MUST be implemented and return mode=blocknumber&from=<CAIP2ID>, where <CAIP2ID> is a CAIP-2 Blockchain ID such as eip155:1.
  • If operating using timestamp, then this function MUST be implemented and return mode=timestamp.
  • If operating using any other mode, then this function MUST be implemented and return a unique identifier for the encoded mode field.

This function is thus optional, and its absence should be considered as a marker of the contract operating using block numbers, which can be clearly identified from the absence of this function. (This makes this EIP compatible with pre-existing voting contracts).

Note that when operating using block number, the clock() is expected to return the value given by the NUMBER opcode (0x43). In some cases this can be the block number of another chain (in arbitrum, opcode 0x43 returns the block number of the last recorded operation on the parent chain). A contract can use from=default to specify that the block number used is the one provided by the NUMBER opcode (0x43). If a more explicit description is needed, CAIP-2 blockchain id should be used, as shown in the above.

The return string MUST be formatted like a URL query string (a.k.a. application/x-www-form-urlencoded). This allows easy decoding in standard JavaScript with new URLSearchParams(CLOCK_MODE).

- name: CLOCK_MODE
  type: function
  stateMutability: view
  inputs: []
  outputs:
    - name: descriptor
      type: string

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.

  • Calling this function with a timepoint that is greater or equal to clock() SHOULD revert.
  • Calling this function with a timepoint strictly smaller than clock() SHOULD NOT revert.
  • For any integer that is strictly smaller than clock(), the value returned by getPastVotes SHOULD be constant. This means that for any call to this function that returns a value, re-executing the same call (at any time in the future) SHOULD return the same value.

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:

  • The current timestamp is less than or equal to expiry.
  • nonces(signer) (before the state update) is equal to nonce.

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:

  • an account (that holds more than 0 assets) updates its delegation from or to delegate,
  • an asset transfer from or to an account that is delegated to 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

interface IERC5805 {
  event DelegateChanged(address indexed delegator, address indexed fromDelegate, address indexed toDelegate);
  event DelegateVotesChanged(address indexed delegate, uint256 previousBalance, uint256 newBalance);

  function clock() external view returns (uint48);
  function CLOCK_MODE() external view returns (string);

  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

  • The clock() function MUST be non-decreasing.
  • For all timepoints t < clock(), getVotes(address(0)) and getPastVotes(address(0), t) SHOULD return 0.
  • For all accounts a != 0, getVotes(a) SHOULD be the sum of the “balances” of all the accounts that delegate to a.
  • For all accounts a != 0 and all timestamp t < clock(), getPastVotes(a, t) SHOULD be the sum of the “balances” of all the accounts that delegated to a when clock() overtook t.
  • For all accounts a, getPastVotes(a, t) MUST be constant after t < clock() is reached.
  • For all accounts a, the action of changing the delegate from b to c MUST not increase the current voting power of b (getVotes(b)) and MUST not decrease the current voting power of c (getVotes(c)).

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, …).

clock returns uint48 as it is largely sufficient for storing realistic values. In timestamp mode, uint48 will be enough until the year 8921556. Even in block number mode, with 10,000 blocks per second, it would be enough until the year 2861. Using a type smaller than uint256 allows some storage packing of timepoints with other associated values. Greatly reducing the cost of writing and reading from storage. Depending on the evolution of the blockchain (particularly layer twos), uint32 might cause issues fairly quickly. On the other hand, anything bigger than uint48 is overkill.

While timestamps produced by 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 EIP. For the vote lookup, this EIP 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 the clock() method, which is compatible with this EIP.

Existing governors, that are currently compatible with OpenZeppelin’s implementation will be compatible with the “block number mode” of this EIP.

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 and related rights waived via CC0.

Citation

Please cite this document as:

Hadrien Croubois (@Amxx), Francisco Giordano (@frangio), "EIP-5805: Voting with delegation [DRAFT]," Ethereum Improvement Proposals, no. 5805, July 2022. [Online serial]. Available: https://eips.ethereum.org/EIPS/eip-5805.