Author SHA1 Message Date
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
74 changed files with 5567 additions and 987 deletions
+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.2/keyring-14.0.3-quantus.2.tgz",
"@polkadot/networks": "https://git.lair.cafe/api/packages/quantus/npm/%40polkadot%2Fnetworks/-/14.0.3-quantus.2/networks-14.0.3-quantus.2.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.2/ui-keyring-3.16.7-quantus.2.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.2/util-crypto-14.0.3-quantus.2.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,11 +98,78 @@ describe('Extension', () => {
expect(result.exportedJson.encoded).toBeDefined();
});
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';
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(u8aToHex(decodeAddress(address))).toEqual(u8aToHex(decodeAddress(CRYSTAL_ALICE)));
});
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('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: ZERO_SEED,
type: 'dilithium87'
}, {} as chrome.runtime.Port);
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);
});
});
describe('account derivation', () => {
let address: string;
// An ed25519 parent, explicitly — which is what upstream's harness got by
// default, hence the vectors below. Naming it matters now: the default here
// is ML-DSA, as in production, and derivation is a property of the key type.
// Quantus derives every account from the mnemonic independently along
// m/44'/189189'/<account>'/0'/<scheme>', so an ML-DSA pair holds nothing a
// child could come from. See quantus/common#4 and `canDerive`.
beforeEach(async () => {
address = await createAccount();
address = await createAccount('ed25519');
});
it('refuses to derive from an ML-DSA parent', async () => {
const mldsa = await createAccount();
await expect(extension.handle('id', 'pri(derivation.validate)', {
parentAddress: mldsa,
parentPassword: password,
suri: '//path'
}, {} as chrome.runtime.Port)).rejects.toThrow();
});
it('pri(derivation.validate) passes for valid suri', async () => {
@@ -125,6 +202,8 @@ describe('Extension', () => {
});
it('pri(derivation.create) adds a derived account', async () => {
const before = keyring.getAccounts().length;
await extension.handle('id', 'pri(derivation.create)', {
name: 'child',
parentAddress: address,
@@ -132,7 +211,7 @@ describe('Extension', () => {
password,
suri: '//path'
}, {} as chrome.runtime.Port);
expect(keyring.getAccounts()).toHaveLength(2);
expect(keyring.getAccounts()).toHaveLength(before + 1);
});
it('pri(derivation.create) saves parent address in meta', async () => {
@@ -182,298 +261,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 +356,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 +397,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 +415,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 +428,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,25 @@
/* 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 { 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, RequestDeriveCreate, RequestDeriveValidate, RequestJsonRestore, RequestMetadataApprove, RequestMetadataReject, RequestSeedCreate, RequestSeedValidate, RequestSigningApprovePassword, RequestSigningApproveSignature, RequestSigningCancel, RequestSigningIsLocked, RequestTypes, RequestUpdateAuthorizedAccounts, RequestWalletAddAccount, RequestWalletCreate, RequestWalletForget, RequestWalletPreview, RequestWalletRename, RequestWormholeBalance, RequestWormholeUnlock, ResponseAccountExport, ResponseAccountsExport, ResponseAuthorizeList, ResponseDeriveValidate, 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';
@@ -37,6 +38,10 @@ function getSuri (seed: string, type?: KeypairType): string {
}
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;
@@ -191,6 +196,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);
@@ -357,32 +465,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
@@ -600,6 +714,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);
@@ -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 -3
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';
@@ -89,7 +90,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];
@@ -196,6 +207,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;
@@ -297,6 +415,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;
}
@@ -388,7 +534,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;
@@ -0,0 +1,24 @@
// Copyright 2019-2026 @polkadot/extension-base authors & contributors
// SPDX-License-Identifier: Apache-2.0
import type { WormholeNullifiersJson } from '../background/types.js';
import { EXTENSION_PREFIX } from '../defaults.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 () {
super(
EXTENSION_PREFIX && EXTENSION_PREFIX !== 'polkadot{.js}'
? `${EXTENSION_PREFIX}quantus:nullifiers`
: 'quantus:nullifiers'
);
}
}
@@ -0,0 +1,24 @@
// Copyright 2019-2026 @polkadot/extension-base authors & contributors
// SPDX-License-Identifier: Apache-2.0
import type { WalletJson } from '../background/types.js';
import { EXTENSION_PREFIX } from '../defaults.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 () {
super(
EXTENSION_PREFIX && EXTENSION_PREFIX !== 'polkadot{.js}'
? `${EXTENSION_PREFIX}quantus:wallet`
: '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: {}
};
}
+32 -2
View File
@@ -3,6 +3,36 @@
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', 'ecdsa', 'ed25519', 'ethereum', 'sr25519'];
/** The types that support deriving a child account from a parent's key. */
const DERIVABLE: KeypairType[] = ['ecdsa', 'ed25519', 'ethereum', 'sr25519'];
/**
* 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);
}
/**
* Whether a child account can be derived from a parent of this type.
*
* False for ML-DSA, and permanently so. Quantus derives every account from the
* mnemonic independently along `m/44'/189189'/<account>'/0'/<scheme>'`, and a
* pair holds no material a child could come from — `pair.derive()` refuses
* outright (quantus/common#4). Adding another account therefore needs the
* recovery phrase, not an unlocked parent, which is why the derive-from-parent
* flow has no Quantus meaning.
*/
export function canDerive (type?: KeypairType): boolean {
return !!type && DERIVABLE.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 { canDerive, 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,41 @@
// 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 { canDerive, 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);
});
// The trap: canDerive gated dapp injection upstream. If that had stayed, no
// Quantus account would reach any dapp.
it('ML-DSA accounts are injected but not derivable', (): void => {
expect(canInject('dilithium65')).toEqual(true);
expect(canInject('dilithium87')).toEqual(true);
expect(canDerive('dilithium65')).toEqual(false);
expect(canDerive('dilithium87')).toEqual(false);
});
it('leaves the curve types alone', (): void => {
expect(canInject('sr25519')).toEqual(true);
expect(canDerive('sr25519')).toEqual(true);
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;
@@ -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;
}
`;
@@ -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,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}
@@ -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;
}
`);
+59 -45
View File
@@ -1,7 +1,7 @@
// 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';
@@ -11,10 +11,11 @@ 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';
@@ -25,7 +26,9 @@ 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';
@@ -67,6 +70,7 @@ function initAccountContext ({ accounts, 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 +79,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 +109,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 +136,62 @@ 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='/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>
</WalletContext.Provider>
</BalanceContext.Provider>
</AuthorizeReqContext.Provider>
</AccountContext.Provider>
</SettingsContext.Provider>
@@ -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>
@@ -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';
@@ -75,7 +77,7 @@ function recodeAddress (address: string, accounts: AccountWithChildren[], chain:
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 {
@@ -95,6 +97,7 @@ const defaultRecoded = { account: null, formatted: null, prefix: 42, type: DEFAU
function Address ({ actions, address, children, className, genesisHash, isExternal, isHardware, isHidden, name, parentName, showVisibilityAction = false, suri, toggleActions, type: givenType }: 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 chain = useMetadata(genesisHash || recodedGenesis, true);
@@ -192,6 +195,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 +243,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 +280,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 +346,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';
@@ -14,6 +14,10 @@ const noop = (): void => undefined;
const AccountContext = React.createContext<AccountsContext>({ accounts: [], hierarchy: [], master: undefined });
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 -17
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, ResponseDeriveValidate, 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);
}
+34 -4
View File
@@ -1,7 +1,7 @@
// 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';
@@ -9,8 +9,8 @@ import logo from '../assets/pjs.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);
@@ -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 {
@@ -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}` : ''}`;
}
@@ -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);
};
}
+17 -6
View File
@@ -5,7 +5,11 @@
"name": "polkadot{.js} extension",
"short_name": "polkadot{.js}",
"manifest_version": 3,
"permissions": ["storage", "tabs"],
"permissions": [
"storage",
"tabs",
"unlimitedStorage"
],
"background": {
"service_worker": "background.js",
"type": "module"
@@ -14,11 +18,18 @@
"default_title": "polkadot{.js}",
"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",
+21 -8
View File
@@ -5,9 +5,15 @@
"name": "polkadot{.js} extension",
"short_name": "polkadot{.js}",
"manifest_version": 3,
"permissions": ["storage", "tabs"],
"permissions": [
"storage",
"tabs",
"unlimitedStorage"
],
"background": {
"scripts": ["background.js"],
"scripts": [
"background.js"
],
"type": "module"
},
"action": {
@@ -20,11 +26,18 @@
"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'"
}
}
}
@@ -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,49 @@
"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.": ""
}
+4 -1
View File
@@ -100,7 +100,10 @@ cryptoWaitReady()
console.log('crypto initialized');
// load all the keyring data
keyring.loadAll({ store: new AccountsStore(), type: 'sr25519' });
// ML-DSA-65 is the scheme new Quantus accounts use. ML-DSA-87 is legacy —
// accounts created before the scheme was recorded, and the dev-genesis ones —
// so it is importable but never chosen. See quantus/extension#1.
keyring.loadAll({ store: new AccountsStore(), type: 'dilithium65' });
console.log('initialization completed');
})
+21 -19
View File
@@ -4,24 +4,19 @@
import type { Message } from '@polkadot/extension-base/types';
import { MESSAGE_ORIGIN_CONTENT, MESSAGE_ORIGIN_PAGE, PORT_CONTENT } from '@polkadot/extension-base/defaults';
import { ensurePortConnection } from '@polkadot/extension-base/utils/portUtils';
import { createPortClient } from '@polkadot/extension-base/utils/portUtils';
import { chrome } from '@polkadot/extension-inject/chrome';
let port: chrome.runtime.Port | undefined;
function onPortMessageHandler (data: Message['data']): void {
window.postMessage({ ...data, origin: MESSAGE_ORIGIN_CONTENT }, '*');
}
function onPortDisconnectHandler (): void {
port = undefined;
}
const portConfig = {
onPortDisconnectHandler,
onPortMessageHandler,
const client = createPortClient({
// answer for the background, which restarted before it could
onLost: (ids: string[]): void => ids.forEach((id) =>
window.postMessage({ error: 'The extension background restarted before answering. Try again.', id, origin: MESSAGE_ORIGIN_CONTENT }, '*')
),
onMessage: (data: Message['data']): void => {
window.postMessage({ ...data, origin: MESSAGE_ORIGIN_CONTENT }, '*');
},
portName: PORT_CONTENT
};
});
// all messages from the page, pass them to the extension
window.addEventListener('message', ({ data, source }: Message): void => {
@@ -30,10 +25,17 @@ window.addEventListener('message', ({ data, source }: Message): void => {
return;
}
ensurePortConnection(port, portConfig).then((connectedPort) => {
connectedPort.postMessage(data);
port = connectedPort;
}).catch((error) => console.error(`Failed to send message: ${(error as Error).message}`));
// Only the account subscription is replayed after a background restart. RPC
// subscriptions are keyed by an id the background hands out, which a replay
// cannot reproduce, and no Quantus dapp uses the extension as a provider.
const { message, request } = data as unknown as { message: string, request: { id?: string } | null };
if (message === 'pub(accounts.unsubscribe)' && request?.id) {
client.forget(request.id);
}
client.send(data, message === 'pub(accounts.subscribe)')
.catch((error) => console.error(`Failed to send message: ${(error as Error).message}`));
});
// inject our data injector
+151
View File
@@ -0,0 +1,151 @@
// Copyright 2019-2026 @polkadot/extension authors & contributors
// SPDX-License-Identifier: Apache-2.0
// quantus/extension#7 tier 1, the cases that prove the plumbing rather than the
// happy path. `submit.mjs` shows one transfer working; this asks the chain the
// questions whose answers cannot be checked locally.
//
// 1. the empty signing context is REJECTED
// 2. a payload over 256 bytes is signed as its BLAKE2b hash and dispatches
// 3. an ML-DSA-65 account signs and is accepted (submit.mjs covers 87)
// 4. an account exported to JSON and re-imported still signs
//
// The first is the important one. Every other failure here shows up as a decode
// error somewhere; a signature under the wrong context is well-formed, verifies
// against its own key, and is refused only by the runtime.
import { contextForSpec } from '@quantus/crypto';
import { Keyring } from '@polkadot/keyring';
import { u8aToHex } from '@polkadot/util';
import { cryptoWaitReady, mnemonicGenerate } from '@polkadot/util-crypto';
import { build, connect, freeBalance, nextNonce, submitAndInclude, transfer } from './chain.mjs';
const ENDPOINT = process.env.QUANTUS_WS || 'wss://a1-heisenberg.quantus.cat';
const ALICE_SEED = `0x${'00'.repeat(32)}`;
const AMOUNT = 1_000_000_000n; // 0.001
// Far more than the ~0.008 a 5.3 KiB ML-DSA-65 extrinsic costs, and comfortably
// over the existential deposit.
const FUNDING = 50_000_000_000_000n;
let failures = 0;
function report (name, ok, detail) {
console.log(`${ok ? 'PASS' : 'FAIL'} ${name}${detail ? ` — ${detail}` : ''}`);
if (!ok) {
failures++;
}
}
await cryptoWaitReady();
const chain = await connect(ENDPOINT);
const { runtime, specVersion } = chain;
const context = contextForSpec(specVersion);
console.log(`chain ${chain.specName} spec ${specVersion} tx ${chain.transactionVersion}`);
console.log(`context ${new TextDecoder().decode(context)}`);
const keyring = new Keyring({ ss58Format: 189, type: 'dilithium87' });
const alice = keyring.createFromUri(ALICE_SEED, { name: 'crystal_alice' }, 'dilithium87');
const fresh = keyring.createFromUri(mnemonicGenerate(12), { name: 'tier1-65' }, 'dilithium65');
console.log(`alice ${alice.address}`);
console.log(`ml-dsa-65 ${fresh.address}`);
console.log('');
// --- 1. the empty signing context is rejected ------------------------------
// No block needed: the node verifies the signature before accepting into the
// pool, which is exactly why this is worth asserting.
{
const nonce = await nextNonce(chain, alice.address);
const { hex } = build(chain, alice, transfer(chain, alice.address, AMOUNT), nonce, { signingContext: new Uint8Array() });
try {
await chain.rpc('author_submitExtrinsic', [hex]);
report('the empty signing context is rejected', false, 'the chain ACCEPTED it');
} catch (error) {
report('the empty signing context is rejected', /bad signature/i.test(error.message), error.message);
}
}
// --- 2. a payload past the hashing threshold, which also funds the 65 account
{
const nonce = await nextNonce(chain, alice.address);
const send = (to, value) => ({
Balances: { transfer_keep_alive: { dest: { Id: u8aToHex(to) }, value: value.toString() } }
});
// Five transfers in one batch_all, to push the signing payload over 256 bytes
// so the BLAKE2b branch is the one that runs. A call nested inside a call also
// exercises the codec at a depth a plain transfer never reaches.
const call = runtime.encodeCall('Utility', 'batch_all', {
calls: [
send(fresh.addressRaw, FUNDING),
send(alice.addressRaw, AMOUNT),
send(alice.addressRaw, AMOUNT),
send(alice.addressRaw, AMOUNT),
send(alice.addressRaw, AMOUNT)
]
});
const { hashed, hex, payload } = build(chain, alice, call, nonce, { signingContext: context });
report('a payload over 256 bytes is hashed before signing', hashed, `${payload.length} bytes -> blake2 32`);
try {
const { height } = await submitAndInclude(chain, hex);
// `batch_all` is atomic, so "included" and "did what it said" are different
// claims. The balance is the one that matters.
const funded = await freeBalance(chain, fresh.address);
report('a batch_all over 256 bytes dispatches', funded === FUNDING, `block ${height}, ${fresh.address} holds ${funded}`);
} catch (error) {
report('a batch_all over 256 bytes dispatches', false, error.message);
}
}
// --- 3. an ML-DSA-65 account signs -----------------------------------------
{
const nonce = await nextNonce(chain, fresh.address);
const { hex } = build(chain, fresh, transfer(chain, alice.address, AMOUNT), nonce, { signingContext: context });
try {
const before = await freeBalance(chain, alice.address);
const { height } = await submitAndInclude(chain, hex);
const moved = await freeBalance(chain, alice.address) - before;
report('an ML-DSA-65 account signs and is accepted', moved === AMOUNT, `block ${height}, moved ${moved}`);
} catch (error) {
report('an ML-DSA-65 account signs and is accepted', false, error.message);
}
}
// --- 4. exported to JSON, re-imported, still signs -------------------------
{
const password = 'tier1-round-trip';
const json = fresh.toJson(password);
const reimported = keyring.createFromJson(json);
reimported.decodePkcs8(password);
const sameAddress = reimported.address === fresh.address;
const nonce = await nextNonce(chain, reimported.address);
const { hex } = build(chain, reimported, transfer(chain, alice.address, AMOUNT), nonce, { signingContext: context });
try {
const before = await freeBalance(chain, alice.address);
const { height } = await submitAndInclude(chain, hex);
const moved = await freeBalance(chain, alice.address) - before;
report('an account exported to JSON and re-imported still signs', sameAddress && moved === AMOUNT, `block ${height}, address preserved=${sameAddress}, moved ${moved}`);
} catch (error) {
report('an account exported to JSON and re-imported still signs', false, error.message);
}
}
await chain.disconnect();
console.log('');
console.log(failures ? `${failures} case(s) failed` : 'all cases passed');
process.exit(failures ? 1 : 0);
+151
View File
@@ -0,0 +1,151 @@
// Copyright 2019-2026 @polkadot/extension authors & contributors
// SPDX-License-Identifier: Apache-2.0
// The plumbing shared by the tier-1 scripts: a JSON-RPC transport, a runtime
// loaded from the chain's own metadata, and the three operations that need to be
// right — build an extrinsic, wait for it to be *included*, and read a balance.
//
// Nothing here uses @polkadot/api. WsProvider is a JSON-RPC client and decodes
// nothing; @quantus/codec produces and reads every byte. See quantus/api#1.
import { Runtime } from '@quantus/codec';
import { WsProvider } from '@polkadot/rpc-provider';
import { hexToU8a, u8aToHex } from '@polkadot/util';
import { blake2AsU8a, decodeAddress } 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.
*/
export const HASH_ABOVE = 256;
export async function connect (endpoint) {
const provider = new WsProvider(endpoint);
await provider.isReady;
const rpc = (method, params = []) => provider.send(method, params);
const [version, genesisHash, metadataHex] = await Promise.all([
rpc('state_getRuntimeVersion'),
rpc('chain_getBlockHash', ['0x0']),
rpc('state_getMetadata')
]);
const runtime = Runtime.fromMetadata(hexToU8a(metadataHex));
return {
disconnect: () => provider.disconnect(),
genesisHash,
rawMetadata: metadataHex,
rpc,
runtime,
specName: version.specName,
specVersion: version.specVersion,
transactionVersion: version.transactionVersion
};
}
/**
* Build, sign and encode one extrinsic, with an immortal era.
*
* `signingContext` is exposed so a caller can get it deliberately wrong — that
* is a case worth proving, because a signature under the wrong FIPS 204 context
* is well-formed, verifies against its own key, and is refused only by the chain.
*/
export function build (chain, pair, call, nonce, { signingContext } = {}) {
const { genesisHash, runtime, specVersion, transactionVersion } = chain;
const values = runtime.standardExtensions({
blockHash: genesisHash,
eraHex: '0x00',
genesisHash,
nonce,
specVersion,
transactionVersion
});
const extra = runtime.encodeExtra(values);
const payload = runtime.signerPayload(call, values);
const toSign = payload.length > HASH_ABOVE ? blake2AsU8a(payload) : payload;
const signature = pair.sign(toSign, { context: signingContext, withType: true });
return {
extra,
hashed: payload.length > HASH_ABOVE,
hex: u8aToHex(runtime.encodeExtrinsic({ Id: u8aToHex(pair.addressRaw) }, signature, extra, call)),
payload,
signature
};
}
/**
* Submit, and wait until the extrinsic is in a block.
*
* **Not** by watching the sender's nonce. `system_accountNextIndex` counts
* pending pool transactions, so it advances the moment a transaction is
* accepted — which is before it has done anything. Waiting on it and then
* reading a balance shows the state from before the dispatch, and the result is
* a working transfer that reports itself as having moved nothing. That cost a
* confusing run: two `batch_all` calls were recorded as reverted when both had
* funded their target with exactly what they were asked to.
*
* So this 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 at all.
*/
export async function submitAndInclude (chain, hex, { timeoutMs = 10 * 60 * 1000 } = {}) {
const { rpc } = chain;
await rpc('author_submitExtrinsic', [hex]);
const deadline = Date.now() + timeoutMs;
let height = parseInt((await rpc('chain_getHeader')).number, 16);
while (Date.now() < deadline) {
const head = parseInt((await rpc('chain_getHeader')).number, 16);
while (height <= head) {
const blockHash = await rpc('chain_getBlockHash', [`0x${height.toString(16)}`]);
const block = await rpc('chain_getBlock', [blockHash]);
const index = block.block.extrinsics.indexOf(hex);
if (index !== -1) {
return { blockHash, height, index };
}
height++;
}
await new Promise((resolve) => setTimeout(resolve, 6_000));
}
throw new Error('accepted into the pool but not included before the deadline');
}
/** An account's free balance, from `System::Account`. */
export async function freeBalance (chain, address) {
const { rpc, runtime } = chain;
const target = runtime.storageTarget('System', 'Account', [u8aToHex(decodeAddress(address))]);
const raw = await rpc('state_getStorage', [target.key]);
// `null` from the node means the entry is unset, and for a `Default` entry
// that means the declared default — an account nobody has funded reads as a
// zero balance, not as an error.
const account = runtime.decodeStorage(target.valueTy, hexToU8a(raw ?? target.default));
return BigInt(account.data.free);
}
/**
* The next usable nonce, pending pool transactions included — which is what a
* signer wants, and is exactly not what an inclusion check wants.
*/
export async function nextNonce (chain, address) {
return parseInt(await chain.rpc('system_accountNextIndex', [address]), 10);
}
/** A `Balances.transfer_keep_alive` call. */
export function transfer (chain, to, value) {
return chain.runtime.encodeCall('Balances', 'transfer_keep_alive', {
dest: { Id: u8aToHex(decodeAddress(to)) },
value: value.toString()
});
}
+102
View File
@@ -0,0 +1,102 @@
// Copyright 2019-2026 @polkadot/extension authors & contributors
// SPDX-License-Identifier: Apache-2.0
// quantus/extension#7 tier 1: sign a real extrinsic with the forked keyring and
// submit it to a real node. No browser, no extension, no dapp.
//
// This is the happy path, printed at every step. `cases.mjs` next to it is the
// set that proves the plumbing — the empty signing context being rejected, a
// payload past the BLAKE2b threshold, an ML-DSA-65 account, a JSON round trip.
//
// Nothing here uses @polkadot/api. Its codec can neither read nor write this
// chain — quantus/api#1 has the evidence — so every byte is produced by
// @quantus/codec against metadata the node generated by running
// `Metadata_metadata` against the runtime WASM.
import { contextForSpec } from '@quantus/crypto';
import { Keyring } from '@polkadot/keyring';
import { u8aToHex } from '@polkadot/util';
import { cryptoWaitReady } from '@polkadot/util-crypto';
import { build, connect, freeBalance, nextNonce, submitAndInclude, transfer } from './chain.mjs';
const ENDPOINT = process.env.QUANTUS_WS || 'wss://a1-heisenberg.quantus.cat';
// crystal_alice: seed = 32 zero bytes, ML-DSA-87. Published in the chain's own
// source, funded at dev genesis, and assumed compromised by everyone.
const SEED = `0x${'00'.repeat(32)}`;
const TYPE = 'dilithium87';
await cryptoWaitReady();
const keyring = new Keyring({ ss58Format: 189, type: TYPE });
const pair = keyring.createFromUri(SEED, { name: 'crystal_alice' }, TYPE);
console.log(`signer ${pair.address}`);
const chain = await connect(ENDPOINT);
const { genesisHash, runtime, specVersion } = chain;
console.log(`chain ${chain.specName} spec ${specVersion} tx ${chain.transactionVersion}`);
console.log(`genesis ${genesisHash}`);
console.log(`extrinsic v${runtime.extrinsicVersion}`);
const dest = process.env.QUANTUS_DEST || 'qzkYEQv8tQsmniZYdame3Cku18RL5g9bGK9Pdydq5TMPdpE3y'; // crystal_bob
// In plancks: 12 decimal places, so 1_000_000_000_000 is 1 HEI. The default is
// deliberately tiny for the happy-path proof; funding an account you intend to
// sign from needs far more, since one ML-DSA extrinsic costs about 0.01 HEI in
// fees alone.
const amount = BigInt(process.env.QUANTUS_AMOUNT || '1000000000'); // default 0.001
const nonce = await nextNonce(chain, pair.address);
const before = await freeBalance(chain, dest);
console.log(`recipient ${dest}`);
console.log(`before ${before}`);
console.log(`nonce ${nonce}`);
const call = transfer(chain, dest, amount);
console.log(`call ${u8aToHex(call)}`);
// Every extension the runtime declares as non-empty that `standardExtensions`
// does not cover. Reported rather than skipped: skipping is the polkadot-js
// failure mode this whole package exists to avoid.
const unmet = runtime
.signedExtensions()
.filter((e) => (e.needsExtra || e.needsAdditional) && !['ChargeTransactionPayment', 'CheckGenesis', 'CheckMetadataHash', 'CheckMortality', 'CheckNonce', 'CheckSpecVersion', 'CheckTxVersion'].includes(e.identifier));
if (unmet.length) {
throw new Error(`runtime declares extensions this harness cannot fill: ${unmet.map((e) => e.identifier).join(', ')}`);
}
const context = contextForSpec(specVersion);
const { extra, hashed, hex, payload, signature } = build(chain, pair, call, nonce, { signingContext: context });
console.log(`extra ${u8aToHex(extra)}`);
console.log(`payload ${payload.length} bytes -> signing ${hashed ? '32 (blake2)' : payload.length}`);
console.log(`context ${context.length ? new TextDecoder().decode(context) : '(empty)'}`);
console.log(`signature ${signature.length} bytes, variant 0x${signature[0].toString(16).padStart(2, '0')}`);
console.log(`extrinsic ${(hex.length - 2) / 2} bytes`);
// Decoding what we are about to send, with the same runtime that built it. A
// round trip that fails here is a bug in the codec; one that succeeds and is
// still rejected is a disagreement with the chain, and telling those apart is
// most of the work.
const readBack = runtime.decodeExtrinsic(Uint8Array.from(Buffer.from(hex.slice(2), 'hex')));
console.log(`round trip v${readBack.version} signed=${readBack.signed} ${JSON.stringify(readBack.call)}`);
const { blockHash, height, index } = await submitAndInclude(chain, hex);
console.log(`included block ${height} index ${index} (${blockHash})`);
const after = await freeBalance(chain, dest);
console.log(`after ${after}`);
console.log(`delta ${after - before} (expected ${amount})`);
await chain.disconnect();
if (after - before !== amount) {
throw new Error(`recipient balance moved by ${after - before}, expected ${amount}`);
}
+83
View File
@@ -0,0 +1,83 @@
# Tier 2 harness
[quantus/extension#7](https://git.lair.cafe/quantus/extension/issues/7) tier 2: a
dapp that asks the **extension** to sign, then assembles and submits the extrinsic
itself.
The point is not the UI. 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 from it. When a node rejects something, that
display is the debugger, and it is exactly what a real app hides behind a success
toast.
Nothing here uses `@polkadot/api`. `@quantus/codec` produces every byte, from
metadata the node generated by running `Metadata_metadata` against the runtime
WASM; the WebSocket is a JSON-RPC transport and decodes nothing. See
[quantus/api#1](https://git.lair.cafe/quantus/api/issues/1) for why.
## Run it
```sh
yarn build # the extension, into packages/extension/build
yarn tier2 # bundles scripts/tier2/src/harness.js
python3 -m http.server 8731
```
Then load `packages/extension/build` as an unpacked extension —
`about:debugging#/runtime/this-firefox` → *Load Temporary Add-on*, or
`chrome://extensions` with developer mode on → *Load unpacked* — and open
<http://localhost:8731/scripts/tier2/index.html>.
It has to be served over HTTP. The extension does not inject into `file://` pages,
so `web3Enable` returns nothing there.
## A funded account
The dev genesis endows three accounts from trivial seeds
(`primitives/dilithium-crypto/src/pair.rs` in the chain):
| account | seed | scheme |
| --- | --- | --- |
| `crystal_alice` | 32 × `0x00` | ML-DSA-87 |
| `dilithium_bob` | 32 × `0x01` | ML-DSA-87 |
| `crystal_charlie` | 32 × `0x02` | ML-DSA-87 |
Import one with *Import account from pre-existing seed* and the raw hex seed
`0x0000…00`. On Heisenberg `crystal_alice` is
`qzk1Nxai3dZD9Cn5kwGcgL6mKxsfxwqdis7kDQJ52aJS2vSn7`.
Two things that makes it worth using early: these are **ML-DSA-87**, the legacy
scheme, where new accounts are ML-DSA-65; and it is the **non-HD** path, a raw
32-byte seed straight into keygen with no derivation, which is a different code
path from the mnemonic one.
## What the buttons do
- **connect** — fetches the runtime version, genesis hash and metadata, and lists
the signed extensions the runtime declares. Two of them,
`ReversibleTransactionExtension` and `WormholeProofRecorderExtension`, exist on
no other chain.
- **list accounts** — `web3Enable` then `web3Accounts`. The first call raises an
authorization request in the extension.
- **signRaw** — the bytes channel. Signs under the **empty** FIPS 204 context, so
the result can never be replayed as an extrinsic.
- **build, sign and submit** — provides this chain's metadata to the extension,
builds the call, asks the extension to sign, assembles the extrinsic and
submits it.
That metadata step is not optional. The extension **refuses** to sign an
extrinsic for a chain it has no metadata for, because without the runtime's own
description of its signed extensions it would be guessing at the payload — and a
signature over the wrong bytes comes back from a node as `BadProof`, which is
also what it says about a wrong key.
## Tier 1
`../tier1/submit.mjs` does the same thing with no browser and no extension,
signing with the forked keyring directly. It is the faster loop, and the one that
answers "is this a crypto bug or a plumbing bug".
```sh
node scripts/tier1/submit.mjs
QUANTUS_WS=wss://… QUANTUS_DEST=qzk… node scripts/tier1/submit.mjs
```
+61
View File
@@ -0,0 +1,61 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Quantus extension — tier 2 harness</title>
<style>
body { font: 13px/1.5 ui-monospace, monospace; margin: 0; padding: 1rem 1.25rem; }
h1 { font-size: 1rem; margin: 0 0 0.25rem; }
p.note { color: #666; margin: 0 0 1rem; max-width: 60rem; }
fieldset { border: 1px solid #ccc; margin: 0 0 0.75rem; padding: 0.5rem 0.75rem; }
legend { color: #666; padding: 0 0.25rem; }
label { display: inline-block; min-width: 5rem; }
input, select { font: inherit; margin: 0.15rem 0; padding: 0.15rem 0.3rem; width: 32rem; max-width: 100%; }
button { font: inherit; margin: 0.25rem 0.25rem 0.25rem 0; padding: 0.2rem 0.6rem; }
#log { border-top: 1px solid #ccc; margin-top: 1rem; padding-top: 0.5rem; }
.row { display: flex; gap: 0.75rem; padding: 0.1rem 0; }
.label { color: #666; flex: 0 0 9rem; text-align: right; }
.value { flex: 1; overflow-wrap: anywhere; }
.good .value { color: #0a0; }
.bad .value { color: #c00; }
</style>
</head>
<body>
<h1>Quantus extension — tier 2 harness</h1>
<p class="note">
quantus/extension#7 tier 2. Every intermediate value is printed: the call, the extra,
the payload that was signed, the signature that came back, and the assembled extrinsic.
The extension signs; this page assembles and submits. Nothing here uses
<code>@polkadot/api</code> — the WebSocket is a JSON-RPC transport and
<code>@quantus/codec</code> does every byte.
</p>
<fieldset>
<legend>chain</legend>
<label for="endpoint">endpoint</label><input id="endpoint" type="text">
<div><button id="connect" type="button">connect</button></div>
</fieldset>
<fieldset>
<legend>accounts</legend>
<label for="account">account</label><select id="account"></select>
<div><button id="accounts" type="button">list accounts</button></div>
</fieldset>
<fieldset>
<legend>sign raw bytes</legend>
<label for="message">message</label><input id="message" type="text" value="hello from tier 2">
<div><button id="sign-raw" type="button">signRaw</button></div>
</fieldset>
<fieldset>
<legend>transfer</legend>
<label for="dest">to</label><input id="dest" type="text" value="qzkYEQv8tQsmniZYdame3Cku18RL5g9bGK9Pdydq5TMPdpE3y">
<label for="amount">amount</label><input id="amount" type="text" value="1000000000">
<div><button id="transfer" type="button">build, sign and submit</button></div>
</fieldset>
<div id="log"></div>
<script src="build/harness.js"></script>
</body>
</html>
+264
View File
@@ -0,0 +1,264 @@
// Copyright 2019-2026 @polkadot/extension authors & contributors
// SPDX-License-Identifier: Apache-2.0
// quantus/extension#7 tier 2: a dapp that asks the *extension* to sign, then
// assembles and submits the extrinsic itself.
//
// The point is not the UI. It is that every intermediate value is on screen —
// the call, the extra, the payload that was signed, the signature that came
// back, the assembled extrinsic. When a node rejects something, that display is
// the debugger, and it is exactly what a real app hides behind a success toast.
//
// Nothing here uses @polkadot/api. The call and the extrinsic are built by
// @quantus/codec from metadata the node produced by running Metadata_metadata
// against the runtime WASM; the WebSocket below is a JSON-RPC transport and
// decodes nothing. See quantus/api#1.
import { Runtime } from '@quantus/codec';
import { web3Accounts, web3Enable, web3FromSource } from '@polkadot/extension-dapp';
import { hexToU8a, u8aToHex } from '@polkadot/util';
import { decodeAddress } from '@polkadot/util-crypto';
const DEFAULT_WS = 'wss://a1-heisenberg.quantus.cat';
let rpcId = 0;
let socket = null;
let runtime = null;
let chain = null;
const $ = (id) => document.getElementById(id);
function show (label, value) {
const row = document.createElement('div');
row.className = 'row';
row.innerHTML = '<span class="label"></span><span class="value"></span>';
row.querySelector('.label').textContent = label;
row.querySelector('.value').textContent = value;
$('log').appendChild(row);
row.scrollIntoView({ block: 'nearest' });
return row;
}
function fail (error) {
show('error', error.message || String(error)).classList.add('bad');
console.error(error);
}
/** A JSON-RPC client, and nothing more. No types cross this boundary decoded. */
function rpc (method, params = []) {
return new Promise((resolve, reject) => {
const id = ++rpcId;
const onMessage = ({ data }) => {
const msg = JSON.parse(data);
if (msg.id !== id) {
return;
}
socket.removeEventListener('message', onMessage);
msg.error ? reject(new Error(`${msg.error.code}: ${msg.error.message}`)) : resolve(msg.result);
};
socket.addEventListener('message', onMessage);
socket.send(JSON.stringify({ id, jsonrpc: '2.0', method, params }));
});
}
async function connect () {
const endpoint = $('endpoint').value.trim() || DEFAULT_WS;
socket = new WebSocket(endpoint);
await new Promise((resolve, reject) => {
socket.addEventListener('open', resolve, { once: true });
socket.addEventListener('error', () => reject(new Error(`cannot reach ${endpoint}`)), { once: true });
});
const [version, genesisHash, metadata, properties] = await Promise.all([
rpc('state_getRuntimeVersion'),
rpc('chain_getBlockHash', ['0x0']),
rpc('state_getMetadata'),
rpc('system_properties')
]);
runtime = Runtime.fromMetadata(hexToU8a(metadata));
chain = {
genesisHash,
rawMetadata: metadata,
specName: version.specName,
specVersion: version.specVersion,
ss58Format: properties.ss58Format ?? 189,
tokenDecimals: Array.isArray(properties.tokenDecimals) ? properties.tokenDecimals[0] : properties.tokenDecimals,
tokenSymbol: Array.isArray(properties.tokenSymbol) ? properties.tokenSymbol[0] : properties.tokenSymbol,
transactionVersion: version.transactionVersion
};
show('chain', `${chain.specName} spec ${chain.specVersion} tx ${chain.transactionVersion}`);
show('genesis', genesisHash);
show('metadata', `${(metadata.length - 2) / 2} bytes, extrinsic v${runtime.extrinsicVersion}`);
show('extensions', runtime.signedExtensions().map((e) => e.identifier).join(', '));
}
/**
* Hand the extension this chain's metadata.
*
* Not optional any more. The extension refuses to sign an extrinsic for a chain
* it has no metadata for, because without the runtime's own description of its
* signed extensions it would be guessing at the payload — and a signature over
* the wrong bytes comes back from a node as `BadProof`, which is also what it
* says about a wrong key.
*/
async function provideMetadata (source) {
const injected = await web3FromSource(source);
const accepted = await injected.metadata.provide({
chain: chain.specName,
genesisHash: chain.genesisHash,
icon: 'substrate',
rawMetadata: chain.rawMetadata,
specVersion: chain.specVersion,
ss58Format: chain.ss58Format,
tokenDecimals: chain.tokenDecimals,
tokenSymbol: chain.tokenSymbol,
types: {}
});
show('metadata provided', accepted);
return injected;
}
async function listAccounts () {
const extensions = await web3Enable('quantus tier 2');
show('extensions', extensions.map((e) => `${e.name} ${e.version}`).join(', ') || 'none');
if (!extensions.length) {
throw new Error('no extension responded to web3Enable — is it installed and is this page served over http?');
}
const accounts = await web3Accounts();
$('account').innerHTML = '';
for (const { address, meta } of accounts) {
const option = document.createElement('option');
option.value = address;
option.dataset.source = meta.source;
option.textContent = `${meta.name || 'unnamed'} — ${address}`;
$('account').appendChild(option);
}
show('accounts', accounts.length);
}
function selected () {
const option = $('account').selectedOptions[0];
if (!option) {
throw new Error('no account selected — click "list accounts" first');
}
return { address: option.value, source: option.dataset.source };
}
async function signRaw () {
const { address, source } = selected();
const injected = await web3FromSource(source);
const data = u8aToHex(new TextEncoder().encode($('message').value));
show('signing bytes', data);
const { signature } = await injected.signer.signRaw({ address, data, type: 'bytes' });
show('signature', `${(signature.length - 2) / 2} bytes`);
show('signature hex', signature);
}
async function transfer () {
const { address, source } = selected();
const injected = await provideMetadata(source);
const dest = $('dest').value.trim();
const amount = $('amount').value.trim();
const call = runtime.encodeCall('Balances', 'transfer_keep_alive', {
dest: { Id: u8aToHex(decodeAddress(dest)) },
value: amount
});
show('call', u8aToHex(call));
const nonce = parseInt(await rpc('system_accountNextIndex', [address]), 10);
show('nonce', nonce);
// An immortal era — quantus/extension#11 covers making these mortal. Its
// implicit is the genesis hash, so there is no birth block for the signer and
// the node to disagree about.
const payload = {
address,
blockHash: chain.genesisHash,
blockNumber: '0x00000000',
era: '0x00',
genesisHash: chain.genesisHash,
method: u8aToHex(call),
nonce: `0x${nonce.toString(16).padStart(8, '0')}`,
signedExtensions: runtime.signedExtensions().map((e) => e.identifier),
specVersion: `0x${chain.specVersion.toString(16).padStart(8, '0')}`,
tip: `0x${'0'.repeat(32)}`,
transactionVersion: `0x${chain.transactionVersion.toString(16).padStart(8, '0')}`,
version: 4
};
// Built here as well as in the extension, from the same metadata. The two must
// agree: the extension signs its own copy, and this one is what gets assembled
// around the signature. Showing it is the whole point of the tier.
const values = runtime.standardExtensions({
blockHash: payload.blockHash,
eraHex: payload.era,
genesisHash: payload.genesisHash,
nonce,
specVersion: chain.specVersion,
transactionVersion: chain.transactionVersion
});
const extra = runtime.encodeExtra(values);
show('extra', u8aToHex(extra));
show('payload', u8aToHex(runtime.signerPayload(call, values)));
const { signature } = await injected.signer.signPayload(payload);
show('signature', `${(signature.length - 2) / 2} bytes, variant 0x${signature.slice(2, 4)}`);
show('signature hex', `${signature.slice(0, 66)}…`);
const extrinsic = runtime.encodeExtrinsic({ Id: u8aToHex(decodeAddress(address)) }, hexToU8a(signature), extra, call);
show('extrinsic', `${extrinsic.length} bytes`);
show('extrinsic hex', `${u8aToHex(extrinsic).slice(0, 130)}…`);
// Decoded with the same runtime that built it, before the node sees it: a bug
// here is ours, a rejection after this is a disagreement with the chain, and
// telling those apart is most of the work.
show('round trip', JSON.stringify(runtime.decodeExtrinsic(extrinsic).call));
const hash = await rpc('author_submitExtrinsic', [u8aToHex(extrinsic)]);
show('accepted', hash).classList.add('good');
}
const wire = (id, fn) => $(id).addEventListener('click', () => {
fn().catch(fail);
});
wire('connect', connect);
wire('accounts', listAccounts);
wire('sign-raw', signRaw);
wire('transfer', transfer);
$('endpoint').value = DEFAULT_WS;
+37
View File
@@ -0,0 +1,37 @@
// Copyright 2019-2026 @polkadot/extension authors & contributors
// SPDX-License-Identifier: Apache-2.0
// Bundles the tier-2 harness. Separate from the extension's own webpack config:
// this is a page served to a browser alongside the extension, not part of it.
const path = require('node:path');
module.exports = {
entry: path.join(__dirname, 'src/harness.js'),
experiments: {
topLevelAwait: true
},
mode: 'development',
output: {
filename: 'harness.js',
path: path.join(__dirname, 'build')
},
resolve: {
alias: {
// The workspace package resolves to its source directory, whose `main`
// points at build output that only exists after `yarn build`. Point at it
// directly so the harness builds from a plain checkout.
'@polkadot/extension-dapp$': path.join(__dirname, '../../packages/extension-dapp/build/bundle.js')
},
extensionAlias: {
'.js': ['.js', '.ts']
},
// @quantus/codec inlines its WASM as base64 and never fetches it, but the
// generated glue still mentions the file — see strip-wasm-fetch-init.mjs in
// quantus/wasm. Nothing here should try to resolve it.
fallback: {
crypto: false
}
},
target: 'web'
};
+6 -2
View File
@@ -10,9 +10,13 @@
".prettierrc.cjs",
"eslint.config.js",
"i18next-scanner.config.cjs",
"rollup.config.js"
"rollup.config.js",
"scripts/**/*.mjs",
"scripts/**/*.js",
"scripts/**/*.cjs"
],
"exclude": [
"**/node_modules/**/*"
"**/node_modules/**/*",
"scripts/tier2/build/**/*"
]
}
+37 -24
View File
@@ -982,6 +982,8 @@ __metadata:
"@polkadot/ui-settings": "npm:^3.16.7"
"@polkadot/util": "npm:^14.0.3"
"@polkadot/util-crypto": "npm:^14.0.3"
"@quantus/codec": "npm:^0.5.0"
"@quantus/crypto": "npm:^0.3.0"
eventemitter3: "npm:^5.0.1"
rxjs: "npm:^7.8.1"
tslib: "npm:^2.8.1"
@@ -996,6 +998,7 @@ __metadata:
"@polkadot/networks": "npm:^14.0.3"
"@polkadot/util": "npm:^14.0.3"
"@polkadot/util-crypto": "npm:^14.0.3"
"@quantus/codec": "npm:^0.5.0"
tslib: "npm:^2.8.1"
peerDependencies:
"@polkadot/api": "*"
@@ -1150,29 +1153,29 @@ __metadata:
languageName: unknown
linkType: soft
"@polkadot/keyring@https://git.lair.cafe/api/packages/quantus/npm/%40polkadot%2Fkeyring/-/14.0.3-quantus.1/keyring-14.0.3-quantus.1.tgz":
version: 14.0.3-quantus.1
resolution: "@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/keyring@https://git.lair.cafe/api/packages/quantus/npm/%40polkadot%2Fkeyring/-/14.0.3-quantus.2/keyring-14.0.3-quantus.2.tgz":
version: 14.0.3-quantus.2
resolution: "@polkadot/keyring@https://git.lair.cafe/api/packages/quantus/npm/%40polkadot%2Fkeyring/-/14.0.3-quantus.2/keyring-14.0.3-quantus.2.tgz"
dependencies:
"@polkadot/util": "npm:14.0.3"
"@polkadot/util-crypto": "npm:14.0.3-quantus.1"
"@polkadot/util-crypto": "npm:14.0.3-quantus.2"
"@quantus/crypto": "npm:^0.1.0"
tslib: "npm:^2.8.0"
peerDependencies:
"@polkadot/util": 14.0.3
"@polkadot/util-crypto": 14.0.3
checksum: 10/513818828aebd066ec618e114441bbd2aa9cb83527fce4a78867d3b9f982e4bb17527090642d704a2a8b1fd047276b30ce8c22efcdc10c81d3e318f4b42c7059
checksum: 10/e3885d6a75b4c006887d24dadbfc25fff562baae84d42fd2125878af60daecbf789deed12c975edfef0884a3caff4dd469dcdcd8b63eacbd1a5fbf8bbfe48815
languageName: node
linkType: hard
"@polkadot/networks@https://git.lair.cafe/api/packages/quantus/npm/%40polkadot%2Fnetworks/-/14.0.3-quantus.1/networks-14.0.3-quantus.1.tgz":
version: 14.0.3-quantus.1
resolution: "@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/networks@https://git.lair.cafe/api/packages/quantus/npm/%40polkadot%2Fnetworks/-/14.0.3-quantus.2/networks-14.0.3-quantus.2.tgz":
version: 14.0.3-quantus.2
resolution: "@polkadot/networks@https://git.lair.cafe/api/packages/quantus/npm/%40polkadot%2Fnetworks/-/14.0.3-quantus.2/networks-14.0.3-quantus.2.tgz"
dependencies:
"@polkadot/util": "npm:14.0.3"
"@substrate/ss58-registry": "npm:^1.51.0"
tslib: "npm:^2.8.0"
checksum: 10/5d93c35908eec5b41f29ffd4a5a79234c7329264e61d6b4346398e5b495b80109c5ce35a2f2bb95e9f6fc4eb22b9da047b9ca4191ec73a668c43da73861d7122
checksum: 10/3d2f0a08cde4be418d073f63587bed451a6ce4c91a8872a5e0c4191ff3e62d57a7942c1a1b6c183ed9c22742f5b95b7496fe4e32e3565baf2093d879ac51a55e
languageName: node
linkType: hard
@@ -1361,14 +1364,14 @@ __metadata:
languageName: node
linkType: hard
"@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":
version: 3.16.7-quantus.1
resolution: "@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.2/ui-keyring-3.16.7-quantus.2.tgz":
version: 3.16.7-quantus.2
resolution: "@polkadot/ui-keyring@https://git.lair.cafe/api/packages/quantus/npm/%40polkadot%2Fui-keyring/-/3.16.7-quantus.2/ui-keyring-3.16.7-quantus.2.tgz"
dependencies:
"@polkadot/keyring": "npm:14.0.3-quantus.1"
"@polkadot/keyring": "npm:14.0.3-quantus.2"
"@polkadot/ui-settings": "npm:3.16.7"
"@polkadot/util": "npm:^14.0.3"
"@polkadot/util-crypto": "npm:14.0.3-quantus.1"
"@polkadot/util-crypto": "npm:14.0.3-quantus.2"
mkdirp: "npm:^3.0.1"
rxjs: "npm:^7.8.1"
store: "npm:^2.0.12"
@@ -1377,7 +1380,7 @@ __metadata:
"@polkadot/keyring": "*"
"@polkadot/ui-settings": "*"
"@polkadot/util": "*"
checksum: 10/8f475e2fbbf795a82513e5195ec924f4c3077cb9cf6f09205db6507a814a527b0b99ba077e44ba506e3c8120366ae1f33371653858c7ed1f291bc1fd2917cb9f
checksum: 10/bd62eb7fb2ad152c8f6844938af34fa75fa6b359d5414cbaca0d77c80b808f8a4e51f62b223cbbb1cbe8feefab43b42db86dd978af92dcce3bab5236cdb3a885
languageName: node
linkType: hard
@@ -1410,13 +1413,13 @@ __metadata:
languageName: node
linkType: hard
"@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":
version: 14.0.3-quantus.1
resolution: "@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.2/util-crypto-14.0.3-quantus.2.tgz":
version: 14.0.3-quantus.2
resolution: "@polkadot/util-crypto@https://git.lair.cafe/api/packages/quantus/npm/%40polkadot%2Futil-crypto/-/14.0.3-quantus.2/util-crypto-14.0.3-quantus.2.tgz"
dependencies:
"@noble/curves": "npm:^1.3.0"
"@noble/hashes": "npm:^1.3.3"
"@polkadot/networks": "npm:14.0.3-quantus.1"
"@polkadot/networks": "npm:14.0.3-quantus.2"
"@polkadot/util": "npm:14.0.3"
"@polkadot/wasm-crypto": "npm:^7.5.3"
"@polkadot/wasm-util": "npm:^7.5.3"
@@ -1428,7 +1431,7 @@ __metadata:
tslib: "npm:^2.8.0"
peerDependencies:
"@polkadot/util": 14.0.3
checksum: 10/ae364a2c47ea6033222f34766f83f3c3740966f83f380978b716013d4ab3862f03355034afe6ef84e7f28c90bda1eb347114c342acd5f4844566580d13d34341
checksum: 10/78f04147ddfe8ae0bca4cba2f235715615a140de9ab95f10d6e4bbabee4a2865e6f8a84f70668722ab688094a8519eeb019ed5122620b355a3ea40b0d8cfdeb8
languageName: node
linkType: hard
@@ -1601,13 +1604,23 @@ __metadata:
languageName: node
linkType: hard
"@quantus/crypto@npm:^0.1.0":
version: 0.1.1
resolution: "@quantus/crypto@npm:0.1.1::__archiveUrl=https%3A%2F%2Fgit.lair.cafe%2Fapi%2Fpackages%2Fquantus%2Fnpm%2F%2540quantus%252Fcrypto%2F-%2F0.1.1%2Fcrypto-0.1.1.tgz"
"@quantus/codec@npm:^0.5.0":
version: 0.5.0
resolution: "@quantus/codec@npm:0.5.0::__archiveUrl=https%3A%2F%2Fgit.lair.cafe%2Fapi%2Fpackages%2Fquantus%2Fnpm%2F%2540quantus%252Fcodec%2F-%2F0.5.0%2Fcodec-0.5.0.tgz"
dependencies:
fflate: "npm:^0.8.2"
tslib: "npm:^2.7.0"
checksum: 10/2ba96cc2310b7c9ede22ed848ba05a9c789345b4ecb4b880e100a65b8280497e79c76f48fa4721e15c8e5567c0278fd08987e7cb10492dfa4e53c0be1d741e68
checksum: 10/248a2ae9650b1521d56aa3bf81c01084ae37474365df1f2dbece928b59647004c719bfb396ba1fccd33ec8e6e67c7e5e82660f9fbbcc2fcda41b5dbe6e3a38fb
languageName: node
linkType: hard
"@quantus/crypto@npm:^0.3.0":
version: 0.3.0
resolution: "@quantus/crypto@npm:0.3.0::__archiveUrl=https%3A%2F%2Fgit.lair.cafe%2Fapi%2Fpackages%2Fquantus%2Fnpm%2F%2540quantus%252Fcrypto%2F-%2F0.3.0%2Fcrypto-0.3.0.tgz"
dependencies:
fflate: "npm:^0.8.2"
tslib: "npm:^2.7.0"
checksum: 10/fa40dbe5206d1ff28038c35825f65f113e4ccf503b3e0126c1d67c7645081c3855b972dfd7e6d76a142b73b1dbfc8947a22354d587b09515f0acf065d6507f13
languageName: node
linkType: hard