feat(trading): include fees in transfer amount (#3345)

This commit is contained in:
m.ray 2023-04-07 13:00:11 -04:00 committed by GitHub
parent 3b830abe07
commit bafd3c384c
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
3 changed files with 328 additions and 88 deletions

View File

@ -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 { AddressField, TransferFee, TransferForm } from './transfer-form';
import { AccountType } from '@vegaprotocol/types';
@ -27,6 +33,34 @@ describe('TransferForm', () => {
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 () => {
render(<TransferForm {...props} />);
@ -62,15 +96,17 @@ describe('TransferForm', () => {
formatNumber(asset.balance, asset.decimals)
);
const amountInput = screen.getByLabelText('Amount');
// Test amount validation
fireEvent.change(screen.getByLabelText('Amount'), {
fireEvent.change(amountInput, {
target: { value: '0.00000001' },
});
expect(
await screen.findByText('Value is below minimum')
).toBeInTheDocument();
fireEvent.change(screen.getByLabelText('Amount'), {
fireEvent.change(amountInput, {
target: { value: '9999999' },
});
expect(
@ -78,7 +114,7 @@ describe('TransferForm', () => {
).toBeInTheDocument();
// set valid amount
fireEvent.change(screen.getByLabelText('Amount'), {
fireEvent.change(amountInput, {
target: { value: amount },
});
expect(screen.getByTestId('transfer-fee')).toHaveTextContent(
@ -100,78 +136,191 @@ describe('TransferForm', () => {
});
});
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');
describe('IncludeFeesCheckbox', () => {
it('validates fields and submits when checkbox is 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 },
});
// 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(() => {
expect(props.submitTransfer).toHaveBeenCalledTimes(1);
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: {},
});
});
});
// same pubkey
fireEvent.change(screen.getByLabelText('Vega key'), {
target: { value: pubKey },
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', () => {
const props = {
pubKeys: ['pubkey-1', 'pubkey-2'],
select: <div>select</div>,
input: <div>input</div>,
onChange: jest.fn(),
};
it('toggles content and calls onChange', async () => {
const mockOnChange = jest.fn();
render(<AddressField {...props} onChange={mockOnChange} />);
// 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'));
expect(screen.queryByText('select')).not.toBeInTheDocument();
expect(screen.getByText('input')).toBeInTheDocument();
expect(mockOnChange).toHaveBeenCalledTimes(1);
fireEvent.click(screen.getByText('Select from wallet'));
expect(screen.getByText('select')).toBeInTheDocument();
expect(screen.queryByText('input')).not.toBeInTheDocument();
expect(mockOnChange).toHaveBeenCalledTimes(2);
});
await waitFor(() => {
const errors = screen.getAllByTestId('input-error-text');
expect(errors[0]).toHaveTextContent('Vega key is the same');
it('Does not provide select option if there is only a single key', () => {
render(<AddressField {...props} pubKeys={['single-pubKey']} />);
expect(screen.getByText('input')).toBeInTheDocument();
expect(screen.queryByText('Select from wallet')).not.toBeInTheDocument();
});
});
describe('TransferFee', () => {
const props = {
amount: '200',
feeFactor: '0.001',
fee: '0.2',
transferAmount: '200',
decimals: 8,
};
it('calculates and renders the transfer fee', () => {
render(<TransferFee {...props} />);
const expected = new BigNumber(props.amount)
.times(props.feeFactor)
.toFixed();
const total = new BigNumber(props.amount).plus(expected).toFixed();
expect(screen.getByTestId('transfer-fee')).toHaveTextContent(expected);
expect(screen.getByTestId('transfer-amount')).toHaveTextContent(
props.amount
);
expect(screen.getByTestId('total-transfer-fee')).toHaveTextContent(total);
});
});
});
describe('AddressField', () => {
const props = {
pubKeys: ['pubkey-1', 'pubkey-2'],
select: <div>select</div>,
input: <div>input</div>,
onChange: jest.fn(),
};
it('toggles content and calls onChange', async () => {
const mockOnChange = jest.fn();
render(<AddressField {...props} onChange={mockOnChange} />);
// 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'));
expect(screen.queryByText('select')).not.toBeInTheDocument();
expect(screen.getByText('input')).toBeInTheDocument();
expect(mockOnChange).toHaveBeenCalledTimes(1);
fireEvent.click(screen.getByText('Select from wallet'));
expect(screen.getByText('select')).toBeInTheDocument();
expect(screen.queryByText('input')).not.toBeInTheDocument();
expect(mockOnChange).toHaveBeenCalledTimes(2);
});
it('Does not provide select option if there is only a single key', () => {
render(<AddressField {...props} pubKeys={['single-pubKey']} />);
expect(screen.getByText('input')).toBeInTheDocument();
expect(screen.queryByText('Select from wallet')).not.toBeInTheDocument();
});
});
describe('TransferFee', () => {
const props = {
amount: '200',
feeFactor: '0.001',
};
it('calculates and renders the transfer fee', () => {
render(<TransferFee {...props} />);
const expected = new BigNumber(props.amount)
.times(props.feeFactor)
.toFixed();
expect(screen.getByTestId('transfer-fee')).toHaveTextContent(expected);
});
});

View File

@ -16,6 +16,7 @@ import {
RichSelect,
Select,
Tooltip,
Checkbox,
} from '@vegaprotocol/ui-toolkit';
import type { Transfer } from '@vegaprotocol/wallet';
import { normalizeTransfer } from '@vegaprotocol/wallet';
@ -63,6 +64,26 @@ export const TransferForm = ({
const amount = watch('amount');
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(() => {
return assets.find((a) => a.id === assetId);
}, [assets, assetId]);
@ -72,13 +93,16 @@ export const TransferForm = ({
if (!asset) {
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,
decimals: asset.decimals,
});
submitTransfer(transfer);
},
[asset, submitTransfer]
[asset, submitTransfer, transferAmount]
);
const min = useMemo(() => {
@ -213,7 +237,32 @@ export const TransferForm = ({
<InputError forInput="amount">{errors.amount.message}</InputError>
)}
</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}>
{t('Confirm transfer')}
</Button>
@ -223,34 +272,71 @@ export const TransferForm = ({
export const TransferFee = ({
amount,
transferAmount,
feeFactor,
fee,
decimals,
}: {
amount: string;
transferAmount: string;
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
// 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();
const totalValue = new BigNumber(transferAmount).plus(fee).toString();
return (
<div className="mb-4 flex justify-between items-center gap-4 flex-wrap">
<div>
<div className="mb-4 flex flex-col gap-2 text-xs">
<div className="flex justify-between gap-1 items-center flex-wrap">
<Tooltip
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>
</Tooltip>
<div
data-testid="transfer-fee"
className="text-neutral-500 dark:text-neutral-300"
>
{formatNumber(fee, decimals)}
</div>
</div>
<div
data-testid="transfer-fee"
className="text-neutral-500 dark:text-neutral-300"
>
{value}
<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>
);

View File

@ -1 +1,6 @@
import '@testing-library/jest-dom';
import ResizeObserver from 'resize-observer-polyfill';
import { defaultFallbackInView } from 'react-intersection-observer';
defaultFallbackInView(true);
global.ResizeObserver = ResizeObserver;