Mempool & Router
Mempool
Everything so far describes a position you already hold: how its health is measured and what happens if it deteriorates. But a position only exists once a borrower and a lender have traded units. Midnight has no order book matching them: instead, makers broadcast signed offers onchain through the mempool, a minimal contract that simply logs each offer as an event (no storage or mapping is performed by the mempool).
The mempool is not a single canonical place. Offers exist entirely offchain so different participants might see different subsets of offers. The competition to find and fill the best offers is the province of routers.
Key difference from an order book
On a CLOB (central limit order book), placing an order locks capital and establishes price-time priority. In Midnight, an offer locks nothing and has no protocol-enforced priority. Best-price ordering is instead handled offchain by the Morpho router, explained in the section that follows.
Router
Because offers float offchain with no canonical queue and no protocol-enforced priority, a taker looking for the best rate faces a genuine search problem. The process of discovering, evaluating, and selecting which offers to take is called routing.
Because routing is permissionless, anyone can run one. Morpho operates its own router, which aggregates offers and surfaces executable offers so integrators don't have to build discovery infrastructure themselves.
Validating offers
The Morpho router only indexes offers that pass its rules. Each rule constrains one dimension of an offer. If any rule is violated, Morpho's router ignores the offer.
| Rule | What it checks | Issue names |
|---|---|---|
| Payload version | Must use the current version byte. | payload_version |
| Payload framing | Structure must follow: version ‖ uint32(gzipLen) ‖ gzip(ABI-encoded offers) ‖ optional suffix. Gzip length must honestly delimit the compressed ABI payload. Suffix must not exceed allowed size. | payload_frame, payload_gzip_length, payload_suffix_too_large, payload_decompression, payload_abi_decode |
| Payload content | Must contain at least one non-empty offer. No duplicate offer hashes. Item count must not exceed the active tree limit. Padding offers for Merkle trees are fine. | empty_payload, duplicate_offer_hash, max_offers_per_tree |
| Chain | Every request is chain-scoped. The chain must be configured by the router. | unsupported_chain |
| Maturity | The offer's market must use a supported maturity bucket for the target chain. | maturity |
| Loan token | The market's loan token must be supported on the target chain. Token support is dynamic, do not rely on static lists. | loan_token |
| Collateral tokens | Every collateral must be supported on the target chain. Total collateral count must not exceed the active maximum. | collateral_token, max_collaterals |
| Oracle address | Each collateral must include a non-zero oracle address. Use the oracle expected by the market. The router does not call price() during validation. This is a format check, not a live price read. A payload can pass validation and still fail to become takeable if the router cannot index a usable price. | oracle |
| LLTV | Each collateral's LLTV must be one of the modes the router supports for safe routing. | collateral_lltv |
| Max LIF | The liquidation incentive factor derived from LLTV and cursor must be within the accepted range. | max_lif |
| Size expression | Each offer must set exactly one of max_units or max_assets. Do not set both. Do not leave both at zero. | amount_missing, amount_conflict |
| Minimum size | When active for a chain and loan token, offers below a USD-denominated floor are rejected. Per-token floors are dynamic, always validate rather than hardcoding. | min_offer_assets_usd |
| Tick range | The offer's tick must fall within the router's supported range. | min_tick, max_tick |
| Tick spacing | The tick must align to the configured spacing for the market. | tick_spacing |
| Minimum duration | The offer's active window (start → expiry) must be long enough to be useful. Very short-lived offers are rejected. | min_duration |
| Group time overlap | If a group contains multiple offers for the same market, their active windows must not overlap. One group can represent alternatives over time, not simultaneous duplicates. | group_consistency |
| Callback policy | Currently only empty-callback offers are accepted. Non-empty callback addresses or data are rejected. Empty-callback buy/sell support is policy-controlled. Validate rather than assuming it won't change. | non_empty_callback, buy_empty_callback, sell_empty_callback |
| Ratifier support | Every offer declares a ratifier contract. The ratifier must be accepted for the target chain. | ratifier |
| Batch consistency | All offers in one batch must share the same maker and the same ratifier. The router checks the declared ratifier address but does not authenticate ratifierData at validation time. Signatures and proofs are checked later by the indexer or onchain. | mixed_maker, mixed_ratifier |
| Group identity | Groups are content-addressed. The group ID must match the hash of its offers after zeroing offer.group and sorting the offer hashes. | group_identity |
| Group field consistency | Offers sharing a group must agree on: loan token, max_units, max_assets, side (buy/sell), callback address, and callback data. Same-market offers within a group must not have overlapping active windows. | group_consistency |
The /mempool/validate endpoint is a pre-flight check: "will the router index this offer?" If validation returns issues, the offer can still exist in the mempool, but the router ignores it.
// Check if the router will index your offers
POST /v0/midnight/mempool/validate
{
"chain_id": 1,
"payload": "0x01..."
}
// Response (accepted payload)
{
"data": {
"issues": []
}
}
// Response (rejected-by-policy payload)
{
"data": {
"issues": [
{
"rule": "loan_token"
},
{
"rule": "maturity"
},
{
"rule": "tick_spacing"
}
]
}
}For example, to confirm ratifier support, the validate endpoint receives both the Offer struct and the ratifierData (e.g., the ECDSA signature or Merkle proof) for a given offer. The verification is two-part:
- Allowlist check: The router checks that
offer.ratifieris a ratifier contract it knows how to validate. - Signature check: The router replicates the ratifier's logic offchain (for an ECDSA ratifier, it runs
ecrecover(hash(offer), signature)) and confirms the recovered address matchesoffer.maker. It proves the maker actually signed this specific offer, not just that someone submitted it with a known ratifier address. Without this, the book would fill with forged offers that look valid but revert onchain.
The order book
The router aggregates all signed & active offers from the mempool that pass its rules in the form of a book (a bid/ask market view). It groups them by tick and sums the units and assets at each tick into a price level (price level = one tick).
Why "asks" and "bids"?
Make/take describes what a participant does. "I make an offer," "someone takes it." Ask/bid describes the aggregated state of the market: "there are asks at this price, bids at that." Both sides of the book are made up of makers' offers; the taker is the person reading the book and deciding what to hit.
An ask is a sell-unit offer (the maker wants to borrow). A bid is a buy-unit offer (the maker wants to lend). If you want to lend, you take asks. If you want to borrow, you take bids.
To fetch a single book's state run the following query:
GET /v0/midnight/books?chain_ids=8453&limit=1
// Response
{
"cursor": null,
"data": [
{
"market_id": "0x52b0f65ac6906080cb2100a2ffbef8f81c6c40ec65ebca6b5fc6ad4412ad77cf",
"id": "0x52b0f65ac6906080cb2100a2ffbef8f81c6c40ec65ebca6b5fc6ad4412ad77cf",
"chain_id": 8453,
"midnight": "0x2f7a3aa739ba5792ce1b4ea046117f2c0095bca6",
"loan_token": "0x4200000000000000000000000000000000000006",
"collaterals": [
{
"token": "0xcbb7...33bf",
"lltv": "860000000000000000",
"liquidation_cursor": "250000000000000000",
"oracle": "0x10b95702a0ce895972c91e432c4f7e19811d320e"
}
],
"maturity": 1783090800,
"rcf_threshold": "0",
"enter_gate": "0x0000000000000000000000000000000000000000",
"liquidator_gate": "0x0000000000000000000000000000000000000000",
"asks": [
{
"tick": 4736,
"price": "998890800000000000",
"units": "666666666666666",
"assets": "665927200000000",
"count": 2
},
{
"tick": 4740,
"price": "998912700000000000",
"units": "1333333333333334",
"assets": "1331883600000002",
"count": 2
},
{
"tick": 4744,
"price": "998934100000000000",
"units": "2000000000000000",
"assets": "1997868200000000",
"count": 2
}
],
"bids": [
{
"tick": 4732,
"price": "998868400000000000",
"units": "667421921312824",
"assets": "666666666666666",
"count": 2
},
{
"tick": 4728,
"price": "998845700000000000",
"units": "1334874178597690",
"assets": "1333333333333334",
"count": 2
},
{
"tick": 4724,
"price": "998822400000000000",
"units": "2002357976753426",
"assets": "2000000000000000",
"count": 2
}
]
}
]
}To fetch a book with a specific depth (max price levels per side), run the following query:
GET /v0/midnight/books/{market-id}?depth=50To only fetch the book's ask side, run the following query:
GET /v0/midnight/books/{market-id}/asks
// Response
{
"data": [
{
"tick": 4736,
"price": "998890800000000000",
"units": "666666666666666",
"assets": "665927200000000",
"count": 2
},
{
"tick": 4740,
"price": "998912700000000000",
"units": "1333333333333334",
"assets": "1331883600000002",
"count": 2
},
{
"tick": 4744,
"price": "998934100000000000",
"units": "2000000000000000",
"assets": "1997868200000000",
"count": 2
}
]
}Takeable offers
The book endpoint returns only aggregated summaries. A price level tells you "10,000 units are available at price 0.9524". To execute, you need the full Offer struct plus the ratifierData.
That's what a takeable offer is: an individual offer that is currently executable, packaged with everything needed to call the Midnight contract onchain. The /quote endpoint returns these individual offers complete with Offer structs and ratifierData ready for onchain submission. You tell it how much you want to trade, a market, a side, a target size, and optional slippage and it searches the mempool, ranks offers by price, and returns a bundle-ready execution plan.
A note on fallback excess returned by the quote:
- When you request a quote for 10,000 units, the router returns more than 10,000 (say 15,000 across four offers, sorted best price first, then earliest first within the same tick). This excess is intentional.
- Between the moment you receive the quote and the moment your transaction lands onchain, other takers may have partially consumed some of those same offers.
- If offer A originally had 5,000 units but someone else took 3,800 of them in the meantime, only 1,200 remain when your transaction executes.
- Your bundler skips what it can't fill from A and moves to offers B and C to reach your 10,000 target. The fallback offers absorb the impact of this concurrency.
- You may get a slightly worse average price (since later offers sit at deeper ticks), but you still fill your slippage target / price tolerance.
For example, if you want to lend 10,000 USDC and take asks you run the following query:
GET /v0/midnight/books/{market-id}/asks/quote
?assets=10000000000
&slippage=0.5
// Response
{
"data": {
"average_best_price": "998890800000000000",
"average_worst_price": "1003885254000000000",
"available_assets": "14983800",
"available_units": "15000000",
"takeable_offers": [
{
"market_id": "0xa249f0b3ffe8891c96ec93fd9c7d00e479da04e4ffe2f2502ea0e5db8eabc75d",
"units": "833333",
"offer": {
"buy": false,
"maker": "0x6ba008e3f6ec55dc6412e459ac67949c6d1620c5",
"max_units": "833333",
"max_assets": "0",
"tick": 4736,
"start": 1782484425,
"expiry": 1783090799,
"group": "0x6c544af73868898482d1802005d87e7a4d1100d4e1a6e099a978f6bd2e6cba17",
"ratifier": "0x1db7b555ce19e85e625428b0e1047da3198211ea"
},
"ratifier_data": "0x0000000000000000000000000000000000000000..."
},
// more offers
{...}
]
}
}