feat: select the governance track, and decode referenda against live metadata - #149
Merged
Conversation
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.
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.
n13
commented
Sep 1, 2026
n13
left a comment
Contributor
Author
There was a problem hiding this comment.
Reviewer model: GPT Sol
Verdict (advisory): Request changes
Blocking finding:
src/cli/tech_referenda.rs:729— The new.map_err(Into::into)convertsQuantusErrorto the same type. The repository's requiredSKIP_CIRCUIT_BUILD=1 cargo clippy --all-targets --locked -- -D warningscommand rejects it asclippy::useless_conversion, so the analysis check fails and its documentation steps never run. Remove the no-op conversion and returnreferendum_value::decode(&value).map(Some)directly, then rerun the strict analysis job.
Validation at exact head 7b200e23a375aed2b94166ba66251b5015d0f722:
cargo test --locked: 316 library tests and 314 binary tests passed; one doc test ignored.cargo +nightly-2026-08-31 fmt --all -- --checkandgit diff --check: passed.quantus runtime update --help: exposes--track {fast-upgrade,root}and preservesfast-upgradeas the default.- Live read-only
tech-referenda status --index 5against Heisenberg successfully decoded the older-runtime referendum and rendered its track, tally, phase, and projected enactment. - Hosted Ubuntu build, format, dependency-cooldown, and security checks pass; macOS/examples remain in progress at review time. Strict Clippy fails locally and in CI at the line above.
I found no other blocking issue in the four-file diff, but the required analysis gate must be restored before merge.
n13
added a commit
that referenced
this pull request
Sep 2, 2026
) * fix: decode remaining version-fragile reads against live metadata A follow-up to #149, which fixed only the tech-referenda read path. Three more sites decoded chain state through the generated codegen, so they failed with Metadata(IncompatibleCodegen) on any runtime whose type graph differs from the one the CLI was built against. - Scheduler::Agenda (quantus scheduler agenda): a scheduled task carries origin: OriginCaller, the same field that broke tech-referenda. This is the queue referendum enactments land in, so it was unreadable exactly when an operator most needs it. The command also reported success after failing every block in the range; it now exits non-zero if any block could not be read. - The exercise suite read ReferendumInfoFor statically in three places, which is literally the bug #149 fixed. They now reuse fetch_referendum/ReferendumSnapshot. - Preimage::RequestStatusFor embeds the runtime-local PreimageDeposit ticket. It gated quantus preimage status/list and, more importantly, the tech-referenda submit path. The scale_value navigation helpers #149 added privately are now a shared cli::dynamic_decode module rather than being copied per call site. Display output is unchanged except Scheduler::Agenda, which moves from a Rust {:?} dump to the scale_value rendering — the same trade #149 already made for tech-referenda get. * fix: render multisig proposal calls from live metadata decode_call_data resolved the pallet name from metadata but then matched hardcoded call indices (idx == 0 => transfer_allow_death, idx == 3 => transfer_keep_alive, ReversibleTransfers idx == 0) and hand-parsed arguments by byte offset. On a runtime that ordered calls differently it would render a confident, fully formatted description of a different call, with no error -- on the screen a multisig signer reads before approving. Every other decode bug in this PR fails loudly; this one did not. Look the pallet up by index, the call variant by index within that pallet, and decode each argument against the type id the metadata declares for it. Nothing about the call layout is hardcoded, so an unknown pallet or call index now says so instead of guessing, and trailing bytes are reported rather than ignored. Account ids still render as SS58 and balances with symbol and decimals, which is what a signer is actually checking. The pure rendering half is split out as describe_call so it can be tested against the checked-in metadata blob without a node. Four tests cover it, including one that derives an undefined call index from the metadata and asserts the output names no real call. * fix: gate SS58 rendering on the declared type, not byte length Review findings on #150. P1: render_call_arg tried account_bytes first, and account_bytes accepted any bare 32-byte sequence. So a non-account argument -- System::authorize_upgrade's code_hash: H256, or a 32-byte remark payload -- rendered as an SS58 address. That is the same class of signer-visible type confusion this decoder exists to prevent: a signer could not compare a proposed runtime hash against the authorized artifact. SS58 is now used only where the metadata resolves the argument's type to AccountId32, directly or inside MultiAddress::Id. Every other byte blob renders as lossless 0x hex. Two regressions cover both directions. P2: exercise/scenarios/preimage.rs still fetched Preimage::RequestStatusFor through the generated address in note_and_verify and preimage_status_exists, so the preimage scenario still broke on exactly the runtimes this PR targets. Both now use fetch_request_status; no static reads of that entry remain. * fix: gate byte-blob rendering on the declared type, not the decoded shape byte_blob treated any composite whose elements all read as numbers as a byte string, and the shared uint helper reaches into composites to get there. Multisig::create_multisig.signers is a Vec<AccountId32>, so a set of two or more signers rendered as one hex byte per signer and dropped 31 of every 32 bytes. That is signer-visible data loss: the account approving a proposal could not see who was actually in the multisig. The metadata now decides. is_byte_sequence resolves value.context and flattens only a Vec<u8>, a [u8; N], or a single-field wrapper around one (H256, BoundedVec<u8, _>). Everything else keeps its structure. The per-element read is strict: dynamic_decode::byte takes a primitive only, so a composite can no longer pass as a byte by yielding its first field. A sequence the metadata does not call bytes now renders element by element, so a signer set reads as addresses rather than a wall of numbers. Tests decode a real two-signer create_multisig and assert both SS58 addresses appear and the truncated hex does not, and assert directly that Vec<AccountId32> is not a byte run while H256 is. * style: order the scale-info dependency for taplo
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Two changes that together let the CLI drive a runtime upgrade on a chain older than the CLI itself. Found while upgrading Heisenberg (spec 144 → 148); both are exercised by that upgrade, which is in flight on referendum #5.
1. Governance track selector
Runtime upgrades already go through
System::authorize_upgradeplus the permissionlessapply_authorized_upgrade; only the referendum's proposal origin differs between the fast and slow lanes. That origin was hardcoded:So the CLI could not upgrade a runtime predating the
fast_upgradetrack —Originsis absent from its metadata, so the payload fails to encode.--track {fast-upgrade,root}onruntime update,tech-referenda submit, andtech-referenda submit-with-preimage:fast-upgrade(default)Origins::FastUpgradefast_upgraderootsystem::Roottech_collective_membersDefault is
fast-upgrade, so existing usage is unchanged.rootworks on every runtime, including ones with noOriginsenum.Sunsetting
set_codeThe exercise suite's real-upgrade scenario used
system.set_code. A runtime blob is hundreds of KiB against a 64 KiBMaxProposalSize, so that shape can never be a real proposal — the scenario exercised a path production cannot use. It now authorizes the hash and applies the blob, matching production.UpgradeMode::SetCode→UpgradeMode::Authorize.SelfNoopkeepsauthorize_upgrade_without_checks, since re-installing identical code would tripSpecVersionNeedsToIncrease.2. Referenda reads decode against live metadata
referendum_info_forused the generated static types, soReferendumStatusembedded the codegen'sOriginCaller. Against a runtime whose custom-origin pallets differ, every read failed:list,get, andstatuswere all unusable against Heisenberg from a CLI built for 148 — while submitting worked fine, because that path encodes dynamically. The upgrade could be started but not monitored.ReferendumInfoForis now read through subxt's dynamic storage API, with the displayed fields pulled out by name into a smallReferendumSnapshot. Works on any runtime still using upstreampallet-referendafield names; display output is unchanged.Enactment projection
While a referendum is
Preparing, the status command said enactment wasunavailable. But once the decision deposit is placed and the proposal is not queued, every remaining phase length is known, so the earliest possible enactment is computable — and it is the block operators actually wait for beforeruntime apply:Cold wallets
Unaffected. The track only changes which payload is built;
submit_transactiondispatchesWalletSigner::Hot/Coldinternally, so Keystone/QR signing works on both tracks.Verification
Against live Heisenberg (spec 144) with a CLI built for 148:
runtime update --track rootsubmitted referendum some cleanup, added list pending transactions #5; raw storage confirms origin bytes00 00=system::Rooton track 0, proposalLookuplen 34, preimage =System.authorize_upgrade(0x58b93c5f…84ec)= blake2-256 ofquantus-runtime-v148.compact.compressed.wasmtech-referenda status --index 5now renders fully where it previously returnedIncompatibleCodegencargo clippy --release --all-targetsclean;cargo +nightly fmt --all -- --checkcleanFollow-up
Other commands may still decode against bundled codegen rather than live metadata; that survey is deliberately not in this PR and will follow separately.