Summary
The interest rate model in ReserveLibrary::calculateBorrowRate()
calculates borrowing rates using the prime rate instead of the configured optimal rate,
causing borrowers to pay double the intended interest rate when utilization reaches the optimal point.
Severity
High
Vulnerability Details
The ReserveLibrary
implements a two-slope interest rate model where rates should scale from base rate to optimal rate in the first slope (0-80% utilization), then from optimal rate to max rate in the second slope (80-100% utilization). However, the implementation incorrectly uses the prime rate instead of optimal rate when calculating the first slope.
The optimal rate is explicitly set to 50% of the prime rate during initialization, indicating an intended relationship between these rates. However, in calculateBorrowRate
, the function uses primeRate in its calculations despite taking optimalRate as a parameter:
if (utilizationRate <= optimalUtilizationRate) {
uint256 rateSlope = primeRate - baseRate;
uint256 rateIncrease = utilizationRate.rayMul(rateSlope).rayDiv(optimalUtilizationRate);
rate = baseRate + rateIncrease;
}
This causes the first slope to target the prime rate instead of the optimal rate at the optimal utilization point, creating a steeper rate curve than intended in normal market conditions.
This is proven to be unintentional behaviour from the documentation.
| optimalRate | uint256 | Rate at optimal utilization in RAY |
And also this blogpost
The protocol offers predictable borrowing rates, with interest rates soft-pegged to half of US Prime Rate
Impact
High impact:
Protocol Design Impact:
Breaks core interest rate model assumptions
Nullifies the purpose of having an optimal rate parameter
Documentation and implementation mismatch on fundamental mechanism
Economic Impact:
Likelihood
High likelihood:
Triggered on every borrow transaction when utilization approaches optimal level
Built into the core interest rate calculation
Affects all markets and all users equally
Proof of Concept
Convert the project into a foundry project, ensuring test in foundry.toml points to a designated test directory.
Comment out the forking object from the hardhat.congif.cjs
file:
networks: {
hardhat: {
mining: {
auto: true,
interval: 0
},
Copy the following test into the directory:
pragma solidity ^0.8.19;
import {Test, console} from "lib/forge-std/src/Test.sol";
import {ReserveLibrary} from "contracts/libraries/pools/ReserveLibrary.sol";
import {WadRayMath} from "contracts/libraries/math/WadRayMath.sol";
import {PercentageMath} from "contracts/libraries/math/PercentageMath.sol";
contract TestOptimalUtilizationRate is Test {
using WadRayMath for uint256;
using PercentageMath for uint256;
ReserveLibrary.ReserveData public reserve;
ReserveLibrary.ReserveRateData public rateData;
function setUp() public {
rateData.primeRate = 1000e25;
rateData.baseRate = rateData.primeRate.percentMul(25_00);
rateData.optimalRate = rateData.primeRate.percentMul(50_00);
rateData.maxRate = rateData.primeRate.percentMul(400_00);
rateData.optimalUtilizationRate = WadRayMath.RAY.percentMul(80_00);
}
function testRateAtOptimalUtilization() public {
rateData.primeRate = 1000e25;
rateData.baseRate = rateData.primeRate.percentMul(25_00);
rateData.optimalRate = rateData.primeRate.percentMul(50_00);
rateData.maxRate = rateData.primeRate.percentMul(400_00);
rateData.optimalUtilizationRate = WadRayMath.RAY.percentMul(80_00);
uint256 rate = ReserveLibrary.calculateBorrowRate(
rateData.primeRate,
rateData.baseRate,
rateData.optimalRate,
rateData.maxRate,
rateData.optimalUtilizationRate,
rateData.optimalUtilizationRate
);
console.log("Actual rate at optimal utilization:", rate / 1e25, "%");
console.log("Expected optimal rate:", rateData.optimalRate / 1e25, "%");
console.log("Prime rate:", rateData.primeRate / 1e25, "%");
assertEq(rate, rateData.primeRate);
assertEq(rate.rayDiv(rateData.optimalRate), 2 * WadRayMath.RAY);
}
}
Run
forge test
Output:
Ran 1 test for test-foundry/TestOptimalUtilizationRate.t.sol:TestOptimalUtilizationRate
[PASS] testRateAtOptimalUtilization() (gas: 22778)
Logs:
Actual rate at optimal utilization: 1000 %
Expected optimal rate: 500 %
Prime rate: 1000 %
Suite result: ok. 1 passed; 0 failed; 0 skipped; finished in 9.79ms (2.80ms CPU time)
Recommendations
Modify the rate calculation in ReserveLibrary.sol to use optimalRate instead of primeRate:
function calculateBorrowRate(
uint256 primeRate,
uint256 baseRate,
uint256 optimalRate,
uint256 maxRate,
uint256 optimalUtilizationRate,
uint256 utilizationRate
) internal pure returns (uint256) {
if (utilizationRate <= optimalUtilizationRate) {
- uint256 rateSlope = primeRate - baseRate;
+ uint256 rateSlope = optimalRate - baseRate;
uint256 rateIncrease = utilizationRate.rayMul(rateSlope).rayDiv(optimalUtilizationRate);
rate = baseRate + rateIncrease;
} else {
uint256 excessUtilization = utilizationRate - optimalUtilizationRate;
uint256 maxExcessUtilization = WadRayMath.RAY - optimalUtilizationRate;
- uint256 rateSlope = maxRate - primeRate;
+ uint256 rateSlope = maxRate - optimalRate;
uint256 rateIncrease = excessUtilization.rayMul(rateSlope).rayDiv(maxExcessUtilization);
rate = primeRate + rateIncrease;
}
return rate;
}