Core Contracts

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

1:1 ratio between DEToken and RToken is broken due to inconsistent transfer logic in RToken

Summary

According to the documentation and contract logic, DETokens are supposed to be redeemable 1:1 with RToken.

The DEToken (Debitum Emptor Token) is an implementation of the token used in the RAAC Stability Pool. It > represents a user's share in the Stability Pool and is redeemable 1:1 with RToken.

DEToken.md from docs

/**
πŸ“Œ * @notice Gets the current exchange rate between rToken and deToken.
* @return Current exchange rate.
*/
function getExchangeRate() public view returns (uint256) {
​
πŸ“Œ return 1e18;
}

StabilityPool.sol#217

Vulnerability Details

However, if we examine the transfer functions in the RToken contract, we can see that the transfer function divides the amount by LendingPool.getNormalizedIncome(). In contrast, the transferFrom function divides the amount by _liquidityIndex.

function transfer(address recipient, uint256 amount) public override(ERC20, IERC20) returns (bool) {
πŸ“Œ uint256 scaledAmount = amount.rayDiv(ILendingPool(_reservePool).getNormalizedIncome());
return super.transfer(recipient, scaledAmount);
}
​
...
​
function transferFrom(address sender, address recipient, uint256 amount) public override(ERC20, IERC20) returns (bool) {
πŸ“Œ uint256 scaledAmount = amount.rayDiv(_liquidityIndex);
return super.transferFrom(sender, recipient, scaledAmount);
}

RToken.sol#212

The problem is that _liquidityIndex is updated only by RToken.updateLiquidityIndex(), which has an onlyReservePool modifier, yet it is never called from the reserve pool (LendingPool).

As a result, when transfer() is called, the amount is divided by a value that is actively updated by the lending pool, whereas transferFrom() is divided by a value that is never updated. Simply put, transferFrom() will always transfer a larger amount than transfer().

The discrepancy between these values is further amplified by _update(), which divides the amount once again by LendingPool.getNormalizedIncome().

function _update(address from, address to, uint256 amount) internal override {
uint256 scaledAmount = amount.rayDiv(ILendingPool(_reservePool).getNormalizedIncome());
super._update(from, to, scaledAmount);
}

RToken.sol#307

Impact

This inconsistency between the two functions causes the users to receive fewer RTokens when calling StabilityPool.withdraw() for the same amount of DETokens.

The higher the liquidity index, the greater the discrepancy between the two values.

Proof Of Concept

Click to reveal PoC

