Reference data for OP Stack wire formats that specify an "unsigned Base128 varint, as defined in
protobuf spec" — span-batch uvarint fields, and the opstack ENR entry.
op-node decodes both with Go's standard library encoding/binary.ReadUvarint. Any Rust decoder
used on the same bytes must accept exactly the same set, or the two clients disagree on
identical input. Background: ethereum-optimism/protocol-team#223, fixed for span batches in
ethereum-optimism/optimism#22126.
| crate | mismatches vs Go | no_std |
verdict |
|---|---|---|---|
prost 0.14 |
0 / 649,092 | yes (default-features = false) |
conformant |
prost 0.13 |
0 / 649,092 | yes | conformant |
leb128 0.2 |
0 / 649,092 | no (io-based) | conformant, std-only |
integer-encoding 4 |
0 / 649,092 | no | conformant, std-only |
unsigned-varint 0.8 |
18,239 (14,444 over-strict) | yes | wrong varint family |
quick-protobuf 0.8 |
3,795 (all over-lenient) | yes | truncates by design |
leb128fmt 0.1 |
293,538 | yes | not a drop-in |
quick-protobuf (pulled in transitively by libp2p) exposes a public BytesReader::read_varint64,
but discards bits past 63 on a ten-byte varint rather than erroring — its own source calls this
"ESSENTIALLY A SILENT TRUNCATION", done deliberately for parity with Google's C++ implementation.
Every one of its 3,795 mismatches is it accepting a value Go rejects.
unsigned-varint is not a buggy protobuf decoder — it implements the multiformats varint,
which is minimal-only by definition. That is the trap: the crate name suggests generality, and
the failure only shows on inputs a malicious peer or batcher controls.
Every vector below is a legal question to ask of a Base128 decoder. value/+n = decoded value with
n bytes left unconsumed.
| vector | Go stdlib | prost .14 | prost .13 | leb128 | int-enc | unsigned-varint |
|---|---|---|---|---|---|---|
[] empty |
reject | reject | reject | reject | reject | reject |
[01] minimal |
1/+0 | 1/+0 | 1/+0 | 1/+0 | 1/+0 | 1/+0 |
[81 00] non-minimal |
1/+0 | 1/+0 | 1/+0 | 1/+0 | 1/+0 | reject |
[81 00 aa bb] trailing |
1/+2 | 1/+2 | 1/+2 | 1/+2 | 1/+2 | reject |
10 bytes, terminator 00 |
1/+0 | 1/+0 | 1/+0 | 1/+0 | 1/+0 | reject |
10 bytes, terminator 01 |
2^63+1 | 2^63+1 | 2^63+1 | 2^63+1 | 2^63+1 | 2^63+1 |
10 bytes, terminator 02 |
reject | reject | reject | reject | reject | 1/+0 (truncates) |
10 bytes, terminator 7f |
reject | reject | reject | reject | reject | 2^63+1 (truncates) |
[ff*9 01] = u64::MAX |
u64::MAX | u64::MAX | u64::MAX | u64::MAX | u64::MAX | u64::MAX |
[80*10] never terminates |
reject | reject | reject | reject | reject | reject |
[80*10 01] 11 bytes |
reject | reject | reject | reject | reject | reject |
[81] truncated |
reject | reject | reject | reject | reject | reject |
The two divergent rows are the whole bug: unsigned-varint rejects valid non-minimal
encodings and silently truncates a value that overflows u64.
quick-protobuf (omitted from the table for width) matches Go on every row except the two
overflow rows, where it returns 1 and 2^63+1 instead of rejecting — the truncation half of the
same defect, without the minimality half.
Yes. The spec text cites protobuf rather than Go, so it is worth checking whether the two agree —
they do. Go's own protobuf implementation, google.golang.org/protobuf/encoding/protowire.ConsumeVarint,
returns the same verdict as binary.ReadUvarint on every vector above, including accepting
[81 00] and rejecting a tenth byte above 0x01.
So spec-conformance and reference-implementation-conformance coincide here, and a decoder that matches one matches the other. There was no spec-vs-op-node conflict to adjudicate.
A ~20-line port of binary.ReadUvarint (rust/kona/crates/protocol/protocol/src/batch/varint.rs)
rather than a crate dependency. prost was the only conformant no_std candidate, and it was
rejected for that crate specifically because kona-protocol compiles into the fault proof VM,
where prost's unsafe fast path and its allocating DecodeError on the reject path both matter.
Outside the FPVM those objections do not apply and prost is a reasonable choice.
# 1. Record Go's verdict for 649,092 byte strings.
go run gen_corpus.go > corpus.txt
# 2. Compare every candidate Rust decoder against it.
mkdir -p varintcmp/src && mv varintcmp-Cargo.toml varintcmp/Cargo.toml
mv varintcmp-main.rs varintcmp/src/main.rs && mv varintcmp-boundary.rs varintcmp/src/boundary.rs
cd varintcmp && cargo run --release -- ../corpus.txt # full corpus
cargo run --release --bin boundary # the table aboveCorpus composition: all 1- and 2-byte strings; exhaustive over an 11-symbol alphabet to depth 5; every tenth byte after nine-byte continuation prefixes, plus an eleventh byte; 400k random continuation-biased strings of length 0–12.
Add a row before trusting a new crate or version. A spec citation does not distinguish two
varint families; only a differential run against binary.ReadUvarint does.