Skip to content

fix: decode chain state against live metadata, not bundled codegen - #150

Merged
n13 merged 5 commits into
mainfrom
n13/dynamic-decode-read-paths
Sep 2, 2026
Merged

fix: decode chain state against live metadata, not bundled codegen#150
n13 merged 5 commits into
mainfrom
n13/dynamic-decode-read-paths

Conversation

@n13

@n13 n13 commented Sep 1, 2026

Copy link
Copy Markdown
Contributor

Follow-up to #149, which fixed only the tech-referenda read path. A survey of every quantus_subxt::api::storage() call site found the rest of the same bug class.

Background

The generated types in chain/quantus_subxt.rs come from one runtime's metadata. Any entry whose type graph reaches a runtime-composed type — OriginCaller, RuntimeCall, a runtime-local struct — gets a different validation hash on a runtime that composes those differently.

For the static codegen path that means subxt refuses the read with Metadata(IncompatibleCodegen): loud, never silently wrong. The exception is the last item below, which does not go through that path at all.

1. Scheduler::Agendaquantus 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.

Live Heisenberg (spec 144), CLI built for 148 — before:

#962590: error fetching agenda: Metadata(IncompatibleCodegen)
✅ Finished scanning Scheduler::Agenda

after:

#962590: ((Some ({ maybe_id: None (), priority: 128, call: Inline (((14, 5, 5, 0, 0, 0))), origin: system (Root ()) })))

That is a live referendum alarm — nudge_referendum, origin: system (Root ()), the very OriginCaller that broke the static decode. The old code also printed ✅ Finished scanning after failing every block; it now exits non-zero.

2. Exercise suite — three static ReferendumInfoFor reads

governance.rs:92, upgrade.rs:158, upgrade.rs:181 were literally the bug #149 fixed, still present. They now reuse fetch_referendum / ReferendumSnapshot.

3. Preimage::RequestStatusFor

Embeds the runtime-local PreimageDeposit ticket. Gated quantus preimage status / list and — more importantly — the tech-referenda submit path, which reads it for preimage_len.

4. Multisig proposal call rendering — the one that failed silently

decode_call_data resolved the pallet name from live metadata, then matched hardcoded call indices:

(_, idx) if pallet_name == "Balances" && (idx == 0 || idx == 3) => {
    let call_name = match idx { 0 => "transfer_allow_death", 3 => "transfer_keep_alive",};

and hand-parsed the arguments by byte offset (args[1..33] as the account, Compact<u128> after it). On a runtime that ordered calls differently this renders a confident, fully formatted description of a different call, with no error — on the screen a multisig signer reads before approving. Everything above fails loudly; this one did not.

Now the pallet is looked up by index, the call variant by index within that pallet, and each argument decoded against the type id the metadata declares for it. Nothing about the layout is hardcoded, so an unknown pallet or call index 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 — what a signer is actually checking. This also deletes ~200 lines of hand-rolled parsing.

Shared helpers

The scale_value navigation helpers #149 added privately are now cli::dynamic_decode, used by the referendum, preimage, and multisig decoders instead of being copied.

Output

Unchanged, except two deliberate cases: Scheduler::Agenda moves from a Rust {:?} dump to scale_value rendering (the trade #149 already made for tech-referenda get), and unknown multisig calls now report the index instead of a fabricated name.

Verification

  • 320 tests pass (4 new). One derives an undefined call index from the metadata and asserts the output names no real call — the regression this decoder exists for. Indices in the tests are read from metadata, never hardcoded.
  • SKIP_CIRCUIT_BUILD=1 cargo clippy --all-targets --locked -- -D warnings clean
  • cargo +nightly fmt --all -- --check clean
  • scheduler agenda and tech-referenda status exercised against live Heisenberg spec 144

Still not covered

Two write paths embed RuntimeCallMultisig::execute and Utility::batch_all (send.rs:278, reached by batch send / multisend). RuntimeCall's hash changes whenever any pallet gains or loses any extrinsic, so these are the most fragile things left, but they are encode paths with a larger blast radius and belong in their own PR. They fail loudly if they break.

n13 added 2 commits September 2, 2026 01:09
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.
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.
@n13 n13 changed the title fix: decode remaining version-fragile reads against live metadata fix: decode chain state against live metadata, not bundled codegen 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 findings:

  1. [P1] Preserve the declared type when rendering 32-byte call arguments — src/cli/multisig.rs:2426-2471. render_call_arg tries account_bytes before considering the argument's metadata type, while account_bytes deliberately accepts any bare 32-byte sequence and recursively any newtype containing one. As a result, non-account arguments such as System::authorize_upgrade.code_hash: H256 (and even a 32-byte System::remark payload) are silently rendered as SS58 addresses instead of their actual hash/bytes. This is signer-visible type confusion in the exact path this change is meant to make trustworthy: a signer cannot compare the proposed runtime hash to the authorized artifact. Gate SS58 rendering on the live metadata type identity (AccountId32 / MultiAddress::Id) and retain lossless hash/byte rendering for other 32-byte values; add regressions for both an account and an H256.

  2. [P2] Route the remaining exercise preimage reads through the dynamic decoder — src/cli/exercise/scenarios/preimage.rs:45-59. This PR adds fetch_request_status because Preimage::RequestStatusFor embeds the runtime-local deposit ticket, but note_and_verify and preimage_status_exists still fetch the generated static address. Against the older/differently composed runtimes targeted by this PR, those steps still fail with Metadata(IncompatibleCodegen), leaving the preimage exercise scenario broken. Reuse fetch_request_status at both sites.

Validation at head 7eff85098f3a8c4a46c0e567017d67a098d04aba:

  • git diff --check — passed.
  • cargo +nightly-2026-08-31 fmt --all -- --check — passed.
  • cargo test --locked — passed (320 library tests and 318 binary tests; one doc test ignored).
  • SKIP_CIRCUIT_BUILD=1 cargo clippy --all-targets --locked -- -D warnings — passed.
  • GitHub format, Ubuntu build/test, analysis/docs, security, and dependency checks were green; macOS and examples were still running when reviewed.

The live-metadata storage conversions are directionally sound, but the signer-visible misrendering and the missed static preimage reads need to be fixed before approval.

@n13 n13 removed the bot-review Request automated review from review-bot label Sep 1, 2026
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.
@n13

n13 commented Sep 2, 2026

Copy link
Copy Markdown
Contributor Author

Both blocking findings fixed in 81c5c28.

P1 — SS58 gated on the declared type. You were right, and it was the same class of bug this decoder is meant to prevent: I keyed the address rendering on byte length rather than the metadata type, so any 32-byte argument became an address.

decode_as_type returns Value<u32> whose context is the type id, so the fix uses the live metadata directly — SS58 only where the type resolves to AccountId32, either directly or inside MultiAddress::Id. Every other byte blob now renders as lossless 0x hex, so a signer can compare an authorize_upgrade code hash against the artifact.

Two regressions, both directions:

  • describe_call_renders_account_id_as_ss58Balances::transfer_allow_death dest still renders SS58
  • describe_call_does_not_render_a_hash_as_an_addressSystem::authorize_upgrade's code_hash: H256 must not contain the SS58 of the same bytes, and must contain their hex

The second fails against the previous implementation, which is the point.

P2 — exercise preimage reads. Both note_and_verify and preimage_status_exists now use fetch_request_status. grep -rn request_status_for src/ --include="*.rs" outside the codegen returns nothing, so no static reads of that entry remain.

Re-validated at 81c5c28:

  • cargo test --release --lib322 passed (2 new)
  • SKIP_CIRCUIT_BUILD=1 cargo clippy --all-targets --locked -- -D warnings — clean
  • cargo +nightly fmt --all -- --check — clean

@n13 n13 added the bot-review Request automated review from review-bot label Sep 2, 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:

  1. [P1] Gate byte-blob rendering on the declared metadata type — src/cli/multisig.rs:2460-2484, src/cli/dynamic_decode.rs:48-53. byte_blob decides that any all-numeric composite is bytes, while the shared uint helper recursively takes element zero from any composite. For the live Multisig::create_multisig.signers: Vec<AccountId32> argument, a vector with two or more signers therefore renders as hex containing only the first byte of each account, discarding the other 31 bytes per signer. That is signer-visible data loss: an unintended signer set cannot be verified before approving the proposal. Resolve value.context through the metadata and only flatten actual byte sequences/arrays (plus deliberate transparent wrappers such as H256 or BoundedVec<u8>); otherwise preserve the full structured value. Add a regression with at least two distinct AccountId32 signers.

The two blockers from the previous review are resolved at this head: H256 no longer renders as SS58, and the remaining exercise preimage reads use the dynamic path.

Validation at head 81c5c285c9e385ca1af1a21953e6dfe90b7112c6:

  • git diff --check — passed.
  • cargo +nightly fmt --all -- --check — passed.
  • cargo test --locked — passed (322 library tests and 320 binary tests; one generated doc test ignored).
  • SKIP_CIRCUIT_BUILD=1 cargo clippy --all-targets --locked -- -D warnings — passed.
  • All eight current GitHub checks are successful.

The live-metadata storage conversions and the prior fixes otherwise look sound, but the signer-visible truncation prevents approval.

@n13 n13 removed the bot-review Request automated review from review-bot label Sep 2, 2026
…hape

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.
@n13 n13 added the bot-review Request automated review from review-bot label Sep 2, 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): Approve

No blocking findings at head 53879c3c60188a0d9b8be4f094e488853f8223c8.

The prior signer-visible blockers are resolved: SS58 rendering is gated by the live metadata type, non-account byte blobs such as H256 remain lossless hex, and Vec<AccountId32> now renders element-by-element with every complete signer address. The new two-signer regression exercises the previously truncated shape. The missed static RequestStatusFor exercise reads use the shared dynamic decoder, and searches find no remaining static RequestStatusFor, ReferendumInfoFor, or Scheduler::Agenda read outside generated code. Unknown call indices are reported rather than guessed, decode failures/trailing bytes remain visible, and the final Taplo-only head commit fixes the dependency ordering that briefly failed the format gate.

Validation:

  • git diff --check — passed.
  • taplo format --check --config taplo.toml — passed.
  • cargo +nightly-2026-08-31 fmt --all -- --check — passed.
  • SKIP_CIRCUIT_BUILD=1 cargo test --locked — passed (324 library tests and 322 binary tests; one generated doc test ignored).
  • SKIP_CIRCUIT_BUILD=1 cargo clippy --all-targets --locked -- -D warnings — passed.
  • GitHub fast format, security, analysis/docs, dependency cooldown, and Ubuntu build/test checks are green; macOS and examples were still running when reviewed.

@n13 n13 removed the bot-review Request automated review from review-bot label Sep 2, 2026
@n13
n13 merged commit cbd8337 into main Sep 2, 2026
7 checks passed
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