feat(trading): include fees in transfer amount (#3345)
This commit is contained in:
parent
3b830abe07
commit
bafd3c384c
@ -1,4 +1,10 @@
|
|||||||
import { fireEvent, render, screen, waitFor } from '@testing-library/react';
|
import {
|
||||||
|
act,
|
||||||
|
fireEvent,
|
||||||
|
render,
|
||||||
|
screen,
|
||||||
|
waitFor,
|
||||||
|
} from '@testing-library/react';
|
||||||
import BigNumber from 'bignumber.js';
|
import BigNumber from 'bignumber.js';
|
||||||
import { AddressField, TransferFee, TransferForm } from './transfer-form';
|
import { AddressField, TransferFee, TransferForm } from './transfer-form';
|
||||||
import { AccountType } from '@vegaprotocol/types';
|
import { AccountType } from '@vegaprotocol/types';
|
||||||
@ -27,6 +33,34 @@ describe('TransferForm', () => {
|
|||||||
submitTransfer: jest.fn(),
|
submitTransfer: jest.fn(),
|
||||||
};
|
};
|
||||||
|
|
||||||
|
it('validates a manually entered address', async () => {
|
||||||
|
render(<TransferForm {...props} />);
|
||||||
|
submit();
|
||||||
|
expect(await screen.findAllByText('Required')).toHaveLength(3);
|
||||||
|
const toggle = screen.getByText('Enter manually');
|
||||||
|
fireEvent.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');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
it('validates fields and submits', async () => {
|
it('validates fields and submits', async () => {
|
||||||
render(<TransferForm {...props} />);
|
render(<TransferForm {...props} />);
|
||||||
|
|
||||||
@ -62,15 +96,17 @@ describe('TransferForm', () => {
|
|||||||
formatNumber(asset.balance, asset.decimals)
|
formatNumber(asset.balance, asset.decimals)
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const amountInput = screen.getByLabelText('Amount');
|
||||||
|
|
||||||
// Test amount validation
|
// Test amount validation
|
||||||
fireEvent.change(screen.getByLabelText('Amount'), {
|
fireEvent.change(amountInput, {
|
||||||
target: { value: '0.00000001' },
|
target: { value: '0.00000001' },
|
||||||
});
|
});
|
||||||
expect(
|
expect(
|
||||||
await screen.findByText('Value is below minimum')
|
await screen.findByText('Value is below minimum')
|
||||||
).toBeInTheDocument();
|
).toBeInTheDocument();
|
||||||
|
|
||||||
fireEvent.change(screen.getByLabelText('Amount'), {
|
fireEvent.change(amountInput, {
|
||||||
target: { value: '9999999' },
|
target: { value: '9999999' },
|
||||||
});
|
});
|
||||||
expect(
|
expect(
|
||||||
@ -78,7 +114,7 @@ describe('TransferForm', () => {
|
|||||||
).toBeInTheDocument();
|
).toBeInTheDocument();
|
||||||
|
|
||||||
// set valid amount
|
// set valid amount
|
||||||
fireEvent.change(screen.getByLabelText('Amount'), {
|
fireEvent.change(amountInput, {
|
||||||
target: { value: amount },
|
target: { value: amount },
|
||||||
});
|
});
|
||||||
expect(screen.getByTestId('transfer-fee')).toHaveTextContent(
|
expect(screen.getByTestId('transfer-fee')).toHaveTextContent(
|
||||||
@ -100,35 +136,139 @@ describe('TransferForm', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it('validates a manually entered address', async () => {
|
describe('IncludeFeesCheckbox', () => {
|
||||||
|
it('validates fields and submits when checkbox is checked', async () => {
|
||||||
render(<TransferForm {...props} />);
|
render(<TransferForm {...props} />);
|
||||||
|
|
||||||
|
// 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],
|
||||||
|
]);
|
||||||
|
|
||||||
submit();
|
submit();
|
||||||
expect(await screen.findAllByText('Required')).toHaveLength(3);
|
expect(await screen.findAllByText('Required')).toHaveLength(3);
|
||||||
const toggle = screen.getByText('Enter manually');
|
|
||||||
fireEvent.click(toggle);
|
// Select a pubkey
|
||||||
// has switched to input
|
|
||||||
expect(toggle).toHaveTextContent('Select from wallet');
|
|
||||||
expect(screen.getByLabelText('Vega key')).toHaveAttribute('type', 'text');
|
|
||||||
fireEvent.change(screen.getByLabelText('Vega key'), {
|
fireEvent.change(screen.getByLabelText('Vega key'), {
|
||||||
target: { value: 'invalid-address' },
|
target: { value: props.pubKeys[1] },
|
||||||
});
|
|
||||||
await waitFor(() => {
|
|
||||||
const errors = screen.getAllByTestId('input-error-text');
|
|
||||||
expect(errors[0]).toHaveTextContent('Invalid Vega key');
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// same pubkey
|
// Select asset
|
||||||
fireEvent.change(screen.getByLabelText('Vega key'), {
|
fireEvent.change(
|
||||||
target: { value: pubKey },
|
// 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(screen.getByTestId('asset-balance')).toHaveTextContent(
|
||||||
|
formatNumber(asset.balance, asset.decimals)
|
||||||
|
);
|
||||||
|
|
||||||
|
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 },
|
||||||
|
});
|
||||||
|
// check include fees checkbox
|
||||||
|
fireEvent.click(checkbox);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
expect(checkbox).toBeChecked();
|
||||||
|
const expectedFee = new BigNumber(amount)
|
||||||
|
.times(props.feeFactor)
|
||||||
|
.toFixed();
|
||||||
|
const expectedAmount = new BigNumber(amount).minus(expectedFee).toFixed();
|
||||||
|
expect(screen.getByTestId('transfer-fee')).toHaveTextContent(expectedFee);
|
||||||
|
expect(screen.getByTestId('transfer-amount')).toHaveTextContent(
|
||||||
|
expectedAmount
|
||||||
|
);
|
||||||
|
expect(screen.getByTestId('total-transfer-fee')).toHaveTextContent(
|
||||||
|
amount
|
||||||
|
);
|
||||||
|
|
||||||
|
submit();
|
||||||
|
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
const errors = screen.getAllByTestId('input-error-text');
|
expect(props.submitTransfer).toHaveBeenCalledTimes(1);
|
||||||
expect(errors[0]).toHaveTextContent('Vega key is the same');
|
expect(props.submitTransfer).toHaveBeenCalledWith({
|
||||||
|
fromAccountType: AccountType.ACCOUNT_TYPE_GENERAL,
|
||||||
|
toAccountType: AccountType.ACCOUNT_TYPE_GENERAL,
|
||||||
|
to: props.pubKeys[1],
|
||||||
|
asset: asset.id,
|
||||||
|
amount: removeDecimal(amount, asset.decimals),
|
||||||
|
oneOff: {},
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('validates fields when checkbox is not checked', async () => {
|
||||||
|
render(<TransferForm {...props} />);
|
||||||
|
|
||||||
|
// 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],
|
||||||
|
]);
|
||||||
|
|
||||||
|
submit();
|
||||||
|
expect(await screen.findAllByText('Required')).toHaveLength(3);
|
||||||
|
|
||||||
|
// Select a pubkey
|
||||||
|
fireEvent.change(screen.getByLabelText('Vega key'), {
|
||||||
|
target: { value: 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(screen.getByTestId('asset-balance')).toHaveTextContent(
|
||||||
|
formatNumber(asset.balance, asset.decimals)
|
||||||
|
);
|
||||||
|
|
||||||
|
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 },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
expect(checkbox).not.toBeChecked();
|
||||||
|
const expectedFee = new BigNumber(amount)
|
||||||
|
.times(props.feeFactor)
|
||||||
|
.toFixed();
|
||||||
|
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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe('AddressField', () => {
|
describe('AddressField', () => {
|
||||||
const props = {
|
const props = {
|
||||||
pubKeys: ['pubkey-1', 'pubkey-2'],
|
pubKeys: ['pubkey-1', 'pubkey-2'],
|
||||||
@ -165,6 +305,9 @@ describe('TransferFee', () => {
|
|||||||
const props = {
|
const props = {
|
||||||
amount: '200',
|
amount: '200',
|
||||||
feeFactor: '0.001',
|
feeFactor: '0.001',
|
||||||
|
fee: '0.2',
|
||||||
|
transferAmount: '200',
|
||||||
|
decimals: 8,
|
||||||
};
|
};
|
||||||
it('calculates and renders the transfer fee', () => {
|
it('calculates and renders the transfer fee', () => {
|
||||||
render(<TransferFee {...props} />);
|
render(<TransferFee {...props} />);
|
||||||
@ -172,6 +315,12 @@ describe('TransferFee', () => {
|
|||||||
const expected = new BigNumber(props.amount)
|
const expected = new BigNumber(props.amount)
|
||||||
.times(props.feeFactor)
|
.times(props.feeFactor)
|
||||||
.toFixed();
|
.toFixed();
|
||||||
|
const total = new BigNumber(props.amount).plus(expected).toFixed();
|
||||||
expect(screen.getByTestId('transfer-fee')).toHaveTextContent(expected);
|
expect(screen.getByTestId('transfer-fee')).toHaveTextContent(expected);
|
||||||
|
expect(screen.getByTestId('transfer-amount')).toHaveTextContent(
|
||||||
|
props.amount
|
||||||
|
);
|
||||||
|
expect(screen.getByTestId('total-transfer-fee')).toHaveTextContent(total);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
@ -16,6 +16,7 @@ import {
|
|||||||
RichSelect,
|
RichSelect,
|
||||||
Select,
|
Select,
|
||||||
Tooltip,
|
Tooltip,
|
||||||
|
Checkbox,
|
||||||
} from '@vegaprotocol/ui-toolkit';
|
} from '@vegaprotocol/ui-toolkit';
|
||||||
import type { Transfer } from '@vegaprotocol/wallet';
|
import type { Transfer } from '@vegaprotocol/wallet';
|
||||||
import { normalizeTransfer } from '@vegaprotocol/wallet';
|
import { normalizeTransfer } from '@vegaprotocol/wallet';
|
||||||
@ -63,6 +64,26 @@ export const TransferForm = ({
|
|||||||
const amount = watch('amount');
|
const amount = watch('amount');
|
||||||
const assetId = watch('asset');
|
const assetId = watch('asset');
|
||||||
|
|
||||||
|
const [includeFee, setIncludeFee] = useState(false);
|
||||||
|
|
||||||
|
const transferAmount = useMemo(() => {
|
||||||
|
if (!amount) return undefined;
|
||||||
|
if (includeFee && feeFactor) {
|
||||||
|
return new BigNumber(1).minus(feeFactor).times(amount).toString();
|
||||||
|
}
|
||||||
|
return amount;
|
||||||
|
}, [amount, includeFee, feeFactor]);
|
||||||
|
|
||||||
|
const fee = useMemo(() => {
|
||||||
|
if (!transferAmount) return undefined;
|
||||||
|
if (includeFee) {
|
||||||
|
return new BigNumber(amount).minus(transferAmount).toString();
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
feeFactor && new BigNumber(feeFactor).times(transferAmount).toString()
|
||||||
|
);
|
||||||
|
}, [amount, includeFee, transferAmount, feeFactor]);
|
||||||
|
|
||||||
const asset = useMemo(() => {
|
const asset = useMemo(() => {
|
||||||
return assets.find((a) => a.id === assetId);
|
return assets.find((a) => a.id === assetId);
|
||||||
}, [assets, assetId]);
|
}, [assets, assetId]);
|
||||||
@ -72,13 +93,16 @@ export const TransferForm = ({
|
|||||||
if (!asset) {
|
if (!asset) {
|
||||||
throw new Error('Submitted transfer with no asset selected');
|
throw new Error('Submitted transfer with no asset selected');
|
||||||
}
|
}
|
||||||
const transfer = normalizeTransfer(fields.toAddress, fields.amount, {
|
if (!transferAmount) {
|
||||||
|
throw new Error('Submitted transfer with no amount selected');
|
||||||
|
}
|
||||||
|
const transfer = normalizeTransfer(fields.toAddress, transferAmount, {
|
||||||
id: asset.id,
|
id: asset.id,
|
||||||
decimals: asset.decimals,
|
decimals: asset.decimals,
|
||||||
});
|
});
|
||||||
submitTransfer(transfer);
|
submitTransfer(transfer);
|
||||||
},
|
},
|
||||||
[asset, submitTransfer]
|
[asset, submitTransfer, transferAmount]
|
||||||
);
|
);
|
||||||
|
|
||||||
const min = useMemo(() => {
|
const min = useMemo(() => {
|
||||||
@ -213,7 +237,32 @@ export const TransferForm = ({
|
|||||||
<InputError forInput="amount">{errors.amount.message}</InputError>
|
<InputError forInput="amount">{errors.amount.message}</InputError>
|
||||||
)}
|
)}
|
||||||
</FormGroup>
|
</FormGroup>
|
||||||
<TransferFee amount={amount} feeFactor={feeFactor} />
|
<div className="mb-4">
|
||||||
|
<Checkbox
|
||||||
|
name="include-transfer-fee"
|
||||||
|
disabled={!transferAmount}
|
||||||
|
label={
|
||||||
|
<Tooltip
|
||||||
|
description={t(
|
||||||
|
`The fee will be taken from the amount you are transferring.`
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<div>{t('Include transfer fee')}</div>
|
||||||
|
</Tooltip>
|
||||||
|
}
|
||||||
|
checked={includeFee}
|
||||||
|
onCheckedChange={() => setIncludeFee(!includeFee)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{transferAmount && fee && (
|
||||||
|
<TransferFee
|
||||||
|
amount={transferAmount}
|
||||||
|
transferAmount={transferAmount}
|
||||||
|
feeFactor={feeFactor}
|
||||||
|
fee={fee}
|
||||||
|
decimals={asset?.decimals}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
<Button type="submit" variant="primary" fill={true}>
|
<Button type="submit" variant="primary" fill={true}>
|
||||||
{t('Confirm transfer')}
|
{t('Confirm transfer')}
|
||||||
</Button>
|
</Button>
|
||||||
@ -223,34 +272,71 @@ export const TransferForm = ({
|
|||||||
|
|
||||||
export const TransferFee = ({
|
export const TransferFee = ({
|
||||||
amount,
|
amount,
|
||||||
|
transferAmount,
|
||||||
feeFactor,
|
feeFactor,
|
||||||
|
fee,
|
||||||
|
decimals,
|
||||||
}: {
|
}: {
|
||||||
amount: string;
|
amount: string;
|
||||||
|
transferAmount: string;
|
||||||
feeFactor: string | null;
|
feeFactor: string | null;
|
||||||
|
fee?: string;
|
||||||
|
decimals?: number;
|
||||||
}) => {
|
}) => {
|
||||||
if (!feeFactor || !amount) return null;
|
if (!feeFactor || !amount || !transferAmount || !fee) return null;
|
||||||
|
|
||||||
// using toFixed without an argument will always return a
|
const totalValue = new BigNumber(transferAmount).plus(fee).toString();
|
||||||
// number in normal notation without rounding, formatting functions
|
|
||||||
// arent working in a way which won't round the decimal places
|
|
||||||
const value = new BigNumber(amount).times(feeFactor).toFixed();
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="mb-4 flex justify-between items-center gap-4 flex-wrap">
|
<div className="mb-4 flex flex-col gap-2 text-xs">
|
||||||
<div>
|
<div className="flex justify-between gap-1 items-center flex-wrap">
|
||||||
<Tooltip
|
<Tooltip
|
||||||
description={t(
|
description={t(
|
||||||
`The transfer fee is set by the network parameter transfer.fee.factor, currently set to ${feeFactor}`
|
`The transfer fee is set by the network parameter transfer.fee.factor, currently set to %s`,
|
||||||
|
[feeFactor]
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<div>{t('Transfer fee')}</div>
|
<div>{t('Transfer fee')}</div>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
</div>
|
|
||||||
<div
|
<div
|
||||||
data-testid="transfer-fee"
|
data-testid="transfer-fee"
|
||||||
className="text-neutral-500 dark:text-neutral-300"
|
className="text-neutral-500 dark:text-neutral-300"
|
||||||
>
|
>
|
||||||
{value}
|
{formatNumber(fee, decimals)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-between gap-1 items-center flex-wrap">
|
||||||
|
<Tooltip
|
||||||
|
description={t(
|
||||||
|
`The total amount to be transferred (without the fee)`
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<div>{t('Amount to be transferred')}</div>
|
||||||
|
</Tooltip>
|
||||||
|
|
||||||
|
<div
|
||||||
|
data-testid="transfer-amount"
|
||||||
|
className="text-neutral-500 dark:text-neutral-300"
|
||||||
|
>
|
||||||
|
{formatNumber(amount, decimals)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-between gap-1 items-center flex-wrap">
|
||||||
|
<Tooltip
|
||||||
|
description={t(
|
||||||
|
`The total amount taken from your account. The amount to be transferred plus the fee.`
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<div>{t('Total amount (with fee)')}</div>
|
||||||
|
</Tooltip>
|
||||||
|
|
||||||
|
<div
|
||||||
|
data-testid="total-transfer-fee"
|
||||||
|
className="text-neutral-500 dark:text-neutral-300"
|
||||||
|
>
|
||||||
|
{formatNumber(totalValue, decimals)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
@ -1 +1,6 @@
|
|||||||
import '@testing-library/jest-dom';
|
import '@testing-library/jest-dom';
|
||||||
|
import ResizeObserver from 'resize-observer-polyfill';
|
||||||
|
import { defaultFallbackInView } from 'react-intersection-observer';
|
||||||
|
|
||||||
|
defaultFallbackInView(true);
|
||||||
|
global.ResizeObserver = ResizeObserver;
|
||||||
|
Loading…
Reference in New Issue
Block a user