Skip to content

Indexer: config validation, full ScVal coverage, parser fuzzing, dead-letter observability (#475, #478) - #477

Merged
Depo-dev merged 8 commits into
devfrom
integration/indexer-config-and-parser
Aug 27, 2026
Merged

Indexer: config validation, full ScVal coverage, parser fuzzing, dead-letter observability (#475, #478)#477
Depo-dev merged 8 commits into
devfrom
integration/indexer-config-and-parser

Conversation

@Depo-dev

@Depo-dev Depo-dev commented Aug 27, 2026

Copy link
Copy Markdown
Collaborator

Carries #475 and #478 (indexer config validation, ScVal coverage, proptest fuzzing, dead-letter hardening) into dev, with their defects fixed and the remaining acceptance criteria implemented.

Both PRs were merged onto integration/indexer-config-and-parser rather than straight into dev so the problems below could be repaired first.

Contributor work landing here

Config validation (#417) — every env var parsed and range-checked in one pass, with all errors collected and reported together rather than failing on the first. Good design: an operator fixes one misconfiguration per restart otherwise.

ScVal coverage (#415) — fixtures per variant asserted against real stellar_xdr types.

Fuzzing (#416) — a proptest harness over the decode entry points asserting the parser never panics.

Dead-letter hardening (#414) — bounded exponential backoff around the parse-error insert, so a transient DB hiccup no longer loses the audit record.

Defects fixed

The proptest generators panicked on their own inputs

Generators requested vec(any::<u8>(), 1..128) — as little as one byte — then indexed up to bytes[8] to fill a u64/i64. Five tests failed inside the generator, before the parser was ever called, so the suite reported failures that said nothing about the code under test. Minimum raised to 9 bytes.

256-bit ScVal decoding was silently wrong

Pre-existing on dev, not introduced by either PR — but the new fuzz coverage is what made it findable.

U256/I256 packed four 64-bit limbs into a u128 using 32-bit shifts, truncating the top half and mis-positioning the rest:

value
hi_hi=1 decoded as 79228162514264337593543950341
correct 6277101735386680763835789423207666416102355444464034512896

A plausible-looking number, just the wrong one — which is exactly why it survived. Replaced with true 256-bit decimal conversion (long multiplication over decimal digits, no big-integer dependency), pinned by exact-value tests at both extremes and u256::MAX.

Cargo.lock and formatting

proptest was added to Cargo.toml but never locked, so --locked builds fail. Formatting fixed for cargo fmt --all -- --check.

Worth flagging: rust-toolchain.toml pins 1.94.0 and its comment claims "this file is therefore what actually pins CI", but the CI logs show the toolchain action resolving stable to rustc 1.98.0. Any Rust PR can pass fmt locally and fail it in CI for reasons the author cannot reproduce. Not fixed here — it deserves its own issue.

Remaining criteria implemented

#414 — dead-letter observability. Added trident_indexer_dead_lettered_total, incremented only once the row is durably written. Deliberately not on the failure path: counting there fires the alert for events never actually captured for replay. Kept distinct from parse_errors_total, which counts every parse failure including ones that later succeed — an alert wants the abandoned-event signal, not retry noise.

#415 — Timepoint, Duration, Error. All three fell through to the debug catch-all, rendering as Timepoint(1700000000) and — worse — incrementing unhandled_scvariant_total despite being fully understood types, which eroded the one signal meant to say "the decoder met something new". Now handled explicitly. Timepoint and Duration serialise as decimal strings, matching the 128/256-bit rule rather than the I64/U64 one: both are u64, and silent precision loss at the top of the range is worse than consistent strings.

Two documentation errors surfaced while checking the table against the encoder, both pre-existing: ScvBytes was documented as base64 (the code emits hex) and ScvError as a structured {"type", "code"} object (no such object is produced). Both corrected, with a test pinning the hex claim so they cannot drift again. The I64/U64-as-number inconsistency is now recorded explicitly rather than left to be rediscovered.

#416 — fuzzing in CI. The proptest suite ran only at its in-file case count. Added a dedicated step at PROPTEST_CASES=50000, so every push does a real fuzz pass. Longer-run invocation documented, along with where proptest persists failing inputs — a regression corpus only helps if the file gets committed.

Issue status

Closed #415 by hand (auto-close does not fire when the base is not the default branch). #417 was already closed.

Left open with specifics recorded on each:

  • launch: dead-letter path so one poison event cannot wedge the cursor #414 — no admin endpoint or documented replay query; no integration test with a deliberately poisoned row asserting the cursor advances. That last one is the criterion that actually proves the fix, and it needs a live database. The alert on the new counter is also not yet configured.
  • launch: fuzz the XDR parser to prove it cannot panic #416 — corpus not yet seeded from real testnet payloads. The generators synthesise structurally-valid ScVals and random bytes, which covers the shape of the input space but not its distribution; real payloads reach variant combinations random generation rarely hits.

Verification

cargo build, cargo clippy --all-targets --all-features -- -D warnings, cargo fmt --all -- --check, and cargo test all pass — 284 tests, up from 265 before this work.

Also carries the apk upgrade fix for CVE-2026-14456, cherry-picked from integration/launch-fixes, since this branch is off dev and would otherwise fail the same required trivy check.

Noragiftfr and others added 8 commits August 27, 2026 08:43
…tter improvements (#414, #415, #416, #417)

- Config validation at startup with all errors accumulated in a single pass
- ScVal variant coverage: Bool, String, U32, I32, U64, I64, Bytes, Vec tests
- Unhandled ScVal variant metric for observability
- Property-based fuzz tests for XDR parser via proptest
- Dead-letter retry with bounded backoff for failed parse_error inserts
- Admin /admin/dead-letter endpoint for inspecting dead-lettered events
- Cursor-advances-past-poison tests for poison event resilience
feat(indexer): config validation, ScVal coverage, fuzz tests, dead-letter improvements
Two problems, one from #475 and one it exposed.

The new proptest generators panicked on their own inputs:

  index out of bounds: the len is 2 but the index is 2

`vec(any::<u8>(), 1..128)` can yield a single byte, but the match arms index
up to `bytes[8]` to assemble a u64/i64. Five tests failed inside the
generator before the parser was ever called, so the fuzzing proved nothing.
Raised the floor to 9 bytes.

U256/I256 decoded to wrong values:

    let val = ((parts.hi_hi as u128) << 96)
        | ((parts.hi_lo as u128) << 64)
        | ((parts.lo_hi as u128) << 32)
        | (parts.lo_lo as u128);

The four limbs are 64 bits each — 256 bits total — packed into a u128 with
32-bit shifts. That both truncates the top half and mis-positions the rest,
so any value above 2^128 produced a plausible-looking but incorrect number.
`UInt256Parts { hi_hi: 1, .. }` is 2^192; the old code rendered it as
79228162514264337593543950336.

Silent wrong data, not a crash — an indexed u256 amount would have been
served to API consumers as a different number. This is pre-existing on dev,
not introduced by #475; it surfaced because #475 added the first tests to
touch these variants, and those tests were asserting against the broken
output.

Replaced with long multiplication over decimal digits, avoiding a
big-integer dependency for the one place it is needed. Signed values are
negated across all four limbs in two's complement before printing.

Also adds the large-integer coverage #415 asks for by name and #475 left
out: u128 full width and max, i128 negative and min, u256 beyond 2^128, u256
max, i256 -1 and min. Verified the u256 test fails when the hi_hi limb is
dropped, so it cannot pass vacuously.
`SBOM + scan (go-api)` fails on CVE-2026-14456 — libcrypto3 3.5.7-r0, a
fixable HIGH (OpenSSL denial of service via unbounded memory). It is a
required status check, so this one finding blocks every PR in the repo,
including ones that touch nothing related.

A digest bump does not fix it: the pin here is already the current
alpine:3.22 (3.22.5, verified with `docker buildx imagetools inspect`). The
patched libcrypto3 3.5.8-r0 is published in the v3.22 package repo but no
rebuilt base image carries it yet.

`apk upgrade --no-cache` in the runtime stage picks up the patched package
while leaving the base pinned by digest, so builds stay reproducible against
a known image and still ship patched system libraries.

Verified 3.5.8-r0 is the current libcrypto3 in the alpine v3.22 main repo.
Not applied to the other Dockerfiles: only go-api is failing the scan, and
the same change elsewhere should follow its own scan evidence rather than
being copied on assumption.
…ting

Two things #475 left uncommitted, both of which fail CI.

Cargo.lock had no entry for proptest. #475 added the dependency to
crates/indexer/Cargo.toml but not the lockfile, so a `--locked` build (or
any CI step that verifies the lockfile is current) sees a manifest and lock
that disagree.

Formatting: the `Rust` job runs `cargo fmt --all -- --check` and flagged
config.rs and streamer/mod.rs. Worth noting why this was not caught locally
— rust-toolchain.toml pins 1.94.0, but the CI logs show the toolchain action
setting `stable` as the default and resolving to rustc 1.98.0, so CI formats
with a newer rustfmt than the pin implies. The pin is not doing what its own
comment says it does ("this file is therefore what actually pins CI"), which
is worth a separate look: any Rust PR can pass fmt locally and fail it in CI
for reasons the author cannot reproduce.

Formatted here with the workspace-wide `cargo fmt --all` that CI actually
runs, rather than the per-package `cargo fmt -p trident-indexer` I used
earlier.
…tter improvements (#414, #415, #416, #417)

- Config validation at startup with all errors accumulated in a single pass
- ScVal variant coverage: Bool, String, U32, I32, U64, I64, Bytes, Vec tests
- Unhandled ScVal variant metric for observability
- Property-based fuzz tests for XDR parser via proptest
- Dead-letter retry with bounded backoff for failed parse_error inserts
- Admin /admin/dead-letter endpoint for inspecting dead-lettered events
- Cursor-advances-past-poison tests for poison event resilience
#475 delivered most of these three issues; this covers the criteria it did
not reach.

#414 — dead-letter observability

Added trident_indexer_dead_lettered_total, incremented only once the
parse-error row is durably written. Deliberately not on the failure path:
counting there would fire the alert for events that were never actually
captured for replay, which is the opposite of what an operator needs to
know. Kept distinct from the existing parse_errors_total, which counts every
parse failure including ones that later succeed — an alert wants the
abandoned-event signal, not the retry noise.

Still open on #414: an admin endpoint or documented replay query, and the
integration test with a deliberately poisoned row. Both need a running
database, so they belong with the integration suite rather than here.

#415 — Timepoint, Duration and Error

These three fell through to the debug catch-all, so a Timepoint rendered as
"Timepoint(1700000000)" and, worse, incremented the unhandled-variant metric
despite being a fully understood type — which erodes the one signal that is
supposed to mean "the decoder met something new". Now handled explicitly.

Timepoint and Duration serialise as decimal strings, matching the 128/256-bit
rule rather than the I64/U64 one: both are u64, and a value that silently
loses precision at the top of its range is worse than one that is
consistently a string. ScvError stays a debug string on purpose — its code is
contract-defined, so there is no stable schema worth promising.

Two documentation errors surfaced while checking the table against the
encoder, both pre-existing:

  - ScvBytes was documented as base64; the code has always emitted hex.
  - ScvError was documented as {"type": "…", "code": N}; no such object is
    ever produced.

Corrected the doc to match the code, and pinned the hex claim with a test so
the two cannot drift again. Also recorded the I64/U64-as-number
inconsistency explicitly rather than leaving it to be rediscovered: it has
the same range problem, but changing it would break existing consumers.

#416 — fuzzing in CI

The proptest suite ran only at its in-file case count as part of cargo test.
Added a dedicated step that re-runs the parser property tests with
PROPTEST_CASES=50000, so every push does a real if short fuzz pass instead of
hoping the default budget stumbles onto a crash. Documented the longer-run
invocation and where proptest persists failing inputs, since a regression
corpus is only useful if the file gets committed.

Still open on #416: seeding the corpus from real testnet payloads.
feat(indexer): config validation, ScVal coverage, fuzz tests, dead-letter improvements

Same branch as #475, re-cut against dev. The content had already landed here
via #475, so the merge conflicted against the follow-up fixes sitting on top
of it.

Resolved in favour of this branch for all four files. Everything unique to
#478 was the pre-fix state of code the follow-ups had already corrected:

  - config.rs, streamer/mod.rs: formatting that fails cargo fmt --all in CI
  - parser/mod.rs: the 256-bit ScVal packing that truncated four 64-bit limbs
    into a u128, and the proptest generators that indexed bytes[8] out of a
    vector as short as one byte
  - metrics.rs: no unique content

Verified after resolution: all three fixes still present, 284 tests passing.
@Depo-dev Depo-dev changed the title Indexer: config validation, ScVal coverage, and a 256-bit decoding fix (#475) Indexer: config validation, full ScVal coverage, parser fuzzing, dead-letter observability (#475, #478) Aug 27, 2026
@Depo-dev
Depo-dev merged commit 42a2d20 into dev Aug 27, 2026
20 checks passed
Depo-dev added a commit that referenced this pull request Aug 27, 2026
Resolves the conflicts introduced by merging #477, #469 and #473 into dev.

Both conflicts were add/add or comment-adjacent, and in both cases the two
sides were solving different problems — so neither side is discarded.

.dockerignore: this branch added it to keep the build context small (the
compose files build with the repo root as context, so a local build was
shipping multi-GB target/ and node_modules/ to the daemon). dev added it to
keep credentials and private key material out of the builder. Both sets of
patterns are kept, grouped under headings that say which concern each serves.

services/api/middleware/auth.go: this branch adds /v1/stats/indexer to the
unauthenticated path list — correct, it is security: [] in
api/openapi.yaml, verified against the spec rather than assumed. dev added a
comment explaining that /v1/version deliberately stays authenticated because
it publishes the commit SHA and schema version. Those are independent
statements about two different endpoints, so the resolution keeps the
/v1/stats/indexer bypass and rewrites dev's note to say explicitly that
/v1/version stays off the list.

Verified after resolution: /v1/version appears in no bypass list, go build,
go vet, and the middleware and handlers suites all pass.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants