Compare commits

...
Author SHA1 Message Date
bwallacee 477f0a06ff chore(trading): remove fixture usage 2024-02-26 13:16:01 +00:00
bwallacee beaed2e133 chore(trading): ci test 2024-02-26 11:04:26 +00:00
Art 72e0cb76aa feat(governance): batch proposal indicators, vote breakdown ui changes (#5857) 2024-02-26 09:48:29 +00:00
m.ray ca64516a52 fix(trading): lp data provider reload - updating book (#5846) 2024-02-23 17:08:22 +00:00
daro-majanddaniel1302 55d692ea6f test(governance): revert tests (#5808)
Co-authored-by: daniel1302 <daniel.1302@gmail.com>
2024-02-23 10:27:35 +01:00
Ben f235c03abe chore(trading): update e2e test readme (#5844) 2024-02-22 21:50:55 +01:00
Matthew Russell 546deb0e1c fix(trading): use same epochs for all games/stats (#5834) 2024-02-22 13:27:13 +00:00
Art 042919eca9 fix(governance): update market proposal diff (#5842) 2024-02-22 13:37:48 +01:00
Ben c4a56e0de3 chore(trading): fix-order-status-test (#5840) 2024-02-21 15:36:55 +00:00
Ben 3c3bfb7dac chore(trading): update e2e dependencies (#5831) 2024-02-21 13:51:30 +00:00
Ben 196ba78806 chore(trading): refactor tests (#5835) 2024-02-21 13:22:48 +00:00
Bartłomiej GłowniaandMatthew Russell 53ac2dadee feat(trading): support modified funding payment calc (#5780)
Co-authored-by: Matthew Russell <mattrussell36@gmail.com>
2024-02-20 19:08:29 +00:00
Matthew Russell e532f88daa fix(governance): market name component not showing correct instrument code (#5830) 2024-02-20 18:00:51 +00:00
Ben 7b06c05853 chore(trading): update to vega 74.3 (#5829) 2024-02-20 17:56:54 +00:00
Edd a92fe92778 fix(governance): fix willpassbylpvotecalculation (#5824) 2024-02-20 16:10:44 +00:00
Art b8725a7fa8 fix(trading): competitions font tweaks (#5828) 2024-02-20 16:17:02 +01:00
Art f556247e1a chore(trading): competitions texts (#5826) 2024-02-20 14:39:41 +00:00
Art 48d6be0adf fix(governance): batch proposal diff and proposal headers (#5825) 2024-02-20 15:04:11 +01:00
Art be6f395ce4 feat(trading): colourful rewards (#5812) 2024-02-20 13:20:35 +01:00
Edd 9a37572f51 fix(explorer): sizedelta in ammends displayed incorrectly' (#5816) 2024-02-20 09:24:25 +00:00
Edd 19fb406d49 feat(explorer): add treasury view (#5798) 2024-02-19 10:24:51 +00:00
Matthew Russell a2a04c57d2 fix(trading): add depth chart on mobile view (#5811) 2024-02-16 21:34:01 +00:00
Matthew Russell 1f08e8225a chore(trading): disable isolated margin tests 2024-02-15 19:55:40 -08:00
Matthew Russell 5fff6ba3f7 chore: disable isolated margin in testnet 2024-02-15 19:52:20 -08:00
Matthew RussellandDariusz Majcherczyk 6cacc46a74 feat(governance): batch proposals (#5735)
Co-authored-by: Dariusz Majcherczyk <dariusz.majcherczyk@gmail.com>
2024-02-15 20:14:25 +00:00
Artandbwallacee 98ff4f3c04 feat(trading): game results table (#5801)
Co-authored-by: bwallacee <ben@vega.xyz>
2024-02-15 20:14:04 +00:00
Matthew RussellandBen 8268acac6d fix(trading): add missing funding panel, fix 24 hr price/volume change (#5788)
Co-authored-by: Ben <ben@vega.xyz>
2024-02-15 15:41:04 +00:00
daro-majandBen 4ad1a47ded chore(trading): add cross isolated margin info test (#5786)
Co-authored-by: Ben <ben@vega.xyz>
2024-02-15 08:17:38 +00:00
daro-majandbwallacee fea97aac19 chore(trading): update vega version for sim tests - 0.74.0 (#5784)
Co-authored-by: bwallacee <ben@vega.xyz>
2024-02-14 09:31:28 +00:00
Ben f652292e02 chore(trading): add missed vega cleanup (#5794) 2024-02-14 09:19:52 +00:00
JonRay15 9195bf8c91 Merge pull request #5789 from vegaprotocol/fix/5677-gov-rewards-table
fix(governance): dont show all reward types in gov rewards table
2024-02-13 16:18:02 +00:00
Jeremy Letang cd481512f3 Merge pull request #5771 from vegaprotocol/feat/4306-transfer-status
feat(explorer): transfer status and games overview
2024-02-13 16:17:14 +00:00
Matthew Russell 8fe6f3f5f2 chore(trading): misc fixes (#5785) 2024-02-13 16:17:00 +00:00
Art 1c2389dee5 fix(trading): game cards colour, missing market info, other tweaks (#5791) 2024-02-13 13:38:01 +00:00
Jeremy Letang 1e6a2debfc Merge pull request #5783 from vegaprotocol/5781-update-EstimatePosition-argument
feat(deal-ticket): rename EstimatePosition argument
2024-02-13 10:26:02 +00:00
Matthew Russell fc2773d748 fix: make sure asset is not added to list if it doesn't container correct reward types 2024-02-12 13:42:49 -08:00
Matthew Russell b2860121e5 fix: dont show unrelated rewards in gov rewards table 2024-02-12 13:32:45 -08:00
Matthew Russell db5e5ee782 chore(trading, governance, explorer): update snap to 1.0.1 (#5768) 2024-02-12 18:38:31 +00:00
Art 0d3bcf05a1 chore(trading): team page refactor, games with specified epoch from (#5772) 2024-02-12 15:03:21 +01:00
Matthew RussellandMadalina Raicu c7dd5e846a fix(trading): validate whitespace team names, show empty in list (#5727)
Co-authored-by: Madalina Raicu <madalina@raygroup.uk>
2024-02-12 13:23:12 +00:00
Dariusz Majcherczyk 61e5941751 fix(deal-ticket): margin test 2024-02-09 21:14:36 +01:00
Bartłomiej Głownia 8db286fa93 feat(deal-ticket): rename EstimatePosition argument 2024-02-09 15:02:09 +01:00
Edd e5635cae61 test(explorer): add tests for games display 2024-02-08 12:26:32 +00:00
Edd b01c67ced5 chore(explorer): update transfer status query to match latest preview 2024-02-08 10:54:00 +00:00
Edd 636b1f98db chore(explorer): relayout transfers 2024-02-08 10:51:10 +00:00
Edd c31a927526 chore(explorer): progress on rank table 2024-02-08 10:51:10 +00:00
Edd 5803d6e890 feat(explorer): complete transfer status 2024-02-08 10:51:10 +00:00
Edd cb0dd17839 chore(explorer): more fields for rewards 2024-02-08 10:51:10 +00:00
Edd 496b0b5a90 feat(explorer): extend transfers view 2024-02-08 10:51:10 +00:00
214 changed files with 9940 additions and 6424 deletions
+1 -1
View File
@@ -10,7 +10,7 @@ on:
inputs:
console-test-branch:
type: choice
description: 'main: v0.73.5, develop: v0.73.5'
description: 'main: v0.73.13, develop: v0.74.0'
options:
- main
- develop
@@ -1,7 +1,7 @@
import { getNewAssetTxBody } from '../support/governance.functions';
context('Proposal page', { tags: '@smoke' }, function () {
describe('Verify elements on page', function () {
describe.skip('Verify elements on page', function () {
const proposalHeading = 'proposals-heading';
const dateTimeRegex =
/(\d{1,2})\/(\d{1,2})\/(\d{4}), (\d{1,2}):(\d{1,2}):(\d{1,2})/gm;
@@ -7,6 +7,7 @@ export type AssetBalanceProps = {
price: string;
showAssetLink?: boolean;
showAssetSymbol?: boolean;
rounded?: boolean;
};
/**
@@ -18,12 +19,17 @@ const AssetBalance = ({
price,
showAssetLink = true,
showAssetSymbol = false,
rounded = false,
}: AssetBalanceProps) => {
const { data: asset, loading } = useAssetDataProvider(assetId);
const label =
!loading && asset && asset.decimals
? addDecimalsFixedFormatNumber(price, asset.decimals)
? addDecimalsFixedFormatNumber(
price,
asset.decimals,
rounded ? 0 : undefined
)
: price;
return (
@@ -41,6 +41,7 @@ export const Header = () => {
Routes.ASSETS,
Routes.MARKETS,
Routes.GOVERNANCE,
Routes.TREASURY,
Routes.NETWORK_PARAMETERS,
Routes.GENESIS,
].map((n) => pages.find((r) => r.path === n))
@@ -32,9 +32,17 @@ export function getNameForParty(id: string, data?: ExplorerNodeNamesQuery) {
export type PartyLinkProps = Partial<ComponentProps<typeof Link>> & {
id: string;
truncate?: boolean;
networkLabel?: string;
truncateLength?: number;
};
const PartyLink = ({ id, truncate = false, ...props }: PartyLinkProps) => {
const PartyLink = ({
id,
truncate = false,
truncateLength = 4,
networkLabel = t('Network'),
...props
}: PartyLinkProps) => {
const { data } = useExplorerNodeNamesQuery();
const name = useMemo(() => getNameForParty(id, data), [data, id]);
const useName = name !== id;
@@ -44,7 +52,7 @@ const PartyLink = ({ id, truncate = false, ...props }: PartyLinkProps) => {
if (id === SPECIAL_CASE_NETWORK || id === SPECIAL_CASE_NETWORK_ID) {
return (
<span className="font-mono" data-testid="network">
{t('Network')}
{networkLabel}
</span>
);
}
@@ -70,7 +78,11 @@ const PartyLink = ({ id, truncate = false, ...props }: PartyLinkProps) => {
{useName ? (
name
) : (
<Hash text={truncate ? truncateMiddle(id, 4, 4) : id} />
<Hash
text={
truncate ? truncateMiddle(id, truncateLength, truncateLength) : id
}
/>
)}
</Link>
</span>
@@ -175,6 +175,7 @@ describe('Amend order details', () => {
const res = renderExistingAmend('123', 1, amend);
expect(await res.findByText('New size')).toBeInTheDocument();
expect(await res.findByText('Size ±')).toBeInTheDocument();
});
it('Renders Reference if provided', async () => {
@@ -82,7 +82,7 @@ const AmendOrderDetails = ({ id, version, amend }: AmendOrderDetailsProps) => {
{amend.sizeDelta && amend.sizeDelta !== '0' ? (
<div className="mb-12 md:mb-0">
<h2 className="text-dark mb-4 text-2xl font-bold">
{t('New size')}
{t('Size ±')}
</h2>
<h5
className={`mb-0 text-lg font-medium capitalize text-gray-500 ${getSideDeltaColour(
@@ -93,6 +93,16 @@ const AmendOrderDetails = ({ id, version, amend }: AmendOrderDetailsProps) => {
</h5>
</div>
) : null}
{o && (
<div className="">
<h2 className="text-dark mb-4 text-2xl font-bold">
{t('New size')}
</h2>
<h5 className="mb-0 text-lg font-medium text-gray-500">
{o ? o.size : null}
</h5>
</div>
)}
{amend.price && amend.price !== '0' ? (
<div className="">
@@ -64,7 +64,9 @@ export const ProposalSummary = ({
return (
<div className="w-auto max-w-lg border-2 border-solid border-vega-light-100 dark:border-vega-dark-200 p-5">
{id && <ProposalStatusIcon id={id} />}
{rationale?.title && <h1 className="text-xl pb-1">{rationale.title}</h1>}
{rationale?.title && (
<h1 className="text-xl pb-1 break-all">{rationale.title}</h1>
)}
{rationale?.description && (
<div className="pt-2 text-sm leading-tight">
<ReactMarkdown
@@ -0,0 +1,18 @@
query ExplorerTransferStatus($id: ID!) {
transfer(id: $id) {
transfer {
reference
timestamp
status
reason
fromAccountType
from
to
toAccountType
asset {
id
}
amount
}
}
}
@@ -0,0 +1,61 @@
import * as Types from '@vegaprotocol/types';
import { gql } from '@apollo/client';
import * as Apollo from '@apollo/client';
const defaultOptions = {} as const;
export type ExplorerTransferStatusQueryVariables = Types.Exact<{
id: Types.Scalars['ID'];
}>;
export type ExplorerTransferStatusQuery = { __typename?: 'Query', transfer?: { __typename?: 'TransferNode', transfer: { __typename?: 'Transfer', reference?: string | null, timestamp: any, status: Types.TransferStatus, reason?: string | null, fromAccountType: Types.AccountType, from: string, to: string, toAccountType: Types.AccountType, amount: string, asset?: { __typename?: 'Asset', id: string } | null } } | null };
export const ExplorerTransferStatusDocument = gql`
query ExplorerTransferStatus($id: ID!) {
transfer(id: $id) {
transfer {
reference
timestamp
status
reason
fromAccountType
from
to
toAccountType
asset {
id
}
amount
}
}
}
`;
/**
* __useExplorerTransferStatusQuery__
*
* To run a query within a React component, call `useExplorerTransferStatusQuery` and pass it any options that fit your needs.
* When your component renders, `useExplorerTransferStatusQuery` 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 } = useExplorerTransferStatusQuery({
* variables: {
* id: // value for 'id'
* },
* });
*/
export function useExplorerTransferStatusQuery(baseOptions: Apollo.QueryHookOptions<ExplorerTransferStatusQuery, ExplorerTransferStatusQueryVariables>) {
const options = {...defaultOptions, ...baseOptions}
return Apollo.useQuery<ExplorerTransferStatusQuery, ExplorerTransferStatusQueryVariables>(ExplorerTransferStatusDocument, options);
}
export function useExplorerTransferStatusLazyQuery(baseOptions?: Apollo.LazyQueryHookOptions<ExplorerTransferStatusQuery, ExplorerTransferStatusQueryVariables>) {
const options = {...defaultOptions, ...baseOptions}
return Apollo.useLazyQuery<ExplorerTransferStatusQuery, ExplorerTransferStatusQueryVariables>(ExplorerTransferStatusDocument, options);
}
export type ExplorerTransferStatusQueryHookResult = ReturnType<typeof useExplorerTransferStatusQuery>;
export type ExplorerTransferStatusLazyQueryHookResult = ReturnType<typeof useExplorerTransferStatusLazyQuery>;
export type ExplorerTransferStatusQueryResult = Apollo.QueryResult<ExplorerTransferStatusQuery, ExplorerTransferStatusQueryVariables>;
@@ -111,7 +111,7 @@ export function TransferParticipants({
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 16 9"
className="fill-vega-light-100 dark:fill-black"
className="fill-white dark:fill-black"
>
<path d="M0,0L8,9l8,-9Z" />
</svg>
@@ -120,7 +120,7 @@ export function TransferParticipants({
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 16 9"
className="fill-vega-light-100 dark:fill-vega-dark-200"
className="fill-vega-light-200 dark:fill-vega-dark-200"
>
<path d="M0,0L8,9l8,-9Z" />
</svg>
@@ -1,97 +1,223 @@
import { t } from '@vegaprotocol/i18n';
import { AssetLink, MarketLink } from '../../../../links';
import { headerClasses, wrapperClasses } from '../transfer-details';
import type { components } from '../../../../../../types/explorer';
import type { Recurring } from '../transfer-details';
import { DispatchMetricLabels } from '@vegaprotocol/types';
import {
DispatchMetricLabels,
DistributionStrategy,
} from '@vegaprotocol/types';
import { VegaIcon, VegaIconNames } from '@vegaprotocol/ui-toolkit';
import { formatNumber } from '@vegaprotocol/utils';
export type Metric = components['schemas']['vegaDispatchMetric'];
export type Strategy = components['schemas']['vegaDispatchStrategy'];
export const wrapperClasses = 'border pv-2 w-full flex-auto basis-full';
export const headerClasses =
'bg-solid bg-vega-light-150 dark:bg-vega-dark-150 text-center text-xl py-2 font-alpha calt';
const metricLabels: Record<Metric, string> = {
DISPATCH_METRIC_UNSPECIFIED: 'Unknown metric',
...DispatchMetricLabels,
};
// Maps the two (non-null) values of entityScope to the icon that represents it
const entityScopeIcons: Record<
string,
typeof VegaIconNames[keyof typeof VegaIconNames]
> = {
ENTITY_SCOPE_INDIVIDUALS: VegaIconNames.MAN,
ENTITY_SCOPE_TEAMS: VegaIconNames.TEAM,
};
const distributionStrategyLabel: Record<DistributionStrategy, string> = {
[DistributionStrategy.DISTRIBUTION_STRATEGY_PRO_RATA]: 'Pro Rata',
[DistributionStrategy.DISTRIBUTION_STRATEGY_RANK]: 'Ranked',
};
interface TransferRewardsProps {
recurring: Recurring;
}
/**
* Renderer for a transfer. These can vary quite
* widely, essentially every field can be null.
* Renders recurring transfers/game details in a way that is, perhaps, easy to understand
*
* @param transfer A recurring transfer object
*/
export function TransferRewards({ recurring }: TransferRewardsProps) {
const metric =
recurring?.dispatchStrategy?.metric || 'DISPATCH_METRIC_UNSPECIFIED';
if (!recurring || !recurring.dispatchStrategy) {
return null;
}
// Destructure to make things a bit more readable
const {
entityScope,
individualScope,
teamScope,
distributionStrategy,
lockPeriod,
markets,
stakingRequirement,
windowLength,
notionalTimeWeightedAveragePositionRequirement,
rankTable,
nTopPerformers,
} = recurring.dispatchStrategy;
return (
<div className={wrapperClasses}>
<h2 className={headerClasses}>{t('Reward metrics')}</h2>
<ul className="relative block rounded-lg py-6 text-center p-6">
{recurring.dispatchStrategy.assetForMetric ? (
<h2 className={headerClasses}>{getRewardTitle(entityScope)}</h2>
<ul className="relative block rounded-lg py-6 text-left p-6">
{entityScope && entityScopeIcons[entityScope] ? (
<li>
<strong>{t('Asset')}</strong>:{' '}
<AssetLink assetId={recurring.dispatchStrategy.assetForMetric} />
<strong>{t('Scope')}</strong>:{' '}
<VegaIcon name={entityScopeIcons[entityScope]} />
&nbsp;
{individualScope ? individualScopeLabels[individualScope] : null}
{getScopeLabel(entityScope, teamScope)}
</li>
) : null}
<li>
<strong>{t('Metric')}</strong>: {metricLabels[metric]}
</li>
{recurring.dispatchStrategy.markets &&
recurring.dispatchStrategy.markets.length > 0 ? (
{recurring.dispatchStrategy &&
recurring.dispatchStrategy.assetForMetric && (
<li>
<strong>{t('Asset for metric')}</strong>:{' '}
<AssetLink assetId={recurring.dispatchStrategy.assetForMetric} />
</li>
)}
{recurring.dispatchStrategy.metric &&
metricLabels[recurring.dispatchStrategy.metric] && (
<li>
<strong>{t('Metric')}</strong>:{' '}
{metricLabels[recurring.dispatchStrategy.metric]}
</li>
)}
{lockPeriod && (
<li>
<strong>{t('Reward lock')}</strong>:&nbsp;
{recurring.dispatchStrategy.lockPeriod}{' '}
{recurring.dispatchStrategy.lockPeriod === '1'
? t('epoch')
: t('epochs')}
</li>
)}
{markets && markets.length > 0 ? (
<li>
<strong>{t('Markets in scope')}</strong>:
<ul>
{recurring.dispatchStrategy.markets.map((m) => (
<li key={m}>
<ul className="inline-block ml-1">
{markets.map((m) => (
<li key={m} className="inline-block mr-2">
<MarketLink id={m} />
</li>
))}
</ul>
</li>
) : null}
<li>
<strong>{t('Factor')}</strong>: {recurring.factor}
</li>
{stakingRequirement && stakingRequirement !== '0' ? (
<li>
<strong>{t('Staking requirement')}</strong>: {stakingRequirement}
</li>
) : null}
{windowLength && windowLength !== '0' ? (
<li>
<strong>{t('Window length')}</strong>:{' '}
{recurring.dispatchStrategy.windowLength}{' '}
{recurring.dispatchStrategy.windowLength === '1'
? t('epoch')
: t('epochs')}
</li>
) : null}
{notionalTimeWeightedAveragePositionRequirement &&
notionalTimeWeightedAveragePositionRequirement !== '' ? (
<li>
<strong>{t('Notional TWAP')}</strong>:{' '}
{notionalTimeWeightedAveragePositionRequirement}
</li>
) : null}
{nTopPerformers && (
<li>
<strong>{t('Elligible team members:')}</strong> top{' '}
{`${formatNumber(Number(nTopPerformers) * 100, 0)}%`}
</li>
)}
{distributionStrategy &&
distributionStrategy !== 'DISTRIBUTION_STRATEGY_UNSPECIFIED' && (
<li>
<strong>{t('Distribution strategy')}</strong>:{' '}
{distributionStrategyLabel[distributionStrategy]}
</li>
)}
</ul>
<div className="px-6 pt-1 pb-5">
{rankTable && rankTable.length > 0 ? (
<table className="border-collapse border border-gray-400 ">
<thead>
<tr>
<th className="border border-gray-300 bg-gray-300 px-3">
<strong>{t('Start rank')}</strong>
</th>
<th className="border border-gray-300 bg-gray-300 px-3">
<strong>{t('Share of reward pool')}</strong>
</th>
</tr>
</thead>
<tbody>
{rankTable.map((row, i) => {
return (
<tr key={`rank-${i}`}>
<td className="border border-slate-300 text-center">
{row.startRank}
</td>
<td className="border border-slate-300 text-center">
{row.shareRatio}
</td>
</tr>
);
})}
</tbody>
</table>
) : null}
</div>
</div>
);
}
interface TransferRecurringStrategyProps {
strategy: Strategy;
}
/**
* Simple renderer for a dispatch strategy in a recurring transfer
*
* @param strategy Dispatch strategy object
*/
export function TransferRecurringStrategy({
strategy,
}: TransferRecurringStrategyProps) {
if (!strategy) {
return null;
export function getScopeLabel(
scope: components['schemas']['vegaEntityScope'] | undefined,
teamScope: readonly string[] | undefined
): string {
if (scope === 'ENTITY_SCOPE_TEAMS') {
if (teamScope && teamScope.length !== 0) {
return ` ${teamScope.length} teams`;
} else {
return t('All teams');
}
} else if (scope === 'ENTITY_SCOPE_INDIVIDUALS') {
return t('Individuals');
} else {
return '';
}
return (
<>
{strategy.assetForMetric ? (
<li>
<strong>{t('Asset for metric')}</strong>:{' '}
<AssetLink assetId={strategy.assetForMetric} />
</li>
) : null}
<li>
<strong>{t('Metric')}</strong>: {strategy.metric}
</li>
</>
);
}
export function getRewardTitle(
scope?: components['schemas']['vegaEntityScope']
) {
if (scope === 'ENTITY_SCOPE_TEAMS') {
return t('Game');
}
return t('Reward metrics');
}
const individualScopeLabels: Record<
components['schemas']['vegaIndividualScope'],
string
> = {
// Unspecified and All are not rendered
INDIVIDUAL_SCOPE_UNSPECIFIED: '',
INDIVIDUAL_SCOPE_ALL: '',
INDIVIDUAL_SCOPE_IN_TEAM: '(in team)',
INDIVIDUAL_SCOPE_NOT_IN_TEAM: '(not in team)',
};
@@ -0,0 +1,100 @@
import { t } from '@vegaprotocol/i18n';
import { headerClasses, wrapperClasses } from '../transfer-details';
import { Icon, Loader } from '@vegaprotocol/ui-toolkit';
import type { IconName } from '@vegaprotocol/ui-toolkit';
import type { ApolloError } from '@apollo/client';
import { TransferStatus, TransferStatusMapping } from '@vegaprotocol/types';
import { IconNames } from '@blueprintjs/icons';
interface TransferStatusProps {
status: TransferStatus | undefined;
error: ApolloError | undefined;
loading: boolean;
}
/**
* Renderer for a transfer. These can vary quite
* widely, essentially every field can be null.
*
* @param transfer A recurring transfer object
*/
export function TransferStatusView({ status, loading }: TransferStatusProps) {
if (!status) {
status = TransferStatus.STATUS_PENDING;
}
return (
<div className={wrapperClasses}>
<h2 className={headerClasses}>{t('Status')}</h2>
<div className="relative block rounded-lg py-6 text-center p-6">
{loading ? (
<div className="leading-10 mt-12">
<Loader size={'small'} />
</div>
) : (
<>
<p className="leading-10 my-2">
<TransferStatusIcon status={status} />
</p>
<p className="leading-10 my-2">{TransferStatusMapping[status]}</p>
</>
)}
</div>
</div>
);
}
interface TransferStatusIconProps {
status: TransferStatus;
}
export function TransferStatusIcon({ status }: TransferStatusIconProps) {
return (
<span title={TransferStatusMapping[status]}>
<Icon
name={getIconForStatus(status)}
className={getColourForStatus(status)}
/>
</span>
);
}
/**
* Simple mapping from status to icon name
* @param status TransferStatus
* @returns IconName
*/
export function getIconForStatus(status: TransferStatus): IconName {
switch (status) {
case TransferStatus.STATUS_PENDING:
return IconNames.TIME;
case TransferStatus.STATUS_DONE:
return IconNames.TICK;
case TransferStatus.STATUS_REJECTED:
return IconNames.CROSS;
case TransferStatus.STATUS_CANCELLED:
return IconNames.CROSS;
default:
return IconNames.TIME;
}
}
/**
* Simple mapping from status to colour
* @param status TransferStatus
* @returns string Tailwind classname
*/
export function getColourForStatus(status: TransferStatus): string {
switch (status) {
case TransferStatus.STATUS_PENDING:
return 'text-yellow-500';
case TransferStatus.STATUS_DONE:
return 'text-green-500';
case TransferStatus.STATUS_REJECTED:
return 'text-red-500';
case TransferStatus.STATUS_CANCELLED:
return 'text-red-600';
default:
return 'text-yellow-500';
}
}
@@ -2,12 +2,15 @@ import type { components } from '../../../../../types/explorer';
import { TransferRepeat } from './blocks/transfer-repeat';
import { TransferRewards } from './blocks/transfer-rewards';
import { TransferParticipants } from './blocks/transfer-participants';
import { useExplorerTransferStatusQuery } from './__generated__/Transfer';
import { TransferStatusView } from './blocks/transfer-status';
import { TransferStatus } from '@vegaprotocol/types';
export type Recurring = components['schemas']['commandsv1RecurringTransfer'];
export type Metric = components['schemas']['vegaDispatchMetric'];
export const wrapperClasses =
'border border-vega-light-150 dark:border-vega-dark-200 rounded-md pv-2 mb-5 w-full sm:w-1/4 min-w-[200px] ';
'border border-vega-light-150 dark:border-vega-dark-200 pv-2 w-full sm:w-1/3 basis-1/3';
export const headerClasses =
'bg-solid bg-vega-light-150 dark:bg-vega-dark-150 border-vega-light-150 text-center text-xl py-2 font-alpha calt';
@@ -16,6 +19,7 @@ export type Transfer = components['schemas']['commandsv1Transfer'];
interface TransferDetailsProps {
transfer: Transfer;
from: string;
id: string;
}
/**
@@ -24,13 +28,24 @@ interface TransferDetailsProps {
*
* @param transfer A recurring transfer object
*/
export function TransferDetails({ transfer, from }: TransferDetailsProps) {
export function TransferDetails({ transfer, from, id }: TransferDetailsProps) {
const recurring = transfer.recurring;
// Currently all this is passed in to TransferStatus, but the extra details
// may be useful in the future.
const { data, error, loading } = useExplorerTransferStatusQuery({
variables: { id },
});
const status = error
? TransferStatus.STATUS_REJECTED
: data?.transfer?.transfer.status;
return (
<div className="flex gap-5 flex-wrap">
<div className="flex flex-wrap">
<TransferParticipants from={from} transfer={transfer} />
{recurring ? <TransferRepeat recurring={transfer.recurring} /> : null}
<TransferStatusView status={status} error={error} loading={loading} />
{recurring && recurring.dispatchStrategy ? (
<TransferRewards recurring={transfer.recurring} />
) : null}
@@ -0,0 +1,172 @@
import {
getScopeLabel,
getRewardTitle,
TransferRewards,
} from './blocks/transfer-rewards';
import { render } from '@testing-library/react';
import type { components } from '../../../../../types/explorer';
import type { Recurring } from './transfer-details';
import {
DispatchMetric,
DistributionStrategy,
EntityScope,
IndividualScope,
} from '@vegaprotocol/types';
import { MemoryRouter } from 'react-router-dom';
import { MockedProvider } from '@apollo/client/testing';
describe('getScopeLabel', () => {
it('should return the correct label for ENTITY_SCOPE_TEAMS with teamScope', () => {
const scope = 'ENTITY_SCOPE_TEAMS';
const teamScope = ['team1', 'team2', 'team3'];
const expectedLabel = ' 3 teams';
const result = getScopeLabel(scope, teamScope);
expect(result).toEqual(expectedLabel);
});
it('should return the correct label for ENTITY_SCOPE_TEAMS without teamScope', () => {
const scope = 'ENTITY_SCOPE_TEAMS';
const teamScope = undefined;
const expectedLabel = 'All teams';
const result = getScopeLabel(scope, teamScope);
expect(result).toEqual(expectedLabel);
});
it('should return the correct label for ENTITY_SCOPE_INDIVIDUALS', () => {
const scope = 'ENTITY_SCOPE_INDIVIDUALS';
const teamScope = undefined;
const expectedLabel = 'Individuals';
const result = getScopeLabel(scope, teamScope);
expect(result).toEqual(expectedLabel);
});
it('should return an empty string for unknown scope', () => {
const scope = 'UNKNOWN_SCOPE';
const teamScope = undefined;
const expectedLabel = '';
const result = getScopeLabel(
scope as unknown as components['schemas']['vegaEntityScope'],
teamScope
);
expect(result).toEqual(expectedLabel);
});
});
describe('getRewardTitle', () => {
it('should return the correct title for ENTITY_SCOPE_TEAMS', () => {
const scope = 'ENTITY_SCOPE_TEAMS';
const expectedTitle = 'Game';
const result = getRewardTitle(scope);
expect(result).toEqual(expectedTitle);
});
it('should return the correct title for other scopes', () => {
const scope = 'ENTITY_SCOPE_INDIVIDUALS';
const expectedTitle = 'Reward metrics';
const result = getRewardTitle(scope);
expect(result).toEqual(expectedTitle);
});
});
describe('TransferRewards', () => {
it('should render nothing if recurring dispatchStrategy is not provided', () => {
const { container } = render(
<TransferRewards recurring={null as unknown as Recurring} />
);
expect(container.firstChild).toBeNull();
});
it('should render nothing if recurring.dispatchStrategy is not provided', () => {
const { container } = render(
<TransferRewards recurring={{} as unknown as Recurring} />
);
expect(container.firstChild).toBeNull();
});
it('should render the reward details correctly', () => {
const recurring = {
dispatchStrategy: {
metric: DispatchMetric.DISPATCH_METRIC_AVERAGE_POSITION,
assetForMetric: '123',
entityScope: EntityScope.ENTITY_SCOPE_TEAMS,
individualScope: IndividualScope.INDIVIDUAL_SCOPE_IN_TEAM,
teamScope: [],
distributionStrategy:
DistributionStrategy.DISTRIBUTION_STRATEGY_PRO_RATA,
lockPeriod: 'lockPeriod',
markets: ['market1', 'market2'],
stakingRequirement: '1',
windowLength: 'windowLength',
notionalTimeWeightedAveragePositionRequirement:
'notionalTimeWeightedAveragePositionRequirement',
rankTable: [
{ startRank: 1, shareRatio: 0.2 },
{ startRank: 2, shareRatio: 0.3 },
],
nTopPerformers: 'nTopPerformers',
},
};
const { getByText } = render(
<MemoryRouter>
<MockedProvider>
<TransferRewards recurring={recurring} />
</MockedProvider>
</MemoryRouter>
);
expect(getByText('Game')).toBeInTheDocument();
expect(getByText('Scope')).toBeInTheDocument();
expect(getByText('Asset for metric')).toBeInTheDocument();
expect(getByText('Metric')).toBeInTheDocument();
expect(getByText('Reward lock')).toBeInTheDocument();
expect(getByText('Markets in scope')).toBeInTheDocument();
expect(getByText('Staking requirement')).toBeInTheDocument();
expect(getByText('Window length')).toBeInTheDocument();
expect(getByText('Notional TWAP')).toBeInTheDocument();
expect(getByText('Elligible team members:')).toBeInTheDocument();
expect(getByText('Distribution strategy')).toBeInTheDocument();
expect(getByText('Start rank')).toBeInTheDocument();
expect(getByText('Share of reward pool')).toBeInTheDocument();
});
it('should not render a rank table if recurring.dispatchStrategy.rankTable is not provided', () => {
const recurring = {
dispatchStrategy: {
entityScope: EntityScope.ENTITY_SCOPE_INDIVIDUALS,
individualScope: IndividualScope.INDIVIDUAL_SCOPE_ALL,
teamScope: ['team1', 'team2', 'team3'],
distributionStrategy:
DistributionStrategy.DISTRIBUTION_STRATEGY_PRO_RATA,
lockPeriod: 'lockPeriod',
markets: ['market1', 'market2'],
stakingRequirement: 'stakingRequirement',
windowLength: 'windowLength',
notionalTimeWeightedAveragePositionRequirement:
'notionalTimeWeightedAveragePositionRequirement',
nTopPerformers: 'nTopPerformers',
},
};
const { container } = render(
<MemoryRouter>
<MockedProvider>
<TransferRewards recurring={recurring} />
</MockedProvider>
</MemoryRouter>
);
expect(container.querySelector('table')).toBeNull();
});
});
@@ -12,6 +12,8 @@ import { ProposalSignatureBundleNewAsset } from './proposal/signature-bundle-new
import { ProposalSignatureBundleUpdateAsset } from './proposal/signature-bundle-update';
import { MarketLink } from '../../links';
import { formatNumber } from '@vegaprotocol/utils';
import { TransferDetails } from './transfer/transfer-details';
import { proposalToTransfer } from '../lib/proposal-to-transfer';
export type Proposal = components['schemas']['v1ProposalSubmission'];
export type ProposalTerms = components['schemas']['vegaProposalTerms'];
@@ -104,6 +106,12 @@ export const TxProposal = ({ txData, pubKey, blockData }: TxProposalProps) => {
? ProposalSignatureBundleNewAsset
: ProposalSignatureBundleUpdateAsset;
let transfer, from;
if (proposal.terms?.newTransfer?.changes) {
transfer = proposalToTransfer(proposal.terms?.newTransfer.changes);
from = proposal.terms.newTransfer.changes.source;
}
return (
<>
<TableWithTbody className="mb-8" allowWrap={true}>
@@ -149,14 +157,26 @@ export const TxProposal = ({ txData, pubKey, blockData }: TxProposalProps) => {
</>
) : null}
</TableWithTbody>
<ProposalSummary
id={deterministicId}
rationale={proposal.rationale}
terms={proposal?.terms}
/>
{proposalRequiresSignatureBundle(proposal) && (
<SignatureBundleComponent id={deterministicId} tx={tx} />
)}
{transfer && (
<div className="mt-8">
<TransferDetails
transfer={transfer}
from={from || ''}
id={deterministicId}
/>
</div>
)}
</>
);
};
@@ -13,6 +13,7 @@ import {
SPECIAL_CASE_NETWORK_ID,
} from '../../links/party-link/party-link';
import { txSignatureToDeterministicId } from '../lib/deterministic-ids';
import Hash from '../../links/hash';
type Transfer = components['schemas']['commandsv1Transfer'];
@@ -60,7 +61,7 @@ export const TxDetailsTransfer = ({
}
const from = txData.submitter;
const id = txSignatureToDeterministicId(txData.signature.value);
return (
<>
<TableWithTbody className="mb-8" allowWrap={true}>
@@ -71,7 +72,7 @@ export const TxDetailsTransfer = ({
<TableRow modifier="bordered" data-testid="id">
<TableCell {...sharedHeaderProps}>{t('Transfer ID')}</TableCell>
<TableCell>
{txSignatureToDeterministicId(txData.signature.value)}
<Hash text={id} />
</TableCell>
</TableRow>
<TxDetailsShared
@@ -105,7 +106,7 @@ export const TxDetailsTransfer = ({
</TableRow>
) : null}
</TableWithTbody>
<TransferDetails from={from} transfer={transfer} />
<TransferDetails from={from} transfer={transfer} id={id} />
</>
);
};
@@ -0,0 +1,29 @@
import type { components } from '../../../../types/explorer';
type TransferProposal = components['schemas']['vegaNewTransferConfiguration'];
type ActualTransfer = components['schemas']['commandsv1Transfer'];
/**
* Converts a governance proposal for a transfer in to a transfer command that the
* TransferDetails component can then render. The types are very similar, but do not
* map precisely to each other due to some missing fields and some different field
* names.
*
* @param proposal Governance proposal for a transfer
* @returns transfer a Transfer object as if it had been submitted
*/
export function proposalToTransfer(proposal: TransferProposal): ActualTransfer {
return {
amount: proposal.amount,
asset: proposal.asset,
// On a transfer, 'from' is determined by the submitter, so there is no 'from' field
// fromAccountType does exist and is just named differently on the proposal
fromAccountType: proposal.sourceType,
oneOff: proposal.oneOff,
recurring: proposal.recurring,
// There is no reference applied on governance initiated transfers
reference: '',
to: proposal.destination,
toAccountType: proposal.destinationType,
};
}
@@ -12,4 +12,5 @@ export const Routes = {
ORACLES: 'oracles',
NETWORK_PARAMETERS: 'network-parameters',
DISCLAIMER: 'disclaimer',
TREASURY: 'treasury',
};
@@ -30,6 +30,7 @@ import { PartyAccountsByAsset } from './parties/id/accounts';
import { Disclaimer } from './pages/disclaimer';
import { useFeatureFlags } from '@vegaprotocol/environment';
import RestrictedPage from './restricted';
import { NetworkTreasury } from './treasury';
export type Navigable = {
path: string;
@@ -229,6 +230,17 @@ export const useRouterConfig = () => {
]
: [];
const treasuryRoutes: Route[] = [
{
path: Routes.TREASURY,
handle: {
name: t('Treasury'),
text: t('Treasury'),
breadcrumb: () => <Link to={Routes.TREASURY}>{t('Treasury')}</Link>,
},
element: <NetworkTreasury />,
},
];
const validators: Route[] = featureFlags.EXPLORER_VALIDATORS
? [
{
@@ -358,6 +370,7 @@ export const useRouterConfig = () => {
...marketsRoutes,
...networkParametersRoutes,
...validators,
...treasuryRoutes,
],
},
{
@@ -0,0 +1,12 @@
query ExplorerTreasury {
assetsConnection(pagination: { last: 1000 }) {
edges {
node {
id
networkTreasuryAccount {
balance
}
}
}
}
}
@@ -0,0 +1,44 @@
query ExplorerTreasuryTransfers {
transfersConnection(
partyId: "network"
direction: ToOrFrom
pagination: { last: 200 }
) {
pageInfo {
hasNextPage
}
edges {
node {
transfer {
timestamp
from
amount
to
status
reason
toAccountType
fromAccountType
asset {
id
}
id
status
kind {
... on OneOffTransfer {
deliverOn
}
... on RecurringTransfer {
startEpoch
}
... on OneOffGovernanceTransfer {
deliverOn
}
... on RecurringGovernanceTransfer {
endEpoch
}
}
}
}
}
}
}
@@ -0,0 +1,52 @@
import * as Types from '@vegaprotocol/types';
import { gql } from '@apollo/client';
import * as Apollo from '@apollo/client';
const defaultOptions = {} as const;
export type ExplorerTreasuryQueryVariables = Types.Exact<{ [key: string]: never; }>;
export type ExplorerTreasuryQuery = { __typename?: 'Query', assetsConnection?: { __typename?: 'AssetsConnection', edges?: Array<{ __typename?: 'AssetEdge', node: { __typename?: 'Asset', id: string, networkTreasuryAccount?: { __typename?: 'AccountBalance', balance: string } | null } } | null> | null } | null };
export const ExplorerTreasuryDocument = gql`
query ExplorerTreasury {
assetsConnection(pagination: {last: 1000}) {
edges {
node {
id
networkTreasuryAccount {
balance
}
}
}
}
}
`;
/**
* __useExplorerTreasuryQuery__
*
* To run a query within a React component, call `useExplorerTreasuryQuery` and pass it any options that fit your needs.
* When your component renders, `useExplorerTreasuryQuery` 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 } = useExplorerTreasuryQuery({
* variables: {
* },
* });
*/
export function useExplorerTreasuryQuery(baseOptions?: Apollo.QueryHookOptions<ExplorerTreasuryQuery, ExplorerTreasuryQueryVariables>) {
const options = {...defaultOptions, ...baseOptions}
return Apollo.useQuery<ExplorerTreasuryQuery, ExplorerTreasuryQueryVariables>(ExplorerTreasuryDocument, options);
}
export function useExplorerTreasuryLazyQuery(baseOptions?: Apollo.LazyQueryHookOptions<ExplorerTreasuryQuery, ExplorerTreasuryQueryVariables>) {
const options = {...defaultOptions, ...baseOptions}
return Apollo.useLazyQuery<ExplorerTreasuryQuery, ExplorerTreasuryQueryVariables>(ExplorerTreasuryDocument, options);
}
export type ExplorerTreasuryQueryHookResult = ReturnType<typeof useExplorerTreasuryQuery>;
export type ExplorerTreasuryLazyQueryHookResult = ReturnType<typeof useExplorerTreasuryLazyQuery>;
export type ExplorerTreasuryQueryResult = Apollo.QueryResult<ExplorerTreasuryQuery, ExplorerTreasuryQueryVariables>;
@@ -0,0 +1,84 @@
import * as Types from '@vegaprotocol/types';
import { gql } from '@apollo/client';
import * as Apollo from '@apollo/client';
const defaultOptions = {} as const;
export type ExplorerTreasuryTransfersQueryVariables = Types.Exact<{ [key: string]: never; }>;
export type ExplorerTreasuryTransfersQuery = { __typename?: 'Query', transfersConnection?: { __typename?: 'TransferConnection', pageInfo: { __typename?: 'PageInfo', hasNextPage: boolean }, edges?: Array<{ __typename?: 'TransferEdge', node: { __typename?: 'TransferNode', transfer: { __typename?: 'Transfer', timestamp: any, from: string, amount: string, to: string, status: Types.TransferStatus, reason?: string | null, toAccountType: Types.AccountType, fromAccountType: Types.AccountType, id: string, asset?: { __typename?: 'Asset', id: string } | null, kind: { __typename?: 'OneOffGovernanceTransfer', deliverOn?: any | null } | { __typename?: 'OneOffTransfer', deliverOn?: any | null } | { __typename?: 'RecurringGovernanceTransfer', endEpoch?: number | null } | { __typename?: 'RecurringTransfer', startEpoch: number } } } } | null> | null } | null };
export const ExplorerTreasuryTransfersDocument = gql`
query ExplorerTreasuryTransfers {
transfersConnection(
partyId: "network"
direction: ToOrFrom
pagination: {last: 200}
) {
pageInfo {
hasNextPage
}
edges {
node {
transfer {
timestamp
from
amount
to
status
reason
toAccountType
fromAccountType
asset {
id
}
id
status
kind {
... on OneOffTransfer {
deliverOn
}
... on RecurringTransfer {
startEpoch
}
... on OneOffGovernanceTransfer {
deliverOn
}
... on RecurringGovernanceTransfer {
endEpoch
}
}
}
}
}
}
}
`;
/**
* __useExplorerTreasuryTransfersQuery__
*
* To run a query within a React component, call `useExplorerTreasuryTransfersQuery` and pass it any options that fit your needs.
* When your component renders, `useExplorerTreasuryTransfersQuery` 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 } = useExplorerTreasuryTransfersQuery({
* variables: {
* },
* });
*/
export function useExplorerTreasuryTransfersQuery(baseOptions?: Apollo.QueryHookOptions<ExplorerTreasuryTransfersQuery, ExplorerTreasuryTransfersQueryVariables>) {
const options = {...defaultOptions, ...baseOptions}
return Apollo.useQuery<ExplorerTreasuryTransfersQuery, ExplorerTreasuryTransfersQueryVariables>(ExplorerTreasuryTransfersDocument, options);
}
export function useExplorerTreasuryTransfersLazyQuery(baseOptions?: Apollo.LazyQueryHookOptions<ExplorerTreasuryTransfersQuery, ExplorerTreasuryTransfersQueryVariables>) {
const options = {...defaultOptions, ...baseOptions}
return Apollo.useLazyQuery<ExplorerTreasuryTransfersQuery, ExplorerTreasuryTransfersQueryVariables>(ExplorerTreasuryTransfersDocument, options);
}
export type ExplorerTreasuryTransfersQueryHookResult = ReturnType<typeof useExplorerTreasuryTransfersQuery>;
export type ExplorerTreasuryTransfersLazyQueryHookResult = ReturnType<typeof useExplorerTreasuryTransfersLazyQuery>;
export type ExplorerTreasuryTransfersQueryResult = Apollo.QueryResult<ExplorerTreasuryTransfersQuery, ExplorerTreasuryTransfersQueryVariables>;
@@ -0,0 +1,37 @@
// NOTE: These are a temporary measure, pulled from an old branch on console.
import { IconNames } from '@blueprintjs/icons';
import { Icon } from '@vegaprotocol/ui-toolkit';
import { USDc } from './usdc';
import { Vega } from './vega';
import { USDt } from './usdt';
export interface AssetIconProps {
symbol: string;
}
/**
* A poorly implemented, limited support for asset icons.
*
* These are committed as 'deprecated' to discourage use outside the Treasury page. Rather
* than use this, a better approach would be to use source contract addresses to match assets.
* This will be done separately.
*
* @deprecated
*/
export function AssetIcon({ symbol }: AssetIconProps) {
const s = symbol.toLowerCase();
switch (s) {
case 'a4a16e250a09a86061ec83c2f9466fc9dc33d332f86876ee74b6f128a5cd6710': // mainnet
case 'c9fe6fc24fce121b2cc72680543a886055abb560043fda394ba5376203b7527d': // mainnet
return <USDc size={32} />;
case 'd1984e3d365faa05bcafbe41f50f90e3663ee7c0da22bb1e24b164e9532691b2': // mainnet
case 'fc7fd956078fb1fc9db5c19b88f0874c4299b2a7639ad05a47a28c0aef291b55': // testnet
return <Vega size={32} />;
case 'bf1e88d19db4b3ca0d1d5bdb73718a01686b18cf731ca26adedf3c8b83802bba': // mainnet
case 'ede4076aef07fd79502d14326c54ab3911558371baaf697a19d077f4f89de399': // testnet
return <USDt size={32} />;
default:
return <Icon name={IconNames.BANK_ACCOUNT} size={8} />;
}
}
@@ -0,0 +1,24 @@
/**
* See note in index.tsx. This component is intended as a placeholder for a
* better, more generic solution.
*
* @deprecated
*/
export const USDc = ({ size = 16 }: { size?: number }) => {
return (
<svg width={size} height={size} viewBox="0 0 2000 2000">
<path
d="M1000 2000c554.17 0 1000-445.83 1000-1000S1554.17 0 1000 0 0 445.83 0 1000s445.83 1000 1000 1000z"
fill="#2775ca"
/>
<path
d="M1275 1158.33c0-145.83-87.5-195.83-262.5-216.66-125-16.67-150-50-150-108.34s41.67-95.83 125-95.83c75 0 116.67 25 137.5 87.5 4.17 12.5 16.67 20.83 29.17 20.83h66.66c16.67 0 29.17-12.5 29.17-29.16v-4.17c-16.67-91.67-91.67-162.5-187.5-170.83v-100c0-16.67-12.5-29.17-33.33-33.34h-62.5c-16.67 0-29.17 12.5-33.34 33.34v95.83c-125 16.67-204.16 100-204.16 204.17 0 137.5 83.33 191.66 258.33 212.5 116.67 20.83 154.17 45.83 154.17 112.5s-58.34 112.5-137.5 112.5c-108.34 0-145.84-45.84-158.34-108.34-4.16-16.66-16.66-25-29.16-25h-70.84c-16.66 0-29.16 12.5-29.16 29.17v4.17c16.66 104.16 83.33 179.16 220.83 200v100c0 16.66 12.5 29.16 33.33 33.33h62.5c16.67 0 29.17-12.5 33.34-33.33v-100c125-20.84 208.33-108.34 208.33-220.84z"
fill="#fff"
/>
<path
d="M787.5 1595.83c-325-116.66-491.67-479.16-370.83-800 62.5-175 200-308.33 370.83-370.83 16.67-8.33 25-20.83 25-41.67V325c0-16.67-8.33-29.17-25-33.33-4.17 0-12.5 0-16.67 4.16-395.83 125-612.5 545.84-487.5 941.67 75 233.33 254.17 412.5 487.5 487.5 16.67 8.33 33.34 0 37.5-16.67 4.17-4.16 4.17-8.33 4.17-16.66v-58.34c0-12.5-12.5-29.16-25-37.5zM1229.17 295.83c-16.67-8.33-33.34 0-37.5 16.67-4.17 4.17-4.17 8.33-4.17 16.67v58.33c0 16.67 12.5 33.33 25 41.67 325 116.66 491.67 479.16 370.83 800-62.5 175-200 308.33-370.83 370.83-16.67 8.33-25 20.83-25 41.67V1700c0 16.67 8.33 29.17 25 33.33 4.17 0 12.5 0 16.67-4.16 395.83-125 612.5-545.84 487.5-941.67-75-237.5-258.34-416.67-487.5-491.67z"
fill="#fff"
/>
</svg>
);
};
@@ -0,0 +1,20 @@
/**
* See note in index.tsx. This component is intended as a placeholder for a
* better, more generic solution.
*
* @deprecated
*/
export const USDt = ({ size = 16 }: { size?: number }) => {
return (
<svg width={size} height={size} viewBox="0 0 339.43 295.27">
<path
fill="#50af95"
d="M62.15,1.45l-61.89,130a2.52,2.52,0,0,0,.54,2.94L167.95,294.56a2.55,2.55,0,0,0,3.53,0L338.63,134.4a2.52,2.52,0,0,0,.54-2.94l-61.89-130A2.5,2.5,0,0,0,275,0H64.45a2.5,2.5,0,0,0-2.3,1.45h0Z"
/>
<path
fill="#fff"
d="M191.19,144.8v0c-1.2.09-7.4,0.46-21.23,0.46-11,0-18.81-.33-21.55-0.46v0c-42.51-1.87-74.24-9.27-74.24-18.13s31.73-16.25,74.24-18.15v28.91c2.78,0.2,10.74.67,21.74,0.67,13.2,0,19.81-.55,21-0.66v-28.9c42.42,1.89,74.08,9.29,74.08,18.13s-31.65,16.24-74.08,18.12h0Zm0-39.25V79.68h59.2V40.23H89.21V79.68H148.4v25.86c-48.11,2.21-84.29,11.74-84.29,23.16s36.18,20.94,84.29,23.16v82.9h42.78V151.83c48-2.21,84.12-11.73,84.12-23.14s-36.09-20.93-84.12-23.15h0Zm0,0h0Z"
/>
</svg>
);
};
@@ -0,0 +1,28 @@
/**
* See note in index.tsx. This component is intended as a placeholder for a
* better, more generic solution.
*
* @deprecated
*/
export const Vega = ({ size = 16 }: { size?: number }) => {
return (
<svg width={size} height={size} viewBox="0 0 42 42">
<rect width="42" height="42" rx="21" fill="black" />
<path d="M13 27.2726H16.4545V10H13V27.2726Z" fill="white" />
<path d="M25.667 23.8181H29.1215V10H25.667V23.8181Z" fill="white" />
<path d="M19.333 33.6059H22.7875V30.1514H19.333V33.6059Z" fill="white" />
<path
d="M22.7871 30.7271H26.2416V27.2726H22.7871V30.7271Z"
fill="white"
/>
<path
d="M29.1211 27.2726H31.9999V23.8181H29.1211V27.2726Z"
fill="white"
/>
<path
d="M16.4551 30.7271H19.3339V27.2726H16.4551V30.7271Z"
fill="white"
/>
</svg>
);
};
@@ -0,0 +1,171 @@
import type { DeepPartial } from '@apollo/client/utilities';
import { parseResultsToAccounts } from './network-accounts-table';
import {
ExplorerTreasuryDocument,
type ExplorerTreasuryQuery,
} from '../__generated__/Treasury';
import { render, screen } from '@testing-library/react';
import { MockedProvider } from '@apollo/client/testing';
import { MemoryRouter } from 'react-router-dom';
import { NetworkAccountsTable } from './network-accounts-table';
describe('parseResultsToAccounts', () => {
it('should return an array of non-zero treasury accounts', () => {
const data: DeepPartial<ExplorerTreasuryQuery> = {
assetsConnection: {
edges: [
{
node: {
id: 'asset1',
networkTreasuryAccount: {
balance: '100',
},
},
},
{
node: {
id: 'has0assets',
networkTreasuryAccount: {
balance: '0',
},
},
},
{
node: {
id: 'asset3',
networkTreasuryAccount: {
balance: '50',
},
},
},
{
node: {
id: 'hasnonetworktreasuryaccount',
},
},
],
},
};
const result = parseResultsToAccounts(data as ExplorerTreasuryQuery);
expect(result).toHaveLength(2);
expect(result).toEqual([
{
assetId: 'asset1',
balance: '100',
type: 'ACCOUNT_TYPE_NETWORK_TREASURY',
},
{
assetId: 'asset3',
balance: '50',
type: 'ACCOUNT_TYPE_NETWORK_TREASURY',
},
]);
});
it('should return an empty array if no non-zero accounts are found', () => {
const data: DeepPartial<ExplorerTreasuryQuery> = {
assetsConnection: {
edges: [
{
node: {
id: 'asset1',
networkTreasuryAccount: {
balance: '0',
},
},
},
{
node: {
id: 'asset2',
networkTreasuryAccount: {
balance: '0',
},
},
},
],
},
};
const result = parseResultsToAccounts(data as ExplorerTreasuryQuery);
expect(result).toHaveLength(0);
expect(result).toEqual([]);
});
it('should handle missing data', () => {
const result = parseResultsToAccounts(
undefined as unknown as ExplorerTreasuryQuery
);
expect(result).toHaveLength(0);
expect(result).toEqual([]);
});
});
describe('NetworkAccountsTable', () => {
const mockData: ExplorerTreasuryQuery = {
assetsConnection: {
edges: [
{
node: {
id: 'asset1',
networkTreasuryAccount: {
balance: '100',
},
},
},
{
node: {
id: 'asset2',
networkTreasuryAccount: {
balance: '50',
},
},
},
],
},
};
const mocks = [
{
request: {
query: ExplorerTreasuryDocument,
},
result: {
data: mockData,
},
},
];
it('should render network accounts (as many as match - often just 1)', async () => {
render(
<MockedProvider mocks={mocks} addTypename={false}>
<MemoryRouter>
<NetworkAccountsTable />
</MemoryRouter>
</MockedProvider>
);
// Wait for the data to load
await screen.findByText('Loading...');
// Assert that the network accounts are rendered
expect(screen.getByText('asset1')).toBeInTheDocument();
expect(screen.getByText('asset2')).toBeInTheDocument();
});
it('should handle loading state', async () => {
render(
<MockedProvider mocks={mocks} addTypename={false}>
<MemoryRouter>
<NetworkAccountsTable />
</MemoryRouter>
</MockedProvider>
);
// Assert that the loading state is rendered
expect(screen.getByText('Loading...')).toBeInTheDocument();
});
});
@@ -0,0 +1,87 @@
import { AsyncRenderer } from '@vegaprotocol/ui-toolkit';
import {
type ExplorerTreasuryQuery,
useExplorerTreasuryQuery,
} from '../__generated__/Treasury';
import AssetBalance from '../../../components/asset-balance/asset-balance';
import { AssetLink } from '../../../components/links';
import { useMemo } from 'react';
import { useScreenDimensions } from '@vegaprotocol/react-helpers';
import { AssetIcon } from './asset-icon';
import { type NonZeroAccount } from '../network-treasury';
import { AccountType } from '@vegaprotocol/types';
import { removePaginationWrapper } from '@vegaprotocol/utils';
export const NetworkAccountsTable = () => {
const { data, loading, error } = useExplorerTreasuryQuery({
// This needs to ignore error as old assets may no longer properly resolve
errorPolicy: 'ignore',
});
const { screenSize } = useScreenDimensions();
const shouldRound = useMemo(
() => ['xs', 'sm', 'md', 'lg'].includes(screenSize),
[screenSize]
);
return (
<AsyncRenderer
data={data}
loading={loading}
error={error}
render={(data) => {
const c = parseResultsToAccounts(data);
return (
<section className="md:flex md:flex-row flex-wrap">
{c.map((a) => (
<div className="basis-1/2 md:basis-1/4">
<div className="bg-white rounded overflow-hidden shadow-lg dark:bg-black dark:border-slate-500 dark:border">
<div className="text-center p-6 bg-gray-100 dark:bg-slate-900 border-b dark:border-slate-500">
<p className="flex justify-center">
<AssetIcon symbol={a.assetId} />
</p>
<p className="mt-3" data-testid="name">
<AssetLink assetId={a.assetId} />
</p>
</div>
<div className="text-center py-5" data-testid="balance">
<AssetBalance
assetId={a.assetId}
price={a.balance}
showAssetSymbol={true}
rounded={shouldRound}
/>
</div>
</div>
</div>
))}
</section>
);
}}
/>
);
};
export function parseResultsToAccounts(
data: ExplorerTreasuryQuery
): NonZeroAccount[] {
const nonZeroAccounts: NonZeroAccount[] = [];
if (data?.assetsConnection?.edges) {
const edges = removePaginationWrapper(data?.assetsConnection?.edges);
if (edges) {
edges.forEach((edge) => {
if (
edge.networkTreasuryAccount &&
edge.networkTreasuryAccount?.balance !== '0'
) {
nonZeroAccounts.push({
assetId: edge.id,
balance: edge.networkTreasuryAccount?.balance,
type: AccountType.ACCOUNT_TYPE_NETWORK_TREASURY,
});
}
});
}
}
return nonZeroAccounts;
}
@@ -0,0 +1,262 @@
import { AccountType } from '@vegaprotocol/types';
import {
typeLabel,
getToAccountTypeLabel,
filterAccountTransfers,
} from './network-transfers-table';
import { render, screen } from '@testing-library/react';
import { NetworkTransfersTable } from './network-transfers-table';
import { MockedProvider } from '@apollo/client/testing';
import { MemoryRouter } from 'react-router-dom';
import {
ExplorerTreasuryTransfersDocument,
type ExplorerTreasuryTransfersQuery,
} from '../__generated__/TreasuryTransfers';
import type { DeepPartial } from '@apollo/client/utilities';
describe('typeLabel', () => {
it('should return "Transfer" for "OneOffTransfer" kind', () => {
expect(typeLabel('OneOffTransfer')).toBe('Transfer');
});
it('should return "Transfer" for "RecurringTransfer" kind', () => {
expect(typeLabel('RecurringTransfer')).toBe('Transfer');
});
it('should return "Governance" for "OneOffGovernanceTransfer" kind', () => {
expect(typeLabel('OneOffGovernanceTransfer')).toBe('Governance');
});
it('should return "Governance" for "RecurringGovernanceTransfer" kind', () => {
expect(typeLabel('RecurringGovernanceTransfer')).toBe('Governance');
});
it('should return "Unknown" for unknown kind', () => {
expect(typeLabel()).toBe('Unknown');
expect(typeLabel('')).toBe('Unknown');
expect(typeLabel('InvalidKind')).toBe('Unknown');
});
});
describe('getToAccountTypeLabel', () => {
it('should return "Treasury" when type is ACCOUNT_TYPE_NETWORK_TREASURY', () => {
expect(
getToAccountTypeLabel(AccountType.ACCOUNT_TYPE_NETWORK_TREASURY)
).toBe('Treasury');
});
it('should return "Fees" when type is any of the fee account types', () => {
expect(
getToAccountTypeLabel(AccountType.ACCOUNT_TYPE_FEES_INFRASTRUCTURE)
).toBe('Fees');
expect(getToAccountTypeLabel(AccountType.ACCOUNT_TYPE_FEES_MAKER)).toBe(
'Fees'
);
expect(getToAccountTypeLabel(AccountType.ACCOUNT_TYPE_FEES_LIQUIDITY)).toBe(
'Fees'
);
expect(
getToAccountTypeLabel(AccountType.ACCOUNT_TYPE_LP_LIQUIDITY_FEES)
).toBe('Fees');
expect(
getToAccountTypeLabel(
AccountType.ACCOUNT_TYPE_PENDING_FEE_REFERRAL_REWARD
)
).toBe('Fees');
});
it('should return "Insurance" when type is ACCOUNT_TYPE_GLOBAL_INSURANCE', () => {
expect(
getToAccountTypeLabel(AccountType.ACCOUNT_TYPE_GLOBAL_INSURANCE)
).toBe('Insurance');
});
it('should return "Rewards" when type is any of the reward account types', () => {
expect(getToAccountTypeLabel(AccountType.ACCOUNT_TYPE_GLOBAL_REWARD)).toBe(
'Rewards'
);
expect(
getToAccountTypeLabel(AccountType.ACCOUNT_TYPE_REWARD_AVERAGE_POSITION)
).toBe('Rewards');
expect(
getToAccountTypeLabel(AccountType.ACCOUNT_TYPE_REWARD_LP_RECEIVED_FEES)
).toBe('Rewards');
expect(
getToAccountTypeLabel(AccountType.ACCOUNT_TYPE_REWARD_MAKER_PAID_FEES)
).toBe('Rewards');
expect(
getToAccountTypeLabel(AccountType.ACCOUNT_TYPE_REWARD_MAKER_RECEIVED_FEES)
).toBe('Rewards');
expect(
getToAccountTypeLabel(AccountType.ACCOUNT_TYPE_REWARD_MARKET_PROPOSERS)
).toBe('Rewards');
expect(
getToAccountTypeLabel(AccountType.ACCOUNT_TYPE_REWARD_RELATIVE_RETURN)
).toBe('Rewards');
expect(
getToAccountTypeLabel(AccountType.ACCOUNT_TYPE_REWARD_RETURN_VOLATILITY)
).toBe('Rewards');
expect(
getToAccountTypeLabel(AccountType.ACCOUNT_TYPE_REWARD_VALIDATOR_RANKING)
).toBe('Rewards');
expect(getToAccountTypeLabel(AccountType.ACCOUNT_TYPE_VESTED_REWARDS)).toBe(
'Rewards'
);
expect(
getToAccountTypeLabel(AccountType.ACCOUNT_TYPE_VESTING_REWARDS)
).toBe('Rewards');
});
it('should return "Other" for any other type', () => {
expect(getToAccountTypeLabel(undefined)).toBe('Other');
expect(getToAccountTypeLabel('unknown' as AccountType)).toBe('Other');
});
});
describe('filterAccountTransfers', () => {
it('filters out transactions that are not to or from a treasury account', () => {
const data: DeepPartial<ExplorerTreasuryTransfersQuery> = {
transfersConnection: {
edges: [
{
node: {
transfer: {
toAccountType: AccountType.ACCOUNT_TYPE_NETWORK_TREASURY,
fromAccountType: AccountType.ACCOUNT_TYPE_FEES_INFRASTRUCTURE,
},
},
},
{
node: {
transfer: {
toAccountType: AccountType.ACCOUNT_TYPE_NETWORK_TREASURY,
fromAccountType:
AccountType.ACCOUNT_TYPE_REWARD_AVERAGE_POSITION,
},
},
},
{
node: {
transfer: {
toAccountType: AccountType.ACCOUNT_TYPE_FEES_INFRASTRUCTURE,
fromAccountType: AccountType.ACCOUNT_TYPE_NETWORK_TREASURY,
},
},
},
{
node: {
transfer: {
toAccountType: AccountType.ACCOUNT_TYPE_REWARD_AVERAGE_POSITION,
fromAccountType:
AccountType.ACCOUNT_TYPE_REWARD_LP_RECEIVED_FEES,
},
},
},
],
},
};
const result = filterAccountTransfers(
data as ExplorerTreasuryTransfersQuery
);
expect(result).toHaveLength(3);
});
it('should return an empty array if no transfers match the filter', () => {
const data: DeepPartial<ExplorerTreasuryTransfersQuery> = {
transfersConnection: {
edges: [
{
node: {
transfer: {
toAccountType: AccountType.ACCOUNT_TYPE_FEES_INFRASTRUCTURE,
fromAccountType: AccountType.ACCOUNT_TYPE_FEES_INFRASTRUCTURE,
},
},
},
{
node: {
transfer: {
toAccountType: AccountType.ACCOUNT_TYPE_REWARD_AVERAGE_POSITION,
fromAccountType:
AccountType.ACCOUNT_TYPE_REWARD_AVERAGE_POSITION,
},
},
},
],
},
};
const result = filterAccountTransfers(
data as ExplorerTreasuryTransfersQuery
);
expect(result).toHaveLength(0);
});
});
describe('NetworkTransfersTable', () => {
it('renders table headers correctly', async () => {
const mocks = [
{
request: {
query: ExplorerTreasuryTransfersDocument,
},
result: {
data: {
transfersConnection: {
edges: [
{
node: {
transfer: {
id: '123',
toAccountType: AccountType.ACCOUNT_TYPE_NETWORK_TREASURY,
fromAccountType:
AccountType.ACCOUNT_TYPE_NETWORK_TREASURY,
amount: '100',
asset: {
id: '1',
},
timestamp: '2022-01-01T00:00:00Z',
from: 'network',
to: '7100a8a82ef45adb9efa070cc821c6c5c48172d6dc5f842431549490fe5897a0',
reason: '',
status: 'COMPLETED',
kind: {
__typename: 'OneOffGovernanceTransfer',
deliverOn: '123',
},
},
},
},
],
},
},
},
},
];
render(
<MockedProvider mocks={mocks} addTypename={true}>
<MemoryRouter>
<NetworkTransfersTable />
</MemoryRouter>
</MockedProvider>
);
expect(await screen.findByText('Amount')).toBeInTheDocument();
expect(screen.getByText('Asset')).toBeInTheDocument();
expect(screen.getByText('Age')).toBeInTheDocument();
expect(screen.getByText('From')).toBeInTheDocument();
expect(screen.getByText('To')).toBeInTheDocument();
expect(screen.getByText('Status')).toBeInTheDocument();
expect(screen.getByText('Type')).toBeInTheDocument();
expect(screen.getByTestId('from-account').textContent).toEqual('Treasury');
expect(screen.getByTestId('to-account').textContent).toEqual('7100…97a0');
expect(screen.getByTestId('transfer-kind').textContent).toEqual(
'Governance'
);
});
});
@@ -0,0 +1,253 @@
import { AsyncRenderer, Icon } from '@vegaprotocol/ui-toolkit';
import AssetBalance from '../../../components/asset-balance/asset-balance';
import { AccountType, AccountTypeMapping } from '@vegaprotocol/types';
import { AssetLink, PartyLink } from '../../../components/links';
import {
type ExplorerTreasuryTransfersQuery,
useExplorerTreasuryTransfersQuery,
} from '../__generated__/TreasuryTransfers';
import { TimeAgo } from '../../../components/time-ago';
import { TransferStatusIcon } from '../../../components/txs/details/transfer/blocks/transfer-status';
import { t } from '@vegaprotocol/i18n';
import { IconNames } from '@blueprintjs/icons';
import { useMemo } from 'react';
import { useScreenDimensions } from '@vegaprotocol/react-helpers';
export const colours = {
INCOMING: '!fill-vega-green-600 text-vega-green-600 mr-2',
OUTGOING: '!fill-vega-pink-600 text-vega-pink-600 mr-2',
};
export const theadClasses =
'py-2 border text-center bg-vega-light-150 dark:bg-vega-dark-150';
export function getToAccountTypeLabel(type?: AccountType): string {
switch (type) {
case AccountType.ACCOUNT_TYPE_NETWORK_TREASURY:
return t('Treasury');
case AccountType.ACCOUNT_TYPE_FEES_INFRASTRUCTURE:
case AccountType.ACCOUNT_TYPE_FEES_MAKER:
case AccountType.ACCOUNT_TYPE_FEES_LIQUIDITY:
case AccountType.ACCOUNT_TYPE_LP_LIQUIDITY_FEES:
case AccountType.ACCOUNT_TYPE_PENDING_FEE_REFERRAL_REWARD:
return t('Fees');
case AccountType.ACCOUNT_TYPE_GLOBAL_INSURANCE:
return t('Insurance');
case AccountType.ACCOUNT_TYPE_GLOBAL_REWARD:
case AccountType.ACCOUNT_TYPE_REWARD_AVERAGE_POSITION:
case AccountType.ACCOUNT_TYPE_REWARD_LP_RECEIVED_FEES:
case AccountType.ACCOUNT_TYPE_REWARD_MAKER_PAID_FEES:
case AccountType.ACCOUNT_TYPE_REWARD_MAKER_RECEIVED_FEES:
case AccountType.ACCOUNT_TYPE_REWARD_MARKET_PROPOSERS:
case AccountType.ACCOUNT_TYPE_REWARD_RELATIVE_RETURN:
case AccountType.ACCOUNT_TYPE_REWARD_RETURN_VOLATILITY:
case AccountType.ACCOUNT_TYPE_REWARD_VALIDATOR_RANKING:
case AccountType.ACCOUNT_TYPE_VESTED_REWARDS:
case AccountType.ACCOUNT_TYPE_VESTING_REWARDS:
return t('Rewards');
default:
return t('Other');
}
}
export function typeLabel(kind?: string): string {
switch (kind) {
case 'OneOffTransfer':
case 'RecurringTransfer':
return t('Transfer');
case 'OneOffGovernanceTransfer':
case 'RecurringGovernanceTransfer':
return t('Governance');
default:
return t('Unknown');
}
}
export function filterAccountTransfers(data: ExplorerTreasuryTransfersQuery) {
return data.transfersConnection?.edges
?.filter((edge) => {
if (
edge?.node.transfer.toAccountType ===
AccountType.ACCOUNT_TYPE_NETWORK_TREASURY
) {
return true;
} else if (
edge?.node.transfer.fromAccountType ===
AccountType.ACCOUNT_TYPE_NETWORK_TREASURY
) {
return true;
}
return false;
})
.map((edge) => {
return edge?.node.transfer;
});
}
export const NetworkTransfersTable = () => {
const { data, loading, error } = useExplorerTreasuryTransfersQuery({
// This needs to ignore error as old assets may no longer properly resolve
errorPolicy: 'ignore',
});
const { screenSize } = useScreenDimensions();
const shouldRound = useMemo(
() => ['xs', 'sm', 'md', 'lg'].includes(screenSize),
[screenSize]
);
const shouldTruncate = useMemo(
() => ['xs', 'sm', 'md', 'lg', 'xl'].includes(screenSize),
[screenSize]
);
const shouldHideColumns = useMemo(
() => ['xs', 'sm'].includes(screenSize),
[screenSize]
);
return (
<section>
<AsyncRenderer
data={data}
loading={loading}
error={error}
render={(data) => {
const c = filterAccountTransfers(data);
if (!c) {
return null;
}
return (
<table className="table-fixed border-spacing-3">
<thead>
<tr>
<th className={theadClasses}>{t('Amount')}</th>
<th className={theadClasses}>{t('Asset')}</th>
<th className={theadClasses}>{t('Age')}</th>
<th className={theadClasses}>{t('From')}</th>
<th className={theadClasses}>{t('To')}</th>
<th
className={`${theadClasses} ${
shouldHideColumns ? 'hidden' : ''
}`}
>
{t('Status')}
</th>
<th
className={`${theadClasses} ${
shouldHideColumns ? 'hidden' : ''
}`}
>
{t('Type')}
</th>
</tr>
</thead>
<tbody>
{c.map((a) => {
const isIncoming =
a?.toAccountType ===
AccountType.ACCOUNT_TYPE_NETWORK_TREASURY;
return (
<tr>
{a && a.amount && a.asset && (
<td
className={`px-2 py-1 border whitespace-nowrap text-right ${
isIncoming ? colours.INCOMING : colours.OUTGOING
}`}
title={a.amount}
>
{a &&
a.toAccountType ===
AccountType.ACCOUNT_TYPE_NETWORK_TREASURY ? (
<Icon
name={IconNames.PLUS}
className={colours.INCOMING}
/>
) : (
<Icon
name={IconNames.MINUS}
className={colours.OUTGOING}
/>
)}
<AssetBalance
assetId={a.asset.id}
price={a.amount}
showAssetLink={false}
rounded={shouldRound}
/>
</td>
)}
<td className="px-2 py-1 border whitespace-nowrap">
{a && a.amount && a.asset && (
<AssetLink
assetId={a.asset.id}
showAssetSymbol={true}
/>
)}
</td>
<td className="px-2 py-1 border">
{a && a.timestamp && <TimeAgo date={a.timestamp} />}
</td>
<td
className="px-2 py-1 border"
data-testid="from-account"
>
{a && a.from && (
<PartyLink
id={a.from}
truncate={true}
truncateLength={shouldTruncate ? 4 : 15}
networkLabel={t('Treasury')}
/>
)}
</td>
<td className="px-2 py-1 border" data-testid="to-account">
{a && a.to && (
<PartyLink
id={a.to}
networkLabel={t('Treasury')}
truncate={true}
truncateLength={shouldTruncate ? 4 : 15}
/>
)}
{a && !a.to && (
<span
className="underline decoration-dotted"
title={AccountTypeMapping[a.toAccountType]}
>
{getToAccountTypeLabel(a.toAccountType)}
</span>
)}
</td>
<td
className={`px-2 py-1 border text-center ${
shouldHideColumns ? 'hidden' : ''
}`}
>
{a && a.status && (
<TransferStatusIcon status={a.status} />
)}
</td>
<td
className={`px-2 py-1 border ${
shouldHideColumns ? 'hidden' : ''
}`}
>
<span
className="underline decoration-dotted"
title={a?.kind.__typename}
data-testid="transfer-kind"
>
{a && typeLabel(a.kind.__typename)}
</span>
</td>
</tr>
);
})}
</tbody>
</table>
);
}}
/>
</section>
);
};
@@ -0,0 +1 @@
export * from './network-treasury';
@@ -0,0 +1,28 @@
import { useDocumentTitle } from '../../hooks/use-document-title';
import type { AccountType } from '@vegaprotocol/types';
import { t } from '@vegaprotocol/i18n';
import { RouteTitle } from '../../components/route-title';
import { NetworkAccountsTable } from './components/network-accounts-table';
import { NetworkTransfersTable } from './components/network-transfers-table';
export type NonZeroAccount = {
assetId: string;
balance: string;
type: AccountType;
};
export const NetworkTreasury = () => {
useDocumentTitle(['Network Treasury']);
return (
<section>
<RouteTitle data-testid="block-header">{t(`Treasury`)}</RouteTitle>
<div>
<NetworkAccountsTable />
</div>
<div className="mt-5">
<h2 className="text-3xl mb-2">{t('Transfers')}</h2>
<NetworkTransfersTable />
</div>
</section>
);
};
@@ -2,7 +2,7 @@ export const proposalsData = {
proposalsConnection: {
edges: [
{
node: {
proposalNode: {
id: 'e8ba9d268e12514644fd1fc7ff289292f4ce6489cc32cc73133aea52c04aef89',
rationale: {
title: 'Add asset Wrapped Ether',
@@ -56,7 +56,7 @@ export const proposalsData = {
__typename: 'ProposalEdge',
},
{
node: {
proposalNode: {
id: 'd848fc7881f13d366df5f61ab139d5fcfa72bf838151bb51b54381870e357931',
rationale: {
title: 'Add asset Dai Stablecoin',
@@ -110,60 +110,7 @@ export const proposalsData = {
__typename: 'ProposalEdge',
},
{
node: {
id: 'ccbd651b4a1167fd73c4a0340ac759fa0a31ca487ad46a13254b741ad71947ed',
rationale: {
title: 'New DAI market',
description: 'New DAI market',
__typename: 'ProposalRationale',
},
reference: '0VFQusmmESdrP5GuL8naB6lxfoE3RPGaEeo7abdN',
state: 'STATE_ENACTED',
datetime: '2022-11-26T19:36:19.26034Z',
rejectionReason: null,
party: {
id: '69464e35bcb8e8a2900ca0f87acaf252d50cf2ab2fc73694845a16b7c8a0dc6f',
__typename: 'Party',
},
errorDetails: null,
terms: {
closingDatetime: '2022-11-26T19:36:42Z',
enactmentDatetime: '2023-03-22T13:57:37Z',
change: {
instrument: {
name: 'UNIDAI Monthly (Dec 2022)',
code: 'UNIDAI.MF21',
product: {
settlementAsset: { symbol: 'tDAI', __typename: 'Asset' },
__typename: 'FutureProduct',
},
__typename: 'InstrumentConfiguration',
},
__typename: 'NewMarket',
},
__typename: 'ProposalTerms',
},
votes: {
yes: {
totalTokens: '0',
totalNumber: '0',
totalEquityLikeShareWeight: '0',
__typename: 'ProposalVoteSide',
},
no: {
totalTokens: '0',
totalNumber: '0',
totalEquityLikeShareWeight: '0',
__typename: 'ProposalVoteSide',
},
__typename: 'ProposalVotes',
},
__typename: 'Proposal',
},
__typename: 'ProposalEdge',
},
{
node: {
proposalNode: {
id: 'bc70383f0e9515b15542cf4c63590cd2ca46b3363ba7c4a72af0e62112b3951b',
rationale: {
title: 'USDC-III',
@@ -217,60 +164,7 @@ export const proposalsData = {
__typename: 'ProposalEdge',
},
{
node: {
id: '9d9b2a9d0179d0e4ccb317f6c4a5db0b905d893190bfb5e5499985ef313281c8',
rationale: {
title: 'New BTC market',
description: 'New BTC market',
__typename: 'ProposalRationale',
},
reference: 'AXeRWS3TvLBFDgWOSHQpKFJf3NTbnWK6310q02fZ',
state: 'STATE_ENACTED',
datetime: '2022-11-26T19:36:19.26034Z',
rejectionReason: null,
party: {
id: '69464e35bcb8e8a2900ca0f87acaf252d50cf2ab2fc73694845a16b7c8a0dc6f',
__typename: 'Party',
},
errorDetails: null,
terms: {
closingDatetime: '2022-11-26T19:36:42Z',
enactmentDatetime: '2023-03-22T13:57:37Z',
change: {
instrument: {
name: 'ETHBTC Quarterly (Feb 2023)',
code: 'ETHBTC.QM21',
product: {
settlementAsset: { symbol: 'tBTC', __typename: 'Asset' },
__typename: 'FutureProduct',
},
__typename: 'InstrumentConfiguration',
},
__typename: 'NewMarket',
},
__typename: 'ProposalTerms',
},
votes: {
yes: {
totalTokens: '0',
totalNumber: '0',
totalEquityLikeShareWeight: '0',
__typename: 'ProposalVoteSide',
},
no: {
totalTokens: '0',
totalNumber: '0',
totalEquityLikeShareWeight: '0',
__typename: 'ProposalVoteSide',
},
__typename: 'ProposalVotes',
},
__typename: 'Proposal',
},
__typename: 'ProposalEdge',
},
{
node: {
proposalNode: {
id: '9c48796e7988769ededc2b2b02220b00e93f65f23e8141bf1fd23a6983d95943',
rationale: {
title: 'Update governance.proposal.asset.requiredMajority',
@@ -236,7 +236,7 @@ context(
});
// 1002-STKE-041 1002-STKE-053
it(
it.skip(
'Able to remove part of a stake against a validator',
// @ts-ignore clash between jest and cypress
{ tags: '@smoke' },
@@ -27,7 +27,7 @@ context('Home Page - verify elements on page', { tags: '@smoke' }, function () {
cy.getByTestId('app-announcement').should('not.exist');
});
it('should show open or enacted proposals without proposal summary', function () {
it.skip('should show open or enacted proposals without proposal summary', function () {
cy.get('body').then(($body) => {
if (!$body.find('[data-testid="proposals-list-item"]').length) {
cy.createMarket();
@@ -82,20 +82,20 @@ context('Home Page - verify elements on page', { tags: '@smoke' }, function () {
it('should have information on active nodes', function () {
cy.getByTestId('node-information')
.first()
.should('contain.text', '2')
.should('contain.text', '1')
.and('contain.text', 'active nodes');
});
it('should have information on consensus nodes', function () {
cy.getByTestId('node-information')
.last()
.should('contain.text', '2')
.should('contain.text', '1')
.and('contain.text', 'consensus nodes');
});
it('should contain link to specific validators', function () {
cy.getByTestId('validators')
.should('have.length', '2')
.should('have.length', '1')
.each(($validator) => {
cy.wrap($validator).find('a').should('have.attr', 'href');
});
@@ -159,7 +159,7 @@ context('Home Page - verify elements on page', { tags: '@smoke' }, function () {
cy.getByTestId('node-url-custom').click({ force: true });
cy.get('input').should('exist');
cy.getByTestId('connect').should('be.disabled');
cy.getByTestId('icon-cross').click();
cy.getByTestId('dialog-close').click();
});
it('should display eth data', function () {
@@ -189,7 +189,7 @@ context('Home Page - verify elements on page', { tags: '@smoke' }, function () {
cy.viewport('iphone-xr');
});
it('should have burger button', () => {
it.skip('should have burger button', () => {
cy.getByTestId('button-menu-drawer').should('be.visible').click();
cy.getByTestId('menu-drawer').should('be.visible');
});
@@ -40,10 +40,10 @@ context('View functionality with public key', { tags: '@smoke' }, function () {
verifyConnectedToPubKey();
});
it('Able to connect public key via wallet and view assets in wallet', function () {
it.skip('Able to connect public key via wallet and view assets in wallet', function () {
verifyConnectedToPubKey();
cy.getByTestId('currency-title', { timeout: 10000 })
.should('have.length.at.least', 4)
.should('have.length.at.least', 2)
.and('contain.text', 'USDC (fake)');
});
+3 -3
View File
@@ -33,7 +33,7 @@ LC_ALL="en_US.UTF-8"
# Cosmic elevator flags
NX_SUCCESSOR_MARKETS=true
NX_METAMASK_SNAPS=true
NX_PRODUCT_PERPETUALS=false
NX_UPDATE_MARKET_STATE=false
NX_PRODUCT_PERPETUALS=true
NX_UPDATE_MARKET_STATE=true
NX_REFERRALS=true
NX_GOVERNANCE_TRANSFERS=false
NX_GOVERNANCE_TRANSFERS=true
+3
View File
@@ -62,6 +62,9 @@ const cache: InMemoryCacheConfig = {
Account: {
keyFields: false,
},
Instrument: {
keyFields: ['code'],
},
Delegation: {
keyFields: false,
// Only get full updates
@@ -1,5 +1,5 @@
import classnames from 'classnames';
import { Icon } from '@vegaprotocol/ui-toolkit';
import { VegaIcon, VegaIconNames } from '@vegaprotocol/ui-toolkit';
import type { Dispatch, SetStateAction, ReactNode } from 'react';
interface CollapsibleToggleProps {
@@ -30,7 +30,7 @@ export const CollapsibleToggle = ({
<div className="flex items-center gap-3">
{children}
<div className={classes} data-testid="toggle-icon-wrapper">
<Icon name="chevron-down" size={8} />
<VegaIcon name={VegaIconNames.CHEVRON_DOWN} size={20} />
</div>
</div>
</button>
@@ -1,7 +1,8 @@
import classNames from 'classnames';
import { type ReactNode } from 'react';
interface HeadingProps {
title?: string;
title?: ReactNode;
centerContent?: boolean;
marginTop?: boolean;
marginBottom?: boolean;
+19 -24
View File
@@ -1,3 +1,4 @@
import compact from 'lodash/compact';
import { useMemo } from 'react';
import { useTranslation } from 'react-i18next';
import { Link } from 'react-router-dom';
@@ -12,10 +13,10 @@ import { useRefreshAfterEpoch } from '../../hooks/use-refresh-after-epoch';
import { ProposalsListItem } from '../proposals/components/proposals-list-item';
import { ProtocolUpgradeProposalsListItem } from '../proposals/components/protocol-upgrade-proposals-list-item/protocol-upgrade-proposals-list-item';
import Routes from '../routes';
import { ExternalLinks, useFeatureFlags } from '@vegaprotocol/environment';
import { ExternalLinks } from '@vegaprotocol/environment';
import { removePaginationWrapper } from '@vegaprotocol/utils';
import { useNodesQuery } from '../staking/home/__generated__/Nodes';
import { useProposalsQuery } from '../proposals/proposals/__generated__/Proposals';
import { useProposalsQuery } from '../proposals/__generated__/Proposals';
import {
getNotRejectedProposals,
getNotRejectedProtocolUpgradeProposals,
@@ -31,7 +32,7 @@ import {
orderByUpgradeBlockHeight,
} from '../proposals/components/proposals-list/proposals-list';
import { BigNumber } from '../../lib/bignumber';
import { type Proposal } from '../proposals/types';
import { type Proposal, type BatchProposal } from '../proposals/types';
const nodesToShow = 6;
@@ -39,7 +40,7 @@ const HomeProposals = ({
proposals,
protocolUpgradeProposals,
}: {
proposals: Proposal[];
proposals: Array<Proposal | BatchProposal>;
protocolUpgradeProposals: ProtocolUpgradeProposalFieldsFragment[];
}) => {
const { t } = useTranslation();
@@ -60,12 +61,9 @@ const HomeProposals = ({
<ProtocolUpgradeProposalsListItem key={index} proposal={proposal} />
))}
{proposals.map(
(proposal) =>
proposal?.id && (
<ProposalsListItem key={proposal.id} proposal={proposal} />
)
)}
{compact(proposals).map((proposal) => {
return <ProposalsListItem key={proposal.id} proposal={proposal} />;
})}
</ul>
<div className="mt-6">
@@ -175,7 +173,6 @@ export const ValidatorDetailsLink = ({
};
const GovernanceHome = ({ name }: RouteChildProps) => {
const featureFlags = useFeatureFlags((state) => state.flags);
useDocumentTitle(name);
const { t } = useTranslation();
const {
@@ -186,11 +183,6 @@ const GovernanceHome = ({ name }: RouteChildProps) => {
pollInterval: 5000,
fetchPolicy: 'network-only',
errorPolicy: 'ignore',
variables: {
includeNewMarketProductFields: !!featureFlags.PRODUCT_PERPETUALS,
includeUpdateMarketStates: !!featureFlags.UPDATE_MARKET_STATE,
includeUpdateReferralPrograms: !!featureFlags.REFERRALS,
},
});
const {
@@ -212,15 +204,18 @@ const GovernanceHome = ({ name }: RouteChildProps) => {
useRefreshAfterEpoch(validatorsData?.epoch.timestamps.expiry, refetch);
const proposals = useMemo(
() =>
proposalsData
? getNotRejectedProposals(
removePaginationWrapper(proposalsData.proposalsConnection?.edges)
const proposals = useMemo(() => {
if (!proposalsData?.proposalsConnection?.edges?.length) return [];
return proposalsData
? getNotRejectedProposals(
compact(
proposalsData.proposalsConnection.edges.map(
(edge) => edge?.proposalNode
)
)
: [],
[proposalsData]
);
)
: [];
}, [proposalsData]);
const sortedProposals = useMemo(
() => orderByDate(proposals).reverse(),
@@ -0,0 +1,493 @@
fragment UpdateMarketStates on UpdateMarketState {
__typename
updateType
market {
decimalPlaces
id
tradableInstrument {
instrument {
product {
__typename
... on Future {
quoteName
}
... on Perpetual {
quoteName
}
}
name
code
}
}
}
updateType
price
}
fragment UpdateReferralPrograms on UpdateReferralProgram {
__typename
benefitTiers {
minimumEpochs
minimumRunningNotionalTakerVolume
referralDiscountFactor
referralRewardFactor
}
endOfProgram: endOfProgramTimestamp
windowLength
stakingTiers {
minimumStakedTokens
referralRewardMultiplier
}
}
fragment UpdateVolumeDiscountPrograms on UpdateVolumeDiscountProgram {
__typename
benefitTiers {
minimumRunningNotionalTakerVolume
volumeDiscountFactor
}
endOfProgramTimestamp
windowLength
}
# I prefix due to clash in libs/proposals
fragment IUpdateMarketFields on UpdateMarket {
__typename
marketId
updateMarketConfiguration {
instrument {
code
product {
... on UpdateFutureProduct {
quoteName
dataSourceSpecForSettlementData {
sourceType {
... on DataSourceDefinitionInternal {
sourceType {
... on DataSourceSpecConfigurationTime {
conditions {
operator
value
}
}
}
}
... on DataSourceDefinitionExternal {
sourceType {
... on DataSourceSpecConfiguration {
signers {
signer {
... on PubKey {
key
}
... on ETHAddress {
address
}
}
}
filters {
key {
name
type
}
conditions {
operator
value
}
}
}
}
}
}
}
# dataSourceSpecForTradingTermination {
# sourceType {
# ... on DataSourceDefinitionInternal {
# sourceType {
# ... on DataSourceSpecConfigurationTime {
# conditions {
# operator
# value
# }
# }
# }
# }
# ... on DataSourceDefinitionExternal {
# sourceType {
# ... on DataSourceSpecConfiguration {
# signers {
# signer {
# ... on PubKey {
# key
# }
# ... on ETHAddress {
# address
# }
# }
# }
# filters {
# key {
# name
# type
# }
# conditions {
# operator
# value
# }
# }
# }
# }
# }
# }
# }
dataSourceSpecBinding {
settlementDataProperty
tradingTerminationProperty
}
}
... on UpdatePerpetualProduct {
quoteName
dataSourceSpecForSettlementData {
sourceType {
... on DataSourceDefinitionInternal {
sourceType {
... on DataSourceSpecConfigurationTime {
conditions {
operator
value
}
}
}
}
... on DataSourceDefinitionExternal {
sourceType {
... on DataSourceSpecConfiguration {
signers {
signer {
... on PubKey {
key
}
... on ETHAddress {
address
}
}
}
filters {
key {
name
type
}
conditions {
operator
value
}
}
}
}
}
}
}
dataSourceSpecBinding {
settlementDataProperty
settlementScheduleProperty
}
}
}
}
metadata
priceMonitoringParameters {
triggers {
horizonSecs
probability
auctionExtensionSecs
}
}
liquidityMonitoringParameters {
targetStakeParameters {
timeWindow
scalingFactor
}
}
riskParameters {
... on UpdateMarketSimpleRiskModel {
simple {
factorLong
factorShort
}
}
... on UpdateMarketLogNormalRiskModel {
logNormal {
riskAversionParameter
tau
params {
r
sigma
mu
}
}
}
}
}
}
# I prefix due to clash in libs/proposals
fragment INewMarketFields on NewMarket {
__typename
decimalPlaces
metadata
riskParameters {
... on LogNormalRiskModel {
riskAversionParameter
tau
params {
mu
r
sigma
}
}
... on SimpleRiskModel {
params {
factorLong
factorShort
}
}
}
successorConfiguration {
parentMarketId
}
instrument {
name
code
product {
... on FutureProduct {
settlementAsset {
id
name
symbol
decimals
quantum
}
quoteName
dataSourceSpecBinding {
settlementDataProperty
tradingTerminationProperty
}
dataSourceSpecForSettlementData {
sourceType {
... on DataSourceDefinitionInternal {
sourceType {
... on DataSourceSpecConfigurationTime {
conditions {
operator
value
}
}
}
}
... on DataSourceDefinitionExternal {
sourceType {
... on DataSourceSpecConfiguration {
signers {
signer {
... on PubKey {
key
}
... on ETHAddress {
address
}
}
}
filters {
key {
name
type
}
conditions {
operator
value
}
}
}
}
}
}
}
}
... on PerpetualProduct {
settlementAsset {
id
name
symbol
decimals
quantum
}
quoteName
}
}
}
priceMonitoringParameters {
triggers {
horizonSecs
probability
auctionExtensionSecs
}
}
liquidityMonitoringParameters {
targetStakeParameters {
timeWindow
scalingFactor
}
}
positionDecimalPlaces
linearSlippageFactor
}
# I prefix due to clash in lib/proposals
fragment INewAssetFields on NewAsset {
__typename
name
symbol
decimals
quantum
source {
... on BuiltinAsset {
maxFaucetAmountMint
}
... on ERC20 {
contractAddress
withdrawThreshold
lifetimeLimit
}
}
}
# I prefix due to clash in libs/proposals
fragment IUpdateAssetFields on UpdateAsset {
__typename
assetId
quantum
source {
... on UpdateERC20 {
lifetimeLimit
withdrawThreshold
}
}
}
# I prefix due to clash in libs/proposals
fragment IUpdateNetworkParameterFields on UpdateNetworkParameter {
__typename
networkParameter {
key
value
}
}
fragment VoteFields on ProposalVotes {
yes {
totalTokens
totalNumber
totalEquityLikeShareWeight
}
no {
totalTokens
totalNumber
totalEquityLikeShareWeight
}
}
fragment ProposalTermsFields on ProposalTerms {
closingDatetime
enactmentDatetime
change {
__typename
...UpdateMarketStates
...UpdateReferralPrograms
...UpdateVolumeDiscountPrograms
...INewMarketFields
...IUpdateMarketFields
...INewAssetFields
...IUpdateNetworkParameterFields
...IUpdateAssetFields
}
}
fragment ProposalFields on Proposal {
id
rationale {
title
description
}
reference
state
datetime
rejectionReason
party {
id
}
errorDetails
terms {
...ProposalTermsFields
}
votes {
...VoteFields
}
}
fragment BatchProposalFields on BatchProposal {
id
rationale {
title
description
}
reference
state
datetime
rejectionReason
party {
id
}
errorDetails
batchTerms {
closingDatetime
changes {
enactmentDatetime
}
}
subProposals {
datetime
terms {
...ProposalTermsFields
}
}
votes {
...VoteFields
}
}
query Proposals {
proposalsConnection {
edges {
proposalNode {
__typename
... on Proposal {
...ProposalFields
}
... on BatchProposal {
...BatchProposalFields
}
}
}
}
}
query Proposal($proposalId: ID!) {
proposal(id: $proposalId) {
... on Proposal {
...ProposalFields
}
... on BatchProposal {
...BatchProposalFields
}
}
}
File diff suppressed because one or more lines are too long
@@ -3,9 +3,13 @@ import { ProposalState } from '@vegaprotocol/types';
import { ProposalInfoLabel } from '../proposal-info-label';
import { type ReactNode } from 'react';
import { type ProposalInfoLabelVariant } from '../proposal-info-label';
import { type Proposal } from '../../types';
import { type Proposal, type BatchProposal } from '../../types';
export const CurrentProposalState = ({ proposal }: { proposal: Proposal }) => {
export const CurrentProposalState = ({
proposal,
}: {
proposal: Proposal | BatchProposal;
}) => {
const { t } = useTranslation();
let proposalStatus: ReactNode;
let variant = 'tertiary' as ProposalInfoLabelVariant;
@@ -79,13 +79,22 @@ export const ListAsset = ({
) {
return null;
}
if (data.asset.source.__typename !== 'ERC20') return null;
if (data.asset.source.__typename !== 'ERC20') {
return null;
}
if (data.asset.status !== Schema.AssetStatus.STATUS_PENDING_LISTING) {
return null;
}
if (errorAsset || errorBundle) return null;
if (errorAsset || errorBundle) {
return null;
}
const { assetSource, signatures, vegaAssetId, nonce } =
assetData.erc20ListAssetBundle;
return (
<div className="mb-8">
<h3 className="mb-2 text-xl">{t('ListAsset')}</h3>
@@ -2,19 +2,53 @@ import { useState } from 'react';
import { useTranslation } from 'react-i18next';
import { SubHeading } from '../../../../components/heading';
import { CollapsibleToggle } from '../../../../components/collapsible-toggle';
import { AssetDetail, AssetDetailsTable } from '@vegaprotocol/assets';
import type { AssetFieldsFragment } from '@vegaprotocol/assets';
import {
AssetDetail,
AssetDetailsTable,
useAssetQuery,
} from '@vegaprotocol/assets';
import { removePaginationWrapper } from '@vegaprotocol/utils';
import {
type INewAssetFieldsFragment,
type IUpdateAssetFieldsFragment,
} from '../../__generated__/Proposals';
export const ProposalAssetDetails = ({
asset,
originalAsset,
change,
assetId,
}: {
asset: AssetFieldsFragment;
originalAsset?: AssetFieldsFragment;
change: IUpdateAssetFieldsFragment | INewAssetFieldsFragment;
assetId: string;
}) => {
const { t } = useTranslation();
const [showAssetDetails, setShowAssetDetails] = useState(false);
const { data } = useAssetQuery({
fetchPolicy: 'network-only',
variables: {
assetId,
},
});
if (!data) return null;
let asset = removePaginationWrapper(data?.assetsConnection?.edges)[0];
const originalAsset = asset;
if (change.__typename === 'UpdateAsset') {
asset = {
...asset,
quantum: change.quantum,
source: { ...asset.source },
};
if (asset.source.__typename === 'ERC20') {
asset.source.lifetimeLimit = change.source.lifetimeLimit;
asset.source.withdrawThreshold = change.source.withdrawThreshold;
}
}
return (
<section data-testid="proposal-asset-details">
<CollapsibleToggle
@@ -6,16 +6,46 @@ import {
KeyValueTableRow,
RoundedWrapper,
} from '@vegaprotocol/ui-toolkit';
import { type Proposal } from '../../types';
import { type Proposal, type BatchProposal } from '../../types';
interface ProposalChangeTableProps {
proposal: Proposal;
proposal: Proposal | BatchProposal;
}
export const ProposalChangeTable = ({ proposal }: ProposalChangeTableProps) => {
const { t } = useTranslation();
const terms = proposal?.terms;
const closingTimeRow =
proposal.__typename === 'Proposal' ? (
<KeyValueTableRow>
{isFuture(new Date(proposal.terms?.closingDatetime))
? t('closesOn')
: t('closedOn')}
{formatDateWithLocalTimezone(new Date(proposal.terms?.closingDatetime))}
</KeyValueTableRow>
) : proposal.__typename === 'BatchProposal' ? (
<KeyValueTableRow>
{isFuture(new Date(proposal.batchTerms?.closingDatetime))
? t('closesOn')
: t('closedOn')}
{formatDateWithLocalTimezone(
new Date(proposal.batchTerms?.closingDatetime)
)}
</KeyValueTableRow>
) : null;
const enactmentRow =
proposal.__typename === 'Proposal' &&
proposal.terms.change.__typename !== 'NewFreeform' ? (
<KeyValueTableRow>
{isFuture(new Date(proposal.terms?.enactmentDatetime || 0))
? t('proposedEnactment')
: t('enactedOn')}
{formatDateWithLocalTimezone(
new Date(proposal.terms?.enactmentDatetime || 0)
)}
</KeyValueTableRow>
) : null;
return (
<RoundedWrapper paddingBottom={true}>
@@ -24,22 +54,8 @@ export const ProposalChangeTable = ({ proposal }: ProposalChangeTableProps) => {
{t('id')}
{proposal?.id}
</KeyValueTableRow>
<KeyValueTableRow>
{isFuture(new Date(terms?.closingDatetime))
? t('closesOn')
: t('closedOn')}
{formatDateWithLocalTimezone(new Date(terms?.closingDatetime))}
</KeyValueTableRow>
{terms?.change.__typename !== 'NewFreeform' ? (
<KeyValueTableRow>
{isFuture(new Date(terms?.enactmentDatetime || 0))
? t('proposedEnactment')
: t('enactedOn')}
{formatDateWithLocalTimezone(
new Date(terms?.enactmentDatetime || 0)
)}
</KeyValueTableRow>
) : null}
{closingTimeRow}
{enactmentRow}
<KeyValueTableRow>
{t('proposedBy')}
<span style={{ wordBreak: 'break-word' }}>{proposal?.party.id}</span>
@@ -18,20 +18,21 @@ import {
nextWeek,
mockWalletContext,
createUserVoteQueryMock,
networkParamsQueryMock,
} from '../../test-helpers/mocks';
import { useFeatureFlags } from '@vegaprotocol/environment';
import { BrowserRouter } from 'react-router-dom';
import { VoteState } from '../vote-details/use-user-vote';
import { useNewTransferProposalDetails } from '@vegaprotocol/proposals';
import {
InstrumentDetailsDocument,
useNewTransferProposalDetails,
type InstrumentDetailsQuery,
type InstrumentDetailsQueryVariables,
} from '@vegaprotocol/proposals';
import { type MockedResponse } from '@apollo/client/testing';
import { type Proposal } from '../../types';
jest.mock('@vegaprotocol/proposals', () => ({
...jest.requireActual('@vegaprotocol/proposals'),
useSuccessorMarketProposalDetails: () => ({
code: 'PARENT_CODE',
parentMarketId: 'PARENT_ID',
}),
useNewTransferProposalDetails: jest.fn(),
}));
@@ -44,7 +45,7 @@ const renderComponent = (
render(
<AppStateProvider>
<BrowserRouter>
<MockedProvider mocks={mocks}>
<MockedProvider mocks={[networkParamsQueryMock, ...mocks]}>
<VegaWalletContext.Provider value={mockWalletContext}>
<ProposalHeader
proposal={proposal}
@@ -61,10 +62,39 @@ describe('Proposal header', () => {
afterAll(() => {
jest.clearAllMocks();
});
it('Renders New market proposal', () => {
useFeatureFlags.setState({ flags: { SUCCESSOR_MARKETS: true } });
it('Renders New market proposal', async () => {
const parentMarketId = 'parent-id';
const parentCode = 'parent-code';
const parentName = 'parent-name';
const mock: MockedResponse<
InstrumentDetailsQuery,
InstrumentDetailsQueryVariables
> = {
request: {
query: InstrumentDetailsDocument,
variables: {
marketId: parentMarketId,
},
},
result: {
data: {
market: {
__typename: 'Market',
tradableInstrument: {
__typename: 'TradableInstrument',
instrument: {
__typename: 'Instrument',
code: parentCode,
name: parentName,
},
},
},
},
},
};
renderComponent(
// @ts-ignore we aren't using batch yet
generateProposal({
rationale: {
title: 'New some market',
@@ -73,6 +103,9 @@ describe('Proposal header', () => {
terms: {
change: {
__typename: 'NewMarket',
successorConfiguration: {
parentMarketId,
},
instrument: {
__typename: 'InstrumentConfiguration',
name: 'Some market',
@@ -87,7 +120,9 @@ describe('Proposal header', () => {
},
},
},
})
}),
undefined,
[mock]
);
expect(screen.getByTestId('proposal-title')).toHaveTextContent(
'New some market'
@@ -96,14 +131,13 @@ describe('Proposal header', () => {
expect(screen.getByTestId('proposal-details')).toHaveTextContent(
'tGBP settled future.'
);
expect(screen.getByTestId('proposal-successor-info')).toHaveTextContent(
'PARENT_CODE'
);
expect(
await screen.findByTestId('proposal-successor-info')
).toHaveTextContent(parentCode);
});
it('Renders Update market proposal', () => {
renderComponent(
// @ts-ignore we aren't using batch yet
generateProposal({
rationale: {
title: 'New market id',
@@ -126,13 +160,12 @@ describe('Proposal header', () => {
screen.queryByTestId('proposal-description')
).not.toBeInTheDocument();
expect(screen.getByTestId('proposal-details')).toHaveTextContent(
'Update to market ID: MarketId'
'Update to market: MarketId'
);
});
it('Renders New asset proposal - ERC20', () => {
renderComponent(
// @ts-ignore we aren't using batch yet
generateProposal({
rationale: {
title: 'New asset: Fake currency',
@@ -162,8 +195,10 @@ describe('Proposal header', () => {
it('Renders New asset proposal - BuiltInAsset', () => {
renderComponent(
// @ts-ignore we aren't using batch yet
generateProposal({
rationale: {
title: 'New asset',
},
terms: {
change: {
__typename: 'NewAsset',
@@ -177,9 +212,7 @@ describe('Proposal header', () => {
},
})
);
expect(screen.getByTestId('proposal-title')).toHaveTextContent(
'New asset proposal'
);
expect(screen.getByTestId('proposal-title')).toHaveTextContent('New asset');
expect(screen.getByTestId('proposal-type')).toHaveTextContent('New asset');
expect(screen.getByTestId('proposal-details')).toHaveTextContent(
'Symbol: BIA. Max faucet amount mint: 300'
@@ -188,7 +221,6 @@ describe('Proposal header', () => {
it('Renders Update network', () => {
renderComponent(
// @ts-ignore we aren't using batch yet
generateProposal({
rationale: {
title: 'Network parameter',
@@ -218,7 +250,6 @@ describe('Proposal header', () => {
it('Renders Freeform proposal - short rationale', () => {
renderComponent(
// @ts-ignore we aren't using batch yet
generateProposal({
id: 'short',
rationale: {
@@ -240,7 +271,6 @@ describe('Proposal header', () => {
it('Renders Freeform proposal - long rationale (105 chars) - listing', () => {
renderComponent(
// @ts-ignore we aren't using batch yet
generateProposal({
id: 'long',
rationale: {
@@ -266,7 +296,6 @@ describe('Proposal header', () => {
// Remove once proposals have rationale and re-enable above tests
it('Renders Freeform proposal - id for title', () => {
renderComponent(
// @ts-ignore we aren't using batch yet
generateProposal({
id: 'freeform id',
rationale: {
@@ -323,7 +352,6 @@ describe('Proposal header', () => {
it('Renders proposal state: Enacted', () => {
renderComponent(
// @ts-ignore we aren't using batch yet
generateProposal({
state: ProposalState.STATE_ENACTED,
terms: {
@@ -336,7 +364,6 @@ describe('Proposal header', () => {
it('Renders proposal state: Passed', () => {
renderComponent(
// @ts-ignore we aren't using batch yet
generateProposal({
state: ProposalState.STATE_PASSED,
terms: {
@@ -350,7 +377,6 @@ describe('Proposal header', () => {
it('Renders proposal state: Waiting for node vote', () => {
renderComponent(
// @ts-ignore we aren't using batch yet
generateProposal({
state: ProposalState.STATE_WAITING_FOR_NODE_VOTE,
terms: {
@@ -365,7 +391,6 @@ describe('Proposal header', () => {
it('Renders proposal state: Open', () => {
renderComponent(
// @ts-ignore we aren't using batch yet
generateProposal({
state: ProposalState.STATE_OPEN,
votes: {
@@ -434,8 +459,6 @@ describe('Proposal header', () => {
});
});
jest.mock('@vegaprotocol/proposals');
describe('<NewTransferSummary />', () => {
it('renders null if no details are provided', () => {
(useNewTransferProposalDetails as jest.Mock).mockReturnValue(null);
@@ -1,4 +1,4 @@
import { useTranslation } from 'react-i18next';
import { Trans, useTranslation } from 'react-i18next';
import {
CopyWithTooltip,
Lozenge,
@@ -8,14 +8,13 @@ import {
} from '@vegaprotocol/ui-toolkit';
import { shorten } from '@vegaprotocol/utils';
import { Heading, SubHeading } from '../../../../components/heading';
import { type ReactNode } from 'react';
import { truncateMiddle } from '../../../../lib/truncate-middle';
import { CurrentProposalState } from '../current-proposal-state';
import { ProposalInfoLabel } from '../proposal-info-label';
import {
useCancelTransferProposalDetails,
useInstrumentDetailsQuery,
useNewTransferProposalDetails,
useSuccessorMarketProposalDetails,
} from '@vegaprotocol/proposals';
import {
CONSOLE_MARKET_PAGE,
@@ -27,217 +26,539 @@ import Routes from '../../../routes';
import { Link } from 'react-router-dom';
import { type VoteState } from '../vote-details/use-user-vote';
import { VoteBreakdown } from '../vote-breakdown';
import { GovernanceTransferKindMapping } from '@vegaprotocol/types';
import { type Proposal } from '../../types';
import {
GovernanceTransferKindMapping,
type ProposalRejectionReason,
ProposalRejectionReasonMapping,
ProposalState,
} from '@vegaprotocol/types';
import { type Proposal, type BatchProposal } from '../../types';
import { type ProposalTermsFieldsFragment } from '../../__generated__/Proposals';
import { differenceInHours, format, formatDistanceToNowStrict } from 'date-fns';
import { DATE_FORMAT_DETAILED } from '../../../../lib/date-formats';
import { getIndicatorStyle } from '../proposal/colours';
import { MarketName } from '../proposal/market-name';
const ProposalTypeTags = ({
proposal,
}: {
proposal: Proposal | BatchProposal;
}) => {
if (proposal.__typename === 'Proposal') {
return (
<div data-testid="proposal-type">
<ProposalTypeTag terms={proposal.terms} />
</div>
);
}
if (proposal.__typename === 'BatchProposal') {
return (
<div data-testid="proposal-type">
<ProposalInfoLabel variant="secondary">BatchProposal</ProposalInfoLabel>
</div>
);
}
return null;
};
const ProposalTypeTag = ({ terms }: { terms: ProposalTermsFieldsFragment }) => {
const { t } = useTranslation();
switch (terms.change.__typename) {
// Speical case for markets where we want to show the product type in the tag
case 'NewMarket': {
return (
<ProposalInfoLabel variant="secondary">
{t(
terms.change?.instrument?.product?.__typename
? `NewMarket${terms.change.instrument.product.__typename}`
: 'NewMarket'
)}
</ProposalInfoLabel>
);
}
default: {
return (
<ProposalInfoLabel variant="secondary">
{t(terms.change.__typename)}
</ProposalInfoLabel>
);
}
}
};
const ProposalDetails = ({
proposal,
}: {
proposal: Proposal | BatchProposal;
}) => {
const { t } = useTranslation();
const featureFlags = useFeatureFlags((store) => store.flags);
const consoleLink = useLinks(DApp.Console);
const renderDetails = (terms: ProposalTermsFieldsFragment) => {
switch (terms.change?.__typename) {
case 'NewMarket': {
const getAsset = (terms: ProposalTermsFieldsFragment) => {
if (
terms?.change.__typename === 'NewMarket' &&
(terms.change.instrument.product?.__typename === 'FutureProduct' ||
terms.change.instrument.product?.__typename ===
'PerpetualProduct')
) {
return terms.change.instrument.product.settlementAsset;
}
return undefined;
};
return (
<>
{terms.change.successorConfiguration && (
<ParentMarketCode
parentMarketId={
terms.change.successorConfiguration.parentMarketId
}
/>
)}
<span>
{t('Code')}: {terms.change.instrument.code}.
</span>{' '}
{terms && getAsset(terms)?.symbol ? (
<>
<span className="font-semibold">{getAsset(terms)?.symbol}</span>{' '}
{t('settled future')}.
</>
) : (
''
)}
</>
);
}
case 'UpdateMarketState': {
const marketPageLink = consoleLink(
CONSOLE_MARKET_PAGE.replace(':marketId', terms.change.market.id)
);
return (
<span>
{featureFlags.UPDATE_MARKET_STATE &&
terms.change?.market?.id &&
terms.change.updateType ? (
<>
<span>{t(terms.change.updateType)}: </span>
<span className="inline-flex gap-2">
<span className="break-all">
<MarketName marketId={terms.change.market.id} />
</span>
<span className="inline-flex items-end gap-0">
<CopyWithTooltip
text={terms.change.market.id}
description={t('copyId')}
>
<button className="inline-block px-1">
<VegaIcon size={20} name={VegaIconNames.COPY} />
</button>
</CopyWithTooltip>
<Tooltip description={t('OpenInConsole')} align="center">
<Link
className="inline-block px-1"
to={marketPageLink}
target="_blank"
>
<VegaIcon
size={20}
name={VegaIconNames.OPEN_EXTERNAL}
/>
</Link>
</Tooltip>
</span>
</span>
</>
) : null}
</span>
);
}
case 'UpdateMarket': {
const marketPageLink = consoleLink(
CONSOLE_MARKET_PAGE.replace(':marketId', terms.change.marketId)
);
return (
<>
<span>{t('UpdateToMarket')}: </span>
<span className="inline-flex items-start gap-2">
<span className="break-all">
<MarketName marketId={terms.change.marketId} />
</span>
<span className="inline-flex items-end gap-0">
<CopyWithTooltip
text={terms.change.marketId}
description={t('copyId')}
>
<button className="inline-block px-1">
<VegaIcon size={20} name={VegaIconNames.COPY} />
</button>
</CopyWithTooltip>
<Tooltip description={t('OpenInConsole')} align="center">
<Link
className="inline-block px-1"
target="_blank"
to={marketPageLink}
>
<VegaIcon size={20} name={VegaIconNames.OPEN_EXTERNAL} />
</Link>
</Tooltip>
</span>
</span>
</>
);
}
case 'UpdateReferralProgram': {
return null;
}
case 'UpdateVolumeDiscountProgram': {
return null;
}
case 'NewAsset': {
return (
<>
<span>{t('Symbol')}:</span>{' '}
<Lozenge>{terms.change.symbol}.</Lozenge>{' '}
{terms.change.source.__typename === 'ERC20' && (
<>
<span>{t('ERC20ContractAddress')}:</span>{' '}
<Lozenge>{terms.change.source.contractAddress}</Lozenge>
</>
)}{' '}
{terms.change.source.__typename === 'BuiltinAsset' && (
<>
<span>{t('MaxFaucetAmountMint')}:</span>{' '}
<Lozenge>{terms.change.source.maxFaucetAmountMint}</Lozenge>
</>
)}
</>
);
}
case 'UpdateNetworkParameter': {
return (
<Trans
i18nKey="Change <lozenge>{{key}}</lozenge> to <lozenge>{{value}}</lozenge>"
values={{
key: terms.change.networkParameter.key,
value: terms.change.networkParameter.value,
}}
components={{
// @ts-ignore children passed by i18next
lozenge: <Lozenge />,
}}
/>
);
}
case 'NewFreeform': {
return <span />;
}
case 'UpdateAsset': {
return (
<Trans
i18nKey="Asset ID: <lozenge>{{id}}</lozenge>"
values={{
id: truncateMiddle(terms.change.assetId),
}}
components={{
// @ts-ignore children passed by i18next
lozenge: <Lozenge />,
}}
/>
);
}
case 'NewTransfer':
return featureFlags.GOVERNANCE_TRANSFERS ? (
<NewTransferSummary proposalId={proposal?.id} />
) : null;
case 'CancelTransfer':
return featureFlags.GOVERNANCE_TRANSFERS ? (
<CancelTransferSummary proposalId={proposal?.id} />
) : null;
default: {
return null;
}
}
};
let details = null;
if (proposal.__typename === 'Proposal') {
details = (
<div>
<div>{renderDetails(proposal.terms)}</div>
<VoteStateText
state={proposal.state}
closingDatetime={proposal.terms.closingDatetime}
enactmentDatetime={proposal.terms.enactmentDatetime}
rejectionReason={proposal.rejectionReason}
/>
</div>
);
}
if (proposal.__typename === 'BatchProposal' && proposal.subProposals) {
details = (
<div>
<h3 className="text-xl border-b border-default pb-3 mb-3">
Proposals in batch
</h3>
<ul className="flex flex-col gap-2 border-b border-default pb-3 mb-3">
{proposal.subProposals.map((p, i) => {
if (!p?.terms) return null;
return (
<li key={i} className="flex gap-3 items-center">
<span className={getIndicatorStyle(i + 1)}>{i + 1}</span>
<span>
<div>{renderDetails(p.terms)}</div>
<SubProposalStateText
state={proposal.state}
enactmentDatetime={p.terms.enactmentDatetime}
/>
</span>
</li>
);
})}
</ul>
<BatchProposalStateText
state={proposal.state}
closingDatetime={proposal.batchTerms?.closingDatetime}
rejectionReason={proposal.rejectionReason}
/>
</div>
);
}
return (
<div
data-testid="proposal-details"
className="break-words mb-6 text-vega-light-200"
>
{details}
</div>
);
};
const VoteStateText = ({
state,
closingDatetime,
enactmentDatetime,
rejectionReason,
}: {
state: ProposalState;
closingDatetime: string;
enactmentDatetime: string;
rejectionReason: ProposalRejectionReason | null | undefined;
}) => {
const { t } = useTranslation();
const nowToCloseInHours = differenceInHours(
new Date(closingDatetime),
new Date()
);
const props = {
'data-testid': 'vote-details',
};
switch (state) {
case ProposalState.STATE_ENACTED: {
return (
<p {...props}>
{t('enactedOn{{date}}', {
enactmentDate:
enactmentDatetime &&
format(new Date(enactmentDatetime), DATE_FORMAT_DETAILED),
})}
</p>
);
}
case ProposalState.STATE_PASSED:
case ProposalState.STATE_WAITING_FOR_NODE_VOTE: {
return (
<p {...props}>
{t('enactsOn{{date}}', {
enactmentDate:
enactmentDatetime &&
format(new Date(enactmentDatetime), DATE_FORMAT_DETAILED),
})}
</p>
);
}
case ProposalState.STATE_OPEN: {
return (
<p {...props}>
<span className={nowToCloseInHours < 6 ? 'text-vega-orange' : ''}>
{t('{{time}} left to vote', {
time: formatDistanceToNowStrict(new Date(closingDatetime)),
})}
</span>
</p>
);
}
case ProposalState.STATE_DECLINED: {
return <p {...props}>{t(state)}</p>;
}
case ProposalState.STATE_REJECTED: {
const props = { 'data-testid': 'vote-status' };
if (rejectionReason) {
return (
<p {...props}>{t(ProposalRejectionReasonMapping[rejectionReason])}</p>
);
}
return <p {...props}>{t('Proposal rejected')}</p>;
}
default: {
return null;
}
}
};
/**
* Renders state details relevant to the sub proposal, namely the enactment
* date and time
*/
const SubProposalStateText = ({
state,
enactmentDatetime,
}: {
state: ProposalState;
enactmentDatetime: string;
}) => {
const { t } = useTranslation();
const props = {
'data-testid': 'vote-details',
className: 'm-0',
};
switch (state) {
case ProposalState.STATE_ENACTED: {
return (
<p {...props}>
{t('enactedOn{{date}}', {
enactmentDate:
enactmentDatetime &&
format(new Date(enactmentDatetime), DATE_FORMAT_DETAILED),
})}
</p>
);
}
case ProposalState.STATE_OPEN:
case ProposalState.STATE_PASSED:
case ProposalState.STATE_WAITING_FOR_NODE_VOTE: {
return (
<p {...props}>
{t('enactsOn{{date}}', {
enactmentDate:
enactmentDatetime &&
format(new Date(enactmentDatetime), DATE_FORMAT_DETAILED),
})}
</p>
);
}
case ProposalState.STATE_REJECTED:
case ProposalState.STATE_DECLINED: {
// If voting is still open we render a single clost time for all sub proposals
return null;
}
default: {
return null;
}
}
};
/**
* Renders state details relevant for the entire batch. IE. if the proposal was
* rejected or declined, or the vote close time. Does not render enactment times as
* those are relevant to the sub proposal
*/
const BatchProposalStateText = ({
state,
closingDatetime,
rejectionReason,
}: {
state: ProposalState;
closingDatetime: string;
rejectionReason: ProposalRejectionReason | null | undefined;
}) => {
const { t } = useTranslation();
const nowToCloseInHours = differenceInHours(
new Date(closingDatetime),
new Date()
);
const props = {
'data-testid': 'vote-details',
};
switch (state) {
case ProposalState.STATE_ENACTED:
case ProposalState.STATE_PASSED:
case ProposalState.STATE_WAITING_FOR_NODE_VOTE: {
return null;
}
case ProposalState.STATE_OPEN: {
return (
<p {...props}>
<span className={nowToCloseInHours < 6 ? 'text-vega-orange' : ''}>
{t('{{time}} left to vote', {
time: formatDistanceToNowStrict(new Date(closingDatetime)),
})}
</span>
</p>
);
}
case ProposalState.STATE_DECLINED: {
return <p {...props}>{t(state)}</p>;
}
case ProposalState.STATE_REJECTED: {
const props = { 'data-testid': 'vote-status' };
if (rejectionReason) {
return (
<p {...props}>{t(ProposalRejectionReasonMapping[rejectionReason])}</p>
);
}
return <p {...props}>{t('Proposal rejected')}</p>;
}
default: {
return null;
}
}
};
export const ProposalHeader = ({
proposal,
isListItem = true,
voteState,
}: {
proposal: Proposal;
proposal: Proposal | BatchProposal;
isListItem?: boolean;
voteState?: VoteState | null;
}) => {
const featureFlags = useFeatureFlags((state) => state.flags);
const { t } = useTranslation();
const change = proposal?.terms.change;
const consoleLink = useLinks(DApp.Console);
let details: ReactNode;
let proposalType = '';
let fallbackTitle = '';
const title = proposal?.rationale.title.trim();
const fallbackTitle = t(
proposal.__typename === 'Proposal'
? 'Unknown proposal'
: 'Unknown batch proposal'
);
const titleContent = shorten(title ?? '', 100);
const getAsset = (proposal: Proposal) => {
const terms = proposal?.terms;
if (
terms?.change.__typename === 'NewMarket' &&
(terms.change.instrument.product?.__typename === 'FutureProduct' ||
terms.change.instrument.product?.__typename === 'PerpetualProduct')
) {
return terms.change.instrument.product.settlementAsset;
}
return undefined;
};
switch (change?.__typename) {
case 'NewMarket': {
proposalType =
featureFlags.PRODUCT_PERPETUALS &&
change?.instrument?.product?.__typename
? `NewMarket${change?.instrument?.product?.__typename}`
: 'NewMarket';
fallbackTitle = t('NewMarketProposal');
details = (
<>
{featureFlags.SUCCESSOR_MARKETS && (
<SuccessorCode proposalId={proposal?.id} />
)}
<span>
{t('Code')}: {change.instrument.code}.
</span>{' '}
{proposal?.terms && getAsset(proposal)?.symbol ? (
<>
<span className="font-semibold">
{getAsset(proposal)?.symbol}
</span>{' '}
{t('settled future')}.
</>
) : (
''
)}
</>
);
break;
}
case 'UpdateMarketState': {
proposalType =
featureFlags.UPDATE_MARKET_STATE && change?.updateType
? t(change.updateType)
: 'UpdateMarketState';
fallbackTitle = t('UpdateMarketStateProposal');
details = (
<span>
{featureFlags.UPDATE_MARKET_STATE &&
change?.market?.id &&
change.updateType ? (
<>
{t(change.updateType)}: {truncateMiddle(change.market.id)}
</>
) : null}
</span>
);
break;
}
case 'UpdateMarket': {
proposalType = 'UpdateMarket';
fallbackTitle = t('UpdateMarketProposal');
details = (
<>
<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;
}
case 'UpdateReferralProgram': {
proposalType = 'UpdateReferralProgram';
fallbackTitle = t('UpdateReferralProgramProposal');
break;
}
case 'UpdateVolumeDiscountProgram': {
proposalType = 'UpdateVolumeDiscountProgram';
fallbackTitle = t('UpdateVolumeDiscountProgramProposal');
break;
}
case 'NewAsset': {
proposalType = 'NewAsset';
fallbackTitle = t('NewAssetProposal');
details = (
<>
<span>{t('Symbol')}:</span> <Lozenge>{change.symbol}.</Lozenge>{' '}
{change.source.__typename === 'ERC20' && (
<>
<span>{t('ERC20ContractAddress')}:</span>{' '}
<Lozenge>{change.source.contractAddress}</Lozenge>
</>
)}{' '}
{change.source.__typename === 'BuiltinAsset' && (
<>
<span>{t('MaxFaucetAmountMint')}:</span>{' '}
<Lozenge>{change.source.maxFaucetAmountMint}</Lozenge>
</>
)}
</>
);
break;
}
case 'UpdateNetworkParameter': {
proposalType = 'NetworkParameter';
fallbackTitle = t('NetworkParameterProposal');
details = (
<>
<span>{t('Change')}:</span>{' '}
<Lozenge>{change.networkParameter.key}</Lozenge>{' '}
<span>{t('to')}</span>{' '}
<span className="whitespace-nowrap">
<Lozenge>{change.networkParameter.value}</Lozenge>
</span>
</>
);
break;
}
case 'NewFreeform': {
proposalType = 'Freeform';
fallbackTitle = t('FreeformProposal');
details = <span />;
break;
}
case 'UpdateAsset': {
proposalType = 'UpdateAsset';
fallbackTitle = t('UpdateAssetProposal');
details = (
<>
<span>{t('AssetID')}:</span>{' '}
<Lozenge>{truncateMiddle(change.assetId)}</Lozenge>
</>
);
break;
}
case 'NewTransfer':
proposalType = 'NewTransfer';
fallbackTitle = t('NewTransferProposal');
details = featureFlags.GOVERNANCE_TRANSFERS ? (
<NewTransferSummary proposalId={proposal?.id} />
) : null;
break;
case 'CancelTransfer':
proposalType = 'CancelTransfer';
fallbackTitle = t('CancelTransferProposal');
details = featureFlags.GOVERNANCE_TRANSFERS ? (
<CancelTransferSummary proposalId={proposal?.id} />
) : null;
break;
}
return (
<>
<div className="flex items-center justify-between gap-4 mb-6 text-sm">
<div data-testid="proposal-type">
<ProposalInfoLabel variant="secondary">
{t(`${proposalType}`)}
</ProposalInfoLabel>
</div>
<ProposalTypeTags proposal={proposal} />
<div className="flex items-center gap-6">
{(voteState === 'Yes' || voteState === 'No') && (
@@ -264,50 +585,43 @@ export const ProposalHeader = ({
<div data-testid="proposal-title" className="break-all">
{isListItem ? (
<header>
<SubHeading
title={titleContent || fallbackTitle || t('Unknown proposal')}
/>
<SubHeading title={titleContent || fallbackTitle} />
</header>
) : (
<Heading
title={titleContent || fallbackTitle || t('Unknown proposal')}
/>
<Heading title={titleContent || fallbackTitle} />
)}
</div>
{details && (
<div
data-testid="proposal-details"
className="break-words mb-6 text-vega-light-200"
>
{details}
</div>
)}
<ProposalDetails proposal={proposal} />
<VoteBreakdown proposal={proposal} />
</>
);
};
export const SuccessorCode = ({
proposalId,
export const ParentMarketCode = ({
parentMarketId,
}: {
proposalId?: string | null;
parentMarketId: string;
}) => {
const { t } = useTranslation();
const successor = useSuccessorMarketProposalDetails(proposalId);
const { data } = useInstrumentDetailsQuery({
variables: {
marketId: parentMarketId,
},
});
return successor.parentMarketId || successor.code ? (
if (!data?.market?.tradableInstrument.instrument.code) return null;
return (
<span className="block" data-testid="proposal-successor-info">
{t('Successor market to')}:{' '}
<Link
to={`${Routes.PROPOSALS}/${successor.parentMarketId}`}
to={`${Routes.PROPOSALS}/${parentMarketId}`}
className="hover:underline"
>
{successor.code || successor.parentMarketId}
{data.market.tradableInstrument.instrument.code}
</Link>
</span>
) : null;
);
};
export const NewTransferSummary = ({
@@ -3,14 +3,8 @@ import { useTranslation } from 'react-i18next';
import { SyntaxHighlighter } from '@vegaprotocol/ui-toolkit';
import { SubHeading } from '../../../../components/heading';
import { CollapsibleToggle } from '../../../../components/collapsible-toggle';
import type { ProposalFieldsFragment } from '../../proposals/__generated__/Proposals';
import type { ProposalQuery } from '../../proposal/__generated__/Proposal';
export const ProposalJson = ({
proposal,
}: {
proposal: ProposalFieldsFragment | ProposalQuery['proposal'];
}) => {
export const ProposalJson = ({ proposal }: { proposal?: unknown }) => {
const { t } = useTranslation();
const [showDetails, setShowDetails] = useState(false);
@@ -5,6 +5,11 @@ import {
} from './proposal-market-changes';
import type { JsonValue } from '../../../../components/json-diff';
jest.mock('../proposal/market-name.tsx', () => ({
...jest.requireActual('../proposal/market-name.tsx'),
MarketName: jest.fn(),
}));
describe('applyImmutableKeysFromEarlierVersion', () => {
it('returns an empty object if any argument is not an object or null', () => {
const earlierVersion: JsonValue = null;
@@ -57,33 +62,21 @@ describe('applyImmutableKeysFromEarlierVersion', () => {
describe('ProposalMarketChanges', () => {
it('renders correctly', () => {
const { getByTestId } = render(
<ProposalMarketChanges
originalProposal={{}}
latestEnactedProposal={{}}
updatedProposal={{}}
/>
<ProposalMarketChanges marketId="market-id" updateProposalNode={null} />
);
expect(getByTestId('proposal-market-changes')).toBeInTheDocument();
});
it('JsonDiff is not visible when showChanges is false', () => {
const { queryByTestId } = render(
<ProposalMarketChanges
originalProposal={{}}
latestEnactedProposal={{}}
updatedProposal={{}}
/>
<ProposalMarketChanges marketId="market-id" updateProposalNode={null} />
);
expect(queryByTestId('json-diff')).not.toBeInTheDocument();
});
it('JsonDiff is visible when showChanges is true', async () => {
const { getByTestId } = render(
<ProposalMarketChanges
originalProposal={{}}
latestEnactedProposal={{}}
updatedProposal={{}}
/>
<ProposalMarketChanges marketId="market-id" updateProposalNode={null} />
);
fireEvent.click(getByTestId('proposal-market-changes-toggle'));
expect(getByTestId('json-diff')).toBeInTheDocument();
@@ -1,12 +1,24 @@
import cloneDeep from 'lodash/cloneDeep';
import set from 'lodash/set';
import get from 'lodash/get';
import { JsonDiff } from '../../../../components/json-diff';
import compact from 'lodash/compact';
import orderBy from 'lodash/orderBy';
import { JsonDiff, type JsonValue } from '../../../../components/json-diff';
import { useTranslation } from 'react-i18next';
import { useState } from 'react';
import { CollapsibleToggle } from '../../../../components/collapsible-toggle';
import { SubHeading } from '../../../../components/heading';
import type { JsonValue } from '../../../../components/json-diff';
import {
useFetchProposal,
useFetchProposals,
flatten,
isBatchProposalNode,
isSingleProposalNode,
type ProposalNode,
type SingleProposalData,
type SubProposalData,
} from '../proposal/proposal-utils';
import { MarketName } from '../proposal/market-name';
const immutableKeys = [
'decimalPlaces',
@@ -16,8 +28,8 @@ const immutableKeys = [
];
export const applyImmutableKeysFromEarlierVersion = (
earlierVersion: JsonValue,
updatedVersion: JsonValue
earlierVersion: unknown,
updatedVersion: unknown
) => {
if (
typeof earlierVersion !== 'object' ||
@@ -33,26 +45,94 @@ export const applyImmutableKeysFromEarlierVersion = (
// Overwrite the immutable keys in the updatedVersionCopy with the earlier values
immutableKeys.forEach((key) => {
set(updatedVersionCopy, key, get(earlierVersion, key));
const earlier = get(earlierVersion, key);
if (earlier) set(updatedVersionCopy, key, earlier);
});
return updatedVersionCopy;
};
interface ProposalMarketChangesProps {
originalProposal: JsonValue;
latestEnactedProposal: JsonValue | undefined;
updatedProposal: JsonValue;
marketId: string;
/** This are the changes from proposal */
updateProposalNode: ProposalNode | null;
indicator?: number;
}
export const ProposalMarketChanges = ({
originalProposal,
latestEnactedProposal,
updatedProposal,
marketId,
updateProposalNode,
indicator,
}: ProposalMarketChangesProps) => {
const { t } = useTranslation();
const [showChanges, setShowChanges] = useState(false);
const { data: originalProposalData } = useFetchProposal({
proposalId: marketId,
});
const { data: enactedProposalsData } = useFetchProposals({
proposalState: 'STATE_ENACTED',
proposalType: 'TYPE_UPDATE_MARKET',
});
let updateProposal: SingleProposalData | SubProposalData | undefined;
if (isBatchProposalNode(updateProposalNode)) {
updateProposal = updateProposalNode.proposals.find(
(p, i) =>
p.terms.updateMarket?.marketId === marketId &&
(indicator != null ? i === indicator - 1 : true)
);
}
if (isSingleProposalNode(updateProposalNode)) {
updateProposal = updateProposalNode.proposal;
}
// this should get the proposal before the current one
const enactedUpdateMarketProposals = orderBy(
compact(
flatten(enactedProposalsData).filter((enacted) => {
const related = enacted.terms.updateMarket?.marketId === marketId;
const notCurrent =
enacted.id !== updateProposal?.id ||
('batchId' in enacted && enacted.batchId !== updateProposal.id);
const beforeCurrent =
Number(enacted.terms.enactmentTimestamp) <
Number(updateProposal?.terms.enactmentTimestamp);
return related && notCurrent && beforeCurrent;
})
),
[(proposal) => Number(proposal.terms.enactmentTimestamp)],
'desc'
);
const latestEnactedProposal =
enactedUpdateMarketProposals.length > 0
? enactedUpdateMarketProposals[0]
: undefined;
let originalProposal;
if (isBatchProposalNode(originalProposalData)) {
originalProposal = originalProposalData.proposals.find(
(proposal) => proposal.id === marketId && proposal.terms.newMarket != null
);
}
if (isSingleProposalNode(originalProposalData)) {
originalProposal = originalProposalData.proposal;
}
// LEFT SIDE: update market proposal enacted just before this one
// or original new market proposal
const left =
latestEnactedProposal?.terms.updateMarket?.changes ||
originalProposal?.terms.newMarket?.changes;
// RIGHT SIDE: this update market proposal
const right = applyImmutableKeysFromEarlierVersion(
left,
updateProposal?.terms.updateMarket?.changes
);
return (
<section data-testid="proposal-market-changes">
<CollapsibleToggle
@@ -60,25 +140,18 @@ export const ProposalMarketChanges = ({
setToggleState={setShowChanges}
dataTestId={'proposal-market-changes-toggle'}
>
<SubHeading title={t('updatesToMarket')} />
<SubHeading
title={
<>
{t('UpdateToMarket')}: <MarketName marketId={marketId} />
</>
}
/>
</CollapsibleToggle>
{showChanges && (
<div className="mb-6">
<JsonDiff
left={latestEnactedProposal || originalProposal}
right={
latestEnactedProposal
? applyImmutableKeysFromEarlierVersion(
latestEnactedProposal,
updatedProposal
)
: applyImmutableKeysFromEarlierVersion(
originalProposal,
updatedProposal
)
}
/>
<JsonDiff left={left as JsonValue} right={right as JsonValue} />
</div>
)}
</section>
@@ -18,6 +18,7 @@ import {
getDataSourceSpecForTradingTermination,
getSigners,
MarginScalingFactorsPanel,
marketInfoProvider,
} from '@vegaprotocol/markets';
import {
Button,
@@ -28,8 +29,8 @@ import {
} from '@vegaprotocol/ui-toolkit';
import { SubHeading } from '../../../../components/heading';
import { CollapsibleToggle } from '../../../../components/collapsible-toggle';
import type { MarketInfo } from '@vegaprotocol/markets';
import { create } from 'zustand';
import { useDataProvider } from '@vegaprotocol/data-provider';
type MarketDataDialogState = {
isOpen: boolean;
@@ -48,18 +49,29 @@ export const useMarketDataDialogStore = create<MarketDataDialogState>(
const marketDataHeaderStyles =
'font-alpha calt text-base border-b border-vega-dark-200 mt-2 py-2';
export const ProposalMarketData = ({
marketData,
parentMarketData,
}: {
marketData: MarketInfo;
parentMarketData?: MarketInfo;
}) => {
export const ProposalMarketData = ({ proposalId }: { proposalId: string }) => {
const { t } = useTranslation();
const { isOpen, open, close } = useMarketDataDialogStore();
const [showDetails, setShowDetails] = useState(false);
if (!marketData) {
const { data: marketData } = useDataProvider({
dataProvider: marketInfoProvider,
skipUpdates: true,
variables: {
marketId: proposalId,
},
});
const { data: parentMarketData } = useDataProvider({
dataProvider: marketInfoProvider,
skipUpdates: true,
skip: !marketData?.parentMarketID,
variables: {
marketId: marketData?.parentMarketID || '',
},
});
if (!marketData || !parentMarketData) {
return null;
}
@@ -7,7 +7,6 @@ import {
formatReferralRewardMultiplier,
ProposalReferralProgramDetails,
} from './proposal-referral-program-details';
import { generateProposal } from '../../test-helpers/generate-proposals';
jest.mock('../../../../contexts/app-state/app-state-context', () => ({
useAppState: () => ({
@@ -59,87 +58,65 @@ describe('ProposalReferralProgramDetails helper functions', () => {
});
});
const mockReferralProposal = generateProposal({
terms: {
change: {
__typename: 'UpdateReferralProgram',
benefitTiers: [
{
minimumEpochs: 6,
minimumRunningNotionalTakerVolume: '10000',
referralDiscountFactor: '0.001',
referralRewardFactor: '0.001',
},
{
minimumEpochs: 24,
minimumRunningNotionalTakerVolume: '500000',
referralDiscountFactor: '0.005',
referralRewardFactor: '0.005',
},
{
minimumEpochs: 48,
minimumRunningNotionalTakerVolume: '1000000',
referralDiscountFactor: '0.01',
referralRewardFactor: '0.01',
},
],
endOfProgram: '2026-10-03T10:34:34Z',
windowLength: 3,
stakingTiers: [
{
minimumStakedTokens: '1',
referralRewardMultiplier: '1',
},
{
minimumStakedTokens: '2',
referralRewardMultiplier: '2',
},
{
minimumStakedTokens: '5',
referralRewardMultiplier: '3',
},
],
const mockChange = {
__typename: 'UpdateReferralProgram' as const,
benefitTiers: [
{
minimumEpochs: 6,
minimumRunningNotionalTakerVolume: '10000',
referralDiscountFactor: '0.001',
referralRewardFactor: '0.001',
},
},
});
{
minimumEpochs: 24,
minimumRunningNotionalTakerVolume: '500000',
referralDiscountFactor: '0.005',
referralRewardFactor: '0.005',
},
{
minimumEpochs: 48,
minimumRunningNotionalTakerVolume: '1000000',
referralDiscountFactor: '0.01',
referralRewardFactor: '0.01',
},
],
endOfProgram: '2026-10-03T10:34:34Z',
windowLength: 3,
stakingTiers: [
{
minimumStakedTokens: '1',
referralRewardMultiplier: '1',
},
{
minimumStakedTokens: '2',
referralRewardMultiplier: '2',
},
{
minimumStakedTokens: '5',
referralRewardMultiplier: '3',
},
],
};
describe('<ProposalReferralProgramDetails />', () => {
it('should not render if proposal is null', () => {
render(<ProposalReferralProgramDetails proposal={null} />);
expect(
screen.queryByTestId('proposal-referral-program-details')
).toBeNull();
});
it('should not render if __typename is not UpdateReferralProgram', () => {
const updateMarketProposal = generateProposal({
terms: {
change: {
__typename: 'UpdateMarket',
},
},
});
render(<ProposalReferralProgramDetails proposal={updateMarketProposal} />);
render(<ProposalReferralProgramDetails change={null} />);
expect(
screen.queryByTestId('proposal-referral-program-details')
).toBeNull();
});
it('should not render if there are no relevant fields', () => {
const incompleteProposal = generateProposal({
terms: {
change: {},
},
});
render(<ProposalReferralProgramDetails proposal={incompleteProposal} />);
const emptyChange = {};
// @ts-ignore change deliberately empty
render(<ProposalReferralProgramDetails change={emptyChange} />);
expect(
screen.queryByTestId('proposal-referral-program-details')
).toBeNull();
});
it('should render relevant fields if present', () => {
render(<ProposalReferralProgramDetails proposal={mockReferralProposal} />);
render(<ProposalReferralProgramDetails change={mockChange} />);
expect(
screen.getByTestId('proposal-referral-program-window-length')
).toBeInTheDocument();
@@ -13,10 +13,10 @@ import {
} from '@vegaprotocol/utils';
import BigNumber from 'bignumber.js';
import { useAppState } from '../../../../contexts/app-state/app-state-context';
import { type Proposal } from '../../types';
import { type UpdateReferralProgramsFragment } from '../../__generated__/Proposals';
interface ProposalReferralProgramDetailsProps {
proposal: Proposal | null;
change: UpdateReferralProgramsFragment | null;
}
export const formatEndOfProgramTimestamp = (value: string) => {
@@ -44,20 +44,19 @@ export const formatReferralRewardMultiplier = (value: string) => {
};
export const ProposalReferralProgramDetails = ({
proposal,
change,
}: ProposalReferralProgramDetailsProps) => {
const {
appState: { decimals },
} = useAppState();
const { t } = useTranslation();
if (proposal?.terms?.change?.__typename !== 'UpdateReferralProgram') {
return null;
}
const benefitTiers = proposal?.terms?.change?.benefitTiers.slice();
const stakingTiers = proposal?.terms?.change?.stakingTiers.slice();
const windowLength = proposal?.terms?.change?.windowLength;
const endOfProgramTimestamp = proposal?.terms?.change?.endOfProgram;
if (change?.__typename !== 'UpdateReferralProgram') return null;
const benefitTiers = change?.benefitTiers.slice();
const stakingTiers = change?.stakingTiers.slice();
const windowLength = change?.windowLength;
const endOfProgramTimestamp = change?.endOfProgram;
if (
!benefitTiers &&
@@ -6,15 +6,14 @@ import {
RoundedWrapper,
} from '@vegaprotocol/ui-toolkit';
import { SubHeading } from '../../../../components/heading';
import { type Proposal } from '../../types';
export const ProposalCancelTransferDetails = ({
proposal,
proposalId,
}: {
proposal: Proposal;
proposalId: string;
}) => {
const { t } = useTranslation();
const details = useCancelTransferProposalDetails(proposal?.id);
const details = useCancelTransferProposalDetails(proposalId);
if (!details) {
return null;
@@ -19,17 +19,16 @@ import {
addDecimalsFormatNumberQuantum,
formatDateWithLocalTimezone,
} from '@vegaprotocol/utils';
import { type Proposal } from '../../types';
export const ProposalTransferDetails = ({
proposal,
proposalId,
}: {
proposal: Proposal;
proposalId: string;
}) => {
const { t } = useTranslation();
const [show, setShow] = useState(false);
const details = useNewTransferProposalDetails(proposal?.id);
const details = useNewTransferProposalDetails(proposalId);
if (!details) {
return null;
}
@@ -1,6 +1,5 @@
import { render, screen } from '@testing-library/react';
import { ProposalUpdateBenefitTiers } from './proposal-update-benefit-tiers-details';
import { generateProposal } from '../../test-helpers/generate-proposals';
jest.mock('../../../../contexts/app-state/app-state-context', () => ({
useAppState: () => ({
@@ -10,109 +9,71 @@ jest.mock('../../../../contexts/app-state/app-state-context', () => ({
}),
}));
const mockVestingBenefitTierProposal = generateProposal({
terms: {
change: {
__typename: 'UpdateNetworkParameter',
networkParameter: {
key: 'blah.blah.benefitTiers',
value: JSON.stringify({
tiers: [
{
minimum_quantum_balance: '10000',
reward_multiplier: '0.05',
},
{
minimum_quantum_balance: '500000000000',
reward_multiplier: '0.1',
},
{
minimum_quantum_balance: '10000000000000',
reward_multiplier: '10',
},
],
}),
},
},
const mockChange1 = {
__typename: 'UpdateNetworkParameter' as const,
networkParameter: {
key: 'blah.blah.benefitTiers',
value: JSON.stringify({
tiers: [
{
minimum_quantum_balance: '10000',
reward_multiplier: '0.05',
},
{
minimum_quantum_balance: '500000000000',
reward_multiplier: '0.1',
},
{
minimum_quantum_balance: '10000000000000',
reward_multiplier: '10',
},
],
}),
},
});
};
const mockActivityStreakBenefitTierProposal = generateProposal({
terms: {
change: {
__typename: 'UpdateNetworkParameter',
networkParameter: {
key: 'blah.blah.benefitTiers',
value: JSON.stringify({
tiers: [
{
minimum_activity_streak: '10000',
vesting_multiplier: '5',
reward_multiplier: '0.1',
},
{
minimum_activity_streak: '10000000000000',
vesting_multiplier: '100',
reward_multiplier: '10',
},
],
}),
},
},
const mockChange2 = {
__typename: 'UpdateNetworkParameter' as const,
networkParameter: {
key: 'blah.blah.benefitTiers',
value: JSON.stringify({
tiers: [
{
minimum_activity_streak: '10000',
vesting_multiplier: '5',
reward_multiplier: '0.1',
},
{
minimum_activity_streak: '10000000000000',
vesting_multiplier: '100',
reward_multiplier: '10',
},
],
}),
},
});
};
describe('ProposalUpdateBenefitTiers', () => {
it('should not render if proposal is null', () => {
render(<ProposalUpdateBenefitTiers proposal={null} />);
expect(screen.queryByTestId('proposal-update-benefit-tiers')).toBeNull();
});
it('should not render if __typename is not UpdateNetworkParameter', () => {
const updateMarketProposal = generateProposal({
terms: {
change: {
__typename: 'UpdateMarket',
},
},
});
render(<ProposalUpdateBenefitTiers proposal={updateMarketProposal} />);
expect(screen.queryByTestId('proposal-update-benefit-tiers')).toBeNull();
});
it('should not render if there are no relevant fields', () => {
const incompleteProposal = generateProposal({
terms: {
change: {
__typename: 'UpdateNetworkParameter',
},
},
});
render(<ProposalUpdateBenefitTiers proposal={incompleteProposal} />);
render(<ProposalUpdateBenefitTiers change={null} />);
expect(screen.queryByTestId('proposal-update-benefit-tiers')).toBeNull();
});
it('should not render if there are relevant fields that are empty', () => {
const incompleteProposal = generateProposal({
terms: {
change: {
__typename: 'UpdateNetworkParameter',
networkParameter: {
key: 'blah.blah.benefitTiers',
value: JSON.stringify({}),
},
},
const incompleteProposal = {
__typename: 'UpdateNetworkParameter' as const,
networkParameter: {
key: 'blah.blah.benefitTiers',
value: JSON.stringify({}),
},
});
};
render(<ProposalUpdateBenefitTiers proposal={incompleteProposal} />);
render(<ProposalUpdateBenefitTiers change={incompleteProposal} />);
expect(screen.queryByTestId('proposal-update-benefit-tiers')).toBeNull();
});
it('should render a valid vesting benefit tier proposal', () => {
render(
<ProposalUpdateBenefitTiers proposal={mockVestingBenefitTierProposal} />
);
render(<ProposalUpdateBenefitTiers change={mockChange1} />);
// 3 tiers in the sample data
expect(screen.getByText('Tier 1')).toBeInTheDocument();
@@ -133,11 +94,7 @@ describe('ProposalUpdateBenefitTiers', () => {
});
it('should render a valid activity streak benefit tier proposal', () => {
render(
<ProposalUpdateBenefitTiers
proposal={mockActivityStreakBenefitTierProposal}
/>
);
render(<ProposalUpdateBenefitTiers change={mockChange2} />);
// 3 tiers in the sample data
expect(screen.getByText('Tier 1')).toBeInTheDocument();
@@ -11,7 +11,7 @@ import {
} from '../proposal-referral-program-details';
import { formatNumberPercentage } from '@vegaprotocol/utils';
import BigNumber from 'bignumber.js';
import { type Proposal } from '../../types';
import { type IUpdateNetworkParameterFieldsFragment } from '../../__generated__/Proposals';
// These types are not generated as it's not known how dynamic these are
type VestingBenefitTier = {
@@ -43,7 +43,7 @@ export const formatVolumeDiscountFactor = (value: string) => {
};
interface ProposalReferralProgramDetailsProps {
proposal: Proposal | null;
change: IUpdateNetworkParameterFieldsFragment | null;
}
/**
@@ -55,17 +55,17 @@ interface ProposalReferralProgramDetailsProps {
* It only renders known fields so that they can be formatted correctly.
*/
export const ProposalUpdateBenefitTiers = ({
proposal,
change,
}: ProposalReferralProgramDetailsProps) => {
const { t } = useTranslation();
if (
proposal?.terms?.change?.__typename !== 'UpdateNetworkParameter' ||
proposal?.terms?.change?.networkParameter.key.slice(-13) !== '.benefitTiers'
change?.__typename !== 'UpdateNetworkParameter' ||
change?.networkParameter.key.slice(-13) !== '.benefitTiers'
) {
return null;
}
const benefitTiersString = proposal?.terms?.change?.networkParameter.value;
const benefitTiersString = change?.networkParameter.value;
const benefitTiers = getBenefitTiers(benefitTiersString);
if (!benefitTiers) {
@@ -1,86 +1,78 @@
import { fireEvent, render, screen } from '@testing-library/react';
import { ProposalUpdateMarketState } from './proposal-update-market-state';
import { generateProposal } from '../../test-helpers/generate-proposals';
import { MarketUpdateType } from '@vegaprotocol/types';
jest.mock('../proposal/market-name.tsx', () => ({
...jest.requireActual('../proposal/market-name.tsx'),
MarketName: jest.fn(),
}));
describe('<ProposalUpdateMarketState />', () => {
const suspendProposal = generateProposal({
terms: {
change: {
__typename: 'UpdateMarketState',
market: {
id: '1',
decimalPlaces: 0,
tradableInstrument: {
instrument: {
name: 'suspendProposal Name',
code: 'suspendProposal Code',
product: {
__typename: 'Future',
quoteName: 'USD',
},
},
const suspendProposal = {
__typename: 'UpdateMarketState' as const,
market: {
id: '1',
decimalPlaces: 0,
tradableInstrument: {
instrument: {
name: 'suspendProposal Name',
code: 'suspendProposal Code',
product: {
__typename: 'Future' as const,
quoteName: 'USD',
},
},
updateType: MarketUpdateType.MARKET_STATE_UPDATE_TYPE_SUSPEND,
},
},
});
updateType: MarketUpdateType.MARKET_STATE_UPDATE_TYPE_SUSPEND,
};
const resumeProposal = generateProposal({
terms: {
change: {
__typename: 'UpdateMarketState',
market: {
id: '1',
decimalPlaces: 0,
tradableInstrument: {
instrument: {
name: 'resumeProposal Name',
code: 'resumeProposal Code',
product: {
__typename: 'Future',
quoteName: 'USD',
},
},
const resumeProposal = {
__typename: 'UpdateMarketState' as const,
market: {
id: '1',
decimalPlaces: 0,
tradableInstrument: {
instrument: {
name: 'resumeProposal Name',
code: 'resumeProposal Code',
product: {
__typename: 'Future' as const,
quoteName: 'USD',
},
},
updateType: MarketUpdateType.MARKET_STATE_UPDATE_TYPE_RESUME,
},
},
});
updateType: MarketUpdateType.MARKET_STATE_UPDATE_TYPE_RESUME,
};
const terminateProposal = generateProposal({
terms: {
change: {
__typename: 'UpdateMarketState',
market: {
id: '1',
decimalPlaces: 0,
tradableInstrument: {
instrument: {
name: 'terminateProposal Name',
code: 'terminateProposal Code',
product: {
__typename: 'Future',
quoteName: 'USD',
},
},
const terminateProposal = {
__typename: 'UpdateMarketState' as const,
market: {
id: '1',
decimalPlaces: 0,
tradableInstrument: {
instrument: {
name: 'terminateProposal Name',
code: 'terminateProposal Code',
product: {
__typename: 'Future' as const,
quoteName: 'USD',
},
},
updateType: MarketUpdateType.MARKET_STATE_UPDATE_TYPE_TERMINATE,
price: '123',
},
},
});
updateType: MarketUpdateType.MARKET_STATE_UPDATE_TYPE_TERMINATE,
price: '123',
};
it('should render nothing if proposal is null', () => {
render(<ProposalUpdateMarketState proposal={null} />);
render(<ProposalUpdateMarketState change={null} />);
expect(screen.queryByTestId('proposal-update-market-state')).toBeNull();
});
it('should toggle details when CollapsibleToggle is clicked', () => {
render(<ProposalUpdateMarketState proposal={suspendProposal} />);
render(<ProposalUpdateMarketState change={suspendProposal} />);
expect(
screen.queryByTestId('proposal-update-market-state-table')
@@ -94,7 +86,7 @@ describe('<ProposalUpdateMarketState />', () => {
});
it('should display suspend market information when showDetails is true', () => {
render(<ProposalUpdateMarketState proposal={suspendProposal} />);
render(<ProposalUpdateMarketState change={suspendProposal} />);
fireEvent.click(screen.getByTestId('proposal-market-data-toggle'));
@@ -103,7 +95,7 @@ describe('<ProposalUpdateMarketState />', () => {
});
it('should display resume market information when showDetails is true', () => {
render(<ProposalUpdateMarketState proposal={resumeProposal} />);
render(<ProposalUpdateMarketState change={resumeProposal} />);
fireEvent.click(screen.getByTestId('proposal-market-data-toggle'));
@@ -112,7 +104,7 @@ describe('<ProposalUpdateMarketState />', () => {
});
it('should display terminate market information when showDetails is true', () => {
render(<ProposalUpdateMarketState proposal={terminateProposal} />);
render(<ProposalUpdateMarketState change={terminateProposal} />);
fireEvent.click(screen.getByTestId('proposal-market-data-toggle'));
@@ -8,29 +8,31 @@ import { Row } from '@vegaprotocol/markets';
import { useState } from 'react';
import { CollapsibleToggle } from '../../../../components/collapsible-toggle';
import { SubHeading } from '../../../../components/heading';
import { type Proposal } from '../../types';
import { type UpdateMarketStatesFragment } from '../../__generated__/Proposals';
import { MarketUpdateTypeMapping } from '@vegaprotocol/types';
import { MarketName } from '../proposal/market-name';
interface ProposalUpdateMarketStateProps {
proposal: Proposal | null;
change: UpdateMarketStatesFragment | null;
}
export const ProposalUpdateMarketState = ({
proposal,
change,
}: ProposalUpdateMarketStateProps) => {
const { t } = useTranslation();
const [showDetails, setShowDetails] = useState(false);
let market;
let isTerminate = false;
if (!proposal) {
if (!change || change.__typename !== 'UpdateMarketState') {
return null;
}
if (proposal?.terms.change.__typename === 'UpdateMarketState') {
market = proposal?.terms?.change?.market;
isTerminate =
proposal?.terms?.change?.updateType ===
'MARKET_STATE_UPDATE_TYPE_TERMINATE';
const market = change?.market;
const isTerminate =
change?.updateType === 'MARKET_STATE_UPDATE_TYPE_TERMINATE';
let toggleTitle = t(change.updateType);
if (toggleTitle.length === 0) {
toggleTitle = t('MarketDetails');
}
return (
@@ -40,17 +42,29 @@ export const ProposalUpdateMarketState = ({
setToggleState={setShowDetails}
dataTestId="proposal-market-data-toggle"
>
<SubHeading title={t('MarketDetails')} />
<SubHeading
title={
<>
{toggleTitle}: <MarketName marketId={market?.id} />
</>
}
/>
</CollapsibleToggle>
{showDetails && (
<RoundedWrapper paddingBottom={true} marginBottomLarge={true}>
{proposal?.terms.change.__typename === 'UpdateMarketState' && (
{change.__typename === 'UpdateMarketState' && (
<KeyValueTable data-testid="proposal-update-market-state-table">
<KeyValueTableRow>
{t('marketId')}
{market?.id}
</KeyValueTableRow>
<KeyValueTableRow>
{t('State')}
<span className="bg-vega-green-650 px-1">
{MarketUpdateTypeMapping[change.updateType]}
</span>
</KeyValueTableRow>
<KeyValueTableRow>
{t('marketName')}
{market?.tradableInstrument?.instrument?.name}
@@ -59,10 +73,10 @@ export const ProposalUpdateMarketState = ({
{t('marketCode')}
{market?.tradableInstrument?.instrument?.code}
</KeyValueTableRow>
{isTerminate && (
{isTerminate && market && (
<Row
field="termination-price"
value={proposal?.terms?.change?.price}
value={change?.price}
assetSymbol={
market?.tradableInstrument?.instrument?.product
?.__typename === 'Future' ||
@@ -1,6 +1,5 @@
import { render, screen } from '@testing-library/react';
import { ProposalVolumeDiscountProgramDetails } from './proposal-volume-discount-program-details';
import { generateProposal } from '../../test-helpers/generate-proposals';
jest.mock('../../../../contexts/app-state/app-state-context', () => ({
useAppState: () => ({
@@ -10,95 +9,56 @@ jest.mock('../../../../contexts/app-state/app-state-context', () => ({
}),
}));
const mockReferralProposal = generateProposal({
terms: {
change: {
__typename: 'UpdateVolumeDiscountProgram',
benefitTiers: [
{
minimumRunningNotionalTakerVolume: '10000',
volumeDiscountFactor: '0.05',
},
{
minimumRunningNotionalTakerVolume: '50000',
volumeDiscountFactor: '0.1',
},
{
minimumRunningNotionalTakerVolume: '100000',
volumeDiscountFactor: '0.15',
},
{
minimumRunningNotionalTakerVolume: '250000',
volumeDiscountFactor: '0.2',
},
{
minimumRunningNotionalTakerVolume: '500000',
volumeDiscountFactor: '0.25',
},
{
minimumRunningNotionalTakerVolume: '1000000',
volumeDiscountFactor: '0.3',
},
{
minimumRunningNotionalTakerVolume: '1500000',
volumeDiscountFactor: '0.35',
},
{
minimumRunningNotionalTakerVolume: '2000000',
volumeDiscountFactor: '0.4',
},
],
endOfProgramTimestamp: '1970-01-01T00:00:01.791568493Z',
windowLength: 7,
const mockChange = {
__typename: 'UpdateVolumeDiscountProgram' as const,
benefitTiers: [
{
minimumRunningNotionalTakerVolume: '10000',
volumeDiscountFactor: '0.05',
},
},
});
{
minimumRunningNotionalTakerVolume: '50000',
volumeDiscountFactor: '0.1',
},
{
minimumRunningNotionalTakerVolume: '100000',
volumeDiscountFactor: '0.15',
},
{
minimumRunningNotionalTakerVolume: '250000',
volumeDiscountFactor: '0.2',
},
{
minimumRunningNotionalTakerVolume: '500000',
volumeDiscountFactor: '0.25',
},
{
minimumRunningNotionalTakerVolume: '1000000',
volumeDiscountFactor: '0.3',
},
{
minimumRunningNotionalTakerVolume: '1500000',
volumeDiscountFactor: '0.35',
},
{
minimumRunningNotionalTakerVolume: '2000000',
volumeDiscountFactor: '0.4',
},
],
endOfProgramTimestamp: '1970-01-01T00:00:01.791568493Z',
windowLength: 7,
};
describe('ProposalVolumeDiscountProgramDetails', () => {
it('should not render if proposal is null', () => {
render(<ProposalVolumeDiscountProgramDetails proposal={null} />);
expect(
screen.queryByTestId('proposal-volume-discount-program-details')
).toBeNull();
});
it('should not render if __typename is not UpdateVolumeDiscountProgram', () => {
const updateMarketProposal = generateProposal({
terms: {
change: {
__typename: 'UpdateMarket',
},
},
});
render(
<ProposalVolumeDiscountProgramDetails proposal={updateMarketProposal} />
);
expect(
screen.queryByTestId('proposal-volume-discount-program-details')
).toBeNull();
});
it('should not render if there are no relevant fields', () => {
const incompleteProposal = generateProposal({
terms: {
change: {
__typename: 'UpdateVolumeDiscountProgram',
},
},
});
render(
<ProposalVolumeDiscountProgramDetails proposal={incompleteProposal} />
);
render(<ProposalVolumeDiscountProgramDetails change={null} />);
expect(
screen.queryByTestId('proposal-volume-discount-program-details')
).toBeNull();
});
it('should render relevant fields if present', () => {
render(
<ProposalVolumeDiscountProgramDetails proposal={mockReferralProposal} />
);
render(<ProposalVolumeDiscountProgramDetails change={mockChange} />);
expect(
screen.getByTestId('proposal-volume-discount-program-window-length')
).toBeInTheDocument();
@@ -11,10 +11,10 @@ import {
} from '../proposal-referral-program-details';
import { formatNumberPercentage } from '@vegaprotocol/utils';
import BigNumber from 'bignumber.js';
import { type Proposal } from '../../types';
import { type UpdateVolumeDiscountProgramsFragment } from '../../__generated__/Proposals';
interface ProposalReferralProgramDetailsProps {
proposal: Proposal | null;
change: UpdateVolumeDiscountProgramsFragment | null;
}
export const formatVolumeDiscountFactor = (value: string) => {
@@ -22,16 +22,16 @@ export const formatVolumeDiscountFactor = (value: string) => {
};
export const ProposalVolumeDiscountProgramDetails = ({
proposal,
change,
}: ProposalReferralProgramDetailsProps) => {
const { t } = useTranslation();
if (proposal?.terms?.change?.__typename !== 'UpdateVolumeDiscountProgram') {
if (change?.__typename !== 'UpdateVolumeDiscountProgram') {
return null;
}
const benefitTiers = proposal?.terms?.change?.benefitTiers;
const windowLength = proposal?.terms?.change?.windowLength;
const endOfProgramTimestamp = proposal?.terms?.change?.endOfProgramTimestamp;
const benefitTiers = change.benefitTiers;
const windowLength = change?.windowLength;
const endOfProgramTimestamp = change?.endOfProgramTimestamp;
if (!benefitTiers && !windowLength && !endOfProgramTimestamp) {
return null;
@@ -0,0 +1,46 @@
import classNames from 'classnames';
// rainbow-ish order
const COLOURS = ['red', 'pink', 'orange', 'yellow', 'green', 'blue', 'purple'];
const getColour = (indicator: number, max = COLOURS.length) => {
const available =
max < COLOURS.length ? COLOURS.slice(COLOURS.length - max) : COLOURS;
const tiers = Object.keys(available).length;
let index = Math.abs(indicator - 1);
if (indicator >= tiers) {
index = index % tiers;
}
return available[index];
};
export const getStyle = (indicator: number, max = COLOURS.length) =>
classNames({
'bg-vega-yellow-400 after:bg-vega-yellow-400':
'yellow' === getColour(indicator, max),
'bg-vega-green-400 after:bg-vega-green-400':
'green' === getColour(indicator, max),
'bg-vega-blue-400 after:bg-vega-blue-400':
'blue' === getColour(indicator, max),
'bg-vega-purple-400 after:bg-vega-purple-400':
'purple' === getColour(indicator, max),
'bg-vega-pink-400 after:bg-vega-pink-400':
'pink' === getColour(indicator, max),
'bg-vega-orange-400 after:bg-vega-orange-400':
'orange' === getColour(indicator, max),
'bg-vega-red-400 after:bg-vega-red-400':
'red' === getColour(indicator, max),
'bg-vega-clight-600 after:bg-vega-clight-600':
'none' === getColour(indicator, max),
});
export const getIndicatorStyle = (indicator: number) =>
classNames(
'rounded-sm text-black inline-block px-1 py-1 font-alpha calt h-8 w-7 text-center',
'text-border-1',
getStyle(indicator),
// Comment below if you want to remove the "chevron"
'relative mr-[11px]',
'after:absolute after:z-[-1] after:top-1 after:right-[-11px] after:rounded-sm',
"after:w-[22.62px] after:h-[22.62px] after:rotate-45 after:content-['']"
);
@@ -0,0 +1,14 @@
import { useMarketInfoQuery } from '@vegaprotocol/markets';
export const MarketName = ({ marketId }: { marketId?: string }) => {
const { data } = useMarketInfoQuery({
variables: {
marketId: marketId || '',
},
skip: !marketId,
});
return (
<span>{data?.market?.tradableInstrument.instrument.code || marketId}</span>
);
};
@@ -0,0 +1,133 @@
import { type ProposalTermsFieldsFragment } from '../../__generated__/Proposals';
import { type Proposal, type BatchProposal } from '../../types';
import { ListAsset } from '../list-asset';
import { ProposalAssetDetails } from '../proposal-asset-details';
import { ProposalMarketChanges } from '../proposal-market-changes';
import { ProposalMarketData } from '../proposal-market-data';
import { ProposalReferralProgramDetails } from '../proposal-referral-program-details';
import {
ProposalCancelTransferDetails,
ProposalTransferDetails,
} from '../proposal-transfer';
import { ProposalUpdateBenefitTiers } from '../proposal-update-benefit-tiers';
import { ProposalUpdateMarketState } from '../proposal-update-market-state';
import { ProposalVolumeDiscountProgramDetails } from '../proposal-volume-discount-program-details';
import { getIndicatorStyle } from './colours';
import { type ProposalNode } from './proposal-utils';
export const ProposalChangeDetails = ({
proposal,
terms,
restData,
indicator,
}: {
proposal: Proposal | BatchProposal;
terms: ProposalTermsFieldsFragment;
restData: ProposalNode | null;
indicator?: number;
}) => {
let details = null;
switch (terms.change.__typename) {
case 'NewAsset': {
if (proposal.id && terms.change.source.__typename === 'ERC20') {
details = (
<div>
<ListAsset
assetId={proposal.id}
withdrawalThreshold={terms.change.source.withdrawThreshold}
lifetimeLimit={terms.change.source.lifetimeLimit}
/>
<ProposalAssetDetails change={terms.change} assetId={proposal.id} />
</div>
);
}
break;
}
case 'UpdateAsset': {
if (proposal.id) {
details = (
<ProposalAssetDetails
change={terms.change}
assetId={terms.change.assetId}
/>
);
}
break;
}
case 'NewMarket': {
if (proposal.id) {
details = <ProposalMarketData proposalId={proposal.id} />;
}
break;
}
case 'UpdateMarket': {
if (proposal.id) {
details = (
<div className="flex flex-col gap-4">
<ProposalMarketData proposalId={proposal.id} />
<ProposalMarketChanges
indicator={indicator}
marketId={terms.change.marketId}
updateProposalNode={restData}
/>
</div>
);
}
break;
}
case 'NewTransfer': {
if (proposal.id) {
details = <ProposalTransferDetails proposalId={proposal.id} />;
}
break;
}
case 'CancelTransfer': {
if (proposal.id) {
details = <ProposalCancelTransferDetails proposalId={proposal.id} />;
}
break;
}
case 'UpdateMarketState': {
details = <ProposalUpdateMarketState change={terms.change} />;
break;
}
case 'UpdateReferralProgram': {
details = <ProposalReferralProgramDetails change={terms.change} />;
break;
}
case 'UpdateVolumeDiscountProgram': {
details = <ProposalVolumeDiscountProgramDetails change={terms.change} />;
break;
}
case 'UpdateNetworkParameter': {
if (
terms.change.networkParameter.key === 'rewards.vesting.benefitTiers' ||
terms.change.networkParameter.key ===
'rewards.activityStreak.benefitTiers'
) {
details = <ProposalUpdateBenefitTiers change={terms.change} />;
}
break;
}
case 'NewFreeform':
case 'NewSpotMarket':
case 'UpdateSpotMarket':
default: {
break;
}
}
if (indicator != null) {
details = (
<div className="flex gap-3 mb-3">
<div className={getIndicatorStyle(indicator)}>{indicator}</div>
<div>{details}</div>
</div>
);
}
return details;
};
@@ -0,0 +1,261 @@
import compact from 'lodash/compact';
import { ENV } from '../../../../config';
import { useEffect, useState } from 'react';
type Maybe<T> = T | null | undefined;
type ProposalState =
| 'STATE_UNSPECIFIED'
| 'STATE_FAILED'
| 'STATE_OPEN'
| 'STATE_PASSED'
| 'STATE_REJECTED'
| 'STATE_DECLINED'
| 'STATE_ENACTED'
| 'STATE_WAITING_FOR_NODE_VOTE';
type ProposalType =
| 'TYPE_UNSPECIFIED'
| 'TYPE_ALL'
| 'TYPE_NEW_MARKET'
| 'TYPE_UPDATE_MARKET'
| 'TYPE_NETWORK_PARAMETERS'
| 'TYPE_NEW_ASSET'
| 'TYPE_NEW_FREE_FORM'
| 'TYPE_UPDATE_ASSET'
| 'TYPE_NEW_SPOT_MARKET'
| 'TYPE_UPDATE_SPOT_MARKET'
| 'TYPE_NEW_TRANSFER'
| 'TYPE_CANCEL_TRANSFER'
| 'TYPE_UPDATE_MARKET_STATE'
| 'TYPE_UPDATE_REFERRAL_PROGRAM'
| 'TYPE_UPDATE_VOLUME_DISCOUNT_PROGRAM';
type ProposalNodeType = 'TYPE_SINGLE_OR_UNSPECIFIED' | 'TYPE_BATCH';
type ProposalData = {
id: string;
rationale: {
description: string;
title: string;
};
state: ProposalState;
timestamp: string;
};
type Terms = {
cancelTransfer?: { changes: unknown };
enactmentTimestamp: string;
newAsset?: { changes: unknown };
newFreeform: object;
newMarket?: { changes: unknown };
newSpotMarket?: { changes: unknown };
newTransfer?: { changes: unknown };
updateAsset?: { assetId: string; changes: unknown };
updateMarket?: { marketId: string; changes: unknown };
updateMarketState?: {
changes: {
marketId: string;
price: string;
updateType:
| 'MARKET_STATE_UPDATE_TYPE_UNSPECIFIED'
| 'MARKET_STATE_UPDATE_TYPE_TERMINATE'
| 'MARKET_STATE_UPDATE_TYPE_SUSPEND'
| 'MARKET_STATE_UPDATE_TYPE_RESUME';
};
};
updateNetworkParameter?: { changes: unknown };
updateReferralProgram?: { changes: unknown };
updateSpotMarket?: { marketId: string; changes: unknown };
updateVolumeDiscountProgram?: { changes: unknown };
};
export type SingleProposalData = ProposalData & {
terms: Terms & {
closingTimestamp: string;
validationTimestamp: string;
};
};
type BatchProposalData = ProposalData & {
batchTerms: {
changes: Terms[];
};
};
export type SubProposalData = SingleProposalData & {
batchId: string;
};
export type ProposalNode = {
proposal: ProposalData;
proposalType: ProposalNodeType;
proposals: SubProposalData[];
};
type SingleProposalNode = ProposalNode & {
proposal: SingleProposalData;
proposalType: 'TYPE_SINGLE_OR_UNSPECIFIED';
proposals: [];
};
type BatchProposalNode = ProposalNode & {
proposal: BatchProposalData;
proposalType: 'TYPE_BATCH';
};
export const isProposalNode = (node: unknown): node is ProposalNode =>
Boolean(
typeof node === 'object' &&
node &&
'proposal' in node &&
typeof node.proposal === 'object' &&
node?.proposal &&
'id' in node.proposal &&
node?.proposal?.id
);
export const isSingleProposalNode = (
node: Maybe<ProposalNode>
): node is SingleProposalNode =>
Boolean(
node &&
node?.proposalType === 'TYPE_SINGLE_OR_UNSPECIFIED' &&
node?.proposal
);
export const isBatchProposalNode = (
node: Maybe<ProposalNode>
): node is BatchProposalNode =>
Boolean(
node &&
node?.proposalType === 'TYPE_BATCH' &&
node?.proposal &&
'batchTerms' in node.proposal &&
node?.proposals?.length > 0
);
// this includes also batch proposals with `updateMarket`s 👍
const PROPOSALS_ENDPOINT = `${ENV.rest}governances?proposalState=:proposalState&proposalType=:proposalType`;
// this can be queried also by sub proposal id as `proposalId` and it will
// return full batch proposal data with all of its sub proposals including
// the requested one inside `proposals` array.
const PROPOSAL_ENDPOINT = `${ENV.rest}governance?proposalId=:proposalId`;
export const getProposals = async ({
proposalState,
proposalType,
}: {
proposalState: ProposalState;
proposalType: ProposalType;
}) => {
try {
const response = await fetch(
PROPOSALS_ENDPOINT.replace(':proposalState', proposalState).replace(
':proposalType',
proposalType
)
);
if (response.ok) {
const data = await response.json();
if (
data &&
'connection' in data &&
data.connection &&
'edges' in data.connection &&
data.connection.edges?.length > 0
) {
const nodes = compact(
data.connection.edges.map((e: { node?: object }) => e?.node)
).filter(isProposalNode);
return nodes;
}
}
} catch {
// NOOP - ignore errors
}
return [];
};
export const getProposal = async ({ proposalId }: { proposalId: string }) => {
try {
const response = await fetch(
PROPOSAL_ENDPOINT.replace(':proposalId', proposalId)
);
if (response.ok) {
const data = await response.json();
if (data && 'data' in data && isProposalNode(data.data)) {
return data.data as ProposalNode;
}
}
} catch (err) {
// NOOP - ignore errors
}
return null;
};
export const useFetchProposal = ({ proposalId }: { proposalId?: string }) => {
const [data, setData] = useState<ProposalNode | null>(null);
const [loading, setLoading] = useState<boolean>(false);
useEffect(() => {
const cb = async () => {
if (!proposalId) return;
setLoading(true);
const data = await getProposal({ proposalId });
setLoading(false);
if (data) {
setData(data);
}
};
cb();
}, [proposalId]);
return { data, loading };
};
export const useFetchProposals = ({
proposalState,
proposalType,
}: {
proposalState: ProposalState;
proposalType: ProposalType;
}) => {
const [data, setData] = useState<ProposalNode[]>([]);
const [loading, setLoading] = useState<boolean>(false);
useEffect(() => {
const cb = async () => {
setLoading(true);
const data = await getProposals({ proposalState, proposalType });
setLoading(false);
if (data) {
setData(data);
}
};
cb();
}, [proposalState, proposalType]);
return { data, loading };
};
export const flatten = (
nodes: ProposalNode[]
): (SingleProposalData | SubProposalData)[] => {
const flattenNodes = [];
for (const node of nodes) {
if (isSingleProposalNode(node)) {
flattenNodes.push(node.proposal);
}
if (isBatchProposalNode(node)) {
for (const sub of node.proposals) {
flattenNodes.push(sub);
}
}
}
return flattenNodes;
};
@@ -6,7 +6,6 @@ import { render, screen } from '@testing-library/react';
import { generateProposal } from '../../test-helpers/generate-proposals';
import { Proposal } from './proposal';
import { ProposalState } from '@vegaprotocol/types';
import { mockNetworkParams } from '../../test-helpers/mocks';
import { type Proposal as IProposal } from '../../types';
jest.mock('@vegaprotocol/network-parameters', () => ({
@@ -38,6 +37,12 @@ jest.mock('../list-asset', () => ({
ListAsset: () => <div data-testid="proposal-list-asset"></div>,
}));
jest.mock('./proposal-change-details', () => ({
ProposalChangeDetails: () => (
<div data-testid="proposal-change-details"></div>
),
}));
const vegaWalletConfig: VegaWalletConfig = {
network: 'TESTNET',
vegaUrl: 'https://vega.xyz',
@@ -56,11 +61,7 @@ const renderComponent = (proposal: IProposal) => {
<MemoryRouter>
<MockedProvider>
<VegaWalletProvider config={vegaWalletConfig}>
<Proposal
restData={{}}
proposal={proposal}
networkParams={mockNetworkParams}
/>
<Proposal restData={null} proposal={proposal} />
</VegaWalletProvider>
</MockedProvider>
</MemoryRouter>
@@ -99,27 +100,6 @@ it('renders each section', async () => {
expect(await screen.findByTestId('proposal-header')).toBeInTheDocument();
expect(screen.getByTestId('proposal-change-table')).toBeInTheDocument();
expect(screen.getByTestId('proposal-json')).toBeInTheDocument();
expect(screen.getByTestId('proposal-change-details')).toBeInTheDocument();
expect(screen.queryByTestId('proposal-list-asset')).not.toBeInTheDocument();
});
it('renders whitelist section if proposal is new asset and source is erc20', async () => {
const proposal = generateProposal({
terms: {
change: {
__typename: 'NewAsset',
name: 'foo',
symbol: 'FOO',
decimals: 18,
quantum: '1',
source: {
__typename: 'ERC20',
lifetimeLimit: '1',
withdrawThreshold: '100',
},
},
},
});
renderComponent(proposal);
expect(screen.getByTestId('proposal-list-asset')).toBeInTheDocument();
});
@@ -5,152 +5,27 @@ import { ProposalHeader } from '../proposal-detail-header/proposal-header';
import { ProposalDescription } from '../proposal-description';
import { ProposalChangeTable } from '../proposal-change-table';
import { ProposalJson } from '../proposal-json';
import { ProposalAssetDetails } from '../proposal-asset-details';
import { ProposalReferralProgramDetails } from '../proposal-referral-program-details';
import { ProposalVolumeDiscountProgramDetails } from '../proposal-volume-discount-program-details';
import { UserVote } from '../vote-details';
import { ListAsset } from '../list-asset';
import Routes from '../../../routes';
import { ProposalMarketData } from '../proposal-market-data';
import { type MarketInfo } from '@vegaprotocol/markets';
import { type AssetQuery } from '@vegaprotocol/assets';
import { removePaginationWrapper } from '@vegaprotocol/utils';
import { ProposalState } from '@vegaprotocol/types';
import { ProposalMarketChanges } from '../proposal-market-changes';
import { ProposalUpdateMarketState } from '../proposal-update-market-state';
import { type NetworkParamsResult } from '@vegaprotocol/network-parameters';
import { type ProposalNode } from './proposal-utils';
import { useVoteSubmit } from '@vegaprotocol/proposals';
import { useUserVote } from '../vote-details/use-user-vote';
import {
ProposalCancelTransferDetails,
ProposalTransferDetails,
} from '../proposal-transfer';
import { useFeatureFlags } from '@vegaprotocol/environment';
import { ProposalUpdateBenefitTiers } from '../proposal-update-benefit-tiers';
import { type Proposal as IProposal } from '../../types';
import { type Proposal as IProposal, type BatchProposal } from '../../types';
import { ProposalChangeDetails } from './proposal-change-details';
import { type JsonValue } from 'type-fest';
export interface ProposalProps {
proposal: IProposal;
networkParams: Partial<NetworkParamsResult>;
marketData?: MarketInfo | null;
parentMarketData?: MarketInfo | null;
assetData?: AssetQuery | null;
proposal: IProposal | BatchProposal;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
restData: any;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
originalMarketProposalRestData?: any;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
mostRecentlyEnactedAssociatedMarketProposal?: any;
restData: ProposalNode | null;
}
export const Proposal = ({
proposal,
networkParams,
restData,
marketData,
parentMarketData,
assetData,
originalMarketProposalRestData,
mostRecentlyEnactedAssociatedMarketProposal,
}: ProposalProps) => {
const featureFlags = useFeatureFlags((state) => state.flags);
export const Proposal = ({ proposal, restData }: ProposalProps) => {
const { t } = useTranslation();
const { submit, Dialog, finalizedVote, transaction } = useVoteSubmit();
const { voteState, voteDatetime } = useUserVote(proposal?.id, finalizedVote);
if (!proposal) {
return null;
}
let asset = assetData
? removePaginationWrapper(assetData.assetsConnection?.edges)[0]
: undefined;
const originalAsset = asset;
if (proposal.terms.change.__typename === 'UpdateAsset' && asset) {
asset = {
...asset,
quantum: proposal.terms.change.quantum,
source: { ...asset.source },
};
if (asset.source.__typename === 'ERC20') {
asset.source.lifetimeLimit = proposal.terms.change.source.lifetimeLimit;
asset.source.withdrawThreshold =
proposal.terms.change.source.withdrawThreshold;
}
}
let minVoterBalance = null;
if (networkParams) {
switch (proposal.terms.change.__typename) {
case 'UpdateMarket':
case 'UpdateMarketState':
minVoterBalance =
networkParams.governance_proposal_updateMarket_minVoterBalance;
break;
case 'NewMarket':
minVoterBalance =
networkParams.governance_proposal_market_minVoterBalance;
break;
case 'NewAsset':
minVoterBalance =
networkParams.governance_proposal_asset_minVoterBalance;
break;
case 'UpdateAsset':
minVoterBalance =
networkParams.governance_proposal_updateAsset_minVoterBalance;
break;
case 'UpdateNetworkParameter':
minVoterBalance =
networkParams.governance_proposal_updateNetParam_minVoterBalance;
break;
case 'NewFreeform':
minVoterBalance =
networkParams.governance_proposal_freeform_minVoterBalance;
break;
case 'NewTransfer':
// TODO: check minVoterBalance for 'NewTransfer'
minVoterBalance =
networkParams.governance_proposal_freeform_minVoterBalance;
break;
case 'CancelTransfer':
// TODO: check minVoterBalance for 'CancelTransfer'
minVoterBalance =
networkParams.governance_proposal_freeform_minVoterBalance;
break;
case 'UpdateReferralProgram':
minVoterBalance =
networkParams.governance_proposal_referralProgram_minVoterBalance;
break;
case 'UpdateVolumeDiscountProgram':
minVoterBalance =
networkParams.governance_proposal_VolumeDiscountProgram_minVoterBalance;
break;
}
}
// Show governance transfer details only if the GOVERNANCE_TRANSFERS flag is on.
const governanceTransferDetails = featureFlags.GOVERNANCE_TRANSFERS && (
<>
{proposal.terms.change.__typename === 'NewTransfer' && (
/** Governance New Transfer Details */
<div className="mb-4">
<ProposalTransferDetails proposal={proposal} />
</div>
)}
{proposal.terms.change.__typename === 'CancelTransfer' && (
/** Governance Cancel Transfer Details */
<div className="mb-4">
<ProposalCancelTransferDetails proposal={proposal} />
</div>
)}
</>
);
return (
<section data-testid="proposal">
<div className="flex items-center gap-1 mb-6">
@@ -181,91 +56,37 @@ export const Proposal = ({
<ProposalChangeTable proposal={proposal} />
</div>
{proposal.terms.change.__typename === 'NewAsset' &&
proposal.terms.change.source.__typename === 'ERC20' &&
proposal.id ? (
<ListAsset
assetId={proposal.id}
withdrawalThreshold={proposal.terms.change.source.withdrawThreshold}
lifetimeLimit={proposal.terms.change.source.lifetimeLimit}
/>
) : null}
<div className="mb-4">
<ProposalDescription description={proposal.rationale.description} />
</div>
{marketData && (
<div className="mb-4">
<ProposalMarketData
marketData={marketData}
parentMarketData={parentMarketData ? parentMarketData : undefined}
<div className="mb-4">
{proposal.__typename === 'Proposal' ? (
<ProposalChangeDetails
proposal={proposal}
terms={proposal.terms}
restData={restData}
/>
</div>
)}
{proposal.terms.change.__typename === 'UpdateMarketState' && (
<div className="mb-4">
<ProposalUpdateMarketState proposal={proposal} />
</div>
)}
{proposal.terms.change.__typename === 'UpdateMarket' && (
<div className="mb-4">
<ProposalMarketChanges
originalProposal={
originalMarketProposalRestData?.data?.proposal?.terms?.newMarket
?.changes || {}
}
latestEnactedProposal={
mostRecentlyEnactedAssociatedMarketProposal?.node?.proposal?.terms
?.updateMarket?.changes || {}
}
updatedProposal={
restData?.data?.proposal?.terms?.updateMarket?.changes || {}
}
/>
</div>
)}
{(proposal.terms.change.__typename === 'NewAsset' ||
proposal.terms.change.__typename === 'UpdateAsset') &&
asset && (
<div className="mb-4">
<ProposalAssetDetails asset={asset} originalAsset={originalAsset} />
</div>
)}
{proposal.terms.change.__typename === 'UpdateReferralProgram' && (
<div className="mb-4">
<ProposalReferralProgramDetails proposal={proposal} />
</div>
)}
{proposal.terms.change.__typename === 'UpdateVolumeDiscountProgram' && (
<div className="mb-4">
<ProposalVolumeDiscountProgramDetails proposal={proposal} />
</div>
)}
{proposal.terms.change.__typename === 'UpdateNetworkParameter' &&
proposal.terms.change.networkParameter.key.slice(-13) ===
'.benefitTiers' && (
<div className="mb-4">
<ProposalUpdateBenefitTiers proposal={proposal} />
</div>
)}
{governanceTransferDetails}
) : proposal.__typename === 'BatchProposal' ? (
proposal.subProposals?.map((p, i) => {
if (!p?.terms) return null;
return (
<ProposalChangeDetails
indicator={i + 1}
key={i}
proposal={proposal}
terms={p.terms}
restData={restData}
/>
);
})
) : null}
</div>
<div className="mb-10">
<RoundedWrapper paddingBottom={true}>
<UserVote
proposal={proposal}
minVoterBalance={minVoterBalance}
spamProtectionMinTokens={
networkParams?.spam_protection_voting_min_tokens
}
submit={submit}
dialog={Dialog}
transaction={transaction}
@@ -276,7 +97,7 @@ export const Proposal = ({
</div>
<div className="mb-6">
<ProposalJson proposal={restData?.data?.proposal} />
<ProposalJson proposal={restData?.proposal as unknown as JsonValue} />
</div>
</section>
);
@@ -1,149 +1,25 @@
import { BrowserRouter as Router } from 'react-router-dom';
import { AppStateProvider } from '../../../../contexts/app-state/app-state-provider';
import { VegaWalletContext } from '@vegaprotocol/wallet';
import { type MockedResponse } from '@apollo/client/testing';
import { MockedProvider } from '@apollo/client/testing';
import { render, screen } from '@testing-library/react';
import { format } from 'date-fns';
import { ProposalRejectionReason, ProposalState } from '@vegaprotocol/types';
import { generateProposal } from '../../test-helpers/generate-proposals';
import { ProposalsListItemDetails } from './proposals-list-item-details';
import { DATE_FORMAT_DETAILED } from '../../../../lib/date-formats';
import {
mockWalletContext,
networkParamsQueryMock,
fiveMinutes,
fiveHours,
fiveDays,
lastWeek,
nextWeek,
} from '../../test-helpers/mocks';
import { type Proposal } from '../../types';
import { mockWalletContext } from '../../test-helpers/mocks';
const renderComponent = (
proposal: Proposal,
// eslint-disable-next-line @typescript-eslint/no-explicit-any
mocks: MockedResponse<any>[] = [networkParamsQueryMock]
) =>
const renderComponent = (id: string) =>
render(
<Router>
<MockedProvider mocks={mocks}>
<AppStateProvider>
<VegaWalletContext.Provider value={mockWalletContext}>
<ProposalsListItemDetails proposal={proposal} />
</VegaWalletContext.Provider>
</AppStateProvider>
</MockedProvider>
<VegaWalletContext.Provider value={mockWalletContext}>
<ProposalsListItemDetails id={id} />
</VegaWalletContext.Provider>
</Router>
);
beforeAll(() => {
jest.useFakeTimers();
jest.setSystemTime(0);
});
afterAll(() => {
jest.useRealTimers();
});
describe('Proposals list item details', () => {
it('Renders proposal state: Enacted', () => {
renderComponent(
generateProposal({
state: ProposalState.STATE_ENACTED,
terms: {
enactmentDatetime: lastWeek.toString(),
},
})
);
expect(screen.getByTestId('vote-details')).toHaveTextContent(
format(lastWeek, DATE_FORMAT_DETAILED)
);
});
it('Renders proposal state: Passed', () => {
renderComponent(
generateProposal({
state: ProposalState.STATE_PASSED,
terms: {
closingDatetime: lastWeek.toString(),
enactmentDatetime: nextWeek.toString(),
},
})
);
expect(screen.getByTestId('vote-details')).toHaveTextContent(
`Enacts on ${format(nextWeek, DATE_FORMAT_DETAILED)}`
);
});
it('Renders proposal state: Waiting for node vote', () => {
renderComponent(
generateProposal({
state: ProposalState.STATE_WAITING_FOR_NODE_VOTE,
terms: {
enactmentDatetime: nextWeek.toString(),
},
})
);
expect(screen.getByTestId('vote-details')).toHaveTextContent(
`Enacts on ${format(nextWeek, DATE_FORMAT_DETAILED)}`
);
});
it('Renders proposal state: Open - 5 minutes left to vote', () => {
renderComponent(
generateProposal({
state: ProposalState.STATE_OPEN,
terms: {
closingDatetime: fiveMinutes.toString(),
},
})
);
expect(screen.getByTestId('vote-details')).toHaveTextContent(
'5 minutes left to vote'
);
});
it('Renders proposal state: Open - 5 hours left to vote', () => {
renderComponent(
generateProposal({
state: ProposalState.STATE_OPEN,
terms: {
closingDatetime: fiveHours.toString(),
},
})
);
expect(screen.getByTestId('vote-details')).toHaveTextContent(
'5 hours left to vote'
);
});
it('Renders proposal state: Open - 5 days left to vote', () => {
renderComponent(
generateProposal({
state: ProposalState.STATE_OPEN,
terms: {
closingDatetime: fiveDays.toString(),
},
})
);
expect(screen.getByTestId('vote-details')).toHaveTextContent(
'5 days left to vote'
);
});
it('Renders proposal state: Rejected', () => {
renderComponent(
generateProposal({
state: ProposalState.STATE_REJECTED,
terms: {
enactmentDatetime: lastWeek.toString(),
},
rejectionReason:
ProposalRejectionReason.PROPOSAL_ERROR_INVALID_FUTURE_PRODUCT,
})
);
expect(screen.getByTestId('vote-status')).toHaveTextContent(
'Invalid future product'
it('links to single proposal page', () => {
const proposalId = 'proposal-id';
renderComponent(proposalId);
expect(screen.getByRole('link')).toHaveAttribute(
'href',
expect.stringContaining(proposalId)
);
});
});
@@ -1,101 +1,16 @@
import { type ReactNode } from 'react';
import { Link } from 'react-router-dom';
import { Button } from '@vegaprotocol/ui-toolkit';
import { differenceInHours, format, formatDistanceToNowStrict } from 'date-fns';
import { useTranslation } from 'react-i18next';
import { DATE_FORMAT_DETAILED } from '../../../../lib/date-formats';
import {
ProposalRejectionReasonMapping,
ProposalState,
} from '@vegaprotocol/types';
import Routes from '../../../routes';
import { type Proposal } from '../../types';
export const ProposalsListItemDetails = ({
proposal,
}: {
proposal: Proposal;
}) => {
export const ProposalsListItemDetails = ({ id }: { id: string }) => {
const { t } = useTranslation();
const state = proposal?.state;
const nowToEnactmentInHours = differenceInHours(
new Date(proposal?.terms.closingDatetime),
new Date()
);
let voteDetails: ReactNode;
let voteStatus: ReactNode;
switch (state) {
case ProposalState.STATE_ENACTED: {
voteDetails =
proposal?.terms.enactmentDatetime &&
t('enactedOn{{date}}', {
enactmentDate:
proposal?.terms.enactmentDatetime &&
format(
new Date(proposal?.terms.enactmentDatetime),
DATE_FORMAT_DETAILED
),
});
break;
}
case ProposalState.STATE_PASSED: {
voteDetails =
proposal?.terms.change.__typename !== 'NewFreeform' &&
t('enactsOn{{date}}', {
enactmentDate:
proposal?.terms.enactmentDatetime &&
format(
new Date(proposal.terms.enactmentDatetime),
DATE_FORMAT_DETAILED
),
});
break;
}
case ProposalState.STATE_WAITING_FOR_NODE_VOTE: {
voteDetails =
proposal?.terms.change.__typename !== 'NewFreeform' &&
t('enactsOn{{date}}', {
enactmentDate:
proposal?.terms.enactmentDatetime &&
format(
new Date(proposal.terms.enactmentDatetime),
DATE_FORMAT_DETAILED
),
});
break;
}
case ProposalState.STATE_OPEN: {
voteDetails = (
<span className={nowToEnactmentInHours < 6 ? 'text-vega-orange' : ''}>
{formatDistanceToNowStrict(new Date(proposal?.terms.closingDatetime))}{' '}
{t('left to vote')}
</span>
);
break;
}
case ProposalState.STATE_REJECTED: {
voteStatus = proposal?.rejectionReason && (
<>{t(ProposalRejectionReasonMapping[proposal.rejectionReason])}</>
);
break;
}
}
return (
<div className="mt-4 items-start text-sm">
<div className="flex items-center gap-2 text-vega-light-300 mb-2">
{voteDetails && <span data-testid="vote-details">{voteDetails}</span>}
{voteDetails && voteStatus && <span>&middot;</span>}
{voteStatus && <span data-testid="vote-status">{voteStatus}</span>}
</div>
{proposal?.id && (
<Link to={`${Routes.PROPOSALS}/${proposal.id}`}>
<Button data-testid="view-proposal-btn">{t('viewDetails')}</Button>
</Link>
)}
<Link to={`${Routes.PROPOSALS}/${id}`}>
<Button data-testid="view-proposal-btn">{t('viewDetails')}</Button>
</Link>
</div>
);
};
@@ -2,10 +2,10 @@ import { RoundedWrapper } from '@vegaprotocol/ui-toolkit';
import { ProposalHeader } from '../proposal-detail-header/proposal-header';
import { ProposalsListItemDetails } from './proposals-list-item-details';
import { useUserVote } from '../vote-details/use-user-vote';
import { type Proposal } from '../../types';
import { type Proposal, type BatchProposal } from '../../types';
interface ProposalsListItemProps {
proposal?: Proposal | null;
proposal?: Proposal | BatchProposal;
}
export const ProposalsListItem = ({ proposal }: ProposalsListItemProps) => {
@@ -16,7 +16,7 @@ export const ProposalsListItem = ({ proposal }: ProposalsListItemProps) => {
<li id={proposal.id} data-testid="proposals-list-item">
<RoundedWrapper paddingBottom={true} heightFull={true}>
<ProposalHeader proposal={proposal} voteState={voteState} />
<ProposalsListItemDetails proposal={proposal} />
<ProposalsListItemDetails id={proposal.id} />
</RoundedWrapper>
</li>
);
@@ -3,14 +3,12 @@ import {
generateProtocolUpgradeProposal,
} from '../../test-helpers/generate-proposals';
import { MockedProvider } from '@apollo/client/testing';
import { VegaWalletContext } from '@vegaprotocol/wallet';
import { BrowserRouter as Router } from 'react-router-dom';
import { AppStateProvider } from '../../../../contexts/app-state/app-state-provider';
import { ProposalsList } from './proposals-list';
import { ProposalState } from '@vegaprotocol/types';
import { fireEvent, render, screen, within } from '@testing-library/react';
import {
mockWalletContext,
networkParamsQueryMock,
lastWeek,
nextWeek,
@@ -20,6 +18,16 @@ import {
import { type ProtocolUpgradeProposalFieldsFragment } from '@vegaprotocol/proposals';
import { type Proposal } from '../../types';
jest.mock('../vote-details/use-user-vote', () => ({
useUserVote: jest.fn().mockImplementation(() => ({ voteState: 'NotCast' })),
}));
jest.mock('../proposals-list-item', () => ({
ProposalsListItem: ({ proposal }: { proposal: { id: string } }) => (
<div data-testid="proposals-list-item" id={proposal.id} />
),
}));
const openProposalClosesNextMonth = generateProposal({
id: 'proposal1',
state: ProposalState.STATE_OPEN,
@@ -69,12 +77,10 @@ const renderComponent = (
<Router>
<MockedProvider mocks={[networkParamsQueryMock]}>
<AppStateProvider>
<VegaWalletContext.Provider value={mockWalletContext}>
<ProposalsList
proposals={proposals}
protocolUpgradeProposals={protocolUpgradeProposals || []}
/>
</VegaWalletContext.Provider>
<ProposalsList
proposals={proposals}
protocolUpgradeProposals={protocolUpgradeProposals || []}
/>
</AppStateProvider>
</MockedProvider>
</Router>
@@ -88,10 +94,6 @@ afterAll(() => {
jest.useRealTimers();
});
jest.mock('../vote-details/use-user-vote', () => ({
useUserVote: jest.fn().mockImplementation(() => ({ voteState: 'NotCast' })),
}));
describe('Proposals list', () => {
it('Render a page title and link to the make proposal form', async () => {
render(renderComponent([]));
@@ -11,19 +11,20 @@ import { Button, Toggle } from '@vegaprotocol/ui-toolkit';
import { Link } from 'react-router-dom';
import { ExternalLink } from '@vegaprotocol/ui-toolkit';
import { ExternalLinks } from '@vegaprotocol/environment';
import { type ProposalFieldsFragment } from '../../proposals/__generated__/Proposals';
import { type ProtocolUpgradeProposalFieldsFragment } from '@vegaprotocol/proposals';
import { type Proposal } from '../../types';
import { type BatchProposal, type Proposal } from '../../types';
type Proposals = Array<Proposal | BatchProposal>;
interface ProposalsListProps {
proposals: Proposal[];
proposals: Proposals;
protocolUpgradeProposals: ProtocolUpgradeProposalFieldsFragment[];
lastBlockHeight?: string;
}
interface SortedProposalsProps {
open: Proposal[];
closed: Proposal[];
open: Proposals;
closed: Proposals;
}
interface SortedProtocolUpgradeProposalsProps {
@@ -31,15 +32,26 @@ interface SortedProtocolUpgradeProposalsProps {
closed: ProtocolUpgradeProposalFieldsFragment[];
}
export const orderByDate = (arr: Proposal[]) =>
export const orderByDate = (arr: Proposals) =>
orderBy(
arr,
[
(p) =>
p?.terms?.enactmentDatetime
? new Date(p?.terms?.enactmentDatetime).getTime()
: // has to be defaulted to 0 because new Date(null).getTime() -> NaN which is first when ordered
new Date(p?.terms?.closingDatetime || 0).getTime(),
(p) => {
if (p.__typename === 'BatchProposal') {
// Batch proposals can have different enactment dates, this could be improved by ordering
// by soonest enactment date in the batch
return new Date(p.batchTerms?.closingDatetime || p.datetime);
}
if (p.__typename === 'Proposal') {
return p?.terms?.enactmentDatetime
? new Date(p?.terms?.enactmentDatetime).getTime()
: // has to be defaulted to 0 because new Date(null).getTime() -> NaN which is first when ordered
new Date(p?.terms?.closingDatetime || 0).getTime();
}
throw new Error('invalid proposal');
},
(p) => new Date(p?.datetime).getTime(),
],
['asc', 'asc']
@@ -76,18 +88,40 @@ export const ProposalsList = ({
const sortedProposals: SortedProposalsProps = useMemo(() => {
const initialSorting = proposals.reduce(
(acc: SortedProposalsProps, proposal) => {
if (isFuture(new Date(proposal?.terms.closingDatetime))) {
acc.open.push(proposal);
} else {
acc.closed.push(proposal);
(acc, proposal) => {
if (proposal.__typename === 'Proposal') {
if (isFuture(new Date(proposal?.terms.closingDatetime))) {
acc.open.push(proposal);
} else {
acc.closed.push(proposal);
}
return acc;
}
if (proposal.__typename === 'BatchProposal') {
if (
// this could be improved by sorting by soonest enactment date of all the
// sub proposals
isFuture(
new Date(
proposal.batchTerms?.closingDatetime || proposal.datetime
)
)
) {
acc.open.push(proposal);
} else {
acc.closed.push(proposal);
}
return acc;
}
return acc;
},
{
open: [],
closed: [],
}
} as SortedProposalsProps
);
return {
open:
@@ -121,7 +155,7 @@ export const ProposalsList = ({
};
}, [protocolUpgradeProposals, lastBlockHeight]);
const filterPredicate = (p: ProposalFieldsFragment | Proposal) =>
const filterPredicate = (p: Proposal | BatchProposal) =>
p?.id?.includes(filterString) ||
p?.party?.id?.toString().includes(filterString);
@@ -1,17 +1,8 @@
import { generateProposal } from '../../test-helpers/generate-proposals';
import { MockedProvider } from '@apollo/client/testing';
import { VegaWalletContext } from '@vegaprotocol/wallet';
import { BrowserRouter as Router } from 'react-router-dom';
import { AppStateProvider } from '../../../../contexts/app-state/app-state-provider';
import { RejectedProposalsList } from './rejected-proposals-list';
import { ProposalState } from '@vegaprotocol/types';
import { render, screen, waitFor, within } from '@testing-library/react';
import {
mockWalletContext,
networkParamsQueryMock,
nextWeek,
lastMonth,
} from '../../test-helpers/mocks';
import { render, screen } from '@testing-library/react';
import { nextWeek, lastMonth } from '../../test-helpers/mocks';
import { type Proposal } from '../../types';
const rejectedProposalClosesNextWeek = generateProposal({
@@ -36,31 +27,15 @@ const rejectedProposalClosedLastMonth = generateProposal({
});
const renderComponent = (proposals: Proposal[]) => (
<Router>
<MockedProvider mocks={[networkParamsQueryMock]}>
<AppStateProvider>
<VegaWalletContext.Provider value={mockWalletContext}>
<RejectedProposalsList proposals={proposals} />
</VegaWalletContext.Provider>
</AppStateProvider>
</MockedProvider>
</Router>
<RejectedProposalsList proposals={proposals} />
);
beforeAll(() => {
jest.useFakeTimers();
jest.setSystemTime(0);
});
afterAll(() => {
jest.useRealTimers();
});
jest.mock('../vote-details/use-user-vote', () => ({
useUserVote: jest.fn().mockImplementation(() => ({ voteState: 'NotCast' })),
jest.mock('../proposals-list-item', () => ({
ProposalsListItem: () => <div data-testid="proposals-list-item" />,
}));
describe('Rejected proposals list', () => {
it('Renders a list of proposals', async () => {
it('Renders a list of proposals', () => {
render(
renderComponent([
rejectedProposalClosedLastMonth,
@@ -68,25 +43,13 @@ describe('Rejected proposals list', () => {
])
);
await waitFor(() => {
const rejectedProposals = within(
screen.getByTestId('rejected-proposals')
);
const rejectedProposalsItems = rejectedProposals.getAllByTestId(
'proposals-list-item'
);
expect(rejectedProposalsItems).toHaveLength(2);
});
expect(screen.getAllByTestId('proposals-list-item')).toHaveLength(2);
});
it('Displays text when there are no proposals', async () => {
render(renderComponent([]));
await waitFor(() => {
expect(screen.getByTestId('no-rejected-proposals')).toBeInTheDocument();
expect(
screen.queryByTestId('rejected-proposals')
).not.toBeInTheDocument();
});
expect(screen.getByTestId('no-rejected-proposals')).toBeInTheDocument();
expect(screen.queryByTestId('rejected-proposals')).not.toBeInTheDocument();
});
});
@@ -3,17 +3,17 @@ import { useTranslation } from 'react-i18next';
import { Heading } from '../../../../components/heading';
import { ProposalsListItem } from '../proposals-list-item';
import { ProposalsListFilter } from '../proposals-list-filter';
import { type Proposal } from '../../types';
import { type BatchProposal, type Proposal } from '../../types';
interface ProposalsListProps {
proposals: Proposal[];
proposals: Array<Proposal | BatchProposal>;
}
export const RejectedProposalsList = ({ proposals }: ProposalsListProps) => {
const { t } = useTranslation();
const [filterString, setFilterString] = useState('');
const filterPredicate = (p: Proposal) =>
const filterPredicate = (p: Proposal | BatchProposal) =>
p?.id?.includes(filterString) ||
p?.party?.id?.toString().includes(filterString);
@@ -64,7 +64,7 @@ describe('VoteBreakdown', () => {
jest.useRealTimers();
});
it('Renders majority reached', () => {
it('Renders majority reached', async () => {
const yesVotes = 100;
const noVotes = 0;
@@ -82,10 +82,10 @@ describe('VoteBreakdown', () => {
},
})
);
expect(screen.getByTestId('token-majority-met')).toBeInTheDocument();
expect(await screen.findByTestId('token-majority-met')).toBeInTheDocument();
});
it('Renders majority not reached', () => {
it('Renders majority not reached', async () => {
const yesVotes = 20;
const noVotes = 80;
@@ -103,10 +103,12 @@ describe('VoteBreakdown', () => {
},
})
);
expect(screen.getByTestId('token-majority-not-met')).toBeInTheDocument();
expect(
await screen.findByTestId('token-majority-not-met')
).toBeInTheDocument();
});
it('Renders participation reached', () => {
it('Renders participation reached', async () => {
const yesVotes = 1000;
const noVotes = 0;
@@ -124,10 +126,12 @@ describe('VoteBreakdown', () => {
},
})
);
expect(screen.getByTestId('token-participation-met')).toBeInTheDocument();
expect(
await screen.findByTestId('token-participation-met')
).toBeInTheDocument();
});
it('Renders participation not reached', () => {
it('Renders participation not reached', async () => {
const yesVotes = 0;
const noVotes = 0;
@@ -145,11 +149,11 @@ describe('VoteBreakdown', () => {
})
);
expect(
screen.getByTestId('token-participation-not-met')
await screen.findByTestId('token-participation-not-met')
).toBeInTheDocument();
});
it('Renders proposal state: Update market proposal - Currently expected to pass by LP vote', () => {
it('Renders proposal state: Update market proposal - Currently expected to pass by LP vote', async () => {
renderComponent(
generateProposal({
state: ProposalState.STATE_OPEN,
@@ -170,12 +174,12 @@ describe('VoteBreakdown', () => {
},
})
);
expect(screen.getByTestId('vote-status')).toHaveTextContent(
expect(await screen.findByTestId('vote-status')).toHaveTextContent(
'Currently expected to pass by liquidity vote'
);
});
it('Renders proposal state: Update market proposal - Currently expected to pass by token vote', () => {
it('Renders proposal state: Update market proposal - Currently expected to pass by token vote', async () => {
renderComponent(
generateProposal({
state: ProposalState.STATE_OPEN,
@@ -196,12 +200,12 @@ describe('VoteBreakdown', () => {
},
})
);
expect(screen.getByTestId('vote-status')).toHaveTextContent(
expect(await screen.findByTestId('vote-status')).toHaveTextContent(
'Currently expected to pass by token vote'
);
});
it('Renders proposal state: Update market proposal - Currently expected to fail', () => {
it('Renders proposal state: Update market proposal - Currently expected to fail', async () => {
renderComponent(
generateProposal({
state: ProposalState.STATE_OPEN,
@@ -222,12 +226,12 @@ describe('VoteBreakdown', () => {
},
})
);
expect(screen.getByTestId('vote-status')).toHaveTextContent(
expect(await screen.findByTestId('vote-status')).toHaveTextContent(
'Currently expected to fail'
);
});
it('Progress bar displays status - token majority', () => {
it('Progress bar displays status - token majority', async () => {
const yesVotes = 80;
const noVotes = 20;
@@ -246,13 +250,13 @@ describe('VoteBreakdown', () => {
})
);
const element = screen.getByTestId('token-majority-progress');
const element = await screen.findByTestId('token-majority-progress');
const style = window.getComputedStyle(element);
expect(style.width).toBe(`${yesVotes}%`);
});
it('Progress bar displays status - token participation', () => {
it('Progress bar displays status - token participation', async () => {
const yesVotes = 40;
const noVotes = 20;
const totalVotes = yesVotes + noVotes;
@@ -274,13 +278,13 @@ describe('VoteBreakdown', () => {
})
);
const element = screen.getByTestId('token-participation-progress');
const element = await screen.findByTestId('token-participation-progress');
const style = window.getComputedStyle(element);
expect(style.width).toBe(`${expectedProgress}%`);
});
it('Progress bar displays status - LP majority', () => {
it('Progress bar displays status - LP majority', async () => {
const yesVotesLP = 0.8;
const noVotesLP = 0.2;
const expectedProgress = (yesVotesLP / (yesVotesLP + noVotesLP)) * 100; // 80%
@@ -307,12 +311,12 @@ describe('VoteBreakdown', () => {
})
);
const element = screen.getByTestId('lp-majority-progress');
const element = await screen.findByTestId('lp-majority-progress');
const style = window.getComputedStyle(element);
expect(style.width).toBe(`${expectedProgress}%`);
});
it('Progress bar displays status - LP participation', () => {
it('Progress bar displays status - LP participation', async () => {
const yesVotesLP = 400;
const noVotesLP = 600;
const totalVotesLP = yesVotesLP + noVotesLP;
@@ -341,7 +345,7 @@ describe('VoteBreakdown', () => {
})
);
const element = screen.getByTestId('lp-participation-progress');
const element = await screen.findByTestId('lp-participation-progress');
const style = window.getComputedStyle(element);
expect(style.width).toBe(`${expectedProgress}%`);
});
@@ -1,13 +1,22 @@
import { type ReactNode } from 'react';
import compact from 'lodash/compact';
import countBy from 'lodash/countBy';
import { useState, type ReactNode } from 'react';
import classNames from 'classnames';
import BigNumber from 'bignumber.js';
import { useTranslation } from 'react-i18next';
import { Trans, useTranslation } from 'react-i18next';
import { useVoteInformation } from '../../hooks';
import { Icon, Tooltip } from '@vegaprotocol/ui-toolkit';
import { Tooltip, VegaIcon, VegaIconNames } from '@vegaprotocol/ui-toolkit';
import { formatNumber } from '@vegaprotocol/utils';
import { ProposalState } from '@vegaprotocol/types';
import { CompactNumber } from '@vegaprotocol/react-helpers';
import { type Proposal } from '../../types';
import { type Proposal, type BatchProposal } from '../../types';
import {
type ProposalTermsFieldsFragment,
type VoteFieldsFragment,
} from '../../__generated__/Proposals';
import { useBatchVoteInformation } from '../../hooks/use-vote-information';
import { getIndicatorStyle } from '../proposal/colours';
import { MarketName } from '../proposal/market-name';
export const CompactVotes = ({ number }: { number: BigNumber }) => (
<CompactNumber
@@ -18,10 +27,6 @@ export const CompactVotes = ({ number }: { number: BigNumber }) => (
/>
);
interface VoteBreakdownProps {
proposal: Proposal;
}
interface VoteProgressProps {
percentageFor: BigNumber;
colourfulBg?: boolean;
@@ -36,8 +41,9 @@ const VoteProgress = ({
children,
}: VoteProgressProps) => {
const containerClasses = classNames(
'relative h-10 rounded-md border border-vega-dark-300 overflow-hidden',
colourfulBg ? 'bg-vega-pink' : 'bg-vega-dark-400'
'relative h-2 rounded-md overflow-hidden',
// 'border border-vega-dark-300',
colourfulBg ? 'bg-vega-red' : 'bg-vega-dark-200'
);
const progressClasses = classNames(
@@ -46,17 +52,19 @@ const VoteProgress = ({
);
const textClasses = classNames(
'absolute top-0 left-0 w-full h-full flex items-center justify-start px-3 text-black'
'w-full flex items-center justify-start text-white text-sm pb-1'
);
return (
<div className={containerClasses}>
<div
className={progressClasses}
style={{ width: `${percentageFor}%` }}
data-testid={testId}
></div>
<div>
<div className={textClasses}>{children}</div>
<div className={containerClasses}>
<div
className={progressClasses}
style={{ width: `${percentageFor}%` }}
data-testid={testId}
/>
</div>
</div>
);
};
@@ -75,14 +83,22 @@ const Status = ({ reached, threshold, text, testId }: StatusProps) => {
<div data-testid={testId}>
{reached ? (
<div className="flex items-center gap-2">
<Icon name="tick" size={4} />
<VegaIcon
name={VegaIconNames.TICK}
className="text-vega-green"
size={20}
/>
<span>
{threshold.toString()}% {text} {t('met')}
</span>
</div>
) : (
<div className="flex items-center gap-2">
<Icon name="cross" size={4} />
<VegaIcon
name={VegaIconNames.CROSS}
className="text-vega-red"
size={20}
/>
<span>
{threshold.toString()}% {text} {t('not met')}
</span>
@@ -92,7 +108,256 @@ const Status = ({ reached, threshold, text, testId }: StatusProps) => {
);
};
export const VoteBreakdown = ({ proposal }: VoteBreakdownProps) => {
export const VoteBreakdown = ({
proposal,
}: {
proposal: Proposal | BatchProposal;
}) => {
if (proposal.__typename === 'Proposal') {
return <VoteBreakdownNormal proposal={proposal} />;
}
if (proposal.__typename === 'BatchProposal') {
return <VoteBreakdownBatch proposal={proposal} />;
}
return null;
};
const VoteBreakdownBatch = ({ proposal }: { proposal: BatchProposal }) => {
const [fullBreakdown, setFullBreakdown] = useState(false);
const { t } = useTranslation();
const voteInfo = useBatchVoteInformation({
terms: compact(
proposal.subProposals ? proposal.subProposals.map((p) => p?.terms) : []
),
votes: proposal.votes,
});
if (!voteInfo) return null;
const batchWillPass = voteInfo.every((i) => i.willPass);
const passingCount = countBy(voteInfo, (v) => v.willPass);
if (proposal.state === ProposalState.STATE_OPEN) {
return (
<div className="flex flex-col gap-2">
<div className="flex items-center justify-between gap-2">
{batchWillPass ? (
<p className="flex gap-2 m-0 items-center">
<VegaIcon
name={VegaIconNames.TICK}
className="text-vega-green"
size={20}
/>
{t(
'Currently expected to pass: conditions met for {{count}} of {{total}} proposals',
{
count: passingCount['true'] || 0,
total: voteInfo.length,
}
)}
</p>
) : (
<p className="flex gap-2 m-0 items-center">
<VegaIcon
name={VegaIconNames.CROSS}
className="text-vega-red"
size={20}
/>
{t(
'Currently expected to fail: {{count}} of {{total}} proposals are passing',
{
count: passingCount['true'] || 0,
total: voteInfo.length,
}
)}
</p>
)}
<button
className="underline"
onClick={() => setFullBreakdown((x) => !x)}
>
{fullBreakdown ? 'Hide vote breakdown' : 'Show vote breakdown'}
</button>
</div>
{fullBreakdown && (
<div>
{proposal.subProposals?.map((p, i) => {
if (!p?.terms) return null;
return (
<VoteBreakdownBatchSubProposal
indicator={i + 1}
key={i}
proposal={proposal}
votes={proposal.votes}
terms={p.terms}
/>
);
})}
</div>
)}
</div>
);
} else if (
proposal.state === ProposalState.STATE_DECLINED ||
proposal.state === ProposalState.STATE_PASSED
) {
return (
<div className="flex flex-col gap-2">
<div className="flex items-center justify-between gap-2">
{batchWillPass ? (
<p className="flex gap-2 m-0 items-center">
<VegaIcon
name={VegaIconNames.TICK}
className="text-vega-green"
size={20}
/>
{t(
'Proposal passed: conditions met for {{count}} of {{total}} proposals',
{
count: passingCount['true'] || 0,
total: voteInfo.length,
}
)}
</p>
) : (
<p className="flex gap-2 m-0 items-center">
<VegaIcon
name={VegaIconNames.CROSS}
className="text-vega-red"
size={20}
/>
{t('Proposal failed: {{count}} of {{total}} proposals passed', {
count: passingCount['true'] || 0,
total: voteInfo.length,
})}
</p>
)}
<button
className="underline"
onClick={() => setFullBreakdown((x) => !x)}
>
{fullBreakdown ? 'Hide vote breakdown' : 'Show vote breakdown'}
</button>
</div>
{fullBreakdown && (
<div>
{proposal.subProposals?.map((p, i) => {
if (!p?.terms) return null;
return (
<VoteBreakdownBatchSubProposal
indicator={i + 1}
key={i}
proposal={proposal}
votes={proposal.votes}
terms={p.terms}
/>
);
})}
</div>
)}
</div>
);
}
return null;
};
const VoteBreakdownBatchSubProposal = ({
proposal,
votes,
terms,
indicator,
}: {
proposal: BatchProposal;
votes: VoteFieldsFragment;
terms: ProposalTermsFieldsFragment;
indicator?: number;
}) => {
const { t } = useTranslation();
const voteInfo = useVoteInformation({
votes,
terms,
});
const isProposalOpen = proposal?.state === ProposalState.STATE_OPEN;
const isUpdateMarket = terms?.change?.__typename === 'UpdateMarket';
let marketId = undefined;
if (terms?.change?.__typename === 'UpdateMarket') {
marketId = terms.change.marketId;
}
if (terms?.change?.__typename === 'UpdateMarketState') {
marketId = terms.change.market.id;
}
const marketName = marketId ? (
<>
: <MarketName marketId={marketId} />
</>
) : null;
const indicatorElement = indicator && (
<span className={getIndicatorStyle(indicator)}>{indicator}</span>
);
return (
<div className="mb-6">
<div className="flex items-baseline gap-3 mb-3">
{indicatorElement}
<h4>
{t(terms.change.__typename)} {marketName}
</h4>
</div>
<div className="rounded-sm bg-vega-dark-100 p-3">
<VoteBreakDownUI
voteInfo={voteInfo}
isProposalOpen={isProposalOpen}
isUpdateMarket={isUpdateMarket}
/>
</div>
</div>
);
};
const VoteBreakdownNormal = ({ proposal }: { proposal: Proposal }) => {
const voteInfo = useVoteInformation({
votes: proposal.votes,
terms: proposal.terms,
});
const isProposalOpen = proposal?.state === ProposalState.STATE_OPEN;
const isUpdateMarket = proposal?.terms?.change?.__typename === 'UpdateMarket';
return (
<div className="mb-6">
<VoteBreakDownUI
voteInfo={voteInfo}
isProposalOpen={isProposalOpen}
isUpdateMarket={isUpdateMarket}
/>
</div>
);
};
const VoteBreakDownUI = ({
voteInfo,
isProposalOpen,
isUpdateMarket,
}: {
voteInfo: ReturnType<typeof useVoteInformation>;
isProposalOpen: boolean;
isUpdateMarket: boolean;
}) => {
const defaultDP = 2;
const { t } = useTranslation();
if (!voteInfo) return null;
const {
totalTokensPercentage,
participationMet,
@@ -101,7 +366,6 @@ export const VoteBreakdown = ({ proposal }: VoteBreakdownProps) => {
noPercentage,
noLPPercentage,
yesPercentage,
yesLPPercentage,
yesTokens,
noTokens,
totalEquityLikeShareWeight,
@@ -114,12 +378,9 @@ export const VoteBreakdown = ({ proposal }: VoteBreakdownProps) => {
majorityLPMet,
willPassByTokenVote,
willPassByLPVote,
} = useVoteInformation({ proposal });
lpVoteWeight,
} = voteInfo;
const { t } = useTranslation();
const defaultDP = 2;
const isProposalOpen = proposal?.state === ProposalState.STATE_OPEN;
const isUpdateMarket = proposal?.terms?.change?.__typename === 'UpdateMarket';
const participationThresholdProgress = BigNumber.min(
totalTokensPercentage.dividedBy(requiredParticipation).multipliedBy(100),
new BigNumber(100)
@@ -142,127 +403,52 @@ export const VoteBreakdown = ({ proposal }: VoteBreakdownProps) => {
'flex justify-between flex-wrap gap-6'
);
const sectionClasses = classNames('min-w-[300px] flex-1 flex-grow');
const headingClasses = classNames('mb-2 text-vega-dark-400');
const headingClasses = classNames('mb-2 text-sm text-white font-bold');
const progressDetailsClasses = classNames(
'flex justify-between flex-wrap mt-2 text-sm'
);
return (
<div className="mb-6">
<div>
{isProposalOpen && (
<div
data-testid="vote-status"
className="flex items-center gap-1 mb-2 text-bold"
className="flex items-center gap-2 mb-2 text-bold"
>
<span>
{willPass ? (
<Icon name="tick" size={5} className="text-vega-green" />
<VegaIcon
name={VegaIconNames.TICK}
size={20}
className="text-vega-green"
/>
) : (
<Icon name="cross" size={5} className="text-vega-pink" />
<VegaIcon
name={VegaIconNames.CROSS}
size={20}
className="text-vega-red"
/>
)}
</span>
<span>{t('currentlySetTo')} </span>
{willPass ? (
<span>
<span className="text-vega-green">{t('pass')}</span>
<p className="m-0">
<Trans
i18nKey={'Currently expected to <0>pass</0>'}
components={[<span className="text-vega-green" />]}
/>
{isUpdateMarket && <span> {updateMarketVotePassMethod}</span>}
</span>
</p>
) : (
<span className="text-vega-pink">{t('fail')}</span>
<p className="m-0">
<Trans
i18nKey={'Currently expected to <0>fail</0>'}
components={[<span className="text-vega-red" />]}
/>
</p>
)}
</div>
)}
{isUpdateMarket && (
<div className="mb-4">
<h3 className={headingClasses}>{t('liquidityProviderVote')}</h3>
<div className={sectionWrapperClasses}>
<section
className={sectionClasses}
data-testid="lp-majority-breakdown"
>
<VoteProgress
percentageFor={yesLPPercentage}
colourfulBg={true}
testId="lp-majority-progress"
>
<Status
reached={majorityLPMet}
threshold={requiredMajorityLPPercentage}
text={t('majorityThreshold')}
testId={
majorityLPMet ? 'lp-majority-met' : 'lp-majority-not-met'
}
/>
</VoteProgress>
<div className={progressDetailsClasses}>
<div className="flex items-center gap-1">
<span>{t('liquidityProviderVotesFor')}:</span>
<Tooltip
description={
<span>{yesLPPercentage.toFixed(defaultDP)}%</span>
}
>
<button>{yesLPPercentage.toFixed(1)}%</button>
</Tooltip>
</div>
<div className="flex items-center gap-1">
<span>{t('liquidityProviderVotesAgainst')}:</span>
<span>
<Tooltip
description={
<span>{noLPPercentage.toFixed(defaultDP)}%</span>
}
>
<button>{noLPPercentage.toFixed(1)}%</button>
</Tooltip>
</span>
</div>
</div>
</section>
<section
className={sectionClasses}
data-testid="lp-participation-breakdown"
>
<VoteProgress
percentageFor={
lpParticipationThresholdProgress || new BigNumber(0)
}
testId="lp-participation-progress"
>
<Status
reached={participationLPMet}
threshold={requiredParticipationLP || new BigNumber(1)}
text={t('participationThreshold')}
testId={
participationLPMet
? 'lp-participation-met'
: 'lp-participation-not-met'
}
/>
</VoteProgress>
<div className="flex mt-2 text-sm">
<div className="flex items-center gap-1">
<span>{t('totalLiquidityProviderTokensVoted')}:</span>
<Tooltip
description={formatNumber(
totalEquityLikeShareWeight,
defaultDP
)}
>
<span>{totalEquityLikeShareWeight.toFixed(1)}%</span>
</Tooltip>
</div>
</div>
</section>
</div>
</div>
)}
{isUpdateMarket && <h3 className={headingClasses}>{t('tokenVote')}</h3>}
<div className={sectionWrapperClasses}>
<section
@@ -362,6 +548,97 @@ export const VoteBreakdown = ({ proposal }: VoteBreakdownProps) => {
</div>
</section>
</div>
{/** Liquidity provider vote */}
{isUpdateMarket && (
<div className="mt-3">
<h3 className={headingClasses}>{t('liquidityProviderVote')}</h3>
<div className={sectionWrapperClasses}>
<section
className={sectionClasses}
data-testid="lp-majority-breakdown"
>
<VoteProgress
percentageFor={lpVoteWeight}
colourfulBg={true}
testId="lp-majority-progress"
>
<Status
reached={majorityLPMet}
threshold={requiredMajorityLPPercentage}
text={t('majorityThreshold')}
testId={
majorityLPMet ? 'lp-majority-met' : 'lp-majority-not-met'
}
/>
</VoteProgress>
<div className={progressDetailsClasses}>
<div className="flex items-center gap-1">
<span>{t('liquidityProviderVotesFor')}:</span>
<Tooltip
description={
<span>{lpVoteWeight.toFixed(defaultDP)}%</span>
}
>
<button>{lpVoteWeight.toFixed(1)}%</button>
</Tooltip>
</div>
<div className="flex items-center gap-1">
<span>{t('liquidityProviderVotesAgainst')}:</span>
<span>
<Tooltip
description={
<span>{noLPPercentage.toFixed(defaultDP)}%</span>
}
>
<button>{noLPPercentage.toFixed(1)}%</button>
</Tooltip>
</span>
</div>
</div>
</section>
<section
className={sectionClasses}
data-testid="lp-participation-breakdown"
>
<VoteProgress
percentageFor={
lpParticipationThresholdProgress || new BigNumber(0)
}
testId="lp-participation-progress"
>
<Status
reached={participationLPMet}
threshold={requiredParticipationLP || new BigNumber(1)}
text={t('participationThreshold')}
testId={
participationLPMet
? 'lp-participation-met'
: 'lp-participation-not-met'
}
/>
</VoteProgress>
<div className="flex mt-2 text-sm">
<div className="flex items-center gap-1">
<span>{t('totalLiquidityProviderTokensVoted')}:</span>
<Tooltip
description={formatNumber(
totalEquityLikeShareWeight,
defaultDP
)}
>
<span>{totalEquityLikeShareWeight.toFixed(1)}%</span>
</Tooltip>
</div>
</div>
</section>
</div>
</div>
)}
</div>
);
};
@@ -8,12 +8,10 @@ import { SubHeading } from '../../../../components/heading';
import { type VoteValue } from '@vegaprotocol/types';
import { type DialogProps, type VegaTxState } from '@vegaprotocol/proposals';
import { type VoteState } from './use-user-vote';
import { type Proposal } from '../../types';
import { type Proposal, type BatchProposal } from '../../types';
interface UserVoteProps {
proposal: Proposal;
minVoterBalance: string | null | undefined;
spamProtectionMinTokens: string | null | undefined;
proposal: Proposal | BatchProposal;
transaction: VegaTxState | null;
submit: (voteValue: VoteValue, proposalId: string | null) => Promise<void>;
dialog: (props: DialogProps) => JSX.Element;
@@ -23,8 +21,6 @@ interface UserVoteProps {
export const UserVote = ({
proposal,
minVoterBalance,
spamProtectionMinTokens,
submit,
transaction,
dialog,
@@ -46,12 +42,17 @@ export const UserVote = ({
{pubKey ? (
proposal && (
<VoteButtonsContainer
changeType={
proposal.__typename === 'BatchProposal'
? // @ts-ignore should not be null/undefined
proposal.subProposals[0]?.terms?.change.__typename
: // @ts-ignore should not be null/undefined
proposal.terms?.change.__typename
}
voteState={voteState}
voteDatetime={voteDatetime}
proposalState={proposal.state}
proposalId={proposal.id ?? ''}
minVoterBalance={minVoterBalance}
spamProtectionMinTokens={spamProtectionMinTokens}
className="flex"
submit={submit}
transaction={transaction}
@@ -19,14 +19,18 @@ import { VoteTransactionDialog } from './vote-transaction-dialog';
import { useVoteButtonsQuery } from './__generated__/Stake';
import type { DialogProps, VegaTxState } from '@vegaprotocol/proposals';
import { filterAcceptableGraphqlErrors } from '../../../../lib/party';
import {
NetworkParams,
useNetworkParams,
} from '@vegaprotocol/network-parameters';
import { type ProposalChangeType } from '../../types';
interface VoteButtonsContainerProps {
changeType: ProposalChangeType;
voteState: VoteState | null;
voteDatetime: Date | null;
proposalId: string | null;
proposalState: ProposalState;
minVoterBalance: string | null | undefined;
spamProtectionMinTokens: string | null | undefined;
submit: (voteValue: VoteValue, proposalId: string | null) => Promise<void>;
transaction: VegaTxState | null;
dialog: (props: DialogProps) => JSX.Element;
@@ -35,20 +39,94 @@ interface VoteButtonsContainerProps {
export const VoteButtonsContainer = (props: VoteButtonsContainerProps) => {
const { pubKey } = useVegaWallet();
const {
appState: { decimals },
} = useAppState();
const { data, loading, error } = useVoteButtonsQuery({
variables: { partyId: pubKey || '' },
skip: !pubKey,
});
const { params: networkParams } = useNetworkParams([
NetworkParams.governance_proposal_market_minVoterBalance,
NetworkParams.governance_proposal_updateMarket_minVoterBalance,
NetworkParams.governance_proposal_asset_minVoterBalance,
NetworkParams.governance_proposal_updateAsset_minVoterBalance,
NetworkParams.governance_proposal_updateNetParam_minVoterBalance,
NetworkParams.governance_proposal_freeform_minVoterBalance,
NetworkParams.governance_proposal_referralProgram_minVoterBalance,
NetworkParams.governance_proposal_VolumeDiscountProgram_minVoterBalance,
NetworkParams.governance_proposal_transfer_minVoterBalance,
NetworkParams.spam_protection_voting_min_tokens,
NetworkParams.governance_proposal_market_requiredMajority,
NetworkParams.governance_proposal_updateMarket_requiredMajority,
NetworkParams.governance_proposal_updateMarket_requiredMajorityLP,
NetworkParams.governance_proposal_asset_requiredMajority,
NetworkParams.governance_proposal_updateAsset_requiredMajority,
NetworkParams.governance_proposal_updateNetParam_requiredMajority,
NetworkParams.governance_proposal_freeform_requiredMajority,
NetworkParams.governance_proposal_referralProgram_requiredMajority,
NetworkParams.governance_proposal_VolumeDiscountProgram_requiredMajority,
NetworkParams.governance_proposal_transfer_requiredMajority,
]);
let minVoterBalance = null;
if (networkParams) {
switch (props.changeType) {
case 'UpdateMarket':
case 'UpdateMarketState':
minVoterBalance =
networkParams.governance_proposal_updateMarket_minVoterBalance;
break;
case 'NewMarket':
minVoterBalance =
networkParams.governance_proposal_market_minVoterBalance;
break;
case 'NewAsset':
minVoterBalance =
networkParams.governance_proposal_asset_minVoterBalance;
break;
case 'UpdateAsset':
minVoterBalance =
networkParams.governance_proposal_updateAsset_minVoterBalance;
break;
case 'UpdateNetworkParameter':
minVoterBalance =
networkParams.governance_proposal_updateNetParam_minVoterBalance;
break;
case 'NewFreeform':
minVoterBalance =
networkParams.governance_proposal_freeform_minVoterBalance;
break;
case 'CancelTransfer':
case 'NewTransfer':
minVoterBalance =
networkParams.governance_proposal_transfer_requiredMajority;
break;
case 'UpdateReferralProgram':
minVoterBalance =
networkParams.governance_proposal_referralProgram_minVoterBalance;
break;
case 'UpdateVolumeDiscountProgram':
minVoterBalance =
networkParams.governance_proposal_VolumeDiscountProgram_minVoterBalance;
break;
}
}
const filteredErrors = filterAcceptableGraphqlErrors(error);
return (
<AsyncRenderer loading={loading} error={filteredErrors} data={data}>
<VoteButtons
{...props}
minVoterBalance={minVoterBalance}
spamProtectionMinTokens={
networkParams.spam_protection_voting_min_tokens
}
currentStakeAvailable={toBigNum(
data?.party?.stakingSummary.currentStakeAvailable || 0,
decimals
@@ -58,8 +136,17 @@ export const VoteButtonsContainer = (props: VoteButtonsContainerProps) => {
);
};
interface VoteButtonsProps extends VoteButtonsContainerProps {
interface VoteButtonsProps {
voteState: VoteState | null;
voteDatetime: Date | null;
proposalId: string | null;
proposalState: ProposalState;
submit: (voteValue: VoteValue, proposalId: string | null) => Promise<void>;
transaction: VegaTxState | null;
dialog: (props: DialogProps) => JSX.Element;
currentStakeAvailable: BigNumber;
minVoterBalance: string | null;
spamProtectionMinTokens: string | null;
}
export const VoteButtons = ({
@@ -1,7 +1,6 @@
import { renderHook } from '@testing-library/react';
import { BigNumber } from '../../../lib/bignumber';
import { useProposalNetworkParams } from './use-proposal-network-params';
import { generateProposal } from '../test-helpers/generate-proposals';
jest.mock('@vegaprotocol/network-parameters', () => ({
...jest.requireActual('@vegaprotocol/network-parameters'),
@@ -29,118 +28,84 @@ jest.mock('@vegaprotocol/network-parameters', () => ({
describe('use-proposal-network-params', () => {
it('returns the correct params for an update market proposal', () => {
const proposal = generateProposal({
terms: {
change: {
__typename: 'UpdateMarket',
},
},
});
const {
result: { current },
} = renderHook(() => useProposalNetworkParams({ proposal }));
} = renderHook(() => useProposalNetworkParams());
const expectedObj = {
requiredMajority: expect.any(BigNumber),
requiredMajorityLP: expect.any(BigNumber),
requiredParticipation: expect.any(BigNumber),
requiredParticipationLP: expect.any(BigNumber),
};
expect(current).toEqual({
requiredMajority: '0.1',
requiredMajorityLP: '0.2',
requiredParticipation: new BigNumber(0.15),
requiredParticipationLP: new BigNumber(0.25),
NewMarket: expectedObj,
NewSpotMarket: expectedObj,
UpdateMarket: expectedObj,
UpdateMarketState: expectedObj,
UpdateSpotMarket: expectedObj,
UpdateNetworkParameter: expectedObj,
NewAsset: expectedObj,
UpdateAsset: expectedObj,
NewFreeform: expectedObj,
UpdateReferralProgram: expectedObj,
UpdateVolumeDiscountProgram: expectedObj,
NewTransfer: expectedObj,
CancelTransfer: expectedObj,
});
});
it('returns the correct params for a market proposal', () => {
const proposal = generateProposal({
terms: {
change: {
__typename: 'NewMarket',
},
},
});
it('returns the correct values for the proposal change type', () => {
const {
result: { current },
} = renderHook(() => useProposalNetworkParams({ proposal }));
} = renderHook(() => useProposalNetworkParams());
expect(current).toEqual({
requiredMajority: '0.3',
requiredParticipation: new BigNumber(0.35),
});
});
expect(current?.UpdateMarket.requiredMajority.toString()).toEqual('0.1');
expect(current?.UpdateMarket.requiredMajorityLP.toString()).toEqual('0.2');
expect(current?.UpdateMarket.requiredParticipation.toString()).toEqual(
'0.15'
);
expect(current?.UpdateMarket.requiredParticipationLP.toString()).toEqual(
'0.25'
);
it('returns the correct params for an asset proposal', () => {
const proposal = generateProposal({
terms: {
change: {
__typename: 'NewAsset',
},
},
});
expect(current?.NewMarket.requiredMajority.toString()).toEqual('0.3');
expect(current?.NewMarket.requiredMajorityLP.toString()).toEqual('0');
expect(current?.NewMarket.requiredParticipation.toString()).toEqual('0.35');
expect(current?.NewMarket.requiredParticipationLP.toString()).toEqual('0');
const {
result: { current },
} = renderHook(() => useProposalNetworkParams({ proposal }));
expect(current?.NewAsset.requiredMajority.toString()).toEqual('0.4');
expect(current?.NewAsset.requiredMajorityLP.toString()).toEqual('0');
expect(current?.NewAsset.requiredParticipation.toString()).toEqual('0.45');
expect(current?.NewAsset.requiredParticipationLP.toString()).toEqual('0');
expect(current).toEqual({
requiredMajority: '0.4',
requiredParticipation: new BigNumber(0.45),
});
});
expect(current?.UpdateAsset.requiredMajority.toString()).toEqual('0.5');
expect(current?.UpdateAsset.requiredMajorityLP.toString()).toEqual('0');
expect(current?.UpdateAsset.requiredParticipation.toString()).toEqual(
'0.55'
);
it('returns the correct params for an update asset proposal', () => {
const proposal = generateProposal({
terms: {
change: {
__typename: 'UpdateAsset',
},
},
});
expect(current?.UpdateNetworkParameter.requiredMajority.toString()).toEqual(
'0.6'
);
expect(
current?.UpdateNetworkParameter.requiredMajorityLP.toString()
).toEqual('0');
expect(
current?.UpdateNetworkParameter.requiredParticipation.toString()
).toEqual('0.65');
expect(
current?.UpdateNetworkParameter.requiredParticipationLP.toString()
).toEqual('0');
const {
result: { current },
} = renderHook(() => useProposalNetworkParams({ proposal }));
expect(current).toEqual({
requiredMajority: '0.5',
requiredParticipation: new BigNumber(0.55),
});
});
it('returns the correct params for a network params proposal', () => {
const proposal = generateProposal({
terms: {
change: {
__typename: 'UpdateNetworkParameter',
},
},
});
const {
result: { current },
} = renderHook(() => useProposalNetworkParams({ proposal }));
expect(current).toEqual({
requiredMajority: '0.6',
requiredParticipation: new BigNumber(0.65),
});
});
it('returns the correct params for a freeform proposal', () => {
const proposal = generateProposal({
terms: {
change: {
__typename: 'NewFreeform',
},
},
});
const {
result: { current },
} = renderHook(() => useProposalNetworkParams({ proposal }));
expect(current).toEqual({
requiredMajority: '0.7',
requiredParticipation: new BigNumber(0.75),
});
expect(current?.NewFreeform.requiredMajority.toString()).toEqual('0.7');
expect(current?.NewFreeform.requiredMajorityLP.toString()).toEqual('0');
expect(current?.NewFreeform.requiredParticipation.toString()).toEqual(
'0.75'
);
expect(current?.NewFreeform.requiredParticipationLP.toString()).toEqual(
'0'
);
});
});
@@ -3,35 +3,33 @@ import {
useNetworkParams,
} from '@vegaprotocol/network-parameters';
import { BigNumber } from '../../../lib/bignumber';
import { type Proposal } from '../types';
import { type ProposalChangeType } from '../types';
export const useProposalNetworkParams = ({
proposal,
}: {
proposal: Proposal;
}) => {
const { params } = useNetworkParams([
NetworkParams.governance_proposal_updateMarket_requiredMajority,
NetworkParams.governance_proposal_updateMarket_requiredMajorityLP,
NetworkParams.governance_proposal_updateMarket_requiredParticipation,
NetworkParams.governance_proposal_updateMarket_requiredParticipationLP,
NetworkParams.governance_proposal_market_requiredMajority,
NetworkParams.governance_proposal_market_requiredParticipation,
NetworkParams.governance_proposal_updateAsset_requiredMajority,
NetworkParams.governance_proposal_referralProgram_requiredMajority,
NetworkParams.governance_proposal_referralProgram_requiredParticipation,
NetworkParams.governance_proposal_updateAsset_requiredParticipation,
NetworkParams.governance_proposal_asset_requiredMajority,
NetworkParams.governance_proposal_asset_requiredParticipation,
NetworkParams.governance_proposal_updateNetParam_requiredMajority,
NetworkParams.governance_proposal_updateNetParam_requiredParticipation,
NetworkParams.governance_proposal_freeform_requiredMajority,
NetworkParams.governance_proposal_freeform_requiredParticipation,
NetworkParams.governance_proposal_VolumeDiscountProgram_requiredMajority,
NetworkParams.governance_proposal_VolumeDiscountProgram_requiredParticipation,
NetworkParams.governance_proposal_transfer_requiredParticipation,
NetworkParams.governance_proposal_transfer_requiredMajority,
]);
const REQUIRED_PARAMS = [
NetworkParams.governance_proposal_updateMarket_requiredMajority,
NetworkParams.governance_proposal_updateMarket_requiredMajorityLP,
NetworkParams.governance_proposal_updateMarket_requiredParticipation,
NetworkParams.governance_proposal_updateMarket_requiredParticipationLP,
NetworkParams.governance_proposal_market_requiredMajority,
NetworkParams.governance_proposal_market_requiredParticipation,
NetworkParams.governance_proposal_updateAsset_requiredMajority,
NetworkParams.governance_proposal_referralProgram_requiredMajority,
NetworkParams.governance_proposal_referralProgram_requiredParticipation,
NetworkParams.governance_proposal_updateAsset_requiredParticipation,
NetworkParams.governance_proposal_asset_requiredMajority,
NetworkParams.governance_proposal_asset_requiredParticipation,
NetworkParams.governance_proposal_updateNetParam_requiredMajority,
NetworkParams.governance_proposal_updateNetParam_requiredParticipation,
NetworkParams.governance_proposal_freeform_requiredMajority,
NetworkParams.governance_proposal_freeform_requiredParticipation,
NetworkParams.governance_proposal_VolumeDiscountProgram_requiredMajority,
NetworkParams.governance_proposal_VolumeDiscountProgram_requiredParticipation,
NetworkParams.governance_proposal_transfer_requiredParticipation,
NetworkParams.governance_proposal_transfer_requiredMajority,
];
export const useProposalNetworkParams = () => {
const { params } = useNetworkParams(REQUIRED_PARAMS);
const fallback = {
requiredMajority: new BigNumber(1),
@@ -41,86 +39,152 @@ export const useProposalNetworkParams = ({
};
if (!params) {
return fallback;
return;
}
switch (proposal?.terms.change.__typename) {
case 'UpdateMarket':
case 'UpdateMarketState':
return {
requiredMajority:
params.governance_proposal_updateMarket_requiredMajority,
requiredMajorityLP:
params.governance_proposal_updateMarket_requiredMajorityLP,
requiredParticipation: new BigNumber(
params.governance_proposal_updateMarket_requiredParticipation
),
requiredParticipationLP: new BigNumber(
params.governance_proposal_updateMarket_requiredParticipationLP
),
};
case 'UpdateNetworkParameter':
return {
requiredMajority:
params.governance_proposal_updateNetParam_requiredMajority,
requiredParticipation: new BigNumber(
params.governance_proposal_updateNetParam_requiredParticipation
),
};
case 'NewAsset':
return {
requiredMajority: params.governance_proposal_asset_requiredMajority,
requiredParticipation: new BigNumber(
params.governance_proposal_asset_requiredParticipation
),
};
case 'UpdateAsset':
return {
requiredMajority:
params.governance_proposal_updateAsset_requiredMajority,
requiredParticipation: new BigNumber(
params.governance_proposal_updateAsset_requiredParticipation
),
};
case 'NewMarket':
return {
requiredMajority: params.governance_proposal_market_requiredMajority,
requiredParticipation: new BigNumber(
params.governance_proposal_market_requiredParticipation
),
};
case 'NewFreeform':
return {
requiredMajority: params.governance_proposal_freeform_requiredMajority,
requiredParticipation: new BigNumber(
params.governance_proposal_freeform_requiredParticipation
),
};
case 'UpdateReferralProgram':
return {
requiredMajority:
params.governance_proposal_referralProgram_requiredMajority,
requiredParticipation: new BigNumber(
params.governance_proposal_referralProgram_requiredParticipation
),
};
case 'UpdateVolumeDiscountProgram':
return {
requiredMajority:
params.governance_proposal_VolumeDiscountProgram_requiredMajority,
requiredParticipation: new BigNumber(
params.governance_proposal_VolumeDiscountProgram_requiredParticipation
),
};
case 'NewTransfer':
case 'CancelTransfer':
return {
requiredMajority: params.governance_proposal_transfer_requiredMajority,
requiredParticipation: new BigNumber(
params.governance_proposal_transfer_requiredParticipation
),
};
default:
return fallback;
}
const result: Record<
ProposalChangeType,
{
requiredMajority: BigNumber;
requiredParticipation: BigNumber;
requiredMajorityLP: BigNumber;
requiredParticipationLP: BigNumber;
}
> = {
NewMarket: {
...fallback,
requiredMajority: new BigNumber(
params.governance_proposal_market_requiredMajority || 1
),
requiredParticipation: new BigNumber(
params.governance_proposal_market_requiredParticipation || 1
),
},
NewSpotMarket: {
...fallback,
requiredMajority: new BigNumber(
params.governance_proposal_market_requiredMajority || 1
),
requiredParticipation: new BigNumber(
params.governance_proposal_market_requiredParticipation || 1
),
},
UpdateMarket: {
requiredMajority: new BigNumber(
params.governance_proposal_updateMarket_requiredMajority || 1
),
requiredMajorityLP: new BigNumber(
params.governance_proposal_updateMarket_requiredMajorityLP || 0
),
requiredParticipation: new BigNumber(
params.governance_proposal_updateMarket_requiredParticipation || 1
),
requiredParticipationLP: new BigNumber(
params.governance_proposal_updateMarket_requiredParticipationLP || 0
),
},
UpdateMarketState: {
requiredMajority: new BigNumber(
params.governance_proposal_updateMarket_requiredMajority || 1
),
requiredMajorityLP: new BigNumber(
params.governance_proposal_updateMarket_requiredMajorityLP || 0
),
requiredParticipation: new BigNumber(
params.governance_proposal_updateMarket_requiredParticipation || 1
),
requiredParticipationLP: new BigNumber(
params.governance_proposal_updateMarket_requiredParticipationLP || 0
),
},
UpdateSpotMarket: {
requiredMajority: new BigNumber(
params.governance_proposal_updateMarket_requiredMajority || 1
),
requiredMajorityLP: new BigNumber(
params.governance_proposal_updateMarket_requiredMajorityLP || 0
),
requiredParticipation: new BigNumber(
params.governance_proposal_updateMarket_requiredParticipation || 1
),
requiredParticipationLP: new BigNumber(
params.governance_proposal_updateMarket_requiredParticipationLP || 0
),
},
UpdateNetworkParameter: {
...fallback,
requiredMajority: new BigNumber(
params.governance_proposal_updateNetParam_requiredMajority || 1
),
requiredParticipation: new BigNumber(
params.governance_proposal_updateNetParam_requiredParticipation || 1
),
},
NewAsset: {
...fallback,
requiredMajority: new BigNumber(
params.governance_proposal_asset_requiredMajority || 1
),
requiredParticipation: new BigNumber(
params.governance_proposal_asset_requiredParticipation || 1
),
},
UpdateAsset: {
...fallback,
requiredMajority: new BigNumber(
params.governance_proposal_updateAsset_requiredMajority || 1
),
requiredParticipation: new BigNumber(
params.governance_proposal_updateAsset_requiredParticipation || 1
),
},
NewFreeform: {
...fallback,
requiredMajority: new BigNumber(
params.governance_proposal_freeform_requiredMajority || 1
),
requiredParticipation: new BigNumber(
params.governance_proposal_freeform_requiredParticipation || 1
),
},
UpdateReferralProgram: {
...fallback,
requiredMajority: new BigNumber(
params.governance_proposal_referralProgram_requiredMajority || 1
),
requiredParticipation: new BigNumber(
params.governance_proposal_referralProgram_requiredParticipation || 1
),
},
UpdateVolumeDiscountProgram: {
...fallback,
requiredMajority: new BigNumber(
params.governance_proposal_VolumeDiscountProgram_requiredMajority || 1
),
requiredParticipation: new BigNumber(
params.governance_proposal_VolumeDiscountProgram_requiredParticipation ||
1
),
},
NewTransfer: {
...fallback,
requiredMajority: new BigNumber(
params.governance_proposal_transfer_requiredMajority || 1
),
requiredParticipation: new BigNumber(
params.governance_proposal_transfer_requiredParticipation || 1
),
},
CancelTransfer: {
...fallback,
requiredMajority: new BigNumber(
params.governance_proposal_transfer_requiredMajority || 1
),
requiredParticipation: new BigNumber(
params.governance_proposal_transfer_requiredParticipation || 1
),
},
};
return result;
};
@@ -79,33 +79,35 @@ describe('use-vote-information', () => {
const {
result: { current },
} = renderHook(() => useVoteInformation({ proposal }));
} = renderHook(() =>
useVoteInformation({ terms: proposal.terms, votes: proposal.votes })
);
expect(current.requiredMajorityPercentage).toEqual(new BigNumber(50));
expect(current.requiredMajorityLPPercentage).toEqual(new BigNumber(50));
expect(current.noTokens).toEqual(new BigNumber(60));
expect(current.noVotes).toEqual(new BigNumber(60));
expect(current.noEquityLikeShareWeight).toEqual(new BigNumber(70));
expect(current.yesTokens).toEqual(new BigNumber(40));
expect(current.yesVotes).toEqual(new BigNumber(40));
expect(current.yesEquityLikeShareWeight).toEqual(new BigNumber(30));
expect(current.totalTokensVoted).toEqual(new BigNumber(100));
expect(current.totalVotes).toEqual(new BigNumber(100));
expect(current.totalEquityLikeShareWeight).toEqual(new BigNumber(100));
expect(current.yesPercentage).toEqual(new BigNumber(40));
expect(current.yesLPPercentage).toEqual(new BigNumber(30));
expect(current.noPercentage).toEqual(new BigNumber(60));
expect(current.noLPPercentage).toEqual(new BigNumber(70));
expect(current.requiredParticipation).toEqual(new BigNumber(50));
expect(current.participationMet).toEqual(true);
expect(current.requiredParticipationLP).toEqual(new BigNumber(50));
expect(current.participationLPMet).toEqual(true);
expect(current.majorityMet).toEqual(false);
expect(current.majorityLPMet).toEqual(false);
expect(current.totalTokensPercentage).toEqual(new BigNumber(100));
expect(current.totalLPTokensPercentage).toEqual(new BigNumber(100));
expect(current.willPassByTokenVote).toEqual(false);
expect(current.willPassByLPVote).toEqual(false);
expect(current?.requiredMajorityLPPercentage).toEqual(new BigNumber(50));
expect(current?.requiredMajorityPercentage).toEqual(new BigNumber(50));
expect(current?.noTokens).toEqual(new BigNumber(60));
expect(current?.noVotes).toEqual(new BigNumber(60));
expect(current?.noEquityLikeShareWeight).toEqual(new BigNumber(70));
expect(current?.yesTokens).toEqual(new BigNumber(40));
expect(current?.yesVotes).toEqual(new BigNumber(40));
expect(current?.yesEquityLikeShareWeight).toEqual(new BigNumber(30));
expect(current?.totalTokensVoted).toEqual(new BigNumber(100));
expect(current?.totalVotes).toEqual(new BigNumber(100));
expect(current?.totalEquityLikeShareWeight).toEqual(new BigNumber(100));
expect(current?.yesPercentage).toEqual(new BigNumber(40));
expect(current?.yesLPPercentage).toEqual(new BigNumber(30));
expect(current?.noPercentage).toEqual(new BigNumber(60));
expect(current?.noLPPercentage).toEqual(new BigNumber(70));
expect(current?.requiredParticipation).toEqual(new BigNumber(50));
expect(current?.participationMet).toEqual(true);
expect(current?.requiredParticipationLP).toEqual(new BigNumber(50));
expect(current?.participationLPMet).toEqual(true);
expect(current?.majorityMet).toEqual(false);
expect(current?.majorityLPMet).toEqual(false);
expect(current?.totalTokensPercentage).toEqual(new BigNumber(100));
expect(current?.totalLPTokensPercentage).toEqual(new BigNumber(100));
expect(current?.willPassByTokenVote).toEqual(false);
expect(current?.willPassByLPVote).toEqual(false);
});
it('correctly returns majority, participation and will-pass status for a proposal with no votes', () => {
@@ -123,11 +125,13 @@ describe('use-vote-information', () => {
const {
result: { current },
} = renderHook(() => useVoteInformation({ proposal }));
} = renderHook(() =>
useVoteInformation({ terms: proposal.terms, votes: proposal.votes })
);
expect(current.participationMet).toEqual(false);
expect(current.majorityMet).toEqual(false);
expect(current.willPassByTokenVote).toEqual(false);
expect(current?.participationMet).toEqual(false);
expect(current?.majorityMet).toEqual(false);
expect(current?.willPassByTokenVote).toEqual(false);
});
it('correctly shows lack of participation for a failing proposal lacking votes', () => {
@@ -145,9 +149,11 @@ describe('use-vote-information', () => {
const {
result: { current },
} = renderHook(() => useVoteInformation({ proposal }));
} = renderHook(() =>
useVoteInformation({ terms: proposal.terms, votes: proposal.votes })
);
expect(current.participationMet).toEqual(false);
expect(current?.participationMet).toEqual(false);
});
it('correctly shows participation but lack of majority for a failing proposal with enough votes but not enough majority', () => {
@@ -165,11 +171,13 @@ describe('use-vote-information', () => {
const {
result: { current },
} = renderHook(() => useVoteInformation({ proposal }));
} = renderHook(() =>
useVoteInformation({ terms: proposal.terms, votes: proposal.votes })
);
expect(current.participationMet).toEqual(true);
expect(current.majorityMet).toEqual(false);
expect(current.willPassByTokenVote).toEqual(false);
expect(current?.participationMet).toEqual(true);
expect(current?.majorityMet).toEqual(false);
expect(current?.willPassByTokenVote).toEqual(false);
});
it('correctly shows participation, majority and will-pass data for successful proposal', () => {
@@ -187,11 +195,13 @@ describe('use-vote-information', () => {
const {
result: { current },
} = renderHook(() => useVoteInformation({ proposal }));
} = renderHook(() =>
useVoteInformation({ terms: proposal.terms, votes: proposal.votes })
);
expect(current.participationMet).toEqual(true);
expect(current.majorityMet).toEqual(true);
expect(current.willPassByTokenVote).toEqual(true);
expect(current?.participationMet).toEqual(true);
expect(current?.majorityMet).toEqual(true);
expect(current?.willPassByTokenVote).toEqual(true);
});
it('correctly shows whether an update market proposal will pass by token or LP vote - both failing', () => {
@@ -221,10 +231,12 @@ describe('use-vote-information', () => {
const {
result: { current },
} = renderHook(() => useVoteInformation({ proposal }));
} = renderHook(() =>
useVoteInformation({ terms: proposal.terms, votes: proposal.votes })
);
expect(current.willPassByTokenVote).toEqual(false);
expect(current.willPassByLPVote).toEqual(false);
expect(current?.willPassByTokenVote).toEqual(false);
expect(current?.willPassByLPVote).toEqual(false);
});
it('correctly shows whether an update market proposal failing token but passing LP voting', () => {
@@ -254,9 +266,81 @@ describe('use-vote-information', () => {
const {
result: { current },
} = renderHook(() => useVoteInformation({ proposal }));
} = renderHook(() =>
useVoteInformation({ terms: proposal.terms, votes: proposal.votes })
);
expect(current.willPassByTokenVote).toEqual(false);
expect(current.willPassByLPVote).toEqual(true);
expect(current?.willPassByTokenVote).toEqual(false);
expect(current?.willPassByLPVote).toEqual(true);
});
it('mainnet recreation: only yes LP votes equal passing', () => {
const yesVotes = 0;
const noVotes = 70;
const yesEquityLikeShareWeight = '0.21';
const noEquityLikeShareWeight = '0';
const fixedTokenValue = 1000000000000000000;
const proposal = generateProposal({
terms: {
change: {
__typename: 'UpdateMarket',
marketId: '12345',
},
},
votes: {
__typename: 'ProposalVotes',
yes: generateYesVotes(
yesVotes,
fixedTokenValue,
yesEquityLikeShareWeight
),
no: generateNoVotes(noVotes, fixedTokenValue, noEquityLikeShareWeight),
},
});
const {
result: { current },
} = renderHook(() =>
useVoteInformation({ terms: proposal.terms, votes: proposal.votes })
);
expect(current?.willPassByTokenVote).toEqual(false);
expect(current?.willPassByLPVote).toEqual(true);
});
it('mainnet recreation: mixed yes and no LP votes equal failing', () => {
const yesVotes = 0;
const noVotes = 70;
const yesEquityLikeShareWeight = '0.21';
const noEquityLikeShareWeight = '0.22';
const fixedTokenValue = 1000000000000000000;
const proposal = generateProposal({
terms: {
change: {
__typename: 'UpdateMarket',
marketId: '12345',
},
},
votes: {
__typename: 'ProposalVotes',
yes: generateYesVotes(
yesVotes,
fixedTokenValue,
yesEquityLikeShareWeight
),
no: generateNoVotes(noVotes, fixedTokenValue, noEquityLikeShareWeight),
},
});
const {
result: { current },
} = renderHook(() =>
useVoteInformation({ terms: proposal.terms, votes: proposal.votes })
);
expect(current?.willPassByTokenVote).toEqual(false);
expect(current?.willPassByLPVote).toEqual(false);
});
});
@@ -1,191 +1,201 @@
import { useMemo } from 'react';
import { useAppState } from '../../../contexts/app-state/app-state-context';
import { BigNumber } from '../../../lib/bignumber';
import { useProposalNetworkParams } from './use-proposal-network-params';
import { addDecimal } from '@vegaprotocol/utils';
import { type Proposal } from '../types';
import {
type ProposalTermsFieldsFragment,
type ProposalFieldsFragment,
type VoteFieldsFragment,
} from '../__generated__/Proposals';
import { type ProposalChangeType } from '../types';
export const useVoteInformation = ({ proposal }: { proposal: Proposal }) => {
export const useVoteInformation = ({
votes,
terms,
}: {
votes: VoteFieldsFragment;
terms: ProposalTermsFieldsFragment;
}) => {
const {
appState: { totalSupply, decimals },
} = useAppState();
const params = useProposalNetworkParams();
if (!params) return;
const paramsForChange = params[terms.change.__typename];
return getVoteData(
terms.change.__typename,
paramsForChange,
votes,
totalSupply,
decimals
);
};
export const useBatchVoteInformation = ({
votes,
terms,
}: {
votes: VoteFieldsFragment;
terms: ProposalTermsFieldsFragment[];
}) => {
const {
requiredMajority,
requiredParticipation,
requiredMajorityLP,
requiredParticipationLP,
} = useProposalNetworkParams({
proposal,
appState: { totalSupply, decimals },
} = useAppState();
const params = useProposalNetworkParams();
if (!params) return;
return terms.map((t) => {
const paramsForChange = params[t.change.__typename];
return getVoteData(
t.change.__typename,
paramsForChange,
votes,
totalSupply,
decimals
);
});
};
const {
requiredMajorityPercentage,
requiredMajorityLPPercentage,
noTokens,
noEquityLikeShareWeight,
yesTokens,
yesEquityLikeShareWeight,
totalTokensVoted,
totalEquityLikeShareWeight,
yesPercentage,
yesLPPercentage,
noPercentage,
noLPPercentage,
participationMet,
participationLPMet,
majorityMet,
majorityLPMet,
totalTokensPercentage,
totalLPTokensPercentage,
willPassByTokenVote,
willPassByLPVote,
} = useMemo(() => {
const requiredMajorityPercentage = requiredMajority
? new BigNumber(requiredMajority).times(100)
: new BigNumber(100);
const getVoteData = (
changeType: ProposalChangeType,
params: {
requiredMajority: BigNumber;
requiredMajorityLP: BigNumber;
requiredParticipation: BigNumber;
requiredParticipationLP: BigNumber;
},
votes: ProposalFieldsFragment['votes'],
totalSupply: BigNumber,
decimals: number
) => {
const requiredMajorityPercentage = params.requiredMajority
? new BigNumber(params.requiredMajority).times(100)
: new BigNumber(100);
const requiredMajorityLPPercentage = requiredMajorityLP
? new BigNumber(requiredMajorityLP).times(100)
: new BigNumber(100);
const requiredMajorityLPPercentage = params.requiredMajorityLP
? new BigNumber(params.requiredMajorityLP).times(100)
: new BigNumber(100);
const noTokens = new BigNumber(
addDecimal(proposal?.votes.no.totalTokens ?? 0, decimals)
);
const noTokens = new BigNumber(
addDecimal(votes.no.totalTokens ?? 0, decimals)
);
const noEquityLikeShareWeight = !proposal?.votes.no
.totalEquityLikeShareWeight
? new BigNumber(0)
: new BigNumber(proposal.votes.no.totalEquityLikeShareWeight).times(100);
const noEquityLikeShareWeight = !votes.no.totalEquityLikeShareWeight
? new BigNumber(0)
: new BigNumber(votes.no.totalEquityLikeShareWeight).times(100);
const yesTokens = new BigNumber(
addDecimal(proposal?.votes.yes.totalTokens ?? 0, decimals)
);
const yesTokens = new BigNumber(
addDecimal(votes.yes.totalTokens ?? 0, decimals)
);
const yesEquityLikeShareWeight = !proposal?.votes.yes
.totalEquityLikeShareWeight
? new BigNumber(0)
: new BigNumber(proposal.votes.yes.totalEquityLikeShareWeight).times(100);
const yesEquityLikeShareWeight = !votes.yes.totalEquityLikeShareWeight
? new BigNumber(0)
: new BigNumber(votes.yes.totalEquityLikeShareWeight).times(100);
const totalTokensVoted = yesTokens.plus(noTokens);
const totalTokensVoted = yesTokens.plus(noTokens);
const totalEquityLikeShareWeight = yesEquityLikeShareWeight.plus(
noEquityLikeShareWeight
);
const totalEquityLikeShareWeight = yesEquityLikeShareWeight.plus(
noEquityLikeShareWeight
);
const yesPercentage = totalTokensVoted.isZero()
? new BigNumber(0)
: yesTokens.multipliedBy(100).dividedBy(totalTokensVoted);
const yesLPPercentage = yesEquityLikeShareWeight;
const yesPercentage = totalTokensVoted.isZero()
? new BigNumber(0)
: yesTokens.multipliedBy(100).dividedBy(totalTokensVoted);
const yesLPPercentage = yesEquityLikeShareWeight;
const noPercentage = totalTokensVoted.isZero()
? new BigNumber(0)
: noTokens.multipliedBy(100).dividedBy(totalTokensVoted);
const noPercentage = totalTokensVoted.isZero()
? new BigNumber(0)
: noTokens.multipliedBy(100).dividedBy(totalTokensVoted);
const noLPPercentage = totalEquityLikeShareWeight.isZero()
? new BigNumber(0)
: noEquityLikeShareWeight
.multipliedBy(100)
.dividedBy(totalEquityLikeShareWeight);
const noLPPercentage = totalEquityLikeShareWeight.isZero()
? new BigNumber(0)
: noEquityLikeShareWeight
.multipliedBy(100)
.dividedBy(totalEquityLikeShareWeight);
const participationMet = totalTokensVoted.isGreaterThan(
totalSupply.multipliedBy(requiredParticipation)
);
const participationMet = totalTokensVoted.isGreaterThan(
totalSupply.multipliedBy(params.requiredParticipation)
);
const participationLPMet = requiredParticipationLP
? totalEquityLikeShareWeight.isGreaterThan(requiredParticipationLP)
: false;
const lpVoteWeight = yesEquityLikeShareWeight
.dividedBy(totalEquityLikeShareWeight)
.multipliedBy(100);
const majorityMet = yesPercentage.isGreaterThanOrEqualTo(
const participationLPMet = params.requiredParticipationLP
? lpVoteWeight.isGreaterThan(params.requiredParticipationLP)
: false;
const majorityMet = yesPercentage.isGreaterThanOrEqualTo(
requiredMajorityPercentage
);
const majorityLPMet = lpVoteWeight.isGreaterThanOrEqualTo(
requiredMajorityLPPercentage
);
const totalTokensPercentage = totalTokensVoted
.multipliedBy(100)
.dividedBy(totalSupply);
const totalLPTokensPercentage = totalEquityLikeShareWeight;
const willPassByTokenVote =
participationMet &&
new BigNumber(yesPercentage).isGreaterThanOrEqualTo(
requiredMajorityPercentage
);
const majorityLPMet = yesLPPercentage.isGreaterThanOrEqualTo(
requiredMajorityLPPercentage
);
const willPassByLPVote =
participationLPMet &&
lpVoteWeight.isGreaterThanOrEqualTo(requiredMajorityLPPercentage);
const totalTokensPercentage = totalTokensVoted
.multipliedBy(100)
.dividedBy(totalSupply);
let willPass = false;
const totalLPTokensPercentage = totalEquityLikeShareWeight;
const willPassByTokenVote =
participationMet &&
new BigNumber(yesPercentage).isGreaterThanOrEqualTo(
requiredMajorityPercentage
);
const willPassByLPVote =
participationLPMet &&
new BigNumber(yesLPPercentage).isGreaterThanOrEqualTo(
requiredMajorityLPPercentage
);
return {
requiredMajorityPercentage,
requiredMajorityLPPercentage,
noTokens,
noEquityLikeShareWeight,
yesTokens,
yesEquityLikeShareWeight,
totalTokensVoted,
totalEquityLikeShareWeight,
yesPercentage,
yesLPPercentage,
noPercentage,
noLPPercentage,
participationMet,
participationLPMet,
majorityMet,
majorityLPMet,
totalTokensPercentage,
totalLPTokensPercentage,
willPassByTokenVote,
willPassByLPVote,
};
}, [
decimals,
proposal?.votes.no.totalEquityLikeShareWeight,
proposal?.votes.no.totalTokens,
proposal?.votes.yes.totalEquityLikeShareWeight,
proposal?.votes.yes.totalTokens,
requiredMajority,
requiredMajorityLP,
requiredParticipation,
requiredParticipationLP,
totalSupply,
]);
if (changeType === 'UpdateMarket' || changeType === 'UpdateMarketState') {
willPass = willPassByTokenVote || willPassByLPVote;
} else {
willPass = willPassByTokenVote;
}
return {
willPassByTokenVote,
willPassByLPVote,
totalTokensPercentage,
totalLPTokensPercentage,
participationMet,
participationLPMet,
totalTokensVoted,
totalEquityLikeShareWeight,
noPercentage,
noLPPercentage,
yesPercentage,
yesLPPercentage,
requiredMajorityPercentage,
requiredMajorityLPPercentage,
noTokens,
noEquityLikeShareWeight,
yesTokens,
yesEquityLikeShareWeight,
yesVotes: new BigNumber(proposal?.votes.yes.totalNumber ?? 0),
noVotes: new BigNumber(proposal?.votes.no.totalNumber ?? 0),
totalVotes: new BigNumber(proposal?.votes.yes.totalNumber ?? 0).plus(
proposal?.votes.no.totalNumber ?? 0
),
requiredMajorityPercentage,
requiredMajorityLPPercentage,
requiredParticipation: new BigNumber(requiredParticipation).times(100),
requiredParticipationLP:
requiredParticipationLP &&
new BigNumber(requiredParticipationLP).times(100),
totalTokensVoted,
totalEquityLikeShareWeight,
yesPercentage,
yesLPPercentage,
noPercentage,
noLPPercentage,
participationMet,
participationLPMet,
majorityMet,
majorityLPMet,
totalTokensPercentage,
totalLPTokensPercentage,
willPassByTokenVote,
willPassByLPVote,
lpVoteWeight: lpVoteWeight.isNaN() ? new BigNumber(0) : lpVoteWeight,
yesVotes: new BigNumber(votes.yes.totalNumber ?? 0),
noVotes: new BigNumber(votes.no.totalNumber ?? 0),
totalVotes: new BigNumber(votes.yes.totalNumber ?? 0).plus(
votes.no.totalNumber ?? 0
),
requiredParticipation: new BigNumber(params.requiredParticipation).times(
100
),
requiredParticipationLP: new BigNumber(
params.requiredParticipationLP
).times(100),
willPass,
};
};
@@ -1,442 +0,0 @@
fragment NewMarketProductField on Proposal {
terms {
change {
... on NewMarket {
instrument {
product {
__typename
}
}
}
}
}
}
fragment UpdateMarketState on Proposal {
terms {
change {
... on UpdateMarketState {
updateType
market {
decimalPlaces
id
tradableInstrument {
instrument {
product {
__typename
... on Future {
quoteName
}
... on Perpetual {
quoteName
}
}
name
code
}
}
}
updateType
price
}
}
}
}
fragment UpdateReferralProgram on Proposal {
terms {
change {
... on UpdateReferralProgram {
benefitTiers {
minimumEpochs
minimumRunningNotionalTakerVolume
referralDiscountFactor
referralRewardFactor
}
endOfProgram: endOfProgramTimestamp
windowLength
stakingTiers {
minimumStakedTokens
referralRewardMultiplier
}
}
}
}
}
fragment UpdateVolumeDiscountProgram on Proposal {
terms {
change {
... on UpdateVolumeDiscountProgram {
benefitTiers {
minimumRunningNotionalTakerVolume
volumeDiscountFactor
}
endOfProgramTimestamp
windowLength
}
}
}
}
query Proposal(
$proposalId: ID!
$includeNewMarketProductField: Boolean!
$includeUpdateMarketState: Boolean!
$includeUpdateReferralProgram: Boolean!
) {
proposal(id: $proposalId) {
... on Proposal {
id
rationale {
title
description
}
reference
state
datetime
rejectionReason
party {
id
}
errorDetails
...NewMarketProductField @include(if: $includeNewMarketProductField)
...UpdateMarketState @include(if: $includeUpdateMarketState)
...UpdateReferralProgram @include(if: $includeUpdateReferralProgram)
...UpdateVolumeDiscountProgram
terms {
closingDatetime
enactmentDatetime
change {
... on NewMarket {
decimalPlaces
metadata
riskParameters {
... on LogNormalRiskModel {
riskAversionParameter
tau
params {
mu
r
sigma
}
}
... on SimpleRiskModel {
params {
factorLong
factorShort
}
}
}
instrument {
name
code
product {
... on FutureProduct {
settlementAsset {
id
name
symbol
decimals
quantum
}
quoteName
dataSourceSpecBinding {
settlementDataProperty
tradingTerminationProperty
}
dataSourceSpecForSettlementData {
sourceType {
... on DataSourceDefinitionInternal {
sourceType {
... on DataSourceSpecConfigurationTime {
conditions {
operator
value
}
}
}
}
... on DataSourceDefinitionExternal {
sourceType {
... on DataSourceSpecConfiguration {
signers {
signer {
... on PubKey {
key
}
... on ETHAddress {
address
}
}
}
filters {
key {
name
type
}
conditions {
operator
value
}
}
}
}
}
}
}
}
... on PerpetualProduct {
settlementAsset {
id
name
symbol
decimals
quantum
}
quoteName
}
}
}
priceMonitoringParameters {
triggers {
horizonSecs
probability
auctionExtensionSecs
}
}
liquidityMonitoringParameters {
targetStakeParameters {
timeWindow
scalingFactor
}
}
positionDecimalPlaces
linearSlippageFactor
}
... on UpdateMarket {
marketId
updateMarketConfiguration {
instrument {
code
product {
... on UpdateFutureProduct {
quoteName
dataSourceSpecForSettlementData {
sourceType {
... on DataSourceDefinitionInternal {
sourceType {
... on DataSourceSpecConfigurationTime {
conditions {
operator
value
}
}
}
}
... on DataSourceDefinitionExternal {
sourceType {
... on DataSourceSpecConfiguration {
signers {
signer {
... on PubKey {
key
}
... on ETHAddress {
address
}
}
}
filters {
key {
name
type
}
conditions {
operator
value
}
}
}
}
}
}
}
# dataSourceSpecForTradingTermination {
# sourceType {
# ... on DataSourceDefinitionInternal {
# sourceType {
# ... on DataSourceSpecConfigurationTime {
# conditions {
# operator
# value
# }
# }
# }
# }
# ... on DataSourceDefinitionExternal {
# sourceType {
# ... on DataSourceSpecConfiguration {
# signers {
# signer {
# ... on PubKey {
# key
# }
# ... on ETHAddress {
# address
# }
# }
# }
# filters {
# key {
# name
# type
# }
# conditions {
# operator
# value
# }
# }
# }
# }
# }
# }
# }
dataSourceSpecBinding {
settlementDataProperty
tradingTerminationProperty
}
}
... on UpdatePerpetualProduct {
quoteName
dataSourceSpecForSettlementData {
sourceType {
... on DataSourceDefinitionInternal {
sourceType {
... on DataSourceSpecConfigurationTime {
conditions {
operator
value
}
}
}
}
... on DataSourceDefinitionExternal {
sourceType {
... on DataSourceSpecConfiguration {
signers {
signer {
... on PubKey {
key
}
... on ETHAddress {
address
}
}
}
filters {
key {
name
type
}
conditions {
operator
value
}
}
}
}
}
}
}
dataSourceSpecBinding {
settlementDataProperty
settlementScheduleProperty
}
}
}
}
metadata
priceMonitoringParameters {
triggers {
horizonSecs
probability
auctionExtensionSecs
}
}
liquidityMonitoringParameters {
targetStakeParameters {
timeWindow
scalingFactor
}
}
riskParameters {
... on UpdateMarketSimpleRiskModel {
simple {
factorLong
factorShort
}
}
... on UpdateMarketLogNormalRiskModel {
logNormal {
riskAversionParameter
tau
params {
r
sigma
mu
}
}
}
}
}
}
... on NewAsset {
name
symbol
decimals
quantum
source {
... on BuiltinAsset {
maxFaucetAmountMint
}
... on ERC20 {
contractAddress
lifetimeLimit
withdrawThreshold
}
}
}
... on UpdateNetworkParameter {
networkParameter {
key
value
}
}
... on UpdateAsset {
quantum
assetId
source {
... on UpdateERC20 {
lifetimeLimit
withdrawThreshold
}
}
}
}
}
votes {
yes {
totalTokens
totalNumber
totalEquityLikeShareWeight
}
no {
totalTokens
totalNumber
totalEquityLikeShareWeight
}
}
}
}
}
File diff suppressed because one or more lines are too long
@@ -1,69 +0,0 @@
import { render, screen, waitFor } from '@testing-library/react';
import { generateProposal } from '../test-helpers/generate-proposals';
import type { ProposalQuery } from './__generated__/Proposal';
import { ProposalContainer } from './proposal-container';
import { MockedProvider } from '@apollo/client/testing';
import { MemoryRouter, Route, Routes } from 'react-router-dom';
import { ProposalDocument } from './__generated__/Proposal';
jest.mock('@vegaprotocol/data-provider', () => ({
...jest.requireActual('@vegaprotocol/data-provider'),
useDataProvider: jest.fn(() => ({ data: [], loading: false })),
}));
jest.mock('../components/proposal', () => ({
Proposal: () => <div data-testid="proposal" />,
}));
jest.mock('../components/proposal-not-found', () => ({
ProposalNotFound: () => <div data-testid="proposal-not-found" />,
}));
const renderComponent = (
proposal: ProposalQuery['proposal'] | null,
id: string
) => {
return (
<MemoryRouter initialEntries={[`/governance/${id}`]}>
<MockedProvider
mocks={[
{
request: {
query: ProposalDocument,
variables: {
proposalId: id,
},
},
result: { data: { proposal } },
},
]}
>
<Routes>
<Route
path={`/governance/:proposalId`}
element={<ProposalContainer />}
/>
</Routes>
</MockedProvider>
</MemoryRouter>
);
};
// These tests are broken due to schema changes. NewMarket.futureProduct -> NewMarket.product union
// eslint-disable-next-line jest/no-disabled-tests
describe.skip('Proposal container', () => {
it('Renders not found if the proposal is not found', async () => {
render(renderComponent(null, 'foo'));
await waitFor(() => {
expect(screen.getByTestId('proposal-not-found')).toBeInTheDocument();
});
});
it('Renders proposal details if proposal is found', async () => {
const proposal = generateProposal({ id: 'foo' });
render(renderComponent(proposal as ProposalQuery['proposal'], 'foo'));
await waitFor(() => {
expect(screen.getByTestId('proposal')).toBeInTheDocument();
});
});
});
@@ -1,260 +1,38 @@
import { AsyncRenderer } from '@vegaprotocol/ui-toolkit';
import { useEffect, useState } from 'react';
import { useParams } from 'react-router-dom';
import { AsyncRenderer } from '@vegaprotocol/ui-toolkit';
import { Proposal } from '../components/proposal';
import { ProposalNotFound } from '../components/proposal-not-found';
import { useProposalQuery } from './__generated__/Proposal';
import { useFetch } from '@vegaprotocol/react-helpers';
import { ENV } from '../../../config';
import { useDataProvider } from '@vegaprotocol/data-provider';
import { marketInfoProvider } from '@vegaprotocol/markets';
import { useAssetQuery } from '@vegaprotocol/assets';
import {
NetworkParams,
useNetworkParams,
} from '@vegaprotocol/network-parameters';
import { useParentMarketIdQuery } from '@vegaprotocol/markets';
import { useFeatureFlags } from '@vegaprotocol/environment';
import { useSuccessorMarketProposalDetails } from '@vegaprotocol/proposals';
import { type Proposal as IProposal } from '../types';
import { useProposalQuery } from '../__generated__/Proposals';
import { useFetchProposal } from '../components/proposal/proposal-utils';
export const ProposalContainer = () => {
const featureFlags = useFeatureFlags((state) => state.flags);
const [
mostRecentlyEnactedAssociatedMarketProposal,
setMostRecentlyEnactedAssociatedMarketProposal,
] = useState(undefined);
const params = useParams<{ proposalId: string }>();
const {
params: networkParams,
loading: networkParamsLoading,
error: networkParamsError,
} = useNetworkParams([
NetworkParams.governance_proposal_market_minVoterBalance,
NetworkParams.governance_proposal_updateMarket_minVoterBalance,
NetworkParams.governance_proposal_asset_minVoterBalance,
NetworkParams.governance_proposal_updateAsset_minVoterBalance,
NetworkParams.governance_proposal_updateNetParam_minVoterBalance,
NetworkParams.governance_proposal_freeform_minVoterBalance,
NetworkParams.governance_proposal_referralProgram_minVoterBalance,
NetworkParams.governance_proposal_VolumeDiscountProgram_minVoterBalance,
NetworkParams.spam_protection_voting_min_tokens,
NetworkParams.governance_proposal_market_requiredMajority,
NetworkParams.governance_proposal_updateMarket_requiredMajority,
NetworkParams.governance_proposal_updateMarket_requiredMajorityLP,
NetworkParams.governance_proposal_asset_requiredMajority,
NetworkParams.governance_proposal_updateAsset_requiredMajority,
NetworkParams.governance_proposal_updateNetParam_requiredMajority,
NetworkParams.governance_proposal_freeform_requiredMajority,
NetworkParams.governance_proposal_referralProgram_requiredMajority,
NetworkParams.governance_proposal_VolumeDiscountProgram_requiredMajority,
]);
const { data: restData, loading: restLoading } = useFetchProposal({
proposalId: params.proposalId,
});
const {
state: { data: restData, loading: restLoading, error: restError },
} = useFetch(`${ENV.rest}governance?proposalId=${params.proposalId}`);
const { data, loading, error, refetch } = useProposalQuery({
const { data, loading, error } = useProposalQuery({
fetchPolicy: 'network-only',
errorPolicy: 'ignore',
variables: {
proposalId: params.proposalId || '',
includeNewMarketProductField: !!featureFlags.PRODUCT_PERPETUALS,
includeUpdateMarketState: !!featureFlags.UPDATE_MARKET_STATE,
includeUpdateReferralProgram: !!featureFlags.REFERRALS,
},
skip: !params.proposalId,
pollInterval: 2000,
});
const proposal = data?.proposal as IProposal;
const successor = useSuccessorMarketProposalDetails(params.proposalId);
const isSuccessor = !!successor?.parentMarketId || !!successor.code;
const {
state: {
data: originalMarketProposalRestData,
loading: originalMarketProposalRestLoading,
error: originalMarketProposalRestError,
},
} = useFetch(
`${ENV.rest}governance?proposalId=${
proposal?.terms.change.__typename === 'UpdateMarket' &&
proposal.terms.change.marketId
}`,
undefined,
true,
proposal?.terms.change.__typename !== 'UpdateMarket'
);
const {
state: {
data: previouslyEnactedMarketProposalsRestData,
loading: previouslyEnactedMarketProposalsRestLoading,
error: previouslyEnactedMarketProposalsRestError,
},
} = useFetch(
`${ENV.rest}governances?proposalState=STATE_ENACTED&proposalType=TYPE_UPDATE_MARKET`,
undefined,
true,
proposal?.terms.change.__typename !== 'UpdateMarket'
);
const {
data: marketData,
loading: marketLoading,
error: marketError,
} = useDataProvider({
dataProvider: marketInfoProvider,
skipUpdates: true,
variables: {
marketId: proposal?.id || '',
skip: !proposal?.id,
},
});
const {
data: parentMarketId,
loading: parentMarketIdLoading,
error: parentMarketIdError,
} = useParentMarketIdQuery({
variables: {
marketId: marketData?.id || '',
},
skip: !featureFlags.SUCCESSOR_MARKETS || !isSuccessor || !marketData?.id,
});
const {
data: parentMarketData,
loading: parentMarketLoading,
error: parentMarketError,
} = useDataProvider({
dataProvider: marketInfoProvider,
skipUpdates: true,
variables: {
marketId: parentMarketId?.market?.parentMarketID || '',
skip:
!featureFlags.SUCCESSOR_MARKETS ||
!isSuccessor ||
!parentMarketId?.market?.parentMarketID,
},
});
const {
data: assetData,
loading: assetLoading,
error: assetError,
} = useAssetQuery({
fetchPolicy: 'network-only',
variables: {
assetId:
(proposal?.terms.change.__typename === 'NewAsset' && proposal?.id) ||
(proposal?.terms.change.__typename === 'UpdateAsset' &&
proposal.terms.change.assetId) ||
'',
},
skip: !['NewAsset', 'UpdateAsset'].includes(
proposal?.terms?.change?.__typename || ''
),
});
useEffect(() => {
if (
previouslyEnactedMarketProposalsRestData &&
proposal?.terms.change.__typename === 'UpdateMarket'
) {
const change = proposal?.terms?.change as { marketId: string };
const filteredProposals =
// @ts-ignore rest data is not typed
previouslyEnactedMarketProposalsRestData.connection.edges.filter(
// @ts-ignore rest data is not typed
({ node }) =>
node?.proposal?.terms?.updateMarket?.marketId === change.marketId
);
const sortedProposals = filteredProposals.sort(
// @ts-ignore rest data is not typed
(a, b) =>
new Date(a?.node?.terms?.enactmentTimestamp).getTime() -
new Date(b?.node?.terms?.enactmentTimestamp).getTime()
);
setMostRecentlyEnactedAssociatedMarketProposal(
sortedProposals[sortedProposals.length - 1]
);
}
}, [
previouslyEnactedMarketProposalsRestData,
params.proposalId,
proposal?.terms.change.__typename,
proposal?.terms.change,
]);
useEffect(() => {
const interval = setInterval(refetch, 2000);
return () => clearInterval(interval);
}, [refetch]);
return (
<AsyncRenderer
loading={
loading ||
marketLoading ||
assetLoading ||
networkParamsLoading ||
parentMarketIdLoading ||
parentMarketLoading ||
(restLoading ? (restLoading as boolean) : false) ||
(originalMarketProposalRestLoading
? (originalMarketProposalRestLoading as boolean)
: false) ||
(previouslyEnactedMarketProposalsRestLoading
? (previouslyEnactedMarketProposalsRestLoading as boolean)
: false)
}
error={
error ||
marketError ||
assetError ||
networkParamsError ||
parentMarketIdError ||
parentMarketError ||
restError ||
originalMarketProposalRestError ||
previouslyEnactedMarketProposalsRestError
}
loading={Boolean(loading || restLoading)}
error={error}
data={{
...data,
...networkParams,
...(marketData ? { newMarketData: marketData } : {}),
...(parentMarketData ? { parentMarketData } : {}),
...(assetData ? { assetData } : {}),
...(restData ? { restData } : {}),
...(originalMarketProposalRestData
? { originalMarketProposalRestData }
: {}),
...(previouslyEnactedMarketProposalsRestData
? { previouslyEnactedMarketProposalsRestData }
: {}),
}}
>
{data?.proposal ? (
<Proposal
proposal={proposal}
networkParams={networkParams}
restData={restData}
marketData={marketData}
parentMarketData={parentMarketData}
assetData={assetData}
originalMarketProposalRestData={originalMarketProposalRestData}
mostRecentlyEnactedAssociatedMarketProposal={
mostRecentlyEnactedAssociatedMarketProposal
}
/>
<Proposal proposal={data.proposal} restData={restData} />
) : (
<ProposalNotFound />
)}
@@ -1,186 +0,0 @@
fragment NewMarketProductFields on Proposal {
terms {
change {
... on NewMarket {
instrument {
product {
__typename
}
}
}
}
}
}
fragment UpdateMarketStates on Proposal {
terms {
change {
... on UpdateMarketState {
updateType
market {
decimalPlaces
id
tradableInstrument {
instrument {
product {
__typename
... on Future {
quoteName
}
... on Perpetual {
quoteName
}
}
name
code
}
}
}
updateType
price
}
}
}
}
fragment UpdateReferralPrograms on Proposal {
terms {
change {
... on UpdateReferralProgram {
benefitTiers {
minimumEpochs
minimumRunningNotionalTakerVolume
referralDiscountFactor
referralRewardFactor
}
endOfProgram: endOfProgramTimestamp
windowLength
stakingTiers {
minimumStakedTokens
referralRewardMultiplier
}
}
}
}
}
fragment UpdateVolumeDiscountPrograms on Proposal {
terms {
change {
... on UpdateVolumeDiscountProgram {
benefitTiers {
minimumRunningNotionalTakerVolume
volumeDiscountFactor
}
endOfProgramTimestamp
windowLength
}
}
}
}
fragment ProposalFields on Proposal {
id
rationale {
title
description
}
reference
state
datetime
rejectionReason
party {
id
}
errorDetails
terms {
closingDatetime
enactmentDatetime
change {
... on NewMarket {
instrument {
name
code
product {
... on FutureProduct {
settlementAsset {
symbol
}
}
... on PerpetualProduct {
settlementAsset {
symbol
}
}
}
}
}
... on UpdateMarket {
marketId
}
... on NewAsset {
__typename
name
symbol
decimals
quantum
source {
... on BuiltinAsset {
maxFaucetAmountMint
}
... on ERC20 {
contractAddress
withdrawThreshold
lifetimeLimit
}
}
}
... on UpdateNetworkParameter {
networkParameter {
key
value
}
}
... on UpdateAsset {
quantum
assetId
source {
... on UpdateERC20 {
lifetimeLimit
withdrawThreshold
}
}
}
}
}
votes {
yes {
totalTokens
totalNumber
totalEquityLikeShareWeight
}
no {
totalTokens
totalNumber
totalEquityLikeShareWeight
}
}
}
query Proposals(
$includeNewMarketProductFields: Boolean!
$includeUpdateMarketStates: Boolean!
$includeUpdateReferralPrograms: Boolean!
) {
proposalsConnection {
edges {
node {
...ProposalFields
...NewMarketProductFields @include(if: $includeNewMarketProductFields)
...UpdateMarketStates @include(if: $includeUpdateMarketStates)
...UpdateReferralPrograms @include(if: $includeUpdateReferralPrograms)
...UpdateVolumeDiscountPrograms
}
}
}
}
@@ -1,247 +0,0 @@
import * as Types from '@vegaprotocol/types';
import { gql } from '@apollo/client';
import * as Apollo from '@apollo/client';
const defaultOptions = {} as const;
export type NewMarketProductFieldsFragment = { __typename?: 'Proposal', terms: { __typename?: 'ProposalTerms', change: { __typename?: 'CancelTransfer' } | { __typename?: 'NewAsset' } | { __typename?: 'NewFreeform' } | { __typename?: 'NewMarket', instrument: { __typename?: 'InstrumentConfiguration', product?: { __typename: 'FutureProduct' } | { __typename: 'PerpetualProduct' } | { __typename: 'SpotProduct' } | null } } | { __typename?: 'NewSpotMarket' } | { __typename?: 'NewTransfer' } | { __typename?: 'UpdateAsset' } | { __typename?: 'UpdateMarket' } | { __typename?: 'UpdateMarketState' } | { __typename?: 'UpdateNetworkParameter' } | { __typename?: 'UpdateReferralProgram' } | { __typename?: 'UpdateSpotMarket' } | { __typename?: 'UpdateVolumeDiscountProgram' } } };
export type UpdateMarketStatesFragment = { __typename?: 'Proposal', terms: { __typename?: 'ProposalTerms', change: { __typename?: 'CancelTransfer' } | { __typename?: 'NewAsset' } | { __typename?: 'NewFreeform' } | { __typename?: 'NewMarket' } | { __typename?: 'NewSpotMarket' } | { __typename?: 'NewTransfer' } | { __typename?: 'UpdateAsset' } | { __typename?: 'UpdateMarket' } | { __typename?: 'UpdateMarketState', updateType: Types.MarketUpdateType, price?: string | null, market: { __typename?: 'Market', decimalPlaces: number, id: string, tradableInstrument: { __typename?: 'TradableInstrument', instrument: { __typename?: 'Instrument', name: string, code: string, product: { __typename: 'Future', quoteName: string } | { __typename: 'Perpetual', quoteName: string } | { __typename: 'Spot' } } } } } | { __typename?: 'UpdateNetworkParameter' } | { __typename?: 'UpdateReferralProgram' } | { __typename?: 'UpdateSpotMarket' } | { __typename?: 'UpdateVolumeDiscountProgram' } } };
export type UpdateReferralProgramsFragment = { __typename?: 'Proposal', terms: { __typename?: 'ProposalTerms', change: { __typename?: 'CancelTransfer' } | { __typename?: 'NewAsset' } | { __typename?: 'NewFreeform' } | { __typename?: 'NewMarket' } | { __typename?: 'NewSpotMarket' } | { __typename?: 'NewTransfer' } | { __typename?: 'UpdateAsset' } | { __typename?: 'UpdateMarket' } | { __typename?: 'UpdateMarketState' } | { __typename?: 'UpdateNetworkParameter' } | { __typename?: 'UpdateReferralProgram', windowLength: number, endOfProgram: any, benefitTiers: Array<{ __typename?: 'BenefitTier', minimumEpochs: number, minimumRunningNotionalTakerVolume: string, referralDiscountFactor: string, referralRewardFactor: string }>, stakingTiers: Array<{ __typename?: 'StakingTier', minimumStakedTokens: string, referralRewardMultiplier: string }> } | { __typename?: 'UpdateSpotMarket' } | { __typename?: 'UpdateVolumeDiscountProgram' } } };
export type UpdateVolumeDiscountProgramsFragment = { __typename?: 'Proposal', terms: { __typename?: 'ProposalTerms', change: { __typename?: 'CancelTransfer' } | { __typename?: 'NewAsset' } | { __typename?: 'NewFreeform' } | { __typename?: 'NewMarket' } | { __typename?: 'NewSpotMarket' } | { __typename?: 'NewTransfer' } | { __typename?: 'UpdateAsset' } | { __typename?: 'UpdateMarket' } | { __typename?: 'UpdateMarketState' } | { __typename?: 'UpdateNetworkParameter' } | { __typename?: 'UpdateReferralProgram' } | { __typename?: 'UpdateSpotMarket' } | { __typename?: 'UpdateVolumeDiscountProgram', endOfProgramTimestamp: any, windowLength: number, benefitTiers: Array<{ __typename?: 'VolumeBenefitTier', minimumRunningNotionalTakerVolume: string, volumeDiscountFactor: string }> } } };
export type ProposalFieldsFragment = { __typename?: 'Proposal', id?: string | null, reference: string, state: Types.ProposalState, datetime: any, rejectionReason?: Types.ProposalRejectionReason | null, errorDetails?: string | null, rationale: { __typename?: 'ProposalRationale', title: string, description: string }, party: { __typename?: 'Party', id: string }, terms: { __typename?: 'ProposalTerms', closingDatetime: any, enactmentDatetime?: any | null, change: { __typename?: 'CancelTransfer' } | { __typename: 'NewAsset', name: string, symbol: string, decimals: number, quantum: string, source: { __typename?: 'BuiltinAsset', maxFaucetAmountMint: string } | { __typename?: 'ERC20', contractAddress: string, withdrawThreshold: string, lifetimeLimit: string } } | { __typename?: 'NewFreeform' } | { __typename?: 'NewMarket', instrument: { __typename?: 'InstrumentConfiguration', name: string, code: string, product?: { __typename?: 'FutureProduct', settlementAsset: { __typename?: 'Asset', symbol: string } } | { __typename?: 'PerpetualProduct', settlementAsset: { __typename?: 'Asset', symbol: string } } | { __typename?: 'SpotProduct' } | null } } | { __typename?: 'NewSpotMarket' } | { __typename?: 'NewTransfer' } | { __typename?: 'UpdateAsset', quantum: string, assetId: string, source: { __typename?: 'UpdateERC20', lifetimeLimit: string, withdrawThreshold: string } } | { __typename?: 'UpdateMarket', marketId: string } | { __typename?: 'UpdateMarketState' } | { __typename?: 'UpdateNetworkParameter', networkParameter: { __typename?: 'NetworkParameter', key: string, value: string } } | { __typename?: 'UpdateReferralProgram' } | { __typename?: 'UpdateSpotMarket' } | { __typename?: 'UpdateVolumeDiscountProgram' } }, votes: { __typename?: 'ProposalVotes', yes: { __typename?: 'ProposalVoteSide', totalTokens: string, totalNumber: string, totalEquityLikeShareWeight: string }, no: { __typename?: 'ProposalVoteSide', totalTokens: string, totalNumber: string, totalEquityLikeShareWeight: string } } };
export type ProposalsQueryVariables = Types.Exact<{
includeNewMarketProductFields: Types.Scalars['Boolean'];
includeUpdateMarketStates: Types.Scalars['Boolean'];
includeUpdateReferralPrograms: Types.Scalars['Boolean'];
}>;
export type ProposalsQuery = { __typename?: 'Query', proposalsConnection?: { __typename?: 'ProposalsConnection', edges?: Array<{ __typename?: 'ProposalEdge', node: { __typename?: 'Proposal', id?: string | null, reference: string, state: Types.ProposalState, datetime: any, rejectionReason?: Types.ProposalRejectionReason | null, errorDetails?: string | null, rationale: { __typename?: 'ProposalRationale', title: string, description: string }, party: { __typename?: 'Party', id: string }, terms: { __typename?: 'ProposalTerms', closingDatetime: any, enactmentDatetime?: any | null, change: { __typename?: 'CancelTransfer' } | { __typename: 'NewAsset', name: string, symbol: string, decimals: number, quantum: string, source: { __typename?: 'BuiltinAsset', maxFaucetAmountMint: string } | { __typename?: 'ERC20', contractAddress: string, withdrawThreshold: string, lifetimeLimit: string } } | { __typename?: 'NewFreeform' } | { __typename?: 'NewMarket', instrument: { __typename?: 'InstrumentConfiguration', name: string, code: string, product?: { __typename: 'FutureProduct', settlementAsset: { __typename?: 'Asset', symbol: string } } | { __typename: 'PerpetualProduct', settlementAsset: { __typename?: 'Asset', symbol: string } } | { __typename: 'SpotProduct' } | null } } | { __typename?: 'NewSpotMarket' } | { __typename?: 'NewTransfer' } | { __typename?: 'UpdateAsset', quantum: string, assetId: string, source: { __typename?: 'UpdateERC20', lifetimeLimit: string, withdrawThreshold: string } } | { __typename?: 'UpdateMarket', marketId: string } | { __typename?: 'UpdateMarketState', updateType: Types.MarketUpdateType, price?: string | null, market: { __typename?: 'Market', decimalPlaces: number, id: string, tradableInstrument: { __typename?: 'TradableInstrument', instrument: { __typename?: 'Instrument', name: string, code: string, product: { __typename: 'Future', quoteName: string } | { __typename: 'Perpetual', quoteName: string } | { __typename: 'Spot' } } } } } | { __typename?: 'UpdateNetworkParameter', networkParameter: { __typename?: 'NetworkParameter', key: string, value: string } } | { __typename?: 'UpdateReferralProgram', windowLength: number, endOfProgram: any, benefitTiers: Array<{ __typename?: 'BenefitTier', minimumEpochs: number, minimumRunningNotionalTakerVolume: string, referralDiscountFactor: string, referralRewardFactor: string }>, stakingTiers: Array<{ __typename?: 'StakingTier', minimumStakedTokens: string, referralRewardMultiplier: string }> } | { __typename?: 'UpdateSpotMarket' } | { __typename?: 'UpdateVolumeDiscountProgram', endOfProgramTimestamp: any, windowLength: number, benefitTiers: Array<{ __typename?: 'VolumeBenefitTier', minimumRunningNotionalTakerVolume: string, volumeDiscountFactor: string }> } }, votes: { __typename?: 'ProposalVotes', yes: { __typename?: 'ProposalVoteSide', totalTokens: string, totalNumber: string, totalEquityLikeShareWeight: string }, no: { __typename?: 'ProposalVoteSide', totalTokens: string, totalNumber: string, totalEquityLikeShareWeight: string } } } } | null> | null } | null };
export const NewMarketProductFieldsFragmentDoc = gql`
fragment NewMarketProductFields on Proposal {
terms {
change {
... on NewMarket {
instrument {
product {
__typename
}
}
}
}
}
}
`;
export const UpdateMarketStatesFragmentDoc = gql`
fragment UpdateMarketStates on Proposal {
terms {
change {
... on UpdateMarketState {
updateType
market {
decimalPlaces
id
tradableInstrument {
instrument {
product {
__typename
... on Future {
quoteName
}
... on Perpetual {
quoteName
}
}
name
code
}
}
}
updateType
price
}
}
}
}
`;
export const UpdateReferralProgramsFragmentDoc = gql`
fragment UpdateReferralPrograms on Proposal {
terms {
change {
... on UpdateReferralProgram {
benefitTiers {
minimumEpochs
minimumRunningNotionalTakerVolume
referralDiscountFactor
referralRewardFactor
}
endOfProgram: endOfProgramTimestamp
windowLength
stakingTiers {
minimumStakedTokens
referralRewardMultiplier
}
}
}
}
}
`;
export const UpdateVolumeDiscountProgramsFragmentDoc = gql`
fragment UpdateVolumeDiscountPrograms on Proposal {
terms {
change {
... on UpdateVolumeDiscountProgram {
benefitTiers {
minimumRunningNotionalTakerVolume
volumeDiscountFactor
}
endOfProgramTimestamp
windowLength
}
}
}
}
`;
export const ProposalFieldsFragmentDoc = gql`
fragment ProposalFields on Proposal {
id
rationale {
title
description
}
reference
state
datetime
rejectionReason
party {
id
}
errorDetails
terms {
closingDatetime
enactmentDatetime
change {
... on NewMarket {
instrument {
name
code
product {
... on FutureProduct {
settlementAsset {
symbol
}
}
... on PerpetualProduct {
settlementAsset {
symbol
}
}
}
}
}
... on UpdateMarket {
marketId
}
... on NewAsset {
__typename
name
symbol
decimals
quantum
source {
... on BuiltinAsset {
maxFaucetAmountMint
}
... on ERC20 {
contractAddress
withdrawThreshold
lifetimeLimit
}
}
}
... on UpdateNetworkParameter {
networkParameter {
key
value
}
}
... on UpdateAsset {
quantum
assetId
source {
... on UpdateERC20 {
lifetimeLimit
withdrawThreshold
}
}
}
}
}
votes {
yes {
totalTokens
totalNumber
totalEquityLikeShareWeight
}
no {
totalTokens
totalNumber
totalEquityLikeShareWeight
}
}
}
`;
export const ProposalsDocument = gql`
query Proposals($includeNewMarketProductFields: Boolean!, $includeUpdateMarketStates: Boolean!, $includeUpdateReferralPrograms: Boolean!) {
proposalsConnection {
edges {
node {
...ProposalFields
...NewMarketProductFields @include(if: $includeNewMarketProductFields)
...UpdateMarketStates @include(if: $includeUpdateMarketStates)
...UpdateReferralPrograms @include(if: $includeUpdateReferralPrograms)
...UpdateVolumeDiscountPrograms
}
}
}
}
${ProposalFieldsFragmentDoc}
${NewMarketProductFieldsFragmentDoc}
${UpdateMarketStatesFragmentDoc}
${UpdateReferralProgramsFragmentDoc}
${UpdateVolumeDiscountProgramsFragmentDoc}`;
/**
* __useProposalsQuery__
*
* To run a query within a React component, call `useProposalsQuery` and pass it any options that fit your needs.
* When your component renders, `useProposalsQuery` 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 } = useProposalsQuery({
* variables: {
* includeNewMarketProductFields: // value for 'includeNewMarketProductFields'
* includeUpdateMarketStates: // value for 'includeUpdateMarketStates'
* includeUpdateReferralPrograms: // value for 'includeUpdateReferralPrograms'
* },
* });
*/
export function useProposalsQuery(baseOptions: Apollo.QueryHookOptions<ProposalsQuery, ProposalsQueryVariables>) {
const options = {...defaultOptions, ...baseOptions}
return Apollo.useQuery<ProposalsQuery, ProposalsQueryVariables>(ProposalsDocument, options);
}
export function useProposalsLazyQuery(baseOptions?: Apollo.LazyQueryHookOptions<ProposalsQuery, ProposalsQueryVariables>) {
const options = {...defaultOptions, ...baseOptions}
return Apollo.useLazyQuery<ProposalsQuery, ProposalsQueryVariables>(ProposalsDocument, options);
}
export type ProposalsQueryHookResult = ReturnType<typeof useProposalsQuery>;
export type ProposalsLazyQueryHookResult = ReturnType<typeof useProposalsLazyQuery>;
export type ProposalsQueryResult = Apollo.QueryResult<ProposalsQuery, ProposalsQueryVariables>;
@@ -1,31 +1,27 @@
import flow from 'lodash/flow';
import compact from 'lodash/compact';
import { Callout, Intent, Splash } from '@vegaprotocol/ui-toolkit';
import { useMemo } from 'react';
import { useTranslation } from 'react-i18next';
import { SplashLoader } from '../../../components/splash-loader';
import { ProposalsList } from '../components/proposals-list';
import { getNodes, removePaginationWrapper } from '@vegaprotocol/utils';
import { getNodes } from '@vegaprotocol/utils';
import {
ProposalState,
ProtocolUpgradeProposalStatus,
} from '@vegaprotocol/types';
import { type NodeConnection, type NodeEdge } from '@vegaprotocol/utils';
import {
useProposalsQuery,
type ProposalFieldsFragment,
} from './__generated__/Proposals';
import { useProposalsQuery } from '../__generated__/Proposals';
import { type ProtocolUpgradeProposalFieldsFragment } from '@vegaprotocol/proposals';
import { useProtocolUpgradeProposalsQuery } from '@vegaprotocol/proposals';
import { useFeatureFlags } from '@vegaprotocol/environment';
import { type BatchProposal, type Proposal } from '../types';
export function getNotRejectedProposals(data?: ProposalFieldsFragment[]) {
return flow([
(data) =>
data.filter(
(p: ProposalFieldsFragment) => p?.state !== ProposalState.STATE_REJECTED
),
])(data);
export function getNotRejectedProposals(
data?: Array<Proposal | BatchProposal>
) {
if (!data) return [];
return data.filter((p) => p.state !== ProposalState.STATE_REJECTED);
}
export function getNotRejectedProtocolUpgradeProposals<
@@ -43,17 +39,11 @@ export function getNotRejectedProtocolUpgradeProposals<
}
export const ProposalsContainer = () => {
const featureFlags = useFeatureFlags((state) => state.flags);
const { t } = useTranslation();
const { data, loading, error } = useProposalsQuery({
pollInterval: 5000,
fetchPolicy: 'network-only',
errorPolicy: 'ignore',
variables: {
includeNewMarketProductFields: !!featureFlags.PRODUCT_PERPETUALS,
includeUpdateMarketStates: !!featureFlags.UPDATE_MARKET_STATE,
includeUpdateReferralPrograms: !!featureFlags.REFERRALS,
},
});
const {
@@ -69,7 +59,7 @@ export const ProposalsContainer = () => {
const proposals = useMemo(
() =>
getNotRejectedProposals(
removePaginationWrapper(data?.proposalsConnection?.edges)
compact(data?.proposalsConnection?.edges?.map((e) => e?.proposalNode))
),
[data]
);
@@ -1,16 +1,16 @@
import compact from 'lodash/compact';
import { Callout, Intent, Splash } from '@vegaprotocol/ui-toolkit';
import { useMemo } from 'react';
import { useTranslation } from 'react-i18next';
import { SplashLoader } from '../../../components/splash-loader';
import { RejectedProposalsList } from '../components/proposals-list';
import type { ProposalFieldsFragment } from '../proposals/__generated__/Proposals';
import { useProposalsQuery } from '../proposals/__generated__/Proposals';
import { removePaginationWrapper } from '@vegaprotocol/utils';
import { type ProposalFieldsFragment } from '../__generated__/Proposals';
import { useProposalsQuery } from '../__generated__/Proposals';
import flow from 'lodash/flow';
import orderBy from 'lodash/orderBy';
import { ProposalState } from '@vegaprotocol/types';
import { useFeatureFlags } from '@vegaprotocol/environment';
import { type BatchProposal, type Proposal } from '../types';
const orderByDate = (arr: ProposalFieldsFragment[]) =>
orderBy(
@@ -22,7 +22,9 @@ const orderByDate = (arr: ProposalFieldsFragment[]) =>
['desc', 'desc']
);
export function getRejectedProposals(data?: ProposalFieldsFragment[] | null) {
export function getRejectedProposals(
data?: Array<Proposal | BatchProposal> | null
) {
return flow([
(data) =>
data.filter(
@@ -33,23 +35,17 @@ export function getRejectedProposals(data?: ProposalFieldsFragment[] | null) {
}
export const RejectedProposalsContainer = () => {
const featureFlags = useFeatureFlags((state) => state.flags);
const { t } = useTranslation();
const { data, loading, error } = useProposalsQuery({
pollInterval: 5000,
fetchPolicy: 'network-only',
errorPolicy: 'ignore',
variables: {
includeNewMarketProductFields: !!featureFlags.PRODUCT_PERPETUALS,
includeUpdateMarketStates: !!featureFlags.UPDATE_MARKET_STATE,
includeUpdateReferralPrograms: !!featureFlags.REFERRALS,
},
});
const proposals = useMemo(
() =>
getRejectedProposals(
removePaginationWrapper(data?.proposalsConnection?.edges)
compact(data?.proposalsConnection?.edges?.map((e) => e?.proposalNode))
),
[data]
);
@@ -6,7 +6,7 @@ import isArray from 'lodash/isArray';
import mergeWith from 'lodash/mergeWith';
import { type PartialDeep } from 'type-fest';
import { type ProposalQuery } from '../proposal/__generated__/Proposal';
import { type ProposalQuery } from '../__generated__/Proposals';
import { type ProtocolUpgradeProposalFieldsFragment } from '@vegaprotocol/proposals';
import { type Proposal } from '../types';
+12 -4
View File
@@ -1,11 +1,19 @@
import type { ProposalQuery } from './proposal/__generated__/Proposal';
import { type ProposalNode } from '@vegaprotocol/types';
import {
type BatchProposalFieldsFragment,
type ProposalFieldsFragment,
} from './__generated__/Proposals';
/**
* The default Proposal type needs extracting from the ProposalNode union type
* as lots of fields on the original type don't exist on BatchProposal. Eventually
* we will support BatchProposal but for now we don't
*/
export type Proposal = Extract<
ProposalQuery['proposal'],
{ __typename?: 'Proposal' }
export type Proposal = ProposalFieldsFragment;
export type BatchProposal = BatchProposalFieldsFragment;
export type ProposalChangeType = NonNullable<
Proposal['terms']['change']['__typename']
>;
export type ProposalType = NonNullable<ProposalNode['__typename']>;
@@ -241,6 +241,16 @@ describe('generateEpochAssetRewardsList', () => {
amount: '5',
},
},
{
// This should not be included in the result
node: {
epoch: 2,
assetId: '3',
decimals: 18,
rewardType: AccountType.ACCOUNT_TYPE_REWARD_RETURN_VOLATILITY,
amount: '5',
},
},
],
},
epoch: {
@@ -83,6 +83,12 @@ export const generateEpochTotalRewardsList = ({
(Number(rewardItem?.amount) || 0) + Number(reward.amount)
).toString();
// only RowAccountTypes are relevant for this table, others should
// be discarded
if (!Object.keys(RowAccountTypes).includes(reward.rewardType)) {
return acc;
}
rewards?.set(reward.rewardType, {
rewardType: reward.rewardType,
amount,
+1 -1
View File
@@ -22,7 +22,7 @@ NX_WALLETCONNECT_PROJECT_ID=fe8091dc35738863e509fc4947525c72
# Cosmic elevator flags
NX_SUCCESSOR_MARKETS=true
NX_STOP_ORDERS=true
NX_ISOLATED_MARGIN=true
NX_ISOLATED_MARGIN=false
NX_ICEBERG_ORDERS=true
NX_METAMASK_SNAPS=true
NX_REFERRALS=true
@@ -1,10 +1,13 @@
import { useT } from '../../lib/use-t';
import { ErrorBoundary } from '@sentry/react';
import { CompetitionsHeader } from '../../components/competitions/competitions-header';
import { Intent, Loader, TradingButton } from '@vegaprotocol/ui-toolkit';
import { useGames } from '../../lib/hooks/use-games';
import { useCurrentEpochInfoQuery } from '../referrals/hooks/__generated__/Epoch';
import {
ExternalLink,
Intent,
Loader,
TradingButton,
} from '@vegaprotocol/ui-toolkit';
import { useEpochInfoQuery } from '../../lib/hooks/__generated__/Epoch';
import { Link, useNavigate } from 'react-router-dom';
import { Links } from '../../lib/links';
import {
@@ -18,6 +21,9 @@ import take from 'lodash/take';
import { usePageTitle } from '../../lib/hooks/use-page-title';
import { TeamCard } from '../../components/competitions/team-card';
import { useMyTeam } from '../../lib/hooks/use-my-team';
import { useRewards } from '../../lib/hooks/use-rewards';
import { Trans } from 'react-i18next';
import { DocsLinks } from '@vegaprotocol/environment';
export const CompetitionsHome = () => {
const t = useT();
@@ -25,12 +31,12 @@ export const CompetitionsHome = () => {
usePageTitle(t('Competitions'));
const { data: epochData } = useCurrentEpochInfoQuery();
const { data: epochData } = useEpochInfoQuery();
const currentEpoch = Number(epochData?.epoch.id);
const { data: gamesData, loading: gamesLoading } = useGames({
const { data: gamesData, loading: gamesLoading } = useRewards({
onlyActive: true,
currentEpoch,
scopeToTeams: true,
});
const { data: teamsData, loading: teamsLoading } = useTeams();
@@ -45,10 +51,34 @@ export const CompetitionsHome = () => {
return (
<ErrorBoundary>
<CompetitionsHeader title={t('Competitions')}>
<p className="text-lg mb-3">
<Trans
i18nKey={
'Check the cards below to see what community-created, on-chain games are active and how to compete. Joining a team also lets you take part in the on-chain <0>referral program</0>.'
}
components={[
<Link className="underline" key="ref-prog" to={Links.REFERRALS()}>
referral program
</Link>,
]}
/>
</p>
<p className="text-lg mb-1">
{t(
'Be a team player! Participate in games and work together to rake in as much profit to win.'
)}
<Trans
i18nKey={
'Got an idea for a competition? Anyone can define and fund one -- <0>propose an on-chain game</0> yourself.'
}
components={[
<ExternalLink
className="underline"
key="propose"
href={DocsLinks?.ASSET_TRANSFER_PROPOSAL}
>
propose an on-chain game
</ExternalLink>,
]}
/>
{/** Docs: https://docs.vega.xyz/mainnet/tutorials/proposals/asset-transfer-proposal */}
</p>
</CompetitionsHeader>
@@ -75,7 +105,7 @@ export const CompetitionsHome = () => {
variant="A"
title={t('Create a team')}
description={t(
'Lorem ipsum dolor sit amet, consectetur adipisicing elit placeat ipsum minus nemo error dicta.'
'Create a new team, share your code with potential members, or set a whitelist for an exclusive group.'
)}
actionElement={
<TradingButton
@@ -94,7 +124,7 @@ export const CompetitionsHome = () => {
variant="B"
title={t('Solo team / lone wolf')}
description={t(
'Lorem ipsum dolor sit amet, consectetur adipisicing elit placeat ipsum minus nemo error dicta.'
'Want to compete but think the best team size is one? This is the option for you.'
)}
actionElement={
<TradingButton
@@ -112,7 +142,7 @@ export const CompetitionsHome = () => {
variant="C"
title={t('Join a team')}
description={t(
'Lorem ipsum dolor sit amet, consectetur adipisicing elit placeat ipsum minus nemo error dicta.'
'Browse existing public teams to find your perfect match.'
)}
actionElement={
<TradingButton
@@ -131,27 +161,57 @@ export const CompetitionsHome = () => {
)}
{/** List of available games */}
<h2 className="text-2xl mb-6">{t('Games')}</h2>
<h2 className="text-2xl mb-1">{t('Games')}</h2>
<p className="mb-6 text-sm">
<Trans
i18nKey={
'See all the live games on the cards below. Every on-chain game is community funded and designed. <0>Find out how to create one</0>.'
}
components={[
<ExternalLink
className="underline"
key="find-out"
href={DocsLinks?.ASSET_TRANSFER_PROPOSAL}
>
Find out how to create one
</ExternalLink>,
]}
/>
{/** Docs: https://docs.vega.xyz/mainnet/tutorials/proposals/asset-transfer-proposal */}
</p>
{gamesLoading ? (
<Loader size="small" />
) : (
<GamesContainer data={gamesData} currentEpoch={currentEpoch} />
)}
<div className="mb-12 flex">
{gamesLoading ? (
<Loader size="small" />
) : (
<GamesContainer data={gamesData} currentEpoch={currentEpoch} />
)}
</div>
{/** The teams ranking */}
<div className="mb-6 flex flex-row items-baseline justify-between">
<h2 className="text-2xl">{t('Leaderboard')}</h2>
<div className="mb-1 flex flex-row items-baseline gap-3 justify-between">
<h2 className="text-2xl">
<Link to={Links.COMPETITIONS_TEAMS()} className=" underline">
{t('Leaderboard')}
</Link>
</h2>
<Link to={Links.COMPETITIONS_TEAMS()} className="text-sm underline">
{t('View all teams')}
</Link>
</div>
<p className="mb-6 text-sm">
{t(
'Teams can earn rewards if they meet the goals set in the on-chain trading competitions. Track your earned rewards here, and see which teams are top of the leaderboard this month.'
)}
</p>
{teamsLoading ? (
<Loader size="small" />
) : (
<CompetitionsLeaderboard data={take(teamsData, 10)} />
)}
<div className="flex">
{teamsLoading ? (
<Loader size="small" />
) : (
<CompetitionsLeaderboard data={take(teamsData, 10)} />
)}
</div>
</ErrorBoundary>
);
};

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