From 2f04e7c62ec05b1319940064dcc467f0be34a4a0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lech=20G=C5=82owiak?= Date: Fri, 10 Jul 2026 17:15:54 +0200 Subject: [PATCH] feat: add pallet-consensus-engine to support AURA to BABE migration MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Lech Głowiak --- Cargo.lock | 32 ++ Cargo.toml | 4 + .../runtime/added/consensus-engine-pallet.md | 11 + .../local-env/configurations/readme.md | 2 +- metadata/static/midnight_metadata.scale | Bin 145216 -> 146504 bytes metadata/static/midnight_metadata_2.1.0.scale | Bin 145216 -> 146504 bytes pallets/consensus-engine/Cargo.toml | 53 +++ pallets/consensus-engine/src/lib.rs | 325 ++++++++++++++++++ pallets/consensus-engine/src/mock.rs | 160 +++++++++ pallets/consensus-engine/src/tests.rs | 260 ++++++++++++++ pallets/consensus-engine/src/weights.rs | 43 +++ primitives/consensus-engine/Cargo.toml | 23 ++ primitives/consensus-engine/src/lib.rs | 37 ++ runtime/Cargo.toml | 5 + runtime/src/lib.rs | 29 +- 15 files changed, 980 insertions(+), 4 deletions(-) create mode 100644 changes/runtime/added/consensus-engine-pallet.md create mode 100644 pallets/consensus-engine/Cargo.toml create mode 100644 pallets/consensus-engine/src/lib.rs create mode 100644 pallets/consensus-engine/src/mock.rs create mode 100644 pallets/consensus-engine/src/tests.rs create mode 100644 pallets/consensus-engine/src/weights.rs create mode 100644 primitives/consensus-engine/Cargo.toml create mode 100644 primitives/consensus-engine/src/lib.rs diff --git a/Cargo.lock b/Cargo.lock index cba00b792..ae3325b4e 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -8185,6 +8185,7 @@ dependencies = [ "midnight-primitives", "midnight-primitives-beefy", "midnight-primitives-cnight-observation", + "midnight-primitives-consensus-engine", "midnight-primitives-federated-authority-observation", "midnight-primitives-session-info", "pallet-aura", @@ -8195,6 +8196,7 @@ dependencies = [ "pallet-c2m-bridge", "pallet-cnight-observation", "pallet-collective", + "pallet-consensus-engine", "pallet-federated-authority", "pallet-federated-authority-observation", "pallet-grandpa", @@ -8538,6 +8540,15 @@ dependencies = [ "thiserror 2.0.18", ] +[[package]] +name = "midnight-primitives-consensus-engine" +version = "0.1.0" +dependencies = [ + "parity-scale-codec", + "scale-info", + "sp-api", +] + [[package]] name = "midnight-primitives-federated-authority-observation" version = "0.1.0" @@ -10395,6 +10406,27 @@ dependencies = [ "sp-runtime", ] +[[package]] +name = "pallet-consensus-engine" +version = "0.1.0" +dependencies = [ + "frame-support", + "frame-system", + "log", + "midnight-primitives-consensus-engine", + "pallet-aura", + "pallet-babe", + "pallet-timestamp", + "parity-scale-codec", + "scale-info", + "sp-consensus-aura", + "sp-consensus-babe", + "sp-consensus-slots", + "sp-core", + "sp-io", + "sp-runtime", +] + [[package]] name = "pallet-federated-authority" version = "0.1.0" diff --git a/Cargo.toml b/Cargo.toml index 873f8d9ab..97892b688 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -7,6 +7,7 @@ members = [ "node", "pallets/midnight", "pallets/c2m-bridge", + "pallets/consensus-engine", "pallets/cnight-observation", "pallets/cnight-observation/mock", "pallets/midnight-system", @@ -20,6 +21,7 @@ members = [ "primitives/midnight", "primitives/ledger", "primitives/session-info", + "primitives/consensus-engine", "primitives/system-parameters", "res", "runtime", @@ -124,6 +126,7 @@ pallet-cnight-observation-mock = { path = "pallets/cnight-observation/mock", def pallet-version = { path = "pallets/version", default-features = false } pallet-system-parameters = { path = "pallets/system-parameters", default-features = false } pallet-throttle = { path = "pallets/throttle", default-features = false } +pallet-consensus-engine = { path = "pallets/consensus-engine", default-features = false } pallet-system-parameters-rpc = { path = "pallets/system-parameters/rpc", default-features = true } pallet-federated-authority = { path = "pallets/federated-authority", default-features = false } pallet-federated-authority-observation = { path = "pallets/federated-authority-observation", default-features = false } @@ -138,6 +141,7 @@ midnight-primitives-cnight-observation = { path = "primitives/cnight-observation midnight-primitives-ics-observation = { path = "primitives/ics-observation", default-features = false } midnight-primitives-reserve-observation = { path = "primitives/reserve-observation", default-features = false } midnight-primitives-session-info = { path = "primitives/session-info", default-features = false } +midnight-primitives-consensus-engine = { path = "primitives/consensus-engine", default-features = false } midnight-primitives-federated-authority-observation = { path = "primitives/federated-authority-observation", default-features = false } midnight-primitives-system-parameters = { path = "primitives/system-parameters", default-features = false } diff --git a/changes/runtime/added/consensus-engine-pallet.md b/changes/runtime/added/consensus-engine-pallet.md new file mode 100644 index 000000000..b13c1c436 --- /dev/null +++ b/changes/runtime/added/consensus-engine-pallet.md @@ -0,0 +1,11 @@ +#runtime + +# Pallet Consensus Engine + +The pallet that will drive consensus engine change. +State machine is implemented in the pallet. +There is number of other tickets to complete the epic, +therefore migration is not finished yet. + +Issue: https://github.com/midnightntwrk/midnight-node/issues/1740 +PR: https://github.com/midnightntwrk/midnight-node/pull/1892 diff --git a/local-environment/src/networks/local-env/configurations/readme.md b/local-environment/src/networks/local-env/configurations/readme.md index fccc0efee..220c3e300 100644 --- a/local-environment/src/networks/local-env/configurations/readme.md +++ b/local-environment/src/networks/local-env/configurations/readme.md @@ -10,7 +10,7 @@ All are setup as validators and are expect to produce blocks. In other words the see [midnight-setup](../../../../../res/local/permissioned-candidates-config.json) related file, this node should fall back to its AURA keys, keys of this node match `//Bob` SURI - `midnight-node-3` does have BABE configured on Cardano (BABE equals AURA), - this node is using seeds files that are used to insert keys to keystore on the node startup, + this node is using seeds files (env variables `_SEED_FILE` are used to set their paths) that are used to insert keys to keystore on the node startup, there is no BABE seed file (so it should fallback to AURA) - `midnight-node-4` does not have BABE configured on Cardano, but it has BABE key in the keystore (which equals AURA key) - `midnight-node-5` is a node that has both keys configured, diff --git a/metadata/static/midnight_metadata.scale b/metadata/static/midnight_metadata.scale index f28006a4066b9054feabe7d17872787bd77ffac7..18f9f63d4c3cf8694b7bf4084920ca93ee3604e1 100644 GIT binary patch delta 9242 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 Gqwqg|^yy{* delta 8035 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 diff --git a/metadata/static/midnight_metadata_2.1.0.scale b/metadata/static/midnight_metadata_2.1.0.scale index f28006a4066b9054feabe7d17872787bd77ffac7..18f9f63d4c3cf8694b7bf4084920ca93ee3604e1 100644 GIT binary patch delta 9242 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 Gqwqg|^yy{* delta 8035 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 diff --git a/pallets/consensus-engine/Cargo.toml b/pallets/consensus-engine/Cargo.toml new file mode 100644 index 000000000..71c74d8e6 --- /dev/null +++ b/pallets/consensus-engine/Cargo.toml @@ -0,0 +1,53 @@ +[package] +name = "pallet-consensus-engine" +version = "0.1.0" +edition = "2024" +description = "Pallet tracking the consensus-engine transition state machine" +license-file.workspace = true +publish = false + +[package.metadata.docs.rs] +targets = ["x86_64-unknown-linux-gnu"] + +[dependencies] +parity-scale-codec = { workspace = true, features = ["derive", "max-encoded-len"] } +scale-info = { workspace = true, features = ["derive"] } +frame-support.workspace = true +frame-system.workspace = true +log.workspace = true +pallet-aura.workspace = true +pallet-babe.workspace = true +sp-consensus-aura.workspace = true +sp-consensus-babe.workspace = true +sp-consensus-slots.workspace = true +midnight-primitives-consensus-engine.workspace = true + +[dev-dependencies] +pallet-timestamp.workspace = true +sp-core.workspace = true +sp-io.workspace = true +sp-runtime.workspace = true + +[features] +default = ["std"] +std = [ + "parity-scale-codec/std", + "frame-support/std", + "frame-system/std", + "pallet-aura/std", + "pallet-babe/std", + "scale-info/std", + "sp-consensus-aura/std", + "sp-consensus-babe/std", + "sp-consensus-slots/std", + "midnight-primitives-consensus-engine/std", +] +try-runtime = [ + "frame-support/try-runtime", + "frame-system/try-runtime", + "pallet-aura/try-runtime", + "pallet-babe/try-runtime", +] + +[package.metadata.cargo-shear] +ignored = ["scale-info"] diff --git a/pallets/consensus-engine/src/lib.rs b/pallets/consensus-engine/src/lib.rs new file mode 100644 index 000000000..2f0596de0 --- /dev/null +++ b/pallets/consensus-engine/src/lib.rs @@ -0,0 +1,325 @@ +// This file is part of midnight-node. +// Copyright (C) Midnight Foundation +// SPDX-License-Identifier: Apache-2.0 +// Licensed under the Apache License, Version 2.0 (the "License"); +// You may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// http://www.apache.org/licenses/LICENSE-2.0 +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +//! Pallet driving the consensus-engine change. It must work together with a compatible node. +//! +//! The chain starts on AURA (`Aura`) and progresses through a sequence +//! of states as it arms, schedules, and performs a flip to BABE block +//! production. The [`ConsensusEngineApi`](midnight_primitives_consensus_engine::ConsensusEngineApi) +//! runtime API surfaces which engine is active for a given state. +//! +//! Once governance has armed BABE, the node is expected to start emitting BABE +//! `PreRuntimeDigest`s that signal secondary slots, using the same authority index as computed +//! by the AURA logic. This should be done once the majority of validators have registered their +//! BABE keys. +//! +//! A further governance action is then required to schedule the update. It should be scheduled +//! only after observing that a finalized block contains a BABE `PreRuntimeDigest`. This +//! information is not available in the runtime, so we rely on a manual action here. +//! +//! Once scheduled, the pallet performs the flip at the last block of the epoch. +//! If the last slot of epoch is empty, then migration is postponed to the last block of the epoch. +//! The 'migration' is supposed to initialize pallet-babe state and transits to the final state `Babe`. +//! The first block of the next epoch is authored with BABE. + +#![cfg_attr(not(feature = "std"), no_std)] + +pub use pallet::*; +pub use weights::WeightInfo; + +mod weights; + +#[cfg(test)] +mod mock; + +#[cfg(test)] +mod tests; + +#[frame_support::pallet] +pub mod pallet { + use crate::WeightInfo; + use frame_support::ConsensusEngineId; + use frame_support::pallet_prelude::*; + use frame_support::traits::FindAuthor; + use frame_support::traits::OnTimestampSet; + use frame_system::pallet_prelude::*; + use midnight_primitives_consensus_engine::ActiveEngine; + use sp_consensus_aura::digests::CompatibleDigestItem as AuraCompatibleDigestItem; + use sp_consensus_babe::digests::CompatibleDigestItem as _; + use sp_consensus_slots::Slot; + + const STORAGE_VERSION: StorageVersion = StorageVersion::new(0); + + /// Bootstrap randomness for BABE's genesis epoch at the consensus flip. Mirrors + /// pallet-babe's own genesis default (zero). + const BABE_GENESIS_RANDOMNESS: sp_consensus_babe::Randomness = [0u8; 32]; + + #[pallet::pallet] + #[pallet::storage_version(STORAGE_VERSION)] + pub struct Pallet(_); + + #[pallet::config] + pub trait Config: frame_system::Config + pallet_aura::Config + pallet_babe::Config { + /// Origin permitted to drive state transitions. + type GovernanceOrigin: EnsureOrigin; + + /// Midnight (sidechain) epochs should be aligned with BABE epochs, + /// so they require the same lenght. The flip is performed at an epoch boundary so they stay aligned. + #[pallet::constant] + type EpochDuration: Get; + + /// Weight information for this pallet's extrinsics. + type WeightInfo: WeightInfo; + } + + /// The consensus-engine transition state machine. + #[derive( + Debug, + Default, + Clone, + Copy, + PartialEq, + Eq, + Encode, + Decode, + DecodeWithMemTracking, + MaxEncodedLen, + TypeInfo, + )] + pub enum State { + /// AURA block production, the baseline state before any transition is armed. + #[default] + Aura, + /// A flip to BABE has been armed but not yet scheduled. Node is supposed to add PreRuntimeDigest of BABE Secondary Plain slots in this state. + ArmedBabe, + /// The flip to BABE is armed to take effect at the last block of an epoch. + /// Blocks are still produced with AURA until the flip actually commits. + ScheduledFlip, + /// The post flip state, migration happened, consensus is BABE. + Babe, + } + + impl State { + /// The consensus engine that is active while in this state. + pub fn active_engine(&self) -> ActiveEngine { + match self { + State::Aura | State::ArmedBabe | State::ScheduledFlip => ActiveEngine::Aura, + State::Babe => ActiveEngine::Babe, + } + } + } + + /// The current consensus-engine transition state. + #[pallet::storage] + pub type EngineState = StorageValue<_, State, ValueQuery>; + + #[pallet::hooks] + impl Hooks> for Pallet { + /// Drives the automatic, non-governance part of the state machine each block. + /// + /// The current slot is read from the AURA pre-runtime digest (the validated, + /// authoritative slot while AURA is producing). + fn on_initialize(_n: BlockNumberFor) -> Weight { + match EngineState::::get() { + // Before arming, the node must not emit BABE pre-digests. A block + // carrying one would let `pallet-babe` self-initialize its genesis + // epoch prematurely (see `arm_babe_storage`), so reject it. This + // is deterministic — every node reads the same header — so a + // misbehaving author's block is rejected on import, not just locally. + State::Aura => { + assert!( + !Self::has_aura_pre_digest_before_babe_pre_digest(), + "Unique BABE pre-runtime digest present after AURA in state 'Aura'", + ); + }, + State::ScheduledFlip => { + if let Some(slot) = Self::current_slot_from_aura_digest() + && Self::is_last_slot_of_epoch(slot) + { + Self::migrate_to_babe(slot); + EngineState::::put(State::Babe); + } + }, + _ => {}, + } + ::WeightInfo::on_initialize() + } + } + + #[pallet::call] + impl Pallet { + /// Arm the flip to BABE: move `Aura` to `ArmedBabe`. + /// + /// Governance-gated. A no-op unless the engine is currently `Aura`. + #[pallet::call_index(0)] + #[pallet::weight(::WeightInfo::arm_babe())] + pub fn arm_babe(origin: OriginFor) -> DispatchResult { + T::GovernanceOrigin::ensure_origin(origin)?; + if EngineState::::get() == State::Aura { + // Pre-seed BABE before the node starts emitting BABE pre-digests, so + // pallet-babe does not prematurely self-initialize its genesis epoch. + Self::set_sentinel_babe_genesis_slot(); + EngineState::::put(State::ArmedBabe); + } + Ok(()) + } + + /// Schedule the flip to BABE: move `ArmedBabe` to `ScheduledFlip`. + /// + /// Governance-gated. A no-op unless the engine is currently `ArmedBabe`. + /// The flip itself commits automatically at the next epoch boundary; see + /// [`Hooks::on_initialize`]. + #[pallet::call_index(1)] + #[pallet::weight(::WeightInfo::schedule_flip())] + pub fn schedule_flip(origin: OriginFor) -> DispatchResult { + T::GovernanceOrigin::ensure_origin(origin)?; + if EngineState::::get() == State::ArmedBabe { + EngineState::::put(State::ScheduledFlip); + } + Ok(()) + } + } + + impl Pallet { + /// The consensus engine currently active, derived from [`EngineState`]. + pub fn active_engine() -> ActiveEngine { + EngineState::::get().active_engine() + } + + /// Sets `GenesisSlot` to a non-zero sentinel so pallet-babe's `initialize` + /// does not self-initialize a genesis epoch and deposit a bogus `NextEpochData` + /// digest into a header we cannot retract. + fn set_sentinel_babe_genesis_slot() { + pallet_babe::GenesisSlot::::put(Slot::from(u64::MAX)); + log::info!( + target: "consensus-engine", + "BABE armed: pre-seeded pallet-babe GenesisSlot to suppress premature genesis init.", + ); + } + + /// Bootstrap `pallet-babe` for its genesis epoch at the flip and log the transition. + /// + /// `slot` is the last slot of the ending epoch (the current block's slot). + /// BABE's genesis is the first slot of the next epoch, so its epoch + /// boundaries stay aligned with the sidechain epochs. + fn migrate_to_babe(slot: Slot) { + let babe_genesis_slot = Self::next_epoch_start(slot); + + // BABE and sidechain epochs boundaries should align + pallet_babe::GenesisSlot::::put(babe_genesis_slot); + pallet_babe::CurrentSlot::::put(babe_genesis_slot); + pallet_babe::EpochIndex::::put(0); + + pallet_babe::Randomness::::put(BABE_GENESIS_RANDOMNESS); + pallet_babe::NextRandomness::::put(BABE_GENESIS_RANDOMNESS); + + log::info!( + target: "consensus-engine", + "Consensus engine flip at the last slot ({:?}) of the epoch; \ + BABE genesis slot {:?}, entering Babe state.", + slot, + babe_genesis_slot, + ); + + // This will prevent each last-of-epoch block to be committeed, + // but doesn't stop the chain completly. + panic!("Issue #1742 adds BABE keys to the runtime"); + } + + fn current_slot_from_aura_digest() -> Option { + frame_system::Pallet::::digest() + .logs + .iter() + .find_map(AuraCompatibleDigestItem::<()>::as_aura_pre_digest) + } + + /// Returns `true` when the current block's digest carries exactly one BABE + /// pre-runtime digest, that digest appears *after* an AURA pre-runtime digest, + /// and its slot matches that AURA digest's slot. + /// + /// This is the shape a node emits while still on AURA once it has begun + /// signalling BABE secondary slots at the same slot — the situation the + /// `State::Aura` guard rejects. Any other arrangement returns `false`: no BABE + /// digest, a BABE digest before any AURA digest, a slot mismatch, or more than + /// one BABE digest. + pub(crate) fn has_aura_pre_digest_before_babe_pre_digest() -> bool { + let mut aura_slot = None; + let mut babe_present = false; + for log in frame_system::Pallet::::digest().logs.iter() { + if let Some(slot) = AuraCompatibleDigestItem::<()>::as_aura_pre_digest(log) { + aura_slot = Some(slot); + continue; + }; + + if let Some(babe) = log.as_babe_pre_digest() { + if let Some(slot) = aura_slot { + if babe.slot() != slot { + // BABE slot different to AURA slot + return false; + } + if babe_present { + // BABE pre-digest is not unique + return false; + } + babe_present = true + } else { + // BABE pre-digest before AURA + return false; + } + } + } + babe_present + } + + fn is_last_slot_of_epoch(slot: Slot) -> bool { + let duration = ::EpochDuration::get().max(1); + (u64::from(slot) + 1) % duration == 0 + } + + /// The first slot of the epoch after the one containing `slot`. + fn next_epoch_start(slot: Slot) -> Slot { + let duration = ::EpochDuration::get().max(1); + let slot = u64::from(slot); + Slot::from((slot / duration + 1) * duration) + } + } + + impl FindAuthor for Pallet { + fn find_author<'a, I>(digests: I) -> Option + where + I: 'a + IntoIterator, + { + match Self::active_engine() { + ActiveEngine::Aura => { + as FindAuthor>::find_author(digests) + }, + ActiveEngine::Babe => { + as FindAuthor>::find_author(digests) + }, + } + } + } + + impl OnTimestampSet for Pallet { + fn on_timestamp_set(moment: T::Moment) { + match Self::active_engine() { + ActiveEngine::Aura => { + as OnTimestampSet>::on_timestamp_set(moment) + }, + ActiveEngine::Babe => { + as OnTimestampSet>::on_timestamp_set(moment) + }, + } + } + } +} diff --git a/pallets/consensus-engine/src/mock.rs b/pallets/consensus-engine/src/mock.rs new file mode 100644 index 000000000..c3f557ba7 --- /dev/null +++ b/pallets/consensus-engine/src/mock.rs @@ -0,0 +1,160 @@ +// This file is part of midnight-node. +// Copyright (C) Midnight Foundation +// SPDX-License-Identifier: Apache-2.0 +// Licensed under the Apache License, Version 2.0 (the "License"); +// You may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// http://www.apache.org/licenses/LICENSE-2.0 +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +//! Test mock for the consensus-engine pallet. + +use frame_support::{ + derive_impl, + traits::{ConstBool, ConstU32, ConstU64}, +}; +use frame_system::EnsureRoot; +use parity_scale_codec::Encode; +use sp_consensus_aura::AURA_ENGINE_ID; +use sp_consensus_babe::BABE_ENGINE_ID; +use sp_consensus_babe::digests::{PreDigest as BabePreDigest, SecondaryPlainPreDigest}; +use sp_consensus_slots::Slot; +use sp_core::H256; +use sp_runtime::{ + BuildStorage, Digest, DigestItem, + traits::{BlakeTwo256, IdentityLookup}, +}; + +use crate as pallet_consensus_engine; + +/// Epoch length shared by pallet-babe and the consensus-engine pallet in the mock. +const EPOCH_DURATION: u64 = 300; +/// Slot duration in milliseconds used by the AURA/BABE/timestamp mock configs. +const SLOT_DURATION: u64 = 6000; + +type Block = frame_system::mocking::MockBlock; + +frame_support::construct_runtime!( + pub struct Test { + System: frame_system = 0, + ConsensusEngine: pallet_consensus_engine = 1, + Timestamp: pallet_timestamp = 2, + Aura: pallet_aura = 3, + Babe: pallet_babe = 4, + } +); + +#[derive_impl(frame_system::config_preludes::TestDefaultConfig)] +impl frame_system::Config for Test { + type BaseCallFilter = frame_support::traits::Everything; + type BlockWeights = (); + type BlockLength = (); + type RuntimeOrigin = RuntimeOrigin; + type RuntimeCall = RuntimeCall; + type RuntimeTask = RuntimeTask; + type Nonce = u64; + type Hash = H256; + type Hashing = BlakeTwo256; + type AccountId = u64; + type Lookup = IdentityLookup; + type Block = Block; + type RuntimeEvent = RuntimeEvent; + type BlockHashCount = (); + type DbWeight = (); + type Version = (); + type PalletInfo = PalletInfo; + type AccountData = (); + type OnNewAccount = (); + type OnKilledAccount = (); + type SystemWeightInfo = (); + type SS58Prefix = (); + type OnSetCode = (); + type MaxConsumers = ConstU32<16>; +} + +#[derive_impl(pallet_timestamp::config_preludes::TestDefaultConfig)] +impl pallet_timestamp::Config for Test { + type Moment = u64; + type OnTimestampSet = (); + type MinimumPeriod = ConstU64<{ SLOT_DURATION / 2 }>; + type WeightInfo = (); +} + +impl pallet_aura::Config for Test { + type AuthorityId = sp_consensus_aura::sr25519::AuthorityId; + type DisabledValidators = (); + type MaxAuthorities = ConstU32<32>; + type AllowMultipleBlocksPerSlot = ConstBool; + type SlotDuration = ConstU64; +} + +impl pallet_babe::Config for Test { + type EpochDuration = ConstU64; + type ExpectedBlockTime = ConstU64; + type EpochChangeTrigger = pallet_babe::ExternalTrigger; + type DisabledValidators = (); + type WeightInfo = (); + type MaxAuthorities = ConstU32<32>; + type MaxNominators = ConstU32<5>; + type KeyOwnerProof = sp_core::Void; + type EquivocationReportSystem = (); +} + +impl pallet_consensus_engine::Config for Test { + // Only root drives state transitions in the mock, mirroring the runtime's governance origin. + type GovernanceOrigin = EnsureRoot; + type EpochDuration = ConstU64; + type WeightInfo = (); +} + +/// Start a new block whose header carries exactly the given digest `logs`. +pub fn start_block_with_logs(logs: Vec) { + let number = System::block_number() + 1; + System::initialize(&number, &Default::default(), &Digest { logs }); +} + +/// An AURA pre-runtime digest item for `slot`. +pub fn aura_pre_digest(slot: u64) -> DigestItem { + DigestItem::PreRuntime(AURA_ENGINE_ID, Slot::from(slot).encode()) +} + +/// A BABE (secondary plain) pre-runtime digest item for `slot`. +pub fn babe_pre_digest(slot: u64) -> DigestItem { + DigestItem::PreRuntime( + BABE_ENGINE_ID, + BabePreDigest::SecondaryPlain(SecondaryPlainPreDigest { + authority_index: 0, + slot: Slot::from(slot), + }) + .encode(), + ) +} + +/// A pre-runtime digest item for an unrelated engine, which the pallet must ignore. +pub fn unrelated_pre_digest() -> DigestItem { + DigestItem::PreRuntime(*b"test", Vec::new()) +} + +/// Start a new block whose header carries an AURA pre-runtime digest for `slot`, +/// mirroring how the pallet reads the current slot in `on_initialize`. +pub fn start_block_at_slot(slot: u64) { + start_block_with_logs(vec![aura_pre_digest(slot)]); +} + +/// Start a new block whose header carries a BABE pre-runtime digest (alongside +/// the AURA one, at the same slot), simulating a node that emits BABE digests too early. +pub fn start_block_with_babe_pre_digest(slot: u64) { + start_block_with_logs(vec![aura_pre_digest(slot), babe_pre_digest(slot)]); +} + +pub fn new_test_ext() -> sp_io::TestExternalities { + let t = frame_system::GenesisConfig::::default().build_storage().unwrap(); + let mut ext: sp_io::TestExternalities = t.into(); + // Block 0 does not record events; move to block 1 so `assert_last_event` works. + ext.execute_with(|| System::set_block_number(1)); + ext +} diff --git a/pallets/consensus-engine/src/tests.rs b/pallets/consensus-engine/src/tests.rs new file mode 100644 index 000000000..c1dac696f --- /dev/null +++ b/pallets/consensus-engine/src/tests.rs @@ -0,0 +1,260 @@ +// This file is part of midnight-node. +// Copyright (C) Midnight Foundation +// SPDX-License-Identifier: Apache-2.0 +// Licensed under the Apache License, Version 2.0 (the "License"); +// You may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// http://www.apache.org/licenses/LICENSE-2.0 +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +//! Tests for the consensus-engine pallet. + +use crate::{State, mock::*, pallet::EngineState}; +use frame_support::{assert_noop, assert_ok, traits::Hooks}; +use midnight_primitives_consensus_engine::ActiveEngine; +use sp_consensus_slots::Slot; +use sp_runtime::DispatchError; + +/// Run the pallet's `on_initialize` hook for the current block. +fn on_initialize() { + ConsensusEngine::on_initialize(System::block_number()); +} + +#[test] +fn default_state_is_baseline_aura() { + new_test_ext().execute_with(|| { + assert_eq!(EngineState::::get(), State::Aura); + assert_eq!(ConsensusEngine::active_engine(), ActiveEngine::Aura); + }); +} + +#[test] +fn arm_babe_from_baseline() { + new_test_ext().execute_with(|| { + // Before arming, BABE's GenesisSlot is unset (its ValueQuery default of 0). + assert_eq!(pallet_babe::GenesisSlot::::get(), Slot::from(0)); + assert_ok!(ConsensusEngine::arm_babe(RuntimeOrigin::root())); + assert_eq!(EngineState::::get(), State::ArmedBabe); + // Arming pre-seeds pallet-babe's GenesisSlot to a sentinel so it does not + // self-initialize its genesis epoch prematurely. + assert_eq!(pallet_babe::GenesisSlot::::get(), Slot::from(u64::MAX)); + // `ArmedBabe` still authors with AURA. + assert_eq!(ConsensusEngine::active_engine(), ActiveEngine::Aura); + }); +} + +#[test] +#[should_panic(expected = "Unique BABE pre-runtime digest present after AURA in state 'Aura'")] +fn baseline_rejects_blocks_with_babe_pre_digest() { + new_test_ext().execute_with(|| { + // Default state is `Aura`; a block carrying a BABE pre-digest is rejected. + start_block_with_babe_pre_digest(100); + on_initialize(); + }); +} + +/// Initialize a block carrying `logs` and evaluate the AURA-then-BABE guard directly. +fn aura_before_babe(logs: Vec) -> bool { + new_test_ext().execute_with(|| { + start_block_with_logs(logs); + ConsensusEngine::has_aura_pre_digest_before_babe_pre_digest() + }) +} + +#[test] +fn aura_then_matching_babe_is_detected() { + // The rejected shape: AURA followed by a single BABE digest at the same slot. + assert!(aura_before_babe(vec![aura_pre_digest(100), babe_pre_digest(100)])); +} + +#[test] +fn aura_then_matching_babe_with_unrelated_digest_is_detected() { + // A pre-runtime digest for another engine between the two is ignored. + assert!(aura_before_babe(vec![ + aura_pre_digest(100), + unrelated_pre_digest(), + babe_pre_digest(100), + ])); +} + +#[test] +fn empty_digest_is_not_detected() { + assert!(!aura_before_babe(vec![])); +} + +#[test] +fn aura_only_is_not_detected() { + assert!(!aura_before_babe(vec![aura_pre_digest(100)])); +} + +#[test] +fn babe_only_is_not_detected() { + // A BABE digest with no preceding AURA digest. + assert!(!aura_before_babe(vec![babe_pre_digest(100)])); +} + +#[test] +fn babe_before_aura_is_not_detected() { + assert!(!aura_before_babe(vec![babe_pre_digest(100), aura_pre_digest(100)])); +} + +#[test] +fn babe_with_mismatched_slot_is_not_detected() { + assert!(!aura_before_babe(vec![aura_pre_digest(100), babe_pre_digest(101)])); +} + +#[test] +fn duplicate_babe_digest_is_not_detected() { + // Two BABE digests (even matching the AURA slot) are not the unique-digest shape. + assert!(!aura_before_babe(vec![ + aura_pre_digest(100), + babe_pre_digest(100), + babe_pre_digest(100), + ])); +} + +#[test] +fn babe_pre_digest_is_allowed_once_armed() { + new_test_ext().execute_with(|| { + EngineState::::put(State::ArmedBabe); + // Once armed the node is expected to emit BABE pre-digests; no rejection. + start_block_with_babe_pre_digest(100); + on_initialize(); + assert_eq!(EngineState::::get(), State::ArmedBabe); + }); +} + +#[test] +fn arm_babe_requires_governance_origin() { + new_test_ext().execute_with(|| { + assert_noop!(ConsensusEngine::arm_babe(RuntimeOrigin::signed(1)), DispatchError::BadOrigin); + assert_noop!(ConsensusEngine::arm_babe(RuntimeOrigin::none()), DispatchError::BadOrigin); + assert_eq!(EngineState::::get(), State::Aura); + }); +} + +#[test] +fn arm_babe_is_rejected_from_other_states() { + new_test_ext().execute_with(|| { + for state in [State::ArmedBabe, State::ScheduledFlip, State::Babe] { + EngineState::::put(state); + assert_ok!(ConsensusEngine::arm_babe(RuntimeOrigin::root())); + assert_eq!(EngineState::::get(), state); + // The arm hook only fires on the real Aura -> ArmedBabe transition, so BABE + // is never pre-seeded from these states. + assert_eq!(pallet_babe::GenesisSlot::::get(), Slot::from(0)); + } + }); +} + +#[test] +fn schedule_flip_from_armed() { + new_test_ext().execute_with(|| { + EngineState::::put(State::ArmedBabe); + + assert_ok!(ConsensusEngine::schedule_flip(RuntimeOrigin::root())); + + assert_eq!(EngineState::::get(), State::ScheduledFlip); + // A `ScheduledFlip` still authors with AURA until the flip commits. + assert_eq!(ConsensusEngine::active_engine(), ActiveEngine::Aura); + }); +} + +#[test] +fn schedule_flip_requires_governance_origin() { + new_test_ext().execute_with(|| { + EngineState::::put(State::ArmedBabe); + assert_noop!( + ConsensusEngine::schedule_flip(RuntimeOrigin::signed(1)), + DispatchError::BadOrigin + ); + assert_noop!( + ConsensusEngine::schedule_flip(RuntimeOrigin::none()), + DispatchError::BadOrigin + ); + assert_eq!(EngineState::::get(), State::ArmedBabe); + }); +} + +#[test] +fn schedule_flip_is_no_op_unless_armed() { + new_test_ext().execute_with(|| { + for state in [State::Aura, State::ScheduledFlip, State::Babe] { + EngineState::::put(state); + assert_ok!(ConsensusEngine::schedule_flip(RuntimeOrigin::root())); + assert_eq!(EngineState::::get(), state); + } + }); +} + +#[test] +#[should_panic(expected = "Issue #1742 adds BABE keys to the runtime")] +fn flip_fires_at_the_last_slot_of_the_epoch() { + new_test_ext().execute_with(|| { + EngineState::::put(State::ScheduledFlip); + + // The last slot of the epoch (1499 for a 300-slot epoch) attempts the flip. + // Completing it panics until real BABE authorities are wired in (Issue #1742). + start_block_at_slot(1499); + on_initialize(); + }); +} + +#[test] +fn flip_does_not_run_mid_epoch() { + new_test_ext().execute_with(|| { + EngineState::::put(State::ScheduledFlip); + // A mid-epoch block does not trigger the flip (no panic, state unchanged). + start_block_at_slot(1400); + on_initialize(); + + assert_eq!(EngineState::::get(), State::ScheduledFlip); + }); +} + +#[test] +fn flip_does_not_run_on_penultimate_or_first_slot_of_next_epoch() { + new_test_ext().execute_with(|| { + EngineState::::put(State::ScheduledFlip); + // Don't flip at the penultimate slot. + start_block_at_slot(1498); + on_initialize(); + assert_eq!(EngineState::::get(), State::ScheduledFlip); + + // The last slot of the epoch (1499) produced no block; the first block of + // the next epoch lands at 1500. The flip must NOT execute — we only flip on + // a block seen exactly at an epoch's last slot. + start_block_at_slot(1500); + on_initialize(); + assert_eq!(EngineState::::get(), State::ScheduledFlip); + }); +} + +#[test] +#[should_panic(expected = "Issue #1742 adds BABE keys to the runtime")] +fn flip_fires_at_next_epoch_last_slot_when_the_last_slot_is_skipped() { + new_test_ext().execute_with(|| { + EngineState::::put(State::ScheduledFlip); + // The epoch's last slot (1499) was skipped; the flip waits and fires at the + // next epoch's last slot (1799), where completing it panics (Issue #1742). + start_block_at_slot(1799); + on_initialize(); + }); +} + +#[test] +fn on_initialize_is_a_no_op_in_stable_states() { + new_test_ext().execute_with(|| { + for state in [State::Aura, State::ArmedBabe, State::Babe] { + EngineState::::put(state); + // Even at an epoch's last slot, non-scheduled states never flip. + start_block_at_slot(1499); + on_initialize(); + assert_eq!(EngineState::::get(), state); + } + }); +} diff --git a/pallets/consensus-engine/src/weights.rs b/pallets/consensus-engine/src/weights.rs new file mode 100644 index 000000000..c329f73a2 --- /dev/null +++ b/pallets/consensus-engine/src/weights.rs @@ -0,0 +1,43 @@ +// This file is part of midnight-node. +// Copyright (C) Midnight Foundation +// SPDX-License-Identifier: Apache-2.0 +// Licensed under the Apache License, Version 2.0 (the "License"); +// You may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// http://www.apache.org/licenses/LICENSE-2.0 +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +//! Weight definitions for `pallet-consensus-engine`. +//! +//! No benchmarks exist yet; the runtime wires the unit implementation (`()`), which reports +//! zero weight. Replace with generated weights once the extrinsics are benchmarked. + +use frame_support::weights::Weight; + +/// Weight functions needed for `pallet-consensus-engine`. +pub trait WeightInfo { + /// Weight of the `arm_babe` extrinsic. + fn arm_babe() -> Weight; + /// Weight of the `schedule_flip` extrinsic. + fn schedule_flip() -> Weight; + /// Weight of the per-block `on_initialize` hook driving the automatic flip transitions. + fn on_initialize() -> Weight; +} + +impl WeightInfo for () { + fn arm_babe() -> Weight { + Weight::zero() + } + + fn schedule_flip() -> Weight { + Weight::zero() + } + + fn on_initialize() -> Weight { + Weight::zero() + } +} diff --git a/primitives/consensus-engine/Cargo.toml b/primitives/consensus-engine/Cargo.toml new file mode 100644 index 000000000..72d041421 --- /dev/null +++ b/primitives/consensus-engine/Cargo.toml @@ -0,0 +1,23 @@ +[package] +name = "midnight-primitives-consensus-engine" +version = "0.1.0" +edition = "2024" +description = "Midnight Consensus Engine Primitives" +license-file.workspace = true +publish = false + +[package.metadata.docs.rs] +targets = ["x86_64-unknown-linux-gnu"] + +[dependencies] +parity-scale-codec = { workspace = true, features = ["derive", "max-encoded-len"] } +scale-info = { workspace = true, features = ["derive"] } +sp-api.workspace = true + +[features] +default = ["std"] +std = [ + "parity-scale-codec/std", + "scale-info/std", + "sp-api/std", +] diff --git a/primitives/consensus-engine/src/lib.rs b/primitives/consensus-engine/src/lib.rs new file mode 100644 index 000000000..2f35547b3 --- /dev/null +++ b/primitives/consensus-engine/src/lib.rs @@ -0,0 +1,37 @@ +// This file is part of midnight-node. +// Copyright (C) Midnight Foundation +// SPDX-License-Identifier: Apache-2.0 +// Licensed under the Apache License, Version 2.0 (the "License"); +// You may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// http://www.apache.org/licenses/LICENSE-2.0 +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +//! Shared consensus-engine primitives and the runtime API exposing which +//! block-production engine is currently active. + +#![cfg_attr(not(feature = "std"), no_std)] + +use parity_scale_codec::{Decode, Encode, MaxEncodedLen}; +use scale_info::TypeInfo; + +/// The block-production consensus engine currently active on the chain. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Encode, Decode, MaxEncodedLen, TypeInfo)] +pub enum ActiveEngine { + /// AURA block production is active. + Aura, + /// BABE block production is active. + Babe, +} + +sp_api::decl_runtime_apis! { + /// Runtime API reporting the consensus engine currently producing blocks. + pub trait ConsensusEngineApi { + /// Returns the consensus engine that is currently active. + fn active_engine() -> ActiveEngine; + } +} diff --git a/runtime/Cargo.toml b/runtime/Cargo.toml index 63bd34dcb..f4f5c5169 100644 --- a/runtime/Cargo.toml +++ b/runtime/Cargo.toml @@ -82,10 +82,12 @@ pallet-midnight.workspace = true pallet-midnight-system.workspace = true pallet-system-parameters.workspace = true pallet-throttle.workspace = true +pallet-consensus-engine.workspace = true pallet-version.workspace = true midnight-primitives-beefy.workspace = true midnight-primitives-federated-authority-observation.workspace = true midnight-primitives-session-info.workspace = true +midnight-primitives-consensus-engine.workspace = true pallet-c2m-bridge.workspace = true pallet-cnight-observation.workspace = true midnight-primitives-cnight-observation.workspace = true @@ -125,6 +127,7 @@ std = [ "pallet-midnight/std", "pallet-system-parameters/std", "pallet-throttle/std", + "pallet-consensus-engine/std", "pallet-mmr/std", "pallet-partner-chains-bridge/std", "pallet-version/std", @@ -172,6 +175,7 @@ std = [ "midnight-primitives-beefy/std", "midnight-primitives-cnight-observation/std", "midnight-primitives-session-info/std", + "midnight-primitives-consensus-engine/std", "runtime-common/std" ] runtime-benchmarks = [ @@ -223,6 +227,7 @@ try-runtime = [ "pallet-membership/try-runtime", "pallet-collective/try-runtime", "pallet-throttle/try-runtime", + "pallet-consensus-engine/try-runtime", "pallet-cnight-observation/try-runtime", ] experimental = [] diff --git a/runtime/src/lib.rs b/runtime/src/lib.rs index 128e5ce01..41b40d84e 100644 --- a/runtime/src/lib.rs +++ b/runtime/src/lib.rs @@ -52,6 +52,7 @@ use midnight_node_ledger::types::{GasCost, Tx, active_version::LedgerApiError}; use midnight_primitives::BridgeRecipient; use midnight_primitives_beefy::BeefyStakes; use midnight_primitives_cnight_observation::CardanoPosition; +use midnight_primitives_consensus_engine::ActiveEngine; use opaque::{CrossChainKey, SessionKeys}; pub use pallet_cnight_observation::Call as CNightObservationCall; use pallet_grandpa::AuthorityId as GrandpaId; @@ -396,7 +397,7 @@ impl pallet_aura::Config for Runtime { } impl pallet_authorship::Config for Runtime { - type FindAuthor = pallet_session::FindAccountFromAuthorIndex; + type FindAuthor = pallet_session::FindAccountFromAuthorIndex; type EventHandler = (); } @@ -531,7 +532,7 @@ impl pallet_beefy_mmr::Config for Runtime { impl pallet_timestamp::Config for Runtime { /// A timestamp: milliseconds since the unix epoch. type Moment = u64; - type OnTimestampSet = Aura; + type OnTimestampSet = ConsensusEngine; type MinimumPeriod = ConstU64<{ SLOT_DURATION / 2 }>; type WeightInfo = weights::pallet_timestamp::WeightInfo; } @@ -660,7 +661,10 @@ impl sp_sidechain::OnNewEpoch for LogBeneficiaries { impl pallet_sidechain::Config for Runtime { fn current_slot_number() -> ScSlotNumber { - ScSlotNumber(*pallet_aura::CurrentSlot::::get()) + match ConsensusEngine::active_engine() { + ActiveEngine::Aura => ScSlotNumber(*pallet_aura::CurrentSlot::::get()), + ActiveEngine::Babe => ScSlotNumber(*pallet_babe::CurrentSlot::::get()), + } } type OnNewEpoch = LogBeneficiaries; } @@ -940,6 +944,15 @@ impl pallet_throttle::Config for Runtime { type WindowSize = WindowSize; } +impl pallet_consensus_engine::Config for Runtime { + // Some state transitions are governance-driven: federated-authority motions dispatch approved + // calls as root. + type GovernanceOrigin = EnsureRoot; + type EpochDuration = SidechainEpochDuration; + // Unit weights for now. Issue #1863. + type WeightInfo = (); +} + parameter_types! { pub const BridgeMaxTransfersPerBlock: u32 = 256; } @@ -1132,6 +1145,10 @@ mod runtime { // Throttling #[runtime::pallet_index(51)] pub type Throttle = pallet_throttle::Pallet; + + // Consensus engine transition state machine + #[runtime::pallet_index(52)] + pub type ConsensusEngine = pallet_consensus_engine::Pallet; } /// The address format for describing accounts. @@ -1758,6 +1775,12 @@ impl_runtime_apis! { } } + impl midnight_primitives_consensus_engine::ConsensusEngineApi for Runtime { + fn active_engine() -> midnight_primitives_consensus_engine::ActiveEngine { + ConsensusEngine::active_engine() + } + } + impl sp_sidechain::GetGenesisUtxo for Runtime { fn genesis_utxo() -> UtxoId { Sidechain::genesis_utxo()