Choosing another endpoint in settings updated balances only eventually. Three
things were wrong:
- The popup read the endpoint only when it rendered. Changing the setting
didn't re-render the popup, so the switch waited for some unrelated update.
- The old subscription was never ended. Each switch added a subscription
beside the old one, and whichever endpoint the background connected to last
won.
- The custom-node field saved every keystroke. Once the switch is immediate,
that means connecting to `w`, `ws`, `ws:`, and so on.
Now:
- the balance hook follows the setting, including a change made in another
extension page;
- it clears balances at once, rather than showing another chain's while the
new one loads;
- it ends the old subscription through a new pri(balances.unsubscribe);
- the background shares one in-flight connection, and discards a connection or
read that a later choice has superseded;
- a typed endpoint is used on Enter or when the field loses focus.
Verified in Firefox against the live nodes, with crystal_alice imported:
- mainnet -> Heisenberg shows 477.325 HEI after 1.5s;
- Heisenberg -> Planck shows 0 PLK after 0.4s;
- Planck -> mainnet shows 0 QTC after 1.0s;
- each switch clears the old balance within 0.1s;
- typing a node address leaves the saved endpoint unchanged until blur.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012uDUodEcRbBwNRi3UCmw8f
Under MV3 the background is not persistent. Firefox suspends an idle event
page after 30 seconds, and Chrome stops an idle service worker. An open port
does not count as activity.
A user importing an account spent longer than that typing a name and
password. The background suspended and discarded the popup's subscriptions.
Adding the account then woke it, and the account was saved. The popup never
re-subscribed, so it kept showing the empty list until a reload.
Upstream reconnected lazily on the next message and forgot what it had
subscribed to. Every caller also raced to open its own port. Pages and the
content script now share one client, which:
- opens a single connection however many requests arrive together;
- re-sends subscriptions, with their original ids, as soon as the connection
drops;
- rejects requests left unanswered by a restart instead of leaving them
hanging;
- keeps the background awake with a 20s wake-up while anything is
outstanding, so a sign request under review, or a popup left open, is not
lost to the idle timeout.
The content script replays only pub(accounts.subscribe). RPC subscriptions
are keyed by an id the background hands out, and a replay can't reproduce
that.
Verified in Firefox:
- With the idle timeout at 5s, importing an ML-DSA-87 account across three
background suspensions shows it in the list without a reload. The previous
build reproduces the bug under the same conditions.
- At the default 30s timeout, an open page saw no disconnects in 75s.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012uDUodEcRbBwNRi3UCmw8f
Both variants of the runtime's DilithiumSignatureScheme are in use on the
chain: ML-DSA-65 is the default, and ML-DSA-87 holds existing accounts,
crystal_alice among them. The backend already signed with either one. The
UI, following upstream, picked the type from the network, so an ML-DSA-87
account could not be created or imported from a phrase.
Create and Import now show a "Key type" selector. The same phrase gives a
different account under each type, so Import keeps the selector out of
"advanced" and warns next to it. Changing the network no longer resets the
user's choice. Create re-derives the address preview when the type changes.
The backend specs prove it end to end:
- the zero seed validated as ML-DSA-87 gives crystal_alice's account id;
- the same seed as ML-DSA-65 gives a different account;
- an account created as ML-DSA-87 is stored as dilithium87 and signs with
variant 0 at ML-DSA-87 size.
The React UI specs are not run. Upstream skips them ("FIXME hanging") and
leaves .spec.tsx out of `yarn test`. They also can't run under node's test
runner: enzyme needs cheerio's removed lib/utils subpath, and jest.spyOn
can't redefine ESM namespace exports. The dropdowns themselves are covered
only by typecheck, lint and build.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012uDUodEcRbBwNRi3UCmw8f
Substrate chains describe themselves. Almost nothing that talks to them takes
advantage of it: polkadot-js hardcodes `ExtrinsicSignature: 'MultiSignature'` in
a definitions file and caps fixed arrays at 2048 bytes, polkadot-api reads the
signature type out of metadata and then discards the answer for a three-name
whitelist, and its block header is a static struct. Each is a client deciding in
advance what a chain is allowed to be. This extension already reads calls and
signed extensions from the runtime; this is the last piece that did not — where
the runtime's description came from.
It came from the dapp. Two problems, and the second is the serious one.
**It does not always happen.** Not every dapp calls `metadata.provide` — the qapi
console does not, because `@polkadot-api/pjs-signer` narrows the injected
interface to `{ name, getAccounts, subscribe, disconnect }` and never surfaces
that half. Since this extension refuses to sign for a chain it cannot describe,
such a dapp could not get a signature at all.
**And the dapp is not a trustworthy source.** Metadata decides what the approval
screen *says* a call does. A dapp supplying its own controls both the
transaction and its description, so it can render something harmless and have
the user sign a transfer. Asking the chain means the description and the thing
described come from the same place, and it is not the page asking for the
signature.
So `ChainMetadata` fetches it, and `pub(extrinsic.sign)` awaits that before
queuing — before the popup, not after approval, because a user asked to approve
a transaction rendered as raw hex has been given nothing to approve.
Three things that make this safe rather than merely convenient:
- **Endpoints are believed only if they prove their identity.** A sign request
names a genesisHash; the endpoint's `chain_getBlockHash(0)` must equal it.
Without that the table of hosts would itself be the thing taken on trust,
which is not an improvement over trusting the dapp. Verified by deliberately
pointing Planck's genesis at Heisenberg's nodes: both were rejected —
"serves 0xa5aa9e5c…, not 0x4901bf5c…" — and the fetch returned null.
- **The payload's spec version is the cache key, not mere presence.** A runtime
upgrade changes which calls and signed extensions exist, and metadata from
before one decodes this chain's calls into something plausible and wrong.
It also keeps the common case off the network entirely. Mainnet is on spec
152 today where the testnets are on 148, so this is not hypothetical.
- **A chain with no endpoint returns null instantly**, with no connection
attempted, and the signer's existing refusal explains it.
Verified against the live chains rather than by unit test: Quantus spec 152 QTC,
Heisenberg 148 HEI, Planck 148 PLK, each 101 KB of metadata with ss58 189;
unknown chain null in 0 ms; second call served from cache in 0 ms.
`extrinsicSign` is now async before it queues, so the specs poll for the request
instead of assuming it is already there — it was a race they had been winning by
accident.
92 tests pass, lint clean, builds.
Refs #1, quantus/papi-console#3
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012uDUodEcRbBwNRi3UCmw8f
The feature was not gone — it is the last item in the settings menu, and it
works. But it is wrong about *when* somebody wants it. The popup closes the
moment focus leaves it, which is exactly while copying an address or a recovery
phrase between tabs, and at that point "open the menu, scroll, click" is two
interactions too many — each of which can itself dismiss the thing being
rescued.
So the same action gets an expand icon beside the add and settings ones,
wherever a header is shown and the window is popup-sized. The menu entry stays:
one is discoverable by name, the other is fast.
Still opens `/` rather than the current screen. ALLOWED_PATH is an allowlist
guarding what can be opened as an extension URL, and widening it to every route
to preserve a scroll position is not a trade worth making.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012uDUodEcRbBwNRi3UCmw8f
Upstream shows an Asset Hub migration notice ahead of everything else on first
open. It is about balances moving from the Polkadot Relay Chain to Asset Hub,
and neither chain exists here — but it is worse than irrelevant, because it told
every user of this extension that their balances had been migrated somewhere and
warned them in red capitals not to teleport to a chain it cannot reach. A wallet
that opens by describing something that did not happen to money the user does
hold is not a cosmetic problem.
Gone with it: the component, the route gate, the `asset_hub_migration_read`
flag and its nine translation strings.
Also regenerates the translation file from source, which dropped 52 more strings
and added none. The scanner adds keys it finds but never prunes ones it does not,
so every UI removal so far has left its text behind — the Ledger path (#5) and
the Parity Signer QR flow account for most of it, along with camera and
network-selection strings from paths that no longer exist. Verified the survivors
rather than assuming: nothing dropped appears as a `t()` call anywhere under
`packages/extension-ui/src`, which is the whole of what the scanner reads.
Refs #2
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012uDUodEcRbBwNRi3UCmw8f
Upstream shows none: polkadot-js's extension is a signer, and a balance is the
dapp's business. That reasoning does not survive contact with this wallet. A
Quantus account id is a Poseidon2 hash of the public key, not the public key, so
somebody cannot paste an address into a block explorer they already trust and
satisfy themselves it is the account they made — the extension is the only thing
that knows. It is also the only way the account list can tell the truth: without
it, an account nobody has funded and an account holding a thousand QTC look
alike.
`Balances` in the background holds one connection and reads `System::Account`
through @quantus/codec's storage addressing, then watches it with
`state_subscribeStorage`. Only changed keys arrive, so updates merge rather than
replace — a block that moves one account must not blank the rest of the list.
`System::Account` is a **Default** entry, so a node returning nothing means a
zero balance, not a failure; conflating those would show an error for every
account at the moment it is created.
Verified against Heisenberg: four accounts read correctly including one nobody
has ever funded (0, not an error), and a live transfer moved the recipient by
exactly 1000000000 through the subscription while the other three stayed put.
The endpoint is a setting, with the known Quantus endpoints offered and a free
text field beside them. Asking a node for balances tells that node which accounts
belong to one person; anyone who doubts a default should point this at their own
node, and "off" is in the list, after which nothing connects at all. 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 the feature needs.
Kept under its own localStorage key rather than @polkadot/ui-settings' `apiUrl`:
that field means the endpoint polkadot-js *apps* talks to and ships a default of
`ws://127.0.0.1:9944/`, so 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.
Formatting is string arithmetic throughout. At 12 decimal places `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 saying how much money somebody has.
The fraction truncates rather than rounds, so a displayed amount is never more
than the account holds.
Closes#12
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012uDUodEcRbBwNRi3UCmw8f
Two halves of #6.
**The approval screen names its recipient.** Moving call decoding onto
@quantus/codec left `dest` rendering as 32 bytes of hex, because the codec has no
business guessing an SS58 prefix. `metadataExpand` now tells it the one the
chain's own metadata definition carries, so a transfer shows
`qzkYEQv8tQsmniZYdame3Cku18RL5g9bGK9Pdydq5TMPdpE3y` rather than
`0x300bb607…`. Somebody approving a transfer has to be able to check the
recipient against what they meant to send to, and hex is the form nobody checks.
Account ids are identified by registry path, not by length, so a 32-byte block
hash still renders as hex — quantus/wasm has the test.
**The fallback prefix is 189, not 42.** Upstream falls back to the generic
Substrate prefix because it is a wallet for every Substrate chain and has no
reason to prefer one. This one does: every account it can hold is a Quantus
account, so 42 would show a correct address in a form no Quantus tool displays —
the same account id, the same funds, an unfamiliar string. Somebody comparing the
extension against quantus-cli or the mobile wallet would reasonably conclude they
had created the wrong account. A chain's own ss58Format still wins where one is
known, and the setting still overrides.
Refs #6, quantus/wasm#3
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012uDUodEcRbBwNRi3UCmw8f
`cases.mjs`, against Heisenberg at spec 148. All five pass:
PASS the empty signing context is rejected — 1010: bad signature
PASS a payload over 256 bytes is hashed before signing — 279 -> blake2 32
PASS a batch_all over 256 bytes dispatches — block 1050692, funded 50 HEI
PASS an ML-DSA-65 account signs and is accepted — block 1050693
PASS an account exported to JSON and re-imported still signs — block 1050694
The first is the one worth having. Every other failure mode here surfaces as a
decode error somewhere; a signature under the wrong FIPS 204 context is
well-formed, verifies against its own key, and is refused only by the runtime —
so nothing local can tell it from a correct one.
**`submitAndInclude` does not watch the nonce**, and the reason is a mistake this
harness made first. `system_accountNextIndex` counts pending pool transactions,
so it advances the moment a transaction is *accepted* — before it has done
anything. Waiting on it and then reading a balance shows the state from before
the dispatch. Two `batch_all` calls were recorded as reverted on that basis when
both had funded their target with exactly what they were asked for; the accounts
were sitting there holding 50 HEI each while the run said they held nothing. It
now looks for the extrinsic's own bytes in the blocks that arrive, which also
exercises the decoder against real chain data — every Quantus block opens with a
bare-v5 timestamp inherent that @polkadot/api cannot read.
`submit.mjs` moves onto the same shared `chain.mjs` and now *asserts* the
recipient's balance moved rather than printing it, which it could not do before:
reading a balance needs storage addressing, which @quantus/codec only gained for
this (quantus/wasm#4).
Refs #7
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012uDUodEcRbBwNRi3UCmw8f
quantus/extension#7 tier 2: a page that asks the *extension* to sign, then
assembles and submits the extrinsic itself. `yarn tier2` bundles it; the README
has the run steps and where to get a funded dev account.
Every intermediate value is printed — the call, the extra, the payload that was
signed, the signature that came back, the assembled extrinsic, and the round trip
decoded back out of it. That display is the debugger when a node rejects
something, and it is exactly what a real app hides behind a success toast.
Nothing in it uses @polkadot/api. @quantus/codec produces every byte and the
WebSocket is a JSON-RPC client that decodes nothing.
Also extracts decodeMethod out of Extrinsic.tsx into util/, with tests. It moved
from `registry.createType('Call', data)` to the runtime in the previous commit and
was the one changed path with no coverage — `yarn test` skips .spec.tsx, so as a
component method it would have stayed that way. The tests pin the three cases
that matter more than the happy path: metadata from a different spec version
decodes a call into something plausible and wrong and must be refused; a chain
with no runtime renders hex; and bytes that are not a call must not throw, since
an exception there lands between somebody and their funds.
Verified so far in a browser: the page loads under Firefox with the extension
installed, and @quantus/codec parses Heisenberg's 101,493-byte metadata client
side and reports extrinsic v4 with all twelve signed extensions. The injected
signing round trip is **not** yet verified end to end — driving the extension's
own UI needs a privileged browsing context this tooling cannot script — so the
popup half of tier 2 still wants a human at a keyboard. The background half is
covered by Extension.spec.ts, which runs the full pub(extrinsic.sign) ->
pri(signing.approve.password) path.
Refs #7
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012uDUodEcRbBwNRi3UCmw8f
Tier 1 proved the crypto in a script. This puts the same path inside the
extension, which is what tier 2 needs: the background builds its signing payload
from the runtime's own metadata, and the approval screen decodes the call the
same way.
- RequestExtrinsicSign takes a Runtime instead of a polkadot-js Registry and
builds the payload with it. `ExtrinsicPayload.sign(pair)` could not survive
either half of this: @polkadot/types cannot describe this chain (fixed arrays
capped at 2048 bytes, ML-DSA signatures are 5261 and 7219), and it has
nowhere to put a FIPS 204 context, which an ML-DSA pair will not sign
without.
- metadataExpand builds a Runtime rather than calling registry.setMetadata,
which would throw on this chain's metadata. Chain.registry survives carrying
chain properties only.
- Extrinsic.tsx decodes the call through the runtime. Without this the approval
screen falls back to raw hex for every transaction — the failure mode where a
user approves bytes nobody read to them.
- A chain whose metadata the extension does not have is one it now **refuses**
to sign an extrinsic for, rather than guessing at the format. The refusal
rejects the dapp's promise as well as the popup's, so a page is told rather
than left waiting.
Raw-bytes signing is unchanged and still needs no metadata: there is no payload
to build and no extension to read.
The dapp-supplied `era` arrives already SCALE-encoded, and is round-tripped
against the runtime's own Era type rather than appended on trust — @quantus/codec
0.2.0 adds that, and rejects anything that does not come back identical.
Tests: upstream's `custom user extension` block was five variations on "does this
agree with @polkadot/api", each computing an expected signature with
registry.createType('ExtrinsicPayload').sign(pair). That comparison is no longer
meaningful, and the `userExtensions` mechanism it exercised — a dapp declaring in
JavaScript what an unrecognised signed extension contributes — is gone, which is
the point: the runtime declares its extensions and the wallet reads them, so
there is nothing for a dapp to assert and no way for it to be believed. Replaced
with tests for what the fork actually guarantees, against real Heisenberg
metadata committed as a fixture.
The harness now loads the keyring with type 'dilithium65' as background.ts does;
it had been making sr25519 accounts and testing the one keypair type this fork
does not target. The derivation specs name ed25519 explicitly, which is what they
were silently getting, and gain a test that an ML-DSA parent cannot derive.
Verified end to end after the rewire: included at Heisenberg block 1050581,
nonce 2762 -> 2763. 79 tests pass, lint clean, extension builds.
Refs #7, quantus/wasm#3
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012uDUodEcRbBwNRi3UCmw8f
The tier-1 harness now signs a real transfer and gets it into a real block:
accepted 0xd25bb081e4a87da6a2af79f038f6991e914d732322393096128894c0d644e806
included block 1050475 index 1
decoded {"Balances":{"transfer_keep_alive":{…,"value":"1000000000"}}}
nonce 2761 -> 2762
on Heisenberg at spec 148, signed by the forked keyring under QUANTUS_EXTRINSIC.
First end-to-end proof that the fork can spend.
It got there by dropping @polkadot/api entirely. Every byte on the wire is now
produced by @quantus/codec from metadata the node generated by running
Metadata_metadata against the runtime WASM; WsProvider appears only as a
JSON-RPC transport. The api route failed three times over — a 2048-byte cap on
fixed arrays, a preamble byte read as a version so that no Quantus block decodes
at all, and signed extensions *assumed* to be empty rather than read — and
quantus/api#1 has the evidence. The first submission through it was rejected as
`1010: Invalid Transaction: Transaction has a bad signature`; through the codec
the same transfer is accepted.
The harness reads the block back with the same runtime that built the extrinsic,
which also exercises what @polkadot/api cannot do at all: index 0 of every
Quantus block is a timestamp inherent whose preamble byte is 0x05 — bare,
version 5 — beside this signed extrinsic's 0x84, while the metadata declares
version 4.
Also moves the post-quantum chain gate here from @polkadot/networks. Gating the
library list broke @polkadot/api at import, because @polkadot/types-known throws
for a chain it holds upgrade history for but cannot find. What a wallet offers
is the wallet's decision, and this is the wallet.
Refs #7, quantus/api#1, quantus/wasm#3
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012uDUodEcRbBwNRi3UCmw8f
Extrinsic signing no longer goes through ExtrinsicPayload.sign(pair). That helper
calls pair.sign(encoded, { withType: true }) and has nowhere to put a signing
context — and an ML-DSA pair refuses to sign without one, so before this the
extension simply threw.
The two lines it would have run are reproduced here with the context added:
encode the payload, BLAKE2b-256 it if it exceeds 256 bytes (Substrate's own rule
from unchecked_extrinsic.rs, which signer and runtime must apply identically),
then sign. Forking @polkadot/types to thread an option through was the
alternative, and is a far larger commitment for the same result.
The context comes from the payload's specVersion, because that is the only place
it can come from: spec >= 148 verifies under QUANTUS_EXTRINSIC, earlier specs
under the empty context, and only the caller knows which runtime it is talking
to. Getting it wrong produces a signature that is cryptographically valid,
rejected by the chain, and indistinguishable from a correct one without asking a
node — which is why both directions are pinned by tests rather than assumed.
Raw bytes use the **empty** context, deliberately not the extrinsic one. A
signature made for a dapp login must never be replayable as a transfer, and the
context is bound into the signature itself. u8aWrapBytes stays, but it is not
what provides that guarantee — it is polkadot-js's own <Bytes> wrapping, kept so
a verifier written against that convention still sees what it expects. For ML-DSA
the context is authoritative: it cannot be stripped or forgotten by a verifier
the way an in-band wrapper can.
No Quantus-specific bytes context is invented. Nothing else in the ecosystem
defines one — the chain and the SDK name only QUANTUS_EXTRINSIC — and making one
up would produce signatures no other Quantus tool could verify.
Raw signing returns sig||pk, which is not a convenience: the account id is a
one-way Poseidon2 hash of the public key, so a verifier holding only an address
cannot recover the key. The key has to travel with the signature. Pinned by a
test that verifies against the signer's address and nothing else.
79 tests pass; typecheck, lint and build:chrome clean.
Refs quantus/extension#4, quantus/common#5
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012uDUodEcRbBwNRi3UCmw8f
New accounts are dilithium65. ML-DSA-87 stays importable — accounts predating the
recorded scheme, and the dev-genesis ones — but is never offered as a choice.
The important part of this change is not the default, it is splitting canDerive.
Upstream used canDerive in two unrelated places: gating the derive-from-parent
UI, and filtering which accounts are handed to dapps. That worked only because
every type it knew about happened to be derivable, so the second use was really
asking "is the type known". The coincidence ends with ML-DSA. Making canDerive
honest — false for lattice keys, which are not derivable from one another —
would have silently hidden **every Quantus account from every dapp**, leaving an
extension that looked empty to the entire ecosystem while holding perfectly good
accounts. It types and builds and tests clean either way, which is what makes it
worth a comment and a test rather than a one-line edit.
So: canInject for dapp injection, canDerive for actual derivation. Tabs.ts uses
the former.
canDerive returning false for ML-DSA removes the derive-from-parent flow on its
own — `master` in the account context is found with it, and the menu item is
gated on `master`. That is the right outcome and a permanent one: a pair holds
no material a child could come from, and pair.derive() refuses outright
(quantus/common#4).
Adding a second account is therefore importing the same phrase with an index,
which ImportSeed already supports — it builds `suri = seed + path` and the suri
rules accept `//1`. Its advanced field is relabelled from "derivation path" to an
account index, with a note saying each Quantus account derives from the phrase
independently, because "derivation path" invites a junction chain that will be
rejected.
getSuri needs no change: it only special-cases ethereum, and returns the phrase
untouched for everything else, which is what the Quantus path wants.
Verified rather than assumed — the new spec checks the default type actually
produces the address quantus-cli derives for the public dev phrase, that //1
gives a different account, and that ML-DSA is injectable but not derivable.
70 tests pass; typecheck, lint and build:chrome clean.
Refs quantus/extension#3, quantus/extension#8
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012uDUodEcRbBwNRi3UCmw8f
Keeps watch-only accounts, drops the protocol that cannot work.
ImportQr scanned a Parity Signer QR — SUBSTRATE_ID 0x53, CRYPTO_SR25519 0x01, a
crypto-type byte with no ML-DSA value — so no Quantus device could ever answer
it, and one branch hardcoded createAccountSuri(..., 'sr25519', ...). But it was
also the *only* caller of createAccountExternal, so deleting it would have taken
watch-only accounts with it. Those are wanted independently of how the address
arrives, and are what the account list is used for.
TrackAddress takes a pasted SS58 string instead, which for Quantus is the common
case anyway: the address is on screen in another wallet, not on a signing device.
It round-trips through decodeAddress/encodeAddress, which rejects a mistyped
address by its checksum rather than storing one that can never receive anything,
and normalises the prefix so an address pasted from a tool using a different one
displays the way the rest of the extension displays it.
The signing path had to change with it, and this is the part that would have been
a bug. `isExternal && !isHardware` previously rendered the QR signer — and a
tracked address satisfies exactly that condition, so leaving it would have shown
a Parity QR that nothing can scan, for an account that can never sign. External
accounts now show the decoded call and say plainly that the extension holds no
key for them.
That makes Signing/Qr.tsx unreachable, along with the CMD_MORTAL and
CMD_SIGN_MESSAGE Parity command bytes and the _onSignature callback, whose whole
job was accepting a signature produced outside the extension. All removed.
approveSignSignature in the background is deliberately left. Unlike Ledger, which
is gone for good, external signing returns when quantus/extension#10 ports the
flow to multipart UR — the message it carries is the right shape for that, and
deleting it would only mean writing it again.
Two specs rewritten rather than deleted: the one asserting a QR scanner appears
for external accounts now asserts the extrinsic and the cannot-sign warning
appear instead.
Typecheck, lint and 65 tests clean; build:chrome completes.
Refs quantus/extension#10, quantus/extension#5
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012uDUodEcRbBwNRi3UCmw8f
No hardware wallet speaks ML-DSA, and @polkadot/hw-ledger talks ed25519 over the
Ledger Substrate app, so every one of these screens was unreachable for a Quantus
account. Removed rather than hidden behind a flag: this fork is not trying to stay
mergeable with upstream, so unreachable UI costs more than it saves. It is also
work that quantus/common#6 turns into a type error regardless.
Gone: ImportLedger.tsx, useLedger.ts, LedgerSign.tsx, legerChains.ts (upstream's
typo), the route and its ALLOWED_PATH entry, the add-menu item, the Ledger App
setting, and the @polkadot/hw-ledger dependency.
Also gone is the background handler. accountsCreateHardware and its message type
were reachable only from ImportLedger, so they are now dead code — and dead code
that mints accounts is worth deleting rather than leaving for someone to wire up
again.
Two things deliberately stay:
isHardware remains on AccountJson. Nothing creates such an account any more, but
a restored JSON can still carry the flag, and the display branches that read it
are correct for that case — an imported hardware account has no key here and
cannot sign. Ripping the flag out would touch unrelated display code to remove a
branch that is right.
canEditGenesis becomes unconditional. It was `!isHardware || ledgerApp ===
'generic'`, because the chain-specific Ledger app could not sign for an arbitrary
genesis hash. With no hardware path and no such setting, nothing restricts it.
The two spec cases that asserted the old behaviour are rewritten into one that
asserts the new rule, rather than deleted.
Not done, contrary to what the issue asked for: no "hardware wallets are not
supported" note in the add menu. The reasoning there was that a missing item
reads as broken — but that only holds for someone looking for it, and in a
Quantus-only wallet nobody is. A permanent dead entry needing translation into
every locale is a worse wart than its absence. It belongs in the README when
branding lands (#2).
65 tests pass; typecheck clean.
Refs quantus/extension#5
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012uDUodEcRbBwNRi3UCmw8f
Wires this repo to the four forked @polkadot packages and @quantus/crypto, and
the extension now builds with post-quantum crypto in it.
Two mechanisms, for the reason established in quantus/ui: @quantus is routed as a
whole scope in .yarnrc.yml, which is safe because we own every name in it, while
the forked @polkadot packages are pinned individually to tarball URLs in
resolutions. Routing the @polkadot scope would send yarn hunting for the dozens
of packages in it that are not forked — api, types, rpc-provider, dev, x-*,
hw-ledger and more — all of which must keep coming from npm.
The forks carry -quantus.N versions, so a resolution left out fails with "No
candidates found" rather than being quietly satisfied from npm by the unforked
package.
buildHierarchy.spec.ts is rewritten rather than deleted. compareByNetwork
resolves a genesis hash through getNetworkMap(), which is built from
selectableNetworks — and this fork only offers chains whose accounts are
post-quantum, so Polkadot and Kusama are no longer in it. Both fixtures resolved
to '' and compared equal, meaning the network-ordering tests passed without
ordering anything. They now use Quantus and Heisenberg, whose display names sort
in the same direction the originals did, so the fixtures keep their shape.
Verified in the built artifacts rather than assumed: background.js carries
QUANTUS_EXTRINSIC, the dilithium keypair types, ext_poseidon_hash, ext_mldsa_sign
and the base64 wasm payload; extension.js carries the Quantus network entry, QTC
and the mainnet genesis hash. The split is right — signing lives in the service
worker, the UI only needs to render addresses.
65 tests pass; `yarn build:chrome` completes.
Refs quantus/extension#2
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012uDUodEcRbBwNRi3UCmw8f
The signing popup decided whether to show the message view or the
transaction view by inspecting fields on the payload (`data`,
`blockNumber`), while the background picks the signing behaviour from the
message channel the request arrived on. Those two are independent and the
payload comes from the dapp, so they can disagree.
`RequestSign` becomes a discriminated union on `channel`, set by the
background from the message type. Correlating the discriminant with the
payload type means consumers narrow through `isExtrinsicRequest` rather
than casting, so the display and the signing path can no longer drift
apart. `Tabs.extrinsicSign` also rejects a payload carrying a truthy
`data`, since that field has no meaning on that channel.
Also branches the signing render on the request rather than on decoded
state. Paging between two queued requests of different shapes could
otherwise commit one frame using the previous request's decoded payload.
Tests added under `signer channel` in `Extension.spec.ts`.
* feat(ui): allow generic Ledger app to derive from any chain via polkadot slip44
* feat: allow Ledger generic app accounts to sign on any chain
* fix(extension-ui): guard generic ledger init and stabilize i18n deps
* fix: prefer WebHID over WebUSB for Ledger transport to fix Flex/Nano X connectivity
* fix(ui): release Ledger transport with explicit disconnect lifecycle
* fix(ui): align Ledger app mode with reactive settings context
* refactor(ui): keep useLedger render phase pure for init errors
* fix(ui): apply popup-aware routing to Ledger connection links
* fix(ui): harden Ledger signing error handling and mismatch UX
- Remove chain.name being set as specName from merkleizeMetadata payload
- Use reactive SettingsContext ledgerApp instead of settings.get() snapshot
- Guard no-metadata signing path with setIsBusy(false) + early return
- Detect signer mismatch by normalizing addresses and block signing when mismatched