Docs

Lend from a Morpho Blue position

Snippets use Morpho SDK 7.0.0 and reuse the client and midnight objects from its Setup section, with maker set to your wallet address.

How it works

The offer names a BlueBuyCallback contract instead of relying on your wallet balance. That contract belongs to you and holds a Morpho Blue supply position. When a borrower takes the offer, the callback withdraws exactly the amount taken from Morpho Blue and Midnight settles with it, in the borrower's transaction.

supplyBlueMakeLend does the setup in one MidnightBundlesV2 transaction:

  1. Creates your BlueBuyCallback through the BlueBuyCallbackFactory, if it doesn't exist yet.
  2. Pulls the loan tokens from your wallet and supplies them to the chosen Morpho Blue market on behalf of the callback.
  3. Activates the offer root on PriceRatifierV1 or RateRatifierV1 and publishes the offers.

Because the callback is created in the same transaction as the offers, it is registered before they are posted. The Morpho API and router only treat an offer as blue_buy when its callback was registered to the maker in the official factory at or before the offer was created.

The assets stay in the callback's Morpho Blue position until a take uses them or you withdraw them. Cancelling or letting the offer expire does not move them.

Choose the Morpho Blue market and the callback

Pick a Morpho Blue market whose loan token is the loan token of the Midnight market you lend on. The callback rejects a take when the two loan tokens differ, and the SDK rejects the offers before publishing.

MidnightBundlesV2 doesn't check the Morpho Blue supply share price, so pick a market that is protected against supply share price inflation. The SDK rejects a Morpho Blue market with no supply shares, and an amount that would mint zero shares.

The callback address depends on your address and a salt you choose. The same salt always gives the same callback, so the salt decides which offer groups share one callback and its assets. See Which callback an offer uses and How the Morpho app uses callbacks.

import { keccak256, toHex } from "viem";
import { base } from "viem/chains";
import {
  getMidnightBlueBuyCallbackAddress,
  getMidnightBlueBuyCallbackData,
} from "@morpho-org/morpho-sdk";
import { fetchBlueMarketParams } from "@morpho-org/morpho-sdk/fetch";
import type { BlueMarketId } from "@morpho-org/morpho-sdk/types";

// The Morpho Blue market that holds the assets while the offer waits.
const blueMarketId = "0xBlueMarketId..." as BlueMarketId;
const blueMarket = await fetchBlueMarketParams(blueMarketId, client);

// Any bytes32 value. Store it: you need it to find the callback again.
const callbackSalt = keccak256(toHex("my-lend-offer-1"));

// The callback address is computed locally. The contract may not exist yet.
const callback = getMidnightBlueBuyCallbackAddress({
  chainId: base.id,
  owner: maker,
  salt: callbackSalt,
});

// Tells the callback which Morpho Blue market to withdraw from.
const callbackData = getMidnightBlueBuyCallbackData(blueMarket);

Build the lend offers

Every offer must set callback to your callback address and callbackData to the encoded Morpho Blue market. Only lend offers (buy: true) can use this callback, and they can't be reduce-only.

import { parseUnits, zeroAddress } from "viem";
import { addresses } from "@morpho-org/morpho-sdk/addresses";
import { Offer } from "@morpho-org/morpho-sdk/midnight/entities";

const marketData = await midnight.getMarketData(marketId);

// Offer expiry: now + 7 days, capped at the market's maturity.
const offerExpiry = BigInt(
  Math.min(Number(marketData.params.maturity), Math.floor(Date.now() / 1000) + 604_800),
);

const lendOffer = Offer.create({
  market: marketData.params,
  maker,
  buy: true,
  tick: 5_000n,
  maxAssets: parseUnits("250000", 6),
  expiry: offerExpiry,
  ratifier: addresses[base.id].priceRatifierV1!,
  callback,
  callbackData,
});

How much a borrower can take is the smallest of three amounts: what is left of the offer group's size, the callback's Morpho Blue supply, and the liquidity that can be withdrawn from that Morpho Blue market. See How the router recognizes the liquidity.

