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;