Skip to content

Commit feb9191

Browse files
committed
docs: record the reindex-path under-application investigation and its negative result
1 parent dc35c62 commit feb9191

1 file changed

Lines changed: 164 additions & 0 deletions

File tree

docs/internal/audits/asset-holdings-negative-balances.md

Lines changed: 164 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -255,3 +255,167 @@ Note for whoever picks up (1): `find_value_contracts` is **not defined anywhere
255255
this repo** — a repo-wide search matches only this doc and the reference to it in
256256
`subgraphs/asset-holdings.ts`'s header comment. It appears to live in a separate
257257
consumer (audit-sentinel); look there, not here.
258+
259+
---
260+
261+
## Addendum — 2026-08-04, post-replay-guard reindex-path investigation
262+
263+
> Read-only forensic follow-up. The live-path replay guard (f069, see
264+
> `live-path-replay-guard.test.ts` and `catchup.ts`'s per-iteration leadership
265+
> check) has since shipped, and a clean ~6h47m from-genesis reindex completed
266+
> 2026-08-03 03:07→09:54 UTC (`subgraph_operations` id `fae75ad1`). This
267+
> addendum re-scopes to whether the REINDEX path itself (as opposed to the
268+
> since-fixed catch-up replay defect) under-applies events, using
269+
> cursor-bounded per-row reconciliation instead of the superseded fleet-average
270+
> / per-holder-`k` methods above. Prod access was `SELECT`-only plus read-only
271+
> HTTPS GETs against the public Index API throughout; nothing was written,
272+
> redeployed, or reindexed against prod. Local docker Postgres was used for the
273+
> one write-capable reproduction attempt (Step 3).
274+
275+
### Correction: negative-row count oscillation is normal, not a signal
276+
277+
The row count at `amount < 0` moved 50→51→52→51 across this investigation as
278+
live rows crossed zero in both directions. **This is expected** — the table
279+
keeps updating live. The only valid reconciliation method is **cursor-bounded**:
280+
snapshot the subgraph's `last_processed_block`, then bound every chain-plane
281+
query to `block_height <= that height`. Unbounded or differently-timed
282+
snapshots are not comparable to each other and produce apparent "drift" that
283+
is really just measurement skew. All numbers below are bounded to cursor
284+
`8,699,193` (captured once, then reused for every query in this pass).
285+
286+
### Step 1 — cursor-bounded reconciliation, full negative set + positive sample
287+
288+
All 52 negative rows present at the time of the snapshot (49 `ft`, 3 `stx`)
289+
were reconciled against `decoded_events` (`canonical = true`, event types
290+
matching the deployed handler: `ft_transfer`/`ft_mint`/`ft_burn` or
291+
`stx_transfer`/`stx_mint`/`stx_burn`), credits split by `recipient`, debits by
292+
`sender`, both bounded to `block_height <= 8699193`.
293+
294+
**Result: 2 of 52 reconcile exactly (both `stx`) — the same two rows Part B
295+
found genuinely, correctly negative** (`univ2-pool-v1_0_0-0173`: stored
296+
`-29,952,162` = chain net exactly; `hilt`: stored `-22,178,700` = chain net
297+
exactly). **The other 50 (49 `ft` + 1 new `stx` row, `vgld-vault-v4`,
298+
deficit −17,790,183) do not reconcile** — every one is missing chain-plane
299+
volume the accumulator should have applied (`stored < chain_net` in every
300+
failing case; never the reverse in this set). This generalizes the two
301+
priority cases from the plan (`gl-fees-bank`/sbtc-token, deficit −3,699 on a
302+
net of 0; `alex-vault-v1-1`/mega, deficit −6,425 on a net of +201) to the
303+
full 50-row set.
304+
305+
A **35-row random positive sample** (9 `stx`, 26 `ft`) reconciled the same
306+
way: **34 of 35 exact, 1 of 35 fails**
307+
`univ2-farming-distributor-1-v1_0_0-0009` / `wen-nakamoto-stxcity::WEN`,
308+
stored `7,319,861,717,284` vs chain net `7,308,750,932,822`, deficit
309+
`11,110,784,462` (same under-application shape as the negatives, ~0.15% of
310+
gross volume — much smaller in relative terms than the worst negatives).
311+
312+
**Blast-radius verdict**: ~96% of currently-negative rows are wrong, ~3% of a
313+
random positive sample is wrong. This is a **materially smaller blast radius**
314+
than f068's "every unbalanced live-active holder, hundreds to thousands of
315+
rows" finding — because that finding was about the pre-f069 catch-up replay
316+
defect, which is now fixed. What remains post-fix is a real but much
317+
smaller-magnitude under-application, concentrated in (but not limited to) the
318+
negative rows.
319+
320+
**Deficit-bearing block ranges** (from `GREATEST(max sender block, max
321+
recipient block)` per failing key, bounded to cursor): one genuinely dense
322+
cluster — 17 of the 49 failing `ft` rows are all `SP3K8BC0PPEVCV7NZ6QSRWPQ2JE9E5B6N3PA0KBR9.alex-vault*`
323+
relationships with last activity between blocks **148,315 and 154,976**
324+
(and, per the full per-holder event-height list pulled for `mega`, real
325+
activity densely populating **133,776–150,373**) — plus the two priority-case
326+
clusters at block **8,465,501** (`gl-fees-bank` / sbtc-token *and* usdh-token,
327+
same block) and a long tail of isolated single-holder deficits scattered
328+
across the whole chain (296k, 398k–433k, 510k, 599k, 644k, 1.18M, 1.6M–1.61M,
329+
2.13M, 2.88M, 8.24M, 8.30M, 8.39M, 8.66M–8.67M). The scattered tail does not
330+
cluster into a small number of incident windows the way the f068 catch-up
331+
burst did — it looks like a low, steady background rate rather than one
332+
episode.
333+
334+
### Step 2 — SDK-consumer comparison: clean
335+
336+
A minimal consumer built ONLY on `@secondlayer/sdk` (`Index.events.walk`,
337+
hitting the public `https://api.secondlayer.tools` Index API — no
338+
`packages/subgraphs` runtime code in the path) reconstructed both priority
339+
cases' full credit/debit histories:
340+
341+
```
342+
mega / alex-vault-v1-1: credits=40,507,496 debits=40,507,295 net=+201
343+
gl-fees-bank / sbtc-token: credits=1,754,308 debits=1,754,308 net=0
344+
```
345+
346+
Both figures are **byte-identical** to the `decoded_events` SQL reconciliation
347+
above, and to Part B's original numbers. **Verdict: the SDK read path is
348+
correct; `holdings` (the subgraph accumulator) is what's wrong.** This is a
349+
clean, unambiguous pass — no STOP condition. The defect, whatever it is,
350+
is confined to the subgraph-runtime write path; SDK-based consumers reading
351+
the same Index API are unaffected.
352+
353+
### Step 3 — reindex-path reproduction: discard counter never fired
354+
355+
`PublicApiBlockSource.loadBlockRange` (`block-source.ts:226-274`) was
356+
instrumented (locally, uncommitted, reverted after this pass) to count events
357+
whose `block_height` was absent from the blocks-walk-seeded map — the exact
358+
mechanism the leading hypothesis named. Reproduction ran against prod's real
359+
public Index API (read-only GETs; no prod writes) with local docker Postgres
360+
as the write target:
361+
362+
1. A real `reindexSubgraph` call (not a synthetic per-block harness) over
363+
blocks 140,000–142,999 — the real function, real HTTP walks, real local
364+
DB writes, `SUBGRAPH_SOURCE=streams-index` — processed ~2,000 contiguous
365+
blocks with **zero discards**.
366+
2. A targeted sample of 40 exact block heights spanning the *entire*
367+
`mega`/`alex-vault-v1-1` relationship's history (133,776→150,373) and 59
368+
exact heights spanning the entire `gl-fees-bank` sbtc-token+usdh-token
369+
relationship's history (857,750→8,465,501) were each probed via
370+
`loadBlockRange(h, h)`**zero discards** across all 99 heights.
371+
372+
**The discard counter never fired.** Per the plan's STOP condition, this
373+
hypothesis is **not supported by empirical evidence** — at least not under
374+
current chain/API conditions, replaying via a fresh, low-concurrency local
375+
process. This does not rule out a historical, timing/concurrency-dependent
376+
occurrence during the original production reindex (a reorg landing between
377+
the concurrent blocks/events walks, the leading candidate) that isn't
378+
reproducible from a quiet replay after the fact — but no positive evidence
379+
for it was found either.
380+
381+
**Pivot per the plan: `FallbackBlockSource` log check.** Read-only
382+
`docker logs secondlayer-subgraph-processor-1` was checked for "fallback" /
383+
"using DB tap" warnings during the reindex window (2026-08-03 03:07–09:54
384+
UTC). **Inconclusive, not clean**: the container was restarted 2026-08-04
385+
02:40:39 UTC (log history starts there), so no logs from the reindex window
386+
exist to check. This is unmeasured, not ruled out.
387+
388+
### Step 4/5 — no fix
389+
390+
Per the STOP condition (discard counter never fired), **no fix was
391+
implemented**. The instrumented `block-source.ts` was reverted to its
392+
committed state; no other file in `packages/subgraphs` was changed. No
393+
regression test was added, since there is no confirmed mechanism to encode
394+
one against.
395+
396+
### Where this leaves things
397+
398+
- The pre-f069 catch-up replay defect is fixed and its blast radius (hundreds
399+
to thousands of rows) no longer applies.
400+
- A smaller, real under-application defect remains: ~96% of currently-negative
401+
rows and ~3% of a random positive sample. Its mechanism is **not** the
402+
`loadBlockRange` blocks/events walk-disagreement hypothesis — that was
403+
tested directly and did not reproduce.
404+
- Two structural facts from the earlier (superseded) investigation still
405+
stand and are worth re-reading before the next attempt: (a) the reindex
406+
era's own `blocks_processed` coverage was 95.2% of its range — consistent
407+
with sparse early-chain history, not evidence of loss on its own; (b) the
408+
per-height retry-or-halt logic in both `reindex.ts:289-308` and
409+
`catchup.ts:302-320` means a height *entirely* missing from the blocks walk
410+
either self-heals (single-height refetch succeeds) or fails loudly
411+
(`subgraph_gaps` row or a halted reindex) — neither of which matches the
412+
silent, gap-free deficits found here. That structural argument, plus this
413+
addendum's empirical null result, together make the `loadBlockRange`
414+
map-seeding gap an unlikely explanation going forward, not just an
415+
unreproduced one this session.
416+
- Genuine next steps, not attempted here: instrument the reindex *write* path
417+
(`ctx.increment` / `context.ts`'s batching) rather than the read path, since
418+
Step 2 already clears the read path all the way to the Index API; and
419+
retry the `FallbackBlockSource` log check with longer-retention logging
420+
(or a metrics counter) in place before the next full reindex, since this
421+
pass found the question unanswerable after the fact.

0 commit comments

Comments
 (0)