Exit lend early
Exit a Midnight lend before maturity by selling credit units to buy offers, with quotes and SDK or contract flows for full or partial exits.
Reduce-only is a check, not a cap: a take that exceeds the lender's remaining credit reverts the transaction.
Snippets assume a viem wallet client walletClient for your account on Base, and taker set to your wallet address.
Read the position
The REST API is the quickest way to inspect the position. It returns the stored, indexed values and can lag a few blocks. To size the transaction, run the SDK read or the direct contract read: they return the live, accrued values that the next steps use.
REST API
Fetch the lender's position to get its credit and pending fee:
GET https://api.morpho.org/v0/midnight/markets
/{marketId}/users/{userAddress}/position
// Response
{
"data": {
"chain_id": 8453,
"market_id": "0xd92de5e7fbb...7614", // market the lender lent into
"user_address": "0xYourWalletAddress",
"last_indexed_block": "47457420",
"loan_token": "0x8335...2913", // USDC
"maturity": "1798761600",
"type": "lend", // "lend" | "borrow" | "collateral_only"
"credit": "10500000000", // credit units held - what the exit sells
"debt": "0",
"pending_fee": "12000", // continuous fee accrued, not yet deducted from credit
"last_loss_factor": "0",
"loss_factor": "0",
"collaterals": [] // lenders post no collateral
}
}
// Inspection only - size the transaction from the SDK or direct contract read below.SDK
The SDK reads the same position through midnight.getPositionData, accrued to the latest block:
import { publicActions } from "viem";
import { morphoViemExtension } from "@morpho-org/morpho-sdk";
// Extend the wallet client with the `morpho` namespace and the public reads
// the SDK needs.
const client = walletClient.extend(morphoViemExtension()).extend(publicActions);
const midnight = client.morpho.midnight(8453); // Midnight entity for Base
// The extended client doubles as the public client the execute snippet reads with.
const publicClient = client;
// Accrued position at the latest block - positionData.credit and
// positionData.pendingFee are the same values updatePositionView returns as
// newCredit / newPendingFee in the direct contract read below.
const block = await client.getBlock();
const positionData = (
await midnight.getPositionData({
marketId,
accountAddress: taker,
parameters: { blockNumber: block.number },
})
).accrueInterest(block.timestamp);
// market = the Market struct - from GET /v0/midnight/markets/{marketId} or a prior browse step
// Deadline for the exit transaction - the fee buffer below is sized to it
const deadline = BigInt(Math.floor(Date.now() / 1000) + 3600); // 1 hour from now
const now = block.timestamp;
// Pending fee accrues linearly until maturity, so credit shrinks between this
// read and execution - buffer the target by the fee accrued before the
// deadline, or the take reverts `NotReduceOnly`. The buffer is measured from
// the snapshot block, so it also covers the fee accrued between the read and
// now. Fee accrual stops at maturity, so the projection end is capped there.
const accrualEnd = deadline < BigInt(market.maturity) ? deadline : BigInt(market.maturity);
const feeBuffer =
positionData.pendingFee === 0n || BigInt(market.maturity) <= now
? 0n
: (positionData.pendingFee * (accrualEnd - now) + BigInt(market.maturity) - now - 1n) /
(BigInt(market.maturity) - now); // ceil(pendingFee x (accrualEnd-now) / (maturity-now)), capped at maturity
// Full-exit units target: accrued credit minus the fee buffer. The buffer
// leaves a small remainder - exit it with the partial exit below or redeem it
// at maturity.
const targetUnits = positionData.credit - feeBuffer;
if (targetUnits <= 0n) throw new Error("nothing to sell after the fee buffer");
// MidnightBundlesV2 withdraws this par-paid portion before it walks bids.
const withdrawableUnits = positionData.market.withdrawable;
const withdrawnUnits =
targetUnits < withdrawableUnits ? targetUnits : withdrawableUnits;
const unitsToQuote = targetUnits - withdrawnUnits;Onchain (direct contract read)
A full exit must be sized to the exact sellable credit right now - and slightly below it: the pending fee accrues linearly until maturity, so the read is stale by the time the transaction lands and the target must be buffered for the fee that accrues before its deadline. The sell-side bundle enforces reduceOnly against updatePositionView's post-accrual newCredit, not the raw credit getter - the stored value is stale relative to loss-factor application and pending fee accrual, and targeting it would revert NotReduceOnly.
import { createPublicClient, http } from "viem";
import { base } from "viem/chains";
import { midnightAbi } from "@morpho-org/morpho-sdk/abis";
const MIDNIGHT = "0xAdedD8ab6dE832766Fedf0FaC4992E5C4D3EA18A"; // Midnight on Base (from the @morpho-org/morpho-ts address registry)
const publicClient = createPublicClient({ chain: base, transport: http() });
// market = the Market struct - from GET /v0/midnight/markets/{marketId} or a prior browse step
const block = await publicClient.getBlock();
const [newCredit, newPendingFee, accruedFee] = await publicClient.readContract({
address: MIDNIGHT,
abi: midnightAbi,
functionName: "updatePositionView",
args: [market, marketId, taker],
blockNumber: block.number,
});
// Deadline for the exit transaction - the fee buffer below is sized to it
const deadline = BigInt(Math.floor(Date.now() / 1000) + 3600); // 1 hour from now
const now = block.timestamp;
// Pending fee accrues linearly until maturity, so newCredit shrinks between
// this read and execution - buffer the target by the fee accrued before
// the deadline, or the take reverts `NotReduceOnly`. The buffer is measured
// from the snapshot block, so it also covers the fee accrued between the
// read and now. Fee accrual stops at maturity, so the projection end is
// capped there.
const accrualEnd = deadline < BigInt(market.maturity) ? deadline : BigInt(market.maturity);
const feeBuffer =
newPendingFee === 0n || BigInt(market.maturity) <= now
? 0n
: (newPendingFee * (accrualEnd - now) + BigInt(market.maturity) - now - 1n) /
(BigInt(market.maturity) - now); // ceil(newPendingFee x (accrualEnd-now) / (maturity-now)), capped at maturity
// Full-exit units target: post-accrual credit minus the fee buffer. The raw
// `credit` getter is stale for this purpose (loss factor / pending fee).
// The buffer leaves a small remainder - exit it with the partial exit
// below or redeem it at maturity.
const targetUnits = newCredit - feeBuffer;
if (targetUnits <= 0n) throw new Error("nothing to sell after the fee buffer");
const withdrawableUnits = await publicClient.readContract({
address: MIDNIGHT,
abi: midnightAbi,
functionName: "withdrawable",
args: [marketId],
blockNumber: block.number,
});
const withdrawnUnits =
targetUnits < withdrawableUnits ? targetUnits : withdrawableUnits;
const unitsToQuote = targetUnits - withdrawnUnits;Get a quote
A lend exit takes bids - makers buying units.
A full exit targets the position's sellable credit, but the bid quote covers only unitsToQuote = targetUnits - min(targetUnits, withdrawableUnits) because MidnightBundlesV2 first withdraws the par-paid portion. When unitsToQuote is zero, there is no bid quote or offer set to submit. withdrawable can change before execution. If liquidity changes and the transaction reverts, reread the position and withdrawable amount, then request a fresh quote.
REST API
GET https://api.morpho.org/v0/midnight/books/{marketId}/bids/quote
?units=4500000000 // example remainder: 10.5B target - min(10.5B target, 6B withdrawable); use assets= for a partial
&slippage=0.5 // 0.5% slippage guard
&limit=10000 // max offers walked/returned; default and maximum 10,000
// Response: the router's execution plan
{
"data": {
"average_best_price": "947619047619047619", // realized average price for the requested bid remainder
"average_worst_price": "943000000000000000", // conservative adverse-placement bound for bids, within the 0.5% guard
"available_assets": "9969975000", // includes fallback excess beyond the requested bid payout
"available_units": "10500000000", // maximum units in this returned limit window
"takeable_offers": [
{
"market_id": "0xd92de5e7fbb...7614",
"units": "5250000000", // maximum this offer may contribute, not an exact allocation
"offer": { /* buy offer: the maker is lending */ },
"ratifier_data": "0x4a8b...f201"
}
// Additional complete offers follow in best-price order as fallback excess.
]
}
}
// Interpreting the quote
const quote = response.data;
const targetUnitsForExample = 10_500_000_000n;
const withdrawableUnitsForExample = 6_000_000_000n;
const withdrawnUnitsForExample =
targetUnitsForExample < withdrawableUnitsForExample
? targetUnitsForExample
: withdrawableUnitsForExample;
const unitsToQuoteForExample = targetUnitsForExample - withdrawnUnitsForExample;
const WAD = 10n ** 18n;
const averageBestPriceWad = BigInt(quote.average_best_price);
const expectedPayout =
withdrawnUnitsForExample +
(unitsToQuoteForExample * averageBestPriceWad) / WAD; // about 10,264 USDC, including the par-withdrawn portion
// Minimum combined payout: par-withdrawn units plus the bid remainder at 0.94.
const minSellerAssets =
withdrawnUnitsForExample +
(unitsToQuoteForExample * 940_000_000_000_000_000n) / WAD;
// A full exit targets the available credit - units already backing your own
// resting reduce-only sell offers are committed elsewhere.SDK
MidnightApi.fetchBookQuote wraps the quote endpoint and returns ABI-ready take objects:
import { MidnightApi } from "@morpho-org/morpho-sdk/midnight-api";
// SDK wraps the quote endpoint and maps the response into ABI-ready take
// objects (uses Morpho's router by default, no config needed)
const quote =
unitsToQuote > 0n
? await MidnightApi.fetchBookQuote({
marketId,
side: "bids", // exiting lender takes bids (not asks)
units: unitsToQuote, // only the remainder after par withdrawal
slippage: "0.5", // 0.5% slippage guard
})
: undefined;
// ABI-ready takes; the type is inferred from the pinned API result.
const takeableOffers = quote?.data.takeableOffers ?? [];There is no onchain quote: offers are signed and published offchain, not stored in the Midnight contract.
Authorize MidnightBundlesV2
The bundle pays the lender rather than pulling tokens from them, so no ERC-20 approval is needed - only the one-time MidnightBundlesV2 authorization on the Midnight contract:
import { midnightAbi } from "@morpho-org/morpho-sdk/abis";
const MIDNIGHT_BUNDLES_V2 = "0x0bF30B530f816a619C3F28a3b9f44C441122555D"; // MidnightBundlesV2 on Base (from the @morpho-org/morpho-ts address registry)
const MIDNIGHT = "0xAdedD8ab6dE832766Fedf0FaC4992E5C4D3EA18A"; // Midnight on Base (from the @morpho-org/morpho-ts address registry)
// Authorize MidnightBundlesV2 to act on your position (one-time boolean, persists across future actions).
const authorizationHash = await walletClient.writeContract({
account: taker,
address: MIDNIGHT,
abi: midnightAbi,
functionName: "setIsAuthorized",
args: [MIDNIGHT_BUNDLES_V2, true, taker],
});
const authorizationReceipt = await publicClient.waitForTransactionReceipt({
hash: authorizationHash,
});
if (authorizationReceipt.status !== "success") {
throw new Error("MidnightBundlesV2 authorization failed");
}Execute the exit
A full exit uses the full targetUnits with bid offers sized only to unitsToQuote, while the bundle withdraws the par-paid portion first. A zero remainder skips the offer-based bundle and uses direct Midnight.withdraw; otherwise the SDK or raw bundle call submits the remainder's offers in one reduce-only transaction.
Execute through the Morpho SDK
Morpho SDK 7.0.0 uses midnight.takeWithdraw for a non-zero remainder through MidnightBundlesV2, while midnight.redeem encodes direct Midnight.withdraw when withdrawable liquidity covers the full target. Only the bundle path needs its authorization requirement.
const WAD = 10n ** 18n;
const minPrice = 940_000_000_000_000_000n;
const minSellerAssets = withdrawnUnits + (unitsToQuote * minPrice) / WAD;
const marketData = positionData.market;
const deadline = BigInt(Math.floor(Date.now() / 1000) + 600);
const output =
unitsToQuote === 0n
? midnight.redeem({
accountAddress: taker,
positionData,
units: targetUnits,
receiver: taker,
})
: midnight.takeWithdraw({
accountAddress: taker,
marketData,
target: { type: "units", units: targetUnits, minSellerAssets },
takeableOffers,
deadline,
});
for (const requirement of await output.getRequirements()) {
if ("sign" in requirement) throw new Error("Unexpected signature requirement");
const hash = await client.sendTransaction({
to: requirement.to,
data: requirement.data,
value: requirement.value,
});
await client.waitForTransactionReceipt({ hash });
}
const hash = await client.sendTransaction(output.buildTx());
console.log("Exit tx:", hash);See Taker flows for the full SDK surface.
Full exit: sell all credit
targetUnits is the position's sellable credit from the onchain read. minSellerAssets is the minimum combined payout: withdrawnUnits + (unitsToQuote * worstAcceptablePriceWad) / WAD, with the par-withdrawn portion counted at 1:1. The raw MidnightBundlesV2 full-exit call below is for unitsToQuote > 0n; when it is zero, use the direct Midnight.withdraw path instead of passing an empty offer set.
import { midnightBundlesV2Abi } from "@morpho-org/morpho-sdk/abis";
import { zeroAddress } from "viem";
const MIDNIGHT_BUNDLES_V2 = "0x0bF30B530f816a619C3F28a3b9f44C441122555D"; // MidnightBundlesV2 on Base (from the @morpho-org/morpho-ts address registry)
const WAD = 10n ** 18n;
const worstAcceptablePriceWad = 940_000_000_000_000_000n;
const minSellerAssets =
withdrawnUnits + (unitsToQuote * worstAcceptablePriceWad) / WAD;
const hash = await walletClient.writeContract({
account: taker,
address: MIDNIGHT_BUNDLES_V2,
abi: midnightBundlesV2Abi,
functionName: "midnightBundlesV2SupplyCollateralAndSellWithUnitsTarget",
args: [
market, // market
targetUnits, // targetUnits from updatePositionView
minSellerAssets, // minimum loan tokens to receive
true, // reduceOnly (this is an exit)
taker, // receiver
[], // collateralSupplies
takeableOffers, // offerFills
0n, // referralFeePct
zeroAddress, // referralFeeRecipient
deadline, // deadline defined with the position read
zeroAddress, // wrappedNative, unused when msg.value is 0
],
});
console.log("Exit tx:", hash);
// Units backing your own resting reduce-only sell offers are already committed.
// Size targetUnits to available credit or cancel those offers first.Partial exit: sell down to an amount
In this zero-fee example, targetSellerAssets is how many loan tokens to receive. The bundle withdraws available credit at par first and takes offers only if that does not meet the target, so an empty offer list is valid when par liquidity covers it. maxUnits caps the credit units given up; calculate it with WAD-scaled ceiling division from the worst-acceptable price.
import { midnightBundlesV2Abi } from "@morpho-org/morpho-sdk/abis";
import { parseUnits, zeroAddress } from "viem";
const MIDNIGHT_BUNDLES_V2 = "0x0bF30B530f816a619C3F28a3b9f44C441122555D"; // MidnightBundlesV2 on Base (from the @morpho-org/morpho-ts address registry)
const targetSellerAssets = parseUnits("5000", 6); // 5,000 USDC to receive
const WAD = 10n ** 18n;
const worstAcceptablePriceWad = 940_000_000_000_000_000n;
const maxUnits =
(targetSellerAssets * WAD + worstAcceptablePriceWad - 1n) / worstAcceptablePriceWad;
const hash = await walletClient.writeContract({
account: taker,
address: MIDNIGHT_BUNDLES_V2,
abi: midnightBundlesV2Abi,
functionName: "midnightBundlesV2SupplyCollateralAndSellWithAssetsTarget",
args: [
market, // market
targetSellerAssets, // loan tokens to receive
maxUnits, // maximum credit units to sell
true, // reduceOnly
taker, // receiver
[], // collateralSupplies
takeableOffers, // offerFills
0n, // referralFeePct
zeroAddress, // referralFeeRecipient
deadline, // 1-hour deadline from the position read
zeroAddress, // wrappedNative, unused when msg.value is 0
],
});
console.log("Partial exit tx:", hash);Exit at a chosen price
Instead of taking bids now, a lender can publish a resting reduce-only sell offer at a chosen tick: it stays published until it is taken, cancelled or expires, and closes credit only when another user takes it at that price. Reduce-only keeps the offer from ever borrowing beyond the credit it backs.
On the SDK, sell-side maker offers are posted through makeWithdraw (the flow validates reduce-only buy: false offers):
import { addresses } from "@morpho-org/morpho-ts";
import { Offer } from "@morpho-org/midnight-sdk";
import { maxUint256 } from "viem";
const chainId = 8453;
const priceRatifierV1 = addresses[chainId].priceRatifierV1!;
const exitOffer = Offer.create({
market,
maker: taker,
buy: false,
reduceOnly: true,
tick: 5_000n,
maxUnits: targetUnits,
expiry: BigInt(
Math.min(Number(market.maturity), Math.floor(Date.now() / 1000) + 604_800),
),
ratifier: priceRatifierV1,
});
// `midnight` is the MorphoMidnight entity built in the SDK page's Maker flows section.
const output = await midnight.makeWithdraw({
accountAddress: taker,
offers: { type: "priceV1", entries: [{ offer: exitOffer }] },
deadline: maxUint256,
});
for (const requirement of await output.getRequirements()) {
if ("sign" in requirement) throw new Error("Unexpected signature requirement");
const requirementHash = await walletClient.sendTransaction({
to: requirement.to,
data: requirement.data,
value: requirement.value,
});
await publicClient.waitForTransactionReceipt({ hash: requirementHash });
}
const makerHash = await walletClient.sendTransaction(output.buildTx());
console.log("Offer publication tx:", makerHash);
// MidnightBundlesV2 publishes atomically without a separate root signature.
// The offer only closes credit when taken at this price.
// Committed credit is excluded from a full taker exit's available amount.See Maker flows for the full pipeline (grouping, tree, ratification, atomic publication).
Exit borrow at maturity
Close a Midnight borrow at maturity by reading debt and collateral, repaying the full balance, and withdrawing collateral through SDK or contract calls.
Exit a borrow early
Exit a Midnight borrow before maturity by buying units from offers, quoting and authorizing a full or partial repayment, and releasing collateral.