diff --git a/src/SpeedJumpIrm.sol b/src/AdaptiveCurveIrm.sol similarity index 90% rename from src/SpeedJumpIrm.sol rename to src/AdaptiveCurveIrm.sol index 3ea03046..9785dad4 100644 --- a/src/SpeedJumpIrm.sol +++ b/src/AdaptiveCurveIrm.sol @@ -6,6 +6,8 @@ import {IIrm} from "../lib/morpho-blue/src/interfaces/IIrm.sol"; import {UtilsLib} from "./libraries/UtilsLib.sol"; import {ErrorsLib} from "./libraries/ErrorsLib.sol"; import {MathLib, WAD_INT as WAD} from "./libraries/MathLib.sol"; +import {ExpLib} from "./libraries/adaptive-curve/ExpLib.sol"; +import {ConstantsLib} from "./libraries/adaptive-curve/ConstantsLib.sol"; import {MarketParamsLib} from "../lib/morpho-blue/src/libraries/MarketParamsLib.sol"; import {Id, MarketParams, Market} from "../lib/morpho-blue/src/interfaces/IMorpho.sol"; import {MathLib as MorphoMathLib} from "../lib/morpho-blue/src/libraries/MathLib.sol"; @@ -25,25 +27,25 @@ contract AdaptiveCurveIrm is IIrm { /// @notice Emitted when a borrow rate is updated. event BorrowRateUpdate(Id indexed id, uint256 avgBorrowRate, uint256 rateAtTarget); - /* CONSTANTS */ + /* IMMUTABLES */ - /// @notice Maximum rate at target per second (scaled by WAD) (1B% APR). - int256 public constant MAX_RATE_AT_TARGET = int256(0.01e9 ether) / 365 days; - /// @notice Mininimum rate at target per second (scaled by WAD) (0.1% APR). - int256 public constant MIN_RATE_AT_TARGET = int256(0.001 ether) / 365 days; /// @notice Address of Morpho. address public immutable MORPHO; + /// @notice Curve steepness (scaled by WAD). - /// @dev Verified to be greater than 1 at construction. + /// @dev Verified to be inside the expected range at construction. int256 public immutable CURVE_STEEPNESS; + /// @notice Adjustment speed (scaled by WAD). /// @dev The speed is per second, so the rate moves at a speed of ADJUSTMENT_SPEED * err each second (while being - /// continuously compounded). A typical value for the ADJUSTMENT_SPEED would be 10 ethers / 365 days. - /// @dev Verified to be non-negative at construction. + /// continuously compounded). A typical value for the ADJUSTMENT_SPEED would be 10 ether / 365 days. + /// @dev Verified to be inside the expected range at construction. int256 public immutable ADJUSTMENT_SPEED; + /// @notice Target utilization (scaled by WAD). /// @dev Verified to be strictly between 0 and 1 at construction. int256 public immutable TARGET_UTILIZATION; + /// @notice Initial rate at target per second (scaled by WAD). /// @dev Verified to be between MIN_RATE_AT_TARGET and MAX_RATE_AT_TARGET at contruction. int256 public immutable INITIAL_RATE_AT_TARGET; @@ -71,11 +73,13 @@ contract AdaptiveCurveIrm is IIrm { ) { require(morpho != address(0), ErrorsLib.ZERO_ADDRESS); require(curveSteepness >= WAD, ErrorsLib.INPUT_TOO_SMALL); + require(curveSteepness <= ConstantsLib.MAX_CURVE_STEEPNESS, ErrorsLib.INPUT_TOO_LARGE); require(adjustmentSpeed >= 0, ErrorsLib.INPUT_TOO_SMALL); + require(adjustmentSpeed <= ConstantsLib.MAX_ADJUSTMENT_SPEED, ErrorsLib.INPUT_TOO_LARGE); require(targetUtilization < WAD, ErrorsLib.INPUT_TOO_LARGE); require(targetUtilization > 0, ErrorsLib.ZERO_INPUT); - require(initialRateAtTarget >= MIN_RATE_AT_TARGET, ErrorsLib.INPUT_TOO_SMALL); - require(initialRateAtTarget <= MAX_RATE_AT_TARGET, ErrorsLib.INPUT_TOO_LARGE); + require(initialRateAtTarget >= ConstantsLib.MIN_RATE_AT_TARGET, ErrorsLib.INPUT_TOO_SMALL); + require(initialRateAtTarget <= ConstantsLib.MAX_RATE_AT_TARGET, ErrorsLib.INPUT_TOO_LARGE); MORPHO = morpho; CURVE_STEEPNESS = curveSteepness; @@ -179,6 +183,8 @@ contract AdaptiveCurveIrm is IIrm { /// The formula is: max(min(startRateAtTarget * exp(linearAdaptation), maxRateAtTarget), minRateAtTarget). function _newRateAtTarget(int256 startRateAtTarget, int256 linearAdaptation) private pure returns (int256) { // Non negative because MIN_RATE_AT_TARGET > 0. - return startRateAtTarget.wMulDown(MathLib.wExp(linearAdaptation)).bound(MIN_RATE_AT_TARGET, MAX_RATE_AT_TARGET); + return startRateAtTarget.wMulDown(ExpLib.wExp(linearAdaptation)).bound( + ConstantsLib.MIN_RATE_AT_TARGET, ConstantsLib.MAX_RATE_AT_TARGET + ); } } diff --git a/src/libraries/MathLib.sol b/src/libraries/MathLib.sol index 120c7756..fb0c7000 100644 --- a/src/libraries/MathLib.sol +++ b/src/libraries/MathLib.sol @@ -8,51 +8,8 @@ int256 constant WAD_INT = int256(WAD); /// @title MathLib /// @author Morpho Labs /// @custom:contact security@morpho.org -/// @notice Library to manage fixed-point arithmetic and approximate the exponential function. +/// @notice Library to manage fixed-point arithmetic on signed integers. library MathLib { - using MathLib for uint128; - using MathLib for uint256; - using {wDivDown} for int256; - - /// @dev ln(2). - int256 internal constant LN_2_INT = 0.693147180559945309 ether; - - /// @dev ln(1e-18). - int256 internal constant LN_WEI_INT = -41.446531673892822312 ether; - - /// @dev Above this bound, `wExp` is clipped to avoid overflowing when multiplied with 1 ether. - /// @dev This upper bound corresponds to: ln(type(int256).max / 1e36) (scaled by WAD, floored). - int256 internal constant WEXP_UPPER_BOUND = 93.859467695000404319 ether; - - /// @dev The value of wExp(`WEXP_UPPER_BOUND`). - int256 internal constant WEXP_UPPER_VALUE = 57716089161558943862588783571184261698504.523000224082296832 ether; - - /// @dev Returns an approximation of exp. - function wExp(int256 x) internal pure returns (int256) { - unchecked { - // If x < ln(1e-18) then exp(x) < 1e-18 so it is rounded to zero. - if (x < LN_WEI_INT) return 0; - // `wExp` is clipped to avoid overflowing when multiplied with 1 ether. - if (x >= WEXP_UPPER_BOUND) return WEXP_UPPER_VALUE; - - // Decompose x as x = q * ln(2) + r with q an integer and -ln(2)/2 <= r <= ln(2)/2. - // q = x / ln(2) rounded half toward zero. - int256 roundingAdjustment = (x < 0) ? -(LN_2_INT / 2) : (LN_2_INT / 2); - // Safe unchecked because x is bounded. - int256 q = (x + roundingAdjustment) / LN_2_INT; - // Safe unchecked because |q * ln(2) - x| <= ln(2)/2. - int256 r = x - q * LN_2_INT; - - // Compute e^r with a 2nd-order Taylor polynomial. - // Safe unchecked because |r| < 1e18. - int256 expR = WAD_INT + r + (r * r) / WAD_INT / 2; - - // Return e^x = 2^q * e^r. - if (q >= 0) return expR << uint256(q); - else return expR >> uint256(-q); - } - } - function wMulDown(int256 a, int256 b) internal pure returns (int256) { return a * b / WAD_INT; } diff --git a/src/libraries/adaptive-curve/ConstantsLib.sol b/src/libraries/adaptive-curve/ConstantsLib.sol new file mode 100644 index 00000000..3166d1c4 --- /dev/null +++ b/src/libraries/adaptive-curve/ConstantsLib.sol @@ -0,0 +1,19 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.0; + +/// @title ConstantsLib +/// @author Morpho Labs +/// @custom:contact security@morpho.org +library ConstantsLib { + /// @notice Maximum rate at target per second (scaled by WAD) (1B% APR). + int256 internal constant MAX_RATE_AT_TARGET = int256(0.01e9 ether) / 365 days; + + /// @notice Mininimum rate at target per second (scaled by WAD) (0.1% APR). + int256 internal constant MIN_RATE_AT_TARGET = int256(0.001 ether) / 365 days; + + /// @notice Maximum curve steepness allowed (scaled by WAD). + int256 internal constant MAX_CURVE_STEEPNESS = 100 ether; + + /// @notice Maximum adjustment speed allowed (scaled by WAD). + int256 internal constant MAX_ADJUSTMENT_SPEED = int256(1_000 ether) / 365 days; +} diff --git a/src/libraries/adaptive-curve/ExpLib.sol b/src/libraries/adaptive-curve/ExpLib.sol new file mode 100644 index 00000000..27110d31 --- /dev/null +++ b/src/libraries/adaptive-curve/ExpLib.sol @@ -0,0 +1,49 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.0; + +import {WAD_INT} from "../MathLib.sol"; + +/// @title ExpLib +/// @author Morpho Labs +/// @custom:contact security@morpho.org +/// @notice Library to approximate the exponential function. +library ExpLib { + /// @dev ln(2). + int256 internal constant LN_2_INT = 0.693147180559945309 ether; + + /// @dev ln(1e-18). + int256 internal constant LN_WEI_INT = -41.446531673892822312 ether; + + /// @dev Above this bound, `wExp` is clipped to avoid overflowing when multiplied with 1 ether. + /// @dev This upper bound corresponds to: ln(type(int256).max / 1e36) (scaled by WAD, floored). + int256 internal constant WEXP_UPPER_BOUND = 93.859467695000404319 ether; + + /// @dev The value of wExp(`WEXP_UPPER_BOUND`). + int256 internal constant WEXP_UPPER_VALUE = 57716089161558943949701069502944508345128.422502756744429568 ether; + + /// @dev Returns an approximation of exp. + function wExp(int256 x) internal pure returns (int256) { + unchecked { + // If x < ln(1e-18) then exp(x) < 1e-18 so it is rounded to zero. + if (x < LN_WEI_INT) return 0; + // `wExp` is clipped to avoid overflowing when multiplied with 1 ether. + if (x >= WEXP_UPPER_BOUND) return WEXP_UPPER_VALUE; + + // Decompose x as x = q * ln(2) + r with q an integer and -ln(2)/2 <= r <= ln(2)/2. + // q = x / ln(2) rounded half toward zero. + int256 roundingAdjustment = (x < 0) ? -(LN_2_INT / 2) : (LN_2_INT / 2); + // Safe unchecked because x is bounded. + int256 q = (x + roundingAdjustment) / LN_2_INT; + // Safe unchecked because |q * ln(2) - x| <= ln(2)/2. + int256 r = x - q * LN_2_INT; + + // Compute e^r with a 2nd-order Taylor polynomial. + // Safe unchecked because |r| < 1e18. + int256 expR = WAD_INT + r + (r * r) / WAD_INT / 2; + + // Return e^x = 2^q * e^r. + if (q >= 0) return expR << uint256(q); + else return expR >> uint256(-q); + } + } +} diff --git a/test/SpeedJumpIrmTest.sol b/test/AdaptiveCurveIrmTest.sol similarity index 95% rename from test/SpeedJumpIrmTest.sol rename to test/AdaptiveCurveIrmTest.sol index 1637a196..b08d4ec0 100644 --- a/test/SpeedJumpIrmTest.sol +++ b/test/AdaptiveCurveIrmTest.sol @@ -1,12 +1,11 @@ // SPDX-License-Identifier: MIT pragma solidity ^0.8.0; -import "../src/SpeedJumpIrm.sol"; +import "../src/AdaptiveCurveIrm.sol"; import "../lib/forge-std/src/Test.sol"; contract AdaptiveCurveIrmTest is Test { - using MathLib for int256; using MathLib for int256; using MathLib for uint256; using UtilsLib for int256; @@ -312,10 +311,6 @@ contract AdaptiveCurveIrmTest is Test { assertApproxEqRel(irm.rateAtTarget(marketParams.id()), expectedRateAtTarget, 0.001 ether, "rateAtTarget"); } - function testWExpWMulDownMaxRate() public view { - MathLib.wExp(MathLib.WEXP_UPPER_BOUND).wMulDown(irm.MAX_RATE_AT_TARGET()); - } - /* HANDLERS */ function handleBorrowRate(uint256 totalSupplyAssets, uint256 totalBorrowAssets, uint256 elapsed) external { @@ -339,8 +334,12 @@ contract AdaptiveCurveIrmTest is Test { market.totalBorrowAssets = 9 ether; market.totalSupplyAssets = 10 ether; - assertGe(irm.borrowRateView(marketParams, market), uint256(irm.MIN_RATE_AT_TARGET().wDivDown(CURVE_STEEPNESS))); - assertGe(irm.borrowRate(marketParams, market), uint256(irm.MIN_RATE_AT_TARGET().wDivDown(CURVE_STEEPNESS))); + assertGe( + irm.borrowRateView(marketParams, market), uint256(ConstantsLib.MIN_RATE_AT_TARGET.wDivDown(CURVE_STEEPNESS)) + ); + assertGe( + irm.borrowRate(marketParams, market), uint256(ConstantsLib.MIN_RATE_AT_TARGET.wDivDown(CURVE_STEEPNESS)) + ); } function invariantLeMaxRateAtTarget() public { @@ -348,8 +347,12 @@ contract AdaptiveCurveIrmTest is Test { market.totalBorrowAssets = 9 ether; market.totalSupplyAssets = 10 ether; - assertLe(irm.borrowRateView(marketParams, market), uint256(irm.MAX_RATE_AT_TARGET().wMulDown(CURVE_STEEPNESS))); - assertLe(irm.borrowRate(marketParams, market), uint256(irm.MAX_RATE_AT_TARGET().wMulDown(CURVE_STEEPNESS))); + assertLe( + irm.borrowRateView(marketParams, market), uint256(ConstantsLib.MAX_RATE_AT_TARGET.wMulDown(CURVE_STEEPNESS)) + ); + assertLe( + irm.borrowRate(marketParams, market), uint256(ConstantsLib.MAX_RATE_AT_TARGET.wMulDown(CURVE_STEEPNESS)) + ); } /* HELPERS */ @@ -359,9 +362,11 @@ contract AdaptiveCurveIrmTest is Test { int256 speed = ADJUSTMENT_SPEED.wMulDown(_err(market)); uint256 elapsed = (rateAtTarget > 0) ? block.timestamp - market.lastUpdate : 0; int256 linearAdaptation = speed * int256(elapsed); - int256 adaptationMultiplier = MathLib.wExp(linearAdaptation); + int256 adaptationMultiplier = ExpLib.wExp(linearAdaptation); return (rateAtTarget > 0) - ? rateAtTarget.wMulDown(adaptationMultiplier).bound(irm.MIN_RATE_AT_TARGET(), irm.MAX_RATE_AT_TARGET()) + ? rateAtTarget.wMulDown(adaptationMultiplier).bound( + ConstantsLib.MIN_RATE_AT_TARGET, ConstantsLib.MAX_RATE_AT_TARGET + ) : INITIAL_RATE_AT_TARGET; } diff --git a/test/ExpLibTest.sol b/test/ExpLibTest.sol new file mode 100644 index 00000000..81e4cd5d --- /dev/null +++ b/test/ExpLibTest.sol @@ -0,0 +1,88 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.0; + +import {MathLib, WAD_INT} from "../src/libraries/MathLib.sol"; +import {ConstantsLib} from "../src/libraries/adaptive-curve/ConstantsLib.sol"; +import {ExpLib} from "../src/libraries/adaptive-curve/ExpLib.sol"; +import {wadExp} from "../lib/solmate/src/utils/SignedWadMath.sol"; +import {MathLib as MorphoMathLib} from "../lib/morpho-blue/src/libraries/MathLib.sol"; + +import "../lib/forge-std/src/Test.sol"; + +contract ExpLibTest is Test { + using MathLib for int256; + using MorphoMathLib for uint256; + + /// @dev ln(1e-9) truncated at 2 decimal places. + int256 internal constant LN_GWEI_INT = -20.72 ether; + + function testWExp(int256 x) public { + // Bounded to have sub-1% relative error. + x = bound(x, LN_GWEI_INT, ExpLib.WEXP_UPPER_BOUND); + + assertApproxEqRel(ExpLib.wExp(x), wadExp(x), 0.01 ether); + } + + function testWExpSmall(int256 x) public { + x = bound(x, ExpLib.LN_WEI_INT, LN_GWEI_INT); + + assertApproxEqAbs(ExpLib.wExp(x), 0, 1e10); + } + + function testWExpTooSmall(int256 x) public { + x = bound(x, type(int256).min, ExpLib.LN_WEI_INT); + + assertEq(ExpLib.wExp(x), 0); + } + + function testWExpTooLarge(int256 x) public { + x = bound(x, ExpLib.WEXP_UPPER_BOUND, type(int256).max); + + assertEq(ExpLib.wExp(x), ExpLib.WEXP_UPPER_VALUE); + } + + function testWExpDoesNotLeadToOverflow() public { + assertGt(ExpLib.WEXP_UPPER_VALUE * 1e18, 0); + } + + function testWExpContinuousUpperBound() public { + assertApproxEqRel(ExpLib.wExp(ExpLib.WEXP_UPPER_BOUND - 1), ExpLib.WEXP_UPPER_VALUE, 1e-10 ether); + assertEq(_wExpUnbounded(ExpLib.WEXP_UPPER_BOUND), ExpLib.WEXP_UPPER_VALUE); + } + + function testWExpPositive(int256 x) public { + x = bound(x, 0, type(int256).max); + + assertGe(ExpLib.wExp(x), 1e18); + } + + function testWExpNegative(int256 x) public { + x = bound(x, type(int256).min, 0); + + assertLe(ExpLib.wExp(x), 1e18); + } + + function testWExpWMulDownMaxRate() public pure { + ExpLib.wExp(ExpLib.WEXP_UPPER_BOUND).wMulDown(ConstantsLib.MAX_RATE_AT_TARGET); + } + + function _wExpUnbounded(int256 x) internal pure returns (int256) { + unchecked { + // Decompose x as x = q * ln(2) + r with q an integer and -ln(2)/2 <= r <= ln(2)/2. + // q = x / ln(2) rounded half toward zero. + int256 roundingAdjustment = (x < 0) ? -(ExpLib.LN_2_INT / 2) : (ExpLib.LN_2_INT / 2); + // Safe unchecked because x is bounded. + int256 q = (x + roundingAdjustment) / ExpLib.LN_2_INT; + // Safe unchecked because |q * ln(2) - x| <= ln(2)/2. + int256 r = x - q * ExpLib.LN_2_INT; + + // Compute e^r with a 2nd-order Taylor polynomial. + // Safe unchecked because |r| < 1e18. + int256 expR = WAD_INT + r + (r * r) / WAD_INT / 2; + + // Return e^x = 2^q * e^r. + if (q >= 0) return expR << uint256(q); + else return expR >> uint256(-q); + } + } +} diff --git a/test/MathLibTest.sol b/test/MathLibTest.sol deleted file mode 100644 index 4f11dc2b..00000000 --- a/test/MathLibTest.sol +++ /dev/null @@ -1,62 +0,0 @@ -// SPDX-License-Identifier: MIT -pragma solidity ^0.8.0; - -import {MathLib} from "../src/libraries/MathLib.sol"; -import {ErrorsLib} from "../src/libraries/ErrorsLib.sol"; -import {wadExp} from "../lib/solmate/src/utils/SignedWadMath.sol"; - -import {AdaptiveCurveIrm} from "../src/SpeedJumpIrm.sol"; -import "../lib/forge-std/src/Test.sol"; - -contract MathLibTest is Test { - using MathLib for uint128; - using MathLib for uint256; - - /// @dev ln(1e-9) truncated at 2 decimal places. - int256 internal constant LN_GWEI_INT = -20.72 ether; - - function testWExp(int256 x) public { - // Bounded to have sub-1% relative error. - x = bound(x, LN_GWEI_INT, MathLib.WEXP_UPPER_BOUND); - - assertApproxEqRel(MathLib.wExp(x), wadExp(x), 0.01 ether); - } - - function testWExpSmall(int256 x) public { - x = bound(x, MathLib.LN_WEI_INT, LN_GWEI_INT); - - assertApproxEqAbs(MathLib.wExp(x), 0, 1e10); - } - - function testWExpTooSmall(int256 x) public { - x = bound(x, type(int256).min, MathLib.LN_WEI_INT); - - assertEq(MathLib.wExp(x), 0); - } - - function testWExpTooLarge(int256 x) public { - x = bound(x, MathLib.WEXP_UPPER_BOUND, type(int256).max); - - assertEq(MathLib.wExp(x), MathLib.WEXP_UPPER_VALUE); - } - - function testWExpDoesNotLeadToOverflow() public { - assertGt(MathLib.WEXP_UPPER_VALUE * 1e18, 0); - } - - function testWExpContinuousUpperBound() public { - assertApproxEqRel(MathLib.wExp(MathLib.WEXP_UPPER_BOUND - 1), MathLib.WEXP_UPPER_VALUE, 1e-10 ether); - } - - function testWExpPositive(int256 x) public { - x = bound(x, 0, type(int256).max); - - assertGe(MathLib.wExp(x), 1e18); - } - - function testWExpNegative(int256 x) public { - x = bound(x, type(int256).min, 0); - - assertLe(MathLib.wExp(x), 1e18); - } -}