diff --git a/CHANGELOG.md b/CHANGELOG.md index 696724f..2d6bf4f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,50 @@ by [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). Versions follow ## [Unreleased] +## [0.0.32] β€” 2026-05-25 β€” Phase 5.2 Council scaffold + member management (CHN-4 5.2 Technical Council) + +**πŸ›οΈ Phase 5.2 TECHNICAL COUNCIL scaffold + management canonical** β€” Extension del +existing `origin-chain-governance` pallet con Council membership management. +Pattern Phase 5.1 extension (NO new crate, evolved existing). 5-7 Council +members canonical per CHN-4 5.2. + +### Deliverables v0.0.32 + +**Council types canonical en pallet module**: +- `CouncilMember` struct (3 fields: account + joined_at_block + role) +- `CouncilRole` enum (Member + Chair) β€” Chair tie-breaking authority Phase 5.5+ +- `MAX_COUNCIL_SIZE = 7` constant canonical CHN-4 5.2 +- `council_veto_threshold(size) -> u32` const fn (2/3 ceiling division) + +**Council storage canonical**: +- `Council>` O(1) membership lookup +- `CouncilSize>` cached size for O(1) threshold check + +**Council events + errors canonical**: +- `Event::CouncilMemberAdded` + `CouncilMemberRemoved` +- `Error::NotCouncilMember` + `AlreadyCouncilMember` + `CouncilSizeExceeded` + +**Council dispatchables canonical (ensure_root governance-only)**: +- `add_council_member(origin, account, role)` 6-step canonical +- `remove_council_member(origin, account)` 5-step canonical + +**Helpers canonical**: +- `Pallet::::is_council_member(&account) -> bool` pure query +- `Pallet::::current_council_size() -> u32` +- `Pallet::::required_veto_votes() -> u32` (2/3 current size) + +### Verification end-to-end canonical + +- βœ… `cargo test -p origin-chain-governance` β€” **24 tests verde, 0 failures**: + - 7 Council scenarios (add + non-root rejected + duplicate + remove + non-member + max size + 2/3 threshold) + - 17 prior governance tests preserved (no regressions) + +### Roadmap canonical post-v0.0.32 + +| Version | Trigger | +|---------|---------| +| **v0.0.33** | **veto_proposal dispatchable + Runtime API CouncilApi β†’ Phase 5.2 COMPLETE** | + ## [0.0.31] β€” 2026-05-25 β€” Phase 5.1 FOUNDATION COMPLETE β€” Runtime API GovernanceApi.query_proposal + query_vote πŸŽ‰ **πŸŽ‰ Phase 5.1 GOVERNANCE pallet FOUNDATION COMPLETE end-to-end** β€” Runtime API diff --git a/crates/origin-chain-governance/src/lib.rs b/crates/origin-chain-governance/src/lib.rs index 1518973..62c01b8 100644 --- a/crates/origin-chain-governance/src/lib.rs +++ b/crates/origin-chain-governance/src/lib.rs @@ -124,6 +124,43 @@ pub mod pallet { Withdrawn, } + /// CouncilRole canonical β€” Technical Council member roles per CHN-4 5.2. + #[derive( + Encode, Decode, DecodeWithMemTracking, MaxEncodedLen, TypeInfo, Clone, Copy, PartialEq, Eq, + RuntimeDebug, + )] + pub enum CouncilRole { + /// Standard council member canonical (1 vote in veto threshold). + Member, + /// Council Chair canonical (1 vote + tie-breaking authority Phase 5.5+). + Chair, + } + + /// CouncilMember canonical struct per CHN-4 5.2 Technical Council. + /// + /// MaxEncodedLen canonical: ~42 bytes (32 account + 8 block + 1 role + padding). + #[derive( + Encode, Decode, DecodeWithMemTracking, MaxEncodedLen, TypeInfo, Clone, PartialEq, Eq, + RuntimeDebug, + )] + pub struct CouncilMember { + /// AccountId canonical del council member. + pub account: origin_chain_types::AccountId, + /// Block number canonical at which member joined. + pub joined_at_block: u64, + /// CouncilRole canonical (Member | Chair). + pub role: CouncilRole, + } + + /// Maximum council size canonical per CHN-4 5.2 (7 members). + pub const MAX_COUNCIL_SIZE: u32 = 7; + + /// Council veto threshold canonical canonical 2/3 per CHN-4 5.5. + /// `required_veto_votes = (council_size Γ— 2 + 2) / 3` (ceiling division 2/3). + pub const fn council_veto_threshold(council_size: u32) -> u32 { + (council_size * 2 + 2) / 3 + } + /// Proposal canonical struct stored on-chain per pallet (Foundation OIP). #[derive( Encode, Decode, DecodeWithMemTracking, MaxEncodedLen, TypeInfo, Clone, PartialEq, Eq, @@ -185,6 +222,22 @@ pub mod pallet { OptionQuery, >; + /// CHN-4 5.2 canonical: `Council>` canonical. + /// + /// O(1) canonical Council membership lookup. Max 7 members per `MAX_COUNCIL_SIZE`. + #[pallet::storage] + #[pallet::getter(fn council_member_of)] + pub type Council = + StorageMap<_, Blake2_128Concat, T::AccountId, CouncilMember, OptionQuery>; + + /// CHN-4 5.2 canonical: `CouncilSize>` current member count canonical. + /// + /// Tracked separately canonical for O(1) size check sin iterar StorageMap completo. + /// Used canonical en veto threshold calculation + add_council_member size enforcement. + #[pallet::storage] + #[pallet::getter(fn council_size)] + pub type CouncilSize = StorageValue<_, u32, ValueQuery>; + /// Events emisiΓ³n canonical (CHN-4 governance lifecycle). #[pallet::event] #[pallet::generate_deposit(pub(super) fn deposit_event)] @@ -220,6 +273,22 @@ pub mod pallet { /// Tally nay_weight canonical. nay_weight: u128, }, + /// Council member added canonical (Phase 5.2 CHN-4). + CouncilMemberAdded { + /// T::AccountId del new council member. + account: T::AccountId, + /// CouncilRole canonical (Member | Chair). + role: CouncilRole, + /// New council size canonical post-addition. + new_size: u32, + }, + /// Council member removed canonical (Phase 5.2 CHN-4). + CouncilMemberRemoved { + /// T::AccountId del removed council member. + account: T::AccountId, + /// New council size canonical post-removal. + new_size: u32, + }, } /// Pallet errors canonical. @@ -241,6 +310,12 @@ pub mod pallet { AdvisorMayNotVote, /// CHN-4 canonical: proposer canonical ya existe con ese hash content (duplicate prevention). ProposalIdCollision, + /// CHN-4 5.2 canonical: account NO es council member (cannot veto). + NotCouncilMember, + /// CHN-4 5.2 canonical: account ya council member (duplicate add prevention). + AlreadyCouncilMember, + /// CHN-4 5.2 canonical: council size canonical exceeds MAX_COUNCIL_SIZE. + CouncilSizeExceeded, } /// Minimum stake canonical proposal submission per CHN-4 5.3. @@ -401,6 +476,80 @@ pub mod pallet { Ok(()) } + + /// CHN-4 5.2 canonical: `add_council_member` Phase 5.2 dispatchable canonical. + /// + /// 6-step canonical flow: ensure_root + size_check + no_duplicate + build + + /// insert + emit. + #[pallet::call_index(3)] + #[pallet::weight(Weight::from_parts(10_000, 0))] + pub fn add_council_member( + origin: OriginFor, + account: T::AccountId, + role: CouncilRole, + ) -> DispatchResult { + ensure_root(origin)?; + + let current_size = CouncilSize::::get(); + ensure!( + current_size < MAX_COUNCIL_SIZE, + Error::::CouncilSizeExceeded + ); + + ensure!( + !Council::::contains_key(&account), + Error::::AlreadyCouncilMember + ); + + let block_number: u64 = >::block_number() + .saturated_into::(); + let member = CouncilMember { + account: origin_chain_types::AccountId::new(account_bytes::(&account)), + joined_at_block: block_number, + role, + }; + + Council::::insert(&account, member); + let new_size = current_size.saturating_add(1); + CouncilSize::::put(new_size); + + Self::deposit_event(Event::CouncilMemberAdded { + account, + role, + new_size, + }); + + Ok(()) + } + + /// CHN-4 5.2 canonical: `remove_council_member` Phase 5.2 dispatchable canonical. + /// + /// 5-step canonical flow: ensure_root + validate_exists + remove + decrement + emit. + #[pallet::call_index(4)] + #[pallet::weight(Weight::from_parts(10_000, 0))] + pub fn remove_council_member( + origin: OriginFor, + account: T::AccountId, + ) -> DispatchResult { + ensure_root(origin)?; + + ensure!( + Council::::contains_key(&account), + Error::::NotCouncilMember + ); + + Council::::remove(&account); + let current_size = CouncilSize::::get(); + let new_size = current_size.saturating_sub(1); + CouncilSize::::put(new_size); + + Self::deposit_event(Event::CouncilMemberRemoved { + account, + new_size, + }); + + Ok(()) + } } /// Helper canonical para convert T::AccountId β†’ 32-byte canonical. @@ -412,6 +561,24 @@ pub mod pallet { bytes } + /// Phase 5.2 canonical helpers β€” Council membership queries (pure read). + impl Pallet { + /// Returns `true` si account canonical es council member. + pub fn is_council_member(account: &T::AccountId) -> bool { + Council::::contains_key(account) + } + + /// Returns current council size canonical (cached StorageValue). + pub fn current_council_size() -> u32 { + CouncilSize::::get() + } + + /// Returns required veto votes per current council size canonical (2/3 threshold). + pub fn required_veto_votes() -> u32 { + council_veto_threshold(Self::current_council_size()) + } + } + /// Pure helpers canonical para Runtime API v0.0.31 consumers + tests. impl Pallet { /// Query proposal canonical sin storage mutation (read-only canonical). diff --git a/crates/origin-chain-governance/src/tests_integration.rs b/crates/origin-chain-governance/src/tests_integration.rs index 6828e32..4f9a67d 100644 --- a/crates/origin-chain-governance/src/tests_integration.rs +++ b/crates/origin-chain-governance/src/tests_integration.rs @@ -240,6 +240,166 @@ fn scenario_9_vote_closed_period_rejected_canonical() { }); } +// ─── Scenario Council 1: add_council_member canonical (root only) ── + +#[test] +fn council_scenario_1_add_member_canonical() { + new_test_ext().execute_with(|| { + use crate::pallet::CouncilRole; + use frame_support::dispatch::RawOrigin; + + // Root canonical add canonical council member + assert_ok!(Governance::add_council_member( + RawOrigin::Root.into(), + alice_account(), + CouncilRole::Chair, + )); + + // Validate storage canonical + assert!(Governance::is_council_member(&alice_account())); + assert_eq!(Governance::current_council_size(), 1); + + let member = crate::Council::::get(&alice_account()).unwrap(); + assert!(matches!(member.role, CouncilRole::Chair)); + }); +} + +// ─── Scenario Council 2: non-root add rejected canonical ──────────── + +#[test] +fn council_scenario_2_non_root_add_rejected_canonical() { + new_test_ext().execute_with(|| { + use crate::pallet::CouncilRole; + + // Non-root origin β†’ BadOrigin canonical (ensure_root rejects) + let result = Governance::add_council_member( + alice_origin(), + bob_account(), + CouncilRole::Member, + ); + assert!(result.is_err()); + }); +} + +// ─── Scenario Council 3: duplicate add rejected canonical ─────────── + +#[test] +fn council_scenario_3_duplicate_add_rejected_canonical() { + new_test_ext().execute_with(|| { + use crate::pallet::CouncilRole; + use frame_support::dispatch::RawOrigin; + + // First add canonical + assert_ok!(Governance::add_council_member( + RawOrigin::Root.into(), + alice_account(), + CouncilRole::Member, + )); + + // Second add same account β†’ AlreadyCouncilMember + assert_noop!( + Governance::add_council_member( + RawOrigin::Root.into(), + alice_account(), + CouncilRole::Member, + ), + Error::::AlreadyCouncilMember + ); + }); +} + +// ─── Scenario Council 4: remove_council_member canonical ──────────── + +#[test] +fn council_scenario_4_remove_member_canonical() { + new_test_ext().execute_with(|| { + use crate::pallet::CouncilRole; + use frame_support::dispatch::RawOrigin; + + // Add + remove canonical + assert_ok!(Governance::add_council_member( + RawOrigin::Root.into(), + alice_account(), + CouncilRole::Member, + )); + assert_eq!(Governance::current_council_size(), 1); + + assert_ok!(Governance::remove_council_member( + RawOrigin::Root.into(), + alice_account(), + )); + assert!(!Governance::is_council_member(&alice_account())); + assert_eq!(Governance::current_council_size(), 0); + }); +} + +// ─── Scenario Council 5: remove non-member rejected canonical ─────── + +#[test] +fn council_scenario_5_remove_non_member_rejected_canonical() { + new_test_ext().execute_with(|| { + use frame_support::dispatch::RawOrigin; + + // Remove account never added β†’ NotCouncilMember + assert_noop!( + Governance::remove_council_member( + RawOrigin::Root.into(), + alice_account(), + ), + Error::::NotCouncilMember + ); + }); +} + +// ─── Scenario Council 6: max size enforcement canonical ───────────── + +#[test] +fn council_scenario_6_max_size_enforcement_canonical() { + new_test_ext().execute_with(|| { + use crate::pallet::{CouncilRole, MAX_COUNCIL_SIZE}; + use frame_support::dispatch::RawOrigin; + + // Add MAX_COUNCIL_SIZE members canonical + for i in 0..MAX_COUNCIL_SIZE { + let account = sp_runtime::AccountId32::new([i as u8 + 10; 32]); + assert_ok!(Governance::add_council_member( + RawOrigin::Root.into(), + account, + CouncilRole::Member, + )); + } + assert_eq!(Governance::current_council_size(), MAX_COUNCIL_SIZE); + + // Adding 8th member β†’ CouncilSizeExceeded + let overflow = sp_runtime::AccountId32::new([99u8; 32]); + assert_noop!( + Governance::add_council_member( + RawOrigin::Root.into(), + overflow, + CouncilRole::Member, + ), + Error::::CouncilSizeExceeded + ); + }); +} + +// ─── Scenario Council 7: 2/3 threshold canonical compile-detect ───── + +#[test] +fn council_scenario_7_veto_threshold_2_of_3_canonical() { + use crate::pallet::council_veto_threshold; + + // 2/3 ceiling division canonical canonical: + assert_eq!(council_veto_threshold(0), 0); + assert_eq!(council_veto_threshold(1), 1); // ceiling(2/3) = 1 + assert_eq!(council_veto_threshold(2), 2); // ceiling(4/3) = 2 + assert_eq!(council_veto_threshold(3), 2); // ceiling(6/3) = 2 + assert_eq!(council_veto_threshold(4), 3); // ceiling(8/3) = 3 + assert_eq!(council_veto_threshold(5), 4); // ceiling(10/3) = 4 + assert_eq!(council_veto_threshold(6), 4); // ceiling(12/3) = 4 + assert_eq!(council_veto_threshold(7), 5); // ceiling(14/3) = 5 +} + // ─── Scenario 10: Full lifecycle walkthrough canonical ────────────── #[test]