29190 - [SC - Insight] Permanent freezing of up to wei of yield each ...

Submitted on Mar 10th 2024 at 01:46:37 UTC by @nethoxa for Boost | ZeroLend

Report ID: #29190

Report type: Smart Contract

Report severity: Insight

Target: https://github.com/zerolend/governance

Impacts:

  • Permanent freezing of unclaimed yield

Description

Brief/Intro

Due to a rounding error when notifying a reward to LendingPoolGauge, up to 3 wei of the used token will be locked forever in the contract.

Vulnerability Details

It's well known Solidity rounds down on integer division. Because of that, in LendingPoolGauge::notifyRewardAmount, if the given amount is not divisible by 4, up to 3 wei of yield will be permanently locked in the contract as there is no way to take them back and the contract sends amount / 4 to the supplyGauge and amount / 4 * 3 to the borrowGauge:

    function notifyRewardAmount(
        address token,
        uint256 amount
    ) external returns (bool) {
        IERC20(token).safeTransferFrom(msg.sender, address(this), amount);

        // send 1/4 to the supply side
        IERC20(token).approve(address(supplyGauge), amount);
        bool a = supplyGauge.notifyRewardAmount(token, amount / 4);

        // send 3/4th to the borrow side
        IERC20(token).approve(address(borrowGauge), amount);
        bool b = borrowGauge.notifyRewardAmount(token, (amount / 4) * 3); // @audit rounding, yield lost forever

        return a && b;
    }

It may not be a high amount for tokens with high decimals, but for other tokens like USDC (6 decimals) or a variant of EURO which I do not remember, but had 2 decimals, it can be a significant loss of yield.

Impact Details

Vanilla loss of yield, permanent as there is no way to take them back.

Proof of Concept

The runnable POC is the next one:

pragma solidity 0.8.20;

import {Test, console2} from "forge-std/Test.sol";

import {StakingBonus} from "src/vesting/StakingBonus.sol";
import {VestedZeroNFT} from "src/vesting/VestedZeroNFT.sol";

import {LockerToken} from "src/locker/LockerToken.sol";
import {LockerLP} from "src/locker/LockerLP.sol";
import {OmnichainStaking} from "src/locker/OmnichainStaking.sol";

import {PoolVoter} from "src/voter/PoolVoter.sol";
import {LendingPoolGauge} from "src/voter/gauge/LendingPoolGauge.sol";
import {RewardBase} from "src/voter/gauge/RewardBase.sol";

import {ZeroLend} from "src/ZeroLendToken.sol";

import {IVestedZeroNFT} from "src/interfaces/IVestedZeroNFT.sol";

import {IERC20} from "openzeppelin-contracts/contracts/token/ERC20/IERC20.sol";

contract DummyGauge is RewardBase {
    function init(address _zero, address _vesting) external {
        __RewardBase_init(_zero, _vesting);
    }

    function rewardPerToken(IERC20 token) public view override returns (uint256) {
        return 0;
    }

    function earned(
        IERC20 token,
        address account
    ) public view override returns (uint256) {
        return 0;
    }

    modifier updateReward(IERC20 token, address account) override {
        _;
    }
}

contract POC is Test {

    function test_POC() external {
        address bob = makeAddr("bob");

        vm.startPrank(bob);

        StakingBonus bonus = new StakingBonus();
        VestedZeroNFT vZero = new VestedZeroNFT();
        LockerToken locker = new LockerToken();
        LockerLP lockerLP = new LockerLP();
        ZeroLend zero = new ZeroLend();
        OmnichainStaking staking = new OmnichainStaking();
        PoolVoter voter = new PoolVoter();
        DummyGauge dummyGauge1 = new DummyGauge();
        DummyGauge dummyGauge2 = new DummyGauge();
        LendingPoolGauge gauge = new LendingPoolGauge(address(dummyGauge1), address(dummyGauge2));

        bonus.init(address(zero), address(locker), address(vZero), 5); // 5% bonus, as StakingBonus::calculateBonus does the maths /100 instead of /10000
        vZero.init(address(zero), address(bonus));
        locker.init(address(zero), address(staking), address(bonus));
        staking.init(address(0), address(locker), address(lockerLP));
        lockerLP.init(address(zero), address(staking), address(bonus));
        voter.init(address(staking), address(zero));
        dummyGauge1.init(address(zero), address(vZero));
        dummyGauge2.init(address(zero), address(vZero));

        zero.togglePause(false);
        zero.approve(address(gauge), 1e18 + 3); // so that % 4 != 0 and the lost yield is 3 wei
        
        console2.log("\n\n");
        console2.log("[\x1b[32m+\x1b[0m] ZERO balance of Bob before the notify =\x1b[31m", zero.balanceOf(bob), "\x1b[0m");
        console2.log("[\x1b[32m+\x1b[0m] ZERO balance of LendingPoolGauge before the notify =\x1b[31m", zero.balanceOf(address(gauge)), "\x1b[0m");

        console2.log("");
        console2.log(" \x1b[33m-\x1b[0m Calling \x1b[32mLendingPoolGauge::notifyRewardAmount\x1b[0m with amount being \x1b[31m1e18 + 3\x1b[0m...");
        gauge.notifyRewardAmount(address(zero), 1e18 + 3);
        console2.log("");

        console2.log("[\x1b[32m+\x1b[0m] ZERO balance of Bob after the notify =\x1b[31m", zero.balanceOf(bob), "\x1b[0m");
        console2.log("[\x1b[32m+\x1b[0m] ZERO balance of LendingPoolGauge after the notify =\x1b[31m", zero.balanceOf(address(gauge)), "\x1b[0m");
        console2.log("\n\n");

        require(zero.balanceOf(address(gauge)) != 0, "POC");

        vm.stopPrank();
    }
}

Last updated