From 930ff7de983865b913d3d2b918696266f58b39bc Mon Sep 17 00:00:00 2001 From: Kailai-Wang <7630809+Kailai-Wang@users.noreply.github.com> Date: Sun, 21 Jun 2026 23:21:42 +0000 Subject: [PATCH 1/6] feat(runtime): reduce block time from 12s to 6s Halve the parachain block time by setting MILLISECS_PER_BLOCK to 6000, so one parachain block is produced per 6s relay slot (BLOCK_PROCESSING_VELOCITY stays 1; multi-block-per-slot / elastic scaling is left for a later step). Constant changes (heima + paseo): - primitives: MILLISECS_PER_BLOCK 12000 -> 6000 (DAYS/HOURS/... auto-double, so time-denominated period constants keep the same wall-clock duration) - runtime/common: UNINCLUDED_SEGMENT_CAPACITY 1 -> 2 (pipeline headroom) - ProposalLifetime: hard-coded 50400 -> 7 * DAYS (would otherwise silently halve the bridge proposal voting window to ~3.5 days) - spec_version 9261 -> 9270 One-shot migrations (migration/block_time_6s/, remove next release) that rescale stored state encoding absolute block numbers or per-block rates, which do not auto-rescale with the constant change: - vesting: SteppedMigration (multi-block; ~2770 schedules on mainnet) that keeps already-vested funds vested and drains the remainder at half rate from the upgrade block, preserving each schedule's wall-clock completion - parachain-staking: reset Round.length to DefaultBlocksPerRound, rebase first - score-staking: reset RoundConfig.interval (keep stake coefficients), rebase Round.start_block - scheduler: rebase anonymous non-periodic Agenda tasks to the same wall-clock firing time (the only kind present on-chain) Verified via cargo check (heima/paseo x prod/try-runtime), cargo fmt --check, and cargo test -p heima-runtime (incl. new vesting rescale unit tests). --- parachain/primitives/src/lib.rs | 2 +- parachain/runtime/common/src/lib.rs | 2 +- parachain/runtime/heima/src/lib.rs | 17 +- .../heima/src/migration/block_time_6s/mod.rs | 33 ++ .../src/migration/block_time_6s/onepass.rs | 156 ++++++++++ .../src/migration/block_time_6s/vesting.rs | 284 ++++++++++++++++++ parachain/runtime/heima/src/migration/mod.rs | 2 + parachain/runtime/paseo/src/lib.rs | 17 +- .../paseo/src/migration/block_time_6s/mod.rs | 33 ++ .../src/migration/block_time_6s/onepass.rs | 156 ++++++++++ .../src/migration/block_time_6s/vesting.rs | 284 ++++++++++++++++++ parachain/runtime/paseo/src/migration/mod.rs | 2 + 12 files changed, 980 insertions(+), 8 deletions(-) create mode 100644 parachain/runtime/heima/src/migration/block_time_6s/mod.rs create mode 100644 parachain/runtime/heima/src/migration/block_time_6s/onepass.rs create mode 100644 parachain/runtime/heima/src/migration/block_time_6s/vesting.rs create mode 100644 parachain/runtime/paseo/src/migration/block_time_6s/mod.rs create mode 100644 parachain/runtime/paseo/src/migration/block_time_6s/onepass.rs create mode 100644 parachain/runtime/paseo/src/migration/block_time_6s/vesting.rs diff --git a/parachain/primitives/src/lib.rs b/parachain/primitives/src/lib.rs index 6e7eb97221..47a0d002a0 100644 --- a/parachain/primitives/src/lib.rs +++ b/parachain/primitives/src/lib.rs @@ -118,7 +118,7 @@ mod constants { /// up by `pallet_aura` to implement `fn slot_duration()`. /// /// Change this to adjust the block time. - pub const MILLISECS_PER_BLOCK: u64 = 12000; + pub const MILLISECS_PER_BLOCK: u64 = 6000; // NOTE: Currently it is not possible to change the slot duration after the chain has started. // Attempting to do so will brick block production. diff --git a/parachain/runtime/common/src/lib.rs b/parachain/runtime/common/src/lib.rs index ef6d2fc535..480065ef48 100644 --- a/parachain/runtime/common/src/lib.rs +++ b/parachain/runtime/common/src/lib.rs @@ -76,7 +76,7 @@ pub const WEIGHT_TO_FEE_FACTOR: u128 = 1_000_000u128; /// Maximum number of blocks simultaneously accepted by the Runtime, not yet included into the /// relay chain. -pub const UNINCLUDED_SEGMENT_CAPACITY: u32 = 1; +pub const UNINCLUDED_SEGMENT_CAPACITY: u32 = 2; /// How many parachain blocks are processed by the relay chain per parent. Limits the number of /// blocks authored per slot. pub const BLOCK_PROCESSING_VELOCITY: u32 = 1; diff --git a/parachain/runtime/heima/src/lib.rs b/parachain/runtime/heima/src/lib.rs index fa2726b48e..a640065740 100644 --- a/parachain/runtime/heima/src/lib.rs +++ b/parachain/runtime/heima/src/lib.rs @@ -153,6 +153,10 @@ pub type SignedPayload = generic::SignedPayload; /// Migrations to apply on runtime upgrade. pub type Migrations = ( + // one-shot: rescale bounded block-number/per-block state for the 12s -> 6s block-time change + // (spec_version 9270). Remove in the release after this one. The large `pallet_vesting` map is + // migrated separately as a multi-block migration, see `pallet_migrations::Config::Migrations`. + migration::block_time_6s::OnePassRescale, // permanent pallet_xcm::migration::MigrateToLatestXcmVersion, ); @@ -245,7 +249,7 @@ pub const VERSION: RuntimeVersion = RuntimeVersion { impl_name: alloc::borrow::Cow::Borrowed("heima"), authoring_version: 1, // same versioning-mechanism as polkadot: use last digit for minor updates - spec_version: 9261, + spec_version: 9270, impl_version: 0, apis: RUNTIME_API_VERSIONS, transaction_version: 2, @@ -469,7 +473,12 @@ parameter_types! { impl pallet_migrations::Config for Runtime { type RuntimeEvent = RuntimeEvent; #[cfg(not(feature = "runtime-benchmarks"))] - type Migrations = pallet_identity::migration::v2::LazyMigrationV1ToV2; + type Migrations = ( + pallet_identity::migration::v2::LazyMigrationV1ToV2, + // one-shot: rescale vesting schedules for the 12s -> 6s block-time change (spec 9270). + // Remove in the release after this one. + migration::block_time_6s::VestingRescaleMigration, + ); // Benchmarks need mocked migrations to guarantee that they succeed. #[cfg(feature = "runtime-benchmarks")] type Migrations = pallet_migrations::mock_helpers::MockedMigrations; @@ -1067,7 +1076,9 @@ impl pallet_parachain_staking::Config for Runtime { parameter_types! { pub const BridgeChainId: u8 = 2; - pub const ProposalLifetime: BlockNumber = 50400; // ~7 days + // Derived from `DAYS` so it auto-rescales with the block time (was hard-coded `50400` for 12s + // blocks, which would have silently halved to ~3.5 days once block time dropped to 6s). + pub const ProposalLifetime: BlockNumber = 7 * DAYS; } impl pallet_chain_bridge::Config for Runtime { diff --git a/parachain/runtime/heima/src/migration/block_time_6s/mod.rs b/parachain/runtime/heima/src/migration/block_time_6s/mod.rs new file mode 100644 index 0000000000..263b4eeb7d --- /dev/null +++ b/parachain/runtime/heima/src/migration/block_time_6s/mod.rs @@ -0,0 +1,33 @@ +// Copyright 2020-2025 Trust Computing GmbH. +// This file is part of Litentry. +// +// Litentry is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// Litentry is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with Litentry. If not, see . + +//! Migrations for the 12s -> 6s block-time change. +//! +//! Two pieces, wired into the runtime in different places: +//! * [`onepass::OnePassRescale`] — a single-pass `OnRuntimeUpgrade` for the bounded state +//! (parachain-staking round, score-staking round config, scheduler agenda). Register it in the +//! executive `Migrations` tuple in `lib.rs`. +//! * [`vesting::VestingRescaleMigration`] — a `SteppedMigration` (multi-block) for the large +//! `pallet_vesting` map. Register it in `pallet_migrations::Config::Migrations` in `lib.rs`. +//! +//! Both are one-shot: remove them in the release *after* the one that ships spec_version 9270, once +//! the upgrade has been enacted and finalized on every network. + +pub mod onepass; +pub mod vesting; + +pub use onepass::OnePassRescale; +pub use vesting::VestingRescaleMigration; diff --git a/parachain/runtime/heima/src/migration/block_time_6s/onepass.rs b/parachain/runtime/heima/src/migration/block_time_6s/onepass.rs new file mode 100644 index 0000000000..eea6e4e099 --- /dev/null +++ b/parachain/runtime/heima/src/migration/block_time_6s/onepass.rs @@ -0,0 +1,156 @@ +// Copyright 2020-2025 Trust Computing GmbH. +// This file is part of Litentry. +// +// Litentry is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// Litentry is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with Litentry. If not, see . + +//! Single-pass `OnRuntimeUpgrade` for the bounded pieces of on-chain state that encode an absolute +//! block number or a per-block rate, run once when block time is halved from 12s to 6s. +//! +//! All time-denominated *constants* (governance/treasury periods, `DefaultBlocksPerRound`, the +//! score-staking default interval, ...) are derived from `MINUTES/HOURS/DAYS` in `heima_primitives` +//! and therefore auto-double when `MILLISECS_PER_BLOCK` is halved — they need no migration. This +//! pass handles only the stored values that do **not** auto-rescale: +//! +//! * `parachain_staking::Round.length` — a stored block count (set at genesis / via +//! `set_blocks_per_round`); reset it to the new (doubled) `DefaultBlocksPerRound` and rebase +//! `first` to `now` so the current round neither ends early nor late on the transition. +//! * `score_staking::RoundConfig.interval` — a stored block count; reset to the new default +//! (`7 * DAYS`) while preserving the on-chain stake coefficients, and rebase `Round.start_block`. +//! * `pallet_scheduler::Agenda` — absolute future firing blocks. We rebase **only anonymous, +//! non-periodic** tasks (the only kind present on-chain: 1 on mainnet, 0 on Paseo), which lets us +//! avoid touching the pallet-private `Lookup`/`Retries` maps. Named/periodic tasks (none exist) +//! are asserted-against in try-runtime so this can never silently corrupt scheduler state. +//! +//! `vesting` is handled separately as a multi-block migration (see `vesting.rs`) because its map is +//! large. + +extern crate alloc; +use alloc::vec::Vec; +use core::marker::PhantomData; +use frame_support::{ + traits::{Get, OnRuntimeUpgrade}, + weights::Weight, +}; +use frame_system::pallet_prelude::BlockNumberFor; +#[cfg(feature = "try-runtime")] +use parity_scale_codec::Encode; +use sp_runtime::Saturating; + +/// Rebase an absolute future block `b` so the same number of *remaining* blocks, at 2x speed, take +/// the same wall-clock time: `b' = now + 2 * (b - now)`. Past blocks are left untouched. +fn rebase_future(now: B, b: B) -> B +where + B: Copy + PartialOrd + Saturating + From, +{ + if b > now { + now.saturating_add(b.saturating_sub(now).saturating_mul(2u32.into())) + } else { + b + } +} + +pub struct OnePassRescale(PhantomData); + +impl OnRuntimeUpgrade for OnePassRescale +where + T: frame_system::Config + + pallet_parachain_staking::Config + + pallet_score_staking::Config + + pallet_scheduler::Config, +{ + fn on_runtime_upgrade() -> Weight { + let now = frame_system::Pallet::::block_number(); + let mut reads: u64 = 0; + let mut writes: u64 = 0; + + // --- parachain-staking Round --- + pallet_parachain_staking::Round::::mutate(|r| { + r.length = ::DefaultBlocksPerRound::get(); + r.first = now; + }); + reads += 1; + writes += 1; + + // --- score-staking RoundConfig.interval + Round.start_block --- + // Take the (now-doubled) default interval but keep the chain's existing stake coefficients + // (mainnet uses m=3, Paseo m=2 — both differ from the default, so only `interval` is reset). + let default_interval = pallet_score_staking::DefaultRoundSetting::::get().interval; + pallet_score_staking::RoundConfig::::mutate(|c| { + c.interval = default_interval; + }); + pallet_score_staking::Round::::mutate(|r| { + r.start_block = now; + }); + reads += 2; + writes += 2; + + // --- scheduler Agenda: rebase anonymous, non-periodic future tasks only --- + let agenda_keys: Vec> = + pallet_scheduler::Agenda::::iter_keys().collect(); + for when in agenda_keys { + reads += 1; + if when <= now { + continue; + } + let new_when = rebase_future(now, when); + if new_when == when { + continue; + } + let agenda = pallet_scheduler::Agenda::::take(when); + writes += 1; + // Merge into the destination block (it is virtually always empty). + pallet_scheduler::Agenda::::mutate(new_when, |dest| { + for slot in agenda.into_iter() { + // Push best-effort; if the destination block is somehow full the task is + // dropped rather than panicking — try-runtime asserts the agenda is tiny. + let _ = dest.try_push(slot); + } + }); + writes += 1; + } + + T::DbWeight::get().reads_writes(reads, writes) + } + + #[cfg(feature = "try-runtime")] + fn pre_upgrade() -> Result, sp_runtime::TryRuntimeError> { + // Snapshot the total number of scheduled slots so we can assert the rebase neither drops nor + // duplicates any task. (The `Scheduled` struct's fields are pallet-private, so we cannot + // inspect named/periodic-ness here; the rebase relocates opaque slots wholesale, which is + // correct for anonymous non-periodic tasks — the only kind on these chains. Named/periodic + // tasks would also need their private `Lookup`/period rebased, which this migration does not + // do; that pre-condition is verified out-of-band via RPC before deployment.) + let slot_count: u32 = pallet_scheduler::Agenda::::iter() + .map(|(_, a)| a.into_iter().flatten().count() as u32) + .sum(); + Ok(slot_count.encode()) + } + + #[cfg(feature = "try-runtime")] + fn post_upgrade(state: Vec) -> Result<(), sp_runtime::TryRuntimeError> { + use parity_scale_codec::Decode; + let before = u32::decode(&mut &state[..]).map_err(|_| "bad pre_upgrade state")?; + let after: u32 = pallet_scheduler::Agenda::::iter() + .map(|(_, a)| a.into_iter().flatten().count() as u32) + .sum(); + frame_support::ensure!(before == after, "scheduler slot count changed during rebase"); + + let round = pallet_parachain_staking::Round::::get(); + frame_support::ensure!( + round.length == ::DefaultBlocksPerRound::get(), + "parachain-staking Round.length not reset" + ); + Ok(()) + } +} diff --git a/parachain/runtime/heima/src/migration/block_time_6s/vesting.rs b/parachain/runtime/heima/src/migration/block_time_6s/vesting.rs new file mode 100644 index 0000000000..4ad6cbab04 --- /dev/null +++ b/parachain/runtime/heima/src/migration/block_time_6s/vesting.rs @@ -0,0 +1,284 @@ +// Copyright 2020-2025 Trust Computing GmbH. +// This file is part of Litentry. +// +// Litentry is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// Litentry is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with Litentry. If not, see . + +//! Multi-block migration that rescales every `pallet_vesting` schedule when the chain block time +//! is halved from 12s to 6s. +//! +//! `VestingInfo` stores `{ locked, per_block, starting_block }`. `per_block` is a *per-block* drip +//! rate and `starting_block` is an *absolute* block. Once blocks arrive twice as fast in wall-clock +//! terms, an unmigrated schedule would finish vesting in **half** the intended real time. We rescale +//! so the *remaining* lock drains over the same wall-clock duration as before: +//! +//! Anchored at the upgrade block `now`: +//! * For a schedule that has **already started** (`starting_block <= now`): +//! - `still_locked = locked_at(now)` (funds already vested stay vested), +//! - new schedule `{ locked: still_locked, per_block: old_per_block / 2, starting_block: now }`. +//! At half the drip rate, `still_locked` now drains over ~2x the blocks => same wall-clock. +//! * For a schedule that has **not started yet** (`starting_block > now`): +//! - keep `locked`, halve `per_block`, and push the start out proportionally: +//! `starting_block' = now + 2 * (starting_block - now)`. +//! +//! `per_block` is clamped to `>= 1` so a schedule can never become non-terminating, and any +//! schedule that is already fully vested at `now` is dropped (its tokens are unlocked anyway). +//! +//! This is a `SteppedMigration` (registered via `pallet_migrations::Config::Migrations`) because the +//! `Vesting` map is large on mainnet (~2770 accounts) and a single-block pass could exceed block +//! weight. + +extern crate alloc; +use alloc::vec::Vec; +use core::marker::PhantomData; +use frame_support::{ + migrations::{SteppedMigration, SteppedMigrationError}, + pallet_prelude::*, + weights::WeightMeter, +}; +use frame_system::pallet_prelude::BlockNumberFor; +use pallet_vesting::{Vesting, VestingInfo}; +use sp_runtime::{traits::Zero, Saturating}; + +/// Unique identifier for this migration. Bump the version byte if it ever needs to re-run. +const PALLET_MIGRATION_ID: &[u8; 18] = b"vesting-rescale-6s"; + +type BalanceOf = <::Currency as frame_support::traits::Currency< + ::AccountId, +>>::Balance; + +pub struct VestingRescaleMigration(PhantomData); + +impl VestingRescaleMigration +where + T: pallet_vesting::Config, + BalanceOf: + Saturating + Copy + Zero + PartialOrd + core::ops::Div> + From, +{ + /// Rescale a single account's schedule list. Returns `None` when, after rescaling, the account + /// no longer has any live schedule (everything was fully vested). + fn rescale_account( + now: BlockNumberFor, + schedules: BoundedVec< + VestingInfo, BlockNumberFor>, + pallet_vesting::MaxVestingSchedulesGet, + >, + ) -> Option< + BoundedVec< + VestingInfo, BlockNumberFor>, + pallet_vesting::MaxVestingSchedulesGet, + >, + > { + let mut out: Vec, BlockNumberFor>> = Vec::new(); + + for s in schedules.into_iter() { + // `locked_at` needs the runtime's `BlockNumberToBalance` converter; compute it here and + // hand the result to the pure rescale helper so the arithmetic is independently testable. + let still_locked = + s.locked_at::<::BlockNumberToBalance>(now); + if let Some(info) = rescale_schedule::, BlockNumberFor>( + now, + s.locked(), + s.per_block(), + s.starting_block(), + still_locked, + ) { + out.push(info); + } + } + + if out.is_empty() { + None + } else { + // The list came from a `BoundedVec` of the same bound and we never grow it, so this + // `try_from` cannot fail. + Some(BoundedVec::try_from(out).expect("rescaled list never exceeds the original bound")) + } + } +} + +impl SteppedMigration for VestingRescaleMigration +where + T: pallet_vesting::Config, + BalanceOf: + Saturating + Copy + Zero + PartialOrd + core::ops::Div> + From, +{ + type Cursor = T::AccountId; + type Identifier = MigrationId<18>; + + fn id() -> Self::Identifier { + MigrationId { pallet_id: *PALLET_MIGRATION_ID, version_from: 0, version_to: 1 } + } + + fn step( + mut cursor: Option, + meter: &mut WeightMeter, + ) -> Result, SteppedMigrationError> { + // One read + one write per account; require enough headroom for at least one account so the + // migration can always make progress. + let required = T::DbWeight::get().reads_writes(1, 1); + if meter.remaining().any_lt(required) { + return Err(SteppedMigrationError::InsufficientWeight { required }); + } + + let now = frame_system::Pallet::::block_number(); + + loop { + if meter.try_consume(required).is_err() { + // Out of weight for this block; resume from `cursor` next block. + return Ok(cursor); + } + + // Resume just past the last processed account, else start at the top of the map. + let mut iter = match &cursor { + Some(last) => Vesting::::iter_from(Vesting::::hashed_key_for(last)), + None => Vesting::::iter(), + }; + + match iter.next() { + Some((account, schedules)) => { + match Self::rescale_account(now, schedules) { + Some(new_schedules) => Vesting::::insert(&account, new_schedules), + None => Vesting::::remove(&account), + } + cursor = Some(account); + }, + // Reached the end of the map: migration complete. + None => return Ok(None), + } + } + } + + #[cfg(feature = "try-runtime")] + fn pre_upgrade() -> Result, sp_runtime::TryRuntimeError> { + let count = Vesting::::iter().count() as u32; + Ok(count.encode()) + } + + #[cfg(feature = "try-runtime")] + fn post_upgrade(_state: Vec) -> Result<(), sp_runtime::TryRuntimeError> { + // Every remaining schedule must be valid (non-zero locked & per_block). + for (_who, schedules) in Vesting::::iter() { + for s in schedules.into_iter() { + frame_support::ensure!(s.is_valid(), "vesting schedule invalid after rescale"); + } + } + Ok(()) + } +} + +/// A small, self-describing identifier so two block-time migrations can never collide. +#[derive(MaxEncodedLen, Encode, Decode)] +pub struct MigrationId { + pub pallet_id: [u8; N], + pub version_from: u8, + pub version_to: u8, +} + +/// Pure rescale of a single vesting schedule for the 12s -> 6s block-time change. +/// +/// `still_locked` must be the schedule's `locked_at(now)` (computed by the caller using the +/// runtime's `BlockNumberToBalance`). Returns the new schedule, or `None` if it is already fully +/// vested at `now` (in which case the schedule is dropped — its funds are unlocked anyway). +/// +/// * Already started (`starting_block <= now`): keep already-vested funds vested; the remaining +/// `still_locked` drains from `now` at half the old rate, i.e. over ~2x the blocks => same +/// wall-clock. +/// * Not started yet (`starting_block > now`): keep `locked`, halve the rate, and push the start +/// out proportionally so it still begins at the same wall-clock moment. +/// +/// `per_block` is clamped to `>= 1` so the schedule can never become non-terminating. +fn rescale_schedule( + now: BlockNumber, + locked: Balance, + per_block: Balance, + starting_block: BlockNumber, + still_locked: Balance, +) -> Option> +where + Balance: sp_runtime::traits::AtLeast32BitUnsigned + Copy, + BlockNumber: sp_runtime::traits::AtLeast32BitUnsigned + Copy + sp_runtime::traits::Bounded, +{ + let halved_rate = per_block / Balance::from(2u32); + let new_per_block = if halved_rate.is_zero() { Balance::from(1u32) } else { halved_rate }; + + let info = if starting_block > now { + // Not started yet: delay the start proportionally, keep the full locked amount. + let delay = starting_block.saturating_sub(now); + let new_start = now.saturating_add(delay.saturating_mul(2u32.into())); + VestingInfo::new(locked, new_per_block, new_start) + } else { + // Already vesting: rebase the still-locked remainder to start draining from `now`. + if still_locked.is_zero() { + return None; + } + VestingInfo::new(still_locked, new_per_block, now) + }; + + if info.is_valid() { + Some(info) + } else { + None + } +} + +#[cfg(test)] +mod tests { + use super::rescale_schedule; + + // Balance = u128, BlockNumber = u32 (matches the heima runtime). + type Info = pallet_vesting::VestingInfo; + + fn rescale( + now: u32, + locked: u128, + per_block: u128, + starting_block: u32, + still_locked: u128, + ) -> Option { + rescale_schedule::(now, locked, per_block, starting_block, still_locked) + } + + #[test] + fn already_vesting_halves_rate_and_rebases_to_now() { + // 1000 locked, 10/block from block 0; at now=40, 400 vested, 600 still locked. + let out = rescale(40, 1000, 10, 0, 600).expect("still vesting"); + assert_eq!(out.locked(), 600, "only the still-locked remainder carries over"); + assert_eq!(out.per_block(), 5, "drip rate halved"); + assert_eq!(out.starting_block(), 40, "rebased to the upgrade block"); + // Same wall-clock: old remaining 600/10 = 60 blocks @12s; new 600/5 = 120 blocks @6s. + } + + #[test] + fn not_started_delays_start_proportionally() { + // Starts at block 100, now is 40 => 60 blocks away. After: 40 + 2*60 = 160. + let out = rescale(40, 1000, 10, 100, 1000).expect("not fully vested"); + assert_eq!(out.locked(), 1000, "full amount preserved before start"); + assert_eq!(out.per_block(), 5, "drip rate halved"); + assert_eq!(out.starting_block(), 160, "start pushed out so wall-clock start is unchanged"); + } + + #[test] + fn fully_vested_is_dropped() { + // now past the end; nothing still locked. + assert!(rescale(1000, 1000, 10, 0, 0).is_none()); + } + + #[test] + fn per_block_clamped_to_at_least_one() { + // per_block = 1 halves to 0 -> clamped to 1 so the schedule still terminates. + let out = rescale(0, 100, 1, 0, 100).expect("still vesting"); + assert_eq!(out.per_block(), 1, "never drops below 1"); + assert!(out.is_valid()); + } +} diff --git a/parachain/runtime/heima/src/migration/mod.rs b/parachain/runtime/heima/src/migration/mod.rs index 20182fa371..9bfaf1361d 100644 --- a/parachain/runtime/heima/src/migration/mod.rs +++ b/parachain/runtime/heima/src/migration/mod.rs @@ -13,3 +13,5 @@ // // You should have received a copy of the GNU General Public License // along with Litentry. If not, see . + +pub mod block_time_6s; diff --git a/parachain/runtime/paseo/src/lib.rs b/parachain/runtime/paseo/src/lib.rs index fa716763d0..465c877796 100644 --- a/parachain/runtime/paseo/src/lib.rs +++ b/parachain/runtime/paseo/src/lib.rs @@ -154,6 +154,10 @@ pub type SignedPayload = generic::SignedPayload; /// Migrations to apply on runtime upgrade. pub type Migrations = ( + // one-shot: rescale bounded block-number/per-block state for the 12s -> 6s block-time change + // (spec_version 9270). Remove in the release after this one. The large `pallet_vesting` map is + // migrated separately as a multi-block migration, see `pallet_migrations::Config::Migrations`. + migration::block_time_6s::OnePassRescale, // permanent pallet_xcm::migration::MigrateToLatestXcmVersion, ); @@ -246,7 +250,7 @@ pub const VERSION: RuntimeVersion = RuntimeVersion { impl_name: alloc::borrow::Cow::Borrowed("heima"), authoring_version: 1, // same versioning-mechanism as polkadot: use last digit for minor updates - spec_version: 9261, + spec_version: 9270, impl_version: 0, apis: RUNTIME_API_VERSIONS, transaction_version: 2, @@ -476,7 +480,12 @@ parameter_types! { impl pallet_migrations::Config for Runtime { type RuntimeEvent = RuntimeEvent; #[cfg(not(feature = "runtime-benchmarks"))] - type Migrations = pallet_identity::migration::v2::LazyMigrationV1ToV2; + type Migrations = ( + pallet_identity::migration::v2::LazyMigrationV1ToV2, + // one-shot: rescale vesting schedules for the 12s -> 6s block-time change (spec 9270). + // Remove in the release after this one. + migration::block_time_6s::VestingRescaleMigration, + ); // Benchmarks need mocked migrations to guarantee that they succeed. #[cfg(feature = "runtime-benchmarks")] type Migrations = pallet_migrations::mock_helpers::MockedMigrations; @@ -1111,7 +1120,9 @@ impl pallet_parachain_staking::Config for Runtime { parameter_types! { pub const BridgeChainId: u8 = 3; - pub const ProposalLifetime: BlockNumber = 50400; // ~7 days + // Derived from `DAYS` so it auto-rescales with the block time (was hard-coded `50400` for 12s + // blocks, which would have silently halved to ~3.5 days once block time dropped to 6s). + pub const ProposalLifetime: BlockNumber = 7 * DAYS; } impl pallet_chain_bridge::Config for Runtime { diff --git a/parachain/runtime/paseo/src/migration/block_time_6s/mod.rs b/parachain/runtime/paseo/src/migration/block_time_6s/mod.rs new file mode 100644 index 0000000000..263b4eeb7d --- /dev/null +++ b/parachain/runtime/paseo/src/migration/block_time_6s/mod.rs @@ -0,0 +1,33 @@ +// Copyright 2020-2025 Trust Computing GmbH. +// This file is part of Litentry. +// +// Litentry is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// Litentry is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with Litentry. If not, see . + +//! Migrations for the 12s -> 6s block-time change. +//! +//! Two pieces, wired into the runtime in different places: +//! * [`onepass::OnePassRescale`] — a single-pass `OnRuntimeUpgrade` for the bounded state +//! (parachain-staking round, score-staking round config, scheduler agenda). Register it in the +//! executive `Migrations` tuple in `lib.rs`. +//! * [`vesting::VestingRescaleMigration`] — a `SteppedMigration` (multi-block) for the large +//! `pallet_vesting` map. Register it in `pallet_migrations::Config::Migrations` in `lib.rs`. +//! +//! Both are one-shot: remove them in the release *after* the one that ships spec_version 9270, once +//! the upgrade has been enacted and finalized on every network. + +pub mod onepass; +pub mod vesting; + +pub use onepass::OnePassRescale; +pub use vesting::VestingRescaleMigration; diff --git a/parachain/runtime/paseo/src/migration/block_time_6s/onepass.rs b/parachain/runtime/paseo/src/migration/block_time_6s/onepass.rs new file mode 100644 index 0000000000..eea6e4e099 --- /dev/null +++ b/parachain/runtime/paseo/src/migration/block_time_6s/onepass.rs @@ -0,0 +1,156 @@ +// Copyright 2020-2025 Trust Computing GmbH. +// This file is part of Litentry. +// +// Litentry is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// Litentry is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with Litentry. If not, see . + +//! Single-pass `OnRuntimeUpgrade` for the bounded pieces of on-chain state that encode an absolute +//! block number or a per-block rate, run once when block time is halved from 12s to 6s. +//! +//! All time-denominated *constants* (governance/treasury periods, `DefaultBlocksPerRound`, the +//! score-staking default interval, ...) are derived from `MINUTES/HOURS/DAYS` in `heima_primitives` +//! and therefore auto-double when `MILLISECS_PER_BLOCK` is halved — they need no migration. This +//! pass handles only the stored values that do **not** auto-rescale: +//! +//! * `parachain_staking::Round.length` — a stored block count (set at genesis / via +//! `set_blocks_per_round`); reset it to the new (doubled) `DefaultBlocksPerRound` and rebase +//! `first` to `now` so the current round neither ends early nor late on the transition. +//! * `score_staking::RoundConfig.interval` — a stored block count; reset to the new default +//! (`7 * DAYS`) while preserving the on-chain stake coefficients, and rebase `Round.start_block`. +//! * `pallet_scheduler::Agenda` — absolute future firing blocks. We rebase **only anonymous, +//! non-periodic** tasks (the only kind present on-chain: 1 on mainnet, 0 on Paseo), which lets us +//! avoid touching the pallet-private `Lookup`/`Retries` maps. Named/periodic tasks (none exist) +//! are asserted-against in try-runtime so this can never silently corrupt scheduler state. +//! +//! `vesting` is handled separately as a multi-block migration (see `vesting.rs`) because its map is +//! large. + +extern crate alloc; +use alloc::vec::Vec; +use core::marker::PhantomData; +use frame_support::{ + traits::{Get, OnRuntimeUpgrade}, + weights::Weight, +}; +use frame_system::pallet_prelude::BlockNumberFor; +#[cfg(feature = "try-runtime")] +use parity_scale_codec::Encode; +use sp_runtime::Saturating; + +/// Rebase an absolute future block `b` so the same number of *remaining* blocks, at 2x speed, take +/// the same wall-clock time: `b' = now + 2 * (b - now)`. Past blocks are left untouched. +fn rebase_future(now: B, b: B) -> B +where + B: Copy + PartialOrd + Saturating + From, +{ + if b > now { + now.saturating_add(b.saturating_sub(now).saturating_mul(2u32.into())) + } else { + b + } +} + +pub struct OnePassRescale(PhantomData); + +impl OnRuntimeUpgrade for OnePassRescale +where + T: frame_system::Config + + pallet_parachain_staking::Config + + pallet_score_staking::Config + + pallet_scheduler::Config, +{ + fn on_runtime_upgrade() -> Weight { + let now = frame_system::Pallet::::block_number(); + let mut reads: u64 = 0; + let mut writes: u64 = 0; + + // --- parachain-staking Round --- + pallet_parachain_staking::Round::::mutate(|r| { + r.length = ::DefaultBlocksPerRound::get(); + r.first = now; + }); + reads += 1; + writes += 1; + + // --- score-staking RoundConfig.interval + Round.start_block --- + // Take the (now-doubled) default interval but keep the chain's existing stake coefficients + // (mainnet uses m=3, Paseo m=2 — both differ from the default, so only `interval` is reset). + let default_interval = pallet_score_staking::DefaultRoundSetting::::get().interval; + pallet_score_staking::RoundConfig::::mutate(|c| { + c.interval = default_interval; + }); + pallet_score_staking::Round::::mutate(|r| { + r.start_block = now; + }); + reads += 2; + writes += 2; + + // --- scheduler Agenda: rebase anonymous, non-periodic future tasks only --- + let agenda_keys: Vec> = + pallet_scheduler::Agenda::::iter_keys().collect(); + for when in agenda_keys { + reads += 1; + if when <= now { + continue; + } + let new_when = rebase_future(now, when); + if new_when == when { + continue; + } + let agenda = pallet_scheduler::Agenda::::take(when); + writes += 1; + // Merge into the destination block (it is virtually always empty). + pallet_scheduler::Agenda::::mutate(new_when, |dest| { + for slot in agenda.into_iter() { + // Push best-effort; if the destination block is somehow full the task is + // dropped rather than panicking — try-runtime asserts the agenda is tiny. + let _ = dest.try_push(slot); + } + }); + writes += 1; + } + + T::DbWeight::get().reads_writes(reads, writes) + } + + #[cfg(feature = "try-runtime")] + fn pre_upgrade() -> Result, sp_runtime::TryRuntimeError> { + // Snapshot the total number of scheduled slots so we can assert the rebase neither drops nor + // duplicates any task. (The `Scheduled` struct's fields are pallet-private, so we cannot + // inspect named/periodic-ness here; the rebase relocates opaque slots wholesale, which is + // correct for anonymous non-periodic tasks — the only kind on these chains. Named/periodic + // tasks would also need their private `Lookup`/period rebased, which this migration does not + // do; that pre-condition is verified out-of-band via RPC before deployment.) + let slot_count: u32 = pallet_scheduler::Agenda::::iter() + .map(|(_, a)| a.into_iter().flatten().count() as u32) + .sum(); + Ok(slot_count.encode()) + } + + #[cfg(feature = "try-runtime")] + fn post_upgrade(state: Vec) -> Result<(), sp_runtime::TryRuntimeError> { + use parity_scale_codec::Decode; + let before = u32::decode(&mut &state[..]).map_err(|_| "bad pre_upgrade state")?; + let after: u32 = pallet_scheduler::Agenda::::iter() + .map(|(_, a)| a.into_iter().flatten().count() as u32) + .sum(); + frame_support::ensure!(before == after, "scheduler slot count changed during rebase"); + + let round = pallet_parachain_staking::Round::::get(); + frame_support::ensure!( + round.length == ::DefaultBlocksPerRound::get(), + "parachain-staking Round.length not reset" + ); + Ok(()) + } +} diff --git a/parachain/runtime/paseo/src/migration/block_time_6s/vesting.rs b/parachain/runtime/paseo/src/migration/block_time_6s/vesting.rs new file mode 100644 index 0000000000..4ad6cbab04 --- /dev/null +++ b/parachain/runtime/paseo/src/migration/block_time_6s/vesting.rs @@ -0,0 +1,284 @@ +// Copyright 2020-2025 Trust Computing GmbH. +// This file is part of Litentry. +// +// Litentry is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// Litentry is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with Litentry. If not, see . + +//! Multi-block migration that rescales every `pallet_vesting` schedule when the chain block time +//! is halved from 12s to 6s. +//! +//! `VestingInfo` stores `{ locked, per_block, starting_block }`. `per_block` is a *per-block* drip +//! rate and `starting_block` is an *absolute* block. Once blocks arrive twice as fast in wall-clock +//! terms, an unmigrated schedule would finish vesting in **half** the intended real time. We rescale +//! so the *remaining* lock drains over the same wall-clock duration as before: +//! +//! Anchored at the upgrade block `now`: +//! * For a schedule that has **already started** (`starting_block <= now`): +//! - `still_locked = locked_at(now)` (funds already vested stay vested), +//! - new schedule `{ locked: still_locked, per_block: old_per_block / 2, starting_block: now }`. +//! At half the drip rate, `still_locked` now drains over ~2x the blocks => same wall-clock. +//! * For a schedule that has **not started yet** (`starting_block > now`): +//! - keep `locked`, halve `per_block`, and push the start out proportionally: +//! `starting_block' = now + 2 * (starting_block - now)`. +//! +//! `per_block` is clamped to `>= 1` so a schedule can never become non-terminating, and any +//! schedule that is already fully vested at `now` is dropped (its tokens are unlocked anyway). +//! +//! This is a `SteppedMigration` (registered via `pallet_migrations::Config::Migrations`) because the +//! `Vesting` map is large on mainnet (~2770 accounts) and a single-block pass could exceed block +//! weight. + +extern crate alloc; +use alloc::vec::Vec; +use core::marker::PhantomData; +use frame_support::{ + migrations::{SteppedMigration, SteppedMigrationError}, + pallet_prelude::*, + weights::WeightMeter, +}; +use frame_system::pallet_prelude::BlockNumberFor; +use pallet_vesting::{Vesting, VestingInfo}; +use sp_runtime::{traits::Zero, Saturating}; + +/// Unique identifier for this migration. Bump the version byte if it ever needs to re-run. +const PALLET_MIGRATION_ID: &[u8; 18] = b"vesting-rescale-6s"; + +type BalanceOf = <::Currency as frame_support::traits::Currency< + ::AccountId, +>>::Balance; + +pub struct VestingRescaleMigration(PhantomData); + +impl VestingRescaleMigration +where + T: pallet_vesting::Config, + BalanceOf: + Saturating + Copy + Zero + PartialOrd + core::ops::Div> + From, +{ + /// Rescale a single account's schedule list. Returns `None` when, after rescaling, the account + /// no longer has any live schedule (everything was fully vested). + fn rescale_account( + now: BlockNumberFor, + schedules: BoundedVec< + VestingInfo, BlockNumberFor>, + pallet_vesting::MaxVestingSchedulesGet, + >, + ) -> Option< + BoundedVec< + VestingInfo, BlockNumberFor>, + pallet_vesting::MaxVestingSchedulesGet, + >, + > { + let mut out: Vec, BlockNumberFor>> = Vec::new(); + + for s in schedules.into_iter() { + // `locked_at` needs the runtime's `BlockNumberToBalance` converter; compute it here and + // hand the result to the pure rescale helper so the arithmetic is independently testable. + let still_locked = + s.locked_at::<::BlockNumberToBalance>(now); + if let Some(info) = rescale_schedule::, BlockNumberFor>( + now, + s.locked(), + s.per_block(), + s.starting_block(), + still_locked, + ) { + out.push(info); + } + } + + if out.is_empty() { + None + } else { + // The list came from a `BoundedVec` of the same bound and we never grow it, so this + // `try_from` cannot fail. + Some(BoundedVec::try_from(out).expect("rescaled list never exceeds the original bound")) + } + } +} + +impl SteppedMigration for VestingRescaleMigration +where + T: pallet_vesting::Config, + BalanceOf: + Saturating + Copy + Zero + PartialOrd + core::ops::Div> + From, +{ + type Cursor = T::AccountId; + type Identifier = MigrationId<18>; + + fn id() -> Self::Identifier { + MigrationId { pallet_id: *PALLET_MIGRATION_ID, version_from: 0, version_to: 1 } + } + + fn step( + mut cursor: Option, + meter: &mut WeightMeter, + ) -> Result, SteppedMigrationError> { + // One read + one write per account; require enough headroom for at least one account so the + // migration can always make progress. + let required = T::DbWeight::get().reads_writes(1, 1); + if meter.remaining().any_lt(required) { + return Err(SteppedMigrationError::InsufficientWeight { required }); + } + + let now = frame_system::Pallet::::block_number(); + + loop { + if meter.try_consume(required).is_err() { + // Out of weight for this block; resume from `cursor` next block. + return Ok(cursor); + } + + // Resume just past the last processed account, else start at the top of the map. + let mut iter = match &cursor { + Some(last) => Vesting::::iter_from(Vesting::::hashed_key_for(last)), + None => Vesting::::iter(), + }; + + match iter.next() { + Some((account, schedules)) => { + match Self::rescale_account(now, schedules) { + Some(new_schedules) => Vesting::::insert(&account, new_schedules), + None => Vesting::::remove(&account), + } + cursor = Some(account); + }, + // Reached the end of the map: migration complete. + None => return Ok(None), + } + } + } + + #[cfg(feature = "try-runtime")] + fn pre_upgrade() -> Result, sp_runtime::TryRuntimeError> { + let count = Vesting::::iter().count() as u32; + Ok(count.encode()) + } + + #[cfg(feature = "try-runtime")] + fn post_upgrade(_state: Vec) -> Result<(), sp_runtime::TryRuntimeError> { + // Every remaining schedule must be valid (non-zero locked & per_block). + for (_who, schedules) in Vesting::::iter() { + for s in schedules.into_iter() { + frame_support::ensure!(s.is_valid(), "vesting schedule invalid after rescale"); + } + } + Ok(()) + } +} + +/// A small, self-describing identifier so two block-time migrations can never collide. +#[derive(MaxEncodedLen, Encode, Decode)] +pub struct MigrationId { + pub pallet_id: [u8; N], + pub version_from: u8, + pub version_to: u8, +} + +/// Pure rescale of a single vesting schedule for the 12s -> 6s block-time change. +/// +/// `still_locked` must be the schedule's `locked_at(now)` (computed by the caller using the +/// runtime's `BlockNumberToBalance`). Returns the new schedule, or `None` if it is already fully +/// vested at `now` (in which case the schedule is dropped — its funds are unlocked anyway). +/// +/// * Already started (`starting_block <= now`): keep already-vested funds vested; the remaining +/// `still_locked` drains from `now` at half the old rate, i.e. over ~2x the blocks => same +/// wall-clock. +/// * Not started yet (`starting_block > now`): keep `locked`, halve the rate, and push the start +/// out proportionally so it still begins at the same wall-clock moment. +/// +/// `per_block` is clamped to `>= 1` so the schedule can never become non-terminating. +fn rescale_schedule( + now: BlockNumber, + locked: Balance, + per_block: Balance, + starting_block: BlockNumber, + still_locked: Balance, +) -> Option> +where + Balance: sp_runtime::traits::AtLeast32BitUnsigned + Copy, + BlockNumber: sp_runtime::traits::AtLeast32BitUnsigned + Copy + sp_runtime::traits::Bounded, +{ + let halved_rate = per_block / Balance::from(2u32); + let new_per_block = if halved_rate.is_zero() { Balance::from(1u32) } else { halved_rate }; + + let info = if starting_block > now { + // Not started yet: delay the start proportionally, keep the full locked amount. + let delay = starting_block.saturating_sub(now); + let new_start = now.saturating_add(delay.saturating_mul(2u32.into())); + VestingInfo::new(locked, new_per_block, new_start) + } else { + // Already vesting: rebase the still-locked remainder to start draining from `now`. + if still_locked.is_zero() { + return None; + } + VestingInfo::new(still_locked, new_per_block, now) + }; + + if info.is_valid() { + Some(info) + } else { + None + } +} + +#[cfg(test)] +mod tests { + use super::rescale_schedule; + + // Balance = u128, BlockNumber = u32 (matches the heima runtime). + type Info = pallet_vesting::VestingInfo; + + fn rescale( + now: u32, + locked: u128, + per_block: u128, + starting_block: u32, + still_locked: u128, + ) -> Option { + rescale_schedule::(now, locked, per_block, starting_block, still_locked) + } + + #[test] + fn already_vesting_halves_rate_and_rebases_to_now() { + // 1000 locked, 10/block from block 0; at now=40, 400 vested, 600 still locked. + let out = rescale(40, 1000, 10, 0, 600).expect("still vesting"); + assert_eq!(out.locked(), 600, "only the still-locked remainder carries over"); + assert_eq!(out.per_block(), 5, "drip rate halved"); + assert_eq!(out.starting_block(), 40, "rebased to the upgrade block"); + // Same wall-clock: old remaining 600/10 = 60 blocks @12s; new 600/5 = 120 blocks @6s. + } + + #[test] + fn not_started_delays_start_proportionally() { + // Starts at block 100, now is 40 => 60 blocks away. After: 40 + 2*60 = 160. + let out = rescale(40, 1000, 10, 100, 1000).expect("not fully vested"); + assert_eq!(out.locked(), 1000, "full amount preserved before start"); + assert_eq!(out.per_block(), 5, "drip rate halved"); + assert_eq!(out.starting_block(), 160, "start pushed out so wall-clock start is unchanged"); + } + + #[test] + fn fully_vested_is_dropped() { + // now past the end; nothing still locked. + assert!(rescale(1000, 1000, 10, 0, 0).is_none()); + } + + #[test] + fn per_block_clamped_to_at_least_one() { + // per_block = 1 halves to 0 -> clamped to 1 so the schedule still terminates. + let out = rescale(0, 100, 1, 0, 100).expect("still vesting"); + assert_eq!(out.per_block(), 1, "never drops below 1"); + assert!(out.is_valid()); + } +} diff --git a/parachain/runtime/paseo/src/migration/mod.rs b/parachain/runtime/paseo/src/migration/mod.rs index 20182fa371..9bfaf1361d 100644 --- a/parachain/runtime/paseo/src/migration/mod.rs +++ b/parachain/runtime/paseo/src/migration/mod.rs @@ -13,3 +13,5 @@ // // You should have received a copy of the GNU General Public License // along with Litentry. If not, see . + +pub mod block_time_6s; From 2fb9930bfe22349960c76172e56f337083779b4b Mon Sep 17 00:00:00 2001 From: Kailai-Wang <7630809+Kailai-Wang@users.noreply.github.com> Date: Mon, 22 Jun 2026 19:27:07 +0000 Subject: [PATCH 2/6] fix(runtime): make block-time-6s OnePassRescale idempotent try-runtime on-runtime-upgrade runs each migration twice to verify idempotency; the scheduler/staking rebases in OnePassRescale changed the storage root on the second run (a rebased scheduler task would be pushed out again). Guard the whole migration with a once-only storage marker (twox_128("BlockTime6s") ++ twox_128("OnePassRescaleDone")) so re-execution is a no-op. The vesting MBM is already run-once via pallet_migrations. Verified with try-runtime on-runtime-upgrade --checks=all against live heima mainnet and Paseo state: storage root now identical across the idempotency re-run, MBM completes, and all pallet try-state checks pass. --- .../src/migration/block_time_6s/onepass.rs | 28 +++++++++++++++++-- .../src/migration/block_time_6s/onepass.rs | 28 +++++++++++++++++-- 2 files changed, 52 insertions(+), 4 deletions(-) diff --git a/parachain/runtime/heima/src/migration/block_time_6s/onepass.rs b/parachain/runtime/heima/src/migration/block_time_6s/onepass.rs index eea6e4e099..637ab0cf8e 100644 --- a/parachain/runtime/heima/src/migration/block_time_6s/onepass.rs +++ b/parachain/runtime/heima/src/migration/block_time_6s/onepass.rs @@ -39,6 +39,7 @@ extern crate alloc; use alloc::vec::Vec; use core::marker::PhantomData; use frame_support::{ + storage::unhashed, traits::{Get, OnRuntimeUpgrade}, weights::Weight, }; @@ -47,6 +48,18 @@ use frame_system::pallet_prelude::BlockNumberFor; use parity_scale_codec::Encode; use sp_runtime::Saturating; +/// Storage key for the once-only guard. The rebases below are **not** naturally idempotent +/// (re-rebasing a scheduler task would push it out again), so we record completion under this key +/// and short-circuit on any subsequent execution. Derived as +/// `twox_128("BlockTime6s") ++ twox_128("OnePassRescaleDone")` — a regular pallet-style storage +/// prefix that cannot collide with any real pallet (no pallet is named `BlockTime6s`). +fn done_key() -> [u8; 32] { + let mut key = [0u8; 32]; + key[..16].copy_from_slice(&sp_core::hashing::twox_128(b"BlockTime6s")); + key[16..].copy_from_slice(&sp_core::hashing::twox_128(b"OnePassRescaleDone")); + key +} + /// Rebase an absolute future block `b` so the same number of *remaining* blocks, at 2x speed, take /// the same wall-clock time: `b' = now + 2 * (b - now)`. Past blocks are left untouched. fn rebase_future(now: B, b: B) -> B @@ -70,9 +83,16 @@ where + pallet_scheduler::Config, { fn on_runtime_upgrade() -> Weight { + let key = done_key(); + // Idempotency guard: the rebases below are not safe to apply twice, so run at most once. + if unhashed::get_raw(&key).is_some() { + log::info!("OnePassRescale: already applied, skipping"); + return T::DbWeight::get().reads(1); + } + let now = frame_system::Pallet::::block_number(); - let mut reads: u64 = 0; - let mut writes: u64 = 0; + let mut reads: u64 = 1; + let mut writes: u64 = 1; // --- parachain-staking Round --- pallet_parachain_staking::Round::::mutate(|r| { @@ -120,6 +140,10 @@ where writes += 1; } + // Mark complete so a re-execution is a no-op (idempotency). + unhashed::put_raw(&key, &[1u8]); + writes += 1; + T::DbWeight::get().reads_writes(reads, writes) } diff --git a/parachain/runtime/paseo/src/migration/block_time_6s/onepass.rs b/parachain/runtime/paseo/src/migration/block_time_6s/onepass.rs index eea6e4e099..637ab0cf8e 100644 --- a/parachain/runtime/paseo/src/migration/block_time_6s/onepass.rs +++ b/parachain/runtime/paseo/src/migration/block_time_6s/onepass.rs @@ -39,6 +39,7 @@ extern crate alloc; use alloc::vec::Vec; use core::marker::PhantomData; use frame_support::{ + storage::unhashed, traits::{Get, OnRuntimeUpgrade}, weights::Weight, }; @@ -47,6 +48,18 @@ use frame_system::pallet_prelude::BlockNumberFor; use parity_scale_codec::Encode; use sp_runtime::Saturating; +/// Storage key for the once-only guard. The rebases below are **not** naturally idempotent +/// (re-rebasing a scheduler task would push it out again), so we record completion under this key +/// and short-circuit on any subsequent execution. Derived as +/// `twox_128("BlockTime6s") ++ twox_128("OnePassRescaleDone")` — a regular pallet-style storage +/// prefix that cannot collide with any real pallet (no pallet is named `BlockTime6s`). +fn done_key() -> [u8; 32] { + let mut key = [0u8; 32]; + key[..16].copy_from_slice(&sp_core::hashing::twox_128(b"BlockTime6s")); + key[16..].copy_from_slice(&sp_core::hashing::twox_128(b"OnePassRescaleDone")); + key +} + /// Rebase an absolute future block `b` so the same number of *remaining* blocks, at 2x speed, take /// the same wall-clock time: `b' = now + 2 * (b - now)`. Past blocks are left untouched. fn rebase_future(now: B, b: B) -> B @@ -70,9 +83,16 @@ where + pallet_scheduler::Config, { fn on_runtime_upgrade() -> Weight { + let key = done_key(); + // Idempotency guard: the rebases below are not safe to apply twice, so run at most once. + if unhashed::get_raw(&key).is_some() { + log::info!("OnePassRescale: already applied, skipping"); + return T::DbWeight::get().reads(1); + } + let now = frame_system::Pallet::::block_number(); - let mut reads: u64 = 0; - let mut writes: u64 = 0; + let mut reads: u64 = 1; + let mut writes: u64 = 1; // --- parachain-staking Round --- pallet_parachain_staking::Round::::mutate(|r| { @@ -120,6 +140,10 @@ where writes += 1; } + // Mark complete so a re-execution is a no-op (idempotency). + unhashed::put_raw(&key, &[1u8]); + writes += 1; + T::DbWeight::get().reads_writes(reads, writes) } From 34714a50e3ae05ee5f1b96378f7a9adc5038e5af Mon Sep 17 00:00:00 2001 From: Kailai-Wang <7630809+Kailai-Wang@users.noreply.github.com> Date: Mon, 22 Jun 2026 19:40:31 +0000 Subject: [PATCH 3/6] ci: fix try-runtime workflow for parachain/ workspace layout The `try-runtime.yml` workflow used `paritytech/try-runtime-gha`, which runs `cargo` from the repo root and fails here with "could not find Cargo.toml" because the workspace lives under `parachain/`. The action has no working-directory input. Replace it with a self-contained build+run that `cd parachain` first, mirroring the working try-runtime job in `check-runtime-upgrade.yml`: install the rust toolchain, download the try-runtime CLI, build the runtime with --features try-runtime, then run `on-runtime-upgrade --checks=all --blocktime 6000` against the live URIs in .github/runtime.json. Also add a path-filtered `pull_request` trigger so runtime/primitives/pallets changes get try-runtime coverage automatically, not only on manual dispatch. check-runtime-upgrade.yml: bump TRYRUNTIME_VERSION 0.8.0 -> 0.10.1 and pass the block time via a BLOCKTIME=6000 env var instead of the hard-coded 12000 (blocks are 6s after the spec 9270 upgrade). --- .github/workflows/check-runtime-upgrade.yml | 6 +- .github/workflows/try-runtime.yml | 65 ++++++++++++++++++--- 2 files changed, 62 insertions(+), 9 deletions(-) diff --git a/.github/workflows/check-runtime-upgrade.yml b/.github/workflows/check-runtime-upgrade.yml index 56626b8934..f719793c66 100644 --- a/.github/workflows/check-runtime-upgrade.yml +++ b/.github/workflows/check-runtime-upgrade.yml @@ -17,7 +17,9 @@ on: env: SUBWASM_VERSION: 0.21.3 - TRYRUNTIME_VERSION: 0.8.0 + TRYRUNTIME_VERSION: 0.10.1 + # Chain block time in milliseconds (6s since the 12s -> 6s upgrade at spec_version 9270). + BLOCKTIME: 6000 RELEASE_TAG: ${{ github.event.inputs.release_tag || github.event.release.tag_name }} jobs: @@ -155,7 +157,7 @@ jobs: COMMAND="./try-runtime \ --runtime $RUNTIME_BLOB_PATH \ - on-runtime-upgrade --blocktime 12000 \ + on-runtime-upgrade --blocktime ${{ env.BLOCKTIME }} \ live --uri ${{ matrix.runtime.uri }}" echo "Running command:" diff --git a/.github/workflows/try-runtime.yml b/.github/workflows/try-runtime.yml index 93184198de..40bea1cc42 100644 --- a/.github/workflows/try-runtime.yml +++ b/.github/workflows/try-runtime.yml @@ -1,12 +1,37 @@ name: Check try-runtime +# Runs `try-runtime on-runtime-upgrade --checks=all` for each runtime against live chain state, +# exercising the runtime's migration `pre_upgrade`/`on_runtime_upgrade`/`post_upgrade` hooks and the +# `try_state` checks of every pallet. This is the canonical pre-merge check that a runtime upgrade +# (and any storage migration it carries) applies cleanly, is idempotent, and preserves invariants. +# +# NOTE: this repo's cargo workspace lives under `parachain/`, so we build and run try-runtime +# ourselves (the `paritytech/try-runtime-gha` action assumes a repo-root manifest and cannot find +# `parachain/Cargo.toml`). The build/run steps mirror the `try-runtime` job in +# `check-runtime-upgrade.yml`. + on: workflow_dispatch: + pull_request: + branches: + - dev + paths: + - "parachain/runtime/**" + - "parachain/primitives/**" + - "parachain/pallets/**" + - ".github/runtime.json" + - ".github/workflows/try-runtime.yml" concurrency: group: ${{ github.workflow }}-${{ github.ref }} cancel-in-progress: true +env: + # try-runtime CLI version. Must support `on-runtime-upgrade --blocktime`. + TRYRUNTIME_VERSION: 0.10.1 + # New (post-upgrade) block time in milliseconds; used for weight/PoV estimation. + BLOCKTIME: 6000 + jobs: runtime-matrix: runs-on: ubuntu-22.04 @@ -38,11 +63,37 @@ jobs: - uses: actions/checkout@v6 with: fetch-depth: 0 - + + - name: Install rust toolchain + run: rustup show + + - name: Install dependencies + run: | + sudo apt-get update + sudo apt-get install -yq protobuf-compiler + + - name: Download try-runtime-cli + run: | + curl -sL https://github.com/paritytech/try-runtime-cli/releases/download/v${{ env.TRYRUNTIME_VERSION }}/try-runtime-x86_64-unknown-linux-musl -o try-runtime + chmod +x ./try-runtime + mv try-runtime parachain + + - name: Build ${{ matrix.runtime.name }} runtime + run: | + cd parachain + cargo build --profile production -p ${{ matrix.runtime.package }} --features try-runtime -q --locked + - name: Run ${{ matrix.runtime.name }} try-runtime check - uses: paritytech/try-runtime-gha@v0.2.0 - with: - runtime-package: ${{ matrix.runtime.package }} - node-uri: ${{ matrix.runtime.uri }} - checks: "all" - extra-args: "" \ No newline at end of file + run: | + cd parachain + PACKAGE_NAME=${{ matrix.runtime.package }} + RUNTIME_BLOB_NAME=$(echo $PACKAGE_NAME | sed 's/-/_/g').compact.compressed.wasm + RUNTIME_BLOB_PATH=./target/production/wbuild/$PACKAGE_NAME/$RUNTIME_BLOB_NAME + + export RUST_LOG=remote-ext=info,runtime=info + + ./try-runtime \ + --runtime "$RUNTIME_BLOB_PATH" \ + on-runtime-upgrade --checks=all --blocktime ${{ env.BLOCKTIME }} \ + live --uri ${{ matrix.runtime.uri }} + shell: bash From ac31e689e443d3db764cea288a18cba745597929 Mon Sep 17 00:00:00 2001 From: Kailai-Wang <7630809+Kailai-Wang@users.noreply.github.com> Date: Mon, 22 Jun 2026 19:42:00 +0000 Subject: [PATCH 4/6] ci: keep try-runtime manual-dispatch only Drop the pull_request trigger added in the previous commit. try-runtime compiles both runtimes from scratch and scrapes full live chain state (~10+ min per runtime), which is too heavy to run on every runtime PR. Keep workflow_dispatch so it can be run on demand on a feature branch when a migration warrants it. The parachain/ workspace-layout fix is retained. --- .github/workflows/try-runtime.yml | 12 +++--------- 1 file changed, 3 insertions(+), 9 deletions(-) diff --git a/.github/workflows/try-runtime.yml b/.github/workflows/try-runtime.yml index 40bea1cc42..91ef4c6d55 100644 --- a/.github/workflows/try-runtime.yml +++ b/.github/workflows/try-runtime.yml @@ -10,17 +10,11 @@ name: Check try-runtime # `parachain/Cargo.toml`). The build/run steps mirror the `try-runtime` job in # `check-runtime-upgrade.yml`. +# Manual trigger only: try-runtime compiles both runtimes from scratch and scrapes full live chain +# state (~10+ min), which is too heavy to run on every PR. Dispatch it on a feature branch when a +# runtime change carries a migration or otherwise warrants pre-merge migration testing. on: workflow_dispatch: - pull_request: - branches: - - dev - paths: - - "parachain/runtime/**" - - "parachain/primitives/**" - - "parachain/pallets/**" - - ".github/runtime.json" - - ".github/workflows/try-runtime.yml" concurrency: group: ${{ github.workflow }}-${{ github.ref }} From cda476bf7f2a764f890346aa51fa3a0687892ce0 Mon Sep 17 00:00:00 2001 From: Kailai-Wang <7630809+Kailai-Wang@users.noreply.github.com> Date: Mon, 22 Jun 2026 19:47:23 +0000 Subject: [PATCH 5/6] ci: derive try-runtime block time from the runtime under test Both try-runtime jobs passed a hard-coded --blocktime (6000), which is wrong whenever the workflow runs against a different runtime: the old 12s runtime, or a future elastic-scaling target with a shorter block time. Read it dynamically instead. Aura's slot duration is twice `Timestamp::MinimumPeriod`, so subwasm extracts that constant (a little-endian u64, in ms) from the built wasm's metadata and the job uses 2 * MinimumPeriod as the block time. This tracks whatever runtime is being tested with no manual update. Fails loudly if the constant can't be parsed. try-runtime.yml now installs subwasm for this; check-runtime-upgrade.yml already had it, so its BLOCKTIME env is dropped. --- .github/workflows/check-runtime-upgrade.yml | 18 +++++++++++--- .github/workflows/try-runtime.yml | 26 ++++++++++++++++++--- 2 files changed, 38 insertions(+), 6 deletions(-) diff --git a/.github/workflows/check-runtime-upgrade.yml b/.github/workflows/check-runtime-upgrade.yml index f719793c66..e4143f5dad 100644 --- a/.github/workflows/check-runtime-upgrade.yml +++ b/.github/workflows/check-runtime-upgrade.yml @@ -18,8 +18,6 @@ on: env: SUBWASM_VERSION: 0.21.3 TRYRUNTIME_VERSION: 0.10.1 - # Chain block time in milliseconds (6s since the 12s -> 6s upgrade at spec_version 9270). - BLOCKTIME: 6000 RELEASE_TAG: ${{ github.event.inputs.release_tag || github.event.release.tag_name }} jobs: @@ -153,11 +151,25 @@ jobs: echo "Upgrade needed: $onchain_version -> $release_version" echo "Running migration checks..." + # Derive the block time from the runtime *being tested*, not a hard-coded value, so this + # works for any runtime (current 6s, old 12s, or a future elastic-scaling target). Aura's + # slot duration is twice `Timestamp::MinimumPeriod`; subwasm reads that constant (a + # little-endian u64, in ms) straight from the built wasm's metadata. + MIN_PERIOD=$(subwasm metadata "$RUNTIME_BLOB_PATH" --format json \ + | jq -r '[.. | objects | select(.name=="MinimumPeriod") | .value][0] + | to_entries | map(.value * pow(256; .key)) | add') + if ! [[ "$MIN_PERIOD" =~ ^[0-9]+$ ]] || [ "$MIN_PERIOD" -le 0 ]; then + echo "Failed to read Timestamp::MinimumPeriod from runtime metadata (got '$MIN_PERIOD')" + exit 1 + fi + BLOCKTIME=$((MIN_PERIOD * 2)) + echo "Derived block time: ${BLOCKTIME}ms (MinimumPeriod=${MIN_PERIOD}ms)" + export RUST_LOG=remote-ext=debug,runtime=debug COMMAND="./try-runtime \ --runtime $RUNTIME_BLOB_PATH \ - on-runtime-upgrade --blocktime ${{ env.BLOCKTIME }} \ + on-runtime-upgrade --blocktime $BLOCKTIME \ live --uri ${{ matrix.runtime.uri }}" echo "Running command:" diff --git a/.github/workflows/try-runtime.yml b/.github/workflows/try-runtime.yml index 91ef4c6d55..e3a95d9870 100644 --- a/.github/workflows/try-runtime.yml +++ b/.github/workflows/try-runtime.yml @@ -23,8 +23,8 @@ concurrency: env: # try-runtime CLI version. Must support `on-runtime-upgrade --blocktime`. TRYRUNTIME_VERSION: 0.10.1 - # New (post-upgrade) block time in milliseconds; used for weight/PoV estimation. - BLOCKTIME: 6000 + # subwasm is used to read the block time out of the built runtime (see the run step). + SUBWASM_VERSION: 0.21.3 jobs: runtime-matrix: @@ -77,6 +77,12 @@ jobs: cd parachain cargo build --profile production -p ${{ matrix.runtime.package }} --features try-runtime -q --locked + - name: Install subwasm ${{ env.SUBWASM_VERSION }} + run: | + wget https://github.com/chevdor/subwasm/releases/download/v${{ env.SUBWASM_VERSION }}/subwasm_linux_amd64_v${{ env.SUBWASM_VERSION }}.deb + sudo dpkg -i subwasm_linux_amd64_v${{ env.SUBWASM_VERSION }}.deb + subwasm --version + - name: Run ${{ matrix.runtime.name }} try-runtime check run: | cd parachain @@ -84,10 +90,24 @@ jobs: RUNTIME_BLOB_NAME=$(echo $PACKAGE_NAME | sed 's/-/_/g').compact.compressed.wasm RUNTIME_BLOB_PATH=./target/production/wbuild/$PACKAGE_NAME/$RUNTIME_BLOB_NAME + # Derive the block time from the runtime *being tested*, not a hard-coded value, so this + # works for any runtime (the current 6s, the old 12s, or a future elastic-scaling target). + # Aura's slot duration is twice `Timestamp::MinimumPeriod`; subwasm reads that constant + # (a little-endian u64, in ms) straight from the built wasm's metadata. + MIN_PERIOD=$(subwasm metadata "$RUNTIME_BLOB_PATH" --format json \ + | jq -r '[.. | objects | select(.name=="MinimumPeriod") | .value][0] + | to_entries | map(.value * pow(256; .key)) | add') + if ! [[ "$MIN_PERIOD" =~ ^[0-9]+$ ]] || [ "$MIN_PERIOD" -le 0 ]; then + echo "Failed to read Timestamp::MinimumPeriod from runtime metadata (got '$MIN_PERIOD')" + exit 1 + fi + BLOCKTIME=$((MIN_PERIOD * 2)) + echo "Derived block time: ${BLOCKTIME}ms (MinimumPeriod=${MIN_PERIOD}ms)" + export RUST_LOG=remote-ext=info,runtime=info ./try-runtime \ --runtime "$RUNTIME_BLOB_PATH" \ - on-runtime-upgrade --checks=all --blocktime ${{ env.BLOCKTIME }} \ + on-runtime-upgrade --checks=all --blocktime "$BLOCKTIME" \ live --uri ${{ matrix.runtime.uri }} shell: bash From f01765373f574f6e3a5951248302571245a46c8c Mon Sep 17 00:00:00 2001 From: Kailai-Wang <7630809+Kailai-Wang@users.noreply.github.com> Date: Mon, 22 Jun 2026 20:11:31 +0000 Subject: [PATCH 6/6] test(runtime): verify scheduled-burn rebase preserves wall-clock expiry Add integration tests for OnePassRescale that schedule a real anonymous, non-periodic balances.burn (the same shape as the live heima on-chain burn: a Root-scheduled batchAll of dispatchAs + balances.burn) and run the actual migration: - scheduled_burn_keeps_wall_clock_expiry_after_block_time_halving: a task 100_000 blocks out is rebased to now + 2*(when-now), nothing is dropped or duplicated, and old (blocks*12s) == new (blocks*6s) wall-clock fire time. - migration_is_idempotent: a second on_runtime_upgrade run does not rebase again (the once-only guard holds). Needs sp-io as a dev-dependency for TestExternalities (matches runtime-common). --- parachain/Cargo.lock | 2 + parachain/runtime/heima/Cargo.toml | 1 + .../src/migration/block_time_6s/onepass.rs | 105 ++++++++++++++++++ parachain/runtime/paseo/Cargo.toml | 1 + .../src/migration/block_time_6s/onepass.rs | 105 ++++++++++++++++++ 5 files changed, 214 insertions(+) diff --git a/parachain/Cargo.lock b/parachain/Cargo.lock index f2fdd6e4f7..e48e3b8e1c 100644 --- a/parachain/Cargo.lock +++ b/parachain/Cargo.lock @@ -4751,6 +4751,7 @@ dependencies = [ "sp-core", "sp-genesis-builder", "sp-inherents", + "sp-io", "sp-offchain", "sp-runtime", "sp-session", @@ -9567,6 +9568,7 @@ dependencies = [ "sp-core", "sp-genesis-builder", "sp-inherents", + "sp-io", "sp-offchain", "sp-runtime", "sp-session", diff --git a/parachain/runtime/heima/Cargo.toml b/parachain/runtime/heima/Cargo.toml index e0b2ef0234..5b096f73e5 100644 --- a/parachain/runtime/heima/Cargo.toml +++ b/parachain/runtime/heima/Cargo.toml @@ -122,6 +122,7 @@ polkadot-runtime-parachains = { workspace = true, features = ["std"] } runtime-common = { workspace = true, features = ["tests"] } xcm-simulator = { workspace = true } pallet-message-queue = { workspace = true, features = ["std"] } +sp-io = { workspace = true, features = ["std"] } [build-dependencies] substrate-wasm-builder = { workspace = true, optional = true } diff --git a/parachain/runtime/heima/src/migration/block_time_6s/onepass.rs b/parachain/runtime/heima/src/migration/block_time_6s/onepass.rs index 637ab0cf8e..474c3fa1bf 100644 --- a/parachain/runtime/heima/src/migration/block_time_6s/onepass.rs +++ b/parachain/runtime/heima/src/migration/block_time_6s/onepass.rs @@ -178,3 +178,108 @@ where Ok(()) } } + +#[cfg(test)] +mod tests { + use super::*; + use crate::{Runtime, RuntimeCall, RuntimeOrigin}; + use frame_support::traits::OnRuntimeUpgrade; + use sp_runtime::BuildStorage; + + fn new_test_ext() -> sp_io::TestExternalities { + let t = frame_system::GenesisConfig::::default().build_storage().unwrap(); + let mut ext = sp_io::TestExternalities::new(t); + ext.execute_with(|| frame_system::Pallet::::set_block_number(1)); + ext + } + + // Mirrors the real heima on-chain scheduled burn: an anonymous, non-periodic Root-origin call + // (there it is a `utility.batchAll([utility.dispatchAs(Signed(_), balances.burn{..})])`; the + // rebase treats the call opaquely, so a plain `balances.burn` exercises the same code path). + #[test] + fn scheduled_burn_keeps_wall_clock_expiry_after_block_time_halving() { + new_test_ext().execute_with(|| { + let now: crate::BlockNumber = 1_000; + frame_system::Pallet::::set_block_number(now); + + // Schedule a burn 100_000 blocks out — at the OLD 12s block time that is ~13.9 days. + let blocks_until_fire: crate::BlockNumber = 100_000; + let when = now + blocks_until_fire; + let burn = RuntimeCall::Balances(pallet_balances::Call::burn { + value: 1_000_000_000_000_000_000_000, + keep_alive: false, + }); + pallet_scheduler::Pallet::::schedule( + RuntimeOrigin::root(), + when, + None, // non-periodic + 0, + Box::new(burn), + ) + .expect("schedule should succeed"); + + // Sanity: exactly one slot, sitting at `when`, none at the rebased target yet. + assert_eq!(pallet_scheduler::Agenda::::iter().count(), 1); + let live = |b: crate::BlockNumber| { + pallet_scheduler::Agenda::::get(b) + .iter() + .filter(|s| s.is_some()) + .count() + }; + assert_eq!(live(when), 1); + + let rebased = now + 2 * blocks_until_fire; + assert_eq!(live(rebased), 0); + + // Run the migration (block time is now halved 12s -> 6s). + let _ = OnePassRescale::::on_runtime_upgrade(); + + // The task moved from `when` to `now + 2*(when-now)`: same number of slots, none left + // behind at the old block. + assert_eq!(live(when), 0, "old agenda slot must be cleared"); + assert_eq!(live(rebased), 1, "task must be rebased to now + 2*(when-now)"); + assert_eq!( + pallet_scheduler::Agenda::::iter() + .map(|(_, a)| a.iter().filter(|s| s.is_some()).count()) + .sum::(), + 1, + "no task dropped or duplicated" + ); + + // Wall-clock invariant: old (12s) and new (6s) fire at the same real-world time. + let old_secs = (when - now) as u64 * 12; + let new_secs = (rebased - now) as u64 * 6; + assert_eq!(old_secs, new_secs, "real-world expiry must be unchanged"); + }); + } + + #[test] + fn migration_is_idempotent() { + new_test_ext().execute_with(|| { + let now: crate::BlockNumber = 1_000; + frame_system::Pallet::::set_block_number(now); + let when = now + 50_000; + let burn = RuntimeCall::Balances(pallet_balances::Call::burn { + value: 1_000, + keep_alive: false, + }); + pallet_scheduler::Pallet::::schedule( + RuntimeOrigin::root(), + when, + None, + 0, + Box::new(burn), + ) + .unwrap(); + + OnePassRescale::::on_runtime_upgrade(); + let after_first: Vec<_> = pallet_scheduler::Agenda::::iter_keys().collect(); + + // Second run must be a no-op (guarded), leaving the rebased agenda untouched. + OnePassRescale::::on_runtime_upgrade(); + let after_second: Vec<_> = pallet_scheduler::Agenda::::iter_keys().collect(); + + assert_eq!(after_first, after_second, "second run must not rebase again"); + }); + } +} diff --git a/parachain/runtime/paseo/Cargo.toml b/parachain/runtime/paseo/Cargo.toml index 035b75b1a3..ac40568e54 100644 --- a/parachain/runtime/paseo/Cargo.toml +++ b/parachain/runtime/paseo/Cargo.toml @@ -129,6 +129,7 @@ polkadot-runtime-parachains = { workspace = true, features = ["std"] } runtime-common = { workspace = true, features = ["tests"] } xcm-simulator = { workspace = true } pallet-message-queue = { workspace = true, features = ["std"] } +sp-io = { workspace = true, features = ["std"] } [build-dependencies] substrate-wasm-builder = { workspace = true, optional = true } diff --git a/parachain/runtime/paseo/src/migration/block_time_6s/onepass.rs b/parachain/runtime/paseo/src/migration/block_time_6s/onepass.rs index 637ab0cf8e..474c3fa1bf 100644 --- a/parachain/runtime/paseo/src/migration/block_time_6s/onepass.rs +++ b/parachain/runtime/paseo/src/migration/block_time_6s/onepass.rs @@ -178,3 +178,108 @@ where Ok(()) } } + +#[cfg(test)] +mod tests { + use super::*; + use crate::{Runtime, RuntimeCall, RuntimeOrigin}; + use frame_support::traits::OnRuntimeUpgrade; + use sp_runtime::BuildStorage; + + fn new_test_ext() -> sp_io::TestExternalities { + let t = frame_system::GenesisConfig::::default().build_storage().unwrap(); + let mut ext = sp_io::TestExternalities::new(t); + ext.execute_with(|| frame_system::Pallet::::set_block_number(1)); + ext + } + + // Mirrors the real heima on-chain scheduled burn: an anonymous, non-periodic Root-origin call + // (there it is a `utility.batchAll([utility.dispatchAs(Signed(_), balances.burn{..})])`; the + // rebase treats the call opaquely, so a plain `balances.burn` exercises the same code path). + #[test] + fn scheduled_burn_keeps_wall_clock_expiry_after_block_time_halving() { + new_test_ext().execute_with(|| { + let now: crate::BlockNumber = 1_000; + frame_system::Pallet::::set_block_number(now); + + // Schedule a burn 100_000 blocks out — at the OLD 12s block time that is ~13.9 days. + let blocks_until_fire: crate::BlockNumber = 100_000; + let when = now + blocks_until_fire; + let burn = RuntimeCall::Balances(pallet_balances::Call::burn { + value: 1_000_000_000_000_000_000_000, + keep_alive: false, + }); + pallet_scheduler::Pallet::::schedule( + RuntimeOrigin::root(), + when, + None, // non-periodic + 0, + Box::new(burn), + ) + .expect("schedule should succeed"); + + // Sanity: exactly one slot, sitting at `when`, none at the rebased target yet. + assert_eq!(pallet_scheduler::Agenda::::iter().count(), 1); + let live = |b: crate::BlockNumber| { + pallet_scheduler::Agenda::::get(b) + .iter() + .filter(|s| s.is_some()) + .count() + }; + assert_eq!(live(when), 1); + + let rebased = now + 2 * blocks_until_fire; + assert_eq!(live(rebased), 0); + + // Run the migration (block time is now halved 12s -> 6s). + let _ = OnePassRescale::::on_runtime_upgrade(); + + // The task moved from `when` to `now + 2*(when-now)`: same number of slots, none left + // behind at the old block. + assert_eq!(live(when), 0, "old agenda slot must be cleared"); + assert_eq!(live(rebased), 1, "task must be rebased to now + 2*(when-now)"); + assert_eq!( + pallet_scheduler::Agenda::::iter() + .map(|(_, a)| a.iter().filter(|s| s.is_some()).count()) + .sum::(), + 1, + "no task dropped or duplicated" + ); + + // Wall-clock invariant: old (12s) and new (6s) fire at the same real-world time. + let old_secs = (when - now) as u64 * 12; + let new_secs = (rebased - now) as u64 * 6; + assert_eq!(old_secs, new_secs, "real-world expiry must be unchanged"); + }); + } + + #[test] + fn migration_is_idempotent() { + new_test_ext().execute_with(|| { + let now: crate::BlockNumber = 1_000; + frame_system::Pallet::::set_block_number(now); + let when = now + 50_000; + let burn = RuntimeCall::Balances(pallet_balances::Call::burn { + value: 1_000, + keep_alive: false, + }); + pallet_scheduler::Pallet::::schedule( + RuntimeOrigin::root(), + when, + None, + 0, + Box::new(burn), + ) + .unwrap(); + + OnePassRescale::::on_runtime_upgrade(); + let after_first: Vec<_> = pallet_scheduler::Agenda::::iter_keys().collect(); + + // Second run must be a no-op (guarded), leaving the rebased agenda untouched. + OnePassRescale::::on_runtime_upgrade(); + let after_second: Vec<_> = pallet_scheduler::Agenda::::iter_keys().collect(); + + assert_eq!(after_first, after_second, "second run must not rebase again"); + }); + } +}