1743 Commits

Author SHA1 Message Date
29033e8bb8 feat: post-quantum only; no derivation, no MetaMask shim, no Ethereum addresses
Some checks failed
Lock Threads / lock (push) Has been cancelled
Moves to the quantus.3 forks of keyring, util-crypto, networks and ui-keyring,
which hold only ML-DSA keys and have deleted the classical primitives
(quantus/common#6). What in the extension depended on them goes too:

- the derive-from-parent flow (Popup/Derive, pri(derivation.*), the
  AccountsContext master, the menu entries): ML-DSA pairs have no //hard/soft
  derivation, and Quantus derives every account from the mnemonic instead
- extension-compat-metamask, which injected Ethereum accounts
- the Ethereum branches in Address and the //m/44'/60' suri suffix
- canDerive and nextDerivationPath, now unused

Restoring a JSON backup of a classical key shows the keyring's reason ("ed25519
keys are not quantum-safe and cannot be held here...") rather than "Invalid
Json file", for single and batch files.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012uDUodEcRbBwNRi3UCmw8f
2026-09-16 21:28:01 +03:00
851500a5fb fix: manifest author and homepage are blackbeard's, not polkadot{.js}'s
The rebrand renamed the extension but left the manifest's author as
"polkadot.{js}" and its homepage as the upstream GitHub repo. Browsers show
both on the add-on's details page. Package.json authorship stays upstream's:
that is credit for the code, not the extension's identity.

Refs #2, #15

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012uDUodEcRbBwNRi3UCmw8f
2026-09-16 20:50:20 +03:00
e0aa78360a feat: rebrand as blackbeard
Some checks failed
Lock Threads / lock (push) Has been cancelled
The extension presented itself as "polkadot{.js} extension". It is now
blackbeard, with the sigil and gold of blackbeard.observer.

Visible:
- manifest name, short_name, action title and page titles are "blackbeard",
  and the description says what it is: a post-quantum wallet for the Quantus
  Network;
- icons (16–128), favicon and the header logo are rendered from observer's
  sigil, centred on a square;
- the accent colour moves from polkadot orange to the sigil's gold;
- the phishing page and header fallback no longer name polkadot{.js};
- the welcome screen no longer claims "your information never leaves this
  machine". That stopped being true when balances arrived. It now says what
  is sent where, and that both can be pointed elsewhere or turned off;
- the README describes this extension rather than upstream's.

Behavioural, and part of the same change:
- Dapps find the extension as window.injectedWeb3['blackbeard']. As
  'polkadot-js' it overwrote, or was overwritten by, the real polkadot{.js}
  extension when both were installed.
- Page/content-script message channels follow short_name, so they are
  blackbeard-*. Sharing polkadot{.js}'s names meant each extension's content
  script answered the other's page messages.
- The Firefox add-on id is extension@blackbeard.observer instead of upstream's
  {7e3ce1f0-…}, so Firefox stops treating this as polkadot{.js}. Storage
  there starts empty; wallets need re-importing.
- Storage keys are fixed rather than derived from EXTENSION_PREFIX, and equal
  to what existing installs already use. chrome.storage is per-extension, and
  deriving keys from the name would have orphaned every stored account and
  wallet on rename.

Package names stay @polkadot/extension-*: they aren't user-visible, and
renaming them touches every import here and in the forks.

Verified in Firefox against the built extension:
- it installs as "blackbeard" with the new id;
- a plain page sees exactly one injected key, blackbeard;
- its enable() request reaches the background over the renamed channels and
  opens the connection-request screen, with the sigil and gold.

Closes #15

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012uDUodEcRbBwNRi3UCmw8f
2026-09-16 19:34:06 +03:00
957db67f7b feat: show what a wallet's wormhole account can still spend
The wormhole tab now shows its spendable balance and, in plain words, every
way that number could be short:

- deposits the chain counts that the observer hasn't indexed yet, with the
  block it has read from ("at least" is shown when that happens);
- deposits whose nullifier this wallet hasn't computed yet, with an Unlock to
  check link. It asks for the wallet password, computes their nullifiers,
  stores them with the wallet, and refreshes;
- the amount already exited, when the check ran, and a Refresh link.

Settings gain "Look up wormhole deposits with": blackbeard.observer by
default, off, or an observer of your own, committed on Enter or blur as the
balance endpoint is.

An answer is cached for the page's lifetime so switching tabs doesn't start
another multi-second check. The cache is keyed by node and observer as well as
wallet and account, so an answer read from mainnet is never shown as
Heisenberg's.

A check now fails in 15 s with "No answer from <node>" when the node is
unreachable, and in 2 min at most overall. Before, WsProvider retried forever
and the tab said "Checking…" indefinitely.

Verified in Firefox against the built extension:
- importing the chain node's test phrase stores its nullifiers (219 KB), and
  the wormhole tab answers "0 QTC spendable, No deposits yet" in about a
  second on mainnet;
- turning lookups off shows that they are off;
- an unreachable node shows the error after 15 s;
- forgetting the wallet leaves extension storage empty: the wallet record,
  both signing pairs, and the nullifiers are all gone.

Part of quantus/extension#14.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012uDUodEcRbBwNRi3UCmw8f
2026-09-16 18:35:58 +03:00
a4e19a4b1b feat: wormhole balances in the background, from chain state and a chosen observer
A wormhole address's System.Account balance is a receipt total. An exit
credits the exit account and records a nullifier, and nothing debits the
address. So what a wallet account can still spend is worked out deposit by
deposit, in pri(wallets.wormholeBalance):

- Transfer counts come from the chain's Wormhole.TransferCount, read from the
  balance node.
- Deposits come from a blackbeard observer
  (/v1/chains/{chain}/wormhole/deposits, blackbeard/observer#22), matched by
  genesis, never by name. Anything the chain counts that the observer didn't
  return is reported as missing, never folded into a smaller balance.
- Spent status comes from Wormhole.UsedNullifiers, read in whole buckets by
  the first byte of blake2_128(nullifier), at least 16 buckets with random
  decoys, in a shuffled order. A lookup by key would hand the node the
  nullifier, and exits publish theirs.
- A deposit whose nullifier was never computed is reported as unchecked.

Nullifiers are precomputed while the password is available and stored per
wallet account: 128 transfer counts for each of the 40 addresses when a phrase
wallet or account is made. pri(wallets.wormholeUnlock) extends only the
addresses whose deposits outgrew what is stored, to their count plus 128.
Forgetting a wallet removes its nullifiers for every account index along with
the secret and the pairs. They're kept under their own prefix, and the
manifests ask for unlimitedStorage, since a busy mining address alone runs to
megabytes.

@quantus/crypto 0.3.0 provides wormholeNullifiers, pinned there to the chain's
circuit crate.

Specs: a wallet stores a full window whose bytes match a direct derivation; a
raw seed stores none; unlock extends only the addresses past their window and
keeps what was there; forget removes every account's nullifiers.

Live against mainnet and the deployed observer:
- a spent nullifier taken from a public ProofVerified event is found in its
  bucket (240 entries, 158 ms);
- for a mining address the chain counts 41,849 transfers, the observer
  returned 24,702, and 17,147 are reported missing. That is blackbeard/
  observer#23's gap, now being rescanned, surfacing as intended.

Part of quantus/extension#14.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012uDUodEcRbBwNRi3UCmw8f
2026-09-16 18:22:23 +03:00
c378b36a3a feat: wallets in the popup, one card per secret with a tab per account
The account list shows each wallet as one card. Tabs switch between the
ML-DSA-65, ML-DSA-87 and wormhole accounts its secret unlocks, and a selector
switches between account indices when there is more than one. Pairs a wallet
owns aren't listed again on their own; accounts from JSON or quantus-cli
imports, and those made before wallets, still are. The last tab and index
shown are remembered per wallet in browser storage.

Import and create no longer ask for a key type, and import no longer takes a
derivation path. A recovery phrase or 0x seed previews all three account-0
addresses before anything is saved, so a user can recognise their wallet by
whichever address they know it by. This was the gap behind a wormhole account
"importing" as the wrong address. More accounts come from the wallet's menu
(Add account, password required), which replaces derive-from-parent.

- The wallet menu offers rename, add account, export of the selected signing
  account, and forget wallet, which removes the stored secret and every pair.
- The wormhole tab shows the receive address and says plainly that balance
  and sending aren't in the extension yet (#13, stage B of #14).
- Address no longer shows its hide-from-dapps toggle for an address with no
  keyring account behind it, where it could only fail.
- The name/password step can now show why creating failed, such as a secret
  that is already a wallet.

Verified in Firefox against the built extension:
- The chain node's TEST_MNEMONIC previews its TEST_WORMHOLE_ADDRESS (qzpWh4…)
  and TEST_ADDRESS_HD_0 (qzm5QC…, ML-DSA-87). The latter matches a vector
  these tests never used.
- The zero seed previews crystal_alice with no wormhole tab, and importing it
  twice is refused.
- Storage holds the secret only encrypted.
- Add account refuses a wrong password, then adds account 1, and the selector
  switches to it.
- Forget removes the wallet record and both of its keyring pairs, and leaves
  the other wallet alone.

Part of quantus/extension#14.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012uDUodEcRbBwNRi3UCmw8f
2026-09-16 16:13:18 +03:00
22eb1c25c8 feat: wallets in the background, one secret shown as every account it unlocks
A wallet holds one secret: a recovery phrase, or a raw 32-byte seed for the
dev accounts. The secret is encrypted with the wallet password (the keyring's
scrypt + xsalsa20-poly1305 JSON encryption) under its own storage prefix,
quantus:wallet. From it the wallet derives, for each account index:

- an ML-DSA-65 and an ML-DSA-87 signing account, at the paths quantus-cli and
  the mobile wallet use. These are ordinary keyring pairs tagged with walletId
  and accountIndex, so signing, dapp injection and JSON export don't change.
  They're named "<wallet> (ML-DSA-65)" and so on, so a dapp's account list can
  tell them apart.
- 20 receive and 20 change wormhole addresses (m/44'/189189189'/n'/b'/k'),
  derived by @quantus/crypto 0.2.0. Only addresses leave WASM. They're derived
  while the password is available, so a balance can later be shown without
  asking for it.

New messages: pri(wallets.subscribe, preview, create, addAccount, rename,
forget).
- The same secret can't become two wallets, because forgetting one would take
  the other's pairs with it.
- A raw seed has one account and no wormhole addresses: wormhole derivation
  starts from the 64-byte BIP39 seed.
- Adding an account needs the password; a wrong one is refused before any
  pair is made.

@quantus/crypto is resolved to 0.2.0 everywhere, so the common forks and the
extension share one copy of the WASM instead of bundling two.

The specs use independent vectors only:
- the chain node's TEST_WORMHOLE_ADDRESS, also pinned by the mobile wallet's
  SDK;
- quantus-cli's ML-DSA-65 and ML-DSA-87 accounts for its dev phrase;
- crystal_alice for the zero seed.

Part of quantus/extension#14.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012uDUodEcRbBwNRi3UCmw8f
2026-09-16 16:04:13 +03:00
6515b398c5 fix: switch balances to a new endpoint as soon as it is chosen
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
2026-09-16 14:47:01 +03:00
ebd3b4b29a fix: keep extension pages and dapps subscribed when the background restarts
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
2026-09-16 14:30:41 +03:00
4a052de4c5 feat: create and import accounts under either ML-DSA parameter set
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
2026-09-16 13:31:30 +03:00
bfd3aa80ba feat: fetch a chain's metadata from a node, not from the dapp asking for a signature
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
2026-09-16 11:05:24 +03:00
dfb98b23d1 feat: put "open in a tab" in the header, one click from anywhere
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
2026-09-15 18:08:35 +03:00
8e767c0332 fix: remove the Asset Hub migration notice, and the strings left by other removals
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
2026-09-15 18:02:45 +03:00
b32b24348f feat: show account balances, from a node the user chooses
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
2026-09-15 15:50:04 +03:00
7fbcae31e4 feat: show addresses, not account ids, and default the prefix to 189
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
2026-09-15 15:26:26 +03:00
4f759c4094 test: the tier-1 cases that prove the plumbing, not the happy path
`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
2026-09-15 15:15:51 +03:00
a29a68d48a feat: add the tier 2 harness dapp
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
2026-09-15 14:57:53 +03:00
34759485d3 feat: sign and decode in the extension itself via @quantus/codec
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
2026-09-15 14:45:12 +03:00
be69149957 feat: build and decode extrinsics with @quantus/codec, and prove tier 1
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
2026-09-15 14:16:34 +03:00
rob thijssen
7fa26252f0 feat: choose the signing context from specVersion, and return sig||pk for raw bytes
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
2026-09-15 13:23:58 +03:00
rob thijssen
ac08676ea7 feat: default new accounts to ML-DSA-65, and stop conflating derivable with signable
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
2026-09-15 13:19:43 +03:00
rob thijssen
6dfd109991 feat: replace the Parity Signer QR flow with "track an address"
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
2026-09-15 12:19:08 +03:00
rob thijssen
87a0b3926c feat: remove the Ledger account path
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
2026-09-15 12:02:19 +03:00
rob thijssen
bff8eec1e6 build: consume the Quantus forks from the Gitea registry
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
2026-09-15 11:53:36 +03:00
Alberto Nicolas Penayo
d7c9ce2145 0.64.0 (#1625)
Some checks failed
Lock Threads / lock (push) Has been cancelled
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-31 12:43:58 -03:00
github-actions[bot]
91f0b7194e [CI Skip] bump/beta 0.63.2-1-x
skip-checks: true
2026-07-31 15:14:13 +00:00
Patricio
e66446edc3 fix: derive raw vs extrinsic signing from the request channel (#1624)
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`.
2026-07-31 12:11:11 -03:00
github-actions[bot]
58093e3a50 [CI Skip] bump/beta 0.63.2-0-x
skip-checks: true
2026-07-30 18:24:37 +00:00
github-actions[bot]
987784c81e [CI Skip] release/stable 0.63.1
skip-checks: true
2026-03-31 01:56:50 +00:00
Tarik Gul
b54ff921b4 0.63.1 (#1615)
* 0.63.1

* Update Changelog
2026-03-31 04:54:01 +03:00
github-actions[bot]
77a7f59489 [CI Skip] bump/beta 0.62.7-5-x
skip-checks: true
2026-03-30 20:47:28 +00:00
Francis O'Brien
bc320ec762 Allow Ledger generic app accounts to sign on any chain (#1613)
* 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
2026-03-30 16:44:34 -04:00
github-actions[bot]
2198396137 [CI Skip] bump/beta 0.62.7-4-x
skip-checks: true
2026-03-26 22:20:12 +00:00
Tarik Gul
ec26b80ab1 Bump pjs deps (#1614) 2026-03-27 00:17:18 +02:00
github-actions[bot]
81714b2cf5 [CI Skip] bump/beta 0.62.7-3-x
skip-checks: true
2026-03-26 19:48:35 +00:00
Francis O'Brien
11929ea24e feat(extension-chains): support rawMetadata in metadataExpand alongside metaCalls (#1612) 2026-03-26 15:45:32 -04:00
github-actions[bot]
a9bbe3efb2 [CI Skip] bump/beta 0.62.7-2-x
skip-checks: true
2026-03-26 19:33:03 +00:00
Francis O'Brien
62b7395023 Improve Ledger reliability and signing (WebHID, lifecycle, mismatch guard) (#1611)
* 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
2026-03-26 15:30:08 -04:00
github-actions[bot]
73fef6ffbf [CI Skip] bump/beta 0.62.7-1-x
skip-checks: true
2026-03-12 18:51:34 +00:00
Francis O'Brien
ff42d99624 fix: set chainSpecific account type and disable Ethereum switch (#1608) 2026-03-12 14:48:38 -04:00
github-actions[bot]
67faeb9f69 [CI Skip] bump/beta 0.62.7-0-x
skip-checks: true
2026-03-12 17:30:41 +00:00
Tarik Gul
a1c87fa383 Set headers to 2026 (#1609) 2026-03-12 19:27:39 +02:00
github-actions[bot]
9288eb12b1 [CI Skip] release/stable 0.62.6
skip-checks: true
2025-11-26 07:49:46 +00:00
Arjun Porwal
4622b6c13d 0.62.6 (#1604) 2025-11-26 13:16:54 +05:30
github-actions[bot]
d1fb181e27 [CI Skip] bump/beta 0.62.6-0-x
skip-checks: true
2025-11-26 07:38:34 +00:00
Arjun Porwal
d6473a5c7a chore: bump deps (#1603) 2025-11-26 13:05:38 +05:30
github-actions[bot]
eeeb336417 [CI Skip] release/stable 0.62.5
skip-checks: true
2025-11-17 13:38:00 +00:00
Arjun Porwal
3f5fa9b043 0.62.5 (#1602) 2025-11-17 08:35:06 -05:00
github-actions[bot]
f64d18828d [CI Skip] bump/beta 0.62.5-0-x
skip-checks: true
2025-11-14 16:42:44 +00:00
Tarik Gul
48ef4046ef Add AHM warning page (#1601) 2025-11-14 11:39:34 -05:00