Reference

Wire format

The frozen inner-proof-v1 encoding, with its byte layout, version axes, length limits, decoder rejection rules, circuit commitment and wrap statement.

inner-proof-v1 is the canonical byte encoding of one inner GKR proof together with its statement and circuit identity. It is the external proof boundary of the engine: encode_sync_result produces it, verify_encoded_sync_op consumes it, and the wrap path reads it. This page restates the layout; the wrap::encoding source and its golden vectors are authoritative.

Status and scope#

  • Frozen. Any byte-level change to the layout bumps proof_encoding_version, and any semantic change bumps the matching version axis below. In code, wrap::encoding owns the layout and decoder, wrap::commitment the circuit commitment, and wrap::statement the wrap statement and field bridge.
  • Inner-proof layer only. Service APIs and other protocol schemas are separate layers with their own versions. Wrapped outer proofs carry the wrap statement described at the end of this page.
  • No witness bytes. The witness is never part of this encoding. The verifier receives it through the typed request.

Version axes#

AxisBumps whenFrozen value
proof_encoding_versionThe inner-proof byte encoding changes1
protocol_versionTranscript or proof semantics change: observation order, degree bounds, claim carry, circuit-digest content1
circuit_versionCompiler or circuit semantics change1
leaf_encoding_versionThe LeafState::encode layout changes1

The axes are never merged into one product version. protocol_version 1 binds the transcript and public-input conventions together with the full circuit commitment absorbed into the transcript. The version inside the transcript domain tag statesync-gkr/v0.1 tracks this axis.

The envelope carries no configuration identifier. It carries the explicit instance values (depth, leaf_max_fields and strategy), and the full circuit commitment binds them cryptographically. Which configurations a verifier accepts is verifier-side policy, for example a registry of allowed tuples, and not part of the proof bytes. Axes outside the inner-proof scope, such as the wrap statement version, are versioned separately.

Byte layout#

All integers are little-endian, and offsets count from the start of the message.

Text
Header (17 bytes)
   0..8    magic  b"SSGKRPRF"
   8..10   proof_encoding_version  u16 = 1
  10..12   protocol_version        u16 = 1
  12..13   proof_kind              u8  = 0 (inner GKR)
  13..17   total message length    u32 (must equal the buffer length)
Circuit identity (44 bytes)
  17..18   op_kind_tag             u8  (0 Membership / 1 NonMembership / 2 Update)
  18..22   tree depth              u32
  22..24   circuit_version         u16 = 1
  24..26   leaf_encoding_version   u16 = 1
  26..28   leaf_max_fields         u16
  28..29   layer_strategy_id       u8  = 0 (strategy A)
  29..61   full_circuit_commitment 8 x fe
Statement (frozen public-input field order, 105 bytes)
  61..93   old_root                8 x fe
  93..125  new_root                8 x fe
 125..126  op_kind_tag             u8 (MUST equal the identity tag)
 126..130  asset_id low limb       u32
 130..134  asset_id high limb      u32
 134..166  value_digest            8 x fe
Proof payload
 166..170  layer count             u32
 per layer, output layer first:
   round-poly count               u32
   per round poly, round order:
     coefficient count            u8 (MUST be 5 = degree bound 4 + 1)
     5 x ef                       ascending coefficient order
   eval_x                         ef
   eval_y                         ef

Element encodings#

NotationBytesEncoding
u8, u16, u321, 2, 4Unsigned little-endian integers
fe4One KoalaBear element as its canonical residue, u32 little-endian. Values at or above p = 2^31 - 2^24 + 1 are rejected, so every value has exactly one encoding
ef16One degree-four extension element as four fe in basis order
8 x fe32One digest: eight fe in order
asset_id8The u64 key as two u32 limbs, low limb first: the same split the transcript absorbs

Rules for the variable part#

  • Round polynomials carry exactly five coefficients under protocol_version 1, trailing zeros included. The prover always emits degree-bound-plus-one coefficients. Encoders must not strip trailing zeros and decoders must not pad.
  • The statement's op_kind_tag must equal the identity's. The identity section stands alone, and the statement keeps the frozen public-input order; the decoder rejects any disagreement.
  • There is no batch or aggregate proof kind. A batch is a sequence of ordinary inner proofs, each identical to the single-request proof for its job. An aggregate kind could only come with a separate protocol and soundness model.
  • Proof kinds for wrapped proofs live in the wrap layer, not here.

Message length#

The layout fixes the length of every message:

Text
total = 170 + sum over layers of (36 + 81 * round_poly_count)

