Platform: Code4rena
Start Date: 21/04/2022
Pot Size: $75,000 USDC
Total HM: 7
Participants: 45
Period: 7 days
Judge: 0xean
Total Solo HM: 5
Id: 111
League: ETH
Rank: 18/45
Findings: 2
Award: $242.80
🌟 Selected for report: 0
🚀 Solo Findings: 0
🌟 Selected for report: IllIllI
Also found by: 0v3rf10w, 0xDjango, 0xmint, CertoraInc, Dravee, MaratCerby, Ruhum, VAD37, catchup, csanuragjain, defsec, delfin454000, dipp, fatima_naz, gzeon, hake, hyh, joestakey, kebabsec, oyc_109, rayn, robee, samruna, simon135, sorrynotsorry, teryanarmen
174.6927 USDC - $174.69
https://github.com/fei-protocol/flywheel-v2/blob/main/src/token/ERC20MultiVotes.sol#L246
ERC20MultiVotes
has variable maxDelegates
that determines the maximum number of accounts any account can delegate to. This check is ignored for contracts where canContractExceedMaxDelegates[address]
is set to true
. These contracts can delegate to an uncapped number of accounts. This will in turn increase the gas cost of calling transfer
transferFrom
and _burn
because they call _decrementVotesUntilFree
which caches every delegated address when the contract does not have enough freeVotes
to transfer the requested amount. Since the number of delegates is uncapped, it can increase to the point where transferring any amount of tokens will exceed the block gas limit, locking all funds in the delegating contract.
The purpose of allowing some contracts to ignore the maxDelegates check is to allow users to earn yield by depositing while maintaining their ability to vote on governance proposals. If the contract that is deposited into automatically delegates all votes back to the users, and exceeds a certain number of users, all funds will be locked in the contract. Some example scenarios:
If a contract delegates to ~4000 users, it will no longer be able to transfer all funds. This can be an issue if trying to rescue funds from a contract with a bug or move funds to a new implementation.
If a contract delegates to ~30000 users, calling delegates(contract)
in a state changing function will exceed the block gas limit.
If a contract delegates to ~100000 users, transferring any amount of tokens will exceed the block gas limit.
This is a problem even if any of these limits aren't reached because the gas is paid for by users. Even at a fraction of the limit, UX will take a big hit since each user withdrawing will have to pay high fees.
Manual analysis. Foundry tests.
I added this test to ERC20MultiVotes.t.sol
to come up with the numbers above.
function testGasBrickDelegateOverMaxDelegatesApproved() public { token.mint(address(this), 100e18); token.setMaxDelegates(8); token.setContractExceedMaxDelegates(address(this), true); uint160 i = 1; // address 0 causes reverts while (i < 100000) { token.incrementDelegation(address(i), token.freeVotes(address(this))/100 /* random-ish distribution of tokens */); i++; } token.incrementDelegation(address(250000), token.freeVotes(address(this))); // forces decrement startMeasuringGas("values view call"); token.delegates(address(this)); stopMeasuringGas(); startMeasuringGas("transfer call"); token.transfer(address(1000000), 1); stopMeasuringGas(); }
To ensure the contract doesn't lock all funds, a global maxDelegates can be used. However, this issue is pretty complex and would more effectively be solved at the level of the contract that takes deposits. One solution would be to ensure the delegating contract has enough freeVotes
to cover any single user withdrawing funds. This can be done by checking the state of the contract off-chain and depositing funds when necessary. It can also be somewhat mitigated by not delegating to every user that deposits and instead giving them the option of "requesting votes", however this can still lead to the same issue if everyone requests votes. A combination of these two methods would work well.
Another solution, which I think is the most optimal, would be to reimplement _decrementVotesUntilFree
to not cache all addresses and instead just remove delegates starting at index 0 until free using .length()
and .at()
methods provided by EnumerableSet
. This solution does not solve the problem with calling delegates(contract)
nor does it allow transferring all tokens at once with > 4000 delegates. This second point can be dealt with by breaking up the transfer into smaller functions and bundling using flashbots. I have added an example implementation below.
function _decrementVotesUntilFree(address user, uint256 votes) internal { uint256 userFreeVotes = freeVotes(user); // early return if already free if (userFreeVotes >= votes) return; // cache total for batch updates uint256 totalFreed; // Loop through all delegates //address[] memory delegateList = _delegates[user].values(); // Free delegates until through entire list or under votes amount uint256 size = _delegates[user].length(); for (uint256 i = 0; i < size && (userFreeVotes + totalFreed) < votes; i++) { address delegatee = _delegates[user].at(i); // get each user as necessary, removes unnecessary SLOADs uint256 delegateVotes = _delegatesVotesCount[user][delegatee]; if (delegateVotes != 0) { totalFreed += delegateVotes; require(_delegates[user].remove(delegatee)); // Remove from set. Should never fail. _delegatesVotesCount[user][delegatee] = 0; _writeCheckpoint(delegatee, _subtract, delegateVotes); emit Undelegation(user, delegatee, delegateVotes); } } userDelegatedVotes[user] -= totalFreed; }
The sponsor mentioned they intend to solve this at the delegating contract level, nonetheless, this issue should at least be mentioned in the contract as it is open source and others might use it with an improper implementation.
#0 - Joeysantoro
2022-05-13T00:55:30Z
This issue is invalid, the purpose of whitelisting contracts is so that contracts can aggregate user exposure to an unbounded set of users, but each user's balances are managed and limited to prevent dosing.
#1 - Joeysantoro
2022-05-20T17:05:48Z
To further clarify, the owners should ONLY whitelist contracts which atomically free votes before transferring tokens, such that the unbounded array iteration never occurs.
That contract would need to take care to not be vulnerable to this issue described.
#2 - 0xean
2022-05-20T17:11:17Z
Based on the sponsors intended use of the whitelist I am going to mark this down to QA since the risk does exist, but the sponsor has a manual way to mitigate the issue in place.
🌟 Selected for report: 0xkatana
Also found by: 0v3rf10w, 0x1f8b, 0xNazgul, 0xmint, CertoraInc, Dravee, Fitraldys, Funen, IllIllI, NoamYakov, Scocco, Tomio, catchup, csanuragjain, defsec, delfin454000, djxploit, fatima_naz, gzeon, joestakey, joshie, kebabsec, nahnah, oyc_109, rayn, robee, rotcivegaf, saian, samruna, sorrynotsorry, teryanarmen, z3s
68.1091 USDC - $68.11
decrementWeightUntilFree
can be optimized by using _userGauges[user].at(i)
and _userGauges[user].length()
directly instead of storing _userGauges[user].values()
as gaugeList
and calling gaugeList.length
and gaugeList[i]
.
Implementation:
function _decrementWeightUntilFree(address user, uint256 weight) internal { uint256 userFreeWeight = balanceOf[user] - getUserWeight[user]; // early return if already free if (userFreeWeight >= weight) return; uint32 currentCycle = _getGaugeCycleEnd(); // cache totals for batch updates uint112 userFreed; uint112 totalFreed; // Loop through all user gauges, live and deprecated //address[] memory gaugeList = _userGauges[user].values(); // Free gauges until through entire list or under weight uint256 size = _userGauges[user].length(); //uint256 size = gaugeList.length; for (uint256 i = 0; i < size && (userFreeWeight + totalFreed) < weight; ) { //address gauge = gaugeList[i]; address gauge = _userGauges[user].at(i); uint112 userGaugeWeight = getUserGaugeWeight[user][gauge]; if (userGaugeWeight != 0) { // If the gauge is live (not deprecated), include its weight in the total to remove if (!_deprecatedGauges.contains(gauge)) { totalFreed += userGaugeWeight; } userFreed += userGaugeWeight; _decrementGaugeWeight(user, gauge, userGaugeWeight, currentCycle); unchecked { i++; } } } getUserWeight[user] -= userFreed; _writeGaugeWeight(_totalWeight, _subtract, totalFreed, currentCycle); }
Gas testing function:
function testGasGauge() public { token.mint(address(this), 100e18); token.setMaxGauges(5); uint160 i = 1; // address 0 causes reverts while (i < 3) { token.addGauge(address(i)); token.incrementGauge(address(i), 50e18); i++; } startMeasuringGas("transfer call"); token.transfer(address(1000000), 1); stopMeasuringGas(); }
#0 - Joeysantoro
2022-05-13T01:19:07Z
good optimization