diff --git a/blockchain/pallets/network-validator/src/lib.rs b/blockchain/pallets/network-validator/src/lib.rs index 8e6aad5..ddceede 100644 --- a/blockchain/pallets/network-validator/src/lib.rs +++ b/blockchain/pallets/network-validator/src/lib.rs @@ -13,12 +13,13 @@ //! (provider, round); its members submit per-dimension integer //! evidence; once a quorum is reached the round closes with a //! trimmed-mean aggregate pushed into `pallet-reputation` through -//! [`ReputationUpdater`]. Closed rounds can be contested within a -//! bounded window, which rolls the dimension back pending governance -//! resolution. +//! [`ReputationUpdater`], and submitters that survived trimming accrue +//! Reward Points through [`ValidatorRewards`]. Closed rounds can be +//! contested within a bounded window, which rolls the dimension back +//! pending governance resolution. //! -//! Still open per ADR-011: validator reward/penalty accrual, and -//! unpredictable (VRF-backed) committee entropy -- see +//! Still open per ADR-011: slashing (penalties beyond earning nothing for +//! a round), and unpredictable (VRF-backed) committee entropy -- see //! [`Pallet::committee`] for why assignment is currently predictable. extern crate alloc; @@ -80,6 +81,19 @@ impl ReputationUpdater for () { } } +/// Credits Reward Points to validators whose submission survived a +/// round's outlier trimming. `pallet-rewards` stays the only writer of +/// reward balances (ADR-011 §5). +pub trait ValidatorRewards { + fn accrue(validator: &AccountId, points: u64) -> DispatchResult; +} + +impl ValidatorRewards for () { + fn accrue(_: &AccountId, _: u64) -> DispatchResult { + Ok(()) + } +} + /// Narrow interface for pallets that only need to know whether an account is /// currently an active, bonded Network Validator (e.g. an origin check on /// availability/reputation submission calls). @@ -152,6 +166,9 @@ pub mod pallet { /// Receives closed-round aggregates; the runtime wires this to /// `pallet-reputation`. type ReputationUpdater: ReputationUpdater; + /// Credits Reward Points to non-outlier submitters; the runtime + /// wires this to `pallet-rewards`. + type ValidatorRewards: ValidatorRewards; #[pallet::constant] type MinStake: Get>; #[pallet::constant] @@ -178,6 +195,9 @@ pub mod pallet { /// How long after a round closes it may still be disputed. #[pallet::constant] type DisputeWindow: Get>; + /// Reward Points credited per submission that survived trimming. + #[pallet::constant] + type PointsPerAcceptedSubmission: Get; type WeightInfo: WeightInfo; } @@ -342,6 +362,8 @@ pub mod pallet { score_bps: u16, submissions: u32, committee_target: u32, + /// How many submitters survived trimming and were rewarded. + rewarded: u32, }, RoundDisputed { provider: T::AccountId, @@ -631,7 +653,7 @@ pub mod pallet { let count = submissions.len() as u32; ensure!(count >= T::MinQuorum::get(), Error::::QuorumNotReached); - let score_bps = Self::trimmed_mean(&submissions); + let (accepted, score_bps) = Self::trimmed(&submissions); let committee_target = T::TargetCommitteeSize::get(); let closed_at = frame_system::Pallet::::block_number(); // Captured before applying, so an upheld dispute restores the @@ -656,6 +678,14 @@ pub mod pallet { T::ReputationUpdater::record_dimension_score(&provider, dimension, score_bps)?; + // Reward only the submissions that survived trimming: an + // outlier -- whether colluding or merely malfunctioning -- + // earns nothing for that round. + let points = T::PointsPerAcceptedSubmission::get(); + for entry in &accepted { + T::ValidatorRewards::accrue(&entry.validator, points)?; + } + Self::deposit_event(Event::RoundClosed { provider, round, @@ -663,6 +693,7 @@ pub mod pallet { score_bps, submissions: count, committee_target, + rewarded: accepted.len() as u32, }); Ok(()) } @@ -831,23 +862,35 @@ pub mod pallet { /// outlier (a colluding or malfunctioning validator) cannot move /// the result. Deterministic and float-free, over a set bounded by /// `MaxSubmissionsPerRound`. - fn trimmed_mean(submissions: &[Submission]) -> u16 { + /// Returns the surviving submissions (sorted) alongside their mean, + /// so callers can both commit the aggregate and reward exactly the + /// validators whose observation counted. + /// + /// Sorting is by `(score_bps, validator)` rather than score alone: + /// with tied scores that makes the choice of which entry gets + /// trimmed total-ordered and therefore identical on every node. + fn trimmed( + submissions: &[Submission], + ) -> (alloc::vec::Vec>, u16) { if submissions.is_empty() { - return 0; + return (alloc::vec::Vec::new(), 0); } - let mut scores: alloc::vec::Vec = - submissions.iter().map(|s| u32::from(s.score_bps)).collect(); - scores.sort_unstable(); - let considered: &[u32] = if scores.len() >= 3 { - &scores[1..scores.len() - 1] + let mut sorted = submissions.to_vec(); + sorted.sort_by(|left, right| { + left.score_bps + .cmp(&right.score_bps) + .then_with(|| left.validator.cmp(&right.validator)) + }); + let considered: alloc::vec::Vec> = if sorted.len() >= 3 { + sorted[1..sorted.len() - 1].to_vec() } else { - &scores[..] + sorted }; - let total: u32 = considered - .iter() - .fold(0u32, |acc, v| acc.saturating_add(*v)); - let mean = total / considered.len() as u32; - mean.min(10_000) as u16 + let total: u32 = considered.iter().fold(0u32, |acc, entry| { + acc.saturating_add(u32::from(entry.score_bps)) + }); + let mean = (total / considered.len() as u32).min(10_000) as u16; + (considered, mean) } /// True only for a registered validator whose status is `Active` diff --git a/blockchain/pallets/network-validator/src/tests.rs b/blockchain/pallets/network-validator/src/tests.rs index 24f41c4..b209394 100644 --- a/blockchain/pallets/network-validator/src/tests.rs +++ b/blockchain/pallets/network-validator/src/tests.rs @@ -85,6 +85,27 @@ impl crate::ReputationUpdater for RecordingUpdater { } } +// Reward Points credited per validator, so tests can assert that only +// non-outlier submitters were paid. +thread_local! { + static POINTS: std::cell::RefCell> = + const { std::cell::RefCell::new(std::collections::BTreeMap::new()) }; +} + +pub struct RecordingRewards; +impl crate::ValidatorRewards for RecordingRewards { + fn accrue(validator: &u64, points: u64) -> sp_runtime::DispatchResult { + POINTS.with(|store| { + *store.borrow_mut().entry(*validator).or_default() += points; + }); + Ok(()) + } +} + +fn points_of(validator: u64) -> u64 { + POINTS.with(|store| store.borrow().get(&validator).copied().unwrap_or(0)) +} + fn current_score(provider: u64, dimension: ScoreDimension) -> u16 { >::dimension_score(&provider, dimension) } @@ -96,6 +117,7 @@ fn recorded() -> Vec<(u64, ScoreDimension, u16)> { fn clear_recorded() { RECORDED.with(|recorded| recorded.borrow_mut().clear()); CURRENT.with(|current| current.borrow_mut().clear()); + POINTS.with(|store| store.borrow_mut().clear()); } parameter_types! { @@ -104,12 +126,14 @@ parameter_types! { pub const TargetCommitteeSize: u32 = 5; pub const MaxValidators: u32 = 16; pub const DisputeWindow: u64 = 20; + pub const PointsPerAcceptedSubmission: u64 = 7; } impl crate::Config for Test { type Currency = Balances; type SuspensionOrigin = frame_system::EnsureRoot; type ReputationUpdater = RecordingUpdater; + type ValidatorRewards = RecordingRewards; type MinStake = MinStake; type UnbondingPeriod = UnbondingPeriod; type MaxSubmissionsPerRound = MaxSubmissionsPerRound; @@ -117,6 +141,7 @@ impl crate::Config for Test { type TargetCommitteeSize = TargetCommitteeSize; type MaxValidators = MaxValidators; type DisputeWindow = DisputeWindow; + type PointsPerAcceptedSubmission = PointsPerAcceptedSubmission; type WeightInfo = (); } @@ -884,3 +909,100 @@ fn resolving_requires_governance_and_an_actual_dispute() { ); }); } + +// --- Validator reward accrual (ADR-011 §5) --- + +#[test] +fn only_submissions_surviving_trimming_are_rewarded() { + new_test_ext().execute_with(|| { + clear_recorded(); + System::set_block_number(1); + register_validators(6); + let assigned = committee(); + // Sorted by score, the 0 and the 10_000 are trimmed; the three + // 6_000s survive and are the only ones paid. + let scores = [0u16, 6_000, 6_000, 6_000, 10_000]; + for (member, score) in assigned.iter().zip(scores) { + assert_ok!(submit(*member, ScoreDimension::Compute, score)); + } + assert_ok!(NetworkValidator::close_round( + RuntimeOrigin::signed(assigned[0]), + PROVIDER, + ROUND, + ScoreDimension::Compute + )); + + let low_outlier = assigned[0]; + let high_outlier = assigned[4]; + assert_eq!(points_of(low_outlier), 0, "a low outlier earns nothing"); + assert_eq!(points_of(high_outlier), 0, "a high outlier earns nothing"); + for member in assigned.iter().take(4).skip(1) { + assert_eq!( + points_of(*member), + PointsPerAcceptedSubmission::get(), + "a non-outlier submitter is rewarded exactly once" + ); + } + }); +} + +#[test] +fn round_closed_event_reports_how_many_were_rewarded() { + new_test_ext().execute_with(|| { + clear_recorded(); + System::set_block_number(1); + register_validators(6); + let assigned = committee(); + for member in assigned.iter().take(3) { + assert_ok!(submit(*member, ScoreDimension::Network, 5_000)); + } + assert_ok!(NetworkValidator::close_round( + RuntimeOrigin::signed(assigned[0]), + PROVIDER, + ROUND, + ScoreDimension::Network + )); + // Three submissions -> one trimmed at each end -> one rewarded. + let rewarded: u64 = assigned + .iter() + .take(3) + .map(|member| points_of(*member)) + .sum(); + assert_eq!(rewarded, PointsPerAcceptedSubmission::get()); + }); +} + +#[test] +fn tied_scores_are_trimmed_deterministically() { + new_test_ext().execute_with(|| { + clear_recorded(); + System::set_block_number(1); + register_validators(6); + let assigned = committee(); + // All-equal scores: the mean is unambiguous, and the tie-break on + // validator id must make the choice of who gets trimmed stable + // rather than dependent on submission order. + for member in assigned.iter() { + assert_ok!(submit(*member, ScoreDimension::Storage, 5_000)); + } + assert_ok!(NetworkValidator::close_round( + RuntimeOrigin::signed(assigned[0]), + PROVIDER, + ROUND, + ScoreDimension::Storage + )); + assert_eq!(recorded(), vec![(PROVIDER, ScoreDimension::Storage, 5_000)]); + // Five submitted, two trimmed, three paid. + let paid = assigned + .iter() + .filter(|member| points_of(**member) > 0) + .count(); + assert_eq!(paid, 3); + // The trimmed pair is the lowest and highest validator id, since + // all scores tie. + let mut sorted = assigned.clone(); + sorted.sort_unstable(); + assert_eq!(points_of(sorted[0]), 0); + assert_eq!(points_of(sorted[4]), 0); + }); +} diff --git a/blockchain/pallets/rewards/src/lib.rs b/blockchain/pallets/rewards/src/lib.rs index a25f928..330e97e 100644 --- a/blockchain/pallets/rewards/src/lib.rs +++ b/blockchain/pallets/rewards/src/lib.rs @@ -68,6 +68,12 @@ pub mod pallet { provider: T::AccountId, points: RewardPoints, }, + /// Points credited to a Network Validator whose submission + /// survived a round's outlier trimming (ADR-011 §5). + ValidatorRewardAccrued { + validator: T::AccountId, + points: RewardPoints, + }, } #[pallet::error] @@ -144,6 +150,9 @@ pub mod pallet { Ok(()) } + /// Claim the caller's accrued Reward Points. Providers and Network + /// Validators share this path -- both accrue into + /// [`RewardBalances`] keyed by their own account. #[pallet::call_index(1)] #[pallet::weight(T::WeightInfo::claim_reward())] pub fn claim_reward(origin: OriginFor) -> DispatchResult { @@ -158,6 +167,34 @@ pub mod pallet { Ok(()) } } + + impl Pallet { + /// Credit Reward Points without an extrinsic. + /// + /// Used by the validator scoring pallet to reward submissions that + /// survived a round's outlier trimming, so this pallet stays the + /// only writer of [`RewardBalances`] and keeps its own overflow + /// checking regardless of the caller (ADR-011 §5). + /// + /// Crediting is one-way: an upheld dispute does **not** currently + /// claw back points already accrued for that round. Clawback is + /// tied to slashing economics, which ADR-011 leaves out of scope. + pub fn accrue_points(account: &T::AccountId, points: RewardPoints) -> DispatchResult { + if points == 0 { + return Ok(()); + } + let balance = RewardBalances::::get(account); + let new_balance = balance + .checked_add(points) + .ok_or(Error::::ArithmeticOverflow)?; + RewardBalances::::insert(account, new_balance); + Self::deposit_event(Event::ValidatorRewardAccrued { + validator: account.clone(), + points, + }); + Ok(()) + } + } } #[cfg(test)] diff --git a/blockchain/runtime/src/lib.rs b/blockchain/runtime/src/lib.rs index e41dd51..ab9ea6b 100644 --- a/blockchain/runtime/src/lib.rs +++ b/blockchain/runtime/src/lib.rs @@ -113,6 +113,7 @@ parameter_types! { pub const MaxNetworkValidators: u32 = 256; // ~30 minutes at 6s blocks to contest a closed round. pub const ValidatorDisputeWindow: u32 = 300; + pub const ValidatorPointsPerAcceptedSubmission: u64 = 10; } #[derive_impl(frame_system::config_preludes::SolochainDefaultConfig)] @@ -293,9 +294,22 @@ impl ScoringReputationUpdater { } } +/// Credits validator Reward Points through `pallet-rewards`, which stays +/// the only writer of reward balances (ADR-011 §5). +pub struct ValidatorRewardsBridge; +impl pallet_network_validator::ValidatorRewards for ValidatorRewardsBridge { + fn accrue( + validator: &interface::AccountId, + points: u64, + ) -> frame::deps::sp_runtime::DispatchResult { + pallet_openinfra_rewards::Pallet::::accrue_points(validator, points) + } +} + impl pallet_network_validator::Config for Runtime { type Currency = Balances; type ReputationUpdater = ScoringReputationUpdater; + type ValidatorRewards = ValidatorRewardsBridge; // Suspend/reinstate is root-gated for the MVP; a validator // committee/governance origin is ADR-011 §5 follow-up work, not decided // by this pallet yet. @@ -307,6 +321,7 @@ impl pallet_network_validator::Config for Runtime { type TargetCommitteeSize = ValidatorTargetCommitteeSize; type MaxValidators = MaxNetworkValidators; type DisputeWindow = ValidatorDisputeWindow; + type PointsPerAcceptedSubmission = ValidatorPointsPerAcceptedSubmission; type WeightInfo = (); }