Core Contracts

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

Multiple Critical Calculation And Logic Errors in `RToken::mint/burn` Function

Summary

The RToken::mint/burn function contains several critical calculation and logic errors that violate the intended token minting&burning mechanism.

  • Incorrect Mint Amount:the param amountToMint is the amount of underlying asset to mint,but the function uses it as the amount of RToken to mint more than expected.

  • Incorrect Burn Amount:the param amount is the amount of underlying asset to burn,but the function uses it as the amount of RToken to burn more than expected.

  • Wrong Balance Comparison:the RToken::burn function compares the unscaled amount with userBalance,but should compare the scaled amountScaled with userBalance.

  • Incorrect Token Scaling:the function uses rayMul instead of rayDiv for scaling amounts.

  • Incorrect Return Values:the RToken::mint/burn function returns the wrong values for scaled amount and underlying amount.

  • Redundant update:the RToken::burn function updates the _userState[from].index twice.

  • Redundant content in RToken::mint function

  • All of the above errors will lead to serious vulnerabilities and require a refactor of these two functions.

Vulnerability Details

function mint(
address caller,
address onBehalfOf,
uint256 amountToMint,
uint256 index
) external override onlyReservePool returns (bool, uint256, uint256, uint256) {
if (amountToMint == 0) {
return (false, 0, 0, 0);
}
uint256 amountScaled = amountToMint.rayDiv(index);
if (amountScaled == 0) revert InvalidAmount();
uint256 scaledBalance = balanceOf(onBehalfOf);
bool isFirstMint = scaledBalance == 0;
uint256 balanceIncrease = 0;
if (_userState[onBehalfOf].index != 0 && _userState[onBehalfOf].index < index) {
balanceIncrease = scaledBalance.rayMul(index) - scaledBalance.rayMul(_userState[onBehalfOf].index);
}
_userState[onBehalfOf].index = index.toUint128();
_mint(onBehalfOf, amountToMint.toUint128());
emit Mint(caller, onBehalfOf, amountToMint, index);
return (isFirstMint, amountToMint, totalSupply(), amountScaled);
}
function burn(
address from,
address receiverOfUnderlying,
uint256 amount,
uint256 index
) external override onlyReservePool returns (uint256, uint256, uint256) {
if (amount == 0) {
return (0, totalSupply(), 0);
}
uint256 userBalance = balanceOf(from);
_userState[from].index = index.toUint128();
if(amount > userBalance){
amount = userBalance;
}
uint256 amountScaled = amount.rayMul(index);
_userState[from].index = index.toUint128();
_burn(from, amount.toUint128());
if (receiverOfUnderlying != address(this)) {
IERC20(_assetAddress).safeTransfer(receiverOfUnderlying, amount);
}
emit Burn(from, receiverOfUnderlying, amount, index);
return (amount, totalSupply(), amount);
}

Impact

  • Incorrectly mints more rTokens for the user, causing issues for other contracts that depend on this rToken.

  • More rTokens will be burned compared to expectations.

  • Due to logical and calculation errors, other potential issues arise.

PoC

Since this is a large project that requires many setup steps in the early stages, and RToken::mint/burn has few external dependencies, the contract has been simplified and refactored, and tested using Foundry.
For instructions on how to use Foundry in Hardhat, you can refer to:

  • Step 1: Create a new RTokenModified contract (contracts\core\tokens\RTokenModified.sol):

