zkvm: a witness is exactly as long as the key's circuit says

A verifying key holds one K point per public input plus a constant term, so it
states how many inputs the circuit it was made for takes. The witness arrives
with the transaction, so a peer picks its length, and only the upper end was
checked. Too few is quieter and no better: the linear combination runs over the
witness, so a short one leaves the trailing K points out and judges the proof
against a smaller statement than the circuit was compiled for.

The count is now exactly what the key states. That is read off the key, not
assumed about the circuit — how many inputs a circuit takes is the key's to say,
while what the trailing ones MEAN still needs whoever owns the circuit. They
remain bound in count and unbound in value.

🪤 The test named for reaching the pairing kept passing after this change while
no longer reaching it: it built a key stating three inputs, supplied two, and
asserted only that the error said "verification failed" — which the new refusal
also says. Its key now states the two it supplies, and it asserts the pairing's
own verdict, so it fails if anything in front of the arithmetic answers first.

Co-authored-by: Hanzo Dev <[email protected]>
This commit is contained in:
2026-08-26 10:19:28 -07:00
co-authored by Hanzo Dev
parent dfdf4fb222
commit 645aef166f
2 changed files with 66 additions and 10 deletions
+12 -7
View File
@@ -486,14 +486,19 @@ func (pv *ProofVerifier) verifyGroth16WithGnark(proof *ZKProof, vkBytes []byte)
// verifyGroth16Pairing performs the Groth16 pairing check
// Verifies: e(A, B) = e(alpha, beta) * e(sum(pubInput_i * K_i), gamma) * e(C, delta)
//
// K carries one point per public input plus the constant term K[0], so a key
// of n points speaks about n-1 public inputs. The witness length arrives with
// the transaction and a peer chooses it, so it is bounded against the key
// before any K point is read.
// K carries one point per public input plus the constant term K[0], so a key of
// n points speaks about exactly n-1 public inputs — that count is a property of
// the circuit the key was made for, not something a transaction chooses.
//
// The witness arrives with the transaction, so a peer picks its length. Too many
// would read past the end of K. Too few is quieter and no better: the sum below
// runs over the witness, so a short one leaves the trailing K points out of the
// linear combination and checks a smaller statement than the circuit was
// compiled for. It has to be exactly what the key says.
func verifyGroth16Pairing(proof *Groth16Proof, vk *Groth16VerifyingKey, witness []fr.Element) error {
if len(witness)+1 > len(vk.K) {
return fmt.Errorf("public inputs: %d supplied, verifying key holds %d K points",
len(witness), len(vk.K))
if want := len(vk.K) - 1; len(witness) != want {
return fmt.Errorf("public inputs: %d supplied, the verifying key's circuit takes %d",
len(witness), want)
}
// Public input linear combination: K[0] + sum(witness_i * K[i+1]).
+54 -3
View File
@@ -175,8 +175,11 @@ func TestWitnessMustFitTheVerifyingKey(t *testing.T) {
// supported deployment and its whole point is that a groth16 proof is accepted
// or rejected on the arithmetic. Any proof that decodes must produce a verdict.
func TestGroth16ReachesItsPairing(t *testing.T) {
// Three K points is a circuit taking two public inputs, which is what this
// transaction supplies. A key that spoke about a different number would be
// refused before the pairing, and this test is about the pairing.
pv := keyedVerifier(t, "groth16", map[string][]byte{
string(TransactionTypeTransfer): groth16Key(4),
string(TransactionTypeTransfer): groth16Key(3),
})
if !pv.VerifyingKeysLoaded() {
t.Fatal("precondition: the chain holds a real verifying key")
@@ -211,8 +214,9 @@ func TestGroth16ReachesItsPairing(t *testing.T) {
}()
// The proof satisfies no statement, so the verdict is "rejected" — but a
// verdict is what has to come back.
if err == nil || !strings.Contains(err.Error(), "verification failed") {
// verdict from the arithmetic is what has to come back, not a refusal from
// the checks in front of it.
if err == nil || !strings.Contains(err.Error(), "pairing check failed") {
t.Fatalf("expected a pairing verdict, got: %v", err)
}
}
@@ -390,3 +394,50 @@ func TestPLONKProofMustBeWholeAndNoMore(t *testing.T) {
})
}
}
// TestWitnessMustMatchWhatTheKeysCircuitTakes. A verifying key holds one K point
// per public input plus a constant term, so it states exactly how many inputs
// the circuit it was made for takes. A transaction supplies that witness, and a
// peer chooses its length.
//
// Too many reads past the end of K. Too few is quieter and no better: the linear
// combination runs over the witness, so a short one leaves the trailing K points
// out and checks a smaller statement than the circuit was compiled for — the
// proof is then judged against something the key does not describe.
func TestWitnessMustMatchWhatTheKeysCircuitTakes(t *testing.T) {
// Four K points is a circuit taking three public inputs.
vk, err := deserializeVerifyingKey(groth16Key(4))
if err != nil {
t.Fatalf("deserialize key: %v", err)
}
proof, err := deserializeGroth16Proof(groth16Frame())
if err != nil {
t.Fatalf("deserialize proof: %v", err)
}
witness := func(n int) []fr.Element {
out := make([]fr.Element, n)
for i := range out {
out[i].SetUint64(uint64(i + 1))
}
return out
}
for _, n := range []int{0, 1, 2, 4, 9} {
err := verifyGroth16Pairing(proof, vk, witness(n))
if err == nil {
t.Fatalf("%d public inputs judged against a circuit taking 3", n)
}
if !strings.Contains(err.Error(), "public inputs") {
t.Fatalf("%d inputs: refused for the wrong reason: %v", n, err)
}
}
// The count the key states reaches the arithmetic, which is the control:
// without it the refusals above would prove only that this rejects
// everything.
err = verifyGroth16Pairing(proof, vk, witness(3))
if err == nil || !strings.Contains(err.Error(), "pairing check failed") {
t.Fatalf("the count the key states must reach the pairing, got: %v", err)
}
}