The Standard

The Standard
DeFiHardhat
20,000 USDC
View results
Submission Details
Severity: medium
Invalid

Protocol doesn't work with fee-on-transfer tokens(like PAXG) which can lead to stuck rewards

Summary

The protocol does not correctly account for fee-on-transfer tokens even though PAXG is in scope and has a fee-on-transfer. You can see its implementation contract here and check the code on line 370-380.

Vulnerability Details

This can cause many problems, if not accounted for properly. Let's take a look at the runLiquidation function in LiquidationPoolManager:

function runLiquidation(uint256 _tokenId) external {
ISmartVaultManager manager = ISmartVaultManager(smartVaultManager);
manager.liquidateVault(_tokenId);
distributeFees();
ITokenManager.Token[] memory tokens = ITokenManager(manager.tokenManager()).getAcceptedTokens();
ILiquidationPoolManager.Asset[] memory assets = new ILiquidationPoolManager.Asset[](tokens.length);
uint256 ethBalance;
for (uint256 i = 0; i < tokens.length; i++) {
ITokenManager.Token memory token = tokens[i];
if (token.addr == address(0)) {
ethBalance = address(this).balance;
if (ethBalance > 0)
assets[i] = ILiquidationPoolManager.Asset(token, ethBalance);
} else {
IERC20 ierc20 = IERC20(token.addr);
uint256 erc20balance = ierc20.balanceOf(address(this));
if (erc20balance > 0) {
assets[i] = ILiquidationPoolManager.Asset(token, erc20balance);
ierc20.approve(pool, erc20balance);
}
}
}
LiquidationPool(pool).distributeAssets{value: ethBalance}(assets, manager.collateralRate(), manager.HUNDRED_PC()); <--
forwardRemainingRewards(tokens);
}

The contract is giving approvals to the LiquidationPool and then calling its distributeAssets function:

function distributeAssets(ILiquidationPoolManager.Asset[] memory _assets, uint256 _collateralRate, uint256 _hundredPC) external payable {
consolidatePendingStakes();
(,int256 priceEurUsd,,,) = Chainlink.AggregatorV3Interface(eurUsd).latestRoundData();
uint256 stakeTotal = getStakeTotal();
uint256 burnEuros;
uint256 nativePurchased;
for (uint256 j = 0; j < holders.length; j++) {
Position memory _position = positions[holders[j]];
uint256 _positionStake = stake(_position);
if (_positionStake > 0) {
for (uint256 i = 0; i < _assets.length; i++) {
ILiquidationPoolManager.Asset memory asset = _assets[i];
if (asset.amount > 0) {
(,int256 assetPriceUsd,,,) = Chainlink.AggregatorV3Interface(asset.token.clAddr).latestRoundData();
uint256 _portion = asset.amount * _positionStake / stakeTotal;
uint256 costInEuros = _portion * 10 ** (18 - asset.token.dec) * uint256(assetPriceUsd) / uint256(priceEurUsd)* _hundredPC / _collateralRate;
if (costInEuros > _position.EUROs) {
_portion = _portion * _position.EUROs / costInEuros;
costInEuros = _position.EUROs;
}
_position.EUROs -= costInEuros;
//here we're adding the rewards to the holder
rewards[abi.encodePacked(_position.holder, asset.token.symbol)] += _portion;
burnEuros += costInEuros;
if (asset.token.addr == address(0)) {
nativePurchased += _portion;
} else {
//here we're transferring the rewards from manager to address this
IERC20(asset.token.addr).safeTransferFrom(manager, address(this), _portion);
}
}
}
}
positions[holders[j]] = _position;
}
if (burnEuros > 0)
IEUROs(EUROs).burn(address(this), burnEuros);
returnUnpurchasedNative(_assets, nativePurchased);
}

As you can see, on this line:

rewards[abi.encodePacked(_position.holder, asset.token.symbol)] += _portion;

we're accounting for the rewards of the holder, by incrementing it with _portion and then we're transferring the required rewards from the manager to address(this):

IERC20(asset.token.addr).safeTransferFrom(manager, address(this), _portion);

The problem is that we accounted for the rewards by incrementing with _portion but the contract will actually receive _portion - fee because of the fee that is taken on the transfer.

Which in turn will cause problems in the claimRewards function:

function claimRewards() external {
ITokenManager.Token[] memory _tokens = ITokenManager(tokenManager).getAcceptedTokens();
for (uint256 i = 0; i < _tokens.length; i++) {
ITokenManager.Token memory _token = _tokens[i];
//here we get the rewardAmount from the rewards mapping
uint256 _rewardAmount = rewards[abi.encodePacked(msg.sender, _token.symbol)]; <--
if (_rewardAmount > 0) {
delete rewards[abi.encodePacked(msg.sender, _token.symbol)];
if (_token.addr == address(0)) {
(bool _sent,) = payable(msg.sender).call{value: _rewardAmount}("");
require(_sent);
} else {
IERC20(_token.addr).transfer(msg.sender, _rewardAmount); <--
}
}
}
}

This function will try to transfer _rewardAmount:

IERC20(_token.addr).transfer(msg.sender, _rewardAmount);

However, the contract received less than _rewardAmount in the previous function. Which will lead to:

  1. the function reverts and the user can't claim his rewards. This is the case if there are no other users with rewards from the same token
    or

  2. he'll be able to claim but he'll claim more than he was supposed to, so at some point, a situation will be reached where someone else can't claim his rewards as there is not enough balance in the contract.

Impact

Stuck user funds.

Tools Used

Manual review

Recommendations

Consider comparing the balance of the contract before and after the transfer to get the actual transferred amount. Like this:

balanceBefore = IERC20(asset.token.addr).balanceOf(address(this));
IERC20(asset.token.addr).safeTransferFrom(manager, address(this), _portion);
balanceAfter = IERC20(asset.token.addr).balanceOf(address(this));
transferredAmount = balanceAfter - balanceBefore;
rewards[abi.encodePacked(_position.holder, asset.token.symbol)] += transferredAmount;
Updates

Lead Judging Commences

hrishibhat Lead Judge over 1 year ago
Submission Judgement Published
Validated
Assigned finding tags:

fee-on-transfer

hrishibhat Lead Judge over 1 year ago
Submission Judgement Published
Invalidated
Reason: Out of scope
Assigned finding tags:

fee-on-transfer

Support

FAQs

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