DEV Community

Cover image for The Commitment Has to Come First: What My Rust KZG Prototype Taught Me About Binding
Samuel Dahunsi
Samuel Dahunsi

Posted on Originally published at samueldahunsi.me

The Commitment Has to Come First: What My Rust KZG Prototype Taught Me About Binding

A polynomial-opening proof can pass every pairing check and still fail to prove the statement you care about.

I ran into that distinction while implementing a multilinear KZG-style commitment scheme in Rust as part of my zero-knowledge cryptography study repository. The prototype could commit to a multilinear polynomial, open it at a point, construct quotient witnesses, and verify the resulting pairing equation over BLS12-381.

The tests passed. The algebra checked out. But the first version of the API left an important question unanswered:

Which commitment did the verifier intend to check?

That question sounds administrative. It is actually the boundary between an opening proof and a self-consistent collection of prover-chosen values.

This article walks through the implementation, the equation behind it, the mistake, and the design changes I would make before treating the code as anything more than a learning prototype.

The argument in one sentence

A polynomial commitment opening is meaningful only relative to a commitment that was fixed before the opening proof was produced.

If the prover is allowed to supply the commitment inside the proof—and the verifier has no independently trusted copy—then verification only establishes:

“This value and these witnesses are consistent with this commitment.”

It does not establish:

“This is the value of the polynomial that was committed to earlier.”

The pairing equation cannot recover missing protocol context. The API has to preserve it.

What I implemented

The repository builds the relevant pieces from first principles:

multilinear polynomials represented by their evaluations on the Boolean hypercube;

partial evaluation and interpolation;

a structured reference string evaluated at a secret point;

commitment computation;

quotient-witness generation for an opening point; and

pairing-based verification using Arkworks and BLS12-381.

This is intentionally educational code. It favors making the algebra visible over hiding it behind an optimized polynomial-commitment interface.

One naming detail matters: the Rust field is called coefficients, but the stored vector behaves as an evaluation table. For an n-variable multilinear polynomial, it contains the values

{ f(b) | b ∈ {0,1}n }

Calling these values “coefficients” is convenient in code, but misleading in an explanation. They are not monomial-basis coefficients.

From a Boolean evaluation table to any field point

Let f be a multilinear polynomial in n variables. Its multilinear extension is

f̃(x) = Σb ∈ {0,1}n f(b) χb(x)

where

χb(x) = ∏j=1n [bjxj + (1 − bj)(1 − xj)]

The implementation evaluates the polynomial one variable at a time. If two entries differ only in the variable being fixed, partial evaluation at r is just linear interpolation:

y(r) = y0 + r(y1 − y0)

The Rust implementation is the same interpolation written directly:

fn interpolate(y0: F, y1: F, r: F) -> F {
y0 + r * (y1 - y0)
}

Repeating that fold over all coordinates reduces the full hypercube table to the single value f(r₁, …, rₙ).

This representation is especially useful for studying sumcheck and GKR because those protocols repeatedly fix variables of multilinear extensions. It also makes the multilinear commitment construction easier to inspect.

Committing in the multilinear Lagrange basis

Classic KZG is often introduced for a univariate polynomial in the monomial basis. This prototype instead commits to a multilinear evaluation table in the Lagrange basis over the Boolean hypercube.

For a secret setup point

τ = (τ₁, …, τₙ)

the setup computes group elements corresponding to the basis evaluations

b(τ)]1

for every Boolean vector b. The commitment is then

C = Σb ∈ {0,1}n f(b)[χb(τ)]1
= [f̃(τ)]1

The commitment code mirrors that inner product. Each evaluation scales its
matching basis element, and the results are accumulated in G₁:

fn commit(g1_basis: &[P::G1], evaluations: &[F]) -> P::G1 {
    let mut commitment = P::G1::zero();

    for (i, basis) in g1_basis.iter().enumerate() {
        let scalar = evaluations[i].into_bigint();
        commitment += basis.mul_bigint(scalar);
    }

    commitment
}
Enter fullscreen mode Exit fullscreen mode

