Liquid Staking

Stakelink
DeFiHardhatOracle
50,000 USDC
View results
Submission Details
Severity: medium
Invalid

Inaccurate Withdrawal Due to Missing Rewards Update

Summary

An issue in the VaultDepositController::withdraw function allows for rewards to be left untransferred if the StakingPool::updateStrategyRewards function has not been called prior to withdrawal. This can result in users receiving less than they are entitled to, leading to potential financial loss and missed rewards.

Vulnerability Details

When VaultDepositController::withdraw is triggered, tokens are withdrawn from the vault. However, there is no guarantee that the StakingPool::updateStrategyRewards function has been called prior to this withdrawal. As a result, rewards that have accrued but not yet been transferred may remain unaccounted for, leading to discrepancies in the vault balance.

This discrepancy occurs because Vault::getPrincipalDeposits is used instead of Vault::getTotalDeposits, which could cause the vault's balance to appear smaller than it actually is.

function withdraw(uint256 _amount, bytes calldata _data) external {
if (!fundFlowController.claimPeriodActive() || _amount > totalUnbonded)
revert InsufficientTokensUnbonded();
GlobalVaultState memory globalState = globalVaultState;
uint64[] memory vaultIds = abi.decode(_data, (uint64[]));
VaultGroup memory group = vaultGroups[globalState.curUnbondedVaultGroup];
// withdrawals must continue with the vault they left off at during the previous call
if (vaultIds[0] != group.withdrawalIndex) revert InvalidVaultIds();
uint256 toWithdraw = _amount;
uint256 unbondedRemaining = totalUnbonded;
(uint256 minDeposits, ) = getVaultDepositLimits();
for (uint256 i = 0; i < vaultIds.length; ++i) {
// vault must be a member of the current unbonded group
if (vaultIds[i] % globalState.numVaultGroups != globalState.curUnbondedVaultGroup)
revert InvalidVaultIds();
group.withdrawalIndex = uint64(vaultIds[i]);
IVault vault = vaults[vaultIds[i]];
@> uint256 deposits = vault.getPrincipalDeposits();
if (deposits != 0 && vault.claimPeriodActive() && !vault.isRemoved()) {
if (toWithdraw > deposits) {
vault.withdraw(deposits);
unbondedRemaining -= deposits;
toWithdraw -= deposits;
} else if (deposits - toWithdraw > 0 && deposits - toWithdraw < minDeposits) {
// cannot leave a vault with less than minimum deposits
vault.withdraw(deposits);
unbondedRemaining -= deposits;
break;
} else {
vault.withdraw(toWithdraw);
unbondedRemaining -= toWithdraw;
break;
}
}
}
uint256 totalWithdrawn = totalUnbonded - unbondedRemaining;
token.safeTransfer(msg.sender, totalWithdrawn);
totalDeposits -= totalWithdrawn;
totalPrincipalDeposits -= totalWithdrawn;
totalUnbonded = unbondedRemaining;
group.totalDepositRoom += uint128(totalWithdrawn);
vaultGroups[globalVaultState.curUnbondedVaultGroup] = group;
}


Consequently, the system may transfer fewer tokens than intended, resulting in potential losses for users due to missed profits.


# PoC
In vault-controller-strategy.test.ts add the following test case

it.only('withdraw inaccurate amount', async () => {
const { adrs, strategy, token, stakingController, vaults, fundFlowController, rewardsController, vaultContracts } =
await loadFixture(deployFixture)
await strategy.deposit(toEther(1200), encodeVaults([]))
await fundFlowController.updateVaultGroups()
await time.increase(claimPeriod)
await fundFlowController.updateVaultGroups()
await time.increase(claimPeriod)
await fundFlowController.updateVaultGroups()
await time.increase(claimPeriod)
await fundFlowController.updateVaultGroups()
await time.increase(claimPeriod)
await fundFlowController.updateVaultGroups()
await expect(
strategy.withdraw(toEther(150), encodeVaults([5, 10]))
).to.be.revertedWithCustomError(strategy, 'WithdrawalFailed()')
await expect(
strategy.withdraw(toEther(150), encodeVaults([0, 1]))
).to.be.revertedWithCustomError(strategy, 'WithdrawalFailed()')
console.log(fromEther(await vaultContracts[5].getTotalDeposits()), " vault contract total deposit - INITIAL")
await rewardsController.setReward(vaults[5], toEther(20))
console.log(fromEther(await vaultContracts[5].getTotalDeposits()), " vault contract total deposit - UPDATED WITH REWARD")
await strategy.withdraw(toEther(300), encodeVaults([0, 5]))
assert.equal(fromEther(await token.balanceOf(adrs.stakingController)), 1000)
assert.equal(fromEther(await stakingController.getStakerPrincipal(vaults[0])), 0)
assert.equal(fromEther(await stakingController.getStakerPrincipal(vaults[5])), 0)
console.log(fromEther(await vaultContracts[5].getTotalDeposits()), " vault contract total deposit - AFTER WITHDRAWAL")
})

Impact

Exploiting this vulnerability can lead to financial losses and missed rewards for users, resulting in potential lost profits and reduced trust in the protocol's operations.

Tools Used

Manual Review

Recommendations

Use Vault::getTotalDeposits instead of Vault::getPrincipalDeposits

Updates

Lead Judging Commences

inallhonesty Lead Judge
9 months ago
inallhonesty Lead Judge 9 months ago
Submission Judgement Published
Invalidated
Reason: Incorrect statement

Support

FAQs

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