Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
81 changes: 62 additions & 19 deletions blockchain/pallets/network-validator/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -80,6 +81,19 @@ impl<AccountId> ReputationUpdater<AccountId> 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<AccountId> {
fn accrue(validator: &AccountId, points: u64) -> DispatchResult;
}

impl<AccountId> ValidatorRewards<AccountId> 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).
Expand Down Expand Up @@ -152,6 +166,9 @@ pub mod pallet {
/// Receives closed-round aggregates; the runtime wires this to
/// `pallet-reputation`.
type ReputationUpdater: ReputationUpdater<Self::AccountId>;
/// Credits Reward Points to non-outlier submitters; the runtime
/// wires this to `pallet-rewards`.
type ValidatorRewards: ValidatorRewards<Self::AccountId>;
#[pallet::constant]
type MinStake: Get<BalanceOf<Self>>;
#[pallet::constant]
Expand All @@ -178,6 +195,9 @@ pub mod pallet {
/// How long after a round closes it may still be disputed.
#[pallet::constant]
type DisputeWindow: Get<BlockNumberFor<Self>>;
/// Reward Points credited per submission that survived trimming.
#[pallet::constant]
type PointsPerAcceptedSubmission: Get<u64>;
type WeightInfo: WeightInfo;
}

Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -631,7 +653,7 @@ pub mod pallet {
let count = submissions.len() as u32;
ensure!(count >= T::MinQuorum::get(), Error::<T>::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::<T>::block_number();
// Captured before applying, so an upheld dispute restores the
Expand All @@ -656,13 +678,22 @@ 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,
dimension,
score_bps,
submissions: count,
committee_target,
rewarded: accepted.len() as u32,
});
Ok(())
}
Expand Down Expand Up @@ -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<T::AccountId>]) -> 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<T::AccountId>],
) -> (alloc::vec::Vec<Submission<T::AccountId>>, u16) {
if submissions.is_empty() {
return 0;
return (alloc::vec::Vec::new(), 0);
}
let mut scores: alloc::vec::Vec<u32> =
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<Submission<T::AccountId>> = 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`
Expand Down
122 changes: 122 additions & 0 deletions blockchain/pallets/network-validator/src/tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,27 @@ impl crate::ReputationUpdater<u64> 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<std::collections::BTreeMap<u64, u64>> =
const { std::cell::RefCell::new(std::collections::BTreeMap::new()) };
}

pub struct RecordingRewards;
impl crate::ValidatorRewards<u64> 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 {
<RecordingUpdater as crate::ReputationUpdater<u64>>::dimension_score(&provider, dimension)
}
Expand All @@ -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! {
Expand All @@ -104,19 +126,22 @@ 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<u64>;
type ReputationUpdater = RecordingUpdater;
type ValidatorRewards = RecordingRewards;
type MinStake = MinStake;
type UnbondingPeriod = UnbondingPeriod;
type MaxSubmissionsPerRound = MaxSubmissionsPerRound;
type MinQuorum = MinQuorum;
type TargetCommitteeSize = TargetCommitteeSize;
type MaxValidators = MaxValidators;
type DisputeWindow = DisputeWindow;
type PointsPerAcceptedSubmission = PointsPerAcceptedSubmission;
type WeightInfo = ();
}

Expand Down Expand Up @@ -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);
});
}
37 changes: 37 additions & 0 deletions blockchain/pallets/rewards/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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]
Expand Down Expand Up @@ -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<T>) -> DispatchResult {
Expand All @@ -158,6 +167,34 @@ pub mod pallet {
Ok(())
}
}

impl<T: Config> Pallet<T> {
/// 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::<T>::get(account);
let new_balance = balance
.checked_add(points)
.ok_or(Error::<T>::ArithmeticOverflow)?;
RewardBalances::<T>::insert(account, new_balance);
Self::deposit_event(Event::ValidatorRewardAccrued {
validator: account.clone(),
points,
});
Ok(())
}
}
}

#[cfg(test)]
Expand Down
Loading