//SPDX-License-Identifier: MIT
pragma solidity ^0.8.19;
import "@openzeppelin/contracts/token/ERC20/ERC20.sol";
import "@openzeppelin/contracts/utils/math/SafeCast.sol";
import "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol";
import "../../libraries/math/WadRayMath.sol";
interface IRTokenModified {
function burn(
address from,
address receiverOfUnderlying,
uint256 amount,
uint256 index
) external returns (uint256, uint256, uint256);
function mint(
address caller,
address onBehalfOf,
uint256 amount,
uint256 index
) external returns (bool, uint256, uint256, uint256);
}
contract RTokenModified is ERC20, IRTokenModified {
using WadRayMath for uint256;
using SafeERC20 for IERC20;
using SafeCast for uint256;
address private _assetAddress;
struct UserState {
uint128 index;
}
mapping(address => UserState) private _userState;
error InvalidAmount();
event Burn(address indexed from, address indexed receiverOfUnderlying, uint256 amount, uint256 index);
event Mint(address indexed caller, address indexed onBehalfOf, uint256 amount, uint256 index);
constructor(address asset) ERC20("RToken", "RT") {
_assetAddress = asset;
}
/**
* @notice Compared with the original `RToken::mint`, only removed the `onlyReservePool` modifier
*/
function mint(
address caller,
address onBehalfOf,
uint256 amountToMint,
uint256 index
) external override returns (bool, uint256, uint256, uint256) {
if (amountToMint == 0) {
return (false, 0, 0, 0);
}
uint256 amountScaled = amountToMint.rayDiv(index);
if (amountScaled == 0) revert InvalidAmount();
uint256 scaledBalance = balanceOf(onBehalfOf);
bool isFirstMint = scaledBalance == 0;
uint256 balanceIncrease = 0;
if (_userState[onBehalfOf].index != 0 && _userState[onBehalfOf].index < index) {
balanceIncrease = scaledBalance.rayMul(index) - scaledBalance.rayMul(_userState[onBehalfOf].index);
}
_userState[onBehalfOf].index = index.toUint128();
_mint(onBehalfOf, amountToMint.toUint128());
emit Mint(caller, onBehalfOf, amountToMint, index);
return (isFirstMint, amountToMint, totalSupply(), amountScaled);
}
/**
* @notice Compared with the original `RToken::burn`, only removed the `onlyReservePool` modifier
*/
function burn(
address from,
address receiverOfUnderlying,
uint256 amount,
uint256 index
) external override returns (uint256, uint256, uint256) {
if (amount == 0) {
return (0, totalSupply(), 0);
}
uint256 userBalance = balanceOf(from);
_userState[from].index = index.toUint128();
if(amount > userBalance){
amount = userBalance;
}
uint256 amountScaled = amount.rayMul(index);
_userState[from].index = index.toUint128();
_burn(from, amount.toUint128());
if (receiverOfUnderlying != address(this)) {
IERC20(_assetAddress).safeTransfer(receiverOfUnderlying, amount);
}
emit Burn(from, receiverOfUnderlying, amount, index);
return (amount, totalSupply(), amount);
}
}
  • Step2:Create the test contract RTokenTest.t.sol(test\RTokenTest.t.sol):

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.19;
import {Test , console2} from "forge-std/Test.sol";
import {RTokenModified} from "../contracts/core/tokens/RTokenModified.sol";
import {ERC20Mock} from "../contracts/mocks/core/tokens/ERC20Mock.sol";
import {WadRayMath} from "../contracts/libraries/math/WadRayMath.sol";
contract RTokenModifiedTest is Test {
using WadRayMath for uint256;
uint256 constant AMOUNT_TO_MINT = 10 ether; // in underlying asset units
RTokenModified rTokenModified;
ERC20Mock asset;
address USER;
uint256 index;
uint256 newIndex;
function setUp() public {
asset = new ERC20Mock("Asset", "ASSET");
USER = makeAddr("USER");
rTokenModified = new RTokenModified(address(asset));
index = 2e27;
}
function test_mint_more_rtoken_than_expected() public {
uint256 expectedRTokenBalance = AMOUNT_TO_MINT.rayDiv(index);
vm.prank(USER);
(,uint256 returnedRTokenBalance,,)=rTokenModified.mint(USER, USER, AMOUNT_TO_MINT, index);
uint256 actualRTokenBalance=rTokenModified.balanceOf(USER);
console2.log("expected RToken balance is:",expectedRTokenBalance);
console2.log("returned RToken balance is:",returnedRTokenBalance);
console2.log("actual RToken balance after mint is:",actualRTokenBalance);
}
function test_burn_more_rtoken_than_expected() public {
vm.prank(USER);
rTokenModified.mint(USER, USER, AMOUNT_TO_MINT, index);
//In this step, we cheat to prevent insufficient balance during the burn.
asset.mint(address(rTokenModified),10000000 ether);
uint256 expectedAssertBalance=AMOUNT_TO_MINT/2;
console2.log("expected assert balance after burn is:",expectedAssertBalance);
uint256 rTokenBalanceBeforeBurn=rTokenModified.balanceOf(USER);
vm.prank(USER);
rTokenModified.burn(USER, USER, AMOUNT_TO_MINT/2, index);
uint256 assertBalanceAfterBurn=asset.balanceOf(USER);
console2.log("actual assert balance after burn is:",assertBalanceAfterBurn);
uint256 expectedrTokenBalanceAfterBurn=rTokenBalanceBeforeBurn-AMOUNT_TO_MINT.rayDiv(index)/2;
console2.log("expected rToken balance after burn is:",expectedrTokenBalanceAfterBurn);
uint256 actualrTokenBalanceAfterBurn=rTokenModified.balanceOf(USER);
console2.log("actual rToken balance after burn is:",actualrTokenBalanceAfterBurn);
}
}
  • Step3:Run the tests:

