diff --git a/pezcumulus/teyrchains/pezpallets/staking-score/src/benchmarking.rs b/pezcumulus/teyrchains/pezpallets/staking-score/src/benchmarking.rs index de417f85..209f0eb7 100644 --- a/pezcumulus/teyrchains/pezpallets/staking-score/src/benchmarking.rs +++ b/pezcumulus/teyrchains/pezpallets/staking-score/src/benchmarking.rs @@ -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::::remove(&caller); + + // Pre-populate CachedStakingDetails for worst-case OnStakingUpdate callback. CachedStakingDetails::::insert( &caller, StakingSource::RelayChain, @@ -26,18 +29,28 @@ mod benchmarks { }, ); - StakingStartBlock::::remove(&caller); - #[extrinsic_call] _(RawOrigin::Signed(caller.clone())); assert!(StakingStartBlock::::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::::insert( + &target, + StakingSource::AssetHub, + StakingDetails { + staked_amount: (200u128 * UNITS).into(), + nominations_count: 1, + unlocking_chunks_count: 0, + }, + ); + #[extrinsic_call] _( RawOrigin::Root, diff --git a/pezcumulus/teyrchains/pezpallets/staking-score/src/lib.rs b/pezcumulus/teyrchains/pezpallets/staking-score/src/lib.rs index 894fb3a9..41e18944 100644 --- a/pezcumulus/teyrchains/pezpallets/staking-score/src/lib.rs +++ b/pezcumulus/teyrchains/pezpallets/staking-score/src/lib.rs @@ -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(_); + /// Trait for checking if an account has noter authority. + /// Noter-authorized accounts can submit staking details on behalf of users. + pub trait NoterCheck { + fn is_noter(who: &AccountId) -> bool; + } + + /// Default implementation: nobody is noter (safe default for tests). + impl NoterCheck for () { + fn is_noter(_who: &AccountId) -> bool { + false + } + } + #[pezpallet::config] pub trait Config: pezframe_system::Config>> 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; } // --- 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 Pezpallet { - /// 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) -> DispatchResult { @@ -152,21 +184,25 @@ pub mod pezpallet { Error::::TrackingAlreadyStarted ); - // Check if user has any stake from any source. - let total_stake = Self::total_cached_stake(&who); - ensure!(!total_stake.is_zero(), Error::::NoStakeFound); - let current_block = pezframe_system::Pezpallet::::block_number(); StakingStartBlock::::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::::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::::remove(&who, source); - CachedStakingDetails::::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::::remove(&who); + } + } else { + let details = + StakingDetails { staked_amount, nominations_count, unlocking_chunks_count }; + CachedStakingDetails::::insert(&who, source, details); + } T::OnStakingUpdate::on_staking_data_changed(&who); diff --git a/pezcumulus/teyrchains/pezpallets/staking-score/src/mock.rs b/pezcumulus/teyrchains/pezpallets/staking-score/src/mock.rs index 44f38454..e82d5379 100644 --- a/pezcumulus/teyrchains/pezpallets/staking-score/src/mock.rs +++ b/pezcumulus/teyrchains/pezpallets/staking-score/src/mock.rs @@ -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 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() } diff --git a/pezcumulus/teyrchains/pezpallets/staking-score/src/tests.rs b/pezcumulus/teyrchains/pezpallets/staking-score/src/tests.rs index c4082d20..83a755bb 100644 --- a/pezcumulus/teyrchains/pezpallets/staking-score/src/tests.rs +++ b/pezcumulus/teyrchains/pezpallets/staking-score/src/tests.rs @@ -2,7 +2,10 @@ //! All tests use receive_staking_details to populate CachedStakingDetails, //! mirroring the real People Chain architecture. -use crate::{mock::*, Error, Event, StakingScoreProvider, StakingSource, MONTH_IN_BLOCKS, UNITS}; +use crate::{ + mock::*, CachedStakingDetails, Error, Event, StakingScoreProvider, StakingSource, + StakingStartBlock, MONTH_IN_BLOCKS, UNITS, +}; use pezframe_support::{assert_noop, assert_ok}; const USER_STASH: AccountId = 10; @@ -13,14 +16,14 @@ const USER_STASH: AccountId = 10; #[test] fn zero_stake_should_return_zero_score() { - ExtBuilder::default().build_and_execute(|| { + ExtBuilder.build_and_execute(|| { assert_eq!(StakingScore::get_staking_score(&USER_STASH).0, 0); }); } #[test] fn score_is_calculated_correctly_without_time_tracking() { - ExtBuilder::default().build_and_execute(|| { + ExtBuilder.build_and_execute(|| { assert_ok!(StakingScore::receive_staking_details( RuntimeOrigin::root(), USER_STASH, @@ -36,7 +39,7 @@ fn score_is_calculated_correctly_without_time_tracking() { #[test] fn start_score_tracking_works_and_enables_duration_multiplier() { - ExtBuilder::default().build_and_execute(|| { + ExtBuilder.build_and_execute(|| { let initial_block = 10u64; System::set_block_number(initial_block); @@ -73,7 +76,7 @@ fn start_score_tracking_works_and_enables_duration_multiplier() { #[test] fn get_staking_score_works_without_explicit_tracking() { - ExtBuilder::default().build_and_execute(|| { + ExtBuilder.build_and_execute(|| { assert_ok!(StakingScore::receive_staking_details( RuntimeOrigin::root(), USER_STASH, @@ -97,7 +100,7 @@ fn get_staking_score_works_without_explicit_tracking() { #[test] fn amount_score_boundary_100_hez() { - ExtBuilder::default().build_and_execute(|| { + ExtBuilder.build_and_execute(|| { assert_ok!(StakingScore::receive_staking_details( RuntimeOrigin::root(), USER_STASH, @@ -113,7 +116,7 @@ fn amount_score_boundary_100_hez() { #[test] fn amount_score_boundary_250_hez() { - ExtBuilder::default().build_and_execute(|| { + ExtBuilder.build_and_execute(|| { assert_ok!(StakingScore::receive_staking_details( RuntimeOrigin::root(), USER_STASH, @@ -129,7 +132,7 @@ fn amount_score_boundary_250_hez() { #[test] fn amount_score_boundary_750_hez() { - ExtBuilder::default().build_and_execute(|| { + ExtBuilder.build_and_execute(|| { assert_ok!(StakingScore::receive_staking_details( RuntimeOrigin::root(), USER_STASH, @@ -145,7 +148,7 @@ fn amount_score_boundary_750_hez() { #[test] fn score_capped_at_100() { - ExtBuilder::default().build_and_execute(|| { + ExtBuilder.build_and_execute(|| { assert_ok!(StakingScore::receive_staking_details( RuntimeOrigin::root(), USER_STASH, @@ -171,7 +174,7 @@ fn score_capped_at_100() { #[test] fn duration_multiplier_1_month() { - ExtBuilder::default().build_and_execute(|| { + ExtBuilder.build_and_execute(|| { assert_ok!(StakingScore::receive_staking_details( RuntimeOrigin::root(), USER_STASH, @@ -193,7 +196,7 @@ fn duration_multiplier_1_month() { #[test] fn duration_multiplier_6_months() { - ExtBuilder::default().build_and_execute(|| { + ExtBuilder.build_and_execute(|| { assert_ok!(StakingScore::receive_staking_details( RuntimeOrigin::root(), USER_STASH, @@ -215,7 +218,7 @@ fn duration_multiplier_6_months() { #[test] fn duration_multiplier_progression() { - ExtBuilder::default().build_and_execute(|| { + ExtBuilder.build_and_execute(|| { let base_block = 100u64; System::set_block_number(base_block); @@ -248,29 +251,25 @@ fn duration_multiplier_progression() { // ============================================================================ #[test] -fn start_tracking_fails_without_stake() { - ExtBuilder::default().build_and_execute(|| { - assert_noop!( - StakingScore::start_score_tracking(RuntimeOrigin::signed(USER_STASH)), - Error::::NoStakeFound - ); +fn start_tracking_works_without_stake() { + ExtBuilder.build_and_execute(|| { + // Opt-in without any cached staking data — should succeed. + // Bot + noter will submit staking data later. + assert_ok!(StakingScore::start_score_tracking(RuntimeOrigin::signed(USER_STASH))); + + // StakingStartBlock is set, but score is 0 (no cached data yet). + assert!(StakingStartBlock::::get(USER_STASH).is_some()); + assert_eq!(StakingScore::get_staking_score(&USER_STASH).0, 0); }); } #[test] fn start_tracking_fails_if_already_started() { - ExtBuilder::default().build_and_execute(|| { - assert_ok!(StakingScore::receive_staking_details( - RuntimeOrigin::root(), - USER_STASH, - StakingSource::RelayChain, - 100 * UNITS, - 0, - 0 - )); - + ExtBuilder.build_and_execute(|| { + // First opt-in succeeds (no stake needed). assert_ok!(StakingScore::start_score_tracking(RuntimeOrigin::signed(USER_STASH))); + // Second attempt fails. assert_noop!( StakingScore::start_score_tracking(RuntimeOrigin::signed(USER_STASH)), Error::::TrackingAlreadyStarted @@ -280,18 +279,10 @@ fn start_tracking_fails_if_already_started() { #[test] fn start_tracking_emits_event() { - ExtBuilder::default().build_and_execute(|| { + ExtBuilder.build_and_execute(|| { System::set_block_number(1); - assert_ok!(StakingScore::receive_staking_details( - RuntimeOrigin::root(), - USER_STASH, - StakingSource::RelayChain, - 100 * UNITS, - 0, - 0 - )); - + // Opt-in without stake — event should still fire. assert_ok!(StakingScore::start_score_tracking(RuntimeOrigin::signed(USER_STASH))); let events = System::events(); @@ -303,7 +294,7 @@ fn start_tracking_emits_event() { #[test] fn start_tracking_works_with_only_asset_hub_stake() { - ExtBuilder::default().build_and_execute(|| { + ExtBuilder.build_and_execute(|| { System::set_block_number(1); // Only Asset Hub stake, no Relay Chain stake @@ -326,8 +317,9 @@ fn start_tracking_works_with_only_asset_hub_stake() { // ============================================================================ #[test] -fn receive_staking_details_requires_root() { - ExtBuilder::default().build_and_execute(|| { +fn receive_staking_details_rejects_non_noter() { + ExtBuilder.build_and_execute(|| { + // Regular user (not noter) cannot submit staking details. assert_noop!( StakingScore::receive_staking_details( RuntimeOrigin::signed(USER_STASH), @@ -337,14 +329,14 @@ fn receive_staking_details_requires_root() { 0, 0 ), - pezsp_runtime::DispatchError::BadOrigin + Error::::NotAuthorized ); }); } #[test] fn receive_staking_details_emits_event() { - ExtBuilder::default().build_and_execute(|| { + ExtBuilder.build_and_execute(|| { System::set_block_number(1); assert_ok!(StakingScore::receive_staking_details( @@ -365,7 +357,7 @@ fn receive_staking_details_emits_event() { #[test] fn receive_staking_details_overwrites_same_source() { - ExtBuilder::default().build_and_execute(|| { + ExtBuilder.build_and_execute(|| { // First: 100 HEZ from Relay assert_ok!(StakingScore::receive_staking_details( RuntimeOrigin::root(), @@ -397,7 +389,7 @@ fn receive_staking_details_overwrites_same_source() { #[test] fn relay_and_asset_hub_stake_aggregated() { - ExtBuilder::default().build_and_execute(|| { + ExtBuilder.build_and_execute(|| { // Relay Chain: 200 HEZ assert_ok!(StakingScore::receive_staking_details( RuntimeOrigin::root(), @@ -426,7 +418,7 @@ fn relay_and_asset_hub_stake_aggregated() { #[test] fn single_source_update_changes_aggregate() { - ExtBuilder::default().build_and_execute(|| { + ExtBuilder.build_and_execute(|| { // Relay: 100 HEZ -> <=100 tier -> 20 points assert_ok!(StakingScore::receive_staking_details( RuntimeOrigin::root(), @@ -453,7 +445,7 @@ fn single_source_update_changes_aggregate() { #[test] fn dual_source_with_duration_multiplier() { - ExtBuilder::default().build_and_execute(|| { + ExtBuilder.build_and_execute(|| { let base_block = 100u64; System::set_block_number(base_block); @@ -490,7 +482,7 @@ fn dual_source_with_duration_multiplier() { #[test] fn multiple_users_independent_scores() { - ExtBuilder::default().build_and_execute(|| { + ExtBuilder.build_and_execute(|| { let user1 = USER_STASH; let user2 = 20; @@ -531,7 +523,7 @@ fn multiple_users_independent_scores() { #[test] fn duration_returned_correctly() { - ExtBuilder::default().build_and_execute(|| { + ExtBuilder.build_and_execute(|| { let start_block = 100u64; System::set_block_number(start_block); @@ -558,3 +550,770 @@ fn duration_returned_correctly() { assert_eq!(duration, target_block - start_block); }); } + +// ============================================================================ +// Noter Authorization Tests +// ============================================================================ + +const NOTER: AccountId = 99; // MockNoterChecker recognizes 99 as noter + +#[test] +fn noter_can_submit_staking_details() { + ExtBuilder.build_and_execute(|| { + assert_ok!(StakingScore::receive_staking_details( + RuntimeOrigin::signed(NOTER), + USER_STASH, + StakingSource::RelayChain, + 200 * UNITS, + 0, + 0 + )); + + assert_eq!(StakingScore::get_staking_score(&USER_STASH).0, 30); + }); +} + +#[test] +fn root_can_still_submit_staking_details() { + ExtBuilder.build_and_execute(|| { + assert_ok!(StakingScore::receive_staking_details( + RuntimeOrigin::root(), + USER_STASH, + StakingSource::RelayChain, + 200 * UNITS, + 0, + 0 + )); + + assert_eq!(StakingScore::get_staking_score(&USER_STASH).0, 30); + }); +} + +// ============================================================================ +// Zero-Stake Cleanup Tests +// ============================================================================ + +#[test] +fn zero_stake_removes_cached_entry() { + ExtBuilder.build_and_execute(|| { + // Setup: noter submits 200 HEZ for relay chain + assert_ok!(StakingScore::receive_staking_details( + RuntimeOrigin::signed(NOTER), + USER_STASH, + StakingSource::RelayChain, + 200 * UNITS, + 0, + 0 + )); + assert!(CachedStakingDetails::::get(USER_STASH, StakingSource::RelayChain).is_some()); + + // Zero stake removes the entry + assert_ok!(StakingScore::receive_staking_details( + RuntimeOrigin::signed(NOTER), + USER_STASH, + StakingSource::RelayChain, + 0u128, + 0, + 0 + )); + assert!(CachedStakingDetails::::get(USER_STASH, StakingSource::RelayChain).is_none()); + assert_eq!(StakingScore::get_staking_score(&USER_STASH).0, 0); + }); +} + +#[test] +fn zero_stake_cleans_up_tracking_when_no_stake_remains() { + ExtBuilder.build_and_execute(|| { + // User opts in + assert_ok!(StakingScore::start_score_tracking(RuntimeOrigin::signed(USER_STASH))); + assert!(StakingStartBlock::::get(USER_STASH).is_some()); + + // Noter submits stake + assert_ok!(StakingScore::receive_staking_details( + RuntimeOrigin::signed(NOTER), + USER_STASH, + StakingSource::RelayChain, + 200 * UNITS, + 0, + 0 + )); + + // Zero out the only source → StakingStartBlock should be cleaned up + assert_ok!(StakingScore::receive_staking_details( + RuntimeOrigin::signed(NOTER), + USER_STASH, + StakingSource::RelayChain, + 0u128, + 0, + 0 + )); + assert!(StakingStartBlock::::get(USER_STASH).is_none()); + assert_eq!(StakingScore::get_staking_score(&USER_STASH).0, 0); + }); +} + +#[test] +fn zero_stake_one_source_keeps_tracking_if_other_source_has_stake() { + ExtBuilder.build_and_execute(|| { + // User opts in + assert_ok!(StakingScore::start_score_tracking(RuntimeOrigin::signed(USER_STASH))); + + // Noter submits stake for both sources + assert_ok!(StakingScore::receive_staking_details( + RuntimeOrigin::signed(NOTER), + USER_STASH, + StakingSource::RelayChain, + 100 * UNITS, + 0, + 0 + )); + assert_ok!(StakingScore::receive_staking_details( + RuntimeOrigin::signed(NOTER), + USER_STASH, + StakingSource::AssetHub, + 150 * UNITS, + 0, + 0 + )); + // Total 250 HEZ → tier 30 + assert_eq!(StakingScore::get_staking_score(&USER_STASH).0, 30); + + // Zero out relay chain only + assert_ok!(StakingScore::receive_staking_details( + RuntimeOrigin::signed(NOTER), + USER_STASH, + StakingSource::RelayChain, + 0u128, + 0, + 0 + )); + + // Tracking preserved (AssetHub still has stake) + assert!(StakingStartBlock::::get(USER_STASH).is_some()); + // 150 HEZ → tier 30 + assert_eq!(StakingScore::get_staking_score(&USER_STASH).0, 30); + }); +} + +// ============================================================================ +// Full Workflow Simulation (Plan Scenarios) +// ============================================================================ + +#[test] +fn full_workflow_ali_scenario() { + ExtBuilder.build_and_execute(|| { + let ali = USER_STASH; + + // 1. Ali opts in at block 1000 + System::set_block_number(1000); + assert_ok!(StakingScore::start_score_tracking(RuntimeOrigin::signed(ali))); + assert_eq!(StakingScore::get_staking_score(&ali).0, 0); // No data yet + + // 2. Bot + noter submit Ali's 200 HEZ relay stake + assert_ok!(StakingScore::receive_staking_details( + RuntimeOrigin::signed(NOTER), + ali, + StakingSource::RelayChain, + 200 * UNITS, + 0, + 0 + )); + // 200 HEZ → tier 30, duration < 1 month → x1.0 → 30 + assert_eq!(StakingScore::get_staking_score(&ali).0, 30); + + // 3. After 28 days → still < 1 month → x1.0 → 30 + System::set_block_number(1000 + 28 * 24 * 60 * 10); + assert_eq!(StakingScore::get_staking_score(&ali).0, 30); + }); +} + +#[test] +fn full_workflow_bob_unbond_scenario() { + ExtBuilder.build_and_execute(|| { + let bob = 20; + + // Bob opts in and has stake + assert_ok!(StakingScore::start_score_tracking(RuntimeOrigin::signed(bob))); + assert_ok!(StakingScore::receive_staking_details( + RuntimeOrigin::signed(NOTER), + bob, + StakingSource::RelayChain, + 200 * UNITS, + 0, + 0 + )); + assert_eq!(StakingScore::get_staking_score(&bob).0, 30); + + // Bob unbonds → noter reports zero + assert_ok!(StakingScore::receive_staking_details( + RuntimeOrigin::signed(NOTER), + bob, + StakingSource::RelayChain, + 0u128, + 0, + 0 + )); + + // Score = 0, tracking cleaned up + assert_eq!(StakingScore::get_staking_score(&bob).0, 0); + assert!(StakingStartBlock::::get(bob).is_none()); + }); +} + +#[test] +fn full_workflow_charlie_dual_chain_partial_unbond() { + ExtBuilder.build_and_execute(|| { + let charlie = 30; + System::set_block_number(500); + + // Charlie opts in + assert_ok!(StakingScore::start_score_tracking(RuntimeOrigin::signed(charlie))); + + // Noter submits both sources: 100 relay + 150 asset hub = 250 HEZ + assert_ok!(StakingScore::receive_staking_details( + RuntimeOrigin::signed(NOTER), + charlie, + StakingSource::RelayChain, + 100 * UNITS, + 0, + 0 + )); + assert_ok!(StakingScore::receive_staking_details( + RuntimeOrigin::signed(NOTER), + charlie, + StakingSource::AssetHub, + 150 * UNITS, + 0, + 0 + )); + // 250 HEZ → tier 30 + assert_eq!(StakingScore::get_staking_score(&charlie).0, 30); + + // Charlie unbonds from Relay Chain + assert_ok!(StakingScore::receive_staking_details( + RuntimeOrigin::signed(NOTER), + charlie, + StakingSource::RelayChain, + 0u128, + 0, + 0 + )); + + // Relay entry removed, Asset Hub remains + assert!(CachedStakingDetails::::get(charlie, StakingSource::RelayChain).is_none()); + assert!(CachedStakingDetails::::get(charlie, StakingSource::AssetHub).is_some()); + // Tracking preserved + assert!(StakingStartBlock::::get(charlie).is_some()); + // 150 HEZ → tier 30 (still in 101-250 range) + assert_eq!(StakingScore::get_staking_score(&charlie).0, 30); + + // After 3 months: 30 * 1.4 = 42 + System::set_block_number(500 + (3 * MONTH_IN_BLOCKS) as u64); + assert_eq!(StakingScore::get_staking_score(&charlie).0, 42); + + // Charlie unbonds from Asset Hub too → fully zeroed + assert_ok!(StakingScore::receive_staking_details( + RuntimeOrigin::signed(NOTER), + charlie, + StakingSource::AssetHub, + 0u128, + 0, + 0 + )); + assert!(StakingStartBlock::::get(charlie).is_none()); + assert_eq!(StakingScore::get_staking_score(&charlie).0, 0); + }); +} + +// ============================================================================ +// E2E: Duration Counts from Opt-in, Not from Data Arrival +// ============================================================================ + +#[test] +fn duration_counts_from_optin_not_from_data_arrival() { + ExtBuilder.build_and_execute(|| { + // Block 100: User opts in (no data yet) + System::set_block_number(100); + assert_ok!(StakingScore::start_score_tracking(RuntimeOrigin::signed(USER_STASH))); + assert_eq!(StakingScore::get_staking_score(&USER_STASH), (0, 0u64)); + + // Block 50_000: Bot + noter submit data (much later) + System::set_block_number(50_000); + assert_ok!(StakingScore::receive_staking_details( + RuntimeOrigin::signed(NOTER), + USER_STASH, + StakingSource::RelayChain, + 200 * UNITS, + 0, + 0 + )); + + // Duration is from block 100, NOT from block 50_000. + // 50_000 - 100 = 49_900 blocks. MONTH_IN_BLOCKS = 432_000 + // 49_900 < 432_000 → x1.0 → 30 + let (score, duration) = StakingScore::get_staking_score(&USER_STASH); + assert_eq!(duration, 49_900); + assert_eq!(score, 30); + + // After reaching 1 month from opt-in: 100 + 432_000 = 432_100 + System::set_block_number(100 + MONTH_IN_BLOCKS as u64); + let (score, _) = StakingScore::get_staking_score(&USER_STASH); + // 30 * 1.2 = 36 + assert_eq!(score, 36); + }); +} + +// ============================================================================ +// E2E: Re-opt-in After Full Unbond +// ============================================================================ + +#[test] +fn re_optin_after_full_unbond() { + ExtBuilder.build_and_execute(|| { + // Phase 1: opt-in + stake + score + System::set_block_number(100); + assert_ok!(StakingScore::start_score_tracking(RuntimeOrigin::signed(USER_STASH))); + assert_ok!(StakingScore::receive_staking_details( + RuntimeOrigin::signed(NOTER), + USER_STASH, + StakingSource::RelayChain, + 200 * UNITS, + 0, + 0 + )); + assert_eq!(StakingScore::get_staking_score(&USER_STASH).0, 30); + + // Phase 2: full unbond → cleanup + assert_ok!(StakingScore::receive_staking_details( + RuntimeOrigin::signed(NOTER), + USER_STASH, + StakingSource::RelayChain, + 0u128, + 0, + 0 + )); + assert!(StakingStartBlock::::get(USER_STASH).is_none()); + + // Phase 3: re-opt-in at block 1000 (fresh start) + System::set_block_number(1000); + assert_ok!(StakingScore::start_score_tracking(RuntimeOrigin::signed(USER_STASH))); + assert_eq!(StakingStartBlock::::get(USER_STASH), Some(1000)); + + // Phase 4: new stake data + assert_ok!(StakingScore::receive_staking_details( + RuntimeOrigin::signed(NOTER), + USER_STASH, + StakingSource::RelayChain, + 500 * UNITS, + 0, + 0 + )); + // 500 HEZ → tier 40, duration from block 1000 + assert_eq!(StakingScore::get_staking_score(&USER_STASH).0, 40); + + // After 6 months from re-opt-in: 40 * 1.7 = 68 + System::set_block_number(1000 + (6 * MONTH_IN_BLOCKS) as u64); + assert_eq!(StakingScore::get_staking_score(&USER_STASH).0, 68); + }); +} + +// ============================================================================ +// E2E: Noter Batch (Multiple Users in Sequence) +// ============================================================================ + +#[test] +fn noter_batch_multiple_users() { + ExtBuilder.build_and_execute(|| { + let user1: AccountId = 10; + let user2: AccountId = 20; + let user3: AccountId = 30; + + // All users opt in + assert_ok!(StakingScore::start_score_tracking(RuntimeOrigin::signed(user1))); + assert_ok!(StakingScore::start_score_tracking(RuntimeOrigin::signed(user2))); + assert_ok!(StakingScore::start_score_tracking(RuntimeOrigin::signed(user3))); + + // Noter submits batch: different amounts for each user + assert_ok!(StakingScore::receive_staking_details( + RuntimeOrigin::signed(NOTER), + user1, + StakingSource::RelayChain, + 50 * UNITS, // tier 20 + 0, + 0 + )); + assert_ok!(StakingScore::receive_staking_details( + RuntimeOrigin::signed(NOTER), + user2, + StakingSource::RelayChain, + 200 * UNITS, // tier 30 + 0, + 0 + )); + assert_ok!(StakingScore::receive_staking_details( + RuntimeOrigin::signed(NOTER), + user3, + StakingSource::AssetHub, + 800 * UNITS, // tier 50 + 0, + 0 + )); + + assert_eq!(StakingScore::get_staking_score(&user1).0, 20); + assert_eq!(StakingScore::get_staking_score(&user2).0, 30); + assert_eq!(StakingScore::get_staking_score(&user3).0, 50); + }); +} + +// ============================================================================ +// E2E: Data Submitted Without Opt-in (No StakingStartBlock) +// ============================================================================ + +#[test] +fn data_without_optin_still_cached_but_no_duration() { + ExtBuilder.build_and_execute(|| { + System::set_block_number(100); + + // Noter submits data for user who hasn't opted in + assert_ok!(StakingScore::receive_staking_details( + RuntimeOrigin::signed(NOTER), + USER_STASH, + StakingSource::RelayChain, + 300 * UNITS, + 0, + 0 + )); + + // Data is cached → score is base tier only (no duration) + assert!(CachedStakingDetails::::get(USER_STASH, StakingSource::RelayChain).is_some()); + assert!(StakingStartBlock::::get(USER_STASH).is_none()); + // 300 HEZ → tier 40, no duration multiplier + let (score, duration) = StakingScore::get_staking_score(&USER_STASH); + assert_eq!(score, 40); + assert_eq!(duration, 0); + + // Time passes without opt-in → no duration benefit + System::set_block_number(100 + (12 * MONTH_IN_BLOCKS) as u64); + assert_eq!(StakingScore::get_staking_score(&USER_STASH).0, 40); + + // User finally opts in → duration starts NOW + assert_ok!(StakingScore::start_score_tracking(RuntimeOrigin::signed(USER_STASH))); + assert_eq!( + StakingStartBlock::::get(USER_STASH), + Some(100 + (12 * MONTH_IN_BLOCKS) as u64) + ); + // Still x1.0 because just started + assert_eq!(StakingScore::get_staking_score(&USER_STASH).0, 40); + }); +} + +// ============================================================================ +// Edge Cases: Exact Tier Boundaries +// ============================================================================ + +#[test] +fn tier_boundary_101_hez() { + ExtBuilder.build_and_execute(|| { + assert_ok!(StakingScore::receive_staking_details( + RuntimeOrigin::root(), + USER_STASH, + StakingSource::RelayChain, + 101 * UNITS, + 0, + 0 + )); + // 101 > 100 → tier 30 + assert_eq!(StakingScore::get_staking_score(&USER_STASH).0, 30); + }); +} + +#[test] +fn tier_boundary_251_hez() { + ExtBuilder.build_and_execute(|| { + assert_ok!(StakingScore::receive_staking_details( + RuntimeOrigin::root(), + USER_STASH, + StakingSource::RelayChain, + 251 * UNITS, + 0, + 0 + )); + // 251 > 250 → tier 40 + assert_eq!(StakingScore::get_staking_score(&USER_STASH).0, 40); + }); +} + +#[test] +fn tier_boundary_751_hez() { + ExtBuilder.build_and_execute(|| { + assert_ok!(StakingScore::receive_staking_details( + RuntimeOrigin::root(), + USER_STASH, + StakingSource::RelayChain, + 751 * UNITS, + 0, + 0 + )); + // 751 > 750 → tier 50 + assert_eq!(StakingScore::get_staking_score(&USER_STASH).0, 50); + }); +} + +#[test] +fn sub_unit_stake_rounds_to_zero() { + ExtBuilder.build_and_execute(|| { + // Less than 1 HEZ (sub-UNITS) + assert_ok!(StakingScore::receive_staking_details( + RuntimeOrigin::root(), + USER_STASH, + StakingSource::RelayChain, + UNITS / 2, // 0.5 HEZ + 0, + 0 + )); + // staked_hez = 0.5 / 1 = 0 (integer division) → score 0 + assert_eq!(StakingScore::get_staking_score(&USER_STASH).0, 0); + }); +} + +#[test] +fn exactly_one_hez_returns_tier_20() { + ExtBuilder.build_and_execute(|| { + assert_ok!(StakingScore::receive_staking_details( + RuntimeOrigin::root(), + USER_STASH, + StakingSource::RelayChain, + UNITS, // 1 HEZ + 0, + 0 + )); + // 1 HEZ ≤ 100 → tier 20 + assert_eq!(StakingScore::get_staking_score(&USER_STASH).0, 20); + }); +} + +// ============================================================================ +// Edge Cases: Unsigned Origin Rejection +// ============================================================================ + +#[test] +fn unsigned_origin_rejected_for_start_tracking() { + ExtBuilder.build_and_execute(|| { + assert_noop!( + StakingScore::start_score_tracking(RuntimeOrigin::none()), + pezsp_runtime::DispatchError::BadOrigin + ); + }); +} + +#[test] +fn unsigned_origin_rejected_for_receive_details() { + ExtBuilder.build_and_execute(|| { + assert_noop!( + StakingScore::receive_staking_details( + RuntimeOrigin::none(), + USER_STASH, + StakingSource::RelayChain, + 100 * UNITS, + 0, + 0 + ), + pezsp_runtime::DispatchError::BadOrigin + ); + }); +} + +// ============================================================================ +// Edge Cases: Zero-Stake Events +// ============================================================================ + +#[test] +fn zero_stake_emits_event_with_zero_amount() { + ExtBuilder.build_and_execute(|| { + System::set_block_number(1); + + // Setup + assert_ok!(StakingScore::receive_staking_details( + RuntimeOrigin::signed(NOTER), + USER_STASH, + StakingSource::RelayChain, + 200 * UNITS, + 0, + 0 + )); + + // Zero-stake + assert_ok!(StakingScore::receive_staking_details( + RuntimeOrigin::signed(NOTER), + USER_STASH, + StakingSource::RelayChain, + 0u128, + 0, + 0 + )); + + let events = System::events(); + // Last event should be StakingDetailsReceived with amount 0 + let last_staking_event = events + .iter() + .rev() + .find(|e| { + matches!(e.event, RuntimeEvent::StakingScore(Event::StakingDetailsReceived { .. })) + }) + .expect("should have StakingDetailsReceived event"); + + match &last_staking_event.event { + RuntimeEvent::StakingScore(Event::StakingDetailsReceived { staked_amount, .. }) => { + assert_eq!(*staked_amount, 0u128) + }, + _ => panic!("wrong event type"), + } + }); +} + +// ============================================================================ +// Edge Cases: Noter Overwrites Previous Noter Submission +// ============================================================================ + +#[test] +fn noter_overwrites_previous_submission() { + ExtBuilder.build_and_execute(|| { + // First submission: 100 HEZ + assert_ok!(StakingScore::receive_staking_details( + RuntimeOrigin::signed(NOTER), + USER_STASH, + StakingSource::RelayChain, + 100 * UNITS, + 5, + 2 + )); + let details = + CachedStakingDetails::::get(USER_STASH, StakingSource::RelayChain).unwrap(); + assert_eq!(details.staked_amount, 100 * UNITS); + assert_eq!(details.nominations_count, 5); + assert_eq!(details.unlocking_chunks_count, 2); + + // Second submission: updated values + assert_ok!(StakingScore::receive_staking_details( + RuntimeOrigin::signed(NOTER), + USER_STASH, + StakingSource::RelayChain, + 300 * UNITS, + 10, + 1 + )); + let details = + CachedStakingDetails::::get(USER_STASH, StakingSource::RelayChain).unwrap(); + assert_eq!(details.staked_amount, 300 * UNITS); + assert_eq!(details.nominations_count, 10); + assert_eq!(details.unlocking_chunks_count, 1); + }); +} + +// ============================================================================ +// Storage Integrity: Zero-stake Does Not Create Ghost Entries +// ============================================================================ + +#[test] +fn zero_stake_for_nonexistent_source_is_noop() { + ExtBuilder.build_and_execute(|| { + // Zero-stake for a source that was never set — should be a no-op + assert_ok!(StakingScore::receive_staking_details( + RuntimeOrigin::signed(NOTER), + USER_STASH, + StakingSource::RelayChain, + 0u128, + 0, + 0 + )); + + // No ghost entries + assert!(CachedStakingDetails::::get(USER_STASH, StakingSource::RelayChain).is_none()); + assert!(StakingStartBlock::::get(USER_STASH).is_none()); + assert_eq!(StakingScore::get_staking_score(&USER_STASH).0, 0); + }); +} + +// ============================================================================ +// Max Score Scenario: Highest Tier + Max Duration +// ============================================================================ + +#[test] +fn max_score_scenario() { + ExtBuilder.build_and_execute(|| { + System::set_block_number(1); + + assert_ok!(StakingScore::start_score_tracking(RuntimeOrigin::signed(USER_STASH))); + + // Dual-chain max: 500 relay + 500 asset hub = 1000 HEZ → tier 50 + assert_ok!(StakingScore::receive_staking_details( + RuntimeOrigin::signed(NOTER), + USER_STASH, + StakingSource::RelayChain, + 500 * UNITS, + 0, + 0 + )); + assert_ok!(StakingScore::receive_staking_details( + RuntimeOrigin::signed(NOTER), + USER_STASH, + StakingSource::AssetHub, + 500 * UNITS, + 0, + 0 + )); + assert_eq!(StakingScore::get_staking_score(&USER_STASH).0, 50); + + // 12+ months → 50 * 2.0 = 100 (capped) + System::set_block_number(1 + (12 * MONTH_IN_BLOCKS) as u64); + assert_eq!(StakingScore::get_staking_score(&USER_STASH).0, 100); + + // 24 months → still 100 (cap) + System::set_block_number(1 + (24 * MONTH_IN_BLOCKS) as u64); + assert_eq!(StakingScore::get_staking_score(&USER_STASH).0, 100); + }); +} + +// ============================================================================ +// Duration Boundary: Exact Month Boundaries +// ============================================================================ + +#[test] +fn duration_exact_month_boundaries() { + ExtBuilder.build_and_execute(|| { + System::set_block_number(0); + assert_ok!(StakingScore::start_score_tracking(RuntimeOrigin::signed(USER_STASH))); + assert_ok!(StakingScore::receive_staking_details( + RuntimeOrigin::root(), + USER_STASH, + StakingSource::RelayChain, + 100 * UNITS, // tier 20 + 0, + 0 + )); + + // Exactly 1 month - 1 block: still x1.0 + System::set_block_number(MONTH_IN_BLOCKS as u64 - 1); + assert_eq!(StakingScore::get_staking_score(&USER_STASH).0, 20); + + // Exactly 1 month: x1.2 → 20 * 1.2 = 24 + System::set_block_number(MONTH_IN_BLOCKS as u64); + assert_eq!(StakingScore::get_staking_score(&USER_STASH).0, 24); + + // Exactly 3 months - 1: still x1.2 + System::set_block_number((3 * MONTH_IN_BLOCKS) as u64 - 1); + assert_eq!(StakingScore::get_staking_score(&USER_STASH).0, 24); + + // Exactly 3 months: x1.4 → 20 * 1.4 = 28 + System::set_block_number((3 * MONTH_IN_BLOCKS) as u64); + assert_eq!(StakingScore::get_staking_score(&USER_STASH).0, 28); + + // Exactly 6 months: x1.7 → 20 * 1.7 = 34 + System::set_block_number((6 * MONTH_IN_BLOCKS) as u64); + assert_eq!(StakingScore::get_staking_score(&USER_STASH).0, 34); + + // Exactly 12 months: x2.0 → 20 * 2.0 = 40 + System::set_block_number((12 * MONTH_IN_BLOCKS) as u64); + assert_eq!(StakingScore::get_staking_score(&USER_STASH).0, 40); + }); +} diff --git a/pezcumulus/teyrchains/pezpallets/staking-score/src/weights.rs b/pezcumulus/teyrchains/pezpallets/staking-score/src/weights.rs index 55e5b370..4348141e 100644 --- a/pezcumulus/teyrchains/pezpallets/staking-score/src/weights.rs +++ b/pezcumulus/teyrchains/pezpallets/staking-score/src/weights.rs @@ -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(PhantomData); impl WeightInfo for BizinikiwiWeight { - /// 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)) } } diff --git a/pezcumulus/teyrchains/runtimes/people/people-pezkuwichain/src/lib.rs b/pezcumulus/teyrchains/runtimes/people/people-pezkuwichain/src/lib.rs index 62e33ddb..d1753e89 100644 --- a/pezcumulus/teyrchains/runtimes/people/people-pezkuwichain/src/lib.rs +++ b/pezcumulus/teyrchains/runtimes/people/people-pezkuwichain/src/lib.rs @@ -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, diff --git a/pezcumulus/teyrchains/runtimes/people/people-pezkuwichain/src/people.rs b/pezcumulus/teyrchains/runtimes/people/people-pezkuwichain/src/people.rs index 00a462a9..2173add3 100644 --- a/pezcumulus/teyrchains/runtimes/people/people-pezkuwichain/src/people.rs +++ b/pezcumulus/teyrchains/runtimes/people/people-pezkuwichain/src/people.rs @@ -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 for TikiNoterChecker { + fn is_noter(who: &AccountId) -> bool { + pezpallet_tiki::Pezpallet::::has_tiki(who, &pezpallet_tiki::Tiki::Noter) + } +} + impl pezpallet_staking_score::Config for Runtime { type WeightInfo = pezpallet_staking_score::weights::BizinikiwiWeight; type Balance = Balance; type OnStakingUpdate = Trust; + type NoterChecker = TikiNoterChecker; } // ============================================================================= diff --git a/vendor/pezkuwi-subxt/subxt/examples/send_staking_details.rs b/vendor/pezkuwi-subxt/subxt/examples/send_staking_details.rs index 510e85b2..c5fe5824 100644 --- a/vendor/pezkuwi-subxt/subxt/examples/send_staking_details.rs +++ b/vendor/pezkuwi-subxt/subxt/examples/send_staking_details.rs @@ -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 { 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 { - 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> { }; // 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(),