Skip to content

feat(midnight-liquidation): gate plans on incentive headroom - #182

Merged
haydenshively merged 10 commits into
fix/bots-35-tick-telemetry-and-submit-outcomefrom
feat/bots-35-headroom-gate
Aug 28, 2026
Merged

feat(midnight-liquidation): gate plans on incentive headroom#182
haydenshively merged 10 commits into
fix/bots-35-tick-telemetry-and-submit-outcomefrom
feat/bots-35-headroom-gate

Conversation

@haydenshively

@haydenshively haydenshively commented Aug 27, 2026

Copy link
Copy Markdown
Collaborator

What this does

Two related guards, both aimed at the same failure: the bot broadcasting a liquidation whose economics
cannot close.

  1. A plan gate. Refuse to size a liquidation whose incentive headroom is below a floor, before any
    quote is spent on it.
  2. A min-out floor. Make every swap quote prove its guaranteed output clears the liquidation's
    break-even repay, and refuse the quote if it cannot.

Together they replace the operator-set SLIPPAGE_BPS — a fixed percentage with no relationship to a
given liquidation's economics — with a floor derived from the position itself.

Why

A liquidation's entire margin is its incentive: headroom = (lif - 1) / lif. On Midnight after
maturity that ramps from 0 over an hour, so early on the margin is a few basis points. A swap quoted
with a flat 1% slippage allowance can legally return 1% less than quoted, which is an order of
magnitude more than the whole incentive. The bot pays gas to broadcast a transaction that then reverts
at the repay transfer, or lands at a loss.

The reverts seen in production (Error(return too low), and the allowance revert investigated in
BOTS-35) are the same shape: a min-out baked at head N that no longer clears when
eth_estimateGas re-runs at head N+k.

The min-out floor is set equal to the incentive

Rather than picking a slippage number, the floor is the break-even repay the contract will derive
for the plan. If no venue can guarantee it, that is the correct answer — the position is not yet
profitable, and the bot should wait for more incentive or a price move rather than allow the swap to
eat the margin. This is the design decision that drove the rest of the PR.

Commits

Commit What
959d0efe Plan gate: LiquidationPlan.lif / impliedRepaidUnits, insufficient_headroom skip reason, headroom diagnostics on the skip path
c43331b3 Quotes gated on the repay the contract will ceil-derive, not the bot's estimate
749ca77f Rank candidates by expected USD profit rather than arbitrary order
130308ae QuoteRequest.minAcceptableAmountOut; the venue slippage percentage is derived from it
78529c88 Delete SLIPPAGE_BPS — unreachable once the floor is economic
e84047fe Review round 1: re-derive the aggregator min-out against the venue quote; fix Blue's break-even (ceil the value, add the shares round-trip); make headroom exactly scale-invariant by deriving it from lif
49a19571 Review round 2: enforce the floor as a postcondition (floor_unmet), not just a retry; Swap.minOutSource so a reconstructed minimum can never satisfy a floor
208c545a Review round 3: postcondition uses the requested floor, not the clamped one; unwrapOnlyPlan enforces it too; 1inch asks for an absolute minReturn
1093e2f7 Correct the 1inch JSDoc to match minReturn
1d3fdeb0 Review round 5: floor_unmet becomes an economic outcome that does not back off; token prices keep a gateway path prefix; the boot price fetch leaves the startup path; MIN_SURPLUS_BPS documented

Key design points

The floor is a postcondition, not an attempt. Asking a venue for a tighter percentage and hoping
is not a guarantee — a second quote can drift lower and still land under the floor. Every path out of
firmQuoteVenue now ends in swap.amountOutMinimum >= minAcceptableAmountOut or a refusal.

Swap.minOutSource: 'venue' | 'derived'. A minimum the bot reconstructed from its own arithmetic
cannot be checked against the bot's own floor — that compares a value with itself. Only a
venue-reported bound can satisfy the floor. This forces any future adapter to declare which it has.

