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

`((n * num_weeks) + n) Earnings` After claiming Staking Rewards, users can withdraw or steal their staked LoveToken balance, severely breaking the `Soulmate` Protocol and its funds.

Summary

The Staking::withdraw function allows users to withdraw (essentially steal) their previously staked LoveToken(s) even after claiming staking rewards. Both functions Staking::withdraw and Staking::claimRewards are vulnerable to funds theft.

function withdraw(uint256 amount) public {
// No require needed because of overflow protection
userStakes[msg.sender] -= amount;
@> // allowing to withdraw with checking claim rewards.
@> loveToken.transfer(msg.sender, amount);
emit Withdrew(msg.sender, amount);
}
/// @notice Claim rewards for staking.
/// @notice Users can claim 1 token per staking token per week.
function claimRewards() public {
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
@> // user Staked amount used but never updated.
@> uint256 amountToClaim = userStakes[msg.sender] * timeInWeeksSinceLastClaim;
loveToken.transferFrom(address(stakingVault), msg.sender, amountToClaim);
emit RewardsClaimed(msg.sender, amountToClaim);
}

Vulnerability Details

(n * num_weeks) + n, Earnings
  1. Place the following test code snippet into the test/unit/soulmateTest.t.sol file. Put it at the very bottom but before the last closing semicolon }.

function test_withdrawAfterStaking() public {
Vault airdropVault_tst = new Vault();
Vault stakingVault_tst = new Vault();
Soulmate soulmateContract_tst = new Soulmate();
LoveToken loveToken_tst = new LoveToken(
ISoulmate(address(soulmateContract_tst)), address(airdropVault_tst), address(stakingVault_tst)
);
Airdrop airdropContract_tst = new Airdrop(
ILoveToken(address(loveToken_tst)),
ISoulmate(address(soulmateContract_tst)),
IVault(address(airdropVault_tst))
);
airdropVault_tst.initVault(ILoveToken(address(loveToken_tst)), address(airdropContract_tst));
Staking stakingContract_tst = new Staking(
ILoveToken(address(loveToken_tst)),
ISoulmate(address(soulmateContract_tst)),
IVault(address(stakingVault_tst))
);
stakingVault_tst.initVault(ILoveToken(address(loveToken_tst)), address(stakingContract_tst));
uint256 totalSupply_one = loveToken_tst.totalSupply();
uint256 airdropVaultBalance = loveToken_tst.balanceOf(address(airdropVault_tst));
uint256 airdropAsManagerSpendAllowance =
loveToken_tst.allowance(address(airdropVault_tst), address(airdropContract_tst));
uint256 stakingVaultBalance = loveToken_tst.balanceOf(address(stakingVault_tst));
uint256 stakingAsManagerSpendAllowance =
loveToken_tst.allowance(address(stakingVault_tst), address(stakingContract_tst));
console2.log("total supply : ", totalSupply_one);
console2.log("airdropVaultBalance : ", airdropVaultBalance);
console2.log("airdropAsManagerSpendAllowance : ", airdropAsManagerSpendAllowance);
console2.log("stakingVaultBalance : ", stakingVaultBalance);
console2.log("stakingAsManagerSpendAllowance : ", stakingAsManagerSpendAllowance);
console2.log("------------------------------------------------------------------------------");
address alice = makeAddr("ALICE");
address bob = makeAddr("BOB");
vm.startPrank(alice);
soulmateContract_tst.mintSoulmateToken();
vm.warp(1 days + 1);
airdropContract_tst.claim();
vm.stopPrank();
uint256 aliceLoveTokenBalanceBeforeStaking = loveToken_tst.balanceOf(alice);
vm.startPrank(alice);
loveToken_tst.approve(address(stakingContract_tst), aliceLoveTokenBalanceBeforeStaking);
uint256 stakingContractBalanceBD = loveToken_tst.balanceOf(address(stakingContract_tst));
uint256 stakingAsStakeProviderSpendAllowanceFromAliceBD =
loveToken_tst.allowance(alice, address(stakingContract_tst));
stakingContract_tst.deposit(aliceLoveTokenBalanceBeforeStaking);
vm.stopPrank();
uint256 aliceStakedLoveTokenBalance = stakingContract_tst.userStakes(alice);
uint256 aliceLoveTokenBalanceAfterStaking = loveToken_tst.balanceOf(alice);
uint256 totalSupplyUpdated = loveToken_tst.totalSupply();
uint256 airdropVaultBalanceUpdated = loveToken_tst.balanceOf(address(airdropVault_tst));
uint256 airdropAsManagerSpendAllowanceUpdated =
loveToken_tst.allowance(address(airdropVault_tst), address(airdropContract_tst));
uint256 stakingVaultBalanceUpdated = loveToken_tst.balanceOf(address(stakingVault_tst));
uint256 stakingAsManagerSpendAllowanceUpdated =
loveToken_tst.allowance(address(stakingVault_tst), address(stakingContract_tst));
uint256 stakingAsStakeProviderSpendAllowanceFromAliceAD =
loveToken_tst.allowance(alice, address(stakingContract_tst));
uint256 stakingContractBalanceAD = loveToken_tst.balanceOf(address(stakingContract_tst));
console2.log("total supply : ", totalSupplyUpdated);
console2.log("airdropVaultBalanceUpdated : ", airdropVaultBalanceUpdated);
console2.log("airdropAsManagerSpendAllowanceUpdated : ", airdropAsManagerSpendAllowanceUpdated);
console2.log("stakingVaultBalanceUpdated : ", stakingVaultBalanceUpdated);
console2.log("stakingAsManagerSpendAllowanceUpdated : ", stakingAsManagerSpendAllowanceUpdated);
console2.log("aliceLoveTokenBalanceBeforeStaking : ", aliceLoveTokenBalanceBeforeStaking);
console2.log("aliceStakedLoveTokenBalance : ", aliceStakedLoveTokenBalance);
console2.log("aliceLoveTokenBalanceAfterStaking : ", aliceLoveTokenBalanceAfterStaking);
console2.log("stakingContractBalanceBD : ", stakingContractBalanceBD);
console2.log("stakingContractBalanceAD : ", stakingContractBalanceAD);
console2.log(
"stakingAsStakeProviderSpendAllowanceFromAliceBD: ", stakingAsStakeProviderSpendAllowanceFromAliceBD
);
console2.log(
"stakingAsStakeProviderSpendAllowanceFromAliceAD: ", stakingAsStakeProviderSpendAllowanceFromAliceAD
);
console2.log("------------------------------------------------------------------------------");
// 👇 is the proof for ilegit withdrawal after reward claim.
vm.warp(block.timestamp + 2 weeks);
vm.startPrank(alice);
stakingContract_tst.claimRewards();
stakingContract_tst.withdraw(1e18);
vm.stopPrank();
uint256 totalSupplyUpdatedNew = loveToken_tst.totalSupply();
uint256 airdropVaultBalanceUpdatedNew = loveToken_tst.balanceOf(address(airdropVault_tst));
uint256 airdropAsManagerSpendAllowanceUpdatedNew =
loveToken_tst.allowance(address(airdropVault_tst), address(airdropContract_tst));
uint256 stakingVaultBalanceUpdatedAS = loveToken_tst.balanceOf(address(stakingVault_tst));
uint256 stakingAsManagerSpendAllowanceUpdatedAS =
loveToken_tst.allowance(address(stakingVault_tst), address(stakingContract_tst));
uint256 aliceBalanceAfterStakingClaim = loveToken_tst.balanceOf(alice);
uint256 stakingContractBalanceNew = loveToken_tst.balanceOf(address(stakingContract_tst));
console2.log("total supply : ", totalSupplyUpdatedNew);
console2.log("airdropVaultBalanceUpdatedNew : ", airdropVaultBalanceUpdatedNew);
console2.log("airdropAsManagerSpendAllowanceUpdatedNew : ", airdropAsManagerSpendAllowanceUpdatedNew);
console2.log("stakingVaultBalanceUpdatedAS : ", stakingVaultBalanceUpdatedAS);
console2.log("stakingAsManagerSpendAllowanceUpdatedAS : ", stakingAsManagerSpendAllowanceUpdatedAS);
console2.log("stakingContractBalanceNew : ", stakingContractBalanceNew);
console2.log("aliceBalanceAfterStakingClaim : ", aliceBalanceAfterStakingClaim);
}
  1. Open Your Bash Terminal and execute the following command...

