Files
pezkuwi-sdk/pezcumulus/teyrchains/pezpallets/staking-score/src/lib.rs
T
pezkuwichain 643c482611 feat: noter delegation for staking score system
- Add NoterCheck trait: accounts with Noter tiki can submit
  receive_staking_details without root origin
- Remove stake requirement from start_score_tracking (opt-in only,
  bot + noter submit data after event detection)
- Add zero-stake cleanup: sending staked_amount=0 removes cached
  entry, cleans up StakingStartBlock when no stake remains
- Add NotAuthorized error for non-noter signed callers
- Configure TikiNoterChecker in people-pezkuwichain runtime
- Update weights with detailed DB operation analysis
- Bump People Chain spec_version to 1_020_007
- 49 unit tests (17 new E2E + edge cases), fmt/clippy clean
2026-02-16 19:01:18 +03:00

350 lines
11 KiB
Rust

#![cfg_attr(not(feature = "std"), no_std)]
//! # Staking Score Pezpallet
//!
//! Calculates time-weighted staking scores from cached staking data received via XCM.
//!
//! ## Overview
//!
//! People Chain does not have direct access to staking data. Staking details are
//! submitted by noter-authorized accounts (or root via XCM Transact) into
//! `CachedStakingDetails`. This pallet aggregates stake from all sources and
//! calculates a score based on amount and duration.
//!
//! ## Noter Delegation
//!
//! The sudo account delegates `receive_staking_details` authority to accounts that
//! hold the `Noter` tiki (role NFT). A bot collects staking data from Relay Chain
//! and Asset Hub, then a noter signs and submits the data to People Chain.
//!
//! ## Dual-Chain Staking
//!
//! Users can stake on both Relay Chain (direct staking) and Asset Hub (nomination pools).
//! `CachedStakingDetails` is a `StorageDoubleMap` keyed by `(AccountId, StakingSource)`
//! to track stake per source. Score calculation aggregates across all sources.
//!
//! ## Workflow
//!
//! 1. User calls `start_score_tracking()` to opt-in to time-based scoring
//! 2. Bot detects the event, collects staking data from Relay Chain / Asset Hub
//! 3. Noter submits `receive_staking_details()` with the staking data
//! 4. `pezpallet-trust` queries staking score via `StakingScoreProvider` trait
//! 5. Score = base_score(amount_tier) * duration_multiplier, capped at 100
pub use pezpallet::*;
#[cfg(feature = "runtime-benchmarks")]
pub mod benchmarking;
#[cfg(test)]
mod mock;
#[cfg(test)]
mod tests;
pub mod weights;
#[pezframe_support::pezpallet]
pub mod pezpallet {
use super::weights::WeightInfo;
use core::ops::Div;
use pezframe_support::pezpallet_prelude::*;
use pezframe_system::pezpallet_prelude::*;
use pezsp_runtime::traits::{Saturating, Zero};
// --- Constants ---
pub const MONTH_IN_BLOCKS: u32 = 30 * 24 * 60 * 10;
pub const UNITS: u128 = 1_000_000_000_000;
/// The chain from which staking data originates.
#[derive(
Encode,
Decode,
DecodeWithMemTracking,
Clone,
Copy,
PartialEq,
Eq,
TypeInfo,
Debug,
MaxEncodedLen,
)]
pub enum StakingSource {
/// Direct staking on the Relay Chain.
RelayChain = 0,
/// Staking via nomination pools on Asset Hub.
AssetHub = 1,
}
#[pezpallet::pezpallet]
pub struct Pezpallet<T>(_);
/// Trait for checking if an account has noter authority.
/// Noter-authorized accounts can submit staking details on behalf of users.
pub trait NoterCheck<AccountId> {
fn is_noter(who: &AccountId) -> bool;
}
/// Default implementation: nobody is noter (safe default for tests).
impl<AccountId> NoterCheck<AccountId> for () {
fn is_noter(_who: &AccountId) -> bool {
false
}
}
#[pezpallet::config]
pub trait Config: pezframe_system::Config<RuntimeEvent: From<Event<Self>>>
where
BlockNumberFor<Self>: From<u32>,
{
/// Balance type used for staking amounts.
type Balance: Member
+ Parameter
+ MaxEncodedLen
+ Copy
+ Default
+ PartialOrd
+ Saturating
+ Zero
+ Div<Output = Self::Balance>
+ From<u128>;
/// Callback when staking data changes for an account.
/// Trust pallet implements this to trigger score recalculation.
type OnStakingUpdate: OnStakingDataUpdate<Self::AccountId>;
/// Weight information for extrinsics.
type WeightInfo: WeightInfo;
/// Checker for noter authority. Accounts with the Noter tiki can submit
/// staking details without requiring root origin.
type NoterChecker: NoterCheck<Self::AccountId>;
}
// --- Storage ---
#[pezpallet::storage]
#[pezpallet::getter(fn staking_start_block)]
pub type StakingStartBlock<T: Config> =
StorageMap<_, Blake2_128Concat, T::AccountId, BlockNumberFor<T>, OptionQuery>;
/// Cached staking details received via XCM from various chains.
/// Keyed by (AccountId, StakingSource) to support stake aggregation across chains.
#[pezpallet::storage]
#[pezpallet::getter(fn cached_staking_details)]
pub type CachedStakingDetails<T: Config> = StorageDoubleMap<
_,
Blake2_128Concat,
T::AccountId,
Blake2_128Concat,
StakingSource,
StakingDetails<T::Balance>,
OptionQuery,
>;
#[pezpallet::event]
#[pezpallet::generate_deposit(pub(super) fn deposit_event)]
pub enum Event<T: Config> {
/// A user started time-based scoring.
ScoreTrackingStarted { who: T::AccountId, start_block: BlockNumberFor<T> },
/// Staking details received from a chain via XCM.
StakingDetailsReceived {
who: T::AccountId,
source: StakingSource,
staked_amount: T::Balance,
},
}
#[pezpallet::error]
pub enum Error<T> {
/// User must have stake to start score tracking.
NoStakeFound,
/// Score tracking has already been started for this account.
TrackingAlreadyStarted,
/// Caller does not have noter authority.
NotAuthorized,
}
#[pezpallet::call]
impl<T: Config> Pezpallet<T> {
/// Start time-based score accumulation. One-time opt-in call per user.
///
/// The user does not need to have cached staking data yet. A bot will
/// detect the `ScoreTrackingStarted` event and a noter will submit the
/// staking data via `receive_staking_details`.
///
/// Duration tracking begins at the block this is called, regardless of
/// when the staking data arrives.
#[pezpallet::call_index(0)]
#[pezpallet::weight(T::WeightInfo::start_score_tracking())]
pub fn start_score_tracking(origin: OriginFor<T>) -> DispatchResult {
let who = ensure_signed(origin)?;
ensure!(
StakingStartBlock::<T>::get(&who).is_none(),
Error::<T>::TrackingAlreadyStarted
);
let current_block = pezframe_system::Pezpallet::<T>::block_number();
StakingStartBlock::<T>::insert(&who, current_block);
// Notify trust pallet. Score may be 0 if CachedStakingDetails is empty.
T::OnStakingUpdate::on_staking_data_changed(&who);
Self::deposit_event(Event::ScoreTrackingStarted { who, start_block: current_block });
Ok(())
}
/// Receive staking details for an account.
///
/// Accepts root origin (XCM Transact) or a signed origin from an account
/// that holds the Noter tiki. This allows a noter-authorized bot to submit
/// staking data collected from Relay Chain and Asset Hub.
///
/// If `staked_amount` is zero, the cached entry for the given source is
/// removed. If no stake remains from any source, `StakingStartBlock` is
/// also cleaned up, effectively resetting the user's staking score to zero.
#[pezpallet::call_index(1)]
#[pezpallet::weight(T::WeightInfo::receive_staking_details())]
pub fn receive_staking_details(
origin: OriginFor<T>,
who: T::AccountId,
source: StakingSource,
staked_amount: T::Balance,
nominations_count: u32,
unlocking_chunks_count: u32,
) -> DispatchResult {
// Root (XCM Transact) OR noter-authorized signed origin.
if ensure_root(origin.clone()).is_err() {
let caller = ensure_signed(origin)?;
ensure!(T::NoterChecker::is_noter(&caller), Error::<T>::NotAuthorized);
}
if staked_amount.is_zero() {
// Zero stake: remove the cached entry for this source.
CachedStakingDetails::<T>::remove(&who, source);
// Check if any stake remains from other sources.
let remaining = Self::total_cached_stake(&who);
if remaining.is_zero() {
// No stake from any source — clean up tracking.
StakingStartBlock::<T>::remove(&who);
}
} else {
let details =
StakingDetails { staked_amount, nominations_count, unlocking_chunks_count };
CachedStakingDetails::<T>::insert(&who, source, details);
}
T::OnStakingUpdate::on_staking_data_changed(&who);
Self::deposit_event(Event::StakingDetailsReceived { who, source, staked_amount });
Ok(())
}
}
// --- Types ---
/// Raw score type used in staking score calculations.
pub type RawScore = u32;
/// Staking details for a single source chain.
#[derive(
Default,
Encode,
Decode,
DecodeWithMemTracking,
Clone,
PartialEq,
Eq,
TypeInfo,
Debug,
MaxEncodedLen,
)]
pub struct StakingDetails<Balance> {
pub staked_amount: Balance,
pub nominations_count: u32,
pub unlocking_chunks_count: u32,
}
// --- Traits ---
/// Interface for querying staking scores. Used by trust pallet.
pub trait StakingScoreProvider<AccountId, BlockNumber> {
/// Returns (score, duration_in_blocks) for the given account.
fn get_staking_score(who: &AccountId) -> (RawScore, BlockNumber);
}
/// Callback trait for when staking data changes.
/// Trust pallet implements this to recalculate scores on staking updates.
pub trait OnStakingDataUpdate<AccountId> {
fn on_staking_data_changed(who: &AccountId);
}
impl<AccountId> OnStakingDataUpdate<AccountId> for () {
fn on_staking_data_changed(_who: &AccountId) {}
}
// --- Helpers ---
impl<T: Config> Pezpallet<T> {
/// Calculate total cached stake across all sources for a given account.
pub fn total_cached_stake(who: &T::AccountId) -> T::Balance {
let mut total = T::Balance::zero();
for (_, details) in CachedStakingDetails::<T>::iter_prefix(who) {
total = total.saturating_add(details.staked_amount);
}
total
}
}
// --- StakingScoreProvider Implementation ---
impl<T: Config> StakingScoreProvider<T::AccountId, BlockNumberFor<T>> for Pezpallet<T> {
fn get_staking_score(who: &T::AccountId) -> (RawScore, BlockNumberFor<T>) {
// Aggregate stake from all cached sources.
let total_staked = Self::total_cached_stake(who);
let staked_hez: T::Balance = total_staked / UNITS.into();
if staked_hez.is_zero() {
return (0, Zero::zero());
}
// Amount-based tier scoring.
let amount_score: u32 = if staked_hez <= 100u128.into() {
20
} else if staked_hez <= 250u128.into() {
30
} else if staked_hez <= 750u128.into() {
40
} else {
50 // 751+ HEZ
};
// Duration-based multiplier.
let (final_score, duration_for_return) = match StakingStartBlock::<T>::get(who) {
Some(start_block) => {
let current_block = pezframe_system::Pezpallet::<T>::block_number();
let duration_in_blocks = current_block.saturating_sub(start_block);
let score = if duration_in_blocks >= (12 * MONTH_IN_BLOCKS).into() {
amount_score * 2 // x2.0 (12+ months)
} else if duration_in_blocks >= (6 * MONTH_IN_BLOCKS).into() {
amount_score * 17 / 10 // x1.7 (6-11 months)
} else if duration_in_blocks >= (3 * MONTH_IN_BLOCKS).into() {
amount_score * 14 / 10 // x1.4 (3-5 months)
} else if duration_in_blocks >= MONTH_IN_BLOCKS.into() {
amount_score * 12 / 10 // x1.2 (1-2 months)
} else {
amount_score // x1.0 (< 1 month)
};
(score, duration_in_blocks)
},
None => (amount_score, Zero::zero()),
};
(final_score.min(100), duration_for_return)
}
}
}