A floor miss is an economic verdict, not a failure. Every venue missing break-even is the normal
state of the early LIF ramp, so floor_unmet is its own QuoteFailureReason: Midnight counts it as
quoteUnprofitable and retries the position on the next block, rather than recording the exponential
backoff that would sample the ramp at t+73s and t+137s and miss the block where it first becomes
fundable. Blue keeps today's backoff — its incentive is static, so a miss there does carry information.

The encoded min-out stays at break-even. MIN_SURPLUS_BPS gates the expected output before a
simulation is spent; it deliberately does not tighten the minimum baked into calldata. Raising that
minimum would buy margin against oracle drift between simulation and inclusion, but the size of that
buffer is exactly what the per-maturity basis readout (BOTS-35 item 1) exists to determine, and this PR
does not pick a number it cannot justify. The residual is a quote-age exposure, now documented in the
bot's README rather than papered over.

The percentage clamp is arithmetic only. A percentage cannot express a floor above its own
denominator, so askableFloor clamps what we ask for. It never lowers what we accept.

Blue is wired too. impliedRepaidAssets mirrors Blue's contract chain — mulDivUp the quoted
value, wDivUp by lif, then the toSharesUp/toAssetsUp round-trip, since Blue repays in shares.
Blue has no pre-simulation profitability gate, so the floor matters more there.

Verification

  • pnpm lint, @repo/swaps / midnight / blue typechecks: 0 (checked by exit code — grep "error TS" silently matches nothing here, because pnpm emits ANSI codes mid-token)
  • pnpm test: 2466 passed, 12 skipped, 0 failed. The 4 failing files are pre-existing fork/e2e suites needing RPC_URL_8453.
  • knip: clean for every symbol this PR adds.

Reviewed externally over five rounds; eleven defects found and fixed, three of them in code previously
reported as fixed. Round 5 raised one more design question — the drift buffer on the encoded min-out —
which is answered above rather than coded.

Linear

Part of BOTS-35

This PR must not close BOTS-35. It satisfies two of the four acceptance criteria:

# Acceptance criterion Status
1 Candidates planned in descending expected-profit order, with a test met749ca77f, test/runner/ranking.test.ts
2 The allowance revert reproduced, root-caused and fixed premise retracted — see below
3 Slippage guard retuned so it does not reject sound fills met — the floor is now the position's break-even
4 Verified against the next cbBTC/USDC maturity not met — needs a live maturity

On criterion 2: the ticket attributes the revert to a just-in-time exact-amount approve racing the
simulation. approvePair does not emit an exact-amount approval — it approves the Executor's live
balance
(packages/swaps/src/execution/executor-calls.ts), so allowance == balance by
construction and ERC20: transfer amount exceeds allowance can only mean the loan balance after the
swap was short of the derived repay. That is a shortfall, not an approval-ordering bug, and it is what
the min-out floor in this PR addresses. Nothing was reproduced by anyone, per the ticket's own
instruction not to assume it fixed — so criterion 2 should be rewritten or struck rather than ticked.

Criterion 4 is the hard blocker: production has logged zero tx.* and zero plan.built events in
14 days, so there is no maturity to verify against yet.

Referenced, not closed

Ref BOTS-87 — Blue does not yet have this PR's counters.
Ref BOTS-81 — sweeping stranded sub-cent debt. Relevant because it pulls the opposite way from a
MIN_NET_PROFIT_USD floor: BOTS-81 wants the bot to take dust deliberately, while BOTS-35's own
out-of-scope note calls dust sweeping gas spent for nothing. Gas evidence for that trade-off is
posted on BOTS-81. No dust floor ships in this PR.
Ref CRTR-3124 — 1inch sends the Executor as origin where the API wants the initiating EOA. Pre-existing (b7fdeb724), needs an EOA threaded through both bots.

Also still open, untracked: the LIF-rate-vs-integer-margin mismatch remains a documented inefficiency
(wasted quoting, not a loss path).

@linear-code

linear-code Bot commented Aug 27, 2026

Copy link
Copy Markdown

@haydenshively
haydenshively marked this pull request as ready for review August 28, 2026 02:00

