Participatory governance for homeowners. Anonymous, verifiable on-chain voting via Groth16 zero-knowledge proofs.
Built on the Polkadot SDK as a Cumulus-based parachain. Features include coercion-resistant voting, passkey sign-in with multi-device pairing, encrypted on-chain PII, declining quorum, committee elections with public Q&A, IPFS-pinned proposals, and an immutable audit trail.
- Blockchain (
blockchain/)- Membership pallet (
blockchain/pallets/membership/): Homes, blocks, committee, elections, candidate Q&A, encrypted PII - Governance pallet (
blockchain/pallets/governance/): Proposals, anonymous Groth16 voting, comments, declining quorum - ZK voting primitives (
blockchain/primitives/zk-voting/): Groth16 circuit, Poseidon commitments, trusted-setup ceremony tooling - Parachain runtime (
blockchain/runtime/): Wires both pallets together with Cumulus
- Membership pallet (
- Frontend (
web/): React + TypeScript app using PAPI for chain interactions and WebAuthn passkeys for sign-in - CLI (
cli/): Rust CLI for membership and governance operations via subxt - Spec (
spec/): Domain model, pallet specs, cryptographic design - Dev scripts (
scripts/): One-command scripts to build, start, test, and deploy
![]() Sign in / register / recover |
![]() Dashboard |
![]() Proposal detail |
![]() My home |
-
Rust (stable, installed via rustup)
-
Node.js 22.x LTS and npm v10.9+
-
OpenSSL development headers (
libssl-devon Ubuntu,opensslon macOS) -
protoc Protocol Buffers compiler (
protobuf-compileron Ubuntu,protobufon macOS) -
zombienet v1.3.x (
npm install -g @zombienet/cli) -
chain-spec-builder v17.0.0 (
cargo install staging-chain-spec-builder) -
Polkadot SDK binaries (stable2512-3):
# Download pre-built binaries (default) ./scripts/download-sdk-binaries.sh # Or build relay node with fast-runtime for faster finality (recommended for dev) ./scripts/download-sdk-binaries.sh --fast-runtime
The
--fast-runtimeflag builds the polkadot relay binary from source with reduced epoch duration, making GRANDPA finality take seconds instead of minutes on local Zombienet. First build takes ~15 minutes; subsequent builds are cached.
Copy the example env file and fill in the values you need:
cp web/.env.example web/.envSee web/.env.example for all available variables. At minimum you will want the PII decryption keys (VITE_COMMUNITY_PRIVATE_KEY, VITE_COMMITTEE_PRIVATE_KEY) and optionally Pinata credentials for IPFS uploads.
Two modes depending on your needs:
./scripts/start-dev.sh
# Substrate RPC: ws://127.0.0.1:9944- Instant finalisation: blocks produced every 3 seconds, finalised immediately
- No relay chain: starts in ~30 seconds, no Zombienet overhead
- Fastest iteration: change code, restart, test in seconds
- Includes all pallets (Membership, Governance) and genesis data (committee, blocks)
Start the frontend separately:
cd web && npm run dev
# Frontend: https://127.0.0.1:5173./scripts/start-all.sh
# Substrate RPC: ws://127.0.0.1:9944
# Frontend: https://127.0.0.1:5173- Full relay chain (2 validators) + parachain collator via Zombienet
- Finalisation takes ~30 seconds (GRANDPA consensus on relay chain)
- First start takes 5-10 minutes
After making pallet or runtime changes, deploy to the running chain:
./scripts/deploy.shThis bumps the spec version, builds the runtime WASM, submits a sudo(system.setCode) upgrade, rebuilds the CLI, and regenerates PAPI descriptors from the WASM blob. Works with both dev node and Zombienet. Chain state is preserved.
# Membership pallet tests
cargo test -p pallet-membership-gov
# Governance pallet tests
cargo test -p pallet-governance
# All workspace tests
cargo test --workspacecargo +nightly fmt && cargo clippy --workspace # Rust
cd web && npm run fmt && npm run lint # FrontendClients — React + PAPI web app and a Rust + subxt CLI; both talk to the parachain over WebSocket.
Parachain runtime — Cumulus-based on polkadot-sdk stable2512-3, with two custom pallets:
| Pallet | Index | Surface |
|---|---|---|
Membership |
51 | Homes, blocks, committee, elections, Q&A, encrypted PII |
Governance |
52 | Proposals, ZK voting, declining quorum, comments |
Governance reads membership state through a MembershipProvider trait; nothing flows the other way.
Off-chain pieces the chain delegates to:
- ZK proofs generated in the browser via WASM (Groth16 over a Poseidon-commitment Merkle tree). Only the verified proof and nullifier hit chain.
- Proposal media (descriptions, images) pinned to IPFS via Pinata; only CIDs are stored on-chain.
- PII (names, phones) X25519-encrypted client-side before submission, decrypted in the right audience's browser (members for names, committee for phones).
- Device sign-in via WebAuthn passkeys + PRF; identity keys derived from a BIP39 mnemonic the user records on first sign-up.
See spec/pallets.md for the full extrinsic surface and spec/web.md for the frontend wiring.
# Apply for membership (unsigned, feeless)
cargo run -p stack-cli -- membership apply --flat-number 101 --block-id 1 --signer //newuser
# Approve an application (committee only)
cargo run -p stack-cli -- membership approve 0x... --signer alice
# List pending applications and committee
cargo run -p stack-cli -- membership list-pending
cargo run -p stack-cli -- membership show-committee
# Create a proposal (description CID pinned to IPFS separately;
# unsigned bare call — `--signer` only identifies the author).
cargo run -p stack-cli -- governance create-proposal --title "Install EV chargers" \
--description-cid QmXyz... --deadline 1776902400000 \
--funding reserve --cost 45000 --signer alice
# Tally and close
cargo run -p stack-cli -- governance get-tally <proposal-id>
cargo run -p stack-cli -- governance close-proposal <proposal-id> --signer aliceRun cargo run -p stack-cli -- membership --help and governance --help for the full surface (elections, comments, transfer, device management, candidate Q&A).
Anonymous voting uses Groth16 zero-knowledge proofs, which require a one-time trusted setup. For dev/testing, pre-generated keys are included. For production, run a multi-party ceremony where each participant adds randomness: only one honest participant is needed for soundness.
# 1. Initialise (creates round 0 from a single-party setup)
cargo run -p zk-voting --example ceremony_init --features zk-voting/ceremony,zk-voting/small-tree
# 2. Each participant contributes (sequential: pass output to next person)
cargo run -p zk-voting --example ceremony_contribute --features zk-voting/ceremony \
-- ceremony/round_000.params ceremony/round_001.params # participant 1
cargo run -p zk-voting --example ceremony_contribute --features zk-voting/ceremony \
-- ceremony/round_001.params ceremony/round_002.params # participant 2
# ... repeat for 10-20 participants
# 3. Finalize: extract keys and verify with a test proof
cargo run -p zk-voting --example ceremony_finalize \
--features zk-voting/ceremony,zk-voting/small-tree \
-- ceremony/round_002.params blockchain/primitives/zk-voting/test-keys
# 4. Deploy
cp blockchain/primitives/zk-voting/test-keys/pk.bin web/public/zk-proving-key.bin
# Rebuild chain (VK in genesis) and frontendEach contributor's randomness is generated from OS entropy and never touches disk. See spec/crypto.md for the full cryptographic design.
Resident names and phone numbers are encrypted on-chain in the membership pallet (blockchain/pallets/membership/) using X25519 hybrid encryption (ECDH + HKDF-SHA256 + AES-256-GCM). Two keypairs are needed: one for names (all members can decrypt) and one for phone numbers (committee-only).
# Generate both keypairs (uses Web Crypto / Node.js >= 20)
node --input-type=module -e "
import { webcrypto } from 'node:crypto';
const { subtle } = webcrypto;
for (const label of ['community', 'committee']) {
const kp = await subtle.generateKey('X25519', true, ['deriveBits']);
const pub = Buffer.from(await subtle.exportKey('raw', kp.publicKey)).toString('hex');
const pkcs8 = Buffer.from(await subtle.exportKey('pkcs8', kp.privateKey));
const priv = pkcs8.subarray(16, 48).toString('hex');
console.log(label + '_public: ' + pub);
console.log(label + '_private: ' + priv);
}
"Deploy the public keys on-chain (set in genesis config or via committee extrinsic):
CommunityPublicKey:blockchain/runtime/src/genesis_config_presets.rs, or callset_community_public_keyafter genesisCommitteePublicKey: same file, or callset_committee_public_keyafter genesis
Private keys are distributed to devices via the app and stored in localStorage:
| localStorage key | Who holds it | How it's set |
|---|---|---|
gov-community-private-key |
All members | Device pairing QR (PairDevicePage.tsx) or dev login (SignInPage.tsx) |
gov-committee-private-key |
Committee only | Key rotation UI (MembershipPage.tsx) or dev login |
- The pairing QR includes the community private key alongside
home_secret(built inMyHomePage.tsx) - Committee private keys are shared via the "Encryption Keys" section on the Membership page
- Dev keys are defined in
web/src/config/devKeys.tsand set automatically on dev login
Key rotation: committee members can rotate either keypair from the Membership page. Rotating the committee key triggers rotate_encrypted_phones to re-encrypt all phone numbers in batches.
Genesis homes (Alice / Bob / Charlie) ship with pre-encrypted phone numbers so the committee table on a fresh chain matches production behaviour rather than relying on a plaintext fallback. The plaintext numbers and home IDs live in blockchain/genesis-content/homes.json; scripts/pin-genesis-content.mjs re-encrypts them with the dev committee public key (matching the committee_public_key in genesis) and writes the resulting *_PHONE_CIPHERTEXT constants into blockchain/runtime/src/genesis_content.rs. Use --skip-ipfs to regenerate phone ciphertexts without re-pinning to Pinata:
node scripts/pin-genesis-content.mjs --skip-ipfsSee spec/crypto.md for the full PII encryption design.
Design specification (spec/):
- model.md — domain model: home, block, committee, proposal, vote, comment
- voting.md — voting rules: declining quorum, anonymity model, pass/fail logic, proposal closure
- pallets.md —
pallet-membership-govandpallet-governance: storage, extrinsics, events, cross-pallet wiring - web.md — frontend design: routes, passkey + PRF authentication, multi-device pairing, ZK voting flow
- crypto.md — cryptographic design: Groth16 voting, PII encryption, key rotation
- plan.md — implementation plan
Setup and tooling (docs/):
- INSTALL.md — prerequisites and build steps
- TOOLS.md — Polkadot stack components used
- DEPLOYMENT.md — deployment and CLI guide
- Polkadot SDK — Cumulus parachain runtime, FRAME pallets
- polkadot-omni-node — block author for the local dev node
- Zombienet — relay chain + collator orchestration for local testing
- PAPI — frontend Substrate client
- subxt — CLI Substrate client
- arkworks — Groth16 + Poseidon for ZK voting (compiled to WASM for in-browser proving)
- WebAuthn + PRF — passkey sign-in with at-rest key sealing
- Pinata — IPFS pinning for proposal descriptions and images
- React + Vite + Tailwind — frontend stack
Pinned versions live in Cargo.toml, web/package.json, and rust-toolchain.toml.



