Core Contracts

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

Icorrect Repayment Amount Calculation in _repay Functio

Summary

A critical flaw exists in the debt repayment logic where the protocol miscalculates user debt values by using scaled balances without accounting for accrued interest. This prevents users from fully repaying debts.

Technical Background
Scaled Debt: User debt is stored as a "scaled" value that remains constant while interest accrues via an increasing usageIndex.

Actual Debt: Calculated as scaledDebt * usageIndex. As time passes, usageIndex grows exponentially, making the actual debt larger.

Vulnerability Details

https://github.com/Cyfrin/2025-02-raac/blob/89ccb062e2b175374d40d824263a4c0b601bcb7f/contracts/core/pools/LendingPool/LendingPool.sol#L398C1-L399C1

// LendingPool.sol - _repay() snippet
uint256 userDebt = IDebtToken(...).balanceOf(onBehalfOf); // Returns SCALED balance
uint256 userScaledDebt = userDebt.rayDiv(reserve.usageIndex);
uint256 actualRepayAmount = amount > userScaledDebt ? userScaledDebt : amount;

Issue

  1. Incorrect Debt Retrieval
    The code uses IDebtToken(...).balanceOf() to get the user's debt. This returns the scaled debt (principal without interest), not the actual owed amount.

  2. Improper Interest Application

The calculation userScaledDebt = userDebt.rayDiv(reserve.usageIndex) further reduces the debt value by dividing instead of multiplying by the index, artificially lowering the perceived debt.

Proof of Concept:

// Flawed logic in _repay():
uint256 userDebt = IDebtToken(...).balanceOf(onBehalfOf); // Returns scaled balance
uint256 userScaledDebt = userDebt.rayDiv(reserve.usageIndex); // Wrong: Division instead of multiplication
uint256 actualRepayAmount = amount > userScaledDebt ? userScaledDebt : amount; // Caps repayment incorrectly

After 10% interest accrual:

  • Actual Debt: 1,000 USDC * 1.1 = 1,100 USDC

  • Code Thinks Debt Is: 1,000 / 1.1 ≈ 909 USDC

Coded POC

