Compare commits
22 Commits
quantus-re
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
29033e8bb8
|
|||
|
851500a5fb
|
|||
|
e0aa78360a
|
|||
|
957db67f7b
|
|||
|
a4e19a4b1b
|
|||
|
c378b36a3a
|
|||
|
22eb1c25c8
|
|||
|
6515b398c5
|
|||
|
ebd3b4b29a
|
|||
|
4a052de4c5
|
|||
|
f61cfd4ed5
|
|||
|
bfd3aa80ba
|
|||
|
dfb98b23d1
|
|||
|
8e767c0332
|
|||
|
b32b24348f
|
|||
|
7fbcae31e4
|
|||
|
4f759c4094
|
|||
|
a29a68d48a
|
|||
|
34759485d3
|
|||
|
be69149957
|
|||
|
|
7fa26252f0
|
||
|
|
ac08676ea7
|
140
README.md
@@ -1,71 +1,74 @@
|
||||
# polkadot{.js} extension
|
||||
# blackbeard
|
||||
|
||||
A very simple scaffolding browser extension that injects a [@polkadot/api](https://github.com/polkadot-js/api) Signer into a page, along with any associated accounts, allowing for use by any dapp. This is an extensible POC implementation of a Polkadot/Substrate browser signer.
|
||||
A post-quantum browser wallet for the [Quantus Network](https://quantus.com), forked from the
|
||||
[polkadot{.js} extension](https://github.com/polkadot-js/extension). It keeps upstream's shape — accounts
|
||||
in a popup, a signer injected into dapps — with the cryptography, encoding and account model Quantus
|
||||
actually needs:
|
||||
|
||||
As it stands, it does one thing: it _only_ manages accounts and allows the signing of transactions with those accounts. It does not inject providers for use by dapps at this early point, nor does it perform wallet functions where it constructs and submits txs to the network.
|
||||
- **ML-DSA only.** Accounts are ML-DSA-65 and ML-DSA-87 (the two variants of the runtime's
|
||||
`DilithiumSignatureScheme`), derived as `quantus-cli` and the mobile wallet derive them.
|
||||
- **The runtime is the codec.** Calls and extrinsics are encoded and decoded from the chain's own
|
||||
metadata ([@quantus/codec](https://git.lair.cafe/quantus/wasm)), fetched from a node and checked
|
||||
against its genesis — never from a hardcoded type registry, which drifts from a runtime that changes.
|
||||
- **Wallets, not keypairs.** A recovery phrase is one wallet, shown as every account it unlocks:
|
||||
ML-DSA-65, ML-DSA-87 and wormhole tabs, per account index. The phrase is stored encrypted with the
|
||||
wallet password so further accounts can be derived later.
|
||||
- **Wormhole balances, read-only.** Deposits come from a [blackbeard observer](https://blackbeard.observer)
|
||||
and are checked against the chain's own transfer counts; spent status is decided locally from
|
||||
nullifiers precomputed while the password is to hand, checked against whole buckets of
|
||||
`Wormhole::UsedNullifiers` so no node learns which ones are yours. Sending from a wormhole address
|
||||
is not in the extension yet (quantus/extension#13).
|
||||
|
||||
Work is tracked at [git.lair.cafe/quantus/extension/issues](https://git.lair.cafe/quantus/extension/issues);
|
||||
#1 is the epic.
|
||||
|
||||
## Installation
|
||||
|
||||
- On Chrome, install via [Chrome web store](https://chrome.google.com/webstore/detail/polkadot%7Bjs%7D-extension/mopnmbcafieddcagagdcbnhejhlodfdd)
|
||||
- On Firefox, install via [Firefox add-ons](https://addons.mozilla.org/en-US/firefox/addon/polkadot-js-extension/)
|
||||
There is no store listing. Build from source (below), or use the zips a build writes to the repo root.
|
||||
|
||||

|
||||
- **Firefox:** `about:debugging#/runtime/this-firefox` → **Load Temporary Add-on…** → `master-ff-build.zip`.
|
||||
Temporary add-ons are unloaded on restart; a permanent install of an unsigned add-on needs Developer
|
||||
Edition or Nightly with `xpinstall.signatures.required` set to `false`.
|
||||
- **Chrome / Chromium / Brave:** unzip `master-chrome-build.zip` into a folder, then `chrome://extensions`
|
||||
→ **Developer mode** → **Load unpacked** → that folder. Not `packages/extension/build`, which holds
|
||||
whichever browser was built last.
|
||||
|
||||
## Documentation and examples
|
||||
Find out more about how to use the extension as a Dapp developer, cookbook, as well as answers to most frequent questions in the [Polkadot-js extension documentation](https://polkadot.js.org/docs/extension/)
|
||||
## Privacy settings
|
||||
|
||||
## Firefox installation from source instructions.
|
||||
Everything the extension asks the network is a setting, and each can be pointed at your own
|
||||
infrastructure or turned off:
|
||||
|
||||
1. Uncompress `master-ff-src.zip`
|
||||
2. Run `corepack enable` [More information](https://github.com/nodejs/corepack?tab=readme-ov-file#corepack-enable--name)
|
||||
3. Install dependencies via `yarn install`
|
||||
4. Build all packages via `yarn build`
|
||||
- The `/packages/extension/build` directory will contain the exact code used in the add-on, and should exactly match the uncompressed `master-ff-build`.
|
||||
|
||||
NOTE: If you would like to regenerate the compressed `master-ff-build.zip`, and `master-ff-src.zip` files run: `yarn build:zip:ff`
|
||||
|
||||
## Development version
|
||||
|
||||
Steps to build the extension and view your changes in a browser:
|
||||
|
||||
1. Chrome:
|
||||
1. Build via `yarn build:chrome`
|
||||
- NOTE: You may need to enable corepack by running `corepack enable`
|
||||
2. Install the extension
|
||||
- go to `chrome://extensions/`
|
||||
- ensure you have the Development flag set
|
||||
- "Load unpacked" and point to `packages/extension/build`
|
||||
- if developing, after making changes - refresh the extension
|
||||
2. Firefox
|
||||
1. Build via `yarn build:ff`
|
||||
- NOTE: You may need to enable corepack by running `corepack enable`
|
||||
2. Install the extension
|
||||
- go to `about:debugging#addons`
|
||||
- check "Enable add-on debugging"
|
||||
- click on "Load Temporary Add-on" and point to `packages/extension/build/manifest.json`
|
||||
- if developing, after making changes - reload the extension
|
||||
3. When visiting `https://polkadot.js.org/apps/` it will inject the extension
|
||||
|
||||
Once added, you can create an account (via a generated seed) or import via an existing seed. The [apps UI](https://github.com/polkadot-js/apps/), when loaded, will show these accounts as `<account name> (extension)`
|
||||
- **Read balances from** — the node used for balances and wormhole chain state.
|
||||
- **Look up wormhole deposits with** — the observer asked which deposits went to your wormhole
|
||||
addresses. It learns that those addresses belong to one person; nothing it is sent can name your exits.
|
||||
|
||||
## Development
|
||||
|
||||
The repo is split into a number of packages -
|
||||
```sh
|
||||
yarn install
|
||||
yarn build # both browsers; writes master-chrome-build.zip and master-ff-build.zip
|
||||
yarn test # background specs (the upstream React specs are not run: see quantus/extension#14)
|
||||
yarn lint
|
||||
```
|
||||
|
||||
- [extension](packages/extension/) - All the injection and background processing logic (the main entry)
|
||||
- [extension-ui](packages/extension-ui/) - The UI components for the extension, to build up the popup
|
||||
- [extension-dapp](packages/extension-dapp/) - A convenience wrapper to work with the injected objects, simplifying data extraction for any dapp that wishes to integrate the extension (or any extension that supports the interface)
|
||||
- [extension-inject](packages/extension-inject/) - A convenience wrapper that allows extension developers to inject their extension for use by any dapp
|
||||
The Quantus forks it builds against (`@polkadot/util-crypto`, `@polkadot/keyring`, `@polkadot/ui-keyring`,
|
||||
`@polkadot/networks`) and `@quantus/crypto` / `@quantus/codec` come from the Gitea package registry;
|
||||
see `.yarnrc.yml`. `scripts/tier1` submits real extrinsics against a node, and `scripts/tier2` is a
|
||||
dapp harness for exercising the injected signer in a browser.
|
||||
|
||||
It also contains a [`manifest_chrome.json`](packages/extension/manifest_chrome.json) file which contains the manifest configuration for Chrome and another [`manifest_firefox.json`](packages/extension/manifest_firefox.json) with the configuration for Firefox, for compatibility reasons, and a dummy `manifest.json` file that's only used by the build.
|
||||
The packages keep their upstream `@polkadot/extension-*` names:
|
||||
|
||||
- [extension](packages/extension/) — injection, background and manifests (the main entry)
|
||||
- [extension-base](packages/extension-base/) — the background: keyring, wallets, signing, balances
|
||||
- [extension-ui](packages/extension-ui/) — the popup
|
||||
- [extension-dapp](packages/extension-dapp/) — a convenience wrapper for dapps using any injected extension
|
||||
- [extension-inject](packages/extension-inject/) — the injection interface
|
||||
|
||||
## Dapp developers
|
||||
|
||||
The actual in-depth technical breakdown is given in the next section for any dapp developer wishing to work with the raw objects injected into the window. However, convenience wrappers are provided that allow for any dapp to use this extension (or any other extension that conforms to the interface) without having to manage any additional info.
|
||||
|
||||
The documentation for Dapp development is available [in the polkadot-js doc](https://polkadot.js.org/docs/extension).
|
||||
|
||||
This approach is used to support multiple external signers in for instance [apps](https://github.com/polkadot-js/apps/). You can read more about the convenience wrapper [@polkadot/extension-dapp](packages/extension-dapp/) along with usage samples.
|
||||
Use [@polkadot/extension-dapp](packages/extension-dapp/), or any library that enumerates
|
||||
`window.injectedWeb3`; this extension appears there as `blackbeard`. Signing requests are encoded and
|
||||
shown using the chain's own metadata, so a dapp does not need to supply any.
|
||||
|
||||
## API interface
|
||||
|
||||
@@ -121,8 +124,8 @@ The extension injects `injectedWeb3` into the global `window` object, exposing t
|
||||
```js
|
||||
window.injectedWeb3 = {
|
||||
// this is the name for this extension, there could be multiples injected,
|
||||
// each with their own keys, here `polkadot-js` is for this extension
|
||||
'polkadot-js': {
|
||||
// each with their own keys, here `blackbeard` is for this extension
|
||||
'blackbeard': {
|
||||
// semver for the package
|
||||
version: '0.1.0',
|
||||
|
||||
@@ -134,30 +137,11 @@ window.injectedWeb3 = {
|
||||
}
|
||||
```
|
||||
|
||||
## Mnemonics, Passwords, and Imports/Exports
|
||||
|
||||
### Using the mnemonic and password from the extension
|
||||
## Recovery phrases and passwords
|
||||
|
||||
When you create a keypair via the extension, it supplies a 12-word mnemonic seed and asks you to create a password. This password only encrypts the private key on disk so that the password is required to spend funds in `polkadot-js/apps` or to import the account from backup. The password does not protect the mnemonic phrase. That is, if an attacker were to acquire the mnemonic phrase, they would be able to use it to spend funds without the password.
|
||||
|
||||
### Importing mnemonics from other key generation utilities
|
||||
|
||||
Some key-generation tools, e.g. [Subkey](https://www.substrate.io/kb/integrate/subkey), support hard and soft key derivation as well as passwords that encrypt the mnemonic phrase such that the mnemonic phrase itself is insufficient to spend funds.
|
||||
|
||||
The extension supports these advanced features. When you import an account from a seed, you can add these derivation paths or password to the end of the mnemonic in the following format:
|
||||
|
||||
```
|
||||
<mnemonic phrase>//<hard>/<soft>///<password>
|
||||
```
|
||||
|
||||
That is, hard-derivation paths are prefixed with `//`, soft paths with `/`, and the password with `///`.
|
||||
|
||||
The extension will still ask you to enter a password for this account. As before, this password only encrypts the private key on disk. It is not required to be the same password as the one that encrypts the mnemonic phrase.
|
||||
|
||||
Accounts can also be derived from existing accounts – `Derive New Account` option in account's dropdown menu should be selected. After providing the password of the parent account, along with name and password of the derived account, enter derivation path in the following format:
|
||||
|
||||
```
|
||||
//<hard>/<soft>
|
||||
```
|
||||
|
||||
The path will be added to the mnemonic phrase of the parent account.
|
||||
A wallet's password encrypts the stored recovery phrase and each account's key; the phrase itself is
|
||||
not protected by it, so anyone with the phrase controls every account the wallet shows. Import accepts
|
||||
a BIP39 phrase, or a `0x`-prefixed 32-byte seed for the dev accounts. There are no derivation paths
|
||||
to type: account indices are added from the wallet's menu, and derive the same accounts
|
||||
`quantus-cli` and the mobile wallet derive for that index.
|
||||
|
||||
12
package.json
@@ -42,7 +42,8 @@
|
||||
"lint": "polkadot-dev-run-lint",
|
||||
"postinstall": "polkadot-dev-yarn-only",
|
||||
"test": "EXTENSION_PREFIX='test' polkadot-dev-run-test --loader ./packages/extension-mocks/src/loader-empty.js --env browser ^:.spec.tsx",
|
||||
"test:one": "EXTENSION_PREFIX='test' polkadot-dev-run-test --env browser"
|
||||
"test:one": "EXTENSION_PREFIX='test' polkadot-dev-run-test --env browser",
|
||||
"tier2": "polkadot-exec-webpack --config scripts/tier2/webpack.config.cjs"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@polkadot/dev": "^0.83.3",
|
||||
@@ -52,14 +53,15 @@
|
||||
},
|
||||
"resolutions": {
|
||||
"@polkadot/api": "^16.5.6",
|
||||
"@polkadot/keyring": "https://git.lair.cafe/api/packages/quantus/npm/%40polkadot%2Fkeyring/-/14.0.3-quantus.1/keyring-14.0.3-quantus.1.tgz",
|
||||
"@polkadot/networks": "https://git.lair.cafe/api/packages/quantus/npm/%40polkadot%2Fnetworks/-/14.0.3-quantus.1/networks-14.0.3-quantus.1.tgz",
|
||||
"@polkadot/keyring": "https://git.lair.cafe/api/packages/quantus/npm/%40polkadot%2Fkeyring/-/14.0.3-quantus.3/keyring-14.0.3-quantus.3.tgz",
|
||||
"@polkadot/networks": "https://git.lair.cafe/api/packages/quantus/npm/%40polkadot%2Fnetworks/-/14.0.3-quantus.3/networks-14.0.3-quantus.3.tgz",
|
||||
"@polkadot/rpc-provider": "^16.5.6",
|
||||
"@polkadot/types": "^16.5.6",
|
||||
"@polkadot/ui-keyring": "https://git.lair.cafe/api/packages/quantus/npm/%40polkadot%2Fui-keyring/-/3.16.7-quantus.1/ui-keyring-3.16.7-quantus.1.tgz",
|
||||
"@polkadot/ui-keyring": "https://git.lair.cafe/api/packages/quantus/npm/%40polkadot%2Fui-keyring/-/3.16.7-quantus.3/ui-keyring-3.16.7-quantus.3.tgz",
|
||||
"@polkadot/util": "^14.0.3",
|
||||
"@polkadot/util-crypto": "https://git.lair.cafe/api/packages/quantus/npm/%40polkadot%2Futil-crypto/-/14.0.3-quantus.1/util-crypto-14.0.3-quantus.1.tgz",
|
||||
"@polkadot/util-crypto": "https://git.lair.cafe/api/packages/quantus/npm/%40polkadot%2Futil-crypto/-/14.0.3-quantus.3/util-crypto-14.0.3-quantus.3.tgz",
|
||||
"@polkadot/x-fetch": "^14.0.3",
|
||||
"@quantus/crypto": "^0.3.0",
|
||||
"safe-buffer": "^5.2.1",
|
||||
"typescript": "^5.5.4"
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
|
||||
249
packages/extension-base/src/background/Balances.ts
Normal file
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
144
packages/extension-base/src/background/ChainMetadata.ts
Normal file
@@ -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 }))
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
311
packages/extension-base/src/background/Wallets.ts
Normal file
@@ -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();
|
||||
}
|
||||
}
|
||||
380
packages/extension-base/src/background/Wormhole.ts
Normal file
@@ -0,0 +1,380 @@
|
||||
// Copyright 2019-2026 @polkadot/extension-base authors & contributors
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
import type { ProviderInterface } from '@polkadot/rpc-provider/types';
|
||||
import type { WalletAccount, WormholeBalance, WormholeNullifiersJson } from './types.js';
|
||||
|
||||
import { Runtime } from '@quantus/codec';
|
||||
import { WormholeBranch, wormholeNullifiers } from '@quantus/crypto';
|
||||
|
||||
import { WsProvider } from '@polkadot/rpc-provider';
|
||||
import { hexToU8a, u8aConcat, u8aToHex } from '@polkadot/util';
|
||||
import { base64Decode, base64Encode, blake2AsU8a, decodeAddress, xxhashAsU8a } from '@polkadot/util-crypto';
|
||||
|
||||
import { NullifiersStore } from '../stores/index.js';
|
||||
|
||||
/**
|
||||
* Transfer counts precomputed per wormhole address whenever the password is at
|
||||
* hand. A deposit whose count is past this cannot be checked until the wallet
|
||||
* is unlocked again, and the balance says so rather than guessing.
|
||||
*/
|
||||
export const NULLIFIER_WINDOW = 128;
|
||||
|
||||
/** Headroom added past an address's current transfer count when unlocking. */
|
||||
const NULLIFIER_HEADROOM = 128;
|
||||
|
||||
/**
|
||||
* Buckets of `Wormhole::UsedNullifiers` fetched per check, at least.
|
||||
*
|
||||
* The map is `Blake2_128Concat`, so a key is `blake2_128(n) ‖ n`: looking one up
|
||||
* by key hands the node the nullifier itself, and exits publish their
|
||||
* nullifiers, so the node could name the exit. Instead whole buckets are read,
|
||||
* keyed by the first byte of that hash, and checked here. The node learns which
|
||||
* buckets were asked for, never which entry in them mattered. Buckets holding
|
||||
* no nullifier of ours are added at random until there are this many, so one
|
||||
* deposit is hidden among a sixteenth of all spends rather than a 256th.
|
||||
*/
|
||||
const NULLIFIER_BUCKETS_MIN = 16;
|
||||
|
||||
const KEYS_PAGE = 1000;
|
||||
|
||||
/** How long to wait for the node to answer at all. WsProvider retries forever. */
|
||||
const CONNECT_TIMEOUT_MS = 15_000;
|
||||
|
||||
/** How long a whole check may take before it is abandoned. */
|
||||
const CHECK_TIMEOUT_MS = 120_000;
|
||||
|
||||
function within<T> (promise: Promise<T>, ms: number, message: string): Promise<T> {
|
||||
let timer: ReturnType<typeof setTimeout> | undefined;
|
||||
|
||||
return Promise.race([
|
||||
promise,
|
||||
new Promise<never>((_resolve, reject) => {
|
||||
timer = setTimeout(() => reject(new Error(message)), ms);
|
||||
})
|
||||
]).finally(() => clearTimeout(timer));
|
||||
}
|
||||
|
||||
const NULLIFIER_BYTES = 32;
|
||||
|
||||
const BRANCHES = [
|
||||
{ branch: WormholeBranch.Receive, key: 'receive' },
|
||||
{ branch: WormholeBranch.Change, key: 'change' }
|
||||
] as const;
|
||||
|
||||
type Branch = typeof BRANCHES[number]['key'];
|
||||
|
||||
function packed (rows: Uint8Array[]): string {
|
||||
return base64Encode(u8aConcat(...rows));
|
||||
}
|
||||
|
||||
/** The stored nullifiers of one address, as one byte array of 32-byte entries. */
|
||||
function unpack (stored: string | undefined): Uint8Array {
|
||||
return stored ? base64Decode(stored) : new Uint8Array();
|
||||
}
|
||||
|
||||
/**
|
||||
* Nullifiers for every wormhole address of one wallet account, for transfer
|
||||
* counts `0..NULLIFIER_WINDOW`. Needs the recovery phrase.
|
||||
*/
|
||||
export function initialNullifiers (mnemonic: string, account: WalletAccount): WormholeNullifiersJson | null {
|
||||
if (!account.wormhole) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const out = {} as WormholeNullifiersJson;
|
||||
|
||||
for (const { branch, key } of BRANCHES) {
|
||||
out[key] = wormholeNullifiers(mnemonic, '', account.index, branch, 0, account.wormhole[key].length, 0, NULLIFIER_WINDOW)
|
||||
.map(packed);
|
||||
}
|
||||
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extend stored nullifiers so each address covers its transfer count plus
|
||||
* headroom. Only the addresses that fell short are derived, one call each.
|
||||
*/
|
||||
export function extendNullifiers (mnemonic: string, account: WalletAccount, stored: WormholeNullifiersJson | null, counts: Record<Branch, number[]>): WormholeNullifiersJson | null {
|
||||
if (!account.wormhole) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const out: WormholeNullifiersJson = {
|
||||
change: [...(stored?.change ?? [])],
|
||||
receive: [...(stored?.receive ?? [])]
|
||||
};
|
||||
|
||||
for (const { branch, key } of BRANCHES) {
|
||||
account.wormhole[key].forEach((_, index) => {
|
||||
const have = unpack(out[key][index]).length / NULLIFIER_BYTES;
|
||||
const count = counts[key][index] ?? 0;
|
||||
// Only where a deposit is past what is stored (or nothing is stored yet,
|
||||
// for a wallet made before nullifiers were): then to its count plus
|
||||
// headroom, so the next few deposits are covered too.
|
||||
const target = have === 0
|
||||
? Math.max(count + NULLIFIER_HEADROOM, NULLIFIER_WINDOW)
|
||||
: count >= have
|
||||
? count + NULLIFIER_HEADROOM
|
||||
: have;
|
||||
|
||||
if (target > have) {
|
||||
const [row] = wormholeNullifiers(mnemonic, '', account.index, branch, index, 1, have, target - have);
|
||||
|
||||
out[key][index] = base64Encode(u8aConcat(unpack(out[key][index]), ...row));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
return out;
|
||||
}
|
||||
|
||||
export class Nullifiers {
|
||||
readonly #store = new NullifiersStore();
|
||||
|
||||
static key (walletId: string, accountIndex: number): string {
|
||||
return `${walletId}:${accountIndex}`;
|
||||
}
|
||||
|
||||
get (walletId: string, accountIndex: number): Promise<WormholeNullifiersJson | null> {
|
||||
return new Promise((resolve) => {
|
||||
this.#store.get(Nullifiers.key(walletId, accountIndex), (value) => resolve(value ?? null))
|
||||
.catch(() => resolve(null));
|
||||
});
|
||||
}
|
||||
|
||||
async set (walletId: string, accountIndex: number, value: WormholeNullifiersJson | null): Promise<void> {
|
||||
if (value) {
|
||||
await this.#store.set(Nullifiers.key(walletId, accountIndex), value);
|
||||
}
|
||||
}
|
||||
|
||||
/** Remove every account's nullifiers for a wallet. */
|
||||
async remove (walletId: string, accountIndices: number[]): Promise<void> {
|
||||
for (const index of accountIndices) {
|
||||
await this.#store.remove(Nullifiers.key(walletId, index));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
interface ObserverChain {
|
||||
genesis: string;
|
||||
id: string;
|
||||
status?: string;
|
||||
}
|
||||
|
||||
interface ObserverDeposit {
|
||||
amount: string;
|
||||
to: string;
|
||||
transfer_count: number;
|
||||
}
|
||||
|
||||
interface ObserverDeposits {
|
||||
deposits: ObserverDeposit[];
|
||||
indexed_from: number | null;
|
||||
indexed_to: number | null;
|
||||
next: string | null;
|
||||
}
|
||||
|
||||
async function json<T> (url: string): Promise<T> {
|
||||
const response = await fetch(url);
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`${url} answered ${response.status}`);
|
||||
}
|
||||
|
||||
return response.json() as Promise<T>;
|
||||
}
|
||||
|
||||
/** The observer's name for a chain, found by genesis rather than trusted by name. */
|
||||
async function observerChain (observer: string, genesis: string): Promise<string> {
|
||||
const chains = await json<ObserverChain[]>(`${observer}/v1/chains`);
|
||||
const matches = chains.filter((c) => c.genesis === genesis);
|
||||
const live = matches.find((c) => c.status === 'live') ?? matches[0];
|
||||
|
||||
if (!live) {
|
||||
throw new Error(`The observer at ${observer} does not index this chain`);
|
||||
}
|
||||
|
||||
return live.id;
|
||||
}
|
||||
|
||||
async function depositsFor (observer: string, chain: string, tos: string[]): Promise<ObserverDeposits> {
|
||||
const all: ObserverDeposit[] = [];
|
||||
let page: ObserverDeposits;
|
||||
let after: string | null = null;
|
||||
|
||||
do {
|
||||
const query = new URLSearchParams({ limit: '1000', to: tos.join(',') });
|
||||
|
||||
after && query.set('after', after);
|
||||
page = await json<ObserverDeposits>(`${observer}/v1/chains/${chain}/wormhole/deposits?${query.toString()}`);
|
||||
all.push(...page.deposits);
|
||||
after = page.next;
|
||||
} while (after);
|
||||
|
||||
return { deposits: all, indexed_from: page.indexed_from, indexed_to: page.indexed_to, next: null };
|
||||
}
|
||||
|
||||
/** Every used nullifier in the given first-byte buckets, as lowercase hex. */
|
||||
export async function usedNullifiers (provider: ProviderInterface, buckets: number[]): Promise<Set<string>> {
|
||||
const prefix = u8aConcat(xxhashAsU8a('Wormhole', 128), xxhashAsU8a('UsedNullifiers', 128));
|
||||
const used = new Set<string>();
|
||||
|
||||
for (const bucket of buckets) {
|
||||
const bucketPrefix = u8aToHex(u8aConcat(prefix, new Uint8Array([bucket])));
|
||||
let start: string | undefined;
|
||||
|
||||
for (;;) {
|
||||
const keys = await provider.send<string[]>('state_getKeysPaged', start ? [bucketPrefix, KEYS_PAGE, start] : [bucketPrefix, KEYS_PAGE]);
|
||||
|
||||
// blake2_128(n) ‖ n: the nullifier is the key's last 32 bytes
|
||||
keys.forEach((key) => used.add(`0x${key.slice(-64).toLowerCase()}`));
|
||||
|
||||
if (keys.length < KEYS_PAGE) {
|
||||
break;
|
||||
}
|
||||
|
||||
start = keys[keys.length - 1];
|
||||
}
|
||||
}
|
||||
|
||||
return used;
|
||||
}
|
||||
|
||||
function randomBuckets (have: Set<number>): number[] {
|
||||
const out = new Set(have);
|
||||
|
||||
while (out.size < NULLIFIER_BUCKETS_MIN) {
|
||||
out.add(globalThis.crypto.getRandomValues(new Uint8Array(1))[0]);
|
||||
}
|
||||
|
||||
// Asked in a shuffled order, so the ones that matter do not come first.
|
||||
const list = [...out];
|
||||
|
||||
for (let i = list.length - 1; i > 0; i--) {
|
||||
const j = globalThis.crypto.getRandomValues(new Uint32Array(1))[0] % (i + 1);
|
||||
|
||||
[list[i], list[j]] = [list[j], list[i]];
|
||||
}
|
||||
|
||||
return list;
|
||||
}
|
||||
|
||||
function first<T> (value: unknown): T | undefined {
|
||||
return (Array.isArray(value) ? value[0] : value) as T | undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* What one wallet account's wormhole addresses can still spend.
|
||||
*
|
||||
* - Deposits come from an observer the user chose, by address. Its answer is
|
||||
* checked against the chain's own `Wormhole::TransferCount`, so an index that
|
||||
* is behind shows as deposits it has not found, never as a smaller balance.
|
||||
* - A deposit is spent if its nullifier is in `Wormhole::UsedNullifiers`, read
|
||||
* in whole buckets (see `NULLIFIER_BUCKETS_MIN`). Nullifiers never leave here.
|
||||
* - A deposit whose nullifier was never precomputed is reported as unchecked:
|
||||
* the wallet has to be unlocked to derive it.
|
||||
*
|
||||
* `TransferCount` is keyed on the canonical recipient (limbs reduced mod the
|
||||
* Goldilocks prime). A wormhole address is a Poseidon digest, whose limbs are
|
||||
* already canonical, so the address itself is the key.
|
||||
*/
|
||||
export function wormholeBalance (account: WalletAccount, nullifiers: WormholeNullifiersJson | null, endpoint: string, observer: string): Promise<WormholeBalance> {
|
||||
const provider = new WsProvider(endpoint, false);
|
||||
|
||||
return within(check(provider, account, nullifiers, endpoint, observer), CHECK_TIMEOUT_MS, 'The check took more than two minutes and was abandoned')
|
||||
.finally(() => {
|
||||
provider.disconnect().catch(() => undefined);
|
||||
});
|
||||
}
|
||||
|
||||
async function check (provider: WsProvider, account: WalletAccount, nullifiers: WormholeNullifiersJson | null, endpoint: string, observer: string): Promise<WormholeBalance> {
|
||||
if (!account.wormhole) {
|
||||
throw new Error('This account has no wormhole addresses');
|
||||
}
|
||||
|
||||
await provider.connect();
|
||||
await within(provider.isReady, CONNECT_TIMEOUT_MS, `No answer from ${endpoint}`);
|
||||
|
||||
const [genesis, metadata, properties] = await Promise.all([
|
||||
provider.send<string>('chain_getBlockHash', [0]),
|
||||
provider.send<string>('state_getMetadata', []),
|
||||
provider.send<Record<string, unknown>>('system_properties', [])
|
||||
]);
|
||||
const runtime = Runtime.fromMetadata(hexToU8a(metadata));
|
||||
const addresses = BRANCHES.flatMap(({ key }) =>
|
||||
(account.wormhole?.[key] ?? []).map((address, index) => ({ branch: key, id: u8aToHex(decodeAddress(address)), index }))
|
||||
);
|
||||
|
||||
const targets = addresses.map(({ id }) => runtime.storageTarget('Wormhole', 'TransferCount', [id]));
|
||||
const [{ changes }] = await provider.send<{ changes: [string, string | null][] }[]>('state_queryStorageAt', [targets.map((t) => t.key)]);
|
||||
const raw = new Map(changes.map(([key, value]) => [key, value]));
|
||||
const counts = targets.map((t) => {
|
||||
const value = raw.get(t.key) ?? t.default ?? '0x0000000000000000';
|
||||
|
||||
return Number(runtime.decodeStorage(t.valueTy, hexToU8a(value)) as string);
|
||||
});
|
||||
|
||||
const active = addresses.filter((_, i) => counts[i] > 0);
|
||||
const transfers = counts.reduce((a, b) => a + b, 0);
|
||||
const found = active.length
|
||||
? await depositsFor(observer, await observerChain(observer, genesis), active.map(({ id }) => id))
|
||||
: { deposits: [], indexed_from: null, indexed_to: null, next: null };
|
||||
|
||||
// our nullifier for each found deposit, when it was precomputed
|
||||
const byId = new Map<string, typeof active[number]>(active.map((a) => [a.id, a]));
|
||||
const ours = found.deposits.map((deposit) => {
|
||||
const address = byId.get(deposit.to.toLowerCase());
|
||||
const bytes = address && unpack(nullifiers?.[address.branch][address.index]);
|
||||
const at = deposit.transfer_count * NULLIFIER_BYTES;
|
||||
|
||||
return {
|
||||
amount: BigInt(deposit.amount),
|
||||
nullifier: bytes && bytes.length >= at + NULLIFIER_BYTES
|
||||
? bytes.subarray(at, at + NULLIFIER_BYTES)
|
||||
: null
|
||||
};
|
||||
});
|
||||
|
||||
const buckets = new Set(ours.flatMap(({ nullifier }) => nullifier ? [blake2AsU8a(nullifier, 128)[0]] : []));
|
||||
const used = buckets.size
|
||||
? await usedNullifiers(provider, randomBuckets(buckets))
|
||||
: new Set<string>();
|
||||
|
||||
let spendable = BigInt(0);
|
||||
let spent = BigInt(0);
|
||||
let unchecked = BigInt(0);
|
||||
let uncheckedDeposits = 0;
|
||||
|
||||
for (const { amount, nullifier } of ours) {
|
||||
if (!nullifier) {
|
||||
unchecked += amount;
|
||||
uncheckedDeposits++;
|
||||
} else if (used.has(u8aToHex(nullifier))) {
|
||||
spent += amount;
|
||||
} else {
|
||||
spendable += amount;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
checkedAt: Date.now(),
|
||||
counts: {
|
||||
change: counts.slice(account.wormhole.receive.length),
|
||||
receive: counts.slice(0, account.wormhole.receive.length)
|
||||
},
|
||||
decimals: Number(first(properties['tokenDecimals']) ?? 12),
|
||||
deposits: found.deposits.length,
|
||||
indexedFrom: found.indexed_from,
|
||||
indexedTo: found.indexed_to,
|
||||
missing: Math.max(0, transfers - found.deposits.length),
|
||||
spendable: spendable.toString(),
|
||||
spent: spent.toString(),
|
||||
symbol: String(first(properties['tokenSymbol']) ?? ''),
|
||||
transfers,
|
||||
unchecked: unchecked.toString(),
|
||||
uncheckedDeposits
|
||||
};
|
||||
}
|
||||
@@ -6,19 +6,21 @@
|
||||
import '@polkadot/extension-mocks/chrome';
|
||||
|
||||
import type * as _ from '@polkadot/dev-test/globals.d.ts';
|
||||
import type { ResponseSigning } from '@polkadot/extension-base/background/types';
|
||||
import type { MetadataDef } from '@polkadot/extension-inject/types';
|
||||
import type { ResponseSigning, WalletInfo, WormholeNullifiersJson } from '@polkadot/extension-base/background/types';
|
||||
import type { KeyringPair } from '@polkadot/keyring/types';
|
||||
import type { ExtDef } from '@polkadot/types/extrinsic/signedExtensions/types';
|
||||
import type { SignerPayloadJSON } from '@polkadot/types/types';
|
||||
import type { HexString } from '@polkadot/util/types';
|
||||
import type { KeypairType } from '@polkadot/util-crypto/types';
|
||||
|
||||
import { TypeRegistry } from '@polkadot/types';
|
||||
import { Scheme, sizes, WormholeBranch, wormholeNullifiers } from '@quantus/crypto';
|
||||
|
||||
import keyring from '@polkadot/ui-keyring';
|
||||
import { stringToHex } from '@polkadot/util';
|
||||
import { cryptoWaitReady } from '@polkadot/util-crypto';
|
||||
import { assert, hexToU8a, stringToHex, u8aConcat, u8aToHex } from '@polkadot/util';
|
||||
import { base64Decode, cryptoWaitReady, decodeAddress } from '@polkadot/util-crypto';
|
||||
|
||||
import { AccountsStore } from '../../stores/index.js';
|
||||
import { HEISENBERG_GENESIS, heisenbergMetadataDef } from '../../test/metadata.js';
|
||||
import { extendNullifiers, NULLIFIER_WINDOW } from '../Wormhole.js';
|
||||
import Extension from './Extension.js';
|
||||
import State from './State.js';
|
||||
import Tabs from './Tabs.js';
|
||||
@@ -34,7 +36,9 @@ describe('Extension', () => {
|
||||
try {
|
||||
await cryptoWaitReady();
|
||||
|
||||
keyring.loadAll({ store: new AccountsStore() });
|
||||
// As background.ts does. Without it the harness makes sr25519 accounts and
|
||||
// tests the one keypair type this fork does not target.
|
||||
keyring.loadAll({ store: new AccountsStore(), type: 'dilithium65' });
|
||||
|
||||
state = new State({}, 0);
|
||||
await state.init();
|
||||
@@ -48,27 +52,33 @@ describe('Extension', () => {
|
||||
}
|
||||
}
|
||||
|
||||
// `type` is passed through rather than special-cased for ethereum: the default
|
||||
// is now ML-DSA, so a test that needs a derivable parent has to say so.
|
||||
// `pub(extrinsic.sign)` is asynchronous before it queues anything: it fetches
|
||||
// the chain's metadata first, so the approval screen has something to decode
|
||||
// the call with. Reading allSignRequests straight after calling it is a race
|
||||
// the test would lose.
|
||||
const nextSignRequest = async () => {
|
||||
const before = state.allSignRequests.length;
|
||||
|
||||
for (let i = 0; i < 100 && state.allSignRequests.length === before; i++) {
|
||||
await new Promise((resolve) => setTimeout(resolve, 10));
|
||||
}
|
||||
|
||||
return state.allSignRequests[state.allSignRequests.length - 1];
|
||||
};
|
||||
|
||||
const createAccount = async (type?: KeypairType): Promise<string> => {
|
||||
await extension.handle('id', 'pri(accounts.create.suri)', type && type === 'ethereum'
|
||||
? {
|
||||
name: 'parent',
|
||||
password,
|
||||
suri,
|
||||
type
|
||||
}
|
||||
: {
|
||||
name: 'parent',
|
||||
password,
|
||||
suri
|
||||
}, {} as chrome.runtime.Port);
|
||||
const { address } = await extension.handle('id', 'pri(seed.validate)', type && type === 'ethereum'
|
||||
? {
|
||||
suri,
|
||||
type
|
||||
}
|
||||
: {
|
||||
suri
|
||||
}, {} as chrome.runtime.Port);
|
||||
await extension.handle('id', 'pri(accounts.create.suri)', {
|
||||
name: 'parent',
|
||||
password,
|
||||
suri,
|
||||
...(type ? { type } : {})
|
||||
}, {} as chrome.runtime.Port);
|
||||
const { address } = await extension.handle('id', 'pri(seed.validate)', {
|
||||
suri,
|
||||
...(type ? { type } : {})
|
||||
}, {} as chrome.runtime.Port);
|
||||
|
||||
return address;
|
||||
};
|
||||
@@ -88,65 +98,64 @@ describe('Extension', () => {
|
||||
expect(result.exportedJson.encoded).toBeDefined();
|
||||
});
|
||||
|
||||
describe('account derivation', () => {
|
||||
let address: string;
|
||||
describe('both ML-DSA parameter sets', () => {
|
||||
// The chain's DilithiumSignatureScheme has exactly two variants, so both are
|
||||
// in use and both must be importable. crystal_alice is the vector: seed
|
||||
// 0x00…00 as ML-DSA-87, funded at dev genesis, and its address is the one the
|
||||
// chain itself holds a balance for. The same seed as ML-DSA-65 is a different,
|
||||
// unfunded account — which is what importing it as the default type used to
|
||||
// produce, with nothing on screen to say so.
|
||||
const ZERO_SEED = `0x${'00'.repeat(32)}`;
|
||||
const CRYSTAL_ALICE = 'qzk1Nxai3dZD9Cn5kwGcgL6mKxsfxwqdis7kDQJ52aJS2vSn7';
|
||||
|
||||
beforeEach(async () => {
|
||||
address = await createAccount();
|
||||
});
|
||||
|
||||
it('pri(derivation.validate) passes for valid suri', async () => {
|
||||
const result = await extension.handle('id', 'pri(derivation.validate)', {
|
||||
parentAddress: address,
|
||||
parentPassword: password,
|
||||
suri: '//path'
|
||||
it('validates a seed as ML-DSA-87 to the account the chain funded', async () => {
|
||||
const { address } = await extension.handle('id', 'pri(seed.validate)', {
|
||||
suri: ZERO_SEED,
|
||||
type: 'dilithium87'
|
||||
}, {} as chrome.runtime.Port);
|
||||
|
||||
expect(result).toEqual({
|
||||
address: '5FP3TT3EruYBNh8YM8yoxsreMx7uZv1J1zNX7fFhoC5enwmN',
|
||||
suri: '//path'
|
||||
});
|
||||
expect(u8aToHex(decodeAddress(address))).toEqual(u8aToHex(decodeAddress(CRYSTAL_ALICE)));
|
||||
});
|
||||
|
||||
it('pri(derivation.validate) throws for invalid suri', async () => {
|
||||
await expect(extension.handle('id', 'pri(derivation.validate)', {
|
||||
parentAddress: address,
|
||||
parentPassword: password,
|
||||
suri: 'invalid-path'
|
||||
}, {} as chrome.runtime.Port)).rejects.toThrow(/is not a valid derivation path/);
|
||||
it('gives a different account for the same seed as ML-DSA-65', async () => {
|
||||
const { address } = await extension.handle('id', 'pri(seed.validate)', {
|
||||
suri: ZERO_SEED,
|
||||
type: 'dilithium65'
|
||||
}, {} as chrome.runtime.Port);
|
||||
|
||||
expect(u8aToHex(decodeAddress(address))).not.toEqual(u8aToHex(decodeAddress(CRYSTAL_ALICE)));
|
||||
});
|
||||
|
||||
it('pri(derivation.validate) throws for invalid password', async () => {
|
||||
await expect(extension.handle('id', 'pri(derivation.validate)', {
|
||||
parentAddress: address,
|
||||
parentPassword: 'invalid-password',
|
||||
suri: '//path'
|
||||
}, {} as chrome.runtime.Port)).rejects.toThrow(/invalid password/);
|
||||
});
|
||||
|
||||
it('pri(derivation.create) adds a derived account', async () => {
|
||||
await extension.handle('id', 'pri(derivation.create)', {
|
||||
name: 'child',
|
||||
parentAddress: address,
|
||||
parentPassword: password,
|
||||
it('stores an imported ML-DSA-87 account as ML-DSA-87, and it signs', async () => {
|
||||
await extension.handle('id', 'pri(accounts.create.suri)', {
|
||||
name: 'crystal_alice',
|
||||
password,
|
||||
suri: '//path'
|
||||
suri: ZERO_SEED,
|
||||
type: 'dilithium87'
|
||||
}, {} as chrome.runtime.Port);
|
||||
expect(keyring.getAccounts()).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('pri(derivation.create) saves parent address in meta', async () => {
|
||||
await extension.handle('id', 'pri(derivation.create)', {
|
||||
name: 'child',
|
||||
parentAddress: address,
|
||||
parentPassword: password,
|
||||
password,
|
||||
suri: '//path'
|
||||
}, {} as chrome.runtime.Port);
|
||||
expect(keyring.getAccount('5FP3TT3EruYBNh8YM8yoxsreMx7uZv1J1zNX7fFhoC5enwmN')?.meta.parentAddress).toEqual(address);
|
||||
const pair = keyring.getPair(CRYSTAL_ALICE);
|
||||
|
||||
expect(pair.type).toEqual('dilithium87');
|
||||
|
||||
pair.decodePkcs8(password);
|
||||
|
||||
// Variant 0 is Dilithium87 in the runtime's DilithiumSignatureScheme, and
|
||||
// the body is sig ‖ pk at the 87 sizes.
|
||||
const signed = pair.sign(new Uint8Array([1, 2, 3]), { context: new Uint8Array(), withType: true });
|
||||
|
||||
expect(signed[0]).toEqual(0);
|
||||
expect(signed.length).toEqual(sizes(Scheme.MlDsa87).signatureWithPublicKey + 1);
|
||||
});
|
||||
});
|
||||
|
||||
// Upstream derived child accounts from a parent pair along a //hard/soft
|
||||
// path. ML-DSA keys have no such derivation; Quantus derives every account
|
||||
// from the mnemonic instead, so the messages are gone (quantus/common#6).
|
||||
it('has no account derivation', async () => {
|
||||
await expect(extension.handle('id', 'pri(derivation.validate)' as 'pri(ping)', {} as never, {} as chrome.runtime.Port)).rejects.toThrow(/Unable to handle message/);
|
||||
});
|
||||
|
||||
describe('account management', () => {
|
||||
let address: string;
|
||||
|
||||
@@ -182,298 +191,93 @@ describe('Extension', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('custom user extension', () => {
|
||||
let address: string, payload: SignerPayloadJSON, pair: KeyringPair;
|
||||
describe('signing an extrinsic', () => {
|
||||
// Upstream's block here was five variations on "does the extension agree with
|
||||
// @polkadot/api", each computing an expected signature with
|
||||
// `registry.createType('ExtrinsicPayload', …).sign(pair)` and comparing.
|
||||
//
|
||||
// None of that survives the fork. @polkadot/types cannot describe this chain
|
||||
// — it caps fixed arrays at 2048 bytes and ML-DSA signatures are 5261 and
|
||||
// 7219 — and `ExtrinsicPayload.sign` has nowhere to put a FIPS 204 context,
|
||||
// which an ML-DSA pair will not sign without. quantus/api#1 has the evidence.
|
||||
//
|
||||
// Those tests were also built on `userExtensions`: a dapp declaring, in
|
||||
// JavaScript, what an unrecognised signed extension contributes to the
|
||||
// payload. That mechanism is gone, and its absence is the point. The runtime
|
||||
// *declares* its extensions and @quantus/codec reads them, so there is
|
||||
// nothing for a dapp to tell the wallet and no way for it to be believed.
|
||||
let address: string, pair: KeyringPair;
|
||||
|
||||
const payloadFor = (genesisHash: string): SignerPayloadJSON => ({
|
||||
address,
|
||||
blockHash: HEISENBERG_GENESIS,
|
||||
blockNumber: '0x00000393',
|
||||
era: '0x00', // immortal
|
||||
genesisHash: genesisHash as HexString,
|
||||
method: '0x020300d43593c715fdd31c61141abd04a99fd6822c8558854ccde39a5684e7a56da27d02286bee',
|
||||
nonce: '0x00000000',
|
||||
signedExtensions: ['CheckSpecVersion', 'CheckTxVersion', 'CheckGenesis', 'CheckMortality', 'CheckNonce', 'CheckWeight', 'ChargeTransactionPayment'],
|
||||
specVersion: '0x00000094',
|
||||
tip: '0x00000000000000000000000000000000',
|
||||
transactionVersion: '0x00000006',
|
||||
version: 4
|
||||
});
|
||||
|
||||
// The newest request, not the oldest: requests from earlier tests in this
|
||||
// file are still queued, and approving one of those silently tests nothing.
|
||||
const approve = async () => extension.handle('1615192072290.7', 'pri(signing.approve.password)', {
|
||||
id: (await nextSignRequest()).id,
|
||||
password,
|
||||
savePass: false
|
||||
}, {} as chrome.runtime.Port);
|
||||
|
||||
beforeEach(async () => {
|
||||
address = await createAccount();
|
||||
pair = keyring.getPair(address);
|
||||
pair.decodePkcs8(password);
|
||||
payload = {
|
||||
});
|
||||
|
||||
it('signs an extrinsic for a chain whose metadata it has', async () => {
|
||||
await state.saveMetadata(heisenbergMetadataDef());
|
||||
|
||||
const signing = tabs.handle('1615191860871.5', 'pub(extrinsic.sign)', payloadFor(HEISENBERG_GENESIS), 'http://localhost:3000', {} as chrome.runtime.Port);
|
||||
|
||||
expect(await approve()).toEqual(true);
|
||||
|
||||
const { signature } = await signing as ResponseSigning;
|
||||
const bytes = hexToU8a(signature);
|
||||
|
||||
// Variant byte for the pair's scheme, then sig ‖ pk as a fixed array.
|
||||
expect(bytes.length).toEqual(sizes(Scheme.MlDsa65).signatureWithPublicKey + 1);
|
||||
expect(bytes[0]).toEqual(1);
|
||||
});
|
||||
|
||||
// The refusal that replaces guessing. Without the runtime's own description
|
||||
// there is no way to know what its signed extensions contribute, and a
|
||||
// signature over the wrong bytes comes back from a node as `BadProof` —
|
||||
// indistinguishable from a wrong key, and only ever seen by the user.
|
||||
it('refuses to sign for a chain whose metadata it does not have', async () => {
|
||||
const unknown = '0x242a54b35e1aad38f37b884eddeb71f6f9931b02fac27bf52dfb62ef754e5e62';
|
||||
const signing = tabs.handle('1615191860871.6', 'pub(extrinsic.sign)', payloadFor(unknown), 'http://localhost:3000', {} as chrome.runtime.Port);
|
||||
|
||||
await expect(approve()).rejects.toThrow(/No metadata for chain/);
|
||||
await expect(signing).rejects.toThrow(/No metadata for chain/);
|
||||
});
|
||||
|
||||
// Raw bytes carry no payload to build and no extension to read, so this
|
||||
// channel still works for a chain the extension has never heard of.
|
||||
it('signs raw bytes without any metadata', async () => {
|
||||
const signing = tabs.handle('1615191860871.7', 'pub(bytes.sign)', {
|
||||
address,
|
||||
blockHash: '0xe1b1dda72998846487e4d858909d4f9a6bbd6e338e4588e5d809de16b1317b80',
|
||||
blockNumber: '0x00000393',
|
||||
era: '0x3601',
|
||||
genesisHash: '0x242a54b35e1aad38f37b884eddeb71f6f9931b02fac27bf52dfb62ef754e5e62',
|
||||
method: '0x040105fa8eaf04151687736326c9fea17e25fc5287613693c912909cb226aa4794f26a4882380100',
|
||||
nonce: '0x0000000000000000',
|
||||
signedExtensions: ['CheckSpecVersion', 'CheckTxVersion', 'CheckGenesis', 'CheckMortality', 'CheckNonce', 'CheckWeight', 'ChargeTransactionPayment'],
|
||||
specVersion: '0x00000026',
|
||||
tip: '0x00000000000000000000000000000000',
|
||||
transactionVersion: '0x00000005',
|
||||
version: 4
|
||||
};
|
||||
});
|
||||
data: '0x68656c6c6f',
|
||||
type: 'bytes'
|
||||
}, 'http://localhost:3000', {} as chrome.runtime.Port);
|
||||
|
||||
it('signs with default signed extensions', async () => {
|
||||
const registry = new TypeRegistry();
|
||||
expect(await approve()).toEqual(true);
|
||||
|
||||
registry.setSignedExtensions(payload.signedExtensions);
|
||||
const { signature } = await signing as ResponseSigning;
|
||||
|
||||
const signatureExpected = registry
|
||||
.createType('ExtrinsicPayload', payload, { version: payload.version }).sign(pair);
|
||||
|
||||
// eslint-disable-next-line jest/valid-expect-in-promise
|
||||
tabs.handle('1615191860871.5', 'pub(extrinsic.sign)', payload, 'http://localhost:3000', {} as chrome.runtime.Port)
|
||||
.then((result) => {
|
||||
// eslint-disable-next-line jest/no-conditional-expect
|
||||
expect((result as ResponseSigning)?.signature).toEqual(signatureExpected.signature);
|
||||
}).catch((err) => console.log(err));
|
||||
|
||||
const res = await extension.handle('1615192072290.7', 'pri(signing.approve.password)', {
|
||||
id: state.allSignRequests[0].id,
|
||||
password,
|
||||
savePass: false
|
||||
}, {} as chrome.runtime.Port);
|
||||
|
||||
expect(res).toEqual(true);
|
||||
});
|
||||
|
||||
it('signs with default signed extensions - ethereum', async () => {
|
||||
const ethAddress = await createAccount('ethereum');
|
||||
const ethPair = keyring.getPair(ethAddress);
|
||||
|
||||
ethPair.decodePkcs8(password);
|
||||
const ethPayload: SignerPayloadJSON = {
|
||||
address: ethAddress,
|
||||
blockHash: '0xf9fc354edc3ff49f43d5e2c14e3c609a0c4ba469ed091edf893d672993dc9bc0',
|
||||
blockNumber: '0x00000393',
|
||||
era: '0x3601',
|
||||
genesisHash: '0xf9fc354edc3ff49f43d5e2c14e3c609a0c4ba469ed091edf893d672993dc9bc0',
|
||||
method: '0x03003cd0a705a2dc65e5b1e1205896baa2be8a07c6e0070010a5d4e8',
|
||||
nonce: '0x00000000',
|
||||
signedExtensions: [
|
||||
'CheckSpecVersion',
|
||||
'CheckTxVersion',
|
||||
'CheckGenesis',
|
||||
'CheckMortality',
|
||||
'CheckNonce',
|
||||
'CheckWeight',
|
||||
'ChargeTransactionPayment'
|
||||
],
|
||||
specVersion: '0x000003e9',
|
||||
tip: '0x00000000000000000000000000000000',
|
||||
transactionVersion: '0x00000002',
|
||||
version: 4
|
||||
};
|
||||
const registry = new TypeRegistry();
|
||||
|
||||
registry.setSignedExtensions(payload.signedExtensions);
|
||||
|
||||
const signatureExpected = registry
|
||||
.createType('ExtrinsicPayload', ethPayload, { version: ethPayload.version }).sign(ethPair);
|
||||
|
||||
// eslint-disable-next-line jest/valid-expect-in-promise
|
||||
tabs.handle('1615191860871.5', 'pub(extrinsic.sign)', ethPayload, 'http://localhost:3000', {} as chrome.runtime.Port)
|
||||
.then((result) => {
|
||||
// eslint-disable-next-line jest/no-conditional-expect
|
||||
expect((result as ResponseSigning)?.signature).toEqual(signatureExpected.signature);
|
||||
}).catch((err) => console.log(err));
|
||||
|
||||
const res = await extension.handle('1615192072290.7', 'pri(signing.approve.password)', {
|
||||
id: state.allSignRequests[0].id,
|
||||
password,
|
||||
savePass: false
|
||||
}, {} as chrome.runtime.Port);
|
||||
|
||||
expect(res).toEqual(true);
|
||||
});
|
||||
|
||||
it('signs with user extensions, known types', async () => {
|
||||
const types = {} as unknown as Record<string, string>;
|
||||
|
||||
const userExtensions = {
|
||||
MyUserExtension: {
|
||||
extrinsic: {
|
||||
assetId: 'AssetId'
|
||||
},
|
||||
payload: {}
|
||||
}
|
||||
} as unknown as ExtDef;
|
||||
|
||||
const meta: MetadataDef = {
|
||||
chain: 'Development',
|
||||
color: '#191a2e',
|
||||
genesisHash: '0x242a54b35e1aad38f37b884eddeb71f6f9931b02fac27bf52dfb62ef754e5e62',
|
||||
icon: '',
|
||||
specVersion: 38,
|
||||
ss58Format: 0,
|
||||
tokenDecimals: 12,
|
||||
tokenSymbol: '',
|
||||
types,
|
||||
userExtensions
|
||||
};
|
||||
|
||||
await state.saveMetadata(meta);
|
||||
|
||||
const payload: SignerPayloadJSON = {
|
||||
address,
|
||||
blockHash: '0xe1b1dda72998846487e4d858909d4f9a6bbd6e338e4588e5d809de16b1317b80',
|
||||
blockNumber: '0x00000393',
|
||||
era: '0x3601',
|
||||
genesisHash: '0x242a54b35e1aad38f37b884eddeb71f6f9931b02fac27bf52dfb62ef754e5e62',
|
||||
method: '0x040105fa8eaf04151687736326c9fea17e25fc5287613693c912909cb226aa4794f26a4882380100',
|
||||
nonce: '0x0000000000000000',
|
||||
signedExtensions: ['MyUserExtension'],
|
||||
specVersion: '0x00000026',
|
||||
tip: '0x00000000000000000000000000000000',
|
||||
transactionVersion: '0x00000005',
|
||||
version: 4
|
||||
};
|
||||
|
||||
const registry = new TypeRegistry();
|
||||
|
||||
registry.setSignedExtensions(payload.signedExtensions, userExtensions);
|
||||
registry.register(types);
|
||||
|
||||
const signatureExpected = registry
|
||||
.createType('ExtrinsicPayload', payload, { version: payload.version }).sign(pair);
|
||||
|
||||
// eslint-disable-next-line jest/valid-expect-in-promise
|
||||
tabs.handle('1615191860771.5', 'pub(extrinsic.sign)', payload, 'http://localhost:3000', {} as chrome.runtime.Port)
|
||||
.then((result) => {
|
||||
// eslint-disable-next-line jest/no-conditional-expect
|
||||
expect((result as ResponseSigning)?.signature).toEqual(signatureExpected.signature);
|
||||
}).catch((err) => console.log(err));
|
||||
|
||||
const res = await extension.handle('1615192062290.7', 'pri(signing.approve.password)', {
|
||||
id: state.allSignRequests[0].id,
|
||||
password,
|
||||
savePass: false
|
||||
}, {} as chrome.runtime.Port);
|
||||
|
||||
expect(res).toEqual(true);
|
||||
});
|
||||
|
||||
it('override default signed extension', async () => {
|
||||
const types = {
|
||||
FeeExchangeV1: {
|
||||
assetId: 'Compact<AssetId>',
|
||||
maxPayment: 'Compact<Balance>'
|
||||
},
|
||||
PaymentOptions: {
|
||||
feeExchange: 'FeeExchangeV1',
|
||||
tip: 'Compact<Balance>'
|
||||
}
|
||||
} as unknown as Record<string, string>;
|
||||
|
||||
const userExtensions = {
|
||||
ChargeTransactionPayment: {
|
||||
extrinsic: {
|
||||
transactionPayment: 'PaymentOptions'
|
||||
},
|
||||
payload: {}
|
||||
}
|
||||
} as unknown as ExtDef;
|
||||
|
||||
const meta: MetadataDef = {
|
||||
chain: 'Development',
|
||||
color: '#191a2e',
|
||||
genesisHash: '0x242a54b35e1aad38f37b884eddeb71f6f9931b02fac27bf52dfb62ef754e5e62',
|
||||
icon: '',
|
||||
specVersion: 38,
|
||||
ss58Format: 0,
|
||||
tokenDecimals: 12,
|
||||
tokenSymbol: '',
|
||||
types,
|
||||
userExtensions
|
||||
};
|
||||
|
||||
await state.saveMetadata(meta);
|
||||
|
||||
const registry = new TypeRegistry();
|
||||
|
||||
registry.setSignedExtensions(payload.signedExtensions, userExtensions);
|
||||
registry.register(types);
|
||||
|
||||
const signatureExpected = registry
|
||||
.createType('ExtrinsicPayload', payload, { version: payload.version }).sign(pair);
|
||||
|
||||
// eslint-disable-next-line jest/valid-expect-in-promise
|
||||
tabs.handle('1615191860771.5', 'pub(extrinsic.sign)', payload, 'http://localhost:3000', {} as chrome.runtime.Port)
|
||||
.then((result) => {
|
||||
// eslint-disable-next-line jest/no-conditional-expect
|
||||
expect((result as ResponseSigning)?.signature).toEqual(signatureExpected.signature);
|
||||
}).catch((err) => console.log(err));
|
||||
|
||||
const res = await extension.handle('1615192062290.7', 'pri(signing.approve.password)', {
|
||||
id: state.allSignRequests[0].id,
|
||||
password,
|
||||
savePass: false
|
||||
}, {} as chrome.runtime.Port);
|
||||
|
||||
expect(res).toEqual(true);
|
||||
});
|
||||
|
||||
it('signs with user extensions, additional types', async () => {
|
||||
const types = {
|
||||
myCustomType: {
|
||||
feeExchange: 'Compact<AssetId>',
|
||||
tip: 'Compact<Balance>'
|
||||
}
|
||||
} as unknown as Record<string, string>;
|
||||
|
||||
const userExtensions = {
|
||||
MyUserExtension: {
|
||||
extrinsic: {
|
||||
myCustomType: 'myCustomType'
|
||||
},
|
||||
payload: {}
|
||||
}
|
||||
} as unknown as ExtDef;
|
||||
|
||||
const meta: MetadataDef = {
|
||||
chain: 'Development',
|
||||
color: '#191a2e',
|
||||
genesisHash: '0x242a54b35e1aad38f37b884eddeb71f6f9931b02fac27bf52dfb62ef754e5e62',
|
||||
icon: '',
|
||||
specVersion: 38,
|
||||
ss58Format: 0,
|
||||
tokenDecimals: 12,
|
||||
tokenSymbol: '',
|
||||
types,
|
||||
userExtensions
|
||||
};
|
||||
|
||||
await state.saveMetadata(meta);
|
||||
|
||||
const payload = {
|
||||
address,
|
||||
blockHash: '0xe1b1dda72998846487e4d858909d4f9a6bbd6e338e4588e5d809de16b1317b80',
|
||||
blockNumber: '0x00000393',
|
||||
era: '0x3601',
|
||||
genesisHash: '0x242a54b35e1aad38f37b884eddeb71f6f9931b02fac27bf52dfb62ef754e5e62',
|
||||
method: '0x040105fa8eaf04151687736326c9fea17e25fc5287613693c912909cb226aa4794f26a4882380100',
|
||||
nonce: '0x0000000000000000',
|
||||
signedExtensions: ['MyUserExtension', 'CheckTxVersion', 'CheckGenesis', 'CheckMortality', 'CheckNonce', 'CheckWeight', 'ChargeTransactionPayment'],
|
||||
specVersion: '0x00000026',
|
||||
tip: null,
|
||||
transactionVersion: '0x00000005',
|
||||
version: 4
|
||||
} as unknown as SignerPayloadJSON;
|
||||
|
||||
const registry = new TypeRegistry();
|
||||
|
||||
registry.setSignedExtensions(payload.signedExtensions, userExtensions);
|
||||
registry.register(types);
|
||||
|
||||
const signatureExpected = registry
|
||||
.createType('ExtrinsicPayload', payload, { version: payload.version }).sign(pair);
|
||||
|
||||
// eslint-disable-next-line jest/valid-expect-in-promise
|
||||
tabs.handle('1615191860771.5', 'pub(extrinsic.sign)', payload, 'http://localhost:3000', {} as chrome.runtime.Port)
|
||||
.then((result) => {
|
||||
// eslint-disable-next-line jest/no-conditional-expect
|
||||
expect((result as ResponseSigning)?.signature).toEqual(signatureExpected.signature);
|
||||
}).catch((err) => console.log(err));
|
||||
|
||||
const res = await extension.handle('1615192062290.7', 'pri(signing.approve.password)', {
|
||||
id: state.allSignRequests[0].id,
|
||||
password,
|
||||
savePass: false
|
||||
}, {} as chrome.runtime.Port);
|
||||
|
||||
expect(res).toEqual(true);
|
||||
expect(hexToU8a(signature).length).toEqual(sizes(Scheme.MlDsa65).signatureWithPublicKey);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -482,18 +286,22 @@ describe('Extension', () => {
|
||||
|
||||
beforeEach(async () => {
|
||||
address = await createAccount();
|
||||
// A chain the extension has metadata for: these tests are about how a
|
||||
// request is routed and displayed, and without it signing refuses before
|
||||
// any of that is reached.
|
||||
await state.saveMetadata(heisenbergMetadataDef());
|
||||
payload = {
|
||||
address,
|
||||
blockHash: '0xe1b1dda72998846487e4d858909d4f9a6bbd6e338e4588e5d809de16b1317b80',
|
||||
blockHash: HEISENBERG_GENESIS,
|
||||
blockNumber: '0x00000393',
|
||||
era: '0x3601',
|
||||
genesisHash: '0x242a54b35e1aad38f37b884eddeb71f6f9931b02fac27bf52dfb62ef754e5e62',
|
||||
method: '0x040105fa8eaf04151687736326c9fea17e25fc5287613693c912909cb226aa4794f26a4882380100',
|
||||
nonce: '0x0000000000000000',
|
||||
era: '0x00',
|
||||
genesisHash: HEISENBERG_GENESIS,
|
||||
method: '0x020300d43593c715fdd31c61141abd04a99fd6822c8558854ccde39a5684e7a56da27d02286bee',
|
||||
nonce: '0x00000000',
|
||||
signedExtensions: ['CheckSpecVersion', 'CheckTxVersion', 'CheckGenesis', 'CheckMortality', 'CheckNonce', 'CheckWeight', 'ChargeTransactionPayment'],
|
||||
specVersion: '0x00000026',
|
||||
specVersion: '0x00000094',
|
||||
tip: '0x00000000000000000000000000000000',
|
||||
transactionVersion: '0x00000005',
|
||||
transactionVersion: '0x00000006',
|
||||
version: 4
|
||||
};
|
||||
});
|
||||
@@ -519,7 +327,7 @@ describe('Extension', () => {
|
||||
} as unknown as SignerPayloadJSON, 'http://localhost:3000', {} as chrome.runtime.Port)
|
||||
.catch((err) => console.log(err));
|
||||
|
||||
const queued = state.allSignRequests[state.allSignRequests.length - 1];
|
||||
const queued = await nextSignRequest();
|
||||
|
||||
const res = await extension.handle('1615192062290.7', 'pri(signing.approve.password)', {
|
||||
id: queued.id,
|
||||
@@ -537,7 +345,7 @@ describe('Extension', () => {
|
||||
tabs.handle('1615191860871.8', 'pub(extrinsic.sign)', payload, 'http://localhost:3000', {} as chrome.runtime.Port)
|
||||
.catch((err) => console.log(err));
|
||||
|
||||
const queued = state.allSignRequests[state.allSignRequests.length - 1];
|
||||
const queued = await nextSignRequest();
|
||||
|
||||
expect(queued.request.channel).toEqual('extrinsic');
|
||||
|
||||
@@ -550,4 +358,204 @@ describe('Extension', () => {
|
||||
expect(res).toEqual(true);
|
||||
});
|
||||
});
|
||||
describe('wallets', () => {
|
||||
// Independent vectors only: none of these came from running this code.
|
||||
// - the chain node's own test data (node/src/tests/data/quantus_key_test_data.rs),
|
||||
// also pinned by the mobile wallet's SDK: TEST_MNEMONIC's wormhole receive 0
|
||||
// - quantus-cli 2.2.2, via @quantus/crypto's conformance tests: DEV's accounts
|
||||
// - crystal_alice, the ML-DSA-87 dev account for the all-zero seed
|
||||
const NODE_PHRASE = 'orchard answer curve patient visual flower maze noise retreat penalty cage small earth domain scan pitch bottom crunch theme club client swap slice raven';
|
||||
const NODE_WORMHOLE = '0xdfcfd6e59c75d208e84f54a887537bcf7b04265790ec79960bf49de123404d0e';
|
||||
const DEV_PHRASE = 'bottom drive obey lake curtain smoke basket hold race lonely fit walk';
|
||||
const DEV_65 = '0xf647dbdefebcfcf726ba078a83481ffc6f4f33004fdfb4cedacf5a5391bc8f00';
|
||||
const DEV_87 = '0x11c6a314e003cdee3dc51cf6569175360141578d054c38d7a70840a65cc0e990';
|
||||
const ZERO_SEED = `0x${'00'.repeat(32)}`;
|
||||
const CRYSTAL_ALICE = '0x1883df2ae47d1fd428a6b8237ad7b59cf0facccaacac4541ef7758be44b3c333';
|
||||
const port = {} as chrome.runtime.Port;
|
||||
const id = (address: string) => u8aToHex(decodeAddress(address));
|
||||
|
||||
let wallets: WalletInfo[] = [];
|
||||
|
||||
beforeAll(async () => {
|
||||
// the mock's remove() returns nothing, and forgetting awaits it
|
||||
(chrome.storage.local.remove as unknown as { returns: (v: unknown) => void }).returns(Promise.resolve());
|
||||
await extension.handle('wallets', 'pri(wallets.subscribe)', null, {
|
||||
onDisconnect: { addListener: () => undefined },
|
||||
postMessage: ({ subscription }: { subscription?: WalletInfo[] }) => {
|
||||
subscription && (wallets = subscription);
|
||||
}
|
||||
} as unknown as chrome.runtime.Port);
|
||||
});
|
||||
|
||||
const walletNamed = (name: string) => wallets.find((w) => w.name === name);
|
||||
|
||||
it('previews all three account-0 addresses of a recovery phrase', async () => {
|
||||
const dev = await extension.handle('id', 'pri(wallets.preview)', { secret: DEV_PHRASE }, port);
|
||||
const node = await extension.handle('id', 'pri(wallets.preview)', { secret: NODE_PHRASE }, port);
|
||||
|
||||
expect(id(dev.mldsa65)).toEqual(DEV_65);
|
||||
expect(id(dev.mldsa87)).toEqual(DEV_87);
|
||||
expect(node.wormhole && id(node.wormhole)).toEqual(NODE_WORMHOLE);
|
||||
});
|
||||
|
||||
it('previews a raw seed with no wormhole account', async () => {
|
||||
const preview = await extension.handle('id', 'pri(wallets.preview)', { secret: ZERO_SEED }, port);
|
||||
|
||||
expect(id(preview.mldsa87)).toEqual(CRYSTAL_ALICE);
|
||||
expect(preview.wormhole).toBe(null);
|
||||
});
|
||||
|
||||
it('refuses something that is neither a phrase nor a seed', async () => {
|
||||
await expect(extension.handle('id', 'pri(wallets.preview)', { secret: 'bottom drive obey' }, port)).rejects.toThrow(/recovery phrase has/);
|
||||
await expect(extension.handle('id', 'pri(wallets.preview)', { secret: '0x1234' }, port)).rejects.toThrow(/32 bytes/);
|
||||
});
|
||||
|
||||
it('creates a wallet whose signing accounts are keyring pairs', async () => {
|
||||
const walletId = await extension.handle('id', 'pri(wallets.create)', { name: 'dev', password, secret: ` ${DEV_PHRASE.replace(/ /g, ' ')} ` }, port);
|
||||
const wallet = walletNamed('dev');
|
||||
|
||||
expect(wallet?.id).toEqual(walletId);
|
||||
expect(wallet && 'secret' in wallet).toBe(false);
|
||||
expect(wallet?.source).toEqual('mnemonic');
|
||||
|
||||
const [account] = wallet?.accounts ?? [];
|
||||
|
||||
expect(id(account.mldsa65)).toEqual(DEV_65);
|
||||
expect(id(account.mldsa87)).toEqual(DEV_87);
|
||||
expect(account.wormhole?.receive).toHaveLength(20);
|
||||
expect(account.wormhole?.change).toHaveLength(20);
|
||||
|
||||
const pair65 = keyring.getPair(account.mldsa65);
|
||||
const pair87 = keyring.getPair(account.mldsa87);
|
||||
|
||||
expect(pair65.type).toEqual('dilithium65');
|
||||
expect(pair87.type).toEqual('dilithium87');
|
||||
// the harness's expect has no objectContaining, so pick the fields
|
||||
const tags = ({ meta: { accountIndex, name, walletId } }: KeyringPair) => ({ accountIndex, name, walletId });
|
||||
|
||||
expect(tags(pair65)).toEqual({ accountIndex: 0, name: 'dev (ML-DSA-65)', walletId });
|
||||
expect(tags(pair87)).toEqual({ accountIndex: 0, name: 'dev (ML-DSA-87)', walletId });
|
||||
});
|
||||
|
||||
it('refuses the same secret twice', async () => {
|
||||
await expect(extension.handle('id', 'pri(wallets.create)', { name: 'again', password, secret: DEV_PHRASE }, port)).rejects.toThrow(/already the wallet "dev"/);
|
||||
});
|
||||
|
||||
it('adds the next account index only with the right password', async () => {
|
||||
const wallet = walletNamed('dev');
|
||||
|
||||
assert(wallet, 'wallet missing');
|
||||
await expect(extension.handle('id', 'pri(wallets.addAccount)', { id: wallet.id, password: 'wrong' }, port)).rejects.toThrow(/password is wrong/);
|
||||
await extension.handle('id', 'pri(wallets.addAccount)', { id: wallet.id, password }, port);
|
||||
|
||||
const accounts = walletNamed('dev')?.accounts ?? [];
|
||||
|
||||
expect(accounts.map((a) => a.index)).toEqual([0, 1]);
|
||||
expect(accounts[1].mldsa65).not.toEqual(accounts[0].mldsa65);
|
||||
expect(accounts[1].wormhole?.receive[0]).not.toEqual(accounts[0].wormhole?.receive[0]);
|
||||
expect(keyring.getPair(accounts[1].mldsa87).meta.name).toEqual('dev #1 (ML-DSA-87)');
|
||||
});
|
||||
|
||||
it('makes a raw seed wallet with one account and no wormhole', async () => {
|
||||
const walletId = await extension.handle('id', 'pri(wallets.create)', { name: 'alice', password, secret: ZERO_SEED }, port);
|
||||
const [account] = walletNamed('alice')?.accounts ?? [];
|
||||
|
||||
expect(id(account.mldsa87)).toEqual(CRYSTAL_ALICE);
|
||||
expect(account.wormhole).toBe(null);
|
||||
await expect(extension.handle('id', 'pri(wallets.addAccount)', { id: walletId, password }, port)).rejects.toThrow(/raw seed has one account/);
|
||||
});
|
||||
|
||||
it('renames the wallet and the names its pairs carry to dapps', async () => {
|
||||
const wallet = walletNamed('alice');
|
||||
|
||||
assert(wallet, 'wallet missing');
|
||||
await extension.handle('id', 'pri(wallets.rename)', { id: wallet.id, name: 'crystal' }, port);
|
||||
|
||||
expect(walletNamed('crystal')).toBeDefined();
|
||||
expect(keyring.getPair(wallet.accounts[0].mldsa65).meta.name).toEqual('crystal (ML-DSA-65)');
|
||||
});
|
||||
|
||||
// the mock store: what was last written under a key, and what was removed
|
||||
// sinon stubs: only their recorded calls are read, never invoked unbound
|
||||
// eslint-disable-next-line @typescript-eslint/unbound-method
|
||||
const setStub = () => chrome.storage.local.set as unknown as { args: [Record<string, unknown>][] };
|
||||
// eslint-disable-next-line @typescript-eslint/unbound-method
|
||||
const removeStub = () => chrome.storage.local.remove as unknown as { args: [string][] };
|
||||
// Keys carry EXTENSION_PREFIX when one is set, as it is under `yarn test`.
|
||||
const written = (key: string) => setStub().args
|
||||
.flatMap(([v]) => Object.entries(v).filter(([k]) => k.endsWith(key)).map(([, value]) => value))
|
||||
.pop() as WormholeNullifiersJson | undefined;
|
||||
|
||||
it('stores a nullifier window for every wormhole address when a phrase wallet is made', () => {
|
||||
const wallet = walletNamed('dev');
|
||||
|
||||
assert(wallet, 'wallet missing');
|
||||
|
||||
for (const index of [0, 1]) {
|
||||
const stored = written(`quantus:nullifiers:${wallet.id}:${index}`);
|
||||
|
||||
assert(stored, `no nullifiers for account ${index}`);
|
||||
expect(stored.receive).toHaveLength(20);
|
||||
expect(stored.change).toHaveLength(20);
|
||||
expect(stored.receive.every((b) => base64Decode(b).length === NULLIFIER_WINDOW * 32)).toBe(true);
|
||||
expect(stored.change.every((b) => base64Decode(b).length === NULLIFIER_WINDOW * 32)).toBe(true);
|
||||
}
|
||||
|
||||
// the stored bytes are the derivation, address by address and count by count
|
||||
const stored = written(`quantus:nullifiers:${wallet.id}:1`);
|
||||
const direct = wormholeNullifiers(DEV_PHRASE, '', 1, WormholeBranch.Change, 3, 1, 100, 2)[0];
|
||||
|
||||
expect(u8aToHex(base64Decode(stored?.change[3] ?? '').subarray(100 * 32, 102 * 32))).toEqual(u8aToHex(u8aConcat(...direct)));
|
||||
});
|
||||
|
||||
it('stores no nullifiers for a raw seed wallet, which has no wormhole', () => {
|
||||
const wallet = walletNamed('crystal');
|
||||
|
||||
assert(wallet, 'wallet missing');
|
||||
expect(written(`quantus:nullifiers:${wallet.id}:0`)).toBeUndefined();
|
||||
});
|
||||
|
||||
it('extends only the addresses whose transfer count outgrew the window', () => {
|
||||
const wallet = walletNamed('dev');
|
||||
|
||||
assert(wallet, 'wallet missing');
|
||||
|
||||
const [account] = wallet.accounts;
|
||||
const stored = written(`quantus:nullifiers:${wallet.id}:0`) ?? null;
|
||||
const receive = new Array<number>(20).fill(0);
|
||||
|
||||
receive[2] = 200;
|
||||
|
||||
const extended = extendNullifiers(DEV_PHRASE, account, stored, { change: new Array<number>(20).fill(5), receive });
|
||||
|
||||
assert(extended && stored, 'nothing extended');
|
||||
expect(base64Decode(extended.receive[2]).length).toEqual((200 + 128) * 32);
|
||||
expect(extended.receive[3]).toEqual(stored.receive[3]);
|
||||
expect(extended.change).toEqual(stored.change);
|
||||
// what was there is kept as it was, and what was added continues from it
|
||||
expect(u8aToHex(base64Decode(extended.receive[2]).subarray(0, NULLIFIER_WINDOW * 32))).toEqual(u8aToHex(base64Decode(stored.receive[2])));
|
||||
expect(u8aToHex(base64Decode(extended.receive[2]).subarray(300 * 32, 301 * 32))).toEqual(u8aToHex(wormholeNullifiers(DEV_PHRASE, '', 0, WormholeBranch.Receive, 2, 1, 300, 1)[0][0]));
|
||||
});
|
||||
|
||||
it('forgets the wallet and every pair it owns', async () => {
|
||||
const wallet = walletNamed('dev');
|
||||
|
||||
assert(wallet, 'wallet missing');
|
||||
await extension.handle('id', 'pri(wallets.forget)', { id: wallet.id }, port);
|
||||
|
||||
expect(walletNamed('dev')).toBeUndefined();
|
||||
|
||||
// and the nullifiers of every account index with it
|
||||
const removed = (key: string) => removeStub().args.some(([k]) => k.endsWith(key));
|
||||
|
||||
expect(removed(`quantus:nullifiers:${wallet.id}:0`)).toBe(true);
|
||||
expect(removed(`quantus:nullifiers:${wallet.id}:1`)).toBe(true);
|
||||
expect(removed(`quantus:wallet:${wallet.id}`)).toBe(true);
|
||||
|
||||
for (const account of wallet.accounts) {
|
||||
expect(() => keyring.getPair(account.mldsa65)).toThrow();
|
||||
expect(() => keyring.getPair(account.mldsa87)).toThrow();
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -3,24 +3,24 @@
|
||||
|
||||
/* global chrome */
|
||||
|
||||
import type { Runtime } from '@quantus/codec';
|
||||
import type { MetadataDef } from '@polkadot/extension-inject/types';
|
||||
import type { KeyringPair, KeyringPair$Json, KeyringPair$Meta } from '@polkadot/keyring/types';
|
||||
import type { Registry } from '@polkadot/types/types';
|
||||
import type { KeyringPair, KeyringPair$Json } from '@polkadot/keyring/types';
|
||||
import type { SubjectInfo } from '@polkadot/ui-keyring/observable/types';
|
||||
import type { KeypairType } from '@polkadot/util-crypto/types';
|
||||
import type { AccountJson, AllowedPath, AuthorizeRequest, MessageTypes, MetadataRequest, RequestAccountBatchExport, RequestAccountChangePassword, RequestAccountCreateExternal, RequestAccountCreateSuri, RequestAccountEdit, RequestAccountExport, RequestAccountForget, RequestAccountShow, RequestAccountTie, RequestAccountValidate, RequestActiveTabsUrlUpdate, RequestAuthorizeApprove, RequestBatchRestore, RequestDeriveCreate, RequestDeriveValidate, RequestJsonRestore, RequestMetadataApprove, RequestMetadataReject, RequestSeedCreate, RequestSeedValidate, RequestSigningApprovePassword, RequestSigningApproveSignature, RequestSigningCancel, RequestSigningIsLocked, RequestTypes, RequestUpdateAuthorizedAccounts, ResponseAccountExport, ResponseAccountsExport, ResponseAuthorizeList, ResponseDeriveValidate, ResponseJsonGetAccountInfo, ResponseSeedCreate, ResponseSeedValidate, ResponseSigningIsLocked, ResponseType, SigningRequest } from '../types.js';
|
||||
import type { AccountJson, AllowedPath, AuthorizeRequest, MessageTypes, MetadataRequest, RequestAccountBatchExport, RequestAccountChangePassword, RequestAccountCreateExternal, RequestAccountCreateSuri, RequestAccountEdit, RequestAccountExport, RequestAccountForget, RequestAccountShow, RequestAccountTie, RequestAccountValidate, RequestActiveTabsUrlUpdate, RequestAuthorizeApprove, RequestBalancesSubscribe, RequestBalancesUnsubscribe, RequestBatchRestore, RequestJsonRestore, RequestMetadataApprove, RequestMetadataReject, RequestSeedCreate, RequestSeedValidate, RequestSigningApprovePassword, RequestSigningApproveSignature, RequestSigningCancel, RequestSigningIsLocked, RequestTypes, RequestUpdateAuthorizedAccounts, RequestWalletAddAccount, RequestWalletCreate, RequestWalletForget, RequestWalletPreview, RequestWalletRename, RequestWormholeBalance, RequestWormholeUnlock, ResponseAccountExport, ResponseAccountsExport, ResponseAuthorizeList, ResponseJsonGetAccountInfo, ResponseSeedCreate, ResponseSeedValidate, ResponseSigningIsLocked, ResponseType, ResponseWalletPreview, SigningRequest, WalletInfo, WormholeBalance } from '../types.js';
|
||||
import type { AuthorizedAccountsDiff } from './State.js';
|
||||
import type State from './State.js';
|
||||
|
||||
import { ALLOWED_PATH, PASSWORD_EXPIRY_MS } from '@polkadot/extension-base/defaults';
|
||||
import { metadataExpand } from '@polkadot/extension-chains';
|
||||
import { TypeRegistry } from '@polkadot/types';
|
||||
import { keyring } from '@polkadot/ui-keyring';
|
||||
import { accounts as accountsObservable } from '@polkadot/ui-keyring/observable/accounts';
|
||||
import { assert, isHex } from '@polkadot/util';
|
||||
import { keyExtractSuri, mnemonicGenerate, mnemonicValidate } from '@polkadot/util-crypto';
|
||||
|
||||
import { isExtrinsicRequest } from '../../utils/index.js';
|
||||
import Balances from '../Balances.js';
|
||||
import Wallets from '../Wallets.js';
|
||||
import { withErrorLog } from './helpers.js';
|
||||
import { createSubscription, unsubscribe } from './subscriptions.js';
|
||||
|
||||
@@ -28,15 +28,12 @@ type CachedUnlocks = Record<string, number>;
|
||||
|
||||
const SEED_DEFAULT_LENGTH = 12;
|
||||
const SEED_LENGTHS = [12, 15, 18, 21, 24];
|
||||
const ETH_DERIVE_DEFAULT = "/m/44'/60'/0'/0/0";
|
||||
|
||||
function getSuri (seed: string, type?: KeypairType): string {
|
||||
return type === 'ethereum'
|
||||
? `${seed}${ETH_DERIVE_DEFAULT}`
|
||||
: seed;
|
||||
}
|
||||
|
||||
export default class Extension {
|
||||
readonly #balances = new Balances();
|
||||
readonly #wallets = new Wallets();
|
||||
// Ends each balance subscription, by id; each runs once however it is reached.
|
||||
readonly #balanceSubs = new Map<string, VoidFunction>();
|
||||
readonly #cachedUnlocks: CachedUnlocks;
|
||||
|
||||
readonly #state: State;
|
||||
@@ -62,7 +59,7 @@ export default class Extension {
|
||||
}
|
||||
|
||||
private accountsCreateSuri ({ genesisHash, name, password, suri, type }: RequestAccountCreateSuri): boolean {
|
||||
keyring.addUri(getSuri(suri, type), password, { genesisHash, name }, type);
|
||||
keyring.addUri(suri, password, { genesisHash, name }, type);
|
||||
|
||||
return true;
|
||||
}
|
||||
@@ -191,6 +188,109 @@ export default class Extension {
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Balances for every account the keyring holds, refreshed as accounts change.
|
||||
*
|
||||
* The endpoint arrives with the request because `@polkadot/ui-settings`
|
||||
* persists to `localStorage`, which an MV3 service worker does not have — the
|
||||
* setting lives in one place and travels here.
|
||||
*
|
||||
* Nothing connects until this is called, and the connection closes with the
|
||||
* last subscriber. Asking a node for balances tells that node which accounts
|
||||
* belong to one person, so holding a socket open for the life of the browser
|
||||
* would report far more than the feature needs.
|
||||
*/
|
||||
private walletsSubscribe (id: string, port: chrome.runtime.Port): boolean {
|
||||
const cb = createSubscription<'pri(wallets.subscribe)'>(id, port);
|
||||
const subscription = this.#wallets.subject.subscribe((wallets: WalletInfo[]): void => cb(wallets));
|
||||
|
||||
port.onDisconnect.addListener((): void => {
|
||||
unsubscribe(id);
|
||||
subscription.unsubscribe();
|
||||
});
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
private walletsPreview ({ secret }: RequestWalletPreview): ResponseWalletPreview {
|
||||
return this.#wallets.preview(secret);
|
||||
}
|
||||
|
||||
private walletsCreate (request: RequestWalletCreate): Promise<string> {
|
||||
return this.#wallets.create(request);
|
||||
}
|
||||
|
||||
private async walletsAddAccount ({ id, password }: RequestWalletAddAccount): Promise<boolean> {
|
||||
await this.#wallets.addAccount(id, password);
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
private async walletsRename ({ id, name }: RequestWalletRename): Promise<boolean> {
|
||||
await this.#wallets.rename(id, name);
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
private walletsWormholeBalance ({ accountIndex, endpoint, id, observer }: RequestWormholeBalance): Promise<WormholeBalance> {
|
||||
return this.#wallets.wormholeBalance(id, accountIndex, endpoint, observer);
|
||||
}
|
||||
|
||||
private async walletsWormholeUnlock ({ accountIndex, counts, id, password }: RequestWormholeUnlock): Promise<boolean> {
|
||||
await this.#wallets.wormholeUnlock(id, accountIndex, password, counts);
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
private async walletsForget ({ id }: RequestWalletForget): Promise<boolean> {
|
||||
await this.#wallets.forget(id);
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
private balancesSubscribe ({ endpoint }: RequestBalancesSubscribe, id: string, port: chrome.runtime.Port): string {
|
||||
const cb = createSubscription<'pri(balances.subscribe)'>(id, port);
|
||||
|
||||
this.#balances.retain();
|
||||
|
||||
const update = (accounts: SubjectInfo): void => {
|
||||
// Deliberately not awaited, and never rethrown: an unreachable endpoint
|
||||
// must leave the account list working without balances rather than break
|
||||
// the popup. The subject simply keeps its last value.
|
||||
this.#balances
|
||||
.update(endpoint, Object.keys(accounts))
|
||||
.catch((error: Error) => console.error(`Unable to read balances from ${endpoint}: ${error.message}`));
|
||||
};
|
||||
|
||||
const balances = this.#balances.subject.subscribe(cb);
|
||||
const accounts = accountsObservable.subject.subscribe(update);
|
||||
|
||||
const end = (): void => {
|
||||
if (this.#balanceSubs.delete(id)) {
|
||||
unsubscribe(id);
|
||||
balances.unsubscribe();
|
||||
accounts.unsubscribe();
|
||||
this.#balances.release();
|
||||
}
|
||||
};
|
||||
|
||||
this.#balanceSubs.set(id, end);
|
||||
port.onDisconnect.addListener(end);
|
||||
|
||||
return id;
|
||||
}
|
||||
|
||||
/**
|
||||
* End a balance subscription while the page that made it stays open, as it
|
||||
* does when the user switches endpoint. Left to the port disconnecting, the
|
||||
* old endpoint's subscription would live on beside the new one.
|
||||
*/
|
||||
private balancesUnsubscribe ({ id }: RequestBalancesUnsubscribe): boolean {
|
||||
this.#balanceSubs.get(id)?.();
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
private authorizeApprove ({ authorizedAccounts, id }: RequestAuthorizeApprove): boolean {
|
||||
const queued = this.#state.getAuthRequest(id);
|
||||
|
||||
@@ -310,7 +410,7 @@ export default class Extension {
|
||||
const seed = _seed || mnemonicGenerate(length);
|
||||
|
||||
return {
|
||||
address: keyring.createFromUri(getSuri(seed, type), {}, type).address,
|
||||
address: keyring.createFromUri(seed, {}, type).address,
|
||||
seed
|
||||
};
|
||||
}
|
||||
@@ -327,7 +427,7 @@ export default class Extension {
|
||||
}
|
||||
|
||||
return {
|
||||
address: keyring.createFromUri(getSuri(suri, type), {}, type).address,
|
||||
address: keyring.createFromUri(suri, {}, type).address,
|
||||
suri
|
||||
};
|
||||
}
|
||||
@@ -357,32 +457,38 @@ export default class Extension {
|
||||
pair.decodePkcs8(password);
|
||||
}
|
||||
|
||||
// construct a new registry (avoiding pollution), between requests
|
||||
let registry: Registry;
|
||||
// The runtime's own description of the chain being signed for, from the
|
||||
// metadata a dapp provided. `null` when none is known, which
|
||||
// RequestExtrinsicSign turns into a refusal rather than a guess — without it
|
||||
// there is no way to know what this chain's signed extensions contribute to
|
||||
// the payload, and a signature over the wrong bytes comes back from the node
|
||||
// as `BadProof`, indistinguishable from a wrong key.
|
||||
//
|
||||
// Raw-bytes signing needs none of this and ignores the argument.
|
||||
let runtime: Runtime | null = null;
|
||||
|
||||
if (isExtrinsicRequest(request)) {
|
||||
const payload = request.payload;
|
||||
|
||||
// Get the metadata for the genesisHash
|
||||
const metadata = this.#state.knownMetadata.find(({ genesisHash }) => genesisHash === payload.genesisHash);
|
||||
const metadata = this.#state.knownMetadata.find(({ genesisHash }) => genesisHash === request.payload.genesisHash);
|
||||
|
||||
if (metadata) {
|
||||
// we have metadata, expand it and extract the info/registry
|
||||
const expanded = metadataExpand(metadata, false);
|
||||
|
||||
registry = expanded.registry;
|
||||
registry.setSignedExtensions(payload.signedExtensions, expanded.definition.userExtensions);
|
||||
} else {
|
||||
// we have no metadata, create a new registry
|
||||
registry = new TypeRegistry();
|
||||
registry.setSignedExtensions(payload.signedExtensions);
|
||||
runtime = metadataExpand(metadata, false).runtime;
|
||||
}
|
||||
} else {
|
||||
// for non-payload, just create a registry to use
|
||||
registry = new TypeRegistry();
|
||||
}
|
||||
|
||||
const result = request.sign(registry, pair);
|
||||
let result;
|
||||
|
||||
try {
|
||||
result = request.sign(runtime, pair);
|
||||
} catch (error) {
|
||||
// Tell the dapp, not just the popup. Signing can now refuse outright — a
|
||||
// chain whose metadata this extension does not have is one it will not
|
||||
// guess a payload for — and an unanswered `signPayload` leaves the page
|
||||
// waiting forever with nothing on screen to explain it.
|
||||
pair.lock();
|
||||
reject(error as Error);
|
||||
|
||||
throw error;
|
||||
}
|
||||
|
||||
if (savePass) {
|
||||
// unlike queued.account.address the following
|
||||
@@ -472,44 +578,6 @@ export default class Extension {
|
||||
return true;
|
||||
}
|
||||
|
||||
private derive (parentAddress: string, suri: string, password: string, metadata: KeyringPair$Meta): KeyringPair {
|
||||
const parentPair = keyring.getPair(parentAddress);
|
||||
|
||||
try {
|
||||
parentPair.decodePkcs8(password);
|
||||
} catch {
|
||||
throw new Error('invalid password');
|
||||
}
|
||||
|
||||
try {
|
||||
return parentPair.derive(suri, metadata);
|
||||
} catch {
|
||||
throw new Error(`"${suri}" is not a valid derivation path`);
|
||||
}
|
||||
}
|
||||
|
||||
private derivationValidate ({ parentAddress, parentPassword, suri }: RequestDeriveValidate): ResponseDeriveValidate {
|
||||
const childPair = this.derive(parentAddress, suri, parentPassword, {});
|
||||
|
||||
return {
|
||||
address: childPair.address,
|
||||
suri
|
||||
};
|
||||
}
|
||||
|
||||
private derivationCreate ({ genesisHash, name, parentAddress, parentPassword, password, suri }: RequestDeriveCreate): boolean {
|
||||
const childPair = this.derive(parentAddress, suri, parentPassword, {
|
||||
genesisHash,
|
||||
name,
|
||||
parentAddress,
|
||||
suri
|
||||
});
|
||||
|
||||
keyring.addPair(childPair, password);
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
private async removeAuthorization (url: string): Promise<ResponseAuthorizeList> {
|
||||
const remAuth = await this.#state.removeAuthorization(url);
|
||||
|
||||
@@ -600,6 +668,36 @@ export default class Extension {
|
||||
case 'pri(accounts.subscribe)':
|
||||
return port && this.accountsSubscribe(id, port);
|
||||
|
||||
case 'pri(balances.subscribe)':
|
||||
return port && this.balancesSubscribe(request as RequestBalancesSubscribe, id, port);
|
||||
|
||||
case 'pri(wallets.addAccount)':
|
||||
return this.walletsAddAccount(request as RequestWalletAddAccount);
|
||||
|
||||
case 'pri(wallets.create)':
|
||||
return this.walletsCreate(request as RequestWalletCreate);
|
||||
|
||||
case 'pri(wallets.forget)':
|
||||
return this.walletsForget(request as RequestWalletForget);
|
||||
|
||||
case 'pri(wallets.preview)':
|
||||
return this.walletsPreview(request as RequestWalletPreview);
|
||||
|
||||
case 'pri(wallets.rename)':
|
||||
return this.walletsRename(request as RequestWalletRename);
|
||||
|
||||
case 'pri(wallets.wormholeBalance)':
|
||||
return this.walletsWormholeBalance(request as RequestWormholeBalance);
|
||||
|
||||
case 'pri(wallets.wormholeUnlock)':
|
||||
return this.walletsWormholeUnlock(request as RequestWormholeUnlock);
|
||||
|
||||
case 'pri(wallets.subscribe)':
|
||||
return port && this.walletsSubscribe(id, port);
|
||||
|
||||
case 'pri(balances.unsubscribe)':
|
||||
return this.balancesUnsubscribe(request as RequestBalancesUnsubscribe);
|
||||
|
||||
case 'pri(accounts.tie)':
|
||||
return this.accountsTie(request as RequestAccountTie);
|
||||
|
||||
@@ -627,12 +725,6 @@ export default class Extension {
|
||||
case 'pri(connectedTabsUrl.get)':
|
||||
return this.getConnectedTabsUrl();
|
||||
|
||||
case 'pri(derivation.create)':
|
||||
return this.derivationCreate(request as RequestDeriveCreate);
|
||||
|
||||
case 'pri(derivation.validate)':
|
||||
return this.derivationValidate(request as RequestDeriveValidate);
|
||||
|
||||
case 'pri(json.restore)':
|
||||
return this.jsonRestore(request as RequestJsonRestore);
|
||||
|
||||
|
||||
@@ -16,6 +16,7 @@ import { assert } from '@polkadot/util';
|
||||
|
||||
import { MetadataStore } from '../../stores/index.js';
|
||||
import { getId } from '../../utils/getId.js';
|
||||
import ChainMetadata from '../ChainMetadata.js';
|
||||
import { withErrorLog } from './helpers.js';
|
||||
|
||||
interface Resolver<T> {
|
||||
@@ -135,6 +136,7 @@ export default class State {
|
||||
readonly #authRequests: Record<string, AuthRequest> = {};
|
||||
|
||||
readonly #metaStore = new MetadataStore();
|
||||
readonly #chainMetadata = new ChainMetadata();
|
||||
|
||||
// Map of providers currently injected in tabs
|
||||
readonly #injectedProviders = new Map<chrome.runtime.Port, ProviderInterface>();
|
||||
@@ -609,6 +611,44 @@ export default class State {
|
||||
return provider.unsubscribe(request.type, request.method, request.subscriptionId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Make sure this extension can describe a chain before it is asked to sign for
|
||||
* it, fetching the metadata from a node if nothing is known or what is known
|
||||
* is for an older runtime.
|
||||
*
|
||||
* Silent on failure. A chain we have no endpoint for is a chain the signer
|
||||
* will refuse, with a message that says so — there is nothing useful to say
|
||||
* here that is not said better there.
|
||||
*/
|
||||
public async ensureMetadata (genesisHash: string, specVersion: number): Promise<void> {
|
||||
// Already able to describe the exact runtime this payload is for. The spec
|
||||
// version is the test rather than mere presence: a runtime upgrade changes
|
||||
// which calls and signed extensions exist, and metadata from before one
|
||||
// decodes this chain's calls into something plausible and wrong — worse
|
||||
// than not decoding them at all.
|
||||
//
|
||||
// This is also what keeps a signature request off the network in the common
|
||||
// case, where the chain has not upgraded since the last one.
|
||||
const known = this.knownMetadata.find((m) => m.genesisHash === genesisHash);
|
||||
|
||||
if (known?.rawMetadata && known.specVersion === specVersion) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const fetched = await this.#chainMetadata.fetch(genesisHash);
|
||||
|
||||
if (fetched) {
|
||||
await this.saveMetadata(fetched);
|
||||
}
|
||||
} catch (error) {
|
||||
// Not fatal and not reported here. A chain we cannot reach is a chain the
|
||||
// signer refuses, with a message that says so; there is nothing useful to
|
||||
// add at this point that is not said better there.
|
||||
console.error(`Unable to fetch metadata for ${genesisHash}: ${(error as Error).message}`);
|
||||
}
|
||||
}
|
||||
|
||||
public async saveMetadata (meta: MetadataDef): Promise<void> {
|
||||
await this.#metaStore.set(meta.genesisHash, meta);
|
||||
|
||||
|
||||
@@ -17,10 +17,10 @@ import { combineLatest, type Subscription } from 'rxjs';
|
||||
import { checkIfDenied } from '@polkadot/phishing';
|
||||
import { keyring } from '@polkadot/ui-keyring';
|
||||
import { accounts as accountsObservable } from '@polkadot/ui-keyring/observable/accounts';
|
||||
import { assert, isNumber } from '@polkadot/util';
|
||||
import { assert, hexToNumber, isNumber } from '@polkadot/util';
|
||||
|
||||
import { PHISHING_PAGE_REDIRECT } from '../../defaults.js';
|
||||
import { canDerive } from '../../utils/index.js';
|
||||
import { canInject } from '../../utils/index.js';
|
||||
import RequestBytesSign from '../RequestBytesSign.js';
|
||||
import RequestExtrinsicSign from '../RequestExtrinsicSign.js';
|
||||
import { withErrorLog } from './helpers.js';
|
||||
@@ -35,7 +35,7 @@ function transformAccounts (accounts: SubjectInfo, anyType = false): InjectedAcc
|
||||
return Object
|
||||
.values(accounts)
|
||||
.filter(({ json: { meta: { isHidden } } }) => !isHidden)
|
||||
.filter(({ type }) => anyType ? true : canDerive(type))
|
||||
.filter(({ type }) => anyType ? true : canInject(type))
|
||||
.sort((a, b) => (a.json.meta.whenCreated || 0) - (b.json.meta.whenCreated || 0))
|
||||
.map(({ json: { address, meta: { genesisHash, name } }, type }): InjectedAccount => ({
|
||||
address,
|
||||
@@ -140,7 +140,7 @@ export default class Tabs {
|
||||
return this.#state.sign(url, new RequestBytesSign(request), { address, ...pair.meta });
|
||||
}
|
||||
|
||||
private extrinsicSign (url: string, request: SignerPayloadJSON): Promise<ResponseSigning> {
|
||||
private async extrinsicSign (url: string, request: SignerPayloadJSON): Promise<ResponseSigning> {
|
||||
// matches the predicate the UI used to key off, so payloads that were
|
||||
// never ambiguous (absent, null, empty) keep working
|
||||
assert(!(request as unknown as SignerPayloadRaw).data, 'Unexpected raw data in a signPayload payload');
|
||||
@@ -148,6 +148,15 @@ export default class Tabs {
|
||||
const address = request.address;
|
||||
const pair = this.getSigningPair(address);
|
||||
|
||||
// Before the popup opens, not after approval: the approval screen decodes
|
||||
// the call with this metadata, and a user asked to approve a transaction
|
||||
// rendered as raw hex has been given nothing to approve.
|
||||
//
|
||||
// Fetched from a node rather than taken from the dapp. The dapp already
|
||||
// controls the transaction; letting it also supply the description would let
|
||||
// it show one thing and have the user sign another. See ChainMetadata.
|
||||
await this.#state.ensureMetadata(request.genesisHash, hexToNumber(request.specVersion));
|
||||
|
||||
return this.#state.sign(url, new RequestExtrinsicSign(request), { address, ...pair.meta });
|
||||
}
|
||||
|
||||
|
||||
@@ -3,13 +3,14 @@
|
||||
|
||||
/* eslint-disable no-use-before-define */
|
||||
|
||||
import type { Runtime } from '@quantus/codec';
|
||||
import type { InjectedAccount, InjectedMetadataKnown, MetadataDef, ProviderList, ProviderMeta } from '@polkadot/extension-inject/types';
|
||||
import type { KeyringPair, KeyringPair$Json, KeyringPair$Meta } from '@polkadot/keyring/types';
|
||||
import type { JsonRpcResponse } from '@polkadot/rpc-provider/types';
|
||||
import type { Registry, SignerPayloadJSON, SignerPayloadRaw } from '@polkadot/types/types';
|
||||
import type { SignerPayloadJSON, SignerPayloadRaw } from '@polkadot/types/types';
|
||||
import type { KeyringPairs$Json } from '@polkadot/ui-keyring/types';
|
||||
import type { HexString } from '@polkadot/util/types';
|
||||
import type { KeypairType } from '@polkadot/util-crypto/types';
|
||||
import type { EncryptedJson, KeypairType } from '@polkadot/util-crypto/types';
|
||||
import type { ALLOWED_PATH } from '../defaults.js';
|
||||
import type { AuthResponse } from './handlers/State.js';
|
||||
|
||||
@@ -50,7 +51,6 @@ export type AccountWithChildren = AccountJson & {
|
||||
export interface AccountsContext {
|
||||
accounts: AccountJson[];
|
||||
hierarchy: AccountWithChildren[];
|
||||
master?: AccountJson;
|
||||
selectedAccounts?: AccountJson['address'][];
|
||||
setSelectedAccounts?: (address: AccountJson['address'][]) => void;
|
||||
}
|
||||
@@ -89,7 +89,17 @@ export interface RequestSignatures {
|
||||
'pri(accounts.show)': [RequestAccountShow, boolean];
|
||||
'pri(accounts.tie)': [RequestAccountTie, boolean];
|
||||
'pri(accounts.subscribe)': [RequestAccountSubscribe, boolean, AccountJson[]];
|
||||
'pri(balances.subscribe)': [RequestBalancesSubscribe, string, AccountBalances];
|
||||
'pri(balances.unsubscribe)': [RequestBalancesUnsubscribe, boolean];
|
||||
'pri(accounts.validate)': [RequestAccountValidate, boolean];
|
||||
'pri(wallets.addAccount)': [RequestWalletAddAccount, boolean];
|
||||
'pri(wallets.create)': [RequestWalletCreate, string];
|
||||
'pri(wallets.forget)': [RequestWalletForget, boolean];
|
||||
'pri(wallets.preview)': [RequestWalletPreview, ResponseWalletPreview];
|
||||
'pri(wallets.rename)': [RequestWalletRename, boolean];
|
||||
'pri(wallets.subscribe)': [null, boolean, WalletInfo[]];
|
||||
'pri(wallets.wormholeBalance)': [RequestWormholeBalance, WormholeBalance];
|
||||
'pri(wallets.wormholeUnlock)': [RequestWormholeUnlock, boolean];
|
||||
'pri(accounts.changePassword)': [RequestAccountChangePassword, boolean];
|
||||
'pri(authorize.approve)': [RequestAuthorizeApprove, boolean];
|
||||
'pri(authorize.list)': [null, ResponseAuthorizeList];
|
||||
@@ -100,8 +110,6 @@ export interface RequestSignatures {
|
||||
'pri(authorize.update)': [RequestUpdateAuthorizedAccounts, void];
|
||||
'pri(activeTabsUrl.update)': [RequestActiveTabsUrlUpdate, void];
|
||||
'pri(connectedTabsUrl.get)': [null, ConnectedTabsUrlResponse];
|
||||
'pri(derivation.create)': [RequestDeriveCreate, boolean];
|
||||
'pri(derivation.validate)': [RequestDeriveValidate, ResponseDeriveValidate];
|
||||
'pri(json.restore)': [RequestJsonRestore, void];
|
||||
'pri(json.batchRestore)': [RequestBatchRestore, void];
|
||||
'pri(json.account.info)': [KeyringPair$Json, ResponseJsonGetAccountInfo];
|
||||
@@ -196,6 +204,113 @@ export interface RequestAccountCreateSuri {
|
||||
type?: KeypairType;
|
||||
}
|
||||
|
||||
/** The accounts one account index of a wallet unlocks, by address. */
|
||||
export interface WalletAccount {
|
||||
index: number;
|
||||
mldsa65: string;
|
||||
mldsa87: string;
|
||||
/**
|
||||
* Wormhole addresses, receive and change branches, from index 0. Null for a
|
||||
* wallet made from a raw seed: wormhole derivation starts from the 64-byte
|
||||
* BIP39 seed, which a raw 32-byte seed does not have.
|
||||
*/
|
||||
wormhole: { change: string[], receive: string[] } | null;
|
||||
}
|
||||
|
||||
/** A wallet as the UI sees it: everything but the secret. */
|
||||
export interface WalletInfo {
|
||||
accounts: WalletAccount[];
|
||||
genesisHash: HexString | null;
|
||||
id: string;
|
||||
name: string;
|
||||
source: 'mnemonic' | 'seed';
|
||||
whenCreated: number;
|
||||
}
|
||||
|
||||
/** A wallet as stored: the secret, encrypted with the wallet password. */
|
||||
export interface WalletJson extends WalletInfo {
|
||||
secret: EncryptedJson;
|
||||
}
|
||||
|
||||
/**
|
||||
* Nullifiers for one wallet account's wormhole addresses: per branch, per
|
||||
* address index, the 32-byte nullifiers for transfer counts 0.. packed and
|
||||
* base64-encoded.
|
||||
*/
|
||||
export interface WormholeNullifiersJson {
|
||||
change: string[];
|
||||
receive: string[];
|
||||
}
|
||||
|
||||
/** What a wallet account's wormhole addresses can still spend, and how sure that is. */
|
||||
export interface WormholeBalance {
|
||||
checkedAt: number;
|
||||
/** The chain's transfer count for each address, per branch. */
|
||||
counts: { change: number[], receive: number[] };
|
||||
decimals: number;
|
||||
/** Deposits the observer returned. */
|
||||
deposits: number;
|
||||
indexedFrom: number | null;
|
||||
indexedTo: number | null;
|
||||
/** Transfers the chain counts that the observer did not return. */
|
||||
missing: number;
|
||||
/** Planck, unspent. */
|
||||
spendable: string;
|
||||
/** Planck, already exited. */
|
||||
spent: string;
|
||||
symbol: string;
|
||||
/** The chain's total transfer count across the addresses. */
|
||||
transfers: number;
|
||||
/** Planck in deposits whose nullifier was never precomputed: unlock to check. */
|
||||
unchecked: string;
|
||||
uncheckedDeposits: number;
|
||||
}
|
||||
|
||||
export interface RequestWormholeBalance {
|
||||
accountIndex: number;
|
||||
/** The node to read chain state from: the balance endpoint. */
|
||||
endpoint: string;
|
||||
id: string;
|
||||
/** Base URL of a blackbeard observer to find deposits with. */
|
||||
observer: string;
|
||||
}
|
||||
|
||||
export interface RequestWormholeUnlock {
|
||||
accountIndex: number;
|
||||
/** From the last balance: derive nullifiers past these. */
|
||||
counts: WormholeBalance['counts'];
|
||||
id: string;
|
||||
password: string;
|
||||
}
|
||||
|
||||
export interface RequestWalletCreate {
|
||||
genesisHash?: HexString | null;
|
||||
name: string;
|
||||
password: string;
|
||||
/** A BIP39 recovery phrase, or a 0x-prefixed 32-byte seed. */
|
||||
secret: string;
|
||||
}
|
||||
|
||||
export interface RequestWalletPreview {
|
||||
secret: string;
|
||||
}
|
||||
|
||||
export type ResponseWalletPreview = Omit<WalletAccount, 'wormhole'> & { wormhole: string | null };
|
||||
|
||||
export interface RequestWalletAddAccount {
|
||||
id: string;
|
||||
password: string;
|
||||
}
|
||||
|
||||
export interface RequestWalletForget {
|
||||
id: string;
|
||||
}
|
||||
|
||||
export interface RequestWalletRename {
|
||||
id: string;
|
||||
name: string;
|
||||
}
|
||||
|
||||
export interface RequestAccountChangePassword {
|
||||
address: string;
|
||||
oldPass: string;
|
||||
@@ -227,21 +342,6 @@ export interface RequestAccountValidate {
|
||||
password: string;
|
||||
}
|
||||
|
||||
export interface RequestDeriveCreate {
|
||||
name: string;
|
||||
genesisHash?: HexString | null;
|
||||
suri: string;
|
||||
parentAddress: string;
|
||||
parentPassword: string;
|
||||
password: string;
|
||||
}
|
||||
|
||||
export interface RequestDeriveValidate {
|
||||
suri: string;
|
||||
parentAddress: string;
|
||||
parentPassword: string;
|
||||
}
|
||||
|
||||
export interface RequestAccountExport {
|
||||
address: string;
|
||||
password: string;
|
||||
@@ -297,6 +397,34 @@ export interface RequestSigningCancel {
|
||||
id: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* The endpoint to read balances from.
|
||||
*
|
||||
* Supplied by the popup rather than read here: `@polkadot/ui-settings` persists
|
||||
* to `localStorage`, which an MV3 service worker does not have. The setting lives
|
||||
* in one place and travels with the request.
|
||||
*/
|
||||
export interface RequestBalancesSubscribe {
|
||||
endpoint: string;
|
||||
}
|
||||
|
||||
/** The id `pri(balances.subscribe)` answered with. */
|
||||
export interface RequestBalancesUnsubscribe {
|
||||
id: string;
|
||||
}
|
||||
|
||||
export interface AccountBalance {
|
||||
/** The chain's decimal places, for formatting. */
|
||||
decimals: number;
|
||||
/** Free balance, as a decimal string — 12 decimals puts ordinary amounts past 2^53. */
|
||||
free: string;
|
||||
/** The chain's token symbol: QTC on mainnet, HEI on Heisenberg. */
|
||||
symbol: string;
|
||||
}
|
||||
|
||||
/** Balances by address. An address absent here has not been read yet. */
|
||||
export type AccountBalances = Record<string, AccountBalance>;
|
||||
|
||||
export interface RequestSigningIsLocked {
|
||||
id: string;
|
||||
}
|
||||
@@ -353,11 +481,6 @@ export interface ResponseSigning {
|
||||
signedTransaction?: HexString;
|
||||
}
|
||||
|
||||
export interface ResponseDeriveValidate {
|
||||
address: string;
|
||||
suri: string;
|
||||
}
|
||||
|
||||
export interface ResponseSeedCreate {
|
||||
address: string;
|
||||
seed: string;
|
||||
@@ -388,7 +511,7 @@ export type MessageTypesWithSubscriptions = keyof SubscriptionMessageTypes;
|
||||
export type MessageTypesWithNoSubscriptions = Exclude<MessageTypes, keyof SubscriptionMessageTypes>
|
||||
|
||||
interface RequestSignBase {
|
||||
sign (registry: Registry, pair: KeyringPair): { signature: HexString };
|
||||
sign (runtime: Runtime | null, pair: KeyringPair): { signature: HexString };
|
||||
}
|
||||
|
||||
// `channel` is set by the background from the message type the request arrived
|
||||
|
||||
@@ -26,3 +26,53 @@ export const PASSWORD_EXPIRY_MS = PASSWORD_EXPIRY_MIN * 60 * 1000;
|
||||
export const PHISHING_PAGE_REDIRECT = '/phishing-page-detected';
|
||||
|
||||
// console.log(`Extension is sending and receiving messages on ${PORT_PREFIX}-*`);
|
||||
|
||||
/**
|
||||
* Quantus endpoints this extension knows about, for the balance setting.
|
||||
*
|
||||
* Offered as a convenience, not as an authority. Asking a node for balances
|
||||
* tells that node which accounts belong to one person, so anyone who doubts a
|
||||
* default should point the setting at their own node — which is the reason the
|
||||
* field is free text and this list is only a starting point.
|
||||
*
|
||||
* Verified reachable on 2026-09-15.
|
||||
*/
|
||||
/**
|
||||
* The chains this extension can fetch metadata for, by genesis hash.
|
||||
*
|
||||
* The genesis hash is the identity: a sign request names one, and the endpoint
|
||||
* that answers is only believed if `chain_getBlockHash(0)` matches it. Without
|
||||
* that check this table would be a list of hosts we take on trust to be the
|
||||
* chain they claim, which is the same mistake as trusting a dapp's metadata.
|
||||
*
|
||||
* Verified reachable, and their genesis hashes read from the nodes, on
|
||||
* 2026-09-16.
|
||||
*/
|
||||
export const QUANTUS_CHAINS = [
|
||||
{
|
||||
endpoints: ['wss://rpc1-mainnet.quantus.com', 'wss://rpc2-mainnet.quantus.com'],
|
||||
genesisHash: '0xfb5487c0be6ae4ade2d41d16e50465129861636c2b8d61fa94d7a19631626fba',
|
||||
name: 'Quantus'
|
||||
},
|
||||
{
|
||||
endpoints: ['wss://a1-heisenberg.quantus.cat', 'wss://a2-heisenberg.quantus.cat'],
|
||||
genesisHash: '0xa5aa9e5c84d4a3722c152295e7973c9af522f2fb1ef7db5afaa3d5f4dc8d3b4f',
|
||||
name: 'Quantus Heisenberg'
|
||||
},
|
||||
{
|
||||
endpoints: ['wss://a1-planck.quantus.cat', 'wss://a2-planck.quantus.cat'],
|
||||
genesisHash: '0x4901bf5c57fd3f9e726af399c763de6670dbdb115a91c0237e173f16eef65e72',
|
||||
name: 'Quantus Planck'
|
||||
}
|
||||
] as const;
|
||||
|
||||
export const QUANTUS_ENDPOINTS = [
|
||||
{ text: 'Quantus', value: 'wss://rpc1-mainnet.quantus.com' },
|
||||
{ text: 'Heisenberg (testnet)', value: 'wss://a1-heisenberg.quantus.cat' },
|
||||
{ text: 'Heisenberg (testnet, a2)', value: 'wss://a2-heisenberg.quantus.cat' },
|
||||
{ text: 'Planck (testnet)', value: 'wss://a1-planck.quantus.cat' },
|
||||
{ text: 'Planck (testnet, a2)', value: 'wss://a2-planck.quantus.cat' }
|
||||
];
|
||||
|
||||
/** Mainnet, so the account list says something true out of the box. */
|
||||
export const DEFAULT_ENDPOINT = QUANTUS_ENDPOINTS[0].value;
|
||||
|
||||
@@ -3,16 +3,14 @@
|
||||
|
||||
import type { KeyringJson, KeyringStore } from '@polkadot/ui-keyring/types';
|
||||
|
||||
import { EXTENSION_PREFIX } from '../defaults.js';
|
||||
import BaseStore from './Base.js';
|
||||
|
||||
export default class AccountsStore extends BaseStore<KeyringJson> implements KeyringStore {
|
||||
constructor () {
|
||||
super(
|
||||
EXTENSION_PREFIX && EXTENSION_PREFIX !== 'polkadot{.js}'
|
||||
? `${EXTENSION_PREFIX}accounts`
|
||||
: null
|
||||
);
|
||||
// Fixed, not derived from EXTENSION_PREFIX. chrome.storage is already per
|
||||
// extension, and deriving the key from the name meant renaming the
|
||||
// extension (quantus/extension#15) would orphan everything stored.
|
||||
super(null);
|
||||
}
|
||||
|
||||
public override async set (key: string, value: KeyringJson, update?: () => void): Promise<void> {
|
||||
|
||||
@@ -3,15 +3,13 @@
|
||||
|
||||
import type { MetadataDef } from '@polkadot/extension-inject/types';
|
||||
|
||||
import { EXTENSION_PREFIX } from '../defaults.js';
|
||||
import BaseStore from './Base.js';
|
||||
|
||||
export default class MetadataStore extends BaseStore<MetadataDef> {
|
||||
constructor () {
|
||||
super(
|
||||
EXTENSION_PREFIX && EXTENSION_PREFIX !== 'polkadot{.js}'
|
||||
? `${EXTENSION_PREFIX}metadata`
|
||||
: 'metadata'
|
||||
);
|
||||
// Fixed, not derived from EXTENSION_PREFIX. chrome.storage is already per
|
||||
// extension, and deriving the key from the name meant renaming the
|
||||
// extension (quantus/extension#15) would orphan everything stored.
|
||||
super('metadata');
|
||||
}
|
||||
}
|
||||
|
||||
22
packages/extension-base/src/stores/Nullifiers.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
// Copyright 2019-2026 @polkadot/extension-base authors & contributors
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
import type { WormholeNullifiersJson } from '../background/types.js';
|
||||
|
||||
import BaseStore from './Base.js';
|
||||
|
||||
/**
|
||||
* Precomputed wormhole nullifiers, per wallet and account index.
|
||||
*
|
||||
* Kept apart from the wallet record because they are large (32 bytes per
|
||||
* address per transfer count) and grow, and because they are the one thing
|
||||
* here that could name a user's exits: a wallet's forget removes them with it.
|
||||
*/
|
||||
export default class NullifiersStore extends BaseStore<WormholeNullifiersJson> {
|
||||
constructor () {
|
||||
// Fixed, not derived from EXTENSION_PREFIX. chrome.storage is already per
|
||||
// extension, and deriving the key from the name meant renaming the
|
||||
// extension (quantus/extension#15) would orphan everything stored.
|
||||
super('quantus:nullifiers');
|
||||
}
|
||||
}
|
||||
22
packages/extension-base/src/stores/Wallets.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
// Copyright 2019-2026 @polkadot/extension-base authors & contributors
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
import type { WalletJson } from '../background/types.js';
|
||||
|
||||
import BaseStore from './Base.js';
|
||||
|
||||
/**
|
||||
* Wallets: one secret each, and the public addresses it unlocks.
|
||||
*
|
||||
* Its own prefix rather than the keyring's `account:` space. A wallet is not a
|
||||
* keyring entry, and the ML-DSA pairs it owns are stored there as they always
|
||||
* were, so signing and dapp injection never need to know wallets exist.
|
||||
*/
|
||||
export default class WalletsStore extends BaseStore<WalletJson> {
|
||||
constructor () {
|
||||
// Fixed, not derived from EXTENSION_PREFIX. chrome.storage is already per
|
||||
// extension, and deriving the key from the name meant renaming the
|
||||
// extension (quantus/extension#15) would orphan everything stored.
|
||||
super('quantus:wallet');
|
||||
}
|
||||
}
|
||||
@@ -3,3 +3,5 @@
|
||||
|
||||
export { default as AccountsStore } from './Accounts.js';
|
||||
export { default as MetadataStore } from './Metadata.js';
|
||||
export { default as NullifiersStore } from './Nullifiers.js';
|
||||
export { default as WalletsStore } from './Wallets.js';
|
||||
|
||||
52
packages/extension-base/src/test/metadata.ts
Normal file
@@ -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: {}
|
||||
};
|
||||
}
|
||||
@@ -3,6 +3,19 @@
|
||||
|
||||
import type { KeypairType } from '@polkadot/util-crypto/types';
|
||||
|
||||
export function canDerive (type?: KeypairType): boolean {
|
||||
return !!type && ['ed25519', 'sr25519', 'ecdsa', 'ethereum'].includes(type);
|
||||
/** Every keypair type this extension can hold and sign with. */
|
||||
const SIGNABLE: KeypairType[] = ['dilithium65', 'dilithium87'];
|
||||
|
||||
/**
|
||||
* Whether an account of this type should be offered to dapps.
|
||||
*
|
||||
* Upstream used `canDerive` for this, which worked only because every type it
|
||||
* knew about was derivable — so the check was really "is the type known". That
|
||||
* coincidence ends with ML-DSA: lattice keys are not derivable from one another,
|
||||
* and using `canDerive` here would have hidden **every Quantus account from every
|
||||
* dapp**, leaving an extension that looked empty to the whole ecosystem while
|
||||
* holding perfectly good accounts.
|
||||
*/
|
||||
export function canInject (type?: KeypairType): boolean {
|
||||
return !!type && SIGNABLE.includes(type);
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
// Copyright 2019-2026 @polkadot/extension-base authors & contributors
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
export { canDerive } from './canDerive.js';
|
||||
export { canInject } from './canDerive.js';
|
||||
export { isExtrinsicRequest } from './isExtrinsicRequest.js';
|
||||
|
||||
152
packages/extension-base/src/utils/portUtils.spec.ts
Normal file
@@ -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;
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
33
packages/extension-base/src/utils/quantusDefaults.spec.ts
Normal file
@@ -0,0 +1,33 @@
|
||||
// Copyright 2019-2026 @polkadot/extension-base authors & contributors
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
/// <reference types="@polkadot/dev-test/globals.d.ts" />
|
||||
import { Keyring } from '@polkadot/keyring';
|
||||
|
||||
import { canInject } from './canDerive.js';
|
||||
|
||||
describe('quantus defaults', (): void => {
|
||||
it('the default type produces a Quantus account', (): void => {
|
||||
const keyring = new Keyring({ ss58Format: 189, type: 'dilithium65' });
|
||||
const pair = keyring.createFromUri(
|
||||
'bottom drive obey lake curtain smoke basket hold race lonely fit walk', {}, 'dilithium65');
|
||||
|
||||
expect(pair.address).toEqual('qzq29m9WvneDAeXbtgueKCREtNe1rVVs6bXSMLmjr6shqvwq6');
|
||||
});
|
||||
|
||||
it('//1 gives a different account from the same phrase', (): void => {
|
||||
const keyring = new Keyring({ ss58Format: 189, type: 'dilithium65' });
|
||||
const phrase = 'bottom drive obey lake curtain smoke basket hold race lonely fit walk';
|
||||
const zero = keyring.createFromUri(phrase, {}, 'dilithium65').address;
|
||||
const one = keyring.createFromUri(`${phrase}//1`, {}, 'dilithium65').address;
|
||||
|
||||
expect(zero).not.toEqual(one);
|
||||
});
|
||||
|
||||
it('ML-DSA accounts are injected, and nothing else is', (): void => {
|
||||
expect(canInject('dilithium65')).toEqual(true);
|
||||
expect(canInject('dilithium87')).toEqual(true);
|
||||
expect(canInject('sr25519' as never)).toEqual(false);
|
||||
expect(canInject(undefined)).toEqual(false);
|
||||
});
|
||||
});
|
||||
@@ -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": {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -1,18 +1,31 @@
|
||||
// Copyright 2019-2026 @polkadot/extension-chains authors & contributors
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
import type { Runtime } from '@quantus/codec';
|
||||
import type { MetadataDef } from '@polkadot/extension-inject/types';
|
||||
import type { Registry } from '@polkadot/types/types';
|
||||
|
||||
export interface Chain {
|
||||
definition: MetadataDef;
|
||||
genesisHash?: string;
|
||||
/** Whether {@link Chain.runtime} was built. Nothing decodes or signs without it. */
|
||||
hasMetadata: boolean;
|
||||
icon: string;
|
||||
isEthereum?: boolean;
|
||||
isUnknown?: boolean;
|
||||
name: string;
|
||||
/**
|
||||
* Chain properties only — ss58 format, token symbol and decimals. Deliberately
|
||||
* carries no metadata: `@polkadot/types` cannot hold this chain's, and
|
||||
* everything that decodes or encodes goes through {@link Chain.runtime}.
|
||||
*/
|
||||
registry: Registry;
|
||||
/**
|
||||
* The runtime's own description of itself, from the metadata a dapp provided.
|
||||
* `null` when no metadata is known, in which case this extension will not sign
|
||||
* an extrinsic for the chain rather than guess at its format.
|
||||
*/
|
||||
runtime: Runtime | null;
|
||||
specVersion: number;
|
||||
ss58Format: number;
|
||||
tokenDecimals: number;
|
||||
|
||||
@@ -1,3 +0,0 @@
|
||||
# @polkadot/extension-metamask-compat
|
||||
|
||||
An optional metamask-compatible layer
|
||||
@@ -1,35 +0,0 @@
|
||||
{
|
||||
"author": "Jaco Greeff <jacogr@gmail.com>",
|
||||
"bugs": "https://github.com/polkadot-js/extension/issues",
|
||||
"description": "Metamask compatibility layer",
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
},
|
||||
"homepage": "https://github.com/polkadot-js/extension/tree/master/packages/extension-compat-metamask#readme",
|
||||
"license": "Apache-2.0",
|
||||
"name": "@polkadot/extension-compat-metamask",
|
||||
"repository": {
|
||||
"directory": "packages/extension-compat-metamask",
|
||||
"type": "git",
|
||||
"url": "https://github.com/polkadot-js/extension.git"
|
||||
},
|
||||
"sideEffects": [
|
||||
"./packageDetect.js",
|
||||
"./packageDetect.cjs"
|
||||
],
|
||||
"type": "module",
|
||||
"version": "0.64.0",
|
||||
"main": "index.js",
|
||||
"dependencies": {
|
||||
"@metamask/detect-provider": "^2.0.0",
|
||||
"@polkadot/extension-inject": "0.64.0",
|
||||
"@polkadot/types": "^16.5.6",
|
||||
"@polkadot/util": "^14.0.3",
|
||||
"tslib": "^2.8.1",
|
||||
"web3": "^4.7.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@polkadot/api": "*",
|
||||
"@polkadot/util": "*"
|
||||
}
|
||||
}
|
||||
@@ -1,102 +0,0 @@
|
||||
// Copyright 2019-2026 @polkadot/extension-compat-metamask authors & contributors
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
import type { Injected, InjectedAccount, InjectedWindow } from '@polkadot/extension-inject/types';
|
||||
import type { SignerPayloadRaw, SignerResult } from '@polkadot/types/types';
|
||||
import type { HexString } from '@polkadot/util/types';
|
||||
|
||||
import detectEthereumProvider from '@metamask/detect-provider';
|
||||
import Web3 from 'web3';
|
||||
|
||||
import { assert } from '@polkadot/util';
|
||||
|
||||
export { packageInfo } from './packageInfo.js';
|
||||
|
||||
interface RequestArguments {
|
||||
method: string;
|
||||
params?: unknown[];
|
||||
}
|
||||
|
||||
interface EthRpcSubscription {
|
||||
unsubscribe: () => void
|
||||
}
|
||||
|
||||
interface EthereumProvider {
|
||||
request: (args: RequestArguments) => Promise<unknown>;
|
||||
isMetaMask: boolean;
|
||||
on: (name: string, cb: (value: unknown) => void) => EthRpcSubscription;
|
||||
}
|
||||
|
||||
interface Web3Window extends InjectedWindow {
|
||||
// this is injected by metaMask
|
||||
ethereum: unknown;
|
||||
}
|
||||
|
||||
function isMetaMaskProvider (prov: unknown): EthereumProvider {
|
||||
assert(prov && (prov as EthereumProvider).isMetaMask, 'Injected provider is not MetaMask');
|
||||
|
||||
return (prov as EthereumProvider);
|
||||
}
|
||||
|
||||
// transform the Web3 accounts into a simple address/name array
|
||||
function transformAccounts (accounts: string[]): InjectedAccount[] {
|
||||
return accounts.map((address, i) => ({
|
||||
address,
|
||||
name: `MetaMask Address #${i}`,
|
||||
type: 'ethereum'
|
||||
}));
|
||||
}
|
||||
|
||||
// add a compat interface of metaMaskSource to window.injectedWeb3
|
||||
function injectMetaMaskWeb3 (win: Web3Window): void {
|
||||
// decorate the compat interface
|
||||
win.injectedWeb3['Web3Source'] = {
|
||||
enable: async (): Promise<Injected> => {
|
||||
const providerRaw = await detectEthereumProvider({ mustBeMetaMask: true });
|
||||
const provider = isMetaMaskProvider(providerRaw);
|
||||
|
||||
await provider.request({ method: 'eth_requestAccounts' });
|
||||
|
||||
return {
|
||||
accounts: {
|
||||
get: async (): Promise<InjectedAccount[]> => {
|
||||
const response = (await provider.request({ method: 'eth_requestAccounts' })) as string[];
|
||||
|
||||
return transformAccounts(response);
|
||||
},
|
||||
subscribe: (cb: (accounts: InjectedAccount[]) => void): (() => void) => {
|
||||
const sub = provider.on('accountsChanged', (accounts): void => {
|
||||
cb(transformAccounts(accounts as string[]));
|
||||
});
|
||||
// TODO: add onchainchanged
|
||||
|
||||
return (): void => {
|
||||
sub.unsubscribe();
|
||||
};
|
||||
}
|
||||
},
|
||||
signer: {
|
||||
signRaw: async (raw: SignerPayloadRaw): Promise<SignerResult> => {
|
||||
const signature = (await provider.request({ method: 'eth_sign', params: [raw.address, Web3.utils.sha3(raw.data)] })) as HexString;
|
||||
|
||||
return { id: 0, signature };
|
||||
}
|
||||
}
|
||||
};
|
||||
},
|
||||
version: '0' // TODO: win.ethereum.version
|
||||
};
|
||||
}
|
||||
|
||||
export default function initMetaMask (): Promise<boolean> {
|
||||
return new Promise((resolve): void => {
|
||||
const win = window as Window & Web3Window;
|
||||
|
||||
if (win.ethereum) {
|
||||
injectMetaMaskWeb3(win);
|
||||
resolve(true);
|
||||
} else {
|
||||
resolve(false);
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -1,7 +0,0 @@
|
||||
// Copyright 2019-2026 @polkadot/extension-compat-metamask authors & contributors
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
// Since we inject into pages, we skip this
|
||||
// import './detectPackage';
|
||||
|
||||
export * from './bundle.js';
|
||||
@@ -1,12 +0,0 @@
|
||||
// Copyright 2017-2026 @polkadot/extension-compat-metamask authors & contributors
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
// Do not edit, auto-generated by @polkadot/dev
|
||||
// (packageInfo imports will be kept as-is, user-editable)
|
||||
|
||||
import { packageInfo as injectInfo } from '@polkadot/extension-inject/packageInfo';
|
||||
import { detectPackage } from '@polkadot/util';
|
||||
|
||||
import { packageInfo } from './packageInfo.js';
|
||||
|
||||
detectPackage(packageInfo, null, [injectInfo]);
|
||||
@@ -1,6 +0,0 @@
|
||||
// Copyright 2017-2026 @polkadot/extension-compat-metamask authors & contributors
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
// Do not edit, auto-generated by @polkadot/dev
|
||||
|
||||
export const packageInfo = { name: '@polkadot/extension-compat-metamask', path: 'auto', type: 'auto', version: '0.64.0' };
|
||||
@@ -1,11 +0,0 @@
|
||||
{
|
||||
"extends": "../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"baseUrl": "..",
|
||||
"outDir": "./build",
|
||||
"rootDir": "./src"
|
||||
},
|
||||
"references": [
|
||||
{ "path": "../extension-inject/tsconfig.build.json" }
|
||||
]
|
||||
}
|
||||
@@ -47,20 +47,19 @@ describe('Account component', () => {
|
||||
</MemoryRouter>);
|
||||
|
||||
it('shows Export option if account is not external', async () => {
|
||||
wrapper = mountAccountComponent({ isExternal: false, type: 'ed25519' });
|
||||
wrapper = mountAccountComponent({ isExternal: false, type: 'dilithium65' });
|
||||
wrapper.find('.settings').first().simulate('click');
|
||||
await act(flushAllPromises);
|
||||
|
||||
expect(wrapper.find('a.menuItem').length).toBe(4);
|
||||
expect(wrapper.find('a.menuItem').length).toBe(3);
|
||||
expect(wrapper.find('a.menuItem').at(0).text()).toBe('Rename');
|
||||
expect(wrapper.find('a.menuItem').at(1).text()).toBe('Derive New Account');
|
||||
expect(wrapper.find('a.menuItem').at(2).text()).toBe('Export Account');
|
||||
expect(wrapper.find('a.menuItem').at(3).text()).toBe('Forget Account');
|
||||
expect(wrapper.find('a.menuItem').at(1).text()).toBe('Export Account');
|
||||
expect(wrapper.find('a.menuItem').at(2).text()).toBe('Forget Account');
|
||||
expect(wrapper.find('.genesisSelection').exists()).toBe(true);
|
||||
});
|
||||
|
||||
it('does not show Export option if account is external', async () => {
|
||||
wrapper = mountAccountComponent({ isExternal: true, type: 'ed25519' });
|
||||
wrapper = mountAccountComponent({ isExternal: true, type: 'dilithium65' });
|
||||
wrapper.find('.settings').first().simulate('click');
|
||||
await act(flushAllPromises);
|
||||
|
||||
@@ -70,19 +69,6 @@ describe('Account component', () => {
|
||||
expect(wrapper.find('.genesisSelection').exists()).toBe(true);
|
||||
});
|
||||
|
||||
it('shows Derive option if account is of ethereum type', async () => {
|
||||
wrapper = mountAccountComponent({ isExternal: false, type: 'ethereum' });
|
||||
wrapper.find('.settings').first().simulate('click');
|
||||
await act(flushAllPromises);
|
||||
|
||||
expect(wrapper.find('a.menuItem').length).toBe(4);
|
||||
expect(wrapper.find('a.menuItem').at(0).text()).toBe('Rename');
|
||||
expect(wrapper.find('a.menuItem').at(1).text()).toBe('Derive New Account');
|
||||
expect(wrapper.find('a.menuItem').at(2).text()).toBe('Export Account');
|
||||
expect(wrapper.find('a.menuItem').at(3).text()).toBe('Forget Account');
|
||||
expect(wrapper.find('.genesisSelection').exists()).toBe(true);
|
||||
});
|
||||
|
||||
// Was two tests, one per Ledger app mode: the chain-specific app could not
|
||||
// sign for an arbitrary genesis hash, so the dropdown was hidden for hardware
|
||||
// accounts unless the generic app was selected. With no hardware path left
|
||||
|
||||
@@ -6,8 +6,6 @@ import type { HexString } from '@polkadot/util/types';
|
||||
|
||||
import React, { useCallback, useContext, useEffect, useMemo, useState } from 'react';
|
||||
|
||||
import { canDerive } from '@polkadot/extension-base/utils';
|
||||
|
||||
import { AccountContext, Address, Checkbox, Dropdown, Link, MenuDivider } from '../../components/index.js';
|
||||
import { useGenesisHashOptions, useTranslation } from '../../hooks/index.js';
|
||||
import { editAccount, tieAccount } from '../../messaging.js';
|
||||
@@ -27,7 +25,7 @@ interface EditState {
|
||||
toggleActions: number;
|
||||
}
|
||||
|
||||
function Account ({ address, className, genesisHash, isExternal, isHardware, isHidden, name, parentName, showVisibilityAction, suri, type, withCheckbox = false, withMenu = true }: Props): React.ReactElement<Props> {
|
||||
function Account ({ address, className, genesisHash, isExternal, isHardware, isHidden, name, parentName, showVisibilityAction, suri, withCheckbox = false, withMenu = true }: Props): React.ReactElement<Props> {
|
||||
const { t } = useTranslation();
|
||||
const [{ isEditing, toggleActions }, setEditing] = useState<EditState>({ isEditing: false, toggleActions: 0 });
|
||||
const [editedName, setName] = useState<string | undefined | null>(name);
|
||||
@@ -84,14 +82,6 @@ function Account ({ address, className, genesisHash, isExternal, isHardware, isH
|
||||
>
|
||||
{t('Rename')}
|
||||
</Link>
|
||||
{!isExternal && canDerive(type) && (
|
||||
<Link
|
||||
className='menuItem'
|
||||
to={`/account/derive/${address}/locked`}
|
||||
>
|
||||
{t('Derive New Account')}
|
||||
</Link>
|
||||
)}
|
||||
<MenuDivider />
|
||||
{!isExternal && (
|
||||
<Link
|
||||
@@ -124,7 +114,7 @@ function Account ({ address, className, genesisHash, isExternal, isHardware, isH
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
), [_onChangeGenesis, _toggleEdit, address, canEditGenesis, genesisHash, genesisOptions, isExternal, t, type]);
|
||||
), [_onChangeGenesis, _toggleEdit, address, canEditGenesis, genesisHash, genesisOptions, isExternal, t]);
|
||||
|
||||
return (
|
||||
<div className={className}>
|
||||
|
||||
262
packages/extension-ui/src/Popup/Accounts/Wallet.tsx
Normal file
@@ -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;
|
||||
}
|
||||
`;
|
||||
197
packages/extension-ui/src/Popup/Accounts/WormholeSummary.tsx
Normal file
@@ -0,0 +1,197 @@
|
||||
// Copyright 2019-2026 @polkadot/extension-ui authors & contributors
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
import type { WormholeBalance } from '@polkadot/extension-base/background/types';
|
||||
|
||||
import React, { useCallback, useEffect, useState } from 'react';
|
||||
|
||||
import { Link } from '../../components/index.js';
|
||||
import { useTranslation } from '../../hooks/index.js';
|
||||
import { wormholeBalance } from '../../messaging.js';
|
||||
import { styled } from '../../styled.js';
|
||||
import { getBalanceEndpoint, onBalanceEndpointChange } from '../../util/balanceEndpoint.js';
|
||||
import { formatBalance } from '../../util/formatBalance.js';
|
||||
import { getWormholeObserver, onWormholeObserverChange } from '../../util/wormholeObserver.js';
|
||||
|
||||
interface Props {
|
||||
accountIndex: number;
|
||||
className?: string;
|
||||
walletId: string;
|
||||
}
|
||||
|
||||
type State =
|
||||
| { kind: 'off', reason: string }
|
||||
| { kind: 'checking' }
|
||||
| { kind: 'error', message: string }
|
||||
| { kind: 'done', balance: WormholeBalance };
|
||||
|
||||
/**
|
||||
* The last answer per wallet account and source, for this page's lifetime. A
|
||||
* check takes seconds, and switching tabs or accounts should not start another.
|
||||
* The node and observer are part of the key: an answer read from mainnet is not
|
||||
* an answer about Heisenberg, and showing it after a switch would be a lie.
|
||||
*/
|
||||
const answers = new Map<string, WormholeBalance>();
|
||||
|
||||
const answerKey = (walletId: string, accountIndex: number, endpoint = getBalanceEndpoint(), observer = getWormholeObserver()) =>
|
||||
`${walletId}:${accountIndex}:${endpoint}:${observer}`;
|
||||
|
||||
export function forgetWormholeAnswer (walletId: string, accountIndex: number): void {
|
||||
const prefix = `${walletId}:${accountIndex}:`;
|
||||
|
||||
[...answers.keys()].filter((k) => k.startsWith(prefix)).forEach((k) => answers.delete(k));
|
||||
}
|
||||
|
||||
export function lastWormholeAnswer (walletId: string, accountIndex: number): WormholeBalance | undefined {
|
||||
return answers.get(answerKey(walletId, accountIndex));
|
||||
}
|
||||
|
||||
/**
|
||||
* What a wallet account's wormhole addresses can still spend, and how far to
|
||||
* trust that.
|
||||
*
|
||||
* Every way the number could be short is said out loud rather than hidden in
|
||||
* it: deposits the observer has not indexed yet, and deposits whose nullifier
|
||||
* this wallet has not computed. A balance that is a floor is labelled a floor.
|
||||
*/
|
||||
function WormholeSummary ({ accountIndex, className, walletId }: Props): React.ReactElement<Props> {
|
||||
const { t } = useTranslation();
|
||||
const [endpoint, setEndpoint] = useState(getBalanceEndpoint);
|
||||
const [observer, setObserver] = useState(getWormholeObserver);
|
||||
const key = answerKey(walletId, accountIndex, endpoint, observer);
|
||||
const [state, setState] = useState<State>(() => {
|
||||
const cached = answers.get(key);
|
||||
|
||||
return cached ? { balance: cached, kind: 'done' } : { kind: 'checking' };
|
||||
});
|
||||
const [generation, setGeneration] = useState(0);
|
||||
|
||||
useEffect(() => onBalanceEndpointChange(setEndpoint), []);
|
||||
useEffect(() => onWormholeObserverChange(setObserver), []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!endpoint) {
|
||||
setState({ kind: 'off', reason: t('Balances are off in settings.') });
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if (!observer) {
|
||||
setState({ kind: 'off', reason: t('Wormhole deposit lookups are off in settings.') });
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
const cached = answers.get(key);
|
||||
|
||||
if (cached && generation === 0) {
|
||||
setState({ balance: cached, kind: 'done' });
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
let isCurrent = true;
|
||||
|
||||
setState({ kind: 'checking' });
|
||||
wormholeBalance(walletId, accountIndex, endpoint, observer)
|
||||
.then((balance) => {
|
||||
answers.set(key, balance);
|
||||
isCurrent && setState({ balance, kind: 'done' });
|
||||
})
|
||||
.catch((error: Error) => {
|
||||
isCurrent && setState({ kind: 'error', message: error.message });
|
||||
});
|
||||
|
||||
return () => {
|
||||
isCurrent = false;
|
||||
};
|
||||
}, [accountIndex, endpoint, generation, key, observer, t, walletId]);
|
||||
|
||||
const _refresh = useCallback(() => setGeneration((g) => g + 1), []);
|
||||
|
||||
const amount = (free: string, { decimals, symbol }: WormholeBalance) =>
|
||||
formatBalance({ decimals, free, symbol });
|
||||
|
||||
return (
|
||||
<div
|
||||
className={className}
|
||||
data-field='wormhole-balance'
|
||||
>
|
||||
{state.kind === 'off' && <div className='note'>{state.reason}</div>}
|
||||
{state.kind === 'checking' && (
|
||||
<div className='note'>{t('Checking deposits against the chain…')}</div>
|
||||
)}
|
||||
{state.kind === 'error' && (
|
||||
<div className='note error'>
|
||||
{t('Could not check the wormhole balance: {{message}}', { replace: { message: state.message } })}
|
||||
{' '}
|
||||
<Link onClick={_refresh}>{t('Retry')}</Link>
|
||||
</div>
|
||||
)}
|
||||
{state.kind === 'done' && (() => {
|
||||
const { balance } = state;
|
||||
const floor = balance.missing > 0 || balance.uncheckedDeposits > 0;
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className='spendable'>
|
||||
{floor ? t('At least') : ''} {amount(balance.spendable, balance)} {t('spendable')}
|
||||
</div>
|
||||
<div className='note'>
|
||||
{balance.transfers === 0
|
||||
? t('No deposits yet.')
|
||||
: t('{{deposits}} deposits, {{spent}} already exited.', {
|
||||
replace: { deposits: balance.transfers, spent: amount(balance.spent, balance) }
|
||||
})}
|
||||
</div>
|
||||
{balance.missing > 0 && (
|
||||
<div className='note warn'>
|
||||
{t('{{missing}} deposits are not indexed by the observer yet (it has read from block {{from}}); they are not counted.', {
|
||||
replace: { from: balance.indexedFrom ?? '?', missing: balance.missing }
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
{balance.uncheckedDeposits > 0 && (
|
||||
<div className='note warn'>
|
||||
{t('{{count}} deposits ({{amount}}) arrived after this wallet last computed its nullifiers, so whether they are spent is unknown.', {
|
||||
replace: { amount: amount(balance.unchecked, balance), count: balance.uncheckedDeposits }
|
||||
})}
|
||||
{' '}
|
||||
<Link to={`/wallet/wormhole-unlock/${walletId}/${accountIndex}`}>{t('Unlock to check')}</Link>
|
||||
</div>
|
||||
)}
|
||||
<div className='note'>
|
||||
{t('Checked {{at}}.', { replace: { at: new Date(balance.checkedAt).toLocaleTimeString() } })}
|
||||
{' '}
|
||||
<Link onClick={_refresh}>{t('Refresh')}</Link>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
})()}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default styled(WormholeSummary)<Props>`
|
||||
padding: 0 12px 8px;
|
||||
|
||||
.spendable {
|
||||
font-size: 15px;
|
||||
margin-bottom: 2px;
|
||||
}
|
||||
|
||||
.note {
|
||||
color: var(--labelColor);
|
||||
font-size: 12px;
|
||||
line-height: 16px;
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
.warn {
|
||||
color: var(--warningColor, var(--labelColor));
|
||||
}
|
||||
|
||||
.error {
|
||||
color: var(--errorColor);
|
||||
}
|
||||
`;
|
||||
@@ -7,12 +7,13 @@ import React, { useCallback, useContext, useEffect, useMemo, useState } from 're
|
||||
|
||||
import getNetworkMap from '@polkadot/extension-ui/util/getNetworkMap';
|
||||
|
||||
import { AccountContext } from '../../components/index.js';
|
||||
import { AccountContext, WalletContext } from '../../components/index.js';
|
||||
import { useTranslation } from '../../hooks/index.js';
|
||||
import { Header } from '../../partials/index.js';
|
||||
import { styled } from '../../styled.js';
|
||||
import AccountsTree from './AccountsTree.js';
|
||||
import AddAccount from './AddAccount.js';
|
||||
import Wallet from './Wallet.js';
|
||||
|
||||
interface Props {
|
||||
className?: string;
|
||||
@@ -23,19 +24,31 @@ function Accounts ({ className }: Props): React.ReactElement {
|
||||
const [filter, setFilter] = useState('');
|
||||
const [filteredAccount, setFilteredAccount] = useState<AccountWithChildren[]>([]);
|
||||
const { hierarchy } = useContext(AccountContext);
|
||||
const wallets = useContext(WalletContext);
|
||||
const networkMap = useMemo(() => getNetworkMap(), []);
|
||||
// Pairs a wallet owns are shown on the wallet's card, not again on their own.
|
||||
const standalone = useMemo(() => hierarchy.filter(({ walletId }) => !walletId), [hierarchy]);
|
||||
const filteredWallets = useMemo(() => filter
|
||||
? wallets.filter(({ accounts, genesisHash, name }) =>
|
||||
name.toLowerCase().includes(filter) ||
|
||||
(genesisHash && networkMap.get(genesisHash)?.toLowerCase().includes(filter)) ||
|
||||
accounts.some(({ mldsa65, mldsa87, wormhole }) =>
|
||||
[mldsa65, mldsa87, ...(wormhole?.receive ?? [])].some((a) => a.toLowerCase().includes(filter))
|
||||
)
|
||||
)
|
||||
: wallets, [filter, networkMap, wallets]);
|
||||
|
||||
useEffect(() => {
|
||||
setFilteredAccount(
|
||||
filter
|
||||
? hierarchy.filter((account) =>
|
||||
? standalone.filter((account) =>
|
||||
account.name?.toLowerCase().includes(filter) ||
|
||||
(account.genesisHash && networkMap.get(account.genesisHash)?.toLowerCase().includes(filter)) ||
|
||||
account.address.toLowerCase().includes(filter)
|
||||
)
|
||||
: hierarchy
|
||||
: standalone
|
||||
);
|
||||
}, [filter, hierarchy, networkMap]);
|
||||
}, [filter, standalone, networkMap]);
|
||||
|
||||
const _onFilter = useCallback((filter: string) => {
|
||||
setFilter(filter.toLowerCase());
|
||||
@@ -43,7 +56,7 @@ function Accounts ({ className }: Props): React.ReactElement {
|
||||
|
||||
return (
|
||||
<>
|
||||
{(hierarchy.length === 0)
|
||||
{(hierarchy.length === 0 && wallets.length === 0)
|
||||
? <AddAccount />
|
||||
: (
|
||||
<>
|
||||
@@ -56,6 +69,12 @@ function Accounts ({ className }: Props): React.ReactElement {
|
||||
text={t('Accounts')}
|
||||
/>
|
||||
<div className={className}>
|
||||
{filteredWallets.map((wallet) => (
|
||||
<Wallet
|
||||
key={wallet.id}
|
||||
wallet={wallet}
|
||||
/>
|
||||
))}
|
||||
{filteredAccount.map((json, index): React.ReactNode => (
|
||||
<AccountsTree
|
||||
{...json}
|
||||
|
||||
@@ -1,81 +0,0 @@
|
||||
// Copyright 2019-2026 @polkadot/extension-ui authors & contributors
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
import React, { useCallback, useContext } from 'react';
|
||||
|
||||
import { ActionContext, Box, Button, ButtonArea, List } from '../components/index.js';
|
||||
import { useTranslation } from '../hooks/index.js';
|
||||
import { Header } from '../partials/index.js';
|
||||
import { styled } from '../styled.js';
|
||||
|
||||
interface Props {
|
||||
className?: string;
|
||||
}
|
||||
|
||||
function AssetHubMigration ({ className }: Props): React.ReactElement<Props> {
|
||||
const { t } = useTranslation();
|
||||
const onAction = useContext(ActionContext);
|
||||
|
||||
const _onClick = useCallback(
|
||||
(): void => {
|
||||
window.localStorage.setItem('asset_hub_migration_read', 'ok');
|
||||
onAction();
|
||||
},
|
||||
[onAction]
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Header text={t('Asset Hub Migration Notice')} />
|
||||
<div className={className}>
|
||||
<p>{t('The Asset Hub migration has been completed. Please note the following important changes:')}</p>
|
||||
<Box>
|
||||
<List>
|
||||
<li>{t('All balances have been migrated from the Relay Chain to Asset Hub')}</li>
|
||||
<li>{t('All on-chain functionality has been moved to Asset Hub')}</li>
|
||||
<li>{t('Asset Hub now holds user balances and provides general functionality')}</li>
|
||||
</List>
|
||||
</Box>
|
||||
<p className='warning'>{t('Do not teleport balances to the Relay Chain unless:')}</p>
|
||||
<Box>
|
||||
<List>
|
||||
<li>{t('You are opening HRMP channels, or')}</li>
|
||||
<li>{t('You are starting a Parachain')}</li>
|
||||
</List>
|
||||
</Box>
|
||||
<p>{t('For all other operations, your balances are already on Asset Hub.')}</p>
|
||||
</div>
|
||||
<ButtonArea>
|
||||
<Button onClick={_onClick}>{t('I Understand')}</Button>
|
||||
</ButtonArea>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export default styled(AssetHubMigration)<Props>`
|
||||
p {
|
||||
color: var(--subTextColor);
|
||||
margin-bottom: 4px;
|
||||
margin-top: 0;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
p.warning {
|
||||
color: var(--errorColor);
|
||||
font-weight: 600;
|
||||
font-size: 1.1em;
|
||||
margin-top: 6px;
|
||||
margin-bottom: 2px;
|
||||
text-transform: uppercase;
|
||||
line-height: 1.3;
|
||||
}
|
||||
|
||||
article {
|
||||
margin: 0.4rem 24px;
|
||||
padding: 8px 20px;
|
||||
}
|
||||
|
||||
ul {
|
||||
margin: 0;
|
||||
}
|
||||
`;
|
||||
@@ -31,17 +31,17 @@ const twoRequests = [
|
||||
];
|
||||
|
||||
const oneAccount = [
|
||||
{ address: '5FjgD3Ns2UpnHJPVeRViMhCttuemaRXEqaD8V5z4vxcsUByA', name: 'A', type: 'sr25519' }
|
||||
{ address: '5FjgD3Ns2UpnHJPVeRViMhCttuemaRXEqaD8V5z4vxcsUByA', name: 'A', type: 'dilithium65' }
|
||||
] as AccountJson[];
|
||||
|
||||
const twoAccountsOnehidden = [
|
||||
...oneAccount,
|
||||
{ address: '5GYmFzQCuC5u3tQNiMZNbFGakrz3Jq31NmMg4D2QAkSoQ2g5', isHidden: true, name: 'B', type: 'sr25519' }
|
||||
{ address: '5GYmFzQCuC5u3tQNiMZNbFGakrz3Jq31NmMg4D2QAkSoQ2g5', isHidden: true, name: 'B', type: 'dilithium65' }
|
||||
] as AccountJson[];
|
||||
|
||||
const threeAccountsOnehidden = [
|
||||
...twoAccountsOnehidden,
|
||||
{ address: '5D2TPhGEy2FhznvzaNYW9AkuMBbg3cyRemnPsBvBY4ZhkZXA', name: 'BB', parentAddress: twoAccountsOnehidden[1].address, type: 'sr25519' }
|
||||
{ address: '5D2TPhGEy2FhznvzaNYW9AkuMBbg3cyRemnPsBvBY4ZhkZXA', name: 'BB', parentAddress: twoAccountsOnehidden[1].address, type: 'dilithium65' }
|
||||
] as AccountJson[];
|
||||
|
||||
describe('Authorize', () => {
|
||||
|
||||
@@ -1,105 +1,84 @@
|
||||
// Copyright 2019-2026 @polkadot/extension-ui authors & contributors
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
import type { ResponseWalletPreview } from '@polkadot/extension-base/background/types';
|
||||
import type { HexString } from '@polkadot/util/types';
|
||||
|
||||
import React, { useCallback, useContext, useEffect, useState } from 'react';
|
||||
|
||||
import AccountNamePasswordCreation from '../../components/AccountNamePasswordCreation.js';
|
||||
import { ActionContext, Address, Dropdown, Loading } from '../../components/index.js';
|
||||
import { useGenesisHashOptions, useMetadata, useTranslation } from '../../hooks/index.js';
|
||||
import { createAccountSuri, createSeed, validateSeed } from '../../messaging.js';
|
||||
import { ActionContext, Dropdown, Loading, WalletPreview } from '../../components/index.js';
|
||||
import { useGenesisHashOptions, useTranslation } from '../../hooks/index.js';
|
||||
import { createSeed, createWallet, previewWallet } from '../../messaging.js';
|
||||
import { HeaderWithSteps } from '../../partials/index.js';
|
||||
import { styled } from '../../styled.js';
|
||||
import { DEFAULT_TYPE } from '../../util/defaultType.js';
|
||||
import Mnemonic from './Mnemonic.js';
|
||||
|
||||
interface Props {
|
||||
className?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a wallet from a new recovery phrase.
|
||||
*
|
||||
* The phrase becomes one wallet showing its ML-DSA-65, ML-DSA-87 and wormhole
|
||||
* accounts together, so there is no key type to choose. See quantus/extension#14.
|
||||
*/
|
||||
function CreateAccount ({ className }: Props): React.ReactElement {
|
||||
const { t } = useTranslation();
|
||||
const onAction = useContext(ActionContext);
|
||||
const [isBusy, setIsBusy] = useState(false);
|
||||
const [step, setStep] = useState(1);
|
||||
const [address, setAddress] = useState<null | string>(null);
|
||||
const [seed, setSeed] = useState<null | string>(null);
|
||||
const [type, setType] = useState(DEFAULT_TYPE);
|
||||
const [preview, setPreview] = useState<ResponseWalletPreview | null>(null);
|
||||
const [name, setName] = useState('');
|
||||
const [error, setError] = useState('');
|
||||
const options = useGenesisHashOptions();
|
||||
const [genesisHash, setGenesis] = useState<HexString | null>(null);
|
||||
const chain = useMetadata(genesisHash, true);
|
||||
|
||||
useEffect((): void => {
|
||||
createSeed(undefined)
|
||||
.then(({ address, seed }): void => {
|
||||
setAddress(address);
|
||||
.then(({ seed }) => {
|
||||
setSeed(seed);
|
||||
|
||||
return previewWallet(seed);
|
||||
})
|
||||
.then(setPreview)
|
||||
.catch(console.error);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
useEffect((): void => {
|
||||
if (seed) {
|
||||
const type = chain && chain.definition.chainType === 'ethereum'
|
||||
? 'ethereum'
|
||||
: DEFAULT_TYPE;
|
||||
|
||||
setType(type);
|
||||
validateSeed(seed, type)
|
||||
.then(({ address }) => setAddress(address))
|
||||
.catch(console.error);
|
||||
}
|
||||
}, [seed, chain]);
|
||||
|
||||
const _onCreate = useCallback(
|
||||
(name: string, password: string): void => {
|
||||
// this should always be the case
|
||||
if (name && password && seed) {
|
||||
setIsBusy(true);
|
||||
setError('');
|
||||
|
||||
createAccountSuri(name, password, seed, type, genesisHash)
|
||||
createWallet(name, password, seed, genesisHash)
|
||||
.then(() => onAction('/'))
|
||||
.catch((error: Error): void => {
|
||||
setIsBusy(false);
|
||||
console.error(error);
|
||||
setError(error.message);
|
||||
});
|
||||
}
|
||||
},
|
||||
[genesisHash, onAction, seed, type]
|
||||
[genesisHash, onAction, seed]
|
||||
);
|
||||
|
||||
const _onNextStep = useCallback(
|
||||
() => setStep((step) => step + 1),
|
||||
[]
|
||||
);
|
||||
|
||||
const _onPreviousStep = useCallback(
|
||||
() => setStep((step) => step - 1),
|
||||
[]
|
||||
);
|
||||
|
||||
const _onChangeNetwork = useCallback(
|
||||
(newGenesisHash: HexString) => setGenesis(newGenesisHash),
|
||||
[]
|
||||
);
|
||||
const _onNextStep = useCallback(() => setStep((step) => step + 1), []);
|
||||
const _onPreviousStep = useCallback(() => setStep((step) => step - 1), []);
|
||||
const _onChangeNetwork = useCallback((newGenesisHash: HexString) => setGenesis(newGenesisHash), []);
|
||||
|
||||
return (
|
||||
<>
|
||||
<HeaderWithSteps
|
||||
step={step}
|
||||
text={t('Create an account')}
|
||||
text={t('Create a wallet')}
|
||||
/>
|
||||
<Loading>
|
||||
<div>
|
||||
<Address
|
||||
address={address}
|
||||
genesisHash={genesisHash}
|
||||
name={name}
|
||||
/>
|
||||
</div>
|
||||
<WalletPreview
|
||||
name={name}
|
||||
preview={preview}
|
||||
/>
|
||||
{seed && (
|
||||
step === 1
|
||||
? (
|
||||
@@ -118,7 +97,8 @@ function CreateAccount ({ className }: Props): React.ReactElement {
|
||||
value={genesisHash}
|
||||
/>
|
||||
<AccountNamePasswordCreation
|
||||
buttonLabel={t('Add the account with the generated seed')}
|
||||
buttonLabel={t('Add the wallet with the generated recovery phrase')}
|
||||
error={error}
|
||||
isBusy={isBusy}
|
||||
onBackClick={_onPreviousStep}
|
||||
onCreate={_onCreate}
|
||||
|
||||
@@ -1,106 +0,0 @@
|
||||
// Copyright 2019-2026 @polkadot/extension-ui authors & contributors
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
import React, { useCallback, useRef, useState } from 'react';
|
||||
|
||||
import arrow from '../../assets/arrow-down.svg';
|
||||
import { Address } from '../../components/index.js';
|
||||
import { useOutsideClick } from '../../hooks/index.js';
|
||||
import { styled } from '../../styled.js';
|
||||
|
||||
interface Props {
|
||||
allAddresses: [string, string | null][];
|
||||
className?: string;
|
||||
onSelect: (address: string) => void;
|
||||
selectedAddress: string;
|
||||
selectedGenesis: string | null;
|
||||
}
|
||||
|
||||
function AddressDropdown ({ allAddresses, className, onSelect, selectedAddress, selectedGenesis }: Props): React.ReactElement<Props> {
|
||||
const [isDropdownVisible, setDropdownVisible] = useState(false);
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
|
||||
const _hideDropdown = useCallback(() => setDropdownVisible(false), []);
|
||||
const _toggleDropdown = useCallback(() => setDropdownVisible(!isDropdownVisible), [isDropdownVisible]);
|
||||
const _selectParent = useCallback((newParent: string) => () => onSelect(newParent), [onSelect]);
|
||||
|
||||
useOutsideClick([ref], _hideDropdown);
|
||||
|
||||
return (
|
||||
<div className={className}>
|
||||
<div
|
||||
onClick={_toggleDropdown}
|
||||
ref={ref}
|
||||
>
|
||||
<Address
|
||||
address={selectedAddress}
|
||||
className='address'
|
||||
genesisHash={selectedGenesis}
|
||||
/>
|
||||
</div>
|
||||
<div className={`dropdown ${isDropdownVisible ? 'visible' : ''}`}>
|
||||
{allAddresses.map(([address, genesisHash]) => (
|
||||
<div
|
||||
data-parent-option
|
||||
key={address}
|
||||
onClick={_selectParent(address)}
|
||||
>
|
||||
<Address
|
||||
address={address}
|
||||
className='address'
|
||||
genesisHash={genesisHash}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default styled(AddressDropdown)<Props>`
|
||||
margin-bottom: 16px;
|
||||
cursor: pointer;
|
||||
|
||||
& > div:first-child > .address::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 66%;
|
||||
transform: translateY(-50%);
|
||||
right: 11px;
|
||||
width: 30px;
|
||||
height: 30px;
|
||||
background: url(${arrow}) center no-repeat;
|
||||
background-color: var(--inputBackground);
|
||||
pointer-events: none;
|
||||
border-radius: 4px;
|
||||
border: 1px solid var(--boxBorderColor);
|
||||
}
|
||||
|
||||
.address .copyIcon {
|
||||
visibility: hidden;
|
||||
}
|
||||
|
||||
.dropdown {
|
||||
position: absolute;
|
||||
visibility: hidden;
|
||||
width: 510px;
|
||||
z-index: 100;
|
||||
background: var(--bodyColor);
|
||||
max-height: 0;
|
||||
overflow: auto;
|
||||
padding: 5px;
|
||||
border: 1px solid var(--boxBorderColor);
|
||||
box-sizing: border-box;
|
||||
border-radius: 4px;
|
||||
margin-top: -8px;
|
||||
|
||||
&.visible{
|
||||
visibility: visible;
|
||||
max-height: 200px;
|
||||
}
|
||||
|
||||
& > div {
|
||||
cursor: pointer;
|
||||
}
|
||||
}
|
||||
`;
|
||||
@@ -1,110 +0,0 @@
|
||||
// Copyright 2019-2026 @polkadot/extension-ui authors & contributors
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
import { faLock, faLockOpen } from '@fortawesome/free-solid-svg-icons';
|
||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
||||
import React, { useCallback, useEffect, useState } from 'react';
|
||||
|
||||
import { Button, InputWithLabel } from '../../components/index.js';
|
||||
import { useTranslation } from '../../hooks/index.js';
|
||||
import { styled } from '../../styled.js';
|
||||
|
||||
interface Props {
|
||||
className?: string;
|
||||
defaultPath: string;
|
||||
isError: boolean;
|
||||
onChange: (suri: string) => void;
|
||||
parentAddress: string;
|
||||
parentPassword: string;
|
||||
withSoftPath: boolean;
|
||||
}
|
||||
|
||||
function DerivationPath ({ className, defaultPath, isError, onChange, withSoftPath }: Props): React.ReactElement<Props> {
|
||||
const { t } = useTranslation();
|
||||
const [path, setPath] = useState<string>(defaultPath);
|
||||
const [isDisabled, setIsDisabled] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
setPath(defaultPath);
|
||||
}, [defaultPath]);
|
||||
|
||||
const _onExpand = useCallback(() => setIsDisabled(!isDisabled), [isDisabled]);
|
||||
|
||||
const _onChange = useCallback((newPath: string): void => {
|
||||
setPath(newPath);
|
||||
onChange(newPath);
|
||||
}, [onChange]);
|
||||
|
||||
return (
|
||||
<div className={className}>
|
||||
<div className='container'>
|
||||
<div className={`pathInput ${isDisabled ? 'locked' : ''}`}>
|
||||
<InputWithLabel
|
||||
data-input-suri
|
||||
disabled={isDisabled}
|
||||
isError={isError || !path}
|
||||
label={
|
||||
isDisabled
|
||||
? t('Derivation Path (unlock to edit)')
|
||||
: t('Derivation Path')
|
||||
}
|
||||
onChange={_onChange}
|
||||
placeholder={withSoftPath
|
||||
? t('//hard/soft')
|
||||
: t('//hard')
|
||||
}
|
||||
value={path}
|
||||
/>
|
||||
</div>
|
||||
<Button
|
||||
className='lockButton'
|
||||
onClick={_onExpand}
|
||||
>
|
||||
<FontAwesomeIcon
|
||||
className='lockIcon'
|
||||
icon={isDisabled ? faLock : faLockOpen}
|
||||
/>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default React.memo(styled(DerivationPath)<Props>`
|
||||
> .container {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
}
|
||||
|
||||
.lockButton {
|
||||
background: none;
|
||||
height: 14px;
|
||||
margin: 36px 2px 0 10px;
|
||||
padding: 3px;
|
||||
width: 11px;
|
||||
|
||||
&:not(:disabled):hover {
|
||||
background: none;
|
||||
}
|
||||
|
||||
&:active, &:focus {
|
||||
outline: none;
|
||||
}
|
||||
|
||||
&::-moz-focus-inner {
|
||||
border: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.lockIcon {
|
||||
color: var(--iconNeutralColor)
|
||||
}
|
||||
|
||||
.pathInput {
|
||||
width: 100%;
|
||||
|
||||
&.locked input {
|
||||
opacity: 50%;
|
||||
}
|
||||
}
|
||||
`);
|
||||
@@ -1,331 +0,0 @@
|
||||
// Copyright 2019-2026 @polkadot/extension-ui authors & contributors
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
import '@polkadot/extension-mocks/chrome';
|
||||
|
||||
import type { ReactWrapper } from 'enzyme';
|
||||
import type * as _ from '@polkadot/dev-test/globals.d.ts';
|
||||
import type { AccountJson, ResponseDeriveValidate } from '@polkadot/extension-base/background/types';
|
||||
|
||||
import Adapter from '@wojtekmaj/enzyme-adapter-react-17';
|
||||
import enzyme from 'enzyme';
|
||||
import React from 'react';
|
||||
import { act } from 'react-dom/test-utils';
|
||||
import { MemoryRouter, Route } from 'react-router';
|
||||
|
||||
import { AccountContext, ActionContext } from '../../components/index.js';
|
||||
import * as messaging from '../../messaging.js';
|
||||
import { flushAllPromises } from '../../testHelpers.js';
|
||||
import { buildHierarchy } from '../../util/buildHierarchy.js';
|
||||
import AddressDropdown from './AddressDropdown.js';
|
||||
import Derive from './index.js';
|
||||
|
||||
const { configure, mount } = enzyme;
|
||||
|
||||
// // NOTE Required for spyOn when using @swc/jest
|
||||
// // https://github.com/swc-project/swc/issues/3843
|
||||
// jest.mock('../../messaging', (): Record<string, unknown> => ({
|
||||
// __esModule: true,
|
||||
// ...jest.requireActual('../../messaging')
|
||||
// }));
|
||||
|
||||
// For this file, there are a lot of them
|
||||
/* eslint-disable @typescript-eslint/no-unsafe-argument */
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment,@typescript-eslint/no-unsafe-call
|
||||
configure({ adapter: new Adapter() });
|
||||
|
||||
const parentPassword = 'pass';
|
||||
const westendGenesis = '0xe143f23803ac50e8f6f8e62695d1ce9e4e1d68aa36c1cd2cfd15340213f3423e';
|
||||
const defaultDerivation = '//0';
|
||||
const derivedAddress = '5GYQRJj3NUznYDzCduENRcocMsyxmb6tjb5xW87ZMErBe9R7';
|
||||
|
||||
const accounts = [
|
||||
{ address: '5FjgD3Ns2UpnHJPVeRViMhCttuemaRXEqaD8V5z4vxcsUByA', name: 'A', type: 'sr25519' },
|
||||
{ address: '5GYmFzQCuC5u3tQNiMZNbFGakrz3Jq31NmMg4D2QAkSoQ2g5', genesisHash: westendGenesis, name: 'B', type: 'sr25519' },
|
||||
{ address: '5D2TPhGEy2FhznvzaNYW9AkuMBbg3cyRemnPsBvBY4ZhkZXA', name: 'BB', parentAddress: '5GYmFzQCuC5u3tQNiMZNbFGakrz3Jq31NmMg4D2QAkSoQ2g5', type: 'sr25519' },
|
||||
{ address: '5GhGENSJBWQZ8d8mARKgqEkiAxiW3hHeznQDW2iG4XzNieb6', isExternal: true, name: 'C', type: 'sr25519' },
|
||||
{ address: '0xd5D81CD4236a43F48A983fc5B895975c511f634D', name: 'Ethereum', type: 'ethereum' },
|
||||
{ address: '5EeaoDj4VDk8V6yQngKBaCD5MpJUCHrhYjVhBjgMHXoYon1s', isExternal: false, name: 'D', type: 'ed25519' },
|
||||
{ address: '5HRKYp5anSNGtqC7cq9ftiaq4y8Mk7uHk7keaXUrQwZqDWLJ', name: 'DD', parentAddress: '5EeaoDj4VDk8V6yQngKBaCD5MpJUCHrhYjVhBjgMHXoYon1s', type: 'ed25519' }
|
||||
] as AccountJson[];
|
||||
|
||||
describe('Derive', () => {
|
||||
const mountComponent = async (locked = false, account = 1): Promise<{
|
||||
wrapper: ReactWrapper;
|
||||
onActionStub: ReturnType<typeof jest.fn>;
|
||||
}> => {
|
||||
const onActionStub = jest.fn();
|
||||
|
||||
const wrapper = mount(
|
||||
<MemoryRouter initialEntries={ [`/account/derive/${accounts[account].address}`] }>
|
||||
<ActionContext.Provider value={onActionStub}>
|
||||
<AccountContext.Provider
|
||||
value={{
|
||||
accounts,
|
||||
hierarchy: buildHierarchy(accounts)
|
||||
}}
|
||||
>
|
||||
<Route path='/account/derive/:address'>
|
||||
<Derive isLocked={locked} />
|
||||
</Route>
|
||||
</AccountContext.Provider>
|
||||
</ActionContext.Provider>
|
||||
</MemoryRouter>
|
||||
);
|
||||
|
||||
await act(flushAllPromises);
|
||||
|
||||
return { onActionStub, wrapper };
|
||||
};
|
||||
|
||||
let wrapper: ReactWrapper;
|
||||
let onActionStub: ReturnType<typeof jest.fn>;
|
||||
|
||||
const type = async (input: ReactWrapper, value: string): Promise<void> => {
|
||||
input.simulate('change', { target: { value } });
|
||||
await act(flushAllPromises);
|
||||
input.update();
|
||||
};
|
||||
|
||||
const enterName = (name: string): Promise<void> => type(wrapper.find('input').first(), name);
|
||||
const password = (password: string) => (): Promise<void> => type(wrapper.find('input[type="password"]').first(), password);
|
||||
const repeat = (password: string) => (): Promise<void> => type(wrapper.find('input[type="password"]').last(), password);
|
||||
|
||||
describe('Parent selection screen', () => {
|
||||
beforeEach(async () => {
|
||||
const mountedComponent = await mountComponent();
|
||||
|
||||
wrapper = mountedComponent.wrapper;
|
||||
onActionStub = mountedComponent.onActionStub;
|
||||
});
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/require-await
|
||||
jest.spyOn(messaging, 'validateAccount').mockImplementation(async (_, pass) => pass === parentPassword);
|
||||
// silencing the following expected console.error
|
||||
console.error = jest.fn();
|
||||
// eslint-disable-next-line @typescript-eslint/require-await
|
||||
jest.spyOn(messaging, 'validateDerivationPath').mockImplementation(async (_, path) => {
|
||||
if (path === '//') {
|
||||
throw new Error('wrong suri');
|
||||
}
|
||||
|
||||
return { address: derivedAddress, suri: defaultDerivation } as ResponseDeriveValidate;
|
||||
});
|
||||
|
||||
it('Button is disabled and password field visible, path field is hidden', () => {
|
||||
const button = wrapper.find('[data-button-action="create derived account"] button');
|
||||
|
||||
expect(button.exists()).toBe(true);
|
||||
expect(button.prop('disabled')).toBe(true);
|
||||
expect(wrapper.find('.pathInput').exists()).toBe(false);
|
||||
});
|
||||
|
||||
it('Password field is visible and not in error state', () => {
|
||||
const passwordField = wrapper.find('[data-input-password]').first();
|
||||
|
||||
expect(passwordField.exists()).toBe(true);
|
||||
expect(passwordField.prop('isError')).toBe(false);
|
||||
});
|
||||
|
||||
it('No error is visible when first loading the page', () => {
|
||||
expect(wrapper.find('Warning')).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('An error is visible, input higlighted and the button disabled when password is incorrect', async () => {
|
||||
await type(wrapper.find('input[type="password"]'), 'wrong_pass');
|
||||
wrapper.find('[data-button-action="create derived account"] button').simulate('click');
|
||||
await act(flushAllPromises);
|
||||
wrapper.update();
|
||||
|
||||
const button = wrapper.find('[data-button-action="create derived account"] button');
|
||||
|
||||
expect(button.prop('disabled')).toBe(true);
|
||||
expect(wrapper.find('[data-input-password]').first().prop('isError')).toBe(true);
|
||||
expect(wrapper.find('.warning-message')).toHaveLength(1);
|
||||
expect(wrapper.find('.warning-message').first().text()).toEqual('Wrong password');
|
||||
});
|
||||
|
||||
it('The error disappears when typing a new password and "Create derived account" is enabled', async () => {
|
||||
await type(wrapper.find('input[type="password"]'), 'wrong_pass');
|
||||
wrapper.find('[data-button-action="create derived account"] button').simulate('click');
|
||||
await act(flushAllPromises);
|
||||
wrapper.update();
|
||||
|
||||
await type(wrapper.find('input[type="password"]'), 'new_attempt');
|
||||
|
||||
const button = wrapper.find('[data-button-action="create derived account"] button');
|
||||
|
||||
expect(button.prop('disabled')).toBe(false);
|
||||
expect(wrapper.find('[data-input-password]').first().prop('isError')).toBe(false);
|
||||
expect(wrapper.find('.warning-message')).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('Button is enabled when password is set', async () => {
|
||||
await type(wrapper.find('input[type="password"]'), parentPassword);
|
||||
|
||||
const button = wrapper.find('[data-button-action="create derived account"] button');
|
||||
|
||||
expect(button.prop('disabled')).toBe(false);
|
||||
expect(wrapper.find('.warning-message')).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('Derivation path gets visible, is set and locked', async () => {
|
||||
await type(wrapper.find('input[type="password"]'), 'wrong_pass');
|
||||
|
||||
expect(wrapper.find('.pathInput.locked input').prop('disabled')).toBe(true);
|
||||
expect(wrapper.find('.pathInput.locked input').prop('value')).toBe('//1');
|
||||
});
|
||||
|
||||
it('Derivation path can be unlocked', async () => {
|
||||
await type(wrapper.find('input[type="password"]'), 'wrong_pass');
|
||||
wrapper.find('FontAwesomeIcon.lockIcon').simulate('click');
|
||||
await act(flushAllPromises);
|
||||
wrapper.update();
|
||||
|
||||
expect(wrapper.find('.pathInput').exists()).toBe(true);
|
||||
expect(wrapper.find('.pathInput input').prop('disabled')).toBe(false);
|
||||
});
|
||||
|
||||
it('Derivation path placeholder contains //hard/soft', async () => {
|
||||
await type(wrapper.find('input[type="password"]'), parentPassword);
|
||||
const pathInput = wrapper.find('[data-input-suri] input');
|
||||
|
||||
expect(pathInput.first().prop('placeholder')).toEqual('//hard/soft');
|
||||
});
|
||||
|
||||
it('An error is visible and the button is disabled when suri is incorrect', async () => {
|
||||
await type(wrapper.find('input[type="password"]'), parentPassword);
|
||||
await type(wrapper.find('[data-input-suri] input'), '//');
|
||||
wrapper.find('[data-button-action="create derived account"] button').simulate('click');
|
||||
await act(flushAllPromises);
|
||||
wrapper.update();
|
||||
|
||||
const button = wrapper.find('[data-button-action="create derived account"] button');
|
||||
|
||||
expect(button.prop('disabled')).toBe(true);
|
||||
expect(wrapper.find('.warning-message')).toHaveLength(1);
|
||||
expect(wrapper.find('.warning-message').first().text()).toEqual('Invalid derivation path');
|
||||
});
|
||||
|
||||
it('An error is visible and the button is disabled when suri contains `///`', async () => {
|
||||
await type(wrapper.find('input[type="password"]'), parentPassword);
|
||||
await type(wrapper.find('[data-input-suri] input'), '///');
|
||||
|
||||
const button = wrapper.find('[data-button-action="create derived account"] button');
|
||||
|
||||
expect(button.prop('disabled')).toBe(true);
|
||||
expect(wrapper.find('.warning-message')).toHaveLength(1);
|
||||
// eslint-disable-next-line quotes
|
||||
expect(wrapper.find('.warning-message').first().text()).toEqual("`///password` not supported for derivation");
|
||||
});
|
||||
|
||||
it('No error is shown when suri contains soft derivation `/` with sr25519', async () => {
|
||||
await type(wrapper.find('input[type="password"]'), parentPassword);
|
||||
await type(wrapper.find('[data-input-suri] input'), '//somehard/soft');
|
||||
|
||||
const button = wrapper.find('[data-button-action="create derived account"] button');
|
||||
|
||||
expect(button.prop('disabled')).toBe(false);
|
||||
expect(wrapper.find('.warning-message')).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('The error disappears and "Create derived account" is enabled when typing a new suri', async () => {
|
||||
await type(wrapper.find('input[type="password"]'), parentPassword);
|
||||
await type(wrapper.find('[data-input-suri] input'), '//');
|
||||
wrapper.find('[data-button-action="create derived account"] button').simulate('click');
|
||||
await act(flushAllPromises);
|
||||
wrapper.update();
|
||||
await type(wrapper.find('[data-input-suri] input'), 'new');
|
||||
|
||||
const button = wrapper.find('[data-button-action="create derived account"] button');
|
||||
|
||||
expect(button.prop('disabled')).toBe(false);
|
||||
expect(wrapper.find('Warning')).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('takes selected address from URL as parent account', () => {
|
||||
expect(wrapper.find('[data-field="name"]').first().text()).toBe('B');
|
||||
});
|
||||
|
||||
it('selects internal root accounts as other options, no external and no Ethereum account', () => {
|
||||
const options = wrapper.find('[data-parent-option] [data-field="name"]').map((el) => el.text());
|
||||
|
||||
expect(options).toEqual(['A', 'B', 'D', 'Ethereum']);
|
||||
});
|
||||
|
||||
it('redirects to derive from next account when other option is selected', () => {
|
||||
wrapper.find('[data-parent-option]').first().simulate('click');
|
||||
|
||||
expect(onActionStub).toHaveBeenCalledWith(`/account/derive/${accounts[0].address}`);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Locked parent selection', () => {
|
||||
beforeAll(async () => {
|
||||
const mountedComponent = (await mountComponent(true));
|
||||
|
||||
wrapper = mountedComponent.wrapper;
|
||||
onActionStub = mountedComponent.onActionStub;
|
||||
});
|
||||
|
||||
it('address dropdown does not exist', () => {
|
||||
expect(wrapper.exists(AddressDropdown)).toBe(false);
|
||||
});
|
||||
|
||||
it('parent is taken from URL', () => {
|
||||
expect(wrapper.find('[data-field="name"]').first().text()).toBe('B');
|
||||
});
|
||||
|
||||
describe('Second phase', () => {
|
||||
it('correctly creates the derived account', async () => {
|
||||
const newAccount = {
|
||||
name: 'newName',
|
||||
password: 'somePassword'
|
||||
};
|
||||
const deriveMock = jest.spyOn(messaging, 'deriveAccount');
|
||||
|
||||
await type(wrapper.find('input[type="password"]'), parentPassword);
|
||||
wrapper.find('[data-button-action="create derived account"] button').simulate('click');
|
||||
await act(flushAllPromises);
|
||||
wrapper.update();
|
||||
await enterName(newAccount.name).then(password(newAccount.password)).then(repeat(newAccount.password));
|
||||
wrapper.find('[data-button-action="add new root"] button').simulate('click');
|
||||
await act(flushAllPromises);
|
||||
wrapper.update();
|
||||
|
||||
expect(deriveMock).toHaveBeenCalledWith(accounts[1].address, defaultDerivation, parentPassword, newAccount.name, newAccount.password, westendGenesis);
|
||||
expect(onActionStub).toHaveBeenCalledWith('/');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('Ed25519 Parent', () => {
|
||||
beforeEach(async () => {
|
||||
const mountedComponent = await mountComponent(false, 5);
|
||||
|
||||
wrapper = mountedComponent.wrapper;
|
||||
onActionStub = mountedComponent.onActionStub;
|
||||
await type(wrapper.find('input[type="password"]'), parentPassword);
|
||||
});
|
||||
|
||||
it('Derivation path placeholder only contains //hard', () => {
|
||||
const pathInput = wrapper.find('[data-input-suri] input');
|
||||
|
||||
expect(pathInput.first().prop('placeholder')).toEqual('//hard');
|
||||
});
|
||||
|
||||
it('An error is shown when suri contains soft derivation `/` with ed25519', async () => {
|
||||
const pathInput = wrapper.find('[data-input-suri] input');
|
||||
|
||||
await type(pathInput, '//somehard/soft');
|
||||
|
||||
const button = wrapper.find('[data-button-action="create derived account"] button');
|
||||
|
||||
expect(button.prop('disabled')).toBe(true);
|
||||
expect(wrapper.find('[data-input-suri]').first().prop('isError')).toBe(true);
|
||||
expect(wrapper.find('.warning-message')).toHaveLength(1);
|
||||
expect(wrapper.find('.warning-message').first().text()).toEqual('Soft derivation is only allowed for sr25519 accounts');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,195 +0,0 @@
|
||||
// Copyright 2019-2026 @polkadot/extension-ui authors & contributors
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
import React, { useCallback, useContext, useEffect, useMemo, useRef, useState } from 'react';
|
||||
|
||||
import { canDerive } from '@polkadot/extension-base/utils';
|
||||
|
||||
import { AccountContext, ActionContext, Address, ButtonArea, InputWithLabel, Label, NextStepButton, VerticalSpace, Warning } from '../../components/index.js';
|
||||
import { useTranslation } from '../../hooks/index.js';
|
||||
import { validateAccount, validateDerivationPath } from '../../messaging.js';
|
||||
import { nextDerivationPath } from '../../util/nextDerivationPath.js';
|
||||
import AddressDropdown from './AddressDropdown.js';
|
||||
import DerivationPath from './DerivationPath.js';
|
||||
|
||||
interface Props {
|
||||
className?: string;
|
||||
isLocked?: boolean;
|
||||
parentAddress: string;
|
||||
parentGenesis: string | null;
|
||||
onDerivationConfirmed: (derivation: { account: { address: string; suri: string }; parentPassword: string }) => void;
|
||||
}
|
||||
|
||||
// match any single slash
|
||||
const singleSlashRegex = /([^/]|^)\/([^/]|$)/;
|
||||
|
||||
export default function SelectParent ({ className, isLocked, onDerivationConfirmed, parentAddress, parentGenesis }: Props): React.ReactElement<Props> {
|
||||
const { t } = useTranslation();
|
||||
const onAction = useContext(ActionContext);
|
||||
const [isBusy, setIsBusy] = useState(false);
|
||||
const { accounts, hierarchy } = useContext(AccountContext);
|
||||
const defaultPath = useMemo(() => nextDerivationPath(accounts, parentAddress), [accounts, parentAddress]);
|
||||
const [suriPath, setSuriPath] = useState<null | string>(defaultPath);
|
||||
const [parentPassword, setParentPassword] = useState<string>('');
|
||||
const [isProperParentPassword, setIsProperParentPassword] = useState(false);
|
||||
const [pathError, setPathError] = useState('');
|
||||
const passwordInputRef = useRef<HTMLDivElement>(null);
|
||||
const allowSoftDerivation = useMemo(() => {
|
||||
const parent = accounts.find(({ address }) => address === parentAddress);
|
||||
|
||||
return parent?.type === 'sr25519';
|
||||
}, [accounts, parentAddress]);
|
||||
|
||||
// reset the password field if the parent address changes
|
||||
useEffect(() => {
|
||||
setParentPassword('');
|
||||
}, [parentAddress]);
|
||||
|
||||
useEffect(() => {
|
||||
// forbid the use of password since Keyring ignores it
|
||||
if (suriPath?.includes('///')) {
|
||||
setPathError(t('`///password` not supported for derivation'));
|
||||
}
|
||||
|
||||
if (!allowSoftDerivation && suriPath && singleSlashRegex.test(suriPath)) {
|
||||
setPathError(t('Soft derivation is only allowed for sr25519 accounts'));
|
||||
}
|
||||
}, [allowSoftDerivation, suriPath, t]);
|
||||
|
||||
const allAddresses = useMemo(
|
||||
() => hierarchy
|
||||
.filter(({ isExternal }) => !isExternal)
|
||||
.filter(({ type }) => canDerive(type))
|
||||
.map(({ address, genesisHash }): [string, string | null] => [address, genesisHash || null]),
|
||||
[hierarchy]
|
||||
);
|
||||
|
||||
const _onParentPasswordEnter = useCallback(
|
||||
(parentPassword: string): void => {
|
||||
setParentPassword(parentPassword);
|
||||
setIsProperParentPassword(!!parentPassword);
|
||||
},
|
||||
[]
|
||||
);
|
||||
|
||||
const _onSuriPathChange = useCallback(
|
||||
(path: string): void => {
|
||||
setSuriPath(path);
|
||||
setPathError('');
|
||||
},
|
||||
[]
|
||||
);
|
||||
|
||||
const _onParentChange = useCallback(
|
||||
(address: string) => onAction(`/account/derive/${address}`),
|
||||
[onAction]
|
||||
);
|
||||
|
||||
const _onSubmit = useCallback(
|
||||
async (): Promise<void> => {
|
||||
if (suriPath && parentAddress && parentPassword) {
|
||||
setIsBusy(true);
|
||||
|
||||
const isUnlockable = await validateAccount(parentAddress, parentPassword);
|
||||
|
||||
if (isUnlockable) {
|
||||
try {
|
||||
const account = await validateDerivationPath(parentAddress, suriPath, parentPassword);
|
||||
|
||||
onDerivationConfirmed({ account, parentPassword });
|
||||
} catch (error) {
|
||||
setIsBusy(false);
|
||||
setPathError(t('Invalid derivation path'));
|
||||
console.error(error);
|
||||
}
|
||||
} else {
|
||||
setIsBusy(false);
|
||||
setIsProperParentPassword(false);
|
||||
}
|
||||
}
|
||||
},
|
||||
[parentAddress, parentPassword, onDerivationConfirmed, suriPath, t]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
setParentPassword('');
|
||||
setIsProperParentPassword(false);
|
||||
|
||||
passwordInputRef.current?.querySelector('input')?.focus();
|
||||
}, [_onParentPasswordEnter]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className={className}>
|
||||
{isLocked
|
||||
? (
|
||||
<Address
|
||||
address={parentAddress}
|
||||
genesisHash={parentGenesis}
|
||||
/>
|
||||
)
|
||||
: (
|
||||
<Label label={t('Choose Parent Account:')}>
|
||||
<AddressDropdown
|
||||
allAddresses={allAddresses}
|
||||
onSelect={_onParentChange}
|
||||
selectedAddress={parentAddress}
|
||||
selectedGenesis={parentGenesis}
|
||||
/>
|
||||
</Label>
|
||||
)
|
||||
}
|
||||
<div ref={passwordInputRef}>
|
||||
<InputWithLabel
|
||||
data-input-password
|
||||
isError={!!parentPassword && !isProperParentPassword}
|
||||
isFocused
|
||||
label={t('enter the password for the account you want to derive from')}
|
||||
onChange={_onParentPasswordEnter}
|
||||
type='password'
|
||||
value={parentPassword}
|
||||
/>
|
||||
{!!parentPassword && !isProperParentPassword && (
|
||||
<Warning
|
||||
isBelowInput
|
||||
isDanger
|
||||
>
|
||||
{t('Wrong password')}
|
||||
</Warning>
|
||||
)}
|
||||
</div>
|
||||
{isProperParentPassword && (
|
||||
<>
|
||||
<DerivationPath
|
||||
defaultPath={defaultPath}
|
||||
isError={!!pathError}
|
||||
onChange={_onSuriPathChange}
|
||||
parentAddress={parentAddress}
|
||||
parentPassword={parentPassword}
|
||||
withSoftPath={allowSoftDerivation}
|
||||
/>
|
||||
{(!!pathError) && (
|
||||
<Warning
|
||||
isBelowInput
|
||||
isDanger
|
||||
>
|
||||
{pathError}
|
||||
</Warning>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<VerticalSpace />
|
||||
<ButtonArea>
|
||||
<NextStepButton
|
||||
data-button-action='create derived account'
|
||||
isBusy={isBusy}
|
||||
isDisabled={!isProperParentPassword || !!pathError}
|
||||
onClick={_onSubmit}
|
||||
>
|
||||
{t('Create a derived account')}
|
||||
</NextStepButton>
|
||||
</ButtonArea>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,104 +0,0 @@
|
||||
// Copyright 2019-2026 @polkadot/extension-ui authors & contributors
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
import React, { useCallback, useContext, useMemo, useState } from 'react';
|
||||
import { useParams } from 'react-router';
|
||||
|
||||
import { AccountContext, AccountNamePasswordCreation, ActionContext, Address } from '../../components/index.js';
|
||||
import { useTranslation } from '../../hooks/index.js';
|
||||
import { deriveAccount } from '../../messaging.js';
|
||||
import { HeaderWithSteps } from '../../partials/index.js';
|
||||
import SelectParent from './SelectParent.js';
|
||||
|
||||
interface Props {
|
||||
isLocked?: boolean;
|
||||
}
|
||||
|
||||
interface AddressState {
|
||||
address: string;
|
||||
}
|
||||
|
||||
interface PathState extends AddressState {
|
||||
suri: string;
|
||||
}
|
||||
|
||||
interface ConfirmState {
|
||||
account: PathState;
|
||||
parentPassword: string;
|
||||
}
|
||||
|
||||
function Derive ({ isLocked }: Props): React.ReactElement<Props> {
|
||||
const { t } = useTranslation();
|
||||
const onAction = useContext(ActionContext);
|
||||
const { accounts } = useContext(AccountContext);
|
||||
const { address: parentAddress } = useParams<AddressState>();
|
||||
const [isBusy, setIsBusy] = useState(false);
|
||||
const [account, setAccount] = useState<null | PathState>(null);
|
||||
const [name, setName] = useState<string | null>(null);
|
||||
const [parentPassword, setParentPassword] = useState<string | null>(null);
|
||||
|
||||
const parentGenesis = useMemo(
|
||||
() => accounts.find((a) => a.address === parentAddress)?.genesisHash || null,
|
||||
[accounts, parentAddress]
|
||||
);
|
||||
|
||||
const _onCreate = useCallback((name: string, password: string) => {
|
||||
if (!account || !name || !password || !parentPassword) {
|
||||
return;
|
||||
}
|
||||
|
||||
setIsBusy(true);
|
||||
deriveAccount(parentAddress, account.suri, parentPassword, name, password, parentGenesis)
|
||||
.then(() => onAction('/'))
|
||||
.catch((error): void => {
|
||||
setIsBusy(false);
|
||||
console.error(error);
|
||||
});
|
||||
}, [account, onAction, parentAddress, parentGenesis, parentPassword]);
|
||||
|
||||
const _onDerivationConfirmed = useCallback(({ account, parentPassword }: ConfirmState) => {
|
||||
setAccount(account);
|
||||
setParentPassword(parentPassword);
|
||||
}, []);
|
||||
|
||||
const _onBackClick = useCallback(() => {
|
||||
setAccount(null);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<>
|
||||
<HeaderWithSteps
|
||||
step={account ? 2 : 1}
|
||||
text={t('Add new account')}
|
||||
/>
|
||||
{!account && (
|
||||
<SelectParent
|
||||
isLocked={isLocked}
|
||||
onDerivationConfirmed={_onDerivationConfirmed}
|
||||
parentAddress={parentAddress}
|
||||
parentGenesis={parentGenesis}
|
||||
/>
|
||||
)}
|
||||
{account && (
|
||||
<>
|
||||
<div>
|
||||
<Address
|
||||
address={account.address}
|
||||
genesisHash={parentGenesis}
|
||||
name={name}
|
||||
/>
|
||||
</div>
|
||||
<AccountNamePasswordCreation
|
||||
buttonLabel={t('Create derived account')}
|
||||
isBusy={isBusy}
|
||||
onBackClick={_onBackClick}
|
||||
onCreate={_onCreate}
|
||||
onNameChange={setName}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export default React.memo(Derive);
|
||||
@@ -1,69 +1,61 @@
|
||||
// Copyright 2019-2026 @polkadot/extension-ui authors & contributors
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
import type { KeypairType } from '@polkadot/util-crypto/types';
|
||||
import type { AccountInfo } from './index.js';
|
||||
import type { HexString } from '@polkadot/util/types';
|
||||
import type { WalletSecret } from './index.js';
|
||||
|
||||
import { faCaretDown, faCaretRight } from '@fortawesome/free-solid-svg-icons';
|
||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
||||
import React, { useCallback, useEffect, useState } from 'react';
|
||||
import React, { useEffect, useState } from 'react';
|
||||
|
||||
import { validateSeed } from '@polkadot/extension-ui/messaging';
|
||||
import { objectSpread } from '@polkadot/util';
|
||||
|
||||
import { ButtonArea, Dropdown, InputWithLabel, NextStepButton, TextAreaWithLabel, VerticalSpace, Warning } from '../../components/index.js';
|
||||
import { ButtonArea, Dropdown, NextStepButton, TextAreaWithLabel, VerticalSpace, Warning } from '../../components/index.js';
|
||||
import { useGenesisHashOptions, useTranslation } from '../../hooks/index.js';
|
||||
import { previewWallet } from '../../messaging.js';
|
||||
import { styled } from '../../styled.js';
|
||||
|
||||
interface Props {
|
||||
className?: string;
|
||||
onNextStep: () => void;
|
||||
onAccountChange: (account: AccountInfo | null) => void;
|
||||
type: KeypairType;
|
||||
onWalletChange: (wallet: WalletSecret | null) => void;
|
||||
}
|
||||
|
||||
function SeedAndPath ({ className, onAccountChange, onNextStep, type }: Props): React.ReactElement {
|
||||
function SeedAndPath ({ className, onNextStep, onWalletChange }: Props): React.ReactElement {
|
||||
const { t } = useTranslation();
|
||||
const genesisOptions = useGenesisHashOptions();
|
||||
const [address, setAddress] = useState('');
|
||||
const [seed, setSeed] = useState<string | null>(null);
|
||||
const [path, setPath] = useState<string | null>(null);
|
||||
const [advanced, setAdvances] = useState(false);
|
||||
const [secret, setSecret] = useState('');
|
||||
const [error, setError] = useState('');
|
||||
const [genesis, setGenesis] = useState('');
|
||||
const [genesis, setGenesis] = useState<string>('');
|
||||
const [isValid, setIsValid] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
// No need to validate an empty seed
|
||||
// we have a dedicated error for this
|
||||
if (!seed) {
|
||||
onAccountChange(null);
|
||||
if (!secret.trim()) {
|
||||
setIsValid(false);
|
||||
setError('');
|
||||
onWalletChange(null);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
const suri = `${seed || ''}${path || ''}`;
|
||||
let isCurrent = true;
|
||||
|
||||
validateSeed(suri, type)
|
||||
.then((validatedAccount) => {
|
||||
setError('');
|
||||
setAddress(validatedAccount.address);
|
||||
onAccountChange(
|
||||
objectSpread<AccountInfo>({}, validatedAccount, { genesis, type })
|
||||
);
|
||||
previewWallet(secret)
|
||||
.then((preview) => {
|
||||
if (isCurrent) {
|
||||
setError('');
|
||||
setIsValid(true);
|
||||
onWalletChange({ genesis: (genesis || null) as HexString | null, preview, secret });
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
setAddress('');
|
||||
onAccountChange(null);
|
||||
setError(path
|
||||
? t('Invalid mnemonic seed or derivation path')
|
||||
: t('Invalid mnemonic seed')
|
||||
);
|
||||
.catch((error: Error) => {
|
||||
if (isCurrent) {
|
||||
setIsValid(false);
|
||||
setError(error.message);
|
||||
onWalletChange(null);
|
||||
}
|
||||
});
|
||||
}, [t, genesis, seed, path, onAccountChange, type]);
|
||||
|
||||
const _onToggleAdvanced = useCallback(() => {
|
||||
setAdvances(!advanced);
|
||||
}, [advanced]);
|
||||
return () => {
|
||||
isCurrent = false;
|
||||
};
|
||||
}, [genesis, onWalletChange, secret]);
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -72,18 +64,18 @@ function SeedAndPath ({ className, onAccountChange, onNextStep, type }: Props):
|
||||
className='seedInput'
|
||||
isError={!!error}
|
||||
isFocused
|
||||
label={t('existing 12 or 24-word mnemonic seed')}
|
||||
onChange={setSeed}
|
||||
label={t('recovery phrase, or a 0x-prefixed 32-byte seed')}
|
||||
onChange={setSecret}
|
||||
rowsCount={2}
|
||||
value={seed || ''}
|
||||
value={secret}
|
||||
/>
|
||||
{!!error && !seed && (
|
||||
{!!error && (
|
||||
<Warning
|
||||
className='seedError'
|
||||
isBelowInput
|
||||
isDanger
|
||||
>
|
||||
{t('Mnemonic needs to contain 12, 15, 18, 21, 24 words')}
|
||||
{error}
|
||||
</Warning>
|
||||
)}
|
||||
<Dropdown
|
||||
@@ -93,34 +85,11 @@ function SeedAndPath ({ className, onAccountChange, onNextStep, type }: Props):
|
||||
options={genesisOptions}
|
||||
value={genesis}
|
||||
/>
|
||||
<div
|
||||
className='advancedToggle'
|
||||
onClick={_onToggleAdvanced}
|
||||
>
|
||||
<FontAwesomeIcon icon={advanced ? faCaretDown : faCaretRight} />
|
||||
<span>{t('advanced')}</span>
|
||||
</div>
|
||||
{ advanced && (
|
||||
<InputWithLabel
|
||||
className='derivationPath'
|
||||
isError={!!path && !!error}
|
||||
label={t('derivation path')}
|
||||
onChange={setPath}
|
||||
value={path || ''}
|
||||
/>
|
||||
)}
|
||||
{!!error && !!seed && (
|
||||
<Warning
|
||||
isDanger
|
||||
>
|
||||
{error}
|
||||
</Warning>
|
||||
)}
|
||||
</div>
|
||||
<VerticalSpace />
|
||||
<ButtonArea>
|
||||
<NextStepButton
|
||||
isDisabled={!address || !!error}
|
||||
isDisabled={!isValid}
|
||||
onClick={onNextStep}
|
||||
>
|
||||
{t('Next')}
|
||||
@@ -131,21 +100,6 @@ function SeedAndPath ({ className, onAccountChange, onNextStep, type }: Props):
|
||||
}
|
||||
|
||||
export default styled(SeedAndPath)<Props>`
|
||||
.advancedToggle {
|
||||
color: var(--textColor);
|
||||
cursor: pointer;
|
||||
line-height: var(--lineHeight);
|
||||
letter-spacing: 0.04em;
|
||||
opacity: 0.65;
|
||||
text-transform: uppercase;
|
||||
|
||||
> span {
|
||||
font-size: var(--inputLabelFontSize);
|
||||
margin-left: .5rem;
|
||||
vertical-align: middle;
|
||||
}
|
||||
}
|
||||
|
||||
.genesisSelection {
|
||||
margin-bottom: var(--fontSize);
|
||||
}
|
||||
|
||||
@@ -1,95 +1,84 @@
|
||||
// Copyright 2019-2026 @polkadot/extension-ui authors & contributors
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
import type { ResponseWalletPreview } from '@polkadot/extension-base/background/types';
|
||||
import type { HexString } from '@polkadot/util/types';
|
||||
|
||||
import React, { useCallback, useContext, useEffect, useState } from 'react';
|
||||
|
||||
import AccountNamePasswordCreation from '../../components/AccountNamePasswordCreation.js';
|
||||
import { AccountContext, ActionContext, Address } from '../../components/index.js';
|
||||
import { useMetadata, useTranslation } from '../../hooks/index.js';
|
||||
import { createAccountSuri } from '../../messaging.js';
|
||||
import { AccountContext, ActionContext, WalletPreview } from '../../components/index.js';
|
||||
import { useTranslation } from '../../hooks/index.js';
|
||||
import { createWallet } from '../../messaging.js';
|
||||
import { HeaderWithSteps } from '../../partials/index.js';
|
||||
import { DEFAULT_TYPE } from '../../util/defaultType.js';
|
||||
import SeedAndPath from './SeedAndPath.js';
|
||||
|
||||
export interface AccountInfo {
|
||||
address: string;
|
||||
genesis?: HexString;
|
||||
suri: string;
|
||||
/** A secret that previewed cleanly, and the network the user tied it to. */
|
||||
export interface WalletSecret {
|
||||
genesis: HexString | null;
|
||||
preview: ResponseWalletPreview;
|
||||
secret: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Import a wallet from a recovery phrase or a raw seed.
|
||||
*
|
||||
* No key-type choice and no derivation path: the secret becomes one wallet, and
|
||||
* its ML-DSA-65, ML-DSA-87 and wormhole accounts are all derived and shown. More
|
||||
* account indices are added from the wallet's own menu. See quantus/extension#14.
|
||||
*/
|
||||
function ImportSeed (): React.ReactElement {
|
||||
const { t } = useTranslation();
|
||||
const { accounts } = useContext(AccountContext);
|
||||
const onAction = useContext(ActionContext);
|
||||
const [isBusy, setIsBusy] = useState(false);
|
||||
const [account, setAccount] = useState<AccountInfo | null>(null);
|
||||
const [wallet, setWallet] = useState<WalletSecret | null>(null);
|
||||
const [name, setName] = useState<string | null>(null);
|
||||
const [error, setError] = useState('');
|
||||
const [step1, setStep1] = useState(true);
|
||||
const [type, setType] = useState(DEFAULT_TYPE);
|
||||
const chain = useMetadata(account?.genesis, true);
|
||||
|
||||
useEffect((): void => {
|
||||
!accounts.length && onAction();
|
||||
}, [accounts, onAction]);
|
||||
|
||||
useEffect((): void => {
|
||||
setType(
|
||||
chain && chain.definition.chainType === 'ethereum'
|
||||
? 'ethereum'
|
||||
: DEFAULT_TYPE
|
||||
);
|
||||
}, [chain]);
|
||||
|
||||
const _onCreate = useCallback((name: string, password: string): void => {
|
||||
// this should always be the case
|
||||
if (name && password && account) {
|
||||
if (name && password && wallet) {
|
||||
setIsBusy(true);
|
||||
setError('');
|
||||
|
||||
createAccountSuri(name, password, account.suri, type, account.genesis)
|
||||
createWallet(name, password, wallet.secret, wallet.genesis)
|
||||
.then(() => onAction('/'))
|
||||
.catch((error): void => {
|
||||
.catch((error: Error): void => {
|
||||
setIsBusy(false);
|
||||
console.error(error);
|
||||
setError(error.message);
|
||||
});
|
||||
}
|
||||
}, [account, onAction, type]);
|
||||
}, [onAction, wallet]);
|
||||
|
||||
const _onNextStep = useCallback(
|
||||
() => setStep1(false),
|
||||
[]
|
||||
);
|
||||
|
||||
const _onBackClick = useCallback(
|
||||
() => setStep1(true),
|
||||
[]
|
||||
);
|
||||
const _onNextStep = useCallback(() => setStep1(false), []);
|
||||
const _onBackClick = useCallback(() => setStep1(true), []);
|
||||
|
||||
return (
|
||||
<>
|
||||
<HeaderWithSteps
|
||||
step={step1 ? 1 : 2}
|
||||
text={t('Import account')}
|
||||
text={t('Import wallet')}
|
||||
/>
|
||||
<WalletPreview
|
||||
name={name}
|
||||
preview={wallet?.preview ?? null}
|
||||
/>
|
||||
<div>
|
||||
<Address
|
||||
address={account?.address}
|
||||
genesisHash={account?.genesis}
|
||||
name={name}
|
||||
/>
|
||||
</div>
|
||||
{step1
|
||||
? (
|
||||
<SeedAndPath
|
||||
onAccountChange={setAccount}
|
||||
onNextStep={_onNextStep}
|
||||
type={type}
|
||||
onWalletChange={setWallet}
|
||||
/>
|
||||
)
|
||||
: (
|
||||
<AccountNamePasswordCreation
|
||||
buttonLabel={t('Add the account with the supplied seed')}
|
||||
buttonLabel={t('Add the wallet')}
|
||||
error={error}
|
||||
isBusy={isBusy}
|
||||
onBackClick={_onBackClick}
|
||||
onCreate={_onCreate}
|
||||
|
||||
@@ -27,7 +27,7 @@ function PhishingDetected ({ className }: Props): React.ReactElement<Props> {
|
||||
<Header text={t('Phishing detected')} />
|
||||
<div className={className}>
|
||||
<p>
|
||||
{t('You have been redirected because the Polkadot{.js} extension believes that this website could compromise the security of your accounts and your tokens.')}
|
||||
{t('You have been redirected because blackbeard believes that this website could compromise the security of your accounts and your tokens.')}
|
||||
</p>
|
||||
<p className='websiteAddress'>
|
||||
{decodedWebsite}
|
||||
|
||||
@@ -23,6 +23,12 @@ interface Props {
|
||||
className?: string;
|
||||
}
|
||||
|
||||
function unsafeKeyMessage (e: Error): string | null {
|
||||
return /not quantum-safe/.test(e.message)
|
||||
? e.message
|
||||
: null;
|
||||
}
|
||||
|
||||
function Upload ({ className }: Props): React.ReactElement {
|
||||
const { t } = useTranslation();
|
||||
const { accounts } = useContext(AccountContext);
|
||||
@@ -30,7 +36,10 @@ function Upload ({ className }: Props): React.ReactElement {
|
||||
const [isBusy, setIsBusy] = useState(false);
|
||||
const [accountsInfo, setAccountsInfo] = useState<ResponseJsonGetAccountInfo[]>([]);
|
||||
const [password, setPassword] = useState<string>('');
|
||||
const [isFileError, setFileError] = useState(false);
|
||||
// The reason a file can't be restored, when there is one worth showing: a
|
||||
// backup of a classical key is well-formed, just not something this
|
||||
// extension will hold (quantus/common#6), and saying "invalid" would hide that.
|
||||
const [fileError, setFileError] = useState<string | null>(null);
|
||||
const [requirePassword, setRequirePassword] = useState(false);
|
||||
const [isPasswordError, setIsPasswordError] = useState(false);
|
||||
// don't use the info from the file directly
|
||||
@@ -51,6 +60,7 @@ function Upload ({ className }: Props): React.ReactElement {
|
||||
const _onChangeFile = useCallback(
|
||||
(file: Uint8Array): void => {
|
||||
setAccountsInfo(() => []);
|
||||
setFileError(null);
|
||||
|
||||
let json: KeyringPair$Json | KeyringPairs$Json | undefined;
|
||||
|
||||
@@ -59,7 +69,7 @@ function Upload ({ className }: Props): React.ReactElement {
|
||||
setFile(json);
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
setFileError(true);
|
||||
setFileError(t('Invalid Json file'));
|
||||
}
|
||||
|
||||
if (json === undefined) {
|
||||
@@ -79,12 +89,12 @@ function Upload ({ className }: Props): React.ReactElement {
|
||||
setRequirePassword(true);
|
||||
jsonGetAccountInfo(json)
|
||||
.then((accountInfo) => setAccountsInfo((old) => [...old, accountInfo]))
|
||||
.catch((e) => {
|
||||
setFileError(true);
|
||||
.catch((e: Error) => {
|
||||
setFileError(unsafeKeyMessage(e) || t('Invalid Json file'));
|
||||
console.error(e);
|
||||
});
|
||||
}
|
||||
}, []
|
||||
}, [t]
|
||||
);
|
||||
|
||||
const _onRestore = useCallback(
|
||||
@@ -103,10 +113,17 @@ function Upload ({ className }: Props): React.ReactElement {
|
||||
.then(() => {
|
||||
onAction('/');
|
||||
})
|
||||
.catch((e) => {
|
||||
.catch((e: Error) => {
|
||||
console.error(e);
|
||||
setIsBusy(false);
|
||||
setIsPasswordError(true);
|
||||
|
||||
// A batch file is only opened on restore, so this is where one
|
||||
// holding a classical key is refused.
|
||||
const unsafe = unsafeKeyMessage(e);
|
||||
|
||||
unsafe
|
||||
? setFileError(unsafe)
|
||||
: setIsPasswordError(true);
|
||||
});
|
||||
},
|
||||
[file, onAction, password, requirePassword]
|
||||
@@ -131,16 +148,16 @@ function Upload ({ className }: Props): React.ReactElement {
|
||||
))}
|
||||
<InputFileWithLabel
|
||||
accept={acceptedFormats}
|
||||
isError={isFileError}
|
||||
isError={!!fileError}
|
||||
label={t('backup file')}
|
||||
onChange={_onChangeFile}
|
||||
withLabel
|
||||
/>
|
||||
{isFileError && (
|
||||
{fileError && (
|
||||
<Warning
|
||||
isDanger
|
||||
>
|
||||
{t('Invalid Json file')}
|
||||
{fileError}
|
||||
</Warning>
|
||||
)}
|
||||
{requirePassword && (
|
||||
@@ -164,7 +181,7 @@ function Upload ({ className }: Props): React.ReactElement {
|
||||
<Button
|
||||
className='restoreButton'
|
||||
isBusy={isBusy}
|
||||
isDisabled={isFileError || isPasswordError}
|
||||
isDisabled={!!fileError || isPasswordError}
|
||||
onClick={_onRestore}
|
||||
>
|
||||
{t('Restore')}
|
||||
|
||||
@@ -1,11 +1,10 @@
|
||||
// Copyright 2019-2026 @polkadot/extension-ui authors & contributors
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
import type { Chain } from '@polkadot/extension-chains/types';
|
||||
import type { Call, ExtrinsicEra, ExtrinsicPayload } from '@polkadot/types/interfaces';
|
||||
import type { AnyJson, SignerPayloadJSON } from '@polkadot/types/types';
|
||||
import type { BN } from '@polkadot/util';
|
||||
import type { ExtrinsicEra, ExtrinsicPayload } from '@polkadot/types/interfaces';
|
||||
import type { SignerPayloadJSON } from '@polkadot/types/types';
|
||||
import type { TFunction } from '../../hooks/useTranslation.js';
|
||||
import type { Decoded } from '../../util/decodeMethod.js';
|
||||
|
||||
import { convertMultilocationToUrl } from '@paraspell/xcm-analyser';
|
||||
import React, { useMemo, useRef } from 'react';
|
||||
@@ -14,11 +13,7 @@ import { bnToBn, formatNumber } from '@polkadot/util';
|
||||
|
||||
import { Table } from '../../components/index.js';
|
||||
import { useMetadata, useTranslation } from '../../hooks/index.js';
|
||||
|
||||
interface Decoded {
|
||||
args: AnyJson | null;
|
||||
method: Call | null;
|
||||
}
|
||||
import { decodeMethod } from '../../util/decodeMethod.js';
|
||||
|
||||
interface Props {
|
||||
className?: string;
|
||||
@@ -27,33 +22,8 @@ interface Props {
|
||||
url: string;
|
||||
}
|
||||
|
||||
function displayDecodeVersion (message: string, chain: Chain, specVersion: BN): string {
|
||||
return `${message}: chain=${chain.name}, specVersion=${chain.specVersion.toString()} (request specVersion=${specVersion.toString()})`;
|
||||
}
|
||||
|
||||
function decodeMethod (data: string, chain: Chain, specVersion: BN): Decoded {
|
||||
let args: AnyJson | null = null;
|
||||
let method: Call | null = null;
|
||||
|
||||
try {
|
||||
if (specVersion.eqn(chain.specVersion)) {
|
||||
method = chain.registry.createType('Call', data);
|
||||
args = (method.toHuman() as { args: AnyJson }).args;
|
||||
} else {
|
||||
console.log(displayDecodeVersion('Outdated metadata to decode', chain, specVersion));
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`${displayDecodeVersion('Error decoding method', chain, specVersion)}:: ${(error as Error).message}`);
|
||||
|
||||
args = null;
|
||||
method = null;
|
||||
}
|
||||
|
||||
return { args, method };
|
||||
}
|
||||
|
||||
function renderMethod (data: string, { args, method }: Decoded, t: TFunction): React.ReactNode {
|
||||
if (!args || !method) {
|
||||
function renderMethod (data: string, { args, name }: Decoded, t: TFunction): React.ReactNode {
|
||||
if (!args || !name) {
|
||||
return (
|
||||
<tr>
|
||||
<td className='label'>{t('method data')}</td>
|
||||
@@ -63,31 +33,15 @@ function renderMethod (data: string, { args, method }: Decoded, t: TFunction): R
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<tr>
|
||||
<td className='label'>{t('method')}</td>
|
||||
<td className='data'>
|
||||
<details>
|
||||
<summary>{method.section}.{method.method}{
|
||||
method.meta
|
||||
? `(${method.meta.args.map(({ name }) => name).join(', ')})`
|
||||
: ''
|
||||
}</summary>
|
||||
<pre>{JSON.stringify(args, null, 2)}</pre>
|
||||
</details>
|
||||
</td>
|
||||
</tr>
|
||||
{method.meta && (
|
||||
<tr>
|
||||
<td className='label'>{t('info')}</td>
|
||||
<td className='data'>
|
||||
<details>
|
||||
<summary>{method.meta.docs.map((d) => d.toString().trim()).join(' ')}</summary>
|
||||
</details>
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
</>
|
||||
<tr>
|
||||
<td className='label'>{t('method')}</td>
|
||||
<td className='data'>
|
||||
<details>
|
||||
<summary>{name}({Object.keys(args).join(', ')})</summary>
|
||||
<pre>{JSON.stringify(args, null, 2)}</pre>
|
||||
</details>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -122,7 +76,7 @@ function Extrinsic ({ className, payload, request: { blockNumber, genesisHash, m
|
||||
const decoded = useMemo(
|
||||
() => chain && chain.hasMetadata
|
||||
? decodeMethod(method, chain, specVersion)
|
||||
: { args: null, method: null },
|
||||
: { args: null, name: null },
|
||||
[method, chain, specVersion]
|
||||
);
|
||||
|
||||
|
||||
@@ -10,7 +10,7 @@ import { isExtrinsicRequest } from '@polkadot/extension-base/utils';
|
||||
import { TypeRegistry } from '@polkadot/types';
|
||||
|
||||
import { Address, VerticalSpace, Warning } from '../../../components/index.js';
|
||||
import { useMetadata, useTranslation } from '../../../hooks/index.js';
|
||||
import { useTranslation } from '../../../hooks/index.js';
|
||||
import Bytes from '../Bytes.js';
|
||||
import Extrinsic from '../Extrinsic.js';
|
||||
import SignArea from './SignArea.js';
|
||||
@@ -36,21 +36,6 @@ export default function Request ({ account: { isExternal, isHardware }, buttonTe
|
||||
const [{ hexBytes, payload }, setData] = useState<Data>({ hexBytes: null, payload: null });
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const { t } = useTranslation();
|
||||
// Raw vs extrinsic follows the channel the request arrived on, never the
|
||||
// payload fields - those are dapp-supplied and can describe either shape.
|
||||
// Use payload genesis for transaction-signing flow. Account genesis can be null
|
||||
// for allow-any accounts and should not drive payload decoding/signing setup.
|
||||
const payloadGenesisHash = isExtrinsicRequest(request)
|
||||
? request.payload.genesisHash
|
||||
: null;
|
||||
const chain = useMetadata(payloadGenesisHash);
|
||||
|
||||
useEffect((): void => {
|
||||
// When the chain and request are ready, configure the chain's registry.
|
||||
if (chain && isExtrinsicRequest(request)) {
|
||||
chain.registry.setSignedExtensions(request.payload.signedExtensions, chain.definition.userExtensions);
|
||||
}
|
||||
}, [chain, request]);
|
||||
|
||||
useEffect((): void => {
|
||||
if (isExtrinsicRequest(request)) {
|
||||
|
||||
127
packages/extension-ui/src/Popup/Wallet/AddAccount.tsx
Normal file
@@ -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;
|
||||
}
|
||||
`);
|
||||
99
packages/extension-ui/src/Popup/Wallet/Forget.tsx
Normal file
@@ -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;
|
||||
}
|
||||
`);
|
||||
136
packages/extension-ui/src/Popup/Wallet/WormholeUnlock.tsx
Normal file
@@ -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;
|
||||
}
|
||||
`);
|
||||
@@ -31,12 +31,14 @@ function Welcome ({ className }: Props): React.ReactElement<Props> {
|
||||
<p>{t('Before we start, just a couple of notes regarding use:')}</p>
|
||||
<Box>
|
||||
<List>
|
||||
<li>{t('We do not send any clicks, pageviews or events to a central server')}</li>
|
||||
<li>{t('We do not use any trackers or analytics')}</li>
|
||||
<li>{t("We don't collect keys, addresses or any information - your information never leaves this machine")}</li>
|
||||
<li>{t('No clicks, pageviews or events are sent anywhere, and there are no trackers or analytics.')}</li>
|
||||
<li>{t('Keys and recovery phrases never leave this browser.')}</li>
|
||||
<li>{t('To show balances, your addresses are sent to a Quantus node; to show wormhole balances, your wormhole addresses are sent to a blackbeard observer. Either can be pointed at your own server, or turned off, in settings.')}</li>
|
||||
</List>
|
||||
</Box>
|
||||
<p>{t('... we are not in the information collection business (even anonymized).')}</p>
|
||||
{/* Upstream said "your information never leaves this machine". Once
|
||||
balances were shown that stopped being true, and a welcome screen
|
||||
is the wrong place to overstate privacy. quantus/extension#15 */}
|
||||
</div>
|
||||
<VerticalSpace />
|
||||
<ButtonArea>
|
||||
|
||||
@@ -1,31 +1,32 @@
|
||||
// Copyright 2019-2026 @polkadot/extension-ui authors & contributors
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
import type { AccountJson, AccountsContext, AuthorizeRequest, MetadataRequest, SigningRequest } from '@polkadot/extension-base/background/types';
|
||||
import type { AccountJson, AccountsContext, AuthorizeRequest, MetadataRequest, SigningRequest, WalletInfo } from '@polkadot/extension-base/background/types';
|
||||
import type { SettingsStruct } from '@polkadot/ui-settings/types';
|
||||
|
||||
import React, { useCallback, useEffect, useState } from 'react';
|
||||
import { Route, Switch, useHistory } from 'react-router';
|
||||
|
||||
import { PHISHING_PAGE_REDIRECT } from '@polkadot/extension-base/defaults';
|
||||
import { canDerive } from '@polkadot/extension-base/utils';
|
||||
import { settings } from '@polkadot/ui-settings';
|
||||
|
||||
import { AccountContext, ActionContext, AuthorizeReqContext, MediaContext, MetadataReqContext, SettingsContext, SigningReqContext } from '../components/contexts.js';
|
||||
import { AccountContext, ActionContext, AuthorizeReqContext, BalanceContext, MediaContext, MetadataReqContext, SettingsContext, SigningReqContext, WalletContext } from '../components/contexts.js';
|
||||
import { ErrorBoundary, Loading } from '../components/index.js';
|
||||
import ToastProvider from '../components/Toast/ToastProvider.js';
|
||||
import { ping, subscribeAccounts, subscribeAuthorizeRequests, subscribeMetadataRequests, subscribeSigningRequests } from '../messaging.js';
|
||||
import { useBalances } from '../hooks/index.js';
|
||||
import { ping, subscribeAccounts, subscribeAuthorizeRequests, subscribeMetadataRequests, subscribeSigningRequests, subscribeWallets } from '../messaging.js';
|
||||
import { buildHierarchy } from '../util/buildHierarchy.js';
|
||||
import Accounts from './Accounts/index.js';
|
||||
import AccountManagement from './AuthManagement/AccountManagement.js';
|
||||
import AuthList from './AuthManagement/index.js';
|
||||
import Authorize from './Authorize/index.js';
|
||||
import CreateAccount from './CreateAccount/index.js';
|
||||
import Derive from './Derive/index.js';
|
||||
import ImportSeed from './ImportSeed/index.js';
|
||||
import Metadata from './Metadata/index.js';
|
||||
import Signing from './Signing/index.js';
|
||||
import AssetHubMigration from './AssetHubMigration.js';
|
||||
import AddWalletAccount from './Wallet/AddAccount.js';
|
||||
import ForgetWallet from './Wallet/Forget.js';
|
||||
import WormholeUnlock from './Wallet/WormholeUnlock.js';
|
||||
import Export from './Export.js';
|
||||
import ExportAll from './ExportAll.js';
|
||||
import Forget from './Forget.js';
|
||||
@@ -55,18 +56,17 @@ async function requestMediaAccess (cameraOn: boolean): Promise<boolean> {
|
||||
|
||||
function initAccountContext ({ accounts, selectedAccounts, setSelectedAccounts }: Omit<AccountsContext, 'hierarchy' | 'master'>): AccountsContext {
|
||||
const hierarchy = buildHierarchy(accounts);
|
||||
const master = hierarchy.find(({ isExternal, type }) => !isExternal && canDerive(type));
|
||||
|
||||
return {
|
||||
accounts,
|
||||
hierarchy,
|
||||
master,
|
||||
selectedAccounts,
|
||||
setSelectedAccounts
|
||||
};
|
||||
}
|
||||
|
||||
export default function Popup (): React.ReactElement {
|
||||
const balances = useBalances();
|
||||
const [accounts, setAccounts] = useState<null | AccountJson[]>(null);
|
||||
const [accountCtx, setAccountCtx] = useState<AccountsContext>({ accounts: [], hierarchy: [] });
|
||||
const [selectedAccounts, setSelectedAccounts] = useState<AccountJson['address'][]>([]);
|
||||
@@ -75,15 +75,14 @@ export default function Popup (): React.ReactElement {
|
||||
const [mediaAllowed, setMediaAllowed] = useState(false);
|
||||
const [metaRequests, setMetaRequests] = useState<null | MetadataRequest[]>(null);
|
||||
const [signRequests, setSignRequests] = useState<null | SigningRequest[]>(null);
|
||||
const [wallets, setWallets] = useState<null | WalletInfo[]>(null);
|
||||
const [isWelcomeDone, setWelcomeDone] = useState(false);
|
||||
const [isMigrationDone, setMigrationDone] = useState(false);
|
||||
const [settingsCtx, setSettingsCtx] = useState<SettingsStruct>(startSettings);
|
||||
const history = useHistory();
|
||||
|
||||
const _onAction = useCallback(
|
||||
(to?: string): void => {
|
||||
setWelcomeDone(window.localStorage.getItem('welcome_read') === 'ok');
|
||||
setMigrationDone(window.localStorage.getItem('asset_hub_migration_read') === 'ok');
|
||||
|
||||
if (!to) {
|
||||
return;
|
||||
@@ -106,7 +105,8 @@ export default function Popup (): React.ReactElement {
|
||||
subscribeAccounts(setAccounts),
|
||||
subscribeAuthorizeRequests(setAuthRequests),
|
||||
subscribeMetadataRequests(setMetaRequests),
|
||||
subscribeSigningRequests(setSignRequests)
|
||||
subscribeSigningRequests(setSignRequests),
|
||||
subscribeWallets(setWallets)
|
||||
])).catch(console.error);
|
||||
|
||||
settings.on('change', (settings): void => {
|
||||
@@ -132,52 +132,60 @@ export default function Popup (): React.ReactElement {
|
||||
return <ErrorBoundary trigger={trigger}>{component}</ErrorBoundary>;
|
||||
}
|
||||
|
||||
// Upstream shows an Asset Hub migration notice here before anything else.
|
||||
// Removed: it is about balances moving from the Polkadot Relay Chain to Asset
|
||||
// Hub, and neither exists on Quantus. It is not merely irrelevant — it told
|
||||
// every user, on first open, that their balances had been migrated somewhere
|
||||
// and warned them not to teleport to a chain this extension cannot reach.
|
||||
const Root = !isWelcomeDone
|
||||
? wrapWithErrorBoundary(<Welcome />, 'welcome')
|
||||
: !isMigrationDone
|
||||
? wrapWithErrorBoundary(<AssetHubMigration />, 'asset-hub-migration')
|
||||
: authRequests?.length
|
||||
? wrapWithErrorBoundary(<Authorize />, 'authorize')
|
||||
: metaRequests?.length
|
||||
? wrapWithErrorBoundary(<Metadata />, 'metadata')
|
||||
: signRequests?.length
|
||||
? wrapWithErrorBoundary(<Signing />, 'signing')
|
||||
: wrapWithErrorBoundary(<Accounts />, 'accounts');
|
||||
: authRequests?.length
|
||||
? wrapWithErrorBoundary(<Authorize />, 'authorize')
|
||||
: metaRequests?.length
|
||||
? wrapWithErrorBoundary(<Metadata />, 'metadata')
|
||||
: signRequests?.length
|
||||
? wrapWithErrorBoundary(<Signing />, 'signing')
|
||||
: wrapWithErrorBoundary(<Accounts />, 'accounts');
|
||||
|
||||
return (
|
||||
<Loading>{accounts && authRequests && metaRequests && signRequests && (
|
||||
<Loading>{accounts && authRequests && metaRequests && signRequests && wallets && (
|
||||
<ActionContext.Provider value={_onAction}>
|
||||
<SettingsContext.Provider value={settingsCtx}>
|
||||
<AccountContext.Provider value={accountCtx}>
|
||||
<AuthorizeReqContext.Provider value={authRequests}>
|
||||
<MediaContext.Provider value={cameraOn && mediaAllowed}>
|
||||
<MetadataReqContext.Provider value={metaRequests}>
|
||||
<SigningReqContext.Provider value={signRequests}>
|
||||
<ToastProvider>
|
||||
<Switch>
|
||||
<Route path='/auth-list'>{wrapWithErrorBoundary(<AuthList />, 'auth-list')}</Route>
|
||||
<Route path='/account/create'>{wrapWithErrorBoundary(<CreateAccount />, 'account-creation')}</Route>
|
||||
<Route path='/account/forget/:address'>{wrapWithErrorBoundary(<Forget />, 'forget-address')}</Route>
|
||||
<Route path='/account/export/:address'>{wrapWithErrorBoundary(<Export />, 'export-address')}</Route>
|
||||
<Route path='/account/export-all'>{wrapWithErrorBoundary(<ExportAll />, 'export-all-address')}</Route>
|
||||
<Route path='/account/track-address'>{wrapWithErrorBoundary(<TrackAddress />, 'track-address')}</Route>
|
||||
<Route path='/account/import-seed'>{wrapWithErrorBoundary(<ImportSeed />, 'import-seed')}</Route>
|
||||
<Route path='/account/restore-json'>{wrapWithErrorBoundary(<RestoreJson />, 'restore-json')}</Route>
|
||||
<Route path='/account/derive/:address/locked'>{wrapWithErrorBoundary(<Derive isLocked />, 'derived-address-locked')}</Route>
|
||||
<Route path='/account/derive/:address'>{wrapWithErrorBoundary(<Derive />, 'derive-address')}</Route>
|
||||
<Route path='/url/manage/:url'>{wrapWithErrorBoundary(<AccountManagement />, 'manage-url')}</Route>
|
||||
<Route path={`${PHISHING_PAGE_REDIRECT}/:website`}>{wrapWithErrorBoundary(<PhishingDetected />, 'phishing-page-redirect')}</Route>
|
||||
<Route
|
||||
exact
|
||||
path='/'
|
||||
>
|
||||
{Root}
|
||||
</Route>
|
||||
</Switch>
|
||||
</ToastProvider>
|
||||
</SigningReqContext.Provider>
|
||||
</MetadataReqContext.Provider>
|
||||
</MediaContext.Provider>
|
||||
<BalanceContext.Provider value={balances}>
|
||||
<WalletContext.Provider value={wallets}>
|
||||
<MediaContext.Provider value={cameraOn && mediaAllowed}>
|
||||
<MetadataReqContext.Provider value={metaRequests}>
|
||||
<SigningReqContext.Provider value={signRequests}>
|
||||
<ToastProvider>
|
||||
<Switch>
|
||||
<Route path='/auth-list'>{wrapWithErrorBoundary(<AuthList />, 'auth-list')}</Route>
|
||||
<Route path='/account/create'>{wrapWithErrorBoundary(<CreateAccount />, 'account-creation')}</Route>
|
||||
<Route path='/wallet/add-account/:id'>{wrapWithErrorBoundary(<AddWalletAccount />, 'wallet-add-account')}</Route>
|
||||
<Route path='/wallet/wormhole-unlock/:id/:index'>{wrapWithErrorBoundary(<WormholeUnlock />, 'wallet-wormhole-unlock')}</Route>
|
||||
<Route path='/wallet/forget/:id'>{wrapWithErrorBoundary(<ForgetWallet />, 'wallet-forget')}</Route>
|
||||
<Route path='/account/forget/:address'>{wrapWithErrorBoundary(<Forget />, 'forget-address')}</Route>
|
||||
<Route path='/account/export/:address'>{wrapWithErrorBoundary(<Export />, 'export-address')}</Route>
|
||||
<Route path='/account/export-all'>{wrapWithErrorBoundary(<ExportAll />, 'export-all-address')}</Route>
|
||||
<Route path='/account/track-address'>{wrapWithErrorBoundary(<TrackAddress />, 'track-address')}</Route>
|
||||
<Route path='/account/import-seed'>{wrapWithErrorBoundary(<ImportSeed />, 'import-seed')}</Route>
|
||||
<Route path='/account/restore-json'>{wrapWithErrorBoundary(<RestoreJson />, 'restore-json')}</Route>
|
||||
<Route path='/url/manage/:url'>{wrapWithErrorBoundary(<AccountManagement />, 'manage-url')}</Route>
|
||||
<Route path={`${PHISHING_PAGE_REDIRECT}/:website`}>{wrapWithErrorBoundary(<PhishingDetected />, 'phishing-page-redirect')}</Route>
|
||||
<Route
|
||||
exact
|
||||
path='/'
|
||||
>
|
||||
{Root}
|
||||
</Route>
|
||||
</Switch>
|
||||
</ToastProvider>
|
||||
</SigningReqContext.Provider>
|
||||
</MetadataReqContext.Provider>
|
||||
</MediaContext.Provider>
|
||||
</WalletContext.Provider>
|
||||
</BalanceContext.Provider>
|
||||
</AuthorizeReqContext.Provider>
|
||||
</AccountContext.Provider>
|
||||
</SettingsContext.Provider>
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
<?xml version="1.0" encoding="utf-8" standalone="yes"?><svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" version="1.1" id="Layer_1" x="0px" y="0px" viewBox="15 15 140 140" style="enable-background:new 0 0 170 170;zoom: 1;" xml:space="preserve"><style type="text/css">.bg0{fill:#FF8C00} .st0{fill:#FFFFFF}</style><g><circle class="bg0" cx="85" cy="85" r="70"></circle><g><path class="st0" d="M85,34.7c-20.8,0-37.8,16.9-37.8,37.8c0,4.2,0.7,8.3,2,12.3c0.9,2.7,3.9,4.2,6.7,3.3c2.7-0.9,4.2-3.9,3.3-6.7 c-1.1-3.1-1.6-6.4-1.5-9.7C58.1,57.6,69.5,46,83.6,45.3c15.7-0.8,28.7,11.7,28.7,27.2c0,14.5-11.4,26.4-25.7,27.2 c0,0-5.3,0.3-7.9,0.7c-1.3,0.2-2.3,0.4-3,0.5c-0.3,0.1-0.6-0.2-0.5-0.5l0.9-4.4L81,73.4c0.6-2.8-1.2-5.6-4-6.2 c-2.8-0.6-5.6,1.2-6.2,4c0,0-11.8,55-11.9,55.6c-0.6,2.8,1.2,5.6,4,6.2c2.8,0.6,5.6-1.2,6.2-4c0.1-0.6,1.7-7.9,1.7-7.9 c1.2-5.6,5.8-9.7,11.2-10.4c1.2-0.2,5.9-0.5,5.9-0.5c19.5-1.5,34.9-17.8,34.9-37.7C122.8,51.6,105.8,34.7,85,34.7z M87.7,121.7 c-3.4-0.7-6.8,1.4-7.5,4.9c-0.7,3.4,1.4,6.8,4.9,7.5c3.4,0.7,6.8-1.4,7.5-4.9C93.3,125.7,91.2,122.4,87.7,121.7z"></path></g></g></svg>
|
||||
|
Before Width: | Height: | Size: 1.1 KiB |
10
packages/extension-ui/src/assets/sigil.svg
Normal file
@@ -0,0 +1,10 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 40 40" width="40" height="40">
|
||||
<!-- blackbeard.observer's sigil, centred on a square for icons -->
|
||||
<rect width="40" height="40" rx="6" fill="#0d0b09"/>
|
||||
<g transform="translate(3 0)">
|
||||
<path d="M17 1 L32 7 V20 C32 29 25 35 17 39 C9 35 2 29 2 20 V7 Z" fill="none" stroke="#bd8829" stroke-width="1.6"/>
|
||||
<path d="M17 8 L24 12 V21 C24 26 20.5 29.5 17 31.5 C13.5 29.5 10 26 10 21 V12 Z" fill="#bd882922"/>
|
||||
<path d="M17 11 V28" stroke="#d8453a" stroke-width="1.6"/>
|
||||
<path d="M13 15 L17 11 L21 15" fill="none" stroke="#d8453a" stroke-width="1.6"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 628 B |
@@ -4,10 +4,12 @@
|
||||
import React, { useCallback, useState } from 'react';
|
||||
|
||||
import { Name, Password } from '../partials/index.js';
|
||||
import { BackButton, ButtonArea, NextStepButton, VerticalSpace } from './index.js';
|
||||
import { BackButton, ButtonArea, NextStepButton, VerticalSpace, Warning } from './index.js';
|
||||
|
||||
interface Props {
|
||||
buttonLabel?: string;
|
||||
/** Why the last attempt to create failed, shown above the buttons. */
|
||||
error?: string;
|
||||
isBusy: boolean;
|
||||
onBackClick?: () => void;
|
||||
onCreate: (name: string, password: string) => void | Promise<void | boolean>;
|
||||
@@ -15,7 +17,7 @@ interface Props {
|
||||
onPasswordChange?: (password: string) => void;
|
||||
}
|
||||
|
||||
function AccountNamePasswordCreation ({ buttonLabel, isBusy, onBackClick, onCreate, onNameChange, onPasswordChange }: Props): React.ReactElement<Props> {
|
||||
function AccountNamePasswordCreation ({ buttonLabel, error, isBusy, onBackClick, onCreate, onNameChange, onPasswordChange }: Props): React.ReactElement<Props> {
|
||||
const [name, setName] = useState<string | null>(null);
|
||||
const [password, setPassword] = useState<string | null>(null);
|
||||
|
||||
@@ -62,6 +64,11 @@ function AccountNamePasswordCreation ({ buttonLabel, isBusy, onBackClick, onCrea
|
||||
onChange={_onNameChange}
|
||||
/>
|
||||
<Password onChange={_onPasswordChange} />
|
||||
{error && (
|
||||
<Warning isDanger>
|
||||
{error}
|
||||
</Warning>
|
||||
)}
|
||||
<VerticalSpace />
|
||||
{onBackClick && buttonLabel && (
|
||||
<ButtonArea>
|
||||
|
||||
@@ -51,7 +51,7 @@ interface AccountTestGenesisJson extends AccountTestJson {
|
||||
genesisHash: HexString;
|
||||
}
|
||||
|
||||
const externalAccount = { address: '5EeaoDj4VDk8V6yQngKBaCD5MpJUCHrhYjVhBjgMHXoYon1s', expectedIconTheme: 'polkadot', isExternal: true, name: 'External Account', type: 'sr25519' } as AccountJson;
|
||||
const externalAccount = { address: '5EeaoDj4VDk8V6yQngKBaCD5MpJUCHrhYjVhBjgMHXoYon1s', expectedIconTheme: 'polkadot', isExternal: true, name: 'External Account', type: 'dilithium65' } as AccountJson;
|
||||
const hardwareAccount = {
|
||||
address: 'HDE6uFdw53SwUyfKSsjwZNmS2sziWMPuY6uJhGHcFzLYRaJ',
|
||||
expectedIconTheme: 'polkadot',
|
||||
@@ -60,14 +60,13 @@ const hardwareAccount = {
|
||||
isExternal: true,
|
||||
isHardware: true,
|
||||
name: 'Hardware Account',
|
||||
type: 'sr25519'
|
||||
type: 'dilithium65'
|
||||
} as AccountJson;
|
||||
|
||||
const accounts = [
|
||||
{ address: '5HSDXAC3qEMkSzZK377sTD1zJhjaPiX5tNWppHx2RQMYkjaJ', expectedIconTheme: 'polkadot', name: 'ECDSA Account', type: 'ecdsa' },
|
||||
{ address: '5FjgD3Ns2UpnHJPVeRViMhCttuemaRXEqaD8V5z4vxcsUByA', expectedIconTheme: 'polkadot', name: 'Ed Account', type: 'ed25519' },
|
||||
{ address: '5Ggap6soAPaP5UeNaiJsgqQwdVhhNnm6ez7Ba1w9jJ62LM2Q', expectedIconTheme: 'polkadot', name: 'Parent Sr Account', type: 'sr25519' },
|
||||
{ address: '0xd5D81CD4236a43F48A983fc5B895975c511f634D', expectedIconTheme: 'ethereum', name: 'Ethereum', type: 'ethereum' },
|
||||
{ address: '5HSDXAC3qEMkSzZK377sTD1zJhjaPiX5tNWppHx2RQMYkjaJ', expectedIconTheme: 'polkadot', name: 'ECDSA Account', type: 'dilithium87' },
|
||||
{ address: '5FjgD3Ns2UpnHJPVeRViMhCttuemaRXEqaD8V5z4vxcsUByA', expectedIconTheme: 'polkadot', name: 'Ed Account', type: 'dilithium65' },
|
||||
{ address: '5Ggap6soAPaP5UeNaiJsgqQwdVhhNnm6ez7Ba1w9jJ62LM2Q', expectedIconTheme: 'polkadot', name: 'Parent Sr Account', type: 'dilithium65' },
|
||||
{ ...externalAccount },
|
||||
{ ...hardwareAccount }
|
||||
] as AccountTestJson[];
|
||||
@@ -82,7 +81,7 @@ const westEndAccount = {
|
||||
expectedNetworkLabel: 'Westend',
|
||||
genesisHash: '0xe143f23803ac50e8f6f8e62695d1ce9e4e1d68aa36c1cd2cfd15340213f3423e',
|
||||
name: 'acc',
|
||||
type: 'ed25519'
|
||||
type: 'dilithium65'
|
||||
} as AccountTestGenesisJson;
|
||||
|
||||
const accountsWithGenesisHash = [
|
||||
@@ -93,7 +92,7 @@ const accountsWithGenesisHash = [
|
||||
expectedIconTheme: 'polkadot',
|
||||
expectedNetworkLabel: 'Polkadot',
|
||||
genesisHash: '0x91b171bb158e2d3848fa23a9f1c25182fb8e20313b2c1eb49219da7a70ce90c3',
|
||||
type: 'sr25519'
|
||||
type: 'dilithium65'
|
||||
},
|
||||
// with Kusama genesis Hash
|
||||
{
|
||||
@@ -102,7 +101,7 @@ const accountsWithGenesisHash = [
|
||||
expectedIconTheme: 'polkadot',
|
||||
expectedNetworkLabel: 'Kusama',
|
||||
genesisHash: '0xb0a8d493285c2df73290dfb7e61f870f17b41801197a149ca93654499ea3dafe',
|
||||
type: 'sr25519'
|
||||
type: 'dilithium65'
|
||||
},
|
||||
// with Edgeware genesis Hash
|
||||
{
|
||||
@@ -111,7 +110,7 @@ const accountsWithGenesisHash = [
|
||||
expectedIconTheme: 'substrate',
|
||||
expectedNetworkLabel: 'Edgeware',
|
||||
genesisHash: '0x742a2ca70c2fda6cee4f8df98d64c4c670a052d9568058982dad9d5a7a135c5b',
|
||||
type: 'sr25519'
|
||||
type: 'dilithium65'
|
||||
}
|
||||
] as AccountTestGenesisJson[];
|
||||
|
||||
@@ -330,7 +329,7 @@ describe('Address', () => {
|
||||
name: 'Luke',
|
||||
parentName: 'Dark Vador',
|
||||
suri: '//42',
|
||||
type: 'sr25519'
|
||||
type: 'dilithium65'
|
||||
} as AccountJson;
|
||||
|
||||
beforeAll(async () => {
|
||||
|
||||
@@ -21,9 +21,11 @@ import details from '../assets/details.svg';
|
||||
import { useMetadata, useOutsideClick, useToast, useTranslation } from '../hooks/index.js';
|
||||
import { showAccount } from '../messaging.js';
|
||||
import { styled } from '../styled.js';
|
||||
import { DEFAULT_PREFIX } from '../util/defaultPrefix.js';
|
||||
import { DEFAULT_TYPE } from '../util/defaultType.js';
|
||||
import { formatBalance } from '../util/formatBalance.js';
|
||||
import getParentNameSuri from '../util/getParentNameSuri.js';
|
||||
import { AccountContext, SettingsContext } from './contexts.js';
|
||||
import { AccountContext, BalanceContext, SettingsContext } from './contexts.js';
|
||||
import Identicon from './Identicon.js';
|
||||
import Menu from './Menu.js';
|
||||
import Svg from './Svg.js';
|
||||
@@ -62,27 +64,18 @@ function findSubstrateAccount (accounts: AccountJson[], publicKey: Uint8Array):
|
||||
) || null;
|
||||
}
|
||||
|
||||
// find an account in our list
|
||||
function findAccountByAddress (accounts: AccountJson[], _address: string): AccountJson | null {
|
||||
return accounts.find(({ address }): boolean =>
|
||||
address === _address
|
||||
) || null;
|
||||
}
|
||||
|
||||
// recodes an supplied address using the prefix/genesisHash, include the actual saved account & chain
|
||||
function recodeAddress (address: string, accounts: AccountWithChildren[], chain: Chain | null, settings: SettingsStruct): Recoded {
|
||||
// decode and create a shortcut for the encoded address
|
||||
const publicKey = isHex(address) ? hexToU8a(address) : decodeAddress(address);
|
||||
// find our account using the actual publicKey, and then find the associated chain
|
||||
const account = findSubstrateAccount(accounts, publicKey);
|
||||
const prefix = chain ? chain.ss58Format : (settings.prefix === -1 ? 42 : settings.prefix);
|
||||
const prefix = chain ? chain.ss58Format : (settings.prefix === -1 ? DEFAULT_PREFIX : settings.prefix);
|
||||
|
||||
// always allow the actual settings to override the display
|
||||
return {
|
||||
account,
|
||||
formatted: account?.type === 'ethereum'
|
||||
? address
|
||||
: encodeAddress(publicKey, prefix),
|
||||
formatted: encodeAddress(publicKey, prefix),
|
||||
genesisHash: account?.genesisHash,
|
||||
prefix,
|
||||
type: account?.type || DEFAULT_TYPE
|
||||
@@ -92,11 +85,12 @@ function recodeAddress (address: string, accounts: AccountWithChildren[], chain:
|
||||
const ACCOUNTS_SCREEN_HEIGHT = 550;
|
||||
const defaultRecoded = { account: null, formatted: null, prefix: 42, type: DEFAULT_TYPE };
|
||||
|
||||
function Address ({ actions, address, children, className, genesisHash, isExternal, isHardware, isHidden, name, parentName, showVisibilityAction = false, suri, toggleActions, type: givenType }: Props): React.ReactElement<Props> {
|
||||
function Address ({ actions, address, children, className, genesisHash, isExternal, isHardware, isHidden, name, parentName, showVisibilityAction = false, suri, toggleActions }: Props): React.ReactElement<Props> {
|
||||
const { t } = useTranslation();
|
||||
const { accounts } = useContext(AccountContext);
|
||||
const balances = useContext(BalanceContext);
|
||||
const settings = useContext(SettingsContext);
|
||||
const [{ account, formatted, genesisHash: recodedGenesis, prefix, type }, setRecoded] = useState<Recoded>(defaultRecoded);
|
||||
const [{ account, formatted, genesisHash: recodedGenesis, prefix }, setRecoded] = useState<Recoded>(defaultRecoded);
|
||||
const chain = useMetadata(genesisHash || recodedGenesis, true);
|
||||
|
||||
const [showActionsMenu, setShowActionsMenu] = useState(false);
|
||||
@@ -112,18 +106,10 @@ function Address ({ actions, address, children, className, genesisHash, isExtern
|
||||
return setRecoded(defaultRecoded);
|
||||
}
|
||||
|
||||
const account = findAccountByAddress(accounts, address);
|
||||
|
||||
setRecoded(
|
||||
(
|
||||
chain?.definition.chainType === 'ethereum' ||
|
||||
account?.type === 'ethereum' ||
|
||||
(!account && givenType === 'ethereum')
|
||||
)
|
||||
? { account, formatted: address, type: 'ethereum' }
|
||||
: recodeAddress(address, accounts, chain, settings)
|
||||
);
|
||||
}, [accounts, address, chain, givenType, settings]);
|
||||
// No Ethereum branch: this extension holds no Ethereum keys, and Quantus
|
||||
// addresses are always SS58. quantus/common#6
|
||||
setRecoded(recodeAddress(address, accounts, chain, settings));
|
||||
}, [accounts, address, chain, settings]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!showActionsMenu) {
|
||||
@@ -141,11 +127,7 @@ function Address ({ actions, address, children, className, genesisHash, isExtern
|
||||
setShowActionsMenu(false);
|
||||
}, [toggleActions]);
|
||||
|
||||
const theme = (
|
||||
type === 'ethereum'
|
||||
? 'ethereum'
|
||||
: (chain?.icon || 'polkadot')
|
||||
) as IconTheme;
|
||||
const theme = (chain?.icon || 'polkadot') as IconTheme;
|
||||
|
||||
const _onClick = useCallback(
|
||||
() => setShowActionsMenu(!showActionsMenu),
|
||||
@@ -192,6 +174,10 @@ function Address ({ actions, address, children, className, genesisHash, isExtern
|
||||
</>);
|
||||
};
|
||||
|
||||
// Keyed by the address the keyring stores, not the re-encoded display form:
|
||||
// the same account renders differently at different prefixes and the map has
|
||||
// one key per account.
|
||||
const balance = (account?.address || address) ? balances[account?.address || address || ''] : undefined;
|
||||
const parentNameSuri = getParentNameSuri(parentName, suri);
|
||||
|
||||
return (
|
||||
@@ -236,6 +222,14 @@ function Address ({ actions, address, children, className, genesisHash, isExtern
|
||||
</div>
|
||||
)
|
||||
}
|
||||
{balance && (
|
||||
<div
|
||||
className='balance'
|
||||
data-field='balance'
|
||||
>
|
||||
{formatBalance(balance)}
|
||||
</div>
|
||||
)}
|
||||
{chain?.genesisHash && chain?.name && (
|
||||
<div
|
||||
className='banner chain'
|
||||
@@ -265,7 +259,10 @@ function Address ({ actions, address, children, className, genesisHash, isExtern
|
||||
title={t('copy address')}
|
||||
/>
|
||||
</CopyToClipboard>
|
||||
{(actions || showVisibilityAction) && (
|
||||
{/* Visibility is a keyring flag: it hides an account from dapps. An
|
||||
address with no keyring account behind it (a wormhole address)
|
||||
has nothing to hide, and toggling it would only error. */}
|
||||
{(actions || showVisibilityAction) && account && (
|
||||
<FontAwesomeIcon
|
||||
className={isHidden ? 'hiddenIcon' : 'visibleIcon'}
|
||||
icon={isHidden ? faEyeSlash : faEye}
|
||||
@@ -328,6 +325,13 @@ export default styled(Address)<Props>`
|
||||
}
|
||||
}
|
||||
|
||||
.balance {
|
||||
color: var(--labelColor);
|
||||
font-size: var(--labelFontSize);
|
||||
line-height: var(--labelLineHeight);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.addressDisplay {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
|
||||
120
packages/extension-ui/src/components/WalletPreview.tsx
Normal file
@@ -0,0 +1,120 @@
|
||||
// Copyright 2019-2026 @polkadot/extension-ui authors & contributors
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
import type { ResponseWalletPreview } from '@polkadot/extension-base/background/types';
|
||||
|
||||
import React, { useContext } from 'react';
|
||||
|
||||
import { decodeAddress, encodeAddress } from '@polkadot/util-crypto';
|
||||
|
||||
import { useTranslation } from '../hooks/index.js';
|
||||
import { styled } from '../styled.js';
|
||||
import { DEFAULT_PREFIX } from '../util/defaultPrefix.js';
|
||||
import { SettingsContext } from './contexts.js';
|
||||
import Identicon from './Identicon.js';
|
||||
|
||||
interface Props {
|
||||
className?: string;
|
||||
name?: string | null;
|
||||
preview: ResponseWalletPreview | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* The three account-0 addresses a secret gives, before anything is saved.
|
||||
*
|
||||
* All three, because a person may know their wallet by any of them: the account
|
||||
* they signed with in quantus-cli (ML-DSA-65 or ML-DSA-87, depending on when it
|
||||
* was made), or the wormhole address their mining rewards went to. Showing one
|
||||
* and deriving the others silently is how an import used to look wrong.
|
||||
*/
|
||||
function WalletPreview ({ className, name, preview }: Props): React.ReactElement<Props> {
|
||||
const { t } = useTranslation();
|
||||
const settings = useContext(SettingsContext);
|
||||
const prefix = settings.prefix === -1 ? DEFAULT_PREFIX : settings.prefix;
|
||||
const rows = [
|
||||
{ address: preview?.mldsa65, label: 'ML-DSA-65' },
|
||||
{ address: preview?.mldsa87, label: 'ML-DSA-87' },
|
||||
{ address: preview?.wormhole, label: t('Wormhole') }
|
||||
];
|
||||
|
||||
return (
|
||||
<div className={className}>
|
||||
<div
|
||||
className='name'
|
||||
data-field='name'
|
||||
>
|
||||
{name || t('<unknown>')}
|
||||
</div>
|
||||
{rows.map(({ address, label }) => {
|
||||
const formatted = address ? encodeAddress(decodeAddress(address), prefix) : null;
|
||||
|
||||
return (
|
||||
<div
|
||||
className='row'
|
||||
data-field={label}
|
||||
key={label}
|
||||
>
|
||||
<Identicon
|
||||
className='icon'
|
||||
prefix={prefix}
|
||||
value={formatted}
|
||||
/>
|
||||
<span className='label'>{label}</span>
|
||||
<span className='address'>
|
||||
{formatted || (preview && label === t('Wormhole')
|
||||
? t('none: a raw seed has no wormhole account')
|
||||
: t('<unknown>'))}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default styled(WalletPreview)<Props>`
|
||||
background: var(--boxBackground);
|
||||
border: 1px solid var(--boxBorderColor);
|
||||
border-radius: 4px;
|
||||
box-sizing: border-box;
|
||||
margin-bottom: 8px;
|
||||
padding: 8px 12px;
|
||||
|
||||
.name {
|
||||
font-size: 16px;
|
||||
line-height: 22px;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.row {
|
||||
align-items: center;
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
min-height: 26px;
|
||||
}
|
||||
|
||||
.icon {
|
||||
height: 20px;
|
||||
width: 20px;
|
||||
|
||||
svg, img {
|
||||
height: 20px !important;
|
||||
width: 20px !important;
|
||||
}
|
||||
}
|
||||
|
||||
.label {
|
||||
color: var(--labelColor);
|
||||
flex: 0 0 72px;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.address {
|
||||
color: var(--labelColor);
|
||||
font-family: var(--fontFamilyMono, monospace);
|
||||
font-size: 11px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
`;
|
||||
@@ -1,7 +1,7 @@
|
||||
// Copyright 2019-2026 @polkadot/extension-ui authors & contributors
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
import type { AccountsContext, AuthorizeRequest, MetadataRequest, SigningRequest } from '@polkadot/extension-base/background/types';
|
||||
import type { AccountBalances, AccountsContext, AuthorizeRequest, MetadataRequest, SigningRequest, WalletInfo } from '@polkadot/extension-base/background/types';
|
||||
import type { SettingsStruct } from '@polkadot/ui-settings/types';
|
||||
import type { Theme } from './themes.js';
|
||||
|
||||
@@ -11,9 +11,13 @@ import { settings } from '@polkadot/ui-settings';
|
||||
|
||||
const noop = (): void => undefined;
|
||||
|
||||
const AccountContext = React.createContext<AccountsContext>({ accounts: [], hierarchy: [], master: undefined });
|
||||
const AccountContext = React.createContext<AccountsContext>({ accounts: [], hierarchy: [] });
|
||||
const ActionContext = React.createContext<(to?: string) => void>(noop);
|
||||
const AuthorizeReqContext = React.createContext<AuthorizeRequest[]>([]);
|
||||
// Empty until a node has answered, and empty again if one never does — an
|
||||
// account list that works without balances beats one that fails with them.
|
||||
const BalanceContext = React.createContext<AccountBalances>({});
|
||||
const WalletContext = React.createContext<WalletInfo[]>([]);
|
||||
const MediaContext = React.createContext<boolean>(false);
|
||||
const MetadataReqContext = React.createContext<MetadataRequest[]>([]);
|
||||
const SettingsContext = React.createContext<SettingsStruct>(settings.get());
|
||||
@@ -21,4 +25,4 @@ const SigningReqContext = React.createContext<SigningRequest[]>([]);
|
||||
const ThemeSwitchContext = React.createContext<(theme: Theme) => void>(noop);
|
||||
const ToastContext = React.createContext<({show: (message: string) => void})>({ show: noop });
|
||||
|
||||
export { AccountContext, ActionContext, AuthorizeReqContext, MediaContext, MetadataReqContext, SettingsContext, SigningReqContext, ThemeSwitchContext, ToastContext };
|
||||
export { AccountContext, ActionContext, AuthorizeReqContext, BalanceContext, MediaContext, MetadataReqContext, SettingsContext, SigningReqContext, ThemeSwitchContext, ToastContext, WalletContext };
|
||||
|
||||
@@ -41,4 +41,5 @@ export * from './themes.js';
|
||||
export { default as ValidatedInput } from './ValidatedInput.js';
|
||||
export { default as VerticalSpace } from './VerticalSpace.js';
|
||||
export { default as View } from './View.js';
|
||||
export { default as WalletPreview } from './WalletPreview.js';
|
||||
export { default as Warning } from './Warning.js';
|
||||
|
||||
@@ -1,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';
|
||||
|
||||
52
packages/extension-ui/src/hooks/useBalances.ts
Normal file
@@ -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;
|
||||
}
|
||||
@@ -1,10 +1,9 @@
|
||||
// Copyright 2019-2026 @polkadot/extension-ui authors & contributors
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
/* global chrome */
|
||||
/* eslint-disable no-redeclare */
|
||||
|
||||
import type { AccountJson, AllowedPath, AuthorizeRequest, ConnectedTabsUrlResponse, MessageTypes, MessageTypesWithNoSubscriptions, MessageTypesWithNullRequest, MessageTypesWithSubscriptions, MetadataRequest, RequestTypes, ResponseAuthorizeList, ResponseDeriveValidate, ResponseJsonGetAccountInfo, ResponseSigningIsLocked, ResponseTypes, SeedLengths, SigningRequest, SubscriptionMessageTypes } from '@polkadot/extension-base/background/types';
|
||||
import type { AccountBalances, AccountJson, AllowedPath, AuthorizeRequest, ConnectedTabsUrlResponse, MessageTypes, MessageTypesWithNoSubscriptions, MessageTypesWithNullRequest, MessageTypesWithSubscriptions, MetadataRequest, RequestTypes, ResponseAuthorizeList, ResponseJsonGetAccountInfo, ResponseSigningIsLocked, ResponseTypes, ResponseWalletPreview, SeedLengths, SigningRequest, SubscriptionMessageTypes, WalletInfo, WormholeBalance } from '@polkadot/extension-base/background/types';
|
||||
import type { Message } from '@polkadot/extension-base/types';
|
||||
import type { Chain } from '@polkadot/extension-chains/types';
|
||||
import type { MetadataDef } from '@polkadot/extension-inject/types';
|
||||
@@ -15,7 +14,7 @@ import type { KeypairType } from '@polkadot/util-crypto/types';
|
||||
|
||||
import { PORT_EXTENSION } from '@polkadot/extension-base/defaults';
|
||||
import { getId } from '@polkadot/extension-base/utils/getId';
|
||||
import { ensurePortConnection } from '@polkadot/extension-base/utils/portUtils';
|
||||
import { createPortClient } from '@polkadot/extension-base/utils/portUtils';
|
||||
import { metadataExpand } from '@polkadot/extension-chains';
|
||||
|
||||
import allChains from './util/chains.js';
|
||||
@@ -33,8 +32,6 @@ type Handlers = Record<string, Handler>;
|
||||
|
||||
const handlers: Handlers = {};
|
||||
|
||||
let port: chrome.runtime.Port | undefined;
|
||||
|
||||
function onPortMessageHandler (data: Message['data']): void {
|
||||
const handler = handlers[data.id];
|
||||
|
||||
@@ -58,15 +55,17 @@ function onPortMessageHandler (data: Message['data']): void {
|
||||
}
|
||||
}
|
||||
|
||||
function onPortDisconnectHandler (): void {
|
||||
port = undefined;
|
||||
}
|
||||
|
||||
const portConfig = {
|
||||
onPortDisconnectHandler,
|
||||
onPortMessageHandler,
|
||||
const client = createPortClient({
|
||||
// the background went away before answering; nothing will answer now
|
||||
onLost: (ids: string[]): void => {
|
||||
for (const id of ids) {
|
||||
handlers[id]?.reject(new Error('The extension background restarted before answering. Try again.'));
|
||||
delete handlers[id];
|
||||
}
|
||||
},
|
||||
onMessage: onPortMessageHandler,
|
||||
portName: PORT_EXTENSION
|
||||
};
|
||||
});
|
||||
|
||||
function sendMessage<TMessageType extends MessageTypesWithNullRequest>(message: TMessageType): Promise<ResponseTypes[TMessageType]>;
|
||||
function sendMessage<TMessageType extends MessageTypesWithNoSubscriptions>(message: TMessageType, request: RequestTypes[TMessageType]): Promise<ResponseTypes[TMessageType]>;
|
||||
@@ -77,10 +76,7 @@ function sendMessage<TMessageType extends MessageTypes> (message: TMessageType,
|
||||
|
||||
handlers[id] = { reject, resolve, subscriber };
|
||||
|
||||
ensurePortConnection(port, portConfig).then((connectedPort) => {
|
||||
connectedPort.postMessage({ id, message, request: request || {} });
|
||||
port = connectedPort;
|
||||
}).catch((error) => {
|
||||
client.send({ id, message, request: request || {} }, !!subscriber).catch((error) => {
|
||||
console.error(`Failed to send message: ${(error as Error).message}`);
|
||||
reject(error);
|
||||
});
|
||||
@@ -196,10 +192,66 @@ export async function rejectMetaRequest (id: string): Promise<boolean> {
|
||||
return sendMessage('pri(metadata.reject)', { id });
|
||||
}
|
||||
|
||||
export async function subscribeWallets (cb: (wallets: WalletInfo[]) => void): Promise<boolean> {
|
||||
return sendMessage('pri(wallets.subscribe)', null, cb);
|
||||
}
|
||||
|
||||
/** The account-0 addresses a recovery phrase or seed would give. Throws on anything else. */
|
||||
export async function previewWallet (secret: string): Promise<ResponseWalletPreview> {
|
||||
return sendMessage('pri(wallets.preview)', { secret });
|
||||
}
|
||||
|
||||
/** Resolves to the new wallet's id. */
|
||||
export async function createWallet (name: string, password: string, secret: string, genesisHash?: HexString | null): Promise<string> {
|
||||
return sendMessage('pri(wallets.create)', { genesisHash, name, password, secret });
|
||||
}
|
||||
|
||||
export async function addWalletAccount (id: string, password: string): Promise<boolean> {
|
||||
return sendMessage('pri(wallets.addAccount)', { id, password });
|
||||
}
|
||||
|
||||
export async function renameWallet (id: string, name: string): Promise<boolean> {
|
||||
return sendMessage('pri(wallets.rename)', { id, name });
|
||||
}
|
||||
|
||||
export async function forgetWallet (id: string): Promise<boolean> {
|
||||
return sendMessage('pri(wallets.forget)', { id });
|
||||
}
|
||||
|
||||
/** What one wallet account's wormhole addresses can still spend. Slow: seconds, not milliseconds. */
|
||||
export async function wormholeBalance (id: string, accountIndex: number, endpoint: string, observer: string): Promise<WormholeBalance> {
|
||||
return sendMessage('pri(wallets.wormholeBalance)', { accountIndex, endpoint, id, observer });
|
||||
}
|
||||
|
||||
export async function wormholeUnlock (id: string, accountIndex: number, password: string, counts: WormholeBalance['counts']): Promise<boolean> {
|
||||
return sendMessage('pri(wallets.wormholeUnlock)', { accountIndex, counts, id, password });
|
||||
}
|
||||
|
||||
export async function subscribeAccounts (cb: (accounts: AccountJson[]) => void): Promise<boolean> {
|
||||
return sendMessage('pri(accounts.subscribe)', null, cb);
|
||||
}
|
||||
|
||||
/**
|
||||
* Balances for every account, from the endpoint in settings.
|
||||
*
|
||||
* The endpoint travels with the request because `@polkadot/ui-settings` persists
|
||||
* to `localStorage`, which the MV3 service worker holding the connection does
|
||||
* not have.
|
||||
*/
|
||||
/** Resolves to the subscription's id, which `unsubscribeBalances` takes. */
|
||||
export async function subscribeBalances (endpoint: string, cb: (balances: AccountBalances) => void): Promise<string> {
|
||||
return sendMessage('pri(balances.subscribe)', { endpoint }, cb);
|
||||
}
|
||||
|
||||
export async function unsubscribeBalances (id: string): Promise<boolean> {
|
||||
// stop listening and stop replaying first: whatever the background says
|
||||
// next about this id is no longer wanted
|
||||
client.forget(id);
|
||||
delete handlers[id];
|
||||
|
||||
return sendMessage('pri(balances.unsubscribe)', { id });
|
||||
}
|
||||
|
||||
export async function subscribeAuthorizeRequests (cb: (accounts: AuthorizeRequest[]) => void): Promise<boolean> {
|
||||
return sendMessage('pri(authorize.requests)', null, cb);
|
||||
}
|
||||
@@ -236,14 +288,6 @@ export async function validateSeed (suri: string, type?: KeypairType): Promise<{
|
||||
return sendMessage('pri(seed.validate)', { suri, type });
|
||||
}
|
||||
|
||||
export async function validateDerivationPath (parentAddress: string, suri: string, parentPassword: string): Promise<ResponseDeriveValidate> {
|
||||
return sendMessage('pri(derivation.validate)', { parentAddress, parentPassword, suri });
|
||||
}
|
||||
|
||||
export async function deriveAccount (parentAddress: string, suri: string, parentPassword: string, name: string, password: string, genesisHash: HexString | null): Promise<boolean> {
|
||||
return sendMessage('pri(derivation.create)', { genesisHash, name, parentAddress, parentPassword, password, suri });
|
||||
}
|
||||
|
||||
export async function windowOpen (path: AllowedPath): Promise<boolean> {
|
||||
return sendMessage('pri(window.open)', path);
|
||||
}
|
||||
|
||||
@@ -1,16 +1,16 @@
|
||||
// Copyright 2019-2026 @polkadot/extension-ui authors & contributors
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
import { faArrowLeft, faCog, faPlusCircle, faSearch } from '@fortawesome/free-solid-svg-icons';
|
||||
import { faArrowLeft, faCog, faExpand, faPlusCircle, faSearch } from '@fortawesome/free-solid-svg-icons';
|
||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
||||
import React, { useCallback, useContext, useEffect, useMemo, useRef, useState } from 'react';
|
||||
|
||||
import logo from '../assets/pjs.svg';
|
||||
import logo from '../assets/sigil.svg';
|
||||
import { ActionContext } from '../components/index.js';
|
||||
import InputFilter from '../components/InputFilter.js';
|
||||
import Link from '../components/Link.js';
|
||||
import { useOutsideClick, useTranslation } from '../hooks/index.js';
|
||||
import { getConnectedTabsUrl } from '../messaging.js';
|
||||
import { useIsPopup, useOutsideClick, useTranslation } from '../hooks/index.js';
|
||||
import { getConnectedTabsUrl, windowOpen } from '../messaging.js';
|
||||
import { styled } from '../styled.js';
|
||||
import MenuAdd from './MenuAdd.js';
|
||||
import MenuSettings from './MenuSettings.js';
|
||||
@@ -35,6 +35,23 @@ function Header ({ children, className = '', onFilter, showAdd, showBackArrow, s
|
||||
const [filter, setFilter] = useState('');
|
||||
const [connectedTabsUrl, setConnectedTabsUrl] = useState<string[]>([]);
|
||||
const { t } = useTranslation();
|
||||
const isPopup = useIsPopup();
|
||||
|
||||
/**
|
||||
* Reopen the extension as an ordinary tab.
|
||||
*
|
||||
* This already existed, at the bottom of the settings menu. It is here as well
|
||||
* because of *when* somebody wants it: the popup closes the moment focus
|
||||
* leaves it, which is precisely while copying an address or a recovery phrase
|
||||
* between tabs — and at that point "open the menu, scroll, click" is two
|
||||
* interactions too many, each of which can itself dismiss the thing.
|
||||
*/
|
||||
const _onWindowOpen = useCallback(
|
||||
(): void => {
|
||||
windowOpen('/').catch(console.error);
|
||||
},
|
||||
[]
|
||||
);
|
||||
const addIconRef = useRef(null);
|
||||
const addMenuRef = useRef<HTMLDivElement>(null);
|
||||
const setIconRef = useRef(null);
|
||||
@@ -113,7 +130,7 @@ function Header ({ children, className = '', onFilter, showAdd, showBackArrow, s
|
||||
/>
|
||||
)
|
||||
}
|
||||
<span className='logoText'>{text || 'polkadot{.js}'}</span>
|
||||
<span className='logoText'>{text || 'blackbeard'}</span>
|
||||
</div>
|
||||
{showSearch && (
|
||||
<div className={`searchBarWrapper ${isSearchOpen ? 'selected' : ''}`}>
|
||||
@@ -145,6 +162,19 @@ function Header ({ children, className = '', onFilter, showAdd, showBackArrow, s
|
||||
</div>
|
||||
)}
|
||||
<div className='popupMenus'>
|
||||
{isPopup && (
|
||||
<div
|
||||
className='popupToggle'
|
||||
onClick={_onWindowOpen}
|
||||
title={t('Open in a tab')}
|
||||
>
|
||||
<FontAwesomeIcon
|
||||
className='expandIcon'
|
||||
icon={faExpand}
|
||||
size='lg'
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{showAdd && (
|
||||
<div
|
||||
className='popupToggle'
|
||||
@@ -288,7 +318,7 @@ export default React.memo(styled(Header)<Props>`
|
||||
}
|
||||
}
|
||||
|
||||
.plusIcon, .cogIcon, .searchIcon {
|
||||
.plusIcon, .cogIcon, .searchIcon, .expandIcon {
|
||||
color: var(--iconNeutralColor);
|
||||
|
||||
&.selected {
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
// Copyright 2019-2026 @polkadot/extension-ui authors & contributors
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
import { faCodeBranch, faEye, faFileExport, faFileUpload, faKey, faPlusCircle } from '@fortawesome/free-solid-svg-icons';
|
||||
import { faEye, faFileExport, faFileUpload, faKey, faPlusCircle } from '@fortawesome/free-solid-svg-icons';
|
||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
||||
import React, { useCallback, useContext } from 'react';
|
||||
import React, { useCallback } from 'react';
|
||||
|
||||
import { AccountContext, Link, Menu, MenuDivider, MenuItem } from '../components/index.js';
|
||||
import { Link, Menu, MenuDivider, MenuItem } from '../components/index.js';
|
||||
import { useIsPopup, useTranslation } from '../hooks/index.js';
|
||||
import { windowOpen } from '../messaging.js';
|
||||
import { styled } from '../styled.js';
|
||||
@@ -19,7 +19,6 @@ const jsonPath = '/account/restore-json';
|
||||
|
||||
function MenuAdd ({ className, reference }: Props): React.ReactElement<Props> {
|
||||
const { t } = useTranslation();
|
||||
const { master } = useContext(AccountContext);
|
||||
const isPopup = useIsPopup();
|
||||
|
||||
const _openJson = useCallback(
|
||||
@@ -40,17 +39,6 @@ function MenuAdd ({ className, reference }: Props): React.ReactElement<Props> {
|
||||
</Link>
|
||||
</MenuItem>
|
||||
<MenuDivider />
|
||||
{!!master && (
|
||||
<>
|
||||
<MenuItem className='menuItem'>
|
||||
<Link to={`/account/derive/${master.address}`}>
|
||||
<FontAwesomeIcon icon={faCodeBranch} />
|
||||
<span>{t('Derive from an account')}</span>
|
||||
</Link>
|
||||
</MenuItem>
|
||||
<MenuDivider />
|
||||
</>
|
||||
)}
|
||||
<MenuItem className='menuItem'>
|
||||
<Link to={'/account/export-all'}>
|
||||
<FontAwesomeIcon icon={faFileExport} />
|
||||
|
||||
@@ -4,13 +4,17 @@
|
||||
import { faExpand, faTasks } from '@fortawesome/free-solid-svg-icons';
|
||||
import React, { useCallback, useContext, useEffect, useMemo, useState } from 'react';
|
||||
|
||||
import { QUANTUS_ENDPOINTS } from '@polkadot/extension-base/defaults';
|
||||
import { settings } from '@polkadot/ui-settings';
|
||||
|
||||
import { ActionContext, ActionText, Checkbox, chooseTheme, Dropdown, Menu, MenuDivider, MenuItem, Switch, ThemeSwitchContext } from '../components/index.js';
|
||||
import { ActionContext, ActionText, Checkbox, chooseTheme, Dropdown, InputWithLabel, Menu, MenuDivider, MenuItem, Switch, ThemeSwitchContext } from '../components/index.js';
|
||||
import { useIsPopup, useTranslation } from '../hooks/index.js';
|
||||
import { setNotification, windowOpen } from '../messaging.js';
|
||||
import { styled } from '../styled.js';
|
||||
import { getBalanceEndpoint, setBalanceEndpoint } from '../util/balanceEndpoint.js';
|
||||
import { DEFAULT_PREFIX } from '../util/defaultPrefix.js';
|
||||
import getLanguageOptions from '../util/getLanguageOptions.js';
|
||||
import { getWormholeObserver, OBSERVER_OPTIONS, setWormholeObserver } from '../util/wormholeObserver.js';
|
||||
|
||||
interface Option {
|
||||
text: string;
|
||||
@@ -25,6 +29,19 @@ interface Props {
|
||||
const notificationOptions = ['Extension', 'PopUp', 'Window']
|
||||
.map((item) => ({ text: item, value: item.toLowerCase() }));
|
||||
|
||||
/**
|
||||
* Where to read balances from.
|
||||
*
|
||||
* Known endpoints plus "off" plus whatever the user types. Asking a node for
|
||||
* balances tells that node which accounts belong to one person; somebody who
|
||||
* would rather not say that to a default can point this at their own node, or
|
||||
* turn it off and have the extension make no network requests at all.
|
||||
*/
|
||||
const endpointOptions: Option[] = [
|
||||
...QUANTUS_ENDPOINTS,
|
||||
{ text: 'Off — do not read balances', value: '' }
|
||||
].map(({ text, value }): Option => ({ text, value }));
|
||||
|
||||
const prefixOptions = settings.availablePrefixes
|
||||
.filter(({ value }) => value !== -1)
|
||||
.map(({ text, value }): Option => ({ text, value: `${value}` }));
|
||||
@@ -32,7 +49,9 @@ const prefixOptions = settings.availablePrefixes
|
||||
function MenuSettings ({ className, reference }: Props): React.ReactElement<Props> {
|
||||
const { t } = useTranslation();
|
||||
const [camera, setCamera] = useState(settings.camera === 'on');
|
||||
const [prefix, setPrefix] = useState(`${settings.prefix === -1 ? 42 : settings.prefix}`);
|
||||
const [prefix, setPrefix] = useState(`${settings.prefix === -1 ? DEFAULT_PREFIX : settings.prefix}`);
|
||||
const [endpoint, setEndpoint] = useState(getBalanceEndpoint());
|
||||
const [observer, setObserver] = useState(getWormholeObserver());
|
||||
const [notification, updateNotification] = useState(settings.notification);
|
||||
const [theme, setTheme] = useState(chooseTheme());
|
||||
const setThemeContext = useContext(ThemeSwitchContext);
|
||||
@@ -51,6 +70,36 @@ function MenuSettings ({ className, reference }: Props): React.ReactElement<Prop
|
||||
}, []
|
||||
);
|
||||
|
||||
// The text field is authoritative; the dropdown only fills it. An endpoint the
|
||||
// list has never heard of is the point of the feature, not an edge case.
|
||||
const _onChangeEndpoint = useCallback(
|
||||
(value: string): void => {
|
||||
setEndpoint(value);
|
||||
setBalanceEndpoint(value);
|
||||
}, []
|
||||
);
|
||||
|
||||
// Typing is not choosing: saving each keystroke would connect to `w`, `ws`,
|
||||
// `ws:` and so on. The typed endpoint is used on Enter or on leaving the field.
|
||||
const _onCommitEndpoint = useCallback(
|
||||
(): void => {
|
||||
endpoint !== getBalanceEndpoint() && setBalanceEndpoint(endpoint);
|
||||
}, [endpoint]
|
||||
);
|
||||
|
||||
const _onChangeObserver = useCallback(
|
||||
(value: string): void => {
|
||||
setObserver(value);
|
||||
setWormholeObserver(value);
|
||||
}, []
|
||||
);
|
||||
|
||||
const _onCommitObserver = useCallback(
|
||||
(): void => {
|
||||
observer !== getWormholeObserver() && setWormholeObserver(observer);
|
||||
}, [observer]
|
||||
);
|
||||
|
||||
const _onChangeNotification = useCallback(
|
||||
(value: string): void => {
|
||||
setNotification(value).catch(console.error);
|
||||
@@ -116,6 +165,50 @@ function MenuSettings ({ className, reference }: Props): React.ReactElement<Prop
|
||||
value={`${prefix}`}
|
||||
/>
|
||||
</MenuItem>
|
||||
<MenuItem
|
||||
className='setting'
|
||||
title={t('Read balances from')}
|
||||
>
|
||||
<Dropdown
|
||||
className='dropdown'
|
||||
label=''
|
||||
onChange={_onChangeEndpoint}
|
||||
options={endpointOptions.some(({ value }) => value === endpoint)
|
||||
? endpointOptions
|
||||
: [...endpointOptions, { text: t('Custom'), value: endpoint }]}
|
||||
value={endpoint}
|
||||
/>
|
||||
<InputWithLabel
|
||||
label={t('or a node of your own')}
|
||||
onBlur={_onCommitEndpoint}
|
||||
onChange={setEndpoint}
|
||||
onEnter={_onCommitEndpoint}
|
||||
placeholder='wss://…'
|
||||
value={endpoint}
|
||||
/>
|
||||
</MenuItem>
|
||||
<MenuItem
|
||||
className='setting'
|
||||
title={t('Look up wormhole deposits with')}
|
||||
>
|
||||
<Dropdown
|
||||
className='dropdown'
|
||||
label=''
|
||||
onChange={_onChangeObserver}
|
||||
options={OBSERVER_OPTIONS.some(({ value }) => value === observer)
|
||||
? OBSERVER_OPTIONS
|
||||
: [...OBSERVER_OPTIONS, { text: t('Custom'), value: observer }]}
|
||||
value={observer}
|
||||
/>
|
||||
<InputWithLabel
|
||||
label={t('or an observer of your own')}
|
||||
onBlur={_onCommitObserver}
|
||||
onChange={setObserver}
|
||||
onEnter={_onCommitObserver}
|
||||
placeholder='https://…'
|
||||
value={observer}
|
||||
/>
|
||||
</MenuItem>
|
||||
<MenuItem
|
||||
className='setting'
|
||||
title={t('Language')}
|
||||
|
||||
69
packages/extension-ui/src/util/balanceEndpoint.ts
Normal file
@@ -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);
|
||||
};
|
||||
}
|
||||
@@ -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],
|
||||
|
||||
64
packages/extension-ui/src/util/decodeMethod.spec.ts
Normal file
@@ -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 });
|
||||
});
|
||||
});
|
||||
64
packages/extension-ui/src/util/decodeMethod.ts
Normal file
@@ -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;
|
||||
}
|
||||
}
|
||||
18
packages/extension-ui/src/util/defaultPrefix.ts
Normal file
@@ -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';
|
||||
|
||||
51
packages/extension-ui/src/util/formatBalance.spec.ts
Normal file
@@ -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');
|
||||
});
|
||||
});
|
||||
32
packages/extension-ui/src/util/formatBalance.ts
Normal file
@@ -0,0 +1,32 @@
|
||||
// Copyright 2019-2026 @polkadot/extension-ui authors & contributors
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
import type { AccountBalance } from '@polkadot/extension-base/background/types';
|
||||
|
||||
/** How many fractional digits to show. Twelve would be noise at a glance. */
|
||||
const SHOWN = 4;
|
||||
|
||||
/**
|
||||
* A balance, as a person reads it.
|
||||
*
|
||||
* Done with strings rather than numbers throughout. Quantus has **12 decimal
|
||||
* places**, so `2^53` smallest units is about 9 007 tokens: any balance above
|
||||
* that loses digits to a `Number`, quietly, on the one screen whose whole job is
|
||||
* telling somebody how much money they have.
|
||||
*
|
||||
* The fraction is truncated rather than rounded, so a displayed amount is never
|
||||
* more than the account actually holds.
|
||||
*/
|
||||
export function formatBalance ({ decimals, free, symbol }: AccountBalance): string {
|
||||
const negative = free.startsWith('-');
|
||||
const digits = (negative ? free.slice(1) : free).padStart(decimals + 1, '0');
|
||||
const whole = digits.slice(0, digits.length - decimals);
|
||||
const fraction = digits.slice(digits.length - decimals, digits.length - decimals + SHOWN).replace(/0+$/, '');
|
||||
|
||||
// Grouped, because `527355948904392` at 12 decimals is 527.3559 and the
|
||||
// difference between 527 and 5 270 is the sort of thing somebody should see
|
||||
// without counting characters.
|
||||
const grouped = whole.replace(/\B(?=(\d{3})+(?!\d))/g, ' ');
|
||||
|
||||
return `${negative ? '-' : ''}${grouped}${fraction ? `.${fraction}` : ''}${symbol ? ` ${symbol}` : ''}`;
|
||||
}
|
||||
@@ -1,32 +0,0 @@
|
||||
// Copyright 2019-2026 @polkadot/extension-ui authors & contributors
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
import type * as _ from '@polkadot/dev-test/globals.d.ts';
|
||||
|
||||
import { nextDerivationPath } from './nextDerivationPath.js';
|
||||
|
||||
describe('Generate Derivation Path', () => {
|
||||
const acc = (address: string, parentAddress?: string): {
|
||||
address: string;
|
||||
parentAddress?: string;
|
||||
} => ({
|
||||
address,
|
||||
parentAddress
|
||||
});
|
||||
|
||||
it('generates path for first masters child', () => {
|
||||
expect(nextDerivationPath([acc('a')], 'a')).toEqual('//0');
|
||||
});
|
||||
|
||||
it('generates path for third masters child', () => {
|
||||
expect(nextDerivationPath([acc('a'), acc('b', 'a'), acc('c', 'a')], 'a')).toEqual('//2');
|
||||
});
|
||||
|
||||
it('generates path for masters child when another root exists', () => {
|
||||
expect(nextDerivationPath([acc('a'), acc('b', 'a'), acc('c', 'a'), acc('d')], 'a')).toEqual('//2');
|
||||
});
|
||||
|
||||
it('generates path for masters grandchild', () => {
|
||||
expect(nextDerivationPath([acc('a'), acc('b', 'a'), acc('c', 'b'), acc('d', 'b')], 'b')).toEqual('//2');
|
||||
});
|
||||
});
|
||||
@@ -1,10 +0,0 @@
|
||||
// Copyright 2019-2026 @polkadot/extension-ui authors & contributors
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
import type { AccountJson } from '@polkadot/extension-base/background/types';
|
||||
|
||||
export function nextDerivationPath (accounts: AccountJson[], parentAddress: string): string {
|
||||
const siblingsCount = accounts.filter((account) => account.parentAddress === parentAddress).length;
|
||||
|
||||
return `//${siblingsCount}`;
|
||||
}
|
||||
58
packages/extension-ui/src/util/wormholeObserver.ts
Normal file
@@ -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);
|
||||
};
|
||||
}
|
||||
@@ -1,24 +1,35 @@
|
||||
{
|
||||
"author": "polkadot.{js}",
|
||||
"description": "Manage your Polkadot accounts outside of dapps. Injects the accounts and allows signing transactions for a specific account.",
|
||||
"homepage_url": "https://github.com/polkadot-js/extension",
|
||||
"name": "polkadot{.js} extension",
|
||||
"short_name": "polkadot{.js}",
|
||||
"author": "blackbeard",
|
||||
"description": "A post-quantum wallet for the Quantus Network: ML-DSA-65 and ML-DSA-87 accounts, wormhole balances, and signing for dapps.",
|
||||
"homepage_url": "https://git.lair.cafe/quantus/extension",
|
||||
"name": "blackbeard",
|
||||
"short_name": "blackbeard",
|
||||
"manifest_version": 3,
|
||||
"permissions": ["storage", "tabs"],
|
||||
"permissions": [
|
||||
"storage",
|
||||
"tabs",
|
||||
"unlimitedStorage"
|
||||
],
|
||||
"background": {
|
||||
"service_worker": "background.js",
|
||||
"type": "module"
|
||||
},
|
||||
"action": {
|
||||
"default_title": "polkadot{.js}",
|
||||
"default_title": "blackbeard",
|
||||
"default_popup": "index.html"
|
||||
},
|
||||
"content_scripts": [{
|
||||
"js": ["content.js"],
|
||||
"matches": ["http://*/*", "https://*/*"],
|
||||
"run_at": "document_start"
|
||||
}],
|
||||
"content_scripts": [
|
||||
{
|
||||
"js": [
|
||||
"content.js"
|
||||
],
|
||||
"matches": [
|
||||
"http://*/*",
|
||||
"https://*/*"
|
||||
],
|
||||
"run_at": "document_start"
|
||||
}
|
||||
],
|
||||
"icons": {
|
||||
"16": "images/icon-16.png",
|
||||
"32": "images/icon-32.png",
|
||||
|
||||
@@ -1,30 +1,43 @@
|
||||
{
|
||||
"author": "polkadot.{js}",
|
||||
"description": "Manage your Polkadot accounts outside of dapps. Injects the accounts and allows signing transactions for a specific account.",
|
||||
"homepage_url": "https://github.com/polkadot-js/extension",
|
||||
"name": "polkadot{.js} extension",
|
||||
"short_name": "polkadot{.js}",
|
||||
"author": "blackbeard",
|
||||
"description": "A post-quantum wallet for the Quantus Network: ML-DSA-65 and ML-DSA-87 accounts, wormhole balances, and signing for dapps.",
|
||||
"homepage_url": "https://git.lair.cafe/quantus/extension",
|
||||
"name": "blackbeard",
|
||||
"short_name": "blackbeard",
|
||||
"manifest_version": 3,
|
||||
"permissions": ["storage", "tabs"],
|
||||
"permissions": [
|
||||
"storage",
|
||||
"tabs",
|
||||
"unlimitedStorage"
|
||||
],
|
||||
"background": {
|
||||
"scripts": ["background.js"],
|
||||
"scripts": [
|
||||
"background.js"
|
||||
],
|
||||
"type": "module"
|
||||
},
|
||||
"action": {
|
||||
"default_title": "polkadot{.js}",
|
||||
"default_title": "blackbeard",
|
||||
"default_popup": "index.html"
|
||||
},
|
||||
"browser_specific_settings": {
|
||||
"gecko": {
|
||||
"id": "{7e3ce1f0-15fb-4fb1-99c6-25774749ec6d}",
|
||||
"id": "extension@blackbeard.observer",
|
||||
"strict_min_version": "108.0.2"
|
||||
}
|
||||
},
|
||||
"content_scripts": [{
|
||||
"js": ["content.js"],
|
||||
"matches": ["http://*/*", "https://*/*"],
|
||||
"run_at": "document_start"
|
||||
}],
|
||||
"content_scripts": [
|
||||
{
|
||||
"js": [
|
||||
"content.js"
|
||||
],
|
||||
"matches": [
|
||||
"http://*/*",
|
||||
"https://*/*"
|
||||
],
|
||||
"run_at": "document_start"
|
||||
}
|
||||
],
|
||||
"icons": {
|
||||
"16": "images/icon-16.png",
|
||||
"32": "images/icon-32.png",
|
||||
@@ -45,4 +58,4 @@
|
||||
"content_security_policy": {
|
||||
"extension_pages": "script-src 'self' 'wasm-unsafe-eval'; object-src 'self'"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
Before Width: | Height: | Size: 37 KiB After Width: | Height: | Size: 15 KiB |
|
Before Width: | Height: | Size: 5.0 KiB After Width: | Height: | Size: 21 KiB |
|
Before Width: | Height: | Size: 510 B After Width: | Height: | Size: 1.8 KiB |
|
Before Width: | Height: | Size: 1.1 KiB After Width: | Height: | Size: 4.0 KiB |
|
Before Width: | Height: | Size: 1.7 KiB After Width: | Height: | Size: 6.5 KiB |
|
Before Width: | Height: | Size: 2.0 KiB After Width: | Height: | Size: 9.2 KiB |
@@ -1,4 +0,0 @@
|
||||
<?xml version="1.0" encoding="utf-8" standalone="yes"?><!-- Generator: Adobe Illustrator 23.0.3, SVG Export Plug-In . SVG Version: 6.00 Build 0) --><svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" version="1.1" id="Layer_1" x="0px" y="0px" viewBox="15 15 140 140" style="enable-background:new 0 0 170 170;zoom: 1;" xml:space="preserve"><style type="text/css">
|
||||
.bg0{fill:#FF8C00}
|
||||
.st0{fill:#FFFFFF;}
|
||||
</style><g><circle class="bg0" cx="85" cy="85" r="70"></circle><g><path class="st0" d="M85,34.7c-20.8,0-37.8,16.9-37.8,37.8c0,4.2,0.7,8.3,2,12.3c0.9,2.7,3.9,4.2,6.7,3.3c2.7-0.9,4.2-3.9,3.3-6.7 c-1.1-3.1-1.6-6.4-1.5-9.7C58.1,57.6,69.5,46,83.6,45.3c15.7-0.8,28.7,11.7,28.7,27.2c0,14.5-11.4,26.4-25.7,27.2 c0,0-5.3,0.3-7.9,0.7c-1.3,0.2-2.3,0.4-3,0.5c-0.3,0.1-0.6-0.2-0.5-0.5l0.9-4.4L81,73.4c0.6-2.8-1.2-5.6-4-6.2 c-2.8-0.6-5.6,1.2-6.2,4c0,0-11.8,55-11.9,55.6c-0.6,2.8,1.2,5.6,4,6.2c2.8,0.6,5.6-1.2,6.2-4c0.1-0.6,1.7-7.9,1.7-7.9 c1.2-5.6,5.8-9.7,11.2-10.4c1.2-0.2,5.9-0.5,5.9-0.5c19.5-1.5,34.9-17.8,34.9-37.7C122.8,51.6,105.8,34.7,85,34.7z M87.7,121.7 c-3.4-0.7-6.8,1.4-7.5,4.9c-0.7,3.4,1.4,6.8,4.9,7.5c3.4,0.7,6.8-1.4,7.5-4.9C93.3,125.7,91.2,122.4,87.7,121.7z"></path></g></g></svg>
|
||||
|
Before Width: | Height: | Size: 1.2 KiB |
@@ -2,7 +2,7 @@
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<title>polkadot{.js}</title>
|
||||
<title>blackbeard</title>
|
||||
<link href="fonts/fonts.css" rel="stylesheet">
|
||||
<link href="theme.css" rel="stylesheet">
|
||||
<style type="text/css">
|
||||
|
||||
@@ -1,21 +1,40 @@
|
||||
{
|
||||
"Copied": "",
|
||||
"<unknown>": "",
|
||||
"hardware wallet account": "",
|
||||
"external account": "",
|
||||
"copy address": "",
|
||||
"account visibility": "",
|
||||
"An error occurred": "",
|
||||
"Something went wrong with the query and rendering of this component. {{message}}": "",
|
||||
"Back to home": "",
|
||||
"click to select or drag and drop the file here": "",
|
||||
"{{name}} ({{size}} bytes)": "",
|
||||
"Warning: Caps lock is on": "",
|
||||
"... loading ...": "",
|
||||
"Generated 12-word mnemonic seed:": "",
|
||||
"Copy to clipboard": "",
|
||||
"Back": "",
|
||||
"Create new account (root or derived)": "",
|
||||
"Allow use on any chain": "",
|
||||
"It looks like this request is coming from an suspicious origin. Please verify the source carefully.": "",
|
||||
"Select all": "",
|
||||
"Search by name or network...": "",
|
||||
"Create new account": "",
|
||||
"Derive from an account": "",
|
||||
"Export all accounts": "",
|
||||
"Import account from pre-existing seed": "",
|
||||
"Restore account from backup JSON file": "",
|
||||
"Attach external QR-signer account": "",
|
||||
"External QR accounts and Access": "",
|
||||
"Allow Camera Access": "",
|
||||
"Track an address": "",
|
||||
"Dark": "",
|
||||
"Light": "",
|
||||
"Display address format for": "",
|
||||
"Read balances from": "",
|
||||
"Custom": "",
|
||||
"or a node of your own": "",
|
||||
"Language": "",
|
||||
"Notifications": "",
|
||||
"External accounts and Access": "",
|
||||
"Allow QR Camera Access": "",
|
||||
"Manage Website Access": "",
|
||||
"Open extension in new window": "",
|
||||
"Account name is too short": "",
|
||||
"A descriptive name for your account": "",
|
||||
@@ -27,40 +46,61 @@
|
||||
"Derive New Account": "",
|
||||
"Export Account": "",
|
||||
"Forget Account": "",
|
||||
"Visible (always inject)": "",
|
||||
"Add Account": "",
|
||||
"You currently don't have any accounts. Create your first account to get started.": "",
|
||||
"Accounts": "",
|
||||
"Authorize": "",
|
||||
"Only approve this request if you trust the application. Approving gives the application access to the addresses of your accounts.": "",
|
||||
"Yes, allow this application access": "",
|
||||
"Add the account with the generated seed": "",
|
||||
"Accounts connected to {{url}}": "",
|
||||
"Connect {{total}} account(s)": "",
|
||||
"example.com": "",
|
||||
"No website request yet!": "",
|
||||
"{{total}} accounts": "",
|
||||
"all accounts": "",
|
||||
"no accounts": "",
|
||||
"Account connection request": "",
|
||||
"Previous": "",
|
||||
"Next": "",
|
||||
"Understood": "",
|
||||
"Reject": "",
|
||||
"Don't ask again": "",
|
||||
"Create an account": "",
|
||||
"Network": "",
|
||||
"Add the account with the generated seed": "",
|
||||
"Please write down your wallet's mnemonic seed and keep it in a safe place. The mnemonic can be used to restore your wallet. Keep it carefully to not lose your assets.": "",
|
||||
"I have saved my mnemonic seed safely.": "",
|
||||
"Next step": "",
|
||||
"Derivation Path": "",
|
||||
"Derivation Path (unlock to edit)": "",
|
||||
"Derivation Path": "",
|
||||
"//hard/soft": "",
|
||||
"//hard": "",
|
||||
"Add new account": "",
|
||||
"Create derived account": "",
|
||||
"Derive new account from existing": "",
|
||||
"`///password` not supported for derivation": "",
|
||||
"Soft derivation is only allowed for sr25519 accounts": "",
|
||||
"Invalid derivation path": "",
|
||||
"Choose Parent Account:": "",
|
||||
"enter the password for the account you want to derive from": "",
|
||||
"Wrong password": "",
|
||||
"Create a derived account": "",
|
||||
"Create account from new seed": "",
|
||||
"Export account": "",
|
||||
"You are exporting your account. Keep it safe and don't share it with anyone.": "",
|
||||
"password for this account": "",
|
||||
"I want to export this account": "",
|
||||
"Cancel": "",
|
||||
"All account": "",
|
||||
"password for encrypting all accounts": "",
|
||||
"I want to export all my accounts": "",
|
||||
"Forget account": "",
|
||||
"You are about to remove the account. This means that you will not be able to access it via this extension anymore. If you wish to recover it, you would need to use the seed.": "",
|
||||
"I want to forget this account": "",
|
||||
"Scan Address Qr": "",
|
||||
"Add the account with identified address": "",
|
||||
"Import account": "",
|
||||
"existing 12 or 24-word mnemonic seed": "",
|
||||
"Add the account with the supplied seed": "",
|
||||
"Invalid mnemonic seed or derivation path": "",
|
||||
"Invalid mnemonic seed": "",
|
||||
"existing 12 or 24-word mnemonic seed": "",
|
||||
"Mnemonic needs to contain 12, 15, 18, 21, 24 words": "",
|
||||
"advanced": "",
|
||||
"account index, e.g. //1 (leave empty for the first account)": "",
|
||||
"Each Quantus account derives from your recovery phrase on its own. To add a second account, enter the same phrase with //1, a third with //2, and so on.": "",
|
||||
"Metadata": "",
|
||||
"from": "",
|
||||
"chain": "",
|
||||
@@ -68,29 +108,39 @@
|
||||
"decimals": "",
|
||||
"symbol": "",
|
||||
"upgrade": "",
|
||||
"This approval will add the metadata to your extension instance, allowing future requests to be decoded using this metadata.": "",
|
||||
"Yes, do this metadata update": "",
|
||||
"Reject": "",
|
||||
"Phishing detected": "",
|
||||
"You have been redirected because the Polkadot{.js} extension believes that this website could compromise the security of your accounts and your tokens.": "",
|
||||
"Restore from JSON": "",
|
||||
"backup file": "",
|
||||
"Invalid Json file": "",
|
||||
"Password for this file": "",
|
||||
"Unable to decode using the supplied passphrase": "",
|
||||
"Restore": "",
|
||||
"bytes": "",
|
||||
"method data": "",
|
||||
"method": "",
|
||||
"info": "",
|
||||
"immortal": "",
|
||||
"mortal, valid from {{birth}} to {{death}}": "",
|
||||
"genesis": "",
|
||||
"version": "",
|
||||
"nonce": "",
|
||||
"tip": "",
|
||||
"Sign the transaction": "",
|
||||
"Sign the message": "",
|
||||
"assetId": "",
|
||||
"lifetime": "",
|
||||
"Transaction": "",
|
||||
"Sign message": "",
|
||||
"Scan signature via camera": "",
|
||||
"Don't ask me again for the next {{expiration}} minutes": "",
|
||||
"Sign the transaction": "",
|
||||
"Sign the message": "",
|
||||
"This account is tracked only — the extension holds no key for it and cannot sign.": "",
|
||||
"Remember my password for the next {{expiration}} minutes": "",
|
||||
"Extend the period without password by {{expiration}} minutes": "",
|
||||
"Password for this account": "",
|
||||
"This is not a valid address": "",
|
||||
"Paste the address you want to track": "",
|
||||
"This account has no key in the extension, so it cannot sign. You will see its balance and can use it as a recipient.": "",
|
||||
"Add the address": "",
|
||||
"Welcome": "",
|
||||
"Before we start, just a couple of notes regarding use:": "",
|
||||
"We do not send any clicks, pageviews or events to a central server": "",
|
||||
@@ -98,113 +148,53 @@
|
||||
"We don't collect keys, addresses or any information - your information never leaves this machine": "",
|
||||
"... we are not in the information collection business (even anonymized).": "",
|
||||
"Understood, let me continue": "",
|
||||
"An error occured": "",
|
||||
"Something went wrong with the query and rendering of this component. {{message}}": "",
|
||||
"Back to home": "",
|
||||
"external account": "",
|
||||
"Phishing detected": "",
|
||||
"Remember my password for the next {{expiration}} minutes": "",
|
||||
"Extend the period without password by {{expiration}} minutes": "",
|
||||
"You have been redirected because the Polkadot{.js} extension believes that this website could compromise the security of your accounts.": "",
|
||||
"The redirection could also happen on an outright malicious website or on a legitimate websites that has been compromised and flagged.": "",
|
||||
"This redirection is based on a list of websites accessible at https://github.com/polkadot-js/phishing. Note that this is a community-driven, curated list. \n It might be incomplete or inaccurate.": "",
|
||||
"copy address": "",
|
||||
"account visibility": "",
|
||||
"Wrong password": "",
|
||||
"Incorrect derivation path": "",
|
||||
"lifetime": "",
|
||||
"Warning: Caps lock is on": "",
|
||||
"Invalid Json file": "",
|
||||
"Unable to decode using the supplied passphrase": "",
|
||||
"Manage Website Access": "",
|
||||
"example.com": "",
|
||||
"No website request yet!": "",
|
||||
"allowed": "",
|
||||
"denied": "",
|
||||
"Derive from an account": "",
|
||||
"Dark": "",
|
||||
"Light": "",
|
||||
"Invalid mnemonic seed or derivation path": "",
|
||||
"Invalid mnemonic seed": "",
|
||||
"Mnemonic needs to contain 12, 15, 18, 21, 24 words": "",
|
||||
"advanced": "",
|
||||
"derivation path": "",
|
||||
"Next": "",
|
||||
"//hard": "",
|
||||
"`///password` not supported for derivation": "",
|
||||
"Soft derivation is only allowed for sr25519 accounts": "",
|
||||
"Invalid derivation path": "",
|
||||
"hardware wallet account": "",
|
||||
"Camera access must be first enabled in the settings": "",
|
||||
"Ledger devices can only be connected with Chrome browser": "",
|
||||
"Attach ledger account": "",
|
||||
"Connect Ledger device": "",
|
||||
"External accounts and Access": "",
|
||||
"Allow QR Camera Access": "",
|
||||
"Add Account": "",
|
||||
"Account type {{index}}": "",
|
||||
"Address index {{index}}": "",
|
||||
"Import Ledger Account": "",
|
||||
"Network": "",
|
||||
"account type": "",
|
||||
"address index": "",
|
||||
"Refresh": "",
|
||||
"Import Account": "",
|
||||
"Sign on Ledger": "",
|
||||
"Allow use on any chain": "",
|
||||
"Select network": "",
|
||||
"You have been redirected because the Polkadot{.js} extension believes that this website could compromise the security of your accounts and your tokens.": "",
|
||||
"Export all accounts": "",
|
||||
"All account": "",
|
||||
"password for encrypting all accounts": "",
|
||||
"I want to export all my accounts": "",
|
||||
"Notifications": "",
|
||||
"Search by name or network...": "",
|
||||
"Select all": "",
|
||||
"Accounts connected to {{url}}": "",
|
||||
"Connect {{total}} account(s)": "",
|
||||
"{{total}} accounts": "",
|
||||
"Account connection request": "",
|
||||
"Understood": "",
|
||||
"Ask again later": "",
|
||||
"no accounts": "",
|
||||
"all accounts": "",
|
||||
"Message signing is not supported for hardware wallets.": "",
|
||||
"\"Allow use on any network\" is not supported to show a QR code. You must associate this account with a network.": "",
|
||||
"An error occurred": "",
|
||||
"Is your ledger locked?": "",
|
||||
"App \"{{network}}\" does not seem to be open": "",
|
||||
"Ledger error: {{errorMessage}}": "",
|
||||
"assetId": "",
|
||||
"This approval will add the metadata to your extension instance, allowing future requests to be decoded using this metadata. It will also allow the use of Ledger's Generic Polkadot App.": "",
|
||||
"No metadata found for this chain. You must upload the metadata to the extension in order to use Ledger.": "",
|
||||
"This network is not available, please report an issue to update the known chains": "",
|
||||
"Ledger App": "",
|
||||
"Don't ask again": "",
|
||||
"Previous": "",
|
||||
"ED25519 Account": "",
|
||||
"Ethereum Account": "",
|
||||
"It looks like this request is coming from an suspicious origin. Please verify the source carefully.": "",
|
||||
"Password is not strong enough": "",
|
||||
"Password must be at least {{length}} characters long": "",
|
||||
"Asset Hub Migration Notice": "",
|
||||
"The Asset Hub migration has been completed. Please note the following important changes:": "",
|
||||
"All balances have been migrated from the Relay Chain to Asset Hub": "",
|
||||
"All on-chain functionality has been moved to Asset Hub": "",
|
||||
"Asset Hub now holds user balances and provides general functionality": "",
|
||||
"⚠️ Do not teleport balances to the Relay Chain unless:": "",
|
||||
"You are opening HRMP channels, or": "",
|
||||
"You are starting a Parachain": "",
|
||||
"For all other operations, your balances are already on Asset Hub.": "",
|
||||
"I Understand": "",
|
||||
"Do not teleport balances to the Relay Chain unless:": "",
|
||||
"Address mismatch: derived {{derived}}, expected {{expected}}. Check that the correct Ledger device is connected and the correct app is selected.": "",
|
||||
"Possible cause: the Ledger App setting differs from the app originally used to derive this account.": "",
|
||||
"This approval will add the metadata to your extension instance, allowing future requests to be decoded using this metadata.": "",
|
||||
"Track an address": "",
|
||||
"This account is tracked only — the extension holds no key for it and cannot sign.": "",
|
||||
"This is not a valid address": "",
|
||||
"Paste the address you want to track": "",
|
||||
"This account has no key in the extension, so it cannot sign. You will see its balance and can use it as a recipient.": "",
|
||||
"Add the address": ""
|
||||
"Open in a tab": "",
|
||||
"Key type": "",
|
||||
"The same recovery phrase gives a different account under each key type. Check the address above is the one you expect before continuing.": "",
|
||||
"Wormhole": "",
|
||||
"none: a raw seed has no wormhole account": "",
|
||||
"Rename wallet": "",
|
||||
"Add account": "",
|
||||
"Export this account": "",
|
||||
"Forget wallet": "",
|
||||
"Account {{index}}": "",
|
||||
"Receive address. Funds here leave only through a zero-knowledge proof, so this account has no key to sign with. Its balance and sending are not in the extension yet.": "",
|
||||
"Create a wallet": "",
|
||||
"Add the wallet with the generated recovery phrase": "",
|
||||
"Import wallet": "",
|
||||
"Add the wallet": "",
|
||||
"recovery phrase, or a 0x-prefixed 32-byte seed": "",
|
||||
"Account {{next}} of \"{{name}}\": new ML-DSA-65, ML-DSA-87 and wormhole accounts from the same recovery phrase. Enter the wallet password to derive them.": "",
|
||||
"Wallet password": "",
|
||||
"Add account {{next}}": "",
|
||||
"This wallet no longer exists.": "",
|
||||
"This removes the stored recovery phrase and all {{count}} accounts derived from it: each one's ML-DSA-65, ML-DSA-87 and wormhole account. Without the recovery phrase they cannot be recovered.": "",
|
||||
"This removes the stored recovery phrase or seed and every account derived from it. Without it they cannot be recovered.": "",
|
||||
"I want to forget this wallet": "",
|
||||
"Look up wormhole deposits with": "",
|
||||
"or an observer of your own": "",
|
||||
"Receive address. Funds here leave only through a zero-knowledge proof, so this account has no key to sign with, and sending from it is not in the extension yet.": "",
|
||||
"Balances are off in settings.": "",
|
||||
"Wormhole deposit lookups are off in settings.": "",
|
||||
"Checking deposits against the chain…": "",
|
||||
"Could not check the wormhole balance: {{message}}": "",
|
||||
"Retry": "",
|
||||
"At least": "",
|
||||
"spendable": "",
|
||||
"No deposits yet.": "",
|
||||
"{{deposits}} deposits, {{spent}} already exited.": "",
|
||||
"{{missing}} deposits are not indexed by the observer yet (it has read from block {{from}}); they are not counted.": "",
|
||||
"{{count}} deposits ({{amount}}) arrived after this wallet last computed its nullifiers, so whether they are spent is unknown.": "",
|
||||
"Unlock to check": "",
|
||||
"Checked {{at}}.": "",
|
||||
"Refresh": "",
|
||||
"Check newer deposits": "",
|
||||
"Whether a wormhole deposit is spent is decided by its nullifier, which only this wallet can compute. {{count}} deposits to \"{{name}}\" arrived after it last did. Enter the wallet password to compute theirs; they are stored with the wallet and removed if you forget it.": "",
|
||||
"Compute and check": "",
|
||||
"Open the wallet's wormhole tab first, so there is a balance to extend.": "",
|
||||
"You have been redirected because blackbeard believes that this website could compromise the security of your accounts and your tokens.": "",
|
||||
"No clicks, pageviews or events are sent anywhere, and there are no trackers or analytics.": "",
|
||||
"Keys and recovery phrases never leave this browser.": "",
|
||||
"To show balances, your addresses are sent to a Quantus node; to show wormhole balances, your wormhole addresses are sent to a blackbeard observer. Either can be pointed at your own server, or turned off, in settings.": ""
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<title>polkadot{.js}</title>
|
||||
<title>blackbeard</title>
|
||||
<link href="fonts/fonts.css" rel="stylesheet">
|
||||
<link href="theme.css" rel="stylesheet">
|
||||
</head>
|
||||
|
||||
@@ -28,15 +28,15 @@ div#root {
|
||||
--lineHeight: 26px;
|
||||
/* shared colors */
|
||||
--accountDotsIconColor: #8E8E8E;
|
||||
--buttonBackground: #E86F00;
|
||||
--buttonBackground: #A8761F;
|
||||
--buttonBackgroundDangerHover: #D93B3B;
|
||||
--buttonBackgroundHover: #ED9329;
|
||||
--buttonBackgroundHover: #BD8829;
|
||||
--buttonTextColor: #FFFFFF;
|
||||
--connectedDotColor: seagreen;
|
||||
--errorColor: #E42F2F;
|
||||
--iconWarningColor: #FF7D01;
|
||||
--iconWarningColor: #BD8829;
|
||||
--identiconBackground: #F4F5F8;
|
||||
--primaryColor: #FF7D01;
|
||||
--primaryColor: #BD8829;
|
||||
}
|
||||
|
||||
[data-theme="dark"] {
|
||||
|
||||