@devin-ai-integration devin-ai-integration Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Devin Review found 1 potential issue.

1 flag not posted on this PR by your GitHub settings — view it in Devin Review. (Configure)

Devin Review

Comment thread bots/midnight-liquidation/README.md Outdated

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 1093e2f794

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread packages/swaps/src/quoting.ts Outdated
Comment thread bots/midnight-liquidation/src/discovery/token-prices.ts
Comment thread bots/midnight-liquidation/src/config.ts
Comment thread bots/midnight-liquidation/src/index.ts Outdated
Comment thread bots/midnight-liquidation/src/quotes.ts
haydenshively and others added 10 commits August 28, 2026 08:30
A seize-exact plan's entire margin is the liquidation incentive
`(lif - 1)/lif`, which post-maturity ramps from zero over an hour. Below
that, no swap route can fund the repay: the shortfall surfaces either as
the router's min-out revert or as Midnight's repay pull failing with
`ERC20: transfer amount exceeds allowance`. On the 31 Jul cbBTC/USDC
maturity, 824 plans produced 81 quotes and 81 simulation reverts, 1:1 —
every quote spent before the incentive crossed our execution cost bought
a guaranteed revert.

Skip those plans in sizing, where the arithmetic already lives:

- `LiquidationPlan` carries the LIF it was sized at and the repay the
  contract will ceil-derive from it. Neither is recoverable from
  `postMaturityMode` alone, because the matured-and-unhealthy branch
  chooses a mode by surplus. `planSurplus` reads both instead of
  recomputing `lifAt`, so it can no longer disagree with the plan.
- `HEADROOM_FLOOR_BPS` (default 3) is a LOWER BOUND on execution cost,
  not a typical-cost estimate. The gate suppresses until
  `headroom(t) >= floor`, making it a pure time gate — 3 bps hides
  roughly the first 25s on a 4.4%-maxLif tier. Higher values blind the
  earliest, most contested part of a maturity, which costs far more than
  a wasted quote.
- The floor reads the CHOSEN plan's LIF, downstream of mode selection.
  Normal mode pays the full `maxLif` with no ramp, so a floor derived
  from a ramping post-maturity LIF would reject matured-and-unhealthy
  positions the chain funds immediately. Covered by regression tests in
  both the sizing and tick suites.
- `insufficient_headroom` rides `plan.skipped` at `debug`: headroom is
  scale-invariant, so it fires identically for every candidate in a
  `(maturity, maxLif, mode)` group. One line per position per block is
  what buried the post-mortem.

Because a sizing skip records neither backoff nor cooldown, a gated
position is re-planned every block — I/O-free, and `planSkipped` will be
correspondingly large. No current tier can sit permanently under the
floor: the ceilings are 420/255/60 bps against 3.

`isBadDebtRealization` now takes only the two amounts it reads, so a
caller holding wire-verified params need not synthesize a plan.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
`liquidate` ends by pulling its own re-derived `repaidUnits` from the payer,
while the Executor's callback approves only its live balance. A route returning
less than that repay therefore reverts as `ERC20: transfer amount exceeds
allowance` — a balance shortfall wearing an allowance error's clothes, with no
amounts attached. On the 31 Jul cbBTC/USDC maturity that string was emitted 131
times and the approval encoding was suspected for a month.

Compare the quote against `plan.impliedRepaidUnits` before simulating, and skip
with `quote.unprofitable` carrying requiredRepay / achievableOut / shortfallBps.
Break-even is read off the plan rather than recomputed: the matured-and-unhealthy
branch picks a mode by surplus, so the LIF a plan was sized at is not recoverable
from `postMaturityMode` or from chain time, and recomputing it would overstate the
repay for a normal-mode plan.

Deliberately no backoff and no cooldown on this exit. Both sides of the
comparison move on a ten-second scale — the ramp lifts break-even while route
cost is itself volatile — so the outcome says almost nothing about the next
attempt. Quote volume is bounded by the pre-quote headroom gate instead.

