Rates Engine v0.5.0-rc.39
Pre-release[v0.5.0-rc.39] — 2026-05-10
Fixed
/v1/changes/coin/{id}accepts friendly slugs alongside the
canonical asset_id. The change-summary worker writes rows under
the canonical form (native,crypto:XLM,USDC-GA5Z…); a
caller passing the friendly slug "XLM" or just "USDC" without
the issuer suffix was silently 404'ing against the strict-
equality lookup even when the underlying data existed. Caught
during the 2026-05-08 prod audit (/v1/changes/coin/XLMand
/v1/changes/coin/nativeboth 404'd despite the worker having
written rows). Handler now expands the input into the same
candidate setoracleAssetCandidatesuses for /v1/oracle/latest
(XLM→[XLM, native, crypto:XLM]) and tries each in order.
First hit wins; storage errors short-circuit. Pinned by 9 unit
tests inchanges_test.go./widgetsshowcase no longer renders broken iframes. The
hardcoded examples referenced asset_id forms (USDC-GA5Z…,
AQUA-GBNZ…) and a synthetic stablecoin-fiat pair
(native~fiat:USD) that aren't in the embed routes'
generateStaticParamsoutput, so the iframes 404'd in the
showcase itself. Aligned the examples with what's actually
pre-rendered: friendly slugs (USDC,AQUA) for the asset
embed and the existing real XLM/USDC pair for the pair embed./v1/observations8s ceiling on the trades hypertable scan.
The handler was missing from the cold-path timeout series shipped
in #1082, #1099-#1106 — a deliberate prod test on 2026-05-08
(asset=native"e=USDC-G…) hit a 10s curl timeout against the
unguarded handler. Now wraps the reader call in
context.WithTimeout(8s); on deadline returns
503 application/problem+jsonwithtype=observations-timeout,
matching the rest of the family.- Auth-failure problem+json
typeURL spelling unified. The
middleware-level 401 (no-auth-at-all) and the account-handler
401 (auth-needed-but-rejected) had drifted to two different
typeURLs —errors/unauthorized(American, middleware) and
errors/unauthorised(British, account.go × 5). Clients keying
on the type URL saw two distinct error categories for what's
semantically one auth failure surface. Standardised on the
American spelling (matches HTTP-spec wording: "Unauthorized");
all 5account.gocall sites updated. No tests pinned the
British form so no test churn. - Explorer home:
HomeCurrenciesandHomeTopMarketsnow
show a "couldn't load" notice on error instead of silently
rendering nothing. Previously both components had
if (isError) return null;— so when /v1/markets panicked
(PR #1233 fix) or /v1/currencies stalled, the entire
section silently disappeared from the homepage and visitors
had no signal that something was wrong vs. the section just
not existing. The new notice points to the full /currencies- /markets pages (which use a different fetch path) and to
status.ratesengine.net for ongoing incident context.
- /markets pages (which use a different fetch path) and to
- Dispatcher tx-read errors are no longer silently swallowed —
internal/dispatcher/dispatcher.go::ProcessLedger's "skip
malformed tx, keep processing the ledger" branch had no
instrumentation: aLedgerTransactionReader.Readfailure
silently dropped the tx and the only signal was a downstream
price gap days later. Now bumps aStats().TxReadErrors
counter that the statsflush periodic snapshot surfaces at
WARN whenever the delta in a flush window > 0. Same pattern
as the existingdecodeErrorsper-source counters; sits
outside the per-source rows schema because tx-read failures
aren't attributable to a single source. Doc comment in the
Stats type + the inline skip both updated to reflect the
new instrumentation. (No-op on healthy r1 today — the value
is the alarm path the moment a corrupt LCM lands in
Galexie.) - Divergence sink failures now log at WARN instead of being
silently swallowed —
internal/divergence/worker.go::flushObservationsdiscarded
theRecordObservationerror with_ = .... So when
Postgres was struggling (e.g. during the 2026-05-09 disk-full
SEV-2 cascade) every divergence_observations row was lost
with no signal. Operators only saw the gap days later when
the explorer's /divergences page surfaced missing data.
ServiceOptionsnow takes an optional*slog.Logger; when
set, sink failures log per (pair, reference) at WARN. The
Redis cache write (load-bearing forflags.divergence_warning)
remains the priority — sink failure does NOT abort the
refresh path. Aggregator passes its component logger; the
API binary doesn't construct a sink so the field stays nil
and the path is no-op.
Added
-
SDK godoc examples for
PriceTip,Sources,Markets,
OHLC(pkg/client/example_test.go). Each is a runnable
example with canned httptest server response + asserted
// Output:comment so the doc is verified at build time and
surfaced in pkg.go.dev. Picks the four most-likely-to-be-used
methods afterPrice/Asset(already had examples) — the
PriceTip / OHLC pair backs every "live UI" use case, while
Markets / Sources back the catalogue / source-attribution
surfaces. -
SDK godoc examples for
PriceBatch,Coins,Coin,Pair
(pkg/client/example_test.go). Round 2 of the godoc-coverage
push — picks the next four highest-traffic methods that lacked
examples on pkg.go.dev. PriceBatch (the recommended bulk path
per the Freighter RFP), Coins + Coin (what powers the
explorer's/assetsand/assets/<slug>pages), and Pair
(per-source attribution for one market). Each example follows
the established httptest +// Output:pattern so it's
verified at build time. -
SDK godoc examples for
Issuers,Issuer,AssetMetadata,
History,Currency,Cursors(pkg/client/example_test.go).
Round 3 of the godoc-coverage push — closes the gap on the
remaining customer-facing methods. Together with the prior two
rounds, the SDK now has runnable examples for every Client
method a typical integration would touch in week one (catalogue- pricing + history + diagnostics surfaces). Each example uses
the established httptest + asserted// Output:pattern so it
surfaces in pkg.go.dev AND verifies at build time.
- pricing + history + diagnostics surfaces). Each example uses
-
Explorer redirects for
/incidents,/converter,
/oracles/<name>(404-audit follow-up, 2026-05-10).
/incidentsand/incident/<slug>now bounce to
status.ratesengine.net (the canonical incidents host —
postmortems live there, the explorer never had a listing
page)./converter(the muscle-memory typo for/convert)
and bare/convertnow land on/convert/USD/XLM/instead
of 404./oracles/<name>bounces to the/oracleslisting
until per-oracle detail pages exist. CF Pages applies all
five 301s pre-render so they're cheap. -
Common-name 404 redirects on the explorer. The 2026-05-10
audit found several natural URL guesses returned the
static-export 404 catch-all (/pool,/pools,/coin,
/token,/tokens,/price,/prices,/api/,/docs,
/docs/<path>). New 301 redirects send each to its canonical
destination:/pool*→/dexes/,/coin|/token*→
/assets/,/price*→/markets/,/api/→
api.ratesengine.net,/docs*→docs.ratesengine.net
(splat-preserved deep-link). 19 new rules in
web/explorer/public/_redirects. (PR #1232) -
/llms.txtfor explorer — llmstxt.org-spec discovery file
for AI agents indexing the site. Single hand-curated markdown
manifest pointing at the API surface, key endpoints, the
ingest sources, the methodology, the SDK, license + status.
Lives atweb/explorer/public/llms.txtso CF Pages serves it
at the well-known path. Caught from a 404 audit (2026-05-10):
curl-of-/llms.txtreturned 404 while the 404-fallback page
loaded a full bundle just to render a stub. -
/v1/coinsand/v1/coins/{slug}:issuer_scam_reasonfield.
When an asset's issuer G-strkey appears on the curated scam
directory (sourced from stellar.expert, same data the
/v1/issuersfamily already exposes), the field is non-empty.
Closes a security UX gap: previously a user landing on
/assets/{slug}for a known-scam asset saw no warning until the
IssuerPanel completed its async fetch — now the field comes back
on the build-time response and the explorer renders a red banner
above the price block at first paint. Always omitted for native
XLM (no issuer) and for issuers we have no scam record on. -
?source=<name>filter on/v1/diagnostics/cursors—
exact-match filter on the source column. Caught from a r1
audit: the param was being silently ignored, so an operator
asking for?source=ledgerstreamto isolate the live cursor
from the ~50 backfill rows got everything. Composes with
?max_age=(both filters apply). Unknown values return an
empty array (not 400) — predictable for typos vs. brand-new
sources. OpenAPI updated; tests pin the filter shape and the
source+max_age composition. -
pkg/client:VWAP,TWAP,PoolsSDK methods — closes the
remaining gap in the Go SDK's coverage of the v1 surface. New
sharedAggregateQueryshape feeds both VWAP and TWAP (TWAP
silently ignoresOutlierSigma— kept on the shared shape for
ergonomic reuse).Poolscarries aPoolsQuerywith
Source/Base/Quote/Asset filter dimensions and the standard
cursor/limit/order_by pagination shape. New wire types:
VWAPResult,TWAPResult,Pool. Five tests pin happy-path
round-trips, query-param shape, and required-field validation.
Supersedes the stale PR #1124 (whose branch had drifted into
conflict). (PR #1226) -
HSTS on the explorer + status site — both surfaces were
missingStrict-Transport-Security, leaving them vulnerable
to a downgrade-protocol-stripping attack on first visit.
AddedStrict-Transport-Security: max-age=31536000; includeSubDomainstoweb/explorer/public/_headers(both
/*and/embed/*blocks; CF Pages doesn't merge rules) and
createdweb/status/public/_headerswith the same shape +
full CSP / X-Frame-Options / Permissions-Policy parity.
preloadis intentionally omitted until the operator
submits the apex to https://hstspreload.org/ (preload is
irrevocable once browsers ship it; ratchet up in two steps). -
SDK godoc examples for
Healthz,Readyz,Version,
Usage,CreateKey,RevokeKey,Keys
(pkg/client/example_test.go). Round 4 / final round of the
godoc-coverage push. Closes the gap on the auth-flow
(CreateKey/RevokeKey/Keys/Usage) and basic health probes
(Healthz/Readyz/Version) that were the last methods without
runnable examples on pkg.go.dev. SDK now has examples for
every public Client method (26 methods, 27 examples — Pair- Markets each have one).
-
ADR-0026 — Stablecoin → fiat proxy is late-binding
aggregator policy, not eager ingest normalisation
(docs/adr/0026-stablecoin-fiat-proxy-late-binding.md).
Records the implicit-from-the-start policy that a flurry
of API-side fallback PRs (#1217 / #1218 / #1219 / #1224 /
#1225 / #1226) each instantiated. Captures: the
late-binding-vs-eager-rewrite tradeoff (depeg detection,
per-stablecoin signal preservation, reversibility), the
default peg list (USDT/USDC/PYUSD/EUROC/EUROB/MXNe), the
operator runbook for a depeg event (remove the affected
peg fromapi.peg_aliases), and the cross-region
byte-identical contract this introduces (every region
ships the SAME peg list; cross-region monitor verifies
config hash). The policy was previously documented only
in CLAUDE.md "things that will surprise you" + scattered
PR descriptions. -
Three new Prometheus alert rules backing the 2026-05-10 incident
postmortem (#1228 ships the runbook + customer-facing post;
this PR ships the rules that prevent silent recurrence):ratesengine_node_root_disk_full(P1) /_warning(P2) —
ratesengine_timescale_disk_fullonly watched
/var/lib/postgresql(own ZFS dataset, plenty of free space);
the root FS that actually filled wasn't covered. New rule
watchesmountpoint="/".ratesengine_redis_writes_blocked(P1) —
redis_rdb_last_bgsave_status == 0for > 60 s. Catches the
same incident from a different angle: Redis can't snapshot,
refuses every write, aggregator VWAP cache stops refreshing,
/v1/price404s on rewritten/triangulated/proxy pairs.
Both alerts link
redis-write-blocked-disk-full.md(cherry-picked
here so the doc-lint orphan check is satisfied without ordering
dependency between this PR and #1228). (PR #1229) -
Seed
configs/example.tomldocuments Chainlink crypto +
FX feeds + the "must overlap with aggregator pairs" gotcha.
Audit on 2026-05-10 found r1's Chainlink feeds were
configured only for fiat:EUR/GBP/JPY × USD — pairs the
aggregator's default coverage doesn't compute, so the
divergence worker had no overlap to cross-check and
divergence_observationswas silently empty. The seed
config previously documented only the fiat:EUR/fiat:USD
example, leading every fresh operator down the same path.
Now documents the matching crypto-pair feed addresses
(BTC/USD, ETH/USD, LINK/USD) that align with the
aggregator's built-in default — so a stock deployment
populates divergence_observations out of the box once the
operator copies the crypto-feed block. Operator action
needed on r1 to add the crypto feeds (tracked). -
Runbook for the
fx_quoteshypertable / migration 0028 gap.
Captures the 2026-05-10 finding that r1's DB is at migration
0027 (PR #1041's migration 0028 was never applied), so the
forex worker WARN-spamspq: relation "fx_quotes" does not existon every refresh tick and/v1/currencies/EUR.history_1y
/.history_allstay empty (customer-visible regression of
task #104). New runbook at
docs/operations/runbooks/fx-history-missing.mddocuments the
triage + 5-min recovery (scp migration →ratesengine-migrate up→ confirm forex worker resumes → optional 10y backfill via
scripts/ops/fx-history-backfill). Cross-linked from
alerts-catalog + external-poller-stale. Prevention notes
capture the choice between auto-migrate-in-deploy-workflow
vs. gate-readyz-on-schema-version. (PR #1230) -
Runbook + customer-facing incident post for the 2026-05-10
Redis-write-blocked outage — r1's root filesystem reached
100% with 35 GB of stale logs, blocking Redis snapshots,
blocking aggregator VWAP cache writes, and surfacing as
/v1/price404s on rewritten / triangulated / stablecoin-proxy
pairs for ~9 hours. New runbook at
docs/operations/runbooks/redis-write-blocked-disk-full.md
captures triage signals + the 5-minute recovery sequence
(vacuum journal, truncate syslog.1, rm WASM-audit stderr,
trigger Redis BGSAVE). Customer-facing incident post at
internal/incidents/data/2026-05-10-redis-writes-blocked-disk-full.md
is auto-served by/v1/incidents. (PR #1228) -
ADR-0025 — Caddy trusts Cloudflare for client-IP signal via
CIDR-pinned static list (docs/adr/0025-caddy-cloudflare-trusted-proxy.md).
Records the architectural commitment from PR #1239: Caddy's
globalservers { trusted_proxies static <CF CIDRs> }block
pins trust on CF's published IP ranges (refreshed manually
on quarterly audit cadence rather than via the third-party
caddy-cloudflare-ipplugin). R2 / R3 inherit the same
topology when they ship; if we ever expose the API directly
without CF in front, the operator MUST delete the
trusted_proxiesblock on that listener — calling that out
in writing prevents a foot-gun. The ADR README index also
gets caught up — entries for ADR-0020 through ADR-0024
(already-accepted but not previously indexed) added in the
same change. -
/v1/pools?asset=<asset_id>filter — restrict the pools
listing to rows where the asset appears on either side (base
OR quote). Mirrors the same filter shape just shipped on
/v1/markets(#1189). Backs the explorer's/assets/{slug}
Liquidity tab — single API request instead of two parallel
?base=+?quote=fetches with client-side merge. Mutually
exclusive with?base=/?quote=(the OR-shape and AND-shape
filters can't be mixed); combining returns 400
conflicting-filters. Invalid asset_ids return 400
invalid-asset-id. (PR #1190) -
/v1/markets?asset=<asset_id>filter — restrict the markets
listing to pairs where the given canonical asset_id appears on
either side (base OR quote). Mirrors the?base=/?quote=
filters already on/v1/pools. Backs the explorer's
/assets/{slug}Markets tab so long-tail assets that fall
outside the global top-100 by volume now surface their markets
correctly. Mutually exclusive with?source=; combining the
two returns 400conflicting-filters. Invalid asset_ids
return 400invalid-asset-id(silent-empty-page guard, same
family as?source=). (PR #1189) -
pkg/client: SDK coverage gap fully closed. Final batch
after #1122/#1123/#1124. AddsClient.Chart,
Client.Observations,Client.ChangeSummary,Client.Incidents,
Client.SACWrapperswith full wire types (ChartSeries,
ChangeSummary,IncidentsPayload,Incident) and 6 unit
tests. Every endpoint registered ininternal/api/v1/server.go
now has a typed Go-client wrapper. Together the four batches
added 14 methods, 9 wire types, and 23 unit tests. -
pkg/client:Currencies(ctx, opts)+Currency(ctx, ticker)
SDK methods for/v1/currenciesand/v1/currencies/{ticker}.
Mirrors the wire shapes the explorer's/currenciesand
/currencies/{ticker}pages already consume —RateUSDis
"1 USD = N units of this currency" per the server contract,
and*float64pointer fields preserve the "no data" vs "0"
distinction on circulating-supply / market-cap. Detail variant
addsInverseUSD,CrossRatesand a 7-day history strip. -
pkg/client:LendingPools(ctx)SDK method for
GET /v1/lending/pools. Mirrors the wire shape of every Blend
pool observed in the trailing 7d auction stream —LendingPool
struct stays additive (TVL / utilisation / supply+borrow APYs
land in subsequent server releases without needing an SDK
bump, since the JSON decoder ignores unknown fields).
Security
- API binary's
/metricsnow refuses non-loopback callers
(defense-in-depth). PR #1172 added the Caddy block at the
edge, but a probe today foundcurl https://api.ratesengine.net/metrics
still returning 1372 lines of Go runtime + per-source counter
data — the operator hasn't re-applied the Caddyfile yet
(operator action pending). Adds a Go-layer gate
(loopbackOnly) so the binary itself returns 404 to any
RemoteAddr that isn't 127.0.0.0/8 or ::1 — the local
Prometheus scraper still reaches it via 127.0.0.1:3000, but
any reverse proxy that forwards public traffic gets a clean
404. Returns 404 (not 403) deliberately so a scanner can't
confirm the route exists. Six unit tests pin both branches
(3 non-loopback IP families × 404, 3 loopback addrs × 200).
Caddy block stays as the primary protection; this is the
belt-and-braces second layer that catches misconfiguration.
(PR #1207) - Caddy
Caddyfile.apinow 404s/metricsfrom the public
api.ratesengine.nethost. The API binary serves /metrics on
:3000 alongside /v1/* (one ServeMux), and the catch-all
reverse_proxywas forwarding the public hit straight through
— verified live:curl -s https://api.ratesengine.net/metrics
returns 8KB+ of Go runtime stats, request counters, and per-
source ingest gauges that fingerprint the deployment for any
attacker. Local Prometheus scraping uses
127.0.0.1:3000/metricsperprometheus.r1.ymland is
unaffected; status.ratesengine.net is the right surface for
public transparency. Operator action: re-apply via
ansible-playbook configs/ansible/playbooks/r1.yml --tags caddy. Vary: Originnow emitted on every CORS-enabled response in
exact-match mode, not just when the request's Origin matched
the allow list. Pre-fix, a cacheable response served to a
no-Origin request (curl, server-side fetch, monitoring probe)
was cached at the CDN without origin discrimination — a later
browser request whose Origin WOULD have been allowed would
receive that cached "no CORS" response and fail client-side
fetch(). The inverse poisoning vector also closes: a response
cached with one allowed Origin'sAllow-Origin: <a>could
previously be served to a request from a different allowed
Origin<b>, breaking that client too. Wildcard mode is
unaffected (response is origin-independent so Vary would just
defeat caching). Two regression tests pin both branches.ratesengine_api_cache_ops_total{cache,op,result}Prometheus
counter for in-memory cache wrappers
(v1.CachedMarketsReader, …). Result ishit(returned cached
value, including single-flight-wait callers) ormiss(called
upstream). Op breaks down per cached method
(distinct_pairs/source_markets/asset_markets/
all_pools). Motivation: three back-to-back prewarm-key drift
bugs (#1185 / #1194 / #1195) where the prewarm warmed one key
but user requests looked up another; each was invisible to
tests + log-greps and only surfaced from live latency probes
("dex pools take forever"). With this counter an alert on
rate(...{result="miss"}[5m]) / rate(...[5m]) > 0.5sustained
catches the next drift in minutes instead of days. (PR #1196)- Prometheus alert
ratesengine_api_cache_miss_rate_highwired
to the new counter. Fires P2/ticket when miss rate > 50% sustained
10 min on any (cache, op) with ≥ 0.1 req/s traffic. The traffic
floor avoids flapping on quiet caches; the ratio (not absolute)
threshold means a low-volume cache with 100% miss won't page but
a high-volume cache with 50% miss will. Runbook
cache-miss-rate-high.md
walks the operator through diffing prewarm vs handler args
(which is what we did manually for #1185 / #1194 / #1195).
(PR #1197) ratesengine_api_cache_ops_totalextended tocoinsand
sources_statscache wrappers. PR #1196 only instrumented
markets; this fills in the other two so the existing alert
(#1197) catches drift on every cached endpoint, not just the
ones that motivated the original bugs. New op labels:
coins/list_coins,coins/price_history_24h,
coins/price_history_7d,sources_stats/source_stats,
sources_stats/volume_history_24h. (PR #1198)
Documentation
- Clarify the source-count semantic gap between
/v1/network/statsand/v1/status. Both endpoints expose
a field calledtotal_sources, but they measure different
things: network/stats counts entries in the static binary
registry (constant per-build); status counts sources the
operator has enabled at runtime (Prometheus-derived, region-
scoped). On r1 today registry=21, enabled=17, active=15. The
semantic gap is by design — keeping the names in separate
envelopes prevents collision in any single response — but the
contrast was undocumented. Updated docstrings on
internal/api/v1.NetworkStats+ the OpenAPI descriptions on
both endpoints so SDK consumers don't need to spelunk to find
out which one they want.
Performance
- Cacheable read endpoints now emit
public, max-age=60, s-maxage=300instead of falling through to the conservative
private, no-storedefault. Eight surfaces were missing from
thepolicyForPathtable — verified live withcurl -sI:
/v1/coins/{slug},/v1/currencies,/v1/currencies/{ticker},
/v1/chart,/v1/lending/pools,/v1/network/stats,
/v1/sac-wrappers,/v1/incidents,/v1/pools. Each was
bypassing the CDN AND telling the browser not to cache,
multiplying origin load on every page render
(/v1/network/statsand/v1/sac-wrapperseach fire on every
explorer page load). Unblocks Cloudflare's edge from absorbing
the explorer's hot path.
Fixed
-
/v1/statusno longer reportsoverall: okwhen the
metrics backend is unreachable. Caught on r1 2026-05-10:
Prometheus had been dead for 18 h (TSDB corruption from the
preceding day's disk-full SEV-2), every backend query (heartbeats,
latency, freshness, incidents) errored out, and the rollup
logic happily reportedoverall: okbecause the "degrade"
branches all lived inside theerr == nilblocks. With the
metrics pipeline blind, the response was a confident lie.
/v1/statusnow setsoverall: degradedwhenever any
backend query fails. Test added pinning the regression. -
/v1/coins/{slug}now accepts canonical asset_id form
(USDC-GA5Z…) alongside friendly slug (USDC). Pre-fix,
copying a canonical asset_id from any other API surface
(/v1/assets/{id}.asset_id,/v1/markets[].base,
/v1/observations[].base_asset) into/v1/coins/<id>got
404 — inconsistent with/v1/assets/{id}which accepts both.
Confirmed broken on r1 (/v1/coins/AQUA-GBNZILSTV…404'd while
/v1/coins/AQUA200'd against the same row). Fix is a one-line
SQL widening:WHERE COALESCE(slug, code) = $1 OR asset_id = $1
plus an(asset_id = $1) DESCORDER BY tiebreak so a friendly
slug input still wins over a code-only collision (preserving
the #45 scam-token disambiguation guard). Handler adds a
canonical.ParseAssetshort-circuit so the canonical-form
path skips the case-insensitive retry it doesn't need.
(PR #1231) -
/v1/oracle/pricesnow applies the same X/fiat:USD → X/
stablecoin-fiat proxy fallback as/v1/oracle/lastprice
(#1220) and the other X/fiat:USD surfaces. Pre-fix, the SEP-40
prices()passthrough returned 200 with an emptydataarray
for any asset that trades only against classic USDC — same
out-of-the-box failure mode as/v1/oracle/lastpricehad
pre-#1220, just expressed as 200-empty rather than 404. Adds a
sharedrecentClosedWithStablecoinFallbackhelper that walks
the operator's classic USD pegs in priority order; first peg
with non-empty closed buckets wins. Response carries
flags.triangulated=trueso the wire shape is honest about the
derivation. (PR #1224) -
F2 fields on
/v1/assets/{id}(market_cap_usd,fdv_usd,
change_24h_pct) now populate via the same X/fiat:USD →
X/ stablecoin-fiat proxy fallback that #1217 added to
/v1/price. The F2 path'slookupUSDPriceand the binary's
storeChange24hReaderboth bypass the v1 handler's
priceFallback, so even with #1217 deployed every asset on
Stellar mainnet hadmarket_cap_usd / fdv_usd / change_24h_pct
silently null — the steady-state because nothing on-chain ever
quotes in fiat:USD.lookupUSDPricenow calls the existing
tryStablecoinFiatProxyhelper on miss;storeChange24hReader
walks the operator's[trades].usd_pegged_classic_assetsfor
the at-or-before lookup. One new test
(TestLookupUSDPrice_StablecoinFiatProxyFallback) pins the
/v1/assets path; existing TestChange24hPct tests still pass.
(PR #1223) -
Explorer
/exchanges/<venue>chart now distinguishes "API
outage" from "no pairs reporting". The pair-list fetcher's
.catch(() => setPairsLoading(false))swallowed every error,
so a 5xx on/v1/markets?source=<venue>rendered the same
"No pairs reporting in the last 14 days" empty-state as a
genuinely-empty venue. Now captures the error message into
pairsErrorstate and surfaces it as a red "Couldn't load
pairs for this venue (HTTP 503). Refresh to retry, or check
status.ratesengine.net" panel — operators investigating a
user-reported "exchange page is broken" can now distinguish
data gap from infra gap at a glance. Same silent-drop family
as the home-page fixes shipped in #1251. (PR #1254) -
Kraken dust trades now use the typed
ErrDustTradesentinel
— extends the #814 / #1234 pattern (Coinbase / Binance /
Bitstamp) to Kraken. Before this PR the liveparse.gopath
had NO dust check at all — a sub-precision-floor live trade
would have produced a Trade with quote=0, the canonical
validator would reject on insert, and the indexer would
log "insert trade failed" at ERROR per frame (the same
pattern that flooded r1 logs for Bitstamp until #1234).
Backfill already had a check but used a generic
fmt.Errorf("zero quote")rather than the typed sentinel
the consumers explicitly understand. Kraken isn't enabled on
r1 today (see[external.kraken].enabledin r1's TOML) so
this is a latent-bug fix — closing it now means flipping
Kraken on later doesn't surprise the operator with a fresh
ERROR storm. -
CoinGecko divergence reference now has a built-in default
IDMap matching the aggregator's default coverage —
internal/divergence/coingecko.go. Caught from r1 on
2026-05-10: the type-level docs claimed "empty IDMap falls
back to a built-in default covering XLM + major stables"
but the constructor copied opts.IDMap as-is with no
fallback. Result: every operator without an explicit
[divergence.coingecko].id_mapgotasset_unsupported
failures for every divergence cross-check call —
divergence_observationssilently empty,flags.divergence_warning
always false, the Compare-layer "ok" counter incremented
while no actual cross-check happened (the aggregator's
refresh metric showed 23,889 "ok" outcomes on r1 with zero
rows in the durable mirror). Default IDMap now covers the
canonical asset_id forms the aggregator computes by default
(crypto:XLM/native/crypto:BTC/crypto:ETH/
crypto:LINK/crypto:SOL/crypto:ADA/crypto:DOT)
plus major USD stablecoins (USDC / USDT / PYUSD) for
cross-checks against the underlying X/USDC or X/USDT path
enabled by ADR-0026. Operator entries merge OVER the
defaults so anyone who relied on the pre-fix behaviour can
still narrow the set. -
Caddy now resolves the real client IP from Cloudflare —
configs/caddy/Caddyfile.api. The previous config rewrote
X-Forwarded-Forto{remote_host}(the immediate TCP peer,
i.e. a CF edge POP), so every API request looked like it came
from a Cloudflare IP. Per-IP rate-limit buckets became
per-CF-edge buckets — a single CF edge hitting the burst
threshold blocked every customer behind it. Access logs were
similarly useless (everyremote_ipwas a 162.158.x.x or
104.22.x.x CF edge, never the actual customer). Fix: add a
globalservers { trusted_proxies static <CF CIDRs>; client_ip_headers CF-Connecting-IP X-Forwarded-For }block
and forward{client_ip}instead of{remote_host}from the
reverse_proxydirective. Trust is CIDR-pinned to CF's
published ranges so an attacker hitting the box's IP directly
can't spoofCF-Connecting-IP. README documents the
CIDR-refresh cadence. -
CoinGecko poller now grows the cooldown exponentially even
when the venue'sRetry-Afteris short — pre-fix the
Retry-After branch took the hint at face value (clamped to
MinBackoff = 60s) and bypassed the doubling. CoinGecko's
free tier returns Retry-After consistently belowMinBackoff
(≈30s), so clamping landed the cooldown at exactly 60s
forever. The runner's PollInterval is also 60s, so each
recovery attempt produced another 429 → another 60s cooldown
→ indefinite throttling at one 429-per-minute. Observed live
on r1 2026-05-09 → 2026-05-10. Post-fix,applyBackofftreats
Retry-After as a FLOOR — cooldown ismax(hint, currentBackoff×2, MinBackoff)clamped toMaxBackoff— so
consecutive 429s grow exponentially regardless of what the
venue claims you can retry after. Two new tests pin both shapes.
(PR #1227) -
Bitstamp dust trades silently dropped instead of being
logged as ERROR on every frame. Tiny lots (e.g. 1e-8 XLM at
$0.16) computebase × price ÷ 10^8 = 0under our integer-scale
precision floor; the canonical validator was rejecting them
withquote_amount must be positive, got 0and the indexer
was emitting "insert trade failed" at ERROR-per-frame.
Following #814's Coinbase + Binance pattern: typed
ErrDustTradesentinel fromparseTradeand
bitstampCandleToTrade; the existing streamer / backfill
error-skip branch absorbs it. Caught from r1 production logs
on 2026-05-10 — XLMUSD trades flooding the indexer ERROR log. -
/v1/ohlcnow applies the same X/fiat:USD → X/ stablecoin
fallback as/v1/price(#1217),/v1/chart(#1015), and the
vwap+twap pair (#1219). Pre-fix,/v1/ohlc?base=native"e=fiat:USD
404'd "no trades in window" out-of-the-box on every fresh
deployment. Freighter RFP §3 names/v1/ohlcas a launch-blocker
for the asset-detail surface, so this gap was visible to every
asset detail page request. NewohlcTradesWithStablecoinFallback
helper walks the operator's classic USD pegs in priority order;
first peg with non-empty trades wins. Response carries
flags.triangulated=true. (PR #1225) -
/v1/vwapand/v1/twapnow apply the same X/fiat:USD →
X/ stablecoin-fiat proxy fallback as/v1/price(#1217)
and/v1/chart(#1015). Pre-fix,/v1/vwap?base=native"e=fiat:USD
and/v1/twap?base=native"e=fiat:USDboth 404'd "no trades
in window" out-of-the-box because no on-chain trades quote in
fiat:USD on Stellar. New helpertradesInRangeWithStablecoinFallback
retries against each operator-declared classic USD peg in priority
order; first non-empty result wins. Response carries
flags.triangulated=trueso wire shape is honest about the
derivation. Same opt-in shape (empty allow-list still 404s);
non-USD fiat quotes skip the fallback. (PR #1219) -
/v1/oracle/lastpriceand/v1/oracle/x_last_priceget the
same X/fiat:USD → X/ stablecoin-fiat proxy fallback as
/v1/price(#1217). Pre-fix, the SEP-40 passthrough surface
inherited the same out-of-the-box 404 mode: an on-chain
integrator drop-in-replacinglastprice(native)against XLM
got 404 even though/v1/coins/nativeshowed $0.16 cleanly.
Same intent as #1217 — keep the SEP-40 surface and the
closed-bucket surface consistent in coverage so an integrator
switching between them sees the same set of "available" pairs.
Two new tests pin the lastprice + x_last_price branches.
(PR #1220) -
Default Chainlink feed map covers BTC/ETH/LINK + EUR/GBP/JPY
vs USD so divergence cross-checks work out-of-the-box on a
stock config. Same shape as the CoinGecko default-IDMap fix in
#1249: the type-level docs implied the operator could deploy
with no[divergence.chainlink].feed_mapand still get
Chainlink cross-checks on the major pairs the aggregator
computes by default, butNewChainlinkReferencewas copying
opts.FeedMapas-is with no fallback — every lookup returned
asset_unsupported. Defaults pin the immutable Ethereum
mainnet AggregatorV3 proxy addresses; operator-supplied entries
merge OVER the defaults so an operator can still narrow the
set, override an address, or flip an Invert flag. XLM/USD,
USDC/USD, USDT/USD remain absent — Chainlink does not publish
these on Ethereum mainnet at audit time. Closes the code-side
half of operator action #119; the operator no longer needs to
hand-paste contract addresses intor1.tomlto unblock
Chainlink cross-checks on the default pair set. (PR #1255) -
Nil-pointer panic in markets/coins single-flight cache —
caught on r1 production (2026-05-10 15:36 UTC, GET/v1/markets).
When the leader's upstream call failed under single-flight,
fetchPairs/fetchPools(and the equivalents in
coins_cache.go) deleted the entry from the map BEFORE closing
the flight chan. Waiters then woke, re-readc.entries[key],
gotnil, and panicked dereferencingout.pairs. Fix:
waiters now hold a pointer to the same entry they joined on
(so the leader'sdeletecan't erase what they read) and the
leader stashes its err on that entry pre-close. Regression
test added under-race. Untested error-path race still
caches the err for in-flight waiters but doesn't TTL-cache it
for new callers — same semantics as before, minus the panic. -
/v1/price/tip?asset=X"e=fiat:USDgets the same
stablecoin-fiat proxy fallback as/v1/price(#1217). Tip
was 404'ing on the same shape —tipWindowVWAP → PriceReader.LatestPrice → tryRedisVWAPFallback → tryFiatCrossRate
with no peg-rewrite branch — so a customer reading the
fastest-feed XLM/USD price endpoint got the same out-of-the-box
404 as/v1/price. Now slotstryStablecoinFiatProxybetween
the Redis cache layer and the fiat-cross-rate fallback in
computeTip. Same opt-in shape (empty allow-list still 404s).
(PR #1218) -
/v1/price?asset=X"e=fiat:USDnow serves via classic-USDC
peg fallback at handler read time, mirroring the/v1/chart
fallback shipped in #1015 (task #98). Same root cause: the
aggregator's[aggregate].enable_stablecoin_fiat_proxyis off
by default, so the literal X/fiat:USD pair never has rows in
prices_1m(no on-chain trades quote in fiat:USD on Stellar).
Pre-fix, the canonical XLM/USD price endpoint —
/v1/price?asset=native"e=fiat:USD— returned 404 "no
trades or oracle observations" out-of-the-box on every fresh
deployment, even though the aggregator had a perfectly good
native/USDC-classicVWAP cached. The new
tryStablecoinFiatProxyfallback walks the operator's
[trades].usd_pegged_classic_assetsallow-list in priority
order and rewrites the lookup; first peg with a row wins.
Response carriesflags.triangulated=trueand the wirequote
field echoes the user's request (fiat:USD), not the proxy
peg. Opt-in shape preserved — empty allow-list still 404s.
(PR #1217) -
Indexer now WARNs at boot when
[supply]watched-sets are all
empty, instead of silently registering zero supply observers.
This was the silent-failure mode behind r1's
asset_supply_historysitting at 0 rows for 6+ days post-deploy
— the operator hadn't populated the watched-sets yet (ops task
#97), but the indexer logged nothing about it. F2 fields
(market_cap_usd,fdv_usd,circulating_supply,
total_supply,max_supply) on/v1/assets/{id}stayed null
for every asset, with no signal until someone manually queried
the empty table. The new WARN names the missing config keys
(sdf_reserve_accountsfor Algorithm 1 XLM,
watched_classic_assetsfor Algorithm 2,
watched_sep41_contractsfor Algorithm 3) and explicitly states
the consequence — so the next operator who tails the indexer log
sees the problem in the first 30 seconds. (PR #1216) -
/v1/coins?limit=200prewarm now matches the handler's
internallistingLimit. Same family as #1194. The handler
subtracts 1 from the requested limit when cursor/issuer/q are
all empty (theprependNativepath that splices a synthetic
XLM row at the top of page 1 without overshooting), so a
/v1/coins?limit=200user request actually queries the store
withListCoinsOptions{Limit: 199, …}. The prewarm passed
Limit: 200so its cache key (ListCoinsExt|200|…) never
matched the handler's lookup key (ListCoinsExt|199|…). The
explorer's/currenciespage (the most-trafficked coins read)
was hitting cold cache on every load. (PR #1195) -
Unfiltered
/v1/poolsprewarm now matches the handler's
cache key. Follow-up to PR #1185, which fixed the
MarketsOrdermismatch but missed a second mismatch in the
Sources dimension. The handler builds
PoolsFilter{Sources: v1.DexSourceNames()}for unfiltered
requests; the prewarm passedPoolsFilter{}(Sources: nil).
Cache key usesfmt.Sprintf("%v", filter.Sources)so
nil→[]while the handler's slice →[aquarius comet phoenix sdex soroswap]. Different strings, different keys —
the unfiltered prewarm warmed a slot no user request ever
hits. Exportedv1.DexSourceNamesso the prewarm can call
the same source-of-truth as the handler. Per-DEX prewarm
(introduced in #1185) was unaffected — its[]string{src}
matches the handler's filtered single-element slice. (PR #1194) -
/v1/coinschange_{1h,24h,7d}_pctfor XLM-triangulated assets
now reflects USD change, not XLM-denominated change. Pre-fix
USDC and PYUSD showed-3.37%and-3.27%24h change live on
r1 — they're stablecoins and never depegged. Thevs_xlm
fallback computedvs_xlm.vwap / vs_xlm_24h.vwap(raw XLM
ratio change) whileprice_usdcorrectly used
vs_xlm.vwap × xlm_usd.vwap(triangulated USD price). For
USD-stable assets these read inversely: USDC at $1 stays at $1
even when XLM moves 3% against it. Multiply both sides of the
change ratio by their respectivexlm_usd_*factor so the
change consistently measures the same triangulated USD price
the row already displays. Same fix applied acrosslistCoins
andGetCoinBySlugqueries. -
Explorer home page now emits
<link rel="canonical">.
Detail pages picked it up via #1094/#1095/#1097 but the root
/was left without one, so search engines were free to treat
https://ratesengine.net/,https://ratesengine.net(no
trailing slash), andhttps://ratesengine.net/index.htmlas
separate pages and split link equity between them. Default
alternates.canonical: '/'on the root layout fixes that;
detail pages still override per-route in their own
generateMetadata. -
/v1/oracle/latest?source=<unknown>now returns 400
unknown-sourceinstead of an empty 200 list. Same fail-fast
validation pattern shipped on /v1/markets (#1162) and
/v1/observations (#1164) — typo'd source names looked
identical on the wire to "this source has no observation for
the asset", masking input errors as data gaps. -
Sitemap URLs now match the canonical trailing-slash form the
explorer actually serves. WithtrailingSlash: truein
next.config.js, every non-trailing-slash URL 308-redirects to
its trailing-slash form — but every URL the sitemap emitted
was bare (/account,/issuers/G...,/markets/X~Y), so every
sitemap entry sent crawlers through a 308 hop before reaching
the real page. Google penalises sitemaps that contain redirect
targets. NewsiteURL()helper appends the trailing slash;
verified live: every URL in the current sitemap returns 308,
post-fix they all return 200 directly. -
<link rel="canonical">on every top-level explorer page.
Audit showed 14 pages —/diagnostics,/methodology,/sdk,
/contact,/widgets,/changelog,/aggregators,/oracles,
/networks,/anomalies,/mev,/pricing,/company,
/careers— all hadmetadata.title+descriptionset but no
alternates.canonical. Search engines were free to treat the
trailing-slash variant, the no-trailing-slash variant, the
index.htmlform, and any?ref=…referral-tag form as
separate URLs and split link equity. Each page now declares its
own canonical alongside the existing meta. Companion to #1167
(home page) and the per-detail-page canonicals from #1094-1097. -
/v1/incidents.atomsummary truncation is now UTF-8-safe.
summaryFromMarkdowndidp[:397] + "..."— a naive byte
slice that could split a multi-byte UTF-8 codepoint in half
for any incident post containing accented characters
(é/ñ/ü/…) or emoji. Verified live: a 396-byte ASCII prefixéée trailingproduces an output where the last byte is
\xC3(the lead byte ofé) without its trailing byte —
invalid UTF-8. Strict feed validators reject the entry; the
explorer's render shows a replacement character. Walk back
to the nearest rune-start byte at or before 397; tests cover
both 2-byte (Latin-1 supplement) and 4-byte (emoji) cases.
-
/v1/incidentsno longer returnsincidents: nullwhen the
embedded corpus is empty. A fresh deployment (or one where
incidents.Loaderrored at startup) lefts.incidents == nil,
which marshalled as"incidents":nulland broke the
pkg/client SDK + explorer JS that.map()over the array.
Caught while writing the handler's first regression tests. -
Asset detail "Markets" tab fetches 100 by volume, not 500
alphabetically.MarketsTabPanelon/assets/{slug}was
callinguseMarkets(500)(defaultpairorder) then
client-side filtering to markets involving the asset. Cold-
cache hit a 5–8s SQL scan (limit=500 isn't in the prewarm set
of 5/25/100/200), and the alphabetical sort meant the cap
could miss popular markets. Switched to
useMarkets(100, 'volume_24h_usd_desc')— hits warm cache,
~5× smaller payload, and surfaces the asset's top-100-by-volume
markets first. Long-tail assets outside the global top-100 by
volume need a server-side?asset=filter on /v1/markets
(only /v1/pools has it today) — tracked as follow-up. -
Home page no longer fetches 500 markets to render 10.
HomeTopMarketscalleduseMarkets(500, …)then immediately
.slice(0, 10)— sending and parsing 490 rows the user never
sees, and missing the API's prewarmed cache key (the prewarm
covers limits 5/25/100/200, not 500). Cold-cache home loads paid
the full/v1/markets?limit=500SQL scan against the trades
hypertable. Trimmed touseMarkets(25, …)— same top-10 with
headroom, hits the warm cache, ~20× smaller payload. Same
pattern previously fixed inHomeNetworkStrip(PR-comment
history). Also corrected the misleadinglimit=500asExample
hints on/marketsMarketsTable — the actual fetch is
limit=100with the user's chosen order_by + sparkline.
(PR #1187)