diff --git a/Cargo.lock b/Cargo.lock index e3b7d4c..d6f3efd 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1142,7 +1142,9 @@ dependencies = [ name = "fc-pallet-communities" version = "1.0.0" dependencies = [ + "binary-merkle-tree", "fc-pallet-referenda-tracks", + "fc-traits-proof-verifier", "frame-benchmarking", "frame-contrib-traits", "frame-support", @@ -1375,6 +1377,16 @@ dependencies = [ "scale-info", ] +[[package]] +name = "fc-traits-proof-verifier" +version = "0.1.0" +dependencies = [ + "frame-support", + "parity-scale-codec", + "scale-info", + "sp-runtime", +] + [[package]] name = "ff" version = "0.13.1" @@ -1481,6 +1493,7 @@ dependencies = [ "fc-traits-listings", "fc-traits-memberships", "fc-traits-payments", + "fc-traits-proof-verifier", ] [[package]] diff --git a/Cargo.toml b/Cargo.toml index 820ec14..9523855 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -27,6 +27,7 @@ fc-traits-listings = { path = "./traits/listings", default-features = false } fc-traits-memberships = { path = "./traits/memberships", default-features = false } fc-traits-nonfungibles-helpers = { path = "./traits/nonfungibles-helpers", default-features = false } fc-traits-payments = { path = "./traits/payments", default-features = false } +fc-traits-proof-verifier = { path = "./traits/proof-verifier", default-features = false } fc-pallet-listings = { path = "./pallets/listings", default-features = false } fc-pallet-payments = { path = "./pallets/payments", default-features = false } fc-pallet-referenda-tracks = { path = "./pallets/referenda-tracks", default-features = false } @@ -67,4 +68,5 @@ members = [ "traits/memberships", "traits/nonfungibles-helpers", "traits/payments", + "traits/proof-verifier", ] diff --git a/pallets/communities/Cargo.toml b/pallets/communities/Cargo.toml index ba8a2d3..6b2e206 100644 --- a/pallets/communities/Cargo.toml +++ b/pallets/communities/Cargo.toml @@ -11,14 +11,18 @@ version = "1.0.0" targets = ["x86_64-unknown-linux-gnu"] [dependencies] +binary-merkle-tree = { version = "16.1.1", default-features = false } codec = { workspace = true, features = ["derive"] } frame-benchmarking = { workspace = true, optional = true } +fc-traits-proof-verifier.workspace = true frame-contrib-traits.workspace = true frame-support.workspace = true frame-system.workspace = true log.workspace = true scale-info = { workspace = true, features = ["derive"] } serde = { optional = true, features = ["alloc", "derive"], workspace = true } +sp-core.workspace = true +sp-io.workspace = true sp-runtime.workspace = true xcm = { workspace = true, optional = true } @@ -38,6 +42,8 @@ pallet-scheduler.workspace = true default = ["std", "xcm", "serde"] serde = ["dep:serde", "scale-info/serde"] std = [ + "binary-merkle-tree/std", + "fc-traits-proof-verifier/std", "fc-pallet-referenda-tracks/std", "frame-benchmarking?/std", "frame-contrib-traits/std", diff --git a/pallets/communities/src/extensions.rs b/pallets/communities/src/extensions.rs new file mode 100644 index 0000000..5353491 --- /dev/null +++ b/pallets/communities/src/extensions.rs @@ -0,0 +1,204 @@ +extern crate alloc; + +use crate::{ + origin::RawOrigin as CommunityOrigin, verifier::MembershipInputs, Config, MerkleRoot, SubRoots, + UsedNullifiers, +}; +use codec::{Decode, DecodeWithMemTracking, Encode}; +use fc_traits_proof_verifier::ProofVerifier; +use frame_support::{ + pallet_prelude::{TransactionValidityError, Weight}, + CloneNoBound, DebugNoBound, DefaultNoBound, EqNoBound, PartialEqNoBound, +}; +use frame_system::pallet_prelude::RuntimeCallFor; +use scale_info::TypeInfo; +use sp_core::H256; +use sp_runtime::{ + traits::{ + DispatchInfoOf, DispatchOriginOf, Implication, PostDispatchInfoOf, TransactionExtension, + ValidateResult, + }, + transaction_validity::{InvalidTransaction, TransactionSource, ValidTransaction}, + DispatchResult, +}; + +/// Custom transaction validity codes for this extension. +pub mod codes { + /// The community has no membership root to verify against. + pub const NO_MEMBERSHIP_ROOT: u8 = 100; + /// The proof did not verify against the membership root. + pub const INVALID_MEMBERSHIP_PROOF: u8 = 101; + /// The nullifier has already been consumed for this action scope. + pub const NULLIFIER_USED: u8 = 102; +} + +/// Transaction extension that authenticates anonymous community members. +/// +/// When `Some(params)`, it verifies a merkle inclusion proof against the community's +/// root and replaces the origin with an anonymous community origin. The nullifier +/// stored in `UsedNullifiers` is **derived deterministically** from the proof leaf +/// and the call hash, so a single leaf cannot authorize more than one action per +/// call scope. Callers cannot pick their own nullifier or rank — both are derived +/// on-chain. +/// +/// When `None`, it acts as a passthrough. +/// +/// ### Soundness limits of the merkle-only MVP +/// +/// The verifier only proves leaf membership, not leaf *contents*. Therefore: +/// - The pallet cannot trust any claimed rank carried in the proof; anonymous votes +/// are rank-1 regardless of the leaf's actual rank. +/// - Anonymity is pseudonymity: the leaf is public, so on-chain observers can link a +/// vote to a member. Real privacy requires the ZK verifier backend. +/// +/// These limits are lifted once `MembershipVerifier` is replaced with a ZK proof +/// verifier that binds private inputs (rank, nullifier seed) to the leaf. +#[derive( + DefaultNoBound, + Encode, + Decode, + DecodeWithMemTracking, + CloneNoBound, + EqNoBound, + PartialEqNoBound, + DebugNoBound, + TypeInfo, +)] +#[scale_info(skip_type_params(T))] +pub struct AnonymousMembership(pub Option>); + +#[derive( + Encode, + Decode, + DecodeWithMemTracking, + CloneNoBound, + EqNoBound, + PartialEqNoBound, + DebugNoBound, + TypeInfo, +)] +#[scale_info(skip_type_params(T))] +pub struct MembershipProofParams { + pub community_id: T::CommunityId, + /// The proof in whatever format the verifier expects + pub proof: ::Proof, + /// Optional sub-track to verify against SubRoots instead of MerkleRoot + pub sub_track: Option, +} + +impl TransactionExtension> for AnonymousMembership +where + T::RuntimeOrigin: From>, + T::CommunityId: Send + Sync, + ::Output: Send + Sync, + ::Proof: Send + Sync, +{ + const IDENTIFIER: &'static str = "fc_pallet_communities::AnonymousMembership"; + type Implicit = (); + /// (community_id, action_scope, nullifier) when authenticated. Used at dispatch and + /// post-dispatch time. All three are derived server-side — never trusted from the caller. + type Val = Option<(T::CommunityId, H256, H256)>; + type Pre = Option<(T::CommunityId, H256, H256)>; + + fn weight(&self, _call: &RuntimeCallFor) -> Weight { + // Placeholder until benchmarks return. Scales with proof size in a real backend. + Weight::from_parts(50_000_000, 0) + } + + fn validate( + &self, + origin: DispatchOriginOf>, + call: &RuntimeCallFor, + _info: &DispatchInfoOf>, + _len: usize, + _self_implicit: Self::Implicit, + _inherited_implication: &impl Implication, + _source: TransactionSource, + ) -> ValidateResult> { + let Some(params) = &self.0 else { + return Ok((ValidTransaction::default(), None, origin)); + }; + + let root = if let Some(sub_track) = params.sub_track { + SubRoots::::get(¶ms.community_id, sub_track) + } else { + MerkleRoot::::get(¶ms.community_id) + } + .ok_or(TransactionValidityError::from(InvalidTransaction::Custom( + codes::NO_MEMBERSHIP_ROOT, + )))?; + + let public_inputs = MembershipInputs { root }; + T::MembershipVerifier::verify(&(), ¶ms.proof, &public_inputs).map_err(|_| { + TransactionValidityError::from(InvalidTransaction::Custom( + codes::INVALID_MEMBERSHIP_PROOF, + )) + })?; + + // Action scope is derived from the call being dispatched — the caller cannot + // choose it, so a proof authenticated for one call cannot be re-used for another. + let action_scope: H256 = sp_io::hashing::blake2_256(&call.encode()).into(); + + // Nullifier is derived from the verified proof leaf and the action scope. Two + // consequences: (a) the caller cannot pick fresh random nullifiers to bypass the + // replay check, and (b) the same leaf produces the same nullifier for the same + // call, so duplicates collide in `UsedNullifiers`. + let nullifier_preimage = ( + b"fc-communities/null/v1", + ¶ms.community_id, + action_scope.as_bytes(), + params.sub_track, + root.encode(), + params.proof.encode(), + ) + .encode(); + let nullifier: H256 = sp_io::hashing::blake2_256(&nullifier_preimage).into(); + + if UsedNullifiers::::contains_key((¶ms.community_id, &action_scope, &nullifier)) { + return Err(TransactionValidityError::from(InvalidTransaction::Custom( + codes::NULLIFIER_USED, + ))); + } + + let mut community_origin = CommunityOrigin::new(params.community_id); + community_origin.with_subset(crate::origin::Subset::AnonymousMember { + // Rank cannot be trusted without a ZK binding — treat the anonymous vote as + // rank-1 (membership-only) regardless of the leaf's on-chain rank. + rank: frame_contrib_traits::memberships::GenericRank::default(), + nullifier, + }); + let new_origin: T::RuntimeOrigin = community_origin.into(); + + Ok(( + ValidTransaction::default(), + Some((params.community_id, action_scope, nullifier)), + new_origin, + )) + } + + fn prepare( + self, + val: Self::Val, + _origin: &DispatchOriginOf>, + _call: &RuntimeCallFor, + _info: &DispatchInfoOf>, + _len: usize, + ) -> Result { + Ok(val) + } + + fn post_dispatch_details( + pre: Self::Pre, + _info: &DispatchInfoOf>, + _post_info: &PostDispatchInfoOf>, + _len: usize, + _result: &DispatchResult, + ) -> Result { + // Store the nullifier whether dispatch succeeded or failed — otherwise a caller + // could griefing-loop by crafting failing calls that never consume the nullifier. + if let Some((community_id, action_scope, nullifier)) = pre { + UsedNullifiers::::insert((&community_id, &action_scope, &nullifier), ()); + } + Ok(Weight::zero()) + } +} diff --git a/pallets/communities/src/functions.rs b/pallets/communities/src/functions.rs index 7db34b0..8609a08 100644 --- a/pallets/communities/src/functions.rs +++ b/pallets/communities/src/functions.rs @@ -1,6 +1,6 @@ use super::*; -use frame_contrib_traits::memberships::{GenericRank, Inspect, Rank}; +use frame_contrib_traits::memberships::GenericRank; use frame_support::{ fail, traits::{ @@ -11,6 +11,7 @@ use frame_support::{ }, }; use sp_runtime::traits::{AccountIdConversion, Dispatchable}; +use sp_runtime::Saturating; impl Pallet { #[inline] @@ -23,20 +24,57 @@ impl Pallet { } pub fn is_member(community_id: &T::CommunityId, who: &AccountIdOf) -> bool { - T::MemberMgmt::is_member_of(community_id, who) + Members::::get(community_id, who) + .map(|m| m.status == MemberStatus::Active) + .unwrap_or(false) } - pub fn member_rank(community_id: &T::CommunityId, m: &MembershipIdOf) -> GenericRank { - T::MemberMgmt::rank_of(community_id, m).unwrap_or_default() + pub fn member_rank(community_id: &T::CommunityId, who: &AccountIdOf) -> GenericRank { + Members::::get(community_id, who) + .map(|m| m.rank) + .unwrap_or_default() } - pub fn get_memberships( - community_id: T::CommunityId, - who: &AccountIdOf, - ) -> Vec> { - T::MemberMgmt::user_memberships(who, Some(community_id)) - .map(|(_, m)| m) - .collect::>() + /// Whether `who` can act as a manager of the community: Admin and Manager roles, + /// active status only. Used to gate member-management extrinsics when invoked by + /// a signed origin that's also a community member. + pub fn is_member_manager(community_id: &T::CommunityId, who: &AccountIdOf) -> bool { + match Members::::get(community_id, who) { + Some(rec) => { + rec.status == MemberStatus::Active + && matches!(rec.role, Role::Admin | Role::Manager) + } + None => false, + } + } + + /// Resolve the community id for a member-mgmt action, and enforce that if the + /// caller is a *signed* account (rather than a community/root/governance origin), + /// that account holds the Admin or Manager role in the target community. + /// + /// This is the authorization layer that makes `Role` meaningful. Without it, + /// anyone who satisfies `MemberMgmtOrigin` (often `EnsureCommunity`, which accepts + /// the community origin or accounts registered via `CommunityIdFor`) could take + /// member-management actions regardless of role. + pub(crate) fn ensure_member_mgmt( + origin: OriginFor, + ) -> Result, DispatchError> { + // Pull out the signed caller (if any) before handing the origin to the + // configured guard — the guard consumes it. + let maybe_signer = origin + .as_system_ref() + .and_then(|s| match s { + frame_system::RawOrigin::Signed(who) => Some(who.clone()), + _ => None, + }); + let community_id = T::MemberMgmtOrigin::ensure_origin(origin)?; + if let Some(signer) = maybe_signer { + ensure!( + Self::is_member_manager(&community_id, &signer), + Error::::NotAuthorized, + ); + } + Ok(community_id) } pub fn force_state(community_id: &CommunityIdOf, state: CommunityState) { @@ -70,17 +108,24 @@ impl Pallet { } CommunityIdFor::::insert(admin, community_id); - Info::::insert(community_id, CommunityInfo::default()); + Info::::insert( + community_id, + CommunityInfo { + state: CommunityState::default(), + privacy: PrivacyLevel::default(), + capacity: T::MaxMembers::get(), + }, + ); frame_system::Pallet::::inc_providers(&Self::community_account(community_id)); Ok(()) } - pub(crate) fn try_vote( + pub(crate) fn try_vote_by_key( community_id: &CommunityIdOf, decision_method: &DecisionMethodFor, - who: &AccountIdOf, - membership_id: &MembershipIdOf, + vote_multiplier: u32, + voter_key: &::Output, poll_index: PollIndexOf, vote: &VoteOf, ) -> DispatchResult { @@ -88,13 +133,6 @@ impl Pallet { let (tally, class) = poll_status.ensure_ongoing().ok_or(Error::::NotOngoing)?; ensure!(community_id == &class, Error::::InvalidTrack); - let vote_multiplier = match CommunityDecisionMethod::::get(community_id) { - DecisionMethod::Rank => T::MemberMgmt::rank_of(community_id, membership_id) - .unwrap_or_default() - .into(), - _ => 1, - }; - let say = *match (vote, decision_method) { ( Vote::AssetBalance(say, asset, amount), @@ -109,37 +147,31 @@ impl Pallet { }; let vote_weight = VoteWeight::from(vote); - tally.add_vote(say, vote_multiplier * vote_weight, vote_weight); + let multiplied = vote_multiplier.saturating_mul(vote_weight); + tally.add_vote(say, multiplied, vote_weight); - CommunityVotes::::insert(poll_index, membership_id, (vote, who)); - Self::update_locks(who, poll_index, vote, LockUpdateType::Add) + CommunityVotes::::insert(poll_index, voter_key, (vote, multiplied)); + Ok(()) }) } - pub(crate) fn try_remove_vote( + pub(crate) fn try_remove_vote_by_key( community_id: &CommunityIdOf, - decision_method: &DecisionMethodFor, - membership_id: &MembershipIdOf, + voter_key: &::Output, poll_index: PollIndexOf, ) -> DispatchResult { T::Polls::try_access_poll(poll_index, |poll_status| { let (tally, class) = poll_status.ensure_ongoing().ok_or(Error::::NotOngoing)?; ensure!(community_id == &class, Error::::InvalidTrack); - let (vote, voter) = CommunityVotes::::get(poll_index, membership_id) + let (vote, multiplied) = CommunityVotes::::get(poll_index, voter_key) .ok_or(Error::::NoVoteCasted)?; - let vote_multiplier = match decision_method { - DecisionMethod::Rank => T::MemberMgmt::rank_of(community_id, membership_id) - .unwrap_or_default() - .into(), - _ => 1, - }; let vote_weight = VoteWeight::from(&vote); - tally.remove_vote(vote.say(), vote_multiplier * vote_weight, vote_weight); + tally.remove_vote(vote.say(), multiplied, vote_weight); - CommunityVotes::::remove(poll_index, membership_id); - Self::update_locks(&voter, poll_index, &vote, LockUpdateType::Remove) + CommunityVotes::::remove(poll_index, voter_key); + Ok(()) }) } @@ -224,6 +256,91 @@ impl Pallet { .map(|_| ()) .map_err(|e| e.error) } + + /// Check if community has enough budget for the given cost. + /// Resets session if expired. Returns remaining capacity after deduction. + pub fn check_budget(community_id: &CommunityIdOf, cost: u64) -> Result> { + let mut budget = Budget::::get(community_id).ok_or(Error::::BudgetExhausted)?; + let now = T::BlockNumberProvider::current_block_number(); + + // Reset session if expired + if now >= budget.session_start.saturating_add(budget.session_length) { + budget.used = 0; + budget.session_start = now; + } + + let remaining = budget.capacity.saturating_sub(budget.used); + if remaining < cost { + return Err(Error::::BudgetExhausted); + } + Ok(remaining.saturating_sub(cost)) + } + + /// Burn gas from community budget. Resets session if expired. + pub fn burn_budget(community_id: &CommunityIdOf, cost: u64) { + Budget::::mutate(community_id, |maybe_budget| { + if let Some(budget) = maybe_budget { + let now = T::BlockNumberProvider::current_block_number(); + if now >= budget.session_start.saturating_add(budget.session_length) { + budget.used = 0; + budget.session_start = now; + } + budget.used = budget.used.saturating_add(cost); + } + }); + } + + /// Refund gas back to community budget. + pub fn refund_budget(community_id: &CommunityIdOf, amount: u64) { + Budget::::mutate(community_id, |maybe_budget| { + if let Some(budget) = maybe_budget { + budget.used = budget.used.saturating_sub(amount); + } + }); + } + + /// Keep the stored merkle root consistent after a membership change. + /// + /// Behaviour by privacy level: + /// - **Public**: rebuild the root from on-chain members (authoritative source). + /// - **Hybrid**: clear the root. Hybrid communities mix on-chain and off-chain + /// members, so an on-chain change alone cannot produce a valid tree — the admin + /// must republish via `update_membership_root`. Clearing the root is the safe + /// default: in-flight anonymous proofs would otherwise remain valid against an + /// already-stale tree (e.g. a suspended member could continue to anonymously + /// vote until the admin bothered to update). + /// - **Private**: clear the root for the same reason as Hybrid. Since all + /// membership is off-chain, we can't rebuild, but we can *refuse* anonymous + /// actions until the admin republishes. + /// + /// Clearing `MerkleRoot` causes the extension to reject proofs with + /// `NO_MEMBERSHIP_ROOT`, which is the desired fail-closed behaviour. + pub fn recompute_merkle_root(community_id: &CommunityIdOf) { + match Info::::get(community_id).map(|i| i.privacy) { + Some(PrivacyLevel::Public) => { + let mut leaves: alloc::vec::Vec<::Output> = + Members::::iter_prefix(community_id) + .filter(|(_, record)| record.status == MemberStatus::Active) + .map(|(who, record)| { + T::Hasher::hash_of(&(who, community_id, record.rank, record.nonce)) + }) + .collect(); + leaves.sort(); + if leaves.is_empty() { + MerkleRoot::::remove(community_id); + } else { + let root = binary_merkle_tree::merkle_root::(leaves); + MerkleRoot::::insert(community_id, root); + } + } + Some(PrivacyLevel::Private) | Some(PrivacyLevel::Hybrid) => { + // Fail-closed: an on-chain suspend/remove may have invalidated the tree. + // The admin must republish with `update_membership_root`. + MerkleRoot::::remove(community_id); + } + None => {} + } + } } impl Tally { diff --git a/pallets/communities/src/impls.rs b/pallets/communities/src/impls.rs index 9e996ae..5db0a52 100644 --- a/pallets/communities/src/impls.rs +++ b/pallets/communities/src/impls.rs @@ -16,11 +16,14 @@ impl VoteTally> for Tally { } fn support(&self, community_id: CommunityIdOf) -> sp_runtime::Perbill { - Perbill::from_rational(self.bare_ayes, Self::max_support(community_id)) + // `1.max(..)` guards against empty/uninitialized communities: without it + // `Perbill::from_rational(any_votes, 0)` clamps to 100% and any poll trivially + // passes the support threshold. + Perbill::from_rational(self.bare_ayes, 1.max(Self::max_support(community_id))) } fn approval(&self, _cid: CommunityIdOf) -> sp_runtime::Perbill { - Perbill::from_rational(self.ayes, 1.max(self.ayes + self.nays)) + Perbill::from_rational(self.ayes, 1.max(self.ayes.saturating_add(self.nays))) } #[cfg(feature = "runtime-benchmarks")] diff --git a/pallets/communities/src/lib.rs b/pallets/communities/src/lib.rs index 78e16cc..40cc9f0 100644 --- a/pallets/communities/src/lib.rs +++ b/pallets/communities/src/lib.rs @@ -43,14 +43,12 @@ //! ## Lifecycle //! //! ```ignore -//! [ ] --> [Pending] --> [Active] --> [Blocked] -//! create set_metadata set_metadata unblock -//! block -//! add_member -//! remove_member -//! promote -//! demote -//! set_voting_mechanism +//! [ ] --> [Active] <-----> [Blocked] +//! create add_member block/unblock +//! remove_member +//! promote +//! demote +//! set_decision_method //! ``` //! //! ## Implementations @@ -114,20 +112,22 @@ extern crate alloc; -use alloc::{boxed::Box, vec::Vec}; +use alloc::boxed::Box; use core::num::NonZeroU8; -use frame_contrib_traits::memberships::{self as membership, Inspect, Manager, Rank}; +use frame_contrib_traits::memberships::GenericRank; use frame_support::{ pallet_prelude::*, traits::{fungible, fungibles, EnsureOrigin, OriginTrait, Polling}, Blake2_128Concat, Parameter, }; use frame_system::pallet_prelude::{ensure_signed, OriginFor}; +use sp_core::H256; use sp_runtime::traits::AccountIdConversion; -use sp_runtime::traits::{BlockNumberProvider, StaticLookup}; +use sp_runtime::traits::{BlockNumberProvider, Hash as _, StaticLookup}; -#[cfg(feature = "runtime-benchmarks")] -mod benchmarking; +// TODO: Re-enable after storage model rework +// #[cfg(feature = "runtime-benchmarks")] +// mod benchmarking; #[cfg(test)] mod mock; @@ -147,6 +147,9 @@ pub use weights::*; pub mod origin; +pub mod extensions; +pub mod verifier; + #[frame_support::pallet] pub mod pallet { use super::*; @@ -163,7 +166,8 @@ pub mod pallet { frame_system::Config< RuntimeEvent: From>, RuntimeCall: From>, - RuntimeOrigin: From>, + RuntimeOrigin: From> + + Into, ::RuntimeOrigin>>, > where AssetIdOf: MaybeSerializeDeserialize, @@ -191,16 +195,22 @@ pub mod pallet { /// This type represents an unique ID for the community type CommunityId: Parameter + MaxEncodedLen + Copy + MaybeSerializeDeserialize; - /// This type represents an unique ID to identify a membership within a - /// community - type MembershipId: Parameter + MaxEncodedLen + Copy + MaybeSerializeDeserialize; + + /// The hashing algorithm used for merkle trees + type Hasher: sp_runtime::traits::Hash; + + /// Verifier for membership proofs. Use `verifier::MerkleVerifier` + /// for simple merkle proofs, or a ZK verifier for privacy. + type MembershipVerifier: fc_traits_proof_verifier::ProofVerifier< + ProgramId = (), + PublicInputs = verifier::MembershipInputs, + >; + + /// Maximum number of members a community can have + type MaxMembers: Get; // Dependencies: The external components this pallet depends on. - /// Means to manage memberships of a community - type MemberMgmt: Inspect, Membership = MembershipIdOf> - + Manager, Membership = MembershipIdOf> - + Rank, Membership = MembershipIdOf>; /// Means to read and mutate the state of a poll. type Polls: Polling< Tally, @@ -275,14 +285,16 @@ pub mod pallet { StorageMap<_, Blake2_128Concat, CommunityIdOf, DecisionMethodFor, ValueQuery>; /// Stores the list of votes for a community. + /// Key: (poll_index, voter_key) where voter_key is hash of account (named) or hash of nullifier (anonymous) + /// Value: (vote, multiplied_weight) for correct removal #[pallet::storage] - pub(super) type CommunityVotes = StorageDoubleMap< + pub(super) type CommunityVotes = StorageDoubleMap< _, Blake2_128Concat, PollIndexOf, Blake2_128Concat, - MembershipIdOf, - (VoteOf, AccountIdOf), + ::Output, + (VoteOf, VoteWeight), >; /// Stores the list of votes for a community. @@ -296,6 +308,75 @@ pub mod pallet { VoteOf, >; + /// Merkle root for private community membership proofs + #[pallet::storage] + pub type MerkleRoot = + StorageMap<_, Blake2_128Concat, CommunityIdOf, ::Output>; + + /// Sub-roots for sharded membership trees + #[pallet::storage] + pub type SubRoots = StorageDoubleMap< + _, + Blake2_128Concat, + CommunityIdOf, + Blake2_128Concat, + u16, + ::Output, + >; + + /// Tracks used nullifiers to prevent double-actions per scope + #[pallet::storage] + pub type UsedNullifiers = StorageNMap< + _, + ( + NMapKey>, + NMapKey, + NMapKey, + ), + (), + >; + + /// Number of members in a community. For Public communities this is maintained + /// automatically by add/remove/suspend. For Private/Hybrid communities this counts + /// on-chain members only (may be 0); the tally denominator is taken from + /// [`ClaimedSupport`] instead. + #[pallet::storage] + pub type MemberCount = + StorageMap<_, Blake2_128Concat, CommunityIdOf, u32, ValueQuery>; + + /// Declared tally support for Private/Hybrid communities where membership is off-chain. + /// Used only as the denominator for `Polling::support()` when the community isn't + /// Public. Separate from [`MemberCount`] so that updating the merkle root does not + /// let the admin manipulate `MemberCount`-driven invariants. + /// + /// Safety note: the admin can still manipulate referendum thresholds by changing + /// this value — that's inherent to off-chain membership. Runtimes that want harder + /// guarantees should gate updates behind governance. + #[pallet::storage] + pub type ClaimedSupport = + StorageMap<_, Blake2_128Concat, CommunityIdOf, u32, ValueQuery>; + + /// Total of all member ranks in a community (for rank-weighted voting) + #[pallet::storage] + pub type RanksTotal = + StorageMap<_, Blake2_128Concat, CommunityIdOf, u32, ValueQuery>; + + /// On-chain member records + #[pallet::storage] + pub type Members = StorageDoubleMap< + _, + Blake2_128Concat, + CommunityIdOf, + Blake2_128Concat, + AccountIdOf, + MemberRecord, + >; + + /// Gas/transaction budget for a community + #[pallet::storage] + pub type Budget = + StorageMap<_, Blake2_128Concat, CommunityIdOf, CommunityBudget>>; + // Pallets use events to inform users when important changes are made. // https://docs.substrate.io/main-docs/build/events-errors/ #[pallet::event] @@ -315,18 +396,26 @@ pub mod pallet { }, MemberAdded { who: AccountIdOf, - membership_id: MembershipIdOf, }, MemberRemoved { who: AccountIdOf, - membership_id: MembershipIdOf, }, - MembershipRankUpdated { - membership_id: MembershipIdOf, - rank: membership::GenericRank, + MemberSuspended { + who: AccountIdOf, }, - VoteCasted { + MemberRankUpdated { who: AccountIdOf, + rank: GenericRank, + }, + MembershipRootUpdated { + community_id: CommunityIdOf, + }, + SubRootUpdated { + community_id: CommunityIdOf, + sub_track_id: u16, + }, + VoteCasted { + who: Option>, poll_index: PollIndexOf, vote: VoteOf, }, @@ -334,6 +423,9 @@ pub mod pallet { who: AccountIdOf, poll_index: PollIndexOf, }, + BudgetSet { + community_id: CommunityIdOf, + }, } // Errors inform users that something worked or went wrong. @@ -349,6 +441,8 @@ pub mod pallet { /// The specified [`AccountId`][`frame_system::Config::AccountId`] is /// not a member of the community NotAMember, + /// The account is already a member of this community + AlreadyMember, /// The indicated index corresponds to a poll that is already ongoing AlreadyOngoing, /// The indicated index corresponds to a poll that is not ongoing @@ -368,6 +462,22 @@ pub mod pallet { AlreadyAdmin, /// The vote is below the minimum requried VoteBelowMinimum, + /// Cannot add members directly to a private community + CommunityIsPrivate, + /// Cannot update membership root for a public community + CommunityIsPublic, + /// The member is suspended + MemberIsSuspended, + /// The community's gas budget has been exhausted + BudgetExhausted, + /// The budget parameters are invalid (e.g. zero-length session). + InvalidBudget, + /// An anonymous vote has already been cast for this voter on this poll. + /// Anonymous votes are immutable: they cannot be changed or removed. + AnonymousVoteAlreadyCast, + /// The caller is authorized by origin but not by role (e.g. a rank-0 member + /// trying to suspend another member). + NotAuthorized, } // Dispatchable functions allows users to interact with the pallet and invoke @@ -419,81 +529,235 @@ pub mod pallet { // === Memberships management === - /// Enroll an account as a community member that receives a membership - /// from the available pool of memberships of the community. + /// Enroll an account as a community member. #[pallet::call_index(3)] - pub fn add_member(origin: OriginFor, who: AccountIdLookupOf) -> DispatchResult { - let community_id = T::MemberMgmtOrigin::ensure_origin(origin)?; + pub fn add_member( + origin: OriginFor, + who: AccountIdLookupOf, + rank: Option, + role: Option, + ) -> DispatchResult { + let community_id = Self::ensure_member_mgmt(origin)?; let who = T::Lookup::lookup(who)?; - let account = Self::community_account(&community_id); - // assume the community has memberships to give out to the new member - let (_, membership_id) = T::MemberMgmt::user_memberships(&account, None) - .next() - .ok_or(Error::::CommunityAtCapacity)?; + let info = Info::::get(&community_id).ok_or(Error::::CommunityDoesNotExist)?; + ensure!( + info.state == CommunityState::Active, + Error::::CommunityDoesNotExist + ); + ensure!( + info.privacy != PrivacyLevel::Private, + Error::::CommunityIsPrivate + ); + ensure!( + !Members::::contains_key(&community_id, &who), + Error::::AlreadyMember + ); + + // `capacity == 0` is interpreted as "explicit zero" (no members allowed) — + // avoid the previous silent fallback to T::MaxMembers which made capacity=0 + // mean "unlimited-ish". A runtime that wants the default should read + // T::MaxMembers directly when creating the community. + let count = MemberCount::::get(&community_id); + ensure!(count < info.capacity, Error::::CommunityAtCapacity); + ensure!( + count < T::MaxMembers::get(), + Error::::CommunityAtCapacity, + ); - T::MemberMgmt::assign(&community_id, &membership_id, &who)?; + let member_rank = rank.unwrap_or_default(); + let member_role = role.unwrap_or_default(); + let rank_val: u32 = member_rank.into(); - Self::deposit_event(Event::MemberAdded { who, membership_id }); + Members::::insert( + &community_id, + &who, + MemberRecord { + rank: member_rank, + role: member_role, + ..Default::default() + }, + ); + MemberCount::::mutate(&community_id, |c| *c = c.saturating_add(1)); + if rank_val > 0 { + RanksTotal::::mutate(&community_id, |t| *t = t.saturating_add(rank_val)); + } + + Self::recompute_merkle_root(&community_id); + Self::deposit_event(Event::MemberAdded { who }); Ok(()) } - /// Removes an account as a community member. While - /// enrolling a member into the community can be an action taken by any - /// member, the decision to remove a member should not be taken - /// arbitrarily by any community member. Also, it shouldn't be possible - /// to arbitrarily remove the community admin, as some privileged calls - /// would be impossible to execute thereafter. + /// Removes an account as a community member. #[pallet::call_index(4)] pub fn remove_member( origin: OriginFor, who: AccountIdLookupOf, - membership_id: MembershipIdOf, ) -> DispatchResult { - let community_id = T::MemberMgmtOrigin::ensure_origin(origin)?; + let community_id = Self::ensure_member_mgmt(origin)?; let who = T::Lookup::lookup(who)?; - ensure!( - T::MemberMgmt::is_member_of(&community_id, &who), - Error::::NotAMember - ); + let record = + Members::::get(&community_id, &who).ok_or(Error::::NotAMember)?; - T::MemberMgmt::release(&community_id, &membership_id)?; + Members::::remove(&community_id, &who); + MemberCount::::mutate(&community_id, |c| *c = c.saturating_sub(1)); + let rank_val: u32 = record.rank.into(); + if rank_val > 0 { + RanksTotal::::mutate(&community_id, |t| *t = t.saturating_sub(rank_val)); + } - Self::deposit_event(Event::MemberRemoved { who, membership_id }); + Self::recompute_merkle_root(&community_id); + Self::deposit_event(Event::MemberRemoved { who }); Ok(()) } /// Increases the rank of a member in the community #[pallet::call_index(5)] - pub fn promote(origin: OriginFor, membership_id: MembershipIdOf) -> DispatchResult { - let community_id = T::MemberMgmtOrigin::ensure_origin(origin)?; + pub fn promote(origin: OriginFor, who: AccountIdLookupOf) -> DispatchResult { + let community_id = Self::ensure_member_mgmt(origin)?; + let who = T::Lookup::lookup(who)?; + + let mut record = + Members::::get(&community_id, &who).ok_or(Error::::NotAMember)?; - let rank = T::MemberMgmt::rank_of(&community_id, &membership_id) - .ok_or(Error::::NotAMember)? - .promote_by(ONE); - T::MemberMgmt::set_rank(&community_id, &membership_id, rank)?; + let new_rank = record.rank.promote_by(ONE); + let old_rank_val: u32 = record.rank.into(); + let new_rank_val: u32 = new_rank.into(); + record.rank = new_rank; + + Members::::insert(&community_id, &who, &record); + RanksTotal::::mutate(&community_id, |t| { + *t = t.saturating_sub(old_rank_val).saturating_add(new_rank_val); + }); - Self::deposit_event(Event::MembershipRankUpdated { - membership_id, - rank, + Self::recompute_merkle_root(&community_id); + Self::deposit_event(Event::MemberRankUpdated { + who, + rank: new_rank, }); Ok(()) } /// Decreases the rank of a member in the community #[pallet::call_index(6)] - pub fn demote(origin: OriginFor, membership_id: MembershipIdOf) -> DispatchResult { - let community_id = T::MemberMgmtOrigin::ensure_origin(origin)?; + pub fn demote(origin: OriginFor, who: AccountIdLookupOf) -> DispatchResult { + let community_id = Self::ensure_member_mgmt(origin)?; + let who = T::Lookup::lookup(who)?; + + let mut record = + Members::::get(&community_id, &who).ok_or(Error::::NotAMember)?; + + let new_rank = record.rank.demote_by(ONE); + let old_rank_val: u32 = record.rank.into(); + let new_rank_val: u32 = new_rank.into(); + record.rank = new_rank; + + Members::::insert(&community_id, &who, &record); + RanksTotal::::mutate(&community_id, |t| { + *t = t.saturating_sub(old_rank_val).saturating_add(new_rank_val); + }); + + Self::recompute_merkle_root(&community_id); + Self::deposit_event(Event::MemberRankUpdated { + who, + rank: new_rank, + }); + Ok(()) + } + + /// Suspend a community member + #[pallet::call_index(12)] + pub fn suspend_member( + origin: OriginFor, + who: AccountIdLookupOf, + ) -> DispatchResult { + let community_id = Self::ensure_member_mgmt(origin)?; + let who = T::Lookup::lookup(who)?; + + let mut record = + Members::::get(&community_id, &who).ok_or(Error::::NotAMember)?; + ensure!( + record.status == MemberStatus::Active, + Error::::MemberIsSuspended + ); + + record.status = MemberStatus::Suspended; + record.nonce = record.nonce.saturating_add(1); + Members::::insert(&community_id, &who, &record); + MemberCount::::mutate(&community_id, |c| *c = c.saturating_sub(1)); + + Self::recompute_merkle_root(&community_id); + Self::deposit_event(Event::MemberSuspended { who }); + Ok(()) + } + + /// Manually update the membership merkle root and claimed support for + /// private/hybrid communities. `claimed_support` is written to + /// [`ClaimedSupport`] and is used only as the denominator for the poll-support + /// calculation; it does not affect [`MemberCount`] or any other on-chain invariant. + #[pallet::call_index(13)] + pub fn update_membership_root( + origin: OriginFor, + new_root: ::Output, + claimed_support: u32, + ) -> DispatchResult { + let community_id = T::AdminOrigin::ensure_origin(origin)?; + + let info = Info::::get(&community_id).ok_or(Error::::CommunityDoesNotExist)?; + ensure!( + info.privacy != PrivacyLevel::Public, + Error::::CommunityIsPublic + ); + + MerkleRoot::::insert(&community_id, new_root); + ClaimedSupport::::insert(&community_id, claimed_support); + + Self::deposit_event(Event::MembershipRootUpdated { community_id }); + Ok(()) + } + + /// Update a sub-root for sharded membership trees + #[pallet::call_index(14)] + pub fn update_sub_root( + origin: OriginFor, + sub_track_id: u16, + new_root: ::Output, + ) -> DispatchResult { + let community_id = T::AdminOrigin::ensure_origin(origin)?; + + SubRoots::::insert(&community_id, sub_track_id, new_root); - let rank = T::MemberMgmt::rank_of(&community_id, &membership_id) - .ok_or(Error::::NotAMember)?; - T::MemberMgmt::set_rank(&community_id, &membership_id, rank.demote_by(ONE))?; + Self::deposit_event(Event::SubRootUpdated { + community_id, + sub_track_id, + }); + Ok(()) + } - Self::deposit_event(Event::MembershipRankUpdated { - membership_id, - rank, + // === Budget === + + /// Set the gas budget for a community + #[pallet::call_index(15)] + pub fn set_budget( + origin: OriginFor, + capacity: u64, + session_length: BlockNumberFor, + ) -> DispatchResult { + use sp_runtime::traits::Zero; + let community_id = T::AdminOrigin::ensure_origin(origin)?; + ensure!(Self::community_exists(&community_id), Error::::CommunityDoesNotExist); + // A zero-length session would reset the budget on every check, making the + // capacity cap meaningless. + ensure!(!session_length.is_zero(), Error::::InvalidBudget); + let now = T::BlockNumberProvider::current_block_number(); + Budget::::insert(&community_id, CommunityBudget { + capacity, + used: 0, + session_start: now, + session_length, }); + Self::deposit_event(Event::BudgetSet { community_id }); Ok(()) } @@ -521,52 +785,111 @@ pub mod pallet { Ok(()) } - /// Cast a vote on an on-going referendum + /// Cast a vote on an on-going referendum. + /// Supports both named (signed) and anonymous (community origin) voting paths. #[pallet::call_index(8)] pub fn vote( origin: OriginFor, - membership_id: MembershipIdOf, #[pallet::compact] poll_index: PollIndexOf, vote: VoteOf, ) -> DispatchResult { ensure!(VoteWeight::from(&vote).gt(&0), Error::::VoteBelowMinimum); - let who = ensure_signed(origin)?; - let community_id = T::MemberMgmt::check_membership(&who, &membership_id) - .ok_or(Error::::NotAMember)?; + + // Determine voting path: signed (named) or community origin (anonymous). + // The anonymous branch goes through `EnsureAnonymousVoter`, which rejects every + // community-origin variant *except* `Subset::AnonymousMember`. That keeps the + // anonymous privilege scoped to voting only — a membership proof cannot dispatch + // admin or member-management calls that check `MemberMgmtOrigin`/`AdminOrigin`. + let (community_id, voter_key, vote_multiplier, maybe_who): ( + CommunityIdOf, + ::Output, + u32, + Option>, + ) = if let Some(who) = origin.as_system_ref() + .and_then(|s| if let frame_system::RawOrigin::Signed(who) = s { Some(who.clone()) } else { None }) + { + // Named vote path - signed origin + let community_id = T::Polls::as_ongoing(poll_index) + .map(|t| t.1) + .ok_or(Error::::NotOngoing)?; + ensure!(Self::is_member(&community_id, &who), Error::::NotAMember); + let decision_method = CommunityDecisionMethod::::get(community_id); + let multiplier = match decision_method { + DecisionMethod::Rank => Self::member_rank(&community_id, &who).into(), + _ => 1u32, + }; + let key = T::Hasher::hash_of(&who); + (community_id, key, multiplier, Some(who)) + } else { + // Anonymous vote path. `EnsureAnonymousVoter` both checks we have an + // AnonymousMember origin *and* returns the (community_id, nullifier). + let (community_id, nullifier) = origin::EnsureAnonymousVoter::::try_origin(origin) + .map_err(|_| DispatchError::BadOrigin)?; + let decision_method = CommunityDecisionMethod::::get(community_id); + // Token-weighted voting cannot be authorized anonymously (no account to lock + // funds against), and the leaf contents — including rank — are not verified, + // so rank-weighted voting is also rejected. Only flat 1-per-member voting is + // allowed via the anonymous path until a ZK backend binds rank to the proof. + ensure!( + matches!(decision_method, DecisionMethod::Membership), + Error::::InvalidVoteType + ); + let key = T::Hasher::hash_of(&nullifier); + (community_id, key, 1u32, None) + }; + let decision_method = CommunityDecisionMethod::::get(community_id); - if CommunityVotes::::contains_key(poll_index, membership_id) { - Self::try_remove_vote(&community_id, &decision_method, &membership_id, poll_index)?; + + // Named voters may replace their vote; anonymous voters cannot, because the + // nullifier scheme in the extension also prevents it at the tx layer and there + // is no signed authority to authorize a replacement. + if CommunityVotes::::contains_key(poll_index, &voter_key) { + ensure!(maybe_who.is_some(), Error::::AnonymousVoteAlreadyCast); + Self::try_remove_vote_by_key(&community_id, &voter_key, poll_index)?; } - Self::try_vote( - &community_id, - &decision_method, - &who, - &membership_id, - poll_index, - &vote, - )?; + + Self::try_vote_by_key(&community_id, &decision_method, vote_multiplier, &voter_key, poll_index, &vote)?; + + // Lock funds only for named voters + if let Some(ref who) = maybe_who { + Self::update_locks(who, poll_index, &vote, LockUpdateType::Add)?; + } + Self::deposit_event(Event::::VoteCasted { - who: who.clone(), + who: maybe_who, poll_index, vote, }); Ok(()) } - /// Remove any previous vote on a given referendum + /// Remove any previous vote on a given referendum. + /// Only available for named (signed) voters. Anonymous votes cannot be removed. #[pallet::call_index(9)] pub fn remove_vote( origin: OriginFor, - membership_id: MembershipIdOf, #[pallet::compact] poll_index: PollIndexOf, ) -> DispatchResult { let who = ensure_signed(origin)?; - let community_id = T::MemberMgmt::check_membership(&who, &membership_id) - .ok_or(Error::::NotAMember)?; - let decision_method = CommunityDecisionMethod::::get(community_id); - Self::try_remove_vote(&community_id, &decision_method, &membership_id, poll_index)?; + let voter_key = T::Hasher::hash_of(&who); + + let community_id = T::Polls::as_ongoing(poll_index) + .map(|t| t.1) + .ok_or(Error::::NotOngoing)?; + + ensure!( + Self::is_member(&community_id, &who), + Error::::NotAMember + ); + + let (vote, _) = CommunityVotes::::get(poll_index, &voter_key) + .ok_or(Error::::NoVoteCasted)?; + + Self::try_remove_vote_by_key(&community_id, &voter_key, poll_index)?; + Self::update_locks(&who, poll_index, &vote, LockUpdateType::Remove)?; + Self::deposit_event(Event::::VoteRemoved { - who: who.clone(), + who, poll_index, }); Ok(()) @@ -602,8 +925,48 @@ pub mod pallet { origin: OriginFor, call: Box>, ) -> DispatchResult { - let community_id = T::MemberMgmtOrigin::ensure_origin(origin)?; + let community_id = Self::ensure_member_mgmt(origin)?; Self::do_dispatch_as_community_account(&community_id, *call) } + + /// Remove a stored vote entry for a poll that has ended. Permissionless: any + /// account can call this for any `(poll_index, voter_key)` once the poll is no + /// longer ongoing. Addresses storage bloat, including for anonymous votes whose + /// voter has no on-chain identity to clean up after themselves. + #[pallet::call_index(16)] + #[pallet::weight((T::DbWeight::get().reads_writes(1, 1), DispatchClass::Normal))] + pub fn prune_vote( + origin: OriginFor, + #[pallet::compact] poll_index: PollIndexOf, + voter_key: ::Output, + ) -> DispatchResult { + let _ = ensure_signed(origin)?; + ensure!( + T::Polls::as_ongoing(poll_index).is_none(), + Error::::AlreadyOngoing, + ); + ensure!( + CommunityVotes::::contains_key(poll_index, &voter_key), + Error::::NoVoteCasted, + ); + CommunityVotes::::remove(poll_index, &voter_key); + Ok(()) + } + + /// Remove a stored sub-root. Admin-only. + #[pallet::call_index(17)] + #[pallet::weight((T::DbWeight::get().reads_writes(0, 1), DispatchClass::Normal))] + pub fn remove_sub_root( + origin: OriginFor, + sub_track_id: u16, + ) -> DispatchResult { + let community_id = T::AdminOrigin::ensure_origin(origin)?; + SubRoots::::remove(&community_id, sub_track_id); + Self::deposit_event(Event::SubRootUpdated { + community_id, + sub_track_id, + }); + Ok(()) + } } } diff --git a/pallets/communities/src/mock.rs b/pallets/communities/src/mock.rs index cc24d6a..9408fa4 100644 --- a/pallets/communities/src/mock.rs +++ b/pallets/communities/src/mock.rs @@ -1,62 +1,40 @@ -use frame_contrib_traits::memberships::NonFungiblesMemberships; use frame_support::{ - derive_impl, - dispatch::DispatchResult, - parameter_types, + derive_impl, parameter_types, traits::{ - fungible::HoldConsideration, tokens::nonfungible_v2::ItemOf, AsEnsureOriginWithArg, - ConstU32, ConstU64, EitherOf, EnsureOriginWithArg, EqualPrivilegeOnly, Footprint, - VariantCountOf, - }, - weights::{ - constants::{WEIGHT_REF_TIME_PER_NANOS, WEIGHT_REF_TIME_PER_SECOND}, - Weight, + fungible::HoldConsideration, AsEnsureOriginWithArg, ConstU32, ConstU64, + EitherOf, EnsureOriginWithArg, EqualPrivilegeOnly, Footprint, + OriginTrait, VariantCountOf, }, + weights::{constants::WEIGHT_REF_TIME_PER_SECOND, Weight}, PalletId, }; use frame_system::{EnsureRoot, EnsureRootWithSuccess, EnsureSigned}; -use pallet_referenda::{TrackIdOf, TrackInfoOf, TracksInfo}; +use pallet_referenda::{TrackIdOf, TracksInfo}; use sp_io::TestExternalities; use sp_runtime::{ - traits::{Convert, IdentifyAccount, IdentityLookup, Verify}, - BuildStorage, MultiSignature, Perbill, + traits::{BlakeTwo256, Convert, IdentifyAccount, IdentityLookup, Verify}, + BuildStorage, MultiSignature, }; -pub type CommunityId = u16; -pub type MembershipId = u64; +pub type CommunityId = u32; use crate::{ self as pallet_communities, origin::{EnsureCommunity, EnsureSignedPays}, types::{Tally, VoteWeight}, - Config, DecisionMethod, + Config, PrivacyLevel, }; // Weights constants - -// max block: 0.5s compute with 12s average block time -pub const MAX_BLOCK_REF_TIME: u64 = WEIGHT_REF_TIME_PER_SECOND.saturating_div(2); // https://github.com/paritytech/cumulus/blob/98e68bd54257b4039a5d5b734816f4a1b7c83a9d/parachain-template/runtime/src/lib.rs#L221 -pub const MAX_BLOCK_POV_SIZE: u64 = 5 * 1024 * 1024; // https://github.com/paritytech/polkadot/blob/ba1f65493d91d4ab1787af2fd6fe880f1da90586/primitives/src/v4/mod.rs#L384 -pub const MAX_BLOCK_WEIGHT: Weight = Weight::from_parts(MAX_BLOCK_REF_TIME, MAX_BLOCK_POV_SIZE); - -// max extrinsics: 75% of block -pub const NORMAL_DISPATCH_RATIO: Perbill = Perbill::from_percent(75); // https://github.com/paritytech/cumulus/blob/d20c4283fe85df0c1ef8cb7c9eb7c09abbcbfa31/parachain-template/runtime/src/lib.rs#L218 - -// max extrinsic: max total extrinsics less average on_initialize ratio and less -// base extrinsic weight -pub const AVERAGE_ON_INITIALIZE_RATIO: Perbill = Perbill::from_percent(5); // https://github.com/paritytech/cumulus/blob/d20c4283fe85df0c1ef8cb7c9eb7c09abbcbfa31/parachain-template/runtime/src/lib.rs#L214 -pub const BASE_EXTRINSIC: Weight = - Weight::from_parts(WEIGHT_REF_TIME_PER_NANOS.saturating_mul(125_000), 0); // https://github.com/paritytech/cumulus/blob/d20c4283fe85df0c1ef8cb7c9eb7c09abbcbfa31/parachain-template/runtime/src/weights/extrinsic_weights.rs#L26 - +pub const MAX_BLOCK_REF_TIME: u64 = WEIGHT_REF_TIME_PER_SECOND.saturating_div(2); +pub const MAX_BLOCK_POV_SIZE: u64 = 5 * 1024 * 1024; type Block = frame_system::mocking::MockBlock; type WeightInfo = (); pub type AccountPublic = ::Signer; pub type AccountId = ::AccountId; pub type Balance = ::Balance; -pub type AssetId = ::AssetId; -// Configure a mock runtime to test the pallet. #[frame_support::runtime] mod runtime { #[runtime::runtime] @@ -91,8 +69,6 @@ mod runtime { pub type Communities = pallet_communities; #[runtime::pallet_index(32)] pub type Tracks = fc_pallet_referenda_tracks; - #[runtime::pallet_index(33)] - pub type Nfts = pallet_nfts; } #[derive_impl(frame_system::config_preludes::TestDefaultConfig as frame_system::DefaultConfig)] @@ -103,9 +79,7 @@ impl frame_system::Config for Test { type AccountData = pallet_balances::AccountData; } -// Monetary operations -#[derive_impl(pallet_balances::config_preludes::TestDefaultConfig as pallet_balances::DefaultConfig -)] +#[derive_impl(pallet_balances::config_preludes::TestDefaultConfig as pallet_balances::DefaultConfig)] impl pallet_balances::Config for Test { type AccountStore = System; type FreezeIdentifier = RuntimeFreezeReason; @@ -127,79 +101,12 @@ impl pallet_assets_freezer::Config for Test { type RuntimeEvent = RuntimeEvent; } -// Memberships -#[cfg(feature = "runtime-benchmarks")] -pub struct NftsBenchmarksHelper; -#[cfg(feature = "runtime-benchmarks")] -impl - pallet_nfts::BenchmarkHelper< - CommunityId, - MembershipId, - AccountPublic, - AccountId, - MultiSignature, - > for NftsBenchmarksHelper -{ - fn collection(_i: u16) -> CommunityId { - COMMUNITY - } - fn item(i: u16) -> MembershipId { - i as MembershipId - } - fn signer() -> (AccountPublic, AccountId) { - let public = sp_io::crypto::sr25519_generate(0.into(), None); - let account = sp_runtime::MultiSigner::Sr25519(public).into_account(); - (public.into(), account) - } - fn sign(signer: &AccountPublic, message: &[u8]) -> MultiSignature { - MultiSignature::Sr25519( - sp_io::crypto::sr25519_sign(0.into(), &signer.clone().try_into().unwrap(), message) - .unwrap(), - ) - } -} - -parameter_types! { - pub const RootAccount: AccountId = AccountId::new([0xff; 32]); -} -impl pallet_nfts::Config for Test { - type RuntimeEvent = RuntimeEvent; - type CollectionId = CommunityId; - type ItemId = MembershipId; - type Currency = (); - type ForceOrigin = EnsureRoot; - type CreateOrigin = AsEnsureOriginWithArg< - EitherOf, EnsureSigned>, - >; - type Locker = (); - type CollectionDeposit = (); - type ItemDeposit = (); - type MetadataDepositBase = (); - type AttributeDepositBase = (); - type DepositPerByte = (); - type StringLimit = (); - type KeyLimit = ConstU32<64>; - type ValueLimit = ConstU32<10>; - type ApprovalsLimit = (); - type ItemAttributesApprovalsLimit = (); - type MaxTips = (); - type MaxDeadlineDuration = (); - type MaxAttributesPerCall = (); - type Features = (); - type OffchainSignature = MultiSignature; - type OffchainPublic = AccountPublic; - #[cfg(feature = "runtime-benchmarks")] - type Helper = NftsBenchmarksHelper; - - type WeightInfo = (); - type BlockNumberProvider = System; -} - -// Governance at Communities +// Governance parameter_types! { pub MaximumSchedulerWeight: Weight = Weight::from_parts(MAX_BLOCK_REF_TIME, MAX_BLOCK_POV_SIZE); pub const MaxScheduledPerBlock: u32 = 512; } + pub struct ConvertDeposit; impl Convert for ConvertDeposit { fn convert(a: Footprint) -> u64 { @@ -244,7 +151,6 @@ impl EnsureOriginWithArg> for EnsureOriginToT let track_id_for_origin: TrackIdOf = Tracks::track_for(&o.clone().caller).map_err(|_| o.clone())?; frame_support::ensure!(&track_id_for_origin == id, o); - Ok(()) } @@ -254,23 +160,10 @@ impl EnsureOriginWithArg> for EnsureOriginToT } } -#[cfg(feature = "runtime-benchmarks")] -use sp_runtime::SaturatedConversion; - -#[cfg(feature = "runtime-benchmarks")] -pub struct TracksBenchmarkHelper; - -#[cfg(feature = "runtime-benchmarks")] -impl fc_pallet_referenda_tracks::BenchmarkHelper for TracksBenchmarkHelper { - fn track_id(id: u32) -> TrackIdOf { - id.saturated_into() - } -} - parameter_types! { pub const MaxTracks: u32 = u32::MAX; } -/// Returns a default group ID (0) when root creates a sub-track. + pub struct EnsureRootReturnGroupId; impl EnsureOriginWithArg> for EnsureRootReturnGroupId @@ -286,7 +179,7 @@ impl EnsureOriginWithArg> } #[cfg(feature = "runtime-benchmarks")] - fn try_successful_origin(_arg: &PalletsOriginOf) -> Result { + fn try_successful_origin(_arg: &pallet_referenda::PalletsOriginOf) -> Result { Ok(RuntimeOrigin::root()) } } @@ -305,8 +198,9 @@ impl fc_pallet_referenda_tracks::Config for Test { } parameter_types! { - pub static AlarmInterval: u64 = 1; + pub static AlarmInterval: u64 = 1; } + impl pallet_referenda::Config for Test { type RuntimeCall = RuntimeCall; type RuntimeEvent = RuntimeEvent; @@ -332,142 +226,15 @@ impl pallet_referenda::Config for Test { parameter_types! { pub const CommunitiesPalletId: PalletId = PalletId(*b"kv/comms"); - pub const MembershipsManagerCollectionId: CommunityId = 0; - pub const MembershipNftAttr: &'static [u8; 10] = b"membership"; - pub const TestCommunity: CommunityId = COMMUNITY; -} - -type MembershipCollection = ItemOf; - -#[cfg(feature = "runtime-benchmarks")] -use crate::{ - types::{AssetIdOf, CommunityIdOf, MembershipIdOf, PollIndexOf}, - BenchmarkHelper, -}; - -#[cfg(feature = "runtime-benchmarks")] -use { - codec::Encode, - frame_benchmarking::BenchmarkError, - frame_support::BoundedVec, - frame_system::pallet_prelude::{OriginFor, RuntimeCallFor}, - pallet_referenda::{BoundedCallOf, Curve, PalletsOriginOf, TrackInfo}, -}; - -#[cfg(feature = "runtime-benchmarks")] -pub struct CommunityBenchmarkHelper; - -#[cfg(feature = "runtime-benchmarks")] -impl BenchmarkHelper for CommunityBenchmarkHelper { - fn community_id() -> CommunityIdOf { - COMMUNITY - } - - fn community_asset_id() -> AssetIdOf { - 1u32 - } - - fn community_desired_size() -> u32 { - u8::MAX as u32 - } - - fn initialize_memberships_collection() -> Result<(), frame_benchmarking::BenchmarkError> { - TestEnvBuilder::initialize_memberships_manager_collection()?; - TestEnvBuilder::initialize_community_memberships_collection(&Self::community_id())?; - Ok(()) - } - - fn issue_membership( - community_id: CommunityIdOf, - membership_id: MembershipIdOf, - ) -> Result<(), frame_benchmarking::BenchmarkError> { - use frame_support::traits::tokens::nonfungible_v2::Mutate; - - let community_account = Communities::community_account(&community_id); - MembershipCollection::mint_into( - &membership_id, - &community_account, - &Default::default(), - true, - )?; - - Ok(()) - } - - fn prepare_track(track_origin: PalletsOriginOf) -> Result<(), BenchmarkError> { - let id = Self::community_id(); - let info = TrackInfo { - name: sp_runtime::str_array("Community"), - max_deciding: 1, - decision_deposit: 5, - prepare_period: 1, - decision_period: 5, - confirm_period: 1, - min_enactment_period: 1, - min_approval: Curve::LinearDecreasing { - length: Perbill::from_percent(100), - floor: Perbill::from_percent(50), - ceil: Perbill::from_percent(100), - }, - min_support: Curve::LinearDecreasing { - length: Perbill::from_percent(100), - floor: Perbill::from_percent(0), - ceil: Perbill::from_percent(100), - }, - }; - - Tracks::do_insert(id, info, track_origin.clone())?; - - Ok(()) - } - - fn prepare_poll( - origin: OriginFor, - proposal_origin: PalletsOriginOf, - proposal_call: RuntimeCallFor, - ) -> Result, BenchmarkError> { - let proposal = - BoundedCallOf::::Inline(BoundedVec::truncate_from(proposal_call.encode())); - let enactment_moment = frame_support::traits::schedule::DispatchTime::After(1); - Referenda::submit( - origin.clone(), - Box::new(proposal_origin), - proposal, - enactment_moment, - )?; - Referenda::place_decision_deposit(origin, 0)?; - - System::set_block_number(2); - Referenda::nudge_referendum(RuntimeOrigin::root(), 0)?; - - Ok(0) - } - - fn finish_poll(index: PollIndexOf) -> Result<(), BenchmarkError> { - System::set_block_number(8); - Referenda::nudge_referendum(RuntimeOrigin::root(), index)?; - - frame_support::assert_ok!(Referenda::ensure_ongoing(index)); - - System::set_block_number(9); - Referenda::nudge_referendum(RuntimeOrigin::root(), index)?; - - frame_support::assert_err!( - Referenda::ensure_ongoing(index), - pallet_referenda::Error::::NotOngoing - ); - - Ok(()) - } -} - -parameter_types! { pub const NoPay: Option<(Balance, AccountId, AccountId)> = None; } + type RootCreatesCommunitiesForFree = EnsureRootWithSuccess; type AnyoneElsePays = EnsureSignedPays, RootAccount>; -pub type MembershipsManager = NonFungiblesMemberships; +parameter_types! { + pub const RootAccount: AccountId = AccountId::new([0xff; 32]); +} impl Config for Test { type RuntimeFreezeReason = RuntimeFreezeReason; @@ -478,8 +245,9 @@ impl Config for Test { type MemberMgmtOrigin = EnsureCommunity; type CommunityId = CommunityId; - type MembershipId = MembershipId; - type MemberMgmt = MembershipsManager; + type Hasher = BlakeTwo256; + type MembershipVerifier = crate::verifier::MerkleVerifier; + type MaxMembers = ConstU32<100>; type Polls = Referenda; type Assets = Assets; @@ -494,111 +262,38 @@ impl Config for Test { } pub const COMMUNITY: CommunityId = 1; -pub const COMMUNITY_ORIGIN: OriginCaller = - OriginCaller::Communities(pallet_communities::Origin::::new(COMMUNITY)); - -// Build genesis storage according to the mock runtime. -pub fn new_test_ext(members: &[AccountId], memberships: &[MembershipId]) -> TestExternalities { - TestEnvBuilder::new() - .add_community( - COMMUNITY, - DecisionMethod::Membership, - members, - memberships, - None, - ) - .build() + +pub fn community_origin(id: CommunityId) -> RuntimeOrigin { + pallet_communities::Origin::::new(id).into() } -#[derive(Default)] pub(crate) struct TestEnvBuilder { - assets_config: AssetsConfig, - balances: Vec<(AccountId, Balance)>, - communities: Vec, - decision_methods: - alloc::collections::btree_map::BTreeMap>, + communities: Vec<(CommunityId, PrivacyLevel)>, members: Vec<(CommunityId, AccountId)>, - memberships: Vec<(CommunityId, MembershipId)>, - tracks: Vec<(TrackIdOf, TrackInfoOf)>, } impl TestEnvBuilder { pub(crate) fn new() -> Self { - Self::default() - } - - pub(crate) fn add_asset( - mut self, - id: &AssetId, - owner: &AccountId, - is_sufficient: bool, - min_balance: Balance, - // name, symbol, decimals - maybe_metadata: Option<(Vec, Vec, u8)>, - maybe_accounts: Option>, - ) -> Self { - self.assets_config - .assets - .push((*id, owner.clone(), is_sufficient, min_balance)); - - if let Some((name, symbol, decimals)) = maybe_metadata { - self.assets_config - .metadata - .push((*id, name, symbol, decimals)); + Self { + communities: Vec::new(), + members: Vec::new(), } - - self.assets_config.accounts.append( - &mut maybe_accounts - .unwrap_or_default() - .into_iter() - .map(|(account_id, balance)| (*id, account_id, balance)) - .collect(), - ); - - self } - pub(crate) fn add_community( - mut self, - community_id: CommunityId, - decision_method: DecisionMethod, - members: &[AccountId], - memberships: &[MembershipId], - maybe_track: Option>, - ) -> Self { - self.communities.push(community_id); - self.decision_methods.insert(community_id, decision_method); - self.members.append( - &mut members - .iter() - .map(|m| (community_id, m.to_owned())) - .collect::>(), - ); - self.memberships.append( - &mut memberships - .iter() - .map(|m| (community_id, m.to_owned())) - .collect::>(), - ); - if let Some(track) = maybe_track { - self.tracks.push((community_id, track)); - } - + pub(crate) fn add_community(mut self, id: CommunityId, privacy: PrivacyLevel) -> Self { + self.communities.push((id, privacy)); self } - pub(crate) fn with_balances(mut self, balances: &[(AccountId, Balance)]) -> Self { - self.balances = balances.to_vec(); + pub(crate) fn add_member(mut self, community_id: CommunityId, who: AccountId) -> Self { + self.members.push((community_id, who)); self } pub(crate) fn build(self) -> TestExternalities { let t = RuntimeGenesisConfig { - assets: self.assets_config, - balances: pallet_balances::GenesisConfig { - balances: self.balances, - dev_accounts: None, - }, + assets: Default::default(), + balances: Default::default(), system: Default::default(), } .build_storage() @@ -609,107 +304,41 @@ impl TestEnvBuilder { ext.execute_with(|| { System::set_block_number(1); - Self::initialize_memberships_manager_collection().expect("collection is initialized"); - - for community_id in &self.communities { - Self::initialize_community_memberships_collection(community_id) - .expect("collection is initialized"); - - let decision_method = self - .decision_methods - .get(community_id) - .expect("should include decision_method on add_community"); - let community_origin: RuntimeOrigin = Self::create_community_origin(community_id); + for (community_id, privacy) in &self.communities { + let origin = community_origin(*community_id); Communities::create( RuntimeOrigin::root(), - community_origin.caller.clone(), + origin.caller().clone(), *community_id, ) - .expect("can add community"); - - Communities::set_decision_method( - community_origin.clone(), - *community_id, - decision_method.clone(), - ) - .expect("can set decision info"); - - let mut members = self.members.iter().filter(|(cid, _)| cid == community_id); - let memberships = self - .memberships - .iter() - .filter(|(cid, _)| cid == community_id); - - assert!( - self.memberships.len() >= self.members.len(), - "there should be at least as many memberships as there are members" - ); - - for (_, membership) in memberships { - use frame_support::traits::tokens::nonfungible_v2::Mutate; - - let account = Communities::community_account(community_id); - MembershipCollection::mint_into( - membership, - &account, - &Default::default(), - true, - ) - .expect("can mint membership"); - - if let Some((_, who)) = members.next() { - Communities::add_member(community_origin.clone(), who.clone()) - .expect("can add member"); - } + .expect("can create community"); + + // Set the privacy level + if *privacy != PrivacyLevel::Public { + crate::Info::::mutate(community_id, |info| { + if let Some(ref mut info) = info { + info.privacy = privacy.clone(); + } + }); } - for (_, track_info) in self.tracks.iter().filter(|(cid, _)| cid == community_id) { - Tracks::do_insert( - *community_id, - track_info.clone(), - community_origin.caller.clone(), + for (cid, who) in self.members.iter().filter(|(cid, _)| cid == community_id) { + Communities::add_member( + community_origin(*cid), + who.clone(), + None, + None, ) - .expect("can add track"); + .expect("can add member"); } } }); ext } +} - pub(crate) fn initialize_memberships_manager_collection() -> DispatchResult { - Nfts::do_create_collection( - MembershipsManagerCollectionId::get(), - RootAccount::get(), - RootAccount::get(), - Default::default(), - 0, - pallet_nfts::Event::ForceCreated { - collection: MembershipsManagerCollectionId::get(), - owner: RootAccount::get(), - }, - ) - } - - pub(crate) fn initialize_community_memberships_collection( - community_id: &CommunityId, - ) -> DispatchResult { - let account = Communities::community_account(community_id); - Nfts::do_create_collection( - *community_id, - account.clone(), - account.clone(), - Default::default(), - 0, - pallet_nfts::Event::ForceCreated { - collection: *community_id, - owner: account, - }, - ) - } - - pub fn create_community_origin(community_id: &CommunityId) -> RuntimeOrigin { - pallet_communities::Origin::::new(*community_id).into() - } +pub fn account(n: u8) -> AccountId { + AccountId::new([n; 32]) } diff --git a/pallets/communities/src/origin.rs b/pallets/communities/src/origin.rs index aa5697c..3fd84d7 100644 --- a/pallets/communities/src/origin.rs +++ b/pallets/communities/src/origin.rs @@ -1,9 +1,9 @@ use crate::{ - types::{CommunityIdOf, CommunityState::Active, MembershipIdOf, RuntimeOriginFor}, + types::{CommunityIdOf, CommunityState::Active, RuntimeOriginFor}, AccountIdOf, CommunityIdFor, Config, Info, Pallet, }; use core::marker::PhantomData; -use frame_contrib_traits::memberships::{GenericRank, Inspect}; +use frame_contrib_traits::memberships::GenericRank; use frame_support::{ pallet_prelude::*, traits::{EnsureOriginWithArg, MapSuccess, OriginTrait}, @@ -11,8 +11,16 @@ use frame_support::{ use frame_system::EnsureSigned; #[cfg(feature = "xcm")] use sp_runtime::traits::TryConvert; +use sp_core::H256; use sp_runtime::{morph_types, Permill}; +/// True when the subset variant represents an anonymous, unverified identity that must +/// never authorize privileged actions. Used by admin/member-mgmt guards to reject +/// anonymous origins. +fn is_anonymous_subset(s: &Option>) -> bool { + matches!(s, Some(Subset::AnonymousMember { .. })) +} + pub struct EnsureCommunity(PhantomData); impl EnsureOrigin> for EnsureCommunity @@ -29,7 +37,12 @@ where return Err(o); } let id = match o.clone().into() { - Ok(RawOrigin { community_id, .. }) => community_id, + Ok(raw) => { + if is_anonymous_subset::(&raw.subset) { + return Err(o); + } + raw.community_id + } Err(_) => { let origin = o.clone().into_caller(); CommunityIdFor::::get(origin).ok_or_else(|| o.clone())? @@ -73,14 +86,12 @@ where o: RuntimeOriginFor, community_id: &CommunityIdOf, ) -> Result> { - use frame_system::RawOrigin::Signed; - - match o.clone().into() { - Ok(Signed(who)) => { - if T::MemberMgmt::is_member_of(community_id, &who) { + match o.as_system_ref() { + Some(frame_system::RawOrigin::Signed(who)) => { + if Pallet::::is_member(community_id, who) { Ok(()) } else { - Err(o.clone()) + Err(o) } } _ => Err(o), @@ -118,6 +129,10 @@ impl RawOrigin { pub fn id(&self) -> CommunityIdOf { self.community_id } + + pub fn subset(&self) -> Option<&Subset> { + self.subset.as_ref() + } } /// Subsets of the community can also have a voice @@ -125,7 +140,11 @@ impl RawOrigin { Clone, Debug, Decode, DecodeWithMemTracking, Encode, Eq, MaxEncodedLen, PartialEq, TypeInfo, )] pub enum Subset { - Member(MembershipIdOf), + Member(AccountIdOf), + AnonymousMember { + rank: GenericRank, + nullifier: H256, + }, Members { count: u32 }, Fraction(Permill), AtLeastRank(GenericRank), @@ -160,6 +179,7 @@ where let part = match o.subset { None => BodyPart::Voice, Some(Subset::Member(_)) => BodyPart::Members { count: 1 }, + Some(Subset::AnonymousMember { .. }) => return Err(()), Some(Subset::Members { count }) => BodyPart::Members { count }, Some(Subset::Fraction(per)) => BodyPart::Fraction { nom: per.deconstruct(), @@ -205,7 +225,7 @@ where } } -/// Ensure the origin is any `Signed` origin. +/// Authorize a call as the community's keyless account. Rejects anonymous subsets. pub struct AsSignedByCommunity(PhantomData); impl EnsureOrigin for AsSignedByCommunity where @@ -220,9 +240,12 @@ where type Success = T::AccountId; fn try_origin(o: OuterOrigin) -> Result { - match o.clone().into() { - Ok(RawOrigin { community_id, .. }) => Ok(Pallet::::community_account(&community_id)), - _ => Err(o.clone()), + let converted: Result, OuterOrigin> = o.clone().into(); + match converted { + Ok(raw) if !is_anonymous_subset::(&raw.subset) => { + Ok(Pallet::::community_account(&raw.community_id)) + } + _ => Err(o), } } @@ -234,7 +257,8 @@ where } } -/// Ensure the origin is any `Signed` origin. +/// Authorize a call as the community's keyless account for a fixed community id. +/// Rejects anonymous subsets. pub struct AsSignedByStaticCommunity(PhantomData<(T, C)>); impl EnsureOrigin for AsSignedByStaticCommunity where @@ -250,11 +274,15 @@ where type Success = T::AccountId; fn try_origin(o: OuterOrigin) -> Result { - match o.clone().into() { + let converted: Result, OuterOrigin> = o.clone().into(); + match converted { Ok(RawOrigin { - ref community_id, .. - }) if community_id == &C::get() => Ok(Pallet::::community_account(community_id)), - _ => Err(o.clone()), + ref community_id, + ref subset, + }) if community_id == &C::get() && !is_anonymous_subset::(subset) => { + Ok(Pallet::::community_account(community_id)) + } + _ => Err(o), } } @@ -265,3 +293,38 @@ where Ok(frame_system::RawOrigin::Signed(Pallet::::community_account(&community_id)).into()) } } + +/// Extracts a community id from an AnonymousMember origin — the only privilege granted to +/// anonymous callers. Rejects every other origin variant. Voting extrinsics use this +/// (instead of `EnsureCommunity`) to accept anonymous origins, so a proof of membership +/// cannot be used to dispatch admin or member-management actions. +pub struct EnsureAnonymousVoter(PhantomData); +impl EnsureOrigin> for EnsureAnonymousVoter +where + RuntimeOriginFor: + OriginTrait + Into, RuntimeOriginFor>> + From>, + T: Config, +{ + type Success = (CommunityIdOf, H256); + + fn try_origin(o: RuntimeOriginFor) -> Result> { + match o.clone().into() { + Ok(raw) => match raw.subset { + Some(Subset::AnonymousMember { nullifier, .. }) => Ok((raw.community_id, nullifier)), + _ => Err(o), + }, + Err(_) => Err(o), + } + } + + #[cfg(feature = "runtime-benchmarks")] + fn try_successful_origin() -> Result, ()> { + use crate::BenchmarkHelper; + let mut raw = RawOrigin::::new(T::BenchmarkHelper::community_id()); + raw.with_subset(Subset::AnonymousMember { + rank: GenericRank::default(), + nullifier: H256::zero(), + }); + Ok(raw.into()) + } +} diff --git a/pallets/communities/src/tests/mod.rs b/pallets/communities/src/tests/mod.rs index fe59d02..0f172bd 100644 --- a/pallets/communities/src/tests/mod.rs +++ b/pallets/communities/src/tests/mod.rs @@ -1,13 +1,1110 @@ -use frame_support::assert_ok; +use frame_support::{assert_noop, assert_ok, traits::Hooks}; +use sp_runtime::traits::{BlakeTwo256, Hash as _}; -mod helpers; +use frame_contrib_traits::memberships::GenericRank; +use sp_core::H256; use crate::mock::*; -use helpers::*; +use crate::types::*; +use crate::verifier::{MembershipInputs, MerkleProof}; +use crate::{Budget, ClaimedSupport, CommunityDecisionMethod, CommunityVotes, MemberCount, Members, MerkleRoot, SubRoots, RanksTotal, UsedNullifiers}; +use fc_traits_proof_verifier::ProofVerifier; type Error = crate::Error; -mod governance; -mod membership; -mod registry; -mod weights; +#[test] +fn test_add_remove_member() { + let alice = account(1); + let bob = account(2); + + TestEnvBuilder::new() + .add_community(COMMUNITY, PrivacyLevel::Public) + .build() + .execute_with(|| { + let origin = community_origin(COMMUNITY); + + // Add alice + assert_ok!(Communities::add_member(origin.clone(), alice.clone(), None, None)); + assert!(Members::::get(COMMUNITY, &alice).is_some()); + assert_eq!(MemberCount::::get(COMMUNITY), 1); + + // Add bob + assert_ok!(Communities::add_member(origin.clone(), bob.clone(), None, None)); + assert_eq!(MemberCount::::get(COMMUNITY), 2); + + // Cannot add alice again + assert_noop!( + Communities::add_member(origin.clone(), alice.clone(), None, None), + Error::AlreadyMember + ); + + // Remove alice + assert_ok!(Communities::remove_member(origin.clone(), alice.clone())); + assert!(Members::::get(COMMUNITY, &alice).is_none()); + assert_eq!(MemberCount::::get(COMMUNITY), 1); + + // Cannot remove alice again + assert_noop!( + Communities::remove_member(origin.clone(), alice.clone()), + Error::NotAMember + ); + }); +} + +#[test] +fn test_add_member_with_rank_and_role() { + let alice = account(1); + + TestEnvBuilder::new() + .add_community(COMMUNITY, PrivacyLevel::Public) + .build() + .execute_with(|| { + let origin = community_origin(COMMUNITY); + + assert_ok!(Communities::add_member( + origin.clone(), + alice.clone(), + Some(GenericRank::from(3u8)), + Some(Role::Admin), + )); + + let record = Members::::get(COMMUNITY, &alice).unwrap(); + assert_eq!(record.role, Role::Admin); + let rank_val: u32 = record.rank.into(); + assert_eq!(rank_val, 3); + assert_eq!(RanksTotal::::get(COMMUNITY), 3); + }); +} + +#[test] +fn test_suspend_member() { + let alice = account(1); + + TestEnvBuilder::new() + .add_community(COMMUNITY, PrivacyLevel::Public) + .add_member(COMMUNITY, alice.clone()) + .build() + .execute_with(|| { + let origin = community_origin(COMMUNITY); + assert_eq!(MemberCount::::get(COMMUNITY), 1); + + // Suspend alice + assert_ok!(Communities::suspend_member(origin.clone(), alice.clone())); + + let record = Members::::get(COMMUNITY, &alice).unwrap(); + assert_eq!(record.status, MemberStatus::Suspended); + assert_eq!(record.nonce, 1); + // Suspended members don't count + assert_eq!(MemberCount::::get(COMMUNITY), 0); + + // Cannot suspend again + assert_noop!( + Communities::suspend_member(origin.clone(), alice.clone()), + Error::MemberIsSuspended + ); + }); +} + +#[test] +fn test_merkle_root_updates() { + let alice = account(1); + let bob = account(2); + + TestEnvBuilder::new() + .add_community(COMMUNITY, PrivacyLevel::Public) + .build() + .execute_with(|| { + let origin = community_origin(COMMUNITY); + + // No root initially + assert!(MerkleRoot::::get(COMMUNITY).is_none()); + + // Add alice, root should be set + assert_ok!(Communities::add_member(origin.clone(), alice.clone(), None, None)); + let root1 = MerkleRoot::::get(COMMUNITY); + assert!(root1.is_some()); + + // Add bob, root should change + assert_ok!(Communities::add_member(origin.clone(), bob.clone(), None, None)); + let root2 = MerkleRoot::::get(COMMUNITY); + assert!(root2.is_some()); + assert_ne!(root1, root2); + + // Remove alice, root should change + assert_ok!(Communities::remove_member(origin.clone(), alice.clone())); + let root3 = MerkleRoot::::get(COMMUNITY); + assert!(root3.is_some()); + assert_ne!(root2, root3); + + // Remove bob, root should be removed (no active members) + assert_ok!(Communities::remove_member(origin.clone(), bob.clone())); + assert!(MerkleRoot::::get(COMMUNITY).is_none()); + }); +} + +#[test] +fn test_private_community_root_update() { + TestEnvBuilder::new() + .add_community(COMMUNITY, PrivacyLevel::Private) + .build() + .execute_with(|| { + let origin = community_origin(COMMUNITY); + + let fake_root = ::hash_of(b"test_root"); + + assert_ok!(Communities::update_membership_root( + origin.clone(), + fake_root, + 42, + )); + + assert_eq!(MerkleRoot::::get(COMMUNITY), Some(fake_root)); + // H3 fix: the admin-supplied number lands in ClaimedSupport, not MemberCount. + assert_eq!(ClaimedSupport::::get(COMMUNITY), 42); + assert_eq!( + MemberCount::::get(COMMUNITY), + 0, + "MemberCount must not be writable by update_membership_root" + ); + }); +} + +#[test] +fn test_m2_suspend_clears_root_on_private_community() { + // Without this, a suspended member's old merkle proof remains valid against the + // stale root until the admin manually republishes. Fail-closed: clear the root on + // any on-chain membership change for Private/Hybrid communities so the extension + // rejects proofs with NO_MEMBERSHIP_ROOT until the admin republishes. + let alice = account(1); + + TestEnvBuilder::new() + .add_community(COMMUNITY, PrivacyLevel::Hybrid) + .build() + .execute_with(|| { + let origin = community_origin(COMMUNITY); + // Seed a member on-chain so we have something to suspend. + crate::Members::::insert( + COMMUNITY, + &alice, + MemberRecord { + rank: GenericRank::default(), + role: Role::Member, + ..Default::default() + }, + ); + MemberCount::::insert(COMMUNITY, 1); + + let published_root = + ::hash_of(b"off-chain tree"); + assert_ok!(Communities::update_membership_root( + origin.clone(), + published_root, + 100, + )); + assert_eq!(MerkleRoot::::get(COMMUNITY), Some(published_root)); + + assert_ok!(Communities::suspend_member(origin.clone(), alice.clone())); + assert!( + MerkleRoot::::get(COMMUNITY).is_none(), + "on-chain suspension must invalidate the published root until admin republishes" + ); + }); +} + +#[test] +fn test_cannot_update_root_on_public_community() { + TestEnvBuilder::new() + .add_community(COMMUNITY, PrivacyLevel::Public) + .build() + .execute_with(|| { + let origin = community_origin(COMMUNITY); + let fake_root = ::hash_of(b"test_root"); + + assert_noop!( + Communities::update_membership_root(origin.clone(), fake_root, 10), + Error::CommunityIsPublic + ); + }); +} + +#[test] +fn test_cannot_add_member_to_private_community() { + let alice = account(1); + + TestEnvBuilder::new() + .add_community(COMMUNITY, PrivacyLevel::Private) + .build() + .execute_with(|| { + let origin = community_origin(COMMUNITY); + + assert_noop!( + Communities::add_member(origin.clone(), alice.clone(), None, None), + Error::CommunityIsPrivate + ); + }); +} + +#[test] +fn test_sub_root_update() { + TestEnvBuilder::new() + .add_community(COMMUNITY, PrivacyLevel::Private) + .build() + .execute_with(|| { + let origin = community_origin(COMMUNITY); + let fake_root = ::hash_of(b"sub_root"); + + assert_ok!(Communities::update_sub_root(origin.clone(), 42, fake_root)); + assert_eq!(SubRoots::::get(COMMUNITY, 42), Some(fake_root)); + }); +} + +#[test] +fn test_role_default_is_member() { + let alice = account(1); + + TestEnvBuilder::new() + .add_community(COMMUNITY, PrivacyLevel::Public) + .add_member(COMMUNITY, alice.clone()) + .build() + .execute_with(|| { + let record = Members::::get(COMMUNITY, &alice).unwrap(); + assert_eq!(record.role, Role::Member); + }); +} + +#[test] +fn test_promote_demote_updates_merkle_root() { + let alice = account(1); + + TestEnvBuilder::new() + .add_community(COMMUNITY, PrivacyLevel::Public) + .add_member(COMMUNITY, alice.clone()) + .build() + .execute_with(|| { + let origin = community_origin(COMMUNITY); + let root_before = MerkleRoot::::get(COMMUNITY); + + assert_ok!(Communities::promote(origin.clone(), alice.clone())); + let root_after_promote = MerkleRoot::::get(COMMUNITY); + assert_ne!(root_before, root_after_promote); + + assert_ok!(Communities::demote(origin.clone(), alice.clone())); + let root_after_demote = MerkleRoot::::get(COMMUNITY); + assert_ne!(root_after_promote, root_after_demote); + // After demote back to 0, should match original root + assert_eq!(root_before, root_after_demote); + }); +} + +#[test] +fn test_set_budget() { + TestEnvBuilder::new() + .add_community(COMMUNITY, PrivacyLevel::Public) + .build() + .execute_with(|| { + let origin = community_origin(COMMUNITY); + + assert_ok!(Communities::set_budget(origin.clone(), 1000, 100)); + + let budget = Budget::::get(COMMUNITY).expect("budget should exist"); + assert_eq!(budget.capacity, 1000); + assert_eq!(budget.used, 0); + assert_eq!(budget.session_length, 100); + assert_eq!(budget.session_start, 1); // block 1 from test setup + }); +} + +#[test] +fn test_budget_check_and_burn() { + TestEnvBuilder::new() + .add_community(COMMUNITY, PrivacyLevel::Public) + .build() + .execute_with(|| { + let origin = community_origin(COMMUNITY); + + assert_ok!(Communities::set_budget(origin.clone(), 1000, 100)); + + // Check budget available + let remaining = Communities::check_budget(&COMMUNITY, 200).expect("should have budget"); + assert_eq!(remaining, 800); + + // Burn some budget + Communities::burn_budget(&COMMUNITY, 300); + let budget = Budget::::get(COMMUNITY).unwrap(); + assert_eq!(budget.used, 300); + + // Check again with reduced budget + let remaining = Communities::check_budget(&COMMUNITY, 200).expect("should have budget"); + assert_eq!(remaining, 500); + + // Refund some + Communities::refund_budget(&COMMUNITY, 100); + let budget = Budget::::get(COMMUNITY).unwrap(); + assert_eq!(budget.used, 200); + }); +} + +#[test] +fn test_budget_session_reset() { + TestEnvBuilder::new() + .add_community(COMMUNITY, PrivacyLevel::Public) + .build() + .execute_with(|| { + let origin = community_origin(COMMUNITY); + + // Set budget at block 1 with session length 10 + assert_ok!(Communities::set_budget(origin.clone(), 1000, 10)); + + // Burn some budget + Communities::burn_budget(&COMMUNITY, 500); + assert_eq!(Budget::::get(COMMUNITY).unwrap().used, 500); + + // Advance past session end (block 1 + 10 = 11) + frame_system::Pallet::::set_block_number(11); + + // check_budget should reset the session + let remaining = Communities::check_budget(&COMMUNITY, 100).expect("should have budget"); + assert_eq!(remaining, 900); // full capacity minus cost + + // burn_budget should also reset the session + frame_system::Pallet::::set_block_number(22); + Communities::burn_budget(&COMMUNITY, 200); + let budget = Budget::::get(COMMUNITY).unwrap(); + assert_eq!(budget.used, 200); + assert_eq!(budget.session_start, 22); + }); +} + +#[test] +fn test_budget_exhaustion() { + TestEnvBuilder::new() + .add_community(COMMUNITY, PrivacyLevel::Public) + .build() + .execute_with(|| { + let origin = community_origin(COMMUNITY); + + assert_ok!(Communities::set_budget(origin.clone(), 100, 50)); + + // Burn all budget + Communities::burn_budget(&COMMUNITY, 100); + + // Check should fail + assert!(Communities::check_budget(&COMMUNITY, 1).is_err()); + + // Check on community without budget should also fail + assert!(Communities::check_budget(&999, 1).is_err()); + }); +} + +#[test] +fn test_anonymous_membership_proof_validation() { + let alice = account(1); + let bob = account(2); + let charlie = account(3); + + TestEnvBuilder::new() + .add_community(COMMUNITY, PrivacyLevel::Public) + .add_member(COMMUNITY, alice.clone()) + .add_member(COMMUNITY, bob.clone()) + .add_member(COMMUNITY, charlie.clone()) + .build() + .execute_with(|| { + // The merkle root should be set + let root = MerkleRoot::::get(COMMUNITY).expect("root should exist"); + + // Compute the leaves the same way recompute_merkle_root does + let mut leaves: alloc::vec::Vec = + Members::::iter_prefix(COMMUNITY) + .filter(|(_, record)| record.status == MemberStatus::Active) + .map(|(who, record)| { + BlakeTwo256::hash_of(&(who, COMMUNITY, record.rank, record.nonce)) + }) + .collect(); + leaves.sort(); + + // Find alice's leaf + let alice_record = Members::::get(COMMUNITY, &alice).unwrap(); + let alice_leaf = + BlakeTwo256::hash_of(&(alice.clone(), COMMUNITY, alice_record.rank, alice_record.nonce)); + let alice_index = leaves.iter().position(|l| l == &alice_leaf).expect("alice leaf in tree"); + + // Generate merkle proof + let bmt_proof = binary_merkle_tree::merkle_proof::( + leaves.iter().map(|l| l.as_ref()), + alice_index as u32, + ); + + // The proof root should match the stored root + assert_eq!(bmt_proof.root, root, "proof root must match stored root"); + + // Verify valid proof via the ProofVerifier trait + let proof = MerkleProof:: { + leaf: alice_leaf, + siblings: bmt_proof.proof.clone(), + leaf_index: bmt_proof.leaf_index as u32, + leaf_count: bmt_proof.number_of_leaves as u32, + }; + let public_inputs = MembershipInputs:: { root }; + assert!( + as ProofVerifier>::verify( + &(), + &proof, + &public_inputs, + ).is_ok(), + "merkle proof should be valid for alice", + ); + + // Verify with wrong leaf should fail + let wrong_leaf = BlakeTwo256::hash_of(b"wrong"); + let bad_proof = MerkleProof:: { + leaf: wrong_leaf, + siblings: bmt_proof.proof, + leaf_index: bmt_proof.leaf_index as u32, + leaf_count: bmt_proof.number_of_leaves as u32, + }; + assert!( + as ProofVerifier>::verify( + &(), + &bad_proof, + &public_inputs, + ).is_err(), + "merkle proof should be invalid for wrong leaf", + ); + }); +} + +#[test] +fn test_nullifier_prevents_replay() { + use sp_core::H256; + + TestEnvBuilder::new() + .add_community(COMMUNITY, PrivacyLevel::Public) + .build() + .execute_with(|| { + let action_scope = H256::from([0xAA; 32]); + let nullifier = H256::from([0xBB; 32]); + + // Initially the nullifier should not exist + assert!( + !UsedNullifiers::::contains_key((&COMMUNITY, &action_scope, &nullifier)) + ); + + // Insert the nullifier + UsedNullifiers::::insert((&COMMUNITY, &action_scope, &nullifier), ()); + + // Now it should be detected + assert!( + UsedNullifiers::::contains_key((&COMMUNITY, &action_scope, &nullifier)) + ); + + // A different action_scope should not be affected + let other_scope = H256::from([0xCC; 32]); + assert!( + !UsedNullifiers::::contains_key((&COMMUNITY, &other_scope, &nullifier)) + ); + + // A different community should not be affected + assert!( + !UsedNullifiers::::contains_key((&999u32, &action_scope, &nullifier)) + ); + }); +} + +// Helper to create a track, submit a referendum, and advance to decision phase +fn setup_poll(community_id: CommunityId) -> u32 { + use codec::Encode; + use frame_support::traits::OriginTrait; + use fc_pallet_referenda_tracks::SplitId; + use pallet_referenda::{BoundedCallOf, Curve, TrackInfo, TrackInfoOf}; + use sp_runtime::{str_array as s, BoundedVec, Perbill}; + + let track_info: TrackInfoOf = TrackInfo { + name: s("Community"), + max_deciding: 1, + decision_deposit: 5, + prepare_period: 1, + decision_period: 5, + confirm_period: 1, + min_enactment_period: 1, + min_approval: Curve::LinearDecreasing { + length: Perbill::from_percent(100), + floor: Perbill::from_percent(50), + ceil: Perbill::from_percent(100), + }, + min_support: Curve::LinearDecreasing { + length: Perbill::from_percent(100), + floor: Perbill::from_percent(0), + ceil: Perbill::from_percent(100), + }, + }; + + let community_origin_caller = community_origin(community_id).caller().clone(); + + // Directly insert track storage for the exact track_id = community_id + let track_id: CommunityId = community_id; + let (group, sub_track) = track_id.split(); + + fc_pallet_referenda_tracks::TracksIds::::try_mutate(|ids| ids.try_insert(track_id)) + .expect("can insert track id"); + fc_pallet_referenda_tracks::Tracks::::set(group, sub_track, Some(track_info)); + fc_pallet_referenda_tracks::OriginToTrackId::::set( + community_origin_caller.clone(), + Some(track_id), + ); + fc_pallet_referenda_tracks::TrackIdToOrigin::::set( + track_id, + Some(community_origin_caller.clone()), + ); + + // Need a funded account to submit and deposit + let submitter = account(99); + assert_ok!(Balances::force_set_balance( + RuntimeOrigin::root(), + submitter.clone(), + 100 + )); + + // Create a dummy proposal call + let call: RuntimeCall = crate::Call::::set_decision_method { + community_id, + decision_method: DecisionMethod::Membership, + } + .into(); + let proposal = BoundedCallOf::::Inline(BoundedVec::truncate_from(call.encode())); + + assert_ok!(Referenda::submit( + RuntimeOrigin::signed(submitter.clone()), + Box::new(community_origin_caller), + proposal, + frame_support::traits::schedule::DispatchTime::After(1), + )); + + // Find the poll index from events + let poll_index = 0u32; // First referendum + + assert_ok!(Referenda::place_decision_deposit( + RuntimeOrigin::signed(submitter), + poll_index + )); + + // Advance to decision phase + System::set_block_number(System::block_number() + 1); + Referenda::on_initialize(System::block_number()); + Scheduler::on_initialize(System::block_number()); + + poll_index +} + +fn anon_origin(community_id: CommunityId, rank: GenericRank, nullifier: H256) -> RuntimeOrigin { + let mut raw = crate::origin::RawOrigin::::new(community_id); + raw.with_subset(crate::origin::Subset::AnonymousMember { rank, nullifier }); + raw.into() +} + +#[test] +fn test_named_vote_still_works() { + let alice = account(1); + + TestEnvBuilder::new() + .add_community(COMMUNITY, PrivacyLevel::Public) + .add_member(COMMUNITY, alice.clone()) + .build() + .execute_with(|| { + let poll_index = setup_poll(COMMUNITY); + + // Named vote should work + assert_ok!(Communities::vote( + RuntimeOrigin::signed(alice.clone()), + poll_index, + Vote::Standard(true), + )); + + // Verify vote was recorded with hash of account as key + let voter_key = BlakeTwo256::hash_of(&alice); + assert!(CommunityVotes::::get(poll_index, &voter_key).is_some()); + + // Verify event + System::assert_has_event( + crate::Event::::VoteCasted { + who: Some(alice.clone()), + poll_index, + vote: Vote::Standard(true), + } + .into(), + ); + }); +} + +#[test] +fn test_anonymous_vote_uses_nullifier_key() { + let alice = account(1); + + TestEnvBuilder::new() + .add_community(COMMUNITY, PrivacyLevel::Public) + .add_member(COMMUNITY, alice.clone()) + .build() + .execute_with(|| { + let poll_index = setup_poll(COMMUNITY); + let nullifier = H256::from_low_u64_be(42); + + // Anonymous vote with membership decision method + let origin = anon_origin(COMMUNITY, GenericRank::default(), nullifier); + assert_ok!(Communities::vote( + origin, + poll_index, + Vote::Standard(true), + )); + + // Verify vote was recorded with hash of nullifier as key + let voter_key = BlakeTwo256::hash_of(&nullifier); + let (vote, multiplied) = CommunityVotes::::get(poll_index, &voter_key) + .expect("vote should be stored"); + assert_eq!(vote, Vote::Standard(true)); + assert_eq!(multiplied, 1); // membership = 1x multiplier + + // Verify event has None for who (anonymous) + System::assert_has_event( + crate::Event::::VoteCasted { + who: None, + poll_index, + vote: Vote::Standard(true), + } + .into(), + ); + }); +} + +#[test] +fn test_anonymous_vote_rejects_rank_weighted_decision() { + // Rank-weighted voting cannot be authorized anonymously: the merkle proof doesn't + // bind the leaf's rank, so the multiplier would be user-chosen and forgeable. + // Only flat `Membership` voting is allowed on the anonymous path until a ZK backend + // makes rank a verified public input. (Issue C2 from review.) + let alice = account(1); + + TestEnvBuilder::new() + .add_community(COMMUNITY, PrivacyLevel::Public) + .add_member(COMMUNITY, alice.clone()) + .build() + .execute_with(|| { + CommunityDecisionMethod::::set(COMMUNITY, DecisionMethod::Rank); + + let poll_index = setup_poll(COMMUNITY); + let nullifier = H256::from_low_u64_be(100); + + let origin = anon_origin(COMMUNITY, GenericRank::from(3u8), nullifier); + assert_noop!( + Communities::vote(origin, poll_index, Vote::Standard(true)), + Error::InvalidVoteType, + ); + }); +} + +#[test] +fn test_anonymous_vote_rank_from_origin_is_ignored() { + // Even when a synthesised anonymous origin carries a high rank, the vote weight + // is always 1 under DecisionMethod::Membership. (Issue C2 from review.) + let alice = account(1); + + TestEnvBuilder::new() + .add_community(COMMUNITY, PrivacyLevel::Public) + .add_member(COMMUNITY, alice.clone()) + .build() + .execute_with(|| { + let poll_index = setup_poll(COMMUNITY); + let nullifier = H256::from_low_u64_be(777); + + // A caller who managed to construct an AnonymousMember origin directly with + // rank=100 still gets weight 1 — the pallet ignores the origin's rank field. + let origin = anon_origin(COMMUNITY, GenericRank::from(100u8), nullifier); + assert_ok!(Communities::vote(origin, poll_index, Vote::Standard(true))); + + let voter_key = BlakeTwo256::hash_of(&nullifier); + let (_, multiplied) = CommunityVotes::::get(poll_index, &voter_key) + .expect("vote should be stored"); + assert_eq!(multiplied, 1, "anonymous vote must be rank-1 regardless of origin rank"); + }); +} + +#[test] +fn test_anonymous_vote_token_weighted_rejected() { + let alice = account(1); + + TestEnvBuilder::new() + .add_community(COMMUNITY, PrivacyLevel::Public) + .add_member(COMMUNITY, alice.clone()) + .build() + .execute_with(|| { + // Set decision method to NativeToken + CommunityDecisionMethod::::set(COMMUNITY, DecisionMethod::NativeToken); + + let poll_index = setup_poll(COMMUNITY); + let nullifier = H256::from_low_u64_be(200); + + // Anonymous vote with NativeToken should fail + let origin = anon_origin(COMMUNITY, GenericRank::default(), nullifier); + assert_noop!( + Communities::vote( + origin, + poll_index, + Vote::Standard(true), + ), + Error::InvalidVoteType + ); + }); +} + +#[test] +fn test_anonymous_vote_duplicate_nullifier_rejected() { + let alice = account(1); + + TestEnvBuilder::new() + .add_community(COMMUNITY, PrivacyLevel::Public) + .add_member(COMMUNITY, alice.clone()) + .build() + .execute_with(|| { + let poll_index = setup_poll(COMMUNITY); + let nullifier = H256::from_low_u64_be(300); + + // First anonymous vote succeeds + let origin = anon_origin(COMMUNITY, GenericRank::default(), nullifier); + assert_ok!(Communities::vote( + origin, + poll_index, + Vote::Standard(true), + )); + + // Second anonymous vote with same nullifier should fail. + let origin2 = anon_origin(COMMUNITY, GenericRank::default(), nullifier); + assert_noop!( + Communities::vote( + origin2, + poll_index, + Vote::Standard(false), + ), + Error::AnonymousVoteAlreadyCast + ); + }); +} + +#[test] +fn test_anonymous_vote_different_nullifiers_both_counted() { + let alice = account(1); + + TestEnvBuilder::new() + .add_community(COMMUNITY, PrivacyLevel::Public) + .add_member(COMMUNITY, alice.clone()) + .build() + .execute_with(|| { + let poll_index = setup_poll(COMMUNITY); + let nullifier1 = H256::from_low_u64_be(400); + let nullifier2 = H256::from_low_u64_be(401); + + // Two different anonymous voters should both succeed + let origin1 = anon_origin(COMMUNITY, GenericRank::default(), nullifier1); + assert_ok!(Communities::vote( + origin1, + poll_index, + Vote::Standard(true), + )); + + let origin2 = anon_origin(COMMUNITY, GenericRank::default(), nullifier2); + assert_ok!(Communities::vote( + origin2, + poll_index, + Vote::Standard(false), + )); + + // Both votes should be stored + let key1 = BlakeTwo256::hash_of(&nullifier1); + let key2 = BlakeTwo256::hash_of(&nullifier2); + assert!(CommunityVotes::::get(poll_index, &key1).is_some()); + assert!(CommunityVotes::::get(poll_index, &key2).is_some()); + + // Check tally via Polling + use frame_support::traits::Polling; + let (tally, _) = Referenda::as_ongoing(poll_index).expect("poll should be ongoing"); + assert_eq!(tally.ayes, 1); + assert_eq!(tally.nays, 1); + assert_eq!(tally.bare_ayes, 1); + }); +} + +// ---- Adversarial tests for the critical issues flagged in the review ---- +// +// These tests target the escalation paths the merkle-only MVP previously allowed. +// If any of them regresses in the future, the anonymous membership scheme is unsound +// again — treat failures as security regressions, not flakes. + +#[test] +fn test_c1_anonymous_origin_cannot_manage_members() { + // An anonymous community origin must NOT be accepted by `MemberMgmtOrigin`. + // Before the C1 fix, `EnsureCommunity` destructured `RawOrigin { community_id, .. }` + // and ignored the subset, so any caller with a valid merkle proof could + // suspend/remove/promote/demote anyone in the community. + let alice = account(1); + let bob = account(2); + + TestEnvBuilder::new() + .add_community(COMMUNITY, PrivacyLevel::Public) + .add_member(COMMUNITY, alice.clone()) + .add_member(COMMUNITY, bob.clone()) + .build() + .execute_with(|| { + let anon = anon_origin(COMMUNITY, GenericRank::default(), H256::from_low_u64_be(1)); + + // Every member-management call must reject the anonymous origin with BadOrigin. + assert_noop!( + Communities::suspend_member(anon.clone(), bob.clone()), + sp_runtime::DispatchError::BadOrigin + ); + assert_noop!( + Communities::remove_member(anon.clone(), bob.clone()), + sp_runtime::DispatchError::BadOrigin + ); + assert_noop!( + Communities::promote(anon.clone(), bob.clone()), + sp_runtime::DispatchError::BadOrigin + ); + assert_noop!( + Communities::demote(anon.clone(), bob.clone()), + sp_runtime::DispatchError::BadOrigin + ); + assert_noop!( + Communities::add_member(anon.clone(), account(42), None, None), + sp_runtime::DispatchError::BadOrigin + ); + + // Bob must still be an active member — no state was mutated. + assert!(Members::::get(COMMUNITY, &bob).is_some()); + assert_eq!( + Members::::get(COMMUNITY, &bob).unwrap().status, + MemberStatus::Active, + ); + }); +} + +#[test] +fn test_c1_anonymous_origin_cannot_call_admin_functions() { + // The same escalation route also guarded admin-only functions via `AdminOrigin`. + TestEnvBuilder::new() + .add_community(COMMUNITY, PrivacyLevel::Public) + .build() + .execute_with(|| { + let anon = anon_origin(COMMUNITY, GenericRank::default(), H256::from_low_u64_be(2)); + let fake_root = BlakeTwo256::hash_of(b"whatever"); + + assert_noop!( + Communities::update_membership_root(anon.clone(), fake_root, 0), + sp_runtime::DispatchError::BadOrigin + ); + assert_noop!( + Communities::update_sub_root(anon.clone(), 1, fake_root), + sp_runtime::DispatchError::BadOrigin + ); + assert_noop!( + Communities::set_budget(anon.clone(), 1_000_000, 100), + sp_runtime::DispatchError::BadOrigin + ); + assert_noop!( + Communities::set_decision_method(anon, COMMUNITY, DecisionMethod::Membership), + sp_runtime::DispatchError::BadOrigin + ); + }); +} + +#[test] +fn test_c1_anonymous_origin_cannot_dispatch_as_account() { + // The most damaging escalation: dispatching as the community's keyless account + // would allow draining the treasury. `MemberMgmtOrigin` must reject anonymous. + TestEnvBuilder::new() + .add_community(COMMUNITY, PrivacyLevel::Public) + .build() + .execute_with(|| { + let anon = anon_origin(COMMUNITY, GenericRank::default(), H256::from_low_u64_be(3)); + // Any inner call works here; we're asserting the outer origin check rejects. + let inner: RuntimeCall = crate::Call::::set_decision_method { + community_id: COMMUNITY, + decision_method: DecisionMethod::Membership, + } + .into(); + assert_noop!( + Communities::dispatch_as_account(anon, Box::new(inner)), + sp_runtime::DispatchError::BadOrigin + ); + }); +} + +#[test] +fn test_m3_member_role_enforced_on_signed_caller() { + // A signed account that is only a plain Member must not be able to use the + // member-management extrinsics even if they pass the configured MemberMgmtOrigin. + let alice = account(1); // plain Member + let bob = account(2); + + TestEnvBuilder::new() + .add_community(COMMUNITY, PrivacyLevel::Public) + .build() + .execute_with(|| { + let cmty = community_origin(COMMUNITY); + assert_ok!(Communities::add_member( + cmty.clone(), + alice.clone(), + None, + None, + )); + assert_ok!(Communities::add_member( + cmty.clone(), + bob.clone(), + None, + None, + )); + // Hook alice's signed origin into the mgmt guard by registering her as the + // community's admin origin caller in CommunityIdFor. + let alice_origin = RuntimeOrigin::signed(alice.clone()); + use frame_support::traits::OriginTrait; + crate::CommunityIdFor::::insert(alice_origin.caller().clone(), COMMUNITY); + + // The role check must block a plain-Member caller from suspending someone. + assert_noop!( + Communities::suspend_member(alice_origin, bob.clone()), + Error::NotAuthorized, + ); + }); +} + +#[test] +fn test_m3_manager_role_can_manage_members() { + let manager = account(1); + let bob = account(2); + + TestEnvBuilder::new() + .add_community(COMMUNITY, PrivacyLevel::Public) + .build() + .execute_with(|| { + let cmty = community_origin(COMMUNITY); + assert_ok!(Communities::add_member( + cmty.clone(), + manager.clone(), + None, + Some(Role::Manager), + )); + assert_ok!(Communities::add_member(cmty, bob.clone(), None, None)); + + let mgr_origin = RuntimeOrigin::signed(manager.clone()); + use frame_support::traits::OriginTrait; + crate::CommunityIdFor::::insert(mgr_origin.caller().clone(), COMMUNITY); + + assert_ok!(Communities::suspend_member(mgr_origin, bob.clone())); + assert_eq!( + Members::::get(COMMUNITY, &bob).unwrap().status, + MemberStatus::Suspended, + ); + }); +} + +#[test] +fn test_m8_prune_vote_removes_stale_entry() { + let voter_key = BlakeTwo256::hash_of(&account(7)); + TestEnvBuilder::new() + .add_community(COMMUNITY, PrivacyLevel::Public) + .build() + .execute_with(|| { + // Poll index 999 does not exist (no setup_poll), so Polls::as_ongoing returns + // None — mimicking a finished/purged poll. + let stored: (VoteOf, VoteWeight) = (Vote::Standard(true), 1); + CommunityVotes::::insert(999u32, &voter_key, stored); + + // Pruning must be permissionless for ended polls. + let random = account(123); + assert_ok!(Communities::prune_vote( + RuntimeOrigin::signed(random), + 999, + voter_key, + )); + assert!(CommunityVotes::::get(999u32, &voter_key).is_none()); + }); +} + +#[test] +fn test_m8_prune_vote_rejected_while_poll_ongoing() { + let alice = account(1); + + TestEnvBuilder::new() + .add_community(COMMUNITY, PrivacyLevel::Public) + .add_member(COMMUNITY, alice.clone()) + .build() + .execute_with(|| { + let poll_index = setup_poll(COMMUNITY); + assert_ok!(Communities::vote( + RuntimeOrigin::signed(alice.clone()), + poll_index, + Vote::Standard(true), + )); + let voter_key = BlakeTwo256::hash_of(&alice); + assert_noop!( + Communities::prune_vote( + RuntimeOrigin::signed(account(99)), + poll_index, + voter_key, + ), + Error::AlreadyOngoing, + ); + }); +} + +#[test] +fn test_m9_remove_sub_root() { + TestEnvBuilder::new() + .add_community(COMMUNITY, PrivacyLevel::Private) + .build() + .execute_with(|| { + let origin = community_origin(COMMUNITY); + let root = BlakeTwo256::hash_of(b"x"); + assert_ok!(Communities::update_sub_root(origin.clone(), 7, root)); + assert_eq!(SubRoots::::get(COMMUNITY, 7), Some(root)); + assert_ok!(Communities::remove_sub_root(origin, 7)); + assert!(SubRoots::::get(COMMUNITY, 7).is_none()); + }); +} + +#[test] +fn test_set_budget_rejects_zero_session_length() { + TestEnvBuilder::new() + .add_community(COMMUNITY, PrivacyLevel::Public) + .build() + .execute_with(|| { + let origin = community_origin(COMMUNITY); + assert_noop!( + Communities::set_budget(origin, 1000, 0), + Error::InvalidBudget, + ); + }); +} + +#[test] +fn test_c4_action_scope_derived_from_call() { + // The extension must derive the nullifier's action-scope from the dispatched call, + // not accept it from the caller. Same caller, same leaf, different call should + // produce different nullifiers. We validate by exercising the same hashing the + // extension would use and asserting the nullifiers differ. + use codec::Encode; + + let call_a: RuntimeCall = crate::Call::::vote { + poll_index: 0, + vote: Vote::Standard(true), + } + .into(); + let call_b: RuntimeCall = crate::Call::::vote { + poll_index: 1, + vote: Vote::Standard(true), + } + .into(); + + let scope_a: H256 = sp_io::hashing::blake2_256(&call_a.encode()).into(); + let scope_b: H256 = sp_io::hashing::blake2_256(&call_b.encode()).into(); + assert_ne!( + scope_a, scope_b, + "different calls must produce different action scopes" + ); +} diff --git a/pallets/communities/src/types.rs b/pallets/communities/src/types.rs index 5870b58..395f369 100644 --- a/pallets/communities/src/types.rs +++ b/pallets/communities/src/types.rs @@ -1,7 +1,7 @@ use super::*; use crate::{CommunityDecisionMethod, Config}; -use frame_contrib_traits::memberships::{Inspect, Rank}; +use frame_contrib_traits::memberships::GenericRank; use frame_support::traits::{ fungible::{self, Inspect as FunInspect}, @@ -23,7 +23,6 @@ pub type PollIndexOf = <::Polls as Polling>>::Index; pub type AccountIdLookupOf = <::Lookup as StaticLookup>::Source; pub type PalletsOriginOf = <::RuntimeOrigin as OriginTrait>::PalletsOrigin; -pub type MembershipIdOf = ::MembershipId; pub type RuntimeCallFor = ::RuntimeCall; pub type RuntimeOriginFor = ::RuntimeOrigin; pub type BlockNumberFor = @@ -32,6 +31,108 @@ pub type BlockNumberFor = #[cfg(feature = "runtime-benchmarks")] pub type BenchmarkHelperOf = ::BenchmarkHelper; +/// Privacy level determines what membership data is stored on-chain +#[derive( + Clone, + Debug, + Decode, + DecodeWithMemTracking, + Default, + Encode, + Eq, + MaxEncodedLen, + PartialEq, + TypeInfo, +)] +#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))] +pub enum PrivacyLevel { + #[default] + Public, + Private, + Hybrid, +} + +/// Status of a community member +#[derive( + Clone, + Debug, + Decode, + DecodeWithMemTracking, + Default, + Encode, + Eq, + MaxEncodedLen, + PartialEq, + TypeInfo, +)] +#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))] +pub enum MemberStatus { + #[default] + Active, + Suspended, +} + +/// Role of a community member +#[derive( + Clone, + Debug, + Decode, + DecodeWithMemTracking, + Default, + Encode, + Eq, + MaxEncodedLen, + PartialEq, + TypeInfo, +)] +#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))] +pub enum Role { + Admin, + Manager, + #[default] + Member, +} + +/// A member's on-chain record +#[derive( + Clone, + Debug, + Decode, + DecodeWithMemTracking, + Default, + Encode, + Eq, + MaxEncodedLen, + PartialEq, + TypeInfo, +)] +pub struct MemberRecord { + pub rank: GenericRank, + pub nonce: u32, + pub status: MemberStatus, + pub role: Role, +} + +/// Gas/transaction budget for a community per session +#[derive( + Clone, + Debug, + Decode, + DecodeWithMemTracking, + Default, + Encode, + Eq, + MaxEncodedLen, + PartialEq, + TypeInfo, +)] +pub struct CommunityBudget { + pub capacity: u64, + pub used: u64, + pub session_start: BlockNumber, + pub session_length: BlockNumber, +} + /// The Community struct holds the basic definition of a community. It includes /// the current state of a community, the [`AccountId`][1] for the community /// admin, and (if any) the ID of the community-issued asset the community has @@ -42,6 +143,10 @@ pub type BenchmarkHelperOf = ::BenchmarkHelper; pub struct CommunityInfo { /// The current state of the community. pub state: CommunityState, + /// The privacy level of the community. + pub privacy: PrivacyLevel, + /// Maximum number of members. + pub capacity: u32, } /// The current state of the community. It represents whether a community @@ -151,8 +256,8 @@ impl Default for Tally { impl Tally { pub(crate) fn max_support(community_id: CommunityIdOf) -> VoteWeight { match CommunityDecisionMethod::::get(community_id) { - DecisionMethod::Membership => T::MemberMgmt::members_total(&community_id), - DecisionMethod::Rank => T::MemberMgmt::ranks_total(&community_id), + DecisionMethod::Membership => membership_denominator::(community_id), + DecisionMethod::Rank => crate::RanksTotal::::get(community_id), DecisionMethod::NativeToken => { T::Balances::total_issuance().saturated_into::() } @@ -163,6 +268,25 @@ impl Tally { } } +/// Denominator for `DecisionMethod::Membership` support. +/// - Public: the on-chain [`crate::MemberCount`], which the admin cannot set arbitrarily. +/// - Private/Hybrid: the admin-declared [`crate::ClaimedSupport`]. If never set, falls +/// back to the on-chain member count so freshly-bootstrapped communities don't render +/// referenda unreachable. +fn membership_denominator(community_id: CommunityIdOf) -> VoteWeight { + match crate::Info::::get(community_id).map(|i| i.privacy) { + Some(crate::PrivacyLevel::Public) | None => crate::MemberCount::::get(community_id), + Some(_) => { + let declared = crate::ClaimedSupport::::get(community_id); + if declared > 0 { + declared + } else { + crate::MemberCount::::get(community_id) + } + } + } +} + #[derive(PartialEq)] pub enum LockUpdateType { Add, @@ -191,15 +315,8 @@ pub trait BenchmarkHelper { u8::MAX as u32 } - /// Initializes the membership collection of a community. - fn initialize_memberships_collection() -> Result<(), frame_benchmarking::BenchmarkError>; - - /// Extends the membership collection of a community with a given - /// membership ID. - fn issue_membership( - community_id: CommunityIdOf, - membership_id: MembershipIdOf, - ) -> Result<(), frame_benchmarking::BenchmarkError>; + /// Sets up members for benchmarking + fn setup_members(community_id: CommunityIdOf, count: u32) -> Result<(), BenchmarkError>; /// This method prepares the referenda track to be used /// to submit the poll, for benchmarking purposes. diff --git a/pallets/communities/src/verifier.rs b/pallets/communities/src/verifier.rs new file mode 100644 index 0000000..0b8a4dc --- /dev/null +++ b/pallets/communities/src/verifier.rs @@ -0,0 +1,61 @@ +//! Default merkle proof verifier for membership proofs. + +use alloc::vec::Vec; +use codec::{Decode, DecodeWithMemTracking, Encode, MaxEncodedLen}; +use fc_traits_proof_verifier::{ProofVerifier, VerifyError}; +use scale_info::TypeInfo; +use sp_runtime::traits::Hash; + +/// Merkle inclusion proof +#[derive(Clone, Debug, Encode, Decode, DecodeWithMemTracking, PartialEq, Eq, TypeInfo)] +pub struct MerkleProof { + /// The leaf hash + pub leaf: H::Output, + /// Proof siblings + pub siblings: Vec, + /// Leaf index in the tree + pub leaf_index: u32, + /// Total number of leaves + pub leaf_count: u32, +} + +/// Public inputs for membership proof verification +#[derive(Clone, Debug, Encode, Decode, DecodeWithMemTracking, PartialEq, Eq, TypeInfo, MaxEncodedLen)] +pub struct MembershipInputs { + /// The merkle root to verify against + pub root: H::Output, +} + +/// Simple merkle inclusion proof verifier (no ZK). +/// Uses binary-merkle-tree for verification. +pub struct MerkleVerifier(core::marker::PhantomData); + +impl ProofVerifier for MerkleVerifier +where + H::Output: Ord + Default, +{ + type Proof = MerkleProof; + type PublicInputs = MembershipInputs; + type ProgramId = (); // no program selection needed + + fn verify( + _program: &Self::ProgramId, + proof: &Self::Proof, + public_inputs: &Self::PublicInputs, + ) -> Result<(), VerifyError> { + // `H::Output: Copy` for all substrate hashers we care about, so the iterator-based + // form avoids the otherwise-needless clone of the siblings vec. + let valid = binary_merkle_tree::verify_proof::( + &public_inputs.root, + proof.siblings.iter().copied(), + proof.leaf_count, + proof.leaf_index, + &proof.leaf, + ); + if valid { + Ok(()) + } else { + Err(VerifyError::InvalidProof) + } + } +} diff --git a/pallets/communities/src/weights.rs b/pallets/communities/src/weights.rs index d3eb9b7..a4de98e 100644 --- a/pallets/communities/src/weights.rs +++ b/pallets/communities/src/weights.rs @@ -43,6 +43,10 @@ pub trait WeightInfo { fn remove_vote() -> Weight; fn unlock() -> Weight; fn dispatch_as_account() -> Weight; + fn suspend_member() -> Weight { Weight::zero() } + fn update_membership_root() -> Weight { Weight::zero() } + fn update_sub_root() -> Weight { Weight::zero() } + fn set_budget() -> Weight { Weight::zero() } } /// Weights for pallet_communities using the Substrate node and recommended hardware. diff --git a/traits/Cargo.toml b/traits/Cargo.toml index 5014261..6b8b9a5 100644 --- a/traits/Cargo.toml +++ b/traits/Cargo.toml @@ -12,6 +12,7 @@ fc-traits-gas-tank.workspace = true fc-traits-listings.workspace = true fc-traits-memberships.workspace = true fc-traits-payments.workspace = true +fc-traits-proof-verifier.workspace = true [features] default = ["std"] @@ -21,6 +22,7 @@ std = [ "fc-traits-listings/std", "fc-traits-memberships/std", "fc-traits-payments/std", + "fc-traits-proof-verifier/std", ] runtime-benchmarks = [ "fc-traits-gas-tank/runtime-benchmarks", diff --git a/traits/proof-verifier/Cargo.toml b/traits/proof-verifier/Cargo.toml new file mode 100644 index 0000000..7a64a5f --- /dev/null +++ b/traits/proof-verifier/Cargo.toml @@ -0,0 +1,22 @@ +[package] +authors.workspace = true +edition.workspace = true +license.workspace = true +name = "fc-traits-proof-verifier" +repository.workspace = true +version = "0.1.0" + +[dependencies] +codec = { workspace = true, features = ["derive"] } +frame-support.workspace = true +scale-info = { workspace = true, features = ["derive"] } +sp-runtime.workspace = true + +[features] +default = ["std"] +std = [ + "codec/std", + "frame-support/std", + "scale-info/std", + "sp-runtime/std", +] diff --git a/traits/proof-verifier/src/lib.rs b/traits/proof-verifier/src/lib.rs new file mode 100644 index 0000000..f852cb2 --- /dev/null +++ b/traits/proof-verifier/src/lib.rs @@ -0,0 +1,46 @@ +#![cfg_attr(not(feature = "std"), no_std)] + +use frame_support::Parameter; +use sp_runtime::DispatchError; + +/// Error from proof verification +#[derive(Debug, PartialEq, Eq)] +pub enum VerifyError { + /// The proof is invalid + InvalidProof, + /// The program/verification key is not registered + UnknownProgram, + /// Public inputs are malformed + InvalidInputs, +} + +impl From for DispatchError { + fn from(e: VerifyError) -> Self { + match e { + VerifyError::InvalidProof => DispatchError::Other("InvalidProof"), + VerifyError::UnknownProgram => DispatchError::Other("UnknownProgram"), + VerifyError::InvalidInputs => DispatchError::Other("InvalidInputs"), + } + } +} + +/// General-purpose proof verification trait. +/// +/// Implementations can range from simple merkle inclusion proofs to +/// full ZK proof verification (e.g. stwo STARKs via a PVM zkVM). +pub trait ProofVerifier { + /// The proof blob + type Proof: Parameter; + /// Public inputs/outputs visible to the verifier + type PublicInputs: Parameter; + /// Identifies the verification program (e.g. verification key, circuit ID). + /// Use `()` for verifiers that don't need program selection. + type ProgramId: Parameter; + + /// Verify a proof for the given program and public inputs. + fn verify( + program: &Self::ProgramId, + proof: &Self::Proof, + public_inputs: &Self::PublicInputs, + ) -> Result<(), VerifyError>; +} diff --git a/traits/src/lib.rs b/traits/src/lib.rs index 216be5e..33c9ffb 100644 --- a/traits/src/lib.rs +++ b/traits/src/lib.rs @@ -8,3 +8,4 @@ pub use fc_traits_gas_tank as gas_tank; pub use fc_traits_listings as listings; pub use fc_traits_memberships as memberships; pub use fc_traits_payments as payments; +pub use fc_traits_proof_verifier as proof_verifier;