diff --git a/.github/workflows/foundry.yml b/.github/workflows/foundry.yml new file mode 100644 index 0000000..38d2529 --- /dev/null +++ b/.github/workflows/foundry.yml @@ -0,0 +1,31 @@ +name: Foundry + +on: + push: + branches: + - main + pull_request: + workflow_dispatch: + +jobs: + test: + strategy: + fail-fast: true + + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: + submodules: recursive + + - name: Install Foundry + uses: foundry-rs/foundry-toolchain@v1 + + - name: Run forge fmt + run: forge fmt --check + + - name: Run forge build + run: forge build --sizes + + - name: Run forge tests + run: forge test -vvv diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml deleted file mode 100644 index 762a296..0000000 --- a/.github/workflows/test.yml +++ /dev/null @@ -1,45 +0,0 @@ -name: CI - -on: - push: - pull_request: - workflow_dispatch: - -env: - FOUNDRY_PROFILE: ci - -jobs: - check: - strategy: - fail-fast: true - - name: Foundry project - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v4 - with: - submodules: recursive - - - name: Install Foundry - uses: foundry-rs/foundry-toolchain@v1 - with: - version: nightly - - - name: Show Forge version - run: | - forge --version - - - name: Run Forge fmt - run: | - forge fmt --check - id: fmt - - - name: Run Forge build - run: | - forge build --sizes - id: build - - - name: Run Forge tests - run: | - forge test -vvv - id: test diff --git a/.gitmodules b/.gitmodules index 888d42d..792e98b 100644 --- a/.gitmodules +++ b/.gitmodules @@ -1,3 +1,9 @@ [submodule "lib/forge-std"] path = lib/forge-std url = https://github.com/foundry-rs/forge-std +[submodule "lib/morpho-blue"] + path = lib/morpho-blue + url = git@github.com:morpho-org/morpho-blue.git +[submodule "lib/solmate"] + path = lib/solmate + url = https://github.com/transmissions11/solmate diff --git a/foundry.toml b/foundry.toml index 25b918f..19eb2c7 100644 --- a/foundry.toml +++ b/foundry.toml @@ -2,5 +2,7 @@ src = "src" out = "out" libs = ["lib"] +via_ir = true +optimizer_runs = 999999 # Etherscan does not support verifying contracts with more optimization runs. # See more config options https://github.com/foundry-rs/foundry/blob/master/crates/config/README.md#all-options diff --git a/lib/forge-std b/lib/forge-std index 1714bee..1ce7535 160000 --- a/lib/forge-std +++ b/lib/forge-std @@ -1 +1 @@ -Subproject commit 1714bee72e286e73f76e320d110e0eaf5c4e649d +Subproject commit 1ce7535a517406b9aec7ea1ea27c1b41376f712c diff --git a/lib/morpho-blue b/lib/morpho-blue new file mode 160000 index 0000000..0448402 --- /dev/null +++ b/lib/morpho-blue @@ -0,0 +1 @@ +Subproject commit 0448402af51b8293ed36653de43cbee8d4d2bfda diff --git a/lib/solmate b/lib/solmate new file mode 160000 index 0000000..97bdb20 --- /dev/null +++ b/lib/solmate @@ -0,0 +1 @@ +Subproject commit 97bdb2003b70382996a79a406813f76417b1cf90 diff --git a/script/Counter.s.sol b/script/Counter.s.sol deleted file mode 100644 index cdc1fe9..0000000 --- a/script/Counter.s.sol +++ /dev/null @@ -1,19 +0,0 @@ -// SPDX-License-Identifier: UNLICENSED -pragma solidity ^0.8.13; - -import {Script, console} from "forge-std/Script.sol"; -import {Counter} from "../src/Counter.sol"; - -contract CounterScript is Script { - Counter public counter; - - function setUp() public {} - - function run() public { - vm.startBroadcast(); - - counter = new Counter(); - - vm.stopBroadcast(); - } -} diff --git a/src/Counter.sol b/src/Counter.sol deleted file mode 100644 index aded799..0000000 --- a/src/Counter.sol +++ /dev/null @@ -1,14 +0,0 @@ -// SPDX-License-Identifier: UNLICENSED -pragma solidity ^0.8.13; - -contract Counter { - uint256 public number; - - function setNumber(uint256 newNumber) public { - number = newNumber; - } - - function increment() public { - number++; - } -} diff --git a/src/PreLiquidation.sol b/src/PreLiquidation.sol new file mode 100644 index 0000000..11e74f7 --- /dev/null +++ b/src/PreLiquidation.sol @@ -0,0 +1,164 @@ +// SPDX-License-Identifier: GPL-2.0-or-later +pragma solidity 0.8.27; + +import {Id, MarketParams, IMorpho, Position, Market} from "../lib/morpho-blue/src/interfaces/IMorpho.sol"; +import {IOracle} from "../lib/morpho-blue/src/interfaces/IOracle.sol"; +import {UtilsLib} from "../lib/morpho-blue/src/libraries/UtilsLib.sol"; +import {ORACLE_PRICE_SCALE} from "../lib/morpho-blue/src/libraries/ConstantsLib.sol"; +import {WAD, MathLib} from "../lib/morpho-blue/src/libraries/MathLib.sol"; +import {SharesMathLib} from "../lib/morpho-blue/src/libraries/SharesMathLib.sol"; +import {SafeTransferLib} from "../lib/solmate/src/utils/SafeTransferLib.sol"; +import {ERC20} from "../lib/solmate/src/tokens/ERC20.sol"; +import {EventsLib} from "./libraries/EventsLib.sol"; +import {ErrorsLib} from "./libraries/ErrorsLib.sol"; +import {IPreLiquidationCallback} from "./interfaces/IPreLiquidationCallback.sol"; +import {IPreLiquidation, PreLiquidationParams} from "./interfaces/IPreLiquidation.sol"; +import {IMorphoRepayCallback} from "../lib/morpho-blue/src/interfaces/IMorphoCallbacks.sol"; + +/// @title PreLiquidation +/// @author Morpho Labs +/// @custom:contact security@morpho.org +/// @notice The Fixed LIF, Fixed CF pre-liquidation contract for Morpho. +contract PreLiquidation is IPreLiquidation, IMorphoRepayCallback { + using SharesMathLib for uint256; + using MathLib for uint256; + using SafeTransferLib for ERC20; + + /* IMMUTABLE */ + + /// @notice Morpho's address. + IMorpho public immutable MORPHO; + /// @notice The id of the Morpho Market specific to the PreLiquidation contract. + Id public immutable ID; + + // Market parameters + address internal immutable LOAN_TOKEN; + address internal immutable COLLATERAL_TOKEN; + address internal immutable ORACLE; + address internal immutable IRM; + uint256 internal immutable LLTV; + + // Pre-liquidation parameters + uint256 internal immutable PRE_LLTV; + uint256 internal immutable CLOSE_FACTOR; + uint256 internal immutable PRE_LIQUIDATION_INCENTIVE_FACTOR; + address internal immutable PRE_LIQUIDATION_ORACLE; + + /// @notice The Morpho market parameters specific to the PreLiquidation contract. + function marketParams() public view returns (MarketParams memory) { + return MarketParams({ + loanToken: LOAN_TOKEN, + collateralToken: COLLATERAL_TOKEN, + oracle: ORACLE, + irm: IRM, + lltv: LLTV + }); + } + + /// @notice The pre-liquidation parameters specific to the PreLiquidation contract. + function preLiquidationParams() external view returns (PreLiquidationParams memory) { + return PreLiquidationParams({ + preLltv: PRE_LLTV, + closeFactor: CLOSE_FACTOR, + preLiquidationIncentiveFactor: PRE_LIQUIDATION_INCENTIVE_FACTOR, + preLiquidationOracle: PRE_LIQUIDATION_ORACLE + }); + } + + /* CONSTRUCTOR */ + + /// @dev Initializes the PreLiquidation contract. + /// @param morpho The address of the Morpho protocol. + /// @param id The id of the Morpho market on which pre-liquidations will occur. + /// @param _preLiquidationParams The pre-liquidation parameters. + constructor(address morpho, Id id, PreLiquidationParams memory _preLiquidationParams) { + require(IMorpho(morpho).market(id).lastUpdate != 0, ErrorsLib.NonexistentMarket()); + MarketParams memory _marketParams = IMorpho(morpho).idToMarketParams(id); + require(_preLiquidationParams.preLltv < _marketParams.lltv, ErrorsLib.PreLltvTooHigh()); + require(_preLiquidationParams.closeFactor <= WAD, ErrorsLib.CloseFactorTooHigh()); + require( + _preLiquidationParams.preLiquidationIncentiveFactor >= WAD, ErrorsLib.PreLiquidationIncentiveFactorTooLow() + ); + + MORPHO = IMorpho(morpho); + + ID = id; + + LOAN_TOKEN = _marketParams.loanToken; + COLLATERAL_TOKEN = _marketParams.collateralToken; + ORACLE = _marketParams.oracle; + IRM = _marketParams.irm; + LLTV = _marketParams.lltv; + + PRE_LLTV = _preLiquidationParams.preLltv; + CLOSE_FACTOR = _preLiquidationParams.closeFactor; + PRE_LIQUIDATION_INCENTIVE_FACTOR = _preLiquidationParams.preLiquidationIncentiveFactor; + PRE_LIQUIDATION_ORACLE = _preLiquidationParams.preLiquidationOracle; + + ERC20(LOAN_TOKEN).safeApprove(morpho, type(uint256).max); + } + + /* PRE-LIQUIDATION */ + + /// @notice Pre-liquidates the given borrower on the market of this contract and with the parameters of this contract. + /// @dev Either `seizedAssets` or `repaidShares` should be zero. + /// @param borrower The owner of the position. + /// @param seizedAssets The amount of collateral to seize. + /// @param repaidShares The amount of shares to repay. + /// @param data Arbitrary data to pass to the `onPreLiquidate` callback. Pass empty data if not needed. + function preLiquidate(address borrower, uint256 seizedAssets, uint256 repaidShares, bytes calldata data) external { + require(UtilsLib.exactlyOneZero(seizedAssets, repaidShares), ErrorsLib.InconsistentInput()); + + MORPHO.accrueInterest(marketParams()); + + Market memory market = MORPHO.market(ID); + Position memory position = MORPHO.position(ID, borrower); + + uint256 collateralPrice = IOracle(PRE_LIQUIDATION_ORACLE).price(); + uint256 borrowed = uint256(position.borrowShares).toAssetsUp(market.totalBorrowAssets, market.totalBorrowShares); + uint256 borrowThreshold = + uint256(position.collateral).mulDivDown(collateralPrice, ORACLE_PRICE_SCALE).wMulDown(PRE_LLTV); + + require(borrowed > borrowThreshold, ErrorsLib.NotPreLiquidatablePosition()); + + if (seizedAssets > 0) { + uint256 seizedAssetsQuoted = seizedAssets.mulDivUp(collateralPrice, ORACLE_PRICE_SCALE); + + repaidShares = seizedAssetsQuoted.wDivUp(PRE_LIQUIDATION_INCENTIVE_FACTOR).toSharesUp( + market.totalBorrowAssets, market.totalBorrowShares + ); + } else { + seizedAssets = repaidShares.toAssetsDown(market.totalBorrowAssets, market.totalBorrowShares).wMulDown( + PRE_LIQUIDATION_INCENTIVE_FACTOR + ).mulDivDown(ORACLE_PRICE_SCALE, collateralPrice); + } + + uint256 borrowerShares = position.borrowShares; + uint256 repayableShares = borrowerShares.wMulDown(CLOSE_FACTOR); + require(repaidShares <= repayableShares, ErrorsLib.PreLiquidationTooLarge(repaidShares, repayableShares)); + + bytes memory callbackData = abi.encode(seizedAssets, borrower, msg.sender, data); + (uint256 repaidAssets,) = MORPHO.repay(marketParams(), 0, repaidShares, borrower, callbackData); + + emit EventsLib.PreLiquidate(ID, msg.sender, borrower, repaidAssets, repaidShares, seizedAssets); + } + + /// @notice Morpho callback after repay call. + /// @dev During pre-liquidation, Morpho will call the `onMorphoRepay` callback function in `PreLiquidation` using the provided data. + /// This mechanism enables the withdrawal of the position’s collateral before the debt repayment occurs, + /// and can also trigger a pre-liquidator callback. The pre-liquidator callback can be used to swap + /// the seized collateral into the asset being repaid, facilitating liquidation without the need for a flashloan. + function onMorphoRepay(uint256 repaidAssets, bytes calldata callbackData) external { + require(msg.sender == address(MORPHO), ErrorsLib.NotMorpho()); + (uint256 seizedAssets, address borrower, address liquidator, bytes memory data) = + abi.decode(callbackData, (uint256, address, address, bytes)); + + MORPHO.withdrawCollateral(marketParams(), seizedAssets, borrower, liquidator); + + if (data.length > 0) { + IPreLiquidationCallback(liquidator).onPreLiquidate(repaidAssets, data); + } + + ERC20(LOAN_TOKEN).safeTransferFrom(liquidator, address(this), repaidAssets); + } +} diff --git a/src/PreLiquidationFactory.sol b/src/PreLiquidationFactory.sol new file mode 100644 index 0000000..308a0c5 --- /dev/null +++ b/src/PreLiquidationFactory.sol @@ -0,0 +1,47 @@ +// SPDX-License-Identifier: GPL-2.0-or-later +pragma solidity 0.8.27; + +import {IMorpho, Id} from "../lib/morpho-blue/src/interfaces/IMorpho.sol"; +import {PreLiquidation} from "./PreLiquidation.sol"; +import {IPreLiquidation, PreLiquidationParams} from "./interfaces/IPreLiquidation.sol"; +import {ErrorsLib} from "./libraries/ErrorsLib.sol"; +import {EventsLib} from "./libraries/EventsLib.sol"; +import {IPreLiquidationFactory} from "./interfaces/IPreLiquidationFactory.sol"; + +/// @title PreLiquidationFactory +/// @author Morpho Labs +/// @custom:contact security@morpho.org +/// @notice The Fixed LIF, Fixed CF pre-liquidation factory contract for Morpho. +contract PreLiquidationFactory is IPreLiquidationFactory { + /* IMMUTABLE */ + + /// @notice The address of the Morpho contract. + IMorpho public immutable MORPHO; + + /* CONSTRUCTOR */ + + /// @param morpho The address of the Morpho contract. + constructor(address morpho) { + require(morpho != address(0), ErrorsLib.ZeroAddress()); + + MORPHO = IMorpho(morpho); + } + + /* EXTERNAL */ + + /// @notice Creates a PreLiquidation contract. + /// @param id The Morpho market for PreLiquidations. + /// @param preLiquidationParams The PreLiquidation params for the PreLiquidation contract. + /// @dev Warning: This function will revert without data if the pre-liquidation already exists. + function createPreLiquidation(Id id, PreLiquidationParams calldata preLiquidationParams) + external + returns (IPreLiquidation) + { + IPreLiquidation preLiquidation = + IPreLiquidation(address(new PreLiquidation{salt: 0}(address(MORPHO), id, preLiquidationParams))); + + emit EventsLib.CreatePreLiquidation(address(preLiquidation), id, preLiquidationParams); + + return preLiquidation; + } +} diff --git a/src/interfaces/IPreLiquidation.sol b/src/interfaces/IPreLiquidation.sol new file mode 100644 index 0000000..2608b55 --- /dev/null +++ b/src/interfaces/IPreLiquidation.sol @@ -0,0 +1,28 @@ +// SPDX-License-Identifier: GPL-2.0-or-later +pragma solidity >= 0.5.0; + +import {Id, IMorpho, MarketParams} from "../../lib/morpho-blue/src/interfaces/IMorpho.sol"; + +/// @notice The pre-liquidation parameters are: +/// - preLltv, the maximum LTV of a position before allowing pre-liquidation. +/// - closeFactor, the maximum proportion of debt that can be pre-liquidated at once. +/// - preLiquidationIncentiveFactor, the factor used to multiply repaid debt value to get the seized collateral value in a pre-liquidation. +/// - preLiquidationOracle, the oracle used to assess whether or not a position can be preliquidated. +struct PreLiquidationParams { + uint256 preLltv; + uint256 closeFactor; + uint256 preLiquidationIncentiveFactor; + address preLiquidationOracle; +} + +interface IPreLiquidation { + function MORPHO() external view returns (IMorpho); + + function ID() external view returns (Id); + + function marketParams() external returns (MarketParams memory); + + function preLiquidationParams() external view returns (PreLiquidationParams memory); + + function preLiquidate(address borrower, uint256 seizedAssets, uint256 repaidShares, bytes calldata data) external; +} diff --git a/src/interfaces/IPreLiquidationCallback.sol b/src/interfaces/IPreLiquidationCallback.sol new file mode 100644 index 0000000..34a8f95 --- /dev/null +++ b/src/interfaces/IPreLiquidationCallback.sol @@ -0,0 +1,12 @@ +// SPDX-License-Identifier: GPL-2.0-or-later +pragma solidity >= 0.5.0; + +/// @title IPreLiquidationCallback +/// @notice Interface that "pre-liquidators" willing to use the pre-liquidation callback must implement. +interface IPreLiquidationCallback { + /// @notice Callback called when a pre-liquidation occurs. + /// @dev The callback is called only if data is not empty. + /// @param repaidAssets The amount of repaid assets. + /// @param data Arbitrary data passed to the `preLiquidate` function. + function onPreLiquidate(uint256 repaidAssets, bytes calldata data) external; +} diff --git a/src/interfaces/IPreLiquidationFactory.sol b/src/interfaces/IPreLiquidationFactory.sol new file mode 100644 index 0000000..47c0298 --- /dev/null +++ b/src/interfaces/IPreLiquidationFactory.sol @@ -0,0 +1,14 @@ +// SPDX-License-Identifier: GPL-2.0-or-later +pragma solidity >= 0.5.0; + +import {Id, IMorpho} from "../../lib/morpho-blue/src/interfaces/IMorpho.sol"; +import {IPreLiquidation, PreLiquidationParams} from "./IPreLiquidation.sol"; +import {PreLiquidationFactory} from "../PreLiquidationFactory.sol"; + +interface IPreLiquidationFactory { + function MORPHO() external view returns (IMorpho); + + function createPreLiquidation(Id id, PreLiquidationParams calldata preLiquidationParams) + external + returns (IPreLiquidation preLiquidation); +} diff --git a/src/libraries/ErrorsLib.sol b/src/libraries/ErrorsLib.sol new file mode 100644 index 0000000..887119a --- /dev/null +++ b/src/libraries/ErrorsLib.sol @@ -0,0 +1,32 @@ +// SPDX-License-Identifier: GPL-2.0-or-later +pragma solidity ^0.8.27; + +import {Id} from "../../lib/morpho-blue/src/interfaces/IMorpho.sol"; + +/// @title ErrorsLib +/// @author Morpho Labs +/// @custom:contact security@morpho.org +/// @notice Library exposing errors. +library ErrorsLib { + /* PRELIQUIDATION ERRORS */ + + error PreLltvTooHigh(); + + error CloseFactorTooHigh(); + + error PreLiquidationIncentiveFactorTooLow(); + + error InconsistentInput(); + + error NotPreLiquidatablePosition(); + + error PreLiquidationTooLarge(uint256 repaidShares, uint256 repayableShares); + + error NotMorpho(); + + error NonexistentMarket(); + + /* PRELIQUIDATION FACTORY ERRORS */ + + error ZeroAddress(); +} diff --git a/src/libraries/EventsLib.sol b/src/libraries/EventsLib.sol new file mode 100644 index 0000000..5dfc298 --- /dev/null +++ b/src/libraries/EventsLib.sol @@ -0,0 +1,22 @@ +// SPDX-License-Identifier: GPL-2.0-or-later +pragma solidity ^0.8.0; + +import {Id, MarketParams} from "../../lib/morpho-blue/src/interfaces/IMorpho.sol"; +import {PreLiquidationParams} from "../interfaces/IPreLiquidation.sol"; + +/// @title EventsLib +/// @author Morpho Labs +/// @custom:contact security@morpho.org +/// @notice Library exposing events. +library EventsLib { + event PreLiquidate( + Id indexed id, + address indexed liquidator, + address indexed borrower, + uint256 repaidAssets, + uint256 repaidShares, + uint256 seizedAssets + ); + + event CreatePreLiquidation(address indexed preLiquidation, Id id, PreLiquidationParams preLiquidationParams); +} diff --git a/src/libraries/periphery/PreLiquidationAddressLib.sol b/src/libraries/periphery/PreLiquidationAddressLib.sol new file mode 100644 index 0000000..0811447 --- /dev/null +++ b/src/libraries/periphery/PreLiquidationAddressLib.sol @@ -0,0 +1,26 @@ +// SPDX-License-Identifier: GPL-2.0-or-later +pragma solidity ^0.8.0; + +import {PreLiquidation} from "../../PreLiquidation.sol"; +import {PreLiquidationParams} from "../../interfaces/IPreLiquidation.sol"; +import {Id} from "../../../lib/morpho-blue/src/interfaces/IMorpho.sol"; + +library PreLiquidationAddressLib { + /// @notice Computes the CREATE2 address of the pre-liquidation contract generated by the `factory` + /// for a specific Morpho market `id` with the pre-liquidation parameters `preLiquidationParams`. + /// @param morpho Morpho's address. + /// @param factory PreLiquidationFactory contract address. + /// @param id Morpho market id for the pre-liquidation contract. + /// @param preLiquidationParams Pre-liquidation parameters. + /// @return preLiquidationAddress The address of this pre-liquidation contract. + function computePreLiquidationAddress( + address morpho, + address factory, + Id id, + PreLiquidationParams memory preLiquidationParams + ) internal pure returns (address) { + bytes32 init_code_hash = + keccak256(abi.encodePacked(type(PreLiquidation).creationCode, abi.encode(morpho, id, preLiquidationParams))); + return address(uint160(uint256(keccak256(abi.encodePacked(uint8(0xff), factory, uint256(0), init_code_hash))))); + } +} diff --git a/src/mocks/ERC20Mock.sol b/src/mocks/ERC20Mock.sol new file mode 100644 index 0000000..7f5e1e6 --- /dev/null +++ b/src/mocks/ERC20Mock.sol @@ -0,0 +1,21 @@ +// SPDX-License-Identifier: GPL-2.0-or-later +pragma solidity ^0.8.0; + +import {ERC20} from "../../lib/solmate/src/tokens/ERC20.sol"; + +contract ERC20Mock is ERC20 { + constructor(string memory _name, string memory _symbol, uint8 _decimals) ERC20(_name, _symbol, _decimals) {} + + function mint(address account, uint256 amount) external { + _mint(account, amount); + } + + function burn(address account, uint256 amount) external { + _burn(account, amount); + } + + function setBalance(address account, uint256 amount) external { + _burn(account, balanceOf[account]); + _mint(account, amount); + } +} diff --git a/src/mocks/IrmMock.sol b/src/mocks/IrmMock.sol new file mode 100644 index 0000000..1b797ab --- /dev/null +++ b/src/mocks/IrmMock.sol @@ -0,0 +1,25 @@ +// SPDX-License-Identifier: GPL-2.0-or-later +pragma solidity ^0.8.0; + +import {IIrm} from "../../lib/morpho-blue/src/interfaces/IIrm.sol"; +import {MarketParams, Market} from "../../lib/morpho-blue/src/interfaces/IMorpho.sol"; + +import {MathLib} from "../../lib/morpho-blue/src/libraries/MathLib.sol"; + +contract IrmMock is IIrm { + using MathLib for uint128; + + uint256 public apr; + + function setApr(uint256 newApr) external { + apr = newApr; + } + + function borrowRateView(MarketParams memory, Market memory) public view returns (uint256) { + return apr / 365 days; + } + + function borrowRate(MarketParams memory marketParams, Market memory market) external view returns (uint256) { + return borrowRateView(marketParams, market); + } +} diff --git a/src/mocks/MorphoImport.sol b/src/mocks/MorphoImport.sol new file mode 100644 index 0000000..2110eee --- /dev/null +++ b/src/mocks/MorphoImport.sol @@ -0,0 +1,7 @@ +// SPDX-License-Identifier: GPL-2.0-or-later +pragma solidity 0.8.19; +// Force foundry to compile Morpho Blue even though it's not imported by PreLiquidation or by the tests. +// Morpho Blue will be compiled with its own solidity version. +// The resulting bytecode is then loaded by BaseTest.sol. + +import "../../lib/morpho-blue/src/Morpho.sol"; diff --git a/src/mocks/OracleMock.sol b/src/mocks/OracleMock.sol new file mode 100644 index 0000000..ffccee1 --- /dev/null +++ b/src/mocks/OracleMock.sol @@ -0,0 +1,12 @@ +// SPDX-License-Identifier: GPL-2.0-or-later +pragma solidity ^0.8.0; + +import {IOracle} from "../../lib/morpho-blue/src/interfaces/IOracle.sol"; + +contract OracleMock is IOracle { + uint256 public price; + + function setPrice(uint256 newPrice) external { + price = newPrice; + } +} diff --git a/test/BaseTest.sol b/test/BaseTest.sol new file mode 100644 index 0000000..df4c2f2 --- /dev/null +++ b/test/BaseTest.sol @@ -0,0 +1,70 @@ +// SPDX-License-Identifier: GPL-2.0-or-later +pragma solidity ^0.8.0; + +import "../lib/forge-std/src/Test.sol"; +import "../lib/forge-std/src/console2.sol"; + +import {ERC20Mock} from "../src/mocks/ERC20Mock.sol"; +import {IrmMock} from "../src/mocks/IrmMock.sol"; +import {OracleMock} from "../src/mocks/OracleMock.sol"; + +import {MarketParams, IMorpho, Id} from "../lib/morpho-blue/src/interfaces/IMorpho.sol"; +import {MarketParamsLib} from "../lib/morpho-blue/src/libraries/MarketParamsLib.sol"; +import {ORACLE_PRICE_SCALE} from "../lib/morpho-blue/src/libraries/ConstantsLib.sol"; + +contract BaseTest is Test { + using MarketParamsLib for MarketParams; + + address internal SUPPLIER = makeAddr("Supplier"); + address internal BORROWER = makeAddr("Borrower"); + address internal LIQUIDATOR = makeAddr("Liquidator"); + address internal MORPHO_OWNER = makeAddr("MorphoOwner"); + address internal MORPHO_FEE_RECIPIENT = makeAddr("MorphoFeeRecipient"); + + IMorpho internal MORPHO = IMorpho(deployCode("Morpho.sol", abi.encode(MORPHO_OWNER))); + ERC20Mock internal loanToken = new ERC20Mock("loan", "B", 18); + ERC20Mock internal collateralToken = new ERC20Mock("collateral", "C", 18); + OracleMock internal oracle = new OracleMock(); + IrmMock internal irm = new IrmMock(); + uint256 internal lltv = 0.8 ether; // 80% + + MarketParams internal marketParams; + Id internal id; + + function setUp() public virtual { + vm.label(address(MORPHO), "Morpho"); + vm.label(address(loanToken), "Loan"); + vm.label(address(collateralToken), "Collateral"); + vm.label(address(oracle), "Oracle"); + vm.label(address(irm), "Irm"); + + oracle.setPrice(ORACLE_PRICE_SCALE); + + irm.setApr(0.5 ether); // 50%. + + vm.startPrank(MORPHO_OWNER); + MORPHO.enableIrm(address(irm)); + MORPHO.setFeeRecipient(MORPHO_FEE_RECIPIENT); + + MORPHO.enableLltv(lltv); + vm.stopPrank(); + + marketParams = MarketParams({ + loanToken: address(loanToken), + collateralToken: address(collateralToken), + oracle: address(oracle), + irm: address(irm), + lltv: lltv + }); + id = marketParams.id(); + + MORPHO.createMarket(marketParams); + + vm.startPrank(SUPPLIER); + loanToken.approve(address(MORPHO), type(uint256).max); + vm.stopPrank(); + + vm.prank(BORROWER); + collateralToken.approve(address(MORPHO), type(uint256).max); + } +} diff --git a/test/Counter.t.sol b/test/Counter.t.sol deleted file mode 100644 index 54b724f..0000000 --- a/test/Counter.t.sol +++ /dev/null @@ -1,24 +0,0 @@ -// SPDX-License-Identifier: UNLICENSED -pragma solidity ^0.8.13; - -import {Test, console} from "forge-std/Test.sol"; -import {Counter} from "../src/Counter.sol"; - -contract CounterTest is Test { - Counter public counter; - - function setUp() public { - counter = new Counter(); - counter.setNumber(0); - } - - function test_Increment() public { - counter.increment(); - assertEq(counter.number(), 1); - } - - function testFuzz_SetNumber(uint256 x) public { - counter.setNumber(x); - assertEq(counter.number(), x); - } -} diff --git a/test/PreLiquidationFactoryTest.sol b/test/PreLiquidationFactoryTest.sol new file mode 100644 index 0000000..c41aaf7 --- /dev/null +++ b/test/PreLiquidationFactoryTest.sol @@ -0,0 +1,79 @@ +// SPDX-License-Identifier: GPL-2.0-or-later +pragma solidity ^0.8.0; + +import "./BaseTest.sol"; +import {PreLiquidationParams, IPreLiquidation} from "../src/interfaces/IPreLiquidation.sol"; +import {PreLiquidationFactory} from "../src/PreLiquidationFactory.sol"; +import {ErrorsLib} from "../src/libraries/ErrorsLib.sol"; +import {PreLiquidationAddressLib} from "../src/libraries/periphery/PreLiquidationAddressLib.sol"; +import {WAD} from "../lib/morpho-blue/src/libraries/MathLib.sol"; + +contract PreLiquidationFactoryTest is BaseTest { + using MarketParamsLib for MarketParams; + + PreLiquidationFactory factory; + + function setUp() public override { + super.setUp(); + } + + function testFactoryAddressZero() public { + vm.expectRevert(ErrorsLib.ZeroAddress.selector); + new PreLiquidationFactory(address(0)); + } + + function testCreatePreLiquidation(PreLiquidationParams memory preLiquidationParams) public { + preLiquidationParams.preLltv = bound(preLiquidationParams.preLltv, WAD / 100, marketParams.lltv - 1); + preLiquidationParams.closeFactor = bound(preLiquidationParams.closeFactor, WAD / 100, WAD); + preLiquidationParams.preLiquidationIncentiveFactor = + WAD + bound(preLiquidationParams.preLiquidationIncentiveFactor, 0, WAD / 10); + + factory = new PreLiquidationFactory(address(MORPHO)); + IPreLiquidation preLiquidation = factory.createPreLiquidation(id, preLiquidationParams); + + assert(preLiquidation.MORPHO() == MORPHO); + assert(Id.unwrap(preLiquidation.ID()) == Id.unwrap(id)); + + PreLiquidationParams memory preLiqParams = preLiquidation.preLiquidationParams(); + assert(preLiqParams.preLltv == preLiquidationParams.preLltv); + assert(preLiqParams.closeFactor == preLiquidationParams.closeFactor); + assert(preLiqParams.preLiquidationIncentiveFactor == preLiquidationParams.preLiquidationIncentiveFactor); + assert(preLiqParams.preLiquidationOracle == preLiquidationParams.preLiquidationOracle); + + MarketParams memory preLiqMarketParams = preLiquidation.marketParams(); + assert(preLiqMarketParams.loanToken == marketParams.loanToken); + assert(preLiqMarketParams.collateralToken == marketParams.collateralToken); + assert(preLiqMarketParams.oracle == marketParams.oracle); + assert(preLiqMarketParams.irm == marketParams.irm); + assert(preLiqMarketParams.lltv == marketParams.lltv); + } + + function testCreate2Deployment(PreLiquidationParams memory preLiquidationParams) public { + preLiquidationParams.preLltv = bound(preLiquidationParams.preLltv, WAD / 100, marketParams.lltv - 1); + preLiquidationParams.closeFactor = bound(preLiquidationParams.closeFactor, WAD / 100, WAD); + preLiquidationParams.preLiquidationIncentiveFactor = + WAD + bound(preLiquidationParams.preLiquidationIncentiveFactor, 0, WAD / 10); + + factory = new PreLiquidationFactory(address(MORPHO)); + IPreLiquidation preLiquidation = factory.createPreLiquidation(id, preLiquidationParams); + + address preLiquidationAddress = PreLiquidationAddressLib.computePreLiquidationAddress( + address(MORPHO), address(factory), id, preLiquidationParams + ); + assert(address(preLiquidation) == preLiquidationAddress); + } + + function testRedundantPreLiquidation(PreLiquidationParams memory preLiquidationParams) public { + preLiquidationParams.preLltv = bound(preLiquidationParams.preLltv, WAD / 100, marketParams.lltv - 1); + preLiquidationParams.closeFactor = bound(preLiquidationParams.closeFactor, WAD / 100, WAD); + preLiquidationParams.preLiquidationIncentiveFactor = + WAD + bound(preLiquidationParams.preLiquidationIncentiveFactor, 0, WAD / 10); + + factory = new PreLiquidationFactory(address(MORPHO)); + + factory.createPreLiquidation(id, preLiquidationParams); + + vm.expectRevert(bytes("")); + factory.createPreLiquidation(id, preLiquidationParams); + } +} diff --git a/test/PreLiquidationTest.sol b/test/PreLiquidationTest.sol new file mode 100644 index 0000000..5a4b709 --- /dev/null +++ b/test/PreLiquidationTest.sol @@ -0,0 +1,228 @@ +// SPDX-License-Identifier: GPL-2.0-or-later +pragma solidity ^0.8.0; + +import "../lib/forge-std/src/Test.sol"; +import "../lib/forge-std/src/console.sol"; + +import "./BaseTest.sol"; + +import {IPreLiquidation, PreLiquidationParams} from "../src/interfaces/IPreLiquidation.sol"; +import {IPreLiquidationCallback} from "../src/interfaces/IPreLiquidationCallback.sol"; +import {IOracle} from "../lib/morpho-blue/src/interfaces/IOracle.sol"; +import {PreLiquidation} from "../src/PreLiquidation.sol"; +import {PreLiquidationFactory} from "../src/PreLiquidationFactory.sol"; +import "../lib/morpho-blue/src/interfaces/IMorpho.sol"; +import {ERC20} from "../lib/solmate/src/tokens/ERC20.sol"; +import {ErrorsLib} from "../src/libraries/ErrorsLib.sol"; +import {MarketParamsLib} from "../lib/morpho-blue/src/libraries/MarketParamsLib.sol"; +import {MathLib, WAD} from "../lib/morpho-blue/src/libraries/MathLib.sol"; +import {SharesMathLib} from "../lib/morpho-blue/src/libraries/SharesMathLib.sol"; + +contract PreLiquidationTest is BaseTest, IPreLiquidationCallback { + using MarketParamsLib for MarketParams; + using SharesMathLib for uint256; + using MathLib for uint256; + using MathLib for uint128; + + PreLiquidationFactory internal factory; + IPreLiquidation internal preLiquidation; + + event CallbackReached(); + + function setUp() public override { + super.setUp(); + + factory = new PreLiquidationFactory(address(MORPHO)); + } + + function preparePreLiquidation( + PreLiquidationParams memory preLiquidationParams, + uint256 collateralAmount, + uint256 borrowAmount, + address liquidator + ) public { + preLiquidation = factory.createPreLiquidation(id, preLiquidationParams); + + loanToken.mint(SUPPLIER, borrowAmount); + vm.prank(SUPPLIER); + MORPHO.supply(marketParams, borrowAmount, 0, SUPPLIER, hex""); + + collateralToken.mint(BORROWER, collateralAmount); + vm.startPrank(BORROWER); + MORPHO.supplyCollateral(marketParams, collateralAmount, BORROWER, hex""); + + vm.startPrank(liquidator); + loanToken.mint(liquidator, type(uint128).max); + loanToken.approve(address(preLiquidation), type(uint256).max); + + vm.expectRevert(ErrorsLib.NotPreLiquidatablePosition.selector); + preLiquidation.preLiquidate(BORROWER, 0, 1, hex""); + + vm.startPrank(BORROWER); + MORPHO.borrow(marketParams, borrowAmount, 0, BORROWER, BORROWER); + MORPHO.setAuthorization(address(preLiquidation), true); + vm.stopPrank(); + } + + function testHighLltv(PreLiquidationParams memory preLiquidationParams) public virtual { + preLiquidationParams.preLltv = bound(preLiquidationParams.preLltv, marketParams.lltv, type(uint256).max); + preLiquidationParams.closeFactor = bound(preLiquidationParams.closeFactor, WAD / 100, WAD); + preLiquidationParams.preLiquidationIncentiveFactor = + WAD + bound(preLiquidationParams.preLiquidationIncentiveFactor, 0, WAD / 10); + + vm.expectRevert(abi.encodeWithSelector(ErrorsLib.PreLltvTooHigh.selector)); + factory.createPreLiquidation(id, preLiquidationParams); + } + + function testHighCloseFactor(PreLiquidationParams memory preLiquidationParams) public virtual { + preLiquidationParams.preLltv = bound(preLiquidationParams.preLltv, WAD / 100, marketParams.lltv - 1); + preLiquidationParams.closeFactor = bound(preLiquidationParams.closeFactor, WAD + 1, type(uint256).max); + preLiquidationParams.preLiquidationIncentiveFactor = + bound(preLiquidationParams.preLiquidationIncentiveFactor, 0, WAD - 1); + + vm.expectRevert(abi.encodeWithSelector(ErrorsLib.CloseFactorTooHigh.selector)); + factory.createPreLiquidation(id, preLiquidationParams); + } + + function testLowLiquidationIncentiveFactor(PreLiquidationParams memory preLiquidationParams) public virtual { + preLiquidationParams.preLltv = bound(preLiquidationParams.preLltv, WAD / 100, marketParams.lltv - 1); + preLiquidationParams.closeFactor = bound(preLiquidationParams.closeFactor, WAD / 100, WAD); + preLiquidationParams.preLiquidationIncentiveFactor = + bound(preLiquidationParams.preLiquidationIncentiveFactor, 0, WAD - 1); + + vm.expectRevert(abi.encodeWithSelector(ErrorsLib.PreLiquidationIncentiveFactorTooLow.selector)); + factory.createPreLiquidation(id, preLiquidationParams); + } + + function testNonexistentMarket(PreLiquidationParams memory preLiquidationParams) public virtual { + vm.expectRevert(abi.encodeWithSelector(ErrorsLib.NonexistentMarket.selector)); + factory.createPreLiquidation(Id.wrap(bytes32(0)), preLiquidationParams); + } + + function testPreLiquidation( + PreLiquidationParams memory preLiquidationParams, + uint256 collateralAmount, + uint256 borrowAmount + ) public virtual { + preLiquidationParams.preLltv = bound(preLiquidationParams.preLltv, WAD / 100, marketParams.lltv - 1); + preLiquidationParams.closeFactor = bound(preLiquidationParams.closeFactor, WAD / 100, WAD); + preLiquidationParams.preLiquidationIncentiveFactor = + WAD + bound(preLiquidationParams.preLiquidationIncentiveFactor, 0, WAD / 10); + preLiquidationParams.preLiquidationOracle = marketParams.oracle; + + collateralAmount = bound(collateralAmount, 10 ** 18, 10 ** 24); + uint256 collateralPrice = IOracle(marketParams.oracle).price(); + uint256 borrowLiquidationThreshold = collateralAmount.mulDivDown( + IOracle(marketParams.oracle).price(), ORACLE_PRICE_SCALE + ).wMulDown(marketParams.lltv); + uint256 borrowPreLiquidationThreshold = + collateralAmount.mulDivDown(collateralPrice, ORACLE_PRICE_SCALE).wMulDown(preLiquidationParams.preLltv); + borrowAmount = bound(borrowAmount, borrowPreLiquidationThreshold + 1, borrowLiquidationThreshold); + + preparePreLiquidation(preLiquidationParams, collateralAmount, borrowAmount, LIQUIDATOR); + + vm.startPrank(LIQUIDATOR); + Position memory position = MORPHO.position(id, BORROWER); + Market memory m = MORPHO.market(id); + + uint256 repayableShares = position.borrowShares.wMulDown(preLiquidationParams.closeFactor); + uint256 seizedAssets = uint256(repayableShares).toAssetsDown(m.totalBorrowAssets, m.totalBorrowShares).wMulDown( + preLiquidationParams.preLiquidationIncentiveFactor + ).mulDivDown(ORACLE_PRICE_SCALE, collateralPrice); + vm.assume(seizedAssets > 0); + + vm.expectRevert(ErrorsLib.InconsistentInput.selector); + preLiquidation.preLiquidate(BORROWER, 0, 0, hex""); + + vm.expectRevert(ErrorsLib.InconsistentInput.selector); + preLiquidation.preLiquidate(BORROWER, seizedAssets, repayableShares, hex""); + + preLiquidation.preLiquidate(BORROWER, 0, repayableShares, hex""); + } + + function testPreLiquidationCallback( + PreLiquidationParams memory preLiquidationParams, + uint256 collateralAmount, + uint256 borrowAmount + ) public virtual { + preLiquidationParams.preLltv = bound(preLiquidationParams.preLltv, WAD / 100, marketParams.lltv - 1); + preLiquidationParams.closeFactor = bound(preLiquidationParams.closeFactor, WAD / 100, WAD); + preLiquidationParams.preLiquidationIncentiveFactor = + WAD + bound(preLiquidationParams.preLiquidationIncentiveFactor, 0, WAD / 10); + preLiquidationParams.preLiquidationOracle = marketParams.oracle; + + collateralAmount = bound(collateralAmount, 10 ** 18, 10 ** 24); + + uint256 collateralPrice = IOracle(marketParams.oracle).price(); + uint256 borrowLiquidationThreshold = collateralAmount.mulDivDown( + IOracle(marketParams.oracle).price(), ORACLE_PRICE_SCALE + ).wMulDown(marketParams.lltv); + uint256 borrowPreLiquidationThreshold = + collateralAmount.mulDivDown(collateralPrice, ORACLE_PRICE_SCALE).wMulDown(preLiquidationParams.preLltv); + + borrowAmount = bound(borrowAmount, borrowPreLiquidationThreshold + 1, borrowLiquidationThreshold); + + preparePreLiquidation(preLiquidationParams, collateralAmount, borrowAmount, address(this)); + + Position memory position = MORPHO.position(marketParams.id(), BORROWER); + Market memory market = MORPHO.market(marketParams.id()); + + uint256 repayableShares = position.borrowShares.wMulDown(preLiquidationParams.closeFactor); + uint256 seizedAssets = uint256(repayableShares).toAssetsDown(market.totalBorrowAssets, market.totalBorrowShares) + .wMulDown(preLiquidationParams.preLiquidationIncentiveFactor).mulDivDown(ORACLE_PRICE_SCALE, collateralPrice); + vm.assume(seizedAssets > 0); + + bytes memory data = abi.encode(this.testPreLiquidationCallback.selector, hex""); + + vm.recordLogs(); + preLiquidation.preLiquidate(BORROWER, 0, repayableShares, data); + + Vm.Log[] memory entries = vm.getRecordedLogs(); + assert(entries.length == 7); + assert(entries[3].topics[0] == keccak256("CallbackReached()")); + } + + function onPreLiquidate(uint256, bytes memory data) external { + bytes4 selector; + (selector,) = abi.decode(data, (bytes4, bytes)); + require(selector == this.testPreLiquidationCallback.selector); + + emit CallbackReached(); + } + + function testPreLiquidationWithInterest(PreLiquidationParams memory preLiquidationParams, uint256 collateralAmount) + public + { + preLiquidationParams.preLltv = bound(preLiquidationParams.preLltv, WAD / 100, marketParams.lltv - 1); + preLiquidationParams.closeFactor = bound(preLiquidationParams.closeFactor, WAD / 100, WAD); + preLiquidationParams.preLiquidationIncentiveFactor = + WAD + bound(preLiquidationParams.preLiquidationIncentiveFactor, 0, WAD / 10); + preLiquidationParams.preLiquidationOracle = marketParams.oracle; + + collateralAmount = bound(collateralAmount, 10 ** 18, 10 ** 24); + + uint256 collateralPrice = IOracle(marketParams.oracle).price(); + uint256 borrowThreshold = uint256(collateralAmount).mulDivDown(collateralPrice, ORACLE_PRICE_SCALE).wMulDown( + preLiquidationParams.preLltv + ) - 1; + preparePreLiquidation(preLiquidationParams, collateralAmount, borrowThreshold, LIQUIDATOR); + + vm.startPrank(LIQUIDATOR); + Position memory position = MORPHO.position(id, BORROWER); + Market memory m = MORPHO.market(id); + + uint256 repayableShares = position.borrowShares.wMulDown(preLiquidationParams.closeFactor); + uint256 seizedAssets = uint256(repayableShares).toAssetsDown(m.totalBorrowAssets, m.totalBorrowShares).wMulDown( + preLiquidationParams.preLiquidationIncentiveFactor + ).mulDivDown(ORACLE_PRICE_SCALE, collateralPrice); + vm.assume(seizedAssets > 0); + + vm.expectRevert(abi.encodeWithSelector(ErrorsLib.NotPreLiquidatablePosition.selector)); + preLiquidation.preLiquidate(BORROWER, 0, repayableShares, hex""); + + vm.warp(block.timestamp + 12); + vm.roll(block.number + 1); + + preLiquidation.preLiquidate(BORROWER, 0, repayableShares, hex""); + } +}