Core Contracts

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

Missing Asset Transfer to RToken Contract in `LendingPool._withdrawFromVault` Causes Liquidity Mismatch and Failed Withdrawals

Summary

A critical asset custody flaw exists in the LendingPool's vault withdrawal mechanism (LendingPool._withdrawFromVault) where funds retrieved from Curve vaults are not properly routed to the RToken contract. This breaks the protocol's core liquidity management by creating a mismatch between internal accounting records and actual asset availability, preventing users from withdrawing their deposited funds despite correct on-paper balances. The issue affects all vault withdrawal operations and propagates to dependent systems like borrowing flow.

Vulnerability Details

The vulnerability stems from incorrect fund flow handling when withdrawing liquidity from the Curve vault in the LendingPool contract. When executing LendingPool._withdrawFromVault (LendingPool.sol#L809-L812), the protocol successfully retrieves reserve assets from the Curve vault but fails to route them to their designated holding contract (RToken).

Key issue points:

  1. Withdrawn assets remain stranded in LendingPool

  2. RToken contract maintains separate accounting for user-withdrawable assets

  3. ReserveLibrary withdrawal process assumes RToken holds all available liquidity

contract LendingPool is ILendingPool, Ownable, ReentrancyGuard, ERC721Holder, Pausable {
function _withdrawFromVault(uint256 amount) internal {
@> curveVault.withdraw(amount, address(this), msg.sender, 0, new address[](0));
totalVaultDeposits -= amount;
}
function _rebalanceLiquidity() internal {
// if curve vault is not set, do nothing
if (address(curveVault) == address(0)) {
return;
}
uint256 totalDeposits = reserve.totalLiquidity; // Total liquidity in the system
uint256 desiredBuffer = totalDeposits.percentMul(liquidityBufferRatio);
@> uint256 currentBuffer = IERC20(reserve.reserveAssetAddress).balanceOf(reserve.reserveRTokenAddress);
if (currentBuffer > desiredBuffer) {
uint256 excess = currentBuffer - desiredBuffer;
// Deposit excess into the Curve vault
_depositIntoVault(excess);
} else if (currentBuffer < desiredBuffer) {
uint256 shortage = desiredBuffer - currentBuffer;
// Withdraw shortage from the Curve vault
@> _withdrawFromVault(shortage);
}
emit LiquidityRebalanced(currentBuffer, totalVaultDeposits);
}
function _ensureLiquidity(uint256 amount) internal {
// if curve vault is not set, do nothing
if (address(curveVault) == address(0)) {
return;
}
@> uint256 availableLiquidity = IERC20(reserve.reserveAssetAddress).balanceOf(reserve.reserveRTokenAddress);
if (availableLiquidity < amount) {
uint256 requiredAmount = amount - availableLiquidity;
// Withdraw required amount from the Curve vault
@> _withdrawFromVault(requiredAmount);
}
}
}
library ReserveLibrary {
function withdraw(
ReserveData storage reserve,
ReserveRateData storage rateData,
uint256 amount,
address recipient
) internal returns (uint256 amountWithdrawn, uint256 amountScaled, uint256 amountUnderlying) {
if (amount < 1) revert InvalidAmount();
// Update the reserve interests
updateReserveInterests(reserve, rateData);
// Burn RToken from the recipient - will send underlying asset to the recipient
@> (uint256 burnedScaledAmount, uint256 newTotalSupply, uint256 amountUnderlying) = IRToken(reserve.reserveRTokenAddress).burn(
recipient, // from
recipient, // receiverOfUnderlying
amount, // amount
reserve.liquidityIndex // index
);
amountWithdrawn = burnedScaledAmount;
// Update the total liquidity and interest rates
updateInterestRatesAndLiquidity(reserve, rateData, 0, amountUnderlying);
emit Withdraw(recipient, amountUnderlying, burnedScaledAmount);
return (amountUnderlying, burnedScaledAmount, amountUnderlying);
}
}
contract RToken is ERC20, ERC20Permit, IRToken, Ownable {
function burn(
address from,
address receiverOfUnderlying,
uint256 amount,
uint256 index
) external override onlyReservePool returns (uint256, uint256, uint256) {
// ...
_burn(from, amount.toUint128());
if (receiverOfUnderlying != address(this)) {
@> IERC20(_assetAddress).safeTransfer(receiverOfUnderlying, amount);
}
// ...
}
}

This creates a critical mismatch where:

  • The protocol's internal accounting (totalVaultDeposits) shows reduced vault exposure

  • The actual withdrawable assets in RToken don't receive the retrieved funds

  • Subsequent withdrawal attempts from users will fail due to insufficient RToken balances

The root cause is the missing asset transfer step between LendingPool and RToken after vault withdrawals, breaking the protocol's asset custody chain.

Impact

This vulnerability creates severe protocol-level consequences:

  1. Failed User Withdrawals
    Users cannot redeem their RToken holdings as the RToken contract lacks sufficient underlying assets, despite the protocol's internal accounting showing available liquidity.

  2. Protocol Insolvency Risk
    Creates a dangerous imbalance where recorded assets exceed actual holdings, potentially making the protocol technically insolvent.

The vulnerability fundamentally undermines the protocol's ability to honor user withdrawals while maintaining proper accounting, representing a critical failure in core protocol functionality.

Tools Used

Manual Review

Recommendations

Modify LendingPool._withdrawFromVault to route withdrawn assets to RToken:

function _withdrawFromVault(uint256 amount) internal {
curveVault.withdraw(amount, address(this), msg.sender, 0, new address[](0));
+ IERC20(reserve.reserveAssetAddress).safeTransfer(reserve.reserveRTokenAddress, amount);
totalVaultDeposits -= amount;
}
Updates

Lead Judging Commences

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

LendingPool::_depositIntoVault and _withdrawFromVault don't transfer tokens between RToken and LendingPool, breaking Curve vault interactions

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

LendingPool::_depositIntoVault and _withdrawFromVault don't transfer tokens between RToken and LendingPool, breaking Curve vault interactions

Support

FAQs

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