The offer size is your choice. A fixed maxAssets, as above, caps fills at that amount. With maxAssets set to maxUint128, the offer size follows whatever the callback holds on Morpho Blue. See Sizing callback-backed offers for the trade-offs.

Publish the offers

import { maxUint256, parseUnits } from "viem";

const supplyBlueMakeLend = await midnight.supplyBlueMakeLend({
  accountAddress: maker,
  offers: {
    type: "priceV1",
    entries: [{ offer: lendOffer, allowedTaker: zeroAddress }],
  },
  blueMarket,
  assetsToPark: parseUnits("250000", 6), // loan tokens supplied to Morpho Blue for the callback
  callbackSalt,
  deadline: maxUint256,
});

// A loan-token approval to MidnightBundlesV2 and the MidnightBundlesV2 authorization, when missing.
for (const requirement of await supplyBlueMakeLend.getRequirements()) {
  const hash = await client.sendTransaction(requirement);
  await client.waitForTransactionReceipt({ hash });
}
const publishHash = await client.sendTransaction(supplyBlueMakeLend.buildTx());
const publishReceipt = await client.waitForTransactionReceipt({ hash: publishHash });
if (publishReceipt.status !== "success") throw new Error("Publication failed");

To add assets or replace the offers later, call supplyBlueMakeLend again with the same callbackSalt. Pass the previous groups in cancellations to cancel them in the same transaction. The new amount is added to the same callback's Morpho Blue position.

See the supplyBlueMakeLend errors for the checks the SDK runs before building the transaction.

Back offers on several markets

One callback can back lend offers on several Midnight markets that share the same loan token. Put the offers in one group so they share one size budget: a fill on any market reduces what is left on all of them. See Multi-market offers.

All offers point to the same callback and the same Morpho Blue market, so every fill withdraws from the same Morpho Blue position.

import { Group } from "@morpho-org/morpho-sdk/midnight/entities";

const [marketA, marketB] = await Promise.all([
  midnight.getMarketData(marketIdA),
  midnight.getMarketData(marketIdB),
]);

// Every offer must expire at or before its own market's maturity, so cap at the earliest one.
const multiMarketExpiry = BigInt(
  Math.min(
    Number(marketA.params.maturity),
    Number(marketB.params.maturity),
    Math.floor(Date.now() / 1000) + 604_800,
  ),
);

// A separate salt gives this group its own callback and assets, apart from the first example.
const multiMarketSalt = keccak256(toHex("my-multi-market-offer-1"));
const multiMarketCallback = getMidnightBlueBuyCallbackAddress({
  chainId: base.id,
  owner: maker,
  salt: multiMarketSalt,
});

const commonOffer = {
  maker,
  buy: true,
  maxAssets: parseUnits("250000", 6), // one budget for both markets
  expiry: multiMarketExpiry,
  ratifier: addresses[base.id].priceRatifierV1!,
  callback: multiMarketCallback,
  callbackData,
} as const;

const group = Group.create([
  Offer.create({ ...commonOffer, market: marketA.params, tick: 5_000n }),
  Offer.create({ ...commonOffer, market: marketB.params, tick: 5_012n }),
]);

const multiMarket = await midnight.supplyBlueMakeLend({
  accountAddress: maker,
  offers: {
    type: "priceV1",
    entries: group.offers.map((offer) => ({ offer, allowedTaker: zeroAddress })),
  },
  blueMarket,
  assetsToPark: parseUnits("250000", 6),
  callbackSalt: multiMarketSalt,
  deadline: maxUint256,
});

Cancel the offers and withdraw the assets

Cancelling stops the offers but leaves the assets in the callback's Morpho Blue position. Withdraw them as a second step.

The callback authorizes you on Morpho Blue when it is created, so you can withdraw its position to your wallet with a regular Morpho Blue withdraw, passing the callback as onBehalf. Withdrawing alone doesn't cancel the offers: they stay published and can be filled again if assets are supplied to the callback again.

