diff --git a/blockchain/pallets/network-validator/src/lib.rs b/blockchain/pallets/network-validator/src/lib.rs index 3a6d796..6a9e955 100644 --- a/blockchain/pallets/network-validator/src/lib.rs +++ b/blockchain/pallets/network-validator/src/lib.rs @@ -25,6 +25,7 @@ use frame_support::{ pallet_prelude::*, traits::{Currency, EnsureOrigin, Get, ReservableCurrency}, weights::Weight, + Hashable, }; use frame_system::pallet_prelude::*; pub use pallet::*; @@ -147,10 +148,15 @@ pub mod pallet { /// 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. + /// Committee size a fully-attested round is expected to reach. + /// Doubles as the confidence denominator: a round closed with + /// fewer submissions than this is visibly under-attested. #[pallet::constant] type TargetCommitteeSize: Get; + /// Hard bound on the enumerable active-validator set. Committee + /// selection iterates it, so it must stay bounded. + #[pallet::constant] + type MaxValidators: Get; type WeightInfo: WeightInfo; } @@ -187,6 +193,14 @@ pub mod pallet { OptionQuery, >; + /// Enumerable set of currently-`Active` validators, kept in step with + /// [`Validators`] on every status transition. `Validators` is a + /// StorageMap and cannot be iterated within a bounded weight, so + /// committee selection reads this instead. + #[pallet::storage] + pub type ActiveValidatorSet = + StorageValue<_, BoundedVec, ValueQuery>; + /// 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. @@ -311,6 +325,11 @@ pub mod pallet { /// Fewer than `MinQuorum` independent submissions -- closing would /// report a score the network cannot stand behind. QuorumNotReached, + /// This validator is not in the committee selected for this + /// provider/round -- slots are assigned, not self-selected. + NotAssignedToRound, + /// `MaxValidators` reached; the active set cannot grow. + TooManyValidators, } #[pallet::call] @@ -328,6 +347,7 @@ pub mod pallet { Error::::AlreadyRegistered ); ensure!(stake >= T::MinStake::get(), Error::::InsufficientStake); + Self::join_active_set(&who)?; T::Currency::reserve(&who, stake).map_err(|_| Error::::InsufficientFreeBalance)?; let now = frame_system::Pallet::::block_number(); Validators::::insert( @@ -369,6 +389,8 @@ pub mod pallet { Ok(available_at) }, )?; + // An exiting validator takes no new committee assignments. + Self::leave_active_set(&who); Self::deposit_event(Event::ValidatorExitRequested { validator: who, available_at, @@ -392,6 +414,9 @@ pub mod pallet { } T::Currency::unreserve(&who, record.stake); Validators::::remove(&who); + // Defensive: request_exit already removed it, but keep the two + // stores consistent even if that ever changes. + Self::leave_active_set(&who); Self::deposit_event(Event::ValidatorExited { validator: who, stake: record.stake, @@ -414,6 +439,7 @@ pub mod pallet { record.status = ValidatorStatus::Suspended; Ok(()) })?; + Self::leave_active_set(&validator); Self::deposit_event(Event::ValidatorSuspended { validator }); Ok(()) } @@ -433,6 +459,7 @@ pub mod pallet { record.status = ValidatorStatus::Active; Ok(()) })?; + Self::join_active_set(&validator)?; Self::deposit_event(Event::ValidatorReinstated { validator }); Ok(()) } @@ -457,8 +484,17 @@ pub mod pallet { Error::::NotAnActiveValidator ); // A validator scoring its own provider account is the cheapest - // possible self-dealing; reject it outright. + // possible self-dealing; reject it outright. Checked before + // assignment so the error stays specific (committee selection + // already excludes the provider, but this keeps the guarantee + // explicit rather than emergent). ensure!(validator != provider, Error::::SelfScoringForbidden); + // Slots are assigned, not self-selected: a Sybil cluster cannot + // fill a round by submitting first (ADR-011 §1). + ensure!( + Self::is_assigned(&provider, round, &validator), + Error::::NotAssignedToRound + ); ensure!(score_bps <= 10_000, Error::::ScoreOutOfBounds); ensure!(sample_count > 0, Error::::InvalidSampleCount); ensure!( @@ -552,6 +588,69 @@ pub mod pallet { } impl Pallet { + fn join_active_set(who: &T::AccountId) -> DispatchResult { + ActiveValidatorSet::::try_mutate(|set| -> DispatchResult { + if !set.contains(who) { + set.try_push(who.clone()) + .map_err(|_| Error::::TooManyValidators)?; + } + Ok(()) + }) + } + + fn leave_active_set(who: &T::AccountId) { + ActiveValidatorSet::::mutate(|set| { + if let Some(index) = set.iter().position(|entry| entry == who) { + // Order-preserving: committee selection indexes into + // this set, so a swap_remove would silently reshuffle + // assignments for unrelated providers. + set.remove(index); + } + }); + } + + /// The committee expected to score `provider` in `round`: + /// `TargetCommitteeSize` distinct active validators, drawn + /// deterministically from [`ActiveValidatorSet`] and never + /// including the provider itself. + /// + /// Selection is `blake2_256((provider, round, nth))` reduced modulo + /// the remaining candidates, drawing without replacement. It is a + /// pure function of committed state, so every node derives the same + /// committee without extra storage. + /// + /// **Known limitation (ADR-011 §1):** the dev chain runs Aura, which + /// offers no VRF, so this assignment is publicly computable ahead of + /// time -- a validator can predict which providers it will score. + /// Security therefore rests on quorum, outlier trimming, and bonded + /// stake rather than on assignment secrecy. Moving to unpredictable + /// per-round entropy is tracked as follow-up work. + pub fn committee(provider: &T::AccountId, round: u64) -> alloc::vec::Vec { + let mut candidates: alloc::vec::Vec = ActiveValidatorSet::::get() + .into_iter() + .filter(|candidate| candidate != provider) + .collect(); + let wanted = (T::TargetCommitteeSize::get() as usize).min(candidates.len()); + let mut committee = alloc::vec::Vec::with_capacity(wanted); + for nth in 0..wanted { + let seed = (provider.clone(), round, nth as u32).blake2_256(); + // Fold the first 8 bytes into an index over what's left; + // drawing without replacement keeps members distinct. + let mut raw = [0u8; 8]; + raw.copy_from_slice(&seed[..8]); + let index = (u64::from_le_bytes(raw) % candidates.len() as u64) as usize; + committee.push(candidates.remove(index)); + } + committee + } + + /// Whether `validator` holds a slot for `provider` in `round`. + pub fn is_assigned(provider: &T::AccountId, round: u64, validator: &T::AccountId) -> bool { + Self::committee(provider, round) + .iter() + .any(|member| member == validator) + } + /// 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 diff --git a/blockchain/pallets/network-validator/src/tests.rs b/blockchain/pallets/network-validator/src/tests.rs index 4a290b2..4166833 100644 --- a/blockchain/pallets/network-validator/src/tests.rs +++ b/blockchain/pallets/network-validator/src/tests.rs @@ -65,6 +65,7 @@ parameter_types! { pub const MaxSubmissionsPerRound: u32 = 8; pub const MinQuorum: u32 = 3; pub const TargetCommitteeSize: u32 = 5; + pub const MaxValidators: u32 = 16; } impl crate::Config for Test { @@ -76,6 +77,7 @@ impl crate::Config for Test { type MaxSubmissionsPerRound = MaxSubmissionsPerRound; type MinQuorum = MinQuorum; type TargetCommitteeSize = TargetCommitteeSize; + type MaxValidators = MaxValidators; type WeightInfo = (); } @@ -232,10 +234,13 @@ fn unregistered_account_is_never_active() { }); } -// --- Scoring: evidence submission and round aggregation (ADR-011 §3/§5) --- +// --- Scoring: committee assignment, evidence, aggregation (ADR-011 §1/§3/§5) --- -/// Registers `count` validators starting at account 10, all funded and -/// active, so scoring tests start from a realistic committee. +const PROVIDER: u64 = 1; +const ROUND: u64 = 7; + +/// Registers `count` validators (accounts 10..10+count), all funded and +/// active, so scoring tests start from a realistic validator set. fn register_validators(count: u64) -> Vec { (10..10 + count) .inspect(|&account| { @@ -246,44 +251,136 @@ fn register_validators(count: u64) -> Vec { .collect() } +/// The validators actually assigned to score PROVIDER in ROUND. Tests +/// submit from these rather than from arbitrary accounts, because slots +/// are assigned rather than self-selected. +fn committee() -> Vec { + NetworkValidator::committee(&PROVIDER, ROUND) +} + +fn submit(validator: u64, dimension: ScoreDimension, score: u16) -> sp_runtime::DispatchResult { + NetworkValidator::submit_evidence( + RuntimeOrigin::signed(validator), + PROVIDER, + ROUND, + dimension, + score, + 10, + [1; 32], + ) +} + +#[test] +fn committee_is_deterministic_bounded_and_excludes_the_provider() { + new_test_ext().execute_with(|| { + // Register the provider itself as a validator too, to prove it is + // filtered out of its own committee. + Balances::force_set_balance(RuntimeOrigin::root(), PROVIDER, 1_000).expect("fund"); + assert_ok!(NetworkValidator::register_validator( + RuntimeOrigin::signed(PROVIDER), + 100 + )); + register_validators(8); + + let first = committee(); + assert_eq!( + first.len(), + TargetCommitteeSize::get() as usize, + "committee must be exactly the target size when enough validators exist" + ); + assert!( + !first.contains(&PROVIDER), + "a provider must never be assigned to score itself" + ); + let mut distinct = first.clone(); + distinct.sort_unstable(); + distinct.dedup(); + assert_eq!(distinct.len(), first.len(), "members must be distinct"); + // Pure function of committed state: same inputs, same committee. + assert_eq!(first, committee()); + // A different round yields a different draw (not a fixed set). + assert_ne!(first, NetworkValidator::committee(&PROVIDER, ROUND + 1)); + }); +} + +#[test] +fn committee_shrinks_gracefully_when_few_validators_exist() { + new_test_ext().execute_with(|| { + let validators = register_validators(2); // fewer than TargetCommitteeSize + let assigned = committee(); + assert_eq!(assigned.len(), 2); + for member in &assigned { + assert!(validators.contains(member)); + } + }); +} + +#[test] +fn suspended_and_exiting_validators_leave_the_committee_pool() { + new_test_ext().execute_with(|| { + let validators = register_validators(6); + assert_eq!(crate::ActiveValidatorSet::::get().len(), 6); + + assert_ok!(NetworkValidator::suspend( + RuntimeOrigin::root(), + validators[0] + )); + assert_ok!(NetworkValidator::request_exit(RuntimeOrigin::signed( + validators[1] + ))); + let pool = crate::ActiveValidatorSet::::get(); + assert_eq!(pool.len(), 4); + assert!(!pool.contains(&validators[0])); + assert!(!pool.contains(&validators[1])); + assert!(!committee().contains(&validators[0])); + assert!(!committee().contains(&validators[1])); + + // Reinstatement puts a validator back in the pool. + assert_ok!(NetworkValidator::reinstate( + RuntimeOrigin::root(), + validators[0] + )); + assert!(crate::ActiveValidatorSet::::get().contains(&validators[0])); + }); +} + #[test] fn evidence_requires_an_active_validator() { new_test_ext().execute_with(|| { - clear_recorded(); + register_validators(6); // 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] - ), + submit(2, ScoreDimension::Compute, 5_000), crate::Error::::NotAnActiveValidator ); }); } +#[test] +fn an_unassigned_validator_cannot_submit() { + new_test_ext().execute_with(|| { + let validators = register_validators(8); + let assigned = committee(); + let outsider = validators + .iter() + .find(|candidate| !assigned.contains(candidate)) + .copied() + .expect("with 8 validators and a committee of 5 some are unassigned"); + assert_noop!( + submit(outsider, ScoreDimension::Compute, 5_000), + crate::Error::::NotAssignedToRound + ); + }); +} + #[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] - )); + register_validators(6); + let member = committee()[0]; + assert_ok!(NetworkValidator::suspend(RuntimeOrigin::root(), member)); assert_noop!( - NetworkValidator::submit_evidence( - RuntimeOrigin::signed(validators[0]), - 1, - 7, - ScoreDimension::Compute, - 5_000, - 10, - [1; 32] - ), + submit(member, ScoreDimension::Compute, 5_000), crate::Error::::NotAnActiveValidator ); }); @@ -292,12 +389,13 @@ fn a_suspended_validator_cannot_submit_evidence() { #[test] fn a_validator_cannot_score_itself() { new_test_ext().execute_with(|| { - let validators = register_validators(1); + register_validators(6); + let member = committee()[0]; assert_noop!( NetworkValidator::submit_evidence( - RuntimeOrigin::signed(validators[0]), - validators[0], // provider == validator - 7, + RuntimeOrigin::signed(member), + member, // provider == validator + ROUND, ScoreDimension::Compute, 10_000, 10, @@ -311,25 +409,17 @@ fn a_validator_cannot_score_itself() { #[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]; + register_validators(6); + let member = committee()[0]; assert_noop!( - NetworkValidator::submit_evidence( - RuntimeOrigin::signed(validator), - 1, - 7, - ScoreDimension::Compute, - 10_001, // > 100.00% - 10, - [1; 32] - ), + submit(member, ScoreDimension::Compute, 10_001), // > 100.00% crate::Error::::ScoreOutOfBounds ); assert_noop!( NetworkValidator::submit_evidence( - RuntimeOrigin::signed(validator), - 1, - 7, + RuntimeOrigin::signed(member), + PROVIDER, + ROUND, ScoreDimension::Compute, 5_000, 0, // no samples backing the claim @@ -337,61 +427,32 @@ fn evidence_rejects_duplicate_replayed_and_out_of_range_submissions() { ), crate::Error::::InvalidSampleCount ); - assert_ok!(NetworkValidator::submit_evidence( - RuntimeOrigin::signed(validator), - 1, - 7, - ScoreDimension::Compute, - 5_000, - 10, - [1; 32] - )); + assert_ok!(submit(member, ScoreDimension::Compute, 5_000)); // Same validator, same (provider, round, dimension) -> replay. assert_noop!( - NetworkValidator::submit_evidence( - RuntimeOrigin::signed(validator), - 1, - 7, - ScoreDimension::Compute, - 9_000, - 10, - [2; 32] - ), + submit(member, ScoreDimension::Compute, 9_000), 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] - )); + assert_ok!(submit(member, ScoreDimension::Storage, 9_000)); }); } #[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] - )); + clear_recorded(); + register_validators(6); + let assigned = committee(); + // Only two of the five assigned validators report; MinQuorum is 3. + for member in assigned.iter().take(2) { + assert_ok!(submit(*member, ScoreDimension::Compute, 5_000)); } assert_noop!( NetworkValidator::close_round( - RuntimeOrigin::signed(validators[0]), - 1, - 7, + RuntimeOrigin::signed(assigned[0]), + PROVIDER, + ROUND, ScoreDimension::Compute ), crate::Error::::QuorumNotReached @@ -406,39 +467,33 @@ 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); + register_validators(6); + let assigned = committee(); // 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] - )); + // observations at 6_000. A plain mean would be 5_200; the trimmed + // mean drops both extremes and yields exactly 6_000. + for (member, score) in assigned.iter().zip([0u16, 6_000, 6_000, 6_000, 10_000]) { + assert_ok!(submit(*member, ScoreDimension::Compute, score)); } assert_ok!(NetworkValidator::close_round( - RuntimeOrigin::signed(validators[0]), - 1, - 7, + RuntimeOrigin::signed(assigned[0]), + PROVIDER, + ROUND, ScoreDimension::Compute )); - let result = crate::Rounds::::get((1, 7, ScoreDimension::Compute)) + let result = crate::Rounds::::get((PROVIDER, ROUND, 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.committee_target, TargetCommitteeSize::get()); assert_eq!(result.closed_at, 11); // The aggregate reached the reputation layer exactly once. - assert_eq!(recorded(), vec![(1, ScoreDimension::Compute, 6_000)]); + assert_eq!(recorded(), vec![(PROVIDER, ScoreDimension::Compute, 6_000)]); // Raw submissions are cleared once aggregated. - assert!(crate::Evidence::::get((1, 7, ScoreDimension::Compute)).is_empty()); + assert!( + crate::Evidence::::get((PROVIDER, ROUND, ScoreDimension::Compute)).is_empty() + ); }); } @@ -446,44 +501,30 @@ fn closing_a_round_trims_outliers_and_records_the_aggregate() { 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] - )); + register_validators(6); + let assigned = committee(); + for member in assigned.iter().take(3) { + assert_ok!(submit(*member, ScoreDimension::Availability, 7_000)); } assert_ok!(NetworkValidator::close_round( - RuntimeOrigin::signed(validators[0]), - 1, - 7, + RuntimeOrigin::signed(assigned[0]), + PROVIDER, + ROUND, ScoreDimension::Availability )); assert_noop!( NetworkValidator::close_round( - RuntimeOrigin::signed(validators[0]), - 1, - 7, + RuntimeOrigin::signed(assigned[0]), + PROVIDER, + ROUND, ScoreDimension::Availability ), crate::Error::::RoundAlreadyClosed ); - // A late submitter cannot reopen or influence a final round. + // A late but legitimately assigned validator cannot reopen or + // influence a final round. assert_noop!( - NetworkValidator::submit_evidence( - RuntimeOrigin::signed(validators[3]), - 1, - 7, - ScoreDimension::Availability, - 0, - 10, - [9; 32] - ), + submit(assigned[4], ScoreDimension::Availability, 0), crate::Error::::RoundAlreadyClosed ); // Exactly one reputation update, despite the repeated attempts. @@ -494,52 +535,19 @@ fn a_closed_round_rejects_new_evidence_and_cannot_close_twice() { #[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] - )); + register_validators(6); + let assigned = committee(); + for member in assigned.iter().take(3) { + assert_ok!(submit(*member, ScoreDimension::Network, 5_000)); } assert_noop!( - NetworkValidator::submit_evidence( - RuntimeOrigin::signed(validators[8]), - 1, - 7, - ScoreDimension::Reliability, - 5_000, - 10, - [1; 32] + NetworkValidator::close_round( + RuntimeOrigin::signed(2), + PROVIDER, + ROUND, + ScoreDimension::Network ), - crate::Error::::TooManySubmissions + crate::Error::::NotAnActiveValidator ); }); } @@ -548,28 +556,34 @@ fn submissions_are_bounded_per_round() { fn exactly_quorum_sized_committee_trims_to_the_median() { new_test_ext().execute_with(|| { clear_recorded(); + register_validators(6); + let assigned = committee(); // 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] - )); + for (member, score) in assigned.iter().take(3).zip([0u16, 4_200, 10_000]) { + assert_ok!(submit(*member, ScoreDimension::Storage, score)); } assert_ok!(NetworkValidator::close_round( - RuntimeOrigin::signed(validators[0]), - 1, - 7, + RuntimeOrigin::signed(assigned[0]), + PROVIDER, + ROUND, ScoreDimension::Storage )); - assert_eq!(recorded(), vec![(1, ScoreDimension::Storage, 4_200)]); + assert_eq!(recorded(), vec![(PROVIDER, ScoreDimension::Storage, 4_200)]); + }); +} + +#[test] +fn the_active_set_is_bounded() { + new_test_ext().execute_with(|| { + // MaxValidators is 16. + register_validators(16); + Balances::force_set_balance(RuntimeOrigin::root(), 99, 1_000).expect("fund"); + assert_noop!( + NetworkValidator::register_validator(RuntimeOrigin::signed(99), 100), + crate::Error::::TooManyValidators + ); }); } diff --git a/blockchain/runtime/src/lib.rs b/blockchain/runtime/src/lib.rs index d26d1d4..7bb441e 100644 --- a/blockchain/runtime/src/lib.rs +++ b/blockchain/runtime/src/lib.rs @@ -110,6 +110,7 @@ parameter_types! { // trimmed mean can discard an outlier at both ends (ADR-011 §5). pub const ValidatorMinQuorum: u32 = 3; pub const ValidatorTargetCommitteeSize: u32 = 5; + pub const MaxNetworkValidators: u32 = 256; } #[derive_impl(frame_system::config_preludes::SolochainDefaultConfig)] @@ -281,6 +282,7 @@ impl pallet_network_validator::Config for Runtime { type MaxSubmissionsPerRound = MaxValidatorSubmissionsPerRound; type MinQuorum = ValidatorMinQuorum; type TargetCommitteeSize = ValidatorTargetCommitteeSize; + type MaxValidators = MaxNetworkValidators; type WeightInfo = (); }