Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
37 commits
Select commit Hold shift + click to select a range
f224c90
feat: first version
peyha Mar 23, 2026
c23627a
lint
peyha Mar 23, 2026
92d19aa
test
peyha Mar 23, 2026
aa6611e
refactor: separate lend callback
peyha Mar 24, 2026
8abe36e
Merge branch 'main' into feat/callbacks
peyha Mar 31, 2026
0b9f0c7
chore: lint
peyha Apr 1, 2026
5faf553
Merge branch 'main' into feat/callbacks
peyha Apr 7, 2026
e787891
feat: update callback
peyha Apr 7, 2026
40008a9
test
peyha Apr 7, 2026
ee5c8fd
chore: lint
peyha Apr 7, 2026
6b5a399
check size in ci (#680)
MathisGD Apr 10, 2026
2f95591
test: fix testReturnJumps (#683)
MathisGD Apr 14, 2026
5690f7e
Allow ApprovalRatifier approvals to be delegated (#686)
prd-carapulse[bot] Apr 14, 2026
bdd585c
[Certora] onlyAuthorizedCanChange liquidate (#492)
QGarchery Apr 14, 2026
3accf71
Missing tests for full coverage (#687)
peyha Apr 15, 2026
85ac46a
naming: is ratified (#695)
MathisGD Apr 15, 2026
a24a9d9
Custom error with commit fix (#701)
peyha Apr 15, 2026
55c4758
cvl context (#698)
MathisGD Apr 16, 2026
67c5563
update position returns more values (#690)
adhusson Apr 16, 2026
9dc82f1
use IMidnight interface (#691)
adhusson Apr 16, 2026
5be0183
Small tweaks (#703)
QGarchery Apr 16, 2026
681e3f6
[Certora] Refactor division by zero (#702)
QGarchery Apr 16, 2026
8c1fafd
test with an old version of foundry (#696)
MathisGD Apr 16, 2026
fc99601
minor naming changes (#697)
MathisGD Apr 16, 2026
12840e8
Rename fee -> trading fee (#705)
peyha Apr 16, 2026
fd9d353
separate callbacks (#704)
adhusson Apr 16, 2026
b9bf808
no solc metadata (#706)
MathisGD Apr 17, 2026
de3e296
refactor take bundler (#710)
MathisGD Apr 20, 2026
b707d4a
arbitrary callback targets (#688)
MathisGD Apr 20, 2026
c9d5cc3
test: cover missing require sites for ObligationNotCreated (#708)
prd-carapulse[bot] Apr 20, 2026
864df15
Merge branch 'main' into feat/callbacks
peyha Apr 28, 2026
c297aac
test: fix for weird erc20
peyha May 18, 2026
adb8a02
Merge branch 'main' into feat/callbacks
peyha May 18, 2026
bdf8858
refactor: obligation -> market
peyha May 19, 2026
c350113
chore: fmt
peyha May 19, 2026
486aebc
refactor: custom error
peyha May 19, 2026
d3d6aa1
test: custom error
peyha May 19, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
38 changes: 38 additions & 0 deletions src/periphery/BorrowerCallback.sol
Original file line number Diff line number Diff line change
@@ -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)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Add allowance setup for collateral transfers

BorrowerCallback.onSell calls Midnight.supplyCollateral from the callback address, but this contract never grants MIDNIGHT an allowance for collateral tokens and exposes no function to do so. On-chain, a regular ERC20 collateral therefore reverts on first use when supplyCollateral reaches safeTransferFrom with allowance(callback, MIDNIGHT) == 0. The new tests pass only because they impersonate the callback address with vm.prank(...) to call approve, which is not possible in production.

Useful? React with 👍 / 👎.

.supplyCollateral(market, collateralData[i].collateralIndex, collateralData[i].amount, seller);
}
return CALLBACK_SUCCESS;
}
}
15 changes: 15 additions & 0 deletions src/periphery/IERC20.sol
Original file line number Diff line number Diff line change
@@ -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);
}
24 changes: 24 additions & 0 deletions src/periphery/IERC4626.sol
Original file line number Diff line number Diff line change
@@ -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);
}
33 changes: 33 additions & 0 deletions src/periphery/ObligationLenderCallback.sol
Original file line number Diff line number Diff line change
@@ -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));

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Reject withdrawing from the market being filled

When data points to the same market as the active take, this withdraw can burn the buyer's just-booked credit before the callback payment is collected, because Midnight.take updates credit/debt and then invokes the buy callback before the loan-token transfers. If the market already has withdrawable liquidity, a lender can fund a new borrow from existing repaid funds without bringing external assets, leaving the new debt unmatched by lender credit. Keep the id callback parameter and reject a source market whose decoded/recomputed id equals it, e.g. require(Midnight(MIDNIGHT).toId(otherMarket) != id, "same market");.

Useful? React with 👍 / 👎.

IERC20(market.loanToken).approve(MIDNIGHT, buyerAssets);
return CALLBACK_SUCCESS;
}
}
32 changes: 32 additions & 0 deletions src/periphery/VaultLenderCallback.sol
Original file line number Diff line number Diff line change
@@ -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;
}
}
186 changes: 186 additions & 0 deletions test/BorrowerCallbackTest.sol
Original file line number Diff line number Diff line change
@@ -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, "");
}
}
Loading
Loading