Fixed Rate Market - Midnight
Introduction
Morpho Midnight is Morpho's fixed-rate, fixed-term market: lenders and borrowers take ratified maker offers that settle credit units onchain at a fixed discount.
The Morpho SDK is the primary entrypoint for integrating Midnight. Transaction flows below builds ready-to-send transactions - taker flows, maker publications, and position maintenance - through the same getRequirements / buildTx flow as every other Morpho surface, via client.morpho.midnight(chainId).
The rest of the page documents the layer underneath those flows: the Midnight toolkit, which ships inside the Morpho SDK. Reach for it when you need to go below the transaction flows:
- Make side - construct offers locally, group offers that share a consumption cap, commit them to a Merkle tree, validate the tree against the Midnight API's mempool policy, and publish offers through
MidnightBundlesV2. Morpho SDK 7.0.0 selectsPriceRatifierV1orRateRatifierV1from the tree and publishes the root and payload atomically; see thePriceRatifierV1andRateRatifierV1reference for details. - Take side - query books, price levels, and quotes from the Midnight API, and execute the returned ABI-ready takes against the
Midnightcontract.
It also ships viem fetch helpers for onchain market and position state, entity classes with local accrual math, TypeScript ports of the protocol's tick math, protocol constants, contract ABIs, and dedicated error classes.
For protocol theory - how ticks discretize prices, how the mempool and router work, how collateral and liquidations behave - see the Midnight docs, in particular Tick structure, Market mechanics, and Mempool & router.
Installation
Everything on this page ships with the Morpho SDK:
pnpm add @morpho-org/morpho-sdk@7.0.0 viem@^2.0.0Examples that import primitives directly from @morpho-org/midnight-sdk or @morpho-org/morpho-ts need those packages declared in your own dependencies as well - both ship with the Morpho SDK. The pinned toolkit package is @morpho-org/midnight-sdk@2.0.0.
Transaction flows
Midnight is Morpho's fixed-rate, fixed-term market surface. Instead of the shared, variable-rate Blue pool, lenders and borrowers settle credit units at a fixed discount through ratified offers: a maker publishes offers through MidnightBundlesV2, and a taker takes them onchain. Each take settles the fixed economics encoded by its offer.
The Morpho SDK exposes Midnight through the same getRequirements / buildTx flow as every other Morpho surface - so you build Midnight transactions with the client, requirement dispatch, and error-handling patterns you already know. The lower-level offer / tree / ratifier toolkit (constructing offers, querying published offers and quotes, tick math) is documented in the sections that follow.
Morpho SDK 7.0.0 and Midnight SDK 2.0.0 target MidnightBundlesV2. MidnightBundlesV1 remains functional, and migration to MidnightBundlesV2 is optional. See the Bundles CHANGELOG for the differences.
client.morpho.midnight(chainId) returns a MorphoMidnight bound to the extended client, keeping reads - market, position, and offer state - separate from transaction construction: the returned buildTx callback is synchronous and only consumes data you already passed in. You build for three kinds of flow: taker flows take existing offers onchain, maker flows publish offers through MidnightBundlesV2, and position maintenance covers supplying collateral, redeeming accrued credit, repaying or withdrawing, and cancelling offers.
Midnight is deployed only on Base, so its flows need a client built on that chain - a mainnet client (like the one built in the Morpho SDK page's Setup) fails the SDK's chain check. The Midnight deployment lives at chain id 8453 (ChainId.BaseMainnet).
Setup
Midnight lives only on Base, so build a Base wallet client, extend it with morphoViemExtension(), and add viem publicActions for the block reads the accrual and requirement flows need. Reuse this midnight entity across every flow below.
import "dotenv/config";
import { type Address, createWalletClient, http, publicActions } from "viem";
import { privateKeyToAccount } from "viem/accounts";
import { base } from "viem/chains";
import { morphoViemExtension } from "@morpho-org/morpho-sdk";
const account = privateKeyToAccount(process.env.PRIVATE_KEY as `0x${string}`);
// Midnight lives on Base, so build the client on `base` (chain id 8453) - not a
// mainnet client. `publicActions` adds the `getBlock` and
// `waitForTransactionReceipt` reads the accrual and requirement flows use.
const client = createWalletClient({
account,
chain: base,
transport: http(process.env.BASE_RPC_URL),
})
.extend(morphoViemExtension())
.extend(publicActions);
// Every entity is chain-validated against the client. Reuse `midnight` across
// all Midnight flows below.
const midnight = client.morpho.midnight(base.id);
const user: Address = account.address;
const marketId = "0xMidnightMarketId..." as `0x${string}`;Fetch state
Read fresh, same-block state and pass it into the builders. Fetch a block once, forward its number to getMarketData / getPositionData, then accrue the position to that block's timestamp.
| Method | Returns |
|---|---|
getMarketData(marketId, parameters?) | A hydrated Midnight Market - market params plus current onchain state. |
getPositionData({ marketId, accountAddress, parameters? }) | An AccrualPosition for the account, paired with its hydrated market. |
getOffersData({ accountAddress, offers, validation? }) | Validated maker-offer data: the offer tree, its groups, ratifier type and address, and encoded payload. |
Taker flows
A taker takes maker offers surfaced by the Midnight API. Fetch a quote with the API client (or the API directly), then hand its takeable offers to a taker flow. Each returns the standard getRequirements / buildTx handles and takes a deadline.
Morpho SDK 7.0.0 supports reduce-only early exits through takeWithdraw and takeRepayWithdrawCollateral, both routed through MidnightBundlesV2. See Exit lend early and Exit borrow early.
import { MidnightApi } from "@morpho-org/morpho-sdk/midnight-api";
import { maxUint256, parseUnits } from "viem";
const deadline = BigInt(Math.floor(Date.now() / 1000) + 3600);
const WAD = 10n ** 18n;
// Pass a fresh, same-block market snapshot into the builder.
const block = await client.getBlock();
const marketData = await midnight.getMarketData(marketId, {
blockNumber: block.number,
});
const lendQuote = await MidnightApi.fetchBookQuote({
marketId,
side: "asks",
assets: targetLoanAssets,
slippage: "0.5",
});
const borrowQuote = await MidnightApi.fetchBookQuote({
marketId,
side: "bids",
assets: targetLoanAssets,
slippage: "0.5",
});
const withdrawQuote = await MidnightApi.fetchBookQuote({
marketId,
side: "bids",
units: creditUnits,
slippage: "0.5",
});
const lendPrice = BigInt(lendQuote.data.averageWorstPrice);
const minUnits = (targetLoanAssets * WAD) / lendPrice;
// Lend side: spend `assets` of the loan token for at least `minUnits` of credit.
// Routes through MidnightBundlesV2. Taker deadlines cannot be more than one hour away.
const takeLend = midnight.takeLend({
accountAddress: user,
marketData,
target: {
type: "assets",
assets: targetLoanAssets,
minUnits,
},
takeableOffers: lendQuote.data.takeableOffers,
maxContinuousFee: maxUint256,
deadline,
});
// Needed token approvals default to maxUint256. Pass approvalAmount to set a different amount.
// Requirements are plain transactions: an ERC-20 approval to MidnightBundlesV2 and a
// one-time MidnightBundlesV2 authorization, not Permit/Permit2 or signable requests.
// Send each and wait for its receipt before buildTx().
const takeLendRequirements = await takeLend.getRequirements({
approvalAmount: targetLoanAssets,
});
for (const requirement of takeLendRequirements) {
const hash = await client.sendTransaction(requirement);
await client.waitForTransactionReceipt({ hash });
}
const takeLendTx = takeLend.buildTx();
await client.sendTransaction(takeLendTx);
// Borrow side: receive the requested loan assets for at most `maxUnits` of debt. Also a
// MidnightBundlesV2 call; its only requirement is MidnightBundlesV2
// authorization (no token is pulled from the borrower at take time).
const borrowPrice = BigInt(borrowQuote.data.averageWorstPrice);
const maxUnits =
(targetLoanAssets * WAD + borrowPrice - 1n) / borrowPrice;
const takeBorrow = midnight.takeBorrow({
accountAddress: user,
marketData,
target: { type: "assets", assets: targetLoanAssets, maxUnits },
takeableOffers: borrowQuote.data.takeableOffers,
deadline,
});
const takeBorrowTx = takeBorrow.buildTx();
// Supply listed collateral and borrow from lend-side offers in one
// MidnightBundlesV2 call. `collateralSupplies` explicitly sets index 0n
// and the amount; requirements include collateral approval and the
// MidnightBundlesV2 authorization.
const supplyCollateralTakeBorrow = midnight.supplyCollateralTakeBorrow({
accountAddress: user,
marketData,
collateralSupplies: [{ collateralIndex: 0n, assets: parseUnits("2", 18) }],
target: { type: "assets", assets: targetLoanAssets, maxUnits },
takeableOffers: borrowQuote.data.takeableOffers,
deadline,
});
const supplyCollateralTakeBorrowTx = supplyCollateralTakeBorrow.buildTx();
const withdrawPrice = BigInt(withdrawQuote.data.averageWorstPrice);
const minSellerAssets = (creditUnits * withdrawPrice) / WAD;
const takeWithdraw = midnight.takeWithdraw({
accountAddress: user,
marketData,
target: { type: "units", units: creditUnits, minSellerAssets },
takeableOffers: withdrawQuote.data.takeableOffers,
deadline,
});
const positionData = (
await midnight.getPositionData({
marketId,
accountAddress: user,
parameters: { blockNumber: block.number },
})
).accrueInterest(block.timestamp);
const debtUnits = positionData.debt;
if (debtUnits > 0n) {
const repayQuote = await MidnightApi.fetchBookQuote({
marketId,
side: "asks",
units: debtUnits,
slippage: "0.5",
});
if (BigInt(repayQuote.data.availableUnits) < debtUnits) {
throw new Error("Ask quote does not cover the full debt");
}
const acceptablePrice = WAD;
const maxBuyerAssets =
(debtUnits * acceptablePrice + WAD - 1n) / WAD;
const takeRepayWithdrawCollateral = midnight.takeRepayWithdrawCollateral({
accountAddress: user,
marketData,
target: { type: "units", units: debtUnits, maxBuyerAssets },
takeableOffers: repayQuote.data.takeableOffers,
repayEnabled: true,
collateralWithdrawals: [{ collateralIndex: 0n, assets: maxUint256 }],
maxContinuousFee: maxUint256,
deadline,
});
const takeRepayRequirements =
await takeRepayWithdrawCollateral.getRequirements();
for (const requirement of takeRepayRequirements) {
const hash = await client.sendTransaction(requirement);
await client.waitForTransactionReceipt({ hash });
}
await client.sendTransaction(takeRepayWithdrawCollateral.buildTx());
}Maker flows
A maker publishes offers through MidnightBundlesV2 with one atomic midnightBundlesV2CancelAndMake call. The SDK validates the offer tree against the mempool API, then the call activates its PriceRatifierV1 or RateRatifierV1 root and publishes the payload; optional cancellations replace prior groups in the same transaction. See PriceRatifierV1 and RateRatifierV1. These flows return the offer groups, tree root, ratifierType, and getRequirements / buildTx handles.
import { maxUint128, maxUint256, parseUnits } from "viem";
// Use a PriceRatifierV1 or RateRatifierV1 tree prepared with the toolkit.
const makeLend = await midnight.makeLend({
accountAddress: maker,
offers: lendOffers,
loanToken: market.loanToken,
loanAssets: parseUnits("250000", 6),
cancellations: [{ group: previousGroup, maxConsumed: maxUint128 }],
deadline: maxUint256,
});
// Needed token approvals default to maxUint256. Use approvalAmount to set another value.
const requirements = await makeLend.getRequirements({
approvalAmount: parseUnits("250000", 6),
});
for (const requirement of requirements) {
const hash = await client.sendTransaction(requirement);
await client.waitForTransactionReceipt({ hash });
}
await client.sendTransaction(makeLend.buildTx());
const makeBorrow = await midnight.makeBorrow({
accountAddress: maker,
offers: borrowOffers,
deadline: maxUint256,
});
const makeWithdraw = await midnight.makeWithdraw({
accountAddress: maker,
offers: reduceOnlySellOffers,
deadline: maxUint256,
});
const makeRepay = await midnight.makeRepay({
accountAddress: maker,
offers: reduceOnlyBuyOffers,
loanToken: market.loanToken,
loanAssets: parseUnits("250000", 6),
deadline: maxUint256,
});
const supplyCollateralMakeBorrow = await midnight.supplyCollateralMakeBorrow({
accountAddress: maker,
offers: borrowOffers,
collateral: {
market: marketData.params,
supplies: [{ collateralIndex: 0n, assets: parseUnits("2", 18) }],
},
deadline: maxUint256,
});
const supplyBlueMakeLend = await midnight.supplyBlueMakeLend({
accountAddress: maker,
offers: blueLendOffers,
blueMarket,
assetsToPark: parseUnits("250000", 6),
callbackSalt,
deadline: maxUint256,
});Position maintenance
import { maxUint128, maxUint256, parseUnits } from "viem";
const block = await client.getBlock();
const marketData = await midnight.getMarketData(marketId, {
blockNumber: block.number,
});
// Supply collateral directly to the Midnight contract (no bundle). Its only
// requirement is a collateral ERC-20 approval to the Midnight contract.
const supplyCollateral = midnight.supplyCollateral({
accountAddress: user,
marketData,
collateralAssets: parseUnits("2", 18),
});
for (const requirement of await supplyCollateral.getRequirements()) {
const hash = await client.sendTransaction(requirement);
await client.waitForTransactionReceipt({ hash });
}
const supplyCollateralTx = supplyCollateral.buildTx();
// Redeem accrued fixed-rate credit - a direct Midnight `withdraw` call with no
// requirements. `units` defaults to the position's face value and `receiver`
// defaults to `accountAddress`. Pass a fresh, accrued position snapshot.
const positionData = (
await midnight.getPositionData({
marketId,
accountAddress: user,
parameters: { blockNumber: block.number },
})
).accrueInterest(block.timestamp);
const redeem = midnight.redeem({
accountAddress: user,
positionData,
});
const redeemTx = redeem.buildTx();
await client.sendTransaction(redeemTx);
// Repay debt and/or withdraw collateral in one MidnightBundlesV2 call. Requirements are
// a standard loan-token approval when needed plus MidnightBundlesV2
// authorization.
const repayWithdrawCollateral = midnight.repayWithdrawCollateral({
accountAddress: user,
marketData,
repay: { type: "full", maxBuyerAssets: parseUnits("1000", 6) },
collateralWithdrawals: [{ collateralIndex: 0n, assets: parseUnits("2", 18) }],
deadline: maxUint256,
});
const repayWithdrawCollateralTx = repayWithdrawCollateral.buildTx();
// Cancel maker groups through MidnightBundlesV2.
const cancelOffers = midnight.cancelOffers({
accountAddress: user,
cancellations: [{ group, maxConsumed: maxUint128 }],
deadline: maxUint256,
});
for (const requirement of await cancelOffers.getRequirements()) {
const hash = await client.sendTransaction(requirement);
await client.waitForTransactionReceipt({ hash });
}
const cancelOffersTx = cancelOffers.buildTx();Use maxUint128 for an unrestricted cancellation. A lower ceiling reverts if current
group consumption already exceeds it.
See the Actions overview on the Morpho SDK page for the full route table and its Errors and invariants for the Midnight error classes.
The rest of this page covers lower-level toolkit utilities, separate from the maker path in Morpho SDK 7.0.0. For current maker integrations, use PriceRatifierV1 or RateRatifierV1 with tagged tree requests in Midnight SDK 2.0.0; the SDK maker flow authorizes the selected PriceRatifierV1 or RateRatifierV1 ratifier for the maker through MidnightBundlesV2 and ratifies its root atomically. See the PriceRatifierV1 and RateRatifierV1 reference.
Core concepts
How the toolkit's types map onto the protocol:
- Markets. A Midnight market is fully described by its immutable params -
loanToken,collateralParams(one{ token, lltv, liquidationCursor, oracle }entry per accepted collateral),maturity,rcfThreshold,enterGate/liquidatorGate- plus thechainIdandmidnightcontract address. The SDK models them asMarketParams, and a hydratedMarketadds mutable state. Onchain reads address markets by theirmarketIdhash. - Ticks and prices. Offer prices live on a discrete tick grid: each tick indexes a WAD-scaled price ≤ 1 (the discount paid today per unit redeemed at maturity), so a tick is equivalently a fixed rate. Ticks must be aligned to the market's tick spacing.
TickLibconverts tick ↔ price ↔ rate ↔ APR - see Tick math. - Units and assets. Offer consumption is capped either in units (the protocol's normalized quantity dimension) or in assets (loan-token amounts). Exactly one of
maxUnits/maxAssetsmust be non-zero on an offer;TakeAmountsLibconverts between the two at a given tick. - Offers. A maker offer commits to a market, a side (
buy: true- the maker buys units, i.e. lends;buy: false- the maker sells units, i.e. borrows), atick, a consumption cap, astart/expirywindow, and aratifier. Optional fields (reduceOnly,continuousFeeCap, maker callback, …) default to protocol-sensible values inOffer.create. - Ratifiers. Offers are committed in trees, and a ratifier contract checks that an offer belongs to a maker-authorized root when it is taken. Use
PriceRatifierV1orRateRatifierV1with tagged tree requests in Midnight SDK 2.0.0 for current integrations; see thePriceRatifierV1andRateRatifierV1reference and Ratification. - Mempool. Encoded offer payloads are published as calldata to the Midnight mempool contract, where the offchain router and API index them for quote and take lookups. See Mempool & router.
Making offers
The lower-level toolkit supports a manual pipeline: build offers → group the ones that share a cap → commit everything to a tree → validate → use a ratification utility → encode → publish. This is separate from the maker methods in Morpho SDK 7.0.0, which authorize the selected PriceRatifierV1 or RateRatifierV1 ratifier for the maker through MidnightBundlesV2 and publish atomically.
1. Define the market and create offers
import { addresses } from "@morpho-org/morpho-ts";
import { Offer } from "@morpho-org/midnight-sdk";
import { parseUnits, zeroAddress, type Address } from "viem";
const chainId = 8453;
const usdc = addresses[chainId].usdc!;
const cbBtc = "0xcbB7C0000aB88B473b1f5aFd9ef808440eed33Bf" as Address; // cbBTC on Base
const midnightAddress = addresses[chainId].midnight!;
const priceRatifierV1 = addresses[chainId].priceRatifierV1!;
const maker = account.address;
const usdcOracle = "0x4429112b3C00b40Cf295db81cfcdE39d7d5ADC1d" as Address; // from the live market's collateralParams
const cbBtcOracle = "0x663BECd10daE6C4A3Dcd89F1d76c1174199639B9" as Address; // from the live market's collateralParams
// Immutable market description, copied verbatim from a live Base market
// (0xf5564b5322b8...3fc5, GET /v0/midnight/markets?chain_id=8453) so its
// maturity is in the future. Onchain reads address the market by its id hash.
const market = {
chainId,
midnight: midnightAddress,
loanToken: usdc,
collateralParams: [
{
token: usdc,
lltv: 980_000_000_000_000_000n,
liquidationCursor: 300_000_000_000_000_000n,
oracle: usdcOracle,
},
{
token: cbBtc,
lltv: 860_000_000_000_000_000n,
liquidationCursor: 300_000_000_000_000_000n,
oracle: cbBtcOracle,
},
],
maturity: 1_806_073_200n, // 2027-03-26 15:00:00 UTC.
rcfThreshold: 3_000000000n,
enterGate: zeroAddress,
liquidatorGate: zeroAddress,
};
// Offer expiry - now + 7 days, capped at the market's maturity
const offerExpiry = BigInt(Math.min(Number(market.maturity), Math.floor(Date.now() / 1000) + 604_800));
const commonOffer = {
market,
maker,
expiry: offerExpiry,
ratifier: priceRatifierV1,
};
// Maker buys units = lends. `maxAssets` caps consumption in loan-token terms.
const lend250kUsdc = Offer.create({
...commonOffer,
buy: true,
tick: 5_000n,
maxAssets: parseUnits("250000", 6),
});
// Maker sells units = borrows. `maxUnits` caps consumption in units.
const borrow50Units = Offer.create({
...commonOffer,
buy: false,
tick: 5_024n,
maxUnits: parseUnits("50", 18),
});Fields you omit get protocol defaults: start defaults to zero (immediately takeable), continuousFeeCap to the protocol maximum, callback / callbackData to none, and receiverIfMakerIsSeller to the maker on sell offers. Exactly one of maxUnits / maxAssets must be non-zero.
2. Group offers that share a consumption cap (optional)
Group.create puts several offers behind one shared consumption budget - Midnight tracks a single consumed scalar per maker and group. Offers in a group must share maker, side, loan token, and cap mode and value; the group id is derived by hashing the offers, and each offer is copied with that id.
import { Group } from "@morpho-org/midnight-sdk";
// One 250k USDC budget posted at two ticks - a fill on either tick
// consumes the same shared cap.
const lendLadder = Group.create([
Offer.create({ ...commonOffer, buy: true, tick: 5_000n, maxAssets: parseUnits("250000", 6) }),
Offer.create({ ...commonOffer, buy: true, tick: 5_012n, maxAssets: parseUnits("250000", 6) }),
]);Group creation is resource-intensive compared to offer construction - build offers first, group once.
For makers quoting a fixed rate across a longer window, the OfferChainUtils namespace builds offer chains - adjacent grouped offers whose expiries tile the window so the maker's displayed fixed rate stays stable across it. OfferChainUtils.buildLendFixedRateOfferChain and OfferChainUtils.buildBorrowFixedRateOfferChain construct the chains, and OfferChainUtils.getMaxFixedRateOfferChainEndTimestamp bounds a chain's end timestamp.
3. Commit to a tagged tree
Use a tagged priceV1 tree from Tree.create({ type: "priceV1", entries }); each leaf commits to its offer and optional allowedTaker, and the offer's tick supplies its price commitment. A zero allowedTaker lets any taker use the offer.
import { Tree } from "@morpho-org/midnight-sdk";
import { zeroAddress } from "viem";
const tree = Tree.create({
type: "priceV1",
entries: [
{ offer: lend250kUsdc, allowedTaker: zeroAddress },
{ offer: borrow50Units, allowedTaker: zeroAddress },
],
});4. Validate against the mempool policy
await tree.mempoolValidate({ chainId });mempoolValidate throws MidnightMempoolValidationError - carrying the API's issues - when policy validation fails. Pass apiUrl to target a custom Midnight API deployment.
5. Authorize the ratifier
Authorization required: The maker must authorize a selected ratifier once before its offers can be taken. A take reverts with RatifierUnauthorized when that authorization is missing.
import { midnightAbi } from "@morpho-org/morpho-sdk/abis";
const authorizationHash = await client.writeContract({
address: midnightAddress,
abi: midnightAbi,
functionName: "setIsAuthorized",
args: [priceRatifierV1, true, maker],
});
await client.waitForTransactionReceipt({ hash: authorizationHash });6. Ratify the root
The maker or an account it authorized can call PriceRatifierV1.encodeSetIsRootRatified to receive a { to, data } descriptor for setIsRootRatified; submit it onchain before PriceRatifierV1.ratify creates payload items. The Morpho SDK maker flow authorizes the selected PriceRatifierV1 or RateRatifierV1 ratifier for the maker through MidnightBundlesV2 and ratifies its root atomically.
import { PriceRatifierV1 } from "@morpho-org/midnight-sdk";
const rootApproval = PriceRatifierV1.encodeSetIsRootRatified({
ratifier: priceRatifierV1,
maker,
root: tree.root,
isRatified: true,
});
const rootApprovalHash = await client.sendTransaction({
to: rootApproval.to,
data: rootApproval.data,
});
await client.waitForTransactionReceipt({ hash: rootApprovalHash });
const items = PriceRatifierV1.ratify({ tree });7. Encode and publish
import { Payload } from "@morpho-org/midnight-sdk";
import { addresses } from "@morpho-org/morpho-ts";
const payload = await Payload.encode(items);
const transactionHash = await client.sendTransaction({
to: addresses[chainId].midnightMempool!,
data: payload,
});Payload.encode turns ratified items into wire bytes for mempool publication; Payload.decode inspects published maker offers, ratifier data, or attribution-tagged payloads.
Ratification
Publishing a payload stores no per-offer state onchain. When a taker calls Midnight.take(offer, ratifierData, ...), Midnight checks that the maker authorized the offer's ratifier and that its isRatified check accepts the offer and proof.
- The maker authorizes the ratifier. The maker authorizes a ratifier in Midnight's authorization mapping. Without that authorization, the take reverts with
RatifierUnauthorized. The SDK maker flow authorizes the selected ratifier for the maker throughMidnightBundlesV2; a maker can also authorize a ratifier directly withMidnight.setIsAuthorized(ratifier, true, maker). - The ratifier validates the offer. Midnight calls
isRatified(offer, ratifierData)on the selected ratifier contract and reverts withRatifierFailedunless the proof is valid. The protocol treatsratifierDataas opaque bytes.
What the ratifier data contains
Both PriceRatifierV1 and RateRatifierV1 prove that an offer belongs to a maker-ratified Merkle root. Their leaf fields differ; see PriceRatifierV1 and RateRatifierV1 in the periphery ratifier reference.
| Ratifier | ratifierData shape | Leaf committed by the maker | Price or rate commitment |
|---|---|---|---|
PriceRatifierV1 | (bytes32 root, uint256 leafIndex, bytes32[] proof, address allowedTaker) | The offer plus allowedTaker | The offer's tick fixes the price in the leaf. |
RateRatifierV1 | (bytes32 root, uint256 leafIndex, bytes32[] proof, uint256 rate, address allowedTaker) | The offer with tick replaced by rate, plus allowedTaker | The WAD-scaled per-second simple-interest rate sets a price limit: buy-offer prices must be at or below it, and sell-offer prices at or above it. |
PriceRatifierV1.decodeRatifierData can inspect published data when you are debugging; takers normally forward the API's ratifierData untouched.
import { PriceRatifierV1 } from "@morpho-org/midnight-sdk";
const { root, leafIndex, proof, allowedTaker } =
PriceRatifierV1.decodeRatifierData(items[0]!.ratifierData);Why offers are ratified instead of sent
- One root covers the tree. Ratify one root for many offers, then publish one proof per offer. Requoting creates a new tree and root rather than requiring one onchain transaction per offer. See Multi-market offers for how this composes with consumption groups.
- Stale quotes cost nothing. Publication stores no per-offer state onchain, and offers retire at
expiry; abandoning a tree does not require an onchain cancellation transaction.
Cancelling ratified offers
Use the route that matches what you need to stop: one offer group, one ratified root, or every offer that names a ratifier for a maker.
| Route | Scope | Effect and reversibility |
|---|---|---|
Morpho SDK 7.0.0 cancelOffers / MidnightBundlesV2 group consumption | Offers sharing a maker and group id | Consumes the selected group budget. Midnight's consumed counter only increases, so consumed group capacity cannot be restored. |
PriceRatifierV1 or RateRatifierV1: setIsRootRatified(maker, root, false) | Every offer committed by one root | The maker or an account authorized by the maker on Midnight can revoke the root. Reversible by setting the same root to true. |
Midnight.setIsAuthorized(ratifier, false, maker) | Offers from that maker that name the ratifier | The maker or an account authorized to act for the maker can revoke the authorization. Reversible by setting it to true. |
Custom ratifiers
offer.ratifier is an address. Any contract that implements the protocol's IRatifier interface, including isRatified(Offer offer, bytes ratifierData) returns (bytes32), can serve as a custom ratifier by returning the protocol success value only when its policy holds. Because it receives the full offer and proof bytes you define, a custom ratifier can gate takeability on offer fields, chain state, or extra proof material. The SDK leaves the policy to you; Payload.encode and Payload.decode carry your Payload.Item data unchanged, and takers forward it to take.
One practical limit: the hosted Midnight API only indexes offers whose ratifier is on its per-chain allowlist, because the router replicates ratifier logic offchain before indexing an offer. See Mempool and router. Custom-ratified offers can remain takeable onchain even if they are not indexed in books or quotes; you distribute them to takers yourself.
Legacy: EcrecoverRatifier and SetterRatifier remain deployed, and their utilities remain exported by @morpho-org/midnight-sdk. Morpho SDK 7.0.0 maker methods do not use them. In Midnight SDK 2.0.0, fetchRatifierInfo classifies only these two legacy routes; EcrecoverRatifier.cancelRoot applies only to the legacy route. See the PriceRatifierV1 and RateRatifierV1 reference.
Taking offers
The take side starts from API responses: book, quote, and takeable-offer endpoints return ABI-ready take objects - each with offer, ratifierData, and units for Midnight.take, plus marketId metadata. The ratifierData bytes are the maker's ratification proof - forward them to take untouched. Book asks are maker sell offers; book bids are maker buy offers.
For production takes, prefer the taker flows above - they wrap a quote's takeable offers in a bundled transaction with requirement handling. The raw loop below is the manual equivalent:
import { midnightAbi } from "@morpho-org/morpho-sdk/abis";
import { MidnightApi } from "@morpho-org/morpho-sdk/midnight-api";
import { addresses } from "@morpho-org/morpho-ts";
import { parseUnits, zeroAddress, type Hash } from "viem";
const chainId = 8453;
const midnightAddress = addresses[chainId].midnight!;
const marketId = "0xMarketId..." as Hash;
const taker = account.address;
const receiverIfTakerIsSeller = taker;
// Quote how to fill 50 units from the ask side, tolerating 0.5% slippage.
const quote = await MidnightApi.fetchBookQuote({
marketId,
side: "asks",
units: parseUnits("50", 18),
slippage: "0.5",
});
// A quote can span several offers: execute the returned takes IN ORDER and
// clamp each take to the remaining target instead of submitting every
// returned cap blindly.
let remainingUnits = parseUnits("50", 18);
for (const take of quote.data.takeableOffers) {
if (remainingUnits === 0n) break;
const units = take.units < remainingUnits ? take.units : remainingUnits;
if (units === 0n) continue;
await client.writeContract({
address: midnightAddress,
abi: midnightAbi,
functionName: "take",
args: [
take.offer,
take.ratifierData,
units,
taker,
receiverIfTakerIsSeller,
zeroAddress, // no taker callback
"0x",
],
});
remainingUnits -= units;
}The Midnight API client
MidnightApi (from @morpho-org/morpho-sdk/midnight-api) is both a static helper surface and an instantiable client. Static methods target https://api.morpho.org/v0/midnight by default and accept per-call configuration; instantiate the class when an integration makes repeated calls or needs shared request options (baseUrl, a custom fetch implementation, and request options such as headers, credentials, and abort signals). The SDK owns endpoint paths, HTTP methods, request bodies, and response normalization; caller inputs and successful JSON output shapes are trusted at runtime.
import { MidnightApi } from "@morpho-org/morpho-sdk/midnight-api";
// One-off static call:
const books = await MidnightApi.fetchBooks({ chainIds: [8453], limit: 10 });
// Shared configuration for repeated calls:
const api = new MidnightApi({ baseUrl: "https://api.morpho.org/v0/midnight" });
const book = await api.fetchBook({ marketId });| Method | Reads | Returns |
|---|---|---|
fetchBooks | GET /books | Paginated active books with market metadata and top price levels, mapped to SDK camelCase fields. |
fetchBook | GET /books/{marketId} | A single book snapshot with market metadata and both sides. |
fetchBookPriceLevels | GET /books/{marketId}/{side} | One side of a book grouped by price level. |
fetchBookTakeableOffers | GET /books/{marketId}/{side}/takeable-offers | ABI-ready take objects for one side of a book. |
fetchBookQuote | GET /books/{marketId}/{side}/quote | A quote (average best/worst price, available assets and units) plus signed ABI-ready take caps for one side. |
fetchTakeableOffers | GET /takeable-offers | Paginated ABI-ready take objects for one maker's unexpired, unmatured offers. |
validateMempoolPayload | POST /mempool/validate | The API's validation issues and a valid summary for already-encoded payload bytes. |
validateMempoolItems | POST /mempool/validate | The API's validation issues and a valid summary after encoding the given SDK-native payload items. |
Every method throws MidnightApiError on a non-2xx response and InvalidMidnightApiResponseError on a malformed success body. validateMempoolPayload is the raw, non-throwing validation surface for already-encoded payload bytes - in normal make-side flows prefer tree.mempoolValidate(...), which throws MidnightMempoolValidationError with the API's issues.
The same endpoints are documented in the Midnight API reference.
Reading onchain state
The fetch helpers are plain viem functions imported from the toolkit package: they resolve the Midnight contract address for the client's chain from the @morpho-org/morpho-ts address registry, and accept optional account, blockNumber, blockTag, and stateOverride call parameters. (For typical reads, Fetch state covers the same ground at a higher level.)
| Fetcher | Reads | Returns |
|---|---|---|
fetchMarketParams(client, { marketId }) | Midnight.toMarket | MarketParams - the market's immutable params |
fetchMarket(client, { marketId }) | Midnight.toMarket + Midnight.marketState | Market - params hydrated with mutable state |
fetchPosition(client, { marketId, user }) | Deployless GetPosition query (fallback: Midnight.position + up to 128 collateral slots) | Position - credit, pending fee, debt, collateral balances |
fetchAccrualPosition(client, { marketId, user }) | fetchPosition + fetchMarket in parallel | AccrualPosition - the position bound to its market |
fetchPosition defaults to a deployless multicall-style query with a per-slot fallback; pass deployless: "force" to disable the fallback or deployless: false to skip it entirely.
AccrualPosition.accrueInterest(timestamp) reproduces Midnight's updatePositionView locally - it returns a new accrued position without an RPC round-trip. It validates its inputs and throws InvalidPositionAccrualTimestampError, InvalidPositionLossFactorError, or InvalidPositionAccrualStateError on inconsistent snapshots - fetch the market and position at the same block. Related helpers: getCollateralBalanceByIndex / getCollateralBalanceByToken, getSettlementFee(timestamp), and the accrued position's withdrawable getter - the maximum credit units currently withdrawable, capped by both the accrued credit and the market's currently withdrawable liquidity.
Tick math
TickLib is a TypeScript port of the onchain TickLib; TakeAmountsLib converts between units and assets when preparing takes. Both are importable from @morpho-org/morpho-sdk/utils. Everything is bigint / WAD math - no floats.
| Function | Converts | Notes |
|---|---|---|
TickLib.divHalfDownUnchecked(x, d) | dividend → quotient | Divides with half-down rounding without validating the denominator. |
TickLib.assertTickInRange(tick) | tick → validated bigint | Asserts the tick is non-negative and at most MAX_TICK; throws NegativeValueError or TickOutOfRangeError. |
TickLib.wExp(x) | WAD exponent → WAD exponential | The WAD-scaled exponential approximation underpinning tick pricing; inverts for negative x. |
TickLib.tickToPrice(tick) | tick → WAD price | Rounds the result to PRICE_ROUNDING_STEP; validates the tick range. |
TickLib.priceToTick(price, spacing?) | WAD price → tick | Returns the lowest spacing-aligned tick whose price is at least price; spacing defaults to DEFAULT_TICK_SPACING; throws PriceGreaterThanOneError above WAD and InvalidTickSpacingError on bad spacing. |
TickLib.snapPriceToTick(price, spacing?) | WAD price → WAD price | SDK-only convenience that snaps a price to the price of the lowest aligned tick at or above it. |
TickLib.rateToPrice(rate) | WAD fixed rate → WAD price | SDK-only rate conversion; rounds down. |
TickLib.tickToRate(tick) | tick → WAD fixed rate | SDK-only rate conversion; rounds up and throws DivisionByZeroError when the tick price is zero. |
TickLib.tickToApr(tick, timeToMaturity) | tick → WAD simple APR | SDK-only display convenience; annualizes tickToRate over timeToMaturity seconds, rounds up, throws DivisionByZeroError when maturity or price is zero. |
TickLib.assertTickAlignedToSpacing(tick, spacing?) | tick → validated bigint | SDK-only assertion that the tick is in range and aligned to spacing; throws InvalidTickSpacingError when misaligned. |
TakeAmountsLib.prices({ offer, settlementFee }) | offer + fee → buyer/seller prices | Derives buyerPrice and sellerPrice from the offer tick; throws SettlementFeeExceedsPriceError when the fee exceeds a buy offer's price. |
TakeAmountsLib.buyerAssetsToUnits({ offer, targetBuyerAssets, settlementFee }) | buyer assets → units | Rounds up for buy offers and down otherwise; throws DivisionByZeroError, PriceGreaterThanOneError, or SettlementFeeExceedsPriceError. |
TakeAmountsLib.sellerAssetsToUnits({ offer, targetSellerAssets, settlementFee }) | seller assets → units | Rounds up for buy offers and down otherwise; throws DivisionByZeroError when the seller price is zero. |
TakeAmountsLib.toUnits({ assets, price, rounding }) | assets → units | SDK-only generic conversion at an explicit WAD price with a caller-supplied rounding; throws DivisionByZeroError when price is zero. |
TakeAmountsLib.toUnitsAtTick({ assets, tick, rounding }) | assets → units | SDK-only conversion at a tick price via tickToPrice, then toUnits. |
Protocol constants
The SDK re-exports the protocol's deployed constants so your code never hardcodes them:
| Constant | Value | Meaning |
|---|---|---|
CBP | 1_000000000000n | Centibip scale used by settlement fees. |
SETTLEMENT_FEE_BREAKPOINTS | 7 bigint seconds, 0n → 31_104_000n (0, 1, 7, 30, 90, 180, 360 days) | Settlement-fee time-to-maturity breakpoints across which Midnight linearly interpolates each market's seven settlement-fee cbp buckets. |
MAX_TICK | 6744n | Maximum Midnight tick. |
PRICE_ROUNDING_STEP | 100_000000000n | WAD price quantum used by Midnight tick prices. |
DEFAULT_TICK_SPACING | 4n | Default Midnight tick spacing. |
MAX_COLLATERALS | 128n | Maximum collateral entries in a Midnight market. |
MAX_COLLATERALS_PER_BORROWER | 16n | Maximum active collateral entries per borrower. |
MAX_SETTLEMENT_FEES | 7 bigint, 14000000000000n → 5000000000000000n | Maximum settlement-fee values by Midnight fee index. |
MAX_CONTINUOUS_FEE | 317097919n | Maximum continuous fee per second. |
MAX_OFFER_CAP | maxUint128 | Maximum value accepted by the uint128 offer cap fields. |
TIME_TO_MAX_LIF | 3600n | Seconds after maturity over which post-maturity LIF reaches the computed maximum liquidation incentive factor. |
COLLATERAL_PARAMS_TYPEHASH | 0x39ed3f92…85b841 | HashLib EIP-712 typehash for the collateral params struct. |
MARKET_TYPEHASH | 0x510b3862…20391a | HashLib EIP-712 typehash for the market struct. |
OFFER_TYPEHASH | 0x99052142…9d8906 | HashLib EIP-712 typehash for the offer struct. |
EIP712_DOMAIN_TYPEHASH | 0x47e79534…469218 | EcrecoverRatifier EIP-712 domain typehash. |
All of these are importable from @morpho-org/morpho-sdk/constants; MAX_OFFER_CAP and TIME_TO_MAX_LIF come from the toolkit package directly, while the Morpho SDK re-exports them as MIDNIGHT_MAX_OFFER_CAP and MIDNIGHT_TIME_TO_MAX_LIF.
Errors
The SDK throws dedicated error classes (no generic Errors) so you can branch on failure modes deterministically - one row per class exported from src/errors.ts:
| Error | When it triggers |
|---|---|
TickOutOfRangeError | A tick exceeds the deployed TickLib maximum during tick or offer construction. |
PriceGreaterThanOneError | A price is above the maximum WAD price (1) accepted by TickLib. |
InvalidTickSpacingError | The tick spacing is not a positive divisor of the maximum tick, or a tick is not aligned to the market's spacing. |
SettlementFeeExceedsPriceError | A buy offer's settlement fee exceeds its tick price. |
InvalidOfferParameterError | An offer builder receives a parameter that cannot satisfy Midnight protocol rules (carries the offending parameter and value). |
InvalidMarketParameterError | A market parameter cannot satisfy Midnight protocol rules (carries the offending parameter and value). |
UnknownCollateralIndexError | A market collateral index is queried that the market does not configure (carries market and collateralIndex). |
InvalidOfferGroupError | Offers grouped together violate group mechanics - they must share maker, side, loan token, and cap mode and value. |
InvalidTreeHeightError | A tree height exceeds the ratifier typehash table. |
InvalidTreeError | A tree cannot be represented by the ratifiers, for example when it is empty. |
ChainIdMismatchError | The viem client's chain id does not match the offer market's chain id required by a signing flow (carries clientChainId and expectedChainId). |
InvalidTypedDataSignatureError | A typed-data signature does not recover to the expected signer (carries the expected signer). |
InvalidEcrecoverSignatureVError | Decoded Ecrecover ratifier data contains a non-canonical signature v value - canonical Ethereum v is 27 or 28 (carries the invalid v). |
InvalidRateRatifierV1RateError | A RateRatifierV1 leaf contains a negative rate. |
RateRatifierV1BoundOverflowError | Rate and time-to-maturity arithmetic exceeds the uint256 bound. |
InvalidRateRatifierV1TickError | A RateRatifierV1 leaf contains a tick below MIN_TICK. |
InvalidRateRatifierV1TimeError | A RateRatifierV1 time input, such as timestamp or timeToMaturity, is negative. |
InvalidRatifierV1AddressError | A ratifier configuration uses the zero address. |
RatifierV1TakerNotAllowedError | A ratifier restricted to one taker receives a different taker address. |
PayloadDecodeError | Mempool payload bytes cannot be encoded or decoded (carries a machine-readable reason). |
MidnightApiError | The Midnight API returns a non-2xx response (carries status, and code / details / requestId when present). |
InvalidMidnightApiResponseError | The Midnight API returns a 2xx response whose body is malformed or missing expected data. |
InvalidMidnightApiQuoteTargetError | A quote request supplies both units and assets, or neither. |
MidnightMempoolValidationError | The Midnight API rejects a tree during mempool policy validation (carries the API's issues). |
InvalidPositionAccrualTimestampError | A local position accrual is requested for a timestamp before the position's last accrual (carries timestamp and lastAccrual). |
InvalidPositionLossFactorError | A local accrual sees a market loss factor below the position's, meaning the market and position were fetched at inconsistent blocks. |
InvalidPositionAccrualStateError | Local accrual inputs violate Midnight accounting invariants, such as a pending fee exceeding credit. |
All of these are importable from @morpho-org/morpho-sdk/errors; InvalidMarketParameterError, ChainIdMismatchError, InvalidTypedDataSignatureError, InvalidEcrecoverSignatureVError, and PayloadDecodeError come from the toolkit package directly, while the Morpho SDK re-exports them as InvalidMidnightMarketParameterError, MidnightChainIdMismatchError, InvalidMidnightTypedDataSignatureError, InvalidMidnightEcrecoverSignatureVError, and MidnightPayloadDecodeError - and note the Morpho SDK's root export ships its own, distinct ChainIdMismatchError for client validation, so don't conflate the two classes.
InvalidRateRatifierV1RateError, InvalidRateRatifierV1TickError, InvalidRateRatifierV1TimeError, InvalidRatifierV1AddressError, RateRatifierV1BoundOverflowError, and RatifierV1TakerNotAllowedError are re-exported by the Morpho SDK as InvalidMidnightRateRatifierV1RateError, InvalidMidnightRateRatifierV1TickError, InvalidMidnightRateRatifierV1TimeError, InvalidMidnightRatifierV1AddressError, MidnightRateRatifierV1BoundOverflowError, and MidnightRatifierV1TakerNotAllowedError, respectively. MidnightApiError and InvalidMidnightApiResponseError are also re-exported from @morpho-org/morpho-sdk/midnight-api alongside the client. The fetch helpers can additionally throw UnsupportedChainIdError / UnknownAddressError from @morpho-org/morpho-ts when the client's chain has no Midnight registry entry.
Resources
- Repository: morpho-org/sdks · packages/midnight-sdk
- Protocol docs: Get started with Midnight · Tick structure · Mempool & router
- Tutorials: Lend at a fixed rate · Borrow at a fixed rate · Redeem lend at maturity · Exit borrow at maturity · Exit lend early · Exit borrow early
- API reference: Midnight API
- Contributing:
CONTRIBUTING.md - Security:
SECURITY.md