This is one of the things I like about implementing cryptography in Rust with Arkworks: group and field operations stay explicit, while the type system prevents accidentally multiplying unrelated algebraic objects.

It also exposed a small but real engineering hazard. This loop indexes one slice using the length of another, while the quotient-commitment path uses zip, which stops at the shorter iterator. Without explicit length checks, malformed dimensions can either panic or be silently truncated. The mathematical definition assumes matching dimensions; the implementation must enforce them.

Opening the polynomial

Suppose the prover claims

v = f̃(r)

at the point r = (r₁, …, rₙ).

For multilinear polynomials, the difference from the claimed value can be decomposed as

f̃(X) − v = Σi=1n (Xi − ri)Qi(X)

where each Qᵢ is a quotient polynomial produced as variables are fixed one by one.

The prototype constructs each quotient from pairs in the current evaluation table. For a variable Xᵢ, the difference between the upper and lower halves gives the slope along that coordinate. After recording that quotient, the current polynomial is partially evaluated at rᵢ, and the process continues.

At the end, the remainder should be zero. That is the computational form of the identity above evaluated at the opening point.

The proof contains the claimed value and commitments to the quotient polynomials. The verifier checks a pairing relation equivalent to

e(C − v[G1], [G2]) = ∏i=1n e([Qi(τ)]1, [τi − ri]2)

Arkworks represents the target-group wrapper with additive operators. That is
why the implementation uses + even though the conventional equation shows a
product:

let claimed_value = g1.mul_bigint(value.into_bigint());
let lhs = P::pairing(commitment - claimed_value, g2);

let mut rhs = PairingOutput::<P>::ZERO;

for (i, tau_i) in g2_taus.iter().enumerate() {
    let r_i = g2.mul_bigint(point[i].into_bigint());
    let tau_minus_r = *tau_i - r_i;
    let quotient = quotient_commitments[i];

    rhs += P::pairing(quotient, tau_minus_r);
}

lhs == rhs
Enter fullscreen mode Exit fullscreen mode

At this point, my tests could generate an opening and verify it successfully. That was useful—but not sufficient.

The API mistake: the proof carried its own commitment

My first proof type looked conceptually like this. The highlighted design problem
is that the proof carries the commitment it is supposed to be checked against:

pub struct KzgProof<G1, F> {
    pub commitment: G1, // Prover-controlled reference
    pub value: F,
    pub quotient_commitments: Vec<G1>,
}
Enter fullscreen mode Exit fullscreen mode

The verifier read commitment from the proof and checked the equation against it.

The equation can be perfectly valid. The problem is that the prover selected every important input to it: the polynomial, the commitment, the claimed value, and the quotient witnesses.

Imagine a database commits to a polynomial encoding a table at time t₀. Later, a client asks for the value at point r. The client needs proof that the answer opens the commitment stored at t₀. If the server can return a fresh commitment alongside the answer, it can choose a different polynomial that gives any convenient value at r and generate a valid opening for that new polynomial.

No pairing check detects this, because the new tuple may be internally correct.

The issue is not broken elliptic-curve arithmetic. It is a broken statement boundary.

The original KZG work defines a polynomial commitment as a short value against which later evaluation claims can be checked. That earlier commitment is the reference point; without it, there is no binding claim to verify. See Kate, Zaverucha, and Goldberg’s original construction.

A better interface

The commitment should not be part of the opening proof’s authority. The verifier should receive the expected commitment from trusted application state, a transcript, a signed object, a consensus layer, or some other previously fixed source.

A better separation removes that authority from the proof:

pub struct OpeningProof<G1, F> {
    pub value: F,
    pub quotient_commitments: Vec<G1>,
}
Enter fullscreen mode Exit fullscreen mode

The verifier receives the expected commitment independently:

pub fn verify_opening<P: Pairing>(
    vk: &VerifierKey<P>,
    expected_commitment: P::G1,
    point: &[P::ScalarField],
    proof: &OpeningProof<P::G1, P::ScalarField>,
) -> Result<bool, VerificationError> {
    validate_dimensions(vk, point, proof)?;
    verify_pairing(vk, expected_commitment, point, proof)
}
Enter fullscreen mode Exit fullscreen mode

