Skip to content

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

Draft
haydenshively wants to merge 2 commits into
mainfrom
feat/midnight-liq-multi-source-whitelist
Draft

feat(midnight-liquidation): union the market whitelist across sources#143
haydenshively wants to merge 2 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.

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: 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).

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.

Known and deliberately not in scope — pre-existing issues 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.
  • Post-maturity mode is always chosen for a matured-and-unhealthy position, forfeiting most of the LIF incentive during the ramp (sizing/plan.ts:125). Only staging will see daily maturities.

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. Reverted after each.

🤖 Generated with Claude Code

haydenshively and others added 2 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>
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.

1 participant