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. For ASSETS, the axis auto-resolves to buyer-assets or seller-assets depending on the batch's side. |
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. |
maxFill / minFill accept type(uint256).max as a sentinel resolved by the adapter against on-chain 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.
Action
Each Action is one individual take operation:
| Field | Description |
|---|---|
actionType | TAKE_ON_BEHALF (routes through IntentSettler → ratifier → Midnight) or MIDNIGHT_TAKE (calls Midnight directly, no ratifier). |
data | ABI-encoded payload for the action. TakeOnBehalfData for TAKE_ON_BEHALF; MidnightTakeData for MIDNIGHT_TAKE. |
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. |
clamp | Optional ITakeClamp contract that further caps takeUnits based on on-chain state (balances, allowances, health, callback-internal budget). See Clamping. |
clampData | Arbitrary data passed to the clamp. |
feeAdjuster | Optional ICallbackFeeAdjuster contract used when the action's callback charges a fee. See Fee Adjustment. |
feeAdjusterData | Arbitrary data passed to the fee adjuster. |
TAKE_ON_BEHALF payload
TakeOnBehalfData carries the IntentSettler arguments: takeUnits, takerCallback, takerCallbackData, receiver, offerRatifierData, the Intent ({user, ratifier, data}), and the inner ratifier data. The taker recorded on Midnight is intent.user (independent of the batch initiator), so a keeper can fill renewals on behalf of many distinct users in the same batch.
MIDNIGHT_TAKE payload
MidnightTakeData carries takeUnits, takerCallback, takerCallbackData, receiverIfTakerIsSeller, and the offer's ratifier data. The taker is the batch's initiator. There is no ratifier involvement.
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.
Execution Modes
The router enforces that every action in a batch shares the same execution mode. Mixing reverts MixedExecutionMode.
- First-party. The initiator is a direct party to the trade. Covers
MIDNIGHT_TAKE(initiator is the taker), andTAKE_ON_BEHALFwhereoffer.maker == initiator(initiator is the maker being filled against). - Third-party. The initiator is a keeper relaying for someone else. Only
TAKE_ON_BEHALFwithoffer.maker != initiator. The actual taker isintent.user, gated by the ratifier.
This separation lets keepers batch many distinct users' renewals together (third-party) without those ever mixing into a user-initiated fill (first-party); two parties' flows would otherwise be credited to one identity under the same slippage denominator.
Per-Batch Invariants
Beyond execution mode, 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). Side is determined as follows:MIDNIGHT_TAKE→ initiator is taker → side is!offer.buy.TAKE_ON_BEHALFwithoffer.maker == initiator→ initiator is maker → side isoffer.buy.TAKE_ON_BEHALFwithoffer.maker != initiator→ keeper-orchestrated; side is!offer.buy(the taker isintent.user).
Clamping
A clamp is a view-only ITakeClamp contract that returns a maximum takeUnits value for an action given the current on-chain 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
ClampLib.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 on-chain 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 on-chain (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.
When set, the adjuster is called twice:
beforeDispatchreplaces the default budget conversion. It returns the largesttakeUnitswhose effective fill (after fee) does not exceed the remaining batch budget.afterDispatchis called 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).
Events
BatchExecuted(initiator, msgSender, params, actionsCount, buyerAssets, sellerAssets, units)emitted once per successfulexecute.ActionReverted(index, reason)emitted per action skipped because ofallowRevert = true.
Custom Errors
| Error | Trigger |
|---|---|
DeadlineExpired(deadline, timestamp) | block.timestamp > deadline and deadline != 0. |
ActionFailed(index, reason) | An action with allowRevert = false reverted. reason is the inner revert data. |
InsufficientFill(filled, minFill) | Total fill on the chosen axis stayed below minFill. |
FillOvershoot(filled, maxFill) | A single action's post-fee fill pushed past maxFill. |
PriceSlippageExceeded(price, min, max) | Realized price escaped [minPrice, maxPrice]. Also triggered if units == 0 while assets > 0 and maxPrice != type(uint256).max. |
InconsistentMarket(index) | Action's offer.market differs from the first action's. |
InconsistentSide(index, batchIsBuyerSide) | Action's resolved side differs from the first action's. |
MixedExecutionMode(index, expectedFirstParty) | Action's execution mode (first/third-party) differs from the first action's. |
SentinelResolvedToZero(fillIndex) | type(uint256).max sentinel resolved to zero on maxFill / minFill. |
SentinelNotSupported(fillIndex) | Sentinel used on the seller-assets axis (unsupported). |
EmptyActions() | Adapter was invoked with an empty actions array. |
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 taker for MIDNIGHT_TAKE actions and as the onBehalf passed into IntentSettler.take for TAKE_ON_BEHALF actions.