From aa5fe2275705d33ff160d26bf6a80b3e2d80e8de Mon Sep 17 00:00:00 2001 From: Nikolaus Heger Date: Wed, 2 Sep 2026 00:09:04 +0800 Subject: [PATCH 1/3] feat: select the governance track for runtime authorization MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Runtime upgrades always go through System::authorize_upgrade + permissionless apply_authorized_upgrade; only the referendum's proposal origin differs. That origin was hardcoded to Origins::FastUpgrade, so the CLI could not upgrade a runtime older than the fast_upgrade track — the Origins enum does not exist in its metadata, so the dynamic payload fails to encode. Add --track {fast-upgrade,root} to runtime update and to tech-referenda submit and submit-with-preimage, defaulting to fast-upgrade so existing usage is unchanged. root dispatches as system::Root on the tech_collective_members track, which every runtime has. Also drop the exercise suite's system.set_code path: a code blob exceeds TechReferenda::MaxProposalSize and can never be a real proposal, so the real upgrade scenario now authorizes the hash and applies the blob exactly as production does. --- src/cli/exercise/mod.rs | 6 +- src/cli/exercise/scenarios/upgrade.rs | 83 ++++++++++++++++------- src/cli/runtime.rs | 95 +++++++++++++++++++++------ src/cli/tech_referenda.rs | 48 ++++++++++---- 4 files changed, 173 insertions(+), 59 deletions(-) diff --git a/src/cli/exercise/mod.rs b/src/cli/exercise/mod.rs index 4f09e4d..4205908 100644 --- a/src/cli/exercise/mod.rs +++ b/src/cli/exercise/mod.rs @@ -215,16 +215,16 @@ pub async fn handle_exercise_command(args: ExerciseArgs, node_url: &str) -> Resu if selected.contains(&Phase::Upgrade) && !report.should_abort() { let mode = match args.upgrade_wasm.clone() { - Some(wasm) => scenarios::upgrade::UpgradeMode::SetCode(wasm), + Some(wasm) => scenarios::upgrade::UpgradeMode::Authorize(wasm), None => scenarios::upgrade::UpgradeMode::SelfNoop, }; - let is_real_upgrade = matches!(mode, scenarios::upgrade::UpgradeMode::SetCode(_)); + let is_real_upgrade = matches!(mode, scenarios::upgrade::UpgradeMode::Authorize(_)); let before = ctx.free_balance(&ctx.root_ss58).await?; scenarios::upgrade::run(&mut ctx, &mut report, "upgrade", mode, args.upgrade_timeout_secs) .await?; note_exempt_spend(&mut ctx, before).await?; - // Only a real set_code upgrade changes the runtime; the self-upgrade + // Only a real authorized upgrade changes the runtime; the self-upgrade // no-op re-installs the same blob, so a post-upgrade re-run would just // burn time without covering any new code path. if is_real_upgrade { diff --git a/src/cli/exercise/scenarios/upgrade.rs b/src/cli/exercise/scenarios/upgrade.rs index 563dafd..72c9eda 100644 --- a/src/cli/exercise/scenarios/upgrade.rs +++ b/src/cli/exercise/scenarios/upgrade.rs @@ -1,17 +1,19 @@ //! Runtime upgrade via tech-referenda (requires fast-governance node). //! +//! Both modes drive the production upgrade pipeline: a referendum approves a 34-byte +//! `system.authorize_upgrade` hash and the full code is then supplied by anyone via +//! `system.apply_authorized_upgrade`. `system.set_code` is not used — the code blob +//! exceeds `TechReferenda::MaxProposalSize`, so it could never be a real proposal. +//! //! Two modes: -//! - `SetCode(wasm)`: a real upgrade via `system.set_code`; requires a candidate WASM with a higher -//! `spec_version` and verifies the spec bump. +//! - `Authorize(wasm)`: a real upgrade via `system.authorize_upgrade`; requires a candidate WASM +//! with a higher `spec_version` and verifies the spec bump. //! - `SelfNoop`: fetches the current on-chain `:code` and re-installs it. //! `frame_system::can_set_code` would reject the identical blob (`SpecVersionNeedsToIncrease`), -//! so the unchecked variants are used; the version-comparison logic itself is trivial and not -//! worth building a bumped WASM for. The code blob exceeds `TechReferenda::MaxProposalSize`, so -//! the referendum approves a 32-byte `system.authorize_upgrade_without_checks` hash and the full -//! code is then supplied by anyone via `system.apply_authorized_upgrade`. This proves the whole -//! upgrade pipeline — referendum with Root origin, enactment, authorization, code storage write — -//! and success is detected via the `System::CodeUpdated` event since the spec version stays the -//! same. +//! so `authorize_upgrade_without_checks` is used instead; the version-comparison logic itself is +//! trivial and not worth building a bumped WASM for. This proves the whole upgrade pipeline — +//! referendum with Root origin, enactment, authorization, code storage write — and success is +//! detected via the `System::CodeUpdated` event since the spec version stays the same. use crate::{ chain::quantus_subxt, @@ -27,8 +29,9 @@ use std::path::PathBuf; use subxt::tx::Payload; pub enum UpgradeMode { - /// Real upgrade: `system.set_code` with the given candidate WASM. - SetCode(PathBuf), + /// Real upgrade: `system.authorize_upgrade` for the candidate WASM's hash, + /// then `apply_authorized_upgrade` with the blob itself. + Authorize(PathBuf), /// No-op self-upgrade: re-install the current on-chain code via /// `authorize_upgrade_without_checks` + `apply_authorized_upgrade`. SelfNoop, @@ -42,12 +45,12 @@ pub async fn run( timeout_secs: u64, ) -> Result<()> { match mode { - UpgradeMode::SetCode(wasm_path) => { + UpgradeMode::Authorize(wasm_path) => { exercise_step!( report, phase, - "governance_set_code", - governance_set_code(ctx, &wasm_path, timeout_secs) + "governance_authorize_upgrade", + governance_authorize_upgrade(ctx, &wasm_path, timeout_secs) ); }, UpgradeMode::SelfNoop => { @@ -181,7 +184,7 @@ async fn referendum_state(ctx: &ExerciseCtx, index: u32) -> Result { Ok(format!("{info:?}")) } -async fn governance_set_code( +async fn governance_authorize_upgrade( ctx: &mut ExerciseCtx, wasm_path: &std::path::Path, timeout_secs: u64, @@ -191,35 +194,65 @@ async fn governance_set_code( let wasm = std::fs::read(wasm_path).map_err(|e| { QuantusError::Generic(format!("failed to read WASM {}: {e}", wasm_path.display())) })?; + let code_hash: sp_core::H256 = BlakeTwo256::hash(&wasm); crate::log_status!( - "⬆️ Upgrade phase: proposing set_code with {} ({} bytes), current spec {}", + "⬆️ Upgrade phase: authorizing {} ({} bytes, hash {code_hash:?}), current spec {}", wasm_path.display(), wasm.len(), spec_before ); - let set_code = quantus_subxt::api::tx().system().set_code(wasm); - let encoded = set_code + let authorize = quantus_subxt::api::tx().system().authorize_upgrade(code_hash); + let encoded = authorize .encode_call_data(&ctx.client.client().metadata()) - .map_err(|e| QuantusError::Generic(format!("failed to encode set_code: {e:?}")))?; + .map_err(|e| QuantusError::Generic(format!("failed to encode authorize_upgrade: {e:?}")))?; let index = submit_root_referendum(ctx, encoded).await?; + let authorized_addr = quantus_subxt::api::storage().system().authorized_upgrade(); + let deadline = std::time::Instant::now() + std::time::Duration::from_secs(timeout_secs); + loop { + tokio::time::sleep(std::time::Duration::from_secs(3)).await; + let latest = ctx.client.get_latest_block().await?; + if let Some(authorized) = + ctx.client.client().storage().at(latest).fetch(&authorized_addr).await? + { + if authorized.code_hash != code_hash { + return Err(QuantusError::Generic(format!( + "unexpected authorized upgrade hash: {:?} (expected {code_hash:?})", + authorized.code_hash + ))); + } + break; + } + if std::time::Instant::now() > deadline { + let info = referendum_state(ctx, index).await?; + return Err(QuantusError::Generic(format!( + "upgrade not authorized within {timeout_secs}s; referendum #{index} state: \ + {info}. Is the node built with the fast-governance feature?" + ))); + } + } + crate::log_status!("⬆️ Upgrade authorized on-chain; applying the code blob…"); + + let apply = quantus_subxt::api::tx().system().apply_authorized_upgrade(wasm); + let alice = ctx.alice.clone(); + submit_ok(ctx, &alice, apply).await?; + let deadline = std::time::Instant::now() + std::time::Duration::from_secs(timeout_secs); loop { - tokio::time::sleep(std::time::Duration::from_secs(6)).await; + tokio::time::sleep(std::time::Duration::from_secs(3)).await; let (spec_now, _) = ctx.client.get_runtime_version().await?; if spec_now > spec_before { let refunds = refund_deposits(ctx, index).await; return Ok(format!( - "runtime upgraded via referendum #{index}: spec {spec_before} → {spec_now}; {refunds}" + "runtime upgraded via referendum #{index} (authorize_upgrade + \ + apply_authorized_upgrade): spec {spec_before} → {spec_now}; {refunds}" )); } if std::time::Instant::now() > deadline { - let info = referendum_state(ctx, index).await?; return Err(QuantusError::Generic(format!( - "spec version still {spec_before} after {timeout_secs}s; referendum #{index} \ - state: {info}. Is the node built with the fast-governance feature and the \ - WASM spec_version higher than {spec_before}?" + "apply_authorized_upgrade was included but the spec version is still \ + {spec_before} after {timeout_secs}s; is the WASM spec_version higher?" ))); } } diff --git a/src/cli/runtime.rs b/src/cli/runtime.rs index c7f5f8c..2acf896 100644 --- a/src/cli/runtime.rs +++ b/src/cli/runtime.rs @@ -8,7 +8,7 @@ use crate::{ log_print, log_success, log_verbose, wallet::WalletSigner, }; -use clap::Subcommand; +use clap::{Subcommand, ValueEnum}; use colored::Colorize; use sp_runtime::traits::{BlakeTwo256, Hash}; @@ -19,14 +19,58 @@ use std::{ }; use subxt::{tx::Payload, OnlineClient}; +/// Governance track that carries the `System::authorize_upgrade` referendum. +/// +/// Both tracks approve the same 34-byte authorization preimage; they differ only in +/// the proposal origin, and therefore in thresholds and timing. +#[derive(ValueEnum, Clone, Copy, Debug, Default, PartialEq, Eq)] +pub enum UpgradeTrack { + /// `Origins::FastUpgrade` (80%/80%, ~30 min). Needs a runtime that defines the + /// `FastUpgrade` origin — absent on runtimes older than the fast_upgrade track. + #[default] + FastUpgrade, + /// `system::Root` (61%/60%, ~2 days). The only option on runtimes that predate + /// the fast_upgrade track. + Root, +} + +impl UpgradeTrack { + /// The `OriginCaller` this track dispatches under, as a dynamic value encoded + /// against the connected runtime's metadata. + fn origin_value(self) -> subxt::dynamic::Value { + use subxt::dynamic::Value; + match self { + Self::FastUpgrade => Value::unnamed_variant( + "Origins", + [Value::unnamed_variant("FastUpgrade", Vec::::new())], + ), + Self::Root => Value::unnamed_variant( + "system", + [Value::unnamed_variant("Root", Vec::::new())], + ), + } + } + + pub(crate) fn label(self) -> &'static str { + match self { + Self::FastUpgrade => "Origins::FastUpgrade", + Self::Root => "system::Root", + } + } +} + #[derive(Subcommand, Debug)] pub enum RuntimeCommands { - /// Propose a version-checked runtime upgrade on the FastUpgrade track + /// Propose a version-checked runtime upgrade via `System::authorize_upgrade` Update { /// Path to the runtime WASM file #[arg(short, long)] wasm_file: PathBuf, + /// Governance track carrying the authorization referendum + #[arg(long, value_enum, default_value_t)] + track: UpgradeTrack, + /// Wallet name to sign with (must be allowed to submit Tech Referenda) #[arg(short, long)] from: String, @@ -44,7 +88,7 @@ pub enum RuntimeCommands { force: bool, }, - /// Apply the exact WASM after its FastUpgrade authorization has enacted + /// Apply the exact WASM after its authorization referendum has enacted Apply { /// Path to the runtime WASM file whose hash was authorized #[arg(short, long)] @@ -137,16 +181,14 @@ pub(crate) fn validate_runtime_authorization_preimage( Ok(code_hash) } -pub(crate) fn build_fast_upgrade_referendum( +pub(crate) fn build_authorization_referendum( preimage_hash: sp_core::H256, call_len: u32, + track: UpgradeTrack, ) -> subxt::tx::DynamicPayload { use subxt::dynamic::Value; - let origin = Value::unnamed_variant( - "Origins", - [Value::unnamed_variant("FastUpgrade", Vec::::new())], - ); + let origin = track.origin_value(); let proposal = Value::named_variant( "Lookup", [ @@ -162,6 +204,7 @@ pub(crate) async fn submit_runtime_authorization( quantus_client: &crate::chain::client::QuantusClient, wasm_code: &[u8], signer: &WalletSigner, + track: UpgradeTrack, execution_mode: ExecutionMode, ) -> crate::error::Result { let authorization = @@ -175,8 +218,8 @@ pub(crate) async fn submit_runtime_authorization( log_verbose!("📝 Authorization call size: {} bytes", call_len); submit_preimage(quantus_client, signer, authorization.encoded_call, execution_mode).await?; - log_print!("📡 Submitting FastUpgrade authorization referendum..."); - let submit_call = build_fast_upgrade_referendum(authorization.preimage_hash, call_len); + log_print!("📡 Submitting authorization referendum on {}...", track.label()); + let submit_call = build_authorization_referendum(authorization.preimage_hash, call_len, track); let tx_hash = submit_transaction(quantus_client, signer, submit_call, None, execution_mode).await?; log_success!("Runtime authorization referendum submitted! Hash: 0x{}", hex::encode(tx_hash)); @@ -212,14 +255,15 @@ pub async fn update_runtime( quantus_client: &crate::chain::client::QuantusClient, wasm_code: Vec, signer: &WalletSigner, + track: UpgradeTrack, force: bool, execution_mode: ExecutionMode, ) -> crate::error::Result { log_print!("📋 Upgrade path:"); - log_print!(" • Propose System::authorize_upgrade(code_hash) as Origins::FastUpgrade"); + log_print!(" • Propose System::authorize_upgrade(code_hash) as {}", track.label()); log_print!(" • Apply the exact WASM after the authorization referendum enacts"); confirm_runtime_action("authorization", force)?; - submit_runtime_authorization(quantus_client, &wasm_code, signer, execution_mode).await + submit_runtime_authorization(quantus_client, &wasm_code, signer, track, execution_mode).await } pub async fn apply_runtime( @@ -348,7 +392,7 @@ pub async fn handle_runtime_command( let quantus_client = crate::chain::client::QuantusClient::new(node_url).await?; match command { - RuntimeCommands::Update { wasm_file, from, password, password_file, force } => { + RuntimeCommands::Update { wasm_file, track, from, password, password_file, force } => { log_print!("🚀 Runtime Management"); log_print!("🔐 Runtime Upgrade Authorization"); log_print!(" 📂 WASM file: {}", wasm_file.display().to_string().bright_cyan()); @@ -357,9 +401,13 @@ pub async fn handle_runtime_command( let wasm_code = read_wasm_file(&wasm_file)?; log_print!("📊 WASM file size: {} bytes", wasm_code.len()); let signer = crate::wallet::load_signer_from_wallet(&from, password, password_file)?; - update_runtime(&quantus_client, wasm_code, &signer, force, execution_mode).await?; + update_runtime(&quantus_client, wasm_code, &signer, track, force, execution_mode) + .await?; - log_print!("💡 Place the decision deposit, collect 8 ayes, and wait for enactment."); + log_print!( + "💡 Place the decision deposit, reach the {} threshold, and wait for enactment.", + track.label() + ); log_print!("💡 Then apply this exact file:"); log_print!( " quantus runtime apply --wasm-file {} --from --node-url {}", @@ -516,9 +564,18 @@ mod tests { } #[test] - fn fast_upgrade_referendum_uses_dynamic_live_metadata_payload() { - let payload = build_fast_upgrade_referendum(sp_core::H256::repeat_byte(7), 34); - assert_eq!(payload.pallet_name(), "TechReferenda"); - assert_eq!(payload.call_name(), "submit"); + fn authorization_referendum_uses_dynamic_live_metadata_payload() { + for track in [UpgradeTrack::FastUpgrade, UpgradeTrack::Root] { + let payload = build_authorization_referendum(sp_core::H256::repeat_byte(7), 34, track); + assert_eq!(payload.pallet_name(), "TechReferenda"); + assert_eq!(payload.call_name(), "submit"); + } + } + + #[test] + fn upgrade_track_defaults_to_fast_upgrade() { + assert_eq!(UpgradeTrack::default(), UpgradeTrack::FastUpgrade); + assert_eq!(UpgradeTrack::FastUpgrade.label(), "Origins::FastUpgrade"); + assert_eq!(UpgradeTrack::Root.label(), "system::Root"); } } diff --git a/src/cli/tech_referenda.rs b/src/cli/tech_referenda.rs index a379e84..e64d9e3 100644 --- a/src/cli/tech_referenda.rs +++ b/src/cli/tech_referenda.rs @@ -1,7 +1,9 @@ //! `quantus tech-referenda` subcommand - manage Tech Referenda proposals use crate::{ - chain::quantus_subxt, cli::common::submit_transaction, error::QuantusError, log_error, - log_print, log_success, log_verbose, + chain::quantus_subxt, + cli::{common::submit_transaction, runtime::UpgradeTrack}, + error::QuantusError, + log_error, log_print, log_success, log_verbose, }; use clap::Subcommand; use colored::Colorize; @@ -16,13 +18,17 @@ use std::{ /// Only Tech Collective members can submit proposals. #[derive(Subcommand, Debug)] pub enum TechReferendaCommands { - /// Submit a FastUpgrade proposal using an existing authorize_upgrade preimage + /// Submit an authorize_upgrade proposal using an existing preimage #[command(arg_required_else_help = true)] Submit { /// Hash of the preimage already stored on-chain (hex, with or without 0x prefix) #[arg(long, value_name = "HASH")] preimage_hash: String, + /// Governance track carrying the authorization referendum + #[arg(long, value_enum, default_value_t)] + track: UpgradeTrack, + /// Wallet name to sign with (must be a Tech Collective member) #[arg(short, long, value_name = "WALLET")] from: String, @@ -34,13 +40,17 @@ pub enum TechReferendaCommands { password_file: Option, }, - /// Hash a WASM, note its authorize_upgrade preimage, and submit on FastUpgrade + /// Hash a WASM, note its authorize_upgrade preimage, and submit the referendum #[command(arg_required_else_help = true)] SubmitWithPreimage { /// Path to the compiled runtime WASM file to propose #[arg(short, long, value_name = "PATH")] wasm_file: PathBuf, + /// Governance track carrying the authorization referendum + #[arg(long, value_enum, default_value_t)] + track: UpgradeTrack, + /// Wallet name to sign with (must be a Tech Collective member) #[arg(short, long, value_name = "WALLET")] from: String, @@ -147,20 +157,28 @@ pub async fn handle_tech_referenda_command( let quantus_client = crate::chain::client::QuantusClient::new(node_url).await?; match command { - TechReferendaCommands::Submit { preimage_hash, from, password, password_file } => + TechReferendaCommands::Submit { preimage_hash, track, from, password, password_file } => submit_runtime_upgrade( &quantus_client, &preimage_hash, + track, &from, password, password_file, execution_mode, ) .await, - TechReferendaCommands::SubmitWithPreimage { wasm_file, from, password, password_file } => + TechReferendaCommands::SubmitWithPreimage { + wasm_file, + track, + from, + password, + password_file, + } => submit_runtime_upgrade_with_preimage( &quantus_client, &wasm_file, + track, &from, password, password_file, @@ -205,16 +223,17 @@ pub async fn handle_tech_referenda_command( } } -/// Submit a FastUpgrade authorization referendum using an existing preimage +/// Submit an authorize_upgrade referendum using an existing preimage async fn submit_runtime_upgrade( quantus_client: &crate::chain::client::QuantusClient, preimage_hash: &str, + track: UpgradeTrack, from: &str, password: Option, password_file: Option, execution_mode: crate::cli::common::ExecutionMode, ) -> crate::error::Result<()> { - log_print!("📝 Submitting FastUpgrade Authorization Referendum"); + log_print!("📝 Submitting Authorization Referendum on {}", track.label()); log_print!(" 🔗 Preimage hash: {}", preimage_hash.bright_cyan()); log_print!(" 🔑 Submitted by: {}", from.bright_yellow()); @@ -271,8 +290,11 @@ async fn submit_runtime_upgrade( )?; log_print!("✅ Authorization preimage found for runtime hash {:?}", code_hash); - let submit_call = - crate::cli::runtime::build_fast_upgrade_referendum(preimage_hash_parsed, preimage_len); + let submit_call = crate::cli::runtime::build_authorization_referendum( + preimage_hash_parsed, + preimage_len, + track, + ); let tx_hash = submit_transaction(quantus_client, &signer, submit_call, None, execution_mode).await?; @@ -286,16 +308,17 @@ async fn submit_runtime_upgrade( Ok(()) } -/// Submit a FastUpgrade authorization referendum (creates preimage first) +/// Submit an authorize_upgrade referendum (creates preimage first) async fn submit_runtime_upgrade_with_preimage( quantus_client: &crate::chain::client::QuantusClient, wasm_file: &Path, + track: UpgradeTrack, from: &str, password: Option, password_file: Option, execution_mode: crate::cli::common::ExecutionMode, ) -> crate::error::Result<()> { - log_print!("📝 Submitting FastUpgrade Authorization Referendum"); + log_print!("📝 Submitting Authorization Referendum on {}", track.label()); log_print!(" 📂 WASM file: {}", wasm_file.display().to_string().bright_cyan()); log_print!(" 🔑 Submitted by: {}", from.bright_yellow()); @@ -306,6 +329,7 @@ async fn submit_runtime_upgrade_with_preimage( quantus_client, &wasm_code, &signer, + track, execution_mode, ) .await?; From 7b200e23a375aed2b94166ba66251b5015d0f722 Mon Sep 17 00:00:00 2001 From: Nikolaus Heger Date: Wed, 2 Sep 2026 00:27:31 +0800 Subject: [PATCH 2/3] fix: decode tech-referenda reads against live metadata MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit referendum_info_for used the generated static types, so ReferendumStatus embedded the codegen's OriginCaller. A runtime whose custom-origin pallets differ from the one the CLI was built against fails every read with Metadata(IncompatibleCodegen) — list, get, and status were all unusable against Heisenberg (spec 144) from a CLI built for 148, even though the submit path worked because it encodes dynamically. Read ReferendumInfoFor through subxt's dynamic storage API and pull the displayed fields out by name, so these commands work on any runtime that still uses upstream pallet-referenda's field names. Output is unchanged. Also project the confirm and enactment blocks while a referendum is still Preparing: once the decision deposit is placed and the proposal is not queued, every remaining phase length is known, so the earliest possible enactment can be shown instead of 'unavailable'. That is the block operators actually wait for before runtime apply. --- src/cli/tech_referenda.rs | 291 ++++++++++++++++++++++++++++++++++---- 1 file changed, 266 insertions(+), 25 deletions(-) diff --git a/src/cli/tech_referenda.rs b/src/cli/tech_referenda.rs index e64d9e3..349cde6 100644 --- a/src/cli/tech_referenda.rs +++ b/src/cli/tech_referenda.rs @@ -379,17 +379,18 @@ async fn get_proposal_details( log_print!("📄 Tech Referendum #{} Details", index); log_print!(""); - let addr = quantus_subxt::api::storage().tech_referenda().referendum_info_for(index); - // Get the latest block hash to read from the latest state (not finalized) let latest_block_hash = quantus_client.get_latest_block().await?; let storage_at = quantus_client.client().storage().at(latest_block_hash); - let info = storage_at.fetch(&addr).await?; + let info = storage_at.fetch(&referendum_info_address(index)).await?; - if let Some(referendum_info) = info { + if let Some(thunk) = info { + let value = thunk.to_value().map_err(|e| { + QuantusError::Generic(format!("Failed to decode referendum #{index}: {e:?}")) + })?; log_print!("📋 Referendum Information (raw):"); - log_print!("{:#?}", referendum_info); + log_print!("{value}"); } else { log_print!("📭 Referendum #{} not found", index); } @@ -503,17 +504,239 @@ async fn scheduled_enactment_block( } /// Compute the enactment block for a given approval block, honoring the track's minimum delay +/// When the referendum's approved call is scheduled to run. +#[derive(Clone, Copy, Debug)] +enum EnactmentMoment { + At(u32), + After(u32), +} + +#[derive(Clone, Copy, Debug)] +struct DecidingSnapshot { + since: u32, + confirming: Option, +} + +#[derive(Clone, Copy, Debug)] +struct TallySnapshot { + ayes: u32, + nays: u32, +} + +/// The subset of `ReferendumStatus` this command displays. +#[derive(Clone, Copy, Debug)] +struct OngoingSnapshot { + track: u16, + submitted: u32, + tally: TallySnapshot, + decision_deposit_placed: bool, + in_queue: bool, + deciding: Option, + enactment: EnactmentMoment, +} + +#[derive(Clone, Copy, Debug)] +enum ReferendumSnapshot { + Ongoing(OngoingSnapshot), + Approved(u32), + Rejected(u32), + Cancelled(u32), + TimedOut(u32), + Killed(u32), +} + +/// Decode `TechReferenda::ReferendumInfoFor` from a dynamically decoded value. +/// +/// The generated static types cannot be used here: `ReferendumStatus` embeds +/// `OriginCaller`, whose variant list changes whenever a runtime gains or loses a +/// custom-origin pallet, so a CLI built against one runtime fails with +/// `Metadata(IncompatibleCodegen)` against another. Decoding against the *live* +/// metadata and reading fields by name keeps these commands working on any runtime +/// that still calls the fields what upstream `pallet-referenda` calls them. +mod referendum_value { + use super::{ + DecidingSnapshot, EnactmentMoment, OngoingSnapshot, QuantusError, ReferendumSnapshot, + }; + use subxt::ext::scale_value::{Composite, Primitive, Value, ValueDef}; + + type Val = Value; + + fn variant(v: &Val) -> Option<(&str, &Composite)> { + match &v.value { + ValueDef::Variant(var) => Some((var.name.as_str(), &var.values)), + _ => None, + } + } + + fn composite(v: &Val) -> Option<&Composite> { + match &v.value { + ValueDef::Composite(c) => Some(c), + _ => None, + } + } + + fn field<'a>(c: &'a Composite, name: &str) -> Option<&'a Val> { + match c { + Composite::Named(fields) => fields.iter().find(|(n, _)| n == name).map(|(_, v)| v), + Composite::Unnamed(_) => None, + } + } + + fn nth(c: &Composite, i: usize) -> Option<&Val> { + match c { + Composite::Named(fields) => fields.get(i).map(|(_, v)| v), + Composite::Unnamed(fields) => fields.get(i), + } + } + + fn uint(v: &Val) -> Option { + match &v.value { + ValueDef::Primitive(Primitive::U128(n)) => Some(*n), + // A single-field newtype (e.g. a compact wrapper) decodes as a composite. + ValueDef::Composite(c) => nth(c, 0).and_then(uint), + _ => None, + } + } + + fn boolean(v: &Val) -> Option { + match &v.value { + ValueDef::Primitive(Primitive::Bool(b)) => Some(*b), + _ => None, + } + } + + /// `Some(inner)` / `None`, or `Err` if the value is not an Option at all. + fn option(v: &Val) -> Option> { + let (name, inner) = variant(v)?; + match name { + "None" => Some(None), + "Some" => Some(Some(nth(inner, 0)?)), + _ => None, + } + } + + fn missing(what: &str) -> QuantusError { + QuantusError::Generic(format!( + "referendum decode: {what} is missing or has an unexpected shape; the runtime's \ + pallet-referenda layout is not recognized" + )) + } + + fn u32_field(c: &Composite, name: &str) -> Result { + let raw = field(c, name).and_then(uint).ok_or_else(|| missing(name))?; + u32::try_from(raw).map_err(|_| missing(name)) + } + + pub fn decode(value: &Val) -> Result { + let (name, body) = variant(value).ok_or_else(|| missing("ReferendumInfo"))?; + let since = || -> Result { + let raw = nth(body, 0).and_then(uint).ok_or_else(|| missing("since"))?; + u32::try_from(raw).map_err(|_| missing("since")) + }; + match name { + "Ongoing" => { + let status = + nth(body, 0).and_then(composite).ok_or_else(|| missing("ReferendumStatus"))?; + + let track = u32_field(status, "track")?; + let track = u16::try_from(track).map_err(|_| missing("track"))?; + + let tally = + field(status, "tally").and_then(composite).ok_or_else(|| missing("tally"))?; + + let deciding = match field(status, "deciding").and_then(option) { + Some(Some(d)) => { + let d = composite(d).ok_or_else(|| missing("deciding"))?; + let confirming = match field(d, "confirming").and_then(option) { + Some(Some(c)) => Some( + u32::try_from(uint(c).ok_or_else(|| missing("confirming"))?) + .map_err(|_| missing("confirming"))?, + ), + Some(None) => None, + None => return Err(missing("confirming")), + }; + Some(DecidingSnapshot { since: u32_field(d, "since")?, confirming }) + }, + Some(None) => None, + None => return Err(missing("deciding")), + }; + + let enactment = { + let (kind, args) = field(status, "enactment") + .and_then(variant) + .ok_or_else(|| missing("enactment"))?; + let n = nth(args, 0).and_then(uint).ok_or_else(|| missing("enactment"))?; + let n = u32::try_from(n).map_err(|_| missing("enactment"))?; + match kind { + "At" => EnactmentMoment::At(n), + "After" => EnactmentMoment::After(n), + _ => return Err(missing("enactment")), + } + }; + + Ok(ReferendumSnapshot::Ongoing(OngoingSnapshot { + track, + submitted: u32_field(status, "submitted")?, + tally: super::TallySnapshot { + ayes: u32_field(tally, "ayes")?, + nays: u32_field(tally, "nays")?, + }, + decision_deposit_placed: matches!( + field(status, "decision_deposit").and_then(option), + Some(Some(_)) + ), + in_queue: field(status, "in_queue").and_then(boolean).unwrap_or(false), + deciding, + enactment, + })) + }, + "Approved" => Ok(ReferendumSnapshot::Approved(since()?)), + "Rejected" => Ok(ReferendumSnapshot::Rejected(since()?)), + "Cancelled" => Ok(ReferendumSnapshot::Cancelled(since()?)), + "TimedOut" => Ok(ReferendumSnapshot::TimedOut(since()?)), + "Killed" => Ok(ReferendumSnapshot::Killed(since()?)), + other => Err(QuantusError::Generic(format!( + "referendum decode: unknown ReferendumInfo variant `{other}`" + ))), + } + } +} + +/// Dynamic `TechReferenda::ReferendumInfoFor(index)` address, decoded against live metadata. +fn referendum_info_address( + index: u32, +) -> subxt::storage::DynamicAddress> { + subxt::dynamic::storage( + "TechReferenda", + "ReferendumInfoFor", + vec![subxt::dynamic::Value::u128(u128::from(index))], + ) +} + +/// Fetch and decode one referendum, tolerating runtime versions the CLI was not built against. +async fn fetch_referendum( + quantus_client: &crate::chain::client::QuantusClient, + index: u32, + block_hash: subxt::utils::H256, +) -> crate::error::Result> { + let storage_at = quantus_client.client().storage().at(block_hash); + let Some(thunk) = storage_at.fetch(&referendum_info_address(index)).await? else { + return Ok(None); + }; + let value = thunk.to_value().map_err(|e| { + QuantusError::Generic(format!("Failed to decode referendum #{index}: {e:?}")) + })?; + referendum_value::decode(&value).map(Some).map_err(Into::into) +} + fn enactment_block( - enactment: &quantus_subxt::api::runtime_types::frame_support::traits::schedule::DispatchTime< - u32, - >, + enactment: &EnactmentMoment, approval_block: u32, min_enactment_period: u32, ) -> u32 { - use quantus_subxt::api::runtime_types::frame_support::traits::schedule::DispatchTime; let desired = match enactment { - DispatchTime::At(block) => *block, - DispatchTime::After(offset) => approval_block.saturating_add(*offset), + EnactmentMoment::At(block) => *block, + EnactmentMoment::After(offset) => approval_block.saturating_add(*offset), }; desired.max(approval_block.saturating_add(min_enactment_period)) } @@ -563,23 +786,18 @@ async fn get_proposal_status( quantus_client: &crate::chain::client::QuantusClient, index: u32, ) -> crate::error::Result<()> { - use quantus_subxt::api::runtime_types::pallet_referenda::types::ReferendumInfo; - log_verbose!("📊 Fetching status for Tech Referendum #{}...", index); - let addr = quantus_subxt::api::storage().tech_referenda().referendum_info_for(index); - // Get the latest block hash to read from the latest state (not finalized) let latest_block_hash = quantus_client.get_latest_block().await?; - let storage_at = quantus_client.client().storage().at(latest_block_hash); - let info_res = storage_at.fetch(&addr).await; + let info_res = fetch_referendum(quantus_client, index, latest_block_hash).await; match info_res { Ok(Some(info)) => { log_print!("📊 Status for Referendum #{}", index.to_string().bright_yellow()); match info { - ReferendumInfo::Ongoing(status) => { + ReferendumSnapshot::Ongoing(status) => { let current_block = quantus_client.client().blocks().at(latest_block_hash).await?.number(); let block_time_ms = target_block_time_ms(quantus_client)?; @@ -610,7 +828,7 @@ async fn get_proposal_status( ); log_print!( " - Decision deposit: {}", - if status.decision_deposit.is_some() { + if status.decision_deposit_placed { "placed".green() } else { "not placed".yellow() @@ -650,7 +868,7 @@ async fn get_proposal_status( let pre_deciding = pre_deciding_state( prepare_end, current_block, - status.decision_deposit.is_some(), + status.decision_deposit_placed, status.in_queue, ); log_print!( @@ -692,6 +910,29 @@ async fn get_proposal_status( " 🏁 Enactment estimate: unavailable {}", pre_deciding.enactment_estimate_reason() ); + // The earliest-possible dates, assuming the current tally already + // clears the track's thresholds when Deciding opens. Confirming + // starts the moment it does, so the remaining phases are fixed. + if status.decision_deposit_placed && !status.in_queue { + let confirm_end = prepare_end.saturating_add(track.confirm_period); + let enacts = enactment_block( + &status.enactment, + confirm_end, + track.min_enactment_period, + ); + log_print!( + " ⤷ projected, if the current tally still clears the \ + threshold when Deciding opens:" + ); + log_print!( + " - Confirm ends at {}", + format_block_eta(confirm_end, current_block, block_time_ms) + ); + log_print!( + " - Enactment at {}", + format_block_eta(enacts, current_block, block_time_ms) + ); + } }, Some(deciding) => match deciding.confirming { None => { @@ -783,7 +1024,7 @@ async fn get_proposal_status( } log_verbose!(" - Full status: {:#?}", status); }, - ReferendumInfo::Approved(since, ..) => { + ReferendumSnapshot::Approved(since) => { let current_block = quantus_client.client().blocks().at(latest_block_hash).await?.number(); let block_time_ms = target_block_time_ms(quantus_client)?; @@ -804,19 +1045,19 @@ async fn get_proposal_status( ), } }, - ReferendumInfo::Rejected(since, ..) => { + ReferendumSnapshot::Rejected(since) => { log_print!(" - Status: {}", "Rejected".red()); log_print!(" - Rejected at block: {}", since); }, - ReferendumInfo::Cancelled(since, ..) => { + ReferendumSnapshot::Cancelled(since) => { log_print!(" - Status: {}", "Cancelled".yellow()); log_print!(" - Cancelled at block: {}", since); }, - ReferendumInfo::TimedOut(since, ..) => { + ReferendumSnapshot::TimedOut(since) => { log_print!(" - Status: {}", "TimedOut".dimmed()); log_print!(" - Timed out at block: {}", since); }, - ReferendumInfo::Killed(since) => { + ReferendumSnapshot::Killed(since) => { log_print!(" - Status: {}", "Killed".red().bold()); log_print!(" - Killed at block: {}", since); }, From 7c37db985e9fe9c13ce1f69e7fe246db2ec39297 Mon Sep 17 00:00:00 2001 From: Nikolaus Heger Date: Wed, 2 Sep 2026 00:42:11 +0800 Subject: [PATCH 3/3] fix clippy --- src/cli/tech_referenda.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/cli/tech_referenda.rs b/src/cli/tech_referenda.rs index 349cde6..3dbae5c 100644 --- a/src/cli/tech_referenda.rs +++ b/src/cli/tech_referenda.rs @@ -726,7 +726,7 @@ async fn fetch_referendum( let value = thunk.to_value().map_err(|e| { QuantusError::Generic(format!("Failed to decode referendum #{index}: {e:?}")) })?; - referendum_value::decode(&value).map(Some).map_err(Into::into) + referendum_value::decode(&value).map(Some) } fn enactment_block(