Core Contracts

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

RToken Burn Function Issues

Summary

The burn function in the RToken contract is responsible for burning RTokens from a user's address and transferring the underlying assets. This is a critical function that handles user withdrawals and interest calculations.
https://github.com/Cyfrin/2025-02-raac/blob/89ccb062e2b175374d40d824263a4c0b601bcb7f/contracts/core/tokens/RToken.sol#L154C4-L185C6

There are various vulnerabilities in the burn function

Vulnerability Details

  1. Incorrect Scaling Direction

  • Uses multiplication with index instead of division

  • Results in burning amount × index tokens instead of amount / index example: With index = 1.1e27 (10% interest), burning 100 tokens would incorrectly burn 110 scaled tokens

uint256 amountScaled = amount.rayMul(index);
  1. Burning Unscaled Amount

_burn(from, amount.toUint128());
  • Burns raw amount instead of the scaled amount

  • Bypasses interest accrual mechanism

  1. Missing Interest Accrual

// Current (vulnerable)
uint256 userBalance = balanceOf(from);
// No balance increase calculation
// Correct Approach (should include)
uint256 balanceIncrease = 0;
if (_userState[from].index < index) {
balanceIncrease = ... // Interest calculation
}

Impact: Incorrect Asset Distribution

  • The desired burn amount will never be burned

Proof of concept

import { expect } from "chai";
import { ethers } from "hardhat";
import { Signer } from "ethers";
import { RToken, ERC20Mock } from "../typechain-types";
describe("RToken Burn Function Vulnerabilities", () => {
let rToken: RToken;
let asset: ERC20Mock;
let owner: Signer;
let user: Signer;
const INITIAL_INDEX = ethers.utils.parseUnits("1.0", 27);
const INTEREST_INDEX = ethers.utils.parseUnits("1.1", 27); // 10% interest
beforeEach(async () => {
[owner, user] = await ethers.getSigners();
// Deploy mock asset
const ERC20Mock = await ethers.getContractFactory("ERC20Mock");
asset = await ERC20Mock.deploy("Asset", "AST", 18);
// Deploy RToken
const RToken = await ethers.getContractFactory("RToken");
rToken = await RToken.deploy(
"RToken",
"RTK",
await owner.getAddress(),
asset.address
);
// Setup reserve pool
await rToken.connect(owner).setReservePool(owner.getAddress());
// Fund user with 1000 tokens
await asset.mint(await user.getAddress(), ethers.utils.parseUnits("1000"));
await asset.connect(user).approve(rToken.address, ethers.constants.MaxUint256);
});
it("should demonstrate incorrect scaling leading to overburn", async () => {
// Initial deposit (contracts/core/tokens/RToken.sol:154-185)
await rToken.connect(owner).mint(
await owner.getAddress(),
await user.getAddress(),
ethers.utils.parseUnits("1000"),
INITIAL_INDEX
);
// Update index with 10% interest
await rToken.connect(owner).updateLiquidityIndex(INTEREST_INDEX);
// Attempt to burn 100 tokens
const burnAmount = ethers.utils.parseUnits("100");
// Pre-balance check
const preBurnBalance = await rToken.balanceOf(await user.getAddress());
// Execute burn (contracts/core/tokens/rtokenaudited.sol:174-217)
await rToken.connect(owner).burn(
await user.getAddress(),
await user.getAddress(),
burnAmount,
INTEREST_INDEX
);
// Post-balance check
const postBurnBalance = await rToken.balanceOf(await user.getAddress());
// Expected: 1000 * 1.1 = 1100 virtual, burn 100/1.1 ≈ 90.9 scaled
const expectedBurn = burnAmount.mul(ethers.BigNumber.from(10).pow(27)).div(INTEREST_INDEX);
expect(preBurnBalance.sub(postBurnBalance)).to.be.gt(expectedBurn); // Fails with correct impl
});
it("should show missing interest accrual", async () => {
// Deposit 1000 tokens
await rToken.connect(owner).mint(
await owner.getAddress(),
await user.getAddress(),
ethers.utils.parseUnits("1000"),
INITIAL_INDEX
);
// Update index with 10% interest
await rToken.connect(owner).updateLiquidityIndex(INTEREST_INDEX);
// Burn all tokens
const userBalance = await rToken.balanceOf(await user.getAddress());
await rToken.connect(owner).burn(
await user.getAddress(),
await user.getAddress(),
userBalance,
INTEREST_INDEX
);
// Check underlying received (contracts/core/tokens/RToken.sol:300-307)
const finalAssetBalance = await asset.balanceOf(await user.getAddress());
// Expected: 1000 * 1.1 = 1100
expect(finalAssetBalance).to.equal(ethers.utils.parseUnits("1000")); // Fails with correct impl
});
});

Recommendations

  • Follow aave's design to do so

function burn(
address from,
address receiverOfUnderlying,
uint256 amount,
uint256 index
) external override onlyReservePool returns (uint256, uint256, uint256) {
if (amount == 0) {
return (0, totalSupply(), 0);
}
// Get scaled balance (internal accounting)
uint256 scaledBalance = super.balanceOf(from);
uint256 balanceIncrease = 0;
// Calculate accrued interest
if (_userState[from].index != 0 && _userState[from].index < index) {
balanceIncrease = scaledBalance.rayMul(index) - scaledBalance.rayMul(_userState[from].index);
}
// Update user index once
_userState[from].index = index.toUint128();
// Calculate scaled amount correctly
uint256 amountScaled = amount.rayDiv(index);
// Cap at actual scaled balance
if (amountScaled > scaledBalance) {
amountScaled = scaledBalance;
amount = amountScaled.rayMul(index); // Recalculate actual underlying amount
}
_burn(from, amountScaled.toUint128());
if (receiverOfUnderlying != address(this)) {
IERC20(_assetAddress).safeTransfer(receiverOfUnderlying, amount + balanceIncrease);
}
emit Burn(from, receiverOfUnderlying, amount, index);
return (amountScaled, totalSupply(), amount + balanceIncrease);
}
Updates

Lead Judging Commences

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

RToken::burn incorrectly calculates amountScaled using rayMul instead of rayDiv, causing incorrect token burn amounts and breaking the interest accrual mechanism

RToken::burn incorrectly burns amount (asset units) instead of amountScaled (token units), breaking token economics and interest-accrual mechanism

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

RToken::burn incorrectly calculates amountScaled using rayMul instead of rayDiv, causing incorrect token burn amounts and breaking the interest accrual mechanism

RToken::burn incorrectly burns amount (asset units) instead of amountScaled (token units), breaking token economics and interest-accrual mechanism

Support

FAQs

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

Give us feedback!