feat: wallets in the popup, one card per secret with a tab per account
The account list shows each wallet as one card. Tabs switch between the ML-DSA-65, ML-DSA-87 and wormhole accounts its secret unlocks, and a selector switches between account indices when there is more than one. Pairs a wallet owns aren't listed again on their own; accounts from JSON or quantus-cli imports, and those made before wallets, still are. The last tab and index shown are remembered per wallet in browser storage. Import and create no longer ask for a key type, and import no longer takes a derivation path. A recovery phrase or 0x seed previews all three account-0 addresses before anything is saved, so a user can recognise their wallet by whichever address they know it by. This was the gap behind a wormhole account "importing" as the wrong address. More accounts come from the wallet's menu (Add account, password required), which replaces derive-from-parent. - The wallet menu offers rename, add account, export of the selected signing account, and forget wallet, which removes the stored secret and every pair. - The wormhole tab shows the receive address and says plainly that balance and sending aren't in the extension yet (#13, stage B of #14). - Address no longer shows its hide-from-dapps toggle for an address with no keyring account behind it, where it could only fail. - The name/password step can now show why creating failed, such as a secret that is already a wallet. Verified in Firefox against the built extension: - The chain node's TEST_MNEMONIC previews its TEST_WORMHOLE_ADDRESS (qzpWh4…) and TEST_ADDRESS_HD_0 (qzm5QC…, ML-DSA-87). The latter matches a vector these tests never used. - The zero seed previews crystal_alice with no wormhole tab, and importing it twice is refused. - Storage holds the secret only encrypted. - Add account refuses a wrong password, then adds account 1, and the selector switches to it. - Forget removes the wallet record and both of its keyring pairs, and leaves the other wallet alone. Part of quantus/extension#14. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012uDUodEcRbBwNRi3UCmw8f
This commit is contained in:
+2
-2
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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<T> (key: string, fallback: T): T {
|
||||
try {
|
||||
const value = localStorage.getItem(key);
|
||||
|
||||
return value === null ? fallback : JSON.parse(value) as T;
|
||||
} catch {
|
||||
return fallback;
|
||||
}
|
||||
}
|
||||
|
||||
function remember (key: string, value: unknown): void {
|
||||
try {
|
||||
localStorage.setItem(key, JSON.stringify(value));
|
||||
} catch {
|
||||
// storage denied: the choice lasts as long as the page does
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* One wallet: a secret, shown as the accounts it unlocks.
|
||||
*
|
||||
* Upstream showed one card per keyring pair. Here the two ML-DSA parameter sets
|
||||
* and the wormhole account are the same recovery phrase, and showing them as
|
||||
* unrelated cards made one wallet look like several (quantus/extension#14), so
|
||||
* they are tabs of one card instead.
|
||||
*/
|
||||
function Wallet ({ className, wallet }: Props): React.ReactElement<Props> {
|
||||
const { t } = useTranslation();
|
||||
const { accounts } = useContext(AccountContext);
|
||||
const [tab, setTab] = useState<Tab>(() => remembered(`quantus:walletTab:${wallet.id}`, 'mldsa65'));
|
||||
const [index, setIndex] = useState<number>(() => remembered(`quantus:walletIndex:${wallet.id}`, 0));
|
||||
const [isEditing, setEditing] = useState(false);
|
||||
const [toggleActions, setToggleActions] = useState(0);
|
||||
const [editedName, setEditedName] = useState<string | null>(wallet.name);
|
||||
|
||||
const account = wallet.accounts.find((a) => a.index === index) || wallet.accounts[0];
|
||||
const tabs = useMemo(() => TABS.filter(({ key }) => key !== 'wormhole' || account.wormhole), [account]);
|
||||
const current = tabs.some(({ key }) => key === tab) ? tab : 'mldsa65';
|
||||
const address = current === 'wormhole'
|
||||
? account.wormhole?.receive[0]
|
||||
: account[current];
|
||||
const pair = accounts.find((a) => a.address === address);
|
||||
const name = wallet.accounts.length > 1
|
||||
? `${wallet.name} #${account.index}`
|
||||
: wallet.name;
|
||||
|
||||
useEffect(() => setEditedName(wallet.name), [wallet.name]);
|
||||
|
||||
const _selectTab = useCallback(({ currentTarget }: React.MouseEvent<HTMLButtonElement>) => {
|
||||
const key = currentTarget.dataset['tab'] as Tab;
|
||||
|
||||
setTab(key);
|
||||
remember(`quantus:walletTab:${wallet.id}`, key);
|
||||
}, [wallet.id]);
|
||||
|
||||
const _selectIndex = useCallback((value: string) => {
|
||||
const next = parseInt(value, 10);
|
||||
|
||||
setIndex(next);
|
||||
remember(`quantus:walletIndex:${wallet.id}`, next);
|
||||
}, [wallet.id]);
|
||||
|
||||
const _toggleEdit = useCallback(() => {
|
||||
setEditing((editing) => !editing);
|
||||
setToggleActions((n) => n + 1);
|
||||
}, []);
|
||||
|
||||
const _saveName = useCallback(() => {
|
||||
editedName && editedName !== wallet.name &&
|
||||
renameWallet(wallet.id, editedName).catch(console.error);
|
||||
|
||||
setEditing(false);
|
||||
}, [editedName, wallet.id, wallet.name]);
|
||||
|
||||
const actions = (
|
||||
<>
|
||||
<Link
|
||||
className='menuItem'
|
||||
onClick={_toggleEdit}
|
||||
>
|
||||
{t('Rename wallet')}
|
||||
</Link>
|
||||
{wallet.source === 'mnemonic' && (
|
||||
<Link
|
||||
className='menuItem'
|
||||
to={`/wallet/add-account/${wallet.id}`}
|
||||
>
|
||||
{t('Add account')}
|
||||
</Link>
|
||||
)}
|
||||
<MenuDivider />
|
||||
{current !== 'wormhole' && address && (
|
||||
<Link
|
||||
className='menuItem'
|
||||
isDanger
|
||||
to={`/account/export/${address}`}
|
||||
>
|
||||
{t('Export this account')}
|
||||
</Link>
|
||||
)}
|
||||
<Link
|
||||
className='menuItem'
|
||||
isDanger
|
||||
to={`/wallet/forget/${wallet.id}`}
|
||||
>
|
||||
{t('Forget wallet')}
|
||||
</Link>
|
||||
</>
|
||||
);
|
||||
|
||||
return (
|
||||
<div className={className}>
|
||||
<Address
|
||||
actions={actions}
|
||||
address={address}
|
||||
genesisHash={wallet.genesisHash}
|
||||
isHidden={pair?.isHidden}
|
||||
name={editedName && isEditing ? editedName : name}
|
||||
toggleActions={toggleActions}
|
||||
>
|
||||
{isEditing && (
|
||||
<Name
|
||||
className='editName'
|
||||
isFocused
|
||||
label={' '}
|
||||
onBlur={_saveName}
|
||||
onChange={setEditedName}
|
||||
value={wallet.name}
|
||||
/>
|
||||
)}
|
||||
<div className='walletTabs'>
|
||||
{tabs.map(({ key, label }) => (
|
||||
<button
|
||||
className={`walletTab ${key === current ? 'selected' : ''}`}
|
||||
data-tab={key}
|
||||
key={key}
|
||||
onClick={_selectTab}
|
||||
type='button'
|
||||
>
|
||||
{label}
|
||||
</button>
|
||||
))}
|
||||
{wallet.accounts.length > 1 && (
|
||||
<Dropdown
|
||||
className='walletIndex'
|
||||
label=''
|
||||
onChange={_selectIndex}
|
||||
options={wallet.accounts.map((a) => ({ text: t('Account {{index}}', { replace: { index: a.index } }), value: `${a.index}` }))}
|
||||
value={`${account.index}`}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
{current === 'wormhole' && (
|
||||
<div className='walletNote'>
|
||||
{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.')}
|
||||
</div>
|
||||
)}
|
||||
</Address>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default styled(Wallet)<Props>`
|
||||
margin-bottom: 8px;
|
||||
|
||||
.editName {
|
||||
position: absolute;
|
||||
flex: 1;
|
||||
left: 70px;
|
||||
top: 10px;
|
||||
width: 350px;
|
||||
|
||||
input {
|
||||
height: 30px;
|
||||
width: 350px;
|
||||
}
|
||||
}
|
||||
|
||||
.walletTabs {
|
||||
align-items: center;
|
||||
border-top: 1px solid var(--boxBorderColor);
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
padding: 6px 8px;
|
||||
}
|
||||
|
||||
.walletTab {
|
||||
background: transparent;
|
||||
border: 1px solid transparent;
|
||||
border-radius: 4px;
|
||||
color: var(--labelColor);
|
||||
cursor: pointer;
|
||||
font-size: 13px;
|
||||
padding: 2px 8px;
|
||||
|
||||
&.selected {
|
||||
border-color: var(--primaryColor);
|
||||
color: var(--textColor);
|
||||
}
|
||||
}
|
||||
|
||||
.walletIndex {
|
||||
margin: 0 0 0 auto;
|
||||
max-width: 160px;
|
||||
|
||||
select {
|
||||
padding-right: 32px;
|
||||
}
|
||||
}
|
||||
|
||||
.walletNote {
|
||||
color: var(--labelColor);
|
||||
font-size: 12px;
|
||||
line-height: 16px;
|
||||
padding: 0 12px 8px;
|
||||
}
|
||||
|
||||
.menuItem {
|
||||
border-radius: 8px;
|
||||
display: block;
|
||||
font-size: 15px;
|
||||
line-height: 20px;
|
||||
margin: 0;
|
||||
min-width: 13rem;
|
||||
padding: 4px 16px;
|
||||
}
|
||||
`;
|
||||
@@ -7,12 +7,13 @@ import React, { useCallback, useContext, useEffect, useMemo, useState } from 're
|
||||
|
||||
import getNetworkMap from '@polkadot/extension-ui/util/getNetworkMap';
|
||||
|
||||
import { AccountContext } from '../../components/index.js';
|
||||
import { AccountContext, WalletContext } from '../../components/index.js';
|
||||
import { useTranslation } from '../../hooks/index.js';
|
||||
import { Header } from '../../partials/index.js';
|
||||
import { styled } from '../../styled.js';
|
||||
import AccountsTree from './AccountsTree.js';
|
||||
import AddAccount from './AddAccount.js';
|
||||
import Wallet from './Wallet.js';
|
||||
|
||||
interface Props {
|
||||
className?: string;
|
||||
@@ -23,19 +24,31 @@ function Accounts ({ className }: Props): React.ReactElement {
|
||||
const [filter, setFilter] = useState('');
|
||||
const [filteredAccount, setFilteredAccount] = useState<AccountWithChildren[]>([]);
|
||||
const { hierarchy } = useContext(AccountContext);
|
||||
const wallets = useContext(WalletContext);
|
||||
const networkMap = useMemo(() => getNetworkMap(), []);
|
||||
// Pairs a wallet owns are shown on the wallet's card, not again on their own.
|
||||
const standalone = useMemo(() => hierarchy.filter(({ walletId }) => !walletId), [hierarchy]);
|
||||
const filteredWallets = useMemo(() => filter
|
||||
? wallets.filter(({ accounts, genesisHash, name }) =>
|
||||
name.toLowerCase().includes(filter) ||
|
||||
(genesisHash && networkMap.get(genesisHash)?.toLowerCase().includes(filter)) ||
|
||||
accounts.some(({ mldsa65, mldsa87, wormhole }) =>
|
||||
[mldsa65, mldsa87, ...(wormhole?.receive ?? [])].some((a) => a.toLowerCase().includes(filter))
|
||||
)
|
||||
)
|
||||
: wallets, [filter, networkMap, wallets]);
|
||||
|
||||
useEffect(() => {
|
||||
setFilteredAccount(
|
||||
filter
|
||||
? hierarchy.filter((account) =>
|
||||
? standalone.filter((account) =>
|
||||
account.name?.toLowerCase().includes(filter) ||
|
||||
(account.genesisHash && networkMap.get(account.genesisHash)?.toLowerCase().includes(filter)) ||
|
||||
account.address.toLowerCase().includes(filter)
|
||||
)
|
||||
: hierarchy
|
||||
: standalone
|
||||
);
|
||||
}, [filter, hierarchy, networkMap]);
|
||||
}, [filter, standalone, networkMap]);
|
||||
|
||||
const _onFilter = useCallback((filter: string) => {
|
||||
setFilter(filter.toLowerCase());
|
||||
@@ -43,7 +56,7 @@ function Accounts ({ className }: Props): React.ReactElement {
|
||||
|
||||
return (
|
||||
<>
|
||||
{(hierarchy.length === 0)
|
||||
{(hierarchy.length === 0 && wallets.length === 0)
|
||||
? <AddAccount />
|
||||
: (
|
||||
<>
|
||||
@@ -56,6 +69,12 @@ function Accounts ({ className }: Props): React.ReactElement {
|
||||
text={t('Accounts')}
|
||||
/>
|
||||
<div className={className}>
|
||||
{filteredWallets.map((wallet) => (
|
||||
<Wallet
|
||||
key={wallet.id}
|
||||
wallet={wallet}
|
||||
/>
|
||||
))}
|
||||
{filteredAccount.map((json, index): React.ReactNode => (
|
||||
<AccountsTree
|
||||
{...json}
|
||||
|
||||
@@ -1,115 +1,84 @@
|
||||
// Copyright 2019-2026 @polkadot/extension-ui authors & contributors
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
import type { ResponseWalletPreview } from '@polkadot/extension-base/background/types';
|
||||
import type { HexString } from '@polkadot/util/types';
|
||||
|
||||
import React, { useCallback, useContext, useEffect, useState } from 'react';
|
||||
|
||||
import AccountNamePasswordCreation from '../../components/AccountNamePasswordCreation.js';
|
||||
import { ActionContext, Address, Dropdown, Loading } from '../../components/index.js';
|
||||
import { useGenesisHashOptions, useMetadata, useTranslation } from '../../hooks/index.js';
|
||||
import { createAccountSuri, createSeed, validateSeed } from '../../messaging.js';
|
||||
import { ActionContext, Dropdown, Loading, WalletPreview } from '../../components/index.js';
|
||||
import { useGenesisHashOptions, useTranslation } from '../../hooks/index.js';
|
||||
import { createSeed, createWallet, previewWallet } from '../../messaging.js';
|
||||
import { HeaderWithSteps } from '../../partials/index.js';
|
||||
import { styled } from '../../styled.js';
|
||||
import { DEFAULT_TYPE } from '../../util/defaultType.js';
|
||||
import { KEY_TYPE_OPTIONS } from '../../util/keyTypes.js';
|
||||
import Mnemonic from './Mnemonic.js';
|
||||
|
||||
interface Props {
|
||||
className?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a wallet from a new recovery phrase.
|
||||
*
|
||||
* The phrase becomes one wallet showing its ML-DSA-65, ML-DSA-87 and wormhole
|
||||
* accounts together, so there is no key type to choose. See quantus/extension#14.
|
||||
*/
|
||||
function CreateAccount ({ className }: Props): React.ReactElement {
|
||||
const { t } = useTranslation();
|
||||
const onAction = useContext(ActionContext);
|
||||
const [isBusy, setIsBusy] = useState(false);
|
||||
const [step, setStep] = useState(1);
|
||||
const [address, setAddress] = useState<null | string>(null);
|
||||
const [seed, setSeed] = useState<null | string>(null);
|
||||
const [type, setType] = useState(DEFAULT_TYPE);
|
||||
const [preview, setPreview] = useState<ResponseWalletPreview | null>(null);
|
||||
const [name, setName] = useState('');
|
||||
const [error, setError] = useState('');
|
||||
const options = useGenesisHashOptions();
|
||||
const [genesisHash, setGenesis] = useState<HexString | null>(null);
|
||||
const chain = useMetadata(genesisHash, true);
|
||||
|
||||
useEffect((): void => {
|
||||
createSeed(undefined)
|
||||
.then(({ address, seed }): void => {
|
||||
setAddress(address);
|
||||
.then(({ seed }) => {
|
||||
setSeed(seed);
|
||||
|
||||
return previewWallet(seed);
|
||||
})
|
||||
.then(setPreview)
|
||||
.catch(console.error);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
// 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 (
|
||||
<>
|
||||
<HeaderWithSteps
|
||||
step={step}
|
||||
text={t('Create an account')}
|
||||
text={t('Create a wallet')}
|
||||
/>
|
||||
<Loading>
|
||||
<div>
|
||||
<Address
|
||||
address={address}
|
||||
genesisHash={genesisHash}
|
||||
name={name}
|
||||
/>
|
||||
</div>
|
||||
<WalletPreview
|
||||
name={name}
|
||||
preview={preview}
|
||||
/>
|
||||
{seed && (
|
||||
step === 1
|
||||
? (
|
||||
@@ -127,15 +96,9 @@ function CreateAccount ({ className }: Props): React.ReactElement {
|
||||
options={options}
|
||||
value={genesisHash}
|
||||
/>
|
||||
<Dropdown
|
||||
className={className}
|
||||
label={t('Key type')}
|
||||
onChange={setType}
|
||||
options={KEY_TYPE_OPTIONS}
|
||||
value={type}
|
||||
/>
|
||||
<AccountNamePasswordCreation
|
||||
buttonLabel={t('Add the account with the generated seed')}
|
||||
buttonLabel={t('Add the wallet with the generated recovery phrase')}
|
||||
error={error}
|
||||
isBusy={isBusy}
|
||||
onBackClick={_onPreviousStep}
|
||||
onCreate={_onCreate}
|
||||
|
||||
@@ -1,71 +1,61 @@
|
||||
// Copyright 2019-2026 @polkadot/extension-ui authors & contributors
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
import type { KeypairType } from '@polkadot/util-crypto/types';
|
||||
import type { AccountInfo } from './index.js';
|
||||
import type { HexString } from '@polkadot/util/types';
|
||||
import type { WalletSecret } from './index.js';
|
||||
|
||||
import { faCaretDown, faCaretRight } from '@fortawesome/free-solid-svg-icons';
|
||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
||||
import React, { useCallback, useEffect, useState } from 'react';
|
||||
import React, { useEffect, useState } from 'react';
|
||||
|
||||
import { validateSeed } from '@polkadot/extension-ui/messaging';
|
||||
import { objectSpread } from '@polkadot/util';
|
||||
|
||||
import { ButtonArea, Dropdown, InputWithLabel, NextStepButton, TextAreaWithLabel, VerticalSpace, Warning } from '../../components/index.js';
|
||||
import { ButtonArea, Dropdown, NextStepButton, TextAreaWithLabel, VerticalSpace, Warning } from '../../components/index.js';
|
||||
import { useGenesisHashOptions, useTranslation } from '../../hooks/index.js';
|
||||
import { previewWallet } from '../../messaging.js';
|
||||
import { styled } from '../../styled.js';
|
||||
import { KEY_TYPE_OPTIONS } from '../../util/keyTypes.js';
|
||||
|
||||
interface Props {
|
||||
className?: string;
|
||||
onNextStep: () => 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<string | null>(null);
|
||||
const [path, setPath] = useState<string | null>(null);
|
||||
const [advanced, setAdvances] = useState(false);
|
||||
const [secret, setSecret] = useState('');
|
||||
const [error, setError] = useState('');
|
||||
const [genesis, setGenesis] = useState('');
|
||||
const [genesis, setGenesis] = useState<string>('');
|
||||
const [isValid, setIsValid] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
// No need to validate an empty seed
|
||||
// we have a dedicated error for this
|
||||
if (!seed) {
|
||||
onAccountChange(null);
|
||||
if (!secret.trim()) {
|
||||
setIsValid(false);
|
||||
setError('');
|
||||
onWalletChange(null);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
const suri = `${seed || ''}${path || ''}`;
|
||||
let isCurrent = true;
|
||||
|
||||
validateSeed(suri, type)
|
||||
.then((validatedAccount) => {
|
||||
setError('');
|
||||
setAddress(validatedAccount.address);
|
||||
onAccountChange(
|
||||
objectSpread<AccountInfo>({}, validatedAccount, { genesis, type })
|
||||
);
|
||||
previewWallet(secret)
|
||||
.then((preview) => {
|
||||
if (isCurrent) {
|
||||
setError('');
|
||||
setIsValid(true);
|
||||
onWalletChange({ genesis: (genesis || null) as HexString | null, preview, secret });
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
setAddress('');
|
||||
onAccountChange(null);
|
||||
setError(path
|
||||
? t('Invalid mnemonic seed or derivation path')
|
||||
: t('Invalid mnemonic seed')
|
||||
);
|
||||
.catch((error: Error) => {
|
||||
if (isCurrent) {
|
||||
setIsValid(false);
|
||||
setError(error.message);
|
||||
onWalletChange(null);
|
||||
}
|
||||
});
|
||||
}, [t, genesis, seed, path, onAccountChange, type]);
|
||||
|
||||
const _onToggleAdvanced = useCallback(() => {
|
||||
setAdvances(!advanced);
|
||||
}, [advanced]);
|
||||
return () => {
|
||||
isCurrent = false;
|
||||
};
|
||||
}, [genesis, onWalletChange, secret]);
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -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 && (
|
||||
<Warning
|
||||
className='seedError'
|
||||
isBelowInput
|
||||
isDanger
|
||||
>
|
||||
{t('Mnemonic needs to contain 12, 15, 18, 21, 24 words')}
|
||||
{error}
|
||||
</Warning>
|
||||
)}
|
||||
<Dropdown
|
||||
@@ -95,58 +85,11 @@ function SeedAndPath ({ className, onAccountChange, onNextStep, onTypeChange, ty
|
||||
options={genesisOptions}
|
||||
value={genesis}
|
||||
/>
|
||||
{/* 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. */}
|
||||
<Dropdown
|
||||
className='keyTypeSelection'
|
||||
label={t('Key type')}
|
||||
onChange={onTypeChange}
|
||||
options={KEY_TYPE_OPTIONS}
|
||||
value={type}
|
||||
/>
|
||||
<Warning>
|
||||
{t('The same recovery phrase gives a different account under each key type. Check the address above is the one you expect before continuing.')}
|
||||
</Warning>
|
||||
<div
|
||||
className='advancedToggle'
|
||||
onClick={_onToggleAdvanced}
|
||||
>
|
||||
<FontAwesomeIcon icon={advanced ? faCaretDown : faCaretRight} />
|
||||
<span>{t('advanced')}</span>
|
||||
</div>
|
||||
{ advanced && (
|
||||
<>
|
||||
<InputWithLabel
|
||||
className='derivationPath'
|
||||
isError={!!path && !!error}
|
||||
// Quantus derives each account from the phrase independently, so
|
||||
// this is an account index rather than a junction chain — see
|
||||
// quantus/common#4 for the accepted forms. Leaving it empty gives
|
||||
// account 0, which is what every other Quantus tool calls the
|
||||
// first account.
|
||||
label={t('account index, e.g. //1 (leave empty for the first account)')}
|
||||
onChange={setPath}
|
||||
value={path || ''}
|
||||
/>
|
||||
<Warning>
|
||||
{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.')}
|
||||
</Warning>
|
||||
</>
|
||||
)}
|
||||
{!!error && !!seed && (
|
||||
<Warning
|
||||
isDanger
|
||||
>
|
||||
{error}
|
||||
</Warning>
|
||||
)}
|
||||
</div>
|
||||
<VerticalSpace />
|
||||
<ButtonArea>
|
||||
<NextStepButton
|
||||
isDisabled={!address || !!error}
|
||||
isDisabled={!isValid}
|
||||
onClick={onNextStep}
|
||||
>
|
||||
{t('Next')}
|
||||
@@ -157,21 +100,6 @@ function SeedAndPath ({ className, onAccountChange, onNextStep, onTypeChange, ty
|
||||
}
|
||||
|
||||
export default styled(SeedAndPath)<Props>`
|
||||
.advancedToggle {
|
||||
color: var(--textColor);
|
||||
cursor: pointer;
|
||||
line-height: var(--lineHeight);
|
||||
letter-spacing: 0.04em;
|
||||
opacity: 0.65;
|
||||
text-transform: uppercase;
|
||||
|
||||
> span {
|
||||
font-size: var(--inputLabelFontSize);
|
||||
margin-left: .5rem;
|
||||
vertical-align: middle;
|
||||
}
|
||||
}
|
||||
|
||||
.genesisSelection {
|
||||
margin-bottom: var(--fontSize);
|
||||
}
|
||||
|
||||
@@ -1,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<AccountInfo | null>(null);
|
||||
const [wallet, setWallet] = useState<WalletSecret | null>(null);
|
||||
const [name, setName] = useState<string | null>(null);
|
||||
const [error, setError] = useState('');
|
||||
const [step1, setStep1] = useState(true);
|
||||
const [type, setType] = useState(DEFAULT_TYPE);
|
||||
const chain = useMetadata(account?.genesis, true);
|
||||
|
||||
useEffect((): void => {
|
||||
!accounts.length && onAction();
|
||||
}, [accounts, onAction]);
|
||||
|
||||
// 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 (
|
||||
<>
|
||||
<HeaderWithSteps
|
||||
step={step1 ? 1 : 2}
|
||||
text={t('Import account')}
|
||||
text={t('Import wallet')}
|
||||
/>
|
||||
<WalletPreview
|
||||
name={name}
|
||||
preview={wallet?.preview ?? null}
|
||||
/>
|
||||
<div>
|
||||
<Address
|
||||
address={account?.address}
|
||||
genesisHash={account?.genesis}
|
||||
name={name}
|
||||
/>
|
||||
</div>
|
||||
{step1
|
||||
? (
|
||||
<SeedAndPath
|
||||
onAccountChange={setAccount}
|
||||
onNextStep={_onNextStep}
|
||||
onTypeChange={setType}
|
||||
type={type}
|
||||
onWalletChange={setWallet}
|
||||
/>
|
||||
)
|
||||
: (
|
||||
<AccountNamePasswordCreation
|
||||
buttonLabel={t('Add the account with the supplied seed')}
|
||||
buttonLabel={t('Add the wallet')}
|
||||
error={error}
|
||||
isBusy={isBusy}
|
||||
onBackClick={_onBackClick}
|
||||
onCreate={_onCreate}
|
||||
|
||||
@@ -0,0 +1,127 @@
|
||||
// Copyright 2019-2026 @polkadot/extension-ui authors & contributors
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
import type { RouteComponentProps } from 'react-router';
|
||||
|
||||
import React, { useCallback, useContext, useState } from 'react';
|
||||
import { withRouter } from 'react-router';
|
||||
|
||||
import { ActionBar, ActionContext, ActionText, Button, InputWithLabel, WalletContext, Warning } from '../../components/index.js';
|
||||
import { useTranslation } from '../../hooks/index.js';
|
||||
import { addWalletAccount } from '../../messaging.js';
|
||||
import { Header } from '../../partials/index.js';
|
||||
import { styled } from '../../styled.js';
|
||||
|
||||
interface Props extends RouteComponentProps<{ id: string }> {
|
||||
className?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Add the next account index to a wallet.
|
||||
*
|
||||
* Asks for the password because it has to: a new index means deriving new keys
|
||||
* from the recovery phrase, which is stored encrypted. It replaces upstream's
|
||||
* "derive from a parent account", which has no meaning for lattice keys.
|
||||
*/
|
||||
function AddAccount ({ className, match: { params: { id } } }: Props): React.ReactElement<Props> {
|
||||
const { t } = useTranslation();
|
||||
const onAction = useContext(ActionContext);
|
||||
const wallet = useContext(WalletContext).find((w) => w.id === id);
|
||||
const [password, setPassword] = useState('');
|
||||
const [error, setError] = useState('');
|
||||
const [isBusy, setIsBusy] = useState(false);
|
||||
const next = wallet ? Math.max(...wallet.accounts.map((a) => a.index)) + 1 : 0;
|
||||
|
||||
const _goHome = useCallback(() => onAction('/'), [onAction]);
|
||||
|
||||
const _onChange = useCallback((value: string) => {
|
||||
setPassword(value);
|
||||
setError('');
|
||||
}, []);
|
||||
|
||||
const _onAdd = useCallback(() => {
|
||||
setIsBusy(true);
|
||||
addWalletAccount(id, password)
|
||||
.then(() => onAction('/'))
|
||||
.catch((error: Error) => {
|
||||
setError(error.message);
|
||||
setIsBusy(false);
|
||||
});
|
||||
}, [id, onAction, password]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Header
|
||||
showBackArrow
|
||||
text={t('Add account')}
|
||||
/>
|
||||
<div className={className}>
|
||||
{wallet
|
||||
? (
|
||||
<>
|
||||
<p className='explain'>
|
||||
{t('Account {{next}} of "{{name}}": new ML-DSA-65, ML-DSA-87 and wormhole accounts from the same recovery phrase. Enter the wallet password to derive them.', { replace: { name: wallet.name, next } })}
|
||||
</p>
|
||||
<InputWithLabel
|
||||
disabled={isBusy}
|
||||
isError={!!error}
|
||||
isFocused
|
||||
label={t('Wallet password')}
|
||||
onChange={_onChange}
|
||||
onEnter={_onAdd}
|
||||
type='password'
|
||||
value={password}
|
||||
/>
|
||||
{error && (
|
||||
<Warning
|
||||
isBelowInput
|
||||
isDanger
|
||||
>
|
||||
{error}
|
||||
</Warning>
|
||||
)}
|
||||
<Button
|
||||
className='action'
|
||||
isBusy={isBusy}
|
||||
isDisabled={!password}
|
||||
onClick={_onAdd}
|
||||
>
|
||||
{t('Add account {{next}}', { replace: { next } })}
|
||||
</Button>
|
||||
</>
|
||||
)
|
||||
: <Warning isDanger>{t('This wallet no longer exists.')}</Warning>
|
||||
}
|
||||
<ActionBar className='withMarginTop'>
|
||||
<ActionText
|
||||
className='center'
|
||||
onClick={_goHome}
|
||||
text={t('Cancel')}
|
||||
/>
|
||||
</ActionBar>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export default withRouter(styled(AddAccount)`
|
||||
padding: 0 24px;
|
||||
|
||||
.explain {
|
||||
color: var(--labelColor);
|
||||
font-size: 14px;
|
||||
line-height: 20px;
|
||||
}
|
||||
|
||||
.action {
|
||||
margin-top: 16px;
|
||||
}
|
||||
|
||||
.center {
|
||||
margin: auto;
|
||||
}
|
||||
|
||||
.withMarginTop {
|
||||
margin-top: 4px;
|
||||
}
|
||||
`);
|
||||
@@ -0,0 +1,99 @@
|
||||
// Copyright 2019-2026 @polkadot/extension-ui authors & contributors
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
import type { RouteComponentProps } from 'react-router';
|
||||
|
||||
import React, { useCallback, useContext, useState } from 'react';
|
||||
import { withRouter } from 'react-router';
|
||||
|
||||
import { ActionBar, ActionContext, ActionText, Button, WalletContext, Warning } from '../../components/index.js';
|
||||
import { useTranslation } from '../../hooks/index.js';
|
||||
import { forgetWallet } from '../../messaging.js';
|
||||
import { Header } from '../../partials/index.js';
|
||||
import { styled } from '../../styled.js';
|
||||
|
||||
interface Props extends RouteComponentProps<{ id: string }> {
|
||||
className?: string;
|
||||
}
|
||||
|
||||
function Forget ({ className, match: { params: { id } } }: Props): React.ReactElement<Props> {
|
||||
const { t } = useTranslation();
|
||||
const onAction = useContext(ActionContext);
|
||||
const wallet = useContext(WalletContext).find((w) => w.id === id);
|
||||
const [isBusy, setIsBusy] = useState(false);
|
||||
|
||||
const _goHome = useCallback(() => onAction('/'), [onAction]);
|
||||
|
||||
const _onForget = useCallback(() => {
|
||||
setIsBusy(true);
|
||||
forgetWallet(id)
|
||||
.then(() => onAction('/'))
|
||||
.catch((error: Error) => {
|
||||
setIsBusy(false);
|
||||
console.error(error);
|
||||
});
|
||||
}, [id, onAction]);
|
||||
|
||||
const count = wallet?.accounts.length ?? 0;
|
||||
|
||||
return (
|
||||
<>
|
||||
<Header
|
||||
showBackArrow
|
||||
text={t('Forget wallet')}
|
||||
/>
|
||||
<div className={className}>
|
||||
{wallet && (
|
||||
<>
|
||||
<p className='name'>{wallet.name}</p>
|
||||
<Warning className='movedWarning'>
|
||||
{count > 1
|
||||
? t('This removes the stored recovery phrase and all {{count}} accounts derived from it: each one\'s ML-DSA-65, ML-DSA-87 and wormhole account. Without the recovery phrase they cannot be recovered.', { replace: { count } })
|
||||
: t('This removes the stored recovery phrase or seed and every account derived from it. Without it they cannot be recovered.')}
|
||||
</Warning>
|
||||
<Button
|
||||
className='action'
|
||||
isBusy={isBusy}
|
||||
isDanger
|
||||
onClick={_onForget}
|
||||
>
|
||||
{t('I want to forget this wallet')}
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
<ActionBar className='withMarginTop'>
|
||||
<ActionText
|
||||
className='center'
|
||||
onClick={_goHome}
|
||||
text={t('Cancel')}
|
||||
/>
|
||||
</ActionBar>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export default withRouter(styled(Forget)`
|
||||
padding: 0 24px;
|
||||
|
||||
.name {
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.action {
|
||||
margin-top: 16px;
|
||||
}
|
||||
|
||||
.center {
|
||||
margin: auto;
|
||||
}
|
||||
|
||||
.movedWarning {
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.withMarginTop {
|
||||
margin-top: 4px;
|
||||
}
|
||||
`);
|
||||
@@ -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 | MetadataRequest[]>(null);
|
||||
const [signRequests, setSignRequests] = useState<null | SigningRequest[]>(null);
|
||||
const [wallets, setWallets] = useState<null | WalletInfo[]>(null);
|
||||
const [isWelcomeDone, setWelcomeDone] = useState(false);
|
||||
const [settingsCtx, setSettingsCtx] = useState<SettingsStruct>(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 />, 'accounts');
|
||||
|
||||
return (
|
||||
<Loading>{accounts && authRequests && metaRequests && signRequests && (
|
||||
<Loading>{accounts && authRequests && metaRequests && signRequests && wallets && (
|
||||
<ActionContext.Provider value={_onAction}>
|
||||
<SettingsContext.Provider value={settingsCtx}>
|
||||
<AccountContext.Provider value={accountCtx}>
|
||||
<AuthorizeReqContext.Provider value={authRequests}>
|
||||
<BalanceContext.Provider value={balances}>
|
||||
<MediaContext.Provider value={cameraOn && mediaAllowed}>
|
||||
<MetadataReqContext.Provider value={metaRequests}>
|
||||
<SigningReqContext.Provider value={signRequests}>
|
||||
<ToastProvider>
|
||||
<Switch>
|
||||
<Route path='/auth-list'>{wrapWithErrorBoundary(<AuthList />, 'auth-list')}</Route>
|
||||
<Route path='/account/create'>{wrapWithErrorBoundary(<CreateAccount />, 'account-creation')}</Route>
|
||||
<Route path='/account/forget/:address'>{wrapWithErrorBoundary(<Forget />, 'forget-address')}</Route>
|
||||
<Route path='/account/export/:address'>{wrapWithErrorBoundary(<Export />, 'export-address')}</Route>
|
||||
<Route path='/account/export-all'>{wrapWithErrorBoundary(<ExportAll />, 'export-all-address')}</Route>
|
||||
<Route path='/account/track-address'>{wrapWithErrorBoundary(<TrackAddress />, 'track-address')}</Route>
|
||||
<Route path='/account/import-seed'>{wrapWithErrorBoundary(<ImportSeed />, 'import-seed')}</Route>
|
||||
<Route path='/account/restore-json'>{wrapWithErrorBoundary(<RestoreJson />, 'restore-json')}</Route>
|
||||
<Route path='/account/derive/:address/locked'>{wrapWithErrorBoundary(<Derive isLocked />, 'derived-address-locked')}</Route>
|
||||
<Route path='/account/derive/:address'>{wrapWithErrorBoundary(<Derive />, 'derive-address')}</Route>
|
||||
<Route path='/url/manage/:url'>{wrapWithErrorBoundary(<AccountManagement />, 'manage-url')}</Route>
|
||||
<Route path={`${PHISHING_PAGE_REDIRECT}/:website`}>{wrapWithErrorBoundary(<PhishingDetected />, 'phishing-page-redirect')}</Route>
|
||||
<Route
|
||||
exact
|
||||
path='/'
|
||||
>
|
||||
{Root}
|
||||
</Route>
|
||||
</Switch>
|
||||
</ToastProvider>
|
||||
</SigningReqContext.Provider>
|
||||
</MetadataReqContext.Provider>
|
||||
</MediaContext.Provider>
|
||||
<WalletContext.Provider value={wallets}>
|
||||
<MediaContext.Provider value={cameraOn && mediaAllowed}>
|
||||
<MetadataReqContext.Provider value={metaRequests}>
|
||||
<SigningReqContext.Provider value={signRequests}>
|
||||
<ToastProvider>
|
||||
<Switch>
|
||||
<Route path='/auth-list'>{wrapWithErrorBoundary(<AuthList />, 'auth-list')}</Route>
|
||||
<Route path='/account/create'>{wrapWithErrorBoundary(<CreateAccount />, 'account-creation')}</Route>
|
||||
<Route path='/wallet/add-account/:id'>{wrapWithErrorBoundary(<AddWalletAccount />, 'wallet-add-account')}</Route>
|
||||
<Route path='/wallet/forget/:id'>{wrapWithErrorBoundary(<ForgetWallet />, 'wallet-forget')}</Route>
|
||||
<Route path='/account/forget/:address'>{wrapWithErrorBoundary(<Forget />, 'forget-address')}</Route>
|
||||
<Route path='/account/export/:address'>{wrapWithErrorBoundary(<Export />, 'export-address')}</Route>
|
||||
<Route path='/account/export-all'>{wrapWithErrorBoundary(<ExportAll />, 'export-all-address')}</Route>
|
||||
<Route path='/account/track-address'>{wrapWithErrorBoundary(<TrackAddress />, 'track-address')}</Route>
|
||||
<Route path='/account/import-seed'>{wrapWithErrorBoundary(<ImportSeed />, 'import-seed')}</Route>
|
||||
<Route path='/account/restore-json'>{wrapWithErrorBoundary(<RestoreJson />, 'restore-json')}</Route>
|
||||
<Route path='/account/derive/:address/locked'>{wrapWithErrorBoundary(<Derive isLocked />, 'derived-address-locked')}</Route>
|
||||
<Route path='/account/derive/:address'>{wrapWithErrorBoundary(<Derive />, 'derive-address')}</Route>
|
||||
<Route path='/url/manage/:url'>{wrapWithErrorBoundary(<AccountManagement />, 'manage-url')}</Route>
|
||||
<Route path={`${PHISHING_PAGE_REDIRECT}/:website`}>{wrapWithErrorBoundary(<PhishingDetected />, 'phishing-page-redirect')}</Route>
|
||||
<Route
|
||||
exact
|
||||
path='/'
|
||||
>
|
||||
{Root}
|
||||
</Route>
|
||||
</Switch>
|
||||
</ToastProvider>
|
||||
</SigningReqContext.Provider>
|
||||
</MetadataReqContext.Provider>
|
||||
</MediaContext.Provider>
|
||||
</WalletContext.Provider>
|
||||
</BalanceContext.Provider>
|
||||
</AuthorizeReqContext.Provider>
|
||||
</AccountContext.Provider>
|
||||
|
||||
@@ -4,10 +4,12 @@
|
||||
import React, { useCallback, useState } from 'react';
|
||||
|
||||
import { Name, Password } from '../partials/index.js';
|
||||
import { BackButton, ButtonArea, NextStepButton, VerticalSpace } from './index.js';
|
||||
import { BackButton, ButtonArea, NextStepButton, VerticalSpace, Warning } from './index.js';
|
||||
|
||||
interface Props {
|
||||
buttonLabel?: string;
|
||||
/** Why the last attempt to create failed, shown above the buttons. */
|
||||
error?: string;
|
||||
isBusy: boolean;
|
||||
onBackClick?: () => void;
|
||||
onCreate: (name: string, password: string) => void | Promise<void | boolean>;
|
||||
@@ -15,7 +17,7 @@ interface Props {
|
||||
onPasswordChange?: (password: string) => void;
|
||||
}
|
||||
|
||||
function AccountNamePasswordCreation ({ buttonLabel, isBusy, onBackClick, onCreate, onNameChange, onPasswordChange }: Props): React.ReactElement<Props> {
|
||||
function AccountNamePasswordCreation ({ buttonLabel, error, isBusy, onBackClick, onCreate, onNameChange, onPasswordChange }: Props): React.ReactElement<Props> {
|
||||
const [name, setName] = useState<string | null>(null);
|
||||
const [password, setPassword] = useState<string | null>(null);
|
||||
|
||||
@@ -62,6 +64,11 @@ function AccountNamePasswordCreation ({ buttonLabel, isBusy, onBackClick, onCrea
|
||||
onChange={_onNameChange}
|
||||
/>
|
||||
<Password onChange={_onPasswordChange} />
|
||||
{error && (
|
||||
<Warning isDanger>
|
||||
{error}
|
||||
</Warning>
|
||||
)}
|
||||
<VerticalSpace />
|
||||
{onBackClick && buttonLabel && (
|
||||
<ButtonArea>
|
||||
|
||||
@@ -280,7 +280,10 @@ function Address ({ actions, address, children, className, genesisHash, isExtern
|
||||
title={t('copy address')}
|
||||
/>
|
||||
</CopyToClipboard>
|
||||
{(actions || showVisibilityAction) && (
|
||||
{/* Visibility is a keyring flag: it hides an account from dapps. An
|
||||
address with no keyring account behind it (a wormhole address)
|
||||
has nothing to hide, and toggling it would only error. */}
|
||||
{(actions || showVisibilityAction) && account && (
|
||||
<FontAwesomeIcon
|
||||
className={isHidden ? 'hiddenIcon' : 'visibleIcon'}
|
||||
icon={isHidden ? faEyeSlash : faEye}
|
||||
|
||||
@@ -0,0 +1,120 @@
|
||||
// Copyright 2019-2026 @polkadot/extension-ui authors & contributors
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
import type { ResponseWalletPreview } from '@polkadot/extension-base/background/types';
|
||||
|
||||
import React, { useContext } from 'react';
|
||||
|
||||
import { decodeAddress, encodeAddress } from '@polkadot/util-crypto';
|
||||
|
||||
import { useTranslation } from '../hooks/index.js';
|
||||
import { styled } from '../styled.js';
|
||||
import { DEFAULT_PREFIX } from '../util/defaultPrefix.js';
|
||||
import { SettingsContext } from './contexts.js';
|
||||
import Identicon from './Identicon.js';
|
||||
|
||||
interface Props {
|
||||
className?: string;
|
||||
name?: string | null;
|
||||
preview: ResponseWalletPreview | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* The three account-0 addresses a secret gives, before anything is saved.
|
||||
*
|
||||
* All three, because a person may know their wallet by any of them: the account
|
||||
* they signed with in quantus-cli (ML-DSA-65 or ML-DSA-87, depending on when it
|
||||
* was made), or the wormhole address their mining rewards went to. Showing one
|
||||
* and deriving the others silently is how an import used to look wrong.
|
||||
*/
|
||||
function WalletPreview ({ className, name, preview }: Props): React.ReactElement<Props> {
|
||||
const { t } = useTranslation();
|
||||
const settings = useContext(SettingsContext);
|
||||
const prefix = settings.prefix === -1 ? DEFAULT_PREFIX : settings.prefix;
|
||||
const rows = [
|
||||
{ address: preview?.mldsa65, label: 'ML-DSA-65' },
|
||||
{ address: preview?.mldsa87, label: 'ML-DSA-87' },
|
||||
{ address: preview?.wormhole, label: t('Wormhole') }
|
||||
];
|
||||
|
||||
return (
|
||||
<div className={className}>
|
||||
<div
|
||||
className='name'
|
||||
data-field='name'
|
||||
>
|
||||
{name || t('<unknown>')}
|
||||
</div>
|
||||
{rows.map(({ address, label }) => {
|
||||
const formatted = address ? encodeAddress(decodeAddress(address), prefix) : null;
|
||||
|
||||
return (
|
||||
<div
|
||||
className='row'
|
||||
data-field={label}
|
||||
key={label}
|
||||
>
|
||||
<Identicon
|
||||
className='icon'
|
||||
prefix={prefix}
|
||||
value={formatted}
|
||||
/>
|
||||
<span className='label'>{label}</span>
|
||||
<span className='address'>
|
||||
{formatted || (preview && label === t('Wormhole')
|
||||
? t('none: a raw seed has no wormhole account')
|
||||
: t('<unknown>'))}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default styled(WalletPreview)<Props>`
|
||||
background: var(--boxBackground);
|
||||
border: 1px solid var(--boxBorderColor);
|
||||
border-radius: 4px;
|
||||
box-sizing: border-box;
|
||||
margin-bottom: 8px;
|
||||
padding: 8px 12px;
|
||||
|
||||
.name {
|
||||
font-size: 16px;
|
||||
line-height: 22px;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.row {
|
||||
align-items: center;
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
min-height: 26px;
|
||||
}
|
||||
|
||||
.icon {
|
||||
height: 20px;
|
||||
width: 20px;
|
||||
|
||||
svg, img {
|
||||
height: 20px !important;
|
||||
width: 20px !important;
|
||||
}
|
||||
}
|
||||
|
||||
.label {
|
||||
color: var(--labelColor);
|
||||
flex: 0 0 72px;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.address {
|
||||
color: var(--labelColor);
|
||||
font-family: var(--fontFamilyMono, monospace);
|
||||
font-size: 11px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
`;
|
||||
@@ -1,7 +1,7 @@
|
||||
// Copyright 2019-2026 @polkadot/extension-ui authors & contributors
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
import type { 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<AuthorizeRequest[]>([]);
|
||||
// Empty until a node has answered, and empty again if one never does — an
|
||||
// account list that works without balances beats one that fails with them.
|
||||
const BalanceContext = React.createContext<AccountBalances>({});
|
||||
const WalletContext = React.createContext<WalletInfo[]>([]);
|
||||
const MediaContext = React.createContext<boolean>(false);
|
||||
const MetadataReqContext = React.createContext<MetadataRequest[]>([]);
|
||||
const SettingsContext = React.createContext<SettingsStruct>(settings.get());
|
||||
@@ -24,4 +25,4 @@ const SigningReqContext = React.createContext<SigningRequest[]>([]);
|
||||
const ThemeSwitchContext = React.createContext<(theme: Theme) => void>(noop);
|
||||
const ToastContext = React.createContext<({show: (message: string) => void})>({ show: noop });
|
||||
|
||||
export { AccountContext, ActionContext, AuthorizeReqContext, BalanceContext, MediaContext, MetadataReqContext, SettingsContext, SigningReqContext, ThemeSwitchContext, ToastContext };
|
||||
export { AccountContext, ActionContext, AuthorizeReqContext, BalanceContext, MediaContext, MetadataReqContext, SettingsContext, SigningReqContext, ThemeSwitchContext, ToastContext, WalletContext };
|
||||
|
||||
@@ -41,4 +41,5 @@ export * from './themes.js';
|
||||
export { default as ValidatedInput } from './ValidatedInput.js';
|
||||
export { default as VerticalSpace } from './VerticalSpace.js';
|
||||
export { default as View } from './View.js';
|
||||
export { default as WalletPreview } from './WalletPreview.js';
|
||||
export { default as Warning } from './Warning.js';
|
||||
|
||||
@@ -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<boolean> {
|
||||
return sendMessage('pri(metadata.reject)', { id });
|
||||
}
|
||||
|
||||
export async function subscribeWallets (cb: (wallets: WalletInfo[]) => void): Promise<boolean> {
|
||||
return sendMessage('pri(wallets.subscribe)', null, cb);
|
||||
}
|
||||
|
||||
/** The account-0 addresses a recovery phrase or seed would give. Throws on anything else. */
|
||||
export async function previewWallet (secret: string): Promise<ResponseWalletPreview> {
|
||||
return sendMessage('pri(wallets.preview)', { secret });
|
||||
}
|
||||
|
||||
/** Resolves to the new wallet's id. */
|
||||
export async function createWallet (name: string, password: string, secret: string, genesisHash?: HexString | null): Promise<string> {
|
||||
return sendMessage('pri(wallets.create)', { genesisHash, name, password, secret });
|
||||
}
|
||||
|
||||
export async function addWalletAccount (id: string, password: string): Promise<boolean> {
|
||||
return sendMessage('pri(wallets.addAccount)', { id, password });
|
||||
}
|
||||
|
||||
export async function renameWallet (id: string, name: string): Promise<boolean> {
|
||||
return sendMessage('pri(wallets.rename)', { id, name });
|
||||
}
|
||||
|
||||
export async function forgetWallet (id: string): Promise<boolean> {
|
||||
return sendMessage('pri(wallets.forget)', { id });
|
||||
}
|
||||
|
||||
export async function subscribeAccounts (cb: (accounts: AccountJson[]) => void): Promise<boolean> {
|
||||
return sendMessage('pri(accounts.subscribe)', null, cb);
|
||||
}
|
||||
|
||||
@@ -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' }
|
||||
];
|
||||
@@ -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": ""
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user