Serialization
lib/proto/ serialises a compiled QuadCircuit to a compact binary blob: constants deduplicated, wire indices delta-encoded, header tagged with field ID and a 32-byte circuit identity hash. The format turns the compile-once-ship-bytes pattern from a guideline into a concrete contract: anyone holding the blob and the same field parameters can reconstruct the exact circuit without re-running the compiler.
When to reach for it
- You are shipping a fixed circuit (attestation verifier, signed-document predicate) to many verifiers and want to compile it once.
- You are building a prover/verifier negotiation layer: both sides agree on the 32-byte circuit ID, then the verifier fetches the matching blob.
- You are diffing two builds of the same circuit and want to confirm they produce byte-identical output — the circuit ID hash is the shortcut.
Design overview
Format. A custom little-endian binary format, not Protobuf (despite the directory name) and not JSON. Current version: 1. A deserialiser that sees any other version rejects the blob.
Constants deduplication. The compiler often references the same field element (zero, one, small powers of two, curve constants) many times. CircuitWriter builds a KvecBuilder that hashes each constant and emits it once; the quads refer to constants by index. On large circuits this reduces the constants section from megabytes to kilobytes.
Delta-encoded wire indices. Within a layer, wire indices tend to be close to the previous one — schedulers pack related gates together. The writer encodes each h[0], h[1] index as a signed delta from the last one, with the LSB holding the sign bit. For the largest test circuits in the upstream repo, this shrinks the xz-compressed artifact from roughly 35 MB to roughly 100 KB.
Circuit ID. A 32-byte hash over the semantic content of the circuit — constants in canonical order, layers in canonical order, quads with indices normalised. Two compiler runs that produce the same circuit give the same ID; two runs that differ anywhere (gate count, constants, layer order) produce different IDs. circuit_id() computes it; the writer embeds it in the header so a reader can cross-check without rebuilding the hash from scratch.
Size limits. CircuitIO::kBytesPerSizeT = 3 — 24-bit indices. This caps both wire count and constant count per layer at 2^24, and incidentally caps the whole circuit at roughly 16 MB once serialised. On a 32-bit platform the deserialiser bails rather than truncating if a blob exceeds this; 64-bit platforms hit the layer-count limit (kMaxLayers = 10000) first.
API surface
CircuitIO — constants and layout
// Top-level enum — defined at namespace scope in circuit_io.h, not nested inside CircuitIO.
enum FieldID {
NONE = 0,
P256_ID = 1,
P384_ID = 2,
P521_ID = 3,
GF2_128_ID = 4,
GF2_16_ID = 5,
FP128_ID = 6,
FP64_ID = 7,
GOLDI_ID = 8,
FP64_2_ID = 9,
SECP_ID = 10,
};
struct CircuitIO {
static constexpr size_t kBytesPerSizeT = 3;
static constexpr size_t kIdSize = 32;
static constexpr size_t kMaxLayers = 10000;
};The FieldID enum is the contract between writer and reader: the reader asserts that the stored field ID matches its runtime field type and rejects otherwise. This catches silent coordinate-field mismatches (e.g., loading a P-256 circuit into a P-384 prover).
CircuitWriter<Field>
template <class Field>
class CircuitWriter {
public:
explicit CircuitWriter(const Field& f, FieldID field_id);
// Serialise into a byte vector; appends, does not clear.
void to_bytes(const Circuit<Field>& c, std::vector<uint8_t>& out);
};- Deduplicates constants via
KvecBuilder. - Delta-encodes wire indices per layer.
- Writes the 32-byte circuit ID into the header so readers can validate without recomputing.
CircuitReader<Field>
ReadBuffer is defined in util/readbuffer.h — it is not a nested type of CircuitReader.
template <class Field>
class CircuitReader {
public:
explicit CircuitReader(const Field& f, FieldID field_id);
// Returns the reconstructed circuit, or nullptr on any error.
std::unique_ptr<Circuit<Field>>
from_bytes(ReadBuffer& buf, bool enforce_circuit_id);
};enforce_circuit_id = truere-computes the circuit ID after reconstruction and compares to the stored one; passfalseif you trust the source (saves the rehash).- Every bounds check is explicit: index overflows and layer overflows return
nullptrrather than UB. - Version mismatch: always fatal, never silently upgraded.
Compatibility notes
- Version pinning. Downstream consumers should version-pin against a specific Longfellow revision. The on-disk format is stable within a minor version but not promised across majors; when the format changes, the version byte bumps and older blobs stop deserialising.
- Field ID collisions. The enum is maintained in
circuit_io.h; adding a new field requires claiming a free ID. Do not reuse IDs. - Circuit ID vs. proof blob. The circuit ID is static per circuit; the proof blob produced by
ZkProverbinds against this ID in its transcript so an old proof cannot be replayed against a newer circuit with the same shape.
See it used
longfellow-zk/lib/proto/circuit_io_test.cc— circuit_io_test.cclongfellow-zk/lib/circuits/mdoc/mdoc_zk.cc— the canonical example of “compile circuit, cache blob, ship” in the upstream repo.
Related
- Circuit Compiler — produces the
Circuit<Field>that is serialised here. - Compiling and Caching Circuits — end-to-end guide to the pattern.
- ZK (top-level) — reads circuits back via
CircuitReaderbefore proving.