This API says more than a comment could:

the proof supplies an opening, not the identity of the committed object;

the caller supplies the commitment it expects;

the verifier only needs a verifier key, not the polynomial or prover state; and

malformed input is distinct from a well-formed but invalid proof.

For a transcript-based protocol, I would also absorb the commitment, evaluation point, claimed value, and protocol/domain identifier into the transcript before deriving any Fiat–Shamir challenges. For an application protocol, I would bind the commitment to the relevant record ID, chain, version, and context.

“The math checks” and “the proof is bound to this application statement” are separate properties. Both need to be designed.

Other changes needed before production

The commitment-boundary issue was the most instructive finding, but it was not the only gap.

  1. Separate prover and verifier state

The current KZG object owns the polynomial and the complete setup. A real verifier should not need the witness polynomial. I would split the code into ProverKey, VerifierKey, commitment, and opening-proof types, with the smallest possible verifier surface.

  1. Treat setup generation as a security protocol

The prototype initializes its setup from explicit, locally known τᵢ values. That is useful for deterministic tests and completely inappropriate as a trusted setup. If the trapdoor is known, binding can fail because forged openings become possible.

A production design needs a secure ceremony, a setup inherited from a correctly generated system, or a transparent commitment scheme with different assumptions. The setup’s origin, size, validation, and update story are protocol requirements—not deployment details.

  1. Validate dimensions before doing algebra

The implementation should reject:

non-power-of-two evaluation tables;

a point whose length differs from the polynomial’s variable count;

setup vectors of the wrong size;

the wrong number of quotient commitments; and

invalid or non-canonical group encodings during deserialization.

Assertions are helpful during development, but public verification APIs should return structured errors and must not panic on adversarial input.

  1. Add serialization and transcript rules

Cryptographic types are not a wire protocol by themselves. A production version needs canonical serialization, subgroup checks, versioning, domain separation, and explicit transcript ordering. Two implementations that perform the same abstract operations can still disagree—or become vulnerable—if they encode or hash them differently.

  1. Test failures, not only successes

My happy-path tests use small explicit polynomials and known opening points. The next useful test suite should mutate one component at a time:

expected commitment;

opening point;

claimed value;

one quotient commitment;

proof length; and

serialized group elements.

Property tests can compare multilinear evaluation against a slow reference implementation. Fuzzing should target parsers and verification entry points. Benchmarks should separate commitment time, witness generation, and verification.

  1. Be precise about what is—and is not—zero knowledge

A polynomial commitment and an evaluation proof are not automatically zero knowledge. Depending on the scheme and how it is used, the commitment or openings may leak information. Hiding requires additional randomization and a security argument for the complete protocol.

This prototype studies commitment and opening mechanics; it does not yet provide a production zero-knowledge polynomial-commitment layer.

Why this lesson matters beyond KZG

The same design error appears in many forms:

verifying a Merkle path against a root supplied by the prover instead of the root stored by the application;

checking a signature while letting the request choose the public key with no identity binding;

validating a SNARK against a verification key or public input that is not tied to the intended circuit or state;

accepting an attestation whose issuer identifier is not anchored to a trusted registry.

Cryptographic verification proves a precise relation over its inputs. It does not decide whether those inputs are the ones your application meant to trust.

That responsibility lives at the boundary between cryptographic code and protocol code—which is why the types and function signatures matter so much.

What I learned

Implementing the scheme from the evaluation table upward made the quotient identity and pairing equation much more concrete. But the more valuable lesson came after the equation worked:

A verifier does not merely need valid algebra. It needs a valid statement, anchored to prior state.

For my next iteration, I would start with the protocol roles and trusted inputs, then make the Rust types encode them. Only after that would I optimize multi-scalar multiplication, batch openings, or benchmark curves.

That ordering is less exciting than making a proof verify for the first time. It is also what turns a cryptographic experiment into dependable infrastructure.

The implementation discussed here is in the kzg module of my ZK cryptography study repository. The repository is educational and exploratory; it should not be used as production cryptographic software.

For more of my implementation notes and protocol work, visit samueldahunsi.me/writing.

Top comments (0)