`MIN_SURPLUS_BPS` defaults to 0, i.e. pure break-even: both sides then come from
the contract's own formula with no tuned value, so the gate can only reject plans
that would have reverted on-chain.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
The tick works candidates serially — one quote and one simulation each — in
whatever order discovery returned them, which is ascending checksummed address
order. At the 31 Jul cbBTC/USDC maturity the largest position sat fifth of
fourteen, behind three worth under $1.10 combined, in an auction where the
first mover takes the whole position.

Split the tick in two. Phase A is pure: the lens batch is already in memory, so
building plans and scoring them costs nothing and ordering the result is free.
Phase B runs the expensive serial stages — cooldown, quote, simulate, submit —
over that order. `plan.built` moves into phase B and gains `rank`, `surplus`
and `surplusUsd`, emitted per candidate so the timestamp sequence remains the
record of what was worked and when; that sequence is how this maturity was
reconstructed at all.

The sort key is oracle surplus converted to USD. Loan units alone are not
comparable across markets — an 18-decimal loan token would systematically
starve a 6-decimal USDC market — so `discovery/token-prices.ts` keeps a
snapshot of loan-token prices from the markets tokens endpoint, whose typed
client is already generated and already consumed by borrower discovery.

Ranking only, never gating, which is what makes an indexed price acceptable:

- `usdValueOf` is synchronous and reads the snapshot, so no candidate waits on
  HTTP before being planned. Awaiting a price mid-burst would add exactly the
  latency this ordering exists to remove.
- An unpriced token sorts last rather than as zero — absent price means
  unrankable, not worthless. The sort is stable, so a total outage degrades to
  the previous discovery order rather than to an untested fallback.
- The snapshot has no max-age ceiling, unlike the listed-markets whitelist: a
  stale whitelist can keep a delisted market in scope, whereas a stale price
  only misorders work. It fails open and reports its age instead.
- Prices refresh on their own timer. `fetchWithRetry` is a 5s deadline times
  three retries, so folding this into the whitelist loop could stall a
  fail-closed safety refresh for ~22s on behalf of a cosmetic one.

USD figures use the 1e8 scale the Blue profitability-gate design settled on for
this same endpoint, so the two can converge without a rescale. Conversion goes
through `parseUnits` on fixed-notation text, so no float arithmetic survives
the API boundary and a price with no usable 1e8 precision reads as unpriced
rather than as zero.

`planSurplus` is exported and reads the plan's own recorded
`impliedRepaidUnits`, so it cannot disagree with the LIF the plan was sized at.
Its docstring states what it is not: oracle-only, excluding DEX cost and gas,
and structurally positive for every post-maturity candidate — a ranking key,
not a profitability measure.

`unpriced` joins TickCounters as an attribute rather than a loop exit, so it
deliberately participates in none of the sum identities: an unpriced candidate
is still worked, just ordered last. A persistently high value means the
snapshot is not covering the loan tokens actually being liquidated.

Ordering is necessary but not sufficient. Early in the post-maturity LIF ramp
every candidate is uniformly unviable regardless of size, so ordering only
decides anything once more than one candidate clears a viability threshold.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
A liquidation's entire margin is the protocol's liquidation incentive, so
break-even — not a percentage — is the economically correct min-out floor.
`SLIPPAGE_BPS` at its 100 default is wrong in BOTH directions, and which
one depends on where the incentive sits, so no single value is right:

  midnight cbBTC/USDC, maxLif 1.043841, oracle value 10_000
    t         incentive   break-even   minOut @1%   the fixed guard is
    +123s        15.0bp      9985.04      9883.17   too loose
    +600s        72.5bp      9927.46      9883.17   too loose
    +1800s      214.5bp      9785.50      9883.17   too tight
    +3600s      420.0bp      9580.00      9883.17   too tight

Below break-even the router accepts a route that cannot settle, and the
shortfall surfaces at the repay as `ERC20: transfer amount exceeds
allowance` — which reads as an approval bug and is not one. Above
break-even the router rejects fills that would have settled profitably.

