From df6b9023f1a55f7ad0511ef2e1de12fbb262c240 Mon Sep 17 00:00:00 2001 From: Edd Date: Wed, 18 Jan 2023 19:13:39 +0000 Subject: [PATCH] feat(explorer): add liquidity provision submission and amend view (#2663) Co-authored-by: m.ray <16125548+MadalinaRaicu@users.noreply.github.com> --- .../amend-order-details.spec.tsx | 5 + .../order-summary/order-summary.spec.tsx | 5 + .../chain-events/lib/get-block-time.spec.ts | 4 +- .../chain-reponse.code.tsx | 2 +- .../Explorer-settlement-asset.graphql | 17 ++ .../Explorer-settlement-asset.ts | 60 +++++ .../liquidity-provision-details-row.spec.tsx | 131 +++++++++++ .../liquidity-provision-details-row.tsx | 77 +++++++ .../liquidity-provision-mid.spec.tsx | 22 ++ .../components/liquidity-provision-mid.tsx | 17 ++ .../liquidity-provision-offset.spec.tsx | 76 +++++++ .../components/liquidity-provision-offset.tsx | 61 +++++ .../liquidity-provision-details.spec.tsx | 209 ++++++++++++++++++ .../liquidity-provision-details.tsx | 94 ++++++++ .../txs/details/tx-details-wrapper.tsx | 9 +- .../txs/details/tx-liquidity-amend.tsx | 73 ++++++ .../txs/details/tx-liquidity-submission.tsx | 72 ++++++ .../components/txs/details/tx-lp-amend.tsx | 41 ---- .../components/txs/txs-infinite-list.spec.tsx | 3 +- 19 files changed, 931 insertions(+), 47 deletions(-) create mode 100644 apps/explorer/src/app/components/txs/details/liquidity-provision/Explorer-settlement-asset.graphql create mode 100644 apps/explorer/src/app/components/txs/details/liquidity-provision/__generated__/Explorer-settlement-asset.ts create mode 100644 apps/explorer/src/app/components/txs/details/liquidity-provision/components/liquidity-provision-details-row.spec.tsx create mode 100644 apps/explorer/src/app/components/txs/details/liquidity-provision/components/liquidity-provision-details-row.tsx create mode 100644 apps/explorer/src/app/components/txs/details/liquidity-provision/components/liquidity-provision-mid.spec.tsx create mode 100644 apps/explorer/src/app/components/txs/details/liquidity-provision/components/liquidity-provision-mid.tsx create mode 100644 apps/explorer/src/app/components/txs/details/liquidity-provision/components/liquidity-provision-offset.spec.tsx create mode 100644 apps/explorer/src/app/components/txs/details/liquidity-provision/components/liquidity-provision-offset.tsx create mode 100644 apps/explorer/src/app/components/txs/details/liquidity-provision/liquidity-provision-details.spec.tsx create mode 100644 apps/explorer/src/app/components/txs/details/liquidity-provision/liquidity-provision-details.tsx create mode 100644 apps/explorer/src/app/components/txs/details/tx-liquidity-amend.tsx create mode 100644 apps/explorer/src/app/components/txs/details/tx-liquidity-submission.tsx delete mode 100644 apps/explorer/src/app/components/txs/details/tx-lp-amend.tsx diff --git a/apps/explorer/src/app/components/order-details/amend-order-details.spec.tsx b/apps/explorer/src/app/components/order-details/amend-order-details.spec.tsx index cda2032dd..6f322719f 100644 --- a/apps/explorer/src/app/components/order-details/amend-order-details.spec.tsx +++ b/apps/explorer/src/app/components/order-details/amend-order-details.spec.tsx @@ -60,10 +60,15 @@ function renderExistingAmend(id: string, version: number, amend: Amend) { market: { __typename: 'Market', id: '789', + state: 'STATUS_ACTIVE', decimalPlaces: '5', tradableInstrument: { instrument: { name: 'test', + product: { + __typename: 'Future', + quoteName: '123', + }, }, }, }, diff --git a/apps/explorer/src/app/components/order-summary/order-summary.spec.tsx b/apps/explorer/src/app/components/order-summary/order-summary.spec.tsx index 7e6e182fd..1ae02e12c 100644 --- a/apps/explorer/src/app/components/order-summary/order-summary.spec.tsx +++ b/apps/explorer/src/app/components/order-summary/order-summary.spec.tsx @@ -37,10 +37,15 @@ const mock = { market: { __typename: 'Market', id: '789', + state: 'STATE_ACTIVE', decimalPlaces: 2, tradableInstrument: { instrument: { name: 'TEST', + product: { + __typename: 'Future', + quoteName: '123', + }, }, }, }, diff --git a/apps/explorer/src/app/components/txs/details/chain-events/lib/get-block-time.spec.ts b/apps/explorer/src/app/components/txs/details/chain-events/lib/get-block-time.spec.ts index d445223a2..db69320d2 100644 --- a/apps/explorer/src/app/components/txs/details/chain-events/lib/get-block-time.spec.ts +++ b/apps/explorer/src/app/components/txs/details/chain-events/lib/get-block-time.spec.ts @@ -9,6 +9,8 @@ describe('Lib: getBlockTime', () => { it('Returns a known date string', () => { const mockBlockTime = '1669223762'; const usRes = getBlockTime(mockBlockTime, 'en-US'); - expect(usRes).toEqual('11/23/2022, 5:16:02 PM'); + expect(usRes).toContain('11/23/2022'); + expect(usRes).toContain('5:16:02'); + expect(usRes).toContain('PM'); }); }); diff --git a/apps/explorer/src/app/components/txs/details/chain-response-code/chain-reponse.code.tsx b/apps/explorer/src/app/components/txs/details/chain-response-code/chain-reponse.code.tsx index cf49bc3b8..1ac4577c4 100644 --- a/apps/explorer/src/app/components/txs/details/chain-response-code/chain-reponse.code.tsx +++ b/apps/explorer/src/app/components/txs/details/chain-response-code/chain-reponse.code.tsx @@ -2,7 +2,7 @@ export const ErrorCodes = new Map([ [51, 'Transaction failed validation'], [60, 'Transaction could not be decoded'], - [70, 'Internal error'], + [70, 'Error'], [80, 'Unknown command'], [89, 'Rejected as spam'], [0, 'Success'], diff --git a/apps/explorer/src/app/components/txs/details/liquidity-provision/Explorer-settlement-asset.graphql b/apps/explorer/src/app/components/txs/details/liquidity-provision/Explorer-settlement-asset.graphql new file mode 100644 index 000000000..7535bacea --- /dev/null +++ b/apps/explorer/src/app/components/txs/details/liquidity-provision/Explorer-settlement-asset.graphql @@ -0,0 +1,17 @@ +query ExplorerSettlementAssetForMarket($id: ID!) { + market(id: $id) { + id + decimalPlaces + tradableInstrument { + instrument { + product { + ... on Future { + settlementAsset { + decimals + } + } + } + } + } + } +} diff --git a/apps/explorer/src/app/components/txs/details/liquidity-provision/__generated__/Explorer-settlement-asset.ts b/apps/explorer/src/app/components/txs/details/liquidity-provision/__generated__/Explorer-settlement-asset.ts new file mode 100644 index 000000000..32656cc4c --- /dev/null +++ b/apps/explorer/src/app/components/txs/details/liquidity-provision/__generated__/Explorer-settlement-asset.ts @@ -0,0 +1,60 @@ +import * as Types from '@vegaprotocol/types'; + +import { gql } from '@apollo/client'; +import * as Apollo from '@apollo/client'; +const defaultOptions = {} as const; +export type ExplorerSettlementAssetForMarketQueryVariables = Types.Exact<{ + id: Types.Scalars['ID']; +}>; + + +export type ExplorerSettlementAssetForMarketQuery = { __typename?: 'Query', market?: { __typename?: 'Market', id: string, decimalPlaces: number, tradableInstrument: { __typename?: 'TradableInstrument', instrument: { __typename?: 'Instrument', product: { __typename?: 'Future', settlementAsset: { __typename?: 'Asset', decimals: number } } } } } | null }; + + +export const ExplorerSettlementAssetForMarketDocument = gql` + query ExplorerSettlementAssetForMarket($id: ID!) { + market(id: $id) { + id + decimalPlaces + tradableInstrument { + instrument { + product { + ... on Future { + settlementAsset { + decimals + } + } + } + } + } + } +} + `; + +/** + * __useExplorerSettlementAssetForMarketQuery__ + * + * To run a query within a React component, call `useExplorerSettlementAssetForMarketQuery` and pass it any options that fit your needs. + * When your component renders, `useExplorerSettlementAssetForMarketQuery` returns an object from Apollo Client that contains loading, error, and data properties + * you can use to render your UI. + * + * @param baseOptions options that will be passed into the query, supported options are listed on: https://www.apollographql.com/docs/react/api/react-hooks/#options; + * + * @example + * const { data, loading, error } = useExplorerSettlementAssetForMarketQuery({ + * variables: { + * id: // value for 'id' + * }, + * }); + */ +export function useExplorerSettlementAssetForMarketQuery(baseOptions: Apollo.QueryHookOptions) { + const options = {...defaultOptions, ...baseOptions} + return Apollo.useQuery(ExplorerSettlementAssetForMarketDocument, options); + } +export function useExplorerSettlementAssetForMarketLazyQuery(baseOptions?: Apollo.LazyQueryHookOptions) { + const options = {...defaultOptions, ...baseOptions} + return Apollo.useLazyQuery(ExplorerSettlementAssetForMarketDocument, options); + } +export type ExplorerSettlementAssetForMarketQueryHookResult = ReturnType; +export type ExplorerSettlementAssetForMarketLazyQueryHookResult = ReturnType; +export type ExplorerSettlementAssetForMarketQueryResult = Apollo.QueryResult; \ No newline at end of file diff --git a/apps/explorer/src/app/components/txs/details/liquidity-provision/components/liquidity-provision-details-row.spec.tsx b/apps/explorer/src/app/components/txs/details/liquidity-provision/components/liquidity-provision-details-row.spec.tsx new file mode 100644 index 000000000..9e563488c --- /dev/null +++ b/apps/explorer/src/app/components/txs/details/liquidity-provision/components/liquidity-provision-details-row.spec.tsx @@ -0,0 +1,131 @@ +import { MockedProvider } from '@apollo/client/testing'; +import { render } from '@testing-library/react'; +import { Side } from '@vegaprotocol/types'; +import type { LiquidityOrder } from '@vegaprotocol/types'; +import { PeggedReference } from '@vegaprotocol/types'; +import { LiquidityProvisionDetailsRow } from './liquidity-provision-details-row'; +import type { VegaSide } from './liquidity-provision-details-row'; + +describe('LiquidityProvisionDetails component', () => { + function renderComponent( + order: LiquidityOrder, + side: VegaSide, + normaliseProportionsTo: number, + marketId: string + ) { + return render( + + + + + +
+
+ ); + } + + it('renders null for an order with no proportion', () => { + const mockOrder = { + offset: '1', + reference: PeggedReference.PEGGED_REFERENCE_MID, + }; + + const res = renderComponent( + mockOrder as LiquidityOrder, + Side.SIDE_BUY, + 100, + '123' + ); + expect(res.getByTestId('container')).toBeEmptyDOMElement(); + }); + + it('renders null for a null order', () => { + const res = renderComponent( + null as unknown as LiquidityOrder, + Side.SIDE_BUY, + 100, + '123' + ); + expect(res.getByTestId('container')).toBeEmptyDOMElement(); + }); + + it('renders a row when the order is as expected', () => { + const mockOrder = { + offset: '1', + proportion: 20, + reference: PeggedReference.PEGGED_REFERENCE_MID, + }; + + const res = renderComponent( + mockOrder as LiquidityOrder, + Side.SIDE_BUY, + 100, + '123' + ); + // Row test ids and keys are based on the side, reference and proportion + expect(res.getByTestId('SIDE_BUY-20-1')).toBeInTheDocument(); + expect(res.getByText('+1')).toBeInTheDocument(); + expect(res.getByText('Mid')).toBeInTheDocument(); + expect(res.getByText('20%')).toBeInTheDocument(); + }); + + it('normalises offsets when normaliseToProportion is not 100', () => { + const mockOrder = { + offset: '1', + proportion: 20, + reference: PeggedReference.PEGGED_REFERENCE_BEST_BID, + }; + + const res = renderComponent( + mockOrder as LiquidityOrder, + Side.SIDE_SELL, + 50, + '123' + ); + // Row test ids and keys are based on the side, reference and proportion - and that proportion is scaled + expect(res.getByTestId('SIDE_SELL-40-1')).toBeInTheDocument(); + expect(res.getByText('-1')).toBeInTheDocument(); + expect(res.getByText('Best Bid')).toBeInTheDocument(); + expect(res.getByText('40% (normalised from: 20%)')).toBeInTheDocument(); + }); + + it('handles a missing offset gracefully (should not happen)', () => { + const mockOrder = { + proportion: 20, + reference: PeggedReference.PEGGED_REFERENCE_BEST_BID, + }; + + const res = renderComponent( + mockOrder as LiquidityOrder, + Side.SIDE_SELL, + 50, + '123' + ); + // Row test ids and keys are based on the side, reference and proportion - and that proportion is scaled + expect(res.getByTestId('SIDE_SELL-40-')).toBeInTheDocument(); + expect(res.getByText('-')).toBeInTheDocument(); + }); + + it('handles a missing reference gracefully (should not happen)', () => { + const mockOrder = { + offset: '1', + proportion: 20, + }; + + const res = renderComponent( + mockOrder as LiquidityOrder, + Side.SIDE_SELL, + 50, + '123' + ); + // Row test ids and keys are based on the side, reference and proportion - and that proportion is scaled + expect(res.getByTestId('SIDE_SELL-40-1')).toBeInTheDocument(); + expect(res.getByText('40% (normalised from: 20%)')).toBeInTheDocument(); + expect(res.getByText('-')).toBeInTheDocument(); + }); +}); diff --git a/apps/explorer/src/app/components/txs/details/liquidity-provision/components/liquidity-provision-details-row.tsx b/apps/explorer/src/app/components/txs/details/liquidity-provision/components/liquidity-provision-details-row.tsx new file mode 100644 index 000000000..cb2c09e95 --- /dev/null +++ b/apps/explorer/src/app/components/txs/details/liquidity-provision/components/liquidity-provision-details-row.tsx @@ -0,0 +1,77 @@ +import { t } from '@vegaprotocol/react-helpers'; + +import type { components } from '../../../../../../types/explorer'; +import { TableRow } from '../../../../table'; +import { LiquidityProvisionOffset } from './liquidity-provision-offset'; + +export type VegaPeggedReference = components['schemas']['vegaPeggedReference']; +export type VegaSide = components['schemas']['vegaSide']; + +export type LiquidityProvisionOrder = + components['schemas']['vegaLiquidityOrder']; + +export const LiquidityReferenceLabel: Record = { + PEGGED_REFERENCE_BEST_ASK: t('Best Ask'), + PEGGED_REFERENCE_BEST_BID: t('Best Bid'), + PEGGED_REFERENCE_MID: t('Mid'), + PEGGED_REFERENCE_UNSPECIFIED: '-', +}; + +export type LiquidityProvisionDetailsRowProps = { + order?: LiquidityProvisionOrder; + marketId?: string; + side: VegaSide; + // If this is + normaliseProportionsTo: number; +}; + +/** + * + * Note: offset is formatted by settlement asset on the market, assuming that is available + * Note: Due to the mix of references (MID vs BEST_X), it's not possible to correctly order + * the orders by their actual distance from a midpoint. This would require us knowing + * the best bid (now or at placement) and the mid. Getting the data for *now* would be + * misleading for LP submissions in the past. There is no API for getting + * at the time of a transaction. + */ +export function LiquidityProvisionDetailsRow({ + normaliseProportionsTo, + order, + side, + marketId, +}: LiquidityProvisionDetailsRowProps) { + if (!order || !order.proportion) { + return null; + } + + const proportion = + normaliseProportionsTo === 100 + ? order.proportion + : Math.round((order.proportion / normaliseProportionsTo) * 100); + + const key = `${side}-${proportion}-${order.offset ? order.offset : ''}`; + + return ( + + + {order.offset && marketId ? ( + + ) : ( + '-' + )} + + + {order.reference ? LiquidityReferenceLabel[order.reference] : '-'} + + + {proportion === order.proportion + ? `${proportion}%` + : `${proportion}% (normalised from: ${order.proportion}%)`}{' '} + + + ); +} diff --git a/apps/explorer/src/app/components/txs/details/liquidity-provision/components/liquidity-provision-mid.spec.tsx b/apps/explorer/src/app/components/txs/details/liquidity-provision/components/liquidity-provision-mid.spec.tsx new file mode 100644 index 000000000..7979bf634 --- /dev/null +++ b/apps/explorer/src/app/components/txs/details/liquidity-provision/components/liquidity-provision-mid.spec.tsx @@ -0,0 +1,22 @@ +import { render } from '@testing-library/react'; +import { LiquidityProvisionMid } from './liquidity-provision-mid'; + +describe('LiquidityProvisionMid component', () => { + function renderComponent() { + return render( + + + + +
+ ); + } + + it('renders a basic row that spans the whole table', () => { + const res = renderComponent(); + const display = res.getByTestId('mid-display'); + expect(res.getByTestId('mid')).toBeInTheDocument(); + expect(display).toBeInTheDocument(); + expect(display).toHaveAttribute('colspan', '3'); + }); +}); diff --git a/apps/explorer/src/app/components/txs/details/liquidity-provision/components/liquidity-provision-mid.tsx b/apps/explorer/src/app/components/txs/details/liquidity-provision/components/liquidity-provision-mid.tsx new file mode 100644 index 000000000..91aa4d8c5 --- /dev/null +++ b/apps/explorer/src/app/components/txs/details/liquidity-provision/components/liquidity-provision-mid.tsx @@ -0,0 +1,17 @@ +import { TableRow } from '../../../../table'; + +/** + * In a LiquidityProvision table, this row is the midpoint. Above our LP orders on the + * buy side, below are LP orders on the sell side. This component simply divides them. + * + * There is no API that can give us the mid price when the order was created, and even + * if there was it isn't clear that would be appropriate for this centre row. So instead + * it's a simple divider. + */ +export function LiquidityProvisionMid() { + return ( + + + + ); +} diff --git a/apps/explorer/src/app/components/txs/details/liquidity-provision/components/liquidity-provision-offset.spec.tsx b/apps/explorer/src/app/components/txs/details/liquidity-provision/components/liquidity-provision-offset.spec.tsx new file mode 100644 index 000000000..efef69f99 --- /dev/null +++ b/apps/explorer/src/app/components/txs/details/liquidity-provision/components/liquidity-provision-offset.spec.tsx @@ -0,0 +1,76 @@ +import { MockedProvider } from '@apollo/client/testing'; +import type { MockedResponse } from '@apollo/client/testing'; +import { render } from '@testing-library/react'; +import { ExplorerSettlementAssetForMarketDocument } from '../__generated__/Explorer-settlement-asset'; +import type { ExplorerSettlementAssetForMarketQuery } from '../__generated__/Explorer-settlement-asset'; +import type { VegaSide } from './liquidity-provision-details-row'; +import { + getFormattedOffset, + LiquidityProvisionOffset, +} from './liquidity-provision-offset'; +const decimalsMock: ExplorerSettlementAssetForMarketQuery = { + market: { + id: '123', + __typename: 'Market', + decimalPlaces: 5, + tradableInstrument: { + instrument: { + product: { + settlementAsset: { + decimals: 5, + }, + }, + }, + }, + }, +}; + +describe('LiquidityProvisionOffset component', () => { + function renderComponent( + offset: string, + side: VegaSide, + marketId: string, + mocks: MockedResponse[] + ) { + return render( + + + + ); + } + + it('renders a simple row before market data comes in', () => { + const res = renderComponent('1', 'SIDE_BUY', '123', []); + expect(res.getByText('+1')).toBeInTheDocument(); + }); + + it('replaces unformatted with formatted if the market data comes in', () => { + const mock = { + request: { + query: ExplorerSettlementAssetForMarketDocument, + variables: { + id: '123', + }, + result: { + data: decimalsMock, + }, + }, + }; + const res = renderComponent('1', 'SIDE_BUY', '123', [mock]); + expect(res.getByText('+1')).toBeInTheDocument(); + }); + + it('getFormattedOffset returns the unformatted offset if there is not enough data', () => { + const res = getFormattedOffset('1', {}); + expect(res).toEqual('1'); + }); + + it('getFormattedOffset decimal formats a number if it comes in with market data', () => { + const res = getFormattedOffset('1', decimalsMock); + expect(res).toEqual('0.00001'); + }); +}); diff --git a/apps/explorer/src/app/components/txs/details/liquidity-provision/components/liquidity-provision-offset.tsx b/apps/explorer/src/app/components/txs/details/liquidity-provision/components/liquidity-provision-offset.tsx new file mode 100644 index 000000000..ce0b2b9c6 --- /dev/null +++ b/apps/explorer/src/app/components/txs/details/liquidity-provision/components/liquidity-provision-offset.tsx @@ -0,0 +1,61 @@ +import { useExplorerSettlementAssetForMarketQuery } from '../__generated__/Explorer-settlement-asset'; +import { addDecimalsFormatNumber } from '@vegaprotocol/react-helpers'; +import type { ExplorerSettlementAssetForMarketQuery } from '../__generated__/Explorer-settlement-asset'; +import type { VegaSide } from './liquidity-provision-details-row'; + +export type LiquidityProvisionOffsetProps = { + side: VegaSide; + offset: string; + marketId: string; +}; + +/** + * Correctly formats an LP's offset according to the market settlement decimal places. + * Initially this will appear unformatted, then when the query loads in the proper formatted + * value will be displayed + * + * @see getFormattedOffset + */ +export function LiquidityProvisionOffset({ + side, + offset, + marketId, +}: LiquidityProvisionOffsetProps) { + const { data } = useExplorerSettlementAssetForMarketQuery({ + variables: { + id: marketId, + }, + }); + + // getFormattedOffset handles missing results/loading states + const formattedOffset = getFormattedOffset(offset, data); + + const label = side === 'SIDE_BUY' ? '+' : '-'; + const className = side === 'SIDE_BUY' ? 'text-vega-green' : 'text-vega-pink'; + return {`${label}${formattedOffset}`}; +} + +/** + * Does the work of formatting the number now we have the settlement decimal places. + * If no market data is assigned (i.e. during loading, or if the market doesn't exist) + * this function will return the unformatted number + * + * @see LiquidityProvisionOffset + * @param data the result of a ExplorerSettlementAssetForMarketQuery + * @param offset the unformatted offset + * @returns string the offset of this lp order formatted with the settlement decimal places + */ +export function getFormattedOffset( + offset: string, + data?: ExplorerSettlementAssetForMarketQuery +) { + const decimals = + data?.market?.tradableInstrument.instrument.product.settlementAsset + .decimals; + + if (!decimals) { + return offset; + } + + return addDecimalsFormatNumber(offset, decimals); +} diff --git a/apps/explorer/src/app/components/txs/details/liquidity-provision/liquidity-provision-details.spec.tsx b/apps/explorer/src/app/components/txs/details/liquidity-provision/liquidity-provision-details.spec.tsx new file mode 100644 index 000000000..65c461e2a --- /dev/null +++ b/apps/explorer/src/app/components/txs/details/liquidity-provision/liquidity-provision-details.spec.tsx @@ -0,0 +1,209 @@ +import { MockedProvider } from '@apollo/client/testing'; +import { render } from '@testing-library/react'; +import type { LiquidityOrder } from '@vegaprotocol/types'; +import { PeggedReference } from '@vegaprotocol/types'; +import type { LiquiditySubmission } from '../tx-liquidity-submission'; +import { + LiquidityProvisionDetails, + sumProportions, +} from './liquidity-provision-details'; + +function mockProportion(proportion: number): LiquidityOrder { + return { + proportion, + reference: PeggedReference.PEGGED_REFERENCE_MID, + offset: '1', + }; +} +describe('sumProportions function', () => { + it('returns 0 if the side is undefined', () => { + const side: LiquidityOrder[] = undefined as unknown as LiquidityOrder[]; + const res = sumProportions(side); + + expect(res).toEqual(0); + }); + + it('returns 0 if the side is empty', () => { + const side: LiquidityOrder[] = []; + const res = sumProportions(side); + + expect(res).toEqual(0); + }); + + it('sums 1 item correctly (under 100%)', () => { + const side: LiquidityOrder[] = [mockProportion(10)]; + const res = sumProportions(side); + + expect(res).toEqual(10); + }); + + it('sums 2 item correctly (exactly 100%)', () => { + const side: LiquidityOrder[] = [mockProportion(50), mockProportion(50)]; + const res = sumProportions(side); + + expect(res).toEqual(100); + }); + + it('sums 3 item correctly to over 100%', () => { + const side: LiquidityOrder[] = [ + mockProportion(20), + mockProportion(40), + mockProportion(50), + ]; + const res = sumProportions(side); + + expect(res).toEqual(110); + }); +}); + +describe('LiquidityProvisionDetails component', () => { + function renderComponent(provision: LiquiditySubmission) { + return render( + + + + ); + } + it('handles an LP with no buys or sells by returning empty (should never happen)', () => { + const mock: LiquiditySubmission = {}; + + const res = renderComponent(mock); + expect(res.container).toBeEmptyDOMElement(); + }); + + it('handles an LP with no sells by just rendering buys', () => { + const mock: LiquiditySubmission = { + marketId: '123', + buys: [ + { + offset: '1', + proportion: 50, + reference: PeggedReference.PEGGED_REFERENCE_MID, + }, + { + offset: '2', + proportion: 50, + reference: PeggedReference.PEGGED_REFERENCE_MID, + }, + ], + }; + + const res = renderComponent(mock); + expect(res.getByText('Price offset')).toBeInTheDocument(); + expect(res.getByText('Price reference')).toBeInTheDocument(); + expect(res.getByText('Proportion')).toBeInTheDocument(); + expect(res.getByTestId('SIDE_BUY-50-1')).toBeInTheDocument(); + expect(res.getByTestId('SIDE_BUY-50-2')).toBeInTheDocument(); + }); + + it('handles an LP with no buys by just rendering sells', () => { + const mock: LiquiditySubmission = { + marketId: '123', + sells: [ + { + offset: '1', + proportion: 50, + reference: PeggedReference.PEGGED_REFERENCE_MID, + }, + { + offset: '2', + proportion: 50, + reference: PeggedReference.PEGGED_REFERENCE_MID, + }, + ], + }; + + const res = renderComponent(mock); + expect(res.getByText('Price offset')).toBeInTheDocument(); + expect(res.getByText('Price reference')).toBeInTheDocument(); + expect(res.getByText('Proportion')).toBeInTheDocument(); + expect(res.getByTestId('SIDE_SELL-50-1')).toBeInTheDocument(); + expect(res.getByTestId('SIDE_SELL-50-2')).toBeInTheDocument(); + }); + + it('handles an LP with sells by just rendering buys', () => { + const mock: LiquiditySubmission = { + marketId: '123', + buys: [ + { + offset: '1', + proportion: 50, + reference: PeggedReference.PEGGED_REFERENCE_MID, + }, + { + offset: '2', + proportion: 50, + reference: PeggedReference.PEGGED_REFERENCE_MID, + }, + ], + }; + + const res = renderComponent(mock); + expect(res.getByText('Price offset')).toBeInTheDocument(); + expect(res.getByText('Price reference')).toBeInTheDocument(); + expect(res.getByText('Proportion')).toBeInTheDocument(); + expect(res.getByTestId('SIDE_BUY-50-1')).toBeInTheDocument(); + expect(res.getByTestId('SIDE_BUY-50-2')).toBeInTheDocument(); + }); + + it('handles an LP with both sides', () => { + const mock: LiquiditySubmission = { + marketId: '123', + buys: [ + { + offset: '1', + proportion: 50, + reference: PeggedReference.PEGGED_REFERENCE_MID, + }, + { + offset: '2', + proportion: 50, + reference: PeggedReference.PEGGED_REFERENCE_MID, + }, + ], + sells: [ + { + offset: '4', + proportion: 50, + reference: PeggedReference.PEGGED_REFERENCE_MID, + }, + { + offset: '2', + proportion: 50, + reference: PeggedReference.PEGGED_REFERENCE_MID, + }, + ], + }; + + const res = renderComponent(mock); + expect(res.getByText('Price offset')).toBeInTheDocument(); + expect(res.getByText('Price reference')).toBeInTheDocument(); + expect(res.getByText('Proportion')).toBeInTheDocument(); + expect(res.getByTestId('SIDE_BUY-50-1')).toBeInTheDocument(); + expect(res.getByTestId('SIDE_BUY-50-2')).toBeInTheDocument(); + expect(res.getByTestId('SIDE_SELL-50-4')).toBeInTheDocument(); + expect(res.getByTestId('SIDE_SELL-50-2')).toBeInTheDocument(); + }); + + it('normalises proportions when they do not total 100%', () => { + const mock: LiquiditySubmission = { + marketId: '123', + buys: [ + { + offset: '1', + proportion: 25, + reference: PeggedReference.PEGGED_REFERENCE_MID, + }, + { + offset: '2', + proportion: 30, + reference: PeggedReference.PEGGED_REFERENCE_MID, + }, + ], + }; + + const res = renderComponent(mock); + expect(res.getByText('45% (normalised from: 25%)')).toBeInTheDocument(); + expect(res.getByText('55% (normalised from: 30%)')).toBeInTheDocument(); + }); +}); diff --git a/apps/explorer/src/app/components/txs/details/liquidity-provision/liquidity-provision-details.tsx b/apps/explorer/src/app/components/txs/details/liquidity-provision/liquidity-provision-details.tsx new file mode 100644 index 000000000..e9c9c4ff3 --- /dev/null +++ b/apps/explorer/src/app/components/txs/details/liquidity-provision/liquidity-provision-details.tsx @@ -0,0 +1,94 @@ +import { t } from '@vegaprotocol/react-helpers'; + +import type { components } from '../../../../../types/explorer'; +import type { LiquiditySubmission } from '../tx-liquidity-submission'; +import { TableRow } from '../../../table'; +import { LiquidityProvisionMid } from './components/liquidity-provision-mid'; +import { LiquidityProvisionDetailsRow } from './components/liquidity-provision-details-row'; +import { Side } from '@vegaprotocol/types'; + +export type VegaPeggedReference = components['schemas']['vegaPeggedReference']; + +export type LiquidityProvisionOrder = + components['schemas']['vegaLiquidityOrder']; + +export const LiquidityReferenceLabel: Record = { + PEGGED_REFERENCE_BEST_ASK: t('Best Ask'), + PEGGED_REFERENCE_BEST_BID: t('Best Bid'), + PEGGED_REFERENCE_MID: t('Mid'), + PEGGED_REFERENCE_UNSPECIFIED: '-', +}; + +/** + * Given a side of a liquidity provision order, returns the total + * It should be 100%, but it isn't always and if it isn't the proportion + * reported for each order should be scaled + * + * @returns number + */ +export function sumProportions( + side: LiquiditySubmission['buys'] | LiquiditySubmission['sells'] +): number { + if (!side || side.length === 0) { + return 0; + } + + return side.reduce((total, o) => total + (o.proportion || 0), 0); +} + +export type LiquidityProvisionDetailsProps = { + provision: LiquiditySubmission; +}; + +/** + * Renders a table displaying all buys and sells in this LP. It is valid for there + * to be no buys or sells. + * + * It might seem logical to turn proportions in to values based on the total commitment + * but based on the current API structure it is awkward, and given that non-LP orders + * will change the amount that is actually deployed vs assigned to a level, we decided + * not to bother going down that route. + */ +export function LiquidityProvisionDetails({ + provision, +}: LiquidityProvisionDetailsProps) { + if (!provision.buys?.length && !provision.sells?.length) { + return null; + } + // We need to do some additional calcs if these aren't both 100 + const buyTotal = sumProportions(provision.buys); + const sellTotal = sumProportions(provision.sells); + + return ( + + + + + + + + + + {provision.buys?.map((b, i) => ( + + ))} + + {provision.sells?.map((s, i) => ( + + ))} + +
{t('Price offset')}{t('Price reference')}{t('Proportion')}
+ ); +} diff --git a/apps/explorer/src/app/components/txs/details/tx-details-wrapper.tsx b/apps/explorer/src/app/components/txs/details/tx-details-wrapper.tsx index 382f1b4ea..dcd682161 100644 --- a/apps/explorer/src/app/components/txs/details/tx-details-wrapper.tsx +++ b/apps/explorer/src/app/components/txs/details/tx-details-wrapper.tsx @@ -5,7 +5,6 @@ import { TxDetailsOrder } from './tx-order'; import type { BlockExplorerTransactionResult } from '../../../routes/types/block-explorer-response'; import type { TendermintBlocksResponse } from '../../../routes/blocks/tendermint-blocks-response'; import { TxDetailsHeartbeat } from './tx-hearbeat'; -import { TxDetailsLPAmend } from './tx-lp-amend'; import { TxDetailsGeneric } from './tx-generic'; import { TxDetailsBatch } from './tx-batch'; import { TxDetailsChainEvent } from './tx-chain-event'; @@ -17,6 +16,8 @@ import { TxDetailsOrderAmend } from './tx-order-amend'; import { TxDetailsWithdrawSubmission } from './tx-withdraw-submission'; import { TxDetailsDelegate } from './tx-delegation'; import { TxDetailsUndelegate } from './tx-undelegation'; +import { TxDetailsLiquiditySubmission } from './tx-liquidity-submission'; +import { TxDetailsLiquidityAmendment } from './tx-liquidity-amend'; interface TxDetailsWrapperProps { txData: BlockExplorerTransactionResult | undefined; @@ -83,8 +84,6 @@ function getTransactionComponent(txData?: BlockExplorerTransactionResult) { return TxDetailsOrderAmend; case 'Validator Heartbeat': return TxDetailsHeartbeat; - case 'Amend LiquidityProvision Order': - return TxDetailsLPAmend; case 'Batch Market Instructions': return TxDetailsBatch; case 'Chain Event': @@ -93,6 +92,10 @@ function getTransactionComponent(txData?: BlockExplorerTransactionResult) { return TxDetailsNodeVote; case 'Withdraw': return TxDetailsWithdrawSubmission; + case 'Liquidity Provision Order': + return TxDetailsLiquiditySubmission; + case 'Amend Liquidity Provision Order': + return TxDetailsLiquidityAmendment; case 'Delegate': return TxDetailsDelegate; case 'Undelegate': diff --git a/apps/explorer/src/app/components/txs/details/tx-liquidity-amend.tsx b/apps/explorer/src/app/components/txs/details/tx-liquidity-amend.tsx new file mode 100644 index 000000000..b19666926 --- /dev/null +++ b/apps/explorer/src/app/components/txs/details/tx-liquidity-amend.tsx @@ -0,0 +1,73 @@ +import { t } from '@vegaprotocol/react-helpers'; +import type { BlockExplorerTransactionResult } from '../../../routes/types/block-explorer-response'; +import { MarketLink } from '../../links'; +import type { TendermintBlocksResponse } from '../../../routes/blocks/tendermint-blocks-response'; +import { TxDetailsShared } from './shared/tx-details-shared'; +import { TableCell, TableRow, TableWithTbody } from '../../table'; +import type { components } from '../../../../types/explorer'; +import { LiquidityProvisionDetails } from './liquidity-provision/liquidity-provision-details'; +import PriceInMarket from '../../price-in-market/price-in-market'; + +export type LiquidityAmendment = + components['schemas']['v1LiquidityProvisionAmendment']; + +interface TxDetailsLiquidityAmendmentProps { + txData: BlockExplorerTransactionResult | undefined; + pubKey: string | undefined; + blockData: TendermintBlocksResponse | undefined; +} + +/** + * An existing liquidity order is being amended. This uses + * exactly the same details as the creation + */ +export const TxDetailsLiquidityAmendment = ({ + txData, + pubKey, + blockData, +}: TxDetailsLiquidityAmendmentProps) => { + if (!txData || !txData.command.liquidityProvisionAmendment) { + return <>{t('Awaiting Block Explorer transaction details')}; + } + + const amendment: LiquidityAmendment = + txData.command.liquidityProvisionAmendment; + const marketId: string = amendment.marketId || '-'; + + return ( + <> + + + + {t('Market')} + + + + + {amendment.commitmentAmount ? ( + + {t('Commitment amount')} + + + + + ) : null} + {amendment.fee ? ( + + {t('Fee')} + {amendment.fee}% + + ) : null} + + + + + ); +}; diff --git a/apps/explorer/src/app/components/txs/details/tx-liquidity-submission.tsx b/apps/explorer/src/app/components/txs/details/tx-liquidity-submission.tsx new file mode 100644 index 000000000..55e8798f8 --- /dev/null +++ b/apps/explorer/src/app/components/txs/details/tx-liquidity-submission.tsx @@ -0,0 +1,72 @@ +import { t } from '@vegaprotocol/react-helpers'; +import type { BlockExplorerTransactionResult } from '../../../routes/types/block-explorer-response'; +import { MarketLink } from '../../links/'; +import type { TendermintBlocksResponse } from '../../../routes/blocks/tendermint-blocks-response'; +import { TxDetailsShared } from './shared/tx-details-shared'; +import { TableCell, TableRow, TableWithTbody } from '../../table'; +import type { components } from '../../../../types/explorer'; +import { LiquidityProvisionDetails } from './liquidity-provision/liquidity-provision-details'; +import PriceInMarket from '../../price-in-market/price-in-market'; + +export type LiquiditySubmission = + components['schemas']['v1LiquidityProvisionSubmission']; + +interface TxDetailsLiquiditySubmissionProps { + txData: BlockExplorerTransactionResult | undefined; + pubKey: string | undefined; + blockData: TendermintBlocksResponse | undefined; +} + +/** + * Someone cancelled an order + */ +export const TxDetailsLiquiditySubmission = ({ + txData, + pubKey, + blockData, +}: TxDetailsLiquiditySubmissionProps) => { + if (!txData || !txData.command.liquidityProvisionSubmission) { + return <>{t('Awaiting Block Explorer transaction details')}; + } + + const submission: LiquiditySubmission = + txData.command.liquidityProvisionSubmission; + const marketId: string = submission.marketId || '-'; + + return ( + <> + + + + {t('Market')} + + + + + {submission.commitmentAmount ? ( + + {t('Commitment amount')} + + + + + ) : null} + {submission.fee ? ( + + {t('Fee')} + {submission.fee}% + + ) : null} + + + + + ); +}; diff --git a/apps/explorer/src/app/components/txs/details/tx-lp-amend.tsx b/apps/explorer/src/app/components/txs/details/tx-lp-amend.tsx deleted file mode 100644 index 8a79abcf9..000000000 --- a/apps/explorer/src/app/components/txs/details/tx-lp-amend.tsx +++ /dev/null @@ -1,41 +0,0 @@ -import { t } from '@vegaprotocol/react-helpers'; -import type { BlockExplorerTransactionResult } from '../../../routes/types/block-explorer-response'; -import { MarketLink } from '../../links/'; -import type { TendermintBlocksResponse } from '../../../routes/blocks/tendermint-blocks-response'; -import { TxDetailsShared } from './shared/tx-details-shared'; -import { TableCell, TableRow, TableWithTbody } from '../../table'; - -interface TxDetailsOrderProps { - txData: BlockExplorerTransactionResult | undefined; - pubKey: string | undefined; - blockData: TendermintBlocksResponse | undefined; -} - -/** - * Specifies changes to the shape of a users Liquidity Commitment order for - * a specific market. So far this only displays the market, which is only - * because it's very easy to do so. - */ -export const TxDetailsLPAmend = ({ - txData, - pubKey, - blockData, -}: TxDetailsOrderProps) => { - if (!txData) { - return <>{t('Awaiting Block Explorer transaction details')}; - } - - const marketId = txData.command.liquidityProvisionAmendment?.marketId || ''; - - return ( - - - - {t('Market')} - - - - - - ); -}; diff --git a/apps/explorer/src/app/components/txs/txs-infinite-list.spec.tsx b/apps/explorer/src/app/components/txs/txs-infinite-list.spec.tsx index 723ef5274..175720b1e 100644 --- a/apps/explorer/src/app/components/txs/txs-infinite-list.spec.tsx +++ b/apps/explorer/src/app/components/txs/txs-infinite-list.spec.tsx @@ -2,6 +2,7 @@ import { TxsInfiniteList } from './txs-infinite-list'; import { render, screen, fireEvent, act } from '@testing-library/react'; import { MemoryRouter } from 'react-router-dom'; import type { BlockExplorerTransactionResult } from '../../routes/types/block-explorer-response'; +import { Side } from '@vegaprotocol/types'; const generateTxs = (number: number): BlockExplorerTransactionResult[] => { return Array.from(Array(number)).map((_) => ({ @@ -24,7 +25,7 @@ const generateTxs = (number: number): BlockExplorerTransactionResult[] => { 'b4d0a070f5cc73a7d53b23d6f63f8cb52e937ed65d2469a3af4cc1e80e155fcf', price: '14525946', size: '54', - side: 'SIDE_SELL', + side: Side.SIDE_SELL, timeInForce: 'TIME_IN_FORCE_GTT', expiresAt: '1664966445481288736', type: 'TYPE_LIMIT',