Proof-Facing Field-Domain Canonicalization Discipline
- Name: proof-facing field-domain canonicalization discipline
- Category: zk-integration reference / caller-boundary checklist / field-domain safety note / proof-facing object-creation discipline
- Summary: Healthy zk integrations do not treat the proving field as a verifier implementation detail. They publish one canonical domain for every proof-facing object — nullifier, external nullifier, group id, Merkle zero value, commitment, or similar identifier — and they enforce that domain at the first caller-facing boundary where the object can become storage, uniqueness state, or user-visible protocol state. This note exists to keep the positive design target explicit instead of making readers reconstruct it only by inverting Semaphore, a16z ZkDrops, or ZK-Kit incidents.
- Why this note exists:
- Proof-facing bugs are often narrated as separate stories — nullifier replay, oversized poll ids, unusable Merkle trees — even though the design failure is usually one missing artifact: the protocol never made
this value is a field elementan end-to-end API contract. - Verifiers often already know the right numeric domain. The recurring failure is that caller-facing contracts, wrappers, or libraries still accept broader
uint256values and let those values become state before the verifier ever sees them. - The useful design habit is to review one security object all the way through: user input, storage key, object creation, proof verification, and any later payout or liveness path that depends on the same value.
- Proof-facing bugs are often narrated as separate stories — nullifier replay, oversized poll ids, unusable Merkle trees — even though the design failure is usually one missing artifact: the protocol never made
Fast classifier: which proof-facing object is at risk of leaving its field domain?
same logical nullifier, two raw encodings— the verifier sees one field element while storage tracks rawnandn + qas different keys. Semaphore nullifier alias replay and a16z ZkDrops are the clean exemplars.object created successfully, but verifier can never honor it later— the contract accepts agroupId, external nullifier, or similar identifier outside the proving field, so the application publishes live-looking state that is dead on arrival for the proof system. [[../incidents/proofs-verifiers/semaphore-groupid-external-nullifier-range-check-omission-inoperable-poll-near-miss-2022-03-07|Semaphore oversizedgroupId/ external nullifier]] is the clean exemplar.library initialized with impossible proof-path material— a tree, commitment object, or zero value becomes stored state even though later proof consumers will reject it as out of field. ZK-Kit zero-leaf tree initialization is the clean exemplar.- Use this note when the sharper positive-design question is
where exactly should the protocol force every proof-facing value into one canonical field-safe object before it becomes state or authority?
What this note means by field-domain canonicalization
- Field-domain canonicalization means the protocol can state, in code and in review artifacts, that a proof-facing value has one allowed numeric domain from user input to verifier semantics.
- The useful positive-design claim is not merely
the verifier will reject bad inputs. It isthe contract cannot even create replay state, object state, or proof-backed library state that lies outside the proving field. - The design target is stronger than simple range checking. A healthy system also makes storage, replay protection, emitted events, and downstream comparisons all speak about the same canonical object the verifier reasons about.
The six joints a healthy proof-facing value should keep fused
1. Caller-facing API domain
- The first public function that accepts a proof-facing value should already know whether the value is semantically
arbitrary uint256orfield element below q. - If the verifier only makes sense for field elements, the public API should expose the same rule directly.
We check it lateris usually how impossible state gets published.
2. Canonical storage representation
- Once the protocol stores a proof-facing value, that stored form should already be the canonical verifier-side object.
- Nullifier maps, poll registries, external-nullifier tables, and Merkle initialization state should not preserve a broader raw representation than the proving system accepts.
- Storage should not distinguish values the verifier treats as identical.
3. Object-creation validity
- Creating a group, tree, claim surface, or wrapper around a proof system is itself a zk security boundary.
Object exists onchainshould already implythis object can later participate in the proof flow it was created for.- If creation can author unverifiable or unspendable state, the protocol has already split its truth object.
4. Proof verification semantics
- The verifier’s field domain should be treated as protocol truth, not local verifier trivia.
- A healthy design makes it obvious that the verifier is checking the same value family the contract accepted and stored.
- This is where replay bugs and availability bugs stop looking different and start looking like the same missing canonicalization step.
5. Economic or operational consumer path
- If verifier success unlocks signaling, claims, withdrawals, or object usability, the consumer path should rely on the same canonicalized value that the verifier consumed.
- A nullifier should not be field-safe inside the proof and raw in the payout path.
- A poll id should not be raw during creation and field-bounded only when voting starts.
6. Regression and impossibility testing
- The protocol should prove that aliased values like
x,x + q, andx + 2qcannot create distinct state or duplicate rights. - It should also prove that any object it can create remains usable in later proof flows.
- The test target is not only theft resistance but
impossible proof-backed objects cannot be authored at all.
What sound design looks like
- Proof-facing values have one published machine domain
- Reviewers should be able to point to one invariant such as
nullifier < q,groupId < q, orzero < qand show that the invariant is enforced before storage or object creation.
- Reviewers should be able to point to one invariant such as
- Creation and consumption share the same object
- The same canonical value should be used for verifier inputs, replay protection, group identity, emitted events, and later comparisons.
- A healthy system does not let storage track one broader object while the verifier reasons about a narrower one.
- Impossible proof-backed state is rejected at creation time
- A poll, tree, or claim object should never be publishable unless the proving system could later honor it under the same assumptions.
- Wrappers inherit the same boundary checks as base contracts
- The caller-facing extension is often the real bug surface, even when the underlying verifier already encoded the right assumptions.
- Tests probe the alias boundary directly
- Good tests do not stop at
proof verifies for a normal value; they also prove that nearby out-of-field encodings cannot create fresh rights or unusable objects.
- Good tests do not stop at
Useful positive design shapes
1. Field-bounded types at the public boundary
- The cleanest shape is a shared helper or type discipline that forces proof-facing values through one
assertFieldElement(x)step before any state transition. - Even if Solidity ultimately stores
uint256, the semantic type should befield-safe proof input, notgeneric integer. - This is the most direct answer to Semaphore- and ZkDrops-style drift.
2. Creation-time liveness guarantees
- Group creation, poll creation, tree initialization, and similar setup paths should enforce the same field-domain guarantees that later proof verification expects.
- The useful design standard is
creation implies future proof usability, not merelycreation did not revert. - This is the clean positive-design answer to Semaphore oversized-
groupIdand ZK-Kit zero-leaf incidents.
3. Canonical spent-key discipline
- Replay protection should consume exactly the same canonical nullifier object the verifier authenticates.
- If the protocol logs or stores proof-facing values, the logged/stored representation should already match that canonical object too.
- The strongest shape is that there is no reachable code path where two raw encodings of one field element can produce two distinct replay keys.
4. Shared scalar-field guard libraries across extensions
- If one protocol family has voting, signaling, airdrop, or whistleblowing wrappers around the same proving stack, all of them should reuse the same caller-boundary guards.
- The protocol should not rely on each downstream extension remembering that
groupId,scope, ornullifierstopped being a generic app identifier once it entered the proof system.
5. Object-usability invariants in tests and audits
- Test suites should explicitly create edge-case polls, trees, and claims at the highest accepted values and prove they still admit the intended proof flow.
- The audit question is not only
can an attacker forge a proof?but alsocan this protocol author proof-backed state the proof system itself will later reject?
Design questions worth asking first
- What exact proof-facing values does this contract or library accept from callers?
- For each one, where is
< qor equivalent canonicalization first enforced? - Does storage preserve the same object the verifier reasons about, or a broader raw encoding?
- Can a successfully created poll, group, tree, or claim object ever be impossible to use in the later proof flow it was created for?
- Can
xandx + qever produce distinct replay keys, identifiers, or other state even if the verifier would treat them as the same field element? - If an extension contract wraps the base proof system, has it inherited the same field-domain checks or silently widened the API again?
Red flags that usually mean field-domain truth is still split
The verifier rejects that anywayused as if verifier rejection were a safe substitute for creation-time validityIt's just a poll idorit's just a zero valueeven though the value later becomes a proof public input or Merkle-path element- replay maps keyed by raw
uint256orbytes32while the proof system reasons modulo a scalar field - wrappers or extensions that forward proof-facing values without reusing the base contract’s scalar-field checks
- successful object creation treated as proof of usability without any later proof-path test
- test coverage that exercises normal inputs but never probes
x + qaliases or maximum accepted ids
Failure-prevention checklist
- Enforce the proving-field bound at the first caller-facing boundary for every proof-facing value before storage writes, object creation, replay checks, event emission, or proof verification.
Verifier rejects it lateris not an acceptable substitute once state can already diverge. - Canonicalize proof-facing values once and reuse that exact representation everywhere that matters: verifier inputs, replay-protection maps, group registries, tree metadata, emitted events, indexer-facing identifiers, and downstream payout or liveness checks.
- Make creation-time validity carry future proof usability. A poll, group, tree, or claim surface should never be publishable unless its identifiers, zero values, and initialization material already satisfy the same field-domain assumptions the later proof flow requires.
- Keep replay and payout logic keyed by the same canonical object the verifier authenticates. A logical nullifier, scope, or identifier must not be able to appear as two different raw keys across storage and settlement paths.
- Reuse shared scalar-field guard helpers across wrappers, extensions, factories, and governance-added modules so the caller boundary cannot silently widen back to raw
uint256semantics one layer out from the audited core. - Add direct alias tests for
x,x + q, andx + 2qon every replay-sensitive and availability-sensitive surface. Require those values to be rejected or normalized into one identical object rather than creating fresh rights or unusable state. - Test maximum accepted creation values, not only normal cases. If the protocol can create a poll, group, or tree at some edge-case identifier, the suite should prove that the resulting object still admits the intended proof flow.
- Treat emitted identifiers and offchain consumers as part of the canonicalization boundary. Logs, indexers, and relayers should not have to infer which raw encoding was the authoritative proof-facing object.
- During upgrades, prove the new verifier field, wrapper code, storage format, and object-creation helpers still describe the same domain object rather than quietly shifting one side of the boundary.
- Keep a regression corpus of prior field-alias failures so future refactors re-run the exact
nversusn + q, oversized-id, and out-of-field-zero-value cases that previously broke live systems.
Audit prompts
- Where exactly is
< qor equivalent canonicalization first enforced for each proof-facing value, and can any caller path reach storage, object creation, events, or proof verification before that guard runs? - Does storage preserve the same semantic object the verifier reasons about, or can raw
uint256/bytes32encodings still distinguish values the proof system aliases modulo the field? - Can
x,x + q, orx + 2qever produce distinct replay keys, identifiers, or emitted operational truth even though the verifier would treat them as the same field element? - Can the protocol successfully create a poll, group, tree, or claim surface that the proving system can never later honor under ordinary use?
- Do wrapper contracts, factories, governance-added modules, or application-specific claimers reuse the same scalar-field guards as the audited base verifier integration, or do they silently widen the public API again?
- If a proof-facing value appears in events, indexers, relayer messages, or offchain caches, which representation is authoritative, and could two raw encodings of one logical object fork downstream operational truth?
- Are there any places where a verifier-side rejection is relied upon as the only control even though state, uniqueness, or user-visible object creation already happened earlier?
- During migrations or verifier swaps, where is the assertion that the new circuit field, wrapper boundary, stored identifiers, and downstream consumers still agree on one canonical object?
- What exact test proves
object created onchainalready impliesobject remains usable in the intended proof flow laterfor edge-case identifiers and initialization values? - If a reviewer asked why one proof-facing value cannot ever become two economic rights or one dead-on-arrival object, what single canonicalization step would you point to in the code?
Lessons that should already be standard
- The proving field is part of the public API, not a hidden verifier implementation detail. If callers can submit broader values than the proof system understands, the protocol already has two competing truths.
Object created successfullyis not enough. For proof-backed systems, creation should already imply future proof usability under the same field-domain assumptions.- Replay protection only works when storage and verifier semantics share one canonical identifier. Raw-value uniqueness is meaningless when the proof system aliases modulo
q. - Wrapper contracts are where field-domain discipline often dies. Teams remember the modulus in the audited core and forget to restate it in polls, claimers, factories, or governance-added modules.
- Offchain consumers inherit canonicalization risk. If events expose raw proof-facing values without one authoritative encoding, indexers and relayers can fork operational truth before theft is even visible onchain.
- Edge-case tests are not optional polish.
Try x + qandtry the largest accepted identifiershould be permanent fixtures anywhere proof-facing values cross into storage or authority. - The clean design mantra is simple: one proof-facing value, one accepted domain, one stored representation, everywhere.
Negative exemplar cluster: what no field-domain canonicalization looks like
- Semaphore nullifier alias replay preserves the clean
same verifier-side nullifier, different raw replay keyshape. - a16z ZkDrops preserves the payout-side version of the same mistake: one Merkle claim right could become two raw nullifier encodings.
- [[../incidents/proofs-verifiers/semaphore-groupid-external-nullifier-range-check-omission-inoperable-poll-near-miss-2022-03-07|Semaphore oversized
groupId/ external nullifier]] preserves theobject created, proof system cannot honor it lateravailability cousin. - ZK-Kit zero-leaf tree initialization preserves the library-level
stored path material was never verifier-safecousin. - The preserved common lesson is not merely
range checks were missing. It is that contracts, libraries, and verifiers were not forced to share one canonical proof-facing object before that object became state, authority, or liveness.
How this note complements the exploit corpus
- Start with public-input-domain-mismatch when the key question is
did the contract and verifier disagree about the numeric domain of a proof-facing input? - Start with nullifier-canonicalization-failure when the sharper exploit-side question is
did one logical nullifier reappear under two raw encodings? - Use this note when the sharper design question becomes
what exact caller-boundary, storage, and object-creation discipline should have kept that proof-facing value canonical from the start? - Pair with creation-time-proof-usability-and-dead-on-arrival-object-prevention when the field-domain question is not enough because the sharper design failure is that the protocol was allowed to publish a poll, tree, or other proof-backed object before proving that object would remain usable in the later lifecycle.
- Pair with nullifier-right-object-canonicalization-discipline when the field-domain question is not enough because the protocol also needs a sharper answer to
what exact semantic one-time right should this nullifier have represented before any witness artifact was hashed into replay-prevention state? - Pair with proof-effect-truth-security-object when the proof-facing values are canonical but the later settlement consumer may still honor a different economically relevant effect set.
- Pair with exact-production-artifact-attestation-and-rollback-discipline only after the proving-domain object itself is already coherent and the remaining question is whether the right verifier or circuit artifacts actually reached production.
Internal linkages
- Core exploit family: public-input-domain-mismatch.
- Narrower replay-family companion: nullifier-canonicalization-failure.
- Adjacent object-lifecycle companion: creation-time-proof-usability-and-dead-on-arrival-object-prevention.
- Adjacent nullifier-identity companion: nullifier-right-object-canonicalization-discipline.
- Adjacent proof-consumer companion: proof-effect-truth-security-object.
- Adjacent release-integrity companion: exact-production-artifact-attestation-and-rollback-discipline.
- Best current negative exemplars: semaphore-nullifier-field-modulus-aliasing-double-signal-replay-near-miss-2019-07-25, a16z-zkdrops-privateairdrop-nullifier-range-check-double-claim-near-miss-2022-01-26, semaphore-groupid-external-nullifier-range-check-omission-inoperable-poll-near-miss-2022-03-07, zk-kit-incremental-binary-quin-tree-zero-leaf-snark-scalar-range-check-omission-near-miss-2022-03-09.
Control surface
-
The decisive control surface is not only the verifier contract.
-
It is the full path where proof-facing values first enter protocol reality: public function parameters, wrapper contracts, storage keys, object-creation helpers, library initializers, emitted identifiers, and any later path that consumes those values as replay protection or liveness truth.
-
Protocols get materially safer when that whole path agrees on one canonical field-safe object instead of letting
verifier-side field elementandcontract-side integerdrift apart into different meanings. -
Sources:
- public-input-domain-mismatch
- nullifier-canonicalization-failure
- semaphore-nullifier-field-modulus-aliasing-double-signal-replay-near-miss-2019-07-25
- a16z-zkdrops-privateairdrop-nullifier-range-check-double-claim-near-miss-2022-01-26
- semaphore-groupid-external-nullifier-range-check-omission-inoperable-poll-near-miss-2022-03-07
- zk-kit-incremental-binary-quin-tree-zero-leaf-snark-scalar-range-check-omission-near-miss-2022-03-09
- https://github.com/semaphore-protocol/semaphore/issues/16
- https://github.com/semaphore-protocol/semaphore/issues/90
- https://github.com/a16z/zkdrops/pull/2
- https://github.com/zk-kit/zk-kit/issues/23
-
Last reviewed: 2026-07-15 UTC