Added
- Testnet / Futurenet support — a one-line `stellar.network` switch. The
indexer now runs correctly (without corrupting data) against Stellar
testnet and futurenet. Grounded in a cold adversarial hardcode audit +
an independent fix-verifier pass (2026-08-26). Pubnet behaviour is
byte-identical (every new default resolves to the old constant).
- Config:
stellar.soroban_genesis_ledger / stellar.movements_floor_ledger
(pubnet values, or genesis=1 on test nets) so the SEP-41 supply and
CAP-67 real-time movements feeds don't floor above the whole chain;
timescale.MovementsFloor() + canonical.NetworkPassphrase() install
seams resolve leaf-package reads to the configured network.
- Corruption guards: SacContractID is network-aware (was serving the
pubnet contract address on testnet /v1/assets); config validation
rejects a pubnet (core-live) history_archive_url on a non-pubnet
network; the cross-anchor archive filler refuses to write pubnet
ledgers into a test-net archive; the SEP-41 supply genesis seed
defaults its boundary from the config's network value.
- Ansible: the archival-node role is network-aware (single-source
stellar_passphrase — fixes the futurenet core.cfg bug — per-network
history archive, boundary knobs, cap67 -floor-ledger); testnet +
futurenet inventory templates.
- CI/CD: deploy.yml gains testnet / futurenet targets; a fleet-model
design proposal (docs/operations/cicd-fleet-model.md).
- Docs: testnet/futurenet deployment guide + reset runbook.
Changed
- Real-time movement latency cadence tuning (~4s → ~2s). An adversarial
audit of a proposed captive-core "fast lane" found the live-movement latency
is a chain of hardcoded cadence constants, not a compute floor — so no new
component/second core is needed. Tuned the safe ones: the indexer's caught-up
MinIO re-check (
liveTailRetryWait) 3s → 500ms (the single largest term — a
caught-up indexer sat a flat ~3s behind the tip; MinIO is local, so a
re-check is a cheap bucket LIST); the /v1/ledger/stream poll that drives the
explorer's "watch it land" refetch 2s → 500ms; and the cap67 movements derive
tick (FOLLOW_INTERVAL) 2s → 1s. The ClickHouse-part-sensitive LiveSink flush
is deliberately left at 1s (sub-second flushing multiplies small parts on the
capacity-bound store). An event-driven MinIO-bucket-notification ingest (still
a single captive-core) is the documented next step toward ~100ms.
Added
- Real-time account-movements follow daemon (5.3). The CAP-67 movements
derive (
stellar.account_movements — the money trail served on
/v1/accounts/{g}/movements) now runs as a continuous follow daemon
(ch-cap67-movements -follow) instead of a ~30s timer + oneshot: it catches
up to the CONTIGUOUS lake tip, sleeps a short interval (2s), and repeats —
cutting movement latency from ~30s to ~2s behind the chain tip so a user
watches their transactions land in near real time. Builds on the contiguity
gate (#174, Cap67Range) so it never derives past a near-tip lake hole; the
timer is retired (single writer, no watermark race); Restart=always +
StartLimitBurst make a crash-loop trip to failed (visible to
node-healthcheck.sh, which now covers the daemon); a transient ClickHouse
error holds the watermark and retries (no ledger skipped). The initial
P23→tip backfill runs as the daemon's first catch-up. Operator cutover: apply
the ansible (or manually stop+disable cap67-movements.timer and start the
-follow daemon) AFTER the ops binary is deployed.
Added
- Soroban resource metering on `stellar.transactions`. Nine additive
DEFAULT 0 columns capture, per Soroban transaction, the DECLARED resource
bid (instruction count, disk-read / write bytes, read/write footprint entry
counts, total resource-fee bid) decoded from the tx envelope's
SorobanTransactionData, plus the ACTUAL charged fees (non-refundable,
refundable, rent) from the tx meta's SorobanTransactionMetaExtV1. Both are
decoded at ingest from the LedgerCloseMeta the indexer already holds; the
decoder is envelope-type-aware (unwraps a fee-bump to its inner tx — a naive
access nil-panics) and meta-version-aware (V3 + p27 V4). No
actual-instructions value is stored — pubnet ledger meta carries none (it
lives only in diagnostic-event core_metrics the lake does not store).
Populated go-forward; the sparse Soroban-only columns compress to
near-nothing. Requires the additive transactions_soroban_metering.sql
migration applied BEFORE the indexer binary (else the tx INSERT halts
ingest). Follow-up exposes the columns on GET /v1/tx.
Fixed
- `stellarindex_aggregator_outlier_storm` alert rescoped from a
self-poisoning relative-spike comparator (
>5× a [1h] offset 1h baseline —
a sustained storm's own drops entered that baseline window and flipped the
ratio false at ~72m, so the alert could never fire on the very storm it
exists to catch, and it ticketed on every benign single-pair robust-VWAP
trimming burst) to an absolute per-pair sustained gate
(sum by (pair) rate[10m] > 10 for 2h). Silent on transient dispersion,
fires on a persistent dispersion / broken-connector storm. - Explorer static export now rides out a transient 502/503/504 from the
API (typically the API mid-deploy) with the same patient, Retry-After-aware,
bounded wait
buildFetch already used for 429, instead of failing the whole
next build on one asset's momentary unavailability.
Fixed
- The
trades.signer sweeper (v0.44.4) failed every tick with tuple
decompression limit exceeded (SQLSTATE 53400) and tagged nothing: its
TagTradesSigner UPDATE joined on (ledger, tx_hash) with no ts
predicate, so on the ts-partitioned trades hypertable it scanned every
chunk — including compressed ones — and tripped the per-DML decompression
limit. Added the ts bound (the close-time span of the tagged txs, threaded
from the lake read) so TimescaleDB prunes to the window's chunks, mirroring
TagTradesRoutedVia. Same fix applies to the tag-signer backfill.
Tested against Stellar protocol 22.
Added
- AMM/Soroban swap actor attribution (`trades.signer`). The AMM decoders
(comet/soroswap/aquarius/phoenix) set
taker to the on-chain caller and
leave maker empty, so a router- or contract-driven swap had no
human/EOA attribution — the taker is the router contract. The tx source
account is that missing initiator, but it is NOT re-derivable from the lake
events the projector replays (they carry no source account), so it cannot
be set on the decode path. New nullable trades.signer column (migration
0150, mirroring routed_via: O(1) ADD COLUMN, deliberately kept out of the
trades UPSERT so a re-derive cannot clobber it) is back-tagged first-wins by
a trailing-window sweeper (pipeline.RunSignerTagger) that reads the signer
from the lake's stellar.transactions. The lake read is scoped to the small
ledger span of AMM trades still needing a signer (not every recent tx), so
it stays cheap at pubnet volume, and a per-tick ledger cap bounds a
cold-start / catch-up sweep. For gaps longer than the sweeper's 30-min
lookback (an indexer/ClickHouse outage or a projector lag), the
stellarindex-ops tag-signer -from N -to N command back-fills the range
through the same first-wins primitive. Exposed as signer on
GET /v1/accounts/{id}/trades.
Added
- /tx now shows the Soroban authorization-invocation tree. An
InvokeHostFunction operation's decoded fields gain an authorizations
tree — the nested SorobanAuthorizedInvocation structure from the op's auth
entries (contract + function + args, recursively), rendered on the explorer
/tx view as "Authorized invocations." This surfaces the nested contract-call
structure the view previously omitted (a step toward the richer /tx detail
stellar.expert shows). Decoded from the already-stored operation BodyXDR,
so no schema change or backfill. It is the AUTHORIZATION subtree, not the
full execution trace (that lives in the tx meta the lake does not store) —
labeled as such. The full execution tree + Soroban resource metering remain
a separate follow-up (they need tx-meta/resources the lake does not persist). - Exploit-shaped detector for AMM self-pair swaps (post-2026-08-25
Blend/Comet). A self-pair swap (
token_in == token_out) on a curated AMM
pool moves no value between distinct assets and has no honest purpose — it
is the primitive the exploit ran ~390 times to walk a pool's spot price,
and the freeze + divergence guards were blind to it because the self-pair
rows decode to zero rows and never reach the served trades table. New
counter stellarindex_amm_self_pair_swap_total{source} is incremented at
the comet decoder's drop point, and a stellarindex_amm_self_pair_swap_burst
alert (ticket) fires on increase[15m] > 10 — far above the historical-zero
baseline. Detection only: it changes no serving or freeze decision, so it
cannot create a false freeze. The counter increments only for LIVE (recent
ledger close time) events, so a backfill or completeness re-derive of the
historical exploit window does not re-fire the alert. Zero-seeded (F-0033)
so operators can tell "armed" from "dead metric," and ships with
false-positive + replay-suppression guard tests plus a runbook.
Fixed
stellarindex_priceless_coverage_check_stale paged a perma-stale FALSE
positive from the indexer and api instances. The
..._last_success_unix gauge is registered in the shared obs registry, so
every binary exports it, but ONLY the aggregator runs the coverage sweep
that sets it — on the other two it sits at unix 0 forever, so time() - 0
crossed the 1800s staleness threshold on every evaluation. Scoped the alert
expr to job="stellarindex-aggregator"; a genuinely-wedged aggregator
(its own gauge stuck at 0) still fires, and a promtool case guards the
non-aggregator-instance suppression.
Added
- Scam-pricing gate. An asset whose issuer is flagged scam-class
(
malicious/unsafe/fraud/scam/hack/phishing) in the curated
account directory now has its aggregated price AND market cap/FDV
withheld — a scam token no longer publishes a value that lends it
legitimacy, even when its market clears the thin-market substance floor
(RIO-GBNLJIYH… did: it showed a $0.0072 price + a $540k market cap on a
deprecated-scam issuer). Wired at the price-reader seam so one gate
covers /v1/price, /v1/price/batch, /v1/twap, /v1/vwap, the SEP-40
oracle price paths, the asset headline and the live tip (keyed on the
base, so it holds across quotes incl. XLM triangulation), plus a payload
suppression on the /v1/assets listing + detail (market_cap / fdv /
price / change). Raw trade surfaces (/v1/ohlc, /v1/observations,
/v1/history) and circulating_supply stay visible; the gate fails
open on a directory outage. Deliberately overturns the directory's
historical "display-only, tags never gate pricing" invariant. (#182)
Fixed
completeness_incomplete{source=comet} fired persistently after the
2026-08-25 Blend/Comet exploit — NOT a data gap (the lake is 100%
complete, watermark at tip) but a verdict artifact: the exploit's 36
self-pair swaps (token_in == token_out) fail canonical.NewPair, so
the comet decoder returned an error and the completeness re-derive
counted each as an undecodable blind spot, holding the source
complete=false forever (the INV-3 do-nothing re-derive trap). The
decoder now returns "zero rows, no error" for determinate business-rule
rejections (self-pair swap, non-positive amounts) so the re-derive
counts them as expected=0; the error path stays reserved for
indeterminate parse failures. (#185)external_fx_rate_rejections{reason=history_deviation} paged
indefinitely on a correctly-refused broken ETB history bar
(2026-08-19 = 44, the pre-float peg vs the correct ~160). The band was
right to refuse it; the _stuck reclassification that de-noises the
alert never engaged because the in-band branch cleared the stuck streak
that a good sibling bar in the same trailing-7d sweep had just
incremented. Removed the reset so a persistently-broken bar reaches the
_stuck threshold (and drops out of the alert), while a genuinely new
bad feed still pages. (#185)- Explorer asset-page price chart was blank for USDC and every fiat
currency: those chart against
fiat:USD, which the /v1/ohlc candle
path has no rows for (a fiat pair has no on-chain constituent; only
/v1/chart carries the fx-cross series). Fiat currencies now render a
USD line from /v1/chart, and USDC — the dollar reference, which has no
USDC/USD series of its own — shows a "≈ $1.00 reference" panel (linking
the divergence board for depeg watching) instead of an empty grid. (#184) - Explorer "Top assets by activity" ranked by all-time
observation_count
(a cumulative counter that floats long-lived stablecoins to the top) and
omitted native XLM entirely (it has no classic_assets row, so it never
appears in /v1/assets) — so USDC ranked #1 and XLM, the most-traded
asset on Stellar, was absent. Now ranks by trailing-24h volume and injects
native XLM (useNativeCoin over /v1/assets/native); XLM takes the #1
spot it earns on volume (~$43M vs USDC's ~$36M). (#181) - Explorer nav: restored a top-level Ledgers entry in the Stellar
section (it had been folded into the Network hub, making it undiscoverable
from the rail). (#181)
/v1/protocols/{name} per-contract activity ran the raw
contract_events FINAL scan (merge-on-read of the 12.8B-row
ReplacingMergeTree), which blew ClickHouse's 2 GiB per-query memory
limit (Code 241) — that memory kill *was* the "certified-lake reader
unavailable" verdict on the protocol page, and the 57s / 3.2B-row scans
were a primary CH-load source behind the API p95/p99 latency alerts and
the tx-outcome read timeouts. Now routed through the existing
contract_events_daily pre-aggregation (like the daily-activity and
event-breakdown views already are) — measured 0.5s vs 57s, no memory
kill. Last-seen is day-grain (sufficient for the roster column). (#180)/v1/anomalies returned 500 on every request — FreezeReasonCounts
and FreezeDailyReasonCounts used the same fragile ($1 || ' days')
interval concat that took down /v1/divergence: it types $1 as
text, but the handler passes an int, and pgx v5 has no int→text
encode plan. Also fixed the latent same bug in ListDivergenceSeries
(/v1/divergence/series, $4/$5). All now use make_interval, and
a package-wide test forbids the concat form so it can't return a third
time. (#179)
Added
- Live account-movements feed: the
/accounts/{id} movements view now
auto-follows the ledger tip — an SSE-nudged refetch (~4s coalesced,
shared tab-wide) gated to the first page so a keyset walk into history
is never yanked back, with a 20s fallback poll if the stream drops.
Classic-asset movement lag is cut from ~5min to ~30s by tightening the
incremental cap67 derive timer (the derive runs in ~0s per fire; true
up-to-the-second via a continuous follow-worker is a follow-on). (#172)
Fixed
- cap67 account-movements derive could permanently lose movements
under lake pressure. The
LiveSink drops whole ledgers under buffer
pressure, leaving holes near the tip, but the derive resolved its upper
bound with raw MaxLedger and advanced its watermark past any hole
with no trailing re-derive — so a dropped ledger's classic/native
account movements were never revisited (the raw lake self-heals via
ch-live-catchup; this derive did not → a permanent gap in account
history). Now clamped to ContiguousWatermark (mirroring the real-time
projector), so the derive stalls at a hole until catch-up heals it —
delayed, never lost. Found by an adversarial audit of the
real-time-movements plan. (#174) /v1/divergence (the per-reference divergence board) returned 500 on
every request — ListDivergenceLatest wrote its trailing-window
filter as now() - ($1 || ' days')::interval, which makes Postgres
infer $1 as text, but the handler passes sinceDays as an int.
pgx v5 has no int→text encode plan, so the query failed before
executing (unable to encode 7 into text format for text (OID 25)).
The window is now make_interval(days => $1), which types $1 as an
integer. Adds a regression test that forbids the text-concat form. (#176)- The
volume_character rollup worker refreshed the all-asset 14-day
account-structure roll every 15 minutes; each pass is a multi-minute
full scan of the trades hypertable (72M rows/7d), so it ran
effectively continuously and starved the customer API (the p99 2259ms
regression introduced in v0.44.0). The cadence is now 6h and the roll
caps its own max_parallel_workers_per_gather + statement_timeout so
a single refresh can't monopolize the primary. (#175) - Explorer pill contrast: category / venue / type chips now route through
the adaptive design tokens instead of hard-coded colors, fixing
low-contrast pills in the dark theme. (#173)
Added
- Materialized
volume_character rollup (design §2): a worker-maintained
per-asset table (migration 0149) computes the wash-vs-market
account-structure signals in one all-asset pass on the aggregate cadence.
The /v1/assets/{id} detail now reads it as a keyed lookup instead of a
per-request 14-day roll (which timed out at 4s on high-volume assets like
USDC), and volume_character is now carried on the /v1/assets listing.
(#35) - §4-B "annotate + demote": the default
volume_24h_usd_desc sort ranks by
concentration-adjusted volume (raw × (1 − top_account_pair_vol_share)
for concentrated/operational assets), so wash/operational volume no longer
tops the directory. The raw volume_24h_usd chain fact stays visible and
every asset stays present — a sort-key overlay only, never a value change
or a hidden row. (#35)
Security
- Bumped
golang.org/x/mod v0.39→v0.40 (CVE-2026-56864, CVE-2026-56865 —
malicious GOPROXY/GOSUMDB) and github.com/moby/go-archive v0.2→v0.3
(CVE-2026-17106 — tar path traversal). govulncheck clean. (#169)
Fixed
TestExternalFleet_EndToEnd integration flake: the consumer goroutine
inserted drained events with the fleet context that shutdown cancels
mid-drain; inserts now use a decoupled context. (#169)
CI / tooling
- Dependabot ignores TypeScript major bumps in the explorer (openapi-typescript
is not yet TS7-compatible), stopping a recurring red PR. (#169)
- Corrected the stale
ansible-drift comment: the vault secrets are restored
and the check works; a failure now signals genuine r1 drift. (#169)
Operator notes
- Migration 0149 (
asset_volume_character rollup table) applies via the
standard deploy migration step. No new Prometheus rules or systemd units.
Added
- Alias-aware asset directory: SAC/alias twins fold onto their canonical
classic row (summing 24h volume + trades with exact
big.Rat); a configured
SAC asset_id resolves to its classic on the detail path. (#28) - Priceless-popular pricing-coverage tripwire: an aggregator sweep pages when a
genuinely popular asset (market-character volume, wash excluded) has no served
price. New
stellarindex_assets_popular_priceless gauge + sweep-health
metrics, bounded by a 5m per-sweep timeout. (#28) - stellar-expert scam-label +
volume_character signals surfaced on the asset
directory + detail (issuer_directory_{tags,domain,name}, volume_character,
volume_character_signals). (#30) - Per-account activity watermark bounds the ops-by-account ClickHouse scan
(fail-safe: a missing watermark falls back to the pre-existing unbounded
scan). (#31)
- W8 ops observability: projector-wedge gauge, notify-send metric, and a
verify-archive Tier-B nightly timer. (#33)
Fixed
/v1/network/stats stamps honest flags.stale + as_of on the
stale-while-revalidate serve path instead of silently asserting fresh (REC-05,
same class as /v1/markets)./v1/markets stamps honest stale + as_of on SWR stale-serve. (#160)- Convert page hydrates header/inverse/ladder live off the shared query instead
of serving build-frozen residue. (#32.10a)
Security
- Removed permissionless DeFindex strategy self-registration: the curated
MainnetStrategies set is the sole trust root; a factory create body can no
longer seed a poisoned strategy into the gated registry. (W8 6c)
Operator notes
- New Prometheus alert rules (
notify, pricing-coverage, projector,
verify-archive), the verify-archive-tier-b systemd timer, and the
account_activity ClickHouse table + MVs are config/schema that a
binary-only deploy does not apply — apply them alongside the binaries.
Added
- Declared fiat-peg pricing (AUDD/AUDR → AUD × served fx), price_basis=declared_peg. (#154)
- USDC + SAC quote bridges for directory pricing. (#152)
Fixed
- FX confirm-veto: an agreeing 7d history majority refuses a pending confirm, stopping the persistent-broken-upstream (UZS) re-poison; genuine devaluations still confirm. Outlier-drop counter gains a pair label. (#157)
- /v1/assets/{id} detail overlay is substance-gated; dust prices no longer leak onto detail. (#154)
- Account ops pages: detached budget for the tx-outcome stitch. (#155)
Fixed
- FX guard: history-majority confirm veto (the Massive UZS second
act). A persistently-broken current feed can no longer re-poison a
healed baseline through the two-fetch confirmation: when a ticker's
trailing-7d majority (≥4 bars mutually agreeing within 10%) REFUTES a
pending candidate, the confirm is refused
(
deviation_history_conflict; repeats reclassify to
…_conflict_stuck, excluded from the rejection alert). History still
never SETS a baseline — genuine devaluations confirm as soon as the
majority stops refuting (follows the move, or the split window yields
no majority). Red-proven tests. (task #29)
Added
stellarindex_aggregator_dropped_trades_total now carries a pair
label (the configured target pair, bounded ~12), so an outlier_storm
is attributable with topk by pair instead of ad-hoc SQL — the
2026-08-14 single-issuer SDEX token-farm wave took the latter.
Storm/spike alert exprs sum() across labels and are unchanged.
(task #29)
Fixed
- FX guard: history-majority heal for poisoned bootstrap baselines +
jitter-tolerant stuck streak (the Massive UZS incident). A broken
current-feed bar bootstrap-accepted at restart no longer poisons the
baseline against the ticker's own correct 7-day history: ≥4
mutually-agreeing rejected bars refute an unconfirmed single-sample
baseline (median wins, the poisoned current-day row is scrubbed
before write). Confirmed baselines are never healed; split series
never heal. Stuck-streak reclassification now tolerance-matches
(exact float equality never matched a live jittering upstream).
Two-lens verified; red-proven tests. (#146)
Added
- Synthetic USD-cross divergence reference — non-USD-fiat pairs
(XLM/EUR, XLM/GBP, …) get a second reference (on-chain oracle
base/USD ÷ reflector-fx or chainlink fiat/USD), so SuccessCount
reaches the divergence trust floor and ADR-0019's corroborated
release can auto-release genuine repricings unattended — four
operator freeze-releases on 2026-08-24 alone were this class.
Migration 0148 admits the source to divergence_observations
(pure-widening CHECK; decompress dance). Two-lens verified incl.
live migration exercise against compressed chunks. (#149)
Fixed
- Freeze lifecycle: escalated-freeze ratchet + restart stall + corroborated
release (ADR-0019 amendment 2026-08-24). Mid-freeze buckets now score
per-tick returns against a shadow comparator (kills the drift-since-freeze
ratchet that kept XLM/GBP-style freezes from ever releasing at a new stable
level, and the restart→unscored stall). Because any HELD level reads calm
under per-tick scoring, auto-unfreeze now additionally requires a
corroborating lens reading that agrees within 5% with the bucket's own fresh
candidate price (
Signal.ReleaseCorroborated): a genuine repricing whose
references follow releases; a parked manipulation walks the ladder to the
operator. Pairs with no usable reference never auto-release (fail-closed;
they escalate and page). Verified by a 3-lens adversarial panel; red-proven
regression tests at both the policy and orchestrator layers. (#142)
Changed
- Explorer: /sdex is the one canonical SDEX surface (protocol analytics view;
/protocols/sdex 301s server-side). Accounts page frame + logo polish.
(#141, #143, #144)
Fixed
- /v1/price p95 tail eliminated: the serving pool now forces custom
plans (
plan_cache_mode=force_custom_plan post-connect). Root cause:
Postgres flipped the request path's raw-trades fallback to a generic
plan whose build costs ~206 ms across the ~870-chunk trades hypertable
and is rebuilt on every plancache invalidation (~1/min) — a steady
~5 % of serving binds paid 250–330 ms. Custom plans bind in 0.2–3 ms.
Background/ops pools keep the default plan mode. - Stuck-upstream FX rejections no longer hold the alert red: after 12
consecutive refusals of the SAME broken history bar (the Massive
ETB=44 case) repeats reclassify to
history_deviation_stuck, excluded
from the alert; fresh disagreement still alerts immediately. The guard
refuses the bar either way. - Explorer: navigation revised into Stellar / External / Developers
sections, Stellar-mark + Inter wordmark logo with the live ledger
number beside it, new /insights hub, /network sub-surface links.
No migrations.
Fixed
- OHLC bars are now bit-for-bit reproducible (migration 0147): the price
CAGGs' open/close resolve same-instant ties by a total key
(epoch-µs ‖ ledger ‖ tx_hash ‖ op_index ‖ source) mirroring the raw-trades
serve order, instead of physical scan order. VWAP switches to the exact
single-division form (≤1e-16 relative, below wire truncation — the
0115-invited free rider). ⚠ The migration recreates the seven price CAGGs +
twap_1h/1d WITH NO DATA; re-materialization is the deploy follow-up
(recent-first plan in the migration header).
- Freeze markers now write for Phase-2 freezes on Phase-1-off deployments:
the freeze writer was gated on the Phase 1 anomaly checker while the Phase 2
confidence lifecycle runs unconditionally — engaged freezes (r1 XLM/GBP)
refused publication with no Redis marker, serving the last value with
flags.frozen absent. Writer is now built unconditionally; AST tripwire
added. - The daily supply-snapshot writer can now actually run: the auto
snapshot-ledger resolver clamps to the lake's landed tip (bounded, 512
ledgers) instead of demanding the realtime cursor's not-yet-landed
stellar.ledgers row — the structural race that failed every timed run.
Operator
-ledger stays exact fail-closed; wall-clock stamping remains
impossible. - Integration tests quiesce CAGG refresh policies in the shared bootstrap
(the 55P03 concurrent-refresh flake).
Changed
- Monitoring: the system recognition census is a drift gauge
(
stellarindex_recognition_unattributed_shapes) with a step-change alert,
no longer a permanently-red completeness_incomplete row; new
galexie-archive partition-contiguity guard (hourly scan + page alert on any
gap/overlap outside the declared capacity trim). - Explorer: shared LastPriceCell (restores the tick flash DexesView's fork had
lost), /dexes pools board follows ledger closes, home Recent Trades ticks on
ledger closes instead of a blind 30s poll.
Added
- `GET /v1/livez/lake` — the lake-critical LB probe (ADR-0050 §7.3):
200 iff ClickHouse pings; 503 on failure or when no lake is wired
(fail-closed). Complements
/v1/readyz's deliberate CH-non-criticality
so a lake-dead instance can be pulled for lake routes without touching
pricing. - SLO lake-guard test: CI now fails if any SLO'd handler
(
/v1/price*, /v1/oracle/*) reads a ClickHouse-backed field — the
"no SLO'd route touches the lake" invariant, enforced.
Changed
- Phoenix completeness reconcile is now STRICT per-ledger — the
aggregate netting opt-out is retired (own-ledger attribution removed
the sweep-shift it absorbed; proven with 0 mismatched ledgers before
removal). A real drop can no longer net against a phantom.
Fixed
- Served-reader determinism:
TradesInRange gains the full ORDER BY
tiebreak (raw OHLC bars no longer depend on arbitrary same-timestamp
ordering); account_movements gains the LIMIT 1 BY read-time dedup
its sibling readers already had; NetworkThroughput derives its day
window and Partial flag from the data's tip close time instead of the
wall clock.
Added
- DeFindex `dfees` fee distributions are now modelled (W5.2, the last
open launch item). Body shape proven from captured on-chain blobs
(
Map{"distributed_fees" → Vec[(token, i128)]}, per-asset, empty vec
valid): one row per distributed-fee token into the new defindex_fees
table, with full sink/projector/reconcile registry parity. The ~12.8K
historical events backfill via projected-rebuild -source defindex
after this release deploys. - Explorer feels alive: live data across the whole site. Pool
reserves, pair tables, and lending reserves refresh on every ledger
close (shared
useLedgerFollow); charts advance their forming candle;
the home "live USD price" actually streams and flashes; venue/DEX
last-price cells flash again; the asset History tab is a true live
trade tape over the previously-unused /v1/observations/stream;
rollup panels and activity feeds auto-poll. - Failed transactions are first-class on the explorer and API with
explicit failed status and failure reason (D-PART-FAILEDTX decision),
plus the 2026-08-14 audit's decisions batch.
- OpenAPI spec overhaul: exact route parity (129/129 with unique
operationIds), valid OpenAPI 3.1 null unions, 0 Spectral errors,
regenerated Postman + types.
Fixed
- Completeness re-derive counts sweep-rescued outputs at their own
ledger (
eventLedgerCarrier): a correlation-buffer rescue (phoenix
7-field era) is now attributed where its served row lives instead of
at the sweep-trigger ledger, removing the CS-084 ± shift noise from
strict per-ledger reconciles. - `projected-rebuild` clamps `-workers` to 1 for correlation-buffer
decoders — concurrent out-of-order windows starve sweep triggers and
silently drop groups (measured: 4 workers lost ~650 of 5,154 phoenix
era trades in a dry-run; 1 worker lost none).
- SQLSTATE class extraction guards malformed codes (
sqlStateClass)
instead of slicing blind. - The pgBackRest restore drill had never once run on its schedule
(BDR-04). CS-110's whole point is evidence that the backups restore,
and the scheduled path produced none — for three stacked reasons, each
hidden behind the one before it:
1.
PrivateTmp=true gives the unit its own empty /tmp and /var/tmp,
so DRILL_ROOT=/var/tmp/restore-drill — a provisioned 5.2 TB ZFS
dataset, plainly present on the host — did not exist inside the
service's mount namespace. ReadWritePaths on that path failed
namespace setup and systemd aborted the unit with 226/NAMESPACE
BEFORE ExecStart. Every passing drill on record was run by hand,
which has no namespace.
2. With that cleared, NoNewPrivileges=true blocked sudo's setuid
transition ("unable to open /etc/sudoers: Operation not permitted").
The unit runs as root by design and DROPS privilege to postgres;
no-new-privs protects nothing on an already-root unit while
disabling the one mechanism it uses to run with LESS privilege.
3. Then pgbackrest, running as postgres, could not traverse
/var/lib/stellarindex (drwxr-x---).
The dataset now lives at /srv/restore-drill, postgres-owned — /srv
is world-traversable, is not shadowed by PrivateTmp, stays writable
under ProtectSystem=full, and already hosts history-archive. The
ZFS role gained optional per-dataset dir_owner/dir_group
(default(omit), so every other dataset is untouched). - `tip_lag` was measuring the backup's AGE, not recoverability (BDR-05).
The scratch instance runs
hot_standby = on and is started with
pg_ctl -w, which returns the moment CONSISTENCY is reached — while
replay of the remaining archived WAL continues in the background. The
drill then measured the restored tip immediately, so the number it
reported was "how old was the backup we restored from". Measured
2026-08-19: lag 13,392 ledgers (~18.6h) against a diff taken 21h
earlier, while archive-get was demonstrably still streaming segments
in ~10ms each minutes later. On a daily-diff schedule that made the
< 5000 threshold unpassable except by drilling shortly after a diff —
the 2026-07-03 pass (240 ledgers) was exactly that accident, and a
threshold met only by luck is not evidence. The drill now drains the
archive stream to an LSN captured from the live primary before
measuring, treating BOTH terminal states as drained (replay passed the
target, or recovery ended and promoted — the latter returns NULL from
pg_last_wal_replay_lsn() and would otherwise spin to the timeout on
the very run that succeeded). The drain is a reported check of its own,
so a timeout can never masquerade as a clean measurement. - The
ReadWritePaths directive is gone entirely rather than repointed:
ProtectSystem=full already leaves /var and /srv writable, and the
directive's only effect here was to make a missing path a hard start
failure.
Fixed
- phoenix stake-init events no longer trip `recognition_ok=FALSE` (#108).
20 real
LP-share staking init events matched no decoder shape, so the
ADR-0033 recognition census counted them as unhandled topics and downgraded
the whole source — even though they carry no financial row to project. The
decoder now *recognises* the init topic (Matches() returns true) and
emits nothing by design, so recognition is honest and projection is
unchanged. No served-data change. - sorocredit `TreasuryUpdated` config event is now recognised and captured
(#108). The main contract's
TreasuryUpdated topic (a treasury-pointer
rotation, body Vec[Address old, Address new]) matched no decoder shape —
one real lake event at ledger 63,847,367 was dropped end-to-end, tripping
recognition_ok=FALSE. It is now captured verbatim into
credit_events.attributes["body"] (exactly like BeaconUpdated /
CollateralHashUpdated), with migration 0145 admitting treasury_updated
into the credit_events_event_type_check CHECK. No promoted column, no
invented semantics. - blend_emitter reconcile fan-out false-red (#107). The projection
reconcile compared served rows against a lake re-derive that counted the
drop event_kind — a fan-out kind the emitter carves out of the served
projection — inflating the expected count and reporting a phantom
Σ|Δ|=14 mismatch on a source whose data was always correct. The reconcile
now excludes drop (event_kind <> 'drop'), so blend_emitter_events
reconciles exactly. No served-data change.
Fixed
- Completeness reconcile no longer times out on factory-gated sources (#104).
The
-pass per-source projection re-derive streamed the entire ~6B-event CH lake
for identity-gated sources with empty catalogue contractIDs (aquarius, phoenix),
blowing the 120-min pass deadline (aquarius: projection: context deadline
exceeded failed the whole pass on r1). For opted-in gated sources it now scopes
the -ch re-derive to a guaranteed superset of the gated contract set (factory
∪ curated seed ∪ protocol_contracts children ∪ lake-announced children) via the
contract-indexed contractIDs prefilter — counts-identical to the full stream
(Matches() rejects non-gated contracts regardless), just orders of magnitude
faster. Opt-in is pinned to {aquarius, phoenix}; defindex is excluded (its decode
correlates events across contracts in a tx, which a contract prefilter would break).
Fail-closed: a missing contract would under-count → a visible red, never a false green.
Security
- govulncheck gated behind a documented lib/pq accepted-risk allowlist (#105).
The 2026-08 CVE-2026-56868..56874 batch surfaced 7 unpatched *called*
vulnerabilities in
github.com/lib/pq@v1.12.3 (the latest release of the now-
unmaintained driver), failing CI on every PR. All require a malicious/compromised
Postgres server or a pre-auth MITM; stellarindex connects only to its own Postgres
over 127.0.0.1 (sslmode=disable, no GSS/.pgpass) → not exploitable in this
deployment. A reviewed allowlist (scripts/ci/govulncheck-allow.txt + a JSON-mode
wrapper that still reds CI on any *other* called vuln) documents the accepted risk;
the durable fix (migrate to jackc/pgx) is tracked as a post-launch follow-up.
Fixed
- soroswap recognition false-red (#100). The ADR-0033 recognition census
built its dispatcher without the soroswap pair registry, so its soroswap
decoder's
pairTokens map was empty and Matches() rejected every real
SoroswapPair protocol event — each became a false "unhandled topic" gap
attributed to soroswap (and the watermark clamp cascaded into spurious
projection floor-loss alarms), even though the indexer decodes + serves those
trades correctly. Both recognition-census paths now seed the soroswap decoder
from the same LoadSoroswapPairRegistry set attribution already uses. - aquarius `set_protocol_fee` Vec-body decode (#101).
set_protocol_fee
events on registered Aquarius pools carry a Vec body (SCV_VEC[SCV_U32] = the
new pool-wide protocol-fee fraction, per the pool WASM's singular
set_protocol_fee_fraction) that the Map-only decoder dropped, blocking
aquarius projection with "undecodable-but-matched" blind spots. decodeFee now
branches on the SCVal kind; the absent prior fraction lands NULL (not invented). - phoenix incomplete gating seed (#102). The curated
MainnetGatedSet was
missing 14 verified-genuine phoenix contracts (1 pool + 13 per-pool stake
contracts), so the reconcile under-counted them AND the live gated pipeline was
silently dropping some still-active contracts' events. All 14 were verified
on-chain (factory pool-create co-occurrence / shared reward keeper / stake-v1.1
migration events) and added to the seed; the pre-upgrade 7-field sweep-emit
ledger shift is absorbed via aggregateReconcile. - defindex projection dirty window re-verified clean and cleared (the #91
harvest-count fix, live since v0.36.0).
Added
- Comprehensive per-source projection reconciliation + a static
catalogue-completeness invariant (#96). The projection axis previously
reconciled only a subset of protocol tables; it now carries reconTargets for
the 1:1 tables it was missing (aquarius admin / protocol-fee / kill-switches /
liquidity / reserves-sync / rewards, soroswap_liquidity, phoenix
initialize/admin), with the genuine per-token fan-out tables (aquarius
reserves/liquidity, sdex_offer_events) explicitly
noReconcile-waived rather
than left silently unvalidated. A new AST-walking invariant test asserts every
decoder EventKind that routes to a persisted table is either
reconciled-by-kind/census or explicitly waived — so a future decoder kind can
no longer silently fall out of the reconcile's EXPECTED sum (the exact class of
the defindex strategy.harvest undercount that produced a phantom
976-mismatch false-red).
Fixed
- The nightly completeness-verdict driver no longer times out and freezes the
alphabetical tail.
run-compute-completeness.sh re-invoked
compute-completeness -ch per source AND per 25k chunk, and every invocation
re-ran the load-heaviest step — the global DistinctTopicShapes recognition
scan (~60s over full history, identical regardless of -source/-from). A
source pinned far below tip (aquarius, recognition-capped near its genesis)
walked hundreds of chunks, so that one scan ran hundreds of times per night —
the 3h52m timeout (Result=timeout) that left the alphabetical tail's verdicts
days stale. A new compute-completeness -ch -pass mode proves recognition +
substrate ONCE at full range for the whole catalogue and reconciles each
source's projection incrementally from its own watermark; the wrapper now makes
one such call. This also (a) clears the low-tip substrate flap — a full-tip
substrate proof advances the tip and is never blocked by the CS-083 write guard
— and (b) finally gives every catalogue source a verdict, including the
never-seeded blend_emitter/blend_backstop/sorocredit (they reconcile from
genesis on the first pass). INV-5, the projection dirty-window mechanism, the
substrate/projection fail-closed claims and CS-083 are all preserved unchanged.
Fixed
- Aquarius pool governance events are no longer silently dropped. The decoder
gated 7 governance topic symbols (
apply_upgrade, commit_upgrade,
set_privileged_addrs, apply_/commit_transfer_ownership,
enable_/disable_emergency_mode) on the canonical router only — but the 337
registered Aquarius pools emit them too (a protocol-wide staged WASM upgrade of
320/337 pools). Pool-emitted governance events returned Matches()=false,
becoming an ADR-0033 recognition gap (holding aquarius completeness red) AND
never reaching Decode → ~1,679 real events lost since ledger 55,363,632. The
gate now accepts registered pools (reg.Has || reg.IsFactory; unidentified
emitters still fail closed), and the upgrade decoder handles the pool body
arities (router = 1 wasm hash, pool apply = 2, pool commit = 3 → staged
hashes in attributes.wasm_hash_N). The events now land in the already-served
aquarius_admin table. (A backfill re-processes the historical drop.) - Defindex `strategy.harvest` flows are counted in the completeness verdict.
The reconciliation catalogue omitted
defindex.strategy.harvest from the
defindex_flows expected-count kinds, so the ADR-0033 verdict under-counted
every genuine harvest by exactly 974 (served=1, expected=0), false-flagging
defindex complete=false. The served data was correct; adding the kind fixes
the count. Count-only — no data mutation.
Fixed
- XLM circulating-supply refresh no longer falsely freezes during quiet
periods. The supply freshness gate anchored on
MAX(ledger) over
account_observations, which only rows on a watched SDF-reserve-account
*balance change* — so any market-quiet stretch beyond the ~1-day dormancy
horizon made the anchor go stale and the gate fail closed, freezing XLM
supply and firing a continuous supply_refresh_error_dominant ticket (which
in turn masked a genuine future observer death). The served value was always
correct — only its freshness signal was wrong. The gate now anchors on a true
per-tick observer watermark (new account_observer_watermark table, migration
0144), advanced every ledger by the indexer: a healthy-but-quiet observer
stays fresh, a genuinely dead observer still trips the gate. Found only by a
live audit of r1 — the code looked correct; the live quiet-reserve state
triggered the latent flaw.
Security
- Account-history participant injection closed. A Soroban
InvokeContract
op's call arguments and SorobanAuthorizationEntry entries are attacker-
controllable at the XDR-decode layer, so they are no longer indexed as
account participants. Previously an attacker could inject an arbitrary
victim's address into that victim's permanent, public
/accounts/{g}/operations history under the attacker's own signature. - audit-2026-08-14 remediation — 79 verified fixes across money-correctness
(SDEX single-leg plausibility ceiling, oracle-execution corroboration for the
Band adapter, MEV-detector evidence-gating +
mev_events retention),
auth/data-integrity (self-service key-mint scope hardening, session token
hashing at rest via migration 0143), and projector durability. Each landed
with a proven-red regression test. - Go toolchain 1.25.12 → 1.25.13. govulncheck reported 7 standard-library
vulnerabilities reachable from live call paths —
net/http (GO-2026-5026,
Punycode label handling) via the ClickHouse reader, the CoinGecko supply
client, the history-archive checkpoint resolver and the galexie trim's S3
calls, and encoding/asn1 via the WebAuthn passkey registration path. All
are fixed in go1.25.13. Every workflow reads go-version-file: go.mod, so
the toolchain directive is the only pin to move. Verified clean locally:
"0 vulnerabilities".
Changed
- Asset identity: one alias registry. A binary-startup
AliasRegistry
built from [supply].sac_wrappers folds an asset's SAC-wrapped form into a
single identity (SAC form ordered last), threaded through the price/volume
read paths. Fixes alias-blind volume/price reads across ~11 money endpoints
(asset detail, VWAP/TWAP/OHLC, pairs, markets, aggregate global tiers) that
previously split an asset's SAC and classic forms into two un-aliased
identities. Non-XLM folding activates per [supply].sac_wrappers config.
Fixed
- Absent-vs-zero honesty across the read surface.
/v1/status incidents
now carry an explicit ok|degraded|unknown tri-state (a failed alert query
no longer serialises as a false all-clear); /v1/tx distinguishes partial
event / op-result coverage; /v1/protocols serves from an SWR cache instead
of a per-request unauthenticated scan; the explorer degraded-banner and
network-insight no longer read a failed query's zero as real data. - Incidents Atom feed `<updated>` now reflects the most-recent entry's
timestamp (empty feed → a stable sentinel) instead of wall-clock
now(), so
a stale or empty feed is no longer syndicated as freshly updated every crawl. - `TestMigrationsRoundTrip` could deadlock against TimescaleDB's own
job scheduler, turning `main` red for 30 hours and firing the
ci-health tripwire every two hours. The test asserts compression
and CAGG-refresh policies are attached, then rolls every migration
back — so
migrate down's DROP ... AccessExclusiveLock raced the
16 background workers running those very policies, and the two could
form a lock cycle ("deadlock detected, Process 94 waits for
AccessExclusiveLock on relation 21724; blocked by process 161"). It
only reproduces under load, which is why it passes locally in 5s.
Retrying is not available as a fix: a failed migration leaves
golang-migrate's version DIRTY. The container now runs with
timescaledb.max_background_workers=0, removing the concurrent
actor entirely, and the test asserts the setting actually applied —
a Cmd override that silently failed to take would otherwise look
exactly like a fix. The assertions are unchanged in strength: they
check policies are ATTACHED (a metadata row), not that they run.
Changed
- `GET /v1/contracts/{id}/interactions` now anchors its window to the
contract's own recent activity, so `?days=` is an UPPER bound rather
than the window served. Both halves of the read scale with the
ledger span they cover, and over the default 90 days a busy contract
cost 3–6s — the slowest panel left on the contract page once the
/wasm scan was bounded. Narrowing to the contract's 500 most recent
active ledgers brings that to 0.705s. This is a deliberate trade, not
a free win: shared_txs counts drop for busy contracts. The ranking
— which is what the panel is for — was unchanged in the same order on
the measured sample, and the endpoint has always reported a bounded
recent sample (subjectTxCap truncates at 50,000 transactions).
Quiet contracts, which are most of them, have fewer active ledgers
than the cap and keep the full window. since_ledger reports the
floor actually served, and the OpenAPI description now says so.
Fixed
- Every cold contract page served at least one failed panel, because
the page starved itself at the refresh gate. The contract view fans
out to five concurrent reads, but four of them (detail events,
interactions, code-history, account activity) all acquired the single
refresh-gate class
contract_detail, capped at half the global limit
— two slots. So on a cold contract two of the four refreshes were
refused, and a refusal with nothing cached is a 503, not a stale
serve. Measured on 20 of 20 cold random contract pages, and it was
not crawl pressure: the same rate held with seconds of think time
between pages. The per-class cap exists to stop one class starving
the OTHERS, so the classes are now keyed per panel, which restores
that intent without letting a page compete with itself. The global
bound was also below one page's width (4 for a 5-read page) — raised
to 8, with the explorer ClickHouse pool 8 → 16 so "detached refreshes
can never consume the whole pool" still holds. r1 has 20 cores and
idles at ~2 concurrent queries, and every explorer scan is pinned to
max_threads = 4. scripts/ops/contract-page-audit.py now scores a non-2xx/404 panel
as UNLOADED instead of as a fast response, and reports it separately
from latency. The first version counted a 503 as a loaded panel, so
it rated pages "ok" at 0.10s while three of five panels were failing
— a broken page scored better than a slow one. It also takes PACE,
because "is one cold page fast" and "does the site hold up under a
sustained crawl" are different questions and were being answered by
one number.
Security
- Registered API keys were completely unmetered in production
(audit 2026-08-13 F1):
MirroredKey carried no monthly quota, so the
record the deployed Redis validator reads had none, and the quota
middleware short-circuits at <= 0 — every key /v1/register handed
out advertised a 1,000,000/month cap (in its own response body and in
the public agent docs) and was enforced nowhere. The rate limiter was
the only live bound. Quota now flows through the mirror, with a
round-trip test (real store → real validator) asserting LITERAL
expected values: the prior tests compared a component against its own
input, which is why a dropped field read as correct on both sides. - `POST /v1/register` was cross-site invocable (F4): the
Content-Type gate only validated the header when present, so a
header-less POST — a CORS *simple* request, never preflighted —
let any page create an account plus a permanent credential per
visitor via
fetch(…, {mode:'no-cors'}), while burning tokens from
the per-IP throttle this endpoint shares with /v1/signup (with the
source addresses distributed across victims). The header is now
required; docs and examples send it.
Fixed
- A contract page took ~8s to finish loading because the WASM panel
paid an unbounded lake scan to produce a nicer 404. When a
contract has no captured instance — the common case —
/wasm asked
"is this a SAC?" via contract_id = ? ORDER BY ledger_seq DESC LIMIT
1 over contract_events, the quiet-contract reverse-scan trap that
contract_active_ledgers exists to prevent. That cost ~0.34s idle,
but the contract page fires five reads at once and the other four
return via stale-while-revalidate while spawning background
refreshes, so the inline WASM read was starved to its full 8s
request deadline. 23 of 25 cold random contract pages breached the
1s budget on this single call, and it also starved sibling panels
into intermittent 503s. The probe is now bounded to the contract's
own recent active ledgers (0.008s measured on r1, ~40x), and an
empty active-ledger walk answers authoritatively without touching
contract_events at all. New scripts/ops/contract-page-audit.py
measures the whole page the way a browser loads it — concurrently,
scored on the SLOWEST panel — because the per-endpoint harness
reported every one of these reads as passing. - SECURITY (live surface): a captured passkey sign-in was an
unlimited, never-expiring session mint.
POST
/v1/auth/passkey/finish-login accepted a replay of the same
ceremony cookie + assertion body indefinitely: the ceremony carried
no server-side expiry, and nothing marked a challenge used. The
expiry was believed to be covered — the guard was written — but
go-webauthn only stamps SessionData.Expires when
Config.Timeouts.<ceremony>.Enforce is true and that field defaults
FALSE, so Expires was always the zero time and the check was dead
code. The only bound was the cookie's Max-Age, which is a browser
hint an attacker's HTTP client ignores. Two fixes: the timeouts are
now configured (5 minutes, enforced) and an unstamped ceremony is
refused rather than treated as eternal; and each challenge is now
SINGLE-USE, spent through a Redis-SETNX guard (passkey:ceremony:*,
the same mechanism the SEP-10 replay guard uses — F-1224) after the
assertion verifies and before any session is minted. The guard fails
CLOSED: if the store is unreachable the sign-in is refused (500)
rather than granted on trust, and email-code sign-in is unaffected.
Redis-less deployments fall back to an in-process spent-set
(single-instance accounting, warned at boot). Note for reviewers of
the old behaviour: the sign-counter clone check was NOT a backstop
here — go-webauthn deliberately exempts counter 0, which is what
Apple/iCloud passkeys report forever. Regression tests drive the
real ceremony end-to-end against a software authenticator, including
a mint-then-replay. - SECURITY (live surface): passkey sign-in never asked for or
required user verification, making passwordless sign-in
possession-only — whoever held the authenticator was the account, no
biometric or PIN involved.
AuthenticatorSelection was unset and
neither begin call passed a user-verification requirement, so the
library's shouldVerifyUser was false, the UV bit was never
checked, and the options JSON omitted the field entirely (browsers
then applied their own default). Both ceremonies now require user
verification. Trade-off, taken deliberately: a security key with no
PIN configured can no longer be enrolled or used as a first factor. - A passkey label with 34+ multi-byte characters 500'd instead of
saving. The name was truncated by BYTES while the storage CHECK
counts CHARACTERS, so a CJK label was cut mid-rune, and Postgres
rejects invalid UTF-8 — after the authenticator had already burned a
resident-credential slot for a credential the server then never
stored. Truncation is now by runes.
- "Body too large" was unreachable on four auth endpoints
(
/v1/auth/login, /v1/auth/verify-code, both passkey finish
routes): io.ReadAll(io.LimitReader(…)) returns a nil error at its
cap, so an oversize body was silently TRUNCATED and then surfaced as
a confusing parse error. All four now use http.MaxBytesReader, the
pattern the rest of the repo already follows. - `/v1/accounts/{g}/positions` runs its six protocol folds in
parallel (sub-second audit's last warm breach, 1.99s): the folds
are independent Postgres reads and were executed serially, so the
endpoint's latency was their sum rather than their max. Output is
byte-identical — each fold writes its own slot and the results plus
coverage notes merge in the original fixed order. Fixing this also
required making the shared per-request asset resolver
concurrency-safe: it memoises into a plain map, and concurrent map
writes are a FATAL runtime throw no recover() catches, so the
parallel folds would have crashed the process under load.
Fixed
- Protocol pages keep their bespoke visual suite when the battery
misses its budget (§2.6b grounding incident): the detail VIEW has
been prewarmed + stale-served since 2026-07-31, but the bespoke block
INSIDE it had no cache of its own — it is built last, so it inherited
whatever was left of the rebuild's 90s budget, and when that ran out
(
protocol bespoke build failed … context deadline exceeded) the
block was dropped and, on a key with no healthy entry yet, the
suite-less view was cached and stamped fresh. The block now has a
last-good cache with a detached, single-flighted, gate-classed
(protocol_bespoke, its own served-tier gate — these are Postgres
queries, not lake scans) refresh: a build serves the previous block
instantly and never blocks, only a true first-ever miss computes
inline (bounded by its caller's context, with the compute surviving
it so the next build lands warm), and a failed or starved refresh
keeps the last good block. A block older than 45 minutes (≈3 prewarm
sweeps) is still served but reported: analytics.status gains a
stale value, distinct from unavailable, and such a build now
counts as COMPLETE for cache displacement instead of being pinned out
as degraded. - `/v1/network/throughput` is prewarmed and snapshot-served: the
/network page's daily series is a FINAL scan over up to a year of
stellar.ledgers with three argMax columns, and it ran inline on the
8s request budget — so a cold or loaded first load lost the panel
(the "no operations in 24h" half of the same incident) and, because
the scan died with the request, no retry could land warm. It now
rides the established SWR shape (5-minute TTL matching the API's
5-minute prewarm loop, detached single-flight refresh under the
network_throughput gate class, stale entries served with
flags.stale + their real as_of). ONE entry holds the maximum
365-day window and every request slices its tail, which also collapses
the key space: an unauthenticated caller walking ?window_days=1..365
previously bought 365 distinct year-class scans. partial is now
decided at serve time, so a cached series that crosses UTC midnight
no longer advertises a complete day as still accumulating.
Fixed
- Explorer: absent data no longer renders as a factual zero
(frontend-honesty sweep, follow-on to the CCTP / roster /
/network
incident in docs/operations/v1-launch-plan.md §2.6b). A whole class
of surfaces coalesced a MISSING value — an expensive aggregate the API
honestly omitted on a budget miss, a 503 from an 8s query ceiling, a
build-time transport blip — into ?? 0 / ?? [], then published the
result as an empirical claim about the chain. Absent now renders —
or an explicit "unavailable" affordance; a served zero is still
rendered as `0` / "no X", which is the entire point of the
distinction. Fixed:
- /dexes/{source} + /exchanges/{name}: a /v1/markets 503 claimed
"No pools/pairs found in the last 14 days" (and "0 on this page").
- /exchanges: the CEX pair board is a Promise.all over four venue
fetches — one 503 headlined "0 CEX pairs · No CEX pairs reporting".
- /dexes, /oracles, /aggregators: a failed /v1/sources read
claimed Stellar has no DEXes / no oracles / no aggregators.
- /issuers/{g}, /issuers long-tail shell, and the issuer panel on
every asset page: /v1/issuers/{g} SOFT-FAILS its per-asset fan-out
(error *or* deadline), so absent assets was baking "Assets 0",
"Total observations 0", "Issued assets (0)" and "No issued assets
observed" for issuers with live assets. Unknown first/last-seen
ledgers also rendered as #0, a ledger that cannot exist.
- /assets/{slug} liquidity tab: a bespoke fetcher swallowed 5xx,
429 and its own timeout into [], baking "No DEX pools observed
touching {code}" into the static export.
- /assets/{slug} supply tab: a failed /v1/chart asserted "No
market-cap history for this asset".
- /external/assets/{slug}: any transport failure baked the flat
denial "We don't track an external asset with the slug X"; only an
authoritative 4xx may say that now.
- /lending/{pool}: an empty listing (what the API serves when no
lending reader is wired) baked "Auctions (total): 0".
- /sources/{name}: a null market read baked "0 pairs · No markets
observed for this source".
- /status: an unreachable latency backend rendered "0.0 ms" in green
(a perfect-SLO claim from a missing measurement) and a failed
freshness probe rendered "0 / 0" active sources.
Each fix ships a render test asserting BOTH directions — absent →
—/unavailable, served zero → 0/"no X".
Fixed
- `/v1/accounts/{g}/transactions` 6.7× faster (sub-second audit
2026-08-13, r1-measured): both union arms carried the WIDE tx column
set (memo, result_code, source_account, …) through their own scan and
sort of
stellar.transactions, and the outer DISTINCT then
materialised both. The query now resolves the KEYSET in the union and
hydrates the wide columns once over the surviving ≤limit keys —
1.479s → 0.219s for the same 50 rows, with the cross-arm dedupe now
provided by the hydration pass's LIMIT 1 BY. - `/v1/accounts/{g}/operations` 2.7× faster — same two-phase shape,
and it matters more here:
opCols carries body_xdr, the column the
code itself measures at ~600ms over the 24B-row table, and both arms
were paying it. 0.407s → 0.153s (r1, 50 rows).
- CI integration gate stopped failing on the clock: the suite's
go-test deadline is raised 20m→35m. It hit the ceiling on three
consecutive pushes with the running test 1s in, while the same suite
completes in ~13m locally (CI runners are ~1.5× slower) — a gate that
reports "the clock ran out" as a failure stops being a signal. The
next raise should split the suite by package instead.
- `POST /v1/register` returned keys that could not authenticate
(found in the v0.32.0 post-deploy battery): the mint wrote only the
Postgres MANAGEMENT row, but r1's auth middleware validates against
the REDIS store (
backend=redis), so a freshly registered key 401'd
on first use — a 200 response carrying a dead credential, worse than
an honest failure. The mint now mirrors the same plaintext into the
validator's own store (RedisAPIKeyStore.CreateWithSecret) whenever
that store is wired, and a mirror failure fails the request instead
of handing back a key that cannot work. The agent-onboarding flow is
functional again.
Added
- Passkey (WebAuthn) sign-in for the dashboard: six new
endpoints under
/v1/auth/passkey/ — begin-login /
finish-login (anonymous, usernameless discoverable-credential
flow; finish mints the SAME session cookie the email-code flow
does, via the shared session-mint path), begin-register /
finish-register (session-gated; resident key required so the
credential can sign in usernameless), and credentials
(GET list + DELETE {id}, session-gated, owner-scoped). Server
is github.com/go-webauthn/webauthn v0.17.4; RP ID/origin derive
from the existing api.dashboard.base_url. Ceremony state rides
a 5-minute HMAC-signed HttpOnly cookie (purpose-bound so a
registration challenge can't finish a login); a sign-count
regression (possible cloned authenticator) refuses the login and
logs. Storage is the new webauthn_credentials table (migration
0140, additive). Explorer: "Sign in with a passkey" on /signin
(feature-detected) + a Passkeys list/add/remove card on
/dashboard/settings. OpenAPI paths + all three generated
artifacts refreshed; SDK triage recorded in
uncoveredOperations.
Security
- Dashboard 6-digit sign-in codes are no longer derivable from
the database (parked audit finding, aggregate+dashboardauth
cold audit 2026-08-03): the code was an unkeyed public function
of
magic_link_tokens.token_hash (base32 of its first 4 bytes),
so any Postgres read — SQL injection elsewhere, a stolen backup —
yielded every in-flight sign-in code directly, no brute force
needed, and with it a session for any address the reader could
trigger a login for. The code is now
HMAC-SHA256(server_secret, token_hash) reduced to 6 digits —
same UX, same storage, one derivation swapped; the secret lives
in config/env (api.dashboard.code_secret_env, default
STELLARINDEX_DASHBOARD_CODE_SECRET), never in Postgres. With
the env unset the API falls back to a random per-process secret
(still keyed; in-flight codes just don't survive a restart —
they live 15 minutes and the magic link is unaffected). Deploy
note: codes emailed before the deploy stop verifying for their
remaining TTL; links keep working.
onboarding path**: one unauthenticated POST (empty body fine;
optional name + contact-only email, never verified) creates a
free-tier platform account and mints its first Postgres-backed API
key, returning {account_id, api_key, key_id, key_prefix, tier,
limits} with the plaintext shown once. Rides the same per-IP
signup throttle as /v1/signup (shared budget → 429) and the
signup Content-Type CSRF gate. OpenAPI path + all three generated
artifacts refreshed (docs-api, docs-postman, web-generate-api);
SDK triage recorded in uncoveredOperations; agent-facing
walkthrough at docs/agent-onboarding.md.
Changed
- Tier model collapsed to `anon` / `free` / `partner` (follow-up
to the Stripe removal — the platform is free).
free is every
registered account's default, anchored to the old Starter numbers
(1000 req/min, 1M req/month, 25 keys, 10 webhooks, 25 price
alerts); partner is staff-set per-account limits via the existing
PATCH /v1/admin/accounts/{id} override + key-clamp path, with the
old Enterprise numbers as ceilings when no override is set
(100k req/min, 1B req/month, 250 keys, 100 webhooks, 1000 alerts);
anon documents the unauthenticated 60/min per-IP baseline.
Legacy stored tier strings map in code
(platform.Tier.Canonical: starter→free,
pro/business/enterprise→partner; unknown fails closed to free) and
writes fold back to CHECK-legal strings
(platform.Tier.StorageValue) — migrations untouched. The admin
PATCH accepts both vocabularies and canonicalises.
Removed
- Stripe/billing integration removed — the platform is free
(operator decision 2026-08-10: anonymous access, free accounts,
staff-set partner limits; no payments). Deleted the
POST /v1/webhooks/stripe endpoint (handler + route + OpenAPI path
+ generated artifacts), [api.stripe] config
(STELLARINDEX_STRIPE_WEBHOOK_SECRET), platform.BillingStore /
Subscription / StripeEvent and their Postgres store,
Account.StripeCustomerID + GetByStripeCustomerID, the
stellarindex_stripe_platform_sync_errors_total +
stellarindex_stripe_dead_letters_open metrics with both alert-rule
trees and their runbooks, and paid-plan copy in the explorer
(pricing/signup/company/dashboard now describe free access).
The shared tier-clamp machinery the admin
PATCH /v1/admin/accounts/{id} path uses survives in
internal/api/v1/keybudgets.go (StripeKeyManager →
SelfServiceKeyManager). Migrations are untouched — historical
stripe_* columns/tables stay in place, unused.
Fixed
- Contract WASM view resolves pre-capture contracts ("this
contract's on-chain WASM isn't in the captured ledger window yet",
operator report 2026-08-11): the instance→hash hop now reads the
genesis-complete
contract_instance_changes index first, so any
contract whose instance was ever written resolves its current
executable (or SAC verdict) regardless of the live-capture window;
the code-bytes hop was already lake-complete (r1-measured: all
4,534 contract_code keys present). Legacy read remains the fallback. - Trade USD valuation: divergent-leg cross-check (fake-XMR
incident 2026-08-11): an attacker planted an INDUSX/XLM bridge rate
for the cost of the $0.01 dust floor and two no-XLM-leg trades were
stamped ~$91M each off the poisoned quote-side rate (real value
<$0.01 — a $182M fake spike in the SDEX volume series). The FX tier
now values BOTH legs through the resolver when possible and stores
the SMALLER when they disagree beyond 10× — inflating a print now
requires pumping both legs' markets with real value. The two
poisoned rows re-derive to honest values via the generation-guarded
corrective path.
Fixed
- Detached-refresh gate is now class-fair (inventory #26 item 5,
second half): the single global bound stopped the unauthenticated
scan-amplification but let one key class starve the rest — a crawler
churning fabricated contract ids could hold every slot while cold
account/holders/directory pages fast-503d behind it. Each refresh
class (account state, contract detail, asset holders, contracts
directory) is now additionally capped at half the global limit; the
global pool-safety bound is unchanged.
- /v1/contracts directory census: 40s scans replaced by a day-keyed
rollup (inventory #26 item 2 — the single heaviest explorer read,
~160 runs per 3h across the prewarm rungs). New
stellar.contracts_census_daily (plain per-day per-contract counts;
whole days recomputed and swapped via REPLACE PARTITION — no MV, so
the Summing double-count class cannot arise) maintained by the new
stellarindex-ops ch-census-rollup on a 30-min timer; the reader
sums day rows (sub-second) with a coverage check that falls back to
the exact scan while a backfill is incomplete. Window floors round
to UTC-day resolution. - Aquarius `claim_protocol_fee` now records WHICH token was claimed
(sources-decode audit 2026-08-04, finding 5): the token address lives
in
topic[1] — not the body — and migration 0129 shipped no token
column on the documented premise that a recent trade could resolve
it; the lake refutes that (one tx claims two different tokens with
near-identical amounts), and per-pool SUM(amount) without the token
adds integers of different token scales. FeeEvent gains Token
(decode refuses a claim without it), migration 0139 adds the nullable
column, and the 163 token-less rows already on r1 re-derive via
projector-replay -source aquarius (queued). - DeFindex `harvest` events are now decoded (sources-decode audit
2026-08-04, finding 4): the recognise-and-drop premise ("body never
observed on-chain") was disproved by the lake — 1,018 harvests with
body
{amount, from, price_per_share}, the exact shape decodeFlow
reads by name. Harvests now emit direction='harvest' strategy-flow
rows (migration 0138 widens the CHECK; user-position sums exclude
them by construction — harvest is strategy yield, not a user flow).
Historical recovery via projector-replay -source defindex (queued). - Phoenix pre-upgrade swaps no longer dropped (sources-decode
audit 2026-08-04, finding 1 — HIGH): the pre-upgrade pool WASM
(ledgers 51,019,036 → 53,134,167) emitted 7 field-events per swap —
no "actual received amount" — but
RawSwap.Complete() required that
slot even though decodeSwap deliberately never reads it, so all
5,161 pre-upgrade swaps aged out as orphans (r1-confirmed: zero
phoenix trades before ledger 53,134,242). Aged-out groups whose
decode-consumed slots are present are now decoded at sweep time
instead of orphaned; the current era's eager 8-field emit and orphan
accounting are unchanged. Recovery of the historical rows needs
projector-replay -source phoenix -from 51019036 (queued). - `/v1/contracts/{id}/code-history` cold reads (the last persistent
503 class in the route sweep): new keyed
stellar.contract_instance_changes index — an MV-fed
ReplacingMergeTree holding one narrow row per captured
instance-entry write with the executable verdict pre-extracted via
fixed-offset XDR substrings (byte-verified against go-stellar-sdk
marshalling and against live r1 data). The reader walks the
contract's primary key instead of a scan-shaped key_xdr predicate
over the whole changes log; legacy scan remains the fallback where
the index is absent. Historical fill via the new
stellarindex-ops ch-instance-backfill (windowed, resumable, under
run-heavy-job.sh).
Fixed
- /ledgers table was permanently stuck on "Loading…" — the page
wrapped
LedgersTable (which takes no useSearchParams) in a
vestigial <Suspense fallback={null}>, and the static exporter
emitted that boundary as a never-completing pending template, so
browsers never hydrated or client-rendered the subtree (zero network
activity; the only such boundary on the site — audited all pages).
Wrapper removed. The live-follow refetch on /ledgers + /operations
is also throttled to one per 10s (operations' newest row structurally
trails the ingest-tip stream, so unthrottled it refetched every
close, ~12 req/min per viewer) and no longer fires while the initial
page fetch is in flight.
Fixed
- `/v1/price/stream` no longer interleaves aggregation windows on
one topic (cold audit 2026-08-03, r1-confirmed: three consecutive
price_update events carried window_seconds 300/3600/86400 with
materially different prices, so a client reading value_decimal saw
the price flap three times per tick). The Hub topic key now includes
the window (closed:<asset>/<quote>/<window_seconds>) and the
stream accepts ?window_seconds= (default 300) to pick one series.
Also fixed: subscribing with an alias spelling (?asset=native vs
the aggregator's crypto:XLM) silently matched nothing forever —
the handler now subscribes to every alias spelling of the pair. - `/v1/observations` (+ its stream) now scans every alias spelling
of the pair — CEX observations live under
crypto:XLM while SDEX
legs live under native, so a ?asset=native query was silently
blind to the CEX rows (and vice versa). Alias results merge keeping
the newest trade per source; single-spelling pairs still do one scan. - `/v1/price/stream` events now carry the documented `/v1/price`
envelope shape from BOTH producers (
data + as_of; flags /
sources only when evaluated). Previously the aggregator bridge
emitted {asset, quote, window_seconds, value_decimal, observed_at}
and streampublish emitted {snapshot, sources, stale} — two bespoke
shapes on one endpoint, neither matching the OpenAPI example. No
fabricated flags: an absent flags object means "not evaluated",
never "fresh". as_of is the bucket end, keeping cross-region
payloads byte-identical (ADR-0015).
- `/v1/price/tip/stream` now shares one tip-compute loop per
distinct (asset, quote, window) across all connections via the
streaming Hub, instead of running a private 5-second query loop per
connection (cold audit 2026-08-04: "tip stream = 6 DB queries/s PER
CONNECTION — pool saturates at ~2300 streams"). Steady-state DB cost
now scales with distinct pairs being watched, not with viewer count;
producers linger 30 s after their last subscriber leaves to absorb
reconnects, and Hub resume (
Last-Event-ID) now works on the tip
stream. Per-connection pre-flight verdicts (404 / withheld / 400)
and the instant first frame are unchanged. Hub-less deployments keep
the legacy per-connection producer. This is the scaling precondition
for the explorer's live-ticking pages (RT-1). - The accounts hub's most-held chart no longer includes native XLM —
every funded account holds XLM by definition, so charting it
collapsed the issued-asset bars to slivers.
- CI's web advisory gate: pnpm override floors raised for four fresh
high GHSAs (nanoid, undici, brace-expansion, js-yaml — the prior
js-yaml range
>=4.2.0 <4.3.0 excluded the patched 4.3.1).
Added
- Explorer live ticks (RT-2): a shared SSE multiplexer
(
web/explorer/src/lib/live/) — one refcounted EventSource per
endpoint per tab, slow reopen on hard failure (WB-04) — now powers a
live network heartbeat in the sidebar (latest closed ledger, pulsing,
linking to its /ledgers page) and live tip-price streaming on asset
pages: the headline price ticks in real time with an up/down flash
and a "live tip price · streaming" caption, degrading to the
existing 60s poll + baked value when the stream is unavailable.
The home strip's XLM cell and the market-pair headline (previously
a BUILD-frozen price captioned "as of <build time>") stream the
same way, and /ledgers + /operations follow the network live: new
rows animate in on every ledger close while page 1 is on screen
(paging into history pauses following). The markets board follows
ledger closes with a throttled refresh and per-cell price flashes —
one SSE connection for the whole table. Animations respect
prefers-reduced-motion. - Contract activity card (insight program unit 1): every contract
page shows first-seen / last-seen ledgers, lifetime active-ledger
count, and a 30-day activity sparkline — µs reads off the
contract-keyed
contract_active_ledgers index.
Added
- Accounts hub analytics (operator request 2026-08-08; API 1.19.0+
/v1/accounts/stats): network totals (funded accounts, trustlines,
XLM held), balance statistics (avg/median/p90/p99 — stroops as
strings, ADR-0003), top-100 concentration, a log10 wealth-
distribution histogram, trustlines-per-account bands, and the
most-held assets board — all computed by the same 30-minute
ch-holders-rollup cycle (the analytics ride the scans the holders
boards already pay for) and served from keyed tables, sub-second by
construction. The /accounts page grows a stat strip, two
distribution charts, and the most-held board above the wealth
directory.
Added
- Sub-second holders boards (inventory #4):
ch-holders-rollup +
30-min timer precompute every asset's top-500 holders + count into
exchange-swapped rollup tables; /v1/assets/{id}/holders serves
keyed reads with a probed fallback to the legacy scans. Part of the
sub-second page goal (2026-08-08); scripts/ops/subsecond-audit.py
is the goal's acceptance harness.
Fixed
- Saturation rejects no longer claim to be timeouts (inventory #5).
/v1/readyz single-flight + 1s cache — unauthenticated spam can no
longer exhaust the DB pool through the checkers (inventory #26).- Migration 0137 disarms the comet replay double-count (inventory
#19); REQUIRES
projector-replay -source comet -from 51499000
after deploy.
Added
- The movements feed covers every asset again — inventory #1.
stellarindex-ops ch-cap67-movements derives post-P23 account
movements for ALL assets (native XLM included — its SAC is
deliberately excluded from the Postgres watched-token projection)
straight from the lake's CAP-67 transfer events into the ClickHouse
movement archive (provenance cap67_derived), windowed + resumable
via a watermark, kept following the tip by a 5-minute timer. The
/v1/accounts/{g}/movements merge boundary is now DYNAMIC: the
ClickHouse arm serves everything at/below the watermark, the
Postgres tail only above it — gap-free and double-count-free at any
derive progress — and the coverage note states the live scope
("complete for all assets through ledger N") instead of the static
P23 disclosure. Closes the "history stops 340 days ago" class for
classic-payment accounts.
Fixed
- main CI is green again (the ci-health email flood): explorer CI
Node 20→22 (vitest/undici), web/status audit advisories fixed via
overrides, a mid-test miniredis close race made deterministic, the
cross-check convergence test reworked to the demoted-leg contract,
two fixture bugs (63-char tx hashes; a hardcoded day that aged out
of a now-relative read window), and a fake-timer test that native
AbortSignal.timeout ignores on Node 22 switched to short real
timers.
Fixed
- Account pages stop dying on compressed history (the GATL report).
Three per-account Postgres reads walked chunks that have no
per-account index once compressed:
/accounts/{g}/trades burned 8s
proving even a zero-trade account empty (16.4M buffers), the
movements post-P23 tail used the from OR to shape and
statement-timed-out for busy accounts (so /movements silently
showed only the pre-P23 archive — "history stops 340d ago"), and the
activity trades count decompress-scanned all 248 compressed chunks.
The movements tail is now the two-arm UNION shape; trades list +
count are bounded to the compression horizon and SAY SO
(note/trades_total_since) instead of serving a silently-partial
"all time" answer. - The movements feed discloses its post-P23 scope. After the P23
boundary the feed carries watched Soroban/SAC tokens only — classic
XLM payment history after 2025-09-03 is not served there yet (the
lake captures it; the all-asset archive is inventory item #1). The
coverage note now states this on every response rather than letting
a busy XLM account's feed masquerade as complete.
Added
docs/operations/open-fixes-inventory-2026-08-08.md — the complete
deduplicated register of sidelined findings (35 items, three tiers),
compiled on operator request; items leave it only when deployed +
verified.
Added
- `stellar.contract_active_ledgers` — the quiet-contract index.
Cold
/v1/contracts/{id} for a low-activity contract scanned the
whole events key range backwards (avg 9.3s / 114M rows — the
inverse of the v0.26.1 busy-contract pathology; three user-reported
503s). New per-(contract, ledger) activity index
(deploy/clickhouse/contract_active_ledgers.sql, ~20-40 GiB vs
~800 GiB for a per-event index): the reader walks the contract's
recent active ledgers by primary key (µs) and bounds the events
read to them — lossless for the page and its cursor, and an empty
walk is an authoritative "no events". Deliberately count-free so
re-derives/replays can't double-count (the migration-0059 class);
kept current by a materialized view, with
stellarindex-ops ch-contract-ledgers-backfill (windowed,
resumable) covering history. Reader falls back to the unbounded
walk when the index is absent or empty (requireRows probe — an
MV-drop must not read as "no contract has events").
Fixed
- Capacity errors never render as 500.
/accounts/{g}/activity
served "detached refresh capacity saturated" as a 500 Internal
error, and /operations did the same with driver-level ClickHouse
i/o timeouts. All 14 explorer handler sites now classify through
one retryableColdMiss (extended to net.Error timeouts) → the
truthful retryable 503.
Fixed
- Busy-contract pages no longer 503.
/v1/contracts/{id} for a
high-activity contract (the Soroswap router has 17.9M events) blew
the 8s explorer read budget on every cold read: the query's
LIMIT 1 BY dedup clause disabled ClickHouse's reverse
read-in-order early exit, turning "most recent 100 events" into a
sort over every event the contract ever emitted — measured 16.3s
vs 0.16s (100×) with the clause removed. The W4-storage-1
duplicate-part dedup is preserved: duplicates are adjacent under
the full ORDER BY tuple, so the reader collapses them in Go over a
100-row over-fetch, falling back to the in-ClickHouse dedup shape
in the (never-observed) case a duplicate storm exhausts the
headroom.
Added
- Curated address labels from the StellarExpert public directory
(API 1.19.0). The MIT-licensed
stellar-expert/public-directory
set (~18.5k well-known G-account and C-contract addresses: names,
domains, tags like
exchange/sdf/custodian/malicious) is
mirrored into a new account_directory table (migration 0136) by
stellarindex-ops directory-sync — one tarball fetch, transactional
upsert + prune, empty-parse refusal so a broken fetch can never wipe
the set. /v1/accounts/{g} and /v1/contracts/{id} gain a
directory object, and GET /v1/directory?addresses=… batch-resolves
up to 100 addresses for list decoration. Display attribution only:
listing is not endorsement, nothing feeds verification or the in-repo
scam list, and malicious/unsafe render as warnings.
Fixed
- Issuer home-domain precedence: the account's own on-chain
`home_domain` field now outranks the hand-curated known-issuers
map on
/v1/assets/{id}, /v1/assets/{id}/metadata, and
/v1/issuers/{g}. Founding case: the ex-apay ETH issuer
(GBFXOHVAS…SOCC) rendered home_domain=apay.io with
sep1_status=verified — a dead anchor's identity, verified against
the wrong domain — while the issuer's live on-chain field has said
ultracapital.xyz since Ultra Stellar acquired apay.io's wrapped
assets. A 2026-08-06 sweep found 8 of 27 curated entries diverged
from chain; the flagrantly wrong ones (both apay.io accounts →
ultracapital.xyz, Circle's centre.io → circle.com for USDC
and EURC, a USDx issuer mis-attributed to Mykobo → FxDAO) are also
corrected in the map itself, which remains only a last-resort
fallback for issuers with no on-chain domain. - S-010 identity suppression can no longer be undone by the
account-state enrich on
/v1/issuers/{g}: suppression for a
flagged, unverified issuer now runs after all enrichment, so the
scammer's self-declared on-chain home_domain (the impersonation
itself, e.g. lobstr.co) is never refilled into the cleared field.
Auth flags still populate — they are objective account state, not
identity claims. - The explorer no longer renders scientific notation for prices
—
$3.353e-4 now renders $0.0003353 (plain decimal,
significant-digits tail, trailing zeros trimmed) via one shared
formatSubunitPrice, replacing the two shared formatters' exponent
branches and five inline copies. - `anomaly_freeze_sustained` pages only on ESCALATION (both rule
trees): the old shape paged whenever any pair freeze-cycled for an
hour, including fire→extend→auto-release cycles resolving exactly
as ADR-0019 designs. Engaged/active stay ticket/informational.
- `metrics_registry_absent` excludes `component="ledgerstream"` —
the documented exception (archive-only deployments) that kept the
alert permanently red on r1.
Changed
- The partial-wrap supply cross-check's over-mint leg is
diagnostic-only. Its premise — cumulative SAC net mint ≤ current
classic outstanding — only holds for one-way wraps, and two live
assets falsified it: BLND retires supply classically after SAC
minting (no SAC burn fires on a classic payment to the issuer), and
PHO minted its entire 200M supply through the SAC once while classic
outstanding is issuer-excluded. Both paged for a week with every
unit accounted for. The over-mint excess stays computed and reported
(
OverMintStroops); the paging divergence is now leg 2 alone —
escrow ≤ net mint, the bound that is impossible to breach under
correct accounting for a partial wrap.
Fixed
- A freeze can no longer escalate to operator-only without a single
scored evaluation. During the ~30-minute post-restart confidence
bootstrap every bucket is unscored, so a freeze rehydrated across a
deploy could never accumulate an unfreeze streak while its hold
expiries still burned extensions — it marched deterministically to
ESCALATED (live occurrence: crypto:XLM/fiat:GBP 24h escalated
entirely inside the v0.25.0 restart window). An unscored expiry now
slides the hold without consuming an extension
(
TransitionHeldUnscored); ADR-0019's 2-hour escalation budget
thereby counts two hours of SCORED asking. Escalated freezes still
never auto-unfreeze. - The permanently-firing `dex_nonstandard_decimals_detected` alert
was removed — it compared an all-time counter to zero, so once any
nonstandard token traded it fired forever (three already-confirmed,
auto-normalized aquarius tokens held it active). The awareness
signal lives on the dashboard metric; the actionable failure mode
keeps its own
correction_failing ticket alert.
Changed
- Classic-asset slugs are now the fully-qualified asset_id
(migration 0135, superseding 0134's abbreviation one day later).
An 8-character issuer prefix is a ~2^32 vanity-grind — minutes on
consumer GPUs, the same economics dust-attack address mimicry
already exploits — and the abbreviation bought nothing but URL
length. The full form is self-certifying (the URL IS the identity,
nothing to grind or front-run) and deletes the writer's whole
collision apparatus, since the slug equals the primary key's value.
Slug resolution on
/v1/assets/{id} is now effectively a
case-insensitive canonical-id match. Verified-currency slugs
(usdc, aqua) are unaffected — that's the catalogue namespace. - Explorer HTML now ships `Cache-Control: public, max-age=0,
must-revalidate` — a zone-level cache rule was edge-caching page
HTML for 7 days, so users saw day-old baked prices and pre-redesign
chart code under three successive deploys. Fingerprinted assets
keep their immutable year. (The zone rule itself is
dashboard-managed and needs the operator: respect-origin + one
purge.)
- **The
/assets/* runtime shell carries generic metadata** ("Asset ·
Stellar Index") instead of baking the literal "shell" title, and the
client view restamps document.title once the real asset loads.
Added
- `/v1/assets/{id}` resolves classic-asset slugs —
usdt-gcqtgzqq
now serves the same detail as USDT-G…. Completes migration 0134:
the explorer links every classic asset by its public slug, and
before this those URLs were only resolvable through the explorer's
own build-time cache, so any page not baked at build time 404'd
(live report: /assets/usdt-gasu4kif). Optional reader capability,
forwarded through the cache wrapper explicitly. - Asset-page headline prices are now LIVE (
LiveAssetPrice): the
static build still bakes an initial value, but the browser
re-fetches /v1/price on mount and every 60s — a 12-day-old deploy
was showing XLM at $0.186 against a live $0.17 chart. Un-refreshed
values are captioned "as baked at deploy"; a price-withheld
verdict replaces any baked number rather than leaving a lower-trust
snapshot on screen.
Changed
- Every asset price chart is anchored to USDC — never XLM (operator
decision). The XLM quote toggle is gone: an XLM-quoted chart
re-denominates the asset in a floating unit, and XLM-quoted thin
markets were the manipulated visuals of the 2026-08-04 incident.
USDC itself charts against
fiat:USD so a depeg is visible; native
XLM and fiat assets chart against the CEX-fed USD series. - Chart price precision is now adaptive — the axis scales its
decimals to the series magnitude (XLM renders ~6 decimals instead of
a flat "$0.17"; sub-cent assets no longer flatline at "$0.00").
Showing the 40 most recent of 209 releases. Full changelog →