feat(1263): split validator page into separate tables (#2250)

* frontend-monorepo-1263 validator status of pending now named 'candidate' in the ui

* feat(1263): validator table updates, calculating penalties

* feat(1263): bit of cleanup

* feat(1263): tables built and values derived

* feat(1263): tweaks

* feat(1263): upping site max width to 1500px

* feat(1263): tweak to normalised voting power column width

* feat(1263): ensure validator rows when hovered have cursor:pointer

* feat(1263): consensus validators now have a ranking field by voting power shown in the table

* feat(1263): fixed linting errors

* feat(1263): a couple of extra tests and correct import of schema to use enums

* Update apps/token/src/routes/staking/home/validator-tables/standby-pending-validators-table.tsx

Co-authored-by: Dexter Edwards <dexter.edwards93@gmail.com>

* Update apps/token/src/routes/staking/home/validator-tables/validator-tables.tsx

Co-authored-by: Dexter Edwards <dexter.edwards93@gmail.com>

* Update apps/token/src/routes/staking/home/validator-tables/validator-tables.tsx

Co-authored-by: Dexter Edwards <dexter.edwards93@gmail.com>

* Update apps/token/src/routes/staking/home/validator-tables/validator-tables.tsx

Co-authored-by: Dexter Edwards <dexter.edwards93@gmail.com>

* feat(1263): tweaks from PR comments, initial push of util to remove 'node' from arrs

* feat(1263): updated new util to remove 'node' from 'edges'. Implemented across the monorepo

* feat(1263): cleaned up splitting validators into consensus, pending and ersatz to put into tables

* feat(1263): used decimals from app state in validator-tables.tsx

* feat(1263): removed redundant compact import

Co-authored-by: Dexter Edwards <dexter.edwards93@gmail.com>
This commit is contained in:
Sam Keen 2022-11-30 12:55:30 +00:00 committed by GitHub
parent 5bc861b5ec
commit 7c8a54b35a
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
40 changed files with 1257 additions and 693 deletions

View File

@ -1,11 +1,14 @@
import { useParams } from 'react-router-dom';
import compact from 'lodash/compact';
import {
DealTicketManager,
usePartyBalanceQuery,
} from '@vegaprotocol/deal-ticket';
import { Loader, Splash } from '@vegaprotocol/ui-toolkit';
import { t, useDataProvider } from '@vegaprotocol/react-helpers';
import {
t,
useDataProvider,
removePaginationWrapper,
} from '@vegaprotocol/react-helpers';
import { useVegaWallet } from '@vegaprotocol/wallet';
import { DealTicketSteps } from './deal-ticket-steps';
import { DealTicketBalance } from './deal-ticket-balance';
@ -46,8 +49,8 @@ export const DealTicketContainer = () => {
skip: !marketId,
});
const accounts = compact(partyData?.party?.accountsConnection?.edges).map(
(e) => e.node
const accounts = removePaginationWrapper(
partyData?.party?.accountsConnection?.edges
);
const loader = <Loader />;

View File

@ -1,7 +1,6 @@
import { useCallback, useEffect, useMemo, useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { useForm, Controller } from 'react-hook-form';
import compact from 'lodash/compact';
import { Stepper } from '../stepper';
import {
getDefaultOrder,
@ -25,6 +24,7 @@ import {
addDecimalsFormatNumber,
addDecimal,
formatNumber,
removePaginationWrapper,
} from '@vegaprotocol/react-helpers';
import {
useOrderSubmit,
@ -82,8 +82,8 @@ export const DealTicketSteps = ({ market }: DealTicketMarketProps) => {
skip: !pubKey,
});
const accounts = compact(partyBalance?.party?.accountsConnection?.edges).map(
(e) => e.node
const accounts = removePaginationWrapper(
partyBalance?.party?.accountsConnection?.edges
);
const maxTrade = useMaximumPositionSize({
partyId: pubKey || '',

View File

@ -7,14 +7,14 @@ import {
import {
addDecimalsFormatNumber,
formatNumber,
removePaginationWrapper,
suitableForSyntaxHighlighter,
t,
useNetworkParamsQuery,
} from '@vegaprotocol/react-helpers';
import { suitableForSyntaxHighlighter } from '@vegaprotocol/react-helpers';
import { RouteTitle } from '../../components/route-title';
import orderBy from 'lodash/orderBy';
import type { NetworkParamsQuery } from '@vegaprotocol/react-helpers';
import { useNetworkParamsQuery } from '@vegaprotocol/react-helpers';
import compact from 'lodash/compact';
const PERCENTAGE_PARAMS = [
'governance.proposal.asset.requiredMajority',
@ -107,9 +107,7 @@ export const NetworkParametersTable = ({
error={error}
render={(data) => {
const ascParams = orderBy(
compact(data.networkParametersConnection.edges).map(
({ node }) => node
) || [],
removePaginationWrapper(data.networkParametersConnection.edges),
(param) => param.key,
'asc'
);

View File

@ -1,7 +1,7 @@
# App configuration variables
NX_VEGA_ENV=DEVNET
NX_VEGA_CONFIG_URL=https://static.vega.xyz/assets/devnet-network.json
NX_VEGA_URL=https://api.n04.d.vega.xyz/graphql
NX_VEGA_URL=https://api.n00.devnet1.vega.xyz/graphql
NX_VEGA_NETWORKS='{"DEVNET":"https://dev.token.vega.xyz","STAGNET3":"https://stagnet3.token.vega.xyz","TESTNET":"https://token.fairground.wtf","MAINNET":"https://token.vega.xyz"}'
NX_ETHEREUM_PROVIDER_URL=https://sepolia.infura.io/v3/4f846e79e13f44d1b51bbd7ed9edefb8
NX_ETHERSCAN_URL=https://sepolia.etherscan.io

View File

@ -1,7 +1,7 @@
# App configuration variables
NX_VEGA_ENV=TESTNET
NX_VEGA_CONFIG_URL=https://static.vega.xyz/assets/testnet-network.json
NX_VEGA_URL=https://api.n11.testnet.vega.xyz/graphql
NX_VEGA_URL=https://api.n10.testnet.vega.xyz/graphql
NX_VEGA_NETWORKS='{"DEVNET":"https://dev.token.vega.xyz","STAGNET3":"https://stagnet3.token.vega.xyz","TESTNET":"https://token.fairground.wtf","MAINNET":"https://token.vega.xyz"}'
NX_ETHEREUM_PROVIDER_URL=https://sepolia.infura.io/v3/4f846e79e13f44d1b51bbd7ed9edefb8
NX_ETHERSCAN_URL=https://sepolia.etherscan.io

View File

@ -88,7 +88,7 @@ const Web3Container = ({
<AppLoader>
<BalanceManager>
<>
<div className="app w-full max-w-[1300px] mx-auto grid grid-rows-[1fr_min-content] min-h-full border-neutral-700 lg:border-l lg:border-r lg:text-body-large">
<div className="app w-full max-w-[1500px] mx-auto grid grid-rows-[1fr_min-content] min-h-full border-neutral-700 lg:border-l lg:border-r lg:text-body-large">
<TemplateSidebar sidebar={sideBar}>
<AppRouter />
</TemplateSidebar>

View File

@ -4,7 +4,6 @@ import keyBy from 'lodash/keyBy';
import uniq from 'lodash/uniq';
import React from 'react';
import { useTranslation } from 'react-i18next';
import compact from 'lodash/compact';
import noIcon from '../../images/token-no-icon.png';
import vegaBlack from '../../images/vega_black.png';
@ -15,7 +14,7 @@ import { useContracts } from '../../contexts/contracts/contracts-context';
import type { ERC20Asset } from '@vegaprotocol/assets';
import { isAssetTypeERC20 } from '@vegaprotocol/assets';
import { Schema } from '@vegaprotocol/types';
import { toBigNum } from '@vegaprotocol/react-helpers';
import { removePaginationWrapper, toBigNum } from '@vegaprotocol/react-helpers';
import { useAppState } from '../../contexts/app-state/app-state-context';
import { addDecimal } from '@vegaprotocol/react-helpers';
import type {
@ -66,9 +65,9 @@ export const usePollForDelegations = () => {
})
.then((res) => {
if (!mounted) return;
const canonisedDelegations = compact(
const canonisedDelegations = removePaginationWrapper(
res.data.party?.delegationsConnection?.edges
).map(({ node }) => node);
);
const filter =
canonisedDelegations.filter((d) => {
return d.epoch.toString() === res.data.epoch.id;
@ -85,9 +84,9 @@ export const usePollForDelegations = () => {
decimals
)
);
const accounts = compact(
const accounts = removePaginationWrapper(
res.data.party?.accountsConnection?.edges
).map((e) => e.node);
);
setAccounts(
accounts
.filter(

View File

@ -574,19 +574,25 @@
"proposalTerms": "Proposal terms",
"currentlySetTo": "Currently expected to ",
"finalOutcomeMayDiffer": "Final outcome may differ",
"rankingScore": "Ranking score",
"stakeScore": "Stake score",
"performanceScore": "Performance",
"votingPower": "Voting power",
"normalisedVotingPower": "Normalised voting power",
"noValidators": "No validators",
"validator": "Validator",
"totalStakeThisEpoch": "Total stake this epoch",
"share": "Share",
"validatorStake": "Validator stake",
"stake": "Stake",
"stakeShare": "Stake share",
"pendingStake": "Pending stake",
"totalPenalties": "Total penalties",
"noPenaltyDataFromLastEpoch": "No penalty data from last epoch",
"stakeNeededForPromotion": "Stake needed for promotion",
"Nodes": "Nodes",
"status-tendermint": "Consensus",
"status-ersatz": "Standby",
"status-pending": "Pending",
"status-pending": "Candidate",
"ersatzDescription": "To be promoted, a standby validator must have more than the lowest consensus stake, plus a bonus given to existing validators. This currently requires a minimum of {{stakeNeededForPromotion}} stake assuming no penalties. Only one validator per epoch can be promoted.",
"pendingDescription1": "Anyone can",
"pendingDescriptionLinkText": "set up and run a node on Vega",
"pendingDescription2": ". A node can move from Pending into Standby based on how much nomination it attracts, assuming it has proven reliability by sending heartbeats to the network.",
"n/a": "N/A",
"Set to": "Set to",
"pass": "pass",
"fail": "fail",

View File

@ -1,4 +1,3 @@
import compact from 'lodash/compact';
import { format } from 'date-fns';
import React from 'react';
import { useTranslation } from 'react-i18next';
@ -11,7 +10,11 @@ import type {
RewardFieldsFragment,
DelegationFieldsFragment,
} from './__generated___/Rewards';
import { formatNumber, toBigNum } from '@vegaprotocol/react-helpers';
import {
formatNumber,
removePaginationWrapper,
toBigNum,
} from '@vegaprotocol/react-helpers';
import { useAppState } from '../../../contexts/app-state/app-state-context';
interface RewardInfoProps {
@ -25,10 +28,7 @@ export const RewardInfo = ({ data, currVegaKey }: RewardInfoProps) => {
const rewards = React.useMemo(() => {
if (!data?.party || !data.party.rewardsConnection?.edges?.length) return [];
return (
compact(data.party.rewardsConnection.edges.map((edge) => edge?.node)) ||
[]
);
return removePaginationWrapper(data.party.rewardsConnection.edges);
}, [data]);
const delegations = React.useMemo(() => {
@ -36,11 +36,7 @@ export const RewardInfo = ({ data, currVegaKey }: RewardInfoProps) => {
return [];
}
return (
compact(
data.party.delegationsConnection.edges.map((edge) => edge?.node)
) || []
);
return removePaginationWrapper(data.party.delegationsConnection.edges);
}, [data]);
return (

View File

@ -9,9 +9,11 @@ import { useGetAssociationBreakdown } from '../../../hooks/use-get-association-b
import { useRefreshBalances } from '../../../hooks/use-refresh-balances';
import { useTransaction } from '../../../hooks/use-transaction';
import { useAppState } from '../../../contexts/app-state/app-state-context';
import { removeDecimal } from '@vegaprotocol/react-helpers';
import {
removeDecimal,
removePaginationWrapper,
} from '@vegaprotocol/react-helpers';
import { Schema } from '@vegaprotocol/types';
import compact from 'lodash/compact';
import type {
LinkingsFieldsFragment,
PartyStakeLinkingsQuery,
@ -92,12 +94,9 @@ export const usePollForStakeLinking = (
fetchPolicy: 'no-cache',
})
.then((res) => {
const linkings =
compact(
res.data?.party?.stakingSummary.linkings.edges?.map(
(e) => e?.node
)
) || [];
const linkings = removePaginationWrapper(
res.data?.party?.stakingSummary.linkings.edges
);
if (!linkings?.length) return;

View File

@ -0,0 +1,15 @@
query PreviousEpoch($epochId: ID) {
epoch(id: $epochId) {
id
validatorsConnection {
edges {
node {
id
rewardScore {
rawValidatorScore
}
}
}
}
}
}

View File

@ -0,0 +1,58 @@
import { Schema as Types } from '@vegaprotocol/types';
import { gql } from '@apollo/client';
import * as Apollo from '@apollo/client';
const defaultOptions = {} as const;
export type PreviousEpochQueryVariables = Types.Exact<{
epochId?: Types.InputMaybe<Types.Scalars['ID']>;
}>;
export type PreviousEpochQuery = { __typename?: 'Query', epoch: { __typename?: 'Epoch', id: string, validatorsConnection?: { __typename?: 'NodesConnection', edges?: Array<{ __typename?: 'NodeEdge', node: { __typename?: 'Node', id: string, rewardScore?: { __typename?: 'RewardScore', rawValidatorScore: string } | null } } | null> | null } | null } };
export const PreviousEpochDocument = gql`
query PreviousEpoch($epochId: ID) {
epoch(id: $epochId) {
id
validatorsConnection {
edges {
node {
id
rewardScore {
rawValidatorScore
}
}
}
}
}
}
`;
/**
* __usePreviousEpochQuery__
*
* To run a query within a React component, call `usePreviousEpochQuery` and pass it any options that fit your needs.
* When your component renders, `usePreviousEpochQuery` 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 } = usePreviousEpochQuery({
* variables: {
* epochId: // value for 'epochId'
* },
* });
*/
export function usePreviousEpochQuery(baseOptions?: Apollo.QueryHookOptions<PreviousEpochQuery, PreviousEpochQueryVariables>) {
const options = {...defaultOptions, ...baseOptions}
return Apollo.useQuery<PreviousEpochQuery, PreviousEpochQueryVariables>(PreviousEpochDocument, options);
}
export function usePreviousEpochLazyQuery(baseOptions?: Apollo.LazyQueryHookOptions<PreviousEpochQuery, PreviousEpochQueryVariables>) {
const options = {...defaultOptions, ...baseOptions}
return Apollo.useLazyQuery<PreviousEpochQuery, PreviousEpochQueryVariables>(PreviousEpochDocument, options);
}
export type PreviousEpochQueryHookResult = ReturnType<typeof usePreviousEpochQuery>;
export type PreviousEpochLazyQueryHookResult = ReturnType<typeof usePreviousEpochLazyQuery>;
export type PreviousEpochQueryResult = Apollo.QueryResult<PreviousEpochQuery, PreviousEpochQueryVariables>;

View File

@ -0,0 +1,59 @@
import { useEffect } from 'react';
import { AsyncRenderer } from '@vegaprotocol/ui-toolkit';
import { EpochCountdown } from '../../../components/epoch-countdown';
import { useNodesQuery } from './__generated___/Nodes';
import { usePreviousEpochQuery } from './__generated___/PreviousEpoch';
import { ValidatorTables } from './validator-tables';
export const EpochData = () => {
// errorPolicy due to vegaprotocol/vega issue 5898
const { data, error, loading, refetch } = useNodesQuery();
const { data: previousEpochData, refetch: previousEpochRefetch } =
usePreviousEpochQuery({
variables: {
epochId: (Number(data?.epoch.id) - 1).toString(),
},
});
useEffect(() => {
const epochInterval = setInterval(() => {
if (!data?.epoch.timestamps.expiry) return;
const now = Date.now();
const expiry = new Date(data.epoch.timestamps.expiry).getTime();
if (now > expiry) {
refetch();
previousEpochRefetch({
epochId: (Number(data?.epoch.id) - 1).toString(),
});
clearInterval(epochInterval);
}
}, 10000);
return () => {
clearInterval(epochInterval);
};
}, [
data?.epoch.id,
data?.epoch.timestamps.expiry,
previousEpochRefetch,
refetch,
]);
return (
<AsyncRenderer loading={loading} error={error} data={data}>
{data?.epoch &&
data.epoch.timestamps.start &&
data?.epoch.timestamps.expiry && (
<div className="mb-8">
<EpochCountdown
id={data.epoch.id}
startDate={new Date(data.epoch.timestamps.start)}
endDate={new Date(data.epoch.timestamps.expiry)}
/>
</div>
)}
<ValidatorTables data={data} previousEpochData={previousEpochData} />
</AsyncRenderer>
);
};

View File

@ -1,251 +0,0 @@
import { act, fireEvent, render, screen } from '@testing-library/react';
import merge from 'lodash/merge';
import { NodeList } from './node-list';
import { MockedProvider } from '@apollo/client/testing';
import { MemoryRouter } from 'react-router-dom';
import type { PartialDeep } from 'type-fest';
import type { NodesFragmentFragment } from './__generated___/Nodes';
import { NodesDocument } from './__generated___/Nodes';
import { Schema } from '@vegaprotocol/types';
import { AppStateProvider } from '../../../contexts/app-state/app-state-provider';
jest.mock('../../../components/epoch-countdown', () => ({
EpochCountdown: () => <div data-testid="epoch-info"></div>,
}));
const nodeFactory = (overrides?: PartialDeep<NodesFragmentFragment>) => {
const defaultNode = {
id: 'ccc022b7e63a4d0a6d3a193c3940c88574060e58a184964c994998d86835a1b4',
name: 'high',
avatarUrl: 'https://upload.wikimedia.org/wikipedia/en/2/25/Marvin-TV-3.jpg',
pubkey: '6abc23391a9f888ab240415bf63d6844b03fc360be822f4a1d2cd832d87b2917',
stakedTotal: '14182454495731682635157',
pendingStake: '0',
rankingScore: {
rankingScore: '0.67845061012234727427532760837568',
stakeScore: '0.3392701644525644',
performanceScore: '0.9998677767864936',
votingPower: '3500',
status: 'tendermint',
__typename: 'RankingScore',
},
__typename: 'Node',
};
return {
node: merge(defaultNode, overrides),
};
};
const MOCK_NODES = {
nodesConnection: {
edges: [
nodeFactory(),
nodeFactory({
id: '966438c6bffac737cfb08173ffcb3f393c4692b099ad80cb45a82e2dc0a8cf99',
name: 'medium',
pubkey:
'ccc3b8362c25b09d20df8ea407b0a476d6b24a0e72bc063d0033c8841652ddd4',
stakedTotal: '9618711883996159534058',
rankingScore: {
rankingScore: '0.4601942440481428',
stakeScore: '0.2300971220240714',
performanceScore: '1',
votingPower: '2408',
status: Schema.ValidatorStatus.VALIDATOR_NODE_STATUS_TENDERMINT,
__typename: 'RankingScore',
},
}),
nodeFactory({
id: '12c81b738e8051152e1afe44376ec37bca9216466e6d44cdd772194bad0ada81',
name: 'low',
pubkey:
'0931a8fd8cc935458f470e435a05414387cea6f329d648be894fcd44bd517a2b',
stakedTotal: '4041343338923442976709',
pendingStake: '0',
rankingScore: {
rankingScore: '0.1932810100133910357676209647912',
stakeScore: '0.0966762995515676',
performanceScore: '0.999629748500531',
votingPower: '1163',
status: Schema.ValidatorStatus.VALIDATOR_NODE_STATUS_TENDERMINT,
__typename: 'RankingScore',
},
}),
],
},
nodeData: {
stakedTotal: '27842509718651285145924',
totalNodes: 3,
inactiveNodes: 0,
uptime: 1560.266845703125,
__typename: 'NodeData',
},
epoch: {
__typename: 'Epoch',
id: '1',
timestamps: {
__typename: 'EpochTimestamps',
start: new Date(0).toISOString(),
end: '',
expiry: new Date(1000 * 60 * 60 * 24).toISOString(),
},
},
};
const renderNodeList = (data = MOCK_NODES) => {
return render(
<AppStateProvider initialState={{ decimals: 18 }}>
<MemoryRouter>
<MockedProvider
mocks={[
{
request: { query: NodesDocument },
result: { data },
},
]}
>
<NodeList />
</MockedProvider>
</MemoryRouter>
</AppStateProvider>
);
};
beforeAll(() => {
jest.useFakeTimers();
jest.setSystemTime(0);
});
afterAll(() => {
jest.useRealTimers();
});
describe('Nodes list', () => {
it('should render epoch info', async () => {
renderNodeList();
expect(await screen.findByTestId('epoch-info')).toBeInTheDocument();
});
it('should initially render a list of nodes with the top third hidden', async () => {
renderNodeList();
expect(await screen.findByText('medium')).toBeInTheDocument();
expect(screen.getByText('low')).toBeInTheDocument();
expect(screen.queryByText('high')).toBeNull();
});
it('should show all validators if button is pressed', async () => {
renderNodeList();
expect(await screen.findByText('medium')).toBeInTheDocument();
expect(screen.queryByText('high')).toBeNull();
act(() => {
fireEvent.click(screen.getByTestId('show-all-validators'));
});
expect(screen.getByText('high')).toBeInTheDocument();
expect(screen.getByText('medium')).toBeInTheDocument();
expect(screen.getByText('low')).toBeInTheDocument();
});
it('should display the correctly formatted fields in the correct columns', async () => {
const MOCK_NODE = {
nodesConnection: {
edges: [
nodeFactory({
id: '966438c6bffac737cfb08173ffcb3f393c4692b099ad80cb45a82e2dc0a8cf99',
name: 'T-800 Terminator',
avatarUrl:
'https://upload.wikimedia.org/wikipedia/en/9/94/T-800_%28Model_101%29.png',
pubkey:
'ccc3b8362c25b09d20df8ea407b0a476d6b24a0e72bc063d0033c8841652ddd4',
stakedTotal: '9618711883996159534058',
rankingScore: {
rankingScore: '0.4601942440481428',
stakeScore: '0.2300971220240714',
performanceScore: '1',
votingPower: '2408',
status: Schema.ValidatorStatus.VALIDATOR_NODE_STATUS_TENDERMINT,
__typename: 'RankingScore',
},
}),
],
},
nodeData: {
stakedTotal: '9618711883996159534058',
totalNodes: 1,
inactiveNodes: 0,
uptime: 1560.266845703125,
__typename: 'NodeData',
},
epoch: {
__typename: 'Epoch',
id: '1',
timestamps: {
__typename: 'EpochTimestamps',
start: new Date(0).toISOString(),
end: '',
expiry: new Date(1000 * 60 * 60 * 24).toISOString(),
},
},
};
renderNodeList(MOCK_NODE);
expect(await screen.findByTestId('validators-grid')).toBeInTheDocument();
const grid = screen.getByTestId('validators-grid');
act(() => {
fireEvent.click(screen.getByTestId('show-all-validators'));
});
expect(
grid.querySelector('[role="gridcell"][col-id="validator"]')
).toHaveTextContent('T-800 Terminator');
expect(
grid.querySelector('[role="gridcell"][col-id="validator"] img')
).toHaveAttribute(
'src',
'https://upload.wikimedia.org/wikipedia/en/9/94/T-800_%28Model_101%29.png'
);
expect(
grid.querySelector('[role="gridcell"][col-id="status"]')
).toHaveTextContent('Consensus');
expect(
grid.querySelector('[role="gridcell"][col-id="totalStakeThisEpoch"]')
).toHaveTextContent('9,618.71');
expect(
grid.querySelector('[role="gridcell"][col-id="share"]')
).toHaveTextContent('100%');
expect(
grid.querySelector('[role="gridcell"][col-id="validatorStake"]')
).toHaveTextContent('9,618.71');
expect(
grid.querySelector('[role="gridcell"][col-id="pendingStake"]')
).toHaveTextContent('0');
expect(
grid.querySelector('[role="gridcell"][col-id="rankingScore"]')
).toHaveTextContent('0.46019');
expect(
grid.querySelector('[role="gridcell"][col-id="stakeScore"]')
).toHaveTextContent('0.2301');
expect(
grid.querySelector('[role="gridcell"][col-id="performanceScore"]')
).toHaveTextContent('1.00');
expect(
grid.querySelector('[role="gridcell"][col-id="votingPower"]')
).toHaveTextContent('2408');
});
});

View File

@ -1,352 +0,0 @@
import { useTranslation } from 'react-i18next';
import compact from 'lodash/compact';
import { forwardRef, useEffect, useMemo, useRef, useState } from 'react';
import type { AgGridReact } from 'ag-grid-react';
import { useNavigate } from 'react-router-dom';
import {
AgGridDynamic as AgGrid,
AsyncRenderer,
Button,
} from '@vegaprotocol/ui-toolkit';
import { EpochCountdown } from '../../../components/epoch-countdown';
import { BigNumber } from '../../../lib/bignumber';
import { formatNumber, toBigNum } from '@vegaprotocol/react-helpers';
import { Schema } from '@vegaprotocol/types';
import type { ColDef } from 'ag-grid-community';
import { useNodesQuery } from './__generated___/Nodes';
import { useAppState } from '../../../contexts/app-state/app-state-context';
const VALIDATOR = 'validator';
const STATUS = 'status';
const TOTAL_STAKE_THIS_EPOCH = 'totalStakeThisEpoch';
const SHARE = 'share';
const VALIDATOR_STAKE = 'validatorStake';
const PENDING_STAKE = 'pendingStake';
const RANKING_SCORE = 'rankingScore';
const STAKE_SCORE = 'stakeScore';
const PERFORMANCE_SCORE = 'performanceScore';
const VOTING_POWER = 'votingPower';
interface ValidatorRendererProps {
data: { validator: { avatarUrl: string; name: string } };
}
interface CanonisedNodeProps {
id: string;
[VALIDATOR]: {
avatarUrl: string | null | undefined;
name: string;
};
[STATUS]: string;
[TOTAL_STAKE_THIS_EPOCH]: string;
[SHARE]: string;
[VALIDATOR_STAKE]: string;
[PENDING_STAKE]: string;
[RANKING_SCORE]: string;
[STAKE_SCORE]: string;
[PERFORMANCE_SCORE]: string;
[VOTING_POWER]: string;
}
export const statusTranslationKey = (status: Schema.ValidatorStatus) => {
// Returns a key for translation
const statuses = {
[Schema.ValidatorStatus.VALIDATOR_NODE_STATUS_ERSATZ]: 'status-ersatz',
[Schema.ValidatorStatus.VALIDATOR_NODE_STATUS_PENDING]: 'status-pending',
[Schema.ValidatorStatus.VALIDATOR_NODE_STATUS_TENDERMINT]:
'status-tendermint',
};
return statuses[status];
};
const ValidatorRenderer = ({ data }: ValidatorRendererProps) => {
const { avatarUrl, name } = data.validator;
return (
<div className="flex items-center">
{avatarUrl && (
<img
className="h-6 w-6 rounded-full mr-2"
src={avatarUrl}
alt={`Avatar icon for ${name}`}
onError={(e) => (e.currentTarget.style.display = 'none')}
/>
)}
{name}
</div>
);
};
// Custom styling to account for the scrollbar. This is needed because the
// AG Grid places the scrollbar over the bottom validator, which obstructs
const nodeListGridStyles = `
.ag-theme-balham-dark .ag-body-horizontal-scroll {
opacity: 0.75;
}
`;
export const NodeList = () => {
const { t } = useTranslation();
// errorPolicy due to vegaprotocol/vega issue 5898
const { data, error, loading, refetch } = useNodesQuery();
const navigate = useNavigate();
const {
appState: { decimals },
} = useAppState();
const [hideTopThird, setHideTopThird] = useState(true);
useEffect(() => {
const epochInterval = setInterval(() => {
if (!data?.epoch.timestamps.expiry) return;
const now = Date.now();
const expiry = new Date(data.epoch.timestamps.expiry).getTime();
if (now > expiry) {
refetch();
clearInterval(epochInterval);
}
}, 10000);
return () => {
clearInterval(epochInterval);
};
}, [data?.epoch.timestamps.expiry, refetch]);
const nodes = useMemo(() => {
if (!data?.nodesConnection.edges) return [];
const canonisedNodes = compact(data.nodesConnection.edges).map(
({
node: {
id,
name,
avatarUrl,
stakedTotal,
rankingScore: {
rankingScore,
stakeScore,
status,
performanceScore,
votingPower,
},
pendingStake,
},
}) => {
const stakedTotalNum = toBigNum(
data?.nodeData?.stakedTotal || 0,
decimals
);
const stakedOnNode = toBigNum(stakedTotal, decimals);
const stakedTotalPercentage =
stakedTotalNum.isEqualTo(0) || stakedOnNode.isEqualTo(0)
? '-'
: stakedOnNode
.dividedBy(stakedTotalNum)
.times(100)
.dp(2)
.toString() + '%';
const translatedStatus = t(statusTranslationKey(status));
return {
id,
[VALIDATOR]: {
avatarUrl,
name,
},
[STATUS]: translatedStatus,
[TOTAL_STAKE_THIS_EPOCH]: formatNumber(
toBigNum(stakedTotal, decimals),
2
),
[SHARE]: stakedTotalPercentage,
[VALIDATOR_STAKE]: formatNumber(stakedOnNode, 2),
[PENDING_STAKE]: formatNumber(toBigNum(pendingStake, decimals), 2),
[RANKING_SCORE]: formatNumber(new BigNumber(rankingScore), 5),
[STAKE_SCORE]: formatNumber(new BigNumber(stakeScore), 5),
[PERFORMANCE_SCORE]: formatNumber(new BigNumber(performanceScore), 5),
[VOTING_POWER]: votingPower,
};
}
);
if (canonisedNodes.length < 3 || !hideTopThird) {
return canonisedNodes;
}
const sortedByVotingPower = canonisedNodes.sort(
(a, b) =>
new BigNumber(b[VOTING_POWER]).toNumber() -
new BigNumber(a[VOTING_POWER]).toNumber()
);
// The point of identifying and hiding the group that could halt the network
// is that we assume the top 1/3 of stake is held by considerably less than
// 1/3 of the validators and we really want people not to stake any more to
// that group, because we want to make it require as many difference
// validators to collude as possible to halt the network, so we hide them.
const removeTopThirdOfStakeScores = sortedByVotingPower.reduce(
(acc, node) => {
if (acc.cumulativeScore < 3333) {
acc.cumulativeScore += Number(node[VOTING_POWER]);
return acc;
}
acc.remaining.push(node);
return acc;
},
{ remaining: [], cumulativeScore: 0 } as {
remaining: CanonisedNodeProps[];
cumulativeScore: number;
}
);
return removeTopThirdOfStakeScores.remaining;
}, [
data?.nodeData?.stakedTotal,
data?.nodesConnection.edges,
decimals,
hideTopThird,
t,
]);
const gridRef = useRef<AgGridReact | null>(null);
const NodeListTable = forwardRef<AgGridReact>((_, ref) => {
const colDefs = useMemo<ColDef[]>(
() => [
{
field: VALIDATOR,
headerName: t('validator').toString(),
cellRenderer: ValidatorRenderer,
comparator: ({ name: a }, { name: b }) => {
if (a === b) return 0;
return a > b ? 1 : -1;
},
pinned: 'left',
},
{
field: STATUS,
headerName: t('status').toString(),
comparator: (a, b) => {
if (a === b) return 0;
return a > b ? 1 : -1;
},
width: 100,
},
{
field: TOTAL_STAKE_THIS_EPOCH,
headerName: t('totalStakeThisEpoch').toString(),
width: 160,
},
{
field: SHARE,
headerName: t('share').toString(),
width: 80,
},
{
field: VALIDATOR_STAKE,
headerName: t('validatorStake').toString(),
width: 120,
},
{
field: PENDING_STAKE,
headerName: t('nextEpoch').toString(),
width: 100,
},
{
field: RANKING_SCORE,
headerName: t('rankingScore').toString(),
width: 120,
sort: 'desc',
},
{
field: STAKE_SCORE,
headerName: t('stakeScore').toString(),
width: 100,
},
{
field: PERFORMANCE_SCORE,
headerName: t('performanceScore').toString(),
width: 100,
},
{
field: VOTING_POWER,
headerName: t('votingPower').toString(),
width: 100,
},
],
[]
);
const defaultColDef = useMemo(
() => ({
sortable: true,
resizable: true,
comparator: (a: string, b: string) => parseFloat(a) - parseFloat(b),
cellStyle: { margin: '10px 0' },
}),
[]
);
return (
<div data-testid="validators-grid">
{hideTopThird && (
<div className="mb-6 py-4 px-4 md:px-12 bg-neutral-900 text-sm text-center">
<div className="mb-4">
<Button
data-testid="show-all-validators"
icon="list"
className="inline-flex items-center"
onClick={() => setHideTopThird(false)}
>
{t('Reveal top validators')}
</Button>
</div>
<p className="font-semibold">
{t(
'Validators with too great a stake share will have the staking rewards for their delegators penalised.'
)}
</p>
<p className="mb-0">
{t(
'To avoid penalties and increase decentralisation of the network, delegate to validators below.'
)}
</p>
</div>
)}
<AgGrid
domLayout="autoHeight"
style={{ width: '100%' }}
customThemeParams={nodeListGridStyles}
overlayNoRowsTemplate={t('noValidators')}
ref={ref}
rowData={nodes}
rowHeight={52}
columnDefs={colDefs}
defaultColDef={defaultColDef}
animateRows={true}
suppressCellFocus={true}
onCellClicked={(event) => {
navigate(event.data.id);
}}
/>
</div>
);
});
return (
<AsyncRenderer loading={loading} error={error} data={nodes}>
{data?.epoch &&
data.epoch.timestamps.start &&
data?.epoch.timestamps.expiry && (
<div className="mb-8">
<EpochCountdown
id={data.epoch.id}
startDate={new Date(data.epoch.timestamps.start)}
endDate={new Date(data.epoch.timestamps.expiry)}
/>
</div>
)}
<NodeListTable ref={gridRef} />
</AsyncRenderer>
);
};

View File

@ -1,6 +1,6 @@
import { useTranslation } from 'react-i18next';
import { StakingIntro } from './staking-intro';
import { NodeList } from './node-list';
import { EpochData } from './epoch-data';
export const Staking = () => {
const { t } = useTranslation();
@ -10,7 +10,7 @@ export const Staking = () => {
<StakingIntro />
<section>
<h2 className="text-2xl uppercase">{t('Validator nodes')}</h2>
<NodeList data-testid="node-list" />
<EpochData data-testid="epoch-data" />
</section>
</>
);

View File

@ -0,0 +1,248 @@
import merge from 'lodash/merge';
import { act, fireEvent, render, screen } from '@testing-library/react';
import { ConsensusValidatorsTable } from './consensus-validators-table';
import { MockedProvider } from '@apollo/client/testing';
import { MemoryRouter } from 'react-router-dom';
import { NodesDocument } from '../__generated___/Nodes';
import { PreviousEpochDocument } from '../__generated___/PreviousEpoch';
import { Schema } from '@vegaprotocol/types';
import { AppStateProvider } from '../../../../contexts/app-state/app-state-provider';
import type { MockedResponse } from '@apollo/client/testing';
import type { PartialDeep } from 'type-fest';
import type { NodesFragmentFragment } from '../__generated___/Nodes';
import type { PreviousEpochQuery } from '../__generated___/PreviousEpoch';
const nodeFactory = (
overrides?: PartialDeep<NodesFragmentFragment>
): NodesFragmentFragment => {
const defaultNode = {
id: 'ccc022b7e63a4d0a6d3a193c3940c88574060e58a184964c994998d86835a1b4',
name: 'high',
avatarUrl: 'https://upload.wikimedia.org/wikipedia/en/2/25/Marvin-TV-3.jpg',
pubkey: '6abc23391a9f888ab240415bf63d6844b03fc360be822f4a1d2cd832d87b2917',
stakedTotal: '14182454495731682635157',
pendingStake: '0',
rankingScore: {
rankingScore: '0.67845061012234727427532760837568',
stakeScore: '0.3392701644525644',
performanceScore: '0.9998677767864936',
votingPower: '3500',
status: Schema.ValidatorStatus.VALIDATOR_NODE_STATUS_TENDERMINT,
__typename: 'RankingScore',
},
__typename: 'Node',
};
return merge(defaultNode, overrides);
};
const MOCK_NODES = [
nodeFactory(),
nodeFactory({
id: '966438c6bffac737cfb08173ffcb3f393c4692b099ad80cb45a82e2dc0a8cf99',
name: 'medium',
pubkey: 'ccc3b8362c25b09d20df8ea407b0a476d6b24a0e72bc063d0033c8841652ddd4',
stakedTotal: '9618711883996159534058',
rankingScore: {
rankingScore: '0.4601942440481428',
stakeScore: '0.2300971220240714',
performanceScore: '1',
votingPower: '2408',
status: Schema.ValidatorStatus.VALIDATOR_NODE_STATUS_TENDERMINT,
__typename: 'RankingScore',
},
}),
nodeFactory({
id: '12c81b738e8051152e1afe44376ec37bca9216466e6d44cdd772194bad0ada81',
name: 'low',
pubkey: '0931a8fd8cc935458f470e435a05414387cea6f329d648be894fcd44bd517a2b',
stakedTotal: '4041343338923442976709',
pendingStake: '0',
rankingScore: {
rankingScore: '0.1932810100133910357676209647912',
stakeScore: '0.0966762995515676',
performanceScore: '0.999629748500531',
votingPower: '1163',
status: Schema.ValidatorStatus.VALIDATOR_NODE_STATUS_TENDERMINT,
__typename: 'RankingScore',
},
}),
];
const MOCK_PREVIOUS_EPOCH: PreviousEpochQuery = {
epoch: {
id: '1',
validatorsConnection: {
edges: [
{
node: {
id: 'ccc022b7e63a4d0a6d3a193c3940c88574060e58a184964c994998d86835a1b4',
rewardScore: {
rawValidatorScore: '0.25',
},
},
},
{
node: {
id: '966438c6bffac737cfb08173ffcb3f393c4692b099ad80cb45a82e2dc0a8cf99',
rewardScore: {
rawValidatorScore: '0.3',
},
},
},
{
node: {
id: '12c81b738e8051152e1afe44376ec37bca9216466e6d44cdd772194bad0ada81',
rewardScore: {
rawValidatorScore: '0.35',
},
},
},
],
},
},
};
const nodesDataMock: MockedResponse<NodesFragmentFragment[]> = {
request: {
query: NodesDocument,
},
result: {
data: {
...MOCK_NODES,
},
},
};
const previousEpochMock: MockedResponse<PreviousEpochQuery> = {
request: {
query: PreviousEpochDocument,
variables: {
id: '1',
},
},
result: {
data: {
...MOCK_PREVIOUS_EPOCH,
},
},
};
const MOCK_TOTAL_STAKE = '28832590188747439203824';
const renderValidatorsTable = (
data = MOCK_NODES,
previousEpochData = MOCK_PREVIOUS_EPOCH
) => {
return render(
<AppStateProvider initialState={{ decimals: 18 }}>
<MemoryRouter>
<MockedProvider mocks={[nodesDataMock, previousEpochMock]}>
<ConsensusValidatorsTable
data={data}
previousEpochData={previousEpochData}
totalStake={MOCK_TOTAL_STAKE}
/>
</MockedProvider>
</MemoryRouter>
</AppStateProvider>
);
};
beforeAll(() => {
jest.useFakeTimers();
jest.setSystemTime(0);
});
afterAll(() => {
jest.useRealTimers();
});
describe('Consensus validators table', () => {
it('should initially render a list of nodes with the top third hidden', async () => {
renderValidatorsTable();
expect(await screen.findByText('medium')).toBeInTheDocument();
expect(screen.getByText('low')).toBeInTheDocument();
expect(screen.queryByText('high')).toBeNull();
});
it('should show all validators if button is pressed', async () => {
renderValidatorsTable();
expect(await screen.findByText('medium')).toBeInTheDocument();
expect(screen.queryByText('high')).toBeNull();
act(() => {
fireEvent.click(screen.getByTestId('show-all-validators'));
});
expect(screen.getByText('high')).toBeInTheDocument();
expect(screen.getByText('medium')).toBeInTheDocument();
expect(screen.getByText('low')).toBeInTheDocument();
});
it('should display the correctly formatted fields in the correct columns', async () => {
const mockNode = [
nodeFactory({
id: '966438c6bffac737cfb08173ffcb3f393c4692b099ad80cb45a82e2dc0a8cf99',
name: 'T-800 Terminator',
avatarUrl:
'https://upload.wikimedia.org/wikipedia/en/9/94/T-800_%28Model_101%29.png',
pubkey:
'ccc3b8362c25b09d20df8ea407b0a476d6b24a0e72bc063d0033c8841652ddd4',
stakedTotal: '9618711883996159534058',
rankingScore: {
rankingScore: '0.4601942440481428',
stakeScore: '0.2300971220240714',
performanceScore: '1',
votingPower: '2408',
status: Schema.ValidatorStatus.VALIDATOR_NODE_STATUS_TENDERMINT,
__typename: 'RankingScore',
},
}),
];
renderValidatorsTable(mockNode, MOCK_PREVIOUS_EPOCH);
expect(
await screen.findByTestId('consensus-validators-table')
).toBeInTheDocument();
const grid = screen.getByTestId('consensus-validators-table');
act(() => {
fireEvent.click(screen.getByTestId('show-all-validators'));
});
expect(
grid.querySelector('[role="gridcell"][col-id="validator"]')
).toHaveTextContent('T-800 Terminator');
expect(
grid.querySelector('[role="gridcell"][col-id="validator"] img')
).toHaveAttribute(
'src',
'https://upload.wikimedia.org/wikipedia/en/9/94/T-800_%28Model_101%29.png'
);
expect(
grid.querySelector('[role="gridcell"][col-id="stake"]')
).toHaveTextContent('9,618.71');
expect(
grid.querySelector('[role="gridcell"][col-id="stakeShare"]')
).toHaveTextContent('23.01%');
expect(
grid.querySelector('[role="gridcell"][col-id="pendingStake"]')
).toHaveTextContent('0');
expect(
grid.querySelector('[role="gridcell"][col-id="totalPenalties"]')
).toHaveTextContent('10.07%');
expect(
grid.querySelector('[role="gridcell"][col-id="normalisedVotingPower"]')
).toHaveTextContent('24.08%');
});
});

View File

@ -0,0 +1,231 @@
import { forwardRef, useMemo, useRef, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { useNavigate } from 'react-router-dom';
import { AgGridDynamic as AgGrid, Button } from '@vegaprotocol/ui-toolkit';
import { useAppState } from '../../../../contexts/app-state/app-state-context';
import {
defaultColDef,
NODE_LIST_GRID_STYLES,
stakedTotalPercentage,
totalPenalties,
ValidatorFields,
ValidatorRenderer,
} from './shared';
import type { AgGridReact } from 'ag-grid-react';
import type { ColDef } from 'ag-grid-community';
import type { ValidatorsTableProps } from './shared';
import { formatNumber, toBigNum } from '@vegaprotocol/react-helpers';
interface CanonisedConsensusNodeProps {
id: string;
[ValidatorFields.VALIDATOR]: {
avatarUrl: string | null | undefined;
name: string;
};
[ValidatorFields.STAKE]: string;
[ValidatorFields.STAKE_SHARE]: string;
[ValidatorFields.PENDING_STAKE]: string;
[ValidatorFields.NORMALISED_VOTING_POWER]: string;
}
export const ConsensusValidatorsTable = ({
data,
previousEpochData,
totalStake,
}: ValidatorsTableProps) => {
const { t } = useTranslation();
const {
appState: { decimals },
} = useAppState();
const navigate = useNavigate();
const [hideTopThird, setHideTopThird] = useState(true);
const gridRef = useRef<AgGridReact | null>(null);
const nodes = useMemo(() => {
if (!data) return [];
const canonisedNodes = data
.sort((a, b) => {
const aVotingPower = toBigNum(a.rankingScore.votingPower, 0);
const bVotingPower = toBigNum(b.rankingScore.votingPower, 0);
return bVotingPower.minus(aVotingPower).toNumber();
})
.map((node, index) => {
const votingPowerRanking = index + 1;
return {
...node,
votingPowerRanking,
};
})
.map(
({
id,
name,
avatarUrl,
stakedTotal,
rankingScore: { stakeScore, votingPower, performanceScore },
pendingStake,
votingPowerRanking,
}) => {
const normalisedVotingPower =
toBigNum(votingPower, 0).dividedBy(100).dp(2).toString() + '%';
return {
id,
[ValidatorFields.RANKING_INDEX]: votingPowerRanking,
[ValidatorFields.VALIDATOR]: {
avatarUrl,
name,
},
[ValidatorFields.STAKE]: formatNumber(
toBigNum(stakedTotal, decimals),
2
),
[ValidatorFields.NORMALISED_VOTING_POWER]: normalisedVotingPower,
[ValidatorFields.STAKE_SHARE]: stakedTotalPercentage(stakeScore),
[ValidatorFields.TOTAL_PENALTIES]: totalPenalties(
previousEpochData,
id,
performanceScore,
stakedTotal,
totalStake
),
[ValidatorFields.PENDING_STAKE]: formatNumber(
toBigNum(pendingStake, decimals),
2
),
};
}
);
if (canonisedNodes.length < 3 || !hideTopThird) {
return canonisedNodes;
}
// The point of identifying and hiding the group that could halt the network
// is that we assume the top 1/3 of stake is held by considerably less than
// 1/3 of the validators and we really want people not to stake any more to
// that group, because we want to make it require as many difference
// validators to collude as possible to halt the network, so we hide them.
const removeTopThirdOfStakeScores = canonisedNodes.reduce(
(acc, node) => {
if (acc.cumulativeScore < 3333) {
acc.cumulativeScore += Number(
node[ValidatorFields.NORMALISED_VOTING_POWER]
);
return acc;
}
acc.remaining.push(node);
return acc;
},
{ remaining: [], cumulativeScore: 0 } as {
remaining: CanonisedConsensusNodeProps[];
cumulativeScore: number;
}
);
return removeTopThirdOfStakeScores.remaining;
}, [data, decimals, hideTopThird, previousEpochData, totalStake]);
const ConsensusTable = forwardRef<AgGridReact>((_, gridRef) => {
const colDefs = useMemo<ColDef[]>(
() => [
{
field: ValidatorFields.RANKING_INDEX,
headerName: '#',
width: 40,
pinned: 'left',
},
{
field: ValidatorFields.VALIDATOR,
headerName: t(ValidatorFields.VALIDATOR).toString(),
cellRenderer: ValidatorRenderer,
comparator: ({ name: a }, { name: b }) => {
if (a === b) return 0;
return a > b ? 1 : -1;
},
pinned: 'left',
width: 240,
},
{
field: ValidatorFields.STAKE,
headerName: t(ValidatorFields.STAKE).toString(),
width: 120,
},
{
field: ValidatorFields.NORMALISED_VOTING_POWER,
headerName: t(ValidatorFields.NORMALISED_VOTING_POWER).toString(),
width: 200,
sort: 'desc',
},
{
field: ValidatorFields.STAKE_SHARE,
headerName: t(ValidatorFields.STAKE_SHARE).toString(),
width: 100,
},
{
field: ValidatorFields.TOTAL_PENALTIES,
headerName: t(ValidatorFields.TOTAL_PENALTIES).toString(),
width: 120,
},
{
field: ValidatorFields.PENDING_STAKE,
headerName: t(ValidatorFields.PENDING_STAKE).toString(),
width: 110,
},
],
[]
);
return (
<div data-testid="consensus-validators-table">
{hideTopThird && (
<div className="mb-6 py-4 px-4 md:px-12 bg-neutral-900 text-sm text-center">
<div className="mb-4">
<Button
data-testid="show-all-validators"
icon="list"
className="inline-flex items-center"
onClick={() => setHideTopThird(false)}
>
{t('Reveal top validators')}
</Button>
</div>
<p className="font-semibold">
{t(
'Validators with too great a stake share will have the staking rewards for their delegators penalised.'
)}
</p>
<p className="mb-0">
{t(
'To avoid penalties and increase decentralisation of the network, delegate to validators below.'
)}
</p>
</div>
)}
{nodes.length > 0 && (
<AgGrid
domLayout="autoHeight"
style={{ width: '100%' }}
customThemeParams={NODE_LIST_GRID_STYLES}
rowHeight={52}
defaultColDef={defaultColDef}
animateRows={true}
suppressCellFocus={true}
overlayNoRowsTemplate={t('noValidators')}
ref={gridRef}
rowData={nodes}
columnDefs={colDefs}
onCellClicked={(event) => {
navigate(event.data.id);
}}
/>
)}
</div>
);
});
return <ConsensusTable ref={gridRef} />;
};

View File

@ -0,0 +1 @@
export * from './validator-tables';

View File

@ -0,0 +1,45 @@
import { stakedTotalPercentage, totalPenalties } from './shared';
const mockPreviousEpochData = {
epoch: {
id: '1',
validatorsConnection: {
edges: [
{
node: {
id: '0x123',
rewardScore: {
rawValidatorScore: '0.25',
},
},
},
],
},
},
};
describe('stakedTotalPercentage', () => {
it('should return the correct percentage as a string, 2dp', () => {
expect(stakedTotalPercentage('1.2345')).toBe('123.45%');
});
});
describe('totalPenalties', () => {
it('should return the correct penalty based on arbitrary values, test 1', () => {
expect(
totalPenalties(mockPreviousEpochData, '0x123', '0.1', '5000', '100000')
).toBe('50%');
});
it('should return the correct penalty based on lower performance score than first test', () => {
expect(
totalPenalties(mockPreviousEpochData, '0x123', '0.05', '5000', '100000')
).toBe('75%');
});
it('should return the correct penalty based on higher amount of stake than other tests (great penalty due to anti-whaling)', () => {
expect(
totalPenalties(mockPreviousEpochData, '0x123', '0.1', '5000', '5500')
).toBe('97.25%');
});
});

View File

@ -0,0 +1,102 @@
import { Link } from 'react-router-dom';
import { removePaginationWrapper, toBigNum } from '@vegaprotocol/react-helpers';
import { Button } from '@vegaprotocol/ui-toolkit';
import type { NodesFragmentFragment } from '../__generated___/Nodes';
import type { PreviousEpochQuery } from '../__generated___/PreviousEpoch';
import { useTranslation } from 'react-i18next';
export enum ValidatorFields {
RANKING_INDEX = 'rankingIndex',
VALIDATOR = 'validator',
STAKE = 'stake',
PENDING_STAKE = 'pendingStake',
STAKE_SHARE = 'stakeShare',
TOTAL_PENALTIES = 'totalPenalties',
NORMALISED_VOTING_POWER = 'normalisedVotingPower',
STAKE_NEEDED_FOR_PROMOTION = 'stakeNeededForPromotion',
}
export interface ValidatorsTableProps {
data: NodesFragmentFragment[] | undefined;
previousEpochData: PreviousEpochQuery | undefined;
totalStake: string;
}
// Custom styling to account for the scrollbar. This is needed because the
// AG Grid places the scrollbar over the bottom validator, which obstructs
export const NODE_LIST_GRID_STYLES = `
.ag-theme-balham-dark .ag-body-horizontal-scroll {
opacity: 0.75;
}
.ag-theme-balham-dark *:hover {
cursor: pointer;
}
`;
export const stakedTotalPercentage = (stakeScore: string) =>
toBigNum(stakeScore, 0).times(100).dp(2).toString() + '%';
export const totalPenalties = (
previousEpochData: PreviousEpochQuery | undefined,
id: string,
performanceScore: string,
stakedTotal: string,
totalStake: string
) => {
const rawValidatorScore = previousEpochData
? removePaginationWrapper(
previousEpochData.epoch?.validatorsConnection?.edges
).find((validator) => validator?.id === id)?.rewardScore
?.rawValidatorScore
: null;
const totalPenaltiesCalc =
rawValidatorScore !== null
? 100 *
Math.max(
0,
1 -
(Number(performanceScore) * Number(rawValidatorScore)) /
(Number(stakedTotal) / Number(totalStake || 0))
)
: 0;
return toBigNum(totalPenaltiesCalc, 0).dp(2).toString() + '%';
};
export const defaultColDef = {
sortable: true,
resizable: true,
autoHeight: true,
comparator: (a: string, b: string) => parseFloat(a) - parseFloat(b),
cellStyle: { margin: '10px 0' },
};
interface ValidatorRendererProps {
data: { id: string; validator: { avatarUrl: string; name: string } };
}
export const ValidatorRenderer = ({ data }: ValidatorRendererProps) => {
const { t } = useTranslation();
const { avatarUrl, name } = data.validator;
return (
<div className="grid grid-cols-[1fr_auto] gap-2 items-center">
<span className="flex overflow-hidden">
{avatarUrl && (
<img
className="h-6 w-6 rounded-full mr-2"
src={avatarUrl}
alt={`Avatar icon for ${name}`}
onError={(e) => (e.currentTarget.style.display = 'none')}
/>
)}
<span>{name}</span>
</span>
<Link to={data.id}>
<Button size="sm" fill={true}>
{t('Stake')}
</Button>
</Link>
</div>
);
};

View File

@ -0,0 +1,165 @@
import { forwardRef, useMemo, useRef } from 'react';
import { useTranslation } from 'react-i18next';
import { useNavigate } from 'react-router-dom';
import { AgGridDynamic as AgGrid } from '@vegaprotocol/ui-toolkit';
import { useAppState } from '../../../../contexts/app-state/app-state-context';
import {
defaultColDef,
NODE_LIST_GRID_STYLES,
stakedTotalPercentage,
totalPenalties,
ValidatorFields,
ValidatorRenderer,
} from './shared';
import type { AgGridReact } from 'ag-grid-react';
import type { ColDef } from 'ag-grid-community';
import type { ValidatorsTableProps } from './shared';
import { formatNumber, toBigNum } from '@vegaprotocol/react-helpers';
interface StandbyPendingValidatorsTableProps extends ValidatorsTableProps {
stakeNeededForPromotion: string | undefined;
}
export const StandbyPendingValidatorsTable = ({
data,
previousEpochData,
totalStake,
stakeNeededForPromotion,
}: StandbyPendingValidatorsTableProps) => {
const { t } = useTranslation();
const {
appState: { decimals },
} = useAppState();
const navigate = useNavigate();
const gridRef = useRef<AgGridReact | null>(null);
const nodes = useMemo(() => {
if (!data) return [];
return data.map(
({
id,
name,
avatarUrl,
stakedTotal,
rankingScore: { stakeScore, performanceScore },
pendingStake,
}) => {
let individualStakeNeededForPromotion = undefined;
if (stakeNeededForPromotion) {
const stakedTotalBigNum = toBigNum(stakedTotal, 0);
const stakeNeededBigNum = toBigNum(stakeNeededForPromotion, 0);
const performanceScoreBigNum = toBigNum(performanceScore, 0);
const calc = stakeNeededBigNum
.dividedBy(performanceScoreBigNum)
.minus(stakedTotalBigNum);
individualStakeNeededForPromotion = calc.isGreaterThan(0)
? calc.toString()
: '0';
}
return {
id,
[ValidatorFields.VALIDATOR]: {
avatarUrl,
name,
},
[ValidatorFields.STAKE]: formatNumber(
toBigNum(stakedTotal, decimals),
2
),
[ValidatorFields.STAKE_NEEDED_FOR_PROMOTION]:
individualStakeNeededForPromotion || t('n/a'),
[ValidatorFields.STAKE_SHARE]: stakedTotalPercentage(stakeScore),
[ValidatorFields.TOTAL_PENALTIES]: totalPenalties(
previousEpochData,
id,
performanceScore,
stakedTotal,
totalStake
),
[ValidatorFields.PENDING_STAKE]: formatNumber(
toBigNum(pendingStake, decimals),
2
),
};
}
);
}, [
data,
decimals,
previousEpochData,
stakeNeededForPromotion,
t,
totalStake,
]);
const StandbyPendingTable = forwardRef<AgGridReact>((_, gridRef) => {
const colDefs = useMemo<ColDef[]>(
() => [
{
field: ValidatorFields.VALIDATOR,
headerName: t(ValidatorFields.VALIDATOR).toString(),
cellRenderer: ValidatorRenderer,
comparator: ({ name: a }, { name: b }) => Math.sign(a - b),
pinned: 'left',
width: 240,
},
{
field: ValidatorFields.STAKE,
headerName: t(ValidatorFields.STAKE).toString(),
width: 120,
},
{
field: ValidatorFields.STAKE_NEEDED_FOR_PROMOTION,
headerName: t(ValidatorFields.STAKE_NEEDED_FOR_PROMOTION).toString(),
width: 210,
sort: 'asc',
},
{
field: ValidatorFields.STAKE_SHARE,
headerName: t(ValidatorFields.STAKE_SHARE).toString(),
width: 100,
},
{
field: ValidatorFields.TOTAL_PENALTIES,
headerName: t(ValidatorFields.TOTAL_PENALTIES).toString(),
width: 120,
},
{
field: ValidatorFields.PENDING_STAKE,
headerName: t(ValidatorFields.PENDING_STAKE).toString(),
width: 110,
},
],
[]
);
return (
<div data-testid="standby-pending-validators-table">
<AgGrid
domLayout="autoHeight"
style={{ width: '100%' }}
customThemeParams={NODE_LIST_GRID_STYLES}
rowHeight={52}
defaultColDef={defaultColDef}
animateRows={true}
suppressCellFocus={true}
overlayNoRowsTemplate={t('noValidators')}
ref={gridRef}
rowData={nodes}
columnDefs={colDefs}
onCellClicked={(event) => {
navigate(event.data.id);
}}
/>
</div>
);
});
return <StandbyPendingTable ref={gridRef} />;
};

View File

@ -0,0 +1,159 @@
import { useMemo } from 'react';
import { Trans, useTranslation } from 'react-i18next';
import { ConsensusValidatorsTable } from './consensus-validators-table';
import { StandbyPendingValidatorsTable } from './standby-pending-validators-table';
import { Schema } from '@vegaprotocol/types';
import type {
NodesQuery,
NodesFragmentFragment,
} from '../__generated___/Nodes';
import type { PreviousEpochQuery } from '../__generated___/PreviousEpoch';
import { formatNumber } from '../../../../lib/format-number';
import {
createDocsLinks,
removePaginationWrapper,
toBigNum,
} from '@vegaprotocol/react-helpers';
import { Link as UTLink } from '@vegaprotocol/ui-toolkit';
import { useEnvironment } from '@vegaprotocol/environment';
import { useAppState } from '../../../../contexts/app-state/app-state-context';
export interface ValidatorsTableProps {
data: NodesQuery | undefined;
previousEpochData: PreviousEpochQuery | undefined;
}
interface SortedValidatorsProps {
consensusValidators: NodesFragmentFragment[];
standbyValidators: NodesFragmentFragment[];
pendingValidators: NodesFragmentFragment[];
}
export const ValidatorTables = ({
data,
previousEpochData,
}: ValidatorsTableProps) => {
const { t } = useTranslation();
const { VEGA_DOCS_URL } = useEnvironment();
const {
appState: { decimals },
} = useAppState();
const totalStake = useMemo(
() => data?.nodeData?.stakedTotal || '0',
[data?.nodeData?.stakedTotal]
);
let stakeNeededForPromotion = undefined;
const { consensusValidators, standbyValidators, pendingValidators } = useMemo(
() =>
removePaginationWrapper(data?.nodesConnection.edges).reduce(
(acc: SortedValidatorsProps, validator) => {
switch (validator.rankingScore?.status) {
case Schema.ValidatorStatus.VALIDATOR_NODE_STATUS_TENDERMINT:
acc.consensusValidators.push(validator);
break;
case Schema.ValidatorStatus.VALIDATOR_NODE_STATUS_ERSATZ:
acc.standbyValidators.push(validator);
break;
case Schema.ValidatorStatus.VALIDATOR_NODE_STATUS_PENDING:
acc.pendingValidators.push(validator);
}
return acc;
},
{
consensusValidators: [],
standbyValidators: [],
pendingValidators: [],
}
),
[data?.nodesConnection.edges]
);
if (
consensusValidators.length &&
(standbyValidators.length || pendingValidators.length)
) {
const lowestRankingConsensusScore = consensusValidators.reduce(
(lowest: NodesFragmentFragment, validator: NodesFragmentFragment) => {
if (
validator?.rankingScore &&
Number(validator.rankingScore) < Number(lowest.rankingScore)
) {
lowest = validator;
}
return lowest;
}
).rankingScore.rankingScore;
const lowestRankingBigNum = toBigNum(lowestRankingConsensusScore, 0);
const totalStakeBigNum = toBigNum(totalStake, decimals);
stakeNeededForPromotion = formatNumber(
lowestRankingBigNum.times(totalStakeBigNum),
2
).toString();
}
return (
<div data-testid="validator-tables">
{consensusValidators.length > 0 && (
<>
<h2>{t('status-tendermint')}</h2>
<ConsensusValidatorsTable
data={consensusValidators}
previousEpochData={previousEpochData}
totalStake={totalStake}
/>
</>
)}
{standbyValidators.length > 0 && (
<>
<h2>{t('status-ersatz')}</h2>
<p>
<Trans
i18nKey="ersatzDescription"
values={{
stakeNeededForPromotion,
}}
/>
</p>
<StandbyPendingValidatorsTable
data={standbyValidators}
previousEpochData={previousEpochData}
totalStake={totalStake}
stakeNeededForPromotion={stakeNeededForPromotion}
/>
</>
)}
{pendingValidators.length > 0 && (
<>
<h2>{t('status-pending')}</h2>
<p>
{VEGA_DOCS_URL && (
<>
<span>{t('pendingDescription1')} </span>
<span>
<UTLink
href={createDocsLinks(VEGA_DOCS_URL).STAKING_GUIDE}
target="_blank"
data-testid="validator-forum-link"
>
{t('pendingDescriptionLinkText')}
</UTLink>
</span>
</>
)}
<span>{t('pendingDescription2')}</span>
</p>
<StandbyPendingValidatorsTable
data={pendingValidators}
previousEpochData={previousEpochData}
totalStake={totalStake}
stakeNeededForPromotion={stakeNeededForPromotion}
/>
</>
)}
</div>
);
};

View File

@ -3,14 +3,14 @@ import { Schema as Types } from '@vegaprotocol/types';
import { gql } from '@apollo/client';
import * as Apollo from '@apollo/client';
const defaultOptions = {} as const;
export type StakingNodeFieldsFragment = { __typename?: 'Node', id: string, name: string, pubkey: string, infoUrl: string, location: string, ethereumAddress: string, stakedByOperator: string, stakedByDelegates: string, stakedTotal: string, pendingStake: string, stakedByOperatorFormatted: string, stakedByDelegatesFormatted: string, stakedTotalFormatted: string, pendingStakeFormatted: string, epochData?: { __typename?: 'EpochData', total: number, offline: number, online: number } | null, rankingScore: { __typename?: 'RankingScore', rankingScore: string, stakeScore: string, performanceScore: string, votingPower: string, status: Types.ValidatorStatus } };
export type StakingNodeFieldsFragment = { __typename?: 'Node', id: string, name: string, pubkey: string, infoUrl: string, location: string, ethereumAddress: string, stakedByOperator: string, stakedByDelegates: string, stakedTotal: string, pendingStake: string, epochData?: { __typename?: 'EpochData', total: number, offline: number, online: number } | null, rankingScore: { __typename?: 'RankingScore', rankingScore: string, stakeScore: string, performanceScore: string, votingPower: string, status: Types.ValidatorStatus } };
export type StakingQueryVariables = Types.Exact<{
partyId: Types.Scalars['ID'];
}>;
export type StakingQuery = { __typename?: 'Query', party?: { __typename?: 'Party', id: string, stakingSummary: { __typename?: 'StakingSummary', currentStakeAvailable: string }, delegationsConnection?: { __typename?: 'DelegationsConnection', edges?: Array<{ __typename?: 'DelegationEdge', node: { __typename?: 'Delegation', amount: string, amountFormatted: string, epoch: number, node: { __typename?: 'Node', id: string } } } | null> | null } | null } | null, epoch: { __typename?: 'Epoch', id: string, timestamps: { __typename?: 'EpochTimestamps', start?: string | null, end?: string | null, expiry?: string | null } }, nodesConnection: { __typename?: 'NodesConnection', edges?: Array<{ __typename?: 'NodeEdge', node: { __typename?: 'Node', id: string, name: string, pubkey: string, infoUrl: string, location: string, ethereumAddress: string, stakedByOperator: string, stakedByDelegates: string, stakedTotal: string, pendingStake: string, stakedByOperatorFormatted: string, stakedByDelegatesFormatted: string, stakedTotalFormatted: string, pendingStakeFormatted: string, epochData?: { __typename?: 'EpochData', total: number, offline: number, online: number } | null, rankingScore: { __typename?: 'RankingScore', rankingScore: string, stakeScore: string, performanceScore: string, votingPower: string, status: Types.ValidatorStatus } } } | null> | null }, nodeData?: { __typename?: 'NodeData', stakedTotal: string, stakedTotalFormatted: string, totalNodes: number, inactiveNodes: number, validatingNodes: number, uptime: number } | null };
export type StakingQuery = { __typename?: 'Query', party?: { __typename?: 'Party', id: string, stakingSummary: { __typename?: 'StakingSummary', currentStakeAvailable: string }, delegationsConnection?: { __typename?: 'DelegationsConnection', edges?: Array<{ __typename?: 'DelegationEdge', node: { __typename?: 'Delegation', amount: string, epoch: number, node: { __typename?: 'Node', id: string } } } | null> | null } | null } | null, epoch: { __typename?: 'Epoch', id: string, timestamps: { __typename?: 'EpochTimestamps', start?: string | null, end?: string | null, expiry?: string | null } }, nodesConnection: { __typename?: 'NodesConnection', edges?: Array<{ __typename?: 'NodeEdge', node: { __typename?: 'Node', id: string, name: string, pubkey: string, infoUrl: string, location: string, ethereumAddress: string, stakedByOperator: string, stakedByDelegates: string, stakedTotal: string, pendingStake: string, epochData?: { __typename?: 'EpochData', total: number, offline: number, online: number } | null, rankingScore: { __typename?: 'RankingScore', rankingScore: string, stakeScore: string, performanceScore: string, votingPower: string, status: Types.ValidatorStatus } } } | null> | null }, nodeData?: { __typename?: 'NodeData', stakedTotal: string, totalNodes: number, inactiveNodes: number, uptime: number } | null };
export const StakingNodeFieldsFragmentDoc = gql`
fragment StakingNodeFields on Node {

View File

@ -4,15 +4,18 @@ import { useParams } from 'react-router-dom';
import { EpochCountdown } from '../../../components/epoch-countdown';
import { BigNumber } from '../../../lib/bignumber';
import { ConnectToVega } from '../../../components/connect-to-vega/connect-to-vega';
import { ConnectToVega } from '../../../components/connect-to-vega';
import { StakingForm } from './staking-form';
import { ValidatorTable } from './validator-table';
import { YourStake } from './your-stake';
import NodeContainer from './nodes-container';
import { useVegaWallet } from '@vegaprotocol/wallet';
import { useAppState } from '../../../contexts/app-state/app-state-context';
import { addDecimal, toBigNum } from '@vegaprotocol/react-helpers';
import compact from 'lodash/compact';
import {
addDecimal,
removePaginationWrapper,
toBigNum,
} from '@vegaprotocol/react-helpers';
import type { StakingQuery } from './__generated___/Staking';
interface StakingNodeProps {
@ -26,21 +29,20 @@ export const StakingNode = ({ data }: StakingNodeProps) => {
} = useAppState();
const { node } = useParams<{ node: string }>();
const { t } = useTranslation();
const nodeInfo = React.useMemo(() => {
const canonisedNodes =
compact(data?.nodesConnection?.edges?.map((edge) => edge?.node)) || [];
return canonisedNodes.find(({ id }) => id === node);
}, [node, data]);
const nodeInfo = React.useMemo(
() =>
removePaginationWrapper(data?.nodesConnection?.edges).find(
({ id }) => id === node
),
[node, data]
);
const currentEpoch = React.useMemo(() => {
return data?.epoch.id;
}, [data?.epoch.id]);
const delegations = React.useMemo(
() =>
compact(
data?.party?.delegationsConnection?.edges?.map((edge) => edge?.node)
) || [],
() => removePaginationWrapper(data?.party?.delegationsConnection?.edges),
[data?.party?.delegationsConnection?.edges]
);

View File

@ -1,6 +1,5 @@
import { useApolloClient } from '@apollo/client';
import * as Sentry from '@sentry/react';
import compact from 'lodash/compact';
import React, { useCallback, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { useNavigate } from 'react-router-dom';
@ -27,6 +26,7 @@ import {
NetworkParams,
removeDecimal,
addDecimal,
removePaginationWrapper,
} from '@vegaprotocol/react-helpers';
import { useBalances } from '../../../lib/balances/balances-store';
@ -157,8 +157,8 @@ export const StakingForm = ({
delegationSearch();
if (data) {
const delegation = compact(
data.party?.delegationsConnection?.edges?.map((edge) => edge?.node)
const delegation = removePaginationWrapper(
data.party?.delegationsConnection?.edges
).find((d) => {
return (
d.node.id === nodeId && d.epoch === Number(data.epoch.id) + 1

View File

@ -6,10 +6,20 @@ import { useEnvironment } from '@vegaprotocol/environment';
import { KeyValueTable, KeyValueTableRow } from '@vegaprotocol/ui-toolkit';
import { BigNumber } from '../../../lib/bignumber';
import { formatNumber } from '../../../lib/format-number';
import { statusTranslationKey } from '../home/node-list';
import type { StakingNodeFieldsFragment } from './__generated___/Staking';
import { toBigNum } from '@vegaprotocol/react-helpers';
import { useAppState } from '../../../contexts/app-state/app-state-context';
import { Schema } from '@vegaprotocol/types';
const statuses = {
[Schema.ValidatorStatus.VALIDATOR_NODE_STATUS_ERSATZ]: 'status-ersatz',
[Schema.ValidatorStatus.VALIDATOR_NODE_STATUS_PENDING]: 'status-pending',
[Schema.ValidatorStatus.VALIDATOR_NODE_STATUS_TENDERMINT]:
'status-tendermint',
};
const statusTranslationKey = (status: Schema.ValidatorStatus) =>
statuses[status];
const ValidatorTableCell = ({
children,

View File

@ -1,9 +1,9 @@
import compact from 'lodash/compact';
import { assetsProvider } from '@vegaprotocol/assets';
import { marketsProvider } from '@vegaprotocol/market-list';
import {
makeDataProvider,
makeDerivedDataProvider,
removePaginationWrapper,
} from '@vegaprotocol/react-helpers';
import { Schema } from '@vegaprotocol/types';
import produce from 'immer';
@ -76,7 +76,7 @@ const getData = (
responseData: AccountsQuery
): AccountFieldsFragment[] | null => {
return (
compact(responseData.party?.accountsConnection?.edges).map((e) => e.node) ??
removePaginationWrapper(responseData.party?.accountsConnection?.edges) ??
null
);
};

View File

@ -1,6 +1,6 @@
import { BigNumber } from 'bignumber.js';
import compact from 'lodash/compact';
import { useMarketPositionsQuery } from './__generated__/MarketPositions';
import { removePaginationWrapper } from '@vegaprotocol/react-helpers';
interface Props {
marketId: string;
partyId: string;
@ -22,8 +22,8 @@ export const useMarketPositions = ({
fetchPolicy: 'no-cache',
});
const accounts = compact(data?.party?.accountsConnection?.edges).map(
(e) => e.node
const accounts = removePaginationWrapper(
data?.party?.accountsConnection?.edges
);
const account = accounts.find((nodes) => nodes.market?.id === marketId);

View File

@ -1,8 +1,11 @@
import { BigNumber } from 'bignumber.js';
import compact from 'lodash/compact';
import type { OrderSubmissionBody } from '@vegaprotocol/wallet';
import { useVegaWallet } from '@vegaprotocol/wallet';
import { addDecimal, formatNumber } from '@vegaprotocol/react-helpers';
import {
addDecimal,
formatNumber,
removePaginationWrapper,
} from '@vegaprotocol/react-helpers';
import { useMarketPositions } from './use-market-positions';
import { useMarketDataMarkPrice } from './use-market-data-mark-price';
import { usePartyMarketDataQuery } from './__generated__/PartyMarketData';
@ -23,8 +26,8 @@ export const useOrderCloseOut = ({
partyData,
}: Props): string | null => {
const { pubKey } = useVegaWallet();
const accounts = compact(partyData?.party?.accountsConnection?.edges).map(
(e) => e.node
const accounts = removePaginationWrapper(
partyData?.party?.accountsConnection?.edges
);
const account = useSettlementAccount(
market.tradableInstrument.instrument.product.settlementAsset.id,
@ -51,8 +54,8 @@ export const useOrderCloseOut = ({
)
);
const dataAccounts = compact(data?.party?.accountsConnection?.edges).map(
(e) => e.node
const dataAccounts = removePaginationWrapper(
data?.party?.accountsConnection?.edges
);
const positionAccount = dataAccounts.find(
(account) => account.market?.id === market.id

View File

@ -3,6 +3,7 @@ import { useEnvironment } from '@vegaprotocol/environment';
import { totalFeesPercentage } from '@vegaprotocol/market-list';
import {
formatNumber,
removePaginationWrapper,
t,
useDataProvider,
useYesterday,
@ -21,7 +22,6 @@ import {
} from '@vegaprotocol/ui-toolkit';
import BigNumber from 'bignumber.js';
import pick from 'lodash/pick';
import compact from 'lodash/compact';
import { useMemo } from 'react';
import { generatePath, Link } from 'react-router-dom';
@ -102,8 +102,8 @@ export const Info = ({ market, onSelect }: InfoProps) => {
if (!market) return null;
const marketAccounts = compact(market.accountsConnection?.edges).map(
(e) => e.node
const marketAccounts = removePaginationWrapper(
market.accountsConnection?.edges
);
const marketDataPanels = [

View File

@ -6,7 +6,7 @@ const defaultOptions = {} as const;
export type StatsQueryVariables = Types.Exact<{ [key: string]: never; }>;
export type StatsQuery = { __typename?: 'Query', nodeData?: { __typename?: 'NodeData', stakedTotal: string, totalNodes: number, inactiveNodes: number, validatingNodes: number } | null, statistics: { __typename?: 'Statistics', status: string, blockHeight: string, blockDuration: string, backlogLength: string, txPerBlock: string, tradesPerSecond: string, ordersPerSecond: string, averageOrdersPerBlock: string, vegaTime: string, appVersion: string, chainVersion: string, chainId: string, genesisTime: string } };
export type StatsQuery = { __typename?: 'Query', nodeData?: { __typename?: 'NodeData', stakedTotal: string, totalNodes: number, inactiveNodes: number } | null, statistics: { __typename?: 'Statistics', status: string, blockHeight: string, blockDuration: string, backlogLength: string, txPerBlock: string, tradesPerSecond: string, ordersPerSecond: string, averageOrdersPerBlock: string, vegaTime: string, appVersion: string, chainVersion: string, chainId: string, genesisTime: string } };
export const StatsDocument = gql`

View File

@ -12,4 +12,5 @@ export * from './lib/storage';
export * from './lib/time';
export * from './lib/validate';
export * from './lib/links';
export * from './lib/remove-pagination-wrapper';
export * from './lib/__generated__/ChainId';

View File

@ -11,3 +11,4 @@ export * from './pagination';
export * from './remove-0x';
export * from './time';
export * from './links';
export * from './remove-pagination-wrapper';

View File

@ -10,6 +10,7 @@ export const createDocsLinks = (docsUrl: string) => ({
STAKING_GUIDE: `${docsUrl}/concepts/vega-chain/#staking-on-vega`,
VEGA_WALLET_CONCEPTS_URL: `${docsUrl}/concepts/vega-wallet`,
PROPOSALS_GUIDE: `${docsUrl}/tutorials/proposals`,
NODE_OPERATORS: `${docsUrl}/node-operators`,
});
export const ExternalLinks = {

View File

@ -0,0 +1,33 @@
import { removePaginationWrapper } from './remove-pagination-wrapper';
const expected = [
{ id: '1', name: 'foo' },
{ id: '2', name: 'bar' },
];
describe('Remove node from edges', () => {
it('should remove the node level structure', () => {
const edges = [
{ node: { id: '1', name: 'foo' } },
{ node: { id: '2', name: 'bar' } },
];
expect(removePaginationWrapper(edges)).toEqual(expected);
});
it('should remove any null values', () => {
const edges = [
{ node: { id: '1', name: 'foo' } },
{ node: null },
{ node: { id: '2', name: 'bar' } },
null,
];
expect(removePaginationWrapper(edges)).toEqual(expected);
});
it('should return an empty array if no edges are provided', () => {
expect(removePaginationWrapper(null)).toEqual([]);
expect(removePaginationWrapper(undefined)).toEqual([]);
});
});

View File

@ -0,0 +1,28 @@
import compact from 'lodash/compact';
/**
* A simple function that removes the 'node' level from API responses, i.e.
*
* [
* { node: { id: '1', name: 'foo' } },
* { node: { id: '2', name: 'bar' } },
* ]
*
* becomes:
*
* [
* { id: '1', name: 'foo' },
* { id: '2', name: 'bar' },
* ]
*
* It also removes any null values.
*
* @param edges array
* @returns array
*/
export function removePaginationWrapper<T>(
edges: Array<{ node: T } | null> | undefined | null
): T[] {
return compact(edges?.map((edge) => edge?.node));
}

View File

@ -1877,6 +1877,8 @@ export type NodeData = {
totalNodes: Scalars['Int'];
/** Total uptime for all epochs across all nodes. Or specify a number of epochs */
uptime: Scalars['Float'];
/** Number of nodes validating */
validatingNodes: Scalars['Int'];
};
/** Edge type containing the node and cursor information returned by a NodesConnection */

View File

@ -1,5 +1,7 @@
import { useNetworkParamsQuery } from '@vegaprotocol/react-helpers';
import compact from 'lodash/compact';
import {
removePaginationWrapper,
useNetworkParamsQuery,
} from '@vegaprotocol/react-helpers';
import { useMemo } from 'react';
export interface EthereumConfig {
@ -31,9 +33,9 @@ export const useEthereumConfig = () => {
return null;
}
const param = compact(data.networkParametersConnection.edges)
.map(({ node }) => node)
?.find((node) => node.key === 'blockchains.ethereumConfig');
const param = removePaginationWrapper(
data.networkParametersConnection.edges
)?.find(({ key }) => key === 'blockchains.ethereumConfig');
if (!param) {
return null;

View File

@ -1,5 +1,4 @@
import orderBy from 'lodash/orderBy';
import compact from 'lodash/compact';
import type { UpdateQueryFn } from '@apollo/client/core/watchQueryOptions';
import { useVegaWallet } from '@vegaprotocol/wallet';
import uniqBy from 'lodash/uniqBy';
@ -14,6 +13,7 @@ import type {
WithdrawalEventSubscription,
WithdrawalEventSubscriptionVariables,
} from './__generated__/Withdrawal';
import { removePaginationWrapper } from '@vegaprotocol/react-helpers';
type WithdrawalEdges = { node: WithdrawalFieldsFragment }[];
@ -47,7 +47,7 @@ export const useWithdrawals = () => {
}
return orderBy(
compact(data.party.withdrawalsConnection.edges).map((edge) => edge.node),
removePaginationWrapper(data.party.withdrawalsConnection.edges),
'createdTimestamp',
'desc'
);