Open code423n4 opened 2 years ago
dmvt marked the issue as primary issue
vince0656 marked the issue as sponsor confirmed
dmvt marked the issue as satisfactory
dmvt marked the issue as selected for report
I dont konw if its valide to raise a question after sponsor confimed. I have found the same issue during the contest. But I finally denied myself when I wrote a test for it. The point is the giantFeesAndMevPool will send the rewards when the user deposit eth to it at the second time and after that, the accumulatedETHPerLPShare will be updated. So there is no losses.
I modified my test file at the time according to the instructions for this issue, hoping to help the judge. test/foundry/CanIClaim.t.sol
pragma solidity ^0.8.13;
// SPDX-License-Identifier: MIT
import {GiantPoolTests} from "./GiantPools.t.sol";
import "forge-std/console.sol";
contract CanIClaimTest is GiantPoolTests {
function test_canIclaim() public{
// 0. let accumulatedETHPerLPShare value = 2
address feesAndMevUserZero = accountOne; vm.deal(feesAndMevUserZero, 100 ether);
vm.startPrank(feesAndMevUserZero);
giantFeesAndMevPool.depositETH{value: 1 ether}(1 ether);
vm.stopPrank();
address(giantFeesAndMevPool).call{value: 2 ether}("");
giantFeesAndMevPool.updateAccumulatedETHPerLP();
console.log("0-accumulatedETHPerLPShare", giantFeesAndMevPool.accumulatedETHPerLPShare() / giantFeesAndMevPool.PRECISION());
// 1- user1 deposit 10 ETH to giant pool and accumulatedETHPerLPShare value is 2 and claimed[user1][lpTokenETH] would be 10 * 2 = 20.
uint user1_origin_balance = 100 ether;
address feesAndMevUserOne = accountTwo; vm.deal(feesAndMevUserOne, user1_origin_balance);
vm.startPrank(feesAndMevUserOne);
giantFeesAndMevPool.depositETH{value: 10 ether}(10 ether);
vm.stopPrank();
uint256 user_claimed = giantFeesAndMevPool.claimed(feesAndMevUserOne, address(giantFeesAndMevPool.lpTokenETH()));
console.log("1-user1 claimed",user_claimed / 1e18);
// 2- some time passes and accumulatedETHPerLPShare set to 4 and user1 has 10 * 4 - 20 = 20 unclaimed ETH rewards (the formula in the code: balance * rewardPerShare - claimed).
address(giantFeesAndMevPool).call{value: 22 ether}("");
giantFeesAndMevPool.updateAccumulatedETHPerLP();
console.log("2-accumulatedETHPerLPShare", giantFeesAndMevPool.accumulatedETHPerLPShare() / giantFeesAndMevPool.PRECISION());
// 3- user deposit 5 ETH to giant pool and accumulatedETHPerLPShare is 4 so the code would call _onDepositETH() which calls _setClaimedToMax which sets claimed[user1][lpTokenETH] to 15 * 4 = 60.
vm.startPrank(feesAndMevUserOne);
giantFeesAndMevPool.depositETH{value: 5 ether}(5 ether);
vm.stopPrank();
// here you can see user1 has already receive the rewards when he depositETH at the second time.
console.log("3-user1 balance", feesAndMevUserOne.balance);
user_claimed = giantFeesAndMevPool.claimed(feesAndMevUserOne, address(giantFeesAndMevPool.lpTokenETH()));
console.log("3-user1 claimed",user_claimed / 1e18);
// !!! note here !!!
// 4- assert
console.log("now user1 balance", feesAndMevUserOne.balance);
assertEq(user1_origin_balance - 10 ether - 5 ether + (22 ether * 10 / 11), feesAndMevUserOne.balance);
}
}
run test
forge test --match-test test_canIclaim -vvv
Result
[PASS] test_canIclaim() (gas: 379033)
Logs:
0-accumulatedETHPerLPShare 2
1-user1 claimed 20
2-accumulatedETHPerLPShare 4
3-user1 balance 105000000000000000000
3-user1 claimed 60
now user1 balance 105000000000000000000
it indeed was true:
/// @dev On depositing on ETH set claimed to max claim so the new depositor cannot claim ETH that they have not accrued
function _onDepositETH() internal override {
_setClaimedToMax(msg.sender);
}
/// @dev Internal re-usable method for setting claimed to max for msg.sender
function _setClaimedToMax(address _user) internal {
// New ETH stakers are not entitled to ETH earned by
claimed[_user][address(lpTokenETH)] = (accumulatedETHPerLPShare * lpTokenETH.balanceOf(_user)) / PRECISION;
}
_setClaimedToMax(msg.sender);
was being called without distributing potential rewards first
console.log("1-user1 claimed",user_claimed / 1e18);
this really isn't super clear to me - they have to deposit multiple times? still sounds like a bug
it indeed was true:
/// @dev On depositing on ETH set claimed to max claim so the new depositor cannot claim ETH that they have not accrued function _onDepositETH() internal override { _setClaimedToMax(msg.sender); } /// @dev Internal re-usable method for setting claimed to max for msg.sender function _setClaimedToMax(address _user) internal { // New ETH stakers are not entitled to ETH earned by claimed[_user][address(lpTokenETH)] = (accumulatedETHPerLPShare * lpTokenETH.balanceOf(_user)) / PRECISION; }
_setClaimedToMax(msg.sender);
was being called without distributing potential rewards first
It will call the GiantLP.mint before _setClaimedToMax. And the GiantLP override the beforeTokenTransfer function as the GiantMevAndFeesPool.beforeTokenTransfer. As you can see, the rewards will be distributed in the function before _setClaimedToMax:
function beforeTokenTransfer(address _from, address _to, uint256) external {
require(msg.sender == address(lpTokenETH), "Caller is not giant LP");
updateAccumulatedETHPerLP();
// Make sure that `_from` gets total accrued before transfer as post transferred anything owed will be wiped
if (_from != address(0)) {
_distributeETHRewardsToUserForToken(
_from,
address(lpTokenETH),
lpTokenETH.balanceOf(_from),
_from
);
}
// Make sure that `_to` gets total accrued before transfer as post transferred anything owed will be wiped
_distributeETHRewardsToUserForToken(
_to,
address(lpTokenETH),
lpTokenETH.balanceOf(_to),
_to
);
}
console.log("1-user1 claimed",user_claimed / 1e18);
this really isn't super clear to me - they have to deposit multiple times? still sounds like a bug
As described in the issue, user1 deposited twice. I don't understand your question about the console.log... 1-user1 claimed
is claimed[user1][lpTokenETH]
.
Ok. I'm going to leave this one in place. Thank you for the additional information @5z1punch. The warden and sponsor agree that the bug is still there regardless of the test you've provided.
Function is working as intended! lpTokenETH.mint(msg.sender, msg.value) -> beforeTokenTransfer -> _distributeETHRewardsToUserForToken -> update claimed and distributes rewards. Finding is invalid. @GalloDaSballo
See my response in the post-judging qa discussion.
Lines of code
https://github.com/code-423n4/2022-11-stakehouse/blob/4b6828e9c807f2f7c569e6d721ca1289f7cf7112/contracts/liquid-staking/GiantMevAndFeesPool.sol#L195-L204 https://github.com/code-423n4/2022-11-stakehouse/blob/4b6828e9c807f2f7c569e6d721ca1289f7cf7112/contracts/liquid-staking/GiantPoolBase.sol#L33-L48
Vulnerability details
Impact
When
depositETH()
is called in giant pool it calls_onDepositETH()
which calls_setClaimedToMax()
to make sure new ETH stakers are not entitled to ETH earned by but this can cause users to lose their remaining rewards when they deposits. code should first transfer user remaining rewards when deposit happens.Proof of Concept
This is
depositETH()
code inGiantPoolBase
:As you can see it increase user
lpTokenETH
balance and then calls_onDepositETH()
. This is_onDepositETH()
and_setClaimedToMax()
code inGiantMevAndFeesPool
contract:As you can see the code set
claimed[msg.sender][address(lpTokenETH]
to maximum value so the user wouldn't be entitled to previous rewards but if user had some remaining rewards in contract he would lose those rewards can't withdraw them. these are the steps: 1-user1
deposit10
ETH to giant pool andaccumulatedETHPerLPShare
value is2
andclaimed[user1][lpTokenETH]
would be10 * 2 = 20
. 2- some time passes andaccumulatedETHPerLPShare
set to4
anduser1
has10 * 4 - 20 = 20
unclaimed ETH rewards (the formula in the code:balance * rewardPerShare - claimed
). 3-user
deposit5
ETH to giant pool andaccumulatedETHPerLPShare
is4
so the code would call_onDepositETH()
which calls_setClaimedToMax
which setsclaimed[user1][lpTokenETH]
to15 * 4 = 60
. 4-user1
new remaining ETH reward would be15 * 4 - 60 = 0
. anduser1
won't receive his rewards because when he deposits contract don't transfer remaining rewards and set claim to max so user loses his funds.Tools Used
VIM
Recommended Mitigation Steps
when deposit happens contract should first send remaining rewards, then increase the user's balance and then set the user claim to max.