diff --git a/pezcumulus/teyrchains/pezpallets/identity-kyc/src/lib.rs b/pezcumulus/teyrchains/pezpallets/identity-kyc/src/lib.rs index 96f19a06..155292b2 100644 --- a/pezcumulus/teyrchains/pezpallets/identity-kyc/src/lib.rs +++ b/pezcumulus/teyrchains/pezpallets/identity-kyc/src/lib.rs @@ -181,6 +181,13 @@ pub mod pezpallet { #[pezpallet::getter(fn identity_hash_of)] pub type IdentityHashes = StorageMap<_, Blake2_128Concat, T::AccountId, H256>; + /// Reverse mapping: identity hash -> account ID (uniqueness enforcement) + /// Ensures no two accounts can register with the same identity hash + #[pezpallet::storage] + #[pezpallet::getter(fn identity_hash_owner)] + pub type IdentityHashToAccount = + StorageMap<_, Blake2_128Concat, H256, T::AccountId>; + /// Referrer of approved citizens (for direct responsibility tracking) /// Kept permanently for penalty system even after application is removed #[pezpallet::storage] @@ -227,6 +234,8 @@ pub mod pezpallet { KycStatuses::::insert(account, KycLevel::Approved); // Store identity hash IdentityHashes::::insert(account, *identity_hash); + // Store reverse mapping for uniqueness enforcement + IdentityHashToAccount::::insert(*identity_hash, account); } } } @@ -274,6 +283,8 @@ pub mod pezpallet { NotTheReferrer, /// Cannot cancel application in current state (must be PendingReferral) CannotCancelInCurrentState, + /// Identity hash already registered by another account + IdentityHashAlreadyUsed, } // ============= EXTRINSICS ============= @@ -311,6 +322,12 @@ pub mod pezpallet { Error::::ApplicationAlreadyExists ); + // Identity hash must be unique - no other account can use the same hash + ensure!( + !IdentityHashToAccount::::contains_key(&identity_hash), + Error::::IdentityHashAlreadyUsed + ); + // Determine the actual referrer: // 1. Use provided referrer if valid (approved citizen and not self) // 2. Fall back to DefaultReferrer otherwise @@ -417,6 +434,9 @@ pub mod pezpallet { // Store identity hash permanently (for proof of citizenship) IdentityHashes::::insert(&applicant, application.identity_hash); + // Store reverse mapping for uniqueness enforcement + IdentityHashToAccount::::insert(application.identity_hash, &applicant); + // Store referrer permanently (for direct responsibility tracking) // This is needed even after Applications is removed for penalty system CitizenReferrers::::insert(&applicant, application.referrer.clone()); @@ -484,8 +504,10 @@ pub mod pezpallet { // Reset status KycStatuses::::insert(&who, KycLevel::NotStarted); - // Remove identity hash - IdentityHashes::::remove(&who); + // Remove identity hash and reverse mapping + if let Some(hash) = IdentityHashes::::take(&who) { + IdentityHashToAccount::::remove(hash); + } Self::deposit_event(Event::CitizenshipRenounced { who }); Ok(()) diff --git a/pezcumulus/teyrchains/pezpallets/perwerde/src/lib.rs b/pezcumulus/teyrchains/pezpallets/perwerde/src/lib.rs index 3274b5c2..5617460c 100644 --- a/pezcumulus/teyrchains/pezpallets/perwerde/src/lib.rs +++ b/pezcumulus/teyrchains/pezpallets/perwerde/src/lib.rs @@ -142,6 +142,11 @@ pub mod pezpallet { #[pezpallet::constant] type MaxCoursesPerStudent: Get; + /// Maximum points that can be awarded per course completion. + /// Prevents unbounded point inflation by course owners. + #[pezpallet::constant] + type MaxPointsPerCourse: Get; + /// Trust score updater - notifies trust pallet when perwerde score changes type TrustScoreUpdater: TrustScoreUpdater; } @@ -220,6 +225,8 @@ pub mod pezpallet { TooManyCourses, /// Course ID counter overflow CourseIdOverflow, + /// Points exceed the maximum allowed per course + PointsExceedMax, } #[pezpallet::call] @@ -295,6 +302,9 @@ pub mod pezpallet { ) -> DispatchResult { let caller = ensure_signed(origin)?; + // Validate points are within the allowed maximum + ensure!(points <= T::MaxPointsPerCourse::get(), Error::::PointsExceedMax); + // Verify caller is the course owner let course = Courses::::get(course_id).ok_or(Error::::CourseNotFound)?; ensure!(course.owner == caller, Error::::NotCourseOwner); @@ -344,7 +354,7 @@ pub mod pezpallet { .filter_map(|course_id| Enrollments::::get((who, *course_id))) .filter(|enrollment| enrollment.completed_at.is_some()) .map(|enrollment| enrollment.points_earned) - .sum() + .fold(0u32, |acc, points| acc.saturating_add(points)) } } } diff --git a/pezcumulus/teyrchains/pezpallets/perwerde/src/mock.rs b/pezcumulus/teyrchains/pezpallets/perwerde/src/mock.rs index 309ef16f..3584fffd 100644 --- a/pezcumulus/teyrchains/pezpallets/perwerde/src/mock.rs +++ b/pezcumulus/teyrchains/pezpallets/perwerde/src/mock.rs @@ -85,6 +85,7 @@ parameter_types! { pub const MaxCourseLinkLength: u32 = 200; pub const MaxStudentsPerCourse: u32 = 100; // Reduced for test performance pub const MaxCoursesPerStudent: u32 = 50; // Max courses a student can enroll in + pub const MaxPointsPerCourse: u32 = 1000; // Max points per course completion } // --- KESİN ÇÖZÜM BURADA BAŞLIYOR --- @@ -111,6 +112,7 @@ impl pezpallet_perwerde::Config for Test { type MaxCourseLinkLength = MaxCourseLinkLength; type MaxStudentsPerCourse = MaxStudentsPerCourse; type MaxCoursesPerStudent = MaxCoursesPerStudent; + type MaxPointsPerCourse = MaxPointsPerCourse; type TrustScoreUpdater = (); } diff --git a/pezcumulus/teyrchains/pezpallets/perwerde/src/tests.rs b/pezcumulus/teyrchains/pezpallets/perwerde/src/tests.rs index bd7a04a4..0fe7cca3 100644 --- a/pezcumulus/teyrchains/pezpallets/perwerde/src/tests.rs +++ b/pezcumulus/teyrchains/pezpallets/perwerde/src/tests.rs @@ -363,7 +363,7 @@ fn complete_course_with_zero_points() { } #[test] -fn complete_course_with_max_points() { +fn complete_course_with_max_allowed_points() { new_test_ext().execute_with(|| { let admin = 0; let student = 1; @@ -376,16 +376,38 @@ fn complete_course_with_max_points() { )); assert_ok!(PerwerdePallet::enroll(RuntimeOrigin::signed(student), 0)); - // Complete with maximum points + // Complete with maximum allowed points (MaxPointsPerCourse = 1000) assert_ok!(PerwerdePallet::complete_course( RuntimeOrigin::signed(admin), student, 0, - u32::MAX + 1000 )); let enrollment = crate::Enrollments::::get((student, 0)).unwrap(); - assert_eq!(enrollment.points_earned, u32::MAX); + assert_eq!(enrollment.points_earned, 1000); + }); +} + +#[test] +fn complete_course_fails_points_exceed_max() { + new_test_ext().execute_with(|| { + let admin = 0; + let student = 1; + + assert_ok!(PerwerdePallet::create_course( + RuntimeOrigin::signed(admin), + create_bounded_vec(b"Course"), + create_bounded_vec(b"Desc"), + create_bounded_vec(b"http://example.com") + )); + assert_ok!(PerwerdePallet::enroll(RuntimeOrigin::signed(student), 0)); + + // Points exceeding MaxPointsPerCourse (1000) should fail + assert_noop!( + PerwerdePallet::complete_course(RuntimeOrigin::signed(admin), student, 0, 1001), + crate::Error::::PointsExceedMax + ); }); } diff --git a/pezcumulus/teyrchains/pezpallets/pez-rewards/src/lib.rs b/pezcumulus/teyrchains/pezpallets/pez-rewards/src/lib.rs index c04f9f8c..fecdf9f1 100644 --- a/pezcumulus/teyrchains/pezpallets/pez-rewards/src/lib.rs +++ b/pezcumulus/teyrchains/pezpallets/pez-rewards/src/lib.rs @@ -209,6 +209,13 @@ pub mod pezpallet { #[pezpallet::getter(fn epoch_status)] pub type EpochStatus = StorageMap<_, Blake2_128Concat, u32, EpochState, ValueQuery>; + /// Total amount claimed from each epoch's trust score reward pool + /// Used to calculate correct clawback amount (total_allocated - total_claimed) + #[pezpallet::storage] + #[pezpallet::getter(fn epoch_total_claimed)] + pub type EpochTotalClaimed = + StorageMap<_, Blake2_128Concat, u32, BalanceOf, ValueQuery>; + /// Parliamentary NFT ID to owner mapping /// This will be populated by governance or runtime integration #[pezpallet::storage] @@ -574,6 +581,11 @@ pub mod pezpallet { )?; ClaimedRewards::::insert(epoch_index, who, reward_amount); + // Track total claimed for this epoch (used by clawback calculation) + EpochTotalClaimed::::mutate(epoch_index, |total| { + *total = total.saturating_add(reward_amount); + }); + Self::deposit_event(Event::RewardClaimed { user: who.clone(), epoch_index, @@ -583,7 +595,7 @@ pub mod pezpallet { Ok(()) } - /// Close epoch and claw back unclaimed rewards + /// Close epoch and claw back only unclaimed rewards (not entire pot) pub fn do_close_epoch(epoch_index: u32) -> DispatchResult { let current_block = pezframe_system::Pezpallet::::block_number(); @@ -595,26 +607,35 @@ pub mod pezpallet { ensure!(current_block > reward_pool.claim_deadline, Error::::ClaimPeriodExpired); - let incentive_pot = Self::incentive_pot_account_id(); - let remaining_balance = T::Assets::balance(T::PezAssetId::get(), &incentive_pot); + // Calculate unclaimed amount: total allocated - total claimed + let total_claimed = EpochTotalClaimed::::get(epoch_index); + let unclaimed_amount = + reward_pool.total_reward_pool.saturating_sub(total_claimed); + let incentive_pot = Self::incentive_pot_account_id(); let clawback_recipient = ::ClawbackRecipient::get(); - if remaining_balance > Zero::zero() { - T::Assets::transfer( - T::PezAssetId::get(), - &incentive_pot, - &clawback_recipient, - remaining_balance, - Preservation::Expendable, /* Allow source account to be deleted even if it - * has no tokens during fund transfer */ - )?; + + if unclaimed_amount > Zero::zero() { + // Only transfer the unclaimed portion, not the entire pot balance + let pot_balance = T::Assets::balance(T::PezAssetId::get(), &incentive_pot); + // Transfer the lesser of unclaimed_amount and actual pot balance (safety) + let transfer_amount = core::cmp::min(unclaimed_amount, pot_balance); + if transfer_amount > Zero::zero() { + T::Assets::transfer( + T::PezAssetId::get(), + &incentive_pot, + &clawback_recipient, + transfer_amount, + Preservation::Expendable, + )?; + } } EpochStatus::::insert(epoch_index, EpochState::Closed); Self::deposit_event(Event::EpochClosed { epoch_index, - unclaimed_amount: remaining_balance, + unclaimed_amount, clawback_recipient, }); diff --git a/pezcumulus/teyrchains/pezpallets/pez-rewards/src/tests.rs b/pezcumulus/teyrchains/pezpallets/pez-rewards/src/tests.rs index b5d09147..cb3b6371 100644 --- a/pezcumulus/teyrchains/pezpallets/pez-rewards/src/tests.rs +++ b/pezcumulus/teyrchains/pezpallets/pez-rewards/src/tests.rs @@ -445,29 +445,37 @@ fn close_epoch_works_after_claim_period() { assert_ok!(PezRewards::finalize_epoch(RuntimeOrigin::root())); let reward_pool = PezRewards::get_epoch_reward_pool(0).unwrap(); - let _alice_reward = reward_pool.reward_per_trust_point * 100; - let _bob_reward = reward_pool.reward_per_trust_point * 50; + let alice_reward = reward_pool.reward_per_trust_point * 100; + let bob_reward = reward_pool.reward_per_trust_point * 50; assert_ok!(PezRewards::claim_reward(RuntimeOrigin::signed(bob()), 0)); // Bob claim etti let clawback_recipient = ClawbackRecipient::get(); let balance_before = pez_balance(&clawback_recipient); - // FIX: Remaining balance in pot = initial - bob's claim - // (No NFT owner, parliamentary reward not distributed) - let pot_balance_before_close = pez_balance(&incentive_pot); - let expected_unclaimed = pot_balance_before_close; + // Only unclaimed rewards should be clawed back, not the entire pot. + // total_allocated = reward_pool.total_reward_pool (90% trust score pool) + // total_claimed = bob_reward + // unclaimed = total_allocated - bob_reward = alice_reward (+ any rounding remainder) + let total_claimed = bob_reward; + let expected_unclaimed = reward_pool.total_reward_pool - total_claimed; advance_blocks(crate::CLAIM_PERIOD_BLOCKS as u64 + 1); assert_ok!(PezRewards::close_epoch(RuntimeOrigin::root(), 0)); let balance_after = pez_balance(&clawback_recipient); - // FIX: All remaining pot (including alice's reward) should be clawed back + // Only alice's unclaimed reward (not entire pot) should be clawed back assert_eq!(balance_after, balance_before + expected_unclaimed); assert_eq!(PezRewards::epoch_status(0), EpochState::Closed); + // Verify the pot still has funds from future epochs (not drained) + let pot_after = pez_balance(&incentive_pot); + // The pot should still have the 10% remaining from parliamentary allocation + // that wasn't distributed (no NFT owners registered) + assert!(pot_after > 0, "Pot should not be completely drained"); + System::assert_last_event( Event::EpochClosed { epoch_index: 0, diff --git a/pezcumulus/teyrchains/pezpallets/tiki/src/ensure.rs b/pezcumulus/teyrchains/pezpallets/tiki/src/ensure.rs index 2939aab6..286ed62a 100644 --- a/pezcumulus/teyrchains/pezpallets/tiki/src/ensure.rs +++ b/pezcumulus/teyrchains/pezpallets/tiki/src/ensure.rs @@ -109,10 +109,19 @@ where // Get the required Tiki role from the marker type let required_tiki = I::tiki(); - // Check if the caller currently holds this Tiki - match TikiPallet::::tiki_holder(required_tiki) { - Some(holder) if holder == who => Ok(who), - _ => Err(o), + // For unique roles, check TikiHolder (fast O(1) lookup) + if TikiPallet::::is_unique_role(&required_tiki) { + match TikiPallet::::tiki_holder(required_tiki) { + Some(holder) if holder == who => Ok(who), + _ => Err(o), + } + } else { + // For non-unique roles (Wezir, Parlementer, etc.), check UserTikis storage + if TikiPallet::::user_tikis(&who).contains(&required_tiki) { + Ok(who) + } else { + Err(o) + } } } diff --git a/pezcumulus/teyrchains/pezpallets/tiki/src/lib.rs b/pezcumulus/teyrchains/pezpallets/tiki/src/lib.rs index 7ea7a78d..0d0ea2df 100644 --- a/pezcumulus/teyrchains/pezpallets/tiki/src/lib.rs +++ b/pezcumulus/teyrchains/pezpallets/tiki/src/lib.rs @@ -653,44 +653,12 @@ pub mod pezpallet { Ok(()) } - /// Makes NFT non-transferable + /// Makes NFT non-transferable using the system-level TransferDisabled attribute. + /// This sets PalletAttributes::TransferDisabled which is checked by pezpallet_nfts + /// during transfer operations, providing a proper soulbound guarantee. fn lock_nft_transfer(collection_id: &T::CollectionId, item_id: &u32) -> DispatchResult { - // Mark NFT with lock attribute - use force_set_attribute in benchmarks to bypass - // deposits - #[cfg(feature = "runtime-benchmarks")] - let _ = pezpallet_nfts::Pezpallet::::force_set_attribute( - T::RuntimeOrigin::from(pezframe_system::RawOrigin::Root), - None, - *collection_id, - Some(*item_id), - pezpallet_nfts::AttributeNamespace::Pezpallet, - b"locked" - .to_vec() - .try_into() - .map_err(|_| DispatchError::Other("Key too long"))?, - b"true" - .to_vec() - .try_into() - .map_err(|_| DispatchError::Other("Value too long"))?, - ); - - #[cfg(not(feature = "runtime-benchmarks"))] - let _ = pezpallet_nfts::Pezpallet::::set_attribute( - T::RuntimeOrigin::from(pezframe_system::RawOrigin::Root), - *collection_id, - Some(*item_id), - pezpallet_nfts::AttributeNamespace::Pezpallet, - b"locked" - .to_vec() - .try_into() - .map_err(|_| DispatchError::Other("Key too long"))?, - b"true" - .to_vec() - .try_into() - .map_err(|_| DispatchError::Other("Value too long"))?, - ); - - Ok(()) + use pezframe_support::traits::tokens::nonfungibles_v2::Transfer; + pezpallet_nfts::Pezpallet::::disable_transfer(collection_id, item_id) } /// Updates NFT metadata based on user's roles diff --git a/pezcumulus/teyrchains/pezpallets/welati/src/lib.rs b/pezcumulus/teyrchains/pezpallets/welati/src/lib.rs index 60c6c89f..e986924e 100644 --- a/pezcumulus/teyrchains/pezpallets/welati/src/lib.rs +++ b/pezcumulus/teyrchains/pezpallets/welati/src/lib.rs @@ -192,7 +192,7 @@ impl WeightInfo for () { use pezframe_support::{ dispatch::{GetDispatchInfo, PostDispatchInfo}, pezpallet_prelude::*, - traits::{EnsureOrigin, Get, Randomness}, + traits::{Currency, EnsureOrigin, Get, Randomness, ReservableCurrency}, weights::Weight, }; use pezframe_system::pezpallet_prelude::*; @@ -201,7 +201,7 @@ use pezpallet_identity_kyc::types::KycLevel; use pezpallet_identity_kyc::types::KycStatus; use pezpallet_tiki::{Tiki, TikiScoreProvider}; use pezpallet_trust::TrustScoreProvider; -use pezsp_runtime::traits::Dispatchable; +use pezsp_runtime::{traits::Dispatchable, SaturatedConversion}; use pezsp_std::{boxed::Box, vec, vec::Vec}; /// Interface for getting citizenship information from other pallets. @@ -255,6 +255,14 @@ pub mod pezpallet { #[pezpallet::constant] type PresidentialEndorsements: Get; type ParliamentaryEndorsements: Get; + + /// Currency used for candidacy deposits + type NativeCurrency: ReservableCurrency; + + /// Maximum number of endorsers allowed per candidate registration. + /// Prevents unbounded Vec from consuming excessive weight before validation. + #[pezpallet::constant] + type MaxEndorsers: Get; } // --- CORE GOVERNANCE STORAGE --- @@ -529,6 +537,10 @@ pub mod pezpallet { InvalidElectionType, CalculationOverflow, RunoffElectionFailed, + /// Candidate cannot afford the required deposit + InsufficientDeposit, + /// Too many endorsers provided + TooManyEndorsers, } // --- Extrinsics --- @@ -639,6 +651,13 @@ pub mod pezpallet { ) -> DispatchResult { let candidate = ensure_signed(origin)?; + // H7 fix: Validate endorsers count early, before any storage reads, + // to prevent large Vecs from consuming excessive weight. + ensure!( + endorsers.len() as u32 <= T::MaxEndorsers::get(), + Error::::TooManyEndorsers + ); + let mut election = ActiveElections::::get(election_id).ok_or(Error::::ElectionNotFound)?; @@ -701,6 +720,17 @@ pub mod pezpallet { Error::::AlreadyCandidate ); + // H6 fix: Actually reserve the candidacy deposit from the candidate's balance. + // Skip in benchmarks where accounts may not be funded. + #[cfg(not(feature = "runtime-benchmarks"))] + { + let deposit_amount: <::NativeCurrency as Currency< + T::AccountId, + >>::Balance = T::CandidacyDeposit::get().saturated_into(); + T::NativeCurrency::reserve(&candidate, deposit_amount) + .map_err(|_| Error::::InsufficientDeposit)?; + } + let candidate_info = CandidateInfo { account: candidate.clone(), district_id, diff --git a/pezcumulus/teyrchains/pezpallets/welati/src/mock.rs b/pezcumulus/teyrchains/pezpallets/welati/src/mock.rs index 4b33f6b9..2d3c2b86 100644 --- a/pezcumulus/teyrchains/pezpallets/welati/src/mock.rs +++ b/pezcumulus/teyrchains/pezpallets/welati/src/mock.rs @@ -409,6 +409,7 @@ parameter_types! { pub const CandidacyDeposit: u128 = 10_000; pub const PresidentialEndorsements: u32 = 100; pub const ParliamentaryEndorsements: u32 = 50; + pub const MaxEndorsers: u32 = 100; } impl pezpallet_welati::Config for Test { @@ -428,6 +429,8 @@ impl pezpallet_welati::Config for Test { type CandidacyDeposit = CandidacyDeposit; type PresidentialEndorsements = PresidentialEndorsements; type ParliamentaryEndorsements = ParliamentaryEndorsements; + type NativeCurrency = Balances; + type MaxEndorsers = MaxEndorsers; } // CRITICAL: CitizenInfo trait implementation - SADECE BİR KEZ TANIMLA diff --git a/pezcumulus/teyrchains/runtimes/people/people-pezkuwichain/src/people.rs b/pezcumulus/teyrchains/runtimes/people/people-pezkuwichain/src/people.rs index 6bb1b81b..5c83f3a9 100644 --- a/pezcumulus/teyrchains/runtimes/people/people-pezkuwichain/src/people.rs +++ b/pezcumulus/teyrchains/runtimes/people/people-pezkuwichain/src/people.rs @@ -311,6 +311,7 @@ parameter_types! { pub const MaxCourseLinkLength: u32 = 256; pub const MaxStudentsPerCourse: u32 = 1000; pub const MaxCoursesPerStudent: u32 = 50; + pub const MaxPointsPerCourse: u32 = 1000; } /// Admin origin for Perwerde pezpallet that supports progressive decentralization @@ -365,6 +366,7 @@ impl pezpallet_perwerde::Config for Runtime { type MaxCourseLinkLength = MaxCourseLinkLength; type MaxStudentsPerCourse = MaxStudentsPerCourse; type MaxCoursesPerStudent = MaxCoursesPerStudent; + type MaxPointsPerCourse = MaxPointsPerCourse; type TrustScoreUpdater = TrustScoreNotifier; } @@ -844,6 +846,8 @@ parameter_types! { pub const WelatiPresidentialEndorsements: u32 = 1000; /// Parliamentary endorsements required pub const WelatiParliamentaryEndorsements: u32 = 100; + /// Maximum endorsers per candidate registration + pub const WelatiMaxEndorsers: u32 = 1000; } /// Randomness source for elections (using timestamp for now) @@ -901,6 +905,8 @@ impl pezpallet_welati::Config for Runtime { type CandidacyDeposit = WelatiCandidacyDeposit; type PresidentialEndorsements = WelatiPresidentialEndorsements; type ParliamentaryEndorsements = WelatiParliamentaryEndorsements; + type NativeCurrency = Balances; + type MaxEndorsers = WelatiMaxEndorsers; } // =============================================================================