feat(accounts): transfer form improvements to handle transfers from vesting accounts (#5212)

Co-authored-by: Madalina Raicu <madalina@raygroup.uk>
This commit is contained in:
Matthew Russell 2023-11-08 23:52:30 -08:00 committed by GitHub
parent 73f37c2477
commit f780013846
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
4 changed files with 319 additions and 176 deletions

View File

@ -1,9 +1,10 @@
import sortBy from 'lodash/sortBy';
import * as Schema from '@vegaprotocol/types'; import * as Schema from '@vegaprotocol/types';
import { truncateByChars } from '@vegaprotocol/utils'; import { truncateByChars } from '@vegaprotocol/utils';
import { t } from '@vegaprotocol/i18n'; import { t } from '@vegaprotocol/i18n';
import { import {
NetworkParams, NetworkParams,
useNetworkParam, useNetworkParams,
} from '@vegaprotocol/network-parameters'; } from '@vegaprotocol/network-parameters';
import { useDataProvider } from '@vegaprotocol/data-provider'; import { useDataProvider } from '@vegaprotocol/data-provider';
import type { Transfer } from '@vegaprotocol/wallet'; import type { Transfer } from '@vegaprotocol/wallet';
@ -21,7 +22,11 @@ export const ALLOWED_ACCOUNTS = [
export const TransferContainer = ({ assetId }: { assetId?: string }) => { export const TransferContainer = ({ assetId }: { assetId?: string }) => {
const { pubKey, pubKeys } = useVegaWallet(); const { pubKey, pubKeys } = useVegaWallet();
const { param } = useNetworkParam(NetworkParams.transfer_fee_factor); const { params } = useNetworkParams([
NetworkParams.transfer_fee_factor,
NetworkParams.transfer_minTransferQuantumMultiple,
]);
const { data } = useDataProvider({ const { data } = useDataProvider({
dataProvider: accountsDataProvider, dataProvider: accountsDataProvider,
variables: { partyId: pubKey || '' }, variables: { partyId: pubKey || '' },
@ -40,6 +45,7 @@ export const TransferContainer = ({ assetId }: { assetId?: string }) => {
const accounts = data const accounts = data
? data.filter((account) => ALLOWED_ACCOUNTS.includes(account.type)) ? data.filter((account) => ALLOWED_ACCOUNTS.includes(account.type))
: []; : [];
const sortedAccounts = sortBy(accounts, (a) => a.asset.symbol.toLowerCase());
return ( return (
<> <>
@ -59,9 +65,10 @@ export const TransferContainer = ({ assetId }: { assetId?: string }) => {
pubKey={pubKey} pubKey={pubKey}
pubKeys={pubKeys ? pubKeys?.map((pk) => pk.publicKey) : null} pubKeys={pubKeys ? pubKeys?.map((pk) => pk.publicKey) : null}
assetId={assetId} assetId={assetId}
feeFactor={param} feeFactor={params.transfer_fee_factor}
minQuantumMultiple={params.transfer_minTransferQuantumMultiple}
submitTransfer={transfer} submitTransfer={transfer}
accounts={accounts} accounts={sortedAccounts}
/> />
</> </>
); );

View File

@ -9,18 +9,10 @@ import {
} from './transfer-form'; } from './transfer-form';
import { AccountType } from '@vegaprotocol/types'; import { AccountType } from '@vegaprotocol/types';
import { removeDecimal } from '@vegaprotocol/utils'; import { removeDecimal } from '@vegaprotocol/utils';
import { MockedProvider } from '@apollo/client/testing';
describe('TransferForm', () => { describe('TransferForm', () => {
const renderComponent = (props: TransferFormProps) => { const renderComponent = (props: TransferFormProps) => {
return render( return render(<TransferForm {...props} />);
// 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
<MockedProvider>
<TransferForm {...props} />
</MockedProvider>
);
}; };
const submit = async () => { const submit = async () => {
@ -54,6 +46,7 @@ describe('TransferForm', () => {
symbol: '€', symbol: '€',
name: 'EUR', name: 'EUR',
decimals: 2, decimals: 2,
quantum: '1',
}; };
const props = { const props = {
pubKey, pubKey,
@ -72,9 +65,10 @@ describe('TransferForm', () => {
{ {
type: AccountType.ACCOUNT_TYPE_VESTED_REWARDS, type: AccountType.ACCOUNT_TYPE_VESTED_REWARDS,
asset, asset,
balance: '100000', balance: '10000',
}, },
], ],
minQuantumMultiple: '1',
}; };
it('form tooltips correctly displayed', async () => { it('form tooltips correctly displayed', async () => {
@ -132,7 +126,7 @@ describe('TransferForm', () => {
// 1003-TRAN-004 // 1003-TRAN-004
renderComponent(props); renderComponent(props);
await submit(); 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'); const toggle = screen.getByText('Enter manually');
await userEvent.click(toggle); await userEvent.click(toggle);
// has switched to input // has switched to input
@ -145,12 +139,12 @@ describe('TransferForm', () => {
screen.getByLabelText('To Vega key'), screen.getByLabelText('To Vega key'),
'invalid-address' 'invalid-address'
); );
expect(screen.getAllByTestId('input-error-text')[0]).toHaveTextContent( expect(screen.getAllByTestId('input-error-text')[1]).toHaveTextContent(
'Invalid Vega key' 'Invalid Vega key'
); );
}); });
it('validates fields and submits', async () => { it('sends transfer from general accounts', async () => {
// 1003-TRAN-002 // 1003-TRAN-002
// 1003-TRAN-003 // 1003-TRAN-003
// 1002-WITH-010 // 1002-WITH-010
@ -168,7 +162,7 @@ describe('TransferForm', () => {
]); ]);
await submit(); 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 // Select a pubkey
await userEvent.selectOptions( await userEvent.selectOptions(
@ -181,15 +175,20 @@ describe('TransferForm', () => {
await userEvent.selectOptions( await userEvent.selectOptions(
screen.getByLabelText('From account'), screen.getByLabelText('From account'),
AccountType.ACCOUNT_TYPE_VESTED_REWARDS `${AccountType.ACCOUNT_TYPE_GENERAL}-${asset.id}`
); );
const amountInput = screen.getByLabelText('Amount'); 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 // Test amount validation
await userEvent.type(amountInput, '0.00000001'); await userEvent.clear(amountInput);
await userEvent.type(amountInput, '0.001'); // Below quantum multiple amount
expect( expect(
await screen.findByText('Value is below minimum') await screen.findByText(/Amount below minimum requirement/)
).toBeInTheDocument(); ).toBeInTheDocument();
await userEvent.clear(amountInput); await userEvent.clear(amountInput);
@ -210,7 +209,7 @@ describe('TransferForm', () => {
await waitFor(() => { await waitFor(() => {
expect(props.submitTransfer).toHaveBeenCalledTimes(1); expect(props.submitTransfer).toHaveBeenCalledTimes(1);
expect(props.submitTransfer).toHaveBeenCalledWith({ expect(props.submitTransfer).toHaveBeenCalledWith({
fromAccountType: AccountType.ACCOUNT_TYPE_VESTED_REWARDS, fromAccountType: AccountType.ACCOUNT_TYPE_GENERAL,
toAccountType: AccountType.ACCOUNT_TYPE_GENERAL, toAccountType: AccountType.ACCOUNT_TYPE_GENERAL,
to: props.pubKeys[1], to: props.pubKeys[1],
asset: asset.id, 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', () => { describe('IncludeFeesCheckbox', () => {
it('validates fields and submits when checkbox is checked', async () => { it('validates fields and submits when checkbox is checked', async () => {
const mockSubmit = jest.fn(); const mockSubmit = jest.fn();
@ -234,7 +310,7 @@ describe('TransferForm', () => {
); );
await submit(); 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 // Select a pubkey
await userEvent.selectOptions( await userEvent.selectOptions(
@ -247,7 +323,7 @@ describe('TransferForm', () => {
await userEvent.selectOptions( await userEvent.selectOptions(
screen.getByLabelText('From account'), screen.getByLabelText('From account'),
AccountType.ACCOUNT_TYPE_VESTED_REWARDS `${AccountType.ACCOUNT_TYPE_GENERAL}-${asset.id}`
); );
const amountInput = screen.getByLabelText('Amount'); const amountInput = screen.getByLabelText('Amount');
@ -281,7 +357,7 @@ describe('TransferForm', () => {
// 1003-TRAN-023 // 1003-TRAN-023
expect(mockSubmit).toHaveBeenCalledTimes(1); expect(mockSubmit).toHaveBeenCalledTimes(1);
expect(mockSubmit).toHaveBeenCalledWith({ expect(mockSubmit).toHaveBeenCalledWith({
fromAccountType: AccountType.ACCOUNT_TYPE_VESTED_REWARDS, fromAccountType: AccountType.ACCOUNT_TYPE_GENERAL,
toAccountType: AccountType.ACCOUNT_TYPE_GENERAL, toAccountType: AccountType.ACCOUNT_TYPE_GENERAL,
to: props.pubKeys[1], to: props.pubKeys[1],
asset: asset.id, asset: asset.id,
@ -303,7 +379,7 @@ describe('TransferForm', () => {
); );
await submit(); 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 // Select a pubkey
await userEvent.selectOptions( await userEvent.selectOptions(
@ -314,6 +390,11 @@ describe('TransferForm', () => {
// Select asset // Select asset
await selectAsset(asset); await selectAsset(asset);
await userEvent.selectOptions(
screen.getByLabelText('From account'),
`${AccountType.ACCOUNT_TYPE_GENERAL}-${asset.id}`
);
const amountInput = screen.getByLabelText('Amount'); const amountInput = screen.getByLabelText('Amount');
const checkbox = screen.getByTestId('include-transfer-fee'); const checkbox = screen.getByTestId('include-transfer-fee');
expect(checkbox).not.toBeChecked(); expect(checkbox).not.toBeChecked();
@ -333,26 +414,28 @@ describe('TransferForm', () => {
describe('AddressField', () => { describe('AddressField', () => {
const props = { const props = {
mode: 'select' as const,
select: <div>select</div>, select: <div>select</div>,
input: <div>input</div>, input: <div>input</div>,
onChange: jest.fn(), 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(); const mockOnChange = jest.fn();
render(<AddressField {...props} onChange={mockOnChange} />); const { rerender } = render(
<AddressField {...props} onChange={mockOnChange} />
);
// select should be shown by default // select should be shown by default
expect(screen.getByText('select')).toBeInTheDocument(); expect(screen.getByText('select')).toBeInTheDocument();
expect(screen.queryByText('input')).not.toBeInTheDocument(); expect(screen.queryByText('input')).not.toBeInTheDocument();
await userEvent.click(screen.getByText('Enter manually')); await userEvent.click(screen.getByText('Enter manually'));
expect(mockOnChange).toHaveBeenCalled();
rerender(<AddressField {...props} mode="input" />);
expect(screen.queryByText('select')).not.toBeInTheDocument(); expect(screen.queryByText('select')).not.toBeInTheDocument();
expect(screen.getByText('input')).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);
}); });
}); });

View File

@ -1,12 +1,12 @@
import sortBy from 'lodash/sortBy'; import sortBy from 'lodash/sortBy';
import { import {
minSafe,
maxSafe, maxSafe,
required, required,
vegaPublicKey, vegaPublicKey,
addDecimal, addDecimal,
formatNumber, formatNumber,
addDecimalsFormatNumber, addDecimalsFormatNumber,
toBigNum,
} from '@vegaprotocol/utils'; } from '@vegaprotocol/utils';
import { t } from '@vegaprotocol/i18n'; import { t } from '@vegaprotocol/i18n';
import { import {
@ -27,14 +27,20 @@ import { useCallback, useEffect, useMemo, useState } from 'react';
import { Controller, useForm } from 'react-hook-form'; import { Controller, useForm } from 'react-hook-form';
import { AssetOption, Balance } from '@vegaprotocol/assets'; import { AssetOption, Balance } from '@vegaprotocol/assets';
import { AccountType, AccountTypeMapping } from '@vegaprotocol/types'; import { AccountType, AccountTypeMapping } from '@vegaprotocol/types';
import { useDataProvider } from '@vegaprotocol/data-provider';
import { accountsDataProvider } from './accounts-data-provider';
interface FormFields { interface FormFields {
toVegaKey: string; 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; amount: string;
fromAccount: AccountType; fromAccount: string; // AccountType-AssetId
}
interface Asset {
id: string;
symbol: string;
name: string;
decimals: number;
quantum: string;
} }
export interface TransferFormProps { export interface TransferFormProps {
@ -43,10 +49,11 @@ export interface TransferFormProps {
accounts: Array<{ accounts: Array<{
type: AccountType; type: AccountType;
balance: string; balance: string;
asset: { id: string; symbol: string; name: string; decimals: number }; asset: Asset;
}>; }>;
assetId?: string; assetId?: string;
feeFactor: string | null; feeFactor: string | null;
minQuantumMultiple: string | null;
submitTransfer: (transfer: Transfer) => void; submitTransfer: (transfer: Transfer) => void;
} }
@ -57,6 +64,7 @@ export const TransferForm = ({
feeFactor, feeFactor,
submitTransfer, submitTransfer,
accounts, accounts,
minQuantumMultiple,
}: TransferFormProps) => { }: TransferFormProps) => {
const { const {
control, control,
@ -72,6 +80,8 @@ export const TransferForm = ({
}, },
}); });
const [toVegaKeyMode, setToVegaKeyMode] = useState<ToVegaKeyMode>('select');
const assets = sortBy( const assets = sortBy(
accounts accounts
.filter( .filter(
@ -99,48 +109,29 @@ export const TransferForm = ({
...account.asset, ...account.asset,
balance: addDecimal(account.balance, account.asset.decimals), balance: addDecimal(account.balance, account.asset.decimals),
})), })),
'name' (a) => a.symbol.toLowerCase()
); );
const selectedPubKey = watch('toVegaKey'); const selectedPubKey = watch('toVegaKey');
const amount = watch('amount'); const amount = watch('amount');
const fromAccount = watch('fromAccount'); const fromAccount = watch('fromAccount');
const assetId = watch('asset'); const selectedAssetId = watch('asset');
const asset = assets.find((a) => a.id === assetId); // Convert the account type (Type-AssetId) into separate values
const [accountType, accountAssetId] = fromAccount
const { data: toAccounts } = useDataProvider({ ? parseFromAccount(fromAccount)
dataProvider: accountsDataProvider, : [undefined, undefined];
variables: { const fromVested = accountType === AccountType.ACCOUNT_TYPE_VESTED_REWARDS;
partyId: selectedPubKey, const asset = assets.find((a) => a.id === accountAssetId);
},
skip: !selectedPubKey,
});
const account = accounts.find( const account = accounts.find(
(a) => a.asset.id === assetId && a.type === fromAccount (a) => a.asset.id === accountAssetId && a.type === accountType
); );
const accountBalance = const accountBalance =
account && addDecimal(account.balance, account.asset.decimals); 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); 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 // Max amount given selected asset and from account
const max = accountBalance ? new BigNumber(accountBalance) : new BigNumber(0); const max = accountBalance ? new BigNumber(accountBalance) : new BigNumber(0);
@ -164,16 +155,21 @@ export const TransferForm = ({
const onSubmit = useCallback( const onSubmit = useCallback(
(fields: FormFields) => { (fields: FormFields) => {
if (!asset) {
throw new Error('Submitted transfer with no asset selected');
}
if (!transferAmount) { if (!transferAmount) {
throw new Error('Submitted transfer with no amount selected'); 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( const transfer = normalizeTransfer(
fields.toVegaKey, fields.toVegaKey,
transferAmount, transferAmount,
fields.fromAccount, type,
AccountType.ACCOUNT_TYPE_GENERAL, // field is readonly in the form AccountType.ACCOUNT_TYPE_GENERAL, // field is readonly in the form
{ {
id: asset.id, id: asset.id,
@ -182,7 +178,7 @@ export const TransferForm = ({
); );
submitTransfer(transfer); submitTransfer(transfer);
}, },
[asset, submitTransfer, transferAmount] [submitTransfer, transferAmount, assets]
); );
// reset for placeholder workaround https://github.com/radix-ui/primitives/issues/1569 // reset for placeholder workaround https://github.com/radix-ui/primitives/issues/1569
@ -198,55 +194,10 @@ export const TransferForm = ({
className="text-sm" className="text-sm"
data-testid="transfer-form" data-testid="transfer-form"
> >
<TradingFormGroup label="To Vega key" labelFor="toVegaKey">
<AddressField
onChange={() => setValue('toVegaKey', '')}
select={
<TradingSelect {...register('toVegaKey')} id="toVegaKey">
<option value="" disabled={true}>
{t('Please select')}
</option>
{pubKeys?.map((pk) => {
const text = pk === pubKey ? t('Current key: ') + pk : pk;
return (
<option key={pk} value={pk}>
{text}
</option>
);
})}
</TradingSelect>
}
input={
<TradingInput
// eslint-disable-next-line jsx-a11y/no-autofocus
autoFocus={true} // focus input immediately after is shown
id="toVegaKey"
type="text"
{...register('toVegaKey', {
validate: {
required,
vegaPublicKey,
},
})}
/>
}
/>
{errors.toVegaKey?.message && (
<TradingInputError forInput="toVegaKey">
{errors.toVegaKey.message}
</TradingInputError>
)}
</TradingFormGroup>
<TradingFormGroup label={t('Asset')} labelFor="asset"> <TradingFormGroup label={t('Asset')} labelFor="asset">
<Controller <Controller
control={control} control={control}
name="asset" name="asset"
rules={{
validate: {
required,
},
}}
render={({ field }) => ( render={({ field }) => (
<TradingRichSelect <TradingRichSelect
data-testid="select-asset" data-testid="select-asset"
@ -254,6 +205,7 @@ export const TransferForm = ({
name={field.name} name={field.name}
onValueChange={(value) => { onValueChange={(value) => {
field.onChange(value); field.onChange(value);
setValue('fromAccount', '');
}} }}
placeholder={t('Please select an asset')} placeholder={t('Please select an asset')}
value={field.value} value={field.value}
@ -280,10 +232,10 @@ export const TransferForm = ({
)} )}
</TradingFormGroup> </TradingFormGroup>
<TradingFormGroup label={t('From account')} labelFor="fromAccount"> <TradingFormGroup label={t('From account')} labelFor="fromAccount">
<TradingSelect <Controller
id="fromAccount" control={control}
defaultValue="" name="fromAccount"
{...register('fromAccount', { rules={{
validate: { validate: {
required, required,
sameAccount: (value) => { sameAccount: (value) => {
@ -298,19 +250,41 @@ export const TransferForm = ({
return true; return true;
}, },
}, },
})} }}
render={({ field }) => (
<TradingSelect
id="fromAccount"
defaultValue=""
{...field}
onChange={(e) => {
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);
}
}}
> >
<option value="" disabled={true}> <option value="" disabled={true}>
{t('Please select')} {t('Please select')}
</option> </option>
{accounts {accounts
.filter((a) => { .filter((a) => {
if (!assetId) return true; if (!selectedAssetId) return true;
return assetId === a.asset.id; return selectedAssetId === a.asset.id;
}) })
.map((a) => { .map((a) => {
const id = `${a.type}-${a.asset.id}`;
return ( return (
<option value={a.type} key={`${a.type}-${a.asset.id}`}> <option value={id} key={id}>
{AccountTypeMapping[a.type]} ( {AccountTypeMapping[a.type]} (
{addDecimalsFormatNumber(a.balance, a.asset.decimals)}{' '} {addDecimalsFormatNumber(a.balance, a.asset.decimals)}{' '}
{a.asset.symbol}) {a.asset.symbol})
@ -318,30 +292,64 @@ export const TransferForm = ({
); );
})} })}
</TradingSelect> </TradingSelect>
)}
/>
{errors.fromAccount?.message && ( {errors.fromAccount?.message && (
<TradingInputError forInput="fromAccount"> <TradingInputError forInput="fromAccount">
{errors.fromAccount.message} {errors.fromAccount.message}
</TradingInputError> </TradingInputError>
)} )}
</TradingFormGroup> </TradingFormGroup>
<TradingFormGroup label={t('To account')} labelFor="toAccount"> <TradingFormGroup label="To Vega key" labelFor="toVegaKey">
<AddressField
onChange={() => {
setValue('toVegaKey', '');
setToVegaKeyMode((curr) => (curr === 'input' ? 'select' : 'input'));
}}
mode={toVegaKeyMode}
select={
<TradingSelect <TradingSelect
id="toAccount" {...register('toVegaKey')}
defaultValue={AccountType.ACCOUNT_TYPE_GENERAL} disabled={fromVested}
id="toVegaKey"
> >
<option value={AccountType.ACCOUNT_TYPE_GENERAL}> <option value="" disabled={true}>
{toGeneralAccount {t('Please select')}
? `${
AccountTypeMapping[AccountType.ACCOUNT_TYPE_GENERAL]
} (${addDecimalsFormatNumber(
toGeneralAccount.balance,
toGeneralAccount.asset.decimals
)} ${toGeneralAccount.asset.symbol})`
: `${AccountTypeMapping[AccountType.ACCOUNT_TYPE_GENERAL]} ${
asset ? `(0 ${asset.symbol})` : ''
}`}
</option> </option>
{pubKeys?.map((pk) => {
const text = pk === pubKey ? t('Current key: ') + pk : pk;
return (
<option key={pk} value={pk}>
{text}
</option>
);
})}
</TradingSelect> </TradingSelect>
}
input={
fromVested ? null : (
<TradingInput
// eslint-disable-next-line jsx-a11y/no-autofocus
autoFocus={true} // focus input immediately after is shown
id="toVegaKey"
type="text"
disabled={fromVested}
{...register('toVegaKey', {
validate: {
required,
vegaPublicKey,
},
})}
/>
)
}
/>
{errors.toVegaKey?.message && (
<TradingInputError forInput="toVegaKey">
{errors.toVegaKey.message}
</TradingInputError>
)}
</TradingFormGroup> </TradingFormGroup>
<TradingFormGroup label="Amount" labelFor="amount"> <TradingFormGroup label="Amount" labelFor="amount">
<TradingInput <TradingInput
@ -353,7 +361,43 @@ export const TransferForm = ({
{...register('amount', { {...register('amount', {
validate: { validate: {
required, required,
minSafe: (value) => 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) => { maxSafe: (v) => {
const value = new BigNumber(v); const value = new BigNumber(v);
if (value.isGreaterThan(max)) { if (value.isGreaterThan(max)) {
@ -369,7 +413,9 @@ export const TransferForm = ({
type="button" type="button"
className="absolute top-0 right-0 ml-auto text-xs underline" className="absolute top-0 right-0 ml-auto text-xs underline"
onClick={() => onClick={() =>
setValue('amount', parseFloat(accountBalance).toString()) setValue('amount', parseFloat(accountBalance).toString(), {
shouldValidate: true,
})
} }
> >
{t('Use max')} {t('Use max')}
@ -390,10 +436,10 @@ export const TransferForm = ({
<div> <div>
<TradingCheckbox <TradingCheckbox
name="include-transfer-fee" name="include-transfer-fee"
disabled={!transferAmount} disabled={!transferAmount || fromVested}
label={t('Include transfer fee')} label={t('Include transfer fee')}
checked={includeFee} checked={includeFee}
onCheckedChange={() => setIncludeFee(!includeFee)} onCheckedChange={() => setIncludeFee((x) => !x)}
/> />
</div> </div>
</Tooltip> </Tooltip>
@ -403,7 +449,7 @@ export const TransferForm = ({
amount={transferAmount} amount={transferAmount}
transferAmount={transferAmount} transferAmount={transferAmount}
feeFactor={feeFactor} feeFactor={feeFactor}
fee={fee} fee={fromVested ? '0' : fee}
decimals={asset?.decimals} decimals={asset?.decimals}
/> />
)} )}
@ -485,32 +531,38 @@ export const TransferFee = ({
); );
}; };
type ToVegaKeyMode = 'input' | 'select';
interface AddressInputProps { interface AddressInputProps {
select: ReactNode; select: ReactNode;
input: ReactNode; input: ReactNode;
mode: ToVegaKeyMode;
onChange: () => void; onChange: () => void;
} }
export const AddressField = ({ export const AddressField = ({
select, select,
input, input,
mode,
onChange, onChange,
}: AddressInputProps) => { }: AddressInputProps) => {
const [isInput, setIsInput] = useState(false); const isInput = mode === 'input';
return ( return (
<> <>
{isInput ? input : select} {isInput ? input : select}
{select && input && (
<button <button
type="button" type="button"
onClick={() => { onClick={onChange}
setIsInput((curr) => !curr);
onChange();
}}
className="absolute top-0 right-0 ml-auto text-xs underline" className="absolute top-0 right-0 ml-auto text-xs underline"
> >
{isInput ? t('Select from wallet') : t('Enter manually')} {isInput ? t('Select from wallet') : t('Enter manually')}
</button> </button>
)}
</> </>
); );
}; };
const parseFromAccount = (fromAccountStr: string) => {
return fromAccountStr.split('-') as [AccountType, string];
};

View File

@ -176,6 +176,7 @@ export const NetworkParams = {
market_liquidity_feeCalculationTimeStep: market_liquidity_feeCalculationTimeStep:
'market_liquidity_feeCalculationTimeStep', 'market_liquidity_feeCalculationTimeStep',
transfer_fee_factor: 'transfer_fee_factor', transfer_fee_factor: 'transfer_fee_factor',
transfer_minTransferQuantumMultiple: 'transfer_minTransferQuantumMultiple',
network_validators_incumbentBonus: 'network_validators_incumbentBonus', network_validators_incumbentBonus: 'network_validators_incumbentBonus',
} as const; } as const;