Core Contracts

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

Interest Accrual Failure Due to Incorrect Scaling in RToken Implementation

Summary

The RToken.sol contract fails to properly implement the scaled balance mechanism for interest-bearing tokens, leading to a systemic failure in interest distribution to depositors. While the protocol correctly tracks interest accrual through the liquidity index, the core token scaling logic is implemented incorrectly, causing all interest payments to become trapped in the protocol rather than being distributed to depositors.

Vulnerability Details

The current RToken.sol implementation incorrectly handles the scaling mechanism needed for interest-bearing tokens. In lending protocols, token balances need to be stored in a scaled form to track proportional ownership as interest accrues. This requires:

  1. Scaling down deposit amounts by dividing by the current liquidity index when minting tokens

  2. Storing these scaled balances internally

  3. Scaling up by multiplying by the current liquidity index when calculating real balances or processing withdrawals

The RToken contract instead:

  1. Stores unscaled balances during minting: _mint(onBehalfOf, amount)

  2. Burns the unscaled amount: _burn(from, amount)

  3. Transfers the unscaled amount: safeTransfer(receiverOfUnderlying, amount)

This fundamentally breaks the interest distribution mechanism as depositors always receive their exact deposit amount back, regardless of accrued interest.

Impact

High

  • Depositors never receive any interest on their deposits

  • Interest payments from borrowers become permanently trapped in the protocol

  • The error affects all depositors equally and consistently

Likelihood

The likelihood is certain (high) as this is not a situational bug but rather a systematic failure in the core token mechanics that will affect every deposit and withdrawal operation.

Proof of Concept

  1. Convert the project into a foundry project, ensuring test in foundry.toml points to a designated test directory.

  2. Comment out the forking object from the hardhat.congif.cjs file:

