Compare commits

...
Author SHA1 Message Date
Madalina Raicu 18f8d5e648 fix(trading): adjust full screen for mobile dialogs 2024-01-24 17:20:40 +00:00
261f32aa5b feat(trading): margin mode selector (#5660)
Co-authored-by: Bartłomiej Głownia <bglownia@gmail.com>
Co-authored-by: Dariusz Majcherczyk <dariusz.majcherczyk@gmail.com>
2024-01-24 13:17:22 +00:00
Edd 053775bef6 fix(governance): handle an expected error (#5653) 2024-01-24 11:41:40 +00:00
Matthew Russellandbwallacee 0660eda334 chore(trading): store all chart state (#5627)
Co-authored-by: bwallacee <ben@vega.xyz>
2024-01-24 10:21:33 +00:00
Matthew Russell f22a3bc2d2 Revert "feat(trading): margin mode selector (#5575)"
This reverts commit fde77ebccb.
2024-01-23 08:46:16 -08:00
Bartłomiej GłowniaandDariusz Majcherczyk fde77ebccb feat(trading): margin mode selector (#5575)
Co-authored-by: Dariusz Majcherczyk <dariusz.majcherczyk@gmail.com>
2024-01-23 16:19:49 +00:00
e309669736 fix(trading): typo translation full featured link (#5659)
Co-authored-by: Matthew Russell <mattrussell36@gmail.com>
Co-authored-by: Edd <edd@vega.xyz>
2024-01-23 16:05:51 +00:00
b1621d1191 chore(trading): fix monitoring bounds (#5655) (#5657)
Co-authored-by: Matthew Russell <mattrussell36@gmail.com>
Co-authored-by: Edd <edd@vega.xyz>
2024-01-23 15:45:05 +00:00
ArtandDexter Edwards 39907f07db feat(wallet): new browser wallet connection model (#5572)
Co-authored-by: Dexter Edwards <dexter.edwards93@gmail.com>
2024-01-23 15:32:23 +00:00
3dab5f3d9b chore(trading): merge main back in develop (#5654)
Co-authored-by: Matthew Russell <mattrussell36@gmail.com>
Co-authored-by: Edd <edd@vega.xyz>
2024-01-23 14:57:20 +00:00
Edd 3cf9ae7582 fix(governance): handle null validator scores properly (#5459) 2024-01-23 14:46:19 +00:00
Ben 557894e2ef chore(trading): turn off parallel (#5650) 2024-01-23 14:43:51 +00:00
m.rayandbwallacee baf9875c69 feat(trading): filter out suspended transfers (#5640)
Co-authored-by: bwallacee <ben@vega.xyz>
2024-01-23 09:38:09 +00:00
Art 51199b02ce feat(governance): update market proposal header with copy and open buttons (#5648) 2024-01-23 10:13:56 +01:00
m.ray bb826c88f0 fix(trading): set trading view as default (#5632) 2024-01-22 16:09:18 +00:00
93 changed files with 21270 additions and 764 deletions
+3 -3
View File
@@ -19,7 +19,7 @@ jobs:
create-docker-image:
name: Create docker image for console-test
runs-on: ubuntu-22.04
timeout-minutes: 90
timeout-minutes: 45
steps:
#----------------------------------------------
# check-out frontend-monorepo
@@ -138,7 +138,7 @@ jobs:
name: run-tests
runs-on: 8-cores
needs: [create-docker-image, console-test-branch]
timeout-minutes: 90
timeout-minutes: 45
steps:
#----------------------------------------------
# load docker image
@@ -205,7 +205,7 @@ jobs:
# run tests
#----------------------------------------------
- name: Run tests
run: CONSOLE_IMAGE_NAME=ci/trading:local poetry run pytest -v -s --numprocesses 2 --dist loadfile --durations=90
run: CONSOLE_IMAGE_NAME=ci/trading:local poetry run pytest -v -s --numprocesses 1 --dist loadfile --durations=45
working-directory: apps/trading/e2e
#----------------------------------------------
# upload traces
@@ -1,9 +1,5 @@
import { render, waitFor } from '@testing-library/react';
import {
BORDER_COLOURS,
NestedDataList,
sortNestedDataByChildren,
} from './nested-data-list';
import { NestedDataList, sortNestedDataByChildren } from './nested-data-list';
import userEvent from '@testing-library/user-event';
const mockData = {
@@ -61,38 +57,6 @@ describe('NestedDataList', () => {
expect(parent[0].querySelector('li')).toHaveClass('pl-4 border-l-4 pt-2');
});
it('should repeat the border colours in the correct order', () => {
const colourMockData = {
t0: {
t1: {
t2: {
t3: {
t4: {
t5: {
t6: {
t7: {
t8: {
hello: 'world',
},
},
},
},
},
},
},
},
},
};
const tree = render(<NestedDataList data={colourMockData} />);
const { getByTestId } = tree;
for (let i = 0; i < 8; i++) {
const item = getByTestId(`T${i}`);
const expected = BORDER_COLOURS.light[i % 5];
expect(item.style.borderColor.toUpperCase()).toBe(expected);
}
});
it('should sort the data by values with children', () => {
const mockData = {
nonce: '5980890939790185837',
+9 -2
View File
@@ -26,7 +26,7 @@ import {
} from '@vegaprotocol/web3';
import { Web3Provider } from '@vegaprotocol/web3';
import { VegaWalletDialogs } from './components/vega-wallet-dialogs';
import { VegaWalletProvider } from '@vegaprotocol/wallet';
import { VegaWalletProvider, useChainId } from '@vegaprotocol/wallet';
import {
useVegaTransactionManager,
useVegaTransactionUpdater,
@@ -96,7 +96,9 @@ const cache: InMemoryCacheConfig = {
const Web3Container = ({
chainId,
}: {
/** Ethereum chain id */
chainId: number;
/** Ethereum provider url */
providerUrl: string;
}) => {
const InitializeHandlers = () => {
@@ -123,6 +125,9 @@ const Web3Container = ({
MOZILLA_EXTENSION_URL,
VEGA_WALLET_URL,
} = useEnvironment();
const vegaChainId = useChainId(VEGA_URL);
useEffect(() => {
if (chainId) {
return initializeConnectors(
@@ -157,7 +162,8 @@ const Web3Container = ({
!VEGA_EXPLORER_URL ||
!DocsLinks ||
!CHROME_EXTENSION_URL ||
!MOZILLA_EXTENSION_URL
!MOZILLA_EXTENSION_URL ||
!vegaChainId
) {
return null;
}
@@ -169,6 +175,7 @@ const Web3Container = ({
config={{
network: VEGA_ENV,
vegaUrl: VEGA_URL,
chainId: vegaChainId,
vegaWalletServiceUrl: VEGA_WALLET_URL,
links: {
explorer: VEGA_EXPLORER_URL,
+112
View File
@@ -0,0 +1,112 @@
import type { ApolloError } from '@apollo/client';
import {
PARTY_NOT_FOUND,
filterAcceptableGraphqlErrors,
isPartyNotFoundError,
} from './party';
import type { GraphQLError } from 'graphql';
/**
*
* @param message
* @returns GraphQLError
*/
function createMockApolloErrors(message: string): GraphQLError {
return {
message,
extensions: {
code: message.toUpperCase().replace(/ /g, '_'),
},
locations: [],
originalError: new Error(message),
path: [],
nodes: [],
positions: [1],
name: message,
source: {
body: message,
name: message,
locationOffset: {
line: 1,
column: 1,
},
},
};
}
describe('filterAcceptableGraphqlErrors', () => {
it('should return undefined if the error is a party not found error', () => {
const error: Partial<ApolloError> = {
graphQLErrors: [createMockApolloErrors('failed to get party for ID')],
};
const result = filterAcceptableGraphqlErrors(error as ApolloError);
expect(result).toBeUndefined();
});
it('should return the error if it is not a party not found error', () => {
const error: Partial<ApolloError> = {
graphQLErrors: [createMockApolloErrors('Some other error')],
};
const result = filterAcceptableGraphqlErrors(error as ApolloError);
expect(result).toEqual(error);
});
it('should return the error if there are multiple errors', () => {
const error: Partial<ApolloError> = {
graphQLErrors: [
createMockApolloErrors('failed to get party for ID'),
createMockApolloErrors('Some other error'),
],
};
const result = filterAcceptableGraphqlErrors(error as ApolloError);
expect(result).toEqual(error);
});
it('should return the error if there are no errors', () => {
const error: Partial<ApolloError> = {
graphQLErrors: [],
};
const result = filterAcceptableGraphqlErrors(error as ApolloError);
expect(result).toEqual(error);
});
it('should return undefined if the error is undefined', () => {
const result = filterAcceptableGraphqlErrors(undefined);
expect(result).toBeUndefined();
});
});
describe('isPartyNotFoundError', () => {
it('should return true if the error message includes PARTY_NOT_FOUND', () => {
const error = { message: 'failed to get party for ID' };
const result = isPartyNotFoundError(error);
expect(result).toBe(true);
});
it('should return false if the error message does not include PARTY_NOT_FOUND', () => {
const error = { message: 'Some other error' };
const result = isPartyNotFoundError(error);
expect(result).toBe(false);
});
// Will trip if the error message changes, which should not be a problem, but there
// might be logic that depends on it
it('expects party not found error to remain consistent', () => {
const error = 'failed to get party for ID';
expect(PARTY_NOT_FOUND).toStrictEqual(error);
});
});
+22
View File
@@ -1,3 +1,5 @@
import type { ApolloError } from '@apollo/client';
export const PARTY_NOT_FOUND = 'failed to get party for ID';
export const isPartyNotFoundError = (error: { message: string }) => {
@@ -6,3 +8,23 @@ export const isPartyNotFoundError = (error: { message: string }) => {
}
return false;
};
/**
* If a party has no accounts or data, then this GraphQL query believes it does not exist
* Not having any rewards is a valid state, so in some cases we can filter this error out.
*
* @param error ApolloError | undefined
* @returns ApolloError | undefined
*/
export function filterAcceptableGraphqlErrors(
error?: ApolloError
): ApolloError | undefined {
// Currently the only error we expect is when a party has no accounts
if (error && error.graphQLErrors.length === 1) {
if (isPartyNotFoundError(error.graphQLErrors[0])) {
return;
}
}
return error;
}
@@ -124,7 +124,7 @@ describe('Proposal header', () => {
screen.queryByTestId('proposal-description')
).not.toBeInTheDocument();
expect(screen.getByTestId('proposal-details')).toHaveTextContent(
'Market change: MarketId'
'Update to market ID: MarketId'
);
});
@@ -1,5 +1,11 @@
import { useTranslation } from 'react-i18next';
import { Lozenge, VegaIcon, VegaIconNames } from '@vegaprotocol/ui-toolkit';
import {
CopyWithTooltip,
Lozenge,
Tooltip,
VegaIcon,
VegaIconNames,
} from '@vegaprotocol/ui-toolkit';
import { shorten } from '@vegaprotocol/utils';
import { Heading, SubHeading } from '../../../../components/heading';
import type { ReactNode } from 'react';
@@ -12,7 +18,12 @@ import {
useNewTransferProposalDetails,
useSuccessorMarketProposalDetails,
} from '@vegaprotocol/proposals';
import { useFeatureFlags } from '@vegaprotocol/environment';
import {
CONSOLE_MARKET_PAGE,
DApp,
useFeatureFlags,
useLinks,
} from '@vegaprotocol/environment';
import Routes from '../../../routes';
import { Link } from 'react-router-dom';
import type { VoteState } from '../vote-details/use-user-vote';
@@ -32,6 +43,8 @@ export const ProposalHeader = ({
const { t } = useTranslation();
const change = proposal?.terms.change;
const consoleLink = useLinks(DApp.Console);
let details: ReactNode;
let proposalType = '';
let fallbackTitle = '';
@@ -106,8 +119,33 @@ export const ProposalHeader = ({
fallbackTitle = t('UpdateMarketProposal');
details = (
<>
<span>{t('MarketChange')}:</span>{' '}
<span>{truncateMiddle(change.marketId)}</span>
<span>{t('UpdateToMarket')}:</span>{' '}
<span className="inline-flex items-start gap-2">
<span className="break-all">{change.marketId} </span>
<span className="inline-flex items-end gap-0">
<CopyWithTooltip
text={change.marketId}
description={t('copyToClipboard')}
>
<button className="inline-block px-1">
<VegaIcon size={20} name={VegaIconNames.COPY} />
</button>
</CopyWithTooltip>
<Tooltip description={t('OpenInConsole')} align="center">
<button
className="inline-block px-1"
onClick={() => {
const marketPageLink = consoleLink(
CONSOLE_MARKET_PAGE.replace(':marketId', change.marketId)
);
window.open(marketPageLink, '_blank');
}}
>
<VegaIcon size={20} name={VegaIconNames.OPEN_EXTERNAL} />
</button>
</Tooltip>
</span>
</span>
</>
);
break;
@@ -48,6 +48,7 @@ const vegaWalletConfig: VegaWalletConfig = {
chromeExtensionUrl: 'chrome',
mozillaExtensionUrl: 'mozilla',
},
chainId: 'VEGA_CHAIN_ID',
};
const renderComponent = (proposal: ProposalQuery['proposal']) => {
@@ -18,6 +18,7 @@ import { ProposalMinRequirements, ProposalUserAction } from '../shared';
import { VoteTransactionDialog } from './vote-transaction-dialog';
import { useVoteButtonsQuery } from './__generated__/Stake';
import type { DialogProps, VegaTxState } from '@vegaprotocol/proposals';
import { filterAcceptableGraphqlErrors } from '../../../../lib/party';
interface VoteButtonsContainerProps {
voteState: VoteState | null;
@@ -42,8 +43,10 @@ export const VoteButtonsContainer = (props: VoteButtonsContainerProps) => {
skip: !pubKey,
});
const filteredErrors = filterAcceptableGraphqlErrors(error);
return (
<AsyncRenderer loading={loading} error={error} data={data}>
<AsyncRenderer loading={loading} error={filteredErrors} data={data}>
<VoteButtons
{...props}
currentStakeAvailable={toBigNum(
@@ -10,6 +10,7 @@ import { EpochIndividualRewardsTable } from './epoch-individual-rewards-table';
import { generateEpochIndividualRewardsList } from './generate-epoch-individual-rewards-list';
import { calculateEpochOffset } from '../../../lib/epoch-pagination';
import { useNetworkParam } from '@vegaprotocol/network-parameters';
import { filterAcceptableGraphqlErrors } from '../../../lib/party';
const EPOCHS_PAGE_SIZE = 10;
@@ -99,17 +100,24 @@ export const EpochIndividualRewards = ({
prevEpochIdRef.current = epochId;
}, [epochId, refetchData]);
// Workarounds for the error handling of AsyncRenderer
const filteredErrors = filterAcceptableGraphqlErrors(error);
const filteredData = data || [];
return (
<AsyncRenderer
loading={loading}
error={error}
data={data}
error={filteredErrors}
data={filteredData}
render={() => (
<div>
<p data-testid="connected-vega-key" className="mb-10">
{t('Connected Vega key')}:{' '}
<span className="text-white">{pubKey}</span>
</p>
{epochIndividualRewardSummaries.length === 0 && (
<p>{t('No rewards for key')}</p>
)}
{epochIndividualRewardSummaries.map(
(epochIndividualRewardSummary) => (
<EpochIndividualRewardsTable
@@ -118,17 +126,19 @@ export const EpochIndividualRewards = ({
/>
)
)}
<Pagination
isLoading={loading}
hasPrevPage={page > 1}
hasNextPage={page < totalPages}
onBack={() => refetchData(page - 1)}
onNext={() => refetchData(page + 1)}
onFirst={() => refetchData(1)}
onLast={() => refetchData(totalPages)}
>
{t('Page')} {page}
</Pagination>
{epochIndividualRewardSummaries.length > 0 && (
<Pagination
isLoading={loading}
hasPrevPage={page > 1}
hasNextPage={page < totalPages}
onBack={() => refetchData(page - 1)}
onNext={() => refetchData(page + 1)}
onFirst={() => refetchData(1)}
onLast={() => refetchData(totalPages)}
>
{t('Page')} {page}
</Pagination>
)}
</div>
)}
/>
@@ -16,7 +16,7 @@ import type { ValidatorsView } from './validator-tables';
const nodeFactory = (
overrides?: PartialDeep<NodesFragmentFragment>
): NodesFragmentFragment => {
const defaultNode = {
const defaultNode: NodesFragmentFragment = {
id: 'ccc022b7e63a4d0a6d3a193c3940c88574060e58a184964c994998d86835a1b4',
name: 'high',
avatarUrl: 'https://upload.wikimedia.org/wikipedia/en/2/25/Marvin-TV-3.jpg',
@@ -288,7 +288,7 @@ describe('Consensus validators table', () => {
expect(
grid.querySelector('[role="gridcell"][col-id="totalPenalties"]')
).toHaveTextContent('10.07%');
).toHaveTextContent('13.16%');
expect(
grid.querySelector('[role="gridcell"][col-id="normalisedVotingPower"]')
@@ -185,6 +185,15 @@ export const ConsensusValidatorsTable = ({
const { rawValidatorScore: previousEpochValidatorScore } =
getLastEpochScoreAndPerformance(previousEpochData, id);
const overstakingPenalty = calculateOverallPenalty(
id,
allNodesInPreviousEpoch
);
const totalPenalty = calculateOverstakedPenalty(
id,
allNodesInPreviousEpoch
);
return {
id,
[ValidatorFields.RANKING_INDEX]: stakedTotalRanking,
@@ -213,11 +222,11 @@ export const ConsensusValidatorsTable = ({
2
),
[ValidatorFields.OVERSTAKING_PENALTY]: formatNumberPercentage(
calculateOverstakedPenalty(id, allNodesInPreviousEpoch),
overstakingPenalty,
2
),
[ValidatorFields.TOTAL_PENALTIES]: formatNumberPercentage(
calculateOverallPenalty(id, allNodesInPreviousEpoch),
totalPenalty,
2
),
[ValidatorFields.PENDING_STAKE]: pendingStake,
@@ -124,6 +124,15 @@ export const StandbyPendingValidatorsTable = ({
}
}
const overstakingPenalty = calculateOverallPenalty(
id,
allNodesInPreviousEpoch
);
const totalPenalty = calculateOverstakedPenalty(
id,
allNodesInPreviousEpoch
);
return {
id,
[ValidatorFields.RANKING_INDEX]: stakedTotalRanking,
@@ -154,11 +163,11 @@ export const StandbyPendingValidatorsTable = ({
2
),
[ValidatorFields.OVERSTAKING_PENALTY]: formatNumberPercentage(
calculateOverstakedPenalty(id, allNodesInPreviousEpoch),
overstakingPenalty,
2
),
[ValidatorFields.TOTAL_PENALTIES]: formatNumberPercentage(
calculateOverallPenalty(id, allNodesInPreviousEpoch),
totalPenalty,
2
),
[ValidatorFields.PENDING_STAKE]: pendingStake,
@@ -266,7 +266,9 @@ export const ValidatorTable = ({
<Tooltip description={t('OverstakedPenaltyDescription')}>
<span data-testid="overstaking-penalty">
{formatNumberPercentage(penalties.overstaked, 2)}
{penalties.overstaked
? formatNumberPercentage(penalties.overstaked, 2)
: '-'}
</span>
</Tooltip>
</KeyValueTableRow>
@@ -285,7 +287,9 @@ export const ValidatorTable = ({
</span>
<span data-testid="total-penalties">
<strong>
{formatNumberPercentage(penalties.overall, 2)}
{penalties.overall
? formatNumberPercentage(penalties.overall, 2)
: '-'}
</strong>
</span>
</KeyValueTableRow>
+106 -45
View File
@@ -3,11 +3,11 @@ import {
getLastEpochScoreAndPerformance,
getNormalisedVotingPower,
getUnnormalisedVotingPower,
getOverstakingPenalty,
getFormattedPerformanceScore,
getPerformancePenalty,
getTotalPenalties,
getStakePercentage,
calculateOverallPenalty,
calculateOverstakedPenalty,
} from './shared';
import * as Schema from '@vegaprotocol/types';
@@ -106,38 +106,6 @@ describe('getUnnormalisedVotingPower', () => {
});
});
describe('getOverstakingPenalty', () => {
it('returns "0%" when both arguments are null or undefined', () => {
expect(getOverstakingPenalty(null, null)).toBe('0%');
expect(getOverstakingPenalty(undefined, undefined)).toBe('0%');
expect(getOverstakingPenalty(null, undefined)).toBe('0%');
expect(getOverstakingPenalty(undefined, null)).toBe('0%');
});
it('returns "0%" when one argument is null or undefined', () => {
expect(getOverstakingPenalty('10', null)).toBe('0%');
expect(getOverstakingPenalty(null, '20')).toBe('0%');
expect(getOverstakingPenalty('10', undefined)).toBe('0%');
expect(getOverstakingPenalty(undefined, '20')).toBe('0%');
});
it('returns "0%" when validatorScore or stakeScore is zero', () => {
expect(getOverstakingPenalty('0', '20')).toBe('0%');
expect(getOverstakingPenalty('10', '0')).toBe('0%');
});
it('returns the correct overstaking penalty', () => {
expect(getOverstakingPenalty('0.18', '0.2')).toBe('10.00%');
expect(getOverstakingPenalty('0.2', '0.2')).toBe('0.00%');
expect(getOverstakingPenalty('0.04', '0.2')).toBe('80.00%');
});
it('handles string numbers with decimals', () => {
expect(getOverstakingPenalty('7.5', '15')).toBe('50.00%');
expect(getOverstakingPenalty('12.5', '25')).toBe('50.00%');
});
});
describe('getFormattedPerformanceScore', () => {
it('should return the formatted performance score', () => {
expect(getFormattedPerformanceScore('0.25')).toEqual(new BigNumber(0.25));
@@ -152,17 +120,6 @@ describe('getPerformancePenalty', () => {
});
});
describe('getTotalPenalties', () => {
it('should return the total penalties', () => {
expect(getTotalPenalties('0.25', '1', '5000', '10000')).toEqual('50.00%');
expect(getTotalPenalties('0.25', '0.5', '5000', '10000')).toEqual('75.00%');
});
it('should return 0 if the total penalties is negative', () => {
expect(getTotalPenalties('0.25', '0.5', '1000', '10000')).toEqual('0.00%');
});
});
describe('getStakePercentage', () => {
it('should return the stake percentage', () => {
expect(
@@ -182,3 +139,107 @@ describe('getStakePercentage', () => {
);
});
});
describe('calculateOverallPenalty', () => {
it('returns null if rewardScore is null', () => {
const res = calculateOverallPenalty('1', [
{
id: '1',
rewardScore: null,
stakedTotal: '',
rankingScore: {
stakeScore: '0.25',
performanceScore: '0.75',
status: Schema.ValidatorStatus.VALIDATOR_NODE_STATUS_TENDERMINT,
previousStatus:
Schema.ValidatorStatus.VALIDATOR_NODE_STATUS_TENDERMINT,
rankingScore: '',
votingPower: '',
},
},
]);
expect(res).toBeNull();
});
it('returns null if rewardScore.rawValidatorScore is null (should not happen)', () => {
const res = calculateOverallPenalty('1', [
{
id: '1',
stakedTotal: '',
rewardScore: {
rawValidatorScore: '0.25',
performanceScore: '0.75',
multisigScore: '',
validatorScore: null as unknown as string,
normalisedScore: '',
validatorStatus:
Schema.ValidatorStatus.VALIDATOR_NODE_STATUS_TENDERMINT,
},
rankingScore: {
stakeScore: '0.25',
performanceScore: '0.75',
status: Schema.ValidatorStatus.VALIDATOR_NODE_STATUS_TENDERMINT,
previousStatus:
Schema.ValidatorStatus.VALIDATOR_NODE_STATUS_TENDERMINT,
rankingScore: '',
votingPower: '',
},
},
]);
expect(res).toBeNull();
});
});
describe('calculateOverstakedPenalty', () => {
it('returns null if rewardScore is null', () => {
const res = calculateOverstakedPenalty('1', [
{
id: '1',
rewardScore: null,
stakedTotal: '',
rankingScore: {
stakeScore: '0.25',
performanceScore: '0.75',
status: Schema.ValidatorStatus.VALIDATOR_NODE_STATUS_TENDERMINT,
previousStatus:
Schema.ValidatorStatus.VALIDATOR_NODE_STATUS_TENDERMINT,
rankingScore: '',
votingPower: '',
},
},
]);
expect(res).toBeNull();
});
it('returns null if rewardScore.rawValidatorScore is null (should not happen)', () => {
const res = calculateOverstakedPenalty('1', [
{
id: '1',
stakedTotal: '',
rewardScore: {
rawValidatorScore: null as unknown as string,
performanceScore: '0.75',
multisigScore: '',
validatorScore: '',
normalisedScore: '',
validatorStatus:
Schema.ValidatorStatus.VALIDATOR_NODE_STATUS_TENDERMINT,
},
rankingScore: {
stakeScore: '0.25',
performanceScore: '0.75',
status: Schema.ValidatorStatus.VALIDATOR_NODE_STATUS_TENDERMINT,
previousStatus:
Schema.ValidatorStatus.VALIDATOR_NODE_STATUS_TENDERMINT,
rankingScore: '',
votingPower: '',
},
},
]);
expect(res).toBeNull();
});
});
+35 -61
View File
@@ -5,6 +5,7 @@ import {
import type { PreviousEpochQuery } from './__generated__/PreviousEpoch';
import { BigNumber } from '../../lib/bignumber';
import type { LastArrayElement } from 'type-fest';
import isNull from 'lodash/isNull';
type Node = NonNullable<
LastArrayElement<
@@ -21,7 +22,10 @@ type Node = NonNullable<
* @returns Theoretical stake score for given node based on the staked total
* of all node of the same type (status)
*/
const calculateTheoreticalStakeScore = (nodeId: string, nodes: Node[]) => {
const calculateTheoreticalStakeScore = (
nodeId: string,
nodes: Node[]
): BigNumber | null => {
const node = nodes.find((n) => n.id === nodeId);
if (!node) {
return new BigNumber(0);
@@ -42,14 +46,25 @@ const calculateTheoreticalStakeScore = (nodeId: string, nodes: Node[]) => {
* @param nodes A collection of all nodes - needed to calculate theoretical stake score
* @returns %
*/
export const calculateOverallPenalty = (nodeId: string, nodes: Node[]) => {
export const calculateOverallPenalty = (
nodeId: string,
nodes: Node[]
): BigNumber | null => {
const node = nodes.find((n) => n.id === nodeId);
const tts = calculateTheoreticalStakeScore(nodeId, nodes);
if (!node || tts.isZero()) {
if (
!node ||
isNull(tts) ||
!node.rewardScore ||
(node.rewardScore && isNull(node.rewardScore.validatorScore))
) {
return null;
}
if (tts.isZero()) {
return new BigNumber(0);
}
const penalty = new BigNumber(1)
.minus(new BigNumber(node.rewardScore?.validatorScore || 0).dividedBy(tts))
.minus(new BigNumber(node.rewardScore.validatorScore).dividedBy(tts))
.times(100);
return penalty.isLessThan(0) ? new BigNumber(0) : penalty;
};
@@ -60,10 +75,21 @@ export const calculateOverallPenalty = (nodeId: string, nodes: Node[]) => {
* @param nodes A collection of all nodes - needed to calculate theoretical stake score
* @returns %
*/
export const calculateOverstakedPenalty = (nodeId: string, nodes: Node[]) => {
export const calculateOverstakedPenalty = (
nodeId: string,
nodes: Node[]
): BigNumber | null => {
const node = nodes.find((n) => n.id === nodeId);
const tts = calculateTheoreticalStakeScore(nodeId, nodes);
if (!node || tts.isZero()) {
if (
!node ||
isNull(tts) ||
isNull(node.rewardScore) ||
(node.rewardScore && node.rewardScore.rawValidatorScore === null)
) {
return null;
}
if (tts.isZero()) {
return new BigNumber(0);
}
const penalty = new BigNumber(1)
@@ -78,7 +104,9 @@ export const calculateOverstakedPenalty = (nodeId: string, nodes: Node[]) => {
* Calculates performance penalty based on the given performance score.
* @returns %
*/
export const calculatesPerformancePenalty = (performanceScore: string) => {
export const calculatesPerformancePenalty = (
performanceScore: string
): BigNumber => {
const penalty = new BigNumber(1)
.minus(new BigNumber(performanceScore))
.times(100);
@@ -123,60 +151,6 @@ export const getPerformancePenalty = (performanceScore?: string) =>
2
);
export const getOverstakingPenalty = (
validatorScore: string | null | undefined,
stakeScore: string | null | undefined
) => {
if (!validatorScore || !stakeScore) {
return '0%';
}
// avoid division by zero
if (
new BigNumber(validatorScore).isZero() ||
new BigNumber(stakeScore).isZero()
) {
return '0%';
}
return formatNumberPercentage(
BigNumber.max(
new BigNumber(1)
.minus(
new BigNumber(validatorScore).dividedBy(new BigNumber(stakeScore))
)
.times(100),
new BigNumber(0)
),
2
);
};
export const getTotalPenalties = (
rawValidatorScore: string | null | undefined,
performanceScore: string | undefined,
stakedOnNode: string,
totalStake: string
) => {
const calc =
rawValidatorScore &&
performanceScore &&
new BigNumber(totalStake).isGreaterThan(0)
? new BigNumber(1).minus(
new BigNumber(performanceScore)
.times(new BigNumber(rawValidatorScore))
.dividedBy(
new BigNumber(stakedOnNode).dividedBy(new BigNumber(totalStake))
)
)
: new BigNumber(0);
return formatNumberPercentage(
calc.isPositive() ? calc.times(100) : new BigNumber(0),
2
);
};
export const getStakePercentage = (total: BigNumber, stakedOnNode: BigNumber) =>
total.isEqualTo(0) || stakedOnNode.isEqualTo(0)
? '0%'
+37 -1
View File
@@ -1,14 +1,50 @@
import { ToastsContainer, useToasts } from '@vegaprotocol/ui-toolkit';
import {
Intent,
ToastsContainer,
TradingButton,
VegaIcon,
VegaIconNames,
useToasts,
} from '@vegaprotocol/ui-toolkit';
import { useVegaWalletDialogStore } from '@vegaprotocol/wallet';
import {
useEthereumTransactionToasts,
useEthereumWithdrawApprovalsToasts,
useVegaTransactionToasts,
useWalletDisconnectToastActions,
useWalletDisconnectedToasts,
} from '@vegaprotocol/web3';
import { useTranslation } from 'react-i18next';
const WalletDisconnectAdditionalContent = () => {
const { t } = useTranslation();
const { hideToast } = useWalletDisconnectToastActions();
const openVegaWalletDialog = useVegaWalletDialogStore(
(store) => store.openVegaWalletDialog
);
return (
<p className="mt-2">
<TradingButton
data-testid="connect-vega-wallet"
onClick={() => {
hideToast();
openVegaWalletDialog();
}}
size="small"
intent={Intent.Danger}
icon={<VegaIcon name={VegaIconNames.ARROW_RIGHT} size={14} />}
>
<span className="whitespace-nowrap uppercase">{t('Connect')}</span>
</TradingButton>
</p>
);
};
export const ToastsManager = () => {
useVegaTransactionToasts();
useEthereumTransactionToasts();
useEthereumWithdrawApprovalsToasts();
useWalletDisconnectedToasts(<WalletDisconnectAdditionalContent />);
const toasts = useToasts((store) => store.toasts);
return <ToastsContainer order="desc" toasts={toasts} />;
+1
View File
@@ -21,6 +21,7 @@ NX_WALLETCONNECT_PROJECT_ID=fe8091dc35738863e509fc4947525c72
# Cosmic elevator flags
NX_SUCCESSOR_MARKETS=true
NX_STOP_ORDERS=true
NX_ISOLATED_MARGIN=true
NX_ICEBERG_ORDERS=true
NX_METAMASK_SNAPS=true
NX_REFERRALS=true
+1
View File
@@ -21,6 +21,7 @@ NX_ETH_WALLET_MNEMONIC="ozone access unlock valid olympic save include omit supp
# Cosmic elevator flags
NX_SUCCESSOR_MARKETS=false
NX_STOP_ORDERS=false
NX_ISOLATED_MARGIN=true
# NX_ICEBERG_ORDERS
# NX_PRODUCT_PERPETUALS
NX_METAMASK_SNAPS=false
+1
View File
@@ -20,6 +20,7 @@ NX_ORACLE_PROOFS_URL=https://raw.githubusercontent.com/vegaprotocol/well-known/m
# Cosmic elevator flags
NX_SUCCESSOR_MARKETS=true
NX_STOP_ORDERS=true
NX_ISOLATED_MARGIN=true
# NX_ICEBERG_ORDERS
# NX_PRODUCT_PERPETUALS
NX_METAMASK_SNAPS=true
+1
View File
@@ -21,6 +21,7 @@ NX_WALLETCONNECT_PROJECT_ID=fe8091dc35738863e509fc4947525c72
# Cosmic elevator flags
NX_SUCCESSOR_MARKETS=true
NX_STOP_ORDERS=true
NX_ISOLATED_MARGIN=false
NX_ICEBERG_ORDERS=true
NX_METAMASK_SNAPS=true
NX_REFERRALS=true
+1
View File
@@ -21,6 +21,7 @@ NX_APP_VERSION=v0.20.19-core-0.71.6
# Cosmic elevator flags
NX_SUCCESSOR_MARKETS=true
NX_STOP_ORDERS=true
NX_ISOLATED_MARGIN=false
NX_ICEBERG_ORDERS=true
# NX_PRODUCT_PERPETUALS
NX_METAMASK_SNAPS=false
+1
View File
@@ -21,6 +21,7 @@ NX_WALLETCONNECT_PROJECT_ID=fe8091dc35738863e509fc4947525c72
# Cosmic elevator flags
NX_SUCCESSOR_MARKETS=true
NX_STOP_ORDERS=true
NX_ISOLATED_MARGIN=true
NX_ICEBERG_ORDERS=true
# NX_PRODUCT_PERPETUALS
NX_METAMASK_SNAPS=true
+1
View File
@@ -22,6 +22,7 @@ NX_WALLETCONNECT_PROJECT_ID=fe8091dc35738863e509fc4947525c72
# Cosmic elevator flags
NX_SUCCESSOR_MARKETS=true
NX_STOP_ORDERS=true
NX_ISOLATED_MARGIN=true
NX_ICEBERG_ORDERS=true
NX_METAMASK_SNAPS=true
NX_REFERRALS=true
+1
View File
@@ -22,6 +22,7 @@ NX_ORACLE_PROOFS_URL=https://raw.githubusercontent.com/vegaprotocol/well-known/m
# Cosmic elevator flags
NX_SUCCESSOR_MARKETS=true
NX_STOP_ORDERS=true
NX_ISOLATED_MARGIN=true
NX_ICEBERG_ORDERS=true
# NX_PRODUCT_PERPETUALS
NX_METAMASK_SNAPS=false
@@ -13,6 +13,7 @@ import type { ReactNode } from 'react';
import { Web3Provider } from './web3-provider';
import { useT } from '../../lib/use-t';
import { DataLoader } from './data-loader';
import { useChainId } from '@vegaprotocol/wallet';
export const Bootstrapper = ({ children }: { children: ReactNode }) => {
const t = useT();
@@ -26,13 +27,16 @@ export const Bootstrapper = ({ children }: { children: ReactNode }) => {
CHROME_EXTENSION_URL,
} = useEnvironment();
const chainId = useChainId(VEGA_URL);
if (
!VEGA_URL ||
!VEGA_WALLET_URL ||
!VEGA_EXPLORER_URL ||
!CHROME_EXTENSION_URL ||
!MOZILLA_EXTENSION_URL ||
!DocsLinks
!DocsLinks ||
!chainId
) {
return <AppLoader />;
}
@@ -72,6 +76,7 @@ export const Bootstrapper = ({ children }: { children: ReactNode }) => {
config={{
network: VEGA_ENV,
vegaUrl: VEGA_URL,
chainId,
vegaWalletServiceUrl: VEGA_WALLET_URL,
links: {
explorer: VEGA_EXPLORER_URL,
@@ -25,12 +25,12 @@ export const ChartContainer = ({ marketId }: { marketId: string }) => {
overlays,
studies,
studySizes,
tradingViewStudies,
setInterval,
setStudies,
setStudySizes,
setOverlays,
setTradingViewStudies,
state,
setState,
} = useChartSettings();
const pennantChart = (
@@ -64,13 +64,13 @@ export const ChartContainer = ({ marketId }: { marketId: string }) => {
libraryHash={CHARTING_LIBRARY_HASH}
marketId={marketId}
interval={toTradingViewResolution(interval)}
studies={tradingViewStudies}
onIntervalChange={(newInterval) => {
setInterval(fromTradingViewResolution(newInterval));
}}
onAutoSaveNeeded={(data: { studies: string[] }) => {
setTradingViewStudies(data.studies);
onAutoSaveNeeded={(data) => {
setState(data);
}}
state={state}
/>
);
}
@@ -27,10 +27,10 @@ describe('ChartMenu', () => {
render(<ChartMenu />);
await userEvent.click(screen.getByRole('button', { name: 'TradingView' }));
await userEvent.click(screen.getByTestId('chartlib-toggle-button'));
expect(useChartSettingsStore.getState().chartlib).toEqual('tradingview');
await userEvent.click(screen.getByRole('button', { name: 'Vega chart' }));
await userEvent.click(screen.getByTestId('chartlib-toggle-button'));
expect(useChartSettingsStore.getState().chartlib).toEqual('pennant');
});
@@ -68,6 +68,7 @@ export const ChartMenu = () => {
setChartlib(isPennant ? 'tradingview' : 'pennant');
}}
size="extra-small"
testId="chartlib-toggle-button"
>
{isPennant ? 'TradingView' : t('Vega chart')}
</TradingButton>
@@ -9,6 +9,7 @@ type StudySizes = { [S in Study]?: number };
export type Chartlib = 'pennant' | 'tradingview';
interface StoredSettings {
state: object | undefined; // Don't see a better type provided from TradingView type definitions
chartlib: Chartlib;
// For interval we use the enum from @vegaprotocol/types, this is to make mapping between different
// chart types easier and more consistent
@@ -17,7 +18,6 @@ interface StoredSettings {
overlays: Overlay[];
studies: Study[];
studySizes: StudySizes;
tradingViewStudies: string[];
}
export const STUDY_SIZE = 90;
@@ -30,13 +30,13 @@ const STUDY_ORDER: Study[] = [
];
export const DEFAULT_CHART_SETTINGS = {
state: undefined,
chartlib: 'pennant' as const,
interval: Interval.INTERVAL_I15M,
type: ChartType.CANDLE,
overlays: [Overlay.MOVING_AVERAGE],
studies: [Study.MACD, Study.VOLUME],
studySizes: {},
tradingViewStudies: ['Volume'],
};
export const useChartSettingsStore = create<
@@ -47,7 +47,7 @@ export const useChartSettingsStore = create<
setStudies: (studies?: Study[]) => void;
setStudySizes: (sizes: number[]) => void;
setChartlib: (lib: Chartlib) => void;
setTradingViewStudies: (studies: string[]) => void;
setState: (state: object) => void;
}
>()(
persist(
@@ -95,10 +95,8 @@ export const useChartSettingsStore = create<
state.chartlib = lib;
});
},
setTradingViewStudies: (studies: string[]) => {
set((state) => {
state.tradingViewStudies = studies;
});
setState: (state) => {
set({ state });
},
})),
{
@@ -147,13 +145,13 @@ export const useChartSettings = () => {
overlays,
studies,
studySizes,
tradingViewStudies: settings.tradingViewStudies,
setInterval: settings.setInterval,
setType: settings.setType,
setStudies: settings.setStudies,
setOverlays: settings.setOverlays,
setStudySizes: settings.setStudySizes,
setChartlib: settings.setChartlib,
setTradingViewStudies: settings.setTradingViewStudies,
state: settings.state,
setState: settings.setState,
};
};
+10 -1
View File
@@ -162,6 +162,8 @@ const NavbarMenu = ({ onClick }: { onClick: () => void }) => {
const envNameMapping = useEnvNameMapping();
const { VEGA_ENV, VEGA_NETWORKS, GITHUB_FEEDBACK_URL } = useEnvironment();
const marketId = useGlobalStore((store) => store.marketId);
const GOVERNANCE_LINK = useLinks(DApp.Governance)();
const EXPLORER_LINK = useLinks(DApp.Explorer)();
return (
<div className="gap-3 lg:flex lg:h-full">
@@ -220,7 +222,7 @@ const NavbarMenu = ({ onClick }: { onClick: () => void }) => {
</NavbarLink>
</NavbarItem>
<NavbarItem>
<NavbarLinkExternal to={useLinks(DApp.Governance)()}>
<NavbarLinkExternal to={GOVERNANCE_LINK}>
{t('Governance')}
</NavbarLinkExternal>
</NavbarItem>
@@ -228,6 +230,13 @@ const NavbarMenu = ({ onClick }: { onClick: () => void }) => {
<NavbarTrigger>{t('Resources')}</NavbarTrigger>
<NavbarContent data-testid="navbar-content-resources">
<ul className="lg:p-4">
{EXPLORER_LINK && (
<NavbarSubItem>
<NavbarLinkExternal to={EXPLORER_LINK}>
{t('Explorer')}
</NavbarLinkExternal>
</NavbarSubItem>
)}
{DocsLinks?.NEW_TO_VEGA && (
<NavbarSubItem>
<NavbarLinkExternal to={DocsLinks?.NEW_TO_VEGA}>
@@ -98,6 +98,7 @@ describe('ActiveRewards', () => {
transferNode={mockTransferNode}
currentEpoch={1}
kind={mockRecurringTransfer}
allMarkets={{}}
/>
);
@@ -1,48 +1,46 @@
import {
useActiveRewardsQuery,
useMarketForRewardsQuery,
} from './__generated__/Rewards';
import { useActiveRewardsQuery } from './__generated__/Rewards';
import { useT } from '../../lib/use-t';
import { addDecimalsFormatNumber } from '@vegaprotocol/utils';
import classNames from 'classnames';
import {
Icon,
type IconName,
type VegaIconSize,
Icon,
Intent,
Tooltip,
VegaIcon,
VegaIconNames,
type VegaIconSize,
TradingInput,
TinyScroll,
} from '@vegaprotocol/ui-toolkit';
import { IconNames } from '@blueprintjs/icons';
import {
type Maybe,
type Transfer,
type TransferNode,
type RecurringTransfer,
DistributionStrategyDescriptionMapping,
DistributionStrategyMapping,
EntityScope,
EntityScopeMapping,
type Maybe,
type Transfer,
type TransferNode,
TransferStatus,
TransferStatusMapping,
DispatchMetric,
DispatchMetricDescription,
DispatchMetricLabels,
type RecurringTransfer,
EntityScopeLabelMapping,
MarketState,
} from '@vegaprotocol/types';
import { Card } from '../card/card';
import { useMemo, useState } from 'react';
import {
type AssetFieldsFragment,
useAssetDataProvider,
useAssetsMapProvider,
} from '@vegaprotocol/assets';
import {
type MarketFieldsFragment,
useMarketsMapProvider,
getAsset,
} from '@vegaprotocol/markets';
export type Filter = {
@@ -74,7 +72,7 @@ export const isActiveReward = (node: TransferNode, currentEpoch: number) => {
export const applyFilter = (
node: TransferNode & {
asset?: AssetFieldsFragment | null;
marketIds?: (MarketFieldsFragment | null)[];
markets?: (MarketFieldsFragment | null)[];
},
filter: Filter
) => {
@@ -85,6 +83,7 @@ export const applyFilter = (
) {
return false;
}
if (
DispatchMetricLabels[transfer.kind.dispatchStrategy.dispatchMetric]
.toLowerCase()
@@ -98,7 +97,7 @@ export const applyFilter = (
node.asset?.name
.toLocaleLowerCase()
.includes(filter.searchTerm.toLowerCase()) ||
node.marketIds?.some((m) =>
node.markets?.some((m) =>
m?.tradableInstrument?.instrument?.name
.toLocaleLowerCase()
.includes(filter.searchTerm.toLowerCase())
@@ -124,7 +123,7 @@ export const ActiveRewards = ({ currentEpoch }: { currentEpoch: number }) => {
const { data: assets } = useAssetsMapProvider();
const { data: markets } = useMarketsMapProvider();
const transfers = activeRewardsData?.transfersConnection?.edges
const enrichedTransfers = activeRewardsData?.transfersConnection?.edges
?.map((e) => e?.node as TransferNode)
.filter((node) => isActiveReward(node, currentEpoch))
.map((node) => {
@@ -138,19 +137,19 @@ export const ActiveRewards = ({ currentEpoch }: { currentEpoch: number }) => {
node.transfer.kind.dispatchStrategy?.dispatchMetricAssetId || ''
];
const marketIds =
const marketsInScope =
node.transfer.kind.dispatchStrategy?.marketIdsInScope?.map(
(id) => markets && markets[id]
);
return { ...node, asset, marketIds };
return { ...node, asset, markets: marketsInScope };
});
if (!transfers || !transfers.length) return null;
if (!enrichedTransfers || !enrichedTransfers.length) return null;
return (
<Card title={t('Active rewards')} className="lg:col-span-full">
{transfers.length > 1 && (
{enrichedTransfers.length > 1 && (
<TradingInput
onChange={(e) =>
setFilter((curr) => ({ ...curr, searchTerm: e.target.value }))
@@ -166,7 +165,7 @@ export const ActiveRewards = ({ currentEpoch }: { currentEpoch: number }) => {
/>
)}
<TinyScroll className="grid gap-x-8 gap-y-10 h-fit grid-cols-[repeat(auto-fill,_minmax(230px,_1fr))] md:grid-cols-[repeat(auto-fill,_minmax(230px,_1fr))] lg:grid-cols-[repeat(auto-fill,_minmax(320px,_1fr))] xl:grid-cols-[repeat(auto-fill,_minmax(335px,_1fr))] max-h-[40rem] overflow-auto pr-2">
{transfers
{enrichedTransfers
.filter((n) => applyFilter(n, filter))
.map((node, i) => {
const { transfer } = node;
@@ -184,6 +183,7 @@ export const ActiveRewards = ({ currentEpoch }: { currentEpoch: number }) => {
transferNode={node}
kind={transfer.kind}
currentEpoch={currentEpoch}
allMarkets={markets || {}}
/>
)
);
@@ -207,14 +207,8 @@ const StatusIndicator = ({
switch (status) {
case TransferStatus.STATUS_DONE:
return { icon: IconNames.TICK_CIRCLE, intent: Intent.Success };
case TransferStatus.STATUS_CANCELLED:
return { icon: IconNames.MOON, intent: Intent.None };
case TransferStatus.STATUS_PENDING:
return { icon: IconNames.HELP, intent: Intent.Primary };
case TransferStatus.STATUS_REJECTED:
return { icon: IconNames.ERROR, intent: Intent.Danger };
case TransferStatus.STATUS_STOPPED:
return { icon: IconNames.ERROR, intent: Intent.Danger };
default:
return { icon: IconNames.HELP, intent: Intent.Primary };
}
@@ -253,49 +247,117 @@ export const ActiveRewardCard = ({
transferNode,
currentEpoch,
kind,
allMarkets,
}: {
transferNode: TransferNode;
transferNode: TransferNode & {
asset?: AssetFieldsFragment | null;
markets?: (MarketFieldsFragment | null)[];
};
currentEpoch: number;
kind: RecurringTransfer;
allMarkets?: Record<string, MarketFieldsFragment | null>;
}) => {
const t = useT();
const { transfer } = transferNode;
const { dispatchStrategy } = kind;
const marketIds = dispatchStrategy?.marketIdsInScope;
const { data: marketNameData } = useMarketForRewardsQuery({
variables: {
marketId: marketIds ? marketIds[0] : '',
},
});
const marketIdsInScope = dispatchStrategy?.marketIdsInScope;
const firstMarketData = transferNode.markets?.[0];
const marketName = useMemo(() => {
if (marketNameData && marketIds && marketIds.length > 1) {
return 'Specific markets';
} else if (
marketNameData &&
marketIds &&
marketNameData &&
marketIds.length === 1
const specificMarkets = useMemo(() => {
if (
!firstMarketData ||
!marketIdsInScope ||
marketIdsInScope.length === 0
) {
return marketNameData?.market?.tradableInstrument?.instrument?.name || '';
return null;
}
return '';
}, [marketIds, marketNameData]);
if (marketIdsInScope.length > 1) {
const marketNames =
allMarkets &&
marketIdsInScope
.map((id) => allMarkets[id]?.tradableInstrument?.instrument?.name)
.join(', ');
const { data: dispatchAsset } = useAssetDataProvider(
dispatchStrategy?.dispatchMetricAssetId || ''
);
return (
<Tooltip description={marketNames}>
<span>Specific markets</span>
</Tooltip>
);
}
return (
<span>{firstMarketData?.tradableInstrument?.instrument?.name || ''}</span>
);
}, [firstMarketData, marketIdsInScope, allMarkets]);
const dispatchAsset = transferNode.asset;
if (!dispatchStrategy) {
return null;
}
const { gradientClassName, mainClassName } = getGradientClasses(
dispatchStrategy.dispatchMetric
// Gray out/hide the cards that are related to not trading markets
const marketSettled = transferNode.markets?.some(
(m) =>
m?.state &&
[
MarketState.STATE_TRADING_TERMINATED,
MarketState.STATE_SETTLED,
MarketState.STATE_CANCELLED,
MarketState.STATE_CLOSED,
].includes(m.state)
);
const assetInSettledMarket =
allMarkets &&
Object.values(allMarkets).some((m: MarketFieldsFragment | null) => {
if (m && getAsset(m).id === dispatchStrategy.dispatchMetricAssetId) {
return (
m?.state &&
[
MarketState.STATE_TRADING_TERMINATED,
MarketState.STATE_SETTLED,
MarketState.STATE_CANCELLED,
MarketState.STATE_CLOSED,
].includes(m.state)
);
}
return false;
});
if (marketSettled) {
return null;
}
// Gray out the cards that are related to suspended markets
const suspended = transferNode.markets?.some(
(m) =>
m?.state === MarketState.STATE_SUSPENDED ||
m?.state === MarketState.STATE_SUSPENDED_VIA_GOVERNANCE
);
const assetInSuspendedMarket =
allMarkets &&
Object.values(allMarkets).some((m: MarketFieldsFragment | null) => {
if (m && getAsset(m).id === dispatchStrategy.dispatchMetricAssetId) {
return (
m?.state === MarketState.STATE_SUSPENDED ||
m?.state === MarketState.STATE_SUSPENDED_VIA_GOVERNANCE
);
}
return false;
});
// Gray out the cards that are related to suspended markets
const { gradientClassName, mainClassName } =
suspended || assetInSuspendedMarket || assetInSettledMarket
? {
gradientClassName: 'from-vega-cdark-500 to-vega-clight-400',
mainClassName: 'from-vega-cdark-400 dark:from-vega-cdark-600 to-20%',
}
: getGradientClasses(dispatchStrategy.dispatchMetric);
const entityScope = dispatchStrategy.entityScope;
return (
<div>
@@ -373,8 +435,20 @@ export const ActiveRewardCard = ({
<span className="border-[0.5px] border-gray-700" />
<span>
{DispatchMetricLabels[dispatchStrategy.dispatchMetric]}
{marketName ? `${marketName}` : `${dispatchAsset?.name}`}
{DispatchMetricLabels[dispatchStrategy.dispatchMetric]} {' '}
<Tooltip
underline={suspended}
description={
(suspended || assetInSuspendedMarket) &&
(specificMarkets
? t('Eligible market(s) currently suspended')
: assetInSuspendedMarket
? t('Currently no markets eligible for reward')
: '')
}
>
<span>{specificMarkets || dispatchAsset?.name}</span>
</Tooltip>
</span>
<div className="flex items-center gap-8 flex-wrap">
+2 -2
View File
@@ -1,4 +1,4 @@
# This file is automatically @generated by Poetry 1.6.1 and should not be changed by hand.
# This file is automatically @generated by Poetry 1.7.1 and should not be changed by hand.
[[package]]
name = "certifi"
@@ -1161,7 +1161,7 @@ profile = ["pytest-profiling", "snakeviz"]
type = "git"
url = "https://github.com/vegaprotocol/vega-market-sim.git/"
reference = "HEAD"
resolved_reference = "2aed8c94b25d8fa2e376d3b63ca1f9193d28cdfd"
resolved_reference = "4440abbb6ce0d3e80beba5cd01f20cd21983cbf8"
[[package]]
name = "websocket-client"
@@ -0,0 +1,53 @@
import pytest
from playwright.sync_api import Page, expect
from vega_sim.null_service import VegaServiceNull
from actions.vega import submit_order
from actions.utils import next_epoch, wait_for_toast_confirmation
tooltip_content = "tooltip-content"
leverage_input = "#leverage-input"
tab_positions = "tab-positions"
margin_row = '[col-id="margin"]'
def create_position(vega: VegaServiceNull, market_id):
submit_order(vega, "Key 1", market_id, "SIDE_SELL", 100, 110)
submit_order(vega, "Key 1", market_id, "SIDE_BUY", 100, 110)
vega.wait_fn(1)
vega.wait_for_total_catchup
@pytest.mark.usefixtures("auth", "risk_accepted")
def test_switch_cross_isolated_margin(
continuous_market, vega: VegaServiceNull, page: Page):
create_position(vega, continuous_market)
page.goto(f"/#/markets/{continuous_market}")
expect(page.locator(margin_row).nth(1)).to_have_text("874.21992Cross1.0x")
# tbd - tooltip is not visible without this wait
page.wait_for_timeout(1000)
page.get_by_test_id(tab_positions).get_by_text("Cross").hover()
expect(page.get_by_test_id(tooltip_content).nth(0)).to_have_text(
"Liquidation: 582.81328Margin: 874.21992General account: 998,084.95183"
)
page.get_by_role("button", name="Isolated 10x").click()
page.locator(leverage_input).clear()
page.locator(leverage_input).type("1")
page.get_by_role("button", name="Confirm").click()
wait_for_toast_confirmation(page)
next_epoch(vega=vega)
expect(page.get_by_test_id("toast-content")).to_have_text(
"ConfirmedYour transaction has been confirmedView in block explorerUpdate margin modeBTC:DAI_2023Isolated margin mode, leverage: 1.0x")
expect(page.locator(margin_row).nth(1)
).to_have_text("11,109.99996Isolated1.0x")
# tbd - tooltip is not visible without this wait
page.wait_for_timeout(1000)
page.get_by_test_id(tab_positions).get_by_text("Isolated").hover()
expect(page.get_by_test_id(tooltip_content).nth(0)).to_have_text(
"Liquidation: 583.62409Margin: 11,109.99996Order: 11,000.00"
)
page.get_by_role("button", name="Cross").click()
page.get_by_role("button", name="Confirm").click()
wait_for_toast_confirmation(page)
next_epoch(vega=vega)
expect(page.locator(margin_row).nth(1)).to_have_text(
"22,109.99996Cross1.0x")
@@ -29,13 +29,15 @@ def verify_data_grid(page: Page, data_test_id, expected_pattern):
logger.info(f"Matched: {expected} == {actual}")
else:
logger.info(f"Not Matched: {expected} != {actual}")
raise AssertionError(f"Pattern does not match: {expected} != {actual}")
raise AssertionError(
f"Pattern does not match: {expected} != {actual}")
else: # it's not a regex, so we escape it
if re.search(re.escape(expected), actual):
logger.info(f"Matched: {expected} == {actual}")
else:
logger.info(f"Not Matched: {expected} != {actual}")
raise AssertionError(f"Pattern does not match: {expected} != {actual}")
raise AssertionError(
f"Pattern does not match: {expected} != {actual}")
def submit_order(vega: VegaServiceNull, wallet_name, market_id, side, volume, price):
@@ -91,7 +93,7 @@ def test_limit_order_trade_open_position(continuous_market, page: Page):
"average_entry_price": "107.50",
"mark_price": "107.50",
"margin": "8.50269",
"leverage": "1.0x",
"leverage": "Cross1.0x",
"liquidation": "0.00",
"realised_pnl": "0.00",
"unrealised_pnl": "0.00",
@@ -104,7 +106,8 @@ def test_limit_order_trade_open_position(continuous_market, page: Page):
# 7004-POSI-002
size_and_notional = table.locator("[col-id='openVolume']")
expect(size_and_notional.get_by_test_id(primary_id)).to_have_text(position["size"])
expect(size_and_notional.get_by_test_id(
primary_id)).to_have_text(position["size"])
expect(size_and_notional.get_by_test_id(secondary_id)).to_have_text(
position["notional"]
)
@@ -28,8 +28,9 @@ def test_usage_breakdown(continuous_market, page: Page):
usage_breakdown = page.get_by_test_id("usage-breakdown")
# Verify headers
headers = ["Market", "Account type", "Balance", "Margin health"]
ag_headers = usage_breakdown.locator(".ag-header-cell-text").element_handles()
headers = ["Market", "Account type", "Balance"]
ag_headers = usage_breakdown.locator(
".ag-header-cell-text").element_handles()
for i, header_element in enumerate(ag_headers):
header_text = header_element.text_content()
assert header_text == headers[i]
@@ -38,30 +39,10 @@ def test_usage_breakdown(continuous_market, page: Page):
expect(usage_breakdown.locator('[class="mb-2 text-sm"]')).to_have_text(
"You have 1,000,000.00 tDAI in total."
)
expect(usage_breakdown.locator(COL_ID_USED).first).to_have_text("8.50269 (0%)")
expect(usage_breakdown.locator(
COL_ID_USED).first).to_have_text("8.50269 (0%)")
expect(usage_breakdown.locator(COL_ID_USED).nth(1)).to_have_text(
"999,991.49731 (99%)"
)
# Maintenance Level
expect(
usage_breakdown.locator(
".ag-center-cols-container [col-id='market.id'] .ag-cell-value"
).first
).to_have_text("2.85556 above maintenance level")
# Margin health tooltip
usage_breakdown.get_by_test_id("margin-health-chart-track").hover()
tooltip_data = [
("maintenance level", "5.64713"),
("search level", "6.21184"),
("initial level", "8.47069"),
("balance", "8.50269"),
("release level", "9.60012"),
]
for index, (label, value) in enumerate(tooltip_data):
expect(page.get_by_test_id(TOOLTIP_LABEL).nth(index)).to_have_text(label)
expect(page.get_by_test_id(TOOLTIP_VALUE).nth(index)).to_have_text(value)
page.get_by_test_id("dialog-close").click()
@@ -0,0 +1,67 @@
import pytest
import vega_sim.proto.vega as vega_protos
from playwright.sync_api import Page, expect
from vega_sim.null_service import VegaServiceNull
from actions.utils import next_epoch
from wallet_config import MM_WALLET, PARTY_A, PARTY_B
from vega_sim.service import MarketStateUpdateType
import vega_sim.api.governance as governance
@pytest.mark.usefixtures("risk_accepted", "auth")
def test_filtered_cards(continuous_market, vega: VegaServiceNull, page: Page):
tDAI_asset_id = vega.find_asset_id(symbol="tDAI")
vega.update_network_parameter(
MM_WALLET.name, parameter="reward.asset", new_value=tDAI_asset_id
)
vega.mint(key_name=PARTY_B.name, asset=tDAI_asset_id, amount=100000)
vega.mint(key_name=PARTY_A.name, asset=tDAI_asset_id, amount=100000)
next_epoch(vega=vega)
vega.recurring_transfer(
from_key_name=PARTY_A.name,
from_account_type=vega_protos.vega.ACCOUNT_TYPE_GENERAL,
to_account_type=vega_protos.vega.ACCOUNT_TYPE_REWARD_MAKER_PAID_FEES,
asset=tDAI_asset_id,
reference="reward",
markets=[continuous_market],
asset_for_metric=tDAI_asset_id,
metric=vega_protos.vega.DISPATCH_METRIC_MAKER_FEES_PAID,
lock_period=5,
amount=100,
factor=1.0,
)
vega.submit_order(
trading_key=PARTY_B.name,
market_id=continuous_market,
order_type="TYPE_MARKET",
time_in_force="TIME_IN_FORCE_IOC",
side="SIDE_BUY",
volume=1,
)
vega.submit_order(
trading_key=PARTY_A.name,
market_id=continuous_market,
order_type="TYPE_MARKET",
time_in_force="TIME_IN_FORCE_IOC",
side="SIDE_BUY",
volume=1,
)
next_epoch(vega=vega)
vega.update_market_state(
market_id=continuous_market,
proposal_key=MM_WALLET.name,
market_state=MarketStateUpdateType.Suspend,
forward_time_to_enactment=True,
)
next_epoch(vega=vega)
page.goto("/#/rewards")
expect(page.locator(".from-vega-cdark-400")).to_be_visible()
governance.submit_oracle_data(
wallet=vega.wallet,
payload={"trading.terminated": "true"},
key_name="FJMKnwfZdd48C8NqvYrG",
)
next_epoch(vega=vega)
page.reload()
expect(page.locator(".from-vega-cdark-400")).not_to_be_in_viewport()
@@ -1,62 +0,0 @@
import {
Intent,
useToasts,
ToastHeading,
CLOSE_AFTER,
} from '@vegaprotocol/ui-toolkit';
import { useVegaWallet } from '@vegaprotocol/wallet';
import { useEffect, useMemo } from 'react';
import { useT } from '../use-t';
import { VegaWalletConnectButton } from '../../components/vega-wallet-connect-button';
const WALLET_DISCONNECTED_TOAST_ID = 'WALLET_DISCONNECTED_TOAST_ID';
export const useWalletDisconnectedToasts = () => {
const t = useT();
const [hasToast, setToast, updateToast] = useToasts((state) => [
state.hasToast,
state.setToast,
state.update,
]);
const { isAlive } = useVegaWallet();
const toast = useMemo(
() => ({
id: WALLET_DISCONNECTED_TOAST_ID,
intent: Intent.Danger,
content: (
<>
<ToastHeading>{t('Wallet connection lost')}</ToastHeading>
<p>{t('The connection to the Vega wallet has been lost.')}</p>
<p className="mt-2">
<VegaWalletConnectButton
intent={Intent.Danger}
onClick={() => {
updateToast(WALLET_DISCONNECTED_TOAST_ID, {
hidden: true,
});
}}
/>
</p>
</>
),
onClose: () => {
updateToast(WALLET_DISCONNECTED_TOAST_ID, {
hidden: true,
});
},
closeAfter: CLOSE_AFTER,
}),
[t, updateToast]
);
useEffect(() => {
if (isAlive === false) {
if (hasToast(WALLET_DISCONNECTED_TOAST_ID)) {
updateToast(WALLET_DISCONNECTED_TOAST_ID, { hidden: false });
} else {
setToast(toast);
}
}
}, [hasToast, isAlive, setToast, t, toast, updateToast]);
};
+22 -3
View File
@@ -1,4 +1,4 @@
import { ToastsContainer, useToasts } from '@vegaprotocol/ui-toolkit';
import { Intent, ToastsContainer, useToasts } from '@vegaprotocol/ui-toolkit';
import { useProposalToasts } from '@vegaprotocol/proposals';
import { useVegaTransactionToasts } from '@vegaprotocol/web3';
import { useEthereumTransactionToasts } from '@vegaprotocol/web3';
@@ -6,7 +6,26 @@ import { useEthereumWithdrawApprovalsToasts } from '@vegaprotocol/web3';
import { useReadyToWithdrawalToasts } from '@vegaprotocol/withdraws';
import { Links } from '../lib/links';
import { useReferralToasts } from '../client-pages/referrals/hooks/use-referral-toasts';
import { useWalletDisconnectedToasts } from '../lib/hooks/use-wallet-disconnected-toasts';
import {
useWalletDisconnectToastActions,
useWalletDisconnectedToasts,
} from '@vegaprotocol/web3';
import { VegaWalletConnectButton } from '../components/vega-wallet-connect-button';
const WalletDisconnectAdditionalContent = () => {
const { hideToast } = useWalletDisconnectToastActions();
return (
<p className="mt-2">
<VegaWalletConnectButton
intent={Intent.Danger}
onClick={() => {
// hide toast when clicked on `Connect`
hideToast();
}}
/>
</p>
);
};
export const ToastsManager = () => {
useProposalToasts();
@@ -17,7 +36,7 @@ export const ToastsManager = () => {
withdrawalsLink: Links.PORTFOLIO(),
});
useReferralToasts();
useWalletDisconnectedToasts();
useWalletDisconnectedToasts(<WalletDisconnectAdditionalContent />);
const toasts = useToasts((store) => store.toasts);
return <ToastsContainer order="desc" toasts={toasts} />;
+6
View File
@@ -3,6 +3,9 @@ fragment MarginFields on MarginLevels {
searchLevel
initialLevel
collateralReleaseLevel
marginFactor
marginMode
orderMarginLevel
asset {
id
}
@@ -33,6 +36,9 @@ subscription MarginsSubscription($partyId: ID!) {
searchLevel
initialLevel
collateralReleaseLevel
marginFactor
marginMode
orderMarginLevel
timestamp
}
}
+9 -3
View File
@@ -3,21 +3,21 @@ import * as Types from '@vegaprotocol/types';
import { gql } from '@apollo/client';
import * as Apollo from '@apollo/client';
const defaultOptions = {} as const;
export type MarginFieldsFragment = { __typename?: 'MarginLevels', maintenanceLevel: string, searchLevel: string, initialLevel: string, collateralReleaseLevel: string, asset: { __typename?: 'Asset', id: string }, market: { __typename?: 'Market', id: string } };
export type MarginFieldsFragment = { __typename?: 'MarginLevels', maintenanceLevel: string, searchLevel: string, initialLevel: string, collateralReleaseLevel: string, marginFactor: string, marginMode: Types.MarginMode, orderMarginLevel: string, asset: { __typename?: 'Asset', id: string }, market: { __typename?: 'Market', id: string } };
export type MarginsQueryVariables = Types.Exact<{
partyId: Types.Scalars['ID'];
}>;
export type MarginsQuery = { __typename?: 'Query', party?: { __typename?: 'Party', id: string, marginsConnection?: { __typename?: 'MarginConnection', edges?: Array<{ __typename?: 'MarginEdge', node: { __typename?: 'MarginLevels', maintenanceLevel: string, searchLevel: string, initialLevel: string, collateralReleaseLevel: string, asset: { __typename?: 'Asset', id: string }, market: { __typename?: 'Market', id: string } } }> | null } | null } | null };
export type MarginsQuery = { __typename?: 'Query', party?: { __typename?: 'Party', id: string, marginsConnection?: { __typename?: 'MarginConnection', edges?: Array<{ __typename?: 'MarginEdge', node: { __typename?: 'MarginLevels', maintenanceLevel: string, searchLevel: string, initialLevel: string, collateralReleaseLevel: string, marginFactor: string, marginMode: Types.MarginMode, orderMarginLevel: string, asset: { __typename?: 'Asset', id: string }, market: { __typename?: 'Market', id: string } } }> | null } | null } | null };
export type MarginsSubscriptionSubscriptionVariables = Types.Exact<{
partyId: Types.Scalars['ID'];
}>;
export type MarginsSubscriptionSubscription = { __typename?: 'Subscription', margins: { __typename?: 'MarginLevelsUpdate', marketId: string, asset: string, partyId: string, maintenanceLevel: string, searchLevel: string, initialLevel: string, collateralReleaseLevel: string, timestamp: any } };
export type MarginsSubscriptionSubscription = { __typename?: 'Subscription', margins: { __typename?: 'MarginLevelsUpdate', marketId: string, asset: string, partyId: string, maintenanceLevel: string, searchLevel: string, initialLevel: string, collateralReleaseLevel: string, marginFactor: string, marginMode: Types.MarginMode, orderMarginLevel: string, timestamp: any } };
export const MarginFieldsFragmentDoc = gql`
fragment MarginFields on MarginLevels {
@@ -25,6 +25,9 @@ export const MarginFieldsFragmentDoc = gql`
searchLevel
initialLevel
collateralReleaseLevel
marginFactor
marginMode
orderMarginLevel
asset {
id
}
@@ -85,6 +88,9 @@ export const MarginsSubscriptionDocument = gql`
searchLevel
initialLevel
collateralReleaseLevel
marginFactor
marginMode
orderMarginLevel
timestamp
}
}
@@ -105,6 +105,7 @@ export interface AccountFields extends Account {
// The total balance of these accounts will be used for the 'used' column in the
// collateral table
const USE_ACCOUNT_TYPES = [
AccountType.ACCOUNT_TYPE_ORDER_MARGIN,
AccountType.ACCOUNT_TYPE_MARGIN,
AccountType.ACCOUNT_TYPE_BOND,
AccountType.ACCOUNT_TYPE_FEES_INFRASTRUCTURE,
+2 -28
View File
@@ -4,14 +4,6 @@ import * as Types from '@vegaprotocol/types';
import type { AccountFields } from './accounts-data-provider';
import { getAccountData } from './accounts-data-provider';
const marginHealthChartTestId = 'margin-health-chart';
jest.mock('./margin-health-chart', () => ({
MarginHealthChart: () => {
return <div data-testid={marginHealthChartTestId}></div>;
},
}));
const singleRow = {
__typename: 'AccountBalance',
type: Types.AccountType.ACCOUNT_TYPE_MARGIN,
@@ -49,10 +41,10 @@ describe('BreakdownTable', () => {
render(<BreakdownTable data={singleRowData} />);
});
const headers = await screen.findAllByRole('columnheader');
expect(headers).toHaveLength(4);
expect(headers).toHaveLength(3);
expect(
headers.map((h) => h.querySelector('[ref="eText"]')?.textContent?.trim())
).toEqual(['Market', 'Account type', 'Balance', 'Margin health']);
).toEqual(['Market', 'Account type', 'Balance']);
});
it('should apply correct formatting', async () => {
@@ -70,24 +62,6 @@ describe('BreakdownTable', () => {
cells.slice(0, -1).forEach((cell, i) => {
expect(cell).toHaveTextContent(expectedValues[i]);
});
expect(screen.getByTestId(marginHealthChartTestId)).toBeInTheDocument();
});
it('displays margin health chart only for margin account', async () => {
await act(async () => {
render(
<BreakdownTable
data={[
{
...singleRow,
type: Types.AccountType.ACCOUNT_TYPE_GENERAL,
market: null,
},
]}
/>
);
});
expect(screen.queryByTestId(marginHealthChartTestId)).toBeNull();
});
it('should get correct account data', () => {
+2 -20
View File
@@ -16,14 +16,13 @@ import { ProgressBarCell } from '@vegaprotocol/datagrid';
import { AgGrid, PriceCell } from '@vegaprotocol/datagrid';
import type { ColDef } from 'ag-grid-community';
import { accountValuesComparator } from './accounts-table';
import { MarginHealthChart } from './margin-health-chart';
import { MarketNameCell } from '@vegaprotocol/datagrid';
import { AccountType } from '@vegaprotocol/types';
const defaultColDef = {
resizable: true,
sortable: true,
minWidth: 100,
flex: 1,
};
interface BreakdownTableProps extends AgGridReactProps {
@@ -39,7 +38,7 @@ const BreakdownTable = forwardRef<AgGridReact, BreakdownTableProps>(
{
headerName: t('Market'),
field: 'market.tradableInstrument.instrument.code',
width: 90,
maxWidth: 150,
pinned: true,
sort: 'desc',
cellRenderer: ({
@@ -111,23 +110,6 @@ const BreakdownTable = forwardRef<AgGridReact, BreakdownTableProps>(
},
comparator: accountValuesComparator,
},
{
headerName: t('Margin health'),
field: 'market.id',
maxWidth: 500,
sortable: false,
cellRenderer: ({
data,
}: VegaICellRendererParams<AccountFields, 'market.id'>) =>
data?.market?.id &&
data.type === AccountType['ACCOUNT_TYPE_MARGIN'] &&
data?.asset.id ? (
<MarginHealthChart
marketId={data.market.id}
assetId={data.asset.id}
/>
) : null,
},
];
return defs;
}, [t]);
+11 -8
View File
@@ -20,14 +20,20 @@ const update = (
return produce(data || [], (draft) => {
const { marketId } = delta;
const index = draft.findIndex((node) => node.market.id === marketId);
const deltaData = {
maintenanceLevel: delta.maintenanceLevel,
searchLevel: delta.searchLevel,
initialLevel: delta.initialLevel,
collateralReleaseLevel: delta.collateralReleaseLevel,
marginFactor: delta.marginFactor,
marginMode: delta.marginMode,
orderMarginLevel: delta.orderMarginLevel,
};
if (index !== -1) {
const currNode = draft[index];
draft[index] = {
...currNode,
maintenanceLevel: delta.maintenanceLevel,
searchLevel: delta.searchLevel,
initialLevel: delta.initialLevel,
collateralReleaseLevel: delta.collateralReleaseLevel,
...deltaData,
};
} else {
draft.unshift({
@@ -36,10 +42,7 @@ const update = (
__typename: 'Market',
id: delta.marketId,
},
maintenanceLevel: delta.maintenanceLevel,
searchLevel: delta.searchLevel,
initialLevel: delta.initialLevel,
collateralReleaseLevel: delta.collateralReleaseLevel,
...deltaData,
asset: {
__typename: 'Asset',
id: delta.asset,
@@ -5,6 +5,7 @@ import {
import { act, render, screen } from '@testing-library/react';
import type { MarginFieldsFragment } from './__generated__/Margins';
import type { AssetFieldsFragment } from '@vegaprotocol/assets';
import { MarginMode } from '@vegaprotocol/types';
const asset: AssetFieldsFragment = {
id: 'assetId',
@@ -18,6 +19,9 @@ const margins: MarginFieldsFragment = {
initialLevel: '800',
searchLevel: '600',
maintenanceLevel: '400',
marginFactor: '',
marginMode: MarginMode.MARGIN_MODE_CROSS_MARGIN,
orderMarginLevel: '',
market: {
id: 'marketId',
},
@@ -13,6 +13,7 @@ import { AsyncRendererInline } from '@vegaprotocol/ui-toolkit';
import { DealTicket } from './deal-ticket';
import { useFeatureFlags } from '@vegaprotocol/environment';
import { useT } from '../../use-t';
import { MarginModeSelector } from './margin-mode-selector';
interface DealTicketContainerProps {
marketId: string;
@@ -51,21 +52,31 @@ export const DealTicketContainer = ({
reload={reload}
>
{market && marketData ? (
featureFlags.STOP_ORDERS && showStopOrder ? (
<StopOrder
market={market}
marketPrice={marketPrice}
submit={(stopOrdersSubmission) => create({ stopOrdersSubmission })}
/>
) : (
<DealTicket
{...props}
market={market}
marketPrice={marketPrice}
marketData={marketData}
submit={(orderSubmission) => create({ orderSubmission })}
/>
)
<>
{featureFlags.ISOLATED_MARGIN && (
<>
<MarginModeSelector marketId={marketId} />
<hr className="border-vega-clight-500 dark:border-vega-cdark-500 mb-4" />
</>
)}
{featureFlags.STOP_ORDERS && showStopOrder ? (
<StopOrder
market={market}
marketPrice={marketPrice}
submit={(stopOrdersSubmission) =>
create({ stopOrdersSubmission })
}
/>
) : (
<DealTicket
{...props}
market={market}
marketPrice={marketPrice}
marketData={marketData}
submit={(orderSubmission) => create({ orderSubmission })}
/>
)}
</>
) : (
<p>{t('Could not load market')}</p>
)}
@@ -0,0 +1,246 @@
import { useDataProvider } from '@vegaprotocol/data-provider';
import {
TradingButton as Button,
TradingInput as Input,
FormGroup,
LeverageSlider,
} from '@vegaprotocol/ui-toolkit';
import { MarginMode, useVegaWallet } from '@vegaprotocol/wallet';
import * as Types from '@vegaprotocol/types';
import {
type VegaTransactionStore,
useVegaTransactionStore,
} from '@vegaprotocol/web3';
import { Dialog } from '@vegaprotocol/ui-toolkit';
import { useEffect, useState } from 'react';
import { useT } from '../../use-t';
import classnames from 'classnames';
import { marketMarginDataProvider } from '@vegaprotocol/accounts';
import { useMaxLeverage } from '@vegaprotocol/positions';
const defaultLeverage = 10;
interface MarginDialogProps {
open: boolean;
onClose: () => void;
marketId: string;
partyId: string;
create: VegaTransactionStore['create'];
}
const CrossMarginModeDialog = ({
open,
onClose,
marketId,
create,
}: MarginDialogProps) => {
const t = useT();
return (
<Dialog
title={t('Cross margin')}
size="small"
open={open}
onChange={(isOpen) => {
if (!isOpen) {
onClose();
}
}}
>
<div className="text-sm mb-4">
<p className="mb-1">
{t('You are setting this market to cross-margin mode.')}
</p>
<p className="mb-1">
{t(
'Your max leverage on each position will be determined by the risk model of the market.'
)}
</p>
<p>
{t(
'All available funds in your general account will be used to finance your margin if the market moves against you.'
)}
</p>
</div>
<Button
className="w-full"
onClick={() => {
create({
updateMarginMode: {
market_id: marketId,
mode: MarginMode.MARGIN_MODE_CROSS_MARGIN,
},
});
onClose();
}}
>
{t('Confirm')}
</Button>
</Dialog>
);
};
const IsolatedMarginModeDialog = ({
open,
onClose,
marketId,
partyId,
marginFactor,
create,
}: MarginDialogProps & { marginFactor: string }) => {
const [leverage, setLeverage] = useState(
Number((1 / Number(marginFactor)).toFixed(1))
);
const { data: maxLeverage } = useMaxLeverage(marketId, partyId);
const max = Math.floor((maxLeverage || 1) * 10) / 10;
useEffect(() => {
setLeverage(Number((1 / Number(marginFactor)).toFixed(1)));
}, [marginFactor]);
useEffect(() => {
if (maxLeverage && leverage > max) {
setLeverage(max);
}
}, [max, maxLeverage, leverage]);
const t = useT();
return (
<Dialog
title={t('Isolated margin')}
size="small"
open={open}
onChange={(isOpen) => {
if (!isOpen) {
onClose();
}
}}
>
<div className="text-sm mb-4">
<p className="mb-1">
{t('You are setting this market to isolated margin mode.')}
</p>
<p className="mb-1">
{t(
'Set the leverage you want below. The maximum leverage you can take is determined by the risk model of the market.'
)}
</p>
<p className="mb-1">
{t(
'Only your allocated margin will be used to fund this position, and if the maintenance margin is breached you will be closed out.'
)}
</p>
</div>
<form
onSubmit={() => {
create({
updateMarginMode: {
market_id: marketId,
mode: MarginMode.MARGIN_MODE_ISOLATED_MARGIN,
marginFactor: `${1 / leverage}`,
},
});
onClose();
}}
>
<FormGroup label={t('Leverage')} labelFor="leverage-input" compact>
<div className="mb-2">
<LeverageSlider
max={max}
step={0.1}
value={[leverage]}
onValueChange={([value]) => setLeverage(value)}
/>
</div>
<Input
type="number"
id="leverage-input"
min={1}
max={max}
step={0.1}
value={leverage}
onChange={(e) => setLeverage(Number(e.target.value))}
/>
</FormGroup>
<Button className="w-full" type="submit">
{t('Confirm')}
</Button>
</form>
</Dialog>
);
};
export const MarginModeSelector = ({ marketId }: { marketId: string }) => {
const t = useT();
const [dialog, setDialog] = useState<'cross' | 'isolated' | ''>();
const { pubKey: partyId, isReadOnly } = useVegaWallet();
const { data: margin } = useDataProvider({
dataProvider: marketMarginDataProvider,
variables: {
partyId: partyId || '',
marketId,
},
skip: !partyId,
});
useEffect(() => {
if (!partyId) {
setDialog('');
}
}, [partyId]);
const create = useVegaTransactionStore((state) => state.create);
const marginMode = margin?.marginMode;
const marginFactor =
margin?.marginFactor && margin?.marginFactor !== '0'
? margin?.marginFactor
: undefined;
const disabled = isReadOnly;
const onClose = () => setDialog(undefined);
const enabledModeClassName = 'bg-vega-clight-500 dark:bg-vega-cdark-500';
return (
<>
<div className="mb-4 grid h-8 leading-8 font-alpha text-xs grid-cols-2">
<button
disabled={disabled}
onClick={() => partyId && setDialog('cross')}
className={classnames('rounded', {
[enabledModeClassName]:
!marginMode ||
marginMode === Types.MarginMode.MARGIN_MODE_CROSS_MARGIN,
})}
>
{t('Cross')}
</button>
<button
disabled={disabled}
onClick={() => partyId && setDialog('isolated')}
className={classnames('rounded', {
[enabledModeClassName]:
marginMode === Types.MarginMode.MARGIN_MODE_ISOLATED_MARGIN,
})}
>
{t('Isolated {{leverage}}x', {
leverage: marginFactor
? (1 / Number(marginFactor)).toFixed(1)
: defaultLeverage,
})}
</button>
</div>
{partyId && (
<CrossMarginModeDialog
partyId={partyId}
open={dialog === 'cross'}
onClose={onClose}
marketId={marketId}
create={create}
/>
)}
{partyId && (
<IsolatedMarginModeDialog
partyId={partyId}
open={dialog === 'isolated'}
onClose={onClose}
marketId={marketId}
create={create}
marginFactor={marginFactor || `${1 / defaultLeverage}`}
/>
)}
</>
);
};
@@ -323,6 +323,9 @@ export const compileFeatureFlags = (refresh = false): FeatureFlags => {
STOP_ORDERS: TRUTHY.includes(
windowOrDefault('NX_STOP_ORDERS', process.env['NX_STOP_ORDERS']) as string
),
ISOLATED_MARGIN: TRUTHY.includes(
windowOrDefault('NX_STOP_ORDERS', process.env['NX_STOP_ORDERS']) as string
),
SUCCESSOR_MARKETS: TRUTHY.includes(
windowOrDefault(
'NX_SUCCESSOR_MARKETS',
+1
View File
@@ -131,6 +131,7 @@ export const useEtherscanLink = () => {
export const CONSOLE_TRANSFER = '#/portfolio/assets/transfer';
export const CONSOLE_TRANSFER_ASSET =
'#/portfolio/assets/transfer?assetId=:assetId';
export const CONSOLE_MARKET_PAGE = '#/markets/:marketId';
// Governance pages
export const TOKEN_NEW_MARKET_PROPOSAL = '/proposals/propose/new-market';
+1
View File
@@ -19,6 +19,7 @@ export type FeatureFlags = z.infer<typeof featureFlagsSchema>;
export type CosmicElevatorFlags = Pick<
FeatureFlags,
| 'ICEBERG_ORDERS'
| 'ISOLATED_MARGIN'
| 'STOP_ORDERS'
| 'SUCCESSOR_MARKETS'
| 'PRODUCT_PERPETUALS'
@@ -76,6 +76,7 @@ export const envSchema = z
const COSMIC_ELEVATOR_FLAGS = {
SUCCESSOR_MARKETS: z.optional(z.boolean()),
STOP_ORDERS: z.optional(z.boolean()),
ISOLATED_MARGIN: z.optional(z.boolean()),
ICEBERG_ORDERS: z.optional(z.boolean()),
PRODUCT_PERPETUALS: z.optional(z.boolean()),
METAMASK_SNAPS: z.optional(z.boolean()),
+13 -1
View File
@@ -8,13 +8,17 @@
"A release candidate for the staging environment": "A release candidate for the staging environment",
"above": "above",
"Advanced": "Advanced",
"All available funds in your general account will be used to finance your margin if the market moves against you.": "All available funds in your general account will be used to finance your margin if the market moves against you.",
"An estimate of the most you would be expected to pay in fees, in the market's settlement asset {{assetSymbol}}. Fees estimated are \"taker\" fees and will only be payable if the order trades aggressively. Rebate equal to the maker portion will be paid to the trader if the order trades passively.": "An estimate of the most you would be expected to pay in fees, in the market's settlement asset {{assetSymbol}}. Fees estimated are \"taker\" fees and will only be payable if the order trades aggressively. Rebate equal to the maker portion will be paid to the trader if the order trades passively.",
"Any orders placed now will not trade until the auction ends": "Any orders placed now will not trade until the auction ends",
"below": "below",
"Cancel": "Cancel",
"Closed": "Closed",
"Closing on {{time}}": "Closing on {{time}}",
"Confirm": "Confirm",
"Could not load market": "Could not load market",
"Cross": "Cross",
"Cross margin": "Cross margin",
"Current margin allocation": "Current margin allocation",
"Custom": "Custom",
"Deduction from collateral": "Deduction from collateral",
@@ -35,6 +39,9 @@
"Iceberg": "Iceberg",
"ICEBERG_TOOLTIP": "Trade only a fraction of the order size at once. After the peak size of the order has traded, the size is reset. This is repeated until the order is cancelled, expires, or its full volume trades away. For example, an iceberg order with a size of 1000 and a peak size of 100 will effectively be split into 10 orders with a size of 100 each. Note that the full volume of the order is not hidden and is still reflected in the order book.",
"Infrastructure fee": "Infrastructure fee",
"Isolated {{leverage}}x": "Isolated {{leverage}}x",
"Isolated margin": "Isolated margin",
"Leverage": "Leverage",
"Limit": "Limit",
"Liquidation": "Liquidation",
"LIQUIDATION_PRICE_ESTIMATE_TOOLTIP_TEXT": "This is an approximation for the liquidation price for that particular contract position, assuming nothing else changes, which may affect your margin and collateral balances.",
@@ -59,6 +66,7 @@
"OCO": "OCO",
"One cancels another": "One cancels another",
"Only limit orders are permitted when market is in auction": "Only limit orders are permitted when market is in auction",
"Only your allocated margin will be used to fund this position, and if the maintenance margin is breached you will be closed out.": "Only your allocated margin will be used to fund this position, and if the maintenance margin is breached you will be closed out.",
"Peak size": "Peak size",
"Peak size cannot be greater than the size ({{size}})": "Peak size cannot be greater than the size ({{size}})",
"Peak size cannot be lower than {{stepSize}}": "Peak size cannot be lower than {{stepSize}}",
@@ -75,6 +83,7 @@
"Public testnet run by the Vega team, often used for incentives": "Public testnet run by the Vega team, often used for incentives",
"Reduce only": "Reduce only",
"Referral discount": "Referral discount",
"Set the leverage you want below. The maximum leverage you can take is determined by the risk model of the market.": "Set the leverage you want below. The maximum leverage you can take is determined by the risk model of the market.",
"Short": "Short",
"Size": "Size",
"Size cannot be lower than {{sizeStep}}": "Size cannot be lower than {{sizeStep}}",
@@ -128,6 +137,8 @@
"VALIDATOR_TESTNET": "VALIDATOR_TESTNET",
"Volume discount": "Volume discount",
"When the order trades and its size falls below this threshold, it will be reset to the peak size and moved to the back of the priority order. Must be less than or equal to peak size, and greater than 0.": "When the order trades and its size falls below this threshold, it will be reset to the peak size and moved to the back of the priority order. Must be less than or equal to peak size, and greater than 0.",
"You are setting this market to cross-margin mode.": "You are setting this market to cross-margin mode.",
"You are setting this market to isolated margin mode.": "You are setting this market to isolated margin mode.",
"You have only {{amount}}.": "You have only {{amount}}.",
"You may not have enough margin available to open this position.": "You may not have enough margin available to open this position.",
"You need {{symbol}} in your wallet to trade in this market.": "You need {{symbol}} in your wallet to trade in this market.",
@@ -137,5 +148,6 @@
"You need to connect your own wallet to start trading on this market": "You need to connect your own wallet to start trading on this market",
"You need to provide a minimum visible size": "You need to provide a minimum visible size",
"You need to provide a peak size": "You need to provide a peak size",
"You need to provide a size": "You need to provide a size"
"You need to provide a size": "You need to provide a size",
"Your max leverage on each position will be determined by the risk model of the market.": "Your max leverage on each position will be determined by the risk model of the market."
}
+2
View File
@@ -799,6 +799,8 @@
"unsupportedVersion": "Looks like you're running an outdated version of GoWallet. You're running {{version}} but {{requiredVersion}} is required.",
"UpdateAsset": "Update asset",
"UpdateAssetProposal": "Update asset proposal",
"UpdateToMarket": "Update to market ID",
"OpenInConsole": "Open in Console",
"UpdateMarket": "Update market",
"UpdateMarketProposal": "Update market proposal",
"UpdateMarketState": "Update market state",
+6
View File
@@ -1,11 +1,17 @@
{
"Best case": "Best case",
"Cross": "Cross",
"Close position": "Close position",
"Entry / Mark": "Entry / Mark",
"General account: {{balance}}": "General account: {{balance}}",
"Isolated": "Isolated",
"Lifetime loss socialisation deductions: {{losses}}": "Lifetime loss socialisation deductions: {{losses}}",
"Liquidation: {{maintenanceLevel}}": "Liquidation: {{maintenanceLevel}}",
"Maintained by network": "Maintained by network",
"Margin / Leverage": "Margin / Leverage",
"Margin: {{balance}}": "Margin: {{balance}}",
"Market": "Market",
"Order: {{balance}}": "Order: {{balance}}",
"No positions": "No positions",
"Profit or loss is realised whenever your position is reduced to zero and the margin is released back to your collateral balance. P&L excludes any fees paid.": "Profit or loss is realised whenever your position is reduced to zero and the margin is released back to your collateral balance. P&L excludes any fees paid.",
"Read more about loss socialisation": "Read more about loss socialisation",
+1 -1
View File
@@ -53,7 +53,7 @@
"Use the Desktop App/CLI": "Use the Desktop App/CLI",
"User rejected": "User rejected",
"Vega browser extension not installed": "Vega browser extension not installed",
"Vega Wallet <0>full featured<0>": "Vega Wallet <0>full featured<0>",
"Vega Wallet <0>full featured</0>": "Vega Wallet <0>full featured</0>",
"Verifying chain": "Verifying chain",
"View as party": "View as party",
"VIEW AS VEGA USER": "VIEW AS VEGA USER",
+2
View File
@@ -43,6 +43,7 @@
"Go to your Ethereum wallet and connect to the network {{networkName}}": "Go to your Ethereum wallet and connect to the network {{networkName}}",
"If the network is reset or has an outage, records of your withdrawal may be lost. It is recommended that you save these details in a safe place so you can still complete your withdrawal.": "If the network is reset or has an outage, records of your withdrawal may be lost. It is recommended that you save these details in a safe place so you can still complete your withdrawal.",
"Invalid asset source: {{source}}": "Invalid asset source: {{source}}",
"Isolated margin mode, leverage: {{leverage}}x": "Isolated margin mode, leverage: {{leverage}}x",
"Loading": "Loading",
"MetaMask": "MetaMask",
"MetaMask, Brave or other injected web wallet": "MetaMask, Brave or other injected web wallet",
@@ -79,6 +80,7 @@
"Transfer": "Transfer",
"Transfer complete": "Transfer complete",
"Unknown": "Unknown",
"Update margin mode": "Update margin mode",
"Vega confirmation": "Vega confirmation",
"Vega is confirming your transaction...": "Vega is confirming your transaction...",
"Verifying withdrawal approval": "Verifying withdrawal approval",
@@ -174,13 +174,13 @@ const marketsData = [
describe('getMetrics && rejoinPositionData', () => {
it('returns positions metrics', () => {
const positionsRejoined = rejoinPositionData(positions, marketsData);
const metrics = getMetrics(positionsRejoined, accounts || null);
const metrics = getMetrics(positionsRejoined, accounts || null, null);
expect(metrics.length).toEqual(2);
});
it('calculates metrics', () => {
const positionsRejoined = rejoinPositionData(positions, marketsData);
const metrics = getMetrics(positionsRejoined, accounts || null);
const metrics = getMetrics(positionsRejoined, accounts || null, null);
expect(metrics[0].assetSymbol).toEqual('tDAI');
expect(metrics[0].averageEntryPrice).toEqual('8993727');
@@ -1,19 +1,26 @@
import isEqual from 'lodash/isEqual';
import produce from 'immer';
import BigNumber from 'bignumber.js';
import sortBy from 'lodash/sortBy';
import { type Account } from '@vegaprotocol/accounts';
import {
marginsDataProvider,
type Account,
type MarginFieldsFragment,
marketMarginDataProvider,
} from '@vegaprotocol/accounts';
import { accountsDataProvider } from '@vegaprotocol/accounts';
import { toBigNum, removePaginationWrapper } from '@vegaprotocol/utils';
import {
makeDataProvider,
makeDerivedDataProvider,
useDataProvider,
} from '@vegaprotocol/data-provider';
import {
type MarketMaybeWithData,
type MarketDataQueryVariables,
allMarketsWithLiveDataProvider,
getAsset,
marketInfoProvider,
type MarketInfo,
} from '@vegaprotocol/markets';
import {
PositionsDocument,
@@ -26,6 +33,7 @@ import {
} from './__generated__/Positions';
import {
AccountType,
MarginMode,
MarketState,
type MarketTradingMode,
type PositionStatus,
@@ -33,6 +41,8 @@ import {
} from '@vegaprotocol/types';
export interface Position {
marginMode: MarginFieldsFragment['marginMode'];
maintenanceLevel: MarginFieldsFragment['maintenanceLevel'] | undefined;
assetId: string;
assetSymbol: string;
averageEntryPrice: string;
@@ -41,6 +51,8 @@ export interface Position {
quantum: string;
lossSocializationAmount: string;
marginAccountBalance: string;
orderAccountBalance: string;
generalAccountBalance: string;
marketDecimalPlaces: number;
marketId: string;
marketCode: string;
@@ -61,7 +73,8 @@ export interface Position {
export const getMetrics = (
data: ReturnType<typeof rejoinPositionData> | null,
accounts: Account[] | null
accounts: Account[] | null,
margins: MarginFieldsFragment[] | null
): Position[] => {
if (!data || !data?.length) {
return [];
@@ -75,8 +88,20 @@ export const getMetrics = (
}
const marketData = market?.data;
const margin = margins?.find((margin) => {
return margin.market?.id === market?.id;
});
const marginAccount = accounts?.find((account) => {
return account.market?.id === market?.id;
return (
account.market?.id === market?.id &&
account.type === AccountType.ACCOUNT_TYPE_MARGIN
);
});
const orderAccount = accounts?.find((account) => {
return (
account.market?.id === market?.id &&
account.type === AccountType.ACCOUNT_TYPE_ORDER_MARGIN
);
});
const asset = getAsset(market);
const generalAccount = accounts?.find(
@@ -93,6 +118,10 @@ export const getMetrics = (
marginAccount?.balance ?? 0,
asset.decimals
);
const orderAccountBalance = toBigNum(
orderAccount?.balance ?? 0,
asset.decimals
);
const generalAccountBalance = toBigNum(
generalAccount?.balance ?? 0,
asset.decimals
@@ -107,21 +136,33 @@ export const getMetrics = (
: openVolume.multipliedBy(-1)
).multipliedBy(markPrice)
: undefined;
const totalBalance = marginAccountBalance.plus(generalAccountBalance);
const currentLeverage = notional
? totalBalance.isEqualTo(0)
? new BigNumber(0)
: notional.dividedBy(totalBalance)
: undefined;
const totalBalance = marginAccountBalance
.plus(generalAccountBalance)
.plus(orderAccountBalance);
const marginMode =
margin?.marginMode || MarginMode.MARGIN_MODE_CROSS_MARGIN;
const marginFactor = margin?.marginFactor;
const currentLeverage =
marginMode === MarginMode.MARGIN_MODE_ISOLATED_MARGIN
? (marginFactor && 1 / Number(marginFactor)) || undefined
: notional
? totalBalance.isEqualTo(0)
? 0
: notional.dividedBy(totalBalance).toNumber()
: undefined;
metrics.push({
marginMode,
maintenanceLevel: margin?.maintenanceLevel,
assetId: asset.id,
assetSymbol: asset.symbol,
averageEntryPrice: position.averageEntryPrice,
currentLeverage: currentLeverage ? currentLeverage.toNumber() : undefined,
currentLeverage,
assetDecimals: asset.decimals,
quantum: asset.quantum,
lossSocializationAmount: position.lossSocializationAmount || '0',
marginAccountBalance: marginAccount?.balance ?? '0',
orderAccountBalance: orderAccount?.balance ?? '0',
generalAccountBalance: generalAccount?.balance ?? '0',
marketDecimalPlaces,
marketId: market.id,
marketCode: market.tradableInstrument.instrument.code,
@@ -291,6 +332,9 @@ export const positionsMarketsProvider = makeDerivedDataProvider<
).sort();
});
const firstOrSelf = (partyIds: string | string[]) =>
Array.isArray(partyIds) ? partyIds[0] : partyIds;
export const positionsMetricsProvider = makeDerivedDataProvider<
Position[],
Position[],
@@ -301,18 +345,24 @@ export const positionsMetricsProvider = makeDerivedDataProvider<
positionsDataProvider(callback, client, { partyIds: variables.partyIds }),
(callback, client, variables) =>
accountsDataProvider(callback, client, {
partyId: Array.isArray(variables.partyIds)
? variables.partyIds[0]
: variables.partyIds,
partyId: firstOrSelf(variables.partyIds),
}),
(callback, client, variables) =>
allMarketsWithLiveDataProvider(callback, client, {
marketIds: variables.marketIds,
}),
(callback, client, variables) =>
marginsDataProvider(callback, client, {
partyId: firstOrSelf(variables.partyIds),
}),
],
([positions, accounts, marketsData], variables) => {
([positions, accounts, marketsData, margins], variables) => {
const positionsData = rejoinPositionData(positions, marketsData);
const metrics = getMetrics(positionsData, accounts as Account[] | null);
const metrics = getMetrics(
positionsData,
accounts as Account[] | null,
margins
);
return preparePositions(metrics, variables.showClosed);
},
(data, delta, previousData) =>
@@ -323,3 +373,67 @@ export const positionsMetricsProvider = makeDerivedDataProvider<
return !(previousRow && isEqual(previousRow, row));
})
);
export const maxLeverageProvider = makeDerivedDataProvider<
number,
never,
{ partyId: string; marketId: string }
>(
[
(callback, client, { marketId }) =>
marketInfoProvider(callback, client, { marketId }),
(callback, client, { marketId, partyId }) =>
positionDataProvider(callback, client, { partyIds: partyId, marketId }),
marketMarginDataProvider,
],
(parts) => {
const market: MarketInfo | null = parts[0];
const position: PositionFieldsFragment | null = parts[1];
const margin: MarginFieldsFragment | null = parts[2];
if (!market || !market?.riskFactors) {
return 1;
}
const maxLeverage =
1 /
(Math.max(
Number(market.riskFactors.long),
Number(market.riskFactors.short)
) || 1);
if (
market &&
position?.openVolume &&
position?.openVolume !== '0' &&
margin
) {
const asset = getAsset(market);
const { positionDecimalPlaces, decimalPlaces: marketDecimalPlaces } =
market;
const openVolume = toBigNum(
position.openVolume.replace(/^-/, ''),
positionDecimalPlaces
);
const averageEntryPrice = toBigNum(
position.averageEntryPrice,
marketDecimalPlaces
);
// https://github.com/vegaprotocol/specs/blob/nebula/protocol/0019-MCAL-margin_calculator.md#isolated-margin-mode
return Math.min(
averageEntryPrice
.multipliedBy(openVolume)
.dividedBy(toBigNum(margin.initialLevel, asset.decimals))
.toNumber(),
maxLeverage
);
}
return maxLeverage;
}
);
export const useMaxLeverage = (marketId: string, partyId?: string) => {
return useDataProvider({
dataProvider: maxLeverageProvider,
variables: { marketId, partyId: partyId || '' },
skip: !partyId,
});
};
+169 -5
View File
@@ -21,6 +21,7 @@ import {
VegaIcon,
VegaIconNames,
Tooltip,
Lozenge,
} from '@vegaprotocol/ui-toolkit';
import {
volumePrefix,
@@ -31,14 +32,17 @@ import {
} from '@vegaprotocol/utils';
import { type Position } from './positions-data-providers';
import {
MarginMode,
MarketTradingMode,
PositionStatus,
PositionStatusMapping,
} from '@vegaprotocol/types';
import { DocsLinks } from '@vegaprotocol/environment';
import { DocsLinks, useFeatureFlags } from '@vegaprotocol/environment';
import { PositionActionsDropdown } from './position-actions-dropdown';
import { LiquidationPrice } from './liquidation-price';
import { useT } from '../use-t';
import classnames from 'classnames';
import BigNumber from 'bignumber.js';
interface Props extends TypedDataAgGrid<Position> {
onClose?: (data: Position) => void;
@@ -74,6 +78,126 @@ const defaultColDef = {
minWidth: 110,
};
interface MarginChartProps {
width?: number;
label: string;
other?: string;
marker?: number;
markerLabel?: string;
className?: string;
}
const MarginChart = ({
width,
label,
other,
marker,
markerLabel,
className,
}: MarginChartProps) => {
return (
<div className={classnames('relative min-w-[208px]', className)}>
{markerLabel ? (
<div className="mb-1 whitespace-nowrap">{markerLabel}</div>
) : null}
<div
className={classnames('flex relative h-2', {
'dark:bg-vega-clight-800 bg-vega-cdark-800': other,
})}
>
<div
style={{ width: `${width || 100}%` }}
className="dark:bg-vega-clight-400 bg-vega-cdark-400"
></div>
{marker ? (
<div
className="absolute dark:border-t-vega-clight-400 border-t-vega-cdark-400 border-l-transparent border-r-transparent"
style={{
top: '-5px',
left: `${marker}%`,
borderWidth: '5px 5px 0px 5px',
transform: 'translateX(-5px)',
display: 'inline-block',
}}
></div>
) : null}
</div>
<div className="flex flex-wrap justify-between whitespace-nowrap">
<div className={classnames({ 'mr-1': other })}>{label}</div>
{other ? <div className="text-right">{other}</div> : null}
</div>
</div>
);
};
const PositionMargin = ({ data }: { data: Position }) => {
const t = useT();
const max =
data.marginMode === MarginMode.MARGIN_MODE_CROSS_MARGIN
? (
BigInt(data.marginAccountBalance) + BigInt(data.generalAccountBalance)
).toString()
: BigInt(data.marginAccountBalance) > BigInt(data.orderAccountBalance)
? data.marginAccountBalance
: data.orderAccountBalance;
const getWidth = (balance: string) =>
BigNumber(balance).multipliedBy(100).dividedBy(max).toNumber();
const inCrossMode = data.marginMode === MarginMode.MARGIN_MODE_CROSS_MARGIN;
const hasOrderAccountBalance =
!inCrossMode && data.orderAccountBalance !== '0';
return (
<>
<MarginChart
width={inCrossMode ? getWidth(data.marginAccountBalance) : undefined}
label={t('Margin: {{balance}}', {
balance: addDecimalsFormatNumberQuantum(
data.marginAccountBalance,
data.assetDecimals,
data.quantum
),
})}
other={
inCrossMode
? t('General account: {{balance}}', {
balance: addDecimalsFormatNumberQuantum(
data.generalAccountBalance,
data.assetDecimals,
data.quantum
),
})
: undefined
}
className={classnames({ 'mb-2': hasOrderAccountBalance })}
marker={
data.maintenanceLevel ? getWidth(data.maintenanceLevel) : undefined
}
markerLabel={
data.maintenanceLevel &&
t('Liquidation: {{maintenanceLevel}}', {
maintenanceLevel: addDecimalsFormatNumberQuantum(
data.maintenanceLevel,
data.assetDecimals,
data.quantum
),
})
}
/>
{hasOrderAccountBalance ? (
<MarginChart
width={getWidth(data.orderAccountBalance)}
label={t('Order: {{balance}}', {
balance: addDecimalsFormatNumber(
data.orderAccountBalance,
data.assetDecimals
),
})}
/>
) : null}
</>
);
};
export const PositionsTable = ({
onClose,
onMarketClick,
@@ -83,6 +207,7 @@ export const PositionsTable = ({
pubKey,
...props
}: Props) => {
const featureFlags = useFeatureFlags((state) => state.flags);
const t = useT();
const colDefs = useMemo<ColDef[]>(() => {
@@ -132,7 +257,8 @@ export const PositionsTable = ({
cellClass: 'font-mono text-right',
cellClassRules: signedNumberCssClassRules,
filter: 'agNumberColumnFilter',
valueGetter: ({ data }: { data: Position }) => {
sortable: false,
filterValueGetter: ({ data }: { data: Position }) => {
return data?.openVolume === undefined
? undefined
: toBigNum(data?.openVolume, data.positionDecimalPlaces).toNumber();
@@ -209,7 +335,8 @@ export const PositionsTable = ({
type: 'rightAligned',
cellClass: 'font-mono text-right',
filter: 'agNumberColumnFilter',
valueGetter: ({ data }: VegaValueGetterParams<Position>) => {
sortable: false,
filterValueGetter: ({ data }: VegaValueGetterParams<Position>) => {
return !data
? undefined
: toBigNum(
@@ -233,7 +360,35 @@ export const PositionsTable = ({
const lev = data?.currentLeverage ? data.currentLeverage : 1;
const leverage = formatNumber(Math.max(1, lev), 1);
return <StackedCell primary={margin} secondary={leverage + 'x'} />;
return (
<Tooltip
description={
data &&
data.marginAccountBalance !== '0' && (
<PositionMargin data={data} />
)
}
>
<div>
<StackedCell
primary={margin}
secondary={
<>
{featureFlags.ISOLATED_MARGIN && (
<Lozenge className="mr-1">
{data?.marginMode ===
MarginMode.MARGIN_MODE_ISOLATED_MARGIN
? t('Isolated')
: t('Cross')}
</Lozenge>
)}
{leverage}x
</>
}
/>
</div>
</Tooltip>
);
},
},
{
@@ -406,7 +561,16 @@ export const PositionsTable = ({
return columnDefs.filter<ColDef>(
(colDef: ColDef | null): colDef is ColDef => colDef !== null
);
}, [isReadOnly, multipleKeys, onClose, onMarketClick, pubKey, pubKeys, t]);
}, [
isReadOnly,
multipleKeys,
onClose,
onMarketClick,
pubKey,
pubKeys,
t,
featureFlags.ISOLATED_MARGIN,
]);
return (
<AgGrid
+13
View File
@@ -130,6 +130,9 @@ const marginsFields: MarginFieldsFragment[] = [
searchLevel: '0',
initialLevel: '0',
collateralReleaseLevel: '0',
marginFactor: '',
marginMode: Schema.MarginMode.MARGIN_MODE_CROSS_MARGIN,
orderMarginLevel: '',
market: {
__typename: 'Market',
id: 'market-0',
@@ -145,6 +148,9 @@ const marginsFields: MarginFieldsFragment[] = [
searchLevel: '0',
initialLevel: '0',
collateralReleaseLevel: '0',
marginFactor: '',
marginMode: Schema.MarginMode.MARGIN_MODE_CROSS_MARGIN,
orderMarginLevel: '',
market: {
__typename: 'Market',
id: 'market-1',
@@ -160,6 +166,9 @@ const marginsFields: MarginFieldsFragment[] = [
searchLevel: '0',
initialLevel: '0',
collateralReleaseLevel: '0',
marginFactor: '',
marginMode: Schema.MarginMode.MARGIN_MODE_CROSS_MARGIN,
orderMarginLevel: '',
market: {
__typename: 'Market',
id: 'market-2',
@@ -172,6 +181,10 @@ const marginsFields: MarginFieldsFragment[] = [
];
export const singleRow: Position = {
generalAccountBalance: '12345600',
maintenanceLevel: '12300000',
marginMode: Schema.MarginMode.MARGIN_MODE_CROSS_MARGIN,
orderAccountBalance: '0',
partyId: 'partyId',
assetId: 'asset-id',
assetSymbol: 'BTC',
+1 -1
View File
@@ -1,6 +1,6 @@
{
"extends": ["plugin:@nx/react", "../../.eslintrc.json"],
"ignorePatterns": ["!**/*", "__generated__"],
"ignorePatterns": ["!**/*", "__generated__", "charting-library.d.ts"],
"overrides": [
{
"files": ["*.ts", "*.tsx", "*.js", "*.jsx"],
File diff suppressed because it is too large Load Diff
@@ -9,17 +9,17 @@ export const TradingViewContainer = ({
libraryHash,
marketId,
interval,
studies,
onIntervalChange,
onAutoSaveNeeded,
state,
}: {
libraryPath: string;
libraryHash: string;
marketId: string;
interval: ResolutionString;
studies: string[];
onIntervalChange: (interval: string) => void;
onAutoSaveNeeded: OnAutoSaveNeededCallback;
state: object | undefined;
}) => {
const t = useT();
const scriptState = useScript(
@@ -48,9 +48,9 @@ export const TradingViewContainer = ({
libraryPath={libraryPath}
marketId={marketId}
interval={interval}
studies={studies}
onIntervalChange={onIntervalChange}
onAutoSaveNeeded={onAutoSaveNeeded}
state={state}
/>
);
};
+127 -87
View File
@@ -1,127 +1,167 @@
import { useEffect, useRef } from 'react';
import {
usePrevious,
useScreenDimensions,
useThemeSwitcher,
} from '@vegaprotocol/react-helpers';
import { useLanguage } from './use-t';
import { useDatafeed } from './use-datafeed';
import { type ResolutionString } from './constants';
import {
type ChartingLibraryFeatureset,
type LanguageCode,
type ChartingLibraryWidgetOptions,
type IChartingLibraryWidget,
type ChartPropertiesOverrides,
type ResolutionString as TVResolutionString,
} from '../charting-library';
export type OnAutoSaveNeededCallback = (data: { studies: string[] }) => void;
const noop = () => {};
export type OnAutoSaveNeededCallback = (data: object) => void;
export const TradingView = ({
marketId,
libraryPath,
interval,
studies,
onIntervalChange,
onAutoSaveNeeded,
state,
}: {
marketId: string;
libraryPath: string;
interval: ResolutionString;
studies: string[];
onIntervalChange: (interval: string) => void;
onAutoSaveNeeded: OnAutoSaveNeededCallback;
state: object | undefined;
}) => {
const { isMobile } = useScreenDimensions();
const { theme } = useThemeSwitcher();
const language = useLanguage();
const chartContainerRef =
useRef<HTMLDivElement>() as React.MutableRefObject<HTMLInputElement>;
// Cant get types as charting_library is externally loaded
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const widgetRef = useRef<any>();
const chartContainerRef = useRef<HTMLDivElement>(null);
const widgetRef = useRef<IChartingLibraryWidget>();
const datafeed = useDatafeed();
useEffect(
() => {
const disableOnSmallScreens = isMobile ? ['left_toolbar'] : [];
const prevMarketId = usePrevious(marketId);
const prevTheme = usePrevious(theme);
const overrides = getOverrides(theme);
const widgetOptions = {
symbol: marketId,
datafeed,
interval: interval,
container: chartContainerRef.current,
library_path: libraryPath,
custom_css_url: 'vega_styles.css',
// Trading view accepts just 'en' rather than 'en-US' which is what react-i18next provides
// https://www.tradingview.com/charting-library-docs/latest/core_concepts/Localization?_highlight=language#supported-languages
locale: language.split('-')[0],
enabled_features: ['tick_resolution'],
disabled_features: [
'header_symbol_search',
'header_compare',
'show_object_tree',
'timeframes_toolbar',
...disableOnSmallScreens,
],
fullscreen: false,
autosize: true,
theme,
overrides,
loading_screen: {
backgroundColor: overrides['paneProperties.background'],
},
};
// @ts-ignore parent component loads TradingView onto window obj
widgetRef.current = new window.TradingView.widget(widgetOptions);
widgetRef.current.onChartReady(() => {
widgetRef.current.applyOverrides(getOverrides(theme));
widgetRef.current.subscribe('onAutoSaveNeeded', () => {
const studies = widgetRef.current
.activeChart()
.getAllStudies()
.map((s: { id: string; name: string }) => s.name);
onAutoSaveNeeded({ studies });
});
const activeChart = widgetRef.current.activeChart();
// Show volume study by default, second bool arg adds it as a overlay on top of the chart
studies.forEach((study) => {
activeChart.createStudy(study);
});
// Subscribe to interval changes so it can be persisted in chart settings
activeChart.onIntervalChanged().subscribe(null, onIntervalChange);
});
return () => {
if (!widgetRef.current) return;
widgetRef.current.remove();
};
},
// No theme in deps to avoid full chart reload when the theme changes
// Instead the theme is changed programmitcally in a separate useEffect
// eslint-disable-next-line react-hooks/exhaustive-deps
[datafeed, marketId, language, libraryPath, isMobile]
);
// Update the trading view theme every time the app theme updates, doen separately
// to avoid full chart reload
useEffect(() => {
if (!widgetRef.current || !widgetRef.current._ready) return;
// Widget already created
if (widgetRef.current !== undefined) {
// Update the symbol if changed
if (marketId !== prevMarketId) {
widgetRef.current.setSymbol(
marketId,
(interval ? interval : '15') as TVResolutionString,
noop
);
}
// Calling changeTheme will reset the default dark/light background to the TV default
// so we need to re-apply the pane bg override. A promise is also required
// https://github.com/tradingview/charting_library/issues/6546#issuecomment-1139517908
widgetRef.current.changeTheme(theme).then(() => {
widgetRef.current.applyOverrides(getOverrides(theme));
// Update theme theme if changed
if (theme !== prevTheme) {
widgetRef.current.changeTheme(theme).then(() => {
if (!widgetRef.current) return;
widgetRef.current.applyOverrides(getOverrides(theme));
});
}
return;
}
if (!chartContainerRef.current) {
return;
}
// Create widget
const overrides = getOverrides(theme);
const disabledOnSmallScreens: ChartingLibraryFeatureset[] = isMobile
? ['left_toolbar']
: [];
const disabledFeatures: ChartingLibraryFeatureset[] = [
'header_symbol_search',
'header_compare',
'show_object_tree',
'timeframes_toolbar',
...disabledOnSmallScreens,
];
const widgetOptions: ChartingLibraryWidgetOptions = {
symbol: marketId,
datafeed,
interval: interval as TVResolutionString,
container: chartContainerRef.current,
library_path: libraryPath,
custom_css_url: 'vega_styles.css',
// Trading view accepts just 'en' rather than 'en-US' which is what react-i18next provides
// https://www.tradingview.com/charting-library-docs/latest/core_concepts/Localization?_highlight=language#supported-languages
locale: language.split('-')[0] as LanguageCode,
enabled_features: ['tick_resolution'],
disabled_features: disabledFeatures,
fullscreen: false,
autosize: true,
theme,
overrides,
loading_screen: {
backgroundColor: overrides['paneProperties.background'],
},
auto_save_delay: 1,
saved_data: state,
};
widgetRef.current = new window.TradingView.widget(widgetOptions);
widgetRef.current.onChartReady(() => {
if (!widgetRef.current) return;
const activeChart = widgetRef.current.activeChart();
if (!state) {
// If chart has loaded with no state, create a volume study
activeChart.createStudy('Volume');
}
// Subscribe to interval changes so it can be persisted in chart settings
activeChart.onIntervalChanged().subscribe(null, onIntervalChange);
});
}, [theme]);
widgetRef.current.subscribe('onAutoSaveNeeded', () => {
if (!widgetRef.current) return;
widgetRef.current.save((newState) => {
onAutoSaveNeeded(newState);
});
});
}, [
state,
datafeed,
interval,
prevTheme,
prevMarketId,
marketId,
theme,
language,
libraryPath,
isMobile,
onAutoSaveNeeded,
onIntervalChange,
]);
useEffect(() => {
return () => {
if (!widgetRef.current) return;
widgetRef.current.remove();
widgetRef.current = undefined;
};
}, []);
return <div ref={chartContainerRef} className="w-full h-full" />;
};
const getOverrides = (theme: 'dark' | 'light') => {
const getOverrides = (
theme: 'dark' | 'light'
): Partial<ChartPropertiesOverrides> => {
return {
// colors set here, trading view lets the user set a color
'paneProperties.background': theme === 'dark' ? '#05060C' : '#fff',
+10 -33
View File
@@ -2,15 +2,6 @@ import { useEffect, useMemo, useRef } from 'react';
import compact from 'lodash/compact';
import { useApolloClient } from '@apollo/client';
import { type Subscription } from 'zen-observable-ts';
/*
* TODO: figure out how we can get the chart types
import {
type LibrarySymbolInfo,
type IBasicDataFeed,
type ResolutionString,
type SeriesFormat,
} from '../charting_library/charting_library';
*/
import {
GetBarsDocument,
LastBarDocument,
@@ -27,6 +18,12 @@ import {
type SymbolQueryVariables,
} from './__generated__/Symbol';
import { getMarketExpiryDate, toBigNum } from '@vegaprotocol/utils';
import {
type IBasicDataFeed,
type DatafeedConfiguration,
type LibrarySymbolInfo,
type ResolutionString,
} from '../charting-library';
const EXCHANGE = 'VEGA';
@@ -42,12 +39,8 @@ const resolutionMap: Record<string, Interval> = {
const supportedResolutions = Object.keys(resolutionMap);
const configurationData = {
// only showing Vega ofc
exchanges: [EXCHANGE],
const configurationData: DatafeedConfiguration = {
// Represents the resolutions for bars supported by your datafeed
// @ts-ignore cant import types as chartin_library is external
supported_resolutions: supportedResolutions as ResolutionString[],
} as const;
@@ -57,9 +50,7 @@ export const useDatafeed = () => {
const client = useApolloClient();
const datafeed = useMemo(() => {
// @ts-ignore cant import types as chartin_library is external
const feed: IBasicDataFeed = {
// @ts-ignore cant import types as chartin_library is external
onReady: (callback) => {
setTimeout(() => callback(configurationData));
},
@@ -69,11 +60,8 @@ export const useDatafeed = () => {
},
resolveSymbol: async (
// @ts-ignore cant import types as chartin_library is external
marketId,
// @ts-ignore cant import types as chartin_library is external
onSymbolResolvedCallback,
// @ts-ignore cant import types as chartin_library is external
onResolveErrorCallback
) => {
try {
@@ -110,9 +98,8 @@ export const useDatafeed = () => {
const expirationDate = getMarketExpiryDate(instrument.metadata.tags);
const expirationTimestamp = expirationDate
? Math.floor(expirationDate.getTime() / 1000)
: null;
: undefined;
// @ts-ignore cant import types as chartin_library is external
const symbolInfo: LibrarySymbolInfo = {
ticker: market.id, // use ticker as our unique identifier so that code/name can be used for name/description
name: instrument.code,
@@ -120,10 +107,9 @@ export const useDatafeed = () => {
description: instrument.name,
listed_exchange: EXCHANGE,
expired: productType === 'Perpetual' ? false : true,
expirationDate: expirationTimestamp,
expiration_date: expirationTimestamp,
// @ts-ignore cant import types as chartin_library is external
format: 'price' as SeriesFormat,
format: 'price',
type,
session: '24x7',
timezone: 'Etc/UTC',
@@ -151,15 +137,10 @@ export const useDatafeed = () => {
},
getBars: async (
// @ts-ignore cant import types as chartin_library is external
symbolInfo,
// @ts-ignore cant import types as chartin_library is external
resolution,
// @ts-ignore cant import types as chartin_library is external
periodParams,
// @ts-ignore cant import types as chartin_library is external
onHistoryCallback,
// @ts-ignore cant import types as chartin_library is external
onErrorCallback
) => {
if (!symbolInfo.ticker) {
@@ -211,13 +192,9 @@ export const useDatafeed = () => {
},
subscribeBars: (
// @ts-ignore cant import types as chartin_library is external
symbolInfo,
// @ts-ignore cant import types as chartin_library is external
resolution,
// @ts-ignore cant import types as chartin_library is external
onTick
// subscriberUID, // chart will subscribe and unsbuscribe when the parent market of the page changes so we don't need to use subscriberUID as of now
) => {
if (!symbolInfo.ticker) {
+72
View File
@@ -113,6 +113,8 @@ export enum AccountType {
ACCOUNT_TYPE_MARGIN = 'ACCOUNT_TYPE_MARGIN',
/** Network treasury, per-asset treasury controlled by the network */
ACCOUNT_TYPE_NETWORK_TREASURY = 'ACCOUNT_TYPE_NETWORK_TREASURY',
/** Per asset market account for party in isolated margin mode */
ACCOUNT_TYPE_ORDER_MARGIN = 'ACCOUNT_TYPE_ORDER_MARGIN',
/** Holds pending rewards to be paid to the referrer of a party out of fees paid by the taker */
ACCOUNT_TYPE_PENDING_FEE_REFERRAL_REWARD = 'ACCOUNT_TYPE_PENDING_FEE_REFERRAL_REWARD',
/** PendingTransfers - a global account for the pending transfers pool */
@@ -1986,8 +1988,14 @@ export type MarginLevels = {
initialLevel: Scalars['String'];
/** Minimal margin for the position to be maintained in the network (unsigned integer) */
maintenanceLevel: Scalars['String'];
/** Margin factor, only relevant for isolated margin mode, else 0 */
marginFactor: Scalars['String'];
/** Margin mode of the party, cross margin or isolated margin */
marginMode: MarginMode;
/** Market in which the margin is required for this party */
market: Market;
/** When in isolated margin, the required order margin level, otherwise, 0 */
orderMarginLevel: Scalars['String'];
/** The party for this margin */
party: Party;
/** If the margin is between maintenance and search, the network will initiate a collateral search, expressed as unsigned integer */
@@ -2010,8 +2018,14 @@ export type MarginLevelsUpdate = {
initialLevel: Scalars['String'];
/** Minimal margin for the position to be maintained in the network (unsigned integer) */
maintenanceLevel: Scalars['String'];
/** Margin factor, only relevant for isolated margin mode, else 0 */
marginFactor: Scalars['String'];
/** Margin mode of the party, cross margin or isolated margin */
marginMode: MarginMode;
/** Market in which the margin is required for this party */
marketId: Scalars['ID'];
/** When in isolated margin, the required order margin level, otherwise, 0 */
orderMarginLevel: Scalars['String'];
/** The party for this margin */
partyId: Scalars['ID'];
/** If the margin is between maintenance and search, the network will initiate a collateral search (unsigned integer) */
@@ -2020,6 +2034,13 @@ export type MarginLevelsUpdate = {
timestamp: Scalars['Timestamp'];
};
export enum MarginMode {
/** Party is in cross margin mode */
MARGIN_MODE_CROSS_MARGIN = 'MARGIN_MODE_CROSS_MARGIN',
/** Party is in isolated margin mode */
MARGIN_MODE_ISOLATED_MARGIN = 'MARGIN_MODE_ISOLATED_MARGIN'
}
/** Represents a product & associated parameters that can be traded on Vega, has an associated OrderBook and Trade history */
export type Market = {
__typename?: 'Market';
@@ -3118,6 +3139,8 @@ export enum OrderRejectionReason {
ORDER_ERROR_INVALID_TIME_IN_FORCE = 'ORDER_ERROR_INVALID_TIME_IN_FORCE',
/** Invalid type */
ORDER_ERROR_INVALID_TYPE = 'ORDER_ERROR_INVALID_TYPE',
/** Party has insufficient funds to cover for the order margin for the new or amended order */
ORDER_ERROR_ISOLATED_MARGIN_CHECK_FAILED = 'ORDER_ERROR_ISOLATED_MARGIN_CHECK_FAILED',
/** Margin check failed - not enough available margin */
ORDER_ERROR_MARGIN_CHECK_FAILED = 'ORDER_ERROR_MARGIN_CHECK_FAILED',
/** Market is closed */
@@ -3138,6 +3161,8 @@ export enum OrderRejectionReason {
ORDER_ERROR_OFFSET_MUST_BE_GREATER_THAN_ZERO = 'ORDER_ERROR_OFFSET_MUST_BE_GREATER_THAN_ZERO',
/** Order is out of sequence */
ORDER_ERROR_OUT_OF_SEQUENCE = 'ORDER_ERROR_OUT_OF_SEQUENCE',
/** Pegged orders are not allowed for a party in isolated margin mode */
ORDER_ERROR_PEGGED_ORDERS_NOT_ALLOWED_IN_ISOLATED_MARGIN_MODE = 'ORDER_ERROR_PEGGED_ORDERS_NOT_ALLOWED_IN_ISOLATED_MARGIN_MODE',
/** A post-only order would produce an aggressive trade and thus it has been rejected */
ORDER_ERROR_POST_ONLY_ORDER_WOULD_TRADE = 'ORDER_ERROR_POST_ONLY_ORDER_WOULD_TRADE',
/** A reduce-ony order would not reduce the party's position and thus it has been rejected */
@@ -3586,6 +3611,41 @@ export type PartyLockedBalance = {
untilEpoch: Scalars['Int'];
};
/** Margin mode selected for the given party and market. */
export type PartyMarginMode = {
__typename?: 'PartyMarginMode';
/** Epoch at which the update happened. */
atEpoch: Scalars['Int'];
/** Selected margin mode. */
marginMode: MarginMode;
/** Margin factor for the market. Isolated mode only. */
margin_factor?: Maybe<Scalars['String']>;
/** Unique ID of the market. */
marketId: Scalars['ID'];
/** Maximum theoretical leverage for the market. Isolated mode only. */
max_theoretical_leverage?: Maybe<Scalars['String']>;
/** Minimum theoretical margin factor for the market. Isolated mode only. */
min_theoretical_margin_factor?: Maybe<Scalars['String']>;
/** Unique ID of the party. */
partyId: Scalars['ID'];
};
/** Edge type containing the deposit and cursor information returned by a PartyMarginModeConnection */
export type PartyMarginModeEdge = {
__typename?: 'PartyMarginModeEdge';
cursor: Scalars['String'];
node: PartyMarginMode;
};
/** Connection type for retrieving cursor-based paginated party margin modes information */
export type PartyMarginModesConnection = {
__typename?: 'PartyMarginModesConnection';
/** The party margin modes */
edges?: Maybe<Array<Maybe<PartyMarginModeEdge>>>;
/** The pagination information */
pageInfo?: Maybe<PageInfo>;
};
/**
* All staking information related to a Party.
* Contains the current recognised balance by the network and
@@ -4438,6 +4498,12 @@ export type Query = {
partiesConnection?: Maybe<PartyConnection>;
/** An entity that is trading on the Vega network */
party?: Maybe<Party>;
/**
* List margin modes per party per market
*
* Get a list of all margin modes, or for a specific market ID, or party ID.
*/
partyMarginModes?: Maybe<PartyMarginModesConnection>;
/** Fetch all positions */
positions?: Maybe<PositionConnection>;
/** A governance proposal located by either its ID or reference. If both are set, ID is used. */
@@ -6213,6 +6279,8 @@ export enum TransferType {
TRANSFER_TYPE_INFRASTRUCTURE_FEE_DISTRIBUTE = 'TRANSFER_TYPE_INFRASTRUCTURE_FEE_DISTRIBUTE',
/** Infrastructure fee paid from general account */
TRANSFER_TYPE_INFRASTRUCTURE_FEE_PAY = 'TRANSFER_TYPE_INFRASTRUCTURE_FEE_PAY',
/** Funds moved from order margin account to margin account. */
TRANSFER_TYPE_ISOLATED_MARGIN_LOW = 'TRANSFER_TYPE_ISOLATED_MARGIN_LOW',
/** Allocates liquidity fee earnings to each liquidity provider's network controlled liquidity fee account. */
TRANSFER_TYPE_LIQUIDITY_FEE_ALLOCATE = 'TRANSFER_TYPE_LIQUIDITY_FEE_ALLOCATE',
/** Liquidity fee received into general account */
@@ -6239,6 +6307,10 @@ export enum TransferType {
TRANSFER_TYPE_MTM_LOSS = 'TRANSFER_TYPE_MTM_LOSS',
/** Funds added to margin account after mark to market gain */
TRANSFER_TYPE_MTM_WIN = 'TRANSFER_TYPE_MTM_WIN',
/** Funds released from order margin account to general. */
TRANSFER_TYPE_ORDER_MARGIN_HIGH = 'TRANSFER_TYPE_ORDER_MARGIN_HIGH',
/** Funds moved from general account to order margin account. */
TRANSFER_TYPE_ORDER_MARGIN_LOW = 'TRANSFER_TYPE_ORDER_MARGIN_LOW',
/** Funds deducted from margin account after a perpetuals funding loss. */
TRANSFER_TYPE_PERPETUALS_FUNDING_LOSS = 'TRANSFER_TYPE_PERPETUALS_FUNDING_LOSS',
/** Funds added to margin account after a perpetuals funding gain. */
+16
View File
@@ -38,6 +38,7 @@ import type { ProductType, ProposalProductType } from './product';
export const AccountTypeMapping: {
[T in AccountType]: string;
} = {
ACCOUNT_TYPE_ORDER_MARGIN: 'Per asset market account',
ACCOUNT_TYPE_BOND: 'Bond account',
ACCOUNT_TYPE_EXTERNAL: 'External account',
ACCOUNT_TYPE_FEES_INFRASTRUCTURE: 'Infrastructure fees account',
@@ -211,6 +212,8 @@ export const OrderRejectionReasonMapping: {
ORDER_ERROR_INVALID_SIZE: 'Invalid size',
ORDER_ERROR_INVALID_TIME_IN_FORCE: 'Invalid time in force',
ORDER_ERROR_INVALID_TYPE: 'Invalid type',
ORDER_ERROR_ISOLATED_MARGIN_CHECK_FAILED:
'Party has insufficient funds to cover for the order margin for the new or amended order',
ORDER_ERROR_MARGIN_CHECK_FAILED: 'Margin check failed',
ORDER_ERROR_MARKET_CLOSED: 'Market closed',
ORDER_ERROR_MISSING_GENERAL_ACCOUNT: 'Missing general account',
@@ -221,6 +224,8 @@ export const OrderRejectionReasonMapping: {
ORDER_ERROR_NOT_FOUND: 'Not found',
ORDER_ERROR_OFFSET_MUST_BE_GREATER_OR_EQUAL_TO_ZERO:
'Offset must be greater or equal to zero',
ORDER_ERROR_PEGGED_ORDERS_NOT_ALLOWED_IN_ISOLATED_MARGIN_MODE:
'Pegged orders are not allowed for a party in isolated margin mode',
ORDER_ERROR_OFFSET_MUST_BE_GREATER_THAN_ZERO:
'Offset must be greater than zero',
ORDER_ERROR_OUT_OF_SEQUENCE: 'Out of sequence',
@@ -475,6 +480,9 @@ export const TransferTypeMapping: TransferTypeMap = {
TRANSFER_TYPE_WIN: 'Final settlement gain',
TRANSFER_TYPE_MTM_LOSS: 'Mark to market loss',
TRANSFER_TYPE_MTM_WIN: 'Mark to market gain',
TRANSFER_TYPE_ORDER_MARGIN_HIGH: 'From order margin account to general',
TRANSFER_TYPE_ORDER_MARGIN_LOW:
'From general account to order margin account',
TRANSFER_TYPE_MARGIN_LOW: 'Margin topped up',
TRANSFER_TYPE_MARGIN_HIGH: 'Margin returned',
TRANSFER_TYPE_MARGIN_CONFISCATED: 'Margin confiscated',
@@ -482,6 +490,8 @@ export const TransferTypeMapping: TransferTypeMap = {
TRANSFER_TYPE_MAKER_FEE_RECEIVE: 'Maker fee received',
TRANSFER_TYPE_INFRASTRUCTURE_FEE_PAY: 'Infrastructure fee paid',
TRANSFER_TYPE_INFRASTRUCTURE_FEE_DISTRIBUTE: 'Infrastructure fee distributed',
TRANSFER_TYPE_ISOLATED_MARGIN_LOW:
'From order margin account to margin account',
TRANSFER_TYPE_LIQUIDITY_FEE_PAY: 'Liquidity fee paid',
TRANSFER_TYPE_LIQUIDITY_FEE_DISTRIBUTE: 'Liquidity fee received',
TRANSFER_TYPE_BOND_LOW: 'Bond account funded',
@@ -515,6 +525,10 @@ export const DescriptionTransferTypeMapping: TransferTypeMap = {
TRANSFER_TYPE_WIN: `Funds added to your general account after final settlement gain`,
TRANSFER_TYPE_MTM_LOSS: `Funds deducted from your margin account after mark to market loss`,
TRANSFER_TYPE_MTM_WIN: `Funds added to your margin account after mark to market gain`,
TRANSFER_TYPE_ORDER_MARGIN_HIGH:
'Funds released from order margin account to general',
TRANSFER_TYPE_ORDER_MARGIN_LOW:
'Funds moved from general account to order margin account',
TRANSFER_TYPE_MARGIN_LOW: `Funds deducted from your general account to meet margin requirement`,
TRANSFER_TYPE_MARGIN_HIGH: `Excess margin amount returned to your general account`,
TRANSFER_TYPE_MARGIN_CONFISCATED: `Margin confiscated from your margin account to fulfil closeout`,
@@ -522,6 +536,8 @@ export const DescriptionTransferTypeMapping: TransferTypeMap = {
TRANSFER_TYPE_MAKER_FEE_RECEIVE: `Maker fee received into your general account when your passive order was filled`,
TRANSFER_TYPE_INFRASTRUCTURE_FEE_PAY: `Infrastructure fee paid from your general account when your order was filled`,
TRANSFER_TYPE_INFRASTRUCTURE_FEE_DISTRIBUTE: `Infrastructure fee received: Infrastructure fee, paid by traders, received into your general account`,
TRANSFER_TYPE_ISOLATED_MARGIN_LOW:
'Funds moved from order margin account to margin account',
TRANSFER_TYPE_LIQUIDITY_FEE_PAY: `Liquidity fee paid from your general account to market's liquidity providers`,
TRANSFER_TYPE_LIQUIDITY_FEE_DISTRIBUTE: `Liquidity fee received into your general account from traders`,
TRANSFER_TYPE_BOND_LOW: `Funds deducted from your general account to meet your required liquidity bond amount`,
@@ -38,14 +38,14 @@ export function Dialog({
);
const wrapperClasses = classNames(
// Dimensions
'max-w-[90vw] p-4 md:p-8',
'w-screen sm:max-w-[90vw] p-4 md:p-8',
// Need to apply background and text colors again as content is rendered in a portal
'dark:bg-black bg-white dark:text-white',
getIntentBorder(intent),
{
'w-[520px]': size === 'small',
'w-[680px]': size === 'medium',
'w-[720px] lg:w-[940px]': size === 'large',
'sm:w-[520px]': size === 'small',
'sm:w-[680px]': size === 'medium',
'sm:w-[720px] lg:w-[940px]': size === 'large',
}
);
@@ -1 +1,2 @@
export * from './slider';
export * from './leverage-slider';
@@ -16,6 +16,7 @@ type TradingButtonProps = {
subLabel?: ReactNode;
fill?: boolean;
minimal?: boolean;
testId?: string;
};
const getClassName = (
@@ -120,6 +121,7 @@ export const TradingButton = forwardRef<
className,
subLabel,
fill,
testId,
...props
},
ref
@@ -132,6 +134,7 @@ export const TradingButton = forwardRef<
{ size, subLabel, intent, fill, minimal },
className
)}
data-testid={testId}
{...props}
>
<Content icon={icon} subLabel={subLabel} children={children} />
+8
View File
@@ -84,6 +84,14 @@ describe('number utils', () => {
expect(formatNumberPercentage(v, d)).toStrictEqual(o);
});
it('formatNumberPercentage returns "-" when value is null', () => {
expect(formatNumberPercentage(null)).toStrictEqual('-');
});
it('formatNumberPercentage returns "-" when value is undefined', () => {
expect(formatNumberPercentage(undefined)).toStrictEqual('-');
});
describe('toNumberParts', () => {
it.each([
{ v: null, d: 3, o: ['0', '000', '.'] },
+8 -1
View File
@@ -156,7 +156,14 @@ export const addDecimalsFixedFormatNumber = (
return formatNumberFixed(x, formatDecimals);
};
export const formatNumberPercentage = (value: BigNumber, decimals?: number) => {
export const formatNumberPercentage = (
value: BigNumber | null | undefined,
decimals?: number
) => {
if (!value) {
return '-';
}
const decimalPlaces =
typeof decimals === 'undefined' ? value.dp() || 0 : decimals;
return `${formatNumber(value, decimalPlaces)}%`;
@@ -22,7 +22,7 @@ const mockUpdateDialogOpen = jest.fn();
const mockCloseVegaDialog = jest.fn();
let mockIsDesktopRunning = true;
const mockChainId = 'chain-id';
const mockChainId = 'VEGA_CHAIN_ID';
jest.mock('../use-is-wallet-service-running', () => ({
useIsWalletServiceRunning: jest
@@ -30,10 +30,6 @@ jest.mock('../use-is-wallet-service-running', () => ({
.mockImplementation(() => mockIsDesktopRunning),
}));
jest.mock('./use-chain-id', () => ({
useChainId: jest.fn().mockImplementation(() => mockChainId),
}));
let defaultProps: VegaConnectDialogProps;
const INITIAL_KEY = 'some-key';
@@ -71,6 +67,7 @@ const defaultConfig: VegaWalletConfig = {
chromeExtensionUrl: 'chrome-link',
mozillaExtensionUrl: 'mozilla-link',
},
chainId: 'VEGA_CHAIN_ID',
};
function generateJSX(
@@ -214,7 +211,12 @@ describe('VegaConnectDialog', () => {
.mockClear()
.mockImplementation(() =>
delayedReject(
new WalletError('User error', 3001, 'The user rejected the request')
new WalletError(
'User error',
3001,
'The user rejected the request'
),
delay
)
);
@@ -323,13 +325,6 @@ describe('VegaConnectDialog', () => {
render(generateJSX());
await selectInjected();
// Chain check
expect(screen.getByText('Verifying chain')).toBeInTheDocument();
expect(vegaWindow.getChainId).toHaveBeenCalled();
await act(async () => {
jest.advanceTimersByTime(delay);
});
// Await user connect
expect(screen.getByText('Connecting...')).toBeInTheDocument();
expect(vegaWindow.connectWallet).toHaveBeenCalled();
@@ -350,43 +345,6 @@ describe('VegaConnectDialog', () => {
expect(mockCloseVegaDialog).toHaveBeenCalledWith();
});
it('handles invalid chain', async () => {
const delay = 100;
const invalidChain = 'invalid chain';
const vegaWindow = {
getChainId: jest.fn(() =>
delayedResolve({ chainID: invalidChain }, delay)
),
connectWallet: jest.fn(() => delayedResolve(null, delay)),
disconnectWallet: jest.fn(() => delayedResolve(undefined, delay)),
listKeys: jest.fn(() =>
delayedResolve(
{
keys: [{ name: 'test key', publicKey: '0x123' }],
},
100
)
),
};
mockBrowserWallet(vegaWindow);
render(generateJSX());
await selectInjected();
// Chain check
expect(screen.getByText('Verifying chain')).toBeInTheDocument();
expect(vegaWindow.getChainId).toHaveBeenCalled();
await act(async () => {
jest.advanceTimersByTime(delay);
});
expect(screen.getByText('Wrong network')).toBeInTheDocument();
expect(
screen.getByText(
new RegExp(`set your wallet network in your app to "${mockChainId}"`)
)
).toBeInTheDocument();
});
async function selectInjected() {
expect(await screen.findByRole('dialog')).toBeInTheDocument();
fireEvent.click(await screen.findByTestId('connector-injected'));
@@ -44,7 +44,6 @@ import { isBrowserWalletInstalled } from '../utils';
import { useIsWalletServiceRunning } from '../use-is-wallet-service-running';
import { SnapStatus, useSnapStatus } from '../use-snap-status';
import { useVegaWalletDialogStore } from './vega-wallet-dialog-store';
import { useChainId } from './use-chain-id';
import { useT } from '../use-t';
import { Trans } from 'react-i18next';
@@ -65,7 +64,7 @@ export const VegaConnectDialog = ({
contentOnly,
onClose,
}: VegaConnectDialogProps) => {
const { disconnect, acknowledgeNeeded } = useVegaWallet();
const { chainId, disconnect, acknowledgeNeeded } = useVegaWallet();
const vegaWalletDialogOpen = useVegaWalletDialogStore(
(store) => store.vegaWalletDialogOpen
);
@@ -85,10 +84,6 @@ export const VegaConnectDialog = ({
[updateVegaWalletDialog, acknowledgeNeeded, disconnect]
);
// Ensure we have a chain Id so we can compare with wallet chain id.
// This value will already be in the cache, if it failed the app wont render
const chainId = useChainId();
const content = chainId && (
<ConnectDialogContainer
connectors={connectors}
@@ -268,7 +263,7 @@ const ConnectorList = ({
onClick={() => onSelect('injected')}
title={
<Trans
defaults="Vega Wallet <0>full featured<0>"
defaults="Vega Wallet <0>full featured</0>"
components={[<span className="text-xs">full featured</span>]}
/>
}
@@ -281,7 +276,7 @@ const ConnectorList = ({
<div>
<h1 className="mb-1 text-lg">
<Trans
defaults="Vega Wallet <0>full featured<0>"
defaults="Vega Wallet <0>full featured</0>"
components={[<span className="text-xs">full featured</span>]}
/>
</h1>
@@ -1,72 +0,0 @@
import { renderHook, waitFor } from '@testing-library/react';
import { useVegaWallet } from '../use-vega-wallet';
import { useChainId } from './use-chain-id';
global.fetch = jest.fn();
const mockFetch = global.fetch as jest.Mock;
mockFetch.mockImplementation((url: string) => {
return Promise.resolve({ ok: true });
});
jest.mock('../use-vega-wallet', () => {
const original = jest.requireActual('../use-vega-wallet');
return {
...original,
useVegaWallet: jest.fn(),
};
});
describe('useChainId', () => {
it('does not call fetch when statistics url could not be determined', async () => {
(useVegaWallet as jest.Mock).mockReturnValue({
vegaUrl: '',
});
renderHook(() => useChainId());
await waitFor(() => {
expect(mockFetch).toHaveBeenCalledTimes(0);
});
});
it('calls fetch with correct statistics url', async () => {
(useVegaWallet as jest.Mock).mockReturnValue({
vegaUrl: 'http://localhost:1234/graphql',
});
renderHook(() => useChainId());
await waitFor(() => {
expect(mockFetch).toHaveBeenCalledWith(
'http://localhost:1234/statistics'
);
});
});
it('does not return chain id when chain id is not present in response', async () => {
(useVegaWallet as jest.Mock).mockReturnValue({
vegaUrl: 'http://localhost:1234/graphql',
});
const { result } = renderHook(() => useChainId());
await waitFor(() => {
expect(result.current).toBeUndefined();
});
});
it('returns chain id when chain id is present in response', async () => {
(useVegaWallet as jest.Mock).mockReturnValue({
vegaUrl: 'http://localhost:1234/graphql',
});
mockFetch.mockImplementation(() => {
return Promise.resolve({
ok: true,
json: () => ({
statistics: {
chainId: '1234',
},
}),
});
});
const { result } = renderHook(() => useChainId());
await waitFor(() => {
expect(result.current).not.toBeUndefined();
expect(result.current).toEqual('1234');
});
});
});
@@ -1,10 +1,11 @@
import { clearConfig, setConfig } from '../storage';
import type { Transaction, VegaConnector } from './vega-connector';
type VegaWalletEvent = 'client.disconnected';
declare global {
interface Vega {
getChainId: () => Promise<{ chainID: string }>;
connectWallet: () => Promise<null>;
connectWallet: (args: { chainId: string }) => Promise<null>;
disconnectWallet: () => Promise<void>;
listKeys: () => Promise<{
keys: Array<{ name: string; publicKey: string }>;
@@ -34,6 +35,9 @@ declare global {
};
transactionHash: string;
}>;
on: (event: VegaWalletEvent, callback: () => void) => void;
isConnected?: () => Promise<boolean>;
}
interface Window {
@@ -47,15 +51,60 @@ export const InjectedConnectorErrors = {
INVALID_CHAIN: new Error('Invalid chain'),
};
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const wait = (ms: number) =>
new Promise<boolean>((_, reject) => {
setTimeout(() => {
reject(false);
}, ms);
});
const INJECTED_CONNECTOR_TIMEOUT = 1000;
export class InjectedConnector implements VegaConnector {
isConnected = false;
chainId: string | null = null;
description = 'Connects using the Vega wallet browser extension';
alive: ReturnType<typeof setInterval> | undefined = undefined;
async getChainId() {
return window.vega.getChainId();
}
async connectWallet(chainId: string) {
this.chainId = chainId;
try {
await window.vega.connectWallet({ chainId });
this.isConnected = true;
window.vega.on('client.disconnected', () => {
this.isConnected = false;
});
connectWallet() {
return window.vega.connectWallet();
this.alive = setInterval(async () => {
try {
const connected = await Promise.race([
// FIXME: All of the `window.vega` initiated promises are `pending`
// while waiting for the user action when transaction is sent.
// (Probably due to the FIFO queue of the `PortServer`?)
// Because of that we cannot `wait` here as while waiting for the
// user action in wallet this will `reject`. It'd be cool if the
// `window.vega` was not blocking the api calls.
// wait(INJECTED_CONNECTOR_TIMEOUT),
// `isConnected` is only available in the newer versions
// of the browser wallet
'isConnected' in window.vega &&
typeof window.vega.isConnected === 'function'
? window.vega.isConnected()
: window.vega.listKeys(),
]);
this.isConnected = Boolean(connected);
} catch {
this.isConnected = false;
}
}, INJECTED_CONNECTOR_TIMEOUT * 2);
} catch {
throw new Error(
`could not connect to the vega wallet on chain: ${chainId}`
);
}
}
async connect() {
@@ -69,19 +118,11 @@ export class InjectedConnector implements VegaConnector {
}
async isAlive() {
try {
const keys = await window.vega.listKeys();
if (keys.keys.length > 0) {
return true;
}
} catch (err) {
return false;
}
return false;
return this.isConnected;
}
disconnect() {
clearInterval(this.alive);
clearConfig();
return window.vega.disconnectWallet();
}
@@ -451,7 +451,22 @@ export type CreateReferralSet = {
};
};
export enum MarginMode {
MARGIN_MODE_CROSS_MARGIN = 1,
MARGIN_MODE_ISOLATED_MARGIN,
}
export interface UpdateMarginMode {
market_id: string;
mode: MarginMode;
marginFactor?: string;
}
export interface UpdateMarginModeBody {
updateMarginMode: UpdateMarginMode;
}
export type Transaction =
| UpdateMarginModeBody
| StopOrdersSubmissionBody
| StopOrdersCancellationBody
| OrderSubmissionBody
@@ -468,6 +483,10 @@ export type Transaction =
| ApplyReferralCode
| CreateReferralSet;
export const isMarginModeUpdateTransaction = (
transaction: Transaction
): transaction is UpdateMarginModeBody => 'updateMarginMode' in transaction;
export const isWithdrawTransaction = (
transaction: Transaction
): transaction is WithdrawSubmissionBody => 'withdrawSubmission' in transaction;
+3
View File
@@ -13,6 +13,9 @@ export interface VegaWalletContextShape {
/** Url of current connected node */
vegaUrl: string;
/** Vega chain id */
chainId: string;
/** Url of running wallet service */
vegaWalletServiceUrl: string;
+1
View File
@@ -7,3 +7,4 @@ export * from './provider';
export * from './connect-dialog';
export * from './utils';
export * from './storage';
export * from './use-chain-id';
+2
View File
@@ -22,6 +22,7 @@ const defaultConfig: VegaWalletConfig = {
chromeExtensionUrl: 'chrome-link',
mozillaExtensionUrl: 'mozilla-link',
},
chainId: 'VEGA_CHAIN_ID',
};
const setup = (config?: Partial<VegaWalletConfig>) => {
@@ -68,6 +69,7 @@ describe('VegaWalletProvider', () => {
expect(result.current).toEqual({
network: defaultConfig.network,
vegaUrl: defaultConfig.vegaUrl,
chainId: defaultConfig.chainId,
vegaWalletServiceUrl: defaultConfig.vegaWalletServiceUrl,
acknowledgeNeeded: false,
pubKey: null,
+2
View File
@@ -39,6 +39,7 @@ interface VegaWalletLinks {
export interface VegaWalletConfig {
network: Networks;
vegaUrl: string;
chainId: string;
vegaWalletServiceUrl: string;
links: VegaWalletLinks;
keepAlive?: number;
@@ -168,6 +169,7 @@ export const VegaWalletProvider = ({
const contextValue = useMemo<VegaWalletContextShape>(() => {
return {
vegaUrl: config.vegaUrl,
chainId: config.chainId,
vegaWalletServiceUrl: config.vegaWalletServiceUrl,
network: config.network,
links: {
+2 -1
View File
@@ -1,6 +1,5 @@
export function mockBrowserWallet(overrides?: Partial<Vega>) {
const vega: Vega = {
getChainId: jest.fn().mockReturnValue(Promise.resolve({ chainID: '1' })),
connectWallet: jest.fn().mockReturnValue(Promise.resolve(null)),
disconnectWallet: jest.fn().mockReturnValue(Promise.resolve()),
listKeys: jest
@@ -14,6 +13,8 @@ export function mockBrowserWallet(overrides?: Partial<Vega>) {
success: true,
txHash: '0x123',
}),
on: jest.fn(),
isConnected: jest.fn().mockRejectedValue(Promise.resolve(true)),
...overrides,
};
// @ts-ignore globalThis has no index signature
+100
View File
@@ -0,0 +1,100 @@
import { renderHook, waitFor } from '@testing-library/react';
import { MAX_FETCH_ATTEMPTS, useChainId } from './use-chain-id';
global.fetch = jest.fn();
const mockFetch = global.fetch as jest.Mock;
mockFetch.mockImplementation((url: string) => {
return Promise.resolve({ ok: true });
});
describe('useChainId', () => {
it('does not call fetch when statistics url could not be determined', async () => {
renderHook(() => useChainId(''));
await waitFor(() => {
expect(mockFetch).toHaveBeenCalledTimes(0);
});
});
it('calls fetch with correct statistics url', async () => {
renderHook(() => useChainId('http://localhost:1234/graphql'));
await waitFor(() => {
expect(mockFetch).toHaveBeenCalledWith(
'http://localhost:1234/statistics'
);
});
});
it('does not return chain id when chain id is not present in response', async () => {
const { result } = renderHook(() =>
useChainId('http://localhost:1234/graphql')
);
await waitFor(() => {
expect(result.current).toBeUndefined();
});
});
it('returns chain id when chain id is present in response', async () => {
mockFetch.mockImplementation(() => {
return Promise.resolve({
ok: true,
json: () => ({
statistics: {
chainId: '1234',
},
}),
});
});
const { result } = renderHook(() =>
useChainId('http://localhost:1234/graphql')
);
await waitFor(() => {
expect(result.current).not.toBeUndefined();
expect(result.current).toEqual('1234');
});
});
it('returns chain id when within max number of attempts', async () => {
mockFetch.mockImplementation(() => {
if (mockFetch.mock.calls.length < MAX_FETCH_ATTEMPTS) {
return Promise.reject();
}
return Promise.resolve({
ok: true,
json: () => ({
statistics: {
chainId: '1234',
},
}),
});
});
const { result } = renderHook(() =>
useChainId('http://localhost:1234/graphql')
);
await waitFor(() => {
expect(result.current).not.toBeUndefined();
expect(result.current).toEqual('1234');
});
});
it('does not return chain id when max number of attempts exceeded', async () => {
mockFetch.mockImplementation(() => {
if (mockFetch.mock.calls.length < MAX_FETCH_ATTEMPTS + 10) {
return Promise.reject();
}
return Promise.resolve({
ok: true,
json: () => ({
statistics: {
chainId: '1234',
},
}),
});
});
const { result } = renderHook(() =>
useChainId('http://localhost:5678/graphql')
);
await waitFor(() => {
expect(result.current).toBeUndefined();
});
});
});
@@ -1,5 +1,6 @@
import { useEffect, useState } from 'react';
import { useVegaWallet } from '../use-vega-wallet';
export const MAX_FETCH_ATTEMPTS = 3;
const cache: Record<string, string> = {};
@@ -18,16 +19,17 @@ const getNodeStatisticsUrl = (vegaUrl: string) => {
}
};
export const useChainId = () => {
const { vegaUrl } = useVegaWallet();
const [chainId, setChainId] = useState<undefined | string>(cache[vegaUrl]);
const [fetchAttempt, setFetchAttempt] = useState(1);
export const useChainId = (vegaUrl: string | undefined) => {
const [chainId, setChainId] = useState<undefined | string>(
vegaUrl ? cache[vegaUrl] : undefined
);
const [fetchAttempts, setFetchAttempts] = useState(1);
const statisticsUrl = getNodeStatisticsUrl(vegaUrl);
const statisticsUrl = vegaUrl ? getNodeStatisticsUrl(vegaUrl) : undefined;
useEffect(() => {
// abort when `/statistics` URL could not be determined
if (!statisticsUrl) return;
if (!statisticsUrl || !vegaUrl) return;
let isCancelled = false;
if (cache[vegaUrl]) {
setChainId(cache[vegaUrl]);
@@ -42,16 +44,19 @@ export const useChainId = () => {
if (!response?.statistics?.chainId) {
throw new Error('statistics.chainId not present in fetched response');
}
setChainId(response?.statistics?.chainId);
const chainId = response.statistics.chainId;
cache[vegaUrl] = chainId;
setChainId(chainId);
})
.catch(() => {
if (fetchAttempt < 3) {
setFetchAttempt((value) => (value += 1));
if (fetchAttempts < MAX_FETCH_ATTEMPTS) {
setFetchAttempts((value) => (value += 1));
}
});
return () => {
isCancelled = true;
};
}, [fetchAttempt, statisticsUrl, vegaUrl]);
}, [fetchAttempts, statisticsUrl, vegaUrl]);
return chainId;
};
+3 -3
View File
@@ -7,7 +7,7 @@ import { useVegaWallet } from './use-vega-wallet';
export function useEagerConnect(connectors: Connectors) {
const [connecting, setConnecting] = useState(true);
const { vegaUrl, connect, acknowledgeNeeded } = useVegaWallet();
const { vegaUrl, chainId, connect, acknowledgeNeeded } = useVegaWallet();
useEffect(() => {
const attemptConnect = async () => {
@@ -33,7 +33,7 @@ export function useEagerConnect(connectors: Connectors) {
try {
if (connector instanceof InjectedConnector) {
await connector.connectWallet();
await connector.connectWallet(chainId);
await connect(connector);
} else if (connector instanceof SnapConnector) {
connector.nodeAddress = new URL(vegaUrl).origin;
@@ -51,7 +51,7 @@ export function useEagerConnect(connectors: Connectors) {
if (typeof window !== 'undefined') {
attemptConnect();
}
}, [connect, connectors, acknowledgeNeeded, vegaUrl]);
}, [connect, connectors, acknowledgeNeeded, vegaUrl, chainId]);
return connecting;
}
@@ -16,6 +16,7 @@ const defaultConfig: VegaWalletConfig = {
chromeExtensionUrl: 'chrome-link',
mozillaExtensionUrl: 'mozilla-link',
},
chainId: 'VEGA_CHAIN_ID',
};
const setup = (callback = jest.fn(), config?: Partial<VegaWalletConfig>) => {
@@ -46,20 +47,10 @@ describe('useInjectedConnector', () => {
expect(result.current.status).toBe(Status.Error);
});
it('errors if chain ids dont match', async () => {
mockBrowserWallet();
const { result } = setup();
await act(async () => {
result.current.connect(injected, '2'); // default mock chainId is '1'
});
expect(result.current.error?.message).toBe('Invalid chain');
expect(result.current.status).toBe(Status.Error);
});
it('errors if connection throws', async () => {
const callback = jest.fn();
mockBrowserWallet({
getChainId: () => Promise.reject('failed'),
connectWallet: jest.fn().mockReturnValue(Promise.reject()),
});
const { result } = setup(callback);
@@ -67,7 +58,9 @@ describe('useInjectedConnector', () => {
result.current.connect(injected, '1'); // default mock chainId is '1'
});
expect(result.current.status).toBe(Status.Error);
expect(result.current.error?.message).toBe('injected connection failed');
expect(result.current.error?.message).toBe(
'could not connect to the vega wallet on chain: 1'
);
});
it('connects', async () => {
@@ -78,7 +71,6 @@ describe('useInjectedConnector', () => {
act(() => {
result.current.connect(injected, '1'); // default mock chainId is '1'
});
expect(result.current.status).toBe(Status.GettingChainId);
await waitFor(() => {
expect(vega.connectWallet).toHaveBeenCalled();
+8 -6
View File
@@ -40,17 +40,19 @@ export const useInjectedConnector = (onConnect: () => void) => {
connector.nodeAddress = new URL(vegaUrl).origin;
}
setStatus(Status.GettingChainId);
const { chainID } = await connector.getChainId();
if (chainID !== appChainId) {
throw InjectedConnectorErrors.INVALID_CHAIN;
// check the chain id for snap connector
if (connector instanceof SnapConnector) {
setStatus(Status.GettingChainId);
const { chainID } = await connector.getChainId();
if (chainID !== appChainId) {
throw InjectedConnectorErrors.INVALID_CHAIN;
}
}
setStatus(Status.Connecting);
if (connector instanceof InjectedConnector) {
// extra step for injected connector - authorize wallet
await connector.connectWallet();
await connector.connectWallet(appChainId);
}
await connect(connector); // connect with keys
+9 -8
View File
@@ -1,7 +1,11 @@
export * from './lib/__generated__/TransactionResult';
export * from './lib/__generated__/WithdrawalApproval';
export * from './lib/constants';
export * from './lib/default-web3-provider';
export * from './lib/eip-1193-custom-bridge';
export * from './lib/ethereum-error';
export * from './lib/ethereum-transaction-dialog';
export * from './lib/types';
export * from './lib/url-connector';
export * from './lib/use-bridge-contract';
export * from './lib/use-eager-connect';
@@ -19,7 +23,12 @@ export * from './lib/use-get-withdraw-delay';
export * from './lib/use-get-withdraw-threshold';
export * from './lib/use-token-contract';
export * from './lib/use-token-decimals';
export * from './lib/use-transaction-result';
export * from './lib/use-vega-transaction-manager';
export * from './lib/use-vega-transaction-store';
export * from './lib/use-vega-transaction-toasts';
export * from './lib/use-vega-transaction-updater';
export * from './lib/use-wallet-disconnected-toasts';
export * from './lib/use-web3-disconnect';
export * from './lib/web3-connect-dialog';
export * from './lib/web3-connect-store';
@@ -27,11 +36,3 @@ export * from './lib/web3-connectors';
export * from './lib/web3-provider';
export * from './lib/withdrawal-approval-dialog';
export * from './lib/withdrawal-approval-status';
export * from './lib/default-web3-provider';
export * from './lib/use-vega-transaction-manager';
export * from './lib/use-vega-transaction-store';
export * from './lib/use-vega-transaction-updater';
export * from './lib/use-transaction-result';
export * from './lib/types';
export * from './lib/__generated__/TransactionResult';
export * from './lib/__generated__/WithdrawalApproval';
@@ -10,6 +10,7 @@ import {
isStopOrdersSubmissionTransaction,
isStopOrdersCancellationTransaction,
determineId,
isMarginModeUpdateTransaction,
} from '@vegaprotocol/wallet';
import { create } from 'zustand';
@@ -58,7 +59,7 @@ export interface VegaTransactionStore {
export const useVegaTransactionStore = create<VegaTransactionStore>()(
subscribeWithSelector((set, get) => ({
transactions: [] as VegaStoredTxState[],
transactions: [] as (VegaStoredTxState | undefined)[],
create: (body: Transaction, order?: OrderTxUpdateFieldsFragment) => {
const transactions = get().transactions;
const now = new Date();
@@ -205,16 +206,22 @@ export const useVegaTransactionStore = create<VegaTransactionStore>()(
isStopOrdersCancellationTransaction(transaction.body);
const isConfirmedStopOrderSubmission =
isStopOrdersSubmissionTransaction(transaction.body);
const isConfirmedMarginModeTransaction =
isMarginModeUpdateTransaction(transaction.body);
if (
(isConfirmedOrderCancellation ||
isConfirmedTransfer ||
isConfirmedStopOrderCancellation ||
isConfirmedStopOrderSubmission) &&
!transactionResult.error &&
transactionResult.status
isConfirmedOrderCancellation ||
isConfirmedTransfer ||
isConfirmedStopOrderCancellation ||
isConfirmedStopOrderSubmission ||
isConfirmedMarginModeTransaction
) {
transaction.status = VegaTxStatus.Complete;
if (transactionResult.error) {
transaction.status = VegaTxStatus.Error;
transaction.error = new Error(transactionResult.error);
} else if (transactionResult.status) {
transaction.status = VegaTxStatus.Complete;
}
}
transaction.dialogOpen = true;
transaction.updatedAt = new Date();
@@ -7,6 +7,7 @@ import type {
OrderSubmission,
StopOrdersSubmission,
StopOrderSetup,
UpdateMarginMode,
} from '@vegaprotocol/wallet';
import type {
OrderTxUpdateFieldsFragment,
@@ -26,6 +27,8 @@ import {
isStopOrdersSubmissionTransaction,
isStopOrdersCancellationTransaction,
isReferralRelatedTransaction,
isMarginModeUpdateTransaction,
MarginMode,
} from '@vegaprotocol/wallet';
import { useVegaTransactionStore } from './use-vega-transaction-store';
import { VegaTxStatus } from './types';
@@ -163,6 +166,7 @@ const isClosePositionTransaction = (tx: VegaStoredTxState) => {
};
const isTransactionTypeSupported = (tx: VegaStoredTxState) => {
const marginModeUpdate = isMarginModeUpdateTransaction(tx.body);
const withdraw = isWithdrawTransaction(tx.body);
const submitOrder = isOrderSubmissionTransaction(tx.body);
const cancelOrder = isOrderCancellationTransaction(tx.body);
@@ -173,6 +177,7 @@ const isTransactionTypeSupported = (tx: VegaStoredTxState) => {
const transfer = isTransferTransaction(tx.body);
const referral = isReferralRelatedTransaction(tx.body);
return (
marginModeUpdate ||
withdraw ||
submitOrder ||
cancelOrder ||
@@ -445,6 +450,27 @@ const CancelOrderDetails = ({
);
};
const MarginModeDetails = ({ data }: { data: UpdateMarginMode }) => {
const t = useT();
const { data: markets } = useMarketsMapProvider();
const marketId = data.market_id;
const market = marketId && markets?.[marketId];
if (!market) {
return null;
}
return (
<Panel>
<h4>{t('Update margin mode')}</h4>
<p>{market?.tradableInstrument.instrument.code}</p>
{data.mode === MarginMode.MARGIN_MODE_CROSS_MARGIN
? t('Cross margin mode')
: t('Isolated margin mode, leverage: {{leverage}}x', {
leverage: (1 / Number(data.marginFactor)).toFixed(1),
})}
</Panel>
);
};
const CancelStopOrderDetails = ({ stopOrderId }: { stopOrderId: string }) => {
const t = useT();
const formatTrigger = useFormatTrigger();
@@ -598,6 +624,10 @@ export const VegaTransactionDetails = ({ tx }: { tx: VegaStoredTxState }) => {
);
}
if (isMarginModeUpdateTransaction(tx.body)) {
return <MarginModeDetails data={tx.body.updateMarginMode} />;
}
if (isClosePositionTransaction(tx)) {
const transaction = tx.body as BatchMarketInstructionSubmissionBody;
const marketId = first(
@@ -0,0 +1,78 @@
import {
Intent,
useToasts,
ToastHeading,
CLOSE_AFTER,
type Toast,
} from '@vegaprotocol/ui-toolkit';
import { useVegaWallet } from '@vegaprotocol/wallet';
import { useEffect, useMemo } from 'react';
import { useT } from './use-t';
import { usePrevious } from '@vegaprotocol/react-helpers';
export const WALLET_DISCONNECTED_TOAST_ID = 'WALLET_DISCONNECTED_TOAST_ID';
export const useWalletDisconnectToastActions = () => {
const [hasToast, updateToast] = useToasts((state) => [
state.hasToast,
state.update,
]);
const hideToast = () => {
if (!hasToast(WALLET_DISCONNECTED_TOAST_ID)) return;
updateToast(WALLET_DISCONNECTED_TOAST_ID, {
hidden: true,
});
};
const showToast = () => {
if (!hasToast(WALLET_DISCONNECTED_TOAST_ID)) return;
updateToast(WALLET_DISCONNECTED_TOAST_ID, {
hidden: false,
});
};
return { showToast, hideToast };
};
export const useWalletDisconnectedToasts = (
additionalContent?: JSX.Element
) => {
const t = useT();
const [hasToast, setToast] = useToasts((state) => [
state.hasToast,
state.setToast,
state.update,
]);
const { showToast, hideToast } = useWalletDisconnectToastActions();
const { isAlive } = useVegaWallet();
const wasAlive = usePrevious(isAlive);
const disconnected = wasAlive && !isAlive;
const toast: Toast = useMemo(
() => ({
id: WALLET_DISCONNECTED_TOAST_ID,
intent: Intent.Danger,
content: (
<>
<ToastHeading>{t('Wallet connection lost')}</ToastHeading>
<p>{t('The connection to the Vega wallet has been lost.')}</p>
{additionalContent}
</>
),
onClose: () => {
hideToast();
},
closeAfter: CLOSE_AFTER,
}),
[additionalContent, hideToast, t]
);
useEffect(() => {
if (disconnected) {
if (hasToast(WALLET_DISCONNECTED_TOAST_ID)) {
showToast();
} else {
setToast(toast);
}
}
}, [disconnected, hasToast, isAlive, setToast, showToast, t, toast]);
};