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..3dbae5c 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?; @@ -355,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); } @@ -479,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) +} + 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)) } @@ -539,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)?; @@ -586,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() @@ -626,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!( @@ -668,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 => { @@ -759,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)?; @@ -780,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); },