networks: {
hardhat: {
mining: {
auto: true,
interval: 0
},
//forking: {
// url: process.env.BASE_RPC_URL,
//},
  1. Copy the following test into the directory:

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.19;
import "forge-std/Test.sol";
import "contracts/core/tokens/DebtToken.sol";
import "contracts/core/tokens/RToken.sol";
import "contracts/core/tokens/RAACNFT.sol";
import "contracts/core/pools/LendingPool/LendingPool.sol";
import "contracts/core/primitives/RAACHousePrices.sol";
import "contracts/libraries/math/PercentageMath.sol";
import "contracts/libraries/math/WadRayMath.sol";
import "@openzeppelin/contracts/token/ERC20/IERC20.sol";
contract MockERC20 is ERC20 {
constructor() ERC20("Mock", "MCK") {}
function mint(address to, uint256 amount) public {
_mint(to, amount);
}
}
contract IncorrectUnderlyingTransfer is Test {
using PercentageMath for uint256;
using WadRayMath for uint256;
DebtToken public debtToken;
RToken public rToken;
RAACNFT public nft;
RAACHousePrices public priceOracle;
LendingPool public lendingPool;
MockERC20 public mockCrvUSD;
address borrower = address(0x1);
address lender = address(0x2);
function setUp() public {
// Deploy mock crvUSD and core contracts
mockCrvUSD = new MockERC20();
rToken = new RToken("RToken", "RTKN", address(this), address(mockCrvUSD));
debtToken = new DebtToken("DebtToken", "DEBT", address(this));
priceOracle = new RAACHousePrices(address(this));
nft = new RAACNFT(address(mockCrvUSD), address(priceOracle), address(this));
lendingPool = new LendingPool(
address(mockCrvUSD),
address(rToken),
address(debtToken),
address(nft),
address(priceOracle),
1e27 // Initial prime rate of 1.0 in RAY
);
// Set up contract connections
debtToken.setReservePool(address(lendingPool));
rToken.setReservePool(address(lendingPool));
// Fund accounts
mockCrvUSD.mint(borrower, 100_000_000e18); // For NFT purchase
mockCrvUSD.mint(lender, 500_000e18); // For lending pool liquidity
// Set up approvals for borrower
vm.startPrank(borrower);
mockCrvUSD.approve(address(lendingPool), type(uint256).max);
mockCrvUSD.approve(address(nft), type(uint256).max);
vm.stopPrank();
// Set up NFT price
vm.prank(address(this));
priceOracle.setOracle(address(this));
priceOracle.setHousePrice(1, 100_000_000e18); // NFT worth 100,000,000
}
function testIncorrectInterestDistribution() public {
// First add protocol owned liquidity
mockCrvUSD.mint(address(this), 500_000e18);
mockCrvUSD.approve(address(lendingPool), 500_000e18);
lendingPool.deposit(500_000e18);
// First lender deposits 100,000 tokens
mockCrvUSD.mint(lender, 100_000e18);
vm.startPrank(lender);
mockCrvUSD.approve(address(lendingPool), 100_000e18);
lendingPool.deposit(100_000e18);
vm.stopPrank();
// Second lender deposits same amount
address lender2 = address(0x3);
mockCrvUSD.mint(lender2, 100_000e18);
vm.startPrank(lender2);
mockCrvUSD.approve(address(lendingPool), 100_000e18);
lendingPool.deposit(100_000e18);
vm.stopPrank();
// At this point pool has 700,000 tokens total
// Protocol: 500,000
// Lender1: 100,000
// Lender2: 100,000
// Simulate borrowing and interest accrual
vm.startPrank(borrower);
nft.mint(1, 100_000_000e18);
nft.approve(address(lendingPool), 1);
lendingPool.depositNFT(1);
lendingPool.borrow(525_000e18); // Borrow 75% of total deposits
vm.stopPrank();
// Move time forward 1 year
vm.warp(block.timestamp + 365 days);
// Borrower repays with 20% interest
vm.startPrank(borrower);
mockCrvUSD.mint(borrower, 630_000e18); // 525,000 + 105,000 (20% interest)
lendingPool.repay(630_000e18);
vm.stopPrank();
// First lender withdraws
vm.startPrank(lender);
uint256 lender1BalanceBefore = mockCrvUSD.balanceOf(lender);
lendingPool.withdraw(100_000e18);
uint256 lender1Received = mockCrvUSD.balanceOf(lender) - lender1BalanceBefore;
vm.stopPrank();
// Second lender withdraws
vm.startPrank(lender2);
uint256 lender2BalanceBefore = mockCrvUSD.balanceOf(lender2);
lendingPool.withdraw(100_000e18);
uint256 lender2Received = mockCrvUSD.balanceOf(lender2) - lender2BalanceBefore;
vm.stopPrank();
console.log("Lender 1 received:", lender1Received);
console.log("Lender 2 received:", lender2Received);
console.log("Protocol balance after:", mockCrvUSD.balanceOf(address(rToken)));
// Both lenders should get their principal plus ~15,000 each in interest (proportional share of the 105,000)
// But instead they only get their principal back
assertEq(lender1Received, 100_000e18, "Lender 1 received incorrect amount");
assertEq(lender2Received, 100_000e18, "Lender 2 received incorrect amount");
// The 105,000 in interest payments remain stuck in the protocol
assertTrue(mockCrvUSD.balanceOf(address(rToken)) > 500_000e18, "Protocol should hold interest");
}
}
  1. Run Forge test --vv, this will show logs

  2. Logs after Lenders have lent 100_000 crvUSD for 1 year:

Ran 1 test for test-foundry/IncorrectUnderlyingTransfer.t.sol:IncorrectUnderlyingTransfer
[PASS] testIncorrectInterestDistribution() (gas: 980343)
Logs:
Lender 1 received: 100000000000000000000000
Lender 2 received: 100000000000000000000000
Protocol balance after: 605000000000000000000000

Recommendations

Modify RToken to implement proper balance scaling following the pattern used in DebtToken.sol in RToken.sol:: mint() burn()

Updates

Lead Judging Commences

inallhonesty Lead Judge about 1 month ago
Submission Judgement Published
Validated
Assigned finding tags:

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 burns amount (asset units) instead of amountScaled (token units), breaking token economics and interest-accrual mechanism

inallhonesty Lead Judge about 1 month ago
Submission Judgement Published
Validated
Assigned finding tags:

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 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.