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:
2026-09-16 16:13:18 +03:00
co-authored by Claude Opus 5
parent 22eb1c25c8
commit c378b36a3a
18 changed files with 838 additions and 299 deletions
+2 -2
View File
@@ -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;
}
`);
+41 -33
View File
@@ -1,7 +1,7 @@
// Copyright 2019-2026 @polkadot/extension-ui authors & contributors
// SPDX-License-Identifier: Apache-2.0
import type { AccountJson, AccountsContext, AuthorizeRequest, MetadataRequest, SigningRequest } from '@polkadot/extension-base/background/types';
import type { AccountJson, AccountsContext, AuthorizeRequest, MetadataRequest, SigningRequest, WalletInfo } from '@polkadot/extension-base/background/types';
import type { SettingsStruct } from '@polkadot/ui-settings/types';
import React, { useCallback, useEffect, useState } from 'react';
@@ -11,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';
+27 -1
View File
@@ -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": ""
}