feat(midnight-liquidation): union the market whitelist across sources - #143
Merged
Merged
Conversation
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>
- 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>
There was a problem hiding this comment.
💡 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".
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>
cashd
approved these changes
Aug 24, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Why
Prod and staging pointed at different Midnight markets APIs — prod at the in-code default
api.morpho.org, staging overriding toapi.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/markets/midnight/liquidation-candidatesSo 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.orgbefore prod did: a breaking change to the host prod depends on would sail past staging.What
MARKETS_API_URLnow 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.ts—createUnionListedMarketFiltercomposes 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— newurlListEnv(order-preserving, de-duplicated on the parsed URL, fail-loud on any malformed entry or an all-separators value);MarketsConfig.apiUrl→apiUrls.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.tsnow 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.
deploy-stagingships this code tostaging-bot.MARKETS_API_URLtohttps://api.morpho.org/v0/midnight/markets,https://api.morpho.dev/v0/midnight/markets, and delete staging's now-redundantLIQUIDATION_CANDIDATES_API_URL(a no-op override — the payloads are identical). Confirmmarkets.whitelist { markets: 19, sources: 2, fresh: 2 }.loadConfigwould 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.refreshfans 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 ahost/pathsource 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(aterror: the non-throwing contract broke).discover.filteredadditionally 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, andproduct-manager. The scoping decision above came out of that review — the original plan had prod read both endpoints. Fixed in13f31f0: the per-tick expiry signal that a longMARKETS_REFRESH_MScould outrun, the silent empty-filterscase, the unreported empty-response transition, the startuptryCatchthat 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_URLnow 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: falseshortly after maturity is by design), delisting-propagation semantics documented,chainIdthreaded into the union events,skippedMarketsids ondiscover.filtered,maxRepaidPreMaturity→maxRepaidNormalMode(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:
continuefor 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
typecheck0 errors;bun lint0 warnings;bun formatapplied;knipclean.bun test: 1600 pass / 9 fail — the same 9 failures as the stashed baseline (fork + e2e tests requiringRPC_URL_8453/FORK_URL), so zero regressions. 14 new tests.isFreshguard 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 thechainIdthreading, dropped the field from themarkets.whitelistemit and confirmed both of its assertions fail. Reverted after each.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