From f224c9013017d4ddc5aa814d295e638f7671202c Mon Sep 17 00:00:00 2001 From: peyha Date: Mon, 23 Mar 2026 15:04:10 +0100 Subject: [PATCH 01/33] feat: first version --- src/periphery/BorrowerCallback.sol | 59 +++++++ src/periphery/IERC20.sol | 15 ++ src/periphery/IERC4626.sol | 24 +++ src/periphery/LenderCallback.sol | 66 ++++++++ test/BorrowerCallbackTest.sol | 194 ++++++++++++++++++++++ test/LenderCallbackTest.sol | 253 +++++++++++++++++++++++++++++ 6 files changed, 611 insertions(+) create mode 100644 src/periphery/BorrowerCallback.sol create mode 100644 src/periphery/IERC20.sol create mode 100644 src/periphery/IERC4626.sol create mode 100644 src/periphery/LenderCallback.sol create mode 100644 test/BorrowerCallbackTest.sol create mode 100644 test/LenderCallbackTest.sol diff --git a/src/periphery/BorrowerCallback.sol b/src/periphery/BorrowerCallback.sol new file mode 100644 index 000000000..3122ece22 --- /dev/null +++ b/src/periphery/BorrowerCallback.sol @@ -0,0 +1,59 @@ +// SPDX-License-Identifier: GPL-2.0-or-later +// Copyright (c) 2025 Morpho Association +pragma solidity 0.8.31; + +import {Obligation} from "../interfaces/IMidnight.sol"; +import {Midnight} from "../Midnight.sol"; + +struct CollateralData { + uint256 collateralIndex; + uint256 amount; +} + +contract BorrowerCallback { + address public immutable midnight; + + constructor(address _midnight) { + midnight = _midnight; + } + + /// @dev Callback to supply collateral on behalf of borrower. + /// @dev The callback contract should be authorized to supply collateral on behalf of the borrower. + function onSell( + Obligation memory obligation, + address seller, + uint256 buyerAssets, + uint256 sellerAssets, + uint256 units, + bytes memory data + ) external { + require(msg.sender == midnight, "unauthorized"); + CollateralData[] memory collateralData = abi.decode(data, (CollateralData[])); + for (uint256 i = 0; i < collateralData.length; i++) { + Midnight(midnight) + .supplyCollateral(obligation, collateralData[i].collateralIndex, collateralData[i].amount, seller); + } + } + + function onBuy( + Obligation memory obligation, + address buyer, + uint256 buyerAssets, + uint256 sellerAssets, + uint256 units, + bytes memory data + ) external { + revert("not implemented"); + } + + function onLiquidate( + Obligation memory obligation, + uint256 collateralIndex, + uint256 seizedAssets, + uint256 repaidUnits, + address borrower, + bytes memory data + ) external { + revert("not implemented"); + } +} diff --git a/src/periphery/IERC20.sol b/src/periphery/IERC20.sol new file mode 100644 index 000000000..7e2170056 --- /dev/null +++ b/src/periphery/IERC20.sol @@ -0,0 +1,15 @@ +// SPDX-License-Identifier: GPL-2.0-or-later +// Copyright (c) 2025 Morpho Association +pragma solidity >=0.5.0; + +interface IERC20 { + function decimals() external view returns (uint8); + function name() external view returns (string memory); + function symbol() external view returns (string memory); + function totalSupply() external view returns (uint256); + function balanceOf(address account) external view returns (uint256); + function transfer(address to, uint256 shares) external returns (bool success); + function transferFrom(address from, address to, uint256 shares) external returns (bool success); + function approve(address spender, uint256 shares) external returns (bool success); + function allowance(address owner, address spender) external view returns (uint256); +} diff --git a/src/periphery/IERC4626.sol b/src/periphery/IERC4626.sol new file mode 100644 index 000000000..6fb73bb48 --- /dev/null +++ b/src/periphery/IERC4626.sol @@ -0,0 +1,24 @@ +// SPDX-License-Identifier: GPL-2.0-or-later +// Copyright (c) 2025 Morpho Association +pragma solidity >=0.5.0; + +import {IERC20} from "./IERC20.sol"; + +interface IERC4626 is IERC20 { + function asset() external view returns (address); + function totalAssets() external view returns (uint256); + function convertToAssets(uint256 shares) external view returns (uint256 assets); + function convertToShares(uint256 assets) external view returns (uint256 shares); + function deposit(uint256 assets, address onBehalf) external returns (uint256 shares); + function mint(uint256 shares, address onBehalf) external returns (uint256 assets); + function withdraw(uint256 assets, address receiver, address onBehalf) external returns (uint256 shares); + function redeem(uint256 shares, address receiver, address onBehalf) external returns (uint256 assets); + function previewDeposit(uint256 assets) external view returns (uint256 shares); + function previewMint(uint256 shares) external view returns (uint256 assets); + function previewWithdraw(uint256 assets) external view returns (uint256 shares); + function previewRedeem(uint256 shares) external view returns (uint256 assets); + function maxDeposit(address onBehalf) external view returns (uint256 assets); + function maxMint(address onBehalf) external view returns (uint256 shares); + function maxWithdraw(address onBehalf) external view returns (uint256 assets); + function maxRedeem(address onBehalf) external view returns (uint256 shares); +} diff --git a/src/periphery/LenderCallback.sol b/src/periphery/LenderCallback.sol new file mode 100644 index 000000000..6dfed355a --- /dev/null +++ b/src/periphery/LenderCallback.sol @@ -0,0 +1,66 @@ +// SPDX-License-Identifier: GPL-2.0-or-later +// Copyright (c) 2025 Morpho Association +pragma solidity 0.8.31; + +import {Obligation} from "../interfaces/IMidnight.sol"; +import {Midnight} from "../Midnight.sol"; +import {IERC4626} from "./IERC4626.sol"; + +enum WithdrawType { + VaultV2, + Midnight + // VaultV1? +} + +contract LenderCallback { + address public immutable midnight; + + constructor(address _midnight) { + midnight = _midnight; + } + + /// @dev Callback to withdraw funds on behalf of lender. + /// @dev The callback contract should be authorized to withdraw funds on behalf of the lender. + function onBuy( + Obligation memory obligation, + address buyer, + uint256 buyerAssets, + uint256 sellerAssets, + uint256 units, + bytes memory data + ) external { + require(msg.sender == midnight, "unauthorized"); + (bytes32 withdrawData, WithdrawType withdrawType) = abi.decode(data, (bytes32, WithdrawType)); + + if (withdrawType == WithdrawType.VaultV2) { + address vault = address(bytes20(withdrawData)); + IERC4626(vault).withdraw(buyerAssets, buyer, buyer); + } else if (withdrawType == WithdrawType.Midnight) { + address obligationDataAddress = address(uint160(uint256(withdrawData))); + Obligation memory otherObligation = abi.decode(obligationDataAddress.code, (Obligation)); + Midnight(midnight).withdraw(otherObligation, buyerAssets, buyer, buyer); + } + } + + function onSell( + Obligation memory obligation, + address seller, + uint256 buyerAssets, + uint256 sellerAssets, + uint256 units, + bytes memory data + ) external { + revert("not implemented"); + } + + function onLiquidate( + Obligation memory obligation, + uint256 collateralIndex, + uint256 seizedAssets, + uint256 repaidUnits, + address borrower, + bytes memory data + ) external { + revert("not implemented"); + } +} diff --git a/test/BorrowerCallbackTest.sol b/test/BorrowerCallbackTest.sol new file mode 100644 index 000000000..5d55ae054 --- /dev/null +++ b/test/BorrowerCallbackTest.sol @@ -0,0 +1,194 @@ +// SPDX-License-Identifier: GPL-2.0-or-later +// Copyright (c) 2025 Morpho Association +pragma solidity ^0.8.0; + +import {Obligation, Offer, Signature, Collateral} from "../src/interfaces/IMidnight.sol"; +import {Midnight} from "../src/Midnight.sol"; +import {WAD, ORACLE_PRICE_SCALE} from "../src/libraries/ConstantsLib.sol"; +import {UtilsLib} from "../src/libraries/UtilsLib.sol"; +import {TickLib, MAX_TICK} from "../src/libraries/TickLib.sol"; +import {BorrowerCallback, CollateralData} from "../src/periphery/BorrowerCallback.sol"; + +import {BaseTest} from "./BaseTest.sol"; +import {ERC20} from "./helpers/ERC20.sol"; + +contract BorrowerCallbackTest is BaseTest { + using UtilsLib for uint256; + + BorrowerCallback internal borrowerCallback; + Obligation internal obligation; + bytes32 internal id; + Offer internal borrowerOffer; + + function setUp() public override { + super.setUp(); + + borrowerCallback = new BorrowerCallback(address(midnight)); + + obligation.loanToken = address(loanToken); + obligation.maturity = block.timestamp + 100; + obligation.collaterals.push( + Collateral({ + token: address(collateralToken1), + lltv: 0.75e18, + maxLif: maxLif(0.75e18, 0.25e18), + oracle: address(oracle1) + }) + ); + obligation.collaterals.push( + Collateral({ + token: address(collateralToken2), + lltv: 0.75e18, + maxLif: maxLif(0.75e18, 0.25e18), + oracle: address(oracle2) + }) + ); + obligation.collaterals = sortCollaterals(obligation.collaterals); + obligation.rcfThreshold = 0; + + id = toId(obligation); + + borrowerOffer.buy = false; + borrowerOffer.maker = borrower; + borrowerOffer.receiverIfMakerIsSeller = borrower; + borrowerOffer.maxUnits = type(uint256).max; + borrowerOffer.obligation = obligation; + borrowerOffer.expiry = block.timestamp + 200; + borrowerOffer.tick = MAX_TICK; + } + + function testConstructor() public view { + assertEq(borrowerCallback.midnight(), address(midnight)); + } + + function testOnSellSingleCollateralMaker(uint256 units) public { + units = bound(units, 1, 1e33); + uint256 collateral = units.mulDivUp(WAD, obligation.collaterals[0].lltv); + + borrowerOffer.callback = address(borrowerCallback); + CollateralData[] memory collateralData = new CollateralData[](1); + collateralData[0] = CollateralData({collateralIndex: 0, amount: collateral}); + borrowerOffer.callbackData = abi.encode(collateralData); + borrowerOffer.maxUnits = units; + + // Fund lender with loan tokens. + uint256 price = TickLib.tickToPrice(MAX_TICK); + deal(address(loanToken), lender, units.mulDivUp(price, WAD)); + + // Fund callback with collateral tokens and approve midnight. + deal(obligation.collaterals[0].token, address(borrowerCallback), collateral); + vm.prank(address(borrowerCallback)); + ERC20(obligation.collaterals[0].token).approve(address(midnight), collateral); + + // Authorize callback to supply collateral on behalf of borrower. + authorize(borrower, address(borrowerCallback)); + + assertEq(midnight.collateralOf(id, borrower, 0), 0); + + take(units, lender, borrowerOffer); + + assertEq(midnight.collateralOf(id, borrower, 0), collateral); + } + + function testOnSellMultipleCollateralsMaker(uint256 units) public { + units = bound(units, 1, 1e33); + uint256 collateral0 = units.mulDivUp(WAD, obligation.collaterals[0].lltv); + uint256 collateral1 = units.mulDivUp(WAD, obligation.collaterals[1].lltv); + + borrowerOffer.callback = address(borrowerCallback); + CollateralData[] memory collateralData = new CollateralData[](2); + collateralData[0] = CollateralData({collateralIndex: 0, amount: collateral0}); + collateralData[1] = CollateralData({collateralIndex: 1, amount: collateral1}); + borrowerOffer.callbackData = abi.encode(collateralData); + borrowerOffer.maxUnits = units; + + // Fund lender with loan tokens. + uint256 price = TickLib.tickToPrice(MAX_TICK); + deal(address(loanToken), lender, units.mulDivUp(price, WAD)); + + // Fund callback with collateral tokens and approve midnight. + deal(obligation.collaterals[0].token, address(borrowerCallback), collateral0); + deal(obligation.collaterals[1].token, address(borrowerCallback), collateral1); + vm.prank(address(borrowerCallback)); + ERC20(obligation.collaterals[0].token).approve(address(midnight), collateral0); + vm.prank(address(borrowerCallback)); + ERC20(obligation.collaterals[1].token).approve(address(midnight), collateral1); + + // Authorize callback to supply collateral on behalf of borrower. + authorize(borrower, address(borrowerCallback)); + + assertEq(midnight.collateralOf(id, borrower, 0), 0); + assertEq(midnight.collateralOf(id, borrower, 1), 0); + + take(units, lender, borrowerOffer); + + assertEq(midnight.collateralOf(id, borrower, 0), collateral0); + assertEq(midnight.collateralOf(id, borrower, 1), collateral1); + } + + function testOnSellTaker(uint256 units) public { + units = bound(units, 1, 1e33); + uint256 collateral = units.mulDivUp(WAD, obligation.collaterals[0].lltv); + + // Lender makes a buy offer. + Offer memory lenderOffer; + lenderOffer.buy = true; + lenderOffer.maker = lender; + lenderOffer.maxUnits = units; + lenderOffer.obligation = obligation; + lenderOffer.expiry = block.timestamp + 200; + lenderOffer.tick = MAX_TICK; + + // Fund lender with loan tokens. + uint256 price = TickLib.tickToPrice(MAX_TICK); + deal(address(loanToken), lender, units.mulDivDown(price, WAD)); + + // Fund callback with collateral tokens and approve midnight. + deal(obligation.collaterals[0].token, address(borrowerCallback), collateral); + vm.prank(address(borrowerCallback)); + ERC20(obligation.collaterals[0].token).approve(address(midnight), collateral); + + // Authorize callback to supply collateral on behalf of borrower. + authorize(borrower, address(borrowerCallback)); + + CollateralData[] memory collateralData = new CollateralData[](1); + collateralData[0] = CollateralData({collateralIndex: 0, amount: collateral}); + + assertEq(midnight.collateralOf(id, borrower, 0), 0); + + // Borrower takes the lender's buy offer, passing BorrowerCallback as taker callback. + vm.prank(borrower); + midnight.take( + units, + borrower, + address(borrowerCallback), + abi.encode(collateralData), + borrower, + lenderOffer, + sig([lenderOffer]), + root([lenderOffer]), + proof([lenderOffer]) + ); + + assertEq(midnight.collateralOf(id, borrower, 0), collateral); + } + + function testOnSellUnauthorized() public { + Obligation memory ob; + vm.prank(makeAddr("attacker")); + vm.expectRevert("unauthorized"); + borrowerCallback.onSell(ob, address(0), 0, 0, 0, ""); + } + + function testOnBuyReverts() public { + Obligation memory ob; + vm.expectRevert("not implemented"); + borrowerCallback.onBuy(ob, address(0), 0, 0, 0, ""); + } + + function testOnLiquidateReverts() public { + Obligation memory ob; + vm.expectRevert("not implemented"); + borrowerCallback.onLiquidate(ob, 0, 0, 0, address(0), ""); + } +} diff --git a/test/LenderCallbackTest.sol b/test/LenderCallbackTest.sol new file mode 100644 index 000000000..f8d5911b1 --- /dev/null +++ b/test/LenderCallbackTest.sol @@ -0,0 +1,253 @@ +// SPDX-License-Identifier: GPL-2.0-or-later +// Copyright (c) 2025 Morpho Association +pragma solidity ^0.8.0; + +import {Obligation, Offer, Signature, Collateral} from "../src/interfaces/IMidnight.sol"; +import {Midnight} from "../src/Midnight.sol"; +import {WAD} from "../src/libraries/ConstantsLib.sol"; +import {UtilsLib} from "../src/libraries/UtilsLib.sol"; +import {TickLib, MAX_TICK} from "../src/libraries/TickLib.sol"; +import {LenderCallback, WithdrawType} from "../src/periphery/LenderCallback.sol"; + +import {BaseTest} from "./BaseTest.sol"; +import {ERC20} from "./helpers/ERC20.sol"; + +// TODO: deploy vault v2 +contract MockVault { + address public asset; + + constructor(address _asset) { + asset = _asset; + } + + function withdraw(uint256 assets, address receiver, address) external returns (uint256) { + ERC20(asset).transfer(receiver, assets); + return assets; + } +} + +contract LenderCallbackTest is BaseTest { + using UtilsLib for uint256; + + LenderCallback internal lenderCallback; + Obligation internal obligation; + bytes32 internal id; + Offer internal borrowerOffer; + + function setUp() public override { + super.setUp(); + + lenderCallback = new LenderCallback(address(midnight)); + + obligation.loanToken = address(loanToken); + obligation.maturity = block.timestamp + 100; + obligation.collaterals + .push( + Collateral({ + token: address(collateralToken1), + lltv: 0.75e18, + maxLif: maxLif(0.75e18, 0.25e18), + oracle: address(oracle1) + }) + ); + obligation.collaterals + .push( + Collateral({ + token: address(collateralToken2), + lltv: 0.75e18, + maxLif: maxLif(0.75e18, 0.25e18), + oracle: address(oracle2) + }) + ); + obligation.collaterals = sortCollaterals(obligation.collaterals); + obligation.rcfThreshold = 0; + + id = toId(obligation); + + borrowerOffer.buy = false; + borrowerOffer.maker = borrower; + borrowerOffer.receiverIfMakerIsSeller = borrower; + borrowerOffer.maxUnits = type(uint256).max; + borrowerOffer.obligation = obligation; + borrowerOffer.expiry = block.timestamp + 200; + borrowerOffer.tick = MAX_TICK; + } + + function testConstructor() public view { + assertEq(lenderCallback.midnight(), address(midnight)); + } + + function testOnBuyVaultV2Maker(uint256 units) public { + units = bound(units, 1, 1e33); + uint256 price = TickLib.tickToPrice(MAX_TICK); + uint256 buyerAssets = units.mulDivUp(price, WAD); + + // Deploy mock vault with loan tokens. + MockVault vault = new MockVault(address(loanToken)); + deal(address(loanToken), address(vault), buyerAssets); + + // Lender makes a buy offer with callback. + Offer memory lenderOffer; + lenderOffer.buy = true; + lenderOffer.maker = lender; + lenderOffer.callback = address(lenderCallback); + lenderOffer.callbackData = abi.encode(bytes32(bytes20(address(vault))), WithdrawType.VaultV2); + lenderOffer.maxUnits = units; + lenderOffer.obligation = obligation; + lenderOffer.expiry = block.timestamp + 200; + lenderOffer.tick = MAX_TICK; + + // Collateralize borrower and take. + collateralize(obligation, borrower, units); + + take(units, borrower, lenderOffer); + + assertEq(midnight.creditOf(id, lender), units); + assertEq(midnight.debtOf(id, borrower), units); + assertEq(loanToken.balanceOf(address(vault)), 0); + } + + function testOnBuyVaultV2Taker(uint256 units) public { + units = bound(units, 1, 1e33); + uint256 price = TickLib.tickToPrice(MAX_TICK); + uint256 buyerAssets = units.mulDivUp(price, WAD); + + // Deploy mock vault with loan tokens. + MockVault vault = new MockVault(address(loanToken)); + deal(address(loanToken), address(vault), buyerAssets); + + // Borrower makes a sell offer. + borrowerOffer.maxUnits = units; + collateralize(obligation, borrower, units); + + // Lender takes via taker callback. + vm.prank(lender); + midnight.take( + units, + lender, + address(lenderCallback), + abi.encode(bytes32(bytes20(address(vault))), WithdrawType.VaultV2), + address(0), + borrowerOffer, + sig([borrowerOffer]), + root([borrowerOffer]), + proof([borrowerOffer]) + ); + + assertEq(midnight.creditOf(id, lender), units); + assertEq(midnight.debtOf(id, borrower), units); + assertEq(loanToken.balanceOf(address(vault)), 0); + } + + /// @dev Helper to set up obligation2 with lender credit and withdrawable funds. + function _setupMidnightSource(uint256 buyerAssets) + internal + returns (Obligation memory obligation2, bytes32 id2) + { + obligation2.loanToken = address(loanToken); + obligation2.maturity = block.timestamp + 200; + obligation2.collaterals = obligation.collaterals; + obligation2.rcfThreshold = 0; + id2 = toId(obligation2); + + // Collateralize borrower on obligation2 and lend. + collateralize(obligation2, borrower, buyerAssets); + deal(address(loanToken), lender, buyerAssets); + + Offer memory lenderOffer2; + lenderOffer2.buy = true; + lenderOffer2.maker = lender; + lenderOffer2.maxUnits = buyerAssets; + lenderOffer2.obligation = obligation2; + lenderOffer2.expiry = block.timestamp + 300; + lenderOffer2.tick = MAX_TICK; + lenderOffer2.group = keccak256("obligation2"); + + take(buyerAssets, borrower, lenderOffer2); + + // Borrower repays to create withdrawable funds. + deal(address(loanToken), borrower, buyerAssets); + vm.prank(borrower); + midnight.repay(obligation2, buyerAssets, borrower); + + // Authorize callback to withdraw on behalf of lender. + authorize(lender, address(lenderCallback)); + } + + function testOnBuyMidnightMaker(uint256 units) public { + units = bound(units, 1, 1e33); + uint256 price = TickLib.tickToPrice(MAX_TICK); + uint256 buyerAssets = units.mulDivUp(price, WAD); + + (, bytes32 id2) = _setupMidnightSource(buyerAssets); + + // Lender makes a buy offer on obligation with callback. + Offer memory lenderOffer; + lenderOffer.buy = true; + lenderOffer.maker = lender; + lenderOffer.callback = address(lenderCallback); + lenderOffer.callbackData = abi.encode(id2, WithdrawType.Midnight); + lenderOffer.maxUnits = units; + lenderOffer.obligation = obligation; + lenderOffer.expiry = block.timestamp + 200; + lenderOffer.tick = MAX_TICK; + + // Collateralize borrower on obligation and take. + collateralize(obligation, borrower, units); + + take(units, borrower, lenderOffer); + + assertEq(midnight.creditOf(id, lender), units); + assertEq(midnight.debtOf(id, borrower), units); + assertEq(midnight.creditOf(id2, lender), 0); + } + + function testOnBuyMidnightTaker(uint256 units) public { + units = bound(units, 1, 1e33); + uint256 price = TickLib.tickToPrice(MAX_TICK); + uint256 buyerAssets = units.mulDivUp(price, WAD); + + (, bytes32 id2) = _setupMidnightSource(buyerAssets); + + // Borrower makes a sell offer. + borrowerOffer.maxUnits = units; + collateralize(obligation, borrower, units); + + // Lender takes via taker callback. + vm.prank(lender); + midnight.take( + units, + lender, + address(lenderCallback), + abi.encode(id2, WithdrawType.Midnight), + address(0), + borrowerOffer, + sig([borrowerOffer]), + root([borrowerOffer]), + proof([borrowerOffer]) + ); + + assertEq(midnight.creditOf(id, lender), units); + assertEq(midnight.debtOf(id, borrower), units); + assertEq(midnight.creditOf(id2, lender), 0); + } + + function testOnBuyUnauthorized() public { + Obligation memory ob; + vm.prank(makeAddr("attacker")); + vm.expectRevert("unauthorized"); + lenderCallback.onBuy(ob, address(0), 0, 0, 0, ""); + } + + function testOnSellReverts() public { + Obligation memory ob; + vm.expectRevert("not implemented"); + lenderCallback.onSell(ob, address(0), 0, 0, 0, ""); + } + + function testOnLiquidateReverts() public { + Obligation memory ob; + vm.expectRevert("not implemented"); + lenderCallback.onLiquidate(ob, 0, 0, 0, address(0), ""); + } +} From c23627abb48051e8e95e5f68e3c8e7006edf89f8 Mon Sep 17 00:00:00 2001 From: peyha Date: Mon, 23 Mar 2026 15:16:28 +0100 Subject: [PATCH 02/33] lint --- src/periphery/BorrowerCallback.sol | 40 ++++++++-------------------- src/periphery/LenderCallback.sol | 42 +++++++++--------------------- test/BorrowerCallbackTest.sol | 36 +++++++++++++------------ test/LenderCallbackTest.sol | 7 ++--- 4 files changed, 44 insertions(+), 81 deletions(-) diff --git a/src/periphery/BorrowerCallback.sol b/src/periphery/BorrowerCallback.sol index 3122ece22..adcff29b1 100644 --- a/src/periphery/BorrowerCallback.sol +++ b/src/periphery/BorrowerCallback.sol @@ -4,56 +4,38 @@ pragma solidity 0.8.31; import {Obligation} from "../interfaces/IMidnight.sol"; import {Midnight} from "../Midnight.sol"; +import {ICallbacks} from "../interfaces/ICallbacks.sol"; struct CollateralData { uint256 collateralIndex; uint256 amount; } -contract BorrowerCallback { - address public immutable midnight; +contract BorrowerCallback is ICallbacks { + address public immutable MIDNIGHT; constructor(address _midnight) { - midnight = _midnight; + MIDNIGHT = _midnight; } /// @dev Callback to supply collateral on behalf of borrower. /// @dev The callback contract should be authorized to supply collateral on behalf of the borrower. - function onSell( - Obligation memory obligation, - address seller, - uint256 buyerAssets, - uint256 sellerAssets, - uint256 units, - bytes memory data - ) external { - require(msg.sender == midnight, "unauthorized"); + function onSell(Obligation memory obligation, address seller, uint256, uint256, uint256, bytes memory data) + external + { + require(msg.sender == MIDNIGHT, "unauthorized"); CollateralData[] memory collateralData = abi.decode(data, (CollateralData[])); for (uint256 i = 0; i < collateralData.length; i++) { - Midnight(midnight) + Midnight(MIDNIGHT) .supplyCollateral(obligation, collateralData[i].collateralIndex, collateralData[i].amount, seller); } } - function onBuy( - Obligation memory obligation, - address buyer, - uint256 buyerAssets, - uint256 sellerAssets, - uint256 units, - bytes memory data - ) external { + function onBuy(Obligation memory, address, uint256, uint256, uint256, bytes memory) external pure { revert("not implemented"); } - function onLiquidate( - Obligation memory obligation, - uint256 collateralIndex, - uint256 seizedAssets, - uint256 repaidUnits, - address borrower, - bytes memory data - ) external { + function onLiquidate(Obligation memory, uint256, uint256, uint256, address, bytes memory) external pure { revert("not implemented"); } } diff --git a/src/periphery/LenderCallback.sol b/src/periphery/LenderCallback.sol index 6dfed355a..88b0f2493 100644 --- a/src/periphery/LenderCallback.sol +++ b/src/periphery/LenderCallback.sol @@ -5,6 +5,7 @@ pragma solidity 0.8.31; import {Obligation} from "../interfaces/IMidnight.sol"; import {Midnight} from "../Midnight.sol"; import {IERC4626} from "./IERC4626.sol"; +import {ICallbacks} from "../interfaces/ICallbacks.sol"; enum WithdrawType { VaultV2, @@ -12,55 +13,36 @@ enum WithdrawType { // VaultV1? } -contract LenderCallback { - address public immutable midnight; +contract LenderCallback is ICallbacks { + address public immutable MIDNIGHT; constructor(address _midnight) { - midnight = _midnight; + MIDNIGHT = _midnight; } /// @dev Callback to withdraw funds on behalf of lender. /// @dev The callback contract should be authorized to withdraw funds on behalf of the lender. - function onBuy( - Obligation memory obligation, - address buyer, - uint256 buyerAssets, - uint256 sellerAssets, - uint256 units, - bytes memory data - ) external { - require(msg.sender == midnight, "unauthorized"); + function onBuy(Obligation memory, address buyer, uint256 buyerAssets, uint256, uint256, bytes memory data) + external + { + require(msg.sender == MIDNIGHT, "unauthorized"); (bytes32 withdrawData, WithdrawType withdrawType) = abi.decode(data, (bytes32, WithdrawType)); if (withdrawType == WithdrawType.VaultV2) { - address vault = address(bytes20(withdrawData)); + address vault = address(uint160(uint256(withdrawData))); IERC4626(vault).withdraw(buyerAssets, buyer, buyer); } else if (withdrawType == WithdrawType.Midnight) { address obligationDataAddress = address(uint160(uint256(withdrawData))); Obligation memory otherObligation = abi.decode(obligationDataAddress.code, (Obligation)); - Midnight(midnight).withdraw(otherObligation, buyerAssets, buyer, buyer); + Midnight(MIDNIGHT).withdraw(otherObligation, buyerAssets, buyer, buyer); } } - function onSell( - Obligation memory obligation, - address seller, - uint256 buyerAssets, - uint256 sellerAssets, - uint256 units, - bytes memory data - ) external { + function onSell(Obligation memory, address, uint256, uint256, uint256, bytes memory) external pure { revert("not implemented"); } - function onLiquidate( - Obligation memory obligation, - uint256 collateralIndex, - uint256 seizedAssets, - uint256 repaidUnits, - address borrower, - bytes memory data - ) external { + function onLiquidate(Obligation memory, uint256, uint256, uint256, address, bytes memory) external pure { revert("not implemented"); } } diff --git a/test/BorrowerCallbackTest.sol b/test/BorrowerCallbackTest.sol index 5d55ae054..28c4d1b5a 100644 --- a/test/BorrowerCallbackTest.sol +++ b/test/BorrowerCallbackTest.sol @@ -27,22 +27,24 @@ contract BorrowerCallbackTest is BaseTest { obligation.loanToken = address(loanToken); obligation.maturity = block.timestamp + 100; - obligation.collaterals.push( - Collateral({ - token: address(collateralToken1), - lltv: 0.75e18, - maxLif: maxLif(0.75e18, 0.25e18), - oracle: address(oracle1) - }) - ); - obligation.collaterals.push( - Collateral({ - token: address(collateralToken2), - lltv: 0.75e18, - maxLif: maxLif(0.75e18, 0.25e18), - oracle: address(oracle2) - }) - ); + obligation.collaterals + .push( + Collateral({ + token: address(collateralToken1), + lltv: 0.75e18, + maxLif: maxLif(0.75e18, 0.25e18), + oracle: address(oracle1) + }) + ); + obligation.collaterals + .push( + Collateral({ + token: address(collateralToken2), + lltv: 0.75e18, + maxLif: maxLif(0.75e18, 0.25e18), + oracle: address(oracle2) + }) + ); obligation.collaterals = sortCollaterals(obligation.collaterals); obligation.rcfThreshold = 0; @@ -58,7 +60,7 @@ contract BorrowerCallbackTest is BaseTest { } function testConstructor() public view { - assertEq(borrowerCallback.midnight(), address(midnight)); + assertEq(borrowerCallback.MIDNIGHT(), address(midnight)); } function testOnSellSingleCollateralMaker(uint256 units) public { diff --git a/test/LenderCallbackTest.sol b/test/LenderCallbackTest.sol index f8d5911b1..cac5fb336 100644 --- a/test/LenderCallbackTest.sol +++ b/test/LenderCallbackTest.sol @@ -74,7 +74,7 @@ contract LenderCallbackTest is BaseTest { } function testConstructor() public view { - assertEq(lenderCallback.midnight(), address(midnight)); + assertEq(lenderCallback.MIDNIGHT(), address(midnight)); } function testOnBuyVaultV2Maker(uint256 units) public { @@ -140,10 +140,7 @@ contract LenderCallbackTest is BaseTest { } /// @dev Helper to set up obligation2 with lender credit and withdrawable funds. - function _setupMidnightSource(uint256 buyerAssets) - internal - returns (Obligation memory obligation2, bytes32 id2) - { + function _setupMidnightSource(uint256 buyerAssets) internal returns (Obligation memory obligation2, bytes32 id2) { obligation2.loanToken = address(loanToken); obligation2.maturity = block.timestamp + 200; obligation2.collaterals = obligation.collaterals; From 92d19aa9948133de00555cf4edfc2287b31a9049 Mon Sep 17 00:00:00 2001 From: peyha Date: Mon, 23 Mar 2026 15:26:58 +0100 Subject: [PATCH 03/33] test --- test/LenderCallbackTest.sol | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/test/LenderCallbackTest.sol b/test/LenderCallbackTest.sol index cac5fb336..4473a1533 100644 --- a/test/LenderCallbackTest.sol +++ b/test/LenderCallbackTest.sol @@ -91,7 +91,7 @@ contract LenderCallbackTest is BaseTest { lenderOffer.buy = true; lenderOffer.maker = lender; lenderOffer.callback = address(lenderCallback); - lenderOffer.callbackData = abi.encode(bytes32(bytes20(address(vault))), WithdrawType.VaultV2); + lenderOffer.callbackData = abi.encode(uint256(uint160(address(vault))), WithdrawType.VaultV2); lenderOffer.maxUnits = units; lenderOffer.obligation = obligation; lenderOffer.expiry = block.timestamp + 200; @@ -126,7 +126,7 @@ contract LenderCallbackTest is BaseTest { units, lender, address(lenderCallback), - abi.encode(bytes32(bytes20(address(vault))), WithdrawType.VaultV2), + abi.encode(bytes32(uint256(uint160(address(vault)))), WithdrawType.VaultV2), address(0), borrowerOffer, sig([borrowerOffer]), From aa6611e09df11e072104f235ab5e71d2aa649f20 Mon Sep 17 00:00:00 2001 From: peyha Date: Tue, 24 Mar 2026 10:47:17 +0100 Subject: [PATCH 04/33] refactor: separate lend callback --- ...lback.sol => ObligationLenderCallback.sol} | 24 +--- src/periphery/VaultLenderCallback.sol | 33 ++++++ test/LenderCallbackTest.sol | 108 +++++++++++++++--- 3 files changed, 128 insertions(+), 37 deletions(-) rename src/periphery/{LenderCallback.sol => ObligationLenderCallback.sol} (53%) create mode 100644 src/periphery/VaultLenderCallback.sol diff --git a/src/periphery/LenderCallback.sol b/src/periphery/ObligationLenderCallback.sol similarity index 53% rename from src/periphery/LenderCallback.sol rename to src/periphery/ObligationLenderCallback.sol index 88b0f2493..e80b461b8 100644 --- a/src/periphery/LenderCallback.sol +++ b/src/periphery/ObligationLenderCallback.sol @@ -4,38 +4,24 @@ pragma solidity 0.8.31; import {Obligation} from "../interfaces/IMidnight.sol"; import {Midnight} from "../Midnight.sol"; -import {IERC4626} from "./IERC4626.sol"; import {ICallbacks} from "../interfaces/ICallbacks.sol"; -enum WithdrawType { - VaultV2, - Midnight - // VaultV1? -} - -contract LenderCallback is ICallbacks { +contract ObligationLenderCallback is ICallbacks { address public immutable MIDNIGHT; constructor(address _midnight) { MIDNIGHT = _midnight; } - /// @dev Callback to withdraw funds on behalf of lender. + /// @dev Callback to withdraw funds from another Midnight obligation. /// @dev The callback contract should be authorized to withdraw funds on behalf of the lender. function onBuy(Obligation memory, address buyer, uint256 buyerAssets, uint256, uint256, bytes memory data) external { require(msg.sender == MIDNIGHT, "unauthorized"); - (bytes32 withdrawData, WithdrawType withdrawType) = abi.decode(data, (bytes32, WithdrawType)); - - if (withdrawType == WithdrawType.VaultV2) { - address vault = address(uint160(uint256(withdrawData))); - IERC4626(vault).withdraw(buyerAssets, buyer, buyer); - } else if (withdrawType == WithdrawType.Midnight) { - address obligationDataAddress = address(uint160(uint256(withdrawData))); - Obligation memory otherObligation = abi.decode(obligationDataAddress.code, (Obligation)); - Midnight(MIDNIGHT).withdraw(otherObligation, buyerAssets, buyer, buyer); - } + bytes32 id = abi.decode(data, (bytes32)); + Obligation memory otherObligation = abi.decode(address(uint160(uint256(id))).code, (Obligation)); + Midnight(MIDNIGHT).withdraw(otherObligation, buyerAssets, buyer, buyer); } function onSell(Obligation memory, address, uint256, uint256, uint256, bytes memory) external pure { diff --git a/src/periphery/VaultLenderCallback.sol b/src/periphery/VaultLenderCallback.sol new file mode 100644 index 000000000..99a3a3898 --- /dev/null +++ b/src/periphery/VaultLenderCallback.sol @@ -0,0 +1,33 @@ +// SPDX-License-Identifier: GPL-2.0-or-later +// Copyright (c) 2025 Morpho Association +pragma solidity 0.8.31; + +import {Obligation} from "../interfaces/IMidnight.sol"; +import {IERC4626} from "./IERC4626.sol"; +import {ICallbacks} from "../interfaces/ICallbacks.sol"; + +contract VaultLenderCallback is ICallbacks { + address public immutable MIDNIGHT; + + constructor(address _midnight) { + MIDNIGHT = _midnight; + } + + /// @dev Callback to withdraw funds from an ERC4626 vault. + /// @dev The callback contract should be authorized to withdraw funds on behalf of the lender. + function onBuy(Obligation memory, address buyer, uint256 buyerAssets, uint256, uint256, bytes memory data) + external + { + require(msg.sender == MIDNIGHT, "unauthorized"); + address vault = abi.decode(data, (address)); + IERC4626(vault).withdraw(buyerAssets, buyer, buyer); + } + + function onSell(Obligation memory, address, uint256, uint256, uint256, bytes memory) external pure { + revert("not implemented"); + } + + function onLiquidate(Obligation memory, uint256, uint256, uint256, address, bytes memory) external pure { + revert("not implemented"); + } +} diff --git a/test/LenderCallbackTest.sol b/test/LenderCallbackTest.sol index 4473a1533..c47dbdf17 100644 --- a/test/LenderCallbackTest.sol +++ b/test/LenderCallbackTest.sol @@ -7,12 +7,13 @@ import {Midnight} from "../src/Midnight.sol"; import {WAD} from "../src/libraries/ConstantsLib.sol"; import {UtilsLib} from "../src/libraries/UtilsLib.sol"; import {TickLib, MAX_TICK} from "../src/libraries/TickLib.sol"; -import {LenderCallback, WithdrawType} from "../src/periphery/LenderCallback.sol"; +import {VaultLenderCallback} from "../src/periphery/VaultLenderCallback.sol"; +import {ObligationLenderCallback} from "../src/periphery/ObligationLenderCallback.sol"; import {BaseTest} from "./BaseTest.sol"; import {ERC20} from "./helpers/ERC20.sol"; -// TODO: deploy vault v2 +// TODO: use real vault v2 contract MockVault { address public asset; @@ -26,10 +27,10 @@ contract MockVault { } } -contract LenderCallbackTest is BaseTest { +contract VaultLenderCallbackTest is BaseTest { using UtilsLib for uint256; - LenderCallback internal lenderCallback; + VaultLenderCallback internal vaultLenderCallback; Obligation internal obligation; bytes32 internal id; Offer internal borrowerOffer; @@ -37,7 +38,7 @@ contract LenderCallbackTest is BaseTest { function setUp() public override { super.setUp(); - lenderCallback = new LenderCallback(address(midnight)); + vaultLenderCallback = new VaultLenderCallback(address(midnight)); obligation.loanToken = address(loanToken); obligation.maturity = block.timestamp + 100; @@ -74,7 +75,7 @@ contract LenderCallbackTest is BaseTest { } function testConstructor() public view { - assertEq(lenderCallback.MIDNIGHT(), address(midnight)); + assertEq(vaultLenderCallback.MIDNIGHT(), address(midnight)); } function testOnBuyVaultV2Maker(uint256 units) public { @@ -90,8 +91,8 @@ contract LenderCallbackTest is BaseTest { Offer memory lenderOffer; lenderOffer.buy = true; lenderOffer.maker = lender; - lenderOffer.callback = address(lenderCallback); - lenderOffer.callbackData = abi.encode(uint256(uint160(address(vault))), WithdrawType.VaultV2); + lenderOffer.callback = address(vaultLenderCallback); + lenderOffer.callbackData = abi.encode(address(vault)); lenderOffer.maxUnits = units; lenderOffer.obligation = obligation; lenderOffer.expiry = block.timestamp + 200; @@ -125,8 +126,8 @@ contract LenderCallbackTest is BaseTest { midnight.take( units, lender, - address(lenderCallback), - abi.encode(bytes32(uint256(uint160(address(vault)))), WithdrawType.VaultV2), + address(vaultLenderCallback), + abi.encode(address(vault)), address(0), borrowerOffer, sig([borrowerOffer]), @@ -139,6 +140,77 @@ contract LenderCallbackTest is BaseTest { assertEq(loanToken.balanceOf(address(vault)), 0); } + function testOnBuyUnauthorized() public { + Obligation memory ob; + vm.prank(makeAddr("attacker")); + vm.expectRevert("unauthorized"); + vaultLenderCallback.onBuy(ob, address(0), 0, 0, 0, ""); + } + + function testOnSellReverts() public { + Obligation memory ob; + vm.expectRevert("not implemented"); + vaultLenderCallback.onSell(ob, address(0), 0, 0, 0, ""); + } + + function testOnLiquidateReverts() public { + Obligation memory ob; + vm.expectRevert("not implemented"); + vaultLenderCallback.onLiquidate(ob, 0, 0, 0, address(0), ""); + } +} + +contract ObligationLenderCallbackTest is BaseTest { + using UtilsLib for uint256; + + ObligationLenderCallback internal obligationLenderCallback; + Obligation internal obligation; + bytes32 internal id; + Offer internal borrowerOffer; + + function setUp() public override { + super.setUp(); + + obligationLenderCallback = new ObligationLenderCallback(address(midnight)); + + obligation.loanToken = address(loanToken); + obligation.maturity = block.timestamp + 100; + obligation.collaterals + .push( + Collateral({ + token: address(collateralToken1), + lltv: 0.75e18, + maxLif: maxLif(0.75e18, 0.25e18), + oracle: address(oracle1) + }) + ); + obligation.collaterals + .push( + Collateral({ + token: address(collateralToken2), + lltv: 0.75e18, + maxLif: maxLif(0.75e18, 0.25e18), + oracle: address(oracle2) + }) + ); + obligation.collaterals = sortCollaterals(obligation.collaterals); + obligation.rcfThreshold = 0; + + id = toId(obligation); + + borrowerOffer.buy = false; + borrowerOffer.maker = borrower; + borrowerOffer.receiverIfMakerIsSeller = borrower; + borrowerOffer.maxUnits = type(uint256).max; + borrowerOffer.obligation = obligation; + borrowerOffer.expiry = block.timestamp + 200; + borrowerOffer.tick = MAX_TICK; + } + + function testConstructor() public view { + assertEq(obligationLenderCallback.MIDNIGHT(), address(midnight)); + } + /// @dev Helper to set up obligation2 with lender credit and withdrawable funds. function _setupMidnightSource(uint256 buyerAssets) internal returns (Obligation memory obligation2, bytes32 id2) { obligation2.loanToken = address(loanToken); @@ -168,7 +240,7 @@ contract LenderCallbackTest is BaseTest { midnight.repay(obligation2, buyerAssets, borrower); // Authorize callback to withdraw on behalf of lender. - authorize(lender, address(lenderCallback)); + authorize(lender, address(obligationLenderCallback)); } function testOnBuyMidnightMaker(uint256 units) public { @@ -182,8 +254,8 @@ contract LenderCallbackTest is BaseTest { Offer memory lenderOffer; lenderOffer.buy = true; lenderOffer.maker = lender; - lenderOffer.callback = address(lenderCallback); - lenderOffer.callbackData = abi.encode(id2, WithdrawType.Midnight); + lenderOffer.callback = address(obligationLenderCallback); + lenderOffer.callbackData = abi.encode(address(uint160(uint256(id2)))); lenderOffer.maxUnits = units; lenderOffer.obligation = obligation; lenderOffer.expiry = block.timestamp + 200; @@ -215,8 +287,8 @@ contract LenderCallbackTest is BaseTest { midnight.take( units, lender, - address(lenderCallback), - abi.encode(id2, WithdrawType.Midnight), + address(obligationLenderCallback), + abi.encode(address(uint160(uint256(id2)))), address(0), borrowerOffer, sig([borrowerOffer]), @@ -233,18 +305,18 @@ contract LenderCallbackTest is BaseTest { Obligation memory ob; vm.prank(makeAddr("attacker")); vm.expectRevert("unauthorized"); - lenderCallback.onBuy(ob, address(0), 0, 0, 0, ""); + obligationLenderCallback.onBuy(ob, address(0), 0, 0, 0, ""); } function testOnSellReverts() public { Obligation memory ob; vm.expectRevert("not implemented"); - lenderCallback.onSell(ob, address(0), 0, 0, 0, ""); + obligationLenderCallback.onSell(ob, address(0), 0, 0, 0, ""); } function testOnLiquidateReverts() public { Obligation memory ob; vm.expectRevert("not implemented"); - lenderCallback.onLiquidate(ob, 0, 0, 0, address(0), ""); + obligationLenderCallback.onLiquidate(ob, 0, 0, 0, address(0), ""); } } From 0b9f0c7a2ba153df91cd510ab0136961cdd6dc8d Mon Sep 17 00:00:00 2001 From: peyha Date: Wed, 1 Apr 2026 14:49:40 +0200 Subject: [PATCH 05/33] chore: lint --- src/periphery/BorrowerCallback.sol | 6 ++--- src/periphery/ObligationLenderCallback.sol | 10 ++++---- src/periphery/VaultLenderCallback.sol | 6 ++--- test/BorrowerCallbackTest.sol | 16 ++++++------ test/LenderCallbackTest.sol | 30 +++++++++++----------- 5 files changed, 34 insertions(+), 34 deletions(-) diff --git a/src/periphery/BorrowerCallback.sol b/src/periphery/BorrowerCallback.sol index adcff29b1..c056e5dff 100644 --- a/src/periphery/BorrowerCallback.sol +++ b/src/periphery/BorrowerCallback.sol @@ -20,7 +20,7 @@ contract BorrowerCallback is ICallbacks { /// @dev Callback to supply collateral on behalf of borrower. /// @dev The callback contract should be authorized to supply collateral on behalf of the borrower. - function onSell(Obligation memory obligation, address seller, uint256, uint256, uint256, bytes memory data) + function onSell(bytes32, Obligation memory obligation, address seller, uint256, uint256, uint256, bytes memory data) external { require(msg.sender == MIDNIGHT, "unauthorized"); @@ -31,11 +31,11 @@ contract BorrowerCallback is ICallbacks { } } - function onBuy(Obligation memory, address, uint256, uint256, uint256, bytes memory) external pure { + function onBuy(bytes32, Obligation memory, address, uint256, uint256, uint256, bytes memory) external pure { revert("not implemented"); } - function onLiquidate(Obligation memory, uint256, uint256, uint256, address, bytes memory) external pure { + function onLiquidate(bytes32, Obligation memory, uint256, uint256, uint256, address, bytes memory) external pure { revert("not implemented"); } } diff --git a/src/periphery/ObligationLenderCallback.sol b/src/periphery/ObligationLenderCallback.sol index e80b461b8..5c8c79bc3 100644 --- a/src/periphery/ObligationLenderCallback.sol +++ b/src/periphery/ObligationLenderCallback.sol @@ -15,20 +15,20 @@ contract ObligationLenderCallback is ICallbacks { /// @dev Callback to withdraw funds from another Midnight obligation. /// @dev The callback contract should be authorized to withdraw funds on behalf of the lender. - function onBuy(Obligation memory, address buyer, uint256 buyerAssets, uint256, uint256, bytes memory data) + function onBuy(bytes32, Obligation memory, address buyer, uint256 buyerAssets, uint256, uint256, bytes memory data) external { require(msg.sender == MIDNIGHT, "unauthorized"); - bytes32 id = abi.decode(data, (bytes32)); - Obligation memory otherObligation = abi.decode(address(uint160(uint256(id))).code, (Obligation)); + bytes32 otherObligationId = abi.decode(data, (bytes32)); + Obligation memory otherObligation = abi.decode(address(uint160(uint256(otherObligationId))).code, (Obligation)); Midnight(MIDNIGHT).withdraw(otherObligation, buyerAssets, buyer, buyer); } - function onSell(Obligation memory, address, uint256, uint256, uint256, bytes memory) external pure { + function onSell(bytes32, Obligation memory, address, uint256, uint256, uint256, bytes memory) external pure { revert("not implemented"); } - function onLiquidate(Obligation memory, uint256, uint256, uint256, address, bytes memory) external pure { + function onLiquidate(bytes32, Obligation memory, uint256, uint256, uint256, address, bytes memory) external pure { revert("not implemented"); } } diff --git a/src/periphery/VaultLenderCallback.sol b/src/periphery/VaultLenderCallback.sol index 99a3a3898..e66b4ad78 100644 --- a/src/periphery/VaultLenderCallback.sol +++ b/src/periphery/VaultLenderCallback.sol @@ -15,7 +15,7 @@ contract VaultLenderCallback is ICallbacks { /// @dev Callback to withdraw funds from an ERC4626 vault. /// @dev The callback contract should be authorized to withdraw funds on behalf of the lender. - function onBuy(Obligation memory, address buyer, uint256 buyerAssets, uint256, uint256, bytes memory data) + function onBuy(bytes32, Obligation memory, address buyer, uint256 buyerAssets, uint256, uint256, bytes memory data) external { require(msg.sender == MIDNIGHT, "unauthorized"); @@ -23,11 +23,11 @@ contract VaultLenderCallback is ICallbacks { IERC4626(vault).withdraw(buyerAssets, buyer, buyer); } - function onSell(Obligation memory, address, uint256, uint256, uint256, bytes memory) external pure { + function onSell(bytes32, Obligation memory, address, uint256, uint256, uint256, bytes memory) external pure { revert("not implemented"); } - function onLiquidate(Obligation memory, uint256, uint256, uint256, address, bytes memory) external pure { + function onLiquidate(bytes32, Obligation memory, uint256, uint256, uint256, address, bytes memory) external pure { revert("not implemented"); } } diff --git a/test/BorrowerCallbackTest.sol b/test/BorrowerCallbackTest.sol index 28c4d1b5a..964fce320 100644 --- a/test/BorrowerCallbackTest.sol +++ b/test/BorrowerCallbackTest.sol @@ -4,7 +4,7 @@ pragma solidity ^0.8.0; import {Obligation, Offer, Signature, Collateral} from "../src/interfaces/IMidnight.sol"; import {Midnight} from "../src/Midnight.sol"; -import {WAD, ORACLE_PRICE_SCALE} from "../src/libraries/ConstantsLib.sol"; +import {WAD, ORACLE_PRICE_SCALE, LLTV_2} from "../src/libraries/ConstantsLib.sol"; import {UtilsLib} from "../src/libraries/UtilsLib.sol"; import {TickLib, MAX_TICK} from "../src/libraries/TickLib.sol"; import {BorrowerCallback, CollateralData} from "../src/periphery/BorrowerCallback.sol"; @@ -31,8 +31,8 @@ contract BorrowerCallbackTest is BaseTest { .push( Collateral({ token: address(collateralToken1), - lltv: 0.75e18, - maxLif: maxLif(0.75e18, 0.25e18), + lltv: LLTV_2, + maxLif: maxLif(LLTV_2, 0.25e18), oracle: address(oracle1) }) ); @@ -40,8 +40,8 @@ contract BorrowerCallbackTest is BaseTest { .push( Collateral({ token: address(collateralToken2), - lltv: 0.75e18, - maxLif: maxLif(0.75e18, 0.25e18), + lltv: LLTV_2, + maxLif: maxLif(LLTV_2, 0.25e18), oracle: address(oracle2) }) ); @@ -179,18 +179,18 @@ contract BorrowerCallbackTest is BaseTest { Obligation memory ob; vm.prank(makeAddr("attacker")); vm.expectRevert("unauthorized"); - borrowerCallback.onSell(ob, address(0), 0, 0, 0, ""); + borrowerCallback.onSell(bytes32(0), ob, address(0), 0, 0, 0, ""); } function testOnBuyReverts() public { Obligation memory ob; vm.expectRevert("not implemented"); - borrowerCallback.onBuy(ob, address(0), 0, 0, 0, ""); + borrowerCallback.onBuy(bytes32(0), ob, address(0), 0, 0, 0, ""); } function testOnLiquidateReverts() public { Obligation memory ob; vm.expectRevert("not implemented"); - borrowerCallback.onLiquidate(ob, 0, 0, 0, address(0), ""); + borrowerCallback.onLiquidate(bytes32(0), ob, 0, 0, 0, address(0), ""); } } diff --git a/test/LenderCallbackTest.sol b/test/LenderCallbackTest.sol index c47dbdf17..9db6e0ab0 100644 --- a/test/LenderCallbackTest.sol +++ b/test/LenderCallbackTest.sol @@ -4,7 +4,7 @@ pragma solidity ^0.8.0; import {Obligation, Offer, Signature, Collateral} from "../src/interfaces/IMidnight.sol"; import {Midnight} from "../src/Midnight.sol"; -import {WAD} from "../src/libraries/ConstantsLib.sol"; +import {WAD, LLTV_2} from "../src/libraries/ConstantsLib.sol"; import {UtilsLib} from "../src/libraries/UtilsLib.sol"; import {TickLib, MAX_TICK} from "../src/libraries/TickLib.sol"; import {VaultLenderCallback} from "../src/periphery/VaultLenderCallback.sol"; @@ -46,8 +46,8 @@ contract VaultLenderCallbackTest is BaseTest { .push( Collateral({ token: address(collateralToken1), - lltv: 0.75e18, - maxLif: maxLif(0.75e18, 0.25e18), + lltv: LLTV_2, + maxLif: maxLif(LLTV_2, 0.25e18), oracle: address(oracle1) }) ); @@ -55,8 +55,8 @@ contract VaultLenderCallbackTest is BaseTest { .push( Collateral({ token: address(collateralToken2), - lltv: 0.75e18, - maxLif: maxLif(0.75e18, 0.25e18), + lltv: LLTV_2, + maxLif: maxLif(LLTV_2, 0.25e18), oracle: address(oracle2) }) ); @@ -144,19 +144,19 @@ contract VaultLenderCallbackTest is BaseTest { Obligation memory ob; vm.prank(makeAddr("attacker")); vm.expectRevert("unauthorized"); - vaultLenderCallback.onBuy(ob, address(0), 0, 0, 0, ""); + vaultLenderCallback.onBuy(bytes32(0), ob, address(0), 0, 0, 0, ""); } function testOnSellReverts() public { Obligation memory ob; vm.expectRevert("not implemented"); - vaultLenderCallback.onSell(ob, address(0), 0, 0, 0, ""); + vaultLenderCallback.onSell(bytes32(0), ob, address(0), 0, 0, 0, ""); } function testOnLiquidateReverts() public { Obligation memory ob; vm.expectRevert("not implemented"); - vaultLenderCallback.onLiquidate(ob, 0, 0, 0, address(0), ""); + vaultLenderCallback.onLiquidate(bytes32(0), ob, 0, 0, 0, address(0), ""); } } @@ -179,8 +179,8 @@ contract ObligationLenderCallbackTest is BaseTest { .push( Collateral({ token: address(collateralToken1), - lltv: 0.75e18, - maxLif: maxLif(0.75e18, 0.25e18), + lltv: LLTV_2, + maxLif: maxLif(LLTV_2, 0.25e18), oracle: address(oracle1) }) ); @@ -188,8 +188,8 @@ contract ObligationLenderCallbackTest is BaseTest { .push( Collateral({ token: address(collateralToken2), - lltv: 0.75e18, - maxLif: maxLif(0.75e18, 0.25e18), + lltv: LLTV_2, + maxLif: maxLif(LLTV_2, 0.25e18), oracle: address(oracle2) }) ); @@ -305,18 +305,18 @@ contract ObligationLenderCallbackTest is BaseTest { Obligation memory ob; vm.prank(makeAddr("attacker")); vm.expectRevert("unauthorized"); - obligationLenderCallback.onBuy(ob, address(0), 0, 0, 0, ""); + obligationLenderCallback.onBuy(bytes32(0), ob, address(0), 0, 0, 0, ""); } function testOnSellReverts() public { Obligation memory ob; vm.expectRevert("not implemented"); - obligationLenderCallback.onSell(ob, address(0), 0, 0, 0, ""); + obligationLenderCallback.onSell(bytes32(0), ob, address(0), 0, 0, 0, ""); } function testOnLiquidateReverts() public { Obligation memory ob; vm.expectRevert("not implemented"); - obligationLenderCallback.onLiquidate(ob, 0, 0, 0, address(0), ""); + obligationLenderCallback.onLiquidate(bytes32(0), ob, 0, 0, 0, address(0), ""); } } From e7878915dd29d65f27d34a84b60a16bfed542a63 Mon Sep 17 00:00:00 2001 From: peyha Date: Tue, 7 Apr 2026 18:40:21 +0200 Subject: [PATCH 06/33] feat: update callback --- src/periphery/BorrowerCallback.sol | 17 ++++++++++--- src/periphery/ObligationLenderCallback.sol | 29 +++++++++++++++++----- src/periphery/VaultLenderCallback.sol | 29 +++++++++++++++++----- 3 files changed, 60 insertions(+), 15 deletions(-) diff --git a/src/periphery/BorrowerCallback.sol b/src/periphery/BorrowerCallback.sol index c056e5dff..3ba34b620 100644 --- a/src/periphery/BorrowerCallback.sol +++ b/src/periphery/BorrowerCallback.sol @@ -1,10 +1,11 @@ // SPDX-License-Identifier: GPL-2.0-or-later // Copyright (c) 2025 Morpho Association -pragma solidity 0.8.31; +pragma solidity 0.8.34; import {Obligation} from "../interfaces/IMidnight.sol"; import {Midnight} from "../Midnight.sol"; import {ICallbacks} from "../interfaces/ICallbacks.sol"; +import {CALLBACK_SUCCESS} from "../libraries/ConstantsLib.sol"; struct CollateralData { uint256 collateralIndex; @@ -20,8 +21,9 @@ contract BorrowerCallback is ICallbacks { /// @dev Callback to supply collateral on behalf of borrower. /// @dev The callback contract should be authorized to supply collateral on behalf of the borrower. - function onSell(bytes32, Obligation memory obligation, address seller, uint256, uint256, uint256, bytes memory data) + function onSell(bytes32, Obligation memory obligation, address seller, uint256, uint256, bytes memory data) external + returns (bytes32) { require(msg.sender == MIDNIGHT, "unauthorized"); CollateralData[] memory collateralData = abi.decode(data, (CollateralData[])); @@ -29,13 +31,22 @@ contract BorrowerCallback is ICallbacks { Midnight(MIDNIGHT) .supplyCollateral(obligation, collateralData[i].collateralIndex, collateralData[i].amount, seller); } + return CALLBACK_SUCCESS; } - function onBuy(bytes32, Obligation memory, address, uint256, uint256, uint256, bytes memory) external pure { + function onBuy(bytes32, Obligation memory, address, uint256, uint256, bytes memory) + external + pure + returns (bytes32) + { revert("not implemented"); } function onLiquidate(bytes32, Obligation memory, uint256, uint256, uint256, address, bytes memory) external pure { revert("not implemented"); } + + function onRepay(bytes32, Obligation memory, uint256, address, bytes memory) external pure { + revert("not implemented"); + } } diff --git a/src/periphery/ObligationLenderCallback.sol b/src/periphery/ObligationLenderCallback.sol index 5c8c79bc3..93a5b744e 100644 --- a/src/periphery/ObligationLenderCallback.sol +++ b/src/periphery/ObligationLenderCallback.sol @@ -1,10 +1,12 @@ // SPDX-License-Identifier: GPL-2.0-or-later // Copyright (c) 2025 Morpho Association -pragma solidity 0.8.31; +pragma solidity 0.8.34; import {Obligation} from "../interfaces/IMidnight.sol"; import {Midnight} from "../Midnight.sol"; import {ICallbacks} from "../interfaces/ICallbacks.sol"; +import {IERC20} from "./IERC20.sol"; +import {CALLBACK_SUCCESS} from "../libraries/ConstantsLib.sol"; contract ObligationLenderCallback is ICallbacks { address public immutable MIDNIGHT; @@ -15,20 +17,35 @@ contract ObligationLenderCallback is ICallbacks { /// @dev Callback to withdraw funds from another Midnight obligation. /// @dev The callback contract should be authorized to withdraw funds on behalf of the lender. - function onBuy(bytes32, Obligation memory, address buyer, uint256 buyerAssets, uint256, uint256, bytes memory data) - external - { + function onBuy( + bytes32, + Obligation memory obligation, + address buyer, + uint256 buyerAssets, + uint256, + bytes memory data + ) external returns (bytes32) { require(msg.sender == MIDNIGHT, "unauthorized"); bytes32 otherObligationId = abi.decode(data, (bytes32)); Obligation memory otherObligation = abi.decode(address(uint160(uint256(otherObligationId))).code, (Obligation)); - Midnight(MIDNIGHT).withdraw(otherObligation, buyerAssets, buyer, buyer); + Midnight(MIDNIGHT).withdraw(otherObligation, buyerAssets, buyer, address(this)); + IERC20(obligation.loanToken).approve(MIDNIGHT, buyerAssets); + return CALLBACK_SUCCESS; } - function onSell(bytes32, Obligation memory, address, uint256, uint256, uint256, bytes memory) external pure { + function onSell(bytes32, Obligation memory, address, uint256, uint256, bytes memory) + external + pure + returns (bytes32) + { revert("not implemented"); } function onLiquidate(bytes32, Obligation memory, uint256, uint256, uint256, address, bytes memory) external pure { revert("not implemented"); } + + function onRepay(bytes32, Obligation memory, uint256, address, bytes memory) external pure { + revert("not implemented"); + } } diff --git a/src/periphery/VaultLenderCallback.sol b/src/periphery/VaultLenderCallback.sol index e66b4ad78..5bc693cf5 100644 --- a/src/periphery/VaultLenderCallback.sol +++ b/src/periphery/VaultLenderCallback.sol @@ -1,10 +1,12 @@ // SPDX-License-Identifier: GPL-2.0-or-later // Copyright (c) 2025 Morpho Association -pragma solidity 0.8.31; +pragma solidity 0.8.34; import {Obligation} from "../interfaces/IMidnight.sol"; import {IERC4626} from "./IERC4626.sol"; +import {IERC20} from "./IERC20.sol"; import {ICallbacks} from "../interfaces/ICallbacks.sol"; +import {CALLBACK_SUCCESS} from "../libraries/ConstantsLib.sol"; contract VaultLenderCallback is ICallbacks { address public immutable MIDNIGHT; @@ -15,19 +17,34 @@ contract VaultLenderCallback is ICallbacks { /// @dev Callback to withdraw funds from an ERC4626 vault. /// @dev The callback contract should be authorized to withdraw funds on behalf of the lender. - function onBuy(bytes32, Obligation memory, address buyer, uint256 buyerAssets, uint256, uint256, bytes memory data) - external - { + function onBuy( + bytes32, + Obligation memory obligation, + address buyer, + uint256 buyerAssets, + uint256, + bytes memory data + ) external returns (bytes32) { require(msg.sender == MIDNIGHT, "unauthorized"); address vault = abi.decode(data, (address)); - IERC4626(vault).withdraw(buyerAssets, buyer, buyer); + IERC4626(vault).withdraw(buyerAssets, address(this), buyer); + IERC20(obligation.loanToken).approve(MIDNIGHT, buyerAssets); + return CALLBACK_SUCCESS; } - function onSell(bytes32, Obligation memory, address, uint256, uint256, uint256, bytes memory) external pure { + function onSell(bytes32, Obligation memory, address, uint256, uint256, bytes memory) + external + pure + returns (bytes32) + { revert("not implemented"); } function onLiquidate(bytes32, Obligation memory, uint256, uint256, uint256, address, bytes memory) external pure { revert("not implemented"); } + + function onRepay(bytes32, Obligation memory, uint256, address, bytes memory) external pure { + revert("not implemented"); + } } From 40008a96dc11d557724680d60d5962aa6b7bece7 Mon Sep 17 00:00:00 2001 From: peyha Date: Tue, 7 Apr 2026 18:40:50 +0200 Subject: [PATCH 07/33] test --- test/BorrowerCallbackTest.sol | 78 ++++++++++++++++++++--------------- test/LenderCallbackTest.sol | 57 ++++++++++++++++--------- 2 files changed, 81 insertions(+), 54 deletions(-) diff --git a/test/BorrowerCallbackTest.sol b/test/BorrowerCallbackTest.sol index 964fce320..ff74cdf62 100644 --- a/test/BorrowerCallbackTest.sol +++ b/test/BorrowerCallbackTest.sol @@ -2,15 +2,14 @@ // Copyright (c) 2025 Morpho Association pragma solidity ^0.8.0; -import {Obligation, Offer, Signature, Collateral} from "../src/interfaces/IMidnight.sol"; -import {Midnight} from "../src/Midnight.sol"; -import {WAD, ORACLE_PRICE_SCALE, LLTV_2} from "../src/libraries/ConstantsLib.sol"; +import {Obligation, Offer, CollateralParams} from "../src/interfaces/IMidnight.sol"; +import {WAD, LLTV_2} from "../src/libraries/ConstantsLib.sol"; import {UtilsLib} from "../src/libraries/UtilsLib.sol"; import {TickLib, MAX_TICK} from "../src/libraries/TickLib.sol"; import {BorrowerCallback, CollateralData} from "../src/periphery/BorrowerCallback.sol"; import {BaseTest} from "./BaseTest.sol"; -import {ERC20} from "./helpers/ERC20.sol"; +import {ERC20} from "./erc20s/ERC20.sol"; contract BorrowerCallbackTest is BaseTest { using UtilsLib for uint256; @@ -27,25 +26,25 @@ contract BorrowerCallbackTest is BaseTest { obligation.loanToken = address(loanToken); obligation.maturity = block.timestamp + 100; - obligation.collaterals + obligation.collateralParams .push( - Collateral({ + CollateralParams({ token: address(collateralToken1), lltv: LLTV_2, maxLif: maxLif(LLTV_2, 0.25e18), oracle: address(oracle1) }) ); - obligation.collaterals + obligation.collateralParams .push( - Collateral({ + CollateralParams({ token: address(collateralToken2), lltv: LLTV_2, maxLif: maxLif(LLTV_2, 0.25e18), oracle: address(oracle2) }) ); - obligation.collaterals = sortCollaterals(obligation.collaterals); + obligation.collateralParams = sortCollateralParams(obligation.collateralParams); obligation.rcfThreshold = 0; id = toId(obligation); @@ -55,6 +54,7 @@ contract BorrowerCallbackTest is BaseTest { borrowerOffer.receiverIfMakerIsSeller = borrower; borrowerOffer.maxUnits = type(uint256).max; borrowerOffer.obligation = obligation; + borrowerOffer.ratifier = address(ecrecoverRatifier); borrowerOffer.expiry = block.timestamp + 200; borrowerOffer.tick = MAX_TICK; } @@ -65,7 +65,7 @@ contract BorrowerCallbackTest is BaseTest { function testOnSellSingleCollateralMaker(uint256 units) public { units = bound(units, 1, 1e33); - uint256 collateral = units.mulDivUp(WAD, obligation.collaterals[0].lltv); + uint256 collateral = units.mulDivUp(WAD, obligation.collateralParams[0].lltv); borrowerOffer.callback = address(borrowerCallback); CollateralData[] memory collateralData = new CollateralData[](1); @@ -78,24 +78,25 @@ contract BorrowerCallbackTest is BaseTest { deal(address(loanToken), lender, units.mulDivUp(price, WAD)); // Fund callback with collateral tokens and approve midnight. - deal(obligation.collaterals[0].token, address(borrowerCallback), collateral); + deal(obligation.collateralParams[0].token, address(borrowerCallback), collateral); vm.prank(address(borrowerCallback)); - ERC20(obligation.collaterals[0].token).approve(address(midnight), collateral); + ERC20(obligation.collateralParams[0].token).approve(address(midnight), collateral); // Authorize callback to supply collateral on behalf of borrower. - authorize(borrower, address(borrowerCallback)); + vm.prank(borrower); + midnight.setIsAuthorized(borrower, address(borrowerCallback), true); - assertEq(midnight.collateralOf(id, borrower, 0), 0); + assertEq(midnight.collateral(id, borrower, 0), 0); take(units, lender, borrowerOffer); - assertEq(midnight.collateralOf(id, borrower, 0), collateral); + assertEq(midnight.collateral(id, borrower, 0), collateral); } function testOnSellMultipleCollateralsMaker(uint256 units) public { units = bound(units, 1, 1e33); - uint256 collateral0 = units.mulDivUp(WAD, obligation.collaterals[0].lltv); - uint256 collateral1 = units.mulDivUp(WAD, obligation.collaterals[1].lltv); + uint256 collateral0 = units.mulDivUp(WAD, obligation.collateralParams[0].lltv); + uint256 collateral1 = units.mulDivUp(WAD, obligation.collateralParams[1].lltv); borrowerOffer.callback = address(borrowerCallback); CollateralData[] memory collateralData = new CollateralData[](2); @@ -109,28 +110,29 @@ contract BorrowerCallbackTest is BaseTest { deal(address(loanToken), lender, units.mulDivUp(price, WAD)); // Fund callback with collateral tokens and approve midnight. - deal(obligation.collaterals[0].token, address(borrowerCallback), collateral0); - deal(obligation.collaterals[1].token, address(borrowerCallback), collateral1); + deal(obligation.collateralParams[0].token, address(borrowerCallback), collateral0); + deal(obligation.collateralParams[1].token, address(borrowerCallback), collateral1); vm.prank(address(borrowerCallback)); - ERC20(obligation.collaterals[0].token).approve(address(midnight), collateral0); + ERC20(obligation.collateralParams[0].token).approve(address(midnight), collateral0); vm.prank(address(borrowerCallback)); - ERC20(obligation.collaterals[1].token).approve(address(midnight), collateral1); + ERC20(obligation.collateralParams[1].token).approve(address(midnight), collateral1); // Authorize callback to supply collateral on behalf of borrower. - authorize(borrower, address(borrowerCallback)); + vm.prank(borrower); + midnight.setIsAuthorized(borrower, address(borrowerCallback), true); - assertEq(midnight.collateralOf(id, borrower, 0), 0); - assertEq(midnight.collateralOf(id, borrower, 1), 0); + assertEq(midnight.collateral(id, borrower, 0), 0); + assertEq(midnight.collateral(id, borrower, 1), 0); take(units, lender, borrowerOffer); - assertEq(midnight.collateralOf(id, borrower, 0), collateral0); - assertEq(midnight.collateralOf(id, borrower, 1), collateral1); + assertEq(midnight.collateral(id, borrower, 0), collateral0); + assertEq(midnight.collateral(id, borrower, 1), collateral1); } function testOnSellTaker(uint256 units) public { units = bound(units, 1, 1e33); - uint256 collateral = units.mulDivUp(WAD, obligation.collaterals[0].lltv); + uint256 collateral = units.mulDivUp(WAD, obligation.collateralParams[0].lltv); // Lender makes a buy offer. Offer memory lenderOffer; @@ -138,6 +140,7 @@ contract BorrowerCallbackTest is BaseTest { lenderOffer.maker = lender; lenderOffer.maxUnits = units; lenderOffer.obligation = obligation; + lenderOffer.ratifier = address(ecrecoverRatifier); lenderOffer.expiry = block.timestamp + 200; lenderOffer.tick = MAX_TICK; @@ -146,17 +149,18 @@ contract BorrowerCallbackTest is BaseTest { deal(address(loanToken), lender, units.mulDivDown(price, WAD)); // Fund callback with collateral tokens and approve midnight. - deal(obligation.collaterals[0].token, address(borrowerCallback), collateral); + deal(obligation.collateralParams[0].token, address(borrowerCallback), collateral); vm.prank(address(borrowerCallback)); - ERC20(obligation.collaterals[0].token).approve(address(midnight), collateral); + ERC20(obligation.collateralParams[0].token).approve(address(midnight), collateral); // Authorize callback to supply collateral on behalf of borrower. - authorize(borrower, address(borrowerCallback)); + vm.prank(borrower); + midnight.setIsAuthorized(borrower, address(borrowerCallback), true); CollateralData[] memory collateralData = new CollateralData[](1); collateralData[0] = CollateralData({collateralIndex: 0, amount: collateral}); - assertEq(midnight.collateralOf(id, borrower, 0), 0); + assertEq(midnight.collateral(id, borrower, 0), 0); // Borrower takes the lender's buy offer, passing BorrowerCallback as taker callback. vm.prank(borrower); @@ -172,20 +176,20 @@ contract BorrowerCallbackTest is BaseTest { proof([lenderOffer]) ); - assertEq(midnight.collateralOf(id, borrower, 0), collateral); + assertEq(midnight.collateral(id, borrower, 0), collateral); } function testOnSellUnauthorized() public { Obligation memory ob; vm.prank(makeAddr("attacker")); vm.expectRevert("unauthorized"); - borrowerCallback.onSell(bytes32(0), ob, address(0), 0, 0, 0, ""); + borrowerCallback.onSell(bytes32(0), ob, address(0), 0, 0, ""); } function testOnBuyReverts() public { Obligation memory ob; vm.expectRevert("not implemented"); - borrowerCallback.onBuy(bytes32(0), ob, address(0), 0, 0, 0, ""); + borrowerCallback.onBuy(bytes32(0), ob, address(0), 0, 0, ""); } function testOnLiquidateReverts() public { @@ -193,4 +197,10 @@ contract BorrowerCallbackTest is BaseTest { vm.expectRevert("not implemented"); borrowerCallback.onLiquidate(bytes32(0), ob, 0, 0, 0, address(0), ""); } + + function testOnRepayReverts() public { + Obligation memory ob; + vm.expectRevert("not implemented"); + borrowerCallback.onRepay(bytes32(0), ob, 0, address(0), ""); + } } diff --git a/test/LenderCallbackTest.sol b/test/LenderCallbackTest.sol index 9db6e0ab0..09ba03823 100644 --- a/test/LenderCallbackTest.sol +++ b/test/LenderCallbackTest.sol @@ -2,8 +2,7 @@ // Copyright (c) 2025 Morpho Association pragma solidity ^0.8.0; -import {Obligation, Offer, Signature, Collateral} from "../src/interfaces/IMidnight.sol"; -import {Midnight} from "../src/Midnight.sol"; +import {Obligation, Offer, CollateralParams} from "../src/interfaces/IMidnight.sol"; import {WAD, LLTV_2} from "../src/libraries/ConstantsLib.sol"; import {UtilsLib} from "../src/libraries/UtilsLib.sol"; import {TickLib, MAX_TICK} from "../src/libraries/TickLib.sol"; @@ -11,7 +10,7 @@ import {VaultLenderCallback} from "../src/periphery/VaultLenderCallback.sol"; import {ObligationLenderCallback} from "../src/periphery/ObligationLenderCallback.sol"; import {BaseTest} from "./BaseTest.sol"; -import {ERC20} from "./helpers/ERC20.sol"; +import {ERC20} from "./erc20s/ERC20.sol"; // TODO: use real vault v2 contract MockVault { @@ -42,25 +41,25 @@ contract VaultLenderCallbackTest is BaseTest { obligation.loanToken = address(loanToken); obligation.maturity = block.timestamp + 100; - obligation.collaterals + obligation.collateralParams .push( - Collateral({ + CollateralParams({ token: address(collateralToken1), lltv: LLTV_2, maxLif: maxLif(LLTV_2, 0.25e18), oracle: address(oracle1) }) ); - obligation.collaterals + obligation.collateralParams .push( - Collateral({ + CollateralParams({ token: address(collateralToken2), lltv: LLTV_2, maxLif: maxLif(LLTV_2, 0.25e18), oracle: address(oracle2) }) ); - obligation.collaterals = sortCollaterals(obligation.collaterals); + obligation.collateralParams = sortCollateralParams(obligation.collateralParams); obligation.rcfThreshold = 0; id = toId(obligation); @@ -70,6 +69,7 @@ contract VaultLenderCallbackTest is BaseTest { borrowerOffer.receiverIfMakerIsSeller = borrower; borrowerOffer.maxUnits = type(uint256).max; borrowerOffer.obligation = obligation; + borrowerOffer.ratifier = address(ecrecoverRatifier); borrowerOffer.expiry = block.timestamp + 200; borrowerOffer.tick = MAX_TICK; } @@ -95,6 +95,7 @@ contract VaultLenderCallbackTest is BaseTest { lenderOffer.callbackData = abi.encode(address(vault)); lenderOffer.maxUnits = units; lenderOffer.obligation = obligation; + lenderOffer.ratifier = address(ecrecoverRatifier); lenderOffer.expiry = block.timestamp + 200; lenderOffer.tick = MAX_TICK; @@ -144,13 +145,13 @@ contract VaultLenderCallbackTest is BaseTest { Obligation memory ob; vm.prank(makeAddr("attacker")); vm.expectRevert("unauthorized"); - vaultLenderCallback.onBuy(bytes32(0), ob, address(0), 0, 0, 0, ""); + vaultLenderCallback.onBuy(bytes32(0), ob, address(0), 0, 0, ""); } function testOnSellReverts() public { Obligation memory ob; vm.expectRevert("not implemented"); - vaultLenderCallback.onSell(bytes32(0), ob, address(0), 0, 0, 0, ""); + vaultLenderCallback.onSell(bytes32(0), ob, address(0), 0, 0, ""); } function testOnLiquidateReverts() public { @@ -158,6 +159,12 @@ contract VaultLenderCallbackTest is BaseTest { vm.expectRevert("not implemented"); vaultLenderCallback.onLiquidate(bytes32(0), ob, 0, 0, 0, address(0), ""); } + + function testOnRepayReverts() public { + Obligation memory ob; + vm.expectRevert("not implemented"); + vaultLenderCallback.onRepay(bytes32(0), ob, 0, address(0), ""); + } } contract ObligationLenderCallbackTest is BaseTest { @@ -175,25 +182,25 @@ contract ObligationLenderCallbackTest is BaseTest { obligation.loanToken = address(loanToken); obligation.maturity = block.timestamp + 100; - obligation.collaterals + obligation.collateralParams .push( - Collateral({ + CollateralParams({ token: address(collateralToken1), lltv: LLTV_2, maxLif: maxLif(LLTV_2, 0.25e18), oracle: address(oracle1) }) ); - obligation.collaterals + obligation.collateralParams .push( - Collateral({ + CollateralParams({ token: address(collateralToken2), lltv: LLTV_2, maxLif: maxLif(LLTV_2, 0.25e18), oracle: address(oracle2) }) ); - obligation.collaterals = sortCollaterals(obligation.collaterals); + obligation.collateralParams = sortCollateralParams(obligation.collateralParams); obligation.rcfThreshold = 0; id = toId(obligation); @@ -203,6 +210,7 @@ contract ObligationLenderCallbackTest is BaseTest { borrowerOffer.receiverIfMakerIsSeller = borrower; borrowerOffer.maxUnits = type(uint256).max; borrowerOffer.obligation = obligation; + borrowerOffer.ratifier = address(ecrecoverRatifier); borrowerOffer.expiry = block.timestamp + 200; borrowerOffer.tick = MAX_TICK; } @@ -215,7 +223,7 @@ contract ObligationLenderCallbackTest is BaseTest { function _setupMidnightSource(uint256 buyerAssets) internal returns (Obligation memory obligation2, bytes32 id2) { obligation2.loanToken = address(loanToken); obligation2.maturity = block.timestamp + 200; - obligation2.collaterals = obligation.collaterals; + obligation2.collateralParams = obligation.collateralParams; obligation2.rcfThreshold = 0; id2 = toId(obligation2); @@ -228,6 +236,7 @@ contract ObligationLenderCallbackTest is BaseTest { lenderOffer2.maker = lender; lenderOffer2.maxUnits = buyerAssets; lenderOffer2.obligation = obligation2; + lenderOffer2.ratifier = address(ecrecoverRatifier); lenderOffer2.expiry = block.timestamp + 300; lenderOffer2.tick = MAX_TICK; lenderOffer2.group = keccak256("obligation2"); @@ -237,10 +246,11 @@ contract ObligationLenderCallbackTest is BaseTest { // Borrower repays to create withdrawable funds. deal(address(loanToken), borrower, buyerAssets); vm.prank(borrower); - midnight.repay(obligation2, buyerAssets, borrower); + midnight.repay(obligation2, buyerAssets, borrower, hex""); // Authorize callback to withdraw on behalf of lender. - authorize(lender, address(obligationLenderCallback)); + vm.prank(lender); + midnight.setIsAuthorized(lender, address(obligationLenderCallback), true); } function testOnBuyMidnightMaker(uint256 units) public { @@ -258,6 +268,7 @@ contract ObligationLenderCallbackTest is BaseTest { lenderOffer.callbackData = abi.encode(address(uint160(uint256(id2)))); lenderOffer.maxUnits = units; lenderOffer.obligation = obligation; + lenderOffer.ratifier = address(ecrecoverRatifier); lenderOffer.expiry = block.timestamp + 200; lenderOffer.tick = MAX_TICK; @@ -305,13 +316,13 @@ contract ObligationLenderCallbackTest is BaseTest { Obligation memory ob; vm.prank(makeAddr("attacker")); vm.expectRevert("unauthorized"); - obligationLenderCallback.onBuy(bytes32(0), ob, address(0), 0, 0, 0, ""); + obligationLenderCallback.onBuy(bytes32(0), ob, address(0), 0, 0, ""); } function testOnSellReverts() public { Obligation memory ob; vm.expectRevert("not implemented"); - obligationLenderCallback.onSell(bytes32(0), ob, address(0), 0, 0, 0, ""); + obligationLenderCallback.onSell(bytes32(0), ob, address(0), 0, 0, ""); } function testOnLiquidateReverts() public { @@ -319,4 +330,10 @@ contract ObligationLenderCallbackTest is BaseTest { vm.expectRevert("not implemented"); obligationLenderCallback.onLiquidate(bytes32(0), ob, 0, 0, 0, address(0), ""); } + + function testOnRepayReverts() public { + Obligation memory ob; + vm.expectRevert("not implemented"); + obligationLenderCallback.onRepay(bytes32(0), ob, 0, address(0), ""); + } } From ee5c8fdad9c5166cf0adebe2d4ca656279db2b8f Mon Sep 17 00:00:00 2001 From: peyha Date: Tue, 7 Apr 2026 18:45:26 +0200 Subject: [PATCH 08/33] chore: lint --- src/Midnight.sol | 15 ++++++++++++++- test/LenderCallbackTest.sol | 1 + 2 files changed, 15 insertions(+), 1 deletion(-) diff --git a/src/Midnight.sol b/src/Midnight.sol index 018f8904a..d8e50eb3e 100644 --- a/src/Midnight.sol +++ b/src/Midnight.sol @@ -6,7 +6,20 @@ import {UtilsLib} from "./libraries/UtilsLib.sol"; import {IdLib} from "./libraries/IdLib.sol"; import {TickLib} from "./libraries/TickLib.sol"; import {SafeTransferLib} from "./libraries/SafeTransferLib.sol"; -import "./libraries/ConstantsLib.sol"; +import { + WAD, + ORACLE_PRICE_SCALE, + FEE_STEP, + MAX_CONTINUOUS_FEE, + TIME_TO_MAX_LIF, + MAX_COLLATERALS, + MAX_COLLATERALS_PER_BORROWER, + LIQUIDATION_CURSOR_LOW, + LIQUIDATION_CURSOR_HIGH, + LIQUIDATION_LOCK_SLOT, + CALLBACK_SUCCESS, + isLltvAllowed +} from "./libraries/ConstantsLib.sol"; import {IOracle} from "./interfaces/IOracle.sol"; import {IMidnight, Obligation, Offer, CollateralParams, ObligationState, Position} from "./interfaces/IMidnight.sol"; import {ICallbacks, IFlashLoanCallback} from "./interfaces/ICallbacks.sol"; diff --git a/test/LenderCallbackTest.sol b/test/LenderCallbackTest.sol index 09ba03823..bce7bea61 100644 --- a/test/LenderCallbackTest.sol +++ b/test/LenderCallbackTest.sol @@ -21,6 +21,7 @@ contract MockVault { } function withdraw(uint256 assets, address receiver, address) external returns (uint256) { + // forge-lint: disable-next-line(erc20-unchecked-transfer) test mock with controlled ERC20. ERC20(asset).transfer(receiver, assets); return assets; } From 6b5a3996abffab0c147addc0311ab3d8dbedfd32 Mon Sep 17 00:00:00 2001 From: MathisGD <74971347+MathisGD@users.noreply.github.com> Date: Fri, 10 Apr 2026 10:17:06 +0200 Subject: [PATCH 09/33] check size in ci (#680) --- .github/workflows/forge.yml | 16 ++++++++++++++++ foundry.toml | 2 +- 2 files changed, 17 insertions(+), 1 deletion(-) diff --git a/.github/workflows/forge.yml b/.github/workflows/forge.yml index 8f0cc2a2c..e22d804f2 100644 --- a/.github/workflows/forge.yml +++ b/.github/workflows/forge.yml @@ -38,6 +38,22 @@ jobs: - name: Run forge lint run: forge lint --deny notes + + sizes: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: + submodules: recursive + + - name: Install Foundry + uses: foundry-rs/foundry-toolchain@v1 + with: + version: nightly-1e342ef7d296a674ea248e95ed12c152f7e00ee6 + + - name: Run forge build + run: forge build --force --sizes + test: runs-on: ubuntu-latest strategy: diff --git a/foundry.toml b/foundry.toml index 9b17622d2..1ef3f0850 100644 --- a/foundry.toml +++ b/foundry.toml @@ -1,7 +1,7 @@ [profile.default] via_ir = true optimizer = true -optimizer_runs = 500 +optimizer_runs = 58 evm_version = "osaka" fs_permissions = [{ access = "read", path = "test/ticks_exact.json" }] From 2f955913719a4be2cf1237410a6e3840a102e36c Mon Sep 17 00:00:00 2001 From: MathisGD <74971347+MathisGD@users.noreply.github.com> Date: Tue, 14 Apr 2026 11:56:34 +0200 Subject: [PATCH 10/33] test: fix testReturnJumps (#683) --- test/TickLibTest.sol | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/test/TickLibTest.sol b/test/TickLibTest.sol index 6cac25959..cdf9aa1e0 100644 --- a/test/TickLibTest.sol +++ b/test/TickLibTest.sol @@ -26,11 +26,14 @@ contract TickLibTest is BaseTest { } function testReturnJumps() public pure { - for (uint256 i = 220; i <= 770; i++) { + for (uint256 i = 250; i <= 800; i++) { uint256 previousReturn = _return(TickLib.tickToPrice(i - 1)); uint256 currentReturn = _return(TickLib.tickToPrice(i)); assertApproxEqRel( - currentReturn.mulDivDown(1e18, previousReturn), 1.025e18, 0.1e18, string.concat("tick ", vm.toString(i)) + currentReturn.mulDivDown(1e18, previousReturn), + 0.975e18, + 0.005e18, + string.concat("tick ", vm.toString(i)) ); } } From 5690f7e827b9ef9aeca9701b0be16c1e92857955 Mon Sep 17 00:00:00 2001 From: "prd-carapulse[bot]" <264278285+prd-carapulse[bot]@users.noreply.github.com> Date: Tue, 14 Apr 2026 18:48:58 +0200 Subject: [PATCH 11/33] Allow ApprovalRatifier approvals to be delegated (#686) Co-authored-by: prd-carapulse[bot] <264278285+prd-carapulse[bot]@users.noreply.github.com> Co-authored-by: Quentin Garchery --- ...pprovalRatifier.sol => SetterRatifier.sol} | 17 ++-- test/SetterRatifierTest.sol | 83 +++++++++++++++++++ 2 files changed, 95 insertions(+), 5 deletions(-) rename src/ratifiers/{ApprovalRatifier.sol => SetterRatifier.sol} (54%) create mode 100644 test/SetterRatifierTest.sol diff --git a/src/ratifiers/ApprovalRatifier.sol b/src/ratifiers/SetterRatifier.sol similarity index 54% rename from src/ratifiers/ApprovalRatifier.sol rename to src/ratifiers/SetterRatifier.sol index 1ac67120f..ed8e43c3e 100644 --- a/src/ratifiers/ApprovalRatifier.sol +++ b/src/ratifiers/SetterRatifier.sol @@ -3,17 +3,24 @@ pragma solidity 0.8.34; import {IRatifier} from "../interfaces/IRatifier.sol"; -import {Offer} from "../interfaces/IMidnight.sol"; +import {IMidnight, Offer} from "../interfaces/IMidnight.sol"; import {CALLBACK_SUCCESS} from "../libraries/ConstantsLib.sol"; -contract ApprovalRatifier is IRatifier { +contract SetterRatifier is IRatifier { event SetApproval(address indexed maker, bytes32 indexed root, bool newApproval); + address public immutable MIDNIGHT; + mapping(address maker => mapping(bytes32 root => bool)) public approved; - function setApproval(bytes32 root, bool newApproval) external { - approved[msg.sender][root] = newApproval; - emit SetApproval(msg.sender, root, newApproval); + constructor(address _midnight) { + MIDNIGHT = _midnight; + } + + function setApproval(address maker, bytes32 root, bool newApproval) public { + require(maker == msg.sender || IMidnight(MIDNIGHT).isAuthorized(maker, msg.sender), "unauthorized"); + approved[maker][root] = newApproval; + emit SetApproval(maker, root, newApproval); } function onRatify(Offer memory offer, bytes32 root, bytes memory) external view returns (bytes32) { diff --git a/test/SetterRatifierTest.sol b/test/SetterRatifierTest.sol new file mode 100644 index 000000000..26c45c506 --- /dev/null +++ b/test/SetterRatifierTest.sol @@ -0,0 +1,83 @@ +// SPDX-License-Identifier: GPL-2.0-or-later +// Copyright (c) 2025 Morpho Association +pragma solidity ^0.8.0; + +import {CollateralParams, Obligation, Offer} from "../src/interfaces/IMidnight.sol"; +import {SetterRatifier} from "../src/ratifiers/SetterRatifier.sol"; +import {CALLBACK_SUCCESS} from "../src/libraries/ConstantsLib.sol"; +import {MAX_TICK} from "../src/libraries/TickLib.sol"; +import {BaseTest} from "./BaseTest.sol"; + +contract SetterRatifierTest is BaseTest { + SetterRatifier internal setterRatifier; + + function setUp() public override { + super.setUp(); + setterRatifier = new SetterRatifier(address(midnight)); + } + + function makeOffer(address maker) internal view returns (Offer memory offer) { + Obligation memory obligation; + obligation.loanToken = address(loanToken); + obligation.maturity = block.timestamp + 100; + obligation.collateralParams = new CollateralParams[](1); + obligation.collateralParams[0] = CollateralParams({ + token: address(collateralToken1), lltv: 0.77e18, maxLif: maxLif(0.77e18, 0.25e18), oracle: address(oracle1) + }); + + offer.obligation = obligation; + offer.buy = true; + offer.maker = maker; + offer.ratifier = address(setterRatifier); + offer.maxUnits = type(uint256).max; + offer.expiry = block.timestamp + 200; + offer.tick = MAX_TICK; + } + + function testSetApprovalMaker() public { + bytes32 _root = keccak256("root"); + + vm.prank(lender); + setterRatifier.setApproval(lender, _root, true); + + assertTrue(setterRatifier.approved(lender, _root)); + } + + function testOnRatifyAuthorizedSetterCanApproveOnBehalf() public { + Offer memory offer = makeOffer(lender); + bytes32 _root = keccak256(abi.encode(offer)); + + vm.prank(lender); + midnight.setIsAuthorized(lender, borrower, true); + + vm.prank(borrower); + setterRatifier.setApproval(lender, _root, true); + + bytes32 result = setterRatifier.onRatify(offer, _root, ""); + assertEq(result, CALLBACK_SUCCESS); + } + + function testTakeAuthorizedSetterCanApproveOnBehalf() public { + Offer memory offer = makeOffer(lender); + bytes32 _root = keccak256(abi.encode(offer)); + + vm.prank(lender); + midnight.setIsAuthorized(lender, address(setterRatifier), true); + vm.prank(lender); + midnight.setIsAuthorized(lender, borrower, true); + + vm.prank(borrower); + setterRatifier.setApproval(lender, _root, true); + + vm.prank(borrower); + midnight.take(0, borrower, address(0), hex"", borrower, offer, emptySig, _root, proof([offer])); + } + + function testSetApprovalUnauthorizedOnBehalf() public { + bytes32 _root = keccak256("root"); + + vm.prank(borrower); + vm.expectRevert("unauthorized"); + setterRatifier.setApproval(lender, _root, true); + } +} From bdd585c918d5effd480f0263360a9985807d41b1 Mon Sep 17 00:00:00 2001 From: Quentin Garchery Date: Tue, 14 Apr 2026 23:04:48 +0200 Subject: [PATCH 12/33] [Certora] onlyAuthorizedCanChange liquidate (#492) --- certora/specs/Liquidate.spec | 74 ++++++++++++++++++++---------------- 1 file changed, 41 insertions(+), 33 deletions(-) diff --git a/certora/specs/Liquidate.spec b/certora/specs/Liquidate.spec index 57908786c..e0ee8f74b 100644 --- a/certora/specs/Liquidate.spec +++ b/certora/specs/Liquidate.spec @@ -3,58 +3,66 @@ methods { function multicall(bytes[]) external => HAVOC_ALL DELETE; - function _.price() external => CVL_price(calledContract) expect(uint256); - function IdLib.toId(Midnight.Obligation memory obligation, uint256 chainId, address midnight) internal returns (bytes32) => CVL_toId(obligation, chainId, midnight); - function UtilsLib.msb(uint128 bitmap) internal returns (uint256) => CVL_msb(bitmap); - function UtilsLib.mulDivDown(uint256 a, uint256 b, uint256 denominator) internal returns (uint256) => CVL_mulDivDown(a, b, denominator); - function UtilsLib.mulDivUp(uint256 a, uint256 b, uint256 denominator) internal returns (uint256) => CVL_mulDivUp(a, b, denominator); + // Oracle summary: we assume the price does not change during the execution of a transaction. + function _.price() external => PER_CALLEE_CONSTANT; + + // UtilsLib summaries: msb, mulDivDown, and mulDivUp are deterministic. + function UtilsLib.msb(uint128 bitmap) internal returns (uint256) => summaryMsb(bitmap); + function UtilsLib.mulDivDown(uint256 a, uint256 b, uint256 denominator) internal returns (uint256) => summaryMulDivDown(a, b, denominator); + function UtilsLib.mulDivUp(uint256 a, uint256 b, uint256 denominator) internal returns (uint256) => summaryMulDivUp(a, b, denominator); + + // IdLib summary: remember the last id returned by toId. + function IdLib.toId(Midnight.Obligation memory obligation, uint256 chainId, address midnight) internal returns (bytes32) => summaryToId(obligation, chainId, midnight); + + function creditOf(bytes32 id, address user) external returns (uint256) envfree; + function debtOf(bytes32 id, address user) external returns (uint256) envfree; + function collateral(bytes32 id, address user, uint256 index) external returns (uint128) envfree; } /// HELPERS /// -// IdLib summary: remember the last id returned by toId. - -persistent ghost bytes32 lastId; +persistent ghost bytes32 liqId; -function CVL_toId(Midnight.Obligation obligation, uint256 chainId, address midnight) returns bytes32 { - // non-deterministic id +function summaryToId(Midnight.Obligation obligation, uint256 chainId, address midnight) returns bytes32 { bytes32 id; - lastId = id; + liqId = id; return id; } -// UtilsLib summaries: msb, mulDivDown, and mulDivUp are deterministic +ghost summaryMsb(uint128) returns uint256; -ghost CVL_msb(uint128) returns uint256; +ghost summaryMulDivDown(uint256, uint256, uint256) returns uint256; -ghost CVL_mulDivDown(uint256, uint256, uint256) returns uint256; +ghost summaryMulDivUp(uint256, uint256, uint256) returns uint256; -ghost CVL_mulDivUp(uint256, uint256, uint256) returns uint256; - -// Oracle summary: we assume the price does not change during the execution of a transaction. - -ghost CVL_price(address) returns uint256; +ghost summaryPrice(address) returns uint256; // RULES /// -rule liquidateRequireUnhealthy(env e, Midnight.Obligation obligation, uint256 collateralIndex, uint256 seizedAssets, uint256 repaidUnits, address borrower, bytes data) { +/// Credit does not change on liquidate. Debt and collateral of a user can only change via liquidate if the position is liquidatable and user is borrower. +/// Furthermore, liquidate can only decrease the borrower's debt and collateral (w.r.t the collateralIndex passed in liquidate). +/// Also show that liquidate can only be called on liquidatable positions. +rule liquidateOnlyAffectsBalancesWhenLiquidatable(env e, Midnight.Obligation obligation, uint256 liqIndex, uint256 seizedAssets, uint256 repaidUnits, address liqUser, bytes data) { bytes32 id; - bool isHealthyBefore = isHealthy(e, obligation, id, borrower); - liquidate(e, obligation, collateralIndex, seizedAssets, repaidUnits, borrower, data); + address user; + uint256 collateralIndex; - // it's okay to check only after the call that the prover chose the correct id. - require id == lastId, "id should be derived from obligation"; + bool wasLiquidatable = isLiquidatable(e, obligation, id, liqUser); - assert !isHealthyBefore || e.block.timestamp > obligation.maturity, "liquidate can only be called on unhealthy obligations"; -} + uint256 creditBefore = creditOf(id, user); + uint256 debtBefore = debtOf(id, user); + uint256 collateralBefore = collateral(id, user, collateralIndex); -rule liquidateRevertsWhenNotLiquidatable(env e, Midnight.Obligation obligation, uint256 collateralIndex, uint256 seizedAssets, uint256 repaidUnits, address borrower, bytes data) { - bytes32 id; - bool wasLiquidatable = isLiquidatable(e, obligation, id, borrower); - liquidate(e, obligation, collateralIndex, seizedAssets, repaidUnits, borrower, data); + liquidate(e, obligation, liqIndex, seizedAssets, repaidUnits, liqUser, data); - // it's okay to check only after the call that the prover chose the correct id. - require id == lastId, "id should be derived from obligation"; + uint256 creditAfter = creditOf(id, user); + uint256 debtAfter = debtOf(id, user); + uint256 collateralAfter = collateral(id, user, collateralIndex); - assert wasLiquidatable, "liquidate cannot succeed when the borrower is not liquidatable"; + assert id == liqId => wasLiquidatable; + assert creditAfter == creditBefore; + assert debtAfter == debtBefore || (id == liqId && user == liqUser); + assert collateralAfter == collateralBefore || (id == liqId && user == liqUser && collateralIndex == liqIndex); + assert debtAfter <= debtBefore; + assert collateralAfter <= collateralBefore; } From 3accf71b678d8132ba485a6ca30b88e7be628e4f Mon Sep 17 00:00:00 2001 From: PA <50184410+peyha@users.noreply.github.com> Date: Wed, 15 Apr 2026 09:23:57 +0200 Subject: [PATCH 13/33] Missing tests for full coverage (#687) --- test/ContinuousFeeTest.sol | 18 +++++++++ test/LiquidationTest.sol | 28 +++++++++++++ test/OtherFunctionsTest.sol | 67 +++++++++++++++++++++++++++++++ test/TakeTest.sol | 79 ++++++++++++++++++++++++++++++++++++- 4 files changed, 191 insertions(+), 1 deletion(-) diff --git a/test/ContinuousFeeTest.sol b/test/ContinuousFeeTest.sol index 33f6561b3..0d34a1f39 100644 --- a/test/ContinuousFeeTest.sol +++ b/test/ContinuousFeeTest.sol @@ -73,6 +73,7 @@ contract ContinuousFeeTest is BaseTest { setupLender(credit, feeRate, ttm); uint256 remaining = midnight.pendingFee(id, lender); + assertEq(midnight.lastAccrual(id, lender), block.timestamp, "lender lastAccrual after take"); vm.warp(block.timestamp + elapsed); uint256 expectedFee = remaining.mulDivDown(elapsed, ttm); @@ -95,6 +96,7 @@ contract ContinuousFeeTest is BaseTest { midnight.updatePosition(obligation, lender); assertEq(midnight.creditOf(id, lender), credit - expectedFee, "credit after direct call"); assertEq(midnight.pendingFee(id, lender), remaining - expectedFee, "remaining after direct call"); + assertEq(midnight.lastAccrual(id, lender), block.timestamp, "lender lastAccrual after update"); // Fee accumulated in continuousFeeCredit if (expectedFee > 0) { @@ -485,4 +487,20 @@ contract ContinuousFeeTest is BaseTest { assertEq(midnight.creditOf(id, lender), newCredit, "view matches credit"); assertEq(midnight.pendingFee(id, lender), newPendingFee, "view matches pendingFee"); } + + function testUpdatePositionRevertsIfObligationNotCreated() public { + vm.expectRevert("obligation not created"); + midnight.updatePosition(obligation, borrower); + } + + function testClaimContinuousFeeRevertsIfObligationNotCreated() public { + vm.prank(feeClaimer); + vm.expectRevert("obligation not created"); + midnight.claimContinuousFee(obligation, 0, feeClaimer); + } + + function testLastAccrualZeroForFreshPosition() public { + setupLender(1e18, 0, 100 days); + assertEq(midnight.lastAccrual(id, makeAddr("nobody")), 0, "lastAccrual zero for fresh position"); + } } diff --git a/test/LiquidationTest.sol b/test/LiquidationTest.sol index d5b06721c..e6e8669f2 100644 --- a/test/LiquidationTest.sol +++ b/test/LiquidationTest.sol @@ -881,6 +881,34 @@ contract LiquidationTest is BaseTest { assertLt(midnight.debtOf(id, borrower), debtBefore, "debt should decrease after liquidation"); } + function testIsLiquidatableNoDebt() public { + midnight.touchObligation(obligation); + assertFalse(midnight.isLiquidatable(obligation, id, borrower), "no debt not liquidatable"); + } + + function testIsLiquidatableHealthyPreMaturityView(uint256 units) public { + units = bound(units, 1, MAX_UNITS); + collateralize(obligation, borrower, units); + setupObligation(obligation, units); + assertFalse(midnight.isLiquidatable(obligation, id, borrower), "healthy pre-maturity not liquidatable"); + } + + function testIsLiquidatablePostMaturityView(uint256 units) public { + units = bound(units, 1, MAX_UNITS); + collateralize(obligation, borrower, units); + setupObligation(obligation, units); + vm.warp(obligation.maturity + 1); + assertTrue(midnight.isLiquidatable(obligation, id, borrower), "post-maturity with debt is liquidatable"); + } + + function testIsLiquidatableUnhealthyPreMaturityView(uint256 units) public { + units = bound(units, 1, MAX_UNITS); + collateralize(obligation, borrower, units); + setupObligation(obligation, units); + Oracle(obligation.collateralParams[0].oracle).setPrice(ORACLE_PRICE_SCALE / 2); + assertTrue(midnight.isLiquidatable(obligation, id, borrower), "unhealthy pre-maturity is liquidatable"); + } + function onLiquidate( bytes32 _id, Obligation memory _obligation, diff --git a/test/OtherFunctionsTest.sol b/test/OtherFunctionsTest.sol index 4e66630ab..6a4f0594e 100644 --- a/test/OtherFunctionsTest.sol +++ b/test/OtherFunctionsTest.sol @@ -585,6 +585,73 @@ contract OtherFunctionsTest is BaseTest { assertEq(midnight.obligationCreated(toId(_obligation)), true, "obligation created with cursor 0.5"); } + function testMaxLifDirect(uint256 seed) public view { + uint256 lltv = allowedLltv(seed); + uint256 expectedLow = maxLif(lltv, 0.25e18); + assertEq(midnight.maxLif(lltv, 0.25e18), expectedLow, "maxLif low cursor"); + + uint256 expectedHigh = maxLif(lltv, 0.5e18); + assertEq(midnight.maxLif(lltv, 0.5e18), expectedHigh, "maxLif high cursor"); + assertTrue(expectedHigh >= expectedLow, "higher cursor gives higher or equal maxLif"); + } + + function testObligationStateGetter(Obligation memory _obligation, uint256 _defaultContinuousFee) public { + vm.assume(_obligation.collateralParams.length > 0); + _obligation = validObligation(_obligation); + _defaultContinuousFee = bound(_defaultContinuousFee, 0, MAX_CONTINUOUS_FEE); + + midnight.setDefaultContinuousFee(_obligation.loanToken, _defaultContinuousFee); + for (uint256 i = 0; i < 7; i++) { + midnight.setDefaultTradingFee(_obligation.loanToken, i, midnight.maxTradingFee(i)); + } + + bytes32 _id = midnight.touchObligation(_obligation); + + ( + uint128 totalUnits, + uint128 _lossIndex, + uint128 _withdrawable, + uint128 _continuousFeeCredit, + uint16 fee0, + uint16 fee1, + uint16 fee2, + uint16 fee3, + uint16 fee4, + uint16 fee5, + uint16 fee6, + uint32 _continuousFee, + bool created + ) = midnight.obligationState(_id); + + assertTrue(created, "obligation should be created"); + assertEq(totalUnits, 0, "totalUnits"); + assertEq(_lossIndex, 0, "lossIndex"); + assertEq(_withdrawable, 0, "withdrawable"); + assertEq(_continuousFeeCredit, 0, "continuousFeeCredit"); + assertEq(_continuousFee, _defaultContinuousFee, "continuousFee"); + assertEq(fee0, midnight.defaultTradingFees(_obligation.loanToken, 0), "fee0"); + assertEq(fee1, midnight.defaultTradingFees(_obligation.loanToken, 1), "fee1"); + assertEq(fee2, midnight.defaultTradingFees(_obligation.loanToken, 2), "fee2"); + assertEq(fee3, midnight.defaultTradingFees(_obligation.loanToken, 3), "fee3"); + assertEq(fee4, midnight.defaultTradingFees(_obligation.loanToken, 4), "fee4"); + assertEq(fee5, midnight.defaultTradingFees(_obligation.loanToken, 5), "fee5"); + assertEq(fee6, midnight.defaultTradingFees(_obligation.loanToken, 6), "fee6"); + } + + function testObligationStateAfterTrade() public { + midnight.setDefaultContinuousFee(address(loanToken), MAX_CONTINUOUS_FEE); + + uint256 units = 1e18; + collateralize(obligation, borrower, units); + setupObligation(obligation, units); + + (uint128 totalUnits,,,,,,,,,,, uint32 _continuousFee, bool created) = midnight.obligationState(id); + + assertTrue(created, "should be created"); + assertEq(totalUnits, units, "totalUnits after trade"); + assertEq(_continuousFee, MAX_CONTINUOUS_FEE, "continuousFee after trade"); + } + function testMidnightRevertsOnCallbacks(address msgSender, bytes calldata data) public { bytes4[4] memory selectors = [ ICallbacks.onBuy.selector, diff --git a/test/TakeTest.sol b/test/TakeTest.sol index 240e3dd6a..eee7db9b7 100644 --- a/test/TakeTest.sol +++ b/test/TakeTest.sol @@ -5,7 +5,7 @@ pragma solidity ^0.8.0; import {Obligation, Offer, CollateralParams} from "../src/interfaces/IMidnight.sol"; import {Signature, EIP712_DOMAIN_TYPEHASH, ROOT_TYPEHASH} from "../src/ratifiers/EcrecoverRatifier.sol"; import {Midnight} from "../src/Midnight.sol"; -import {WAD, CALLBACK_SUCCESS} from "../src/libraries/ConstantsLib.sol"; +import {WAD, CALLBACK_SUCCESS, MAX_CONTINUOUS_FEE} from "../src/libraries/ConstantsLib.sol"; import {UtilsLib} from "../src/libraries/UtilsLib.sol"; import {TickLib, MAX_TICK} from "../src/libraries/TickLib.sol"; import {ICallbacks} from "../src/interfaces/ICallbacks.sol"; @@ -1503,6 +1503,83 @@ contract TakeTest is BaseTest { new bytes32[](0) ); } + + function testBuyBuyerCallbackRevertsOnInvalidReturn(uint256 units) public { + units = bound(units, 1, maxAssets); + borrowerOffer.maxUnits = units; + borrowerOffer.tick = MAX_TICK; + uint256 price = TickLib.tickToPrice(MAX_TICK); + uint256 assets = units.mulDivUp(price, WAD); + address callback = address(new InvalidBuyCallback()); + deal(address(loanToken), callback, assets); + collateralize(obligation, borrower, units); + + vm.expectRevert("invalid callback"); + vm.prank(lender); + midnight.take( + units, + lender, + callback, + hex"", + address(0), + borrowerOffer, + sig([borrowerOffer]), + root([borrowerOffer]), + proof([borrowerOffer]) + ); + } + + function testBuyerPendingFeeExceedsCredit() public { + // Use a very long maturity so continuousFee * TTM > WAD. + midnight.setDefaultContinuousFee(address(loanToken), MAX_CONTINUOUS_FEE); + + Obligation memory longObligation; + longObligation.loanToken = address(loanToken); + longObligation.maturity = block.timestamp + 200 * 365 days; + longObligation.collateralParams = obligation.collateralParams; + + uint256 units = 1e18; + Offer memory bOffer; + bOffer.obligation = longObligation; + bOffer.buy = false; + bOffer.maker = borrower; + bOffer.receiverIfMakerIsSeller = borrower; + bOffer.maxUnits = units; + bOffer.ratifier = address(ecrecoverRatifier); + bOffer.start = block.timestamp; + bOffer.expiry = block.timestamp + 200; + bOffer.tick = MAX_TICK; + + uint256 price = TickLib.tickToPrice(MAX_TICK); + deal(address(loanToken), lender, units.mulDivUp(price, WAD)); + collateralize(longObligation, borrower, units); + + vm.expectRevert("buyer pendingFee exceeds credit"); + vm.prank(lender); + midnight.take(units, lender, address(0), hex"", lender, bOffer, sig([bOffer]), root([bOffer]), proof([bOffer])); + } +} + +contract InvalidBuyCallback is ICallbacks { + function onBuy(bytes32, Obligation memory, address, uint256, uint256, bytes memory) + external + pure + returns (bytes32) + { + return bytes32(0); + } + + function onSell(bytes32, Obligation memory, address, uint256, uint256, bytes memory) + external + pure + returns (bytes32) + { + return CALLBACK_SUCCESS; + } + + function onLiquidate(bytes32, Obligation memory, uint256, uint256, uint256, address, bytes memory) external {} + + function onRepay(bytes32, Obligation memory, uint256, address, bytes memory) external {} } contract BorrowCallback is ICallbacks { From 85ac46ad56e542ecfde3710266976a7883ae0b79 Mon Sep 17 00:00:00 2001 From: MathisGD <74971347+MathisGD@users.noreply.github.com> Date: Wed, 15 Apr 2026 14:10:00 +0200 Subject: [PATCH 14/33] naming: is ratified (#695) --- src/ratifiers/SetterRatifier.sol | 12 ++++++------ test/SetterRatifierTest.sol | 18 +++++++++--------- 2 files changed, 15 insertions(+), 15 deletions(-) diff --git a/src/ratifiers/SetterRatifier.sol b/src/ratifiers/SetterRatifier.sol index ed8e43c3e..ea1eb9c1d 100644 --- a/src/ratifiers/SetterRatifier.sol +++ b/src/ratifiers/SetterRatifier.sol @@ -7,24 +7,24 @@ import {IMidnight, Offer} from "../interfaces/IMidnight.sol"; import {CALLBACK_SUCCESS} from "../libraries/ConstantsLib.sol"; contract SetterRatifier is IRatifier { - event SetApproval(address indexed maker, bytes32 indexed root, bool newApproval); + event SetIsRatified(address indexed maker, bytes32 indexed root, bool newIsRatified); address public immutable MIDNIGHT; - mapping(address maker => mapping(bytes32 root => bool)) public approved; + mapping(address maker => mapping(bytes32 root => bool)) public isRatified; constructor(address _midnight) { MIDNIGHT = _midnight; } - function setApproval(address maker, bytes32 root, bool newApproval) public { + function setIsRatified(address maker, bytes32 root, bool newIsRatified) public { require(maker == msg.sender || IMidnight(MIDNIGHT).isAuthorized(maker, msg.sender), "unauthorized"); - approved[maker][root] = newApproval; - emit SetApproval(maker, root, newApproval); + isRatified[maker][root] = newIsRatified; + emit SetIsRatified(maker, root, newIsRatified); } function onRatify(Offer memory offer, bytes32 root, bytes memory) external view returns (bytes32) { - require(approved[offer.maker][root], "not approved"); + require(isRatified[offer.maker][root], "not ratified"); return CALLBACK_SUCCESS; } } diff --git a/test/SetterRatifierTest.sol b/test/SetterRatifierTest.sol index 26c45c506..35764a0bb 100644 --- a/test/SetterRatifierTest.sol +++ b/test/SetterRatifierTest.sol @@ -34,16 +34,16 @@ contract SetterRatifierTest is BaseTest { offer.tick = MAX_TICK; } - function testSetApprovalMaker() public { + function testSetIsRatifiedMaker() public { bytes32 _root = keccak256("root"); vm.prank(lender); - setterRatifier.setApproval(lender, _root, true); + setterRatifier.setIsRatified(lender, _root, true); - assertTrue(setterRatifier.approved(lender, _root)); + assertTrue(setterRatifier.isRatified(lender, _root)); } - function testOnRatifyAuthorizedSetterCanApproveOnBehalf() public { + function testOnRatifyAuthorizedSetterCanRatifyOnBehalf() public { Offer memory offer = makeOffer(lender); bytes32 _root = keccak256(abi.encode(offer)); @@ -51,13 +51,13 @@ contract SetterRatifierTest is BaseTest { midnight.setIsAuthorized(lender, borrower, true); vm.prank(borrower); - setterRatifier.setApproval(lender, _root, true); + setterRatifier.setIsRatified(lender, _root, true); bytes32 result = setterRatifier.onRatify(offer, _root, ""); assertEq(result, CALLBACK_SUCCESS); } - function testTakeAuthorizedSetterCanApproveOnBehalf() public { + function testTakeAuthorizedSetterCanRatifyOnBehalf() public { Offer memory offer = makeOffer(lender); bytes32 _root = keccak256(abi.encode(offer)); @@ -67,17 +67,17 @@ contract SetterRatifierTest is BaseTest { midnight.setIsAuthorized(lender, borrower, true); vm.prank(borrower); - setterRatifier.setApproval(lender, _root, true); + setterRatifier.setIsRatified(lender, _root, true); vm.prank(borrower); midnight.take(0, borrower, address(0), hex"", borrower, offer, emptySig, _root, proof([offer])); } - function testSetApprovalUnauthorizedOnBehalf() public { + function testSetIsRatifiedUnauthorizedOnBehalf() public { bytes32 _root = keccak256("root"); vm.prank(borrower); vm.expectRevert("unauthorized"); - setterRatifier.setApproval(lender, _root, true); + setterRatifier.setIsRatified(lender, _root, true); } } From a24a9d9e69a5c67f8d5a4bfdf62f3adc280065ef Mon Sep 17 00:00:00 2001 From: PA <50184410+peyha@users.noreply.github.com> Date: Wed, 15 Apr 2026 18:24:55 +0200 Subject: [PATCH 15/33] Custom error with commit fix (#701) Co-authored-by: MathisGD <74971347+MathisGD@users.noreply.github.com> Co-authored-by: prd-carapulse[bot] <264278285+prd-carapulse[bot]@users.noreply.github.com> Co-authored-by: MathisGD Co-authored-by: Quentin Garchery --- certora/helpers/Havoc.sol | 1 - certora/helpers/MidnightWrapper.sol | 7 +- certora/helpers/MulDiv.sol | 1 - certora/helpers/Utils.sol | 1 + foundry.toml | 2 +- src/Midnight.sol | 140 ++++++++---------- src/authorizers/EcrecoverAuthorizer.sol | 13 +- .../interfaces/IEcrecoverAuthorizer.sol | 24 +++ src/interfaces/IMidnight.sol | 47 +++++- src/libraries/IdLib.sol | 4 +- src/libraries/SafeTransferLib.sol | 12 +- src/libraries/TickLib.sol | 7 +- src/libraries/UtilsLib.sol | 4 +- src/periphery/TakeAmountsLib.sol | 2 +- src/periphery/TakeBundler.sol | 40 ++--- src/periphery/interfaces/ITakeBundler.sol | 33 +++++ src/ratifiers/EcrecoverRatifier.sol | 8 +- src/ratifiers/SetterRatifier.sol | 10 +- .../interfaces/IEcrecoverRatifier.sol | 14 ++ src/ratifiers/interfaces/ISetterRatifier.sol | 21 +++ test/AuthorizationTest.sol | 18 +-- test/BundlerTest.sol | 47 +++--- test/ContinuousFeeTest.sol | 8 +- test/EcrecoverRatifierTest.sol | 9 +- test/GateTest.sol | 10 +- test/LiquidationTest.sol | 12 +- test/MaxAmountsTest.sol | 4 +- test/MulticallTest.sol | 3 +- test/OtherFunctionsTest.sol | 20 +-- test/SafeTransferLibTest.sol | 8 +- test/SetIsAuthorizedWithSigTest.sol | 7 +- test/SetterRatifierTest.sol | 3 +- test/SettersTest.sol | 36 ++--- test/SignatureTest.sol | 3 +- test/TakeTest.sol | 91 ++++++------ test/TickLibTest.sol | 4 +- test/TradingFeeTest.sol | 6 +- test/UtilsLibTest.sol | 2 +- 38 files changed, 402 insertions(+), 280 deletions(-) create mode 100644 src/authorizers/interfaces/IEcrecoverAuthorizer.sol create mode 100644 src/periphery/interfaces/ITakeBundler.sol create mode 100644 src/ratifiers/interfaces/IEcrecoverRatifier.sol create mode 100644 src/ratifiers/interfaces/ISetterRatifier.sol diff --git a/certora/helpers/Havoc.sol b/certora/helpers/Havoc.sol index 27c3238fd..cf9bdba97 100644 --- a/certora/helpers/Havoc.sol +++ b/certora/helpers/Havoc.sol @@ -7,7 +7,6 @@ interface IHavoc { } contract Havoc { - function callHavoc(address account) external { (IHavoc(account)).havocAll(); } diff --git a/certora/helpers/MidnightWrapper.sol b/certora/helpers/MidnightWrapper.sol index a03211bde..e48ddc3d1 100644 --- a/certora/helpers/MidnightWrapper.sol +++ b/certora/helpers/MidnightWrapper.sol @@ -11,7 +11,7 @@ import {ORACLE_PRICE_SCALE, WAD} from "../../src/libraries/ConstantsLib.sol"; contract MidnightWrapper is Midnight { using UtilsLib for uint256; using UtilsLib for uint128; - + /* This isHealthy function iterates over all collateralParams, it doesn't use the collateral bitmap. */ function isHealthyNoBitmap(Obligation memory obligation, bytes32 id, address borrower) public view returns (bool) { @@ -19,11 +19,12 @@ contract MidnightWrapper is Midnight { uint256 debt = _position.debt; uint256 maxDebt; uint256 len = obligation.collateralParams.length; - for (uint256 i = len; i > 0 && maxDebt < debt; ) { + for (uint256 i = len; i > 0 && maxDebt < debt;) { i--; CollateralParams memory collateralParam = obligation.collateralParams[i]; uint256 price = IOracle(collateralParam.oracle).price(); - maxDebt += _position.collateral[i].mulDivDown(price, ORACLE_PRICE_SCALE).mulDivDown(collateralParam.lltv, WAD); + maxDebt += _position.collateral[i].mulDivDown(price, ORACLE_PRICE_SCALE) + .mulDivDown(collateralParam.lltv, WAD); } return maxDebt >= debt; } diff --git a/certora/helpers/MulDiv.sol b/certora/helpers/MulDiv.sol index ec98747a3..fdf2d7a8b 100644 --- a/certora/helpers/MulDiv.sol +++ b/certora/helpers/MulDiv.sol @@ -5,7 +5,6 @@ pragma solidity ^0.8.0; import {UtilsLib} from "../../src/libraries/UtilsLib.sol"; contract MulDiv { - function mulDivUp(uint256 a, uint256 b, uint256 d) external pure returns (uint256) { return UtilsLib.mulDivUp(a, b, d); } diff --git a/certora/helpers/Utils.sol b/certora/helpers/Utils.sol index 3cd8b940e..1c5848ffa 100644 --- a/certora/helpers/Utils.sol +++ b/certora/helpers/Utils.sol @@ -5,6 +5,7 @@ pragma solidity ^0.8.0; import {Obligation} from "../../src/interfaces/IMidnight.sol"; import {IdLib} from "../../src/libraries/IdLib.sol"; import {UtilsLib} from "../../src/libraries/UtilsLib.sol"; + contract Utils { function hashObligation(Obligation memory obligation) external pure returns (bytes32) { return keccak256(abi.encode(obligation)); diff --git a/foundry.toml b/foundry.toml index 1ef3f0850..249199836 100644 --- a/foundry.toml +++ b/foundry.toml @@ -1,7 +1,7 @@ [profile.default] via_ir = true optimizer = true -optimizer_runs = 58 +optimizer_runs = 300 evm_version = "osaka" fs_permissions = [{ access = "read", path = "test/ticks_exact.json" }] diff --git a/src/Midnight.sol b/src/Midnight.sol index d8e50eb3e..566b08002 100644 --- a/src/Midnight.sol +++ b/src/Midnight.sol @@ -6,20 +6,8 @@ import {UtilsLib} from "./libraries/UtilsLib.sol"; import {IdLib} from "./libraries/IdLib.sol"; import {TickLib} from "./libraries/TickLib.sol"; import {SafeTransferLib} from "./libraries/SafeTransferLib.sol"; -import { - WAD, - ORACLE_PRICE_SCALE, - FEE_STEP, - MAX_CONTINUOUS_FEE, - TIME_TO_MAX_LIF, - MAX_COLLATERALS, - MAX_COLLATERALS_PER_BORROWER, - LIQUIDATION_CURSOR_LOW, - LIQUIDATION_CURSOR_HIGH, - LIQUIDATION_LOCK_SLOT, - CALLBACK_SUCCESS, - isLltvAllowed -} from "./libraries/ConstantsLib.sol"; +// forge-lint: disable-next-item(unaliased-plain-import) +import "./libraries/ConstantsLib.sol"; import {IOracle} from "./interfaces/IOracle.sol"; import {IMidnight, Obligation, Offer, CollateralParams, ObligationState, Position} from "./interfaces/IMidnight.sol"; import {ICallbacks, IFlashLoanCallback} from "./interfaces/ICallbacks.sol"; @@ -164,30 +152,30 @@ contract Midnight is IMidnight { /// ADMIN FUNCTIONS /// function setRoleSetter(address newRoleSetter) external { - require(msg.sender == roleSetter, "only role setter"); + require(msg.sender == roleSetter, OnlyRoleSetter()); roleSetter = newRoleSetter; emit EventsLib.SetRoleSetter(newRoleSetter); } function setFeeSetter(address newFeeSetter) external { - require(msg.sender == roleSetter, "only role setter"); + require(msg.sender == roleSetter, OnlyRoleSetter()); feeSetter = newFeeSetter; emit EventsLib.SetFeeSetter(newFeeSetter); } function setFeeClaimer(address newFeeClaimer) external { - require(msg.sender == roleSetter, "only role setter"); + require(msg.sender == roleSetter, OnlyRoleSetter()); feeClaimer = newFeeClaimer; emit EventsLib.SetFeeClaimer(newFeeClaimer); } function setObligationTradingFee(bytes32 id, uint256 index, uint256 newTradingFee) external { ObligationState storage _obligationState = obligationState[id]; - require(msg.sender == feeSetter, "only fee setter"); - require(index <= 6, "invalid index"); - require(newTradingFee <= maxTradingFee(index), "trading fee too high"); - require(newTradingFee % FEE_STEP == 0, "fee should be a multiple of FEE_STEP"); - require(_obligationState.created, "obligation not created"); + require(msg.sender == feeSetter, OnlyFeeSetter()); + require(index <= 6, InvalidFeeIndex()); + require(newTradingFee <= maxTradingFee(index), TradingFeeTooHigh()); + require(newTradingFee % FEE_STEP == 0, FeeNotMultipleOfFeeStep()); + require(_obligationState.created, ObligationNotCreated()); // forge-lint: disable-next-item(unsafe-typecast) as newTradingFee <= maxTradingFee <= uint16.max * FEE_STEP uint16 toStore = uint16(newTradingFee / FEE_STEP); if (index == 0) _obligationState.fee0 = toStore; @@ -201,10 +189,10 @@ contract Midnight is IMidnight { } function setDefaultTradingFee(address loanToken, uint256 index, uint256 newTradingFee) external { - require(msg.sender == feeSetter, "only fee setter"); - require(index <= 6, "invalid index"); - require(newTradingFee <= maxTradingFee(index), "trading fee too high"); - require(newTradingFee % FEE_STEP == 0, "fee should be a multiple of FEE_STEP"); + require(msg.sender == feeSetter, OnlyFeeSetter()); + require(index <= 6, InvalidFeeIndex()); + require(newTradingFee <= maxTradingFee(index), TradingFeeTooHigh()); + require(newTradingFee % FEE_STEP == 0, FeeNotMultipleOfFeeStep()); // forge-lint: disable-next-item(unsafe-typecast) as newTradingFee <= maxTradingFee <= uint16.max * FEE_STEP defaultTradingFees[loanToken][index] = uint16(newTradingFee / FEE_STEP); emit EventsLib.SetDefaultTradingFee(loanToken, index, newTradingFee); @@ -212,24 +200,24 @@ contract Midnight is IMidnight { function setObligationContinuousFee(bytes32 id, uint256 newContinuousFee) external { ObligationState storage _obligationState = obligationState[id]; - require(msg.sender == feeSetter, "only fee setter"); - require(newContinuousFee <= MAX_CONTINUOUS_FEE, "continuous fee too high"); - require(_obligationState.created, "obligation not created"); + require(msg.sender == feeSetter, OnlyFeeSetter()); + require(newContinuousFee <= MAX_CONTINUOUS_FEE, ContinuousFeeTooHigh()); + require(_obligationState.created, ObligationNotCreated()); // forge-lint: disable-next-line(unsafe-typecast) as newContinuousFee <= MAX_CONTINUOUS_FEE < type(uint32).max _obligationState.continuousFee = uint32(newContinuousFee); emit EventsLib.SetObligationContinuousFee(id, newContinuousFee); } function setDefaultContinuousFee(address loanToken, uint256 newContinuousFee) external { - require(msg.sender == feeSetter, "only fee setter"); - require(newContinuousFee <= MAX_CONTINUOUS_FEE, "continuous fee too high"); + require(msg.sender == feeSetter, OnlyFeeSetter()); + require(newContinuousFee <= MAX_CONTINUOUS_FEE, ContinuousFeeTooHigh()); // forge-lint: disable-next-line(unsafe-typecast) as newContinuousFee <= MAX_CONTINUOUS_FEE < type(uint32).max defaultContinuousFee[loanToken] = uint32(newContinuousFee); emit EventsLib.SetDefaultContinuousFee(loanToken, newContinuousFee); } function claimTradingFee(address token, uint256 amount, address receiver) external { - require(msg.sender == feeClaimer, "only fee claimer"); + require(msg.sender == feeClaimer, OnlyFeeClaimer()); claimableTradingFee[token] -= amount; emit EventsLib.ClaimTradingFee(msg.sender, token, amount, receiver); SafeTransferLib.safeTransfer(token, receiver, amount); @@ -238,8 +226,8 @@ contract Midnight is IMidnight { function claimContinuousFee(Obligation memory obligation, uint256 amount, address receiver) external { bytes32 id = toId(obligation); ObligationState storage _obligationState = obligationState[id]; - require(msg.sender == feeClaimer, "only fee claimer"); - require(_obligationState.created, "obligation not created"); + require(msg.sender == feeClaimer, OnlyFeeClaimer()); + require(_obligationState.created, ObligationNotCreated()); _obligationState.continuousFeeCredit -= UtilsLib.toUint128(amount); _obligationState.totalUnits -= UtilsLib.toUint128(amount); @@ -273,15 +261,17 @@ contract Midnight is IMidnight { ) external returns (uint256, uint256, uint256) { bytes32 id = touchObligation(offer.obligation); ObligationState storage _obligationState = obligationState[id]; - require(UtilsLib.atMostOneNonZero(offer.maxSellerAssets, offer.maxBuyerAssets, offer.maxUnits), "multiple max"); - require(taker == msg.sender || isAuthorized[taker][msg.sender], "taker unauthorized"); - require(block.timestamp >= offer.start, "offer not started"); - require(block.timestamp <= offer.expiry, "offer expired"); - require(offer.maker != taker, "cannot self take"); - require(UtilsLib.isLeaf(root, keccak256(abi.encode(offer)), proof), "invalid proof"); - require(offer.session == session[offer.maker], "invalid session"); - require(isAuthorized[offer.maker][offer.ratifier], "ratifier unauthorized"); - require(IRatifier(offer.ratifier).onRatify(offer, root, ratifierData) == CALLBACK_SUCCESS, "not ratified"); + require( + UtilsLib.atMostOneNonZero(offer.maxSellerAssets, offer.maxBuyerAssets, offer.maxUnits), MultipleNonZero() + ); + require(taker == msg.sender || isAuthorized[taker][msg.sender], TakerUnauthorized()); + require(block.timestamp >= offer.start, OfferNotStarted()); + require(block.timestamp <= offer.expiry, OfferExpired()); + require(offer.maker != taker, SelfTake()); + require(UtilsLib.isLeaf(root, keccak256(abi.encode(offer)), proof), InvalidProof()); + require(offer.session == session[offer.maker], InvalidSession()); + require(isAuthorized[offer.maker][offer.ratifier], RatifierUnauthorized()); + require(IRatifier(offer.ratifier).onRatify(offer, root, ratifierData) == CALLBACK_SUCCESS, RatifierFail()); ( address buyer, @@ -322,13 +312,13 @@ contract Midnight is IMidnight { uint256 newConsumed; if (offer.maxSellerAssets > 0) { newConsumed = consumed[offer.maker][offer.group] += sellerAssets; - require(newConsumed <= offer.maxSellerAssets, "consumed seller assets"); + require(newConsumed <= offer.maxSellerAssets, ConsumedSellerAssets()); } else if (offer.maxBuyerAssets > 0) { newConsumed = consumed[offer.maker][offer.group] += buyerAssets; - require(newConsumed <= offer.maxBuyerAssets, "consumed buyer assets"); + require(newConsumed <= offer.maxBuyerAssets, ConsumedBuyerAssets()); } else { newConsumed = consumed[offer.maker][offer.group] += units; - require(newConsumed <= offer.maxUnits, "consumed units"); + require(newConsumed <= offer.maxUnits, ConsumedUnits()); } Position storage buyerPos = position[id][buyer]; @@ -357,20 +347,20 @@ contract Midnight is IMidnight { _obligationState.totalUnits = UtilsLib.toUint128(_obligationState.totalUnits + buyerCreditIncrease - sellerCreditDecrease); - require(buyerPos.pendingFee <= buyerPos.credit, "buyer pendingFee exceeds credit"); + require(buyerPos.pendingFee <= buyerPos.credit, BuyerPendingFeeExceedsCredit()); if (offer.reduceOnly) { - require(offer.buy ? buyerCreditIncrease == 0 : sellerDebtIncrease == 0, "maker credit or debt increased"); + require(offer.buy ? buyerCreditIncrease == 0 : sellerDebtIncrease == 0, MakerCreditOrDebtIncreased()); } require( offer.obligation.enterGate == address(0) || buyerCreditIncrease == 0 || IEnterGate(offer.obligation.enterGate).canIncreaseCredit(buyer), - "buyer gated from increasing credit" + BuyerGatedFromIncreasingCredit() ); require( offer.obligation.enterGate == address(0) || sellerDebtIncrease == 0 || IEnterGate(offer.obligation.enterGate).canIncreaseDebt(seller), - "seller gated from increasing debt" + SellerGatedFromIncreasingDebt() ); emit EventsLib.Take( @@ -396,7 +386,7 @@ contract Midnight is IMidnight { require( ICallbacks(buyerCallback).onBuy(id, offer.obligation, buyer, buyerAssets, units, buyerCallbackData) == CALLBACK_SUCCESS, - "invalid callback" + InvalidBuyCallback() ); } @@ -409,11 +399,11 @@ contract Midnight is IMidnight { require( ICallbacks(sellerCallback).onSell(id, offer.obligation, seller, sellerAssets, units, sellerCallbackData) == CALLBACK_SUCCESS, - "invalid callback" + InvalidSellCallback() ); } if (!wasLocked) UtilsLib.tExchange(LIQUIDATION_LOCK_SLOT, id, seller, false); - require(!isLiquidatable(offer.obligation, id, seller), "seller is liquidatable"); + require(!isLiquidatable(offer.obligation, id, seller), SellerIsLiquidatable()); return (buyerAssets, sellerAssets, units); } @@ -422,7 +412,7 @@ contract Midnight is IMidnight { function withdraw(Obligation memory obligation, uint256 units, address onBehalf, address receiver) external { bytes32 id = touchObligation(obligation); ObligationState storage _obligationState = obligationState[id]; - require(onBehalf == msg.sender || isAuthorized[onBehalf][msg.sender], "unauthorized"); + require(onBehalf == msg.sender || isAuthorized[onBehalf][msg.sender], Unauthorized()); _updatePosition(obligation, id, onBehalf); Position storage _position = position[id][onBehalf]; @@ -441,7 +431,7 @@ contract Midnight is IMidnight { } function repay(Obligation memory obligation, uint256 units, address onBehalf, bytes calldata data) external { - require(onBehalf == msg.sender || isAuthorized[onBehalf][msg.sender], "unauthorized"); + require(onBehalf == msg.sender || isAuthorized[onBehalf][msg.sender], Unauthorized()); bytes32 id = touchObligation(obligation); position[id][onBehalf].debt -= UtilsLib.toUint128(units); @@ -462,7 +452,7 @@ contract Midnight is IMidnight { { bytes32 id = touchObligation(obligation); address collateralToken = obligation.collateralParams[collateralIndex].token; - require(onBehalf == msg.sender || isAuthorized[onBehalf][msg.sender], "unauthorized"); + require(onBehalf == msg.sender || isAuthorized[onBehalf][msg.sender], Unauthorized()); Position storage _position = position[id][onBehalf]; uint256 oldCollateral = _position.collateral[collateralIndex]; @@ -471,7 +461,7 @@ contract Midnight is IMidnight { if (oldCollateral == 0 && assets > 0) { uint128 newBitmap = _position.activatedCollaterals.setBit(collateralIndex); _position.activatedCollaterals = newBitmap; - require(UtilsLib.countBits(newBitmap) <= MAX_COLLATERALS_PER_BORROWER, "too many activated collaterals"); + require(UtilsLib.countBits(newBitmap) <= MAX_COLLATERALS_PER_BORROWER, TooManyActivatedCollaterals()); } emit EventsLib.SupplyCollateral(msg.sender, id, collateralToken, assets, onBehalf); @@ -489,7 +479,7 @@ contract Midnight is IMidnight { ) external { bytes32 id = touchObligation(obligation); address collateralToken = obligation.collateralParams[collateralIndex].token; - require(onBehalf == msg.sender || isAuthorized[onBehalf][msg.sender], "unauthorized"); + require(onBehalf == msg.sender || isAuthorized[onBehalf][msg.sender], Unauthorized()); Position storage _position = position[id][onBehalf]; uint256 newCollateral = _position.collateral[collateralIndex] - assets; @@ -499,7 +489,7 @@ contract Midnight is IMidnight { _position.activatedCollaterals = _position.activatedCollaterals.clearBit(collateralIndex); } - require(isHealthy(obligation, id, onBehalf), "unhealthy borrower"); + require(isHealthy(obligation, id, onBehalf), UnhealthyBorrower()); emit EventsLib.WithdrawCollateral(msg.sender, id, collateralToken, assets, onBehalf, receiver); @@ -526,11 +516,11 @@ contract Midnight is IMidnight { bytes32 id = touchObligation(obligation); ObligationState storage _obligationState = obligationState[id]; Position storage _position = position[id][borrower]; - require(UtilsLib.atMostOneNonZero(repaidUnits, seizedAssets), "inconsistent input"); + require(UtilsLib.atMostOneNonZero(repaidUnits, seizedAssets), InconsistentInput()); require( obligation.liquidatorGate == address(0) || ILiquidatorGate(obligation.liquidatorGate).canLiquidate(msg.sender), - "liquidator gated from liquidating" + LiquidatorGatedFromLiquidating() ); uint256 maxDebt; @@ -554,7 +544,7 @@ contract Midnight is IMidnight { require( originalDebt > 0 && !liquidationLocked(id, borrower) && (block.timestamp > obligation.maturity || originalDebt > maxDebt), - "not liquidatable" + NotLiquidatable() ); if (badDebt > 0) { @@ -601,7 +591,7 @@ contract Midnight is IMidnight { repaidUnits <= maxRepaid || _position.collateral[collateralIndex].mulDivDown(liquidatedCollatPrice, ORACLE_PRICE_SCALE) .mulDivDown(WAD, lif).zeroFloorSub(maxRepaid) < obligation.rcfThreshold, - "recovery close factor conditions violated" + RecoveryCloseFactorConditionsViolated() ); } @@ -639,15 +629,15 @@ contract Midnight is IMidnight { /// @dev Passing type(uint256).max cancels all offers in the group (and never reverts). function setConsumed(bytes32 group, uint256 amount, address onBehalf) external { - require(onBehalf == msg.sender || isAuthorized[onBehalf][msg.sender], "unauthorized"); - require(amount >= consumed[onBehalf][group], "already consumed"); + require(onBehalf == msg.sender || isAuthorized[onBehalf][msg.sender], Unauthorized()); + require(amount >= consumed[onBehalf][group], AlreadyConsumed()); consumed[onBehalf][group] = amount; emit EventsLib.SetConsumed(msg.sender, onBehalf, group, amount); } /// @dev TODO: is it safe enough? function shuffleSession(address onBehalf) external { - require(onBehalf == msg.sender || isAuthorized[onBehalf][msg.sender], "unauthorized"); + require(onBehalf == msg.sender || isAuthorized[onBehalf][msg.sender], Unauthorized()); bytes32 newSession = keccak256(abi.encode(session[onBehalf], blockhash(block.number - 1))); session[onBehalf] = newSession; emit EventsLib.ShuffleSession(msg.sender, onBehalf, newSession); @@ -655,7 +645,7 @@ contract Midnight is IMidnight { /// @dev Authorized addresses can authorize other addresses to act on their behalf so it should be used carefully. function setIsAuthorized(address onBehalf, address authorized, bool newIsAuthorized) external { - require(onBehalf == msg.sender || isAuthorized[onBehalf][msg.sender], "unauthorized"); + require(onBehalf == msg.sender || isAuthorized[onBehalf][msg.sender], Unauthorized()); isAuthorized[onBehalf][authorized] = newIsAuthorized; emit EventsLib.SetIsAuthorized(msg.sender, onBehalf, authorized, newIsAuthorized); } @@ -671,18 +661,18 @@ contract Midnight is IMidnight { function touchObligation(Obligation memory obligation) public returns (bytes32) { bytes32 id = toId(obligation); if (!obligationState[id].created) { - require(obligation.collateralParams.length > 0, "no collateralParams"); - require(obligation.collateralParams.length <= MAX_COLLATERALS, "too many collateralParams"); + require(obligation.collateralParams.length > 0, NoCollateralParams()); + require(obligation.collateralParams.length <= MAX_COLLATERALS, TooManyCollateralParams()); address previousCollateralToken; for (uint256 i = 0; i < obligation.collateralParams.length; i++) { address collateralToken = obligation.collateralParams[i].token; - require(collateralToken > previousCollateralToken, "collateralParams not sorted"); + require(collateralToken > previousCollateralToken, CollateralParamsNotSorted()); uint256 lltv = obligation.collateralParams[i].lltv; - require(isLltvAllowed(lltv), "lltv not allowed"); + require(isLltvAllowed(lltv), LltvNotAllowed()); require( obligation.collateralParams[i].maxLif == maxLif(lltv, LIQUIDATION_CURSOR_LOW) || obligation.collateralParams[i].maxLif == maxLif(lltv, LIQUIDATION_CURSOR_HIGH), - "invalid maxLif" + InvalidMaxLif() ); previousCollateralToken = collateralToken; } @@ -735,7 +725,7 @@ contract Midnight is IMidnight { /// @dev Slashes the position and accrues the continuous fee. function updatePosition(Obligation memory obligation, address user) external { bytes32 id = toId(obligation); - require(obligationState[id].created, "obligation not created"); + require(obligationState[id].created, ObligationNotCreated()); _updatePosition(obligation, id, user); } @@ -782,7 +772,7 @@ contract Midnight is IMidnight { /// @dev Reverts if the id is not a valid id of a touched obligation. /// @dev Returns the obligation corresponding to the given id. function toObligation(bytes32 id) external view returns (Obligation memory) { - require(obligationState[id].created, "obligation not created"); + require(obligationState[id].created, ObligationNotCreated()); address create2Address = address(uint160(uint256(id))); return abi.decode(create2Address.code, (Obligation)); } @@ -882,7 +872,7 @@ contract Midnight is IMidnight { /// @dev Returns the trading fee using piecewise linear interpolation between breakpoints. function tradingFee(bytes32 id, uint256 timeToMaturity) public view returns (uint256) { ObligationState storage _obligationState = obligationState[id]; - require(_obligationState.created, "obligation not created"); + require(_obligationState.created, ObligationNotCreated()); if (timeToMaturity >= 360 days) return _obligationState.fee6 * FEE_STEP; diff --git a/src/authorizers/EcrecoverAuthorizer.sol b/src/authorizers/EcrecoverAuthorizer.sol index 206f38634..8e55ca1c9 100644 --- a/src/authorizers/EcrecoverAuthorizer.sol +++ b/src/authorizers/EcrecoverAuthorizer.sol @@ -3,13 +3,10 @@ pragma solidity 0.8.34; import {IMidnight} from "../interfaces/IMidnight.sol"; +import {IEcrecoverAuthorizer} from "./interfaces/IEcrecoverAuthorizer.sol"; import {Authorization, Signature, AUTHORIZATION_TYPEHASH, EIP712_DOMAIN_TYPEHASH} from "../interfaces/IEcrecover.sol"; -event SetIsAuthorized( - address indexed caller, address indexed authorizer, address indexed authorized, bool isAuthorized, uint256 nonce -); - -contract EcrecoverAuthorizer { +contract EcrecoverAuthorizer is IEcrecoverAuthorizer { address public immutable MIDNIGHT; mapping(address => uint256) public nonce; @@ -18,8 +15,8 @@ contract EcrecoverAuthorizer { } function setIsAuthorized(Authorization memory authorization, Signature calldata signature) external { - require(block.timestamp <= authorization.deadline, "expired"); - require(authorization.nonce == nonce[authorization.authorizer]++, "invalid nonce"); + require(block.timestamp <= authorization.deadline, Expired()); + require(authorization.nonce == nonce[authorization.authorizer]++, InvalidNonce()); bytes32 hashStruct = keccak256(abi.encode(AUTHORIZATION_TYPEHASH, authorization)); bytes32 domainSeparator = keccak256(abi.encode(EIP712_DOMAIN_TYPEHASH, block.chainid, address(this))); @@ -29,7 +26,7 @@ contract EcrecoverAuthorizer { signer != address(0) && (signer == authorization.authorizer || IMidnight(MIDNIGHT).isAuthorized(authorization.authorizer, signer)), - "invalid signature" + InvalidSignature() ); emit SetIsAuthorized( diff --git a/src/authorizers/interfaces/IEcrecoverAuthorizer.sol b/src/authorizers/interfaces/IEcrecoverAuthorizer.sol new file mode 100644 index 000000000..cacf074ae --- /dev/null +++ b/src/authorizers/interfaces/IEcrecoverAuthorizer.sol @@ -0,0 +1,24 @@ +// SPDX-License-Identifier: GPL-2.0-or-later +// Copyright (c) 2025 Morpho Association +pragma solidity >=0.5.0; + +import {Authorization, Signature} from "../../interfaces/IEcrecover.sol"; + +interface IEcrecoverAuthorizer { + /// ERRORS /// + error Expired(); + error InvalidNonce(); + error InvalidSignature(); + + /// EVENTS /// + event SetIsAuthorized( + address indexed caller, address indexed authorizer, address indexed authorized, bool isAuthorized, uint256 nonce + ); + + /// STORAGE GETTERS /// + function MIDNIGHT() external view returns (address); + function nonce(address authorizer) external view returns (uint256); + + /// FUNCTIONS /// + function setIsAuthorized(Authorization memory authorization, Signature calldata signature) external; +} diff --git a/src/interfaces/IMidnight.sol b/src/interfaces/IMidnight.sol index ca96e2425..3142311b2 100644 --- a/src/interfaces/IMidnight.sol +++ b/src/interfaces/IMidnight.sol @@ -64,6 +64,48 @@ struct Position { } interface IMidnight { + /// ERRORS /// + error AlreadyConsumed(); + error BuyerGatedFromIncreasingCredit(); + error BuyerPendingFeeExceedsCredit(); + error CollateralParamsNotSorted(); + error ConsumedBuyerAssets(); + error ConsumedSellerAssets(); + error ConsumedUnits(); + error ContinuousFeeTooHigh(); + error FeeNotMultipleOfFeeStep(); + error InconsistentInput(); + error InvalidBuyCallback(); + error InvalidSellCallback(); + error InvalidFeeIndex(); + error InvalidMaxLif(); + error InvalidProof(); + error InvalidSession(); + error LiquidatorGatedFromLiquidating(); + error LltvNotAllowed(); + error MakerCreditOrDebtIncreased(); + error MultipleNonZero(); + error NoCollateralParams(); + error NotLiquidatable(); + error ObligationNotCreated(); + error OfferExpired(); + error OfferNotStarted(); + error OnlyFeeClaimer(); + error OnlyFeeSetter(); + error OnlyRoleSetter(); + error RatifierFail(); + error RatifierUnauthorized(); + error RecoveryCloseFactorConditionsViolated(); + error SelfTake(); + error SellerGatedFromIncreasingDebt(); + error SellerIsLiquidatable(); + error TakerUnauthorized(); + error TooManyActivatedCollaterals(); + error TooManyCollateralParams(); + error TradingFeeTooHigh(); + error Unauthorized(); + error UnhealthyBorrower(); + // forgefmt: disable-start /// STORAGE GETTERS /// function position(bytes32 id, address user) external view returns (uint128 credit, uint128 pendingFee, uint128 lossIndex, uint128 lastAccrual, uint128 debt, uint128 activatedCollaterals); @@ -79,11 +121,9 @@ interface IMidnight { function feeSetter() external view returns (address); /// MULTICALL /// - function multicall(bytes[] calldata calls) external; /// ADMIN FUNCTIONS /// - function setRoleSetter(address newRoleSetter) external; function setFeeSetter(address newFeeSetter) external; function setFeeClaimer(address newFeeClaimer) external; @@ -95,7 +135,6 @@ interface IMidnight { function claimContinuousFee(Obligation memory obligation, uint256 amount, address receiver) external; /// ENTRY-POINTS /// - function take(uint256 units, address taker, address takerCallback, bytes memory takerCallbackData, address receiverIfTakerIsSeller, Offer memory offer, bytes memory ratifierData, bytes32 root, bytes32[] memory proof) external returns (uint256, uint256, uint256); function withdraw(Obligation memory obligation, uint256 units, address onBehalf, address receiver) external; function repay(Obligation memory obligation, uint256 units, address onBehalf, bytes calldata data) external; @@ -109,12 +148,10 @@ interface IMidnight { function touchObligation(Obligation memory obligation) external returns (bytes32); /// SLASHING AND CONTINUOUS FEE ACCRUAL /// - function updatePositionView(Obligation memory obligation, bytes32 id, address user) external view returns (uint128, uint128, uint128); function updatePosition(Obligation memory obligation, address user) external; /// OTHER VIEW FUNCTIONS /// - function userLossIndex(bytes32 id, address user) external view returns (uint128); function activatedCollaterals(bytes32 id, address user) external view returns (uint128); function collateral(bytes32 id, address user, uint256 index) external view returns (uint128); diff --git a/src/libraries/IdLib.sol b/src/libraries/IdLib.sol index 863bf8166..b3150c6a8 100644 --- a/src/libraries/IdLib.sol +++ b/src/libraries/IdLib.sol @@ -5,6 +5,8 @@ pragma solidity ^0.8.0; import {Obligation} from "../interfaces/IMidnight.sol"; library IdLib { + error SStore2DeploymentFailed(); + /// @dev Used as a prefix to some data, to give a creation code that deploys the data as runtime bytecode. /// @dev Explanation of the prefix: /// hex opcode stack comments @@ -35,6 +37,6 @@ library IdLib { assembly ("memory-safe") { create2Address := create2(0, add(creationCode, 0x20), mload(creationCode), chainid()) } - require(create2Address != address(0), "Failed to create SStore2 contract"); + require(create2Address != address(0), SStore2DeploymentFailed()); } } diff --git a/src/libraries/SafeTransferLib.sol b/src/libraries/SafeTransferLib.sol index 3dfd0e9c9..0f6735a8e 100644 --- a/src/libraries/SafeTransferLib.sol +++ b/src/libraries/SafeTransferLib.sol @@ -5,8 +5,12 @@ pragma solidity ^0.8.0; import {IERC20} from "../interfaces/IERC20.sol"; library SafeTransferLib { + error NoCode(); + error TransferFromReturnedFalse(); + error TransferReturnedFalse(); + function safeTransfer(address token, address to, uint256 value) internal { - require(token.code.length > 0, "no code"); + require(token.code.length > 0, NoCode()); (bool success, bytes memory returndata) = token.call(abi.encodeCall(IERC20.transfer, (to, value))); if (!success) { @@ -14,11 +18,11 @@ library SafeTransferLib { revert(add(returndata, 0x20), mload(returndata)) } } - require(returndata.length == 0 || abi.decode(returndata, (bool)), "transfer returned false"); + require(returndata.length == 0 || abi.decode(returndata, (bool)), TransferReturnedFalse()); } function safeTransferFrom(address token, address from, address to, uint256 value) internal { - require(token.code.length > 0, "no code"); + require(token.code.length > 0, NoCode()); (bool success, bytes memory returndata) = token.call(abi.encodeCall(IERC20.transferFrom, (from, to, value))); if (!success) { @@ -26,6 +30,6 @@ library SafeTransferLib { revert(add(returndata, 0x20), mload(returndata)) } } - require(returndata.length == 0 || abi.decode(returndata, (bool)), "transferFrom returned false"); + require(returndata.length == 0 || abi.decode(returndata, (bool)), TransferFromReturnedFalse()); } } diff --git a/src/libraries/TickLib.sol b/src/libraries/TickLib.sol index e56f5496e..a3dcc8ee5 100644 --- a/src/libraries/TickLib.sol +++ b/src/libraries/TickLib.sol @@ -8,6 +8,9 @@ uint256 constant MAX_TICK = 1046; library TickLib { using TickLib for uint256; + error PriceGreaterThanOne(); + error TickOutOfRange(); + /// @dev Returns (`x` + `d` - 1) / `d` rounded to the nearest integer with ties rounded down, without checking for /// overflow. function divHalfDownUnchecked(uint256 x, uint256 d) internal pure returns (uint256) { @@ -36,7 +39,7 @@ library TickLib { } function tickToPrice(uint256 tick) internal pure returns (uint256) { - require(tick <= MAX_TICK, "tick out of range"); + require(tick <= MAX_TICK, TickOutOfRange()); unchecked { // forge-lint: disable-next-item(unsafe-typecast) return uint256(1e36) @@ -47,7 +50,7 @@ library TickLib { /// @dev Returns the lowest tick with a higher price. function priceToTick(uint256 price) internal pure returns (uint256) { - require(price <= 1e18, "Price is greater than one"); + require(price <= 1e18, PriceGreaterThanOne()); uint256 low = 0; uint256 high = MAX_TICK; while (low != high) { diff --git a/src/libraries/UtilsLib.sol b/src/libraries/UtilsLib.sol index 7e3826eae..34e0ead98 100644 --- a/src/libraries/UtilsLib.sol +++ b/src/libraries/UtilsLib.sol @@ -3,6 +3,8 @@ pragma solidity ^0.8.0; library UtilsLib { + error CastOverflow(); + /// @dev Returns true if at most one of `x` and `y` is nonzero. function atMostOneNonZero(uint256 x, uint256 y) internal pure returns (bool z) { assembly { @@ -61,7 +63,7 @@ library UtilsLib { } function toUint128(uint256 x) internal pure returns (uint128) { - require(x <= type(uint128).max, "uint256 overflows uint128"); + require(x <= type(uint128).max, CastOverflow()); // forge-lint: disable-next-item(unsafe-typecast) as x is less than type(uint128).max return uint128(x); } diff --git a/src/periphery/TakeAmountsLib.sol b/src/periphery/TakeAmountsLib.sol index 3ec1c6388..ad6fc8b64 100644 --- a/src/periphery/TakeAmountsLib.sol +++ b/src/periphery/TakeAmountsLib.sol @@ -21,7 +21,7 @@ library TakeAmountsLib { uint256 offerPrice = TickLib.tickToPrice(offer.tick); uint256 tradingFee = midnight.tradingFee(id, UtilsLib.zeroFloorSub(offer.obligation.maturity, block.timestamp)); uint256 buyerPrice = offer.buy ? offerPrice : offerPrice + tradingFee; - require(buyerPrice <= WAD, "buyerPrice"); + require(buyerPrice <= WAD, TickLib.PriceGreaterThanOne()); return offer.buy ? targetBuyerAssets.mulDivUp(WAD, buyerPrice) : targetBuyerAssets.mulDivDown(WAD, buyerPrice); } diff --git a/src/periphery/TakeBundler.sol b/src/periphery/TakeBundler.sol index 09a3d8244..332ff19ca 100644 --- a/src/periphery/TakeBundler.sol +++ b/src/periphery/TakeBundler.sol @@ -3,21 +3,13 @@ pragma solidity 0.8.34; import {Midnight} from "../Midnight.sol"; -import {Offer} from "../interfaces/IMidnight.sol"; +import {ITakeBundler, Take} from "./interfaces/ITakeBundler.sol"; import {UtilsLib} from "../libraries/UtilsLib.sol"; import {TakeAmountsLib} from "./TakeAmountsLib.sol"; -contract TakeBundler { +contract TakeBundler is ITakeBundler { using UtilsLib for uint256; - struct Take { - uint256 units; - Offer offer; - bytes sig; - bytes32 root; - bytes32[] proof; - } - /// @dev Iterates through orders, filling up to targetUnits units total. /// @dev Assumes offers are all buy or all sell and share the same obligation id. /// @dev The taker must have authorized this bundler and the msg.sender (if different from the taker) on Midnight. @@ -34,7 +26,7 @@ contract TakeBundler { uint256 minSellerAssets, uint256 maxSellerAssets ) external { - require(taker == msg.sender || midnight.isAuthorized(taker, msg.sender), "unauthorized"); + require(taker == msg.sender || midnight.isAuthorized(taker, msg.sender), Unauthorized()); uint256 totalFilledUnits; uint256 totalBuyerAssets; @@ -59,11 +51,11 @@ contract TakeBundler { } catch {} } - require(totalFilledUnits == targetUnits, "insufficient liquidity"); - require(totalBuyerAssets >= minBuyerAssets, "buyer assets below min"); - require(totalBuyerAssets <= maxBuyerAssets, "buyer assets above max"); - require(totalSellerAssets >= minSellerAssets, "seller assets below min"); - require(totalSellerAssets <= maxSellerAssets, "seller assets above max"); + require(totalFilledUnits == targetUnits, InsufficientLiquidity()); + require(totalBuyerAssets >= minBuyerAssets, BuyerAssetsBelowMin()); + require(totalBuyerAssets <= maxBuyerAssets, BuyerAssetsAboveMax()); + require(totalSellerAssets >= minSellerAssets, SellerAssetsBelowMin()); + require(totalSellerAssets <= maxSellerAssets, SellerAssetsAboveMax()); } /// @dev Same as bundleTakeUnits but targets buyer assets. @@ -80,7 +72,7 @@ contract TakeBundler { uint256 minUnits, uint256 maxUnits ) external { - require(taker == msg.sender || midnight.isAuthorized(taker, msg.sender), "unauthorized"); + require(taker == msg.sender || midnight.isAuthorized(taker, msg.sender), Unauthorized()); bytes32 id = midnight.touchObligation(takes[0].offer.obligation); // to have the correct trading fees. uint256 totalFilledBuyerAssets; @@ -109,9 +101,9 @@ contract TakeBundler { } catch {} } - require(totalFilledBuyerAssets == targetBuyerAssets, "insufficient liquidity"); - require(totalUnits >= minUnits, "units below min"); - require(totalUnits <= maxUnits, "units above max"); + require(totalFilledBuyerAssets == targetBuyerAssets, InsufficientLiquidity()); + require(totalUnits >= minUnits, UnitsBelowMin()); + require(totalUnits <= maxUnits, UnitsAboveMax()); } /// @dev Same as bundleTakeUnits but targets seller assets. @@ -127,7 +119,7 @@ contract TakeBundler { uint256 minUnits, uint256 maxUnits ) external { - require(taker == msg.sender || midnight.isAuthorized(taker, msg.sender), "unauthorized"); + require(taker == msg.sender || midnight.isAuthorized(taker, msg.sender), Unauthorized()); bytes32 id = midnight.touchObligation(takes[0].offer.obligation); // to have the correct trading fees. uint256 totalFilledSellerAssets; @@ -156,8 +148,8 @@ contract TakeBundler { } catch {} } - require(totalFilledSellerAssets == targetSellerAssets, "insufficient liquidity"); - require(totalUnits >= minUnits, "units below min"); - require(totalUnits <= maxUnits, "units above max"); + require(totalFilledSellerAssets == targetSellerAssets, InsufficientLiquidity()); + require(totalUnits >= minUnits, UnitsBelowMin()); + require(totalUnits <= maxUnits, UnitsAboveMax()); } } diff --git a/src/periphery/interfaces/ITakeBundler.sol b/src/periphery/interfaces/ITakeBundler.sol new file mode 100644 index 000000000..f892995e7 --- /dev/null +++ b/src/periphery/interfaces/ITakeBundler.sol @@ -0,0 +1,33 @@ +// SPDX-License-Identifier: GPL-2.0-or-later +// Copyright (c) 2025 Morpho Association +pragma solidity >=0.5.0; + +import {Midnight} from "../../Midnight.sol"; +import {Offer} from "../../interfaces/IMidnight.sol"; + +struct Take { + uint256 units; + Offer offer; + bytes sig; + bytes32 root; + bytes32[] proof; +} + +interface ITakeBundler { + /// ERRORS /// + error BuyerAssetsAboveMax(); + error BuyerAssetsBelowMin(); + error InsufficientLiquidity(); + error SellerAssetsAboveMax(); + error SellerAssetsBelowMin(); + error Unauthorized(); + error UnitsAboveMax(); + error UnitsBelowMin(); + + // forgefmt: disable-start + /// FUNCTIONS /// + function bundleTakeUnits(Midnight midnight, uint256 targetUnits, address taker, address receiverIfTakerIsSeller, Take[] calldata takes, uint256 minBuyerAssets, uint256 maxBuyerAssets, uint256 minSellerAssets, uint256 maxSellerAssets) external; + function bundleTakeBuyerAssets(Midnight midnight, uint256 targetBuyerAssets, address taker, address receiverIfTakerIsSeller, Take[] calldata takes, uint256 minUnits, uint256 maxUnits) external; + function bundleTakeSellerAssets(Midnight midnight, uint256 targetSellerAssets, address taker, address receiverIfTakerIsSeller, Take[] calldata takes, uint256 minUnits, uint256 maxUnits) external; + // forgefmt: disable-end +} diff --git a/src/ratifiers/EcrecoverRatifier.sol b/src/ratifiers/EcrecoverRatifier.sol index 2d260cefe..24f4423f3 100644 --- a/src/ratifiers/EcrecoverRatifier.sol +++ b/src/ratifiers/EcrecoverRatifier.sol @@ -2,12 +2,12 @@ // Copyright (c) 2025 Morpho Association pragma solidity 0.8.34; -import {IRatifier} from "../interfaces/IRatifier.sol"; +import {IEcrecoverRatifier} from "./interfaces/IEcrecoverRatifier.sol"; import {IMidnight, Offer} from "../interfaces/IMidnight.sol"; import {CALLBACK_SUCCESS} from "../libraries/ConstantsLib.sol"; import {Signature, EIP712_DOMAIN_TYPEHASH, ROOT_TYPEHASH} from "../interfaces/IEcrecover.sol"; -contract EcrecoverRatifier is IRatifier { +contract EcrecoverRatifier is IEcrecoverRatifier { address public immutable MIDNIGHT; constructor(address _midnight) { @@ -20,8 +20,8 @@ contract EcrecoverRatifier is IRatifier { bytes32 domainSeparator = keccak256(abi.encode(EIP712_DOMAIN_TYPEHASH, block.chainid, address(this))); bytes32 digest = keccak256(bytes.concat("\x19\x01", domainSeparator, structHash)); address _signer = ecrecover(digest, sig.v, sig.r, sig.s); - require(_signer != address(0), "invalid signature"); - require(_signer == offer.maker || IMidnight(MIDNIGHT).isAuthorized(offer.maker, _signer), "unauthorized"); + require(_signer != address(0), InvalidSignature()); + require(_signer == offer.maker || IMidnight(MIDNIGHT).isAuthorized(offer.maker, _signer), Unauthorized()); return CALLBACK_SUCCESS; } } diff --git a/src/ratifiers/SetterRatifier.sol b/src/ratifiers/SetterRatifier.sol index ea1eb9c1d..e4485521c 100644 --- a/src/ratifiers/SetterRatifier.sol +++ b/src/ratifiers/SetterRatifier.sol @@ -2,13 +2,11 @@ // Copyright (c) 2025 Morpho Association pragma solidity 0.8.34; -import {IRatifier} from "../interfaces/IRatifier.sol"; +import {ISetterRatifier} from "./interfaces/ISetterRatifier.sol"; import {IMidnight, Offer} from "../interfaces/IMidnight.sol"; import {CALLBACK_SUCCESS} from "../libraries/ConstantsLib.sol"; -contract SetterRatifier is IRatifier { - event SetIsRatified(address indexed maker, bytes32 indexed root, bool newIsRatified); - +contract SetterRatifier is ISetterRatifier { address public immutable MIDNIGHT; mapping(address maker => mapping(bytes32 root => bool)) public isRatified; @@ -18,13 +16,13 @@ contract SetterRatifier is IRatifier { } function setIsRatified(address maker, bytes32 root, bool newIsRatified) public { - require(maker == msg.sender || IMidnight(MIDNIGHT).isAuthorized(maker, msg.sender), "unauthorized"); + require(maker == msg.sender || IMidnight(MIDNIGHT).isAuthorized(maker, msg.sender), Unauthorized()); isRatified[maker][root] = newIsRatified; emit SetIsRatified(maker, root, newIsRatified); } function onRatify(Offer memory offer, bytes32 root, bytes memory) external view returns (bytes32) { - require(isRatified[offer.maker][root], "not ratified"); + require(isRatified[offer.maker][root], NotRatified()); return CALLBACK_SUCCESS; } } diff --git a/src/ratifiers/interfaces/IEcrecoverRatifier.sol b/src/ratifiers/interfaces/IEcrecoverRatifier.sol new file mode 100644 index 000000000..a15076794 --- /dev/null +++ b/src/ratifiers/interfaces/IEcrecoverRatifier.sol @@ -0,0 +1,14 @@ +// SPDX-License-Identifier: GPL-2.0-or-later +// Copyright (c) 2025 Morpho Association +pragma solidity ^0.8.0; + +import {IRatifier} from "../../interfaces/IRatifier.sol"; + +interface IEcrecoverRatifier is IRatifier { + /// ERRORS /// + error InvalidSignature(); + error Unauthorized(); + + /// STORAGE GETTERS /// + function MIDNIGHT() external view returns (address); +} diff --git a/src/ratifiers/interfaces/ISetterRatifier.sol b/src/ratifiers/interfaces/ISetterRatifier.sol new file mode 100644 index 000000000..d87cf7ca7 --- /dev/null +++ b/src/ratifiers/interfaces/ISetterRatifier.sol @@ -0,0 +1,21 @@ +// SPDX-License-Identifier: GPL-2.0-or-later +// Copyright (c) 2025 Morpho Association +pragma solidity ^0.8.0; + +import {IRatifier} from "../../interfaces/IRatifier.sol"; + +interface ISetterRatifier is IRatifier { + /// ERRORS /// + error Unauthorized(); + error NotRatified(); + + /// EVENTS /// + event SetIsRatified(address indexed maker, bytes32 indexed root, bool newApproval); + + /// FUNCTIONS /// + function setIsRatified(address maker, bytes32 root, bool newIsRatified) external; + + /// STORAGE GETTERS /// + function MIDNIGHT() external view returns (address); + function isRatified(address maker, bytes32 root) external view returns (bool); +} diff --git a/test/AuthorizationTest.sol b/test/AuthorizationTest.sol index a27f97af4..244dfed18 100644 --- a/test/AuthorizationTest.sol +++ b/test/AuthorizationTest.sol @@ -2,7 +2,7 @@ // Copyright (c) 2025 Morpho Association pragma solidity ^0.8.0; -import {Obligation, CollateralParams, Offer} from "../src/interfaces/IMidnight.sol"; +import {IMidnight, Obligation, CollateralParams, Offer} from "../src/interfaces/IMidnight.sol"; import {BaseTest} from "./BaseTest.sol"; import {UtilsLib} from "../src/libraries/UtilsLib.sol"; import {ERC20} from "./erc20s/ERC20.sol"; @@ -63,7 +63,7 @@ contract AuthorizationTest is BaseTest { // Attacker tries to withdraw lender's units address attacker = makeAddr("attacker"); vm.prank(attacker); - vm.expectRevert("unauthorized"); + vm.expectRevert(IMidnight.Unauthorized.selector); midnight.withdraw(obligation, units, lender, lender); } @@ -82,7 +82,7 @@ contract AuthorizationTest is BaseTest { // Attacker tries to withdraw user's collateral address attacker = makeAddr("attacker"); vm.prank(attacker); - vm.expectRevert("unauthorized"); + vm.expectRevert(IMidnight.Unauthorized.selector); midnight.withdrawCollateral(obligation, 0, collateralAmount, user, user); } @@ -145,7 +145,7 @@ contract AuthorizationTest is BaseTest { ERC20(collateralToken).approve(address(midnight), collateralAmount); vm.prank(operator); - vm.expectRevert("unauthorized"); + vm.expectRevert(IMidnight.Unauthorized.selector); midnight.supplyCollateral(obligation, 0, collateralAmount, user); // User authorizes operator @@ -213,7 +213,7 @@ contract AuthorizationTest is BaseTest { // Attacker tries to take on behalf of taker address attacker = makeAddr("attacker"); vm.prank(attacker); - vm.expectRevert("taker unauthorized"); + vm.expectRevert(IMidnight.TakerUnauthorized.selector); midnight.take(units, taker, address(0), hex"", address(0), offer, sig([offer]), root([offer]), proof([offer])); } @@ -259,7 +259,7 @@ contract AuthorizationTest is BaseTest { loanToken.approve(address(midnight), units); vm.prank(authorized); - vm.expectRevert("unauthorized"); + vm.expectRevert(IMidnight.Unauthorized.selector); midnight.repay(obligation, units, borrower, hex""); vm.prank(borrower); @@ -275,7 +275,7 @@ contract AuthorizationTest is BaseTest { vm.assume(user != authorized); vm.prank(authorized); - vm.expectRevert("unauthorized"); + vm.expectRevert(IMidnight.Unauthorized.selector); midnight.setConsumed(bytes32(0), 100, user); vm.prank(user); @@ -291,7 +291,7 @@ contract AuthorizationTest is BaseTest { vm.assume(user != authorized); vm.prank(authorized); - vm.expectRevert("unauthorized"); + vm.expectRevert(IMidnight.Unauthorized.selector); midnight.shuffleSession(user); vm.prank(user); @@ -307,7 +307,7 @@ contract AuthorizationTest is BaseTest { vm.assume(user != authorized); vm.prank(authorized); - vm.expectRevert("unauthorized"); + vm.expectRevert(IMidnight.Unauthorized.selector); midnight.setIsAuthorized(user, newAuthorized, true); vm.prank(user); diff --git a/test/BundlerTest.sol b/test/BundlerTest.sol index 9b4f66d73..222a4b526 100644 --- a/test/BundlerTest.sol +++ b/test/BundlerTest.sol @@ -7,6 +7,7 @@ import {UtilsLib} from "../src/libraries/UtilsLib.sol"; import {TickLib, MAX_TICK} from "../src/libraries/TickLib.sol"; import {WAD} from "../src/libraries/ConstantsLib.sol"; import {TakeBundler} from "../src/periphery/TakeBundler.sol"; +import {ITakeBundler, Take} from "../src/periphery/interfaces/ITakeBundler.sol"; import {BaseTest} from "./BaseTest.sol"; contract BundlerTest is BaseTest { @@ -82,13 +83,13 @@ contract BundlerTest is BaseTest { } function testUnauthorized() public { - TakeBundler.Take[] memory takes = new TakeBundler.Take[](1); - takes[0] = TakeBundler.Take({ + Take[] memory takes = new Take[](1); + takes[0] = Take({ offer: offers[0], units: 100, sig: sig([offers[0]]), root: root([offers[0]]), proof: proof([offers[0]]) }); vm.prank(address(0xdead)); - vm.expectRevert("unauthorized"); + vm.expectRevert(ITakeBundler.Unauthorized.selector); takeBundler.bundleTakeUnits( midnight, 100, borrower, address(0), takes, 0, type(uint256).max, 0, type(uint256).max ); @@ -102,15 +103,15 @@ contract BundlerTest is BaseTest { collateralize(obligation, borrower, units); - TakeBundler.Take[] memory takes = new TakeBundler.Take[](2); - takes[0] = TakeBundler.Take({ + Take[] memory takes = new Take[](2); + takes[0] = Take({ offer: offers[0], units: offerUnits0, sig: sig([offers[0]]), root: root([offers[0]]), proof: proof([offers[0]]) }); - takes[1] = TakeBundler.Take({ + takes[1] = Take({ offer: offers[1], units: offerUnits1, sig: sig([offers[1]]), @@ -133,7 +134,7 @@ contract BundlerTest is BaseTest { assertEq(midnight.debtOf(id, borrower), units, "debt"); } else { vm.prank(borrower); - vm.expectRevert("insufficient liquidity"); + vm.expectRevert(ITakeBundler.InsufficientLiquidity.selector); takeBundler.bundleTakeUnits( midnight, units, borrower, borrower, takes, 0, type(uint256).max, 0, type(uint256).max ); @@ -152,15 +153,15 @@ contract BundlerTest is BaseTest { collateralize(obligation, borrower, units); - TakeBundler.Take[] memory takes = new TakeBundler.Take[](2); - takes[0] = TakeBundler.Take({ + Take[] memory takes = new Take[](2); + takes[0] = Take({ offer: offers[0], units: offerUnits0, sig: sig([offers[0]]), root: root([offers[0]]), proof: proof([offers[0]]) }); - takes[1] = TakeBundler.Take({ + takes[1] = Take({ offer: offers[1], units: offerUnits1, sig: sig([offers[1]]), @@ -183,7 +184,7 @@ contract BundlerTest is BaseTest { assertEq(loanToken.balanceOf(lender), type(uint256).max - targetBuyerAssets, "lender balance"); } else { vm.prank(borrower); - vm.expectRevert("insufficient liquidity"); + vm.expectRevert(ITakeBundler.InsufficientLiquidity.selector); takeBundler.bundleTakeBuyerAssets( midnight, targetBuyerAssets, borrower, borrower, takes, 0, type(uint256).max ); @@ -204,15 +205,15 @@ contract BundlerTest is BaseTest { // Extra collateral headroom for the potential extra unit of debt. collateralize(obligation, borrower, units + 1); - TakeBundler.Take[] memory takes = new TakeBundler.Take[](2); - takes[0] = TakeBundler.Take({ + Take[] memory takes = new Take[](2); + takes[0] = Take({ offer: offers[0], units: offerUnits0, sig: sig([offers[0]]), root: root([offers[0]]), proof: proof([offers[0]]) }); - takes[1] = TakeBundler.Take({ + takes[1] = Take({ offer: offers[1], units: offerUnits1, sig: sig([offers[1]]), @@ -241,7 +242,7 @@ contract BundlerTest is BaseTest { assertEq(loanToken.balanceOf(borrower), targetSellerAssets, "borrower balance"); } else { vm.prank(borrower); - vm.expectRevert("insufficient liquidity"); + vm.expectRevert(ITakeBundler.InsufficientLiquidity.selector); takeBundler.bundleTakeSellerAssets( midnight, targetSellerAssets, borrower, borrower, takes, 0, type(uint256).max ); @@ -297,15 +298,15 @@ contract BundlerTest is BaseTest { collateralize(obligation, borrower, targetUnits); - TakeBundler.Take[] memory takes = new TakeBundler.Take[](2); - takes[0] = TakeBundler.Take({ + Take[] memory takes = new Take[](2); + takes[0] = Take({ offer: offers[0], units: offerUnits0, sig: sig([offers[0]]), root: root([offers[0]]), proof: proof([offers[0]]) }); - takes[1] = TakeBundler.Take({ + takes[1] = Take({ offer: offers[1], units: offerUnits1, sig: sig([offers[1]]), @@ -316,7 +317,7 @@ contract BundlerTest is BaseTest { _authorizeBundler(); vm.prank(borrower); - vm.expectRevert("buyer assets above max"); + vm.expectRevert(ITakeBundler.BuyerAssetsAboveMax.selector); takeBundler.bundleTakeUnits( midnight, targetUnits, borrower, borrower, takes, 0, maxBuyerAssets, 0, type(uint256).max ); @@ -347,15 +348,15 @@ contract BundlerTest is BaseTest { collateralize(obligation, borrower, targetUnits); - TakeBundler.Take[] memory takes = new TakeBundler.Take[](2); - takes[0] = TakeBundler.Take({ + Take[] memory takes = new Take[](2); + takes[0] = Take({ offer: offers[0], units: offerUnits0, sig: sig([offers[0]]), root: root([offers[0]]), proof: proof([offers[0]]) }); - takes[1] = TakeBundler.Take({ + takes[1] = Take({ offer: offers[1], units: offerUnits1, sig: sig([offers[1]]), @@ -366,7 +367,7 @@ contract BundlerTest is BaseTest { _authorizeBundler(); vm.prank(borrower); - vm.expectRevert("buyer assets below min"); + vm.expectRevert(ITakeBundler.BuyerAssetsBelowMin.selector); takeBundler.bundleTakeUnits( midnight, targetUnits, borrower, borrower, takes, minBuyerAssets, type(uint256).max, 0, type(uint256).max ); diff --git a/test/ContinuousFeeTest.sol b/test/ContinuousFeeTest.sol index 0d34a1f39..b073b161c 100644 --- a/test/ContinuousFeeTest.sol +++ b/test/ContinuousFeeTest.sol @@ -6,7 +6,7 @@ import {WAD, MAX_CONTINUOUS_FEE} from "../src/libraries/ConstantsLib.sol"; import {EventsLib} from "../src/libraries/EventsLib.sol"; import {UtilsLib} from "../src/libraries/UtilsLib.sol"; import {TickLib, MAX_TICK} from "../src/libraries/TickLib.sol"; -import {Obligation, Offer, CollateralParams} from "../src/interfaces/IMidnight.sol"; +import {IMidnight, Obligation, Offer, CollateralParams} from "../src/interfaces/IMidnight.sol"; import {BaseTest, MAX_TEST_AMOUNT} from "./BaseTest.sol"; uint256 constant MAX_CREDIT = MAX_TEST_AMOUNT / 4; @@ -439,7 +439,7 @@ contract ContinuousFeeTest is BaseTest { function testClaimContinuousFeeOnlyFeeClaimer(address caller) public { vm.assume(caller != feeClaimer); vm.prank(caller); - vm.expectRevert("only fee claimer"); + vm.expectRevert(IMidnight.OnlyFeeClaimer.selector); midnight.claimContinuousFee(obligation, 0, caller); } @@ -489,13 +489,13 @@ contract ContinuousFeeTest is BaseTest { } function testUpdatePositionRevertsIfObligationNotCreated() public { - vm.expectRevert("obligation not created"); + vm.expectRevert(IMidnight.ObligationNotCreated.selector); midnight.updatePosition(obligation, borrower); } function testClaimContinuousFeeRevertsIfObligationNotCreated() public { vm.prank(feeClaimer); - vm.expectRevert("obligation not created"); + vm.expectRevert(IMidnight.ObligationNotCreated.selector); midnight.claimContinuousFee(obligation, 0, feeClaimer); } diff --git a/test/EcrecoverRatifierTest.sol b/test/EcrecoverRatifierTest.sol index cd991f71b..26cbc00ed 100644 --- a/test/EcrecoverRatifierTest.sol +++ b/test/EcrecoverRatifierTest.sol @@ -5,6 +5,7 @@ pragma solidity ^0.8.0; import {Offer} from "../src/interfaces/IMidnight.sol"; import {Signature, EIP712_DOMAIN_TYPEHASH, ROOT_TYPEHASH} from "../src/interfaces/IEcrecover.sol"; import {CALLBACK_SUCCESS} from "../src/libraries/ConstantsLib.sol"; +import {IEcrecoverRatifier} from "../src/ratifiers/interfaces/IEcrecoverRatifier.sol"; import {BaseTest} from "./BaseTest.sol"; contract EcrecoverRatifierTest is BaseTest { @@ -50,7 +51,7 @@ contract EcrecoverRatifierTest is BaseTest { bytes32 _root = keccak256(abi.encode(offer)); bytes memory data = signRoot(_root, borrower); - vm.expectRevert("unauthorized"); + vm.expectRevert(IEcrecoverRatifier.Unauthorized.selector); ecrecoverRatifier.onRatify(offer, _root, data); } @@ -59,7 +60,7 @@ contract EcrecoverRatifierTest is BaseTest { bytes32 _root = keccak256(abi.encode(offer)); bytes memory data = abi.encode(Signature({v: 27, r: bytes32(uint256(1)), s: bytes32(uint256(2))})); - vm.expectRevert("unauthorized"); + vm.expectRevert(IEcrecoverRatifier.Unauthorized.selector); ecrecoverRatifier.onRatify(offer, _root, data); } @@ -69,7 +70,7 @@ contract EcrecoverRatifierTest is BaseTest { bytes memory data = signRoot(_root, lender); bytes32 wrongRoot = keccak256("wrong"); - vm.expectRevert("unauthorized"); + vm.expectRevert(IEcrecoverRatifier.Unauthorized.selector); ecrecoverRatifier.onRatify(offer, wrongRoot, data); } @@ -89,7 +90,7 @@ contract EcrecoverRatifierTest is BaseTest { vm.prank(lender); midnight.setIsAuthorized(lender, borrower, false); - vm.expectRevert("unauthorized"); + vm.expectRevert(IEcrecoverRatifier.Unauthorized.selector); ecrecoverRatifier.onRatify(offer, _root, data); } } diff --git a/test/GateTest.sol b/test/GateTest.sol index 77a893518..cc3923e38 100644 --- a/test/GateTest.sol +++ b/test/GateTest.sol @@ -2,7 +2,7 @@ // Copyright (c) 2025 Morpho Association pragma solidity ^0.8.0; -import {Obligation, Offer, CollateralParams} from "../src/interfaces/IMidnight.sol"; +import {IMidnight, Obligation, Offer, CollateralParams} from "../src/interfaces/IMidnight.sol"; import {IEnterGate, ILiquidatorGate} from "../src/interfaces/IGate.sol"; import {LIQUIDATION_CURSOR_LOW, ORACLE_PRICE_SCALE} from "../src/libraries/ConstantsLib.sol"; import {MAX_TICK} from "../src/libraries/TickLib.sol"; @@ -100,7 +100,7 @@ contract GateTest is BaseTest { gate.setWhitelisted(borrower, true); - vm.expectRevert("buyer gated from increasing credit"); + vm.expectRevert(IMidnight.BuyerGatedFromIncreasingCredit.selector); take(units, lender, borrowerOffer); } @@ -110,7 +110,7 @@ contract GateTest is BaseTest { gate.setWhitelisted(lender, true); - vm.expectRevert("seller gated from increasing debt"); + vm.expectRevert(IMidnight.SellerGatedFromIncreasingDebt.selector); take(units, borrower, lenderOffer); } @@ -279,7 +279,7 @@ contract GateTest is BaseTest { deal(address(loanToken), liquidator, units); vm.prank(liquidator); - if (!isWhitelisted) vm.expectRevert("liquidator gated from liquidating"); + if (!isWhitelisted) vm.expectRevert(IMidnight.LiquidatorGatedFromLiquidating.selector); midnight.liquidate(gatedObligation, 0, 1, 0, borrower, ""); } @@ -295,7 +295,7 @@ contract GateTest is BaseTest { Oracle(gatedObligation.collateralParams[0].oracle).setPrice(0); vm.prank(liquidator); - if (!isWhitelisted) vm.expectRevert("liquidator gated from liquidating"); + if (!isWhitelisted) vm.expectRevert(IMidnight.LiquidatorGatedFromLiquidating.selector); midnight.liquidate(gatedObligation, 0, 0, 0, borrower, ""); } diff --git a/test/LiquidationTest.sol b/test/LiquidationTest.sol index e6e8669f2..fa04abb18 100644 --- a/test/LiquidationTest.sol +++ b/test/LiquidationTest.sol @@ -9,7 +9,7 @@ import { LLTV_8, LIQUIDATION_CURSOR_LOW } from "../src/libraries/ConstantsLib.sol"; -import {Obligation, CollateralParams} from "../src/interfaces/IMidnight.sol"; +import {IMidnight, Obligation, CollateralParams} from "../src/interfaces/IMidnight.sol"; import {IdLib} from "../src/libraries/IdLib.sol"; import {IOracle} from "../src/interfaces/IOracle.sol"; import {UtilsLib} from "../src/libraries/UtilsLib.sol"; @@ -101,7 +101,7 @@ contract LiquidationTest is BaseTest { setupObligation(obligation, units); Oracle(obligation.collateralParams[0].oracle).setPrice(liquidationOraclePrice); - vm.expectRevert("not liquidatable"); + vm.expectRevert(IMidnight.NotLiquidatable.selector); midnight.liquidate(obligation, 0, 0, 0, borrower, ""); } @@ -142,7 +142,7 @@ contract LiquidationTest is BaseTest { collateralize(obligation, borrower, units); setupObligation(obligation, units); - vm.expectRevert("inconsistent input"); + vm.expectRevert(IMidnight.InconsistentInput.selector); midnight.liquidate(obligation, 0, 1, 1, borrower, ""); } @@ -463,7 +463,7 @@ contract LiquidationTest is BaseTest { uint256 maxR = _maxRepaid(units, units, liquidationOraclePrice); repaid = bound(repaid, maxR + 1, max(units, maxR + 1)); - vm.expectRevert("recovery close factor conditions violated"); + vm.expectRevert(IMidnight.RecoveryCloseFactorConditionsViolated.selector); midnight.liquidate(obligation, 0, 0, repaid, borrower, ""); repaid = bound(repaid, 0, min(maxR, units)); @@ -536,7 +536,7 @@ contract LiquidationTest is BaseTest { Oracle(obligation.collateralParams[0].oracle).setPrice(liquidationOraclePrice); // Full liquidation should revert because remaining debt >= rcfThreshold. - vm.expectRevert("recovery close factor conditions violated"); + vm.expectRevert(IMidnight.RecoveryCloseFactorConditionsViolated.selector); midnight.liquidate(obligation, 0, 0, units, borrower, ""); } @@ -552,7 +552,7 @@ contract LiquidationTest is BaseTest { // At exact maturity: recovery close factor applies. if (maxRepaid < units) { vm.warp(obligation.maturity); - vm.expectRevert("recovery close factor conditions violated"); + vm.expectRevert(IMidnight.RecoveryCloseFactorConditionsViolated.selector); midnight.liquidate(obligation, 0, 0, units, borrower, ""); } diff --git a/test/MaxAmountsTest.sol b/test/MaxAmountsTest.sol index bd76d7647..3d1fd23fd 100644 --- a/test/MaxAmountsTest.sol +++ b/test/MaxAmountsTest.sol @@ -98,7 +98,7 @@ contract MaxAmountsTest is BaseTest { borrowerOffer.ratifier = address(ecrecoverRatifier); borrowerOffer.tick = MAX_TICK; - vm.expectRevert("uint256 overflows uint128"); + vm.expectRevert(UtilsLib.CastOverflow.selector); take(amount, lender, borrowerOffer); } @@ -125,7 +125,7 @@ contract MaxAmountsTest is BaseTest { midnight.setIsAuthorized(borrower, address(this), true); - vm.expectRevert("uint256 overflows uint128"); + vm.expectRevert(UtilsLib.CastOverflow.selector); midnight.supplyCollateral(obligation, 0, amount, borrower); } } diff --git a/test/MulticallTest.sol b/test/MulticallTest.sol index e7965d5e7..7ae937aa6 100644 --- a/test/MulticallTest.sol +++ b/test/MulticallTest.sol @@ -3,6 +3,7 @@ pragma solidity ^0.8.0; import {BaseTest} from "./BaseTest.sol"; +import {IMidnight} from "../src/interfaces/IMidnight.sol"; contract MulticallTest is BaseTest { function testMulticallSuccess() public { @@ -23,7 +24,7 @@ contract MulticallTest is BaseTest { data[1] = abi.encodeCall(midnight.setFeeSetter, (makeAddr("newFeeSetter"))); vm.prank(midnight.roleSetter()); - vm.expectRevert("only role setter"); + vm.expectRevert(IMidnight.OnlyRoleSetter.selector); midnight.multicall(data); } diff --git a/test/OtherFunctionsTest.sol b/test/OtherFunctionsTest.sol index 6a4f0594e..59e0edbe7 100644 --- a/test/OtherFunctionsTest.sol +++ b/test/OtherFunctionsTest.sol @@ -2,7 +2,7 @@ // Copyright (c) 2025 Morpho Association pragma solidity ^0.8.0; -import {Obligation, CollateralParams} from "../src/interfaces/IMidnight.sol"; +import {IMidnight, Obligation, CollateralParams} from "../src/interfaces/IMidnight.sol"; import {ICallbacks} from "../src/interfaces/ICallbacks.sol"; import {Midnight} from "../src/Midnight.sol"; import {IdLib} from "../src/libraries/IdLib.sol"; @@ -101,7 +101,7 @@ contract OtherFunctionsTest is BaseTest { withdraw = bound(withdraw, additionalCollateral + 1, initialCollateral); vm.prank(borrower); - vm.expectRevert("unhealthy borrower"); + vm.expectRevert(IMidnight.UnhealthyBorrower.selector); midnight.withdrawCollateral(obligation, 0, withdraw, borrower, borrower); } @@ -213,7 +213,7 @@ contract OtherFunctionsTest is BaseTest { midnight.setConsumed(group, amount0, user); vm.prank(user); - vm.expectRevert("already consumed"); + vm.expectRevert(IMidnight.AlreadyConsumed.selector); midnight.setConsumed(group, amount1, user); } @@ -362,7 +362,7 @@ contract OtherFunctionsTest is BaseTest { _obligation.loanToken = address(loanToken); _obligation.maturity = block.timestamp + 100; _obligation.collateralParams = new CollateralParams[](0); - vm.expectRevert("no collateralParams"); + vm.expectRevert(IMidnight.NoCollateralParams.selector); midnight.touchObligation(_obligation); } @@ -370,7 +370,7 @@ contract OtherFunctionsTest is BaseTest { numCollaterals = bound(numCollaterals, MAX_COLLATERALS + 1, 1000); Obligation memory _obligation = _createMultiCollateralObligation(numCollaterals); - vm.expectRevert("too many collateralParams"); + vm.expectRevert(IMidnight.TooManyCollateralParams.selector); midnight.touchObligation(_obligation); } @@ -386,7 +386,7 @@ contract OtherFunctionsTest is BaseTest { token: address(uint160(1)), lltv: 0.77e18, maxLif: maxLif(0.77e18, 0.25e18), oracle: address(oracle2) }); _obligation.collateralParams = collateralParams; - vm.expectRevert("collateralParams not sorted"); + vm.expectRevert(IMidnight.CollateralParamsNotSorted.selector); midnight.touchObligation(_obligation); } @@ -400,7 +400,7 @@ contract OtherFunctionsTest is BaseTest { token: address(collateralToken1), lltv: lltv, maxLif: maxLif(0.77e18, 0.25e18), oracle: address(oracle1) }); _obligation.collateralParams = collateralParams; - vm.expectRevert("lltv not allowed"); + vm.expectRevert(IMidnight.LltvNotAllowed.selector); midnight.touchObligation(_obligation); } @@ -415,7 +415,7 @@ contract OtherFunctionsTest is BaseTest { token: address(collateralToken1), lltv: lltv, maxLif: maxLif(0.77e18, 0.25e18), oracle: address(oracle1) }); _obligation.collateralParams = collateralParams; - vm.expectRevert("lltv not allowed"); + vm.expectRevert(IMidnight.LltvNotAllowed.selector); midnight.touchObligation(_obligation); } @@ -440,7 +440,7 @@ contract OtherFunctionsTest is BaseTest { address lastToken = _obligation.collateralParams[numCollaterals - 1].token; deal(lastToken, address(this), 1e18); ERC20(lastToken).approve(address(midnight), 1e18); - vm.expectRevert("too many activated collaterals"); + vm.expectRevert(IMidnight.TooManyActivatedCollaterals.selector); midnight.supplyCollateral(_obligation, numCollaterals - 1, 1e18, borrower); } @@ -551,7 +551,7 @@ contract OtherFunctionsTest is BaseTest { CollateralParams({token: address(collateralToken1), lltv: lltv, maxLif: lif, oracle: address(oracle1)}); _obligation.collateralParams = collateralParams; - vm.expectRevert("invalid maxLif"); + vm.expectRevert(IMidnight.InvalidMaxLif.selector); midnight.touchObligation(_obligation); } diff --git a/test/SafeTransferLibTest.sol b/test/SafeTransferLibTest.sol index fb43b04b5..59f9133f3 100644 --- a/test/SafeTransferLibTest.sol +++ b/test/SafeTransferLibTest.sol @@ -66,7 +66,7 @@ contract SafeTransferLibTest is Test { } function testSafeTransferNoCode() public { - vm.expectRevert("no code"); + vm.expectRevert(SafeTransferLib.NoCode.selector); this.safeTransfer(address(1), address(1), 1); } @@ -81,7 +81,7 @@ contract SafeTransferLibTest is Test { } function testSafeTransferReturnedFalse() public { - vm.expectRevert("transfer returned false"); + vm.expectRevert(SafeTransferLib.TransferReturnedFalse.selector); this.safeTransfer(address(tokenFalse), address(1), 1); } @@ -96,7 +96,7 @@ contract SafeTransferLibTest is Test { } function testSafeTransferFromNoCode() public { - vm.expectRevert("no code"); + vm.expectRevert(SafeTransferLib.NoCode.selector); this.safeTransferFrom(address(1), address(1), address(1), 1); } @@ -111,7 +111,7 @@ contract SafeTransferLibTest is Test { } function testSafeTransferFromReturnedFalse() public { - vm.expectRevert("transferFrom returned false"); + vm.expectRevert(SafeTransferLib.TransferFromReturnedFalse.selector); this.safeTransferFrom(address(tokenFalse), address(1), address(1), 1); } diff --git a/test/SetIsAuthorizedWithSigTest.sol b/test/SetIsAuthorizedWithSigTest.sol index 9238119e4..73bd063d8 100644 --- a/test/SetIsAuthorizedWithSigTest.sol +++ b/test/SetIsAuthorizedWithSigTest.sol @@ -8,6 +8,7 @@ import { EIP712_DOMAIN_TYPEHASH, AUTHORIZATION_TYPEHASH } from "../src/interfaces/IEcrecover.sol"; +import {IEcrecoverAuthorizer} from "../src/authorizers/interfaces/IEcrecoverAuthorizer.sol"; import {BaseTest} from "./BaseTest.sol"; contract EcrecoverAuthorizerTest is BaseTest { @@ -76,7 +77,7 @@ contract EcrecoverAuthorizerTest is BaseTest { Authorization memory auth = makeAuthorization(borrower, lender, true); Signature memory sig = signAuthorization(auth, lender); // wrong signer - vm.expectRevert("invalid signature"); + vm.expectRevert(IEcrecoverAuthorizer.InvalidSignature.selector); ecrecoverAuthorizer.setIsAuthorized(auth, sig); assertEq(midnight.isAuthorized(borrower, lender), false); @@ -88,7 +89,7 @@ contract EcrecoverAuthorizerTest is BaseTest { auth.deadline = block.timestamp - 1; Signature memory sig = signAuthorization(auth, borrower); - vm.expectRevert("expired"); + vm.expectRevert(IEcrecoverAuthorizer.Expired.selector); ecrecoverAuthorizer.setIsAuthorized(auth, sig); } @@ -97,7 +98,7 @@ contract EcrecoverAuthorizerTest is BaseTest { auth.nonce = 999; // wrong nonce Signature memory sig = signAuthorization(auth, borrower); - vm.expectRevert("invalid nonce"); + vm.expectRevert(IEcrecoverAuthorizer.InvalidNonce.selector); ecrecoverAuthorizer.setIsAuthorized(auth, sig); } diff --git a/test/SetterRatifierTest.sol b/test/SetterRatifierTest.sol index 35764a0bb..214cb1d3a 100644 --- a/test/SetterRatifierTest.sol +++ b/test/SetterRatifierTest.sol @@ -4,6 +4,7 @@ pragma solidity ^0.8.0; import {CollateralParams, Obligation, Offer} from "../src/interfaces/IMidnight.sol"; import {SetterRatifier} from "../src/ratifiers/SetterRatifier.sol"; +import {ISetterRatifier} from "../src/ratifiers/interfaces/ISetterRatifier.sol"; import {CALLBACK_SUCCESS} from "../src/libraries/ConstantsLib.sol"; import {MAX_TICK} from "../src/libraries/TickLib.sol"; import {BaseTest} from "./BaseTest.sol"; @@ -77,7 +78,7 @@ contract SetterRatifierTest is BaseTest { bytes32 _root = keccak256("root"); vm.prank(borrower); - vm.expectRevert("unauthorized"); + vm.expectRevert(ISetterRatifier.Unauthorized.selector); setterRatifier.setIsRatified(lender, _root, true); } } diff --git a/test/SettersTest.sol b/test/SettersTest.sol index 90e5cdfc7..24d0c272a 100644 --- a/test/SettersTest.sol +++ b/test/SettersTest.sol @@ -4,7 +4,7 @@ pragma solidity ^0.8.0; import {MAX_CONTINUOUS_FEE} from "../src/libraries/ConstantsLib.sol"; import {BaseTest} from "./BaseTest.sol"; -import {Obligation, CollateralParams} from "../src/interfaces/IMidnight.sol"; +import {IMidnight, Obligation, CollateralParams} from "../src/interfaces/IMidnight.sol"; contract SettersTest is BaseTest { function testInitialRoleSetter() public view { @@ -19,7 +19,7 @@ contract SettersTest is BaseTest { function testSetRoleSetterOnlyRoleSetter(address rdm) public { vm.assume(rdm != address(this)); vm.prank(rdm); - vm.expectRevert("only role setter"); + vm.expectRevert(IMidnight.OnlyRoleSetter.selector); midnight.setRoleSetter(makeAddr("newRoleSetter")); } @@ -31,7 +31,7 @@ contract SettersTest is BaseTest { function testSetFeeSetterOnlyRoleSetter(address rdm) public { vm.assume(rdm != address(this)); vm.prank(rdm); - vm.expectRevert("only role setter"); + vm.expectRevert(IMidnight.OnlyRoleSetter.selector); midnight.setFeeSetter(makeAddr("newFeeSetter")); } @@ -88,19 +88,19 @@ contract SettersTest is BaseTest { } function testSetTradingFeeInvalidIndex(bytes32 id) public { - vm.expectRevert("invalid index"); + vm.expectRevert(IMidnight.InvalidFeeIndex.selector); midnight.setObligationTradingFee(id, 7, 0); } function testSetDefaultTradingFeeInvalidIndex(address loanToken) public { - vm.expectRevert("invalid index"); + vm.expectRevert(IMidnight.InvalidFeeIndex.selector); midnight.setDefaultTradingFee(loanToken, 7, 0); } function testSetObligationTradingFeeValueTooHigh(bytes32 id, uint256 feeTooHigh, uint256 index) public { index = bound(index, 0, 6); feeTooHigh = bound(feeTooHigh, midnight.maxTradingFee(index) + 1, 1e18); - vm.expectRevert("trading fee too high"); + vm.expectRevert(IMidnight.TradingFeeTooHigh.selector); midnight.setObligationTradingFee(id, index, feeTooHigh); } @@ -108,7 +108,7 @@ contract SettersTest is BaseTest { index = bound(index, 0, 6); fee = bound(fee, 1, midnight.maxTradingFee(index)); vm.assume(fee % 1e12 != 0); - vm.expectRevert("fee should be a multiple of FEE_STEP"); + vm.expectRevert(IMidnight.FeeNotMultipleOfFeeStep.selector); midnight.setObligationTradingFee(id, index, fee); } @@ -116,19 +116,19 @@ contract SettersTest is BaseTest { index = bound(index, 0, 6); fee = bound(fee, 1, midnight.maxTradingFee(index)); vm.assume(fee % 1e12 != 0); - vm.expectRevert("fee should be a multiple of FEE_STEP"); + vm.expectRevert(IMidnight.FeeNotMultipleOfFeeStep.selector); midnight.setDefaultTradingFee(loanToken, index, fee); } function testSetObligationTradingFeeObligationNotCreated(bytes32 id) public { - vm.expectRevert("obligation not created"); + vm.expectRevert(IMidnight.ObligationNotCreated.selector); midnight.setObligationTradingFee(id, 0, 0); } function testSetTradingFeeOnlyFeeSetter(address rdm, bytes32 id) public { vm.assume(rdm != address(this)); vm.prank(rdm); - vm.expectRevert("only fee setter"); + vm.expectRevert(IMidnight.OnlyFeeSetter.selector); midnight.setObligationTradingFee(id, 0, 0); } @@ -140,14 +140,14 @@ contract SettersTest is BaseTest { function testSetFeeClaimerOnlyRoleSetter(address rdm) public { vm.assume(rdm != address(this)); vm.prank(rdm); - vm.expectRevert("only role setter"); + vm.expectRevert(IMidnight.OnlyRoleSetter.selector); midnight.setFeeClaimer(makeAddr("newRecipient")); } // Default trading fee tests function testTradingFeeRevertsWhenNotCreated() public { - vm.expectRevert("obligation not created"); + vm.expectRevert(IMidnight.ObligationNotCreated.selector); midnight.tradingFee(bytes32(0), 0); } @@ -207,14 +207,14 @@ contract SettersTest is BaseTest { function testSetDefaultTradingFeeOnlyFeeSetter(address rdm, address loanToken) public { vm.assume(rdm != address(this)); vm.prank(rdm); - vm.expectRevert("only fee setter"); + vm.expectRevert(IMidnight.OnlyFeeSetter.selector); midnight.setDefaultTradingFee(loanToken, 0, 0); } function testSetDefaultTradingFeeValidation(address loanToken, uint256 feeTooHigh, uint256 index) public { index = bound(index, 0, 6); feeTooHigh = bound(feeTooHigh, midnight.maxTradingFee(index) + 1, 1e18); - vm.expectRevert("trading fee too high"); + vm.expectRevert(IMidnight.TradingFeeTooHigh.selector); midnight.setDefaultTradingFee(loanToken, index, feeTooHigh); } @@ -303,11 +303,11 @@ contract SettersTest is BaseTest { bytes32 id = toId(obligation); vm.prank(rdm); - vm.expectRevert("only fee setter"); + vm.expectRevert(IMidnight.OnlyFeeSetter.selector); midnight.setObligationContinuousFee(id, 100); vm.prank(rdm); - vm.expectRevert("only fee setter"); + vm.expectRevert(IMidnight.OnlyFeeSetter.selector); midnight.setDefaultContinuousFee(address(loanToken), 100); } @@ -329,10 +329,10 @@ contract SettersTest is BaseTest { midnight.touchObligation(obligation); bytes32 id = toId(obligation); - vm.expectRevert("continuous fee too high"); + vm.expectRevert(IMidnight.ContinuousFeeTooHigh.selector); midnight.setObligationContinuousFee(id, fee); - vm.expectRevert("continuous fee too high"); + vm.expectRevert(IMidnight.ContinuousFeeTooHigh.selector); midnight.setDefaultContinuousFee(address(loanToken), fee); } diff --git a/test/SignatureTest.sol b/test/SignatureTest.sol index 4ce2e71f4..91e0e3377 100644 --- a/test/SignatureTest.sol +++ b/test/SignatureTest.sol @@ -5,6 +5,7 @@ pragma solidity ^0.8.0; import {Signature, EIP712_DOMAIN_TYPEHASH, ROOT_TYPEHASH} from "../src/interfaces/IEcrecover.sol"; import {Offer} from "../src/interfaces/IMidnight.sol"; import {CALLBACK_SUCCESS} from "../src/libraries/ConstantsLib.sol"; +import {IEcrecoverRatifier} from "../src/ratifiers/interfaces/IEcrecoverRatifier.sol"; import {BaseTest} from "./BaseTest.sol"; contract SignatureTest is BaseTest { @@ -44,7 +45,7 @@ contract SignatureTest is BaseTest { Signature memory badSig; - vm.expectRevert("invalid signature"); + vm.expectRevert(IEcrecoverRatifier.InvalidSignature.selector); ecrecoverRatifier.onRatify(offer, root, abi.encode(badSig)); } } diff --git a/test/TakeTest.sol b/test/TakeTest.sol index eee7db9b7..a7b71f4ce 100644 --- a/test/TakeTest.sol +++ b/test/TakeTest.sol @@ -2,8 +2,9 @@ // Copyright (c) 2025 Morpho Association pragma solidity ^0.8.0; -import {Obligation, Offer, CollateralParams} from "../src/interfaces/IMidnight.sol"; +import {IMidnight, Obligation, Offer, CollateralParams} from "../src/interfaces/IMidnight.sol"; import {Signature, EIP712_DOMAIN_TYPEHASH, ROOT_TYPEHASH} from "../src/ratifiers/EcrecoverRatifier.sol"; +import {IEcrecoverRatifier} from "../src/ratifiers/interfaces/IEcrecoverRatifier.sol"; import {Midnight} from "../src/Midnight.sol"; import {WAD, CALLBACK_SUCCESS, MAX_CONTINUOUS_FEE} from "../src/libraries/ConstantsLib.sol"; import {UtilsLib} from "../src/libraries/UtilsLib.sol"; @@ -11,7 +12,6 @@ import {TickLib, MAX_TICK} from "../src/libraries/TickLib.sol"; import {ICallbacks} from "../src/interfaces/ICallbacks.sol"; import {IRatifier} from "../src/interfaces/IRatifier.sol"; import {IdLib} from "../src/libraries/IdLib.sol"; - import {BaseTest} from "./BaseTest.sol"; import {ERC20} from "./erc20s/ERC20.sol"; import {Oracle} from "./helpers/Oracle.sol"; @@ -322,7 +322,7 @@ contract TakeTest is BaseTest { deal(address(loanToken), lender, units); collateralize(obligation, borrower, units); - vm.expectRevert("seller is liquidatable"); + vm.expectRevert(IMidnight.SellerIsLiquidatable.selector); take(units, lender, borrowerOffer); } @@ -335,7 +335,7 @@ contract TakeTest is BaseTest { deal(address(loanToken), lender, units); collateralize(obligation, borrower, units); - vm.expectRevert("seller is liquidatable"); + vm.expectRevert(IMidnight.SellerIsLiquidatable.selector); take(units, borrower, lenderOffer); } @@ -396,7 +396,7 @@ contract TakeTest is BaseTest { deal(address(loanToken), otherBorrower, units); collateralize(obligation, borrower, units); - vm.expectRevert("seller is liquidatable"); + vm.expectRevert(IMidnight.SellerIsLiquidatable.selector); take(units, otherBorrower, borrowerOffer); } @@ -411,7 +411,7 @@ contract TakeTest is BaseTest { deal(address(loanToken), otherBorrower, units); collateralize(obligation, borrower, units); - vm.expectRevert("seller is liquidatable"); + vm.expectRevert(IMidnight.SellerIsLiquidatable.selector); take(units, borrower, otherBorrowerOffer); } @@ -490,7 +490,7 @@ contract TakeTest is BaseTest { otherBorrowerOffer.maxUnits = exitUnits; otherBorrowerOffer.reduceOnly = true; - vm.expectRevert("maker credit or debt increased"); + vm.expectRevert(IMidnight.MakerCreditOrDebtIncreased.selector); take(exitUnits, borrower, otherBorrowerOffer); } @@ -525,7 +525,7 @@ contract TakeTest is BaseTest { otherLenderOffer.maxUnits = exitUnits; otherLenderOffer.reduceOnly = true; - vm.expectRevert("maker credit or debt increased"); + vm.expectRevert(IMidnight.MakerCreditOrDebtIncreased.selector); take(exitUnits, lender, otherLenderOffer); } @@ -545,7 +545,7 @@ contract TakeTest is BaseTest { take(units, lender, borrowerOffer); - vm.expectRevert("consumed units"); + vm.expectRevert(IMidnight.ConsumedUnits.selector); take(secondRevertingTake, lender, borrowerOffer); take(secondPassingTake, lender, borrowerOffer); @@ -565,7 +565,7 @@ contract TakeTest is BaseTest { take(units, borrower, lenderOffer); - vm.expectRevert("consumed units"); + vm.expectRevert(IMidnight.ConsumedUnits.selector); take(secondRevertingTake, borrower, lenderOffer); take(secondPassingTake, borrower, lenderOffer); @@ -584,7 +584,7 @@ contract TakeTest is BaseTest { take(firstFill, lender, borrowerOffer); - vm.expectRevert("consumed units"); + vm.expectRevert(IMidnight.ConsumedUnits.selector); take(secondFill + 1, lender, borrowerOffer2); take(secondFill, lender, borrowerOffer2); @@ -603,7 +603,7 @@ contract TakeTest is BaseTest { take(firstFill, borrower, lenderOffer); - vm.expectRevert("consumed units"); + vm.expectRevert(IMidnight.ConsumedUnits.selector); take(secondFill + 1, borrower, lenderOffer2); take(secondFill, borrower, lenderOffer2); @@ -673,7 +673,7 @@ contract TakeTest is BaseTest { deal(address(loanToken), lender, 100); collateralize(obligation, borrower, 100); - vm.expectRevert("seller is liquidatable"); + vm.expectRevert(IMidnight.SellerIsLiquidatable.selector); take(100, lender, borrowerOffer); } @@ -686,7 +686,7 @@ contract TakeTest is BaseTest { deal(address(loanToken), lender, 100); collateralize(obligation, borrower, 100); - vm.expectRevert("seller is liquidatable"); + vm.expectRevert(IMidnight.SellerIsLiquidatable.selector); take(100, borrower, lenderOffer); } @@ -700,7 +700,7 @@ contract TakeTest is BaseTest { deal(address(loanToken), lender, units.mulDivUp(price, WAD)); collateralize(obligation, borrower, collateralized); - vm.expectRevert("seller is liquidatable"); + vm.expectRevert(IMidnight.SellerIsLiquidatable.selector); take(units, lender, borrowerOffer); } @@ -714,7 +714,7 @@ contract TakeTest is BaseTest { deal(address(loanToken), lender, units.mulDivDown(price, WAD)); collateralize(obligation, borrower, collateralized); - vm.expectRevert("seller is liquidatable"); + vm.expectRevert(IMidnight.SellerIsLiquidatable.selector); take(units, borrower, lenderOffer); } @@ -722,7 +722,7 @@ contract TakeTest is BaseTest { vm.prank(lender); midnight.shuffleSession(lender); - vm.expectRevert("invalid session"); + vm.expectRevert(IMidnight.InvalidSession.selector); take(100, borrower, lenderOffer); } @@ -730,14 +730,14 @@ contract TakeTest is BaseTest { start = bound(start, block.timestamp + 1, type(uint256).max); Offer memory badOffer = lenderOffer; badOffer.start = start; - vm.expectRevert("offer not started"); + vm.expectRevert(IMidnight.OfferNotStarted.selector); take(0, borrower, badOffer); } function testTakeOfferExpired(uint256 elapsed) public { elapsed = bound(elapsed, 1, type(uint64).max); vm.warp(lenderOffer.expiry + elapsed); - vm.expectRevert("offer expired"); + vm.expectRevert(IMidnight.OfferExpired.selector); take(0, borrower, lenderOffer); } @@ -747,7 +747,7 @@ contract TakeTest is BaseTest { privateKey[taker] = pkey; lenderOffer.maker = taker; - vm.expectRevert("cannot self take"); + vm.expectRevert(IMidnight.SelfTake.selector); take(0, taker, lenderOffer); } @@ -761,7 +761,7 @@ contract TakeTest is BaseTest { lenderOffer.maxUnits = 0; lenderOffer.maxSellerAssets = 1; - vm.expectRevert("consumed seller assets"); + vm.expectRevert(IMidnight.ConsumedSellerAssets.selector); take(units, borrower, lenderOffer); } @@ -786,7 +786,7 @@ contract TakeTest is BaseTest { borrowerOffer.maxUnits = 0; borrowerOffer.maxBuyerAssets = 1; - vm.expectRevert("consumed buyer assets"); + vm.expectRevert(IMidnight.ConsumedBuyerAssets.selector); take(units, lender, borrowerOffer); } @@ -860,7 +860,7 @@ contract TakeTest is BaseTest { lenderOffer.maxBuyerAssets = 1e18; lenderOffer.maxUnits = 0; - vm.expectRevert("multiple max"); + vm.expectRevert(IMidnight.MultipleNonZero.selector); take(units, borrower, lenderOffer); } @@ -872,7 +872,7 @@ contract TakeTest is BaseTest { lenderOffer.maxSellerAssets = 1e18; lenderOffer.maxUnits = 1e18; - vm.expectRevert("multiple max"); + vm.expectRevert(IMidnight.MultipleNonZero.selector); take(units, borrower, lenderOffer); } @@ -885,7 +885,7 @@ contract TakeTest is BaseTest { lenderOffer.maxBuyerAssets = 1e18; lenderOffer.maxUnits = 1e18; - vm.expectRevert("multiple max"); + vm.expectRevert(IMidnight.MultipleNonZero.selector); take(units, borrower, lenderOffer); } @@ -893,7 +893,7 @@ contract TakeTest is BaseTest { function testTakeInvalidRoot(bytes32 invalidRoot) public { vm.assume(invalidRoot != root([lenderOffer])); - vm.expectRevert("invalid proof"); + vm.expectRevert(IMidnight.InvalidProof.selector); vm.prank(borrower); midnight.take( 100, borrower, address(0), hex"", borrower, lenderOffer, sig([lenderOffer]), invalidRoot, new bytes32[](0) @@ -901,7 +901,7 @@ contract TakeTest is BaseTest { } function testTakeInvalidSignature() public { - vm.expectRevert("invalid signature"); + vm.expectRevert(IEcrecoverRatifier.InvalidSignature.selector); Signature memory _sig = Signature({v: 1, r: 0, s: 0}); vm.prank(borrower); midnight.take( @@ -977,7 +977,7 @@ contract TakeTest is BaseTest { function testTakeInvalidPathOneLeaf(bytes32[] memory _path) public { vm.assume(_path.length >= 1); - vm.expectRevert("invalid proof"); + vm.expectRevert(IMidnight.InvalidProof.selector); vm.prank(borrower); midnight.take( 100, borrower, address(0), hex"", borrower, lenderOffer, sig([lenderOffer]), root([lenderOffer]), _path @@ -987,7 +987,7 @@ contract TakeTest is BaseTest { function testTakeInvalidPathTwoLeaves(Offer memory otherOffer, bytes32[] memory _path) public { vm.assume(_path.length >= 1); vm.assume(_path[0] != keccak256(abi.encode(otherOffer))); - vm.expectRevert("invalid proof"); + vm.expectRevert(IMidnight.InvalidProof.selector); vm.prank(borrower); midnight.take( 100, @@ -1079,7 +1079,7 @@ contract TakeTest is BaseTest { vm.prank(vm.addr(makerSecretKey)); midnight.setIsAuthorized(vm.addr(makerSecretKey), address(ecrecoverRatifier), true); - vm.expectRevert("unauthorized"); + vm.expectRevert(IEcrecoverRatifier.Unauthorized.selector); vm.prank(sender); midnight.take( 100, @@ -1138,7 +1138,7 @@ contract TakeTest is BaseTest { vm.prank(maker); midnight.setIsAuthorized(maker, address(ratifier), true); - vm.expectRevert("not ratified"); + vm.expectRevert(IMidnight.RatifierFail.selector); vm.prank(sender); midnight.take( 0, @@ -1158,7 +1158,7 @@ contract TakeTest is BaseTest { vm.assume(taker != sender); vm.assume(!midnight.isAuthorized(taker, sender)); - vm.expectRevert("taker unauthorized"); + vm.expectRevert(IMidnight.TakerUnauthorized.selector); vm.prank(sender); midnight.take( 100, @@ -1295,7 +1295,7 @@ contract TakeTest is BaseTest { ); assertFalse(callback.liquidateSucceeded()); - assertEq(callback.liquidateError(), "not liquidatable"); + assertEq(callback.liquidateErrorSelector(), IMidnight.NotLiquidatable.selector); assertEq(midnight.debtOf(id, borrower), units); assertEq(midnight.collateral(id, borrower, 0), collateral); } @@ -1346,7 +1346,7 @@ contract TakeTest is BaseTest { assertTrue(callback.reentered()); assertFalse(callback.liquidateSucceeded()); - assertEq(callback.liquidateError(), "not liquidatable"); + assertEq(callback.liquidateErrorSelector(), IMidnight.NotLiquidatable.selector); assertTrue(midnight.liquidationLocked(id, borrower) == false); assertEq(midnight.debtOf(id, borrower), 2 * units); assertEq(midnight.collateral(id, borrower, 0), 2 * collateral); @@ -1361,7 +1361,7 @@ contract TakeTest is BaseTest { collateralize(obligation, borrower, units); address callback = address(new InvalidSellCallback()); - vm.expectRevert("invalid callback"); + vm.expectRevert(IMidnight.InvalidSellCallback.selector); vm.prank(borrower); midnight.take( units, @@ -1489,7 +1489,7 @@ contract TakeTest is BaseTest { Signature memory badSig; - vm.expectRevert("ratifier unauthorized"); + vm.expectRevert(IMidnight.RatifierUnauthorized.selector); vm.prank(borrower); midnight.take( units, @@ -1514,7 +1514,7 @@ contract TakeTest is BaseTest { deal(address(loanToken), callback, assets); collateralize(obligation, borrower, units); - vm.expectRevert("invalid callback"); + vm.expectRevert(IMidnight.InvalidBuyCallback.selector); vm.prank(lender); midnight.take( units, @@ -1554,7 +1554,7 @@ contract TakeTest is BaseTest { deal(address(loanToken), lender, units.mulDivUp(price, WAD)); collateralize(longObligation, borrower, units); - vm.expectRevert("buyer pendingFee exceeds credit"); + vm.expectRevert(IMidnight.BuyerPendingFeeExceedsCredit.selector); vm.prank(lender); midnight.take(units, lender, address(0), hex"", lender, bOffer, sig([bOffer]), root([bOffer]), proof([bOffer])); } @@ -1615,8 +1615,7 @@ contract BorrowCallback is ICallbacks { contract ReentrantLiquidateBorrowCallback is ICallbacks { bool public liquidateSucceeded; - string public liquidateError; - bytes public liquidateRevertData; + bytes4 public liquidateErrorSelector; function onSell(bytes32 id, Obligation memory obligation, address seller, uint256, uint256, bytes memory data) external @@ -1637,10 +1636,9 @@ contract ReentrantLiquidateBorrowCallback is ICallbacks { uint256, uint256 ) { liquidateSucceeded = true; - } catch Error(string memory reason) { - liquidateError = reason; } catch (bytes memory revertData) { - liquidateRevertData = revertData; + // forge-lint: disable-next-line(unsafe-typecast) + liquidateErrorSelector = bytes4(revertData); } oracle.setPrice(healthyPrice); return CALLBACK_SUCCESS; @@ -1662,7 +1660,7 @@ contract ReentrantLiquidateBorrowCallback is ICallbacks { contract NestedTakeReentrantLiquidateCallback is ICallbacks { bool public reentered; bool public liquidateSucceeded; - string public liquidateError; + bytes4 public liquidateErrorSelector; Offer internal storedOffer; bytes internal storedSig; @@ -1718,8 +1716,9 @@ contract NestedTakeReentrantLiquidateCallback is ICallbacks { uint256, uint256 ) { liquidateSucceeded = true; - } catch Error(string memory reason) { - liquidateError = reason; + } catch (bytes memory revertData) { + // forge-lint: disable-next-line(unsafe-typecast) + liquidateErrorSelector = bytes4(revertData); } oracle.setPrice(healthyPrice); } diff --git a/test/TickLibTest.sol b/test/TickLibTest.sol index cdf9aa1e0..6650d26b4 100644 --- a/test/TickLibTest.sol +++ b/test/TickLibTest.sol @@ -55,7 +55,7 @@ contract TickLibTest is BaseTest { /// forge-config: default.allow_internal_expect_revert = true function testTickToPriceOutOfRange(uint256 tick) public { tick = bound(tick, MAX_TICK + 1, type(uint256).max); - vm.expectRevert("tick out of range"); + vm.expectRevert(TickLib.TickOutOfRange.selector); TickLib.tickToPrice(tick); } @@ -64,7 +64,7 @@ contract TickLibTest is BaseTest { /// forge-config: default.allow_internal_expect_revert = true function testPriceToTickGreaterThanOne(uint256 price) public { price = bound(price, 1 ether + 1, type(uint256).max); - vm.expectRevert("Price is greater than one"); + vm.expectRevert(TickLib.PriceGreaterThanOne.selector); TickLib.priceToTick(price); } diff --git a/test/TradingFeeTest.sol b/test/TradingFeeTest.sol index 4211349cf..bddbe5cbd 100644 --- a/test/TradingFeeTest.sol +++ b/test/TradingFeeTest.sol @@ -5,7 +5,7 @@ pragma solidity ^0.8.0; import {WAD} from "../src/libraries/ConstantsLib.sol"; import {UtilsLib} from "../src/libraries/UtilsLib.sol"; import {TickLib, MAX_TICK} from "../src/libraries/TickLib.sol"; -import {Obligation, Offer, CollateralParams} from "../src/interfaces/IMidnight.sol"; +import {IMidnight, Obligation, Offer, CollateralParams} from "../src/interfaces/IMidnight.sol"; import {BaseTest, MAX_TEST_AMOUNT} from "./BaseTest.sol"; @@ -202,7 +202,7 @@ contract TradingFeeTest is BaseTest { collateralize(obligation, borrower, MAX_DEBT); - vm.expectRevert("seller is liquidatable"); + vm.expectRevert(IMidnight.SellerIsLiquidatable.selector); take(units, lender, borrowerOffer); } @@ -261,7 +261,7 @@ contract TradingFeeTest is BaseTest { function testClaimTradingFeeOnlyFeeClaimer(address caller) public { vm.assume(caller != feeClaimer); vm.prank(caller); - vm.expectRevert("only fee claimer"); + vm.expectRevert(IMidnight.OnlyFeeClaimer.selector); midnight.claimTradingFee(address(loanToken), 0, caller); } diff --git a/test/UtilsLibTest.sol b/test/UtilsLibTest.sol index f13725283..c27eef467 100644 --- a/test/UtilsLibTest.sol +++ b/test/UtilsLibTest.sol @@ -103,7 +103,7 @@ contract UtilsLibTest is Test { /// forge-config: default.allow_internal_expect_revert = true function testToUint128Overflow(uint256 x) public { x = bound(x, uint256(type(uint128).max) + 1, type(uint256).max); - vm.expectRevert("uint256 overflows uint128"); + vm.expectRevert(UtilsLib.CastOverflow.selector); UtilsLib.toUint128(x); } From 55c4758623b8bdd7e32a1acd3024055df763eab0 Mon Sep 17 00:00:00 2001 From: MathisGD <74971347+MathisGD@users.noreply.github.com> Date: Thu, 16 Apr 2026 10:46:40 +0200 Subject: [PATCH 16/33] cvl context (#698) Signed-off-by: MathisGD <74971347+MathisGD@users.noreply.github.com> Co-authored-by: Quentin Garchery --- AGENTS.md | 76 +++++++++++++++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 74 insertions(+), 2 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 853ecd1c2..38ef9ebf1 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,5 +1,74 @@ ## Review guidelines +### CVL Context + +This repo uses the Certora Prover (CVL) to formally verify Solidity contracts. +The primer below describes how CVL actually behaves — read it before reasoning about `.spec` files, because CVL semantics differ in subtle ways from Solidity and from what an LLM might assume by default. + +#### Rules, invariants, `satisfy` + +- A `rule` passes iff every `assert` holds on every execution path that satisfies all preceding `require`s. A counterexample is a concrete trace (env, args, method choice) that satisfies the `require`s and falsifies an `assert`. +- A rule can be *vacuous*: if the `require`s exclude every path, it passes trivially. Vacuity is silent unless a sanity rule is run. Basic sanity is run by default. +- `satisfy p` is the dual of `assert`: the Prover must find at least one feasible path where `p` holds. A rule with only `satisfy` and no `assert` therefore does not check any universal property. +- CVL has both *weak* and *strong* invariants. A weak invariant is proven by induction over methods: base case checks it after the constructor; inductive step assumes it, runs an arbitrary method `f`, then asserts it again. By default invariants are checked only for `public`/`external` non-`view`/non-`pure` methods. A strong invariant assumes the invariant before any unresolved external calls, havoc the state and assert the invariant after the call. There is no temporal reasoning beyond this. +- A `preserved` block is a code block, that notably allows to inject extra `require`s into the inductive step for one method. Those assumptions are *not* checked by default, but they could be checked in other rules/invariants. Unsound `preserved` is a common source of fake invariant proofs. +- `requireInvariant J` assumes another invariant `J` at the start of the rule/invariant. It's sound only because `J` was itself proven by the same induction scheme. +- A *parametric rule* (one with a `method f` parameter) is expanded into one sub-rule per method in scope. A `filtered { f -> ... }` clause drops methods from that expansion; filtered-out methods are simply not checked. + +#### Reverts and path pruning + +- By default, a call `f(e, args)` **only explores non-reverting paths**. Reverting executions are silently pruned from the rule. This is a semantic choice, not an optimization. +- `f@withrevert(e, args)` explores both reverting and non-reverting paths. After it, the builtin `lastReverted` is true on the revert branches and false otherwise. +- Without `@withrevert`, `lastReverted` after a call is always `false` (since the revert paths were pruned). Testing `lastReverted` after a plain call is meaningless. +- Non-persistent ghosts are rolled back on revert; persistent ghosts are not. + +#### Methods, env, calldataarg + +- `env e` captures block number, timestamp, `msg.sender`, `msg.value`, etc. The Prover quantifies over all `env`s consistent with active `require`s. +- `calldataarg args` is an opaque bundle of arguments. You cannot inspect or constrain its fields; it only exists to call a `method f` parametrically. +- `f(e, args)` targets `currentContract` by default. Call other contracts via `other.f(e, args)` where `other` is declared with `using OtherContract as other;`. +- Addresses drawn nondeterministically may coincide with known contracts unless explicitly constrained (`require addr != currentContract`, etc.). The Prover does *not* assume addresses are distinct. +- `envfree` tells the Prover a method doesn't read `env`; such calls omit the `env` argument. The Prover statically checks this. + +#### Ghosts, hooks, havoc + +- A ghost is an SMT variable (possibly a function `uint → uint`, etc.), not contract state. It exists only in the spec and can be updated by hooks or CVL assignments. +- On an *unresolved* external call, the Prover havocs all non-persistent ghosts (assumes they take any value consistent with their axioms). `persistent ghost` declarations survive havoc. +- Hooks fire on EVM-level events: `Sload`, `Sstore`, `CALL`, `REVERT`, etc. They match by storage slot / selector / opcode. Signature or layout drift silently disables a hook — it does not error. Hooks are not triggered by CVL code, including CVL access to Solidity storage, and hooks are not recursive. +- Inside an `Sstore` hook, the bound names conventionally written as old/new values refer to the pre-write and post-write values at that slot. +- A two-state ghost function can be referenced as `g@old` / `g@new` inside `havoc g assuming ...`, letting you specify how the ghost changes across a havoc (e.g. `havoc g assuming g@new(x) == g@old(x) + 1`). +- `axiom P` constrains the ghost in every state the Prover considers — adding an unsatisfiable axiom makes every rule vacuously pass. `init_state axiom P` only constrains the ghost in the base case of invariant induction, which is almost always what you want for "starts at zero"-style facts. +- Ghost axioms may refer only to the ghost itself and quantified variables — not to Solidity or CVL functions. + +#### Summaries and dispatch + +- The `methods { ... }` block declares how external calls are resolved. Exact-signature entries beat wildcard entries (`function _.foo() ...`). +- `AUTO` (the default for unresolved calls): view/pure methods are summarized as NONDET, other external calls are summarized as HAVOC_ECF (see below). +- `DISPATCHER(true)` / `DISPATCHER(false)`: on an unresolved interface call, the Prover considers every known contract with that selector. `true` is *optimistic* (only known impls are considered, assumed to be the full set); `false` is pessimistic (assumes an unknown impl could also exist). DISPATCHER does not apply to library calls. +- `HAVOC_ALL` erases all contract state plus the return value — the maximally conservative summary. `HAVOC_ECF` (externally-controlled footprint) only havocs state that non-reentrant external callees could touch. +- `NONDET` returns an unconstrained value and does not touch storage. For stateful methods it is unsound unless combined with a havoc. +- A CVL function summary `=> cvlFn(args)` substitutes the CVL function body for the call. The Prover never looks at the Solidity body, so the summary must match the contract's semantics or proofs are unsound. +- `DELETE` (e.g. `HAVOC_ALL DELETE`) additionally removes the callee's code from the scene, preventing path explosion from modeling its body. + +#### Types and casts + +- `mathint` is unbounded signed integer; arithmetic never overflows. `uint256` / `int256` wrap modulo 2^n. Mixing them requires an explicit cast. +- `to_mathint(x)` widens a machine int to `mathint` (always safe). `assert_uint256(m)` converts back and fails the current rule if `m` doesn't fit. `require_uint256(m)` converts back and *assumes* it fits (which can introduce vacuity if it never does). Arithmetic operations in CVL automatically convert the result to mathint, so in most cases `to_mathint` is not needed and redundant. +- `storage` is a first-class type. `storage s = lastStorage;` snapshots the full state; `f(e, args) at s` re-executes from that snapshot. `s1 == s2` compares every slot of every contract in scope — it's correct but expensive. +- Direct field access on storage (`currentContract.x.y`) works for primitive/static-layout fields but not for dynamic arrays, `bytes`, or `string`. + +#### Quantifiers + +- `forall type v. e` / `exists type v. e` are first-class in CVL. The solver reasons about them via standard SMT quantifier handling; nested quantifiers and quantifiers over large domains drive solver blow-up. +- Quantified expressions cannot call contract functions — they can only reference ghosts, storage, and CVL-pure expressions. + +#### Multi-contract setup + +- `currentContract` is the main verified contract (set in the `.conf`). Other contracts are linked by `using Foo as foo;`. By default, invariants and parametric rules range over methods of all contracts in the scene; `--parametric_contracts` narrows that set. +- Inside hooks, `executingContract` can differ from `currentContract` when a hook fires during a sub-call. + +### What to focus on + Flag notably these issues: - Typos, broken links, and inconsistent formatting - State not being fully reconstructible through events @@ -10,7 +79,10 @@ Flag notably these issues: - Readability issues Don't flag breaking changes, the code is not in production and will be immutable. - Flag all issues, including those that were not introduced in this commit or pull request. - Spend some time on P2 and P3 issues too. + +### Output + +Try to be as concise as possible in your output. +When relevant, include in your comment directly the fix you would apply (as a suggestion markdown block). From 67c55639974cf3bc2c358e0d28558b30de47b730 Mon Sep 17 00:00:00 2001 From: Adrien Husson Date: Thu, 16 Apr 2026 11:43:19 +0200 Subject: [PATCH 17/33] update position returns more values (#690) Signed-off-by: MathisGD <74971347+MathisGD@users.noreply.github.com> Co-authored-by: MathisGD <74971347+MathisGD@users.noreply.github.com> Co-authored-by: MathisGD --- certora/specs/UpdateBeforeCredit.spec | 8 +++++-- src/Midnight.sol | 13 ++++++++--- src/interfaces/IMidnight.sol | 2 +- test/ContinuousFeeTest.sol | 33 +++++++++++++++++++++++++++ 4 files changed, 50 insertions(+), 6 deletions(-) diff --git a/certora/specs/UpdateBeforeCredit.spec b/certora/specs/UpdateBeforeCredit.spec index 71ae8ce0b..85a8b9328 100644 --- a/certora/specs/UpdateBeforeCredit.spec +++ b/certora/specs/UpdateBeforeCredit.spec @@ -17,7 +17,7 @@ methods { function _.onRatify(Midnight.Offer, bytes32, bytes) external => NONDET; // Summarize _updatePosition so that its credit reads/writes do not fire the hooks below. - function _updatePosition(Midnight.Obligation memory, bytes32 id, address user) internal => summaryUpdatePosition(id, user); + function _updatePosition(Midnight.Obligation memory, bytes32 id, address user) internal returns (uint128, uint128, uint128) => summaryUpdatePosition(id, user); function hasCredit(bytes32 id, address user) internal returns (bool) => summaryHasCredit(id, user); } @@ -36,8 +36,12 @@ persistent ghost mapping(bytes32 => mapping(address => bool)) creditLoadedBefore /// Summary for _updatePosition: just sets the updated ghost flag. /// The original function body is replaced, so its internal credit reads/writes do not fire hooks. -function summaryUpdatePosition(bytes32 id, address user) { +function summaryUpdatePosition(bytes32 id, address user) returns (uint128, uint128, uint128) { updated[id][user] = true; + uint128 newCredit; + uint128 newPendingFee; + uint128 accruedFee; + return (newCredit, newPendingFee, accruedFee); } /// Summary for hasCredit: circumvent the load hook for credit checks. diff --git a/src/Midnight.sol b/src/Midnight.sol index 566b08002..78c1e4ead 100644 --- a/src/Midnight.sol +++ b/src/Midnight.sol @@ -723,15 +723,20 @@ contract Midnight is IMidnight { } /// @dev Slashes the position and accrues the continuous fee. - function updatePosition(Obligation memory obligation, address user) external { + /// @dev Returns the new credit, new pending fee, and accrued fee after having updated the position. + function updatePosition(Obligation memory obligation, address user) external returns (uint128, uint128, uint128) { bytes32 id = toId(obligation); require(obligationState[id].created, ObligationNotCreated()); - _updatePosition(obligation, id, user); + return _updatePosition(obligation, id, user); } /// @dev Expects the obligation to be touched. /// @dev Expects the id to correspond to the obligation's id. - function _updatePosition(Obligation memory obligation, bytes32 id, address user) internal { + /// @dev Returns the new credit, new pending fee, and accrued fee after having updated the position. + function _updatePosition(Obligation memory obligation, bytes32 id, address user) + internal + returns (uint128, uint128, uint128) + { Position storage _position = position[id][user]; (uint128 newCredit, uint128 newPendingFee, uint128 accruedFee) = updatePositionView(obligation, id, user); @@ -745,6 +750,8 @@ contract Midnight is IMidnight { obligationState[id].continuousFeeCredit += UtilsLib.toUint128(accruedFee); emit EventsLib.UpdatePosition(id, user, creditDecrease, pendingFeeDecrease, accruedFee); + + return (newCredit, newPendingFee, accruedFee); } function hasCredit(bytes32 id, address user) internal view returns (bool) { diff --git a/src/interfaces/IMidnight.sol b/src/interfaces/IMidnight.sol index 3142311b2..6f13ae3a8 100644 --- a/src/interfaces/IMidnight.sol +++ b/src/interfaces/IMidnight.sol @@ -149,7 +149,7 @@ interface IMidnight { /// SLASHING AND CONTINUOUS FEE ACCRUAL /// function updatePositionView(Obligation memory obligation, bytes32 id, address user) external view returns (uint128, uint128, uint128); - function updatePosition(Obligation memory obligation, address user) external; + function updatePosition(Obligation memory obligation, address user) external returns (uint128, uint128, uint128); /// OTHER VIEW FUNCTIONS /// function userLossIndex(bytes32 id, address user) external view returns (uint128); diff --git a/test/ContinuousFeeTest.sol b/test/ContinuousFeeTest.sol index b073b161c..2fefb5cdf 100644 --- a/test/ContinuousFeeTest.sol +++ b/test/ContinuousFeeTest.sol @@ -488,6 +488,39 @@ contract ContinuousFeeTest is BaseTest { assertEq(midnight.pendingFee(id, lender), newPendingFee, "view matches pendingFee"); } + function testUpdatePositionReturnsUpdatedValues( + uint256 credit, + uint256 feeRate, + uint256 ttm, + uint256 elapsed, + bool withBadDebt + ) public { + credit = bound(credit, 100, MAX_CREDIT); + feeRate = bound(feeRate, 1, MAX_CONTINUOUS_FEE); + ttm = bound(ttm, 10, 360 days); + elapsed = bound(elapsed, 1, ttm - 1); + + setupLender(credit, feeRate, ttm); + + if (withBadDebt) createBadDebt(obligation); + + vm.warp(block.timestamp + elapsed); + + (uint128 expectedCredit, uint128 expectedPendingFee, uint128 expectedAccruedFee) = + midnight.updatePositionView(obligation, id, lender); + uint256 expectedContinuousFeeCredit = midnight.continuousFeeCredit(id) + expectedAccruedFee; + + (uint128 returnedCredit, uint128 returnedPendingFee, uint128 returnedAccruedFee) = + midnight.updatePosition(obligation, lender); + + assertEq(returnedCredit, expectedCredit, "returned credit"); + assertEq(returnedPendingFee, expectedPendingFee, "returned pendingFee"); + assertEq(returnedAccruedFee, expectedAccruedFee, "returned accruedFee"); + assertEq(midnight.creditOf(id, lender), returnedCredit, "stored credit"); + assertEq(midnight.pendingFee(id, lender), returnedPendingFee, "stored pendingFee"); + assertEq(midnight.continuousFeeCredit(id), expectedContinuousFeeCredit, "continuousFeeCredit"); + } + function testUpdatePositionRevertsIfObligationNotCreated() public { vm.expectRevert(IMidnight.ObligationNotCreated.selector); midnight.updatePosition(obligation, borrower); From 9dc82f162157924befc5d8665e4c62e9e4cf549e Mon Sep 17 00:00:00 2001 From: Adrien Husson Date: Thu, 16 Apr 2026 11:43:49 +0200 Subject: [PATCH 18/33] use IMidnight interface (#691) Signed-off-by: MathisGD <74971347+MathisGD@users.noreply.github.com> Co-authored-by: MathisGD <74971347+MathisGD@users.noreply.github.com> Co-authored-by: MathisGD --- src/periphery/TakeAmountsLib.sol | 13 +-- src/periphery/TakeBundler.sol | 105 +++++++++++----------- src/periphery/interfaces/ITakeBundler.sol | 7 +- test/BundlerTest.sol | 26 ++++-- test/TakeAmountsTest.sol | 8 +- 5 files changed, 86 insertions(+), 73 deletions(-) diff --git a/src/periphery/TakeAmountsLib.sol b/src/periphery/TakeAmountsLib.sol index ad6fc8b64..f5722cde9 100644 --- a/src/periphery/TakeAmountsLib.sol +++ b/src/periphery/TakeAmountsLib.sol @@ -1,8 +1,7 @@ // SPDX-License-Identifier: GPL-2.0-or-later pragma solidity ^0.8.0; -import {Midnight} from "../Midnight.sol"; -import {Offer} from "../interfaces/IMidnight.sol"; +import {IMidnight, Offer} from "../interfaces/IMidnight.sol"; import {UtilsLib} from "../libraries/UtilsLib.sol"; import {TickLib} from "../libraries/TickLib.sol"; import {WAD} from "../libraries/ConstantsLib.sol"; @@ -13,13 +12,14 @@ library TakeAmountsLib { // Forward: buyerAssets = offer.buy ? units.mulDivDown(buyerPrice, WAD) : units.mulDivUp(buyerPrice, WAD). /// @dev Reverts if buyerPrice > WAD, because not all buyerAssets are reachable then. /// @dev Returns the number of units to take to get the target buyer assets. - function buyerAssetsToUnits(Midnight midnight, bytes32 id, Offer memory offer, uint256 targetBuyerAssets) + function buyerAssetsToUnits(address midnight, bytes32 id, Offer memory offer, uint256 targetBuyerAssets) internal view returns (uint256) { uint256 offerPrice = TickLib.tickToPrice(offer.tick); - uint256 tradingFee = midnight.tradingFee(id, UtilsLib.zeroFloorSub(offer.obligation.maturity, block.timestamp)); + uint256 tradingFee = + IMidnight(midnight).tradingFee(id, UtilsLib.zeroFloorSub(offer.obligation.maturity, block.timestamp)); uint256 buyerPrice = offer.buy ? offerPrice : offerPrice + tradingFee; require(buyerPrice <= WAD, TickLib.PriceGreaterThanOne()); return offer.buy ? targetBuyerAssets.mulDivUp(WAD, buyerPrice) : targetBuyerAssets.mulDivDown(WAD, buyerPrice); @@ -27,13 +27,14 @@ library TakeAmountsLib { // Forward: sellerAssets = offer.buy ? units.mulDivDown(sellerPrice, WAD) : units.mulDivUp(sellerPrice, WAD). /// @dev Returns the number of units to take to get the target seller assets. - function sellerAssetsToUnits(Midnight midnight, bytes32 id, Offer memory offer, uint256 targetSellerAssets) + function sellerAssetsToUnits(address midnight, bytes32 id, Offer memory offer, uint256 targetSellerAssets) internal view returns (uint256) { uint256 offerPrice = TickLib.tickToPrice(offer.tick); - uint256 tradingFee = midnight.tradingFee(id, UtilsLib.zeroFloorSub(offer.obligation.maturity, block.timestamp)); + uint256 tradingFee = + IMidnight(midnight).tradingFee(id, UtilsLib.zeroFloorSub(offer.obligation.maturity, block.timestamp)); uint256 sellerPrice = offer.buy ? offerPrice - tradingFee : offerPrice; return offer.buy ? targetSellerAssets.mulDivUp(WAD, sellerPrice) : targetSellerAssets.mulDivDown(WAD, sellerPrice); diff --git a/src/periphery/TakeBundler.sol b/src/periphery/TakeBundler.sol index 332ff19ca..eb2dceab3 100644 --- a/src/periphery/TakeBundler.sol +++ b/src/periphery/TakeBundler.sol @@ -2,7 +2,7 @@ // Copyright (c) 2025 Morpho Association pragma solidity 0.8.34; -import {Midnight} from "../Midnight.sol"; +import {IMidnight} from "../interfaces/IMidnight.sol"; import {ITakeBundler, Take} from "./interfaces/ITakeBundler.sol"; import {UtilsLib} from "../libraries/UtilsLib.sol"; import {TakeAmountsLib} from "./TakeAmountsLib.sol"; @@ -16,7 +16,7 @@ contract TakeBundler is ITakeBundler { /// @dev The bundler skips every reason why `take` can revert (including ones that are not asynchrony related). /// @dev If taking an offer reverts, the bundler will completely skip this offer. function bundleTakeUnits( - Midnight midnight, + address midnight, uint256 targetUnits, address taker, address receiverIfTakerIsSeller, @@ -26,23 +26,24 @@ contract TakeBundler is ITakeBundler { uint256 minSellerAssets, uint256 maxSellerAssets ) external { - require(taker == msg.sender || midnight.isAuthorized(taker, msg.sender), Unauthorized()); + require(taker == msg.sender || IMidnight(midnight).isAuthorized(taker, msg.sender), Unauthorized()); uint256 totalFilledUnits; uint256 totalBuyerAssets; uint256 totalSellerAssets; for (uint256 i; i < takes.length && totalFilledUnits < targetUnits; i++) { - try midnight.take( - UtilsLib.min(targetUnits - totalFilledUnits, takes[i].units), - taker, - address(0), - "", - receiverIfTakerIsSeller, - takes[i].offer, - takes[i].sig, - takes[i].root, - takes[i].proof - ) returns ( + try IMidnight(midnight) + .take( + UtilsLib.min(targetUnits - totalFilledUnits, takes[i].units), + taker, + address(0), + "", + receiverIfTakerIsSeller, + takes[i].offer, + takes[i].sig, + takes[i].root, + takes[i].proof + ) returns ( uint256 filledBuyerAssets, uint256 filledSellerAssets, uint256 filledUnits ) { totalFilledUnits += filledUnits; @@ -64,7 +65,7 @@ contract TakeBundler is ITakeBundler { /// tradingFee) are not caught by the try/catch and will abort the bundle. /// @dev Requires a non-empty takes array. function bundleTakeBuyerAssets( - Midnight midnight, + address midnight, uint256 targetBuyerAssets, address taker, address receiverIfTakerIsSeller, @@ -72,28 +73,30 @@ contract TakeBundler is ITakeBundler { uint256 minUnits, uint256 maxUnits ) external { - require(taker == msg.sender || midnight.isAuthorized(taker, msg.sender), Unauthorized()); - bytes32 id = midnight.touchObligation(takes[0].offer.obligation); // to have the correct trading fees. + require(taker == msg.sender || IMidnight(midnight).isAuthorized(taker, msg.sender), Unauthorized()); + bytes32 id = IMidnight(midnight).touchObligation(takes[0].offer.obligation); // to have the correct trading + // fees. uint256 totalFilledBuyerAssets; uint256 totalUnits; for (uint256 i; i < takes.length && totalFilledBuyerAssets < targetBuyerAssets; i++) { - try midnight.take( - UtilsLib.min( - TakeAmountsLib.buyerAssetsToUnits( - midnight, id, takes[i].offer, targetBuyerAssets - totalFilledBuyerAssets + try IMidnight(midnight) + .take( + UtilsLib.min( + TakeAmountsLib.buyerAssetsToUnits( + midnight, id, takes[i].offer, targetBuyerAssets - totalFilledBuyerAssets + ), + takes[i].units ), - takes[i].units - ), - taker, - address(0), - "", - receiverIfTakerIsSeller, - takes[i].offer, - takes[i].sig, - takes[i].root, - takes[i].proof - ) returns ( + taker, + address(0), + "", + receiverIfTakerIsSeller, + takes[i].offer, + takes[i].sig, + takes[i].root, + takes[i].proof + ) returns ( uint256 filledBuyerAssets, uint256, uint256 filledUnits ) { totalFilledBuyerAssets += filledBuyerAssets; @@ -111,7 +114,7 @@ contract TakeBundler is ITakeBundler { /// tradingFee) are not caught by the try/catch and will abort the bundle. /// @dev Requires a non-empty takes array. function bundleTakeSellerAssets( - Midnight midnight, + address midnight, uint256 targetSellerAssets, address taker, address receiverIfTakerIsSeller, @@ -119,28 +122,30 @@ contract TakeBundler is ITakeBundler { uint256 minUnits, uint256 maxUnits ) external { - require(taker == msg.sender || midnight.isAuthorized(taker, msg.sender), Unauthorized()); - bytes32 id = midnight.touchObligation(takes[0].offer.obligation); // to have the correct trading fees. + require(taker == msg.sender || IMidnight(midnight).isAuthorized(taker, msg.sender), Unauthorized()); + bytes32 id = IMidnight(midnight).touchObligation(takes[0].offer.obligation); // to have the correct trading + // fees. uint256 totalFilledSellerAssets; uint256 totalUnits; for (uint256 i; i < takes.length && totalFilledSellerAssets < targetSellerAssets; i++) { - try midnight.take( - UtilsLib.min( - TakeAmountsLib.sellerAssetsToUnits( - midnight, id, takes[i].offer, targetSellerAssets - totalFilledSellerAssets + try IMidnight(midnight) + .take( + UtilsLib.min( + TakeAmountsLib.sellerAssetsToUnits( + midnight, id, takes[i].offer, targetSellerAssets - totalFilledSellerAssets + ), + takes[i].units ), - takes[i].units - ), - taker, - address(0), - "", - receiverIfTakerIsSeller, - takes[i].offer, - takes[i].sig, - takes[i].root, - takes[i].proof - ) returns ( + taker, + address(0), + "", + receiverIfTakerIsSeller, + takes[i].offer, + takes[i].sig, + takes[i].root, + takes[i].proof + ) returns ( uint256, uint256 filledSellerAssets, uint256 filledUnits ) { totalFilledSellerAssets += filledSellerAssets; diff --git a/src/periphery/interfaces/ITakeBundler.sol b/src/periphery/interfaces/ITakeBundler.sol index f892995e7..c28696f83 100644 --- a/src/periphery/interfaces/ITakeBundler.sol +++ b/src/periphery/interfaces/ITakeBundler.sol @@ -2,7 +2,6 @@ // Copyright (c) 2025 Morpho Association pragma solidity >=0.5.0; -import {Midnight} from "../../Midnight.sol"; import {Offer} from "../../interfaces/IMidnight.sol"; struct Take { @@ -26,8 +25,8 @@ interface ITakeBundler { // forgefmt: disable-start /// FUNCTIONS /// - function bundleTakeUnits(Midnight midnight, uint256 targetUnits, address taker, address receiverIfTakerIsSeller, Take[] calldata takes, uint256 minBuyerAssets, uint256 maxBuyerAssets, uint256 minSellerAssets, uint256 maxSellerAssets) external; - function bundleTakeBuyerAssets(Midnight midnight, uint256 targetBuyerAssets, address taker, address receiverIfTakerIsSeller, Take[] calldata takes, uint256 minUnits, uint256 maxUnits) external; - function bundleTakeSellerAssets(Midnight midnight, uint256 targetSellerAssets, address taker, address receiverIfTakerIsSeller, Take[] calldata takes, uint256 minUnits, uint256 maxUnits) external; + function bundleTakeUnits(address midnight, uint256 targetUnits, address taker, address receiverIfTakerIsSeller, Take[] calldata takes, uint256 minBuyerAssets, uint256 maxBuyerAssets, uint256 minSellerAssets, uint256 maxSellerAssets) external; + function bundleTakeBuyerAssets(address midnight, uint256 targetBuyerAssets, address taker, address receiverIfTakerIsSeller, Take[] calldata takes, uint256 minUnits, uint256 maxUnits) external; + function bundleTakeSellerAssets(address midnight, uint256 targetSellerAssets, address taker, address receiverIfTakerIsSeller, Take[] calldata takes, uint256 minUnits, uint256 maxUnits) external; // forgefmt: disable-end } diff --git a/test/BundlerTest.sol b/test/BundlerTest.sol index 222a4b526..b7d0e0d25 100644 --- a/test/BundlerTest.sol +++ b/test/BundlerTest.sol @@ -91,7 +91,7 @@ contract BundlerTest is BaseTest { vm.prank(address(0xdead)); vm.expectRevert(ITakeBundler.Unauthorized.selector); takeBundler.bundleTakeUnits( - midnight, 100, borrower, address(0), takes, 0, type(uint256).max, 0, type(uint256).max + address(midnight), 100, borrower, address(0), takes, 0, type(uint256).max, 0, type(uint256).max ); } @@ -124,7 +124,7 @@ contract BundlerTest is BaseTest { if (offerUnits1 >= units - fromOffer0) { vm.prank(borrower); takeBundler.bundleTakeUnits( - midnight, units, borrower, borrower, takes, 0, type(uint256).max, 0, type(uint256).max + address(midnight), units, borrower, borrower, takes, 0, type(uint256).max, 0, type(uint256).max ); uint256 consumed0 = midnight.consumed(offers[0].maker, offers[0].group); @@ -136,7 +136,7 @@ contract BundlerTest is BaseTest { vm.prank(borrower); vm.expectRevert(ITakeBundler.InsufficientLiquidity.selector); takeBundler.bundleTakeUnits( - midnight, units, borrower, borrower, takes, 0, type(uint256).max, 0, type(uint256).max + address(midnight), units, borrower, borrower, takes, 0, type(uint256).max, 0, type(uint256).max ); } } @@ -174,7 +174,7 @@ contract BundlerTest is BaseTest { if (offerUnits1 >= units - fromOffer0) { vm.prank(borrower); takeBundler.bundleTakeBuyerAssets( - midnight, targetBuyerAssets, borrower, borrower, takes, 0, type(uint256).max + address(midnight), targetBuyerAssets, borrower, borrower, takes, 0, type(uint256).max ); uint256 consumed0 = midnight.consumed(offers[0].maker, offers[0].group); @@ -186,7 +186,7 @@ contract BundlerTest is BaseTest { vm.prank(borrower); vm.expectRevert(ITakeBundler.InsufficientLiquidity.selector); takeBundler.bundleTakeBuyerAssets( - midnight, targetBuyerAssets, borrower, borrower, takes, 0, type(uint256).max + address(midnight), targetBuyerAssets, borrower, borrower, takes, 0, type(uint256).max ); } } @@ -232,7 +232,7 @@ contract BundlerTest is BaseTest { if (offerUnits1 >= neededFromOffer1) { vm.prank(borrower); takeBundler.bundleTakeSellerAssets( - midnight, targetSellerAssets, borrower, borrower, takes, 0, type(uint256).max + address(midnight), targetSellerAssets, borrower, borrower, takes, 0, type(uint256).max ); uint256 consumed0 = midnight.consumed(offers[0].maker, offers[0].group); @@ -244,7 +244,7 @@ contract BundlerTest is BaseTest { vm.prank(borrower); vm.expectRevert(ITakeBundler.InsufficientLiquidity.selector); takeBundler.bundleTakeSellerAssets( - midnight, targetSellerAssets, borrower, borrower, takes, 0, type(uint256).max + address(midnight), targetSellerAssets, borrower, borrower, takes, 0, type(uint256).max ); } } @@ -319,7 +319,7 @@ contract BundlerTest is BaseTest { vm.prank(borrower); vm.expectRevert(ITakeBundler.BuyerAssetsAboveMax.selector); takeBundler.bundleTakeUnits( - midnight, targetUnits, borrower, borrower, takes, 0, maxBuyerAssets, 0, type(uint256).max + address(midnight), targetUnits, borrower, borrower, takes, 0, maxBuyerAssets, 0, type(uint256).max ); } @@ -369,7 +369,15 @@ contract BundlerTest is BaseTest { vm.prank(borrower); vm.expectRevert(ITakeBundler.BuyerAssetsBelowMin.selector); takeBundler.bundleTakeUnits( - midnight, targetUnits, borrower, borrower, takes, minBuyerAssets, type(uint256).max, 0, type(uint256).max + address(midnight), + targetUnits, + borrower, + borrower, + takes, + minBuyerAssets, + type(uint256).max, + 0, + type(uint256).max ); } } diff --git a/test/TakeAmountsTest.sol b/test/TakeAmountsTest.sol index 6b2c7f14d..71c83c657 100644 --- a/test/TakeAmountsTest.sol +++ b/test/TakeAmountsTest.sol @@ -93,7 +93,7 @@ contract TakeAmountsTest is BaseTest { tick = bound(tick, 1, _maxTick(tradingFee)); offer.tick = tick; - uint256 units = TakeAmountsLib.buyerAssetsToUnits(midnight, id, offer, targetBuyerAssets); + uint256 units = TakeAmountsLib.buyerAssetsToUnits(address(midnight), id, offer, targetBuyerAssets); deal(address(loanToken), lender, type(uint256).max); collateralize(obligation, borrower, units); offer.maker = borrower; @@ -112,7 +112,7 @@ contract TakeAmountsTest is BaseTest { tick = bound(tick, 1, _maxTick(tradingFee)); offer.tick = tick; - uint256 units = TakeAmountsLib.sellerAssetsToUnits(midnight, id, offer, targetSellerAssets); + uint256 units = TakeAmountsLib.sellerAssetsToUnits(address(midnight), id, offer, targetSellerAssets); deal(address(loanToken), lender, type(uint256).max); collateralize(obligation, borrower, units); offer.maker = borrower; @@ -137,7 +137,7 @@ contract TakeAmountsTest is BaseTest { offer.maker = lender; offer.receiverIfMakerIsSeller = lender; offer.tick = tick; - uint256 units = TakeAmountsLib.buyerAssetsToUnits(midnight, id, offer, targetBuyerAssets); + uint256 units = TakeAmountsLib.buyerAssetsToUnits(address(midnight), id, offer, targetBuyerAssets); deal(address(loanToken), borrower, type(uint256).max); (uint256 buyerAssets,,) = take(units, borrower, offer); @@ -160,7 +160,7 @@ contract TakeAmountsTest is BaseTest { offer.maker = lender; offer.receiverIfMakerIsSeller = lender; offer.tick = tick; - uint256 units = TakeAmountsLib.sellerAssetsToUnits(midnight, id, offer, targetSellerAssets); + uint256 units = TakeAmountsLib.sellerAssetsToUnits(address(midnight), id, offer, targetSellerAssets); deal(address(loanToken), borrower, type(uint256).max); (, uint256 sellerAssets,) = take(units, borrower, offer); From 5be0183e9c6bcfdc37368c4e4f194d0b59db25fa Mon Sep 17 00:00:00 2001 From: Quentin Garchery Date: Thu, 16 Apr 2026 14:32:36 +0200 Subject: [PATCH 19/33] Small tweaks (#703) --- .github/workflows/forge.yml | 4 ++-- certora/helpers/FlashLiquidateCallback.sol | 6 +++--- certora/helpers/Utils.sol | 1 - src/Midnight.sol | 1 + src/interfaces/IMidnight.sol | 2 +- src/libraries/TickLib.sol | 3 +-- 6 files changed, 8 insertions(+), 9 deletions(-) diff --git a/.github/workflows/forge.yml b/.github/workflows/forge.yml index e22d804f2..ee5dfecf0 100644 --- a/.github/workflows/forge.yml +++ b/.github/workflows/forge.yml @@ -22,7 +22,7 @@ jobs: version: nightly-1e342ef7d296a674ea248e95ed12c152f7e00ee6 - name: Check formatting - run: forge fmt --check + run: forge fmt src test script certora --check lint: runs-on: ubuntu-latest @@ -37,7 +37,7 @@ jobs: version: nightly-1e342ef7d296a674ea248e95ed12c152f7e00ee6 - name: Run forge lint - run: forge lint --deny notes + run: forge lint src test script certora --deny notes sizes: runs-on: ubuntu-latest diff --git a/certora/helpers/FlashLiquidateCallback.sol b/certora/helpers/FlashLiquidateCallback.sol index ead3047c4..bfd7c04e8 100644 --- a/certora/helpers/FlashLiquidateCallback.sol +++ b/certora/helpers/FlashLiquidateCallback.sol @@ -20,10 +20,10 @@ contract FlashLiquidateCallback { function onLiquidate( bytes32, Obligation memory obligation, - uint256 collateralIndex, - uint256 seizedAssets, + uint256, + uint256, uint256 repaidUnits, - address borrower, + address, bytes memory data ) external { startFlashloan(obligation.loanToken, repaidUnits); diff --git a/certora/helpers/Utils.sol b/certora/helpers/Utils.sol index 1c5848ffa..94b9ed340 100644 --- a/certora/helpers/Utils.sol +++ b/certora/helpers/Utils.sol @@ -3,7 +3,6 @@ pragma solidity ^0.8.0; import {Obligation} from "../../src/interfaces/IMidnight.sol"; -import {IdLib} from "../../src/libraries/IdLib.sol"; import {UtilsLib} from "../../src/libraries/UtilsLib.sol"; contract Utils { diff --git a/src/Midnight.sol b/src/Midnight.sol index 78c1e4ead..3e4973694 100644 --- a/src/Midnight.sol +++ b/src/Midnight.sol @@ -504,6 +504,7 @@ contract Midnight is IMidnight { /// equivalent to repaidUnits <= (debtOf-maxDebt) / (1 - LIF*LLTV). /// @dev If an account is healthy, the LIF grows linearly from 1 at maturity to maxLif(lltv) at maturity + /// TIME_TO_MAX_LIF. + /// @dev Passing both 0 for `seizedAssets` and `repaidUnits` allows to realize bad debt with 0 token transferred. /// @dev Returns the seized assets and the repaid units. function liquidate( Obligation calldata obligation, diff --git a/src/interfaces/IMidnight.sol b/src/interfaces/IMidnight.sol index 6f13ae3a8..9d8579ab2 100644 --- a/src/interfaces/IMidnight.sol +++ b/src/interfaces/IMidnight.sol @@ -116,9 +116,9 @@ interface IMidnight { function defaultTradingFees(address loanToken, uint256 index) external view returns (uint16); function defaultContinuousFee(address loanToken) external view returns (uint32); function claimableTradingFee(address token) external view returns (uint256); - function feeClaimer() external view returns (address); function roleSetter() external view returns (address); function feeSetter() external view returns (address); + function feeClaimer() external view returns (address); /// MULTICALL /// function multicall(bytes[] calldata calls) external; diff --git a/src/libraries/TickLib.sol b/src/libraries/TickLib.sol index a3dcc8ee5..b36c264cd 100644 --- a/src/libraries/TickLib.sol +++ b/src/libraries/TickLib.sol @@ -11,8 +11,7 @@ library TickLib { error PriceGreaterThanOne(); error TickOutOfRange(); - /// @dev Returns (`x` + `d` - 1) / `d` rounded to the nearest integer with ties rounded down, without checking for - /// overflow. + /// @dev Returns `x` / `d` rounded to the nearest integer with ties rounded down, without checking for overflow. function divHalfDownUnchecked(uint256 x, uint256 d) internal pure returns (uint256) { unchecked { return (x + (d - 1) / 2) / d; From 681e3f6d5d87e9ebc50d93a4c7a648e9fa6e38a4 Mon Sep 17 00:00:00 2001 From: Quentin Garchery Date: Thu, 16 Apr 2026 15:18:10 +0200 Subject: [PATCH 20/33] [Certora] Refactor division by zero (#702) Co-authored-by: MathisGD <74971347+MathisGD@users.noreply.github.com> --- certora/specs/ExactMath.spec | 8 +--- certora/specs/MulDiv.spec | 11 ++++++ certora/specs/NoDivisionByZero.spec | 58 +++++++++++++---------------- 3 files changed, 38 insertions(+), 39 deletions(-) diff --git a/certora/specs/ExactMath.spec b/certora/specs/ExactMath.spec index bf2a941cc..571d79506 100644 --- a/certora/specs/ExactMath.spec +++ b/certora/specs/ExactMath.spec @@ -16,13 +16,9 @@ rule lifTimesLltvIsLessThanOrEqualToOne(uint256 lltv, uint256 cursor) { assert lltv * maxLif(lltv, cursor) <= WAD() * WAD(); } -/// @dev maxLif >= WAD. Used in NoDivisionByZero.spec (assumption 4) to prove that the nested -/// mulDivDown divisor in maxLif is positive, without assuming it. -/// Proof: maxLif = WAD^2 / (WAD - cursor*(WAD-lltv)/WAD). The denominator <= WAD (since -/// cursor*(WAD-lltv)/WAD >= 0), so the result >= WAD^2/WAD = WAD. +/// @dev maxLif >= WAD. Used in NoDivisionByZero.spec to prove that the nested mulDivDown divisor in maxLif is positive, without assuming it. +/// Proof: maxLif = WAD^2 / (WAD - cursor*(WAD-lltv)/WAD) and the denominator is less than WAD because the subtractions are checked to not underflow in solidity. rule maxLifIsAtLeastWad(uint256 lltv, uint256 cursor) { - require lltv <= WAD(), "see rule createdObligationsHaveLltvLessThanOrEqualToOne"; - require cursor < WAD(), "see the definition of LIQUIDATION_CURSOR_LOW and LIQUIDATION_CURSOR_HIGH"; assert maxLif(lltv, cursor) >= WAD(); } diff --git a/certora/specs/MulDiv.spec b/certora/specs/MulDiv.spec index 4e571c7e4..b2b93ab88 100644 --- a/certora/specs/MulDiv.spec +++ b/certora/specs/MulDiv.spec @@ -48,3 +48,14 @@ rule mulDivInverseUpDown(uint256 a, uint256 b, uint256 d) { rule mulDivLifLLTV(uint256 a, uint256 lif, uint256 lltv, uint256 WAD) { assert lltv * lif <= WAD * WAD => mulDivUp(a, lltv, WAD) <= mulDivUp(a, WAD, lif); } + +rule mulDivArgumentLesserThanDenominator(uint256 a, uint256 b, uint256 d) { + assert a <= d => mulDivDown(a, b, d) <= b; + assert a <= d => mulDivUp(a, b, d) <= b; + assert b <= d => mulDivDown(a, b, d) <= a; + assert b <= d => mulDivUp(a, b, d) <= a; +} + +rule mulDivUpUpperBound(uint256 a, uint256 b, uint256 d) { + assert mulDivUp(a, b, d) * d <= a * b + d - 1; +} diff --git a/certora/specs/NoDivisionByZero.spec b/certora/specs/NoDivisionByZero.spec index 8e4bde805..77502247b 100644 --- a/certora/specs/NoDivisionByZero.spec +++ b/certora/specs/NoDivisionByZero.spec @@ -2,42 +2,38 @@ // Proves that no division by zero occurs in mulDivDown or mulDivUp. // -// All other Solidity divisions in the codebase use constant denominators: +// All other Solidity divisions in the codebase use non-zero denominators: // - tradingFee: divides by (end - start), always a positive constant from the breakpoint table. // - setObligationTradingFee / setDefaultTradingFee: divide by FEE_STEP (1e12). // - liquidate: divides by TIME_TO_MAX_LIF (15 minutes = 900). -// Therefore, only mulDivDown and mulDivUp can have variable denominators. -// -// maxLif(uint256, uint256) is excluded: it is a pure function callable with arbitrary inputs. -// A standalone call with cursor >= WAD causes a safe revert (Solidity checked arithmetic). -// -// The liquidate function is verified in a separate rule (noDivisionByZeroLiquidate). -// The toId summary follows the approach from PR #388: a ghost-backed deterministic function. +// - tickToPrice: divides by 5e12 or a value greater than 1e18. +// - wExp, used in tickToPrice: divides by non-zero constants. +// Therefore, we only look for division by zero in mulDivDown and mulDivUp in this file. import "BitmapSummaries.spec"; methods { function multicall(bytes[]) external => HAVOC_ALL DELETE; + // Ghost price function so that the price can be referenced in the rules. function _.price() external => ghostPrice(calledContract) expect(uint256); + // Summary for deterministic toId for the global obligation. function IdLib.toId(Midnight.Obligation memory obligation, uint256 chainId, address midnight) internal returns (bytes32) => summaryToId(obligation, chainId, midnight); + // Those functions are checked manually to not cause a division by zero. function UtilsLib.isLeaf(bytes32, bytes32, bytes32[] memory) internal returns (bool) => NONDET; function TickLib.tickToPrice(uint256) internal returns (uint256) => NONDET; - function TickLib.wExp(int256) internal returns (uint256) => NONDET; + // Hook on mulDivDown and mulDivUp to check that the denominator is not zero, and add the necessary lemmas. function UtilsLib.mulDivDown(uint256 x, uint256 y, uint256 d) internal returns (uint256) => mulDivDownSummary(x, y, d); function UtilsLib.mulDivUp(uint256 x, uint256 y, uint256 d) internal returns (uint256) => mulDivUpSummary(x, y, d); } /// GHOSTS /// -persistent ghost bool divisionByZero { - init_state axiom !divisionByZero; -} +// Reuse part of the setup of Healthiness.spec. -// Global obligation ghosts for deterministic toId (approach from PR #388). persistent ghost address globalObligationLoanToken; persistent ghost uint256 globalObligationCollateralLength; @@ -76,7 +72,7 @@ hook Sload uint128 value position[KEY bytes32 id][KEY address user].lossIndex { ghost ghostPrice(address) returns uint256; -definition WAD() returns uint256 = 1000000000000000000; +definition WAD() returns uint256 = 10 ^ 18; definition collateralMatches(Midnight.Obligation obligation, uint256 index) returns bool = (index < globalObligationCollateralLength => obligation.collateralParams[index].oracle == globalObligationCollateralOracle[index] && obligation.collateralParams[index].token == globalObligationCollateralToken[index] && obligation.collateralParams[index].lltv == globalObligationCollateralLLTV[index] && obligation.collateralParams[index].maxLif == globalObligationCollateralMaxLif[index]); @@ -95,50 +91,46 @@ function summaryToId(Midnight.Obligation obligation, uint256 chainId, address mo } function mulDivDownSummary(uint256 x, uint256 y, uint256 d) returns uint256 { - if (d == 0) { - divisionByZero = true; - } + assert d > 0; + uint256 result; - require d == 0 || to_mathint(result) * to_mathint(d) <= to_mathint(x) * to_mathint(y); - require d == 0 || y > d || result <= x; - require d == 0 || x > d || result <= y; + require y <= d => result <= x, "see mulDivArgumentLesserThanDenominator in MulDiv.spec"; + require x <= d => result <= y, "see mulDivArgumentLesserThanDenominator in MulDiv.spec"; return result; } function mulDivUpSummary(uint256 x, uint256 y, uint256 d) returns uint256 { - if (d == 0) { - divisionByZero = true; - } + assert d > 0; + uint256 result; - require d == 0 || to_mathint(result) * to_mathint(d) <= to_mathint(x) * to_mathint(y) + to_mathint(d) - 1; - require d == 0 || y > d || result <= x; - require d == 0 || x > d || result <= y; + require result * d <= x * y + d - 1, "see mulDivUpUpperBound in MulDiv.spec"; + require y <= d => result <= x, "see mulDivArgumentLesserThanDenominator in MulDiv.spec"; + require x <= d => result <= y, "see mulDivArgumentLesserThanDenominator in MulDiv.spec"; return result; } /// RULES /// +// The liquidate function is verified in a separate rule (noDivisionByZeroLiquidate). +// The maxLif function is excluded: it is a pure function callable with arbitrary inputs. rule noDivisionByZero(method f, env e, calldataarg args) filtered { f -> f.selector != sig:maxLif(uint256, uint256).selector && f.selector != sig:liquidate(Midnight.Obligation, uint256, uint256, uint256, address, bytes).selector } { - require !divisionByZero; f(e, args); - assert !divisionByZero, "division by zero detected in mulDivDown or mulDivUp"; + assert true; } +// Show that liquidate does not cause a division by zero, in case the oracle price is non-zero and the collateral is active. rule noDivisionByZeroLiquidate(env e, Midnight.Obligation obligation, uint256 collateralIndex, uint256 seizedAssets, uint256 repaidUnits, address borrower, bytes data) { require equalsGlobalObligation(obligation); - // Sound: touchObligation enforces maxLif >= WAD for all collateralParams (ExactMath.spec). // Needed for the bitmap loop which calls mulDivUp(WAD, maxLif) for every activated collateral. - require forall uint256 i. i < obligation.collateralParams.length => obligation.collateralParams[i].maxLif >= WAD(); + require forall uint256 i. i < obligation.collateralParams.length => obligation.collateralParams[i].maxLif >= WAD(), "see maxLifIsAtLeastWad in ExactMath.spec"; - // Sound: ExactMath.spec proves maxLif * lltv <= WAD * (WAD - 1) when lltv < WAD (lifTimesLltvStrictBound). require obligation.collateralParams[collateralIndex].lltv < WAD() => to_mathint(obligation.collateralParams[collateralIndex].maxLif) * to_mathint(obligation.collateralParams[collateralIndex].lltv) <= to_mathint(WAD()) * (to_mathint(WAD()) - 1), "see lifTimesLltvStrictBound in ExactMath.spec"; // Assume that the collateral price is non-zero and the collateral is active. Otherwise, liquidate may revert with div by zero. require ghostPrice(obligation.collateralParams[collateralIndex].oracle) > 0, "Assumption: the collateral price is not zero"; require summaryGetBit(currentContract.position[globalId][borrower].activatedCollaterals, collateralIndex), "Assumption: liquidated collateral was activated"; - require !divisionByZero; liquidate(e, obligation, collateralIndex, seizedAssets, repaidUnits, borrower, data); - assert !divisionByZero, "division by zero detected in mulDivDown or mulDivUp"; + assert true; } From 8c1fafdf746e11506f2ca4ff97e244a76ae66984 Mon Sep 17 00:00:00 2001 From: MathisGD <74971347+MathisGD@users.noreply.github.com> Date: Thu, 16 Apr 2026 15:21:42 +0200 Subject: [PATCH 21/33] test with an old version of foundry (#696) --- .github/workflows/forge-old.yml | 65 +++++++++++++++++++++++++++++++++ 1 file changed, 65 insertions(+) create mode 100644 .github/workflows/forge-old.yml diff --git a/.github/workflows/forge-old.yml b/.github/workflows/forge-old.yml new file mode 100644 index 000000000..bf4f89544 --- /dev/null +++ b/.github/workflows/forge-old.yml @@ -0,0 +1,65 @@ +name: forge-old + +on: + push: + branches: + - main + pull_request: + workflow_dispatch: + +jobs: + compare-bytecode: + runs-on: ubuntu-latest + steps: + - name: Checkout repository + uses: actions/checkout@v4 + with: + submodules: recursive + - name: Install Foundry (old) + uses: foundry-rs/foundry-toolchain@v1 + with: + version: v1.5.1 + - name: Verify forge executable hash + env: + EXPECTED_FORGE_SHA256: a39fc1913b53dde6a35b603756f65d799e13817ef694107ec0d3704c20435cce + run: | + forge_path="$(which forge)" + echo "forge path: $forge_path" + actual="$(sha256sum "$forge_path" | awk '{print $1}')" + echo "actual: $actual" + echo "expected: $EXPECTED_FORGE_SHA256" + [ "$actual" = "$EXPECTED_FORGE_SHA256" ] || { echo "forge hash mismatch"; exit 1; } + - name: Install solc 0.8.34 into svm cache + run: | + mkdir -p "$HOME/.svm/0.8.34" + curl -fsSL -o "$HOME/.svm/0.8.34/solc-0.8.34" \ + https://github.com/ethereum/solidity/releases/download/v0.8.34/solc-static-linux + chmod +x "$HOME/.svm/0.8.34/solc-0.8.34" + "$HOME/.svm/0.8.34/solc-0.8.34" --version + - name: Build with Foundry (old) + run: forge build --force + - name: Export bytecode manifest (old) + run: | + mkdir -p compare + find out -name '*.json' -print0 | sort -z | while IFS= read -r -d '' file; do + rel="${file#out/}" + jq -r --arg rel "$rel" '[$rel, (.bytecode.object // ""), (.deployedBytecode.object // "")] | @tsv' "$file" + done > compare/old.tsv + [ -s compare/old.tsv ] || { echo "old manifest is empty"; exit 1; } + - name: Run Forge tests + run: forge test + - name: Install Foundry (latest nightly) + uses: foundry-rs/foundry-toolchain@v1 + with: + version: nightly + - name: Build with Foundry (latest nightly) + run: forge build --force --out out-new --cache-path cache-new + - name: Export bytecode manifest (new) + run: | + find out-new -name '*.json' -print0 | sort -z | while IFS= read -r -d '' file; do + rel="${file#out-new/}" + jq -r --arg rel "$rel" '[$rel, (.bytecode.object // ""), (.deployedBytecode.object // "")] | @tsv' "$file" + done > compare/new.tsv + [ -s compare/new.tsv ] || { echo "new manifest is empty"; exit 1; } + - name: Compare bytecode + run: diff -u compare/old.tsv compare/new.tsv From fc99601871b99af2a4ff5fdcbbb5706b3e19f436 Mon Sep 17 00:00:00 2001 From: MathisGD <74971347+MathisGD@users.noreply.github.com> Date: Thu, 16 Apr 2026 16:17:09 +0200 Subject: [PATCH 22/33] minor naming changes (#697) Co-authored-by: Adrien Husson --- README.md | 4 +- certora/specs/CollateralBitmap.spec | 2 +- certora/specs/Healthiness.spec | 6 +-- certora/specs/NoDivisionByZero.spec | 4 +- src/interfaces/IRatifier.sol | 2 +- src/periphery/TakeBundler.sol | 6 +-- src/periphery/interfaces/ITakeBundler.sol | 2 +- src/ratifiers/EcrecoverRatifier.sol | 4 +- test/AuthorizationTest.sol | 8 ++- test/BaseTest.sol | 12 +++-- test/BundlerTest.sol | 26 +++++---- test/EcrecoverRatifierTest.sol | 26 ++++----- test/TakeTest.sol | 66 ++++++++++++++--------- 13 files changed, 98 insertions(+), 70 deletions(-) diff --git a/README.md b/README.md index 404f5f24b..fdd123600 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ -# Morpho Midnight +# Midnight -Morpho Midnight is a fixed-rate lending protocol based on zero-coupon obligations. +Midnight is a fixed-rate lending protocol based on zero-coupon obligations. ## Whitepaper diff --git a/certora/specs/CollateralBitmap.spec b/certora/specs/CollateralBitmap.spec index e0e6a2a56..35cd6aff7 100644 --- a/certora/specs/CollateralBitmap.spec +++ b/certora/specs/CollateralBitmap.spec @@ -15,7 +15,7 @@ methods { */ function _.price() external => PER_CALLEE_CONSTANT; function TickLib.tickToPrice(uint256 tick) internal returns (uint256) => NONDET; - function IdLib.toId(Midnight.Obligation memory obligation, uint256 chainId, address morpho) internal returns (bytes32) => NONDET; + function IdLib.toId(Midnight.Obligation memory obligation, uint256 chainId, address midnight) internal returns (bytes32) => NONDET; /* Simplify mulDiv reasoning for the solver. We summarize these by ghost functions, i.e., * arbitrary deterministic functions and axiomatize the axioms we need. diff --git a/certora/specs/Healthiness.spec b/certora/specs/Healthiness.spec index c9c91c669..579342802 100644 --- a/certora/specs/Healthiness.spec +++ b/certora/specs/Healthiness.spec @@ -17,7 +17,7 @@ methods { */ function _.price() external => summaryPrice(calledContract) expect(uint256); function TickLib.tickToPrice(uint256 tick) internal returns (uint256) => NONDET; - function IdLib.toId(Midnight.Obligation memory obligation, uint256 chainId, address morpho) internal returns (bytes32) => summaryToId(obligation, chainId, morpho); + function IdLib.toId(Midnight.Obligation memory obligation, uint256 chainId, address midnight) internal returns (bytes32) => summaryToId(obligation, chainId, midnight); /* Summarize mulDivDown and mulDivUp to simplify the verification task. * Use a ghost function that ensures mulDivDown/Up behaves deterministically and @@ -136,9 +136,9 @@ function getGlobalObligation() returns (Midnight.Obligation) { return obligation; } -function summaryToId(Midnight.Obligation obligation, uint256 chainId, address morpho) returns (bytes32) { +function summaryToId(Midnight.Obligation obligation, uint256 chainId, address midnight) returns (bytes32) { bytes32 id; - if (equalsGlobalObligation(obligation) && morpho == currentContract) { + if (equalsGlobalObligation(obligation) && midnight == currentContract) { require id == globalId, "toId() is deterministic"; } else { require id != globalId, "toId() is injective"; diff --git a/certora/specs/NoDivisionByZero.spec b/certora/specs/NoDivisionByZero.spec index 77502247b..d98ae3bcb 100644 --- a/certora/specs/NoDivisionByZero.spec +++ b/certora/specs/NoDivisionByZero.spec @@ -80,9 +80,9 @@ function equalsGlobalObligation(Midnight.Obligation obligation) returns (bool) { return obligation.loanToken == globalObligationLoanToken && obligation.collateralParams.length == globalObligationCollateralLength && collateralMatches(obligation, 0) && collateralMatches(obligation, 1) && collateralMatches(obligation, 2) && obligation.maturity == globalObligationMaturity && obligation.rcfThreshold == globalObligationRcfThreshold && obligation.enterGate == globalObligationEnterGate && obligation.liquidatorGate == globalObligationLiquidatorGate; } -function summaryToId(Midnight.Obligation obligation, uint256 chainId, address morpho) returns (bytes32) { +function summaryToId(Midnight.Obligation obligation, uint256 chainId, address midnight) returns (bytes32) { bytes32 id; - if (equalsGlobalObligation(obligation) && morpho == currentContract) { + if (equalsGlobalObligation(obligation) && midnight == currentContract) { require id == globalId, "toId() is deterministic"; } else { require id != globalId, "toId() is injective"; diff --git a/src/interfaces/IRatifier.sol b/src/interfaces/IRatifier.sol index 32253bc78..c3cfea0f1 100644 --- a/src/interfaces/IRatifier.sol +++ b/src/interfaces/IRatifier.sol @@ -5,5 +5,5 @@ pragma solidity >=0.5.0; import {Offer} from "./IMidnight.sol"; interface IRatifier { - function onRatify(Offer memory offer, bytes32 root, bytes memory data) external returns (bytes32); + function onRatify(Offer memory offer, bytes32 root, bytes memory ratifierData) external returns (bytes32); } diff --git a/src/periphery/TakeBundler.sol b/src/periphery/TakeBundler.sol index eb2dceab3..77025d890 100644 --- a/src/periphery/TakeBundler.sol +++ b/src/periphery/TakeBundler.sol @@ -40,7 +40,7 @@ contract TakeBundler is ITakeBundler { "", receiverIfTakerIsSeller, takes[i].offer, - takes[i].sig, + takes[i].ratifierData, takes[i].root, takes[i].proof ) returns ( @@ -93,7 +93,7 @@ contract TakeBundler is ITakeBundler { "", receiverIfTakerIsSeller, takes[i].offer, - takes[i].sig, + takes[i].ratifierData, takes[i].root, takes[i].proof ) returns ( @@ -142,7 +142,7 @@ contract TakeBundler is ITakeBundler { "", receiverIfTakerIsSeller, takes[i].offer, - takes[i].sig, + takes[i].ratifierData, takes[i].root, takes[i].proof ) returns ( diff --git a/src/periphery/interfaces/ITakeBundler.sol b/src/periphery/interfaces/ITakeBundler.sol index c28696f83..f699228fa 100644 --- a/src/periphery/interfaces/ITakeBundler.sol +++ b/src/periphery/interfaces/ITakeBundler.sol @@ -7,7 +7,7 @@ import {Offer} from "../../interfaces/IMidnight.sol"; struct Take { uint256 units; Offer offer; - bytes sig; + bytes ratifierData; bytes32 root; bytes32[] proof; } diff --git a/src/ratifiers/EcrecoverRatifier.sol b/src/ratifiers/EcrecoverRatifier.sol index 24f4423f3..31bd0ecee 100644 --- a/src/ratifiers/EcrecoverRatifier.sol +++ b/src/ratifiers/EcrecoverRatifier.sol @@ -14,8 +14,8 @@ contract EcrecoverRatifier is IEcrecoverRatifier { MIDNIGHT = _midnight; } - function onRatify(Offer memory offer, bytes32 root, bytes memory data) external view returns (bytes32) { - Signature memory sig = abi.decode(data, (Signature)); + function onRatify(Offer memory offer, bytes32 root, bytes memory ratifierData) external view returns (bytes32) { + Signature memory sig = abi.decode(ratifierData, (Signature)); bytes32 structHash = keccak256(abi.encode(ROOT_TYPEHASH, root)); bytes32 domainSeparator = keccak256(abi.encode(EIP712_DOMAIN_TYPEHASH, block.chainid, address(this))); bytes32 digest = keccak256(bytes.concat("\x19\x01", domainSeparator, structHash)); diff --git a/test/AuthorizationTest.sol b/test/AuthorizationTest.sol index 244dfed18..1e94a1552 100644 --- a/test/AuthorizationTest.sol +++ b/test/AuthorizationTest.sol @@ -214,7 +214,9 @@ contract AuthorizationTest is BaseTest { address attacker = makeAddr("attacker"); vm.prank(attacker); vm.expectRevert(IMidnight.TakerUnauthorized.selector); - midnight.take(units, taker, address(0), hex"", address(0), offer, sig([offer]), root([offer]), proof([offer])); + midnight.take( + units, taker, address(0), hex"", address(0), offer, ratifierData([offer]), root([offer]), proof([offer]) + ); } function testTakeAuthorized() public { @@ -240,7 +242,9 @@ contract AuthorizationTest is BaseTest { // Operator can take on behalf of taker vm.prank(operator); - midnight.take(units, taker, address(0), hex"", taker, offer, sig([offer]), root([offer]), proof([offer])); + midnight.take( + units, taker, address(0), hex"", taker, offer, ratifierData([offer]), root([offer]), proof([offer]) + ); assertEq(midnight.debtOf(id, taker), units); } diff --git a/test/BaseTest.sol b/test/BaseTest.sol index d3202defa..aeab71ee6 100644 --- a/test/BaseTest.sol +++ b/test/BaseTest.sol @@ -144,7 +144,9 @@ abstract contract BaseTest is Test { // receiverIfTakerIsSeller param is for taker (when offer.buy == true) // offer.receiverIfMakerIsSeller is for maker (when offer.buy == false) vm.prank(taker); - return midnight.take(units, taker, address(0), hex"", taker, offer, sig([offer]), root([offer]), proof([offer])); + return midnight.take( + units, taker, address(0), hex"", taker, offer, ratifierData([offer]), root([offer]), proof([offer]) + ); } function setupOtherUsers(Obligation memory obligation, uint256 units) internal { @@ -217,7 +219,7 @@ abstract contract BaseTest is Test { return IdLib.toId(obligation, block.chainid, address(midnight)); } - function sig(Offer[1] memory offers, address _signer) internal view returns (bytes memory) { + function ratifierData(Offer[1] memory offers, address _signer) internal view returns (bytes memory) { return abi.encode(signature(root(offers), privateKey[_signer], offers[0].ratifier)); } @@ -260,12 +262,12 @@ abstract contract BaseTest is Test { return _signature; } - function sig(Offer[1] memory offers) internal view returns (bytes memory) { + function ratifierData(Offer[1] memory offers) internal view returns (bytes memory) { bytes32 _root = root(offers); return abi.encode(signature(_root, privateKey[offers[0].maker], offers[0].ratifier)); } - function sig(Offer[2] memory offers) internal view returns (bytes memory) { + function ratifierData(Offer[2] memory offers) internal view returns (bytes memory) { bytes32 _root = root(offers); return abi.encode(signature(_root, privateKey[offers[0].maker], offers[0].ratifier)); } @@ -328,7 +330,7 @@ abstract contract BaseTest is Test { hex"", borrower, borrowerOffer, - sig([borrowerOffer]), + ratifierData([borrowerOffer]), root([borrowerOffer]), proof([borrowerOffer]) ); diff --git a/test/BundlerTest.sol b/test/BundlerTest.sol index b7d0e0d25..7e4ddbc49 100644 --- a/test/BundlerTest.sol +++ b/test/BundlerTest.sol @@ -85,7 +85,11 @@ contract BundlerTest is BaseTest { function testUnauthorized() public { Take[] memory takes = new Take[](1); takes[0] = Take({ - offer: offers[0], units: 100, sig: sig([offers[0]]), root: root([offers[0]]), proof: proof([offers[0]]) + offer: offers[0], + units: 100, + ratifierData: ratifierData([offers[0]]), + root: root([offers[0]]), + proof: proof([offers[0]]) }); vm.prank(address(0xdead)); @@ -107,14 +111,14 @@ contract BundlerTest is BaseTest { takes[0] = Take({ offer: offers[0], units: offerUnits0, - sig: sig([offers[0]]), + ratifierData: ratifierData([offers[0]]), root: root([offers[0]]), proof: proof([offers[0]]) }); takes[1] = Take({ offer: offers[1], units: offerUnits1, - sig: sig([offers[1]]), + ratifierData: ratifierData([offers[1]]), root: root([offers[1]]), proof: proof([offers[1]]) }); @@ -157,14 +161,14 @@ contract BundlerTest is BaseTest { takes[0] = Take({ offer: offers[0], units: offerUnits0, - sig: sig([offers[0]]), + ratifierData: ratifierData([offers[0]]), root: root([offers[0]]), proof: proof([offers[0]]) }); takes[1] = Take({ offer: offers[1], units: offerUnits1, - sig: sig([offers[1]]), + ratifierData: ratifierData([offers[1]]), root: root([offers[1]]), proof: proof([offers[1]]) }); @@ -209,14 +213,14 @@ contract BundlerTest is BaseTest { takes[0] = Take({ offer: offers[0], units: offerUnits0, - sig: sig([offers[0]]), + ratifierData: ratifierData([offers[0]]), root: root([offers[0]]), proof: proof([offers[0]]) }); takes[1] = Take({ offer: offers[1], units: offerUnits1, - sig: sig([offers[1]]), + ratifierData: ratifierData([offers[1]]), root: root([offers[1]]), proof: proof([offers[1]]) }); @@ -302,14 +306,14 @@ contract BundlerTest is BaseTest { takes[0] = Take({ offer: offers[0], units: offerUnits0, - sig: sig([offers[0]]), + ratifierData: ratifierData([offers[0]]), root: root([offers[0]]), proof: proof([offers[0]]) }); takes[1] = Take({ offer: offers[1], units: offerUnits1, - sig: sig([offers[1]]), + ratifierData: ratifierData([offers[1]]), root: root([offers[1]]), proof: proof([offers[1]]) }); @@ -352,14 +356,14 @@ contract BundlerTest is BaseTest { takes[0] = Take({ offer: offers[0], units: offerUnits0, - sig: sig([offers[0]]), + ratifierData: ratifierData([offers[0]]), root: root([offers[0]]), proof: proof([offers[0]]) }); takes[1] = Take({ offer: offers[1], units: offerUnits1, - sig: sig([offers[1]]), + ratifierData: ratifierData([offers[1]]), root: root([offers[1]]), proof: proof([offers[1]]) }); diff --git a/test/EcrecoverRatifierTest.sol b/test/EcrecoverRatifierTest.sol index 26cbc00ed..a61727c99 100644 --- a/test/EcrecoverRatifierTest.sol +++ b/test/EcrecoverRatifierTest.sol @@ -27,9 +27,9 @@ contract EcrecoverRatifierTest is BaseTest { function testOnRatifyMakerSigns() public view { Offer memory offer = makeOffer(lender); bytes32 _root = keccak256(abi.encode(offer)); - bytes memory data = signRoot(_root, lender); + bytes memory ratifierData = signRoot(_root, lender); - bytes32 result = ecrecoverRatifier.onRatify(offer, _root, data); + bytes32 result = ecrecoverRatifier.onRatify(offer, _root, ratifierData); assertEq(result, CALLBACK_SUCCESS); } @@ -40,38 +40,38 @@ contract EcrecoverRatifierTest is BaseTest { vm.prank(lender); midnight.setIsAuthorized(lender, borrower, true); - bytes memory data = signRoot(_root, borrower); + bytes memory ratifierData = signRoot(_root, borrower); - bytes32 result = ecrecoverRatifier.onRatify(offer, _root, data); + bytes32 result = ecrecoverRatifier.onRatify(offer, _root, ratifierData); assertEq(result, CALLBACK_SUCCESS); } function testOnRatifyUnauthorizedSigner() public { Offer memory offer = makeOffer(lender); bytes32 _root = keccak256(abi.encode(offer)); - bytes memory data = signRoot(_root, borrower); + bytes memory ratifierData = signRoot(_root, borrower); vm.expectRevert(IEcrecoverRatifier.Unauthorized.selector); - ecrecoverRatifier.onRatify(offer, _root, data); + ecrecoverRatifier.onRatify(offer, _root, ratifierData); } function testOnRatifyInvalidSignature() public { Offer memory offer = makeOffer(lender); bytes32 _root = keccak256(abi.encode(offer)); - bytes memory data = abi.encode(Signature({v: 27, r: bytes32(uint256(1)), s: bytes32(uint256(2))})); + bytes memory ratifierData = abi.encode(Signature({v: 27, r: bytes32(uint256(1)), s: bytes32(uint256(2))})); vm.expectRevert(IEcrecoverRatifier.Unauthorized.selector); - ecrecoverRatifier.onRatify(offer, _root, data); + ecrecoverRatifier.onRatify(offer, _root, ratifierData); } function testOnRatifyWrongRoot() public { Offer memory offer = makeOffer(lender); bytes32 _root = keccak256(abi.encode(offer)); - bytes memory data = signRoot(_root, lender); + bytes memory ratifierData = signRoot(_root, lender); bytes32 wrongRoot = keccak256("wrong"); vm.expectRevert(IEcrecoverRatifier.Unauthorized.selector); - ecrecoverRatifier.onRatify(offer, wrongRoot, data); + ecrecoverRatifier.onRatify(offer, wrongRoot, ratifierData); } function testOnRatifyRevokeAuthorizationInvalidates() public { @@ -81,16 +81,16 @@ contract EcrecoverRatifierTest is BaseTest { vm.prank(lender); midnight.setIsAuthorized(lender, borrower, true); - bytes memory data = signRoot(_root, borrower); + bytes memory ratifierData = signRoot(_root, borrower); // Works while authorized. - ecrecoverRatifier.onRatify(offer, _root, data); + ecrecoverRatifier.onRatify(offer, _root, ratifierData); // Revoke. vm.prank(lender); midnight.setIsAuthorized(lender, borrower, false); vm.expectRevert(IEcrecoverRatifier.Unauthorized.selector); - ecrecoverRatifier.onRatify(offer, _root, data); + ecrecoverRatifier.onRatify(offer, _root, ratifierData); } } diff --git a/test/TakeTest.sol b/test/TakeTest.sol index a7b71f4ce..af37fc8f3 100644 --- a/test/TakeTest.sol +++ b/test/TakeTest.sol @@ -896,7 +896,15 @@ contract TakeTest is BaseTest { vm.expectRevert(IMidnight.InvalidProof.selector); vm.prank(borrower); midnight.take( - 100, borrower, address(0), hex"", borrower, lenderOffer, sig([lenderOffer]), invalidRoot, new bytes32[](0) + 100, + borrower, + address(0), + hex"", + borrower, + lenderOffer, + ratifierData([lenderOffer]), + invalidRoot, + new bytes32[](0) ); } @@ -937,7 +945,7 @@ contract TakeTest is BaseTest { hex"", sender, lenderOffer, - sig([lenderOffer], vm.addr(otherPrivateKey)), + ratifierData([lenderOffer], vm.addr(otherPrivateKey)), root([lenderOffer]), proof([lenderOffer]) ); @@ -967,7 +975,7 @@ contract TakeTest is BaseTest { hex"", sender, lenderOffer, - sig([lenderOffer], vm.addr(otherPrivateKey)), + ratifierData([lenderOffer], vm.addr(otherPrivateKey)), root([lenderOffer]), proof([lenderOffer]) ); @@ -980,7 +988,15 @@ contract TakeTest is BaseTest { vm.expectRevert(IMidnight.InvalidProof.selector); vm.prank(borrower); midnight.take( - 100, borrower, address(0), hex"", borrower, lenderOffer, sig([lenderOffer]), root([lenderOffer]), _path + 100, + borrower, + address(0), + hex"", + borrower, + lenderOffer, + ratifierData([lenderOffer]), + root([lenderOffer]), + _path ); } @@ -996,7 +1012,7 @@ contract TakeTest is BaseTest { hex"", borrower, lenderOffer, - sig([lenderOffer, otherOffer]), + ratifierData([lenderOffer, otherOffer]), root([lenderOffer, otherOffer]), _path ); @@ -1017,7 +1033,7 @@ contract TakeTest is BaseTest { hex"", borrower, lenderOffer, - sig([lenderOffer, otherOffer]), + ratifierData([lenderOffer, otherOffer]), root([lenderOffer, otherOffer]), proof([lenderOffer, otherOffer]) ); @@ -1047,7 +1063,7 @@ contract TakeTest is BaseTest { hex"", sender, lenderOffer, - sig([lenderOffer]), + ratifierData([lenderOffer]), root([lenderOffer]), proof([lenderOffer]) ); @@ -1088,7 +1104,7 @@ contract TakeTest is BaseTest { hex"", sender, lenderOffer, - sig([lenderOffer], vm.addr(otherSecretKey)), + ratifierData([lenderOffer], vm.addr(otherSecretKey)), root([lenderOffer]), proof([lenderOffer]) ); @@ -1120,7 +1136,7 @@ contract TakeTest is BaseTest { hex"", sender, lenderOffer, - sig([lenderOffer], vm.addr(otherSecretKey)), + ratifierData([lenderOffer], vm.addr(otherSecretKey)), root([lenderOffer]), proof([lenderOffer]) ); @@ -1147,7 +1163,7 @@ contract TakeTest is BaseTest { hex"", sender, lenderOffer, - sig([lenderOffer], vm.addr(signerPrivateKey)), + ratifierData([lenderOffer], vm.addr(signerPrivateKey)), root([lenderOffer]), proof([lenderOffer]) ); @@ -1167,7 +1183,7 @@ contract TakeTest is BaseTest { hex"", taker, lenderOffer, - sig([lenderOffer]), + ratifierData([lenderOffer]), root([lenderOffer]), proof([lenderOffer]) ); @@ -1184,7 +1200,7 @@ contract TakeTest is BaseTest { hex"", taker, lenderOffer, - sig([lenderOffer]), + ratifierData([lenderOffer]), root([lenderOffer]), proof([lenderOffer]) ); @@ -1205,7 +1221,7 @@ contract TakeTest is BaseTest { hex"", taker, lenderOffer, - sig([lenderOffer]), + ratifierData([lenderOffer]), root([lenderOffer]), proof([lenderOffer]) ); @@ -1257,7 +1273,7 @@ contract TakeTest is BaseTest { abi.encode(0, collateral), borrower, lenderOffer, - sig([lenderOffer]), + ratifierData([lenderOffer]), root([lenderOffer]), proof([lenderOffer]) ); @@ -1289,7 +1305,7 @@ contract TakeTest is BaseTest { abi.encode(0, collateral, repaidUnits), borrower, lenderOffer, - sig([lenderOffer]), + ratifierData([lenderOffer]), root([lenderOffer]), proof([lenderOffer]) ); @@ -1322,7 +1338,7 @@ contract TakeTest is BaseTest { callback.prepare( lenderOffer, - sig([lenderOffer]), + ratifierData([lenderOffer]), root([lenderOffer]), proof([lenderOffer]), units, @@ -1339,7 +1355,7 @@ contract TakeTest is BaseTest { "", borrower, lenderOffer, - sig([lenderOffer]), + ratifierData([lenderOffer]), root([lenderOffer]), proof([lenderOffer]) ); @@ -1370,7 +1386,7 @@ contract TakeTest is BaseTest { hex"", borrower, lenderOffer, - sig([lenderOffer]), + ratifierData([lenderOffer]), root([lenderOffer]), proof([lenderOffer]) ); @@ -1412,7 +1428,7 @@ contract TakeTest is BaseTest { abi.encode(address(loanToken), assets), address(0), borrowerOffer, - sig([borrowerOffer]), + ratifierData([borrowerOffer]), root([borrowerOffer]), proof([borrowerOffer]) ); @@ -1523,7 +1539,7 @@ contract TakeTest is BaseTest { hex"", address(0), borrowerOffer, - sig([borrowerOffer]), + ratifierData([borrowerOffer]), root([borrowerOffer]), proof([borrowerOffer]) ); @@ -1556,7 +1572,9 @@ contract TakeTest is BaseTest { vm.expectRevert(IMidnight.BuyerPendingFeeExceedsCredit.selector); vm.prank(lender); - midnight.take(units, lender, address(0), hex"", lender, bOffer, sig([bOffer]), root([bOffer]), proof([bOffer])); + midnight.take( + units, lender, address(0), hex"", lender, bOffer, ratifierData([bOffer]), root([bOffer]), proof([bOffer]) + ); } } @@ -1798,11 +1816,11 @@ contract RatifyCallback is IRatifier { return _recordedOffer; } - function onRatify(Offer memory offer, bytes32 root, bytes memory data) external returns (bytes32) { + function onRatify(Offer memory offer, bytes32 root, bytes memory ratifierData) external returns (bytes32) { _recordedOffer = offer; - if (data.length > 0) { - Signature memory signature = abi.decode(data, (Signature)); + if (ratifierData.length > 0) { + Signature memory signature = abi.decode(ratifierData, (Signature)); bytes32 structHash = keccak256(abi.encode(ROOT_TYPEHASH, root)); bytes32 domainSeparator = keccak256(abi.encode(EIP712_DOMAIN_TYPEHASH, block.chainid, address(this))); bytes32 digest = keccak256(bytes.concat("\x19\x01", domainSeparator, structHash)); From 12840e8b0751b9bb5ecc3972b4368e940fe46d08 Mon Sep 17 00:00:00 2001 From: PA <50184410+peyha@users.noreply.github.com> Date: Thu, 16 Apr 2026 16:19:39 +0200 Subject: [PATCH 23/33] Rename fee -> trading fee (#705) --- certora/specs/CreatedObligations.spec | 6 +-- certora/specs/FeeBoundaries.spec | 68 +++++++++++++------------- certora/specs/MakerProtection.spec | 2 +- src/Midnight.sol | 56 +++++++++++----------- src/interfaces/IMidnight.sol | 16 +++---- test/OtherFunctionsTest.sol | 28 +++++------ test/SettersTest.sol | 69 ++++++++++++++------------- test/TakeAmountsTest.sol | 61 +++++++++++++---------- test/TradingFeeTest.sol | 35 +++++++++----- 9 files changed, 182 insertions(+), 159 deletions(-) diff --git a/certora/specs/CreatedObligations.spec b/certora/specs/CreatedObligations.spec index 8f5040bc0..8718d04b2 100644 --- a/certora/specs/CreatedObligations.spec +++ b/certora/specs/CreatedObligations.spec @@ -123,8 +123,8 @@ strong invariant obligationTotalUnitsIsEmptyIfNotCreated(bytes32 id) strong invariant obligationWithdrawableIsEmptyIfNotCreated(bytes32 id) !Midnight.obligationCreated(id) => Midnight.withdrawable(id) == 0; -strong invariant obligationFeesAreEmptyIfNotCreated(bytes32 id) - !Midnight.obligationCreated(id) => noFeesAreSet(id); +strong invariant obligationTradingFeesAreEmptyIfNotCreated(bytes32 id) + !Midnight.obligationCreated(id) => noTradingFeesAreSet(id); strong invariant obligationContinuousFeeIsEmptyIfNotCreated(bytes32 id) !Midnight.obligationCreated(id) => Midnight.continuousFee(id) == 0; @@ -156,7 +156,7 @@ strong invariant obligationCollateralIsEmptyIfNotCreated(bytes32 id, address use strong invariant positionLossIndexIsEmptyIfNotCreated(bytes32 id, address user) !Midnight.obligationCreated(id) => currentContract.position[id][user].lossIndex == 0; -function noFeesAreSet(bytes32 id) returns (bool) { +function noTradingFeesAreSet(bytes32 id) returns (bool) { uint16[7] fees = Midnight.tradingFees(id); return fees[0] == 0 && fees[1] == 0 && fees[2] == 0 && fees[3] == 0 && fees[4] == 0 && fees[5] == 0 && fees[6] == 0; } diff --git a/certora/specs/FeeBoundaries.spec b/certora/specs/FeeBoundaries.spec index a4f6b04f5..4ecb53d89 100644 --- a/certora/specs/FeeBoundaries.spec +++ b/certora/specs/FeeBoundaries.spec @@ -24,87 +24,87 @@ definition upperIndex(uint256 ttm) returns uint256 = ttm >= breakpointTime(6) ? definition FEE_STEP() returns uint256 = 1000000000000; -definition defaultFee(address loanToken, uint256 index) returns uint256 = assert_uint256(currentContract.defaultTradingFees[loanToken][index] * FEE_STEP()); +definition defaultTradingFee(address loanToken, uint256 index) returns uint256 = assert_uint256(currentContract.defaultTradingFees[loanToken][index] * FEE_STEP()); -definition rawObligationFee(bytes32 id, uint256 index) returns uint16 = index == 0 ? currentContract.obligationState[id].fee0 : index == 1 ? currentContract.obligationState[id].fee1 : index == 2 ? currentContract.obligationState[id].fee2 : index == 3 ? currentContract.obligationState[id].fee3 : index == 4 ? currentContract.obligationState[id].fee4 : index == 5 ? currentContract.obligationState[id].fee5 : currentContract.obligationState[id].fee6; +definition rawObligationTradingFee(bytes32 id, uint256 index) returns uint16 = index == 0 ? currentContract.obligationState[id].tradingFee0 : index == 1 ? currentContract.obligationState[id].tradingFee1 : index == 2 ? currentContract.obligationState[id].tradingFee2 : index == 3 ? currentContract.obligationState[id].tradingFee3 : index == 4 ? currentContract.obligationState[id].tradingFee4 : index == 5 ? currentContract.obligationState[id].tradingFee5 : currentContract.obligationState[id].tradingFee6; -definition obligationFee(bytes32 id, uint256 index) returns uint256 = assert_uint256(rawObligationFee(id, index) * FEE_STEP()); +definition obligationTradingFee(bytes32 id, uint256 index) returns uint256 = assert_uint256(rawObligationTradingFee(id, index) * FEE_STEP()); -/// Default fees for any loan token at each index are bounded by its specific maxTradingFee cap. -invariant defaultFeePerIndexBound(address loanToken, uint256 index) - index <= 6 => defaultFee(loanToken, index) <= maxTradingFee(index); +/// Default trading fees for any loan token at each index are bounded by its specific maxTradingFee cap. +invariant defaultTradingFeePerIndexBound(address loanToken, uint256 index) + index <= 6 => defaultTradingFee(loanToken, index) <= maxTradingFee(index); -/// Every obligation's fee breakpoints are bounded by the per-index maximum. -invariant obligationFeePerIndexBound(bytes32 id, uint256 index) - index <= 6 => obligationFee(id, index) <= maxTradingFee(index) +/// Every obligation's trading fee breakpoints are bounded by the per-index maximum. +invariant obligationTradingFeePerIndexBound(bytes32 id, uint256 index) + index <= 6 => obligationTradingFee(id, index) <= maxTradingFee(index) { preserved touchObligation(Midnight.Obligation obligation) with (env e) { - requireInvariant defaultFeePerIndexBound(obligation.loanToken, index); + requireInvariant defaultTradingFeePerIndexBound(obligation.loanToken, index); } preserved withdraw(Midnight.Obligation obligation, uint256 units, address onBehalf, address receiver) with (env e) { - requireInvariant defaultFeePerIndexBound(obligation.loanToken, index); + requireInvariant defaultTradingFeePerIndexBound(obligation.loanToken, index); } preserved repay(Midnight.Obligation obligation, uint256 units, address onBehalf, bytes data) with (env e) { - requireInvariant defaultFeePerIndexBound(obligation.loanToken, index); + requireInvariant defaultTradingFeePerIndexBound(obligation.loanToken, index); } preserved supplyCollateral(Midnight.Obligation obligation, uint256 collateralIndex, uint256 assets, address onBehalf) with (env e) { - requireInvariant defaultFeePerIndexBound(obligation.loanToken, index); + requireInvariant defaultTradingFeePerIndexBound(obligation.loanToken, index); } preserved withdrawCollateral(Midnight.Obligation obligation, uint256 collateralIndex, uint256 assets, address onBehalf, address receiver) with (env e) { - requireInvariant defaultFeePerIndexBound(obligation.loanToken, index); + requireInvariant defaultTradingFeePerIndexBound(obligation.loanToken, index); } preserved liquidate(Midnight.Obligation obligation, uint256 collateralIndex, uint256 seizedAssets, uint256 repaidUnits, address borrower, bytes data) with (env e) { - requireInvariant defaultFeePerIndexBound(obligation.loanToken, index); + requireInvariant defaultTradingFeePerIndexBound(obligation.loanToken, index); } preserved take(uint256 units, address taker, address takerCallback, bytes takerCallbackData, address receiverIfTakerIsSeller, Midnight.Offer offer, bytes ratifierData, bytes32 root, bytes32[] proof) with (env e) { - requireInvariant defaultFeePerIndexBound(offer.obligation.loanToken, index); + requireInvariant defaultTradingFeePerIndexBound(offer.obligation.loanToken, index); } } -/// When an obligation is created, its fees are set to the default fees of its loan token. -rule newObligationFeesMatchDefault(env e, Midnight.Obligation obligation, uint256 index) { +/// When an obligation is created, its trading fees are set to the default trading fees of its loan token. +rule newObligationTradingFeesMatchDefault(env e, Midnight.Obligation obligation, uint256 index) { require index <= 6, "index out of bounds"; bytes32 id = toId(e, obligation); require !obligationCreated(id), "obligation not yet created"; - uint256 expectedFee = defaultFee(obligation.loanToken, index); + uint256 expectedTradingFee = defaultTradingFee(obligation.loanToken, index); touchObligation(e, obligation); - assert obligationFee(id, index) == expectedFee; + assert obligationTradingFee(id, index) == expectedTradingFee; } -/// Only the fee setter can modify default fees (multicall is DELETEd and not checked here). -rule onlyFeeSetterCanChangeDefaultFees(method f, env e, address token, uint256 index) filtered { f -> !f.isView } { +/// Only the fee setter can modify default trading fees (multicall is DELETEd and not checked here). +rule onlyFeeSetterCanChangeDefaultTradingFees(method f, env e, address token, uint256 index) filtered { f -> !f.isView } { require index <= 6, "index out of bounds"; - uint256 defaultFeeBefore = defaultFee(token, index); + uint256 defaultTradingFeeBefore = defaultTradingFee(token, index); calldataarg args; f(e, args); - assert defaultFee(token, index) != defaultFeeBefore => e.msg.sender == currentContract.feeSetter() && f.selector == sig:setDefaultTradingFee(address, uint256, uint256).selector; + assert defaultTradingFee(token, index) != defaultTradingFeeBefore => e.msg.sender == currentContract.feeSetter() && f.selector == sig:setDefaultTradingFee(address, uint256, uint256).selector; } -/// Once an obligation is created, only the fee setter can modify its fees. -rule onlyFeeSetterCanChangeObligationFeesPostCreation(method f, env e, bytes32 id, uint256 index) filtered { f -> !f.isView } { +/// Once an obligation is created, only the fee setter can modify its trading fees. +rule onlyFeeSetterCanChangeObligationTradingFeesPostCreation(method f, env e, bytes32 id, uint256 index) filtered { f -> !f.isView } { require index <= 6, "index out of bounds"; require obligationCreated(id), "assume that the obligation is created"; - uint256 obligationFeeBefore = obligationFee(id, index); + uint256 obligationTradingFeeBefore = obligationTradingFee(id, index); calldataarg args; f(e, args); - assert obligationFee(id, index) != obligationFeeBefore => e.msg.sender == currentContract.feeSetter() && f.selector == sig:setObligationTradingFee(bytes32, uint256, uint256).selector; + assert obligationTradingFee(id, index) != obligationTradingFeeBefore => e.msg.sender == currentContract.feeSetter() && f.selector == sig:setObligationTradingFee(bytes32, uint256, uint256).selector; } -/// The trading fee at a breakpoint is equal to the fee state variable at that index. +/// The trading fee at a breakpoint is equal to the trading fee state variable at that index. rule tradingFeeAtBreakpoint(bytes32 id, uint256 index) { - assert index <= 6 => tradingFee(id, breakpointTime(index)) == obligationFee(id, index); + assert index <= 6 => tradingFee(id, breakpointTime(index)) == obligationTradingFee(id, index); } /// For any time-to-maturity the trading fee is enclosed between the two adjacent breakpoint values (never overshoots or undershoots). rule tradingFeeIsBoundedByBreakpointFees(bytes32 id, uint256 timeToMaturity) { - uint256 feeLo = obligationFee(id, lowerIndex(timeToMaturity)); - uint256 feeHi = obligationFee(id, upperIndex(timeToMaturity)); + uint256 tradingFeeLo = obligationTradingFee(id, lowerIndex(timeToMaturity)); + uint256 tradingFeeHi = obligationTradingFee(id, upperIndex(timeToMaturity)); uint256 fee = tradingFee(id, timeToMaturity); - assert (feeLo <= feeHi) => (fee >= feeLo && fee <= feeHi); - assert (feeHi <= feeLo) => (fee >= feeHi && fee <= feeLo); + assert (tradingFeeLo <= tradingFeeHi) => (fee >= tradingFeeLo && fee <= tradingFeeHi); + assert (tradingFeeHi <= tradingFeeLo) => (fee >= tradingFeeHi && fee <= tradingFeeLo); } diff --git a/certora/specs/MakerProtection.spec b/certora/specs/MakerProtection.spec index 2f913adf8..5af05d4f4 100644 --- a/certora/specs/MakerProtection.spec +++ b/certora/specs/MakerProtection.spec @@ -54,7 +54,7 @@ rule makerFavorableRounding(env e, uint256 units, address taker, address takerCa // The trading fee cannot be bypassed: the spread between what the buyer pays and what // the seller receives is at least floor(units * fee / WAD) and at most ceil(units * fee / WAD). -rule feeIsNotBypassed(env e, uint256 units, address taker, address takerCallback, bytes takerCallbackData, address receiver, Midnight.Offer offer, bytes ratifierData, bytes32 root, bytes32[] proof) { +rule tradingFeeIsNotBypassed(env e, uint256 units, address taker, address takerCallback, bytes takerCallbackData, address receiver, Midnight.Offer offer, bytes ratifierData, bytes32 root, bytes32[] proof) { uint256 timeToMaturity = e.block.timestamp <= offer.obligation.maturity ? assert_uint256(offer.obligation.maturity - e.block.timestamp) : 0; uint256 buyerAssets; diff --git a/src/Midnight.sol b/src/Midnight.sol index 3e4973694..7dc511f40 100644 --- a/src/Midnight.sol +++ b/src/Midnight.sol @@ -178,13 +178,13 @@ contract Midnight is IMidnight { require(_obligationState.created, ObligationNotCreated()); // forge-lint: disable-next-item(unsafe-typecast) as newTradingFee <= maxTradingFee <= uint16.max * FEE_STEP uint16 toStore = uint16(newTradingFee / FEE_STEP); - if (index == 0) _obligationState.fee0 = toStore; - else if (index == 1) _obligationState.fee1 = toStore; - else if (index == 2) _obligationState.fee2 = toStore; - else if (index == 3) _obligationState.fee3 = toStore; - else if (index == 4) _obligationState.fee4 = toStore; - else if (index == 5) _obligationState.fee5 = toStore; - else if (index == 6) _obligationState.fee6 = toStore; + if (index == 0) _obligationState.tradingFee0 = toStore; + else if (index == 1) _obligationState.tradingFee1 = toStore; + else if (index == 2) _obligationState.tradingFee2 = toStore; + else if (index == 3) _obligationState.tradingFee3 = toStore; + else if (index == 4) _obligationState.tradingFee4 = toStore; + else if (index == 5) _obligationState.tradingFee5 = toStore; + else if (index == 6) _obligationState.tradingFee6 = toStore; emit EventsLib.SetObligationTradingFee(id, index, newTradingFee); } @@ -681,13 +681,13 @@ contract Midnight is IMidnight { ObligationState storage _obligationState = obligationState[id]; _obligationState.created = true; uint16[7] memory _defaultTradingFees = defaultTradingFees[obligation.loanToken]; - _obligationState.fee0 = _defaultTradingFees[0]; - _obligationState.fee1 = _defaultTradingFees[1]; - _obligationState.fee2 = _defaultTradingFees[2]; - _obligationState.fee3 = _defaultTradingFees[3]; - _obligationState.fee4 = _defaultTradingFees[4]; - _obligationState.fee5 = _defaultTradingFees[5]; - _obligationState.fee6 = _defaultTradingFees[6]; + _obligationState.tradingFee0 = _defaultTradingFees[0]; + _obligationState.tradingFee1 = _defaultTradingFees[1]; + _obligationState.tradingFee2 = _defaultTradingFees[2]; + _obligationState.tradingFee3 = _defaultTradingFees[3]; + _obligationState.tradingFee4 = _defaultTradingFees[4]; + _obligationState.tradingFee5 = _defaultTradingFees[5]; + _obligationState.tradingFee6 = _defaultTradingFees[6]; _obligationState.continuousFee = defaultContinuousFee[obligation.loanToken]; IdLib.storeInCode(obligation); @@ -811,13 +811,13 @@ contract Midnight is IMidnight { function tradingFees(bytes32 id) external view returns (uint16[7] memory) { return [ - obligationState[id].fee0, - obligationState[id].fee1, - obligationState[id].fee2, - obligationState[id].fee3, - obligationState[id].fee4, - obligationState[id].fee5, - obligationState[id].fee6 + obligationState[id].tradingFee0, + obligationState[id].tradingFee1, + obligationState[id].tradingFee2, + obligationState[id].tradingFee3, + obligationState[id].tradingFee4, + obligationState[id].tradingFee5, + obligationState[id].tradingFee6 ]; } @@ -882,16 +882,16 @@ contract Midnight is IMidnight { ObligationState storage _obligationState = obligationState[id]; require(_obligationState.created, ObligationNotCreated()); - if (timeToMaturity >= 360 days) return _obligationState.fee6 * FEE_STEP; + if (timeToMaturity >= 360 days) return _obligationState.tradingFee6 * FEE_STEP; // forgefmt: disable-start (uint256 start, uint256 end, uint256 feeLower, uint256 feeUpper) = - timeToMaturity < 1 days ? ( 0 days, 1 days, _obligationState.fee0 * FEE_STEP, _obligationState.fee1 * FEE_STEP) : - timeToMaturity < 7 days ? ( 1 days, 7 days, _obligationState.fee1 * FEE_STEP, _obligationState.fee2 * FEE_STEP) : - timeToMaturity < 30 days ? ( 7 days, 30 days, _obligationState.fee2 * FEE_STEP, _obligationState.fee3 * FEE_STEP) : - timeToMaturity < 90 days ? ( 30 days, 90 days, _obligationState.fee3 * FEE_STEP, _obligationState.fee4 * FEE_STEP) : - timeToMaturity < 180 days ? ( 90 days, 180 days, _obligationState.fee4 * FEE_STEP, _obligationState.fee5 * FEE_STEP) : - (180 days, 360 days, _obligationState.fee5 * FEE_STEP, _obligationState.fee6 * FEE_STEP); + timeToMaturity < 1 days ? ( 0 days, 1 days, _obligationState.tradingFee0 * FEE_STEP, _obligationState.tradingFee1 * FEE_STEP) : + timeToMaturity < 7 days ? ( 1 days, 7 days, _obligationState.tradingFee1 * FEE_STEP, _obligationState.tradingFee2 * FEE_STEP) : + timeToMaturity < 30 days ? ( 7 days, 30 days, _obligationState.tradingFee2 * FEE_STEP, _obligationState.tradingFee3 * FEE_STEP) : + timeToMaturity < 90 days ? ( 30 days, 90 days, _obligationState.tradingFee3 * FEE_STEP, _obligationState.tradingFee4 * FEE_STEP) : + timeToMaturity < 180 days ? ( 90 days, 180 days, _obligationState.tradingFee4 * FEE_STEP, _obligationState.tradingFee5 * FEE_STEP) : + (180 days, 360 days, _obligationState.tradingFee5 * FEE_STEP, _obligationState.tradingFee6 * FEE_STEP); // forgefmt: disable-end return (feeLower * (end - timeToMaturity) + feeUpper * (timeToMaturity - start)) / (end - start); diff --git a/src/interfaces/IMidnight.sol b/src/interfaces/IMidnight.sol index 9d8579ab2..748eff342 100644 --- a/src/interfaces/IMidnight.sol +++ b/src/interfaces/IMidnight.sol @@ -42,13 +42,13 @@ struct ObligationState { uint128 lossIndex; uint128 withdrawable; uint128 continuousFeeCredit; - uint16 fee0; - uint16 fee1; - uint16 fee2; - uint16 fee3; - uint16 fee4; - uint16 fee5; - uint16 fee6; + uint16 tradingFee0; + uint16 tradingFee1; + uint16 tradingFee2; + uint16 tradingFee3; + uint16 tradingFee4; + uint16 tradingFee5; + uint16 tradingFee6; uint32 continuousFee; bool created; } @@ -109,7 +109,7 @@ interface IMidnight { // forgefmt: disable-start /// STORAGE GETTERS /// function position(bytes32 id, address user) external view returns (uint128 credit, uint128 pendingFee, uint128 lossIndex, uint128 lastAccrual, uint128 debt, uint128 activatedCollaterals); - function obligationState(bytes32 id) external view returns (uint128 totalUnits, uint128 lossIndex, uint128 withdrawable, uint128 continuousFeeCredit, uint16 fee0, uint16 fee1, uint16 fee2, uint16 fee3, uint16 fee4, uint16 fee5, uint16 fee6, uint32 continuousFee, bool created); + function obligationState(bytes32 id) external view returns (uint128 totalUnits, uint128 lossIndex, uint128 withdrawable, uint128 continuousFeeCredit, uint16 tradingFee0, uint16 tradingFee1, uint16 tradingFee2, uint16 tradingFee3, uint16 tradingFee4, uint16 tradingFee5, uint16 tradingFee6, uint32 continuousFee, bool created); function consumed(address user, bytes32 group) external view returns (uint256); function session(address user) external view returns (bytes32); function isAuthorized(address authorizer, address authorized) external view returns (bool); diff --git a/test/OtherFunctionsTest.sol b/test/OtherFunctionsTest.sol index 59e0edbe7..c906a9818 100644 --- a/test/OtherFunctionsTest.sol +++ b/test/OtherFunctionsTest.sol @@ -612,13 +612,13 @@ contract OtherFunctionsTest is BaseTest { uint128 _lossIndex, uint128 _withdrawable, uint128 _continuousFeeCredit, - uint16 fee0, - uint16 fee1, - uint16 fee2, - uint16 fee3, - uint16 fee4, - uint16 fee5, - uint16 fee6, + uint16 tradingFee0, + uint16 tradingFee1, + uint16 tradingFee2, + uint16 tradingFee3, + uint16 tradingFee4, + uint16 tradingFee5, + uint16 tradingFee6, uint32 _continuousFee, bool created ) = midnight.obligationState(_id); @@ -629,13 +629,13 @@ contract OtherFunctionsTest is BaseTest { assertEq(_withdrawable, 0, "withdrawable"); assertEq(_continuousFeeCredit, 0, "continuousFeeCredit"); assertEq(_continuousFee, _defaultContinuousFee, "continuousFee"); - assertEq(fee0, midnight.defaultTradingFees(_obligation.loanToken, 0), "fee0"); - assertEq(fee1, midnight.defaultTradingFees(_obligation.loanToken, 1), "fee1"); - assertEq(fee2, midnight.defaultTradingFees(_obligation.loanToken, 2), "fee2"); - assertEq(fee3, midnight.defaultTradingFees(_obligation.loanToken, 3), "fee3"); - assertEq(fee4, midnight.defaultTradingFees(_obligation.loanToken, 4), "fee4"); - assertEq(fee5, midnight.defaultTradingFees(_obligation.loanToken, 5), "fee5"); - assertEq(fee6, midnight.defaultTradingFees(_obligation.loanToken, 6), "fee6"); + assertEq(tradingFee0, midnight.defaultTradingFees(_obligation.loanToken, 0), "tradingFee0"); + assertEq(tradingFee1, midnight.defaultTradingFees(_obligation.loanToken, 1), "tradingFee1"); + assertEq(tradingFee2, midnight.defaultTradingFees(_obligation.loanToken, 2), "tradingFee2"); + assertEq(tradingFee3, midnight.defaultTradingFees(_obligation.loanToken, 3), "tradingFee3"); + assertEq(tradingFee4, midnight.defaultTradingFees(_obligation.loanToken, 4), "tradingFee4"); + assertEq(tradingFee5, midnight.defaultTradingFees(_obligation.loanToken, 5), "tradingFee5"); + assertEq(tradingFee6, midnight.defaultTradingFees(_obligation.loanToken, 6), "tradingFee6"); } function testObligationStateAfterTrade() public { diff --git a/test/SettersTest.sol b/test/SettersTest.sol index 24d0c272a..1fd384866 100644 --- a/test/SettersTest.sol +++ b/test/SettersTest.sol @@ -218,22 +218,22 @@ contract SettersTest is BaseTest { midnight.setDefaultTradingFee(loanToken, index, feeTooHigh); } - function testLinearInterpolation( - uint256 fee0, - uint256 fee1, - uint256 fee2, - uint256 fee3, - uint256 fee4, - uint256 fee5, - uint256 fee6 + function testTradingFeeLinearInterpolation( + uint256 tradingFee0, + uint256 tradingFee1, + uint256 tradingFee2, + uint256 tradingFee3, + uint256 tradingFee4, + uint256 tradingFee5, + uint256 tradingFee6 ) public { - fee0 = bound(fee0, 0, midnight.maxTradingFee(0)) / 1e12 * 1e12; - fee1 = bound(fee1, 0, midnight.maxTradingFee(1)) / 1e12 * 1e12; - fee2 = bound(fee2, 0, midnight.maxTradingFee(2)) / 1e12 * 1e12; - fee3 = bound(fee3, 0, midnight.maxTradingFee(3)) / 1e12 * 1e12; - fee4 = bound(fee4, 0, midnight.maxTradingFee(4)) / 1e12 * 1e12; - fee5 = bound(fee5, 0, midnight.maxTradingFee(5)) / 1e12 * 1e12; - fee6 = bound(fee6, 0, midnight.maxTradingFee(6)) / 1e12 * 1e12; + tradingFee0 = bound(tradingFee0, 0, midnight.maxTradingFee(0)) / 1e12 * 1e12; + tradingFee1 = bound(tradingFee1, 0, midnight.maxTradingFee(1)) / 1e12 * 1e12; + tradingFee2 = bound(tradingFee2, 0, midnight.maxTradingFee(2)) / 1e12 * 1e12; + tradingFee3 = bound(tradingFee3, 0, midnight.maxTradingFee(3)) / 1e12 * 1e12; + tradingFee4 = bound(tradingFee4, 0, midnight.maxTradingFee(4)) / 1e12 * 1e12; + tradingFee5 = bound(tradingFee5, 0, midnight.maxTradingFee(5)) / 1e12 * 1e12; + tradingFee6 = bound(tradingFee6, 0, midnight.maxTradingFee(6)) / 1e12 * 1e12; CollateralParams[] memory cols = new CollateralParams[](1); cols[0] = CollateralParams({ @@ -250,38 +250,39 @@ contract SettersTest is BaseTest { bytes32 id = toId(obligation); midnight.touchObligation(obligation); - midnight.setObligationTradingFee(id, 0, fee0); - midnight.setObligationTradingFee(id, 1, fee1); - midnight.setObligationTradingFee(id, 2, fee2); - midnight.setObligationTradingFee(id, 3, fee3); - midnight.setObligationTradingFee(id, 4, fee4); - midnight.setObligationTradingFee(id, 5, fee5); - midnight.setObligationTradingFee(id, 6, fee6); + midnight.setObligationTradingFee(id, 0, tradingFee0); + midnight.setObligationTradingFee(id, 1, tradingFee1); + midnight.setObligationTradingFee(id, 2, tradingFee2); + midnight.setObligationTradingFee(id, 3, tradingFee3); + midnight.setObligationTradingFee(id, 4, tradingFee4); + midnight.setObligationTradingFee(id, 5, tradingFee5); + midnight.setObligationTradingFee(id, 6, tradingFee6); // Test exact breakpoints - assertEq(midnight.tradingFee(id, 0), fee0, "0 days"); - assertEq(midnight.tradingFee(id, 1 days), fee1, "1 day"); - assertEq(midnight.tradingFee(id, 7 days), fee2, "7 days"); - assertEq(midnight.tradingFee(id, 30 days), fee3, "30 days"); - assertEq(midnight.tradingFee(id, 90 days), fee4, "90 days"); - assertEq(midnight.tradingFee(id, 180 days), fee5, "180 days"); - assertEq(midnight.tradingFee(id, 360 days), fee6, "360 days"); + assertEq(midnight.tradingFee(id, 0), tradingFee0, "0 days"); + assertEq(midnight.tradingFee(id, 1 days), tradingFee1, "1 day"); + assertEq(midnight.tradingFee(id, 7 days), tradingFee2, "7 days"); + assertEq(midnight.tradingFee(id, 30 days), tradingFee3, "30 days"); + assertEq(midnight.tradingFee(id, 90 days), tradingFee4, "90 days"); + assertEq(midnight.tradingFee(id, 180 days), tradingFee5, "180 days"); + assertEq(midnight.tradingFee(id, 360 days), tradingFee6, "360 days"); // Test interpolation midpoint (0.5 days is between index 0 and 1) - uint256 expectedMidpoint = (fee0 * (1 days - 0.5 days) + fee1 * (0.5 days)) / 1 days; + uint256 expectedMidpoint = (tradingFee0 * (1 days - 0.5 days) + tradingFee1 * (0.5 days)) / 1 days; assertEq(midnight.tradingFee(id, 0.5 days), expectedMidpoint, "Midpoint 0-1d"); // Test interpolation midpoint (4 days is between index 1 and 2) - uint256 expectedMid4d = (fee1 * (7 days - 4 days) + fee2 * (4 days - 1 days)) / (7 days - 1 days); + uint256 expectedMid4d = (tradingFee1 * (7 days - 4 days) + tradingFee2 * (4 days - 1 days)) / (7 days - 1 days); assertEq(midnight.tradingFee(id, 4 days), expectedMid4d, "Midpoint 1-7d"); // Test interpolation midpoint (270 days is between index 5 [180d] and index 6 [360d]) - uint256 expectedMid270d = (fee5 * (360 days - 270 days) + fee6 * (270 days - 180 days)) / (360 days - 180 days); + uint256 expectedMid270d = + (tradingFee5 * (360 days - 270 days) + tradingFee6 * (270 days - 180 days)) / (360 days - 180 days); assertEq(midnight.tradingFee(id, 270 days), expectedMid270d, "Midpoint 180-360d"); // Test beyond 360 days - assertEq(midnight.tradingFee(id, 365 days), fee6, "365 days"); - assertEq(midnight.tradingFee(id, 1000 days), fee6, "1000 days"); + assertEq(midnight.tradingFee(id, 365 days), tradingFee6, "365 days"); + assertEq(midnight.tradingFee(id, 1000 days), tradingFee6, "1000 days"); } function testSetContinuousFeeOnlyFeeSetter(address rdm) public { diff --git a/test/TakeAmountsTest.sol b/test/TakeAmountsTest.sol index 71c83c657..f45a10bf9 100644 --- a/test/TakeAmountsTest.sol +++ b/test/TakeAmountsTest.sol @@ -54,12 +54,12 @@ contract TakeAmountsTest is BaseTest { createBadDebt(obligation); // to create non trivial lossIndex. } - function _setFees(uint256 fee0, uint256 fee1) internal returns (uint256 tradingFee) { - fee0 = bound(fee0, 0, midnight.maxTradingFee(0)) / 1e12 * 1e12; - fee1 = bound(fee1, 0, midnight.maxTradingFee(1)) / 1e12 * 1e12; + function _setTradingFees(uint256 tradingFee0, uint256 tradingFee1) internal returns (uint256 tradingFee) { + tradingFee0 = bound(tradingFee0, 0, midnight.maxTradingFee(0)) / 1e12 * 1e12; + tradingFee1 = bound(tradingFee1, 0, midnight.maxTradingFee(1)) / 1e12 * 1e12; midnight.touchObligation(obligation); - midnight.setObligationTradingFee(id, 0, fee0); - midnight.setObligationTradingFee(id, 1, fee1); + midnight.setObligationTradingFee(id, 0, tradingFee0); + midnight.setObligationTradingFee(id, 1, tradingFee1); tradingFee = midnight.tradingFee(id, obligation.maturity - block.timestamp); } @@ -85,10 +85,13 @@ contract TakeAmountsTest is BaseTest { // buyerIsLender = true: buyer = taker (lender, no debt), seller = maker (borrower). - function testBuyerAssetsToUnitsBuyerIsLender(uint256 targetBuyerAssets, uint256 tick, uint256 fee0, uint256 fee1) - public - { - uint256 tradingFee = _setFees(fee0, fee1); + function testBuyerAssetsToUnitsBuyerIsLender( + uint256 targetBuyerAssets, + uint256 tick, + uint256 tradingFee0, + uint256 tradingFee1 + ) public { + uint256 tradingFee = _setTradingFees(tradingFee0, tradingFee1); targetBuyerAssets = bound(targetBuyerAssets, 1, 1e30); tick = bound(tick, 1, _maxTick(tradingFee)); @@ -104,10 +107,13 @@ contract TakeAmountsTest is BaseTest { assertEq(buyerAssets, targetBuyerAssets, "e2e buyerAssets"); } - function testSellerAssetsToUnitsBuyerIsLender(uint256 targetSellerAssets, uint256 tick, uint256 fee0, uint256 fee1) - public - { - uint256 tradingFee = _setFees(fee0, fee1); + function testSellerAssetsToUnitsBuyerIsLender( + uint256 targetSellerAssets, + uint256 tick, + uint256 tradingFee0, + uint256 tradingFee1 + ) public { + uint256 tradingFee = _setTradingFees(tradingFee0, tradingFee1); targetSellerAssets = bound(targetSellerAssets, 1, 1e30); tick = bound(tick, 1, _maxTick(tradingFee)); @@ -125,10 +131,13 @@ contract TakeAmountsTest is BaseTest { // buyerIsLender = false: buyer = taker (borrower, has debt), seller = maker (lender, has units). - function testBuyerAssetsToUnitsBuyerIsBorrower(uint256 targetBuyerAssets, uint256 tick, uint256 fee0, uint256 fee1) - public - { - uint256 tradingFee = _setFees(fee0, fee1); + function testBuyerAssetsToUnitsBuyerIsBorrower( + uint256 targetBuyerAssets, + uint256 tick, + uint256 tradingFee0, + uint256 tradingFee1 + ) public { + uint256 tradingFee = _setTradingFees(tradingFee0, tradingFee1); targetBuyerAssets = bound(targetBuyerAssets, 1, 1e30); tick = bound(tick, 1, _maxTick(tradingFee)); @@ -148,10 +157,10 @@ contract TakeAmountsTest is BaseTest { function testSellerAssetsToUnitsBuyerIsBorrower( uint256 targetSellerAssets, uint256 tick, - uint256 fee0, - uint256 fee1 + uint256 tradingFee0, + uint256 tradingFee1 ) public { - uint256 tradingFee = _setFees(fee0, fee1); + uint256 tradingFee = _setTradingFees(tradingFee0, tradingFee1); targetSellerAssets = bound(targetSellerAssets, 1, 1e30); tick = bound(tick, 1, _maxTick(tradingFee)); @@ -170,8 +179,10 @@ contract TakeAmountsTest is BaseTest { // buyerPrice >= WAD: not all buyerAssets are reachable, but snapped values are. - function testSnappedBuyerAssetsBuyerIsLender(uint256 targetBuyerAssets, uint256 fee0, uint256 fee1) public { - uint256 tradingFee = _setFees(fee0, fee1); + function testSnappedBuyerAssetsBuyerIsLender(uint256 targetBuyerAssets, uint256 tradingFee0, uint256 tradingFee1) + public + { + uint256 tradingFee = _setTradingFees(tradingFee0, tradingFee1); targetBuyerAssets = bound(targetBuyerAssets, 1, 1e30); uint256 buyerPrice = TickLib.tickToPrice(MAX_TICK) + tradingFee; @@ -188,8 +199,10 @@ contract TakeAmountsTest is BaseTest { assertEq(buyerAssets, targetBuyerAssets.mulDivUp(WAD, buyerPrice).mulDivUp(buyerPrice, WAD), "e2e buyerAssets"); } - function testSnappedBuyerAssetsBuyerIsBorrower(uint256 targetBuyerAssets, uint256 fee0, uint256 fee1) public { - uint256 tradingFee = _setFees(fee0, fee1); + function testSnappedBuyerAssetsBuyerIsBorrower(uint256 targetBuyerAssets, uint256 tradingFee0, uint256 tradingFee1) + public + { + uint256 tradingFee = _setTradingFees(tradingFee0, tradingFee1); targetBuyerAssets = bound(targetBuyerAssets, 1, 1e30); _createPosition(1e36); diff --git a/test/TradingFeeTest.sol b/test/TradingFeeTest.sol index bddbe5cbd..50a114804 100644 --- a/test/TradingFeeTest.sol +++ b/test/TradingFeeTest.sol @@ -126,7 +126,7 @@ contract TradingFeeTest is BaseTest { assertEq(loanToken.balanceOf(address(midnight)) - balanceBefore, expectedFee, "contract balance increase"); } - function testDefaultFee(uint256 units, uint256 sellerTick, uint256 tradingFee) public { + function testDefaultTradingFee(uint256 units, uint256 sellerTick, uint256 tradingFee) public { units = bound(units, 0, MAX_DEBT); sellerTick = bound(sellerTick, 0, MAX_TICK); uint256 sellerPrice = TickLib.tickToPrice(sellerTick); @@ -149,20 +149,25 @@ contract TradingFeeTest is BaseTest { assertEq(loanToken.balanceOf(address(midnight)) - balanceBefore, expectedFee, "contract balance increase"); } - function testSevenDayTtmFee(uint256 units, uint256 sellerTick, uint256 fee1Day, uint256 fee7Days) public { + function testSevenDayTtmTradingFee( + uint256 units, + uint256 sellerTick, + uint256 tradingFee1Day, + uint256 tradingFee7Days + ) public { units = bound(units, 0, MAX_DEBT); sellerTick = bound(sellerTick, 0, MAX_TICK); uint256 sellerPrice = TickLib.tickToPrice(sellerTick); vm.assume(sellerPrice >= MIN_SELLER_PRICE); - fee1Day = bound(fee1Day, 0, midnight.maxTradingFee(1)) / 1e12 * 1e12; - fee7Days = bound(fee7Days, fee1Day, midnight.maxTradingFee(2)) / 1e12 * 1e12; + tradingFee1Day = bound(tradingFee1Day, 0, midnight.maxTradingFee(1)) / 1e12 * 1e12; + tradingFee7Days = bound(tradingFee7Days, tradingFee1Day, midnight.maxTradingFee(2)) / 1e12 * 1e12; obligation.maturity = block.timestamp + 3 days; // Set fees at breakpoints for linear interpolation (3 days is between 1 and 7 days) // Must be set before touchObligation, which snapshots defaultFees at creation time. - midnight.setDefaultTradingFee(address(loanToken), 1, fee1Day); - midnight.setDefaultTradingFee(address(loanToken), 2, fee7Days); + midnight.setDefaultTradingFee(address(loanToken), 1, tradingFee1Day); + midnight.setDefaultTradingFee(address(loanToken), 2, tradingFee7Days); id = midnight.touchObligation(obligation); lenderOffer.obligation = obligation; @@ -185,19 +190,21 @@ contract TradingFeeTest is BaseTest { assertEq(loanToken.balanceOf(address(midnight)) - balanceBefore, expectedFee, "contract balance increase"); } - function testPostMaturityFee(uint256 units, uint256 sellerTick, uint256 fee0Day, uint256 maturity) public { + function testPostMaturityTradingFee(uint256 units, uint256 sellerTick, uint256 tradingFee0Day, uint256 maturity) + public + { units = bound(units, 1, MAX_DEBT); sellerTick = bound(sellerTick, 0, MAX_TICK); uint256 sellerPrice = TickLib.tickToPrice(sellerTick); vm.assume(sellerPrice >= MIN_SELLER_PRICE); - fee0Day = bound(fee0Day, 0, midnight.maxTradingFee(0)) / 1e12 * 1e12; + tradingFee0Day = bound(tradingFee0Day, 0, midnight.maxTradingFee(0)) / 1e12 * 1e12; maturity = bound(maturity, 0, block.timestamp - 1); obligation.maturity = maturity; id = toId(obligation); lenderOffer.obligation = obligation; borrowerOffer.obligation = obligation; - midnight.setDefaultTradingFee(address(loanToken), 0, fee0Day); + midnight.setDefaultTradingFee(address(loanToken), 0, tradingFee0Day); borrowerOffer.tick = sellerTick; collateralize(obligation, borrower, MAX_DEBT); @@ -206,12 +213,14 @@ contract TradingFeeTest is BaseTest { take(units, lender, borrowerOffer); } - function testEarlyFee(uint256 units, uint256 sellerTick, uint256 fee360Days, uint256 maturity) public { + function testEarlyTradingFee(uint256 units, uint256 sellerTick, uint256 tradingFee360Days, uint256 maturity) + public + { units = bound(units, 0, MAX_DEBT); sellerTick = bound(sellerTick, 0, MAX_TICK); uint256 sellerPrice = TickLib.tickToPrice(sellerTick); vm.assume(sellerPrice >= MIN_SELLER_PRICE); - fee360Days = bound(fee360Days, 0, midnight.maxTradingFee(6)) / 1e12 * 1e12; + tradingFee360Days = bound(tradingFee360Days, 0, midnight.maxTradingFee(6)) / 1e12 * 1e12; maturity = bound(maturity, block.timestamp + 360 days, block.timestamp + 36500 days); obligation.maturity = maturity; @@ -219,10 +228,10 @@ contract TradingFeeTest is BaseTest { lenderOffer.obligation = obligation; borrowerOffer.obligation = obligation; - midnight.setDefaultTradingFee(address(loanToken), 6, fee360Days); + midnight.setDefaultTradingFee(address(loanToken), 6, tradingFee360Days); borrowerOffer.tick = sellerTick; - uint256 tradingFee = fee360Days; + uint256 tradingFee = tradingFee360Days; uint256 buyerPrice = sellerPrice + tradingFee; vm.assume(buyerPrice <= WAD); From fd9d353bb828dc6b600504d03b17e7f38274b218 Mon Sep 17 00:00:00 2001 From: Adrien Husson Date: Thu, 16 Apr 2026 18:55:42 +0200 Subject: [PATCH 24/33] separate callbacks (#704) Co-authored-by: peyha --- src/Midnight.sol | 17 +++++-- src/interfaces/ICallbacks.sol | 49 +++++++------------- test/OtherFunctionsTest.sol | 19 +++++--- test/TakeTest.sol | 86 +++-------------------------------- 4 files changed, 48 insertions(+), 123 deletions(-) diff --git a/src/Midnight.sol b/src/Midnight.sol index 7dc511f40..37684e5a0 100644 --- a/src/Midnight.sol +++ b/src/Midnight.sol @@ -10,7 +10,13 @@ import {SafeTransferLib} from "./libraries/SafeTransferLib.sol"; import "./libraries/ConstantsLib.sol"; import {IOracle} from "./interfaces/IOracle.sol"; import {IMidnight, Obligation, Offer, CollateralParams, ObligationState, Position} from "./interfaces/IMidnight.sol"; -import {ICallbacks, IFlashLoanCallback} from "./interfaces/ICallbacks.sol"; +import { + IBuyCallback, + ISellCallback, + ILiquidateCallback, + IRepayCallback, + IFlashLoanCallback +} from "./interfaces/ICallbacks.sol"; import {IRatifier} from "./interfaces/IRatifier.sol"; import {IEnterGate, ILiquidatorGate} from "./interfaces/IGate.sol"; import {EventsLib} from "./libraries/EventsLib.sol"; @@ -384,7 +390,7 @@ contract Midnight is IMidnight { bool wasLocked = UtilsLib.tExchange(LIQUIDATION_LOCK_SLOT, id, seller, true); if (buyerCallback != address(0)) { require( - ICallbacks(buyerCallback).onBuy(id, offer.obligation, buyer, buyerAssets, units, buyerCallbackData) + IBuyCallback(buyerCallback).onBuy(id, offer.obligation, buyer, buyerAssets, units, buyerCallbackData) == CALLBACK_SUCCESS, InvalidBuyCallback() ); @@ -397,7 +403,8 @@ contract Midnight is IMidnight { if (sellerCallback != address(0)) { require( - ICallbacks(sellerCallback).onSell(id, offer.obligation, seller, sellerAssets, units, sellerCallbackData) + ISellCallback(sellerCallback) + .onSell(id, offer.obligation, seller, sellerAssets, units, sellerCallbackData) == CALLBACK_SUCCESS, InvalidSellCallback() ); @@ -440,7 +447,7 @@ contract Midnight is IMidnight { emit EventsLib.Repay(msg.sender, id, units, onBehalf); if (data.length > 0) { - ICallbacks(msg.sender).onRepay(id, obligation, units, onBehalf, data); + IRepayCallback(msg.sender).onRepay(id, obligation, units, onBehalf, data); } SafeTransferLib.safeTransferFrom(obligation.loanToken, msg.sender, address(this), units); @@ -619,7 +626,7 @@ contract Midnight is IMidnight { SafeTransferLib.safeTransfer(obligation.collateralParams[collateralIndex].token, msg.sender, seizedAssets); if (data.length > 0) { - ICallbacks(msg.sender) + ILiquidateCallback(msg.sender) .onLiquidate(id, obligation, collateralIndex, seizedAssets, repaidUnits, borrower, data); } diff --git a/src/interfaces/ICallbacks.sol b/src/interfaces/ICallbacks.sol index 200521ebb..204b3b86b 100644 --- a/src/interfaces/ICallbacks.sol +++ b/src/interfaces/ICallbacks.sol @@ -4,41 +4,24 @@ pragma solidity >=0.5.0; import {Obligation} from "./IMidnight.sol"; -interface ICallbacks { - function onBuy( - bytes32 id, - Obligation memory obligation, - address buyer, - uint256 buyerAssets, - uint256 units, - bytes memory data - ) external returns (bytes32); - function onSell( - bytes32 id, - Obligation memory obligation, - address seller, - uint256 sellerAssets, - uint256 units, - bytes memory data - ) external returns (bytes32); - function onLiquidate( - bytes32 id, - Obligation memory obligation, - uint256 collateralIndex, - uint256 seizedAssets, - uint256 repaidUnits, - address borrower, - bytes memory data - ) external; - function onRepay( - bytes32 obligationId, - Obligation memory obligation, - uint256 units, - address onBehalf, - bytes memory data - ) external; +// forgefmt: disable-start +interface IBuyCallback { + function onBuy(bytes32 id, Obligation memory obligation, address buyer, uint256 buyerAssets, uint256 units, bytes memory data) external returns (bytes32); } +interface ISellCallback { + function onSell(bytes32 id, Obligation memory obligation, address seller, uint256 sellerAssets, uint256 units, bytes memory data) external returns (bytes32); +} + +interface ILiquidateCallback { + function onLiquidate(bytes32 id, Obligation memory obligation, uint256 collateralIndex, uint256 seizedAssets, uint256 repaidUnits, address borrower, bytes memory data) external; +} + +interface IRepayCallback { + function onRepay(bytes32 obligationId, Obligation memory obligation, uint256 units, address onBehalf, bytes memory data) external; +} +// forgefmt: disable-end + interface IFlashLoanCallback { function onFlashLoan(address token, uint256 amount, bytes memory data) external; } diff --git a/test/OtherFunctionsTest.sol b/test/OtherFunctionsTest.sol index c906a9818..cecfa96dc 100644 --- a/test/OtherFunctionsTest.sol +++ b/test/OtherFunctionsTest.sol @@ -3,7 +3,13 @@ pragma solidity ^0.8.0; import {IMidnight, Obligation, CollateralParams} from "../src/interfaces/IMidnight.sol"; -import {ICallbacks} from "../src/interfaces/ICallbacks.sol"; +import { + IBuyCallback, + ISellCallback, + ILiquidateCallback, + IRepayCallback, + IFlashLoanCallback +} from "../src/interfaces/ICallbacks.sol"; import {Midnight} from "../src/Midnight.sol"; import {IdLib} from "../src/libraries/IdLib.sol"; @@ -653,11 +659,12 @@ contract OtherFunctionsTest is BaseTest { } function testMidnightRevertsOnCallbacks(address msgSender, bytes calldata data) public { - bytes4[4] memory selectors = [ - ICallbacks.onBuy.selector, - ICallbacks.onSell.selector, - ICallbacks.onLiquidate.selector, - ICallbacks.onRepay.selector + bytes4[5] memory selectors = [ + IBuyCallback.onBuy.selector, + ISellCallback.onSell.selector, + ILiquidateCallback.onLiquidate.selector, + IRepayCallback.onRepay.selector, + IFlashLoanCallback.onFlashLoan.selector ]; for (uint256 i = 0; i < selectors.length; i++) { vm.prank(msgSender); diff --git a/test/TakeTest.sol b/test/TakeTest.sol index af37fc8f3..75e042d7e 100644 --- a/test/TakeTest.sol +++ b/test/TakeTest.sol @@ -9,7 +9,7 @@ import {Midnight} from "../src/Midnight.sol"; import {WAD, CALLBACK_SUCCESS, MAX_CONTINUOUS_FEE} from "../src/libraries/ConstantsLib.sol"; import {UtilsLib} from "../src/libraries/UtilsLib.sol"; import {TickLib, MAX_TICK} from "../src/libraries/TickLib.sol"; -import {ICallbacks} from "../src/interfaces/ICallbacks.sol"; +import {IBuyCallback, ISellCallback} from "../src/interfaces/ICallbacks.sol"; import {IRatifier} from "../src/interfaces/IRatifier.sol"; import {IdLib} from "../src/libraries/IdLib.sol"; import {BaseTest} from "./BaseTest.sol"; @@ -1578,7 +1578,7 @@ contract TakeTest is BaseTest { } } -contract InvalidBuyCallback is ICallbacks { +contract InvalidBuyCallback is IBuyCallback { function onBuy(bytes32, Obligation memory, address, uint256, uint256, bytes memory) external pure @@ -1586,21 +1586,9 @@ contract InvalidBuyCallback is ICallbacks { { return bytes32(0); } - - function onSell(bytes32, Obligation memory, address, uint256, uint256, bytes memory) - external - pure - returns (bytes32) - { - return CALLBACK_SUCCESS; - } - - function onLiquidate(bytes32, Obligation memory, uint256, uint256, uint256, address, bytes memory) external {} - - function onRepay(bytes32, Obligation memory, uint256, address, bytes memory) external {} } -contract BorrowCallback is ICallbacks { +contract BorrowCallback is ISellCallback { bytes public recordedData; bytes32 public recordedId; @@ -1617,21 +1605,9 @@ contract BorrowCallback is ICallbacks { Midnight(msg.sender).supplyCollateral(obligation, collateralIndex, amount, seller); return CALLBACK_SUCCESS; } - - function onBuy(bytes32, Obligation memory, address, uint256, uint256, bytes memory) - external - pure - returns (bytes32) - { - return CALLBACK_SUCCESS; - } - - function onLiquidate(bytes32, Obligation memory, uint256, uint256, uint256, address, bytes memory) external {} - - function onRepay(bytes32, Obligation memory, uint256, address, bytes memory) external {} } -contract ReentrantLiquidateBorrowCallback is ICallbacks { +contract ReentrantLiquidateBorrowCallback is ISellCallback { bool public liquidateSucceeded; bytes4 public liquidateErrorSelector; @@ -1661,21 +1637,9 @@ contract ReentrantLiquidateBorrowCallback is ICallbacks { oracle.setPrice(healthyPrice); return CALLBACK_SUCCESS; } - - function onBuy(bytes32, Obligation memory, address, uint256, uint256, bytes memory) - external - pure - returns (bytes32) - { - return CALLBACK_SUCCESS; - } - - function onLiquidate(bytes32, Obligation memory, uint256, uint256, uint256, address, bytes memory) external {} - - function onRepay(bytes32, Obligation memory, uint256, address, bytes memory) external {} } -contract NestedTakeReentrantLiquidateCallback is ICallbacks { +contract NestedTakeReentrantLiquidateCallback is ISellCallback { bool public reentered; bool public liquidateSucceeded; bytes4 public liquidateErrorSelector; @@ -1742,21 +1706,9 @@ contract NestedTakeReentrantLiquidateCallback is ICallbacks { } return CALLBACK_SUCCESS; } - - function onBuy(bytes32, Obligation memory, address, uint256, uint256, bytes memory) - external - pure - returns (bytes32) - { - return CALLBACK_SUCCESS; - } - - function onLiquidate(bytes32, Obligation memory, uint256, uint256, uint256, address, bytes memory) external {} - - function onRepay(bytes32, Obligation memory, uint256, address, bytes memory) external {} } -contract LendCallback is ICallbacks { +contract LendCallback is IBuyCallback { bytes public recordedData; bytes32 public recordedId; @@ -1771,29 +1723,9 @@ contract LendCallback is ICallbacks { ERC20(obligation.loanToken).approve(msg.sender, buyerAssets); return CALLBACK_SUCCESS; } - - function onSell(bytes32, Obligation memory, address, uint256, uint256, bytes memory) - external - pure - returns (bytes32) - { - return CALLBACK_SUCCESS; - } - - function onLiquidate(bytes32, Obligation memory, uint256, uint256, uint256, address, bytes memory) external {} - - function onRepay(bytes32, Obligation memory, uint256, address, bytes memory) external {} } -contract InvalidSellCallback is ICallbacks { - function onBuy(bytes32, Obligation memory, address, uint256, uint256, bytes memory) - external - pure - returns (bytes32) - { - return CALLBACK_SUCCESS; - } - +contract InvalidSellCallback is ISellCallback { function onSell(bytes32, Obligation memory, address, uint256, uint256, bytes memory) external pure @@ -1801,10 +1733,6 @@ contract InvalidSellCallback is ICallbacks { { return bytes32(0); } - - function onLiquidate(bytes32, Obligation memory, uint256, uint256, uint256, address, bytes memory) external {} - - function onRepay(bytes32, Obligation memory, uint256, address, bytes memory) external {} } contract RatifyCallback is IRatifier { From b9bf808cbbd84af26695a845345e08926f179908 Mon Sep 17 00:00:00 2001 From: MathisGD <74971347+MathisGD@users.noreply.github.com> Date: Fri, 17 Apr 2026 13:09:35 +0200 Subject: [PATCH 25/33] no solc metadata (#706) --- foundry.toml | 1 + 1 file changed, 1 insertion(+) diff --git a/foundry.toml b/foundry.toml index 249199836..382a88968 100644 --- a/foundry.toml +++ b/foundry.toml @@ -2,6 +2,7 @@ via_ir = true optimizer = true optimizer_runs = 300 +bytecode_hash = "none" evm_version = "osaka" fs_permissions = [{ access = "read", path = "test/ticks_exact.json" }] From de3e296a6ef187b0e28b951cda71c0747cd0c095 Mon Sep 17 00:00:00 2001 From: MathisGD <74971347+MathisGD@users.noreply.github.com> Date: Mon, 20 Apr 2026 14:35:08 +0200 Subject: [PATCH 26/33] refactor take bundler (#710) --- src/periphery/TakeBundler.sol | 81 ++++++++++----- src/periphery/interfaces/ITakeBundler.sol | 8 +- test/BundlerTest.sol | 118 ++++++++++++---------- 3 files changed, 121 insertions(+), 86 deletions(-) diff --git a/src/periphery/TakeBundler.sol b/src/periphery/TakeBundler.sol index 77025d890..20849a23d 100644 --- a/src/periphery/TakeBundler.sol +++ b/src/periphery/TakeBundler.sol @@ -10,28 +10,64 @@ import {TakeAmountsLib} from "./TakeAmountsLib.sol"; contract TakeBundler is ITakeBundler { using UtilsLib for uint256; - /// @dev Iterates through orders, filling up to targetUnits units total. - /// @dev Assumes offers are all buy or all sell and share the same obligation id. + /// @dev Assumes offers are all share the same obligation id. /// @dev The taker must have authorized this bundler and the msg.sender (if different from the taker) on Midnight. /// @dev The bundler skips every reason why `take` can revert (including ones that are not asynchrony related). /// @dev If taking an offer reverts, the bundler will completely skip this offer. - function bundleTakeUnits( + function buyUnitsTarget( address midnight, uint256 targetUnits, address taker, - address receiverIfTakerIsSeller, Take[] calldata takes, uint256 minBuyerAssets, - uint256 maxBuyerAssets, + uint256 maxBuyerAssets + ) external { + require(taker == msg.sender || IMidnight(midnight).isAuthorized(taker, msg.sender), Unauthorized()); + + uint256 totalFilledUnits; + uint256 totalBuyerAssets; + for (uint256 i; i < takes.length && totalFilledUnits < targetUnits; i++) { + require(!takes[i].offer.buy, InconsistentSide()); + try IMidnight(midnight) + .take( + UtilsLib.min(targetUnits - totalFilledUnits, takes[i].units), + taker, + address(0), + "", + address(0), + takes[i].offer, + takes[i].ratifierData, + takes[i].root, + takes[i].proof + ) returns ( + uint256 filledBuyerAssets, uint256, uint256 filledUnits + ) { + totalFilledUnits += filledUnits; + totalBuyerAssets += filledBuyerAssets; + } catch {} + } + + require(totalFilledUnits == targetUnits, InsufficientLiquidity()); + require(totalBuyerAssets >= minBuyerAssets, BuyerAssetsBelowMin()); + require(totalBuyerAssets <= maxBuyerAssets, BuyerAssetsAboveMax()); + } + + /// @dev See buyUnitsTarget. + function sellUnitsTarget( + address midnight, + uint256 targetUnits, + address taker, + address receiverIfTakerIsSeller, + Take[] calldata takes, uint256 minSellerAssets, uint256 maxSellerAssets ) external { require(taker == msg.sender || IMidnight(midnight).isAuthorized(taker, msg.sender), Unauthorized()); uint256 totalFilledUnits; - uint256 totalBuyerAssets; uint256 totalSellerAssets; for (uint256 i; i < takes.length && totalFilledUnits < targetUnits; i++) { + require(takes[i].offer.buy, InconsistentSide()); try IMidnight(midnight) .take( UtilsLib.min(targetUnits - totalFilledUnits, takes[i].units), @@ -44,42 +80,35 @@ contract TakeBundler is ITakeBundler { takes[i].root, takes[i].proof ) returns ( - uint256 filledBuyerAssets, uint256 filledSellerAssets, uint256 filledUnits + uint256, uint256 filledSellerAssets, uint256 filledUnits ) { totalFilledUnits += filledUnits; - totalBuyerAssets += filledBuyerAssets; totalSellerAssets += filledSellerAssets; } catch {} } require(totalFilledUnits == targetUnits, InsufficientLiquidity()); - require(totalBuyerAssets >= minBuyerAssets, BuyerAssetsBelowMin()); - require(totalBuyerAssets <= maxBuyerAssets, BuyerAssetsAboveMax()); require(totalSellerAssets >= minSellerAssets, SellerAssetsBelowMin()); require(totalSellerAssets <= maxSellerAssets, SellerAssetsAboveMax()); } - /// @dev Same as bundleTakeUnits but targets buyer assets. - /// @dev Not usable if buyerPrice > WAD, because not all buyerAssets are reachable then. - /// @dev buyerAssetsToUnits is evaluated before midnight.take, so reverts there (e.g. underflow when offerPrice < - /// tradingFee) are not caught by the try/catch and will abort the bundle. - /// @dev Requires a non-empty takes array. - function bundleTakeBuyerAssets( + /// @dev See buyUnitsTarget. + function buyBuyerAssetsTarget( address midnight, uint256 targetBuyerAssets, address taker, - address receiverIfTakerIsSeller, Take[] calldata takes, uint256 minUnits, uint256 maxUnits ) external { require(taker == msg.sender || IMidnight(midnight).isAuthorized(taker, msg.sender), Unauthorized()); - bytes32 id = IMidnight(midnight).touchObligation(takes[0].offer.obligation); // to have the correct trading - // fees. + // touchObligation to have the correct trading fees. + bytes32 id = IMidnight(midnight).touchObligation(takes[0].offer.obligation); uint256 totalFilledBuyerAssets; uint256 totalUnits; for (uint256 i; i < takes.length && totalFilledBuyerAssets < targetBuyerAssets; i++) { + require(!takes[i].offer.buy, InconsistentSide()); try IMidnight(midnight) .take( UtilsLib.min( @@ -91,7 +120,7 @@ contract TakeBundler is ITakeBundler { taker, address(0), "", - receiverIfTakerIsSeller, + address(0), takes[i].offer, takes[i].ratifierData, takes[i].root, @@ -109,11 +138,8 @@ contract TakeBundler is ITakeBundler { require(totalUnits <= maxUnits, UnitsAboveMax()); } - /// @dev Same as bundleTakeUnits but targets seller assets. - /// @dev sellerAssetsToUnits is evaluated before midnight.take, so reverts there (e.g. underflow when offerPrice < - /// tradingFee) are not caught by the try/catch and will abort the bundle. - /// @dev Requires a non-empty takes array. - function bundleTakeSellerAssets( + /// @dev See buyUnitsTarget. + function sellSellerAssetsTarget( address midnight, uint256 targetSellerAssets, address taker, @@ -123,12 +149,13 @@ contract TakeBundler is ITakeBundler { uint256 maxUnits ) external { require(taker == msg.sender || IMidnight(midnight).isAuthorized(taker, msg.sender), Unauthorized()); - bytes32 id = IMidnight(midnight).touchObligation(takes[0].offer.obligation); // to have the correct trading - // fees. + // touchObligation to have the correct trading fees. + bytes32 id = IMidnight(midnight).touchObligation(takes[0].offer.obligation); uint256 totalFilledSellerAssets; uint256 totalUnits; for (uint256 i; i < takes.length && totalFilledSellerAssets < targetSellerAssets; i++) { + require(takes[i].offer.buy, InconsistentSide()); try IMidnight(midnight) .take( UtilsLib.min( diff --git a/src/periphery/interfaces/ITakeBundler.sol b/src/periphery/interfaces/ITakeBundler.sol index f699228fa..9b1459beb 100644 --- a/src/periphery/interfaces/ITakeBundler.sol +++ b/src/periphery/interfaces/ITakeBundler.sol @@ -16,6 +16,7 @@ interface ITakeBundler { /// ERRORS /// error BuyerAssetsAboveMax(); error BuyerAssetsBelowMin(); + error InconsistentSide(); error InsufficientLiquidity(); error SellerAssetsAboveMax(); error SellerAssetsBelowMin(); @@ -25,8 +26,9 @@ interface ITakeBundler { // forgefmt: disable-start /// FUNCTIONS /// - function bundleTakeUnits(address midnight, uint256 targetUnits, address taker, address receiverIfTakerIsSeller, Take[] calldata takes, uint256 minBuyerAssets, uint256 maxBuyerAssets, uint256 minSellerAssets, uint256 maxSellerAssets) external; - function bundleTakeBuyerAssets(address midnight, uint256 targetBuyerAssets, address taker, address receiverIfTakerIsSeller, Take[] calldata takes, uint256 minUnits, uint256 maxUnits) external; - function bundleTakeSellerAssets(address midnight, uint256 targetSellerAssets, address taker, address receiverIfTakerIsSeller, Take[] calldata takes, uint256 minUnits, uint256 maxUnits) external; + function buyUnitsTarget(address midnight, uint256 targetUnits, address taker, Take[] calldata takes, uint256 minBuyerAssets, uint256 maxBuyerAssets) external; + function sellUnitsTarget(address midnight, uint256 targetUnits, address taker, address receiverIfTakerIsSeller, Take[] calldata takes, uint256 minSellerAssets, uint256 maxSellerAssets) external; + function buyBuyerAssetsTarget(address midnight, uint256 targetBuyerAssets, address taker, Take[] calldata takes, uint256 minUnits, uint256 maxUnits) external; + function sellSellerAssetsTarget(address midnight, uint256 targetSellerAssets, address taker, address receiverIfTakerIsSeller, Take[] calldata takes, uint256 minUnits, uint256 maxUnits) external; // forgefmt: disable-end } diff --git a/test/BundlerTest.sol b/test/BundlerTest.sol index 7e4ddbc49..9dc8b01f5 100644 --- a/test/BundlerTest.sol +++ b/test/BundlerTest.sol @@ -23,6 +23,9 @@ contract BundlerTest is BaseTest { super.setUp(); takeBundler = new TakeBundler(); + deal(address(loanToken), address(takeBundler), type(uint256).max); + vm.prank(address(takeBundler)); + loanToken.approve(address(midnight), type(uint256).max); // Set trading fees to max for all breakpoints. midnight.setFeeClaimer(makeAddr("feeClaimer")); @@ -83,6 +86,8 @@ contract BundlerTest is BaseTest { } function testUnauthorized() public { + offers[0].buy = false; + Take[] memory takes = new Take[](1); takes[0] = Take({ offer: offers[0], @@ -94,12 +99,10 @@ contract BundlerTest is BaseTest { vm.prank(address(0xdead)); vm.expectRevert(ITakeBundler.Unauthorized.selector); - takeBundler.bundleTakeUnits( - address(midnight), 100, borrower, address(0), takes, 0, type(uint256).max, 0, type(uint256).max - ); + takeBundler.buyUnitsTarget(address(midnight), 100, borrower, takes, 0, type(uint256).max); } - function testBundleTakeUnits(uint256 offerUnits0, uint256 offerUnits1, uint256 units) public { + function testSellUnitsTarget(uint256 offerUnits0, uint256 offerUnits1, uint256 units) public { units = bound(units, 0, uint256(type(uint128).max) * 3 / 4); offers[0].maxUnits = offerUnits0; offers[1].maxUnits = offerUnits1; @@ -127,9 +130,7 @@ contract BundlerTest is BaseTest { if (offerUnits1 >= units - fromOffer0) { vm.prank(borrower); - takeBundler.bundleTakeUnits( - address(midnight), units, borrower, borrower, takes, 0, type(uint256).max, 0, type(uint256).max - ); + takeBundler.sellUnitsTarget(address(midnight), units, borrower, borrower, takes, 0, type(uint256).max); uint256 consumed0 = midnight.consumed(offers[0].maker, offers[0].group); uint256 consumed1 = midnight.consumed(offers[1].maker, offers[1].group); @@ -139,23 +140,32 @@ contract BundlerTest is BaseTest { } else { vm.prank(borrower); vm.expectRevert(ITakeBundler.InsufficientLiquidity.selector); - takeBundler.bundleTakeUnits( - address(midnight), units, borrower, borrower, takes, 0, type(uint256).max, 0, type(uint256).max - ); + takeBundler.sellUnitsTarget(address(midnight), units, borrower, borrower, takes, 0, type(uint256).max); } } - function testBundleTakeBuyerAssets(uint256 offerUnits0, uint256 offerUnits1, uint256 targetBuyerAssets) public { + function testBuyBuyerAssetsTarget(uint256 offerUnits0, uint256 offerUnits1, uint256 targetBuyerAssets) public { targetBuyerAssets = bound(targetBuyerAssets, 1, uint256(type(uint128).max) / 2); + + offers[0].buy = false; + offers[0].receiverIfMakerIsSeller = lender; offers[0].maxUnits = offerUnits0; + offers[1].buy = false; + offers[1].receiverIfMakerIsSeller = lender; offers[1].maxUnits = offerUnits1; + // Reset trading fees so buyerPrice = price <= WAD at MAX_TICK. + for (uint256 i; i <= 6; i++) { + midnight.setObligationTradingFee(id, i, 0); + } + deal(address(loanToken), lender, 0); + uint256 price = TickLib.tickToPrice(MAX_TICK); // NB: splitting across offers can require 1 extra unit due to per-leg rounding of buyer assets. uint256 units = targetBuyerAssets.mulDivUp(WAD, price); uint256 fromOffer0 = UtilsLib.min(units, offerUnits0); - collateralize(obligation, borrower, units); + collateralize(obligation, lender, units); Take[] memory takes = new Take[](2); takes[0] = Take({ @@ -177,25 +187,27 @@ contract BundlerTest is BaseTest { if (offerUnits1 >= units - fromOffer0) { vm.prank(borrower); - takeBundler.bundleTakeBuyerAssets( - address(midnight), targetBuyerAssets, borrower, borrower, takes, 0, type(uint256).max + takeBundler.buyBuyerAssetsTarget( + address(midnight), targetBuyerAssets, borrower, takes, 0, type(uint256).max ); uint256 consumed0 = midnight.consumed(offers[0].maker, offers[0].group); uint256 consumed1 = midnight.consumed(offers[1].maker, offers[1].group); assertEq(consumed0, fromOffer0, "consumed offer 0"); - assertEq(consumed0 + consumed1, midnight.debtOf(id, borrower), "total consumed"); - assertEq(loanToken.balanceOf(lender), type(uint256).max - targetBuyerAssets, "lender balance"); + assertEq(consumed0 + consumed1, midnight.debtOf(id, lender), "total consumed"); + assertEq( + loanToken.balanceOf(address(takeBundler)), type(uint256).max - targetBuyerAssets, "bundler balance" + ); } else { vm.prank(borrower); vm.expectRevert(ITakeBundler.InsufficientLiquidity.selector); - takeBundler.bundleTakeBuyerAssets( - address(midnight), targetBuyerAssets, borrower, borrower, takes, 0, type(uint256).max + takeBundler.buyBuyerAssetsTarget( + address(midnight), targetBuyerAssets, borrower, takes, 0, type(uint256).max ); } } - function testBundleTakeSellerAssets(uint256 offerUnits0, uint256 offerUnits1, uint256 targetSellerAssets) public { + function testSellSellerAssetsTarget(uint256 offerUnits0, uint256 offerUnits1, uint256 targetSellerAssets) public { targetSellerAssets = bound(targetSellerAssets, 1, uint256(type(uint128).max) / 2); offers[0].maxUnits = offerUnits0; offers[1].maxUnits = offerUnits1; @@ -235,7 +247,7 @@ contract BundlerTest is BaseTest { uint256 neededFromOffer1 = targetSellerAssets.zeroFloorSub(filledSellerAssets0).mulDivUp(WAD, sellerPrice); if (offerUnits1 >= neededFromOffer1) { vm.prank(borrower); - takeBundler.bundleTakeSellerAssets( + takeBundler.sellSellerAssetsTarget( address(midnight), targetSellerAssets, borrower, borrower, takes, 0, type(uint256).max ); @@ -247,7 +259,7 @@ contract BundlerTest is BaseTest { } else { vm.prank(borrower); vm.expectRevert(ITakeBundler.InsufficientLiquidity.selector); - takeBundler.bundleTakeSellerAssets( + takeBundler.sellSellerAssetsTarget( address(midnight), targetSellerAssets, borrower, borrower, takes, 0, type(uint256).max ); } @@ -255,23 +267,18 @@ contract BundlerTest is BaseTest { // Average prices. - function _minTick() internal view returns (uint256) { - uint256 fee = midnight.tradingFee(id, obligation.maturity - block.timestamp); - return TickLib.priceToTick(fee); - } - - /// @dev Computes the expected totalBuyerAssets for bundleTakeUnits. - /// @dev Since buy=true and the obligation starts empty, buyerPrice == tickToPrice(tick). + /// @dev Computes the expected totalBuyerAssets for buyUnitsTarget. + /// @dev Since buy=false, buyerPrice == tickToPrice(tick) + tradingFee. function _expectedBuyerAssets(uint256 targetUnits, uint256 offerUnits0, uint256 tick0, uint256 tick1) internal - pure + view returns (uint256) { + uint256 fee = midnight.tradingFee(id, obligation.maturity - block.timestamp); uint256 fromOffer0 = UtilsLib.min(targetUnits, offerUnits0); uint256 fromOffer1 = targetUnits - fromOffer0; - return - fromOffer0.mulDivDown(TickLib.tickToPrice(tick0), WAD) - + fromOffer1.mulDivDown(TickLib.tickToPrice(tick1), WAD); + return fromOffer0.mulDivUp(TickLib.tickToPrice(tick0) + fee, WAD) + + fromOffer1.mulDivUp(TickLib.tickToPrice(tick1) + fee, WAD); } function testAveragePriceTooHigh( @@ -282,16 +289,22 @@ contract BundlerTest is BaseTest { uint256 tick1, uint256 maxBuyerAssets ) public { - uint256 minTick = _minTick(); - tick0 = bound(tick0, minTick, MAX_TICK); - tick1 = bound(tick1, minTick, MAX_TICK); + tick0 = bound(tick0, 0, MAX_TICK); + tick1 = bound(tick1, 0, MAX_TICK); // Ensure buyerAssets > 0 so the max bound actually triggers. - uint256 minPrice = UtilsLib.min(TickLib.tickToPrice(tick0), TickLib.tickToPrice(tick1)); - targetUnits = bound(targetUnits, WAD / minPrice + 1, uint256(type(uint128).max) * 3 / 4); + uint256 fee = midnight.tradingFee(id, obligation.maturity - block.timestamp); + uint256 minBuyerPrice = UtilsLib.min(TickLib.tickToPrice(tick0) + fee, TickLib.tickToPrice(tick1) + fee); + targetUnits = bound(targetUnits, WAD / minBuyerPrice + 1, uint256(type(uint128).max) * 3 / 4); + + offers[0].buy = false; + offers[0].receiverIfMakerIsSeller = lender; offers[0].maxUnits = offerUnits0; offers[0].tick = tick0; + offers[1].buy = false; + offers[1].receiverIfMakerIsSeller = lender; offers[1].maxUnits = offerUnits1; offers[1].tick = tick1; + deal(address(loanToken), lender, 0); uint256 fromOffer0 = UtilsLib.min(targetUnits, offerUnits0); vm.assume(offerUnits1 >= targetUnits - fromOffer0); @@ -300,7 +313,7 @@ contract BundlerTest is BaseTest { vm.assume(expected > 0); maxBuyerAssets = bound(maxBuyerAssets, 0, expected - 1); - collateralize(obligation, borrower, targetUnits); + collateralize(obligation, lender, targetUnits); Take[] memory takes = new Take[](2); takes[0] = Take({ @@ -322,9 +335,7 @@ contract BundlerTest is BaseTest { vm.prank(borrower); vm.expectRevert(ITakeBundler.BuyerAssetsAboveMax.selector); - takeBundler.bundleTakeUnits( - address(midnight), targetUnits, borrower, borrower, takes, 0, maxBuyerAssets, 0, type(uint256).max - ); + takeBundler.buyUnitsTarget(address(midnight), targetUnits, borrower, takes, 0, maxBuyerAssets); } function testAveragePriceTooLow( @@ -335,14 +346,19 @@ contract BundlerTest is BaseTest { uint256 tick1, uint256 minBuyerAssets ) public { - uint256 minTick = _minTick(); - tick0 = bound(tick0, minTick, MAX_TICK); - tick1 = bound(tick1, minTick, MAX_TICK); + tick0 = bound(tick0, 0, MAX_TICK); + tick1 = bound(tick1, 0, MAX_TICK); targetUnits = bound(targetUnits, 1, uint256(type(uint128).max) * 3 / 4); + + offers[0].buy = false; + offers[0].receiverIfMakerIsSeller = lender; offers[0].maxUnits = offerUnits0; offers[0].tick = tick0; + offers[1].buy = false; + offers[1].receiverIfMakerIsSeller = lender; offers[1].maxUnits = offerUnits1; offers[1].tick = tick1; + deal(address(loanToken), lender, 0); uint256 fromOffer0 = UtilsLib.min(targetUnits, offerUnits0); vm.assume(offerUnits1 >= targetUnits - fromOffer0); @@ -350,7 +366,7 @@ contract BundlerTest is BaseTest { uint256 expected = _expectedBuyerAssets(targetUnits, offerUnits0, tick0, tick1); minBuyerAssets = bound(minBuyerAssets, expected + 1, type(uint256).max); - collateralize(obligation, borrower, targetUnits); + collateralize(obligation, lender, targetUnits); Take[] memory takes = new Take[](2); takes[0] = Take({ @@ -372,16 +388,6 @@ contract BundlerTest is BaseTest { vm.prank(borrower); vm.expectRevert(ITakeBundler.BuyerAssetsBelowMin.selector); - takeBundler.bundleTakeUnits( - address(midnight), - targetUnits, - borrower, - borrower, - takes, - minBuyerAssets, - type(uint256).max, - 0, - type(uint256).max - ); + takeBundler.buyUnitsTarget(address(midnight), targetUnits, borrower, takes, minBuyerAssets, type(uint256).max); } } From b707d4a28f9475a3c6800089c584e1b9fa8589b1 Mon Sep 17 00:00:00 2001 From: MathisGD <74971347+MathisGD@users.noreply.github.com> Date: Mon, 20 Apr 2026 14:43:04 +0200 Subject: [PATCH 27/33] arbitrary callback targets (#688) Signed-off-by: MathisGD <74971347+MathisGD@users.noreply.github.com> --- certora/helpers/FlashLiquidateCallback.sol | 13 ++- certora/specs/BalanceEffects.spec | 18 ++-- certora/specs/CreatedObligations.spec | 8 +- certora/specs/FeeBoundaries.spec | 4 +- certora/specs/Healthiness.spec | 16 +-- certora/specs/Liquidate.spec | 4 +- certora/specs/Midnight.spec | 4 +- certora/specs/NoDivisionByZero.spec | 6 +- certora/specs/OnlyAuthorizedCanChange.spec | 4 +- certora/specs/WithdrawableMonotonicity.spec | 12 +-- src/Midnight.sol | 95 +++++++++--------- src/interfaces/ICallbacks.sol | 8 +- src/interfaces/IMidnight.sol | 11 ++- src/libraries/EventsLib.sol | 8 +- test/AuthorizationTest.sol | 10 +- test/BaseTest.sol | 4 +- test/ContinuousFeeTest.sol | 5 +- test/FlashloanTest.sol | 4 +- test/GateTest.sol | 8 +- test/LiquidationTest.sol | 102 ++++++++++++-------- test/OtherFunctionsTest.sol | 12 ++- test/TakeTest.sol | 12 ++- 22 files changed, 201 insertions(+), 167 deletions(-) diff --git a/certora/helpers/FlashLiquidateCallback.sol b/certora/helpers/FlashLiquidateCallback.sol index bfd7c04e8..b5644e34d 100644 --- a/certora/helpers/FlashLiquidateCallback.sol +++ b/certora/helpers/FlashLiquidateCallback.sol @@ -3,6 +3,7 @@ pragma solidity ^0.8.0; import {Obligation} from "../../src/interfaces/IMidnight.sol"; +import {CALLBACK_SUCCESS} from "../../src/libraries/ConstantsLib.sol"; interface IHavoc { function havoc() external; @@ -25,24 +26,30 @@ contract FlashLiquidateCallback { uint256 repaidUnits, address, bytes memory data - ) external { + ) external returns (bytes32) { startFlashloan(obligation.loanToken, repaidUnits); address account = abi.decode(data, (address)); IHavoc(account).havoc(); endFlashloan(obligation.loanToken, repaidUnits); + return CALLBACK_SUCCESS; } - function onRepay(bytes32, Obligation memory obligation, uint256 units, address, bytes memory data) external { + function onRepay(bytes32, Obligation memory obligation, uint256 units, address, bytes memory data) + external + returns (bytes32) + { startFlashloan(obligation.loanToken, units); address account = abi.decode(data, (address)); IHavoc(account).havoc(); endFlashloan(obligation.loanToken, units); + return CALLBACK_SUCCESS; } - function onFlashLoan(address token, uint256 amount, bytes calldata data) external { + function onFlashLoan(address token, uint256 amount, bytes calldata data) external returns (bytes32) { startFlashloan(token, amount); address account = abi.decode(data, (address)); IHavoc(account).havoc(); endFlashloan(token, amount); + return CALLBACK_SUCCESS; } } diff --git a/certora/specs/BalanceEffects.spec b/certora/specs/BalanceEffects.spec index 04c275aab..7adba1169 100644 --- a/certora/specs/BalanceEffects.spec +++ b/certora/specs/BalanceEffects.spec @@ -112,14 +112,14 @@ rule takeEffects(env e, uint256 units, address taker, address takerCallback, byt /// REPAY /// /// Repay decreases onBehalf's debt by exactly units and only changes position[id][onBehalf].debt -rule repayEffects(env e, Midnight.Obligation obligation, uint256 units, address onBehalf, bytes data, bytes32 anyId, address anyUser) { +rule repayEffects(env e, Midnight.Obligation obligation, uint256 units, address onBehalf, address callback, bytes data, bytes32 anyId, address anyUser) { bytes32 id = toId(e, obligation); uint256 debtBefore = debtOf(id, onBehalf); uint256 otherCreditBefore = creditOf(anyId, anyUser); uint256 otherDebtBefore = debtOf(anyId, anyUser); - repay(e, obligation, units, onBehalf, data); + repay(e, obligation, units, onBehalf, callback, data); assert debtOf(id, onBehalf) == debtBefore - units; assert creditOf(anyId, anyUser) == otherCreditBefore; @@ -130,7 +130,7 @@ rule repayEffects(env e, Midnight.Obligation obligation, uint256 units, address /// Liquidate decreases the borrower's debt by at least repaidUnits, /// and only changes position[id][borrower].debt. -rule liquidateEffects(env e, Midnight.Obligation obligation, uint256 collateralIndex, uint256 seizedAssets, uint256 repaidUnits, address borrower, bytes data, bytes32 anyId, address anyUser) { +rule liquidateEffects(env e, Midnight.Obligation obligation, uint256 collateralIndex, uint256 seizedAssets, uint256 repaidUnits, address borrower, address receiver, address callback, bytes data, bytes32 anyId, address anyUser) { bytes32 id = toId(e, obligation); uint256 debtBefore = debtOf(id, borrower); @@ -139,7 +139,7 @@ rule liquidateEffects(env e, Midnight.Obligation obligation, uint256 collateralI uint256 seizedResult; uint256 repaidResult; - seizedResult, repaidResult = liquidate(e, obligation, collateralIndex, seizedAssets, repaidUnits, borrower, data); + seizedResult, repaidResult = liquidate(e, obligation, collateralIndex, seizedAssets, repaidUnits, borrower, receiver, callback, data); assert debtOf(id, borrower) <= debtBefore - repaidResult; assert creditOf(anyId, anyUser) == otherCreditBefore; @@ -154,8 +154,8 @@ filtered { f -> !f.isView && f.selector != sig:take(uint256, address, address, bytes, address, Midnight.Offer, bytes, bytes32, bytes32[]).selector && f.selector != sig:withdraw(Midnight.Obligation, uint256, address, address).selector - && f.selector != sig:repay(Midnight.Obligation, uint256, address, bytes).selector - && f.selector != sig:liquidate(Midnight.Obligation, uint256, uint256, uint256, address, bytes).selector + && f.selector != sig:repay(Midnight.Obligation, uint256, address, address, bytes).selector + && f.selector != sig:liquidate(Midnight.Obligation, uint256, uint256, uint256, address, address, address, bytes).selector && f.selector != sig:updatePosition(Midnight.Obligation, address).selector } { uint256 creditBefore = creditOf(id, user); @@ -201,14 +201,14 @@ rule withdrawCollateralCollateralEffects(env e, Midnight.Obligation obligation, /// liquidate decreases the borrower's collateral at collateralIndex by exactly seizedResult, /// and only changes position[id][borrower].collateral[collateralIndex]. -rule liquidateCollateralEffects(env e, Midnight.Obligation obligation, uint256 collateralIndex, uint256 seizedAssets, uint256 repaidUnits, address borrower, bytes data, bytes32 anyId, address anyUser, uint256 anyIndex) { +rule liquidateCollateralEffects(env e, Midnight.Obligation obligation, uint256 collateralIndex, uint256 seizedAssets, uint256 repaidUnits, address borrower, address receiver, address callback, bytes data, bytes32 anyId, address anyUser, uint256 anyIndex) { bytes32 id = toId(e, obligation); uint256 collateralBefore = collateral(id, borrower, collateralIndex); uint256 otherCollateralBefore = collateral(anyId, anyUser, anyIndex); uint256 seizedResult; - seizedResult, _ = liquidate(e, obligation, collateralIndex, seizedAssets, repaidUnits, borrower, data); + seizedResult, _ = liquidate(e, obligation, collateralIndex, seizedAssets, repaidUnits, borrower, receiver, callback, data); assert collateral(id, borrower, collateralIndex) == collateralBefore - seizedResult; assert anyUser != borrower || anyId != id || anyIndex != collateralIndex => collateral(anyId, anyUser, anyIndex) == otherCollateralBefore; @@ -222,7 +222,7 @@ filtered { f -> !f.isView && f.selector != sig:supplyCollateral(Midnight.Obligation, uint256, uint256, address).selector && f.selector != sig:withdrawCollateral(Midnight.Obligation, uint256, uint256, address, address).selector - && f.selector != sig:liquidate(Midnight.Obligation, uint256, uint256, uint256, address, bytes).selector + && f.selector != sig:liquidate(Midnight.Obligation, uint256, uint256, uint256, address, address, address, bytes).selector } { uint256 collateralBefore = collateral(id, user, colIdx); f(e, args); diff --git a/certora/specs/CreatedObligations.spec b/certora/specs/CreatedObligations.spec index 8718d04b2..a42bb39f5 100644 --- a/certora/specs/CreatedObligations.spec +++ b/certora/specs/CreatedObligations.spec @@ -96,8 +96,8 @@ rule obligationIsCreatedAfterWithdraw(env e, Midnight.Obligation obligation, uin assert obligationIsCreated(obligation); } -rule obligationIsCreatedAfterRepay(env e, Midnight.Obligation obligation, uint256 units, address onBehalf, bytes data) { - Midnight.repay(e, obligation, units, onBehalf, data); +rule obligationIsCreatedAfterRepay(env e, Midnight.Obligation obligation, uint256 units, address onBehalf, address callback, bytes data) { + Midnight.repay(e, obligation, units, onBehalf, callback, data); assert obligationIsCreated(obligation); } @@ -111,8 +111,8 @@ rule obligationIsCreatedAfterWithdrawCollateral(env e, Midnight.Obligation oblig assert obligationIsCreated(obligation); } -rule obligationIsCreatedAfterLiquidate(env e, Midnight.Obligation obligation, uint256 collateralIndex, uint256 seizedAssets, uint256 repaidUnits, address borrower, bytes data) { - Midnight.liquidate(e, obligation, collateralIndex, seizedAssets, repaidUnits, borrower, data); +rule obligationIsCreatedAfterLiquidate(env e, Midnight.Obligation obligation, uint256 collateralIndex, uint256 seizedAssets, uint256 repaidUnits, address borrower, address receiver, address callback, bytes data) { + Midnight.liquidate(e, obligation, collateralIndex, seizedAssets, repaidUnits, borrower, receiver, callback, data); assert obligationIsCreated(obligation); } diff --git a/certora/specs/FeeBoundaries.spec b/certora/specs/FeeBoundaries.spec index 4ecb53d89..1d1465092 100644 --- a/certora/specs/FeeBoundaries.spec +++ b/certora/specs/FeeBoundaries.spec @@ -44,7 +44,7 @@ invariant obligationTradingFeePerIndexBound(bytes32 id, uint256 index) preserved withdraw(Midnight.Obligation obligation, uint256 units, address onBehalf, address receiver) with (env e) { requireInvariant defaultTradingFeePerIndexBound(obligation.loanToken, index); } - preserved repay(Midnight.Obligation obligation, uint256 units, address onBehalf, bytes data) with (env e) { + preserved repay(Midnight.Obligation obligation, uint256 units, address onBehalf, address callback, bytes data) with (env e) { requireInvariant defaultTradingFeePerIndexBound(obligation.loanToken, index); } preserved supplyCollateral(Midnight.Obligation obligation, uint256 collateralIndex, uint256 assets, address onBehalf) with (env e) { @@ -53,7 +53,7 @@ invariant obligationTradingFeePerIndexBound(bytes32 id, uint256 index) preserved withdrawCollateral(Midnight.Obligation obligation, uint256 collateralIndex, uint256 assets, address onBehalf, address receiver) with (env e) { requireInvariant defaultTradingFeePerIndexBound(obligation.loanToken, index); } - preserved liquidate(Midnight.Obligation obligation, uint256 collateralIndex, uint256 seizedAssets, uint256 repaidUnits, address borrower, bytes data) with (env e) { + preserved liquidate(Midnight.Obligation obligation, uint256 collateralIndex, uint256 seizedAssets, uint256 repaidUnits, address borrower, address receiver, address callback, bytes data) with (env e) { requireInvariant defaultTradingFeePerIndexBound(obligation.loanToken, index); } preserved take(uint256 units, address taker, address takerCallback, bytes takerCallbackData, address receiverIfTakerIsSeller, Midnight.Offer offer, bytes ratifierData, bytes32 root, bytes32[] proof) with (env e) { diff --git a/certora/specs/Healthiness.spec b/certora/specs/Healthiness.spec index 579342802..fba3eb061 100644 --- a/certora/specs/Healthiness.spec +++ b/certora/specs/Healthiness.spec @@ -32,9 +32,9 @@ methods { function _.transfer(address to, uint256 amount) external with(env e) => genericCallbackBool() expect(bool); function _.onBuy(bytes32 id, Midnight.Obligation obligation, address buyer, uint256 buyerAssets, uint256 units, bytes data) external => genericCallbackBytes32() expect(bytes32); function _.onSell(bytes32 id, Midnight.Obligation obligation, address seller, uint256 sellerAssets, uint256 units, bytes data) external => genericCallbackBytes32() expect(bytes32); - function _.onRepay(bytes32 id, Midnight.Obligation obligation, uint256 units, address onBehalf, bytes data) external => genericCallback() expect void; - function _.onLiquidate(bytes32 id, Midnight.Obligation obligation, uint256 collateralIndex, uint256 seizedAssets, uint256 repaidUnits, address borrower, bytes data) external => genericCallback() expect void; - function _.onFlashLoan(address token, uint256 amount, bytes data) external => genericCallback() expect void; + function _.onRepay(bytes32 id, Midnight.Obligation obligation, uint256 units, address onBehalf, bytes data) external => genericCallbackBytes32() expect(bytes32); + function _.onLiquidate(bytes32 id, Midnight.Obligation obligation, uint256 collateralIndex, uint256 seizedAssets, uint256 repaidUnits, address borrower, bytes data) external => genericCallbackBytes32() expect(bytes32); + function _.onFlashLoan(address token, uint256 amount, bytes data) external => genericCallbackBytes32() expect(bytes32); } /// SUMMARY /// @@ -200,7 +200,7 @@ function genericCallbackBytes32() returns (bytes32) { // and then we have a final rule for all other functions of the contract. // Show that the user stays healthy on liquidate, if the user gets liquidated (can occur if blocktime exceeds maturity) -rule stayHealthyLiquidateSameBorrower(env e, uint256 collateralIndex, uint256 seizedAssetsIn, uint256 repaidUnitsIn, bytes data) { +rule stayHealthyLiquidateSameBorrower(env e, uint256 collateralIndex, uint256 seizedAssetsIn, uint256 repaidUnitsIn, address receiver, address callback, bytes data) { useIsHealthyNoBitmap = false; // This variable is set to false whenever isHealthy() is violated before a callback. Initially we set it to true to indicate no violations detected. @@ -218,7 +218,7 @@ rule stayHealthyLiquidateSameBorrower(env e, uint256 collateralIndex, uint256 se uint256 seizedAssetsOut; uint256 repaidUnitsOut; - seizedAssetsOut, repaidUnitsOut = liquidate(e, globalObligation, collateralIndex, seizedAssetsIn, repaidUnitsIn, globalBorrower, data); + seizedAssetsOut, repaidUnitsOut = liquidate(e, globalObligation, collateralIndex, seizedAssetsIn, repaidUnitsIn, globalBorrower, receiver, callback, data); // we cannot use collateral, as it may already have been changed by the callbacks. mathint collateralAfter = collateralBefore - seizedAssetsOut; @@ -241,7 +241,7 @@ rule stayHealthyLiquidateSameBorrower(env e, uint256 collateralIndex, uint256 se } // Show that the user stays healthy on liquidate, if another user gets liquidated or obligation differs. -rule stayHealthyLiquidateOtherBorrower(env e, Midnight.Obligation obligation, uint256 collateralIndex, uint256 seizedAssets, uint256 repaidUnits, address borrower, bytes data) { +rule stayHealthyLiquidateOtherBorrower(env e, Midnight.Obligation obligation, uint256 collateralIndex, uint256 seizedAssets, uint256 repaidUnits, address borrower, address receiver, address callback, bytes data) { useIsHealthyNoBitmap = true; // This variable is set to false whenever isHealthy() is violated before a callback. Initially we set it to true to indicate no violations detected. @@ -254,14 +254,14 @@ rule stayHealthyLiquidateOtherBorrower(env e, Midnight.Obligation obligation, ui require callIsHealthy(globalObligation, globalId, globalBorrower), "user is healthy before call"; - liquidate(e, obligation, collateralIndex, seizedAssets, repaidUnits, borrower, data); + liquidate(e, obligation, collateralIndex, seizedAssets, repaidUnits, borrower, receiver, callback, data); assert healthyBeforeCallback, "user is healthy before callbacks"; assert callIsHealthy(globalObligation, globalId, globalBorrower), "user is healthy after call"; } // Show that the user stays healthy on any other function than liquidate or take. -rule stayHealthy(env e, method f, calldataarg args) filtered { f -> f.selector != sig:liquidate(Midnight.Obligation, uint256, uint256, uint256, address, bytes).selector && f.selector != sig:take(uint256, address, address, bytes, address, Midnight.Offer, bytes, bytes32, bytes32[]).selector } { +rule stayHealthy(env e, method f, calldataarg args) filtered { f -> f.selector != sig:liquidate(Midnight.Obligation, uint256, uint256, uint256, address, address, address, bytes).selector && f.selector != sig:take(uint256, address, address, bytes, address, Midnight.Offer, bytes, bytes32, bytes32[]).selector } { // for withdraw collateral we choose isHealthy() for all others the isHealthyNoBitmap function. useIsHealthyNoBitmap = (f.selector != sig:withdrawCollateral(Midnight.Obligation, uint256, uint256, address, address).selector); diff --git a/certora/specs/Liquidate.spec b/certora/specs/Liquidate.spec index e0ee8f74b..95d1feb5f 100644 --- a/certora/specs/Liquidate.spec +++ b/certora/specs/Liquidate.spec @@ -42,7 +42,7 @@ ghost summaryPrice(address) returns uint256; /// Credit does not change on liquidate. Debt and collateral of a user can only change via liquidate if the position is liquidatable and user is borrower. /// Furthermore, liquidate can only decrease the borrower's debt and collateral (w.r.t the collateralIndex passed in liquidate). /// Also show that liquidate can only be called on liquidatable positions. -rule liquidateOnlyAffectsBalancesWhenLiquidatable(env e, Midnight.Obligation obligation, uint256 liqIndex, uint256 seizedAssets, uint256 repaidUnits, address liqUser, bytes data) { +rule liquidateOnlyAffectsBalancesWhenLiquidatable(env e, Midnight.Obligation obligation, uint256 liqIndex, uint256 seizedAssets, uint256 repaidUnits, address liqUser, address receiver, address callback, bytes data) { bytes32 id; address user; uint256 collateralIndex; @@ -53,7 +53,7 @@ rule liquidateOnlyAffectsBalancesWhenLiquidatable(env e, Midnight.Obligation obl uint256 debtBefore = debtOf(id, user); uint256 collateralBefore = collateral(id, user, collateralIndex); - liquidate(e, obligation, liqIndex, seizedAssets, repaidUnits, liqUser, data); + liquidate(e, obligation, liqIndex, seizedAssets, repaidUnits, liqUser, receiver, callback, data); uint256 creditAfter = creditOf(id, user); uint256 debtAfter = debtOf(id, user); diff --git a/certora/specs/Midnight.spec b/certora/specs/Midnight.spec index 8d6ba234c..f236c5775 100644 --- a/certora/specs/Midnight.spec +++ b/certora/specs/Midnight.spec @@ -85,11 +85,11 @@ rule takeInputOutputConsistency(env e, uint256 unitsInput, address taker, addres assert claimableTradingFee(offer.obligation.loanToken) == claimableBefore + buyerAssetsOutput - sellerAssetsOutput; } -rule liquidateInputOutputConsistency(env e, Midnight.Obligation obligation, uint256 collateralIndex, uint256 seizedAssets, uint256 repaidUnits, address borrower, bytes data) { +rule liquidateInputOutputConsistency(env e, Midnight.Obligation obligation, uint256 collateralIndex, uint256 seizedAssets, uint256 repaidUnits, address borrower, address receiver, address callback, bytes data) { uint256 seizedAssetsOutput; uint256 repaidUnitsOutput; - seizedAssetsOutput, repaidUnitsOutput = liquidate(e, obligation, collateralIndex, seizedAssets, repaidUnits, borrower, data); + seizedAssetsOutput, repaidUnitsOutput = liquidate(e, obligation, collateralIndex, seizedAssets, repaidUnits, borrower, receiver, callback, data); // At most one of the input arguments can be zero. assert seizedAssets == 0 || repaidUnits == 0; diff --git a/certora/specs/NoDivisionByZero.spec b/certora/specs/NoDivisionByZero.spec index d98ae3bcb..ba8947e47 100644 --- a/certora/specs/NoDivisionByZero.spec +++ b/certora/specs/NoDivisionByZero.spec @@ -113,13 +113,13 @@ function mulDivUpSummary(uint256 x, uint256 y, uint256 d) returns uint256 { // The liquidate function is verified in a separate rule (noDivisionByZeroLiquidate). // The maxLif function is excluded: it is a pure function callable with arbitrary inputs. -rule noDivisionByZero(method f, env e, calldataarg args) filtered { f -> f.selector != sig:maxLif(uint256, uint256).selector && f.selector != sig:liquidate(Midnight.Obligation, uint256, uint256, uint256, address, bytes).selector } { +rule noDivisionByZero(method f, env e, calldataarg args) filtered { f -> f.selector != sig:maxLif(uint256, uint256).selector && f.selector != sig:liquidate(Midnight.Obligation, uint256, uint256, uint256, address, address, address, bytes).selector } { f(e, args); assert true; } // Show that liquidate does not cause a division by zero, in case the oracle price is non-zero and the collateral is active. -rule noDivisionByZeroLiquidate(env e, Midnight.Obligation obligation, uint256 collateralIndex, uint256 seizedAssets, uint256 repaidUnits, address borrower, bytes data) { +rule noDivisionByZeroLiquidate(env e, Midnight.Obligation obligation, uint256 collateralIndex, uint256 seizedAssets, uint256 repaidUnits, address borrower, address receiver, address callback, bytes data) { require equalsGlobalObligation(obligation); // Needed for the bitmap loop which calls mulDivUp(WAD, maxLif) for every activated collateral. @@ -131,6 +131,6 @@ rule noDivisionByZeroLiquidate(env e, Midnight.Obligation obligation, uint256 co require ghostPrice(obligation.collateralParams[collateralIndex].oracle) > 0, "Assumption: the collateral price is not zero"; require summaryGetBit(currentContract.position[globalId][borrower].activatedCollaterals, collateralIndex), "Assumption: liquidated collateral was activated"; - liquidate(e, obligation, collateralIndex, seizedAssets, repaidUnits, borrower, data); + liquidate(e, obligation, collateralIndex, seizedAssets, repaidUnits, borrower, receiver, callback, data); assert true; } diff --git a/certora/specs/OnlyAuthorizedCanChange.spec b/certora/specs/OnlyAuthorizedCanChange.spec index 23c60fdc1..9da05a462 100644 --- a/certora/specs/OnlyAuthorizedCanChange.spec +++ b/certora/specs/OnlyAuthorizedCanChange.spec @@ -62,7 +62,7 @@ definition noAccrual(env e, bytes32 id, address borrower) returns bool = current /// An unauthorized caller cannot change a user's credit and debt except via liquidate and updatePosition. /// Assumes no reentrancy: callbacks (onBuy, onSell) and token transfers are not modeled as re-entering Midnight, so re-entrant credit and debt changes are not covered. -rule onlyAuthorizedCanChangeCreditAndDebtExceptLiquidateAndUpdatePosition(env e, method f, calldataarg args, bytes32 id, address user) filtered { f -> f.selector != sig:liquidate(Midnight.Obligation, uint256, uint256, uint256, address, bytes).selector && f.selector != sig:updatePosition(Midnight.Obligation, address).selector } { +rule onlyAuthorizedCanChangeCreditAndDebtExceptLiquidateAndUpdatePosition(env e, method f, calldataarg args, bytes32 id, address user) filtered { f -> f.selector != sig:liquidate(Midnight.Obligation, uint256, uint256, uint256, address, address, address, bytes).selector && f.selector != sig:updatePosition(Midnight.Obligation, address).selector } { bool userIsAuthorized = user == e.msg.sender || isAuthorized(user, e.msg.sender); uint256 creditBefore = creditOf(id, user); @@ -78,7 +78,7 @@ rule onlyAuthorizedCanChangeCreditAndDebtExceptLiquidateAndUpdatePosition(env e, /// An unauthorized caller cannot change a user's collateral except via liquidate. /// Assumes no reentrancy: callbacks and token transfers are not modeled as re-entering Midnight, so re-entrant collateral changes are not covered. -rule onlyAuthorizedCanChangeCollateralExceptLiquidate(env e, method f, calldataarg args, bytes32 id, address user, uint256 collateralIndex) filtered { f -> f.selector != sig:liquidate(Midnight.Obligation, uint256, uint256, uint256, address, bytes).selector } { +rule onlyAuthorizedCanChangeCollateralExceptLiquidate(env e, method f, calldataarg args, bytes32 id, address user, uint256 collateralIndex) filtered { f -> f.selector != sig:liquidate(Midnight.Obligation, uint256, uint256, uint256, address, address, address, bytes).selector } { bool userIsAuthorized = user == e.msg.sender || isAuthorized(user, e.msg.sender); uint256 collateralBefore = collateral(id, user, collateralIndex); diff --git a/certora/specs/WithdrawableMonotonicity.spec b/certora/specs/WithdrawableMonotonicity.spec index b9e8598e5..df5351315 100644 --- a/certora/specs/WithdrawableMonotonicity.spec +++ b/certora/specs/WithdrawableMonotonicity.spec @@ -10,20 +10,20 @@ methods { function _.onRatify(Midnight.Offer, bytes32, bytes) external => NONDET; } -rule repayIncreasesWithdrawable(env e, Midnight.Obligation obligation, uint256 units, address onBehalf, bytes data) { +rule repayIncreasesWithdrawable(env e, Midnight.Obligation obligation, uint256 units, address onBehalf, address callback, bytes data) { bytes32 id = toId(e, obligation); uint256 withdrawableBefore = withdrawable(id); - repay(e, obligation, units, onBehalf, data); + repay(e, obligation, units, onBehalf, callback, data); uint256 withdrawableAfter = withdrawable(id); assert withdrawableAfter == withdrawableBefore + units; } -rule liquidateIncreasesWithdrawable(env e, Midnight.Obligation obligation, uint256 collateralIndex, uint256 seizedAssets, uint256 repaidUnits, address borrower, bytes data) { +rule liquidateIncreasesWithdrawable(env e, Midnight.Obligation obligation, uint256 collateralIndex, uint256 seizedAssets, uint256 repaidUnits, address borrower, address receiver, address callback, bytes data) { bytes32 id = toId(e, obligation); uint256 withdrawableBefore = withdrawable(id); uint256 seizedResult; uint256 repaidResult; - seizedResult, repaidResult = liquidate(e, obligation, collateralIndex, seizedAssets, repaidUnits, borrower, data); + seizedResult, repaidResult = liquidate(e, obligation, collateralIndex, seizedAssets, repaidUnits, borrower, receiver, callback, data); uint256 withdrawableAfter = withdrawable(id); assert withdrawableAfter == withdrawableBefore + repaidResult; } @@ -47,8 +47,8 @@ rule claimContinuousFeeDecreasesWithdrawableExactly(env e, Midnight.Obligation o rule withdrawableUnchanged(method f, env e, calldataarg args, bytes32 id) filtered { f -> !f.isView - && f.selector != sig:repay(Midnight.Obligation, uint256, address, bytes).selector - && f.selector != sig:liquidate(Midnight.Obligation, uint256, uint256, uint256, address, bytes).selector + && f.selector != sig:repay(Midnight.Obligation, uint256, address, address, bytes).selector + && f.selector != sig:liquidate(Midnight.Obligation, uint256, uint256, uint256, address, address, address, bytes).selector && f.selector != sig:withdraw(Midnight.Obligation, uint256, address, address).selector && f.selector != sig:claimContinuousFee(Midnight.Obligation, uint256, address).selector } { diff --git a/src/Midnight.sol b/src/Midnight.sol index 37684e5a0..6caf9d82f 100644 --- a/src/Midnight.sol +++ b/src/Midnight.sol @@ -102,8 +102,8 @@ import {EventsLib} from "./libraries/EventsLib.sol"; /// @dev If a token sent by Midnight reverts on `transfer` despite balances being right, `withdraw`, /// `withdrawCollateral`, fee claims, the collateral leg of `liquidate`, and `flashLoan` revert when they need to send /// that token. -/// @dev If a callback reverts, or if a buy/sell callback returns something other than `CALLBACK_SUCCESS`, -/// callback-enabled `take`, `repay`, `liquidate`, and `flashLoan` revert. +/// @dev If a callback reverts or returns something other than `CALLBACK_SUCCESS`, `take`, `repay`, `liquidate`, and +/// `flashLoan` revert. /// /// ROLES /// @dev The role setter can set the role setter, fee setter, and fee claimer. @@ -279,33 +279,7 @@ contract Midnight is IMidnight { require(isAuthorized[offer.maker][offer.ratifier], RatifierUnauthorized()); require(IRatifier(offer.ratifier).onRatify(offer, root, ratifierData) == CALLBACK_SUCCESS, RatifierFail()); - ( - address buyer, - address buyerCallback, - bytes memory buyerCallbackData, - address seller, - address sellerCallback, - bytes memory sellerCallbackData, - address receiver - ) = offer.buy - ? ( - offer.maker, - offer.callback, - offer.callbackData, - taker, - takerCallback, - takerCallbackData, - receiverIfTakerIsSeller - ) - : ( - taker, - takerCallback, - takerCallbackData, - offer.maker, - offer.callback, - offer.callbackData, - offer.receiverIfMakerIsSeller - ); + (address buyer, address seller) = offer.buy ? (offer.maker, taker) : (taker, offer.maker); uint256 offerPrice = TickLib.tickToPrice(offer.tick); uint256 timeToMaturity = UtilsLib.zeroFloorSub(offer.obligation.maturity, block.timestamp); @@ -369,6 +343,11 @@ contract Midnight is IMidnight { SellerGatedFromIncreasingDebt() ); + address buyerCallback = offer.buy ? offer.callback : takerCallback; + address sellerCallback = offer.buy ? takerCallback : offer.callback; + address payer = buyerCallback != address(0) ? buyerCallback : (offer.buy ? buyer : msg.sender); + address receiver = offer.buy ? receiverIfTakerIsSeller : offer.receiverIfMakerIsSeller; + emit EventsLib.Take( msg.sender, id, @@ -378,6 +357,7 @@ contract Midnight is IMidnight { buyerAssets, sellerAssets, units, + payer, receiver, offer.group, newConsumed, @@ -389,24 +369,25 @@ contract Midnight is IMidnight { bool wasLocked = UtilsLib.tExchange(LIQUIDATION_LOCK_SLOT, id, seller, true); if (buyerCallback != address(0)) { + bytes memory buyerCallbackData = offer.buy ? offer.callbackData : takerCallbackData; require( IBuyCallback(buyerCallback).onBuy(id, offer.obligation, buyer, buyerAssets, units, buyerCallbackData) == CALLBACK_SUCCESS, - InvalidBuyCallback() + WrongBuyCallbackReturnValue() ); } - address payer = buyerCallback != address(0) ? buyerCallback : (offer.buy ? buyer : msg.sender); SafeTransferLib.safeTransferFrom(offer.obligation.loanToken, payer, address(this), buyerAssets - sellerAssets); claimableTradingFee[offer.obligation.loanToken] += buyerAssets - sellerAssets; SafeTransferLib.safeTransferFrom(offer.obligation.loanToken, payer, receiver, sellerAssets); if (sellerCallback != address(0)) { + bytes memory sellerCallbackData = offer.buy ? takerCallbackData : offer.callbackData; require( ISellCallback(sellerCallback) .onSell(id, offer.obligation, seller, sellerAssets, units, sellerCallbackData) == CALLBACK_SUCCESS, - InvalidSellCallback() + WrongSellCallbackReturnValue() ); } if (!wasLocked) UtilsLib.tExchange(LIQUIDATION_LOCK_SLOT, id, seller, false); @@ -437,20 +418,25 @@ contract Midnight is IMidnight { SafeTransferLib.safeTransfer(obligation.loanToken, receiver, units); } - function repay(Obligation memory obligation, uint256 units, address onBehalf, bytes calldata data) external { + function repay(Obligation memory obligation, uint256 units, address onBehalf, address callback, bytes calldata data) + external + { require(onBehalf == msg.sender || isAuthorized[onBehalf][msg.sender], Unauthorized()); bytes32 id = touchObligation(obligation); position[id][onBehalf].debt -= UtilsLib.toUint128(units); obligationState[id].withdrawable += UtilsLib.toUint128(units); - emit EventsLib.Repay(msg.sender, id, units, onBehalf); + address payer = callback != address(0) ? callback : msg.sender; + emit EventsLib.Repay(msg.sender, id, units, onBehalf, payer); - if (data.length > 0) { - IRepayCallback(msg.sender).onRepay(id, obligation, units, onBehalf, data); + if (callback != address(0)) { + require( + IRepayCallback(callback).onRepay(id, obligation, units, onBehalf, data) == CALLBACK_SUCCESS, + WrongRepayCallbackReturnValue() + ); } - - SafeTransferLib.safeTransferFrom(obligation.loanToken, msg.sender, address(this), units); + SafeTransferLib.safeTransferFrom(obligation.loanToken, payer, address(this), units); } /// @dev This function checks authorization to prevent activated collateral poisoning. @@ -519,6 +505,8 @@ contract Midnight is IMidnight { uint256 seizedAssets, uint256 repaidUnits, address borrower, + address receiver, + address callback, bytes calldata data ) external returns (uint256, uint256) { bytes32 id = touchObligation(obligation); @@ -612,6 +600,8 @@ contract Midnight is IMidnight { _position.debt -= UtilsLib.toUint128(repaidUnits); } + address payer = callback != address(0) ? callback : msg.sender; + emit EventsLib.Liquidate( msg.sender, id, @@ -620,17 +610,23 @@ contract Midnight is IMidnight { repaidUnits, borrower, badDebt, - _obligationState.lossIndex + _obligationState.lossIndex, + payer, + receiver ); - SafeTransferLib.safeTransfer(obligation.collateralParams[collateralIndex].token, msg.sender, seizedAssets); + SafeTransferLib.safeTransfer(obligation.collateralParams[collateralIndex].token, receiver, seizedAssets); - if (data.length > 0) { - ILiquidateCallback(msg.sender) - .onLiquidate(id, obligation, collateralIndex, seizedAssets, repaidUnits, borrower, data); + if (callback != address(0)) { + require( + ILiquidateCallback(callback) + .onLiquidate(id, obligation, collateralIndex, seizedAssets, repaidUnits, borrower, data) + == CALLBACK_SUCCESS, + WrongLiquidateCallbackReturnValue() + ); } - SafeTransferLib.safeTransferFrom(obligation.loanToken, msg.sender, address(this), repaidUnits); + SafeTransferLib.safeTransferFrom(obligation.loanToken, payer, address(this), repaidUnits); return (seizedAssets, repaidUnits); } @@ -659,10 +655,13 @@ contract Midnight is IMidnight { } function flashLoan(address token, uint256 assets, address callback, bytes calldata data) external { - emit EventsLib.FlashLoan(msg.sender, token, assets); - SafeTransferLib.safeTransfer(token, msg.sender, assets); - IFlashLoanCallback(callback).onFlashLoan(token, assets, data); - SafeTransferLib.safeTransferFrom(token, msg.sender, address(this), assets); + emit EventsLib.FlashLoan(msg.sender, token, assets, callback); + SafeTransferLib.safeTransfer(token, callback, assets); + require( + IFlashLoanCallback(callback).onFlashLoan(token, assets, data) == CALLBACK_SUCCESS, + WrongFlashLoanCallbackReturnValue() + ); + SafeTransferLib.safeTransferFrom(token, callback, address(this), assets); } /// @dev Returns the obligation id and creates the obligation if it doesn't exist yet. diff --git a/src/interfaces/ICallbacks.sol b/src/interfaces/ICallbacks.sol index 204b3b86b..121dd8333 100644 --- a/src/interfaces/ICallbacks.sol +++ b/src/interfaces/ICallbacks.sol @@ -14,14 +14,14 @@ interface ISellCallback { } interface ILiquidateCallback { - function onLiquidate(bytes32 id, Obligation memory obligation, uint256 collateralIndex, uint256 seizedAssets, uint256 repaidUnits, address borrower, bytes memory data) external; + function onLiquidate(bytes32 id, Obligation memory obligation, uint256 collateralIndex, uint256 seizedAssets, uint256 repaidUnits, address borrower, bytes memory data) external returns (bytes32); } interface IRepayCallback { - function onRepay(bytes32 obligationId, Obligation memory obligation, uint256 units, address onBehalf, bytes memory data) external; + function onRepay(bytes32 id, Obligation memory obligation, uint256 units, address onBehalf, bytes memory data) external returns (bytes32); } -// forgefmt: disable-end interface IFlashLoanCallback { - function onFlashLoan(address token, uint256 amount, bytes memory data) external; + function onFlashLoan(address token, uint256 assets, bytes memory data) external returns (bytes32); } +// forgefmt: disable-end diff --git a/src/interfaces/IMidnight.sol b/src/interfaces/IMidnight.sol index 748eff342..338b32ca8 100644 --- a/src/interfaces/IMidnight.sol +++ b/src/interfaces/IMidnight.sol @@ -75,8 +75,11 @@ interface IMidnight { error ContinuousFeeTooHigh(); error FeeNotMultipleOfFeeStep(); error InconsistentInput(); - error InvalidBuyCallback(); - error InvalidSellCallback(); + error WrongBuyCallbackReturnValue(); + error WrongSellCallbackReturnValue(); + error WrongRepayCallbackReturnValue(); + error WrongLiquidateCallbackReturnValue(); + error WrongFlashLoanCallbackReturnValue(); error InvalidFeeIndex(); error InvalidMaxLif(); error InvalidProof(); @@ -137,10 +140,10 @@ interface IMidnight { /// ENTRY-POINTS /// function take(uint256 units, address taker, address takerCallback, bytes memory takerCallbackData, address receiverIfTakerIsSeller, Offer memory offer, bytes memory ratifierData, bytes32 root, bytes32[] memory proof) external returns (uint256, uint256, uint256); function withdraw(Obligation memory obligation, uint256 units, address onBehalf, address receiver) external; - function repay(Obligation memory obligation, uint256 units, address onBehalf, bytes calldata data) external; + function repay(Obligation memory obligation, uint256 units, address onBehalf, address callback, bytes calldata data) external; function supplyCollateral(Obligation memory obligation, uint256 collateralIndex, uint256 assets, address onBehalf) external; function withdrawCollateral(Obligation memory obligation, uint256 collateralIndex, uint256 assets, address onBehalf, address receiver) external; - function liquidate(Obligation calldata obligation, uint256 collateralIndex, uint256 seizedAssets, uint256 repaidUnits, address borrower, bytes calldata data) external returns (uint256, uint256); + function liquidate(Obligation calldata obligation, uint256 collateralIndex, uint256 seizedAssets, uint256 repaidUnits, address borrower, address receiver, address callback, bytes calldata data) external returns (uint256, uint256); function setConsumed(bytes32 group, uint256 amount, address onBehalf) external; function shuffleSession(address onBehalf) external; function setIsAuthorized(address onBehalf, address authorized, bool newIsAuthorized) external; diff --git a/src/libraries/EventsLib.sol b/src/libraries/EventsLib.sol index a885dd5aa..d53874511 100644 --- a/src/libraries/EventsLib.sol +++ b/src/libraries/EventsLib.sol @@ -17,15 +17,15 @@ library EventsLib { event SetDefaultContinuousFee(address indexed loanToken, uint256 newContinuousFee); event UpdatePosition(bytes32 indexed id_, address indexed user, uint256 creditDecrease, uint256 pendingFeeDecrease, uint256 accruedFee); event ObligationCreated(bytes32 indexed id_, Obligation obligation); - event Take(address caller, bytes32 indexed id_, address indexed maker, address indexed taker, bool offerIsBuy, uint256 buyerAssets, uint256 sellerAssets, uint256 units, address sellerReceiver, bytes32 group, uint256 consumed, uint256 buyerPendingFeeIncrease, uint256 sellerPendingFeeDecrease, uint256 buyerCreditIncrease, uint256 sellerCreditDecrease); + event Take(address caller, bytes32 indexed id_, address indexed maker, address indexed taker, bool offerIsBuy, uint256 buyerAssets, uint256 sellerAssets, uint256 units, address buyerCallback, address sellerReceiver, bytes32 group, uint256 consumed, uint256 buyerPendingFeeIncrease, uint256 sellerPendingFeeDecrease, uint256 buyerCreditIncrease, uint256 sellerCreditDecrease); event Withdraw(address caller, bytes32 indexed id_, uint256 units, address indexed onBehalf, address indexed receiver, uint256 pendingFeeDecrease); - event Repay(address indexed caller, bytes32 indexed id_, uint256 units, address indexed onBehalf); + event Repay(address indexed caller, bytes32 indexed id_, uint256 units, address indexed onBehalf, address callback); event SupplyCollateral(address caller, bytes32 indexed id_, address indexed collateral, uint256 assets, address indexed onBehalf); event WithdrawCollateral(address caller, bytes32 indexed id_, address indexed collateral, uint256 assets, address indexed onBehalf, address receiver); - event Liquidate(address caller, bytes32 indexed id_, address indexed collateral, uint256 seizedAssets, uint256 repaidUnits, address indexed borrower, uint256 badDebt, uint256 latestLossIndex); + event Liquidate(address caller, bytes32 indexed id_, address indexed collateral, uint256 seizedAssets, uint256 repaidUnits, address indexed borrower, uint256 badDebt, uint256 latestLossIndex, address payer, address receiver); event SetConsumed(address indexed caller, address indexed onBehalf, bytes32 indexed group, uint256 amount); event ShuffleSession(address indexed caller, address indexed onBehalf, bytes32 session); - event FlashLoan(address indexed caller, address indexed token, uint256 assets); + event FlashLoan(address indexed caller, address indexed token, uint256 assets, address callback); event SetIsAuthorized(address indexed caller, address indexed onBehalf, address indexed authorized, bool newIsAuthorized); event ClaimContinuousFee(address indexed caller, bytes32 indexed id_, uint256 amount, address indexed receiver); event ClaimTradingFee(address indexed caller, address indexed token, uint256 amount, address indexed receiver); diff --git a/test/AuthorizationTest.sol b/test/AuthorizationTest.sol index 1e94a1552..86ff9aadc 100644 --- a/test/AuthorizationTest.sol +++ b/test/AuthorizationTest.sol @@ -58,7 +58,7 @@ contract AuthorizationTest is BaseTest { skip(99); deal(address(loanToken), borrower, units); vm.prank(borrower); - midnight.repay(obligation, units, borrower, hex""); + midnight.repay(obligation, units, borrower, address(0), hex""); // Attacker tries to withdraw lender's units address attacker = makeAddr("attacker"); @@ -95,7 +95,7 @@ contract AuthorizationTest is BaseTest { skip(99); deal(address(loanToken), borrower, units); vm.prank(borrower); - midnight.repay(obligation, units, borrower, hex""); + midnight.repay(obligation, units, borrower, address(0), hex""); // Lender authorizes operator address operator = makeAddr("operator"); @@ -167,7 +167,7 @@ contract AuthorizationTest is BaseTest { skip(99); deal(address(loanToken), borrower, units); vm.prank(borrower); - midnight.repay(obligation, units, borrower, hex""); + midnight.repay(obligation, units, borrower, address(0), hex""); // Lender can withdraw their own units (no authorization needed) vm.prank(lender); @@ -264,13 +264,13 @@ contract AuthorizationTest is BaseTest { vm.prank(authorized); vm.expectRevert(IMidnight.Unauthorized.selector); - midnight.repay(obligation, units, borrower, hex""); + midnight.repay(obligation, units, borrower, address(0), hex""); vm.prank(borrower); midnight.setIsAuthorized(borrower, authorized, true); vm.prank(authorized); - midnight.repay(obligation, units, borrower, hex""); + midnight.repay(obligation, units, borrower, address(0), hex""); assertEq(midnight.debtOf(id, borrower), 0); } diff --git a/test/BaseTest.sol b/test/BaseTest.sol index aeab71ee6..86cd2e797 100644 --- a/test/BaseTest.sol +++ b/test/BaseTest.sol @@ -202,13 +202,13 @@ abstract contract BaseTest is Test { take(100, unluckyLender, badBorrowerOffer); Oracle(obligation.collateralParams[0].oracle).setPrice(ORACLE_PRICE_SCALE / 4); - midnight.liquidate(obligation, 0, 0, 0, badBorrower, ""); + midnight.liquidate(obligation, 0, 0, 0, badBorrower, address(this), address(0), ""); // then empty the market (borrow side only). vm.prank(badBorrower); midnight.setIsAuthorized(badBorrower, address(this), true); deal(address(loanToken), address(this), midnight.debtOf(toId(obligation), badBorrower)); - midnight.repay(obligation, midnight.debtOf(toId(obligation), badBorrower), badBorrower, hex""); + midnight.repay(obligation, midnight.debtOf(toId(obligation), badBorrower), badBorrower, address(0), hex""); assertEq(midnight.debtOf(toId(obligation), badBorrower), 0, "debt"); // reset the price. diff --git a/test/ContinuousFeeTest.sol b/test/ContinuousFeeTest.sol index 2fefb5cdf..b43bd18ff 100644 --- a/test/ContinuousFeeTest.sol +++ b/test/ContinuousFeeTest.sol @@ -284,6 +284,7 @@ contract ContinuousFeeTest is BaseTest { takeAssets, takeAssets, exitAmount, + otherLender, lender, keccak256("lender-exit"), exitAmount, @@ -331,7 +332,7 @@ contract ContinuousFeeTest is BaseTest { deal(address(loanToken), borrower, credit); vm.prank(borrower); - midnight.repay(obligation, credit, borrower, hex""); + midnight.repay(obligation, credit, borrower, address(0), hex""); uint256 pendingFeeDecrease = creditAfterAccrual > 0 ? remainingAfterAccrual.mulDivUp(withdrawAmount, creditAfterAccrual) : 0; @@ -419,7 +420,7 @@ contract ContinuousFeeTest is BaseTest { // Repay so withdrawable covers the claim. deal(address(loanToken), borrower, credit); vm.prank(borrower); - midnight.repay(obligation, credit, borrower, hex""); + midnight.repay(obligation, credit, borrower, address(0), hex""); address receiver = makeAddr("receiver"); uint256 totalUnitsBefore = midnight.totalUnits(id); diff --git a/test/FlashloanTest.sol b/test/FlashloanTest.sol index c62dba4f8..de3ff1176 100644 --- a/test/FlashloanTest.sol +++ b/test/FlashloanTest.sol @@ -5,6 +5,7 @@ pragma solidity ^0.8.0; import {BaseTest} from "./BaseTest.sol"; import {SafeTransferLib} from "../src/libraries/SafeTransferLib.sol"; import {IFlashLoanCallback} from "../src/interfaces/ICallbacks.sol"; +import {CALLBACK_SUCCESS} from "../src/libraries/ConstantsLib.sol"; contract FlashLoanTest is BaseTest, IFlashLoanCallback { uint256 internal amountStored; @@ -35,10 +36,11 @@ contract FlashLoanTest is BaseTest, IFlashLoanCallback { midnight.flashLoan(address(loanToken), amount, address(this), data); } - function onFlashLoan(address token, uint256 amount, bytes memory data) external { + function onFlashLoan(address token, uint256 amount, bytes memory data) external returns (bytes32) { assertEq(token, address(loanToken), "wrong token"); assertEq(amount, amountStored, "wrong amount"); assertEq(data, dataStored, "wrong data"); if (discardToken) SafeTransferLib.safeTransfer(token, address(0xdead), amount); + return CALLBACK_SUCCESS; } } diff --git a/test/GateTest.sol b/test/GateTest.sol index cc3923e38..242e7f530 100644 --- a/test/GateTest.sol +++ b/test/GateTest.sol @@ -239,7 +239,7 @@ contract GateTest is BaseTest { deal(address(loanToken), borrower, units); vm.prank(borrower); - midnight.repay(gatedObligation, units, borrower, hex""); + midnight.repay(gatedObligation, units, borrower, address(0), hex""); assertEq(midnight.debtOf(gatedId, borrower), 0, "borrower should have repaid"); } @@ -254,7 +254,7 @@ contract GateTest is BaseTest { deal(address(loanToken), borrower, units); vm.prank(borrower); - midnight.repay(gatedObligation, units, borrower, hex""); + midnight.repay(gatedObligation, units, borrower, address(0), hex""); gate.setWhitelisted(lender, false); @@ -280,7 +280,7 @@ contract GateTest is BaseTest { deal(address(loanToken), liquidator, units); vm.prank(liquidator); if (!isWhitelisted) vm.expectRevert(IMidnight.LiquidatorGatedFromLiquidating.selector); - midnight.liquidate(gatedObligation, 0, 1, 0, borrower, ""); + midnight.liquidate(gatedObligation, 0, 1, 0, borrower, address(this), address(0), ""); } function testLiquidatorGateOnBadDebt(uint256 units, bool isWhitelisted) public { @@ -296,7 +296,7 @@ contract GateTest is BaseTest { vm.prank(liquidator); if (!isWhitelisted) vm.expectRevert(IMidnight.LiquidatorGatedFromLiquidating.selector); - midnight.liquidate(gatedObligation, 0, 0, 0, borrower, ""); + midnight.liquidate(gatedObligation, 0, 0, 0, borrower, address(this), address(0), ""); } // --- Default (no gate) tests --- diff --git a/test/LiquidationTest.sol b/test/LiquidationTest.sol index fa04abb18..aa91fb079 100644 --- a/test/LiquidationTest.sol +++ b/test/LiquidationTest.sol @@ -7,7 +7,8 @@ import { ORACLE_PRICE_SCALE, TIME_TO_MAX_LIF, LLTV_8, - LIQUIDATION_CURSOR_LOW + LIQUIDATION_CURSOR_LOW, + CALLBACK_SUCCESS } from "../src/libraries/ConstantsLib.sol"; import {IMidnight, Obligation, CollateralParams} from "../src/interfaces/IMidnight.sol"; import {IdLib} from "../src/libraries/IdLib.sol"; @@ -70,7 +71,7 @@ contract LiquidationTest is BaseTest { Oracle(obligation.collateralParams[0].oracle).setPrice(1e36 - 1); vm.expectRevert(stdError.indexOOBError); - midnight.liquidate(obligation, 2, 0, 0, borrower, ""); + midnight.liquidate(obligation, 2, 0, 0, borrower, address(this), address(0), ""); } function testLiquidateInactiveCollateralIndex(uint256 units) public { @@ -82,13 +83,13 @@ contract LiquidationTest is BaseTest { assertEq(midnight.collateral(id, borrower, 1), 0); vm.expectRevert(); - midnight.liquidate(obligation, 1, 0, 1, borrower, ""); + midnight.liquidate(obligation, 1, 0, 1, borrower, address(this), address(0), ""); vm.expectRevert(); - midnight.liquidate(obligation, 1, 1, 0, borrower, ""); + midnight.liquidate(obligation, 1, 1, 0, borrower, address(this), address(0), ""); uint256 collatBefore = midnight.collateral(id, borrower, 0); - midnight.liquidate(obligation, 1, 0, 0, borrower, ""); + midnight.liquidate(obligation, 1, 0, 0, borrower, address(this), address(0), ""); assertEq(midnight.debtOf(id, borrower), 0); assertEq(midnight.collateral(id, borrower, 0), collatBefore); assertEq(midnight.collateral(id, borrower, 1), 0); @@ -102,7 +103,7 @@ contract LiquidationTest is BaseTest { Oracle(obligation.collateralParams[0].oracle).setPrice(liquidationOraclePrice); vm.expectRevert(IMidnight.NotLiquidatable.selector); - midnight.liquidate(obligation, 0, 0, 0, borrower, ""); + midnight.liquidate(obligation, 0, 0, 0, borrower, address(this), address(0), ""); } function testLiquidateUnhealthyPreMaturity(uint256 units, uint256 liquidationOraclePrice) public { @@ -112,7 +113,7 @@ contract LiquidationTest is BaseTest { setupObligation(obligation, units); Oracle(obligation.collateralParams[0].oracle).setPrice(liquidationOraclePrice); - midnight.liquidate(obligation, 0, 0, 0, borrower, ""); + midnight.liquidate(obligation, 0, 0, 0, borrower, address(this), address(0), ""); } function testLiquidateHealthyPostMaturity(uint256 units, uint256 liquidationOraclePrice) public { @@ -123,7 +124,7 @@ contract LiquidationTest is BaseTest { Oracle(obligation.collateralParams[0].oracle).setPrice(liquidationOraclePrice); vm.warp(obligation.maturity + 1); - midnight.liquidate(obligation, 0, 0, 0, borrower, ""); + midnight.liquidate(obligation, 0, 0, 0, borrower, address(this), address(0), ""); } function testLiquidateUnhealthyPostMaturity(uint256 units, uint256 liquidationOraclePrice) public { @@ -134,7 +135,7 @@ contract LiquidationTest is BaseTest { vm.warp(obligation.maturity + 1); Oracle(obligation.collateralParams[0].oracle).setPrice(liquidationOraclePrice); - midnight.liquidate(obligation, 0, 0, 0, borrower, ""); + midnight.liquidate(obligation, 0, 0, 0, borrower, address(this), address(0), ""); } function testLiquidateInconsistentInput(uint256 units) public { @@ -143,7 +144,7 @@ contract LiquidationTest is BaseTest { setupObligation(obligation, units); vm.expectRevert(IMidnight.InconsistentInput.selector); - midnight.liquidate(obligation, 0, 1, 1, borrower, ""); + midnight.liquidate(obligation, 0, 1, 1, borrower, address(this), address(0), ""); } function testLiquidateUnitsInput(uint256 units, uint256 repaid, uint256 liquidationOraclePrice) public { @@ -156,7 +157,8 @@ contract LiquidationTest is BaseTest { Oracle(obligation.collateralParams[0].oracle).setPrice(liquidationOraclePrice); vm.warp(obligation.maturity + TIME_TO_MAX_LIF); // Warp to post-maturity to bypass recovery close factor. - (uint256 seizedAssets, uint256 repaidUnits) = midnight.liquidate(obligation, 0, 0, repaid, borrower, ""); + (uint256 seizedAssets, uint256 repaidUnits) = + midnight.liquidate(obligation, 0, 0, repaid, borrower, address(this), address(0), ""); assertEq(repaidUnits, repaid, "repaid units"); assertEq( @@ -188,7 +190,8 @@ contract LiquidationTest is BaseTest { Oracle(obligation.collateralParams[0].oracle).setPrice(liquidationOraclePrice); vm.warp(obligation.maturity + TIME_TO_MAX_LIF); // Warp to post-maturity to bypass recovery close factor. - (uint256 seizedAssets, uint256 repaidUnits) = midnight.liquidate(obligation, 0, seized, 0, borrower, ""); + (uint256 seizedAssets, uint256 repaidUnits) = + midnight.liquidate(obligation, 0, seized, 0, borrower, address(this), address(0), ""); assertEq( repaidUnits, @@ -214,7 +217,7 @@ contract LiquidationTest is BaseTest { Oracle(obligation.collateralParams[0].oracle).setPrice(liquidationOraclePrice); vm.warp(obligation.maturity + TIME_TO_MAX_LIF); // Warp to post-maturity to bypass recovery close factor. - midnight.liquidate(obligation, 0, 0, repaid, borrower, data); + midnight.liquidate(obligation, 0, 0, repaid, borrower, address(this), address(this), data); assertEq(recordedRepaidUnits, repaid, "repaid units"); assertEq(recordedData, data, "data"); @@ -239,7 +242,7 @@ contract LiquidationTest is BaseTest { repaid = bound(repaid, units + 1, max(maxRepaid, units + 1)); vm.expectRevert(stdError.arithmeticError); - midnight.liquidate(obligation, 0, 0, repaid, borrower, ""); + midnight.liquidate(obligation, 0, 0, repaid, borrower, address(this), address(0), ""); } function testCannotSeizeMoreThanCollateral(uint256 units, uint256 seized, uint256 liquidationOraclePrice) public { @@ -252,7 +255,7 @@ contract LiquidationTest is BaseTest { Oracle(obligation.collateralParams[0].oracle).setPrice(liquidationOraclePrice); vm.expectRevert(stdError.arithmeticError); - midnight.liquidate(obligation, 0, seized, 0, borrower, ""); + midnight.liquidate(obligation, 0, seized, 0, borrower, address(this), address(0), ""); } function testBadDebtPriceDownGivesBadDebt(uint256 units) public { @@ -283,7 +286,7 @@ contract LiquidationTest is BaseTest { Oracle(obligation.collateralParams[0].oracle).setPrice(liquidationOraclePrice); uint256 expectedBadDebt = _badDebt(); - midnight.liquidate(obligation, 0, 0, 0, borrower, ""); + midnight.liquidate(obligation, 0, 0, 0, borrower, address(this), address(0), ""); assertEq(midnight.debtOf(id, borrower), units - expectedBadDebt, "debt"); assertEq(midnight.totalUnits(id), units - expectedBadDebt, "total units"); @@ -308,9 +311,18 @@ contract LiquidationTest is BaseTest { vm.expectEmit(true, true, true, true); emit EventsLib.Liquidate( - address(this), id, obligation.collateralParams[0].token, 0, 0, borrower, expectedBadDebt, expectedLossIndex + address(this), + id, + obligation.collateralParams[0].token, + 0, + 0, + borrower, + expectedBadDebt, + expectedLossIndex, + address(this), + address(this) ); - midnight.liquidate(obligation, 0, 0, 0, borrower, ""); + midnight.liquidate(obligation, 0, 0, 0, borrower, address(this), address(0), ""); } function testSlashNonFull(uint256 units) public { @@ -319,7 +331,7 @@ contract LiquidationTest is BaseTest { setupObligation(obligation, units); Oracle(obligation.collateralParams[0].oracle).setPrice(badDebtPriceDown(units)); - midnight.liquidate(obligation, 0, 0, 0, borrower, ""); + midnight.liquidate(obligation, 0, 0, 0, borrower, address(this), address(0), ""); uint256 lossIndex = midnight.lossIndex(id); uint256 expectedCredit = units.mulDivDown(type(uint128).max - lossIndex, type(uint128).max); @@ -341,7 +353,7 @@ contract LiquidationTest is BaseTest { Oracle(obligation.collateralParams[0].oracle).setPrice(liquidationOraclePrice); uint256 debtAfterBadDebt = units - _badDebt(); - (, uint256 repaid) = midnight.liquidate(obligation, 0, seized, 0, borrower, ""); + (, uint256 repaid) = midnight.liquidate(obligation, 0, seized, 0, borrower, address(this), address(0), ""); assertEq(midnight.debtOf(id, borrower), debtAfterBadDebt - repaid, "debt"); assertEq(midnight.totalUnits(id), debtAfterBadDebt, "total units"); @@ -363,7 +375,7 @@ contract LiquidationTest is BaseTest { .mulDivDown(liquidationOraclePrice, ORACLE_PRICE_SCALE).mulDivDown(WAD, lif0); repaid = bound(repaid, 0, UtilsLib.min(UtilsLib.min(maxRepaid, debtAfterBadDebt), maxRepaidFromCollat)); - midnight.liquidate(obligation, 0, 0, repaid, borrower, ""); + midnight.liquidate(obligation, 0, 0, repaid, borrower, address(this), address(0), ""); assertEq(midnight.debtOf(id, borrower), debtAfterBadDebt - repaid, "debt"); assertEq(midnight.totalUnits(id), debtAfterBadDebt, "total units"); @@ -380,7 +392,9 @@ contract LiquidationTest is BaseTest { setupObligation(obligation, units); Oracle(obligation.collateralParams[0].oracle).setPrice(liquidationOraclePrice); - midnight.liquidate(obligation, 0, midnight.collateral(id, borrower, 0), 0, borrower, ""); + midnight.liquidate( + obligation, 0, midnight.collateral(id, borrower, 0), 0, borrower, address(this), address(0), "" + ); assertApproxEqAbs(midnight.debtOf(id, borrower), 0, 1e3, "almost all remaining debt repaid"); assertApproxEqAbs( @@ -411,7 +425,7 @@ contract LiquidationTest is BaseTest { uint256 initialCollateral = midnight.collateral(id, borrower, 0); - midnight.liquidate(obligation, 0, 0, repaid, borrower, ""); + midnight.liquidate(obligation, 0, 0, repaid, borrower, address(this), address(0), ""); assertEq(midnight.debtOf(id, borrower), units - repaid, "debt"); assertEq( @@ -440,7 +454,7 @@ contract LiquidationTest is BaseTest { uint256 initialCollateral = midnight.collateral(id, borrower, 0); - midnight.liquidate(obligation, 0, 0, repaid, borrower, ""); + midnight.liquidate(obligation, 0, 0, repaid, borrower, address(this), address(0), ""); uint256 lif = WAD + (obligation.collateralParams[0].maxLif - WAD) * delay / TIME_TO_MAX_LIF; @@ -464,10 +478,10 @@ contract LiquidationTest is BaseTest { repaid = bound(repaid, maxR + 1, max(units, maxR + 1)); vm.expectRevert(IMidnight.RecoveryCloseFactorConditionsViolated.selector); - midnight.liquidate(obligation, 0, 0, repaid, borrower, ""); + midnight.liquidate(obligation, 0, 0, repaid, borrower, address(this), address(0), ""); repaid = bound(repaid, 0, min(maxR, units)); - midnight.liquidate(obligation, 0, 0, repaid, borrower, ""); + midnight.liquidate(obligation, 0, 0, repaid, borrower, address(this), address(0), ""); } function testMaxRepaidMeansRecovery(uint256 units, uint256 liquidationOraclePrice) public { @@ -478,7 +492,7 @@ contract LiquidationTest is BaseTest { uint256 maxR = _maxRepaid(units, units, liquidationOraclePrice); - midnight.liquidate(obligation, 0, 0, min(maxR, units), borrower, ""); + midnight.liquidate(obligation, 0, 0, min(maxR, units), borrower, address(this), address(0), ""); uint256 remainingCollateral = midnight.collateral(id, borrower, 0); uint256 remainingDebt = midnight.debtOf(id, borrower); @@ -509,7 +523,7 @@ contract LiquidationTest is BaseTest { Oracle(obligation.collateralParams[0].oracle).setPrice(liquidationOraclePrice); // Full liquidation should succeed because remaining debt < rcfThreshold. - midnight.liquidate(obligation, 0, 0, units, borrower, ""); + midnight.liquidate(obligation, 0, 0, units, borrower, address(this), address(0), ""); assertEq(midnight.debtOf(toId(obligation), borrower), 0, "debt should be zero"); } @@ -537,7 +551,7 @@ contract LiquidationTest is BaseTest { // Full liquidation should revert because remaining debt >= rcfThreshold. vm.expectRevert(IMidnight.RecoveryCloseFactorConditionsViolated.selector); - midnight.liquidate(obligation, 0, 0, units, borrower, ""); + midnight.liquidate(obligation, 0, 0, units, borrower, address(this), address(0), ""); } /// @dev Recovery close factor applies at exact maturity but not one second after. @@ -553,12 +567,12 @@ contract LiquidationTest is BaseTest { if (maxRepaid < units) { vm.warp(obligation.maturity); vm.expectRevert(IMidnight.RecoveryCloseFactorConditionsViolated.selector); - midnight.liquidate(obligation, 0, 0, units, borrower, ""); + midnight.liquidate(obligation, 0, 0, units, borrower, address(this), address(0), ""); } // One second later: recovery close factor no longer applies. vm.warp(obligation.maturity + 1); - midnight.liquidate(obligation, 0, 0, units, borrower, ""); + midnight.liquidate(obligation, 0, 0, units, borrower, address(this), address(0), ""); assertEq(midnight.debtOf(id, borrower), 0); } @@ -612,9 +626,9 @@ contract LiquidationTest is BaseTest { uint256 collateralNeededToRepayAll = units.mulDivDown(obligation.collateralParams[0].maxLif, WAD); if (collateralNeededToRepayAll <= collateral1) { - midnight.liquidate(obligation, 0, 0, units, borrower, ""); + midnight.liquidate(obligation, 0, 0, units, borrower, address(this), address(0), ""); } else { - midnight.liquidate(obligation, 0, collateral1, 0, borrower, ""); + midnight.liquidate(obligation, 0, collateral1, 0, borrower, address(this), address(0), ""); } uint256 debtAfter = midnight.debtOf(id, borrower); @@ -664,7 +678,7 @@ contract LiquidationTest is BaseTest { WAD - obligation.collateralParams[liqIdx].maxLif.mulDivUp(obligation.collateralParams[liqIdx].lltv, WAD) ); - midnight.liquidate(obligation, liqIdx, 0, maxR, borrower, ""); + midnight.liquidate(obligation, liqIdx, 0, maxR, borrower, address(this), address(0), ""); } // gas tests @@ -698,7 +712,8 @@ contract LiquidationTest is BaseTest { // Multicall with 1 liquidation. bytes[] memory calls1 = new bytes[](1); - calls1[0] = abi.encodeCall(midnight.liquidate, (obligation, 0, 0, repay, borrower, "")); + calls1[0] = + abi.encodeCall(midnight.liquidate, (obligation, 0, 0, repay, borrower, address(this), address(0), "")); uint256 gasBefore1 = gasleft(); midnight.multicall(calls1); uint256 gas1 = gasBefore1 - gasleft(); @@ -706,8 +721,10 @@ contract LiquidationTest is BaseTest { // Multicall with 2 liquidations. bytes[] memory calls2 = new bytes[](2); - calls2[0] = abi.encodeCall(midnight.liquidate, (obligation, 0, 0, repay, borrower, "")); - calls2[1] = abi.encodeCall(midnight.liquidate, (obligation, 1, 0, repay, borrower, "")); + calls2[0] = + abi.encodeCall(midnight.liquidate, (obligation, 0, 0, repay, borrower, address(this), address(0), "")); + calls2[1] = + abi.encodeCall(midnight.liquidate, (obligation, 1, 0, repay, borrower, address(this), address(0), "")); uint256 gasBefore2 = gasleft(); midnight.multicall(calls2); uint256 gas2 = gasBefore2 - gasleft(); @@ -736,7 +753,7 @@ contract LiquidationTest is BaseTest { setupObligation(obligation, units); Oracle(obligation.collateralParams[0].oracle).setPrice(badDebtPriceDown(units)); - midnight.liquidate(obligation, 0, 0, 0, borrower, ""); + midnight.liquidate(obligation, 0, 0, 0, borrower, address(this), address(0), ""); assertEq(midnight.creditOf(id, borrower), 0, "no credit before"); uint256 debtBefore = midnight.debtOf(id, borrower); @@ -756,7 +773,7 @@ contract LiquidationTest is BaseTest { setupObligation(obligation, units); Oracle(obligation.collateralParams[0].oracle).setPrice(badDebtPriceDown(units)); - midnight.liquidate(obligation, 0, 0, 0, borrower, ""); + midnight.liquidate(obligation, 0, 0, 0, borrower, address(this), address(0), ""); uint256 creditBeforeSlash = midnight.creditOf(id, lender); midnight.updatePosition(obligation, lender); @@ -778,7 +795,7 @@ contract LiquidationTest is BaseTest { setupObligation(obligation, units); Oracle(obligation.collateralParams[0].oracle).setPrice(0); - midnight.liquidate(obligation, 0, 0, 0, borrower, ""); + midnight.liquidate(obligation, 0, 0, 0, borrower, address(this), address(0), ""); assertEq(midnight.debtOf(id, borrower), 0, "debt"); assertEq(midnight.totalUnits(id), 0, "total units"); @@ -877,7 +894,7 @@ contract LiquidationTest is BaseTest { uint256 debtBefore = midnight.debtOf(id, borrower); // Non-zero seizedAssets exercises the recovery close factor path. - midnight.liquidate(obligation, 0, 1, 0, borrower, ""); + midnight.liquidate(obligation, 0, 1, 0, borrower, address(this), address(0), ""); assertLt(midnight.debtOf(id, borrower), debtBefore, "debt should decrease after liquidation"); } @@ -917,9 +934,10 @@ contract LiquidationTest is BaseTest { uint256 _repaidUnits, address, bytes memory data - ) public { + ) public returns (bytes32) { require(_id == IdLib.toId(_obligation, block.chainid, msg.sender), "wrong id"); recordedRepaidUnits = _repaidUnits; recordedData = data; + return CALLBACK_SUCCESS; } } diff --git a/test/OtherFunctionsTest.sol b/test/OtherFunctionsTest.sol index cecfa96dc..a5eba56e8 100644 --- a/test/OtherFunctionsTest.sol +++ b/test/OtherFunctionsTest.sol @@ -23,7 +23,8 @@ import { MAX_CONTINUOUS_FEE, WAD, ORACLE_PRICE_SCALE, - TIME_TO_MAX_LIF + TIME_TO_MAX_LIF, + CALLBACK_SUCCESS } from "../src/libraries/ConstantsLib.sol"; import {UtilsLib} from "../src/libraries/UtilsLib.sol"; @@ -121,7 +122,7 @@ contract OtherFunctionsTest is BaseTest { deal(address(loanToken), address(borrower), repaid); vm.prank(borrower); - midnight.repay(obligation, repaid, borrower, hex""); + midnight.repay(obligation, repaid, borrower, address(0), hex""); assertEq(midnight.debtOf(id, borrower), units - repaid); assertEq(midnight.withdrawable(id), repaid); @@ -534,7 +535,7 @@ contract OtherFunctionsTest is BaseTest { vm.warp(_obligation.maturity + TIME_TO_MAX_LIF); deal(address(loanToken), address(this), 1e18); - midnight.liquidate(_obligation, collateralIndex, 1e18, 0, borrower, ""); + midnight.liquidate(_obligation, collateralIndex, 1e18, 0, borrower, address(this), address(0), ""); uint128 bitmap = midnight.activatedCollaterals(_id, borrower); assertEq(UtilsLib.countBits(bitmap), numCollaterals - 1, "one bit cleared"); @@ -684,7 +685,7 @@ contract RepayCallback { external { ERC20(obligation.loanToken).approve(address(midnight), units); - midnight.repay(obligation, units, onBehalf, data); + midnight.repay(obligation, units, onBehalf, address(this), data); } function onRepay( @@ -693,11 +694,12 @@ contract RepayCallback { uint256 units, address onBehalf, bytes memory data - ) external { + ) external returns (bytes32) { require(obligationId == IdLib.toId(obligation, block.chainid, msg.sender), "wrong obligationId"); recordedObligationId = obligationId; recordedData = data; recordedUnits = units; recordedOnBehalf = onBehalf; + return CALLBACK_SUCCESS; } } diff --git a/test/TakeTest.sol b/test/TakeTest.sol index 75e042d7e..da05dc3f8 100644 --- a/test/TakeTest.sol +++ b/test/TakeTest.sol @@ -9,7 +9,7 @@ import {Midnight} from "../src/Midnight.sol"; import {WAD, CALLBACK_SUCCESS, MAX_CONTINUOUS_FEE} from "../src/libraries/ConstantsLib.sol"; import {UtilsLib} from "../src/libraries/UtilsLib.sol"; import {TickLib, MAX_TICK} from "../src/libraries/TickLib.sol"; -import {IBuyCallback, ISellCallback} from "../src/interfaces/ICallbacks.sol"; +import {IBuyCallback, ISellCallback, ILiquidateCallback, IRepayCallback} from "../src/interfaces/ICallbacks.sol"; import {IRatifier} from "../src/interfaces/IRatifier.sol"; import {IdLib} from "../src/libraries/IdLib.sol"; import {BaseTest} from "./BaseTest.sol"; @@ -1377,7 +1377,7 @@ contract TakeTest is BaseTest { collateralize(obligation, borrower, units); address callback = address(new InvalidSellCallback()); - vm.expectRevert(IMidnight.InvalidSellCallback.selector); + vm.expectRevert(IMidnight.WrongSellCallbackReturnValue.selector); vm.prank(borrower); midnight.take( units, @@ -1530,7 +1530,7 @@ contract TakeTest is BaseTest { deal(address(loanToken), callback, assets); collateralize(obligation, borrower, units); - vm.expectRevert(IMidnight.InvalidBuyCallback.selector); + vm.expectRevert(IMidnight.WrongBuyCallbackReturnValue.selector); vm.prank(lender); midnight.take( units, @@ -1626,7 +1626,8 @@ contract ReentrantLiquidateBorrowCallback is ISellCallback { uint256 healthyPrice = oracle.price(); oracle.setPrice(healthyPrice / 2); ERC20(obligation.loanToken).approve(msg.sender, repaidUnits); - try Midnight(msg.sender).liquidate(obligation, collateralIndex, 0, repaidUnits, seller, "") returns ( + try Midnight(msg.sender) + .liquidate(obligation, collateralIndex, 0, repaidUnits, seller, address(this), address(0), "") returns ( uint256, uint256 ) { liquidateSucceeded = true; @@ -1694,7 +1695,8 @@ contract NestedTakeReentrantLiquidateCallback is ISellCallback { uint256 healthyPrice = oracle.price(); oracle.setPrice(healthyPrice / 2); ERC20(obligation.loanToken).approve(msg.sender, storedRepaidUnits); - try Midnight(msg.sender).liquidate(obligation, idx, 0, storedRepaidUnits, seller, "") returns ( + try Midnight(msg.sender) + .liquidate(obligation, idx, 0, storedRepaidUnits, seller, address(this), address(0), "") returns ( uint256, uint256 ) { liquidateSucceeded = true; From c9d5cc3e5e4ede9347bed9525e3c4f8a24005d55 Mon Sep 17 00:00:00 2001 From: "prd-carapulse[bot]" <264278285+prd-carapulse[bot]@users.noreply.github.com> Date: Mon, 20 Apr 2026 14:56:16 +0200 Subject: [PATCH 28/33] test: cover missing require sites for ObligationNotCreated (#708) Co-authored-by: prd-carapulse[bot] <264278285+prd-carapulse[bot]@users.noreply.github.com> --- test/OtherFunctionsTest.sol | 2 +- test/SettersTest.sol | 6 ++++++ 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/test/OtherFunctionsTest.sol b/test/OtherFunctionsTest.sol index a5eba56e8..216a98f7a 100644 --- a/test/OtherFunctionsTest.sol +++ b/test/OtherFunctionsTest.sol @@ -273,7 +273,7 @@ contract OtherFunctionsTest is BaseTest { } function testToObligationRevertsIfNotCreated(bytes32 _id) public { - vm.expectRevert(); + vm.expectRevert(IMidnight.ObligationNotCreated.selector); midnight.toObligation(_id); } diff --git a/test/SettersTest.sol b/test/SettersTest.sol index 1fd384866..051cc3ebb 100644 --- a/test/SettersTest.sol +++ b/test/SettersTest.sol @@ -125,6 +125,12 @@ contract SettersTest is BaseTest { midnight.setObligationTradingFee(id, 0, 0); } + function testSetObligationContinuousFeeObligationNotCreated(bytes32 id, uint256 fee) public { + fee = bound(fee, 0, MAX_CONTINUOUS_FEE); + vm.expectRevert(IMidnight.ObligationNotCreated.selector); + midnight.setObligationContinuousFee(id, fee); + } + function testSetTradingFeeOnlyFeeSetter(address rdm, bytes32 id) public { vm.assume(rdm != address(this)); vm.prank(rdm); From c297aacbc8760de82c1f7d452c860745f764cc96 Mon Sep 17 00:00:00 2001 From: peyha Date: Mon, 18 May 2026 18:53:36 +0200 Subject: [PATCH 29/33] test: fix for weird erc20 --- test/LenderCallbackTest.sol | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/test/LenderCallbackTest.sol b/test/LenderCallbackTest.sol index 1fa394407..d94773c2a 100644 --- a/test/LenderCallbackTest.sol +++ b/test/LenderCallbackTest.sol @@ -11,6 +11,7 @@ import {ObligationLenderCallback} from "../src/periphery/ObligationLenderCallbac import {BaseTest} from "./BaseTest.sol"; import {ERC20} from "./erc20s/ERC20.sol"; +import {SafeTransferLib} from "../src/libraries/SafeTransferLib.sol"; // TODO: use real vault v2 contract MockVault { @@ -21,8 +22,7 @@ contract MockVault { } function withdraw(uint256 assets, address receiver, address) external returns (uint256) { - // forge-lint: disable-next-line(erc20-unchecked-transfer) test mock with controlled ERC20. - ERC20(asset).transfer(receiver, assets); + SafeTransferLib.safeTransfer(asset, receiver, assets); return assets; } } From bdf8858f0f544a056dfb61eeed3778bba6981742 Mon Sep 17 00:00:00 2001 From: peyha Date: Tue, 19 May 2026 09:46:52 +0200 Subject: [PATCH 30/33] refactor: obligation -> market --- src/periphery/BorrowerCallback.sol | 6 ++--- src/periphery/ObligationLenderCallback.sol | 12 ++++----- src/periphery/VaultLenderCallback.sol | 6 ++--- test/BorrowerCallbackTest.sol | 14 +++++----- test/LenderCallbackTest.sol | 30 ++++++++++------------ 5 files changed, 31 insertions(+), 37 deletions(-) diff --git a/src/periphery/BorrowerCallback.sol b/src/periphery/BorrowerCallback.sol index 083e0eb4d..e3db052ce 100644 --- a/src/periphery/BorrowerCallback.sol +++ b/src/periphery/BorrowerCallback.sol @@ -2,7 +2,7 @@ // Copyright (c) 2025 Morpho Association pragma solidity 0.8.34; -import {Obligation} from "../interfaces/IMidnight.sol"; +import {Market} from "../interfaces/IMidnight.sol"; import {Midnight} from "../Midnight.sol"; import {ISellCallback} from "../interfaces/ICallbacks.sol"; import {CALLBACK_SUCCESS} from "../libraries/ConstantsLib.sol"; @@ -21,7 +21,7 @@ contract BorrowerCallback is ISellCallback { /// @dev Callback to supply collateral on behalf of borrower. /// @dev The callback contract should be authorized to supply collateral on behalf of the borrower. - function onSell(bytes32, Obligation memory obligation, address seller, uint256, uint256, bytes memory data) + function onSell(bytes32, Market memory market, address seller, uint256, uint256, bytes memory data) external returns (bytes32) { @@ -29,7 +29,7 @@ contract BorrowerCallback is ISellCallback { CollateralData[] memory collateralData = abi.decode(data, (CollateralData[])); for (uint256 i = 0; i < collateralData.length; i++) { Midnight(MIDNIGHT) - .supplyCollateral(obligation, collateralData[i].collateralIndex, collateralData[i].amount, seller); + .supplyCollateral(market, collateralData[i].collateralIndex, collateralData[i].amount, seller); } return CALLBACK_SUCCESS; } diff --git a/src/periphery/ObligationLenderCallback.sol b/src/periphery/ObligationLenderCallback.sol index 07064c130..ab936207b 100644 --- a/src/periphery/ObligationLenderCallback.sol +++ b/src/periphery/ObligationLenderCallback.sol @@ -2,7 +2,7 @@ // Copyright (c) 2025 Morpho Association pragma solidity 0.8.34; -import {Obligation} from "../interfaces/IMidnight.sol"; +import {Market} from "../interfaces/IMidnight.sol"; import {Midnight} from "../Midnight.sol"; import {IBuyCallback} from "../interfaces/ICallbacks.sol"; import {IERC20} from "./IERC20.sol"; @@ -19,17 +19,17 @@ contract ObligationLenderCallback is IBuyCallback { /// @dev The callback contract should be authorized to withdraw funds on behalf of the lender. function onBuy( bytes32, - Obligation memory obligation, + Market memory market, address buyer, uint256 buyerAssets, uint256, bytes memory data ) external returns (bytes32) { require(msg.sender == MIDNIGHT, "unauthorized"); - bytes32 otherObligationId = abi.decode(data, (bytes32)); - Obligation memory otherObligation = abi.decode(address(uint160(uint256(otherObligationId))).code, (Obligation)); - Midnight(MIDNIGHT).withdraw(otherObligation, buyerAssets, buyer, address(this)); - IERC20(obligation.loanToken).approve(MIDNIGHT, buyerAssets); + bytes32 otherMarketId = abi.decode(data, (bytes32)); + Market memory otherMarket = abi.decode(address(uint160(uint256(otherMarketId))).code, (Market)); + Midnight(MIDNIGHT).withdraw(otherMarket, buyerAssets, buyer, address(this)); + IERC20(market.loanToken).approve(MIDNIGHT, buyerAssets); return CALLBACK_SUCCESS; } } diff --git a/src/periphery/VaultLenderCallback.sol b/src/periphery/VaultLenderCallback.sol index fa54bee44..5195ba051 100644 --- a/src/periphery/VaultLenderCallback.sol +++ b/src/periphery/VaultLenderCallback.sol @@ -2,7 +2,7 @@ // Copyright (c) 2025 Morpho Association pragma solidity 0.8.34; -import {Obligation} from "../interfaces/IMidnight.sol"; +import {Market} from "../interfaces/IMidnight.sol"; import {IERC4626} from "./IERC4626.sol"; import {IERC20} from "./IERC20.sol"; import {IBuyCallback} from "../interfaces/ICallbacks.sol"; @@ -19,7 +19,7 @@ contract VaultLenderCallback is IBuyCallback { /// @dev The callback contract should be authorized to withdraw funds on behalf of the lender. function onBuy( bytes32, - Obligation memory obligation, + Market memory market, address buyer, uint256 buyerAssets, uint256, @@ -28,7 +28,7 @@ contract VaultLenderCallback is IBuyCallback { require(msg.sender == MIDNIGHT, "unauthorized"); address vault = abi.decode(data, (address)); IERC4626(vault).withdraw(buyerAssets, address(this), buyer); - IERC20(obligation.loanToken).approve(MIDNIGHT, buyerAssets); + IERC20(market.loanToken).approve(MIDNIGHT, buyerAssets); return CALLBACK_SUCCESS; } } diff --git a/test/BorrowerCallbackTest.sol b/test/BorrowerCallbackTest.sol index 78b58139f..5d0a1605e 100644 --- a/test/BorrowerCallbackTest.sol +++ b/test/BorrowerCallbackTest.sol @@ -2,7 +2,7 @@ // Copyright (c) 2025 Morpho Association pragma solidity ^0.8.0; -import {Obligation, Offer, CollateralParams} from "../src/interfaces/IMidnight.sol"; +import {Market, Offer, CollateralParams} from "../src/interfaces/IMidnight.sol"; import {WAD, LLTV_2} from "../src/libraries/ConstantsLib.sol"; import {UtilsLib} from "../src/libraries/UtilsLib.sol"; import {TickLib, MAX_TICK} from "../src/libraries/TickLib.sol"; @@ -15,7 +15,7 @@ contract BorrowerCallbackTest is BaseTest { using UtilsLib for uint256; BorrowerCallback internal borrowerCallback; - Obligation internal obligation; + Market internal obligation; bytes32 internal id; Offer internal borrowerOffer; @@ -53,7 +53,7 @@ contract BorrowerCallbackTest is BaseTest { borrowerOffer.maker = borrower; borrowerOffer.receiverIfMakerIsSeller = borrower; borrowerOffer.maxUnits = type(uint256).max; - borrowerOffer.obligation = obligation; + borrowerOffer.market = obligation; borrowerOffer.ratifier = address(ecrecoverRatifier); borrowerOffer.expiry = block.timestamp + 200; borrowerOffer.tick = MAX_TICK; @@ -139,7 +139,7 @@ contract BorrowerCallbackTest is BaseTest { lenderOffer.buy = true; lenderOffer.maker = lender; lenderOffer.maxUnits = units; - lenderOffer.obligation = obligation; + lenderOffer.market = obligation; lenderOffer.ratifier = address(ecrecoverRatifier); lenderOffer.expiry = block.timestamp + 200; lenderOffer.tick = MAX_TICK; @@ -171,16 +171,14 @@ contract BorrowerCallbackTest is BaseTest { abi.encode(collateralData), borrower, lenderOffer, - ratifierData([lenderOffer]), - root([lenderOffer]), - proof([lenderOffer]) + merkleRatifierData([lenderOffer]) ); assertEq(midnight.collateral(id, borrower, 0), collateral); } function testOnSellUnauthorized() public { - Obligation memory ob; + Market memory ob; vm.prank(makeAddr("attacker")); vm.expectRevert("unauthorized"); borrowerCallback.onSell(bytes32(0), ob, address(0), 0, 0, ""); diff --git a/test/LenderCallbackTest.sol b/test/LenderCallbackTest.sol index d94773c2a..36da4192b 100644 --- a/test/LenderCallbackTest.sol +++ b/test/LenderCallbackTest.sol @@ -2,7 +2,7 @@ // Copyright (c) 2025 Morpho Association pragma solidity ^0.8.0; -import {Obligation, Offer, CollateralParams} from "../src/interfaces/IMidnight.sol"; +import {Market, Offer, CollateralParams} from "../src/interfaces/IMidnight.sol"; import {WAD, LLTV_2} from "../src/libraries/ConstantsLib.sol"; import {UtilsLib} from "../src/libraries/UtilsLib.sol"; import {TickLib, MAX_TICK} from "../src/libraries/TickLib.sol"; @@ -31,7 +31,7 @@ contract VaultLenderCallbackTest is BaseTest { using UtilsLib for uint256; VaultLenderCallback internal vaultLenderCallback; - Obligation internal obligation; + Market internal obligation; bytes32 internal id; Offer internal borrowerOffer; @@ -69,7 +69,7 @@ contract VaultLenderCallbackTest is BaseTest { borrowerOffer.maker = borrower; borrowerOffer.receiverIfMakerIsSeller = borrower; borrowerOffer.maxUnits = type(uint256).max; - borrowerOffer.obligation = obligation; + borrowerOffer.market = obligation; borrowerOffer.ratifier = address(ecrecoverRatifier); borrowerOffer.expiry = block.timestamp + 200; borrowerOffer.tick = MAX_TICK; @@ -95,7 +95,7 @@ contract VaultLenderCallbackTest is BaseTest { lenderOffer.callback = address(vaultLenderCallback); lenderOffer.callbackData = abi.encode(address(vault)); lenderOffer.maxUnits = units; - lenderOffer.obligation = obligation; + lenderOffer.market = obligation; lenderOffer.ratifier = address(ecrecoverRatifier); lenderOffer.expiry = block.timestamp + 200; lenderOffer.tick = MAX_TICK; @@ -132,9 +132,7 @@ contract VaultLenderCallbackTest is BaseTest { abi.encode(address(vault)), address(0), borrowerOffer, - ratifierData([borrowerOffer]), - root([borrowerOffer]), - proof([borrowerOffer]) + merkleRatifierData([borrowerOffer]) ); assertEq(midnight.creditOf(id, lender), units); @@ -143,7 +141,7 @@ contract VaultLenderCallbackTest is BaseTest { } function testOnBuyUnauthorized() public { - Obligation memory ob; + Market memory ob; vm.prank(makeAddr("attacker")); vm.expectRevert("unauthorized"); vaultLenderCallback.onBuy(bytes32(0), ob, address(0), 0, 0, ""); @@ -154,7 +152,7 @@ contract ObligationLenderCallbackTest is BaseTest { using UtilsLib for uint256; ObligationLenderCallback internal obligationLenderCallback; - Obligation internal obligation; + Market internal obligation; bytes32 internal id; Offer internal borrowerOffer; @@ -192,7 +190,7 @@ contract ObligationLenderCallbackTest is BaseTest { borrowerOffer.maker = borrower; borrowerOffer.receiverIfMakerIsSeller = borrower; borrowerOffer.maxUnits = type(uint256).max; - borrowerOffer.obligation = obligation; + borrowerOffer.market = obligation; borrowerOffer.ratifier = address(ecrecoverRatifier); borrowerOffer.expiry = block.timestamp + 200; borrowerOffer.tick = MAX_TICK; @@ -203,7 +201,7 @@ contract ObligationLenderCallbackTest is BaseTest { } /// @dev Helper to set up obligation2 with lender credit and withdrawable funds. - function _setupMidnightSource(uint256 buyerAssets) internal returns (Obligation memory obligation2, bytes32 id2) { + function _setupMidnightSource(uint256 buyerAssets) internal returns (Market memory obligation2, bytes32 id2) { obligation2.loanToken = address(loanToken); obligation2.maturity = block.timestamp + 200; obligation2.collateralParams = obligation.collateralParams; @@ -218,7 +216,7 @@ contract ObligationLenderCallbackTest is BaseTest { lenderOffer2.buy = true; lenderOffer2.maker = lender; lenderOffer2.maxUnits = buyerAssets; - lenderOffer2.obligation = obligation2; + lenderOffer2.market = obligation2; lenderOffer2.ratifier = address(ecrecoverRatifier); lenderOffer2.expiry = block.timestamp + 300; lenderOffer2.tick = MAX_TICK; @@ -250,7 +248,7 @@ contract ObligationLenderCallbackTest is BaseTest { lenderOffer.callback = address(obligationLenderCallback); lenderOffer.callbackData = abi.encode(address(uint160(uint256(id2)))); lenderOffer.maxUnits = units; - lenderOffer.obligation = obligation; + lenderOffer.market = obligation; lenderOffer.ratifier = address(ecrecoverRatifier); lenderOffer.expiry = block.timestamp + 200; lenderOffer.tick = MAX_TICK; @@ -285,9 +283,7 @@ contract ObligationLenderCallbackTest is BaseTest { abi.encode(address(uint160(uint256(id2)))), address(0), borrowerOffer, - ratifierData([borrowerOffer]), - root([borrowerOffer]), - proof([borrowerOffer]) + merkleRatifierData([borrowerOffer]) ); assertEq(midnight.creditOf(id, lender), units); @@ -296,7 +292,7 @@ contract ObligationLenderCallbackTest is BaseTest { } function testOnBuyUnauthorized() public { - Obligation memory ob; + Market memory ob; vm.prank(makeAddr("attacker")); vm.expectRevert("unauthorized"); obligationLenderCallback.onBuy(bytes32(0), ob, address(0), 0, 0, ""); From c350113c230b5e88f674577a23bd719712240844 Mon Sep 17 00:00:00 2001 From: peyha Date: Tue, 19 May 2026 09:48:25 +0200 Subject: [PATCH 31/33] chore: fmt --- src/periphery/ObligationLenderCallback.sol | 12 ++++-------- src/periphery/VaultLenderCallback.sol | 12 ++++-------- 2 files changed, 8 insertions(+), 16 deletions(-) diff --git a/src/periphery/ObligationLenderCallback.sol b/src/periphery/ObligationLenderCallback.sol index ab936207b..1eb97e1c9 100644 --- a/src/periphery/ObligationLenderCallback.sol +++ b/src/periphery/ObligationLenderCallback.sol @@ -17,14 +17,10 @@ contract ObligationLenderCallback is IBuyCallback { /// @dev Callback to withdraw funds from another Midnight obligation. /// @dev The callback contract should be authorized to withdraw funds on behalf of the lender. - function onBuy( - bytes32, - Market memory market, - address buyer, - uint256 buyerAssets, - uint256, - bytes memory data - ) external returns (bytes32) { + function onBuy(bytes32, Market memory market, address buyer, uint256 buyerAssets, uint256, bytes memory data) + external + returns (bytes32) + { require(msg.sender == MIDNIGHT, "unauthorized"); bytes32 otherMarketId = abi.decode(data, (bytes32)); Market memory otherMarket = abi.decode(address(uint160(uint256(otherMarketId))).code, (Market)); diff --git a/src/periphery/VaultLenderCallback.sol b/src/periphery/VaultLenderCallback.sol index 5195ba051..6cdbf5631 100644 --- a/src/periphery/VaultLenderCallback.sol +++ b/src/periphery/VaultLenderCallback.sol @@ -17,14 +17,10 @@ contract VaultLenderCallback is IBuyCallback { /// @dev Callback to withdraw funds from an ERC4626 vault. /// @dev The callback contract should be authorized to withdraw funds on behalf of the lender. - function onBuy( - bytes32, - Market memory market, - address buyer, - uint256 buyerAssets, - uint256, - bytes memory data - ) external returns (bytes32) { + function onBuy(bytes32, Market memory market, address buyer, uint256 buyerAssets, uint256, bytes memory data) + external + returns (bytes32) + { require(msg.sender == MIDNIGHT, "unauthorized"); address vault = abi.decode(data, (address)); IERC4626(vault).withdraw(buyerAssets, address(this), buyer); From 486aebc53e71d635e741e95f1dae2361098cd403 Mon Sep 17 00:00:00 2001 From: peyha Date: Tue, 19 May 2026 10:03:25 +0200 Subject: [PATCH 32/33] refactor: custom error --- src/periphery/BorrowerCallback.sol | 4 +++- src/periphery/ObligationLenderCallback.sol | 4 +++- src/periphery/VaultLenderCallback.sol | 4 +++- 3 files changed, 9 insertions(+), 3 deletions(-) diff --git a/src/periphery/BorrowerCallback.sol b/src/periphery/BorrowerCallback.sol index e3db052ce..061e8ccf6 100644 --- a/src/periphery/BorrowerCallback.sol +++ b/src/periphery/BorrowerCallback.sol @@ -13,6 +13,8 @@ struct CollateralData { } contract BorrowerCallback is ISellCallback { + error NotMidnight(); + address public immutable MIDNIGHT; constructor(address _midnight) { @@ -25,7 +27,7 @@ contract BorrowerCallback is ISellCallback { external returns (bytes32) { - require(msg.sender == MIDNIGHT, "unauthorized"); + require(msg.sender == MIDNIGHT, NotMidnight()); CollateralData[] memory collateralData = abi.decode(data, (CollateralData[])); for (uint256 i = 0; i < collateralData.length; i++) { Midnight(MIDNIGHT) diff --git a/src/periphery/ObligationLenderCallback.sol b/src/periphery/ObligationLenderCallback.sol index 1eb97e1c9..b65c127b2 100644 --- a/src/periphery/ObligationLenderCallback.sol +++ b/src/periphery/ObligationLenderCallback.sol @@ -9,6 +9,8 @@ import {IERC20} from "./IERC20.sol"; import {CALLBACK_SUCCESS} from "../libraries/ConstantsLib.sol"; contract ObligationLenderCallback is IBuyCallback { + error NotMidnight(); + address public immutable MIDNIGHT; constructor(address _midnight) { @@ -21,7 +23,7 @@ contract ObligationLenderCallback is IBuyCallback { external returns (bytes32) { - require(msg.sender == MIDNIGHT, "unauthorized"); + require(msg.sender == MIDNIGHT, NotMidnight()); bytes32 otherMarketId = abi.decode(data, (bytes32)); Market memory otherMarket = abi.decode(address(uint160(uint256(otherMarketId))).code, (Market)); Midnight(MIDNIGHT).withdraw(otherMarket, buyerAssets, buyer, address(this)); diff --git a/src/periphery/VaultLenderCallback.sol b/src/periphery/VaultLenderCallback.sol index 6cdbf5631..8a223e1cc 100644 --- a/src/periphery/VaultLenderCallback.sol +++ b/src/periphery/VaultLenderCallback.sol @@ -9,6 +9,8 @@ import {IBuyCallback} from "../interfaces/ICallbacks.sol"; import {CALLBACK_SUCCESS} from "../libraries/ConstantsLib.sol"; contract VaultLenderCallback is IBuyCallback { + error NotMidnight(); + address public immutable MIDNIGHT; constructor(address _midnight) { @@ -21,7 +23,7 @@ contract VaultLenderCallback is IBuyCallback { external returns (bytes32) { - require(msg.sender == MIDNIGHT, "unauthorized"); + require(msg.sender == MIDNIGHT, NotMidnight()); address vault = abi.decode(data, (address)); IERC4626(vault).withdraw(buyerAssets, address(this), buyer); IERC20(market.loanToken).approve(MIDNIGHT, buyerAssets); From d3d6aa118fb12c52949458c3678c1e5a97e6e581 Mon Sep 17 00:00:00 2001 From: peyha Date: Tue, 19 May 2026 10:31:03 +0200 Subject: [PATCH 33/33] test: custom error --- test/BorrowerCallbackTest.sol | 2 +- test/LenderCallbackTest.sol | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/test/BorrowerCallbackTest.sol b/test/BorrowerCallbackTest.sol index 5d0a1605e..50990ecc4 100644 --- a/test/BorrowerCallbackTest.sol +++ b/test/BorrowerCallbackTest.sol @@ -180,7 +180,7 @@ contract BorrowerCallbackTest is BaseTest { function testOnSellUnauthorized() public { Market memory ob; vm.prank(makeAddr("attacker")); - vm.expectRevert("unauthorized"); + vm.expectRevert(BorrowerCallback.NotMidnight.selector); borrowerCallback.onSell(bytes32(0), ob, address(0), 0, 0, ""); } } diff --git a/test/LenderCallbackTest.sol b/test/LenderCallbackTest.sol index 36da4192b..46666cddc 100644 --- a/test/LenderCallbackTest.sol +++ b/test/LenderCallbackTest.sol @@ -143,7 +143,7 @@ contract VaultLenderCallbackTest is BaseTest { function testOnBuyUnauthorized() public { Market memory ob; vm.prank(makeAddr("attacker")); - vm.expectRevert("unauthorized"); + vm.expectRevert(VaultLenderCallback.NotMidnight.selector); vaultLenderCallback.onBuy(bytes32(0), ob, address(0), 0, 0, ""); } } @@ -294,7 +294,7 @@ contract ObligationLenderCallbackTest is BaseTest { function testOnBuyUnauthorized() public { Market memory ob; vm.prank(makeAddr("attacker")); - vm.expectRevert("unauthorized"); + vm.expectRevert(ObligationLenderCallback.NotMidnight.selector); obligationLenderCallback.onBuy(bytes32(0), ob, address(0), 0, 0, ""); } }