feat(midnight-liquidation): union the market whitelist across sources - #143
Draft
haydenshively wants to merge 2 commits into
Draft
feat(midnight-liquidation): union the market whitelist across sources#143haydenshively wants to merge 2 commits into
haydenshively wants to merge 2 commits into
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>
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.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:
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).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.Known and deliberately not in scope — pre-existing issues 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.sizing/plan.ts:125). Only staging will see daily maturities.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. Reverted after each.🤖 Generated with Claude Code