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
This commit is contained in:
@@ -15,7 +15,10 @@ mod benchmarks {
|
||||
fn start_score_tracking() {
|
||||
let caller: T::AccountId = whitelisted_caller();
|
||||
|
||||
// Populate CachedStakingDetails with test data
|
||||
// Ensure no prior tracking exists.
|
||||
StakingStartBlock::<T>::remove(&caller);
|
||||
|
||||
// Pre-populate CachedStakingDetails for worst-case OnStakingUpdate callback.
|
||||
CachedStakingDetails::<T>::insert(
|
||||
&caller,
|
||||
StakingSource::RelayChain,
|
||||
@@ -26,18 +29,28 @@ mod benchmarks {
|
||||
},
|
||||
);
|
||||
|
||||
StakingStartBlock::<T>::remove(&caller);
|
||||
|
||||
#[extrinsic_call]
|
||||
_(RawOrigin::Signed(caller.clone()));
|
||||
|
||||
assert!(StakingStartBlock::<T>::get(&caller).is_some());
|
||||
}
|
||||
|
||||
/// Benchmark worst case: root origin, non-zero stake insert.
|
||||
#[benchmark]
|
||||
fn receive_staking_details() {
|
||||
let target: T::AccountId = whitelisted_caller();
|
||||
|
||||
// Pre-populate both sources for worst-case trust callback iteration.
|
||||
CachedStakingDetails::<T>::insert(
|
||||
&target,
|
||||
StakingSource::AssetHub,
|
||||
StakingDetails {
|
||||
staked_amount: (200u128 * UNITS).into(),
|
||||
nominations_count: 1,
|
||||
unlocking_chunks_count: 0,
|
||||
},
|
||||
);
|
||||
|
||||
#[extrinsic_call]
|
||||
_(
|
||||
RawOrigin::Root,
|
||||
|
||||
@@ -6,10 +6,16 @@
|
||||
//!
|
||||
//! ## Overview
|
||||
//!
|
||||
//! People Chain does not have direct access to staking data. Instead, staking details
|
||||
//! are pushed from Relay Chain and Asset Hub via XCM Transact into `CachedStakingDetails`.
|
||||
//! This pallet aggregates stake from all sources and calculates a score based on amount
|
||||
//! and duration.
|
||||
//! 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
|
||||
//!
|
||||
@@ -19,10 +25,11 @@
|
||||
//!
|
||||
//! ## Workflow
|
||||
//!
|
||||
//! 1. Relay Chain / Asset Hub pushes staking data via XCM → `receive_staking_details()`
|
||||
//! 2. User calls `start_score_tracking()` to begin time-based score accumulation
|
||||
//! 3. `pezpallet-trust` queries staking score via `StakingScoreProvider` trait
|
||||
//! 4. Score = base_score(amount_tier) * duration_multiplier, capped at 100
|
||||
//! 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::*;
|
||||
|
||||
@@ -71,6 +78,19 @@ pub mod pezpallet {
|
||||
#[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
|
||||
@@ -94,6 +114,10 @@ pub mod pezpallet {
|
||||
|
||||
/// 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 ---
|
||||
@@ -136,12 +160,20 @@ pub mod pezpallet {
|
||||
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 call per user.
|
||||
/// Requires the user to have cached staking data from at least one source.
|
||||
/// 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 {
|
||||
@@ -152,21 +184,25 @@ pub mod pezpallet {
|
||||
Error::<T>::TrackingAlreadyStarted
|
||||
);
|
||||
|
||||
// Check if user has any stake from any source.
|
||||
let total_stake = Self::total_cached_stake(&who);
|
||||
ensure!(!total_stake.is_zero(), Error::<T>::NoStakeFound);
|
||||
|
||||
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 from a chain via XCM Transact.
|
||||
/// Only root origin is accepted (XCM Transact from sibling/parent arrives as root).
|
||||
/// 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(
|
||||
@@ -177,12 +213,27 @@ pub mod pezpallet {
|
||||
nominations_count: u32,
|
||||
unlocking_chunks_count: u32,
|
||||
) -> DispatchResult {
|
||||
ensure_root(origin)?;
|
||||
// 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);
|
||||
}
|
||||
|
||||
let details =
|
||||
StakingDetails { staked_amount, nominations_count, unlocking_chunks_count };
|
||||
if staked_amount.is_zero() {
|
||||
// Zero stake: remove the cached entry for this source.
|
||||
CachedStakingDetails::<T>::remove(&who, source);
|
||||
|
||||
CachedStakingDetails::<T>::insert(&who, source, details);
|
||||
// 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);
|
||||
|
||||
|
||||
@@ -47,10 +47,20 @@ impl pezpallet_balances::Config for Test {
|
||||
type AccountStore = System;
|
||||
}
|
||||
|
||||
/// Mock noter checker for tests.
|
||||
/// Account 99 is noter, everyone else is not.
|
||||
pub struct MockNoterChecker;
|
||||
impl crate::NoterCheck<AccountId> for MockNoterChecker {
|
||||
fn is_noter(who: &AccountId) -> bool {
|
||||
*who == 99
|
||||
}
|
||||
}
|
||||
|
||||
impl crate::Config for Test {
|
||||
type Balance = Balance;
|
||||
type WeightInfo = ();
|
||||
type OnStakingUpdate = ();
|
||||
type NoterChecker = MockNoterChecker;
|
||||
}
|
||||
|
||||
// --- ExtBuilder ---
|
||||
@@ -73,6 +83,8 @@ impl ExtBuilder {
|
||||
(2, 1_000_000 * UNITS),
|
||||
(10, 1_000_000 * UNITS),
|
||||
(20, 100_000 * UNITS),
|
||||
(30, 100_000 * UNITS), // Charlie
|
||||
(99, 100_000 * UNITS), // NOTER
|
||||
],
|
||||
..Default::default()
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -21,6 +21,7 @@
|
||||
//! They account for the `OnStakingUpdate` callback cost (trust pallet update).
|
||||
//!
|
||||
//! DATE: 2026-02-16
|
||||
//! UPDATED: 2026-02-16 (noter delegation + zero-stake cleanup)
|
||||
//! TODO: Run proper benchmarks to replace these estimates.
|
||||
|
||||
#![cfg_attr(rustfmt, rustfmt_skip)]
|
||||
@@ -41,51 +42,72 @@ pub trait WeightInfo {
|
||||
/// Weights for `pezpallet_staking_score` using the Bizinikiwi node and recommended hardware.
|
||||
pub struct BizinikiwiWeight<T>(PhantomData<T>);
|
||||
impl<T: pezframe_system::Config> WeightInfo for BizinikiwiWeight<T> {
|
||||
/// Storage: `StakingScore::StakingStartBlock` (r:1 w:1)
|
||||
/// Storage: `StakingScore::CachedStakingDetails` (r:2 w:0) -- iter_prefix worst case 2 sources
|
||||
/// Storage: `Trust::TrustScores` (r:1 w:1) -- OnStakingUpdate callback
|
||||
/// Storage: `Trust::TotalActiveTrustScore` (r:1 w:1) -- OnStakingUpdate callback
|
||||
/// Storage: `IdentityKyc::KycStatuses` (r:1 w:0) -- citizenship check
|
||||
/// Storage: `StakingScore` reads for score calc (r:2 w:0)
|
||||
/// `start_score_tracking` — user opt-in, no stake check needed.
|
||||
///
|
||||
/// Total: r:8 w:3
|
||||
/// Pallet operations:
|
||||
/// StakingScore::StakingStartBlock (r:1 w:1) — check + insert
|
||||
///
|
||||
/// OnStakingUpdate callback (trust pallet):
|
||||
/// StakingScore::CachedStakingDetails (r:2 w:0) — iter_prefix for score calc
|
||||
/// StakingScore::StakingStartBlock (r:1 w:0) — duration lookup in score calc
|
||||
/// Trust::TrustScores (r:1 w:1) — update trust score
|
||||
/// Trust::TotalActiveTrustScore (r:1 w:1) — update aggregate
|
||||
/// IdentityKyc::KycStatuses (r:1 w:0) — citizenship gate
|
||||
///
|
||||
/// Total: r:7 w:3
|
||||
fn start_score_tracking() -> Weight {
|
||||
// Conservative estimate: ~35 microseconds execution + 8 reads + 3 writes
|
||||
// Proof size: StakingStartBlock(52) + CachedStakingDetails(77*2) + TrustScores(48) +
|
||||
// TotalActiveTrustScore(16) + KycStatuses(34) + overhead = ~8000
|
||||
Weight::from_parts(35_000_000, 8_000)
|
||||
.saturating_add(T::DbWeight::get().reads(8_u64))
|
||||
// ~30us execution + 7 reads + 3 writes
|
||||
// Proof size: StakingStartBlock(52) + CachedStakingDetails(77*2) +
|
||||
// TrustScores(48) + TotalActiveTrustScore(16) + KycStatuses(34) = ~7500
|
||||
Weight::from_parts(30_000_000, 7_500)
|
||||
.saturating_add(T::DbWeight::get().reads(7_u64))
|
||||
.saturating_add(T::DbWeight::get().writes(3_u64))
|
||||
}
|
||||
|
||||
/// Storage: `StakingScore::CachedStakingDetails` (r:1 w:1) -- DoubleMap insert
|
||||
/// Storage: `Trust::TrustScores` (r:1 w:1) -- OnStakingUpdate callback
|
||||
/// Storage: `Trust::TotalActiveTrustScore` (r:1 w:1) -- OnStakingUpdate callback
|
||||
/// Storage: `IdentityKyc::KycStatuses` (r:1 w:0) -- citizenship check
|
||||
/// Storage: `StakingScore` reads for score calc (r:2 w:0)
|
||||
/// `receive_staking_details` — worst case: noter signed + zero-stake cleanup.
|
||||
///
|
||||
/// Total: r:6 w:3
|
||||
/// Origin check (noter path):
|
||||
/// Tiki::UserTikis (r:1 w:0) — noter authority check via has_tiki()
|
||||
///
|
||||
/// Zero-stake cleanup path (worst case):
|
||||
/// StakingScore::CachedStakingDetails (r:1 w:1) — remove entry for source
|
||||
/// StakingScore::CachedStakingDetails (r:2 w:0) — iter_prefix remaining check
|
||||
/// StakingScore::StakingStartBlock (r:1 w:1) — remove if no remaining stake
|
||||
///
|
||||
/// OnStakingUpdate callback (trust pallet):
|
||||
/// StakingScore::CachedStakingDetails (r:2 w:0) — iter for score calc (overlaps)
|
||||
/// StakingScore::StakingStartBlock (r:1 w:0) — duration lookup (overlaps)
|
||||
/// Trust::TrustScores (r:1 w:1) — update trust score
|
||||
/// Trust::TotalActiveTrustScore (r:1 w:1) — update aggregate
|
||||
/// IdentityKyc::KycStatuses (r:1 w:0) — citizenship gate
|
||||
///
|
||||
/// Note: Some reads overlap (CachedStakingDetails iter + StakingStartBlock read
|
||||
/// are done both in cleanup and in the trust callback score calculation).
|
||||
/// Counting unique reads conservatively:
|
||||
///
|
||||
/// Total: r:10 w:4
|
||||
fn receive_staking_details() -> Weight {
|
||||
// Conservative estimate: ~30 microseconds execution + 6 reads + 3 writes
|
||||
// Proof size: CachedStakingDetails(77) + TrustScores(48) +
|
||||
// TotalActiveTrustScore(16) + KycStatuses(34) + overhead = ~7000
|
||||
Weight::from_parts(30_000_000, 7_000)
|
||||
.saturating_add(T::DbWeight::get().reads(6_u64))
|
||||
.saturating_add(T::DbWeight::get().writes(3_u64))
|
||||
// ~40us execution + 10 reads + 4 writes
|
||||
// Proof size: Tiki::UserTikis(200) + CachedStakingDetails(77*2) +
|
||||
// StakingStartBlock(52) + TrustScores(48) +
|
||||
// TotalActiveTrustScore(16) + KycStatuses(34) = ~9500
|
||||
Weight::from_parts(40_000_000, 9_500)
|
||||
.saturating_add(T::DbWeight::get().reads(10_u64))
|
||||
.saturating_add(T::DbWeight::get().writes(4_u64))
|
||||
}
|
||||
}
|
||||
|
||||
// For backwards compatibility and tests.
|
||||
impl WeightInfo for () {
|
||||
fn start_score_tracking() -> Weight {
|
||||
Weight::from_parts(35_000_000, 8_000)
|
||||
.saturating_add(RocksDbWeight::get().reads(8_u64))
|
||||
Weight::from_parts(30_000_000, 7_500)
|
||||
.saturating_add(RocksDbWeight::get().reads(7_u64))
|
||||
.saturating_add(RocksDbWeight::get().writes(3_u64))
|
||||
}
|
||||
|
||||
fn receive_staking_details() -> Weight {
|
||||
Weight::from_parts(30_000_000, 7_000)
|
||||
.saturating_add(RocksDbWeight::get().reads(6_u64))
|
||||
.saturating_add(RocksDbWeight::get().writes(3_u64))
|
||||
Weight::from_parts(40_000_000, 9_500)
|
||||
.saturating_add(RocksDbWeight::get().reads(10_u64))
|
||||
.saturating_add(RocksDbWeight::get().writes(4_u64))
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user