---
title: Private Coercion-Resistant Voting for Gov
description: Specification for adding receipt-free, anonymous, verifiable proposal voting to Gov, with Wulong as a dispensable relayer and Genji with w3pk as the voting client.
date: 2026-10-12
lang: en-US
author: Julien Béranger
model: Claude Opus 5.5
conversation: https://claude.ai/chat/9b653e5d-e9e7-4ead-8f1b-ca50d102a219
source: https://julienberanger.com/gov-private-voting
---

# Private Coercion-Resistant Voting for Gov

**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](https://github.com/w3hc/gov), the minimalist DAO built on [OpenZeppelin Governor](https://docs.openzeppelin.com/contracts/5.x/api/governance). It has four parts:

- **Gov, with modifications** — the only proposal, quorum and execution authority.
- **[Wulong](https://github.com/w3hc/wulong)** — a dispensable relayer that pays gas, hides submitter IP addresses and generates cover traffic.
- **[Genji](https://github.com/w3hc/genji)** — the frontend, using the [w3pk](https://github.com/w3hc/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](https://pse.dev/articles/state-of-private-voting-2026/state-of-private-voting-2026-v2.pdf) report by [Privacy Stewards of Ethereum](https://pse.dev/) and [Shutter](https://www.shutter.network/). The report rates only four protocols "Yes" on coercion resistance: [MACI](https://maci.pse.dev/), [SIV](https://siv.org), [DAVINCI](https://davinci.vote/) and [Enclave CRISP](https://www.enclave.gg/). 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](https://semaphore.pse.dev/) 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](#12-phase-2-roadmap).
- **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](https://www.torproject.org/) provide.**

## 3. Components

```text
            ┌───────────────────────────── 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](https://base.org) or [OP Mainnet](https://www.optimism.io), because every vote and mask carries an on-chain proof verification. [Crosschain Gov](https://github.com/w3hc/gov-crosschain) can carry the DAO there.

## 4. Cryptographic building blocks

| Primitive | Choice | Used for |
| --- | --- | --- |
| Curve | [Baby Jubjub](https://eips.ethereum.org/EIPS/eip-2494), a [twisted Edwards curve](https://en.wikipedia.org/wiki/Twisted_Edwards_curve) over the BN254 scalar field | Ballot encryption, committee keys |
| Encryption | Exponential [ElGamal](https://en.wikipedia.org/wiki/ElGamal_encryption): `Enc(m; r) = (r·G, m·G + r·PK)` | Ballots, additively homomorphic |
| Hash | [Poseidon](https://eprint.iacr.org/2019/458) | Commitments, voting keys, Merkle trees |
| Membership proof | [Semaphore v4](https://github.com/semaphore-protocol) circuit and verifier, unchanged | Anonymous per-proposal voter registration |
| Update proof | [Groth16](https://eprint.iacr.org/2016/260) via [Circom](https://docs.circom.io) and [snarkjs](https://github.com/iden3/snarkjs), with a dedicated phase-2 ceremony | Vote or mask, indistinguishably |
| Key generation | Joint-Feldman [distributed key generation](https://en.wikipedia.org/wiki/Distributed_key_generation) on [Shamir shares](https://en.wikipedia.org/wiki/Shamir%27s_secret_sharing) | 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](https://en.wikipedia.org/wiki/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`](https://docs.openzeppelin.com/contracts/5.x/api/governance#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](https://www.w3.org/TR/webauthn-3/#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](#84-required-w3pk-additions)) | 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](#9-key-management-and-recovery).

## 5. Lifecycle

```mermaid
sequenceDiagram
    autonumber
    participant M as Member (Genji + w3pk)
    participant W as Wulong (optional)
    participant G as Gov
    participant V as PrivateVoting
    participant K as Keypers
    Note over M,G: Phase 0 — once per member
    M->>V: MemberRegistry.registerIdentity(tokenId, idc)
    Note over G,V: Phase 1 — proposal creation
    M->>G: proposePrivate(...)
    G->>V: openPoll(id, memberRoot, memberCount, epochPK, times)
    Note over M,V: Phase 2 — registration window (= votingDelay)
    M->>W: registerVoter(id, vkh, semaphoreProof)
    W->>V: relay (batched)
    Note over M,V: Phase 3 — voting period
    W->>V: opening sweep — mask every slot
    M->>W: update(id, slot, newBallot, proof) + k cover masks
    W->>V: relay; masks keep flowing until the deadline
    Note over K,V: Phase 4 — tally
    K->>V: submitShare(id, D[3], dleq[3]) × t
    M->>V: finalize(id, counts[3])  (anyone)
    Note over G: Phase 5 — Gov reads result → Succeeded/Defeated → execute
```

### 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](#92-identity-rotation)):

- 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](#73-masking-policy).
- **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`

| 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](https://github.com/privacy-scaling-explorations/zk-kit)-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.

```solidity
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`

```solidity
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`

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

```solidity
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`

```solidity
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:

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](https://nestjs.com/) service in a TEE ([dstack](https://github.com/Dstack-TEE/dstack) on [Intel TDX](https://www.intel.com/content/www/us/en/developer/tools/trust-domain-extensions/overview.html), deployed to [Phala Cloud](https://phala.network/)). 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

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.

| 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](#12-phase-2-roadmap)) |

**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](#12-phase-2-roadmap), 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`), 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

| 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](#12-phase-2-roadmap)) |
| 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](#12-phase-2-roadmap), 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

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](https://eprint.iacr.org/2002/165)-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](https://blog.shutter.network/coming-soon-to-daos-permanent-shielded-voting-via-homomorphic-encryption/) 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](https://arxiv.org/pdf/2501.05626) 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](https://eprint.iacr.org/2023/1473.pdf)) 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](#84-required-w3pk-additions).

## 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](https://getfoundry.sh/))

- 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](#84-required-w3pk-additions)): 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

- [State of Private Voting 2026](https://pse.dev/articles/state-of-private-voting-2026/state-of-private-voting-2026-v2.pdf) — the comparison this design builds on
- [CRISP on ethresear.ch](https://ethresear.ch/t/collusion-resistant-impartial-selection-protocol-crisp/18804) — the masking idea
- [DAVINCI whitepaper](https://whitepaper.vocdoni.io/) — re-randomization and stealth overwrites
- [MACI](https://maci.pse.dev/) — key change against bribery
- [Semaphore documentation](https://semaphore.pse.dev/)
- [Cicada paper](https://eprint.iacr.org/2023/1473.pdf) — homomorphic tallying and ballot extensions
- [Kite paper](https://arxiv.org/pdf/2501.05626) — private delegation on a Governor
- [w3pk documentation](https://w3pk.w3hc.org) and [Genji live demo](https://genji.w3hc.org)
