Guides
Encoding and transport
Encode a proof into canonical inner-proof-v1 bytes, carry it to another process with your own request identifier, and verify the received bytes against the original request.
A proof that leaves the proving process travels as inner-proof-v1 bytes. This guide covers the round trip between a proving side and a verifying side: what the bytes carry, how a reply finds its original request, how the receiving side verifies it and how to find out why received bytes were rejected. The byte layout itself is specified in Wire format.
Before you begin#
- Build preparations on both sides from the same configuration, meaning the same tree depth, leaf bound and layer strategy, as described in Prepared execution. A proof made under one configuration does not verify under another.
- Plan for the verifying side to hold the original request, including its private witness. Trust boundaries explains why the verifier needs it and what that means for confidentiality.
How a proof travels#
- The side that needs the proof records the request under an identifier of its choosing before it asks for a proof.
- The proving side proves the request, for example with
prove_sync_op_prepared, and callsencode_sync_result(&prepared, &result), which returnsResult<Vec<u8>, EncodeError>. - Your transport carries the identifier and the bytes together. The encoding has no field for the identifier.
- The receiving side looks up the original request by its identifier and calls
verify_encoded_sync_op(&prepared, &request, &bytes), which returnsbool. Onlytruecounts as acceptance.
| Data | In the encoded bytes | Where the verifying side gets it |
|---|---|---|
| Circuit identity: operation tag, tree depth, version numbers, leaf bound, layer strategy and full circuit commitment | Yes | Compared with the identity computed from its own preparation |
Public inputs: old_root, new_root, op_kind_tag, asset_id and value_digest | Yes | Must equal the public inputs of the original request |
| Proof messages: round polynomials and per-layer evaluations | Yes | Checked by the GKR verifier |
| The operation, with its payload or its old and new leaf states | No | The original request |
| The private witness: leaf and sibling path | No | The original request |
| Request identifier, timestamps and routing data | No | Your transport envelope |
verify_encoded_sync_op decodes the complete byte string strictly and requires the decoded circuit identity to equal the identity of the verifier's own preparation, including a circuit commitment computed locally rather than read from the bytes. Only then does it run the prepared verifier on the decoded public inputs and proof, against the original request. Proof lifecycle lists every check in order.
A complete round trip#
The program below plays both sides in one process. The verifying side keeps its pending requests in a map keyed by request identifier and has its own prover instance and preparation, as a separate process would. The proving side answers three of four requests, and the program then delivers the replies out of order, once more as a duplicate, once damaged and once under the wrong identifier.
use std::collections::HashMap;
use std::process::ExitCode;
use statesync_gkr::compiler::{
AssetId, LayerStrategy, LeafPayload, LeafState, MerklePath, PublicInputs, SmtOpKind,
SmtOperation, SmtParams, SmtWitness,
};
use statesync_gkr::primitives::field::{BaseField, PrimeCharacteristicRing};
use statesync_gkr::primitives::hash::{Digest, HashGadget, Poseidon2Gadget};
use statesync_gkr::wrap::encoding::{DecodeError, decode_inner_proof};
use statesync_gkr::{PreparedSync, StateSyncGkrConfig, StateSyncProver, SyncRequest};
/// One message on the wire: the requester's identifier and the encoded proof.
#[derive(Clone, Debug)]
struct ProofMessage {
request_id: u64,
proof: Vec<u8>,
}
/// The verifying side: its own prover instance and preparation, and the
/// original requests it is waiting for, keyed by request identifier.
struct Verifier {
engine: StateSyncProver,
prepared: PreparedSync,
pending: HashMap<u64, SyncRequest>,
}
impl Verifier {
fn new(config: StateSyncGkrConfig) -> Result<Self, String> {
let engine = StateSyncProver::new(config);
let prepared = engine
.prepare(SmtOpKind::Membership)
.map_err(|error| format!("verifier preparation failed: {error:?}"))?;
Ok(Self {
engine,
prepared,
pending: HashMap::new(),
})
}
/// Record a request before anyone is asked to prove it.
fn expect(&mut self, request_id: u64, request: SyncRequest) {
self.pending.insert(request_id, request);
}
/// Check one received message against the original request it names.
fn receive(&mut self, message: &ProofMessage) -> String {
let Some(request) = self.pending.get(&message.request_id) else {
return format!("request {}: unknown or already settled", message.request_id);
};
// The original request selects the preparation; the bytes do not.
if request.operation.kind() != self.prepared.kind() {
return format!("request {}: no preparation for its kind", message.request_id);
}
if self
.engine
.verify_encoded_sync_op(&self.prepared, request, &message.proof)
{
self.pending.remove(&message.request_id);
return format!("request {}: accepted", message.request_id);
}
format!(
"request {}: rejected ({})",
message.request_id,
self.diagnose(request, &message.proof)
)
}
/// Explain a rejection. Diagnostics only: the verdict is the `false` above.
fn diagnose(&self, request: &SyncRequest, bytes: &[u8]) -> String {
let envelope = match decode_inner_proof(bytes) {
Ok(envelope) => envelope,
Err(DecodeError::DeclaredLengthMismatch { .. }) => {
return "received length differs from the declared length".to_owned();
}
Err(error) => return format!("malformed encoding: {error:?}"),
};
match self.engine.circuit_identity(&self.prepared) {
Ok(expected) if expected == envelope.identity => {}
Ok(_) => return "proof was made for another circuit or configuration".to_owned(),
Err(error) => return format!("no local circuit identity: {error:?}"),
}
if envelope.public_inputs != request.public_inputs {
return "proof is for different public inputs than the original request".to_owned();
}
"identity and public inputs match; the request or the proof failed verification".to_owned()
}
}
/// The proving side: prove one request and encode the result for transport.
fn prove_message(
prover: &StateSyncProver,
prepared: &PreparedSync,
request_id: u64,
request: &SyncRequest,
) -> Result<ProofMessage, String> {
let result = prover
.prove_sync_op_prepared(prepared, request)
.map_err(|error| format!("request {request_id}: proving failed: {error:?}"))?;
let proof = prover
.encode_sync_result(prepared, &result)
.map_err(|error| format!("request {request_id}: encoding failed: {error:?}"))?;
Ok(ProofMessage { request_id, proof })
}
fn field(value: u32) -> BaseField {
BaseField::from_u32(value)
}
/// A synthetic membership request. A real service reads the root, the leaf
/// and the path from its own state store.
fn membership_request(params: &SmtParams, key: u64) -> Result<SyncRequest, String> {
let hasher = Poseidon2Gadget::new(params.leaf_max_fields as usize);
let key = AssetId(key);
let payload = LeafPayload {
sync_state: vec![field(100), field(7)],
identity_digest: [9_u8; 32],
};
let leaf = LeafState::Occupied(payload.clone());
let path = MerklePath {
siblings: (0..params.depth)
.map(|level| Digest([field(level * 13 + 1); 8]))
.collect(),
};
let root = path
.compute_root(&hasher, params, key, &leaf)
.map_err(|error| format!("root construction failed: {error:?}"))?;
let value_digest = hasher
.hash_leaf(&leaf.encode())
.map_err(|error| format!("leaf hashing failed: {error:?}"))?;
let operation = SmtOperation::Membership { key, payload };
let op_kind_tag = PublicInputs::kind_tag(operation.kind());
Ok(SyncRequest {
operation,
witness: SmtWitness { leaf, path },
public_inputs: PublicInputs {
old_root: root,
new_root: root,
op_kind_tag,
asset_id: key,
value_digest,
},
})
}
fn run() -> Result<(), String> {
let config = StateSyncGkrConfig {
smt: SmtParams {
depth: 4,
..Default::default()
},
layer_strategy: LayerStrategy::A,
..Default::default()
};
let params = config.smt;
// Each side prepares from the same configuration, as two processes would.
let prover = StateSyncProver::new(config.clone());
let prover_prepared = prover
.prepare(SmtOpKind::Membership)
.map_err(|error| format!("prover preparation failed: {error:?}"))?;
let mut verifier = Verifier::new(config)?;
// The verifying side records each request under its identifier first.
let request_1 = membership_request(¶ms, 5)?;
let request_2 = membership_request(¶ms, 6)?;
let request_3 = membership_request(¶ms, 7)?;
let request_4 = membership_request(¶ms, 8)?;
verifier.expect(1, request_1.clone());
verifier.expect(2, request_2.clone());
verifier.expect(3, request_3.clone());
verifier.expect(4, request_4);
// The proving side answers requests 1 to 3.
let reply_1 = prove_message(&prover, &prover_prepared, 1, &request_1)?;
let reply_2 = prove_message(&prover, &prover_prepared, 2, &request_2)?;
let reply_3 = prove_message(&prover, &prover_prepared, 3, &request_3)?;
// Replies can arrive in any order.
println!("{}", verifier.receive(&reply_2));
println!("{}", verifier.receive(&reply_1));
// A settled identifier is not accepted twice.
println!("{}", verifier.receive(&reply_1));
// Damaged in transit: the last byte is missing.
let mut damaged = reply_3.clone();
damaged.proof.pop();
println!("{}", verifier.receive(&damaged));
// A valid proof delivered under another request's identifier.
let misrouted = ProofMessage {
request_id: 4,
proof: reply_3.proof.clone(),
};
println!("{}", verifier.receive(&misrouted));
// The intact reply still verifies.
println!("{}", verifier.receive(&reply_3));
Ok(())
}
fn main() -> ExitCode {
match run() {
Ok(()) => ExitCode::SUCCESS,
Err(error) => {
eprintln!("transport=FAIL: {error}");
ExitCode::FAILURE
}
}
}Running it prints:
request 2: accepted
request 1: accepted
request 1: unknown or already settled
request 3: rejected (received length differs from the declared length)
request 4: rejected (proof is for different public inputs than the original request)
request 3: accepted- Replies 2 and 1 arrive out of order and are both accepted, because each reply is checked against the request its identifier names.
- The second delivery of reply 1 finds no pending entry: the verifier removed it when it accepted the first delivery.
- Reply 3 with its last byte missing fails decoding, and the diagnosis reads the decoder's error.
- The intact bytes of reply 3, delivered under identifier 4, decode and carry the expected circuit identity, but their public inputs belong to request 3. Verification rejects them.
- The intact reply 3 is accepted afterwards. A
falseresult is final for the bytes that were rejected, not for the request they claimed to answer.
Match replies by request identifier#
- Assign the identifier where the request enters your system, and keep the original request until a verdict exists. Neither
SyncResultnor the encoding carries an identifier. - The identifier routes a reply; it does not authenticate one. A proof delivered under the wrong identifier is rejected because verification compares it with the request that identifier names, as request 4 shows.
- Select the preparation from the original request's operation kind. The decoded circuit identity is then compared with that choice; it never makes the choice.
- Proving is deterministic: the same request under the same configuration and
protocol_versionproduces the same bytes with any worker count and on both the baseline and the CPU-specific builds that the repository compares, so a retransmitted proof is identical to the first. You may detect duplicates by comparing bytes, but count a request as accepted only from the verifier's result. A matching hash is not verification. - Decide how long an unanswered request stays pending and what happens to it afterwards. The library has no timeouts.
Frame and bound messages#
- Pass
verify_encoded_sync_opexactly one complete message. The header declares the total length, the decoder rejects a buffer of any other length, and bytes after the last field are rejected. On a byte stream, frame each proof yourself, for example with a length prefix, or read the declared total length from the four header bytes at offsets 13 through 16, a little-endianu32. - Bound the size of a message before you decode it. Wire format gives the length formula. In the measured profiles, complete encodings were 176,948 to 201,086 bytes for the three operations at depths 24 to 32; Performance tuning lists each profile.
- Give your transport its own error detection. Damaged bytes are rejected by the decoder or by the verifier either way, but only the transport can tell transport damage from a wrong proof and ask for the message again.
Why received bytes are rejected#
verify_encoded_sync_op returns false in every case below. The last column shows what a diagnosis like the one in the example reports.
| Cause | Check that fails | What the diagnosis shows |
|---|---|---|
| Bytes cut, extended or concatenated in transit | Decoder | DeclaredLengthMismatch, or HeaderTooShort when fewer than 17 bytes arrive (BadMagic when the start was lost) |
Not an inner-proof-v1 message, or a version this release does not decode | Decoder | BadMagic, UnsupportedProofEncodingVersion, UnsupportedProtocolVersion, UnsupportedCircuitVersion, UnsupportedLeafEncodingVersion, UnsupportedProofKind or UnsupportedLayerStrategy |
| Malformed or modified content, including counts that disagree with the declared length | Decoder | NonCanonicalFieldElement, BadRoundPolyArity, UnknownOpKindTag, OpKindMismatch, OversizedCount, Truncated or TrailingBytes |
| A proof made under another configuration or for another operation kind | Identity comparison | The decoded identity differs from circuit_identity(&prepared) |
| A proof for another request: a wrong identifier, mixed-up replies or a stale proof | Public-input comparison | The decoded public_inputs differ from the original request's |
| Modified proof messages, or a request that does not hold | The verifier's checks on the request and the proof | Identity and public inputs match, and verification still returns false |
Errors describes every DecodeError variant, and Troubleshooting covers requests that do not hold.
Diagnose a rejection#
verify_encoded_sync_op reports every failure as false. To learn which check failed, repeat its first steps yourself, as Verifier::diagnose does in the example:
wrap::encoding::decode_inner_proof(&bytes)returns theDecodeErrorfor any problem with the bytes.circuit_identity(&prepared)returns the identity the verifier expects. Compare it with the decodedidentity.- Compare the decoded
public_inputswith the original request's public inputs.
If all three agree, the request checks, the GKR checks or the final input claims failed; Troubleshooting shows how to narrow that down. Use a diagnosis for logs and alerts only. The verdict is the verifier's false, and a diagnosis never turns it into acceptance.
Versions and accepted configurations#
- The encoding carries four version numbers: for the byte encoding, the protocol, the circuit and the leaf encoding. This release decodes only version 1 of each and rejects anything else, and there is no version negotiation.
- The encoding carries the configuration values themselves rather than a configuration name. Which configurations a verifier accepts is the verifier's own policy. Make that policy explicit: keep one preparation for each operation kind and configuration you accept, and no others. Proofs made under any other configuration fail the identity comparison.
Getting the witness to the verifier#
The current verifier rebuilds the circuit input from the request's leaf and sibling path, so the verifying side must hold the witness for the root that the proof states. It can read the leaf and path from its own copy of the state, or receive them over a channel through which it is entitled to see them. The inner proof is not zero-knowledge, so design the data flow on the assumption that the verifying party sees the witness.
The external wrap path#
The same encoded bytes are the input to wrap_relation and wrap_sync_op, which connect an inner proof to an outer proof system. The published outer proof covers one fixed depth-24 membership example and is not a service for arbitrary requests; see Trust boundaries.
Checklist#
- Prover and verifier build their preparations from the same configuration.
- Every request has an identifier from admission until its verdict, and the original request is kept until then.
- The preparation is selected from the original request's operation kind.
- Each message carries one complete encoding, and its size is bounded before decoding.
- A request counts as accepted only when
verify_encoded_sync_opreturnstruefor it. - A
falseresult is recorded and never retried for the same bytes. - The verifying side holds the witness for the root that each proof states.
Next steps#
- Production integration: put encoding and verification into a service.
- Wire format: the normative byte layout and decoding rules.
- Errors: every error type, including
EncodeErrorandDecodeError. - Troubleshooting: narrow down a rejected request.