diff --git a/.changelog/1923.feature.md b/.changelog/1923.feature.md new file mode 100644 index 0000000000..b54beb14a8 --- /dev/null +++ b/.changelog/1923.feature.md @@ -0,0 +1 @@ +Allow to use eth private key that starts with 0x diff --git a/playwright/tests/paraTimes.spec.ts b/playwright/tests/paraTimes.spec.ts index aef21603d3..2dd7457f5e 100644 --- a/playwright/tests/paraTimes.spec.ts +++ b/playwright/tests/paraTimes.spec.ts @@ -1,5 +1,5 @@ import { test, expect } from '@playwright/test' -import { privateKey, privateKeyAddress } from '../../src/utils/__fixtures__/test-inputs' +import { privateKey, privateKeyAddress, ethAccount } from '../../src/utils/__fixtures__/test-inputs' import { fillPrivateKeyWithoutPassword } from '../utils/fillPrivateKey' import { warnSlowApi } from '../utils/warnSlowApi' import { mockApi } from '../utils/mockApi' @@ -33,4 +33,40 @@ test.describe('ParaTimes', () => { await page.getByRole('button', { name: /Next/i }).click() await expect(page.getByPlaceholder('0x...')).toHaveValue('') }) + + test('should validate eth private key', async ({ page }) => { + const validKey = ethAccount.privateKey + const validKeyWithPrefix = `0x${validKey}` + const invalidKey = validKey.replace('c', 'g') + const invalidKeyWithPrefix = `0x${invalidKey}` + + async function testPrivateKeyValidation(key, expected) { + await page.getByPlaceholder('Enter Ethereum-compatible private key').fill(key) + await page.getByRole('button', { name: 'Next' }).click() + await expect(page.getByText(expected)).toBeVisible() + } + + await page.goto('/open-wallet/private-key') + await fillPrivateKeyWithoutPassword(page, { + privateKey: privateKey, + privateKeyAddress: privateKeyAddress, + persistenceCheckboxChecked: false, + persistenceCheckboxDisabled: false, + }) + await page.getByTestId('nav-paratime').click() + await page.getByRole('button', { name: /Withdraw/i }).click() + await page.getByRole('button', { name: 'Select a ParaTime' }).click() + await expect(page.getByRole('listbox')).toBeVisible() + await page.getByRole('listbox').locator('button', { hasText: 'Sapphire' }).click() + await page.getByRole('button', { name: 'Next' }).click() + await page.getByPlaceholder(privateKeyAddress).fill(privateKeyAddress) + // valid eth private keys + await testPrivateKeyValidation(validKey, /enter the amount/) + await page.getByRole('button', { name: 'Back' }).click() + await testPrivateKeyValidation(validKeyWithPrefix, /enter the amount/) + await page.getByRole('button', { name: 'Back' }).click() + // invalid eth private keys + await testPrivateKeyValidation(invalidKey, /private key is invalid/) + await testPrivateKeyValidation(invalidKeyWithPrefix, /private key is invalid/) + }) }) diff --git a/src/app/lib/eth-helpers.ts b/src/app/lib/eth-helpers.ts index adb2d9cbf8..ef80464ba9 100644 --- a/src/app/lib/eth-helpers.ts +++ b/src/app/lib/eth-helpers.ts @@ -1,7 +1,7 @@ import * as oasis from '@oasisprotocol/client' import * as oasisRT from '@oasisprotocol/client-rt' import { bytesToHex, isValidPrivate, privateToAddress, toChecksumAddress } from '@ethereumjs/util' -export { isValidAddress as isValidEthAddress } from '@ethereumjs/util' +export { isValidAddress as isValidEthAddress, stripHexPrefix } from '@ethereumjs/util' export const hexToBuffer = (value: string): Buffer => Buffer.from(value, 'hex') export const isValidEthPrivateKey = (ethPrivateKey: string): boolean => { diff --git a/src/app/pages/ParaTimesPage/TransactionRecipient/__tests__/index.test.tsx b/src/app/pages/ParaTimesPage/TransactionRecipient/__tests__/index.test.tsx index dfbec81393..797d8a60bc 100644 --- a/src/app/pages/ParaTimesPage/TransactionRecipient/__tests__/index.test.tsx +++ b/src/app/pages/ParaTimesPage/TransactionRecipient/__tests__/index.test.tsx @@ -1,7 +1,7 @@ import * as React from 'react' import { render, screen } from '@testing-library/react' import userEvent from '@testing-library/user-event' -import { TransactionTypes } from 'app/state/paratimes/types' +import { TransactionForm, TransactionTypes } from 'app/state/paratimes/types' import { useParaTimes, ParaTimesHook } from '../../useParaTimes' import { useParaTimesNavigation, ParaTimesNavigationHook } from '../../useParaTimesNavigation' import { TransactionRecipient } from '..' @@ -25,7 +25,7 @@ describe('', () => { ticker: 'ROSE', transactionForm: { recipient: '', - ethPrivateKey: '', + ethPrivateKeyRaw: '', }, usesOasisAddress: true, } as ParaTimesHook @@ -123,7 +123,7 @@ describe('', () => { ...mockUseParaTimesEVMcResult, transactionForm: { ...mockUseParaTimesEVMcResult.transactionForm, - ethPrivateKey: '123', + ethPrivateKeyRaw: '123', }, }) jest.mocked(useParaTimesNavigation).mockReturnValue({ @@ -144,7 +144,7 @@ describe('', () => { ...mockUseParaTimesEVMcResult, transactionForm: { ...mockUseParaTimesEVMcResult.transactionForm, - ethPrivateKey: '----------------------------------------------------------------', + ethPrivateKeyRaw: '----------------------------------------------------------------', }, }) jest.mocked(useParaTimesNavigation).mockReturnValue({ @@ -160,13 +160,18 @@ describe('', () => { }) it('should navigate to amount selection step when address is valid', async () => { + const ethPrivateKey = mockUseParaTimesEVMcResult.evmAccounts[0].ethPrivateKey + const ethPrivateKeyWith0xPrefix = `0x${ethPrivateKey}` + const setTransactionForm = jest.fn() const navigateToAmount = jest.fn() jest.mocked(useParaTimes).mockReturnValue({ ...mockUseParaTimesResult, + setTransactionForm, transactionForm: { recipient: 'oasis1qq3xrq0urs8qcffhvmhfhz4p0mu7ewc8rscnlwxe', - }, - } as ParaTimesHook) + ethPrivateKeyRaw: ethPrivateKeyWith0xPrefix, + } as TransactionForm, + }) jest.mocked(useParaTimesNavigation).mockReturnValue({ ...mockUseParaTimesNavigationResult, navigateToAmount, @@ -175,6 +180,11 @@ describe('', () => { await userEvent.click(screen.getByRole('button', { name: 'Next' })) + expect(setTransactionForm).toHaveBeenCalledWith({ + ethPrivateKey: ethPrivateKey, + ethPrivateKeyRaw: ethPrivateKeyWith0xPrefix, + recipient: 'oasis1qq3xrq0urs8qcffhvmhfhz4p0mu7ewc8rscnlwxe', + }) expect(navigateToAmount).toHaveBeenCalled() }) diff --git a/src/app/pages/ParaTimesPage/TransactionRecipient/index.tsx b/src/app/pages/ParaTimesPage/TransactionRecipient/index.tsx index b17ccac7b1..f304d66e60 100644 --- a/src/app/pages/ParaTimesPage/TransactionRecipient/index.tsx +++ b/src/app/pages/ParaTimesPage/TransactionRecipient/index.tsx @@ -13,6 +13,7 @@ import { useParaTimes } from '../useParaTimes' import { useParaTimesNavigation } from '../useParaTimesNavigation' import { PasswordField } from 'app/components/PasswordField' import { preventSavingInputsToUserData } from 'app/lib/preventSavingInputsToUserData' +import { stripHexPrefix } from '../../../lib/eth-helpers' export const TransactionRecipient = () => { const { t } = useTranslation() @@ -65,13 +66,19 @@ export const TransactionRecipient = () => { onChange={nextValue => setTransactionForm({ ...nextValue, - ethPrivateKey: - typeof nextValue.ethPrivateKey === 'object' - ? (nextValue.ethPrivateKey as any).value // from suggestions - : nextValue.ethPrivateKey, + ethPrivateKeyRaw: + typeof nextValue.ethPrivateKeyRaw === 'object' + ? (nextValue.ethPrivateKeyRaw as any).value // from suggestions + : nextValue.ethPrivateKeyRaw, }) } - onSubmit={navigateToAmount} + onSubmit={formData => { + setTransactionForm({ + ...formData.value, + ethPrivateKey: stripHexPrefix(formData.value.ethPrivateKeyRaw), + }) + navigateToAmount() + }} value={transactionForm} style={{ width: isMobile ? '100%' : '465px' }} {...preventSavingInputsToUserData} @@ -79,15 +86,15 @@ export const TransactionRecipient = () => { {isEvmcParaTime && !isDepositing && ( - !isValidEthPrivateKeyLength(ethPrivateKey) + inputElementId="ethPrivateKeyRaw" + name="ethPrivateKeyRaw" + validate={ethPrivateKeyRaw => + !isValidEthPrivateKeyLength(stripHexPrefix(ethPrivateKeyRaw)) ? t( 'paraTimes.validation.invalidEthPrivateKeyLength', 'Private key should be 64 characters long', ) - : !isValidEthPrivateKey(ethPrivateKey) + : !isValidEthPrivateKey(stripHexPrefix(ethPrivateKeyRaw)) ? t( 'paraTimes.validation.invalidEthPrivateKey', 'Ethereum-compatible private key is invalid', @@ -98,7 +105,7 @@ export const TransactionRecipient = () => { 'paraTimes.recipient.ethPrivateKeyPlaceholder', 'Enter Ethereum-compatible private key', )} - value={transactionForm.ethPrivateKey} + value={transactionForm.ethPrivateKeyRaw} showTip={t('openWallet.privateKey.showPrivateKey', 'Show private key')} hideTip={t('openWallet.privateKey.hidePrivateKey', 'Hide private key')} suggestions={evmAccounts.map(acc => ({ label: acc.ethAddress, value: acc.ethPrivateKey }))} diff --git a/src/app/state/paratimes/index.ts b/src/app/state/paratimes/index.ts index 47f378b10a..016fd41bb8 100644 --- a/src/app/state/paratimes/index.ts +++ b/src/app/state/paratimes/index.ts @@ -14,6 +14,7 @@ export const initialState: ParaTimesState = { confirmTransferToForeignAccount: false, defaultFeeAmount: '', ethPrivateKey: '', + ethPrivateKeyRaw: '', feeAmount: '', feeGas: '', paraTime: undefined, diff --git a/src/app/state/paratimes/types.ts b/src/app/state/paratimes/types.ts index de03b8e4f0..45dddfd5f5 100644 --- a/src/app/state/paratimes/types.ts +++ b/src/app/state/paratimes/types.ts @@ -23,7 +23,10 @@ export interface TransactionForm { confirmTransferToValidator: boolean confirmTransferToForeignAccount: boolean defaultFeeAmount: string + // compatible with oasisRT.signatureSecp256k1 ethPrivateKey: string + // provided by user and used in form inputs allowing back and forth form navigation + ethPrivateKeyRaw: string feeAmount: string feeGas: string paraTime?: ParaTime diff --git a/src/utils/__fixtures__/test-inputs.ts b/src/utils/__fixtures__/test-inputs.ts index 7e6a49f192..1eca4d07f0 100644 --- a/src/utils/__fixtures__/test-inputs.ts +++ b/src/utils/__fixtures__/test-inputs.ts @@ -80,6 +80,7 @@ export const privateKeyUnlockedState = { confirmTransferToForeignAccount: false, defaultFeeAmount: '', ethPrivateKey: '', + ethPrivateKeyRaw: '', feeAmount: '', feeGas: '', paraTime: undefined, @@ -213,6 +214,11 @@ export const walletExtensionV0PersistedState = { }, } satisfies WalletExtensionV0State +export const ethAccount = { + address: '0xbA1b346233E5bB5b44f5B4aC6bF224069f427b18', + privateKey: '6593a788d944bb3e25357df140fac5b0e6273f1500a3b37d6513bf9e9807afe2', +} + export const walletExtensionV0UnlockedState = { account: { address: 'oasis1qq30ejf9puuc6qnrazmy9dmn7f3gessveum5wnr6', @@ -235,9 +241,9 @@ export const walletExtensionV0UnlockedState = { }, }, evmAccounts: { - '0xbA1b346233E5bB5b44f5B4aC6bF224069f427b18': { - ethAddress: '0xbA1b346233E5bB5b44f5B4aC6bF224069f427b18', - ethPrivateKey: '6593a788d944bb3e25357df140fac5b0e6273f1500a3b37d6513bf9e9807afe2', + [ethAccount.address]: { + ethAddress: ethAccount.address, + ethPrivateKey: ethAccount.privateKey, }, }, createWallet: { checkbox: false, mnemonic: [] }, @@ -268,6 +274,7 @@ export const walletExtensionV0UnlockedState = { confirmTransferToForeignAccount: false, defaultFeeAmount: '', ethPrivateKey: '', + ethPrivateKeyRaw: '', feeAmount: '', feeGas: '', paraTime: undefined,