diff --git a/libs/accounts/src/lib/transfer-container.tsx b/libs/accounts/src/lib/transfer-container.tsx index ba339a73a6..30c6fa1442 100644 --- a/libs/accounts/src/lib/transfer-container.tsx +++ b/libs/accounts/src/lib/transfer-container.tsx @@ -1,9 +1,10 @@ +import sortBy from 'lodash/sortBy'; import * as Schema from '@vegaprotocol/types'; import { truncateByChars } from '@vegaprotocol/utils'; import { t } from '@vegaprotocol/i18n'; import { NetworkParams, - useNetworkParam, + useNetworkParams, } from '@vegaprotocol/network-parameters'; import { useDataProvider } from '@vegaprotocol/data-provider'; import type { Transfer } from '@vegaprotocol/wallet'; @@ -21,7 +22,11 @@ export const ALLOWED_ACCOUNTS = [ export const TransferContainer = ({ assetId }: { assetId?: string }) => { const { pubKey, pubKeys } = useVegaWallet(); - const { param } = useNetworkParam(NetworkParams.transfer_fee_factor); + const { params } = useNetworkParams([ + NetworkParams.transfer_fee_factor, + NetworkParams.transfer_minTransferQuantumMultiple, + ]); + const { data } = useDataProvider({ dataProvider: accountsDataProvider, variables: { partyId: pubKey || '' }, @@ -40,6 +45,7 @@ export const TransferContainer = ({ assetId }: { assetId?: string }) => { const accounts = data ? data.filter((account) => ALLOWED_ACCOUNTS.includes(account.type)) : []; + const sortedAccounts = sortBy(accounts, (a) => a.asset.symbol.toLowerCase()); return ( <> @@ -59,9 +65,10 @@ export const TransferContainer = ({ assetId }: { assetId?: string }) => { pubKey={pubKey} pubKeys={pubKeys ? pubKeys?.map((pk) => pk.publicKey) : null} assetId={assetId} - feeFactor={param} + feeFactor={params.transfer_fee_factor} + minQuantumMultiple={params.transfer_minTransferQuantumMultiple} submitTransfer={transfer} - accounts={accounts} + accounts={sortedAccounts} /> ); diff --git a/libs/accounts/src/lib/transfer-form.spec.tsx b/libs/accounts/src/lib/transfer-form.spec.tsx index 98314620e2..0fdad0897b 100644 --- a/libs/accounts/src/lib/transfer-form.spec.tsx +++ b/libs/accounts/src/lib/transfer-form.spec.tsx @@ -9,18 +9,10 @@ import { } from './transfer-form'; import { AccountType } from '@vegaprotocol/types'; import { removeDecimal } from '@vegaprotocol/utils'; -import { MockedProvider } from '@apollo/client/testing'; describe('TransferForm', () => { const renderComponent = (props: TransferFormProps) => { - return render( - // Wrap with mock provider as the form will make queries to fetch the selected - // toVegaKey accounts. We don't test this for now but we need to wrap so that - // the component has access to the client - - - - ); + return render(); }; const submit = async () => { @@ -54,6 +46,7 @@ describe('TransferForm', () => { symbol: '€', name: 'EUR', decimals: 2, + quantum: '1', }; const props = { pubKey, @@ -72,9 +65,10 @@ describe('TransferForm', () => { { type: AccountType.ACCOUNT_TYPE_VESTED_REWARDS, asset, - balance: '100000', + balance: '10000', }, ], + minQuantumMultiple: '1', }; it('form tooltips correctly displayed', async () => { @@ -132,7 +126,7 @@ describe('TransferForm', () => { // 1003-TRAN-004 renderComponent(props); await submit(); - expect(await screen.findAllByText('Required')).toHaveLength(3); // pubkey is set as default value + expect(await screen.findAllByText('Required')).toHaveLength(2); // pubkey is set as default value const toggle = screen.getByText('Enter manually'); await userEvent.click(toggle); // has switched to input @@ -145,12 +139,12 @@ describe('TransferForm', () => { screen.getByLabelText('To Vega key'), 'invalid-address' ); - expect(screen.getAllByTestId('input-error-text')[0]).toHaveTextContent( + expect(screen.getAllByTestId('input-error-text')[1]).toHaveTextContent( 'Invalid Vega key' ); }); - it('validates fields and submits', async () => { + it('sends transfer from general accounts', async () => { // 1003-TRAN-002 // 1003-TRAN-003 // 1002-WITH-010 @@ -168,7 +162,7 @@ describe('TransferForm', () => { ]); await submit(); - expect(await screen.findAllByText('Required')).toHaveLength(3); // pubkey is set as default value + expect(await screen.findAllByText('Required')).toHaveLength(2); // pubkey is set as default value // Select a pubkey await userEvent.selectOptions( @@ -181,15 +175,20 @@ describe('TransferForm', () => { await userEvent.selectOptions( screen.getByLabelText('From account'), - AccountType.ACCOUNT_TYPE_VESTED_REWARDS + `${AccountType.ACCOUNT_TYPE_GENERAL}-${asset.id}` ); const amountInput = screen.getByLabelText('Amount'); + // Test use max button + await userEvent.click(screen.getByRole('button', { name: 'Use max' })); + expect(amountInput).toHaveValue('1000'); + // Test amount validation - await userEvent.type(amountInput, '0.00000001'); + await userEvent.clear(amountInput); + await userEvent.type(amountInput, '0.001'); // Below quantum multiple amount expect( - await screen.findByText('Value is below minimum') + await screen.findByText(/Amount below minimum requirement/) ).toBeInTheDocument(); await userEvent.clear(amountInput); @@ -210,7 +209,7 @@ describe('TransferForm', () => { await waitFor(() => { expect(props.submitTransfer).toHaveBeenCalledTimes(1); expect(props.submitTransfer).toHaveBeenCalledWith({ - fromAccountType: AccountType.ACCOUNT_TYPE_VESTED_REWARDS, + fromAccountType: AccountType.ACCOUNT_TYPE_GENERAL, toAccountType: AccountType.ACCOUNT_TYPE_GENERAL, to: props.pubKeys[1], asset: asset.id, @@ -220,6 +219,83 @@ describe('TransferForm', () => { }); }); + it('sends transfer from vested accounts', async () => { + const mockSubmit = jest.fn(); + renderComponent({ + ...props, + submitTransfer: mockSubmit, + minQuantumMultiple: '100000', + }); + + // check current pubkey not shown + const keySelect: HTMLSelectElement = screen.getByLabelText('To 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 + ); + + await submit(); + expect(await screen.findAllByText('Required')).toHaveLength(2); // pubkey set as default value + + // Select a pubkey + await userEvent.selectOptions( + screen.getByLabelText('To Vega key'), + props.pubKeys[1] // Use not current pubkey so we can check it switches to current pubkey later + ); + + // Select asset + await selectAsset(asset); + + await userEvent.selectOptions( + screen.getByLabelText('From account'), + `${AccountType.ACCOUNT_TYPE_VESTED_REWARDS}-${asset.id}` + ); + + // Check switch back to connected key + expect(screen.getByLabelText('To Vega key')).toHaveValue(props.pubKey); + + const amountInput = screen.getByLabelText('Amount'); + + const checkbox = screen.getByTestId('include-transfer-fee'); + expect(checkbox).not.toBeChecked(); + + await userEvent.clear(amountInput); + await userEvent.type(amountInput, '50'); + + expect(await screen.findByText(/Use max to bypass/)).toBeInTheDocument(); + + // Test use max button + await userEvent.click(screen.getByRole('button', { name: 'Use max' })); + expect(amountInput).toHaveValue('100'); + + // If transfering from a vested account 'include fees' checkbox should + // be disabled and fees should be 0 + expect(checkbox).not.toBeChecked(); + expect(checkbox).toBeDisabled(); + const expectedFee = '0'; + const total = new BigNumber(amount).plus(expectedFee).toFixed(); + + expect(screen.getByTestId('transfer-fee')).toHaveTextContent(expectedFee); + expect(screen.getByTestId('transfer-amount')).toHaveTextContent(amount); + expect(screen.getByTestId('total-transfer-fee')).toHaveTextContent(total); + + await submit(); + + await waitFor(() => { + // 1003-TRAN-023 + expect(mockSubmit).toHaveBeenCalledTimes(1); + expect(mockSubmit).toHaveBeenCalledWith({ + fromAccountType: AccountType.ACCOUNT_TYPE_VESTED_REWARDS, + toAccountType: AccountType.ACCOUNT_TYPE_GENERAL, + to: props.pubKey, + asset: asset.id, + amount: removeDecimal(amount, asset.decimals), + oneOff: {}, + }); + }); + }); + describe('IncludeFeesCheckbox', () => { it('validates fields and submits when checkbox is checked', async () => { const mockSubmit = jest.fn(); @@ -234,7 +310,7 @@ describe('TransferForm', () => { ); await submit(); - expect(await screen.findAllByText('Required')).toHaveLength(3); // pubkey set as default value + expect(await screen.findAllByText('Required')).toHaveLength(2); // pubkey set as default value // Select a pubkey await userEvent.selectOptions( @@ -247,7 +323,7 @@ describe('TransferForm', () => { await userEvent.selectOptions( screen.getByLabelText('From account'), - AccountType.ACCOUNT_TYPE_VESTED_REWARDS + `${AccountType.ACCOUNT_TYPE_GENERAL}-${asset.id}` ); const amountInput = screen.getByLabelText('Amount'); @@ -281,7 +357,7 @@ describe('TransferForm', () => { // 1003-TRAN-023 expect(mockSubmit).toHaveBeenCalledTimes(1); expect(mockSubmit).toHaveBeenCalledWith({ - fromAccountType: AccountType.ACCOUNT_TYPE_VESTED_REWARDS, + fromAccountType: AccountType.ACCOUNT_TYPE_GENERAL, toAccountType: AccountType.ACCOUNT_TYPE_GENERAL, to: props.pubKeys[1], asset: asset.id, @@ -303,7 +379,7 @@ describe('TransferForm', () => { ); await submit(); - expect(await screen.findAllByText('Required')).toHaveLength(3); // pubkey set as default value + expect(await screen.findAllByText('Required')).toHaveLength(2); // pubkey set as default value // Select a pubkey await userEvent.selectOptions( @@ -314,6 +390,11 @@ describe('TransferForm', () => { // Select asset await selectAsset(asset); + await userEvent.selectOptions( + screen.getByLabelText('From account'), + `${AccountType.ACCOUNT_TYPE_GENERAL}-${asset.id}` + ); + const amountInput = screen.getByLabelText('Amount'); const checkbox = screen.getByTestId('include-transfer-fee'); expect(checkbox).not.toBeChecked(); @@ -333,26 +414,28 @@ describe('TransferForm', () => { describe('AddressField', () => { const props = { + mode: 'select' as const, select:
select
, input:
input
, onChange: jest.fn(), }; - it('toggles content and calls onChange', async () => { + it('renders correct content by mode prop and calls onChange', async () => { const mockOnChange = jest.fn(); - render(); + const { rerender } = render( + + ); // select should be shown by default expect(screen.getByText('select')).toBeInTheDocument(); expect(screen.queryByText('input')).not.toBeInTheDocument(); await userEvent.click(screen.getByText('Enter manually')); + expect(mockOnChange).toHaveBeenCalled(); + + rerender(); + expect(screen.queryByText('select')).not.toBeInTheDocument(); expect(screen.getByText('input')).toBeInTheDocument(); - expect(mockOnChange).toHaveBeenCalledTimes(1); - 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 eeb7011115..07958ddfcd 100644 --- a/libs/accounts/src/lib/transfer-form.tsx +++ b/libs/accounts/src/lib/transfer-form.tsx @@ -1,12 +1,12 @@ import sortBy from 'lodash/sortBy'; import { - minSafe, maxSafe, required, vegaPublicKey, addDecimal, formatNumber, addDecimalsFormatNumber, + toBigNum, } from '@vegaprotocol/utils'; import { t } from '@vegaprotocol/i18n'; import { @@ -27,14 +27,20 @@ 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'; -import { useDataProvider } from '@vegaprotocol/data-provider'; -import { accountsDataProvider } from './accounts-data-provider'; interface FormFields { toVegaKey: string; - asset: string; + asset: string; // This is used to simply filter the from account list, the fromAccount type should be used in the tx amount: string; - fromAccount: AccountType; + fromAccount: string; // AccountType-AssetId +} + +interface Asset { + id: string; + symbol: string; + name: string; + decimals: number; + quantum: string; } export interface TransferFormProps { @@ -43,10 +49,11 @@ export interface TransferFormProps { accounts: Array<{ type: AccountType; balance: string; - asset: { id: string; symbol: string; name: string; decimals: number }; + asset: Asset; }>; assetId?: string; feeFactor: string | null; + minQuantumMultiple: string | null; submitTransfer: (transfer: Transfer) => void; } @@ -57,6 +64,7 @@ export const TransferForm = ({ feeFactor, submitTransfer, accounts, + minQuantumMultiple, }: TransferFormProps) => { const { control, @@ -72,6 +80,8 @@ export const TransferForm = ({ }, }); + const [toVegaKeyMode, setToVegaKeyMode] = useState('select'); + const assets = sortBy( accounts .filter( @@ -99,48 +109,29 @@ export const TransferForm = ({ ...account.asset, balance: addDecimal(account.balance, account.asset.decimals), })), - 'name' + (a) => a.symbol.toLowerCase() ); const selectedPubKey = watch('toVegaKey'); const amount = watch('amount'); const fromAccount = watch('fromAccount'); - const assetId = watch('asset'); - - const asset = assets.find((a) => a.id === assetId); + const selectedAssetId = watch('asset'); - const { data: toAccounts } = useDataProvider({ - dataProvider: accountsDataProvider, - variables: { - partyId: selectedPubKey, - }, - skip: !selectedPubKey, - }); + // Convert the account type (Type-AssetId) into separate values + const [accountType, accountAssetId] = fromAccount + ? parseFromAccount(fromAccount) + : [undefined, undefined]; + const fromVested = accountType === AccountType.ACCOUNT_TYPE_VESTED_REWARDS; + const asset = assets.find((a) => a.id === accountAssetId); const account = accounts.find( - (a) => a.asset.id === assetId && a.type === fromAccount + (a) => a.asset.id === accountAssetId && a.type === accountType ); const accountBalance = account && addDecimal(account.balance, account.asset.decimals); - // The general account of the selected pubkey. You can only transfer - // to general accounts, either when redeeming vested rewards or just - // during normal general -> general transfers - const toGeneralAccount = - toAccounts && - toAccounts.find((a) => { - return ( - a.asset.id === assetId && a.type === AccountType.ACCOUNT_TYPE_GENERAL - ); - }); - const [includeFee, setIncludeFee] = useState(false); - // Min viable amount given asset decimals EG for WEI 0.000000000000000001 - const min = asset - ? new BigNumber(addDecimal('1', asset.decimals)) - : new BigNumber(0); - // Max amount given selected asset and from account const max = accountBalance ? new BigNumber(accountBalance) : new BigNumber(0); @@ -164,16 +155,21 @@ export const TransferForm = ({ const onSubmit = useCallback( (fields: FormFields) => { - if (!asset) { - throw new Error('Submitted transfer with no asset selected'); - } if (!transferAmount) { throw new Error('Submitted transfer with no amount selected'); } + + const [type, assetId] = parseFromAccount(fields.fromAccount); + const asset = assets.find((a) => a.id === assetId); + + if (!asset) { + throw new Error('Submitted transfer with no asset selected'); + } + const transfer = normalizeTransfer( fields.toVegaKey, transferAmount, - fields.fromAccount, + type, AccountType.ACCOUNT_TYPE_GENERAL, // field is readonly in the form { id: asset.id, @@ -182,7 +178,7 @@ export const TransferForm = ({ ); submitTransfer(transfer); }, - [asset, submitTransfer, transferAmount] + [submitTransfer, transferAmount, assets] ); // reset for placeholder workaround https://github.com/radix-ui/primitives/issues/1569 @@ -198,55 +194,10 @@ export const TransferForm = ({ className="text-sm" data-testid="transfer-form" > - - setValue('toVegaKey', '')} - select={ - - - {pubKeys?.map((pk) => { - const text = pk === pubKey ? t('Current key: ') + pk : pk; - - return ( - - ); - })} - - } - input={ - - } - /> - {errors.toVegaKey?.message && ( - - {errors.toVegaKey.message} - - )} - ( { field.onChange(value); + setValue('fromAccount', ''); }} placeholder={t('Please select an asset')} value={field.value} @@ -280,10 +232,10 @@ export const TransferForm = ({ )} - { @@ -298,50 +250,106 @@ export const TransferForm = ({ return true; }, }, - })} - > - - {accounts - .filter((a) => { - if (!assetId) return true; - return assetId === a.asset.id; - }) - .map((a) => { - return ( - - ); - })} - + }} + render={({ field }) => ( + { + field.onChange(e); + + const [type] = parseFromAccount(e.target.value); + + // Enforce that if transferring from a vested rewards account it must go to + // the current connected general account + if ( + type === AccountType.ACCOUNT_TYPE_VESTED_REWARDS && + pubKey + ) { + setValue('toVegaKey', pubKey); + setToVegaKeyMode('select'); + setIncludeFee(false); + } + }} + > + + {accounts + .filter((a) => { + if (!selectedAssetId) return true; + return selectedAssetId === a.asset.id; + }) + .map((a) => { + const id = `${a.type}-${a.asset.id}`; + return ( + + ); + })} + + )} + /> {errors.fromAccount?.message && ( {errors.fromAccount.message} )} - - - - + + { + setValue('toVegaKey', ''); + setToVegaKeyMode((curr) => (curr === 'input' ? 'select' : 'input')); + }} + mode={toVegaKeyMode} + select={ + + + {pubKeys?.map((pk) => { + const text = pk === pubKey ? t('Current key: ') + pk : pk; + + return ( + + ); + })} + + } + input={ + fromVested ? null : ( + + ) + } + /> + {errors.toVegaKey?.message && ( + + {errors.toVegaKey.message} + + )} minSafe(new BigNumber(min))(value), + minSafe: (v) => { + if (!asset || !minQuantumMultiple) return true; + + const value = new BigNumber(v); + + if (value.isZero()) { + return t('Amount cannot be 0'); + } + + const minByQuantumMultiple = toBigNum( + minQuantumMultiple, + asset.decimals + ); + + if (fromVested) { + // special conditions which let you bypass min transfer rules set by quantum multiple + if (value.isGreaterThanOrEqualTo(max)) { + return true; + } + + if (value.isLessThan(minByQuantumMultiple)) { + return t( + 'Amount below minimum requirements for partial transfer. Use max to bypass' + ); + } + + return true; + } else { + if (value.isLessThan(minByQuantumMultiple)) { + return t( + 'Amount below minimum requirement set by transfer.minTransferQuantumMultiple' + ); + } + } + + return true; + }, maxSafe: (v) => { const value = new BigNumber(v); if (value.isGreaterThan(max)) { @@ -369,7 +413,9 @@ export const TransferForm = ({ type="button" className="absolute top-0 right-0 ml-auto text-xs underline" onClick={() => - setValue('amount', parseFloat(accountBalance).toString()) + setValue('amount', parseFloat(accountBalance).toString(), { + shouldValidate: true, + }) } > {t('Use max')} @@ -390,10 +436,10 @@ export const TransferForm = ({
setIncludeFee(!includeFee)} + onCheckedChange={() => setIncludeFee((x) => !x)} />
@@ -403,7 +449,7 @@ export const TransferForm = ({ amount={transferAmount} transferAmount={transferAmount} feeFactor={feeFactor} - fee={fee} + fee={fromVested ? '0' : fee} decimals={asset?.decimals} /> )} @@ -485,32 +531,38 @@ export const TransferFee = ({ ); }; +type ToVegaKeyMode = 'input' | 'select'; + interface AddressInputProps { select: ReactNode; input: ReactNode; + mode: ToVegaKeyMode; onChange: () => void; } export const AddressField = ({ select, input, + mode, onChange, }: AddressInputProps) => { - const [isInput, setIsInput] = useState(false); - + const isInput = mode === 'input'; return ( <> {isInput ? input : select} - + {select && input && ( + + )} ); }; + +const parseFromAccount = (fromAccountStr: string) => { + return fromAccountStr.split('-') as [AccountType, string]; +}; diff --git a/libs/network-parameters/src/use-network-params.ts b/libs/network-parameters/src/use-network-params.ts index 9a121a05be..e0f648b9cf 100644 --- a/libs/network-parameters/src/use-network-params.ts +++ b/libs/network-parameters/src/use-network-params.ts @@ -176,6 +176,7 @@ export const NetworkParams = { market_liquidity_feeCalculationTimeStep: 'market_liquidity_feeCalculationTimeStep', transfer_fee_factor: 'transfer_fee_factor', + transfer_minTransferQuantumMultiple: 'transfer_minTransferQuantumMultiple', network_validators_incumbentBonus: 'network_validators_incumbentBonus', } as const;