diff --git a/package.json b/package.json index 036a0643..0c1ab2a5 100644 --- a/package.json +++ b/package.json @@ -61,8 +61,8 @@ "@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/x-fetch": "^14.0.3", + "@quantus/crypto": "^0.2.0", "safe-buffer": "^5.2.1", - "typescript": "^5.5.4", - "@quantus/crypto": "^0.2.0" + "typescript": "^5.5.4" } } diff --git a/packages/extension-base/src/background/handlers/Extension.spec.ts b/packages/extension-base/src/background/handlers/Extension.spec.ts index f58ead34..1c41b622 100644 --- a/packages/extension-base/src/background/handlers/Extension.spec.ts +++ b/packages/extension-base/src/background/handlers/Extension.spec.ts @@ -513,7 +513,7 @@ describe('Extension', () => { it('adds the next account index only with the right password', async () => { const wallet = walletNamed('dev'); - assert(wallet); + 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); @@ -537,7 +537,7 @@ describe('Extension', () => { it('renames the wallet and the names its pairs carry to dapps', async () => { const wallet = walletNamed('alice'); - assert(wallet); + assert(wallet, 'wallet missing'); await extension.handle('id', 'pri(wallets.rename)', { id: wallet.id, name: 'crystal' }, port); expect(walletNamed('crystal')).toBeDefined(); @@ -547,7 +547,7 @@ describe('Extension', () => { it('forgets the wallet and every pair it owns', async () => { const wallet = walletNamed('dev'); - assert(wallet); + assert(wallet, 'wallet missing'); await extension.handle('id', 'pri(wallets.forget)', { id: wallet.id }, port); expect(walletNamed('dev')).toBeUndefined(); diff --git a/packages/extension-ui/src/Popup/Accounts/Wallet.tsx b/packages/extension-ui/src/Popup/Accounts/Wallet.tsx new file mode 100644 index 00000000..e937a25e --- /dev/null +++ b/packages/extension-ui/src/Popup/Accounts/Wallet.tsx @@ -0,0 +1,255 @@ +// 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'; + +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 (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 { + const { t } = useTranslation(); + const { accounts } = useContext(AccountContext); + const [tab, setTab] = useState(() => remembered(`quantus:walletTab:${wallet.id}`, 'mldsa65')); + const [index, setIndex] = useState(() => remembered(`quantus:walletIndex:${wallet.id}`, 0)); + const [isEditing, setEditing] = useState(false); + const [toggleActions, setToggleActions] = useState(0); + const [editedName, setEditedName] = useState(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) => { + 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 = ( + <> + + {t('Rename wallet')} + + {wallet.source === 'mnemonic' && ( + + {t('Add account')} + + )} + + {current !== 'wormhole' && address && ( + + {t('Export this account')} + + )} + + {t('Forget wallet')} + + + ); + + return ( +
+
+ {isEditing && ( + + )} +
+ {tabs.map(({ key, label }) => ( + + ))} + {wallet.accounts.length > 1 && ( + ({ text: t('Account {{index}}', { replace: { index: a.index } }), value: `${a.index}` }))} + value={`${account.index}`} + /> + )} +
+ {current === 'wormhole' && ( +
+ {t('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.')} +
+ )} +
+
+ ); +} + +export default styled(Wallet)` + 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; + } +`; diff --git a/packages/extension-ui/src/Popup/Accounts/index.tsx b/packages/extension-ui/src/Popup/Accounts/index.tsx index 222ccb88..77f6b672 100644 --- a/packages/extension-ui/src/Popup/Accounts/index.tsx +++ b/packages/extension-ui/src/Popup/Accounts/index.tsx @@ -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([]); 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) ? : ( <> @@ -56,6 +69,12 @@ function Accounts ({ className }: Props): React.ReactElement { text={t('Accounts')} />
+ {filteredWallets.map((wallet) => ( + + ))} {filteredAccount.map((json, index): React.ReactNode => ( (null); const [seed, setSeed] = useState(null); - const [type, setType] = useState(DEFAULT_TYPE); + const [preview, setPreview] = useState(null); const [name, setName] = useState(''); + const [error, setError] = useState(''); const options = useGenesisHashOptions(); const [genesisHash, setGenesis] = useState(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 }, []); - // Only the Ethereum case is decided by the network; otherwise the type is the - // user's choice between the two ML-DSA parameter sets and a network change - // must not reset it. - useEffect((): void => { - if (chain?.definition.chainType === 'ethereum') { - setType('ethereum'); - } else { - setType((current) => current === 'ethereum' ? DEFAULT_TYPE : current); - } - }, [chain]); - - // Re-derive on every type change. The phrase is the same either way; the - // account it produces is not, and the address preview has to show the one - // that will actually be created. - useEffect((): void => { - if (seed) { - validateSeed(seed, type) - .then(({ address }) => setAddress(address)) - .catch(console.error); - } - }, [seed, type]); - 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 ( <> -
-
-
+ {seed && ( step === 1 ? ( @@ -127,15 +96,9 @@ function CreateAccount ({ className }: Props): React.ReactElement { options={options} value={genesisHash} /> - void; - onAccountChange: (account: AccountInfo | null) => void; - type: KeypairType; - onTypeChange: (type: KeypairType) => void; + onWalletChange: (wallet: WalletSecret | null) => void; } -function SeedAndPath ({ className, onAccountChange, onNextStep, onTypeChange, 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(null); - const [path, setPath] = useState(null); - const [advanced, setAdvances] = useState(false); + const [secret, setSecret] = useState(''); const [error, setError] = useState(''); - const [genesis, setGenesis] = useState(''); + const [genesis, setGenesis] = useState(''); + 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({}, 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 ( <> @@ -74,18 +64,18 @@ function SeedAndPath ({ className, onAccountChange, onNextStep, onTypeChange, ty 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 && ( - {t('Mnemonic needs to contain 12, 15, 18, 21, 24 words')} + {error} )} - {/* Not under "advanced": choosing the wrong type imports a different - account from the same phrase, with nothing else on screen to say - so. The address preview above updates as this changes, and that - preview is what the warning points the reader at. */} - - - {t('The same recovery phrase gives a different account under each key type. Check the address above is the one you expect before continuing.')} - -
- - {t('advanced')} -
- { advanced && ( - <> - - - {t('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.')} - - - )} - {!!error && !!seed && ( - - {error} - - )}
{t('Next')} @@ -157,21 +100,6 @@ function SeedAndPath ({ className, onAccountChange, onNextStep, onTypeChange, ty } export default styled(SeedAndPath)` - .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); } diff --git a/packages/extension-ui/src/Popup/ImportSeed/index.tsx b/packages/extension-ui/src/Popup/ImportSeed/index.tsx index 67e303af..9a8b3e17 100644 --- a/packages/extension-ui/src/Popup/ImportSeed/index.tsx +++ b/packages/extension-ui/src/Popup/ImportSeed/index.tsx @@ -1,99 +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(null); + const [wallet, setWallet] = useState(null); const [name, setName] = useState(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]); - // Only the Ethereum case is decided by the network. Anything else is the - // user's choice between the two ML-DSA parameter sets, and resetting it on a - // network change would silently re-derive a different account. - useEffect((): void => { - if (chain?.definition.chainType === 'ethereum') { - setType('ethereum'); - } else { - setType((current) => current === 'ethereum' ? DEFAULT_TYPE : current); - } - }, [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 ( <> + -
-
-
{step1 ? ( ) : ( { + 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 { + 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 ( + <> +
+
+ {wallet + ? ( + <> +

+ {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 } })} +

+ + {error && ( + + {error} + + )} + + + ) + : {t('This wallet no longer exists.')} + } + + + +
+ + ); +} + +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; + } +`); diff --git a/packages/extension-ui/src/Popup/Wallet/Forget.tsx b/packages/extension-ui/src/Popup/Wallet/Forget.tsx new file mode 100644 index 00000000..8a686a37 --- /dev/null +++ b/packages/extension-ui/src/Popup/Wallet/Forget.tsx @@ -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 { + 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 ( + <> +
+
+ {wallet && ( + <> +

{wallet.name}

+ + {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.')} + + + + )} + + + +
+ + ); +} + +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; + } +`); diff --git a/packages/extension-ui/src/Popup/index.tsx b/packages/extension-ui/src/Popup/index.tsx index 83bc3504..a90b1b4f 100644 --- a/packages/extension-ui/src/Popup/index.tsx +++ b/packages/extension-ui/src/Popup/index.tsx @@ -1,7 +1,7 @@ // Copyright 2019-2026 @polkadot/extension-ui authors & contributors // SPDX-License-Identifier: Apache-2.0 -import type { AccountJson, AccountsContext, AuthorizeRequest, MetadataRequest, SigningRequest } from '@polkadot/extension-base/background/types'; +import type { AccountJson, AccountsContext, AuthorizeRequest, MetadataRequest, SigningRequest, WalletInfo } from '@polkadot/extension-base/background/types'; import type { SettingsStruct } from '@polkadot/ui-settings/types'; import React, { useCallback, useEffect, useState } from 'react'; @@ -11,11 +11,11 @@ import { PHISHING_PAGE_REDIRECT } from '@polkadot/extension-base/defaults'; import { canDerive } from '@polkadot/extension-base/utils'; import { settings } from '@polkadot/ui-settings'; -import { AccountContext, ActionContext, AuthorizeReqContext, BalanceContext, 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 { useBalances } from '../hooks/index.js'; -import { ping, subscribeAccounts, subscribeAuthorizeRequests, subscribeMetadataRequests, subscribeSigningRequests } from '../messaging.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'; @@ -26,6 +26,8 @@ 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 AddWalletAccount from './Wallet/AddAccount.js'; +import ForgetWallet from './Wallet/Forget.js'; import Export from './Export.js'; import ExportAll from './ExportAll.js'; import Forget from './Forget.js'; @@ -76,6 +78,7 @@ export default function Popup (): React.ReactElement { const [mediaAllowed, setMediaAllowed] = useState(false); const [metaRequests, setMetaRequests] = useState(null); const [signRequests, setSignRequests] = useState(null); + const [wallets, setWallets] = useState(null); const [isWelcomeDone, setWelcomeDone] = useState(false); const [settingsCtx, setSettingsCtx] = useState(startSettings); const history = useHistory(); @@ -105,7 +108,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 => { @@ -147,40 +151,44 @@ export default function Popup (): React.ReactElement { : wrapWithErrorBoundary(, 'accounts'); return ( - {accounts && authRequests && metaRequests && signRequests && ( + {accounts && authRequests && metaRequests && signRequests && wallets && ( - - - - - - {wrapWithErrorBoundary(, 'auth-list')} - {wrapWithErrorBoundary(, 'account-creation')} - {wrapWithErrorBoundary(, 'forget-address')} - {wrapWithErrorBoundary(, 'export-address')} - {wrapWithErrorBoundary(, 'export-all-address')} - {wrapWithErrorBoundary(, 'track-address')} - {wrapWithErrorBoundary(, 'import-seed')} - {wrapWithErrorBoundary(, 'restore-json')} - {wrapWithErrorBoundary(, 'derived-address-locked')} - {wrapWithErrorBoundary(, 'derive-address')} - {wrapWithErrorBoundary(, 'manage-url')} - {wrapWithErrorBoundary(, 'phishing-page-redirect')} - - {Root} - - - - - - + + + + + + + {wrapWithErrorBoundary(, 'auth-list')} + {wrapWithErrorBoundary(, 'account-creation')} + {wrapWithErrorBoundary(, 'wallet-add-account')} + {wrapWithErrorBoundary(, 'wallet-forget')} + {wrapWithErrorBoundary(, 'forget-address')} + {wrapWithErrorBoundary(, 'export-address')} + {wrapWithErrorBoundary(, 'export-all-address')} + {wrapWithErrorBoundary(, 'track-address')} + {wrapWithErrorBoundary(, 'import-seed')} + {wrapWithErrorBoundary(, 'restore-json')} + {wrapWithErrorBoundary(, 'derived-address-locked')} + {wrapWithErrorBoundary(, 'derive-address')} + {wrapWithErrorBoundary(, 'manage-url')} + {wrapWithErrorBoundary(, 'phishing-page-redirect')} + + {Root} + + + + + + + diff --git a/packages/extension-ui/src/components/AccountNamePasswordCreation.tsx b/packages/extension-ui/src/components/AccountNamePasswordCreation.tsx index 16debd67..d569aefa 100644 --- a/packages/extension-ui/src/components/AccountNamePasswordCreation.tsx +++ b/packages/extension-ui/src/components/AccountNamePasswordCreation.tsx @@ -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; @@ -15,7 +17,7 @@ interface Props { onPasswordChange?: (password: string) => void; } -function AccountNamePasswordCreation ({ buttonLabel, isBusy, onBackClick, onCreate, onNameChange, onPasswordChange }: Props): React.ReactElement { +function AccountNamePasswordCreation ({ buttonLabel, error, isBusy, onBackClick, onCreate, onNameChange, onPasswordChange }: Props): React.ReactElement { const [name, setName] = useState(null); const [password, setPassword] = useState(null); @@ -62,6 +64,11 @@ function AccountNamePasswordCreation ({ buttonLabel, isBusy, onBackClick, onCrea onChange={_onNameChange} /> + {error && ( + + {error} + + )} {onBackClick && buttonLabel && ( diff --git a/packages/extension-ui/src/components/Address.tsx b/packages/extension-ui/src/components/Address.tsx index d1a35930..0fe6a5a2 100644 --- a/packages/extension-ui/src/components/Address.tsx +++ b/packages/extension-ui/src/components/Address.tsx @@ -280,7 +280,10 @@ function Address ({ actions, address, children, className, genesisHash, isExtern title={t('copy address')} /> - {(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 && ( { + 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 ( +
+
+ {name || t('')} +
+ {rows.map(({ address, label }) => { + const formatted = address ? encodeAddress(decodeAddress(address), prefix) : null; + + return ( +
+ + {label} + + {formatted || (preview && label === t('Wormhole') + ? t('none: a raw seed has no wormhole account') + : t(''))} + +
+ ); + })} +
+ ); +} + +export default styled(WalletPreview)` + 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; + } +`; diff --git a/packages/extension-ui/src/components/contexts.tsx b/packages/extension-ui/src/components/contexts.tsx index 713629fb..9203273a 100644 --- a/packages/extension-ui/src/components/contexts.tsx +++ b/packages/extension-ui/src/components/contexts.tsx @@ -1,7 +1,7 @@ // Copyright 2019-2026 @polkadot/extension-ui authors & contributors // SPDX-License-Identifier: Apache-2.0 -import type { AccountBalances, 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'; @@ -17,6 +17,7 @@ const AuthorizeReqContext = React.createContext([]); // 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({}); +const WalletContext = React.createContext([]); const MediaContext = React.createContext(false); const MetadataReqContext = React.createContext([]); const SettingsContext = React.createContext(settings.get()); @@ -24,4 +25,4 @@ const SigningReqContext = React.createContext([]); const ThemeSwitchContext = React.createContext<(theme: Theme) => void>(noop); const ToastContext = React.createContext<({show: (message: string) => void})>({ show: noop }); -export { AccountContext, ActionContext, AuthorizeReqContext, BalanceContext, MediaContext, MetadataReqContext, SettingsContext, SigningReqContext, ThemeSwitchContext, ToastContext }; +export { AccountContext, ActionContext, AuthorizeReqContext, BalanceContext, MediaContext, MetadataReqContext, SettingsContext, SigningReqContext, ThemeSwitchContext, ToastContext, WalletContext }; diff --git a/packages/extension-ui/src/components/index.ts b/packages/extension-ui/src/components/index.ts index 8e0a921f..fcaabc29 100644 --- a/packages/extension-ui/src/components/index.ts +++ b/packages/extension-ui/src/components/index.ts @@ -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'; diff --git a/packages/extension-ui/src/messaging.ts b/packages/extension-ui/src/messaging.ts index 1ffcd892..9c3c1521 100644 --- a/packages/extension-ui/src/messaging.ts +++ b/packages/extension-ui/src/messaging.ts @@ -3,7 +3,7 @@ /* eslint-disable no-redeclare */ -import type { AccountBalances, AccountJson, AllowedPath, AuthorizeRequest, ConnectedTabsUrlResponse, MessageTypes, MessageTypesWithNoSubscriptions, MessageTypesWithNullRequest, MessageTypesWithSubscriptions, MetadataRequest, RequestTypes, ResponseAuthorizeList, ResponseDeriveValidate, ResponseJsonGetAccountInfo, ResponseSigningIsLocked, ResponseTypes, SeedLengths, SigningRequest, SubscriptionMessageTypes } from '@polkadot/extension-base/background/types'; +import type { AccountBalances, AccountJson, AllowedPath, AuthorizeRequest, ConnectedTabsUrlResponse, MessageTypes, MessageTypesWithNoSubscriptions, MessageTypesWithNullRequest, MessageTypesWithSubscriptions, MetadataRequest, RequestTypes, ResponseAuthorizeList, ResponseDeriveValidate, ResponseJsonGetAccountInfo, ResponseSigningIsLocked, ResponseTypes, ResponseWalletPreview, SeedLengths, SigningRequest, SubscriptionMessageTypes, WalletInfo } 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'; @@ -192,6 +192,32 @@ export async function rejectMetaRequest (id: string): Promise { return sendMessage('pri(metadata.reject)', { id }); } +export async function subscribeWallets (cb: (wallets: WalletInfo[]) => void): Promise { + 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 { + 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 { + return sendMessage('pri(wallets.create)', { genesisHash, name, password, secret }); +} + +export async function addWalletAccount (id: string, password: string): Promise { + return sendMessage('pri(wallets.addAccount)', { id, password }); +} + +export async function renameWallet (id: string, name: string): Promise { + return sendMessage('pri(wallets.rename)', { id, name }); +} + +export async function forgetWallet (id: string): Promise { + return sendMessage('pri(wallets.forget)', { id }); +} + export async function subscribeAccounts (cb: (accounts: AccountJson[]) => void): Promise { return sendMessage('pri(accounts.subscribe)', null, cb); } diff --git a/packages/extension-ui/src/util/keyTypes.ts b/packages/extension-ui/src/util/keyTypes.ts deleted file mode 100644 index 74da5333..00000000 --- a/packages/extension-ui/src/util/keyTypes.ts +++ /dev/null @@ -1,23 +0,0 @@ -// Copyright 2019-2026 @polkadot/extension-ui authors & contributors -// SPDX-License-Identifier: Apache-2.0 - -import type { KeypairType } from '@polkadot/util-crypto/types'; - -/** - * The key types a Quantus account can have. - * - * Exactly the variants of the runtime's `DilithiumSignatureScheme` — both are in - * use on the chain, so both have to be creatable and importable. Upstream never - * offered a choice here: it derived the type from the network, because each - * chain it supported had one valid type. Quantus has two on the same chain, so - * the network cannot decide and the user has to. - * - * The distinction is not cosmetic. The same recovery phrase or seed produces a - * **different account** under each type — crystal_alice's seed is a funded - * account as ML-DSA-87 and an empty one as ML-DSA-65 — so importing under the - * wrong type silently yields the wrong address. - */ -export const KEY_TYPE_OPTIONS: { text: string, value: KeypairType }[] = [ - { text: 'ML-DSA-65 (recommended)', value: 'dilithium65' }, - { text: 'ML-DSA-87', value: 'dilithium87' } -]; diff --git a/packages/extension/public/locales/en/translation.json b/packages/extension/public/locales/en/translation.json index a9fdf980..607ca067 100644 --- a/packages/extension/public/locales/en/translation.json +++ b/packages/extension/public/locales/en/translation.json @@ -151,5 +151,25 @@ "Password must be at least {{length}} characters long": "", "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.": "" + "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": "" }