Status: IN DEVELOPMENT. Deployed on Base Sepolia at
0xFa5901E9dfD813b910061F29ef1f65a1faA0b268; not on Base Mainnet.
Members of a group can signal (vote in a poll, attest, give feedback) without revealing which member they are, and each member signals once per scope. This follows the Semaphore protocol.
How it works
| Part | What it is |
|---|---|
| Identity | A secret number. Its public commitment is Poseidon(secret). In the app the secret is derived from a wallet signature over a fixed message, so it is never stored and can be recreated. |
| Group | A Merkle tree (LeanIMT) of commitments. The admin publishes the new commitments (MembersAdded event) and the tree's root; the contract stores only the root. |
| Proof | A zero-knowledge proof (Noir circuit, UltraHonk) that "I know the secret of one commitment under this root", bound to a scope and a message. It reveals neither the secret nor the member. |
| Nullifier | Poseidon(scope, secret): the same every time a member signals in a scope, different across scopes and members. The contract accepts each nullifier once. |
| Contract | ARLAnonymousSignal: groups (admin and root), a one-hour grace period for a replaced root, one signal per nullifier, and the generated verifier. |
Code
| Path | Contents |
|---|---|
zk/semaphore/ |
Noir circuit (max depth 16, 65,536 members); 5 circuit tests |
zk/check.sh |
Runs the circuit tests and rebuilds the circuit and verifier; fails if the committed artifacts differ |
packages/zk/ |
@arl/zk: identities, groups, circuit inputs, proving (noir_js + bb.js) and verification; circuit/ holds the compiled circuit |
contracts/src/ARLAnonymousSignal.sol |
The signal contract (Apache-2.0) |
contracts/zk/ARLSemaphoreVerifier.sol |
The UltraHonk verifier, generated by bb 5.2.0 (evm target: keccak transcript, zero knowledge), committed as generated |
contracts/zk-script/DevZk.s.sol |
Deploys the verifier and the contract on local Anvil only (the app's fixture) |
contracts/test-zk/ |
Contract tests with real proofs (FOUNDRY_PROFILE=zk forge test); fixture from npm run fixture -w @arl/zk |
The verifier lives in a separate Foundry profile because Slither cannot parse the generated file;
Slither still analyses ARLAnonymousSignal.
Choices
- Noir and Barretenberg, not circom and snarkjs. snarkjs and circomlib are GPL-3.0; the Noir compiler, noir_js, bb.js and the libraries used are MIT or Apache-2.0.
- Poseidon(secret) identities (as in Semaphore v3) instead of Semaphore v4's Baby Jubjub keys. The Noir Baby Jubjub library used by Semaphore-Noir has no license, so it is not used. The circuit is also smaller (about 19,000 gates), which keeps proving in a browser practical.
- Root published by the admin instead of an on-chain tree: no on-chain Poseidon, and group membership rules (for example verified whitelist members) stay with the group's admin.
In the app
The app's Private screen runs an anonymous poll with this contract; see app.md.
Costs and limits
- Proving takes a few seconds (about 4 s for prove and verify in the tests).
- On-chain verification costs about 3 million gas per signal.
- The admin decides who is in the group and can publish a new root; the admin cannot signal for members, learn who signalled or reuse a nullifier. Renouncing the admin role freezes the group.
- Anyone who sees a signal transaction can resubmit the same proof; it records the same signal and is then refused as a used nullifier.
Tests
| Suite | Command | Covers |
|---|---|---|
| Circuit | nargo test in zk/semaphore |
Single-member group, sibling order, nullifier per scope, zero secret refused, over-long proof refused |
| TypeScript | npm test -w @arl/zk |
Commitments, LeanIMT, field hashing, non-members refused, a real proof verified, a changed message refused |
| Contract | FOUNDRY_PROFILE=zk forge test |
Valid signals accepted once per scope; changed message, scope, nullifier, proof byte or root refused; root grace period; admin |