fix: decode chain state against live metadata, not bundled codegen - #150
Conversation
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
left a comment
There was a problem hiding this comment.
Reviewer model: GPT Sol
Verdict (advisory): Request changes
Blocking findings:
-
[P1] Preserve the declared type when rendering 32-byte call arguments —
src/cli/multisig.rs:2426-2471.render_call_argtriesaccount_bytesbefore considering the argument's metadata type, whileaccount_bytesdeliberately accepts any bare 32-byte sequence and recursively any newtype containing one. As a result, non-account arguments such asSystem::authorize_upgrade.code_hash: H256(and even a 32-byteSystem::remarkpayload) 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 anH256. -
[P2] Route the remaining exercise preimage reads through the dynamic decoder —
src/cli/exercise/scenarios/preimage.rs:45-59. This PR addsfetch_request_statusbecausePreimage::RequestStatusForembeds the runtime-local deposit ticket, butnote_and_verifyandpreimage_status_existsstill fetch the generated static address. Against the older/differently composed runtimes targeted by this PR, those steps still fail withMetadata(IncompatibleCodegen), leaving the preimage exercise scenario broken. Reusefetch_request_statusat 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.
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.
|
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.
Two regressions, both directions:
The second fails against the previous implementation, which is the point. P2 — exercise preimage reads. Both Re-validated at
|
n13
left a comment
There was a problem hiding this comment.
Reviewer model: GPT Sol
Verdict (advisory): Request changes
Blocking finding:
- [P1] Gate byte-blob rendering on the declared metadata type —
src/cli/multisig.rs:2460-2484,src/cli/dynamic_decode.rs:48-53.byte_blobdecides that any all-numeric composite is bytes, while the shareduinthelper recursively takes element zero from any composite. For the liveMultisig::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. Resolvevalue.contextthrough the metadata and only flatten actual byte sequences/arrays (plus deliberate transparent wrappers such asH256orBoundedVec<u8>); otherwise preserve the full structured value. Add a regression with at least two distinctAccountId32signers.
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.
…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
left a comment
There was a problem hiding this comment.
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.
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.rscome 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::Agenda—quantus scheduler agendaA 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:
after:
That is a live referendum alarm —
nudge_referendum,origin: system (Root ()), the veryOriginCallerthat broke the static decode. The old code also printed✅ Finished scanningafter failing every block; it now exits non-zero.2. Exercise suite — three static
ReferendumInfoForreadsgovernance.rs:92,upgrade.rs:158,upgrade.rs:181were literally the bug #149 fixed, still present. They now reusefetch_referendum/ReferendumSnapshot.3.
Preimage::RequestStatusForEmbeds the runtime-local
PreimageDepositticket. Gatedquantus preimage status/listand — more importantly — thetech-referenda submitpath, which reads it forpreimage_len.4. Multisig proposal call rendering — the one that failed silently
decode_call_dataresolved the pallet name from live metadata, then matched hardcoded call indices: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_valuenavigation helpers #149 added privately are nowcli::dynamic_decode, used by the referendum, preimage, and multisig decoders instead of being copied.Output
Unchanged, except two deliberate cases:
Scheduler::Agendamoves from a Rust{:?}dump toscale_valuerendering (the trade #149 already made fortech-referenda get), and unknown multisig calls now report the index instead of a fabricated name.Verification
SKIP_CIRCUIT_BUILD=1 cargo clippy --all-targets --locked -- -D warningscleancargo +nightly fmt --all -- --checkcleanscheduler agendaandtech-referenda statusexercised against live Heisenberg spec 144Still not covered
Two write paths embed
RuntimeCall—Multisig::executeandUtility::batch_all(send.rs:278, reached bybatch 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.