Indexer: config validation, full ScVal coverage, parser fuzzing, dead-letter observability (#475, #478) - #477
Merged
Conversation
…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
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.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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-parserrather than straight intodevso 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_xdrtypes.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 tobytes[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/I256packed four 64-bit limbs into au128using 32-bit shifts, truncating the top half and mis-positioning the rest:hi_hi=1decoded asA 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.lockand formattingproptest was added to
Cargo.tomlbut never locked, so--lockedbuilds fail. Formatting fixed forcargo fmt --all -- --check.Worth flagging:
rust-toolchain.tomlpins1.94.0and its comment claims "this file is therefore what actually pins CI", but the CI logs show the toolchain action resolvingstableto 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 fromparse_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 — incrementingunhandled_scvariant_totaldespite 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:
ScvByteswas documented as base64 (the code emits hex) andScvErroras 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:
Verification
cargo build,cargo clippy --all-targets --all-features -- -D warnings,cargo fmt --all -- --check, andcargo testall pass — 284 tests, up from 265 before this work.Also carries the
apk upgradefix for CVE-2026-14456, cherry-picked fromintegration/launch-fixes, since this branch is offdevand would otherwise fail the same required trivy check.