From 29033e8bb805cd7d75599f55996ea12414a725ae Mon Sep 17 00:00:00 2001 From: rob thijssen Date: Wed, 16 Sep 2026 21:28:01 +0300 Subject: [PATCH] feat: post-quantum only; no derivation, no MetaMask shim, no Ethereum addresses Moves to the quantus.3 forks of keyring, util-crypto, networks and ui-keyring, which hold only ML-DSA keys and have deleted the classical primitives (quantus/common#6). What in the extension depended on them goes too: - the derive-from-parent flow (Popup/Derive, pri(derivation.*), the AccountsContext master, the menu entries): ML-DSA pairs have no //hard/soft derivation, and Quantus derives every account from the mnemonic instead - extension-compat-metamask, which injected Ethereum accounts - the Ethereum branches in Address and the //m/44'/60' suri suffix - canDerive and nextDerivationPath, now unused Restoring a JSON backup of a classical key shows the keyring's reason ("ed25519 keys are not quantum-safe and cannot be held here...") rather than "Invalid Json file", for single and batch files. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_012uDUodEcRbBwNRi3UCmw8f --- package.json | 8 +- .../src/background/handlers/Extension.spec.ts | 80 +---- .../src/background/handlers/Extension.ts | 62 +--- .../extension-base/src/background/types.ts | 23 -- .../extension-base/src/utils/canDerive.ts | 19 +- packages/extension-base/src/utils/index.ts | 2 +- .../src/utils/quantusDefaults.spec.ts | 14 +- packages/extension-compat-metamask/README.md | 3 - .../extension-compat-metamask/package.json | 35 -- .../extension-compat-metamask/src/bundle.ts | 102 ------ .../extension-compat-metamask/src/index.ts | 7 - .../src/packageDetect.ts | 12 - .../src/packageInfo.ts | 6 - .../tsconfig.build.json | 11 - .../src/Popup/Accounts/Account.spec.tsx | 24 +- .../src/Popup/Accounts/Account.tsx | 14 +- .../src/Popup/Authorize/Authorize.spec.tsx | 6 +- .../src/Popup/Derive/AddressDropdown.tsx | 106 ------ .../src/Popup/Derive/DerivationPath.tsx | 110 ------ .../src/Popup/Derive/Derive.spec.tsx | 331 ------------------ .../src/Popup/Derive/SelectParent.tsx | 195 ----------- .../extension-ui/src/Popup/Derive/index.tsx | 104 ------ .../extension-ui/src/Popup/RestoreJson.tsx | 39 ++- packages/extension-ui/src/Popup/index.tsx | 6 - .../src/components/Address.spec.tsx | 21 +- .../extension-ui/src/components/Address.tsx | 37 +- .../extension-ui/src/components/contexts.tsx | 2 +- packages/extension-ui/src/messaging.ts | 10 +- .../extension-ui/src/partials/MenuAdd.tsx | 18 +- .../src/util/nextDerivationPath.spec.ts | 32 -- .../src/util/nextDerivationPath.ts | 10 - tsconfig.base.json | 1 - tsconfig.build.json | 1 - yarn.lock | 44 +-- 34 files changed, 102 insertions(+), 1393 deletions(-) delete mode 100644 packages/extension-compat-metamask/README.md delete mode 100644 packages/extension-compat-metamask/package.json delete mode 100644 packages/extension-compat-metamask/src/bundle.ts delete mode 100644 packages/extension-compat-metamask/src/index.ts delete mode 100644 packages/extension-compat-metamask/src/packageDetect.ts delete mode 100644 packages/extension-compat-metamask/src/packageInfo.ts delete mode 100644 packages/extension-compat-metamask/tsconfig.build.json delete mode 100644 packages/extension-ui/src/Popup/Derive/AddressDropdown.tsx delete mode 100644 packages/extension-ui/src/Popup/Derive/DerivationPath.tsx delete mode 100644 packages/extension-ui/src/Popup/Derive/Derive.spec.tsx delete mode 100644 packages/extension-ui/src/Popup/Derive/SelectParent.tsx delete mode 100644 packages/extension-ui/src/Popup/Derive/index.tsx delete mode 100644 packages/extension-ui/src/util/nextDerivationPath.spec.ts delete mode 100644 packages/extension-ui/src/util/nextDerivationPath.ts diff --git a/package.json b/package.json index e4268d6b..191af6b0 100644 --- a/package.json +++ b/package.json @@ -53,13 +53,13 @@ }, "resolutions": { "@polkadot/api": "^16.5.6", - "@polkadot/keyring": "https://git.lair.cafe/api/packages/quantus/npm/%40polkadot%2Fkeyring/-/14.0.3-quantus.2/keyring-14.0.3-quantus.2.tgz", - "@polkadot/networks": "https://git.lair.cafe/api/packages/quantus/npm/%40polkadot%2Fnetworks/-/14.0.3-quantus.2/networks-14.0.3-quantus.2.tgz", + "@polkadot/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.2/ui-keyring-3.16.7-quantus.2.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.2/util-crypto-14.0.3-quantus.2.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", diff --git a/packages/extension-base/src/background/handlers/Extension.spec.ts b/packages/extension-base/src/background/handlers/Extension.spec.ts index 2abe0d95..55dfb1d6 100644 --- a/packages/extension-base/src/background/handlers/Extension.spec.ts +++ b/packages/extension-base/src/background/handlers/Extension.spec.ts @@ -149,81 +149,11 @@ describe('Extension', () => { }); }); - describe('account derivation', () => { - let address: string; - - // An ed25519 parent, explicitly — which is what upstream's harness got by - // default, hence the vectors below. Naming it matters now: the default here - // is ML-DSA, as in production, and derivation is a property of the key type. - // Quantus derives every account from the mnemonic independently along - // m/44'/189189'/'/0'/', so an ML-DSA pair holds nothing a - // child could come from. See quantus/common#4 and `canDerive`. - beforeEach(async () => { - address = await createAccount('ed25519'); - }); - - it('refuses to derive from an ML-DSA parent', async () => { - const mldsa = await createAccount(); - - await expect(extension.handle('id', 'pri(derivation.validate)', { - parentAddress: mldsa, - parentPassword: password, - suri: '//path' - }, {} as chrome.runtime.Port)).rejects.toThrow(); - }); - - it('pri(derivation.validate) passes for valid suri', async () => { - const result = await extension.handle('id', 'pri(derivation.validate)', { - parentAddress: address, - parentPassword: password, - suri: '//path' - }, {} as chrome.runtime.Port); - - expect(result).toEqual({ - address: '5FP3TT3EruYBNh8YM8yoxsreMx7uZv1J1zNX7fFhoC5enwmN', - suri: '//path' - }); - }); - - 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('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 () => { - const before = keyring.getAccounts().length; - - await extension.handle('id', 'pri(derivation.create)', { - name: 'child', - parentAddress: address, - parentPassword: password, - password, - suri: '//path' - }, {} as chrome.runtime.Port); - expect(keyring.getAccounts()).toHaveLength(before + 1); - }); - - 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); - }); + // 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', () => { diff --git a/packages/extension-base/src/background/handlers/Extension.ts b/packages/extension-base/src/background/handlers/Extension.ts index 6cbbc9ba..90d81937 100644 --- a/packages/extension-base/src/background/handlers/Extension.ts +++ b/packages/extension-base/src/background/handlers/Extension.ts @@ -5,10 +5,9 @@ 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 { 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, RequestBalancesSubscribe, RequestBalancesUnsubscribe, RequestBatchRestore, RequestDeriveCreate, RequestDeriveValidate, RequestJsonRestore, RequestMetadataApprove, RequestMetadataReject, RequestSeedCreate, RequestSeedValidate, RequestSigningApprovePassword, RequestSigningApproveSignature, RequestSigningCancel, RequestSigningIsLocked, RequestTypes, RequestUpdateAuthorizedAccounts, RequestWalletAddAccount, RequestWalletCreate, RequestWalletForget, RequestWalletPreview, RequestWalletRename, RequestWormholeBalance, RequestWormholeUnlock, ResponseAccountExport, ResponseAccountsExport, ResponseAuthorizeList, ResponseDeriveValidate, ResponseJsonGetAccountInfo, ResponseSeedCreate, ResponseSeedValidate, ResponseSigningIsLocked, ResponseType, ResponseWalletPreview, SigningRequest, WalletInfo, WormholeBalance } from '../types.js'; +import type { 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'; @@ -29,13 +28,6 @@ type CachedUnlocks = Record; 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(); @@ -67,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; } @@ -418,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 }; } @@ -435,7 +427,7 @@ export default class Extension { } return { - address: keyring.createFromUri(getSuri(suri, type), {}, type).address, + address: keyring.createFromUri(suri, {}, type).address, suri }; } @@ -586,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 { const remAuth = await this.#state.removeAuthorization(url); @@ -771,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); diff --git a/packages/extension-base/src/background/types.ts b/packages/extension-base/src/background/types.ts index 6bd26e66..9deb5bc4 100644 --- a/packages/extension-base/src/background/types.ts +++ b/packages/extension-base/src/background/types.ts @@ -51,7 +51,6 @@ export type AccountWithChildren = AccountJson & { export interface AccountsContext { accounts: AccountJson[]; hierarchy: AccountWithChildren[]; - master?: AccountJson; selectedAccounts?: AccountJson['address'][]; setSelectedAccounts?: (address: AccountJson['address'][]) => void; } @@ -111,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]; @@ -345,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; @@ -499,11 +481,6 @@ export interface ResponseSigning { signedTransaction?: HexString; } -export interface ResponseDeriveValidate { - address: string; - suri: string; -} - export interface ResponseSeedCreate { address: string; seed: string; diff --git a/packages/extension-base/src/utils/canDerive.ts b/packages/extension-base/src/utils/canDerive.ts index dc337a9c..182fa5e5 100644 --- a/packages/extension-base/src/utils/canDerive.ts +++ b/packages/extension-base/src/utils/canDerive.ts @@ -4,10 +4,7 @@ import type { KeypairType } from '@polkadot/util-crypto/types'; /** Every keypair type this extension can hold and sign with. */ -const SIGNABLE: KeypairType[] = ['dilithium65', 'dilithium87', 'ecdsa', 'ed25519', 'ethereum', 'sr25519']; - -/** The types that support deriving a child account from a parent's key. */ -const DERIVABLE: KeypairType[] = ['ecdsa', 'ed25519', 'ethereum', 'sr25519']; +const SIGNABLE: KeypairType[] = ['dilithium65', 'dilithium87']; /** * Whether an account of this type should be offered to dapps. @@ -22,17 +19,3 @@ const DERIVABLE: KeypairType[] = ['ecdsa', 'ed25519', 'ethereum', 'sr25519']; export function canInject (type?: KeypairType): boolean { return !!type && SIGNABLE.includes(type); } - -/** - * Whether a child account can be derived from a parent of this type. - * - * False for ML-DSA, and permanently so. Quantus derives every account from the - * mnemonic independently along `m/44'/189189'/'/0'/'`, and a - * pair holds no material a child could come from — `pair.derive()` refuses - * outright (quantus/common#4). Adding another account therefore needs the - * recovery phrase, not an unlocked parent, which is why the derive-from-parent - * flow has no Quantus meaning. - */ -export function canDerive (type?: KeypairType): boolean { - return !!type && DERIVABLE.includes(type); -} diff --git a/packages/extension-base/src/utils/index.ts b/packages/extension-base/src/utils/index.ts index 5d23793d..3a45598c 100644 --- a/packages/extension-base/src/utils/index.ts +++ b/packages/extension-base/src/utils/index.ts @@ -1,5 +1,5 @@ // Copyright 2019-2026 @polkadot/extension-base authors & contributors // SPDX-License-Identifier: Apache-2.0 -export { canDerive, canInject } from './canDerive.js'; +export { canInject } from './canDerive.js'; export { isExtrinsicRequest } from './isExtrinsicRequest.js'; diff --git a/packages/extension-base/src/utils/quantusDefaults.spec.ts b/packages/extension-base/src/utils/quantusDefaults.spec.ts index 207513c9..416325b3 100644 --- a/packages/extension-base/src/utils/quantusDefaults.spec.ts +++ b/packages/extension-base/src/utils/quantusDefaults.spec.ts @@ -4,7 +4,7 @@ /// import { Keyring } from '@polkadot/keyring'; -import { canDerive, canInject } from './canDerive.js'; +import { canInject } from './canDerive.js'; describe('quantus defaults', (): void => { it('the default type produces a Quantus account', (): void => { @@ -24,18 +24,10 @@ describe('quantus defaults', (): void => { expect(zero).not.toEqual(one); }); - // The trap: canDerive gated dapp injection upstream. If that had stayed, no - // Quantus account would reach any dapp. - it('ML-DSA accounts are injected but not derivable', (): void => { + it('ML-DSA accounts are injected, and nothing else is', (): void => { expect(canInject('dilithium65')).toEqual(true); expect(canInject('dilithium87')).toEqual(true); - expect(canDerive('dilithium65')).toEqual(false); - expect(canDerive('dilithium87')).toEqual(false); - }); - - it('leaves the curve types alone', (): void => { - expect(canInject('sr25519')).toEqual(true); - expect(canDerive('sr25519')).toEqual(true); + expect(canInject('sr25519' as never)).toEqual(false); expect(canInject(undefined)).toEqual(false); }); }); diff --git a/packages/extension-compat-metamask/README.md b/packages/extension-compat-metamask/README.md deleted file mode 100644 index f5f91f27..00000000 --- a/packages/extension-compat-metamask/README.md +++ /dev/null @@ -1,3 +0,0 @@ -# @polkadot/extension-metamask-compat - -An optional metamask-compatible layer diff --git a/packages/extension-compat-metamask/package.json b/packages/extension-compat-metamask/package.json deleted file mode 100644 index 50cd2133..00000000 --- a/packages/extension-compat-metamask/package.json +++ /dev/null @@ -1,35 +0,0 @@ -{ - "author": "Jaco Greeff ", - "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": "*" - } -} diff --git a/packages/extension-compat-metamask/src/bundle.ts b/packages/extension-compat-metamask/src/bundle.ts deleted file mode 100644 index 434217d2..00000000 --- a/packages/extension-compat-metamask/src/bundle.ts +++ /dev/null @@ -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; - 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 => { - const providerRaw = await detectEthereumProvider({ mustBeMetaMask: true }); - const provider = isMetaMaskProvider(providerRaw); - - await provider.request({ method: 'eth_requestAccounts' }); - - return { - accounts: { - get: async (): Promise => { - 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 => { - 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 { - return new Promise((resolve): void => { - const win = window as Window & Web3Window; - - if (win.ethereum) { - injectMetaMaskWeb3(win); - resolve(true); - } else { - resolve(false); - } - }); -} diff --git a/packages/extension-compat-metamask/src/index.ts b/packages/extension-compat-metamask/src/index.ts deleted file mode 100644 index 8336e57c..00000000 --- a/packages/extension-compat-metamask/src/index.ts +++ /dev/null @@ -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'; diff --git a/packages/extension-compat-metamask/src/packageDetect.ts b/packages/extension-compat-metamask/src/packageDetect.ts deleted file mode 100644 index fcb41290..00000000 --- a/packages/extension-compat-metamask/src/packageDetect.ts +++ /dev/null @@ -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]); diff --git a/packages/extension-compat-metamask/src/packageInfo.ts b/packages/extension-compat-metamask/src/packageInfo.ts deleted file mode 100644 index eba7068f..00000000 --- a/packages/extension-compat-metamask/src/packageInfo.ts +++ /dev/null @@ -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' }; diff --git a/packages/extension-compat-metamask/tsconfig.build.json b/packages/extension-compat-metamask/tsconfig.build.json deleted file mode 100644 index b27ae679..00000000 --- a/packages/extension-compat-metamask/tsconfig.build.json +++ /dev/null @@ -1,11 +0,0 @@ -{ - "extends": "../../tsconfig.base.json", - "compilerOptions": { - "baseUrl": "..", - "outDir": "./build", - "rootDir": "./src" - }, - "references": [ - { "path": "../extension-inject/tsconfig.build.json" } - ] -} diff --git a/packages/extension-ui/src/Popup/Accounts/Account.spec.tsx b/packages/extension-ui/src/Popup/Accounts/Account.spec.tsx index 2d66c20b..436fa69d 100644 --- a/packages/extension-ui/src/Popup/Accounts/Account.spec.tsx +++ b/packages/extension-ui/src/Popup/Accounts/Account.spec.tsx @@ -47,20 +47,19 @@ describe('Account component', () => { ); 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 diff --git a/packages/extension-ui/src/Popup/Accounts/Account.tsx b/packages/extension-ui/src/Popup/Accounts/Account.tsx index 50f48d61..d9184829 100644 --- a/packages/extension-ui/src/Popup/Accounts/Account.tsx +++ b/packages/extension-ui/src/Popup/Accounts/Account.tsx @@ -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 { +function Account ({ address, className, genesisHash, isExternal, isHardware, isHidden, name, parentName, showVisibilityAction, suri, withCheckbox = false, withMenu = true }: Props): React.ReactElement { const { t } = useTranslation(); const [{ isEditing, toggleActions }, setEditing] = useState({ isEditing: false, toggleActions: 0 }); const [editedName, setName] = useState(name); @@ -84,14 +82,6 @@ function Account ({ address, className, genesisHash, isExternal, isHardware, isH > {t('Rename')} - {!isExternal && canDerive(type) && ( - - {t('Derive New Account')} - - )} {!isExternal && ( )} - ), [_onChangeGenesis, _toggleEdit, address, canEditGenesis, genesisHash, genesisOptions, isExternal, t, type]); + ), [_onChangeGenesis, _toggleEdit, address, canEditGenesis, genesisHash, genesisOptions, isExternal, t]); return (
diff --git a/packages/extension-ui/src/Popup/Authorize/Authorize.spec.tsx b/packages/extension-ui/src/Popup/Authorize/Authorize.spec.tsx index 4a88dc9d..47d50839 100644 --- a/packages/extension-ui/src/Popup/Authorize/Authorize.spec.tsx +++ b/packages/extension-ui/src/Popup/Authorize/Authorize.spec.tsx @@ -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', () => { diff --git a/packages/extension-ui/src/Popup/Derive/AddressDropdown.tsx b/packages/extension-ui/src/Popup/Derive/AddressDropdown.tsx deleted file mode 100644 index 8c2dbae4..00000000 --- a/packages/extension-ui/src/Popup/Derive/AddressDropdown.tsx +++ /dev/null @@ -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 { - const [isDropdownVisible, setDropdownVisible] = useState(false); - const ref = useRef(null); - - const _hideDropdown = useCallback(() => setDropdownVisible(false), []); - const _toggleDropdown = useCallback(() => setDropdownVisible(!isDropdownVisible), [isDropdownVisible]); - const _selectParent = useCallback((newParent: string) => () => onSelect(newParent), [onSelect]); - - useOutsideClick([ref], _hideDropdown); - - return ( -
-
-
-
-
- {allAddresses.map(([address, genesisHash]) => ( -
-
-
- ))} -
-
- ); -} - -export default styled(AddressDropdown)` - 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; - } - } -`; diff --git a/packages/extension-ui/src/Popup/Derive/DerivationPath.tsx b/packages/extension-ui/src/Popup/Derive/DerivationPath.tsx deleted file mode 100644 index c5a07b67..00000000 --- a/packages/extension-ui/src/Popup/Derive/DerivationPath.tsx +++ /dev/null @@ -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 { - const { t } = useTranslation(); - const [path, setPath] = useState(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 ( -
-
-
- -
- -
-
- ); -} - -export default React.memo(styled(DerivationPath)` - > .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%; - } - } -`); diff --git a/packages/extension-ui/src/Popup/Derive/Derive.spec.tsx b/packages/extension-ui/src/Popup/Derive/Derive.spec.tsx deleted file mode 100644 index fa892873..00000000 --- a/packages/extension-ui/src/Popup/Derive/Derive.spec.tsx +++ /dev/null @@ -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 => ({ -// __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; - }> => { - const onActionStub = jest.fn(); - - const wrapper = mount( - - - - - - - - - - ); - - await act(flushAllPromises); - - return { onActionStub, wrapper }; - }; - - let wrapper: ReactWrapper; - let onActionStub: ReturnType; - - const type = async (input: ReactWrapper, value: string): Promise => { - input.simulate('change', { target: { value } }); - await act(flushAllPromises); - input.update(); - }; - - const enterName = (name: string): Promise => type(wrapper.find('input').first(), name); - const password = (password: string) => (): Promise => type(wrapper.find('input[type="password"]').first(), password); - const repeat = (password: string) => (): Promise => 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'); - }); - }); -}); diff --git a/packages/extension-ui/src/Popup/Derive/SelectParent.tsx b/packages/extension-ui/src/Popup/Derive/SelectParent.tsx deleted file mode 100644 index 3e6c0212..00000000 --- a/packages/extension-ui/src/Popup/Derive/SelectParent.tsx +++ /dev/null @@ -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 { - 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(defaultPath); - const [parentPassword, setParentPassword] = useState(''); - const [isProperParentPassword, setIsProperParentPassword] = useState(false); - const [pathError, setPathError] = useState(''); - const passwordInputRef = useRef(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 => { - 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 ( - <> -
- {isLocked - ? ( -
- ) - : ( - - ) - } -
- - {!!parentPassword && !isProperParentPassword && ( - - {t('Wrong password')} - - )} -
- {isProperParentPassword && ( - <> - - {(!!pathError) && ( - - {pathError} - - )} - - )} -
- - - - {t('Create a derived account')} - - - - ); -} diff --git a/packages/extension-ui/src/Popup/Derive/index.tsx b/packages/extension-ui/src/Popup/Derive/index.tsx deleted file mode 100644 index 01833158..00000000 --- a/packages/extension-ui/src/Popup/Derive/index.tsx +++ /dev/null @@ -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 { - const { t } = useTranslation(); - const onAction = useContext(ActionContext); - const { accounts } = useContext(AccountContext); - const { address: parentAddress } = useParams(); - const [isBusy, setIsBusy] = useState(false); - const [account, setAccount] = useState(null); - const [name, setName] = useState(null); - const [parentPassword, setParentPassword] = useState(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 ( - <> - - {!account && ( - - )} - {account && ( - <> -
-
-
- - - )} - - ); -} - -export default React.memo(Derive); diff --git a/packages/extension-ui/src/Popup/RestoreJson.tsx b/packages/extension-ui/src/Popup/RestoreJson.tsx index 6f811b79..b37fb7e6 100644 --- a/packages/extension-ui/src/Popup/RestoreJson.tsx +++ b/packages/extension-ui/src/Popup/RestoreJson.tsx @@ -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([]); const [password, setPassword] = useState(''); - 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(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 { ))} - {isFileError && ( + {fileError && ( - {t('Invalid Json file')} + {fileError} )} {requirePassword && ( @@ -164,7 +181,7 @@ function Upload ({ className }: Props): React.ReactElement {