diff --git a/blockchain/pallets/network-validator/src/lib.rs b/blockchain/pallets/network-validator/src/lib.rs index c3dd7e0..3a6d796 100644 --- a/blockchain/pallets/network-validator/src/lib.rs +++ b/blockchain/pallets/network-validator/src/lib.rs @@ -1,13 +1,25 @@ #![cfg_attr(not(feature = "std"), no_std)] -//! Network Validator identity, stake, and lifecycle registry (ADR-011). +//! Network Validator identity, stake, lifecycle, and worker scoring +//! (ADR-011). //! -//! This pallet only answers "is this account a bonded, active Network -//! Validator". It deliberately does not decide *who gets assigned to -//! challenge which provider* (self-assignment exclusion, committee -//! selection) or how evidence is aggregated into reputation -- those are -//! separate, still-to-be-implemented pieces per ADR-011 that consume -//! [`NetworkValidatorInspector`]. +//! Two responsibilities, deliberately in one pallet because ADR-011 §5 +//! places round aggregation here: +//! +//! 1. **Registry**: who is a bonded, active Network Validator +//! ([`NetworkValidatorInspector`], consumed by `pallet-availability` +//! and `pallet-reputation` to gate their submission origins). +//! 2. **Scoring**: validators submit per-dimension integer evidence for a +//! provider/round; once a quorum is reached the round is closed with a +//! trimmed-mean aggregate that is pushed into `pallet-reputation` +//! through [`ReputationUpdater`]. +//! +//! Committee *assignment* (deterministically selecting which validators +//! are expected to challenge which provider in a round) is still open -- +//! today any active validator may submit, bounded by +//! `MaxSubmissionsPerRound` and the self-scoring exclusion below. + +extern crate alloc; use frame_support::{ pallet_prelude::*, @@ -17,6 +29,46 @@ use frame_support::{ use frame_system::pallet_prelude::*; pub use pallet::*; +/// A component of a provider's reputation vector that validators score +/// independently. Mapped by the runtime onto `pallet-reputation`'s own +/// dimension enum, so neither pallet depends on the other's types. +#[derive( + Clone, + Copy, + Encode, + Decode, + DecodeWithMemTracking, + Eq, + MaxEncodedLen, + PartialEq, + Debug, + TypeInfo, +)] +pub enum ScoreDimension { + Compute, + Storage, + Network, + Availability, + Reliability, +} + +/// Applies an aggregated round result to a provider's reputation. +/// `pallet-reputation` stays the only writer of the reputation vector and +/// keeps enforcing its own bounds (ADR-011 §5). +pub trait ReputationUpdater { + fn record_dimension_score( + provider: &AccountId, + dimension: ScoreDimension, + score_bps: u16, + ) -> DispatchResult; +} + +impl ReputationUpdater for () { + fn record_dimension_score(_: &AccountId, _: ScoreDimension, _: u16) -> 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). @@ -36,6 +88,8 @@ pub trait WeightInfo { fn withdraw_unbonded() -> Weight; fn suspend() -> Weight; fn reinstate() -> Weight; + fn submit_evidence() -> Weight; + fn close_round() -> Weight; } impl WeightInfo for () { @@ -54,6 +108,12 @@ impl WeightInfo for () { fn reinstate() -> Weight { Weight::from_parts(10_000, 0) } + fn submit_evidence() -> Weight { + Weight::from_parts(10_000, 0) + } + fn close_round() -> Weight { + Weight::from_parts(10_000, 0) + } } #[frame_support::pallet] @@ -70,10 +130,27 @@ pub mod pallet { /// `EnsureRoot` for the MVP; a validator committee/governance origin /// is future work (ADR-011 §5). type SuspensionOrigin: EnsureOrigin; + /// Receives closed-round aggregates; the runtime wires this to + /// `pallet-reputation`. + type ReputationUpdater: ReputationUpdater; #[pallet::constant] type MinStake: Get>; #[pallet::constant] type UnbondingPeriod: Get>; + /// Hard bound on stored submissions per (provider, round, + /// dimension) -- keeps the aggregation loop and storage item + /// bounded, as consensus state must be. + #[pallet::constant] + type MaxSubmissionsPerRound: Get; + /// Minimum independent submissions before a round may close. Below + /// this, the round is explicitly degraded rather than silently + /// accepted (ADR-011 §5). + #[pallet::constant] + type MinQuorum: Get; + /// Denominator for the reported confidence ratio: the committee + /// size a fully-attested round is expected to reach. + #[pallet::constant] + type TargetCommitteeSize: Get; type WeightInfo: WeightInfo; } @@ -110,6 +187,62 @@ pub mod pallet { OptionQuery, >; + /// One validator's attributable, signed-by-extrinsic observation. + /// `payload_hash` addresses the full evidence off-chain; only this + /// bounded integer summary is ever kept in consensus state. + #[derive( + Clone, Encode, Decode, DecodeWithMemTracking, Eq, MaxEncodedLen, PartialEq, Debug, TypeInfo, + )] + pub struct Submission { + pub validator: AccountId, + /// 0..=10_000, never a float. + pub score_bps: u16, + pub sample_count: u32, + pub payload_hash: [u8; 32], + } + + /// The aggregate committed when a round closes. + #[derive( + Clone, Encode, Decode, DecodeWithMemTracking, Eq, MaxEncodedLen, PartialEq, Debug, TypeInfo, + )] + pub struct RoundResult { + /// Trimmed-mean score in basis points. + pub score_bps: u16, + /// How many independent validators contributed. + pub submissions: u32, + /// `TargetCommitteeSize` at closing time, so a reader can compute + /// confidence without assuming today's configuration. + pub committee_target: u32, + pub closed_at: BlockNumber, + } + + /// Open submissions, keyed by (provider, round, dimension). + #[pallet::storage] + pub type Evidence = StorageNMap< + _, + ( + NMapKey, + NMapKey, + NMapKey, + ), + BoundedVec, T::MaxSubmissionsPerRound>, + ValueQuery, + >; + + /// Closed rounds, keyed identically. A present entry means the round + /// is final: further submissions are rejected and it cannot re-close. + #[pallet::storage] + pub type Rounds = StorageNMap< + _, + ( + NMapKey, + NMapKey, + NMapKey, + ), + RoundResult>, + OptionQuery, + >; + #[pallet::event] #[pallet::generate_deposit(pub(super) fn deposit_event)] pub enum Event { @@ -131,6 +264,21 @@ pub mod pallet { ValidatorReinstated { validator: T::AccountId, }, + EvidenceSubmitted { + provider: T::AccountId, + validator: T::AccountId, + round: u64, + dimension: ScoreDimension, + score_bps: u16, + }, + RoundClosed { + provider: T::AccountId, + round: u64, + dimension: ScoreDimension, + score_bps: u16, + submissions: u32, + committee_target: u32, + }, } #[pallet::error] @@ -145,6 +293,24 @@ pub mod pallet { UnbondingPeriodOverflow, NotActive, NotSuspended, + /// The submitting account is not an active Network Validator. + NotAnActiveValidator, + /// A validator may never score itself (ADR-011 §4). + SelfScoringForbidden, + /// This validator already submitted for this provider/round/dimension. + DuplicateSubmission, + /// `MaxSubmissionsPerRound` reached for this round. + TooManySubmissions, + /// Scores are basis points and must be 0..=10_000. + ScoreOutOfBounds, + /// A submission must be backed by at least one sample. + InvalidSampleCount, + /// The round is already closed; it can neither accept new evidence + /// nor be closed twice. + RoundAlreadyClosed, + /// Fewer than `MinQuorum` independent submissions -- closing would + /// report a score the network cannot stand behind. + QuorumNotReached, } #[pallet::call] @@ -270,9 +436,146 @@ pub mod pallet { Self::deposit_event(Event::ValidatorReinstated { validator }); Ok(()) } + + /// Submit one validator's integer observation for a provider in a + /// round. Attributable (the extrinsic signer is recorded), bounded, + /// replay-resistant, and never self-scoring. + #[pallet::call_index(5)] + #[pallet::weight(T::WeightInfo::submit_evidence())] + pub fn submit_evidence( + origin: OriginFor, + provider: T::AccountId, + round: u64, + dimension: ScoreDimension, + score_bps: u16, + sample_count: u32, + payload_hash: [u8; 32], + ) -> DispatchResult { + let validator = ensure_signed(origin)?; + ensure!( + Self::is_active(&validator), + Error::::NotAnActiveValidator + ); + // A validator scoring its own provider account is the cheapest + // possible self-dealing; reject it outright. + ensure!(validator != provider, Error::::SelfScoringForbidden); + ensure!(score_bps <= 10_000, Error::::ScoreOutOfBounds); + ensure!(sample_count > 0, Error::::InvalidSampleCount); + ensure!( + Rounds::::get((&provider, round, dimension)).is_none(), + Error::::RoundAlreadyClosed + ); + + Evidence::::try_mutate( + (&provider, round, dimension), + |submissions| -> DispatchResult { + ensure!( + !submissions.iter().any(|entry| entry.validator == validator), + Error::::DuplicateSubmission + ); + submissions + .try_push(Submission { + validator: validator.clone(), + score_bps, + sample_count, + payload_hash, + }) + .map_err(|_| Error::::TooManySubmissions)?; + Ok(()) + }, + )?; + + Self::deposit_event(Event::EvidenceSubmitted { + provider, + validator, + round, + dimension, + score_bps, + }); + Ok(()) + } + + /// Close a round once `MinQuorum` independent submissions exist, + /// aggregate them with a trimmed mean, and push the result into + /// reputation. Callable by any active validator: closing is a + /// deterministic function of already-committed state, so it needs + /// no privileged origin -- only a bounded, quorum-gated trigger. + #[pallet::call_index(6)] + #[pallet::weight(T::WeightInfo::close_round())] + pub fn close_round( + origin: OriginFor, + provider: T::AccountId, + round: u64, + dimension: ScoreDimension, + ) -> DispatchResult { + let caller = ensure_signed(origin)?; + ensure!(Self::is_active(&caller), Error::::NotAnActiveValidator); + ensure!( + Rounds::::get((&provider, round, dimension)).is_none(), + Error::::RoundAlreadyClosed + ); + + let submissions = Evidence::::get((&provider, round, dimension)); + let count = submissions.len() as u32; + ensure!(count >= T::MinQuorum::get(), Error::::QuorumNotReached); + + let score_bps = Self::trimmed_mean(&submissions); + let committee_target = T::TargetCommitteeSize::get(); + let closed_at = frame_system::Pallet::::block_number(); + + Rounds::::insert( + (&provider, round, dimension), + RoundResult { + score_bps, + submissions: count, + committee_target, + closed_at, + }, + ); + // Raw submissions are no longer needed once the aggregate is + // committed; the off-chain evidence remains addressable by + // each submission's payload_hash in the emitted events. + Evidence::::remove((&provider, round, dimension)); + + T::ReputationUpdater::record_dimension_score(&provider, dimension, score_bps)?; + + Self::deposit_event(Event::RoundClosed { + provider, + round, + dimension, + score_bps, + submissions: count, + committee_target, + }); + Ok(()) + } } impl Pallet { + /// Integer trimmed mean: with three or more submissions, drop one + /// lowest and one highest value before averaging, so a single + /// 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 { + if submissions.is_empty() { + return 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] + } else { + &scores[..] + }; + 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 + } + /// True only for a registered validator whose status is `Active` /// (not `Suspended`, not `Exiting`). pub fn is_active(validator: &T::AccountId) -> bool { diff --git a/blockchain/pallets/network-validator/src/tests.rs b/blockchain/pallets/network-validator/src/tests.rs index 2b4cdc8..4a290b2 100644 --- a/blockchain/pallets/network-validator/src/tests.rs +++ b/blockchain/pallets/network-validator/src/tests.rs @@ -1,4 +1,5 @@ use crate as pallet_network_validator; +use crate::ScoreDimension; use frame_support::{assert_noop, assert_ok, derive_impl, parameter_types, traits::ConstU64}; use sp_runtime::{BuildStorage, DispatchError}; @@ -28,11 +29,53 @@ parameter_types! { pub const UnbondingPeriod: u64 = 5; } +// Records what the scoring rounds pushed into reputation, so tests can +// assert the aggregate actually reached the reputation layer instead of +// only checking this pallet's own storage. +thread_local! { + static RECORDED: std::cell::RefCell> = + const { std::cell::RefCell::new(Vec::new()) }; +} + +pub struct RecordingUpdater; +impl crate::ReputationUpdater for RecordingUpdater { + fn record_dimension_score( + provider: &u64, + dimension: ScoreDimension, + score_bps: u16, + ) -> sp_runtime::DispatchResult { + RECORDED.with(|recorded| { + recorded + .borrow_mut() + .push((*provider, dimension, score_bps)) + }); + Ok(()) + } +} + +fn recorded() -> Vec<(u64, ScoreDimension, u16)> { + RECORDED.with(|recorded| recorded.borrow().clone()) +} + +fn clear_recorded() { + RECORDED.with(|recorded| recorded.borrow_mut().clear()); +} + +parameter_types! { + pub const MaxSubmissionsPerRound: u32 = 8; + pub const MinQuorum: u32 = 3; + pub const TargetCommitteeSize: u32 = 5; +} + impl crate::Config for Test { type Currency = Balances; type SuspensionOrigin = frame_system::EnsureRoot; + type ReputationUpdater = RecordingUpdater; type MinStake = MinStake; type UnbondingPeriod = UnbondingPeriod; + type MaxSubmissionsPerRound = MaxSubmissionsPerRound; + type MinQuorum = MinQuorum; + type TargetCommitteeSize = TargetCommitteeSize; type WeightInfo = (); } @@ -188,3 +231,345 @@ fn unregistered_account_is_never_active() { assert!(!NetworkValidator::is_active(&42)); }); } + +// --- Scoring: evidence submission and round aggregation (ADR-011 §3/§5) --- + +/// Registers `count` validators starting at account 10, all funded and +/// active, so scoring tests start from a realistic committee. +fn register_validators(count: u64) -> Vec { + (10..10 + count) + .inspect(|&account| { + Balances::force_set_balance(RuntimeOrigin::root(), account, 1_000).expect("fund"); + NetworkValidator::register_validator(RuntimeOrigin::signed(account), 100) + .expect("register"); + }) + .collect() +} + +#[test] +fn evidence_requires_an_active_validator() { + new_test_ext().execute_with(|| { + clear_recorded(); + // Account 2 is funded but never registered as a validator. + assert_noop!( + NetworkValidator::submit_evidence( + RuntimeOrigin::signed(2), + 1, + 7, + ScoreDimension::Compute, + 5_000, + 10, + [1; 32] + ), + crate::Error::::NotAnActiveValidator + ); + }); +} + +#[test] +fn a_suspended_validator_cannot_submit_evidence() { + new_test_ext().execute_with(|| { + let validators = register_validators(1); + assert_ok!(NetworkValidator::suspend( + RuntimeOrigin::root(), + validators[0] + )); + assert_noop!( + NetworkValidator::submit_evidence( + RuntimeOrigin::signed(validators[0]), + 1, + 7, + ScoreDimension::Compute, + 5_000, + 10, + [1; 32] + ), + crate::Error::::NotAnActiveValidator + ); + }); +} + +#[test] +fn a_validator_cannot_score_itself() { + new_test_ext().execute_with(|| { + let validators = register_validators(1); + assert_noop!( + NetworkValidator::submit_evidence( + RuntimeOrigin::signed(validators[0]), + validators[0], // provider == validator + 7, + ScoreDimension::Compute, + 10_000, + 10, + [1; 32] + ), + crate::Error::::SelfScoringForbidden + ); + }); +} + +#[test] +fn evidence_rejects_duplicate_replayed_and_out_of_range_submissions() { + new_test_ext().execute_with(|| { + let validators = register_validators(1); + let validator = validators[0]; + assert_noop!( + NetworkValidator::submit_evidence( + RuntimeOrigin::signed(validator), + 1, + 7, + ScoreDimension::Compute, + 10_001, // > 100.00% + 10, + [1; 32] + ), + crate::Error::::ScoreOutOfBounds + ); + assert_noop!( + NetworkValidator::submit_evidence( + RuntimeOrigin::signed(validator), + 1, + 7, + ScoreDimension::Compute, + 5_000, + 0, // no samples backing the claim + [1; 32] + ), + crate::Error::::InvalidSampleCount + ); + assert_ok!(NetworkValidator::submit_evidence( + RuntimeOrigin::signed(validator), + 1, + 7, + ScoreDimension::Compute, + 5_000, + 10, + [1; 32] + )); + // Same validator, same (provider, round, dimension) -> replay. + assert_noop!( + NetworkValidator::submit_evidence( + RuntimeOrigin::signed(validator), + 1, + 7, + ScoreDimension::Compute, + 9_000, + 10, + [2; 32] + ), + crate::Error::::DuplicateSubmission + ); + // A different dimension in the same round is a separate slot. + assert_ok!(NetworkValidator::submit_evidence( + RuntimeOrigin::signed(validator), + 1, + 7, + ScoreDimension::Storage, + 9_000, + 10, + [2; 32] + )); + }); +} + +#[test] +fn a_round_cannot_close_below_quorum() { + new_test_ext().execute_with(|| { + let validators = register_validators(2); // MinQuorum is 3 + for validator in &validators { + assert_ok!(NetworkValidator::submit_evidence( + RuntimeOrigin::signed(*validator), + 1, + 7, + ScoreDimension::Compute, + 5_000, + 10, + [1; 32] + )); + } + assert_noop!( + NetworkValidator::close_round( + RuntimeOrigin::signed(validators[0]), + 1, + 7, + ScoreDimension::Compute + ), + crate::Error::::QuorumNotReached + ); + // Nothing may reach reputation from a sub-quorum round. + assert!(recorded().is_empty()); + }); +} + +#[test] +fn closing_a_round_trims_outliers_and_records_the_aggregate() { + new_test_ext().execute_with(|| { + clear_recorded(); + System::set_block_number(11); + let validators = register_validators(5); + // One low outlier (0), one high outlier (10_000), three honest + // observations around 60%. A plain mean would be 5_200; the + // trimmed mean drops both extremes and yields exactly 6_000. + let scores = [0u16, 6_000, 6_000, 6_000, 10_000]; + for (validator, score) in validators.iter().zip(scores) { + assert_ok!(NetworkValidator::submit_evidence( + RuntimeOrigin::signed(*validator), + 1, + 7, + ScoreDimension::Compute, + score, + 10, + [1; 32] + )); + } + assert_ok!(NetworkValidator::close_round( + RuntimeOrigin::signed(validators[0]), + 1, + 7, + ScoreDimension::Compute + )); + + let result = crate::Rounds::::get((1, 7, ScoreDimension::Compute)) + .expect("round result is stored"); + assert_eq!(result.score_bps, 6_000, "outliers must be trimmed"); + assert_eq!(result.submissions, 5); + assert_eq!(result.committee_target, 5); + assert_eq!(result.closed_at, 11); + // The aggregate reached the reputation layer exactly once. + assert_eq!(recorded(), vec![(1, ScoreDimension::Compute, 6_000)]); + // Raw submissions are cleared once aggregated. + assert!(crate::Evidence::::get((1, 7, ScoreDimension::Compute)).is_empty()); + }); +} + +#[test] +fn a_closed_round_rejects_new_evidence_and_cannot_close_twice() { + new_test_ext().execute_with(|| { + clear_recorded(); + let validators = register_validators(4); + for validator in validators.iter().take(3) { + assert_ok!(NetworkValidator::submit_evidence( + RuntimeOrigin::signed(*validator), + 1, + 7, + ScoreDimension::Availability, + 7_000, + 10, + [1; 32] + )); + } + assert_ok!(NetworkValidator::close_round( + RuntimeOrigin::signed(validators[0]), + 1, + 7, + ScoreDimension::Availability + )); + assert_noop!( + NetworkValidator::close_round( + RuntimeOrigin::signed(validators[0]), + 1, + 7, + ScoreDimension::Availability + ), + crate::Error::::RoundAlreadyClosed + ); + // A late submitter cannot reopen or influence a final round. + assert_noop!( + NetworkValidator::submit_evidence( + RuntimeOrigin::signed(validators[3]), + 1, + 7, + ScoreDimension::Availability, + 0, + 10, + [9; 32] + ), + crate::Error::::RoundAlreadyClosed + ); + // Exactly one reputation update, despite the repeated attempts. + assert_eq!(recorded().len(), 1); + }); +} + +#[test] +fn closing_requires_an_active_validator() { + new_test_ext().execute_with(|| { + let validators = register_validators(3); + for validator in &validators { + assert_ok!(NetworkValidator::submit_evidence( + RuntimeOrigin::signed(*validator), + 1, + 7, + ScoreDimension::Network, + 5_000, + 10, + [1; 32] + )); + } + assert_noop!( + NetworkValidator::close_round(RuntimeOrigin::signed(2), 1, 7, ScoreDimension::Network), + crate::Error::::NotAnActiveValidator + ); + }); +} + +#[test] +fn submissions_are_bounded_per_round() { + new_test_ext().execute_with(|| { + // MaxSubmissionsPerRound is 8; register one more than that. + let validators = register_validators(9); + for validator in validators.iter().take(8) { + assert_ok!(NetworkValidator::submit_evidence( + RuntimeOrigin::signed(*validator), + 1, + 7, + ScoreDimension::Reliability, + 5_000, + 10, + [1; 32] + )); + } + assert_noop!( + NetworkValidator::submit_evidence( + RuntimeOrigin::signed(validators[8]), + 1, + 7, + ScoreDimension::Reliability, + 5_000, + 10, + [1; 32] + ), + crate::Error::::TooManySubmissions + ); + }); +} + +#[test] +fn exactly_quorum_sized_committee_trims_to_the_median() { + new_test_ext().execute_with(|| { + clear_recorded(); + // With exactly MinQuorum (3) submissions the trim drops the lowest + // and highest, leaving the median alone -- a deliberately strong + // property: at minimum quorum a single dishonest validator cannot + // shift the result at all. + let validators = register_validators(3); + for (validator, score) in validators.iter().zip([0u16, 4_200, 10_000]) { + assert_ok!(NetworkValidator::submit_evidence( + RuntimeOrigin::signed(*validator), + 1, + 7, + ScoreDimension::Storage, + score, + 10, + [1; 32] + )); + } + assert_ok!(NetworkValidator::close_round( + RuntimeOrigin::signed(validators[0]), + 1, + 7, + ScoreDimension::Storage + )); + assert_eq!(recorded(), vec![(1, ScoreDimension::Storage, 4_200)]); + }); +} diff --git a/blockchain/pallets/reputation/src/lib.rs b/blockchain/pallets/reputation/src/lib.rs index ea9f9c4..91cec36 100644 --- a/blockchain/pallets/reputation/src/lib.rs +++ b/blockchain/pallets/reputation/src/lib.rs @@ -267,7 +267,78 @@ pub mod pallet { } } + /// One component of the reputation vector. Declared here (rather than + /// shared with the scoring pallet) so this pallet stays the single + /// owner of what a reputation dimension means; the runtime maps the + /// scoring pallet's own dimension enum onto this one. + #[derive( + Clone, + Copy, + Encode, + Decode, + DecodeWithMemTracking, + Eq, + MaxEncodedLen, + PartialEq, + Debug, + TypeInfo, + )] + pub enum VectorDimension { + Compute, + Storage, + Network, + Availability, + Reliability, + } + impl Pallet { + /// Set a single reputation dimension from an aggregated, integer + /// basis-points score (0..=10_000) and recompute the global score. + /// + /// Not an extrinsic: this is the internal entry point used by the + /// validator scoring pallet's round aggregation, so that + /// `pallet-reputation` remains the only writer of + /// `ReputationVectors` and keeps enforcing its own `MaxScore` + /// bound regardless of which pallet triggered the update + /// (ADR-011 §5). + pub fn set_dimension_score( + provider: &T::AccountId, + dimension: VectorDimension, + score_bps: u16, + ) -> DispatchResult { + ensure!( + T::ProviderInspector::is_registered(provider), + Error::::ProviderNotRegistered + ); + ensure!(score_bps <= 10_000, Error::::AvailabilityOutOfBounds); + let score = u32::from(score_bps) + .checked_mul(T::MaxScore::get()) + .ok_or(Error::::VectorValueOutOfBounds)? + / 10_000; + let mut vector = + ReputationVectors::::get(provider).unwrap_or_else(|| Self::default_vector()); + match dimension { + VectorDimension::Compute => vector.compute = score, + VectorDimension::Storage => vector.storage = score, + VectorDimension::Network => vector.network = score, + VectorDimension::Availability => vector.availability = score, + VectorDimension::Reliability => vector.reliability = score, + } + vector.global = Self::global(&vector); + ReputationVectors::::insert(provider, vector); + if matches!(dimension, VectorDimension::Availability) { + // Keep the legacy scalar score in step with the + // availability component, as submit_score/record_availability + // already do. + ReputationScores::::insert(provider, score); + } + Self::deposit_event(Event::VectorUpdated { + provider: provider.clone(), + vector, + }); + Ok(()) + } + fn default_vector() -> ReputationVector { let score = T::DefaultScore::get(); ReputationVector { diff --git a/blockchain/runtime/src/lib.rs b/blockchain/runtime/src/lib.rs index a1dce2c..d26d1d4 100644 --- a/blockchain/runtime/src/lib.rs +++ b/blockchain/runtime/src/lib.rs @@ -105,6 +105,11 @@ parameter_types! { pub const MaxRewardDuration: u64 = 10_000_000; pub const MinValidatorStake: u64 = 1_000; pub const ValidatorUnbondingPeriod: u32 = 14_400; // ~1 day at 6s blocks + pub const MaxValidatorSubmissionsPerRound: u32 = 32; + // Three independent submissions is the smallest committee where the + // trimmed mean can discard an outlier at both ends (ADR-011 §5). + pub const ValidatorMinQuorum: u32 = 3; + pub const ValidatorTargetCommitteeSize: u32 = 5; } #[derive_impl(frame_system::config_preludes::SolochainDefaultConfig)] @@ -239,14 +244,43 @@ impl pallet_availability::Config for Runtime { type WeightInfo = (); } +/// Applies closed-round aggregates to the reputation vector, mapping the +/// scoring pallet's `ScoreDimension` onto `pallet-reputation`'s own +/// `VectorDimension` so neither pallet depends on the other's types. +pub struct ScoringReputationUpdater; +impl pallet_network_validator::ReputationUpdater + for ScoringReputationUpdater +{ + fn record_dimension_score( + provider: &interface::AccountId, + dimension: pallet_network_validator::ScoreDimension, + score_bps: u16, + ) -> frame::deps::sp_runtime::DispatchResult { + use pallet_network_validator::ScoreDimension; + use pallet_reputation::pallet::VectorDimension; + let mapped = match dimension { + ScoreDimension::Compute => VectorDimension::Compute, + ScoreDimension::Storage => VectorDimension::Storage, + ScoreDimension::Network => VectorDimension::Network, + ScoreDimension::Availability => VectorDimension::Availability, + ScoreDimension::Reliability => VectorDimension::Reliability, + }; + pallet_reputation::Pallet::::set_dimension_score(provider, mapped, score_bps) + } +} + impl pallet_network_validator::Config for Runtime { type Currency = Balances; + type ReputationUpdater = ScoringReputationUpdater; // 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. type SuspensionOrigin = frame_system::EnsureRoot; type MinStake = MinValidatorStake; type UnbondingPeriod = ValidatorUnbondingPeriod; + type MaxSubmissionsPerRound = MaxValidatorSubmissionsPerRound; + type MinQuorum = ValidatorMinQuorum; + type TargetCommitteeSize = ValidatorTargetCommitteeSize; type WeightInfo = (); }