Liquid Staking

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

StakingPool::_updateStrategyRewards is unable to distribute rewards for all receivers if one strategy fee receiver reverts

Summary

StakingPool::_updateStrategyRewards is unable to distribute rewards for all receivers if one strategy fee receiver reverts.

Vulnerability Details

This is because StakingPool::_updateStrategyRewards utilizes StakingPool::transferAndCallFrom to distribute fee amounts:

function _updateStrategyRewards(uint256[] memory _strategyIdxs, bytes memory _data) private {
// ... snippet: fee and rewards calculation
// fee distribution
if (totalFeeAmounts > 0) {
uint256 sharesToMint = (totalFeeAmounts * totalShares) /
(totalStaked - totalFeeAmounts);
_mintShares(address(this), sharesToMint);
uint256 feesPaidCount;
for (uint256 i = 0; i < receivers.length; i++) {
for (uint256 j = 0; j < receivers[i].length; j++) {
if (feesPaidCount == totalFeeCount - 1) {
=>[1] transferAndCallFrom(
address(this),
receivers[i][j],
balanceOf(address(this)),
"0x"
);
} else {
=>[2] transferAndCallFrom(address(this), receivers[i][j], feeAmounts[i][j], "0x");
feesPaidCount++;
}
}
}
}

StakingPool::transferAndCallFrom:

function transferAndCallFrom(
address _sender,
=> address _to, // receiver
uint256 _value,
bytes memory _data
) internal returns (bool) {
_transfer(_sender, _to, _value);
if (isContract(_to)) {
contractFallback(_sender, _to, _value, _data);
}
return true;
}

StakingPool::transferAndCallFrom internally calls StakingPool::contractFallback (inherited from contracts/core/tokens/base/ERC677Upgradeable.sol) if the receiver is a contract

function contractFallback(
address _sender,
=> address _to, // receiver
uint256 _value,
bytes memory _data
) internal {
IERC677Receiver receiver = IERC677Receiver(_to);
=>[1] receiver.onTokenTransfer(_sender, _value, _data);
}

In [1] StakingPool will call receiver::onTokenTransfer, so if receiver onTokenTransfer reverts, or if receiver is a contract that doesnt implement onTokenTransfer and doesnt have a fallback function then it will revert too.
This will cause all strategy rewards distribution to fail for all receivers

The following PoC shows the described issue with the following scenario:
StakingPool set as a fee receiver a contract with a fallback that reverts
So when updateStrategyRewards is called all fee distribution process fails

Create contract file DoSCt.sol in contracts/core folder with following content

// SPDX-License-Identifier: GPL-3.0
pragma solidity 0.8.15;
contract DoSCt {
constructor() {
}
fallback() external payable{
revert();
}
}
it('[R] SPool unable to distribute rewards if one receiver reverts', async () => {
const { signers, accounts, adrs, token, stakingPool, strategy, sdlPool, pp, withdrawalPool } = await loadFixture(
deployFixture
)
await strategy.setMaxDeposits(toEther(100));
await stakingPool.setUnusedDepositLimit(toEther(20));
await strategy.setFeeBasisPoints(1000)
console.log("[i] Deploying DoSCt: a contract with fallback that reverts");
console.log("[i] Setting accounts[0] and DoSCt as fee receivers, 5% each");
const dosct = (await deploy('DoSCt'));
const dosct_addr = await dosct.getAddress();
await stakingPool.addFee(accounts[0], 500)
await stakingPool.addFee(dosct.getAddress(), 500)
console.log("[i] Staking token with accounts 1,2,3 , amount 10 **18 each ");
//await stake(1, 10)
await pp.connect(signers[1]).deposit(
toEther(10),
true,
['0x', '0x', '0x']
)
//await stake(2, 10)
await pp.connect(signers[2]).deposit(
toEther(10),
true,
['0x', '0x', '0x']
)
//await stake(3, 10)
await pp.connect(signers[3]).deposit(
toEther(10),
true,
['0x', '0x', '0x']
)
console.log("[i] Depositing 10 eth to strategy to simulate rewards");
await token.transfer(strategy.getAddress(), toEther(50))
console.log("\n=== Balances before updateStrategyRewards ===");
console.log(
"token.balanceOf(accounts[0]) ",
await token.balanceOf(accounts[0]),
"\nstToken.balanceOf(accounts[0]) ",
await stakingPool.balanceOf(accounts[0])
);
console.log(
"\ntoken.balanceOf(dosct_addr) ",
await token.balanceOf(dosct_addr),
"\nstToken.balanceOf(dosct_addr) ",
await stakingPool.balanceOf(dosct_addr)
);
console.log("\n[i] Calling updateStrategyRewards: will revert cause DoSCt will revert on its fallback function");
await expect(stakingPool.updateStrategyRewards([0], '0x')).to.be.reverted
console.log("\n === Balances after updateStrategyRewards ===");
console.log(
"token.balanceOf(accounts[0]) ",
await token.balanceOf(accounts[0]),
"\nstToken.balanceOf(accounts[0]) ",
await stakingPool.balanceOf(accounts[0])
);
console.log(
"\ntoken.balanceOf(dosct_addr) ",
await token.balanceOf(dosct_addr),
"\nstToken.balanceOf(dosct_addr) ",
await stakingPool.balanceOf(dosct_addr)
);
})

Impact

High, if one fee receiver reverts then no fee receiver can obtain his rewards

Tools Used

Manual Review

Recommendations

try catch cant be used because transferAndCallFrom is an internal function and try catch only work on external functions
However data used as a param in StakingPool::_updateStrategyRewards transferAndCallFrom is always '0x' so, its calling the receiver with an empty string as data, ie, there is no need to use transferAndCallFrom, just use transfer function

function _updateStrategyRewards(uint256[] memory _strategyIdxs, bytes memory _data) private {
// ... snippet...
if (totalFeeAmounts > 0) {
for (uint256 i = 0; i < receivers.length; i++) {
for (uint256 j = 0; j < receivers[i].length; j++) {
if (feesPaidCount == totalFeeCount - 1) {
(bool success) = transfer(receivers[i][j],balanceOf(address(this));
require(sucess, "couldnt transfer to receiver");
} else {
(bool success) = transfer(receivers[i][j],feeAmounts[i][j]);
require(sucess, "couldnt transfer to receiver");
}
}
}
// ... snippet...
}
}
Updates

Lead Judging Commences

inallhonesty Lead Judge 10 months ago
Submission Judgement Published
Invalidated
Reason: Non-acceptable severity

Appeal created

0xw3 Submitter
10 months ago
inallhonesty Lead Judge 10 months ago
Submission Judgement Published
Validated
Assigned finding tags:

Inability to independently update Fee receivers without sending fees to old receivers

Support

FAQs

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