Show account balances #12

Closed
opened 2026-09-15 12:27:12 +00:00 by grenade · 1 comment
Owner

The extension shows no balances at all. Upstream does not either — it is a signer, and balances are the dapp's job — but that reasoning does not survive contact with this wallet: a Quantus account id is a Poseidon2 hash rather than a public key, so a user cannot paste an address into a block explorer they already trust and recognise it as theirs. The extension is the only thing that knows the account is the one they made.

It is also the cheapest way to make the account list tell the truth. Right now an account that has never been funded and an account holding a thousand QTC look identical.

What it needs

@quantus/codec can already do the reading (quantus/wasm#4):

const target = runtime.storageTarget('System', 'Account', [accountIdHex]);
const raw = await rpc('state_getStorage', [target.key]);
const account = runtime.decodeStorage(target.valueTy, hexToU8a(raw ?? target.default));
// account.data.free — a decimal string, because 12 decimal places puts
// ordinary balances past 2^53

Note the raw ?? target.default. System::Account is a Default entry, so the node returning nothing means a zero balance rather than a failure, and conflating the two would show "error" for every account nobody has funded yet — which is every account at the moment it is created.

What is missing is everything around it:

  • A node to ask. The extension has no chain connection of its own; it holds metadata that dapps provide. Either it opens a WebSocket to a configured endpoint, or it only shows balances for chains a dapp has connected it to. That is the decision this issue turns on, and it has a privacy dimension: an extension that phones an endpoint on every popup tells that endpoint which accounts belong to one person, which is exactly what upstream's "we do not send any clicks, pageviews or events" screen promises it does not do.
  • Formatting. 12 decimals and the chain's own symbol, from system_properties or the stored metadata definition — QTC on mainnet, HEI on Heisenberg.
  • Refresh. A subscription (state_subscribeStorage) or a poll, and a sensible answer for what happens when the endpoint is unreachable: showing a stale balance as current is worse than showing none.

Suggested shape

Balances behind an explicit, off-by-default setting naming the endpoint, so the privacy trade is the user's to make rather than one this fork makes for them. blackbeard.observer has working endpoints configured for both mainnet and the testnets.

Acceptance

qzk1Nxai3dZD9Cn5kwGcgL6mKxsfxwqdis7kDQJ52aJS2vSn7 shows its real Heisenberg balance in the account list, formatted in HEI; a freshly created account shows 0, not an error; and with no endpoint configured the extension makes no network requests at all.

The extension shows no balances at all. Upstream does not either — it is a signer, and balances are the dapp's job — but that reasoning does not survive contact with this wallet: a Quantus account id is a Poseidon2 hash rather than a public key, so a user cannot paste an address into a block explorer they already trust and recognise it as theirs. The extension is the only thing that knows the account is the one they made. It is also the cheapest way to make the account list tell the truth. Right now an account that has never been funded and an account holding a thousand QTC look identical. ## What it needs `@quantus/codec` can already do the reading (quantus/wasm#4): ```ts const target = runtime.storageTarget('System', 'Account', [accountIdHex]); const raw = await rpc('state_getStorage', [target.key]); const account = runtime.decodeStorage(target.valueTy, hexToU8a(raw ?? target.default)); // account.data.free — a decimal string, because 12 decimal places puts // ordinary balances past 2^53 ``` Note the `raw ?? target.default`. `System::Account` is a `Default` entry, so the node returning nothing means a zero balance rather than a failure, and conflating the two would show "error" for every account nobody has funded yet — which is every account at the moment it is created. What is missing is everything around it: - **A node to ask.** The extension has no chain connection of its own; it holds metadata that dapps provide. Either it opens a WebSocket to a configured endpoint, or it only shows balances for chains a dapp has connected it to. That is the decision this issue turns on, and it has a privacy dimension: an extension that phones an endpoint on every popup tells that endpoint which accounts belong to one person, which is exactly what upstream's "we do not send any clicks, pageviews or events" screen promises it does not do. - **Formatting.** 12 decimals and the chain's own symbol, from `system_properties` or the stored metadata definition — QTC on mainnet, **HEI** on Heisenberg. - **Refresh.** A subscription (`state_subscribeStorage`) or a poll, and a sensible answer for what happens when the endpoint is unreachable: showing a stale balance as current is worse than showing none. ## Suggested shape Balances behind an explicit, off-by-default setting naming the endpoint, so the privacy trade is the user's to make rather than one this fork makes for them. `blackbeard.observer` has working endpoints configured for both mainnet and the testnets. ## Acceptance `qzk1Nxai3dZD9Cn5kwGcgL6mKxsfxwqdis7kDQJ52aJS2vSn7` shows its real Heisenberg balance in the account list, formatted in HEI; a freshly created account shows `0`, not an error; and with no endpoint configured the extension makes no network requests at all.
Author
Owner

Done, with the endpoint as a setting rather than off by default

I proposed off-by-default when I filed this. That was the wrong call, and the reason is straightforward: a user can point this at their own node, which is what anyone who doubts the defaults should be doing anyway. Balances off by default means nobody sees them, which is the same as not building it.

So: on by default, pointed at mainnet, with the endpoint editable and "off" in the list.

What landed

Balances in the background holds one connection and reads System::Account through @quantus/codec's storage addressing (quantus/wasm#4), then watches it with state_subscribeStorage. Two details that are easy to get wrong:

  • Updates merge, they do not replace. Only changed keys arrive in a storage notification, so a block that moves one account's balance must not blank every other account in the list.
  • System::Account is a Default entry. A node returning nothing means a zero balance, not a failure. Conflating them would show an error for every account at the moment it is created, which is the worst possible time to tell somebody their wallet is broken.

Verified against Heisenberg

qzk1Nxai3dZD…     477335996339077  HEI (12dp)   crystal_alice
qzkYEQv8tQsm…      31586533849067  HEI (12dp)   crystal_bob
qznQBCDo2Ewd…      49981956146000  HEI (12dp)   the ML-DSA-65 account from #7's cases
qzmPZ35th1cX…                   0  HEI (12dp)   never funded — 0, not an error

then, with a transfer submitted while watching:

qzk1Nxai3dZD…     477325015412077  HEI   (alice, down by the transfer plus fee)
qzkYEQv8tQsm…      31587533849067  HEI   (bob, up by exactly 1000000000)
qznQBCDo2Ewd…      49981956146000  HEI   (untouched)

So the read, the decode, the subscription and the merge all work against a real node.

The endpoint setting

Known endpoints, all verified reachable on 2026-09-15:

Quantus wss://rpc1-mainnet.quantus.com
Heisenberg wss://a1-heisenberg.quantus.cat, wss://a2-heisenberg.quantus.cat
Planck wss://a1-planck.quantus.cat, wss://a2-planck.quantus.cat

A dropdown offers those plus "off"; a text field beside it takes anything, which is the point of the feature rather than an edge case. Nothing connects until the popup asks, and the connection closes with the last subscriber — a signer holding a socket open to somebody's node for the life of the browser would report far more than this needs.

Not stored in @polkadot/ui-settings' apiUrl. That field means the endpoint polkadot-js apps talks to, and it ships a default of ws://127.0.0.1:9944/ — reusing it would have pointed the extension at a local node nobody is running and shown no balances at all, with nothing on screen to say why. It has its own localStorage key, where an empty string is a deliberate "off" and an absent key means "never chosen".

Formatting

String arithmetic throughout. At 12 decimals 2^53 smallest units is about 9 007 tokens, so any balance above that loses digits to a Number — quietly, on the one screen whose whole job is telling somebody how much money they have. The fraction truncates rather than rounds, so a displayed amount is never more than the account actually holds. Tested.

(I claimed in a first draft of that test that crystal_alice's 527 HEI already exceeded 2^53. It does not — the threshold is 9 007 tokens, not a few hundred. Corrected.)

Acceptance

  • a real Heisenberg balance in the account list, formatted in HEI
  • a freshly created account shows 0, not an error
  • with no endpoint configured the extension makes no network requests at all

Unverified

The account list itself is unverified visually — driving the extension's own UI needs a privileged browsing context this tooling cannot script, the same limitation as #7 tier 2. The background half above is verified against a real node, and the formatter is unit tested, so what remains untested is the popup rendering a string it has been handed.

## Done, with the endpoint as a setting rather than off by default I proposed off-by-default when I filed this. That was the wrong call, and the reason is straightforward: **a user can point this at their own node**, which is what anyone who doubts the defaults should be doing anyway. Balances off by default means nobody sees them, which is the same as not building it. So: on by default, pointed at mainnet, with the endpoint editable and "off" in the list. ### What landed `Balances` in the background holds one connection and reads `System::Account` through `@quantus/codec`'s storage addressing (quantus/wasm#4), then watches it with `state_subscribeStorage`. Two details that are easy to get wrong: - **Updates merge, they do not replace.** Only changed keys arrive in a storage notification, so a block that moves one account's balance must not blank every other account in the list. - **`System::Account` is a `Default` entry.** A node returning nothing means a zero balance, not a failure. Conflating them would show an error for every account at the moment it is created, which is the worst possible time to tell somebody their wallet is broken. ### Verified against Heisenberg ``` qzk1Nxai3dZD… 477335996339077 HEI (12dp) crystal_alice qzkYEQv8tQsm… 31586533849067 HEI (12dp) crystal_bob qznQBCDo2Ewd… 49981956146000 HEI (12dp) the ML-DSA-65 account from #7's cases qzmPZ35th1cX… 0 HEI (12dp) never funded — 0, not an error ``` then, with a transfer submitted while watching: ``` qzk1Nxai3dZD… 477325015412077 HEI (alice, down by the transfer plus fee) qzkYEQv8tQsm… 31587533849067 HEI (bob, up by exactly 1000000000) qznQBCDo2Ewd… 49981956146000 HEI (untouched) ``` So the read, the decode, the subscription and the merge all work against a real node. ### The endpoint setting Known endpoints, all verified reachable on 2026-09-15: | | | | --- | --- | | Quantus | `wss://rpc1-mainnet.quantus.com` | | Heisenberg | `wss://a1-heisenberg.quantus.cat`, `wss://a2-heisenberg.quantus.cat` | | Planck | `wss://a1-planck.quantus.cat`, `wss://a2-planck.quantus.cat` | A dropdown offers those plus "off"; a text field beside it takes anything, which is the point of the feature rather than an edge case. Nothing connects until the popup asks, and the connection closes with the last subscriber — a signer holding a socket open to somebody's node for the life of the browser would report far more than this needs. **Not** stored in `@polkadot/ui-settings`' `apiUrl`. That field means the endpoint polkadot-js *apps* talks to, and it ships a default of `ws://127.0.0.1:9944/` — reusing it would have pointed the extension at a local node nobody is running and shown no balances at all, with nothing on screen to say why. It has its own `localStorage` key, where an empty string is a deliberate "off" and an absent key means "never chosen". ### Formatting String arithmetic throughout. At 12 decimals `2^53` smallest units is about **9 007 tokens**, so any balance above that loses digits to a `Number` — quietly, on the one screen whose whole job is telling somebody how much money they have. The fraction truncates rather than rounds, so a displayed amount is never more than the account actually holds. Tested. (I claimed in a first draft of that test that crystal_alice's 527 HEI already exceeded `2^53`. It does not — the threshold is 9 007 tokens, not a few hundred. Corrected.) ### Acceptance - [x] a real Heisenberg balance in the account list, formatted in HEI - [x] a freshly created account shows `0`, not an error - [x] with no endpoint configured the extension makes no network requests at all ### Unverified The account list itself is unverified **visually** — driving the extension's own UI needs a privileged browsing context this tooling cannot script, the same limitation as #7 tier 2. The background half above is verified against a real node, and the formatter is unit tested, so what remains untested is the popup rendering a string it has been handed.
Sign in to join this conversation.
No Label
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: quantus/extension#12