The Morpho app cancels first, then withdraws in a separate transaction, so a failed withdrawal can't undo the cancellation.

import { maxUint128, maxUint256 } from "viem";
import { blueAbi } from "@morpho-org/morpho-sdk/abis";
import { getChainAddress } from "@morpho-org/morpho-sdk/addresses";

// 1. Cancel the offer groups returned when they were published.
const cancelOffers = midnight.cancelOffers({
  accountAddress: maker,
  cancellations: supplyBlueMakeLend.groups.map((group) => ({ group, maxConsumed: maxUint128 })),
  deadline: maxUint256,
});
for (const requirement of await cancelOffers.getRequirements()) {
  const hash = await client.sendTransaction(requirement);
  await client.waitForTransactionReceipt({ hash });
}
const cancelHash = await client.sendTransaction(cancelOffers.buildTx());
const cancelReceipt = await client.waitForTransactionReceipt({ hash: cancelHash });
// Withdraw only once the cancellation has landed, so no take can use the assets in between.
if (cancelReceipt.status !== "success") throw new Error("Cancellation failed");

// 2. Withdraw all of the callback's supply shares to your wallet.
const blue = getChainAddress(base.id, "blue");
const [supplyShares] = await client.readContract({
  address: blue,
  abi: blueAbi,
  functionName: "position",
  args: [blueMarketId, callback],
});

// Morpho Blue reverts a withdrawal of zero shares, so skip an empty position.
if (supplyShares > 0n) {
  await client.writeContract({
    address: blue,
    abi: blueAbi,
    functionName: "withdraw",
    args: [blueMarket, 0n, supplyShares, callback, maker], // assets, shares, onBehalf, receiver
  });
}

The withdrawal reverts when the Morpho Blue market doesn't have enough liquidity for the full amount. Withdraw a smaller amount, or try again when liquidity comes back.

If several offer groups share the callback (same salt), withdrawing its position also removes the assets that back the groups that are still open.

Recover assets from expired offers

An expired offer can't be taken, but its assets are still in the callback's Morpho Blue position. List your expired callback groups with the API, then withdraw each callback's position as above.

The endpoint returns at most 100 groups per page: keep the same filters and pass each returned cursor until it is null. Several groups can point to the same callback and Morpho Blue market, so withdraw each pair of callback_address and market_id once, and skip it when its supply shares are zero. Also skip a pair that still backs an active or scheduled group, such as replacement offers published with the same salt, or cancel those groups first: withdrawing would leave them published with no assets behind them.

GET https://api.morpho.org/v0/midnight/users/{maker}/offer-groups
  ?chain_ids=8453
  &statuses=expired
  &callback_type=blue_buy

Each returned group has a callback object with the callback address and the Morpho Blue market it holds assets in:

import type { Address } from "viem";

type BlueBuyCallbackInfo = {
  callback_address: string;
  market_id: string;
  market_params: {
    loan_token: string;
    collateral_token: string;
    oracle_address: string;
    irm_address: string;
    lltv_wad: string;
  };
};

const withdrawArgs = ({ callback_address, market_id, market_params }: BlueBuyCallbackInfo) => {
  return {
    callback: callback_address as Address,
    blueMarketId: market_id as BlueMarketId,
    blueMarket: {
      loanToken: market_params.loan_token as Address,
      collateralToken: market_params.collateral_token as Address,
      oracle: market_params.oracle_address as Address,
      irm: market_params.irm_address as Address,
      lltv: BigInt(market_params.lltv_wad),
    },
  };
};

The same applies to cancelled groups, and to filled groups whose callback still holds interest or leftover assets. The callback_type=blue_buy filter only returns verified callbacks. To find the others, see Find every callback that may still hold assets.

Tokens held by the callback contract itself, outside Morpho Blue, such as rewards earned by its Morpho Blue position, can be sent to you with the callback's skim(token) function. Anyone can call it, and it always pays the callback's owner.