The fixed part is 170 bytes, the header, identity and statement plus the layer count. Each layer adds 4 bytes for its round-polynomial count, 81 bytes per round polynomial (one count byte and five 16-byte coefficients) and 32 bytes for eval_x and eval_y. In an honest proof a layer has two round polynomials per bit of the width of the layer below it. Measured encoded membership proofs are 176,948 B at depth 24 and 182,132 B at depth 32.

LimitValueWhen exceeded
Total message lengthu32, at most 4,294,967,295 bytesEncodeError::MessageTooLong
Layer count, round-polynomial countu32 eachEncodeError::CountOverflow
leaf_max_fieldsu16, at most 65,535EncodeError::CountOverflow when the facade builds the identity
Tree depthu32Cannot overflow; the configuration value is also a u32
Coefficients per round polynomialExactly 5EncodeError::BadRoundPolyArity; DecodeError::BadRoundPolyArity

Decoding rules#

Decoding is strict and fails closed:

  • Unknown versions, proof kinds, strategies and operation tags are rejected, never inferred or defaulted.
  • Every count is checked against the remaining byte budget before any allocation.
  • Non-canonical field elements are rejected with their exact byte offset.
  • The declared total length must equal the buffer length, and trailing bytes are rejected.
  • Decoding and then re-encoding gives the identical bytes.

wrap::encoding::DecodeError has one variant per rejection path:

VariantRaised whenFields
HeaderTooShortThe buffer is shorter than the 17-byte headerhave
BadMagicThe first 8 bytes are not SSGKRPRF
UnsupportedProofEncodingVersionproof_encoding_version is not 1The value
UnsupportedProtocolVersionprotocol_version is not 1The value
UnsupportedProofKindproof_kind is not 0The value
DeclaredLengthMismatchThe declared total length differs from the buffer lengthdeclared, actual
UnsupportedCircuitVersioncircuit_version is not 1The value
UnsupportedLeafEncodingVersionleaf_encoding_version is not 1The value
UnsupportedLayerStrategylayer_strategy_id is not 0The value
UnknownOpKindTagAn operation tag is above 2The value
OpKindMismatchThe statement tag differs from the identity tagidentity, statement
NonCanonicalFieldElementAn element is at or above poffset
BadRoundPolyArityA coefficient count byte is not 5layer, round, got
OversizedCountA layer or round-polynomial count exceeds the remaining bytesoffset
TruncatedThe buffer ends inside a required fieldoffset
TrailingBytesBytes remain after the last layerextra

wrap::encoding::EncodeError covers structural problems in the data being encoded: BadRoundPolyArity { layer, round, got } for a round polynomial without exactly five coefficients, CountOverflow for a count that does not fit its wire width, and MessageTooLong for a message above u32::MAX bytes.

What the decoder does not check#

The decoder does not compare the proof shape (layer and round counts) with an actual circuit, and it does not check that the commitment is true. A decoder alone cannot know the circuit, and trusting counts from the wire would invert the boundary. Those checks belong to the verifying side, against its own canonical compilation.

Responsibility split#

  1. Decoder (wrap::encoding): byte shape, versions and canonical form. It is stateless and has no circuit knowledge.
  2. Verifying side (verify_encoded_sync_op): the decoded identity must equal the identity of the local configuration, including the full circuit commitment from the verifier's own preparation. Only then does the inner verifier run.
  3. Inner verifier: the cryptographic checks, with witness access through the typed request.
  4. Wrap backend (wrap::WrapBackend): proves exactly the facade's wrap_relation, meaning decoding, identity and inner-verifier acceptance, which yields the wrap statement. It may reject an input but may never accept what the inner verifier rejects. The facade checks the relation natively before invoking a backend and compares the statement the backend echoes.

Cryptographic suite#

  • Base field: KoalaBear, p = 2^31 - 2^24 + 1.
  • Challenge field: the degree-four binomial extension over KoalaBear.
  • Hash and permutation: Poseidon2-KoalaBear, width 16, 4 + 4 external and 20 internal rounds, with the Plonky3 KOALABEAR_RC16_* constants.
  • Transcript: a duplex sponge with rate 8 over the same permutation; domain separator statesync-gkr/v0.1.

The suite is bound by protocol_version, which the decoder checks, rather than by suite bytes in each message.

Circuit identity and full circuit commitment#

The identity section (wrap::encoding::CircuitIdentity) carries the instance values explicitly, together with the full circuit commitment:

FieldType
op_kind_tagu8
depthu32
circuit_versionu16
leaf_encoding_versionu16
leaf_max_fieldsu16
layer_strategy_idu8
full_circuit_commitmentDigest<BaseField>

The commitment is a Poseidon2 duplex digest over every gate and constant of the compiled circuit:

