Compare commits

..
Author SHA1 Message Date
m.ray 79a16b8562 fix(trading): show deal ticket in sidebar by default (#5292) 2023-11-16 09:08:20 -08:00
Matthew Russell 5661b74082 fix(trading): do not use stakeToCcyVolume to format fees value (#5280) 2023-11-16 09:34:38 +00:00
m.ray 22703d937e fix(trading): fix percentage formatter rounding in liquidity table (#5250) 2023-11-13 16:14:18 +00:00
Matthew RussellandMadalina Raicu f780013846 feat(accounts): transfer form improvements to handle transfers from vesting accounts (#5212)
Co-authored-by: Madalina Raicu <madalina@raygroup.uk>
2023-11-08 23:52:30 -08:00
Ben 73f37c2477 chore(ci): fix prepare dist (#5214) 2023-11-08 09:43:36 +00:00
Art 607ad06971 fix(environment): missing block height in node switcher (#5210) 2023-11-08 09:47:23 +01:00
Matthew Russell 7aee2a3a7b chore(candles-chart): update pennant package so chart draws at 60 fps (#5213) 2023-11-07 21:49:09 -08:00
Bartłomiej Głownia 1fdf519780 feat(trading): fee discounts tweaks (#5205) 2023-11-07 16:15:54 +01:00
ArtandMatthew Russell 9233778e85 feat(governance,accounts): vesting and vested accound in wallet card, fix transfer form to account (#5206)
Co-authored-by: Matthew Russell <mattrussell36@gmail.com>
2023-11-06 12:42:42 -08:00
m.ray 7a91f48bcb fix(trading): update LP table on Updating next epoch (#5204) 2023-11-06 15:47:08 +01:00
Edd b43ea0f60b feat(explorer): update liquidity submission view (#5187) 2023-11-03 15:52:27 +00:00
Matthew Russell 6e31fb03ae fix(accounts): fix key selection and validation, and add use max to transfers (#5184) 2023-11-03 15:12:40 +00:00
Matthew Russell d76fa13c5d chore(trading): fees page use window length in label (#5186) 2023-11-03 15:11:51 +00:00
Edd 89aeed6305 feat(explorer): new labels for proposals (#5190) 2023-11-03 14:58:56 +00:00
Edd 6e677084a3 feat(explorer): explorer oracle section rework (#5135) 2023-11-03 14:01:33 +00:00
m.ray 8069aa5ee7 fix(trading): hide funding tabs if market is not perp (#5181) 2023-11-03 10:49:31 +00:00
Matthew Russell b0169a0d39 fix(accounts): dont add vesting account balances to used total (#5182) 2023-11-02 16:56:56 -07:00
Edd 06cfd79415 feat(explorer): add timestamp to tx list (#5174) 2023-11-02 13:22:37 +00:00
Edd 443220283c fix(explorer): format new network params correctly (#5172) 2023-11-02 13:17:11 +00:00
Matthew Russell a642bf8ce4 chore(trading): misc changes to trades and transfer (#5175) 2023-11-01 15:57:38 -07:00
Matthew Russell 20233db706 fix(deal-ticket): fees breakdown rendering issues (#5173) 2023-11-01 13:32:51 -07:00
m.ray d4f50eb70c fix(trading): pagination limit for candles (#5169) 2023-11-01 12:21:36 -07:00
Matthew Russell e5d3f90d45 feat(trading): transfer to and from accounts on same key (#5161) 2023-11-01 11:11:17 +00:00
Bartłomiej Głownia 673c896e2f feat(deal-ticket): get discounts for estimation from previous epoch (#5163) 2023-11-01 10:09:56 +00:00
Matthew Russell deea63fa5e fix(trading): empty items loaded text (#5158) 2023-11-01 10:08:16 +00:00
Matthew Russell d31333538b chore(trading): ignore .next from lint (#5162) 2023-11-01 10:06:53 +00:00
Matthew Russell 4af9979a21 chore(trading): referrals stats tweaks (#5160) 2023-11-01 10:06:36 +00:00
m.ray 6421cf87c6 feat(trading): liquidity provision current and pending on update (#5152) 2023-10-31 11:24:18 +00:00
Matthew Russell 0ebfab64ff fix(ui-toolkit): sparkline appearing to go up when the price moved down (#5137) 2023-10-31 11:20:48 +00:00
Edd 142f08343b feat(governance): add update benefit tiers table (#5146) 2023-10-31 10:20:38 +00:00
m.ray 61aa45a9ed feat(trading): show fees paid across whole market (#5154) 2023-10-30 18:16:49 -07:00
Bartłomiej GłowniaandMatthew Russell 4c95db5fb3 feat(deal-ticket): calculate fees and discount base on volumeDiscountStats and referralSetStats (#5147)
Co-authored-by: Matthew Russell <mattrussell36@gmail.com>
2023-10-30 18:13:26 -07:00
ArtandMatthew Russell 3072b7824f fix(trading): referrer stats tiles (#5143)
Co-authored-by: Matthew Russell <mattrussell36@gmail.com>
2023-10-30 18:12:59 -07:00
Bartłomiej Głownia 0580e90171 feat(funding-payments): add load more button to the funding payments table (#5150) 2023-10-30 15:57:43 -07:00
Bartłomiej Głownia c440abc77d feat(trading): show full history of orders, fills and trades (#5105) 2023-10-27 14:21:26 +01:00
Matthew Russell fbafc726a4 feat(candles-chart): combine overlays and studies into indicators dropdown (#5129) 2023-10-27 14:21:14 +01:00
Joe Tsang dd1890d8c6 chore(cypress): fix failing assertions (#5132) 2023-10-27 14:21:02 +01:00
Matthew Russell c8e624eaba chore(trading): update fee factor with discount calculation (#5138) 2023-10-27 14:20:45 +01:00
Art cc6629ad27 fix(trading): referrals next tier tiles (#5139) 2023-10-27 13:48:43 +02:00
Art 9838efa00e feat(proposals): generic proposal toasts (#5134) 2023-10-27 13:17:51 +02:00
m.ray dac7142a98 feat(trading): add earmarked fees (#5123) 2023-10-27 13:09:01 +02:00
Matthew Russell 1397aafd25 fix(ui-toolkit): sparkline red shaded area color 2023-10-26 10:19:05 -07:00
Matthew Russell b192603e57 feat(ui-toolkit): sparkline shaded area (#5131) 2023-10-26 10:06:05 -07:00
Matthew Russell 8c2c8d987c feat(trading): add row click for fees table (#5130) 2023-10-26 08:38:52 -07:00
Matthew Russell 52c96794f7 feat(trading): fees page (#5055) 2023-10-25 14:59:30 -07:00
184 changed files with 6865 additions and 2804 deletions
@@ -39,7 +39,6 @@ context('Market page', { tags: '@regression' }, function () {
cy.contains('Test market 1').click();
cy.getByTestId(marketHeaders).should('have.text', 'Test market 1');
cy.validate_element_from_table('Name', 'Test market 1');
cy.validate_element_from_table('Market ID', this.createdMarketId);
cy.validate_element_from_table('Trading Mode', 'Opening auction');
cy.validate_element_from_table('Market Decimal Places', '5');
cy.validate_element_from_table('Position Decimal Places', '5');
@@ -1,68 +0,0 @@
context('Oracle page', { tags: '@smoke' }, () => {
describe('Verify elements on page', () => {
before('create market and navigate to oracle page', () => {
cy.createMarket();
cy.visit('/oracles');
});
it('should see oracle data', () => {
cy.getByTestId('oracle-details').should('have.length.at.least', 2);
cy.getByTestId('oracle-details')
.should('exist')
.eq(0)
.within(() => {
cy.get('tr')
.eq(0)
.within(() => {
cy.get('th').should('have.text', 'ID');
cy.get('a').invoke('text').should('have.length', 64);
cy.get('a')
.should('have.attr', 'href')
.and('contain', '/oracles/');
});
cy.get('tr')
.eq(1)
.within(() => {
cy.get('th').should('have.text', 'Type');
cy.get('td').should('have.text', 'External data');
});
cy.get('tr')
.eq(2)
.within(() => {
cy.get('th').should('have.text', 'Signer');
cy.getByTestId('keytype').should('have.text', 'Vega');
cy.get('a').invoke('text').should('have.length', 64);
cy.get('a')
.should('have.attr', 'href')
.and('contain', '/parties/');
});
cy.get('tr')
.eq(3)
.within(() => {
cy.get('th').should('have.text', 'Settlement for');
cy.get('a').invoke('text').should('have.length', 64);
cy.get('a')
.should('have.attr', 'href')
.and('contain', '/markets/');
});
cy.get('tr')
.eq(4)
.within(() => {
cy.get('th').should('have.text', 'Matched data');
cy.get('td').should('have.text', '❌');
});
cy.get('details')
.eq(0)
.within(() => {
cy.contains('Filter').click();
cy.get('.language-json').should('exist');
});
cy.get('details')
.eq(1)
.within(() => {
cy.contains('JSON').click();
cy.get('.language-json').should('exist');
});
});
});
});
});
+1 -1
View File
@@ -77,7 +77,7 @@
"executor": "nx:run-commands",
"options": {
"commands": [
"npx openapi-typescript https://raw.githubusercontent.com/vegaprotocol/documentation/main/specs/v0.72.3/blockexplorer.openapi.json --output apps/explorer/src/types/explorer.d.ts --immutable-types"
"npx openapi-typescript https://raw.githubusercontent.com/vegaprotocol/documentation/main/specs/v0.73.1/blockexplorer.openapi.json --output apps/explorer/src/types/explorer.d.ts --immutable-types"
]
}
},
@@ -1,5 +1,6 @@
export type HashProps = {
text: string;
truncate?: boolean;
};
/**
@@ -7,10 +8,16 @@ export type HashProps = {
* are broken when they need to wrap. This will remove the need
* for a lot of the overflow scrolling that currently exists.
*/
const Hash = ({ text }: HashProps) => {
const Hash = ({ text, truncate = false }: HashProps) => {
const h = truncate ? text.slice(0, 6) : text;
return (
<code className="break-all font-mono" style={{ wordWrap: 'break-word' }}>
{text}
<code
title={text}
className="break-all font-mono"
style={{ wordWrap: 'break-word' }}
>
{h}
</code>
);
};
@@ -3,6 +3,7 @@ query ExplorerMarket($id: ID!) {
id
decimalPlaces
positionDecimalPlaces
state
tradableInstrument {
instrument {
name
@@ -22,6 +23,5 @@ query ExplorerMarket($id: ID!) {
}
}
}
state
}
}
@@ -17,6 +17,7 @@ export const ExplorerMarketDocument = gql`
id
decimalPlaces
positionDecimalPlaces
state
tradableInstrument {
instrument {
name
@@ -36,7 +37,6 @@ export const ExplorerMarketDocument = gql`
}
}
}
state
}
}
`;
@@ -63,6 +63,9 @@ describe('Market link component', () => {
product: {
__typename: 'Future',
quoteName: 'dai',
settlementAsset: {
decimals: 8,
},
},
},
},
@@ -23,6 +23,7 @@ const MarketLink = ({
}: MarketLinkProps) => {
const { data, error, loading } = useExplorerMarketQuery({
variables: { id },
fetchPolicy: 'cache-first',
});
let label = <span>{id}</span>;
@@ -0,0 +1,57 @@
import OracleLink, { getStatusString } from './oracle-link';
import { render, screen } from '@testing-library/react';
import { MemoryRouter } from 'react-router-dom';
import { MockedProvider } from '@apollo/client/testing';
describe('getStatusString', () => {
it("returns 'Unknown' for undefined status", () => {
expect(getStatusString(undefined)).toBe('Unknown');
});
it('returns the correct string for a known status', () => {
expect(getStatusString('STATUS_ACTIVE')).toBe('Active');
expect(getStatusString('STATUS_DEACTIVATED')).toBe('Deactivated');
});
});
describe('OracleLink', () => {
it('renders the truncated Oracle ID', () => {
const id = '123456789';
render(
<MockedProvider>
<MemoryRouter>
<OracleLink id={id} />
</MemoryRouter>
</MockedProvider>
);
const idElement = screen.getByText(id.slice(0, 6));
expect(idElement).toBeInTheDocument();
expect(idElement).toHaveAttribute('title', id);
});
it('renders the Oracle status', () => {
const id = '123';
const status = 'STATUS_ACTIVE';
render(
<MockedProvider>
<MemoryRouter>
<OracleLink id={id} status={status} data-testid="link" />
</MemoryRouter>
</MockedProvider>
);
expect(screen.getByTestId('link')).toHaveAttribute('data-status', 'Active');
});
it('renders the Oracle data indicator', () => {
const id = '123';
const hasSeenOracleReports = true;
render(
<MockedProvider>
<MemoryRouter>
<OracleLink id={id} hasSeenOracleReports={hasSeenOracleReports} />
</MemoryRouter>
</MockedProvider>
);
expect(screen.getByTestId('oracle-data-indicator')).toBeInTheDocument();
});
});
@@ -3,20 +3,93 @@ import { Link } from 'react-router-dom';
import type { ComponentProps } from 'react';
import Hash from '../hash';
import { Tooltip, VegaIcon, VegaIconNames } from '@vegaprotocol/ui-toolkit';
import {
DataSourceSpecStatus,
DataSourceSpecStatusMapping,
} from '@vegaprotocol/types';
import { t } from '@vegaprotocol/i18n';
/**
* Returns a human-readable string for the given status, or a meaningful
* default if the status is unrecognised
* @param status string status
*/
export function getStatusString(status: string | undefined): string {
if (status && status in DataSourceSpecStatus) {
return DataSourceSpecStatusMapping[status as DataSourceSpecStatus];
}
return t('Unknown');
}
export type OracleLinkProps = Partial<ComponentProps<typeof Link>> & {
// The Oracle ID
id: string;
// If available, the oracle status
status?: string;
// If the oracle has corresponding data in the OracleDataConnection
hasSeenOracleReports?: boolean;
};
const OracleLink = ({ id, ...props }: OracleLinkProps) => {
/**
* Given an Oracle ID, renders a data-dense link to the Oracle page. Data density is achieved by:
* - Colour coding the link based on the Oracle's status
* - Showing a small indicator if the Oracle has matched data
* - Showing a tooltip with the Oracle's status and whether it has matched data
*
* @returns
*/
export const OracleLink = ({
id,
status,
hasSeenOracleReports = false,
...props
}: OracleLinkProps) => {
const bgColour =
status === 'STATUS_ACTIVE'
? 'bg-yellow-100 hover:bg-yellow-200 border-yellow-200 dark:bg-yellow-200 dark:border-yellow-200 dark:text-gray-900 dark:border-yellow-300'
: 'bg-gray-100 hover:bg-gray-200 border-gray-200';
const indicatorColour =
status === 'STATUS_ACTIVE'
? 'bg-yellow-300 hover:bg-yellow-500 dark:bg-yellow-500'
: 'bg-gray-300 hover:bg-gray-500';
const description = (
<div>
<p>
<strong>{`Status: `}</strong>
{getStatusString(status)}
</p>
<p>
<strong>{`Matched data: `}</strong>
{hasSeenOracleReports ? (
<VegaIcon name={VegaIconNames.TICK} />
) : (
<VegaIcon name={VegaIconNames.CROSS} />
)}
</p>
</div>
);
return (
<Link
className="underline font-mono"
{...props}
to={`/${Routes.ORACLES}/${id}`}
>
<Hash text={id} />
</Link>
<Tooltip description={description}>
<Link
className={`pl-2 pr-2 font-mono dark:text-black ${bgColour} rounded-sm border-solid border-2 relative`}
{...props}
to={`/${Routes.ORACLES}/${id}`}
data-status={getStatusString(status)}
>
<Hash text={id} truncate={true} />
{hasSeenOracleReports ? (
<strong
data-testid="oracle-data-indicator"
className={`absolute top-0 right-0 w-1 h-full font-thin ${indicatorColour}`}
title="Oracle has matched data"
></strong>
) : null}
</Link>
</Tooltip>
);
};
@@ -74,6 +74,9 @@ function renderExistingAmend(
product: {
__typename: 'Future',
quoteName: '123',
settlementAsset: {
decimals: 8,
},
},
},
},
@@ -124,6 +127,9 @@ function renderExistingAmend(
product: {
__typename: 'Future',
quoteName: '123',
settlementAsset: {
decimals: 8,
},
},
},
},
@@ -152,6 +158,9 @@ function renderExistingAmend(
product: {
__typename: 'Future',
quoteName: 'dai',
settlementAsset: {
decimals: 8,
},
},
},
},
@@ -22,5 +22,9 @@ export const TimeAgo = ({ date, ...props }: TimeAgoProps) => {
return <>{t('Date unknown')}</>;
}
return <span {...props}>{t(`${distanceToNow} ago`)}</span>;
return (
<span {...props} title={date} className="underline decoration-dotted">
{t(`${distanceToNow} ago`)}
</span>
);
};
@@ -1,6 +0,0 @@
query ExplorerSettlementAssetForMarket($id: ID!) {
market(id: $id) {
id
decimalPlaces
}
}
@@ -1,49 +0,0 @@
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 } | null };
export const ExplorerSettlementAssetForMarketDocument = gql`
query ExplorerSettlementAssetForMarket($id: ID!) {
market(id: $id) {
id
decimalPlaces
}
}
`;
/**
* __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<ExplorerSettlementAssetForMarketQuery, ExplorerSettlementAssetForMarketQueryVariables>) {
const options = {...defaultOptions, ...baseOptions}
return Apollo.useQuery<ExplorerSettlementAssetForMarketQuery, ExplorerSettlementAssetForMarketQueryVariables>(ExplorerSettlementAssetForMarketDocument, options);
}
export function useExplorerSettlementAssetForMarketLazyQuery(baseOptions?: Apollo.LazyQueryHookOptions<ExplorerSettlementAssetForMarketQuery, ExplorerSettlementAssetForMarketQueryVariables>) {
const options = {...defaultOptions, ...baseOptions}
return Apollo.useLazyQuery<ExplorerSettlementAssetForMarketQuery, ExplorerSettlementAssetForMarketQueryVariables>(ExplorerSettlementAssetForMarketDocument, options);
}
export type ExplorerSettlementAssetForMarketQueryHookResult = ReturnType<typeof useExplorerSettlementAssetForMarketQuery>;
export type ExplorerSettlementAssetForMarketLazyQueryHookResult = ReturnType<typeof useExplorerSettlementAssetForMarketLazyQuery>;
export type ExplorerSettlementAssetForMarketQueryResult = Apollo.QueryResult<ExplorerSettlementAssetForMarketQuery, ExplorerSettlementAssetForMarketQueryVariables>;
@@ -1,131 +0,0 @@
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(
<MockedProvider>
<table>
<tbody data-testid="container">
<LiquidityProvisionDetailsRow
order={order}
marketId={marketId}
normaliseProportionsTo={normaliseProportionsTo}
side={side}
/>
</tbody>
</table>
</MockedProvider>
);
}
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%')).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%')).toBeInTheDocument();
expect(res.getByText('-')).toBeInTheDocument();
});
});
@@ -1,73 +0,0 @@
import { t } from '@vegaprotocol/i18n';
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<VegaPeggedReference, string> = {
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 <mid />
* 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 (
<TableRow modifier="bordered" key={key} data-testid={key}>
<td className="text-right px-2">
{order.offset && marketId ? (
<LiquidityProvisionOffset
offset={order.offset}
side={side}
marketId={marketId}
/>
) : (
'-'
)}
</td>
<td className="text-center">
{order.reference ? LiquidityReferenceLabel[order.reference] : '-'}
</td>
<td className="text-center">{proportion}%</td>
</TableRow>
);
}
@@ -1,22 +0,0 @@
import { render } from '@testing-library/react';
import { LiquidityProvisionMid } from './liquidity-provision-mid';
describe('LiquidityProvisionMid component', () => {
function renderComponent() {
return render(
<table>
<tbody data-testid="container">
<LiquidityProvisionMid />
</tbody>
</table>
);
}
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');
});
});
@@ -1,17 +0,0 @@
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 (
<TableRow modifier="bordered" data-testid="mid">
<td data-testid="mid-display" colSpan={3} className="bg-white"></td>
</TableRow>
);
}
@@ -1,67 +0,0 @@
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,
},
};
describe('LiquidityProvisionOffset component', () => {
function renderComponent(
offset: string,
side: VegaSide,
marketId: string,
mocks: MockedResponse[]
) {
return render(
<MockedProvider mocks={mocks}>
<LiquidityProvisionOffset
offset={offset}
side={side}
marketId={marketId}
/>
</MockedProvider>
);
}
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');
});
});
@@ -1,59 +0,0 @@
import { useExplorerSettlementAssetForMarketQuery } from '../__generated__/Explorer-settlement-asset';
import { addDecimalsFormatNumber } from '@vegaprotocol/utils';
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 <span className={className}>{`${label}${formattedOffset}`}</span>;
}
/**
* Does the work of formatting the number now we have the market 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?.decimalPlaces;
if (!decimals) {
return offset;
}
return addDecimalsFormatNumber(offset, decimals);
}
@@ -1,209 +0,0 @@
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(
<MockedProvider>
<LiquidityProvisionDetails provision={provision} />
</MockedProvider>
);
}
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%')).toBeInTheDocument();
expect(res.getByText('55%')).toBeInTheDocument();
});
});
@@ -1,94 +0,0 @@
import { t } from '@vegaprotocol/i18n';
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<VegaPeggedReference, string> = {
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 (
<table>
<thead>
<TableRow modifier="bordered">
<th className="px-2 pb-1">{t('Price offset')}</th>
<th className="px-2 pb-1">{t('Price reference')}</th>
<th className="px-2 pb-1">{t('Proportion')}</th>
</TableRow>
</thead>
<tbody>
{provision.buys?.map((b, i) => (
<LiquidityProvisionDetailsRow
order={b}
marketId={provision.marketId}
side={Side.SIDE_BUY}
key={`SIDE_BUY-${i}`}
normaliseProportionsTo={buyTotal}
/>
))}
<LiquidityProvisionMid />
{provision.sells?.map((s, i) => (
<LiquidityProvisionDetailsRow
order={s}
marketId={provision.marketId}
side={Side.SIDE_SELL}
key={`SIDE_SELL-${i}`}
normaliseProportionsTo={sellTotal}
/>
))}
</tbody>
</table>
);
}
@@ -1,11 +1,20 @@
import { t } from '@vegaprotocol/i18n';
import type { components } from '../../../../../types/explorer';
import { TableCell, TableRow } from '../../../table';
import type { VegaPeggedReference } from '../liquidity-provision/liquidity-provision-details';
import { Side, PeggedReferenceMapping } from '@vegaprotocol/types';
import { useExplorerMarketQuery } from '../../../links/market-link/__generated__/Market';
import type { ExplorerMarketQuery } from '../../../links/market-link/__generated__/Market';
import { addDecimalsFormatNumber } from '@vegaprotocol/utils';
export type VegaPeggedReference = components['schemas']['vegaPeggedReference'];
export const LiquidityReferenceLabel: Record<VegaPeggedReference, string> = {
PEGGED_REFERENCE_BEST_ASK: t('Best Ask'),
PEGGED_REFERENCE_BEST_BID: t('Best Bid'),
PEGGED_REFERENCE_MID: t('Mid'),
PEGGED_REFERENCE_UNSPECIFIED: '-',
};
export interface TxDetailsOrderProps {
offset: string;
reference: VegaPeggedReference;
@@ -33,6 +33,14 @@ const AccountType: Record<AccountTypes, string> = {
ACCOUNT_TYPE_HOLDING: 'Holding',
ACCOUNT_TYPE_LIQUIDITY_FEES_BONUS_DISTRIBUTION: 'Bonus Distribution',
ACCOUNT_TYPE_LP_LIQUIDITY_FEES: 'LP Liquidity Fees',
ACCOUNT_TYPE_NETWORK_TREASURY: 'Network Treasury',
ACCOUNT_TYPE_VESTING_REWARDS: 'Vesting Rewards',
ACCOUNT_TYPE_VESTED_REWARDS: 'Vested Rewards',
ACCOUNT_TYPE_REWARD_AVERAGE_POSITION: 'Reward Average Position',
ACCOUNT_TYPE_REWARD_RELATIVE_RETURN: 'Reward Relative Return',
ACCOUNT_TYPE_REWARD_RETURN_VOLATILITY: 'Reward Return Volatility',
ACCOUNT_TYPE_REWARD_VALIDATOR_RANKING: 'Reward Validator Ranking',
ACCOUNT_TYPE_PENDING_FEE_REFERRAL_REWARD: 'Pending Fee Referral Reward',
};
interface TransferParticipantsProps {
@@ -1,10 +1,50 @@
import { t } from '@vegaprotocol/i18n';
import { TxDetailsShared } from './shared/tx-details-shared';
import { TableWithTbody } from '../../table';
import { defaultAbiCoder, base64 } from 'ethers/lib/utils';
import { ChainEvent } from './chain-events';
import { BigNumber } from 'ethers';
import type { AbiType } from '../../../lib/encoders/abis/abi-types';
import type { BlockExplorerTransactionResult } from '../../../routes/types/block-explorer-response';
import type { TendermintBlocksResponse } from '../../../routes/blocks/tendermint-blocks-response';
import { ChainEvent } from './chain-events';
interface AbiOutput {
type: AbiType;
internalType: AbiType;
name: string;
}
/**
* Decodes the b64/ABIcoded result from an eth cal
* @param data
* @returns
*/
export function decodeEthCallResult(
data: BlockExplorerTransactionResult
): string {
const ethResult = data.command.chainEvent?.contractCall.result;
try {
// Decode the result string: base64 => uint8array
const data = base64.decode(ethResult);
// Parse the escaped ABI in to an object
const abi = JSON.parse(
'[{"inputs":[],"name":"latestAnswer","outputs":[{"internalType":"int256","name":"","type":"int256"}],"stateMutability":"view","type":"function"}]'
);
// Pull the expected types out of the Oracles ABI
const types: AbiType[] = abi[0].outputs.map((o: AbiOutput) => o.type);
const rawResult = defaultAbiCoder.decode(types, data);
// Finally, convert the resulting BigNumber in to a string
const res = BigNumber.from(rawResult[0]).toString();
return res;
} catch (e) {
return '-';
}
}
interface TxDetailsChainEventProps {
txData: BlockExplorerTransactionResult | undefined;
@@ -16,7 +16,6 @@ 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';
import { TxDetailsLiquidityCancellation } from './tx-liquidity-cancel';
import { TxDetailsDataSubmission } from './tx-data-submission';
@@ -28,6 +27,7 @@ import { TxDetailsStateVariable } from './tx-state-variable-proposal';
import { TxProposal } from './tx-proposal';
import { TxDetailsTransfer } from './tx-transfer';
import { TxDetailsStopOrderSubmission } from './tx-stop-order-submission';
import { TxDetailsLiquiditySubmission } from './tx-liquidity-submission';
interface TxDetailsWrapperProps {
txData: BlockExplorerTransactionResult | undefined;
@@ -107,7 +107,7 @@ function getTransactionComponent(txData?: BlockExplorerTransactionResult) {
return TxDetailsWithdrawSubmission;
case 'Liquidity Provision Order':
return TxDetailsLiquiditySubmission;
case 'Amend LiquidityProvision Order':
case 'Amend Liquidity Provision Order':
return TxDetailsLiquidityAmendment;
case 'Cancel LiquidityProvision Order':
return TxDetailsLiquidityCancellation;
@@ -5,7 +5,6 @@ import type { TendermintBlocksResponse } from '../../../routes/blocks/tendermint
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';
import BigNumber from 'bignumber.js';
@@ -40,40 +39,32 @@ export const TxDetailsLiquidityAmendment = ({
: '-';
return (
<>
<TableWithTbody className="mb-8" allowWrap={true}>
<TxDetailsShared
txData={txData}
pubKey={pubKey}
blockData={blockData}
/>
<TableWithTbody className="mb-8" allowWrap={true}>
<TxDetailsShared txData={txData} pubKey={pubKey} blockData={blockData} />
<TableRow modifier="bordered">
<TableCell>{t('Market')}</TableCell>
<TableCell>
<MarketLink id={marketId} />
</TableCell>
</TableRow>
{amendment.commitmentAmount ? (
<TableRow modifier="bordered">
<TableCell>{t('Market')}</TableCell>
<TableCell>{t('Commitment amount')}</TableCell>
<TableCell>
<MarketLink id={marketId} />
<PriceInMarket
price={amendment.commitmentAmount}
marketId={marketId}
decimalSource="SETTLEMENT_ASSET"
/>
</TableCell>
</TableRow>
{amendment.commitmentAmount ? (
<TableRow modifier="bordered">
<TableCell>{t('Commitment amount')}</TableCell>
<TableCell>
<PriceInMarket
price={amendment.commitmentAmount}
marketId={marketId}
decimalSource="SETTLEMENT_ASSET"
/>
</TableCell>
</TableRow>
) : null}
{amendment.fee ? (
<TableRow modifier="bordered">
<TableCell>{t('Fee')}</TableCell>
<TableCell>{fee}%</TableCell>
</TableRow>
) : null}
</TableWithTbody>
<LiquidityProvisionDetails provision={amendment} />
</>
) : null}
{amendment.fee ? (
<TableRow modifier="bordered">
<TableCell>{t('Fee')}</TableCell>
<TableCell>{fee}%</TableCell>
</TableRow>
) : null}
</TableWithTbody>
);
};
@@ -1,86 +0,0 @@
import { render } from '@testing-library/react';
import { TxDetailsLiquiditySubmission } from './tx-liquidity-submission';
import type { TendermintBlocksResponse } from '../../../routes/blocks/tendermint-blocks-response';
import type { BlockExplorerTransactionResult } from '../../../routes/types/block-explorer-response';
import { MockedProvider } from '@apollo/client/testing';
import { MemoryRouter } from 'react-router-dom';
describe('TxDetailsLiquiditySubmission', () => {
const mockTxData = {
hash: 'test',
command: {
liquidityProvisionSubmission: {
marketId: 'BTC-USD',
commitmentAmount: 100,
fee: '0.01',
},
},
};
const mockPubKey = '123';
const mockBlockData = {
result: {
block: {
header: {
height: '123',
},
},
},
};
it('should render the component with correct data', () => {
const { getByText } = render(
<MockedProvider>
<MemoryRouter>
<TxDetailsLiquiditySubmission
txData={mockTxData as BlockExplorerTransactionResult}
pubKey={mockPubKey}
blockData={mockBlockData as TendermintBlocksResponse}
/>
</MemoryRouter>
</MockedProvider>
);
expect(getByText('Market')).toBeInTheDocument();
expect(getByText('BTC-USD')).toBeInTheDocument();
expect(getByText('Commitment amount')).toBeInTheDocument();
expect(getByText('100')).toBeInTheDocument();
expect(getByText('Fee')).toBeInTheDocument();
expect(getByText('1%')).toBeInTheDocument();
});
it('should display awaiting message when tx data is undefined', () => {
const { getByText } = render(
<MockedProvider>
<MemoryRouter>
<TxDetailsLiquiditySubmission
txData={undefined}
pubKey={mockPubKey}
blockData={mockBlockData as TendermintBlocksResponse}
/>
</MemoryRouter>
</MockedProvider>
);
expect(
getByText('Awaiting Block Explorer transaction details')
).toBeInTheDocument();
});
it('should display awaiting message when liquidityProvisionSubmission is undefined', () => {
const { getByText } = render(
<MockedProvider>
<MemoryRouter>
<TxDetailsLiquiditySubmission
txData={{ command: {} } as BlockExplorerTransactionResult}
pubKey={mockPubKey}
blockData={mockBlockData as TendermintBlocksResponse}
/>
</MemoryRouter>
</MockedProvider>
);
expect(
getByText('Awaiting Block Explorer transaction details')
).toBeInTheDocument();
});
});
@@ -1,31 +1,30 @@
import { t } from '@vegaprotocol/i18n';
import type { BlockExplorerTransactionResult } from '../../../routes/types/block-explorer-response';
import { MarketLink } from '../../links/';
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';
import BigNumber from 'bignumber.js';
export type LiquiditySubmission =
components['schemas']['v1LiquidityProvisionSubmission'];
interface TxDetailsLiquiditySubmissionProps {
interface TxDetailsLiquidityAmendmentProps {
txData: BlockExplorerTransactionResult | undefined;
pubKey: string | undefined;
blockData: TendermintBlocksResponse | undefined;
}
/**
* Someone cancelled an order
* An existing liquidity order is being created.
*/
export const TxDetailsLiquiditySubmission = ({
txData,
pubKey,
blockData,
}: TxDetailsLiquiditySubmissionProps) => {
}: TxDetailsLiquidityAmendmentProps) => {
if (!txData || !txData.command.liquidityProvisionSubmission) {
return <>{t('Awaiting Block Explorer transaction details')}</>;
}
@@ -39,40 +38,38 @@ export const TxDetailsLiquiditySubmission = ({
: '-';
return (
<>
<TableWithTbody className="mb-8" allowWrap={true}>
<TxDetailsShared
txData={txData}
pubKey={pubKey}
blockData={blockData}
/>
<TableWithTbody className="mb-8" allowWrap={true}>
<TxDetailsShared txData={txData} pubKey={pubKey} blockData={blockData} />
<TableRow modifier="bordered">
<TableCell>{t('Market')}</TableCell>
<TableCell>
<MarketLink id={marketId} />
</TableCell>
</TableRow>
{submission.commitmentAmount ? (
<TableRow modifier="bordered">
<TableCell>{t('Market')}</TableCell>
<TableCell>{t('Commitment amount')}</TableCell>
<TableCell>
<MarketLink id={marketId} />
<PriceInMarket
price={submission.commitmentAmount}
marketId={marketId}
decimalSource="SETTLEMENT_ASSET"
/>
</TableCell>
</TableRow>
{submission.commitmentAmount ? (
<TableRow modifier="bordered">
<TableCell>{t('Commitment amount')}</TableCell>
<TableCell>
<PriceInMarket
price={submission.commitmentAmount}
marketId={marketId}
decimalSource="SETTLEMENT_ASSET"
/>
</TableCell>
</TableRow>
) : null}
{submission.fee ? (
<TableRow modifier="bordered">
<TableCell>{t('Fee')}</TableCell>
<TableCell>{fee}%</TableCell>
</TableRow>
) : null}
</TableWithTbody>
<LiquidityProvisionDetails provision={submission} />
</>
) : null}
{submission.fee ? (
<TableRow modifier="bordered">
<TableCell>{t('Fee')}</TableCell>
<TableCell>{fee}%</TableCell>
</TableRow>
) : null}
{submission.reference ? (
<TableRow modifier="bordered">
<TableCell>{t('Reference')}</TableCell>
<TableCell>{submission.reference}</TableCell>
</TableRow>
) : null}
</TableWithTbody>
);
};
@@ -18,11 +18,13 @@ import { FilterLabel } from './tx-filter-label';
export type FilterOption =
| 'Amend LiquidityProvision Order'
| 'Amend Order'
| 'Apply Referral Code'
| 'Batch Market Instructions'
| 'Cancel LiquidityProvision Order'
| 'Cancel Order'
| 'Cancel Transfer Funds'
| 'Chain Event'
| 'Create Referral Set'
| 'Delegate'
| 'Ethereum Key Rotate Submission'
| 'Issue Signatures'
@@ -40,6 +42,7 @@ export type FilterOption =
| 'Submit Order'
| 'Transfer Funds'
| 'Undelegate'
| 'Update Referral Set'
| 'Validator Heartbeat'
| 'Vote on Proposal'
| 'Withdraw';
@@ -104,10 +104,30 @@ export function getLabelForProposal(
}
} else if (proposal.terms?.updateMarket) {
return t('Proposal: Update market');
} else if (proposal.terms?.updateSpotMarket) {
return t('Proposal: Update spot');
} else if (proposal.terms?.updateMarketState) {
const type = proposal.terms.updateMarketState.changes?.updateType;
if (type === 'MARKET_STATE_UPDATE_TYPE_TERMINATE') {
return t('Proposal: Market terminate');
} else if (type === 'MARKET_STATE_UPDATE_TYPE_SUSPEND') {
return t('Proposal: Market suspend');
} else if (type === 'MARKET_STATE_UPDATE_TYPE_RESUME') {
return t('Proposal: Market resume');
}
return t('Proposal: Market state');
} else if (proposal.terms?.updateNetworkParameter) {
return t('Proposal: Network parameter');
} else if (proposal.terms?.updateReferralProgram) {
return t('Proposal: Referral program');
} else if (proposal.terms?.updateVolumeDiscountProgram) {
return t('Proposal: Discount program');
} else if (proposal.terms?.newFreeform) {
return t('Proposal: Freeform');
} else if (proposal.terms?.newTransfer) {
return t('Proposal: Transfer');
} else if (proposal.terms?.cancelTransfer) {
return t('Proposal: Transfer cancel');
} else {
return t('Proposal');
}
@@ -82,7 +82,7 @@ describe('Txs infinite list item', () => {
expect(screen.getByText('Missing vital data')).toBeInTheDocument();
});
it('renders data correctly', () => {
it('renders data even with missing time', () => {
render(
<MockedProvider>
<MemoryRouter>
@@ -105,5 +105,33 @@ describe('Txs infinite list item', () => {
expect(screen.getByTestId('pub-key')).toHaveTextContent('testPubKey');
expect(screen.getByTestId('tx-type')).toHaveTextContent('testType');
expect(screen.getByTestId('tx-block')).toHaveTextContent('1');
expect(screen.getByTestId('tx-time')).toHaveTextContent('-');
});
it('renders data correctly', () => {
render(
<MockedProvider>
<MemoryRouter>
<table>
<tbody>
<TxsInfiniteListItem
type="testType"
submitter="testPubKey"
hash="testTxHash"
block="1"
code={0}
command={{}}
createdAt="1970-11-01T18:07:15Z"
/>
</tbody>
</table>
</MemoryRouter>
</MockedProvider>
);
expect(screen.getByTestId('tx-hash')).toHaveTextContent('testTxHash');
expect(screen.getByTestId('pub-key')).toHaveTextContent('testPubKey');
expect(screen.getByTestId('tx-type')).toHaveTextContent('testType');
expect(screen.getByTestId('tx-block')).toHaveTextContent('1');
expect(screen.getByTestId('tx-time').textContent).toMatch(/years ago/);
});
});
@@ -9,6 +9,7 @@ import { PartyLink } from '../links';
import { useScreenDimensions } from '@vegaprotocol/react-helpers';
import type { Screen } from '@vegaprotocol/react-helpers';
import { useMemo } from 'react';
import { TimeAgo } from '../time-ago';
const DEFAULT_TRUNCATE_LENGTH = 7;
@@ -32,6 +33,7 @@ export const TxsInfiniteListItem = ({
type,
block,
command,
createdAt,
}: Partial<BlockExplorerTransactionResult>) => {
const { screenSize } = useScreenDimensions();
const idTruncateLength = useMemo(
@@ -85,6 +87,11 @@ export const TxsInfiniteListItem = ({
endChars={5}
/>
</td>
{['lg', 'xl', 'xxl', 'xxxl'].includes(screenSize) && (
<td className="text-sm items-center font-mono" data-testid="tx-time">
{createdAt ? <TimeAgo date={createdAt} /> : '-'}
</td>
)}
</tr>
);
};
@@ -3,6 +3,7 @@ import { TxsInfiniteListItem } from './txs-infinite-list-item';
import type { BlockExplorerTransactionResult } from '../../routes/types/block-explorer-response';
import EmptyList from '../empty-list/empty-list';
import { Loader } from '@vegaprotocol/ui-toolkit';
import { useScreenDimensions } from '@vegaprotocol/react-helpers';
interface TxsInfiniteListProps {
hasMoreTxs: boolean;
@@ -19,7 +20,16 @@ interface ItemProps {
}
const Item = ({ tx }: ItemProps) => {
const { hash, submitter, type, command, block, code, index: blockIndex } = tx;
const {
hash,
submitter,
type,
command,
block,
code,
createdAt,
index: blockIndex,
} = tx;
return (
<TxsInfiniteListItem
type={type}
@@ -29,6 +39,7 @@ const Item = ({ tx }: ItemProps) => {
hash={hash}
block={block}
index={blockIndex}
createdAt={createdAt}
/>
);
};
@@ -39,6 +50,7 @@ export const TxsInfiniteList = ({
className,
hasFilters = false,
}: TxsInfiniteListProps) => {
const { screenSize } = useScreenDimensions();
if (!txs || txs.length === 0) {
if (!areTxsLoading) {
return (
@@ -66,6 +78,9 @@ export const TxsInfiniteList = ({
<th>{t('Type')}</th>
<th className="text-left">{t('From')}</th>
<th>{t('Block')}</th>
{['lg', 'xl', 'xxl', 'xxxl'].includes(screenSize) && (
<th>{t('Time')}</th>
)}
</tr>
</thead>
<tbody>
@@ -43,7 +43,7 @@ export const getTxsDataUrl = (params: IGetTxsDataUrl) => {
url.searchParams.append('first', count);
url.searchParams.append('after', params.after);
} else {
url.searchParams.append('first', count);
url.searchParams.append('last', count);
}
// Hacky fix for param as array
@@ -6,7 +6,7 @@ describe('getTxsDataUrl', () => {
count: 10,
baseUrl: 'https://example.com/transactions',
};
const expectedUrl = 'https://example.com/transactions?first=10';
const expectedUrl = 'https://example.com/transactions?last=10';
expect(getTxsDataUrl(params)).toEqual(expectedUrl);
});
@@ -41,7 +41,7 @@ describe('getTxsDataUrl', () => {
baseUrl: 'https://example.com/transactions',
};
const expectedUrl =
'https://example.com/transactions?first=10&filters[cmd.type]=Made%20Up%20Transaction&filters[tx.submitter]=1234';
'https://example.com/transactions?last=10&filters[cmd.type]=Made%20Up%20Transaction&filters[tx.submitter]=1234';
expect(getTxsDataUrl(params)).toEqual(expectedUrl);
});
@@ -36,30 +36,50 @@ const PERCENTAGE_PARAMS = [
'governance.proposal.updateNetParam.requiredMajority',
'governance.proposal.updateNetParam.requiredParticipation',
'governance.proposal.updateMarket.minProposerEquityLikeShare',
'governance.proposal.VolumeDiscountProgram.requiredMajority',
'governance.proposal.VolumeDiscountProgram.requiredParticipation',
'governance.proposal.referralProgram.requiredMajority',
'governance.proposal.transfer.requiredMajority',
'governance.proposal.updateAsset.requiredMajority',
'governance.proposal.updateAsset.requiredParticipation',
'governance.proposal.transfer.requiredParticipation',
'governance.proposal.referralProgram.requiredParticipation',
'network.validators.ersatz.rewardFactor',
'network.validators.ersatz.multipleOfTendermintValidators',
'validators.vote.required',
];
'referralProgram.maxReferralRewardFactor',
'referralProgram.maxReferralDiscountFactor',
'referralProgram.maxReferralRewardProportion',
].map((p) => p.toLowerCase());
const BIG_NUMBER_PARAMS = [
'spam.protection.delegation.min.tokens',
'validators.delegation.minAmount',
'governance.proposal.transfer.maxAmount',
'reward.staking.delegation.minimumValidatorStake',
'reward.staking.delegation.maxPayoutPerParticipant',
'reward.staking.delegation.maxPayoutPerEpoch',
'spam.protection.voting.min.tokens',
'spam.protection.proposal.min.tokens',
'governance.proposal.transfer.minVoterBalance',
'governance.proposal.freeform.minProposerBalance',
'governance.proposal.updateNetParam.minVoterBalance',
'governance.proposal.updateMarket.minVoterBalance',
'governance.proposal.asset.minVoterBalance',
'governance.proposal.updateNetParam.minProposerBalance',
'governance.proposal.freeform.minVoterBalance',
'spam.protection.proposal.min.tokens',
'governance.proposal.updateMarket.minProposerBalance',
'governance.proposal.asset.minProposerBalance',
'governance.proposal.transfer.minProposerBalance',
'governance.proposal.market.minProposerBalance',
'governance.proposal.market.minVoterBalance',
'governance.proposal.updateAsset.minProposerBalance',
'governance.proposal.updateAsset.minVoterBalance',
];
'governance.proposal.referralProgram.minProposerBalance',
'governance.proposal.referralProgram.minVoterBalance',
'governance.proposal.VolumeDiscountProgram.minProposerBalance',
'governance.proposal.VolumeDiscountProgram.minVoterBalance',
].map((p) => p.toLowerCase());
export const renderGroupedParams = (
group: GroupedParams,
@@ -131,12 +151,12 @@ export const NetworkParameterRow = ({
<div className="pb-2">
<SyntaxHighlighter data={JSON.parse(value)} />
</div>
) : BIG_NUMBER_PARAMS.includes(key.toLowerCase()) ? (
addDecimalsFormatNumber(Number(value), 18)
) : PERCENTAGE_PARAMS.includes(key.toLowerCase()) ? (
`${formatNumber(Number(value) * 100, 0)}%`
) : isNaN(Number(value)) ? (
value
) : BIG_NUMBER_PARAMS.includes(key) ? (
addDecimalsFormatNumber(Number(value), 18)
) : PERCENTAGE_PARAMS.includes(key) ? (
`${formatNumber(Number(value) * 100, 0)}%`
) : (
formatNumber(Number(value), 4)
)}
@@ -1,5 +1,5 @@
fragment ExplorerOracleDataConnection on OracleSpec {
dataConnection {
dataConnection(pagination: { first: 30 }) {
edges {
node {
externalData {
@@ -45,6 +45,16 @@ fragment ExplorerOracleDataSource on OracleSpec {
operator
}
}
... on DataSourceSpecConfigurationTimeTrigger {
conditions {
value
operator
}
triggers {
initial
every
}
}
}
}
... on DataSourceDefinitionExternal {
@@ -103,6 +113,23 @@ fragment ExplorerOracleDataSource on OracleSpec {
}
}
}
... on EthCallSpec {
abi
address
requiredConfirmations
method
filters {
key {
type
name
numberDecimalPlaces
}
conditions {
value
operator
}
}
}
}
}
}
@@ -112,7 +139,7 @@ fragment ExplorerOracleDataSource on OracleSpec {
}
query ExplorerOracleSpecs {
oracleSpecsConnection(pagination: { first: 50 }) {
oracleSpecsConnection(pagination: { first: 30 }) {
pageInfo {
hasNextPage
}
@@ -1,22 +1,85 @@
fragment ExplorerOraclePerpetual on Perpetual {
dataSourceSpecForSettlementData {
id
status
}
dataSourceSpecForSettlementSchedule {
id
status
}
}
fragment ExplorerOracleFuture on Future {
dataSourceSpecForSettlementData {
id
status
}
dataSourceSpecForTradingTermination {
id
status
}
}
fragment ExplorerOracleForMarketsMarket on Market {
id
state
tradableInstrument {
instrument {
product {
... on Future {
dataSourceSpecForSettlementData {
id
}
dataSourceSpecForTradingTermination {
id
}
...ExplorerOracleFuture
}
... on Perpetual {
dataSourceSpecForSettlementData {
id
...ExplorerOraclePerpetual
}
}
}
}
}
fragment ExplorerOracleDataSourceSpec on ExternalDataSourceSpec {
spec {
id
status
data {
sourceType {
... on DataSourceDefinitionInternal {
sourceType {
... on DataSourceSpecConfigurationTime {
conditions {
value
operator
}
}
... on DataSourceSpecConfigurationTimeTrigger {
conditions {
value
operator
}
triggers {
initial
every
}
}
}
dataSourceSpecForSettlementSchedule {
id
}
... on DataSourceDefinitionExternal {
sourceType {
... on EthCallSpec {
address
}
... on DataSourceSpecConfiguration {
signers {
signer {
... on ETHAddress {
address
}
... on PubKey {
key
}
}
}
}
}
}
}
@@ -32,4 +95,27 @@ query ExplorerOracleFormMarkets {
}
}
}
oracleSpecsConnection {
edges {
node {
dataSourceSpec {
...ExplorerOracleDataSourceSpec
}
dataConnection(pagination: { last: 1 }) {
edges {
node {
externalData {
data {
data {
name
value
}
}
}
}
}
}
}
}
}
}
+32 -5
View File
@@ -5,23 +5,23 @@ import * as Apollo from '@apollo/client';
const defaultOptions = {} as const;
export type ExplorerOracleDataConnectionFragment = { __typename?: 'OracleSpec', dataConnection: { __typename?: 'OracleDataConnection', edges?: Array<{ __typename?: 'OracleDataEdge', node: { __typename?: 'OracleData', externalData: { __typename?: 'ExternalData', data: { __typename?: 'Data', matchedSpecIds?: Array<string> | null, broadcastAt: any, signers?: Array<{ __typename?: 'Signer', signer: { __typename?: 'ETHAddress', address?: string | null } | { __typename?: 'PubKey', key?: string | null } }> | null, data?: Array<{ __typename?: 'Property', name: string, value: string }> | null } } } } | null> | null } };
export type ExplorerOracleDataSourceFragment = { __typename?: 'OracleSpec', dataSourceSpec: { __typename?: 'ExternalDataSourceSpec', spec: { __typename?: 'DataSourceSpec', id: string, createdAt: any, updatedAt?: any | null, status: Types.DataSourceSpecStatus, data: { __typename?: 'DataSourceDefinition', sourceType: { __typename?: 'DataSourceDefinitionExternal', sourceType: { __typename?: 'DataSourceSpecConfiguration', signers?: Array<{ __typename?: 'Signer', signer: { __typename?: 'ETHAddress', address?: string | null } | { __typename?: 'PubKey', key?: string | null } }> | null, filters?: Array<{ __typename?: 'Filter', key: { __typename?: 'PropertyKey', name?: string | null, type: Types.PropertyKeyType, numberDecimalPlaces?: number | null }, conditions?: Array<{ __typename?: 'Condition', value?: string | null, operator: Types.ConditionOperator }> | null }> | null } | { __typename?: 'EthCallSpec', abi?: Array<string> | null, args?: Array<string> | null, method: string, requiredConfirmations: number, address: string, normalisers?: Array<{ __typename?: 'Normaliser', name: string, expression: string }> | null, trigger: { __typename?: 'EthCallTrigger', trigger: { __typename?: 'EthTimeTrigger', initial?: any | null, every?: number | null, until?: any | null } }, filters?: Array<{ __typename?: 'Filter', key: { __typename?: 'PropertyKey', name?: string | null, type: Types.PropertyKeyType, numberDecimalPlaces?: number | null }, conditions?: Array<{ __typename?: 'Condition', value?: string | null, operator: Types.ConditionOperator }> | null }> | null } } | { __typename?: 'DataSourceDefinitionInternal', sourceType: { __typename?: 'DataSourceSpecConfigurationTime', conditions: Array<{ __typename?: 'Condition', value?: string | null, operator: Types.ConditionOperator } | null> } | { __typename?: 'DataSourceSpecConfigurationTimeTrigger' } } } } }, dataConnection: { __typename?: 'OracleDataConnection', edges?: Array<{ __typename?: 'OracleDataEdge', node: { __typename?: 'OracleData', externalData: { __typename?: 'ExternalData', data: { __typename?: 'Data', matchedSpecIds?: Array<string> | null, broadcastAt: any, signers?: Array<{ __typename?: 'Signer', signer: { __typename?: 'ETHAddress', address?: string | null } | { __typename?: 'PubKey', key?: string | null } }> | null, data?: Array<{ __typename?: 'Property', name: string, value: string }> | null } } } } | null> | null } };
export type ExplorerOracleDataSourceFragment = { __typename?: 'OracleSpec', dataSourceSpec: { __typename?: 'ExternalDataSourceSpec', spec: { __typename?: 'DataSourceSpec', id: string, createdAt: any, updatedAt?: any | null, status: Types.DataSourceSpecStatus, data: { __typename?: 'DataSourceDefinition', sourceType: { __typename?: 'DataSourceDefinitionExternal', sourceType: { __typename?: 'DataSourceSpecConfiguration', signers?: Array<{ __typename?: 'Signer', signer: { __typename?: 'ETHAddress', address?: string | null } | { __typename?: 'PubKey', key?: string | null } }> | null, filters?: Array<{ __typename?: 'Filter', key: { __typename?: 'PropertyKey', name?: string | null, type: Types.PropertyKeyType, numberDecimalPlaces?: number | null }, conditions?: Array<{ __typename?: 'Condition', value?: string | null, operator: Types.ConditionOperator }> | null }> | null } | { __typename?: 'EthCallSpec', abi?: Array<string> | null, args?: Array<string> | null, method: string, requiredConfirmations: number, address: string, normalisers?: Array<{ __typename?: 'Normaliser', name: string, expression: string }> | null, trigger: { __typename?: 'EthCallTrigger', trigger: { __typename?: 'EthTimeTrigger', initial?: any | null, every?: number | null, until?: any | null } }, filters?: Array<{ __typename?: 'Filter', key: { __typename?: 'PropertyKey', name?: string | null, type: Types.PropertyKeyType, numberDecimalPlaces?: number | null }, conditions?: Array<{ __typename?: 'Condition', value?: string | null, operator: Types.ConditionOperator }> | null }> | null } } | { __typename?: 'DataSourceDefinitionInternal', sourceType: { __typename?: 'DataSourceSpecConfigurationTime', conditions: Array<{ __typename?: 'Condition', value?: string | null, operator: Types.ConditionOperator } | null> } | { __typename?: 'DataSourceSpecConfigurationTimeTrigger', conditions: Array<{ __typename?: 'Condition', value?: string | null, operator: Types.ConditionOperator } | null>, triggers: Array<{ __typename?: 'InternalTimeTrigger', initial?: number | null, every?: number | null } | null> } } } } }, dataConnection: { __typename?: 'OracleDataConnection', edges?: Array<{ __typename?: 'OracleDataEdge', node: { __typename?: 'OracleData', externalData: { __typename?: 'ExternalData', data: { __typename?: 'Data', matchedSpecIds?: Array<string> | null, broadcastAt: any, signers?: Array<{ __typename?: 'Signer', signer: { __typename?: 'ETHAddress', address?: string | null } | { __typename?: 'PubKey', key?: string | null } }> | null, data?: Array<{ __typename?: 'Property', name: string, value: string }> | null } } } } | null> | null } };
export type ExplorerOracleSpecsQueryVariables = Types.Exact<{ [key: string]: never; }>;
export type ExplorerOracleSpecsQuery = { __typename?: 'Query', oracleSpecsConnection?: { __typename?: 'OracleSpecsConnection', pageInfo: { __typename?: 'PageInfo', hasNextPage: boolean }, edges?: Array<{ __typename?: 'OracleSpecEdge', node: { __typename?: 'OracleSpec', dataSourceSpec: { __typename?: 'ExternalDataSourceSpec', spec: { __typename?: 'DataSourceSpec', id: string, createdAt: any, updatedAt?: any | null, status: Types.DataSourceSpecStatus, data: { __typename?: 'DataSourceDefinition', sourceType: { __typename?: 'DataSourceDefinitionExternal', sourceType: { __typename?: 'DataSourceSpecConfiguration', signers?: Array<{ __typename?: 'Signer', signer: { __typename?: 'ETHAddress', address?: string | null } | { __typename?: 'PubKey', key?: string | null } }> | null, filters?: Array<{ __typename?: 'Filter', key: { __typename?: 'PropertyKey', name?: string | null, type: Types.PropertyKeyType, numberDecimalPlaces?: number | null }, conditions?: Array<{ __typename?: 'Condition', value?: string | null, operator: Types.ConditionOperator }> | null }> | null } | { __typename?: 'EthCallSpec', abi?: Array<string> | null, args?: Array<string> | null, method: string, requiredConfirmations: number, address: string, normalisers?: Array<{ __typename?: 'Normaliser', name: string, expression: string }> | null, trigger: { __typename?: 'EthCallTrigger', trigger: { __typename?: 'EthTimeTrigger', initial?: any | null, every?: number | null, until?: any | null } }, filters?: Array<{ __typename?: 'Filter', key: { __typename?: 'PropertyKey', name?: string | null, type: Types.PropertyKeyType, numberDecimalPlaces?: number | null }, conditions?: Array<{ __typename?: 'Condition', value?: string | null, operator: Types.ConditionOperator }> | null }> | null } } | { __typename?: 'DataSourceDefinitionInternal', sourceType: { __typename?: 'DataSourceSpecConfigurationTime', conditions: Array<{ __typename?: 'Condition', value?: string | null, operator: Types.ConditionOperator } | null> } | { __typename?: 'DataSourceSpecConfigurationTimeTrigger' } } } } }, dataConnection: { __typename?: 'OracleDataConnection', edges?: Array<{ __typename?: 'OracleDataEdge', node: { __typename?: 'OracleData', externalData: { __typename?: 'ExternalData', data: { __typename?: 'Data', matchedSpecIds?: Array<string> | null, broadcastAt: any, signers?: Array<{ __typename?: 'Signer', signer: { __typename?: 'ETHAddress', address?: string | null } | { __typename?: 'PubKey', key?: string | null } }> | null, data?: Array<{ __typename?: 'Property', name: string, value: string }> | null } } } } | null> | null } } } | null> | null } | null };
export type ExplorerOracleSpecsQuery = { __typename?: 'Query', oracleSpecsConnection?: { __typename?: 'OracleSpecsConnection', pageInfo: { __typename?: 'PageInfo', hasNextPage: boolean }, edges?: Array<{ __typename?: 'OracleSpecEdge', node: { __typename?: 'OracleSpec', dataSourceSpec: { __typename?: 'ExternalDataSourceSpec', spec: { __typename?: 'DataSourceSpec', id: string, createdAt: any, updatedAt?: any | null, status: Types.DataSourceSpecStatus, data: { __typename?: 'DataSourceDefinition', sourceType: { __typename?: 'DataSourceDefinitionExternal', sourceType: { __typename?: 'DataSourceSpecConfiguration', signers?: Array<{ __typename?: 'Signer', signer: { __typename?: 'ETHAddress', address?: string | null } | { __typename?: 'PubKey', key?: string | null } }> | null, filters?: Array<{ __typename?: 'Filter', key: { __typename?: 'PropertyKey', name?: string | null, type: Types.PropertyKeyType, numberDecimalPlaces?: number | null }, conditions?: Array<{ __typename?: 'Condition', value?: string | null, operator: Types.ConditionOperator }> | null }> | null } | { __typename?: 'EthCallSpec', abi?: Array<string> | null, args?: Array<string> | null, method: string, requiredConfirmations: number, address: string, normalisers?: Array<{ __typename?: 'Normaliser', name: string, expression: string }> | null, trigger: { __typename?: 'EthCallTrigger', trigger: { __typename?: 'EthTimeTrigger', initial?: any | null, every?: number | null, until?: any | null } }, filters?: Array<{ __typename?: 'Filter', key: { __typename?: 'PropertyKey', name?: string | null, type: Types.PropertyKeyType, numberDecimalPlaces?: number | null }, conditions?: Array<{ __typename?: 'Condition', value?: string | null, operator: Types.ConditionOperator }> | null }> | null } } | { __typename?: 'DataSourceDefinitionInternal', sourceType: { __typename?: 'DataSourceSpecConfigurationTime', conditions: Array<{ __typename?: 'Condition', value?: string | null, operator: Types.ConditionOperator } | null> } | { __typename?: 'DataSourceSpecConfigurationTimeTrigger', conditions: Array<{ __typename?: 'Condition', value?: string | null, operator: Types.ConditionOperator } | null>, triggers: Array<{ __typename?: 'InternalTimeTrigger', initial?: number | null, every?: number | null } | null> } } } } }, dataConnection: { __typename?: 'OracleDataConnection', edges?: Array<{ __typename?: 'OracleDataEdge', node: { __typename?: 'OracleData', externalData: { __typename?: 'ExternalData', data: { __typename?: 'Data', matchedSpecIds?: Array<string> | null, broadcastAt: any, signers?: Array<{ __typename?: 'Signer', signer: { __typename?: 'ETHAddress', address?: string | null } | { __typename?: 'PubKey', key?: string | null } }> | null, data?: Array<{ __typename?: 'Property', name: string, value: string }> | null } } } } | null> | null } } } | null> | null } | null };
export type ExplorerOracleSpecByIdQueryVariables = Types.Exact<{
id: Types.Scalars['ID'];
}>;
export type ExplorerOracleSpecByIdQuery = { __typename?: 'Query', oracleSpec?: { __typename?: 'OracleSpec', dataSourceSpec: { __typename?: 'ExternalDataSourceSpec', spec: { __typename?: 'DataSourceSpec', id: string, createdAt: any, updatedAt?: any | null, status: Types.DataSourceSpecStatus, data: { __typename?: 'DataSourceDefinition', sourceType: { __typename?: 'DataSourceDefinitionExternal', sourceType: { __typename?: 'DataSourceSpecConfiguration', signers?: Array<{ __typename?: 'Signer', signer: { __typename?: 'ETHAddress', address?: string | null } | { __typename?: 'PubKey', key?: string | null } }> | null, filters?: Array<{ __typename?: 'Filter', key: { __typename?: 'PropertyKey', name?: string | null, type: Types.PropertyKeyType, numberDecimalPlaces?: number | null }, conditions?: Array<{ __typename?: 'Condition', value?: string | null, operator: Types.ConditionOperator }> | null }> | null } | { __typename?: 'EthCallSpec', abi?: Array<string> | null, args?: Array<string> | null, method: string, requiredConfirmations: number, address: string, normalisers?: Array<{ __typename?: 'Normaliser', name: string, expression: string }> | null, trigger: { __typename?: 'EthCallTrigger', trigger: { __typename?: 'EthTimeTrigger', initial?: any | null, every?: number | null, until?: any | null } }, filters?: Array<{ __typename?: 'Filter', key: { __typename?: 'PropertyKey', name?: string | null, type: Types.PropertyKeyType, numberDecimalPlaces?: number | null }, conditions?: Array<{ __typename?: 'Condition', value?: string | null, operator: Types.ConditionOperator }> | null }> | null } } | { __typename?: 'DataSourceDefinitionInternal', sourceType: { __typename?: 'DataSourceSpecConfigurationTime', conditions: Array<{ __typename?: 'Condition', value?: string | null, operator: Types.ConditionOperator } | null> } | { __typename?: 'DataSourceSpecConfigurationTimeTrigger' } } } } }, dataConnection: { __typename?: 'OracleDataConnection', edges?: Array<{ __typename?: 'OracleDataEdge', node: { __typename?: 'OracleData', externalData: { __typename?: 'ExternalData', data: { __typename?: 'Data', matchedSpecIds?: Array<string> | null, broadcastAt: any, signers?: Array<{ __typename?: 'Signer', signer: { __typename?: 'ETHAddress', address?: string | null } | { __typename?: 'PubKey', key?: string | null } }> | null, data?: Array<{ __typename?: 'Property', name: string, value: string }> | null } } } } | null> | null } } | null };
export type ExplorerOracleSpecByIdQuery = { __typename?: 'Query', oracleSpec?: { __typename?: 'OracleSpec', dataSourceSpec: { __typename?: 'ExternalDataSourceSpec', spec: { __typename?: 'DataSourceSpec', id: string, createdAt: any, updatedAt?: any | null, status: Types.DataSourceSpecStatus, data: { __typename?: 'DataSourceDefinition', sourceType: { __typename?: 'DataSourceDefinitionExternal', sourceType: { __typename?: 'DataSourceSpecConfiguration', signers?: Array<{ __typename?: 'Signer', signer: { __typename?: 'ETHAddress', address?: string | null } | { __typename?: 'PubKey', key?: string | null } }> | null, filters?: Array<{ __typename?: 'Filter', key: { __typename?: 'PropertyKey', name?: string | null, type: Types.PropertyKeyType, numberDecimalPlaces?: number | null }, conditions?: Array<{ __typename?: 'Condition', value?: string | null, operator: Types.ConditionOperator }> | null }> | null } | { __typename?: 'EthCallSpec', abi?: Array<string> | null, args?: Array<string> | null, method: string, requiredConfirmations: number, address: string, normalisers?: Array<{ __typename?: 'Normaliser', name: string, expression: string }> | null, trigger: { __typename?: 'EthCallTrigger', trigger: { __typename?: 'EthTimeTrigger', initial?: any | null, every?: number | null, until?: any | null } }, filters?: Array<{ __typename?: 'Filter', key: { __typename?: 'PropertyKey', name?: string | null, type: Types.PropertyKeyType, numberDecimalPlaces?: number | null }, conditions?: Array<{ __typename?: 'Condition', value?: string | null, operator: Types.ConditionOperator }> | null }> | null } } | { __typename?: 'DataSourceDefinitionInternal', sourceType: { __typename?: 'DataSourceSpecConfigurationTime', conditions: Array<{ __typename?: 'Condition', value?: string | null, operator: Types.ConditionOperator } | null> } | { __typename?: 'DataSourceSpecConfigurationTimeTrigger', conditions: Array<{ __typename?: 'Condition', value?: string | null, operator: Types.ConditionOperator } | null>, triggers: Array<{ __typename?: 'InternalTimeTrigger', initial?: number | null, every?: number | null } | null> } } } } }, dataConnection: { __typename?: 'OracleDataConnection', edges?: Array<{ __typename?: 'OracleDataEdge', node: { __typename?: 'OracleData', externalData: { __typename?: 'ExternalData', data: { __typename?: 'Data', matchedSpecIds?: Array<string> | null, broadcastAt: any, signers?: Array<{ __typename?: 'Signer', signer: { __typename?: 'ETHAddress', address?: string | null } | { __typename?: 'PubKey', key?: string | null } }> | null, data?: Array<{ __typename?: 'Property', name: string, value: string }> | null } } } } | null> | null } } | null };
export const ExplorerOracleDataConnectionFragmentDoc = gql`
fragment ExplorerOracleDataConnection on OracleSpec {
dataConnection {
dataConnection(pagination: {first: 30}) {
edges {
node {
externalData {
@@ -68,6 +68,16 @@ export const ExplorerOracleDataSourceFragmentDoc = gql`
operator
}
}
... on DataSourceSpecConfigurationTimeTrigger {
conditions {
value
operator
}
triggers {
initial
every
}
}
}
}
... on DataSourceDefinitionExternal {
@@ -126,6 +136,23 @@ export const ExplorerOracleDataSourceFragmentDoc = gql`
}
}
}
... on EthCallSpec {
abi
address
requiredConfirmations
method
filters {
key {
type
name
numberDecimalPlaces
}
conditions {
value
operator
}
}
}
}
}
}
@@ -136,7 +163,7 @@ export const ExplorerOracleDataSourceFragmentDoc = gql`
${ExplorerOracleDataConnectionFragmentDoc}`;
export const ExplorerOracleSpecsDocument = gql`
query ExplorerOracleSpecs {
oracleSpecsConnection(pagination: {first: 50}) {
oracleSpecsConnection(pagination: {first: 30}) {
pageInfo {
hasNextPage
}
@@ -3,33 +3,106 @@ import * as Types from '@vegaprotocol/types';
import { gql } from '@apollo/client';
import * as Apollo from '@apollo/client';
const defaultOptions = {} as const;
export type ExplorerOracleForMarketsMarketFragment = { __typename?: 'Market', id: string, tradableInstrument: { __typename?: 'TradableInstrument', instrument: { __typename?: 'Instrument', product: { __typename?: 'Future', dataSourceSpecForSettlementData: { __typename?: 'DataSourceSpec', id: string }, dataSourceSpecForTradingTermination: { __typename?: 'DataSourceSpec', id: string } } | { __typename?: 'Perpetual', dataSourceSpecForSettlementData: { __typename?: 'DataSourceSpec', id: string }, dataSourceSpecForSettlementSchedule: { __typename?: 'DataSourceSpec', id: string } } | { __typename?: 'Spot' } } } };
export type ExplorerOraclePerpetualFragment = { __typename?: 'Perpetual', dataSourceSpecForSettlementData: { __typename?: 'DataSourceSpec', id: string, status: Types.DataSourceSpecStatus }, dataSourceSpecForSettlementSchedule: { __typename?: 'DataSourceSpec', id: string, status: Types.DataSourceSpecStatus } };
export type ExplorerOracleFutureFragment = { __typename?: 'Future', dataSourceSpecForSettlementData: { __typename?: 'DataSourceSpec', id: string, status: Types.DataSourceSpecStatus }, dataSourceSpecForTradingTermination: { __typename?: 'DataSourceSpec', id: string, status: Types.DataSourceSpecStatus } };
export type ExplorerOracleForMarketsMarketFragment = { __typename?: 'Market', id: string, state: Types.MarketState, tradableInstrument: { __typename?: 'TradableInstrument', instrument: { __typename?: 'Instrument', product: { __typename?: 'Future', dataSourceSpecForSettlementData: { __typename?: 'DataSourceSpec', id: string, status: Types.DataSourceSpecStatus }, dataSourceSpecForTradingTermination: { __typename?: 'DataSourceSpec', id: string, status: Types.DataSourceSpecStatus } } | { __typename?: 'Perpetual', dataSourceSpecForSettlementData: { __typename?: 'DataSourceSpec', id: string, status: Types.DataSourceSpecStatus }, dataSourceSpecForSettlementSchedule: { __typename?: 'DataSourceSpec', id: string, status: Types.DataSourceSpecStatus } } | { __typename?: 'Spot' } } } };
export type ExplorerOracleDataSourceSpecFragment = { __typename?: 'ExternalDataSourceSpec', spec: { __typename?: 'DataSourceSpec', id: string, status: Types.DataSourceSpecStatus, data: { __typename?: 'DataSourceDefinition', sourceType: { __typename?: 'DataSourceDefinitionExternal', sourceType: { __typename?: 'DataSourceSpecConfiguration', signers?: Array<{ __typename?: 'Signer', signer: { __typename?: 'ETHAddress', address?: string | null } | { __typename?: 'PubKey', key?: string | null } }> | null } | { __typename?: 'EthCallSpec', address: string } } | { __typename?: 'DataSourceDefinitionInternal', sourceType: { __typename?: 'DataSourceSpecConfigurationTime', conditions: Array<{ __typename?: 'Condition', value?: string | null, operator: Types.ConditionOperator } | null> } | { __typename?: 'DataSourceSpecConfigurationTimeTrigger', conditions: Array<{ __typename?: 'Condition', value?: string | null, operator: Types.ConditionOperator } | null>, triggers: Array<{ __typename?: 'InternalTimeTrigger', initial?: number | null, every?: number | null } | null> } } } } };
export type ExplorerOracleFormMarketsQueryVariables = Types.Exact<{ [key: string]: never; }>;
export type ExplorerOracleFormMarketsQuery = { __typename?: 'Query', marketsConnection?: { __typename?: 'MarketConnection', edges: Array<{ __typename?: 'MarketEdge', node: { __typename?: 'Market', id: string, tradableInstrument: { __typename?: 'TradableInstrument', instrument: { __typename?: 'Instrument', product: { __typename?: 'Future', dataSourceSpecForSettlementData: { __typename?: 'DataSourceSpec', id: string }, dataSourceSpecForTradingTermination: { __typename?: 'DataSourceSpec', id: string } } | { __typename?: 'Perpetual', dataSourceSpecForSettlementData: { __typename?: 'DataSourceSpec', id: string }, dataSourceSpecForSettlementSchedule: { __typename?: 'DataSourceSpec', id: string } } | { __typename?: 'Spot' } } } } }> } | null };
export type ExplorerOracleFormMarketsQuery = { __typename?: 'Query', marketsConnection?: { __typename?: 'MarketConnection', edges: Array<{ __typename?: 'MarketEdge', node: { __typename?: 'Market', id: string, state: Types.MarketState, tradableInstrument: { __typename?: 'TradableInstrument', instrument: { __typename?: 'Instrument', product: { __typename?: 'Future', dataSourceSpecForSettlementData: { __typename?: 'DataSourceSpec', id: string, status: Types.DataSourceSpecStatus }, dataSourceSpecForTradingTermination: { __typename?: 'DataSourceSpec', id: string, status: Types.DataSourceSpecStatus } } | { __typename?: 'Perpetual', dataSourceSpecForSettlementData: { __typename?: 'DataSourceSpec', id: string, status: Types.DataSourceSpecStatus }, dataSourceSpecForSettlementSchedule: { __typename?: 'DataSourceSpec', id: string, status: Types.DataSourceSpecStatus } } | { __typename?: 'Spot' } } } } }> } | null, oracleSpecsConnection?: { __typename?: 'OracleSpecsConnection', edges?: Array<{ __typename?: 'OracleSpecEdge', node: { __typename?: 'OracleSpec', dataSourceSpec: { __typename?: 'ExternalDataSourceSpec', spec: { __typename?: 'DataSourceSpec', id: string, status: Types.DataSourceSpecStatus, data: { __typename?: 'DataSourceDefinition', sourceType: { __typename?: 'DataSourceDefinitionExternal', sourceType: { __typename?: 'DataSourceSpecConfiguration', signers?: Array<{ __typename?: 'Signer', signer: { __typename?: 'ETHAddress', address?: string | null } | { __typename?: 'PubKey', key?: string | null } }> | null } | { __typename?: 'EthCallSpec', address: string } } | { __typename?: 'DataSourceDefinitionInternal', sourceType: { __typename?: 'DataSourceSpecConfigurationTime', conditions: Array<{ __typename?: 'Condition', value?: string | null, operator: Types.ConditionOperator } | null> } | { __typename?: 'DataSourceSpecConfigurationTimeTrigger', conditions: Array<{ __typename?: 'Condition', value?: string | null, operator: Types.ConditionOperator } | null>, triggers: Array<{ __typename?: 'InternalTimeTrigger', initial?: number | null, every?: number | null } | null> } } } } }, dataConnection: { __typename?: 'OracleDataConnection', edges?: Array<{ __typename?: 'OracleDataEdge', node: { __typename?: 'OracleData', externalData: { __typename?: 'ExternalData', data: { __typename?: 'Data', data?: Array<{ __typename?: 'Property', name: string, value: string }> | null } } } } | null> | null } } } | null> | null } | null };
export const ExplorerOracleFutureFragmentDoc = gql`
fragment ExplorerOracleFuture on Future {
dataSourceSpecForSettlementData {
id
status
}
dataSourceSpecForTradingTermination {
id
status
}
}
`;
export const ExplorerOraclePerpetualFragmentDoc = gql`
fragment ExplorerOraclePerpetual on Perpetual {
dataSourceSpecForSettlementData {
id
status
}
dataSourceSpecForSettlementSchedule {
id
status
}
}
`;
export const ExplorerOracleForMarketsMarketFragmentDoc = gql`
fragment ExplorerOracleForMarketsMarket on Market {
id
state
tradableInstrument {
instrument {
product {
... on Future {
dataSourceSpecForSettlementData {
id
}
dataSourceSpecForTradingTermination {
id
}
...ExplorerOracleFuture
}
... on Perpetual {
dataSourceSpecForSettlementData {
id
...ExplorerOraclePerpetual
}
}
}
}
}
${ExplorerOracleFutureFragmentDoc}
${ExplorerOraclePerpetualFragmentDoc}`;
export const ExplorerOracleDataSourceSpecFragmentDoc = gql`
fragment ExplorerOracleDataSourceSpec on ExternalDataSourceSpec {
spec {
id
status
data {
sourceType {
... on DataSourceDefinitionInternal {
sourceType {
... on DataSourceSpecConfigurationTime {
conditions {
value
operator
}
}
... on DataSourceSpecConfigurationTimeTrigger {
conditions {
value
operator
}
triggers {
initial
every
}
}
}
dataSourceSpecForSettlementSchedule {
id
}
... on DataSourceDefinitionExternal {
sourceType {
... on EthCallSpec {
address
}
... on DataSourceSpecConfiguration {
signers {
signer {
... on ETHAddress {
address
}
... on PubKey {
key
}
}
}
}
}
}
}
@@ -46,8 +119,32 @@ export const ExplorerOracleFormMarketsDocument = gql`
}
}
}
oracleSpecsConnection {
edges {
node {
dataSourceSpec {
...ExplorerOracleDataSourceSpec
}
dataConnection(pagination: {last: 1}) {
edges {
node {
externalData {
data {
data {
name
value
}
}
}
}
}
}
}
}
}
}
${ExplorerOracleForMarketsMarketFragmentDoc}`;
${ExplorerOracleForMarketsMarketFragmentDoc}
${ExplorerOracleDataSourceSpecFragmentDoc}`;
/**
* __useExplorerOracleFormMarketsQuery__
@@ -51,15 +51,26 @@ describe('Oracle Data view', () => {
{
node: {
externalData: {
__typename: 'ExternalData',
data: {
broadcastAt: '2022-01-01',
__typename: 'Data',
matchedSpecIds: ['123'],
broadcastAt: '2023-01-01T00:00:00Z',
data: [
{
__typename: 'Property',
name: 'Test-name',
value: 'Test-data',
},
],
},
},
},
},
],
} as DataConnection)
} as ExplorerOracleDataConnectionFragment['dataConnection'])
);
expect(res.getByText('Broadcast data')).toBeInTheDocument();
expect(res.getByText('Test-name')).toBeInTheDocument();
expect(res.getByText('Test-data')).toBeInTheDocument();
});
});
@@ -1,39 +1,55 @@
import { t } from '@vegaprotocol/i18n';
import { SyntaxHighlighter } from '@vegaprotocol/ui-toolkit';
import filter from 'recursive-key-filter';
import type { ExplorerOracleDataConnectionFragment } from '../__generated__/Oracles';
import { TimeAgo } from '../../../components/time-ago';
import { t } from '@vegaprotocol/i18n';
const cellSpacing = 'px-3';
interface OracleDataTypeProps {
data: ExplorerOracleDataConnectionFragment['dataConnection'];
}
/**
* If there is data that has matched this oracle, this view will
* render the data inside a collapsed element so that it can be viewed.
* Currently the data is just rendered as a JSON view, because
* that Does The Job, rather than because it's good.
*/
export function OracleData({ data }: OracleDataTypeProps) {
if (!data || !data.edges?.length || data.edges.length > 1) {
if (!data || !data.edges?.length) {
return null;
}
return (
<details data-testid="oracle-data">
<summary>{t('Broadcast data')}</summary>
<ul>
{data.edges.map((d) => {
if (!d) {
return null;
}
<>
<h2 className="text-3xl font-bold mb-4 display-5 mt-5">
{t('Recent data')}
</h2>
<table>
<thead>
<tr className="text-left">
<th className={cellSpacing}>Value</th>
<th className={cellSpacing}>Key</th>
<th className={cellSpacing}>Date</th>
</tr>
</thead>
<tbody>
{data.edges.map((d) => {
if (!d) {
return null;
}
return (
<li key={d.node.externalData.data.broadcastAt}>
<SyntaxHighlighter data={filter(d, ['__typename'])} />
</li>
);
})}
</ul>
</details>
const broadcastAt = d.node.externalData.data.broadcastAt;
const node = d.node.externalData.data.data?.at(0);
if (!node || !node.value || !node.name || !node || !broadcastAt) {
return null;
}
return (
<tr key={d.node.externalData.data.broadcastAt}>
<td className={`${cellSpacing} font-mono`}>{node.value}</td>
<td className={`${cellSpacing} font-mono`}>{node.name}</td>
<td className={cellSpacing}>
<TimeAgo date={broadcastAt} />
</td>
</tr>
);
})}
</tbody>
</table>
</>
);
}
@@ -55,13 +55,13 @@ describe('Oracle type view', () => {
const s = mock('prices.external.whatever');
expect(isInternalSourceType(s)).toEqual(false);
const res = render(renderWrappedComponent(s));
expect(res.getByText('External data')).toBeInTheDocument();
expect(res.getByText('External Data')).toBeInTheDocument();
});
it('Renders External data otherwise', () => {
const s = mock('prices.external.vegaprotocol.builtin.');
expect(isInternalSourceType(s)).toEqual(false);
const res = render(renderWrappedComponent(s));
expect(res.getByText('External data')).toBeInTheDocument();
expect(res.getByText('External Data')).toBeInTheDocument();
});
});
@@ -26,6 +26,19 @@ export function isInternalSourceType(s: SourceType) {
return false;
}
export function getExternalType(s: SourceType) {
if (s.sourceType.__typename === 'EthCallSpec') {
return 'Ethereum Contract Call';
} else {
return 'External Data';
}
}
export function getTypeString(s: SourceType) {
const isInternal = isInternalSourceType(s);
return isInternal ? 'Internal data' : getExternalType(s);
}
interface OracleDetailsTypeProps {
sourceType: SourceType;
}
@@ -39,14 +52,10 @@ export function OracleDetailsType({ sourceType }: OracleDetailsTypeProps) {
return null;
}
const isInternal = isInternalSourceType(sourceType);
return (
<TableRow modifier="bordered">
<TableHeader scope="row">Type</TableHeader>
<TableCell modifier="bordered">
{isInternal ? 'Internal data' : 'External data'}
</TableCell>
<TableCell modifier="bordered">{getTypeString(sourceType)}</TableCell>
</TableRow>
);
}
@@ -0,0 +1,39 @@
import { TableRow, TableCell, TableHeader } from '../../../components/table';
import type { SourceType } from './oracle';
import {
EthExplorerLink,
EthExplorerLinkTypes,
} from '../../../components/links/eth-explorer-link/eth-explorer-link';
interface OracleDetailsEthSourceProps {
sourceType: SourceType;
}
/**
* Given an Oracle that sources data from Ethereum, this component will render
* a link to the smart contract and some basic details
*/
export function OracleEthSource({ sourceType }: OracleDetailsEthSourceProps) {
if (
sourceType.__typename !== 'DataSourceDefinitionExternal' ||
sourceType.sourceType.__typename !== 'EthCallSpec'
) {
return null;
}
const address = sourceType.sourceType.address;
if (!address) {
return null;
}
return (
<TableRow modifier="bordered">
<TableHeader scope="row">Ethereum Contract</TableHeader>
<TableCell modifier="bordered">
<EthExplorerLink id={address} type={EthExplorerLinkTypes.address} />
<span className="mx-3">&rArr;</span>
<code>{sourceType.sourceType.method}</code>
</TableCell>
</TableRow>
);
}
@@ -1,14 +1,17 @@
import { render } from '@testing-library/react';
import { getConditionsOrFilters, OracleFilter } from './oracle-filter';
import type { Filter } from './oracle-filter';
import { OracleFilter } from './oracle-filter';
import type { ExplorerOracleDataSourceFragment } from '../__generated__/Oracles';
import {
ConditionOperator,
DataSourceSpecStatus,
PropertyKeyType,
} from '@vegaprotocol/types';
import type { Condition } from '@vegaprotocol/types';
const mockExternalSpec = {
type Spec =
ExplorerOracleDataSourceFragment['dataSourceSpec']['spec']['data']['sourceType'];
const mockExternalSpec: Spec = {
sourceType: {
__typename: 'DataSourceSpecConfiguration',
filters: [
@@ -16,12 +19,12 @@ const mockExternalSpec = {
__typename: 'Filter',
key: {
type: PropertyKeyType.TYPE_INTEGER,
name: 'test',
name: 'testKey',
},
conditions: [
{
__typename: 'Condition',
value: 'test',
value: 'testValue',
operator: ConditionOperator.OPERATOR_EQUALS,
},
],
@@ -30,16 +33,6 @@ const mockExternalSpec = {
},
};
const mockTimeSpec = {
__typename: 'DataSourceSpecConfigurationTime',
conditions: [
{
value: '123',
operator: ConditionOperator.OPERATOR_EQUALS,
},
],
};
function renderComponent(data: ExplorerOracleDataSourceFragment) {
return <OracleFilter data={data} />;
}
@@ -70,11 +63,16 @@ describe('Oracle Filter view', () => {
},
},
},
} as ExplorerOracleDataSourceFragment)
dataConnection: {
edges: [],
},
})
);
expect(res.getByText('Filter')).toBeInTheDocument();
// Avoids asserting on how the data is presented because it is very rudimentary
// Renders a comprehensible summary of key = value
expect(res.getByText('testKey')).toBeInTheDocument();
expect(res.getByText('=')).toBeInTheDocument();
expect(res.getByText('testValue')).toBeInTheDocument();
});
it('Renders conditions if type is DataSourceSpecConfigurationTime', () => {
@@ -88,75 +86,59 @@ describe('Oracle Filter view', () => {
data: {
sourceType: {
__typename: 'DataSourceDefinitionInternal',
sourceType: mockTimeSpec,
sourceType: {
__typename: 'DataSourceSpecConfigurationTime',
conditions: [
{
value: '1',
operator: ConditionOperator.OPERATOR_EQUALS,
},
],
},
},
},
},
},
} as ExplorerOracleDataSourceFragment)
dataConnection: {
edges: [],
},
})
);
expect(res.getByText('Filter')).toBeInTheDocument();
expect(res.getByText('Time')).toBeInTheDocument();
expect(res.getByText('=')).toBeInTheDocument();
expect(res.getByTitle('1').textContent).toMatch(/1970/);
// Avoids asserting on how the data is presented because it is very rudimentary
});
});
describe('getConditionsOrFilter', () => {
it('Returns null if the type is undetermined (not DataSourceSpecConfiguration or DataSourceSpecConfigurationTime', () => {
expect(getConditionsOrFilters({})).toBeNull();
});
it('Returns the conditions object for time specs', () => {
const mock: Filter = {
__typename: 'DataSourceSpecConfigurationTime',
conditions: [
{
__typename: 'Condition',
value: '100',
operator: ConditionOperator.OPERATOR_GREATER_THAN,
},
],
};
const res = getConditionsOrFilters(mock);
// This ugly construction is due to lazy typing on getConditionsOrFilter
if (!res || res.length !== 1 || !res[0] || 'key' in res[0]) {
throw new Error(
'getConditionsOrFilter did not return conditions on a time spec'
);
}
expect(res[0].__typename).toEqual('Condition');
});
it('Returns the filters object for external specs', () => {
const mock: Filter = {
__typename: 'DataSourceSpecConfiguration',
filters: [
{
__typename: 'Filter',
key: {
type: PropertyKeyType.TYPE_INTEGER,
name: 'test',
},
conditions: [
{
__typename: 'Condition',
value: 'test',
operator: ConditionOperator.OPERATOR_EQUALS,
it('DataSourceSpecConfigurationTime handles empty conditions', () => {
const res = render(
renderComponent({
dataSourceSpec: {
spec: {
id: 'irrelevant-test-data',
createdAt: 'irrelevant-test-data',
status: DataSourceSpecStatus.STATUS_ACTIVE,
data: {
sourceType: {
__typename: 'DataSourceDefinitionInternal',
sourceType: {
__typename: 'DataSourceSpecConfigurationTime',
conditions: [undefined as unknown as Condition],
},
},
},
],
},
},
],
};
dataConnection: {
edges: [],
},
})
);
const res = getConditionsOrFilters(mock);
// This ugly construction is due to lazy typing on getConditionsOrFilter
if (!res || res.length !== 1 || !res[0] || 'value' in res[0]) {
throw new Error(
'getConditionsOrFilter did not return filters on a external spec'
);
}
expect(res[0].__typename).toEqual('Filter');
// This should never happen, but for coverage sake we test that it does this
const ul = res.getByRole('list');
expect(ul).toBeInTheDocument();
expect(ul).toBeEmptyDOMElement();
});
});
@@ -1,36 +1,15 @@
import { t } from '@vegaprotocol/i18n';
import { SyntaxHighlighter } from '@vegaprotocol/ui-toolkit';
import filter from 'recursive-key-filter';
import type { ExplorerOracleDataSourceFragment } from '../__generated__/Oracles';
import { OracleSpecInternalTimeTrigger } from './oracle-spec/internal-time-trigger';
import { OracleSpecCondition } from './oracle-spec/condition';
import { getCharacterForOperator } from './oracle-spec/operator';
interface OracleFilterProps {
data: ExplorerOracleDataSourceFragment;
}
export type Filter =
ExplorerOracleDataSourceFragment['dataSourceSpec']['spec']['data']['sourceType']['sourceType'];
/**
* Given the main Filter view just uses a JSON dump view, this function
* selects the correct filter to dump in to that view. Internal oracles
* (i.e. the Time oracle) have conditions while external data sources
* have filters
*
* @param s A data source
* @returns Object an object containing conditions or filters
*/
export function getConditionsOrFilters(s: Filter) {
if (s.__typename === 'DataSourceSpecConfiguration') {
return s.filters;
} else if (s.__typename === 'DataSourceSpecConfigurationTime') {
return s.conditions;
}
return null;
}
/**
* Shows the conditions that this oracle is using to filter
* data sources.
* data sources, as a list.
*
* Renders nothing if there is no data (which will frequently)
* be the case) and if there is data, currently renders a simple
@@ -42,16 +21,53 @@ export function OracleFilter({ data }: OracleFilterProps) {
}
const s = data.dataSourceSpec.spec.data.sourceType.sourceType;
const f = getConditionsOrFilters(s);
if (s.__typename === 'DataSourceSpecConfigurationTime' && s.conditions) {
return (
<ul>
{s.conditions
.filter((c) => !!c)
.map((c) => {
if (!c) {
return null;
}
return (
<OracleSpecCondition key={c.value} data={c} type={s.__typename} />
);
})}
</ul>
);
} else if (
s.__typename === 'DataSourceSpecConfigurationTimeTrigger' &&
s.triggers
) {
return <OracleSpecInternalTimeTrigger data={s} />;
} else if (
s.__typename === 'EthCallSpec' ||
s.__typename === 'DataSourceSpecConfiguration'
) {
if (s.filters !== null && s.filters && 'filters' in s) {
return (
<ul>
{s.filters.map((f) => {
const prop = <code title={f.key.type}>{f.key.name}</code>;
if (!f) {
return null;
if (!f.conditions || f.conditions.length === 0) {
return prop;
} else {
return f.conditions.map((c) => {
return (
<li key={`${prop}${c.value}`}>
{prop} {getCharacterForOperator(c.operator)}{' '}
<code>{c.value ? c.value : '-'}</code>
</li>
);
});
}
})}
</ul>
);
}
}
return (
<details>
<summary>{t('Filter')}</summary>
<SyntaxHighlighter data={filter(f, ['__typename'])} />
</details>
);
return null;
}
@@ -11,7 +11,7 @@ function renderComponent(id: string, mocks: MockedResponse[]) {
<MemoryRouter>
<MockedProvider mocks={mocks}>
<Table>
<tbody>
<tbody data-testid="wrapper">
<OracleMarkets id={id} />
</tbody>
</Table>
@@ -23,8 +23,7 @@ function renderComponent(id: string, mocks: MockedResponse[]) {
describe('Oracle Markets component', () => {
it('Renders a row with the market ID initially', () => {
const res = render(renderComponent('123', []));
expect(res.getByText('Market')).toBeInTheDocument();
expect(res.getByText('123')).toBeInTheDocument();
expect(res.getByTestId('wrapper')).toBeEmptyDOMElement();
});
it('Renders that this is a termination source for the right market', async () => {
@@ -34,21 +33,58 @@ describe('Oracle Markets component', () => {
},
result: {
data: {
oracleSpecsConnection: {
edges: [
{
node: {
dataConnection: {
edges: [
{
node: {
externalData: {
data: {
data: {
name: '123',
value: '456',
},
},
},
},
},
],
},
dataSourceSpec: {
spec: {
id: '789',
state: 'Active',
status: 'Active',
data: {
sourceType: {},
},
},
},
},
},
],
},
marketsConnection: {
edges: [
{
node: {
__typename: 'Market',
id: '123',
state: 'Active',
tradableInstrument: {
instrument: {
product: {
__typename: 'Future',
dataSourceSpecForSettlementData: {
id: '456',
status: 'Active',
},
dataSourceSpecForTradingTermination: {
id: '789',
status: 'Active',
},
},
},
@@ -59,15 +95,18 @@ describe('Oracle Markets component', () => {
node: {
__typename: 'Market',
id: 'abc',
state: 'Active',
tradableInstrument: {
instrument: {
product: {
__typename: 'Future',
dataSourceSpecForSettlementData: {
id: 'def',
status: 'Active',
},
dataSourceSpecForTradingTermination: {
id: 'ghi',
status: 'Active',
},
},
},
@@ -91,21 +130,58 @@ describe('Oracle Markets component', () => {
},
result: {
data: {
oracleSpecsConnection: {
edges: [
{
node: {
dataConnection: {
edges: [
{
node: {
externalData: {
data: {
data: {
name: '123',
value: '456',
},
},
},
},
},
],
},
dataSourceSpec: {
spec: {
id: '789',
state: 'Active',
status: 'Active',
data: {
sourceType: {},
},
},
},
},
},
],
},
marketsConnection: {
edges: [
{
node: {
__typename: 'Market',
id: '123',
state: 'Active',
tradableInstrument: {
instrument: {
product: {
__typename: 'Future',
dataSourceSpecForSettlementData: {
id: '789',
status: 'Active',
},
dataSourceSpecForTradingTermination: {
id: '123',
status: 'Active',
},
},
},
@@ -116,15 +192,18 @@ describe('Oracle Markets component', () => {
node: {
__typename: 'Market',
id: 'abc',
state: 'Active',
tradableInstrument: {
instrument: {
product: {
__typename: 'Future',
dataSourceSpecForSettlementData: {
id: 'def',
status: 'Active',
},
dataSourceSpecForTradingTermination: {
id: 'ghi',
status: 'Active',
},
},
},
@@ -1,5 +1,4 @@
import { getNodes } from '@vegaprotocol/utils';
import { t } from '@vegaprotocol/i18n';
import { MarketLink } from '../../../components/links';
import { TableRow, TableCell, TableHeader } from '../../../components/table';
import type { ExplorerOracleForMarketsMarketFragment } from '../__generated__/OraclesForMarkets';
@@ -24,38 +23,37 @@ export function OracleMarkets({ id }: OracleMarketsProps) {
);
if (markets) {
const m = markets.find((m) => {
const p = m.tradableInstrument.instrument.product;
const m = markets.filter((market) => {
const p = market.tradableInstrument.instrument.product;
if (
((p.__typename === 'Future' || p.__typename === 'Perpetual') &&
p.dataSourceSpecForSettlementData.id === id) ||
('dataSourceSpecForTradingTermination' in p &&
p.dataSourceSpecForTradingTermination.id === id)
p.dataSourceSpecForTradingTermination.id === id) ||
(p.__typename === 'Perpetual' &&
p.dataSourceSpecForSettlementSchedule.id === id)
) {
return true;
}
return false;
});
if (m && m.id) {
if (m && m.length > 0) {
return (
<TableRow modifier="bordered">
<TableHeader scope="row">{getLabel(id, m)}</TableHeader>
<TableCell modifier="bordered" data-testid={`m-${m.id}`}>
<MarketLink id={m.id} />
</TableCell>
</TableRow>
<>
{m.map((market) => (
<TableRow modifier="bordered" key={`m-${market.id}`}>
<TableHeader scope="row">{getLabel(id, market)}</TableHeader>
<TableCell modifier="bordered" data-testid={`m-${market.id}`}>
<MarketLink id={market.id} />
</TableCell>
</TableRow>
))}
</>
);
}
}
return (
<TableRow modifier="bordered">
<TableHeader scope="row">{t('Market')}</TableHeader>
<TableCell modifier="bordered">
<span>{id}</span>
</TableCell>
</TableRow>
);
return null;
}
export function getLabel(
@@ -0,0 +1,37 @@
import { t } from '@vegaprotocol/i18n';
import fromUnixTime from 'date-fns/fromUnixTime';
import { getCharacterForOperator } from './operator';
import type { Condition } from '@vegaprotocol/types';
export interface OracleSpecCondition {
data: Condition;
type?: string;
}
export function OracleSpecCondition({ data, type }: OracleSpecCondition) {
const c = getCharacterForOperator(data.operator);
const value =
type === 'DataSourceSpecConfigurationTime' && data.value
? fromUnixTime(parseInt(data.value)).toLocaleString()
: data.value;
const typeLabel =
type === 'DataSourceSpecConfigurationTime' ? (
<span>{t('Time')}</span>
) : (
type
);
return (
<li key={`${typeLabel}${c}${value}`}>
{typeLabel} {c}{' '}
{value && (
<span
title={data.value || value}
className="underline decoration-dotted"
>
{value}
</span>
)}
</li>
);
}
@@ -0,0 +1,44 @@
import { t } from '@vegaprotocol/i18n';
import type { DataSourceSpecConfigurationTimeTrigger } from '@vegaprotocol/types';
import secondsToMinutes from 'date-fns/secondsToMinutes';
import fromUnixTime from 'date-fns/fromUnixTime';
export interface OracleSpecInternalTimeTriggerProps {
data: DataSourceSpecConfigurationTimeTrigger;
}
export function OracleSpecInternalTimeTrigger({
data,
}: OracleSpecInternalTimeTriggerProps) {
return (
<div>
<span>{t('Time')}</span>,&nbsp;
{data.triggers.map((tr) => {
return (
<span>
{tr?.initial ? (
<span title={`${tr.initial}`}>
<strong>{t('starting at')}</strong>{' '}
<em className="not-italic underline decoration-dotted">
{fromUnixTime(tr.initial).toLocaleString()}
</em>
</span>
) : (
''
)}
{tr?.every ? (
<span title={`${tr.every} ${t('seconds')}`}>
, <strong>{t('every')}</strong>{' '}
<em className="not-italic underline decoration-dotted">
{secondsToMinutes(tr.every)} {t('minutes')}
</em>{' '}
</span>
) : (
''
)}
</span>
);
})}
</div>
);
}
@@ -0,0 +1,21 @@
import { t } from '@vegaprotocol/i18n';
import type { ConditionOperator } from '@vegaprotocol/types';
export function getCharacterForOperator(
operator: ConditionOperator
): React.ReactElement {
switch (operator) {
case 'OPERATOR_EQUALS':
return <span title={t('equals')}>=</span>;
case 'OPERATOR_GREATER_THAN':
return <span title={t('greater than')}>&gt;</span>;
case 'OPERATOR_GREATER_THAN_OR_EQUAL':
return <span title={t('greater than or equal')}>&ge;</span>;
case 'OPERATOR_LESS_THAN':
return <span title={t('less than')}>&lt;</span>;
case 'OPERATOR_LESS_THAN_OR_EQUAL':
return <span title={t('less than or equal')}>&le;</span>;
}
return <span>{operator}</span>;
}
@@ -14,7 +14,9 @@ import { OracleFilter } from './oracle-filter';
import { OracleDetailsType } from './oracle-details-type';
import { OracleMarkets } from './oracle-markets';
import { OracleSigners } from './oracle-signers';
import OracleLink from '../../../components/links/oracle-link/oracle-link';
import { OracleEthSource } from './oracle-eth-source';
import Hash from '../../../components/links/hash';
import { getStatusString } from '../../../components/links/oracle-link/oracle-link';
export type SourceType =
ExplorerOracleDataSourceFragment['dataSourceSpec']['spec']['data']['sourceType'];
@@ -38,10 +40,8 @@ export const OracleDetails = ({
id,
dataSource,
dataConnection,
showBroadcasts = false,
}: OracleDetailsProps) => {
const sourceType = dataSource.dataSourceSpec.spec.data.sourceType;
const reportsCount: number = dataConnection.edges?.length || 0;
return (
<div>
@@ -49,23 +49,27 @@ export const OracleDetails = ({
<TableRow modifier="bordered">
<TableHeader scope="row">{t('ID')}</TableHeader>
<TableCell modifier="bordered">
<OracleLink id={id} />
<Hash text={id} />
</TableCell>
</TableRow>
<OracleDetailsType sourceType={sourceType} />
<TableRow modifier="bordered">
<TableHeader scope="row">{t('Status')}</TableHeader>
<TableCell modifier="bordered">
{getStatusString(dataSource.dataSourceSpec.spec.status)}
</TableCell>
</TableRow>
<OracleSigners sourceType={sourceType} />
<OracleEthSource sourceType={sourceType} />
<OracleMarkets id={id} />
<TableRow modifier="bordered">
<TableHeader scope="row">{t('Matched data')}</TableHeader>
<TableHeader scope="row">{t('Filter')}</TableHeader>
<TableCell modifier="bordered">
{showBroadcasts ? reportsCount : reportsCount > 0 ? '✅' : '❌'}
<OracleFilter data={dataSource} />
</TableCell>
</TableRow>
</TableWithTbody>
<OracleFilter data={dataSource} />
{showBroadcasts && dataConnection ? (
<OracleData data={dataConnection} />
) : null}
{dataConnection ? <OracleData data={dataConnection} /> : null}
</div>
);
};
@@ -1,20 +1,28 @@
import { AsyncRenderer, SyntaxHighlighter } from '@vegaprotocol/ui-toolkit';
import compact from 'lodash/compact';
import { AsyncRenderer } from '@vegaprotocol/ui-toolkit';
import { RouteTitle } from '../../../components/route-title';
import { t } from '@vegaprotocol/i18n';
import { useExplorerOracleSpecsQuery } from '../__generated__/Oracles';
import { useDocumentTitle } from '../../../hooks/use-document-title';
import { OracleDetails } from '../components/oracle';
import { useScrollToLocation } from '../../../hooks/scroll-to-location';
import filter from 'recursive-key-filter';
import { useExplorerOracleFormMarketsQuery } from '../__generated__/OraclesForMarkets';
import { MarketLink } from '../../../components/links';
import { OracleLink } from '../../../components/links/oracle-link/oracle-link';
import { useState } from 'react';
import { MarketStateMapping } from '@vegaprotocol/types';
import type { MarketState } from '@vegaprotocol/types';
const cellSpacing = 'px-3';
const Oracles = () => {
const { data, loading, error } = useExplorerOracleSpecsQuery({
const { data, loading, error } = useExplorerOracleFormMarketsQuery({
errorPolicy: 'ignore',
});
useDocumentTitle(['Oracles']);
useScrollToLocation();
const [hoveredOracle, setHoveredOracle] = useState('');
return (
<section>
<RouteTitle data-testid="oracle-specs-heading">{t('Oracles')}</RouteTitle>
@@ -30,36 +38,148 @@ const Oracles = () => {
data.oracleSpecsConnection.edges?.length === 0
}
>
{data?.oracleSpecsConnection?.edges
? data.oracleSpecsConnection.edges.map((o) => {
const id = o?.node.dataSourceSpec.spec.id;
if (!id) {
return null;
}
<table className="text-left">
<thead>
<tr>
<th className={cellSpacing}>Market</th>
<th className={cellSpacing}>Type</th>
<th className={cellSpacing}>State</th>
<th className={cellSpacing}>Settlement</th>
<th className={cellSpacing}>Termination</th>
</tr>
</thead>
<tbody>
{data?.marketsConnection?.edges
? data.marketsConnection.edges.map((o) => {
let hasSeenOracleReports = false;
let settlementOracle = '-';
let settlementOracleStatus = '-';
let terminationOracle = '-';
let terminationOracleStatus = '-';
const dataConnection = o?.node.dataConnection;
const id = o?.node.id;
if (!id) {
return null;
}
return (
<div
id={id}
key={id}
className="mb-10"
data-testid="oracle-details"
>
<OracleDetails
id={id}
dataSource={o?.node}
dataConnection={dataConnection}
showBroadcasts={false}
/>
<details>
<summary className="pointer">JSON</summary>
<SyntaxHighlighter data={filter(o, ['__typename'])} />
</details>
</div>
);
})
: null}
if (
o.node.tradableInstrument.instrument.product.__typename ===
'Future'
) {
settlementOracle =
o.node.tradableInstrument.instrument.product
.dataSourceSpecForSettlementData.id;
terminationOracle =
o.node.tradableInstrument.instrument.product
.dataSourceSpecForTradingTermination.id;
settlementOracleStatus =
o.node.tradableInstrument.instrument.product
.dataSourceSpecForSettlementData.status;
terminationOracleStatus =
o.node.tradableInstrument.instrument.product
.dataSourceSpecForTradingTermination.status;
} else if (
o.node.tradableInstrument.instrument.product.__typename ===
'Perpetual'
) {
settlementOracle =
o.node.tradableInstrument.instrument.product
.dataSourceSpecForSettlementData.id;
terminationOracle =
o.node.tradableInstrument.instrument.product
.dataSourceSpecForSettlementSchedule.id;
settlementOracleStatus =
o.node.tradableInstrument.instrument.product
.dataSourceSpecForSettlementData.status;
terminationOracleStatus =
o.node.tradableInstrument.instrument.product
.dataSourceSpecForSettlementSchedule.status;
}
const oracleInformationUnfiltered =
data?.oracleSpecsConnection?.edges?.map((e) =>
e && e.node ? e.node : undefined
) || [];
const oracleInformation = compact(oracleInformationUnfiltered)
.filter(
(o) =>
o.dataConnection.edges &&
o.dataConnection.edges.length > 0 &&
(o.dataSourceSpec.spec.id === settlementOracle ||
o.dataSourceSpec.spec.id === terminationOracle)
)
.at(0);
if (oracleInformation) {
hasSeenOracleReports = true;
}
const oracleList = `${settlementOracle} ${terminationOracle}`;
return (
<tr
id={id}
key={id}
className={
hoveredOracle.length > 0 &&
oracleList.indexOf(hoveredOracle) > -1
? 'bg-gray-100 dark:bg-gray-800'
: ''
}
data-testid="oracle-details"
data-oracles={oracleList}
>
<td className={cellSpacing}>
<MarketLink id={id} />
</td>
<td className={cellSpacing}>
{
o.node.tradableInstrument.instrument.product
.__typename
}
</td>
<td className={cellSpacing}>
{MarketStateMapping[o.node.state as MarketState]}
</td>
<td
className={
hoveredOracle.length > 0 &&
hoveredOracle === settlementOracle
? `indent-1 ${cellSpacing}`
: cellSpacing
}
>
<OracleLink
id={settlementOracle}
status={settlementOracleStatus}
hasSeenOracleReports={hasSeenOracleReports}
onMouseOver={() => setHoveredOracle(settlementOracle)}
onMouseOut={() => setHoveredOracle('')}
/>
</td>
<td
className={
hoveredOracle.length > 0 &&
hoveredOracle === terminationOracle
? `indent-1 ${cellSpacing}`
: cellSpacing
}
>
<OracleLink
id={terminationOracle}
status={terminationOracleStatus}
hasSeenOracleReports={hasSeenOracleReports}
onMouseOver={() =>
setHoveredOracle(terminationOracle)
}
onMouseOut={() => setHoveredOracle('')}
/>
</td>
</tr>
);
})
: null}
</tbody>
</table>
</AsyncRenderer>
</section>
);
@@ -40,10 +40,9 @@ export const Oracle = () => {
id={id || ''}
dataSource={data?.oracleSpec}
dataConnection={data?.oracleSpec.dataConnection}
showBroadcasts={true}
/>
<details>
<summary className="pointer">JSON</summary>
<details className="mt-5 cursor-pointer">
<summary>JSON</summary>
<SyntaxHighlighter data={filter(data, ['__typename'])} />
</details>
</div>
@@ -14,6 +14,10 @@ export interface BlockExplorerTransactionResult {
value: string;
};
error?: string;
// These aren't strictly optional but are new in 0.73.0 so we need to make them optional
createdAt?: string;
version?: string;
pow?: string;
}
export interface BlockExplorerTransactions {
+670 -247
View File
File diff suppressed because it is too large Load Diff
@@ -537,10 +537,10 @@ describe(
cy.VegaWalletSubmitProposal(createGovernanceTransferProposalTxBody());
cy.reload();
getProposalFromTitle('Governance transfer proposal').within(() => {
cy.getByTestId(marketProposalType).should('have.text', 'NewTransfer');
cy.getByTestId(marketProposalType).should('have.text', 'New transfer');
cy.getByTestId(viewProposalButton).click();
});
cy.getByTestId(marketProposalType).should('have.text', 'NewTransfer');
cy.getByTestId(marketProposalType).should('have.text', 'New transfer');
cy.getByTestId(governanceTransferToggle).click();
cy.getByTestId('proposal-transfer-details-table').within(() => {
getProposalInformationFromTable('Source Type')
@@ -590,7 +590,7 @@ describe(
);
cy.getByTestId(viewProposalButton).click();
});
cy.getByTestId(marketProposalType).should('have.text', 'CancelTransfer');
cy.getByTestId(marketProposalType).should('have.text', 'Cancel transfer');
getProposalInformationFromTable('Error details')
.invoke('text')
.and('eq', 'Governance transfer invalid transfer id not found');
@@ -8,6 +8,7 @@ import { ENV } from '../../config';
import noIcon from '../../images/token-no-icon.png';
import vegaBlack from '../../images/vega_black.png';
import vegaVesting from '../../images/vega_vesting.png';
import { BigNumber } from '../../lib/bignumber';
import type { WalletCardAssetProps } from '../wallet-card';
import { useVegaWallet } from '@vegaprotocol/wallet';
@@ -102,7 +103,10 @@ export const usePollForDelegations = () => {
setAccounts(
accounts
.filter(
(a) => a.type === Schema.AccountType.ACCOUNT_TYPE_GENERAL
(a) =>
a.type === Schema.AccountType.ACCOUNT_TYPE_GENERAL ||
a.type === Schema.AccountType.ACCOUNT_TYPE_VESTED_REWARDS ||
a.type === Schema.AccountType.ACCOUNT_TYPE_VESTING_REWARDS
)
.map((a) => {
const isVega =
@@ -115,14 +119,23 @@ export const usePollForDelegations = () => {
subheading: isVega ? t('collateral') : a.asset.symbol,
symbol: a.asset.symbol,
decimals: a.asset.decimals,
assetId: a.asset.id,
balance: new BigNumber(
addDecimal(a.balance, a.asset.decimals)
),
image: isVega ? vegaBlack : noIcon,
image: isVega
? vegaBlack
: a.type ===
Schema.AccountType.ACCOUNT_TYPE_VESTED_REWARDS ||
a.type ===
Schema.AccountType.ACCOUNT_TYPE_VESTING_REWARDS
? vegaVesting
: noIcon,
border: isVega,
address: isAssetTypeERC20(a.asset)
? a.asset.source.contractAddress
: undefined,
type: a.type,
};
})
.sort((a, b) => {
@@ -1,9 +1,18 @@
import React from 'react';
import React, { useMemo } from 'react';
import { Link } from 'react-router-dom';
import { useAnimateValue } from '../../hooks/use-animate-value';
import type { BigNumber } from '../../lib/bignumber';
import { useNumberParts } from '@vegaprotocol/react-helpers';
import * as Schema from '@vegaprotocol/types';
import { useTranslation } from 'react-i18next';
import { AnchorButton, Tooltip } from '@vegaprotocol/ui-toolkit';
import {
CONSOLE_TRANSFER_ASSET,
DApp,
useLinks,
} from '@vegaprotocol/environment';
import { useNetworkParam } from '@vegaprotocol/network-parameters';
interface WalletCardProps {
children: React.ReactNode;
@@ -100,8 +109,10 @@ export interface WalletCardAssetProps {
symbol: string;
balance: BigNumber;
decimals: number;
assetId?: string;
border?: boolean;
subheading?: string;
type?: Schema.AccountType;
}
export const WalletCardAsset = ({
@@ -110,16 +121,37 @@ export const WalletCardAsset = ({
symbol,
balance,
decimals,
assetId,
border,
subheading,
type,
}: WalletCardAssetProps) => {
const [integers, decimalsPlaces, separator] = useNumberParts(
balance,
decimals
);
const { t } = useTranslation();
const consoleLink = useLinks(DApp.Console);
const transferAssetLink = (assetId: string) =>
consoleLink(CONSOLE_TRANSFER_ASSET.replace(':assetId', assetId));
const { param: baseRate } = useNetworkParam('rewards_vesting_baseRate');
const isRedeemable =
type === Schema.AccountType.ACCOUNT_TYPE_VESTED_REWARDS && assetId;
const accountTypeTooltip = useMemo(() => {
if (type === Schema.AccountType.ACCOUNT_TYPE_VESTED_REWARDS) {
return t('VestedRewardsTooltip');
}
if (type === Schema.AccountType.ACCOUNT_TYPE_VESTING_REWARDS && baseRate) {
return t('VestingRewardsTooltip', { baseRate });
}
return null;
}, [baseRate, t, type]);
return (
<div className="flex flex-nowrap mt-2 mb-4">
<div className="flex flex-nowrap gap-2 mt-2 mb-4">
<img
alt="Vega"
src={image}
@@ -129,15 +161,37 @@ export const WalletCardAsset = ({
/>
<div>
<div
className="flex align-center text-base"
className="flex align-center items-baseline text-base gap-2"
data-testid="currency-title"
>
<div className="mb-0 px-2 uppercase">{name}</div>
<div className="mb-0 uppercase">{name}</div>
<div className="mb-0 uppercase text-neutral-400">
{subheading || symbol}
</div>
</div>
<div className="px-2 basis-full font-mono" data-testid="currency-value">
{type ? (
<div className="mb-[2px] flex gap-2 items-baseline">
<Tooltip description={accountTypeTooltip}>
<span className="px-2 py-1 leading-none text-xs bg-vega-cdark-700 rounded">
{Schema.AccountTypeMapping[type]}
</span>
</Tooltip>
{isRedeemable ? (
<Tooltip description={t('RedeemRewardsTooltip')}>
<AnchorButton
variant="primary"
size="xs"
href={transferAssetLink(assetId)}
target="_blank"
className="px-2 py-1 leading-none text-xs bg-vega-yellow text-black rounded"
>
{t('Redeem')}
</AnchorButton>
</Tooltip>
) : null}
</div>
) : null}
<div className="basis-full font-mono" data-testid="currency-value">
<span>
{integers}
{separator}
+12 -1
View File
@@ -909,6 +909,14 @@
"BenefitTierReferralDiscountFactorDescription": "The proportion of the referee's taker fees to be discounted",
"BenefitTierReferralRewardFactor": "Referral reward factor",
"BenefitTierReferralRewardFactorDescription": "The proportion of the referee's taker fees to be rewarded to the referrer",
"BenefitTierMinimumActivityStreak": "Minimum activity streak",
"BenefitTierMinimumActivityStreakDescription": "The minimum number of times the party needs to have completed the activity",
"BenefitTierMinimumQuantumBalance": "Minimum quantum balance",
"BenefitTierMinimumQuantumBalanceDescription": "The minimum amount of the vesting token to qualify",
"BenefitTierVestingMultiplier": "Vesting multiplier",
"BenefitTierVestingMultiplierDescription": "Vesting multiplier for the tier",
"BenefitTierRewardMultiplier": "Reward multiplier",
"BenefitTierRewardMultiplierDescription": "The multiplier",
"StakingTiers": "Staking tiers",
"StakingTierMinimumStakedTokens": "Minimum staked tokens",
"StakingTierMinimumStakedTokensDescription": "Required number of governance tokens ($VEGA) a referrer must have staked to receive the multiplier",
@@ -945,5 +953,8 @@
"ACCOUNT_TYPE_REWARD_RELATIVE_RETURN": "Relative return reward account",
"ACCOUNT_TYPE_REWARD_RETURN_VOLATILITY": "Return volatility reward account",
"ACCOUNT_TYPE_REWARD_VALIDATOR_RANKING": "Validator ranking reward account",
"ACCOUNT_TYPE_PENDING_FEE_REFERRAL_REWARD": "Pending fee referral reward account"
"ACCOUNT_TYPE_PENDING_FEE_REFERRAL_REWARD": "Pending fee referral reward account",
"VestingRewardsTooltip": "Vesting rewards will be moved to vested account at a rate of {{baseRate}} per epoch.",
"VestedRewardsTooltip": "Vested rewards can be redeemed using Console",
"RedeemRewardsTooltip": "Click to redeem vested rewards in Console"
}
@@ -36,13 +36,13 @@ describe('ProposalReferralProgramDetails helper functions', () => {
it('should format referral discount factor correctly', () => {
const input = '0.05';
const formatted = formatReferralDiscountFactor(input);
expect(formatted).toBe('5.00%');
expect(formatted).toBe('5%');
});
it('should format referral reward factor correctly', () => {
const input = '0.1';
const formatted = formatReferralRewardFactor(input);
expect(formatted).toBe('10.00%');
expect(formatted).toBe('10%');
});
it('should format minimum staked tokens correctly', () => {
@@ -0,0 +1 @@
export * from './proposal-update-benefit-tiers-details';
@@ -0,0 +1,158 @@
import { render, screen } from '@testing-library/react';
import { ProposalUpdateBenefitTiers } from './proposal-update-benefit-tiers-details';
import { generateProposal } from '../../test-helpers/generate-proposals';
jest.mock('../../../../contexts/app-state/app-state-context', () => ({
useAppState: () => ({
appState: {
decimals: 2,
},
}),
}));
const mockVestingBenefitTierProposal = generateProposal({
terms: {
change: {
__typename: 'UpdateNetworkParameter',
networkParameter: {
key: 'blah.blah.benefitTiers',
value: JSON.stringify({
tiers: [
{
minimum_quantum_balance: '10000',
reward_multiplier: '0.05',
},
{
minimum_quantum_balance: '500000000000',
reward_multiplier: '0.1',
},
{
minimum_quantum_balance: '10000000000000',
reward_multiplier: '10',
},
],
}),
},
},
},
});
const mockActivityStreakBenefitTierProposal = generateProposal({
terms: {
change: {
__typename: 'UpdateNetworkParameter',
networkParameter: {
key: 'blah.blah.benefitTiers',
value: JSON.stringify({
tiers: [
{
minimum_activity_streak: '10000',
vesting_multiplier: '5',
reward_multiplier: '0.1',
},
{
minimum_activity_streak: '10000000000000',
vesting_multiplier: '100',
reward_multiplier: '10',
},
],
}),
},
},
},
});
describe('ProposalUpdateBenefitTiers', () => {
it('should not render if proposal is null', () => {
render(<ProposalUpdateBenefitTiers proposal={null} />);
expect(screen.queryByTestId('proposal-update-benefit-tiers')).toBeNull();
});
it('should not render if __typename is not UpdateNetworkParameter', () => {
const updateMarketProposal = generateProposal({
terms: {
change: {
__typename: 'UpdateMarket',
},
},
});
render(<ProposalUpdateBenefitTiers proposal={updateMarketProposal} />);
expect(screen.queryByTestId('proposal-update-benefit-tiers')).toBeNull();
});
it('should not render if there are no relevant fields', () => {
const incompleteProposal = generateProposal({
terms: {
change: {
__typename: 'UpdateNetworkParameter',
},
},
});
render(<ProposalUpdateBenefitTiers proposal={incompleteProposal} />);
expect(screen.queryByTestId('proposal-update-benefit-tiers')).toBeNull();
});
it('should not render if there are relevant fields that are empty', () => {
const incompleteProposal = generateProposal({
terms: {
change: {
__typename: 'UpdateNetworkParameter',
networkParameter: {
key: 'blah.blah.benefitTiers',
value: JSON.stringify({}),
},
},
},
});
render(<ProposalUpdateBenefitTiers proposal={incompleteProposal} />);
expect(screen.queryByTestId('proposal-update-benefit-tiers')).toBeNull();
});
it('should render a valid vesting benefit tier proposal', () => {
render(
<ProposalUpdateBenefitTiers proposal={mockVestingBenefitTierProposal} />
);
// 3 tiers in the sample data
expect(screen.getByText('Tier 1')).toBeInTheDocument();
expect(screen.getByText('Tier 2')).toBeInTheDocument();
expect(screen.getByText('Tier 3')).toBeInTheDocument();
expect(screen.getAllByText('Minimum quantum balance').length).toBe(3);
expect(screen.getAllByText('Reward multiplier').length).toBe(3);
expect(screen.getByText('0.00000000000001')).toBeInTheDocument();
expect(screen.getByText('0.05x')).toBeInTheDocument();
expect(screen.getByText('0.0000005')).toBeInTheDocument();
expect(screen.getByText('0.1x')).toBeInTheDocument();
expect(screen.getByText('0.00001')).toBeInTheDocument();
expect(screen.getByText('10x')).toBeInTheDocument();
});
it('should render a valid activity streak benefit tier proposal', () => {
render(
<ProposalUpdateBenefitTiers
proposal={mockActivityStreakBenefitTierProposal}
/>
);
// 3 tiers in the sample data
expect(screen.getByText('Tier 1')).toBeInTheDocument();
expect(screen.getByText('Tier 2')).toBeInTheDocument();
expect(screen.getAllByText('Minimum activity streak').length).toBe(2);
expect(screen.getAllByText('Vesting multiplier').length).toBe(2);
expect(screen.getAllByText('Reward multiplier').length).toBe(2);
expect(screen.getByText('10000')).toBeInTheDocument();
expect(screen.getByText('5x')).toBeInTheDocument();
expect(screen.getByText('0.1x')).toBeInTheDocument();
expect(screen.getByText('10000000000000')).toBeInTheDocument();
expect(screen.getByText('100x')).toBeInTheDocument();
expect(screen.getByText('10x')).toBeInTheDocument();
});
});
@@ -0,0 +1,164 @@
import { useTranslation } from 'react-i18next';
import type { ProposalQuery } from '../../proposal/__generated__/Proposal';
import {
KeyValueTable,
KeyValueTableRow,
RoundedWrapper,
Tooltip,
} from '@vegaprotocol/ui-toolkit';
import {
formatMinimumStakedTokens,
formatReferralRewardMultiplier,
} from '../proposal-referral-program-details';
import { formatNumberPercentage } from '@vegaprotocol/utils';
import BigNumber from 'bignumber.js';
// These types are not generated as it's not known how dynamic these are
type VestingBenefitTier = {
minimum_quantum_balance: string;
reward_multiplier: string;
};
type ActivityStreakBenefitTier = {
minimum_activity_streak: number;
reward_multiplier: string;
vesting_multiplier: string;
};
export type BenefitTiers =
| Array<ActivityStreakBenefitTier>
| Array<VestingBenefitTier>;
export function getBenefitTiers(json: string): BenefitTiers {
try {
const parsed = JSON.parse(json);
return parsed.tiers;
} catch (e) {
return [];
}
}
export const formatVolumeDiscountFactor = (value: string) => {
return formatNumberPercentage(new BigNumber(value).times(100));
};
interface ProposalReferralProgramDetailsProps {
proposal: ProposalQuery['proposal'];
}
/**
* Special rendered for network proposals that change any benefit tiers,
* which is detected by:
* 1) it being a network parameter change
* 2) the name of the field ending in `.benefitTiers`
*
* It only renders known fields so that they can be formatted correctly.
*/
export const ProposalUpdateBenefitTiers = ({
proposal,
}: ProposalReferralProgramDetailsProps) => {
const { t } = useTranslation();
if (
proposal?.terms?.change?.__typename !== 'UpdateNetworkParameter' ||
proposal?.terms?.change?.networkParameter.key.slice(-13) !== '.benefitTiers'
) {
return null;
}
const benefitTiersString = proposal?.terms?.change?.networkParameter.value;
const benefitTiers = getBenefitTiers(benefitTiersString);
if (!benefitTiers) {
return null;
}
return (
<div data-testid="proposal-update-benefit-tiers">
<RoundedWrapper paddingBottom={true}>
{benefitTiers && (
<div
className="mb-6"
data-testid="proposal-volume-discount-program-benefit-tiers"
>
<h3 className="mb-3 uppercase font-semibold text-lg">
{t('BenefitTiers')}
</h3>
<KeyValueTable>
{benefitTiers
.sort(
(a, b) =>
Number(a.reward_multiplier) - Number(b.reward_multiplier)
)
.map((benefitTier, index) => (
<div className="mb-4" key={index}>
<h4 className="font-semibold uppercase">
Tier {index + 1}
</h4>
{'minimum_activity_streak' in benefitTier && (
<KeyValueTableRow
data-testid={`mas-${benefitTier.reward_multiplier}`}
>
<Tooltip
description={t(
'BenefitTierMinimumActivityStreakDescription'
)}
>
<span>{t('BenefitTierMinimumActivityStreak')}</span>
</Tooltip>
{benefitTier.minimum_activity_streak}
</KeyValueTableRow>
)}
{'minimum_quantum_balance' in benefitTier && (
<KeyValueTableRow
data-testid={`mqb-${benefitTier.reward_multiplier}`}
>
<Tooltip
description={t(
'BenefitTierMinimumQuantumBalanceDescription'
)}
>
<span>{t('BenefitTierMinimumQuantumBalance')}</span>
</Tooltip>
{formatMinimumStakedTokens(
benefitTier.minimum_quantum_balance,
18
)}
</KeyValueTableRow>
)}
{'vesting_multiplier' in benefitTier && (
<KeyValueTableRow
data-testid={`vm-${benefitTier.reward_multiplier}`}
>
<Tooltip
description={t('BenefitTierVestingMultiplier')}
>
<span>{t('BenefitTierVestingMultiplier')}</span>
</Tooltip>
{formatReferralRewardMultiplier(
benefitTier.vesting_multiplier
)}
</KeyValueTableRow>
)}
{'reward_multiplier' in benefitTier && (
<KeyValueTableRow
data-testid={`rm-${benefitTier.reward_multiplier}`}
>
<Tooltip description={t('BenefitTierRewardMultiplier')}>
<span>{t('BenefitTierRewardMultiplier')}</span>
</Tooltip>
{formatReferralRewardMultiplier(
benefitTier.reward_multiplier
)}
</KeyValueTableRow>
)}
</div>
))}
</KeyValueTable>
</div>
)}
</RoundedWrapper>
</div>
);
};
@@ -27,6 +27,7 @@ import {
ProposalTransferDetails,
} from '../proposal-transfer';
import { FLAGS } from '@vegaprotocol/environment';
import { ProposalUpdateBenefitTiers } from '../proposal-update-benefit-tiers';
export interface ProposalProps {
proposal: ProposalQuery['proposal'];
@@ -243,6 +244,14 @@ export const Proposal = ({
</div>
)}
{proposal.terms.change.__typename === 'UpdateNetworkParameter' &&
proposal.terms.change.networkParameter.key.slice(-13) ===
'.benefitTiers' && (
<div className="mb-4">
<ProposalUpdateBenefitTiers proposal={proposal} />
</div>
)}
{governanceTransferDetails}
<div className="mb-10">
@@ -22,18 +22,19 @@ const colUpdatedAt = '[col-id="updatedAt"] button';
const headers = [
'Party',
'Status',
'Commitment (tDAI)',
'Obligation',
'Fee',
'Adjusted stake share',
'Share',
'Live supplied liquidity',
'Live time fraction on book',
'Fees accrued this epoch',
'Live time on book',
'Live liquidity quality score (%)',
'Last time fraction on the book',
'Last time on the book',
'Last fee penalty',
'Last bond penalty',
'Status',
'Created',
'Updated',
];
+4 -8
View File
@@ -3,17 +3,16 @@ NX_ETHERSCAN_URL=https://sepolia.etherscan.io
NX_GITHUB_FEEDBACK_URL=https://github.com/vegaprotocol/feedback/discussions
NX_HOSTED_WALLET_URL=https://wallet.testnet.vega.xyz
NX_SENTRY_DSN=https://2ffce43721964aafa78277c50654ece4@o286262.ingest.sentry.io/6300613
NX_VEGA_CONFIG_URL=https://raw.githubusercontent.com/vegaprotocol/networks-internal/main/fairground/vegawallet-fairground.toml
NX_VEGA_ENV=TESTNET
NX_VEGA_EXPLORER_URL=https://explorer.fairground.wtf
NX_VEGA_CONFIG_URL=https://raw.githubusercontent.com/vegaprotocol/networks-internal/main/stagnet1/vegawallet-stagnet1.toml
NX_VEGA_ENV=STAGNET1
NX_VEGA_EXPLORER_URL=https://explorer.stagnet1.vega.rocks
NX_VEGA_NETWORKS={\"MAINNET\":\"https://console.vega.xyz\",\"TESTNET\":\"https://console.fairground.wtf\",\"STAGNET1\":\"https://trading.stagnet1.vega.rocks\"}
NX_VEGA_TOKEN_URL=https://governance.fairground.wtf
NX_VEGA_TOKEN_URL=https://governance.stagnet1.vega.rocks
NX_VEGA_WALLET_URL=http://localhost:1789
NX_VEGA_DOCS_URL=https://docs.vega.xyz/testnet
NX_VEGA_REPO_URL=https://github.com/vegaprotocol/vega/releases
NX_ANNOUNCEMENTS_CONFIG_URL=https://raw.githubusercontent.com/vegaprotocol/announcements/fairground/announcements.json
NX_VEGA_INCIDENT_URL=https://blog.vega.xyz/tagged/vega-incident-reports
NX_VEGA_CONSOLE_URL=https://console.fairground.wtf
NX_CHROME_EXTENSION_URL=https://chrome.google.com/webstore/detail/vega-wallet-fairground/nmmjkiafpmphlikhefgjbblebfgclikn
NX_MOZILLA_EXTENSION_URL=https://addons.mozilla.org/firefox/addon/vega-wallet-fairground
NX_ORACLE_PROOFS_URL=https://raw.githubusercontent.com/vegaprotocol/well-known/main/__generated__/oracle-proofs.json
@@ -26,6 +25,3 @@ NX_ICEBERG_ORDERS=true
# NX_PRODUCT_PERPETUALS
NX_METAMASK_SNAPS=true
NX_REFERRALS=true
NX_TENDERMINT_URL=https://tm.be.testnet.vega.xyz
NX_TENDERMINT_WEBSOCKET_URL=wss://be.testnet.vega.xyz/websocket
+1 -1
View File
@@ -5,7 +5,7 @@
"next",
"next/core-web-vitals"
],
"ignorePatterns": ["!**/*", "__generated__"],
"ignorePatterns": ["!**/*", "__generated__", ".next"],
"overrides": [
{
"files": ["*.ts", "*.tsx", "*.js", "*.jsx"],
+11
View File
@@ -0,0 +1,11 @@
import { t } from '@vegaprotocol/i18n';
import { FeesContainer } from '../../components/fees-container';
export const Fees = () => {
return (
<div className="container p-4 mx-auto">
<h1 className="px-4 pb-4 text-2xl">{t('Fees')}</h1>
<FeesContainer />
</div>
);
};
+1
View File
@@ -0,0 +1 @@
export { Fees } from './fees';
@@ -82,6 +82,13 @@ export const TradePanels = ({ market, pinnedAsset }: TradePanelsProps) => {
'bg-vega-clight-500 dark:bg-vega-cdark-500': isActive,
}
);
if (
market?.tradableInstrument.instrument.product.__typename !==
'Perpetual' &&
(key === 'funding' || key === 'fundingPayments')
) {
return null;
}
return (
<button
data-testid={key}
@@ -1,7 +1,5 @@
import { Route, Routes, useParams } from 'react-router-dom';
import { MarketState } from '@vegaprotocol/types';
import { Route, Routes } from 'react-router-dom';
import { t } from '@vegaprotocol/i18n';
import { useMarket } from '@vegaprotocol/markets';
import { VegaIconNames } from '@vegaprotocol/ui-toolkit';
import {
SidebarButton,
@@ -11,12 +9,7 @@ import {
import { useGetCurrentRouteId } from '../../lib/hooks/use-get-current-route-id';
export const MarketsSidebar = () => {
const { marketId } = useParams();
const currentRouteId = useGetCurrentRouteId();
const { data } = useMarket(marketId);
const active =
data &&
[MarketState.STATE_ACTIVE, MarketState.STATE_PENDING].includes(data.state);
return (
<>
@@ -44,14 +37,12 @@ export const MarketsSidebar = () => {
element={
<>
<SidebarDivider />
{active && (
<SidebarButton
view={ViewType.Order}
icon={VegaIconNames.TICKET}
tooltip={t('Order')}
routeId={currentRouteId}
/>
)}
<SidebarButton
view={ViewType.Order}
icon={VegaIconNames.TICKET}
tooltip={t('Order')}
routeId={currentRouteId}
/>
<SidebarButton
view={ViewType.Info}
icon={VegaIconNames.BREAKDOWN}
@@ -32,7 +32,7 @@ const WithdrawalsIndicator = () => {
return null;
}
return (
<span className="bg-vega-clight-500 dark:bg-vega-cdark-500 text-default rounded p-1 leading-none">
<span className="p-1 leading-none rounded bg-vega-clight-500 dark:bg-vega-cdark-500 text-default">
{ready.length}
</span>
);
@@ -128,7 +128,7 @@ interface PortfolioGridChildProps {
const PortfolioGridChild = ({ children }: PortfolioGridChildProps) => {
return (
<section className="h-full p-1">
<div className="border border-default h-full rounded-sm">{children}</div>
<div className="h-full border rounded-sm border-default">{children}</div>
</section>
);
};
@@ -18,6 +18,7 @@ import { Routes } from '../../lib/links';
import { useTransactionEventSubscription } from '@vegaprotocol/web3';
import { t } from '@vegaprotocol/i18n';
import { Statistics } from './referral-statistics';
import { useReferralProgram } from './hooks/use-referral-program';
const RELOAD_DELAY = 3000;
@@ -32,6 +33,7 @@ const validateCode = (value: string) => {
};
export const ApplyCodeForm = () => {
const program = useReferralProgram();
const navigate = useNavigate();
const openWalletDialog = useVegaWalletDialogStore(
(store) => store.openVegaWalletDialog
@@ -237,7 +239,7 @@ export const ApplyCodeForm = () => {
{previewData ? (
<div className="mt-10">
<h2 className="text-2xl mb-5">{t('You are joining')}</h2>
<Statistics data={previewData} as="referee" />
<Statistics data={previewData} program={program} as="referee" />
</div>
) : null}
</>
@@ -1,5 +1,5 @@
query Referees($code: ID!, $aggregationDays: Int) {
referralSetReferees(id: $code, aggregationDays: $aggregationDays) {
query Referees($code: ID!, $aggregationEpochs: Int) {
referralSetReferees(id: $code, aggregationEpochs: $aggregationEpochs) {
edges {
node {
referralSetId
@@ -10,6 +10,7 @@ query ReferralSetStats($code: ID!, $epoch: Int) {
referralSetRunningNotionalTakerVolume
rewardsMultiplier
rewardsFactorMultiplier
referrerTakerVolume
}
}
}
@@ -5,7 +5,7 @@ import * as Apollo from '@apollo/client';
const defaultOptions = {} as const;
export type RefereesQueryVariables = Types.Exact<{
code: Types.Scalars['ID'];
aggregationDays?: Types.InputMaybe<Types.Scalars['Int']>;
aggregationEpochs?: Types.InputMaybe<Types.Scalars['Int']>;
}>;
@@ -13,8 +13,8 @@ export type RefereesQuery = { __typename?: 'Query', referralSetReferees: { __typ
export const RefereesDocument = gql`
query Referees($code: ID!, $aggregationDays: Int) {
referralSetReferees(id: $code, aggregationDays: $aggregationDays) {
query Referees($code: ID!, $aggregationEpochs: Int) {
referralSetReferees(id: $code, aggregationEpochs: $aggregationEpochs) {
edges {
node {
referralSetId
@@ -42,7 +42,7 @@ export const RefereesDocument = gql`
* const { data, loading, error } = useRefereesQuery({
* variables: {
* code: // value for 'code'
* aggregationDays: // value for 'aggregationDays'
* aggregationEpochs: // value for 'aggregationEpochs'
* },
* });
*/
@@ -9,7 +9,7 @@ export type ReferralSetStatsQueryVariables = Types.Exact<{
}>;
export type ReferralSetStatsQuery = { __typename?: 'Query', referralSetStats: { __typename?: 'ReferralSetStatsConnection', edges: Array<{ __typename?: 'ReferralSetStatsEdge', node: { __typename?: 'ReferralSetStats', atEpoch: number, partyId: string, discountFactor: string, rewardFactor: string, epochNotionalTakerVolume: string, referralSetRunningNotionalTakerVolume: string, rewardsMultiplier: string, rewardsFactorMultiplier: string } } | null> } };
export type ReferralSetStatsQuery = { __typename?: 'Query', referralSetStats: { __typename?: 'ReferralSetStatsConnection', edges: Array<{ __typename?: 'ReferralSetStatsEdge', node: { __typename?: 'ReferralSetStats', atEpoch: number, partyId: string, discountFactor: string, rewardFactor: string, epochNotionalTakerVolume: string, referralSetRunningNotionalTakerVolume: string, rewardsMultiplier: string, rewardsFactorMultiplier: string, referrerTakerVolume: string } } | null> } };
export const ReferralSetStatsDocument = gql`
@@ -25,6 +25,7 @@ export const ReferralSetStatsDocument = gql`
referralSetRunningNotionalTakerVolume
rewardsMultiplier
rewardsFactorMultiplier
referrerTakerVolume
}
}
}
@@ -5,14 +5,14 @@ import compact from 'lodash/compact';
import type { ReferralSetsQueryVariables } from './__generated__/ReferralSets';
import { useReferralSetsQuery } from './__generated__/ReferralSets';
const DEFAULT_AGGREGATION_DAYS = 30;
export const DEFAULT_AGGREGATION_DAYS = 30;
export type Role = 'referrer' | 'referee';
type UseReferralArgs = (
| { code: string }
| { pubKey: string | null; role: Role }
) & {
aggregationDays?: number;
aggregationEpochs?: number;
};
const prepareVariables = (
@@ -70,9 +70,9 @@ export const useReferral = (args: UseReferralArgs) => {
} = useRefereesQuery({
variables: {
code: referralSet?.id as string,
aggregationDays:
args.aggregationDays != null
? args.aggregationDays
aggregationEpochs:
args.aggregationEpochs !== null
? args.aggregationEpochs
: DEFAULT_AGGREGATION_DAYS,
},
skip: !referralSet?.id,
@@ -3,10 +3,12 @@ import {
VegaIcon,
VegaIconNames,
truncateMiddle,
ExternalLink,
Tooltip,
} from '@vegaprotocol/ui-toolkit';
import { useVegaWallet } from '@vegaprotocol/wallet';
import { useReferral } from './hooks/use-referral';
import { DEFAULT_AGGREGATION_DAYS, useReferral } from './hooks/use-referral';
import { CreateCodeContainer } from './create-code-form';
import classNames from 'classnames';
import { Table } from './table';
@@ -27,25 +29,31 @@ import { useLayoutEffect, useRef, useState } from 'react';
import { useCurrentEpochInfoQuery } from './hooks/__generated__/Epoch';
import BigNumber from 'bignumber.js';
import { t } from '@vegaprotocol/i18n';
import maxBy from 'lodash/maxBy';
import { DocsLinks } from '@vegaprotocol/environment';
export const ReferralStatistics = () => {
const { pubKey } = useVegaWallet();
const program = useReferralProgram();
const { data: referee } = useReferral({
pubKey,
role: 'referee',
aggregationEpochs: program.details?.windowLength,
});
const { data: referrer } = useReferral({
pubKey,
role: 'referrer',
aggregationEpochs: program.details?.windowLength,
});
if (referee?.code) {
return <Statistics data={referee} as="referee" />;
return <Statistics data={referee} program={program} as="referee" />;
}
if (referrer?.code) {
return <Statistics data={referrer} as="referrer" />;
return <Statistics data={referrer} program={program} as="referrer" />;
}
return <CreateCodeContainer />;
@@ -53,14 +61,16 @@ export const ReferralStatistics = () => {
export const Statistics = ({
data,
program,
as,
}: {
data: NonNullable<ReturnType<typeof useReferral>['data']>;
program: ReturnType<typeof useReferralProgram>;
as: 'referrer' | 'referee';
}) => {
const { benefitTiers, details } = program;
const { data: epochData } = useCurrentEpochInfoQuery();
const { stakeAvailable } = useStakeAvailable();
const { benefitTiers } = useReferralProgram();
const { data: statsData } = useReferralSetStatsQuery({
variables: {
code: data.code,
@@ -71,6 +81,13 @@ export const Statistics = ({
const currentEpoch = Number(epochData?.epoch.id);
const compactNumFormat = new Intl.NumberFormat(getUserLocale(), {
minimumFractionDigits: 0,
maximumFractionDigits: 2,
notation: 'compact',
compactDisplay: 'short',
});
const stats =
statsData?.referralSetStats.edges &&
compact(removePaginationWrapper(statsData.referralSetStats.edges));
@@ -86,10 +103,13 @@ export const Statistics = ({
const runningVolumeValue = statsAvailable
? Number(statsAvailable.referralSetRunningNotionalTakerVolume)
: 0;
const referrerVolumeValue = statsAvailable
? Number(statsAvailable.referrerTakerVolume)
: 0;
const multiplier = statsAvailable
? Number(statsAvailable.rewardsMultiplier)
: 1;
const finalCommissionValue = !isNaN(multiplier)
const finalCommissionValue = isNaN(multiplier)
? baseCommissionValue
: multiplier * baseCommissionValue;
@@ -102,9 +122,9 @@ export const Statistics = ({
!isNaN(t.discountFactor) &&
t.discountFactor === discountFactorValue
);
const nextBenefitTierValue =
currentBenefitTierValue &&
benefitTiers.find((t) => t.tier === currentBenefitTierValue.tier - 1);
const nextBenefitTierValue = currentBenefitTierValue
? benefitTiers.find((t) => t.tier === currentBenefitTierValue.tier - 1)
: maxBy(benefitTiers, (bt) => bt.tier); // max tier number is lowest tier
const epochsValue =
!isNaN(currentEpoch) && refereeInfo?.atEpoch
? currentEpoch - refereeInfo?.atEpoch
@@ -117,7 +137,13 @@ export const Statistics = ({
: 0;
const baseCommissionTile = (
<StatTile title={t('Base commission rate')}>
<StatTile
title={t('Base commission rate')}
description={t('(Combined set volume %s over last %s epochs)', [
compactNumFormat.format(runningVolumeValue),
(details?.windowLength || DEFAULT_AGGREGATION_DAYS).toString(),
])}
>
{baseCommissionValue * 100}%
</StatTile>
);
@@ -133,7 +159,16 @@ export const Statistics = ({
</StatTile>
);
const finalCommissionTile = (
<StatTile title={t('Final commission rate')}>
<StatTile
title={t('Final commission rate')}
description={
!isNaN(multiplier)
? `(${baseCommissionValue * 100}% ⨉ ${multiplier} = ${
finalCommissionValue * 100
}%)`
: undefined
}
>
{finalCommissionValue * 100}%
</StatTile>
);
@@ -142,12 +177,21 @@ export const Statistics = ({
<StatTile title={t('Number of traders')}>{numberOfTradersValue}</StatTile>
);
const codeTile = <CodeTile code={data?.code} />;
const createdAtTile = (
<StatTile title={t('Created at')}>
<span className="text-3xl">
{getDateFormat().format(new Date(data.createdAt))}
</span>
const codeTile = (
<CodeTile
code={data?.code}
createdAt={getDateFormat().format(new Date(data.createdAt))}
/>
);
const referrerVolumeTile = (
<StatTile
title={t(
'My volume (last %s epochs)',
(details?.windowLength || DEFAULT_AGGREGATION_DAYS).toString()
)}
>
{compactNumFormat.format(referrerVolumeValue)}
</StatTile>
);
@@ -156,8 +200,11 @@ export const Statistics = ({
.reduce((all, r) => all.plus(r), new BigNumber(0));
const totalCommissionTile = (
<StatTile
title={t('Total commission (last 30 days)')}
description={t('(qUSD)')}
title={t(
'Total commission (last %s epochs)',
(details?.windowLength || DEFAULT_AGGREGATION_DAYS).toString()
)}
description={<QUSDTooltip />}
>
{getNumberFormat(0).format(Number(totalCommissionValue))}
</StatTile>
@@ -173,30 +220,28 @@ export const Statistics = ({
<div className="grid grid-rows-1 gap-5 grid-cols-1 sm:grid-cols-2 xl:grid-cols-4">
{codeTile}
{createdAtTile}
{referrerVolumeTile}
{numberOfTradersTile}
{totalCommissionTile}
</div>
</>
);
const compactNumFormat = new Intl.NumberFormat(getUserLocale(), {
minimumFractionDigits: 0,
maximumFractionDigits: 2,
notation: 'compact',
compactDisplay: 'short',
});
const currentBenefitTierTile = (
<StatTile title={t('Current tier')}>
{currentBenefitTierValue?.tier || '-'}
{currentBenefitTierValue?.tier || 'None'}
</StatTile>
);
const discountFactorTile = (
<StatTile title={t('Discount')}>{discountFactorValue * 100}%</StatTile>
);
const runningVolumeTile = (
<StatTile title={t('Combined volume')}>
<StatTile
title={t(
'Combined volume (last %s epochs)',
details?.windowLength.toString()
)}
>
{compactNumFormat.format(runningVolumeValue)}
</StatTile>
);
@@ -255,7 +300,7 @@ export const Statistics = ({
{/* Referees (only for referrer view) */}
{as === 'referrer' && data.referees.length > 0 && (
<div className="mt-20 mb-20">
<h2 className="text-2xl mb-5">{t('Referees')}</h2>
<h2 className="mb-5 text-2xl">{t('Referees')}</h2>
<div
className={classNames(
collapsed && [
@@ -281,10 +326,28 @@ export const Statistics = ({
columns={[
{ name: 'party', displayName: t('Trader') },
{ name: 'joined', displayName: t('Date Joined') },
{ name: 'volume', displayName: t('Volume (last 30 days)') },
{
name: 'volume',
displayName: t(
'Volume (last %s epochs)',
(
details?.windowLength || DEFAULT_AGGREGATION_DAYS
).toString()
),
},
{
name: 'commission',
displayName: t('Commission earned (last 30 days)'),
displayName: (
<>
{t('Commission earned in')} <QUSDTooltip />{' '}
{t(
'(last %s epochs)',
(
details?.windowLength || DEFAULT_AGGREGATION_DAYS
).toString()
)}
</>
),
},
]}
data={sortBy(
@@ -313,3 +376,25 @@ export const Statistics = ({
</>
);
};
export const QUSDTooltip = () => (
<Tooltip
description={
<>
<p className="mb-1">
{t(
'qUSD provides a rough USD equivalent of balances across all assets using the value of "Quantum" for that asset'
)}
</p>
{DocsLinks && (
<ExternalLink href={DocsLinks.QUANTUM}>
{t('Find out more')}
</ExternalLink>
)}
</>
}
underline={true}
>
<span>{t('qUSD')}</span>
</Tooltip>
);
+11 -14
View File
@@ -1,10 +1,10 @@
import { Tooltip, VegaIcon, VegaIconNames } from '@vegaprotocol/ui-toolkit';
import classNames from 'classnames';
import { forwardRef, type HTMLAttributes } from 'react';
import { forwardRef, type ReactNode, type HTMLAttributes } from 'react';
import { BORDER_COLOR, GRADIENT } from './constants';
type TableColumnDefinition = {
displayName?: string;
displayName?: ReactNode;
name: string;
tooltip?: string;
className?: string;
@@ -46,7 +46,7 @@ export const Table = forwardRef<
INNER_BORDER_STYLE
)}
>
<span className="flex flex-row gap-2 items-center">
<span className="flex flex-row items-center gap-2">
<span>{displayName}</span>
{tooltip ? (
<Tooltip description={tooltip}>
@@ -102,17 +102,14 @@ export const Table = forwardRef<
key={`${i}-${name}`}
>
{/** display column name in mobile view */}
{!noCollapse &&
!noHeader &&
displayName &&
displayName.length > 0 && (
<span
aria-hidden
className="md:hidden font-mono text-xs px-0 text-vega-clight-100 dark:text-vega-cdark-100"
>
{displayName}
</span>
)}
{!noCollapse && !noHeader && displayName && (
<span
aria-hidden
className="px-0 font-mono text-xs md:hidden text-vega-clight-100 dark:text-vega-cdark-100"
>
{displayName}
</span>
)}
<span>{d[name]}</span>
</td>
))}
+12 -1
View File
@@ -109,6 +109,7 @@ export const TiersContainer = () => {
<Loading variant="large" />
) : (
<TiersTable
windowLength={details?.windowLength}
data={benefitTiers.map((bt) => ({
...bt,
tierElement: (
@@ -162,6 +163,7 @@ const StakingTiers = ({
const TiersTable = ({
data,
windowLength,
}: {
data: Array<{
tier: number;
@@ -170,6 +172,7 @@ const TiersTable = ({
discount: string;
volume: string;
}>;
windowLength?: number;
}) => {
return (
<Table
@@ -181,7 +184,15 @@ const TiersTable = ({
tooltip: t('A percentage of commission earned by the referrer'),
},
{ name: 'discount', displayName: t('Referrer trading discount') },
{ name: 'volume', displayName: t('Min. trading volume') },
{
name: 'volume',
displayName: t(
'Min. trading volume %s',
windowLength
? t('(last %s epochs)', windowLength.toString())
: undefined
),
},
{ name: 'epochs', displayName: t('Min. epochs') },
]}
data={data.map((d) => ({
+11 -5
View File
@@ -7,6 +7,7 @@ import {
import classNames from 'classnames';
import type { HTMLAttributes, ReactNode } from 'react';
import { Button } from './buttons';
import { t } from '@vegaprotocol/i18n';
export const Tile = ({
className,
@@ -28,7 +29,7 @@ export const Tile = ({
type StatTileProps = {
title: string;
description?: string;
description?: ReactNode;
children?: ReactNode;
};
export const StatTile = ({ title, description, children }: StatTileProps) => {
@@ -54,18 +55,23 @@ const FADE_OUT_STYLE = classNames(
export const CodeTile = ({
code,
createdAt,
className,
}: {
code: string;
createdAt?: string;
className?: string;
}) => {
return (
<StatTile title="Your referral code">
<div className="flex gap-2 items-center justify-between">
<StatTile
title={t('Your referral code')}
description={createdAt ? t('(Created at: %s)', createdAt) : undefined}
>
<div className="flex items-center justify-between gap-2">
<Tooltip
description={
<div className="break-all">
<span className="text-xl bg-rainbow bg-clip-text text-transparent">
<span className="text-xl text-transparent bg-rainbow bg-clip-text">
{code}
</span>
</div>
@@ -82,7 +88,7 @@ export const CodeTile = ({
</Tooltip>
<CopyWithTooltip text={code}>
<Button className="text-sm no-underline !py-0 !px-0 h-fit !bg-transparent">
<span className="sr-only">Copy</span>
<span className="sr-only">{t('Copy')}</span>
<VegaIcon size={24} name={VegaIconNames.COPY} />
</Button>
</CopyWithTooltip>
@@ -8,7 +8,7 @@ jest.mock('@vegaprotocol/accounts', () => ({
),
}));
jest.mock('../../components/welcome-dialog/get-started.ts', () => ({
jest.mock('../../components/welcome-dialog/get-started', () => ({
GetStarted: () => <div>GetStarted</div>,
}));
@@ -8,7 +8,7 @@ jest.mock('../../components/withdraw-container', () => ({
),
}));
jest.mock('../../components/welcome-dialog/get-started.ts', () => ({
jest.mock('../../components/welcome-dialog/get-started', () => ({
GetStarted: () => <div>GetStarted</div>,
}));
@@ -0,0 +1,58 @@
query DiscountPrograms {
currentReferralProgram {
benefitTiers {
minimumEpochs
minimumRunningNotionalTakerVolume
referralDiscountFactor
}
windowLength
}
currentVolumeDiscountProgram {
benefitTiers {
minimumRunningNotionalTakerVolume
volumeDiscountFactor
}
windowLength
}
}
query Fees(
$partyId: ID!
$volumeDiscountEpochs: Int!
$referralDiscountEpochs: Int!
) {
epoch {
id
}
volumeDiscountStats(
partyId: $partyId
pagination: { last: $volumeDiscountEpochs }
) {
edges {
node {
atEpoch
discountFactor
runningVolume
}
}
}
referralSetReferees(referee: $partyId) {
edges {
node {
atEpoch
}
}
}
referralSetStats(
partyId: $partyId
pagination: { last: $referralDiscountEpochs }
) {
edges {
node {
atEpoch
discountFactor
referralSetRunningNotionalTakerVolume
}
}
}
}
@@ -0,0 +1,131 @@
import * as Types from '@vegaprotocol/types';
import { gql } from '@apollo/client';
import * as Apollo from '@apollo/client';
const defaultOptions = {} as const;
export type DiscountProgramsQueryVariables = Types.Exact<{ [key: string]: never; }>;
export type DiscountProgramsQuery = { __typename?: 'Query', currentReferralProgram?: { __typename?: 'CurrentReferralProgram', windowLength: number, benefitTiers: Array<{ __typename?: 'BenefitTier', minimumEpochs: number, minimumRunningNotionalTakerVolume: string, referralDiscountFactor: string }> } | null, currentVolumeDiscountProgram?: { __typename?: 'VolumeDiscountProgram', windowLength: number, benefitTiers: Array<{ __typename?: 'VolumeBenefitTier', minimumRunningNotionalTakerVolume: string, volumeDiscountFactor: string }> } | null };
export type FeesQueryVariables = Types.Exact<{
partyId: Types.Scalars['ID'];
volumeDiscountEpochs: Types.Scalars['Int'];
referralDiscountEpochs: Types.Scalars['Int'];
}>;
export type FeesQuery = { __typename?: 'Query', epoch: { __typename?: 'Epoch', id: string }, volumeDiscountStats: { __typename?: 'VolumeDiscountStatsConnection', edges: Array<{ __typename?: 'VolumeDiscountStatsEdge', node: { __typename?: 'VolumeDiscountStats', atEpoch: number, discountFactor: string, runningVolume: string } } | null> }, referralSetReferees: { __typename?: 'ReferralSetRefereeConnection', edges: Array<{ __typename?: 'ReferralSetRefereeEdge', node: { __typename?: 'ReferralSetReferee', atEpoch: number } } | null> }, referralSetStats: { __typename?: 'ReferralSetStatsConnection', edges: Array<{ __typename?: 'ReferralSetStatsEdge', node: { __typename?: 'ReferralSetStats', atEpoch: number, discountFactor: string, referralSetRunningNotionalTakerVolume: string } } | null> } };
export const DiscountProgramsDocument = gql`
query DiscountPrograms {
currentReferralProgram {
benefitTiers {
minimumEpochs
minimumRunningNotionalTakerVolume
referralDiscountFactor
}
windowLength
}
currentVolumeDiscountProgram {
benefitTiers {
minimumRunningNotionalTakerVolume
volumeDiscountFactor
}
windowLength
}
}
`;
/**
* __useDiscountProgramsQuery__
*
* To run a query within a React component, call `useDiscountProgramsQuery` and pass it any options that fit your needs.
* When your component renders, `useDiscountProgramsQuery` 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 } = useDiscountProgramsQuery({
* variables: {
* },
* });
*/
export function useDiscountProgramsQuery(baseOptions?: Apollo.QueryHookOptions<DiscountProgramsQuery, DiscountProgramsQueryVariables>) {
const options = {...defaultOptions, ...baseOptions}
return Apollo.useQuery<DiscountProgramsQuery, DiscountProgramsQueryVariables>(DiscountProgramsDocument, options);
}
export function useDiscountProgramsLazyQuery(baseOptions?: Apollo.LazyQueryHookOptions<DiscountProgramsQuery, DiscountProgramsQueryVariables>) {
const options = {...defaultOptions, ...baseOptions}
return Apollo.useLazyQuery<DiscountProgramsQuery, DiscountProgramsQueryVariables>(DiscountProgramsDocument, options);
}
export type DiscountProgramsQueryHookResult = ReturnType<typeof useDiscountProgramsQuery>;
export type DiscountProgramsLazyQueryHookResult = ReturnType<typeof useDiscountProgramsLazyQuery>;
export type DiscountProgramsQueryResult = Apollo.QueryResult<DiscountProgramsQuery, DiscountProgramsQueryVariables>;
export const FeesDocument = gql`
query Fees($partyId: ID!, $volumeDiscountEpochs: Int!, $referralDiscountEpochs: Int!) {
epoch {
id
}
volumeDiscountStats(
partyId: $partyId
pagination: {last: $volumeDiscountEpochs}
) {
edges {
node {
atEpoch
discountFactor
runningVolume
}
}
}
referralSetReferees(referee: $partyId) {
edges {
node {
atEpoch
}
}
}
referralSetStats(partyId: $partyId, pagination: {last: $referralDiscountEpochs}) {
edges {
node {
atEpoch
discountFactor
referralSetRunningNotionalTakerVolume
}
}
}
}
`;
/**
* __useFeesQuery__
*
* To run a query within a React component, call `useFeesQuery` and pass it any options that fit your needs.
* When your component renders, `useFeesQuery` 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 } = useFeesQuery({
* variables: {
* partyId: // value for 'partyId'
* volumeDiscountEpochs: // value for 'volumeDiscountEpochs'
* referralDiscountEpochs: // value for 'referralDiscountEpochs'
* },
* });
*/
export function useFeesQuery(baseOptions: Apollo.QueryHookOptions<FeesQuery, FeesQueryVariables>) {
const options = {...defaultOptions, ...baseOptions}
return Apollo.useQuery<FeesQuery, FeesQueryVariables>(FeesDocument, options);
}
export function useFeesLazyQuery(baseOptions?: Apollo.LazyQueryHookOptions<FeesQuery, FeesQueryVariables>) {
const options = {...defaultOptions, ...baseOptions}
return Apollo.useLazyQuery<FeesQuery, FeesQueryVariables>(FeesDocument, options);
}
export type FeesQueryHookResult = ReturnType<typeof useFeesQuery>;
export type FeesLazyQueryHookResult = ReturnType<typeof useFeesLazyQuery>;
export type FeesQueryResult = Apollo.QueryResult<FeesQuery, FeesQueryVariables>;
@@ -0,0 +1,36 @@
import classNames from 'classnames';
import type { ReactNode } from 'react';
export const FeeCard = ({
children,
title,
className,
loading = false,
}: {
children: ReactNode;
title: string;
className?: string;
loading?: boolean;
}) => {
return (
<div
className={classNames(
'p-4 bg-vega-clight-800 dark:bg-vega-cdark-800 col-span-full lg:col-auto',
'rounded-lg',
className
)}
>
<h2 className="mb-3">{title}</h2>
{loading ? <FeeCardLoader /> : children}
</div>
);
};
export const FeeCardLoader = () => {
return (
<div className="flex flex-col gap-2">
<div className="w-full h-5 bg-vega-clight-600 dark:bg-vega-cdark-600" />
<div className="w-3/4 h-6 bg-vega-clight-600 dark:bg-vega-cdark-600" />
</div>
);
};
@@ -0,0 +1,103 @@
import { render, screen } from '@testing-library/react';
import { formatNumber } from '@vegaprotocol/utils';
import BigNumber from 'bignumber.js';
import { CurrentVolume, TradingFees } from './fees-container';
import { formatPercentage, getAdjustedFee } from './utils';
describe('TradingFees', () => {
it('renders correct fee data', () => {
const makerFee = 0.01;
const infraFee = 0.01;
const minLiqFee = 0.1;
const maxLiqFee = 0.3;
const referralDiscount = 0.01;
const volumeDiscount = 0.01;
const makerBigNum = new BigNumber(makerFee);
const infraBigNum = new BigNumber(infraFee);
const minLiqBigNum = new BigNumber(minLiqFee);
const maxLiqBigNum = new BigNumber(maxLiqFee);
const referralBigNum = new BigNumber(referralDiscount);
const volumeBigNum = new BigNumber(volumeDiscount);
const props = {
params: {
market_fee_factors_makerFee: makerFee.toString(),
market_fee_factors_infrastructureFee: infraFee.toString(),
},
markets: [
{ fees: { factors: { liquidityFee: minLiqFee.toString() } } },
{ fees: { factors: { liquidityFee: '0.2' } } },
{ fees: { factors: { liquidityFee: maxLiqFee.toString() } } },
],
referralDiscount,
volumeDiscount,
};
render(<TradingFees {...props} />);
const minFee = formatPercentage(
makerBigNum.plus(infraFee).plus(minLiqFee).toNumber()
);
const maxFee = formatPercentage(
makerBigNum.plus(infraFee).plus(maxLiqFee).toNumber()
);
expect(
screen.getByText('Total fee before discount').nextElementSibling
).toHaveTextContent(`${minFee}%-${maxFee}%`);
expect(
screen.getByText('Infrastructure').nextElementSibling
).toHaveTextContent(formatPercentage(infraFee) + '%');
expect(screen.getByText('Maker').nextElementSibling).toHaveTextContent(
formatPercentage(makerFee) + '%'
);
const minAdjustedFees = formatPercentage(
getAdjustedFee(
[makerBigNum, infraBigNum, minLiqBigNum],
[referralBigNum, volumeBigNum]
)
);
const maxAdjustedFees = formatPercentage(
getAdjustedFee(
[makerBigNum, infraBigNum, maxLiqBigNum],
[referralBigNum, volumeBigNum]
)
);
expect(screen.getByTestId('adjusted-fees')).toHaveTextContent(
`${minAdjustedFees}%-${maxAdjustedFees}%`
);
});
});
describe('CurerntVolume', () => {
it('renders the required amount for the next tier', () => {
const windowLengthVolume = 1500;
const nextTierVolume = 2000;
const props = {
tiers: [
{ minimumRunningNotionalTakerVolume: '1000' },
{ minimumRunningNotionalTakerVolume: nextTierVolume.toString() },
{ minimumRunningNotionalTakerVolume: '3000' },
],
tierIndex: 0,
windowLengthVolume,
windowLength: 5,
};
render(<CurrentVolume {...props} />);
expect(
screen.getByText(formatNumber(windowLengthVolume)).nextElementSibling
).toHaveTextContent(`Past ${props.windowLength} epochs`);
expect(
screen.getByText(formatNumber(nextTierVolume - windowLengthVolume))
.nextElementSibling
).toHaveTextContent('Required for next tier');
});
});
@@ -0,0 +1,493 @@
import maxBy from 'lodash/maxBy';
import minBy from 'lodash/minBy';
import { t } from '@vegaprotocol/i18n';
import { useVegaWallet } from '@vegaprotocol/wallet';
import {
useNetworkParams,
NetworkParams,
} from '@vegaprotocol/network-parameters';
import { useMarketList } from '@vegaprotocol/markets';
import { formatNumber, formatNumberRounded } from '@vegaprotocol/utils';
import { useDiscountProgramsQuery, useFeesQuery } from './__generated__/Fees';
import { FeeCard } from './fees-card';
import { MarketFees } from './market-fees';
import { Stat } from './stat';
import { useVolumeStats } from './use-volume-stats';
import { useReferralStats } from './use-referral-stats';
import { formatPercentage, getAdjustedFee } from './utils';
import { Table, Td, Th, THead, Tr } from './table';
import BigNumber from 'bignumber.js';
export const FeesContainer = () => {
const { pubKey } = useVegaWallet();
const { params, loading: paramsLoading } = useNetworkParams([
NetworkParams.market_fee_factors_makerFee,
NetworkParams.market_fee_factors_infrastructureFee,
]);
const { data: markets, loading: marketsLoading } = useMarketList();
const { data: programData, loading: programLoading } =
useDiscountProgramsQuery();
const volumeDiscountWindowLength =
programData?.currentVolumeDiscountProgram?.windowLength || 1;
const referralDiscountWindowLength =
programData?.currentReferralProgram?.windowLength || 1;
const { data: feesData, loading: feesLoading } = useFeesQuery({
variables: {
partyId: pubKey || '',
volumeDiscountEpochs: volumeDiscountWindowLength,
referralDiscountEpochs: referralDiscountWindowLength,
},
skip: !pubKey || !programData,
});
const { volumeDiscount, volumeTierIndex, volumeInWindow, volumeTiers } =
useVolumeStats(
feesData?.volumeDiscountStats,
programData?.currentVolumeDiscountProgram
);
const {
referralDiscount,
referralVolumeInWindow,
referralTierIndex,
referralTiers,
epochsInSet,
} = useReferralStats(
feesData?.referralSetStats,
feesData?.referralSetReferees,
programData?.currentReferralProgram,
feesData?.epoch
);
const loading = paramsLoading || feesLoading || programLoading;
const isConnected = Boolean(pubKey);
return (
<div className="grid auto-rows-min grid-cols-4 gap-3">
{isConnected && (
<>
<FeeCard
title={t('My trading fees')}
className="sm:col-span-2"
loading={loading}
>
<TradingFees
params={params}
markets={markets}
referralDiscount={referralDiscount}
volumeDiscount={volumeDiscount}
/>
</FeeCard>
<FeeCard
title={t('Total discount')}
className="sm:col-span-2"
loading={loading}
>
<TotalDiscount
referralDiscount={referralDiscount}
volumeDiscount={volumeDiscount}
/>
</FeeCard>
<FeeCard
title={t('My current volume')}
className="sm:col-span-2"
loading={loading}
>
<CurrentVolume
tiers={volumeTiers}
tierIndex={volumeTierIndex}
windowLengthVolume={volumeInWindow}
windowLength={volumeDiscountWindowLength}
/>
</FeeCard>
<FeeCard
title={t('Referral benefits')}
className="sm:col-span-2"
loading={loading}
>
<ReferralBenefits
setRunningNotionalTakerVolume={referralVolumeInWindow}
epochsInSet={epochsInSet}
epochs={referralDiscountWindowLength}
/>
</FeeCard>
</>
)}
<FeeCard
title={t('Volume discount')}
className="lg:col-span-full xl:col-span-2"
loading={loading}
>
<VolumeTiers
tiers={volumeTiers}
tierIndex={volumeTierIndex}
lastEpochVolume={volumeInWindow}
windowLength={volumeDiscountWindowLength}
/>
</FeeCard>
<FeeCard
title={t('Referral discount')}
className="lg:col-span-full xl:col-span-2"
loading={loading}
>
<ReferralTiers
tiers={referralTiers}
tierIndex={referralTierIndex}
epochsInSet={epochsInSet}
referralVolumeInWindow={referralVolumeInWindow}
/>
</FeeCard>
<FeeCard
title={t('Liquidity fees')}
className="lg:col-span-full"
loading={marketsLoading}
>
<MarketFees
markets={markets}
referralDiscount={referralDiscount}
volumeDiscount={volumeDiscount}
/>
</FeeCard>
</div>
);
};
export const TradingFees = ({
params,
markets,
referralDiscount,
volumeDiscount,
}: {
params: {
market_fee_factors_infrastructureFee: string;
market_fee_factors_makerFee: string;
};
markets: Array<{ fees: { factors: { liquidityFee: string } } }> | null;
referralDiscount: number;
volumeDiscount: number;
}) => {
const referralDiscountBigNum = new BigNumber(referralDiscount);
const volumeDiscountBigNum = new BigNumber(volumeDiscount);
// Show min and max liquidity fees from all markets
const minLiq = minBy(markets, (m) => Number(m.fees.factors.liquidityFee));
const maxLiq = maxBy(markets, (m) => Number(m.fees.factors.liquidityFee));
const total = new BigNumber(params.market_fee_factors_makerFee).plus(
new BigNumber(params.market_fee_factors_infrastructureFee)
);
const adjustedTotal = getAdjustedFee(
[total],
[referralDiscountBigNum, volumeDiscountBigNum]
);
let minTotal;
let maxTotal;
let minAdjustedTotal;
let maxAdjustedTotal;
if (minLiq && maxLiq) {
const minLiqFee = new BigNumber(minLiq.fees.factors.liquidityFee);
const maxLiqFee = new BigNumber(maxLiq.fees.factors.liquidityFee);
minTotal = total.plus(minLiqFee);
maxTotal = total.plus(maxLiqFee);
minAdjustedTotal = getAdjustedFee(
[total, minLiqFee],
[referralDiscountBigNum, volumeDiscountBigNum]
);
maxAdjustedTotal = getAdjustedFee(
[total, maxLiqFee],
[referralDiscountBigNum, volumeDiscountBigNum]
);
}
return (
<div>
<div className="pt-6 leading-none">
<p className="block text-3xl leading-none" data-testid="adjusted-fees">
{minAdjustedTotal !== undefined && maxAdjustedTotal !== undefined
? `${formatPercentage(minAdjustedTotal)}%-${formatPercentage(
maxAdjustedTotal
)}%`
: `${formatPercentage(adjustedTotal)}%`}
</p>
<table className="w-full mt-0.5 text-xs text-muted">
<tbody>
<tr>
<th className="font-normal text-left text-default">
{t('Total fee before discount')}
</th>
<td className="text-right text-default">
{minTotal !== undefined && maxTotal !== undefined
? `${formatPercentage(
minTotal.toNumber()
)}%-${formatPercentage(maxTotal.toNumber())}%`
: `${formatPercentage(total.toNumber())}%`}
</td>
</tr>
<tr>
<th className="font-normal text-left">{t('Infrastructure')}</th>
<td className="text-right">
{formatPercentage(
Number(params.market_fee_factors_infrastructureFee)
)}
%
</td>
</tr>
<tr>
<th className="font-normal text-left ">{t('Maker')}</th>
<td className="text-right">
{formatPercentage(Number(params.market_fee_factors_makerFee))}%
</td>
</tr>
{minLiq && maxLiq && (
<tr>
<th className="font-normal text-left ">{t('Liquidity')}</th>
<td className="text-right">
{formatPercentage(Number(minLiq.fees.factors.liquidityFee))}%
{'-'}
{formatPercentage(Number(maxLiq.fees.factors.liquidityFee))}%
</td>
</tr>
)}
</tbody>
</table>
</div>
</div>
);
};
export const CurrentVolume = ({
tiers,
tierIndex,
windowLengthVolume,
windowLength,
}: {
tiers: Array<{ minimumRunningNotionalTakerVolume: string }>;
tierIndex: number;
windowLengthVolume: number;
windowLength: number;
}) => {
const nextTier = tiers[tierIndex + 1];
const requiredForNextTier = nextTier
? Number(nextTier.minimumRunningNotionalTakerVolume) - windowLengthVolume
: 0;
return (
<div>
<Stat
value={formatNumberRounded(new BigNumber(windowLengthVolume))}
text={t('Past %s epochs', windowLength.toString())}
/>
{requiredForNextTier > 0 && (
<Stat
value={formatNumber(requiredForNextTier)}
text={t('Required for next tier')}
/>
)}
</div>
);
};
const ReferralBenefits = ({
epochsInSet,
setRunningNotionalTakerVolume,
epochs,
}: {
epochsInSet: number;
setRunningNotionalTakerVolume: number;
epochs: number;
}) => {
return (
<div>
<Stat
// all sets volume (not just current party)
value={formatNumber(setRunningNotionalTakerVolume)}
text={t(
'Combined running notional over the %s epochs',
epochs.toString()
)}
/>
<Stat value={epochsInSet} text={t('epochs in referral set')} />
</div>
);
};
const TotalDiscount = ({
referralDiscount,
volumeDiscount,
}: {
referralDiscount: number;
volumeDiscount: number;
}) => {
return (
<div>
<Stat
value={formatPercentage(referralDiscount + volumeDiscount) + '%'}
highlight={true}
/>
<table className="w-full mt-0.5 text-xs text-muted">
<tbody>
<tr>
<th className="font-normal text-left">{t('Volume discount')}</th>
<td className="text-right">{formatPercentage(volumeDiscount)}%</td>
</tr>
<tr>
<th className="font-normal text-left ">{t('Referral discount')}</th>
<td className="text-right">
{formatPercentage(referralDiscount)}%
</td>
</tr>
</tbody>
</table>
</div>
);
};
const VolumeTiers = ({
tiers,
tierIndex,
lastEpochVolume,
windowLength,
}: {
tiers: Array<{
volumeDiscountFactor: string;
minimumRunningNotionalTakerVolume: string;
}>;
tierIndex: number;
lastEpochVolume: number;
windowLength: number;
}) => {
if (!tiers.length) {
return (
<p className="text-sm text-muted">
{t('No volume discount program active')}
</p>
);
}
return (
<div>
<Table>
<THead>
<tr>
<Th>{t('Tier')}</Th>
<Th>{t('Discount')}</Th>
<Th>{t('Min. trading volume')}</Th>
<Th>{t('My volume (last %s epochs)', windowLength.toString())}</Th>
<Th />
</tr>
</THead>
<tbody>
{Array.from(tiers)
.reverse()
.map((tier, i) => {
const isUserTier = tiers.length - 1 - tierIndex === i;
return (
<Tr key={i}>
<Td>{i + 1}</Td>
<Td>
{formatPercentage(Number(tier.volumeDiscountFactor))}%
</Td>
<Td>
{formatNumber(tier.minimumRunningNotionalTakerVolume)}
</Td>
<Td>{isUserTier ? formatNumber(lastEpochVolume) : ''}</Td>
<Td>{isUserTier ? <YourTier /> : null}</Td>
</Tr>
);
})}
</tbody>
</Table>
</div>
);
};
const ReferralTiers = ({
tiers,
tierIndex,
epochsInSet,
referralVolumeInWindow,
}: {
tiers: Array<{
referralDiscountFactor: string;
minimumRunningNotionalTakerVolume: string;
minimumEpochs: number;
}>;
tierIndex: number;
epochsInSet: number;
referralVolumeInWindow: number;
}) => {
if (!tiers.length) {
return (
<p className="text-sm text-muted">{t('No referral program active')}</p>
);
}
return (
<div>
<Table>
<THead>
<tr>
<Th>{t('Tier')}</Th>
<Th>{t('Discount')}</Th>
<Th>{t('Min. trading volume')}</Th>
<Th>{t('Required epochs')}</Th>
<Th />
</tr>
</THead>
<tbody>
{Array.from(tiers)
.reverse()
.map((t, i) => {
const isUserTier = tiers.length - 1 - tierIndex === i;
const requiredVolume = Number(
t.minimumRunningNotionalTakerVolume
);
let unlocksIn = null;
if (
referralVolumeInWindow >= requiredVolume &&
epochsInSet < t.minimumEpochs
) {
unlocksIn = (
<span className="text-muted">
Unlocks in {t.minimumEpochs - epochsInSet} epochs
</span>
);
}
return (
<Tr key={i}>
<Td>{i + 1}</Td>
<Td>{formatPercentage(Number(t.referralDiscountFactor))}%</Td>
<Td>{formatNumber(t.minimumRunningNotionalTakerVolume)}</Td>
<Td>{t.minimumEpochs}</Td>
<Td>{isUserTier ? <YourTier /> : unlocksIn}</Td>
</Tr>
);
})}
</tbody>
</Table>
</div>
);
};
const YourTier = () => {
return (
<span className="px-4 py-1.5 rounded-xl bg-rainbow whitespace-nowrap text-white">
{t('Your tier')}
</span>
);
};
@@ -0,0 +1 @@
export { FeesContainer } from './fees-container';
@@ -0,0 +1,104 @@
import compact from 'lodash/compact';
import type { MarketMaybeWithDataAndCandles } from '@vegaprotocol/markets';
import { AgGrid } from '@vegaprotocol/datagrid';
import { t } from '@vegaprotocol/i18n';
import { formatPercentage, getAdjustedFee } from './utils';
import { MarketCodeCell } from '../../client-pages/markets/market-code-cell';
import BigNumber from 'bignumber.js';
import { useNavigateWithMeta } from '../../lib/hooks/use-market-click-handler';
import { Links } from '../../lib/links';
const feesTableColumnDefs = [
{ field: 'code', cellRenderer: 'MarketCodeCell' },
{
field: 'feeAfterDiscount',
headerName: t('Total fee after discount'),
valueFormatter: ({ value }: { value: number }) => value + '%',
},
{
field: 'infraFee',
valueFormatter: ({ value }: { value: number }) => value + '%',
},
{
field: 'makerFee',
valueFormatter: ({ value }: { value: number }) => value + '%',
},
{
field: 'liquidityFee',
valueFormatter: ({ value }: { value: number }) => value + '%',
},
{
field: 'totalFee',
headerName: t('Total fee before discount'),
valueFormatter: ({ value }: { value: number }) => value + '%',
},
];
const feesTableDefaultColDef = {
flex: 1,
resizable: true,
sortable: true,
};
const components = {
MarketCodeCell,
};
export const MarketFees = ({
markets,
referralDiscount,
volumeDiscount,
}: {
markets: MarketMaybeWithDataAndCandles[] | null;
referralDiscount: number;
volumeDiscount: number;
}) => {
const navigateWithMeta = useNavigateWithMeta();
const rows = compact(markets || []).map((m) => {
const infraFee = new BigNumber(m.fees.factors.infrastructureFee);
const makerFee = new BigNumber(m.fees.factors.makerFee);
const liquidityFee = new BigNumber(m.fees.factors.liquidityFee);
const totalFee = infraFee.plus(makerFee).plus(liquidityFee);
const feeAfterDiscount = getAdjustedFee(
[infraFee, makerFee, liquidityFee],
[new BigNumber(referralDiscount), new BigNumber(volumeDiscount)]
);
return {
id: m.id,
code: m.tradableInstrument.instrument.code,
productType: m.tradableInstrument.instrument.product.__typename,
infraFee: formatPercentage(infraFee.toNumber()),
makerFee: formatPercentage(makerFee.toNumber()),
liquidityFee: formatPercentage(liquidityFee.toNumber()),
totalFee: formatPercentage(totalFee.toNumber()),
feeAfterDiscount: formatPercentage(feeAfterDiscount),
parentMarketID: m.parentMarketID,
successorMarketID: m.successorMarketID,
};
});
return (
<div className="border rounded-sm border-default">
<AgGrid
columnDefs={feesTableColumnDefs}
rowData={rows}
getRowId={({ data }) => data.id}
defaultColDef={feesTableDefaultColDef}
domLayout="autoHeight"
components={components}
rowHeight={45}
rowClass="cursor-pointer"
onRowClicked={({ data, event }) => {
navigateWithMeta(
Links.MARKET(data.id),
// @ts-ignore metaKey and ctrlKey exist
event.metaKey || event.ctrlKey
);
}}
/>
</div>
);
};
@@ -0,0 +1,26 @@
import classNames from 'classnames';
export const Stat = ({
value,
text,
highlight,
}: {
value: string | number;
text?: string;
highlight?: boolean;
}) => {
return (
<p className="pt-3 leading-none first:pt-6">
<span
className={classNames('inline-block text-3xl leading-none', {
'text-transparent bg-rainbow bg-clip-text': highlight,
})}
>
{value}
</span>
{text && (
<small className="block mt-0.5 text-xs text-muted">{text}</small>
)}
</p>
);
};
@@ -0,0 +1,40 @@
import classNames from 'classnames';
import type { ReactNode } from 'react';
const cellClass = 'px-4 py-2 text-xs font-normal text-left last:text-right';
export const Th = ({ children }: { children?: ReactNode }) => {
return (
<th className={classNames(cellClass, 'text-secondary leading-none py-3')}>
{children}
</th>
);
};
export const Td = ({ children }: { children?: ReactNode }) => {
return <th className={cellClass}>{children}</th>;
};
export const Tr = ({ children }: { children?: ReactNode }) => {
return (
<tr className="hover:bg-vega-clight-600 dark:hover:bg-vega-cdark-700">
{children}
</tr>
);
};
export const Table = ({ children }: { children: ReactNode }) => {
return (
<table className="w-full border border-separate rounded-sm border-spacing-0 border-vega-clight-600 dark:border-vega-cdark-600">
{children}
</table>
);
};
export const THead = ({ children }: { children: ReactNode }) => {
return (
<thead className="border-b bg-vega-clight-700 dark:bg-vega-cdark-700 border-vega-clight-600 dark:border-vega-cdark-600">
{children}
</thead>
);
};
@@ -0,0 +1,175 @@
import { renderHook } from '@testing-library/react';
import { useReferralStats } from './use-referral-stats';
describe('useReferralStats', () => {
const setStats = {
edges: [
{
__typename: 'ReferralSetStatsEdge' as const,
node: {
__typename: 'ReferralSetStats' as const,
atEpoch: 9,
discountFactor: '0.2',
referralSetRunningNotionalTakerVolume: '100',
},
},
{
__typename: 'ReferralSetStatsEdge' as const,
node: {
__typename: 'ReferralSetStats' as const,
atEpoch: 10,
discountFactor: '0.3',
referralSetRunningNotionalTakerVolume: '200',
},
},
],
};
const sets = {
edges: [
{
node: {
atEpoch: 3,
},
},
{
node: {
atEpoch: 4,
},
},
],
};
const epoch = {
id: '10',
};
const program = {
windowLength: 5,
benefitTiers: [
{
minimumEpochs: 4,
minimumRunningNotionalTakerVolume: '100',
referralDiscountFactor: '0.01',
},
{
minimumEpochs: 6,
minimumRunningNotionalTakerVolume: '200',
referralDiscountFactor: '0.05',
},
{
minimumEpochs: 8,
minimumRunningNotionalTakerVolume: '300',
referralDiscountFactor: '0.1',
},
],
};
it('returns correct default values', () => {
const { result } = renderHook(() => useReferralStats());
expect(result.current).toEqual({
referralDiscount: 0,
referralVolumeInWindow: 0,
referralTierIndex: -1,
referralTiers: [],
epochsInSet: 0,
});
});
it('returns formatted data and tiers', () => {
const { result } = renderHook(() =>
useReferralStats(setStats, sets, program, epoch)
);
// should use stats from latest epoch
const stats = setStats.edges[1].node;
const set = sets.edges[1].node;
expect(result.current).toEqual({
referralDiscount: Number(stats.discountFactor),
referralVolumeInWindow: Number(
stats.referralSetRunningNotionalTakerVolume
),
referralTierIndex: 1,
referralTiers: program.benefitTiers,
epochsInSet: Number(epoch.id) - set.atEpoch,
});
});
it.each([
{ joinedAt: 2, index: -1 },
{ joinedAt: 3, index: -1 },
{ joinedAt: 4, index: 0 },
{ joinedAt: 5, index: 0 },
{ joinedAt: 6, index: 1 },
{ joinedAt: 7, index: 1 },
{ joinedAt: 8, index: 2 },
{ joinedAt: 9, index: 2 },
])('joined at epoch: $joinedAt should be index: $index', (obj) => {
const statsA = {
edges: [
{
__typename: 'ReferralSetStatsEdge' as const,
node: {
__typename: 'ReferralSetStats' as const,
atEpoch: 10,
discountFactor: '0.3',
referralSetRunningNotionalTakerVolume: '100000',
},
},
],
};
const setsA = {
edges: [
{
node: {
atEpoch: Number(epoch.id) - obj.joinedAt,
},
},
],
};
const { result } = renderHook(() =>
useReferralStats(statsA, setsA, program, epoch)
);
expect(result.current.referralTierIndex).toEqual(obj.index);
});
it.each([
{ volume: '50', index: -1 },
{ volume: '100', index: 0 },
{ volume: '150', index: 0 },
{ volume: '200', index: 1 },
{ volume: '250', index: 1 },
{ volume: '300', index: 2 },
{ volume: '999', index: 2 },
])('volume: $volume should be index: $index', (obj) => {
const statsA = {
edges: [
{
__typename: 'ReferralSetStatsEdge' as const,
node: {
__typename: 'ReferralSetStats' as const,
atEpoch: 10,
discountFactor: '0.3',
referralSetRunningNotionalTakerVolume: obj.volume,
},
},
],
};
const setsA = {
edges: [
{
node: {
atEpoch: 1,
},
},
],
};
const { result } = renderHook(() =>
useReferralStats(statsA, setsA, program, epoch)
);
expect(result.current.referralTierIndex).toEqual(obj.index);
});
});
@@ -0,0 +1,52 @@
import compact from 'lodash/compact';
import maxBy from 'lodash/maxBy';
import { getReferralBenefitTier } from './utils';
import type { DiscountProgramsQuery, FeesQuery } from './__generated__/Fees';
export const useReferralStats = (
setStats?: FeesQuery['referralSetStats'],
setReferees?: FeesQuery['referralSetReferees'],
program?: DiscountProgramsQuery['currentReferralProgram'],
epoch?: FeesQuery['epoch']
) => {
const referralTiers = program?.benefitTiers || [];
if (!setStats || !setReferees || !program || !epoch) {
return {
referralDiscount: 0,
referralVolumeInWindow: 0,
referralTierIndex: -1,
referralTiers,
epochsInSet: 0,
};
}
const referralSetsStats = compact(setStats.edges).map((e) => e.node);
const referralSets = compact(setReferees.edges).map((e) => e.node);
const referralSet = maxBy(referralSets, (s) => s.atEpoch);
const referralStats = maxBy(referralSetsStats, (s) => s.atEpoch);
const epochsInSet = referralSet ? Number(epoch.id) - referralSet.atEpoch : 0;
const referralDiscount = Number(referralStats?.discountFactor || 0);
const referralVolumeInWindow = Number(
referralStats?.referralSetRunningNotionalTakerVolume || 0
);
const referralTierIndex = referralStats
? getReferralBenefitTier(
epochsInSet,
Number(referralStats.referralSetRunningNotionalTakerVolume),
referralTiers
)
: -1;
return {
referralDiscount,
referralVolumeInWindow,
referralTierIndex,
referralTiers,
epochsInSet,
};
};
@@ -0,0 +1,95 @@
import { renderHook } from '@testing-library/react';
import { useVolumeStats } from './use-volume-stats';
describe('useReferralStats', () => {
const statsList = {
edges: [
{
__typename: 'VolumeDiscountStatsEdge' as const,
node: {
__typename: 'VolumeDiscountStats' as const,
atEpoch: 9,
discountFactor: '0.1',
runningVolume: '100',
},
},
{
__typename: 'VolumeDiscountStatsEdge' as const,
node: {
__typename: 'VolumeDiscountStats' as const,
atEpoch: 10,
discountFactor: '0.3',
runningVolume: '200',
},
},
],
};
const program = {
windowLength: 5,
benefitTiers: [
{
minimumRunningNotionalTakerVolume: '100',
volumeDiscountFactor: '0.01',
},
{
minimumRunningNotionalTakerVolume: '200',
volumeDiscountFactor: '0.05',
},
{
minimumRunningNotionalTakerVolume: '300',
volumeDiscountFactor: '0.1',
},
],
};
it('returns correct default values', () => {
const { result } = renderHook(() => useVolumeStats());
expect(result.current).toEqual({
volumeDiscount: 0,
volumeInWindow: 0,
volumeTierIndex: -1,
volumeTiers: [],
});
});
it('returns formatted data and tiers', () => {
const { result } = renderHook(() => useVolumeStats(statsList, program));
// should use stats from latest epoch
const stats = statsList.edges[1].node;
expect(result.current).toEqual({
volumeDiscount: Number(stats.discountFactor),
volumeInWindow: Number(stats.runningVolume),
volumeTierIndex: 1,
volumeTiers: program.benefitTiers,
});
});
it.each([
{ volume: '100', index: 0 },
{ volume: '150', index: 0 },
{ volume: '200', index: 1 },
{ volume: '250', index: 1 },
{ volume: '300', index: 2 },
{ volume: '350', index: 2 },
])('returns index: $index for the running volume: $volume', (obj) => {
const statsA = {
edges: [
{
__typename: 'VolumeDiscountStatsEdge' as const,
node: {
__typename: 'VolumeDiscountStats' as const,
atEpoch: 10,
discountFactor: '0.3',
runningVolume: obj.volume,
},
},
],
};
const { result } = renderHook(() => useVolumeStats(statsA, program));
expect(result.current.volumeTierIndex).toBe(obj.index);
});
});

Some files were not shown because too many files have changed in this diff Show More