The notifyRewardAmount() function within the GaugeIncentiveController() contract (derived from RewardBase.sol) allows rewards to be sent and distributed to holders of AToken based on their eligibility determined by the EligibilityCriteria contract.
However, a crucial check is missing in the notifyRewardAmount() function. It fails to verify whether totalSupply == 0 before accepting the reward. This issue could result in the complete loss of the reward or a portion of it, which would then be locked in the GaugeIncentiveController() contract's balance indefinitely.
Consider the following scenario:
Initially, there was a distribution of 10ZeroLend rewards to GaugeIncentiveController().
Subsequently, another 10ZeroLend rewards were distributed after an hour.
At this point, totalSupply equals 0.
After 13 days, Alice mints 1Atoken, now totalSupply > 0. She then waits an additional 14 days (as defined by the DURATION variable in RewardBase) and earns 1.4ZeroLend.
Consequently, a total of 18.6ZeroLend becomes irreversibly locked in GaugeIncentiveController().
For the mitigation, add a check require(totalSupply > 0) to the notifyRewardAmount() of RewardBase.
Proof of Concept
To run the Poc put it's code to the governance-main/test/Gauge.poc.ts file, generate random private key, and issue the following command:
WALLET_PRIVATE_KEY=0x... NODE_ENV=test npx hardhat test test/Gauge.poc.ts --config hardhat.config.ts --network hardhat
import { expect } from "chai";
import { loadFixture } from "@nomicfoundation/hardhat-network-helpers";
import { SignerWithAddress } from "@nomicfoundation/hardhat-ethers/signers";
import {
GaugeIncentiveController,
OmnichainStaking,
Pool,
PoolVoter,
StakingBonus,
TestnetERC20,
VestedZeroNFT,
ZeroLend,
} from "../typechain-types";
import { e18 } from "./fixtures/utils";
import { deployVoters } from "./fixtures/voters";
import { ethers } from "hardhat";
import { time } from "@nomicfoundation/hardhat-network-helpers";
// Put inside governance-main/test/PoolVoter.poc.ts
// Run as:
// WALLET_PRIVATE_KEY=0x... NODE_ENV=test npx hardhat test test/Gauge.poc.ts --config hardhat.config.ts --network hardhat
describe.only("ZeroLend Immunefi Boost", () => {
let ant: SignerWithAddress;
let deployer: SignerWithAddress;
let now: number;
let omniStaking: OmnichainStaking;
let poolVoter: PoolVoter;
let reserve: TestnetERC20;
let stakingBonus: StakingBonus;
let vest: VestedZeroNFT;
let pool: Pool;
let aTokenGauge: GaugeIncentiveController;
let zero: ZeroLend;
let owner: SignerWithAddress;
beforeEach(async () => {
const deployment = await loadFixture(deployVoters);
ant = deployment.ant;
now = Math.floor(Date.now() / 1000);
omniStaking = deployment.governance.omnichainStaking;
poolVoter = deployment.poolVoter;
reserve = deployment.lending.erc20;
stakingBonus = deployment.governance.stakingBonus;
vest = deployment.governance.vestedZeroNFT;
zero = deployment.governance.zero;
pool = deployment.lending.pool;
aTokenGauge = deployment.aTokenGauge;
owner = deployment.governance.lending.owner;
deployer = deployment.governance.deployer;
// deployer should be able to mint a nft for another user
await vest.mint(
ant.address,
e18 * 20n, // 20 ZERO linear vesting
0, // 0 ZERO upfront
1000, // linear duration - 1000 seconds
0, // cliff duration - 0 seconds
now + 1000, // unlock date
true, // penalty -> false
0
);
// stake nft on behalf of the ant
await vest
.connect(ant)
["safeTransferFrom(address,address,uint256)"](
ant.address,
stakingBonus.target,
1
);
// there should now be some voting power for the user to play with
// ant voting power is ~ 19 ether
expect(await omniStaking.balanceOf(ant.address)).lessThan(e18 * 20n);
});
it("Stuck reward in Gauge", async function () {
let gaugeToken = await aTokenGauge.aToken();
let atoken = await ethers.getContractAt("AToken", gaugeToken);
// Mint 1 WETH to ant
await reserve.connect(owner)["mint(address,uint256)"](ant.address, 1n * e18);
expect(await reserve.balanceOf(ant.address)).eq(1n * e18);
// Distribute reward, 10 ZeroLend when totalSupply == 0
await zero.connect(deployer).approve(aTokenGauge.target, 10n * e18);
await aTokenGauge.connect(deployer).notifyRewardAmount(zero.target, 10n * e18);
// + 1 Hour
await time.increase(3600);
// Distribute reward, 10 ZeroLend when totalSupply == 0
await zero.connect(deployer).approve(aTokenGauge.target, 10n * e18);
await aTokenGauge.connect(deployer).notifyRewardAmount(zero.target, 10n * e18);
// + 13 Days
await time.increase(1123200);
// Mint AToken from WETH for ant
await reserve.connect(ant).approve(pool.target, 1n * e18);
await pool.connect(ant).supply(reserve.target, 1n * e18, ant.address, 0n);
expect(await atoken.balanceOf(ant.address)).greaterThan(0);
// + 14 Days
await time.increase(1209600);
// ant gets reward for 1 day
let earned = await aTokenGauge.earned(zero.target, ant.address);
console.log(`${earned}`);
// The rest of the Reward stuck and not recoverable
expect(await zero.balanceOf(aTokenGauge.target)).greaterThan(10n * e18);
});
});