Differential fuzzing
shapekit fuzz feeds generated and mutated XDR values through two decoders and reports every
input on which they disagree:
- rust:
stellar-xdr30.0.0, linked intoshapekitand called in process the way core calls it,T::from_xdr(bytes, limits)thento_xdr(limits). - go:
go-stellar-sdkat the current version ofconformance/versions.toml(v0.8.0), through its conformance testee and thedecode_encodeop of the testee protocol.
The nightly workflow runs it for 25 minutes. It goes beyond what the RFP asks for.
shapekit fuzz --seconds 1200 --seed 20261112 --jobs 4
shapekit fuzz --replay findings/fuzz # run every stored reproducer againWhy not cargo-fuzz
cargo-fuzz 0.13.2 with libfuzzer-sys 0.4.13 needs a nightly compiler (-Zsanitizer). rustup
on the build host can install one, and cargo +nightly fuzz would not disturb the 1.99.1 pin
in rust-toolchain.toml, because the +toolchain override applies to one invocation. It was
still not used:
- libFuzzer's feedback is coverage of the process it runs in. Half of every comparison happens
in the go testee, a separate process, so coverage would steer towards new paths in
stellar-xdronly and say nothing about go. - Every input costs a pipe round trip to go either way, so the in-process speed libFuzzer is built for is not available. The measured rate below is pipe-bound.
- A nightly compiler in CI is an unpinned toolchain beside a pinned one, and a second full build of the dependency tree with sanitizer flags, for no gain in what is compared.
So the fuzzer is an ordinary subcommand on the pinned toolchain. Values come from
stellar-xdr's own arbitrary feature (arbitrary 1.5.0) over bytes from a seeded generator,
and from xdr-aware mutation of corpus entries. Every class records the seed, worker index and
iteration of its input. A whole run is reproducible from --seed for any --jobs when it is given
--max-inputs and finishes before --seconds runs out: each worker then has a fixed share of the
inputs and classes are merged in a fixed order. report.json says reproducible: true in that
case; timing goes to timing.json so it does not affect the report.
Inputs
Types: every type both testees decode. The go testee's type table is generated from the corpus
manifest, so this is the manifest's xdr types: TransactionEnvelope, LedgerCloseMeta,
TransactionResultMeta, TransactionResultMetaV1, LedgerEntry, ContractEvent, ScVal,
SorobanAuthorizationEntry, ScSpecFunctionV0, Asset, SignerKey. --types narrows it.
Seeds: every corpus kind = xdr entry of those types up to --max-seed-bytes (64 KiB, which
keeps all but a few large ledgers), plus the contract arguments and auth entries inside
envelopes and the key and value of contract data entries, so the small types get real seeds.
Each iteration picks a type, then one of:
| origin | what |
|---|---|
seed-mutation | a corpus seed with one to four mutations |
arbitrary | T::arbitrary over 1 to 4096 generated bytes, encoded without limits |
arbitrary-mutation | the same, then mutated |
pool-mutation | an input both sides accepted earlier in the run, mutated again |
The mutations work on 4-byte words because XDR lengths, counts and discriminants are
big-endian words: set a word to a boundary value (0, 1, 2, 0x7fffffff, 0x80000000,
0xffffffff and others), set a length word near the number of bytes after it, nudge a word by
a small delta, delete or repeat a run of words, insert zero or random words, truncate, splice
words from another input of the same type, flip a bit, append bytes.
Limits
Both sides get the corpus decode limits from crates/shapekit-manifest/src/depth.rs: depth
1000 (xdr.envelope.depth), 1012 for LedgerCloseMeta, and the input length as the length
limit. The go testee honours both (decode_limits: "honoured").
What counts as a disagreement
| class kind | meaning |
|---|---|
rust-accepts-go-rejects | rust decodes the whole input, go refuses it |
go-accepts-rust-rejects | the other way round |
reencode-differs | both decode, the re-encoded bytes differ |
rust-panic, go-panic | a decoder crashed instead of refusing |
go-failure | the go testee timed out, died or broke the protocol |
Both refusing, for whatever reasons, is agreement. Both accepting with the same bytes is agreement even when those bytes differ from the input.
Disagreements are grouped into classes by a signature: the type, the kind, rust's error
variant without its payload, and go's innermost decoding <Type>: ... message with numbers
and quoted values blanked. go wraps its error once per enclosing type, so without the trim one
refusal reached through two paths would be two classes. The class id is
fuzz.<type>-<kind>-<first 8 hex of sha256(signature)>.
The depth-accounting filter
go-stellar-sdk spends MaxDepth once per generated DecodeFrom call; rs-stellar-xdr spends
its depth once per read_xdr of every type, including the wrappers and boxes go does not
count. At the same number the two refuse at different nesting: go reads about twice as deep.
This is already known and recorded (conformance/known-failures/go.txt, the depth entries,
and their drafts in findings/issues/), so it is filtered rather than reported again.
A disagreement is filtered when, and only when, lifting the refusing side's depth limit, and nothing else, makes that side accept with exactly the bytes the other side produced:
depth-accounting-go-deeper: rust refused withDepthLimitExceeded, go accepted, and rust at depthu32::MAXwith the same length limit accepts and re-encodes to go's bytes.depth-accounting-go-shallower: go refused withmaximum decoding depth reached, rust accepted, and go at depthu32::MAXaccepts and re-encodes to rust's bytes.
Anything else about the input, including a depth refusal combined with a second difference, stays a disagreement. The report counts filtered inputs per type.
Reproducers
At the end of the run every class is shrunk while it stays in the same class: delta debugging over aligned word runs, then trailing bytes, then each remaining word set to 0 or 1. Each class gets a directory in the corpus vector layout:
entry.json: a manifest entry record (kind: xdr,synthetic: true, familyfuzz), checked against the manifest's own record type before it is written.any.xdr: the minimised bytes.class.json: the signature, both verdicts, the run seed and where the class was first seen.
Every class goes to <out>/reproducers/; classes not in findings/fuzz/known-classes.txt are
also written under findings/fuzz/, which is where triaged reproducers are kept.
The expected verdict in entry.json follows core where core fixes it:
| rust on the reproducer | type | expected |
|---|---|---|
| accepts, re-encodes other bytes | any | implementation-defined, not-emittable: not canonical, no conforming encoder writes it |
| accepts and round-trips | TransactionEnvelope | must-accept: core's decode is stellar-xdr at depth 1000 |
| accepts and round-trips | other | implementation-defined: core produces these types and never decodes untrusted bytes of them |
| rejects | TransactionEnvelope | must-reject, invalid-by-core: from protocol 28 core refuses what its rust parser cannot read (can_parse_transaction) |
| rejects | other | implementation-defined |
core decodes an envelope with its own decoder, so stellar-xdr's verdict on bytes it does not round-trip is not core's verdict, and such inputs are left implementation-defined.
A reproducer is a finding, not a corpus entry. When a class is real and new, add a synthetic
entry for it to crates/shapekit-gen/src/synth_xdr.rs (where the build recomputes the
expectation with stellar-xdr), draft the issue under findings/issues/, and list the class in
findings/fuzz/known-classes.txt with the entry and draft.
Known classes and the exit status
findings/fuzz/known-classes.txt lists triaged classes, one <class-id> <issue or reason> per
line. A run exits non-zero when it sees a class that is not listed; listed classes are counted
and reported. --replay exits non-zero when a stored reproducer no longer lands in its class,
which is how a fix upstream shows up.
Output
<out>/report.json and <out>/FUZZ.md (posted to the nightly job summary): inputs and rate,
per type the number of seeds, inputs, both-accept, both-reject, filtered and disagreeing
inputs, the inputs per origin, and every class with its count, reproducer and both verdicts.
First run (2026-11-12)
Seed 20261112, 4 workers, 1200 s on the 10-core build host: 87,738,857 inputs, about 73,000
per second (39.5 M seed mutations, 21.9 M pool mutations, 17.5 M arbitrary values, 8.8 M
mutated arbitrary values). 8,439 inputs were filtered as depth accounting (8,328
TransactionEnvelope, 111 ScVal, all depth-accounting-go-deeper).
Every other disagreement, 113,063 inputs, was one root cause in eight classes, one per type
that carries a bool: stellar-xdr reads a bool word other than 0 or 1 (as false), go-stellar-sdk
refuses it. Core's own decoder, xdrpp, reads the same word as true and re-encodes it as 1, and
core hashes and checks signatures against that re-encoding, never the submitted bytes. The
minimal reproducer is the ScVal 00000000 00000002. The corpus already holds it as
soroban.scval-shapes.synth-bool-value-2, and in an envelope as
soroban.invoke-basics.synth-invoke-arg-bool-2; the issue drafts are
findings/issues/fuzz-rust-bool-non-canonical.md and
findings/issues/stellar-core-bool-non-canonical.md. No input made either side panic, no go
request failed, and no input was accepted by both sides with different re-encodings.
ScSpecFunctionV0, Asset and SignerKey, which carry no bool, had no disagreement at all.
This page is generated from docs/differential-fuzzing.md in the repository.