Skip to content

feat(midnight-liquidation): union the market whitelist across sources - #143

Merged
haydenshively merged 5 commits into
mainfrom
feat/midnight-liq-multi-source-whitelist
Aug 24, 2026
Merged

feat(midnight-liquidation): union the market whitelist across sources#143
haydenshively merged 5 commits into
mainfrom
feat/midnight-liq-multi-source-whitelist

Conversation

@haydenshively

@haydenshively haydenshively commented Aug 6, 2026

Copy link
Copy Markdown
Collaborator

Why

Prod and staging pointed at different Midnight markets APIs — prod at the in-code default api.morpho.org, staging overriding to api.morpho.dev (which carries extra whitelisted shorter-maturity markets for testing). Staging is supposed to reveal problems that will exist in prod, and an external dependency differing between the two undercuts that.

Measuring the actual difference first narrowed the problem considerably:

api.morpho.org (prod) api.morpho.dev (staging)
/v0/midnight/markets?listed=true 6 markets 19 — the same 6 plus 13 test markets maturing daily
/markets/midnight/liquidation-candidates 45 rows, 26 markets byte-for-byte identical

So the candidates endpoint is chain-wide, not environment-scoped (staging's override of it is a no-op), and the whitelist was the only real difference — additive, on the same chain, with the same cbBTC collateral and USDC loan token in both sets. The gap that actually mattered was that nothing ever exercised api.morpho.org before prod did: a breaking change to the host prod depends on would sail past staging.

What

MARKETS_API_URL now accepts a comma-separated list of endpoints, and the whitelist is the union across them, with the max-age staleness rule applied per source.

  • src/discovery/markets.tscreateUnionListedMarketFilter composes per-source filters. current() freezes the fresh-source set for one discovery pass, so a pass is judged against one staleness reading.
  • src/config.ts — new urlListEnv (order-preserving, de-duplicated on the parsed URL, fail-loud on any malformed entry or an all-separators value); MarketsConfig.apiUrlapiUrls.
  • src/index.ts — builds one filter per URL and reports the all-expired case per tick.

Post-maturity liquidation-mode choice (faf1e33)

Surfaced by the protocol review and folded in here because the daily-maturity test markets staging now whitelists make it routine: for a matured and unhealthy position the contract opens both liquidation gates, but plan() always picked post-maturity mode, whose LIF ramps WAD → maxLif over an hour. Early in the ramp that repays nearly the full seized value, forfeiting the ~420 bps incentive normal mode pays immediately (~40x haircut at 60s past maturity) — and it made the first sim-passing block the block where profit ≈ 0, inviting sim-to-inclusion reverts.

sizing/plan.ts now builds both candidate plans when both gates are open and keeps the one with the higher expected surplus (seized oracle value minus contract-derived repaid). Ties resolve toward post-maturity: after the ramp it is at least as good (same LIF, no RCF cap), and its gate (now > maturity) cannot close between read and exec, while normal mode's (unhealthy) can if the price recovers. Matured-and-healthy positions are unchanged — post-maturity is their only open gate.

Intended deployment: staging gets the superset, prod stays prod-only

The property that matters is superset, not equality: staging must exercise everything prod touches. Staging reads both endpoints (so it finally exercises api.morpho.org, and its whitelist is a strict superset of prod's); prod keeps the single-endpoint default and does not widen. That fixes the stated problem at zero prod blast radius, and keeps the prod key from spending real gas on markets that exist only for testing.

The default stays the single public endpoint, so this branch is inert for prod — no prod env change is needed at all.

  1. Merge → deploy-staging ships this code to staging-bot.
  2. Set staging MARKETS_API_URL to https://api.morpho.org/v0/midnight/markets,https://api.morpho.dev/v0/midnight/markets, and delete staging's now-redundant LIQUIDATION_CANDIDATES_API_URL (a no-op override — the payloads are identical). Confirm markets.whitelist { markets: 19, sources: 2, fresh: 2 }.
  3. Prod: nothing to do.

⚠️ Set a list value only after the supporting image is live, and clear it back to a single URL before rolling back past this release — older images validate the var as one URL and loadConfig would crash-loop the service. Documented in the README env table.

Safety semantics

Per-source staleness is what makes reading two endpoints safe in both directions: a source that goes down or goes stale drops out of the union (markets.source_expired) rather than either emptying the whitelist and halting all liquidations, or letting a stale set keep a since-delisted market in scope. refresh fans out concurrently and never throws — a per-source failure keeps that source's last-known-good and still lands its healthy peers. The union throws at construction if no source is configured, because an empty union lists nothing while logging nothing.

Log events (all carrying chainId): markets.whitelist (deduplicated combined size — per-source counts overlap and can be neither summed nor maxed into it), markets.listed / markets.refresh_failed / markets.source_expired (all carrying a host/path source label), markets.listed_empty (a source went from some markets to none — authoritative, but a healthy peer would otherwise mask it), markets.whitelist_expired (per tick, all sources stale → fail-closed), markets.refresh_error (at error: the non-throwing contract broke). discover.filtered additionally names the filtered-out market ids (skippedMarkets), so "was market X whitelisted at tick T" is answerable from one log line.

One semantic consequence of union-additivity, now documented in the union JSDoc and the README: an active delisting takes effect only once every configured source has dropped the market — one endpoint delisting it does not remove it while a peer still lists it.

Review

Reviewed by reviewer, protocol-engineer, and product-manager. The scoping decision above came out of that review — the original plan had prod read both endpoints. Fixed in 13f31f0: the per-tick expiry signal that a long MARKETS_REFRESH_MS could outrun, the silent empty-filters case, the unreported empty-response transition, the startup tryCatch that had been dropped, per-pass staleness consistency, the union-size event, and source labels/dedup that collided on same-host endpoints.

Codex follow-ups fixed in ad9fcb5: MARKETS_API_URL now rejects empty entries (a leading/trailing/repeated comma — usually a misinterpolated env var — must fail loud rather than silently narrow the whitelist), and trailing path slashes are canonicalized before de-duplication so two spellings of one endpoint cannot be polled twice and counted as two independent sources.

A second review round (same three agents) confirmed the mode-choice implementation line-by-line against the contract source and found no remaining Critical/Important code issues. Fixed in 1e2f62d: README mode docs brought in line with the both-gates choice (postMaturityMode: false shortly after maturity is by design), delisting-propagation semantics documented, chainId threaded into the union events, skippedMarkets ids on discover.filtered, maxRepaidPreMaturitymaxRepaidNormalMode (normal mode is gated on unhealthiness alone, so it applies after maturity too), and two comment inaccuracies (union source label is host + path; URL dedup collapses only bare-origin trailing slashes and default ports).

Known and deliberately not in scope — pre-existing issue this PR no longer amplifies, now that prod stays narrow:

  • The uncounted continue for unplannable positions (runner/tick.ts:140) — prod's silently-dropped set stays at its current size rather than growing. Still worth its own fix.

Verification

  • typecheck 0 errors; bun lint 0 warnings; bun format applied; knip clean.
  • bun test: 1600 pass / 9 fail — the same 9 failures as the stashed baseline (fork + e2e tests requiring RPC_URL_8453 / FORK_URL), so zero regressions. 14 new tests.
  • Vacuity checks: dropped the isFresh guard from the union and confirmed both per-source-expiry tests fail; separately replaced the deduplicated union size with a sum and removed the empty-source guard, and confirmed both of those tests fail; for the mode choice, forced post-maturity unconditionally and confirmed both normal-mode-preference tests fail; for the chainId threading, dropped the field from the markets.whitelist emit and confirmed both of its assertions fail. Reverted after each.
  • Mode choice: 4 new plan() tests (normal wins early in the ramp — cap-bound and underwater shapes; post-maturity wins the post-ramp tie; post-maturity wins when the RCF cap binds normal mode). The fork e2e position is matured-and-healthy, so its post-maturity assertion is unaffected.

🤖 Generated with Claude Code

haydenshively and others added 3 commits August 6, 2026 10:39
Staging and prod pointed at different Midnight markets APIs (api.morpho.dev
vs api.morpho.org), so the two deployments disagreed about which external
service defines the market whitelist. The dev list is a strict superset of
prod's — the same 6 markets plus 13 daily-maturity test markets — and the
liquidation-candidates endpoint is byte-identical across both hosts, so the
whitelist was the only real difference.

`MARKETS_API_URL` now accepts a comma-separated list of endpoints, and the
whitelist is the union across them. Both deployments can then hold the same
value: the difference becomes which markets are listed, not which API is
trusted, and staging finally exercises the endpoint prod depends on.

The max-age staleness rule is applied PER SOURCE, which is what makes reading
two endpoints safe in both directions: a source that goes down or goes stale
drops out of the union (markets.source_expired) instead of either emptying the
whitelist and halting all liquidations, or letting a stale set keep a
since-delisted market in scope. Only when every source is stale is the
whitelist empty (markets.whitelist_expired, fail-closed). `refresh` fans out
concurrently and never throws — a per-source failure keeps that source's
last-known-good and still lands its healthy peers, because a partial refresh
must not read as a total one.

The default stays the single public endpoint: an additional source widens what
the bot will spend real capital on, so it is opt-in per deployment rather than
shipped in the default. A single-URL value parses exactly as before.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…nion

Keeps production single-source (the public endpoint) and lets staging hold the
superset — staging then exercises the endpoint production depends on without
production spending real capital on markets whitelisted for testing. The union
mechanism stays; only the intended deployment differs, and the README no longer
tells operators to set the same value everywhere.

Silent-failure fixes:

- Report `markets.whitelist_expired` per tick from `discover` again, instead of
  once per refresh. The whitelist expires on LISTED_MARKETS_MAX_AGE_MS but was
  only re-checked on MARKETS_REFRESH_MS, which is unbounded — a longer interval
  (or a wedged refresh loop) left a total liquidation halt unreported for most
  of each interval, visible only as `discover.filtered { listed: 0 }`.
- Throw when no markets source is configured. An empty union lists nothing,
  logs nothing, and is indistinguishable from a working fail-closed whitelist.
- Warn `markets.listed_empty` when a source goes from some listed markets to
  none. A successful-but-empty response is authoritative, so it replaces
  last-known-good — and in a union a healthy peer would mask it entirely.
- Restore the startup `tryCatch`: a first-fetch failure must not be fatal, and
  the comment claiming "non-fatal by construction" contradicted the code.
- Emit `markets.refresh_error` at error, not warn — it means the union's
  non-throwing contract broke, which is not an API blip.

Correctness and observability:

- `current()` freezes the fresh-source set for one discovery pass, so a pass is
  judged against one staleness reading rather than re-deriving it per candidate.
- Log the deduplicated union size as `markets.whitelist`. Per-source
  `markets.listed` counts overlap, so they can be neither summed nor maxed into
  the combined number.
- Label a source by host AND path, so two sources on one host stay
  distinguishable; de-duplicate `MARKETS_API_URL` on the parsed URL so trivially
  different spellings of one endpoint are not polled and counted twice.

Docs: correct the claim that EXCLUDE_COLLATERALS vetoes an added source (it is
collateral-scoped, and every listed market shares one collateral), note that
LISTED_MARKETS_MAX_AGE_MS is a build-time constant rather than a knob, and warn
that rolling back past this release with a list-valued var will crash-loop.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…aturity

For a matured AND unhealthy position the contract opens both liquidation
gates — "After maturity, an unhealthy borrower's liquidator can choose
between both modes" — but the plan always picked post-maturity mode, whose
LIF ramps WAD → maxLif over an hour. Early in the ramp that repays nearly
the full seized value (LIF ≈ 1), forfeiting almost the entire incentive
that normal mode would pay immediately at the full maxLif: at 60s past
maturity the difference is ~40x on these markets. It also meant the first
block where the simulation passes is by construction the block where
profit ≈ 0, inviting sim-to-inclusion reverts.

Each mode's sizing now lives in its own builder, and when both gates are
open the plan builds both candidates and keeps the one with the higher
expected surplus (seized oracle value minus the contract-derived repaid).
A tie resolves toward post-maturity: after the ramp it is at least as good
(same LIF, no RCF cap), and its gate (`now > maturity`) cannot close
between read and exec, while normal mode's (`unhealthy`) can if the price
recovers. Matured-and-healthy positions are unchanged — post-maturity is
their only open gate.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@haydenshively haydenshively self-assigned this Aug 21, 2026
@haydenshively
haydenshively marked this pull request as ready for review August 21, 2026 21:35
@haydenshively
haydenshively requested a review from cashd August 21, 2026 21:35
- README: document the matured-and-unhealthy both-gates mode choice
  (postMaturityMode: false shortly after maturity is by design) and the
  union's delisting semantics (must propagate to every source)
- markets: note active-delisting propagation in the union JSDoc, fix the
  UnionSourceSnapshot.source doc (host + path, not host), and thread
  chainId into the three union events for log-context consistency
- config: correct the urlListEnv dedup comment (bare-origin trailing
  slash only, not any trailing slash)
- rcf: rename maxRepaidPreMaturity -> maxRepaidNormalMode (normal mode
  is gated on unhealthiness alone, so it applies after maturity too)
- index: name the whitelist-filtered market ids in discover.filtered so
  "why was market X skipped" is answerable from logs

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

@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: No Issues Found

Devin Review analyzed this PR and found no bugs or issues to report.

Open in Devin Review

@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: faf1e33388

ℹ️ 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 bots/midnight-liquidation/src/config.ts
Comment thread bots/midnight-liquidation/src/discovery/markets.ts
Comment thread bots/midnight-liquidation/src/config.ts
Comment thread bots/midnight-liquidation/src/sizing/plan.ts
Comment thread bots/midnight-liquidation/src/sizing/plan.ts
Comment thread bots/midnight-liquidation/src/discovery/markets.ts
Comment thread bots/midnight-liquidation/src/config.ts Outdated
Comment thread bots/midnight-liquidation/src/config.ts
Codex review follow-ups on urlListEnv:
- reject empty entries (leading/trailing/repeated commas) instead of
  silently dropping them — a misinterpolated env var must not start the
  bot with a narrowed whitelist
- canonicalize trailing path slashes before de-duplication, so two
  spellings of one endpoint cannot be polled twice and counted as two
  independent sources in the union's freshness bookkeeping

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@haydenshively
haydenshively merged commit d2e737d into main Aug 24, 2026
7 checks passed
@haydenshively
haydenshively deleted the feat/midnight-liq-multi-source-whitelist branch August 24, 2026 15:54
@linear-code

linear-code Bot commented Aug 24, 2026

Copy link
Copy Markdown

BOTS-3

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants