ERC-5805 Delegation-based voting power tracking standard (Voting with delegation)
Review · Tokens · created 2022-07-04
A standard that lets token holders delegate voting power to another account without transferring the tokens themselves, and lets anyone query the voting power held at a specific point in the past.
Why it is needed
Many decentralized autonomous organizations (DAOs) determine voting power by token quantity. However, handling voting purely through the balance query (balanceOf) that most tokens provide creates two problems. First, because balances are not recorded per point in time, a person could vote and then move their tokens to another account to vote again with the same tokens. Second, there is no way to hand off only voting power to someone else without transferring ownership of the tokens outright. As a result, several projects, including Compound, have each built their own voting tokens with delegation and timepoint recording (checkpoints), but no standard existed. Existing implementations also only support recording by block number, which is a problem on chains like layer 2s where block intervals are not constant. This standard unifies the delegation and voting power query interface and allows any monotonically increasing time reference, whether block number or timestamp, to be used.
What to implement
event DelegateChanged(address indexed delegator, address indexed fromDelegate, address indexed toDelegate);: An event emitted when an account's delegation target changes. It must be emitted whenever delegation is modified via delegate or delegateBySig.event DelegateVotesChanged(address indexed delegate, uint256 previousBalance, uint256 newBalance);: An event emitted when a delegate account's voting power changes. It is emitted when voting power changes due to a delegation change or a token transfer.function getVotes(address account) external view returns (uint256);: Returns the voting power that account currently holds. This is the total voting power delegated to that account as of the time of the call.function getPastVotes(address account, uint256 timepoint) external view returns (uint256);: Returns the voting power that account held at a specific point in the past. The timepoint argument must be in the same unit as the contract's clock mode.function delegates(address account) external view returns (address);: Returns the address that the account's voting power is currently delegated to.function nonces(address owner) public view virtual returns (uint256): Returns a per-account nonce value used to prevent replay of signed delegations. It increments by 1 each time delegateBySig is executed.function delegate(address delegatee) external;: Changes the caller's delegation target and updates the voting power record.function delegateBySig(address delegatee, uint256 nonce, uint256 expiry, uint8 v, bytes32 r, bytes32 s) external;: Changes delegation via an EIP-712 signature instead of sending a transaction directly. It only executes if the signer's nonce and expiry match.
In depth
Delegation mechanism
Each account can designate itself, a third party, or address(0) as its delegation target. Held assets cannot be used as voting power until they are delegated. When a delegator delegates, that balance is added to the delegate's voting power; if the delegation is changed, it is subtracted from the previous delegate and added to the new one. When tokens are transferred, the voting power must be subtracted from the sending delegate and added to the receiving delegate. Tokens delegated to address(0) are not tracked, so users can skip updating delegate checkpoints to save gas on transfers. Since a token is delegated to at most one party at a time, double voting is prevented.
Clock and ERC-6372
Contracts conforming to this standard SHOULD implement ERC-6372 (contract clock) to signal the time reference used for vote tracking. If ERC-6372 is not implemented, the contract MUST behave as if CLOCK_MODE returned mode=blocknumber&from=default, operating with a block number clock. On mainnet, block numbers offer better compatibility with existing apps, while timestamps are more meaningful for users to understand and fit better for expressing durations in seconds. External contracts such as governors can read the clock() value to stay consistent with the token's checkpoints.
Rules for getVotes and getPastVotes
getVotes(account) returns the total voting power delegated to that account at the time of the call. getPastVotes(account, timepoint) returns the voting power at a past point in time, and timepoint MUST match the contract's operating mode. Calling with a timepoint at or beyond the current clock SHOULD revert, calling with a timepoint earlier than the current clock SHOULD NOT revert, and a past value once returned SHOULD remain the same whenever queried again later. Tokens delegated to address(0) are not tallied, so getVotes(0) and getPastVotes(0, x) MUST always return 0. The ERC-6372 clock is uint48, but getPastVotes' argument is uint256 for backward compatibility, and timepoints of 2**48 or greater represent future queries and MUST revert.
delegateBySig and EIP-712 signatures
delegateBySig(delegatee, nonce, expiry, v, r, s) changes the signer's delegation using an EIP-712 signature of type Delegation(address delegatee, uint256 nonce, uint256 expiry). It only executes when the current timestamp is at or before expiry and nonces(signer) equals nonce; it must revert if these conditions are not met. On success, it increments the signer's nonce by 1 and emits a DelegateChanged event (and DelegateVotesChanged if applicable). The function can be called by anyone and does not reference msg.sender. This lets holders who do not want to pay gas delegate with just a signature, and the nonces mapping prevents replay. DOMAIN_SEPARATOR must be unique per contract and chain to prevent replay attacks across domains.
Conditions for emitting events
DelegateChanged MUST be emitted whenever an account's delegation target is modified via delegate(address) or delegateBySig. DelegateVotesChanged MUST be emitted in two cases: when an account holding a nonzero amount of assets changes its delegation to or from a given delegate, and when assets are transferred to or from an account delegated to that delegate. The event carries the previous voting power (previousBalance) and the new voting power (newBalance).
Invariants to maintain
Letting clock denote the current clock, getVotes(a) SHOULD equal the sum of the balances of all accounts delegated to a, for every nonzero account a. getPastVotes(a, t) for a past point in time t MUST equal the sum of the balances of accounts that were delegated to a as of the clock passing t, and this value MUST NOT change once t has become the past. Changing delegation from b to c MUST NOT increase b's current voting power and MUST NOT decrease c's current voting power.
Compatibility with existing implementations
Compound and OpenZeppelin already provide voting token implementations, and the delegation-related methods are the same across both implementations and this standard. Voting power queries follow the OpenZeppelin approach (uint256 return type), since Compound's implementation returns uint96, which imposes significant constraints on allowed values. Both implementations use block number checkpoints and do not implement ERC-6372, which is compatible with this standard. Existing governors compatible with the OpenZeppelin implementation are compatible with this standard's block number mode. Note that this proposal is still in Review status and its content may change.
Things to watch out for
- Checking clock mode before querying: Before querying, you should check the return value of clock() to confirm that the query argument is consistent. Querying a block-number-based contract with a timestamp argument is likely to revert, and the reverse case is likely to return 0.
- Front-running signed delegations: Even if a Delegation signer expects a specific party to submit the transaction, another party can call delegateBySig first. However, the outcome is the same from the signer's perspective.
- Silent failure of ecrecover: The ecrecover precompile returns the zero address instead of failing when given an invalid message. You must check signer != address(0) to prevent funds belonging to the zero address from being delegated.
- Censorship risk in signed delegation: A relayer who received a signature can withhold it without submitting it. The expiry parameter is one mitigation, and if the signer holds ETH, they can submit it themselves to invalidate the earlier signature.
- Replay risk on chain splits: If chainId is included in DOMAIN_SEPARATOR but it is fixed at deployment time rather than regenerated with each signature, there is a risk of cross-chain replay attacks if the chain later splits.