Mocked lending pool

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.19;
import "../../contracts/core/tokens/DebtToken.sol";
import "../../contracts/interfaces/core/pools/LendingPool/ILendingPool.sol";
import "@openzeppelin/contracts/access/Ownable.sol";
import { WadRayMath } from "../../contracts/libraries/math/WadRayMath.sol";
contract LendingPoolMock is Ownable, ILendingPool {
using WadRayMath for uint256;
DebtToken public debtToken;
// Simplified reserve usage index
uint256 public usageIndex = 10**27; // RAY
// Mapping borrower => scaledDebtBalance
mapping(address => uint256) public scaledDebt;
constructor(address _debtToken) Ownable(msg.sender) {
debtToken = DebtToken(_debtToken);
}
// Allow owner (our test) to update usageIndex to simulate interest accrual
function setUsageIndex(uint256 newIndex) external onlyOwner {
usageIndex = newIndex;
}
function borrow(uint256 amount) external override {
// For simplicity, we update scaledDebt as amount scaled with initial usageIndex = RAY.
// scaledDebt = amount.rayDiv(usageIndex) but since usageIndex==RAY initially, scaledDebt == amount.
scaledDebt[msg.sender] += amount;
// Mint debt tokens (vulnerable mint call)
debtToken.mint(msg.sender, msg.sender, amount, usageIndex);
}
function repay(uint256 amount) external override {
_repay(amount, msg.sender);
}
function _repay(uint256 amount, address onBehalfOf) internal {
// Compute the actual user debt in underlying terms.
uint256 userDebt = scaledDebt[onBehalfOf] * usageIndex;
// Cap repayment to the available debt.
uint256 actualRepay = amount > userDebt ? userDebt : amount;
// Compute scaled repayment (integer division may leave some dust).
uint256 scaledRepay = actualRepay / usageIndex;
// Burn the corresponding amount of debt tokens.
debtToken.burn(onBehalfOf, scaledRepay, usageIndex);
// Reduce the stored scaled debt.
scaledDebt[onBehalfOf] -= scaledRepay;
}
// Helper to get user actual debt = scaledDebt * usageIndex
function getUserDebt(address user) external view returns (uint256) {
return scaledDebt[user] * usageIndex;
}
// Dummy implementations for other ILendingPool methods (left empty for PoC)
function deposit(uint256 /*amount*/) external override { }
function withdraw(uint256 /*amount*/) external override { }
function depositNFT(uint256 /*tokenId*/) external override { }
function withdrawNFT(uint256 /*tokenId*/) external override { }
function updateState() external override { }
function initiateLiquidation(address /*userAddress*/) external override { }
function closeLiquidation() external override { }
function finalizeLiquidation(address /*userAddress*/) external override { }
function setParameter(OwnerParameter /*param*/, uint256 /*newValue*/) external override { }
function setPrimeRate(uint256 /*newPrimeRate*/) external override { }
function setProtocolFeeRate(uint256 /*newProtocolFeeRate*/) external override { }
function setStabilityPool(address /*newStabilityPool*/) external override { }
function transferAccruedDust(address /*recipient*/, uint256 /*amountUnderlying*/) external override { }
}
const { expect } = require("chai");
const { ethers } = require("hardhat");
const { BigNumber } = require("ethers");
const RAY = BigNumber.from("1000000000000000000000000000"); // 1e27
describe("Repay Issue PoC", function () {
let owner, borrower, other;
let lendingPool, debtToken;
// For simplicity we deploy a minimal LendingPool and DebtToken that use the vulnerable _repay logic.
beforeEach(async function () {
[owner, borrower, other] = await ethers.getSigners();
// Deploy DebtToken (using the vulnerable implementation)
const DebtToken = await ethers.getContractFactory("DebtToken", owner);
debtToken = await DebtToken.deploy("DebtToken", "DT", owner.address);
await debtToken.deployed();
// Deploy a simple LendingPool that uses the deployed debtToken.
// For this PoC, we simulate update of reserve usageIndex.
const LendingPool = await ethers.getContractFactory("LendingPoolMock", owner);
lendingPool = await LendingPool.deploy(debtToken.address);
await lendingPool.deployed();
// Set the reserve.debtTokenAddress to our deployed debtToken.
await debtToken.setReservePool(lendingPool.address);
// For testing, assume lendingPool passes funds using a mock ERC20.
});
it("should under-repay debt due to miscalculation", async function () {
// Borrow scenario:
// Borrow 1000 units. (assume token decimals 18)
const borrowAmount = ethers.parseEther("1000");
// Borrower borrows funds.
await lendingPool.connect(borrower).borrow(borrowAmount);
// Initially, usageIndex is RAY so actual debt = 1000
let debtBefore = await lendingPool.getUserDebt(borrower.address);
expect(debtBefore).to.equal(borrowAmount);
// Simulate interest accrual: update usageIndex to 1.1·RAY
const newUsageIndex = RAY.mul(110).div(100);
await lendingPool.setUsageIndex(newUsageIndex);
// Now, actual debt should be 1000 * 1.1 = 1100 units.
let actualDebt = (await lendingPool.getUserDebt(borrower.address));
expect(actualDebt).to.equal(borrowAmount.mul(110).div(100));
// The flawed _repay logic calculates repayable amount:
// scaledDebt = debtToken.balanceOf(borrower) returns ~1000 (scaled principal)
// then scaledDebt.rayDiv(newUsageIndex) gives ~1000/1.1 ≈ 909
// So if borrower sends 1100, only ~909 is applied.
const repayAmount = ethers.utils.parseEther("1100");
await lendingPool.connect(borrower).repay(repayAmount);
// After repayment, the debt will be reduced by ~909 units only.
let remainingDebt = await lendingPool.getUserDebt(borrower.address);
// Expected remaining debt = 1100 - 909 = approx 191 (we use a tolerance for rounding)
expect(remainingDebt).to.be.gt(ethers.utils.parseEther("190"));
expect(remainingDebt).to.be.lt(ethers.utils.parseEther("200"));
});
it("should fully repay debt with correct calculation", async function () {
const borrowAmount = ethers.parseEther("1000");
// Borrower borrows funds.
await lendingPool.connect(borrower).borrow(borrowAmount);
let debtBefore = await lendingPool.getUserDebt(borrower.address);
expect(debtBefore).to.equal(borrowAmount);
// Simulate interest accrual by updating usageIndex to 1.1 * RAY.
const newUsageIndex = RAY.mul(110).div(100);
await lendingPool.setUsageIndex(newUsageIndex);
let actualDebt = await lendingPool.getUserDebt(borrower.address);
expect(actualDebt).to.equal(borrowAmount.mul(110).div(100));
// Borrower repays the full underlying debt.
const repayAmount = actualDebt;
await lendingPool.connect(borrower).repay(repayAmount);
let remainingDebt = await lendingPool.getUserDebt(borrower.address);
// After a full repayment, the debt should be zero.
expect(remainingDebt).to.equal(0);
});
});

Impact

  • Debts are systematically underpaid, creating bad debt.

  • Liquidators cannot fully recover owed amounts, disincentivizing liquidations.

  • Direct Fund Loss: Attackers exploit this to borrow funds and repay less than owed, stealing the difference.

Recommendations

function _repay(uint256 amount, address onBehalfOf) internal {
// 1. Calculate ACTUAL debt (scaledDebt * usageIndex)
uint256 actualDebt = user.scaledDebtBalance.rayMul(reserve.usageIndex);
// 2. Determine repayable amount
uint256 actualRepayAmount = amount > actualDebt ? actualDebt : amount;
// 3. Convert to scaled amount for burning
uint256 scaledRepayAmount = actualRepayAmount.rayDiv(reserve.usageIndex);
// 4. Burn debt tokens using SCALED amount
IDebtToken(reserve.reserveDebtTokenAddress).burn(
onBehalfOf,
scaledRepayAmount,
reserve.usageIndex
);
// 5. Update state
user.scaledDebtBalance -= scaledRepayAmount;
}
Updates

Lead Judging Commences

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

LendingPool::_repay emits Repay event with capped actualRepayAmount instead of the real amountScaled value that was transferred, causing misleading event data

LendingPool::_repay caps actualRepayAmount at userScaledDebt instead of userDebt, preventing users from repaying full debt with interest in one transaction

That amount is not actually used.

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

LendingPool::_repay emits Repay event with capped actualRepayAmount instead of the real amountScaled value that was transferred, causing misleading event data

LendingPool::_repay caps actualRepayAmount at userScaledDebt instead of userDebt, preventing users from repaying full debt with interest in one transaction

That amount is not actually used.

Support

FAQs

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

Give us feedback!