Two custom pallets sit in the runtime:
| Pallet | Crate | pallet_index |
Path |
|---|---|---|---|
| Membership | pallet-membership-gov |
51 | blockchain/pallets/membership/ |
| Governance | pallet-governance |
52 | blockchain/pallets/governance/ |
Governance depends on membership through a MembershipProvider trait (defined in pallet-governance, implemented in the runtime config). All state-changing extrinsics emit events; the frontend subscribes via PAPI for live updates. Most extrinsics are signed; cast_vote is unsigned and apply is unsigned + authorised (covered below).
Manages homes, blocks, the committee, elections (with on-chain Q&A) and the X25519 public keys used to encrypt resident PII. Single-org assumption: no org registry needed.
Homes: Map<HomeId, HomeInfo>: flat number, block ID, active flag, primary account, plaintext name, encrypted phone (X25519 hybrid ciphertext)AccountHome: Map<AccountId, HomeId>: device → home lookupHomeAccounts: Map<HomeId, BoundedVec<AccountId, MaxDevicesPerHome>>: every device linked to a homeDeviceLabels: Map<AccountId, BoundedVec<u8, 64>>: optional user-facing label per device ("iPhone", "MacBook")Blocks: Map<BlockId, BlockInfo>: block name and live home countCommittee: BoundedVec<AccountId, MaxCommitteeSize>: current committee membersCommitteeTermEnd: Timestamp: Unix-ms expiry of the current termPendingApplications: Map<AccountId, Application>: applicants awaiting approvalElectionOpen: bool: election lifecycle flagCandidates: Map<AccountId, Candidacy>: nominee with name, agenda, vote countElectionVoters: Map<HomeId, bool>: per-home voted flag in the current electionElectionQAs: DoubleMap<AccountId, QuestionId, Question>: per-candidate Q&ANextQuestionId: Map<AccountId, QuestionId>NextHomeId: HomeIdCommunityPublicKey: [u8; 32]: X25519 public key for community-wide name encryptionCommitteePublicKey: [u8; 32]: X25519 public key for committee-only phone encryption
Phones are encrypted client-side with the committee's X25519 public key (X25519 + HKDF-SHA256 + AES-256-GCM, with HKDF info pii-phone for purpose-only domain separation — the homeId is not included because applicants encrypt before they have one). Only committee members holding the matching private key can decrypt. Names use the same scheme but with the community public key (HKDF info pii-name), so any active member can decrypt them. rotate_encrypted_phones lets the committee batch-update ciphertexts after a key rotation. See crypto.md for the full design and README.md#genesis-demo-data for the genesis pre-encryption pipeline.
apply(applicant, flat_number, block_id, name, encrypted_phone)— unsigned, authorised via a customCheckApplyvalidity extension. Stores a pendingApplication; the applicant's account is identified in the call.approve(applicant)— committee. Creates the home, seeds the new account withSeedAmount, increments the block's home count, removes the application.reject(applicant)— committee. Removes the pending application.revoke_device(account)— home member (self-service) or committee. Cannot remove the last device of a home.revoke_home(home_id)— committee. Removes the entire home, all linked accounts, and demotes the account from the committee if applicable.transfer(home_id, new_account)— committee. Tenant change: unlinks all old devices, links the new primary, seeds it withSeedAmount.add_device(new_account, label)— active member. Links an additional device to the caller's home and seeds it withSeedAmount. Errors on full home or already-linked account.register_block(block_id, name)— committee. Initialises a new block withhome_count = 0.start_election()— any signed account, after the term has expired. Opens nomination + Q&A + voting.nominate(name, agenda)— active member. Stores aCandidacy; incumbents may re-nominate.ask_candidate(candidate, question)— active member. Records a publicly viewable question against a candidate during an open election (capped per candidate). Asker's home ID is recorded.answer_question(question_id, answer)— the candidate. One answer per question.vote_election(votes: BoundedVec<(AccountId, bool), MaxElectionVotes>)— active member. One call per home per election. Each vote is approve/oppose for one candidate.finalise_election()— any active member (deliberately not committee-only, to prevent incumbents from blocking transitions). TopMaxCommitteeSizecandidates by approval count become the new committee, term end is reset, all election state is cleared.set_community_public_key(public_key)/set_committee_public_key(public_key)— committee. Rotates the X25519 public key used by clients for new encryptions.rotate_encrypted_phones(updates: Vec<(HomeId, BoundedVec<u8, MaxPhoneLen>)>)— committee. Batch-rewrites stored phone ciphertexts after a key rotation. Capped at 50 per call.
ApplicationSubmitted, HomeApproved, ApplicationRejected, DeviceRevoked, HomeRevoked, HomeTransferred, DeviceAdded, BlockRegistered, ElectionStarted, CandidateNominated, ElectionVoteCast, ElectionFinalised, QuestionAsked, QuestionAnswered, CommunityPublicKeySet, CommitteePublicKeySet, EncryptedPhonesRotated.
GenesisConfig {
committee: Vec<AccountId>,
blocks: Vec<(BlockId, Vec<u8>)>,
// (flat, block, name, encrypted_phone, account)
homes: Vec<(u32, BlockId, Vec<u8>, Vec<u8>, AccountId)>,
community_public_key: Option<Vec<u8>>,
committee_public_key: Option<Vec<u8>>,
}Demo phones are pre-encrypted with the dev committee key — see the genesis pipeline in README.md and scripts/pin-genesis-content.mjs.
Proposal lifecycle, anonymous voting, and on-chain comments in a single pallet. Voting uses a Groth16 SNARK over a Merkle tree of Poseidon commitments — not ring-VRF — so verification is constant-time regardless of electorate size and proofs verify in a few milliseconds in-runtime. Treasury execution is off-chain; the pallet stores a FundingSource enum for transparency only.
- Registration. Each home calls
register_commitment(secret_hash, identity_key_hash), which insertscommitment = poseidon(3, secret_hash, identity_key_hash)into an on-chain Merkle tree. The leaf index is recorded inCommitmentLeafIndex; the home's identity key hash is stored once and is immutable thereafter. - Voting. To vote, the home generates a Groth16 proof in the browser whose public inputs are
(merkle_root, nullifier, proposal_id, genesis_hash, vote_direction, author_identity_key_hash)and whose private witness is(home_secret, identity_key, merkle_path). The circuit enforces: the commitment is in the tree, the nullifier isposeidon(home_secret, proposal_id), and the voter is not the proposal author (viaidentity_key ≠ author_identity_key_hash). - Submission.
cast_voteis unsigned: the proof is the authorisation. A small proof-of-work (leading-zero bits overblake2b(zk_proof || pow_nonce)) replaces gas as the spam mitigation. - Replay protection.
Nullifiers: DoubleMap<ProposalId, [u8; 32], VoteDirection>records every used nullifier per proposal. Re-submission with the same nullifier fails. Because the nullifier is deterministic in(secret, proposal_id), double-voting is impossible without revealing two distinct secrets. - Historic roots.
MerkleRootHistoryis a 32-slot ring buffer. A vote's proof can target any root in the buffer, so adding/rotating commitments mid-proposal does not invalidate in-flight votes.
register_commitment(secret_hash, identity_key_hash)— once per home. Errors if the home is re-registering while older proposals (created before the home's commitment was removed) are still open, to prevent ballot-stuffing on a refreshed key.rotate_commitment(new_secret_hash)— compromise response. Combines the new secret with the immutableidentity_key_hashand updates the leaf in place. Records(timestamp, rotator_account)inLastRotationInfo.remove_commitment(home_id)— any device of the home. Zeros the leaf and stampsCommitmentRemovedAt. Used during home revocation or device cleanup; re-registration is gated on no older open proposals existing.
Proposals: Map<ProposalId, Proposal>— title, IPFS CIDs (description, optional image), deadline, scope, status, author,author_home_id,author_identity_key_hash,created_at,funding_source,estimated_cost_usd, declining-quorum parameters,min_voting_endNextProposalId: ProposalIdComments: Map<ProposalId, BoundedVec<Comment>>— CID, optional author (None = anonymous),flagged,created_atNullifiers: DoubleMap<ProposalId, [u8; 32], VoteDirection>Tallies: Map<ProposalId, Tally>—(yes, no, abstain)Commitments: Map<HomeId, [u8; 32]>andCommitmentLeafIndex: Map<HomeId, u32>MerkleNodes: DoubleMap<u32 level, u32 index, [u8; 32]>andMerkleRootStore: [u8; 32]NextLeafIndex: u32andMerkleRootHistory: BoundedVec<[u8; 32], 32>IdentityKeyHashes: Map<HomeId, [u8; 32]>— set once onregister_commitmentLastRotationInfo: Map<HomeId, (Timestamp, AccountId)>,CommitmentRemovedAt: Map<HomeId, Timestamp>VerificationKeyStore: BoundedVec<u8, 512>— Groth16 verification key, set in genesisActiveProposalCount: Map<AccountId, u32>— per-author cap (MaxActiveProposals); the only spam mitigation forcreate_proposalsince the bare-call form holds no balance depositEligibleCount: Map<ProposalId, u32>— voter denominator captured at proposal creation (excludes the author's home)EarliestOpenProposalBlock: Timestamp— fast-path filter for the auto-close hook
enum VoteDirection { Yes, No, Abstain }
enum ProposalStatus { Open, Passed, Rejected, Withdrawn }
enum ProposalScope { All, Block(BlockId) }
enum FundingSource<T> { None, ReserveFund, SpecialAssessment, Other(T) }create_proposal(author, title, description_cid, image_cid, deadline, scope, funding_source, estimated_cost_usd)— unsigned, authorised via theCheckCreateProposalextension (see below). Theauthor: AccountIdparameter identifies the proposing home; the chain validates membership and the per-author active-proposal cap (MaxActiveProposals) in both the extension and the dispatch. Deadline must be in the future.estimated_cost_usdis required unlessfunding_source == None. The author's home is excluded from the eligible count to enforce the "voter ≠ author" circuit constraint at the tally level too. No balance deposit is held — passkey-derived accounts have zero balance, so the spam mitigation is the per-author active-proposal cap rather than a refundable deposit.withdraw_proposal(proposal_id)— author only, before any votes. DecrementsActiveProposalCount.cast_vote(proposal_id, vote, nullifier, zk_proof, pow_nonce, merkle_root)— unsigned. Verifies:merkle_rootis current or in history, PoW satisfies the difficulty target, the Groth16 proof is valid for the public inputs, the nullifier is unused for this proposal. Records the nullifier and updates the tally.comment(proposal_id, comment_cid, anonymous)— active member.anonymous = truestoresauthor = None; the comment is otherwise indistinguishable. Capped per proposal byMaxCommentsPerProposal.flag_comment(proposal_id, comment_index)— committee. Marks the comment as hidden; the CID stays on-chain (immutability) but the frontend hides it by default.close_proposal(proposal_id)— any signed account after the deadline. Sets status toPassed/Rejectedagainst the declining-quorum threshold and the simple majority. Early close is allowed if (a) caller is committee, (b)min_voting_endhas passed, (c) the current quorum threshold is met. DecrementsActiveProposalCount.register_commitment(secret_hash, identity_key_hash)/rotate_commitment(new_secret_hash)/remove_commitment(home_id)— see Commitment lifecycle above.reset_home_devices(home_id)— governance origin only. Currently a placeholder hook for governance-authorised device cleanup; the actual device mutations live inpallet-membership-gov.
ProposalCreated, ProposalWithdrawn, ProposalClosed { proposal_id, status }, VoteCast, CommentAdded { proposal_id, index, anonymous }, CommentFlagged, CommitmentRegistered, CommitmentRotated, CommitmentRemoved, HomeDevicesReset.
On every block:
- Fast path. If
EarliestOpenProposalBlockshows that no open proposal could yet have expired, return after one read. - Otherwise iterate open proposals (capped at
MaxAutoClosePerBlock), close any whose deadline has passed: compute the declining-quorum threshold for the deadline timestamp, decide pass/fail, decrementActiveProposalCount, emitProposalClosed. - Update
EarliestOpenProposalBlockso future blocks can use the fast path again.
Governance defines a trait it consumes:
pub trait MembershipProvider<AccountId> {
fn is_active_member(who: &AccountId) -> bool;
fn home_id_of(who: &AccountId) -> Option<HomeId>;
fn is_committee_member(who: &AccountId) -> bool;
}The runtime config implements MembershipProvider for pallet-membership-gov, so governance can check membership / committee status without a direct dependency on the membership crate's storage layout. There is no traffic in the other direction.
GenesisConfig {
proposals: Vec<GenesisProposal<AccountId>>,
verification_key: Vec<u8>,
}verification_key is the serialised Groth16 VK; it must be set or cast_vote always fails with NoVerificationKey. proposals carries demo proposals (title, IPFS CIDs, scope, status, deadline, funding source, etc.) with their pre-computed tallies and eligible counts so the UI is populated on a fresh chain.
- Weights are parameterised per pallet conventions (e.g.
T::WeightInfo::foo(n)rather than multiplying a fixed weight) so benchmarks track actual proof verification, comment-list mutation, and tree update costs. - Groth16 verification fits in one block. Verification is constant-time in the electorate size; the cost driver is curve operations (
ark-bls12-381,ark-groth16), not membership count. - Tree depth is fixed at compile time (currently 5 for the small-tree example keys; production uses depth 20 for ~1M voters).
- No ring-VRF / Bandersnatch surface (
rotate_vrf_key, ring construction). Replaced by Groth16 + commitment lifecycle above. - No per-slot anonymous comment nullifier scheme. Anonymous comments are flagged via the
anonymous: boolparameter oncomment; rate-limiting per home is not currently enforced on-chain (relies on the per-proposalMaxCommentsPerProposalcap and the per-author active-proposal cap).
Groth16 / arkworks stack via primitives/zk-voting: ark-bls12-381, ark-groth16, ark-snark, ark-relations, plus an in-house Poseidon implementation (ark-crypto-primitives-compatible). Test/dev keys live in blockchain/primitives/zk-voting/test-keys/.