Place the following test case in StabilityPool.test.js below describe("Core Functionality", function () { describe("Deposits", function () {:

it.only("should showcase inconsistent ratio between rToken and DEToken", async function () {
await crvusd.mint(user1.address, ethers.parseEther("100"));
​
await crvusd
.connect(user1)
.approve(raacNFT.target, ethers.parseEther("100"));
await raacHousePrices.setOracle(owner.address);
await raacHousePrices.setHousePrice(1, ethers.parseEther("100"));
await raacNFT.connect(user1).mint(1, ethers.parseEther("100"));
await raacNFT.connect(user1).approve(lendingPool.target, 1);
await lendingPool.connect(user1).depositNFT(1); // Deposit NFT for collateral
await lendingPool.connect(user1).borrow(ethers.parseEther("20")); // Borrow 20 crvUSD in order to increase indexes
​
// Some time passes
await ethers.provider.send("evm_increaseTime", [86400 * 7]); // 7 days
await ethers.provider.send("evm_mine");
await lendingPool.updateState();
​
const liquidityIndex = await lendingPool.getNormalizedIncome();
// Assert that the liquidity index is greater than 1
expect(liquidityIndex).to.be.gt(ethers.parseUnits("1", 27));
​
const initial_deTokenBalance = await deToken.balanceOf(user2.address);
const initial_rTokenBalance = await rToken.balanceOf(user2.address);
console.table({
"Initial DEToken Balance": initial_deTokenBalance.toString(),
"Initial RToken Balance": initial_rTokenBalance.toString(),
"Liquidity Index": liquidityIndex.toString(),
});
​
const depositAmount = ethers.parseEther("50");
await stabilityPool.connect(user2).deposit(depositAmount);
console.log("Deposited 50 RTokens");
const deposit_deTokenBalance = await deToken.balanceOf(user2.address);
const deposit_rTokenBalance = await rToken.balanceOf(user2.address);
console.table({
"DEToken Balance after deposit": deposit_deTokenBalance.toString(),
"RToken Balance after deposit": deposit_rTokenBalance.toString(),
"RToken Balance difference from initial": (
initial_rTokenBalance - deposit_rTokenBalance
).toString(),
});
​
// Withdrawing the same amount we deposited
await stabilityPool.connect(user2).withdraw(depositAmount);
console.log("Withdrawn 50 RTokens");
​
const withdraw_deTokenBalance = await deToken.balanceOf(user2.address);
const withdraw_rTokenBalance = await rToken.balanceOf(user2.address);
const withdraw_liqIndex = await lendingPool.getNormalizedIncome();
console.table({
"DEToken Balance after withdraw": withdraw_deTokenBalance.toString(),
"RToken Balance after withdraw": withdraw_rTokenBalance.toString(),
"RToken Balance difference from initial": (
initial_rTokenBalance - withdraw_rTokenBalance
).toString(),
"Liquidity Index": withdraw_liqIndex.toString(),
"Liquidity Index difference from initial": (
withdraw_liqIndex - liquidityIndex
).toString(),
});
​
// The liquidity index hasnt changed, but the rToken balance has decreased
// even though the user has withdrawn the same amount they deposited
// => the ratio between rToken and DEToken is inconsistent
expect(liquidityIndex).to.be.eq(withdraw_liqIndex);
expect(withdraw_rTokenBalance).to.be.lt(initial_rTokenBalance);
});

Logs:

StabilityPool
Core Functionality
Deposits
β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
β”‚ (index) β”‚ Values β”‚
β”œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”Όβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€
β”‚ Initial DEToken Balance β”‚ '0' β”‚
β”‚ Initial RToken Balance β”‚ '1100003484888783727044' β”‚
β”‚ Liquidity Index β”‚ '1000003168080712479131351338' β”‚
β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”΄β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜
Deposited 50 RTokens
β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
β”‚ (index) β”‚ Values β”‚
β”œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”Όβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€
β”‚ DEToken Balance after deposit β”‚ '50000000000000000000' β”‚
β”‚ RToken Balance after deposit β”‚ '1050003484888783727044' β”‚
β”‚ RToken Balance difference from initial β”‚ '50000000000000000000' β”‚
β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”΄β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜
Withdrawn 50 RTokens
β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
β”‚ (index) β”‚ Values β”‚
β”œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”Όβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€
β”‚ DEToken Balance after withdraw β”‚ '0' β”‚
β”‚ RToken Balance after withdraw β”‚ '1100003326485249938268' β”‚
β”‚ RToken Balance difference from initial β”‚ '158403533788776' β”‚
β”‚ Liquidity Index β”‚ '1000003168080712479131351338' β”‚
β”‚ Liquidity Index difference from initial β”‚ '0' β”‚
β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”΄β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜
βœ” should showcase inconsistent ratio between rToken and DEToken (5980ms)

Tools Used

Manual review

Recommendations

Update the following line in RToken.sol#transferFrom():

- uint256 scaledAmount = amount.rayDiv(_liquidityIndex);
+ uint256 scaledAmount = amount.rayDiv(ILendingPool(_reservePool).getNormalizedIncome());

PoC logs after the fix confirm that the issue is resolved:

β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
β”‚ (index) β”‚ Values β”‚
β”œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”Όβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€
β”‚ DEToken Balance after withdraw β”‚ '0' β”‚
β”‚ RToken Balance after withdraw β”‚ '1100003484888783727044' β”‚
β”‚ RToken Balance difference from initial β”‚ '0' β”‚
β”‚ Liquidity Index β”‚ '1000003168080712479131351338' β”‚
β”‚ Liquidity Index difference from initial β”‚ '0' β”‚
β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”΄β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜
Updates

Lead Judging Commences

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

RToken::updateLiquidityIndex() has onlyReservePool modifier but LendingPool never calls it, causing transferFrom() to use stale liquidity index values

RToken::transfer uses getNormalizedIncome() while transferFrom uses _liquidityIndex, creating inconsistent transfer amounts depending on function used

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

RToken::updateLiquidityIndex() has onlyReservePool modifier but LendingPool never calls it, causing transferFrom() to use stale liquidity index values

RToken::transfer uses getNormalizedIncome() while transferFrom uses _liquidityIndex, creating inconsistent transfer amounts depending on function used

Support

FAQs

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