From d91ef582f33473274294a470c6f39ba3ec659503 Mon Sep 17 00:00:00 2001 From: dglowinski Date: Wed, 6 Aug 2025 16:45:40 +0200 Subject: [PATCH] add fixed cyclical binary irm --- src/IRM/IRMFixedCyclicalBinary.sol | 72 +++++++++++ .../EulerFixedCyclicalBinaryIRMFactory.sol | 46 +++++++ .../IEulerFixedCyclicalBinaryIRMFactory.sol | 26 ++++ test/IRM/IRMFixedCyclicalBinary.t.sol | 114 ++++++++++++++++++ 4 files changed, 258 insertions(+) create mode 100644 src/IRM/IRMFixedCyclicalBinary.sol create mode 100644 src/IRMFactory/EulerFixedCyclicalBinaryIRMFactory.sol create mode 100644 src/IRMFactory/interfaces/IEulerFixedCyclicalBinaryIRMFactory.sol create mode 100644 test/IRM/IRMFixedCyclicalBinary.t.sol diff --git a/src/IRM/IRMFixedCyclicalBinary.sol b/src/IRM/IRMFixedCyclicalBinary.sol new file mode 100644 index 00000000..d6aae2cb --- /dev/null +++ b/src/IRM/IRMFixedCyclicalBinary.sol @@ -0,0 +1,72 @@ +// SPDX-License-Identifier: GPL-2.0-or-later + +pragma solidity ^0.8.0; + +import {IIRM} from "evk/InterestRateModels/IIRM.sol"; + +/// @title IRMFixedCyclicalBinary +/// @custom:security-contact security@euler.xyz +/// @author Euler Labs (https://www.eulerlabs.com/) +/// @notice Implementation of an interest rate model, where interest rate cycles between two fixed values, +contract IRMFixedCyclicalBinary is IIRM { + /// @notice Interest rate applied during the first part of the cycle + uint256 public immutable primaryRate; + /// @notice Interest rate applied during the second part of the cycle + uint256 public immutable secondaryRate; + /// @notice Duration of the primary part of the cycle in seconds + uint256 public immutable primaryDuration; + /// @notice Duration of the secondary part of the cycle in seconds + uint256 public immutable secondaryDuration; + /// @notice Timestamp of the start of the first cycle + uint256 public immutable startTimestamp; + + /// @notice Error thrown when start timestamp is in the future + error BadStartTimestamp(); + /// @notice Error thrown when duration of either primary or secondary part of the cycle is zero + /// or when the whole cycle duration overflows uint + error BadDuration(); + + /// @notice Creates a fixed cyclical binary interest rate model + /// @param primaryRate_ Interest rate applied during the first part of the cycle + /// @param secondaryRate_ Interest rate applied during the second part of the cycle + /// @param primaryDuration_ Duration of the primary part of the cycle in seconds + /// @param secondaryDuration_ Duration of the secondary part of the cycle in seconds + /// @param startTimestamp_ Timestamp of the start of the first cycle + constructor( + uint256 primaryRate_, + uint256 secondaryRate_, + uint256 primaryDuration_, + uint256 secondaryDuration_, + uint256 startTimestamp_ + ) { + if (startTimestamp_ > block.timestamp) revert BadStartTimestamp(); + if ( + primaryDuration_ == 0 || secondaryDuration_ == 0 + || (type(uint256).max - primaryDuration_ < secondaryDuration_) + ) revert BadDuration(); + + primaryRate = primaryRate_; + secondaryRate = secondaryRate_; + primaryDuration = primaryDuration_; + secondaryDuration = secondaryDuration_; + startTimestamp = startTimestamp_; + } + + /// @inheritdoc IIRM + function computeInterestRate(address vault, uint256, uint256) external view override returns (uint256) { + if (msg.sender != vault) revert E_IRMUpdateUnauthorized(); + + return computeInterestRateInternal(); + } + + /// @inheritdoc IIRM + function computeInterestRateView(address, uint256, uint256) external view override returns (uint256) { + return computeInterestRateInternal(); + } + + function computeInterestRateInternal() internal view returns (uint256) { + uint256 timeSinceStart = block.timestamp - startTimestamp; + + return timeSinceStart % (primaryDuration + secondaryDuration) <= primaryDuration ? primaryRate : secondaryRate; + } +} diff --git a/src/IRMFactory/EulerFixedCyclicalBinaryIRMFactory.sol b/src/IRMFactory/EulerFixedCyclicalBinaryIRMFactory.sol new file mode 100644 index 00000000..3093e521 --- /dev/null +++ b/src/IRMFactory/EulerFixedCyclicalBinaryIRMFactory.sol @@ -0,0 +1,46 @@ +// SPDX-License-Identifier: GPL-2.0-or-later + +pragma solidity ^0.8.0; + +import {BaseFactory} from "../BaseFactory/BaseFactory.sol"; +import {IRMFixedCyclicalBinary} from "../IRM/IRMFixedCyclicalBinary.sol"; +import {IEulerFixedCyclicalBinaryIRMFactory} from "./interfaces/IEulerFixedCyclicalBinaryIRMFactory.sol"; + +/// @title EulerFixedCyclicalBinaryIRMFactory +/// @custom:security-contact security@euler.xyz +/// @author Euler Labs (https://www.eulerlabs.com/) +/// @notice A minimal factory for Fixed Cyclical Binary IRMs. +contract EulerFixedCyclicalBinaryIRMFactory is BaseFactory, IEulerFixedCyclicalBinaryIRMFactory { + // corresponds to 1000% APY + uint256 internal constant MAX_ALLOWED_INTEREST_RATE = 75986279153383989049; + + /// @notice Error thrown when the computed interest rate exceeds the maximum allowed limit. + error IRMFactory_ExcessiveInterestRate(); + + /// @notice Deploys a new IRMFixedCyclicalBinary. + /// @param primaryRate Interest rate applied during the first part of the cycle + /// @param secondaryRate Interest rate applied during the second part of the cycle + /// @param primaryDuration Duration of the primary part of the cycle in seconds + /// @param secondaryDuration Duration of the secondary part of the cycle in seconds + /// @param startTimestamp Timestamp of the start of the first cycle + /// @return The deployment address. + function deploy( + uint256 primaryRate, + uint256 secondaryRate, + uint256 primaryDuration, + uint256 secondaryDuration, + uint256 startTimestamp + ) external override returns (address) { + if (primaryRate > MAX_ALLOWED_INTEREST_RATE || secondaryRate > MAX_ALLOWED_INTEREST_RATE) { + revert IRMFactory_ExcessiveInterestRate(); + } + + IRMFixedCyclicalBinary irm = + new IRMFixedCyclicalBinary(primaryRate, secondaryRate, primaryDuration, secondaryDuration, startTimestamp); + + deploymentInfo[address(irm)] = DeploymentInfo(msg.sender, uint96(block.timestamp)); + deployments.push(address(irm)); + emit ContractDeployed(address(irm), msg.sender, block.timestamp); + return address(irm); + } +} diff --git a/src/IRMFactory/interfaces/IEulerFixedCyclicalBinaryIRMFactory.sol b/src/IRMFactory/interfaces/IEulerFixedCyclicalBinaryIRMFactory.sol new file mode 100644 index 00000000..6aa9fc42 --- /dev/null +++ b/src/IRMFactory/interfaces/IEulerFixedCyclicalBinaryIRMFactory.sol @@ -0,0 +1,26 @@ +// SPDX-License-Identifier: GPL-2.0-or-later + +pragma solidity >=0.8.0; + +import {IFactory} from "../../BaseFactory/interfaces/IFactory.sol"; + +/// @title IEulerFixedCyclicalBinaryRMFactory +/// @custom:security-contact security@euler.xyz +/// @author Euler Labs (https://www.eulerlabs.com/) +/// @notice A minimal factory for EulerFixedCyclicalBinaryIRM. +interface IEulerFixedCyclicalBinaryIRMFactory is IFactory { + /// @notice Deploys a new IRMFixedCyclicalBinary. + /// @param primaryRate Interest rate applied during the first part of the cycle + /// @param secondaryRate Interest rate applied during the second part of the cycle + /// @param primaryDuration Duration of the primary part of the cycle in seconds + /// @param secondaryDuration Duration of the secondary part of the cycle in seconds + /// @param startTimestamp Timestamp of the start of the first cycle + /// @return The deployment address. + function deploy( + uint256 primaryRate, + uint256 secondaryRate, + uint256 primaryDuration, + uint256 secondaryDuration, + uint256 startTimestamp + ) external returns (address); +} diff --git a/test/IRM/IRMFixedCyclicalBinary.t.sol b/test/IRM/IRMFixedCyclicalBinary.t.sol new file mode 100644 index 00000000..e3dc0705 --- /dev/null +++ b/test/IRM/IRMFixedCyclicalBinary.t.sol @@ -0,0 +1,114 @@ +// SPDX-License-Identifier: GPL-2.0-or-later +pragma solidity ^0.8.13; + +import {Test} from "forge-std/Test.sol"; +import {IIRM} from "evk/InterestRateModels/IIRM.sol"; +import {IRMFixedCyclicalBinary} from "../../src/IRM/IRMFixedCyclicalBinary.sol"; +import {EulerFixedCyclicalBinaryIRMFactory} from "../../src/IRMFactory/EulerFixedCyclicalBinaryIRMFactory.sol"; +import {MathTesting} from "../utils/MathTesting.sol"; + +import {console} from "forge-std/console.sol"; + +contract IRMFixedCyclicalBinaryTest is Test, MathTesting { + IRMFixedCyclicalBinary irm; + EulerFixedCyclicalBinaryIRMFactory factory; + + uint256 constant PRIMARY_RATE = 1; + uint256 constant SECONDARY_RATE = 2; + uint256 constant PRIMARY_DURATION = 28 days; + uint256 constant SECONDARY_DURATION = 2 days; + + // corresponds to 1000% APY + uint256 internal constant MAX_ALLOWED_INTEREST_RATE = 75986279153383989049; + + function setUp() public { + irm = new IRMFixedCyclicalBinary( + PRIMARY_RATE, SECONDARY_RATE, PRIMARY_DURATION, SECONDARY_DURATION, block.timestamp + ); + + factory = new EulerFixedCyclicalBinaryIRMFactory(); + } + + function test_OnlyVaultCanMutateIRMState() public { + vm.expectRevert(IIRM.E_IRMUpdateUnauthorized.selector); + irm.computeInterestRate(address(1234), 5, 6); + + vm.prank(address(1234)); + irm.computeInterestRate(address(1234), 5, 6); + } + + function test_ExpectRevertStartTimeInFuture() public { + vm.expectRevert(IRMFixedCyclicalBinary.BadStartTimestamp.selector); + new IRMFixedCyclicalBinary( + PRIMARY_RATE, SECONDARY_RATE, PRIMARY_DURATION, SECONDARY_DURATION, block.timestamp + 1 + ); + } + + function test_ExpectRevertPrimaryDurationZero() public { + vm.expectRevert(IRMFixedCyclicalBinary.BadDuration.selector); + new IRMFixedCyclicalBinary(PRIMARY_RATE, SECONDARY_RATE, 0, SECONDARY_DURATION, block.timestamp); + } + + function test_ExpectRevertSecondaryDurationZero() public { + vm.expectRevert(IRMFixedCyclicalBinary.BadDuration.selector); + new IRMFixedCyclicalBinary(PRIMARY_RATE, SECONDARY_RATE, PRIMARY_DURATION, 0, block.timestamp); + } + + function test_ExpectRevertCycleDurationOverflows() public { + vm.expectRevert(IRMFixedCyclicalBinary.BadDuration.selector); + new IRMFixedCyclicalBinary(PRIMARY_RATE, SECONDARY_RATE, type(uint256).max - 1, 2, block.timestamp); + } + + function test_RateAtDeployment() public view { + assertEq(getIr(), PRIMARY_RATE); + } + + function test_RateAtEndOfPrimaryPeriod() public { + vm.warp(block.timestamp + PRIMARY_DURATION - 1); + assertEq(getIr(), PRIMARY_RATE); + + vm.warp(block.timestamp + 1); + assertEq(getIr(), PRIMARY_RATE); + + vm.warp(block.timestamp + 1); + assertEq(getIr(), SECONDARY_RATE); + } + + function test_RateAtEndOfCycle() public { + vm.warp(block.timestamp + PRIMARY_DURATION + SECONDARY_DURATION - 1); + assertEq(getIr(), SECONDARY_RATE); + + vm.warp(block.timestamp + 1); + assertEq(getIr(), PRIMARY_RATE); + } + + function test_CycleRates(uint256 timeElapsed, uint256 primaryDuration, uint256 secondaryDuration) public { + timeElapsed = bound(timeElapsed, 0, 1000 days); + primaryDuration = bound(primaryDuration, 1, 100 days); + secondaryDuration = bound(secondaryDuration, 1, 100 days); + + irm = + IRMFixedCyclicalBinary(factory.deploy(PRIMARY_RATE, SECONDARY_RATE, primaryDuration, secondaryDuration, 0)); + + vm.warp(timeElapsed); + + uint cyclesElapsed = timeElapsed / (primaryDuration + secondaryDuration); + + if (timeElapsed - cyclesElapsed * (primaryDuration + secondaryDuration) <= primaryDuration) { + assertEq(getIr(), PRIMARY_RATE); + } else { + assertEq(getIr(), SECONDARY_RATE); + } + } + + function test_ExpectRevertFactoryRateTooHigh() public { + vm.expectRevert(EulerFixedCyclicalBinaryIRMFactory.IRMFactory_ExcessiveInterestRate.selector); + factory.deploy(MAX_ALLOWED_INTEREST_RATE + 1, SECONDARY_RATE, PRIMARY_DURATION, SECONDARY_DURATION, 0); + vm.expectRevert(EulerFixedCyclicalBinaryIRMFactory.IRMFactory_ExcessiveInterestRate.selector); + factory.deploy(PRIMARY_RATE, MAX_ALLOWED_INTEREST_RATE + 1, PRIMARY_DURATION, SECONDARY_DURATION, 0); + } + + function getIr() private view returns (uint256) { + return irm.computeInterestRateView(address(1), 0, 0); + } +}