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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 7 additions & 3 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

50 changes: 50 additions & 0 deletions frame/validator-set/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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<NodeVersion>)` (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
Expand Down
8 changes: 7 additions & 1 deletion frame/validator-set/Cargo.toml
Original file line number Diff line number Diff line change
@@ -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"
Expand All @@ -11,14 +11,17 @@ 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
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 }

Expand All @@ -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",
]
Expand Down
109 changes: 109 additions & 0 deletions frame/validator-set/src/author_version/declarations.rs
Original file line number Diff line number Diff line change
@@ -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<T: Config> Pallet<T> {
/// 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<Call<T>> {
if let Some(min) = MinAuthorVersion::<T>::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::<T>::get(),
Error::<T>::AuthorVersionAlreadyNoted
);
if let Some(min) = MinAuthorVersion::<T>::get() {
ensure!(version >= min, Error::<T>::AuthorVersionTooOld);
}
AuthorVersionNoted::<T>::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::<T>::take() {
return;
}
Self::forget_author_version();
if let Some(min) = MinAuthorVersion::<T>::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::<T>::get();
if validators.is_empty() {
return false;
}
let now = frame_system::Pallet::<T>::block_number();
let window = T::QuorumWindow::get();
let ready = validators
.iter()
.filter(|v| {
LastAuthorVersion::<T>::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::<T>::get().contains(&author) {
let now = frame_system::Pallet::<T>::block_number();
LastAuthorVersion::<T>::insert(author, (version, now));
}
}

/// Drops the block's author's last declaration.
fn forget_author_version() {
if let Some(author) = Self::block_author() {
LastAuthorVersion::<T>::remove(author);
}
}

/// The current block's author, from its pre-runtime digests.
fn block_author() -> Option<T::AccountId> {
let digest = frame_system::Pallet::<T>::digest();
T::FindAuthor::find_author(digest.logs.iter().filter_map(|d| d.as_pre_runtime()))
}
}
31 changes: 31 additions & 0 deletions frame/validator-set/src/author_version/inherent.rs
Original file line number Diff line number Diff line change
@@ -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<Result<(), sp_inherents::Error>> {
None
}
}
26 changes: 26 additions & 0 deletions frame/validator-set/src/author_version/mod.rs
Original file line number Diff line number Diff line change
@@ -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;
Loading
Loading