`QuoteParameters`/`QuoteRequest` gain an optional `minAcceptableAmountOut`.
When set, the allowance is derived from it instead of from `slippageBps`.
It is expressed as a percentage of the oracle reference rather than as an
absolute because the aggregators' only lever IS a slippage parameter —
they bake the min-out themselves from it. Uniswap derives its min-out as
`reference · (1 - slippage)`, so the same percentage lands it on the
absolute floor exactly, and no venue needed changing.

An aggregator lands slightly below the floor, by its quote's shortfall
against the oracle (the execution cost). That is the safe direction: it can
never reject a fill that would have settled.

Wired in both liquidators, and omitting the field reproduces today's
behavior exactly:

- midnight reads `plan.impliedRepaidUnits` straight off the plan. The
  matured-and-unhealthy branch picks a mode by surplus, so the LIF is
  recoverable from neither `postMaturityMode` nor chain time.
- blue derives it at the seam from `lifFromLltv(lltv)`. Its LIF is a pure
  function of the market — no maturity ramp, no mode choice — so there is
  nothing about it a plan could know that the market does not.

This also makes the knob monotone. Raising `SLIPPAGE_BPS` previously
loosened the router's protection and pushed shortfalls onto the repay; an
economic floor cannot be widened below break-even.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
Both consumers of `@repo/swaps` supply an economic min-out floor, and the
package is private/unpublished, so nothing could reach the operator
percentage any more. Deleting it rather than leaving a knob that silently
does nothing:

- `QuoteRequest.minAcceptableAmountOut` is required, and the derivation is
  unconditional.
- `composeMultiVenueQuoting` no longer takes `slippageBps`, and the entries
  it synthesizes no longer carry one. Venues already read
  `params.slippageBps` — the derived value — never the entry's, so
  `entry.slippageBps` was vestigial before this too.
- `SLIPPAGE_BPS` and `VenueConfig.slippageBps` are gone from both bots.
- `parseSwapConfig` keeps accepting `slippageBps`, now optional: the seed
  script reads only `venue`/`router`/`fee`, so existing operator JSON still
  parses. Prod's stale `SWAP_CONFIG_PATH` is likewise inert.

Leaving the env var unread rather than rejected is deliberate — a
deployment that still sets it must keep starting, since an unknown env var
is not a misconfiguration. Both bots have a test pinning that.

`minAcceptableAmountOut` also comes off `QuoteParameters`: no venue reads
it, only the quoting layer that derives the percentage from it, so it has
no business on the venue-facing type. `slippageBps` stays there as the
derived value a venue consumes — which is why the fork suites, which call
`quoteUniswapV3` directly with their own percentage, are untouched.

A ceiling would have been the wrong repair. Capping the allowance below the
incentive reintroduces the too-tight failure late in the ramp, which is
exactly what the previous commit fixed.

`PENDLE_SLIPPAGE_BPS` is unrelated and unaffected — it bounds the PT →
underlying unwrap hop, not a venue's min-out.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
An external review (gpt-5.6-sol) found three defects in the preceding
commits. All three verified; all three were places a claim was made and
the code did not deliver it.

**1. The aggregator min-out sat below break-even — the whole point of the
floor.** The percentage was derived against the oracle reference `R`, but
aggregators apply it to their own quote `Q < R`, landing their min-out at
`Q·F/R`. With `R=10000, F=9580, Q=9700` a 420bps request floors at 9292:
a fill of 9342 clears the router and then reverts at the protocol's repay
pull, which is the exact failure the floor exists to prevent. Calling that
"the safe direction" conflated safety-against-false-rejection with an
economic floor.

`firmQuoteVenue` now re-derives against the venue's own quoted output when
the first pass comes back short, so the floor lands where it belongs. One
extra call, taken only when needed and only for the already-chosen venue;
Uniswap applies the percentage to the reference itself and never takes the
branch. A failed re-quote keeps the first quote rather than losing the
position — it is still executable, just with a looser on-chain floor, and
simulation plus the pre-broadcast check both still stand in front of it.

