Skip to main content

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

FieldDescription
deadlineMaximum block.timestamp for execution. 0 disables the check. Reverts DeadlineExpired otherwise.
fillAxisFillAxis.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.
maxFillCap 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).
minFillMinimum acceptable total fill on the chosen axis. Reverts InsufficientFill if not met after all actions. Accepts the same sentinel as maxFill.
minPrice / maxPricePost-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.
reduceOnlyCrossing protection — reverts if the initiator's opposite-side position grows (credit for buyer, debt for seller).
Renewal/close-out sentinel

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_UNITS resolves to the initiator's debt (buyer side) or credit (seller side).
  • FILL_BUYER_ASSETS resolves to the adapter's loan token balance.
  • FILL_SELLER_ASSETS is unsupported and reverts.

Action

Each Action is one individual Midnight take:

FieldDescription
takeThe MidnightTakeData payload for the take (see below).
allowRevertIf 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.
offerThe Offer struct used for dispatch and passed to the clamp / fee adjuster. offer.maker is the counterparty providing liquidity for the action.
clampOptional ITakeClamp contract that further caps takeUnits based on onchain state (balances, allowances, health, callback-internal budget). See Clamping.
clampDataClamp-specific encoded params passed to the clamp.
feeAdjusterOptional ICallbackFeeAdjuster contract used when the action's callback charges a fee. See Fee Adjustment.
feeAdjusterDataAdjuster-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.

Reentrant 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.market is compared against the first action; mismatches revert InconsistentMarket(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.buy for 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:

  1. Remaining batch budget, converted to units (or feeAdjuster.beforeDispatch if a fee adjuster is set).
  2. Structural offer capacity via TakeMathLib.getOfferRemaining (raw consumed vs offer cap). Enforced unconditionally; clamps must not check offer consumption themselves.
  3. The configured clamp contract, 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:

  • beforeDispatch replaces the default budget conversion. It returns the largest takeUnits whose effective fill (after fee) does not exceed the remaining batch budget.
  • afterDispatch is 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.