diff --git a/packages/base/package.json b/packages/base/package.json index 968f029b..55e4c99c 100644 --- a/packages/base/package.json +++ b/packages/base/package.json @@ -20,6 +20,7 @@ "@coong/utils": "^0.0.20", "@polkadot/networks": "^10.4.1", "@polkadot/types": "^9.14.1", + "@polkadot/util": "^10.4.1", "@polkadot/util-crypto": "^10.4.1", "rxjs": "^7.8.0" }, diff --git a/packages/base/src/requests/WalletState.ts b/packages/base/src/requests/WalletState.ts index 2bfa7bc6..0eadee80 100644 --- a/packages/base/src/requests/WalletState.ts +++ b/packages/base/src/requests/WalletState.ts @@ -1,6 +1,7 @@ import { InjectedAccount } from '@polkadot/extension-inject/types'; import { TypeRegistry } from '@polkadot/types'; -import { SignerPayloadJSON } from '@polkadot/types/types'; +import { SignerPayloadJSON, SignerPayloadRaw } from '@polkadot/types/types'; +import { u8aToHex, u8aWrapBytes } from '@polkadot/util'; import { encodeAddress } from '@polkadot/util-crypto'; import { KeypairType } from '@polkadot/util-crypto/types'; import Keyring from '@coong/keyring'; @@ -159,6 +160,8 @@ export default class WalletState { const registry = new TypeRegistry(); registry.setSignedExtensions(payloadJSON.signedExtensions); + + // https://github.com/polkadot-js/extension/blob/master/packages/extension-base/src/background/RequestExtrinsicSign.ts#L18-L22 const payload = registry.createType('ExtrinsicPayload', payloadJSON, { version: payloadJSON.version }); const result = payload.sign(pair); @@ -170,7 +173,31 @@ export default class WalletState { cancelSignExtrinsic() { const currentMessage = this.getCurrentRequestMessage('tab/signExtrinsic'); + currentMessage.reject(new StandardCoongError('Cancelled')); + } + + async signRawMessage(password: string) { + await this.#keyring.verifyPassword(password); + + const currentMessage = this.getCurrentRequestMessage('tab/signRaw'); + + const { id, request, resolve } = currentMessage; + const payloadJSON = request.body as SignerPayloadRaw; + + const pair = this.#keyring.getSigningPair(payloadJSON.address); + pair.unlock(password); + + // https://github.com/polkadot-js/extension/blob/master/packages/extension-base/src/background/RequestBytesSign.ts#L20-L27 + const signature = u8aToHex(pair.sign(u8aWrapBytes(payloadJSON.data))); + + resolve({ + id, + signature, + }); + } + cancelSignRawMessage() { + const currentMessage = this.getCurrentRequestMessage('tab/signRaw'); currentMessage.reject(new StandardCoongError('Cancelled')); } diff --git a/packages/ui/public/locales/en/translation.json b/packages/ui/public/locales/en/translation.json index f96d06f8..03232e56 100644 --- a/packages/ui/public/locales/en/translation.json +++ b/packages/ui/public/locales/en/translation.json @@ -10,6 +10,7 @@ "Address copied!": "", "Address format": "", "An application, self-identifying as request app name is requesting access your wallet from origin.": "An application, self-identifying as {{appName}} is requesting access your wallet from {{origin}}.", + "Approve Transaction": "", "Auto-lock wallet after": "", "Back": "", "Backup secret recovery phrase": "", @@ -69,10 +70,14 @@ "Set up your Coong wallet now": "", "Settings": "", "Setup your Coong wallet now to connect": "", + "Sign": "", + "Sign Message": "", + "Sign Message Request": "", "System": "", "Theme Mode": "", "This page should be loaded inside an iframe!": "", "This page should not be open directly!": "", + "Transaction Approval Request": "", "Type again your chosen password to ensure you remember it.": "", "UnknownRequest": "Unknown request", "UnknownRequestOrigin": "Unknown request origin", @@ -87,7 +92,16 @@ "Welcome to Coong Wallet!": "", "Write down the below 12 words and keep it in a safe place.": "", "You are about to reveal the secret recovery phrase which give access to your accounts and funds.": "", + "You are approving a transaction with account": "", + "You are signing a message with account": "", "Your password will be used to encrypt accounts as well as unlock the wallet, make sure to pick a strong & easy-to-remember password": "Your password will be used to encrypt accounts as well as unlock the wallet, make sure to pick a strong & easy-to-remember password.", "Your wallet password": "", - "account(s) selected": "" -} \ No newline at end of file + "account(s) selected": "", + "bytes": "", + "from": "", + "genesis": "", + "life time": "", + "method data": "", + "nonce": "", + "version": "" +} diff --git a/packages/ui/src/components/pages/Request/RequestContent.tsx b/packages/ui/src/components/pages/Request/RequestContent.tsx index 49810cfd..3c409c4c 100644 --- a/packages/ui/src/components/pages/Request/RequestContent.tsx +++ b/packages/ui/src/components/pages/Request/RequestContent.tsx @@ -2,7 +2,8 @@ import React, { FC } from 'react'; import { CoongError, ErrorCode } from '@coong/utils'; import { CircularProgress } from '@mui/material'; import RequestAccess from 'components/pages/Request/RequestAccess'; -import RequestTransactionApproval from 'components/pages/Request/RequestTransactionApproval'; +import RequestSignRawMessage from 'components/pages/Request/RequestSigning/RequestSignRawMessage'; +import RequestTransactionApproval from 'components/pages/Request/RequestSigning/RequestTransactionApproval'; import useCurrentRequestMessage from 'hooks/messages/useCurrentRequestMessage'; import { Props } from 'types'; @@ -23,6 +24,8 @@ const RequestContent: FC = () => { return ; } else if (requestName === 'tab/signExtrinsic') { return ; + } else if (requestName === 'tab/signRaw') { + return ; } throw new CoongError(ErrorCode.UnknownRequest); diff --git a/packages/ui/src/components/pages/Request/RequestSigning/DetailRow.tsx b/packages/ui/src/components/pages/Request/RequestSigning/DetailRow.tsx new file mode 100644 index 00000000..5ff3c955 --- /dev/null +++ b/packages/ui/src/components/pages/Request/RequestSigning/DetailRow.tsx @@ -0,0 +1,41 @@ +import { FC } from 'react'; +import { useTranslation } from 'react-i18next'; +import clsx from 'clsx'; +import { Props } from 'types'; + +export enum ValueStyle { + TEXT_BOLD, + BOX, +} + +export interface DetailRowProps extends Props { + name: string; + value: any; + breakWord?: boolean; + style?: ValueStyle; +} + +const DetailRow: FC = ({ name, value, breakWord = false, style = ValueStyle.TEXT_BOLD }) => { + const { t } = useTranslation(); + const renderValue = () => { + switch (style) { + case ValueStyle.BOX: + return ( +
+ {value} +
+ ); + case ValueStyle.TEXT_BOLD: + default: + return {value}; + } + }; + + return ( +
+
{t(name)}:
+ {renderValue()} +
+ ); +}; +export default DetailRow; diff --git a/packages/ui/src/components/pages/Request/RequestSigning/RequestDetails.tsx b/packages/ui/src/components/pages/Request/RequestSigning/RequestDetails.tsx new file mode 100644 index 00000000..c0e01b1f --- /dev/null +++ b/packages/ui/src/components/pages/Request/RequestSigning/RequestDetails.tsx @@ -0,0 +1,19 @@ +import { FC } from 'react'; +import DetailRow, { DetailRowProps } from 'components/pages/Request/RequestSigning/DetailRow'; +import { Props } from 'types'; + +interface RequestDetails extends Props { + rows: DetailRowProps[]; +} + +const RequestDetails: FC = ({ className = '', rows }) => { + return ( +
+ {rows.map((row) => ( + + ))} +
+ ); +}; + +export default RequestDetails; diff --git a/packages/ui/src/components/pages/Request/RequestSigning/RequestSignRawMessage.tsx b/packages/ui/src/components/pages/Request/RequestSigning/RequestSignRawMessage.tsx new file mode 100644 index 00000000..017b42c2 --- /dev/null +++ b/packages/ui/src/components/pages/Request/RequestSigning/RequestSignRawMessage.tsx @@ -0,0 +1,36 @@ +import { FC } from 'react'; +import { useTranslation } from 'react-i18next'; +import AccountCard from 'components/pages/Accounts/AccountCard'; +import RequestDetails from 'components/pages/Request/RequestSigning/RequestDetails'; +import SignArea from 'components/pages/Request/RequestSigning/SignArea'; +import { useRawMessageDetails } from 'components/pages/Request/RequestSigning/hooks/useRawMessageDetails'; +import useTargetAccount from 'components/pages/Request/RequestSigning/hooks/useTargetAccount'; +import { RequestProps } from 'components/pages/Request/types'; +import { useWalletState } from 'providers/WalletStateProvider'; + +const RequestSignRawMessage: FC = ({ className = '', message }) => { + const { t } = useTranslation(); + const { walletState } = useWalletState(); + const targetAccount = useTargetAccount(message); + const detailRows = useRawMessageDetails(message); + + const doSignMessage = async (password: string) => { + await walletState.signRawMessage(password); + }; + + const cancelRequest = () => { + walletState.cancelSignRawMessage(); + }; + + return ( +
+

{t('Sign Message Request')}

+

{t('You are signing a message with account')}

+ {targetAccount && } + + +
+ ); +}; + +export default RequestSignRawMessage; diff --git a/packages/ui/src/components/pages/Request/RequestSigning/RequestTransactionApproval.tsx b/packages/ui/src/components/pages/Request/RequestSigning/RequestTransactionApproval.tsx new file mode 100644 index 00000000..757c00b9 --- /dev/null +++ b/packages/ui/src/components/pages/Request/RequestSigning/RequestTransactionApproval.tsx @@ -0,0 +1,36 @@ +import { FC } from 'react'; +import { useTranslation } from 'react-i18next'; +import AccountCard from 'components/pages/Accounts/AccountCard'; +import RequestDetails from 'components/pages/Request/RequestSigning/RequestDetails'; +import SignArea from 'components/pages/Request/RequestSigning/SignArea'; +import useTargetAccount from 'components/pages/Request/RequestSigning/hooks/useTargetAccount'; +import useTransactionDetails from 'components/pages/Request/RequestSigning/hooks/useTransactionDetails'; +import { RequestProps } from 'components/pages/Request/types'; +import { useWalletState } from 'providers/WalletStateProvider'; + +const RequestTransactionApproval: FC = ({ className, message }) => { + const { t } = useTranslation(); + const { walletState } = useWalletState(); + const targetAccount = useTargetAccount(message); + const detailRows = useTransactionDetails(message); + + const approveTransaction = async (password: string) => { + await walletState.approveSignExtrinsic(password); + }; + + const cancelRequest = () => { + walletState.cancelSignExtrinsic(); + }; + + return ( +
+

{t('Transaction Approval Request')}

+

{t('You are approving a transaction with account')}

+ {targetAccount && } + + +
+ ); +}; + +export default RequestTransactionApproval; diff --git a/packages/ui/src/components/pages/Request/RequestSigning/SignArea.tsx b/packages/ui/src/components/pages/Request/RequestSigning/SignArea.tsx new file mode 100644 index 00000000..d345df4c --- /dev/null +++ b/packages/ui/src/components/pages/Request/RequestSigning/SignArea.tsx @@ -0,0 +1,63 @@ +import { FC, FormEvent, useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import { Form } from 'react-router-dom'; +import { toast } from 'react-toastify'; +import { useToggle } from 'react-use'; +import { Button, TextField } from '@mui/material'; +import { Props } from 'types'; + +interface SignAreaProps extends Props { + onSign: (password: string) => void; + onCancel: () => void; + cancelButtonLabel?: string; + signButtonLabel?: string; +} + +const SignArea: FC = ({ onSign, onCancel, cancelButtonLabel = 'Cancel', signButtonLabel = 'Sign' }) => { + const { t } = useTranslation(); + const [password, setPassword] = useState(''); + const [loading, toggleLoading] = useToggle(false); + + const doSign = (e: FormEvent) => { + e.preventDefault(); + toggleLoading(true); + + // signing & accounts decryption are synchronous operations + // and might take some time to do + // so we delay it a short amount of time to make sure the UI could be updated (disable button, ...) + // before the signing process begin + // TODO: Moving CPU-intensive operations to worker + setTimeout(async () => { + try { + await onSign(password); + } catch (e: any) { + toggleLoading(false); + toast.error(t(e.message)); + } + }, 200); + }; + + return ( +
+ ('Wallet password')} + size='medium' + type='password' + fullWidth + autoFocus + value={password} + onChange={(event) => setPassword(event.target.value)} + /> +
+ + +
+ + ); +}; + +export default SignArea; diff --git a/packages/ui/src/components/pages/Request/RequestSigning/__tests__/RequestSigning.spec.tsx b/packages/ui/src/components/pages/Request/RequestSigning/__tests__/RequestSigning.spec.tsx new file mode 100644 index 00000000..faf8d8ac --- /dev/null +++ b/packages/ui/src/components/pages/Request/RequestSigning/__tests__/RequestSigning.spec.tsx @@ -0,0 +1,304 @@ +import { SignerPayloadJSON, SignerPayloadRaw } from '@polkadot/types/types'; +import { defaultNetwork, newWalletErrorResponse, newWalletRequest } from '@coong/base'; +import { WalletRequestMessage } from '@coong/base/types'; +import Keyring from '@coong/keyring'; +import { AccountInfo } from '@coong/keyring/types'; +import { SpyInstance } from '@vitest/spy'; +import { + initializeKeyring, + newUser, + PASSWORD, + render, + RouterWrapper, + screen, + setupAuthorizedApps, + UserEvent, + waitFor, +} from '__tests__/testUtils'; +import { Mock } from 'vitest'; +import Request from '../../index'; +import RequestSignRawMessage from '../RequestSignRawMessage'; + +const preloadedState = { app: { seedReady: true, addressPrefix: defaultNetwork.prefix } }; +let windowClose: SpyInstance, postMessage: Mock, user: UserEvent, account01: AccountInfo, keyring: Keyring; + +beforeEach(async () => { + windowClose = vi.spyOn(window, 'close').mockImplementation(() => vi.fn()); + + postMessage = vi.fn(); + window.opener = { postMessage }; + + keyring = await initializeKeyring(); + account01 = await keyring.createNewAccount('Account 01', PASSWORD); + + user = newUser(); +}); + +describe('RequestTransactionApproval', () => { + const newPayload = (address: string) => { + return { + specVersion: '0x00002490', + transactionVersion: '0x00000013', + address, + blockHash: '0x740c0ff582a5f5ed089a83afe396be64db42486397ee23611811e123a70bd63f', + blockNumber: '0x00dd836d', + era: '0xd502', + genesisHash: '0x91b171bb158e2d3848fa23a9f1c25182fb8e20313b2c1eb49219da7a70ce90c3', + method: '0x050000004769bbe59968882c1597ec1151621f0193547285125f1c1337371c013ff61f02890700', + nonce: '0x00000000', + signedExtensions: [ + 'CheckNonZeroSender', + 'CheckSpecVersion', + 'CheckTxVersion', + 'CheckGenesis', + 'CheckMortality', + 'CheckNonce', + 'CheckWeight', + 'ChargeTransactionPayment', + 'PrevalidateAttests', + ], + tip: '0x00000000000000000000000000000000', + version: 4, + }; + }; + + let requestUrl: string, requestSignExtrinsic: WalletRequestMessage; + + beforeEach(async () => { + requestSignExtrinsic = newWalletRequest({ name: 'tab/signExtrinsic', body: newPayload(account01.address) }); + const queryParam = new URLSearchParams({ + message: JSON.stringify(requestSignExtrinsic), + }).toString(); + requestUrl = `/request?${queryParam}`; + }); + + describe('app is not authorized', () => { + it('should reject request & close window', async () => { + render( + + + , + { preloadedState }, + ); + + await waitFor(() => { + const expectedResponse = newWalletErrorResponse( + `The app at ${requestSignExtrinsic.origin} has not been authorized yet!`, + requestSignExtrinsic.id, + ); + expect(postMessage).toHaveBeenNthCalledWith(2, expectedResponse, requestSignExtrinsic.origin); + }); + }); + }); + + describe('app is authorized', () => { + beforeEach(() => { + setupAuthorizedApps([account01.address], window.location.origin); + }); + + it('should display the request correctly', async () => { + render( + + + , + { preloadedState }, + ); + + expect(await screen.findByText(/Transaction Approval Request/)).toBeInTheDocument(); + expect(await screen.findByText(/You are approving a transaction with account/)).toBeInTheDocument(); + expect(await screen.findByText(/Account 01/)).toBeInTheDocument(); + expect(await screen.findByTestId('row-from')).toHaveTextContent(`from: ${window.location.origin}`); + expect(await screen.findByTestId('row-method-data')).toHaveTextContent( + `method data: ${(requestSignExtrinsic.request.body as SignerPayloadJSON).method}`, + ); + + expect(await screen.findByLabelText('Wallet password')).toBeInTheDocument(); + expect(await screen.findByRole('button', { name: /Approve Transaction/ })).toBeDisabled(); + expect(await screen.findByRole('button', { name: /Cancel/ })).toBeEnabled(); + }); + + it('should show error message for incorrect password', async () => { + render( + + + , + { preloadedState }, + ); + + const passwordField = await screen.findByLabelText('Wallet password'); + await user.type(passwordField, 'incorrect-password'); + + const approvalButton = await screen.findByRole('button', { name: /Approve Transaction/ }); + expect(approvalButton).toBeEnabled(); + await user.click(approvalButton); + + expect(await screen.findByText('Password incorrect')).toBeInTheDocument(); + }); + + it('should post back message and close window for correct password', async () => { + render( + + + , + { preloadedState }, + ); + + const passwordField = await screen.findByLabelText('Wallet password'); + await user.type(passwordField, PASSWORD); + + const approvalButton = await screen.findByRole('button', { name: /Approve Transaction/ }); + expect(approvalButton).toBeEnabled(); + await user.click(approvalButton); + + await waitFor(() => { + expect(postMessage).toHaveBeenCalledTimes(2); // 1: initialized signal, 2: signature message + }); + expect(windowClose).toBeCalled(); + }); + + it('should post back message and close window if cancelling', async () => { + render( + + + , + { preloadedState }, + ); + + const cancelButton = await screen.findByRole('button', { name: /Cancel/ }); + expect(cancelButton).toBeEnabled(); + await user.click(cancelButton); + + await waitFor(() => { + const expectedResponse = newWalletErrorResponse('Cancelled', requestSignExtrinsic.id); + expect(postMessage).toHaveBeenNthCalledWith(2, expectedResponse, requestSignExtrinsic.origin); + }); + expect(windowClose).toBeCalled(); + }); + }); +}); + +describe('RequestSignRawMessage', () => { + const newPayloadRaw = (address: string) => { + return { + address, + type: 'bytes', + data: 'This is a dummy message to sign', + } as SignerPayloadRaw; + }; + + let requestUrl: string, requestSignRawMessage: WalletRequestMessage; + + beforeEach(async () => { + requestSignRawMessage = newWalletRequest({ name: 'tab/signRaw', body: newPayloadRaw(account01.address) }); + const queryParam = new URLSearchParams({ + message: JSON.stringify(requestSignRawMessage), + }).toString(); + requestUrl = `/request?${queryParam}`; + + user = newUser(); + }); + + describe('app is not authorized', () => { + it('should reject request & close window', async () => { + render( + + + , + { preloadedState }, + ); + + await waitFor(() => { + const expectedResponse = newWalletErrorResponse( + `The app at ${requestSignRawMessage.origin} has not been authorized yet!`, + requestSignRawMessage.id, + ); + expect(postMessage).toHaveBeenNthCalledWith(2, expectedResponse, requestSignRawMessage.origin); + }); + }); + }); + + describe('app is authorized', () => { + beforeEach(() => { + setupAuthorizedApps([account01.address], window.location.origin); + }); + + it('should render the request correctly', async () => { + render( + + + , + { preloadedState }, + ); + + expect(await screen.findByText(/Sign Message Request/)).toBeInTheDocument(); + expect(await screen.findByText(/You are signing a message with account/)).toBeInTheDocument(); + expect(await screen.findByText(/Account 01/)).toBeInTheDocument(); + expect(await screen.findByTestId('row-from')).toHaveTextContent(`from: ${window.location.origin}`); + expect(await screen.findByTestId('row-bytes')).toHaveTextContent( + `bytes: ${(requestSignRawMessage.request.body as SignerPayloadRaw).data}`, + ); + + expect(await screen.findByLabelText('Wallet password')).toBeInTheDocument(); + expect(await screen.findByRole('button', { name: /Sign Message/ })).toBeDisabled(); + expect(await screen.findByRole('button', { name: /Cancel/ })).toBeEnabled(); + }); + + it('should show error message for incorrect password', async () => { + render( + + + , + { preloadedState }, + ); + + const passwordField = await screen.findByLabelText('Wallet password'); + await user.type(passwordField, 'incorrect-password'); + + const approvalButton = await screen.findByRole('button', { name: /Sign Message/ }); + expect(approvalButton).toBeEnabled(); + await user.click(approvalButton); + + expect(await screen.findByText('Password incorrect')).toBeInTheDocument(); + }); + + it('should post back message and close window for correct password', async () => { + render( + + + , + { preloadedState }, + ); + + const passwordField = await screen.findByLabelText('Wallet password'); + await user.type(passwordField, PASSWORD); + + const approvalButton = await screen.findByRole('button', { name: /Sign Message/ }); + expect(approvalButton).toBeEnabled(); + await user.click(approvalButton); + + await waitFor(() => { + expect(postMessage).toHaveBeenCalledTimes(2); // 1: initialized signal, 2: signature message + }); + expect(windowClose).toBeCalled(); + }); + + it('should post back message and close window if cancelling', async () => { + render( + + + , + { preloadedState }, + ); + + const cancelButton = await screen.findByRole('button', { name: /Cancel/ }); + expect(cancelButton).toBeEnabled(); + await user.click(cancelButton); + + await waitFor(() => { + const expectedResponse = newWalletErrorResponse('Cancelled', requestSignRawMessage.id); + expect(postMessage).toHaveBeenNthCalledWith(2, expectedResponse, requestSignRawMessage.origin); + }); + expect(windowClose).toBeCalled(); + }); + }); +}); diff --git a/packages/ui/src/components/pages/Request/RequestSigning/hooks/useRawMessageDetails.ts b/packages/ui/src/components/pages/Request/RequestSigning/hooks/useRawMessageDetails.ts new file mode 100644 index 00000000..fb0d98a5 --- /dev/null +++ b/packages/ui/src/components/pages/Request/RequestSigning/hooks/useRawMessageDetails.ts @@ -0,0 +1,28 @@ +import { useMemo } from 'react'; +import { SignerPayloadRaw } from '@polkadot/types/types'; +import { isAscii, u8aToString, u8aUnwrapBytes } from '@polkadot/util'; +import { WalletRequestWithResolver } from '@coong/base/types'; +import { DetailRowProps, ValueStyle } from 'components/pages/Request/RequestSigning/DetailRow'; + +export function useRawMessageDetails(message: WalletRequestWithResolver): DetailRowProps[] { + const { origin, request } = message; + const payloadJSON = request.body as SignerPayloadRaw; + const { data } = payloadJSON; + + return useMemo( + () => [ + { + name: 'from', + value: origin, + breakWord: true, + }, + { + name: 'bytes', + value: isAscii(data) ? u8aToString(u8aUnwrapBytes(data)) : data, + breakWord: true, + style: ValueStyle.BOX, + }, + ], + [message], + ); +} diff --git a/packages/ui/src/components/pages/Request/RequestSigning/hooks/useTargetAccount.ts b/packages/ui/src/components/pages/Request/RequestSigning/hooks/useTargetAccount.ts new file mode 100644 index 00000000..5ddf9579 --- /dev/null +++ b/packages/ui/src/components/pages/Request/RequestSigning/hooks/useTargetAccount.ts @@ -0,0 +1,35 @@ +import { useState } from 'react'; +import { useSelector } from 'react-redux'; +import { useAsync } from 'react-use'; +import { SignerPayloadJSON, SignerPayloadRaw } from '@polkadot/types/types'; +import { encodeAddress } from '@polkadot/util-crypto'; +import { WalletRequestWithResolver } from '@coong/base/types'; +import useThrowError from 'hooks/useThrowError'; +import { useWalletState } from 'providers/WalletStateProvider'; +import { RootState } from 'redux/store'; +import { AccountInfoExt } from 'types'; + +export default function useTargetAccount(message: WalletRequestWithResolver) { + const { keyring } = useWalletState(); + const { addressPrefix } = useSelector((state: RootState) => state.app); + const [targetAccount, setTargetAccount] = useState(); + const throwError = useThrowError(); + const { request } = message; + + useAsync(async () => { + try { + const payloadJSON = request.body as SignerPayloadJSON | SignerPayloadRaw; + const account = await keyring.getAccount(payloadJSON.address); + const networkAddress = encodeAddress(account.address, addressPrefix); + + setTargetAccount({ + ...account, + networkAddress, + }); + } catch (e: any) { + throwError(e); + } + }, [message]); + + return targetAccount; +} diff --git a/packages/ui/src/components/pages/Request/RequestSigning/hooks/useTransactionDetails.ts b/packages/ui/src/components/pages/Request/RequestSigning/hooks/useTransactionDetails.ts new file mode 100644 index 00000000..e70eb6d4 --- /dev/null +++ b/packages/ui/src/components/pages/Request/RequestSigning/hooks/useTransactionDetails.ts @@ -0,0 +1,66 @@ +import { useMemo } from 'react'; +import { TypeRegistry } from '@polkadot/types'; +import { ExtrinsicEra } from '@polkadot/types/interfaces'; +import { SignerPayloadJSON } from '@polkadot/types/types'; +import { bnToBn, formatNumber, hexToNumber } from '@polkadot/util'; +import { WalletRequestWithResolver } from '@coong/base/types'; +import { DetailRowProps } from 'components/pages/Request/RequestSigning/DetailRow'; + +function mortalityAsString(era: ExtrinsicEra, hexBlockNumber: string): string { + if (era.isImmortalEra) { + return 'Immortal'; + } + + const blockNumber = bnToBn(hexBlockNumber); + const mortal = era.asMortalEra; + const birth = formatNumber(mortal.birth(blockNumber)); + const death = formatNumber(mortal.death(blockNumber)); + + return `Mortal, valid from ${birth} to ${death}`; +} + +const registry = new TypeRegistry(); + +export default function useTransactionDetails(message: WalletRequestWithResolver): DetailRowProps[] { + const { origin, request } = message; + + const payloadJSON = request.body as SignerPayloadJSON; + + const { genesisHash, specVersion, nonce, method, blockNumber } = payloadJSON; + + registry.setSignedExtensions(payloadJSON.signedExtensions); + const { era } = registry.createType('ExtrinsicPayload', payloadJSON, { version: payloadJSON.version }); + + return useMemo( + () => [ + { + name: 'from', + value: origin, + breakWord: true, + }, + { + name: 'genesis', + value: genesisHash, + breakWord: true, + }, + { + name: 'version', + value: bnToBn(specVersion).toNumber(), + }, + { + name: 'nonce', + value: hexToNumber(nonce), + }, + { + name: 'method data', + value: method, + breakWord: true, + }, + { + name: 'life time', + value: mortalityAsString(era, blockNumber), + }, + ], + [message], + ); +} diff --git a/packages/ui/src/components/pages/Request/RequestTransactionApproval/RequestDetails.tsx b/packages/ui/src/components/pages/Request/RequestTransactionApproval/RequestDetails.tsx deleted file mode 100644 index a8e7dfe0..00000000 --- a/packages/ui/src/components/pages/Request/RequestTransactionApproval/RequestDetails.tsx +++ /dev/null @@ -1,84 +0,0 @@ -import { FC } from 'react'; -import { TypeRegistry } from '@polkadot/types'; -import { ExtrinsicEra } from '@polkadot/types/interfaces'; -import { SignerPayloadJSON } from '@polkadot/types/types'; -import { bnToBn, formatNumber, hexToNumber } from '@polkadot/util'; -import clsx from 'clsx'; -import { RequestProps } from 'components/pages/Request/types'; - -function mortalityAsString(era: ExtrinsicEra, hexBlockNumber: string): string { - if (era.isImmortalEra) { - return 'Immortal'; - } - - const blockNumber = bnToBn(hexBlockNumber); - const mortal = era.asMortalEra; - const birth = formatNumber(mortal.birth(blockNumber)); - const death = formatNumber(mortal.death(blockNumber)); - - return `Mortal, valid from ${birth} to ${death}`; -} - -const registry = new TypeRegistry(); - -interface DetailLine { - name: string; - value: any; - breakWord?: boolean; -} - -const RequestDetails: FC = ({ className, message }) => { - const { origin, request } = message; - - const payloadJSON = request.body as SignerPayloadJSON; - - const { genesisHash, specVersion, nonce, method, blockNumber } = payloadJSON; - - registry.setSignedExtensions(payloadJSON.signedExtensions); - const { era } = registry.createType('ExtrinsicPayload', payloadJSON, { version: payloadJSON.version }); - - const requestDetails: DetailLine[] = [ - { - name: 'from', - value: origin, - breakWord: true, - }, - { - name: 'genesis', - value: genesisHash, - breakWord: true, - }, - { - name: 'version', - value: bnToBn(specVersion).toNumber(), - }, - { - name: 'nonce', - value: hexToNumber(nonce), - }, - { - name: 'method data', - value: method, - breakWord: true, - }, - { - name: 'life time', - value: mortalityAsString(era, blockNumber), - }, - ]; - - return ( -
- {requestDetails.map(({ name, value, breakWord }) => ( -
-
{name}:
-
- {value} -
-
- ))} -
- ); -}; - -export default RequestDetails; diff --git a/packages/ui/src/components/pages/Request/RequestTransactionApproval/__tests__/RequestTransactionApproval.spec.tsx b/packages/ui/src/components/pages/Request/RequestTransactionApproval/__tests__/RequestTransactionApproval.spec.tsx deleted file mode 100644 index e758c5ab..00000000 --- a/packages/ui/src/components/pages/Request/RequestTransactionApproval/__tests__/RequestTransactionApproval.spec.tsx +++ /dev/null @@ -1,179 +0,0 @@ -import { defaultNetwork, newWalletErrorResponse, newWalletRequest } from '@coong/base'; -import { WalletRequestMessage } from '@coong/base/types'; -import Keyring from '@coong/keyring'; -import { AccountInfo } from '@coong/keyring/types'; -import { SpyInstance } from '@vitest/spy'; -import { - initializeKeyring, - newUser, - PASSWORD, - render, - RouterWrapper, - screen, - setupAuthorizedApps, - UserEvent, - waitFor, -} from '__tests__/testUtils'; -import { Mock } from 'vitest'; -import Request from '../../index'; - -const newPayload = (address: string) => { - return { - specVersion: '0x00002490', - transactionVersion: '0x00000013', - address, - blockHash: '0x740c0ff582a5f5ed089a83afe396be64db42486397ee23611811e123a70bd63f', - blockNumber: '0x00dd836d', - era: '0xd502', - genesisHash: '0x91b171bb158e2d3848fa23a9f1c25182fb8e20313b2c1eb49219da7a70ce90c3', - method: '0x050000004769bbe59968882c1597ec1151621f0193547285125f1c1337371c013ff61f02890700', - nonce: '0x00000000', - signedExtensions: [ - 'CheckNonZeroSender', - 'CheckSpecVersion', - 'CheckTxVersion', - 'CheckGenesis', - 'CheckMortality', - 'CheckNonce', - 'CheckWeight', - 'ChargeTransactionPayment', - 'PrevalidateAttests', - ], - tip: '0x00000000000000000000000000000000', - version: 4, - }; -}; - -describe('RequestTransactionApproval', () => { - const preloadedState = { app: { seedReady: true, addressPrefix: defaultNetwork.prefix } }; - let windowClose: SpyInstance, - postMessage: Mock, - requestUrl: string, - user: UserEvent, - requestAccessMessage: WalletRequestMessage, - account01: AccountInfo, - keyring: Keyring; - - beforeEach(async () => { - windowClose = vi.spyOn(window, 'close').mockImplementation(() => vi.fn()); - - postMessage = vi.fn(); - window.opener = { postMessage }; - - keyring = await initializeKeyring(); - account01 = await keyring.createNewAccount('Account 01', PASSWORD); - - requestAccessMessage = newWalletRequest({ name: 'tab/signExtrinsic', body: newPayload(account01.address) }); - const queryParam = new URLSearchParams({ - message: JSON.stringify(requestAccessMessage), - }).toString(); - requestUrl = `/request?${queryParam}`; - - user = newUser(); - }); - - describe('app is not authorized', () => { - it('should reject request & close window', async () => { - render( - - - , - { preloadedState }, - ); - - await waitFor(() => { - const expectedResponse = newWalletErrorResponse( - `The app at ${requestAccessMessage.origin} has not been authorized yet!`, - requestAccessMessage.id, - ); - expect(postMessage).toHaveBeenNthCalledWith(2, expectedResponse, requestAccessMessage.origin); - }); - }); - }); - - describe('app is authorized', () => { - beforeEach(() => { - setupAuthorizedApps([account01.address], window.location.origin); - }); - - it('should display the request correctly', async () => { - render( - - - , - { preloadedState }, - ); - - expect(await screen.findByText(/Transaction Approval Request/)).toBeInTheDocument(); - expect(await screen.findByText(/You are approving a transaction with account/)).toBeInTheDocument(); - expect(await screen.findByText(/Account 01/)).toBeInTheDocument(); - expect(await screen.findByTestId('row-from')).toHaveTextContent(`from: ${window.location.origin}`); - expect(await screen.findByTestId('row-method-data')).toHaveTextContent( - // @ts-ignore - `method data: ${requestAccessMessage.request.body.method}`, - ); - - expect(await screen.findByLabelText('Wallet password')).toBeInTheDocument(); - expect(await screen.findByRole('button', { name: /Approve Transaction/ })).toBeDisabled(); - expect(await screen.findByRole('button', { name: /Cancel/ })).toBeEnabled(); - }); - - it('should show error message for incorrect password', async () => { - render( - - - , - { preloadedState }, - ); - - const passwordField = await screen.findByLabelText('Wallet password'); - await user.type(passwordField, 'incorrect-password'); - - const approvalButton = await screen.findByRole('button', { name: /Approve Transaction/ }); - expect(approvalButton).toBeEnabled(); - await user.click(approvalButton); - - expect(await screen.findByText('Password incorrect')).toBeInTheDocument(); - }); - - it('should post back message and close window for correct password', async () => { - render( - - - , - { preloadedState }, - ); - - const passwordField = await screen.findByLabelText('Wallet password'); - await user.type(passwordField, PASSWORD); - - const approvalButton = await screen.findByRole('button', { name: /Approve Transaction/ }); - expect(approvalButton).toBeEnabled(); - await user.click(approvalButton); - - await waitFor(() => { - expect(postMessage).toHaveBeenCalledTimes(2); // 1: initialized signal, 2: signature message - }); - expect(windowClose).toBeCalled(); - }); - - it('should post back message and close window if cancelling', async () => { - render( - - - , - { preloadedState }, - ); - - const cancelButton = await screen.findByRole('button', { name: /Cancel/ }); - expect(cancelButton).toBeEnabled(); - await user.click(cancelButton); - - await waitFor(() => { - const expectedResponse = newWalletErrorResponse('Cancelled', requestAccessMessage.id); - expect(postMessage).toHaveBeenNthCalledWith(2, expectedResponse, requestAccessMessage.origin); - }); - expect(windowClose).toBeCalled(); - }); - }); -}); diff --git a/packages/ui/src/components/pages/Request/RequestTransactionApproval/index.tsx b/packages/ui/src/components/pages/Request/RequestTransactionApproval/index.tsx deleted file mode 100644 index ba2fac59..00000000 --- a/packages/ui/src/components/pages/Request/RequestTransactionApproval/index.tsx +++ /dev/null @@ -1,95 +0,0 @@ -import { FC, FormEvent, useState } from 'react'; -import { useTranslation } from 'react-i18next'; -import { useSelector } from 'react-redux'; -import { Form } from 'react-router-dom'; -import { toast } from 'react-toastify'; -import { useAsync, useToggle } from 'react-use'; -import { SignerPayloadJSON } from '@polkadot/types/types'; -import { encodeAddress } from '@polkadot/util-crypto'; -import { Button, TextField } from '@mui/material'; -import AccountCard from 'components/pages/Accounts/AccountCard'; -import RequestDetails from 'components/pages/Request/RequestTransactionApproval/RequestDetails'; -import { RequestProps } from 'components/pages/Request/types'; -import useThrowError from 'hooks/useThrowError'; -import { useWalletState } from 'providers/WalletStateProvider'; -import { RootState } from 'redux/store'; -import { AccountInfoExt } from 'types'; - -const RequestTransactionApproval: FC = ({ className, message }) => { - const { keyring, walletState } = useWalletState(); - const { addressPrefix } = useSelector((state: RootState) => state.app); - const [password, setPassword] = useState(''); - const { request } = message; - const [loading, toggleLoading] = useToggle(false); - const [targetAccount, setTargetAccount] = useState(); - const throwError = useThrowError(); - const { t } = useTranslation(); - - useAsync(async () => { - try { - const payloadJSON = request.body as SignerPayloadJSON; - const account = await keyring.getAccount(payloadJSON.address); - const networkAddress = encodeAddress(account.address, addressPrefix); - - setTargetAccount({ - ...account, - networkAddress, - }); - } catch (e: any) { - throwError(e); - } - }, []); - - const approveTransaction = (e: FormEvent) => { - e.preventDefault(); - toggleLoading(true); - - // signing & accounts decryption are synchronous operations - // and might take some time to do - // so we delay it a short amount of time to make sure the UI could be updated (disable button, ...) - // before the signing process begin - // TODO: Moving CPU-intensive operations to worker - setTimeout(async () => { - try { - await walletState.approveSignExtrinsic(password); - } catch (e: any) { - toggleLoading(false); - toast.error(t(e.message)); - } - }, 200); - }; - - const cancelRequest = () => { - walletState.cancelSignExtrinsic(); - }; - - return ( -
-

Transaction Approval Request

-

You are approving a transaction with account

- {targetAccount && } - -
- setPassword(event.target.value)} - /> -
- - -
- -
- ); -}; - -export default RequestTransactionApproval; diff --git a/yarn.lock b/yarn.lock index a6ae1fe8..9a5a69f6 100644 --- a/yarn.lock +++ b/yarn.lock @@ -375,6 +375,7 @@ __metadata: "@polkadot/networks": ^10.4.1 "@polkadot/types": ^9.14.1 "@polkadot/ui-keyring": ^2.11.2 + "@polkadot/util": ^10.4.1 "@polkadot/util-crypto": ^10.4.1 rxjs: ^7.8.0 languageName: unknown