Feat/1660 validate available margin (#1860)

* feat: deal ticket margin validation

* feat: deal ticket margin validation - working solution

* feat: deal ticket margin validation - add int test

* feat: deal ticket margin validation - improve int test

* feat: deal ticket margin validation - improve int test

* feat: deal ticket margin validation - fix unit test

* feat: deal ticket margin validation - improve case when no account

* feat: deal ticket margin validation - fix unit test

* feat: deal ticket margin validation - fix int test

* feat: deal ticket margin validation - fix int test

* feat: deal ticket margin validation - fix int test

Co-authored-by: maciek <maciek@vegaprotocol.io>
This commit is contained in:
macqbat 2022-10-27 20:12:03 +02:00 committed by GitHub
parent 75db1d3ec6
commit 5ecf7d6a9f
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
14 changed files with 355 additions and 16 deletions

View File

@ -1,15 +1,29 @@
import { Schema as Types } from '@vegaprotocol/types';
export const generatePartyMarketData = () => {
return {
party: {
id: '2e1ef32e5804e14232406aebaad719087d326afa5c648b7824d0823d8a46c8d1',
accounts: [
{
type: 'General',
type: Types.AccountType.ACCOUNT_TYPE_GENERAL,
balance: '1200000',
asset: { id: 'fBTC', decimals: 5, __typename: 'Asset' },
market: null,
__typename: 'AccountBalance',
},
{
__typename: 'AccountBalance',
type: Types.AccountType.ACCOUNT_TYPE_GENERAL,
balance: '0.000000001',
asset: {
__typename: 'Asset',
id: '5cfa87844724df6069b94e4c8a6f03af21907d7bc251593d08e4251043ee9f7c',
symbol: 'tUSD',
name: 'usd',
decimals: 0,
},
},
],
marginsConnection: { edges: null, __typename: 'MarginConnection' },
positionsConnection: { edges: null, __typename: 'PositionConnection' },

View File

@ -68,19 +68,20 @@ export const DealTicketSteps = ({ market }: DealTicketMarketProps) => {
const emptyString = ' - ';
const step = toDecimal(market.positionDecimalPlaces);
const order = watch();
const { message: invalidText, isDisabled } = useOrderValidation({
market,
orderType: order.type,
orderTimeInForce: order.timeInForce,
fieldErrors: errors,
});
const { submit, transaction, finalizedOrder, Dialog } = useOrderSubmit();
const { pubKey } = useVegaWallet();
const estMargin = useOrderMargin({
order,
market,
partyId: pubKey || '',
});
const { message: invalidText, isDisabled } = useOrderValidation({
market,
orderType: order.type,
orderTimeInForce: order.timeInForce,
fieldErrors: errors,
estMargin,
});
const { submit, transaction, finalizedOrder, Dialog } = useOrderSubmit();
const { data: partyBalance } = usePartyBalanceQuery({
variables: { partyId: pubKey || '' },

View File

@ -3,7 +3,9 @@ import {
MarketTradingMode,
AuctionTrigger,
} from '@vegaprotocol/types';
import { generateEstimateOrder } from '../support/mocks/generate-fees';
import { connectVegaWallet } from '../support/vega-wallet';
import { aliasQuery } from '@vegaprotocol/cypress';
const orderSizeField = 'order-size';
const orderPriceField = 'order-price';
@ -201,7 +203,7 @@ describe('must submit order', { tags: '@smoke' }, () => {
});
describe('deal ticket validation', { tags: '@smoke' }, () => {
before(() => {
beforeEach(() => {
cy.mockTradingPage();
cy.visit('/markets/market-0');
cy.wait('@Market');
@ -441,3 +443,40 @@ describe('suspended market validation', { tags: '@regression' }, () => {
);
});
});
describe('margin required validation', { tags: '@regression' }, () => {
before(() => {
cy.mockTradingPage();
cy.mockGQL((req) => {
aliasQuery(
req,
'EstimateOrder',
generateEstimateOrder({
estimateOrder: {
marginLevels: { __typename: 'MarginLevels', initialLevel: '1000' },
},
})
);
});
cy.visit('/markets/market-0');
connectVegaWallet();
cy.wait('@Market');
});
it('should display info and button for deposit', () => {
cy.getByTestId('place-order').should('be.disabled');
cy.getByTestId('dealticket-error-message').should(
'contain.text',
"You don't have enough margin available to open this position"
);
cy.getByTestId('dealticket-error-message').should(
'contain.text',
'0.01000 tBTC currently required, 0.00100 tBTC available'
);
cy.getByTestId('deal-ticket-deposit-dialog-button').click();
cy.getByTestId('dialog-content')
.find('h1')
.eq(0)
.should('have.text', 'Deposit');
});
});

View File

@ -0,0 +1,98 @@
import type { PartialDeep } from 'type-fest';
import merge from 'lodash/merge';
import { Schema as Types } from '@vegaprotocol/types';
import type {
EstimateOrderQuery,
MarketMarkPriceQuery,
PartyBalanceQuery,
PartyMarketDataQuery,
} from '@vegaprotocol/deal-ticket';
const estimateOrderMock: EstimateOrderQuery = {
estimateOrder: {
__typename: 'OrderEstimate',
totalFeeAmount: '0.0006',
fee: {
__typename: 'TradeFee',
makerFee: '0.0001',
infrastructureFee: '0.0002',
liquidityFee: '0.0003',
},
marginLevels: { __typename: 'MarginLevels', initialLevel: '1' },
},
};
export const generateEstimateOrder = (
override?: PartialDeep<EstimateOrderQuery>
) => {
return merge(estimateOrderMock, override);
};
const marketMarkPriceMock: MarketMarkPriceQuery = {
market: {
__typename: 'Market',
decimalPlaces: 5,
data: {
__typename: 'MarketData',
markPrice: '100',
market: { __typename: 'Market', id: 'market-0' },
},
},
};
export const generateMarkPrice = () => {
return marketMarkPriceMock;
};
const partyBalanceMock: PartyBalanceQuery = {
party: {
__typename: 'Party',
accounts: [
{
__typename: 'AccountBalance',
type: Types.AccountType.ACCOUNT_TYPE_GENERAL,
balance: '100',
asset: {
__typename: 'Asset',
id: '5cfa87844724df6069b94e4c8a6f03af21907d7bc251593d08e4251043ee9f7c',
symbol: 'tBTC',
name: 'BTC',
decimals: 5,
},
},
],
},
};
export const generatePartyBalance = () => {
return partyBalanceMock;
};
export const generatePartyMarketData = (): PartyMarketDataQuery => {
return {
party: {
id: Cypress.env('VEGA_PUBLIC_KEY'),
accounts: [
{
type: Types.AccountType.ACCOUNT_TYPE_GENERAL,
balance: '1200000',
asset: { id: 'fBTC', decimals: 5, __typename: 'Asset' },
market: null,
__typename: 'AccountBalance',
},
{
__typename: 'AccountBalance',
type: Types.AccountType.ACCOUNT_TYPE_GENERAL,
balance: '100',
asset: {
__typename: 'Asset',
id: '5cfa87844724df6069b94e4c8a6f03af21907d7bc251593d08e4251043ee9f7c',
decimals: 5,
},
},
],
marginsConnection: { edges: null, __typename: 'MarginConnection' },
__typename: 'Party',
},
};
};

View File

@ -22,6 +22,12 @@ import { generateMargins, generatePositions } from './mocks/generate-positions';
import { generateStatistics } from './mocks/generate-statistics';
import { generateTrades } from './mocks/generate-trades';
import { generateWithdrawals } from './mocks/generate-withdrawals';
import {
generateEstimateOrder,
generateMarkPrice,
generatePartyBalance,
generatePartyMarketData,
} from './mocks/generate-fees';
const mockTradingPage = (
req: CyHttpMessages.IncomingHttpRequest,
@ -96,6 +102,11 @@ const mockTradingPage = (
aliasQuery(req, 'Candles', generateCandles());
aliasQuery(req, 'Withdrawals', generateWithdrawals());
aliasQuery(req, 'NetworkParamsQuery', generateNetworkParameters());
aliasQuery(req, 'EstimateOrder', generateEstimateOrder());
aliasQuery(req, 'MarketMarkPrice', generateMarkPrice());
aliasQuery(req, 'PartyBalance', generatePartyBalance());
aliasQuery(req, 'MarketPositions', generatePositions());
aliasQuery(req, 'PartyMarketData', generatePartyMarketData());
};
declare global {

View File

@ -60,6 +60,12 @@ export function createClient(base?: string) {
AccountUpdate: {
keyFields: false,
},
Party: {
keyFields: false,
},
Fees: {
keyFields: false,
},
},
});

View File

@ -41,6 +41,9 @@ function AppBody({ Component, pageProps }: AppProps) {
const [theme, toggleTheme] = useThemeSwitcher();
return (
<ThemeContext.Provider value={theme}>
<Head>
<meta name="viewport" content="width=device-width, initial-scale=1" />
</Head>
<Title />
<div className="h-full relative dark:bg-black dark:text-white z-0 grid grid-rows-[min-content,1fr,min-content]">
<AppLoader>

View File

@ -0,0 +1,47 @@
import { useVegaWallet } from '@vegaprotocol/wallet';
import { AccountType } from '@vegaprotocol/types';
import { toBigNum } from '@vegaprotocol/react-helpers';
import type { DealTicketMarketFragment } from '../deal-ticket/__generated___/DealTicket';
import type { OrderMargin } from '../../hooks/use-order-margin';
import { usePartyBalanceQuery, useSettlementAccount } from '../../hooks';
interface Props {
market: DealTicketMarketFragment;
estMargin: OrderMargin | null;
}
export const useOrderMarginValidation = ({ market, estMargin }: Props) => {
const { pubKey } = useVegaWallet();
const { data: partyBalance } = usePartyBalanceQuery({
variables: { partyId: pubKey || '' },
skip: !pubKey,
fetchPolicy: 'no-cache',
});
const settlementAccount = useSettlementAccount(
market.tradableInstrument.instrument.product.settlementAsset.id,
partyBalance?.party?.accounts || [],
AccountType.ACCOUNT_TYPE_GENERAL
);
const balance = settlementAccount
? toBigNum(
settlementAccount.balance || 0,
settlementAccount.asset.decimals || 0
)
: toBigNum('0', 0);
const margin = toBigNum(estMargin?.margin || 0, 0);
const { id, symbol, decimals } =
market.tradableInstrument.instrument.product.settlementAsset;
if (balance.isZero() || balance.isLessThan(margin)) {
return {
balance: balance.toString(),
margin: margin.toString(),
id,
symbol,
decimals,
};
}
return false;
};

View File

@ -1,5 +1,7 @@
import * as React from 'react';
import { renderHook } from '@testing-library/react';
import { useVegaWallet } from '@vegaprotocol/wallet';
import { MockedProvider } from '@apollo/client/testing';
import type { VegaWalletContextShape } from '@vegaprotocol/wallet';
import {
MarketState,
@ -8,10 +10,11 @@ import {
Schema,
} from '@vegaprotocol/types';
import type { ValidationProps } from './use-order-validation';
import { marketTranslations } from './use-order-validation';
import { useOrderValidation } from './use-order-validation';
import { marketTranslations, useOrderValidation } from './use-order-validation';
import { ERROR_SIZE_DECIMAL } from './validate-size';
import type { DealTicketMarketFragment } from '../deal-ticket/__generated___/DealTicket';
import * as OrderMarginValidation from './use-order-margin-validation';
import { ValidateMargin } from './validate-margin';
jest.mock('@vegaprotocol/wallet');
@ -73,6 +76,15 @@ const defaultOrder = {
step: 0.1,
orderType: Schema.OrderType.TYPE_MARKET,
orderTimeInForce: Schema.OrderTimeInForce.TIME_IN_FORCE_FOK,
estMargin: {
margin: '0,000001',
totalFees: '0,000006',
fees: {
makerFee: '0,000003',
liquidityFee: '0,000002',
infrastructureFee: '0,000001',
},
},
};
const ERROR = {
@ -99,16 +111,29 @@ function setup(
) {
const mockUseVegaWallet = useVegaWallet as jest.Mock;
mockUseVegaWallet.mockReturnValue({ ...defaultWalletContext, context });
return renderHook(() => useOrderValidation({ ...defaultOrder, ...props }));
return renderHook(() => useOrderValidation({ ...defaultOrder, ...props }), {
wrapper: MockedProvider,
});
}
describe('useOrderValidation', () => {
afterEach(() => {
jest.clearAllMocks();
});
it('Returns empty string when given valid data', () => {
jest
.spyOn(OrderMarginValidation, 'useOrderMarginValidation')
.mockReturnValue(false);
const { result } = setup();
expect(result.current).toStrictEqual({ isDisabled: false, message: `` });
});
it('Returns an error message when no keypair found', () => {
jest
.spyOn(OrderMarginValidation, 'useOrderMarginValidation')
.mockReturnValue(false);
const { result } = setup(defaultOrder, { pubKey: null });
expect(result.current).toStrictEqual({ isDisabled: false, message: `` });
});
@ -239,4 +264,28 @@ describe('useOrderValidation', () => {
message: ERROR.FIELD_PRICE_STEP_DECIMAL,
});
});
it('Returns an error message when the estimated margin is higher than collateral', async () => {
const invalidatedMockValue = {
balance: '0000000,1',
margin: '000,1',
id: 'instrument-id',
symbol: 'asset-symbol',
decimals: 5,
};
jest
.spyOn(OrderMarginValidation, 'useOrderMarginValidation')
.mockReturnValue(invalidatedMockValue);
const { result } = setup({});
expect(result.current.isDisabled).toBe(true);
const testElement = <ValidateMargin {...invalidatedMockValue} />;
expect((result.current.message as React.ReactElement)?.props).toEqual(
testElement.props
);
expect((result.current.message as React.ReactElement)?.type).toEqual(
testElement.type
);
});
});

View File

@ -15,6 +15,9 @@ import { ERROR_SIZE_DECIMAL } from './validate-size';
import { MarketDataGrid } from '../trading-mode-tooltip';
import { compileGridData } from '../trading-mode-tooltip/compile-grid-data';
import type { DealTicketMarketFragment } from '../deal-ticket/__generated___/DealTicket';
import { ValidateMargin } from './validate-margin';
import type { OrderMargin } from '../../hooks/use-order-margin';
import { useOrderMarginValidation } from './use-order-margin-validation';
export const isMarketInAuction = (market: DealTicketMarketFragment) => {
return [
@ -30,6 +33,7 @@ export type ValidationProps = {
orderType: Schema.OrderType;
orderTimeInForce: Schema.OrderTimeInForce;
fieldErrors?: FieldErrors<OrderSubmissionBody['orderSubmission']>;
estMargin: OrderMargin | null;
};
export const marketTranslations = (marketState: MarketState) => {
@ -46,6 +50,7 @@ export const useOrderValidation = ({
fieldErrors = {},
orderType,
orderTimeInForce,
estMargin,
}: ValidationProps): {
message: React.ReactNode | string;
isDisabled: boolean;
@ -53,6 +58,8 @@ export const useOrderValidation = ({
const { pubKey } = useVegaWallet();
const minSize = toDecimal(market.positionDecimalPlaces);
const isInvalidOrderMargin = useOrderMarginValidation({ market, estMargin });
const { message, isDisabled } = useMemo(() => {
if (!pubKey) {
return { message: t('No public key selected'), isDisabled: true };
@ -266,6 +273,13 @@ export const useOrderValidation = ({
};
}
if (isInvalidOrderMargin) {
return {
isDisabled: true,
message: <ValidateMargin {...isInvalidOrderMargin} />,
};
}
if (
[
MarketTradingMode.TRADING_MODE_BATCH_AUCTION,
@ -291,6 +305,7 @@ export const useOrderValidation = ({
fieldErrors?.price?.type,
orderType,
orderTimeInForce,
isInvalidOrderMargin,
]);
return { message, isDisabled };

View File

@ -0,0 +1,53 @@
import React from 'react';
import { formatNumber, t } from '@vegaprotocol/react-helpers';
import { Button, Dialog } from '@vegaprotocol/ui-toolkit';
import { useState } from 'react';
import { DepositContainer } from '@vegaprotocol/deposits';
interface Props {
margin: string;
symbol: string;
id: string;
balance: string;
decimals: number;
}
export const ValidateMargin = ({
margin,
symbol,
id,
balance,
decimals,
}: Props) => {
const [depositDialog, setDepositDialog] = useState(false);
return (
<>
<div
className="flex flex-col center pb-3"
data-testid="deal-ticket-margin-invalidated"
>
<p>
{t("You don't have enough margin available to open this position.")}
</p>
<p>
{`${formatNumber(margin, decimals)} ${symbol} ${t(
'currently required'
)}, ${formatNumber(balance, decimals)} ${symbol} ${t('available')}`}
</p>
<Button
className="center mt-2"
variant="default"
size="xs"
data-testid="deal-ticket-deposit-dialog-button"
onClick={() => setDepositDialog(true)}
>
{t('Deposit')} {symbol}
</Button>
</div>
<Dialog open={depositDialog} onChange={setDepositDialog}>
<h1 className="text-2xl mb-4">{t('Deposit')}</h1>
<DepositContainer assetId={id} />
</Dialog>
</>
);
};

View File

@ -52,11 +52,16 @@ export const DealTicket = ({
defaultValues: getDefaultOrder(market),
});
const order = watch();
const feeDetails = useFeeDealTicketDetails(order, market);
const details = getFeeDetailsValues(feeDetails);
const { message, isDisabled: disabled } = useOrderValidation({
market,
orderType: order.type,
orderTimeInForce: order.timeInForce,
fieldErrors: errors,
estMargin: feeDetails.estMargin,
});
const isDisabled = transactionStatus === 'pending' || disabled;
@ -101,9 +106,6 @@ export const DealTicket = ({
}
}, [marketPriceFormatted, order.type, setValue]);
const feeDetails = useFeeDealTicketDetails(order, market);
const details = getFeeDetailsValues(feeDetails);
return (
<form onSubmit={handleSubmit(onSubmit)} className="p-4" noValidate>
<Controller

View File

@ -1 +1,2 @@
import '@testing-library/jest-dom';
import 'jest-canvas-mock';

View File

@ -29,7 +29,7 @@ export function Dialog({
size = 'small',
}: DialogProps) {
const contentClasses = classNames(
'fixed top-0 left-0 z-20 flex justify-center items-start overflow-scroll',
'fixed top-0 left-0 z-20 flex justify-center items-start overflow-auto',
'w-full h-full'
);
const wrapperClasses = classNames(