Status: Draft v2 — supersedes the previous draft, which reused a Semaphore nullifier for re-voting and was therefore not receipt-free.
1. Summary
This document specifies a private voting mode for Gov, the minimalist DAO built on OpenZeppelin Governor. It has four parts:
- Gov, with modifications — the only proposal, quorum and execution authority.
- Wulong — a dispensable relayer that pays gas, hides submitter IP addresses and generates cover traffic.
- Genji — the frontend, using the w3pk passkey wallet.
- A small threshold committee — holds the election decryption key and decrypts only aggregates.
The design follows the lessons of the State of Private Voting 2026 report by Privacy Stewards of Ethereum and Shutter. The report rates only four protocols "Yes" on coercion resistance: MACI, SIV, DAVINCI and Enclave CRISP. All four make a vote overwrite indistinguishable from some other operation an observer cannot attribute. This spec takes CRISP's approach and adapts it to Gov:
- Fixed slots. Each voter has one encrypted ballot slot.
- Indistinguishable updates. A voter's overwrite and anyone's re-randomizing "mask" are submitted through the same circuit, so no observer can tell them apart.
- Homomorphic tallying. Ballots are only ever summed. Individual ballots are never decrypted.
On top of this, the slot is owned by an anonymous per-proposal voting key, registered with a Semaphore proof. Ballots are therefore unlinkable to members even if the decryption committee colludes. This mirrors the identity/ballot separation the report credits to DAVINCI.
Everything that decides the outcome happens on-chain and is verified by contracts:
- registration proofs,
- update proofs,
- the encrypted running aggregate,
- decryption-share proofs,
- the final count.
Wulong never touches correctness, and the system runs without it.
2. Goals and non-goals
Goals
- One member, one vote. Each Gov membership NFT gives at most one counted ballot per private proposal.
- Ballot privacy. Nobody learns how a member voted, including Wulong and the committee (unless the committee reaches threshold).
- Anonymity. A ballot slot cannot be linked to a member, even by a colluding committee.
- Receipt-freeness. A voter cannot prove to a coercer how they voted, or whether they voted.
- Universal verifiability. Anyone can check that the published tally is the sum of all final ballots.
- Censorship resistance. Any voter can bypass Wulong and submit directly on-chain.
- Dispensable Wulong. If Wulong is down, malicious or compromised, integrity is unaffected. Only metadata privacy and convenience degrade.
- Running-tally privacy. No partial results before the deadline.
Non-goals for the MVP
- Key-surrender resistance. A voter who hands their secret to a coercer before the deadline is not protected; see §12.
- Delegation in private proposals. Delegated voting power is ignored in private mode.
- Weighted, quadratic or ranked ballots. Ballots are For / Against / Abstain only.
- Post-quantum ballot confidentiality. ElGamal and Groth16 are classical.
- Network-level anonymity beyond what Wulong or Tor provide.
3. Components
┌───────────────────────────── Genji (Next.js) ─────────────────────────────┐
│ w3pk passkey wallet · snarkjs prover (Web Worker) · attestation checker │
└──────────────┬─────────────────────────────────────────────┬─────────────┘
│ optional: TLS terminating in the enclave │ fallback: direct tx
▼ │
┌──────────────────────┐ │
│ Wulong (TEE, Phala) │ relays · batches · masks │
└──────────┬───────────┘ │
▼ ▼
┌──────────────────────────────────────── L2 chain ────────────────────────────────────────┐
│ NFT (modified) ─► MemberRegistry ─► PrivateVoting ◄── TallyCommittee ◄── keyper CLI │
│ │ ▲ │
│ └──── Gov (modified) reads finalized results, executes ──────────│
└──────────────────────────────────────────────────────────────────────────────────────────┘| Component | Role | Trusted for |
|---|---|---|
Gov (modified) | Proposals, quorum, execution; new PrivateZK mode | Same as today |
NFT (modified) | Membership; burn hook into the registry | Same as today |
MemberRegistry (new) | Merkle tree of member identity commitments | Nothing beyond contract code |
PrivateVoting (new) | Per-proposal registration, encrypted slots, aggregate, decryption, finalization | Nothing beyond contract code and circuits |
TallyCommittee (new) | Keyper set, distributed key generation, public keys per epoch | Threshold honesty, for privacy and liveness only |
| Wulong | Gas relay, batching, IP shielding, mask bot | Nothing for integrity. Metadata privacy only if its TEE holds |
| Genji + w3pk | Key derivation, proving, submission, UX | The voter's own device |
gov-keyper CLI (new) | Committee members run DKG and post decryption shares | Each operator's own key share |
The recommended deployment is an L2 such as Base or OP Mainnet, because every vote and mask carries an on-chain proof verification. Crosschain Gov can carry the DAO there.
4. Cryptographic building blocks
| Primitive | Choice | Used for |
|---|---|---|
| Curve | Baby Jubjub, a twisted Edwards curve over the BN254 scalar field | Ballot encryption, committee keys |
| Encryption | Exponential ElGamal: Enc(m; r) = (r·G, m·G + r·PK) | Ballots, additively homomorphic |
| Hash | Poseidon | Commitments, voting keys, Merkle trees |
| Membership proof | Semaphore v4 circuit and verifier, unchanged | Anonymous per-proposal voter registration |
| Update proof | Groth16 via Circom and snarkjs, with a dedicated phase-2 ceremony | Vote or mask, indistinguishably |
| Key generation | Joint-Feldman distributed key generation on Shamir shares | Election public key, t-of-n |
| Decryption proof | Chaum–Pedersen discrete-log-equality proof, verified on-chain | Each keyper's partial decryption |
| Count recovery | Baby-step giant-step, off-chain, checked on-chain by one scalar multiplication | m from m·G, bounded by slot count |
Ballot encoding
A ballot is three ciphertexts (E_for, E_against, E_abstain). Each plaintext is 0 or 1, and their sum is 0 or 1:
- A sum of 0 means "registered but not counted". A voter can return to this state at any time, which keeps forced abstention unverifiable.
- Gov's quorum rule counts For + Abstain, as in
GovernorCountingSimple.
Keys a member holds
Member secrets come from two independent roots in w3pk:
- The member wallet comes from the mnemonic, as today.
- The voting identity comes from the passkey, through the WebAuthn PRF extension. It is never derived from the mnemonic and never written to a backup file.
| Secret | Root | Derivation | Public counterpart |
|---|---|---|---|
| Member wallet | Mnemonic | w3pk STANDARD mode, tag MAIN | Address holding the NFT |
Identity secret ids | Passkey PRF | w3pk getSemaphoreIdentity('gov-identity:<chainId>:<gov>') (§8.4) | Semaphore identity commitment idc, a leaf of MemberRegistry |
Voting secret vs_p | Passkey PRF | Poseidon(ids, proposalId, "VOTE"), computed inside w3pk | Voting key hash vkh_p = Poseidon(vs_p), the owner of slot i in proposal p |
The identity secret never leaves w3pk. Every proof that needs it is produced by w3pk's zkProve, which injects the secret into the witness and returns only the proof.
A synced passkey yields the same identity on every device of the same ecosystem. Key management, rotation and recovery are specified in §9.
5. Lifecycle
Phase 0 — identity registration (once per member)
After receiving their NFT, the member opens Genji, which:
- derives
idsandidc; - calls
MemberRegistry.registerIdentity(tokenId, idc)from the member wallet.
The link between idc and the member is public. That is acceptable, because idc is never used directly to vote.
A member can rotate their identity, for example after moving to another passkey ecosystem or after a suspected compromise. Rotation can be authorized in three ways (§9.2):
- by the current identity;
- by a delayed request from the member wallet;
- by Gov.
Rotation takes effect from the next proposal onward.
Phase 1 — proposal creation
Gov.proposePrivate(...) creates a normal Governor proposal and records ProposalMode.PrivateZK. It then calls PrivateVoting.openPoll, which snapshots:
MemberRegistry.root(),depth()andactiveCount();- the current
TallyCommitteeepoch and its public keyPK; voteStartandvoteEnd(Governor timestamps, since the NFT usesmode=timestamp);finalizeBy = voteEnd + finalizationWindow.
Creation reverts in any of these cases:
- private voting is disabled;
- no committee epoch is active;
votingDelay < minRegistrationWindow;requireNoActiveOperatoris set and the NFT operator is still live.
The last rule closes the operator-minting Sybil vector the Gov README warns about.
Phase 2 — anonymous voter registration (votingDelay)
For each open proposal, Genji computes vkh_p and a Semaphore v4 proof with:
merkleTreeRootequal to the snapshotted root;scope = proposalId;message = vkh_p.
It sends these through Wulong, or directly from any address. PrivateVoting.registerVoter:
- verifies the proof against the stored root, not the registry's current root;
- records the nullifier;
- appends a slot owned by
vkh_p, initialized to the trivial ciphertext.
One identity therefore yields at most one slot per proposal. The slot is linked only to an anonymous key.
Genji registers automatically whenever the member opens the app during the window, for every open proposal. Members who miss the window cannot vote on that proposal. This is the main UX cost of the design.
Phase 3 — voting
Every write to a slot is a call to update(proposalId, slot, newBallot, proof), whose proof shows one of two statements without revealing which:
- Vote branch. "I know
vswithPoseidon(vs) = vkh_slot, andnewBallotis a fresh valid encryption underPK." - Mask branch. "
newBallot = oldBallot + Enc(0,0,0; r)for somerI know."
Anyone can mask any slot. A voter's first vote, change of mind, return to abstention and a stranger's mask all produce the same kind of transaction.
The contract keeps an encrypted aggregate: aggregate = aggregate − oldBallot + newBallot. The tally therefore never requires reading every slot.
Masking traffic comes from three sources, none of which is required:
- Wulong's mask bot, starting with an opening sweep at
voteStart; see §7.3. - Cover traffic from Genji. Every Genji session that votes also masks k random slots, and occasionally the user's own slot.
- Anyone else. Any member or third party can run the open-source mask script.
Phase 4 — tally
After voteEnd:
- Each keyper j posts, for each of the three aggregate ciphertexts
(C1, C2), a shareD_j = sk_j · C1with a Chaum–Pedersen proof thatlog_G(PK_j) = log_{C1}(D_j). - Once t valid shares exist, anyone computes off-chain
M = C2 − Σ λ_j · D_j = m·G(Lagrange coefficientsλ_j), solves form, and callsfinalize(proposalId, [m_for, m_against, m_abstain]). - The contract recomputes
Mand checksm·G == Mfor each option. A wrong claimed count simply fails.
Individual slots are never decrypted. If finalizeBy passes without enough shares, the proposal becomes Defeated.
Phase 5 — execution
Gov reads the finalized counts and applies:
- Quorum:
for + abstain ≥ quorumNumerator × memberCountAtCreation / quorumDenominator. - Success:
for > against.
The normal Governor execute path follows. Gov today has no timelock; adding GovernorTimelockControl is recommended but orthogonal to this spec.
6. Contracts
6.1 Changes to NFT.sol
| Change | Detail |
|---|---|
memberRegistry address | Set once by the owner (Gov) via setMemberRegistry |
| Burn hook | In _update, when to == address(0) (covers both govBurn and self-burn via ERC721Burnable), call memberRegistry.onBurn(tokenId) |
| No other behavior change | Non-transferability, operator and delegation are untouched |
6.2 MemberRegistry
The registry is a LeanIMT-compatible tree (the structure Semaphore v4 proves against) that stores all nodes on-chain. A burn can therefore zero its leaf inside the burn transaction, without anyone supplying Merkle siblings.
interface IMemberRegistry {
event IdentityRegistered(uint256 indexed tokenId, uint256 indexed leafIndex, uint256 idc);
event IdentityRotated(uint256 indexed tokenId, uint256 oldIdc, uint256 newIdc);
event RotationRequested(uint256 indexed tokenId, uint256 newIdc, uint64 executableAt);
event RotationCancelled(uint256 indexed tokenId);
event IdentityRemoved(uint256 indexed tokenId, uint256 idc);
error NotTokenOwner();
error AlreadyRegistered();
error DuplicateCommitment();
error InvalidOwnershipProof();
error RotationPending();
error RotationNotReady();
error OnlyNFT();
error OnlyGov();
function registerIdentity(uint256 tokenId, uint256 idc) external; // msg.sender == ownerOf(tokenId)
function onBurn(uint256 tokenId) external; // only NFT; sets leaf to 0
// Rotation — three paths, see §9.2
function rotateIdentity(uint256 tokenId, uint256 newIdc, OwnershipProof calldata proof) external; // proof of current ids, immediate
function requestRotation(uint256 tokenId, uint256 newIdc) external; // msg.sender == ownerOf(tokenId); starts rotationDelay
function cancelRotation(uint256 tokenId, OwnershipProof calldata proof) external; // proof of current ids
function executeRotation(uint256 tokenId) external; // anyone, once rotationDelay has passed
function govResetIdentity(uint256 tokenId, uint256 newIdc) external; // only Gov
function root() external view returns (uint256);
function depth() external view returns (uint256);
function activeCount() external view returns (uint256);
}Rules:
- One identity commitment per
tokenId. Commitments are unique among active leaves. A commitment whose leaf was zeroed by a burn can be registered again, which lets a re-minted member keep their passkey-derived identity. - Leaves are never deleted, only zeroed, so leaf indices stay stable.
- Holding the member wallet alone can never change an identity immediately. It can only open a rotation request that the current identity can cancel during
rotationDelay.
6.3 PrivateVoting
struct Point { uint256 x; uint256 y; } // Baby Jubjub, affine
struct Ciphertext { Point c1; Point c2; }
struct Ballot { Ciphertext[3] e; } // for, against, abstain
interface IPrivateVoting {
event PollOpened(uint256 indexed proposalId, uint256 memberRoot, uint32 memberCount,
uint32 epoch, uint64 voteStart, uint64 voteEnd, uint64 finalizeBy);
event VoterRegistered(uint256 indexed proposalId, uint32 indexed slot, uint256 vkh);
event SlotUpdated(uint256 indexed proposalId, uint32 indexed slot, Ballot ballot);
event ShareSubmitted(uint256 indexed proposalId, uint8 indexed keyper);
event PollFinalized(uint256 indexed proposalId, uint256 forVotes, uint256 againstVotes, uint256 abstainVotes);
error OnlyGov();
error WrongPhase();
error RootMismatch();
error NullifierUsed();
error InvalidProof();
error StaleBallot(); // oldBallot changed since the proof was made
error NotKeyper();
error ShareAlreadySubmitted();
error InvalidShare();
error NotEnoughShares();
error WrongCount();
error AlreadyFinalized();
// Gov
function openPoll(uint256 proposalId, uint64 voteStart, uint64 voteEnd) external;
// Phase 2 — permissionless caller
function registerVoter(uint256 proposalId, uint256 vkh, ISemaphore.SemaphoreProof calldata proof) external;
// Phase 3 — permissionless caller, vote or mask
function update(uint256 proposalId, uint32 slot, Ballot calldata newBallot,
uint256[8] calldata groth16) external;
function updateMany(uint256 proposalId, uint32[] calldata slots, Ballot[] calldata newBallots,
uint256[8][] calldata proofs) external returns (uint256 okBitmap);
// Phase 4
function submitShare(uint256 proposalId, Point[3] calldata d, uint256[2][3] calldata dleq) external;
function finalize(uint256 proposalId, uint256[3] calldata counts) external; // permissionless
// Views
function phase(uint256 proposalId) external view returns (uint8); // Registration, Voting, Tallying, Finalized, Failed
function result(uint256 proposalId) external view
returns (bool finalized, uint256 forVotes, uint256 againstVotes, uint256 abstainVotes, uint32 memberCount);
function slotBallot(uint256 proposalId, uint32 slot) external view returns (Ballot memory);
function aggregate(uint256 proposalId) external view returns (Ballot memory);
}Implementation notes:
- Registration.
registerVotercalls the deployed Semaphore v4 verifier directly with the stored poll root. Semaphore's own group contract accepts recent roots within a time window, which is not what a snapshot needs. Nullifiers are tracked per proposal. - Public inputs.
updatepasses(proposalId, PK, vkh_slot, oldBallot, newBallot)to the verifier. BecauseoldBallotis read from storage at execution time, a proof made against a stale slot fails withStaleBallot, and the client re-proves.updateManyuses try/catch per item, so one stale mask cannot revert a relayer's whole batch. - Curve validity.
newBallotpoints come out of the circuit's constrained computation, so they lie on the curve and in the prime-order subgroup by construction. No separate on-chain check is needed for them. Keyper shares are checked on-chain. - Curve arithmetic. On-chain Baby Jubjub arithmetic uses projective coordinates for the six additions per update. Scalar multiplications happen only in
submitShareandfinalize. - Empty poll. If no slot was ever registered,
finalizeaccepts zero counts without shares. - Gas. Benchmarks are required before committing to parameters. For orientation, the report measured roughly 285k gas for verifying a Semaphore proof with 30 members. The update proof has about 29 public inputs. Hashing them into a single input is an optimization to evaluate.
6.4 TallyCommittee
| Element | Detail |
|---|---|
| Membership | n keyper addresses and threshold t, set only by Gov proposals. MVP default is 3-of-5 |
| Exclusions | The Wulong relayer address and the NFT operator cannot be keypers |
| Epoch | DKG round producing PK and per-keyper public shares PK_j. Polls bind to the epoch active at creation |
| DKG on-chain data | Feldman commitments A_{j,k}, encrypted shares (for data availability), complaints, qualified set |
| Rotation | A new epoch can start at any time. Old epochs stay valid for polls already bound to them |
interface ITallyCommittee {
function currentEpoch() external view returns (uint32);
function epochKey(uint32 epoch) external view returns (Point memory pk, uint8 t, uint8 n);
function keyperShare(uint32 epoch, uint8 index) external view returns (address keyper, Point memory pkj);
function isActive(uint32 epoch) external view returns (bool);
}6.5 Changes to Gov.sol
enum ProposalMode { Public, PrivateZK }
mapping(uint256 => ProposalMode) public proposalMode;
IPrivateVoting public privateVoting;
bool public privateVotingEnabled; // governance-controlled
bool public requireNoActiveOperator = true; // governance-controlled
uint48 public minRegistrationWindow; // governance-controlled
uint48 public finalizationWindow; // governance-controlled
function proposePrivate(address[] memory targets, uint256[] memory values,
bytes[] memory calldatas, string memory description)
public returns (uint256 proposalId)
{
if (!privateVotingEnabled) revert PrivateVotingDisabled();
if (votingDelay() < minRegistrationWindow) revert RegistrationWindowTooShort();
if (requireNoActiveOperator) _requireOperatorInactive(); // operator == 0 or expired
proposalId = propose(targets, values, calldatas, description);
proposalMode[proposalId] = ProposalMode.PrivateZK;
privateVoting.openPoll(proposalId, uint64(proposalSnapshot(proposalId)), uint64(proposalDeadline(proposalId)));
}| Override | Behavior for PrivateZK proposals |
|---|---|
_castVote | Reverts with PrivateProposal(), which blocks castVote* |
_quorumReached | Uses the finalized counts and memberCount snapshotted at creation, with the historical quorum numerator |
_voteSucceeded | forVotes > againstVotes from the finalized counts |
state | After voteEnd: Active while tallying (inspect privateVoting.phase for detail), Defeated if not finalized by finalizeBy, otherwise the standard Governor logic |
proposalVotes | Returns the finalized private counts, so existing tooling displays results |
| Setters | setPrivateVoting, setPrivateVotingEnabled, setRequireNoActiveOperator, setMinRegistrationWindow, setFinalizationWindow, all onlyGovernance |
Delegation. It is ignored for private proposals: every registered member gets one slot regardless of delegation. proposalThreshold still uses delegated votes, so delegation keeps working for proposing. The report recommends that delegates vote transparently to address the principal–agent problem. Those votes would require weighted public votes alongside private ones, which this MVP excludes.
Migration. Neither contract is upgradeable. An existing DAO migrates in four steps:
- Deploy the new
NFT,Govand modules. - Mint to the current member list (from
ERC721Enumerable) in the constructor. - Move treasury and roles with a final proposal on the old Gov.
- Disable the operator on the new NFT, or set a short expiry.
7. Wulong as a dispensable relayer
Wulong runs as a NestJS service in a TEE (dstack on Intel TDX, deployed to Phala Cloud). TLS terminates inside the enclave, so the operator does not see request contents or client IPs. In this design Wulong is a convenience layer, never a trust anchor.
7.1 What Wulong does and does not do
| Does | Does not |
|---|---|
Relays registerVoter and update calls from its relayer wallet, batching with updateMany | Hold any key share, election key or member secret |
Pre-checks requests cheaply (phase, slot exists, oldBallot matches), then verifies the proof before paying gas | Authenticate voters. No SIWE on voting routes: the ZK proof is the authorization |
| Runs the mask bot (opening sweep, steady masking, end-game cadence) | Decide validity. The contract re-verifies everything |
| Exposes attestation so Genji can check the enclave before sending | Persist request metadata (IP, timing) beyond the in-memory queue |
Helps keypers' finalize by running baby-step giant-step and submitting counts (also permissionless) | Gate anything: all functions it calls are open to any address |
7.2 New API module (/vote)
| Route | Body / response | Notes |
|---|---|---|
GET /vote/:proposalId | Poll parameters, phase, slot count, artifact hashes | Public, cacheable |
POST /vote/register | { proposalId, vkh, semaphoreProof } → { txHash } | Rejected after the registration window |
POST /vote/update | { proposalId, slot, ballot, proof } → { txHash } or 409 { currentBallot } | 409 means stale: Genji re-proves |
GET /vote/tx/:txHash | Inclusion status | Lets Genji detect censorship |
Operational changes needed in Wulong:
- Rate limiting. The README notes that, with TLS in the enclave, all clients share one per-IP counter. Voting routes need their own, much higher limit. Spam is bounded by the cheap pre-checks and by proof verification cost.
- Relayer balance. The default cap (
RELAYER_MAX_BALANCE_WEI, 0.01 ETH) needs a voting-specific budget, topped up by a Gov proposal. - Configuration. One replica, as today. The relayer wallet is configured for the chain Gov is deployed on.
- No payload encryption. Ballots are already ciphertexts and become public on-chain, so ML-KEM payload encryption adds nothing here. TLS-in-enclave is what hides the submitter.
7.3 Masking policy
Receipt-freeness depends on unattributable updates being frequent enough that a voter can always say "that later change was me". The policy below is configurable:
| Window | Target |
|---|---|
At voteStart | Opening sweep: mask every registered slot once, so no slot stays in its trivial initial state |
| During voting | Each slot masked at random times (Poisson process), at rate λ per slot per hour |
Final W hours | Each slot masked at least every Δ minutes |
Mask proofs are generated inside the enclave with enclave randomness. The mask script is also published as a standalone CLI, so any member can run it if Wulong is down.
7.4 If Wulong fails or misbehaves
| Failure | Effect | Mitigation |
|---|---|---|
| Down | No gas sponsorship, fewer masks | Genji falls back to direct submission. Cover masks from Genji sessions and the standalone script continue |
| Censoring | Some updates are not relayed | Genji tracks inclusion and resubmits directly after a timeout |
| Malicious relayer, TEE intact | Cannot alter or forge anything (all on-chain verified) | None needed |
| Enclave compromised | Learns IPs and timing: can link an IP to a registered vkh and to updates of that slot. Never learns ballot contents | Genji spaces out registration and voting, masks its own slot, and supports Tor. Ballot contents stay protected by the committee threshold |
Direct submission links the sending address to the transaction. Genji therefore sends direct submissions from a separate, origin-scoped w3pk address (tag GOV-RELAY), never from the member's MAIN address. Funding that address leaves its own trail, which Genji discloses to the user.
8. Genji and w3pk
8.1 Additions to Genji
| Area | Addition |
|---|---|
| Pages | /proposals/[id] gains a private-voting panel. /settings/voting shows identity status, relayer choice and Wulong attestation status |
| Background jobs | Auto-registers for open proposals in their registration window. Sends cover masks after any voting action |
| Prover | w3pk zkProve (snarkjs and Semaphore v4 underneath), run in a Web Worker. .wasm and .zkey artifacts are fetched from IPFS and checked with w3pk's artifact verification against the CIDs pinned in PrivateVoting |
| Wallet | w3pk: MAIN for identity registration, rotation requests and proposing; the PRF-derived identity for every voting proof; a separate tagged address for direct-relay gas |
| Identity watch | Watches RotationRequested for the member's token and offers a one-tap cancel. Compares the identity w3pk derives with the registered one, and offers rotation on mismatch |
| Verification | Checks the w3pk build against its on-chain registry (existing Genji practice) and Wulong's attestation before first use |
| Security headers | CSP must allow WebAssembly (wasm-unsafe-eval), the IPFS gateway and the Wulong origin. Changes go in next.config.ts, which /genji-sync preserves |
8.2 Voting flow in the client
- Load the poll. If not yet registered and the registration window is open, ask w3pk for
vkh_pand a Semaphore membership proof, and submit. - During voting, the user picks For, Against, Abstain or "no vote". The client:
- reads the current
oldBallotfor its slot; - asks w3pk's
zkProvefor a vote-branch proof; - submits;
- re-proves on
409.
- reads the current
- Send k cover masks on random slots, plus a mask on its own slot at a random later time if the session stays open.
- Show inclusion ("your update was included"), never the content. Keep nothing exportable: no stored ciphertext randomness, no downloadable receipt.
8.3 UX copy that supports coercion resistance
- "You can change your vote as many times as you like until the deadline. Only your last vote counts."
- "Nobody — not this app, not the relayer, not other members — can see how you voted or whether you changed it."
- "If someone pressures you to vote a certain way, you can vote as they ask and change it later. They cannot tell."
- "Votes changed in the last few minutes before the deadline may be noticed. Change your vote early."
8.4 Required w3pk additions
These additions are generic: none of them is Gov-specific, so other w3pk apps can use them too.
| Addition | Behavior | Why this design needs it |
|---|---|---|
derivePrfSecret(tag) | Deterministic secret from the WebAuthn PRF extension, salted with the origin and tag. Never derived from or stored with the mnemonic. Throws when the authenticator lacks PRF, with no fallback, matching w3pk's rule for persistent sessions. Reports when the credential, and therefore the secret, has changed | Keeps the voting identity out of backup files |
getSemaphoreIdentity(tag) | Semaphore v4 identity built from derivePrfSecret. Returns the commitment only | Identity registration and rotation |
zkProve(tag, artifacts, publicInputs, extraWitness) | Injects the tag's secret (and secrets derived from it, such as vs_p) into the witness, runs snarkjs in a worker, returns the proof and public signals only. ElGamal randomness is generated and discarded inside the call | The app never handles the secret or ballot randomness, so it cannot leak a key or build a receipt by accident |
w3pk/zk: Baby Jubjub keys and exponential ElGamal | Encryption, re-randomization and point helpers on top of the existing Poseidon utilities | Ballot construction and masks |
| Artifact verification | Checks .wasm and .zkey CIDs against on-chain pins, reusing the build-verification helper | Detects a swapped proving key |
| BackupFile v3 | Argon2id instead of PBKDF2 for password mode. The plaintext ethereumAddress is optional, or replaced by a salted hash. A recommendedMode hint (passkey or hybrid), and a security-score penalty for password-only files | A stolen file is harder to crack and does not name its owner |
| Reserved name: decoy credentials | No implementation yet | Phase 2, JCJ route (§12) |
Isolation caveat. w3pk runs in the host app's JavaScript context. zkProve keeps the secret out of the app's API surface, but malicious app code in the same context could still read it. True isolation requires w3pk on its own origin (§12, item 7).
9. Key management and recovery
9.1 Two roots, two blast radii
| Root | Controls | Backed up by | If stolen | If lost |
|---|---|---|---|---|
| Mnemonic | Member wallet: NFT, public votes, proposing, delegation, gas | w3pk backup file, social recovery | Public actions, self-burn, and a rotation request the member can cancel | Gov burn and re-mint to a new wallet |
| Passkey (PRF) | Identity and every voting secret | Passkey sync within one ecosystem | Private votes and slot linkage, including past proposals | Delayed rotation from the member wallet, or Gov reset |
The backup file is the secret most likely to leak: it sits in cloud drives and password managers, and in pieces with guardians. Splitting the roots means a leaked backup file never touches private voting. The price is that moving to another passkey ecosystem means rotating the identity, so voting resumes after rotationDelay.
9.2 Identity rotation
| Path | Authorized by | Takes effect | Typical use |
|---|---|---|---|
rotateIdentity | Ownership proof for the current identity | Immediately | Routine rotation; suspected device compromise while the passkey is still held |
requestRotation then executeRotation | Member wallet | After rotationDelay; cancellable by the current identity | Passkey lost, or moving to another ecosystem |
govResetIdentity | A Gov proposal | When the proposal executes | Both roots lost, or a contested identity |
Ownership proof. The link between a token and its idc is already public, so the proof needs no anonymity, only knowledge of the secret behind leaf[tokenId]. Two options:
- Semaphore v4 proof against a single-leaf root (root equal to the stored
idc), withscope = hash("ROTATE", tokenId, nonce)andmessage = newIdc. This reuses the deployed verifier, pending confirmation that the circuit accepts that tree shape. - EdDSA signature by the identity key over the same tuple.
In both cases a per-token nonce prevents replay.
Effects:
- A new
idcapplies to proposals created after the change. Open polls keep their snapshot, so the old identity still votes there. - Genji watches
RotationRequestedand lets the member cancel from any device that holds the passkey. - A thief holding only the wallet can keep opening requests but never complete one. The member can end this by asking Gov to burn the token and re-mint it to a fresh wallet. That wallet re-registers the same passkey-derived
idc, which is allowed because the old leaf was zeroed.
9.3 Recovery scenarios
| Scenario | What is lost | Recovery |
|---|---|---|
| Device lost, passkey synced, backup file available | Nothing | Restore the wallet on a new device with the synced passkey. The identity is unchanged |
| Device lost, passkey synced, no backup file | Member wallet | Gov burns and re-mints to a new wallet, which re-registers the same idc. Private voting continues from the next proposal |
| Moving from Apple to Android, backup file available | Identity (new passkey, new PRF output) | Restore the wallet from the backup file, then requestRotation and wait rotationDelay |
| Backup file stolen | Member wallet, if the password is cracked | Cancel any rotation request, then Gov burn and re-mint to a fresh wallet. Private votes are unaffected |
| Passkey provider account compromised | Identity | rotateIdentity at once from a trusted device, then secure the account. Votes in open proposals stay contested until their deadline |
| Passkey and all wallet backups lost | Both | govResetIdentity after Gov re-mints to a new wallet |
10. Threat model and guarantees
10.1 Adversaries
| Adversary | Can | Cannot |
|---|---|---|
| Public observer | See registrations count, slot updates, final counts | Link a slot to a member; read a ballot; tell a vote from a mask |
| Coercer or briber without the voter's secret | Watch the voter vote; demand the ciphertext randomness | Confirm the ballot is still the final one, since any later update may be a re-vote. Confirm abstention |
| Coercer present close to the deadline | See that no update hit the slot after the coerced vote | — this is the last-minute limit, bounded by the end-game masking interval Δ |
Coercer holding the voter's ids or vs_p | Vote last with the stolen key | — not covered in the MVP (§12) |
Fewer than t keypers colluding | Nothing beyond the public view | Decrypt anything |
t or more keypers colluding | Decrypt individual slots, before or after the deadline | Link a slot to a member (anonymous vkh); forge counts (on-chain checks) |
| Keypers withholding shares | Block finalization → proposal Defeated | Change the result |
| Wulong, TEE intact | Delay or drop relayed transactions | Anything else |
| Wulong, TEE compromised | Learn IP-to-slot metadata | Read ballots; forge anything |
Wulong compromised and ≥ t keypers | Link IP to slot to ballot for voters who used Wulong | Forge counts |
| NFT operator | Mint memberships before a proposal | Create private proposals while active (default rule) |
| Thief of a w3pk backup file (password cracked) | Act as the member wallet: public votes, proposing, delegation, self-burn, requestRotation | Vote privately, link the member's slots, or complete a rotation the member cancels within rotationDelay |
| Attacker controlling the member's passkey provider account | Derive the identity: vote privately as the member, link the member's past and present slots | Read ballot contents without ≥ t keypers |
10.2 Residual risks to state publicly
- Last-minute coercion. A coercer who watches a voter vote less than
Δbefore the deadline, and sees no later update on that slot, gets a receipt with high probability. Every slot-overwrite design has this limit. - Key surrender. The identity secret stays inside w3pk, so selling it means handing over the passkey or extracting the secret from the SDK's memory. That is harder than copying a string. But w3pk runs in Genji's own JavaScript context, so it is not a cryptographic barrier (§12, item 7). A buyer keeps the member's private votes until the member rotates.
- Passkey provider accounts. Whoever controls a member's iCloud Keychain or Google Password Manager account controls their voting identity. Members should protect that account like a hardware wallet.
- Small electorates. A unanimous or near-unanimous count is itself a receipt. DAOs with very few members should expect weaker privacy.
- Registration metadata. Registration count is public. Registration timing correlated with app activity can narrow anonymity sets.
- Committee threshold. At or above
t, ballot privacy falls back on voter anonymity alone. - Circuit and ceremony soundness. A bug in the update circuit or a compromised phase-2 ceremony breaks integrity. This requires audits and a multi-party ceremony.
11. Properties against the report's criteria
| Criterion (PSE report) | This design |
|---|---|
| Correct execution | Yes — all inputs verified on-chain; counts checked against the decrypted aggregate |
| Robustness | Medium-High — liveness needs t of n keypers; Wulong is optional |
| Censorship resistance & liveness | Yes for submission (direct path). Tally liveness depends on keypers |
| Coercion resistance (receipt-freeness) | Yes, under masking liveness and outside the last-minute window. No against key surrender (MVP) |
| Quantum resistance | No. Identity and ballot separation limits damage if ballots are ever decrypted |
| Private vote | Yes — encrypted and anonymous |
| Running tally privacy | Yes |
| Quorum status during vote | No — only the registration count is visible |
| Individual verifiability | Yes — inclusion of one's own update |
| Universal verifiability | Yes |
| Eligibility / uniqueness | Yes — snapshotted member tree plus per-proposal nullifier |
| Multiple choice / weighted | No / No (For–Against–Abstain, one member one vote) |
| Vote updatability | Yes |
| Delegation | Not in private mode |
| Gas efficiency | Medium — per-update proof verification; L2 recommended |
| Implementation simplicity | Low-Medium |
12. Phase 2 roadmap
-
Key-surrender resistance. Two credible routes, both heavier than the MVP:
- MACI-style key change. Encrypted key-change messages are processed by a coordinator. Here the coordinator would be the keyper set running the processing as an MPC, so no single party sees ballots.
- JCJ-style fake credentials. Voters can hand coercers convincing fake keys. Ballots cast with fake keys are discarded at tally time through a verifiable shuffle and plaintext-equivalence tests run by the committee.
Either route replaces the vote branch's public ownership check with one checked only at tally time.
-
Keyper snitching and slashing. Taken from the report's future-work list: keypers post a bond, and anyone who proves knowledge of a keyper's share before the epoch ends claims it. This makes collusion attributable.
-
Shared keyper infrastructure. Shutter's Permanent Shielded Voting uses the same threshold-ElGamal plus homomorphic-tally pattern. Using an external keyper network instead of a DAO-run committee would increase independence.
-
Private delegation. Kite shows private delegate, revoke and re-delegate on a Governor. Integrating it would restore delegation in private mode.
-
Post-quantum migration. Follow the w3pk post-quantum roadmap. Ballot encryption would need a lattice-based additively homomorphic scheme.
-
Richer ballots. Multiple choice and approval voting through wider plaintext vectors. Cicada's paper (Section 3) describes these extensions.
-
Non-extractable voting keys. Running w3pk on its own origin, as an iframe or popup wallet, would make the identity secret unreachable from Genji's code. This turns the key-sale barrier from soft to real.
-
Decoy credentials in w3pk. If the JCJ route from item 1 is chosen, w3pk generates the fake credentials, using the API name reserved in §8.4.
13. Parameters (initial proposals, to benchmark)
| Parameter | Proposed default | Set by |
|---|---|---|
minRegistrationWindow | 2 days | Gov |
rotationDelay | 7 days | Gov |
votingPeriod (private) | ≥ 5 days | Gov |
finalizationWindow | 3 days | Gov |
Committee t-of-n | 3-of-5, independent operators | Gov |
Mask rate λ | 1 per slot per 6 h | Wulong config |
End-game window W / interval Δ | 6 h / 15 min | Wulong config |
Genji cover masks per session k | 3 | Genji config |
| Member tree depth | Up to 16 | Registry constant |
14. Testing plan
Contracts (Foundry)
- Identity registration and duplicate rejection; burn zeroes the leaf for both
govBurnand self-burn; a zeroed commitment can be re-registered after re-mint. - Rotation:
- immediate rotation requires a valid ownership proof bound to the token and nonce (replay rejected);
- wallet-requested rotation executes only after
rotationDelay; - the current identity can cancel a pending request;
- Gov reset works only through governance;
- a rotation during an open poll does not affect that poll's snapshot.
proposePrivatereverts while the operator is live, with no committee epoch, or with a short delay.- Registration: wrong root, wrong scope, reused nullifier, after window.
- Update: vote branch, mask branch, stale
oldBallot, outside voting period,updateManypartial failure. - Aggregate equals the sum of slots after any sequence of updates (fuzzed).
- Shares: invalid proof rejected, non-keyper rejected, duplicate rejected. Finalize: wrong counts rejected, fewer than
tshares rejected. - Gov:
castVoteblocked, quorum from snapshotted member count,DefeatedafterfinalizeBy, execution only after finalization.
w3pk
derivePrfSecretreturns the same secret for the same credential on two devices of one ecosystem, and a different one for a new credential.- It throws on authenticators without PRF.
- The identity secret never appears in any backup file, any sync export, or any
zkProvereturn value. - A BackupFile v3 round-trip works for each encryption mode, and v2 files remain readable.
Circuits
- The two branches produce proofs with identical public-input shape, and verifier transcripts are indistinguishable across many samples.
- Invalid ballots fail: plaintext 2, two options set, wrong
PK. - Mask with a non-zero plaintext fails.
End-to-end
- Happy path: create, register, vote, mask, tally, finalize, execute.
- Coercion simulation: coerced vote, later re-vote, coercer view vs voter view.
- Wulong outages: Wulong offline for the whole poll (direct submission plus script masks); Wulong censoring a subset.
- Keyper failures: keyper dropout below
t; collusion test confirming slots decrypt only to anonymousvkh. - Membership changes: member minted after creation cannot register; member burned after creation can still vote that proposal (snapshot semantics).
15. Deployment checklist
- w3pk release with
derivePrfSecret,getSemaphoreIdentity,zkProve, artifact verification and BackupFile v3, registered in the w3pk build registry. - Circuits audited; phase-2 ceremony with independent contributors; artifact hashes pinned in
PrivateVoting. -
MemberRegistry,PrivateVoting,TallyCommittee,GovandNFTchanges audited. - NFT operator disabled or expired;
requireNoActiveOperatoron. - Keyper set of independent operators named publicly; DKG completed; epoch active.
- Wulong
/votemodule released with a pinned image digest and verified attestation; relayer funded. - Genji release with artifact and attestation checks, w3pk build verification, CSP updated.
- Standalone mask script published.
- Testnet election with real members, including a simulated coercion exercise.
16. Implementation order
- w3pk additions (§8.4): PRF identity,
zkProve, artifact verification, BackupFile v3. MemberRegistrywith the three rotation paths, and the NFT burn hook.- Update circuit (vote/mask OR) and its benchmarks.
PrivateVotingregistration, slots and aggregate.TallyCommitteeand thegov-keyperCLI (DKG, shares).PrivateVotingshares and finalization.Govprivate mode and overrides.- Genji client flows and identity watch, then the Wulong
/votemodule and mask bot. - Ceremony, audits, testnet election.
Further reading
- State of Private Voting 2026 — the comparison this design builds on
- CRISP on ethresear.ch — the masking idea
- DAVINCI whitepaper — re-randomization and stealth overwrites
- MACI — key change against bribery
- Semaphore documentation
- Cicada paper — homomorphic tallying and ballot extensions
- Kite paper — private delegation on a Governor
- w3pk documentation and Genji live demo