Liquid Staking

Stakelink
DeFiHardhatOracle
50,000 USDC
View results
Submission Details
Severity: high
Valid

No LSTs transfer on node operator withdrawals resulting in stuck funds and loss for node operators

Summary

Inside OperatorStakingPool, node operators are required to stake their LSTs. Total LST staked and node operators balances are tracked by state variables:

// stores the LST share balance for each operator
mapping(address => uint256) private shareBalances;
// total number of LST shares staked in this pool
uint256 private totalShares;

Upon withdrawing (OperatorStakingPool:_withdraw:L200-204) sharesBalances and totalShares are updated but no LST is transfered from OperatorStakingPool back to operator. This leaves OperatorStakingPool with stuck LST tokens and node operators can't withdraw their stake.

function withdraw(uint256 _amount) external {
if (!isOperator(msg.sender)) revert SenderNotAuthorized();
_withdraw(msg.sender, _amount);
}
function _withdraw(address _operator, uint256 _amount) private {
uint256 sharesAmount = lst.getSharesByStake(_amount);
@> shareBalances[_operator] -= sharesAmount;
@> totalShares -= sharesAmount;
emit Withdraw(_operator, _amount, sharesAmount);
}

Vulnerability Details

Vulnerable code: https://github.com/Cyfrin/2024-09-stakelink/blob/main/contracts/linkStaking/OperatorStakingPool.sol#L199

PoC

it('PoC:High:OperatorStakingPool.sol#L199-204=>No LSTs transfer on node operator withdrawals resulting in stuck funds', async () => {
const { signers, accounts, opPool, lst } = await loadFixture(deployFixture)
const operator = signers[0]
const operatorDepositAmount = toEther(1000)
// 1. ========= Deposit to operator staking pool =========
// take snapshot of operator balance before deposit
const operatorBalanceBeforeDeposit = await lst.balanceOf(operator.address)
// take snapshot of operator staking pool balance before deposit
const opStakingPoolBalanceBeforeDeposit = await lst.balanceOf(opPool.target)
// deposit to operator staking pool
await lst.connect(operator).transferAndCall(opPool.target, operatorDepositAmount, '0x')
// take snapshot of operator balance after deposit
const operatorBalanceAfterDeposit = await lst.balanceOf(operator.address)
// take snapshot of operator staking pool balance after deposit
const opStakingPoolBalanceAfterDeposit = await lst.balanceOf(opPool.target)
// make sure operator balance decreased by the deposit amount
assert.equal(operatorBalanceBeforeDeposit - operatorBalanceAfterDeposit, operatorDepositAmount)
// make sure operator staking pool balance increased by the deposit amount
assert.equal(opStakingPoolBalanceAfterDeposit, opStakingPoolBalanceBeforeDeposit + operatorDepositAmount)
// 2. ========= Withdraw from operator staking pool =========
// take snapshot of operator balance before withdraw
const operatorBalanceBeforeWithdraw = await lst.balanceOf(operator.address)
// take snapshot of operator staking pool balance before withdraw
const opStakingPoolBalanceBeforeWithdraw = await lst.balanceOf(opPool.target)
// withdraw from operator staking pool
await opPool.connect(operator).withdraw(operatorDepositAmount)
// take snapshot of operator balance after withdraw
const operatorBalanceAfterWithdraw = await lst.balanceOf(operator.address)
// take snapshot of operator staking pool balance after withdraw
const opStakingPoolBalanceAfterWithdraw = await lst.balanceOf(opPool.target)
// make sure operator principal is 0
assert.equal(fromEther(await opPool.getOperatorPrincipal(accounts[0])), 0)
// make sure operator staked is 0
assert.equal(fromEther(await opPool.getOperatorStaked(accounts[0])), 0)
// show that operator LST balance didn't change
assert.equal(operatorBalanceAfterWithdraw, operatorBalanceBeforeWithdraw)
// show that operator staking pool has the same balance as before the withdraw
assert.equal(opStakingPoolBalanceAfterWithdraw, opStakingPoolBalanceBeforeWithdraw)
})

Running Poc:

  1. Copy test to ./test/linkStaking/operator-staking-pool.test.ts

  2. Run tests with npx hardhat test ./test/linkStaking/operator-staking-pool.test.ts --network hardhat

Output:

OperatorStakingPool
✔ PoC:High:OperatorStakingPool.sol#L200-204=>No LSTs transfer on operator withdrawals resulting in stuck funds (1499ms)

Impact

Likelihood: High

This will happen on every call to withdraw function inside OperatorStakingPool.
Also when owner calls removeOperators function which will call underlying withdraw method if operator has some stake.

Impact: Medium

Operators won't be able to retrieve their staked LSTs, and the funds will be temporarily locked inside the OperatorStakingPool. One way to handle this issue in production would be for the owner to upgrade this implementation with a new one that withdraws all stuck funds. By reviewing past Withdraw events, the owner could redistribute the funds back to the operators.

Tools Used

Manual review, hardhat tests.

Recommendations

Inside _withdraw method after totalShares update, send amount of LSTs back to operator.

+ import "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol";
+ import "@openzeppelin/contracts/token/ERC20/IERC20.sol";
import "../core/interfaces/IStakingPool.sol";
contract OperatorStakingPool is Initializable, UUPSUpgradeable, OwnableUpgradeable {
using SafeERC20Upgradeable for IERC20Upgradeable;
+ using SafeERC20 for IERC20;
...
/**
* @notice Withdraws tokens
* @param _operator address of operator with withdraw for
* @param _amount amount to withdraw
**/
function _withdraw(address _operator, uint256 _amount) private {
uint256 sharesAmount = lst.getSharesByStake(_amount);
shareBalances[_operator] -= sharesAmount;
totalShares -= sharesAmount;
+ IERC20(address(lst)).safeTransfer(_operator, _amount);
emit Withdraw(_operator, _amount, sharesAmount);
}
Updates

Lead Judging Commences

inallhonesty Lead Judge 8 months ago
Submission Judgement Published
Validated
Assigned finding tags:

`OperatorStakingPool::_withdraw()` function doesn't transfer the tokens

Support

FAQs

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