Voting with delegation
Video
Original
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:
- 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 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.
- Calling this function with a timepoint that is greater or equal to the current clock SHOULD revert.
- Calling this function with a timepoint strictly smaller than the current clock SHOULD NOT revert.
- For any integer that is strictly smaller than the current 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 tononce
.
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 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.
- For all timepoints
t < clock
,getVotes(address(0))
andgetPastVotes(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 toa
. - For all accounts
a != 0
and all timestampt < clock
,getPastVotes(a, t)
SHOULD be the sum of the "balances" of all the accounts that delegated toa
whenclock
overtookt
. - For all accounts
a
,getPastVotes(a, t)
MUST be constant aftert < clock
is reached. - For all accounts
a
, the action of changing the delegate fromb
toc
MUST not increase the current voting power ofb
(getVotes(b)
) and MUST not decrease the current voting power ofc
(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, ...).
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 Delegation
s 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.
Adopted by projects
Not miss a beat of EIPs' update?
Subscribe EIPs Fun to receive the latest updates of EIPs Good for Buidlers to follow up.
View all