Platform: Code4rena
Start Date: 07/08/2023
Pot Size: $36,500 USDC
Total HM: 11
Participants: 125
Period: 3 days
Judge: alcueca
Total Solo HM: 4
Id: 274
League: ETH
Rank: 16/125
Findings: 4
Award: $289.11
🌟 Selected for report: 0
🚀 Solo Findings: 0
🌟 Selected for report: SpicyMeatball
Also found by: 0xComfyCat, GREY-HAWK-REACH, Yanchuan, cducrest, immeas, kaden, mert_eren, nonseodion, pep7siup, popular00, ppetrov
143.0396 USDC - $143.04
https://github.com/code-423n4/2023-08-verwa/blob/main/src/LendingLedger.sol#L16-L17 https://github.com/code-423n4/2023-08-verwa/blob/main/src/LendingLedger.sol#L134-L137 https://github.com/code-423n4/2023-08-verwa/blob/main/src/LendingLedger.sol#L168-L175
Rewards in LendingLedger
are distributed on a per epoch (weekly) basis:
LendingLedger.lendingMarketBalances
:
File: LendingLedger.sol 16: /// @dev Lending Market => Lender => Epoch => Balance 17: mapping(address => mapping(address => mapping(uint256 => uint256))) public lendingMarketBalances; // cNote balances of users within the lending markets, indexed by epoch
Here each time a lender deposits or withdraws their balance is updated:
File: LendingLedger.sol 134: uint256 currEpoch = (block.timestamp / WEEK) * WEEK; 135: int256 updatedLenderBalance = int256(lendingMarketBalances[lendingMarket][_lender][currEpoch]) + _delta; 136: require(updatedLenderBalance >= 0, "Lender balance underflow"); // Sanity check performed here, but the market should ensure that this never happens 137: lendingMarketBalances[lendingMarket][_lender][currEpoch] = uint256(updatedLenderBalance);
Then rewards can be claimed up to the previous epoch:
File: LendingLedger.sol 168: for (uint256 i = claimStart; i <= claimEnd; i += WEEK) { 169: uint256 userBalance = lendingMarketBalances[_market][lender][i]; 170: uint256 marketBalance = lendingMarketTotalBalance[_market][i]; 171: RewardInformation memory ri = rewardInformation[i]; 172: require(ri.set, "Reward not set yet"); // Can only claim for epochs where rewards are set, even if it is set to 0 173: uint256 marketWeight = gaugeController.gauge_relative_weight_write(_market, i); // Normalized to 1e18 174: cantoToSend += (marketWeight * userBalance * ri.amount) / (1e18 * marketBalance); // (marketWeight / 1e18) * (userBalance / marketBalance) * ri.amount; 175: }
The issue is that, since it is tracked on a discrete epoch basis, a user only needs to hold a position just when the epoch transitions over. They can open a position in the block before the epoch switch and then close it in the block after. Thus they only need to actually hold the position in one block.
To get rewards, a lender only needs to hold a position for the block crossing the epoch "cutoff". This is easily scripted with a bot to automate on behalf of the lender.
Test in LendingLedger.t.sol
:
function testOnlyHoldPositionAcrossEpochCutoff() public { whiteListMarket(); vm.prank(goverance); ledger.setRewards(fromEpoch, toEpoch, amountPerEpoch); payable(ledger).transfer(1000 ether); int256 delta = 1.1 ether; vm.startPrank(lendingMarket); // lender deposits just before next epoch vm.warp(WEEK * 5 + WEEK - 1); // epoch "5" ledger.sync_ledger(lender, delta); assertEq(ledger.lendingMarketBalances(lendingMarket,lender,WEEK * 5),uint256(delta)); // lender withdraws one block after vm.warp(WEEK * 5 + WEEK + 1); // epoch "6" ledger.sync_ledger(lender, -delta); assertEq(ledger.lendingMarketBalances(lendingMarket,lender,WEEK * 6),0); // same process can be repeated each epoch cutoff vm.warp(WEEK * 6 + WEEK - 1); // epoch "6" ledger.sync_ledger(lender, delta); assertEq(ledger.lendingMarketBalances(lendingMarket,lender,WEEK * 6),uint256(delta)); vm.warp(WEEK * 6 + WEEK + 1); // epoch "7" ledger.sync_ledger(lender, -delta); assertEq(ledger.lendingMarketBalances(lendingMarket,lender,WEEK * 7),0); vm.stopPrank(); uint256 balanceBefore = lender.balance; vm.prank(lender); // lender has only held a position for two blocks, still got rewards for two epochs ledger.claim(lendingMarket, WEEK * 5, WEEK * 7); assertEq(lender.balance - balanceBefore,2*amountPerEpoch); }
Manual audit
Consider tracking rewards per second instead of per epoch
Timing
#0 - c4-pre-sort
2023-08-13T01:40:13Z
141345 marked the issue as duplicate of #71
#1 - c4-judge
2023-08-25T11:01:30Z
alcueca changed the severity to 3 (High Risk)
#2 - c4-judge
2023-08-25T11:03:18Z
alcueca marked the issue as satisfactory
🌟 Selected for report: 0x73696d616f
Also found by: 0xCiphky, 0xComfyCat, 0xDetermination, GREY-HAWK-REACH, QiuhaoLi, SpicyMeatball, Team_Rocket, Tricko, Yanchuan, deadrxsezzz, immeas, kaden, lanrebayode77, ltyu, mert_eren, nonseodion, oakcobalt, popular00, th13vn
36.9443 USDC - $36.94
https://github.com/code-423n4/2023-08-verwa/blob/main/src/GaugeController.sol#L214-L220
When voting for gauge weights in GaugeController
a users latest vote weight is queried from VotingEscrow
:
GaugeController::vote_for_gauge_weights
File: GaugeController.sol 214: VotingEscrow ve = votingEscrow; 215: ( 216: , 217: /*int128 bias*/ 218: int128 slope_, /*uint256 ts*/ 219: 220: ) = ve.getLastUserPoint(msg.sender);
This is then used to calculate the total voting weight for the gauge
.
The issue is that weights are added based on the current voting power of the user. Hence two users/accounts can collude and delegate to each other:
Users that are colluding or have multiple accounts can have their votes counted multiple times when voting for gauge
weights. This can cause the gauge
they are voting for to get an unfair share of rewards in LendingLedger
Test in GaugeController.t.sol
address user3 = address(3333); function testDelegateAndVote() public { vm.warp(WEEK); vm.deal(user1, 100 ether); vm.deal(user2, 100 ether); vm.deal(user3, 100 ether); vm.startPrank(gov); gc.add_gauge(gauge1); gc.change_gauge_weight(gauge1, 0); gc.add_gauge(gauge2); gc.change_gauge_weight(gauge2, 0); vm.stopPrank(); uint256 v = 10 ether; uint256 epoch = (block.timestamp / WEEK) * WEEK + WEEK; // reference gauge vm.startPrank(user1); ve.createLock{value: v}(v); gc.vote_for_gauge_weights(gauge1, 10_000); vm.stopPrank(); vm.prank(user2); ve.createLock{value: v}(v); // user3 delegates to user2 vm.startPrank(user3); ve.createLock{value: v}(v); ve.delegate(user2); vm.stopPrank(); vm.prank(user2); gc.vote_for_gauge_weights(gauge2, 10_000); // gauge2 as double the votes to gauge1, since two people voted uint256 relWeight = gc.gauge_relative_weight_write(gauge2, epoch); // ~66% assertApproxEqRel(0.6666e18,relWeight,0.01e18); // switch delegatee vm.prank(user3); ve.delegate(user3); vm.prank(user2); ve.delegate(user3); // user3 votes for gauge2 with user2 delegated votes vm.prank(user3); gc.vote_for_gauge_weights(gauge2, 10_000); relWeight = gc.gauge_relative_weight_write(gauge2, epoch); // ~80% relative weight for gauge2 i.e. 4 times the weight after just 2 people voted assertApproxEqRel(0.80e18,relWeight,0.001e18); }
Manual audit
Consider not counting delegated votes when adding weight. Otherwise an elaborate tracking system for who delegated for who is needed.
Invalid Validation
#0 - c4-pre-sort
2023-08-13T01:41:44Z
141345 marked the issue as duplicate of #45
#1 - c4-pre-sort
2023-08-13T13:16:55Z
141345 marked the issue as duplicate of #99
#2 - c4-pre-sort
2023-08-13T17:09:15Z
141345 marked the issue as duplicate of #178
#3 - c4-pre-sort
2023-08-13T17:36:44Z
141345 marked the issue as not a duplicate
#4 - c4-pre-sort
2023-08-13T17:37:10Z
141345 marked the issue as duplicate of #86
#5 - c4-judge
2023-08-25T10:51:34Z
alcueca changed the severity to 3 (High Risk)
#6 - c4-judge
2023-08-25T10:54:58Z
alcueca marked the issue as satisfactory
🌟 Selected for report: thekmj
Also found by: 0xCiphky, 0xDetermination, 0xbrett8571, Eeyore, Team_Rocket, Tripathi, bart1e, deadrxsezzz, immeas, ltyu, mert_eren, pep7siup, popular00
99.3104 USDC - $99.31
https://github.com/code-423n4/2023-08-verwa/blob/main/src/GaugeController.sol#L127-L132
On top of the curve GaugeController.vy
, GaugeController.sol
has added the ability for governance
to remove a gauge
:
GaugeController::remove_gauge
:
File: GaugeController.sol 127: function remove_gauge(address _gauge) external onlyGovernance { 128: require(isValidGauge[_gauge], "Invalid gauge address"); 129: isValidGauge[_gauge] = false; // <-- valid set to false 130: _change_gauge_weight(_gauge, 0); 131: emit GaugeRemoved(_gauge); 132: }
The issue is that once removed, a user cannot remove their assigned vote power:
GaugeController::vote_for_gauge_weights
:
File: GaugeController.sol 213: require(isValidGauge[_gauge_addr], "Invalid gauge address"); // <-- must be valid to interact with
Hence, any vote power still allocated to the removed gauge
will be locked and cannot be re-assigned.
Any vote power assigned to a gauge that is removed will be locked. It can only be recovered by adding the gauge back. Making removing a gauge impractical.
Test in GaugeController.t.sol
function testVoteStuckWhenGaugeRemoved() public { // prepare vm.deal(user1, 100 ether); vm.startPrank(gov); gc.add_gauge(user1); gc.change_gauge_weight(user1, 100); gc.add_gauge(gauge2); gc.change_gauge_weight(gauge2, 100); vm.stopPrank(); uint256 v = 10 ether; // user votes for a gauge vm.startPrank(user1); ve.createLock{value: v}(v); gc.vote_for_gauge_weights(user1, 10_000); vm.stopPrank(); // governance removes the gauge vm.prank(gov); gc.remove_gauge(user1); vm.startPrank(user1); // user cannot remove their votes vm.expectRevert("Invalid gauge address"); gc.vote_for_gauge_weights(user1, 0); // or use the power to vote on anything else vm.expectRevert("Used too much power"); gc.vote_for_gauge_weights(gauge2, 1); vm.stopPrank(); }
Manual audit
Consider adding a call to reclaim vote power when a gauge is removed. This would also need tracking of vote power per gauge to make sure only vote power assigned to inactive gauges can be reclaimed.
DoS
#0 - c4-pre-sort
2023-08-12T15:17:35Z
141345 marked the issue as duplicate of #62
#1 - c4-judge
2023-08-25T11:10:20Z
alcueca marked the issue as satisfactory
#2 - c4-judge
2023-08-25T22:43:36Z
alcueca changed the severity to 3 (High Risk)
🌟 Selected for report: RED-LOTUS-REACH
Also found by: 0x3b, 0x4non, 0xCiphky, 0xDING99YA, 0xDetermination, 0xE1, 0xG0P1, 0xStalin, 0xWaitress, 0xbrett8571, 0xhacksmithh, 0xkazim, 0xmuxyz, 0xweb3boy, 14si2o_Flint, AlexCzm, Alhakista, Bube, Bughunter101, Deekshith99, Eeyore, Giorgio, HChang26, InAllHonesty, JP_Courses, KmanOfficial, MatricksDeCoder, Mike_Bello90, MrPotatoMagic, Naubit, QiuhaoLi, RHaO-sec, Raihan, Rolezn, SUPERMAN_I4G, Shubham, Silverskrrrt, Strausses, T1MOH, Topmark, Tripathi, Watermelon, _eperezok, aakansha, auditsea, audityourcontracts, ayden, carlos__alegre, castle_chain, cducrest, ch0bu, d23e, deadrxsezzz, deth, devival, erebus, fatherOfBlocks, halden, hassan-truscova, hpsb, hunter_w3b, imkapadia, immeas, jat, kaden, kaveyjoe, klau5, koxuan, kutugu, ladboy233, lanrebayode77, leasowillow, lsaudit, markus_ether, matrix_0wl, merlin, nemveer, ni8mare, nonseodion, oakcobalt, owadez, p_crypt0, pipidu83, piyushshukla, popular00, ppetrov, rjs, sandy, sl1, supervrijdag, tay054, thekmj, wahedtalash77, windhustler, zhaojie
9.8204 USDC - $9.82
governance
cannot be changedIn both LendingLedger
and GaugeController
there is a privileged role, governance
, that can perform certain functions. This role is written upon construction. The issue is that it cannot be changed. There are plenty of reasons this could be needed, the project might want to move their governance to a new multisig/timelock or from a EOA to multisig or from a multisig to a timelock.
Consider adding a way to change governance
, preferably a two step process.
https://github.com/code-423n4/2023-08-verwa/blob/main/src/GaugeController.sol#L215-L220
File: GaugeController.sol 215: ( 216: , 217: /*int128 bias*/ 218: int128 slope_, /*uint256 ts*/ 219: 220: ) = ve.getLastUserPoint(msg.sender);
This is weirdly formatted which makes it hard to read, consider condensing it and either move the comments to where the variables would have been or remove them:
( , int128 slope_, ) = ve.getLastUserPoint(msg.sender);
or
VotingEscrow ve = votingEscrow; ( /*int128 bias*/, int128 slope_, /*uint256 ts*/ ) = ve.getLastUserPoint(msg.sender);
This makes it easier to read.
In VotingEscrow.sol
there are some natspec comments that lack an extra /
with makes them not highight properly:
File: VotingEscrow.sol 267: // See IVotingEscrow for documentation 286: // See IVotingEscrow for documentation 287: // @dev A lock is active until both lock.amount==0 and lock.end<=block.timestamp 422. // @dev Floors a timestamp to the nearest weekly increment 423: // @param _t Timestamp to floor 428: // @dev Uses binarysearch to find the most recent point history preceeding block 429: // @param _block Find the most recent point history before this block 430: // @param _maxEpoch Do not search pointHistories past this index 448: // @dev Uses binarysearch to find the most recent user point history preceeding block 449: // @param _addr User for which to search 450: // @param _block Find the most recent point history before this block
Consider adding an extra /
to add proper highlighting to the natspec.
#0 - 141345
2023-08-13T14:38:16Z
#1 - c4-judge
2023-08-22T13:56:52Z
alcueca marked the issue as grade-a