diff --git a/packages/extension-base/src/background/handlers/Extension.ts b/packages/extension-base/src/background/handlers/Extension.ts index 3b98ce7e..8e291cb6 100644 --- a/packages/extension-base/src/background/handlers/Extension.ts +++ b/packages/extension-base/src/background/handlers/Extension.ts @@ -576,7 +576,6 @@ export default class Extension { case 'pri(accounts.create.external)': return this.accountsCreateExternal(request as RequestAccountCreateExternal); - case 'pri(accounts.create.suri)': return this.accountsCreateSuri(request as RequestAccountCreateSuri); diff --git a/packages/extension-base/src/defaults.ts b/packages/extension-base/src/defaults.ts index b3d088d1..61e9aef6 100644 --- a/packages/extension-base/src/defaults.ts +++ b/packages/extension-base/src/defaults.ts @@ -18,7 +18,7 @@ export const MESSAGE_ORIGIN_CONTENT = `${PORT_PREFIX}-content`; // '/account/import-ledger' is gone with the Ledger path: no hardware wallet // speaks ML-DSA, so there is nothing for it to connect to. See quantus/extension#5. -export const ALLOWED_PATH = ['/', '/account/restore-json'] as const; +export const ALLOWED_PATH = ['/', '/account/restore-json', '/account/track-address'] as const; export const PASSWORD_EXPIRY_MIN = 15; export const PASSWORD_EXPIRY_MS = PASSWORD_EXPIRY_MIN * 60 * 1000; diff --git a/packages/extension-ui/src/Popup/ImportQr.spec.tsx b/packages/extension-ui/src/Popup/ImportQr.spec.tsx deleted file mode 100644 index 64d4bbc4..00000000 --- a/packages/extension-ui/src/Popup/ImportQr.spec.tsx +++ /dev/null @@ -1,140 +0,0 @@ -// Copyright 2019-2026 @polkadot/extension-ui authors & contributors -// SPDX-License-Identifier: Apache-2.0 - -import '@polkadot/extension-mocks/chrome'; - -import type { ReactWrapper } from 'enzyme'; -import type * as _ from '@polkadot/dev-test/globals.d.ts'; - -import Adapter from '@wojtekmaj/enzyme-adapter-react-17'; -import enzyme from 'enzyme'; -import React from 'react'; -import { act } from 'react-dom/test-utils'; -import { MemoryRouter } from 'react-router'; - -import { Button } from '../components/index.js'; -import * as messaging from '../messaging.js'; -import { flushAllPromises } from '../testHelpers.js'; -import ImportQr from './ImportQr.js'; - -const { configure, mount } = enzyme; - -const mockedAccount = { - content: '12bxf6QJS5hMJgwbJMDjFot1sq93EvgQwyuPWENr9SzJfxtN', - expectedBannerChain: 'Polkadot', - genesisHash: '0x91b171bb158e2d3848fa23a9f1c25182fb8e20313b2c1eb49219da7a70ce90c3', - isAddress: true, - name: 'My Polkadot Account' -}; - -interface ScanType { - isAddress: boolean; - content: string; - genesisHash: string; - name?: string; -} - -interface QrScanAddressProps { - className?: string; - onError?: (error: Error) => void; - onScan: (scanned: ScanType) => void; - size?: string | number; - style?: React.CSSProperties; -} - -// // NOTE Required for spyOn when using @swc/jest -// // https://github.com/swc-project/swc/issues/3843 -// jest.mock('../messaging', (): Record => ({ -// __esModule: true, -// ...jest.requireActual('../messaging') -// })); - -// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment,@typescript-eslint/no-unsafe-call -configure({ adapter: new Adapter() }); - -const typeName = async (wrapper: ReactWrapper, value: string) => { - wrapper.find('input').first().simulate('change', { target: { value } }); - await act(flushAllPromises); - wrapper.update(); -}; - -// jest.mock('@polkadot/react-qr', () => { -// return { -// QrScanAddress: (_: QrScanAddressProps): null => { -// return null; -// } -// }; -// }); - -describe('ImportQr component', () => { - let wrapper: ReactWrapper; - - beforeEach(async () => { - // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment - wrapper = mount( - - - - ); - - // eslint-disable-next-line @typescript-eslint/no-floating-promises - act(() => { - // eslint-disable-next-line @typescript-eslint/no-unnecessary-type-assertion - (wrapper.find('QrScanAddress').first().prop('onScan') as unknown as QrScanAddressProps['onScan'])(mockedAccount); - }); - await act(flushAllPromises); - wrapper.update(); - }); - - describe('Address component', () => { - it('shows account as external', () => { - expect(wrapper.find('Name').find('FontAwesomeIcon [data-icon="qrcode"]').exists()).toBe(true); - }); - - it('shows the correct name', () => { - expect(wrapper.find('Name span').text()).toEqual(mockedAccount.name); - }); - - it('shows the correct address', () => { - expect(wrapper.find('[data-field="address"]').text()).toEqual(mockedAccount.content); - }); - - it('shows the correct banner', () => { - expect(wrapper.find('[data-field="chain"]').text()).toEqual(mockedAccount.expectedBannerChain); - }); - }); - - it('has the button enabled', () => { - expect(wrapper.find(Button).prop('isDisabled')).toBe(false); - }); - - it('displays and error and the button is disabled with a short name', async () => { - await typeName(wrapper, 'a'); - - expect(wrapper.find('.warning-message').first().text()).toBe('Account name is too short'); - expect(wrapper.find(Button).prop('isDisabled')).toBe(true); - }); - - it('has no error message and button enabled with a long name', async () => { - const longName = 'aaa'; - - await typeName(wrapper, 'a'); - await typeName(wrapper, longName); - - expect(wrapper.find('.warning-message')).toHaveLength(0); - expect(wrapper.find(Button).prop('isDisabled')).toBe(false); - expect(wrapper.find('Name span').text()).toEqual(longName); - }); - - it('shows the external name in the input field', () => { - expect(wrapper.find('input').prop('value')).toBe(mockedAccount.name); - }); - - it('creates the external account', async () => { - jest.spyOn(messaging, 'createAccountExternal').mockImplementation(() => Promise.resolve(false)); - wrapper.find(Button).simulate('click'); - await act(flushAllPromises); - - expect(messaging.createAccountExternal).toHaveBeenCalledWith(mockedAccount.name, mockedAccount.content, mockedAccount.genesisHash); - }); -}); diff --git a/packages/extension-ui/src/Popup/ImportQr.tsx b/packages/extension-ui/src/Popup/ImportQr.tsx deleted file mode 100644 index 002b6591..00000000 --- a/packages/extension-ui/src/Popup/ImportQr.tsx +++ /dev/null @@ -1,115 +0,0 @@ -// Copyright 2019-2026 @polkadot/extension-ui authors & contributors -// SPDX-License-Identifier: Apache-2.0 - -import type { HexString } from '@polkadot/util/types'; - -import React, { useCallback, useContext, useState } from 'react'; - -import { QrScanAddress } from '@polkadot/react-qr'; - -import AccountNamePasswordCreation from '../components/AccountNamePasswordCreation.js'; -import { ActionContext, Address, ButtonArea, NextStepButton, VerticalSpace } from '../components/index.js'; -import { useTranslation } from '../hooks/index.js'; -import { createAccountExternal, createAccountSuri, createSeed } from '../messaging.js'; -import { Header, Name } from '../partials/index.js'; - -interface QrAccount { - content: string; - genesisHash: HexString | null; - isAddress: boolean; - name?: string; -} - -export default function ImportQr (): React.ReactElement { - const { t } = useTranslation(); - const onAction = useContext(ActionContext); - const [account, setAccount] = useState(null); - const [address, setAddress] = useState(null); - const [name, setName] = useState(null); - const [password, setPassword] = useState(null); - - const _setAccount = useCallback( - (qrAccount: QrAccount) => { - setAccount(qrAccount); - setName(qrAccount?.name || null); - - if (qrAccount.isAddress) { - setAddress(qrAccount.content); - } else { - createSeed(undefined, qrAccount.content) - .then(({ address }) => setAddress(address)) - .catch(console.error); - } - }, - [] - ); - - const _onCreate = useCallback( - (): void => { - if (account && name) { - if (account.isAddress) { - createAccountExternal(name, account.content, account.genesisHash) - .then(() => onAction('/')) - .catch((error: Error) => console.error(error)); - } else if (password) { - createAccountSuri(name, password, account.content, 'sr25519', account.genesisHash) - .then(() => onAction('/')) - .catch((error: Error) => console.error(error)); - } - } - }, - [account, name, onAction, password] - ); - - return ( - <> -
- {!account && ( -
- -
- )} - {account && ( - <> -
-
-
- {account.isAddress - ? ( - - ) - : ( - - ) - } - - - - {t('Add the account with identified address')} - - - - )} - - ); -} diff --git a/packages/extension-ui/src/Popup/Signing/Qr.tsx b/packages/extension-ui/src/Popup/Signing/Qr.tsx deleted file mode 100644 index 76acc7f2..00000000 --- a/packages/extension-ui/src/Popup/Signing/Qr.tsx +++ /dev/null @@ -1,103 +0,0 @@ -// Copyright 2019-2026 @polkadot/extension-ui authors & contributors -// SPDX-License-Identifier: Apache-2.0 - -import type { ExtrinsicPayload } from '@polkadot/types/interfaces'; -import type { HexString } from '@polkadot/util/types'; - -import React, { useCallback, useMemo, useState } from 'react'; - -import { QrDisplayPayload, QrScanSignature } from '@polkadot/react-qr'; -import { u8aWrapBytes } from '@polkadot/util'; - -import { Button } from '../../components/index.js'; -import { useTranslation } from '../../hooks/index.js'; -import { styled } from '../../styled.js'; -import { CMD_MORTAL, CMD_SIGN_MESSAGE } from './Request/index.js'; - -interface Props { - address: string; - children?: React.ReactNode; - className?: string; - cmd: number; - genesisHash: string; - onSignature: ({ signature }: { signature: HexString }) => void; - payload: ExtrinsicPayload | string; - -} - -function Qr ({ address, className, cmd, genesisHash, onSignature, payload }: Props): React.ReactElement { - const { t } = useTranslation(); - const [isScanning, setIsScanning] = useState(false); - - const payloadU8a = useMemo( - () => { - switch (cmd) { - case CMD_MORTAL: - return (payload as ExtrinsicPayload).toU8a(); - case CMD_SIGN_MESSAGE: - return u8aWrapBytes(payload as string); - default: - return null; - } - }, - [cmd, payload] - ); - - const _onShowQr = useCallback( - () => setIsScanning(true), - [] - ); - - if (!payloadU8a) { - return ( -
-
- Transaction command:{cmd} not supported. -
-
- ); - } - - return ( -
-
- {isScanning - ? - : ( - - ) - } -
- {!isScanning && ( - - )} -
- ); -} - -export default styled(Qr)` - height: 100%; - - .qrContainer { - margin: 5px auto 10px auto; - width: 65%; - - img { - border: white solid 1px; - } - } - - .scanButton { - margin-bottom: 8px; - } -`; diff --git a/packages/extension-ui/src/Popup/Signing/Request/index.tsx b/packages/extension-ui/src/Popup/Signing/Request/index.tsx index fd7490f6..3497d6f2 100644 --- a/packages/extension-ui/src/Popup/Signing/Request/index.tsx +++ b/packages/extension-ui/src/Popup/Signing/Request/index.tsx @@ -3,19 +3,16 @@ import type { AccountJson, RequestSign } from '@polkadot/extension-base/background/types'; import type { ExtrinsicPayload } from '@polkadot/types/interfaces'; -import type { HexString } from '@polkadot/util/types'; -import React, { useCallback, useContext, useEffect, useState } from 'react'; +import React, { useEffect, useState } from 'react'; import { isExtrinsicRequest } from '@polkadot/extension-base/utils'; import { TypeRegistry } from '@polkadot/types'; -import { ActionContext, Address, VerticalSpace, Warning } from '../../../components/index.js'; +import { Address, VerticalSpace, Warning } from '../../../components/index.js'; import { useMetadata, useTranslation } from '../../../hooks/index.js'; -import { approveSignSignature } from '../../../messaging.js'; import Bytes from '../Bytes.js'; import Extrinsic from '../Extrinsic.js'; -import Qr from '../Qr.js'; import SignArea from './SignArea.js'; interface Props { @@ -32,14 +29,10 @@ interface Data { payload: ExtrinsicPayload | null; } -export const CMD_MORTAL = 2; -export const CMD_SIGN_MESSAGE = 3; - // keep it global, we can and will re-use this across requests const registry = new TypeRegistry(); -export default function Request ({ account: { genesisHash: accountGenesisHash, isExternal, isHardware }, buttonText, isFirst, request, signId, url }: Props): React.ReactElement | null { - const onAction = useContext(ActionContext); +export default function Request ({ account: { isExternal, isHardware }, buttonText, isFirst, request, signId, url }: Props): React.ReactElement | null { const [{ hexBytes, payload }, setData] = useState({ hexBytes: null, payload: null }); const [error, setError] = useState(null); const { t } = useTranslation(); @@ -77,18 +70,6 @@ export default function Request ({ account: { genesisHash: accountGenesisHash, i } }, [request]); - const _onSignature = useCallback( - ({ signature }: { signature: HexString }, signedTransaction?: HexString): void => { - approveSignSignature(signId, signature, signedTransaction) - .then(() => onAction()) - .catch((error: Error): void => { - setError(error.message); - console.error(error); - }); - }, - [onAction, signId] - ); - // Branch on the request itself rather than on the decoded state, so a render // that lands before the effect has caught up shows nothing instead of feeding // the previous request's view a payload of the other shape. @@ -109,24 +90,17 @@ export default function Request ({ account: { genesisHash: accountGenesisHash, i isHardware={isHardware} /> - {isExternal && !isHardware - ? ( - - ) - : ( - - ) - } + + {isExternal && ( + <> + {t('This account is tracked only — the extension holds no key for it and cannot sign.')} + + + )} - {isExternal && !isHardware && accountGenesisHash - ? ( - - ) - : ( - - ) - } + - {isExternal && !isHardware && !accountGenesisHash && ( - <> - {t('"Allow use on any network" is not supported to show a QR code. You must associate this account with a network.')} - - - )} - {isHardware && <> - {t('Message signing is not supported for hardware wallets.')} + {isExternal && <> + {t('This account is tracked only — the extension holds no key for it and cannot sign.')} } { }); describe('External account', () => { - it('shows Qr scanner for external accounts', async () => { + // Was "shows Qr scanner for external accounts". That flow round-tripped a + // signature through a Parity Signer QR, a protocol no Quantus device speaks + // (quantus/extension#10). An external account here is now a tracked address: + // the extension holds no key for it and there is no signer to hand off to, so + // the right behaviour is to show the call and say so rather than display a + // QR nothing can scan. + it('shows the extrinsic and a cannot-sign warning for external accounts', async () => { signRequests = [{ account: { address: '5Cf1CGZas62RWwce3d2EPqUvSoi1txaXKd9M5w9bEFSsQtRe', @@ -251,8 +256,8 @@ describe('Signing requests', () => { url: 'https://polkadot.js.org/apps/?rpc=wss%3A%2F%2Fwestend-rpc.polkadot.io#/accounts' }]; await mountComponent(); - expect(wrapper.find(Extrinsic)).toHaveLength(0); - expect(wrapper.find(Qr)).toHaveLength(1); + expect(wrapper.find(Extrinsic)).toHaveLength(1); + expect(wrapper.text()).toContain('tracked only'); }); }); diff --git a/packages/extension-ui/src/Popup/TrackAddress.tsx b/packages/extension-ui/src/Popup/TrackAddress.tsx new file mode 100644 index 00000000..4ce4170d --- /dev/null +++ b/packages/extension-ui/src/Popup/TrackAddress.tsx @@ -0,0 +1,135 @@ +// Copyright 2019-2026 @polkadot/extension-ui authors & contributors +// SPDX-License-Identifier: Apache-2.0 + +import React, { useCallback, useContext, useState } from 'react'; + +import { decodeAddress, encodeAddress } from '@polkadot/util-crypto'; + +import { ActionContext, Address, ButtonArea, NextStepButton, VerticalSpace, Warning } from '../components/index.js'; +import { useTranslation } from '../hooks/index.js'; +import { createAccountExternal } from '../messaging.js'; +import { Header, Name } from '../partials/index.js'; +import { styled } from '../styled.js'; + +interface Props { + className?: string; +} + +// Replaces ImportQr as the way to add an account the extension holds no key for. +// +// That flow scanned a Parity Signer QR, a protocol no Quantus device speaks +// (quantus/extension#10) — but it was also the *only* caller of +// createAccountExternal, so removing it would have taken watch-only accounts with +// it. Those are wanted independently of how the address arrives, and for Quantus +// pasting is the common case anyway: the address is on screen in another wallet, +// not on a signing device. +function TrackAddress ({ className }: Props): React.ReactElement { + const { t } = useTranslation(); + const onAction = useContext(ActionContext); + const [address, setAddress] = useState(null); + const [error, setError] = useState(null); + const [name, setName] = useState(null); + + const _onChangeAddress = useCallback( + (event: React.ChangeEvent): void => { + const value = event.target.value; + const trimmed = value.trim(); + + if (!trimmed) { + setAddress(null); + setError(null); + + return; + } + + try { + // Round-tripping through decode/encode does two jobs: it rejects a + // mistyped address by its checksum rather than storing something that + // will never receive anything, and it normalises the prefix so an + // address pasted from a tool using a different one is stored the way the + // rest of the extension will display it. + setAddress(encodeAddress(decodeAddress(trimmed))); + setError(null); + } catch { + setAddress(null); + setError(t('This is not a valid address')); + } + }, + [t] + ); + + const _onCreate = useCallback( + (): void => { + if (address && name) { + // genesisHash null — "any network". A tracked address is just an + // address; nothing here knows which chain the holder uses it on. + createAccountExternal(name, address, null) + .then(() => onAction('/')) + .catch((error: Error) => { + console.error(error); + setError((error).message); + }); + } + }, + [address, name, onAction] + ); + + return ( + <> +
+
+
+