#![cfg_attr(not(feature = "std"), no_std)] //! # PEZ Rewards Pallet //! //! A pallet for distributing PEZ token rewards based on trust scores with epoch-based mechanics. //! //! ## Overview //! //! This pallet implements a sophisticated reward distribution system that incentivizes //! ecosystem participation through trust-based rewards. The system operates in monthly //! epochs with automatic reward calculation, distribution, and clawback mechanisms. //! //! ## Core Mechanisms //! //! ### Epoch System //! //! - **Duration**: 1 month (~432,000 blocks at 10 blocks/minute) //! - **States**: Open → ClaimPeriod → Closed //! - **Claim Window**: 1 week after epoch finalization (~100,800 blocks) //! - **Automatic Progression**: Scheduler-driven state transitions //! //! ### Reward Distribution //! //! 1. **Trust Score Recording**: Users record their trust scores during the Open epoch //! 2. **Epoch Finalization**: Total pool and per-trust-point rewards calculated //! 3. **Claim Period**: Users claim proportional rewards based on their trust scores //! 4. **Clawback**: Unclaimed rewards returned to designated recipient after claim period //! //! ### Parliamentary NFT Rewards //! //! - **Allocation**: 10% of each epoch's incentive pool reserved for NFT holders //! - **NFT Collection**: ID 100 with 201 Parliamentary NFTs //! - **Automatic Distribution**: Pro-rata distribution to all NFT holders at epoch finalization //! //! ## Reward Calculation Formula //! //! ```text //! user_reward = (user_trust_score / total_trust_score) * epoch_reward_pool //! ``` //! //! Where: //! - `epoch_reward_pool` = Incentive pot balance - 10% parliamentary allocation //! - `total_trust_score` = Sum of all recorded trust scores in epoch //! - `user_trust_score` = User's trust score snapshot from epoch //! //! ## Interface //! //! ### User Extrinsics //! //! - `record_trust_score()` - Record current trust score for active epoch //! - `claim_reward(epoch_index)` - Claim reward from a finalized epoch (within claim period) //! //! ### Privileged Extrinsics //! //! - `initialize_rewards_system()` - Start the first epoch (one-time, root) //! - `finalize_epoch()` - Calculate rewards and start claim period (scheduler/root) //! - `close_epoch(epoch_index)` - Close claim period and claw back unclaimed rewards //! (scheduler/root) //! //! ### Storage //! //! - `EpochInfo` - Current epoch metadata (index, start block, completion count) //! - `EpochRewardPools` - Historical reward pool data for each epoch //! - `UserEpochScores` - User trust score snapshots per epoch //! - `ClaimedRewards` - Tracking claimed rewards per user per epoch //! - `EpochStatus` - Current state (Open/ClaimPeriod/Closed) for each epoch //! - `ParliamentaryNftOwners` - Mapping of Parliamentary NFT IDs to owners //! //! ## Dependencies //! //! This pallet requires integration with: //! - `pezpallet-trust` - Trust score provider //! - `pezpallet-pez-treasury` - Incentive pot funding source //! - `pezpallet-nfts` - Parliamentary NFT collection (optional) //! //! ## Runtime Integration Example //! //! ```ignore //! impl pezpallet_pez_rewards::Config for Runtime { //! type RuntimeEvent = RuntimeEvent; //! type Assets = Assets; //! type PezAssetId = ConstU32<1>; // PEZ asset ID //! type WeightInfo = pezpallet_pez_rewards::weights::BizinikiwiWeight; //! type TrustScoreSource = Trust; //! type IncentivePotId = IncentivePotId; //! type ClawbackRecipient = ClawbackRecipient; // Governance account //! type ForceOrigin = EnsureRoot; //! type CollectionId = u32; //! type ItemId = u32; //! } //! ``` pub use pallet::*; pub mod weights; pub use weights::WeightInfo; #[cfg(test)] mod mock; #[cfg(test)] mod tests; #[cfg(feature = "runtime-benchmarks")] mod benchmarking; use codec::{Decode, Encode, MaxEncodedLen}; use pezframe_support::{ traits::{ fungibles::{Inspect, Mutate}, tokens::Preservation, Get, }, PalletId, Parameter, }; use pezframe_system::pezpallet_prelude::BlockNumberFor; use pezpallet_trust::TrustScoreProvider; use scale_info::TypeInfo; use pezsp_runtime::traits::{AccountIdConversion, Member, Saturating, Zero}; #[pezframe_support::pallet] pub mod pallet { use super::*; use pezframe_support::pezpallet_prelude::*; use pezframe_system::pezpallet_prelude::*; use pezsp_runtime::traits::{CheckedDiv, CheckedMul}; /// Epoch (period) constants // pub const BLOCKS_PER_EPOCH: u32 = 20; // CHANGED FOR TESTING - Original is 432_000 pub const BLOCKS_PER_EPOCH: u32 = 432_000; // 1 month = ~30 days * 24 hours * 60 minutes * 10 blocks/minute pub const CLAIM_PERIOD_BLOCKS: u32 = 100_800; // 1 week = ~7 days * 24 hours * 60 minutes * 10 blocks/minute /// Parliamentary NFT constants pub const PARLIAMENTARY_COLLECTION_ID: u32 = 100; pub const PARLIAMENTARY_NFT_COUNT: u32 = 201; pub const PARLIAMENTARY_REWARD_PERCENT: u32 = 10; // 10% of incentive pool #[pallet::pallet] pub struct Pallet(_); #[pallet::config] pub trait Config: pezframe_system::Config + pezpallet_trust::Config + TypeInfo { type Assets: Mutate; #[pallet::constant] type PezAssetId: Get<>::AssetId>; type WeightInfo: crate::weights::WeightInfo; /// Trust score provider type TrustScoreSource: pezpallet_trust::TrustScoreProvider; /// Authority to spend from incentive pot #[pallet::constant] type IncentivePotId: Get; /// Clawback recipient (Qazi Muhammed) #[pallet::constant] type ClawbackRecipient: Get; /// Authority check for root origin type ForceOrigin: EnsureOrigin; /// NFT Collection ID ve Item ID types - must match pezpallet_nfts::Config type CollectionId: Member + Parameter + MaxEncodedLen + Copy + From + Into; type ItemId: Member + Parameter + MaxEncodedLen + Copy + From + Into; } pub type BalanceOf = <::Assets as Inspect<::AccountId>>::Balance; /// Storage holding epoch (period) information #[pallet::storage] #[pallet::getter(fn epoch_info)] pub type EpochInfo = StorageValue<_, EpochData, ValueQuery>; /// Storage holding total reward pool for each epoch #[pallet::storage] #[pallet::getter(fn epoch_reward_pools)] pub type EpochRewardPools = StorageMap<_, Blake2_128Concat, u32, EpochRewardPool, OptionQuery>; /// Storage holding user's trust score for a specific epoch #[pallet::storage] #[pallet::getter(fn user_epoch_scores)] pub type UserEpochScores = StorageDoubleMap< _, Blake2_128Concat, u32, // epoch_index Blake2_128Concat, T::AccountId, // user u128, // trust_score OptionQuery, >; /// Storage tracking whether user has claimed reward from a specific epoch #[pallet::storage] #[pallet::getter(fn claimed_rewards)] pub type ClaimedRewards = StorageDoubleMap< _, Blake2_128Concat, u32, // epoch_index Blake2_128Concat, T::AccountId, // user BalanceOf, // claimed_amount OptionQuery, >; /// Storage holding epoch state (Open, ClaimPeriod, Closed) #[pallet::storage] #[pallet::getter(fn epoch_status)] pub type EpochStatus = StorageMap<_, Blake2_128Concat, u32, EpochState, ValueQuery>; /// Parliamentary NFT ID to owner mapping /// This will be populated by governance or runtime integration #[pallet::storage] #[pallet::getter(fn parliamentary_nft_owners)] pub type ParliamentaryNftOwners = StorageMap< _, Blake2_128Concat, u32, // nft_id T::AccountId, // owner OptionQuery, >; #[derive(Encode, Decode, Clone, PartialEq, Eq, RuntimeDebug, TypeInfo, MaxEncodedLen)] pub struct EpochData { pub current_epoch: u32, pub epoch_start_block: BlockNumberFor, pub total_epochs_completed: u32, } #[derive(Encode, Decode, Clone, PartialEq, Eq, RuntimeDebug, TypeInfo, MaxEncodedLen)] pub struct EpochRewardPool { pub epoch_index: u32, pub total_reward_pool: BalanceOf, // Total reward for this epoch pub total_trust_score: u128, // Total trust score in this epoch pub reward_per_trust_point: BalanceOf, // Reward per trust point pub participants_count: u32, // Number of participants pub claim_deadline: BlockNumberFor, // Claim deadline } #[derive( Encode, Decode, Clone, Copy, PartialEq, Eq, RuntimeDebug, TypeInfo, MaxEncodedLen, Default, )] pub enum EpochState { #[default] Open, // Active epoch - scores being collected ClaimPeriod, // Claim period - claims can be made for 1 week Closed, // Closed - unclaimed rewards have been clawed back } impl Default for EpochData { fn default() -> Self { Self { current_epoch: 0, epoch_start_block: Zero::zero(), total_epochs_completed: 0 } } } // Part to be added to Event enum in lib.rs (around line ~174) #[pallet::event] #[pallet::generate_deposit(pub(super) fn deposit_event)] pub enum Event { /// New epoch started NewEpochStarted { epoch_index: u32, start_block: BlockNumberFor }, /// Epoch reward pool calculated and claim period started EpochRewardPoolCalculated { epoch_index: u32, total_pool: BalanceOf, total_trust_score: u128, participants_count: u32, claim_deadline: BlockNumberFor, }, /// User claimed their reward RewardClaimed { user: T::AccountId, epoch_index: u32, amount: BalanceOf }, /// Epoch claim period ended and unclaimed rewards were clawed back EpochClosed { epoch_index: u32, unclaimed_amount: BalanceOf, clawback_recipient: T::AccountId, }, /// User's trust score recorded for epoch TrustScoreRecorded { user: T::AccountId, epoch_index: u32, trust_score: u128 }, /// Parliamentary NFT reward automatically distributed ParliamentaryNftRewardDistributed { nft_id: u32, owner: T::AccountId, amount: BalanceOf, epoch: u32, }, /// Parliamentary NFT owner registered (NEW EVENT - for tests.rs:590) ParliamentaryOwnerRegistered { nft_id: u32, owner: T::AccountId }, } #[pallet::error] pub enum Error { /// Reward system not yet initialized RewardsNotInitialized, /// Epoch not yet finished EpochNotFinished, /// Reward already claimed for this epoch RewardAlreadyClaimed, /// Reward pool not yet calculated for this epoch RewardPoolNotCalculated, /// User has no trust score for this epoch NoTrustScoreForEpoch, /// Claim period has expired ClaimPeriodExpired, /// Epoch already closed EpochAlreadyClosed, /// Insufficient incentive pot balance InsufficientIncentivePot, /// Invalid epoch index InvalidEpochIndex, /// Calculation overflow CalculationOverflow, /// System already initialized AlreadyInitialized, // ADD THIS LINE (for tests.rs:37) /// User has no reward to claim from this epoch NoRewardToClaim, /* ADD THIS LINE (for tests.rs:251 and 333) * EpochNotFinished already exists in lib.rs as shown in 'help' */ } #[pallet::genesis_config] #[derive(pezframe_support::DefaultNoBound)] pub struct GenesisConfig { pub start_rewards_system: bool, #[serde(skip)] pub _phantom: core::marker::PhantomData, } #[pallet::genesis_build] impl BuildGenesisConfig for GenesisConfig { fn build(&self) { if self.start_rewards_system { let _ = Pallet::::do_initialize_rewards_system(); } } } #[pallet::call] impl Pallet { /// Initialize reward system (root only) #[pallet::call_index(0)] #[pallet::weight(::WeightInfo::initialize_rewards_system())] pub fn initialize_rewards_system(origin: OriginFor) -> DispatchResult { ::ForceOrigin::ensure_origin(origin)?; Self::do_initialize_rewards_system() } /// Record user's current trust score #[pallet::call_index(1)] #[pallet::weight(::WeightInfo::record_trust_score())] pub fn record_trust_score(origin: OriginFor) -> DispatchResult { let who = ensure_signed(origin)?; Self::do_record_trust_score(&who) } /// Finalize epoch and calculate reward pool (called by scheduler) #[pallet::call_index(2)] #[pallet::weight(::WeightInfo::finalize_epoch())] pub fn finalize_epoch(origin: OriginFor) -> DispatchResult { ::ForceOrigin::ensure_origin(origin)?; Self::do_finalize_epoch() } /// Claim reward #[pallet::call_index(3)] #[pallet::weight(::WeightInfo::claim_reward())] pub fn claim_reward(origin: OriginFor, epoch_index: u32) -> DispatchResult { let who = ensure_signed(origin)?; Self::do_claim_reward(&who, epoch_index) } /// Close epoch and claw back unclaimed rewards (called by scheduler) #[pallet::call_index(4)] #[pallet::weight(::WeightInfo::close_epoch())] pub fn close_epoch(origin: OriginFor, epoch_index: u32) -> DispatchResult { ::ForceOrigin::ensure_origin(origin)?; Self::do_close_epoch(epoch_index) } /// Register parliamentary NFT owner (governance only) #[pallet::call_index(5)] #[pallet::weight(::WeightInfo::register_parliamentary_nft_owner())] pub fn register_parliamentary_nft_owner( origin: OriginFor, nft_id: u32, owner: T::AccountId, ) -> DispatchResult { ::ForceOrigin::ensure_origin(origin)?; Self::do_register_parliamentary_nft_owner(nft_id, owner); Ok(()) } } impl Pallet { /// Return incentive pot account pub fn incentive_pot_account_id() -> T::AccountId { ::IncentivePotId::get().into_account_truncating() } /// Initialize reward system pub fn do_initialize_rewards_system() -> DispatchResult { // GUARD: Check if already initialized if EpochInfo::::exists() { return Err(Error::::AlreadyInitialized.into()); } let current_block = pezframe_system::Pallet::::block_number(); let epoch_data = EpochData { current_epoch: 0, epoch_start_block: current_block, total_epochs_completed: 0, }; EpochInfo::::put(epoch_data); EpochStatus::::insert(0, EpochState::Open); Self::deposit_event(Event::NewEpochStarted { epoch_index: 0, start_block: current_block, }); Ok(()) } /// Record user's trust score for current epoch pub fn do_record_trust_score(who: &T::AccountId) -> DispatchResult { let epoch_data = EpochInfo::::get(); let current_epoch = epoch_data.current_epoch; // Scores can only be recorded in open epochs let epoch_state = EpochStatus::::get(current_epoch); ensure!(epoch_state == EpochState::Open, Error::::EpochAlreadyClosed); // Get trust score let trust_score = ::TrustScoreSource::trust_score_of(who); let trust_score_u128: u128 = trust_score; // FIX: Also record zero scores (tests expect this) UserEpochScores::::insert(current_epoch, who, trust_score_u128); Self::deposit_event(Event::TrustScoreRecorded { user: who.clone(), epoch_index: current_epoch, trust_score: trust_score_u128, }); Ok(()) } /// Finalize epoch and calculate reward pool pub fn do_finalize_epoch() -> DispatchResult { let mut epoch_data = EpochInfo::::get(); let current_epoch = epoch_data.current_epoch; let current_block = pezframe_system::Pallet::::block_number(); // Check if epoch has finished let epoch_duration = current_block.saturating_sub(epoch_data.epoch_start_block); ensure!(epoch_duration >= BLOCKS_PER_EPOCH.into(), Error::::EpochNotFinished); // GUARD: Epoch already finalized? let epoch_state = EpochStatus::::get(current_epoch); ensure!(epoch_state == EpochState::Open, Error::::EpochAlreadyClosed); // Get incentive pot balance let incentive_pot = Self::incentive_pot_account_id(); let total_reward_pool = T::Assets::balance(T::PezAssetId::get(), &incentive_pot); ensure!(total_reward_pool > Zero::zero(), Error::::InsufficientIncentivePot); // Parliamentary rewards distribute et (10%) Self::distribute_parliamentary_rewards(current_epoch, total_reward_pool)?; // Remaining 90% for trust score rewards let trust_score_pool = total_reward_pool * 90u32.into() / 100u32.into(); // Calculate total trust score of all users in this epoch let mut total_trust_score = 0u128; let mut participants_count = 0u32; for (_, trust_score) in UserEpochScores::::iter_prefix(current_epoch) { total_trust_score = total_trust_score.saturating_add(trust_score); participants_count = participants_count.saturating_add(1); } let reward_per_trust_point = if total_trust_score > 0 { let trust_score_balance = BalanceOf::::try_from(total_trust_score) .map_err(|_| Error::::CalculationOverflow)?; trust_score_pool.checked_div(&trust_score_balance).unwrap_or_else(Zero::zero) } else { Zero::zero() }; // Talep son tarihini belirle (1 hafta sonra) let claim_deadline = current_block.saturating_add(CLAIM_PERIOD_BLOCKS.into()); // Save reward pool information let reward_pool = EpochRewardPool { epoch_index: current_epoch, total_reward_pool: trust_score_pool, total_trust_score, reward_per_trust_point, participants_count, claim_deadline, }; EpochRewardPools::::insert(current_epoch, reward_pool); // FIX: Set epoch state to ClaimPeriod (not Closed!) EpochStatus::::insert(current_epoch, EpochState::ClaimPeriod); // Start new epoch let new_epoch = epoch_data.current_epoch.saturating_add(1); epoch_data.current_epoch = new_epoch; epoch_data.epoch_start_block = current_block; epoch_data.total_epochs_completed = epoch_data.total_epochs_completed.saturating_add(1); EpochInfo::::put(epoch_data); EpochStatus::::insert(new_epoch, EpochState::Open); // FIX: Show trust_score_pool in event (not total_reward_pool) Self::deposit_event(Event::EpochRewardPoolCalculated { epoch_index: current_epoch, total_pool: trust_score_pool, // ← 90% pool total_trust_score, participants_count, claim_deadline, }); Self::deposit_event(Event::NewEpochStarted { epoch_index: new_epoch, start_block: current_block, }); Ok(()) } pub fn do_claim_reward(who: &T::AccountId, epoch_index: u32) -> DispatchResult { let current_block = pezframe_system::Pallet::::block_number(); let epoch_state = EpochStatus::::get(epoch_index); ensure!(epoch_state == EpochState::ClaimPeriod, Error::::ClaimPeriodExpired); ensure!( !ClaimedRewards::::contains_key(epoch_index, who), Error::::RewardAlreadyClaimed ); let reward_pool = EpochRewardPools::::get(epoch_index) .ok_or(Error::::RewardPoolNotCalculated)?; ensure!(current_block <= reward_pool.claim_deadline, Error::::ClaimPeriodExpired); let user_trust_score = UserEpochScores::::get(epoch_index, who) .ok_or(Error::::NoTrustScoreForEpoch)?; let user_trust_balance = BalanceOf::::try_from(user_trust_score) .map_err(|_| Error::::CalculationOverflow)?; let reward_amount = reward_pool .reward_per_trust_point .checked_mul(&user_trust_balance) .ok_or(Error::::CalculationOverflow)?; // FIX: If reward is 0, there is nothing to claim ensure!(reward_amount > Zero::zero(), Error::::NoRewardToClaim); let incentive_pot = Self::incentive_pot_account_id(); T::Assets::transfer( T::PezAssetId::get(), &incentive_pot, who, reward_amount, Preservation::Expendable, )?; ClaimedRewards::::insert(epoch_index, who, reward_amount); Self::deposit_event(Event::RewardClaimed { user: who.clone(), epoch_index, amount: reward_amount, }); Ok(()) } /// Close epoch and claw back unclaimed rewards pub fn do_close_epoch(epoch_index: u32) -> DispatchResult { let current_block = pezframe_system::Pallet::::block_number(); let epoch_state = EpochStatus::::get(epoch_index); ensure!(epoch_state == EpochState::ClaimPeriod, Error::::EpochAlreadyClosed); let reward_pool = EpochRewardPools::::get(epoch_index) .ok_or(Error::::RewardPoolNotCalculated)?; 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); 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 */ )?; } EpochStatus::::insert(epoch_index, EpochState::Closed); Self::deposit_event(Event::EpochClosed { epoch_index, unclaimed_amount: remaining_balance, clawback_recipient, }); Ok(()) } /// Return current epoch information pub fn get_current_epoch_info() -> EpochData { EpochInfo::::get() } /// Return reward pool information for specific epoch pub fn get_epoch_reward_pool(epoch_index: u32) -> Option> { EpochRewardPools::::get(epoch_index) } /// Return user's trust score for specific epoch pub fn get_user_trust_score_for_epoch( epoch_index: u32, who: &T::AccountId, ) -> Option { UserEpochScores::::get(epoch_index, who) } /// Return reward amount claimed by user from specific epoch pub fn get_claimed_reward(epoch_index: u32, who: &T::AccountId) -> Option> { ClaimedRewards::::get(epoch_index, who) } /// Distribute rewards to parliamentary NFT holders automatically pub fn distribute_parliamentary_rewards( epoch: u32, total_incentive_pool: BalanceOf, ) -> DispatchResult { let parliamentary_allocation = total_incentive_pool * PARLIAMENTARY_REWARD_PERCENT.into() / 100u32.into(); let per_nft_reward = parliamentary_allocation / PARLIAMENTARY_NFT_COUNT.into(); let incentive_pot = Self::incentive_pot_account_id(); for nft_id in 1..=PARLIAMENTARY_NFT_COUNT { if let Some(owner) = Self::get_parliamentary_nft_owner(nft_id) { T::Assets::transfer( T::PezAssetId::get(), &incentive_pot, &owner, per_nft_reward, Preservation::Expendable, /* Allow source account to be deleted even if * it has no tokens during fund transfer */ )?; Self::deposit_event(Event::ParliamentaryNftRewardDistributed { nft_id, owner, amount: per_nft_reward, epoch, }); } } Ok(()) } /// Get parliamentary NFT owner from our storage pub fn get_parliamentary_nft_owner(nft_id: u32) -> Option { ParliamentaryNftOwners::::get(nft_id) } /// Register parliamentary NFT owner (can be called by governance) pub fn do_register_parliamentary_nft_owner(nft_id: u32, owner: T::AccountId) { ParliamentaryNftOwners::::insert(nft_id, owner.clone()); // NEW: Emit event Self::deposit_event(Event::ParliamentaryOwnerRegistered { nft_id, owner }); } } }