forge test --mt test_mint_more_rtoken_than_expected -vv
forge test --mt test_burn_more_rtoken_than_expected -vv
  • Step4:Test results:

Ran 1 test for test/RTokenTest.t.sol:RTokenModifiedTest
[PASS] test_mint_more_rtoken_than_expected() (gas: 95337)
Logs:
expected RToken balance is: 5000000000000000000
returned RToken balance is: 10000000000000000000
actual RToken balance after mint is: 10000000000000000000
Ran 1 test for test/RTokenTest.t.sol:RTokenModifiedTest
[PASS] test_burn_more_rtoken_than_expected() (gas: 193958)
Logs:
expected assert balance after burn is: 5000000000000000000
actual assert balance after burn is: 5000000000000000000
expected rToken balance after burn is: 7500000000000000000
actual rToken balance after burn is: 5000000000000000000

Recommendations

Refactor as follows:

function mint(
address caller,
address onBehalfOf,
uint256 amountToMint,
uint256 index
) external override onlyReservePool returns (bool, uint256, uint256, uint256) {
if (amountToMint == 0) {
return (false, 0, 0, 0);
}
uint256 amountScaled = amountToMint.rayDiv(index);
if (amountScaled == 0) revert InvalidAmount();
uint256 scaledBalance = balanceOf(onBehalfOf);
bool isFirstMint = scaledBalance == 0;
- uint256 balanceIncrease = 0;
- if (_userState[onBehalfOf].index != 0 && _userState[onBehalfOf].index < index) {
- balanceIncrease = scaledBalance.rayMul(index) - scaledBalance.rayMul(_userState[onBehalfOf].index);
- }
_userState[onBehalfOf].index = index.toUint128();
- _mint(onBehalfOf, amountToMint.toUint128());
+ _mint(onBehalfOf, amountScaled.toUint128());
emit Mint(caller, onBehalfOf, amountToMint, index);
- return (isFirstMint, amountToMint, totalSupply(), amountScaled);
+ return (isFirstMint, amountScaled, totalSupply(), amountToMint);
}
function burn(
address from,
address receiverOfUnderlying,
uint256 amount,
uint256 index
) external override onlyReservePool returns (uint256, uint256, uint256) {
if (amount == 0) return (0, totalSupply(), 0);
uint256 userBalance = balanceOf(from);
- _userState[from].index = index.toUint128();
- if(amount > userBalance){
- amount = userBalance;
- }
- uint256 amountScaled = amount.rayMul(index);
+ uint256 amountScaled = amount.rayDiv(index);
+ if(amountScaled > userBalance) {
+ amountScaled = userBalance;
+ }
_userState[from].index = index.toUint128();
- _burn(from, amount.toUint128());
+ _burn(from, amountScaled.toUint128());
+ uint256 underlyingAmount = amountScaled.rayMul(index);
if (receiverOfUnderlying != address(this)) {
- IERC20(_assetAddress).safeTransfer(receiverOfUnderlying, amount);
+ IERC20(_assetAddress).safeTransfer(receiverOfUnderlying, underlyingAmount);
}
- return (amount, totalSupply(), amount);
+ return (amountScaled, totalSupply(), underlyingAmount);
}
Updates

Lead Judging Commences

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

DebtToken::mint miscalculates debt by applying interest twice, inflating borrow amounts and risking premature liquidations

RToken::burn returns incorrect underlying asset amount (amount instead of amountScaled), leading to wrong interest rate calculations

RToken::burn transfers original deposit amount (amount) to users instead of amount plus interest (amountScaled), causing loss of all accrued interest on withdrawals

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:

DebtToken::mint miscalculates debt by applying interest twice, inflating borrow amounts and risking premature liquidations

RToken::burn returns incorrect underlying asset amount (amount instead of amountScaled), leading to wrong interest rate calculations

RToken::burn transfers original deposit amount (amount) to users instead of amount plus interest (amountScaled), causing loss of all accrued interest on withdrawals

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!