Tenor Router
The Tenor Router fills a user's position across multiple offers in a single transaction. Instead of taking offers one at a time, you define a fill target and a price band, and the router iterates through an array of actions, accumulating fills until the target is met or no more offers can be matched.
For example, a borrower initiating a $1M USDC position can submit a batch of offers at increasing rates, a maxFill of 1,000,000 USDC, and a price band corresponding to ≤ 6% APR. The router fills from the best rate down until the full amount is matched or the price band trips.
Batch Execution
The execute function takes two arguments: an ExecuteParams struct defining the batch, and an ordered array of Action structs defining each take. It returns (buyerAssetsTotal, sellerAssetsTotal, unitsTotal) and emits BatchExecuted on success.
ExecuteParams
| Field | Description |
|---|---|
deadline | Maximum block.timestamp for execution. 0 disables the check. Reverts DeadlineExpired otherwise. |
fillAxis | FillAxis.ASSETS or FillAxis.UNITS. Picks which axis accumulates toward maxFill / minFill. FillAxis.ASSETS resolves to the batch side's asset dimension: buyer assets if the initiator is on the buyer side, seller assets if on the seller side. FillAxis.UNITS always refers to market units. |
maxFill | Cap on the chosen axis; the loop stops once it is reached. Reverts FillOvershoot if a single action's post-fee fill pushes past it. Accepts type(uint256).max as the renewal/close-out sentinel (see below). |
minFill | Minimum acceptable total fill on the chosen axis. Reverts InsufficientFill if not met after all actions. Accepts the same sentinel as maxFill. |
minPrice / maxPrice | Post-batch inclusive price band in D18 {taker-side asset / unit}. The taker-side asset is auto-anchored (buyer assets on the seller side, seller assets on the buyer side). Use 0 to disable the floor and type(uint256).max to disable the ceiling. Reverts PriceSlippageExceeded otherwise; a degenerate units == 0 with assets > 0 also reverts unless the ceiling is disabled. |
reduceOnly | Crossing protection — reverts if the initiator's opposite-side position grows (credit for buyer, debt for seller). |
maxFill / minFill accept type(uint256).max as a sentinel resolved by the adapter against onchain state (e.g. current debt for a borrower close-out, current balance for a vault deposit). If resolution yields zero (no prior position, empty adapter balance), execution reverts SentinelResolvedToZero. Opening flows must pass concrete bounds: the sentinel does not mean "fill everything available", and SentinelNotSupported reverts if used on the seller-assets axis.
Sentinel resolution by axis:
FILL_UNITSresolves to the initiator's debt (buyer side) or credit (seller side).FILL_BUYER_ASSETSresolves to the adapter's loan token balance.FILL_SELLER_ASSETSis unsupported and reverts.
Action
Each Action is one individual Midnight take:
| Field | Description |
|---|---|
take | The MidnightTakeData payload for the take (see below). |
allowRevert | If true, a failed action is skipped and an ActionReverted event is emitted instead of reverting the whole batch. If false, the first failed action reverts the batch with ActionFailed. |
offer | The Offer struct used for dispatch and passed to the clamp / fee adjuster. offer.maker is the counterparty providing liquidity for the action. |
clamp | Optional ITakeClamp contract that further caps takeUnits based on onchain state (balances, allowances, health, callback-internal budget). See Clamping. |
clampData | Clamp-specific encoded params passed to the clamp. |
feeAdjuster | Optional ICallbackFeeAdjuster contract used when the action's callback charges a fee. See Fee Adjustment. |
feeAdjusterData | Adjuster-specific encoded params passed to the fee adjuster. |
MidnightTakeData payload
MidnightTakeData carries takeUnits, takerCallback, takerCallbackData, receiverIfTakerIsSeller, and the offer's ratifierData. The taker is always the batch's initiator; the router calls Midnight.take() directly. When the offer's maker has authorized a ratifier on Midnight (e.g. for a renewal), Midnight runs that ratifier's isRatified() itself — the router is unaware of migrations, it only takes.
If takerCallback reenters Bundler3 (e.g. the Tenor Adapter), at most one such reentrant TAKE action may execute per top-level Bundler3.multicall call entry. When allowRevert = true, follow-up reentrant actions in the same batch silently no-op with IncorrectReenterHash instead of failing the call.
Per-Batch Invariants
The initiator is always the Midnight taker for every action. Beyond that, every action in a batch must share:
- Same market.
action.offer.marketis compared against the first action; mismatches revertInconsistentMarket(i). - Same side. Whether the batch is on the buyer side or the seller side is locked by the first action; mismatches revert
InconsistentSide(i, batchIsBuyerSide). Because the initiator is always the taker, the batch side is!offer.buyfor every action.
Clamping
A clamp is a view-only ITakeClamp contract that returns a maximum takeUnits value for an action given the current onchain state. The router takes the minimum of the clamp's return and its own running cap, so a clamp can only reduce a take, never grow it.
For each action, the router applies three caps in order:
- Remaining batch budget, converted to units (or
feeAdjuster.beforeDispatchif a fee adjuster is set). - Structural offer capacity via
TakeMathLib.getOfferRemaining(rawconsumedvs offer cap). Enforced unconditionally; clamps must not check offer consumption themselves. - The configured
clampcontract, if any.
The smallest of the three wins.
Clamps exist to encode the onchain truths the router cannot generically infer: wallet balances, allowances, position health, callback-internal budget math. Different operations get different clamps (renewals, vault rollovers, cross-protocol migrations, etc.), each implementing the constraints specific to its flow.
Clamps return a best-effort cap, not an exact ceiling. They rely on simplifying assumptions about the action's flow and skip checks that would be too expensive to perform onchain (e.g. they do not re-simulate the full post-action position health). A take that passes the clamp can still revert at dispatch if reality diverges from those assumptions, and conversely the clamp may be conservative enough to leave headroom the action could in principle have used.
Fee Adjustment
A feeAdjuster is an ICallbackFeeAdjuster contract used to correctly size takeUnits when the action's callback charges a fee that consumes part of the user's budget. Without an adjuster, the router's default budget→units conversion (RouterLib.budgetToUnits) over-sizes the take and busts the budget; for fee-less callbacks, no adjuster is needed.
Fee adjusters are view-only hooks: beforeDispatch caps takeUnits so the fill budget accounts for fees, and afterDispatch reports the fee amount charged so the router can tighten its running totals. Both are external view functions — pure computations, not stateful callbacks.
When set, the adjuster is consulted twice:
beforeDispatchreplaces the default budget conversion. It returns the largesttakeUnitswhose effective fill (after fee) does not exceed the remaining batch budget.afterDispatchis consulted once the take settles. It reports the realized fee back to the router, which shifts the action's recorded fill in the taker-worsening direction so the post-batch price band reflects what the user actually paid.
Clamps and fee adjusters are typically paired for callback-fee operations (e.g. a Midnight renewal where the renewal callback charges a fee).
Bundler3 Integration
The router is exposed through the Tenor Adapter, which is the entry point in production. The initiator (the original EOA) is resolved as the Midnight taker for every action. When a renewal offer's maker has authorized a ratifier on Midnight, Midnight runs that ratifier's isRatified() during the take; the router performs no authorization or ratification itself.