diff --git a/Cargo.lock b/Cargo.lock index e0a2d09c7..c856dcb3c 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 7d0b5055d..6bf2fbca5 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 f28006a40..18f9f63d4 100644 Binary files a/metadata/static/midnight_metadata.scale and b/metadata/static/midnight_metadata.scale differ diff --git a/metadata/static/midnight_metadata_2.1.0.scale b/metadata/static/midnight_metadata_2.1.0.scale index f28006a40..18f9f63d4 100644 Binary files a/metadata/static/midnight_metadata_2.1.0.scale and b/metadata/static/midnight_metadata_2.1.0.scale differ 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 6a91d8f07..eea091caa 100644 --- a/runtime/src/lib.rs +++ b/runtime/src/lib.rs @@ -395,7 +395,9 @@ impl pallet_aura::Config for Runtime { } impl pallet_authorship::Config for Runtime { - type FindAuthor = pallet_session::FindAccountFromAuthorIndex; + // `ConsensusEngine` implements `FindAuthor`, routing author lookup to AURA before + // the consensus flip and BABE after it. + type FindAuthor = pallet_session::FindAccountFromAuthorIndex; type EventHandler = (); } @@ -530,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; } @@ -939,6 +941,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; } @@ -1131,6 +1142,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. @@ -1757,6 +1772,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()