diff --git a/libs/accounts/src/lib/transfer-container.tsx b/libs/accounts/src/lib/transfer-container.tsx index c831fe624..fc9af6bec 100644 --- a/libs/accounts/src/lib/transfer-container.tsx +++ b/libs/accounts/src/lib/transfer-container.tsx @@ -9,12 +9,17 @@ import { useDataProvider } from '@vegaprotocol/data-provider'; import type { Transfer } from '@vegaprotocol/wallet'; import { useVegaTransactionStore } from '@vegaprotocol/web3'; import { useVegaWallet } from '@vegaprotocol/wallet'; -import { useCallback, useMemo } from 'react'; +import { useCallback } from 'react'; import { accountsDataProvider } from './accounts-data-provider'; import { TransferForm } from './transfer-form'; import sortBy from 'lodash/sortBy'; import { Lozenge } from '@vegaprotocol/ui-toolkit'; +export const ALLOWED_ACCOUNTS = [ + Schema.AccountType.ACCOUNT_TYPE_GENERAL, + Schema.AccountType.ACCOUNT_TYPE_VESTED_REWARDS, +]; + export const TransferContainer = ({ assetId }: { assetId?: string }) => { const { pubKey, pubKeys } = useVegaWallet(); const { param } = useNetworkParam(NetworkParams.transfer_fee_factor); @@ -33,20 +38,19 @@ export const TransferContainer = ({ assetId }: { assetId?: string }) => { [create] ); - const assets = useMemo(() => { - if (!data) return []; - return data - .filter( - (account) => account.type === Schema.AccountType.ACCOUNT_TYPE_GENERAL - ) - .map((account) => ({ - id: account.asset.id, - symbol: account.asset.symbol, - name: account.asset.name, - decimals: account.asset.decimals, - balance: addDecimal(account.balance, account.asset.decimals), - })); - }, [data]); + const accounts = data + ? data.filter((account) => ALLOWED_ACCOUNTS.includes(account.type)) + : []; + + const assets = accounts.map((account) => ({ + id: account.asset.id, + symbol: account.asset.symbol, + name: account.asset.name, + decimals: account.asset.decimals, + balance: addDecimal(account.balance, account.asset.decimals), + })); + + if (data === null) return null; return ( <> @@ -69,6 +73,7 @@ export const TransferContainer = ({ assetId }: { assetId?: string }) => { assetId={assetId} feeFactor={param} submitTransfer={transfer} + accounts={accounts} /> ); diff --git a/libs/accounts/src/lib/transfer-form.spec.tsx b/libs/accounts/src/lib/transfer-form.spec.tsx index 2e9bfafcf..886bfe556 100644 --- a/libs/accounts/src/lib/transfer-form.spec.tsx +++ b/libs/accounts/src/lib/transfer-form.spec.tsx @@ -1,18 +1,38 @@ -import { - act, - fireEvent, - render, - screen, - waitFor, -} from '@testing-library/react'; +import { fireEvent, render, screen, waitFor } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; import BigNumber from 'bignumber.js'; import { AddressField, TransferFee, TransferForm } from './transfer-form'; import { AccountType } from '@vegaprotocol/types'; import { addDecimal, formatNumber, removeDecimal } from '@vegaprotocol/utils'; -import userEvent from '@testing-library/user-event'; describe('TransferForm', () => { - const submit = () => fireEvent.submit(screen.getByTestId('transfer-form')); + const submit = async () => { + await userEvent.click( + screen.getByRole('button', { name: 'Confirm transfer' }) + ); + }; + + const selectAsset = async (asset: { + id: string; + balance: string; + name: string; + decimals: number; + }) => { + // Bypass RichSelect and target hidden native select + // eslint-disable-next-line + fireEvent.change(document.querySelector('select[name="asset"]')!, { + target: { value: asset.id }, + }); + + // assert rich select as updated + expect(await screen.findByTestId('select-asset')).toHaveTextContent( + asset.name + ); + expect(await screen.findByTestId('asset-balance')).toHaveTextContent( + formatNumber(asset.balance, asset.decimals) + ); + }; + const amount = '100'; const pubKey = '70d14a321e02e71992fd115563df765000ccc4775cbe71a0e2f9ff5a3b9dc680'; @@ -32,6 +52,18 @@ describe('TransferForm', () => { assets: [asset], feeFactor: '0.001', submitTransfer: jest.fn(), + accounts: [ + { + type: AccountType.ACCOUNT_TYPE_GENERAL, + asset, + balance: '100', + }, + { + type: AccountType.ACCOUNT_TYPE_VESTED_REWARDS, + asset, + balance: '100', + }, + ], }; it('form tooltips correctly displayed', async () => { @@ -42,49 +74,45 @@ describe('TransferForm', () => { // 1003-TRAN-019 render(); // Select a pubkey - fireEvent.change(screen.getByLabelText('Vega key'), { - target: { value: props.pubKeys[1] }, - }); + await userEvent.selectOptions( + screen.getByLabelText('Vega key'), + props.pubKeys[1] + ); // Select asset - fireEvent.change( - // Bypass RichSelect and target hidden native select - // eslint-disable-next-line - document.querySelector('select[name="asset"]')!, - { target: { value: asset.id } } - ); + await selectAsset(asset); + // set valid amount - fireEvent.change(screen.getByLabelText('Amount'), { - target: { value: amount }, - }); + const amountInput = screen.getByLabelText('Amount'); + await userEvent.type(amountInput, amount); + expect(amountInput).toHaveValue(amount); - userEvent.hover(screen.getByText('Include transfer fee')); + const includeTransferLabel = screen.getByText('Include transfer fee'); + await userEvent.hover(includeTransferLabel); + expect(await screen.findByRole('tooltip')).toHaveTextContent( + 'The fee will be taken from the amount you are transferring.' + ); + await userEvent.unhover(screen.getByText('Include transfer fee')); - await waitFor(() => { - const tooltips = screen.getAllByTestId('tooltip-content'); - expect(tooltips[0]).toBeVisible(); - }); + const transferFee = screen.getByText('Transfer fee'); + await userEvent.hover(transferFee); + expect(await screen.findByRole('tooltip')).toHaveTextContent( + /transfer.fee.factor/ + ); + await userEvent.unhover(transferFee); - userEvent.hover(screen.getByText('Transfer fee')); + const amountToBeTransferred = screen.getByText('Amount to be transferred'); + await userEvent.hover(amountToBeTransferred); + expect(await screen.findByRole('tooltip')).toHaveTextContent( + /without the fee/ + ); + await userEvent.unhover(amountToBeTransferred); - await waitFor(() => { - const tooltips = screen.getAllByTestId('tooltip-content'); - expect(tooltips[0]).toBeVisible(); - }); - - userEvent.hover(screen.getByText('Amount to be transferred')); - - await waitFor(() => { - const tooltips = screen.getAllByTestId('tooltip-content'); - expect(tooltips[0]).toBeVisible(); - }); - - userEvent.hover(screen.getByText('Total amount (with fee)')); - - await waitFor(() => { - const tooltips = screen.getAllByTestId('tooltip-content'); - expect(tooltips[0]).toBeVisible(); - }); + const totalAmountWithFee = screen.getByText('Total amount (with fee)'); + await userEvent.hover(totalAmountWithFee); + expect(await screen.findByRole('tooltip')).toHaveTextContent( + /total amount taken from your account/ + ); }); it('validates a manually entered address', async () => { @@ -92,30 +120,17 @@ describe('TransferForm', () => { // 1003-TRAN-013 // 1003-TRAN-004 render(); - submit(); - expect(await screen.findAllByText('Required')).toHaveLength(3); + await submit(); + expect(await screen.findAllByText('Required')).toHaveLength(4); const toggle = screen.getByText('Enter manually'); - fireEvent.click(toggle); + await userEvent.click(toggle); // has switched to input expect(toggle).toHaveTextContent('Select from wallet'); expect(screen.getByLabelText('Vega key')).toHaveAttribute('type', 'text'); - fireEvent.change(screen.getByLabelText('Vega key'), { - target: { value: 'invalid-address' }, - }); - await waitFor(() => { - const errors = screen.getAllByTestId('input-error-text'); - expect(errors[0]).toHaveTextContent('Invalid Vega key'); - }); - - // same pubkey - fireEvent.change(screen.getByLabelText('Vega key'), { - target: { value: pubKey }, - }); - - await waitFor(() => { - const errors = screen.getAllByTestId('input-error-text'); - expect(errors[0]).toHaveTextContent('Vega key is the same'); - }); + await userEvent.type(screen.getByLabelText('Vega key'), 'invalid-address'); + expect(screen.getAllByTestId('input-error-text')[0]).toHaveTextContent( + 'Invalid Vega key' + ); }); it('validates fields and submits', async () => { @@ -127,28 +142,25 @@ describe('TransferForm', () => { render(); // check current pubkey not shown - const keySelect: HTMLSelectElement = screen.getByLabelText('Vega key'); - expect(keySelect.children).toHaveLength(2); + const keySelect = screen.getByLabelText('Vega key'); + expect(keySelect.children).toHaveLength(3); expect(Array.from(keySelect.options).map((o) => o.value)).toEqual([ '', + pubKey, props.pubKeys[1], ]); - submit(); - expect(await screen.findAllByText('Required')).toHaveLength(3); + await submit(); + expect(await screen.findAllByText('Required')).toHaveLength(4); // Select a pubkey - fireEvent.change(screen.getByLabelText('Vega key'), { - target: { value: props.pubKeys[1] }, - }); + await userEvent.selectOptions( + screen.getByLabelText('Vega key'), + props.pubKeys[1] + ); // Select asset - fireEvent.change( - // Bypass RichSelect and target hidden native select - // eslint-disable-next-line - document.querySelector('select[name="asset"]')!, - { target: { value: asset.id } } - ); + await selectAsset(asset); // assert rich select as updated expect(await screen.findByTestId('select-asset')).toHaveTextContent( @@ -158,37 +170,38 @@ describe('TransferForm', () => { formatNumber(asset.balance, asset.decimals) ); + await userEvent.selectOptions( + screen.getByLabelText('From account'), + AccountType.ACCOUNT_TYPE_VESTED_REWARDS + ); + const amountInput = screen.getByLabelText('Amount'); // Test amount validation - fireEvent.change(amountInput, { - target: { value: '0.00000001' }, - }); + await userEvent.type(amountInput, '0.00000001'); expect( await screen.findByText('Value is below minimum') ).toBeInTheDocument(); - fireEvent.change(amountInput, { - target: { value: '9999999' }, - }); + await userEvent.clear(amountInput); + await userEvent.type(amountInput, '9999999'); expect( await screen.findByText(/cannot transfer more/i) ).toBeInTheDocument(); // set valid amount - fireEvent.change(amountInput, { - target: { value: amount }, - }); + await userEvent.clear(amountInput); + await userEvent.type(amountInput, amount); expect(screen.getByTestId('transfer-fee')).toHaveTextContent( new BigNumber(props.feeFactor).times(amount).toFixed() ); - submit(); + await submit(); await waitFor(() => { expect(props.submitTransfer).toHaveBeenCalledTimes(1); expect(props.submitTransfer).toHaveBeenCalledWith({ - fromAccountType: AccountType.ACCOUNT_TYPE_GENERAL, + fromAccountType: AccountType.ACCOUNT_TYPE_VESTED_REWARDS, toAccountType: AccountType.ACCOUNT_TYPE_GENERAL, to: props.pubKeys[1], asset: asset.id, @@ -200,59 +213,50 @@ describe('TransferForm', () => { describe('IncludeFeesCheckbox', () => { it('validates fields and submits when checkbox is checked', async () => { - render(); + const mockSubmit = jest.fn(); + render(); // check current pubkey not shown - const keySelect: HTMLSelectElement = screen.getByLabelText('Vega key'); - expect(keySelect.children).toHaveLength(2); - expect(Array.from(keySelect.options).map((o) => o.value)).toEqual([ - '', - props.pubKeys[1], - ]); + const keySelect = screen.getByLabelText('Vega key'); + const pubKeyOptions = ['', pubKey, props.pubKeys[1]]; + expect(keySelect.children).toHaveLength(pubKeyOptions.length); + expect(Array.from(keySelect.options).map((o) => o.value)).toEqual( + pubKeyOptions + ); - submit(); - expect(await screen.findAllByText('Required')).toHaveLength(3); + await submit(); + expect(await screen.findAllByText('Required')).toHaveLength(4); // Select a pubkey - fireEvent.change(screen.getByLabelText('Vega key'), { - target: { value: props.pubKeys[1] }, - }); + await userEvent.selectOptions( + screen.getByLabelText('Vega key'), + props.pubKeys[1] + ); // Select asset - fireEvent.change( - // Bypass RichSelect and target hidden native select - // eslint-disable-next-line - document.querySelector('select[name="asset"]')!, - { target: { value: asset.id } } - ); + await selectAsset(asset); - // assert rich select as updated - expect(await screen.findByTestId('select-asset')).toHaveTextContent( - asset.name - ); - expect(await screen.findByTestId('asset-balance')).toHaveTextContent( - formatNumber(asset.balance, asset.decimals) + await userEvent.selectOptions( + screen.getByLabelText('From account'), + AccountType.ACCOUNT_TYPE_VESTED_REWARDS ); const amountInput = screen.getByLabelText('Amount'); const checkbox = screen.getByTestId('include-transfer-fee'); + // 1003-TRAN-022 expect(checkbox).not.toBeChecked(); - act(() => { - /* fire events that update state */ - // set valid amount - fireEvent.change(amountInput, { - target: { value: amount }, - }); - // check include fees checkbox - fireEvent.click(checkbox); - }); + + await userEvent.clear(amountInput); + await userEvent.type(amountInput, amount); + await userEvent.click(checkbox); expect(checkbox).toBeChecked(); const expectedFee = new BigNumber(amount) .times(props.feeFactor) .toFixed(); const expectedAmount = new BigNumber(amount).minus(expectedFee).toFixed(); + // 1003-TRAN-020 expect(screen.getByTestId('transfer-fee')).toHaveTextContent(expectedFee); expect(screen.getByTestId('transfer-amount')).toHaveTextContent( @@ -262,18 +266,17 @@ describe('TransferForm', () => { amount ); - submit(); + await submit(); await waitFor(() => { // 1003-TRAN-023 - - expect(props.submitTransfer).toHaveBeenCalledTimes(1); - expect(props.submitTransfer).toHaveBeenCalledWith({ - fromAccountType: AccountType.ACCOUNT_TYPE_GENERAL, + expect(mockSubmit).toHaveBeenCalledTimes(1); + expect(mockSubmit).toHaveBeenCalledWith({ + fromAccountType: AccountType.ACCOUNT_TYPE_VESTED_REWARDS, toAccountType: AccountType.ACCOUNT_TYPE_GENERAL, to: props.pubKeys[1], asset: asset.id, - amount: removeDecimal(amount, asset.decimals), + amount: removeDecimal(expectedAmount, asset.decimals), oneOff: {}, }); }); @@ -284,46 +287,29 @@ describe('TransferForm', () => { // check current pubkey not shown const keySelect: HTMLSelectElement = screen.getByLabelText('Vega key'); - expect(keySelect.children).toHaveLength(2); - expect(Array.from(keySelect.options).map((o) => o.value)).toEqual([ - '', - props.pubKeys[1], - ]); + const pubKeyOptions = ['', pubKey, props.pubKeys[1]]; + expect(keySelect.children).toHaveLength(pubKeyOptions.length); + expect(Array.from(keySelect.options).map((o) => o.value)).toEqual( + pubKeyOptions + ); - submit(); - expect(await screen.findAllByText('Required')).toHaveLength(3); + await submit(); + expect(await screen.findAllByText('Required')).toHaveLength(4); // Select a pubkey - fireEvent.change(screen.getByLabelText('Vega key'), { - target: { value: props.pubKeys[1] }, - }); + await userEvent.selectOptions( + screen.getByLabelText('Vega key'), + props.pubKeys[1] + ); // Select asset - fireEvent.change( - // Bypass RichSelect and target hidden native select - // eslint-disable-next-line - document.querySelector('select[name="asset"]')!, - { target: { value: asset.id } } - ); - - // assert rich select as updated - expect(await screen.findByTestId('select-asset')).toHaveTextContent( - asset.name - ); - expect(await screen.findByTestId('asset-balance')).toHaveTextContent( - formatNumber(asset.balance, asset.decimals) - ); + await selectAsset(asset); const amountInput = screen.getByLabelText('Amount'); const checkbox = screen.getByTestId('include-transfer-fee'); expect(checkbox).not.toBeChecked(); - act(() => { - /* fire events that update state */ - // set valid amount - fireEvent.change(amountInput, { - target: { value: amount }, - }); - }); + + await userEvent.type(amountInput, amount); expect(checkbox).not.toBeChecked(); const expectedFee = new BigNumber(amount) .times(props.feeFactor) @@ -351,11 +337,11 @@ describe('TransferForm', () => { // select should be shown as multiple pubkeys provided expect(screen.getByText('select')).toBeInTheDocument(); expect(screen.queryByText('input')).not.toBeInTheDocument(); - fireEvent.click(screen.getByText('Enter manually')); + await userEvent.click(screen.getByText('Enter manually')); expect(screen.queryByText('select')).not.toBeInTheDocument(); expect(screen.getByText('input')).toBeInTheDocument(); expect(mockOnChange).toHaveBeenCalledTimes(1); - fireEvent.click(screen.getByText('Select from wallet')); + await userEvent.click(screen.getByText('Select from wallet')); expect(screen.getByText('select')).toBeInTheDocument(); expect(screen.queryByText('input')).not.toBeInTheDocument(); expect(mockOnChange).toHaveBeenCalledTimes(2); diff --git a/libs/accounts/src/lib/transfer-form.tsx b/libs/accounts/src/lib/transfer-form.tsx index d74208ac2..8a6bd542d 100644 --- a/libs/accounts/src/lib/transfer-form.tsx +++ b/libs/accounts/src/lib/transfer-form.tsx @@ -24,22 +24,30 @@ import type { ReactNode } from 'react'; import { useCallback, useEffect, useMemo, useState } from 'react'; import { Controller, useForm } from 'react-hook-form'; import { AssetOption, Balance } from '@vegaprotocol/assets'; +import { AccountType, AccountTypeMapping } from '@vegaprotocol/types'; interface FormFields { toAddress: string; asset: string; amount: string; + fromAccount: AccountType; +} + +interface Asset { + id: string; + symbol: string; + name: string; + decimals: number; + balance: string; } interface TransferFormProps { pubKey: string | null; pubKeys: string[] | null; - assets: Array<{ - id: string; - symbol: string; - name: string; - decimals: number; - balance: string; + assets: Array; + accounts: Array<{ + type: AccountType; + asset: { id: string; symbol: string }; }>; assetId?: string; feeFactor: string | null; @@ -53,6 +61,7 @@ export const TransferForm = ({ assetId: initialAssetId, feeFactor, submitTransfer, + accounts, }: TransferFormProps) => { const { control, @@ -67,6 +76,7 @@ export const TransferForm = ({ }, }); + const selectedPubKey = watch('toAddress'); const amount = watch('amount'); const assetId = watch('asset'); @@ -102,10 +112,16 @@ export const TransferForm = ({ if (!transferAmount) { throw new Error('Submitted transfer with no amount selected'); } - const transfer = normalizeTransfer(fields.toAddress, transferAmount, { - id: asset.id, - decimals: asset.decimals, - }); + const transfer = normalizeTransfer( + fields.toAddress, + transferAmount, + fields.fromAccount, + AccountType.ACCOUNT_TYPE_GENERAL, // field is readonly in the form + { + id: asset.id, + decimals: asset.decimals, + } + ); submitTransfer(transfer); }, [asset, submitTransfer, transferAmount] @@ -137,57 +153,53 @@ export const TransferForm = ({ className="text-sm" data-testid="transfer-form" > - + setValue('toAddress', '')} select={ {pubKeys?.length && - pubKeys - .filter((pk) => pk !== pubKey) // remove currently selected pubkey - .map((pk) => ( + pubKeys.map((pk) => { + const text = pk === pubKey ? t('Current key: ') + pk : pk; + + return ( - ))} + ); + })} } input={ { - if (value === pubKey) { - return t('Vega key is the same as current key'); - } - return true; - }, }, })} /> } /> {errors.toAddress?.message && ( - + {errors.toAddress.message} )} - + )} + + { + if ( + pubKey === selectedPubKey && + value === AccountType.ACCOUNT_TYPE_GENERAL + ) { + return t( + 'Cannot transfer to the same account type for the connected key' + ); + } + return true; + }, + }, + })} + > + + {accounts + .filter((a) => { + if (!assetId) return true; + return assetId === a.asset.id; + }) + .map((a) => { + return ( + + ); + })} + + {errors.fromAccount?.message && ( + + {errors.fromAccount.message} + + )} + + + + + + !curr); onChange(); }} - className="absolute top-0 right-0 ml-auto text-sm underline" + className="absolute top-0 right-0 ml-auto text-xs underline" > {isInput ? t('Select from wallet') : t('Enter manually')} diff --git a/libs/wallet/src/utils.ts b/libs/wallet/src/utils.ts index 1b0ce0a19..8c0c20c64 100644 --- a/libs/wallet/src/utils.ts +++ b/libs/wallet/src/utils.ts @@ -1,6 +1,6 @@ import { removeDecimal, toNanoSeconds } from '@vegaprotocol/utils'; import type { Market, Order } from '@vegaprotocol/types'; -import { AccountType } from '@vegaprotocol/types'; +import type { AccountType } from '@vegaprotocol/types'; import BigNumber from 'bignumber.js'; import { ethers } from 'ethers'; import { sha3_256 } from 'js-sha3'; @@ -47,15 +47,17 @@ export const normalizeOrderAmendment = >( export const normalizeTransfer = >( address: string, amount: string, + fromAccountType: AccountType, + toAccountType: AccountType, asset: { id: string; decimals: number; } ): Transfer => { return { - fromAccountType: AccountType.ACCOUNT_TYPE_GENERAL, to: address, - toAccountType: AccountType.ACCOUNT_TYPE_GENERAL, + fromAccountType, + toAccountType, asset: asset.id, amount: removeDecimal(amount, asset.decimals), // oneOff or recurring required otherwise wallet will error