Feat/1541: Hide top third of validators to promote decentralisation (#1626)

* Feat/1541: Hide top third of validators to promote decentralisation

* Feat/1541: Hide top third of validators

* Feat/1541: Only top validators if 3 or more
This commit is contained in:
Sam Keen 2022-10-11 09:23:29 +01:00 committed by GitHub
parent e6c19cf9b1
commit 178597dc01
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
3 changed files with 109 additions and 21 deletions

View File

@ -1,4 +1,4 @@
import { render, screen, waitFor } from '@testing-library/react';
import { act, fireEvent, render, screen } from '@testing-library/react';
import merge from 'lodash/merge';
import { NodeList, NODES_QUERY } from './node-list';
import { MockedProvider } from '@apollo/client/testing';
@ -15,7 +15,7 @@ jest.mock('../../components/epoch-countdown', () => ({
const nodeFactory = (overrides?: PartialDeep<Nodes_nodes>) => {
const defaultNode = {
id: 'ccc022b7e63a4d0a6d3a193c3940c88574060e58a184964c994998d86835a1b4',
name: 'Skynet',
name: 'high',
avatarUrl: 'https://upload.wikimedia.org/wikipedia/en/2/25/Marvin-TV-3.jpg',
pubkey: '6abc23391a9f888ab240415bf63d6844b03fc360be822f4a1d2cd832d87b2917',
infoUrl: 'https://en.wikipedia.org/wiki/Skynet_(Terminator)',
@ -32,7 +32,7 @@ const nodeFactory = (overrides?: PartialDeep<Nodes_nodes>) => {
rankingScore: '0.67845061012234727427532760837568',
stakeScore: '0.3392701644525644',
performanceScore: '0.9998677767864936',
votingPower: '2407',
votingPower: '3500',
status: 'tendermint',
__typename: 'RankingScore',
},
@ -46,7 +46,7 @@ const MOCK_NODES = {
nodeFactory(),
nodeFactory({
id: '966438c6bffac737cfb08173ffcb3f393c4692b099ad80cb45a82e2dc0a8cf99',
name: 'T-800 Terminator',
name: 'medium',
pubkey:
'ccc3b8362c25b09d20df8ea407b0a476d6b24a0e72bc063d0033c8841652ddd4',
stakedTotal: '9618711883996159534058',
@ -62,7 +62,7 @@ const MOCK_NODES = {
}),
nodeFactory({
id: '12c81b738e8051152e1afe44376ec37bca9216466e6d44cdd772194bad0ada81',
name: 'NCC-1701-E',
name: 'low',
pubkey:
'0931a8fd8cc935458f470e435a05414387cea6f329d648be894fcd44bd517a2b',
stakedTotal: '4041343338923442976709',
@ -129,20 +129,30 @@ afterAll(() => {
describe('Nodes list', () => {
it('should render epoch info', async () => {
renderNodeList();
await waitFor(() => {
expect(screen.getByText(MOCK_NODES.nodes[0].name)).toBeInTheDocument();
});
expect(screen.getByTestId('epoch-info')).toBeInTheDocument();
expect(await screen.findByTestId('epoch-info')).toBeInTheDocument();
});
it('should render a list of all nodes', async () => {
it('should initially render a list of nodes with the top third hidden', async () => {
renderNodeList();
await waitFor(() => {
expect(screen.getByText(MOCK_NODES.nodes[0].name)).toBeInTheDocument();
expect(screen.getByText(MOCK_NODES.nodes[1].name)).toBeInTheDocument();
expect(screen.getByText(MOCK_NODES.nodes[2].name)).toBeInTheDocument();
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 () => {
@ -179,12 +189,15 @@ describe('Nodes list', () => {
};
renderNodeList(MOCK_NODE);
await waitFor(() => {
expect(screen.getByText(MOCK_NODE.nodes[0].name)).toBeInTheDocument();
});
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');

View File

@ -1,8 +1,9 @@
import { gql, useQuery } from '@apollo/client';
import { forwardRef, useEffect, useMemo, useRef } from 'react';
import { forwardRef, useEffect, useMemo, useRef, useState } from 'react';
import {
AgGridDynamic as AgGrid,
AsyncRenderer,
Button,
} from '@vegaprotocol/ui-toolkit';
import type { AgGridReact } from 'ag-grid-react';
import { useNavigate } from 'react-router-dom';
@ -60,6 +61,23 @@ interface ValidatorRendererProps {
data: { validator: { avatarUrl: string; name: string } };
}
interface CanonisedNodeProps {
id: string;
[VALIDATOR]: {
avatarUrl: string | null;
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;
}
const ValidatorRenderer = ({ data }: ValidatorRendererProps) => {
const { avatarUrl, name } = data.validator;
return (
@ -92,6 +110,7 @@ export const NodeList = ({ epoch }: NodeListProps) => {
errorPolicy: 'ignore',
});
const navigate = useNavigate();
const [hideTopThird, setHideTopThird] = useState(true);
useEffect(() => {
const epochInterval = setInterval(() => {
@ -113,7 +132,7 @@ export const NodeList = ({ epoch }: NodeListProps) => {
const nodes = useMemo(() => {
if (!data?.nodes) return [];
return data.nodes.map(
const canonisedNodes = data.nodes.map(
({
id,
name,
@ -166,7 +185,39 @@ export const NodeList = ({ epoch }: NodeListProps) => {
};
}
);
}, [data, t]);
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, t, hideTopThird]);
const gridRef = useRef<AgGridReact | null>(null);
@ -247,6 +298,30 @@ export const NodeList = ({ epoch }: NodeListProps) => {
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%' }}

View File

@ -43,7 +43,7 @@ export const Staking = ({ data }: { data?: StakingQueryResult }) => {
</Callout>
</section>
<section>
<h2 className="text-2xl uppercase">{t('Nodes')}</h2>
<h2 className="text-2xl uppercase">{t('Validator nodes')}</h2>
<NodeList data-testid="node-list" epoch={data?.epoch} />
</section>
</>