Liquid Staking

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

OperatorStakingPool::withdraw fails to transfer tokens

Summary

The OperatorStakingPool contract is intended to manage the stakes of node operators, but the withdraw function updates internal balances without transferring the corresponding tokens to the operator, which results to in the operator's tokens being locked in the contract.

Vulnerability Details

The Operator Staking Pool allows node operators to stake their LSTs.

The withdraw function is supposed to handle the transfer of these tokens back to the operator upon withdrawal. However, it only updates the internal balances of the contract without initiating a token transfer, leaving the operator's tokens locked inside the contract.

This issue arises because the transferAndCall function, which should be responsible for transferring tokens, is missing from the withdrawal process. As a result, while the contract's internal accounting shows a withdrawal, the tokens remain within the contract.

Impact

Deposited funds by operators are lost in the contract.

PoC

The following PoC can be copy-pasted in the operator-staking-pool.test.ts file and run using npx hardhat test --grep "OperatorStakingPool withdraw function fails to transfer tokens"

it('OperatorStakingPool withdraw function fails to transfer tokens', async () => {
const { signers, accounts, opPool, lst } = await loadFixture(deployFixture)
const signer1InitialBalance = fromEther(await lst.balanceOf(signers[1]))
// signer1 starts with 10_000 ether worth of lst and deposits all of it
assert.equal(signer1InitialBalance, 10000)
await lst.connect(signers[1]).transferAndCall(opPool.target, toEther(signer1InitialBalance), '0x')
// The pool receives the deposit accordingly
assert.equal(fromEther(await lst.balanceOf(signers[1])), 0)
assert.equal(fromEther(await lst.balanceOf(opPool.target)), 10000)
assert.equal(fromEther(await opPool.getOperatorPrincipal(accounts[1])), 10000)
assert.equal(fromEther(await opPool.getOperatorStaked(accounts[1])), 10000)
await opPool.connect(signers[1]).withdraw(toEther(signer1InitialBalance))
// After trying to withdraw, signer1 has 0 lst in its balance
assert.equal(fromEther(await lst.balanceOf(signers[1])), 0)
// and the pool (which kept its lst) has 10_000 ether worth of lst
assert.equal(fromEther(await lst.balanceOf(opPool.target)), 10000)
// even though the pool thinks the signer1 has 0 lst in it
assert.equal(fromEther(await opPool.getOperatorPrincipal(accounts[1])), 0)
assert.equal(fromEther(await opPool.getOperatorStaked(accounts[1])), 0)
})

Tools Used

Manual review.

Recommendations

To resolve this issue, ensure the tokens are transferred to the operator using the transferAndCall function :

function _withdraw(address _operator, uint256 _amount) private {
uint256 sharesAmount = lst.getSharesByStake(_amount);
shareBalances[_operator] -= sharesAmount;
totalShares -= sharesAmount;
+ lst.transferAndCall(_operator, _amount, "");
emit Withdraw(_operator, _amount, sharesAmount);
}

Also, tests regarding funds, like withdraw should work correctly, can be updated to test both the actual possession of the tokens from the operators and their supposed balance in the contract.

Updates

Lead Judging Commences

inallhonesty Lead Judge about 1 year 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.