Skip to content

Latest commit

 

History

History
182 lines (136 loc) · 15.5 KB

File metadata and controls

182 lines (136 loc) · 15.5 KB

Pallets

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

pallet-membership-gov

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.

Storage

  • 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 lookup
  • HomeAccounts: Map<HomeId, BoundedVec<AccountId, MaxDevicesPerHome>>: every device linked to a home
  • DeviceLabels: Map<AccountId, BoundedVec<u8, 64>>: optional user-facing label per device ("iPhone", "MacBook")
  • Blocks: Map<BlockId, BlockInfo>: block name and live home count
  • Committee: BoundedVec<AccountId, MaxCommitteeSize>: current committee members
  • CommitteeTermEnd: Timestamp: Unix-ms expiry of the current term
  • PendingApplications: Map<AccountId, Application>: applicants awaiting approval
  • ElectionOpen: bool: election lifecycle flag
  • Candidates: Map<AccountId, Candidacy>: nominee with name, agenda, vote count
  • ElectionVoters: Map<HomeId, bool>: per-home voted flag in the current election
  • ElectionQAs: DoubleMap<AccountId, QuestionId, Question>: per-candidate Q&A
  • NextQuestionId: Map<AccountId, QuestionId>
  • NextHomeId: HomeId
  • CommunityPublicKey: [u8; 32]: X25519 public key for community-wide name encryption
  • CommitteePublicKey: [u8; 32]: X25519 public key for committee-only phone encryption

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

Extrinsics

  • apply(applicant, flat_number, block_id, name, encrypted_phone) — unsigned, authorised via a custom CheckApply validity extension. Stores a pending Application; the applicant's account is identified in the call.
  • approve(applicant) — committee. Creates the home, seeds the new account with SeedAmount, 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 with SeedAmount.
  • add_device(new_account, label) — active member. Links an additional device to the caller's home and seeds it with SeedAmount. Errors on full home or already-linked account.
  • register_block(block_id, name) — committee. Initialises a new block with home_count = 0.
  • start_election() — any signed account, after the term has expired. Opens nomination + Q&A + voting.
  • nominate(name, agenda) — active member. Stores a Candidacy; 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). Top MaxCommitteeSize candidates 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.

Events

ApplicationSubmitted, HomeApproved, ApplicationRejected, DeviceRevoked, HomeRevoked, HomeTransferred, DeviceAdded, BlockRegistered, ElectionStarted, CandidateNominated, ElectionVoteCast, ElectionFinalised, QuestionAsked, QuestionAnswered, CommunityPublicKeySet, CommitteePublicKeySet, EncryptedPhonesRotated.

Genesis

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.

pallet-governance

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.

Anonymous voting (Groth16 + Poseidon + Merkle)

  1. Registration. Each home calls register_commitment(secret_hash, identity_key_hash), which inserts commitment = poseidon(3, secret_hash, identity_key_hash) into an on-chain Merkle tree. The leaf index is recorded in CommitmentLeafIndex; the home's identity key hash is stored once and is immutable thereafter.
  2. 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 is poseidon(home_secret, proposal_id), and the voter is not the proposal author (via identity_key ≠ author_identity_key_hash).
  3. Submission. cast_vote is unsigned: the proof is the authorisation. A small proof-of-work (leading-zero bits over blake2b(zk_proof || pow_nonce)) replaces gas as the spam mitigation.
  4. 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.
  5. Historic roots. MerkleRootHistory is 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.

Commitment lifecycle

  • 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 immutable identity_key_hash and updates the leaf in place. Records (timestamp, rotator_account) in LastRotationInfo.
  • remove_commitment(home_id) — any device of the home. Zeros the leaf and stamps CommitmentRemovedAt. Used during home revocation or device cleanup; re-registration is gated on no older open proposals existing.

Storage

  • 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_end
  • NextProposalId: ProposalId
  • Comments: Map<ProposalId, BoundedVec<Comment>> — CID, optional author (None = anonymous), flagged, created_at
  • Nullifiers: DoubleMap<ProposalId, [u8; 32], VoteDirection>
  • Tallies: Map<ProposalId, Tally>(yes, no, abstain)
  • Commitments: Map<HomeId, [u8; 32]> and CommitmentLeafIndex: Map<HomeId, u32>
  • MerkleNodes: DoubleMap<u32 level, u32 index, [u8; 32]> and MerkleRootStore: [u8; 32]
  • NextLeafIndex: u32 and MerkleRootHistory: BoundedVec<[u8; 32], 32>
  • IdentityKeyHashes: Map<HomeId, [u8; 32]> — set once on register_commitment
  • LastRotationInfo: Map<HomeId, (Timestamp, AccountId)>, CommitmentRemovedAt: Map<HomeId, Timestamp>
  • VerificationKeyStore: BoundedVec<u8, 512> — Groth16 verification key, set in genesis
  • ActiveProposalCount: Map<AccountId, u32> — per-author cap (MaxActiveProposals); the only spam mitigation for create_proposal since the bare-call form holds no balance deposit
  • EligibleCount: Map<ProposalId, u32> — voter denominator captured at proposal creation (excludes the author's home)
  • EarliestOpenProposalBlock: Timestamp — fast-path filter for the auto-close hook

Types

enum VoteDirection { Yes, No, Abstain }
enum ProposalStatus { Open, Passed, Rejected, Withdrawn }
enum ProposalScope { All, Block(BlockId) }
enum FundingSource<T> { None, ReserveFund, SpecialAssessment, Other(T) }

Extrinsics

  • create_proposal(author, title, description_cid, image_cid, deadline, scope, funding_source, estimated_cost_usd)unsigned, authorised via the CheckCreateProposal extension (see below). The author: AccountId parameter 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_usd is required unless funding_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. Decrements ActiveProposalCount.
  • cast_vote(proposal_id, vote, nullifier, zk_proof, pow_nonce, merkle_root)unsigned. Verifies: merkle_root is 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 = true stores author = None; the comment is otherwise indistinguishable. Capped per proposal by MaxCommentsPerProposal.
  • 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 to Passed/Rejected against the declining-quorum threshold and the simple majority. Early close is allowed if (a) caller is committee, (b) min_voting_end has passed, (c) the current quorum threshold is met. Decrements ActiveProposalCount.
  • 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 in pallet-membership-gov.

Events

ProposalCreated, ProposalWithdrawn, ProposalClosed { proposal_id, status }, VoteCast, CommentAdded { proposal_id, index, anonymous }, CommentFlagged, CommitmentRegistered, CommitmentRotated, CommitmentRemoved, HomeDevicesReset.

Hook: on_initialize auto-close

On every block:

  1. Fast path. If EarliestOpenProposalBlock shows that no open proposal could yet have expired, return after one read.
  2. 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, decrement ActiveProposalCount, emit ProposalClosed.
  3. Update EarliestOpenProposalBlock so future blocks can use the fast path again.

Cross-pallet wiring

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.

Genesis

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.

Benchmarking and constraints

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

Out-of-scope vs. earlier spec

  • 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: bool parameter on comment; rate-limiting per home is not currently enforced on-chain (relies on the per-proposal MaxCommentsPerProposal cap and the per-author active-proposal cap).

Dependencies

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