Author SHA1 Message Date
grenadeandClaude Opus 5 29033e8bb8 feat: post-quantum only; no derivation, no MetaMask shim, no Ethereum addresses
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
grenadeandClaude Opus 5 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
grenadeandClaude Opus 5 e0aa78360a feat: rebrand as blackbeard
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
grenadeandClaude Opus 5 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
grenadeandClaude Opus 5 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
grenadeandClaude Opus 5 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
grenadeandClaude Opus 5 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
grenadeandClaude Opus 5 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
grenadeandClaude Opus 5 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
grenadeandClaude Opus 5 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
grenadeandClaude Opus 5 f61cfd4ed5 chore: let the tier-1 harness send a configurable amount
`QUANTUS_AMOUNT`, in plancks. The hardcoded 0.001 HEI was right for proving a
transfer lands and useless for funding an account to sign *from*: one ML-DSA
extrinsic costs about 0.01 HEI in fees, so an account funded with the default
could not pay for its own first transaction.

Needed for the manual verification pass, which has to fund a fresh account
created in the extension — the only funded dev account (crystal_alice) is
ML-DSA-87, and the import-seed screen always imports as ML-DSA-65, so the
funded account cannot be brought in through the UI at all.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012uDUodEcRbBwNRi3UCmw8f
2026-09-16 11:47:24 +03:00
grenadeandClaude Opus 5 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
grenadeandClaude Opus 5 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
grenadeandClaude Opus 5 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
grenadeandClaude Opus 5 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
grenadeandClaude Opus 5 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
grenadeandClaude Opus 5 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
grenadeandClaude Opus 5 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
grenadeandClaude Opus 5 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
grenadeandClaude Opus 5 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 thijssenandClaude Opus 5 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 thijssenandClaude Opus 5 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
114 changed files with 5692 additions and 2413 deletions
+62 -78
View File
@@ -1,71 +1,74 @@
# polkadot{.js} extension
# blackbeard
A very simple scaffolding browser extension that injects a [@polkadot/api](https://github.com/polkadot-js/api) Signer into a page, along with any associated accounts, allowing for use by any dapp. This is an extensible POC implementation of a Polkadot/Substrate browser signer.
A post-quantum browser wallet for the [Quantus Network](https://quantus.com), forked from the
[polkadot{.js} extension](https://github.com/polkadot-js/extension). It keeps upstream's shape — accounts
in a popup, a signer injected into dapps — with the cryptography, encoding and account model Quantus
actually needs:
As it stands, it does one thing: it _only_ manages accounts and allows the signing of transactions with those accounts. It does not inject providers for use by dapps at this early point, nor does it perform wallet functions where it constructs and submits txs to the network.
- **ML-DSA only.** Accounts are ML-DSA-65 and ML-DSA-87 (the two variants of the runtime's
`DilithiumSignatureScheme`), derived as `quantus-cli` and the mobile wallet derive them.
- **The runtime is the codec.** Calls and extrinsics are encoded and decoded from the chain's own
metadata ([@quantus/codec](https://git.lair.cafe/quantus/wasm)), fetched from a node and checked
against its genesis — never from a hardcoded type registry, which drifts from a runtime that changes.
- **Wallets, not keypairs.** A recovery phrase is one wallet, shown as every account it unlocks:
ML-DSA-65, ML-DSA-87 and wormhole tabs, per account index. The phrase is stored encrypted with the
wallet password so further accounts can be derived later.
- **Wormhole balances, read-only.** Deposits come from a [blackbeard observer](https://blackbeard.observer)
and are checked against the chain's own transfer counts; spent status is decided locally from
nullifiers precomputed while the password is to hand, checked against whole buckets of
`Wormhole::UsedNullifiers` so no node learns which ones are yours. Sending from a wormhole address
is not in the extension yet (quantus/extension#13).
Work is tracked at [git.lair.cafe/quantus/extension/issues](https://git.lair.cafe/quantus/extension/issues);
#1 is the epic.
## Installation
- On Chrome, install via [Chrome web store](https://chrome.google.com/webstore/detail/polkadot%7Bjs%7D-extension/mopnmbcafieddcagagdcbnhejhlodfdd)
- On Firefox, install via [Firefox add-ons](https://addons.mozilla.org/en-US/firefox/addon/polkadot-js-extension/)
There is no store listing. Build from source (below), or use the zips a build writes to the repo root.
![interface screenshots](docs/extension-overview.png)
- **Firefox:** `about:debugging#/runtime/this-firefox`**Load Temporary Add-on…**`master-ff-build.zip`.
Temporary add-ons are unloaded on restart; a permanent install of an unsigned add-on needs Developer
Edition or Nightly with `xpinstall.signatures.required` set to `false`.
- **Chrome / Chromium / Brave:** unzip `master-chrome-build.zip` into a folder, then `chrome://extensions`
**Developer mode****Load unpacked** → that folder. Not `packages/extension/build`, which holds
whichever browser was built last.
## Documentation and examples
Find out more about how to use the extension as a Dapp developer, cookbook, as well as answers to most frequent questions in the [Polkadot-js extension documentation](https://polkadot.js.org/docs/extension/)
## Privacy settings
## Firefox installation from source instructions.
Everything the extension asks the network is a setting, and each can be pointed at your own
infrastructure or turned off:
1. Uncompress `master-ff-src.zip`
2. Run `corepack enable` [More information](https://github.com/nodejs/corepack?tab=readme-ov-file#corepack-enable--name)
3. Install dependencies via `yarn install`
4. Build all packages via `yarn build`
- The `/packages/extension/build` directory will contain the exact code used in the add-on, and should exactly match the uncompressed `master-ff-build`.
NOTE: If you would like to regenerate the compressed `master-ff-build.zip`, and `master-ff-src.zip` files run: `yarn build:zip:ff`
## Development version
Steps to build the extension and view your changes in a browser:
1. Chrome:
1. Build via `yarn build:chrome`
- NOTE: You may need to enable corepack by running `corepack enable`
2. Install the extension
- go to `chrome://extensions/`
- ensure you have the Development flag set
- "Load unpacked" and point to `packages/extension/build`
- if developing, after making changes - refresh the extension
2. Firefox
1. Build via `yarn build:ff`
- NOTE: You may need to enable corepack by running `corepack enable`
2. Install the extension
- go to `about:debugging#addons`
- check "Enable add-on debugging"
- click on "Load Temporary Add-on" and point to `packages/extension/build/manifest.json`
- if developing, after making changes - reload the extension
3. When visiting `https://polkadot.js.org/apps/` it will inject the extension
Once added, you can create an account (via a generated seed) or import via an existing seed. The [apps UI](https://github.com/polkadot-js/apps/), when loaded, will show these accounts as `<account name> (extension)`
- **Read balances from** — the node used for balances and wormhole chain state.
- **Look up wormhole deposits with** — the observer asked which deposits went to your wormhole
addresses. It learns that those addresses belong to one person; nothing it is sent can name your exits.
## Development
The repo is split into a number of packages -
```sh
yarn install
yarn build # both browsers; writes master-chrome-build.zip and master-ff-build.zip
yarn test # background specs (the upstream React specs are not run: see quantus/extension#14)
yarn lint
```
- [extension](packages/extension/) - All the injection and background processing logic (the main entry)
- [extension-ui](packages/extension-ui/) - The UI components for the extension, to build up the popup
- [extension-dapp](packages/extension-dapp/) - A convenience wrapper to work with the injected objects, simplifying data extraction for any dapp that wishes to integrate the extension (or any extension that supports the interface)
- [extension-inject](packages/extension-inject/) - A convenience wrapper that allows extension developers to inject their extension for use by any dapp
The Quantus forks it builds against (`@polkadot/util-crypto`, `@polkadot/keyring`, `@polkadot/ui-keyring`,
`@polkadot/networks`) and `@quantus/crypto` / `@quantus/codec` come from the Gitea package registry;
see `.yarnrc.yml`. `scripts/tier1` submits real extrinsics against a node, and `scripts/tier2` is a
dapp harness for exercising the injected signer in a browser.
It also contains a [`manifest_chrome.json`](packages/extension/manifest_chrome.json) file which contains the manifest configuration for Chrome and another [`manifest_firefox.json`](packages/extension/manifest_firefox.json) with the configuration for Firefox, for compatibility reasons, and a dummy `manifest.json` file that's only used by the build.
The packages keep their upstream `@polkadot/extension-*` names:
- [extension](packages/extension/) — injection, background and manifests (the main entry)
- [extension-base](packages/extension-base/) — the background: keyring, wallets, signing, balances
- [extension-ui](packages/extension-ui/) — the popup
- [extension-dapp](packages/extension-dapp/) — a convenience wrapper for dapps using any injected extension
- [extension-inject](packages/extension-inject/) — the injection interface
## Dapp developers
The actual in-depth technical breakdown is given in the next section for any dapp developer wishing to work with the raw objects injected into the window. However, convenience wrappers are provided that allow for any dapp to use this extension (or any other extension that conforms to the interface) without having to manage any additional info.
The documentation for Dapp development is available [in the polkadot-js doc](https://polkadot.js.org/docs/extension).
This approach is used to support multiple external signers in for instance [apps](https://github.com/polkadot-js/apps/). You can read more about the convenience wrapper [@polkadot/extension-dapp](packages/extension-dapp/) along with usage samples.
Use [@polkadot/extension-dapp](packages/extension-dapp/), or any library that enumerates
`window.injectedWeb3`; this extension appears there as `blackbeard`. Signing requests are encoded and
shown using the chain's own metadata, so a dapp does not need to supply any.
## API interface
@@ -121,8 +124,8 @@ The extension injects `injectedWeb3` into the global `window` object, exposing t
```js
window.injectedWeb3 = {
// this is the name for this extension, there could be multiples injected,
// each with their own keys, here `polkadot-js` is for this extension
'polkadot-js': {
// each with their own keys, here `blackbeard` is for this extension
'blackbeard': {
// semver for the package
version: '0.1.0',
@@ -134,30 +137,11 @@ window.injectedWeb3 = {
}
```
## Mnemonics, Passwords, and Imports/Exports
### Using the mnemonic and password from the extension
## Recovery phrases and passwords
When you create a keypair via the extension, it supplies a 12-word mnemonic seed and asks you to create a password. This password only encrypts the private key on disk so that the password is required to spend funds in `polkadot-js/apps` or to import the account from backup. The password does not protect the mnemonic phrase. That is, if an attacker were to acquire the mnemonic phrase, they would be able to use it to spend funds without the password.
### Importing mnemonics from other key generation utilities
Some key-generation tools, e.g. [Subkey](https://www.substrate.io/kb/integrate/subkey), support hard and soft key derivation as well as passwords that encrypt the mnemonic phrase such that the mnemonic phrase itself is insufficient to spend funds.
The extension supports these advanced features. When you import an account from a seed, you can add these derivation paths or password to the end of the mnemonic in the following format:
```
<mnemonic phrase>//<hard>/<soft>///<password>
```
That is, hard-derivation paths are prefixed with `//`, soft paths with `/`, and the password with `///`.
The extension will still ask you to enter a password for this account. As before, this password only encrypts the private key on disk. It is not required to be the same password as the one that encrypts the mnemonic phrase.
Accounts can also be derived from existing accounts `Derive New Account` option in account's dropdown menu should be selected. After providing the password of the parent account, along with name and password of the derived account, enter derivation path in the following format:
```
//<hard>/<soft>
```
The path will be added to the mnemonic phrase of the parent account.
A wallet's password encrypts the stored recovery phrase and each account's key; the phrase itself is
not protected by it, so anyone with the phrase controls every account the wallet shows. Import accepts
a BIP39 phrase, or a `0x`-prefixed 32-byte seed for the dev accounts. There are no derivation paths
to type: account indices are added from the wallet's menu, and derive the same accounts
`quantus-cli` and the mobile wallet derive for that index.
+7 -5
View File
@@ -42,7 +42,8 @@
"lint": "polkadot-dev-run-lint",
"postinstall": "polkadot-dev-yarn-only",
"test": "EXTENSION_PREFIX='test' polkadot-dev-run-test --loader ./packages/extension-mocks/src/loader-empty.js --env browser ^:.spec.tsx",
"test:one": "EXTENSION_PREFIX='test' polkadot-dev-run-test --env browser"
"test:one": "EXTENSION_PREFIX='test' polkadot-dev-run-test --env browser",
"tier2": "polkadot-exec-webpack --config scripts/tier2/webpack.config.cjs"
},
"devDependencies": {
"@polkadot/dev": "^0.83.3",
@@ -52,14 +53,15 @@
},
"resolutions": {
"@polkadot/api": "^16.5.6",
"@polkadot/keyring": "https://git.lair.cafe/api/packages/quantus/npm/%40polkadot%2Fkeyring/-/14.0.3-quantus.1/keyring-14.0.3-quantus.1.tgz",
"@polkadot/networks": "https://git.lair.cafe/api/packages/quantus/npm/%40polkadot%2Fnetworks/-/14.0.3-quantus.1/networks-14.0.3-quantus.1.tgz",
"@polkadot/keyring": "https://git.lair.cafe/api/packages/quantus/npm/%40polkadot%2Fkeyring/-/14.0.3-quantus.3/keyring-14.0.3-quantus.3.tgz",
"@polkadot/networks": "https://git.lair.cafe/api/packages/quantus/npm/%40polkadot%2Fnetworks/-/14.0.3-quantus.3/networks-14.0.3-quantus.3.tgz",
"@polkadot/rpc-provider": "^16.5.6",
"@polkadot/types": "^16.5.6",
"@polkadot/ui-keyring": "https://git.lair.cafe/api/packages/quantus/npm/%40polkadot%2Fui-keyring/-/3.16.7-quantus.1/ui-keyring-3.16.7-quantus.1.tgz",
"@polkadot/ui-keyring": "https://git.lair.cafe/api/packages/quantus/npm/%40polkadot%2Fui-keyring/-/3.16.7-quantus.3/ui-keyring-3.16.7-quantus.3.tgz",
"@polkadot/util": "^14.0.3",
"@polkadot/util-crypto": "https://git.lair.cafe/api/packages/quantus/npm/%40polkadot%2Futil-crypto/-/14.0.3-quantus.1/util-crypto-14.0.3-quantus.1.tgz",
"@polkadot/util-crypto": "https://git.lair.cafe/api/packages/quantus/npm/%40polkadot%2Futil-crypto/-/14.0.3-quantus.3/util-crypto-14.0.3-quantus.3.tgz",
"@polkadot/x-fetch": "^14.0.3",
"@quantus/crypto": "^0.3.0",
"safe-buffer": "^5.2.1",
"typescript": "^5.5.4"
}
+2
View File
@@ -34,6 +34,8 @@
"@polkadot/ui-settings": "^3.16.7",
"@polkadot/util": "^14.0.3",
"@polkadot/util-crypto": "^14.0.3",
"@quantus/codec": "^0.5.0",
"@quantus/crypto": "^0.3.0",
"eventemitter3": "^5.0.1",
"rxjs": "^7.8.1",
"tslib": "^2.8.1"
@@ -0,0 +1,249 @@
// Copyright 2019-2026 @polkadot/extension-base authors & contributors
// SPDX-License-Identifier: Apache-2.0
import type { ProviderInterface } from '@polkadot/rpc-provider/types';
import type { AccountBalance, AccountBalances } from './types.js';
import { Runtime } from '@quantus/codec';
import { BehaviorSubject } from 'rxjs';
import { WsProvider } from '@polkadot/rpc-provider';
import { hexToU8a, u8aToHex } from '@polkadot/util';
import { decodeAddress } from '@polkadot/util-crypto';
interface ChainInfo {
decimals: number;
runtime: Runtime;
symbol: string;
}
interface StoredAccount {
data: { free: string };
}
/**
* Account balances, read from a node the user chose.
*
* Upstream has nothing like this: polkadot-js's extension is a signer, and a
* balance is the dapp's business. That reasoning does not survive contact with
* Quantus. An account id here 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 ever funded and an account holding a thousand QTC look
* exactly alike.
*
* ## The privacy trade, stated
*
* Asking a node for balances tells that node which accounts belong to one
* person. That is a real cost and it is why the endpoint is a **setting**: a
* user who does not trust the default points this at their own node, which is
* what anyone with that concern should be doing anyway. Nothing connects until
* the popup asks, and the connection closes when the last subscriber goes away.
*/
export default class Balances {
readonly subject = new BehaviorSubject<AccountBalances>({});
#chain: ChainInfo | null = null;
// A connection being made, so readers arriving together share one.
#connecting: { endpoint: string, promise: Promise<ChainInfo> } | null = null;
#endpoint: string | null = null;
#provider: ProviderInterface | null = null;
#subscribers = 0;
// The node's own subscription id, so a refresh replaces its watch rather than
// stacking another one on top.
#storageSub: number | string | null = null;
#watched: string[] = [];
/**
* Connect, or reconnect when the endpoint has changed.
*
* Metadata is fetched once per connection. It is 100 KiB on Heisenberg and
* parsing it is the expensive part, so it is held for as long as the
* connection is.
*/
#connect (endpoint: string): Promise<ChainInfo> {
if (this.#chain && this.#endpoint === endpoint && this.#provider?.isConnected) {
return Promise.resolve(this.#chain);
}
if (this.#connecting?.endpoint !== endpoint) {
const promise = this.#open(endpoint).finally(() => {
if (this.#connecting?.promise === promise) {
this.#connecting = null;
}
});
this.#connecting = { endpoint, promise };
}
return this.#connecting.promise;
}
async #open (endpoint: string): Promise<ChainInfo> {
// Balances from the previous endpoint are another chain's: a switch from
// Heisenberg to mainnet must not go on showing HEI while mainnet loads.
if (this.#endpoint !== endpoint) {
this.subject.next({});
}
await this.#close();
const provider = new WsProvider(endpoint);
await provider.isReady;
const [metadata, properties] = await Promise.all([
provider.send<string>('state_getMetadata', []),
provider.send<Record<string, unknown>>('system_properties', [])
]);
// The user moved on while this was loading. Whatever connects last must not
// win; whatever was asked for last must.
if (this.#connecting?.endpoint !== endpoint) {
await provider.disconnect().catch(console.error);
throw new Error(`${endpoint} was replaced before it connected`);
}
const runtime = Runtime.fromMetadata(hexToU8a(metadata));
// `system_properties` reports these as either a scalar or a one-element
// array, depending on the chain's spec.
const first = (value: unknown): unknown => Array.isArray(value) ? (value as unknown[])[0] : value;
this.#chain = {
decimals: Number(first(properties['tokenDecimals']) ?? 12),
runtime,
symbol: String(first(properties['tokenSymbol']) ?? '')
};
this.#endpoint = endpoint;
this.#provider = provider;
return this.#chain;
}
async #close (): Promise<void> {
const provider = this.#provider;
this.#chain = null;
this.#endpoint = null;
this.#provider = null;
this.#storageSub = null;
this.#watched = [];
if (provider) {
await provider.disconnect().catch(console.error);
}
}
/**
* Read the balances for these addresses and watch them for changes.
*
* `System::Account` is a **Default** entry, so a node returning nothing means
* a zero balance rather than a failure. Treating the two alike 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.
*/
async update (endpoint: string, addresses: string[]): Promise<void> {
if (!addresses.length) {
this.subject.next({});
return;
}
const { decimals, runtime, symbol } = await this.#connect(endpoint);
const keys = addresses.map((address) =>
runtime.storageTarget('System', 'Account', [u8aToHex(decodeAddress(address))])
);
const provider = this.#provider;
if (!provider) {
return;
}
const read = (index: number, raw: string | null): AccountBalance => {
const bytes = hexToU8a(raw ?? keys[index].default ?? '0x');
const account = runtime.decodeStorage(keys[index].valueTy, bytes) as StoredAccount;
return { decimals, free: account.data.free, symbol };
};
const values = await provider.send<(string | null)[]>('state_queryStorageAt', [keys.map((k) => k.key)])
.then((results) => (results as unknown as { changes: [string, string | null][] }[])[0].changes.map(([, value]) => value));
// a read that outlived a switch of endpoint belongs to the old chain
if (this.#provider !== provider) {
return;
}
this.subject.next(Object.fromEntries(addresses.map((address, index) => [address, read(index, values[index])])));
await this.#watch(provider, addresses, keys.map((k) => k.key), read);
}
async #watch (
provider: ProviderInterface,
addresses: string[],
keys: string[],
read: (index: number, raw: string | null) => AccountBalance
): Promise<void> {
if (this.#watched.join() === keys.join()) {
return;
}
if (this.#storageSub !== null) {
await provider.unsubscribe('state_storage', 'state_unsubscribeStorage', this.#storageSub).catch(console.error);
this.#storageSub = null;
}
this.#watched = keys;
this.#storageSub = await provider.subscribe(
'state_storage',
'state_subscribeStorage',
[keys],
(error, result: { changes: [string, string | null][] }) => {
if (error) {
console.error(error);
return;
}
// Only the keys that changed arrive, so this merges rather than
// replaces — a block that moves one account's balance must not blank
// every other account in the list.
const current = { ...this.subject.getValue() };
for (const [key, value] of result.changes) {
const index = keys.indexOf(key);
if (index !== -1) {
current[addresses[index]] = read(index, value);
}
}
this.subject.next(current);
}
);
}
/** A popup has opened. */
retain (): void {
this.#subscribers++;
}
/**
* A popup has gone away. The connection closes with the last one: a signer
* that holds a socket open to somebody's node for as long as the browser runs
* is reporting far more than it needs to.
*/
release (): void {
this.#subscribers = Math.max(0, this.#subscribers - 1);
if (!this.#subscribers) {
this.subject.next({});
this.#close().catch(console.error);
}
}
}
@@ -0,0 +1,144 @@
// Copyright 2019-2026 @polkadot/extension-base authors & contributors
// SPDX-License-Identifier: Apache-2.0
import type { MetadataDef } from '@polkadot/extension-inject/types';
import type { HexString } from '@polkadot/util/types';
import { QUANTUS_CHAINS } from '@polkadot/extension-base/defaults';
import { WsProvider } from '@polkadot/rpc-provider';
interface RuntimeVersion {
specName: string;
specVersion: number;
transactionVersion: number;
}
/**
* Metadata, fetched from a node rather than accepted from a dapp.
*
* ## Why this exists
*
* Upstream's extension learns what a chain looks like only when a dapp tells it
* (`metadata.provide`). That has two problems, and the second is the serious one.
*
* **It does not always happen.** Not every dapp provides metadata — the qapi
* console does not, because `@polkadot-api/pjs-signer` never exposes that half
* of the injected interface. Since this extension refuses to sign an extrinsic
* for a chain it cannot describe, a dapp that does not provide metadata cannot
* get a signature at all.
*
* **And the dapp is not a trustworthy source for it.** Metadata decides what the
* approval screen *says* a call does. A dapp that supplies its own controls both
* the transaction and its description, so it can show a harmless-looking call and
* have the user sign a transfer. Asking the chain removes that: the description
* and the thing being described then come from the same place, and it is not the
* page asking for the signature.
*
* ## The identity check
*
* A sign request names a `genesisHash`. Endpoints are looked up by it, and an
* endpoint's answer is believed only if `chain_getBlockHash(0)` comes back equal
* to the hash that was asked for. Skipping that would replace "trust the dapp"
* with "trust this table", which is not obviously better.
*/
export default class ChainMetadata {
/** By genesis hash. Refetched when the chain's spec version moves on. */
readonly #cache = new Map<string, MetadataDef>();
/** In-flight fetches, so five queued requests make one connection. */
readonly #inFlight = new Map<string, Promise<MetadataDef | null>>();
/**
* Metadata for a chain, or `null` when this extension has no endpoint for it.
*
* `null` is not a failure to report loudly: a user may legitimately be signing
* for a chain we do not know, and the caller turns it into a refusal with a
* message that says so.
*/
async fetch (genesisHash: string): Promise<MetadataDef | null> {
const cached = this.#cache.get(genesisHash);
if (cached) {
return cached;
}
const existing = this.#inFlight.get(genesisHash);
if (existing) {
return existing;
}
const pending = this.#fetch(genesisHash).finally(() => this.#inFlight.delete(genesisHash));
this.#inFlight.set(genesisHash, pending);
return pending;
}
/** Drop a chain's cached metadata, so the next request refetches it. */
forget (genesisHash: string): void {
this.#cache.delete(genesisHash);
}
async #fetch (genesisHash: string): Promise<MetadataDef | null> {
const chain = QUANTUS_CHAINS.find((c) => c.genesisHash === genesisHash);
if (!chain) {
return null;
}
for (const endpoint of chain.endpoints) {
try {
const def = await this.#fromEndpoint(endpoint, genesisHash, chain.name);
this.#cache.set(genesisHash, def);
return def;
} catch (error) {
// Try the next endpoint. One node being down is not the chain being
// unknown, and the two must not look alike to the caller.
console.error(`metadata from ${endpoint} failed: ${(error as Error).message}`);
}
}
return null;
}
async #fromEndpoint (endpoint: string, genesisHash: string, name: string): Promise<MetadataDef> {
const provider = new WsProvider(endpoint, false);
try {
await provider.connect();
await provider.isReady;
const served = await provider.send<string>('chain_getBlockHash', ['0x0']);
// The whole point of the identity check. An endpoint that answers for a
// different chain would hand over metadata that decodes this chain's calls
// into something plausible and wrong.
if (served !== genesisHash) {
throw new Error(`serves ${served}, not ${genesisHash}`);
}
const [version, metadata, properties] = await Promise.all([
provider.send<RuntimeVersion>('state_getRuntimeVersion', []),
provider.send<HexString>('state_getMetadata', []),
provider.send<Record<string, unknown>>('system_properties', [])
]);
const first = (value: unknown): unknown => Array.isArray(value) ? (value as unknown[])[0] : value;
return {
chain: name,
genesisHash: genesisHash as HexString,
icon: 'substrate',
rawMetadata: metadata,
specVersion: version.specVersion,
ss58Format: Number(properties['ss58Format'] ?? 189),
tokenDecimals: Number(first(properties['tokenDecimals']) ?? 12),
tokenSymbol: String(first(properties['tokenSymbol']) ?? ''),
types: {}
};
} finally {
await provider.disconnect().catch(console.error);
}
}
}
@@ -1,14 +1,34 @@
// Copyright 2019-2026 @polkadot/extension-base authors & contributors
// SPDX-License-Identifier: Apache-2.0
import type { Runtime } from '@quantus/codec';
import type { KeyringPair } from '@polkadot/keyring/types';
import type { TypeRegistry } from '@polkadot/types';
import type { SignerPayloadRaw } from '@polkadot/types/types';
import type { HexString } from '@polkadot/util/types';
import type { RequestSignBytes } from './types.js';
import { u8aToHex, u8aWrapBytes } from '@polkadot/util';
/**
* Raw-bytes signing uses the **empty** FIPS 204 context, not the extrinsic one.
*
* That separation is the point. A signature made here must never be replayable
* as an extrinsic, and the context is bound into the signature itself, so a
* blob signed for a dapp login cannot be presented to the chain as a transfer.
*
* `u8aWrapBytes` is kept as well, but it is not what provides that guarantee —
* it is polkadot-js's own `<Bytes>…</Bytes>` wrapping, inherited so that a
* verifier written against the polkadot-js 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.
*
* There is deliberately no Quantus-specific bytes context. Nothing else in the
* ecosystem defines one — the chain and the SDK name only QUANTUS_EXTRINSIC —
* and inventing one here would produce signatures no other Quantus tool could
* verify.
*/
const BYTES_CONTEXT = new Uint8Array();
export default class RequestBytesSign implements RequestSignBytes {
public readonly channel = 'bytes' as const;
public readonly payload: SignerPayloadRaw;
@@ -17,11 +37,16 @@ export default class RequestBytesSign implements RequestSignBytes {
this.payload = payload;
}
sign (_registry: TypeRegistry, pair: KeyringPair): { signature: HexString } {
sign (_runtime: Runtime | null, pair: KeyringPair): { signature: HexString } {
// For an ML-DSA pair this returns `signature ‖ publicKey`, 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 to check
// anything. The key has to travel with the signature.
return {
signature: u8aToHex(
pair.sign(
u8aWrapBytes(this.payload.data)
u8aWrapBytes(this.payload.data),
{ context: BYTES_CONTEXT }
)
)
};
@@ -0,0 +1,145 @@
// Copyright 2019-2026 @polkadot/extension-base authors & contributors
// SPDX-License-Identifier: Apache-2.0
/// <reference types="@polkadot/dev-test/globals.d.ts" />
import type { Runtime } from '@quantus/codec';
import type { SignerPayloadJSON, SignerPayloadRaw } from '@polkadot/types/types';
import type { HexString } from '@polkadot/util/types';
import { contextForSpec, Scheme, sizes } from '@quantus/crypto';
import { Keyring } from '@polkadot/keyring';
import { hexToU8a, u8aToHex, u8aWrapBytes } from '@polkadot/util';
import { dilithiumVerify } from '@polkadot/util-crypto';
import { HEISENBERG_GENESIS, heisenbergRuntime } from '../test/metadata.js';
import RequestBytesSign from './RequestBytesSign.js';
import RequestExtrinsicSign from './RequestExtrinsicSign.js';
const ADDRESS = 'qzq29m9WvneDAeXbtgueKCREtNe1rVVs6bXSMLmjr6shqvwq6';
describe('signing with an ML-DSA pair', (): void => {
const runtime = heisenbergRuntime();
const keyring = new Keyring({ ss58Format: 189, type: 'dilithium65' });
const pair = keyring.createFromUri('bottom drive obey lake curtain smoke basket hold race lonely fit walk', {}, 'dilithium65');
const s65 = sizes(Scheme.MlDsa65);
// Encoded by the runtime rather than pasted, so this is a call this chain
// actually has rather than bytes that merely look like one.
const method = u8aToHex(runtime.encodeCall('Balances', 'transfer_keep_alive', {
dest: { Id: u8aToHex(pair.addressRaw) },
value: '1000000000'
}));
function payloadFor (specVersion: HexString): SignerPayloadJSON {
return {
address: ADDRESS,
blockHash: HEISENBERG_GENESIS,
blockNumber: '0x00000000',
era: '0x00', // immortal
genesisHash: HEISENBERG_GENESIS,
method,
nonce: '0x00000000',
signedExtensions: ['CheckSpecVersion', 'CheckTxVersion', 'CheckGenesis', 'CheckMortality', 'CheckNonce', 'CheckWeight', 'ChargeTransactionPayment'],
specVersion,
tip: '0x00000000000000000000000000000000',
transactionVersion: '0x00000006',
version: 4
};
}
const signExtrinsic = (specVersion: HexString, on: Runtime | null = runtime) =>
hexToU8a(new RequestExtrinsicSign(payloadFor(specVersion)).sign(on, pair).signature);
// What the runtime will actually verify against, built the same way the signer
// builds it. This pins the context and the wire shape; that the *payload* is
// right is pinned by quantus/extension#7 tier 1, which submits one.
const payloadBytes = (specVersion: number) => {
const json = payloadFor(`0x${specVersion.toString(16).padStart(8, '0')}`);
return runtime.signerPayload(hexToU8a(json.method), runtime.standardExtensions({
blockHash: json.blockHash,
eraHex: json.era,
genesisHash: json.genesisHash,
nonce: 0,
specVersion,
transactionVersion: 6
}));
};
// The whole point of the change. ExtrinsicPayload.sign(pair) has nowhere to put
// a context, and an ML-DSA pair refuses to sign without one — so before this,
// signing an extrinsic threw.
it('signs an extrinsic at all', (): void => {
expect(signExtrinsic('0x00000094').length).toEqual(s65.signatureWithPublicKey + 1);
});
// withType prepends the runtime's DilithiumSignatureScheme variant index, and
// the body is sig ‖ pk as a fixed array — no compact length prefix.
it('produces the wire shape the runtime reads', (): void => {
const signed = signExtrinsic('0x00000094');
expect(signed[0]).toEqual(1); // Dilithium65
expect(signed.length).toEqual(s65.signatureWithPublicKey + 1);
});
// Without the runtime there is no way to know what this chain's signed
// extensions contribute, and signing anyway would mean guessing — a signature
// over the wrong bytes, which the chain reports as BadProof and nothing local
// can tell from a wrong key. Refusing is the honest answer. See quantus/api#1.
it('refuses to sign for a chain whose metadata it does not have', (): void => {
expect(
() => signExtrinsic('0x00000094', null)
).toThrow(/No metadata for chain/);
});
// spec 148 = 0x94. The context is chosen from the payload, and getting it
// wrong is the failure that cannot be detected locally — a valid signature the
// chain rejects.
it('signs spec >= 148 under QUANTUS_EXTRINSIC', (): void => {
const signed = signExtrinsic('0x00000094');
const encoded = payloadBytes(148);
expect(dilithiumVerify(encoded, signed.subarray(1), pair.addressRaw, 'dilithium65', contextForSpec(148))).toEqual(true);
expect(dilithiumVerify(encoded, signed.subarray(1), pair.addressRaw, 'dilithium65', contextForSpec(147))).toEqual(false);
});
it('signs an earlier spec under the empty context', (): void => {
const signed = signExtrinsic('0x00000093'); // spec 147
const encoded = payloadBytes(147);
expect(dilithiumVerify(encoded, signed.subarray(1), pair.addressRaw, 'dilithium65', contextForSpec(147))).toEqual(true);
expect(dilithiumVerify(encoded, signed.subarray(1), pair.addressRaw, 'dilithium65', contextForSpec(148))).toEqual(false);
});
describe('raw bytes', (): void => {
const raw: SignerPayloadRaw = {
address: ADDRESS,
data: '0x68656c6c6f',
type: 'bytes'
};
it('returns sig || pk, with no variant byte', (): void => {
const signature = hexToU8a(new RequestBytesSign(raw).sign(null, pair).signature);
expect(signature.length).toEqual(s65.signatureWithPublicKey);
});
// Raw bytes need no runtime: there is no payload to build and no extension to
// read, which is why this channel still signs for a chain the extension has
// never heard of.
it('verifies against the signer address', (): void => {
const signature = hexToU8a(new RequestBytesSign(raw).sign(null, pair).signature);
expect(dilithiumVerify(u8aWrapBytes(raw.data), signature, pair.addressRaw, 'dilithium65', new Uint8Array())).toEqual(true);
});
// The separation that stops a dapp-login signature being replayed as a
// transfer.
it('is not valid under the extrinsic context', (): void => {
const signature = hexToU8a(new RequestBytesSign(raw).sign(null, pair).signature);
expect(dilithiumVerify(u8aWrapBytes(raw.data), signature, pair.addressRaw, 'dilithium65', contextForSpec(148))).toEqual(false);
});
});
});
@@ -1,12 +1,24 @@
// Copyright 2019-2026 @polkadot/extension-base authors & contributors
// SPDX-License-Identifier: Apache-2.0
import type { Runtime } from '@quantus/codec';
import type { KeyringPair } from '@polkadot/keyring/types';
import type { TypeRegistry } from '@polkadot/types';
import type { SignerPayloadJSON } from '@polkadot/types/types';
import type { HexString } from '@polkadot/util/types';
import type { RequestSignExtrinsic } from './types.js';
import { contextForSpec } from '@quantus/crypto';
import { hexToNumber, hexToU8a, u8aToHex } from '@polkadot/util';
import { blake2AsU8a } from '@polkadot/util-crypto';
/**
* Substrate's own rule, from `unchecked_extrinsic.rs`: a signing payload longer
* than 256 bytes is signed as its BLAKE2b-256 hash, otherwise as-is. The signer
* and the runtime must apply it identically or nothing verifies.
*/
const HASH_ABOVE = 256;
export default class RequestExtrinsicSign implements RequestSignExtrinsic {
public readonly channel = 'extrinsic' as const;
public readonly payload: SignerPayloadJSON;
@@ -15,9 +27,77 @@ export default class RequestExtrinsicSign implements RequestSignExtrinsic {
this.payload = payload;
}
sign (registry: TypeRegistry, pair: KeyringPair): { signature: HexString } {
return registry
.createType('ExtrinsicPayload', this.payload, { version: this.payload.version })
.sign(pair);
/**
* Build the payload from the runtime's own description of itself, then sign it.
*
* Upstream called `registry.createType('ExtrinsicPayload', …).sign(pair)`.
* Neither half of that survives here.
*
* The **encoding** cannot, because `@polkadot/types` cannot describe this
* chain: it caps fixed arrays at 2048 bytes where ML-DSA signatures are 5261
* and 7219, and — the part that would have gone unnoticed — it writes zero
* bytes for any signed extension it does not recognise, logging
* `treating them as no-effect`. That guess is right only while every
* unrecognised extension happens to be zero-sized. When it stops being right
* this would keep signing: valid signatures over a payload missing bytes the
* runtime put there, which the chain reports as `BadProof` — the same thing it
* reports for a wrong key. See quantus/api#1.
*
* The **signing** cannot, because `ExtrinsicPayload.sign` has nowhere to put a
* FIPS 204 context, and an ML-DSA pair will not sign without one.
*
* So the payload comes from `@quantus/codec`, which walks the extensions the
* runtime declares, in order, and refuses to build anything at all when one
* that encodes to something has no value here. A wallet that cannot sign is a
* bug report; a wallet that signs the wrong bytes is a support case nobody
* diagnoses.
*/
sign (runtime: Runtime | null, pair: KeyringPair): { signature: HexString } {
if (!runtime) {
// Refusing is the honest outcome. Without the runtime's own description
// there is no way to know what this chain's extensions contribute, and
// signing anyway would mean guessing — which is the failure this whole
// path exists to remove. The dapp's remedy is to provide its metadata.
throw new Error(`No metadata for chain ${this.payload.genesisHash}; it must be provided before this extension can sign for it`);
}
const { blockHash, era, genesisHash, metadataHash, method, mode, nonce, specVersion, tip, transactionVersion } = this.payload;
const values = runtime.standardExtensions({
blockHash,
// `era` arrives already SCALE-encoded, because the dapp encoded it. It is
// round-tripped against the runtime's own Era type rather than appended on
// trust — see @quantus/codec.
eraHex: era,
genesisHash,
metadataHash: metadataHash ?? null,
nonce: hexToNumber(nonce),
specVersion: hexToNumber(specVersion),
tip,
transactionVersion: hexToNumber(transactionVersion)
});
if (mode !== undefined) {
values['CheckMetadataHash'] = {
...values['CheckMetadataHash'],
extra: mode === 1 ? 'Enabled' : 'Disabled'
};
}
const payload = runtime.signerPayload(hexToU8a(method), values);
const toSign = payload.length > HASH_ABOVE
? blake2AsU8a(payload)
: payload;
// Only the caller knows the runtime version, and it arrives in the payload:
// spec >= 148 verifies under QUANTUS_EXTRINSIC, earlier specs under the
// empty context. The wrong one yields a signature that is cryptographically
// valid, rejected by the chain, and indistinguishable from a correct one
// without asking a node.
const context = contextForSpec(hexToNumber(specVersion));
return {
signature: u8aToHex(pair.sign(toSign, { context, withType: true }))
};
}
}
@@ -0,0 +1,311 @@
// Copyright 2019-2026 @polkadot/extension-base authors & contributors
// SPDX-License-Identifier: Apache-2.0
import type { RequestWalletCreate, ResponseWalletPreview, WalletAccount, WalletInfo, WalletJson, WormholeBalance } from './types.js';
import { wormholeAddresses, WormholeBranch } from '@quantus/crypto';
import { BehaviorSubject } from 'rxjs';
import { keyring } from '@polkadot/ui-keyring';
import { assert, isHex, objectSpread, stringToU8a, u8aToHex, u8aToString } from '@polkadot/util';
import { jsonDecrypt, jsonEncrypt, mnemonicValidate } from '@polkadot/util-crypto';
import { WalletsStore } from '../stores/index.js';
import { extendNullifiers, initialNullifiers, Nullifiers, wormholeBalance } from './Wormhole.js';
/**
* How many wormhole addresses to derive per branch, per account index.
*
* The usual BIP44 gap limit. Deriving needs the recovery phrase, and so the
* password; showing a balance should not. So a window is derived whenever the
* password is to hand, which is at creation and when an account is added.
*/
export const WORMHOLE_WINDOW = 20;
const SEED_WORDS = [12, 15, 18, 21, 24];
type Source = WalletInfo['source'];
/** The keypair type for each signing tab, and how its pair is named for dapps. */
const SIGNING = [
{ key: 'mldsa65', label: 'ML-DSA-65', type: 'dilithium65' },
{ key: 'mldsa87', label: 'ML-DSA-87', type: 'dilithium87' }
] as const;
function sourceOf (secret: string): Source {
if (isHex(secret)) {
assert(isHex(secret, 256), 'A hex seed must be 32 bytes');
return 'seed';
}
const words = secret.trim().split(/\s+/);
assert(SEED_WORDS.includes(words.length), `A recovery phrase has ${SEED_WORDS.join(', ')} words`);
assert(mnemonicValidate(secret), 'Not a valid recovery phrase');
return 'mnemonic';
}
function normalise (secret: string): string {
return isHex(secret.trim())
? secret.trim().toLowerCase()
: secret.trim().split(/\s+/).join(' ');
}
/**
* The suri for one signing account.
*
* `//<n>` is the account index, which the fork's path resolver turns into
* `m/44'/189189'/<n>'/0'/<scheme>'` — the path `quantus-cli` and the mobile
* wallet derive. A raw seed has no derivation at all: it is the ML-DSA key
* generation input itself, which is how the dev accounts are made.
*/
function suriOf (secret: string, source: Source, index: number): string {
return source === 'seed'
? secret
: `${secret}//${index}`;
}
/** The name a signing pair carries into the keyring, and so to dapps. */
function pairName (name: string, index: number, label: string): string {
return index
? `${name} #${index} (${label})`
: `${name} (${label})`;
}
function randomId (): string {
const bytes = new Uint8Array(16);
globalThis.crypto.getRandomValues(bytes);
return u8aToHex(bytes).slice(2);
}
function derive (secret: string, source: Source, index: number): Omit<WalletAccount, 'wormhole'> {
const [mldsa65, mldsa87] = SIGNING.map(({ type }) =>
keyring.createFromUri(suriOf(secret, source, index), {}, type).address
);
return { index, mldsa65, mldsa87 };
}
function deriveWormhole (secret: string, source: Source, index: number, count: number): WalletAccount['wormhole'] {
if (source !== 'mnemonic') {
return null;
}
const encode = (branch: WormholeBranch) =>
wormholeAddresses(secret, '', index, branch, 0, count).map((id) => keyring.encodeAddress(id));
return {
change: encode(WormholeBranch.Change),
receive: encode(WormholeBranch.Receive)
};
}
function info ({ secret: _, ...wallet }: WalletJson): WalletInfo {
return wallet;
}
/**
* Wallets: a secret shown as every account it unlocks.
*
* The ML-DSA pairs a wallet owns are ordinary keyring pairs, tagged with the
* wallet's id and their account index. Signing, dapp injection and JSON export
* keep working on them unchanged; this only adds the grouping, the stored
* secret that lets more accounts be added later, and the wormhole addresses,
* which have no keyring form because they have no key.
*/
export default class Wallets {
readonly subject = new BehaviorSubject<WalletInfo[]>([]);
readonly #store = new WalletsStore();
readonly #nullifiers = new Nullifiers();
readonly #wallets = new Map<string, WalletJson>();
readonly #ready: Promise<void>;
constructor () {
this.#ready = this.#store.all((_, wallet) => {
this.#wallets.set(wallet.id, wallet);
}).then(() => this.#publish());
}
#publish (): void {
this.subject.next(
[...this.#wallets.values()]
.sort((a, b) => a.whenCreated - b.whenCreated)
.map(info)
);
}
async #save (wallet: WalletJson): Promise<void> {
this.#wallets.set(wallet.id, wallet);
await this.#store.set(wallet.id, wallet);
this.#publish();
}
#unlock (wallet: WalletJson, password: string): string {
try {
return u8aToString(jsonDecrypt(wallet.secret, password));
} catch {
throw new Error('Unable to unlock the wallet: the password is wrong');
}
}
#addPairs (wallet: WalletInfo, secret: string, index: number, password: string): void {
for (const { label, type } of SIGNING) {
keyring.addUri(suriOf(secret, wallet.source, index), password, {
accountIndex: index,
genesisHash: wallet.genesisHash,
name: pairName(wallet.name, index, label),
walletId: wallet.id
}, type);
}
}
/** The account-0 addresses a secret would give, so a user can recognise their wallet. */
preview (secret: string): ResponseWalletPreview {
const normalised = normalise(secret);
const source = sourceOf(normalised);
const wormhole = deriveWormhole(normalised, source, 0, 1);
return objectSpread(derive(normalised, source, 0), {
wormhole: wormhole ? wormhole.receive[0] : null
});
}
async create ({ genesisHash = null, name, password, secret }: RequestWalletCreate): Promise<string> {
await this.#ready;
const normalised = normalise(secret);
const source = sourceOf(normalised);
const account = objectSpread<WalletAccount>(derive(normalised, source, 0), {
wormhole: deriveWormhole(normalised, source, 0, WORMHOLE_WINDOW)
});
// The same secret twice would be two cards fighting over one set of keyring
// pairs: forgetting either would take the other's accounts with it.
for (const wallet of this.#wallets.values()) {
assert(wallet.accounts[0]?.mldsa65 !== account.mldsa65, `This secret is already the wallet "${wallet.name}"`);
}
const wallet: WalletJson = {
accounts: [account],
genesisHash,
id: randomId(),
name,
secret: jsonEncrypt(stringToU8a(normalised), ['scrypt', 'xsalsa20-poly1305'], password),
source,
whenCreated: Date.now()
};
this.#addPairs(wallet, normalised, 0, password);
// While the phrase is to hand, so a wormhole balance never needs it just to
// be shown. A raw seed has no wormhole addresses and so nothing to store.
source === 'mnemonic' && await this.#nullifiers.set(wallet.id, 0, initialNullifiers(normalised, account));
await this.#save(wallet);
return wallet.id;
}
/** Add the next account index. Needs the password: it means deriving from the secret. */
async addAccount (id: string, password: string): Promise<void> {
await this.#ready;
const wallet = this.#wallets.get(id);
assert(wallet, 'Unable to find the wallet');
assert(wallet.source === 'mnemonic', 'A wallet made from a raw seed has one account; only a recovery phrase can derive more');
const secret = this.#unlock(wallet, password);
const index = Math.max(...wallet.accounts.map((a) => a.index)) + 1;
const account = objectSpread<WalletAccount>(derive(secret, wallet.source, index), {
wormhole: deriveWormhole(secret, wallet.source, index, WORMHOLE_WINDOW)
});
this.#addPairs(wallet, secret, index, password);
await this.#nullifiers.set(wallet.id, index, initialNullifiers(secret, account));
await this.#save(objectSpread({}, wallet, { accounts: [...wallet.accounts, account] }));
}
async rename (id: string, name: string): Promise<void> {
await this.#ready;
const wallet = this.#wallets.get(id);
assert(wallet, 'Unable to find the wallet');
for (const { index, ...addresses } of wallet.accounts) {
for (const { key, label } of SIGNING) {
const pair = keyring.getPair(addresses[key]);
pair && keyring.saveAccountMeta(pair, objectSpread({}, pair.meta, { name: pairName(name, index, label) }));
}
}
await this.#save(objectSpread({}, wallet, { name }));
}
#account (id: string, accountIndex: number): { account: WalletAccount, wallet: WalletJson } {
const wallet = this.#wallets.get(id);
assert(wallet, 'Unable to find the wallet');
const account = wallet.accounts.find((a) => a.index === accountIndex);
assert(account, `The wallet has no account ${accountIndex}`);
return { account, wallet };
}
/** What one account's wormhole addresses can still spend. No password needed. */
async wormholeBalance (id: string, accountIndex: number, endpoint: string, observer: string): Promise<WormholeBalance> {
await this.#ready;
const { account } = this.#account(id, accountIndex);
return wormholeBalance(account, await this.#nullifiers.get(id, accountIndex), endpoint, observer.replace(/\/+$/, ''));
}
/**
* Derive nullifiers past each address's current transfer count, so deposits a
* balance reported as unchecked can be checked. Needs the password.
*/
async wormholeUnlock (id: string, accountIndex: number, password: string, counts: WormholeBalance['counts']): Promise<void> {
await this.#ready;
const { account, wallet } = this.#account(id, accountIndex);
const secret = this.#unlock(wallet, password);
await this.#nullifiers.set(id, accountIndex, extendNullifiers(secret, account, await this.#nullifiers.get(id, accountIndex), counts));
}
/**
* Forget the wallet: its secret, every signing pair it owns, and the
* nullifiers precomputed for its wormhole addresses, which are the one thing
* left behind that could still tie this user to their exits.
*/
async forget (id: string): Promise<void> {
await this.#ready;
const wallet = this.#wallets.get(id);
assert(wallet, 'Unable to find the wallet');
for (const account of wallet.accounts) {
for (const { key } of SIGNING) {
try {
keyring.forgetAccount(account[key]);
} catch {
// Already forgotten on its own. The wallet still goes.
}
}
}
await this.#nullifiers.remove(id, wallet.accounts.map((a) => a.index));
this.#wallets.delete(id);
await this.#store.remove(id);
this.#publish();
}
}
@@ -0,0 +1,380 @@
// Copyright 2019-2026 @polkadot/extension-base authors & contributors
// SPDX-License-Identifier: Apache-2.0
import type { ProviderInterface } from '@polkadot/rpc-provider/types';
import type { WalletAccount, WormholeBalance, WormholeNullifiersJson } from './types.js';
import { Runtime } from '@quantus/codec';
import { WormholeBranch, wormholeNullifiers } from '@quantus/crypto';
import { WsProvider } from '@polkadot/rpc-provider';
import { hexToU8a, u8aConcat, u8aToHex } from '@polkadot/util';
import { base64Decode, base64Encode, blake2AsU8a, decodeAddress, xxhashAsU8a } from '@polkadot/util-crypto';
import { NullifiersStore } from '../stores/index.js';
/**
* Transfer counts precomputed per wormhole address whenever the password is at
* hand. A deposit whose count is past this cannot be checked until the wallet
* is unlocked again, and the balance says so rather than guessing.
*/
export const NULLIFIER_WINDOW = 128;
/** Headroom added past an address's current transfer count when unlocking. */
const NULLIFIER_HEADROOM = 128;
/**
* Buckets of `Wormhole::UsedNullifiers` fetched per check, at least.
*
* The map is `Blake2_128Concat`, so a key is `blake2_128(n) ‖ n`: looking one up
* by key hands the node the nullifier itself, and exits publish their
* nullifiers, so the node could name the exit. Instead whole buckets are read,
* keyed by the first byte of that hash, and checked here. The node learns which
* buckets were asked for, never which entry in them mattered. Buckets holding
* no nullifier of ours are added at random until there are this many, so one
* deposit is hidden among a sixteenth of all spends rather than a 256th.
*/
const NULLIFIER_BUCKETS_MIN = 16;
const KEYS_PAGE = 1000;
/** How long to wait for the node to answer at all. WsProvider retries forever. */
const CONNECT_TIMEOUT_MS = 15_000;
/** How long a whole check may take before it is abandoned. */
const CHECK_TIMEOUT_MS = 120_000;
function within<T> (promise: Promise<T>, ms: number, message: string): Promise<T> {
let timer: ReturnType<typeof setTimeout> | undefined;
return Promise.race([
promise,
new Promise<never>((_resolve, reject) => {
timer = setTimeout(() => reject(new Error(message)), ms);
})
]).finally(() => clearTimeout(timer));
}
const NULLIFIER_BYTES = 32;
const BRANCHES = [
{ branch: WormholeBranch.Receive, key: 'receive' },
{ branch: WormholeBranch.Change, key: 'change' }
] as const;
type Branch = typeof BRANCHES[number]['key'];
function packed (rows: Uint8Array[]): string {
return base64Encode(u8aConcat(...rows));
}
/** The stored nullifiers of one address, as one byte array of 32-byte entries. */
function unpack (stored: string | undefined): Uint8Array {
return stored ? base64Decode(stored) : new Uint8Array();
}
/**
* Nullifiers for every wormhole address of one wallet account, for transfer
* counts `0..NULLIFIER_WINDOW`. Needs the recovery phrase.
*/
export function initialNullifiers (mnemonic: string, account: WalletAccount): WormholeNullifiersJson | null {
if (!account.wormhole) {
return null;
}
const out = {} as WormholeNullifiersJson;
for (const { branch, key } of BRANCHES) {
out[key] = wormholeNullifiers(mnemonic, '', account.index, branch, 0, account.wormhole[key].length, 0, NULLIFIER_WINDOW)
.map(packed);
}
return out;
}
/**
* Extend stored nullifiers so each address covers its transfer count plus
* headroom. Only the addresses that fell short are derived, one call each.
*/
export function extendNullifiers (mnemonic: string, account: WalletAccount, stored: WormholeNullifiersJson | null, counts: Record<Branch, number[]>): WormholeNullifiersJson | null {
if (!account.wormhole) {
return null;
}
const out: WormholeNullifiersJson = {
change: [...(stored?.change ?? [])],
receive: [...(stored?.receive ?? [])]
};
for (const { branch, key } of BRANCHES) {
account.wormhole[key].forEach((_, index) => {
const have = unpack(out[key][index]).length / NULLIFIER_BYTES;
const count = counts[key][index] ?? 0;
// Only where a deposit is past what is stored (or nothing is stored yet,
// for a wallet made before nullifiers were): then to its count plus
// headroom, so the next few deposits are covered too.
const target = have === 0
? Math.max(count + NULLIFIER_HEADROOM, NULLIFIER_WINDOW)
: count >= have
? count + NULLIFIER_HEADROOM
: have;
if (target > have) {
const [row] = wormholeNullifiers(mnemonic, '', account.index, branch, index, 1, have, target - have);
out[key][index] = base64Encode(u8aConcat(unpack(out[key][index]), ...row));
}
});
}
return out;
}
export class Nullifiers {
readonly #store = new NullifiersStore();
static key (walletId: string, accountIndex: number): string {
return `${walletId}:${accountIndex}`;
}
get (walletId: string, accountIndex: number): Promise<WormholeNullifiersJson | null> {
return new Promise((resolve) => {
this.#store.get(Nullifiers.key(walletId, accountIndex), (value) => resolve(value ?? null))
.catch(() => resolve(null));
});
}
async set (walletId: string, accountIndex: number, value: WormholeNullifiersJson | null): Promise<void> {
if (value) {
await this.#store.set(Nullifiers.key(walletId, accountIndex), value);
}
}
/** Remove every account's nullifiers for a wallet. */
async remove (walletId: string, accountIndices: number[]): Promise<void> {
for (const index of accountIndices) {
await this.#store.remove(Nullifiers.key(walletId, index));
}
}
}
interface ObserverChain {
genesis: string;
id: string;
status?: string;
}
interface ObserverDeposit {
amount: string;
to: string;
transfer_count: number;
}
interface ObserverDeposits {
deposits: ObserverDeposit[];
indexed_from: number | null;
indexed_to: number | null;
next: string | null;
}
async function json<T> (url: string): Promise<T> {
const response = await fetch(url);
if (!response.ok) {
throw new Error(`${url} answered ${response.status}`);
}
return response.json() as Promise<T>;
}
/** The observer's name for a chain, found by genesis rather than trusted by name. */
async function observerChain (observer: string, genesis: string): Promise<string> {
const chains = await json<ObserverChain[]>(`${observer}/v1/chains`);
const matches = chains.filter((c) => c.genesis === genesis);
const live = matches.find((c) => c.status === 'live') ?? matches[0];
if (!live) {
throw new Error(`The observer at ${observer} does not index this chain`);
}
return live.id;
}
async function depositsFor (observer: string, chain: string, tos: string[]): Promise<ObserverDeposits> {
const all: ObserverDeposit[] = [];
let page: ObserverDeposits;
let after: string | null = null;
do {
const query = new URLSearchParams({ limit: '1000', to: tos.join(',') });
after && query.set('after', after);
page = await json<ObserverDeposits>(`${observer}/v1/chains/${chain}/wormhole/deposits?${query.toString()}`);
all.push(...page.deposits);
after = page.next;
} while (after);
return { deposits: all, indexed_from: page.indexed_from, indexed_to: page.indexed_to, next: null };
}
/** Every used nullifier in the given first-byte buckets, as lowercase hex. */
export async function usedNullifiers (provider: ProviderInterface, buckets: number[]): Promise<Set<string>> {
const prefix = u8aConcat(xxhashAsU8a('Wormhole', 128), xxhashAsU8a('UsedNullifiers', 128));
const used = new Set<string>();
for (const bucket of buckets) {
const bucketPrefix = u8aToHex(u8aConcat(prefix, new Uint8Array([bucket])));
let start: string | undefined;
for (;;) {
const keys = await provider.send<string[]>('state_getKeysPaged', start ? [bucketPrefix, KEYS_PAGE, start] : [bucketPrefix, KEYS_PAGE]);
// blake2_128(n) ‖ n: the nullifier is the key's last 32 bytes
keys.forEach((key) => used.add(`0x${key.slice(-64).toLowerCase()}`));
if (keys.length < KEYS_PAGE) {
break;
}
start = keys[keys.length - 1];
}
}
return used;
}
function randomBuckets (have: Set<number>): number[] {
const out = new Set(have);
while (out.size < NULLIFIER_BUCKETS_MIN) {
out.add(globalThis.crypto.getRandomValues(new Uint8Array(1))[0]);
}
// Asked in a shuffled order, so the ones that matter do not come first.
const list = [...out];
for (let i = list.length - 1; i > 0; i--) {
const j = globalThis.crypto.getRandomValues(new Uint32Array(1))[0] % (i + 1);
[list[i], list[j]] = [list[j], list[i]];
}
return list;
}
function first<T> (value: unknown): T | undefined {
return (Array.isArray(value) ? value[0] : value) as T | undefined;
}
/**
* What one wallet account's wormhole addresses can still spend.
*
* - Deposits come from an observer the user chose, by address. Its answer is
* checked against the chain's own `Wormhole::TransferCount`, so an index that
* is behind shows as deposits it has not found, never as a smaller balance.
* - A deposit is spent if its nullifier is in `Wormhole::UsedNullifiers`, read
* in whole buckets (see `NULLIFIER_BUCKETS_MIN`). Nullifiers never leave here.
* - A deposit whose nullifier was never precomputed is reported as unchecked:
* the wallet has to be unlocked to derive it.
*
* `TransferCount` is keyed on the canonical recipient (limbs reduced mod the
* Goldilocks prime). A wormhole address is a Poseidon digest, whose limbs are
* already canonical, so the address itself is the key.
*/
export function wormholeBalance (account: WalletAccount, nullifiers: WormholeNullifiersJson | null, endpoint: string, observer: string): Promise<WormholeBalance> {
const provider = new WsProvider(endpoint, false);
return within(check(provider, account, nullifiers, endpoint, observer), CHECK_TIMEOUT_MS, 'The check took more than two minutes and was abandoned')
.finally(() => {
provider.disconnect().catch(() => undefined);
});
}
async function check (provider: WsProvider, account: WalletAccount, nullifiers: WormholeNullifiersJson | null, endpoint: string, observer: string): Promise<WormholeBalance> {
if (!account.wormhole) {
throw new Error('This account has no wormhole addresses');
}
await provider.connect();
await within(provider.isReady, CONNECT_TIMEOUT_MS, `No answer from ${endpoint}`);
const [genesis, metadata, properties] = await Promise.all([
provider.send<string>('chain_getBlockHash', [0]),
provider.send<string>('state_getMetadata', []),
provider.send<Record<string, unknown>>('system_properties', [])
]);
const runtime = Runtime.fromMetadata(hexToU8a(metadata));
const addresses = BRANCHES.flatMap(({ key }) =>
(account.wormhole?.[key] ?? []).map((address, index) => ({ branch: key, id: u8aToHex(decodeAddress(address)), index }))
);
const targets = addresses.map(({ id }) => runtime.storageTarget('Wormhole', 'TransferCount', [id]));
const [{ changes }] = await provider.send<{ changes: [string, string | null][] }[]>('state_queryStorageAt', [targets.map((t) => t.key)]);
const raw = new Map(changes.map(([key, value]) => [key, value]));
const counts = targets.map((t) => {
const value = raw.get(t.key) ?? t.default ?? '0x0000000000000000';
return Number(runtime.decodeStorage(t.valueTy, hexToU8a(value)) as string);
});
const active = addresses.filter((_, i) => counts[i] > 0);
const transfers = counts.reduce((a, b) => a + b, 0);
const found = active.length
? await depositsFor(observer, await observerChain(observer, genesis), active.map(({ id }) => id))
: { deposits: [], indexed_from: null, indexed_to: null, next: null };
// our nullifier for each found deposit, when it was precomputed
const byId = new Map<string, typeof active[number]>(active.map((a) => [a.id, a]));
const ours = found.deposits.map((deposit) => {
const address = byId.get(deposit.to.toLowerCase());
const bytes = address && unpack(nullifiers?.[address.branch][address.index]);
const at = deposit.transfer_count * NULLIFIER_BYTES;
return {
amount: BigInt(deposit.amount),
nullifier: bytes && bytes.length >= at + NULLIFIER_BYTES
? bytes.subarray(at, at + NULLIFIER_BYTES)
: null
};
});
const buckets = new Set(ours.flatMap(({ nullifier }) => nullifier ? [blake2AsU8a(nullifier, 128)[0]] : []));
const used = buckets.size
? await usedNullifiers(provider, randomBuckets(buckets))
: new Set<string>();
let spendable = BigInt(0);
let spent = BigInt(0);
let unchecked = BigInt(0);
let uncheckedDeposits = 0;
for (const { amount, nullifier } of ours) {
if (!nullifier) {
unchecked += amount;
uncheckedDeposits++;
} else if (used.has(u8aToHex(nullifier))) {
spent += amount;
} else {
spendable += amount;
}
}
return {
checkedAt: Date.now(),
counts: {
change: counts.slice(account.wormhole.receive.length),
receive: counts.slice(0, account.wormhole.receive.length)
},
decimals: Number(first(properties['tokenDecimals']) ?? 12),
deposits: found.deposits.length,
indexedFrom: found.indexed_from,
indexedTo: found.indexed_to,
missing: Math.max(0, transfers - found.deposits.length),
spendable: spendable.toString(),
spent: spent.toString(),
symbol: String(first(properties['tokenSymbol']) ?? ''),
transfers,
unchecked: unchecked.toString(),
uncheckedDeposits
};
}
@@ -6,19 +6,21 @@
import '@polkadot/extension-mocks/chrome';
import type * as _ from '@polkadot/dev-test/globals.d.ts';
import type { ResponseSigning } from '@polkadot/extension-base/background/types';
import type { MetadataDef } from '@polkadot/extension-inject/types';
import type { ResponseSigning, WalletInfo, WormholeNullifiersJson } from '@polkadot/extension-base/background/types';
import type { KeyringPair } from '@polkadot/keyring/types';
import type { ExtDef } from '@polkadot/types/extrinsic/signedExtensions/types';
import type { SignerPayloadJSON } from '@polkadot/types/types';
import type { HexString } from '@polkadot/util/types';
import type { KeypairType } from '@polkadot/util-crypto/types';
import { TypeRegistry } from '@polkadot/types';
import { Scheme, sizes, WormholeBranch, wormholeNullifiers } from '@quantus/crypto';
import keyring from '@polkadot/ui-keyring';
import { stringToHex } from '@polkadot/util';
import { cryptoWaitReady } from '@polkadot/util-crypto';
import { assert, hexToU8a, stringToHex, u8aConcat, u8aToHex } from '@polkadot/util';
import { base64Decode, cryptoWaitReady, decodeAddress } from '@polkadot/util-crypto';
import { AccountsStore } from '../../stores/index.js';
import { HEISENBERG_GENESIS, heisenbergMetadataDef } from '../../test/metadata.js';
import { extendNullifiers, NULLIFIER_WINDOW } from '../Wormhole.js';
import Extension from './Extension.js';
import State from './State.js';
import Tabs from './Tabs.js';
@@ -34,7 +36,9 @@ describe('Extension', () => {
try {
await cryptoWaitReady();
keyring.loadAll({ store: new AccountsStore() });
// As background.ts does. Without it the harness makes sr25519 accounts and
// tests the one keypair type this fork does not target.
keyring.loadAll({ store: new AccountsStore(), type: 'dilithium65' });
state = new State({}, 0);
await state.init();
@@ -48,27 +52,33 @@ describe('Extension', () => {
}
}
// `type` is passed through rather than special-cased for ethereum: the default
// is now ML-DSA, so a test that needs a derivable parent has to say so.
// `pub(extrinsic.sign)` is asynchronous before it queues anything: it fetches
// the chain's metadata first, so the approval screen has something to decode
// the call with. Reading allSignRequests straight after calling it is a race
// the test would lose.
const nextSignRequest = async () => {
const before = state.allSignRequests.length;
for (let i = 0; i < 100 && state.allSignRequests.length === before; i++) {
await new Promise((resolve) => setTimeout(resolve, 10));
}
return state.allSignRequests[state.allSignRequests.length - 1];
};
const createAccount = async (type?: KeypairType): Promise<string> => {
await extension.handle('id', 'pri(accounts.create.suri)', type && type === 'ethereum'
? {
name: 'parent',
password,
suri,
type
}
: {
name: 'parent',
password,
suri
}, {} as chrome.runtime.Port);
const { address } = await extension.handle('id', 'pri(seed.validate)', type && type === 'ethereum'
? {
suri,
type
}
: {
suri
}, {} as chrome.runtime.Port);
await extension.handle('id', 'pri(accounts.create.suri)', {
name: 'parent',
password,
suri,
...(type ? { type } : {})
}, {} as chrome.runtime.Port);
const { address } = await extension.handle('id', 'pri(seed.validate)', {
suri,
...(type ? { type } : {})
}, {} as chrome.runtime.Port);
return address;
};
@@ -88,65 +98,64 @@ describe('Extension', () => {
expect(result.exportedJson.encoded).toBeDefined();
});
describe('account derivation', () => {
let address: string;
describe('both ML-DSA parameter sets', () => {
// The chain's DilithiumSignatureScheme has exactly two variants, so both are
// in use and both must be importable. crystal_alice is the vector: seed
// 0x00…00 as ML-DSA-87, funded at dev genesis, and its address is the one the
// chain itself holds a balance for. The same seed as ML-DSA-65 is a different,
// unfunded account — which is what importing it as the default type used to
// produce, with nothing on screen to say so.
const ZERO_SEED = `0x${'00'.repeat(32)}`;
const CRYSTAL_ALICE = 'qzk1Nxai3dZD9Cn5kwGcgL6mKxsfxwqdis7kDQJ52aJS2vSn7';
beforeEach(async () => {
address = await createAccount();
});
it('pri(derivation.validate) passes for valid suri', async () => {
const result = await extension.handle('id', 'pri(derivation.validate)', {
parentAddress: address,
parentPassword: password,
suri: '//path'
it('validates a seed as ML-DSA-87 to the account the chain funded', async () => {
const { address } = await extension.handle('id', 'pri(seed.validate)', {
suri: ZERO_SEED,
type: 'dilithium87'
}, {} as chrome.runtime.Port);
expect(result).toEqual({
address: '5FP3TT3EruYBNh8YM8yoxsreMx7uZv1J1zNX7fFhoC5enwmN',
suri: '//path'
});
expect(u8aToHex(decodeAddress(address))).toEqual(u8aToHex(decodeAddress(CRYSTAL_ALICE)));
});
it('pri(derivation.validate) throws for invalid suri', async () => {
await expect(extension.handle('id', 'pri(derivation.validate)', {
parentAddress: address,
parentPassword: password,
suri: 'invalid-path'
}, {} as chrome.runtime.Port)).rejects.toThrow(/is not a valid derivation path/);
it('gives a different account for the same seed as ML-DSA-65', async () => {
const { address } = await extension.handle('id', 'pri(seed.validate)', {
suri: ZERO_SEED,
type: 'dilithium65'
}, {} as chrome.runtime.Port);
expect(u8aToHex(decodeAddress(address))).not.toEqual(u8aToHex(decodeAddress(CRYSTAL_ALICE)));
});
it('pri(derivation.validate) throws for invalid password', async () => {
await expect(extension.handle('id', 'pri(derivation.validate)', {
parentAddress: address,
parentPassword: 'invalid-password',
suri: '//path'
}, {} as chrome.runtime.Port)).rejects.toThrow(/invalid password/);
});
it('pri(derivation.create) adds a derived account', async () => {
await extension.handle('id', 'pri(derivation.create)', {
name: 'child',
parentAddress: address,
parentPassword: password,
it('stores an imported ML-DSA-87 account as ML-DSA-87, and it signs', async () => {
await extension.handle('id', 'pri(accounts.create.suri)', {
name: 'crystal_alice',
password,
suri: '//path'
suri: ZERO_SEED,
type: 'dilithium87'
}, {} as chrome.runtime.Port);
expect(keyring.getAccounts()).toHaveLength(2);
});
it('pri(derivation.create) saves parent address in meta', async () => {
await extension.handle('id', 'pri(derivation.create)', {
name: 'child',
parentAddress: address,
parentPassword: password,
password,
suri: '//path'
}, {} as chrome.runtime.Port);
expect(keyring.getAccount('5FP3TT3EruYBNh8YM8yoxsreMx7uZv1J1zNX7fFhoC5enwmN')?.meta.parentAddress).toEqual(address);
const pair = keyring.getPair(CRYSTAL_ALICE);
expect(pair.type).toEqual('dilithium87');
pair.decodePkcs8(password);
// Variant 0 is Dilithium87 in the runtime's DilithiumSignatureScheme, and
// the body is sig ‖ pk at the 87 sizes.
const signed = pair.sign(new Uint8Array([1, 2, 3]), { context: new Uint8Array(), withType: true });
expect(signed[0]).toEqual(0);
expect(signed.length).toEqual(sizes(Scheme.MlDsa87).signatureWithPublicKey + 1);
});
});
// Upstream derived child accounts from a parent pair along a //hard/soft
// path. ML-DSA keys have no such derivation; Quantus derives every account
// from the mnemonic instead, so the messages are gone (quantus/common#6).
it('has no account derivation', async () => {
await expect(extension.handle('id', 'pri(derivation.validate)' as 'pri(ping)', {} as never, {} as chrome.runtime.Port)).rejects.toThrow(/Unable to handle message/);
});
describe('account management', () => {
let address: string;
@@ -182,298 +191,93 @@ describe('Extension', () => {
});
});
describe('custom user extension', () => {
let address: string, payload: SignerPayloadJSON, pair: KeyringPair;
describe('signing an extrinsic', () => {
// Upstream's block here was five variations on "does the extension agree with
// @polkadot/api", each computing an expected signature with
// `registry.createType('ExtrinsicPayload', …).sign(pair)` and comparing.
//
// None of that survives the fork. @polkadot/types cannot describe this chain
// — it caps fixed arrays at 2048 bytes and ML-DSA signatures are 5261 and
// 7219 — and `ExtrinsicPayload.sign` has nowhere to put a FIPS 204 context,
// which an ML-DSA pair will not sign without. quantus/api#1 has the evidence.
//
// Those tests were also built on `userExtensions`: a dapp declaring, in
// JavaScript, what an unrecognised signed extension contributes to the
// payload. That mechanism is gone, and its absence is the point. The runtime
// *declares* its extensions and @quantus/codec reads them, so there is
// nothing for a dapp to tell the wallet and no way for it to be believed.
let address: string, pair: KeyringPair;
const payloadFor = (genesisHash: string): SignerPayloadJSON => ({
address,
blockHash: HEISENBERG_GENESIS,
blockNumber: '0x00000393',
era: '0x00', // immortal
genesisHash: genesisHash as HexString,
method: '0x020300d43593c715fdd31c61141abd04a99fd6822c8558854ccde39a5684e7a56da27d02286bee',
nonce: '0x00000000',
signedExtensions: ['CheckSpecVersion', 'CheckTxVersion', 'CheckGenesis', 'CheckMortality', 'CheckNonce', 'CheckWeight', 'ChargeTransactionPayment'],
specVersion: '0x00000094',
tip: '0x00000000000000000000000000000000',
transactionVersion: '0x00000006',
version: 4
});
// The newest request, not the oldest: requests from earlier tests in this
// file are still queued, and approving one of those silently tests nothing.
const approve = async () => extension.handle('1615192072290.7', 'pri(signing.approve.password)', {
id: (await nextSignRequest()).id,
password,
savePass: false
}, {} as chrome.runtime.Port);
beforeEach(async () => {
address = await createAccount();
pair = keyring.getPair(address);
pair.decodePkcs8(password);
payload = {
});
it('signs an extrinsic for a chain whose metadata it has', async () => {
await state.saveMetadata(heisenbergMetadataDef());
const signing = tabs.handle('1615191860871.5', 'pub(extrinsic.sign)', payloadFor(HEISENBERG_GENESIS), 'http://localhost:3000', {} as chrome.runtime.Port);
expect(await approve()).toEqual(true);
const { signature } = await signing as ResponseSigning;
const bytes = hexToU8a(signature);
// Variant byte for the pair's scheme, then sig ‖ pk as a fixed array.
expect(bytes.length).toEqual(sizes(Scheme.MlDsa65).signatureWithPublicKey + 1);
expect(bytes[0]).toEqual(1);
});
// The refusal that replaces guessing. Without the runtime's own description
// there is no way to know what its signed extensions contribute, and a
// signature over the wrong bytes comes back from a node as `BadProof` —
// indistinguishable from a wrong key, and only ever seen by the user.
it('refuses to sign for a chain whose metadata it does not have', async () => {
const unknown = '0x242a54b35e1aad38f37b884eddeb71f6f9931b02fac27bf52dfb62ef754e5e62';
const signing = tabs.handle('1615191860871.6', 'pub(extrinsic.sign)', payloadFor(unknown), 'http://localhost:3000', {} as chrome.runtime.Port);
await expect(approve()).rejects.toThrow(/No metadata for chain/);
await expect(signing).rejects.toThrow(/No metadata for chain/);
});
// Raw bytes carry no payload to build and no extension to read, so this
// channel still works for a chain the extension has never heard of.
it('signs raw bytes without any metadata', async () => {
const signing = tabs.handle('1615191860871.7', 'pub(bytes.sign)', {
address,
blockHash: '0xe1b1dda72998846487e4d858909d4f9a6bbd6e338e4588e5d809de16b1317b80',
blockNumber: '0x00000393',
era: '0x3601',
genesisHash: '0x242a54b35e1aad38f37b884eddeb71f6f9931b02fac27bf52dfb62ef754e5e62',
method: '0x040105fa8eaf04151687736326c9fea17e25fc5287613693c912909cb226aa4794f26a4882380100',
nonce: '0x0000000000000000',
signedExtensions: ['CheckSpecVersion', 'CheckTxVersion', 'CheckGenesis', 'CheckMortality', 'CheckNonce', 'CheckWeight', 'ChargeTransactionPayment'],
specVersion: '0x00000026',
tip: '0x00000000000000000000000000000000',
transactionVersion: '0x00000005',
version: 4
};
});
data: '0x68656c6c6f',
type: 'bytes'
}, 'http://localhost:3000', {} as chrome.runtime.Port);
it('signs with default signed extensions', async () => {
const registry = new TypeRegistry();
expect(await approve()).toEqual(true);
registry.setSignedExtensions(payload.signedExtensions);
const { signature } = await signing as ResponseSigning;
const signatureExpected = registry
.createType('ExtrinsicPayload', payload, { version: payload.version }).sign(pair);
// eslint-disable-next-line jest/valid-expect-in-promise
tabs.handle('1615191860871.5', 'pub(extrinsic.sign)', payload, 'http://localhost:3000', {} as chrome.runtime.Port)
.then((result) => {
// eslint-disable-next-line jest/no-conditional-expect
expect((result as ResponseSigning)?.signature).toEqual(signatureExpected.signature);
}).catch((err) => console.log(err));
const res = await extension.handle('1615192072290.7', 'pri(signing.approve.password)', {
id: state.allSignRequests[0].id,
password,
savePass: false
}, {} as chrome.runtime.Port);
expect(res).toEqual(true);
});
it('signs with default signed extensions - ethereum', async () => {
const ethAddress = await createAccount('ethereum');
const ethPair = keyring.getPair(ethAddress);
ethPair.decodePkcs8(password);
const ethPayload: SignerPayloadJSON = {
address: ethAddress,
blockHash: '0xf9fc354edc3ff49f43d5e2c14e3c609a0c4ba469ed091edf893d672993dc9bc0',
blockNumber: '0x00000393',
era: '0x3601',
genesisHash: '0xf9fc354edc3ff49f43d5e2c14e3c609a0c4ba469ed091edf893d672993dc9bc0',
method: '0x03003cd0a705a2dc65e5b1e1205896baa2be8a07c6e0070010a5d4e8',
nonce: '0x00000000',
signedExtensions: [
'CheckSpecVersion',
'CheckTxVersion',
'CheckGenesis',
'CheckMortality',
'CheckNonce',
'CheckWeight',
'ChargeTransactionPayment'
],
specVersion: '0x000003e9',
tip: '0x00000000000000000000000000000000',
transactionVersion: '0x00000002',
version: 4
};
const registry = new TypeRegistry();
registry.setSignedExtensions(payload.signedExtensions);
const signatureExpected = registry
.createType('ExtrinsicPayload', ethPayload, { version: ethPayload.version }).sign(ethPair);
// eslint-disable-next-line jest/valid-expect-in-promise
tabs.handle('1615191860871.5', 'pub(extrinsic.sign)', ethPayload, 'http://localhost:3000', {} as chrome.runtime.Port)
.then((result) => {
// eslint-disable-next-line jest/no-conditional-expect
expect((result as ResponseSigning)?.signature).toEqual(signatureExpected.signature);
}).catch((err) => console.log(err));
const res = await extension.handle('1615192072290.7', 'pri(signing.approve.password)', {
id: state.allSignRequests[0].id,
password,
savePass: false
}, {} as chrome.runtime.Port);
expect(res).toEqual(true);
});
it('signs with user extensions, known types', async () => {
const types = {} as unknown as Record<string, string>;
const userExtensions = {
MyUserExtension: {
extrinsic: {
assetId: 'AssetId'
},
payload: {}
}
} as unknown as ExtDef;
const meta: MetadataDef = {
chain: 'Development',
color: '#191a2e',
genesisHash: '0x242a54b35e1aad38f37b884eddeb71f6f9931b02fac27bf52dfb62ef754e5e62',
icon: '',
specVersion: 38,
ss58Format: 0,
tokenDecimals: 12,
tokenSymbol: '',
types,
userExtensions
};
await state.saveMetadata(meta);
const payload: SignerPayloadJSON = {
address,
blockHash: '0xe1b1dda72998846487e4d858909d4f9a6bbd6e338e4588e5d809de16b1317b80',
blockNumber: '0x00000393',
era: '0x3601',
genesisHash: '0x242a54b35e1aad38f37b884eddeb71f6f9931b02fac27bf52dfb62ef754e5e62',
method: '0x040105fa8eaf04151687736326c9fea17e25fc5287613693c912909cb226aa4794f26a4882380100',
nonce: '0x0000000000000000',
signedExtensions: ['MyUserExtension'],
specVersion: '0x00000026',
tip: '0x00000000000000000000000000000000',
transactionVersion: '0x00000005',
version: 4
};
const registry = new TypeRegistry();
registry.setSignedExtensions(payload.signedExtensions, userExtensions);
registry.register(types);
const signatureExpected = registry
.createType('ExtrinsicPayload', payload, { version: payload.version }).sign(pair);
// eslint-disable-next-line jest/valid-expect-in-promise
tabs.handle('1615191860771.5', 'pub(extrinsic.sign)', payload, 'http://localhost:3000', {} as chrome.runtime.Port)
.then((result) => {
// eslint-disable-next-line jest/no-conditional-expect
expect((result as ResponseSigning)?.signature).toEqual(signatureExpected.signature);
}).catch((err) => console.log(err));
const res = await extension.handle('1615192062290.7', 'pri(signing.approve.password)', {
id: state.allSignRequests[0].id,
password,
savePass: false
}, {} as chrome.runtime.Port);
expect(res).toEqual(true);
});
it('override default signed extension', async () => {
const types = {
FeeExchangeV1: {
assetId: 'Compact<AssetId>',
maxPayment: 'Compact<Balance>'
},
PaymentOptions: {
feeExchange: 'FeeExchangeV1',
tip: 'Compact<Balance>'
}
} as unknown as Record<string, string>;
const userExtensions = {
ChargeTransactionPayment: {
extrinsic: {
transactionPayment: 'PaymentOptions'
},
payload: {}
}
} as unknown as ExtDef;
const meta: MetadataDef = {
chain: 'Development',
color: '#191a2e',
genesisHash: '0x242a54b35e1aad38f37b884eddeb71f6f9931b02fac27bf52dfb62ef754e5e62',
icon: '',
specVersion: 38,
ss58Format: 0,
tokenDecimals: 12,
tokenSymbol: '',
types,
userExtensions
};
await state.saveMetadata(meta);
const registry = new TypeRegistry();
registry.setSignedExtensions(payload.signedExtensions, userExtensions);
registry.register(types);
const signatureExpected = registry
.createType('ExtrinsicPayload', payload, { version: payload.version }).sign(pair);
// eslint-disable-next-line jest/valid-expect-in-promise
tabs.handle('1615191860771.5', 'pub(extrinsic.sign)', payload, 'http://localhost:3000', {} as chrome.runtime.Port)
.then((result) => {
// eslint-disable-next-line jest/no-conditional-expect
expect((result as ResponseSigning)?.signature).toEqual(signatureExpected.signature);
}).catch((err) => console.log(err));
const res = await extension.handle('1615192062290.7', 'pri(signing.approve.password)', {
id: state.allSignRequests[0].id,
password,
savePass: false
}, {} as chrome.runtime.Port);
expect(res).toEqual(true);
});
it('signs with user extensions, additional types', async () => {
const types = {
myCustomType: {
feeExchange: 'Compact<AssetId>',
tip: 'Compact<Balance>'
}
} as unknown as Record<string, string>;
const userExtensions = {
MyUserExtension: {
extrinsic: {
myCustomType: 'myCustomType'
},
payload: {}
}
} as unknown as ExtDef;
const meta: MetadataDef = {
chain: 'Development',
color: '#191a2e',
genesisHash: '0x242a54b35e1aad38f37b884eddeb71f6f9931b02fac27bf52dfb62ef754e5e62',
icon: '',
specVersion: 38,
ss58Format: 0,
tokenDecimals: 12,
tokenSymbol: '',
types,
userExtensions
};
await state.saveMetadata(meta);
const payload = {
address,
blockHash: '0xe1b1dda72998846487e4d858909d4f9a6bbd6e338e4588e5d809de16b1317b80',
blockNumber: '0x00000393',
era: '0x3601',
genesisHash: '0x242a54b35e1aad38f37b884eddeb71f6f9931b02fac27bf52dfb62ef754e5e62',
method: '0x040105fa8eaf04151687736326c9fea17e25fc5287613693c912909cb226aa4794f26a4882380100',
nonce: '0x0000000000000000',
signedExtensions: ['MyUserExtension', 'CheckTxVersion', 'CheckGenesis', 'CheckMortality', 'CheckNonce', 'CheckWeight', 'ChargeTransactionPayment'],
specVersion: '0x00000026',
tip: null,
transactionVersion: '0x00000005',
version: 4
} as unknown as SignerPayloadJSON;
const registry = new TypeRegistry();
registry.setSignedExtensions(payload.signedExtensions, userExtensions);
registry.register(types);
const signatureExpected = registry
.createType('ExtrinsicPayload', payload, { version: payload.version }).sign(pair);
// eslint-disable-next-line jest/valid-expect-in-promise
tabs.handle('1615191860771.5', 'pub(extrinsic.sign)', payload, 'http://localhost:3000', {} as chrome.runtime.Port)
.then((result) => {
// eslint-disable-next-line jest/no-conditional-expect
expect((result as ResponseSigning)?.signature).toEqual(signatureExpected.signature);
}).catch((err) => console.log(err));
const res = await extension.handle('1615192062290.7', 'pri(signing.approve.password)', {
id: state.allSignRequests[0].id,
password,
savePass: false
}, {} as chrome.runtime.Port);
expect(res).toEqual(true);
expect(hexToU8a(signature).length).toEqual(sizes(Scheme.MlDsa65).signatureWithPublicKey);
});
});
@@ -482,18 +286,22 @@ describe('Extension', () => {
beforeEach(async () => {
address = await createAccount();
// A chain the extension has metadata for: these tests are about how a
// request is routed and displayed, and without it signing refuses before
// any of that is reached.
await state.saveMetadata(heisenbergMetadataDef());
payload = {
address,
blockHash: '0xe1b1dda72998846487e4d858909d4f9a6bbd6e338e4588e5d809de16b1317b80',
blockHash: HEISENBERG_GENESIS,
blockNumber: '0x00000393',
era: '0x3601',
genesisHash: '0x242a54b35e1aad38f37b884eddeb71f6f9931b02fac27bf52dfb62ef754e5e62',
method: '0x040105fa8eaf04151687736326c9fea17e25fc5287613693c912909cb226aa4794f26a4882380100',
nonce: '0x0000000000000000',
era: '0x00',
genesisHash: HEISENBERG_GENESIS,
method: '0x020300d43593c715fdd31c61141abd04a99fd6822c8558854ccde39a5684e7a56da27d02286bee',
nonce: '0x00000000',
signedExtensions: ['CheckSpecVersion', 'CheckTxVersion', 'CheckGenesis', 'CheckMortality', 'CheckNonce', 'CheckWeight', 'ChargeTransactionPayment'],
specVersion: '0x00000026',
specVersion: '0x00000094',
tip: '0x00000000000000000000000000000000',
transactionVersion: '0x00000005',
transactionVersion: '0x00000006',
version: 4
};
});
@@ -519,7 +327,7 @@ describe('Extension', () => {
} as unknown as SignerPayloadJSON, 'http://localhost:3000', {} as chrome.runtime.Port)
.catch((err) => console.log(err));
const queued = state.allSignRequests[state.allSignRequests.length - 1];
const queued = await nextSignRequest();
const res = await extension.handle('1615192062290.7', 'pri(signing.approve.password)', {
id: queued.id,
@@ -537,7 +345,7 @@ describe('Extension', () => {
tabs.handle('1615191860871.8', 'pub(extrinsic.sign)', payload, 'http://localhost:3000', {} as chrome.runtime.Port)
.catch((err) => console.log(err));
const queued = state.allSignRequests[state.allSignRequests.length - 1];
const queued = await nextSignRequest();
expect(queued.request.channel).toEqual('extrinsic');
@@ -550,4 +358,204 @@ describe('Extension', () => {
expect(res).toEqual(true);
});
});
describe('wallets', () => {
// Independent vectors only: none of these came from running this code.
// - the chain node's own test data (node/src/tests/data/quantus_key_test_data.rs),
// also pinned by the mobile wallet's SDK: TEST_MNEMONIC's wormhole receive 0
// - quantus-cli 2.2.2, via @quantus/crypto's conformance tests: DEV's accounts
// - crystal_alice, the ML-DSA-87 dev account for the all-zero seed
const NODE_PHRASE = 'orchard answer curve patient visual flower maze noise retreat penalty cage small earth domain scan pitch bottom crunch theme club client swap slice raven';
const NODE_WORMHOLE = '0xdfcfd6e59c75d208e84f54a887537bcf7b04265790ec79960bf49de123404d0e';
const DEV_PHRASE = 'bottom drive obey lake curtain smoke basket hold race lonely fit walk';
const DEV_65 = '0xf647dbdefebcfcf726ba078a83481ffc6f4f33004fdfb4cedacf5a5391bc8f00';
const DEV_87 = '0x11c6a314e003cdee3dc51cf6569175360141578d054c38d7a70840a65cc0e990';
const ZERO_SEED = `0x${'00'.repeat(32)}`;
const CRYSTAL_ALICE = '0x1883df2ae47d1fd428a6b8237ad7b59cf0facccaacac4541ef7758be44b3c333';
const port = {} as chrome.runtime.Port;
const id = (address: string) => u8aToHex(decodeAddress(address));
let wallets: WalletInfo[] = [];
beforeAll(async () => {
// the mock's remove() returns nothing, and forgetting awaits it
(chrome.storage.local.remove as unknown as { returns: (v: unknown) => void }).returns(Promise.resolve());
await extension.handle('wallets', 'pri(wallets.subscribe)', null, {
onDisconnect: { addListener: () => undefined },
postMessage: ({ subscription }: { subscription?: WalletInfo[] }) => {
subscription && (wallets = subscription);
}
} as unknown as chrome.runtime.Port);
});
const walletNamed = (name: string) => wallets.find((w) => w.name === name);
it('previews all three account-0 addresses of a recovery phrase', async () => {
const dev = await extension.handle('id', 'pri(wallets.preview)', { secret: DEV_PHRASE }, port);
const node = await extension.handle('id', 'pri(wallets.preview)', { secret: NODE_PHRASE }, port);
expect(id(dev.mldsa65)).toEqual(DEV_65);
expect(id(dev.mldsa87)).toEqual(DEV_87);
expect(node.wormhole && id(node.wormhole)).toEqual(NODE_WORMHOLE);
});
it('previews a raw seed with no wormhole account', async () => {
const preview = await extension.handle('id', 'pri(wallets.preview)', { secret: ZERO_SEED }, port);
expect(id(preview.mldsa87)).toEqual(CRYSTAL_ALICE);
expect(preview.wormhole).toBe(null);
});
it('refuses something that is neither a phrase nor a seed', async () => {
await expect(extension.handle('id', 'pri(wallets.preview)', { secret: 'bottom drive obey' }, port)).rejects.toThrow(/recovery phrase has/);
await expect(extension.handle('id', 'pri(wallets.preview)', { secret: '0x1234' }, port)).rejects.toThrow(/32 bytes/);
});
it('creates a wallet whose signing accounts are keyring pairs', async () => {
const walletId = await extension.handle('id', 'pri(wallets.create)', { name: 'dev', password, secret: ` ${DEV_PHRASE.replace(/ /g, ' ')} ` }, port);
const wallet = walletNamed('dev');
expect(wallet?.id).toEqual(walletId);
expect(wallet && 'secret' in wallet).toBe(false);
expect(wallet?.source).toEqual('mnemonic');
const [account] = wallet?.accounts ?? [];
expect(id(account.mldsa65)).toEqual(DEV_65);
expect(id(account.mldsa87)).toEqual(DEV_87);
expect(account.wormhole?.receive).toHaveLength(20);
expect(account.wormhole?.change).toHaveLength(20);
const pair65 = keyring.getPair(account.mldsa65);
const pair87 = keyring.getPair(account.mldsa87);
expect(pair65.type).toEqual('dilithium65');
expect(pair87.type).toEqual('dilithium87');
// the harness's expect has no objectContaining, so pick the fields
const tags = ({ meta: { accountIndex, name, walletId } }: KeyringPair) => ({ accountIndex, name, walletId });
expect(tags(pair65)).toEqual({ accountIndex: 0, name: 'dev (ML-DSA-65)', walletId });
expect(tags(pair87)).toEqual({ accountIndex: 0, name: 'dev (ML-DSA-87)', walletId });
});
it('refuses the same secret twice', async () => {
await expect(extension.handle('id', 'pri(wallets.create)', { name: 'again', password, secret: DEV_PHRASE }, port)).rejects.toThrow(/already the wallet "dev"/);
});
it('adds the next account index only with the right password', async () => {
const wallet = walletNamed('dev');
assert(wallet, 'wallet missing');
await expect(extension.handle('id', 'pri(wallets.addAccount)', { id: wallet.id, password: 'wrong' }, port)).rejects.toThrow(/password is wrong/);
await extension.handle('id', 'pri(wallets.addAccount)', { id: wallet.id, password }, port);
const accounts = walletNamed('dev')?.accounts ?? [];
expect(accounts.map((a) => a.index)).toEqual([0, 1]);
expect(accounts[1].mldsa65).not.toEqual(accounts[0].mldsa65);
expect(accounts[1].wormhole?.receive[0]).not.toEqual(accounts[0].wormhole?.receive[0]);
expect(keyring.getPair(accounts[1].mldsa87).meta.name).toEqual('dev #1 (ML-DSA-87)');
});
it('makes a raw seed wallet with one account and no wormhole', async () => {
const walletId = await extension.handle('id', 'pri(wallets.create)', { name: 'alice', password, secret: ZERO_SEED }, port);
const [account] = walletNamed('alice')?.accounts ?? [];
expect(id(account.mldsa87)).toEqual(CRYSTAL_ALICE);
expect(account.wormhole).toBe(null);
await expect(extension.handle('id', 'pri(wallets.addAccount)', { id: walletId, password }, port)).rejects.toThrow(/raw seed has one account/);
});
it('renames the wallet and the names its pairs carry to dapps', async () => {
const wallet = walletNamed('alice');
assert(wallet, 'wallet missing');
await extension.handle('id', 'pri(wallets.rename)', { id: wallet.id, name: 'crystal' }, port);
expect(walletNamed('crystal')).toBeDefined();
expect(keyring.getPair(wallet.accounts[0].mldsa65).meta.name).toEqual('crystal (ML-DSA-65)');
});
// the mock store: what was last written under a key, and what was removed
// sinon stubs: only their recorded calls are read, never invoked unbound
// eslint-disable-next-line @typescript-eslint/unbound-method
const setStub = () => chrome.storage.local.set as unknown as { args: [Record<string, unknown>][] };
// eslint-disable-next-line @typescript-eslint/unbound-method
const removeStub = () => chrome.storage.local.remove as unknown as { args: [string][] };
// Keys carry EXTENSION_PREFIX when one is set, as it is under `yarn test`.
const written = (key: string) => setStub().args
.flatMap(([v]) => Object.entries(v).filter(([k]) => k.endsWith(key)).map(([, value]) => value))
.pop() as WormholeNullifiersJson | undefined;
it('stores a nullifier window for every wormhole address when a phrase wallet is made', () => {
const wallet = walletNamed('dev');
assert(wallet, 'wallet missing');
for (const index of [0, 1]) {
const stored = written(`quantus:nullifiers:${wallet.id}:${index}`);
assert(stored, `no nullifiers for account ${index}`);
expect(stored.receive).toHaveLength(20);
expect(stored.change).toHaveLength(20);
expect(stored.receive.every((b) => base64Decode(b).length === NULLIFIER_WINDOW * 32)).toBe(true);
expect(stored.change.every((b) => base64Decode(b).length === NULLIFIER_WINDOW * 32)).toBe(true);
}
// the stored bytes are the derivation, address by address and count by count
const stored = written(`quantus:nullifiers:${wallet.id}:1`);
const direct = wormholeNullifiers(DEV_PHRASE, '', 1, WormholeBranch.Change, 3, 1, 100, 2)[0];
expect(u8aToHex(base64Decode(stored?.change[3] ?? '').subarray(100 * 32, 102 * 32))).toEqual(u8aToHex(u8aConcat(...direct)));
});
it('stores no nullifiers for a raw seed wallet, which has no wormhole', () => {
const wallet = walletNamed('crystal');
assert(wallet, 'wallet missing');
expect(written(`quantus:nullifiers:${wallet.id}:0`)).toBeUndefined();
});
it('extends only the addresses whose transfer count outgrew the window', () => {
const wallet = walletNamed('dev');
assert(wallet, 'wallet missing');
const [account] = wallet.accounts;
const stored = written(`quantus:nullifiers:${wallet.id}:0`) ?? null;
const receive = new Array<number>(20).fill(0);
receive[2] = 200;
const extended = extendNullifiers(DEV_PHRASE, account, stored, { change: new Array<number>(20).fill(5), receive });
assert(extended && stored, 'nothing extended');
expect(base64Decode(extended.receive[2]).length).toEqual((200 + 128) * 32);
expect(extended.receive[3]).toEqual(stored.receive[3]);
expect(extended.change).toEqual(stored.change);
// what was there is kept as it was, and what was added continues from it
expect(u8aToHex(base64Decode(extended.receive[2]).subarray(0, NULLIFIER_WINDOW * 32))).toEqual(u8aToHex(base64Decode(stored.receive[2])));
expect(u8aToHex(base64Decode(extended.receive[2]).subarray(300 * 32, 301 * 32))).toEqual(u8aToHex(wormholeNullifiers(DEV_PHRASE, '', 0, WormholeBranch.Receive, 2, 1, 300, 1)[0][0]));
});
it('forgets the wallet and every pair it owns', async () => {
const wallet = walletNamed('dev');
assert(wallet, 'wallet missing');
await extension.handle('id', 'pri(wallets.forget)', { id: wallet.id }, port);
expect(walletNamed('dev')).toBeUndefined();
// and the nullifiers of every account index with it
const removed = (key: string) => removeStub().args.some(([k]) => k.endsWith(key));
expect(removed(`quantus:nullifiers:${wallet.id}:0`)).toBe(true);
expect(removed(`quantus:nullifiers:${wallet.id}:1`)).toBe(true);
expect(removed(`quantus:wallet:${wallet.id}`)).toBe(true);
for (const account of wallet.accounts) {
expect(() => keyring.getPair(account.mldsa65)).toThrow();
expect(() => keyring.getPair(account.mldsa87)).toThrow();
}
});
});
});
@@ -3,24 +3,24 @@
/* global chrome */
import type { Runtime } from '@quantus/codec';
import type { MetadataDef } from '@polkadot/extension-inject/types';
import type { KeyringPair, KeyringPair$Json, KeyringPair$Meta } from '@polkadot/keyring/types';
import type { Registry } from '@polkadot/types/types';
import type { KeyringPair, KeyringPair$Json } from '@polkadot/keyring/types';
import type { SubjectInfo } from '@polkadot/ui-keyring/observable/types';
import type { KeypairType } from '@polkadot/util-crypto/types';
import type { AccountJson, AllowedPath, AuthorizeRequest, MessageTypes, MetadataRequest, RequestAccountBatchExport, RequestAccountChangePassword, RequestAccountCreateExternal, RequestAccountCreateSuri, RequestAccountEdit, RequestAccountExport, RequestAccountForget, RequestAccountShow, RequestAccountTie, RequestAccountValidate, RequestActiveTabsUrlUpdate, RequestAuthorizeApprove, RequestBatchRestore, RequestDeriveCreate, RequestDeriveValidate, RequestJsonRestore, RequestMetadataApprove, RequestMetadataReject, RequestSeedCreate, RequestSeedValidate, RequestSigningApprovePassword, RequestSigningApproveSignature, RequestSigningCancel, RequestSigningIsLocked, RequestTypes, RequestUpdateAuthorizedAccounts, ResponseAccountExport, ResponseAccountsExport, ResponseAuthorizeList, ResponseDeriveValidate, ResponseJsonGetAccountInfo, ResponseSeedCreate, ResponseSeedValidate, ResponseSigningIsLocked, ResponseType, SigningRequest } from '../types.js';
import type { AccountJson, AllowedPath, AuthorizeRequest, MessageTypes, MetadataRequest, RequestAccountBatchExport, RequestAccountChangePassword, RequestAccountCreateExternal, RequestAccountCreateSuri, RequestAccountEdit, RequestAccountExport, RequestAccountForget, RequestAccountShow, RequestAccountTie, RequestAccountValidate, RequestActiveTabsUrlUpdate, RequestAuthorizeApprove, RequestBalancesSubscribe, RequestBalancesUnsubscribe, RequestBatchRestore, RequestJsonRestore, RequestMetadataApprove, RequestMetadataReject, RequestSeedCreate, RequestSeedValidate, RequestSigningApprovePassword, RequestSigningApproveSignature, RequestSigningCancel, RequestSigningIsLocked, RequestTypes, RequestUpdateAuthorizedAccounts, RequestWalletAddAccount, RequestWalletCreate, RequestWalletForget, RequestWalletPreview, RequestWalletRename, RequestWormholeBalance, RequestWormholeUnlock, ResponseAccountExport, ResponseAccountsExport, ResponseAuthorizeList, ResponseJsonGetAccountInfo, ResponseSeedCreate, ResponseSeedValidate, ResponseSigningIsLocked, ResponseType, ResponseWalletPreview, SigningRequest, WalletInfo, WormholeBalance } from '../types.js';
import type { AuthorizedAccountsDiff } from './State.js';
import type State from './State.js';
import { ALLOWED_PATH, PASSWORD_EXPIRY_MS } from '@polkadot/extension-base/defaults';
import { metadataExpand } from '@polkadot/extension-chains';
import { TypeRegistry } from '@polkadot/types';
import { keyring } from '@polkadot/ui-keyring';
import { accounts as accountsObservable } from '@polkadot/ui-keyring/observable/accounts';
import { assert, isHex } from '@polkadot/util';
import { keyExtractSuri, mnemonicGenerate, mnemonicValidate } from '@polkadot/util-crypto';
import { isExtrinsicRequest } from '../../utils/index.js';
import Balances from '../Balances.js';
import Wallets from '../Wallets.js';
import { withErrorLog } from './helpers.js';
import { createSubscription, unsubscribe } from './subscriptions.js';
@@ -28,15 +28,12 @@ type CachedUnlocks = Record<string, number>;
const SEED_DEFAULT_LENGTH = 12;
const SEED_LENGTHS = [12, 15, 18, 21, 24];
const ETH_DERIVE_DEFAULT = "/m/44'/60'/0'/0/0";
function getSuri (seed: string, type?: KeypairType): string {
return type === 'ethereum'
? `${seed}${ETH_DERIVE_DEFAULT}`
: seed;
}
export default class Extension {
readonly #balances = new Balances();
readonly #wallets = new Wallets();
// Ends each balance subscription, by id; each runs once however it is reached.
readonly #balanceSubs = new Map<string, VoidFunction>();
readonly #cachedUnlocks: CachedUnlocks;
readonly #state: State;
@@ -62,7 +59,7 @@ export default class Extension {
}
private accountsCreateSuri ({ genesisHash, name, password, suri, type }: RequestAccountCreateSuri): boolean {
keyring.addUri(getSuri(suri, type), password, { genesisHash, name }, type);
keyring.addUri(suri, password, { genesisHash, name }, type);
return true;
}
@@ -191,6 +188,109 @@ export default class Extension {
return true;
}
/**
* Balances for every account the keyring holds, refreshed as accounts change.
*
* The endpoint arrives with the request because `@polkadot/ui-settings`
* persists to `localStorage`, which an MV3 service worker does not have — the
* setting lives in one place and travels here.
*
* Nothing connects until this is called, and the connection closes with the
* last subscriber. Asking a node for balances tells that node which accounts
* belong to one person, so holding a socket open for the life of the browser
* would report far more than the feature needs.
*/
private walletsSubscribe (id: string, port: chrome.runtime.Port): boolean {
const cb = createSubscription<'pri(wallets.subscribe)'>(id, port);
const subscription = this.#wallets.subject.subscribe((wallets: WalletInfo[]): void => cb(wallets));
port.onDisconnect.addListener((): void => {
unsubscribe(id);
subscription.unsubscribe();
});
return true;
}
private walletsPreview ({ secret }: RequestWalletPreview): ResponseWalletPreview {
return this.#wallets.preview(secret);
}
private walletsCreate (request: RequestWalletCreate): Promise<string> {
return this.#wallets.create(request);
}
private async walletsAddAccount ({ id, password }: RequestWalletAddAccount): Promise<boolean> {
await this.#wallets.addAccount(id, password);
return true;
}
private async walletsRename ({ id, name }: RequestWalletRename): Promise<boolean> {
await this.#wallets.rename(id, name);
return true;
}
private walletsWormholeBalance ({ accountIndex, endpoint, id, observer }: RequestWormholeBalance): Promise<WormholeBalance> {
return this.#wallets.wormholeBalance(id, accountIndex, endpoint, observer);
}
private async walletsWormholeUnlock ({ accountIndex, counts, id, password }: RequestWormholeUnlock): Promise<boolean> {
await this.#wallets.wormholeUnlock(id, accountIndex, password, counts);
return true;
}
private async walletsForget ({ id }: RequestWalletForget): Promise<boolean> {
await this.#wallets.forget(id);
return true;
}
private balancesSubscribe ({ endpoint }: RequestBalancesSubscribe, id: string, port: chrome.runtime.Port): string {
const cb = createSubscription<'pri(balances.subscribe)'>(id, port);
this.#balances.retain();
const update = (accounts: SubjectInfo): void => {
// Deliberately not awaited, and never rethrown: an unreachable endpoint
// must leave the account list working without balances rather than break
// the popup. The subject simply keeps its last value.
this.#balances
.update(endpoint, Object.keys(accounts))
.catch((error: Error) => console.error(`Unable to read balances from ${endpoint}: ${error.message}`));
};
const balances = this.#balances.subject.subscribe(cb);
const accounts = accountsObservable.subject.subscribe(update);
const end = (): void => {
if (this.#balanceSubs.delete(id)) {
unsubscribe(id);
balances.unsubscribe();
accounts.unsubscribe();
this.#balances.release();
}
};
this.#balanceSubs.set(id, end);
port.onDisconnect.addListener(end);
return id;
}
/**
* End a balance subscription while the page that made it stays open, as it
* does when the user switches endpoint. Left to the port disconnecting, the
* old endpoint's subscription would live on beside the new one.
*/
private balancesUnsubscribe ({ id }: RequestBalancesUnsubscribe): boolean {
this.#balanceSubs.get(id)?.();
return true;
}
private authorizeApprove ({ authorizedAccounts, id }: RequestAuthorizeApprove): boolean {
const queued = this.#state.getAuthRequest(id);
@@ -310,7 +410,7 @@ export default class Extension {
const seed = _seed || mnemonicGenerate(length);
return {
address: keyring.createFromUri(getSuri(seed, type), {}, type).address,
address: keyring.createFromUri(seed, {}, type).address,
seed
};
}
@@ -327,7 +427,7 @@ export default class Extension {
}
return {
address: keyring.createFromUri(getSuri(suri, type), {}, type).address,
address: keyring.createFromUri(suri, {}, type).address,
suri
};
}
@@ -357,32 +457,38 @@ export default class Extension {
pair.decodePkcs8(password);
}
// construct a new registry (avoiding pollution), between requests
let registry: Registry;
// The runtime's own description of the chain being signed for, from the
// metadata a dapp provided. `null` when none is known, which
// RequestExtrinsicSign turns into a refusal rather than a guess — without it
// there is no way to know what this chain's signed extensions contribute to
// the payload, and a signature over the wrong bytes comes back from the node
// as `BadProof`, indistinguishable from a wrong key.
//
// Raw-bytes signing needs none of this and ignores the argument.
let runtime: Runtime | null = null;
if (isExtrinsicRequest(request)) {
const payload = request.payload;
// Get the metadata for the genesisHash
const metadata = this.#state.knownMetadata.find(({ genesisHash }) => genesisHash === payload.genesisHash);
const metadata = this.#state.knownMetadata.find(({ genesisHash }) => genesisHash === request.payload.genesisHash);
if (metadata) {
// we have metadata, expand it and extract the info/registry
const expanded = metadataExpand(metadata, false);
registry = expanded.registry;
registry.setSignedExtensions(payload.signedExtensions, expanded.definition.userExtensions);
} else {
// we have no metadata, create a new registry
registry = new TypeRegistry();
registry.setSignedExtensions(payload.signedExtensions);
runtime = metadataExpand(metadata, false).runtime;
}
} else {
// for non-payload, just create a registry to use
registry = new TypeRegistry();
}
const result = request.sign(registry, pair);
let result;
try {
result = request.sign(runtime, pair);
} catch (error) {
// Tell the dapp, not just the popup. Signing can now refuse outright — a
// chain whose metadata this extension does not have is one it will not
// guess a payload for — and an unanswered `signPayload` leaves the page
// waiting forever with nothing on screen to explain it.
pair.lock();
reject(error as Error);
throw error;
}
if (savePass) {
// unlike queued.account.address the following
@@ -472,44 +578,6 @@ export default class Extension {
return true;
}
private derive (parentAddress: string, suri: string, password: string, metadata: KeyringPair$Meta): KeyringPair {
const parentPair = keyring.getPair(parentAddress);
try {
parentPair.decodePkcs8(password);
} catch {
throw new Error('invalid password');
}
try {
return parentPair.derive(suri, metadata);
} catch {
throw new Error(`"${suri}" is not a valid derivation path`);
}
}
private derivationValidate ({ parentAddress, parentPassword, suri }: RequestDeriveValidate): ResponseDeriveValidate {
const childPair = this.derive(parentAddress, suri, parentPassword, {});
return {
address: childPair.address,
suri
};
}
private derivationCreate ({ genesisHash, name, parentAddress, parentPassword, password, suri }: RequestDeriveCreate): boolean {
const childPair = this.derive(parentAddress, suri, parentPassword, {
genesisHash,
name,
parentAddress,
suri
});
keyring.addPair(childPair, password);
return true;
}
private async removeAuthorization (url: string): Promise<ResponseAuthorizeList> {
const remAuth = await this.#state.removeAuthorization(url);
@@ -600,6 +668,36 @@ export default class Extension {
case 'pri(accounts.subscribe)':
return port && this.accountsSubscribe(id, port);
case 'pri(balances.subscribe)':
return port && this.balancesSubscribe(request as RequestBalancesSubscribe, id, port);
case 'pri(wallets.addAccount)':
return this.walletsAddAccount(request as RequestWalletAddAccount);
case 'pri(wallets.create)':
return this.walletsCreate(request as RequestWalletCreate);
case 'pri(wallets.forget)':
return this.walletsForget(request as RequestWalletForget);
case 'pri(wallets.preview)':
return this.walletsPreview(request as RequestWalletPreview);
case 'pri(wallets.rename)':
return this.walletsRename(request as RequestWalletRename);
case 'pri(wallets.wormholeBalance)':
return this.walletsWormholeBalance(request as RequestWormholeBalance);
case 'pri(wallets.wormholeUnlock)':
return this.walletsWormholeUnlock(request as RequestWormholeUnlock);
case 'pri(wallets.subscribe)':
return port && this.walletsSubscribe(id, port);
case 'pri(balances.unsubscribe)':
return this.balancesUnsubscribe(request as RequestBalancesUnsubscribe);
case 'pri(accounts.tie)':
return this.accountsTie(request as RequestAccountTie);
@@ -627,12 +725,6 @@ export default class Extension {
case 'pri(connectedTabsUrl.get)':
return this.getConnectedTabsUrl();
case 'pri(derivation.create)':
return this.derivationCreate(request as RequestDeriveCreate);
case 'pri(derivation.validate)':
return this.derivationValidate(request as RequestDeriveValidate);
case 'pri(json.restore)':
return this.jsonRestore(request as RequestJsonRestore);
@@ -16,6 +16,7 @@ import { assert } from '@polkadot/util';
import { MetadataStore } from '../../stores/index.js';
import { getId } from '../../utils/getId.js';
import ChainMetadata from '../ChainMetadata.js';
import { withErrorLog } from './helpers.js';
interface Resolver<T> {
@@ -135,6 +136,7 @@ export default class State {
readonly #authRequests: Record<string, AuthRequest> = {};
readonly #metaStore = new MetadataStore();
readonly #chainMetadata = new ChainMetadata();
// Map of providers currently injected in tabs
readonly #injectedProviders = new Map<chrome.runtime.Port, ProviderInterface>();
@@ -609,6 +611,44 @@ export default class State {
return provider.unsubscribe(request.type, request.method, request.subscriptionId);
}
/**
* Make sure this extension can describe a chain before it is asked to sign for
* it, fetching the metadata from a node if nothing is known or what is known
* is for an older runtime.
*
* Silent on failure. A chain we have no endpoint for is a chain the signer
* will refuse, with a message that says so — there is nothing useful to say
* here that is not said better there.
*/
public async ensureMetadata (genesisHash: string, specVersion: number): Promise<void> {
// Already able to describe the exact runtime this payload is for. The spec
// version is the test rather than 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 — worse
// than not decoding them at all.
//
// This is also what keeps a signature request off the network in the common
// case, where the chain has not upgraded since the last one.
const known = this.knownMetadata.find((m) => m.genesisHash === genesisHash);
if (known?.rawMetadata && known.specVersion === specVersion) {
return;
}
try {
const fetched = await this.#chainMetadata.fetch(genesisHash);
if (fetched) {
await this.saveMetadata(fetched);
}
} catch (error) {
// Not fatal and not reported here. A chain we cannot reach is a chain the
// signer refuses, with a message that says so; there is nothing useful to
// add at this point that is not said better there.
console.error(`Unable to fetch metadata for ${genesisHash}: ${(error as Error).message}`);
}
}
public async saveMetadata (meta: MetadataDef): Promise<void> {
await this.#metaStore.set(meta.genesisHash, meta);
@@ -17,10 +17,10 @@ import { combineLatest, type Subscription } from 'rxjs';
import { checkIfDenied } from '@polkadot/phishing';
import { keyring } from '@polkadot/ui-keyring';
import { accounts as accountsObservable } from '@polkadot/ui-keyring/observable/accounts';
import { assert, isNumber } from '@polkadot/util';
import { assert, hexToNumber, isNumber } from '@polkadot/util';
import { PHISHING_PAGE_REDIRECT } from '../../defaults.js';
import { canDerive } from '../../utils/index.js';
import { canInject } from '../../utils/index.js';
import RequestBytesSign from '../RequestBytesSign.js';
import RequestExtrinsicSign from '../RequestExtrinsicSign.js';
import { withErrorLog } from './helpers.js';
@@ -35,7 +35,7 @@ function transformAccounts (accounts: SubjectInfo, anyType = false): InjectedAcc
return Object
.values(accounts)
.filter(({ json: { meta: { isHidden } } }) => !isHidden)
.filter(({ type }) => anyType ? true : canDerive(type))
.filter(({ type }) => anyType ? true : canInject(type))
.sort((a, b) => (a.json.meta.whenCreated || 0) - (b.json.meta.whenCreated || 0))
.map(({ json: { address, meta: { genesisHash, name } }, type }): InjectedAccount => ({
address,
@@ -140,7 +140,7 @@ export default class Tabs {
return this.#state.sign(url, new RequestBytesSign(request), { address, ...pair.meta });
}
private extrinsicSign (url: string, request: SignerPayloadJSON): Promise<ResponseSigning> {
private async extrinsicSign (url: string, request: SignerPayloadJSON): Promise<ResponseSigning> {
// matches the predicate the UI used to key off, so payloads that were
// never ambiguous (absent, null, empty) keep working
assert(!(request as unknown as SignerPayloadRaw).data, 'Unexpected raw data in a signPayload payload');
@@ -148,6 +148,15 @@ export default class Tabs {
const address = request.address;
const pair = this.getSigningPair(address);
// Before the popup opens, not after approval: the approval screen decodes
// the call with this metadata, and a user asked to approve a transaction
// rendered as raw hex has been given nothing to approve.
//
// Fetched from a node rather than taken from the dapp. The dapp already
// controls the transaction; letting it also supply the description would let
// it show one thing and have the user sign another. See ChainMetadata.
await this.#state.ensureMetadata(request.genesisHash, hexToNumber(request.specVersion));
return this.#state.sign(url, new RequestExtrinsicSign(request), { address, ...pair.meta });
}
+149 -26
View File
@@ -3,13 +3,14 @@
/* eslint-disable no-use-before-define */
import type { Runtime } from '@quantus/codec';
import type { InjectedAccount, InjectedMetadataKnown, MetadataDef, ProviderList, ProviderMeta } from '@polkadot/extension-inject/types';
import type { KeyringPair, KeyringPair$Json, KeyringPair$Meta } from '@polkadot/keyring/types';
import type { JsonRpcResponse } from '@polkadot/rpc-provider/types';
import type { Registry, SignerPayloadJSON, SignerPayloadRaw } from '@polkadot/types/types';
import type { SignerPayloadJSON, SignerPayloadRaw } from '@polkadot/types/types';
import type { KeyringPairs$Json } from '@polkadot/ui-keyring/types';
import type { HexString } from '@polkadot/util/types';
import type { KeypairType } from '@polkadot/util-crypto/types';
import type { EncryptedJson, KeypairType } from '@polkadot/util-crypto/types';
import type { ALLOWED_PATH } from '../defaults.js';
import type { AuthResponse } from './handlers/State.js';
@@ -50,7 +51,6 @@ export type AccountWithChildren = AccountJson & {
export interface AccountsContext {
accounts: AccountJson[];
hierarchy: AccountWithChildren[];
master?: AccountJson;
selectedAccounts?: AccountJson['address'][];
setSelectedAccounts?: (address: AccountJson['address'][]) => void;
}
@@ -89,7 +89,17 @@ export interface RequestSignatures {
'pri(accounts.show)': [RequestAccountShow, boolean];
'pri(accounts.tie)': [RequestAccountTie, boolean];
'pri(accounts.subscribe)': [RequestAccountSubscribe, boolean, AccountJson[]];
'pri(balances.subscribe)': [RequestBalancesSubscribe, string, AccountBalances];
'pri(balances.unsubscribe)': [RequestBalancesUnsubscribe, boolean];
'pri(accounts.validate)': [RequestAccountValidate, boolean];
'pri(wallets.addAccount)': [RequestWalletAddAccount, boolean];
'pri(wallets.create)': [RequestWalletCreate, string];
'pri(wallets.forget)': [RequestWalletForget, boolean];
'pri(wallets.preview)': [RequestWalletPreview, ResponseWalletPreview];
'pri(wallets.rename)': [RequestWalletRename, boolean];
'pri(wallets.subscribe)': [null, boolean, WalletInfo[]];
'pri(wallets.wormholeBalance)': [RequestWormholeBalance, WormholeBalance];
'pri(wallets.wormholeUnlock)': [RequestWormholeUnlock, boolean];
'pri(accounts.changePassword)': [RequestAccountChangePassword, boolean];
'pri(authorize.approve)': [RequestAuthorizeApprove, boolean];
'pri(authorize.list)': [null, ResponseAuthorizeList];
@@ -100,8 +110,6 @@ export interface RequestSignatures {
'pri(authorize.update)': [RequestUpdateAuthorizedAccounts, void];
'pri(activeTabsUrl.update)': [RequestActiveTabsUrlUpdate, void];
'pri(connectedTabsUrl.get)': [null, ConnectedTabsUrlResponse];
'pri(derivation.create)': [RequestDeriveCreate, boolean];
'pri(derivation.validate)': [RequestDeriveValidate, ResponseDeriveValidate];
'pri(json.restore)': [RequestJsonRestore, void];
'pri(json.batchRestore)': [RequestBatchRestore, void];
'pri(json.account.info)': [KeyringPair$Json, ResponseJsonGetAccountInfo];
@@ -196,6 +204,113 @@ export interface RequestAccountCreateSuri {
type?: KeypairType;
}
/** The accounts one account index of a wallet unlocks, by address. */
export interface WalletAccount {
index: number;
mldsa65: string;
mldsa87: string;
/**
* Wormhole addresses, receive and change branches, from index 0. Null for a
* wallet made from a raw seed: wormhole derivation starts from the 64-byte
* BIP39 seed, which a raw 32-byte seed does not have.
*/
wormhole: { change: string[], receive: string[] } | null;
}
/** A wallet as the UI sees it: everything but the secret. */
export interface WalletInfo {
accounts: WalletAccount[];
genesisHash: HexString | null;
id: string;
name: string;
source: 'mnemonic' | 'seed';
whenCreated: number;
}
/** A wallet as stored: the secret, encrypted with the wallet password. */
export interface WalletJson extends WalletInfo {
secret: EncryptedJson;
}
/**
* Nullifiers for one wallet account's wormhole addresses: per branch, per
* address index, the 32-byte nullifiers for transfer counts 0.. packed and
* base64-encoded.
*/
export interface WormholeNullifiersJson {
change: string[];
receive: string[];
}
/** What a wallet account's wormhole addresses can still spend, and how sure that is. */
export interface WormholeBalance {
checkedAt: number;
/** The chain's transfer count for each address, per branch. */
counts: { change: number[], receive: number[] };
decimals: number;
/** Deposits the observer returned. */
deposits: number;
indexedFrom: number | null;
indexedTo: number | null;
/** Transfers the chain counts that the observer did not return. */
missing: number;
/** Planck, unspent. */
spendable: string;
/** Planck, already exited. */
spent: string;
symbol: string;
/** The chain's total transfer count across the addresses. */
transfers: number;
/** Planck in deposits whose nullifier was never precomputed: unlock to check. */
unchecked: string;
uncheckedDeposits: number;
}
export interface RequestWormholeBalance {
accountIndex: number;
/** The node to read chain state from: the balance endpoint. */
endpoint: string;
id: string;
/** Base URL of a blackbeard observer to find deposits with. */
observer: string;
}
export interface RequestWormholeUnlock {
accountIndex: number;
/** From the last balance: derive nullifiers past these. */
counts: WormholeBalance['counts'];
id: string;
password: string;
}
export interface RequestWalletCreate {
genesisHash?: HexString | null;
name: string;
password: string;
/** A BIP39 recovery phrase, or a 0x-prefixed 32-byte seed. */
secret: string;
}
export interface RequestWalletPreview {
secret: string;
}
export type ResponseWalletPreview = Omit<WalletAccount, 'wormhole'> & { wormhole: string | null };
export interface RequestWalletAddAccount {
id: string;
password: string;
}
export interface RequestWalletForget {
id: string;
}
export interface RequestWalletRename {
id: string;
name: string;
}
export interface RequestAccountChangePassword {
address: string;
oldPass: string;
@@ -227,21 +342,6 @@ export interface RequestAccountValidate {
password: string;
}
export interface RequestDeriveCreate {
name: string;
genesisHash?: HexString | null;
suri: string;
parentAddress: string;
parentPassword: string;
password: string;
}
export interface RequestDeriveValidate {
suri: string;
parentAddress: string;
parentPassword: string;
}
export interface RequestAccountExport {
address: string;
password: string;
@@ -297,6 +397,34 @@ export interface RequestSigningCancel {
id: string;
}
/**
* The endpoint to read balances from.
*
* Supplied by the popup rather than read here: `@polkadot/ui-settings` persists
* to `localStorage`, which an MV3 service worker does not have. The setting lives
* in one place and travels with the request.
*/
export interface RequestBalancesSubscribe {
endpoint: string;
}
/** The id `pri(balances.subscribe)` answered with. */
export interface RequestBalancesUnsubscribe {
id: string;
}
export interface AccountBalance {
/** The chain's decimal places, for formatting. */
decimals: number;
/** Free balance, as a decimal string — 12 decimals puts ordinary amounts past 2^53. */
free: string;
/** The chain's token symbol: QTC on mainnet, HEI on Heisenberg. */
symbol: string;
}
/** Balances by address. An address absent here has not been read yet. */
export type AccountBalances = Record<string, AccountBalance>;
export interface RequestSigningIsLocked {
id: string;
}
@@ -353,11 +481,6 @@ export interface ResponseSigning {
signedTransaction?: HexString;
}
export interface ResponseDeriveValidate {
address: string;
suri: string;
}
export interface ResponseSeedCreate {
address: string;
seed: string;
@@ -388,7 +511,7 @@ export type MessageTypesWithSubscriptions = keyof SubscriptionMessageTypes;
export type MessageTypesWithNoSubscriptions = Exclude<MessageTypes, keyof SubscriptionMessageTypes>
interface RequestSignBase {
sign (registry: Registry, pair: KeyringPair): { signature: HexString };
sign (runtime: Runtime | null, pair: KeyringPair): { signature: HexString };
}
// `channel` is set by the background from the message type the request arrived
+50
View File
@@ -26,3 +26,53 @@ export const PASSWORD_EXPIRY_MS = PASSWORD_EXPIRY_MIN * 60 * 1000;
export const PHISHING_PAGE_REDIRECT = '/phishing-page-detected';
// console.log(`Extension is sending and receiving messages on ${PORT_PREFIX}-*`);
/**
* Quantus endpoints this extension knows about, for the balance setting.
*
* Offered as a convenience, not as an authority. Asking a node for balances
* tells that node which accounts belong to one person, so anyone who doubts a
* default should point the setting at their own node — which is the reason the
* field is free text and this list is only a starting point.
*
* Verified reachable on 2026-09-15.
*/
/**
* The chains this extension can fetch metadata for, by genesis hash.
*
* The genesis hash is the identity: a sign request names one, and the endpoint
* that answers is only believed if `chain_getBlockHash(0)` matches it. Without
* that check this table would be a list of hosts we take on trust to be the
* chain they claim, which is the same mistake as trusting a dapp's metadata.
*
* Verified reachable, and their genesis hashes read from the nodes, on
* 2026-09-16.
*/
export const QUANTUS_CHAINS = [
{
endpoints: ['wss://rpc1-mainnet.quantus.com', 'wss://rpc2-mainnet.quantus.com'],
genesisHash: '0xfb5487c0be6ae4ade2d41d16e50465129861636c2b8d61fa94d7a19631626fba',
name: 'Quantus'
},
{
endpoints: ['wss://a1-heisenberg.quantus.cat', 'wss://a2-heisenberg.quantus.cat'],
genesisHash: '0xa5aa9e5c84d4a3722c152295e7973c9af522f2fb1ef7db5afaa3d5f4dc8d3b4f',
name: 'Quantus Heisenberg'
},
{
endpoints: ['wss://a1-planck.quantus.cat', 'wss://a2-planck.quantus.cat'],
genesisHash: '0x4901bf5c57fd3f9e726af399c763de6670dbdb115a91c0237e173f16eef65e72',
name: 'Quantus Planck'
}
] as const;
export const QUANTUS_ENDPOINTS = [
{ text: 'Quantus', value: 'wss://rpc1-mainnet.quantus.com' },
{ text: 'Heisenberg (testnet)', value: 'wss://a1-heisenberg.quantus.cat' },
{ text: 'Heisenberg (testnet, a2)', value: 'wss://a2-heisenberg.quantus.cat' },
{ text: 'Planck (testnet)', value: 'wss://a1-planck.quantus.cat' },
{ text: 'Planck (testnet, a2)', value: 'wss://a2-planck.quantus.cat' }
];
/** Mainnet, so the account list says something true out of the box. */
export const DEFAULT_ENDPOINT = QUANTUS_ENDPOINTS[0].value;
@@ -3,16 +3,14 @@
import type { KeyringJson, KeyringStore } from '@polkadot/ui-keyring/types';
import { EXTENSION_PREFIX } from '../defaults.js';
import BaseStore from './Base.js';
export default class AccountsStore extends BaseStore<KeyringJson> implements KeyringStore {
constructor () {
super(
EXTENSION_PREFIX && EXTENSION_PREFIX !== 'polkadot{.js}'
? `${EXTENSION_PREFIX}accounts`
: null
);
// Fixed, not derived from EXTENSION_PREFIX. chrome.storage is already per
// extension, and deriving the key from the name meant renaming the
// extension (quantus/extension#15) would orphan everything stored.
super(null);
}
public override async set (key: string, value: KeyringJson, update?: () => void): Promise<void> {
@@ -3,15 +3,13 @@
import type { MetadataDef } from '@polkadot/extension-inject/types';
import { EXTENSION_PREFIX } from '../defaults.js';
import BaseStore from './Base.js';
export default class MetadataStore extends BaseStore<MetadataDef> {
constructor () {
super(
EXTENSION_PREFIX && EXTENSION_PREFIX !== 'polkadot{.js}'
? `${EXTENSION_PREFIX}metadata`
: 'metadata'
);
// Fixed, not derived from EXTENSION_PREFIX. chrome.storage is already per
// extension, and deriving the key from the name meant renaming the
// extension (quantus/extension#15) would orphan everything stored.
super('metadata');
}
}
@@ -0,0 +1,22 @@
// Copyright 2019-2026 @polkadot/extension-base authors & contributors
// SPDX-License-Identifier: Apache-2.0
import type { WormholeNullifiersJson } from '../background/types.js';
import BaseStore from './Base.js';
/**
* Precomputed wormhole nullifiers, per wallet and account index.
*
* Kept apart from the wallet record because they are large (32 bytes per
* address per transfer count) and grow, and because they are the one thing
* here that could name a user's exits: a wallet's forget removes them with it.
*/
export default class NullifiersStore extends BaseStore<WormholeNullifiersJson> {
constructor () {
// Fixed, not derived from EXTENSION_PREFIX. chrome.storage is already per
// extension, and deriving the key from the name meant renaming the
// extension (quantus/extension#15) would orphan everything stored.
super('quantus:nullifiers');
}
}
@@ -0,0 +1,22 @@
// Copyright 2019-2026 @polkadot/extension-base authors & contributors
// SPDX-License-Identifier: Apache-2.0
import type { WalletJson } from '../background/types.js';
import BaseStore from './Base.js';
/**
* Wallets: one secret each, and the public addresses it unlocks.
*
* Its own prefix rather than the keyring's `account:` space. A wallet is not a
* keyring entry, and the ML-DSA pairs it owns are stored there as they always
* were, so signing and dapp injection never need to know wallets exist.
*/
export default class WalletsStore extends BaseStore<WalletJson> {
constructor () {
// Fixed, not derived from EXTENSION_PREFIX. chrome.storage is already per
// extension, and deriving the key from the name meant renaming the
// extension (quantus/extension#15) would orphan everything stored.
super('quantus:wallet');
}
}
@@ -3,3 +3,5 @@
export { default as AccountsStore } from './Accounts.js';
export { default as MetadataStore } from './Metadata.js';
export { default as NullifiersStore } from './Nullifiers.js';
export { default as WalletsStore } from './Wallets.js';
File diff suppressed because one or more lines are too long
@@ -0,0 +1,52 @@
// Copyright 2019-2026 @polkadot/extension-base authors & contributors
// SPDX-License-Identifier: Apache-2.0
import type { MetadataDef } from '@polkadot/extension-inject/types';
import type { HexString } from '@polkadot/util/types';
import { Runtime } from '@quantus/codec';
import { readFileSync } from 'node:fs';
import { dirname, join } from 'node:path';
import { fileURLToPath } from 'node:url';
import { hexToU8a } from '@polkadot/util';
/**
* Metadata captured from Heisenberg at spec 148 — the runtime quantus/extension#7
* tier 1 submits to.
*
* A real blob, not a hand-built one. The whole argument for @quantus/codec is
* that a runtime describes itself and a test that describes it instead proves
* only that the test and the code agree. This one carries the two extensions no
* Substrate-shaped decoder knows about — ReversibleTransactionExtension and
* WormholeProofRecorderExtension — which is exactly the case worth covering.
*/
export const HEISENBERG_GENESIS = '0xa5aa9e5c84d4a3722c152295e7973c9af522f2fb1ef7db5afaa3d5f4dc8d3b4f' as HexString;
export const HEISENBERG_SPEC = 148;
const raw = readFileSync(
join(dirname(fileURLToPath(import.meta.url)), 'heisenberg-v148.metadata.hex'),
'utf-8'
).trim();
export const heisenbergRawMetadata: HexString = `0x${raw}`;
export function heisenbergRuntime (): Runtime {
return Runtime.fromMetadata(hexToU8a(heisenbergRawMetadata));
}
/** The metadata a dapp would provide, as the extension stores it. */
export function heisenbergMetadataDef (genesisHash: HexString = HEISENBERG_GENESIS): MetadataDef {
return {
chain: 'Quantus Heisenberg',
genesisHash,
icon: 'substrate',
rawMetadata: heisenbergRawMetadata,
specVersion: HEISENBERG_SPEC,
ss58Format: 189,
tokenDecimals: 12,
tokenSymbol: 'HEI',
types: {}
};
}
+15 -2
View File
@@ -3,6 +3,19 @@
import type { KeypairType } from '@polkadot/util-crypto/types';
export function canDerive (type?: KeypairType): boolean {
return !!type && ['ed25519', 'sr25519', 'ecdsa', 'ethereum'].includes(type);
/** Every keypair type this extension can hold and sign with. */
const SIGNABLE: KeypairType[] = ['dilithium65', 'dilithium87'];
/**
* Whether an account of this type should be offered to dapps.
*
* Upstream used `canDerive` for this, which worked only because every type it
* knew about was derivable — so the check was really "is the type known". That
* coincidence ends with ML-DSA: lattice keys are not derivable from one another,
* and using `canDerive` here would have hidden **every Quantus account from every
* dapp**, leaving an extension that looked empty to the whole ecosystem while
* holding perfectly good accounts.
*/
export function canInject (type?: KeypairType): boolean {
return !!type && SIGNABLE.includes(type);
}
+1 -1
View File
@@ -1,5 +1,5 @@
// Copyright 2019-2026 @polkadot/extension-base authors & contributors
// SPDX-License-Identifier: Apache-2.0
export { canDerive } from './canDerive.js';
export { canInject } from './canDerive.js';
export { isExtrinsicRequest } from './isExtrinsicRequest.js';
@@ -0,0 +1,152 @@
// Copyright 2019-2026 @polkadot/extension-base authors & contributors
// SPDX-License-Identifier: Apache-2.0
/* global chrome */
import '@polkadot/extension-mocks/chrome';
import type * as _ from '@polkadot/dev-test/globals.d.ts';
import type { Message } from '../types.js';
import type { PortClient } from './portUtils.js';
import { createPortClient } from './portUtils.js';
type Listener = (data?: unknown) => void;
// the sinon stubs @polkadot/extension-mocks puts in place of chrome.runtime
interface Stub { callsFake: (fn: (...args: never[]) => unknown) => void }
// A port whose far end the test controls: it records what is posted, and can
// answer or drop the connection the way a suspended background does.
class FakePort {
public posted: { id: string, message?: string }[] = [];
#onMessage: Listener[] = [];
#onDisconnect: Listener[] = [];
public onMessage = { addListener: (cb: Listener) => this.#onMessage.push(cb) };
public onDisconnect = { addListener: (cb: Listener) => this.#onDisconnect.push(cb) };
postMessage (data: { id: string, message?: string }): void {
this.posted.push(data);
}
answer (data: Message['data']): void {
this.#onMessage.forEach((cb) => cb(data));
}
drop (): void {
this.#onDisconnect.forEach((cb) => cb());
}
}
// a few microtask turns: long enough for a reconnect to post its replay
const settle = async (): Promise<void> => {
for (let i = 0; i < 10; i++) {
await Promise.resolve();
}
};
describe('createPortClient', (): void => {
let ports: FakePort[];
beforeEach((): void => {
ports = [];
(chrome.runtime.connect as unknown as Stub).callsFake(() => {
const port = new FakePort();
ports.push(port);
return port;
});
// the background answers every wake-up
(chrome.runtime.sendMessage as unknown as Stub).callsFake((_: unknown, respond: (r: { status: string }) => void) => respond({ status: 'awake' }));
});
// Close every connection so no keep-alive timer outlives its test.
let open: { client: PortClient, ids: string[] }[] = [];
afterEach((): void => {
open.forEach(({ client, ids }) => ids.forEach((id) => client.forget(id)));
ports.forEach((port) => port.drop());
open = [];
});
const client = (onLost = jest.fn()): PortClient => {
const inner = createPortClient({ onLost, onMessage: jest.fn(), portName: 'test' });
const entry = { client: inner, ids: [] as string[] };
open.push(entry);
return {
forget: inner.forget,
send: (data, subscription) => {
entry.ids.push(data.id);
return inner.send(data, subscription);
}
};
};
it('opens one connection for requests sent together', async (): Promise<void> => {
const c = client();
await Promise.all([
c.send({ id: '1' }, true),
c.send({ id: '2' }, true),
c.send({ id: '3' }, false)
]);
expect(ports).toHaveLength(1);
expect(ports[0].posted.map(({ id }) => id)).toEqual(['1', '2', '3']);
});
it('re-sends subscriptions, with their ids, when the background goes away', async (): Promise<void> => {
const c = client();
await c.send({ id: 'accounts', message: 'pri(accounts.subscribe)' }, true);
ports[0].answer({ id: 'accounts', origin: '', response: 'true' });
ports[0].drop();
await settle();
expect(ports).toHaveLength(2);
expect(ports[1].posted).toEqual([{ id: 'accounts', message: 'pri(accounts.subscribe)' }]);
});
it('does not re-send a subscription that was forgotten', async (): Promise<void> => {
const c = client();
await c.send({ id: 'accounts' }, true);
c.forget('accounts');
ports[0].drop();
await settle();
expect(ports).toHaveLength(1);
});
it('reports requests left unanswered, and does not re-send them', async (): Promise<void> => {
const onLost = jest.fn();
const c = client(onLost);
await c.send({ id: 'answered' }, false);
await c.send({ id: 'unanswered' }, false);
ports[0].answer({ id: 'answered', origin: '', response: 'true' });
ports[0].drop();
await settle();
expect(onLost).toHaveBeenCalledWith(['unanswered']);
expect(ports).toHaveLength(1);
});
it('reconnects on the next send after a drop with nothing to restore', async (): Promise<void> => {
const c = client();
await c.send({ id: '1' }, false);
ports[0].answer({ id: '1', origin: '', response: 'true' });
ports[0].drop();
await c.send({ id: '2' }, false);
expect(ports).toHaveLength(2);
expect(ports[1].posted.map(({ id }) => id)).toEqual(['2']);
});
});
@@ -63,3 +63,123 @@ export async function ensurePortConnection (
throw new Error('Failed to wake up the service worker and setup the port after multiple attempts');
}
/**
* How often an open connection tells the background it is still wanted.
*
* Under MV3 the background is not persistent: Firefox suspends an idle event
* page, and Chrome stops an idle service worker, after 30 seconds. An open port
* does not count as activity; a message does. Without this, a popup left alone
* while the user types a name and password loses the background mid-flow, and
* with it every subscription the popup holds and any request awaiting approval.
*/
export const KEEPALIVE_MS = 20_000;
export interface PortClient {
/** Drop a subscription so it is no longer replayed, e.g. on an explicit unsubscribe. */
forget: (id: string) => void;
/**
* Post a request. A `subscription` is replayed, with its original id, each
* time the connection has to be re-established; anything else awaiting an
* answer when the connection drops is reported through `onLost`.
*/
send: <T extends { id: string }>(data: T, subscription: boolean) => Promise<void>;
}
/**
* A connection to the background that survives the background going away.
*
* Upstream reconnected lazily, on the next message, and forgot what it had
* subscribed to. The UI then showed whatever the dead subscriptions last
* delivered, e.g. an empty account list after an account had been added, until
* the page was reloaded. Here subscriptions are re-sent as soon as the
* connection drops, and the background is kept awake while there is anything
* to keep it awake for.
*/
export function createPortClient ({ onLost, onMessage, portName }: {
onLost: (ids: string[]) => void,
onMessage: (data: Message['data']) => void,
portName: string
}): PortClient {
let port: chrome.runtime.Port | undefined;
let connecting: Promise<chrome.runtime.Port> | undefined;
let keepAlive: ReturnType<typeof setInterval> | undefined;
const subscriptions = new Map<string, { id: string }>();
const pending = new Set<string>();
const onPortMessage = (data: Message['data']): void => {
if (!data.subscription) {
pending.delete(data.id);
}
onMessage(data);
};
const onPortDisconnect = (): void => {
port = undefined;
clearInterval(keepAlive);
keepAlive = undefined;
const lost = [...pending];
const replay = [...subscriptions.values()];
pending.clear();
lost.length && onLost(lost);
if (replay.length) {
connect()
.then((connected) => replay.forEach((data) => connected.postMessage(data)))
.catch((error: Error) => console.error(`Unable to restore subscriptions: ${error.message}`));
}
};
function connect (): Promise<chrome.runtime.Port> {
if (port) {
return Promise.resolve(port);
}
// one connection, however many requests arrive while it is being made
connecting ||= ensurePortConnection(undefined, {
onPortDisconnectHandler: onPortDisconnect,
onPortMessageHandler: onPortMessage,
portName
}).then((connected) => {
port = connected;
keepAlive = setInterval((): void => {
(subscriptions.size || pending.size) &&
wakeUpServiceWorkerWrapper.wakeUpServiceWorker().catch(() => undefined);
}, KEEPALIVE_MS);
// Keeping the background alive must not keep anything else alive: under
// node (the specs) a bare interval would stop the process exiting.
(keepAlive as { unref?: () => void }).unref?.();
return connected;
}).finally(() => {
connecting = undefined;
});
return connecting;
}
return {
forget: (id: string): void => {
subscriptions.delete(id);
},
send: async <T extends { id: string }>(data: T, subscription: boolean): Promise<void> => {
if (subscription) {
subscriptions.set(data.id, data);
} else {
pending.add(data.id);
}
try {
(await connect()).postMessage(data);
} catch (error) {
subscriptions.delete(data.id);
pending.delete(data.id);
throw error;
}
}
};
}
@@ -0,0 +1,33 @@
// Copyright 2019-2026 @polkadot/extension-base authors & contributors
// SPDX-License-Identifier: Apache-2.0
/// <reference types="@polkadot/dev-test/globals.d.ts" />
import { Keyring } from '@polkadot/keyring';
import { canInject } from './canDerive.js';
describe('quantus defaults', (): void => {
it('the default type produces a Quantus account', (): void => {
const keyring = new Keyring({ ss58Format: 189, type: 'dilithium65' });
const pair = keyring.createFromUri(
'bottom drive obey lake curtain smoke basket hold race lonely fit walk', {}, 'dilithium65');
expect(pair.address).toEqual('qzq29m9WvneDAeXbtgueKCREtNe1rVVs6bXSMLmjr6shqvwq6');
});
it('//1 gives a different account from the same phrase', (): void => {
const keyring = new Keyring({ ss58Format: 189, type: 'dilithium65' });
const phrase = 'bottom drive obey lake curtain smoke basket hold race lonely fit walk';
const zero = keyring.createFromUri(phrase, {}, 'dilithium65').address;
const one = keyring.createFromUri(`${phrase}//1`, {}, 'dilithium65').address;
expect(zero).not.toEqual(one);
});
it('ML-DSA accounts are injected, and nothing else is', (): void => {
expect(canInject('dilithium65')).toEqual(true);
expect(canInject('dilithium87')).toEqual(true);
expect(canInject('sr25519' as never)).toEqual(false);
expect(canInject(undefined)).toEqual(false);
});
});
+1
View File
@@ -25,6 +25,7 @@
"@polkadot/networks": "^14.0.3",
"@polkadot/util": "^14.0.3",
"@polkadot/util-crypto": "^14.0.3",
"@quantus/codec": "^0.5.0",
"tslib": "^2.8.1"
},
"peerDependencies": {
+47 -10
View File
@@ -5,7 +5,9 @@ import type { MetadataDef } from '@polkadot/extension-inject/types';
import type { ChainProperties } from '@polkadot/types/interfaces';
import type { Chain } from './types.js';
import { Metadata, TypeRegistry } from '@polkadot/types';
import { Runtime } from '@quantus/codec';
import { TypeRegistry } from '@polkadot/types';
import { hexToU8a } from '@polkadot/util';
import { base64Decode } from '@polkadot/util-crypto';
@@ -20,6 +22,30 @@ const definitions = new Map<string, MetadataDef>(
const expanded = new Map<string, Chain>();
/**
* Parse metadata, or report why it could not be used and carry on without it.
*
* A chain whose metadata will not parse is one this extension declines to sign
* for — see `RequestExtrinsicSign` — which is a better outcome than an exception
* thrown from a metadata lookup on an unrelated screen.
*/
function fromMetadata (bytes: Uint8Array, genesisHash: string, ss58Format: number): Runtime | null {
try {
const runtime = Runtime.fromMetadata(bytes);
// So a decoded call names its recipient as an address rather than as 32
// bytes of hex. That matters on exactly one screen — the one asking somebody
// to approve a transfer — and hex is the form nobody checks.
runtime.setSs58Format(ss58Format);
return runtime;
} catch (error) {
console.error(`Unable to read the metadata for ${genesisHash}: ${(error as Error).message}`);
return null;
}
}
export function metadataExpand (definition: MetadataDef, isPartial = false): Chain {
const cached = expanded.get(definition.genesisHash);
@@ -27,7 +53,7 @@ export function metadataExpand (definition: MetadataDef, isPartial = false): Cha
return cached;
}
const { chain, genesisHash, icon, metaCalls, rawMetadata, specVersion, ss58Format, tokenDecimals, tokenSymbol, types, userExtensions } = definition;
const { chain, genesisHash, icon, metaCalls, rawMetadata, specVersion, ss58Format, tokenDecimals, tokenSymbol, types } = definition;
const registry = new TypeRegistry();
if (!isPartial) {
@@ -40,16 +66,26 @@ export function metadataExpand (definition: MetadataDef, isPartial = false): Cha
tokenSymbol
}) as unknown as ChainProperties);
const metadataBytes = metaCalls
? base64Decode(metaCalls)
: rawMetadata
? hexToU8a(rawMetadata)
// `rawMetadata` first: `metaCalls` is upstream's calls-only stripping, and
// this chain's signed extensions and extrinsic type parameters have to survive
// for anything here to encode a payload.
const metadataBytes = rawMetadata
? hexToU8a(rawMetadata)
: metaCalls
? base64Decode(metaCalls)
: null;
const hasMetadata = !!metadataBytes && !isPartial;
if (hasMetadata) {
registry.setMetadata(new Metadata(registry, metadataBytes), undefined, userExtensions);
}
// Deliberately NOT `registry.setMetadata`. @polkadot/types cannot hold this
// chain's metadata at all — PortableRegistry refuses a fixed array longer than
// 2048 bytes and the ML-DSA signature types are 5261 and 7219 — and even where
// it can, it guesses at signed extensions it does not recognise rather than
// reading them. quantus/api#1 has the evidence; @quantus/codec is the
// replacement, and it is the runtime describing itself rather than a decoder
// written against a version of it.
const runtime = metadataBytes && !isPartial
? fromMetadata(metadataBytes, genesisHash, ss58Format)
: null;
const hasMetadata = !!runtime;
const isUnknown = genesisHash === '0x';
@@ -63,6 +99,7 @@ export function metadataExpand (definition: MetadataDef, isPartial = false): Cha
isUnknown,
name: chain,
registry,
runtime,
specVersion,
ss58Format,
tokenDecimals,
+13
View File
@@ -1,18 +1,31 @@
// Copyright 2019-2026 @polkadot/extension-chains authors & contributors
// SPDX-License-Identifier: Apache-2.0
import type { Runtime } from '@quantus/codec';
import type { MetadataDef } from '@polkadot/extension-inject/types';
import type { Registry } from '@polkadot/types/types';
export interface Chain {
definition: MetadataDef;
genesisHash?: string;
/** Whether {@link Chain.runtime} was built. Nothing decodes or signs without it. */
hasMetadata: boolean;
icon: string;
isEthereum?: boolean;
isUnknown?: boolean;
name: string;
/**
* Chain properties only — ss58 format, token symbol and decimals. Deliberately
* carries no metadata: `@polkadot/types` cannot hold this chain's, and
* everything that decodes or encodes goes through {@link Chain.runtime}.
*/
registry: Registry;
/**
* The runtime's own description of itself, from the metadata a dapp provided.
* `null` when no metadata is known, in which case this extension will not sign
* an extrinsic for the chain rather than guess at its format.
*/
runtime: Runtime | null;
specVersion: number;
ss58Format: number;
tokenDecimals: number;
@@ -1,3 +0,0 @@
# @polkadot/extension-metamask-compat
An optional metamask-compatible layer
@@ -1,35 +0,0 @@
{
"author": "Jaco Greeff <jacogr@gmail.com>",
"bugs": "https://github.com/polkadot-js/extension/issues",
"description": "Metamask compatibility layer",
"engines": {
"node": ">=18"
},
"homepage": "https://github.com/polkadot-js/extension/tree/master/packages/extension-compat-metamask#readme",
"license": "Apache-2.0",
"name": "@polkadot/extension-compat-metamask",
"repository": {
"directory": "packages/extension-compat-metamask",
"type": "git",
"url": "https://github.com/polkadot-js/extension.git"
},
"sideEffects": [
"./packageDetect.js",
"./packageDetect.cjs"
],
"type": "module",
"version": "0.64.0",
"main": "index.js",
"dependencies": {
"@metamask/detect-provider": "^2.0.0",
"@polkadot/extension-inject": "0.64.0",
"@polkadot/types": "^16.5.6",
"@polkadot/util": "^14.0.3",
"tslib": "^2.8.1",
"web3": "^4.7.0"
},
"peerDependencies": {
"@polkadot/api": "*",
"@polkadot/util": "*"
}
}
@@ -1,102 +0,0 @@
// Copyright 2019-2026 @polkadot/extension-compat-metamask authors & contributors
// SPDX-License-Identifier: Apache-2.0
import type { Injected, InjectedAccount, InjectedWindow } from '@polkadot/extension-inject/types';
import type { SignerPayloadRaw, SignerResult } from '@polkadot/types/types';
import type { HexString } from '@polkadot/util/types';
import detectEthereumProvider from '@metamask/detect-provider';
import Web3 from 'web3';
import { assert } from '@polkadot/util';
export { packageInfo } from './packageInfo.js';
interface RequestArguments {
method: string;
params?: unknown[];
}
interface EthRpcSubscription {
unsubscribe: () => void
}
interface EthereumProvider {
request: (args: RequestArguments) => Promise<unknown>;
isMetaMask: boolean;
on: (name: string, cb: (value: unknown) => void) => EthRpcSubscription;
}
interface Web3Window extends InjectedWindow {
// this is injected by metaMask
ethereum: unknown;
}
function isMetaMaskProvider (prov: unknown): EthereumProvider {
assert(prov && (prov as EthereumProvider).isMetaMask, 'Injected provider is not MetaMask');
return (prov as EthereumProvider);
}
// transform the Web3 accounts into a simple address/name array
function transformAccounts (accounts: string[]): InjectedAccount[] {
return accounts.map((address, i) => ({
address,
name: `MetaMask Address #${i}`,
type: 'ethereum'
}));
}
// add a compat interface of metaMaskSource to window.injectedWeb3
function injectMetaMaskWeb3 (win: Web3Window): void {
// decorate the compat interface
win.injectedWeb3['Web3Source'] = {
enable: async (): Promise<Injected> => {
const providerRaw = await detectEthereumProvider({ mustBeMetaMask: true });
const provider = isMetaMaskProvider(providerRaw);
await provider.request({ method: 'eth_requestAccounts' });
return {
accounts: {
get: async (): Promise<InjectedAccount[]> => {
const response = (await provider.request({ method: 'eth_requestAccounts' })) as string[];
return transformAccounts(response);
},
subscribe: (cb: (accounts: InjectedAccount[]) => void): (() => void) => {
const sub = provider.on('accountsChanged', (accounts): void => {
cb(transformAccounts(accounts as string[]));
});
// TODO: add onchainchanged
return (): void => {
sub.unsubscribe();
};
}
},
signer: {
signRaw: async (raw: SignerPayloadRaw): Promise<SignerResult> => {
const signature = (await provider.request({ method: 'eth_sign', params: [raw.address, Web3.utils.sha3(raw.data)] })) as HexString;
return { id: 0, signature };
}
}
};
},
version: '0' // TODO: win.ethereum.version
};
}
export default function initMetaMask (): Promise<boolean> {
return new Promise((resolve): void => {
const win = window as Window & Web3Window;
if (win.ethereum) {
injectMetaMaskWeb3(win);
resolve(true);
} else {
resolve(false);
}
});
}
@@ -1,7 +0,0 @@
// Copyright 2019-2026 @polkadot/extension-compat-metamask authors & contributors
// SPDX-License-Identifier: Apache-2.0
// Since we inject into pages, we skip this
// import './detectPackage';
export * from './bundle.js';
@@ -1,12 +0,0 @@
// Copyright 2017-2026 @polkadot/extension-compat-metamask authors & contributors
// SPDX-License-Identifier: Apache-2.0
// Do not edit, auto-generated by @polkadot/dev
// (packageInfo imports will be kept as-is, user-editable)
import { packageInfo as injectInfo } from '@polkadot/extension-inject/packageInfo';
import { detectPackage } from '@polkadot/util';
import { packageInfo } from './packageInfo.js';
detectPackage(packageInfo, null, [injectInfo]);
@@ -1,6 +0,0 @@
// Copyright 2017-2026 @polkadot/extension-compat-metamask authors & contributors
// SPDX-License-Identifier: Apache-2.0
// Do not edit, auto-generated by @polkadot/dev
export const packageInfo = { name: '@polkadot/extension-compat-metamask', path: 'auto', type: 'auto', version: '0.64.0' };
@@ -1,11 +0,0 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"baseUrl": "..",
"outDir": "./build",
"rootDir": "./src"
},
"references": [
{ "path": "../extension-inject/tsconfig.build.json" }
]
}
@@ -47,20 +47,19 @@ describe('Account component', () => {
</MemoryRouter>);
it('shows Export option if account is not external', async () => {
wrapper = mountAccountComponent({ isExternal: false, type: 'ed25519' });
wrapper = mountAccountComponent({ isExternal: false, type: 'dilithium65' });
wrapper.find('.settings').first().simulate('click');
await act(flushAllPromises);
expect(wrapper.find('a.menuItem').length).toBe(4);
expect(wrapper.find('a.menuItem').length).toBe(3);
expect(wrapper.find('a.menuItem').at(0).text()).toBe('Rename');
expect(wrapper.find('a.menuItem').at(1).text()).toBe('Derive New Account');
expect(wrapper.find('a.menuItem').at(2).text()).toBe('Export Account');
expect(wrapper.find('a.menuItem').at(3).text()).toBe('Forget Account');
expect(wrapper.find('a.menuItem').at(1).text()).toBe('Export Account');
expect(wrapper.find('a.menuItem').at(2).text()).toBe('Forget Account');
expect(wrapper.find('.genesisSelection').exists()).toBe(true);
});
it('does not show Export option if account is external', async () => {
wrapper = mountAccountComponent({ isExternal: true, type: 'ed25519' });
wrapper = mountAccountComponent({ isExternal: true, type: 'dilithium65' });
wrapper.find('.settings').first().simulate('click');
await act(flushAllPromises);
@@ -70,19 +69,6 @@ describe('Account component', () => {
expect(wrapper.find('.genesisSelection').exists()).toBe(true);
});
it('shows Derive option if account is of ethereum type', async () => {
wrapper = mountAccountComponent({ isExternal: false, type: 'ethereum' });
wrapper.find('.settings').first().simulate('click');
await act(flushAllPromises);
expect(wrapper.find('a.menuItem').length).toBe(4);
expect(wrapper.find('a.menuItem').at(0).text()).toBe('Rename');
expect(wrapper.find('a.menuItem').at(1).text()).toBe('Derive New Account');
expect(wrapper.find('a.menuItem').at(2).text()).toBe('Export Account');
expect(wrapper.find('a.menuItem').at(3).text()).toBe('Forget Account');
expect(wrapper.find('.genesisSelection').exists()).toBe(true);
});
// Was two tests, one per Ledger app mode: the chain-specific app could not
// sign for an arbitrary genesis hash, so the dropdown was hidden for hardware
// accounts unless the generic app was selected. With no hardware path left
@@ -6,8 +6,6 @@ import type { HexString } from '@polkadot/util/types';
import React, { useCallback, useContext, useEffect, useMemo, useState } from 'react';
import { canDerive } from '@polkadot/extension-base/utils';
import { AccountContext, Address, Checkbox, Dropdown, Link, MenuDivider } from '../../components/index.js';
import { useGenesisHashOptions, useTranslation } from '../../hooks/index.js';
import { editAccount, tieAccount } from '../../messaging.js';
@@ -27,7 +25,7 @@ interface EditState {
toggleActions: number;
}
function Account ({ address, className, genesisHash, isExternal, isHardware, isHidden, name, parentName, showVisibilityAction, suri, type, withCheckbox = false, withMenu = true }: Props): React.ReactElement<Props> {
function Account ({ address, className, genesisHash, isExternal, isHardware, isHidden, name, parentName, showVisibilityAction, suri, withCheckbox = false, withMenu = true }: Props): React.ReactElement<Props> {
const { t } = useTranslation();
const [{ isEditing, toggleActions }, setEditing] = useState<EditState>({ isEditing: false, toggleActions: 0 });
const [editedName, setName] = useState<string | undefined | null>(name);
@@ -84,14 +82,6 @@ function Account ({ address, className, genesisHash, isExternal, isHardware, isH
>
{t('Rename')}
</Link>
{!isExternal && canDerive(type) && (
<Link
className='menuItem'
to={`/account/derive/${address}/locked`}
>
{t('Derive New Account')}
</Link>
)}
<MenuDivider />
{!isExternal && (
<Link
@@ -124,7 +114,7 @@ function Account ({ address, className, genesisHash, isExternal, isHardware, isH
</>
)}
</>
), [_onChangeGenesis, _toggleEdit, address, canEditGenesis, genesisHash, genesisOptions, isExternal, t, type]);
), [_onChangeGenesis, _toggleEdit, address, canEditGenesis, genesisHash, genesisOptions, isExternal, t]);
return (
<div className={className}>
@@ -0,0 +1,262 @@
// Copyright 2019-2026 @polkadot/extension-ui authors & contributors
// SPDX-License-Identifier: Apache-2.0
import type { WalletInfo } from '@polkadot/extension-base/background/types';
import React, { useCallback, useContext, useEffect, useMemo, useState } from 'react';
import { AccountContext, Address, Dropdown, Link, MenuDivider } from '../../components/index.js';
import { useTranslation } from '../../hooks/index.js';
import { renameWallet } from '../../messaging.js';
import { Name } from '../../partials/index.js';
import { styled } from '../../styled.js';
import WormholeSummary from './WormholeSummary.js';
type Tab = 'mldsa65' | 'mldsa87' | 'wormhole';
interface Props {
className?: string;
wallet: WalletInfo;
}
const TABS: { key: Tab, label: string }[] = [
{ key: 'mldsa65', label: 'ML-DSA-65' },
{ key: 'mldsa87', label: 'ML-DSA-87' },
{ key: 'wormhole', label: 'Wormhole' }
];
// Which tab and account a wallet last showed. A per-viewer convenience, so
// browser storage, and anything unreadable just means the defaults.
function remembered<T> (key: string, fallback: T): T {
try {
const value = localStorage.getItem(key);
return value === null ? fallback : JSON.parse(value) as T;
} catch {
return fallback;
}
}
function remember (key: string, value: unknown): void {
try {
localStorage.setItem(key, JSON.stringify(value));
} catch {
// storage denied: the choice lasts as long as the page does
}
}
/**
* One wallet: a secret, shown as the accounts it unlocks.
*
* Upstream showed one card per keyring pair. Here the two ML-DSA parameter sets
* and the wormhole account are the same recovery phrase, and showing them as
* unrelated cards made one wallet look like several (quantus/extension#14), so
* they are tabs of one card instead.
*/
function Wallet ({ className, wallet }: Props): React.ReactElement<Props> {
const { t } = useTranslation();
const { accounts } = useContext(AccountContext);
const [tab, setTab] = useState<Tab>(() => remembered(`quantus:walletTab:${wallet.id}`, 'mldsa65'));
const [index, setIndex] = useState<number>(() => remembered(`quantus:walletIndex:${wallet.id}`, 0));
const [isEditing, setEditing] = useState(false);
const [toggleActions, setToggleActions] = useState(0);
const [editedName, setEditedName] = useState<string | null>(wallet.name);
const account = wallet.accounts.find((a) => a.index === index) || wallet.accounts[0];
const tabs = useMemo(() => TABS.filter(({ key }) => key !== 'wormhole' || account.wormhole), [account]);
const current = tabs.some(({ key }) => key === tab) ? tab : 'mldsa65';
const address = current === 'wormhole'
? account.wormhole?.receive[0]
: account[current];
const pair = accounts.find((a) => a.address === address);
const name = wallet.accounts.length > 1
? `${wallet.name} #${account.index}`
: wallet.name;
useEffect(() => setEditedName(wallet.name), [wallet.name]);
const _selectTab = useCallback(({ currentTarget }: React.MouseEvent<HTMLButtonElement>) => {
const key = currentTarget.dataset['tab'] as Tab;
setTab(key);
remember(`quantus:walletTab:${wallet.id}`, key);
}, [wallet.id]);
const _selectIndex = useCallback((value: string) => {
const next = parseInt(value, 10);
setIndex(next);
remember(`quantus:walletIndex:${wallet.id}`, next);
}, [wallet.id]);
const _toggleEdit = useCallback(() => {
setEditing((editing) => !editing);
setToggleActions((n) => n + 1);
}, []);
const _saveName = useCallback(() => {
editedName && editedName !== wallet.name &&
renameWallet(wallet.id, editedName).catch(console.error);
setEditing(false);
}, [editedName, wallet.id, wallet.name]);
const actions = (
<>
<Link
className='menuItem'
onClick={_toggleEdit}
>
{t('Rename wallet')}
</Link>
{wallet.source === 'mnemonic' && (
<Link
className='menuItem'
to={`/wallet/add-account/${wallet.id}`}
>
{t('Add account')}
</Link>
)}
<MenuDivider />
{current !== 'wormhole' && address && (
<Link
className='menuItem'
isDanger
to={`/account/export/${address}`}
>
{t('Export this account')}
</Link>
)}
<Link
className='menuItem'
isDanger
to={`/wallet/forget/${wallet.id}`}
>
{t('Forget wallet')}
</Link>
</>
);
return (
<div className={className}>
<Address
actions={actions}
address={address}
genesisHash={wallet.genesisHash}
isHidden={pair?.isHidden}
name={editedName && isEditing ? editedName : name}
toggleActions={toggleActions}
>
{isEditing && (
<Name
className='editName'
isFocused
label={' '}
onBlur={_saveName}
onChange={setEditedName}
value={wallet.name}
/>
)}
<div className='walletTabs'>
{tabs.map(({ key, label }) => (
<button
className={`walletTab ${key === current ? 'selected' : ''}`}
data-tab={key}
key={key}
onClick={_selectTab}
type='button'
>
{label}
</button>
))}
{wallet.accounts.length > 1 && (
<Dropdown
className='walletIndex'
label=''
onChange={_selectIndex}
options={wallet.accounts.map((a) => ({ text: t('Account {{index}}', { replace: { index: a.index } }), value: `${a.index}` }))}
value={`${account.index}`}
/>
)}
</div>
{current === 'wormhole' && (
<>
<WormholeSummary
accountIndex={account.index}
walletId={wallet.id}
/>
<div className='walletNote'>
{t('Receive address. Funds here leave only through a zero-knowledge proof, so this account has no key to sign with, and sending from it is not in the extension yet.')}
</div>
</>
)}
</Address>
</div>
);
}
export default styled(Wallet)<Props>`
margin-bottom: 8px;
.editName {
position: absolute;
flex: 1;
left: 70px;
top: 10px;
width: 350px;
input {
height: 30px;
width: 350px;
}
}
.walletTabs {
align-items: center;
border-top: 1px solid var(--boxBorderColor);
display: flex;
gap: 4px;
padding: 6px 8px;
}
.walletTab {
background: transparent;
border: 1px solid transparent;
border-radius: 4px;
color: var(--labelColor);
cursor: pointer;
font-size: 13px;
padding: 2px 8px;
&.selected {
border-color: var(--primaryColor);
color: var(--textColor);
}
}
.walletIndex {
margin: 0 0 0 auto;
max-width: 160px;
select {
padding-right: 32px;
}
}
.walletNote {
color: var(--labelColor);
font-size: 12px;
line-height: 16px;
padding: 0 12px 8px;
}
.menuItem {
border-radius: 8px;
display: block;
font-size: 15px;
line-height: 20px;
margin: 0;
min-width: 13rem;
padding: 4px 16px;
}
`;
@@ -0,0 +1,197 @@
// Copyright 2019-2026 @polkadot/extension-ui authors & contributors
// SPDX-License-Identifier: Apache-2.0
import type { WormholeBalance } from '@polkadot/extension-base/background/types';
import React, { useCallback, useEffect, useState } from 'react';
import { Link } from '../../components/index.js';
import { useTranslation } from '../../hooks/index.js';
import { wormholeBalance } from '../../messaging.js';
import { styled } from '../../styled.js';
import { getBalanceEndpoint, onBalanceEndpointChange } from '../../util/balanceEndpoint.js';
import { formatBalance } from '../../util/formatBalance.js';
import { getWormholeObserver, onWormholeObserverChange } from '../../util/wormholeObserver.js';
interface Props {
accountIndex: number;
className?: string;
walletId: string;
}
type State =
| { kind: 'off', reason: string }
| { kind: 'checking' }
| { kind: 'error', message: string }
| { kind: 'done', balance: WormholeBalance };
/**
* The last answer per wallet account and source, for this page's lifetime. A
* check takes seconds, and switching tabs or accounts should not start another.
* The node and observer are part of the key: an answer read from mainnet is not
* an answer about Heisenberg, and showing it after a switch would be a lie.
*/
const answers = new Map<string, WormholeBalance>();
const answerKey = (walletId: string, accountIndex: number, endpoint = getBalanceEndpoint(), observer = getWormholeObserver()) =>
`${walletId}:${accountIndex}:${endpoint}:${observer}`;
export function forgetWormholeAnswer (walletId: string, accountIndex: number): void {
const prefix = `${walletId}:${accountIndex}:`;
[...answers.keys()].filter((k) => k.startsWith(prefix)).forEach((k) => answers.delete(k));
}
export function lastWormholeAnswer (walletId: string, accountIndex: number): WormholeBalance | undefined {
return answers.get(answerKey(walletId, accountIndex));
}
/**
* What a wallet account's wormhole addresses can still spend, and how far to
* trust that.
*
* Every way the number could be short is said out loud rather than hidden in
* it: deposits the observer has not indexed yet, and deposits whose nullifier
* this wallet has not computed. A balance that is a floor is labelled a floor.
*/
function WormholeSummary ({ accountIndex, className, walletId }: Props): React.ReactElement<Props> {
const { t } = useTranslation();
const [endpoint, setEndpoint] = useState(getBalanceEndpoint);
const [observer, setObserver] = useState(getWormholeObserver);
const key = answerKey(walletId, accountIndex, endpoint, observer);
const [state, setState] = useState<State>(() => {
const cached = answers.get(key);
return cached ? { balance: cached, kind: 'done' } : { kind: 'checking' };
});
const [generation, setGeneration] = useState(0);
useEffect(() => onBalanceEndpointChange(setEndpoint), []);
useEffect(() => onWormholeObserverChange(setObserver), []);
useEffect(() => {
if (!endpoint) {
setState({ kind: 'off', reason: t('Balances are off in settings.') });
return;
}
if (!observer) {
setState({ kind: 'off', reason: t('Wormhole deposit lookups are off in settings.') });
return;
}
const cached = answers.get(key);
if (cached && generation === 0) {
setState({ balance: cached, kind: 'done' });
return;
}
let isCurrent = true;
setState({ kind: 'checking' });
wormholeBalance(walletId, accountIndex, endpoint, observer)
.then((balance) => {
answers.set(key, balance);
isCurrent && setState({ balance, kind: 'done' });
})
.catch((error: Error) => {
isCurrent && setState({ kind: 'error', message: error.message });
});
return () => {
isCurrent = false;
};
}, [accountIndex, endpoint, generation, key, observer, t, walletId]);
const _refresh = useCallback(() => setGeneration((g) => g + 1), []);
const amount = (free: string, { decimals, symbol }: WormholeBalance) =>
formatBalance({ decimals, free, symbol });
return (
<div
className={className}
data-field='wormhole-balance'
>
{state.kind === 'off' && <div className='note'>{state.reason}</div>}
{state.kind === 'checking' && (
<div className='note'>{t('Checking deposits against the chain…')}</div>
)}
{state.kind === 'error' && (
<div className='note error'>
{t('Could not check the wormhole balance: {{message}}', { replace: { message: state.message } })}
{' '}
<Link onClick={_refresh}>{t('Retry')}</Link>
</div>
)}
{state.kind === 'done' && (() => {
const { balance } = state;
const floor = balance.missing > 0 || balance.uncheckedDeposits > 0;
return (
<>
<div className='spendable'>
{floor ? t('At least') : ''} {amount(balance.spendable, balance)} {t('spendable')}
</div>
<div className='note'>
{balance.transfers === 0
? t('No deposits yet.')
: t('{{deposits}} deposits, {{spent}} already exited.', {
replace: { deposits: balance.transfers, spent: amount(balance.spent, balance) }
})}
</div>
{balance.missing > 0 && (
<div className='note warn'>
{t('{{missing}} deposits are not indexed by the observer yet (it has read from block {{from}}); they are not counted.', {
replace: { from: balance.indexedFrom ?? '?', missing: balance.missing }
})}
</div>
)}
{balance.uncheckedDeposits > 0 && (
<div className='note warn'>
{t('{{count}} deposits ({{amount}}) arrived after this wallet last computed its nullifiers, so whether they are spent is unknown.', {
replace: { amount: amount(balance.unchecked, balance), count: balance.uncheckedDeposits }
})}
{' '}
<Link to={`/wallet/wormhole-unlock/${walletId}/${accountIndex}`}>{t('Unlock to check')}</Link>
</div>
)}
<div className='note'>
{t('Checked {{at}}.', { replace: { at: new Date(balance.checkedAt).toLocaleTimeString() } })}
{' '}
<Link onClick={_refresh}>{t('Refresh')}</Link>
</div>
</>
);
})()}
</div>
);
}
export default styled(WormholeSummary)<Props>`
padding: 0 12px 8px;
.spendable {
font-size: 15px;
margin-bottom: 2px;
}
.note {
color: var(--labelColor);
font-size: 12px;
line-height: 16px;
margin-top: 2px;
}
.warn {
color: var(--warningColor, var(--labelColor));
}
.error {
color: var(--errorColor);
}
`;
@@ -7,12 +7,13 @@ import React, { useCallback, useContext, useEffect, useMemo, useState } from 're
import getNetworkMap from '@polkadot/extension-ui/util/getNetworkMap';
import { AccountContext } from '../../components/index.js';
import { AccountContext, WalletContext } from '../../components/index.js';
import { useTranslation } from '../../hooks/index.js';
import { Header } from '../../partials/index.js';
import { styled } from '../../styled.js';
import AccountsTree from './AccountsTree.js';
import AddAccount from './AddAccount.js';
import Wallet from './Wallet.js';
interface Props {
className?: string;
@@ -23,19 +24,31 @@ function Accounts ({ className }: Props): React.ReactElement {
const [filter, setFilter] = useState('');
const [filteredAccount, setFilteredAccount] = useState<AccountWithChildren[]>([]);
const { hierarchy } = useContext(AccountContext);
const wallets = useContext(WalletContext);
const networkMap = useMemo(() => getNetworkMap(), []);
// Pairs a wallet owns are shown on the wallet's card, not again on their own.
const standalone = useMemo(() => hierarchy.filter(({ walletId }) => !walletId), [hierarchy]);
const filteredWallets = useMemo(() => filter
? wallets.filter(({ accounts, genesisHash, name }) =>
name.toLowerCase().includes(filter) ||
(genesisHash && networkMap.get(genesisHash)?.toLowerCase().includes(filter)) ||
accounts.some(({ mldsa65, mldsa87, wormhole }) =>
[mldsa65, mldsa87, ...(wormhole?.receive ?? [])].some((a) => a.toLowerCase().includes(filter))
)
)
: wallets, [filter, networkMap, wallets]);
useEffect(() => {
setFilteredAccount(
filter
? hierarchy.filter((account) =>
? standalone.filter((account) =>
account.name?.toLowerCase().includes(filter) ||
(account.genesisHash && networkMap.get(account.genesisHash)?.toLowerCase().includes(filter)) ||
account.address.toLowerCase().includes(filter)
)
: hierarchy
: standalone
);
}, [filter, hierarchy, networkMap]);
}, [filter, standalone, networkMap]);
const _onFilter = useCallback((filter: string) => {
setFilter(filter.toLowerCase());
@@ -43,7 +56,7 @@ function Accounts ({ className }: Props): React.ReactElement {
return (
<>
{(hierarchy.length === 0)
{(hierarchy.length === 0 && wallets.length === 0)
? <AddAccount />
: (
<>
@@ -56,6 +69,12 @@ function Accounts ({ className }: Props): React.ReactElement {
text={t('Accounts')}
/>
<div className={className}>
{filteredWallets.map((wallet) => (
<Wallet
key={wallet.id}
wallet={wallet}
/>
))}
{filteredAccount.map((json, index): React.ReactNode => (
<AccountsTree
{...json}
@@ -1,81 +0,0 @@
// Copyright 2019-2026 @polkadot/extension-ui authors & contributors
// SPDX-License-Identifier: Apache-2.0
import React, { useCallback, useContext } from 'react';
import { ActionContext, Box, Button, ButtonArea, List } from '../components/index.js';
import { useTranslation } from '../hooks/index.js';
import { Header } from '../partials/index.js';
import { styled } from '../styled.js';
interface Props {
className?: string;
}
function AssetHubMigration ({ className }: Props): React.ReactElement<Props> {
const { t } = useTranslation();
const onAction = useContext(ActionContext);
const _onClick = useCallback(
(): void => {
window.localStorage.setItem('asset_hub_migration_read', 'ok');
onAction();
},
[onAction]
);
return (
<>
<Header text={t('Asset Hub Migration Notice')} />
<div className={className}>
<p>{t('The Asset Hub migration has been completed. Please note the following important changes:')}</p>
<Box>
<List>
<li>{t('All balances have been migrated from the Relay Chain to Asset Hub')}</li>
<li>{t('All on-chain functionality has been moved to Asset Hub')}</li>
<li>{t('Asset Hub now holds user balances and provides general functionality')}</li>
</List>
</Box>
<p className='warning'>{t('Do not teleport balances to the Relay Chain unless:')}</p>
<Box>
<List>
<li>{t('You are opening HRMP channels, or')}</li>
<li>{t('You are starting a Parachain')}</li>
</List>
</Box>
<p>{t('For all other operations, your balances are already on Asset Hub.')}</p>
</div>
<ButtonArea>
<Button onClick={_onClick}>{t('I Understand')}</Button>
</ButtonArea>
</>
);
}
export default styled(AssetHubMigration)<Props>`
p {
color: var(--subTextColor);
margin-bottom: 4px;
margin-top: 0;
line-height: 1.4;
}
p.warning {
color: var(--errorColor);
font-weight: 600;
font-size: 1.1em;
margin-top: 6px;
margin-bottom: 2px;
text-transform: uppercase;
line-height: 1.3;
}
article {
margin: 0.4rem 24px;
padding: 8px 20px;
}
ul {
margin: 0;
}
`;
@@ -31,17 +31,17 @@ const twoRequests = [
];
const oneAccount = [
{ address: '5FjgD3Ns2UpnHJPVeRViMhCttuemaRXEqaD8V5z4vxcsUByA', name: 'A', type: 'sr25519' }
{ address: '5FjgD3Ns2UpnHJPVeRViMhCttuemaRXEqaD8V5z4vxcsUByA', name: 'A', type: 'dilithium65' }
] as AccountJson[];
const twoAccountsOnehidden = [
...oneAccount,
{ address: '5GYmFzQCuC5u3tQNiMZNbFGakrz3Jq31NmMg4D2QAkSoQ2g5', isHidden: true, name: 'B', type: 'sr25519' }
{ address: '5GYmFzQCuC5u3tQNiMZNbFGakrz3Jq31NmMg4D2QAkSoQ2g5', isHidden: true, name: 'B', type: 'dilithium65' }
] as AccountJson[];
const threeAccountsOnehidden = [
...twoAccountsOnehidden,
{ address: '5D2TPhGEy2FhznvzaNYW9AkuMBbg3cyRemnPsBvBY4ZhkZXA', name: 'BB', parentAddress: twoAccountsOnehidden[1].address, type: 'sr25519' }
{ address: '5D2TPhGEy2FhznvzaNYW9AkuMBbg3cyRemnPsBvBY4ZhkZXA', name: 'BB', parentAddress: twoAccountsOnehidden[1].address, type: 'dilithium65' }
] as AccountJson[];
describe('Authorize', () => {
@@ -1,105 +1,84 @@
// Copyright 2019-2026 @polkadot/extension-ui authors & contributors
// SPDX-License-Identifier: Apache-2.0
import type { ResponseWalletPreview } from '@polkadot/extension-base/background/types';
import type { HexString } from '@polkadot/util/types';
import React, { useCallback, useContext, useEffect, useState } from 'react';
import AccountNamePasswordCreation from '../../components/AccountNamePasswordCreation.js';
import { ActionContext, Address, Dropdown, Loading } from '../../components/index.js';
import { useGenesisHashOptions, useMetadata, useTranslation } from '../../hooks/index.js';
import { createAccountSuri, createSeed, validateSeed } from '../../messaging.js';
import { ActionContext, Dropdown, Loading, WalletPreview } from '../../components/index.js';
import { useGenesisHashOptions, useTranslation } from '../../hooks/index.js';
import { createSeed, createWallet, previewWallet } from '../../messaging.js';
import { HeaderWithSteps } from '../../partials/index.js';
import { styled } from '../../styled.js';
import { DEFAULT_TYPE } from '../../util/defaultType.js';
import Mnemonic from './Mnemonic.js';
interface Props {
className?: string;
}
/**
* Create a wallet from a new recovery phrase.
*
* The phrase becomes one wallet showing its ML-DSA-65, ML-DSA-87 and wormhole
* accounts together, so there is no key type to choose. See quantus/extension#14.
*/
function CreateAccount ({ className }: Props): React.ReactElement {
const { t } = useTranslation();
const onAction = useContext(ActionContext);
const [isBusy, setIsBusy] = useState(false);
const [step, setStep] = useState(1);
const [address, setAddress] = useState<null | string>(null);
const [seed, setSeed] = useState<null | string>(null);
const [type, setType] = useState(DEFAULT_TYPE);
const [preview, setPreview] = useState<ResponseWalletPreview | null>(null);
const [name, setName] = useState('');
const [error, setError] = useState('');
const options = useGenesisHashOptions();
const [genesisHash, setGenesis] = useState<HexString | null>(null);
const chain = useMetadata(genesisHash, true);
useEffect((): void => {
createSeed(undefined)
.then(({ address, seed }): void => {
setAddress(address);
.then(({ seed }) => {
setSeed(seed);
return previewWallet(seed);
})
.then(setPreview)
.catch(console.error);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
useEffect((): void => {
if (seed) {
const type = chain && chain.definition.chainType === 'ethereum'
? 'ethereum'
: DEFAULT_TYPE;
setType(type);
validateSeed(seed, type)
.then(({ address }) => setAddress(address))
.catch(console.error);
}
}, [seed, chain]);
const _onCreate = useCallback(
(name: string, password: string): void => {
// this should always be the case
if (name && password && seed) {
setIsBusy(true);
setError('');
createAccountSuri(name, password, seed, type, genesisHash)
createWallet(name, password, seed, genesisHash)
.then(() => onAction('/'))
.catch((error: Error): void => {
setIsBusy(false);
console.error(error);
setError(error.message);
});
}
},
[genesisHash, onAction, seed, type]
[genesisHash, onAction, seed]
);
const _onNextStep = useCallback(
() => setStep((step) => step + 1),
[]
);
const _onPreviousStep = useCallback(
() => setStep((step) => step - 1),
[]
);
const _onChangeNetwork = useCallback(
(newGenesisHash: HexString) => setGenesis(newGenesisHash),
[]
);
const _onNextStep = useCallback(() => setStep((step) => step + 1), []);
const _onPreviousStep = useCallback(() => setStep((step) => step - 1), []);
const _onChangeNetwork = useCallback((newGenesisHash: HexString) => setGenesis(newGenesisHash), []);
return (
<>
<HeaderWithSteps
step={step}
text={t('Create an account')}
text={t('Create a wallet')}
/>
<Loading>
<div>
<Address
address={address}
genesisHash={genesisHash}
name={name}
/>
</div>
<WalletPreview
name={name}
preview={preview}
/>
{seed && (
step === 1
? (
@@ -118,7 +97,8 @@ function CreateAccount ({ className }: Props): React.ReactElement {
value={genesisHash}
/>
<AccountNamePasswordCreation
buttonLabel={t('Add the account with the generated seed')}
buttonLabel={t('Add the wallet with the generated recovery phrase')}
error={error}
isBusy={isBusy}
onBackClick={_onPreviousStep}
onCreate={_onCreate}
@@ -1,106 +0,0 @@
// Copyright 2019-2026 @polkadot/extension-ui authors & contributors
// SPDX-License-Identifier: Apache-2.0
import React, { useCallback, useRef, useState } from 'react';
import arrow from '../../assets/arrow-down.svg';
import { Address } from '../../components/index.js';
import { useOutsideClick } from '../../hooks/index.js';
import { styled } from '../../styled.js';
interface Props {
allAddresses: [string, string | null][];
className?: string;
onSelect: (address: string) => void;
selectedAddress: string;
selectedGenesis: string | null;
}
function AddressDropdown ({ allAddresses, className, onSelect, selectedAddress, selectedGenesis }: Props): React.ReactElement<Props> {
const [isDropdownVisible, setDropdownVisible] = useState(false);
const ref = useRef<HTMLDivElement>(null);
const _hideDropdown = useCallback(() => setDropdownVisible(false), []);
const _toggleDropdown = useCallback(() => setDropdownVisible(!isDropdownVisible), [isDropdownVisible]);
const _selectParent = useCallback((newParent: string) => () => onSelect(newParent), [onSelect]);
useOutsideClick([ref], _hideDropdown);
return (
<div className={className}>
<div
onClick={_toggleDropdown}
ref={ref}
>
<Address
address={selectedAddress}
className='address'
genesisHash={selectedGenesis}
/>
</div>
<div className={`dropdown ${isDropdownVisible ? 'visible' : ''}`}>
{allAddresses.map(([address, genesisHash]) => (
<div
data-parent-option
key={address}
onClick={_selectParent(address)}
>
<Address
address={address}
className='address'
genesisHash={genesisHash}
/>
</div>
))}
</div>
</div>
);
}
export default styled(AddressDropdown)<Props>`
margin-bottom: 16px;
cursor: pointer;
& > div:first-child > .address::after {
content: '';
position: absolute;
top: 66%;
transform: translateY(-50%);
right: 11px;
width: 30px;
height: 30px;
background: url(${arrow}) center no-repeat;
background-color: var(--inputBackground);
pointer-events: none;
border-radius: 4px;
border: 1px solid var(--boxBorderColor);
}
.address .copyIcon {
visibility: hidden;
}
.dropdown {
position: absolute;
visibility: hidden;
width: 510px;
z-index: 100;
background: var(--bodyColor);
max-height: 0;
overflow: auto;
padding: 5px;
border: 1px solid var(--boxBorderColor);
box-sizing: border-box;
border-radius: 4px;
margin-top: -8px;
&.visible{
visibility: visible;
max-height: 200px;
}
& > div {
cursor: pointer;
}
}
`;
@@ -1,110 +0,0 @@
// Copyright 2019-2026 @polkadot/extension-ui authors & contributors
// SPDX-License-Identifier: Apache-2.0
import { faLock, faLockOpen } from '@fortawesome/free-solid-svg-icons';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import React, { useCallback, useEffect, useState } from 'react';
import { Button, InputWithLabel } from '../../components/index.js';
import { useTranslation } from '../../hooks/index.js';
import { styled } from '../../styled.js';
interface Props {
className?: string;
defaultPath: string;
isError: boolean;
onChange: (suri: string) => void;
parentAddress: string;
parentPassword: string;
withSoftPath: boolean;
}
function DerivationPath ({ className, defaultPath, isError, onChange, withSoftPath }: Props): React.ReactElement<Props> {
const { t } = useTranslation();
const [path, setPath] = useState<string>(defaultPath);
const [isDisabled, setIsDisabled] = useState(true);
useEffect(() => {
setPath(defaultPath);
}, [defaultPath]);
const _onExpand = useCallback(() => setIsDisabled(!isDisabled), [isDisabled]);
const _onChange = useCallback((newPath: string): void => {
setPath(newPath);
onChange(newPath);
}, [onChange]);
return (
<div className={className}>
<div className='container'>
<div className={`pathInput ${isDisabled ? 'locked' : ''}`}>
<InputWithLabel
data-input-suri
disabled={isDisabled}
isError={isError || !path}
label={
isDisabled
? t('Derivation Path (unlock to edit)')
: t('Derivation Path')
}
onChange={_onChange}
placeholder={withSoftPath
? t('//hard/soft')
: t('//hard')
}
value={path}
/>
</div>
<Button
className='lockButton'
onClick={_onExpand}
>
<FontAwesomeIcon
className='lockIcon'
icon={isDisabled ? faLock : faLockOpen}
/>
</Button>
</div>
</div>
);
}
export default React.memo(styled(DerivationPath)<Props>`
> .container {
display: flex;
flex-direction: row;
}
.lockButton {
background: none;
height: 14px;
margin: 36px 2px 0 10px;
padding: 3px;
width: 11px;
&:not(:disabled):hover {
background: none;
}
&:active, &:focus {
outline: none;
}
&::-moz-focus-inner {
border: 0;
}
}
.lockIcon {
color: var(--iconNeutralColor)
}
.pathInput {
width: 100%;
&.locked input {
opacity: 50%;
}
}
`);
@@ -1,331 +0,0 @@
// Copyright 2019-2026 @polkadot/extension-ui authors & contributors
// SPDX-License-Identifier: Apache-2.0
import '@polkadot/extension-mocks/chrome';
import type { ReactWrapper } from 'enzyme';
import type * as _ from '@polkadot/dev-test/globals.d.ts';
import type { AccountJson, ResponseDeriveValidate } from '@polkadot/extension-base/background/types';
import Adapter from '@wojtekmaj/enzyme-adapter-react-17';
import enzyme from 'enzyme';
import React from 'react';
import { act } from 'react-dom/test-utils';
import { MemoryRouter, Route } from 'react-router';
import { AccountContext, ActionContext } from '../../components/index.js';
import * as messaging from '../../messaging.js';
import { flushAllPromises } from '../../testHelpers.js';
import { buildHierarchy } from '../../util/buildHierarchy.js';
import AddressDropdown from './AddressDropdown.js';
import Derive from './index.js';
const { configure, mount } = enzyme;
// // NOTE Required for spyOn when using @swc/jest
// // https://github.com/swc-project/swc/issues/3843
// jest.mock('../../messaging', (): Record<string, unknown> => ({
// __esModule: true,
// ...jest.requireActual('../../messaging')
// }));
// For this file, there are a lot of them
/* eslint-disable @typescript-eslint/no-unsafe-argument */
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment,@typescript-eslint/no-unsafe-call
configure({ adapter: new Adapter() });
const parentPassword = 'pass';
const westendGenesis = '0xe143f23803ac50e8f6f8e62695d1ce9e4e1d68aa36c1cd2cfd15340213f3423e';
const defaultDerivation = '//0';
const derivedAddress = '5GYQRJj3NUznYDzCduENRcocMsyxmb6tjb5xW87ZMErBe9R7';
const accounts = [
{ address: '5FjgD3Ns2UpnHJPVeRViMhCttuemaRXEqaD8V5z4vxcsUByA', name: 'A', type: 'sr25519' },
{ address: '5GYmFzQCuC5u3tQNiMZNbFGakrz3Jq31NmMg4D2QAkSoQ2g5', genesisHash: westendGenesis, name: 'B', type: 'sr25519' },
{ address: '5D2TPhGEy2FhznvzaNYW9AkuMBbg3cyRemnPsBvBY4ZhkZXA', name: 'BB', parentAddress: '5GYmFzQCuC5u3tQNiMZNbFGakrz3Jq31NmMg4D2QAkSoQ2g5', type: 'sr25519' },
{ address: '5GhGENSJBWQZ8d8mARKgqEkiAxiW3hHeznQDW2iG4XzNieb6', isExternal: true, name: 'C', type: 'sr25519' },
{ address: '0xd5D81CD4236a43F48A983fc5B895975c511f634D', name: 'Ethereum', type: 'ethereum' },
{ address: '5EeaoDj4VDk8V6yQngKBaCD5MpJUCHrhYjVhBjgMHXoYon1s', isExternal: false, name: 'D', type: 'ed25519' },
{ address: '5HRKYp5anSNGtqC7cq9ftiaq4y8Mk7uHk7keaXUrQwZqDWLJ', name: 'DD', parentAddress: '5EeaoDj4VDk8V6yQngKBaCD5MpJUCHrhYjVhBjgMHXoYon1s', type: 'ed25519' }
] as AccountJson[];
describe('Derive', () => {
const mountComponent = async (locked = false, account = 1): Promise<{
wrapper: ReactWrapper;
onActionStub: ReturnType<typeof jest.fn>;
}> => {
const onActionStub = jest.fn();
const wrapper = mount(
<MemoryRouter initialEntries={ [`/account/derive/${accounts[account].address}`] }>
<ActionContext.Provider value={onActionStub}>
<AccountContext.Provider
value={{
accounts,
hierarchy: buildHierarchy(accounts)
}}
>
<Route path='/account/derive/:address'>
<Derive isLocked={locked} />
</Route>
</AccountContext.Provider>
</ActionContext.Provider>
</MemoryRouter>
);
await act(flushAllPromises);
return { onActionStub, wrapper };
};
let wrapper: ReactWrapper;
let onActionStub: ReturnType<typeof jest.fn>;
const type = async (input: ReactWrapper, value: string): Promise<void> => {
input.simulate('change', { target: { value } });
await act(flushAllPromises);
input.update();
};
const enterName = (name: string): Promise<void> => type(wrapper.find('input').first(), name);
const password = (password: string) => (): Promise<void> => type(wrapper.find('input[type="password"]').first(), password);
const repeat = (password: string) => (): Promise<void> => type(wrapper.find('input[type="password"]').last(), password);
describe('Parent selection screen', () => {
beforeEach(async () => {
const mountedComponent = await mountComponent();
wrapper = mountedComponent.wrapper;
onActionStub = mountedComponent.onActionStub;
});
// eslint-disable-next-line @typescript-eslint/require-await
jest.spyOn(messaging, 'validateAccount').mockImplementation(async (_, pass) => pass === parentPassword);
// silencing the following expected console.error
console.error = jest.fn();
// eslint-disable-next-line @typescript-eslint/require-await
jest.spyOn(messaging, 'validateDerivationPath').mockImplementation(async (_, path) => {
if (path === '//') {
throw new Error('wrong suri');
}
return { address: derivedAddress, suri: defaultDerivation } as ResponseDeriveValidate;
});
it('Button is disabled and password field visible, path field is hidden', () => {
const button = wrapper.find('[data-button-action="create derived account"] button');
expect(button.exists()).toBe(true);
expect(button.prop('disabled')).toBe(true);
expect(wrapper.find('.pathInput').exists()).toBe(false);
});
it('Password field is visible and not in error state', () => {
const passwordField = wrapper.find('[data-input-password]').first();
expect(passwordField.exists()).toBe(true);
expect(passwordField.prop('isError')).toBe(false);
});
it('No error is visible when first loading the page', () => {
expect(wrapper.find('Warning')).toHaveLength(0);
});
it('An error is visible, input higlighted and the button disabled when password is incorrect', async () => {
await type(wrapper.find('input[type="password"]'), 'wrong_pass');
wrapper.find('[data-button-action="create derived account"] button').simulate('click');
await act(flushAllPromises);
wrapper.update();
const button = wrapper.find('[data-button-action="create derived account"] button');
expect(button.prop('disabled')).toBe(true);
expect(wrapper.find('[data-input-password]').first().prop('isError')).toBe(true);
expect(wrapper.find('.warning-message')).toHaveLength(1);
expect(wrapper.find('.warning-message').first().text()).toEqual('Wrong password');
});
it('The error disappears when typing a new password and "Create derived account" is enabled', async () => {
await type(wrapper.find('input[type="password"]'), 'wrong_pass');
wrapper.find('[data-button-action="create derived account"] button').simulate('click');
await act(flushAllPromises);
wrapper.update();
await type(wrapper.find('input[type="password"]'), 'new_attempt');
const button = wrapper.find('[data-button-action="create derived account"] button');
expect(button.prop('disabled')).toBe(false);
expect(wrapper.find('[data-input-password]').first().prop('isError')).toBe(false);
expect(wrapper.find('.warning-message')).toHaveLength(0);
});
it('Button is enabled when password is set', async () => {
await type(wrapper.find('input[type="password"]'), parentPassword);
const button = wrapper.find('[data-button-action="create derived account"] button');
expect(button.prop('disabled')).toBe(false);
expect(wrapper.find('.warning-message')).toHaveLength(0);
});
it('Derivation path gets visible, is set and locked', async () => {
await type(wrapper.find('input[type="password"]'), 'wrong_pass');
expect(wrapper.find('.pathInput.locked input').prop('disabled')).toBe(true);
expect(wrapper.find('.pathInput.locked input').prop('value')).toBe('//1');
});
it('Derivation path can be unlocked', async () => {
await type(wrapper.find('input[type="password"]'), 'wrong_pass');
wrapper.find('FontAwesomeIcon.lockIcon').simulate('click');
await act(flushAllPromises);
wrapper.update();
expect(wrapper.find('.pathInput').exists()).toBe(true);
expect(wrapper.find('.pathInput input').prop('disabled')).toBe(false);
});
it('Derivation path placeholder contains //hard/soft', async () => {
await type(wrapper.find('input[type="password"]'), parentPassword);
const pathInput = wrapper.find('[data-input-suri] input');
expect(pathInput.first().prop('placeholder')).toEqual('//hard/soft');
});
it('An error is visible and the button is disabled when suri is incorrect', async () => {
await type(wrapper.find('input[type="password"]'), parentPassword);
await type(wrapper.find('[data-input-suri] input'), '//');
wrapper.find('[data-button-action="create derived account"] button').simulate('click');
await act(flushAllPromises);
wrapper.update();
const button = wrapper.find('[data-button-action="create derived account"] button');
expect(button.prop('disabled')).toBe(true);
expect(wrapper.find('.warning-message')).toHaveLength(1);
expect(wrapper.find('.warning-message').first().text()).toEqual('Invalid derivation path');
});
it('An error is visible and the button is disabled when suri contains `///`', async () => {
await type(wrapper.find('input[type="password"]'), parentPassword);
await type(wrapper.find('[data-input-suri] input'), '///');
const button = wrapper.find('[data-button-action="create derived account"] button');
expect(button.prop('disabled')).toBe(true);
expect(wrapper.find('.warning-message')).toHaveLength(1);
// eslint-disable-next-line quotes
expect(wrapper.find('.warning-message').first().text()).toEqual("`///password` not supported for derivation");
});
it('No error is shown when suri contains soft derivation `/` with sr25519', async () => {
await type(wrapper.find('input[type="password"]'), parentPassword);
await type(wrapper.find('[data-input-suri] input'), '//somehard/soft');
const button = wrapper.find('[data-button-action="create derived account"] button');
expect(button.prop('disabled')).toBe(false);
expect(wrapper.find('.warning-message')).toHaveLength(0);
});
it('The error disappears and "Create derived account" is enabled when typing a new suri', async () => {
await type(wrapper.find('input[type="password"]'), parentPassword);
await type(wrapper.find('[data-input-suri] input'), '//');
wrapper.find('[data-button-action="create derived account"] button').simulate('click');
await act(flushAllPromises);
wrapper.update();
await type(wrapper.find('[data-input-suri] input'), 'new');
const button = wrapper.find('[data-button-action="create derived account"] button');
expect(button.prop('disabled')).toBe(false);
expect(wrapper.find('Warning')).toHaveLength(0);
});
it('takes selected address from URL as parent account', () => {
expect(wrapper.find('[data-field="name"]').first().text()).toBe('B');
});
it('selects internal root accounts as other options, no external and no Ethereum account', () => {
const options = wrapper.find('[data-parent-option] [data-field="name"]').map((el) => el.text());
expect(options).toEqual(['A', 'B', 'D', 'Ethereum']);
});
it('redirects to derive from next account when other option is selected', () => {
wrapper.find('[data-parent-option]').first().simulate('click');
expect(onActionStub).toHaveBeenCalledWith(`/account/derive/${accounts[0].address}`);
});
});
describe('Locked parent selection', () => {
beforeAll(async () => {
const mountedComponent = (await mountComponent(true));
wrapper = mountedComponent.wrapper;
onActionStub = mountedComponent.onActionStub;
});
it('address dropdown does not exist', () => {
expect(wrapper.exists(AddressDropdown)).toBe(false);
});
it('parent is taken from URL', () => {
expect(wrapper.find('[data-field="name"]').first().text()).toBe('B');
});
describe('Second phase', () => {
it('correctly creates the derived account', async () => {
const newAccount = {
name: 'newName',
password: 'somePassword'
};
const deriveMock = jest.spyOn(messaging, 'deriveAccount');
await type(wrapper.find('input[type="password"]'), parentPassword);
wrapper.find('[data-button-action="create derived account"] button').simulate('click');
await act(flushAllPromises);
wrapper.update();
await enterName(newAccount.name).then(password(newAccount.password)).then(repeat(newAccount.password));
wrapper.find('[data-button-action="add new root"] button').simulate('click');
await act(flushAllPromises);
wrapper.update();
expect(deriveMock).toHaveBeenCalledWith(accounts[1].address, defaultDerivation, parentPassword, newAccount.name, newAccount.password, westendGenesis);
expect(onActionStub).toHaveBeenCalledWith('/');
});
});
});
describe('Ed25519 Parent', () => {
beforeEach(async () => {
const mountedComponent = await mountComponent(false, 5);
wrapper = mountedComponent.wrapper;
onActionStub = mountedComponent.onActionStub;
await type(wrapper.find('input[type="password"]'), parentPassword);
});
it('Derivation path placeholder only contains //hard', () => {
const pathInput = wrapper.find('[data-input-suri] input');
expect(pathInput.first().prop('placeholder')).toEqual('//hard');
});
it('An error is shown when suri contains soft derivation `/` with ed25519', async () => {
const pathInput = wrapper.find('[data-input-suri] input');
await type(pathInput, '//somehard/soft');
const button = wrapper.find('[data-button-action="create derived account"] button');
expect(button.prop('disabled')).toBe(true);
expect(wrapper.find('[data-input-suri]').first().prop('isError')).toBe(true);
expect(wrapper.find('.warning-message')).toHaveLength(1);
expect(wrapper.find('.warning-message').first().text()).toEqual('Soft derivation is only allowed for sr25519 accounts');
});
});
});
@@ -1,195 +0,0 @@
// Copyright 2019-2026 @polkadot/extension-ui authors & contributors
// SPDX-License-Identifier: Apache-2.0
import React, { useCallback, useContext, useEffect, useMemo, useRef, useState } from 'react';
import { canDerive } from '@polkadot/extension-base/utils';
import { AccountContext, ActionContext, Address, ButtonArea, InputWithLabel, Label, NextStepButton, VerticalSpace, Warning } from '../../components/index.js';
import { useTranslation } from '../../hooks/index.js';
import { validateAccount, validateDerivationPath } from '../../messaging.js';
import { nextDerivationPath } from '../../util/nextDerivationPath.js';
import AddressDropdown from './AddressDropdown.js';
import DerivationPath from './DerivationPath.js';
interface Props {
className?: string;
isLocked?: boolean;
parentAddress: string;
parentGenesis: string | null;
onDerivationConfirmed: (derivation: { account: { address: string; suri: string }; parentPassword: string }) => void;
}
// match any single slash
const singleSlashRegex = /([^/]|^)\/([^/]|$)/;
export default function SelectParent ({ className, isLocked, onDerivationConfirmed, parentAddress, parentGenesis }: Props): React.ReactElement<Props> {
const { t } = useTranslation();
const onAction = useContext(ActionContext);
const [isBusy, setIsBusy] = useState(false);
const { accounts, hierarchy } = useContext(AccountContext);
const defaultPath = useMemo(() => nextDerivationPath(accounts, parentAddress), [accounts, parentAddress]);
const [suriPath, setSuriPath] = useState<null | string>(defaultPath);
const [parentPassword, setParentPassword] = useState<string>('');
const [isProperParentPassword, setIsProperParentPassword] = useState(false);
const [pathError, setPathError] = useState('');
const passwordInputRef = useRef<HTMLDivElement>(null);
const allowSoftDerivation = useMemo(() => {
const parent = accounts.find(({ address }) => address === parentAddress);
return parent?.type === 'sr25519';
}, [accounts, parentAddress]);
// reset the password field if the parent address changes
useEffect(() => {
setParentPassword('');
}, [parentAddress]);
useEffect(() => {
// forbid the use of password since Keyring ignores it
if (suriPath?.includes('///')) {
setPathError(t('`///password` not supported for derivation'));
}
if (!allowSoftDerivation && suriPath && singleSlashRegex.test(suriPath)) {
setPathError(t('Soft derivation is only allowed for sr25519 accounts'));
}
}, [allowSoftDerivation, suriPath, t]);
const allAddresses = useMemo(
() => hierarchy
.filter(({ isExternal }) => !isExternal)
.filter(({ type }) => canDerive(type))
.map(({ address, genesisHash }): [string, string | null] => [address, genesisHash || null]),
[hierarchy]
);
const _onParentPasswordEnter = useCallback(
(parentPassword: string): void => {
setParentPassword(parentPassword);
setIsProperParentPassword(!!parentPassword);
},
[]
);
const _onSuriPathChange = useCallback(
(path: string): void => {
setSuriPath(path);
setPathError('');
},
[]
);
const _onParentChange = useCallback(
(address: string) => onAction(`/account/derive/${address}`),
[onAction]
);
const _onSubmit = useCallback(
async (): Promise<void> => {
if (suriPath && parentAddress && parentPassword) {
setIsBusy(true);
const isUnlockable = await validateAccount(parentAddress, parentPassword);
if (isUnlockable) {
try {
const account = await validateDerivationPath(parentAddress, suriPath, parentPassword);
onDerivationConfirmed({ account, parentPassword });
} catch (error) {
setIsBusy(false);
setPathError(t('Invalid derivation path'));
console.error(error);
}
} else {
setIsBusy(false);
setIsProperParentPassword(false);
}
}
},
[parentAddress, parentPassword, onDerivationConfirmed, suriPath, t]
);
useEffect(() => {
setParentPassword('');
setIsProperParentPassword(false);
passwordInputRef.current?.querySelector('input')?.focus();
}, [_onParentPasswordEnter]);
return (
<>
<div className={className}>
{isLocked
? (
<Address
address={parentAddress}
genesisHash={parentGenesis}
/>
)
: (
<Label label={t('Choose Parent Account:')}>
<AddressDropdown
allAddresses={allAddresses}
onSelect={_onParentChange}
selectedAddress={parentAddress}
selectedGenesis={parentGenesis}
/>
</Label>
)
}
<div ref={passwordInputRef}>
<InputWithLabel
data-input-password
isError={!!parentPassword && !isProperParentPassword}
isFocused
label={t('enter the password for the account you want to derive from')}
onChange={_onParentPasswordEnter}
type='password'
value={parentPassword}
/>
{!!parentPassword && !isProperParentPassword && (
<Warning
isBelowInput
isDanger
>
{t('Wrong password')}
</Warning>
)}
</div>
{isProperParentPassword && (
<>
<DerivationPath
defaultPath={defaultPath}
isError={!!pathError}
onChange={_onSuriPathChange}
parentAddress={parentAddress}
parentPassword={parentPassword}
withSoftPath={allowSoftDerivation}
/>
{(!!pathError) && (
<Warning
isBelowInput
isDanger
>
{pathError}
</Warning>
)}
</>
)}
</div>
<VerticalSpace />
<ButtonArea>
<NextStepButton
data-button-action='create derived account'
isBusy={isBusy}
isDisabled={!isProperParentPassword || !!pathError}
onClick={_onSubmit}
>
{t('Create a derived account')}
</NextStepButton>
</ButtonArea>
</>
);
}
@@ -1,104 +0,0 @@
// Copyright 2019-2026 @polkadot/extension-ui authors & contributors
// SPDX-License-Identifier: Apache-2.0
import React, { useCallback, useContext, useMemo, useState } from 'react';
import { useParams } from 'react-router';
import { AccountContext, AccountNamePasswordCreation, ActionContext, Address } from '../../components/index.js';
import { useTranslation } from '../../hooks/index.js';
import { deriveAccount } from '../../messaging.js';
import { HeaderWithSteps } from '../../partials/index.js';
import SelectParent from './SelectParent.js';
interface Props {
isLocked?: boolean;
}
interface AddressState {
address: string;
}
interface PathState extends AddressState {
suri: string;
}
interface ConfirmState {
account: PathState;
parentPassword: string;
}
function Derive ({ isLocked }: Props): React.ReactElement<Props> {
const { t } = useTranslation();
const onAction = useContext(ActionContext);
const { accounts } = useContext(AccountContext);
const { address: parentAddress } = useParams<AddressState>();
const [isBusy, setIsBusy] = useState(false);
const [account, setAccount] = useState<null | PathState>(null);
const [name, setName] = useState<string | null>(null);
const [parentPassword, setParentPassword] = useState<string | null>(null);
const parentGenesis = useMemo(
() => accounts.find((a) => a.address === parentAddress)?.genesisHash || null,
[accounts, parentAddress]
);
const _onCreate = useCallback((name: string, password: string) => {
if (!account || !name || !password || !parentPassword) {
return;
}
setIsBusy(true);
deriveAccount(parentAddress, account.suri, parentPassword, name, password, parentGenesis)
.then(() => onAction('/'))
.catch((error): void => {
setIsBusy(false);
console.error(error);
});
}, [account, onAction, parentAddress, parentGenesis, parentPassword]);
const _onDerivationConfirmed = useCallback(({ account, parentPassword }: ConfirmState) => {
setAccount(account);
setParentPassword(parentPassword);
}, []);
const _onBackClick = useCallback(() => {
setAccount(null);
}, []);
return (
<>
<HeaderWithSteps
step={account ? 2 : 1}
text={t('Add new account')}
/>
{!account && (
<SelectParent
isLocked={isLocked}
onDerivationConfirmed={_onDerivationConfirmed}
parentAddress={parentAddress}
parentGenesis={parentGenesis}
/>
)}
{account && (
<>
<div>
<Address
address={account.address}
genesisHash={parentGenesis}
name={name}
/>
</div>
<AccountNamePasswordCreation
buttonLabel={t('Create derived account')}
isBusy={isBusy}
onBackClick={_onBackClick}
onCreate={_onCreate}
onNameChange={setName}
/>
</>
)}
</>
);
}
export default React.memo(Derive);
@@ -1,69 +1,61 @@
// Copyright 2019-2026 @polkadot/extension-ui authors & contributors
// SPDX-License-Identifier: Apache-2.0
import type { KeypairType } from '@polkadot/util-crypto/types';
import type { AccountInfo } from './index.js';
import type { HexString } from '@polkadot/util/types';
import type { WalletSecret } from './index.js';
import { faCaretDown, faCaretRight } from '@fortawesome/free-solid-svg-icons';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import React, { useCallback, useEffect, useState } from 'react';
import React, { useEffect, useState } from 'react';
import { validateSeed } from '@polkadot/extension-ui/messaging';
import { objectSpread } from '@polkadot/util';
import { ButtonArea, Dropdown, InputWithLabel, NextStepButton, TextAreaWithLabel, VerticalSpace, Warning } from '../../components/index.js';
import { ButtonArea, Dropdown, NextStepButton, TextAreaWithLabel, VerticalSpace, Warning } from '../../components/index.js';
import { useGenesisHashOptions, useTranslation } from '../../hooks/index.js';
import { previewWallet } from '../../messaging.js';
import { styled } from '../../styled.js';
interface Props {
className?: string;
onNextStep: () => void;
onAccountChange: (account: AccountInfo | null) => void;
type: KeypairType;
onWalletChange: (wallet: WalletSecret | null) => void;
}
function SeedAndPath ({ className, onAccountChange, onNextStep, type }: Props): React.ReactElement {
function SeedAndPath ({ className, onNextStep, onWalletChange }: Props): React.ReactElement {
const { t } = useTranslation();
const genesisOptions = useGenesisHashOptions();
const [address, setAddress] = useState('');
const [seed, setSeed] = useState<string | null>(null);
const [path, setPath] = useState<string | null>(null);
const [advanced, setAdvances] = useState(false);
const [secret, setSecret] = useState('');
const [error, setError] = useState('');
const [genesis, setGenesis] = useState('');
const [genesis, setGenesis] = useState<string>('');
const [isValid, setIsValid] = useState(false);
useEffect(() => {
// No need to validate an empty seed
// we have a dedicated error for this
if (!seed) {
onAccountChange(null);
if (!secret.trim()) {
setIsValid(false);
setError('');
onWalletChange(null);
return;
}
const suri = `${seed || ''}${path || ''}`;
let isCurrent = true;
validateSeed(suri, type)
.then((validatedAccount) => {
setError('');
setAddress(validatedAccount.address);
onAccountChange(
objectSpread<AccountInfo>({}, validatedAccount, { genesis, type })
);
previewWallet(secret)
.then((preview) => {
if (isCurrent) {
setError('');
setIsValid(true);
onWalletChange({ genesis: (genesis || null) as HexString | null, preview, secret });
}
})
.catch(() => {
setAddress('');
onAccountChange(null);
setError(path
? t('Invalid mnemonic seed or derivation path')
: t('Invalid mnemonic seed')
);
.catch((error: Error) => {
if (isCurrent) {
setIsValid(false);
setError(error.message);
onWalletChange(null);
}
});
}, [t, genesis, seed, path, onAccountChange, type]);
const _onToggleAdvanced = useCallback(() => {
setAdvances(!advanced);
}, [advanced]);
return () => {
isCurrent = false;
};
}, [genesis, onWalletChange, secret]);
return (
<>
@@ -72,18 +64,18 @@ function SeedAndPath ({ className, onAccountChange, onNextStep, type }: Props):
className='seedInput'
isError={!!error}
isFocused
label={t('existing 12 or 24-word mnemonic seed')}
onChange={setSeed}
label={t('recovery phrase, or a 0x-prefixed 32-byte seed')}
onChange={setSecret}
rowsCount={2}
value={seed || ''}
value={secret}
/>
{!!error && !seed && (
{!!error && (
<Warning
className='seedError'
isBelowInput
isDanger
>
{t('Mnemonic needs to contain 12, 15, 18, 21, 24 words')}
{error}
</Warning>
)}
<Dropdown
@@ -93,34 +85,11 @@ function SeedAndPath ({ className, onAccountChange, onNextStep, type }: Props):
options={genesisOptions}
value={genesis}
/>
<div
className='advancedToggle'
onClick={_onToggleAdvanced}
>
<FontAwesomeIcon icon={advanced ? faCaretDown : faCaretRight} />
<span>{t('advanced')}</span>
</div>
{ advanced && (
<InputWithLabel
className='derivationPath'
isError={!!path && !!error}
label={t('derivation path')}
onChange={setPath}
value={path || ''}
/>
)}
{!!error && !!seed && (
<Warning
isDanger
>
{error}
</Warning>
)}
</div>
<VerticalSpace />
<ButtonArea>
<NextStepButton
isDisabled={!address || !!error}
isDisabled={!isValid}
onClick={onNextStep}
>
{t('Next')}
@@ -131,21 +100,6 @@ function SeedAndPath ({ className, onAccountChange, onNextStep, type }: Props):
}
export default styled(SeedAndPath)<Props>`
.advancedToggle {
color: var(--textColor);
cursor: pointer;
line-height: var(--lineHeight);
letter-spacing: 0.04em;
opacity: 0.65;
text-transform: uppercase;
> span {
font-size: var(--inputLabelFontSize);
margin-left: .5rem;
vertical-align: middle;
}
}
.genesisSelection {
margin-bottom: var(--fontSize);
}
@@ -1,95 +1,84 @@
// Copyright 2019-2026 @polkadot/extension-ui authors & contributors
// SPDX-License-Identifier: Apache-2.0
import type { ResponseWalletPreview } from '@polkadot/extension-base/background/types';
import type { HexString } from '@polkadot/util/types';
import React, { useCallback, useContext, useEffect, useState } from 'react';
import AccountNamePasswordCreation from '../../components/AccountNamePasswordCreation.js';
import { AccountContext, ActionContext, Address } from '../../components/index.js';
import { useMetadata, useTranslation } from '../../hooks/index.js';
import { createAccountSuri } from '../../messaging.js';
import { AccountContext, ActionContext, WalletPreview } from '../../components/index.js';
import { useTranslation } from '../../hooks/index.js';
import { createWallet } from '../../messaging.js';
import { HeaderWithSteps } from '../../partials/index.js';
import { DEFAULT_TYPE } from '../../util/defaultType.js';
import SeedAndPath from './SeedAndPath.js';
export interface AccountInfo {
address: string;
genesis?: HexString;
suri: string;
/** A secret that previewed cleanly, and the network the user tied it to. */
export interface WalletSecret {
genesis: HexString | null;
preview: ResponseWalletPreview;
secret: string;
}
/**
* Import a wallet from a recovery phrase or a raw seed.
*
* No key-type choice and no derivation path: the secret becomes one wallet, and
* its ML-DSA-65, ML-DSA-87 and wormhole accounts are all derived and shown. More
* account indices are added from the wallet's own menu. See quantus/extension#14.
*/
function ImportSeed (): React.ReactElement {
const { t } = useTranslation();
const { accounts } = useContext(AccountContext);
const onAction = useContext(ActionContext);
const [isBusy, setIsBusy] = useState(false);
const [account, setAccount] = useState<AccountInfo | null>(null);
const [wallet, setWallet] = useState<WalletSecret | null>(null);
const [name, setName] = useState<string | null>(null);
const [error, setError] = useState('');
const [step1, setStep1] = useState(true);
const [type, setType] = useState(DEFAULT_TYPE);
const chain = useMetadata(account?.genesis, true);
useEffect((): void => {
!accounts.length && onAction();
}, [accounts, onAction]);
useEffect((): void => {
setType(
chain && chain.definition.chainType === 'ethereum'
? 'ethereum'
: DEFAULT_TYPE
);
}, [chain]);
const _onCreate = useCallback((name: string, password: string): void => {
// this should always be the case
if (name && password && account) {
if (name && password && wallet) {
setIsBusy(true);
setError('');
createAccountSuri(name, password, account.suri, type, account.genesis)
createWallet(name, password, wallet.secret, wallet.genesis)
.then(() => onAction('/'))
.catch((error): void => {
.catch((error: Error): void => {
setIsBusy(false);
console.error(error);
setError(error.message);
});
}
}, [account, onAction, type]);
}, [onAction, wallet]);
const _onNextStep = useCallback(
() => setStep1(false),
[]
);
const _onBackClick = useCallback(
() => setStep1(true),
[]
);
const _onNextStep = useCallback(() => setStep1(false), []);
const _onBackClick = useCallback(() => setStep1(true), []);
return (
<>
<HeaderWithSteps
step={step1 ? 1 : 2}
text={t('Import account')}
text={t('Import wallet')}
/>
<WalletPreview
name={name}
preview={wallet?.preview ?? null}
/>
<div>
<Address
address={account?.address}
genesisHash={account?.genesis}
name={name}
/>
</div>
{step1
? (
<SeedAndPath
onAccountChange={setAccount}
onNextStep={_onNextStep}
type={type}
onWalletChange={setWallet}
/>
)
: (
<AccountNamePasswordCreation
buttonLabel={t('Add the account with the supplied seed')}
buttonLabel={t('Add the wallet')}
error={error}
isBusy={isBusy}
onBackClick={_onBackClick}
onCreate={_onCreate}
@@ -27,7 +27,7 @@ function PhishingDetected ({ className }: Props): React.ReactElement<Props> {
<Header text={t('Phishing detected')} />
<div className={className}>
<p>
{t('You have been redirected because the Polkadot{.js} extension believes that this website could compromise the security of your accounts and your tokens.')}
{t('You have been redirected because blackbeard believes that this website could compromise the security of your accounts and your tokens.')}
</p>
<p className='websiteAddress'>
{decodedWebsite}
+28 -11
View File
@@ -23,6 +23,12 @@ interface Props {
className?: string;
}
function unsafeKeyMessage (e: Error): string | null {
return /not quantum-safe/.test(e.message)
? e.message
: null;
}
function Upload ({ className }: Props): React.ReactElement {
const { t } = useTranslation();
const { accounts } = useContext(AccountContext);
@@ -30,7 +36,10 @@ function Upload ({ className }: Props): React.ReactElement {
const [isBusy, setIsBusy] = useState(false);
const [accountsInfo, setAccountsInfo] = useState<ResponseJsonGetAccountInfo[]>([]);
const [password, setPassword] = useState<string>('');
const [isFileError, setFileError] = useState(false);
// The reason a file can't be restored, when there is one worth showing: a
// backup of a classical key is well-formed, just not something this
// extension will hold (quantus/common#6), and saying "invalid" would hide that.
const [fileError, setFileError] = useState<string | null>(null);
const [requirePassword, setRequirePassword] = useState(false);
const [isPasswordError, setIsPasswordError] = useState(false);
// don't use the info from the file directly
@@ -51,6 +60,7 @@ function Upload ({ className }: Props): React.ReactElement {
const _onChangeFile = useCallback(
(file: Uint8Array): void => {
setAccountsInfo(() => []);
setFileError(null);
let json: KeyringPair$Json | KeyringPairs$Json | undefined;
@@ -59,7 +69,7 @@ function Upload ({ className }: Props): React.ReactElement {
setFile(json);
} catch (e) {
console.error(e);
setFileError(true);
setFileError(t('Invalid Json file'));
}
if (json === undefined) {
@@ -79,12 +89,12 @@ function Upload ({ className }: Props): React.ReactElement {
setRequirePassword(true);
jsonGetAccountInfo(json)
.then((accountInfo) => setAccountsInfo((old) => [...old, accountInfo]))
.catch((e) => {
setFileError(true);
.catch((e: Error) => {
setFileError(unsafeKeyMessage(e) || t('Invalid Json file'));
console.error(e);
});
}
}, []
}, [t]
);
const _onRestore = useCallback(
@@ -103,10 +113,17 @@ function Upload ({ className }: Props): React.ReactElement {
.then(() => {
onAction('/');
})
.catch((e) => {
.catch((e: Error) => {
console.error(e);
setIsBusy(false);
setIsPasswordError(true);
// A batch file is only opened on restore, so this is where one
// holding a classical key is refused.
const unsafe = unsafeKeyMessage(e);
unsafe
? setFileError(unsafe)
: setIsPasswordError(true);
});
},
[file, onAction, password, requirePassword]
@@ -131,16 +148,16 @@ function Upload ({ className }: Props): React.ReactElement {
))}
<InputFileWithLabel
accept={acceptedFormats}
isError={isFileError}
isError={!!fileError}
label={t('backup file')}
onChange={_onChangeFile}
withLabel
/>
{isFileError && (
{fileError && (
<Warning
isDanger
>
{t('Invalid Json file')}
{fileError}
</Warning>
)}
{requirePassword && (
@@ -164,7 +181,7 @@ function Upload ({ className }: Props): React.ReactElement {
<Button
className='restoreButton'
isBusy={isBusy}
isDisabled={isFileError || isPasswordError}
isDisabled={!!fileError || isPasswordError}
onClick={_onRestore}
>
{t('Restore')}
@@ -1,11 +1,10 @@
// Copyright 2019-2026 @polkadot/extension-ui authors & contributors
// SPDX-License-Identifier: Apache-2.0
import type { Chain } from '@polkadot/extension-chains/types';
import type { Call, ExtrinsicEra, ExtrinsicPayload } from '@polkadot/types/interfaces';
import type { AnyJson, SignerPayloadJSON } from '@polkadot/types/types';
import type { BN } from '@polkadot/util';
import type { ExtrinsicEra, ExtrinsicPayload } from '@polkadot/types/interfaces';
import type { SignerPayloadJSON } from '@polkadot/types/types';
import type { TFunction } from '../../hooks/useTranslation.js';
import type { Decoded } from '../../util/decodeMethod.js';
import { convertMultilocationToUrl } from '@paraspell/xcm-analyser';
import React, { useMemo, useRef } from 'react';
@@ -14,11 +13,7 @@ import { bnToBn, formatNumber } from '@polkadot/util';
import { Table } from '../../components/index.js';
import { useMetadata, useTranslation } from '../../hooks/index.js';
interface Decoded {
args: AnyJson | null;
method: Call | null;
}
import { decodeMethod } from '../../util/decodeMethod.js';
interface Props {
className?: string;
@@ -27,33 +22,8 @@ interface Props {
url: string;
}
function displayDecodeVersion (message: string, chain: Chain, specVersion: BN): string {
return `${message}: chain=${chain.name}, specVersion=${chain.specVersion.toString()} (request specVersion=${specVersion.toString()})`;
}
function decodeMethod (data: string, chain: Chain, specVersion: BN): Decoded {
let args: AnyJson | null = null;
let method: Call | null = null;
try {
if (specVersion.eqn(chain.specVersion)) {
method = chain.registry.createType('Call', data);
args = (method.toHuman() as { args: AnyJson }).args;
} else {
console.log(displayDecodeVersion('Outdated metadata to decode', chain, specVersion));
}
} catch (error) {
console.error(`${displayDecodeVersion('Error decoding method', chain, specVersion)}:: ${(error as Error).message}`);
args = null;
method = null;
}
return { args, method };
}
function renderMethod (data: string, { args, method }: Decoded, t: TFunction): React.ReactNode {
if (!args || !method) {
function renderMethod (data: string, { args, name }: Decoded, t: TFunction): React.ReactNode {
if (!args || !name) {
return (
<tr>
<td className='label'>{t('method data')}</td>
@@ -63,31 +33,15 @@ function renderMethod (data: string, { args, method }: Decoded, t: TFunction): R
}
return (
<>
<tr>
<td className='label'>{t('method')}</td>
<td className='data'>
<details>
<summary>{method.section}.{method.method}{
method.meta
? `(${method.meta.args.map(({ name }) => name).join(', ')})`
: ''
}</summary>
<pre>{JSON.stringify(args, null, 2)}</pre>
</details>
</td>
</tr>
{method.meta && (
<tr>
<td className='label'>{t('info')}</td>
<td className='data'>
<details>
<summary>{method.meta.docs.map((d) => d.toString().trim()).join(' ')}</summary>
</details>
</td>
</tr>
)}
</>
<tr>
<td className='label'>{t('method')}</td>
<td className='data'>
<details>
<summary>{name}({Object.keys(args).join(', ')})</summary>
<pre>{JSON.stringify(args, null, 2)}</pre>
</details>
</td>
</tr>
);
}
@@ -122,7 +76,7 @@ function Extrinsic ({ className, payload, request: { blockNumber, genesisHash, m
const decoded = useMemo(
() => chain && chain.hasMetadata
? decodeMethod(method, chain, specVersion)
: { args: null, method: null },
: { args: null, name: null },
[method, chain, specVersion]
);
@@ -10,7 +10,7 @@ import { isExtrinsicRequest } from '@polkadot/extension-base/utils';
import { TypeRegistry } from '@polkadot/types';
import { Address, VerticalSpace, Warning } from '../../../components/index.js';
import { useMetadata, useTranslation } from '../../../hooks/index.js';
import { useTranslation } from '../../../hooks/index.js';
import Bytes from '../Bytes.js';
import Extrinsic from '../Extrinsic.js';
import SignArea from './SignArea.js';
@@ -36,21 +36,6 @@ export default function Request ({ account: { isExternal, isHardware }, buttonTe
const [{ hexBytes, payload }, setData] = useState<Data>({ hexBytes: null, payload: null });
const [error, setError] = useState<string | null>(null);
const { t } = useTranslation();
// Raw vs extrinsic follows the channel the request arrived on, never the
// payload fields - those are dapp-supplied and can describe either shape.
// Use payload genesis for transaction-signing flow. Account genesis can be null
// for allow-any accounts and should not drive payload decoding/signing setup.
const payloadGenesisHash = isExtrinsicRequest(request)
? request.payload.genesisHash
: null;
const chain = useMetadata(payloadGenesisHash);
useEffect((): void => {
// When the chain and request are ready, configure the chain's registry.
if (chain && isExtrinsicRequest(request)) {
chain.registry.setSignedExtensions(request.payload.signedExtensions, chain.definition.userExtensions);
}
}, [chain, request]);
useEffect((): void => {
if (isExtrinsicRequest(request)) {
@@ -0,0 +1,127 @@
// Copyright 2019-2026 @polkadot/extension-ui authors & contributors
// SPDX-License-Identifier: Apache-2.0
import type { RouteComponentProps } from 'react-router';
import React, { useCallback, useContext, useState } from 'react';
import { withRouter } from 'react-router';
import { ActionBar, ActionContext, ActionText, Button, InputWithLabel, WalletContext, Warning } from '../../components/index.js';
import { useTranslation } from '../../hooks/index.js';
import { addWalletAccount } from '../../messaging.js';
import { Header } from '../../partials/index.js';
import { styled } from '../../styled.js';
interface Props extends RouteComponentProps<{ id: string }> {
className?: string;
}
/**
* Add the next account index to a wallet.
*
* Asks for the password because it has to: a new index means deriving new keys
* from the recovery phrase, which is stored encrypted. It replaces upstream's
* "derive from a parent account", which has no meaning for lattice keys.
*/
function AddAccount ({ className, match: { params: { id } } }: Props): React.ReactElement<Props> {
const { t } = useTranslation();
const onAction = useContext(ActionContext);
const wallet = useContext(WalletContext).find((w) => w.id === id);
const [password, setPassword] = useState('');
const [error, setError] = useState('');
const [isBusy, setIsBusy] = useState(false);
const next = wallet ? Math.max(...wallet.accounts.map((a) => a.index)) + 1 : 0;
const _goHome = useCallback(() => onAction('/'), [onAction]);
const _onChange = useCallback((value: string) => {
setPassword(value);
setError('');
}, []);
const _onAdd = useCallback(() => {
setIsBusy(true);
addWalletAccount(id, password)
.then(() => onAction('/'))
.catch((error: Error) => {
setError(error.message);
setIsBusy(false);
});
}, [id, onAction, password]);
return (
<>
<Header
showBackArrow
text={t('Add account')}
/>
<div className={className}>
{wallet
? (
<>
<p className='explain'>
{t('Account {{next}} of "{{name}}": new ML-DSA-65, ML-DSA-87 and wormhole accounts from the same recovery phrase. Enter the wallet password to derive them.', { replace: { name: wallet.name, next } })}
</p>
<InputWithLabel
disabled={isBusy}
isError={!!error}
isFocused
label={t('Wallet password')}
onChange={_onChange}
onEnter={_onAdd}
type='password'
value={password}
/>
{error && (
<Warning
isBelowInput
isDanger
>
{error}
</Warning>
)}
<Button
className='action'
isBusy={isBusy}
isDisabled={!password}
onClick={_onAdd}
>
{t('Add account {{next}}', { replace: { next } })}
</Button>
</>
)
: <Warning isDanger>{t('This wallet no longer exists.')}</Warning>
}
<ActionBar className='withMarginTop'>
<ActionText
className='center'
onClick={_goHome}
text={t('Cancel')}
/>
</ActionBar>
</div>
</>
);
}
export default withRouter(styled(AddAccount)`
padding: 0 24px;
.explain {
color: var(--labelColor);
font-size: 14px;
line-height: 20px;
}
.action {
margin-top: 16px;
}
.center {
margin: auto;
}
.withMarginTop {
margin-top: 4px;
}
`);
@@ -0,0 +1,99 @@
// Copyright 2019-2026 @polkadot/extension-ui authors & contributors
// SPDX-License-Identifier: Apache-2.0
import type { RouteComponentProps } from 'react-router';
import React, { useCallback, useContext, useState } from 'react';
import { withRouter } from 'react-router';
import { ActionBar, ActionContext, ActionText, Button, WalletContext, Warning } from '../../components/index.js';
import { useTranslation } from '../../hooks/index.js';
import { forgetWallet } from '../../messaging.js';
import { Header } from '../../partials/index.js';
import { styled } from '../../styled.js';
interface Props extends RouteComponentProps<{ id: string }> {
className?: string;
}
function Forget ({ className, match: { params: { id } } }: Props): React.ReactElement<Props> {
const { t } = useTranslation();
const onAction = useContext(ActionContext);
const wallet = useContext(WalletContext).find((w) => w.id === id);
const [isBusy, setIsBusy] = useState(false);
const _goHome = useCallback(() => onAction('/'), [onAction]);
const _onForget = useCallback(() => {
setIsBusy(true);
forgetWallet(id)
.then(() => onAction('/'))
.catch((error: Error) => {
setIsBusy(false);
console.error(error);
});
}, [id, onAction]);
const count = wallet?.accounts.length ?? 0;
return (
<>
<Header
showBackArrow
text={t('Forget wallet')}
/>
<div className={className}>
{wallet && (
<>
<p className='name'>{wallet.name}</p>
<Warning className='movedWarning'>
{count > 1
? t('This removes the stored recovery phrase and all {{count}} accounts derived from it: each one\'s ML-DSA-65, ML-DSA-87 and wormhole account. Without the recovery phrase they cannot be recovered.', { replace: { count } })
: t('This removes the stored recovery phrase or seed and every account derived from it. Without it they cannot be recovered.')}
</Warning>
<Button
className='action'
isBusy={isBusy}
isDanger
onClick={_onForget}
>
{t('I want to forget this wallet')}
</Button>
</>
)}
<ActionBar className='withMarginTop'>
<ActionText
className='center'
onClick={_goHome}
text={t('Cancel')}
/>
</ActionBar>
</div>
</>
);
}
export default withRouter(styled(Forget)`
padding: 0 24px;
.name {
font-size: 16px;
font-weight: 600;
}
.action {
margin-top: 16px;
}
.center {
margin: auto;
}
.movedWarning {
margin-top: 8px;
}
.withMarginTop {
margin-top: 4px;
}
`);
@@ -0,0 +1,136 @@
// Copyright 2019-2026 @polkadot/extension-ui authors & contributors
// SPDX-License-Identifier: Apache-2.0
import type { RouteComponentProps } from 'react-router';
import React, { useCallback, useContext, useState } from 'react';
import { withRouter } from 'react-router';
import { ActionBar, ActionContext, ActionText, Button, InputWithLabel, WalletContext, Warning } from '../../components/index.js';
import { useTranslation } from '../../hooks/index.js';
import { wormholeUnlock } from '../../messaging.js';
import { Header } from '../../partials/index.js';
import { styled } from '../../styled.js';
import { forgetWormholeAnswer, lastWormholeAnswer } from '../Accounts/WormholeSummary.js';
interface Props extends RouteComponentProps<{ id: string, index: string }> {
className?: string;
}
/**
* Compute nullifiers past what is stored, so newer deposits can be checked.
*
* Needs the password because a nullifier needs the address's secret. The
* result is stored like the ones computed at creation, and removed with the
* wallet.
*/
function WormholeUnlock ({ className, match: { params: { id, index } } }: Props): React.ReactElement<Props> {
const { t } = useTranslation();
const onAction = useContext(ActionContext);
const wallet = useContext(WalletContext).find((w) => w.id === id);
const accountIndex = parseInt(index, 10);
const answer = lastWormholeAnswer(id, accountIndex);
const [password, setPassword] = useState('');
const [error, setError] = useState('');
const [isBusy, setIsBusy] = useState(false);
const _goHome = useCallback(() => onAction('/'), [onAction]);
const _onChange = useCallback((value: string) => {
setPassword(value);
setError('');
}, []);
const _onUnlock = useCallback(() => {
if (!answer) {
return;
}
setIsBusy(true);
wormholeUnlock(id, accountIndex, password, answer.counts)
.then(() => {
forgetWormholeAnswer(id, accountIndex);
onAction('/');
})
.catch((error: Error) => {
setError(error.message);
setIsBusy(false);
});
}, [accountIndex, answer, id, onAction, password]);
return (
<>
<Header
showBackArrow
text={t('Check newer deposits')}
/>
<div className={className}>
{wallet && answer
? (
<>
<p className='explain'>
{t('Whether a wormhole deposit is spent is decided by its nullifier, which only this wallet can compute. {{count}} deposits to "{{name}}" arrived after it last did. Enter the wallet password to compute theirs; they are stored with the wallet and removed if you forget it.', { replace: { count: answer.uncheckedDeposits, name: wallet.name } })}
</p>
<InputWithLabel
disabled={isBusy}
isError={!!error}
isFocused
label={t('Wallet password')}
onChange={_onChange}
onEnter={_onUnlock}
type='password'
value={password}
/>
{error && (
<Warning
isBelowInput
isDanger
>
{error}
</Warning>
)}
<Button
className='action'
isBusy={isBusy}
isDisabled={!password}
onClick={_onUnlock}
>
{t('Compute and check')}
</Button>
</>
)
: <Warning>{t('Open the wallet\'s wormhole tab first, so there is a balance to extend.')}</Warning>
}
<ActionBar className='withMarginTop'>
<ActionText
className='center'
onClick={_goHome}
text={t('Cancel')}
/>
</ActionBar>
</div>
</>
);
}
export default withRouter(styled(WormholeUnlock)`
padding: 0 24px;
.explain {
color: var(--labelColor);
font-size: 14px;
line-height: 20px;
}
.action {
margin-top: 16px;
}
.center {
margin: auto;
}
.withMarginTop {
margin-top: 4px;
}
`);
+6 -4
View File
@@ -31,12 +31,14 @@ function Welcome ({ className }: Props): React.ReactElement<Props> {
<p>{t('Before we start, just a couple of notes regarding use:')}</p>
<Box>
<List>
<li>{t('We do not send any clicks, pageviews or events to a central server')}</li>
<li>{t('We do not use any trackers or analytics')}</li>
<li>{t("We don't collect keys, addresses or any information - your information never leaves this machine")}</li>
<li>{t('No clicks, pageviews or events are sent anywhere, and there are no trackers or analytics.')}</li>
<li>{t('Keys and recovery phrases never leave this browser.')}</li>
<li>{t('To show balances, your addresses are sent to a Quantus node; to show wormhole balances, your wormhole addresses are sent to a blackbeard observer. Either can be pointed at your own server, or turned off, in settings.')}</li>
</List>
</Box>
<p>{t('... we are not in the information collection business (even anonymized).')}</p>
{/* Upstream said "your information never leaves this machine". Once
balances were shown that stopped being true, and a welcome screen
is the wrong place to overstate privacy. quantus/extension#15 */}
</div>
<VerticalSpace />
<ButtonArea>
+57 -49
View File
@@ -1,31 +1,32 @@
// Copyright 2019-2026 @polkadot/extension-ui authors & contributors
// SPDX-License-Identifier: Apache-2.0
import type { AccountJson, AccountsContext, AuthorizeRequest, MetadataRequest, SigningRequest } from '@polkadot/extension-base/background/types';
import type { AccountJson, AccountsContext, AuthorizeRequest, MetadataRequest, SigningRequest, WalletInfo } from '@polkadot/extension-base/background/types';
import type { SettingsStruct } from '@polkadot/ui-settings/types';
import React, { useCallback, useEffect, useState } from 'react';
import { Route, Switch, useHistory } from 'react-router';
import { PHISHING_PAGE_REDIRECT } from '@polkadot/extension-base/defaults';
import { canDerive } from '@polkadot/extension-base/utils';
import { settings } from '@polkadot/ui-settings';
import { AccountContext, ActionContext, AuthorizeReqContext, MediaContext, MetadataReqContext, SettingsContext, SigningReqContext } from '../components/contexts.js';
import { AccountContext, ActionContext, AuthorizeReqContext, BalanceContext, MediaContext, MetadataReqContext, SettingsContext, SigningReqContext, WalletContext } from '../components/contexts.js';
import { ErrorBoundary, Loading } from '../components/index.js';
import ToastProvider from '../components/Toast/ToastProvider.js';
import { ping, subscribeAccounts, subscribeAuthorizeRequests, subscribeMetadataRequests, subscribeSigningRequests } from '../messaging.js';
import { useBalances } from '../hooks/index.js';
import { ping, subscribeAccounts, subscribeAuthorizeRequests, subscribeMetadataRequests, subscribeSigningRequests, subscribeWallets } from '../messaging.js';
import { buildHierarchy } from '../util/buildHierarchy.js';
import Accounts from './Accounts/index.js';
import AccountManagement from './AuthManagement/AccountManagement.js';
import AuthList from './AuthManagement/index.js';
import Authorize from './Authorize/index.js';
import CreateAccount from './CreateAccount/index.js';
import Derive from './Derive/index.js';
import ImportSeed from './ImportSeed/index.js';
import Metadata from './Metadata/index.js';
import Signing from './Signing/index.js';
import AssetHubMigration from './AssetHubMigration.js';
import AddWalletAccount from './Wallet/AddAccount.js';
import ForgetWallet from './Wallet/Forget.js';
import WormholeUnlock from './Wallet/WormholeUnlock.js';
import Export from './Export.js';
import ExportAll from './ExportAll.js';
import Forget from './Forget.js';
@@ -55,18 +56,17 @@ async function requestMediaAccess (cameraOn: boolean): Promise<boolean> {
function initAccountContext ({ accounts, selectedAccounts, setSelectedAccounts }: Omit<AccountsContext, 'hierarchy' | 'master'>): AccountsContext {
const hierarchy = buildHierarchy(accounts);
const master = hierarchy.find(({ isExternal, type }) => !isExternal && canDerive(type));
return {
accounts,
hierarchy,
master,
selectedAccounts,
setSelectedAccounts
};
}
export default function Popup (): React.ReactElement {
const balances = useBalances();
const [accounts, setAccounts] = useState<null | AccountJson[]>(null);
const [accountCtx, setAccountCtx] = useState<AccountsContext>({ accounts: [], hierarchy: [] });
const [selectedAccounts, setSelectedAccounts] = useState<AccountJson['address'][]>([]);
@@ -75,15 +75,14 @@ export default function Popup (): React.ReactElement {
const [mediaAllowed, setMediaAllowed] = useState(false);
const [metaRequests, setMetaRequests] = useState<null | MetadataRequest[]>(null);
const [signRequests, setSignRequests] = useState<null | SigningRequest[]>(null);
const [wallets, setWallets] = useState<null | WalletInfo[]>(null);
const [isWelcomeDone, setWelcomeDone] = useState(false);
const [isMigrationDone, setMigrationDone] = useState(false);
const [settingsCtx, setSettingsCtx] = useState<SettingsStruct>(startSettings);
const history = useHistory();
const _onAction = useCallback(
(to?: string): void => {
setWelcomeDone(window.localStorage.getItem('welcome_read') === 'ok');
setMigrationDone(window.localStorage.getItem('asset_hub_migration_read') === 'ok');
if (!to) {
return;
@@ -106,7 +105,8 @@ export default function Popup (): React.ReactElement {
subscribeAccounts(setAccounts),
subscribeAuthorizeRequests(setAuthRequests),
subscribeMetadataRequests(setMetaRequests),
subscribeSigningRequests(setSignRequests)
subscribeSigningRequests(setSignRequests),
subscribeWallets(setWallets)
])).catch(console.error);
settings.on('change', (settings): void => {
@@ -132,52 +132,60 @@ export default function Popup (): React.ReactElement {
return <ErrorBoundary trigger={trigger}>{component}</ErrorBoundary>;
}
// Upstream shows an Asset Hub migration notice here before anything else.
// Removed: it is about balances moving from the Polkadot Relay Chain to Asset
// Hub, and neither exists on Quantus. It is not merely irrelevant — it told
// every user, on first open, that their balances had been migrated somewhere
// and warned them not to teleport to a chain this extension cannot reach.
const Root = !isWelcomeDone
? wrapWithErrorBoundary(<Welcome />, 'welcome')
: !isMigrationDone
? wrapWithErrorBoundary(<AssetHubMigration />, 'asset-hub-migration')
: authRequests?.length
? wrapWithErrorBoundary(<Authorize />, 'authorize')
: metaRequests?.length
? wrapWithErrorBoundary(<Metadata />, 'metadata')
: signRequests?.length
? wrapWithErrorBoundary(<Signing />, 'signing')
: wrapWithErrorBoundary(<Accounts />, 'accounts');
: authRequests?.length
? wrapWithErrorBoundary(<Authorize />, 'authorize')
: metaRequests?.length
? wrapWithErrorBoundary(<Metadata />, 'metadata')
: signRequests?.length
? wrapWithErrorBoundary(<Signing />, 'signing')
: wrapWithErrorBoundary(<Accounts />, 'accounts');
return (
<Loading>{accounts && authRequests && metaRequests && signRequests && (
<Loading>{accounts && authRequests && metaRequests && signRequests && wallets && (
<ActionContext.Provider value={_onAction}>
<SettingsContext.Provider value={settingsCtx}>
<AccountContext.Provider value={accountCtx}>
<AuthorizeReqContext.Provider value={authRequests}>
<MediaContext.Provider value={cameraOn && mediaAllowed}>
<MetadataReqContext.Provider value={metaRequests}>
<SigningReqContext.Provider value={signRequests}>
<ToastProvider>
<Switch>
<Route path='/auth-list'>{wrapWithErrorBoundary(<AuthList />, 'auth-list')}</Route>
<Route path='/account/create'>{wrapWithErrorBoundary(<CreateAccount />, 'account-creation')}</Route>
<Route path='/account/forget/:address'>{wrapWithErrorBoundary(<Forget />, 'forget-address')}</Route>
<Route path='/account/export/:address'>{wrapWithErrorBoundary(<Export />, 'export-address')}</Route>
<Route path='/account/export-all'>{wrapWithErrorBoundary(<ExportAll />, 'export-all-address')}</Route>
<Route path='/account/track-address'>{wrapWithErrorBoundary(<TrackAddress />, 'track-address')}</Route>
<Route path='/account/import-seed'>{wrapWithErrorBoundary(<ImportSeed />, 'import-seed')}</Route>
<Route path='/account/restore-json'>{wrapWithErrorBoundary(<RestoreJson />, 'restore-json')}</Route>
<Route path='/account/derive/:address/locked'>{wrapWithErrorBoundary(<Derive isLocked />, 'derived-address-locked')}</Route>
<Route path='/account/derive/:address'>{wrapWithErrorBoundary(<Derive />, 'derive-address')}</Route>
<Route path='/url/manage/:url'>{wrapWithErrorBoundary(<AccountManagement />, 'manage-url')}</Route>
<Route path={`${PHISHING_PAGE_REDIRECT}/:website`}>{wrapWithErrorBoundary(<PhishingDetected />, 'phishing-page-redirect')}</Route>
<Route
exact
path='/'
>
{Root}
</Route>
</Switch>
</ToastProvider>
</SigningReqContext.Provider>
</MetadataReqContext.Provider>
</MediaContext.Provider>
<BalanceContext.Provider value={balances}>
<WalletContext.Provider value={wallets}>
<MediaContext.Provider value={cameraOn && mediaAllowed}>
<MetadataReqContext.Provider value={metaRequests}>
<SigningReqContext.Provider value={signRequests}>
<ToastProvider>
<Switch>
<Route path='/auth-list'>{wrapWithErrorBoundary(<AuthList />, 'auth-list')}</Route>
<Route path='/account/create'>{wrapWithErrorBoundary(<CreateAccount />, 'account-creation')}</Route>
<Route path='/wallet/add-account/:id'>{wrapWithErrorBoundary(<AddWalletAccount />, 'wallet-add-account')}</Route>
<Route path='/wallet/wormhole-unlock/:id/:index'>{wrapWithErrorBoundary(<WormholeUnlock />, 'wallet-wormhole-unlock')}</Route>
<Route path='/wallet/forget/:id'>{wrapWithErrorBoundary(<ForgetWallet />, 'wallet-forget')}</Route>
<Route path='/account/forget/:address'>{wrapWithErrorBoundary(<Forget />, 'forget-address')}</Route>
<Route path='/account/export/:address'>{wrapWithErrorBoundary(<Export />, 'export-address')}</Route>
<Route path='/account/export-all'>{wrapWithErrorBoundary(<ExportAll />, 'export-all-address')}</Route>
<Route path='/account/track-address'>{wrapWithErrorBoundary(<TrackAddress />, 'track-address')}</Route>
<Route path='/account/import-seed'>{wrapWithErrorBoundary(<ImportSeed />, 'import-seed')}</Route>
<Route path='/account/restore-json'>{wrapWithErrorBoundary(<RestoreJson />, 'restore-json')}</Route>
<Route path='/url/manage/:url'>{wrapWithErrorBoundary(<AccountManagement />, 'manage-url')}</Route>
<Route path={`${PHISHING_PAGE_REDIRECT}/:website`}>{wrapWithErrorBoundary(<PhishingDetected />, 'phishing-page-redirect')}</Route>
<Route
exact
path='/'
>
{Root}
</Route>
</Switch>
</ToastProvider>
</SigningReqContext.Provider>
</MetadataReqContext.Provider>
</MediaContext.Provider>
</WalletContext.Provider>
</BalanceContext.Provider>
</AuthorizeReqContext.Provider>
</AccountContext.Provider>
</SettingsContext.Provider>
-1
View File
@@ -1 +0,0 @@
<?xml version="1.0" encoding="utf-8" standalone="yes"?><svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" version="1.1" id="Layer_1" x="0px" y="0px" viewBox="15 15 140 140" style="enable-background:new 0 0 170 170;zoom: 1;" xml:space="preserve"><style type="text/css">.bg0{fill:#FF8C00} .st0{fill:#FFFFFF}</style><g><circle class="bg0" cx="85" cy="85" r="70"></circle><g><path class="st0" d="M85,34.7c-20.8,0-37.8,16.9-37.8,37.8c0,4.2,0.7,8.3,2,12.3c0.9,2.7,3.9,4.2,6.7,3.3c2.7-0.9,4.2-3.9,3.3-6.7 c-1.1-3.1-1.6-6.4-1.5-9.7C58.1,57.6,69.5,46,83.6,45.3c15.7-0.8,28.7,11.7,28.7,27.2c0,14.5-11.4,26.4-25.7,27.2 c0,0-5.3,0.3-7.9,0.7c-1.3,0.2-2.3,0.4-3,0.5c-0.3,0.1-0.6-0.2-0.5-0.5l0.9-4.4L81,73.4c0.6-2.8-1.2-5.6-4-6.2 c-2.8-0.6-5.6,1.2-6.2,4c0,0-11.8,55-11.9,55.6c-0.6,2.8,1.2,5.6,4,6.2c2.8,0.6,5.6-1.2,6.2-4c0.1-0.6,1.7-7.9,1.7-7.9 c1.2-5.6,5.8-9.7,11.2-10.4c1.2-0.2,5.9-0.5,5.9-0.5c19.5-1.5,34.9-17.8,34.9-37.7C122.8,51.6,105.8,34.7,85,34.7z M87.7,121.7 c-3.4-0.7-6.8,1.4-7.5,4.9c-0.7,3.4,1.4,6.8,4.9,7.5c3.4,0.7,6.8-1.4,7.5-4.9C93.3,125.7,91.2,122.4,87.7,121.7z"></path></g></g></svg>

Before

Width:  |  Height:  |  Size: 1.1 KiB

@@ -0,0 +1,10 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 40 40" width="40" height="40">
<!-- blackbeard.observer's sigil, centred on a square for icons -->
<rect width="40" height="40" rx="6" fill="#0d0b09"/>
<g transform="translate(3 0)">
<path d="M17 1 L32 7 V20 C32 29 25 35 17 39 C9 35 2 29 2 20 V7 Z" fill="none" stroke="#bd8829" stroke-width="1.6"/>
<path d="M17 8 L24 12 V21 C24 26 20.5 29.5 17 31.5 C13.5 29.5 10 26 10 21 V12 Z" fill="#bd882922"/>
<path d="M17 11 V28" stroke="#d8453a" stroke-width="1.6"/>
<path d="M13 15 L17 11 L21 15" fill="none" stroke="#d8453a" stroke-width="1.6"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 628 B

@@ -4,10 +4,12 @@
import React, { useCallback, useState } from 'react';
import { Name, Password } from '../partials/index.js';
import { BackButton, ButtonArea, NextStepButton, VerticalSpace } from './index.js';
import { BackButton, ButtonArea, NextStepButton, VerticalSpace, Warning } from './index.js';
interface Props {
buttonLabel?: string;
/** Why the last attempt to create failed, shown above the buttons. */
error?: string;
isBusy: boolean;
onBackClick?: () => void;
onCreate: (name: string, password: string) => void | Promise<void | boolean>;
@@ -15,7 +17,7 @@ interface Props {
onPasswordChange?: (password: string) => void;
}
function AccountNamePasswordCreation ({ buttonLabel, isBusy, onBackClick, onCreate, onNameChange, onPasswordChange }: Props): React.ReactElement<Props> {
function AccountNamePasswordCreation ({ buttonLabel, error, isBusy, onBackClick, onCreate, onNameChange, onPasswordChange }: Props): React.ReactElement<Props> {
const [name, setName] = useState<string | null>(null);
const [password, setPassword] = useState<string | null>(null);
@@ -62,6 +64,11 @@ function AccountNamePasswordCreation ({ buttonLabel, isBusy, onBackClick, onCrea
onChange={_onNameChange}
/>
<Password onChange={_onPasswordChange} />
{error && (
<Warning isDanger>
{error}
</Warning>
)}
<VerticalSpace />
{onBackClick && buttonLabel && (
<ButtonArea>
@@ -51,7 +51,7 @@ interface AccountTestGenesisJson extends AccountTestJson {
genesisHash: HexString;
}
const externalAccount = { address: '5EeaoDj4VDk8V6yQngKBaCD5MpJUCHrhYjVhBjgMHXoYon1s', expectedIconTheme: 'polkadot', isExternal: true, name: 'External Account', type: 'sr25519' } as AccountJson;
const externalAccount = { address: '5EeaoDj4VDk8V6yQngKBaCD5MpJUCHrhYjVhBjgMHXoYon1s', expectedIconTheme: 'polkadot', isExternal: true, name: 'External Account', type: 'dilithium65' } as AccountJson;
const hardwareAccount = {
address: 'HDE6uFdw53SwUyfKSsjwZNmS2sziWMPuY6uJhGHcFzLYRaJ',
expectedIconTheme: 'polkadot',
@@ -60,14 +60,13 @@ const hardwareAccount = {
isExternal: true,
isHardware: true,
name: 'Hardware Account',
type: 'sr25519'
type: 'dilithium65'
} as AccountJson;
const accounts = [
{ address: '5HSDXAC3qEMkSzZK377sTD1zJhjaPiX5tNWppHx2RQMYkjaJ', expectedIconTheme: 'polkadot', name: 'ECDSA Account', type: 'ecdsa' },
{ address: '5FjgD3Ns2UpnHJPVeRViMhCttuemaRXEqaD8V5z4vxcsUByA', expectedIconTheme: 'polkadot', name: 'Ed Account', type: 'ed25519' },
{ address: '5Ggap6soAPaP5UeNaiJsgqQwdVhhNnm6ez7Ba1w9jJ62LM2Q', expectedIconTheme: 'polkadot', name: 'Parent Sr Account', type: 'sr25519' },
{ address: '0xd5D81CD4236a43F48A983fc5B895975c511f634D', expectedIconTheme: 'ethereum', name: 'Ethereum', type: 'ethereum' },
{ address: '5HSDXAC3qEMkSzZK377sTD1zJhjaPiX5tNWppHx2RQMYkjaJ', expectedIconTheme: 'polkadot', name: 'ECDSA Account', type: 'dilithium87' },
{ address: '5FjgD3Ns2UpnHJPVeRViMhCttuemaRXEqaD8V5z4vxcsUByA', expectedIconTheme: 'polkadot', name: 'Ed Account', type: 'dilithium65' },
{ address: '5Ggap6soAPaP5UeNaiJsgqQwdVhhNnm6ez7Ba1w9jJ62LM2Q', expectedIconTheme: 'polkadot', name: 'Parent Sr Account', type: 'dilithium65' },
{ ...externalAccount },
{ ...hardwareAccount }
] as AccountTestJson[];
@@ -82,7 +81,7 @@ const westEndAccount = {
expectedNetworkLabel: 'Westend',
genesisHash: '0xe143f23803ac50e8f6f8e62695d1ce9e4e1d68aa36c1cd2cfd15340213f3423e',
name: 'acc',
type: 'ed25519'
type: 'dilithium65'
} as AccountTestGenesisJson;
const accountsWithGenesisHash = [
@@ -93,7 +92,7 @@ const accountsWithGenesisHash = [
expectedIconTheme: 'polkadot',
expectedNetworkLabel: 'Polkadot',
genesisHash: '0x91b171bb158e2d3848fa23a9f1c25182fb8e20313b2c1eb49219da7a70ce90c3',
type: 'sr25519'
type: 'dilithium65'
},
// with Kusama genesis Hash
{
@@ -102,7 +101,7 @@ const accountsWithGenesisHash = [
expectedIconTheme: 'polkadot',
expectedNetworkLabel: 'Kusama',
genesisHash: '0xb0a8d493285c2df73290dfb7e61f870f17b41801197a149ca93654499ea3dafe',
type: 'sr25519'
type: 'dilithium65'
},
// with Edgeware genesis Hash
{
@@ -111,7 +110,7 @@ const accountsWithGenesisHash = [
expectedIconTheme: 'substrate',
expectedNetworkLabel: 'Edgeware',
genesisHash: '0x742a2ca70c2fda6cee4f8df98d64c4c670a052d9568058982dad9d5a7a135c5b',
type: 'sr25519'
type: 'dilithium65'
}
] as AccountTestGenesisJson[];
@@ -330,7 +329,7 @@ describe('Address', () => {
name: 'Luke',
parentName: 'Dark Vador',
suri: '//42',
type: 'sr25519'
type: 'dilithium65'
} as AccountJson;
beforeAll(async () => {
@@ -21,9 +21,11 @@ import details from '../assets/details.svg';
import { useMetadata, useOutsideClick, useToast, useTranslation } from '../hooks/index.js';
import { showAccount } from '../messaging.js';
import { styled } from '../styled.js';
import { DEFAULT_PREFIX } from '../util/defaultPrefix.js';
import { DEFAULT_TYPE } from '../util/defaultType.js';
import { formatBalance } from '../util/formatBalance.js';
import getParentNameSuri from '../util/getParentNameSuri.js';
import { AccountContext, SettingsContext } from './contexts.js';
import { AccountContext, BalanceContext, SettingsContext } from './contexts.js';
import Identicon from './Identicon.js';
import Menu from './Menu.js';
import Svg from './Svg.js';
@@ -62,27 +64,18 @@ function findSubstrateAccount (accounts: AccountJson[], publicKey: Uint8Array):
) || null;
}
// find an account in our list
function findAccountByAddress (accounts: AccountJson[], _address: string): AccountJson | null {
return accounts.find(({ address }): boolean =>
address === _address
) || null;
}
// recodes an supplied address using the prefix/genesisHash, include the actual saved account & chain
function recodeAddress (address: string, accounts: AccountWithChildren[], chain: Chain | null, settings: SettingsStruct): Recoded {
// decode and create a shortcut for the encoded address
const publicKey = isHex(address) ? hexToU8a(address) : decodeAddress(address);
// find our account using the actual publicKey, and then find the associated chain
const account = findSubstrateAccount(accounts, publicKey);
const prefix = chain ? chain.ss58Format : (settings.prefix === -1 ? 42 : settings.prefix);
const prefix = chain ? chain.ss58Format : (settings.prefix === -1 ? DEFAULT_PREFIX : settings.prefix);
// always allow the actual settings to override the display
return {
account,
formatted: account?.type === 'ethereum'
? address
: encodeAddress(publicKey, prefix),
formatted: encodeAddress(publicKey, prefix),
genesisHash: account?.genesisHash,
prefix,
type: account?.type || DEFAULT_TYPE
@@ -92,11 +85,12 @@ function recodeAddress (address: string, accounts: AccountWithChildren[], chain:
const ACCOUNTS_SCREEN_HEIGHT = 550;
const defaultRecoded = { account: null, formatted: null, prefix: 42, type: DEFAULT_TYPE };
function Address ({ actions, address, children, className, genesisHash, isExternal, isHardware, isHidden, name, parentName, showVisibilityAction = false, suri, toggleActions, type: givenType }: Props): React.ReactElement<Props> {
function Address ({ actions, address, children, className, genesisHash, isExternal, isHardware, isHidden, name, parentName, showVisibilityAction = false, suri, toggleActions }: Props): React.ReactElement<Props> {
const { t } = useTranslation();
const { accounts } = useContext(AccountContext);
const balances = useContext(BalanceContext);
const settings = useContext(SettingsContext);
const [{ account, formatted, genesisHash: recodedGenesis, prefix, type }, setRecoded] = useState<Recoded>(defaultRecoded);
const [{ account, formatted, genesisHash: recodedGenesis, prefix }, setRecoded] = useState<Recoded>(defaultRecoded);
const chain = useMetadata(genesisHash || recodedGenesis, true);
const [showActionsMenu, setShowActionsMenu] = useState(false);
@@ -112,18 +106,10 @@ function Address ({ actions, address, children, className, genesisHash, isExtern
return setRecoded(defaultRecoded);
}
const account = findAccountByAddress(accounts, address);
setRecoded(
(
chain?.definition.chainType === 'ethereum' ||
account?.type === 'ethereum' ||
(!account && givenType === 'ethereum')
)
? { account, formatted: address, type: 'ethereum' }
: recodeAddress(address, accounts, chain, settings)
);
}, [accounts, address, chain, givenType, settings]);
// No Ethereum branch: this extension holds no Ethereum keys, and Quantus
// addresses are always SS58. quantus/common#6
setRecoded(recodeAddress(address, accounts, chain, settings));
}, [accounts, address, chain, settings]);
useEffect(() => {
if (!showActionsMenu) {
@@ -141,11 +127,7 @@ function Address ({ actions, address, children, className, genesisHash, isExtern
setShowActionsMenu(false);
}, [toggleActions]);
const theme = (
type === 'ethereum'
? 'ethereum'
: (chain?.icon || 'polkadot')
) as IconTheme;
const theme = (chain?.icon || 'polkadot') as IconTheme;
const _onClick = useCallback(
() => setShowActionsMenu(!showActionsMenu),
@@ -192,6 +174,10 @@ function Address ({ actions, address, children, className, genesisHash, isExtern
</>);
};
// Keyed by the address the keyring stores, not the re-encoded display form:
// the same account renders differently at different prefixes and the map has
// one key per account.
const balance = (account?.address || address) ? balances[account?.address || address || ''] : undefined;
const parentNameSuri = getParentNameSuri(parentName, suri);
return (
@@ -236,6 +222,14 @@ function Address ({ actions, address, children, className, genesisHash, isExtern
</div>
)
}
{balance && (
<div
className='balance'
data-field='balance'
>
{formatBalance(balance)}
</div>
)}
{chain?.genesisHash && chain?.name && (
<div
className='banner chain'
@@ -265,7 +259,10 @@ function Address ({ actions, address, children, className, genesisHash, isExtern
title={t('copy address')}
/>
</CopyToClipboard>
{(actions || showVisibilityAction) && (
{/* Visibility is a keyring flag: it hides an account from dapps. An
address with no keyring account behind it (a wormhole address)
has nothing to hide, and toggling it would only error. */}
{(actions || showVisibilityAction) && account && (
<FontAwesomeIcon
className={isHidden ? 'hiddenIcon' : 'visibleIcon'}
icon={isHidden ? faEyeSlash : faEye}
@@ -328,6 +325,13 @@ export default styled(Address)<Props>`
}
}
.balance {
color: var(--labelColor);
font-size: var(--labelFontSize);
line-height: var(--labelLineHeight);
white-space: nowrap;
}
.addressDisplay {
display: flex;
justify-content: space-between;
@@ -0,0 +1,120 @@
// Copyright 2019-2026 @polkadot/extension-ui authors & contributors
// SPDX-License-Identifier: Apache-2.0
import type { ResponseWalletPreview } from '@polkadot/extension-base/background/types';
import React, { useContext } from 'react';
import { decodeAddress, encodeAddress } from '@polkadot/util-crypto';
import { useTranslation } from '../hooks/index.js';
import { styled } from '../styled.js';
import { DEFAULT_PREFIX } from '../util/defaultPrefix.js';
import { SettingsContext } from './contexts.js';
import Identicon from './Identicon.js';
interface Props {
className?: string;
name?: string | null;
preview: ResponseWalletPreview | null;
}
/**
* The three account-0 addresses a secret gives, before anything is saved.
*
* All three, because a person may know their wallet by any of them: the account
* they signed with in quantus-cli (ML-DSA-65 or ML-DSA-87, depending on when it
* was made), or the wormhole address their mining rewards went to. Showing one
* and deriving the others silently is how an import used to look wrong.
*/
function WalletPreview ({ className, name, preview }: Props): React.ReactElement<Props> {
const { t } = useTranslation();
const settings = useContext(SettingsContext);
const prefix = settings.prefix === -1 ? DEFAULT_PREFIX : settings.prefix;
const rows = [
{ address: preview?.mldsa65, label: 'ML-DSA-65' },
{ address: preview?.mldsa87, label: 'ML-DSA-87' },
{ address: preview?.wormhole, label: t('Wormhole') }
];
return (
<div className={className}>
<div
className='name'
data-field='name'
>
{name || t('<unknown>')}
</div>
{rows.map(({ address, label }) => {
const formatted = address ? encodeAddress(decodeAddress(address), prefix) : null;
return (
<div
className='row'
data-field={label}
key={label}
>
<Identicon
className='icon'
prefix={prefix}
value={formatted}
/>
<span className='label'>{label}</span>
<span className='address'>
{formatted || (preview && label === t('Wormhole')
? t('none: a raw seed has no wormhole account')
: t('<unknown>'))}
</span>
</div>
);
})}
</div>
);
}
export default styled(WalletPreview)<Props>`
background: var(--boxBackground);
border: 1px solid var(--boxBorderColor);
border-radius: 4px;
box-sizing: border-box;
margin-bottom: 8px;
padding: 8px 12px;
.name {
font-size: 16px;
line-height: 22px;
margin-bottom: 4px;
}
.row {
align-items: center;
display: flex;
gap: 8px;
min-height: 26px;
}
.icon {
height: 20px;
width: 20px;
svg, img {
height: 20px !important;
width: 20px !important;
}
}
.label {
color: var(--labelColor);
flex: 0 0 72px;
font-size: 12px;
}
.address {
color: var(--labelColor);
font-family: var(--fontFamilyMono, monospace);
font-size: 11px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
`;
@@ -1,7 +1,7 @@
// Copyright 2019-2026 @polkadot/extension-ui authors & contributors
// SPDX-License-Identifier: Apache-2.0
import type { AccountsContext, AuthorizeRequest, MetadataRequest, SigningRequest } from '@polkadot/extension-base/background/types';
import type { AccountBalances, AccountsContext, AuthorizeRequest, MetadataRequest, SigningRequest, WalletInfo } from '@polkadot/extension-base/background/types';
import type { SettingsStruct } from '@polkadot/ui-settings/types';
import type { Theme } from './themes.js';
@@ -11,9 +11,13 @@ import { settings } from '@polkadot/ui-settings';
const noop = (): void => undefined;
const AccountContext = React.createContext<AccountsContext>({ accounts: [], hierarchy: [], master: undefined });
const AccountContext = React.createContext<AccountsContext>({ accounts: [], hierarchy: [] });
const ActionContext = React.createContext<(to?: string) => void>(noop);
const AuthorizeReqContext = React.createContext<AuthorizeRequest[]>([]);
// Empty until a node has answered, and empty again if one never does — an
// account list that works without balances beats one that fails with them.
const BalanceContext = React.createContext<AccountBalances>({});
const WalletContext = React.createContext<WalletInfo[]>([]);
const MediaContext = React.createContext<boolean>(false);
const MetadataReqContext = React.createContext<MetadataRequest[]>([]);
const SettingsContext = React.createContext<SettingsStruct>(settings.get());
@@ -21,4 +25,4 @@ const SigningReqContext = React.createContext<SigningRequest[]>([]);
const ThemeSwitchContext = React.createContext<(theme: Theme) => void>(noop);
const ToastContext = React.createContext<({show: (message: string) => void})>({ show: noop });
export { AccountContext, ActionContext, AuthorizeReqContext, MediaContext, MetadataReqContext, SettingsContext, SigningReqContext, ThemeSwitchContext, ToastContext };
export { AccountContext, ActionContext, AuthorizeReqContext, BalanceContext, MediaContext, MetadataReqContext, SettingsContext, SigningReqContext, ThemeSwitchContext, ToastContext, WalletContext };
@@ -41,4 +41,5 @@ export * from './themes.js';
export { default as ValidatedInput } from './ValidatedInput.js';
export { default as VerticalSpace } from './VerticalSpace.js';
export { default as View } from './View.js';
export { default as WalletPreview } from './WalletPreview.js';
export { default as Warning } from './Warning.js';
+1
View File
@@ -1,6 +1,7 @@
// Copyright 2019-2026 @polkadot/extension-ui authors & contributors
// SPDX-License-Identifier: Apache-2.0
export { default as useBalances } from './useBalances.js';
export { default as useGenesisHashOptions } from './useGenesisHashOptions.js';
export { default as useIsMounted } from './useIsMounted.js';
export { default as useIsPopup } from './useIsPopup.js';
@@ -0,0 +1,52 @@
// Copyright 2019-2026 @polkadot/extension-ui authors & contributors
// SPDX-License-Identifier: Apache-2.0
import type { AccountBalances } from '@polkadot/extension-base/background/types';
import { useEffect, useState } from 'react';
import { subscribeBalances, unsubscribeBalances } from '../messaging.js';
import { getBalanceEndpoint, onBalanceEndpointChange } from '../util/balanceEndpoint.js';
/**
* Balances for every account, from the endpoint in settings.
*
* The endpoint is passed to the background rather than read there: it lives in
* `localStorage`, which an MV3 service worker does not have.
*
* An empty string means the user has turned balances off, and then nothing
* connects at all which is the point of being able to turn them off. A node
* asked for balances learns which accounts belong to one person, and somebody
* who would rather not say can either point this at their own node or stop
* asking entirely.
*/
export default function useBalances (): AccountBalances {
const [balances, setBalances] = useState<AccountBalances>({});
const [endpoint, setEndpoint] = useState(getBalanceEndpoint);
// Follow the setting as it changes. Read once at render, a new endpoint
// waited for something unrelated to re-render the popup.
useEffect(() => onBalanceEndpointChange(setEndpoint), []);
useEffect(() => {
// The old endpoint's balances are another chain's; show none rather than
// those while the new one loads.
setBalances({});
if (!endpoint) {
return;
}
let isCurrent = true;
const id = subscribeBalances(endpoint, (next) => isCurrent && setBalances(next));
id.catch(console.error);
return (): void => {
isCurrent = false;
id.then(unsubscribeBalances).catch(console.error);
};
}, [endpoint]);
return balances;
}
+69 -25
View File
@@ -1,10 +1,9 @@
// Copyright 2019-2026 @polkadot/extension-ui authors & contributors
// SPDX-License-Identifier: Apache-2.0
/* global chrome */
/* eslint-disable no-redeclare */
import type { AccountJson, AllowedPath, AuthorizeRequest, ConnectedTabsUrlResponse, MessageTypes, MessageTypesWithNoSubscriptions, MessageTypesWithNullRequest, MessageTypesWithSubscriptions, MetadataRequest, RequestTypes, ResponseAuthorizeList, ResponseDeriveValidate, ResponseJsonGetAccountInfo, ResponseSigningIsLocked, ResponseTypes, SeedLengths, SigningRequest, SubscriptionMessageTypes } from '@polkadot/extension-base/background/types';
import type { AccountBalances, AccountJson, AllowedPath, AuthorizeRequest, ConnectedTabsUrlResponse, MessageTypes, MessageTypesWithNoSubscriptions, MessageTypesWithNullRequest, MessageTypesWithSubscriptions, MetadataRequest, RequestTypes, ResponseAuthorizeList, ResponseJsonGetAccountInfo, ResponseSigningIsLocked, ResponseTypes, ResponseWalletPreview, SeedLengths, SigningRequest, SubscriptionMessageTypes, WalletInfo, WormholeBalance } from '@polkadot/extension-base/background/types';
import type { Message } from '@polkadot/extension-base/types';
import type { Chain } from '@polkadot/extension-chains/types';
import type { MetadataDef } from '@polkadot/extension-inject/types';
@@ -15,7 +14,7 @@ import type { KeypairType } from '@polkadot/util-crypto/types';
import { PORT_EXTENSION } from '@polkadot/extension-base/defaults';
import { getId } from '@polkadot/extension-base/utils/getId';
import { ensurePortConnection } from '@polkadot/extension-base/utils/portUtils';
import { createPortClient } from '@polkadot/extension-base/utils/portUtils';
import { metadataExpand } from '@polkadot/extension-chains';
import allChains from './util/chains.js';
@@ -33,8 +32,6 @@ type Handlers = Record<string, Handler>;
const handlers: Handlers = {};
let port: chrome.runtime.Port | undefined;
function onPortMessageHandler (data: Message['data']): void {
const handler = handlers[data.id];
@@ -58,15 +55,17 @@ function onPortMessageHandler (data: Message['data']): void {
}
}
function onPortDisconnectHandler (): void {
port = undefined;
}
const portConfig = {
onPortDisconnectHandler,
onPortMessageHandler,
const client = createPortClient({
// the background went away before answering; nothing will answer now
onLost: (ids: string[]): void => {
for (const id of ids) {
handlers[id]?.reject(new Error('The extension background restarted before answering. Try again.'));
delete handlers[id];
}
},
onMessage: onPortMessageHandler,
portName: PORT_EXTENSION
};
});
function sendMessage<TMessageType extends MessageTypesWithNullRequest>(message: TMessageType): Promise<ResponseTypes[TMessageType]>;
function sendMessage<TMessageType extends MessageTypesWithNoSubscriptions>(message: TMessageType, request: RequestTypes[TMessageType]): Promise<ResponseTypes[TMessageType]>;
@@ -77,10 +76,7 @@ function sendMessage<TMessageType extends MessageTypes> (message: TMessageType,
handlers[id] = { reject, resolve, subscriber };
ensurePortConnection(port, portConfig).then((connectedPort) => {
connectedPort.postMessage({ id, message, request: request || {} });
port = connectedPort;
}).catch((error) => {
client.send({ id, message, request: request || {} }, !!subscriber).catch((error) => {
console.error(`Failed to send message: ${(error as Error).message}`);
reject(error);
});
@@ -196,10 +192,66 @@ export async function rejectMetaRequest (id: string): Promise<boolean> {
return sendMessage('pri(metadata.reject)', { id });
}
export async function subscribeWallets (cb: (wallets: WalletInfo[]) => void): Promise<boolean> {
return sendMessage('pri(wallets.subscribe)', null, cb);
}
/** The account-0 addresses a recovery phrase or seed would give. Throws on anything else. */
export async function previewWallet (secret: string): Promise<ResponseWalletPreview> {
return sendMessage('pri(wallets.preview)', { secret });
}
/** Resolves to the new wallet's id. */
export async function createWallet (name: string, password: string, secret: string, genesisHash?: HexString | null): Promise<string> {
return sendMessage('pri(wallets.create)', { genesisHash, name, password, secret });
}
export async function addWalletAccount (id: string, password: string): Promise<boolean> {
return sendMessage('pri(wallets.addAccount)', { id, password });
}
export async function renameWallet (id: string, name: string): Promise<boolean> {
return sendMessage('pri(wallets.rename)', { id, name });
}
export async function forgetWallet (id: string): Promise<boolean> {
return sendMessage('pri(wallets.forget)', { id });
}
/** What one wallet account's wormhole addresses can still spend. Slow: seconds, not milliseconds. */
export async function wormholeBalance (id: string, accountIndex: number, endpoint: string, observer: string): Promise<WormholeBalance> {
return sendMessage('pri(wallets.wormholeBalance)', { accountIndex, endpoint, id, observer });
}
export async function wormholeUnlock (id: string, accountIndex: number, password: string, counts: WormholeBalance['counts']): Promise<boolean> {
return sendMessage('pri(wallets.wormholeUnlock)', { accountIndex, counts, id, password });
}
export async function subscribeAccounts (cb: (accounts: AccountJson[]) => void): Promise<boolean> {
return sendMessage('pri(accounts.subscribe)', null, cb);
}
/**
* Balances for every account, from the endpoint in settings.
*
* The endpoint travels with the request because `@polkadot/ui-settings` persists
* to `localStorage`, which the MV3 service worker holding the connection does
* not have.
*/
/** Resolves to the subscription's id, which `unsubscribeBalances` takes. */
export async function subscribeBalances (endpoint: string, cb: (balances: AccountBalances) => void): Promise<string> {
return sendMessage('pri(balances.subscribe)', { endpoint }, cb);
}
export async function unsubscribeBalances (id: string): Promise<boolean> {
// stop listening and stop replaying first: whatever the background says
// next about this id is no longer wanted
client.forget(id);
delete handlers[id];
return sendMessage('pri(balances.unsubscribe)', { id });
}
export async function subscribeAuthorizeRequests (cb: (accounts: AuthorizeRequest[]) => void): Promise<boolean> {
return sendMessage('pri(authorize.requests)', null, cb);
}
@@ -236,14 +288,6 @@ export async function validateSeed (suri: string, type?: KeypairType): Promise<{
return sendMessage('pri(seed.validate)', { suri, type });
}
export async function validateDerivationPath (parentAddress: string, suri: string, parentPassword: string): Promise<ResponseDeriveValidate> {
return sendMessage('pri(derivation.validate)', { parentAddress, parentPassword, suri });
}
export async function deriveAccount (parentAddress: string, suri: string, parentPassword: string, name: string, password: string, genesisHash: HexString | null): Promise<boolean> {
return sendMessage('pri(derivation.create)', { genesisHash, name, parentAddress, parentPassword, password, suri });
}
export async function windowOpen (path: AllowedPath): Promise<boolean> {
return sendMessage('pri(window.open)', path);
}
+36 -6
View File
@@ -1,16 +1,16 @@
// Copyright 2019-2026 @polkadot/extension-ui authors & contributors
// SPDX-License-Identifier: Apache-2.0
import { faArrowLeft, faCog, faPlusCircle, faSearch } from '@fortawesome/free-solid-svg-icons';
import { faArrowLeft, faCog, faExpand, faPlusCircle, faSearch } from '@fortawesome/free-solid-svg-icons';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import React, { useCallback, useContext, useEffect, useMemo, useRef, useState } from 'react';
import logo from '../assets/pjs.svg';
import logo from '../assets/sigil.svg';
import { ActionContext } from '../components/index.js';
import InputFilter from '../components/InputFilter.js';
import Link from '../components/Link.js';
import { useOutsideClick, useTranslation } from '../hooks/index.js';
import { getConnectedTabsUrl } from '../messaging.js';
import { useIsPopup, useOutsideClick, useTranslation } from '../hooks/index.js';
import { getConnectedTabsUrl, windowOpen } from '../messaging.js';
import { styled } from '../styled.js';
import MenuAdd from './MenuAdd.js';
import MenuSettings from './MenuSettings.js';
@@ -35,6 +35,23 @@ function Header ({ children, className = '', onFilter, showAdd, showBackArrow, s
const [filter, setFilter] = useState('');
const [connectedTabsUrl, setConnectedTabsUrl] = useState<string[]>([]);
const { t } = useTranslation();
const isPopup = useIsPopup();
/**
* Reopen the extension as an ordinary tab.
*
* This already existed, at the bottom of the settings menu. It is here as well
* because of *when* somebody wants it: the popup closes the moment focus
* leaves it, which is precisely 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.
*/
const _onWindowOpen = useCallback(
(): void => {
windowOpen('/').catch(console.error);
},
[]
);
const addIconRef = useRef(null);
const addMenuRef = useRef<HTMLDivElement>(null);
const setIconRef = useRef(null);
@@ -113,7 +130,7 @@ function Header ({ children, className = '', onFilter, showAdd, showBackArrow, s
/>
)
}
<span className='logoText'>{text || 'polkadot{.js}'}</span>
<span className='logoText'>{text || 'blackbeard'}</span>
</div>
{showSearch && (
<div className={`searchBarWrapper ${isSearchOpen ? 'selected' : ''}`}>
@@ -145,6 +162,19 @@ function Header ({ children, className = '', onFilter, showAdd, showBackArrow, s
</div>
)}
<div className='popupMenus'>
{isPopup && (
<div
className='popupToggle'
onClick={_onWindowOpen}
title={t('Open in a tab')}
>
<FontAwesomeIcon
className='expandIcon'
icon={faExpand}
size='lg'
/>
</div>
)}
{showAdd && (
<div
className='popupToggle'
@@ -288,7 +318,7 @@ export default React.memo(styled(Header)<Props>`
}
}
.plusIcon, .cogIcon, .searchIcon {
.plusIcon, .cogIcon, .searchIcon, .expandIcon {
color: var(--iconNeutralColor);
&.selected {
+3 -15
View File
@@ -1,11 +1,11 @@
// Copyright 2019-2026 @polkadot/extension-ui authors & contributors
// SPDX-License-Identifier: Apache-2.0
import { faCodeBranch, faEye, faFileExport, faFileUpload, faKey, faPlusCircle } from '@fortawesome/free-solid-svg-icons';
import { faEye, faFileExport, faFileUpload, faKey, faPlusCircle } from '@fortawesome/free-solid-svg-icons';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import React, { useCallback, useContext } from 'react';
import React, { useCallback } from 'react';
import { AccountContext, Link, Menu, MenuDivider, MenuItem } from '../components/index.js';
import { Link, Menu, MenuDivider, MenuItem } from '../components/index.js';
import { useIsPopup, useTranslation } from '../hooks/index.js';
import { windowOpen } from '../messaging.js';
import { styled } from '../styled.js';
@@ -19,7 +19,6 @@ const jsonPath = '/account/restore-json';
function MenuAdd ({ className, reference }: Props): React.ReactElement<Props> {
const { t } = useTranslation();
const { master } = useContext(AccountContext);
const isPopup = useIsPopup();
const _openJson = useCallback(
@@ -40,17 +39,6 @@ function MenuAdd ({ className, reference }: Props): React.ReactElement<Props> {
</Link>
</MenuItem>
<MenuDivider />
{!!master && (
<>
<MenuItem className='menuItem'>
<Link to={`/account/derive/${master.address}`}>
<FontAwesomeIcon icon={faCodeBranch} />
<span>{t('Derive from an account')}</span>
</Link>
</MenuItem>
<MenuDivider />
</>
)}
<MenuItem className='menuItem'>
<Link to={'/account/export-all'}>
<FontAwesomeIcon icon={faFileExport} />
@@ -4,13 +4,17 @@
import { faExpand, faTasks } from '@fortawesome/free-solid-svg-icons';
import React, { useCallback, useContext, useEffect, useMemo, useState } from 'react';
import { QUANTUS_ENDPOINTS } from '@polkadot/extension-base/defaults';
import { settings } from '@polkadot/ui-settings';
import { ActionContext, ActionText, Checkbox, chooseTheme, Dropdown, Menu, MenuDivider, MenuItem, Switch, ThemeSwitchContext } from '../components/index.js';
import { ActionContext, ActionText, Checkbox, chooseTheme, Dropdown, InputWithLabel, Menu, MenuDivider, MenuItem, Switch, ThemeSwitchContext } from '../components/index.js';
import { useIsPopup, useTranslation } from '../hooks/index.js';
import { setNotification, windowOpen } from '../messaging.js';
import { styled } from '../styled.js';
import { getBalanceEndpoint, setBalanceEndpoint } from '../util/balanceEndpoint.js';
import { DEFAULT_PREFIX } from '../util/defaultPrefix.js';
import getLanguageOptions from '../util/getLanguageOptions.js';
import { getWormholeObserver, OBSERVER_OPTIONS, setWormholeObserver } from '../util/wormholeObserver.js';
interface Option {
text: string;
@@ -25,6 +29,19 @@ interface Props {
const notificationOptions = ['Extension', 'PopUp', 'Window']
.map((item) => ({ text: item, value: item.toLowerCase() }));
/**
* Where to read balances from.
*
* Known endpoints plus "off" plus whatever the user types. Asking a node for
* balances tells that node which accounts belong to one person; somebody who
* would rather not say that to a default can point this at their own node, or
* turn it off and have the extension make no network requests at all.
*/
const endpointOptions: Option[] = [
...QUANTUS_ENDPOINTS,
{ text: 'Off — do not read balances', value: '' }
].map(({ text, value }): Option => ({ text, value }));
const prefixOptions = settings.availablePrefixes
.filter(({ value }) => value !== -1)
.map(({ text, value }): Option => ({ text, value: `${value}` }));
@@ -32,7 +49,9 @@ const prefixOptions = settings.availablePrefixes
function MenuSettings ({ className, reference }: Props): React.ReactElement<Props> {
const { t } = useTranslation();
const [camera, setCamera] = useState(settings.camera === 'on');
const [prefix, setPrefix] = useState(`${settings.prefix === -1 ? 42 : settings.prefix}`);
const [prefix, setPrefix] = useState(`${settings.prefix === -1 ? DEFAULT_PREFIX : settings.prefix}`);
const [endpoint, setEndpoint] = useState(getBalanceEndpoint());
const [observer, setObserver] = useState(getWormholeObserver());
const [notification, updateNotification] = useState(settings.notification);
const [theme, setTheme] = useState(chooseTheme());
const setThemeContext = useContext(ThemeSwitchContext);
@@ -51,6 +70,36 @@ function MenuSettings ({ className, reference }: Props): React.ReactElement<Prop
}, []
);
// The text field is authoritative; the dropdown only fills it. An endpoint the
// list has never heard of is the point of the feature, not an edge case.
const _onChangeEndpoint = useCallback(
(value: string): void => {
setEndpoint(value);
setBalanceEndpoint(value);
}, []
);
// Typing is not choosing: saving each keystroke would connect to `w`, `ws`,
// `ws:` and so on. The typed endpoint is used on Enter or on leaving the field.
const _onCommitEndpoint = useCallback(
(): void => {
endpoint !== getBalanceEndpoint() && setBalanceEndpoint(endpoint);
}, [endpoint]
);
const _onChangeObserver = useCallback(
(value: string): void => {
setObserver(value);
setWormholeObserver(value);
}, []
);
const _onCommitObserver = useCallback(
(): void => {
observer !== getWormholeObserver() && setWormholeObserver(observer);
}, [observer]
);
const _onChangeNotification = useCallback(
(value: string): void => {
setNotification(value).catch(console.error);
@@ -116,6 +165,50 @@ function MenuSettings ({ className, reference }: Props): React.ReactElement<Prop
value={`${prefix}`}
/>
</MenuItem>
<MenuItem
className='setting'
title={t('Read balances from')}
>
<Dropdown
className='dropdown'
label=''
onChange={_onChangeEndpoint}
options={endpointOptions.some(({ value }) => value === endpoint)
? endpointOptions
: [...endpointOptions, { text: t('Custom'), value: endpoint }]}
value={endpoint}
/>
<InputWithLabel
label={t('or a node of your own')}
onBlur={_onCommitEndpoint}
onChange={setEndpoint}
onEnter={_onCommitEndpoint}
placeholder='wss://…'
value={endpoint}
/>
</MenuItem>
<MenuItem
className='setting'
title={t('Look up wormhole deposits with')}
>
<Dropdown
className='dropdown'
label=''
onChange={_onChangeObserver}
options={OBSERVER_OPTIONS.some(({ value }) => value === observer)
? OBSERVER_OPTIONS
: [...OBSERVER_OPTIONS, { text: t('Custom'), value: observer }]}
value={observer}
/>
<InputWithLabel
label={t('or an observer of your own')}
onBlur={_onCommitObserver}
onChange={setObserver}
onEnter={_onCommitObserver}
placeholder='https://…'
value={observer}
/>
</MenuItem>
<MenuItem
className='setting'
title={t('Language')}
@@ -0,0 +1,69 @@
// Copyright 2019-2026 @polkadot/extension-ui authors & contributors
// SPDX-License-Identifier: Apache-2.0
import { DEFAULT_ENDPOINT } from '@polkadot/extension-base/defaults';
/**
* The node this extension reads balances from.
*
* Kept under its own key rather than in `@polkadot/ui-settings`. That package's
* `apiUrl` means something else the endpoint polkadot-js *apps* talks to and
* it ships a default of `ws://127.0.0.1:9944/`, so reusing it would point the
* extension at a local node nobody is running and show no balances at all, with
* nothing on screen to say why.
*
* Stored in `localStorage`, which is where `ui-settings` keeps its own and is
* available in the popup. The background never reads it an MV3 service worker
* has no `localStorage` so the value travels with the subscription request.
*/
const KEY = 'quantus:balanceEndpoint';
/**
* The configured endpoint, or the default when the user has never chosen.
*
* An empty string is a *choice*: balances off, and then nothing connects at all.
* That is distinct from never having chosen, which is why this reads the raw
* entry rather than treating `''` as absent.
*/
export function getBalanceEndpoint (): string {
try {
const stored = localStorage.getItem(KEY);
return stored === null ? DEFAULT_ENDPOINT : stored;
} catch {
// Storage can be unavailable or denied. Balances are a convenience; failing
// to read a setting must not take the account list with it.
return DEFAULT_ENDPOINT;
}
}
const CHANGED = 'quantus:balanceEndpointChanged';
export function setBalanceEndpoint (endpoint: string): void {
try {
localStorage.setItem(KEY, endpoint);
} catch (error) {
console.error(error);
}
// `storage` events reach other extension pages (a popup and a tab open side
// by side) but never the page that made the change, so tell this one directly.
window.dispatchEvent(new Event(CHANGED));
}
/** Call `cb` whenever the endpoint changes, here or in another extension page. */
export function onBalanceEndpointChange (cb: (endpoint: string) => void): () => void {
const onChanged = (): void => cb(getBalanceEndpoint());
const onStorage = ({ key }: StorageEvent): void => {
key === KEY && onChanged();
};
window.addEventListener(CHANGED, onChanged);
window.addEventListener('storage', onStorage);
return (): void => {
window.removeEventListener(CHANGED, onChanged);
window.removeEventListener('storage', onStorage);
};
}
+16 -2
View File
@@ -3,10 +3,24 @@
import type { MetadataDefBase } from '@polkadot/extension-inject/types';
import { selectableNetworks } from '@polkadot/networks';
import { isQuantumSafe, selectableNetworks } from '@polkadot/networks';
// The chains this extension offers.
//
// Filtered here rather than in @polkadot/networks. An earlier attempt gated
// `selectableNetworks` itself, which broke @polkadot/api at import:
// @polkadot/types-known looks up every chain it holds upgrade history for and
// throws when one is missing. Those lists are a library's view of what Substrate
// networks exist; which of them a *wallet* offers is the wallet's decision, and
// this is the wallet.
//
// The filter is post-quantum accounts. Everything else in the registry uses a
// discrete-log scheme — *25519, Sr25519, Ed25519, secp256k1 — all of which Shor's
// algorithm breaks at once, so such a chain has no quantum-safe account type to
// offer whatever else is true of it. A null standardAccount counts as unsafe:
// unknown is not the same as safe.
const hashes: MetadataDefBase[] = selectableNetworks
.filter(({ genesisHash }) => !!genesisHash.length)
.filter(({ genesisHash, standardAccount }) => !!genesisHash.length && isQuantumSafe(standardAccount))
.map((network) => ({
chain: network.displayName,
genesisHash: network.genesisHash[0],
@@ -0,0 +1,64 @@
// Copyright 2019-2026 @polkadot/extension-ui authors & contributors
// SPDX-License-Identifier: Apache-2.0
/// <reference types="@polkadot/dev-test/globals.d.ts" />
import type { Chain } from '@polkadot/extension-chains/types';
import { HEISENBERG_SPEC, heisenbergMetadataDef } from '@polkadot/extension-base/test/metadata';
import { metadataExpand } from '@polkadot/extension-chains';
import { bnToBn, u8aToHex } from '@polkadot/util';
import { decodeMethod } from './decodeMethod.js';
describe('decoding a call for the approval screen', (): void => {
const chain: Chain = metadataExpand(heisenbergMetadataDef(), false);
const runtime = chain.runtime;
if (!runtime) {
throw new Error('the fixture metadata did not produce a runtime');
}
// crystal_bob on Heisenberg, as an account id and as the address it renders to.
const BOB_ID = '0x300bb607ba60e89461d2f9005668231ceb30237b33db53a614164b8590965519';
const BOB = 'qzkYEQv8tQsmniZYdame3Cku18RL5g9bGK9Pdydq5TMPdpE3y';
const transfer = u8aToHex(runtime.encodeCall('Balances', 'transfer_keep_alive', {
dest: { Id: BOB_ID },
value: '1000000000'
}));
// What the screen exists to do. Before this it went through
// registry.createType('Call', …), which cannot work on a chain @polkadot/types
// cannot describe — so every transaction rendered as raw hex, which is the
// failure mode where somebody approves bytes nobody read to them.
it('names the call and its arguments as the runtime does', (): void => {
const { args, name } = decodeMethod(transfer, chain, bnToBn(HEISENBERG_SPEC));
expect(name).toEqual('Balances.transfer_keep_alive');
expect(args?.['value']).toEqual('1000000000');
// An address, not 32 bytes of hex — somebody approving a transfer has to be
// able to check the recipient against what they meant to send to.
expect(args?.['dest']).toEqual({ Id: BOB });
});
// Metadata from a different runtime decodes a call into something plausible and
// wrong. Showing hex is worse than showing a decoded call; showing the *wrong*
// decoded call is worse than both.
it('refuses to decode against a different spec version', (): void => {
expect(decodeMethod(transfer, chain, bnToBn(HEISENBERG_SPEC + 1))).toEqual({ args: null, name: null });
});
// A chain the extension holds no metadata for. It will not sign one of these
// either — see RequestExtrinsicSign — but the screen still has to render, and
// rendering hex is the honest thing to show.
it('returns nothing readable when there is no runtime', (): void => {
expect(decodeMethod(transfer, { ...chain, runtime: null }, bnToBn(HEISENBERG_SPEC))).toEqual({ args: null, name: null });
});
// Bytes that are not a call at all. The screen must not throw: an exception
// here lands between somebody and their funds.
it('returns nothing readable for bytes that are not a call', (): void => {
expect(decodeMethod('0xdeadbeef', chain, bnToBn(HEISENBERG_SPEC))).toEqual({ args: null, name: null });
});
});
@@ -0,0 +1,64 @@
// Copyright 2019-2026 @polkadot/extension-ui authors & contributors
// SPDX-License-Identifier: Apache-2.0
import type { Chain } from '@polkadot/extension-chains/types';
import type { BN } from '@polkadot/util';
import { hexToU8a } from '@polkadot/util';
export interface Decoded {
/** The call's arguments, by the names the runtime gives them. */
args: Record<string, unknown> | null;
/** `pallet.call`, as the runtime names them. */
name: string | null;
}
const UNDECODED: Decoded = { args: null, name: null };
export function displayDecodeVersion (message: string, chain: Chain, specVersion: BN): string {
return `${message}: chain=${chain.name}, specVersion=${chain.specVersion.toString()} (request specVersion=${specVersion.toString()})`;
}
/**
* Decode the call a user is being asked to approve, using the runtime's own
* description of itself.
*
* Not `registry.createType('Call', data)`. `@polkadot/types` cannot hold this
* chain's metadata it caps fixed arrays at 2048 bytes and the ML-DSA signature
* types are 5261 and 7219 so the approval screen would fall back to raw hex for
* every transaction, which is the failure mode where somebody approves bytes
* nobody read to them. See quantus/api#1.
*
* The spec-version check is kept from upstream and matters more here than it
* looks: metadata from a different runtime decodes a call into something
* plausible and wrong, and showing that is worse than showing hex.
*
* Returns nulls rather than throwing. A call this cannot read is one the caller
* should render as hex, not an exception on the screen standing between someone
* and their funds.
*/
export function decodeMethod (data: string, chain: Chain, specVersion: BN): Decoded {
try {
if (!specVersion.eqn(chain.specVersion)) {
console.log(displayDecodeVersion('Outdated metadata to decode', chain, specVersion));
return UNDECODED;
}
if (!chain.runtime) {
return UNDECODED;
}
// `{ Pallet: { call_name: { arg: value } } }` — two nested variants, named by
// the runtime rather than by anything in this repo.
const decoded = chain.runtime.decodeCall(hexToU8a(data)) as Record<string, Record<string, Record<string, unknown>>>;
const pallet = Object.keys(decoded)[0];
const call = Object.keys(decoded[pallet])[0];
return { args: decoded[pallet][call], name: `${pallet}.${call}` };
} catch (error) {
console.error(`${displayDecodeVersion('Error decoding method', chain, specVersion)}:: ${(error as Error).message}`);
return UNDECODED;
}
}
@@ -0,0 +1,18 @@
// Copyright 2019-2026 @polkadot/extension-ui authors & contributors
// SPDX-License-Identifier: Apache-2.0
/**
* The SS58 prefix to display an address at when nothing else has decided.
*
* Upstream falls back to 42, 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 with
* `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; this is only the
* fallback, and the user can still override it in settings.
*/
export const DEFAULT_PREFIX = 189;
@@ -3,4 +3,11 @@
import type { KeypairType } from '@polkadot/util-crypto/types';
export const DEFAULT_TYPE: KeypairType = 'sr25519';
/**
* The scheme new accounts use.
*
* ML-DSA-87 (`dilithium87`) is the legacy parameter set accounts predating the
* recorded scheme, and the dev-genesis accounts so it must remain importable
* but is never offered as a choice.
*/
export const DEFAULT_TYPE: KeypairType = 'dilithium65';
@@ -0,0 +1,51 @@
// Copyright 2019-2026 @polkadot/extension-ui authors & contributors
// SPDX-License-Identifier: Apache-2.0
/// <reference types="@polkadot/dev-test/globals.d.ts" />
import { formatBalance } from './formatBalance.js';
const HEI = (free: string) => ({ decimals: 12, free, symbol: 'HEI' });
describe('formatting a balance', (): void => {
// crystal_alice's real Heisenberg balance, mid-session.
it('formats a real balance', (): void => {
expect(formatBalance(HEI('527355948904392'))).toEqual('527.3559 HEI');
});
// Why the arithmetic is done on strings. At 12 decimals `2^53` smallest units
// is about 9 007 tokens, so any balance above that loses digits to a `Number`
// — on the one screen whose job is telling somebody how much money they have.
it('keeps every digit of a balance a Number would round', (): void => {
const free = '12345678901234567'; // 12 345.678901234567
expect(Number(free) > Number.MAX_SAFE_INTEGER).toEqual(true);
expect(Number(free).toString()).not.toEqual(free);
expect(formatBalance(HEI(free))).toEqual('12 345.6789 HEI');
});
it('shows a zero balance as zero, not as nothing', (): void => {
expect(formatBalance(HEI('0'))).toEqual('0 HEI');
});
// Truncated, not rounded: a displayed amount must never be more than the
// account actually holds.
it('truncates the fraction rather than rounding it up', (): void => {
expect(formatBalance(HEI('1999999999999'))).toEqual('1.9999 HEI');
});
it('pads an amount smaller than one whole token', (): void => {
expect(formatBalance(HEI('1000000000'))).toEqual('0.001 HEI');
expect(formatBalance(HEI('1'))).toEqual('0 HEI');
});
// Grouped, because the difference between 527 and 5 270 should not need
// counting characters.
it('groups the whole part', (): void => {
expect(formatBalance(HEI('5270000000000000'))).toEqual('5 270 HEI');
});
it('handles a chain with different decimals', (): void => {
expect(formatBalance({ decimals: 0, free: '42', symbol: 'QTC' })).toEqual('42 QTC');
});
});
@@ -0,0 +1,32 @@
// Copyright 2019-2026 @polkadot/extension-ui authors & contributors
// SPDX-License-Identifier: Apache-2.0
import type { AccountBalance } from '@polkadot/extension-base/background/types';
/** How many fractional digits to show. Twelve would be noise at a glance. */
const SHOWN = 4;
/**
* A balance, as a person reads it.
*
* Done with strings rather than numbers throughout. Quantus has **12 decimal
* places**, so `2^53` smallest units is about 9 007 tokens: 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 is truncated rather than rounded, so a displayed amount is never
* more than the account actually holds.
*/
export function formatBalance ({ decimals, free, symbol }: AccountBalance): string {
const negative = free.startsWith('-');
const digits = (negative ? free.slice(1) : free).padStart(decimals + 1, '0');
const whole = digits.slice(0, digits.length - decimals);
const fraction = digits.slice(digits.length - decimals, digits.length - decimals + SHOWN).replace(/0+$/, '');
// Grouped, because `527355948904392` at 12 decimals is 527.3559 and the
// difference between 527 and 5 270 is the sort of thing somebody should see
// without counting characters.
const grouped = whole.replace(/\B(?=(\d{3})+(?!\d))/g, ' ');
return `${negative ? '-' : ''}${grouped}${fraction ? `.${fraction}` : ''}${symbol ? ` ${symbol}` : ''}`;
}
@@ -1,32 +0,0 @@
// Copyright 2019-2026 @polkadot/extension-ui authors & contributors
// SPDX-License-Identifier: Apache-2.0
import type * as _ from '@polkadot/dev-test/globals.d.ts';
import { nextDerivationPath } from './nextDerivationPath.js';
describe('Generate Derivation Path', () => {
const acc = (address: string, parentAddress?: string): {
address: string;
parentAddress?: string;
} => ({
address,
parentAddress
});
it('generates path for first masters child', () => {
expect(nextDerivationPath([acc('a')], 'a')).toEqual('//0');
});
it('generates path for third masters child', () => {
expect(nextDerivationPath([acc('a'), acc('b', 'a'), acc('c', 'a')], 'a')).toEqual('//2');
});
it('generates path for masters child when another root exists', () => {
expect(nextDerivationPath([acc('a'), acc('b', 'a'), acc('c', 'a'), acc('d')], 'a')).toEqual('//2');
});
it('generates path for masters grandchild', () => {
expect(nextDerivationPath([acc('a'), acc('b', 'a'), acc('c', 'b'), acc('d', 'b')], 'b')).toEqual('//2');
});
});
@@ -1,10 +0,0 @@
// Copyright 2019-2026 @polkadot/extension-ui authors & contributors
// SPDX-License-Identifier: Apache-2.0
import type { AccountJson } from '@polkadot/extension-base/background/types';
export function nextDerivationPath (accounts: AccountJson[], parentAddress: string): string {
const siblingsCount = accounts.filter((account) => account.parentAddress === parentAddress).length;
return `//${siblingsCount}`;
}
@@ -0,0 +1,58 @@
// Copyright 2019-2026 @polkadot/extension-ui authors & contributors
// SPDX-License-Identifier: Apache-2.0
/**
* Where wormhole deposits are looked up.
*
* The chain cannot answer "which deposits went to these addresses" (the zk
* tree has no index by recipient), so something that has read the whole chain
* has to, and whatever answers learns that the addresses belong to one person.
* The default is a blackbeard observer rather than Quantus's own indexer; a user
* can point this at their own, or turn it off.
*/
const KEY = 'quantus:wormholeObserver';
const CHANGED = 'quantus:wormholeObserverChanged';
export const DEFAULT_OBSERVER = 'https://blackbeard.observer';
export const OBSERVER_OPTIONS = [
{ text: 'blackbeard.observer', value: DEFAULT_OBSERVER },
{ text: 'Off — do not look up wormhole deposits', value: '' }
];
/** The configured observer, or the default. An empty string means off. */
export function getWormholeObserver (): string {
try {
const stored = localStorage.getItem(KEY);
return stored === null ? DEFAULT_OBSERVER : stored;
} catch {
return DEFAULT_OBSERVER;
}
}
export function setWormholeObserver (observer: string): void {
try {
localStorage.setItem(KEY, observer.trim().replace(/\/+$/, ''));
} catch (error) {
console.error(error);
}
window.dispatchEvent(new Event(CHANGED));
}
export function onWormholeObserverChange (cb: (observer: string) => void): () => void {
const onChanged = (): void => cb(getWormholeObserver());
const onStorage = ({ key }: StorageEvent): void => {
key === KEY && onChanged();
};
window.addEventListener(CHANGED, onChanged);
window.addEventListener('storage', onStorage);
return (): void => {
window.removeEventListener(CHANGED, onChanged);
window.removeEventListener('storage', onStorage);
};
}
+23 -12
View File
@@ -1,24 +1,35 @@
{
"author": "polkadot.{js}",
"description": "Manage your Polkadot accounts outside of dapps. Injects the accounts and allows signing transactions for a specific account.",
"homepage_url": "https://github.com/polkadot-js/extension",
"name": "polkadot{.js} extension",
"short_name": "polkadot{.js}",
"author": "blackbeard",
"description": "A post-quantum wallet for the Quantus Network: ML-DSA-65 and ML-DSA-87 accounts, wormhole balances, and signing for dapps.",
"homepage_url": "https://git.lair.cafe/quantus/extension",
"name": "blackbeard",
"short_name": "blackbeard",
"manifest_version": 3,
"permissions": ["storage", "tabs"],
"permissions": [
"storage",
"tabs",
"unlimitedStorage"
],
"background": {
"service_worker": "background.js",
"type": "module"
},
"action": {
"default_title": "polkadot{.js}",
"default_title": "blackbeard",
"default_popup": "index.html"
},
"content_scripts": [{
"js": ["content.js"],
"matches": ["http://*/*", "https://*/*"],
"run_at": "document_start"
}],
"content_scripts": [
{
"js": [
"content.js"
],
"matches": [
"http://*/*",
"https://*/*"
],
"run_at": "document_start"
}
],
"icons": {
"16": "images/icon-16.png",
"32": "images/icon-32.png",
+28 -15
View File
@@ -1,30 +1,43 @@
{
"author": "polkadot.{js}",
"description": "Manage your Polkadot accounts outside of dapps. Injects the accounts and allows signing transactions for a specific account.",
"homepage_url": "https://github.com/polkadot-js/extension",
"name": "polkadot{.js} extension",
"short_name": "polkadot{.js}",
"author": "blackbeard",
"description": "A post-quantum wallet for the Quantus Network: ML-DSA-65 and ML-DSA-87 accounts, wormhole balances, and signing for dapps.",
"homepage_url": "https://git.lair.cafe/quantus/extension",
"name": "blackbeard",
"short_name": "blackbeard",
"manifest_version": 3,
"permissions": ["storage", "tabs"],
"permissions": [
"storage",
"tabs",
"unlimitedStorage"
],
"background": {
"scripts": ["background.js"],
"scripts": [
"background.js"
],
"type": "module"
},
"action": {
"default_title": "polkadot{.js}",
"default_title": "blackbeard",
"default_popup": "index.html"
},
"browser_specific_settings": {
"gecko": {
"id": "{7e3ce1f0-15fb-4fb1-99c6-25774749ec6d}",
"id": "extension@blackbeard.observer",
"strict_min_version": "108.0.2"
}
},
"content_scripts": [{
"js": ["content.js"],
"matches": ["http://*/*", "https://*/*"],
"run_at": "document_start"
}],
"content_scripts": [
{
"js": [
"content.js"
],
"matches": [
"http://*/*",
"https://*/*"
],
"run_at": "document_start"
}
],
"icons": {
"16": "images/icon-16.png",
"32": "images/icon-32.png",
@@ -45,4 +58,4 @@
"content_security_policy": {
"extension_pages": "script-src 'self' 'wasm-unsafe-eval'; object-src 'self'"
}
}
}
Binary file not shown.

Before

Width:  |  Height:  |  Size: 37 KiB

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.0 KiB

After

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 510 B

After

Width:  |  Height:  |  Size: 1.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.1 KiB

After

Width:  |  Height:  |  Size: 4.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.7 KiB

After

Width:  |  Height:  |  Size: 6.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.0 KiB

After

Width:  |  Height:  |  Size: 9.2 KiB

-4
View File
@@ -1,4 +0,0 @@
<?xml version="1.0" encoding="utf-8" standalone="yes"?><!-- Generator: Adobe Illustrator 23.0.3, SVG Export Plug-In . SVG Version: 6.00 Build 0) --><svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" version="1.1" id="Layer_1" x="0px" y="0px" viewBox="15 15 140 140" style="enable-background:new 0 0 170 170;zoom: 1;" xml:space="preserve"><style type="text/css">
.bg0{fill:#FF8C00}
.st0{fill:#FFFFFF;}
</style><g><circle class="bg0" cx="85" cy="85" r="70"></circle><g><path class="st0" d="M85,34.7c-20.8,0-37.8,16.9-37.8,37.8c0,4.2,0.7,8.3,2,12.3c0.9,2.7,3.9,4.2,6.7,3.3c2.7-0.9,4.2-3.9,3.3-6.7 c-1.1-3.1-1.6-6.4-1.5-9.7C58.1,57.6,69.5,46,83.6,45.3c15.7-0.8,28.7,11.7,28.7,27.2c0,14.5-11.4,26.4-25.7,27.2 c0,0-5.3,0.3-7.9,0.7c-1.3,0.2-2.3,0.4-3,0.5c-0.3,0.1-0.6-0.2-0.5-0.5l0.9-4.4L81,73.4c0.6-2.8-1.2-5.6-4-6.2 c-2.8-0.6-5.6,1.2-6.2,4c0,0-11.8,55-11.9,55.6c-0.6,2.8,1.2,5.6,4,6.2c2.8,0.6,5.6-1.2,6.2-4c0.1-0.6,1.7-7.9,1.7-7.9 c1.2-5.6,5.8-9.7,11.2-10.4c1.2-0.2,5.9-0.5,5.9-0.5c19.5-1.5,34.9-17.8,34.9-37.7C122.8,51.6,105.8,34.7,85,34.7z M87.7,121.7 c-3.4-0.7-6.8,1.4-7.5,4.9c-0.7,3.4,1.4,6.8,4.9,7.5c3.4,0.7,6.8-1.4,7.5-4.9C93.3,125.7,91.2,122.4,87.7,121.7z"></path></g></g></svg>

Before

Width:  |  Height:  |  Size: 1.2 KiB

+1 -1
View File
@@ -2,7 +2,7 @@
<html lang="en">
<head>
<meta charset="utf-8">
<title>polkadot{.js}</title>
<title>blackbeard</title>
<link href="fonts/fonts.css" rel="stylesheet">
<link href="theme.css" rel="stylesheet">
<style type="text/css">
@@ -1,21 +1,40 @@
{
"Copied": "",
"<unknown>": "",
"hardware wallet account": "",
"external account": "",
"copy address": "",
"account visibility": "",
"An error occurred": "",
"Something went wrong with the query and rendering of this component. {{message}}": "",
"Back to home": "",
"click to select or drag and drop the file here": "",
"{{name}} ({{size}} bytes)": "",
"Warning: Caps lock is on": "",
"... loading ...": "",
"Generated 12-word mnemonic seed:": "",
"Copy to clipboard": "",
"Back": "",
"Create new account (root or derived)": "",
"Allow use on any chain": "",
"It looks like this request is coming from an suspicious origin. Please verify the source carefully.": "",
"Select all": "",
"Search by name or network...": "",
"Create new account": "",
"Derive from an account": "",
"Export all accounts": "",
"Import account from pre-existing seed": "",
"Restore account from backup JSON file": "",
"Attach external QR-signer account": "",
"External QR accounts and Access": "",
"Allow Camera Access": "",
"Track an address": "",
"Dark": "",
"Light": "",
"Display address format for": "",
"Read balances from": "",
"Custom": "",
"or a node of your own": "",
"Language": "",
"Notifications": "",
"External accounts and Access": "",
"Allow QR Camera Access": "",
"Manage Website Access": "",
"Open extension in new window": "",
"Account name is too short": "",
"A descriptive name for your account": "",
@@ -27,40 +46,61 @@
"Derive New Account": "",
"Export Account": "",
"Forget Account": "",
"Visible (always inject)": "",
"Add Account": "",
"You currently don't have any accounts. Create your first account to get started.": "",
"Accounts": "",
"Authorize": "",
"Only approve this request if you trust the application. Approving gives the application access to the addresses of your accounts.": "",
"Yes, allow this application access": "",
"Add the account with the generated seed": "",
"Accounts connected to {{url}}": "",
"Connect {{total}} account(s)": "",
"example.com": "",
"No website request yet!": "",
"{{total}} accounts": "",
"all accounts": "",
"no accounts": "",
"Account connection request": "",
"Previous": "",
"Next": "",
"Understood": "",
"Reject": "",
"Don't ask again": "",
"Create an account": "",
"Network": "",
"Add the account with the generated seed": "",
"Please write down your wallet's mnemonic seed and keep it in a safe place. The mnemonic can be used to restore your wallet. Keep it carefully to not lose your assets.": "",
"I have saved my mnemonic seed safely.": "",
"Next step": "",
"Derivation Path": "",
"Derivation Path (unlock to edit)": "",
"Derivation Path": "",
"//hard/soft": "",
"//hard": "",
"Add new account": "",
"Create derived account": "",
"Derive new account from existing": "",
"`///password` not supported for derivation": "",
"Soft derivation is only allowed for sr25519 accounts": "",
"Invalid derivation path": "",
"Choose Parent Account:": "",
"enter the password for the account you want to derive from": "",
"Wrong password": "",
"Create a derived account": "",
"Create account from new seed": "",
"Export account": "",
"You are exporting your account. Keep it safe and don't share it with anyone.": "",
"password for this account": "",
"I want to export this account": "",
"Cancel": "",
"All account": "",
"password for encrypting all accounts": "",
"I want to export all my accounts": "",
"Forget account": "",
"You are about to remove the account. This means that you will not be able to access it via this extension anymore. If you wish to recover it, you would need to use the seed.": "",
"I want to forget this account": "",
"Scan Address Qr": "",
"Add the account with identified address": "",
"Import account": "",
"existing 12 or 24-word mnemonic seed": "",
"Add the account with the supplied seed": "",
"Invalid mnemonic seed or derivation path": "",
"Invalid mnemonic seed": "",
"existing 12 or 24-word mnemonic seed": "",
"Mnemonic needs to contain 12, 15, 18, 21, 24 words": "",
"advanced": "",
"account index, e.g. //1 (leave empty for the first account)": "",
"Each Quantus account derives from your recovery phrase on its own. To add a second account, enter the same phrase with //1, a third with //2, and so on.": "",
"Metadata": "",
"from": "",
"chain": "",
@@ -68,29 +108,39 @@
"decimals": "",
"symbol": "",
"upgrade": "",
"This approval will add the metadata to your extension instance, allowing future requests to be decoded using this metadata.": "",
"Yes, do this metadata update": "",
"Reject": "",
"Phishing detected": "",
"You have been redirected because the Polkadot{.js} extension believes that this website could compromise the security of your accounts and your tokens.": "",
"Restore from JSON": "",
"backup file": "",
"Invalid Json file": "",
"Password for this file": "",
"Unable to decode using the supplied passphrase": "",
"Restore": "",
"bytes": "",
"method data": "",
"method": "",
"info": "",
"immortal": "",
"mortal, valid from {{birth}} to {{death}}": "",
"genesis": "",
"version": "",
"nonce": "",
"tip": "",
"Sign the transaction": "",
"Sign the message": "",
"assetId": "",
"lifetime": "",
"Transaction": "",
"Sign message": "",
"Scan signature via camera": "",
"Don't ask me again for the next {{expiration}} minutes": "",
"Sign the transaction": "",
"Sign the message": "",
"This account is tracked only — the extension holds no key for it and cannot sign.": "",
"Remember my password for the next {{expiration}} minutes": "",
"Extend the period without password by {{expiration}} minutes": "",
"Password for this account": "",
"This is not a valid address": "",
"Paste the address you want to track": "",
"This account has no key in the extension, so it cannot sign. You will see its balance and can use it as a recipient.": "",
"Add the address": "",
"Welcome": "",
"Before we start, just a couple of notes regarding use:": "",
"We do not send any clicks, pageviews or events to a central server": "",
@@ -98,113 +148,53 @@
"We don't collect keys, addresses or any information - your information never leaves this machine": "",
"... we are not in the information collection business (even anonymized).": "",
"Understood, let me continue": "",
"An error occured": "",
"Something went wrong with the query and rendering of this component. {{message}}": "",
"Back to home": "",
"external account": "",
"Phishing detected": "",
"Remember my password for the next {{expiration}} minutes": "",
"Extend the period without password by {{expiration}} minutes": "",
"You have been redirected because the Polkadot{.js} extension believes that this website could compromise the security of your accounts.": "",
"The redirection could also happen on an outright malicious website or on a legitimate websites that has been compromised and flagged.": "",
"This redirection is based on a list of websites accessible at https://github.com/polkadot-js/phishing. Note that this is a community-driven, curated list. \n It might be incomplete or inaccurate.": "",
"copy address": "",
"account visibility": "",
"Wrong password": "",
"Incorrect derivation path": "",
"lifetime": "",
"Warning: Caps lock is on": "",
"Invalid Json file": "",
"Unable to decode using the supplied passphrase": "",
"Manage Website Access": "",
"example.com": "",
"No website request yet!": "",
"allowed": "",
"denied": "",
"Derive from an account": "",
"Dark": "",
"Light": "",
"Invalid mnemonic seed or derivation path": "",
"Invalid mnemonic seed": "",
"Mnemonic needs to contain 12, 15, 18, 21, 24 words": "",
"advanced": "",
"derivation path": "",
"Next": "",
"//hard": "",
"`///password` not supported for derivation": "",
"Soft derivation is only allowed for sr25519 accounts": "",
"Invalid derivation path": "",
"hardware wallet account": "",
"Camera access must be first enabled in the settings": "",
"Ledger devices can only be connected with Chrome browser": "",
"Attach ledger account": "",
"Connect Ledger device": "",
"External accounts and Access": "",
"Allow QR Camera Access": "",
"Add Account": "",
"Account type {{index}}": "",
"Address index {{index}}": "",
"Import Ledger Account": "",
"Network": "",
"account type": "",
"address index": "",
"Refresh": "",
"Import Account": "",
"Sign on Ledger": "",
"Allow use on any chain": "",
"Select network": "",
"You have been redirected because the Polkadot{.js} extension believes that this website could compromise the security of your accounts and your tokens.": "",
"Export all accounts": "",
"All account": "",
"password for encrypting all accounts": "",
"I want to export all my accounts": "",
"Notifications": "",
"Search by name or network...": "",
"Select all": "",
"Accounts connected to {{url}}": "",
"Connect {{total}} account(s)": "",
"{{total}} accounts": "",
"Account connection request": "",
"Understood": "",
"Ask again later": "",
"no accounts": "",
"all accounts": "",
"Message signing is not supported for hardware wallets.": "",
"\"Allow use on any network\" is not supported to show a QR code. You must associate this account with a network.": "",
"An error occurred": "",
"Is your ledger locked?": "",
"App \"{{network}}\" does not seem to be open": "",
"Ledger error: {{errorMessage}}": "",
"assetId": "",
"This approval will add the metadata to your extension instance, allowing future requests to be decoded using this metadata. It will also allow the use of Ledger's Generic Polkadot App.": "",
"No metadata found for this chain. You must upload the metadata to the extension in order to use Ledger.": "",
"This network is not available, please report an issue to update the known chains": "",
"Ledger App": "",
"Don't ask again": "",
"Previous": "",
"ED25519 Account": "",
"Ethereum Account": "",
"It looks like this request is coming from an suspicious origin. Please verify the source carefully.": "",
"Password is not strong enough": "",
"Password must be at least {{length}} characters long": "",
"Asset Hub Migration Notice": "",
"The Asset Hub migration has been completed. Please note the following important changes:": "",
"All balances have been migrated from the Relay Chain to Asset Hub": "",
"All on-chain functionality has been moved to Asset Hub": "",
"Asset Hub now holds user balances and provides general functionality": "",
"⚠️ Do not teleport balances to the Relay Chain unless:": "",
"You are opening HRMP channels, or": "",
"You are starting a Parachain": "",
"For all other operations, your balances are already on Asset Hub.": "",
"I Understand": "",
"Do not teleport balances to the Relay Chain unless:": "",
"Address mismatch: derived {{derived}}, expected {{expected}}. Check that the correct Ledger device is connected and the correct app is selected.": "",
"Possible cause: the Ledger App setting differs from the app originally used to derive this account.": "",
"This approval will add the metadata to your extension instance, allowing future requests to be decoded using this metadata.": "",
"Track an address": "",
"This account is tracked only — the extension holds no key for it and cannot sign.": "",
"This is not a valid address": "",
"Paste the address you want to track": "",
"This account has no key in the extension, so it cannot sign. You will see its balance and can use it as a recipient.": "",
"Add the address": ""
"Open in a tab": "",
"Key type": "",
"The same recovery phrase gives a different account under each key type. Check the address above is the one you expect before continuing.": "",
"Wormhole": "",
"none: a raw seed has no wormhole account": "",
"Rename wallet": "",
"Add account": "",
"Export this account": "",
"Forget wallet": "",
"Account {{index}}": "",
"Receive address. Funds here leave only through a zero-knowledge proof, so this account has no key to sign with. Its balance and sending are not in the extension yet.": "",
"Create a wallet": "",
"Add the wallet with the generated recovery phrase": "",
"Import wallet": "",
"Add the wallet": "",
"recovery phrase, or a 0x-prefixed 32-byte seed": "",
"Account {{next}} of \"{{name}}\": new ML-DSA-65, ML-DSA-87 and wormhole accounts from the same recovery phrase. Enter the wallet password to derive them.": "",
"Wallet password": "",
"Add account {{next}}": "",
"This wallet no longer exists.": "",
"This removes the stored recovery phrase and all {{count}} accounts derived from it: each one's ML-DSA-65, ML-DSA-87 and wormhole account. Without the recovery phrase they cannot be recovered.": "",
"This removes the stored recovery phrase or seed and every account derived from it. Without it they cannot be recovered.": "",
"I want to forget this wallet": "",
"Look up wormhole deposits with": "",
"or an observer of your own": "",
"Receive address. Funds here leave only through a zero-knowledge proof, so this account has no key to sign with, and sending from it is not in the extension yet.": "",
"Balances are off in settings.": "",
"Wormhole deposit lookups are off in settings.": "",
"Checking deposits against the chain…": "",
"Could not check the wormhole balance: {{message}}": "",
"Retry": "",
"At least": "",
"spendable": "",
"No deposits yet.": "",
"{{deposits}} deposits, {{spent}} already exited.": "",
"{{missing}} deposits are not indexed by the observer yet (it has read from block {{from}}); they are not counted.": "",
"{{count}} deposits ({{amount}}) arrived after this wallet last computed its nullifiers, so whether they are spent is unknown.": "",
"Unlock to check": "",
"Checked {{at}}.": "",
"Refresh": "",
"Check newer deposits": "",
"Whether a wormhole deposit is spent is decided by its nullifier, which only this wallet can compute. {{count}} deposits to \"{{name}}\" arrived after it last did. Enter the wallet password to compute theirs; they are stored with the wallet and removed if you forget it.": "",
"Compute and check": "",
"Open the wallet's wormhole tab first, so there is a balance to extend.": "",
"You have been redirected because blackbeard believes that this website could compromise the security of your accounts and your tokens.": "",
"No clicks, pageviews or events are sent anywhere, and there are no trackers or analytics.": "",
"Keys and recovery phrases never leave this browser.": "",
"To show balances, your addresses are sent to a Quantus node; to show wormhole balances, your wormhole addresses are sent to a blackbeard observer. Either can be pointed at your own server, or turned off, in settings.": ""
}
+1 -1
View File
@@ -2,7 +2,7 @@
<html lang="en">
<head>
<meta charset="utf-8">
<title>polkadot{.js}</title>
<title>blackbeard</title>
<link href="fonts/fonts.css" rel="stylesheet">
<link href="theme.css" rel="stylesheet">
</head>
+4 -4
View File
@@ -28,15 +28,15 @@ div#root {
--lineHeight: 26px;
/* shared colors */
--accountDotsIconColor: #8E8E8E;
--buttonBackground: #E86F00;
--buttonBackground: #A8761F;
--buttonBackgroundDangerHover: #D93B3B;
--buttonBackgroundHover: #ED9329;
--buttonBackgroundHover: #BD8829;
--buttonTextColor: #FFFFFF;
--connectedDotColor: seagreen;
--errorColor: #E42F2F;
--iconWarningColor: #FF7D01;
--iconWarningColor: #BD8829;
--identiconBackground: #F4F5F8;
--primaryColor: #FF7D01;
--primaryColor: #BD8829;
}
[data-theme="dark"] {

Some files were not shown because too many files have changed in this diff Show More