Summary
The internal function OperatorStakingPool::_withdraw
correctly updates the internal share balance during the withdrawal process. However, it fails to transfer the tokens back to the operator invoking the function.
Vulnerability Details
In https://github.com/Cyfrin/2024-09-stakelink/blob/main/contracts/linkStaking/OperatorStakingPool.sol#L116-L126, the onTokenTransfer
function enables an operator to deposit liquid staking tokens into the OperatorStakingPool
contract.
function onTokenTransfer(address _sender, uint256 _value, bytes calldata) external {
if (msg.sender != address(lst)) revert InvalidToken();
if (!isOperator(_sender)) revert SenderNotAuthorized();
if (getOperatorStaked(_sender) + _value > depositLimit) revert ExceedsDepositLimit();
uint256 sharesAmount = lst.getSharesByStake(_value);
shareBalances[_sender] += sharesAmount;
totalShares += sharesAmount;
emit Deposit(_sender, _value, sharesAmount);
}
In https://github.com/Cyfrin/2024-09-stakelink/blob/main/contracts/linkStaking/OperatorStakingPool.sol#L199-L205, the _withdraw
function accurately updates the balances; however, it does not send the liquid staking tokens back to the operator.
function _withdraw(address _operator, uint256 _amount) private {
uint256 sharesAmount = lst.getSharesByStake(_amount);
shareBalances[_operator] -= sharesAmount;
totalShares -= sharesAmount;
@> Function never transfers the tokens to the caller
emit Withdraw(_operator, _amount, sharesAmount);
}
PoC
Add the following test to /test/linkStaking/operator-staking-pool.test.ts
:
In this test, accounts[0]
first deposits 1000 ETH into the OperatorStakingPool
. Immediately after, the same account calls the withdraw
function. However, the final balance is 1000 ETH less than expected, indicating an issue. Additionally, the OperatorStakingPool
balance has increased by 1000 ETH, further highlighting the discrepancy.
it.only('does NOT transfer on withdraw', async () => {
const { signers, accounts, opPool, lst } = await loadFixture(deployFixture)
const accountZeroBalanceBefore = await lst.balanceOf(accounts[0])
console.log({ bbzero: fromEther(accountZeroBalanceBefore) })
const opPoolBalanceBefore = await lst.balanceOf(opPool)
console.log({ opb: fromEther(opPoolBalanceBefore) })
await lst.transferAndCall(opPool.target, toEther(1000), '0x')
const opPoolBalanceMiddle = await lst.balanceOf(opPool)
console.log({ opm: fromEther(opPoolBalanceMiddle) })
await opPool.withdraw(toEther(1000))
const accountZeroBalanceAfter = await lst.balanceOf(accounts[0])
console.log({ bazero: fromEther(accountZeroBalanceAfter) })
const opPoolBalanceAfter = await lst.balanceOf(opPool)
console.log({ opa: fromEther(opPoolBalanceAfter) })
assert.equal(fromEther(accountZeroBalanceBefore - accountZeroBalanceAfter), 1000)
assert.equal(fromEther(opPoolBalanceAfter - opPoolBalanceBefore), 1000)
})
Impact
Operators are unable to unstake (withdraw) tokens from the OperatorStakingPool
, resulting in the tokens being locked within the contract.
Tools Used
Manual review
Recommendations
Perform the actual token transfer after the internal account balance updates.
function _withdraw(address _operator, uint256 _amount) private {
uint256 sharesAmount = lst.getSharesByStake(_amount);
shareBalances[_operator] -= sharesAmount;
totalShares -= sharesAmount;
+ lst.safeTransfer(_operator, _amount);
emit Withdraw(_operator, _amount, sharesAmount);
}