Skip to content

Stellar Index v0.21.9

Choose a tag to compare

@github-actions github-actions released this 01 Aug 03:00

[v0.21.9] — 2026-08-01

Fixed

  • Carried-claim invalidation gap: a projector-replay below the watermark
    now forces re-reconciliation
    (migration 0125 +
    internal/ops/ingest/projector.go + internal/ops/chops/compute_completeness.go).
    The daily completeness driver reconciles each source only from its prior
    watermark to tip and CARRIES the prior clean projection claim for the older
    prefix (projectionClaim rule 3 / INV-5) — sound only while the served tier
    below the watermark is immutable. A projector-replay rewind rewrites
    exactly that region, and nothing forced a re-check: the carried claim kept
    certifying rows the replay had changed, which is how the 07-30 cctp
    replay's 19,366 over-projected event_index-0 twin rows (62.27M–63.55M)
    escaped the verifier. projector-replay now records a per-source dirty
    window (projection_dirty_windows, widened LEAST/GREATEST on overlap)
    BEFORE rewinding the cursor — refusing to rewind if the record fails —
    projected-rebuild -write records the same window before its first write
    (same invalidation, bulk path), and
    compute-completeness fails closed on reading the table, extends its
    projection reconcile floor over any pending window regardless of -from,
    and clears the window only after a CLEAN projection verdict whose scope
    covered it (bounded delete, race-safe against a concurrent widening replay).
  • cctp + rozo reconciliation-catalogue genesis floors corrected to the
    lake-derived exact first events
    (internal/ops/chops/reconciliation_catalogue.go),
    mirroring the 07-30 protocols_registry.go fix: cctp 62_403_000 →
    62_146_641 (410 real served rows sat permanently BELOW the verify floor,
    structurally out of every verdict), rozo 62_403_000 → 60_829_397. All other
    catalogue entries audited against the registry — no further drift.
    (DefaultGapDetectorTargets in per_source_gaps.go still carries the old
    floors — supporting signal only, owned by another change.)
  • cctp bespoke reader post-twin-deletion cleanup
    (internal/storage/timescale/protocol_bespoke_cctp.go). The flow CTEs
    collapsed per (tx_hash, op_index) with max(amount) — a workaround for the
    legacy event_index-0 twin rows deleted from r1 on 2026-07-31 — which would
    silently HALVE a future genuine batched double-transfer in one op (the 0112
    class: same-op same-type groups are proven on the wire by admin events).
    Value-carrying CTEs (deposit_for_burn / mint_and_withdraw) now read raw
    per-event rows; the message_received CTE keeps its one-body-per-op group
    (join semantics — a second body row would fan out every joined mint row),
    and the mint_and_forward restatement exclusion (a real semantic rule, not
    twin dedup) is untouched. Also: the all-time "Unique recipients" KPI now
    counts DISTINCT recipient addresses as labelled — it previously counted
    recipient-bearing ops.
  • Explorer honesty wave — cold-audit findings across web/explorer/src.
    Eight fabrication/robustness classes fixed in the explorer only (no
    API/server changes): (1) /lending pools table: an API failure now
    renders an explicit unavailable state instead of the fabricated
    "No Blend pools have emitted auction events yet" empty-state, and
    PoolRealStats (hoisted to module scope — it was re-created every
    parent render) computes TVL/utilization/weighted APRs over PRICED
    reserves only with the basis labelled, instead of coercing absent
    supplied_usd/borrowed_usd/APRs to 0 inside sums and weighted
    averages; (2) partial trailing daily bucket: a new shared
    @/lib/series helper (dropPartialTrailingDay) drops today's
    accumulating UTC bucket from every daily-grain chart mapper
    (BespokeSection standalone + grouped panels, BridgeShowcase lines,
    ProtocolView activity, SourceVolumeHistory, SdexVolumeSection;
    hourly 24h series keep the live day) and TimeSeriesChart headlines
    "Latest" from the last COMPLETE point; (3) /network
    protocol-upgrade markers now require BOTH sides of a step to be a
    real (>0) version — lake sentinel rows minted phantom "protocol v0/vN"
    markers (the field is a uint32 without omitempty, so null-guards never
    fired); (4) /anomalies freeze durations render "—" instead of
    "NaNd" for missing/garbage timestamps; (5) holder balances render via
    a BigInt-divide-first formatBaseUnits/scaleBaseUnits
    (@/lib/format, ADR-0003) instead of Number() on >2^53
    smallest-unit strings (absent → "—", not NaN); SupplyFlowsBar shares
    the same scaler; (6) toChartNumber returns null — the point is
    dropped — for non-numeric series values (previously plotted as
    fabricated 0s) and for compact-suffixed figures ("1.2M" previously
    mis-scaled 10^6× to 1.2); (7) non-parsable point dates are dropped
    before setData instead of the epoch-0 fallback that plotted 1970
    points and could crash lightweight-charts on duplicate time:0;
    (8) small honesty items: SdexVolumeSection renders a served 1-point
    series as the point ("insufficient history"), not a false
    "unavailable"; the anomalies ReasonHeatmap derives its day columns
    from the served data's date range, not the client clock; a bespoke
    block's notes no longer caption other windows' data after a failed
    window refetch.
  • Daily-grain protocol/bespoke series exclude the current (partial)
    UTC day server-side.
    Every bespoke daily series (DEX activity/
    traders/top-pairs, lending sides/backstop/auctions/per-pool, credit,
    oracle, defindex, CCTP flows + cumulative net inflow, rozo) and the
    protocol activity series/breakdown included today's still-accumulating
    bucket with no marker — the UXP-16 "phantom cliff" class, previously
    fixed only on /network. Daily-grain SQL now bounds at the day start
    (shared completeDaysOnly fragment; ClickHouse readers bound
    close_time/day likewise — the event breakdown shares the bound so
    sum(event_breakdown) == events_total still reconciles). The 24h
    window's hourly grain keeps its live edge.
  • Three small honesty guards (audit 2026-07-31). (a) The SDEX
    order-book reader no longer silently skips a non-removed offer change
    whose XDR fails to decode — each skip freezes that key's prior served
    state, so it now logs a warn line and increments the new
    stellarindex_sdex_orderbook_undecodable_offers_total counter.
    (b) mapFreezeReason no longer defaults unrecognized automated
    freeze decisions to manual (the operator-initiated reason) — they
    record as the new other reason (migration 0124 extends the CHECK;
    spec 1.18.0 adds the enum value).
    (c) A soroswap swap with all FOUR amounts non-zero satisfied both
    direction arms and silently decoded as 0→1, dropping the 1→0 leg —
    now refused whole as ErrAmbiguousSwapDirection (wraps
    ErrNonDirectionalSwap, so it stays a recognized no-op for ADR-0033
    completeness).
  • Blend bespoke block no longer sums token amounts across assets.
    The headline "Net supplied/borrowed" KPIs and the per-pool table's
    cross-asset sums + "Util %" ratio added token_amount across tokens
    with different decimals — a meaningless number under an authoritative
    label (the sibling count-first suite in bespoke_lending.go documents
    exactly why). KPIs are now count-first (active users, flash loans,
    alongside the existing side-event/pool counts), the per-pool table
    serves supply/borrow-side event + user counts, and amount sums survive
    only where scoped to a single asset (the per-asset table).
  • Stale "soroswap has no taker" claims removed from the wire. The
    soroswap decoder captures the SwapEvent to recipient since
    2026-07-30 (100% taker coverage on new rows), but the
    /v1/accounts/{g}/trades scope note still excluded soroswap and the
    DEX bespoke block carried a hard-coded "0% taker coverage" note. The
    scope note now includes soroswap (with the pre-2026-07-30 caveat) and
    the bespoke omission note is data-driven — emitted only when the
    served window observably has zero taker-stamped trades.
  • usd_volume no longer hard-codes the off-chain 1e8 amount scale.
    The FX pollers stamp trades at 1e6 (registry AmountDecimals: 6),
    but both the insert-path stamper (tradeUSDVolume/tradeUSDVolumeViaFX)
    and ClassifyUSDVolumeTier (the verify-usd-volume checker, which
    would have certified the error) divided by a hard-coded 1e8 — a latent
    100× under-valuation the moment FX trades re-enable. Both now consult
    the source's registered AmountScaleDecimals (CS-040).
  • Explorer detached SWR refreshes are now globally bounded. The
    stale-while-revalidate caches (account state, asset holders, contracts
    directory, contract detail) single-flighted per key but were unbounded
    ACROSS keys — and the key space is attacker-chosen on unauthenticated
    routes, so churning fabricated G-/C-addresses queued one detached
    multi-minute lake scan per key onto the 8-connection serving pool. A
    shared semaphore (clickhouse.RefreshGate, limit 4 — half the pool)
    now bounds all detached refreshes; on saturation the refresh is
    skipped, never queued: stale entries keep serving, cold keys 503 with
    the retryable timeout contract.
  • /v1/assets/{asset_id}/holders normalizes the asset spelling before
    the cache and the lake query.
    ParseAsset admits the Horizon
    CODE:ISSUER spelling, but the raw request string was used as both
    the SWR cache key and the query asset — the colon spelling scanned
    for a value the lake never stores and cached an authoritative-looking
    empty board under a duplicate key. Both spellings now share one
    canonical (CODE-ISSUER) cache entry and query.
  • Protocol-detail cache: a degraded fast-failing rebuild can no longer
    displace a good cached entry.
    The keep-old-entry guard only checked
    the rebuild context's timeout, so a build that failed FAST (ClickHouse
    down → every enrich errors in ms) replaced the previously-good view
    with an analytics-empty one stamped fresh — one prewarm sweep during a
    store outage would blank every protocol page. A non-ok build now only
    populates cold/degraded keys (registry-only beats 503) and never
    displaces a healthy entry, which stale-serves instead.
  • Protocol daily-pre-aggregation probe no longer latches a transient
    error for the process lifetime.
    The fast-activity availability probe
    was cached with a sync.Once, so one ClickHouse blip at first build
    pinned the raw 12B-row scans forever. Per the schemaProbe precedent
    (C1-048) only DEFINITIVE answers are cached (table missing = false,
    rows found = true); transient errors degrade that call and re-probe.
  • Protocol analytics window integrity: one shared tip read + fast/raw
    decision.
    Each analytics fill re-read the lake tip discarding the
    error — a failed read (tip=0) made the fast path serve a 1-day window
    still labeled 90d — and the three concurrent fills could split across
    the fast/raw sources, breaking the sum(event_breakdown)==events_total
    reconcile. The build now derives one plan from a single checked tip
    read; an unreadable tip degrades the analytics honestly (status
    unavailable).

Security

  • Redstone attribution chain hardened end-to-end (OpArgs provenance +
    cross-corroboration; cold-audit wave 2026-07-31).
    The dispatcher
    attached an op's top-level InvokeContract args to EVERY event the op
    produced — including events emitted by OTHER contracts reached as
    sub-invocations — so a wrapper contract could call
    adapter.write_prices with the genuine signed payload while its own
    attacker-chosen top-level args supplied the feed_ids redstone zips
    prices onto (feed-attribution steering). Defense in depth, all layers
    refusal-first (honest-blind beats misattributed):
    • Provenance gate (dispatcher + lake twin): args now attach only
      when the op's invoked contract IS the event's own contract
      (internal/dispatcher/dispatcher.go;
      internal/storage/clickhouse/extract.go applies the identical rule
      at lake-write time, so re-derives stay in parity). Wrapper-invoked
      adapter events arrive with no args and refuse cleanly via
      ErrMissingOpArgs, counted through the per-source decode-error
      counter.
    • Body↔args binding: the event body's updater must equal the
      op-args updater (ErrUpdaterMismatch), and duplicate feed_ids
      are refused outright (ErrDuplicateFeedIDs) — a genuine
      write_prices cannot carry them (redstone-core Config::try_new
      rejects ConfigReoccurringFeedId before the adapter can emit), and
      pre-refusal they were a subset-arity inflation lever (audit F2);
      subsetFromStateWrites additionally counts each written feed once.
    • Equal-arity corroboration: when the op's value-changed
      state-write keys are plumbed, even the equal-arity positional zip
      is cross-checked — the changed feed-key set must equal the
      feed_ids set (order-insensitive), else the whole event refuses
      (ErrStateWriteFeedMismatch).
    • The residual external assumption (redstone-core signer filtering is
      not vendored; disagreement lands in the honest-blind class, never a
      misattribution) is documented as an accepted risk in
      internal/sources/redstone/payload.go.

Fixed

  • CH state-write reader: parse failure now excludes the KEY, not the
    row.
    An unparseable entry_xdr in
    internal/storage/clickhouse/state_write_keys.go dropped only that
    ROW, so a lost pre-image promoted an identical-value rewrite
    (adapter-REJECTED feed) to "changed" — violating the documented
    "parse failure excludes the key" rule the dispatcher twin already
    enforced. The key is now poisoned via a per-key bad-set, mirroring
    the dispatcher.
  • CH state-write reader: ReplacingMergeTree dedup keeps the LATEST
    version.
    Duplicate un-merged rows per (op, change_index) were
    deduped first-read-wins; ReplacingMergeTree(ingested_at) semantics
    say highest version wins, so a re-ingested correction could lose to
    its stale predecessor depending on read order. The lookup now selects
    ingested_at and keeps the max-version row.

Changed

  • State-write enrichment is lazy on the ingest hot path. The
    dispatcher computed every event-bearing op's value-changed
    contract-data keys unconditionally; it now resolves them once per op
    and only for events whose contract a registered decoder declared via
    the new optional dispatcher.StateWriteKeyConsumer interface
    (redstone is the only current implementer).