forge test --mt "test_withdrawAfterStaking" -vvv --via-ir
  1. Please read the Output that might be appeared on your bash terminal. Output should indicate that test Passed Successfully and the user alice successfully stolen/withdrew her staked LoveToken(s) after claiming staking rewards.

Impact

  • Potentially Breaking the Soulmate Protocol.

  • Allows users to proportionally steal all the Staking Revenue Collected.

  • Makes Protocol: TotalSupply < AirDropVault_funds + StakingVault_funds.

  • Allows users to earn (n * number_of_weeks_staked) + n LoveTokens, Which is wrong. Protocol expect to allow users to earn only n * number_of_weeks_staked.

Tools Used

Foundry Framework (Solidity, Rust)

Recommendations

  1. "Add a claims mapping to track whether a user has claimed their staked LoveToken rewards. Then, include an if statement with a require check in the Staking::withdraw function to verify if a user has claimed their last staking rewards. If yes, revert the transaction. Additionally, whenever a user stakes again, update the user's claiming information to false in the claims mapping."

  2. It's quite simple, as the Staking::withdraw function already has overflow protection, there's no need to worry. The mitigation is straightforward: update the Staking::userStakes mapping whenever a user claims their staking rewards by executing the Staking::claimRewards function. One caveat here is not to delete a user's records from the Staking::userStakes mapping because a user may stake more LoveTokens later, so it would not be advisable to delete the user's records from Staking::userStakes.

Update The Staking::claimRewars function like below...

...
...
...
function claimRewards() public {
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;
+ userStakes[msg.sender] = 0;
loveToken.transferFrom(address(stakingVault), msg.sender, amountToClaim);
emit RewardsClaimed(msg.sender, amountToClaim);
}
...
...
...
Updates

Lead Judging Commences

0xnevi Lead Judge over 1 year ago
Submission Judgement Published
Invalidated
Reason: Other
theirrationalone Submitter
over 1 year ago
theirrationalone Submitter
over 1 year ago
0xnevi Lead Judge
over 1 year ago
0xnevi Lead Judge over 1 year ago
Submission Judgement Published
Invalidated
Reason: Other

Support

FAQs

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