Skip to content

feat: select the governance track, and decode referenda against live metadata - #149

Merged
n13 merged 3 commits into
mainfrom
n13/upgrade-track-selector
Sep 1, 2026
Merged

feat: select the governance track, and decode referenda against live metadata#149
n13 merged 3 commits into
mainfrom
n13/upgrade-track-selector

Conversation

@n13

@n13 n13 commented Sep 1, 2026

Copy link
Copy Markdown
Contributor

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_upgrade plus the permissionless apply_authorized_upgrade; only the referendum's proposal origin differs between the fast and slow lanes. That origin was hardcoded:

let origin = Value::unnamed_variant(
    "Origins", [Value::unnamed_variant("FastUpgrade", Vec::<Value>::new())]);

So the CLI could not upgrade a runtime predating the fast_upgrade track — Origins is absent from its metadata, so the payload fails to encode.

--track {fast-upgrade,root} on runtime update, tech-referenda submit, and tech-referenda submit-with-preimage:

track origin referenda track thresholds timing
fast-upgrade (default) Origins::FastUpgrade fast_upgrade 80% / 80% ~30 min
root system::Root tech_collective_members 61% / 60% ~2 days

Default is fast-upgrade, so existing usage is unchanged. root works on every runtime, including ones with no Origins enum.

Sunsetting set_code

The exercise suite's real-upgrade scenario used system.set_code. A runtime blob is hundreds of KiB against a 64 KiB MaxProposalSize, 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::SetCodeUpgradeMode::Authorize. SelfNoop keeps authorize_upgrade_without_checks, since re-installing identical code would trip SpecVersionNeedsToIncrease.

2. Referenda reads decode against live metadata

referendum_info_for used the generated static types, so ReferendumStatus embedded the codegen's OriginCaller. Against a runtime whose custom-origin pallets differ, every read failed:

❌ Failed to fetch referendum #5: Metadata(IncompatibleCodegen)

list, get, and status were 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.

ReferendumInfoFor is now read through subxt's dynamic storage API, with the displayed fields pulled out by name into a small ReferendumSnapshot. Works on any runtime still using upstream pallet-referenda field names; display output is unchanged.

Enactment projection

While a referendum is Preparing, the status command said enactment was unavailable. 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 before runtime apply:

🏁 Enactment estimate: unavailable until the prepare period ends and Deciding starts
   ⤷ projected, if the current tally still clears the threshold when Deciding opens:
     - Confirm ends at block 969790 (in 7732 blocks / ~25.8h, ≈ 2026-09-02 18:13 UTC)
     - Enactment at block 976990 (in 14932 blocks / ~2.1 days, ≈ 2026-09-03 18:13 UTC)

Cold wallets

Unaffected. The track only changes which payload is built; submit_transaction dispatches WalletSigner::Hot/Cold internally, so Keystone/QR signing works on both tracks.

Verification

Against live Heisenberg (spec 144) with a CLI built for 148:

  • runtime update --track root submitted referendum some cleanup, added list pending transactions #5; raw storage confirms origin bytes 00 00 = system::Root on track 0, proposal Lookup len 34, preimage = System.authorize_upgrade(0x58b93c5f…84ec) = blake2-256 of quantus-runtime-v148.compact.compressed.wasm
  • tech-referenda status --index 5 now renders fully where it previously returned IncompatibleCodegen
  • 316 unit tests pass (2 new, covering both origins and the default); cargo clippy --release --all-targets clean; cargo +nightly fmt --all -- --check clean

Follow-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.

n13 added 2 commits September 2, 2026 00:09
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 n13 changed the title feat: select the governance track for runtime authorization feat: select the governance track, and decode referenda against live metadata Sep 1, 2026
@n13 n13 added the bot-review Request automated review from review-bot label Sep 1, 2026

@n13 n13 left a comment

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Reviewer model: GPT Sol

Verdict (advisory): Request changes

Blocking finding:

  • src/cli/tech_referenda.rs:729 — The new .map_err(Into::into) converts QuantusError to the same type. The repository's required SKIP_CIRCUIT_BUILD=1 cargo clippy --all-targets --locked -- -D warnings command rejects it as clippy::useless_conversion, so the analysis check fails and its documentation steps never run. Remove the no-op conversion and return referendum_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 -- --check and git diff --check: passed.
  • quantus runtime update --help: exposes --track {fast-upgrade,root} and preserves fast-upgrade as the default.
  • Live read-only tech-referenda status --index 5 against 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 n13 removed the bot-review Request automated review from review-bot label Sep 1, 2026
@n13
n13 merged commit 33f7aed into main Sep 1, 2026
7 checks passed
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
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant