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 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,78 +136,191 @@ describe('TransferForm', () => {
}); });
}); });
it('validates a manually entered address', async () => { describe('IncludeFeesCheckbox', () => {
render(<TransferForm {...props} />); it('validates fields and submits when checkbox is checked', async () => {
submit(); render(<TransferForm {...props} />);
expect(await screen.findAllByText('Required')).toHaveLength(3);
const toggle = screen.getByText('Enter manually'); // check current pubkey not shown
fireEvent.click(toggle); const keySelect: HTMLSelectElement = screen.getByLabelText('Vega key');
// has switched to input expect(keySelect.children).toHaveLength(2);
expect(toggle).toHaveTextContent('Select from wallet'); expect(Array.from(keySelect.options).map((o) => o.value)).toEqual([
expect(screen.getByLabelText('Vega key')).toHaveAttribute('type', 'text'); '',
fireEvent.change(screen.getByLabelText('Vega key'), { props.pubKeys[1],
target: { value: 'invalid-address' }, ]);
});
await waitFor(() => { submit();
const errors = screen.getAllByTestId('input-error-text'); expect(await screen.findAllByText('Required')).toHaveLength(3);
expect(errors[0]).toHaveTextContent('Invalid Vega key');
// 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 it('validates fields when checkbox is not checked', async () => {
fireEvent.change(screen.getByLabelText('Vega key'), { render(<TransferForm {...props} />);
target: { value: pubKey },
// 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(() => { it('Does not provide select option if there is only a single key', () => {
const errors = screen.getAllByTestId('input-error-text'); render(<AddressField {...props} pubKeys={['single-pubKey']} />);
expect(errors[0]).toHaveTextContent('Vega key is the same'); 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, 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
data-testid="transfer-fee"
className="text-neutral-500 dark:text-neutral-300"
>
{formatNumber(fee, decimals)}
</div>
</div> </div>
<div <div className="flex justify-between gap-1 items-center flex-wrap">
data-testid="transfer-fee" <Tooltip
className="text-neutral-500 dark:text-neutral-300" description={t(
> `The total amount to be transferred (without the fee)`
{value} )}
>
<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>
); );

View File

@ -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;