Skip to content

feat(pallet-communities): private communities with merkle membership proofs - #74

Open
olanod wants to merge 9 commits into
mainfrom
feat/private-communities
Open

feat(pallet-communities): private communities with merkle membership proofs#74
olanod wants to merge 9 commits into
mainfrom
feat/private-communities

Conversation

@olanod

@olanod olanod commented Apr 21, 2026

Copy link
Copy Markdown
Member

Summary

Rework pallet-communities to support privacy-preserving communities. Replaces NFT-based memberships with built-in storage and merkle tree commitments for anonymous membership proofs.

Closes virto-network/kreivo#474

Changes

Commit 1: New storage model

  • Replace external MemberMgmt trait (pallet-nfts backed) with built-in Members storage
  • New types: PrivacyLevel (Public/Private/Hybrid), MemberRecord, MemberStatus, CommunityBudget
  • New storage: MerkleRoot, SubRoots, MemberCount, RanksTotal, Members, Budget
  • Config: removed MembershipId/MemberMgmt, added Hasher/MaxMembers
  • vote() no longer takes membership_id — uses account-based lookup

Commit 2: Member management with roles

  • Role enum (Admin/Manager/Member) in MemberRecord
  • add_member accepts optional rank and role
  • New extrinsics: suspend_member, update_membership_root, update_sub_root
  • Automatic merkle root recomputation for public communities on every member change
  • Privacy enforcement: private communities reject add_member, public reject update_membership_root

Commit 3: Community-level gas budget

  • set_budget extrinsic with capacity and session length
  • check_budget/burn_budget/refund_budget public helpers for runtime gas payment integration
  • Session-based auto-renewal when period expires

Commit 4: Anonymous membership transaction extension

  • AnonymousMembership<T> tx extension verifies merkle inclusion proofs
  • Nullifier tracking prevents double-actions (e.g. double-voting)
  • Produces anonymous CommunityMember origin with rank as public input
  • New Subset::AnonymousMember origin variant

Commit 5: Private voting

  • vote() dual-path: named (signed) and anonymous (community origin)
  • Hash-based voter keys work for both paths
  • Rank from proof's public inputs used as vote multiplier
  • Token-weighted voting rejected for anonymous origins
  • Anonymous votes cannot be removed
  • Public tally preserved

Design decisions

  • MVP without ZK: Simple merkle inclusion proofs with nullifier scheme. Designed for easy upgrade to ZK (stwo stark verifier coming)
  • Leaf structure: hash(who || community_id || rank || nonce) — nonce invalidates old proofs on changes
  • Nullifier: hash(identity_secret, action_scope) — deterministic per member per action
  • Gas model: Community-level budget (like org-level usage), not per-member
  • Anonymous + pallet-pass: Mutually exclusive — anonymous proofs are the sole authentication

Test coverage

24 tests covering:

  • Member add/remove/suspend lifecycle
  • Role-based permissions
  • Merkle root computation and verification
  • Privacy level enforcement
  • Budget set/check/burn/session-reset/exhaustion
  • Nullifier replay prevention
  • Named and anonymous voting paths
  • Rank-weighted anonymous voting
  • Token-weighted voting rejection for anonymous

Migration

This is a breaking change. Kreivo runtime will need storage migrations from NFT-based memberships to the new model (tracked separately).

