diff --git a/Cargo.lock b/Cargo.lock index 07b86d58..eff625c3 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -9911,7 +9911,7 @@ checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d" [[package]] name = "orbinum-node" -version = "0.3.0" +version = "0.4.0" dependencies = [ "async-trait", "clap", @@ -9948,6 +9948,7 @@ dependencies = [ "pallet-transaction-payment", "pallet-transaction-payment-rpc", "pallet-transaction-payment-rpc-runtime-api", + "pallet-validator-set", "pallet-zk-verifier-rpc", "pallet-zk-verifier-runtime-api", "parity-scale-codec", @@ -9994,7 +9995,7 @@ dependencies = [ [[package]] name = "orbinum-runtime" -version = "0.3.0" +version = "0.4.0" dependencies = [ "anyhow", "ethereum", @@ -12797,17 +12798,20 @@ dependencies = [ [[package]] name = "pallet-validator-set" -version = "0.3.0" +version = "0.4.0" dependencies = [ + "async-trait", "frame-benchmarking", "frame-support", "frame-system", "impl-trait-for-tuples", + "log", "pallet-balances", "pallet-session", "parity-scale-codec", "scale-info", "sp-core", + "sp-inherents", "sp-io", "sp-runtime", "sp-std", diff --git a/frame/validator-set/CHANGELOG.md b/frame/validator-set/CHANGELOG.md index 6e2d7c21..0550ed1d 100644 --- a/frame/validator-set/CHANGELOG.md +++ b/frame/validator-set/CHANGELOG.md @@ -5,6 +5,56 @@ Format follows [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). --- +## [Unreleased] + +--- + +## [0.4.0] — 2026-10-07 + +Governance can require a minimum node version from block authors, to coordinate +upgrades that need every author on a newer binary. **Breaking** — `Config` gains +`FindAuthor` and `QuorumWindow`. Ships with runtime spec 18 and `orbinum-node` +0.4.0, the first binary that declares its version. + +### Added + +#### Minimum author version +- `note_author_version(NodeVersion)` (call 7): a mandatory inherent through + which the author's node declares its crate version (`INHERENT_IDENTIFIER` + `*b"nodevers"`, provided by `author_version::InherentDataProvider`). It runs + at most once per block and is refused below the minimum. A node below it + builds no inherent and logs why, so its block fails in `on_finalize`. The + pool rejects the call from any account, signed or not: mandatory calls are + never validated as transactions. +- `set_min_author_version(Option)` (call 6), `AddRemoveOrigin`: + sets or lifts `MinAuthorVersion` and emits `MinAuthorVersionSet`. `Some(v)` is + refused with `VersionQuorumNotMet` unless 2/3 of the approved set declared + ≥ `v` within `QuorumWindow` blocks; an empty set is always refused, and + lifting needs no quorum. +- With a minimum set, a block without a declaration is invalid + (`on_finalize` panics). Binaries that predate the inherent provide none, so + they lose their slots and keep importing. +- `LastAuthorVersion` records each approved author's last declaration and its + block. Leaving the set drops the entry, so the map stays bounded by + `MaxValidators`. A block authored without a declaration drops its author's + entry too, so a validator that rolled back to an older binary stops counting + toward a quorum at once rather than for the rest of `QuorumWindow`. +- `NodeVersion`: `const fn parse` (so a node parses its crate version at + compile time and an unfit version fails the build) and `Display` + (`major.minor.patch`). + +The version is self-declared: it coordinates honest operators and is not a +security boundary. Declarations are public, mapping each validator to the +version it runs. + +**Config:** `FindAuthor` and `QuorumWindow` (the runtime uses one session). + +**Weights:** `set_min_author_version` and `note_author_version` are estimated, +not yet benchmarked on the reference machine. `remove_validator` and +`deregister_validator` gain one storage removal; re-benchmark both. + +--- + ## [0.3.0] — 2026-08-20 Validator onboarding moves off-chain: the two-phase self-registration flow is diff --git a/frame/validator-set/Cargo.toml b/frame/validator-set/Cargo.toml index 7a2ff1c1..84574170 100644 --- a/frame/validator-set/Cargo.toml +++ b/frame/validator-set/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "pallet-validator-set" -version = "0.3.0" +version = "0.4.0" description = "Sudo-controlled validator set for Orbinum. Validators can only join via governance." authors = { workspace = true } license = "GPL-3.0-or-later" @@ -11,7 +11,9 @@ repository = "https://github.com/orbinum/node" targets = ["x86_64-unknown-linux-gnu"] [dependencies] +async-trait = { workspace = true, optional = true } impl-trait-for-tuples = { workspace = true } +log = { workspace = true } scale-codec = { workspace = true } scale-info = { workspace = true } # Substrate @@ -19,6 +21,7 @@ frame-benchmarking = { workspace = true, optional = true } frame-support = { workspace = true } frame-system = { workspace = true } pallet-session = { workspace = true } +sp-inherents = { workspace = true } sp-runtime = { workspace = true } sp-std = { workspace = true } @@ -34,12 +37,15 @@ sp-runtime = { workspace = true, features = ["std"] } [features] default = ["std"] std = [ + "async-trait", + "log/std", "scale-codec/std", "scale-info/std", "frame-benchmarking?/std", "frame-support/std", "frame-system/std", "pallet-session/std", + "sp-inherents/std", "sp-runtime/std", "sp-std/std", ] diff --git a/frame/validator-set/src/author_version/declarations.rs b/frame/validator-set/src/author_version/declarations.rs new file mode 100644 index 00000000..01f3bcb8 --- /dev/null +++ b/frame/validator-set/src/author_version/declarations.rs @@ -0,0 +1,109 @@ +//! The on-chain rules for declared author versions: which declarations a block +//! accepts, what is recorded, when a block is invalid, and the quorum a new +//! minimum needs. + +use super::NodeVersion; +use crate::{ + ApprovedValidators, AuthorVersionNoted, Call, Config, Error, LastAuthorVersion, + MinAuthorVersion, Pallet, +}; +use frame_support::{ + dispatch::DispatchResult, + ensure, + traits::{FindAuthor, Get}, +}; +use sp_runtime::traits::Saturating; + +impl Pallet { + /// The inherent call declaring `version`, or none if the chain would refuse + /// it. A refused node then authors a block without a declaration, which + /// `settle_author_declaration` rejects; the node logs why here instead of the + /// proposer's generic "inherent returned unexpected error". + pub(crate) fn declaration_call(version: NodeVersion) -> Option> { + if let Some(min) = MinAuthorVersion::::get().filter(|min| version < *min) { + log::warn!( + target: "runtime::validator-set", + "this node's version {version} is below the minimum {min}: it cannot author until it upgrades", + ); + return None; + } + Some(Call::note_author_version { version }) + } + + /// Accepts the block author's declaration: at most one per block, never + /// below the minimum. + pub(crate) fn note_declaration(version: NodeVersion) -> DispatchResult { + ensure!( + !AuthorVersionNoted::::get(), + Error::::AuthorVersionAlreadyNoted + ); + if let Some(min) = MinAuthorVersion::::get() { + ensure!(version >= min, Error::::AuthorVersionTooOld); + } + AuthorVersionNoted::::put(true); + Self::record_author_version(version); + Ok(()) + } + + /// Closes the block's declaration. Without one, the author stops counting + /// toward a quorum (it may have rolled back to an older binary), and while + /// a minimum is set the block is invalid: panicking in `on_finalize` is how + /// FRAME rejects a block, so the author's node fails to build it and any + /// other node fails to import it. + pub(crate) fn settle_author_declaration() { + if AuthorVersionNoted::::take() { + return; + } + Self::forget_author_version(); + if let Some(min) = MinAuthorVersion::::get() { + panic!("block author declared no node version; minimum is {min}"); + } + } + + /// Whether at least 2/3 of the approved set declared `min` or newer within + /// `QuorumWindow`. An empty set proves nothing: the session may still run + /// validators that never declared. + pub(crate) fn has_version_quorum(min: &NodeVersion) -> bool { + let validators = ApprovedValidators::::get(); + if validators.is_empty() { + return false; + } + let now = frame_system::Pallet::::block_number(); + let window = T::QuorumWindow::get(); + let ready = validators + .iter() + .filter(|v| { + LastAuthorVersion::::get(v).is_some_and(|(version, at)| { + version >= *min && now.saturating_sub(at) <= window + }) + }) + .count(); + ready.saturating_mul(3) >= validators.len().saturating_mul(2) + } + + /// Records `version` against the block's author if it is approved. Only + /// approved validators count toward the quorum, and keeping the map to them + /// bounds it by `MaxValidators`. + fn record_author_version(version: NodeVersion) { + let Some(author) = Self::block_author() else { + return; + }; + if ApprovedValidators::::get().contains(&author) { + let now = frame_system::Pallet::::block_number(); + LastAuthorVersion::::insert(author, (version, now)); + } + } + + /// Drops the block's author's last declaration. + fn forget_author_version() { + if let Some(author) = Self::block_author() { + LastAuthorVersion::::remove(author); + } + } + + /// The current block's author, from its pre-runtime digests. + fn block_author() -> Option { + let digest = frame_system::Pallet::::digest(); + T::FindAuthor::find_author(digest.logs.iter().filter_map(|d| d.as_pre_runtime())) + } +} diff --git a/frame/validator-set/src/author_version/inherent.rs b/frame/validator-set/src/author_version/inherent.rs new file mode 100644 index 00000000..ef9b39f1 --- /dev/null +++ b/frame/validator-set/src/author_version/inherent.rs @@ -0,0 +1,31 @@ +//! The inherent that carries the author's node version from the node to the +//! runtime. + +use super::NodeVersion; +use sp_inherents::InherentIdentifier; + +/// Inherent identifier of the author's declared node version. +pub const INHERENT_IDENTIFIER: InherentIdentifier = *b"nodevers"; + +/// Provides the running node's version to the blocks it authors. +#[cfg(feature = "std")] +pub struct InherentDataProvider(pub NodeVersion); + +#[cfg(feature = "std")] +#[async_trait::async_trait] +impl sp_inherents::InherentDataProvider for InherentDataProvider { + async fn provide_inherent_data( + &self, + inherent_data: &mut sp_inherents::InherentData, + ) -> Result<(), sp_inherents::Error> { + inherent_data.put_data(INHERENT_IDENTIFIER, &self.0) + } + + async fn try_handle_error( + &self, + _identifier: &InherentIdentifier, + _error: &[u8], + ) -> Option> { + None + } +} diff --git a/frame/validator-set/src/author_version/mod.rs b/frame/validator-set/src/author_version/mod.rs new file mode 100644 index 00000000..58a3984b --- /dev/null +++ b/frame/validator-set/src/author_version/mod.rs @@ -0,0 +1,26 @@ +//! The node version a block author declares, and the rules the runtime applies +//! to it. +//! +//! The runtime cannot see the binary executing it, so the author's node states +//! its version in an inherent and the runtime checks the statement against +//! [`MinAuthorVersion`](crate::MinAuthorVersion). It coordinates honest +//! operators; it does not stop a modified binary from claiming any version. +//! +//! A binary that predates this inherent provides none: while no minimum is set +//! that changes nothing, and once one is, its blocks are invalid. +//! +//! Declarations are public chain state: anyone can map a validator to the +//! version it runs, as telemetry and `system_version` already allow. +//! +//! - [`version`]: the declared [`NodeVersion`]. +//! - [`inherent`]: how the node hands it to the runtime. +//! - `declarations`: what the runtime accepts, records and rejects. + +mod declarations; +pub mod inherent; +pub mod version; + +pub use inherent::INHERENT_IDENTIFIER; +#[cfg(feature = "std")] +pub use inherent::InherentDataProvider; +pub use version::NodeVersion; diff --git a/frame/validator-set/src/author_version/version.rs b/frame/validator-set/src/author_version/version.rs new file mode 100644 index 00000000..2e242fe8 --- /dev/null +++ b/frame/validator-set/src/author_version/version.rs @@ -0,0 +1,136 @@ +//! The `major.minor.patch` version a node declares. + +use core::fmt; +use scale_codec::{Decode, DecodeWithMemTracking, Encode, MaxEncodedLen}; +use scale_info::TypeInfo; + +/// A `major.minor.patch` node version. Ordering is lexicographic over the +/// fields, which is semver precedence for release versions. +#[derive( + Clone, + Copy, + PartialEq, + Eq, + PartialOrd, + Ord, + Encode, + Decode, + DecodeWithMemTracking, + MaxEncodedLen, + TypeInfo, + Debug +)] +pub struct NodeVersion { + pub major: u16, + pub minor: u16, + pub patch: u16, +} + +impl NodeVersion { + pub const fn new(major: u16, minor: u16, patch: u16) -> Self { + Self { + major, + minor, + patch, + } + } + + /// Parses `major.minor.patch`, ignoring any `-pre` or `+build` suffix. Each + /// part must be a non-empty decimal that fits in a `u16`. + /// + /// `const`, so a node can parse its own crate version at compile time and a + /// version that does not fit fails the build rather than the running node. + pub const fn parse(version: &str) -> Option { + let bytes = version.as_bytes(); + let mut parts = [0u16; 3]; + let (mut part, mut value, mut digits, mut i) = (0usize, 0u32, 0usize, 0usize); + while i < bytes.len() { + match bytes[i] { + b @ b'0'..=b'9' => { + // `value` <= u16::MAX here, so this cannot overflow a u32. + value = value * 10 + (b - b'0') as u32; + if value > u16::MAX as u32 { + return None; + } + digits += 1; + } + b'.' if digits > 0 && part < 2 => { + parts[part] = value as u16; + (part, value, digits) = (part + 1, 0, 0); + } + b'-' | b'+' => break, + _ => return None, + } + i += 1; + } + if digits == 0 || part != 2 { + return None; + } + parts[2] = value as u16; + Some(Self::new(parts[0], parts[1], parts[2])) + } +} + +impl fmt::Display for NodeVersion { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "{}.{}.{}", self.major, self.minor, self.patch) + } +} + +#[cfg(test)] +mod tests { + use super::NodeVersion; + + const V: fn(u16, u16, u16) -> NodeVersion = NodeVersion::new; + + #[test] + fn parses_release_and_suffixed_versions() { + assert_eq!(NodeVersion::parse("0.3.0"), Some(V(0, 3, 0))); + assert_eq!(NodeVersion::parse("1.12.7-rc.1"), Some(V(1, 12, 7))); + assert_eq!(NodeVersion::parse("2.0.1+sha.abc"), Some(V(2, 0, 1))); + assert_eq!( + NodeVersion::parse("65535.0.65535"), + Some(V(65535, 0, 65535)) + ); + } + + #[test] + fn rejects_malformed_versions() { + for bad in [ + "", + "1", + "1.2", + "1.2.3.4", + "a.b.c", + "1.2.x", + "1..2", + ".1.2", + "1.2.", + "70000.0.0", + "1.65536.0", + "-1.2.3", + " 1.2.3", + ] { + assert_eq!(NodeVersion::parse(bad), None, "{bad:?}"); + } + } + + #[test] + fn parses_at_compile_time() { + const PARSED: Option = NodeVersion::parse("1.2.3"); + assert_eq!(PARSED, Some(V(1, 2, 3))); + } + + #[test] + fn orders_by_major_then_minor_then_patch() { + assert!(V(0, 3, 0) < V(0, 3, 1)); + assert!(V(0, 3, 9) < V(0, 4, 0)); + assert!(V(0, 9, 9) < V(1, 0, 0)); + assert_eq!(V(1, 2, 3), V(1, 2, 3)); + } + + #[test] + fn displays_as_major_minor_patch() { + assert_eq!(V(0, 4, 12).to_string(), "0.4.12"); + } +} diff --git a/frame/validator-set/src/benchmarking.rs b/frame/validator-set/src/benchmarking.rs index 55a70740..ed87815b 100644 --- a/frame/validator-set/src/benchmarking.rs +++ b/frame/validator-set/src/benchmarking.rs @@ -75,6 +75,48 @@ mod benchmarks { assert!(!ApprovedValidators::::get().contains(&target)); } + // ── set_min_author_version ─────────────────────────────────────────────── + + /// Worst case: a full set, every validator counted toward the quorum, so the + /// guard reads one `LastAuthorVersion` entry per validator. + #[benchmark] + fn set_min_author_version() { + let max = T::MaxValidators::get(); + let validators: sp_std::vec::Vec = (0..max) + .map(|i| account::("validator", i, 0)) + .collect(); + let min = NodeVersion::new(1, 0, 0); + let now = frame_system::Pallet::::block_number(); + for v in &validators { + LastAuthorVersion::::insert(v, (min, now)); + } + let bounded: frame_support::BoundedVec = validators + .try_into() + .expect("max entries == MaxValidators; qed"); + ApprovedValidators::::put(bounded); + + #[extrinsic_call] + set_min_author_version(RawOrigin::Root, Some(min)); + + assert_eq!(MinAuthorVersion::::get(), Some(min)); + } + + // ── note_author_version ────────────────────────────────────────────────── + + /// Worst case: a minimum is set, so the declared version is compared too. + /// The runtime's `FindAuthor` needs an Aura pre-digest a benchmark block + /// lacks, so the author lookup and the `LastAuthorVersion` write are not + /// measured; the estimated weight accounts for them. + #[benchmark] + fn note_author_version() { + MinAuthorVersion::::put(NodeVersion::new(0, 0, 1)); + + #[extrinsic_call] + note_author_version(RawOrigin::None, NodeVersion::new(1, 0, 0)); + + assert!(AuthorVersionNoted::::get()); + } + impl_benchmark_test_suite!( Pallet, crate::mock::ExtBuilder::default().build(), diff --git a/frame/validator-set/src/lib.rs b/frame/validator-set/src/lib.rs index a6d9dc94..401ed011 100644 --- a/frame/validator-set/src/lib.rs +++ b/frame/validator-set/src/lib.rs @@ -31,6 +31,15 @@ //! an approved account without keys would occupy a slot without authoring blocks. //! - Leaving the set (by either path) notifies [`Config::OnValidatorRemoved`] so //! dependent state, such as the EVM relay binding, is cleaned up. +//! +//! ## Minimum author version +//! +//! Each block's author declares its node version in an inherent +//! ([`author_version`]). [`set_min_author_version`][Pallet::set_min_author_version] +//! makes a block invalid unless its author declared at least that version, so a +//! lagging binary loses its slots until it upgrades. It is refused unless 2/3 of +//! the set already authored with that version within [`Config::QuorumWindow`], +//! so it cannot halt the chain. With no minimum set, any binary may author. #![cfg_attr(not(feature = "std"), no_std)] @@ -54,10 +63,17 @@ mod tests; pub mod traits; pub use traits::{OnValidatorRemoved, ValidatorPrerequisites, ValidatorSetInterface}; +pub mod author_version; +pub use author_version::NodeVersion; + #[frame_support::pallet] pub mod pallet { use super::*; - use frame_support::{pallet_prelude::*, traits::EnsureOrigin}; + use crate::author_version::INHERENT_IDENTIFIER; + use frame_support::{ + pallet_prelude::*, + traits::{EnsureOrigin, FindAuthor}, + }; use frame_system::pallet_prelude::*; use pallet_session::SessionManager; @@ -83,6 +99,15 @@ pub mod pallet { /// Notified whenever an account leaves the approved set. type OnValidatorRemoved: crate::OnValidatorRemoved; + /// The account that authored the current block, from its pre-runtime digests. + type FindAuthor: FindAuthor; + + /// How recent a validator's declared version must be to count toward the + /// quorum [`Pallet::set_min_author_version`] requires. One session is enough + /// for every active validator to author at least once. + #[pallet::constant] + type QuorumWindow: Get>; + /// Weight information for the pallet's dispatchables. type WeightInfo: crate::WeightInfo; } @@ -98,6 +123,25 @@ pub mod pallet { pub type ApprovedValidators = StorageValue<_, BoundedVec, ValueQuery>; + /// The oldest node version a block author may run. `None`: any version, or none + /// declared at all. + #[pallet::storage] + pub type MinAuthorVersion = StorageValue<_, NodeVersion, OptionQuery>; + + /// The node version each validator last declared, and the block it did so in. + #[pallet::storage] + pub type LastAuthorVersion = StorageMap< + _, + Blake2_128Concat, + T::AccountId, + (NodeVersion, BlockNumberFor), + OptionQuery, + >; + + /// Whether the current block's author declared a version. Cleared every block. + #[pallet::storage] + pub type AuthorVersionNoted = StorageValue<_, bool, ValueQuery>; + // ── Events ───────────────────────────────────────────────────────────── #[pallet::event] #[pallet::generate_deposit(pub(super) fn deposit_event)] @@ -106,6 +150,8 @@ pub mod pallet { ValidatorAdded { validator: T::AccountId }, /// A validator left the approved set, by sudo or voluntarily. ValidatorRemoved { validator: T::AccountId }, + /// The minimum author version changed; `None` lifts it. + MinAuthorVersionSet { version: Option }, } // ── Errors ───────────────────────────────────────────────────────────── @@ -119,6 +165,30 @@ pub mod pallet { TooManyValidators, /// Session keys (Aura + GRANDPA) not yet registered via `session.setKeys`. NoSessionKeys, + /// Fewer than 2/3 of the approved set authored with that version within + /// `QuorumWindow`, or the set is empty: setting it could leave the chain + /// without authors. + VersionQuorumNotMet, + /// The author declared a version below `MinAuthorVersion`. + AuthorVersionTooOld, + /// The author's version was already declared in this block. + AuthorVersionAlreadyNoted, + } + + // ── Hooks ────────────────────────────────────────────────────────────── + #[pallet::hooks] + impl Hooks> for Pallet { + fn on_initialize(_n: BlockNumberFor) -> Weight { + // What `on_finalize` reads and clears: the flag, the minimum, the + // author lookup and, for an undeclared block, its author's entry. + T::DbWeight::get().reads_writes(3, 2) + } + + /// Rejects the block if a minimum is set and its author declared no + /// version. + fn on_finalize(_n: BlockNumberFor) { + Self::settle_author_declaration(); + } } // ── Genesis ──────────────────────────────────────────────────────────── @@ -204,12 +274,70 @@ pub mod pallet { Self::remove_from_approved(&who)?; Ok(()) } + + // ── Minimum author version ───────────────────────────────────────────── + + /// Set, or with `None` lift, the oldest node version a block author may run. + /// + /// Requires `AddRemoveOrigin`. Setting a version is refused unless 2/3 of the + /// approved set authored with at least that version within `QuorumWindow`; + /// lifting it is always allowed. + #[pallet::call_index(6)] + #[pallet::weight(T::WeightInfo::set_min_author_version())] + pub fn set_min_author_version( + origin: OriginFor, + version: Option, + ) -> DispatchResult { + T::AddRemoveOrigin::ensure_origin(origin)?; + if let Some(min) = &version { + ensure!( + Self::has_version_quorum(min), + Error::::VersionQuorumNotMet + ); + } + MinAuthorVersion::::set(version); + Self::deposit_event(Event::MinAuthorVersionSet { version }); + Ok(()) + } + + /// The block author's declared node version. Inherent: one per block, + /// provided by the author's node. + #[pallet::call_index(7)] + #[pallet::weight((T::WeightInfo::note_author_version(), DispatchClass::Mandatory))] + pub fn note_author_version(origin: OriginFor, version: NodeVersion) -> DispatchResult { + ensure_none(origin)?; + Self::note_declaration(version) + } + } + + // ── Inherent ─────────────────────────────────────────────────────────── + #[pallet::inherent] + impl ProvideInherent for Pallet { + type Call = Call; + type Error = sp_inherents::MakeFatalError<()>; + const INHERENT_IDENTIFIER: InherentIdentifier = INHERENT_IDENTIFIER; + + /// A node that provides no version (one predating this inherent) yields no + /// call, and neither does one the chain would refuse; `on_finalize` decides + /// whether that block may stand. + fn create_inherent(data: &InherentData) -> Option { + let version = data + .get_data::(&INHERENT_IDENTIFIER) + .ok() + .flatten()?; + Self::declaration_call(version) + } + + fn is_inherent(call: &Self::Call) -> bool { + matches!(call, Call::note_author_version { .. }) + } } // ── Internal helpers ─────────────────────────────────────────────────── impl Pallet { - /// Drop `validator` from the approved set, notify the removal hook and - /// emit `ValidatorRemoved`. Fails if the account is not in the set. + /// Drop `validator` from the approved set, and its declared version with + /// it, notify the removal hook and emit `ValidatorRemoved`. Fails if the + /// account is not in the set. fn remove_from_approved(validator: &T::AccountId) -> DispatchResult { ApprovedValidators::::try_mutate(|validators| -> DispatchResult { let pos = validators @@ -219,6 +347,7 @@ pub mod pallet { validators.remove(pos); Ok(()) })?; + LastAuthorVersion::::remove(validator); T::OnValidatorRemoved::on_validator_removed(validator); Self::deposit_event(Event::ValidatorRemoved { diff --git a/frame/validator-set/src/mock.rs b/frame/validator-set/src/mock.rs index b34ee272..5f7ca48f 100644 --- a/frame/validator-set/src/mock.rs +++ b/frame/validator-set/src/mock.rs @@ -76,11 +76,36 @@ parameter_types! { pub const MaxValidators: u32 = 10; } +parameter_types! { + pub const QuorumWindow: u64 = 100; +} + +std::thread_local! { + static BLOCK_AUTHOR: core::cell::RefCell> = const { core::cell::RefCell::new(None) }; +} + +/// Makes `who` the author of the blocks that follow. +pub fn set_block_author(who: Option) { + BLOCK_AUTHOR.with(|a| *a.borrow_mut() = who); +} + +pub struct MockFindAuthor; +impl frame_support::traits::FindAuthor for MockFindAuthor { + fn find_author<'a, I>(_digests: I) -> Option + where + I: 'a + IntoIterator, + { + BLOCK_AUTHOR.with(|a| *a.borrow()) + } +} + impl pallet_validator_set::Config for Test { type AddRemoveOrigin = frame_system::EnsureRoot; type MaxValidators = MaxValidators; type Prerequisites = MockPrerequisites; type OnValidatorRemoved = MockOnValidatorRemoved; + type FindAuthor = MockFindAuthor; + type QuorumWindow = QuorumWindow; type WeightInfo = (); } @@ -110,6 +135,7 @@ impl ExtBuilder { // whichever test happens to run next. MOCK_HAS_SESSION_KEYS.with(|v| *v.borrow_mut() = true); REMOVED_HOOK_CALLS.with(|v| v.borrow_mut().clear()); + set_block_author(None); let mut storage = frame_system::GenesisConfig::::default() .build_storage() diff --git a/frame/validator-set/src/tests/author_version.rs b/frame/validator-set/src/tests/author_version.rs new file mode 100644 index 00000000..5c680adc --- /dev/null +++ b/frame/validator-set/src/tests/author_version.rs @@ -0,0 +1,302 @@ +//! Minimum author version: declarations, the block check and the quorum. + +use super::*; +use crate::{ + AuthorVersionNoted, LastAuthorVersion, MinAuthorVersion, NodeVersion, + author_version::INHERENT_IDENTIFIER, +}; +use frame_support::{ + inherent::{InherentData, ProvideInherent}, + traits::Hooks, +}; + +const V: fn(u16, u16, u16) -> NodeVersion = NodeVersion::new; + +/// `who` authors the current block declaring `version`, as its node's inherent would. +fn author_declares( + who: AccountId, + version: NodeVersion, +) -> frame_support::dispatch::DispatchResult { + set_block_author(Some(who)); + ValidatorSet::note_author_version(RuntimeOrigin::none(), version) +} + +fn finalize() { + ValidatorSet::on_finalize(System::block_number()); +} + +fn next_block() { + System::set_block_number(System::block_number() + 1); +} + +/// Each of `who` authors a block declaring `version`. +fn all_declare(who: &[AccountId], version: NodeVersion) { + for &v in who { + assert_ok!(author_declares(v, version)); + finalize(); + next_block(); + } +} + +// ── note_author_version ────────────────────────────────────────────────── + +#[test] +fn records_the_authors_version_and_block() { + ExtBuilder::default().build().execute_with(|| { + assert_ok!(author_declares(1, V(0, 4, 0))); + assert_eq!(LastAuthorVersion::::get(1), Some((V(0, 4, 0), 1))); + assert!(AuthorVersionNoted::::get()); + }); +} + +#[test] +fn a_non_approved_author_is_not_recorded() { + ExtBuilder::default().build().execute_with(|| { + assert_ok!(author_declares(99, V(0, 4, 0))); + assert!(AuthorVersionNoted::::get()); + assert_eq!(LastAuthorVersion::::get(99), None); + }); +} + +#[test] +fn leaving_the_set_drops_the_declaration() { + ExtBuilder::default().build().execute_with(|| { + all_declare(&[1, 2], V(0, 4, 0)); + assert_ok!(ValidatorSet::remove_validator(RuntimeOrigin::root(), 1)); + assert_ok!(ValidatorSet::deregister_validator(RuntimeOrigin::signed(2))); + assert_eq!(LastAuthorVersion::::get(1), None); + assert_eq!(LastAuthorVersion::::get(2), None); + }); +} + +#[test] +fn an_author_that_stops_declaring_no_longer_counts() { + ExtBuilder::default() + .validators(vec![10, 20, 30, 40]) + .build() + .execute_with(|| { + all_declare(&[10, 20, 30], V(0, 4, 0)); + // 30 rolls back to a binary that declares nothing. + set_block_author(Some(30)); + finalize(); + next_block(); + assert_eq!(LastAuthorVersion::::get(30), None); + assert_noop!( + ValidatorSet::set_min_author_version(RuntimeOrigin::root(), Some(V(0, 4, 0))), + Error::::VersionQuorumNotMet + ); + }); +} + +#[test] +fn is_an_inherent_not_a_signed_call() { + ExtBuilder::default().build().execute_with(|| { + assert!(ValidatorSet::note_author_version(RuntimeOrigin::signed(1), V(0, 4, 0)).is_err()); + assert!(ValidatorSet::note_author_version(RuntimeOrigin::root(), V(0, 4, 0)).is_err()); + }); +} + +#[test] +fn is_declared_at_most_once_per_block() { + ExtBuilder::default().build().execute_with(|| { + assert_ok!(author_declares(1, V(0, 4, 0))); + assert_noop!( + author_declares(1, V(0, 4, 0)), + Error::::AuthorVersionAlreadyNoted + ); + finalize(); + next_block(); + assert_ok!(author_declares(1, V(0, 4, 0))); + }); +} + +#[test] +fn a_version_below_the_minimum_is_refused_equal_or_newer_accepted() { + ExtBuilder::default().build().execute_with(|| { + MinAuthorVersion::::put(V(0, 4, 0)); + for older in [V(0, 3, 9), V(0, 3, 0), V(0, 0, 9)] { + assert_noop!( + author_declares(1, older), + Error::::AuthorVersionTooOld + ); + } + for ok in [V(0, 4, 0), V(0, 4, 1), V(0, 5, 0), V(1, 0, 0)] { + assert_ok!(author_declares(1, ok)); + AuthorVersionNoted::::kill(); + } + }); +} + +// ── on_finalize ────────────────────────────────────────────────────────── + +#[test] +fn without_a_minimum_a_block_needs_no_declaration() { + ExtBuilder::default().build().execute_with(|| { + finalize(); // no panic: an old binary may author + }); +} + +#[test] +#[should_panic(expected = "block author declared no node version")] +fn with_a_minimum_a_block_without_declaration_is_invalid() { + ExtBuilder::default().build().execute_with(|| { + MinAuthorVersion::::put(V(0, 4, 0)); + finalize(); + }); +} + +#[test] +fn with_a_minimum_a_declared_block_finalises_and_the_flag_clears() { + ExtBuilder::default().build().execute_with(|| { + MinAuthorVersion::::put(V(0, 4, 0)); + assert_ok!(author_declares(1, V(0, 4, 0))); + finalize(); + assert!(!AuthorVersionNoted::::get()); + }); +} + +// ── set_min_author_version ─────────────────────────────────────────────── + +#[test] +fn only_the_add_remove_origin_sets_it() { + ExtBuilder::default() + .validators(vec![]) + .build() + .execute_with(|| { + assert!( + ValidatorSet::set_min_author_version(RuntimeOrigin::signed(1), Some(V(0, 4, 0))) + .is_err() + ); + }); +} + +#[test] +fn sets_with_two_thirds_of_the_set_ready_and_emits_the_event() { + ExtBuilder::default() + .validators(vec![10, 20, 30, 40]) + .build() + .execute_with(|| { + all_declare(&[10, 20, 30], V(0, 4, 0)); + assert_ok!(ValidatorSet::set_min_author_version( + RuntimeOrigin::root(), + Some(V(0, 4, 0)) + )); + assert_eq!(MinAuthorVersion::::get(), Some(V(0, 4, 0))); + System::assert_last_event( + Event::MinAuthorVersionSet { + version: Some(V(0, 4, 0)), + } + .into(), + ); + }); +} + +#[test] +fn is_refused_without_quorum() { + ExtBuilder::default() + .validators(vec![10, 20, 30, 40]) + .build() + .execute_with(|| { + all_declare(&[10, 20], V(0, 4, 0)); + assert_noop!( + ValidatorSet::set_min_author_version(RuntimeOrigin::root(), Some(V(0, 4, 0))), + Error::::VersionQuorumNotMet + ); + }); +} + +#[test] +fn older_declarations_do_not_count_toward_quorum() { + ExtBuilder::default() + .validators(vec![10, 20, 30, 40]) + .build() + .execute_with(|| { + all_declare(&[10, 20], V(0, 4, 0)); + all_declare(&[30], V(0, 3, 0)); + assert_noop!( + ValidatorSet::set_min_author_version(RuntimeOrigin::root(), Some(V(0, 4, 0))), + Error::::VersionQuorumNotMet + ); + }); +} + +#[test] +fn stale_declarations_do_not_count_toward_quorum() { + ExtBuilder::default() + .validators(vec![10, 20, 30, 40]) + .build() + .execute_with(|| { + all_declare(&[10, 20, 30], V(0, 4, 0)); + // 30 declared at block 3; QuorumWindow is 100 in the mock. + System::set_block_number(3 + QuorumWindow::get() + 1); + all_declare(&[10, 20], V(0, 4, 0)); + assert_noop!( + ValidatorSet::set_min_author_version(RuntimeOrigin::root(), Some(V(0, 4, 0))), + Error::::VersionQuorumNotMet + ); + }); +} + +#[test] +fn lifting_it_needs_no_quorum() { + ExtBuilder::default() + .validators(vec![10, 20, 30, 40]) + .build() + .execute_with(|| { + MinAuthorVersion::::put(V(9, 9, 9)); + assert_ok!(ValidatorSet::set_min_author_version( + RuntimeOrigin::root(), + None + )); + assert_eq!(MinAuthorVersion::::get(), None); + System::assert_last_event(Event::MinAuthorVersionSet { version: None }.into()); + }); +} + +#[test] +fn is_refused_for_an_empty_set() { + ExtBuilder::default() + .validators(vec![]) + .build() + .execute_with(|| { + assert_noop!( + ValidatorSet::set_min_author_version(RuntimeOrigin::root(), Some(V(0, 4, 0))), + Error::::VersionQuorumNotMet + ); + }); +} + +// ── ProvideInherent ────────────────────────────────────────────────────── + +#[test] +fn the_inherent_is_built_only_when_the_node_provides_a_version() { + ExtBuilder::default().build().execute_with(|| { + let mut data = InherentData::new(); + assert_eq!(ValidatorSet::create_inherent(&data), None); + + data.put_data(INHERENT_IDENTIFIER, &V(0, 4, 0)).unwrap(); + let call = ValidatorSet::create_inherent(&data).expect("version provided"); + assert!(ValidatorSet::is_inherent(&call)); + assert_eq!( + call, + crate::Call::note_author_version { + version: V(0, 4, 0) + } + ); + }); +} + +#[test] +fn a_version_below_the_minimum_yields_no_inherent() { + ExtBuilder::default().build().execute_with(|| { + MinAuthorVersion::::put(V(0, 4, 0)); + let inherent_for = |version: NodeVersion| { + let mut data = InherentData::new(); + data.put_data(INHERENT_IDENTIFIER, &version).unwrap(); + ValidatorSet::create_inherent(&data) + }; + assert_eq!(inherent_for(V(0, 3, 9)), None); + assert!(inherent_for(V(0, 4, 0)).is_some()); + assert!(inherent_for(V(1, 0, 0)).is_some()); + }); +} diff --git a/frame/validator-set/src/tests.rs b/frame/validator-set/src/tests/mod.rs similarity index 98% rename from frame/validator-set/src/tests.rs rename to frame/validator-set/src/tests/mod.rs index b267bb82..9421bc2a 100644 --- a/frame/validator-set/src/tests.rs +++ b/frame/validator-set/src/tests/mod.rs @@ -474,3 +474,7 @@ fn can_be_re_added_after_deregister() { assert!(ApprovedValidators::::get().contains(&42)); }); } + +// ── Minimum author version ─────────────────────────────────────────────────── + +mod author_version; diff --git a/frame/validator-set/src/weights.rs b/frame/validator-set/src/weights.rs index a377d0ba..8f48e3d6 100644 --- a/frame/validator-set/src/weights.rs +++ b/frame/validator-set/src/weights.rs @@ -43,6 +43,8 @@ use core::marker::PhantomData; pub trait WeightInfo { fn add_validator() -> Weight; fn remove_validator() -> Weight; + fn set_min_author_version() -> Weight; + fn note_author_version() -> Weight; } /// Weights for pallet_validator_set using the Substrate node and recommended hardware. @@ -92,6 +94,20 @@ impl WeightInfo for SubstrateWeight { .saturating_add(T::DbWeight::get().reads(6_u64)) .saturating_add(T::DbWeight::get().writes(5_u64)) } + /// Estimated, not yet benchmarked on the reference hardware: one read of the set, + /// one `LastAuthorVersion` read per validator (`MaxValidators` = 32), one write. + fn set_min_author_version() -> Weight { + Weight::from_parts(25_000_000, 1520) + .saturating_add(T::DbWeight::get().reads(34_u64)) + .saturating_add(T::DbWeight::get().writes(2_u64)) + } + /// Estimated, not yet benchmarked on the reference hardware: the flag, the + /// minimum, the author lookup and the approved set (32 accounts) read. + fn note_author_version() -> Weight { + Weight::from_parts(12_000_000, 2700) + .saturating_add(T::DbWeight::get().reads(5_u64)) + .saturating_add(T::DbWeight::get().writes(2_u64)) + } } // For backwards compatibility and tests @@ -140,4 +156,18 @@ impl WeightInfo for () { .saturating_add(RocksDbWeight::get().reads(6_u64)) .saturating_add(RocksDbWeight::get().writes(5_u64)) } + /// Estimated, not yet benchmarked on the reference hardware: one read of the set, + /// one `LastAuthorVersion` read per validator (`MaxValidators` = 32), one write. + fn set_min_author_version() -> Weight { + Weight::from_parts(25_000_000, 1520) + .saturating_add(RocksDbWeight::get().reads(34_u64)) + .saturating_add(RocksDbWeight::get().writes(2_u64)) + } + /// Estimated, not yet benchmarked on the reference hardware: the flag, the + /// minimum, the author lookup and the approved set (32 accounts) read. + fn note_author_version() -> Weight { + Weight::from_parts(12_000_000, 2700) + .saturating_add(RocksDbWeight::get().reads(5_u64)) + .saturating_add(RocksDbWeight::get().writes(2_u64)) + } } diff --git a/template/node/Cargo.toml b/template/node/Cargo.toml index 14aea4fe..28e2aafb 100644 --- a/template/node/Cargo.toml +++ b/template/node/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "orbinum-node" -version = "0.3.0" +version = "0.4.0" license = "Unlicense" build = "build.rs" description = "Orbinum Network Node - Privacy-focused blockchain with ZK proofs and EVM compatibility." @@ -25,6 +25,7 @@ scale-codec = { workspace = true } serde_json = { workspace = true, features = ["arbitrary_precision"] } # Substrate +pallet-validator-set = { workspace = true, features = ["std"] } prometheus-endpoint = { workspace = true } sc-basic-authorship = { workspace = true } sc-chain-spec = { workspace = true } diff --git a/template/node/src/author_version.rs b/template/node/src/author_version.rs new file mode 100644 index 00000000..390a0811 --- /dev/null +++ b/template/node/src/author_version.rs @@ -0,0 +1,54 @@ +//! The node version this binary declares in the blocks it authors. +//! +//! - [`inherent_data_provider`] hands the version to every block the node builds; +//! - [`warn_if_below_minimum`] tells a validator's operator at startup that the +//! chain's minimum author version is above this binary's. +//! +//! The runtime enforces the minimum; this module only declares and reports. A +//! validator below the minimum still starts, imports and votes on finality: it +//! just cannot author until it upgrades. + +use pallet_validator_set::{author_version::InherentDataProvider, MinAuthorVersion, NodeVersion}; +use sc_client_api::{StorageKey, StorageProvider}; +use scale_codec::Decode; +use sp_blockchain::HeaderBackend; +use sp_runtime::traits::Block as BlockT; + +use crate::client::FullBackend; + +/// This binary's version. Parsed at compile time: a crate version that does not +/// fit `major.minor.patch` in u16 fails the build. +pub const AUTHOR_VERSION: NodeVersion = match NodeVersion::parse(env!("CARGO_PKG_VERSION")) { + Some(version) => version, + None => panic!("crate version must be major.minor.patch, each part up to 65535"), +}; + +/// Declares [`AUTHOR_VERSION`] in every block this node builds. +pub fn inherent_data_provider() -> InherentDataProvider { + InherentDataProvider(AUTHOR_VERSION) +} + +/// Logs an error if the chain's minimum author version, as of the best block, +/// is above this binary's. A node still syncing reads an older state, so this +/// can miss a recent minimum; the runtime rejects its blocks regardless. +pub fn warn_if_below_minimum(client: &C) +where + B: BlockT, + C: StorageProvider> + HeaderBackend, +{ + let key = StorageKey(MinAuthorVersion::::hashed_key().to_vec()); + let min = match client.storage(client.info().best_hash, &key) { + Ok(Some(data)) => match NodeVersion::decode(&mut &data.0[..]) { + Ok(min) => min, + Err(e) => return log::warn!("cannot decode the minimum author version: {e}"), + }, + Ok(None) => return, + Err(e) => return log::warn!("cannot read the minimum author version: {e}"), + }; + if AUTHOR_VERSION < min { + log::error!( + "this node is {AUTHOR_VERSION}, the chain requires at least {min} to author blocks: \ + it will import and vote but not author until it is upgraded" + ); + } +} diff --git a/template/node/src/main.rs b/template/node/src/main.rs index 34f8e87b..2adef322 100644 --- a/template/node/src/main.rs +++ b/template/node/src/main.rs @@ -9,6 +9,7 @@ )] #![cfg_attr(feature = "runtime-benchmarks", warn(unused_crate_dependencies))] +mod author_version; #[cfg(feature = "runtime-benchmarks")] mod benchmarking; mod chain_spec; diff --git a/template/node/src/service.rs b/template/node/src/service.rs index 9706999f..7ec559e7 100644 --- a/template/node/src/service.rs +++ b/template/node/src/service.rs @@ -24,6 +24,7 @@ use orbinum_runtime::{opaque::Block, AccountId, Balance, Nonce, RuntimeApi, Tran pub use crate::eth::{db_config_dir, EthConfiguration}; use crate::{ + author_version, cli::Sealing, client::{BaseRuntimeApiCollection, FullBackend, FullClient, RuntimeApiCollection}, eth::{ @@ -245,7 +246,12 @@ where slot_duration, ); let dynamic_fee = fp_dynamic_fee::InherentDataProvider(U256::from(target_gas_price)); - Ok((slot, timestamp, dynamic_fee)) + Ok(( + slot, + timestamp, + dynamic_fee, + author_version::inherent_data_provider(), + )) }; let import_queue = sc_consensus_aura::import_queue::( @@ -331,6 +337,9 @@ where transaction_pool, other: (mut telemetry, block_import, grandpa_link, frontier_backend, storage_override), } = new_partial(&config, ð_config, build_import_queue)?; + if config.role.is_authority() { + author_version::warn_if_below_minimum(&*client); + } let hwbench = hardware_benchmarks .then(|| { @@ -506,7 +515,12 @@ where slot_duration, ); let dynamic_fee = fp_dynamic_fee::InherentDataProvider(U256::from(target_gas_price)); - Ok((slot, timestamp, dynamic_fee)) + Ok(( + slot, + timestamp, + dynamic_fee, + author_version::inherent_data_provider(), + )) }; Box::new(move |subscription_task_executor| { @@ -667,7 +681,12 @@ where slot_duration, ); let dynamic_fee = fp_dynamic_fee::InherentDataProvider(U256::from(target_gas_price)); - Ok((slot, timestamp, dynamic_fee)) + Ok(( + slot, + timestamp, + dynamic_fee, + author_version::inherent_data_provider(), + )) }; let aura = sc_consensus_aura::start_aura::( @@ -808,7 +827,11 @@ where let create_inherent_data_providers = move |_, ()| async move { let timestamp = MockTimestampInherentDataProvider; let dynamic_fee = fp_dynamic_fee::InherentDataProvider(U256::from(target_gas_price)); - Ok((timestamp, dynamic_fee)) + Ok(( + timestamp, + dynamic_fee, + author_version::inherent_data_provider(), + )) }; let manual_seal = match sealing { diff --git a/template/runtime/Cargo.toml b/template/runtime/Cargo.toml index f37efa0e..7979cb6b 100644 --- a/template/runtime/Cargo.toml +++ b/template/runtime/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "orbinum-runtime" -version = "0.3.0" +version = "0.4.0" license = "Apache-2.0 OR GPL-3.0-or-later" description = "Orbinum Privacy Chain Runtime" publish = false diff --git a/template/runtime/RUNTIME_VERSIONS.md b/template/runtime/RUNTIME_VERSIONS.md index fc74c71d..f285c555 100644 --- a/template/runtime/RUNTIME_VERSIONS.md +++ b/template/runtime/RUNTIME_VERSIONS.md @@ -20,6 +20,29 @@ to `spec_version` / `transaction_version` must add a row here in the same PR. The genesis reset (`69d1b837`) set `spec_version` back to 1 and `transaction_version` to 1 for the public testnet launch. +### spec 18 — tx 5 — [Unreleased] + +Governance can require a minimum node version from block authors. +`transaction_version` stays: validator-set only gains calls 6 and 7. Ships with +`orbinum-runtime` / `orbinum-node` 0.4.0 and `pallet-validator-set` 0.4.0; +node 0.4.0 is the first binary that declares its version. + +#### 1 · Minimum author version + +Each author's node declares its crate version in the mandatory +`validatorSet.note_author_version` inherent. While `MinAuthorVersion` is `None` +(the default) nothing is enforced and every binary, including those that +predate the inherent, keeps authoring. Root sets it with +`set_min_author_version`; from then on a block without a declaration, or with +an older one, is invalid. The call is refused unless 2/3 of the approved set +declared at least that version within the last session (an empty set is +refused), so a minimum can never halt Aura. `None` lifts it without the quorum +check. Only approved validators' declarations are kept; leaving the set, or +authoring a block without one, drops them. + +Old binaries run spec 18 unchanged (no new host function). Setting a minimum is +what removes them from authoring; they keep importing and voting in GRANDPA. + ### spec 17 — tx 5 — [Unreleased] A shield must prove its note is worth exactly the deposit. `transaction_version` diff --git a/template/runtime/src/configs/consensus.rs b/template/runtime/src/configs/consensus.rs index 0e2f9509..93b70dbc 100644 --- a/template/runtime/src/configs/consensus.rs +++ b/template/runtime/src/configs/consensus.rs @@ -101,6 +101,8 @@ impl pallet_validator_set::Config for Runtime { type MaxValidators = ConstU32<32>; type Prerequisites = ValidatorPrerequisiteChecker; type OnValidatorRemoved = RelayerCleanup; + type FindAuthor = FindAuthorAccountId; + type QuorumWindow = Period; type WeightInfo = pallet_validator_set::weights::SubstrateWeight; } diff --git a/template/runtime/src/lib.rs b/template/runtime/src/lib.rs index 17b9c053..64c4cb5b 100644 --- a/template/runtime/src/lib.rs +++ b/template/runtime/src/lib.rs @@ -160,7 +160,7 @@ pub const VERSION: RuntimeVersion = RuntimeVersion { spec_name: Cow::Borrowed("orbinum"), impl_name: Cow::Borrowed("orbinum"), authoring_version: 1, - spec_version: 17, + spec_version: 18, impl_version: 1, apis: RUNTIME_API_VERSIONS, transaction_version: 5,