feat(pallet-communities): private communities with merkle membership proofs - #74
Open
olanod wants to merge 9 commits into
Open
feat(pallet-communities): private communities with merkle membership proofs#74olanod wants to merge 9 commits into
olanod wants to merge 9 commits into
Conversation
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
self-requested a review
April 21, 2026 18:47
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
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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
MemberMgmttrait (pallet-nfts backed) with built-inMembersstoragePrivacyLevel(Public/Private/Hybrid),MemberRecord,MemberStatus,CommunityBudgetMerkleRoot,SubRoots,MemberCount,RanksTotal,Members,BudgetMembershipId/MemberMgmt, addedHasher/MaxMembersvote()no longer takesmembership_id— uses account-based lookupCommit 2: Member management with roles
Roleenum (Admin/Manager/Member) inMemberRecordadd_memberaccepts optional rank and rolesuspend_member,update_membership_root,update_sub_rootadd_member, public rejectupdate_membership_rootCommit 3: Community-level gas budget
set_budgetextrinsic with capacity and session lengthcheck_budget/burn_budget/refund_budgetpublic helpers for runtime gas payment integrationCommit 4: Anonymous membership transaction extension
AnonymousMembership<T>tx extension verifies merkle inclusion proofsCommunityMemberorigin with rank as public inputSubset::AnonymousMemberorigin variantCommit 5: Private voting
vote()dual-path: named (signed) and anonymous (community origin)Design decisions
hash(who || community_id || rank || nonce)— nonce invalidates old proofs on changeshash(identity_secret, action_scope)— deterministic per member per actionTest coverage
24 tests covering:
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