Follow-up work

  • Re-enable benchmarking module with new extrinsics
  • CommunityId u16→u32 migration (kreivo)
  • Remove communities-manager pallet (kreivo)
  • ZK proof verification (replace merkle inclusion with stark proofs)
  • Encrypted tally for fully private voting (homomorphic encryption)
  • Vote multiplier calculator integration (kreivo#476)

olanod added 5 commits April 21, 2026 14:44
Replace the external MemberMgmt trait (backed by pallet-nfts) with
built-in membership storage inside the pallet.

New storage model:
- Members: (CommunityId, AccountId) → MemberRecord (rank, nonce, status)
- MemberCount, RanksTotal: per-community counters
- MerkleRoot, SubRoots: for future membership proof verification
- Budget: per-community gas/transaction budget

New types:
- PrivacyLevel (Public/Private/Hybrid)
- MemberRecord, MemberStatus
- CommunityBudget

Config changes:
- Removed: MembershipId, MemberMgmt
- Added: Hasher, MaxMembers

Breaking changes:
- vote() no longer takes membership_id
- add_member/remove_member/promote/demote use AccountId instead of MembershipId
- CommunityVotes keyed by AccountId instead of MembershipId
- CommunityInfo now includes privacy and capacity fields

Tests and benchmarks temporarily disabled, will be re-enabled in
subsequent commits.
- Add Role enum (Admin/Manager/Member) to MemberRecord
- add_member now accepts optional rank and role parameters
- New extrinsics: suspend_member, update_membership_root, update_sub_root
- Automatic merkle root recomputation for public communities on every
  member change (add/remove/suspend/promote/demote)
- Private communities require manual root updates via update_membership_root
- Public communities reject update_membership_root calls
- Re-enable mock and tests with 12 passing tests covering member
  management, roles, merkle computation, and privacy level enforcement
Add per-community transaction budget with session-based renewal:
- set_budget extrinsic for admins to configure capacity and session length
- check_budget/burn_budget/refund_budget public helpers for runtime
  integration with gas payment systems
- Budget auto-resets when session expires
- 4 new tests covering set, check/burn, session reset, and exhaustion
New AnonymousMembership<T> transaction extension that:
- Verifies merkle inclusion proofs against community MerkleRoot or SubRoots
- Checks nullifier hasn't been used (prevents double-actions like double-voting)
- Replaces the transaction origin with an anonymous CommunityMember origin
  carrying rank and nullifier as public inputs
- Stores used nullifiers in post_dispatch to prevent replay regardless
  of dispatch success/failure

New Subset variant: AnonymousMember { rank, nullifier } for anonymous
community origins that can't be linked to a specific account.

New storage: UsedNullifiers NMap (CommunityId, action_scope, nullifier)

2 new tests covering proof validation and nullifier replay prevention.
vote() now supports both named (signed) and anonymous (community origin)
callers:

Named path: signed origin → hash(account) as voter key → fund locking
Anonymous path: CommunityMember origin from tx extension → hash(nullifier)
  as voter key → no fund locking, rank from proof's public inputs

Key changes:
- CommunityVotes keyed by hash-based voter key (works for both paths)
- Stores multiplied_weight alongside vote for correct removal
- Token-weighted voting (NativeToken/CommunityAsset) rejected for
  anonymous origins (can't lock funds anonymously)
- Anonymous votes cannot be removed (remove_vote is signed-only)
- Duplicate anonymous votes (same nullifier) rejected
- VoteCasted event uses Option<AccountId> (None for anonymous)

6 new tests covering named voting, anonymous voting with rank,
token-weighted rejection, nullifier dedup, and multi-voter tally.
@pandres95
pandres95 self-requested a review April 21, 2026 18:47
olanod added 4 commits April 22, 2026 10:19
New fc-traits-proof-verifier crate with a general-purpose ProofVerifier
trait for abstracting proof verification backends (merkle, ZK, etc.).

- ProofVerifier trait: verify(program, proof, public_inputs) → Result
- MerkleVerifier<H>: default implementation using binary-merkle-tree
- MembershipInputs<H>: public inputs type containing the merkle root

Pallet-communities now uses Config::MembershipVerifier instead of
hardcoded binary_merkle_tree calls. The extension's proof type is
generic over the verifier — swap MerkleVerifier for a ZK verifier
(e.g. stwo STARK) without changing the pallet code.
…ge escalation

The merkle-only MVP in PR #74 had four critical security issues that together
allowed any holder of a valid membership proof to impersonate the community
(incl. draining the treasury via dispatch_as_account) and vote arbitrarily
many times under anonymous origins.

Fixes:

C1. Reject AnonymousMember subset in admin/member-mgmt origin guards.
    EnsureCommunity, AsSignedByCommunity, AsSignedByStaticCommunity now all
    refuse Subset::AnonymousMember. Added EnsureAnonymousVoter as the sole
    guard that accepts it, used by vote() only.

C2. Drop user-provided rank from the anonymous origin. Without a ZK binding,
    rank is not verified against the leaf, so anonymous votes are forced to
    rank-1 regardless of what the proof claims. Rank-weighted decisions are
    rejected on the anonymous path.

C3. Drop user-provided nullifier. The extension now derives it on-chain from
    (community_id, action_scope, sub_track, root, proof_bytes) with a domain
    tag — callers cannot pick fresh random nullifiers to bypass replay
    detection.

C4. Drop user-provided action_scope. It is now derived from the dispatched
    call so that a proof authenticated for one call cannot be re-used for a
    different call (same or different poll).

Collateral hardening:
- H4: support() and approval() in impls.rs now guard the denominator with
  1.max(..) and use saturating_add to prevent divide-by-zero panics and
  tally overflow.
- H5: try_vote_by_key uses saturating_mul for vote_multiplier × vote_weight.
- L1: verifier iterates siblings via .iter().copied() (Copy Output) rather
  than cloning the whole Vec.
- Extension error surface: InvalidTransaction::BadSigner replaced with
  documented Custom codes (100: NO_MEMBERSHIP_ROOT, 101: INVALID_PROOF,
  102: NULLIFIER_USED). IDENTIFIER namespaced to
  "fc_pallet_communities::AnonymousMembership" to prevent collisions.
- set_budget: reject session_length=0 (would reset the budget every check).
- Errors: add AnonymousVoteAlreadyCast (replaces misleading AlreadyOngoing
  for duplicate anonymous votes) and InvalidBudget.

Adversarial tests added (security regressions must not be allowed to slip
back in silently):
- test_c1_anonymous_origin_cannot_manage_members
- test_c1_anonymous_origin_cannot_call_admin_functions
- test_c1_anonymous_origin_cannot_dispatch_as_account
- test_anonymous_vote_rejects_rank_weighted_decision
- test_anonymous_vote_rank_from_origin_is_ignored
- test_c4_action_scope_derived_from_call
…kle root

Two related hardenings for Private/Hybrid communities.

H3. update_membership_root no longer writes to MemberCount. The admin-declared
    number lands in a new ClaimedSupport storage map, used only as the
    denominator for DecisionMethod::Membership support on Private/Hybrid
    communities. MemberCount remains the tamper-proof on-chain count for
    Public communities, and cannot be manipulated via root updates to move
    referendum thresholds.

    Added membership_denominator() helper in types.rs that picks the right
    source by privacy level — falls back to MemberCount when ClaimedSupport
    is unset so freshly-bootstrapped communities don't render polls
    unreachable.

M2. recompute_merkle_root for Private/Hybrid communities now CLEARS the
    stored root instead of being a silent no-op. Rationale: a suspended
    member's old proof would otherwise remain valid against a stale on-chain
    root until the admin noticed and republished. Fail-closed means
    anonymous proofs return NO_MEMBERSHIP_ROOT until the admin republishes
    — safe default.

Tests:
- test_private_community_root_update updated to assert the H3 invariant
  (MemberCount stays 0, ClaimedSupport gets the declared value).
- test_m2_suspend_clears_root_on_private_community: new.
M3. Enforce the previously-cosmetic Role field. Added is_member_manager()
    (checks Active status + Admin/Manager role) and ensure_member_mgmt(),
    which is now used by every member-mgmt extrinsic: add/remove/promote/
    demote/suspend_member and dispatch_as_account. Signed callers must hold
    Admin or Manager role to invoke these; community-origin/root/governance
    callers still pass through unchanged so bootstrapping and governance
    dispatches keep working.

    New error: NotAuthorized.

M8. Add prune_vote permissionless extrinsic for removing CommunityVotes
    entries after a poll has ended. Addresses storage bloat, including for
    anonymous votes that have no signed owner to clean up after themselves.

M9. Add remove_sub_root admin extrinsic so accumulated sub-roots don't
    become permanent storage bloat.

Polish:
- Fix lifecycle docstring (removed phantom "Pending" state, which never
  existed in the CommunityState enum).
- capacity=0 no longer silently falls back to T::MaxMembers. If an admin
  explicitly sets 0 the community is at capacity — while still enforcing
  the T::MaxMembers hard ceiling.
- Remove unused WEIGHT_REF_TIME_PER_NANOS import and dead MAX_BLOCK_WEIGHT
  const from mock.rs.

Tests:
- test_m3_member_role_enforced_on_signed_caller
- test_m3_manager_role_can_manage_members
- test_m8_prune_vote_removes_stale_entry
- test_m8_prune_vote_rejected_while_poll_ongoing
- test_m9_remove_sub_root
- test_set_budget_rejects_zero_session_length
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Private communities with ZK membership proofs

1 participant