diff --git a/src/periphery/BorrowerCallback.sol b/src/periphery/BorrowerCallback.sol new file mode 100644 index 000000000..061e8ccf6 --- /dev/null +++ b/src/periphery/BorrowerCallback.sol @@ -0,0 +1,38 @@ +// SPDX-License-Identifier: GPL-2.0-or-later +// Copyright (c) 2025 Morpho Association +pragma solidity 0.8.34; + +import {Market} from "../interfaces/IMidnight.sol"; +import {Midnight} from "../Midnight.sol"; +import {ISellCallback} from "../interfaces/ICallbacks.sol"; +import {CALLBACK_SUCCESS} from "../libraries/ConstantsLib.sol"; + +struct CollateralData { + uint256 collateralIndex; + uint256 amount; +} + +contract BorrowerCallback is ISellCallback { + error NotMidnight(); + + 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(bytes32, Market memory market, address seller, uint256, uint256, bytes memory data) + external + returns (bytes32) + { + require(msg.sender == MIDNIGHT, NotMidnight()); + CollateralData[] memory collateralData = abi.decode(data, (CollateralData[])); + for (uint256 i = 0; i < collateralData.length; i++) { + Midnight(MIDNIGHT) + .supplyCollateral(market, collateralData[i].collateralIndex, collateralData[i].amount, seller); + } + return CALLBACK_SUCCESS; + } +} 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/ObligationLenderCallback.sol b/src/periphery/ObligationLenderCallback.sol new file mode 100644 index 000000000..b65c127b2 --- /dev/null +++ b/src/periphery/ObligationLenderCallback.sol @@ -0,0 +1,33 @@ +// SPDX-License-Identifier: GPL-2.0-or-later +// Copyright (c) 2025 Morpho Association +pragma solidity 0.8.34; + +import {Market} from "../interfaces/IMidnight.sol"; +import {Midnight} from "../Midnight.sol"; +import {IBuyCallback} from "../interfaces/ICallbacks.sol"; +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) { + MIDNIGHT = _midnight; + } + + /// @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) + { + 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)); + IERC20(market.loanToken).approve(MIDNIGHT, buyerAssets); + return CALLBACK_SUCCESS; + } +} diff --git a/src/periphery/VaultLenderCallback.sol b/src/periphery/VaultLenderCallback.sol new file mode 100644 index 000000000..8a223e1cc --- /dev/null +++ b/src/periphery/VaultLenderCallback.sol @@ -0,0 +1,32 @@ +// SPDX-License-Identifier: GPL-2.0-or-later +// Copyright (c) 2025 Morpho Association +pragma solidity 0.8.34; + +import {Market} from "../interfaces/IMidnight.sol"; +import {IERC4626} from "./IERC4626.sol"; +import {IERC20} from "./IERC20.sol"; +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) { + 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(bytes32, Market memory market, address buyer, uint256 buyerAssets, uint256, bytes memory data) + external + returns (bytes32) + { + require(msg.sender == MIDNIGHT, NotMidnight()); + address vault = abi.decode(data, (address)); + IERC4626(vault).withdraw(buyerAssets, address(this), buyer); + IERC20(market.loanToken).approve(MIDNIGHT, buyerAssets); + return CALLBACK_SUCCESS; + } +} diff --git a/test/BorrowerCallbackTest.sol b/test/BorrowerCallbackTest.sol new file mode 100644 index 000000000..50990ecc4 --- /dev/null +++ b/test/BorrowerCallbackTest.sol @@ -0,0 +1,186 @@ +// SPDX-License-Identifier: GPL-2.0-or-later +// Copyright (c) 2025 Morpho Association +pragma solidity ^0.8.0; + +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"; +import {BorrowerCallback, CollateralData} from "../src/periphery/BorrowerCallback.sol"; + +import {BaseTest} from "./BaseTest.sol"; +import {ERC20} from "./erc20s/ERC20.sol"; + +contract BorrowerCallbackTest is BaseTest { + using UtilsLib for uint256; + + BorrowerCallback internal borrowerCallback; + Market 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.collateralParams + .push( + CollateralParams({ + token: address(collateralToken1), + lltv: LLTV_2, + maxLif: maxLif(LLTV_2, 0.25e18), + oracle: address(oracle1) + }) + ); + obligation.collateralParams + .push( + CollateralParams({ + token: address(collateralToken2), + lltv: LLTV_2, + maxLif: maxLif(LLTV_2, 0.25e18), + oracle: address(oracle2) + }) + ); + obligation.collateralParams = sortCollateralParams(obligation.collateralParams); + obligation.rcfThreshold = 0; + + id = toId(obligation); + + borrowerOffer.buy = false; + borrowerOffer.maker = borrower; + borrowerOffer.receiverIfMakerIsSeller = borrower; + borrowerOffer.maxUnits = type(uint256).max; + borrowerOffer.market = obligation; + borrowerOffer.ratifier = address(ecrecoverRatifier); + 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.collateralParams[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.collateralParams[0].token, address(borrowerCallback), collateral); + vm.prank(address(borrowerCallback)); + ERC20(obligation.collateralParams[0].token).approve(address(midnight), collateral); + + // Authorize callback to supply collateral on behalf of borrower. + vm.prank(borrower); + midnight.setIsAuthorized(borrower, address(borrowerCallback), true); + + assertEq(midnight.collateral(id, borrower, 0), 0); + + take(units, lender, borrowerOffer); + + assertEq(midnight.collateral(id, borrower, 0), collateral); + } + + function testOnSellMultipleCollateralsMaker(uint256 units) public { + units = bound(units, 1, 1e33); + 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); + 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.collateralParams[0].token, address(borrowerCallback), collateral0); + deal(obligation.collateralParams[1].token, address(borrowerCallback), collateral1); + vm.prank(address(borrowerCallback)); + ERC20(obligation.collateralParams[0].token).approve(address(midnight), collateral0); + vm.prank(address(borrowerCallback)); + ERC20(obligation.collateralParams[1].token).approve(address(midnight), collateral1); + + // Authorize callback to supply collateral on behalf of borrower. + vm.prank(borrower); + midnight.setIsAuthorized(borrower, address(borrowerCallback), true); + + assertEq(midnight.collateral(id, borrower, 0), 0); + assertEq(midnight.collateral(id, borrower, 1), 0); + + take(units, lender, borrowerOffer); + + 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.collateralParams[0].lltv); + + // Lender makes a buy offer. + Offer memory lenderOffer; + lenderOffer.buy = true; + lenderOffer.maker = lender; + lenderOffer.maxUnits = units; + lenderOffer.market = obligation; + lenderOffer.ratifier = address(ecrecoverRatifier); + 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.collateralParams[0].token, address(borrowerCallback), collateral); + vm.prank(address(borrowerCallback)); + ERC20(obligation.collateralParams[0].token).approve(address(midnight), collateral); + + // Authorize callback to supply collateral on behalf of borrower. + vm.prank(borrower); + midnight.setIsAuthorized(borrower, address(borrowerCallback), true); + + CollateralData[] memory collateralData = new CollateralData[](1); + collateralData[0] = CollateralData({collateralIndex: 0, amount: collateral}); + + assertEq(midnight.collateral(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, + merkleRatifierData([lenderOffer]) + ); + + assertEq(midnight.collateral(id, borrower, 0), collateral); + } + + function testOnSellUnauthorized() public { + Market memory ob; + vm.prank(makeAddr("attacker")); + vm.expectRevert(BorrowerCallback.NotMidnight.selector); + borrowerCallback.onSell(bytes32(0), ob, address(0), 0, 0, ""); + } +} diff --git a/test/LenderCallbackTest.sol b/test/LenderCallbackTest.sol new file mode 100644 index 000000000..46666cddc --- /dev/null +++ b/test/LenderCallbackTest.sol @@ -0,0 +1,300 @@ +// SPDX-License-Identifier: GPL-2.0-or-later +// Copyright (c) 2025 Morpho Association +pragma solidity ^0.8.0; + +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"; +import {VaultLenderCallback} from "../src/periphery/VaultLenderCallback.sol"; +import {ObligationLenderCallback} from "../src/periphery/ObligationLenderCallback.sol"; + +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 { + address public asset; + + constructor(address _asset) { + asset = _asset; + } + + function withdraw(uint256 assets, address receiver, address) external returns (uint256) { + SafeTransferLib.safeTransfer(asset, receiver, assets); + return assets; + } +} + +contract VaultLenderCallbackTest is BaseTest { + using UtilsLib for uint256; + + VaultLenderCallback internal vaultLenderCallback; + Market internal obligation; + bytes32 internal id; + Offer internal borrowerOffer; + + function setUp() public override { + super.setUp(); + + vaultLenderCallback = new VaultLenderCallback(address(midnight)); + + obligation.loanToken = address(loanToken); + obligation.maturity = block.timestamp + 100; + obligation.collateralParams + .push( + CollateralParams({ + token: address(collateralToken1), + lltv: LLTV_2, + maxLif: maxLif(LLTV_2, 0.25e18), + oracle: address(oracle1) + }) + ); + obligation.collateralParams + .push( + CollateralParams({ + token: address(collateralToken2), + lltv: LLTV_2, + maxLif: maxLif(LLTV_2, 0.25e18), + oracle: address(oracle2) + }) + ); + obligation.collateralParams = sortCollateralParams(obligation.collateralParams); + obligation.rcfThreshold = 0; + + id = toId(obligation); + + borrowerOffer.buy = false; + borrowerOffer.maker = borrower; + borrowerOffer.receiverIfMakerIsSeller = borrower; + borrowerOffer.maxUnits = type(uint256).max; + borrowerOffer.market = obligation; + borrowerOffer.ratifier = address(ecrecoverRatifier); + borrowerOffer.expiry = block.timestamp + 200; + borrowerOffer.tick = MAX_TICK; + } + + function testConstructor() public view { + assertEq(vaultLenderCallback.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(vaultLenderCallback); + lenderOffer.callbackData = abi.encode(address(vault)); + lenderOffer.maxUnits = units; + lenderOffer.market = obligation; + lenderOffer.ratifier = address(ecrecoverRatifier); + 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(vaultLenderCallback), + abi.encode(address(vault)), + address(0), + borrowerOffer, + merkleRatifierData([borrowerOffer]) + ); + + assertEq(midnight.creditOf(id, lender), units); + assertEq(midnight.debtOf(id, borrower), units); + assertEq(loanToken.balanceOf(address(vault)), 0); + } + + function testOnBuyUnauthorized() public { + Market memory ob; + vm.prank(makeAddr("attacker")); + vm.expectRevert(VaultLenderCallback.NotMidnight.selector); + vaultLenderCallback.onBuy(bytes32(0), ob, address(0), 0, 0, ""); + } +} + +contract ObligationLenderCallbackTest is BaseTest { + using UtilsLib for uint256; + + ObligationLenderCallback internal obligationLenderCallback; + Market 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.collateralParams + .push( + CollateralParams({ + token: address(collateralToken1), + lltv: LLTV_2, + maxLif: maxLif(LLTV_2, 0.25e18), + oracle: address(oracle1) + }) + ); + obligation.collateralParams + .push( + CollateralParams({ + token: address(collateralToken2), + lltv: LLTV_2, + maxLif: maxLif(LLTV_2, 0.25e18), + oracle: address(oracle2) + }) + ); + obligation.collateralParams = sortCollateralParams(obligation.collateralParams); + obligation.rcfThreshold = 0; + + id = toId(obligation); + + borrowerOffer.buy = false; + borrowerOffer.maker = borrower; + borrowerOffer.receiverIfMakerIsSeller = borrower; + borrowerOffer.maxUnits = type(uint256).max; + borrowerOffer.market = obligation; + borrowerOffer.ratifier = address(ecrecoverRatifier); + 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 (Market memory obligation2, bytes32 id2) { + obligation2.loanToken = address(loanToken); + obligation2.maturity = block.timestamp + 200; + obligation2.collateralParams = obligation.collateralParams; + 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.market = obligation2; + lenderOffer2.ratifier = address(ecrecoverRatifier); + 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, address(0), hex""); + + // Authorize callback to withdraw on behalf of lender. + vm.prank(lender); + midnight.setIsAuthorized(lender, address(obligationLenderCallback), true); + } + + 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(obligationLenderCallback); + lenderOffer.callbackData = abi.encode(address(uint160(uint256(id2)))); + lenderOffer.maxUnits = units; + lenderOffer.market = obligation; + lenderOffer.ratifier = address(ecrecoverRatifier); + 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(obligationLenderCallback), + abi.encode(address(uint160(uint256(id2)))), + address(0), + borrowerOffer, + merkleRatifierData([borrowerOffer]) + ); + + assertEq(midnight.creditOf(id, lender), units); + assertEq(midnight.debtOf(id, borrower), units); + assertEq(midnight.creditOf(id2, lender), 0); + } + + function testOnBuyUnauthorized() public { + Market memory ob; + vm.prank(makeAddr("attacker")); + vm.expectRevert(ObligationLenderCallback.NotMidnight.selector); + obligationLenderCallback.onBuy(bytes32(0), ob, address(0), 0, 0, ""); + } +}