diff --git a/blockchain/pallets/network-validator/src/lib.rs b/blockchain/pallets/network-validator/src/lib.rs index 6a9e955..8e6aad5 100644 --- a/blockchain/pallets/network-validator/src/lib.rs +++ b/blockchain/pallets/network-validator/src/lib.rs @@ -9,20 +9,23 @@ //! 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`]. +//! 2. **Scoring**: a deterministic committee is assigned per +//! (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. //! -//! 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. +//! Still open per ADR-011: validator reward/penalty accrual, and +//! unpredictable (VRF-backed) committee entropy -- see +//! [`Pallet::committee`] for why assignment is currently predictable. extern crate alloc; use frame_support::{ pallet_prelude::*, + sp_runtime::traits::Saturating, traits::{Currency, EnsureOrigin, Get, ReservableCurrency}, weights::Weight, Hashable, @@ -62,12 +65,19 @@ pub trait ReputationUpdater { dimension: ScoreDimension, score_bps: u16, ) -> DispatchResult; + + /// The dimension's current value, in basis points. Captured before a + /// round is applied so an upheld dispute can restore it (ADR-011 §5). + fn dimension_score(provider: &AccountId, dimension: ScoreDimension) -> u16; } impl ReputationUpdater for () { fn record_dimension_score(_: &AccountId, _: ScoreDimension, _: u16) -> DispatchResult { Ok(()) } + fn dimension_score(_: &AccountId, _: ScoreDimension) -> u16 { + 0 + } } /// Narrow interface for pallets that only need to know whether an account is @@ -91,6 +101,8 @@ pub trait WeightInfo { fn reinstate() -> Weight; fn submit_evidence() -> Weight; fn close_round() -> Weight; + fn dispute_round() -> Weight; + fn resolve_dispute() -> Weight; } impl WeightInfo for () { @@ -115,6 +127,12 @@ impl WeightInfo for () { fn close_round() -> Weight { Weight::from_parts(10_000, 0) } + fn dispute_round() -> Weight { + Weight::from_parts(10_000, 0) + } + fn resolve_dispute() -> Weight { + Weight::from_parts(10_000, 0) + } } #[frame_support::pallet] @@ -157,6 +175,9 @@ pub mod pallet { /// selection iterates it, so it must stay bounded. #[pallet::constant] type MaxValidators: Get; + /// How long after a round closes it may still be disputed. + #[pallet::constant] + type DisputeWindow: Get>; type WeightInfo: WeightInfo; } @@ -215,6 +236,31 @@ pub mod pallet { pub payload_hash: [u8; 32], } + /// Lifecycle of a closed round (ADR-011 §5). + #[derive( + Clone, + Copy, + Encode, + Decode, + DecodeWithMemTracking, + Eq, + MaxEncodedLen, + PartialEq, + Debug, + TypeInfo, + )] + pub enum RoundStatus { + /// Closed and applied to reputation. + Final, + /// Contested within the dispute window; the dimension has been + /// rolled back to `previous_score_bps` pending resolution. + Disputed, + /// Governance agreed with the disputer; the rollback stands. + DisputeUpheld, + /// Governance rejected the dispute; the aggregate was re-applied. + DisputeRejected, + } + /// The aggregate committed when a round closes. #[derive( Clone, Encode, Decode, DecodeWithMemTracking, Eq, MaxEncodedLen, PartialEq, Debug, TypeInfo, @@ -222,12 +268,16 @@ pub mod pallet { pub struct RoundResult { /// Trimmed-mean score in basis points. pub score_bps: u16, + /// The dimension's value immediately before this round applied, so + /// an upheld dispute can restore it exactly. + pub previous_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, + pub status: RoundStatus, } /// Open submissions, keyed by (provider, round, dimension). @@ -293,6 +343,21 @@ pub mod pallet { submissions: u32, committee_target: u32, }, + RoundDisputed { + provider: T::AccountId, + round: u64, + dimension: ScoreDimension, + disputed_by: T::AccountId, + /// The value reputation was rolled back to. + restored_score_bps: u16, + }, + DisputeResolved { + provider: T::AccountId, + round: u64, + dimension: ScoreDimension, + upheld: bool, + effective_score_bps: u16, + }, } #[pallet::error] @@ -330,6 +395,17 @@ pub mod pallet { NotAssignedToRound, /// `MaxValidators` reached; the active set cannot grow. TooManyValidators, + /// No closed round exists for this (provider, round, dimension). + RoundNotFound, + /// The `DisputeWindow` after closing has elapsed. + DisputeWindowClosed, + /// Only the scored provider or a validator that was on the + /// round's committee may dispute it. + NotEntitledToDispute, + /// The round is already under dispute or already resolved. + AlreadyDisputed, + /// The round is not under dispute, so there is nothing to resolve. + NotDisputed, } #[pallet::call] @@ -558,14 +634,19 @@ pub mod pallet { let score_bps = Self::trimmed_mean(&submissions); let committee_target = T::TargetCommitteeSize::get(); let closed_at = frame_system::Pallet::::block_number(); + // Captured before applying, so an upheld dispute restores the + // exact prior value rather than guessing at it. + let previous_score_bps = T::ReputationUpdater::dimension_score(&provider, dimension); Rounds::::insert( (&provider, round, dimension), RoundResult { score_bps, + previous_score_bps, submissions: count, committee_target, closed_at, + status: RoundStatus::Final, }, ); // Raw submissions are no longer needed once the aggregate is @@ -585,6 +666,100 @@ pub mod pallet { }); Ok(()) } + + /// Contest a closed round within `DisputeWindow` blocks. Callable + /// by the scored provider or by any validator that sat on the + /// round's committee. The dimension is immediately rolled back to + /// the value it held before the round applied -- a contested score + /// must not keep influencing scheduling while it is unresolved -- + /// pending `resolve_dispute`. + #[pallet::call_index(7)] + #[pallet::weight(T::WeightInfo::dispute_round())] + pub fn dispute_round( + origin: OriginFor, + provider: T::AccountId, + round: u64, + dimension: ScoreDimension, + ) -> DispatchResult { + let who = ensure_signed(origin)?; + let mut result = + Rounds::::get((&provider, round, dimension)).ok_or(Error::::RoundNotFound)?; + ensure!( + matches!(result.status, RoundStatus::Final), + Error::::AlreadyDisputed + ); + let now = frame_system::Pallet::::block_number(); + let deadline = result.closed_at.saturating_add(T::DisputeWindow::get()); + ensure!(now <= deadline, Error::::DisputeWindowClosed); + ensure!( + who == provider || Self::is_assigned(&provider, round, &who), + Error::::NotEntitledToDispute + ); + + T::ReputationUpdater::record_dimension_score( + &provider, + dimension, + result.previous_score_bps, + )?; + let restored_score_bps = result.previous_score_bps; + result.status = RoundStatus::Disputed; + Rounds::::insert((&provider, round, dimension), result); + + Self::deposit_event(Event::RoundDisputed { + provider, + round, + dimension, + disputed_by: who, + restored_score_bps, + }); + Ok(()) + } + + /// Settle a dispute. `SuspensionOrigin`-gated: full on-chain + /// adjudication is deferred (ADR-011 §5), so for the MVP a + /// governance origin decides. Upholding keeps the rollback; + /// rejecting re-applies the round's aggregate. + #[pallet::call_index(8)] + #[pallet::weight(T::WeightInfo::resolve_dispute())] + pub fn resolve_dispute( + origin: OriginFor, + provider: T::AccountId, + round: u64, + dimension: ScoreDimension, + uphold: bool, + ) -> DispatchResult { + T::SuspensionOrigin::ensure_origin(origin)?; + let mut result = + Rounds::::get((&provider, round, dimension)).ok_or(Error::::RoundNotFound)?; + ensure!( + matches!(result.status, RoundStatus::Disputed), + Error::::NotDisputed + ); + + let effective_score_bps = if uphold { + result.status = RoundStatus::DisputeUpheld; + // Already rolled back by dispute_round; nothing to re-apply. + result.previous_score_bps + } else { + result.status = RoundStatus::DisputeRejected; + T::ReputationUpdater::record_dimension_score( + &provider, + dimension, + result.score_bps, + )?; + result.score_bps + }; + Rounds::::insert((&provider, round, dimension), result); + + Self::deposit_event(Event::DisputeResolved { + provider, + round, + dimension, + upheld: uphold, + effective_score_bps, + }); + Ok(()) + } } impl Pallet { diff --git a/blockchain/pallets/network-validator/src/tests.rs b/blockchain/pallets/network-validator/src/tests.rs index 4166833..24f41c4 100644 --- a/blockchain/pallets/network-validator/src/tests.rs +++ b/blockchain/pallets/network-validator/src/tests.rs @@ -37,6 +37,23 @@ thread_local! { const { std::cell::RefCell::new(Vec::new()) }; } +// Current per-(provider, dimension) score, so the double acts like a +// real reputation store and dispute rollbacks can be asserted. +thread_local! { + static CURRENT: std::cell::RefCell> = + const { std::cell::RefCell::new(std::collections::BTreeMap::new()) }; +} + +fn dimension_key(dimension: ScoreDimension) -> u8 { + match dimension { + ScoreDimension::Compute => 0, + ScoreDimension::Storage => 1, + ScoreDimension::Network => 2, + ScoreDimension::Availability => 3, + ScoreDimension::Reliability => 4, + } +} + pub struct RecordingUpdater; impl crate::ReputationUpdater for RecordingUpdater { fn record_dimension_score( @@ -49,8 +66,27 @@ impl crate::ReputationUpdater for RecordingUpdater { .borrow_mut() .push((*provider, dimension, score_bps)) }); + CURRENT.with(|current| { + current + .borrow_mut() + .insert((*provider, dimension_key(dimension)), score_bps) + }); Ok(()) } + + fn dimension_score(provider: &u64, dimension: ScoreDimension) -> u16 { + CURRENT.with(|current| { + current + .borrow() + .get(&(*provider, dimension_key(dimension))) + .copied() + .unwrap_or(0) + }) + } +} + +fn current_score(provider: u64, dimension: ScoreDimension) -> u16 { + >::dimension_score(&provider, dimension) } fn recorded() -> Vec<(u64, ScoreDimension, u16)> { @@ -59,6 +95,7 @@ fn recorded() -> Vec<(u64, ScoreDimension, u16)> { fn clear_recorded() { RECORDED.with(|recorded| recorded.borrow_mut().clear()); + CURRENT.with(|current| current.borrow_mut().clear()); } parameter_types! { @@ -66,6 +103,7 @@ parameter_types! { pub const MinQuorum: u32 = 3; pub const TargetCommitteeSize: u32 = 5; pub const MaxValidators: u32 = 16; + pub const DisputeWindow: u64 = 20; } impl crate::Config for Test { @@ -78,6 +116,7 @@ impl crate::Config for Test { type MinQuorum = MinQuorum; type TargetCommitteeSize = TargetCommitteeSize; type MaxValidators = MaxValidators; + type DisputeWindow = DisputeWindow; type WeightInfo = (); } @@ -587,3 +626,261 @@ fn the_active_set_is_bounded() { ); }); } + +// --- Disputes (ADR-011 §5) --- + +/// Closes a round at `score`, returning the committee, so dispute tests +/// start from a real Final round. +fn close_round_at(dimension: ScoreDimension, score: u16) -> Vec { + let assigned = committee(); + for member in assigned.iter().take(3) { + assert_ok!(submit(*member, dimension, score)); + } + assert_ok!(NetworkValidator::close_round( + RuntimeOrigin::signed(assigned[0]), + PROVIDER, + ROUND, + dimension + )); + assigned +} + +#[test] +fn a_dispute_rolls_reputation_back_to_the_pre_round_value() { + new_test_ext().execute_with(|| { + clear_recorded(); + System::set_block_number(1); + register_validators(6); + // Establish a prior value via an earlier round -- using that + // round's own committee -- so the rollback target is a real score + // rather than the zero default. + let prior = NetworkValidator::committee(&PROVIDER, ROUND - 1); + for member in prior.iter().take(3) { + assert_ok!(NetworkValidator::submit_evidence( + RuntimeOrigin::signed(*member), + PROVIDER, + ROUND - 1, + ScoreDimension::Compute, + 3_000, + 10, + [1; 32] + )); + } + assert_ok!(NetworkValidator::close_round( + RuntimeOrigin::signed(prior[0]), + PROVIDER, + ROUND - 1, + ScoreDimension::Compute + )); + let before_disputed_round = current_score(PROVIDER, ScoreDimension::Compute); + assert_eq!(before_disputed_round, 3_000); + + close_round_at(ScoreDimension::Compute, 9_000); + assert_eq!(current_score(PROVIDER, ScoreDimension::Compute), 9_000); + + // The provider contests its own score. + assert_ok!(NetworkValidator::dispute_round( + RuntimeOrigin::signed(PROVIDER), + PROVIDER, + ROUND, + ScoreDimension::Compute + )); + assert_eq!( + current_score(PROVIDER, ScoreDimension::Compute), + before_disputed_round, + "a contested score must stop influencing reputation immediately" + ); + let result = + crate::Rounds::::get((PROVIDER, ROUND, ScoreDimension::Compute)).expect("round"); + assert_eq!(result.status, crate::RoundStatus::Disputed); + }); +} + +#[test] +fn only_the_provider_or_a_committee_member_may_dispute() { + new_test_ext().execute_with(|| { + clear_recorded(); + System::set_block_number(1); + let validators = register_validators(8); + let assigned = close_round_at(ScoreDimension::Network, 5_000); + let outsider = validators + .iter() + .find(|candidate| !assigned.contains(candidate)) + .copied() + .expect("some validators are unassigned"); + + assert_noop!( + NetworkValidator::dispute_round( + RuntimeOrigin::signed(outsider), + PROVIDER, + ROUND, + ScoreDimension::Network + ), + crate::Error::::NotEntitledToDispute + ); + // A validator that sat on the committee may dispute. + assert_ok!(NetworkValidator::dispute_round( + RuntimeOrigin::signed(assigned[1]), + PROVIDER, + ROUND, + ScoreDimension::Network + )); + }); +} + +#[test] +fn a_dispute_must_land_inside_the_window() { + new_test_ext().execute_with(|| { + clear_recorded(); + System::set_block_number(1); + register_validators(6); + close_round_at(ScoreDimension::Storage, 5_000); + System::set_block_number(1 + DisputeWindow::get() + 1); + assert_noop!( + NetworkValidator::dispute_round( + RuntimeOrigin::signed(PROVIDER), + PROVIDER, + ROUND, + ScoreDimension::Storage + ), + crate::Error::::DisputeWindowClosed + ); + }); +} + +#[test] +fn a_round_cannot_be_disputed_twice() { + new_test_ext().execute_with(|| { + clear_recorded(); + System::set_block_number(1); + register_validators(6); + close_round_at(ScoreDimension::Reliability, 5_000); + assert_ok!(NetworkValidator::dispute_round( + RuntimeOrigin::signed(PROVIDER), + PROVIDER, + ROUND, + ScoreDimension::Reliability + )); + assert_noop!( + NetworkValidator::dispute_round( + RuntimeOrigin::signed(PROVIDER), + PROVIDER, + ROUND, + ScoreDimension::Reliability + ), + crate::Error::::AlreadyDisputed + ); + }); +} + +#[test] +fn disputing_an_unknown_round_fails() { + new_test_ext().execute_with(|| { + register_validators(6); + assert_noop!( + NetworkValidator::dispute_round( + RuntimeOrigin::signed(PROVIDER), + PROVIDER, + 999, + ScoreDimension::Compute + ), + crate::Error::::RoundNotFound + ); + }); +} + +#[test] +fn upholding_a_dispute_keeps_the_rollback() { + new_test_ext().execute_with(|| { + clear_recorded(); + System::set_block_number(1); + register_validators(6); + close_round_at(ScoreDimension::Compute, 9_000); + assert_ok!(NetworkValidator::dispute_round( + RuntimeOrigin::signed(PROVIDER), + PROVIDER, + ROUND, + ScoreDimension::Compute + )); + assert_ok!(NetworkValidator::resolve_dispute( + RuntimeOrigin::root(), + PROVIDER, + ROUND, + ScoreDimension::Compute, + true + )); + let result = + crate::Rounds::::get((PROVIDER, ROUND, ScoreDimension::Compute)).expect("round"); + assert_eq!(result.status, crate::RoundStatus::DisputeUpheld); + assert_eq!( + current_score(PROVIDER, ScoreDimension::Compute), + result.previous_score_bps + ); + }); +} + +#[test] +fn rejecting_a_dispute_reapplies_the_aggregate() { + new_test_ext().execute_with(|| { + clear_recorded(); + System::set_block_number(1); + register_validators(6); + close_round_at(ScoreDimension::Compute, 9_000); + assert_ok!(NetworkValidator::dispute_round( + RuntimeOrigin::signed(PROVIDER), + PROVIDER, + ROUND, + ScoreDimension::Compute + )); + assert_ne!(current_score(PROVIDER, ScoreDimension::Compute), 9_000); + assert_ok!(NetworkValidator::resolve_dispute( + RuntimeOrigin::root(), + PROVIDER, + ROUND, + ScoreDimension::Compute, + false + )); + let result = + crate::Rounds::::get((PROVIDER, ROUND, ScoreDimension::Compute)).expect("round"); + assert_eq!(result.status, crate::RoundStatus::DisputeRejected); + assert_eq!(current_score(PROVIDER, ScoreDimension::Compute), 9_000); + }); +} + +#[test] +fn resolving_requires_governance_and_an_actual_dispute() { + new_test_ext().execute_with(|| { + clear_recorded(); + System::set_block_number(1); + register_validators(6); + let assigned = close_round_at(ScoreDimension::Compute, 9_000); + // Not disputed yet. + assert_noop!( + NetworkValidator::resolve_dispute( + RuntimeOrigin::root(), + PROVIDER, + ROUND, + ScoreDimension::Compute, + true + ), + crate::Error::::NotDisputed + ); + assert_ok!(NetworkValidator::dispute_round( + RuntimeOrigin::signed(PROVIDER), + PROVIDER, + ROUND, + ScoreDimension::Compute + )); + // A validator cannot settle its own dispute. + assert_noop!( + NetworkValidator::resolve_dispute( + RuntimeOrigin::signed(assigned[0]), + PROVIDER, + ROUND, + ScoreDimension::Compute, + true + ), + DispatchError::BadOrigin + ); + }); +} diff --git a/blockchain/pallets/reputation/src/lib.rs b/blockchain/pallets/reputation/src/lib.rs index 91cec36..9a72596 100644 --- a/blockchain/pallets/reputation/src/lib.rs +++ b/blockchain/pallets/reputation/src/lib.rs @@ -339,6 +339,29 @@ pub mod pallet { Ok(()) } + /// Read one dimension back as basis points, inverting + /// [`Self::set_dimension_score`]'s scaling. + /// + /// The round trip is exact whenever `MaxScore` divides 10_000 + /// evenly (it is 1_000 in the runtime, giving a step of 10 bps); + /// otherwise it is deterministically truncating, never lossy in a + /// way that differs between nodes. + pub fn dimension_score_bps(provider: &T::AccountId, dimension: VectorDimension) -> u16 { + let max_score = T::MaxScore::get(); + if max_score == 0 { + return 0; + } + let vector = ReputationVectors::::get(provider).unwrap_or_else(Self::default_vector); + let score = match dimension { + VectorDimension::Compute => vector.compute, + VectorDimension::Storage => vector.storage, + VectorDimension::Network => vector.network, + VectorDimension::Availability => vector.availability, + VectorDimension::Reliability => vector.reliability, + }; + (u64::from(score).saturating_mul(10_000) / u64::from(max_score)).min(10_000) as u16 + } + 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 7bb441e..e41dd51 100644 --- a/blockchain/runtime/src/lib.rs +++ b/blockchain/runtime/src/lib.rs @@ -111,6 +111,8 @@ parameter_types! { pub const ValidatorMinQuorum: u32 = 3; pub const ValidatorTargetCommitteeSize: u32 = 5; pub const MaxNetworkValidators: u32 = 256; + // ~30 minutes at 6s blocks to contest a closed round. + pub const ValidatorDisputeWindow: u32 = 300; } #[derive_impl(frame_system::config_preludes::SolochainDefaultConfig)] @@ -257,16 +259,37 @@ impl pallet_network_validator::ReputationUpdater dimension: pallet_network_validator::ScoreDimension, score_bps: u16, ) -> frame::deps::sp_runtime::DispatchResult { + pallet_reputation::Pallet::::set_dimension_score( + provider, + Self::map_dimension(dimension), + score_bps, + ) + } + + fn dimension_score( + provider: &interface::AccountId, + dimension: pallet_network_validator::ScoreDimension, + ) -> u16 { + pallet_reputation::Pallet::::dimension_score_bps( + provider, + Self::map_dimension(dimension), + ) + } +} + +impl ScoringReputationUpdater { + fn map_dimension( + dimension: pallet_network_validator::ScoreDimension, + ) -> pallet_reputation::pallet::VectorDimension { use pallet_network_validator::ScoreDimension; use pallet_reputation::pallet::VectorDimension; - let mapped = match dimension { + 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) + } } } @@ -283,6 +306,7 @@ impl pallet_network_validator::Config for Runtime { type MinQuorum = ValidatorMinQuorum; type TargetCommitteeSize = ValidatorTargetCommitteeSize; type MaxValidators = MaxNetworkValidators; + type DisputeWindow = ValidatorDisputeWindow; type WeightInfo = (); }