The old tests asserted only the percentage sent, never the resulting
min-out. That is why this escaped them, so the new ones assert the min-out.

**2. Blue's break-even understated what Blue pulls.** It reused
`expectedLoanOut`, which floors, where Blue ceils, and skipped the shares
round-trip entirely. Blue settles the repay in shares — `toSharesUp` then
`toAssetsUp` — and both conversions round up, so the estimate was short and
the floor derived from it did not protect the repay.

`LiquidationPlan` now carries `impliedRepaidAssets`, mirroring the contract
chain step for step. Checked against `contractRepaidShares`, an independent
reimplementation already in the test file for the underflow sweep, so the
assertion is not circular.

**3. Headroom was not scale-invariant, as claimed.** `seizedValueOf` floors
while `impliedRepaidUnits` double-ceils, so the amount-wise ratio was
neither exact nor monotone in size: at one LIF, 167 units reported 0bps
where 168 reported 59, and 1000 units reported 50. It is now derived from
`lif` directly — `(lif - WAD)·BPS/lif` — which is exactly scale-invariant
by construction, since no amount enters it.

Also from the review: `quote.unprofitable` measured its shortfall against
break-even while viability compared against break-even plus the configured
surplus, so a rejected route could report a NEGATIVE shortfall and omit the
bar that rejected it. It now reports `requiredThreshold` and
`minSurplusBps` and measures against the threshold.
`plan.skipped/insufficient_headroom` gained the floor, `maxLif` and
seconds-since-maturity; the realized headroom is deliberately absent, since
a skip discards the plan and the chosen mode's LIF is not recoverable.

`expectedLoanOut` narrows to `Pick<LiquidationPlan, 'seizedAssets'>` in both
bots — it reads only that, and requiring the derived fields would force
callers to synthesize them.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
Second review round found the retry did not actually guarantee the floor.
Three ways it could still ship a quote whose encoded minimum sits under
break-even:

- The retry derives its percentage from the FIRST quote's output, so a
  second quote that comes back lower lands under the floor again. Floor
  9580, first quote 9700 -> ask 123bps, second quote 9600 -> minimum 9481,
  which is 99 units short. The previous test held both responses at 9700,
  so it never exercised drift.
- A failed retry knowingly kept the first, under-floor quote.
- 1inch returns opaque calldata and we RECONSTRUCT its minimum
  arithmetically, so comparing that value against the floor compares our
  arithmetic with itself and always agrees.

The retry is now only an attempt to satisfy a postcondition, and the
postcondition is the guarantee: after the final response, a quote whose
encoded minimum is not known to clear break-even is refused, and the caller
falls through to the next venue with `quote.floor_unmet`. Refusing is
correct — a floor that does not protect the repay is the original defect,
and simulation does not restore the invariant, since it proves one state
rather than preventing a later fill between the router minimum and the
protocol pull.

`Swap` gains `minOutSource: 'venue' | 'derived'` so this is decidable at
all. uniswap-v3 encodes the calldata here; 0x, LiFi and LiquidSwap report
their own minimum; 1inch does not, so it can never satisfy an enforced
floor until its `minReturn` parameter is wired. It is not enabled in prod
(no ONEINCH_API_KEY) and it still falls through rather than failing the
tick, but an operator enabling it will see every attempt refused with
`minOutSource: 'derived'` in the event.

Also: the claim that a skip's realized headroom was unrecoverable was
wrong. `gateOnHeadroom` holds the chosen plan when it rejects it; the value
was being discarded by `PlanOutcome`'s shape, not lost. The skip now
carries `{ bps, lif, postMaturityMode }`, and `plan.skipped` logs all
three. `maxLif` plus chain time cannot substitute: a matured-and-unhealthy
position may be sized in either mode, so neither identifies the LIF that
was applied.

