diff --git a/Cargo.lock b/Cargo.lock index 74e40d72..b5f26e48 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -10016,6 +10016,7 @@ dependencies = [ "ismp", "ismp-grandpa", "libsecp256k1", + "log", "pallet-aura", "pallet-authorship", "pallet-balances", diff --git a/frame/validator-set/CHANGELOG.md b/frame/validator-set/CHANGELOG.md index 0550ed1d..ebb7a703 100644 --- a/frame/validator-set/CHANGELOG.md +++ b/frame/validator-set/CHANGELOG.md @@ -7,6 +7,15 @@ Format follows [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). ## [Unreleased] +### Fixed + +- `Config::FindAuthor` is documented as returning the author's validator account + (an `ApprovedValidators` entry), not its consensus key. The Orbinum runtime + read the Aura key as an account, so a validator that set its keys from another + account was never recorded in `LastAuthorVersion` and the quorum for + `set_min_author_version` could not be reached; runtime spec 19 maps the Aura + authority index to its session validator instead. + --- ## [0.4.0] — 2026-10-07 diff --git a/frame/validator-set/src/lib.rs b/frame/validator-set/src/lib.rs index 401ed011..35560fff 100644 --- a/frame/validator-set/src/lib.rs +++ b/frame/validator-set/src/lib.rs @@ -99,7 +99,10 @@ pub mod pallet { /// Notified whenever an account leaves the approved set. type OnValidatorRemoved: crate::OnValidatorRemoved; - /// The account that authored the current block, from its pre-runtime digests. + /// The validator account that authored the current block, from its + /// pre-runtime digests: the account in [`ApprovedValidators`], not the + /// author's consensus key. A declaration from an author it cannot place + /// there is dropped, and that validator never counts toward a quorum. type FindAuthor: FindAuthor; /// How recent a validator's declared version must be to count toward the diff --git a/template/runtime/Cargo.toml b/template/runtime/Cargo.toml index 849c0d35..b0a1366f 100644 --- a/template/runtime/Cargo.toml +++ b/template/runtime/Cargo.toml @@ -15,6 +15,7 @@ targets = ["x86_64-unknown-linux-gnu"] ethereum = { workspace = true } hex-literal = { workspace = true } libsecp256k1 = { workspace = true } +log = { workspace = true } scale-codec = { workspace = true } scale-info = { workspace = true } serde_json = { workspace = true, default-features = false, features = [ @@ -120,6 +121,7 @@ poseidon-native-runtime = [ std = [ "ethereum/std", + "log/std", "scale-codec/std", "scale-info/std", # Substrate diff --git a/template/runtime/RUNTIME_VERSIONS.md b/template/runtime/RUNTIME_VERSIONS.md index 77ec9052..b35c6dca 100644 --- a/template/runtime/RUNTIME_VERSIONS.md +++ b/template/runtime/RUNTIME_VERSIONS.md @@ -20,7 +20,26 @@ to `spec_version` / `transaction_version` must add a row here in the same PR. The genesis reset (`69d1b837`) set `spec_version` back to 1 and `transaction_version` to 1 for the public testnet launch. -### spec 18 — tx 6 — [Unreleased] +### spec 19 — tx 6 — [Unreleased] + +The block author is the validator account at the Aura authority index, not the +Aura key read as an account. No call or encoding changes. + +- **Author lookup.** `pallet_validator_set` and `pallet_authorship` take + `FindAuthor = pallet_session::FindAccountFromAuthorIndex`. + Reading the Aura key as an `AccountId` matched only validators that registered + their key as their own account: on testnet 9 of 14 had set their keys from + another account, so their `note_author_version` declarations were dropped (the + quorum for `set_min_author_version` was unreachable) and their fallback relay + fees were credited to their Aura keys. +- **Migration `MoveAuraKeyRelayFees`.** Each `PendingRelayerFees` balance on an + Aura key owned by a current validator (`pallet_session::KeyOwner`) moves to + that validator's account; keys no validator owns are left alone. Idempotent. + try-runtime on testnet state: 9 balances moved, 0.27% of block weight. +- `pallet_zk_verifier::migrations::v2::MigrateToV2` is no longer run: every + live chain is at storage version 2. + +### spec 18 — tx 6 — 2026-10-12 (`v0.4.0`) Governance can require a minimum node version from block authors, and a private transfer can spend two notes from different trees. `transaction_version` moves: diff --git a/template/runtime/src/configs/consensus.rs b/template/runtime/src/configs/consensus.rs index 93b70dbc..656e0c5e 100644 --- a/template/runtime/src/configs/consensus.rs +++ b/template/runtime/src/configs/consensus.rs @@ -101,31 +101,24 @@ impl pallet_validator_set::Config for Runtime { type MaxValidators = ConstU32<32>; type Prerequisites = ValidatorPrerequisiteChecker; type OnValidatorRemoved = RelayerCleanup; - type FindAuthor = FindAuthorAccountId; + type FindAuthor = ValidatorAuthor; type QuorumWindow = Period; type WeightInfo = pallet_validator_set::weights::SubstrateWeight; } impl pallet_authorship::Config for Runtime { - type FindAuthor = FindAuthorAccountId; + type FindAuthor = ValidatorAuthor; type EventHandler = (); } -/// Maps an Aura authority index to its `AccountId32`: upstream's `AuraAuthorId` yields -/// the index, and the sr25519 public key at that index is the account's 32 bytes. -pub struct FindAuthorAccountId; -impl FindAuthor for FindAuthorAccountId { - fn find_author<'a, I>(digests: I) -> Option - where - I: 'a + IntoIterator, - { - if let Some(aura_id) = pallet_aura::AuraAuthorId::::find_author(digests) { - let raw: [u8; 32] = aura_id.to_raw_vec().try_into().unwrap_or([0u8; 32]); - return Some(AccountId::from(raw)); - } - None - } -} +/// The block author's validator account: the session validator at the Aura +/// authority index the block's pre-digest names. Aura's authorities are set from +/// the session's validators, in the same order. +/// +/// Not the Aura key read as an account: that is the validator's account only when +/// the operator registered the key as its own account, so every validator that +/// called `session.setKeys` from another account went unrecognised. +pub type ValidatorAuthor = pallet_session::FindAccountFromAuthorIndex; impl pallet_grandpa::Config for Runtime { type RuntimeEvent = RuntimeEvent; diff --git a/template/runtime/src/lib.rs b/template/runtime/src/lib.rs index 2aff713a..84407907 100644 --- a/template/runtime/src/lib.rs +++ b/template/runtime/src/lib.rs @@ -14,6 +14,7 @@ use sp_io as _; mod configs; mod evm_account; mod genesis_config_preset; +mod migrations; mod orbinum_signature; mod precompiles; mod weights; @@ -119,7 +120,7 @@ pub type SignedPayload = generic::SignedPayload; /// Runs on upgrade, oldest first. Drop an entry once every live chain has passed its /// version: a migration that can no longer run could be re-armed by mistake. -pub type Migrations = (pallet_zk_verifier::migrations::v2::MigrateToV2,); +pub type Migrations = (migrations::MoveAuraKeyRelayFees,); pub type Executive = frame_executive::Executive< Runtime, @@ -160,7 +161,7 @@ pub const VERSION: RuntimeVersion = RuntimeVersion { spec_name: Cow::Borrowed("orbinum"), impl_name: Cow::Borrowed("orbinum"), authoring_version: 1, - spec_version: 18, + spec_version: 19, impl_version: 1, apis: RUNTIME_API_VERSIONS, transaction_version: 6, diff --git a/template/runtime/src/migrations.rs b/template/runtime/src/migrations.rs new file mode 100644 index 00000000..0f836be9 --- /dev/null +++ b/template/runtime/src/migrations.rs @@ -0,0 +1,78 @@ +//! Runtime-level storage migrations, run by `Executive` on upgrade (`Migrations`). + +use crate::{AccountId, Runtime}; +use frame_support::{traits::OnRuntimeUpgrade, weights::Weight}; +use sp_core::crypto::key_types::AURA; + +#[cfg(feature = "try-runtime")] +use alloc::vec::Vec; + +/// Moves relay fees credited to a validator's Aura key, read as an account, to +/// the validator's own account (spec 19). +/// +/// Until spec 19 the block author was the Aura key taken as an `AccountId`, so a +/// validator whose account is not its Aura key earned its fallback relay fees on +/// an account nobody claims from. The Aura key's owner is the validator that +/// registered it (`pallet_session::KeyOwner`); a key no current validator owns is +/// left where it is. Idempotent: once moved, no balance sits on an owned key. +pub struct MoveAuraKeyRelayFees; + +impl MoveAuraKeyRelayFees { + /// `(aura_key_account, asset_id, amount, validator)` for every balance to move. + fn moves() -> alloc::vec::Vec<(AccountId, u32, u128, AccountId)> { + pallet_relayer::PendingRelayerFees::::iter() + .filter_map(|(holder, asset_id, amount)| { + let key: &[u8] = holder.as_ref(); + let owner = pallet_session::Pallet::::key_owner(AURA, key)?; + (owner != holder).then_some((holder, asset_id, amount, owner)) + }) + .collect() + } +} + +impl OnRuntimeUpgrade for MoveAuraKeyRelayFees { + fn on_runtime_upgrade() -> Weight { + let entries = pallet_relayer::PendingRelayerFees::::iter().count() as u64; + let moves = Self::moves(); + for (holder, asset_id, amount, validator) in &moves { + pallet_relayer::PendingRelayerFees::::remove(holder, asset_id); + pallet_relayer::PendingRelayerFees::::mutate(validator, asset_id, |b| { + *b = b.saturating_add(*amount); + }); + } + log::info!( + target: "runtime::migrations", + "moved {} relay fee balance(s) from Aura keys to their validators", + moves.len(), + ); + let moved = moves.len() as u64; + // Two passes over the map, a key-owner read per entry, and per move a + // read and two writes. + ::DbWeight::get().reads_writes( + entries.saturating_mul(3).saturating_add(moved), + moved.saturating_mul(2), + ) + } + + #[cfg(feature = "try-runtime")] + fn pre_upgrade() -> Result, sp_runtime::TryRuntimeError> { + use scale_codec::Encode; + let total: u128 = pallet_relayer::PendingRelayerFees::::iter_values().sum(); + Ok((total, Self::moves().len() as u32).encode()) + } + + #[cfg(feature = "try-runtime")] + fn post_upgrade(state: Vec) -> Result<(), sp_runtime::TryRuntimeError> { + use scale_codec::Decode; + let (before, to_move) = <(u128, u32)>::decode(&mut &state[..]) + .map_err(|_| sp_runtime::TryRuntimeError::Other("undecodable pre-upgrade state"))?; + let after: u128 = pallet_relayer::PendingRelayerFees::::iter_values().sum(); + frame_support::ensure!(before == after, "relay fee total changed"); + frame_support::ensure!( + Self::moves().is_empty(), + "a fee balance is still on an Aura key" + ); + log::info!(target: "runtime::migrations", "{to_move} balance(s) moved, total unchanged"); + Ok(()) + } +} diff --git a/template/runtime/src/runtime_tests.rs b/template/runtime/src/runtime_tests.rs index 58ed2075..d505d690 100644 --- a/template/runtime/src/runtime_tests.rs +++ b/template/runtime/src/runtime_tests.rs @@ -605,3 +605,139 @@ fn the_runtime_does_not_import_the_groth16_host_function_yet() { b"ext_groth_16_host_interface_bn254_groth16_verify" )); } + +// ── Block author ────────────────────────────────────────────────────────────── + +mod block_author { + use crate::{ + configs::consensus::ValidatorAuthor, AccountId, Runtime, RuntimeCall, RuntimeOrigin, + }; + use frame_support::traits::{FindAuthor, OnRuntimeUpgrade}; + use pallet_validator_set::NodeVersion; + use scale_codec::Encode; + use sp_consensus_aura::{sr25519::AuthorityId as AuraId, Slot, AURA_ENGINE_ID}; + use sp_core::{crypto::key_types::AURA, sr25519, Pair}; + use sp_runtime::{traits::Dispatchable, Digest, DigestItem}; + + fn aura_key(seed: &str) -> AuraId { + sr25519::Pair::from_string(seed, None) + .unwrap() + .public() + .into() + } + + fn as_account(key: &AuraId) -> AccountId { + let raw: &[u8] = key.as_ref(); + <[u8; 32]>::try_from(raw).unwrap().into() + } + + /// Two validators: `alice` registered her Aura key as her own account (the + /// genesis case), `bob` set his from another account (the `setKeys` case). + struct Set { + alice: AccountId, + bob: AccountId, + bob_aura: AuraId, + } + + fn validator_set() -> Set { + let alice_aura = aura_key("//Alice"); + let alice = as_account(&alice_aura); + let bob_aura = aura_key("//BobAura"); + let bob: AccountId = [0xb0; 32].into(); + pallet_session::Validators::::put(vec![alice.clone(), bob.clone()]); + pallet_aura::Authorities::::put( + frame_support::BoundedVec::try_from(vec![alice_aura.clone(), bob_aura.clone()]) + .unwrap(), + ); + for (key, owner) in [(&alice_aura, &alice), (&bob_aura, &bob)] { + let raw: &[u8] = key.as_ref(); + pallet_session::KeyOwner::::insert((AURA, raw.to_vec()), owner); + } + pallet_validator_set::ApprovedValidators::::put( + frame_support::BoundedVec::try_from(vec![alice.clone(), bob.clone()]).unwrap(), + ); + Set { + alice, + bob, + bob_aura, + } + } + + /// A pre-digest naming the authority at `index` of two. + fn digest(index: u64) -> Digest { + Digest { + logs: vec![DigestItem::PreRuntime( + AURA_ENGINE_ID, + Slot::from(index).encode(), + )], + } + } + + #[test] + fn the_author_is_the_session_validator_not_its_aura_key() { + super::with_ext(|| { + let set = validator_set(); + let logs = digest(1); + let found = + ValidatorAuthor::find_author(logs.logs.iter().filter_map(|d| d.as_pre_runtime())); + assert_eq!(found, Some(set.bob.clone())); + assert_ne!(found, Some(as_account(&set.bob_aura))); + let logs = digest(0); + let found = + ValidatorAuthor::find_author(logs.logs.iter().filter_map(|d| d.as_pre_runtime())); + assert_eq!(found, Some(set.alice)); + }); + } + + #[test] + fn a_setkeys_validator_has_its_version_recorded() { + super::with_ext(|| { + let set = validator_set(); + frame_system::Pallet::::initialize(&1, &Default::default(), &digest(1)); + let version = NodeVersion::new(0, 4, 0); + RuntimeCall::ValidatorSet(pallet_validator_set::Call::note_author_version { version }) + .dispatch(RuntimeOrigin::none()) + .expect("the declaration is accepted"); + assert_eq!( + pallet_validator_set::LastAuthorVersion::::get(&set.bob), + Some((version, 1)) + ); + }); + } + + #[test] + fn fallback_relay_fees_go_to_the_validator_account() { + super::with_ext(|| { + let set = validator_set(); + frame_system::Pallet::::initialize(&1, &Default::default(), &digest(1)); + assert_eq!( + pallet_authorship::Pallet::::author(), + Some(set.bob) + ); + }); + } + + #[test] + fn the_migration_moves_fees_off_aura_keys_once() { + use pallet_relayer::PendingRelayerFees as Fees; + super::with_ext(|| { + let set = validator_set(); + let bob_key = as_account(&set.bob_aura); + let stranger: AccountId = [0x5c; 32].into(); + Fees::::insert(&bob_key, 0, 10u128); + Fees::::insert(&set.bob, 0, 5u128); + Fees::::insert(&set.alice, 0, 7u128); // her key is her account + Fees::::insert(&stranger, 0, 3u128); // no validator owns it + + crate::migrations::MoveAuraKeyRelayFees::on_runtime_upgrade(); + + assert_eq!(Fees::::get(&set.bob, 0), 15); + assert!(!Fees::::contains_key(&bob_key, 0)); + assert_eq!(Fees::::get(&set.alice, 0), 7); + assert_eq!(Fees::::get(&stranger, 0), 3); + + crate::migrations::MoveAuraKeyRelayFees::on_runtime_upgrade(); + assert_eq!(Fees::::get(&set.bob, 0), 15); + }); + } +}