Skip to content

fix: read metadata and runtime version at the head, not the finalized block - #154

Open
n13 wants to merge 1 commit into
mainfrom
n13/client-metadata-at-head
Open

fix: read metadata and runtime version at the head, not the finalized block#154
n13 wants to merge 1 commit into
mainfrom
n13/client-metadata-at-head

Conversation

@n13

@n13 n13 commented Sep 3, 2026

Copy link
Copy Markdown
Contributor

Found live: after Heisenberg enacted spec 148, quantus tech-referenda config still showed only track 0 with the 144 decision deposit of 1000 UNIT, so it looked like the fast_upgrade track had not shipped. It had — the CLI was reading the pre-upgrade runtime.

Cause

OnlineClient::from_rpc_client pins metadata and runtime version to latest_finalized_block_ref(). QPoW finality trails the head by ~100 blocks, so for roughly 20 minutes after any runtime upgrade enacts the client keeps talking to the old runtime.

Measured on Heisenberg at the time:

apply landed in block: 977079
finalized block:       977001   (78 blocks / ~15 min behind)

metadata @ head       -> fast_upgrade PRESENT   (spec 148)
metadata @ finalized  -> fast_upgrade ABSENT    (spec 144)

The client was already inconsistent with itself: the runtime-identity check calls state_getRuntimeVersion with no block argument, which answers at the head, while the metadata it kept was from the finalized block.

Consequences

  1. Governance config reads stale — the symptom above.
  2. Calls and storage added by the upgrade look absent, so commands that need them fail for ~20 minutes after an upgrade that in fact succeeded.
  3. transaction_version is wrong, which signs extrinsics the chain rejects. This is the one that actually bites: 144 → 148 moves it 3 → 6, and signing_context() reads the client's cached runtime version to pick the FIPS 204 signing context.

Change

After connecting, fetch metadata and runtime version at the head and install them via set_metadata / set_runtime_version. Both RPCs answer at the head when given no block argument.

This matches every other read path in the CLI — #152 made the same head-not-finalized correction for collect-rewards proofs, for the same reason.

A failure to read either is an error, not a fallback: silently continuing would leave the client on finalized metadata, which is precisely the bug being fixed.

Verification

Same command that exposed it, against live Heisenberg on 148:

• Track #0: tech_collective_members   Decision Deposit: 10000000000000   (10 UNIT, was showing 1000)
• Track #1: fast_upgrade              Prepare 50 blocks (~10min)
                                      Confirm 50 blocks (~10min)
                                      Min Enactment 50 blocks (~10min)

Track #1 now appears and track #0 shows 148's deposit.

  • cargo test --release --lib — 328 passed
  • SKIP_CIRCUIT_BUILD=1 cargo clippy --all-targets --locked -- -D warnings — clean
  • cargo +nightly fmt --all -- --check — clean

Note on ordering

Independent of #150, which is still in review. This touches only client construction; #150 touches decoding. They do not overlap, but #150 is branched from an older main and will want a rebase.

… block

subxt pins a client's metadata and runtime version to the latest finalized block.
QPoW finality trails the head by ~100 blocks, so for roughly 20 minutes after a
runtime upgrade enacts the CLI keeps talking to the old runtime:

- governance config reads stale. After Heisenberg enacted spec 148,
  tech-referenda config still listed only track 0 with the 144 decision deposit
  of 1000 UNIT, so the fast_upgrade track looked like it had not shipped.
- calls and storage added by the upgrade appear absent.
- transaction_version is wrong, which signs extrinsics the chain rejects. That
  matters here because 144 -> 148 moves it from 3 to 6.

Re-point both at the head after connecting, matching every other read path in the
CLI (#152 did the same for collect-rewards proofs). A failure to read them is an
error rather than a fallback, since silently continuing would leave the client on
finalized metadata -- the bug this fixes.
@n13 n13 added the bot-review Request automated review from review-bot label Sep 3, 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. src/chain/client.rs:149 installs head metadata into the one shared OnlineClient, but not every consumer reads the head. events --finalized, events --block, events --block-hash, block analysis, and SDK finalized paths pass older block hashes to Subxt, which still decodes them with this single head metadata snapshot. This is a concrete regression across an upgrade boundary. During review, Heisenberg's head was spec/tx 148/6 while finalized block 977072 was still 144/3 and the metadata payloads differed. Before this patch, events --finalized decoded all 10 events in block 977072. The exact PR binary, using spec-148 head metadata, fails on that same block after four events with Could not decode Phase, variant doesn't exist. Please keep finalized/historical reads on metadata from their target block (or separate the head-oriented client from block-specific clients) instead of globally assuming every caller targets the head.

  2. src/chain/client.rs:187-205 does not capture one coherent head snapshot. state_getMetadata and state_getRuntimeVersion each resolve an omitted block argument independently, so an upgrade block becoming best between the two calls leaves old metadata paired with a new runtime version. The identity gate at lines 155-167 then makes a third unpinned version request and cannot detect that mismatch. Because this client does not run Subxt's runtime updater, the inconsistent pair persists for the command and can reproduce the missing-call or invalid-extrinsic behavior this patch is intended to eliminate. Capture the best block hash once, request both metadata and runtime version at that hash, validate that same version response, and add coverage for the upgrade-boundary case.

Non-blocking accuracy note: Subxt 0.44.3's LegacyBackend::current_runtime_version already calls state_getRuntimeVersion(None) at the head; its metadata fetch is the part pinned to the finalized hash. The comments and PR rationale should not claim both values were previously finalized.

Validation on exact head 250ef8a0138acdd5fc129b266f9867beec2b001f:

  • git diff --check - passed.
  • taplo format --check --config taplo.toml - passed.
  • cargo +nightly-2026-08-31 fmt --all -- --check - passed.
  • cargo metadata --locked --no-deps --format-version 1 - passed.
  • SKIP_CIRCUIT_BUILD=1 cargo test --release --locked --lib chain::client::tests - 3 passed; the new retargeting path itself has no automated coverage.
  • SKIP_CIRCUIT_BUILD=1 cargo clippy --all-targets --locked -- -D warnings - passed.
  • Live Heisenberg tech-referenda config - passed and showed both spec-148 tracks, confirming the intended head-read fix.
  • Live Heisenberg historical-event reproduction at block 977072 (0x705dadae...c685f5) - failed only after the client was retargeted to spec-148 metadata, as described above.

All current hosted checks pass, including Ubuntu/macOS builds and tests, examples, strict analysis/docs, formatting, security audit, and dependency cooldown.

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