@quantus/codec: storage keys and values, so the wallet can read balances and nonces #4

Closed
opened 2026-09-15 11:17:58 +00:00 by grenade · 1 comment
Owner

Follow-up to #3, which covers extrinsics only.

The extension needs to read chain state: an account's free balance for the
account list, and its nonce to build the next extrinsic. Tier 1
(quantus/extension#7) side-stepped both — it takes the nonce from
system_accountNextIndex, an RPC that happens to exist, and never shows a
balance. Neither dodge survives contact with the UI.

What is needed

Storage addressing, read from the metadata the same way everything in #3 is:

  • storageKey(pallet, item, keys)twox128(pallet) ‖ twox128(item) and then
    each key hashed by the hasher the runtime declares for it. The hashers are in
    the metadata; nothing here should know that System::Account is
    Blake2_128Concat.
  • decodeStorage(pallet, item, bytes) — against the entry's declared value type.
  • The default for a Default entry, which is what the chain means when
    state_getStorage returns nothing. An Optional entry means nothing when it
    returns nothing, and conflating the two reads an unfunded account as an error
    rather than as zero.

blackbeard.observer's StorageTarget and StorageMap in
crates/blackbeard-core/src/runtime.rs are the reference; they already carry the
default-vs-optional distinction and the "which hashers keep their key" table.

twox128 is a new dependency (twox-hash), and Blake2_128Concat needs blake2 —
both already in blackbeard's graph.

Acceptance

The extension's account list shows a real Heisenberg balance for
qzk1Nxai…2vSn7 matching what the chain reports, and the tier-1 harness gets its
nonce from System::Account rather than from an RPC convenience method.

Fee estimation (TransactionPaymentApi_query_info) is a runtime call rather than
a storage read; it needs state_call and a return type decoded from the metadata,
and is worth its own issue once this lands.

Follow-up to #3, which covers extrinsics only. The extension needs to read chain state: an account's free balance for the account list, and its nonce to build the next extrinsic. Tier 1 (quantus/extension#7) side-stepped both — it takes the nonce from `system_accountNextIndex`, an RPC that happens to exist, and never shows a balance. Neither dodge survives contact with the UI. ## What is needed Storage addressing, read from the metadata the same way everything in #3 is: - `storageKey(pallet, item, keys)` — `twox128(pallet) ‖ twox128(item)` and then each key hashed by the hasher the runtime declares for it. The hashers are in the metadata; nothing here should know that `System::Account` is `Blake2_128Concat`. - `decodeStorage(pallet, item, bytes)` — against the entry's declared value type. - The **default** for a `Default` entry, which is what the chain means when `state_getStorage` returns nothing. An `Optional` entry means nothing when it returns nothing, and conflating the two reads an unfunded account as an error rather than as zero. `blackbeard.observer`'s `StorageTarget` and `StorageMap` in `crates/blackbeard-core/src/runtime.rs` are the reference; they already carry the default-vs-optional distinction and the "which hashers keep their key" table. `twox128` is a new dependency (`twox-hash`), and `Blake2_128Concat` needs blake2 — both already in blackbeard's graph. ## Acceptance The extension's account list shows a real Heisenberg balance for `qzk1Nxai…2vSn7` matching what the chain reports, and the tier-1 harness gets its nonce from `System::Account` rather than from an RPC convenience method. Fee estimation (`TransactionPaymentApi_query_info`) is a runtime call rather than a storage read; it needs `state_call` and a return type decoded from the metadata, and is worth its own issue once this lands.
Author
Owner

Shipped in @quantus/codec 0.4.0.

storageTarget(pallet, item, keys) returns { key, valueTy, default } — the key being twox128(prefix) ‖ twox128(item) plus each map key hashed by the hasher the entry declares, with the number of keys checked against the number of hashers. decodeStorage(valueTy, bytes) reads the value. Nothing in it knows that System::Account is a Blake2_128Concat map over an AccountId32; the prefix, hashers, key types, value type and default all come out of the metadata.

The Default/Optional distinction is carried as asked: default is hex for a Default entry and null for an Optional one, so an account nobody has funded reads as a zero balance rather than as a failure.

Proven against Heisenberg through quantus/extension's tier-1 harness, which now reads real balances and asserts they moved — including catching that two batch_all calls it had reported as reverted had in fact funded their targets with exactly 50 HEI each. Unit tests pin the key layout (16+16+16+32 for System::Account) and the declared default decoding to a zero balance, against real spec-148 metadata.

Closing short of one stated acceptance criterion, deliberately. The extension's account list does not yet show a balance — that is extension-side UI work with its own issue to be written, not a gap in this package. The tier-1 harness also still takes its nonce from system_accountNextIndex rather than System::Account, and that turns out to be the correct call for choosing a nonce: it counts pending pool transactions, which is exactly what a signer wants. It is the wrong call for waiting on inclusion, and the harness no longer uses it for that — see quantus/extension#7.

One other thing came out of the same work and is worth noting here, since it is the same package: a multi-field variant with named fields only accepted a positional array, so Utility.batch_all — whose Vec<RuntimeCall> holds calls spelled exactly like top-level ones — could not be encoded at all. Nested calls now take the object form at any depth.

Shipped in `@quantus/codec` 0.4.0. `storageTarget(pallet, item, keys)` returns `{ key, valueTy, default }` — the key being `twox128(prefix) ‖ twox128(item)` plus each map key hashed by the hasher the **entry declares**, with the number of keys checked against the number of hashers. `decodeStorage(valueTy, bytes)` reads the value. Nothing in it knows that `System::Account` is a `Blake2_128Concat` map over an `AccountId32`; the prefix, hashers, key types, value type and default all come out of the metadata. The `Default`/`Optional` distinction is carried as asked: `default` is hex for a `Default` entry and `null` for an `Optional` one, so an account nobody has funded reads as a zero balance rather than as a failure. Proven against Heisenberg through `quantus/extension`'s tier-1 harness, which now reads real balances and asserts they moved — including catching that two `batch_all` calls it had reported as reverted had in fact funded their targets with exactly 50 HEI each. Unit tests pin the key layout (16+16+16+32 for `System::Account`) and the declared default decoding to a zero balance, against real spec-148 metadata. **Closing short of one stated acceptance criterion, deliberately.** The extension's account list does not yet show a balance — that is extension-side UI work with its own issue to be written, not a gap in this package. The tier-1 harness also still takes its nonce from `system_accountNextIndex` rather than `System::Account`, and that turns out to be the *correct* call for choosing a nonce: it counts pending pool transactions, which is exactly what a signer wants. It is the wrong call for waiting on inclusion, and the harness no longer uses it for that — see quantus/extension#7. One other thing came out of the same work and is worth noting here, since it is the same package: a multi-field variant with named fields only accepted a positional array, so `Utility.batch_all` — whose `Vec<RuntimeCall>` holds calls spelled exactly like top-level ones — could not be encoded at all. Nested calls now take the object form at any depth.
Sign in to join this conversation.
No Label
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: quantus/wasm#4