Test fixtures across three packages had break-even set at or above their
stubs' reported minimums, which the postcondition correctly refuses. They
now sit below it, so those cases exercise what they are about — lens
projection and venue routing — rather than the floor. The 0x stub in the
floor tests now applies the requested slippage to its own quote, which is
what the real API does and what a fixed-minimum stub could not model.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
Third review round. Both holes let a quote through whose encoded minimum
sits under break-even.

**The postcondition was checking a weakened floor.** A floor above the
oracle reference was clamped, and the check then used the clamped value —
so a request for 1001 against a reference of 1000 could return a swap whose
minimum was 1000, and a test preserved that. The clamp exists only because
a percentage cannot express a floor above its own denominator (at
`lif == WAD` the double-ceil can land a unit over the floored reference); it
is a bound on what we can ASK for, never on what we accept. The
postcondition now uses the requested `minAcceptableAmountOut`, so that case
is refused rather than silently downgraded.

**The unwrap-only path skipped the floor entirely.** A chain ending in the
loan token returns before any venue is quoted, and only route quality was
checked — an unrelated threshold. Reference 1000, route-quality floor 950,
unwrap worst case 970, break-even 990: passed, with a bound 20 units short.
Live for Blue in particular, which has no pre-simulation profitability gate.
It now enforces the same floor and logs `quote.floor_unmet`.

**1inch now asks for an absolute `minReturn`** rather than a percentage the
API applies to its own quote, so its bound can be reported instead of
reconstructed, and it stops being permanently unusable under a floor. Two
reviews independently cited the v6.1 docs for `minReturn` being an absolute
base-unit minimum. If the parameter is rejected the quote fails and falls
through to the next venue, which is the same outcome as before, so the
downside is bounded.

That required restoring `minAcceptableAmountOut` on `QuoteParameters`, which
an earlier commit removed on the argument that no venue reads it. A venue
that takes an absolute minimum does; venues that take a percentage still get
`slippageBps` derived from it.

Also: the retry is skipped for a `derived` minimum, which could never clear
the floor however it was asked for — it was costing a second API call
guaranteed not to help.

`clearsFloor` is exported and unit-tested on both branches, because no
shipped venue reports a `derived` minimum any more and the rule would
otherwise be untested. Two new tests pin each bot's projection of plan
break-even into the slippage it asks for; both fail if the adapter stops
threading it, which the loosened fixtures no longer prove on their own.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
Both doc blocks still described the pre-minReturn shape: one said the on-chain
min-out is an oracle-derived floor recorded for observability, the other that a
percentage is all any aggregator accepts and so the absolute floor never reaches
a venue. The adapter now asks for an absolute minimum and reports the router's
own bound, which is what makes it usable under an enforced floor.
Review round 5. A venue whose guaranteed output missed break-even collapsed
into `bad_route`, so the tick recorded backoff and cooldown on what the design
calls an economic skip — sampling the LIF ramp exponentially and potentially
skipping the block where a position first becomes fundable.

- `QuoteFailureReason` gains `floor_unmet`, split from the transport/route
  failures it is not; midnight counts it as `quoteUnprofitable` and retries the
  next block. The per-venue log drops to `info`, since every venue misses the
  floor for every candidate through the early ramp. Blue keeps today's backoff:
  its incentive is static, so a miss there does carry information.
- Token prices derive their base URL from the *candidates* path, not the tokens
  path — the old comparison could never match, so a gateway prefix silently
  collapsed to the origin and left every candidate unpriced.
- The boot price fetch moves into its refresh loop: a hanging tokens endpoint
  delayed the first tick by ~22s on a source that is ranking-only and fail-open.
- README: document `MIN_SURPLUS_BPS`, including that it gates the expected
  output and not the min-out encoded in calldata, and restore the venue
  section's truncated sentence.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
@haydenshively
haydenshively force-pushed the feat/bots-35-headroom-gate branch from 1d3fdeb to 1b6f8c7 Compare August 28, 2026 13:31
@haydenshively
haydenshively merged commit 298aa1b into main Aug 28, 2026
4 checks passed
@haydenshively
haydenshively deleted the feat/bots-35-headroom-gate branch August 28, 2026 13:34
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants