Private Coercion-Resistant Voting for Gov

Julien Béranger

+ Claude Opus 5.5

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

  1. One member, one vote. Each Gov membership NFT gives at most one counted ballot per private proposal.
  2. Ballot privacy. Nobody learns how a member voted, including Wulong and the committee (unless the committee reaches threshold).
  3. Anonymity. A ballot slot cannot be linked to a member, even by a colluding committee.
  4. Receipt-freeness. A voter cannot prove to a coercer how they voted, or whether they voted.
  5. Universal verifiability. Anyone can check that the published tally is the sum of all final ballots.
  6. Censorship resistance. Any voter can bypass Wulong and submit directly on-chain.
  7. Dispensable Wulong. If Wulong is down, malicious or compromised, integrity is unaffected. Only metadata privacy and convenience degrade.
  8. 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 ──────────│
 └──────────────────────────────────────────────────────────────────────────────────────────┘
ComponentRoleTrusted for
Gov (modified)Proposals, quorum, execution; new PrivateZK modeSame as today
NFT (modified)Membership; burn hook into the registrySame as today
MemberRegistry (new)Merkle tree of member identity commitmentsNothing beyond contract code
PrivateVoting (new)Per-proposal registration, encrypted slots, aggregate, decryption, finalizationNothing beyond contract code and circuits
TallyCommittee (new)Keyper set, distributed key generation, public keys per epochThreshold honesty, for privacy and liveness only
WulongGas relay, batching, IP shielding, mask botNothing for integrity. Metadata privacy only if its TEE holds
Genji + w3pkKey derivation, proving, submission, UXThe voter's own device
gov-keyper CLI (new)Committee members run DKG and post decryption sharesEach 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

PrimitiveChoiceUsed for
CurveBaby Jubjub, a twisted Edwards curve over the BN254 scalar fieldBallot encryption, committee keys
EncryptionExponential ElGamal: Enc(m; r) = (r·G, m·G + r·PK)Ballots, additively homomorphic
HashPoseidonCommitments, voting keys, Merkle trees
Membership proofSemaphore v4 circuit and verifier, unchangedAnonymous per-proposal voter registration
Update proofGroth16 via Circom and snarkjs, with a dedicated phase-2 ceremonyVote or mask, indistinguishably
Key generationJoint-Feldman distributed key generation on Shamir sharesElection public key, t-of-n
Decryption proofChaum–Pedersen discrete-log-equality proof, verified on-chainEach keyper's partial decryption
Count recoveryBaby-step giant-step, off-chain, checked on-chain by one scalar multiplicationm 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.
SecretRootDerivationPublic counterpart
Member walletMnemonicw3pk STANDARD mode, tag MAINAddress holding the NFT
Identity secret idsPasskey PRFw3pk getSemaphoreIdentity('gov-identity:<chainId>:<gov>') (§8.4)Semaphore identity commitment idc, a leaf of MemberRegistry
Voting secret vs_pPasskey PRFPoseidon(ids, proposalId, "VOTE"), computed inside w3pkVoting 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:

  1. derives ids and idc;
  2. 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() and activeCount();
  • the current TallyCommittee epoch and its public key PK;
  • voteStart and voteEnd (Governor timestamps, since the NFT uses mode=timestamp);
  • finalizeBy = voteEnd + finalizationWindow.

Creation reverts in any of these cases:

  • private voting is disabled;
  • no committee epoch is active;
  • votingDelay < minRegistrationWindow;
  • requireNoActiveOperator is 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:

  • merkleTreeRoot equal to the snapshotted root;
  • scope = proposalId;
  • message = vkh_p.

It sends these through Wulong, or directly from any address. PrivateVoting.registerVoter:

  1. verifies the proof against the stored root, not the registry's current root;
  2. records the nullifier;
  3. 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 vs with Poseidon(vs) = vkh_slot, and newBallot is a fresh valid encryption under PK."
  • Mask branch. "newBallot = oldBallot + Enc(0,0,0; r) for some r I 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:

  1. Each keyper j posts, for each of the three aggregate ciphertexts (C1, C2), a share D_j = sk_j · C1 with a Chaum–Pedersen proof that log_G(PK_j) = log_{C1}(D_j).
  2. Once t valid shares exist, anyone computes off-chain M = C2 − Σ λ_j · D_j = m·G (Lagrange coefficients λ_j), solves for m, and calls finalize(proposalId, [m_for, m_against, m_abstain]).
  3. The contract recomputes M and checks m·G == M for 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

ChangeDetail
memberRegistry addressSet once by the owner (Gov) via setMemberRegistry
Burn hookIn _update, when to == address(0) (covers both govBurn and self-burn via ERC721Burnable), call memberRegistry.onBurn(tokenId)
No other behavior changeNon-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. registerVoter calls 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. update passes (proposalId, PK, vkh_slot, oldBallot, newBallot) to the verifier. Because oldBallot is read from storage at execution time, a proof made against a stale slot fails with StaleBallot, and the client re-proves. updateMany uses try/catch per item, so one stale mask cannot revert a relayer's whole batch.
  • Curve validity. newBallot points 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 submitShare and finalize.
  • Empty poll. If no slot was ever registered, finalize accepts 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

ElementDetail
Membershipn keyper addresses and threshold t, set only by Gov proposals. MVP default is 3-of-5
ExclusionsThe Wulong relayer address and the NFT operator cannot be keypers
EpochDKG round producing PK and per-keyper public shares PK_j. Polls bind to the epoch active at creation
DKG on-chain dataFeldman commitments A_{j,k}, encrypted shares (for data availability), complaints, qualified set
RotationA 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)));
}
OverrideBehavior for PrivateZK proposals
_castVoteReverts with PrivateProposal(), which blocks castVote*
_quorumReachedUses the finalized counts and memberCount snapshotted at creation, with the historical quorum numerator
_voteSucceededforVotes > againstVotes from the finalized counts
stateAfter voteEnd: Active while tallying (inspect privateVoting.phase for detail), Defeated if not finalized by finalizeBy, otherwise the standard Governor logic
proposalVotesReturns the finalized private counts, so existing tooling displays results
SetterssetPrivateVoting, 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:

  1. Deploy the new NFT, Gov and modules.
  2. Mint to the current member list (from ERC721Enumerable) in the constructor.
  3. Move treasury and roles with a final proposal on the old Gov.
  4. 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

DoesDoes not
Relays registerVoter and update calls from its relayer wallet, batching with updateManyHold any key share, election key or member secret
Pre-checks requests cheaply (phase, slot exists, oldBallot matches), then verifies the proof before paying gasAuthenticate 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 sendingPersist 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)

RouteBody / responseNotes
GET /vote/:proposalIdPoll parameters, phase, slot count, artifact hashesPublic, 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/:txHashInclusion statusLets 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:

WindowTarget
At voteStartOpening sweep: mask every registered slot once, so no slot stays in its trivial initial state
During votingEach slot masked at random times (Poisson process), at rate λ per slot per hour
Final W hoursEach 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

FailureEffectMitigation
DownNo gas sponsorship, fewer masksGenji falls back to direct submission. Cover masks from Genji sessions and the standalone script continue
CensoringSome updates are not relayedGenji tracks inclusion and resubmits directly after a timeout
Malicious relayer, TEE intactCannot alter or forge anything (all on-chain verified)None needed
Enclave compromisedLearns IPs and timing: can link an IP to a registered vkh and to updates of that slot. Never learns ballot contentsGenji 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

AreaAddition
Pages/proposals/[id] gains a private-voting panel. /settings/voting shows identity status, relayer choice and Wulong attestation status
Background jobsAuto-registers for open proposals in their registration window. Sends cover masks after any voting action
Proverw3pk 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
Walletw3pk: 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 watchWatches 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
VerificationChecks the w3pk build against its on-chain registry (existing Genji practice) and Wulong's attestation before first use
Security headersCSP 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

  1. Load the poll. If not yet registered and the registration window is open, ask w3pk for vkh_p and a Semaphore membership proof, and submit.
  2. During voting, the user picks For, Against, Abstain or "no vote". The client:
    • reads the current oldBallot for its slot;
    • asks w3pk's zkProve for a vote-branch proof;
    • submits;
    • re-proves on 409.
  3. Send k cover masks on random slots, plus a mask on its own slot at a random later time if the session stays open.
  4. 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.

AdditionBehaviorWhy 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 changedKeeps the voting identity out of backup files
getSemaphoreIdentity(tag)Semaphore v4 identity built from derivePrfSecret. Returns the commitment onlyIdentity 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 callThe 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 ElGamalEncryption, re-randomization and point helpers on top of the existing Poseidon utilitiesBallot construction and masks
Artifact verificationChecks .wasm and .zkey CIDs against on-chain pins, reusing the build-verification helperDetects a swapped proving key
BackupFile v3Argon2id 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 filesA stolen file is harder to crack and does not name its owner
Reserved name: decoy credentialsNo implementation yetPhase 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

RootControlsBacked up byIf stolenIf lost
MnemonicMember wallet: NFT, public votes, proposing, delegation, gasw3pk backup file, social recoveryPublic actions, self-burn, and a rotation request the member can cancelGov burn and re-mint to a new wallet
Passkey (PRF)Identity and every voting secretPasskey sync within one ecosystemPrivate votes and slot linkage, including past proposalsDelayed 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

PathAuthorized byTakes effectTypical use
rotateIdentityOwnership proof for the current identityImmediatelyRoutine rotation; suspected device compromise while the passkey is still held
requestRotation then executeRotationMember walletAfter rotationDelay; cancellable by the current identityPasskey lost, or moving to another ecosystem
govResetIdentityA Gov proposalWhen the proposal executesBoth 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), with scope = hash("ROTATE", tokenId, nonce) and message = 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 idc applies to proposals created after the change. Open polls keep their snapshot, so the old identity still votes there.
  • Genji watches RotationRequested and 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

ScenarioWhat is lostRecovery
Device lost, passkey synced, backup file availableNothingRestore the wallet on a new device with the synced passkey. The identity is unchanged
Device lost, passkey synced, no backup fileMember walletGov 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 availableIdentity (new passkey, new PRF output)Restore the wallet from the backup file, then requestRotation and wait rotationDelay
Backup file stolenMember wallet, if the password is crackedCancel any rotation request, then Gov burn and re-mint to a fresh wallet. Private votes are unaffected
Passkey provider account compromisedIdentityrotateIdentity at once from a trusted device, then secure the account. Votes in open proposals stay contested until their deadline
Passkey and all wallet backups lostBothgovResetIdentity after Gov re-mints to a new wallet

10. Threat model and guarantees

10.1 Adversaries

AdversaryCanCannot
Public observerSee registrations count, slot updates, final countsLink a slot to a member; read a ballot; tell a vote from a mask
Coercer or briber without the voter's secretWatch the voter vote; demand the ciphertext randomnessConfirm the ballot is still the final one, since any later update may be a re-vote. Confirm abstention
Coercer present close to the deadlineSee 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_pVote last with the stolen key— not covered in the MVP (§12)
Fewer than t keypers colludingNothing beyond the public viewDecrypt anything
t or more keypers colludingDecrypt individual slots, before or after the deadlineLink a slot to a member (anonymous vkh); forge counts (on-chain checks)
Keypers withholding sharesBlock finalization → proposal DefeatedChange the result
Wulong, TEE intactDelay or drop relayed transactionsAnything else
Wulong, TEE compromisedLearn IP-to-slot metadataRead ballots; forge anything
Wulong compromised and ≥ t keypersLink IP to slot to ballot for voters who used WulongForge counts
NFT operatorMint memberships before a proposalCreate 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, requestRotationVote privately, link the member's slots, or complete a rotation the member cancels within rotationDelay
Attacker controlling the member's passkey provider accountDerive the identity: vote privately as the member, link the member's past and present slotsRead 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 executionYes — all inputs verified on-chain; counts checked against the decrypted aggregate
RobustnessMedium-High — liveness needs t of n keypers; Wulong is optional
Censorship resistance & livenessYes 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 resistanceNo. Identity and ballot separation limits damage if ballots are ever decrypted
Private voteYes — encrypted and anonymous
Running tally privacyYes
Quorum status during voteNo — only the registration count is visible
Individual verifiabilityYes — inclusion of one's own update
Universal verifiabilityYes
Eligibility / uniquenessYes — snapshotted member tree plus per-proposal nullifier
Multiple choice / weightedNo / No (For–Against–Abstain, one member one vote)
Vote updatabilityYes
DelegationNot in private mode
Gas efficiencyMedium — per-update proof verification; L2 recommended
Implementation simplicityLow-Medium

12. Phase 2 roadmap

  1. 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.

  2. 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.

  3. 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.

  4. Private delegation. Kite shows private delegate, revoke and re-delegate on a Governor. Integrating it would restore delegation in private mode.

  5. Post-quantum migration. Follow the w3pk post-quantum roadmap. Ballot encryption would need a lattice-based additively homomorphic scheme.

  6. Richer ballots. Multiple choice and approval voting through wider plaintext vectors. Cicada's paper (Section 3) describes these extensions.

  7. 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.

  8. 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)

ParameterProposed defaultSet by
minRegistrationWindow2 daysGov
rotationDelay7 daysGov
votingPeriod (private)≥ 5 daysGov
finalizationWindow3 daysGov
Committee t-of-n3-of-5, independent operatorsGov
Mask rate λ1 per slot per 6 hWulong config
End-game window W / interval Δ6 h / 15 minWulong config
Genji cover masks per session k3Genji config
Member tree depthUp to 16Registry constant

14. Testing plan

Contracts (Foundry)

  • Identity registration and duplicate rejection; burn zeroes the leaf for both govBurn and 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.
  • proposePrivate reverts 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, updateMany partial 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 t shares rejected.
  • Gov: castVote blocked, quorum from snapshotted member count, Defeated after finalizeBy, execution only after finalization.

w3pk

  • derivePrfSecret returns 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 zkProve return 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 anonymous vkh.
  • 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, Gov and NFT changes audited.
  • NFT operator disabled or expired; requireNoActiveOperator on.
  • Keyper set of independent operators named publicly; DKG completed; epoch active.
  • Wulong /vote module 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

  1. w3pk additions (§8.4): PRF identity, zkProve, artifact verification, BackupFile v3.
  2. MemberRegistry with the three rotation paths, and the NFT burn hook.
  3. Update circuit (vote/mask OR) and its benchmarks.
  4. PrivateVoting registration, slots and aggregate.
  5. TallyCommittee and the gov-keyper CLI (DKG, shares).
  6. PrivateVoting shares and finalization.
  7. Gov private mode and overrides.
  8. Genji client flows and identity watch, then the Wulong /vote module and mask bot.
  9. Ceremony, audits, testnet election.

Further reading