Text
commitment = squeeze_digest( duplex_sponge(
    domain = "ssgkr/circuit-commitment/v1",
    op_kind_tag, depth, leaf_max_fields,
    strategy_id, strategy_arg,
    input_width_bits, layer_count,
    for each layer (output layer first):
        width_bits, gate_count,
        for each gate (compile order):  kind_tag, out, in1, in2, coeff,
        const_count,
        for each const (compile order): wire, value,
))
  • The sponge is the same Poseidon2-KoalaBear duplex the transcript uses. The digest is the base-field limbs of two squeezed extension challenges, eight elements in total.
  • Every scalar is absorbed as one canonical field element. Counts and indices are far below the field order; a value at or above it is a schema violation that panics rather than wrapping, because a wrap would let two preimages alias.
  • Gates and constants are absorbed in the compiler's deterministic emission order, so equal circuits commit equally and any reordering is a different commitment.
  • Compiler and tool versions are not part of the preimage, so identical circuits commit identically across compiler versions. Version axes are validated at the envelope layer instead.
  • Gate kind tags are Lin 0, Mul 1 and Pow3 2. The strategy identity (strategy_id, strategy_arg) is (0, 0) for A, (1, merge_k) for B and (2, 0) for C.

The proof transcript absorbs the circuit's shape values first, as a direct binding that does not depend on the hash, and then the commitment, so every proof is bound to the exact gate list.

Verifiers never trust the commitment, or any identity field, from the wire. The verifying side recomputes the commitment from its own canonical compilation for the version and configuration it accepts, or compares it with a registered value, and rejects any mismatch.

Statement#

The statement section holds the public inputs in their frozen observation order: old_root, new_root, op_kind_tag, asset_id, value_digest. Digests are eight base-field elements of 4 bytes each, and asset_id is a u64 split into two u32 limbs, low limb first. The statement's op_kind_tag must equal the identity section's. Proof lifecycle explains what each field asserts.

Determinism#

Byte identity holds for the deterministic inner GKR path: the same input under the same protocol_version produces the same transcript observations and the same canonical bytes across scalar and vectorized builds and for any worker count. The proof_digest development binary prints a canonical proof digest per fixed case and serves as a cheap equality check between builds; the encoder is the wire format.

Golden vectors#

The repository commits one honest proof per operation kind at the default configuration (depth 24, leaf_max_fields 31, strategy A) as canonical bytes. Tests compare each vector byte for byte with a fresh prove-and-encode, verify it fully, and run tamper and rejection checks over identity and statement bytes, the payload, cross-kind use and every decoder error. Vectors are regenerated only with a version bump. There are no batch vectors, because batched proofs are these same bytes.

Wrap statement#

wrap-statement-v1 is what an outer proof attests about an inner proof. It is six BN254 scalars in a fixed order:

Text
fr[0] header word (little-endian byte layout, value < 2^128):
      bytes 0..2   wrap_statement_version u16 = 1
      bytes 2..4   protocol_version       u16
      bytes 4..6   circuit_version        u16
      bytes 6..8   leaf_encoding_version  u16
      bytes 8..10  leaf_max_fields        u16
      byte  10     layer_strategy_id      u8
      byte  11     op_kind_tag            u8
      bytes 12..16 depth                  u32
fr[1] full_circuit_commitment (packed digest)
fr[2] old_root                (packed digest)
fr[3] new_root                (packed digest)
fr[4] asset_id                (u64, direct)
fr[5] value_digest            (packed digest)

A digest packs into one scalar with a 31-bit stride:

Text
packed = sum over i in 0..8 of limb_i * 2^(31 * i)      (limb_0 is digest word 0)
  • The packing is injective, and its value is always below 2^248, which is below the BN254 scalar modulus r. A 32-bit stride would reach 2^255, above r, and alias residues, so it is not used.
  • Unpacking is exact and total-checked: every 31-bit digit must be below p and no bit above position 247 may be set. wrap::statement::unpack_digest returns None otherwise.
  • An outer circuit that binds inner digests must enforce the same digit ranges and recomposition in-circuit. That is part of the soundness review of each backend.

Each scalar travels as 32 little-endian bytes (wrap::statement::Bn254Fr), matching zkVerify's BN254 Groth16 public-input convention; to_be_bytes gives the big-endian form that EVM and snarkjs-style consumers expect. A consumer never trusts fr[1]: it compares the value with the commitment it registered or recomputed for the versions in fr[0].

The published outer proof for this statement is a RISC Zero native succinct proof of one fixed depth-24 membership example; it is not a general wrapping service. See Trust boundaries.