diff --git a/apps/console-lite-e2e/src/support/mocks/generate-party-market-data.ts b/apps/console-lite-e2e/src/support/mocks/generate-party-market-data.ts index 1a7bff715..71f7862a9 100644 --- a/apps/console-lite-e2e/src/support/mocks/generate-party-market-data.ts +++ b/apps/console-lite-e2e/src/support/mocks/generate-party-market-data.ts @@ -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' }, diff --git a/apps/console-lite/src/app/components/deal-ticket/deal-ticket-steps.tsx b/apps/console-lite/src/app/components/deal-ticket/deal-ticket-steps.tsx index e0b874834..78ecf2253 100644 --- a/apps/console-lite/src/app/components/deal-ticket/deal-ticket-steps.tsx +++ b/apps/console-lite/src/app/components/deal-ticket/deal-ticket-steps.tsx @@ -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 || '' }, diff --git a/apps/trading-e2e/src/integration/trading-deal-ticket.cy.ts b/apps/trading-e2e/src/integration/trading-deal-ticket.cy.ts index ec000f713..48a09de48 100644 --- a/apps/trading-e2e/src/integration/trading-deal-ticket.cy.ts +++ b/apps/trading-e2e/src/integration/trading-deal-ticket.cy.ts @@ -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'); + }); +}); diff --git a/apps/trading-e2e/src/support/mocks/generate-fees.ts b/apps/trading-e2e/src/support/mocks/generate-fees.ts new file mode 100644 index 000000000..6f68e6dd1 --- /dev/null +++ b/apps/trading-e2e/src/support/mocks/generate-fees.ts @@ -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 +) => { + 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', + }, + }; +}; diff --git a/apps/trading-e2e/src/support/trading.ts b/apps/trading-e2e/src/support/trading.ts index 8fe700357..cc69b1e42 100644 --- a/apps/trading-e2e/src/support/trading.ts +++ b/apps/trading-e2e/src/support/trading.ts @@ -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 { diff --git a/apps/trading/lib/apollo-client.ts b/apps/trading/lib/apollo-client.ts index 8d9b67ce5..4e3a1a3d0 100644 --- a/apps/trading/lib/apollo-client.ts +++ b/apps/trading/lib/apollo-client.ts @@ -60,6 +60,12 @@ export function createClient(base?: string) { AccountUpdate: { keyFields: false, }, + Party: { + keyFields: false, + }, + Fees: { + keyFields: false, + }, }, }); diff --git a/apps/trading/pages/_app.page.tsx b/apps/trading/pages/_app.page.tsx index 0a8dc0915..c0f0f1952 100644 --- a/apps/trading/pages/_app.page.tsx +++ b/apps/trading/pages/_app.page.tsx @@ -41,6 +41,9 @@ function AppBody({ Component, pageProps }: AppProps) { const [theme, toggleTheme] = useThemeSwitcher(); return ( + + + <div className="h-full relative dark:bg-black dark:text-white z-0 grid grid-rows-[min-content,1fr,min-content]"> <AppLoader> diff --git a/libs/deal-ticket/src/components/deal-ticket-validation/use-order-margin-validation.tsx b/libs/deal-ticket/src/components/deal-ticket-validation/use-order-margin-validation.tsx new file mode 100644 index 000000000..d51b9bfc5 --- /dev/null +++ b/libs/deal-ticket/src/components/deal-ticket-validation/use-order-margin-validation.tsx @@ -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; +}; diff --git a/libs/deal-ticket/src/components/deal-ticket-validation/use-order-validation.spec.tsx b/libs/deal-ticket/src/components/deal-ticket-validation/use-order-validation.spec.tsx index e7b5efb08..ae0d1236f 100644 --- a/libs/deal-ticket/src/components/deal-ticket-validation/use-order-validation.spec.tsx +++ b/libs/deal-ticket/src/components/deal-ticket-validation/use-order-validation.spec.tsx @@ -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 + ); + }); }); diff --git a/libs/deal-ticket/src/components/deal-ticket-validation/use-order-validation.tsx b/libs/deal-ticket/src/components/deal-ticket-validation/use-order-validation.tsx index a84e2fbec..5fa59fa59 100644 --- a/libs/deal-ticket/src/components/deal-ticket-validation/use-order-validation.tsx +++ b/libs/deal-ticket/src/components/deal-ticket-validation/use-order-validation.tsx @@ -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 }; diff --git a/libs/deal-ticket/src/components/deal-ticket-validation/validate-margin.tsx b/libs/deal-ticket/src/components/deal-ticket-validation/validate-margin.tsx new file mode 100644 index 000000000..a6d800d94 --- /dev/null +++ b/libs/deal-ticket/src/components/deal-ticket-validation/validate-margin.tsx @@ -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> + </> + ); +}; diff --git a/libs/deal-ticket/src/components/deal-ticket/deal-ticket.tsx b/libs/deal-ticket/src/components/deal-ticket/deal-ticket.tsx index 66f78a3c8..8b5afaa5a 100644 --- a/libs/deal-ticket/src/components/deal-ticket/deal-ticket.tsx +++ b/libs/deal-ticket/src/components/deal-ticket/deal-ticket.tsx @@ -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 diff --git a/libs/deal-ticket/src/setup-tests.ts b/libs/deal-ticket/src/setup-tests.ts index 7b0828bfa..068c53d36 100644 --- a/libs/deal-ticket/src/setup-tests.ts +++ b/libs/deal-ticket/src/setup-tests.ts @@ -1 +1,2 @@ import '@testing-library/jest-dom'; +import 'jest-canvas-mock'; diff --git a/libs/ui-toolkit/src/components/dialog/dialog.tsx b/libs/ui-toolkit/src/components/dialog/dialog.tsx index bf52a8e27..4288f55e8 100644 --- a/libs/ui-toolkit/src/components/dialog/dialog.tsx +++ b/libs/ui-toolkit/src/components/dialog/dialog.tsx @@ -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(