Why was it decided for ERC20Votes to support token supplies of up to 2^{224} - 1?

The original ERC20 extension from Compound proposed the use of uint96 for vote weight representation. Why did OpenZeppelin choose uint224? Was this decision arbitrary?

I also find it odd that despite this, getVotes() and getPastVotes(), getPastTotalSupply() still all return a uint256. uint224 safety checks are done only on minting and when writing the checkpoint.

Is this to save gas on safety checks? Why not just make the votes of type uint256 altogether?

Compound used uint96 as an optimization to pack multiple values into the same storage slot. They could do this because the Comp token has a supply cap of 10 million tokens (with 18 decimals). OpenZeppelin Contracts, however, offers a generic ERC20 implementation and our users can create tokens with much higher supply or even dynamic supply that can't risk overflowing a uint96 number. So we couldn't use such a small limit.

Some optimization was important to us though so we used uint224 in order to pack the number of votes and the block number in a single slot. The number is 256 - 32, where 32 bits are necessary to store a block number.

What the further optimization implemented by Comp achieves is to pack two Checkpoints in a single storage slot. This will make every other transfer a little cheaper because the current and new checkpoints share the storage slot so it is already warm on the second access.

getVotes() and getPastVotes() return uint256 because we see the uint224 type as an internal implementation detail. Another contract could implement the same interface in a different way and potentially use the full 256 bits.

Note that Comp has getPriorVotes with a return type of uint96. We would've loved to use the same function names in order to maximize compatibility, but the small return type made this impossible because Solidity would (silently!) truncate all bits past the 96th if someone invoked a function from ERC20Votes thinking it followed the Comp interface. We think the right thing would've been for Comp to return uint256, and so we did that as well.

3 Likes

@frangio thank you for the very insightful answer! That clarifies most of my questions. I have two more:

  • I'm curious why Compound decided to use a uint96 and not a uint32 for voting as that would have allowed for packing 4 Checkpoints per slot while ensuring votes don't exceed their 10M supply. Any ideas?

  • The uint224 as an internal implementation detail makes sense. However, following this logic, why wasn't numCheckpoints() chosen to return a uint256, instead of a uint32 [1]? There seems to be no guard for preventing a Checkpoint[] array from exceeding a length of 2^32 - 1, yet when calling this function a safety check is made. Wouldn't it have made more sense for numCheckpoints() to just return a uint256?

[1] https://github.com/OpenZeppelin/openzeppelin-contracts/blob/master/contracts/token/ERC20/extensions/ERC20Votes.sol#L61

32 bits are not enough because token balances have 18 decimal places, so you actually need 25 decimal digits or around 84 bits.

For numCheckpoints(), that number is bounded by the current block number, and there is a natural limit that arises from assumptions about the rate at which blocks are produced. You could also make an argument that an implementation of the same interface could use checkpoint numbers that didn't correspond to blocks, to be honest I don't think this occurred to us, as the interface is kind of coupled to blocks.

2 Likes

Can you elaborate a bit about the further optimization?

Where exactly are two Checkpoints accessed in transfer? Is it the checkpoints mapping inside _writeCheckpoint()? But mappings don't store the values continuously do they?

I think you're right. I was assuming the Checkpoint struct was stored in an array, in that case making it smaller would've resulted in two Checkpoints per storage slot. But this doesn't seem to be the case, since they are stored in a mapping.

2 Likes