Skip to content

Rates Engine v0.5.0-rc.39

Pre-release
Pre-release

Choose a tag to compare

@github-actions github-actions released this 10 May 20:47
· 1065 commits to main since this release
33ef6f5

[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/XLM and
    /v1/changes/coin/native both 404'd despite the worker having
    written rows). Handler now expands the input into the same
    candidate set oracleAssetCandidates uses 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 in changes_test.go.
  • /widgets showcase 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'
    generateStaticParams output, 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/observations 8s 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&quote=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+json with type=observations-timeout,
    matching the rest of the family.
  • Auth-failure problem+json type URL spelling unified. The
    middleware-level 401 (no-auth-at-all) and the account-handler
    401 (auth-needed-but-rejected) had drifted to two different
    type URLs — 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 5 account.go call sites updated. No tests pinned the
    British form so no test churn.
  • Explorer home: HomeCurrencies and HomeTopMarkets now
    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.
  • 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: a LedgerTransactionReader.Read failure
    silently dropped the tx and the only signal was a downstream
    price gap days later. Now bumps a Stats().TxReadErrors
    counter that the statsflush periodic snapshot surfaces at
    WARN whenever the delta in a flush window > 0. Same pattern
    as the existing decodeErrors per-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::flushObservations discarded
    the RecordObservation error 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.
    ServiceOptions now takes an optional *slog.Logger; when
    set, sink failures log per (pair, reference) at WARN. The
    Redis cache write (load-bearing for flags.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 after Price / 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 /assets and /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.
  • Explorer redirects for /incidents, /converter,
    /oracles/<name>
    (404-audit follow-up, 2026-05-10).
    /incidents and /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 /convert now land on /convert/USD/XLM/ instead
    of 404. /oracles/<name> bounces to the /oracles listing
    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.txt for 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 at web/explorer/public/llms.txt so CF Pages serves it
    at the well-known path. Caught from a 404 audit (2026-05-10):
    curl-of-/llms.txt returned 404 while the 404-fallback page
    loaded a full bundle just to render a stub.

  • /v1/coins and /v1/coins/{slug}: issuer_scam_reason field.
    When an asset's issuer G-strkey appears on the curated scam
    directory (sourced from stellar.expert, same data the
    /v1/issuers family 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=ledgerstream to 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, Pools SDK methods — closes the
    remaining gap in the Go SDK's coverage of the v1 surface. New
    shared AggregateQuery shape feeds both VWAP and TWAP (TWAP
    silently ignores OutlierSigma — kept on the shared shape for
    ergonomic reuse). Pools carries a PoolsQuery with
    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
    missing Strict-Transport-Security, leaving them vulnerable
    to a downgrade-protocol-stripping attack on first visit.
    Added Strict-Transport-Security: max-age=31536000; includeSubDomains to web/explorer/public/_headers (both
    /* and /embed/* blocks; CF Pages doesn't merge rules) and
    created web/status/public/_headers with the same shape +
    full CSP / X-Frame-Options / Permissions-Policy parity.
    preload is 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 from api.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_full only watched
      /var/lib/postgresql (own ZFS dataset, plenty of free space);
      the root FS that actually filled wasn't covered. New rule
      watches mountpoint="/".
    • ratesengine_redis_writes_blocked (P1) —
      redis_rdb_last_bgsave_status == 0 for > 60 s. Catches the
      same incident from a different angle: Redis can't snapshot,
      refuses every write, aggregator VWAP cache stops refreshing,
      /v1/price 404s 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.toml documents 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_observations was 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_quotes hypertable / 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-spams pq: relation "fx_quotes" does not exist on every refresh tick and /v1/currencies/EUR.history_1y
    / .history_all stay empty (customer-visible regression of
    task #104). New runbook at
    docs/operations/runbooks/fx-history-missing.md documents 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/price 404s 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
    global servers { 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-ip plugin). 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_proxies block 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 400 conflicting-filters. Invalid asset_ids
    return 400 invalid-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. Adds Client.Chart,
    Client.Observations, Client.ChangeSummary, Client.Incidents,
    Client.SACWrappers with full wire types (ChartSeries,
    ChangeSummary, IncidentsPayload, Incident) and 6 unit
    tests. Every endpoint registered in internal/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/currencies and /v1/currencies/{ticker}.
    Mirrors the wire shapes the explorer's /currencies and
    /currencies/{ticker} pages already consume — RateUSD is
    "1 USD = N units of this currency" per the server contract,
    and *float64 pointer fields preserve the "no data" vs "0"
    distinction on circulating-supply / market-cap. Detail variant
    adds InverseUSD, CrossRates and 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 /metrics now refuses non-loopback callers
    (defense-in-depth)
    . PR #1172 added the Caddy block at the
    edge, but a probe today found curl 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.api now 404s /metrics from the public
    api.ratesengine.net host
    . The API binary serves /metrics on
    :3000 alongside /v1/* (one ServeMux), and the catch-all
    reverse_proxy was 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/metrics per prometheus.r1.yml and 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: Origin now 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's Allow-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 is hit (returned cached
    value, including single-flight-wait callers) or miss (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.5 sustained
    catches the next drift in minutes instead of days. (PR #1196)
  • Prometheus alert ratesengine_api_cache_miss_rate_high wired
    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_total extended to coins and
    sources_stats cache 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/stats and /v1/status
    . Both endpoints expose
    a field called total_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=300 instead of falling through to the conservative
    private, no-store default. Eight surfaces were missing from
    the policyForPath table — verified live with curl -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/stats and /v1/sac-wrappers each fire on every
    explorer page load). Unblocks Cloudflare's edge from absorbing
    the explorer's hot path.

Fixed

  • /v1/status no longer reports overall: ok when 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 reported overall: ok because the "degrade"
    branches all lived inside the err == nil blocks. With the
    metrics pipeline blind, the response was a confident lie.
    /v1/status now sets overall: degraded whenever 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/AQUA 200'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) DESC ORDER 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.ParseAsset short-circuit so the canonical-form
    path skips the case-insensitive retry it doesn't need.
    (PR #1231)

  • /v1/oracle/prices now 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 empty data array
    for any asset that trades only against classic USDC — same
    out-of-the-box failure mode as /v1/oracle/lastprice had
    pre-#1220, just expressed as 200-empty rather than 404. Adds a
    shared recentClosedWithStablecoinFallback helper that walks
    the operator's classic USD pegs in priority order; first peg
    with non-empty closed buckets wins. Response carries
    flags.triangulated=true so 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's lookupUSDPrice and the binary's
    storeChange24hReader both bypass the v1 handler's
    priceFallback, so even with #1217 deployed every asset on
    Stellar mainnet had market_cap_usd / fdv_usd / change_24h_pct
    silently null — the steady-state because nothing on-chain ever
    quotes in fiat:USD. lookupUSDPrice now calls the existing
    tryStablecoinFiatProxy helper on miss; storeChange24hReader
    walks the operator's [trades].usd_pegged_classic_assets for
    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
    pairsError state 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 ErrDustTrade sentinel
    — extends the #814 / #1234 pattern (Coinbase / Binance /
    Bitstamp) to Kraken. Before this PR the live parse.go path
    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].enabled in 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_map got asset_unsupported
    failures for every divergence cross-check call —
    divergence_observations silently 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-For to {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 (every remote_ip was a 162.158.x.x or
    104.22.x.x CF edge, never the actual customer). Fix: add a
    global servers { 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_proxy directive. Trust is CIDR-pinned to CF's
    published ranges so an attacker hitting the box's IP directly
    can't spoof CF-Connecting-IP. README documents the
    CIDR-refresh cadence.

  • CoinGecko poller now grows the cooldown exponentially even
    when the venue's Retry-After is 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 below MinBackoff
    (≈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, applyBackoff treats
    Retry-After as a FLOOR — cooldown is max(hint, currentBackoff×2, MinBackoff) clamped to MaxBackoff — 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) compute base × price ÷ 10^8 = 0 under our integer-scale
    precision floor; the canonical validator was rejecting them
    with quote_amount must be positive, got 0 and the indexer
    was emitting "insert trade failed" at ERROR-per-frame.
    Following #814's Coinbase + Binance pattern: typed
    ErrDustTrade sentinel from parseTrade and
    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/ohlc now 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&quote=fiat:USD
    404'd "no trades in window" out-of-the-box on every fresh
    deployment. Freighter RFP §3 names /v1/ohlc as a launch-blocker
    for the asset-detail surface, so this gap was visible to every
    asset detail page request. New ohlcTradesWithStablecoinFallback
    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/vwap and /v1/twap now 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&quote=fiat:USD
    and /v1/twap?base=native&quote=fiat:USD both 404'd "no trades
    in window" out-of-the-box because no on-chain trades quote in
    fiat:USD on Stellar. New helper tradesInRangeWithStablecoinFallback
    retries against each operator-declared classic USD peg in priority
    order; first non-empty result wins. Response carries
    flags.triangulated=true so 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/lastprice and /v1/oracle/x_last_price get 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-replacing lastprice(native) against XLM
    got 404 even though /v1/coins/native showed $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_map and still get
    Chainlink cross-checks on the major pairs the aggregator
    computes by default, but NewChainlinkReference was copying
    opts.FeedMap as-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 into r1.toml to 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-read c.entries[key],
    got nil, and panicked dereferencing out.pairs. Fix:
    waiters now hold a pointer to the same entry they joined on
    (so the leader's delete can'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&quote=fiat:USD gets 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 slots tryStablecoinFiatProxy between
    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&quote=fiat:USD now 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_proxy is 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&quote=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-classic VWAP cached. The new
    tryStablecoinFiatProxy fallback walks the operator's
    [trades].usd_pegged_classic_assets allow-list in priority
    order and rewrites the lookup; first peg with a row wins.
    Response carries flags.triangulated=true and the wire quote
    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_history sitting 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_accounts for Algorithm 1 XLM,
    watched_classic_assets for Algorithm 2,
    watched_sep41_contracts for 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=200 prewarm now matches the handler's
    internal listingLimit
    . Same family as #1194. The handler
    subtracts 1 from the requested limit when cursor/issuer/q are
    all empty (the prependNative path that splices a synthetic
    XLM row at the top of page 1 without overshooting), so a
    /v1/coins?limit=200 user request actually queries the store
    with ListCoinsOptions{Limit: 199, …}. The prewarm passed
    Limit: 200 so its cache key (ListCoinsExt|200|…) never
    matched the handler's lookup key (ListCoinsExt|199|…). The
    explorer's /currencies page (the most-trafficked coins read)
    was hitting cold cache on every load. (PR #1195)

  • Unfiltered /v1/pools prewarm now matches the handler's
    cache key
    . Follow-up to PR #1185, which fixed the
    MarketsOrder mismatch but missed a second mismatch in the
    Sources dimension. The handler builds
    PoolsFilter{Sources: v1.DexSourceNames()} for unfiltered
    requests; the prewarm passed PoolsFilter{} (Sources: nil).
    Cache key uses fmt.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. Exported v1.DexSourceNames so 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/coins change_{1h,24h,7d}_pct for 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. The vs_xlm
    fallback computed vs_xlm.vwap / vs_xlm_24h.vwap (raw XLM
    ratio change) while price_usd correctly 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 respective xlm_usd_* factor so the
    change consistently measures the same triangulated USD price
    the row already displays. Same fix applied across listCoins
    and GetCoinBySlug queries.

  • 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), and https://ratesengine.net/index.html as
    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-source instead 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
    . With trailingSlash: true in
    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. New siteURL() 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 had metadata.title + description set but no
    alternates.canonical. Search engines were free to treat the
    trailing-slash variant, the no-trailing-slash variant, the
    index.html form, 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.atom summary truncation is now UTF-8-safe.
    summaryFromMarkdown did p[: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 trailing produces 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/incidents no longer returns incidents: null when the
    embedded corpus is empty
    . A fresh deployment (or one where
    incidents.Load errored at startup) left s.incidents == nil,
    which marshalled as "incidents":null and 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
    . MarketsTabPanel on /assets/{slug} was
    calling useMarkets(500) (default pair order) 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.
    HomeTopMarkets called useMarkets(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=500 SQL scan against the trades
    hypertable. Trimmed to useMarkets(25, …) — same top-10 with
    headroom, hits the warm cache, ~20× smaller payload. Same
    pattern previously fixed in HomeNetworkStrip (PR-comment
    history). Also corrected the misleading limit=500 asExample
    hints on /markets MarketsTable — the actual fetch is
    limit=100 with the user's chosen order_by + sparkline.
    (PR #1187)