Building a proof
This is the hands-on part of the anonymous tier. What a proof means and what a Control Plane does with it is in anonymous access.
One warning before you start: most mistakes here are silent. The proof builds, it is sent, it is refused, and nothing on either side says which detail was wrong. Every value below matters.
The circuit and its version
Section titled “The circuit and its version”Semaphore v4, tree depth 16.
The version is published on chain and must match character for character:
SubscriberRegistry.circuitVersion() → "semaphore/4.14.3/16"The registry’s address comes from SingulaProtocol.snapshot(). Read the version at start and compare it with your own artifacts.
Artifacts
Section titled “Artifacts”You need a .wasm witness generator and a .zkey proving key, both for tree depth 16 and both matching the version the registry names. Use the official Semaphore v4 artifacts from the project’s published trusted setup — the same files Wanderer ships as semaphore-16.wasm and semaphore-16.zkey. Do not generate your own: a proving key from a different ceremony describes a different circuit, and every proof built with it is refused.
Ship them inside your app instead of downloading them. For two reasons, and the second matters more.
A client that has to reach the internet before it can build a proof does not work on a filtered network, which is exactly where it is needed.
And the .wasm computes the witness from your secret. Whoever serves you that file runs code over the value your whole identity comes from. The .zkey is less sensitive: a tampered proving key only produces proofs that fail, because validity is decided by the verification key on the other side.
The secret
Section titled “The secret”The secret comes from a wallet signature over a string that never changes:
Singula Anonymous Identity v1The signature is not used directly; its keccak256 becomes the identity secret:
secret = keccak256(utf8(signature))That brings it to 32 bytes in the right range. It is not protection: whoever holds the signature is one keccak256 away from the same secret.
Treat the signature as the identity itself. A leaked signature can take your permissions and open sessions at your expense.
Derivation is deterministic, so the identity moves with the wallet. Check the resulting commitment against the one registered on chain before relying on it.
The set
Section titled “The set”Build the tree from every commitment in the registry, in order:
SubscriberRegistry.totalCommitments()SubscriberRegistry.commitmentsSlice(start, count)Read it in pages and add the leaves to a Semaphore group in the order the contract returned them — a leaf’s position is part of the proof.
Keep the zeros. A removed member’s slot is zeroed rather than dropped, so commitmentsSlice returns zeros where members have left. Filter them out and every later position shifts, giving a root nobody else computes.
Before proving, check that your commitment is in the set. Otherwise the failure happens deep inside the witness calculator, with a useless message.
The session scope
Section titled “The session scope”Three values packed into one integer:
scope = (cpStarId << 96) | (epoch << 32) | indexcpStarId— the Control Plane you are going to;epoch— the current epoch, by the chain’s clock;index— the quota slot, from zero.
All three must be in the scope, not in the message. Semaphore derives the nullifier from (secret, scope) alone, so a slot put in the message gives every slot the same nullifier.
Pass index itself as the message.
Keep track of spent slots yourself. A Control Plane cannot tell you which of your slots are used — telling one subscriber from another is exactly what it is built not to do. Keep a counter per Control Plane and epoch, and move to the next slot when one comes back as already used. Slots reset every epoch.
The permission scope
Section titled “The permission scope”Each permission is requested with a proof of its own, and that proof is not built against the scope above.
scope = (2 << 128) | (window << 64) | index2— a domain tag that separates permission proofs from session proofs;window— the window the permission is for, which is the epoch;index— the permission’s number, from zero up to the count the issuer publishes, minus one.
There is no Control Plane id here, and that is the point. The nullifier follows from the scope, so a scope that names no operator gives the same nullifier everywhere — which is what makes each number one per Orbit per window across the whole network.
Pass the same value as the message as well as the scope. The verifier rebuilds both from its own window and the index in your TakePacketRequest.
The domain tag matters: without it, a proof built to take a permission could be replayed to open a session.
The window is never taken from your request; the issuer uses its own clock.
Hashing
Section titled “Hashing”The circuit sees the message and the scope as hashes, not as plain numbers:
h(v) = keccak256(be32(v)) >> 8The value as 32 bytes big-endian, then keccak256, then a right shift by 8 bits so the result lands inside the scalar field. The official Semaphore library does this for you.
Packing the points
Section titled “Packing the points”This is where third-party clients break most often.
A proof travels as eight field elements in Ethereum calldata order, which is not the order snarkjs produces: for B, the two coefficients of each Fq2 pair are swapped.
| Index | Contents |
|---|---|
| 0, 1 | A.x, A.y |
| 2, 3 | B.x.c1, B.x.c0 — reversed |
| 4, 5 | B.y.c1, B.y.c0 — reversed |
| 6, 7 | C.x, C.y |
Each element is exactly 32 bytes, big-endian.
generateProof from the official Semaphore library already uses this order. The trap is for those calling snarkjs directly: the proof is mathematically correct and is rejected every time.
What to send
Section titled “What to send”message AccessProof { uint64 epoch = 1; bytes merkle_root = 2; bytes nullifier = 3; uint32 index = 4; repeated bytes points = 5; // 8 elements of 32 bytes}epoch is a hint, not a claim: the Control Plane rebuilds the scope from its own id and its own epoch. For the first five minutes after an epoch changes it also accepts a proof built for the previous epoch, so a proof made just before the change still lands. Anything older is refused.
For a session, the proof goes inside a SessionRequest. For a permission, inside a TakePacketRequest next to its index — the same message with a different scope.
From then on, checkpoints are signed with the session’s one-off ed25519 key, not the wallet. The rest is in build a client.
When it is refused
Section titled “When it is refused”The refusal says why. These five come from the proof itself; the full list, permissions included, is in troubleshooting.
- The proof does not verify — check that
circuitVersion()matches your artifacts, that the tree has depth 16, and that the point order matches the table above. - The set is not recognised — your root is out of date. Rebuild it: any registration or removal moves the root, including a stranger’s, and the Control Plane accepts the current root and one recently replaced.
- The claim was already used — that slot’s nullifier is spent. Use the next slot.
- The epoch’s quota is used up — every slot for this epoch at this Control Plane is spent. Another Control Plane has its own.
- The slot has used its budget — that slot’s node time is spent. Same fix as above.
