Compare commits

..
Author SHA1 Message Date
Madalina Raicu 18f8d5e648 fix(trading): adjust full screen for mobile dialogs 2024-01-24 17:20:40 +00:00
261f32aa5b feat(trading): margin mode selector (#5660)
Co-authored-by: Bartłomiej Głownia <bglownia@gmail.com>
Co-authored-by: Dariusz Majcherczyk <dariusz.majcherczyk@gmail.com>
2024-01-24 13:17:22 +00:00
Edd 053775bef6 fix(governance): handle an expected error (#5653) 2024-01-24 11:41:40 +00:00
Matthew Russellandbwallacee 0660eda334 chore(trading): store all chart state (#5627)
Co-authored-by: bwallacee <ben@vega.xyz>
2024-01-24 10:21:33 +00:00
Matthew Russell f22a3bc2d2 Revert "feat(trading): margin mode selector (#5575)"
This reverts commit fde77ebccb.
2024-01-23 08:46:16 -08:00
Bartłomiej GłowniaandDariusz Majcherczyk fde77ebccb feat(trading): margin mode selector (#5575)
Co-authored-by: Dariusz Majcherczyk <dariusz.majcherczyk@gmail.com>
2024-01-23 16:19:49 +00:00
e309669736 fix(trading): typo translation full featured link (#5659)
Co-authored-by: Matthew Russell <mattrussell36@gmail.com>
Co-authored-by: Edd <edd@vega.xyz>
2024-01-23 16:05:51 +00:00
b1621d1191 chore(trading): fix monitoring bounds (#5655) (#5657)
Co-authored-by: Matthew Russell <mattrussell36@gmail.com>
Co-authored-by: Edd <edd@vega.xyz>
2024-01-23 15:45:05 +00:00
ArtandDexter Edwards 39907f07db feat(wallet): new browser wallet connection model (#5572)
Co-authored-by: Dexter Edwards <dexter.edwards93@gmail.com>
2024-01-23 15:32:23 +00:00
3dab5f3d9b chore(trading): merge main back in develop (#5654)
Co-authored-by: Matthew Russell <mattrussell36@gmail.com>
Co-authored-by: Edd <edd@vega.xyz>
2024-01-23 14:57:20 +00:00
Edd 3cf9ae7582 fix(governance): handle null validator scores properly (#5459) 2024-01-23 14:46:19 +00:00
Ben 557894e2ef chore(trading): turn off parallel (#5650) 2024-01-23 14:43:51 +00:00
m.rayandbwallacee baf9875c69 feat(trading): filter out suspended transfers (#5640)
Co-authored-by: bwallacee <ben@vega.xyz>
2024-01-23 09:38:09 +00:00
Art 51199b02ce feat(governance): update market proposal header with copy and open buttons (#5648) 2024-01-23 10:13:56 +01:00
m.ray bb826c88f0 fix(trading): set trading view as default (#5632) 2024-01-22 16:09:18 +00:00
Edd 0da20b750f feat(explorer): referral and team transaction support (#5623) 2024-01-22 10:53:53 +00:00
m.rayandDariusz Majcherczyk e16c447564 chore(trading): remove quadratic slippage factor (#5641)
Co-authored-by: Dariusz Majcherczyk <dariusz.majcherczyk@gmail.com>
2024-01-22 10:17:16 +00:00
Bartłomiej GłowniaandDariusz Majcherczyk a2170d27d6 feat(ui-toolkit): update slider design (#5645)
Co-authored-by: Dariusz Majcherczyk <dariusz.majcherczyk@gmail.com>
2024-01-22 08:30:57 +01:00
Ben 00d840b7b3 chore(trading): delete and move some tests to jest (#5633) 2024-01-19 14:15:53 +00:00
Ben 2a4a05630f chore(trading): fix skipped tests (#5638) 2024-01-19 08:20:52 +00:00
Ben 97f1f40f2c chore(trading): remove waits and tests (#5637) 2024-01-18 13:20:58 +00:00
Bartłomiej GłowniaandMatthew Russell a8e6963521 feat(deposits): if wrong network dont show the form and prompt switching to the correct network (#5571)
Co-authored-by: Matthew Russell <mattrussell36@gmail.com>
2024-01-18 10:41:19 +01:00
m.ray a2bffa1dfd fix(trading): alphabetically order translations in trading.json (#5635) 2024-01-17 21:28:20 +00:00
Ben 9253e8067a chore(trading): market-sim branch parralel fix (#5630) 2024-01-17 14:44:11 +00:00
Bartłomiej Głownia 58972f0a11 feat(ui-toolkit): add leverage slider component (#5594) 2024-01-17 13:46:11 +00:00
Ben a3c55fd7c3 chore(trading): update vega-market-sim to use to 0.74.0-preview.2 (#5603) 2024-01-17 12:19:52 +00:00
Matthew Russell c63cba1071 chore(trading): enable trading view on mainnet (#5628) 2024-01-16 18:16:58 +00:00
m.ray bc9d87fe30 chore(trading): clean up triggering ratio (#5604) 2024-01-16 18:10:32 +01:00
m.ray 6a21862378 fix(trading): use No trading instead of Trading terminated (#5624) 2024-01-16 18:30:42 +02:00
Ben 0fb4348e9c chore(trading): upload market-sim logs (#5622) 2024-01-15 20:56:52 +00:00
m.ray b751bcf17d feat(trading): mobile responsiveness tweaks (#5613) 2024-01-15 15:26:13 +00:00
m.ray d32f27fcb1 feat(trading): mobile responsiveness - market selector (#5582) 2024-01-11 11:23:48 +00:00
m.ray d05cd6a2ed fix(trading): tiny scroll for rewards (#5586) 2024-01-10 15:49:58 +00:00
m.ray c003e5fa30 fix(trading): liquidity table improve readability and remove grouping (#5598) 2024-01-10 15:49:35 +00:00
Ben f62d3289ab chore(trading): retry on http error (#5601) 2024-01-10 15:44:01 +00:00
Ben a3d3d18c5c chore(trading): fix price monitoring test (#5600) 2024-01-10 14:56:17 +00:00
77ca101781 chore(trading): merge main back in develop (#5597)
Co-authored-by: Matthew Russell <mattrussell36@gmail.com>
Co-authored-by: Edd <edd@vega.xyz>
2024-01-10 10:31:15 +00:00
Ben d238c37d0c chore(trading): vesting test (#5595) 2024-01-10 10:07:59 +00:00
Edd 79feb485f6 fix(governance): improve error message for configuration error (#5589) 2024-01-09 17:21:56 +00:00
Ben 6aa5c3b6e3 chore(trading): rewards page e2e (#5578) 2024-01-09 15:54:40 +00:00
Ben 82abc13fda chore(trading): skip test due to issue (#5583) 2024-01-08 16:16:03 +00:00
Edd 933f07cddf fix(explorer): minor tidyup of deterministic order view (#5573) 2024-01-08 13:25:03 +00:00
Matthew Russell c8067669c2 fix(trading): ensure fees queries always fetch on mount and poll (#5566) 2024-01-05 12:43:30 +00:00
78f5a9c520 feat(trading): activity streaks, reward hoarder bonus and active rewards (#5491)
Co-authored-by: candida-d <62548908+candida-d@users.noreply.github.com>
Co-authored-by: Matthew Russell <mattrussell36@gmail.com>
Co-authored-by: bwallacee <ben@vega.xyz>
2024-01-05 11:16:59 +00:00
Ben 462066959d chore(trading): update vega (#5563) 2024-01-03 12:00:00 +00:00
daro-maj f12f5ab961 chore(trading): fix volume issue for market selector tests (#5564) 2024-01-03 12:04:49 +01:00
98b2460d8b fix(trading): mobile friendly referral and fees views (#5530)
Co-authored-by: Dariusz Majcherczyk <dariusz.majcherczyk@gmail.com>
Co-authored-by: Matthew Russell <mattrussell36@gmail.com>
2024-01-02 15:50:04 +00:00
Matthew Russell 67c182f3de chore(trading-view): minor tv chart tweaks (#5545) 2024-01-02 13:20:01 +00:00
68e0009ab0 feat(trading,markets,proposals): market banners (#5494)
Co-authored-by: bwallacee <ben@vega.xyz>
Co-authored-by: Dariusz Majcherczyk <dariusz.majcherczyk@gmail.com>
2024-01-02 11:04:43 +00:00
278 changed files with 27227 additions and 4079 deletions
+46 -3
View File
@@ -19,7 +19,7 @@ jobs:
create-docker-image:
name: Create docker image for console-test
runs-on: ubuntu-22.04
timeout-minutes: 20
timeout-minutes: 45
steps:
#----------------------------------------------
# check-out frontend-monorepo
@@ -138,7 +138,7 @@ jobs:
name: run-tests
runs-on: 8-cores
needs: [create-docker-image, console-test-branch]
timeout-minutes: 20
timeout-minutes: 45
steps:
#----------------------------------------------
# load docker image
@@ -205,7 +205,7 @@ jobs:
# run tests
#----------------------------------------------
- name: Run tests
run: CONSOLE_IMAGE_NAME=ci/trading:local poetry run pytest -v -s --numprocesses 4 --dist loadfile --durations=15
run: CONSOLE_IMAGE_NAME=ci/trading:local poetry run pytest -v -s --numprocesses 1 --dist loadfile --durations=45
working-directory: apps/trading/e2e
#----------------------------------------------
# upload traces
@@ -227,3 +227,46 @@ jobs:
name: worker-logs
path: ./logs/
retention-days: 15
#----------------------------------------------
# ----- upload market-sim logs -----
#----------------------------------------------
- name: Prepare and Zip market-sim-logs
if: always()
run: |
parent_dir="/tmp/market-sim-logs"
echo "Creating parent directory at $parent_dir"
mkdir -p "$parent_dir"
echo "Waiting for vega-sim-* folders to be created..."
sleep 10 # Waits 10 seconds to ensure all folders are created
echo "Before searching for vega-sim-* folders in /tmp..."
folders=$(find /tmp -mindepth 1 -type d -name 'vega-sim-*' -print) || echo "Find command failed with exit code $?"
echo "After searching for vega-sim-* folders in /tmp..."
if [ -z "$folders" ]; then
echo "No vega-sim-* folders found."
exit 0
fi
echo "Moving vega-sim-* folders to $parent_dir"
echo "$folders" | xargs -I {} mv {} "$parent_dir/"
echo "Checking if $parent_dir is not empty..."
if [ "$(ls -A $parent_dir)" ]; then
echo "Zipping the parent directory..."
zip -r market-sim-logs.zip "$parent_dir" && echo "Zip file created successfully."
else
echo "$parent_dir is empty. No zip file created."
exit 0
fi
shell: /usr/bin/bash -e {0}
- name: Upload market-sim-logs
uses: actions/upload-artifact@v3
if: always()
with:
name: market-sim-logs
path: market-sim-logs.zip
retention-days: 15
@@ -44,7 +44,7 @@ context('Proposal page', { tags: '@smoke' }, function () {
cy.getByTestId('icon-cross').click();
});
it.skip('Proposal page displayed on mobile', function () {
it('Proposal page displayed on mobile', function () {
const proposalTitle = 'Add Lorem Ipsum market';
cy.common_switch_to_mobile_and_click_toggle();
@@ -55,7 +55,7 @@ context('Proposal page', { tags: '@smoke' }, function () {
});
});
it('Able to view new asset proposal', function () {
it.skip('Able to view new asset proposal', function () {
const proposalTitle = 'Test new asset proposal';
const newAssetProposalBody = getNewAssetTxBody();
cy.VegaWalletSubmitProposal(newAssetProposalBody);
@@ -60,7 +60,7 @@ const PartyLink = ({ id, truncate = false, ...props }: PartyLinkProps) => {
}
return (
<span className="whitespace-nowrap">
<span>
{useName && <Icon size={4} name="cube" className="mr-2" />}
<Link
className="underline font-mono"
@@ -1,9 +1,5 @@
import { render, waitFor } from '@testing-library/react';
import {
BORDER_COLOURS,
NestedDataList,
sortNestedDataByChildren,
} from './nested-data-list';
import { NestedDataList, sortNestedDataByChildren } from './nested-data-list';
import userEvent from '@testing-library/user-event';
const mockData = {
@@ -61,38 +57,6 @@ describe('NestedDataList', () => {
expect(parent[0].querySelector('li')).toHaveClass('pl-4 border-l-4 pt-2');
});
it('should repeat the border colours in the correct order', () => {
const colourMockData = {
t0: {
t1: {
t2: {
t3: {
t4: {
t5: {
t6: {
t7: {
t8: {
hello: 'world',
},
},
},
},
},
},
},
},
},
};
const tree = render(<NestedDataList data={colourMockData} />);
const { getByTestId } = tree;
for (let i = 0; i < 8; i++) {
const item = getByTestId(`T${i}`);
const expected = BORDER_COLOURS.light[i % 5];
expect(item.style.borderColor.toUpperCase()).toBe(expected);
}
});
it('should sort the data by values with children', () => {
const mockData = {
nonce: '5980890939790185837',
@@ -60,7 +60,7 @@ const DeterministicOrderDetails = ({
const o = data.orderByID;
return (
<div className={wrapperClasses}>
<div className="mb-12 lg:mb-0">
<div className="mb-0">
<div className="relative block px-3 py-6 md:px-6 lg:-mr-7">
<h2 className="text-3xl font-bold mb-4 display-5">
<abbr title={tifFull[o.timeInForce]} className="bb-dotted mr-2">
@@ -89,9 +89,9 @@ const DeterministicOrderDetails = ({
<span>{t('Reference')}</span>: {o.reference}
</p>
) : null}
<div className="grid md:grid-cols-5 gap-x-6 mt-4">
<div className="mb-12 md:mb-0">
<h2 className="text-2xl font-bold text-dark mb-4">
<div className="grid grid-cols-2 md:grid-cols-5 gap-x-6 mt-4">
<div className="mb-6 md:mb-0">
<h2 className="text-2xl font-bold text-dark mb-0 md:mb-4">
{t('Status')}
</h2>
<h5 className="text-lg font-medium text-gray-500 mb-0 capitalize">
@@ -99,15 +99,17 @@ const DeterministicOrderDetails = ({
</h5>
</div>
<div className="mb-12 md:mb-0">
<h2 className="text-2xl font-bold text-dark mb-4">{t('Size')}</h2>
<div className="mb-6 md:mb-0">
<h2 className="text-2xl font-bold text-dark mb-0 md:mb-4">
{t('Size')}
</h2>
<h5 className="text-lg font-medium text-gray-500 mb-0">
<SizeInMarket size={o.size} marketId={o.market.id} />
</h5>
</div>
<div className="">
<h2 className="text-2xl font-bold text-dark mb-4">
<div className="mb-6 md:mb-0">
<h2 className="text-2xl font-bold text-dark mb-0 md:mb-4">
{t('Version')}
</h2>
<h5 className="text-lg font-medium text-gray-500 mb-0">
@@ -115,8 +117,8 @@ const DeterministicOrderDetails = ({
</h5>
</div>
{o.type ? (
<div className="">
<h2 className="text-2xl font-bold text-dark mb-4">
<div className="mb-6 md:mb-0">
<h2 className="text-2xl font-bold text-dark mb-0 md:mb-4">
{t('Type')}
</h2>
<h5 className="text-lg font-medium text-gray-500 mb-0">
@@ -30,12 +30,12 @@ export const Signature = ({ signature }: SignatureProps) => {
return (
<div className="inline-flex border rounded signature-component relative pr-[20px]">
<span
<div
className="bg-gray-100 px-2.5 py-0.5 text-xs text-gray-500 select-none cursor-default"
title={`Version ${signature.version}`}
title={`${signature.algo}`}
>
{signature.algo}
</span>
<span>v{signature.version}</span>
</div>
<div
className={
isOpen
@@ -0,0 +1,54 @@
import * as Types from '@vegaprotocol/types';
import { gql } from '@apollo/client';
import * as Apollo from '@apollo/client';
const defaultOptions = {} as const;
export type ExplorerReferralCodeOwnerQueryVariables = Types.Exact<{
id: Types.Scalars['ID'];
}>;
export type ExplorerReferralCodeOwnerQuery = { __typename?: 'Query', referralSets: { __typename?: 'ReferralSetConnection', edges: Array<{ __typename?: 'ReferralSetEdge', node: { __typename?: 'ReferralSet', createdAt: any, updatedAt: any, referrer: string } } | null> } };
export const ExplorerReferralCodeOwnerDocument = gql`
query ExplorerReferralCodeOwner($id: ID!) {
referralSets(id: $id) {
edges {
node {
createdAt
updatedAt
referrer
}
}
}
}
`;
/**
* __useExplorerReferralCodeOwnerQuery__
*
* To run a query within a React component, call `useExplorerReferralCodeOwnerQuery` and pass it any options that fit your needs.
* When your component renders, `useExplorerReferralCodeOwnerQuery` 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 } = useExplorerReferralCodeOwnerQuery({
* variables: {
* id: // value for 'id'
* },
* });
*/
export function useExplorerReferralCodeOwnerQuery(baseOptions: Apollo.QueryHookOptions<ExplorerReferralCodeOwnerQuery, ExplorerReferralCodeOwnerQueryVariables>) {
const options = {...defaultOptions, ...baseOptions}
return Apollo.useQuery<ExplorerReferralCodeOwnerQuery, ExplorerReferralCodeOwnerQueryVariables>(ExplorerReferralCodeOwnerDocument, options);
}
export function useExplorerReferralCodeOwnerLazyQuery(baseOptions?: Apollo.LazyQueryHookOptions<ExplorerReferralCodeOwnerQuery, ExplorerReferralCodeOwnerQueryVariables>) {
const options = {...defaultOptions, ...baseOptions}
return Apollo.useLazyQuery<ExplorerReferralCodeOwnerQuery, ExplorerReferralCodeOwnerQueryVariables>(ExplorerReferralCodeOwnerDocument, options);
}
export type ExplorerReferralCodeOwnerQueryHookResult = ReturnType<typeof useExplorerReferralCodeOwnerQuery>;
export type ExplorerReferralCodeOwnerLazyQueryHookResult = ReturnType<typeof useExplorerReferralCodeOwnerLazyQuery>;
export type ExplorerReferralCodeOwnerQueryResult = Apollo.QueryResult<ExplorerReferralCodeOwnerQuery, ExplorerReferralCodeOwnerQueryVariables>;
@@ -0,0 +1,11 @@
query ExplorerReferralCodeOwner($id: ID!) {
referralSets(id: $id) {
edges {
node {
createdAt
updatedAt
referrer
}
}
}
}
@@ -0,0 +1,95 @@
import { render, waitFor } from '@testing-library/react';
import type { MockedResponse } from '@apollo/client/testing';
import { MockedProvider } from '@apollo/client/testing';
import { MemoryRouter } from 'react-router-dom';
import { ReferralCodeOwner } from './referral-code-owner';
import type { ReferralCodeOwnerProps } from './referral-code-owner';
import { ExplorerReferralCodeOwnerDocument } from './__generated__/code-owner';
const renderComponent = (
props: ReferralCodeOwnerProps,
mocks: MockedResponse[]
) => {
return render(
<MockedProvider mocks={mocks}>
<MemoryRouter>
<ReferralCodeOwner {...props} />
</MemoryRouter>
</MockedProvider>
);
};
describe('ReferralCodeOwner', () => {
it('should render loading state', () => {
const mocks = [
{
request: {
query: ExplorerReferralCodeOwnerDocument,
variables: {
id: 'ABC123',
},
},
},
];
const { getByText } = renderComponent({ code: 'ABC123' }, mocks);
expect(getByText('Loading...')).toBeInTheDocument();
});
it('should render error state', async () => {
const errorMessage = 'Error fetching referrer: ABC123';
const mocks = [
{
request: {
query: ExplorerReferralCodeOwnerDocument,
variables: {
id: 'ABC123',
},
},
error: new Error('nope'),
},
];
const { getByText } = renderComponent({ code: 'ABC123' }, mocks);
await waitFor(() => {
expect(getByText(errorMessage)).toBeInTheDocument();
});
});
it('should render link to referring party', async () => {
const referrerId = 'DEF789';
const mocks = [
{
request: {
query: ExplorerReferralCodeOwnerDocument,
variables: {
id: 'ABC123',
},
},
result: {
data: {
referralSets: {
edges: [
{
node: {
__typename: 'ReferralSet',
referrer: referrerId,
createdAt: '123',
updatedAt: '456',
},
},
],
},
},
},
},
];
const { getByText } = renderComponent({ code: 'ABC123' }, mocks);
await waitFor(() => {
expect(getByText(referrerId)).toBeInTheDocument();
});
});
});
@@ -0,0 +1,26 @@
import { TableCell } from '../../../table';
import { useExplorerReferralCodeOwnerQuery } from './__generated__/code-owner';
import { PartyLink } from '../../../links';
export interface ReferralCodeOwnerProps {
code: string;
}
/**
* Render the owner of a referral code
*/
export const ReferralCodeOwner = ({ code }: ReferralCodeOwnerProps) => {
const { data, error, loading } = useExplorerReferralCodeOwnerQuery({
variables: {
id: code,
},
});
const referrer = data?.referralSets.edges[0]?.node.referrer || '';
return (
<TableCell>
{loading && 'Loading...'}
{error && `Error fetching referrer: ${code}`}
{referrer.length > 0 && <PartyLink id={referrer} />}
</TableCell>
);
};
@@ -0,0 +1,93 @@
import { render } from '@testing-library/react';
import { ReferralTeam } from './team';
import type { CreateReferralSet } from './team';
import { MockedProvider } from '@apollo/client/testing';
describe('ReferralTeam', () => {
const team = {
name: 'Test Team',
teamUrl: 'https://example.com/team',
avatarUrl: 'https://example.com/avatar',
closed: false,
};
const mockTx: CreateReferralSet = {
team,
};
const mockId = '123456';
const mockCreator = 'JohnDoe';
it('should render the team name', () => {
const { getByText } = render(
<MockedProvider>
<ReferralTeam tx={mockTx} id={mockId} creator={mockCreator} />
</MockedProvider>
);
expect(getByText('Test Team')).toBeInTheDocument();
});
it('should render the team ID', () => {
const { getByText } = render(
<MockedProvider>
<ReferralTeam tx={mockTx} id={mockId} creator={mockCreator} />
</MockedProvider>
);
expect(getByText('Id')).toBeInTheDocument();
expect(getByText(mockId)).toBeInTheDocument();
});
it('should render the creator', () => {
const { getByText } = render(
<MockedProvider>
<ReferralTeam tx={mockTx} id={mockId} creator={mockCreator} />
</MockedProvider>
);
expect(getByText('Creator')).toBeInTheDocument();
expect(getByText(mockCreator)).toBeInTheDocument();
});
it('should render the team URL', () => {
const { getByText } = render(
<MockedProvider>
<ReferralTeam tx={mockTx} id={mockId} creator={mockCreator} />
</MockedProvider>
);
expect(getByText('Team URL')).toBeInTheDocument();
expect(getByText(team.teamUrl)).toBeInTheDocument();
});
it('should render the avatar URL', () => {
const { getByText } = render(
<MockedProvider>
<ReferralTeam tx={mockTx} id={mockId} creator={mockCreator} />
</MockedProvider>
);
expect(getByText('Avatar')).toBeInTheDocument();
expect(getByText(team.avatarUrl)).toBeInTheDocument();
});
it('should render the open status as a tick if closed is falsy', () => {
const { getByTestId } = render(
<MockedProvider>
<ReferralTeam tx={mockTx} id={mockId} creator={mockCreator} />
</MockedProvider>
);
expect(getByTestId('open-yes')).toBeInTheDocument();
});
it('should render the open status as a cross if it is truthy', () => {
const m = {
team: {
closed: true,
},
};
const { getByTestId } = render(
<MockedProvider>
<ReferralTeam tx={m} id={mockId} creator={mockCreator} />
</MockedProvider>
);
expect(getByTestId('open-no')).toBeInTheDocument();
});
});
@@ -0,0 +1,73 @@
import {
VegaIcon,
Icon,
KeyValueTable,
KeyValueTableRow,
VegaIconNames,
} from '@vegaprotocol/ui-toolkit';
import type { components } from '../../../../../types/explorer';
import Hash from '../../../links/hash';
import { t } from 'i18next';
import { PartyLink } from '../../../links';
export type CreateReferralSet = components['schemas']['v1CreateReferralSet'];
export type ReferralTeam = CreateReferralSet['team'];
export interface ReferralTeamProps {
tx: CreateReferralSet;
id: string;
creator: string;
}
/**
* Renders the details for a team in a CreateReferralSet or UpdateReferralSet transaction.
*
* Intentionally does not render the avatar image or link to the team url.
*/
export const ReferralTeam = ({ tx, id, creator }: ReferralTeamProps) => {
if (!tx.team) {
return null;
}
return (
<section>
<div className="inline-block mr-2 leading-none">
<VegaIcon name={VegaIconNames.TEAM} />
</div>
{tx.team.name && (
<h3 className="inline-block leading-loose">{tx.team.name}</h3>
)}
<div className="min-w-fit max-w-2xl block">
<KeyValueTable>
<KeyValueTableRow>
{t('Id')}
<Hash text={id} truncate={false} />
</KeyValueTableRow>
<KeyValueTableRow>
{t('Creator')}
<PartyLink id={creator} truncate={false} />
</KeyValueTableRow>
{tx.team.teamUrl && (
<KeyValueTableRow>
{t('Team URL')}
<Hash text={tx.team.teamUrl} truncate={false} />
</KeyValueTableRow>
)}
{tx.team.avatarUrl && (
<KeyValueTableRow>
{t('Avatar')}
<Hash text={tx.team.avatarUrl} truncate={false} />
</KeyValueTableRow>
)}
<KeyValueTableRow>
{t('Open')}
<span data-testid={!tx.team.closed ? 'open-yes' : 'open-no'}>
{!tx.team.closed ? <Icon name="tick" /> : <Icon name="cross" />}
</span>
</KeyValueTableRow>
</KeyValueTable>
</div>
</section>
);
};
@@ -27,7 +27,8 @@ export const sharedHeaderProps = {
className: 'align-top',
};
const Labels: Record<BlockExplorerTransactionResult['type'], string> = {
// The incoming type field is usually the right thing to show. Exceptions are listed here
const LabelOverrides: Record<BlockExplorerTransactionResult['type'], string> = {
'Stop Orders Submission': 'Stop Order',
'Stop Orders Cancellation': 'Cancel Stop Order',
};
@@ -50,7 +51,7 @@ export const TxDetailsShared = ({
const time: string = blockData?.result.block.header.time || '';
const height: string = blockData?.result.block.header.height || txData.block;
const type = Labels[txData.type] || txData.type;
const type = LabelOverrides[txData.type] || txData.type;
return (
<>
@@ -0,0 +1,47 @@
import { t } from '@vegaprotocol/i18n';
import type { BlockExplorerTransactionResult } from '../../../routes/types/block-explorer-response';
import type { TendermintBlocksResponse } from '../../../routes/blocks/tendermint-blocks-response';
import { TxDetailsShared } from './shared/tx-details-shared';
import { TableCell, TableRow, TableWithTbody } from '../../table';
import { ReferralCodeOwner } from './referrals/referral-code-owner';
interface TxDetailsApplyReferralCodeProps {
txData: BlockExplorerTransactionResult | undefined;
pubKey: string | undefined;
blockData: TendermintBlocksResponse | undefined;
}
/**
* The signature can be turned in to an id with txSignatureToDeterministicId but
*/
export const TxDetailsApplyReferralCode = ({
txData,
pubKey,
blockData,
}: TxDetailsApplyReferralCodeProps) => {
if (!txData || !txData.command.applyReferralCode || !txData.signature) {
return <>{t('Awaiting Block Explorer transaction details')}</>;
}
const referralCode = txData.command.applyReferralCode.id;
return (
<div>
<TableWithTbody className="mb-8" allowWrap={true}>
<TxDetailsShared
txData={txData}
pubKey={pubKey}
blockData={blockData}
/>
<TableRow modifier="bordered">
<TableCell>{t('Applied Code')}</TableCell>
<TableCell>{referralCode}</TableCell>
</TableRow>
<TableRow modifier="bordered">
<TableCell>{t('Referrer')}</TableCell>
<ReferralCodeOwner code={referralCode} />
</TableRow>
</TableWithTbody>
</div>
);
};
@@ -0,0 +1,52 @@
import { t } from '@vegaprotocol/i18n';
import type { BlockExplorerTransactionResult } from '../../../routes/types/block-explorer-response';
import type { TendermintBlocksResponse } from '../../../routes/blocks/tendermint-blocks-response';
import { TxDetailsShared } from './shared/tx-details-shared';
import { TableCell, TableRow, TableWithTbody } from '../../table';
import { txSignatureToDeterministicId } from '../lib/deterministic-ids';
import { ReferralTeam } from './referrals/team';
interface TxDetailsCreateReferralProps {
txData: BlockExplorerTransactionResult | undefined;
pubKey: string | undefined;
blockData: TendermintBlocksResponse | undefined;
}
/**
* The signature can be turned in to an id with txSignatureToDeterministicId but
*/
export const TxDetailsCreateReferralSet = ({
txData,
pubKey,
blockData,
}: TxDetailsCreateReferralProps) => {
if (!txData || !txData.command.createReferralSet || !txData.signature) {
return <>{t('Awaiting Block Explorer transaction details')}</>;
}
const id = txSignatureToDeterministicId(txData.signature.value);
const isTeam = txData.command.createReferralSet.isTeam || false;
return (
<div>
<TableWithTbody className="mb-8" allowWrap={true}>
<TxDetailsShared
txData={txData}
pubKey={pubKey}
blockData={blockData}
/>
<TableRow modifier="bordered">
<TableCell>{isTeam ? t('Team ID') : t('Referral code')}</TableCell>
<TableCell>{id}</TableCell>
</TableRow>
</TableWithTbody>
<ReferralTeam
tx={txData.command.createReferralSet}
id={id}
creator={txData.submitter}
/>
</div>
);
};
@@ -28,6 +28,10 @@ import { TxProposal } from './tx-proposal';
import { TxDetailsTransfer } from './tx-transfer';
import { TxDetailsStopOrderSubmission } from './tx-stop-order-submission';
import { TxDetailsLiquiditySubmission } from './tx-liquidity-submission';
import { TxDetailsCreateReferralSet } from './tx-create-referral-set';
import { TxDetailsApplyReferralCode } from './tx-apply-referral-code';
import { TxDetailsUpdateReferralSet } from './tx-update-referral-set';
import { TxDetailsJoinTeam } from './tx-join-team';
interface TxDetailsWrapperProps {
txData: BlockExplorerTransactionResult | undefined;
@@ -121,6 +125,14 @@ function getTransactionComponent(txData?: BlockExplorerTransactionResult) {
return TxDetailsStopOrderSubmission;
case 'Transfer Funds':
return TxDetailsTransfer;
case 'Create Referral Set':
return TxDetailsCreateReferralSet;
case 'Update Referral Set':
return TxDetailsUpdateReferralSet;
case 'Apply Referral Code':
return TxDetailsApplyReferralCode;
case 'Join Team':
return TxDetailsJoinTeam;
default:
return TxDetailsGeneric;
}
@@ -0,0 +1,47 @@
import { t } from '@vegaprotocol/i18n';
import type { BlockExplorerTransactionResult } from '../../../routes/types/block-explorer-response';
import type { TendermintBlocksResponse } from '../../../routes/blocks/tendermint-blocks-response';
import { TxDetailsShared } from './shared/tx-details-shared';
import { TableCell, TableRow, TableWithTbody } from '../../table';
import { ReferralCodeOwner } from './referrals/referral-code-owner';
interface TxDetailsJoinTeamProps {
txData: BlockExplorerTransactionResult | undefined;
pubKey: string | undefined;
blockData: TendermintBlocksResponse | undefined;
}
/**
* The signature can be turned in to an id with txSignatureToDeterministicId but
*/
export const TxDetailsJoinTeam = ({
txData,
pubKey,
blockData,
}: TxDetailsJoinTeamProps) => {
if (!txData || !txData.command.joinTeam || !txData.signature) {
return <>{t('Awaiting Block Explorer transaction details')}</>;
}
const team = txData.command.joinTeam.id;
return (
<div>
<TableWithTbody className="mb-8" allowWrap={true}>
<TxDetailsShared
txData={txData}
pubKey={pubKey}
blockData={blockData}
/>
<TableRow modifier="bordered">
<TableCell>{t('Team')}</TableCell>
<TableCell>{team}</TableCell>
</TableRow>
<TableRow modifier="bordered">
<TableCell>{t('Referrer')}</TableCell>
<ReferralCodeOwner code={team} />
</TableRow>
</TableWithTbody>
</div>
);
};
@@ -0,0 +1,54 @@
import { t } from '@vegaprotocol/i18n';
import type { BlockExplorerTransactionResult } from '../../../routes/types/block-explorer-response';
import type { TendermintBlocksResponse } from '../../../routes/blocks/tendermint-blocks-response';
import { TxDetailsShared } from './shared/tx-details-shared';
import { TableCell, TableRow, TableWithTbody } from '../../table';
import { txSignatureToDeterministicId } from '../lib/deterministic-ids';
import { ReferralTeam } from './referrals/team';
interface TxDetailsUpdateReferralProps {
txData: BlockExplorerTransactionResult | undefined;
pubKey: string | undefined;
blockData: TendermintBlocksResponse | undefined;
}
/**
* A copy of create referral set, effectively.
* Updating a referral set without a team doesn't make sense,
* but is valid, so this component ignores sense.
*/
export const TxDetailsUpdateReferralSet = ({
txData,
pubKey,
blockData,
}: TxDetailsUpdateReferralProps) => {
if (!txData || !txData.command.updateReferralSet || !txData.signature) {
return <>{t('Awaiting Block Explorer transaction details')}</>;
}
const id = txSignatureToDeterministicId(txData.signature.value);
const isTeam = txData.command.updateReferralSet.isTeam || false;
return (
<div>
<TableWithTbody className="mb-8" allowWrap={true}>
<TxDetailsShared
txData={txData}
pubKey={pubKey}
blockData={blockData}
/>
<TableRow modifier="bordered">
<TableCell>{isTeam ? t('Team ID') : t('Referral code')}</TableCell>
<TableCell>{id}</TableCell>
</TableRow>
</TableWithTbody>
<ReferralTeam
tx={txData.command.updateReferralSet}
id={id}
creator={txData.submitter}
/>
</div>
);
};
@@ -28,6 +28,7 @@ export type FilterOption =
| 'Delegate'
| 'Ethereum Key Rotate Submission'
| 'Issue Signatures'
| 'Join Team'
| 'Key Rotate Submission'
| 'Liquidity Provision Order'
| 'Node Signature'
@@ -47,45 +48,46 @@ export type FilterOption =
| 'Vote on Proposal'
| 'Withdraw';
// Alphabetised list of transaction types to appear at the top level
export const PrimaryFilterOptions: FilterOption[] = [
'Amend LiquidityProvision Order',
'Amend Order',
'Batch Market Instructions',
'Cancel LiquidityProvision Order',
'Cancel Order',
'Cancel Transfer Funds',
'Delegate',
'Liquidity Provision Order',
'Proposal',
'Stop Orders Submission',
'Stop Orders Cancellation',
'Submit Oracle Data',
'Submit Order',
'Transfer Funds',
'Undelegate',
'Vote on Proposal',
'Withdraw',
];
export const filterOptions: Record<string, FilterOption[]> = {
'Market Instructions': [
'Amend LiquidityProvision Order',
'Amend Order',
'Batch Market Instructions',
'Cancel LiquidityProvision Order',
'Cancel Order',
'Liquidity Provision Order',
'Stop Orders Submission',
'Stop Orders Cancellation',
'Submit Order',
],
'Transfers and Withdrawals': [
'Transfer Funds',
'Cancel Transfer Funds',
'Withdraw',
],
Governance: ['Delegate', 'Undelegate', 'Vote on Proposal', 'Proposal'],
Referrals: [
'Apply Referral Code',
'Create Referral Set',
'Join Team',
'Update Referral Set',
],
'External Data': ['Chain Event', 'Submit Oracle Data'],
Validators: [
'Ethereum Key Rotate Submission',
'Issue Signatures',
'Key Rotate Submission',
'Node Signature',
'Node Vote',
'Protocol Upgrade',
'Register new Node',
'State Variable Proposal',
'Validator Heartbeat',
],
};
// Alphabetised list of transaction types to nest under a 'More...' submenu
export const SecondaryFilterOptions: FilterOption[] = [
'Chain Event',
'Ethereum Key Rotate Submission',
'Issue Signatures',
'Key Rotate Submission',
'Node Signature',
'Node Vote',
'Protocol Upgrade',
'Register new Node',
'State Variable Proposal',
'Validator Heartbeat',
];
export const AllFilterOptions: FilterOption[] = [
...PrimaryFilterOptions,
...SecondaryFilterOptions,
];
export const AllFilterOptions: FilterOption[] =
Object.values(filterOptions).flat();
export interface TxFilterProps {
filters: Set<FilterOption>;
@@ -122,46 +124,33 @@ export const TxsFilter = ({ filters, setFilters }: TxFilterProps) => {
<DropdownMenuSeparator />
</>
)}
{PrimaryFilterOptions.map((f) => (
<DropdownMenuCheckboxItem
key={f}
checked={filters.has(f)}
onCheckedChange={() => {
// NOTE: These act like radio buttons until the API supports multiple filters
setFilters(new Set([f]));
}}
id={`radio-${f}`}
>
{f}
<DropdownMenuItemIndicator>
<Icon name="tick-circle" />
</DropdownMenuItemIndicator>
</DropdownMenuCheckboxItem>
{Object.entries(filterOptions).map(([key, value]) => (
<DropdownMenuSub key={key}>
<DropdownMenuSubTrigger>
{t(key)}
<Icon name="chevron-right" />
</DropdownMenuSubTrigger>
<DropdownMenuSubContent>
{value.map((f) => (
<DropdownMenuCheckboxItem
key={f}
checked={filters.has(f)}
onCheckedChange={(checked) => {
// NOTE: These act like radio buttons until the API supports multiple filters
setFilters(new Set([f]));
}}
id={`radio-${f}`}
>
{f}
<DropdownMenuItemIndicator>
<Icon name="tick-circle" className="inline" />
</DropdownMenuItemIndicator>
</DropdownMenuCheckboxItem>
))}
</DropdownMenuSubContent>
</DropdownMenuSub>
))}
<DropdownMenuSub>
<DropdownMenuSubTrigger>
{t('More Types')}
<Icon name="chevron-right" />
</DropdownMenuSubTrigger>
<DropdownMenuSubContent>
{SecondaryFilterOptions.map((f) => (
<DropdownMenuCheckboxItem
key={f}
checked={filters.has(f)}
onCheckedChange={(checked) => {
// NOTE: These act like radio buttons until the API supports multiple filters
setFilters(new Set([f]));
}}
id={`radio-${f}`}
>
{f}
<DropdownMenuItemIndicator>
<Icon name="tick-circle" className="inline" />
</DropdownMenuItemIndicator>
</DropdownMenuCheckboxItem>
))}
</DropdownMenuSubContent>
</DropdownMenuSub>
</DropdownMenuContent>
</DropdownMenu>
);
@@ -48,6 +48,8 @@ const displayString: StringMap = {
StopOrdersSubmission: 'Stop',
StopOrdersCancellation: 'Cancel stop',
'Stop Orders Cancellation': 'Cancel stop',
'Apply Referral Code': 'Referral',
'Create Referral Set': 'Create referral',
};
export function getLabelForStopOrderType(
+1 -1
View File
@@ -73,7 +73,7 @@ export const Layout = () => {
<ProtocolUpgradeInProgressNotification />
</div>
<div className={fixedWidthClasses}>
<main className="p-4">
<main className="md:p-4">
{!isHome && <BreadcrumbsContainer className="mb-4" />}
<Outlet />
</main>
@@ -31,6 +31,10 @@ const Tx = () => {
}
}
if (!data || !data?.transaction) {
errorMessage = 'Transaction not found';
}
return (
<section>
<PageHeader
@@ -49,7 +53,7 @@ const Tx = () => {
<TxDetails
className="mb-28"
txData={data?.transaction}
pubKey={data?.transaction.submitter}
pubKey={data?.transaction?.submitter}
/>
</RenderFetched>
</section>
@@ -11,8 +11,8 @@ interface TxDetailsProps {
export const txDetailsTruncateLength = 30;
export const TxDetails = ({ txData, pubKey }: TxDetailsProps) => {
if (!txData) {
return <>{t('Awaiting Block Explorer transaction details')}</>;
if (!txData || !pubKey) {
return <>{t('Transaction could not be found')}</>;
}
return (
<section className="mb-10" key={txData.hash}>
+2 -2
View File
@@ -8,13 +8,13 @@ type Without<T, U> = { [P in Exclude<keyof T, keyof U>]?: never };
type XOR<T, U> = T | U extends object
? (Without<T, U> & U) | (Without<U, T> & T)
: T | U;
/* eslint-disable @typescript-eslint/no-explicit-any */
/* eslint-disable @typescript-eslint/no-explicit-any */
type OneOf<T extends any[]> = T extends [infer Only]
? Only
: T extends [infer A, infer B, ...infer Rest]
? OneOf<[XOR<A, B>, ...Rest]>
: never;
/* eslint-enable @typescript-eslint/no-explicit-any */
/* eslint-enable @typescript-eslint/no-explicit-any */
export interface paths {
'/info': {
@@ -47,7 +47,7 @@ context('View functionality with public key', { tags: '@smoke' }, function () {
.and('contain.text', 'USDC (fake)');
});
it('Unable to submit proposal with public key', function () {
it.skip('Unable to submit proposal with public key', function () {
const expectedErrorTxt = `You are connected in a view only state for public key: ${vegaWalletPubKey}. In order to send transactions you must connect to a real wallet.`;
goToMakeNewProposal(governanceProposalType.RAW);
@@ -55,7 +55,10 @@ context('View functionality with public key', { tags: '@smoke' }, function () {
cy.getByTestId('dialog-content')
.first()
.within(() => {
cy.get('h1').should('have.text', 'Transaction failed');
cy.getByTestId('dialog-title').should(
'have.text',
'Transaction failed'
);
cy.getByTestId('Error').should('have.text', expectedErrorTxt);
});
});
@@ -112,6 +112,7 @@ export function createNewMarketProposalTxBody(): ProposalSubmissionBody {
performanceHysteresisEpochs: 2,
slaCompetitionFactor: '0.1',
},
// FIXME: workaround because of https://github.com/vegaprotocol/vega/issues/10343
quadraticSlippageFactor: '0',
instrument: {
name: 'Token test market',
@@ -196,6 +197,7 @@ export function createNewMarketProposalTxBody(): ProposalSubmissionBody {
timeWindow: '3600',
scalingFactor: 10,
},
// FIXME: workaround because of https://github.com/vegaprotocol/vega/issues/10343
triggeringRatio: '0.7',
auctionExtension: '1',
},
@@ -240,6 +242,7 @@ export function createSuccessorMarketProposalTxBody(
decimalPlaces: '5',
positionDecimalPlaces: '5',
linearSlippageFactor: '0.001',
// FIXME: workaround because of https://github.com/vegaprotocol/vega/issues/10343
quadraticSlippageFactor: '0',
liquiditySlaParameters: {
priceRange: '0.5',
@@ -334,6 +337,7 @@ export function createSuccessorMarketProposalTxBody(
timeWindow: '3600',
scalingFactor: 10,
},
// FIXME: workaround because of https://github.com/vegaprotocol/vega/issues/10343
triggeringRatio: '0.7',
auctionExtension: '1',
},
+9 -2
View File
@@ -26,7 +26,7 @@ import {
} from '@vegaprotocol/web3';
import { Web3Provider } from '@vegaprotocol/web3';
import { VegaWalletDialogs } from './components/vega-wallet-dialogs';
import { VegaWalletProvider } from '@vegaprotocol/wallet';
import { VegaWalletProvider, useChainId } from '@vegaprotocol/wallet';
import {
useVegaTransactionManager,
useVegaTransactionUpdater,
@@ -96,7 +96,9 @@ const cache: InMemoryCacheConfig = {
const Web3Container = ({
chainId,
}: {
/** Ethereum chain id */
chainId: number;
/** Ethereum provider url */
providerUrl: string;
}) => {
const InitializeHandlers = () => {
@@ -123,6 +125,9 @@ const Web3Container = ({
MOZILLA_EXTENSION_URL,
VEGA_WALLET_URL,
} = useEnvironment();
const vegaChainId = useChainId(VEGA_URL);
useEffect(() => {
if (chainId) {
return initializeConnectors(
@@ -157,7 +162,8 @@ const Web3Container = ({
!VEGA_EXPLORER_URL ||
!DocsLinks ||
!CHROME_EXTENSION_URL ||
!MOZILLA_EXTENSION_URL
!MOZILLA_EXTENSION_URL ||
!vegaChainId
) {
return null;
}
@@ -169,6 +175,7 @@ const Web3Container = ({
config={{
network: VEGA_ENV,
vegaUrl: VEGA_URL,
chainId: vegaChainId,
vegaWalletServiceUrl: VEGA_WALLET_URL,
links: {
explorer: VEGA_EXPLORER_URL,
+112
View File
@@ -0,0 +1,112 @@
import type { ApolloError } from '@apollo/client';
import {
PARTY_NOT_FOUND,
filterAcceptableGraphqlErrors,
isPartyNotFoundError,
} from './party';
import type { GraphQLError } from 'graphql';
/**
*
* @param message
* @returns GraphQLError
*/
function createMockApolloErrors(message: string): GraphQLError {
return {
message,
extensions: {
code: message.toUpperCase().replace(/ /g, '_'),
},
locations: [],
originalError: new Error(message),
path: [],
nodes: [],
positions: [1],
name: message,
source: {
body: message,
name: message,
locationOffset: {
line: 1,
column: 1,
},
},
};
}
describe('filterAcceptableGraphqlErrors', () => {
it('should return undefined if the error is a party not found error', () => {
const error: Partial<ApolloError> = {
graphQLErrors: [createMockApolloErrors('failed to get party for ID')],
};
const result = filterAcceptableGraphqlErrors(error as ApolloError);
expect(result).toBeUndefined();
});
it('should return the error if it is not a party not found error', () => {
const error: Partial<ApolloError> = {
graphQLErrors: [createMockApolloErrors('Some other error')],
};
const result = filterAcceptableGraphqlErrors(error as ApolloError);
expect(result).toEqual(error);
});
it('should return the error if there are multiple errors', () => {
const error: Partial<ApolloError> = {
graphQLErrors: [
createMockApolloErrors('failed to get party for ID'),
createMockApolloErrors('Some other error'),
],
};
const result = filterAcceptableGraphqlErrors(error as ApolloError);
expect(result).toEqual(error);
});
it('should return the error if there are no errors', () => {
const error: Partial<ApolloError> = {
graphQLErrors: [],
};
const result = filterAcceptableGraphqlErrors(error as ApolloError);
expect(result).toEqual(error);
});
it('should return undefined if the error is undefined', () => {
const result = filterAcceptableGraphqlErrors(undefined);
expect(result).toBeUndefined();
});
});
describe('isPartyNotFoundError', () => {
it('should return true if the error message includes PARTY_NOT_FOUND', () => {
const error = { message: 'failed to get party for ID' };
const result = isPartyNotFoundError(error);
expect(result).toBe(true);
});
it('should return false if the error message does not include PARTY_NOT_FOUND', () => {
const error = { message: 'Some other error' };
const result = isPartyNotFoundError(error);
expect(result).toBe(false);
});
// Will trip if the error message changes, which should not be a problem, but there
// might be logic that depends on it
it('expects party not found error to remain consistent', () => {
const error = 'failed to get party for ID';
expect(PARTY_NOT_FOUND).toStrictEqual(error);
});
});
+22
View File
@@ -1,3 +1,5 @@
import type { ApolloError } from '@apollo/client';
export const PARTY_NOT_FOUND = 'failed to get party for ID';
export const isPartyNotFoundError = (error: { message: string }) => {
@@ -6,3 +8,23 @@ export const isPartyNotFoundError = (error: { message: string }) => {
}
return false;
};
/**
* If a party has no accounts or data, then this GraphQL query believes it does not exist
* Not having any rewards is a valid state, so in some cases we can filter this error out.
*
* @param error ApolloError | undefined
* @returns ApolloError | undefined
*/
export function filterAcceptableGraphqlErrors(
error?: ApolloError
): ApolloError | undefined {
// Currently the only error we expect is when a party has no accounts
if (error && error.graphQLErrors.length === 1) {
if (isPartyNotFoundError(error.graphQLErrors[0])) {
return;
}
}
return error;
}
@@ -124,7 +124,7 @@ describe('Proposal header', () => {
screen.queryByTestId('proposal-description')
).not.toBeInTheDocument();
expect(screen.getByTestId('proposal-details')).toHaveTextContent(
'Market change: MarketId'
'Update to market ID: MarketId'
);
});
@@ -1,5 +1,11 @@
import { useTranslation } from 'react-i18next';
import { Lozenge, VegaIcon, VegaIconNames } from '@vegaprotocol/ui-toolkit';
import {
CopyWithTooltip,
Lozenge,
Tooltip,
VegaIcon,
VegaIconNames,
} from '@vegaprotocol/ui-toolkit';
import { shorten } from '@vegaprotocol/utils';
import { Heading, SubHeading } from '../../../../components/heading';
import type { ReactNode } from 'react';
@@ -12,7 +18,12 @@ import {
useNewTransferProposalDetails,
useSuccessorMarketProposalDetails,
} from '@vegaprotocol/proposals';
import { useFeatureFlags } from '@vegaprotocol/environment';
import {
CONSOLE_MARKET_PAGE,
DApp,
useFeatureFlags,
useLinks,
} from '@vegaprotocol/environment';
import Routes from '../../../routes';
import { Link } from 'react-router-dom';
import type { VoteState } from '../vote-details/use-user-vote';
@@ -32,6 +43,8 @@ export const ProposalHeader = ({
const { t } = useTranslation();
const change = proposal?.terms.change;
const consoleLink = useLinks(DApp.Console);
let details: ReactNode;
let proposalType = '';
let fallbackTitle = '';
@@ -106,8 +119,33 @@ export const ProposalHeader = ({
fallbackTitle = t('UpdateMarketProposal');
details = (
<>
<span>{t('MarketChange')}:</span>{' '}
<span>{truncateMiddle(change.marketId)}</span>
<span>{t('UpdateToMarket')}:</span>{' '}
<span className="inline-flex items-start gap-2">
<span className="break-all">{change.marketId} </span>
<span className="inline-flex items-end gap-0">
<CopyWithTooltip
text={change.marketId}
description={t('copyToClipboard')}
>
<button className="inline-block px-1">
<VegaIcon size={20} name={VegaIconNames.COPY} />
</button>
</CopyWithTooltip>
<Tooltip description={t('OpenInConsole')} align="center">
<button
className="inline-block px-1"
onClick={() => {
const marketPageLink = consoleLink(
CONSOLE_MARKET_PAGE.replace(':marketId', change.marketId)
);
window.open(marketPageLink, '_blank');
}}
>
<VegaIcon size={20} name={VegaIconNames.OPEN_EXTERNAL} />
</button>
</Tooltip>
</span>
</span>
</>
);
break;
@@ -48,6 +48,7 @@ const vegaWalletConfig: VegaWalletConfig = {
chromeExtensionUrl: 'chrome',
mozillaExtensionUrl: 'mozilla',
},
chainId: 'VEGA_CHAIN_ID',
};
const renderComponent = (proposal: ProposalQuery['proposal']) => {
@@ -18,6 +18,7 @@ import { ProposalMinRequirements, ProposalUserAction } from '../shared';
import { VoteTransactionDialog } from './vote-transaction-dialog';
import { useVoteButtonsQuery } from './__generated__/Stake';
import type { DialogProps, VegaTxState } from '@vegaprotocol/proposals';
import { filterAcceptableGraphqlErrors } from '../../../../lib/party';
interface VoteButtonsContainerProps {
voteState: VoteState | null;
@@ -42,8 +43,10 @@ export const VoteButtonsContainer = (props: VoteButtonsContainerProps) => {
skip: !pubKey,
});
const filteredErrors = filterAcceptableGraphqlErrors(error);
return (
<AsyncRenderer loading={loading} error={error} data={data}>
<AsyncRenderer loading={loading} error={filteredErrors} data={data}>
<VoteButtons
{...props}
currentStakeAvailable={toBigNum(
@@ -205,7 +205,6 @@ query Proposal(
}
}
liquidityMonitoringParameters {
triggeringRatio
targetStakeParameters {
timeWindow
scalingFactor
@@ -213,7 +212,6 @@ query Proposal(
}
positionDecimalPlaces
linearSlippageFactor
quadraticSlippageFactor
}
... on UpdateMarket {
marketId
@@ -366,7 +364,6 @@ query Proposal(
}
}
liquidityMonitoringParameters {
triggeringRatio
targetStakeParameters {
timeWindow
scalingFactor
File diff suppressed because one or more lines are too long
@@ -10,6 +10,7 @@ import { EpochIndividualRewardsTable } from './epoch-individual-rewards-table';
import { generateEpochIndividualRewardsList } from './generate-epoch-individual-rewards-list';
import { calculateEpochOffset } from '../../../lib/epoch-pagination';
import { useNetworkParam } from '@vegaprotocol/network-parameters';
import { filterAcceptableGraphqlErrors } from '../../../lib/party';
const EPOCHS_PAGE_SIZE = 10;
@@ -99,17 +100,24 @@ export const EpochIndividualRewards = ({
prevEpochIdRef.current = epochId;
}, [epochId, refetchData]);
// Workarounds for the error handling of AsyncRenderer
const filteredErrors = filterAcceptableGraphqlErrors(error);
const filteredData = data || [];
return (
<AsyncRenderer
loading={loading}
error={error}
data={data}
error={filteredErrors}
data={filteredData}
render={() => (
<div>
<p data-testid="connected-vega-key" className="mb-10">
{t('Connected Vega key')}:{' '}
<span className="text-white">{pubKey}</span>
</p>
{epochIndividualRewardSummaries.length === 0 && (
<p>{t('No rewards for key')}</p>
)}
{epochIndividualRewardSummaries.map(
(epochIndividualRewardSummary) => (
<EpochIndividualRewardsTable
@@ -118,17 +126,19 @@ export const EpochIndividualRewards = ({
/>
)
)}
<Pagination
isLoading={loading}
hasPrevPage={page > 1}
hasNextPage={page < totalPages}
onBack={() => refetchData(page - 1)}
onNext={() => refetchData(page + 1)}
onFirst={() => refetchData(1)}
onLast={() => refetchData(totalPages)}
>
{t('Page')} {page}
</Pagination>
{epochIndividualRewardSummaries.length > 0 && (
<Pagination
isLoading={loading}
hasPrevPage={page > 1}
hasNextPage={page < totalPages}
onBack={() => refetchData(page - 1)}
onNext={() => refetchData(page + 1)}
onFirst={() => refetchData(1)}
onLast={() => refetchData(totalPages)}
>
{t('Page')} {page}
</Pagination>
)}
</div>
)}
/>
@@ -16,7 +16,7 @@ import type { ValidatorsView } from './validator-tables';
const nodeFactory = (
overrides?: PartialDeep<NodesFragmentFragment>
): NodesFragmentFragment => {
const defaultNode = {
const defaultNode: NodesFragmentFragment = {
id: 'ccc022b7e63a4d0a6d3a193c3940c88574060e58a184964c994998d86835a1b4',
name: 'high',
avatarUrl: 'https://upload.wikimedia.org/wikipedia/en/2/25/Marvin-TV-3.jpg',
@@ -288,7 +288,7 @@ describe('Consensus validators table', () => {
expect(
grid.querySelector('[role="gridcell"][col-id="totalPenalties"]')
).toHaveTextContent('10.07%');
).toHaveTextContent('13.16%');
expect(
grid.querySelector('[role="gridcell"][col-id="normalisedVotingPower"]')
@@ -185,6 +185,15 @@ export const ConsensusValidatorsTable = ({
const { rawValidatorScore: previousEpochValidatorScore } =
getLastEpochScoreAndPerformance(previousEpochData, id);
const overstakingPenalty = calculateOverallPenalty(
id,
allNodesInPreviousEpoch
);
const totalPenalty = calculateOverstakedPenalty(
id,
allNodesInPreviousEpoch
);
return {
id,
[ValidatorFields.RANKING_INDEX]: stakedTotalRanking,
@@ -213,11 +222,11 @@ export const ConsensusValidatorsTable = ({
2
),
[ValidatorFields.OVERSTAKING_PENALTY]: formatNumberPercentage(
calculateOverstakedPenalty(id, allNodesInPreviousEpoch),
overstakingPenalty,
2
),
[ValidatorFields.TOTAL_PENALTIES]: formatNumberPercentage(
calculateOverallPenalty(id, allNodesInPreviousEpoch),
totalPenalty,
2
),
[ValidatorFields.PENDING_STAKE]: pendingStake,
@@ -124,6 +124,15 @@ export const StandbyPendingValidatorsTable = ({
}
}
const overstakingPenalty = calculateOverallPenalty(
id,
allNodesInPreviousEpoch
);
const totalPenalty = calculateOverstakedPenalty(
id,
allNodesInPreviousEpoch
);
return {
id,
[ValidatorFields.RANKING_INDEX]: stakedTotalRanking,
@@ -154,11 +163,11 @@ export const StandbyPendingValidatorsTable = ({
2
),
[ValidatorFields.OVERSTAKING_PENALTY]: formatNumberPercentage(
calculateOverstakedPenalty(id, allNodesInPreviousEpoch),
overstakingPenalty,
2
),
[ValidatorFields.TOTAL_PENALTIES]: formatNumberPercentage(
calculateOverallPenalty(id, allNodesInPreviousEpoch),
totalPenalty,
2
),
[ValidatorFields.PENDING_STAKE]: pendingStake,
@@ -266,7 +266,9 @@ export const ValidatorTable = ({
<Tooltip description={t('OverstakedPenaltyDescription')}>
<span data-testid="overstaking-penalty">
{formatNumberPercentage(penalties.overstaked, 2)}
{penalties.overstaked
? formatNumberPercentage(penalties.overstaked, 2)
: '-'}
</span>
</Tooltip>
</KeyValueTableRow>
@@ -285,7 +287,9 @@ export const ValidatorTable = ({
</span>
<span data-testid="total-penalties">
<strong>
{formatNumberPercentage(penalties.overall, 2)}
{penalties.overall
? formatNumberPercentage(penalties.overall, 2)
: '-'}
</strong>
</span>
</KeyValueTableRow>
+106 -45
View File
@@ -3,11 +3,11 @@ import {
getLastEpochScoreAndPerformance,
getNormalisedVotingPower,
getUnnormalisedVotingPower,
getOverstakingPenalty,
getFormattedPerformanceScore,
getPerformancePenalty,
getTotalPenalties,
getStakePercentage,
calculateOverallPenalty,
calculateOverstakedPenalty,
} from './shared';
import * as Schema from '@vegaprotocol/types';
@@ -106,38 +106,6 @@ describe('getUnnormalisedVotingPower', () => {
});
});
describe('getOverstakingPenalty', () => {
it('returns "0%" when both arguments are null or undefined', () => {
expect(getOverstakingPenalty(null, null)).toBe('0%');
expect(getOverstakingPenalty(undefined, undefined)).toBe('0%');
expect(getOverstakingPenalty(null, undefined)).toBe('0%');
expect(getOverstakingPenalty(undefined, null)).toBe('0%');
});
it('returns "0%" when one argument is null or undefined', () => {
expect(getOverstakingPenalty('10', null)).toBe('0%');
expect(getOverstakingPenalty(null, '20')).toBe('0%');
expect(getOverstakingPenalty('10', undefined)).toBe('0%');
expect(getOverstakingPenalty(undefined, '20')).toBe('0%');
});
it('returns "0%" when validatorScore or stakeScore is zero', () => {
expect(getOverstakingPenalty('0', '20')).toBe('0%');
expect(getOverstakingPenalty('10', '0')).toBe('0%');
});
it('returns the correct overstaking penalty', () => {
expect(getOverstakingPenalty('0.18', '0.2')).toBe('10.00%');
expect(getOverstakingPenalty('0.2', '0.2')).toBe('0.00%');
expect(getOverstakingPenalty('0.04', '0.2')).toBe('80.00%');
});
it('handles string numbers with decimals', () => {
expect(getOverstakingPenalty('7.5', '15')).toBe('50.00%');
expect(getOverstakingPenalty('12.5', '25')).toBe('50.00%');
});
});
describe('getFormattedPerformanceScore', () => {
it('should return the formatted performance score', () => {
expect(getFormattedPerformanceScore('0.25')).toEqual(new BigNumber(0.25));
@@ -152,17 +120,6 @@ describe('getPerformancePenalty', () => {
});
});
describe('getTotalPenalties', () => {
it('should return the total penalties', () => {
expect(getTotalPenalties('0.25', '1', '5000', '10000')).toEqual('50.00%');
expect(getTotalPenalties('0.25', '0.5', '5000', '10000')).toEqual('75.00%');
});
it('should return 0 if the total penalties is negative', () => {
expect(getTotalPenalties('0.25', '0.5', '1000', '10000')).toEqual('0.00%');
});
});
describe('getStakePercentage', () => {
it('should return the stake percentage', () => {
expect(
@@ -182,3 +139,107 @@ describe('getStakePercentage', () => {
);
});
});
describe('calculateOverallPenalty', () => {
it('returns null if rewardScore is null', () => {
const res = calculateOverallPenalty('1', [
{
id: '1',
rewardScore: null,
stakedTotal: '',
rankingScore: {
stakeScore: '0.25',
performanceScore: '0.75',
status: Schema.ValidatorStatus.VALIDATOR_NODE_STATUS_TENDERMINT,
previousStatus:
Schema.ValidatorStatus.VALIDATOR_NODE_STATUS_TENDERMINT,
rankingScore: '',
votingPower: '',
},
},
]);
expect(res).toBeNull();
});
it('returns null if rewardScore.rawValidatorScore is null (should not happen)', () => {
const res = calculateOverallPenalty('1', [
{
id: '1',
stakedTotal: '',
rewardScore: {
rawValidatorScore: '0.25',
performanceScore: '0.75',
multisigScore: '',
validatorScore: null as unknown as string,
normalisedScore: '',
validatorStatus:
Schema.ValidatorStatus.VALIDATOR_NODE_STATUS_TENDERMINT,
},
rankingScore: {
stakeScore: '0.25',
performanceScore: '0.75',
status: Schema.ValidatorStatus.VALIDATOR_NODE_STATUS_TENDERMINT,
previousStatus:
Schema.ValidatorStatus.VALIDATOR_NODE_STATUS_TENDERMINT,
rankingScore: '',
votingPower: '',
},
},
]);
expect(res).toBeNull();
});
});
describe('calculateOverstakedPenalty', () => {
it('returns null if rewardScore is null', () => {
const res = calculateOverstakedPenalty('1', [
{
id: '1',
rewardScore: null,
stakedTotal: '',
rankingScore: {
stakeScore: '0.25',
performanceScore: '0.75',
status: Schema.ValidatorStatus.VALIDATOR_NODE_STATUS_TENDERMINT,
previousStatus:
Schema.ValidatorStatus.VALIDATOR_NODE_STATUS_TENDERMINT,
rankingScore: '',
votingPower: '',
},
},
]);
expect(res).toBeNull();
});
it('returns null if rewardScore.rawValidatorScore is null (should not happen)', () => {
const res = calculateOverstakedPenalty('1', [
{
id: '1',
stakedTotal: '',
rewardScore: {
rawValidatorScore: null as unknown as string,
performanceScore: '0.75',
multisigScore: '',
validatorScore: '',
normalisedScore: '',
validatorStatus:
Schema.ValidatorStatus.VALIDATOR_NODE_STATUS_TENDERMINT,
},
rankingScore: {
stakeScore: '0.25',
performanceScore: '0.75',
status: Schema.ValidatorStatus.VALIDATOR_NODE_STATUS_TENDERMINT,
previousStatus:
Schema.ValidatorStatus.VALIDATOR_NODE_STATUS_TENDERMINT,
rankingScore: '',
votingPower: '',
},
},
]);
expect(res).toBeNull();
});
});
+35 -61
View File
@@ -5,6 +5,7 @@ import {
import type { PreviousEpochQuery } from './__generated__/PreviousEpoch';
import { BigNumber } from '../../lib/bignumber';
import type { LastArrayElement } from 'type-fest';
import isNull from 'lodash/isNull';
type Node = NonNullable<
LastArrayElement<
@@ -21,7 +22,10 @@ type Node = NonNullable<
* @returns Theoretical stake score for given node based on the staked total
* of all node of the same type (status)
*/
const calculateTheoreticalStakeScore = (nodeId: string, nodes: Node[]) => {
const calculateTheoreticalStakeScore = (
nodeId: string,
nodes: Node[]
): BigNumber | null => {
const node = nodes.find((n) => n.id === nodeId);
if (!node) {
return new BigNumber(0);
@@ -42,14 +46,25 @@ const calculateTheoreticalStakeScore = (nodeId: string, nodes: Node[]) => {
* @param nodes A collection of all nodes - needed to calculate theoretical stake score
* @returns %
*/
export const calculateOverallPenalty = (nodeId: string, nodes: Node[]) => {
export const calculateOverallPenalty = (
nodeId: string,
nodes: Node[]
): BigNumber | null => {
const node = nodes.find((n) => n.id === nodeId);
const tts = calculateTheoreticalStakeScore(nodeId, nodes);
if (!node || tts.isZero()) {
if (
!node ||
isNull(tts) ||
!node.rewardScore ||
(node.rewardScore && isNull(node.rewardScore.validatorScore))
) {
return null;
}
if (tts.isZero()) {
return new BigNumber(0);
}
const penalty = new BigNumber(1)
.minus(new BigNumber(node.rewardScore?.validatorScore || 0).dividedBy(tts))
.minus(new BigNumber(node.rewardScore.validatorScore).dividedBy(tts))
.times(100);
return penalty.isLessThan(0) ? new BigNumber(0) : penalty;
};
@@ -60,10 +75,21 @@ export const calculateOverallPenalty = (nodeId: string, nodes: Node[]) => {
* @param nodes A collection of all nodes - needed to calculate theoretical stake score
* @returns %
*/
export const calculateOverstakedPenalty = (nodeId: string, nodes: Node[]) => {
export const calculateOverstakedPenalty = (
nodeId: string,
nodes: Node[]
): BigNumber | null => {
const node = nodes.find((n) => n.id === nodeId);
const tts = calculateTheoreticalStakeScore(nodeId, nodes);
if (!node || tts.isZero()) {
if (
!node ||
isNull(tts) ||
isNull(node.rewardScore) ||
(node.rewardScore && node.rewardScore.rawValidatorScore === null)
) {
return null;
}
if (tts.isZero()) {
return new BigNumber(0);
}
const penalty = new BigNumber(1)
@@ -78,7 +104,9 @@ export const calculateOverstakedPenalty = (nodeId: string, nodes: Node[]) => {
* Calculates performance penalty based on the given performance score.
* @returns %
*/
export const calculatesPerformancePenalty = (performanceScore: string) => {
export const calculatesPerformancePenalty = (
performanceScore: string
): BigNumber => {
const penalty = new BigNumber(1)
.minus(new BigNumber(performanceScore))
.times(100);
@@ -123,60 +151,6 @@ export const getPerformancePenalty = (performanceScore?: string) =>
2
);
export const getOverstakingPenalty = (
validatorScore: string | null | undefined,
stakeScore: string | null | undefined
) => {
if (!validatorScore || !stakeScore) {
return '0%';
}
// avoid division by zero
if (
new BigNumber(validatorScore).isZero() ||
new BigNumber(stakeScore).isZero()
) {
return '0%';
}
return formatNumberPercentage(
BigNumber.max(
new BigNumber(1)
.minus(
new BigNumber(validatorScore).dividedBy(new BigNumber(stakeScore))
)
.times(100),
new BigNumber(0)
),
2
);
};
export const getTotalPenalties = (
rawValidatorScore: string | null | undefined,
performanceScore: string | undefined,
stakedOnNode: string,
totalStake: string
) => {
const calc =
rawValidatorScore &&
performanceScore &&
new BigNumber(totalStake).isGreaterThan(0)
? new BigNumber(1).minus(
new BigNumber(performanceScore)
.times(new BigNumber(rawValidatorScore))
.dividedBy(
new BigNumber(stakedOnNode).dividedBy(new BigNumber(totalStake))
)
)
: new BigNumber(0);
return formatNumberPercentage(
calc.isPositive() ? calc.times(100) : new BigNumber(0),
2
);
};
export const getStakePercentage = (total: BigNumber, stakedOnNode: BigNumber) =>
total.isEqualTo(0) || stakedOnNode.isEqualTo(0)
? '0%'
+37 -1
View File
@@ -1,14 +1,50 @@
import { ToastsContainer, useToasts } from '@vegaprotocol/ui-toolkit';
import {
Intent,
ToastsContainer,
TradingButton,
VegaIcon,
VegaIconNames,
useToasts,
} from '@vegaprotocol/ui-toolkit';
import { useVegaWalletDialogStore } from '@vegaprotocol/wallet';
import {
useEthereumTransactionToasts,
useEthereumWithdrawApprovalsToasts,
useVegaTransactionToasts,
useWalletDisconnectToastActions,
useWalletDisconnectedToasts,
} from '@vegaprotocol/web3';
import { useTranslation } from 'react-i18next';
const WalletDisconnectAdditionalContent = () => {
const { t } = useTranslation();
const { hideToast } = useWalletDisconnectToastActions();
const openVegaWalletDialog = useVegaWalletDialogStore(
(store) => store.openVegaWalletDialog
);
return (
<p className="mt-2">
<TradingButton
data-testid="connect-vega-wallet"
onClick={() => {
hideToast();
openVegaWalletDialog();
}}
size="small"
intent={Intent.Danger}
icon={<VegaIcon name={VegaIconNames.ARROW_RIGHT} size={14} />}
>
<span className="whitespace-nowrap uppercase">{t('Connect')}</span>
</TradingButton>
</p>
);
};
export const ToastsManager = () => {
useVegaTransactionToasts();
useEthereumTransactionToasts();
useEthereumWithdrawApprovalsToasts();
useWalletDisconnectedToasts(<WalletDisconnectAdditionalContent />);
const toasts = useToasts((store) => store.toasts);
return <ToastsContainer order="desc" toasts={toasts} />;
+8 -2
View File
@@ -21,8 +21,14 @@ NX_WALLETCONNECT_PROJECT_ID=fe8091dc35738863e509fc4947525c72
# Cosmic elevator flags
NX_SUCCESSOR_MARKETS=true
NX_STOP_ORDERS=true
NX_ISOLATED_MARGIN=true
NX_ICEBERG_ORDERS=true
# NX_PRODUCT_PERPETUALS
NX_METAMASK_SNAPS=true
NX_REFERRALS=true
NX_TEAM_COMPETITION=true
# NX_DISABLE_CLOSE_POSITION=false
NX_TENDERMINT_URL=https://be.vega.community
NX_TENDERMINT_WEBSOCKET_URL=wss://be.vega.community/websocket
NX_CHARTING_LIBRARY_PATH=https://assets.vega.community/trading-view-bundle/v0.0.1/
NX_CHARTING_LIBRARY_HASH=PDjWaqPFndDp+LCvqbKvntWriaqNzNpZ5i9R/BULzCg=
+1
View File
@@ -21,6 +21,7 @@ NX_ETH_WALLET_MNEMONIC="ozone access unlock valid olympic save include omit supp
# Cosmic elevator flags
NX_SUCCESSOR_MARKETS=false
NX_STOP_ORDERS=false
NX_ISOLATED_MARGIN=true
# NX_ICEBERG_ORDERS
# NX_PRODUCT_PERPETUALS
NX_METAMASK_SNAPS=false
+1
View File
@@ -20,6 +20,7 @@ NX_ORACLE_PROOFS_URL=https://raw.githubusercontent.com/vegaprotocol/well-known/m
# Cosmic elevator flags
NX_SUCCESSOR_MARKETS=true
NX_STOP_ORDERS=true
NX_ISOLATED_MARGIN=true
# NX_ICEBERG_ORDERS
# NX_PRODUCT_PERPETUALS
NX_METAMASK_SNAPS=true
+4
View File
@@ -21,6 +21,7 @@ NX_WALLETCONNECT_PROJECT_ID=fe8091dc35738863e509fc4947525c72
# Cosmic elevator flags
NX_SUCCESSOR_MARKETS=true
NX_STOP_ORDERS=true
NX_ISOLATED_MARGIN=false
NX_ICEBERG_ORDERS=true
NX_METAMASK_SNAPS=true
NX_REFERRALS=true
@@ -28,3 +29,6 @@ NX_REFERRALS=true
NX_TENDERMINT_URL=https://be.vega.community
NX_TENDERMINT_WEBSOCKET_URL=wss://be.vega.community/websocket
NX_CHARTING_LIBRARY_PATH=https://assets.vega.community/trading-view-bundle/v0.0.1/
NX_CHARTING_LIBRARY_HASH=PDjWaqPFndDp+LCvqbKvntWriaqNzNpZ5i9R/BULzCg=
+1
View File
@@ -21,6 +21,7 @@ NX_APP_VERSION=v0.20.19-core-0.71.6
# Cosmic elevator flags
NX_SUCCESSOR_MARKETS=true
NX_STOP_ORDERS=true
NX_ISOLATED_MARGIN=false
NX_ICEBERG_ORDERS=true
# NX_PRODUCT_PERPETUALS
NX_METAMASK_SNAPS=false
+1
View File
@@ -21,6 +21,7 @@ NX_WALLETCONNECT_PROJECT_ID=fe8091dc35738863e509fc4947525c72
# Cosmic elevator flags
NX_SUCCESSOR_MARKETS=true
NX_STOP_ORDERS=true
NX_ISOLATED_MARGIN=true
NX_ICEBERG_ORDERS=true
# NX_PRODUCT_PERPETUALS
NX_METAMASK_SNAPS=true
+1
View File
@@ -22,6 +22,7 @@ NX_WALLETCONNECT_PROJECT_ID=fe8091dc35738863e509fc4947525c72
# Cosmic elevator flags
NX_SUCCESSOR_MARKETS=true
NX_STOP_ORDERS=true
NX_ISOLATED_MARGIN=true
NX_ICEBERG_ORDERS=true
NX_METAMASK_SNAPS=true
NX_REFERRALS=true
+1
View File
@@ -22,6 +22,7 @@ NX_ORACLE_PROOFS_URL=https://raw.githubusercontent.com/vegaprotocol/well-known/m
# Cosmic elevator flags
NX_SUCCESSOR_MARKETS=true
NX_STOP_ORDERS=true
NX_ISOLATED_MARGIN=true
NX_ICEBERG_ORDERS=true
# NX_PRODUCT_PERPETUALS
NX_METAMASK_SNAPS=false
+2
View File
@@ -54,3 +54,5 @@ To run the UI automation tests with a mocked API, run:
```bash
yarn nx run trading-e2e:e2e
```
To run tests with market sim please read [the readme](e2e/README.md).
+8 -13
View File
@@ -1,4 +1,4 @@
import React, { useEffect, useMemo } from 'react';
import { useEffect } from 'react';
import { addDecimalsFormatNumber, titlefy } from '@vegaprotocol/utils';
import { useScreenDimensions } from '@vegaprotocol/react-helpers';
import { useThrottledDataProvider } from '@vegaprotocol/data-provider';
@@ -72,17 +72,6 @@ export const MarketPage = () => {
}
}, [update, lastMarketId, data?.id]);
const pinnedAsset = data && getAsset(data);
const tradeView = useMemo(() => {
if (pinnedAsset) {
if (largeScreen) {
return <TradeGrid market={data} pinnedAsset={pinnedAsset} />;
}
return <TradePanels market={data} pinnedAsset={pinnedAsset} />;
}
}, [largeScreen, data, pinnedAsset]);
if (loading) {
return (
<Splash>
@@ -118,6 +107,8 @@ export const MarketPage = () => {
);
}
const pinnedAsset = data && getAsset(data);
return (
<>
<TitleUpdater
@@ -125,7 +116,11 @@ export const MarketPage = () => {
marketName={data?.tradableInstrument.instrument.name}
decimalPlaces={data?.decimalPlaces}
/>
{tradeView}
{largeScreen ? (
<TradeGrid market={data} pinnedAsset={pinnedAsset} />
) : (
<TradePanels market={data} pinnedAsset={pinnedAsset} />
)}
</>
);
};
+17 -38
View File
@@ -1,11 +1,10 @@
import { memo } from 'react';
import type { ReactNode } from 'react';
import { memo, type ReactNode } from 'react';
import { LayoutPriority } from 'allotment';
import classNames from 'classnames';
import AutoSizer from 'react-virtualized-auto-sizer';
import type { PinnedAsset } from '@vegaprotocol/accounts';
import { OracleBanner, useMarket } from '@vegaprotocol/markets';
import type { Market } from '@vegaprotocol/markets';
import { useFeatureFlags } from '@vegaprotocol/environment';
import { type PinnedAsset } from '@vegaprotocol/accounts';
import { type Market } from '@vegaprotocol/markets';
import { Tab, LocalStoragePersistTabs as Tabs } from '@vegaprotocol/ui-toolkit';
import {
ResizableGrid,
@@ -13,31 +12,19 @@ import {
usePaneLayout,
} from '../../components/resizable-grid';
import { TradingViews } from './trade-views';
import {
MarketSuccessorBanner,
MarketSuccessorProposalBanner,
MarketTerminationBanner,
} from '../../components/market-banner';
import { useFeatureFlags } from '@vegaprotocol/environment';
import { useT } from '../../lib/use-t';
import { ErrorBoundary } from '../../components/error-boundary';
import { MarketBanner } from '../../components/market-banner';
interface TradeGridProps {
market: Market | null;
pinnedAsset?: PinnedAsset;
market: Market;
pinnedAsset?: PinnedAsset | undefined;
}
const MainGrid = memo(
({
marketId,
pinnedAsset,
}: {
marketId: string;
pinnedAsset?: PinnedAsset;
}) => {
({ market, pinnedAsset }: { market: Market; pinnedAsset?: PinnedAsset }) => {
const featureFlags = useFeatureFlags((state) => state.flags);
const t = useT();
const { data: market } = useMarket(marketId);
const [sizes, handleOnLayoutChange] = usePaneLayout({ id: 'top' });
const [sizesMiddle, handleOnMiddleLayoutChange] = usePaneLayout({
id: 'middle-1',
@@ -65,17 +52,17 @@ const MainGrid = memo(
menu={<TradingViews.chart.menu />}
>
<ErrorBoundary feature="chart">
<TradingViews.chart.component marketId={marketId} />
<TradingViews.chart.component marketId={market.id} />
</ErrorBoundary>
</Tab>
<Tab id="depth" name={t('Depth')}>
<ErrorBoundary feature="depth">
<TradingViews.depth.component marketId={marketId} />
<TradingViews.depth.component marketId={market.id} />
</ErrorBoundary>
</Tab>
<Tab id="liquidity" name={t('Liquidity')}>
<ErrorBoundary feature="liquidity">
<TradingViews.liquidity.component marketId={marketId} />
<TradingViews.liquidity.component marketId={market.id} />
</ErrorBoundary>
</Tab>
{market &&
@@ -83,7 +70,7 @@ const MainGrid = memo(
'Perpetual' ? (
<Tab id="funding-history" name={t('Funding history')}>
<ErrorBoundary feature="funding-history">
<TradingViews.funding.component marketId={marketId} />
<TradingViews.funding.component marketId={market.id} />
</ErrorBoundary>
</Tab>
) : null}
@@ -97,7 +84,7 @@ const MainGrid = memo(
>
<ErrorBoundary feature="funding-payments">
<TradingViews.fundingPayments.component
marketId={marketId}
marketId={market.id}
/>
</ErrorBoundary>
</Tab>
@@ -113,7 +100,7 @@ const MainGrid = memo(
<Tabs storageKey="console-trade-grid-main-right">
<Tab id="orderbook" name={t('Orderbook')}>
<ErrorBoundary feature="orderbook">
<TradingViews.orderbook.component marketId={marketId} />
<TradingViews.orderbook.component marketId={market.id} />
</ErrorBoundary>
</Tab>
<Tab
@@ -122,7 +109,7 @@ const MainGrid = memo(
settings={<TradingViews.trades.settings />}
>
<ErrorBoundary feature="trades">
<TradingViews.trades.component marketId={marketId} />
<TradingViews.trades.component marketId={market.id} />
</ErrorBoundary>
</Tab>
</Tabs>
@@ -225,7 +212,6 @@ const MainGrid = memo(
MainGrid.displayName = 'MainGrid';
export const TradeGrid = ({ market, pinnedAsset }: TradeGridProps) => {
const featureFlags = useFeatureFlags((state) => state.flags);
const wrapperClasses = classNames(
'h-full grid',
'grid-rows-[min-content_1fr]'
@@ -234,17 +220,10 @@ export const TradeGrid = ({ market, pinnedAsset }: TradeGridProps) => {
return (
<div className={wrapperClasses}>
<div>
{featureFlags.SUCCESSOR_MARKETS && (
<>
<MarketSuccessorBanner market={market} />
<MarketSuccessorProposalBanner marketId={market?.id} />
</>
)}
<MarketTerminationBanner market={market} />
<OracleBanner marketId={market?.id || ''} />
<MarketBanner market={market} />
</div>
<div className="min-h-0 p-0.5">
<MainGrid marketId={market?.id || ''} pinnedAsset={pinnedAsset} />
<MainGrid market={market} pinnedAsset={pinnedAsset} />
</div>
</div>
);
@@ -1,6 +1,7 @@
import { type PinnedAsset } from '@vegaprotocol/accounts';
import { type Market } from '@vegaprotocol/markets';
import { OracleBanner } from '@vegaprotocol/markets';
// TODO: handle oracle banner
// import { OracleBanner } from '@vegaprotocol/markets';
import { useState } from 'react';
import AutoSizer from 'react-virtualized-auto-sizer';
import classNames from 'classnames';
@@ -11,23 +12,17 @@ import {
VegaIconNames,
} from '@vegaprotocol/ui-toolkit';
import { useT } from '../../lib/use-t';
import {
MarketSuccessorBanner,
MarketSuccessorProposalBanner,
MarketTerminationBanner,
} from '../../components/market-banner';
import { MarketBanner } from '../../components/market-banner';
import { ErrorBoundary } from '../../components/error-boundary';
import { type TradingView } from './trade-views';
import { TradingViews } from './trade-views';
import { useFeatureFlags } from '@vegaprotocol/environment';
interface TradePanelsProps {
market: Market | null;
market: Market;
pinnedAsset?: PinnedAsset;
}
export const TradePanels = ({ market, pinnedAsset }: TradePanelsProps) => {
const featureFlags = useFeatureFlags((state) => state.flags);
const [view, setView] = useState<TradingView>('chart');
const viewCfg = TradingViews[view];
@@ -76,14 +71,7 @@ export const TradePanels = ({ market, pinnedAsset }: TradePanelsProps) => {
return (
<div className="h-full grid grid-rows-[min-content_min-content_1fr_min-content]">
<div>
{featureFlags.SUCCESSOR_MARKETS && (
<>
<MarketSuccessorBanner market={market} />
<MarketSuccessorProposalBanner marketId={market?.id} />
</>
)}
<MarketTerminationBanner market={market} />
<OracleBanner marketId={market?.id || ''} />
<MarketBanner market={market} />
</div>
<div>{renderMenu()}</div>
<div className="h-full relative">
@@ -314,7 +314,7 @@ describe('Closed', () => {
});
it('display market actions', async () => {
// Use market with a succcessor Id as the actions dropdown will optionally
// Use market with a successor Id as the actions dropdown will optionally
// show a link to the successor market
const marketsWithSuccessorAndParent = [
{
@@ -137,6 +137,8 @@ const ClosedMarketsDataGrid = ({
headerName: t('Market'),
field: 'code',
cellRenderer: 'MarketCodeCell',
width: 150,
resizable: true,
},
{
headerName: t('Status'),
@@ -280,6 +282,7 @@ const ClosedMarketsDataGrid = ({
return (
<AgGrid
rowData={rowData}
defaultColDef={COL_DEFS.default}
columnDefs={colDefs}
getRowId={({ data }) => data.id}
overlayNoRowsTemplate={error ? error.message : t('No markets')}
@@ -17,6 +17,7 @@ const defaultColDef = {
filter: true,
resizable: true,
filterParams: { buttons: ['reset'] },
minWidth: 120,
};
const components = {
@@ -53,6 +53,7 @@ export const MarketsPage = () => {
size="extra-small"
data-testid="propose-new-market"
href={externalLink}
target="_blank"
>
{t('Propose a new market')}
</TradingAnchorButton>
@@ -29,6 +29,7 @@ export const useColumnDefs = () => {
{
headerName: t('Market'),
field: 'tradableInstrument.instrument.code',
pinned: true,
cellRenderer: ({
value,
data,
@@ -4,6 +4,7 @@ import { useT } from '../../lib/use-t';
import { RewardsContainer } from '../../components/rewards-container';
import { usePageTitleStore } from '../../stores';
import { ErrorBoundary } from '../../components/error-boundary';
import { TinyScroll } from '@vegaprotocol/ui-toolkit';
export const Rewards = () => {
const t = useT();
@@ -16,10 +17,10 @@ export const Rewards = () => {
}, [updateTitle, title]);
return (
<ErrorBoundary feature="rewards">
<div className="container mx-auto p-4">
<TinyScroll className="p-4 max-h-full overflow-auto">
<h1 className="px-4 pb-4 text-2xl">{title}</h1>
<RewardsContainer />
</div>
</TinyScroll>
</ErrorBoundary>
);
};
@@ -12,6 +12,8 @@ import { VegaWalletProvider } from '@vegaprotocol/wallet';
import type { ReactNode } from 'react';
import { Web3Provider } from './web3-provider';
import { useT } from '../../lib/use-t';
import { DataLoader } from './data-loader';
import { useChainId } from '@vegaprotocol/wallet';
export const Bootstrapper = ({ children }: { children: ReactNode }) => {
const t = useT();
@@ -25,13 +27,16 @@ export const Bootstrapper = ({ children }: { children: ReactNode }) => {
CHROME_EXTENSION_URL,
} = useEnvironment();
const chainId = useChainId(VEGA_URL);
if (
!VEGA_URL ||
!VEGA_WALLET_URL ||
!VEGA_EXPLORER_URL ||
!CHROME_EXTENSION_URL ||
!MOZILLA_EXTENSION_URL ||
!DocsLinks
!DocsLinks ||
!chainId
) {
return <AppLoader />;
}
@@ -52,28 +57,39 @@ export const Bootstrapper = ({ children }: { children: ReactNode }) => {
/>
}
>
<Web3Provider
<DataLoader
skeleton={<AppLoader />}
failure={
<AppFailure title={t('Could not configure web3 provider')} />
<AppFailure
title={t('Could not load market data or asset data')}
error={error}
/>
}
>
<VegaWalletProvider
config={{
network: VEGA_ENV,
vegaUrl: VEGA_URL,
vegaWalletServiceUrl: VEGA_WALLET_URL,
links: {
explorer: VEGA_EXPLORER_URL,
concepts: DocsLinks.VEGA_WALLET_CONCEPTS_URL,
chromeExtensionUrl: CHROME_EXTENSION_URL,
mozillaExtensionUrl: MOZILLA_EXTENSION_URL,
},
}}
<Web3Provider
skeleton={<AppLoader />}
failure={
<AppFailure title={t('Could not configure web3 provider')} />
}
>
{children}
</VegaWalletProvider>
</Web3Provider>
<VegaWalletProvider
config={{
network: VEGA_ENV,
vegaUrl: VEGA_URL,
chainId,
vegaWalletServiceUrl: VEGA_WALLET_URL,
links: {
explorer: VEGA_EXPLORER_URL,
concepts: DocsLinks.VEGA_WALLET_CONCEPTS_URL,
chromeExtensionUrl: CHROME_EXTENSION_URL,
mozillaExtensionUrl: MOZILLA_EXTENSION_URL,
},
}}
>
{children}
</VegaWalletProvider>
</Web3Provider>
</DataLoader>
</NodeGuard>
</NetworkLoader>
);
@@ -0,0 +1,34 @@
import { useAssetsMapProvider } from '@vegaprotocol/assets';
import { useMarketsMapProvider } from '@vegaprotocol/markets';
import type { ReactNode } from 'react';
export const DataLoader = ({
children,
failure,
skeleton,
}: {
children: ReactNode;
failure: ReactNode;
skeleton: ReactNode;
}) => {
// Query all markets and assets to ensure they are cached
const { data: markets, error, loading } = useMarketsMapProvider();
const {
data: assets,
error: errorAssets,
loading: loadingAssets,
} = useAssetsMapProvider();
if (loading || loadingAssets) {
// eslint-disable-next-line
return <>{skeleton}</>;
}
if (error || errorAssets || !markets || !assets) {
// eslint-disable-next-line react/jsx-no-useless-fragment
return <>{failure}</>;
}
// eslint-disable-next-line react/jsx-no-useless-fragment
return <>{children}</>;
};
@@ -25,12 +25,12 @@ export const ChartContainer = ({ marketId }: { marketId: string }) => {
overlays,
studies,
studySizes,
tradingViewStudies,
setInterval,
setStudies,
setStudySizes,
setOverlays,
setTradingViewStudies,
state,
setState,
} = useChartSettings();
const pennantChart = (
@@ -64,13 +64,13 @@ export const ChartContainer = ({ marketId }: { marketId: string }) => {
libraryHash={CHARTING_LIBRARY_HASH}
marketId={marketId}
interval={toTradingViewResolution(interval)}
studies={tradingViewStudies}
onIntervalChange={(newInterval) => {
setInterval(fromTradingViewResolution(newInterval));
}}
onAutoSaveNeeded={(data: { studies: string[] }) => {
setTradingViewStudies(data.studies);
onAutoSaveNeeded={(data) => {
setState(data);
}}
state={state}
/>
);
}
@@ -27,10 +27,10 @@ describe('ChartMenu', () => {
render(<ChartMenu />);
await userEvent.click(screen.getByRole('button', { name: 'TradingView' }));
await userEvent.click(screen.getByTestId('chartlib-toggle-button'));
expect(useChartSettingsStore.getState().chartlib).toEqual('tradingview');
await userEvent.click(screen.getByRole('button', { name: 'Vega chart' }));
await userEvent.click(screen.getByTestId('chartlib-toggle-button'));
expect(useChartSettingsStore.getState().chartlib).toEqual('pennant');
});
@@ -68,6 +68,7 @@ export const ChartMenu = () => {
setChartlib(isPennant ? 'tradingview' : 'pennant');
}}
size="extra-small"
testId="chartlib-toggle-button"
>
{isPennant ? 'TradingView' : t('Vega chart')}
</TradingButton>
@@ -9,6 +9,7 @@ type StudySizes = { [S in Study]?: number };
export type Chartlib = 'pennant' | 'tradingview';
interface StoredSettings {
state: object | undefined; // Don't see a better type provided from TradingView type definitions
chartlib: Chartlib;
// For interval we use the enum from @vegaprotocol/types, this is to make mapping between different
// chart types easier and more consistent
@@ -17,7 +18,6 @@ interface StoredSettings {
overlays: Overlay[];
studies: Study[];
studySizes: StudySizes;
tradingViewStudies: string[];
}
export const STUDY_SIZE = 90;
@@ -30,13 +30,13 @@ const STUDY_ORDER: Study[] = [
];
export const DEFAULT_CHART_SETTINGS = {
state: undefined,
chartlib: 'pennant' as const,
interval: Interval.INTERVAL_I15M,
type: ChartType.CANDLE,
overlays: [Overlay.MOVING_AVERAGE],
studies: [Study.MACD, Study.VOLUME],
studySizes: {},
tradingViewStudies: ['Volume'],
};
export const useChartSettingsStore = create<
@@ -47,7 +47,7 @@ export const useChartSettingsStore = create<
setStudies: (studies?: Study[]) => void;
setStudySizes: (sizes: number[]) => void;
setChartlib: (lib: Chartlib) => void;
setTradingViewStudies: (studies: string[]) => void;
setState: (state: object) => void;
}
>()(
persist(
@@ -95,10 +95,8 @@ export const useChartSettingsStore = create<
state.chartlib = lib;
});
},
setTradingViewStudies: (studies: string[]) => {
set((state) => {
state.tradingViewStudies = studies;
});
setState: (state) => {
set({ state });
},
})),
{
@@ -147,13 +145,13 @@ export const useChartSettings = () => {
overlays,
studies,
studySizes,
tradingViewStudies: settings.tradingViewStudies,
setInterval: settings.setInterval,
setType: settings.setType,
setStudies: settings.setStudies,
setOverlays: settings.setOverlays,
setStudySizes: settings.setStudySizes,
setChartlib: settings.setChartlib,
setTradingViewStudies: settings.setTradingViewStudies,
state: settings.state,
setState: settings.setState,
};
};
@@ -38,7 +38,11 @@ export const FeesContainer = () => {
const { data: markets, loading: marketsLoading } = useMarketList();
const { data: programData, loading: programLoading } =
useDiscountProgramsQuery({ errorPolicy: 'ignore' });
useDiscountProgramsQuery({
errorPolicy: 'ignore',
fetchPolicy: 'cache-and-network',
pollInterval: 15000,
});
const volumeDiscountWindowLength =
programData?.currentVolumeDiscountProgram?.windowLength || 1;
@@ -49,6 +53,8 @@ export const FeesContainer = () => {
partyId: pubKey || '',
},
skip: !pubKey,
fetchPolicy: 'cache-and-network',
pollInterval: 15000,
});
const previousEpoch = (Number(feesData?.epoch.id) || 0) - 1;
@@ -1,4 +1,5 @@
import type { DiscountProgramsQuery, FeesQuery } from './__generated__/Fees';
export const useReferralStats = (
previousEpoch?: number,
referralStats?: NonNullable<
@@ -0,0 +1,21 @@
import React from 'react';
import { render } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { GridSettings } from './grid-settings';
import '@testing-library/jest-dom';
describe('GridSettings', () => {
it('calls updateGridStore with correct arguments on button click', async () => {
const mockUpdateGridStore = jest.fn();
const { getByText } = render(
<GridSettings updateGridStore={mockUpdateGridStore} />
);
await userEvent.click(getByText('Reset Columns'));
expect(mockUpdateGridStore).toHaveBeenCalledWith({
columnState: undefined,
filterModel: undefined,
});
});
});
@@ -42,13 +42,9 @@ export const LiquidityHeader = () => {
const assetDecimalPlaces = asset?.decimals || 0;
const symbol = asset?.symbol;
const triggeringRatio =
market?.liquidityMonitoringParameters.triggeringRatio || '1';
const { percentage, status } = useCheckLiquidityStatus({
suppliedStake: suppliedStake || 0,
targetStake: targetStake || 0,
triggeringRatio,
});
const feesObject = feesPaidRes?.paidLiquidityFees?.edges?.find(
@@ -47,9 +47,6 @@ export const MarketLiquiditySupplied = ({
]);
const stakeToCcyVolume = params.market_liquidity_stakeToCcyVolume;
const triggeringRatio = Number(
params.market_liquidity_targetstake_triggering_ratio
);
const variables = useMemo(
() => ({
@@ -94,7 +91,6 @@ export const MarketLiquiditySupplied = ({
const { percentage, status } = useCheckLiquidityStatus({
suppliedStake: market?.suppliedStake || 0,
targetStake: market?.targetStake || 0,
triggeringRatio,
});
const showMessage =
@@ -1,3 +1 @@
export * from './market-successor-banner';
export * from './market-successor-proposal-banner';
export * from './market-termination-banner';
export { MarketBanner } from './market-banner';
@@ -0,0 +1,205 @@
import compact from 'lodash/compact';
import { MarketState } from '@vegaprotocol/types';
import { Intent, NotificationBanner } from '@vegaprotocol/ui-toolkit';
import {
useMarketState,
type Market,
useMaliciousOracle,
} from '@vegaprotocol/markets';
import { useState } from 'react';
import { type MarketViewProposalFieldsFragment } from '@vegaprotocol/proposals';
import { MarketSuspendedBanner } from './market-suspended-banner';
import { MarketUpdateBanner } from './market-update-banner';
import { MarketUpdateStateBanner } from './market-update-state-banner';
import { MarketSettledBanner } from './market-settled-banner';
import { MarketSuccessorProposalBanner } from './market-successor-proposal-banner';
import { MarketOracleBanner, type Oracle } from './market-oracle-banner';
import {
useSuccessorMarketProposals,
useUpdateMarketProposals,
useUpdateMarketStateProposals,
} from './use-market-proposals';
type UpdateMarketBanner = {
kind: 'UpdateMarket';
proposals: MarketViewProposalFieldsFragment[];
};
type UpdateMarketStateBanner = {
kind: 'UpdateMarketState';
proposals: MarketViewProposalFieldsFragment[];
};
type NewMarketBanner = {
kind: 'NewMarket'; // aka a proposal of NewMarket which succeeds the current market
proposals: MarketViewProposalFieldsFragment[];
};
type SettledBanner = {
kind: 'Settled';
market: Market;
};
type SuspendedBanner = {
kind: 'Suspended';
market: Market;
};
type OracleBanner = {
kind: 'Oracle';
oracle: Oracle;
};
type Banner =
| UpdateMarketBanner
| UpdateMarketStateBanner
| NewMarketBanner
| SettledBanner
| SuspendedBanner
| OracleBanner;
export const MarketBanner = ({ market }: { market: Market }) => {
const { data: marketState } = useMarketState(market.id);
const { proposals: successorProposals, loading: successorLoading } =
useSuccessorMarketProposals(market.id);
const { proposals: updateMarketProposals, loading: updateMarketLoading } =
useUpdateMarketProposals(market.id);
const {
proposals: updateMarketStateProposals,
loading: updateMarketStateLoading,
} = useUpdateMarketStateProposals(market.id);
const { data: maliciousOracle, loading: oracleLoading } = useMaliciousOracle(
market.id
);
const loading =
successorLoading ||
updateMarketLoading ||
updateMarketStateLoading ||
oracleLoading;
if (loading) {
return null;
}
const banners = compact([
updateMarketStateProposals.length
? {
kind: 'UpdateMarketState' as const,
proposals: updateMarketStateProposals,
}
: undefined,
updateMarketProposals.length
? {
kind: 'UpdateMarket' as const,
proposals: updateMarketProposals,
}
: undefined,
successorProposals.length
? {
kind: 'NewMarket' as const,
proposals: successorProposals,
}
: undefined,
marketState === MarketState.STATE_SETTLED
? {
kind: 'Settled' as const,
market,
}
: undefined,
marketState === MarketState.STATE_SUSPENDED_VIA_GOVERNANCE
? {
kind: 'Suspended' as const,
market,
}
: undefined,
maliciousOracle !== undefined
? {
kind: 'Oracle' as const,
oracle: maliciousOracle,
}
: undefined,
]);
return <BannerQueue banners={banners} market={market} />;
};
const BannerQueue = ({
banners,
market,
}: {
banners: Banner[];
market: Market;
}) => {
// for showing banner by index
const [index, setIndex] = useState(0);
const banner = banners[index];
if (!banner) return null;
let content = null;
let intent = Intent.Primary;
switch (banner.kind) {
case 'UpdateMarket': {
content = <MarketUpdateBanner proposals={banner.proposals} />;
break;
}
case 'UpdateMarketState': {
content = (
<MarketUpdateStateBanner proposals={banner.proposals} market={market} />
);
break;
}
case 'NewMarket': {
content = <MarketSuccessorProposalBanner proposals={banner.proposals} />;
intent = Intent.Warning;
break;
}
case 'Settled': {
content = <MarketSettledBanner market={market} />;
break;
}
case 'Suspended': {
content = <MarketSuspendedBanner />;
intent = Intent.Warning;
break;
}
case 'Oracle': {
// @ts-ignore oracle cannot be undefined
content = <MarketOracleBanner oracle={banner.oracle} />;
intent = Intent.Danger;
break;
}
default: {
return null;
}
}
const showCount = banners.length > 1;
const onClose = () => {
setIndex((x) => x + 1);
};
return (
<NotificationBanner
intent={intent}
onClose={onClose}
data-testid="market-banner"
>
<div className="flex items-center justify-between">
{content}
{showCount ? (
<p>
{index + 1}/{banners.length}
</p>
) : null}
</div>
</NotificationBanner>
);
};
@@ -0,0 +1,58 @@
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { MarketOracleBanner } from './market-oracle-banner';
jest.mock('@vegaprotocol/markets', () => ({
...jest.requireActual('@vegaprotocol/markets'),
OracleDialog: ({ open }: { open: boolean }) => {
return <div data-testid="oracle-dialog">{open ? 'open' : 'closed'}</div>;
},
}));
const oracle = {
dataSourceSpecId: 'someId',
provider: {
name: 'Test oracle',
url: 'https://zombo.com',
description_markdown:
'Some markdown describing the oracle provider.\n\nTwitter: @FacesPics2\n',
oracle: {
status: 'COMPROMISED' as const,
status_reason: 'Reason here',
first_verified: '2021-01-01T00:00:00Z',
last_verified: '2022-01-01T00:00:00Z',
type: 'public_key' as const,
public_key: '0xfCEAdAFab14d46e20144F48824d0C09B1a03F2BC',
},
proofs: [
{
format: 'url' as const,
available: true,
type: 'web' as const,
url: 'https://proofweb.com',
},
{
format: 'signed_message' as const,
available: true,
type: 'public_key' as const,
public_key: '0xfCEAdAFab14d46e20144F48824d0C09B1a03F2BC',
message: 'SOMEHEX',
},
],
github_link:
'https://github.com/vegaprotocol/well-known/blob/main/oracle-providers/eth_address-0xfCEAdAFab14d46e20144F48824d0C09B1a03F2BC.toml',
},
};
describe('MarketOracleBanner', () => {
it('should render successfully', async () => {
render(<MarketOracleBanner oracle={oracle} />);
expect(screen.getByTestId('oracle-dialog')).toHaveTextContent('closed');
expect(screen.getByTestId('oracle-banner-status')).toHaveTextContent(
'COMPROMISED'
);
await userEvent.click(screen.getByRole('button', { name: 'Show more' }));
expect(screen.getByTestId('oracle-dialog')).toHaveTextContent('open');
});
});
@@ -0,0 +1,46 @@
import { useState } from 'react';
import { Trans } from 'react-i18next';
import {
OracleDialog,
useOracleStatuses,
type useMaliciousOracle,
} from '@vegaprotocol/markets';
import { ButtonLink } from '@vegaprotocol/ui-toolkit';
export type Oracle = ReturnType<typeof useMaliciousOracle>['data'];
export const MarketOracleBanner = ({
oracle,
}: {
oracle: NonNullable<Oracle>;
}) => {
const [open, setOpen] = useState(false);
const oracleStatuses = useOracleStatuses();
return (
<>
<OracleDialog open={open} onChange={setOpen} {...oracle} />
<p>
<Trans
defaults="Oracle status for this market is <0>{{status}}</0>. {{description}} <1>Show more</1>"
components={[
<span key="oracle-status" data-testid="oracle-banner-status">
status
</span>,
<ButtonLink
key="oracle-button"
onClick={() => setOpen((x) => !x)}
data-testid="oracle-banner-dialog-trigger"
>
Show more
</ButtonLink>,
]}
values={{
status: oracle.provider.oracle.status,
description: oracleStatuses[oracle.provider.oracle.status],
}}
/>
</p>
</>
);
};
@@ -0,0 +1,219 @@
import { render, screen } from '@testing-library/react';
import { addDays, subDays, subHours } from 'date-fns';
import { MockedProvider, type MockedResponse } from '@apollo/react-testing';
import {
SuccessorMarketDocument,
MarketCandlesDocument,
MarketCandlesUpdateDocument,
SuccessorMarketIdDocument,
type Market,
type SuccessorMarketQuery,
type SuccessorMarketQueryVariables,
type MarketCandlesQuery,
type MarketCandlesQueryVariables,
type MarketCandlesUpdateSubscription,
type MarketCandlesUpdateSubscriptionVariables,
type SuccessorMarketIdQuery,
type SuccessorMarketIdQueryVariables,
} from '@vegaprotocol/markets';
import { MemoryRouter } from 'react-router-dom';
import { createMarketFragment } from '@vegaprotocol/mock';
import { Interval, MarketState, MarketTradingMode } from '@vegaprotocol/types';
import { MarketSettledBanner } from './market-settled-banner';
describe('MarketSettledBanner', () => {
const now = 1701388800000;
const origDateNow = Date.now;
beforeAll(() => {
Date.now = () => now;
});
afterAll(() => {
Date.now = origDateNow;
});
const marketId = 'market-0';
const successorId = 'successor-id';
const successorName = 'successor-name';
const successorMarketIdMock: MockedResponse<
SuccessorMarketIdQuery,
SuccessorMarketIdQueryVariables
> = {
request: {
query: SuccessorMarketIdDocument,
variables: {
marketId: marketId,
},
},
result: {
data: {
market: {
successorMarketID: successorId,
},
},
},
};
const successorMock: MockedResponse<
SuccessorMarketQuery,
SuccessorMarketQueryVariables
> = {
request: {
query: SuccessorMarketDocument,
variables: {
marketId: successorId,
},
},
result: {
data: {
market: {
id: successorId,
state: MarketState.STATE_ACTIVE,
tradingMode: MarketTradingMode.TRADING_MODE_CONTINUOUS,
positionDecimalPlaces: 0,
tradableInstrument: {
instrument: {
name: successorName,
code: 'code',
},
},
proposal: {
id: 'proposal-id',
},
},
},
},
};
const since = subDays(new Date(now), 5).toISOString();
const successorCandlesMock: MockedResponse<
MarketCandlesQuery,
MarketCandlesQueryVariables
> = {
request: {
query: MarketCandlesDocument,
variables: {
marketId: successorId,
interval: Interval.INTERVAL_I1H,
since,
},
},
result: {
data: {
marketsConnection: {
edges: [
{
node: {
candlesConnection: {
edges: [
{
node: {
high: '100',
low: '100',
open: '100',
close: '100',
volume: '100',
periodStart: subHours(new Date(now), 1).toISOString(),
},
},
{
node: {
high: '100',
low: '100',
open: '100',
close: '200',
volume: '100',
periodStart: subHours(new Date(now), 2).toISOString(),
},
},
],
},
},
},
],
},
},
},
};
const successorCandlesUpdateMock: MockedResponse<
MarketCandlesUpdateSubscription,
MarketCandlesUpdateSubscriptionVariables
> = {
request: {
query: MarketCandlesUpdateDocument,
variables: {
marketId: successorId,
interval: Interval.INTERVAL_I1H,
// @ts-ignore this query doesn't need the 'since' variable, but the data-provider
// uses all variables for both the query and the subscription so its needed here
// to match the mock
since,
},
},
result: {
data: {
candles: {
high: '100',
low: '100',
open: '100',
close: '100',
volume: '100',
periodStart: '2020-01-01T00:00:00',
},
},
},
};
const renderComponent = (market: Market, mocks?: MockedResponse[]) => {
return render(
<MemoryRouter>
<MockedProvider mocks={mocks}>
<MarketSettledBanner market={market} />
</MockedProvider>
</MemoryRouter>
);
};
it('shows that the market is settled if there is no successor', () => {
const market = createMarketFragment({
id: marketId,
successorMarketID: undefined,
});
renderComponent(market, [successorMarketIdMock, successorMock]);
expect(
screen.getByText('This market has been settled')
).toBeInTheDocument();
});
it('shows that the market has been succeeded with an active market', async () => {
const market = createMarketFragment({
id: marketId,
successorMarketID: successorId,
marketTimestamps: {
close: addDays(new Date(now), 1).toISOString(),
},
});
renderComponent(market, [
successorMarketIdMock,
successorMock,
successorCandlesMock,
successorCandlesUpdateMock,
]);
expect(
await screen.findByText('This market has been succeeded')
).toBeInTheDocument();
expect(
await screen.findByText(/has a 24h trading volume of 200$/i)
).toBeInTheDocument();
expect(
screen.getByText('This market expires in 1 day.')
).toBeInTheDocument();
expect(screen.getByRole('link', { name: successorName })).toHaveAttribute(
'href',
`/markets/${successorId}`
);
});
});
@@ -0,0 +1,122 @@
import { Link } from 'react-router-dom';
import { Trans } from 'react-i18next';
import { isBefore, formatDuration, intervalToDuration } from 'date-fns';
import {
calcCandleVolume,
useCandles,
useSuccessorMarket,
type Market,
} from '@vegaprotocol/markets';
import {
addDecimalsFormatNumber,
getMarketExpiryDate,
isNumeric,
} from '@vegaprotocol/utils';
import { useT, ns } from '../../lib/use-t';
import { Links } from '../../lib/links';
const getExpiryDate = (tags: string[], close?: string): Date | null => {
const expiryDate = getMarketExpiryDate(tags);
return expiryDate || (close && new Date(close)) || null;
};
export const MarketSettledBanner = ({ market }: { market: Market }) => {
const t = useT();
const { data: successorMarket } = useSuccessorMarket(market.id);
const expiry = market
? getExpiryDate(
market.tradableInstrument.instrument.metadata.tags || [],
market.marketTimestamps.close
)
: null;
const duration =
expiry && isBefore(new Date(Date.now()), expiry)
? intervalToDuration({ start: new Date(Date.now()), end: expiry })
: null;
const { oneDayCandles } = useCandles({
marketId: successorMarket?.id,
});
const candleVolume = oneDayCandles?.length
? calcCandleVolume(oneDayCandles)
: null;
const successorVolume =
candleVolume && isNumeric(successorMarket?.positionDecimalPlaces)
? addDecimalsFormatNumber(
candleVolume,
successorMarket?.positionDecimalPlaces as number
)
: null;
if (successorMarket) {
return (
<div>
<p>{t('This market has been succeeded')}</p>
{duration && (
<div className="mt-1">
<span>
{t('This market expires in {{duration}}.', {
duration: formatDuration(duration, {
format: [
'years',
'months',
'weeks',
'days',
'hours',
'minutes',
],
}),
})}
</span>
<>
{' '}
{successorVolume ? (
<Trans
defaults="The successor market <0>{{instrumentName}}</0> has a 24h trading volume of {{successorVolume}}"
values={{
successorVolume,
instrumentName:
successorMarket?.tradableInstrument.instrument.name,
}}
components={[
<Link
to={Links.MARKET(successorMarket.id)}
key="link"
target="_blank"
>
successor market name
</Link>,
]}
/>
) : (
<Trans
defaults="The successor market is <0>{{instrumentName}}</0>"
values={{
instrumentName:
successorMarket?.tradableInstrument.instrument.name,
}}
components={[
<Link
to={Links.MARKET(successorMarket.id)}
key="link"
target="_blank"
>
successor market name
</Link>,
]}
ns={ns}
/>
)}
</>
</div>
)}
</div>
);
}
return <p>{t('This market has been settled')}</p>;
};
@@ -1,155 +0,0 @@
import { render, screen } from '@testing-library/react';
import { MockedProvider } from '@apollo/react-testing';
import { MarketSuccessorBanner } from './market-successor-banner';
import * as Types from '@vegaprotocol/types';
import * as allUtils from '@vegaprotocol/utils';
import type { Market } from '@vegaprotocol/markets';
import type { PartialDeep } from 'type-fest';
const market = {
id: 'marketId',
tradableInstrument: {
instrument: {
metadata: {
tags: [],
},
},
},
marketTimestamps: {
close: null,
},
} as unknown as Market;
let mockDataSuccessorMarket: PartialDeep<Market> | null = null;
let mockDataMarketState: Market['state'] | null = null;
jest.mock('@vegaprotocol/data-provider', () => ({
...jest.requireActual('@vegaprotocol/data-provider'),
useDataProvider: jest.fn().mockImplementation((args) => {
if (args.skip) {
return {
data: null,
error: null,
};
}
return {
data: mockDataSuccessorMarket,
error: null,
};
}),
}));
jest.mock('@vegaprotocol/utils', () => ({
...jest.requireActual('@vegaprotocol/utils'),
getMarketExpiryDate: jest.fn(),
}));
let mockCandles = {};
jest.mock('@vegaprotocol/markets', () => ({
...jest.requireActual('@vegaprotocol/markets'),
useMarketState: (marketId: string) =>
marketId
? {
data: mockDataMarketState,
}
: { data: undefined },
useSuccessorMarket: (marketId: string) =>
marketId
? {
data: mockDataSuccessorMarket,
}
: { data: undefined },
useCandles: () => mockCandles,
}));
describe('MarketSuccessorBanner', () => {
beforeEach(() => {
jest.clearAllMocks();
mockDataSuccessorMarket = {
id: 'successorMarketID',
state: Types.MarketState.STATE_ACTIVE,
tradingMode: Types.MarketTradingMode.TRADING_MODE_CONTINUOUS,
tradableInstrument: {
instrument: {
name: 'Successor Market Name',
},
},
};
});
describe('should be hidden', () => {
it('when no market', () => {
const { container } = render(<MarketSuccessorBanner market={null} />, {
wrapper: MockedProvider,
});
expect(container).toBeEmptyDOMElement();
});
it('no successor market data', () => {
mockDataSuccessorMarket = null;
const { container } = render(<MarketSuccessorBanner market={market} />, {
wrapper: MockedProvider,
});
expect(container).toBeEmptyDOMElement();
});
});
describe('should be displayed', () => {
it('should be rendered', () => {
render(<MarketSuccessorBanner market={market} />, {
wrapper: MockedProvider,
});
expect(
screen.getByText('This market has been succeeded')
).toBeInTheDocument();
expect(
screen.getByRole('link', { name: 'Successor Market Name' })
).toHaveAttribute('href', '/#/markets/successorMarketID');
});
it('no successor market data, market settled', () => {
mockDataSuccessorMarket = null;
mockDataMarketState = Types.MarketState.STATE_SETTLED;
render(<MarketSuccessorBanner market={market} />, {
wrapper: MockedProvider,
});
expect(
screen.getByText('This market has been settled')
).toBeInTheDocument();
});
it('should display optionally successor volume', () => {
mockDataSuccessorMarket = {
...mockDataSuccessorMarket,
positionDecimalPlaces: 3,
};
mockCandles = {
oneDayCandles: [
{ volume: 123 },
{ volume: 456 },
{ volume: 789 },
{ volume: 99999 },
],
};
render(<MarketSuccessorBanner market={market} />, {
wrapper: MockedProvider,
});
expect(
screen.getByText('has a 24h trading volume of 101.367', {
exact: false,
})
).toBeInTheDocument();
});
it('should display optionally duration', () => {
jest
.spyOn(allUtils, 'getMarketExpiryDate')
.mockReturnValue(
new Date(Date.now() + 24 * 60 * 60 * 1000 + 60 * 1000)
);
render(<MarketSuccessorBanner market={market} />, {
wrapper: MockedProvider,
});
expect(
screen.getByText(/^This market expires in 1 day/)
).toBeInTheDocument();
});
});
});
@@ -1,146 +0,0 @@
import { useState } from 'react';
import { isBefore, formatDuration, intervalToDuration } from 'date-fns';
import type { Market } from '@vegaprotocol/markets';
import {
calcCandleVolume,
useCandles,
useMarketState,
useSuccessorMarket,
} from '@vegaprotocol/markets';
import {
ExternalLink,
Intent,
NotificationBanner,
} from '@vegaprotocol/ui-toolkit';
import {
addDecimalsFormatNumber,
getMarketExpiryDate,
isNumeric,
} from '@vegaprotocol/utils';
import * as Types from '@vegaprotocol/types';
import { useT, ns } from '../../lib/use-t';
import { Trans } from 'react-i18next';
const getExpiryDate = (tags: string[], close?: string): Date | null => {
const expiryDate = getMarketExpiryDate(tags);
return expiryDate || (close && new Date(close)) || null;
};
export const MarketSuccessorBanner = ({
market,
}: {
market: Market | null;
}) => {
const t = useT();
const { data: marketState } = useMarketState(market?.id);
const isSettled = marketState === Types.MarketState.STATE_SETTLED;
const { data: successorData, loading } = useSuccessorMarket(market?.id);
const [visible, setVisible] = useState(true);
const expiry = market
? getExpiryDate(
market.tradableInstrument.instrument.metadata.tags || [],
market.marketTimestamps.close
)
: null;
const duration =
expiry && isBefore(new Date(), expiry)
? intervalToDuration({ start: new Date(), end: expiry })
: null;
const { oneDayCandles } = useCandles({
marketId: successorData?.id,
});
const candleVolume = oneDayCandles?.length
? calcCandleVolume(oneDayCandles)
: null;
const successorVolume =
candleVolume && isNumeric(successorData?.positionDecimalPlaces)
? addDecimalsFormatNumber(
candleVolume,
successorData?.positionDecimalPlaces as number
)
: null;
if (!loading && (isSettled || successorData) && visible) {
return (
<NotificationBanner
intent={Intent.Primary}
onClose={() => {
setVisible(false);
}}
>
<div className="uppercase">
{successorData
? t('This market has been succeeded')
: t('This market has been settled')}
</div>
{(duration || successorData) && (
<div className="mt-1">
{duration && (
<span>
{t('This market expires in {{duration}}.', {
duration: formatDuration(duration, {
format: [
'years',
'months',
'weeks',
'days',
'hours',
'minutes',
],
}),
})}
</span>
)}
{successorData && (
<>
{' '}
{successorVolume ? (
<Trans
defaults="The successor market <0>{{instrumentName}}</0> has a 24h trading volume of {{successorVolume}}"
values={{
successorVolume,
instrumentName:
successorData?.tradableInstrument.instrument.name,
}}
components={[
<ExternalLink
href={`/#/markets/${successorData?.id}`}
key="link"
>
successor market name
</ExternalLink>,
]}
/>
) : (
<Trans
defaults="The successor market is <0>{{instrumentName}}</0>"
values={{
instrumentName:
successorData?.tradableInstrument.instrument.name,
}}
components={[
<ExternalLink
href={`/#/markets/${successorData?.id}`}
key="link"
>
successor market name
</ExternalLink>,
]}
ns={ns}
/>
)}
</>
)}
</div>
)}
</NotificationBanner>
);
}
return null;
};
@@ -1,164 +1,77 @@
import { act, render, screen, waitFor } from '@testing-library/react';
import type { SingleExecutionResult } from '@apollo/client';
import type { MockedResponse } from '@apollo/react-testing';
import { MockedProvider } from '@apollo/react-testing';
import { render, screen } from '@testing-library/react';
import { MarketSuccessorProposalBanner } from './market-successor-proposal-banner';
import type { MarketViewProposalsQuery } from '@vegaprotocol/proposals';
import { MarketViewProposalsDocument } from '@vegaprotocol/proposals';
import * as Types from '@vegaprotocol/types';
import { ProposalState } from '@vegaprotocol/types';
const marketProposalMock: MockedResponse<MarketViewProposalsQuery> = {
request: {
query: MarketViewProposalsDocument,
variables: {
inState: Types.ProposalState.STATE_OPEN,
proposalType: Types.ProposalType.TYPE_NEW_MARKET,
const proposal = {
__typename: 'Proposal' as const,
id: 'proposal-1',
state: ProposalState.STATE_OPEN,
terms: {
__typename: 'ProposalTerms' as const,
closingDatetime: '2023-09-27',
enactmentDatetime: '2023-09-28',
change: {
__typename: 'NewMarket' as const,
instrument: {
name: 'New proposal of the market successor',
},
successorConfiguration: {
parentMarketId: 'marketId',
},
},
},
result: {
data: {
proposalsConnection: {
edges: [
{
node: {
__typename: 'Proposal',
id: 'proposal-1',
state: Types.ProposalState.STATE_OPEN,
terms: {
__typename: 'ProposalTerms',
closingDatetime: '2023-09-27',
enactmentDatetime: '2023-09-28',
change: {
__typename: 'NewMarket',
instrument: {
name: 'New proposal of the market successor',
},
successorConfiguration: {
parentMarketId: 'marketId',
},
},
},
},
},
],
};
const proposal2 = {
__typename: 'Proposal' as const,
id: 'proposal-2',
state: ProposalState.STATE_OPEN,
terms: {
__typename: 'ProposalTerms' as const,
closingDatetime: '2023-09-27',
enactmentDatetime: '2023-09-28',
change: {
__typename: 'NewMarket' as const,
instrument: {
name: 'New second proposal of the market successor',
},
successorConfiguration: {
parentMarketId: 'marketId',
},
},
},
};
describe('MarketSuccessorProposalBanner', () => {
it('should display single proposal', async () => {
render(
<MockedProvider mocks={[marketProposalMock]}>
<MarketSuccessorProposalBanner marketId="marketId" />
</MockedProvider>
);
await waitFor(() => {
expect(
screen.getByText('A successors to this market has been proposed')
).toBeInTheDocument();
});
it('should display single proposal', () => {
render(<MarketSuccessorProposalBanner proposals={[proposal]} />);
expect(
screen
.getByRole('link')
.getAttribute('href')
?.endsWith('/proposals/proposal-1') ?? false
).toBe(true);
});
it('should display plural proposals', async () => {
const dualProposalMock = {
...marketProposalMock,
result: {
...marketProposalMock.result,
data: {
proposalsConnection: {
edges: [
...((
marketProposalMock?.result as SingleExecutionResult<MarketViewProposalsQuery>
)?.data?.proposalsConnection?.edges ?? []),
{
node: {
__typename: 'Proposal',
id: 'proposal-2',
state: Types.ProposalState.STATE_OPEN,
terms: {
__typename: 'ProposalTerms',
change: {
__typename: 'NewMarket',
instrument: {
name: 'New second proposal of the market successor',
},
successorConfiguration: {
parentMarketId: 'marketId',
},
},
},
},
},
],
},
},
},
};
screen.getByText('A successor to this market has been proposed')
).toBeInTheDocument();
render(
<MockedProvider mocks={[dualProposalMock]}>
<MarketSuccessorProposalBanner marketId="marketId" />
</MockedProvider>
expect(screen.getByRole('link')).toHaveAttribute(
'href',
expect.stringContaining(proposal.id)
);
await waitFor(() => {
expect(
screen.getByText('Successors to this market have been proposed')
).toBeInTheDocument();
});
});
it('should display plural proposals', () => {
const proposals = [proposal, proposal2];
render(<MarketSuccessorProposalBanner proposals={proposals} />);
expect(
screen
.getAllByRole('link')[0]
.getAttribute('href')
?.endsWith('/proposals/proposal-1') ?? false
).toBe(true);
expect(
screen
.getAllByRole('link')[1]
.getAttribute('href')
?.endsWith('/proposals/proposal-2') ?? false
).toBe(true);
});
screen.getByText('Successors to this market have been proposed')
).toBeInTheDocument();
it('banner should be hidden because no proposals', () => {
const { container } = render(
<MockedProvider>
<MarketSuccessorProposalBanner marketId="marketId" />
</MockedProvider>
const links = screen.getAllByRole('link');
expect(links).toHaveLength(proposals.length);
expect(links[0]).toHaveAttribute(
'href',
expect.stringContaining(proposal.id)
);
expect(container).toBeEmptyDOMElement();
});
it('banner should be hidden because no proposals for the market', () => {
const { container } = render(
<MockedProvider mocks={[marketProposalMock]}>
<MarketSuccessorProposalBanner marketId="otherMarketId" />
</MockedProvider>
expect(links[1]).toHaveAttribute(
'href',
expect.stringContaining(proposal2.id)
);
expect(container).toBeEmptyDOMElement();
});
it('banner should be hidden after user close click', async () => {
const { container } = render(
<MockedProvider mocks={[marketProposalMock]}>
<MarketSuccessorProposalBanner marketId="marketId" />
</MockedProvider>
);
await waitFor(() => {
expect(
screen.getByText('A successors to this market has been proposed')
).toBeInTheDocument();
});
await act(() => {
screen.getByTestId('notification-banner-close').click();
});
await waitFor(() => {
expect(container).toBeEmptyDOMElement();
});
});
});
@@ -1,89 +1,51 @@
import { Fragment, useState } from 'react';
import {
marketViewProposalsDataProvider,
type NewMarketSuccessorFieldsFragment,
} from '@vegaprotocol/proposals';
import {
ExternalLink,
Intent,
NotificationBanner,
} from '@vegaprotocol/ui-toolkit';
import { Fragment } from 'react';
import { type MarketViewProposalFieldsFragment } from '@vegaprotocol/proposals';
import { ExternalLink } from '@vegaprotocol/ui-toolkit';
import { DApp, TOKEN_PROPOSAL, useLinks } from '@vegaprotocol/environment';
import * as Types from '@vegaprotocol/types';
import { useDataProvider } from '@vegaprotocol/data-provider';
import { useT } from '../../lib/use-t';
export const MarketSuccessorProposalBanner = ({
marketId,
proposals,
}: {
marketId?: string;
proposals: MarketViewProposalFieldsFragment[];
}) => {
const t = useT();
const { data: proposals } = useDataProvider({
dataProvider: marketViewProposalsDataProvider,
skip: !marketId,
variables: {
inState: Types.ProposalState.STATE_OPEN,
proposalType: Types.ProposalType.TYPE_NEW_MARKET,
},
});
const successors =
proposals?.filter((item) => {
if (item.terms.change.__typename === 'NewMarket') {
const newMarket = item.terms.change;
if (
newMarket.successorConfiguration?.parentMarketId === marketId &&
item.state === Types.ProposalState.STATE_OPEN
) {
return true;
}
}
return false;
}) ?? [];
const [visible, setVisible] = useState(true);
const tokenLink = useLinks(DApp.Governance);
if (visible && successors.length) {
return (
<NotificationBanner
intent={Intent.Primary}
onClose={() => {
setVisible(false);
}}
>
<div className="uppercase mb-1">
{successors.length === 1
? t('A successors to this market has been proposed')
: t('Successors to this market have been proposed')}
</div>
<div>
{t(
'checkOutProposalsAndVote',
'Check out the terms of the proposals and vote:',
{
count: successors.length,
}
)}{' '}
{successors.map((item, i) => {
const externalLink = tokenLink(
TOKEN_PROPOSAL.replace(':id', item.id || '')
);
return (
<Fragment key={i}>
<ExternalLink href={externalLink} key={i}>
{
(item.terms?.change as NewMarketSuccessorFieldsFragment)
?.instrument.name
}
</ExternalLink>
{i < successors.length - 1 && ', '}
</Fragment>
);
})}
</div>
</NotificationBanner>
);
}
return null;
return (
<div>
<div className="uppercase mb-1">
{proposals.length === 1
? t('A successor to this market has been proposed')
: t('Successors to this market have been proposed')}
</div>
<div>
{t(
'checkOutProposalsAndVote',
'Check out the terms of the proposals and vote:',
{
count: proposals.length,
}
)}{' '}
{proposals.map((item, i) => {
if (item.terms.change.__typename !== 'NewMarket') {
return null;
}
const externalLink = tokenLink(
TOKEN_PROPOSAL.replace(':id', item.id || '')
);
return (
<Fragment key={i}>
<ExternalLink href={externalLink} key={i}>
{item.terms.change.instrument.name}
</ExternalLink>
{i < proposals.length - 1 && ', '}
</Fragment>
);
})}
</div>
</div>
);
};
@@ -0,0 +1,6 @@
import { useT } from '../../lib/use-t';
export const MarketSuspendedBanner = () => {
const t = useT();
return <p>{t('Market was suspended by governance')}</p>;
};
@@ -1,230 +0,0 @@
import { render, screen, waitFor } from '@testing-library/react';
import * as Types from '@vegaprotocol/types';
import type { MockedResponse } from '@apollo/client/testing';
import { MockedProvider } from '@apollo/client/testing';
import type { MarketViewProposalsQuery } from '@vegaprotocol/proposals';
import { MarketViewProposalsDocument } from '@vegaprotocol/proposals';
import type { Market } from '@vegaprotocol/markets';
import { MarketTerminationBanner } from './market-termination-banner';
const marketMock = {
id: 'market-1',
decimalPlaces: 3,
tradableInstrument: {
instrument: {
product: {
__typename: 'Future',
quoteName: 'tDAI',
},
},
},
} as Market;
const passedProposalMock: MockedResponse<MarketViewProposalsQuery> = {
request: {
query: MarketViewProposalsDocument,
variables: {
inState: Types.ProposalState.STATE_PASSED,
proposalType: Types.ProposalType.TYPE_UPDATE_MARKET_STATE,
},
},
result: {
data: {
proposalsConnection: {
edges: [
{
node: {
id: '1',
state: Types.ProposalState.STATE_PASSED,
terms: {
closingDatetime: '2023-09-27T11:48:18Z',
enactmentDatetime: '2023-09-30T11:48:18',
change: {
__typename: 'UpdateMarketState',
updateType:
Types.MarketUpdateType.MARKET_STATE_UPDATE_TYPE_TERMINATE,
price: '',
market: {
id: 'market-1',
tradableInstrument: {
instrument: {
name: 'Market one name',
code: 'Market one',
},
},
},
},
},
},
},
{
node: {
id: '2',
state: Types.ProposalState.STATE_PASSED,
terms: {
closingDatetime: '2023-09-27T11:48:18Z',
enactmentDatetime: '2023-10-01T11:48:18',
change: {
__typename: 'UpdateMarketState',
updateType:
Types.MarketUpdateType.MARKET_STATE_UPDATE_TYPE_TERMINATE,
price: '',
market: {
id: 'market-2',
tradableInstrument: {
instrument: {
name: 'Market two name',
code: 'Market two',
},
},
},
},
},
},
},
],
},
},
},
};
const openProposalMock: MockedResponse<MarketViewProposalsQuery> = {
request: {
query: MarketViewProposalsDocument,
variables: {
inState: Types.ProposalState.STATE_OPEN,
proposalType: Types.ProposalType.TYPE_UPDATE_MARKET_STATE,
},
},
result: {
data: {
proposalsConnection: {
edges: [
{
node: {
id: '3',
state: Types.ProposalState.STATE_OPEN,
terms: {
closingDatetime: '2023-09-27T11:48:18Z',
enactmentDatetime: '2023-10-01T11:48:18',
change: {
__typename: 'UpdateMarketState',
updateType:
Types.MarketUpdateType.MARKET_STATE_UPDATE_TYPE_TERMINATE,
price: '',
market: {
id: 'market-3',
tradableInstrument: {
instrument: {
name: 'Market three name',
code: 'Market three',
},
},
},
},
},
},
},
{
node: {
id: '4',
state: Types.ProposalState.STATE_OPEN,
terms: {
closingDatetime: '2023-09-27T11:48:18Z',
enactmentDatetime: '2023-10-11T11:48:18',
change: {
__typename: 'UpdateMarketState',
updateType:
Types.MarketUpdateType.MARKET_STATE_UPDATE_TYPE_TERMINATE,
price: '',
market: {
id: 'market-3',
tradableInstrument: {
instrument: {
name: 'Market three name',
code: 'Market three',
},
},
},
},
},
},
},
{
node: {
id: '5',
state: Types.ProposalState.STATE_OPEN,
terms: {
closingDatetime: '2023-09-27T11:48:18Z',
enactmentDatetime: '2023-10-01T11:48:18',
change: {
__typename: 'UpdateMarketState',
updateType:
Types.MarketUpdateType.MARKET_STATE_UPDATE_TYPE_TERMINATE,
price: '',
market: {
id: 'market-4',
tradableInstrument: {
instrument: {
name: 'Market four name',
code: 'Market four',
},
},
},
},
},
},
},
],
},
},
},
};
const mocks: MockedResponse[] = [passedProposalMock, openProposalMock];
describe('MarketTerminationBanner', () => {
beforeAll(() => {
jest.useFakeTimers().setSystemTime(new Date('2023-09-28T10:10:10.000Z'));
});
afterAll(() => {
jest.useRealTimers();
});
it('should be properly rendered', async () => {
const { container } = render(
<MockedProvider mocks={mocks}>
<MarketTerminationBanner market={marketMock} />
</MockedProvider>
);
await waitFor(() => {
expect(container).not.toBeEmptyDOMElement();
});
expect(
screen.getByTestId('termination-warning-banner-market-1')
).toBeInTheDocument();
});
it('should render link to proposals', async () => {
const { container } = render(
<MockedProvider mocks={mocks}>
<MarketTerminationBanner market={{ ...marketMock, id: 'market-3' }} />
</MockedProvider>
);
await waitFor(() => {
expect(container).not.toBeEmptyDOMElement();
});
expect(screen.getByText('View proposals')).toBeInTheDocument();
});
it('should render link to proposal', async () => {
const { container } = render(
<MockedProvider mocks={mocks}>
<MarketTerminationBanner market={{ ...marketMock, id: 'market-4' }} />
</MockedProvider>
);
await waitFor(() => {
expect(container).not.toBeEmptyDOMElement();
});
expect(screen.getByText('View proposal')).toBeInTheDocument();
});
});
@@ -0,0 +1,84 @@
import { render, screen } from '@testing-library/react';
import { ProposalState } from '@vegaprotocol/types';
import { MarketUpdateBanner } from './market-update-banner';
import { type MarketViewProposalFieldsFragment } from '@vegaprotocol/proposals';
describe('MarketUpdateBanner', () => {
const change = {
__typename: 'UpdateMarket' as const,
marketId: 'update-market-id',
};
const openProposal = {
__typename: 'Proposal' as const,
id: 'proposal-1',
state: ProposalState.STATE_OPEN,
terms: {
__typename: 'ProposalTerms' as const,
closingDatetime: '2023-09-27',
enactmentDatetime: '2023-09-28',
change,
},
};
const passedProposal = {
__typename: 'Proposal' as const,
id: 'proposal-2',
state: ProposalState.STATE_PASSED,
terms: {
__typename: 'ProposalTerms' as const,
closingDatetime: '2023-09-27',
enactmentDatetime: '2023-09-28',
change,
},
};
it('renders content for a single open proposal', () => {
render(
<MarketUpdateBanner
proposals={[openProposal as MarketViewProposalFieldsFragment]}
/>
);
expect(
screen.getByText(/^changes have been proposed/i)
).toBeInTheDocument();
expect(screen.getByRole('link', { name: 'View proposal' })).toHaveAttribute(
'href',
expect.stringContaining(openProposal.id as string)
);
});
it('renders content for a single passed proposal', () => {
render(
<MarketUpdateBanner
proposals={[passedProposal as MarketViewProposalFieldsFragment]}
/>
);
expect(
screen.getByText(/^proposal set to change market/i)
).toBeInTheDocument();
expect(screen.getByRole('link', { name: 'View proposal' })).toHaveAttribute(
'href',
expect.stringContaining(passedProposal.id)
);
});
it('renders content for multiple passed proposals', () => {
const proposals = [
openProposal,
openProposal,
] as MarketViewProposalFieldsFragment[];
render(<MarketUpdateBanner proposals={proposals} />);
expect(
screen.getByText(
new RegExp(`^There are ${proposals.length} open proposals`)
)
).toBeInTheDocument();
expect(
screen.getByRole('link', { name: 'View proposals' })
).toHaveAttribute('href', expect.stringContaining('proposals'));
});
});
@@ -0,0 +1,71 @@
import sortBy from 'lodash/sortBy';
import { format } from 'date-fns';
import {
DApp,
TOKEN_PROPOSAL,
TOKEN_PROPOSALS,
useLinks,
} from '@vegaprotocol/environment';
import { type MarketViewProposalFieldsFragment } from '@vegaprotocol/proposals';
import { ExternalLink } from '@vegaprotocol/ui-toolkit';
import { ProposalState } from '@vegaprotocol/types';
import { useT } from '../../lib/use-t';
export const MarketUpdateBanner = ({
proposals,
}: {
proposals: MarketViewProposalFieldsFragment[];
}) => {
const governanceLink = useLinks(DApp.Governance);
const t = useT();
const openProposals = sortBy(
proposals.filter((p) => p.state === ProposalState.STATE_OPEN),
(p) => p.terms.enactmentDatetime
);
const passedProposals = sortBy(
proposals.filter((p) => p.state === ProposalState.STATE_PASSED),
(p) => p.terms.enactmentDatetime
);
let content = null;
if (openProposals.length > 1) {
content = (
<p>
{t('There are {{count}} open proposals to change this market', {
count: openProposals.length,
})}
<ExternalLink href={governanceLink(TOKEN_PROPOSALS)}>
{t('View proposals')}
</ExternalLink>
</p>
);
} else if (passedProposals.length) {
const proposal = passedProposals[0];
const proposalLink = governanceLink(
TOKEN_PROPOSAL.replace(':id', proposal?.id || '')
);
content = (
<p>
{t('Proposal set to change market on {{date}}.', {
date: format(new Date(proposal.terms.enactmentDatetime), 'dd MMMM'),
})}
<ExternalLink href={proposalLink}>{t('View proposal')}</ExternalLink>,
</p>
);
} else {
const proposal = openProposals[0];
const proposalLink = governanceLink(
TOKEN_PROPOSAL.replace(':id', proposal?.id || '')
);
content = (
<p>
{t('Changes have been proposed for this market.')}{' '}
<ExternalLink href={proposalLink}>{t('View proposal')}</ExternalLink>
</p>
);
}
return <div data-testid="market-proposal-notification">{content}</div>;
};
@@ -1,44 +1,120 @@
import type { ReactNode } from 'react';
import { useState } from 'react';
import { format, formatDuration, intervalToDuration } from 'date-fns';
import {
ExternalLink,
Intent,
NotificationBanner,
} from '@vegaprotocol/ui-toolkit';
import type { MarketViewProposalFieldsFragment } from '@vegaprotocol/proposals';
import { marketViewProposalsDataProvider } from '@vegaprotocol/proposals';
import * as Types from '@vegaprotocol/types';
import type { Market } from '@vegaprotocol/markets';
import { getQuoteName } from '@vegaprotocol/markets';
import { addDecimalsFormatNumber } from '@vegaprotocol/utils';
import { type ReactNode } from 'react';
import sortBy from 'lodash/sortBy';
import { format, formatDuration, intervalToDuration } from 'date-fns';
import { type MarketViewProposalFieldsFragment } from '@vegaprotocol/proposals';
import { ProposalState } from '@vegaprotocol/types';
import {
DApp,
TOKEN_PROPOSAL,
TOKEN_PROPOSALS,
useLinks,
} from '@vegaprotocol/environment';
import { useDataProvider } from '@vegaprotocol/data-provider';
import { getQuoteName, type Market } from '@vegaprotocol/markets';
import { addDecimalsFormatNumber } from '@vegaprotocol/utils';
import { useT } from '../../lib/use-t';
import { ExternalLink } from '@vegaprotocol/ui-toolkit';
const filterProposals = (
data: MarketViewProposalFieldsFragment[] | null,
marketId: string,
now: number
) =>
sortBy(
(data || []).filter(
(item) =>
item.terms.change.__typename === 'UpdateMarketState' &&
item.terms.change.market.id === marketId &&
item.terms.change.updateType ===
Types.MarketUpdateType.MARKET_STATE_UPDATE_TYPE_TERMINATE &&
item.terms.enactmentDatetime &&
new Date(item.terms.enactmentDatetime).getTime() > now
),
(item) => item.terms.enactmentDatetime
export const MarketUpdateStateBanner = ({
market,
proposals,
}: {
market: Market;
proposals: MarketViewProposalFieldsFragment[];
}) => {
const t = useT();
const governanceLink = useLinks(DApp.Governance);
const openProposals = sortBy(
proposals.filter((p) => p.state === ProposalState.STATE_OPEN),
(p) => p.terms.enactmentDatetime
);
const passedProposals = sortBy(
proposals.filter((p) => p.state === ProposalState.STATE_PASSED),
(p) => p.terms.enactmentDatetime
);
if (!passedProposals.length && !openProposals.length) {
return null;
}
const name = market.tradableInstrument.instrument.code;
const assetSymbol = getQuoteName(market);
const proposalLink =
!passedProposals.length && openProposals[0]?.id
? governanceLink(TOKEN_PROPOSAL.replace(':id', openProposals[0]?.id))
: undefined;
const proposalsLink =
openProposals.length > 1 ? governanceLink(TOKEN_PROPOSALS) : undefined;
let content: ReactNode;
if (passedProposals.length) {
const { date, duration, price } = getMessageVariables(passedProposals[0]);
content = (
<>
<p className="uppercase mb-1">
{t('Trading on market {{name}} will stop on {{date}}', {
name,
date,
})}
</p>
<p>
{t(
'You will no longer be able to hold a position on this market when it closes in {{duration}}.',
{ duration }
)}{' '}
{price &&
assetSymbol &&
t('The final price will be {{price}} {{assetSymbol}}.', {
price: addDecimalsFormatNumber(price, market.decimalPlaces),
assetSymbol,
})}
</p>
</>
);
} else if (openProposals.length > 1) {
content = (
<>
<p className="uppercase mb-1">
{t(
'Trading on market {{name}} may stop. There are open proposals to close this market',
{ name }
)}
</p>
<p>
<ExternalLink href={proposalsLink}>
{t('View proposals')}
</ExternalLink>
</p>
</>
);
} else {
const { date, price } = getMessageVariables(openProposals[0]);
content = (
<>
<p className="mb-1">
{t(
'Trading on market {{name}} may stop on {{date}}. There is an open proposal to close this market.',
{ name, date }
)}
</p>
<p>
{price &&
assetSymbol &&
t('Proposed final price is {{price}} {{assetSymbol}}.', {
price: addDecimalsFormatNumber(price, market.decimalPlaces),
assetSymbol,
})}{' '}
<ExternalLink href={proposalLink}>{t('View proposal')}</ExternalLink>
</p>
</>
);
}
return <div data-testid={`update-state-banner-${market.id}`}>{content}</div>;
};
const getMessageVariables = (proposal: MarketViewProposalFieldsFragment) => {
const enactmentDatetime = new Date(proposal.terms.enactmentDatetime);
@@ -62,126 +138,3 @@ const getMessageVariables = (proposal: MarketViewProposalFieldsFragment) => {
price,
};
};
export const MarketTerminationBanner = ({
market,
}: {
market: Market | null;
}) => {
const t = useT();
const [visible, setVisible] = useState(true);
const skip = !market || !visible;
const { data: passedProposalsData } = useDataProvider({
dataProvider: marketViewProposalsDataProvider,
skip,
variables: {
inState: Types.ProposalState.STATE_PASSED,
proposalType: Types.ProposalType.TYPE_UPDATE_MARKET_STATE,
},
});
const { data: openProposalsData } = useDataProvider({
dataProvider: marketViewProposalsDataProvider,
skip,
variables: {
inState: Types.ProposalState.STATE_OPEN,
proposalType: Types.ProposalType.TYPE_UPDATE_MARKET_STATE,
},
});
const governanceLink = useLinks(DApp.Governance);
if (!market) return null;
const now = Date.now();
const passedProposals = filterProposals(passedProposalsData, market.id, now);
const openProposals = filterProposals(openProposalsData, market.id, now);
const name = market.tradableInstrument.instrument.code;
if (!passedProposals.length && !openProposals.length) {
return null;
}
const assetSymbol = getQuoteName(market);
const proposalLink =
!passedProposals.length && openProposals[0]?.id
? governanceLink(TOKEN_PROPOSAL.replace(':id', openProposals[0]?.id))
: undefined;
const proposalsLink =
openProposals.length > 1 ? governanceLink(TOKEN_PROPOSALS) : undefined;
let content: ReactNode;
if (passedProposals.length) {
const { date, duration, price } = getMessageVariables(passedProposals[0]);
content = (
<>
<div className="uppercase mb-1">
{t('Trading on Market {{name}} will stop on {{date}}', {
name,
date,
})}
</div>
<div>
{t(
'You will no longer be able to hold a position on this market when it closes in {{duration}}.',
{ duration }
)}{' '}
{price &&
assetSymbol &&
t('The final price will be {{price}} {{assetSymbol}}.', {
price: addDecimalsFormatNumber(price, market.decimalPlaces),
assetSymbol,
})}
</div>
</>
);
} else if (openProposals.length > 1) {
content = (
<>
<div className="uppercase mb-1">
{t(
'Trading on Market {{name}} may stop. There are open proposals to close this market',
{ name }
)}
</div>
<div>
<ExternalLink href={proposalsLink}>
{t('View proposals')}
</ExternalLink>
</div>
</>
);
} else {
const { date, price } = getMessageVariables(openProposals[0]);
content = (
<>
<div className="uppercase mb-1">
{t(
'Trading on Market {{name}} may stop on {{date}}. There is open proposal to close this market.',
{ name, date }
)}
</div>
<div>
{price &&
assetSymbol &&
t('Proposed final price is {{price}} {{assetSymbol}}.', {
price: addDecimalsFormatNumber(price, market.decimalPlaces),
assetSymbol,
})}
</div>
<div>
<ExternalLink href={proposalLink}>{t('View proposal')}</ExternalLink>
</div>
</>
);
}
return (
<NotificationBanner
intent={openProposals.length ? Intent.Warning : Intent.Info}
onClose={() => {
setVisible(false);
}}
data-testid={`termination-warning-banner-${market.id}`}
>
{content}
</NotificationBanner>
);
};
@@ -0,0 +1,88 @@
import {
useMarketProposals,
type MarketViewProposalFieldsFragment,
} from '@vegaprotocol/proposals';
import { ProposalState, ProposalType } from '@vegaprotocol/types';
const isPending = (p: MarketViewProposalFieldsFragment) => {
return [ProposalState.STATE_OPEN, ProposalState.STATE_PASSED].includes(
p.state
);
};
export const useUpdateMarketStateProposals = (
marketId: string,
inState?: ProposalState
) => {
const { data, error, loading } = useMarketProposals({
proposalType: ProposalType.TYPE_UPDATE_MARKET_STATE,
inState,
});
const proposals = data
? data.filter(isPending).filter((p) => {
const change = p.terms.change;
if (
change.__typename === 'UpdateMarketState' &&
change.market.id === marketId
) {
return true;
}
return false;
})
: [];
return { data, error, loading, proposals };
};
export const useUpdateMarketProposals = (
marketId: string,
inState?: ProposalState
) => {
const { data, error, loading } = useMarketProposals({
proposalType: ProposalType.TYPE_UPDATE_MARKET,
inState,
});
const proposals = data
? data.filter(isPending).filter((p) => {
const change = p.terms.change;
if (
change.__typename === 'UpdateMarket' &&
change.marketId === marketId
) {
return true;
}
return false;
})
: [];
return { data, error, loading, proposals };
};
export const useSuccessorMarketProposals = (
marketId: string,
inState?: ProposalState
) => {
const { data, loading, error } = useMarketProposals({
proposalType: ProposalType.TYPE_NEW_MARKET,
inState,
});
const proposals = data
? data.filter(isPending).filter((p) => {
const change = p.terms.change;
if (
change.__typename === 'NewMarket' &&
change.successorConfiguration?.parentMarketId === marketId
) {
return true;
}
return false;
})
: [];
return { data, error, loading, proposals };
};
@@ -97,9 +97,9 @@ const MarketData = ({
return (
<>
<div className="w-2/5" role="gridcell">
<div className="w-2/6" role="gridcell">
<h3 className="flex items-baseline">
<span className="overflow-hidden text-sm lg:text-base text-ellipsis whitespace-nowrap">
<span className="overflow-hidden text-xs md:text-sm lg:text-base text-ellipsis whitespace-nowrap">
{market.tradableInstrument.instrument.code}
</span>
{allProducts && productType && (
@@ -113,7 +113,7 @@ const MarketData = ({
)}
</div>
<div
className="w-1/5 overflow-hidden text-xs lg:text-sm whitespace-nowrap text-ellipsis"
className="w-2/6 overflow-hidden text-xs lg:text-sm whitespace-nowrap text-ellipsis text-right"
title={symbol}
data-testid="market-selector-price"
role="gridcell"
@@ -121,14 +121,14 @@ const MarketData = ({
{price} {symbol}
</div>
<div
className="w-1/5 overflow-hidden text-xs text-right lg:text-sm whitespace-nowrap text-ellipsis"
className="w-2/6 sm:w-1/6 overflow-hidden text-xs lg:text-sm whitespace-nowrap text-ellipsis text-right"
title={t('24h vol')}
data-testid="market-selector-volume"
role="gridcell"
>
{volume}
</div>
<div className="flex justify-end w-1/5" role="gridcell">
<div className="hidden sm:w-1/6 sm:flex justify-end" role="gridcell">
{oneDayCandles && (
<Sparkline
width={64}
@@ -64,7 +64,7 @@ export const MarketSelector = ({
setFilter((curr) => ({ ...curr, product }));
}}
/>
<div className="text-sm grid grid-cols-[2fr_1fr_1fr] gap-1 ">
<div className="text-sm flex sm:grid grid-cols-[2fr_1fr_1fr] gap-1 ">
<div className="flex-1">
<TradingInput
onChange={(e) =>
@@ -182,16 +182,16 @@ const MarketList = ({
'p-2 mx-2 border-b border-default text-xs text-secondary'
)}
>
<div className="w-2/5" role="columnheader">
<div className="w-2/6" role="columnheader">
{t('Name')}
</div>
<div className="w-1/5" role="columnheader">
<div className="w-2/6 text-right pr-4" role="columnheader">
{t('Price')}
</div>
<div className="w-1/5 text-right" role="columnheader">
<div className="w-2/6 sm:w-1/6 text-right" role="columnheader">
{t('24h volume')}
</div>
<div className="w-1/5" role="columnheader" />
<div className="hidden sm:w-1/6" role="columnheader" />
</div>
<div ref={listRef}>
<List
+11 -1
View File
@@ -5,6 +5,7 @@ import { useParams } from 'react-router-dom';
import * as PopoverPrimitive from '@radix-ui/react-popover';
import { useState } from 'react';
import { useT } from '../../lib/use-t';
import classNames from 'classnames';
/**
* This is only rendered for the mobile navigation
@@ -30,7 +31,16 @@ export const NavHeader = () => {
trigger={
<h1 className="flex gap-1 sm:gap-2 md:gap-4 items-center text-default text-lg whitespace-nowrap xl:pr-4 xl:border-r border-default">
{data ? data.tradableInstrument.instrument.code : t('Select market')}
<VegaIcon name={VegaIconNames.CHEVRON_DOWN} size={20} />
<span
className={classNames(
'transition-transform ease-in-out duration-300',
{
'rotate-180': open,
}
)}
>
<VegaIcon name={VegaIconNames.CHEVRON_DOWN} size={20} />
</span>
</h1>
}
>
+10 -1
View File
@@ -162,6 +162,8 @@ const NavbarMenu = ({ onClick }: { onClick: () => void }) => {
const envNameMapping = useEnvNameMapping();
const { VEGA_ENV, VEGA_NETWORKS, GITHUB_FEEDBACK_URL } = useEnvironment();
const marketId = useGlobalStore((store) => store.marketId);
const GOVERNANCE_LINK = useLinks(DApp.Governance)();
const EXPLORER_LINK = useLinks(DApp.Explorer)();
return (
<div className="gap-3 lg:flex lg:h-full">
@@ -220,7 +222,7 @@ const NavbarMenu = ({ onClick }: { onClick: () => void }) => {
</NavbarLink>
</NavbarItem>
<NavbarItem>
<NavbarLinkExternal to={useLinks(DApp.Governance)()}>
<NavbarLinkExternal to={GOVERNANCE_LINK}>
{t('Governance')}
</NavbarLinkExternal>
</NavbarItem>
@@ -228,6 +230,13 @@ const NavbarMenu = ({ onClick }: { onClick: () => void }) => {
<NavbarTrigger>{t('Resources')}</NavbarTrigger>
<NavbarContent data-testid="navbar-content-resources">
<ul className="lg:p-4">
{EXPLORER_LINK && (
<NavbarSubItem>
<NavbarLinkExternal to={EXPLORER_LINK}>
{t('Explorer')}
</NavbarLinkExternal>
</NavbarSubItem>
)}
{DocsLinks?.NEW_TO_VEGA && (
<NavbarSubItem>
<NavbarLinkExternal to={DocsLinks?.NEW_TO_VEGA}>
@@ -2,14 +2,19 @@ query RewardsPage($partyId: ID!) {
party(id: $partyId) {
id
vestingStats {
# AKA hoarder reward multiplier
rewardBonusMultiplier
quantumBalance
epochSeq
}
activityStreak {
# vesting multiplier
rewardVestingMultiplier
# AKA streak multiplier
activeFor
isActive
inactiveFor
rewardDistributionMultiplier
rewardVestingMultiplier
epoch
tradedVolume
openVolume
}
vestingBalancesSummary {
epoch
@@ -36,6 +41,74 @@ query RewardsPage($partyId: ID!) {
}
}
query ActiveRewards(
$isReward: Boolean
$partyId: ID
$direction: TransferDirection
$pagination: Pagination
) {
transfersConnection(
partyId: $partyId
isReward: $isReward
direction: $direction
pagination: $pagination
) {
edges {
node {
transfer {
amount
id
from
fromAccountType
to
toAccountType
asset {
id
symbol
decimals
name
quantum
status
}
reference
status
timestamp
kind {
... on RecurringTransfer {
startEpoch
endEpoch
dispatchStrategy {
dispatchMetric
dispatchMetricAssetId
marketIdsInScope
entityScope
individualScope
teamScope
nTopPerformers
stakingRequirement
notionalTimeWeightedAveragePositionRequirement
windowLength
lockPeriod
distributionStrategy
rankTable {
startRank
shareRatio
}
}
}
}
reason
}
fees {
transferId
amount
epoch
}
}
}
}
}
query RewardsHistory(
$partyId: ID!
$epochRewardSummariesPagination: Pagination
@@ -92,3 +165,18 @@ query RewardsEpoch {
id
}
}
query MarketForRewards($marketId: ID!) {
market(id: $marketId) {
tradableInstrument {
instrument {
id
name
code
metadata {
tags
}
}
}
}
}
@@ -8,7 +8,17 @@ export type RewardsPageQueryVariables = Types.Exact<{
}>;
export type RewardsPageQuery = { __typename?: 'Query', party?: { __typename?: 'Party', id: string, vestingStats?: { __typename?: 'PartyVestingStats', rewardBonusMultiplier: string } | null, activityStreak?: { __typename?: 'PartyActivityStreak', rewardVestingMultiplier: string, rewardDistributionMultiplier: string } | null, vestingBalancesSummary: { __typename?: 'PartyVestingBalancesSummary', epoch?: number | null, vestingBalances?: Array<{ __typename?: 'PartyVestingBalance', balance: string, asset: { __typename?: 'Asset', id: string, symbol: string, decimals: number, quantum: string } }> | null, lockedBalances?: Array<{ __typename?: 'PartyLockedBalance', balance: string, untilEpoch: number, asset: { __typename?: 'Asset', id: string, symbol: string, decimals: number, quantum: string } }> | null } } | null };
export type RewardsPageQuery = { __typename?: 'Query', party?: { __typename?: 'Party', id: string, vestingStats?: { __typename?: 'PartyVestingStats', rewardBonusMultiplier: string, quantumBalance: string, epochSeq: number } | null, activityStreak?: { __typename?: 'PartyActivityStreak', activeFor: number, isActive: boolean, inactiveFor: number, rewardDistributionMultiplier: string, rewardVestingMultiplier: string, epoch: number, tradedVolume: string, openVolume: string } | null, vestingBalancesSummary: { __typename?: 'PartyVestingBalancesSummary', epoch?: number | null, vestingBalances?: Array<{ __typename?: 'PartyVestingBalance', balance: string, asset: { __typename?: 'Asset', id: string, symbol: string, decimals: number, quantum: string } }> | null, lockedBalances?: Array<{ __typename?: 'PartyLockedBalance', balance: string, untilEpoch: number, asset: { __typename?: 'Asset', id: string, symbol: string, decimals: number, quantum: string } }> | null } } | null };
export type ActiveRewardsQueryVariables = Types.Exact<{
isReward?: Types.InputMaybe<Types.Scalars['Boolean']>;
partyId?: Types.InputMaybe<Types.Scalars['ID']>;
direction?: Types.InputMaybe<Types.TransferDirection>;
pagination?: Types.InputMaybe<Types.Pagination>;
}>;
export type ActiveRewardsQuery = { __typename?: 'Query', transfersConnection?: { __typename?: 'TransferConnection', edges?: Array<{ __typename?: 'TransferEdge', node: { __typename?: 'TransferNode', transfer: { __typename?: 'Transfer', amount: string, id: string, from: string, fromAccountType: Types.AccountType, to: string, toAccountType: Types.AccountType, reference?: string | null, status: Types.TransferStatus, timestamp: any, reason?: string | null, asset?: { __typename?: 'Asset', id: string, symbol: string, decimals: number, name: string, quantum: string, status: Types.AssetStatus } | null, kind: { __typename?: 'OneOffGovernanceTransfer' } | { __typename?: 'OneOffTransfer' } | { __typename?: 'RecurringGovernanceTransfer' } | { __typename?: 'RecurringTransfer', startEpoch: number, endEpoch?: number | null, dispatchStrategy?: { __typename?: 'DispatchStrategy', dispatchMetric: Types.DispatchMetric, dispatchMetricAssetId: string, marketIdsInScope?: Array<string> | null, entityScope: Types.EntityScope, individualScope?: Types.IndividualScope | null, teamScope?: Array<string | null> | null, nTopPerformers?: string | null, stakingRequirement: string, notionalTimeWeightedAveragePositionRequirement: string, windowLength: number, lockPeriod: number, distributionStrategy: Types.DistributionStrategy, rankTable?: Array<{ __typename?: 'RankTable', startRank: number, shareRatio: number } | null> | null } | null } }, fees?: Array<{ __typename?: 'TransferFee', transferId: string, amount: string, epoch: number } | null> | null } } | null> | null } | null };
export type RewardsHistoryQueryVariables = Types.Exact<{
partyId: Types.Scalars['ID'];
@@ -26,6 +36,13 @@ export type RewardsEpochQueryVariables = Types.Exact<{ [key: string]: never; }>;
export type RewardsEpochQuery = { __typename?: 'Query', epoch: { __typename?: 'Epoch', id: string } };
export type MarketForRewardsQueryVariables = Types.Exact<{
marketId: Types.Scalars['ID'];
}>;
export type MarketForRewardsQuery = { __typename?: 'Query', market?: { __typename?: 'Market', tradableInstrument: { __typename?: 'TradableInstrument', instrument: { __typename?: 'Instrument', id: string, name: string, code: string, metadata: { __typename?: 'InstrumentMetadata', tags?: Array<string> | null } } } } | null };
export const RewardsPageDocument = gql`
query RewardsPage($partyId: ID!) {
@@ -33,10 +50,18 @@ export const RewardsPageDocument = gql`
id
vestingStats {
rewardBonusMultiplier
quantumBalance
epochSeq
}
activityStreak {
rewardVestingMultiplier
activeFor
isActive
inactiveFor
rewardDistributionMultiplier
rewardVestingMultiplier
epoch
tradedVolume
openVolume
}
vestingBalancesSummary {
epoch
@@ -91,6 +116,101 @@ export function useRewardsPageLazyQuery(baseOptions?: Apollo.LazyQueryHookOption
export type RewardsPageQueryHookResult = ReturnType<typeof useRewardsPageQuery>;
export type RewardsPageLazyQueryHookResult = ReturnType<typeof useRewardsPageLazyQuery>;
export type RewardsPageQueryResult = Apollo.QueryResult<RewardsPageQuery, RewardsPageQueryVariables>;
export const ActiveRewardsDocument = gql`
query ActiveRewards($isReward: Boolean, $partyId: ID, $direction: TransferDirection, $pagination: Pagination) {
transfersConnection(
partyId: $partyId
isReward: $isReward
direction: $direction
pagination: $pagination
) {
edges {
node {
transfer {
amount
id
from
fromAccountType
to
toAccountType
asset {
id
symbol
decimals
name
quantum
status
}
reference
status
timestamp
kind {
... on RecurringTransfer {
startEpoch
endEpoch
dispatchStrategy {
dispatchMetric
dispatchMetricAssetId
marketIdsInScope
entityScope
individualScope
teamScope
nTopPerformers
stakingRequirement
notionalTimeWeightedAveragePositionRequirement
windowLength
lockPeriod
distributionStrategy
rankTable {
startRank
shareRatio
}
}
}
}
reason
}
fees {
transferId
amount
epoch
}
}
}
}
}
`;
/**
* __useActiveRewardsQuery__
*
* To run a query within a React component, call `useActiveRewardsQuery` and pass it any options that fit your needs.
* When your component renders, `useActiveRewardsQuery` 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 } = useActiveRewardsQuery({
* variables: {
* isReward: // value for 'isReward'
* partyId: // value for 'partyId'
* direction: // value for 'direction'
* pagination: // value for 'pagination'
* },
* });
*/
export function useActiveRewardsQuery(baseOptions?: Apollo.QueryHookOptions<ActiveRewardsQuery, ActiveRewardsQueryVariables>) {
const options = {...defaultOptions, ...baseOptions}
return Apollo.useQuery<ActiveRewardsQuery, ActiveRewardsQueryVariables>(ActiveRewardsDocument, options);
}
export function useActiveRewardsLazyQuery(baseOptions?: Apollo.LazyQueryHookOptions<ActiveRewardsQuery, ActiveRewardsQueryVariables>) {
const options = {...defaultOptions, ...baseOptions}
return Apollo.useLazyQuery<ActiveRewardsQuery, ActiveRewardsQueryVariables>(ActiveRewardsDocument, options);
}
export type ActiveRewardsQueryHookResult = ReturnType<typeof useActiveRewardsQuery>;
export type ActiveRewardsLazyQueryHookResult = ReturnType<typeof useActiveRewardsLazyQuery>;
export type ActiveRewardsQueryResult = Apollo.QueryResult<ActiveRewardsQuery, ActiveRewardsQueryVariables>;
export const RewardsHistoryDocument = gql`
query RewardsHistory($partyId: ID!, $epochRewardSummariesPagination: Pagination, $partyRewardsPagination: Pagination, $fromEpoch: Int, $toEpoch: Int) {
epochRewardSummaries(
@@ -202,4 +322,48 @@ export function useRewardsEpochLazyQuery(baseOptions?: Apollo.LazyQueryHookOptio
}
export type RewardsEpochQueryHookResult = ReturnType<typeof useRewardsEpochQuery>;
export type RewardsEpochLazyQueryHookResult = ReturnType<typeof useRewardsEpochLazyQuery>;
export type RewardsEpochQueryResult = Apollo.QueryResult<RewardsEpochQuery, RewardsEpochQueryVariables>;
export type RewardsEpochQueryResult = Apollo.QueryResult<RewardsEpochQuery, RewardsEpochQueryVariables>;
export const MarketForRewardsDocument = gql`
query MarketForRewards($marketId: ID!) {
market(id: $marketId) {
tradableInstrument {
instrument {
id
name
code
metadata {
tags
}
}
}
}
}
`;
/**
* __useMarketForRewardsQuery__
*
* To run a query within a React component, call `useMarketForRewardsQuery` and pass it any options that fit your needs.
* When your component renders, `useMarketForRewardsQuery` 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 } = useMarketForRewardsQuery({
* variables: {
* marketId: // value for 'marketId'
* },
* });
*/
export function useMarketForRewardsQuery(baseOptions: Apollo.QueryHookOptions<MarketForRewardsQuery, MarketForRewardsQueryVariables>) {
const options = {...defaultOptions, ...baseOptions}
return Apollo.useQuery<MarketForRewardsQuery, MarketForRewardsQueryVariables>(MarketForRewardsDocument, options);
}
export function useMarketForRewardsLazyQuery(baseOptions?: Apollo.LazyQueryHookOptions<MarketForRewardsQuery, MarketForRewardsQueryVariables>) {
const options = {...defaultOptions, ...baseOptions}
return Apollo.useLazyQuery<MarketForRewardsQuery, MarketForRewardsQueryVariables>(MarketForRewardsDocument, options);
}
export type MarketForRewardsQueryHookResult = ReturnType<typeof useMarketForRewardsQuery>;
export type MarketForRewardsLazyQueryHookResult = ReturnType<typeof useMarketForRewardsLazyQuery>;
export type MarketForRewardsQueryResult = Apollo.QueryResult<MarketForRewardsQuery, MarketForRewardsQueryVariables>;
@@ -0,0 +1,175 @@
import { render, screen } from '@testing-library/react';
import {
ActiveRewardCard,
applyFilter,
isActiveReward,
} from './active-rewards';
import {
AccountType,
AssetStatus,
DispatchMetric,
DistributionStrategy,
EntityScope,
IndividualScope,
type RecurringTransfer,
type TransferNode,
TransferStatus,
type Transfer,
} from '@vegaprotocol/types';
jest.mock('./__generated__/Rewards', () => ({
useMarketForRewardsQuery: () => ({
data: undefined,
}),
}));
jest.mock('@vegaprotocol/assets', () => ({
useAssetDataProvider: () => {
return {
data: {
assetId: 'asset-1',
},
};
},
}));
describe('ActiveRewards', () => {
const mockRecurringTransfer: RecurringTransfer = {
__typename: 'RecurringTransfer',
startEpoch: 115332,
endEpoch: 115432,
factor: '1',
dispatchStrategy: {
__typename: 'DispatchStrategy',
dispatchMetric: DispatchMetric.DISPATCH_METRIC_LP_FEES_RECEIVED,
dispatchMetricAssetId:
'c9fe6fc24fce121b2cc72680543a886055abb560043fda394ba5376203b7527d',
marketIdsInScope: null,
entityScope: EntityScope.ENTITY_SCOPE_INDIVIDUALS,
individualScope: IndividualScope.INDIVIDUAL_SCOPE_ALL,
teamScope: null,
nTopPerformers: '',
stakingRequirement: '',
notionalTimeWeightedAveragePositionRequirement: '',
windowLength: 1,
lockPeriod: 0,
distributionStrategy: DistributionStrategy.DISTRIBUTION_STRATEGY_PRO_RATA,
rankTable: null,
},
};
const mockTransferNode: TransferNode = {
__typename: 'TransferNode',
transfer: {
__typename: 'Transfer',
amount: '1613000000',
id: 'c4e59bd389c8098e6c7669f2d3e1613b9f42d30b1c4c3793ac44380f4c522835',
from: '69464e35bcb8e8a2900ca0f87acaf252d50cf2ab2fc73694845a16b7c8a0dc6f',
fromAccountType: AccountType.ACCOUNT_TYPE_GENERAL,
to: 'network',
toAccountType: AccountType.ACCOUNT_TYPE_REWARD_LP_RECEIVED_FEES,
asset: {
__typename: 'Asset',
id: 'c9fe6fc24fce121b2cc72680543a886055abb560043fda394ba5376203b7527d',
symbol: 'tUSDC',
decimals: 5,
name: 'tUSDC TEST',
quantum: '1',
status: AssetStatus.STATUS_ENABLED,
source: {
__typename: 'ERC20' as const,
contractAddress: '0x123',
lifetimeLimit: '100',
withdrawThreshold: '100',
},
},
reference: 'reward',
status: TransferStatus.STATUS_PENDING,
timestamp: '2023-12-18T13:05:35.948706Z',
kind: mockRecurringTransfer,
reason: null,
},
fees: [],
};
it('renders with valid props', () => {
render(
<ActiveRewardCard
transferNode={mockTransferNode}
currentEpoch={1}
kind={mockRecurringTransfer}
allMarkets={{}}
/>
);
expect(
screen.getByText(/Liquidity provision fees received/i)
).toBeInTheDocument();
expect(screen.getByText('Entity scope')).toBeInTheDocument();
expect(screen.getByText('Average position')).toBeInTheDocument();
expect(screen.getByText('Ends in')).toBeInTheDocument();
expect(screen.getByText('115431 epochs')).toBeInTheDocument();
expect(screen.getByText('Assessed over')).toBeInTheDocument();
expect(screen.getByText('1 epoch')).toBeInTheDocument();
});
describe('isActiveReward', () => {
it('returns true for valid active reward', () => {
const node = {
transfer: {
kind: {
__typename: 'RecurringTransfer',
dispatchStrategy: {},
endEpoch: 10,
},
status: TransferStatus.STATUS_PENDING,
},
} as TransferNode;
expect(isActiveReward(node, 5)).toBeTruthy();
});
it('returns false for invalid active reward', () => {
const node = {
transfer: {
kind: {
__typename: 'RecurringTransfer',
dispatchStrategy: {},
endEpoch: 10,
},
status: TransferStatus.STATUS_PENDING,
},
} as TransferNode;
expect(isActiveReward(node, 15)).toBeFalsy();
});
});
describe('applyFilter', () => {
it('returns true when filter matches dispatch metric label', () => {
const transfer = {
kind: {
__typename: 'RecurringTransfer',
dispatchStrategy: {
dispatchMetric: DispatchMetric.DISPATCH_METRIC_AVERAGE_POSITION,
},
},
asset: { symbol: 'XYZ' },
} as Transfer;
const filter = { searchTerm: 'average position' };
expect(applyFilter({ transfer }, filter)).toBeTruthy();
});
it('returns true when filter matches asset symbol', () => {
const transfer = {
kind: {
__typename: 'RecurringTransfer',
dispatchStrategy: {
dispatchMetric: DispatchMetric.DISPATCH_METRIC_AVERAGE_POSITION,
},
},
asset: { symbol: 'XYZ' },
} as Transfer;
const filter = { searchTerm: 'average position' };
expect(applyFilter({ transfer }, filter)).toBeTruthy();
});
});
});
@@ -0,0 +1,642 @@
import { useActiveRewardsQuery } from './__generated__/Rewards';
import { useT } from '../../lib/use-t';
import { addDecimalsFormatNumber } from '@vegaprotocol/utils';
import classNames from 'classnames';
import {
type IconName,
type VegaIconSize,
Icon,
Intent,
Tooltip,
VegaIcon,
VegaIconNames,
TradingInput,
TinyScroll,
} from '@vegaprotocol/ui-toolkit';
import { IconNames } from '@blueprintjs/icons';
import {
type Maybe,
type Transfer,
type TransferNode,
type RecurringTransfer,
DistributionStrategyDescriptionMapping,
DistributionStrategyMapping,
EntityScope,
EntityScopeMapping,
TransferStatus,
TransferStatusMapping,
DispatchMetric,
DispatchMetricDescription,
DispatchMetricLabels,
EntityScopeLabelMapping,
MarketState,
} from '@vegaprotocol/types';
import { Card } from '../card/card';
import { useMemo, useState } from 'react';
import {
type AssetFieldsFragment,
useAssetsMapProvider,
} from '@vegaprotocol/assets';
import {
type MarketFieldsFragment,
useMarketsMapProvider,
getAsset,
} from '@vegaprotocol/markets';
export type Filter = {
searchTerm: string;
};
export const isActiveReward = (node: TransferNode, currentEpoch: number) => {
const { transfer } = node;
if (transfer.kind.__typename !== 'RecurringTransfer') {
return false;
}
const { dispatchStrategy } = transfer.kind;
if (!dispatchStrategy) {
return false;
}
if (transfer.kind.endEpoch && transfer.kind.endEpoch < currentEpoch) {
return false;
}
if (transfer.status !== TransferStatus.STATUS_PENDING) {
return false;
}
return true;
};
export const applyFilter = (
node: TransferNode & {
asset?: AssetFieldsFragment | null;
markets?: (MarketFieldsFragment | null)[];
},
filter: Filter
) => {
const { transfer } = node;
if (
transfer.kind.__typename !== 'RecurringTransfer' ||
!transfer.kind.dispatchStrategy?.dispatchMetric
) {
return false;
}
if (
DispatchMetricLabels[transfer.kind.dispatchStrategy.dispatchMetric]
.toLowerCase()
.includes(filter.searchTerm.toLowerCase()) ||
transfer.asset?.symbol
.toLowerCase()
.includes(filter.searchTerm.toLowerCase()) ||
EntityScopeLabelMapping[transfer.kind.dispatchStrategy.entityScope]
.toLowerCase()
.includes(filter.searchTerm.toLowerCase()) ||
node.asset?.name
.toLocaleLowerCase()
.includes(filter.searchTerm.toLowerCase()) ||
node.markets?.some((m) =>
m?.tradableInstrument?.instrument?.name
.toLocaleLowerCase()
.includes(filter.searchTerm.toLowerCase())
)
) {
return true;
}
return false;
};
export const ActiveRewards = ({ currentEpoch }: { currentEpoch: number }) => {
const t = useT();
const { data: activeRewardsData } = useActiveRewardsQuery({
variables: {
isReward: true,
},
});
const [filter, setFilter] = useState<Filter>({
searchTerm: '',
});
const { data: assets } = useAssetsMapProvider();
const { data: markets } = useMarketsMapProvider();
const enrichedTransfers = activeRewardsData?.transfersConnection?.edges
?.map((e) => e?.node as TransferNode)
.filter((node) => isActiveReward(node, currentEpoch))
.map((node) => {
if (node.transfer.kind.__typename !== 'RecurringTransfer') {
return node;
}
const asset =
assets &&
assets[
node.transfer.kind.dispatchStrategy?.dispatchMetricAssetId || ''
];
const marketsInScope =
node.transfer.kind.dispatchStrategy?.marketIdsInScope?.map(
(id) => markets && markets[id]
);
return { ...node, asset, markets: marketsInScope };
});
if (!enrichedTransfers || !enrichedTransfers.length) return null;
return (
<Card title={t('Active rewards')} className="lg:col-span-full">
{enrichedTransfers.length > 1 && (
<TradingInput
onChange={(e) =>
setFilter((curr) => ({ ...curr, searchTerm: e.target.value }))
}
value={filter.searchTerm}
type="text"
placeholder={t(
'Search by reward dispatch metric, entity scope or asset name'
)}
data-testid="search-term"
className="mb-4 w-20 mr-2"
prependElement={<VegaIcon name={VegaIconNames.SEARCH} />}
/>
)}
<TinyScroll className="grid gap-x-8 gap-y-10 h-fit grid-cols-[repeat(auto-fill,_minmax(230px,_1fr))] md:grid-cols-[repeat(auto-fill,_minmax(230px,_1fr))] lg:grid-cols-[repeat(auto-fill,_minmax(320px,_1fr))] xl:grid-cols-[repeat(auto-fill,_minmax(335px,_1fr))] max-h-[40rem] overflow-auto pr-2">
{enrichedTransfers
.filter((n) => applyFilter(n, filter))
.map((node, i) => {
const { transfer } = node;
if (
transfer.kind.__typename !== 'RecurringTransfer' ||
!transfer.kind.dispatchStrategy?.dispatchMetric
) {
return null;
}
return (
node && (
<ActiveRewardCard
key={i}
transferNode={node}
kind={transfer.kind}
currentEpoch={currentEpoch}
allMarkets={markets || {}}
/>
)
);
})}
</TinyScroll>
</Card>
);
};
// This was built to be a status indicator for the rewards based on the transfer status
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const StatusIndicator = ({
status,
reason,
}: {
status: TransferStatus;
reason?: Maybe<string> | undefined;
}) => {
const t = useT();
const getIconIntent = (status: string) => {
switch (status) {
case TransferStatus.STATUS_DONE:
return { icon: IconNames.TICK_CIRCLE, intent: Intent.Success };
case TransferStatus.STATUS_REJECTED:
return { icon: IconNames.ERROR, intent: Intent.Danger };
default:
return { icon: IconNames.HELP, intent: Intent.Primary };
}
};
const { icon, intent } = getIconIntent(status);
return (
<Tooltip
description={
<span>
{t('Transfer status: {{status}} {{reason}}', {
status: TransferStatusMapping[status],
reason: reason ? `(${reason})` : '',
})}
</span>
}
>
<span
className={classNames(
{
'text-gray-700 dark:text-gray-300': intent === Intent.None,
'text-vega-blue': intent === Intent.Primary,
'text-vega-green dark:text-vega-green': intent === Intent.Success,
'dark:text-yellow text-yellow-600': intent === Intent.Warning,
'text-vega-red': intent === Intent.Danger,
},
'flex items-start p-1 align-text-bottom'
)}
>
<Icon size={3} name={icon as IconName} />
</span>
</Tooltip>
);
};
export const ActiveRewardCard = ({
transferNode,
currentEpoch,
kind,
allMarkets,
}: {
transferNode: TransferNode & {
asset?: AssetFieldsFragment | null;
markets?: (MarketFieldsFragment | null)[];
};
currentEpoch: number;
kind: RecurringTransfer;
allMarkets?: Record<string, MarketFieldsFragment | null>;
}) => {
const t = useT();
const { transfer } = transferNode;
const { dispatchStrategy } = kind;
const marketIdsInScope = dispatchStrategy?.marketIdsInScope;
const firstMarketData = transferNode.markets?.[0];
const specificMarkets = useMemo(() => {
if (
!firstMarketData ||
!marketIdsInScope ||
marketIdsInScope.length === 0
) {
return null;
}
if (marketIdsInScope.length > 1) {
const marketNames =
allMarkets &&
marketIdsInScope
.map((id) => allMarkets[id]?.tradableInstrument?.instrument?.name)
.join(', ');
return (
<Tooltip description={marketNames}>
<span>Specific markets</span>
</Tooltip>
);
}
return (
<span>{firstMarketData?.tradableInstrument?.instrument?.name || ''}</span>
);
}, [firstMarketData, marketIdsInScope, allMarkets]);
const dispatchAsset = transferNode.asset;
if (!dispatchStrategy) {
return null;
}
// Gray out/hide the cards that are related to not trading markets
const marketSettled = transferNode.markets?.some(
(m) =>
m?.state &&
[
MarketState.STATE_TRADING_TERMINATED,
MarketState.STATE_SETTLED,
MarketState.STATE_CANCELLED,
MarketState.STATE_CLOSED,
].includes(m.state)
);
const assetInSettledMarket =
allMarkets &&
Object.values(allMarkets).some((m: MarketFieldsFragment | null) => {
if (m && getAsset(m).id === dispatchStrategy.dispatchMetricAssetId) {
return (
m?.state &&
[
MarketState.STATE_TRADING_TERMINATED,
MarketState.STATE_SETTLED,
MarketState.STATE_CANCELLED,
MarketState.STATE_CLOSED,
].includes(m.state)
);
}
return false;
});
if (marketSettled) {
return null;
}
// Gray out the cards that are related to suspended markets
const suspended = transferNode.markets?.some(
(m) =>
m?.state === MarketState.STATE_SUSPENDED ||
m?.state === MarketState.STATE_SUSPENDED_VIA_GOVERNANCE
);
const assetInSuspendedMarket =
allMarkets &&
Object.values(allMarkets).some((m: MarketFieldsFragment | null) => {
if (m && getAsset(m).id === dispatchStrategy.dispatchMetricAssetId) {
return (
m?.state === MarketState.STATE_SUSPENDED ||
m?.state === MarketState.STATE_SUSPENDED_VIA_GOVERNANCE
);
}
return false;
});
// Gray out the cards that are related to suspended markets
const { gradientClassName, mainClassName } =
suspended || assetInSuspendedMarket || assetInSettledMarket
? {
gradientClassName: 'from-vega-cdark-500 to-vega-clight-400',
mainClassName: 'from-vega-cdark-400 dark:from-vega-cdark-600 to-20%',
}
: getGradientClasses(dispatchStrategy.dispatchMetric);
const entityScope = dispatchStrategy.entityScope;
return (
<div>
<div
className={classNames(
'bg-gradient-to-r col-span-full p-0.5 lg:col-auto h-full',
'rounded-lg',
gradientClassName
)}
>
<div
className={classNames(
mainClassName,
'bg-gradient-to-b bg-vega-clight-800 dark:bg-vega-cdark-800 h-full w-full rounded p-4 flex flex-col gap-4'
)}
>
<div className="flex justify-between gap-4">
<div className="flex flex-col gap-2 items-center text-center">
<EntityIcon transfer={transfer} />
{entityScope && (
<span className="text-muted text-xs">
{EntityScopeLabelMapping[entityScope] || t('Unspecified')}
</span>
)}
</div>
<div className="flex flex-col gap-2 items-center text-center">
<h3 className="flex flex-col gap-1 text-2xl shrink-1 text-center">
<span className="font-glitch">
{addDecimalsFormatNumber(
transferNode.transfer.amount,
transferNode.transfer.asset?.decimals || 0,
6
)}
</span>
<span className="font-alpha">
{transferNode.transfer.asset?.symbol}
</span>
</h3>
{
<Tooltip
description={t(
DistributionStrategyDescriptionMapping[
dispatchStrategy.distributionStrategy
]
)}
underline={true}
>
<span className="text-xs">
{
DistributionStrategyMapping[
dispatchStrategy.distributionStrategy
]
}
</span>
</Tooltip>
}
</div>
<div className="flex flex-col gap-2 items-center text-center">
<CardIcon
iconName={VegaIconNames.LOCK}
tooltip={t(
'Number of epochs after distribution to delay vesting of rewards by'
)}
/>
<span className="text-muted text-xs whitespace-nowrap">
{t('numberEpochs', '{{count}} epochs', {
count: kind.dispatchStrategy?.lockPeriod,
})}
</span>
</div>
</div>
<span className="border-[0.5px] border-gray-700" />
<span>
{DispatchMetricLabels[dispatchStrategy.dispatchMetric]} {' '}
<Tooltip
underline={suspended}
description={
(suspended || assetInSuspendedMarket) &&
(specificMarkets
? t('Eligible market(s) currently suspended')
: assetInSuspendedMarket
? t('Currently no markets eligible for reward')
: '')
}
>
<span>{specificMarkets || dispatchAsset?.name}</span>
</Tooltip>
</span>
<div className="flex items-center gap-8 flex-wrap">
{kind.endEpoch && (
<span className="flex flex-col">
<span className="text-muted text-xs">{t('Ends in')}</span>
<span>
{t('numberEpochs', '{{count}} epochs', {
count: kind.endEpoch - currentEpoch,
})}
</span>
</span>
)}
{
<span className="flex flex-col">
<span className="text-muted text-xs">{t('Assessed over')}</span>
<span>
{t('numberEpochs', '{{count}} epochs', {
count: dispatchStrategy.windowLength,
})}
</span>
</span>
}
</div>
{dispatchStrategy?.dispatchMetric && (
<span className="text-muted text-sm h-[2rem]">
{t(DispatchMetricDescription[dispatchStrategy?.dispatchMetric])}
</span>
)}
<span className="border-[0.5px] border-gray-700" />
<div className="flex justify-between flex-wrap items-center gap-3 text-xs">
<span className="flex flex-col gap-1">
<span className="flex items-center gap-1 text-muted">
{t('Entity scope')}{' '}
</span>
<span className="flex items-center gap-1">
{kind.dispatchStrategy?.teamScope && (
<Tooltip
description={
<span>{kind.dispatchStrategy?.teamScope}</span>
}
>
<span className="flex items-center p-1 rounded-full border border-gray-600">
{<VegaIcon name={VegaIconNames.TEAM} size={16} />}
</span>
</Tooltip>
)}
{kind.dispatchStrategy?.individualScope && (
<Tooltip
description={
<span>{kind.dispatchStrategy?.individualScope}</span>
}
>
<span className="flex items-center p-1 rounded-full border border-gray-600">
{<VegaIcon name={VegaIconNames.MAN} size={16} />}
</span>
</Tooltip>
)}
{/* Shows transfer status */}
{/* <StatusIndicator
status={transfer.status}
reason={transfer.reason}
/> */}
</span>
</span>
<span className="flex flex-col gap-1">
<span className="flex items-center gap-1 text-muted">
{t('Staked VEGA')}{' '}
</span>
<span className="flex items-center gap-1">
{addDecimalsFormatNumber(
kind.dispatchStrategy?.stakingRequirement || 0,
transfer.asset?.decimals || 0
)}
</span>
</span>
<span className="flex flex-col gap-1">
<span className="flex items-center gap-1 text-muted">
{t('Average position')}{' '}
</span>
<span className="flex items-center gap-1">
{addDecimalsFormatNumber(
kind.dispatchStrategy
?.notionalTimeWeightedAveragePositionRequirement || 0,
transfer.asset?.decimals || 0
)}
</span>
</span>
</div>
</div>
</div>
</div>
);
};
const getGradientClasses = (d: DispatchMetric | undefined) => {
switch (d) {
case DispatchMetric.DISPATCH_METRIC_AVERAGE_POSITION:
return {
gradientClassName: 'from-vega-pink-500 to-vega-purple-400',
mainClassName: 'from-vega-pink-400 dark:from-vega-pink-600 to-20%',
};
case DispatchMetric.DISPATCH_METRIC_LP_FEES_RECEIVED:
return {
gradientClassName: 'from-vega-green-500 to-vega-yellow-500',
mainClassName: 'from-vega-green-400 dark:from-vega-green-600 to-20%',
};
case DispatchMetric.DISPATCH_METRIC_MAKER_FEES_PAID:
return {
gradientClassName: 'from-vega-orange-500 to-vega-pink-400',
mainClassName: 'from-vega-orange-400 dark:from-vega-orange-600 to-20%',
};
case DispatchMetric.DISPATCH_METRIC_MARKET_VALUE:
case DispatchMetric.DISPATCH_METRIC_RELATIVE_RETURN:
return {
gradientClassName: 'from-vega-purple-500 to-vega-blue-400',
mainClassName: 'from-vega-purple-400 dark:from-vega-purple-600 to-20%',
};
case DispatchMetric.DISPATCH_METRIC_RETURN_VOLATILITY:
return {
gradientClassName: 'from-vega-blue-500 to-vega-green-400',
mainClassName: 'from-vega-blue-400 dark:from-vega-blue-600 to-20%',
};
case DispatchMetric.DISPATCH_METRIC_VALIDATOR_RANKING:
default:
return {
gradientClassName: 'from-vega-pink-500 to-vega-purple-400',
mainClassName: 'from-vega-pink-400 dark:from-vega-pink-600 to-20%',
};
}
};
const CardIcon = ({
size = 18,
iconName,
tooltip,
}: {
size?: VegaIconSize;
iconName: VegaIconNames;
tooltip: string;
}) => {
return (
<Tooltip description={<span>{tooltip}</span>}>
<span className="flex items-center p-2 rounded-full border border-gray-600">
<VegaIcon name={iconName} size={size} />
</span>
</Tooltip>
);
};
const EntityIcon = ({
transfer,
size = 18,
}: {
transfer: Transfer;
size?: VegaIconSize;
}) => {
if (transfer.kind.__typename !== 'RecurringTransfer') {
return null;
}
const entityScope = transfer.kind.dispatchStrategy?.entityScope;
const getIconName = () => {
switch (entityScope) {
case EntityScope.ENTITY_SCOPE_TEAMS:
return VegaIconNames.TEAM;
case EntityScope.ENTITY_SCOPE_INDIVIDUALS:
return VegaIconNames.MAN;
default:
return VegaIconNames.QUESTION_MARK;
}
};
const iconName = getIconName();
return (
<Tooltip
description={
<span>{entityScope ? EntityScopeMapping[entityScope] : ''}</span>
}
>
<span className="flex items-center p-2 rounded-full border border-gray-600">
{iconName && <VegaIcon name={iconName} size={size} />}
</span>
</Tooltip>
);
};
@@ -33,6 +33,10 @@ import { useGetCurrentRouteId } from '../../lib/hooks/use-get-current-route-id';
import { RewardsHistoryContainer } from './rewards-history';
import { useT } from '../../lib/use-t';
import { useAssetsMapProvider } from '@vegaprotocol/assets';
import { ActiveRewards } from './active-rewards';
import { ActivityStreak } from './streaks/activity-streaks';
import { RewardHoarderBonus } from './streaks/reward-hoarder-bonus';
import classNames from 'classnames';
const ASSETS_WITH_INCORRECT_VESTING_REWARD_DATA = [
'bf1e88d19db4b3ca0d1d5bdb73718a01686b18cf731ca26adedf3c8b83802bba', // USDT mainnet
@@ -45,6 +49,7 @@ export const RewardsContainer = () => {
const { params, loading: paramsLoading } = useNetworkParams([
NetworkParams.reward_asset,
NetworkParams.rewards_activityStreak_benefitTiers,
NetworkParams.rewards_vesting_benefitTiers,
NetworkParams.rewards_vesting_baseRate,
]);
@@ -54,6 +59,14 @@ export const RewardsContainer = () => {
const { data: epochData } = useRewardsEpochQuery();
const { rewards_activityStreak_benefitTiers, rewards_vesting_benefitTiers } =
params || {};
const activityStreakBenefitTiers = JSON.parse(
rewards_activityStreak_benefitTiers
);
const vestingBenefitTiers = JSON.parse(rewards_vesting_benefitTiers);
// No need to specify the fromEpoch as it will by default give you the last
// Note activityStreak in query will fail
const { data: rewardsData, loading: rewardsLoading } = useRewardsPageQuery({
@@ -68,6 +81,9 @@ export const RewardsContainer = () => {
pollInterval: 10000,
});
const partyActivityStreak = rewardsData?.party?.activityStreak;
const vestingDetails = rewardsData?.party?.vestingStats;
if (!epochData?.epoch || !assetMap) return null;
const loading = paramsLoading || accountsLoading || rewardsLoading;
@@ -114,74 +130,98 @@ export const RewardsContainer = () => {
]);
return (
<div className="grid auto-rows-min grid-cols-6 gap-3">
{/* Always show reward information for vega */}
<Card
key={params.reward_asset}
title={t('Vega Reward pot')}
className="lg:col-span-3 xl:col-span-2"
loading={loading}
highlight={true}
>
<RewardPot
pubKey={pubKey}
accounts={accounts}
assetId={params.reward_asset}
vestingBalancesSummary={rewardsData?.party?.vestingBalancesSummary}
/>
</Card>
<Card
title={t('Vesting')}
className="lg:col-span-3 xl:col-span-2"
loading={loading}
>
<Vesting
pubKey={pubKey}
baseRate={params.rewards_vesting_baseRate}
multiplier={
rewardsData?.party?.activityStreak?.rewardVestingMultiplier
}
/>
</Card>
<Card
title={t('Rewards multipliers')}
className="lg:col-span-3 xl:col-span-2"
loading={loading}
highlight={true}
>
<Multipliers
pubKey={pubKey}
hoarderMultiplier={
rewardsData?.party?.vestingStats?.rewardBonusMultiplier
}
streakMultiplier={
rewardsData?.party?.activityStreak?.rewardDistributionMultiplier
}
/>
</Card>
<div className="flex flex-col w-full gap-3">
<div className="grid auto-rows-min grid-cols-6 gap-3">
{/* Always show reward information for vega */}
<Card
key={params.reward_asset}
title={t('Vega Reward pot')}
className="lg:col-span-3 xl:col-span-2"
loading={loading}
highlight={true}
>
<RewardPot
pubKey={pubKey}
accounts={accounts}
assetId={params.reward_asset}
vestingBalancesSummary={rewardsData?.party?.vestingBalancesSummary}
/>
</Card>
<Card
title={t('Vesting')}
className="lg:col-span-3 xl:col-span-2"
loading={loading}
>
<Vesting
pubKey={pubKey}
baseRate={params.rewards_vesting_baseRate}
multiplier={
rewardsData?.party?.activityStreak?.rewardVestingMultiplier
}
/>
</Card>
<Card
title={t('Rewards multipliers')}
className="lg:col-span-3 xl:col-span-2"
loading={loading}
highlight={true}
>
<Multipliers
pubKey={pubKey}
hoarderMultiplier={
rewardsData?.party?.vestingStats?.rewardBonusMultiplier
}
streakMultiplier={
rewardsData?.party?.activityStreak?.rewardDistributionMultiplier
}
/>
</Card>
{/* Show all other reward pots, most of the time users will not have other rewards */}
{assets
.filter((assetId) => assetId !== params.reward_asset)
.map((assetId) => {
const asset = assetMap ? assetMap[assetId] : null;
{/* Show all other reward pots, most of the time users will not have other rewards */}
{assets
.filter((assetId) => assetId !== params.reward_asset)
.map((assetId) => {
const asset = assetMap ? assetMap[assetId] : null;
if (!asset) return null;
if (!asset) return null;
// Following code is for mitigating an issue due to a core bug where locked and vesting
// balances were incorrectly increased for infrastructure rewards for USDT on mainnet
//
// We don't want to incorrectly show the wring locked/vesting values, but we DO want to
// show the user that they have rewards available to withdraw
if (ASSETS_WITH_INCORRECT_VESTING_REWARD_DATA.includes(asset.id)) {
const accountsForAsset = rewardAccountsAssetMap[asset.id];
const vestedAccount = accountsForAsset?.find(
(a) => a.type === AccountType.ACCOUNT_TYPE_VESTED_REWARDS
);
// Following code is for mitigating an issue due to a core bug where locked and vesting
// balances were incorrectly increased for infrastructure rewards for USDT on mainnet
//
// We don't want to incorrectly show the wring locked/vesting values, but we DO want to
// show the user that they have rewards available to withdraw
if (ASSETS_WITH_INCORRECT_VESTING_REWARD_DATA.includes(asset.id)) {
const accountsForAsset = rewardAccountsAssetMap[asset.id];
const vestedAccount = accountsForAsset?.find(
(a) => a.type === AccountType.ACCOUNT_TYPE_VESTED_REWARDS
);
// No vested rewards available to withdraw, so skip over USDT
if (!vestedAccount || Number(vestedAccount.balance) <= 0) {
return null;
// No vested rewards available to withdraw, so skip over USDT
if (!vestedAccount || Number(vestedAccount.balance) <= 0) {
return null;
}
return (
<Card
key={assetId}
title={t('{{assetSymbol}} Reward pot', {
assetSymbol: asset.symbol,
})}
className="lg:col-span-3 xl:col-span-2"
loading={loading}
>
<RewardPot
pubKey={pubKey}
accounts={accounts}
assetId={assetId}
// Ensure that these values are shown as 0
vestingBalancesSummary={{
lockedBalances: [],
vestingBalances: [],
}}
/>
</Card>
);
}
return (
@@ -197,48 +237,69 @@ export const RewardsContainer = () => {
pubKey={pubKey}
accounts={accounts}
assetId={assetId}
// Ensure that these values are shown as 0
vestingBalancesSummary={{
lockedBalances: [],
vestingBalances: [],
}}
vestingBalancesSummary={
rewardsData?.party?.vestingBalancesSummary
}
/>
</Card>
);
}
return (
<Card
key={assetId}
title={t('{{assetSymbol}} Reward pot', {
assetSymbol: asset.symbol,
})}
className="lg:col-span-3 xl:col-span-2"
loading={loading}
>
<RewardPot
pubKey={pubKey}
accounts={accounts}
assetId={assetId}
vestingBalancesSummary={
rewardsData?.party?.vestingBalancesSummary
}
})}
</div>
<div className="grid auto-rows-min grid-cols-6 gap-3">
{pubKey && activityStreakBenefitTiers.tiers?.length > 0 && (
<Card
title={t('Activity Streak')}
className={classNames(
{
'lg:col-span-6 xl:col-span-3':
activityStreakBenefitTiers.tiers.length <= 4,
'xl:col-span-6': activityStreakBenefitTiers.tiers.length > 4,
},
'hidden md:block'
)}
>
<span className="flex flex-col mr-8 pr-4">
<ActivityStreak
tiers={activityStreakBenefitTiers.tiers}
streak={partyActivityStreak}
/>
</Card>
);
})}
<Card
title={t('Rewards history')}
className="lg:col-span-full"
loading={rewardsLoading}
noBackgroundOnMobile={true}
>
<RewardsHistoryContainer
epoch={Number(epochData?.epoch.id)}
pubKey={pubKey}
assets={assetMap}
/>
</Card>
</span>
</Card>
)}
{pubKey && vestingBenefitTiers.tiers?.length > 0 && (
<Card
title={t('Reward Hoarder Bonus')}
className={classNames(
{
'lg:col-span-6 xl:col-span-3':
vestingBenefitTiers.tiers.length <= 4,
'xl:col-span-6': vestingBenefitTiers.tiers.length > 4,
},
'hidden md:block'
)}
>
<span className="flex flex-col mr-8 pr-4">
<RewardHoarderBonus
tiers={vestingBenefitTiers.tiers}
vestingDetails={vestingDetails}
/>
</span>
</Card>
)}
<ActiveRewards currentEpoch={Number(epochData?.epoch.id)} />
<Card
title={t('Rewards history')}
className="lg:col-span-full hidden md:block"
loading={rewardsLoading}
noBackgroundOnMobile={true}
>
<RewardsHistoryContainer
epoch={Number(epochData?.epoch.id)}
pubKey={pubKey}
assets={assetMap}
/>
</Card>
</div>
</div>
);
};
@@ -343,7 +404,7 @@ export const RewardPot = ({
})}
<VegaIcon name={VegaIconNames.LOCK} size={12} />
</CardTableTH>
<CardTableTD>
<CardTableTD data-testid="locked-value">
{addDecimalsFormatNumberQuantum(
totalLocked.toString(),
rewardAsset.decimals,
@@ -357,7 +418,7 @@ export const RewardPot = ({
assetSymbol: rewardAsset.symbol,
})}
</CardTableTH>
<CardTableTD>
<CardTableTD data-testid="vesting-value">
{addDecimalsFormatNumberQuantum(
totalVesting.toString(),
rewardAsset.decimals,
@@ -369,7 +430,7 @@ export const RewardPot = ({
<CardTableTH>
{t('Available to withdraw this epoch')}
</CardTableTH>
<CardTableTD>
<CardTableTD data-testid="available-to-withdraw-value">
{addDecimalsFormatNumberQuantum(
totalVestedRewardsByRewardAsset.toString(),
rewardAsset.decimals,
@@ -388,6 +449,7 @@ export const RewardPot = ({
)
}
size="small"
data-testid="redeem-rewards-button"
>
{t('Redeem rewards')}
</TradingButton>
@@ -422,12 +484,16 @@ export const Vesting = ({
<CardTable>
<tr>
<CardTableTH>{t('Base rate')}</CardTableTH>
<CardTableTD>{baseRateFormatted}%</CardTableTD>
<CardTableTD data-testid="base-rate-value">
{baseRateFormatted}%
</CardTableTD>
</tr>
{pubKey && (
<tr>
<CardTableTH>{t('Vesting multiplier')}</CardTableTH>
<CardTableTD>{multiplier ? `${multiplier}x` : '-'}</CardTableTD>
<CardTableTD data-testid="vesting multiplier-value">
{multiplier ? `${multiplier}x` : '-'}
</CardTableTD>
</tr>
)}
</CardTable>
@@ -467,13 +533,13 @@ export const Multipliers = ({
<CardTable>
<tr>
<CardTableTH>{t('Streak reward multiplier')}</CardTableTH>
<CardTableTD>
<CardTableTD data-testid="streak-reward-multiplier-value">
{streakMultiplier ? `${streakMultiplier}x` : '-'}
</CardTableTD>
</tr>
<tr>
<CardTableTH>{t('Hoarder reward multiplier')}</CardTableTH>
<CardTableTD>
<CardTableTD data-testid="hoarder-reward-multiplier-value">
{hoarderMultiplier ? `${hoarderMultiplier}x` : '-'}
</CardTableTD>
</tr>
@@ -319,6 +319,7 @@ export const RewardHistoryTable = ({
onClick={() => setIsParty(false)}
size="extra-small"
minimal={isParty}
data-testid="total-distributed-button"
>
{t('Total distributed')}
</TradingButton>
@@ -327,6 +328,7 @@ export const RewardHistoryTable = ({
size="extra-small"
disabled={!pubKey}
minimal={!isParty}
data-testid="earned-by-me-button"
>
{t('Earned by me')}
</TradingButton>
@@ -0,0 +1,66 @@
import { render, screen } from '@testing-library/react';
import { ActivityStreak } from './activity-streaks';
describe('ActivityStreak', () => {
it('renders null when streak is not active', () => {
const tiers: {
minimum_activity_streak: number;
reward_multiplier: string;
vesting_multiplier: string;
}[] = [];
const streak = null;
render(<ActivityStreak tiers={tiers} streak={streak} />);
const component = screen.queryByText(/epochs streak/i);
expect(component).toBeNull();
});
it('renders null when tiers are empty', () => {
const tiers: {
minimum_activity_streak: number;
reward_multiplier: string;
vesting_multiplier: string;
}[] = [];
const streak = {
activeFor: 10,
isActive: true,
inactiveFor: 10,
rewardDistributionMultiplier: '45678',
rewardVestingMultiplier: '45678',
epoch: 10,
tradedVolume: '45678',
openVolume: '45678',
};
render(<ActivityStreak tiers={tiers} streak={streak} />);
const component = screen.queryByText(/epochs streak/i);
expect(component).toBeNull();
});
it('renders the component with tiers and active streak', () => {
const tiers = [
{
minimum_activity_streak: 5,
reward_multiplier: '1.5x',
vesting_multiplier: '2x',
},
{
minimum_activity_streak: 10,
reward_multiplier: '2x',
vesting_multiplier: '3x',
},
];
const streak = {
activeFor: 7,
isActive: true,
inactiveFor: 10,
rewardDistributionMultiplier: '45678',
rewardVestingMultiplier: '45678',
epoch: 10,
tradedVolume: '45678',
openVolume: '45678',
};
render(<ActivityStreak tiers={tiers} streak={streak} />);
const tierLabels = screen.getAllByText(/Tier/i);
expect(tierLabels.length).toBe(3); // 2 tiers + 1 label
});
});
@@ -0,0 +1,226 @@
import { VegaIcon, VegaIconNames } from '@vegaprotocol/ui-toolkit';
import { useT } from '../../../lib/use-t';
import classNames from 'classnames';
import BigNumber from 'bignumber.js';
import type { PartyActivityStreak } from '@vegaprotocol/types';
export const safeProgress = (
i: number,
userTierIndex: number,
total: number | string,
progress?: number | string
) => {
if (i < userTierIndex) return 100;
if (i > userTierIndex) return 0;
if (!progress || !total) return 0;
if (new BigNumber(progress).isGreaterThan(total)) return 100;
return new BigNumber(progress)
.multipliedBy(100)
.dividedBy(total || 1)
.toNumber();
};
export const useGetUserTier = (
tiers: {
minimum_activity_streak: number;
reward_multiplier: string;
vesting_multiplier: string;
}[],
progress?: number
) => {
if (!progress) return 0;
if (!tiers || tiers.length === 0) return 0;
let userTier = 0;
let i = 0;
while (
i < tiers.length &&
tiers[userTier].minimum_activity_streak < progress
) {
userTier = i;
i++;
}
if (
i === tiers.length &&
tiers[userTier].minimum_activity_streak <= progress
) {
userTier = i;
}
if (userTier > tiers.length) {
userTier--;
}
return userTier;
};
export const ActivityStreak = ({
tiers,
streak,
}: {
tiers: {
minimum_activity_streak: number;
reward_multiplier: string;
vesting_multiplier: string;
}[];
streak?: PartyActivityStreak | null;
}) => {
const t = useT();
const userTierIndex = useGetUserTier(tiers, streak?.activeFor);
if (!tiers || tiers.length === 0) return null;
const progressBarHeight = 'h-10';
return (
<>
<div className="flex flex-col gap-1 w-full">
<div className="flex flex-col gap-1">
<div
className="grid"
style={{
gridTemplateColumns:
'repeat(' + tiers.length + ', minmax(0, 1fr))',
}}
>
{tiers.map((tier, index) => {
return (
<div key={index} className="flex justify-end -mr-[2.85rem]">
<span className="flex flex-col items-center gap-4 justify-between">
<span className="flex flex-col items-center gap-1">
<span className="flex flex-col items-center font-medium">
<span className="text-sm">
{t('Tier {{tier}}', {
tier: index + 1,
})}
</span>
<span className="text-muted text-xs">
{t('numberEpochs', '{{count}} epochs', {
count: tier.minimum_activity_streak,
})}
</span>
</span>
<span
className={classNames(
'text-xs flex flex-col items-center justify-center px-2 py-1 rounded-lg text-white border',
{
'border-pink-600 bg-pink-900': index % 6 === 0,
'border-purple-600 bg-purple-900': index % 6 === 1,
'border-blue-600 bg-blue-900': index % 6 === 2,
'border-orange-600 bg-orange-900': index % 6 === 3,
'border-green-600 bg-green-900': index % 6 === 4,
'border-yellow-600 bg-yellow-900': index % 6 === 5,
}
)}
>
<span>
{t('Reward {{reward}}x', {
reward: tier.reward_multiplier,
})}
</span>
<span>
{t('Vesting {{vesting}}x', {
vesting: tier.vesting_multiplier,
})}
</span>
</span>
</span>
<span
className={classNames(
{
'text-pink-500': index % 6 === 0,
'text-purple-500': index % 6 === 1,
'text-blue-500': index % 6 === 2,
'text-orange-500': index % 6 === 3,
'text-green-500': index % 6 === 4,
'text-yellow-500': index % 6 === 5,
},
'leading-[0] font-sans text-[48px]'
)}
>
</span>
</span>
</div>
);
})}
</div>
</div>
<div className="flex items-center gap-1">
{tiers.map((_tier, index) => {
return (
<div
key={index}
className="bg-white dark:bg-gray-800 shadow-card rounded-[100px] grow"
>
<div
className={classNames(
'relative w-full rounded-[100px] bg-gray-200 dark:bg-gray-800',
progressBarHeight
)}
>
<div
className={classNames(
'absolute left-0 top-0 h-full rounded-[100px] bg-gradient-to-r',
{
'from-vega-dark-400 to-vega-dark-200':
userTierIndex === 0 || streak?.isActive === false,
'from-vega-pink-600 to-vega-pink-500':
userTierIndex % 6 === 1,
'from-vega-purple-600 to-vega-purple-500':
userTierIndex % 6 === 2,
'from-vega-blue-600 to-vega-blue-500':
userTierIndex % 6 === 3,
'from-vega-orange-600 to-vega-orange-500':
userTierIndex % 6 === 4,
'from-vega-green-600 to-vega-green-500':
userTierIndex % 6 === 5,
'from-vega-yellow-600 to-vega-yellow-500':
userTierIndex % 6 === 0,
}
)}
style={{
width:
safeProgress(
index,
userTierIndex,
tiers[index].minimum_activity_streak,
streak?.activeFor
) + '%',
}}
></div>
</div>
</div>
);
})}
</div>
<div className="flex items-center gap-1">
<VegaIcon name={VegaIconNames.STREAK} />
<span className="flex flex-col">
{streak?.isActive && (
<span data-testid="epoch-streak">
{t('userActive', '{{active}} trader: {{count}} epochs so far', {
active: streak?.isActive ? 'Active' : 'Inactive',
count: streak?.activeFor || 0,
})}{' '}
{userTierIndex > 0 &&
new BigNumber(
tiers[0].minimum_activity_streak
).isLessThanOrEqualTo(streak?.activeFor || 0) &&
t('(Tier {{tier}} as of last epoch)', {
tier: userTierIndex,
})}
</span>
)}
</span>
</div>
</div>
</>
);
};

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