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:
2026-02-16 19:01:18 +03:00
parent 0a917d330b
commit 643c482611
9 changed files with 990 additions and 107 deletions
+1 -1
View File
@@ -52,7 +52,7 @@
| Para ID | Isim | Durum | spec_version | Block | Peers |
|---------|------|-------|-------------|-------|-------|
| 1000 | Asset Hub | CALISIYOR | 1_020_004 | ~11,009 | 1 |
| 1004 | People Chain | CALISIYOR | 1_020_004 | ~11,029 | 1 |
| 1004 | People Chain | CALISIYOR | 1_020_006 | ~17,200 | 1 |
#### Mainnet Servis Isimleri (VPS3)
- `pez-mainnet-validator-1` ... `pez-mainnet-validator-4`
@@ -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))
}
}
@@ -157,7 +157,7 @@ pub const VERSION: RuntimeVersion = RuntimeVersion {
spec_name: alloc::borrow::Cow::Borrowed("people-pezkuwichain"),
impl_name: alloc::borrow::Cow::Borrowed("people-pezkuwichain"),
authoring_version: 1,
spec_version: 1_020_006,
spec_version: 1_020_007,
impl_version: 0,
apis: RUNTIME_API_VERSIONS,
transaction_version: 1,
@@ -469,10 +469,20 @@ parameter_types! {
pub const StakingScoreUpdateInterval: BlockNumber = HOURS;
}
/// Noter authority checker backed by the Tiki pallet.
/// Accounts holding the `Noter` tiki role can submit staking details.
pub struct TikiNoterChecker;
impl pezpallet_staking_score::NoterCheck<AccountId> for TikiNoterChecker {
fn is_noter(who: &AccountId) -> bool {
pezpallet_tiki::Pezpallet::<Runtime>::has_tiki(who, &pezpallet_tiki::Tiki::Noter)
}
}
impl pezpallet_staking_score::Config for Runtime {
type WeightInfo = pezpallet_staking_score::weights::BizinikiwiWeight<Runtime>;
type Balance = Balance;
type OnStakingUpdate = Trust;
type NoterChecker = TikiNoterChecker;
}
// =============================================================================
+20 -4
View File
@@ -1,7 +1,9 @@
//! Send receive_staking_details to People Chain for all 21 validators via XCM Transact
//!
//! People Chain StakingScore pallet (index 80) has:
//! receive_staking_details(who, staked_amount, nominations_count, unlocking_chunks_count)
//! receive_staking_details(who, source, staked_amount, nominations_count, unlocking_chunks_count)
//!
//! source: StakingSource enum (0=RelayChain, 1=AssetHub)
//!
//! This populates CachedStakingDetails on People Chain so validators can
//! call start_score_tracking() and have their staking scores calculated.
@@ -57,22 +59,29 @@ fn validators() -> Vec<ValidatorInfo> {
const PLANCK_PER_HEZ: u128 = 1_000_000_000_000;
/// Encode StakingScore.receive_staking_details(who, staked_amount, nominations_count, unlocking_chunks_count)
/// StakingSource enum (SCALE-encoded as single byte)
const STAKING_SOURCE_RELAY_CHAIN: u8 = 0;
const STAKING_SOURCE_ASSET_HUB: u8 = 1;
/// Encode StakingScore.receive_staking_details(who, source, staked_amount, nominations_count, unlocking_chunks_count)
/// Pallet 80 (0x50), call_index 1
/// who: AccountId32 (32 bytes raw)
/// source: StakingSource (1 byte enum: 0=RelayChain, 1=AssetHub)
/// staked_amount: u128 LE (16 bytes) - this is T::Balance which is u128
/// nominations_count: u32 LE (4 bytes)
/// unlocking_chunks_count: u32 LE (4 bytes)
fn encode_receive_staking_details(
account_id: &[u8; 32],
source: u8,
staked_amount: u128,
nominations_count: u32,
unlocking_chunks_count: u32,
) -> Vec<u8> {
let mut encoded = Vec::with_capacity(58);
let mut encoded = Vec::with_capacity(59);
encoded.push(STAKING_SCORE_PALLET); // 0x50
encoded.push(RECEIVE_STAKING_DETAILS_CALL); // 0x01
encoded.extend_from_slice(account_id); // 32 bytes
encoded.push(source); // 1 byte (StakingSource enum)
encoded.extend_from_slice(&staked_amount.to_le_bytes()); // 16 bytes
encoded.extend_from_slice(&nominations_count.to_le_bytes()); // 4 bytes
encoded.extend_from_slice(&unlocking_chunks_count.to_le_bytes()); // 4 bytes
@@ -177,9 +186,16 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
};
// Encode receive_staking_details call
// source = RelayChain (validators stake on Relay Chain)
// nominations_count = 0 (validators don't nominate, they validate)
// unlocking_chunks_count = 0 (no pending unstakes)
let call = encode_receive_staking_details(&account.0, staked_planck, 0, 0);
let call = encode_receive_staking_details(
&account.0,
STAKING_SOURCE_RELAY_CHAIN,
staked_planck,
0,
0,
);
println!(
" Call: {} bytes (0x{}...)",
call.len(),