Compare commits

..
Author SHA1 Message Date
asiaznik 69ddba288a chore(trading): create and update team form traversing 2024-02-06 17:42:30 +01:00
bf70dc33ec chore(trading): back merge main to develop (#5749)
Co-authored-by: Matthew Russell <mattrussell36@gmail.com>
Co-authored-by: Edd <edd@vega.xyz>
Co-authored-by: Bartłomiej Głownia <bglownia@gmail.com>
Co-authored-by: Art <artur@vegaprotocol.io>
2024-02-06 14:41:48 +00:00
Art d6084e75a0 fix(environment): pick best node (#5752) 2024-02-06 14:40:14 +01:00
Art 75e7cea32a fix(trading): trading view on multiple tabs (#5748) 2024-02-06 12:31:12 +01:00
Bartłomiej Głownia b4e98e285e feat(deal-ticket): update fields in margin details (#5722) 2024-02-06 08:42:11 +01:00
Edd f62e29c67f feat(explorer): batch proposal support (#5711) 2024-02-05 17:35:12 +00:00
94e7ad489f feat(trading): main to develop back merge (#5739)
Co-authored-by: m.ray <16125548+MadalinaRaicu@users.noreply.github.com>
Co-authored-by: Matthew Russell <mattrussell36@gmail.com>
Co-authored-by: Edd <edd@vega.xyz>
2024-02-05 16:06:33 +01:00
Art 657942d995 feat(trading): team card (#5724) 2024-02-02 20:07:06 +02:00
Matthew Russell a529b9b031 chore(trading): make team stats aggregation epochs 192 (#5723) 2024-02-02 18:01:28 +00:00
Ben 0f311611ba chore(trading): team e2e tests (#5716) 2024-02-01 19:33:25 +00:00
Art 02bd031bee fix(trading): mark as updated when tx confirmed (#5715) 2024-02-01 13:27:21 +00:00
Art bd97651dd3 fix(trading): game cards are not showing (#5713) 2024-02-01 12:04:36 +00:00
Bartłomiej Głownia 516b3e5b93 feat(deal-ticket): fix margin calculations in DealTicketMarginDetails for isolated margin mode (#5709) 2024-02-01 12:52:45 +01:00
Matthew Russellandbwallacee 42a98b6a35 fix(trading): teams snags (#5707)
Co-authored-by: bwallacee <ben@vega.xyz>
2024-02-01 10:38:57 +00:00
Art 2002731c52 feat(trading): gas fee estimation for withdraw transaction (#5668) 2024-02-01 09:07:13 +01:00
71 changed files with 2038 additions and 838 deletions
+5 -5
View File
@@ -4,7 +4,7 @@ The front-end monorepo provides a toolkit for building apps that interact with V
This repository is managed using [Nx](https://nx.dev).
# 🔎 Applications in this repo
## 🔎 Applications in this repo
### [Block explorer](./apps/explorer)
@@ -30,7 +30,7 @@ Hosting for static content being shared across apps, for example fonts.
The utility dApp for validators wishing to add or remove themselves as a signer of the multisig contract.
# 🧱 Libraries in this repo
## 🧱 Libraries in this repo
### [UI toolkit](./libs/ui-toolkit)
@@ -53,7 +53,7 @@ A utility library for connecting to the Ethereum network and interacting with Ve
Generic react helpers that can be used across multiple applications, along with other utilities.
# 💻 Develop
## 💻 Develop
### Set up
@@ -103,7 +103,7 @@ In CI linting, formatting and also run. These checks can be seen in the [CI work
Visit the [Nx Documentation](https://nx.dev/getting-started/intro) to learn more.
# 🐋 Hosting a console
## 🐋 Hosting a console
To host a console there are two possible build scenarios for running the frontends: nx performed **outside** or **inside** docker build. For specific build instructions follow [build instructions](#build-instructions).
@@ -226,6 +226,6 @@ Note: The script is only needed if capsule was built for first time or fresh. To
vega wallet service run -n DV --load-tokens --tokens-passphrase-file passphrase --no-version-check --automatic-consent --home ~/.vegacapsule/testnet/wallet
```
# 📑 License
## 📑 License
[MIT](./LICENSE)
@@ -1,4 +1,4 @@
export type HashProps = {
export type HashProps = React.HTMLProps<HTMLSpanElement> & {
text: string;
truncate?: boolean;
};
@@ -2,4 +2,5 @@ export { default as BlockLink } from './block-link/block-link';
export { default as PartyLink } from './party-link/party-link';
export { default as NodeLink } from './node-link/node-link';
export { default as MarketLink } from './market-link/market-link';
export { default as NetworkParameterLink } from './network-parameter-link/network-parameter-link';
export * from './asset-link/asset-link';
@@ -0,0 +1,30 @@
import React from 'react';
import { Routes } from '../../../routes/route-names';
import { Link } from 'react-router-dom';
import type { ComponentProps } from 'react';
import Hash from '../hash';
export type NetworkParameterLinkProps = Partial<ComponentProps<typeof Link>> & {
parameter: string;
};
/**
* Links a given network parameter to the relevant page and anchor on the page
*/
const NetworkParameterLink = ({
parameter,
...props
}: NetworkParameterLinkProps) => {
return (
<Link
className="underline"
{...props}
to={`/${Routes.NETWORK_PARAMETERS}#${parameter}`}
>
<Hash text={parameter} />
</Link>
);
};
export default NetworkParameterLink;
@@ -26,7 +26,7 @@ const ProposalLink = ({ id, text }: ProposalLinkProps) => {
>;
const base = ENV.dataSources.governanceUrl;
const label = proposal?.rationale.title || id;
const label = proposal?.rationale?.title || id;
return (
<ExternalLink href={`${base}/proposals/${id}`}>
@@ -5,5 +5,10 @@ query ExplorerProposalStatus($id: ID!) {
state
rejectionReason
}
... on BatchProposal {
id
state
rejectionReason
}
}
}
@@ -8,7 +8,7 @@ export type ExplorerProposalStatusQueryVariables = Types.Exact<{
}>;
export type ExplorerProposalStatusQuery = { __typename?: 'Query', proposal?: { __typename?: 'BatchProposal' } | { __typename?: 'Proposal', id?: string | null, state: Types.ProposalState, rejectionReason?: Types.ProposalRejectionReason | null } | null };
export type ExplorerProposalStatusQuery = { __typename?: 'Query', proposal?: { __typename?: 'BatchProposal', id?: string | null, state: Types.ProposalState, rejectionReason?: Types.ProposalRejectionReason | null } | { __typename?: 'Proposal', id?: string | null, state: Types.ProposalState, rejectionReason?: Types.ProposalRejectionReason | null } | null };
export const ExplorerProposalStatusDocument = gql`
@@ -19,6 +19,11 @@ export const ExplorerProposalStatusDocument = gql`
state
rejectionReason
}
... on BatchProposal {
id
state
rejectionReason
}
}
}
`;
@@ -0,0 +1,257 @@
import { render, screen } from '@testing-library/react';
import { BatchItem } from './batch-item';
import { MemoryRouter } from 'react-router-dom';
import { MockedProvider } from '@apollo/client/testing';
import type { components } from '../../../../../types/explorer';
type Item = components['schemas']['vegaBatchProposalTermsChange'];
describe('BatchItem', () => {
it('Renders "Unknown proposal type" by default', () => {
const item = {};
render(<BatchItem item={item} />);
expect(screen.getByText('Unknown proposal type')).toBeInTheDocument();
});
it('Renders "Unknown proposal type" for unknown items', () => {
const item = {
newLochNessMonster: {
location: 'Loch Ness',
},
} as unknown as Item;
render(<BatchItem item={item} />);
expect(screen.getByText('Unknown proposal type')).toBeInTheDocument();
});
it('Renders "New spot market"', () => {
const item = {
newSpotMarket: {},
};
render(<BatchItem item={item} />);
expect(screen.getByText('New spot market')).toBeInTheDocument();
});
it('Renders "Cancel transfer"', () => {
const item = {
cancelTransfer: {
changes: {
transferId: 'transfer123',
},
},
};
render(<BatchItem item={item} />);
expect(screen.getByText('Cancel transfer')).toBeInTheDocument();
expect(screen.getByText('transf')).toBeInTheDocument();
});
it('Renders "Cancel transfer" without an id', () => {
const item = {
cancelTransfer: {
changes: {},
},
};
render(<BatchItem item={item} />);
expect(screen.getByText('Cancel transfer')).toBeInTheDocument();
});
it('Renders "New freeform"', () => {
const item = {
newFreeform: {},
};
render(<BatchItem item={item} />);
expect(screen.getByText('New freeform proposal')).toBeInTheDocument();
});
it('Renders "New market"', () => {
const item = {
newMarket: {},
};
render(<BatchItem item={item} />);
expect(screen.getByText('New market')).toBeInTheDocument();
});
it('Renders "New transfer"', () => {
const item = {
newTransfer: {},
};
render(<BatchItem item={item} />);
expect(screen.getByText('New transfer')).toBeInTheDocument();
});
it('Renders "Update asset" with assetId', () => {
const item = {
updateAsset: {
assetId: 'asset123',
},
};
render(
<MemoryRouter>
<MockedProvider>
<BatchItem item={item} />
</MockedProvider>
</MemoryRouter>
);
expect(screen.getByText('Update asset')).toBeInTheDocument();
expect(screen.getByText('asset123')).toBeInTheDocument();
});
it('Renders "Update asset" even if assetId is not set', () => {
const item = {
updateAsset: {
assetId: undefined,
},
};
render(<BatchItem item={item} />);
expect(screen.getByText('Update asset')).toBeInTheDocument();
});
it('Renders "Update market state" with marketId', () => {
const item = {
updateMarketState: {
changes: {
marketId: 'market123',
},
},
};
render(
<MemoryRouter>
<MockedProvider>
<BatchItem item={item} />
</MockedProvider>
</MemoryRouter>
);
expect(screen.getByText('Update market state')).toBeInTheDocument();
expect(screen.getByText('market123')).toBeInTheDocument();
});
it('Renders "Update market state" even if marketId is not set', () => {
const item = {
updateMarketState: {
changes: {
marketId: undefined,
},
},
};
render(
<MemoryRouter>
<MockedProvider>
<BatchItem item={item} />
</MockedProvider>
</MemoryRouter>
);
expect(screen.getByText('Update market state')).toBeInTheDocument();
});
it('Renders "Update network parameter" with parameter', () => {
const item = {
updateNetworkParameter: {
changes: {
key: 'parameter123',
},
},
};
render(
<MockedProvider>
<MemoryRouter>
<BatchItem item={item} />
</MemoryRouter>
</MockedProvider>
);
expect(screen.getByText('Update network parameter')).toBeInTheDocument();
expect(screen.getByText('parameter123')).toBeInTheDocument();
});
it('Renders "Update network parameter" even if parameter is not set', () => {
const item = {
updateNetworkParameter: {
changes: {
key: undefined,
},
},
};
render(<BatchItem item={item} />);
expect(screen.getByText('Update network parameter')).toBeInTheDocument();
});
it('Renders "Update referral program"', () => {
const item = {
updateReferralProgram: {},
};
render(<BatchItem item={item} />);
expect(screen.getByText('Update referral program')).toBeInTheDocument();
});
it('Renders "Update spot market" with marketId', () => {
const item = {
updateSpotMarket: {
marketId: 'market123',
},
};
render(
<MemoryRouter>
<MockedProvider>
<BatchItem item={item} />
</MockedProvider>
</MemoryRouter>
);
expect(screen.getByText('Update spot market')).toBeInTheDocument();
expect(screen.getByText('market123')).toBeInTheDocument();
});
it('Renders "Update spot market" even if marketId is not set', () => {
const item = {
updateSpotMarket: {
marketId: undefined,
},
};
render(
<MemoryRouter>
<MockedProvider>
<BatchItem item={item} />
</MockedProvider>
</MemoryRouter>
);
expect(screen.getByText('Update spot market')).toBeInTheDocument();
});
it('Renders "Update market" with marketId', () => {
const item = {
updateMarket: {
marketId: 'market123',
},
};
render(
<MemoryRouter>
<MockedProvider>
<BatchItem item={item} />
</MockedProvider>
</MemoryRouter>
);
expect(screen.getByText('Update market')).toBeInTheDocument();
expect(screen.getByText('market123')).toBeInTheDocument();
});
it('Renders "Update market" even if marketId is not set', () => {
const item = {
updateMarket: {
marketId: undefined,
},
};
render(
<MemoryRouter>
<MockedProvider>
<BatchItem item={item} />
</MockedProvider>
</MemoryRouter>
);
expect(screen.getByText('Update market')).toBeInTheDocument();
});
it('Renders "Update volume discount program"', () => {
const item = {
updateVolumeDiscountProgram: {},
};
render(<BatchItem item={item} />);
expect(
screen.getByText('Update volume discount program')
).toBeInTheDocument();
});
});
@@ -0,0 +1,87 @@
import { t } from '@vegaprotocol/i18n';
import { AssetLink, MarketLink, NetworkParameterLink } from '../../../links';
import type { components } from '../../../../../types/explorer';
import Hash from '../../../links/hash';
type Item = components['schemas']['vegaBatchProposalTermsChange'];
export interface BatchItemProps {
item: Item;
}
/**
* Produces a one line summary for an item in a batch proposal. Could
* easily be adapted to summarise individual proposals, but there is no
* place for that yet.
*
* Details (like IDs) should be shown and linked if available, but handled
* if not available. This is adequate as the ProposalSummary component contains
* a JSON viewer for the full proposal.
*/
export const BatchItem = ({ item }: BatchItemProps) => {
if (item.cancelTransfer) {
const transferId = item?.cancelTransfer?.changes?.transferId || false;
return (
<span>
{t('Cancel transfer')}&nbsp;
{transferId && (
<Hash className="ml-1" truncate={true} text={transferId} />
)}
</span>
);
} else if (item.newFreeform) {
return <span>{t('New freeform proposal')}</span>;
} else if (item.newMarket) {
return <span>{t('New market')}</span>;
} else if (item.newSpotMarket) {
return <span>{t('New spot market')}</span>;
} else if (item.newTransfer) {
return <span>{t('New transfer')}</span>;
} else if (item.updateAsset) {
const assetId = item?.updateAsset?.assetId || false;
return (
<span>
{t('Update asset')}
{assetId && <AssetLink className="ml-1" assetId={assetId} />}
</span>
);
} else if (item.updateMarket) {
const marketId = item?.updateMarket?.marketId || false;
return (
<span>
{t('Update market')}{' '}
{marketId && <MarketLink className="ml-1" id={marketId} />}
</span>
);
} else if (item.updateMarketState) {
const marketId = item?.updateMarketState?.changes?.marketId || false;
return (
<span>
{t('Update market state')}
{marketId && <MarketLink className="ml-1" id={marketId} />}
</span>
);
} else if (item.updateNetworkParameter) {
const param = item?.updateNetworkParameter?.changes?.key || false;
return (
<span>
{t('Update network parameter')}
{param && <NetworkParameterLink className="ml-1" parameter={param} />}
</span>
);
} else if (item.updateReferralProgram) {
return <span>{t('Update referral program')}</span>;
} else if (item.updateSpotMarket) {
const marketId = item?.updateSpotMarket?.marketId || '';
return (
<span>
{t('Update spot market')}
<MarketLink className="ml-1" id={marketId} />
</span>
);
} else if (item.updateVolumeDiscountProgram) {
return <span>{t('Update volume discount program')}</span>;
}
return <span>{t('Unknown proposal type')}</span>;
};
@@ -1,6 +1,4 @@
import type { ProposalTerms } from '../tx-proposal';
import { useState } from 'react';
import type { components } from '../../../../../types/explorer';
import { JsonViewerDialog } from '../../../dialogs/json-viewer-dialog';
import ProposalLink from '../../../links/proposal-link/proposal-link';
import truncate from 'lodash/truncate';
@@ -9,7 +7,12 @@ import ReactMarkdown from 'react-markdown';
import { ProposalDate } from './proposal-date';
import { t } from '@vegaprotocol/i18n';
import type { ProposalTerms } from '../tx-proposal';
import type { components } from '../../../../../types/explorer';
import { BatchItem } from './batch-item';
type Rationale = components['schemas']['vegaProposalRationale'];
type Batch = components['schemas']['v1BatchProposalSubmissionTerms']['changes'];
type ProposalTermsDialog = {
open: boolean;
@@ -21,6 +24,7 @@ interface ProposalSummaryProps {
id: string;
rationale?: Rationale;
terms?: ProposalTerms;
batch?: Batch;
}
/**
@@ -31,6 +35,7 @@ export const ProposalSummary = ({
id,
rationale,
terms,
batch,
}: ProposalSummaryProps) => {
const [dialog, setDialog] = useState<ProposalTermsDialog>({
open: false,
@@ -72,6 +77,18 @@ export const ProposalSummary = ({
</ReactMarkdown>
</div>
)}
{batch && (
<section className="pt-2 text-sm leading-tight my-3">
<h2 className="text-lg pb-1">{t('Changes')}</h2>
<ol>
{batch.map((change, index) => (
<li className="ml-4 list-decimal" key={`batch-${index}`}>
<BatchItem item={change} />
</li>
))}
</ol>
</section>
)}
<div className="pt-5">
<button className="underline max-md:hidden mr-5" onClick={openDialog}>
{t('View terms')}
@@ -0,0 +1,75 @@
import type { BlockExplorerTransactionResult } from '../../../routes/types/block-explorer-response';
import type { TendermintBlocksResponse } from '../../../routes/blocks/tendermint-blocks-response';
import { sharedHeaderProps, TxDetailsShared } from './shared/tx-details-shared';
import { TableCell, TableRow, TableWithTbody } from '../../table';
import type { components } from '../../../../types/explorer';
import { txSignatureToDeterministicId } from '../lib/deterministic-ids';
import { ProposalSummary } from './proposal/summary';
import Hash from '../../links/hash';
import { t } from '@vegaprotocol/i18n';
export type Proposal = components['schemas']['v1BatchProposalSubmission'];
export type ProposalTerms = components['schemas']['vegaProposalTerms'];
interface TxBatchProposalProps {
txData: BlockExplorerTransactionResult | undefined;
pubKey: string | undefined;
blockData: TendermintBlocksResponse | undefined;
}
/**
*
*/
export const TxBatchProposal = ({
txData,
pubKey,
blockData,
}: TxBatchProposalProps) => {
if (!txData || !txData.command.batchProposalSubmission) {
return <>{t('Awaiting Block Explorer transaction details')}</>;
}
let deterministicId = '';
const proposal: Proposal = txData.command.batchProposalSubmission;
const sig = txData?.signature?.value;
if (sig) {
deterministicId = txSignatureToDeterministicId(sig);
}
return (
<>
<TableWithTbody className="mb-8" allowWrap={true}>
<TableRow modifier="bordered">
<TableCell {...sharedHeaderProps}>{t('Type')}</TableCell>
<TableCell>{t('Batch proposal')}</TableCell>
</TableRow>
<TxDetailsShared
txData={txData}
pubKey={pubKey}
blockData={blockData}
hideTypeRow={true}
/>
<TableRow modifier="bordered">
<TableCell>{t('Batch size')}</TableCell>
<TableCell>
{proposal.terms?.changes?.length || t('No changes')}
</TableCell>
</TableRow>
<TableRow modifier="bordered">
<TableCell>{t('Proposal ID')}</TableCell>
<TableCell>
<Hash text={deterministicId} />
</TableCell>
</TableRow>
</TableWithTbody>
{proposal && (
<ProposalSummary
id={deterministicId}
rationale={proposal?.rationale}
terms={proposal.terms}
batch={proposal.terms?.changes}
/>
)}
</>
);
};
@@ -33,6 +33,7 @@ import { TxDetailsApplyReferralCode } from './tx-apply-referral-code';
import { TxDetailsUpdateReferralSet } from './tx-update-referral-set';
import { TxDetailsJoinTeam } from './tx-join-team';
import { TxDetailsUpdateMarginMode } from './tx-update-margin-mode';
import { TxBatchProposal } from './tx-batch-proposal';
interface TxDetailsWrapperProps {
txData: BlockExplorerTransactionResult | undefined;
@@ -136,6 +137,8 @@ function getTransactionComponent(txData?: BlockExplorerTransactionResult) {
return TxDetailsJoinTeam;
case 'Update Margin Mode':
return TxDetailsUpdateMarginMode;
case 'Batch Proposal':
return TxBatchProposal;
default:
return TxDetailsGeneric;
}
@@ -20,6 +20,7 @@ export type FilterOption =
| 'Amend Order'
| 'Apply Referral Code'
| 'Batch Market Instructions'
| 'Batch Proposal'
| 'Cancel LiquidityProvision Order'
| 'Cancel Order'
| 'Cancel Transfer Funds'
@@ -67,7 +68,13 @@ export const filterOptions: Record<string, FilterOption[]> = {
'Cancel Transfer Funds',
'Withdraw',
],
Governance: ['Delegate', 'Undelegate', 'Vote on Proposal', 'Proposal'],
Governance: [
'Batch Proposal',
'Delegate',
'Undelegate',
'Vote on Proposal',
'Proposal',
],
Referrals: [
'Apply Referral Code',
'Create Referral Set',
@@ -1,5 +1,10 @@
import { useSearchParams } from 'react-router-dom';
import { Intent, TradingAnchorButton } from '@vegaprotocol/ui-toolkit';
import { Link, useSearchParams } from 'react-router-dom';
import {
Intent,
TradingAnchorButton,
VegaIcon,
VegaIconNames,
} from '@vegaprotocol/ui-toolkit';
import { useVegaWallet, useVegaWalletDialogStore } from '@vegaprotocol/wallet';
import { addDecimalsFormatNumber } from '@vegaprotocol/utils';
import { useT } from '../../lib/use-t';
@@ -30,6 +35,19 @@ export const CompetitionsCreateTeam = () => {
<LayoutWithGradient>
<div className="mx-auto md:w-2/3 max-w-xl">
<Box className="flex flex-col gap-4">
<Link
to={Links.COMPETITIONS()}
className="text-xs inline-flex items-center gap-1 group"
>
<VegaIcon
name={VegaIconNames.CHEVRON_LEFT}
size={12}
className="text-vega-clight-100 dark:text-vega-cdark-100"
/>{' '}
<span className="group-hover:underline">
{t('Go back to the competitions')}
</span>
</Link>
<h1 className="calt text-2xl lg:text-3xl xl:text-4xl">
{isSolo ? t('Create solo team') : t('Create a team')}
</h1>
@@ -71,18 +89,29 @@ const CreateTeamFormContainer = ({ isSolo }: { isSolo: boolean }) => {
if (status === 'confirmed') {
return (
<div className="flex flex-col items-start gap-2">
<div
className="flex flex-col items-start gap-2"
data-testid="team-creation-success-message"
>
<p className="text-sm">{t('Team creation transaction successful')}</p>
{code && (
<>
<p className="text-sm">
Your team ID is:{' '}
<span className="font-mono break-all">{code}</span>
</p>
<dl>
<dt className="text-sm">{t('Your team ID:')}</dt>
<dl>
<span
className="font-mono break-all bg-rainbow bg-clip-text text-transparent text-2xl"
data-testid="team-id-display"
>
{code}
</span>
</dl>
</dl>
<TradingAnchorButton
href={Links.COMPETITIONS_TEAM(code)}
intent={Intent.Info}
size="small"
data-testid="view-team-button"
>
{t('View team')}
</TradingAnchorButton>
@@ -16,6 +16,8 @@ import { CompetitionsLeaderboard } from '../../components/competitions/competiti
import { useTeams } from '../../lib/hooks/use-teams';
import take from 'lodash/take';
import { usePageTitle } from '../../lib/hooks/use-page-title';
import { TeamCard } from '../../components/competitions/team-card';
import { useMyTeam } from '../../lib/hooks/use-my-team';
export const CompetitionsHome = () => {
const t = useT();
@@ -33,6 +35,13 @@ export const CompetitionsHome = () => {
const { data: teamsData, loading: teamsLoading } = useTeams();
const {
team: myTeam,
stats: myTeamStats,
games: myTeamGames,
rank: myTeamRank,
} = useMyTeam();
return (
<ErrorBoundary>
<CompetitionsHeader title={t('Competitions')}>
@@ -43,65 +52,83 @@ export const CompetitionsHome = () => {
</p>
</CompetitionsHeader>
{/** Get started */}
<h2 className="text-2xl mb-6">{t('Get started')}</h2>
{/** Team card */}
{myTeam ? (
<>
<h2 className="text-2xl mb-6">{t('My team')}</h2>
<div className="mb-12">
<TeamCard
team={myTeam}
rank={myTeamRank}
stats={myTeamStats}
games={myTeamGames}
/>
</div>
</>
) : (
<>
{/** Get started */}
<h2 className="text-2xl mb-6">{t('Get started')}</h2>
<CompetitionsActionsContainer>
<CompetitionsAction
variant="A"
title={t('Create a team')}
description={t(
'Lorem ipsum dolor sit amet, consectetur adipisicing elit placeat ipsum minus nemo error dicta.'
)}
actionElement={
<TradingButton
intent={Intent.Primary}
onClick={(e) => {
e.preventDefault();
navigate(Links.COMPETITIONS_CREATE_TEAM());
}}
>
{t('Create a public team')}
</TradingButton>
}
/>
<CompetitionsAction
variant="B"
title={t('Solo team / lone wolf')}
description={t(
'Lorem ipsum dolor sit amet, consectetur adipisicing elit placeat ipsum minus nemo error dicta.'
)}
actionElement={
<TradingButton
intent={Intent.Primary}
onClick={(e) => {
e.preventDefault();
navigate(Links.COMPETITIONS_CREATE_TEAM_SOLO());
}}
>
{t('Create a private team')}
</TradingButton>
}
/>
<CompetitionsAction
variant="C"
title={t('Join a team')}
description={t(
'Lorem ipsum dolor sit amet, consectetur adipisicing elit placeat ipsum minus nemo error dicta.'
)}
actionElement={
<TradingButton
intent={Intent.Primary}
onClick={(e) => {
e.preventDefault();
navigate(Links.COMPETITIONS_TEAMS());
}}
>
{t('Choose a team')}
</TradingButton>
}
/>
</CompetitionsActionsContainer>
<CompetitionsActionsContainer>
<CompetitionsAction
variant="A"
title={t('Create a team')}
description={t(
'Lorem ipsum dolor sit amet, consectetur adipisicing elit placeat ipsum minus nemo error dicta.'
)}
actionElement={
<TradingButton
intent={Intent.Primary}
onClick={(e) => {
e.preventDefault();
navigate(Links.COMPETITIONS_CREATE_TEAM());
}}
data-testId="create-public-team-button"
>
{t('Create a public team')}
</TradingButton>
}
/>
<CompetitionsAction
variant="B"
title={t('Solo team / lone wolf')}
description={t(
'Lorem ipsum dolor sit amet, consectetur adipisicing elit placeat ipsum minus nemo error dicta.'
)}
actionElement={
<TradingButton
intent={Intent.Primary}
onClick={(e) => {
e.preventDefault();
navigate(Links.COMPETITIONS_CREATE_TEAM_SOLO());
}}
>
{t('Create a private team')}
</TradingButton>
}
/>
<CompetitionsAction
variant="C"
title={t('Join a team')}
description={t(
'Lorem ipsum dolor sit amet, consectetur adipisicing elit placeat ipsum minus nemo error dicta.'
)}
actionElement={
<TradingButton
intent={Intent.Primary}
onClick={(e) => {
e.preventDefault();
navigate(Links.COMPETITIONS_TEAMS());
}}
>
{t('Choose a team')}
</TradingButton>
}
/>
</CompetitionsActionsContainer>
</>
)}
{/** List of available games */}
<h2 className="text-2xl mb-6">{t('Games')}</h2>
@@ -3,7 +3,14 @@ import { usePageTitle } from '../../lib/hooks/use-page-title';
import { Box } from '../../components/competitions/box';
import { useT } from '../../lib/use-t';
import { useVegaWallet, useVegaWalletDialogStore } from '@vegaprotocol/wallet';
import { Loader, Splash } from '@vegaprotocol/ui-toolkit';
import {
Intent,
Loader,
Splash,
TradingAnchorButton,
VegaIcon,
VegaIconNames,
} from '@vegaprotocol/ui-toolkit';
import { RainbowButton } from '../../components/rainbow-button';
import { Link, Navigate, useParams } from 'react-router-dom';
import { Links } from '../../lib/links';
@@ -11,6 +18,7 @@ import { useReferralSetTransaction } from '../../lib/hooks/use-referral-set-tran
import { type FormFields, TeamForm, TransactionType } from './team-form';
import { useTeam } from '../../lib/hooks/use-team';
import { LayoutWithGradient } from '../../components/layouts-inner';
import { useEffect, useState } from 'react';
export const CompetitionsUpdateTeam = () => {
const t = useT();
@@ -29,6 +37,19 @@ export const CompetitionsUpdateTeam = () => {
<LayoutWithGradient>
<div className="mx-auto md:w-2/3 max-w-xl">
<Box className="flex flex-col gap-4">
<Link
to={Links.COMPETITIONS_TEAM(teamId)}
className="text-xs inline-flex items-center gap-1 group"
>
<VegaIcon
name={VegaIconNames.CHEVRON_LEFT}
size={12}
className="text-vega-clight-100 dark:text-vega-cdark-100"
/>{' '}
<span className="group-hover:underline">
{t('Go back to the team profile')}
</span>
</Link>
<h1 className="calt text-2xl lg:text-3xl xl:text-5xl">
{t('Update a team')}
</h1>
@@ -57,7 +78,8 @@ const UpdateTeamFormContainer = ({
pubKey: string;
}) => {
const t = useT();
const { team, loading, error } = useTeam(teamId, pubKey);
const [refetching, setRefetching] = useState<boolean>(false);
const { team, loading, error, refetch } = useTeam(teamId, pubKey);
const { err, status, onSubmit } = useReferralSetTransaction({
onSuccess: () => {
@@ -65,7 +87,15 @@ const UpdateTeamFormContainer = ({
},
});
if (loading) {
// refetch when saved
useEffect(() => {
if (refetch && status === 'confirmed') {
refetch();
setRefetching(true);
}
}, [refetch, status]);
if (loading && !refetching) {
return <Loader size="small" />;
}
if (error) {
@@ -84,6 +114,33 @@ const UpdateTeamFormContainer = ({
return <Navigate to={Links.COMPETITIONS_TEAM(teamId)} />;
}
if (status === 'confirmed') {
return (
<div
className="flex flex-col items-start gap-2"
data-testid="team-creation-success-message"
>
<p className="text-sm">
<VegaIcon
name={VegaIconNames.TICK}
size={18}
className="text-vega-green-500"
/>{' '}
{t('Changes successfully saved to your team.')}
</p>
<TradingAnchorButton
href={Links.COMPETITIONS_TEAM(teamId)}
intent={Intent.Info}
size="small"
data-testid="view-team-button"
>
{t('View team')}
</TradingAnchorButton>
</div>
);
}
const defaultValues: FormFields = {
id: team.teamId,
name: team.name,
@@ -105,7 +105,11 @@ export const JoinButton = ({
// Party is in a team, but not this one
else if (partyTeam && partyTeam.teamId !== team.teamId) {
return (
<Button onClick={() => onJoin('switch')} intent={Intent.Primary}>
<Button
onClick={() => onJoin('switch')}
intent={Intent.Primary}
data-testid="switch-team-button"
>
{t('Switch team')}{' '}
</Button>
);
@@ -215,7 +219,11 @@ const DialogContent = ({
</>
)}
<div className="flex justify-between gap-2">
<Button onClick={joinTeam} intent={Intent.Success}>
<Button
onClick={joinTeam}
intent={Intent.Success}
data-testid="confirm-switch-button"
>
{t('Confirm')}
</Button>
<Button onClick={onCancel} intent={Intent.Danger}>
@@ -6,6 +6,8 @@ import {
TextArea,
TradingButton,
Intent,
VegaIcon,
VegaIconNames,
} from '@vegaprotocol/ui-toolkit';
import { URL_REGEX, isValidVegaPublicKey } from '@vegaprotocol/utils';
@@ -17,6 +19,8 @@ import type {
UpdateReferralSet,
Status,
} from '@vegaprotocol/wallet';
import classNames from 'classnames';
import { useLayoutEffect, useState } from 'react';
export type FormFields = {
id: string;
@@ -111,9 +115,12 @@ export const TeamForm = ({
<form onSubmit={handleSubmit(sendTransaction)}>
<input type="hidden" {...register('id')} />
<TradingFormGroup label={t('Team name')} labelFor="name">
<TradingInput {...register('name', { required: t('Required') })} />
<TradingInput
{...register('name', { required: t('Required') })}
data-testid="team-name-input"
/>
{errors.name?.message && (
<TradingInputError forInput="name">
<TradingInputError forInput="name" data-testid="team-name-error">
{errors.name.message}
</TradingInputError>
)}
@@ -129,9 +136,10 @@ export const TeamForm = ({
{...register('url', {
pattern: { value: URL_REGEX, message: t('Invalid URL') },
})}
data-testid="team-url-input"
/>
{errors.url?.message && (
<TradingInputError forInput="url">
<TradingInputError forInput="url" data-testid="team-url-error">
{errors.url.message}
</TradingInputError>
)}
@@ -148,9 +156,13 @@ export const TeamForm = ({
message: t('Invalid image URL'),
},
})}
data-testid="avatar-url-input"
/>
{errors.avatarUrl?.message && (
<TradingInputError forInput="avatarUrl">
<TradingInputError
forInput="avatarUrl"
data-testid="avatar-url-error"
>
{errors.avatarUrl.message}
</TradingInputError>
)}
@@ -175,6 +187,7 @@ export const TeamForm = ({
onCheckedChange={(value) => {
field.onChange(value);
}}
data-testid="team-private-checkbox"
/>
);
}}
@@ -203,9 +216,13 @@ export const TeamForm = ({
},
},
})}
data-testid="team-allow-list-textarea"
/>
{errors.allowList?.message && (
<TradingInputError forInput="avatarUrl">
<TradingInputError
forInput="avatarUrl"
data-testid="team-allow-list-error"
>
{errors.allowList.message}
</TradingInputError>
)}
@@ -239,16 +256,57 @@ const SubmitButton = ({
text = t('Update');
}
let confirmedText = t('Created');
if (type === TransactionType.UpdateReferralSet) {
confirmedText = t('Updated');
}
if (status === 'requested') {
text = t('Confirm in wallet...');
} else if (status === 'pending') {
text = t('Confirming transaction...');
}
const [showConfirmed, setShowConfirmed] = useState<boolean>(false);
useLayoutEffect(() => {
let to: ReturnType<typeof setTimeout>;
if (status === 'confirmed' && !showConfirmed) {
to = setTimeout(() => {
setShowConfirmed(true);
}, 100);
}
return () => {
clearTimeout(to);
};
}, [showConfirmed, status]);
const confirmed = (
<span
className={classNames('text-sm transition-opacity opacity-0', {
'opacity-100': showConfirmed,
})}
>
<VegaIcon
name={VegaIconNames.TICK}
size={18}
className="text-vega-green-500"
/>{' '}
{confirmedText}
</span>
);
return (
<TradingButton type="submit" intent={Intent.Info} disabled={disabled}>
{text}
</TradingButton>
<div className="flex gap-2 items-baseline">
<TradingButton
type="submit"
intent={Intent.Info}
disabled={disabled}
data-testid="team-form-submit-button"
>
{text}
</TradingButton>
{status === 'confirmed' && confirmed}
</div>
);
};
@@ -1,16 +1,24 @@
import { useVegaWallet } from '@vegaprotocol/wallet';
import { type Team } from '../../lib/hooks/use-team';
import { type ComponentProps } from 'react';
import { useVegaWallet } from '@vegaprotocol/wallet';
import { Intent, TradingAnchorButton } from '@vegaprotocol/ui-toolkit';
import { Links } from '../../lib/links';
import { useT } from '../../lib/use-t';
export const UpdateTeamButton = ({ team }: { team: Team }) => {
export const UpdateTeamButton = ({
team,
size = 'medium',
}: {
team: Pick<Team, 'teamId' | 'referrer'>;
size?: ComponentProps<typeof TradingAnchorButton>['size'];
}) => {
const t = useT();
const { pubKey, isReadOnly } = useVegaWallet();
if (pubKey && !isReadOnly && pubKey === team.referrer) {
return (
<TradingAnchorButton
size={size}
data-testid="update-team-button"
href={Links.COMPETITIONS_UPDATE_TEAM(team.teamId)}
intent={Intent.Info}
+20 -3
View File
@@ -5,7 +5,11 @@ export const BORDER_COLOR = 'border-vega-clight-500 dark:border-vega-cdark-500';
export const GRADIENT =
'bg-gradient-to-b from-vega-clight-800 dark:from-vega-cdark-800 to-transparent';
export const Box = (props: HTMLAttributes<HTMLDivElement>) => {
export const Box = ({
children,
backgroundImage,
...props
}: HTMLAttributes<HTMLDivElement> & { backgroundImage?: string }) => {
return (
<div
{...props}
@@ -13,9 +17,22 @@ export const Box = (props: HTMLAttributes<HTMLDivElement>) => {
BORDER_COLOR,
GRADIENT,
'border rounded-lg',
'p-6',
'relative p-6 overflow-hidden',
props.className
)}
/>
>
{Boolean(backgroundImage?.length) && (
<div
className={classNames(
'pointer-events-none',
'bg-no-repeat bg-center bg-[length:500px_500px]',
'absolute top-0 left-0 w-full h-full -z-10 opacity-30 blur-lg'
)}
style={{ backgroundImage: `url("${backgroundImage}")` }}
></div>
)}
{children}
</div>
);
};
@@ -1,9 +1,11 @@
import { isValidUrl } from '@vegaprotocol/utils';
import classNames from 'classnames';
import { useEffect, useState } from 'react';
const NUM_AVATARS = 20;
const AVATAR_PATHNAME_PATTERN = '/team-avatars/{id}.png';
const getFallbackAvatar = (teamId: string) => {
export const getFallbackAvatar = (teamId: string) => {
const avatarId = ((parseInt(teamId, 16) % NUM_AVATARS) + 1)
.toString()
.padStart(2, '0'); // between 01 - 20
@@ -11,6 +13,26 @@ const getFallbackAvatar = (teamId: string) => {
return AVATAR_PATHNAME_PATTERN.replace('{id}', avatarId);
};
const useAvatar = (teamId: string, url: string) => {
const fallback = getFallbackAvatar(teamId);
const [avatar, setAvatar] = useState<string>(fallback);
useEffect(() => {
if (!isValidUrl(url)) return;
fetch(url, { cache: 'force-cache' })
.then((response) => {
if (response.ok) {
setAvatar(url);
}
})
.catch(() => {
/** noop */
});
});
return avatar;
};
export const TeamAvatar = ({
teamId,
imgUrl,
@@ -22,7 +44,7 @@ export const TeamAvatar = ({
alt?: string;
size?: 'large' | 'small';
}) => {
const img = imgUrl && imgUrl.length > 0 ? imgUrl : getFallbackAvatar(teamId);
const img = useAvatar(teamId, imgUrl);
return (
// eslint-disable-next-line @next/next/no-img-element
<img
@@ -0,0 +1,154 @@
import { type TeamGame, type TeamStats } from '../../lib/hooks/use-team';
import { type TeamsFieldsFragment } from '../../lib/hooks/__generated__/Teams';
import { TeamAvatar, getFallbackAvatar } from './team-avatar';
import { FavoriteGame, Stat } from './team-stats';
import { useT } from '../../lib/use-t';
import { formatNumberRounded } from '@vegaprotocol/utils';
import BigNumber from 'bignumber.js';
import { Box } from './box';
import { Intent, Tooltip, TradingAnchorButton } from '@vegaprotocol/ui-toolkit';
import { Links } from '../../lib/links';
import orderBy from 'lodash/orderBy';
import { take } from 'lodash';
import { DispatchMetricLabels } from '@vegaprotocol/types';
import classNames from 'classnames';
import { UpdateTeamButton } from '../../client-pages/competitions/update-team-button';
export const TeamCard = ({
rank,
team,
stats,
games,
}: {
rank: number;
team: TeamsFieldsFragment;
stats?: TeamStats;
games?: TeamGame[];
}) => {
const t = useT();
const lastGames = take(
orderBy(
games?.map((g) => ({
rank: g.team.rank,
metric: g.team.rewardMetric,
epoch: g.epoch,
})),
(i) => i.epoch,
'desc'
),
5
);
return (
<div
className={classNames(
'gap-6 grid grid-cols-1 grid-rows-1',
'md:grid-cols-3'
)}
>
{/** Card */}
<Box
backgroundImage={team.avatarUrl || getFallbackAvatar(team.teamId)}
className="flex flex-col items-center gap-3 min-w-[80px] lg:min-w-[112px]"
>
<TeamAvatar teamId={team.teamId} imgUrl={team.avatarUrl} />
<h1 className="calt lg:text-2xl" data-testid="team-name">
{team.name}
</h1>
{games && <FavoriteGame games={games} noLabel />}
<TradingAnchorButton
size="extra-small"
intent={Intent.Primary}
href={Links.COMPETITIONS_TEAM(team.teamId)}
>
{t('Profile')}
</TradingAnchorButton>
<UpdateTeamButton team={team} size="extra-small" />
</Box>
{/** Tiles */}
<Box className="w-full md:col-span-2">
<div
className={classNames(
'grid gap-3 w-full mb-4',
'md:grid-cols-3 md:grid-rows-2',
'grid-cols-2 grid-rows-3'
)}
>
<Stat
className="flex flex-col-reverse"
value={rank}
label={t('Rank')}
valueTestId="team-rank"
/>
<Stat
className="flex flex-col-reverse"
value={team.totalMembers || 0}
label={t('Members')}
valueTestId="members-count-stat"
/>
<Stat
className="flex flex-col-reverse"
value={stats?.totalGamesPlayed || 0}
label={t('Total games')}
valueTestId="total-games-stat"
/>
<Stat
className="flex flex-col-reverse"
value={
stats?.totalQuantumVolume
? formatNumberRounded(
new BigNumber(stats.totalQuantumVolume || 0),
'1e3'
)
: 0
}
label={t('Total volume')}
valueTestId="total-volume-stat"
/>
<Stat
className="flex flex-col-reverse"
value={
stats?.totalQuantumRewards
? formatNumberRounded(
new BigNumber(stats.totalQuantumRewards || 0),
'1e3'
)
: 0
}
label={t('Rewards paid out')}
valueTestId="rewards-paid-stat"
/>
</div>
<dl className="w-full pt-4 border-t border-vega-clight-700 dark:border-vega-cdark-700">
<dt className="mb-1 text-sm text-muted">
{t('Last {{games}} games result', {
replace: { games: lastGames.length || '' },
})}
</dt>
<dd className="flex flex-row flex-wrap gap-2">
{lastGames.length === 0 && t('None available')}
{lastGames.map((game, i) => (
<Tooltip key={i} description={DispatchMetricLabels[game.metric]}>
<button className="cursor-help text-sm bg-vega-clight-700 dark:bg-vega-cdark-700 px-2 py-1 rounded-full">
<RankLabel rank={game.rank} />
</button>
</Tooltip>
))}
</dd>
</dl>
</Box>
</div>
);
};
/**
* Sets the english ordinal for given rank only if the current language is set
* to english.
*/
const RankLabel = ({ rank }: { rank: number }) => {
const t = useT();
return t('place', { count: rank, ordinal: true });
};
@@ -15,6 +15,7 @@ import {
} from '../../lib/hooks/use-team';
import { useT } from '../../lib/use-t';
import { DispatchMetricLabels, type DispatchMetric } from '@vegaprotocol/types';
import classNames from 'classnames';
export const TeamStats = ({
stats,
@@ -102,7 +103,13 @@ const LatestResults = ({ games }: { games: TeamGame[] }) => {
);
};
const FavoriteGame = ({ games }: { games: TeamGame[] }) => {
export const FavoriteGame = ({
games,
noLabel = false,
}: {
games: TeamGame[];
noLabel?: boolean;
}) => {
const t = useT();
const rewardMetrics = games.map(
@@ -128,7 +135,13 @@ const FavoriteGame = ({ games }: { games: TeamGame[] }) => {
return (
<dl className="flex flex-col gap-1">
<dt className="text-muted text-sm">{t('Favorite game')}</dt>
<dt
className={classNames('text-muted text-sm', {
hidden: noLabel,
})}
>
{t('Favorite game')}
</dt>
<dd>
<Pill className="inline-flex items-center gap-1 bg-transparent text-sm">
<VegaIcon
@@ -142,7 +155,7 @@ const FavoriteGame = ({ games }: { games: TeamGame[] }) => {
);
};
const StatSection = ({ children }: { children: ReactNode }) => {
export const StatSection = ({ children }: { children: ReactNode }) => {
return (
<section className="flex flex-col lg:flex-row gap-4 lg:gap-8">
{children}
@@ -150,11 +163,11 @@ const StatSection = ({ children }: { children: ReactNode }) => {
);
};
const StatSectionSeparator = () => {
export const StatSectionSeparator = () => {
return <div className="hidden md:block border-r border-default" />;
};
const StatList = ({ children }: { children: ReactNode }) => {
export const StatList = ({ children }: { children: ReactNode }) => {
return (
<dl className="grid grid-cols-2 md:flex gap-4 md:gap-6 lg:gap-8 whitespace-nowrap">
{children}
@@ -162,19 +175,21 @@ const StatList = ({ children }: { children: ReactNode }) => {
);
};
const Stat = ({
export const Stat = ({
value,
label,
tooltip,
valueTestId,
className,
}: {
value: ReactNode;
label: ReactNode;
tooltip?: string;
valueTestId?: string;
className?: classNames.Argument;
}) => {
return (
<div>
<div className={classNames(className)}>
<dd className="text-3xl lg:text-4xl" data-testid={valueTestId}>
{value}
</dd>
@@ -152,7 +152,11 @@ export const ActiveRewards = ({ currentEpoch }: { currentEpoch: number }) => {
if (!enrichedTransfers || !enrichedTransfers.length) return null;
return (
<Card title={t('Active rewards')} className="lg:col-span-full">
<Card
title={t('Active rewards')}
className="lg:col-span-full"
data-testid="active-rewards-card"
>
{enrichedTransfers.length > 1 && (
<TradingInput
onChange={(e) =>
@@ -312,49 +316,30 @@ export const ActiveRewardCard = ({
MarketState.STATE_CLOSED,
].includes(m.state)
);
if (marketSettled) {
return null;
}
const assetInSettledMarket =
const assetInActiveMarket =
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 m?.state && MarketState.STATE_ACTIVE === m.state;
}
return false;
});
// Gray out the cards that are related to suspended markets
const suspended = transferNode.markets?.some(
const marketSuspended = 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
// Or settlement assets in markets that are not active and eligible for rewards
const { gradientClassName, mainClassName } =
suspended || assetInSuspendedMarket || assetInSettledMarket
marketSuspended || !assetInActiveMarket
? {
gradientClassName: 'from-vega-cdark-500 to-vega-clight-400',
mainClassName: 'from-vega-cdark-400 dark:from-vega-cdark-600 to-20%',
@@ -371,6 +356,7 @@ export const ActiveRewardCard = ({
'rounded-lg',
gradientClassName
)}
data-testid="active-rewards-card"
>
<div
className={classNames(
@@ -382,7 +368,7 @@ export const ActiveRewardCard = ({
<div className="flex flex-col gap-2 items-center text-center">
<EntityIcon transfer={transfer} />
{entityScope && (
<span className="text-muted text-xs">
<span className="text-muted text-xs" data-testid="entity-scope">
{EntityScopeLabelMapping[entityScope] || t('Unspecified')}
</span>
)}
@@ -390,7 +376,7 @@ export const ActiveRewardCard = ({
<div className="flex flex-col gap-2 items-center text-center">
<h3 className="flex flex-col gap-1 text-2xl shrink-1 text-center">
<span className="font-glitch">
<span className="font-glitch" data-testid="reward-value">
{addDecimalsFormatNumber(
transferNode.transfer.amount,
transferNode.transfer.asset?.decimals || 0,
@@ -411,7 +397,7 @@ export const ActiveRewardCard = ({
)}
underline={true}
>
<span className="text-xs">
<span className="text-xs" data-testid="distribution-strategy">
{
DistributionStrategyMapping[
dispatchStrategy.distributionStrategy
@@ -429,7 +415,10 @@ export const ActiveRewardCard = ({
'Number of epochs after distribution to delay vesting of rewards by'
)}
/>
<span className="text-muted text-xs whitespace-nowrap">
<span
className="text-muted text-xs whitespace-nowrap"
data-testid="locked-for"
>
{t('numberEpochs', '{{count}} epochs', {
count: kind.dispatchStrategy?.lockPeriod,
})}
@@ -438,15 +427,15 @@ export const ActiveRewardCard = ({
</div>
<span className="border-[0.5px] border-gray-700" />
<span>
<span data-testid="dispatch-metric-info">
{DispatchMetricLabels[dispatchStrategy.dispatchMetric]} {' '}
<Tooltip
underline={suspended}
underline={marketSuspended}
description={
(suspended || assetInSuspendedMarket) &&
(marketSuspended || !assetInActiveMarket) &&
(specificMarkets
? t('Eligible market(s) currently suspended')
: assetInSuspendedMarket
: !assetInActiveMarket
? t('Currently no markets eligible for reward')
: '')
}
@@ -458,8 +447,8 @@ export const ActiveRewardCard = ({
<div className="flex items-center gap-8 flex-wrap">
{kind.endEpoch && (
<span className="flex flex-col">
<span className="text-muted text-xs">{t('Ends in')}</span>
<span>
<span className="text-muted text-xs">{t('Ends in')} </span>
<span data-testid="ends-in">
{t('numberEpochs', '{{count}} epochs', {
count: kind.endEpoch - currentEpoch,
})}
@@ -470,7 +459,7 @@ export const ActiveRewardCard = ({
{
<span className="flex flex-col">
<span className="text-muted text-xs">{t('Assessed over')}</span>
<span>
<span data-testid="assessed-over">
{t('numberEpochs', '{{count}} epochs', {
count: dispatchStrategy.windowLength,
})}
@@ -513,7 +502,7 @@ const RewardRequirements = ({
entity: EntityScopeLabelMapping[dispatchStrategy.entityScope],
})}
</dt>
<dd className="flex items-center gap-1">
<dd className="flex items-center gap-1" data-testid="scope">
<RewardEntityScope dispatchStrategy={dispatchStrategy} />
</dd>
</div>
@@ -522,7 +511,10 @@ const RewardRequirements = ({
<dt className="flex items-center gap-1 text-muted">
{t('Staked VEGA')}
</dt>
<dd className="flex items-center gap-1">
<dd
className="flex items-center gap-1"
data-testid="staking-requirement"
>
{addDecimalsFormatNumber(
dispatchStrategy?.stakingRequirement || 0,
assetDecimalPlaces
@@ -534,7 +526,7 @@ const RewardRequirements = ({
<dt className="flex items-center gap-1 text-muted">
{t('Average position')}
</dt>
<dd className="flex items-center gap-1">
<dd className="flex items-center gap-1" data-testid="average-position">
{addDecimalsFormatNumber(
dispatchStrategy?.notionalTimeWeightedAveragePositionRequirement ||
0,
@@ -81,6 +81,7 @@ export const Settings = () => {
intent={Intent.Primary}
onClick={() => {
localStorage.clear();
sessionStorage.clear();
window.location.reload();
}}
>
@@ -33,7 +33,7 @@ def test_should_display_info_and_button_for_deposit(continuous_market, page: Pag
"You may not have enough margin available to open this position.")
page.get_by_test_id(deal_ticket_warning_margin).hover()
expect(page.get_by_test_id("tooltip-content").nth(0)).to_have_text(
"1,661,896.6317 tDAI is currently required.You have only 1,000,000.00.Deposit tDAI")
"1,661,888.12901 tDAI is currently required.You have only 999,991.49731.Deposit tDAI")
page.get_by_test_id(deal_ticket_deposit_dialog_button).nth(0).click()
expect(page.get_by_test_id("sidebar-content")
).to_contain_text("DepositFrom")
@@ -59,7 +59,7 @@ def test_filtered_cards(continuous_market, vega: VegaServiceNull, page: Page):
next_epoch(vega=vega)
page.reload()
expect(page.locator(".from-vega-cdark-400")).to_be_visible(timeout=15000)
expect(page.get_by_test_id("active-rewards-card")).to_be_visible(timeout=15000)
governance.submit_oracle_data(
wallet=vega.wallet,
payload={"trading.terminated": "true"},
@@ -67,4 +67,4 @@ def test_filtered_cards(continuous_market, vega: VegaServiceNull, page: Page):
)
next_epoch(vega=vega)
page.reload()
expect(page.locator(".from-vega-cdark-400")).not_to_be_in_viewport()
expect(page.get_by_test_id("active-rewards-card")).not_to_be_in_viewport()
+160 -43
View File
@@ -3,7 +3,7 @@ from playwright.sync_api import expect, Page
import vega_sim.proto.vega as vega_protos
from vega_sim.null_service import VegaServiceNull
from conftest import init_vega
from actions.utils import next_epoch
from actions.utils import next_epoch, change_keys
from fixtures.market import setup_continuous_market
from conftest import auth_setup, init_page, init_vega, risk_accepted_setup
from wallet_config import PARTY_A, PARTY_B, PARTY_C, PARTY_D, MM_WALLET
@@ -14,6 +14,7 @@ def vega(request):
with init_vega(request) as vega:
yield vega
@pytest.fixture(scope="module")
def team_page(vega, browser, request, setup_teams_and_games):
with init_page(vega, browser, request) as page:
@@ -23,9 +24,20 @@ def team_page(vega, browser, request, setup_teams_and_games):
page.goto(f"/#/competitions/teams/{team_id}")
yield page
@pytest.fixture(scope="module")
def competitions_page(vega, browser, request, setup_teams_and_games):
with init_page(vega, browser, request) as page:
risk_accepted_setup(page)
auth_setup(vega, page)
team_id = setup_teams_and_games["team_id"]
page.goto(f"/#/competitions/")
yield page
@pytest.fixture(scope="module")
def setup_teams_and_games(vega: VegaServiceNull):
tDAI_market = setup_continuous_market(vega)
tDAI_market = setup_continuous_market(vega, custom_quantum=100000)
tDAI_asset_id = vega.find_asset_id(symbol="tDAI")
vega.mint(key_name=PARTY_B.name, asset=tDAI_asset_id, amount=100000)
vega.mint(key_name=PARTY_C.name, asset=tDAI_asset_id, amount=100000)
@@ -46,6 +58,18 @@ def setup_teams_and_games(vega: VegaServiceNull):
# list_teams actually returns a dictionary {"team_id": Team}
team_id = list(teams.keys())[0]
vega.create_referral_set(
key_name="market_maker",
name="test",
team_url="https://vega.xyz",
avatar_url="http://placekitten.com/200/200",
closed=False,
)
next_epoch(vega)
teams = vega.list_teams()
team_id_2 = list(teams.keys())[0]
vega.apply_referral_code("Key 1", team_id_2)
vega.apply_referral_code(PARTY_B.name, team_id)
@@ -63,7 +87,7 @@ def setup_teams_and_games(vega: VegaServiceNull):
current_epoch = vega.statistics().epoch_seq
game_start = current_epoch + 1
game_end = current_epoch + 11
game_end = current_epoch + 14
current_epoch = vega.statistics().epoch_seq
print(f"[EPOCH: {current_epoch}] creating recurring transfer")
@@ -84,9 +108,42 @@ def setup_teams_and_games(vega: VegaServiceNull):
factor=1.0,
start_epoch=game_start,
end_epoch=game_end,
window_length=10
window_length=15,
)
vega.wait_fn(1)
vega.wait_for_total_catchup()
vega.recurring_transfer(
from_key_name=PARTY_B.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",
asset_for_metric=tDAI_asset_id,
metric=vega_protos.vega.DISPATCH_METRIC_MAKER_FEES_PAID,
entity_scope=vega_protos.vega.ENTITY_SCOPE_INDIVIDUALS,
individual_scope=vega_protos.vega.INDIVIDUAL_SCOPE_IN_TEAM,
n_top_performers=1,
amount=100,
factor=1.0,
window_length=15
)
vega.wait_fn(1)
vega.wait_for_total_catchup()
vega.recurring_transfer(
from_key_name=PARTY_C.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",
asset_for_metric=tDAI_asset_id,
metric=vega_protos.vega.DISPATCH_METRIC_MAKER_FEES_PAID,
entity_scope=vega_protos.vega.ENTITY_SCOPE_INDIVIDUALS,
individual_scope=vega_protos.vega.INDIVIDUAL_SCOPE_NOT_IN_TEAM,
n_top_performers=1,
amount=100,
factor=1.0,
window_length=15
)
next_epoch(vega)
print(f"[EPOCH: {vega.statistics().epoch_seq}] starting order activity")
@@ -113,6 +170,22 @@ def setup_teams_and_games(vega: VegaServiceNull):
side="SIDE_BUY",
volume=1,
)
vega.submit_order(
trading_key="Key 1",
market_id=tDAI_market,
order_type="TYPE_MARKET",
time_in_force="TIME_IN_FORCE_IOC",
side="SIDE_BUY",
volume=1,
)
vega.submit_order(
trading_key="market_maker",
market_id=tDAI_market,
order_type="TYPE_MARKET",
time_in_force="TIME_IN_FORCE_IOC",
side="SIDE_BUY",
volume=1,
)
next_epoch(vega)
print(f"[EPOCH: {vega.statistics().epoch_seq}] {i} epoch passed")
@@ -120,6 +193,7 @@ def setup_teams_and_games(vega: VegaServiceNull):
"market_id": tDAI_market,
"asset_id": tDAI_asset_id,
"team_id": team_id,
"team_id_2": team_id_2,
"team_name": team_name,
}
@@ -136,66 +210,109 @@ def create_team(vega: VegaServiceNull):
return team_name
def test_team_page_games_table(team_page: Page):
team_page.pause()
team_page.get_by_test_id("games-toggle").click()
expect(team_page.get_by_test_id("games-toggle")).to_have_text("Games (1)")
expect(team_page.get_by_test_id("rank-0")).to_have_text("1")
expect(team_page.get_by_test_id("epoch-0")).to_have_text("18")
expect(team_page.get_by_test_id("type-0")).to_have_text("Price maker fees paid")
expect(team_page.get_by_test_id("amount-0")).to_have_text("100,000,000")
expect(team_page.get_by_test_id("participatingTeams-0")).to_have_text(
"1"
)
expect(team_page.get_by_test_id("participatingMembers-0")).to_have_text(
"2"
)
expect(team_page.get_by_test_id("rank-0")).to_have_text("2")
expect(team_page.get_by_test_id("epoch-0")).to_have_text("19")
expect(team_page.get_by_test_id("type-0")
).to_have_text("Price maker fees paid")
expect(team_page.get_by_test_id("amount-0")).to_have_text("74")
expect(team_page.get_by_test_id("participatingTeams-0")).to_have_text("2")
expect(team_page.get_by_test_id("participatingMembers-0")).to_have_text("4")
def test_team_page_members_table(team_page: Page):
team_page.get_by_test_id("members-toggle").click()
expect(team_page.get_by_test_id("members-toggle")).to_have_text("Members (4)")
expect(team_page.get_by_test_id("members-toggle")
).to_have_text("Members (4)")
expect(team_page.get_by_test_id("referee-0")).to_be_visible()
expect(team_page.get_by_test_id("joinedAt-0")).to_be_visible()
expect(team_page.get_by_test_id("joinedAtEpoch-0")).to_have_text("8")
expect(team_page.get_by_test_id("joinedAtEpoch-0")).to_have_text("9")
def test_team_page_headline(team_page: Page, setup_teams_and_games
):
def test_team_page_headline(team_page: Page, setup_teams_and_games):
team_name = setup_teams_and_games["team_name"]
expect(team_page.get_by_test_id("team-name")).to_have_text(team_name)
expect(team_page.get_by_test_id("members-count-stat")).to_have_text("4")
expect(team_page.get_by_test_id("total-games-stat")).to_have_text(
"1"
)
expect(team_page.get_by_test_id("total-games-stat")).to_have_text("2")
# TODO this still seems wrong as its always 0
expect(team_page.get_by_test_id("total-volume-stat")).to_have_text(
"0"
)
expect(team_page.get_by_test_id("total-volume-stat")).to_have_text("0")
expect(team_page.get_by_test_id("rewards-paid-stat")).to_have_text(
"100m"
)
expect(team_page.get_by_test_id("rewards-paid-stat")).to_have_text("214")
def test_switch_teams(team_page: Page, vega: VegaServiceNull):
team_page.get_by_test_id("switch-team-button").click()
team_page.get_by_test_id("confirm-switch-button").click()
expect(team_page.get_by_test_id("dialog-content").first).to_be_visible()
vega.wait_fn(1)
vega.wait_for_total_catchup()
next_epoch(vega=vega)
team_page.reload()
expect(team_page.get_by_test_id("members-count-stat")).to_have_text("5")
@pytest.fixture(scope="module")
def competitions_page(vega, browser, request):
with init_page(vega, browser, request) as page:
risk_accepted_setup(page)
auth_setup(vega, page)
yield page
def test_leaderboard(competitions_page: Page, setup_teams_and_games):
team_name = setup_teams_and_games["team_name"]
competitions_page.goto(f"/#/competitions/")
expect(competitions_page.get_by_test_id("rank-0").locator(".text-yellow-300")).to_have_count(1)
expect(competitions_page.get_by_test_id("team-0")).to_have_text(team_name)
expect(competitions_page.get_by_test_id("status-0")).to_have_text("Open")
competitions_page.reload()
expect(
competitions_page.get_by_test_id("rank-0").locator(".text-yellow-300")
).to_have_count(1)
expect(
competitions_page.get_by_test_id(
"rank-1").locator(".text-vega-clight-500")
).to_have_count(1)
expect(competitions_page.get_by_test_id("team-1")).to_have_text(team_name)
expect(competitions_page.get_by_test_id("status-1")).to_have_text("Open")
expect(competitions_page.get_by_test_id("earned-0")).to_have_text("100,000,000")
expect(competitions_page.get_by_test_id("games-0")).to_have_text("1")
# FIXME: the numbers are different we need to clarify this with the backend
# expect(competitions_page.get_by_test_id("earned-1")).to_have_text("160")
expect(competitions_page.get_by_test_id("games-1")).to_have_text("2")
# TODO still odd that this is 0
expect(competitions_page.get_by_test_id("volume-0")).to_have_text("-")
#TODO def test_games(competitions_page: Page):
#TODO currently no games appear which i think is a bug
def test_game_card(competitions_page: Page):
expect(competitions_page.get_by_test_id(
"active-rewards-card")).to_have_count(2)
game_1 = competitions_page.get_by_test_id("active-rewards-card").first
expect(game_1).to_be_visible()
expect(game_1.get_by_test_id("entity-scope")).to_have_text("Individual")
expect(game_1.get_by_test_id("locked-for")).to_have_text("1 epoch")
expect(game_1.get_by_test_id("reward-value")).to_have_text("100.00")
expect(game_1.get_by_test_id("distribution-strategy")
).to_have_text("Pro rata")
expect(game_1.get_by_test_id("dispatch-metric-info")
).to_have_text("Price maker fees paid • ")
expect(game_1.get_by_test_id("assessed-over")).to_have_text("15 epochs")
expect(game_1.get_by_test_id("scope")).to_have_text("In team")
expect(game_1.get_by_test_id("staking-requirement")).to_have_text("0.00")
expect(game_1.get_by_test_id("average-position")).to_have_text("0.00")
def test_create_team(competitions_page: Page, vega: VegaServiceNull):
change_keys(competitions_page, vega, "market_maker_2")
competitions_page.get_by_test_id("create-public-team-button").click()
competitions_page.get_by_test_id("team-name-input").fill("e2e")
competitions_page.get_by_test_id("team-url-input").fill("https://vega.xyz")
competitions_page.get_by_test_id("avatar-url-input").fill(
"http://placekitten.com/200/200"
)
competitions_page.get_by_test_id("team-form-submit-button").click()
expect(competitions_page.get_by_test_id("team-form-submit-button")).to_have_text(
"Confirming transaction..."
)
vega.wait_fn(2)
vega.wait_for_total_catchup()
expect(
competitions_page.get_by_test_id("team-creation-success-message")
).to_be_visible()
expect(competitions_page.get_by_test_id("team-id-display")).to_be_visible()
expect(competitions_page.get_by_test_id("team-id-display")).to_be_visible()
competitions_page.get_by_test_id("view-team-button").click()
expect(competitions_page.get_by_test_id("team-name")).to_have_text("e2e")
+13 -8
View File
@@ -1,15 +1,20 @@
fragment TeamsFields on Team {
teamId
referrer
name
teamUrl
avatarUrl
createdAt
createdAtEpoch
closed
totalMembers
}
query Teams($teamId: ID, $partyId: ID) {
teams(teamId: $teamId, partyId: $partyId) {
edges {
node {
teamId
referrer
name
teamUrl
avatarUrl
createdAt
createdAtEpoch
closed
...TeamsFields
}
}
}
+18 -11
View File
@@ -3,33 +3,40 @@ import * as Types from '@vegaprotocol/types';
import { gql } from '@apollo/client';
import * as Apollo from '@apollo/client';
const defaultOptions = {} as const;
export type TeamsFieldsFragment = { __typename?: 'Team', teamId: string, referrer: string, name: string, teamUrl: string, avatarUrl: string, createdAt: any, createdAtEpoch: number, closed: boolean, totalMembers: number };
export type TeamsQueryVariables = Types.Exact<{
teamId?: Types.InputMaybe<Types.Scalars['ID']>;
partyId?: Types.InputMaybe<Types.Scalars['ID']>;
}>;
export type TeamsQuery = { __typename?: 'Query', teams?: { __typename?: 'TeamConnection', edges: Array<{ __typename?: 'TeamEdge', node: { __typename?: 'Team', teamId: string, referrer: string, name: string, teamUrl: string, avatarUrl: string, createdAt: any, createdAtEpoch: number, closed: boolean } }> } | null };
export type TeamsQuery = { __typename?: 'Query', teams?: { __typename?: 'TeamConnection', edges: Array<{ __typename?: 'TeamEdge', node: { __typename?: 'Team', teamId: string, referrer: string, name: string, teamUrl: string, avatarUrl: string, createdAt: any, createdAtEpoch: number, closed: boolean, totalMembers: number } }> } | null };
export const TeamsFieldsFragmentDoc = gql`
fragment TeamsFields on Team {
teamId
referrer
name
teamUrl
avatarUrl
createdAt
createdAtEpoch
closed
totalMembers
}
`;
export const TeamsDocument = gql`
query Teams($teamId: ID, $partyId: ID) {
teams(teamId: $teamId, partyId: $partyId) {
edges {
node {
teamId
referrer
name
teamUrl
avatarUrl
createdAt
createdAtEpoch
closed
...TeamsFields
}
}
}
}
`;
${TeamsFieldsFragmentDoc}`;
/**
* __useTeamsQuery__
+14 -3
View File
@@ -1,12 +1,23 @@
import compact from 'lodash/compact';
import { useActiveRewardsQuery } from '../../components/rewards-container/__generated__/Rewards';
import { isActiveReward } from '../../components/rewards-container/active-rewards';
import { EntityScope, type TransferNode } from '@vegaprotocol/types';
import {
EntityScope,
IndividualScope,
type TransferNode,
} from '@vegaprotocol/types';
const isScopedToTeams = (node: TransferNode) =>
node.transfer.kind.__typename === 'RecurringTransfer' &&
node.transfer.kind.dispatchStrategy?.entityScope ===
EntityScope.ENTITY_SCOPE_TEAMS;
// scoped to teams
(node.transfer.kind.dispatchStrategy?.entityScope ===
EntityScope.ENTITY_SCOPE_TEAMS ||
// or to individuals
(node.transfer.kind.dispatchStrategy?.entityScope ===
EntityScope.ENTITY_SCOPE_INDIVIDUALS &&
// but they have to be in a team
node.transfer.kind.dispatchStrategy.individualScope ===
IndividualScope.INDIVIDUAL_SCOPE_IN_TEAM));
export const useGames = ({
currentEpoch,
+25
View File
@@ -0,0 +1,25 @@
import { useVegaWallet } from '@vegaprotocol/wallet';
import compact from 'lodash/compact';
import first from 'lodash/first';
import { useTeamsQuery } from './__generated__/Teams';
import { useTeam } from './use-team';
import { useTeams } from './use-teams';
export const useMyTeam = () => {
const { pubKey } = useVegaWallet();
const { data: teams } = useTeams();
const { data: maybeMyTeam } = useTeamsQuery({
variables: {
partyId: pubKey,
},
skip: !pubKey,
fetchPolicy: 'cache-and-network',
});
const team = first(compact(maybeMyTeam?.teams?.edges.map((n) => n.node)));
const rank = teams.findIndex((t) => t.teamId === team?.teamId) + 1;
const { games, stats } = useTeam(team?.teamId);
return { team, stats, games, rank };
};
+2 -1
View File
@@ -4,7 +4,8 @@ import { useTeamsQuery } from './__generated__/Teams';
import { useTeamsStatisticsQuery } from './__generated__/TeamsStatistics';
import compact from 'lodash/compact';
export const DEFAULT_AGGREGATION_EPOCHS = 10;
// 192
export const DEFAULT_AGGREGATION_EPOCHS = 192;
export const useTeams = (aggregationEpochs = DEFAULT_AGGREGATION_EPOCHS) => {
const {
+1 -86
View File
@@ -96,11 +96,6 @@ describe('TransferForm', () => {
});
it.each([
{
targetText: 'Include transfer fee',
tooltipText:
'The fee will be taken from the amount you are transferring.',
},
{
targetText: 'Transfer fee',
tooltipText: /transfer\.fee\.factor/,
@@ -276,9 +271,6 @@ describe('TransferForm', () => {
const amountInput = screen.getByLabelText('Amount');
const checkbox = screen.getByTestId('include-transfer-fee');
expect(checkbox).not.toBeChecked();
await userEvent.clear(amountInput);
await userEvent.type(amountInput, '50');
@@ -288,10 +280,7 @@ describe('TransferForm', () => {
await userEvent.click(screen.getByRole('button', { name: 'Use max' }));
expect(amountInput).toHaveValue('100.00');
// If transfering from a vested account 'include fees' checkbox should
// be disabled and fees should be 0
expect(checkbox).not.toBeChecked();
expect(checkbox).toBeDisabled();
// If transfering from a vested account fees should be 0
const expectedFee = '0';
const total = new BigNumber(amount).plus(expectedFee).toFixed();
@@ -396,78 +385,7 @@ describe('TransferForm', () => {
});
});
});
describe('IncludeFeesCheckbox', () => {
it('validates fields and submits when checkbox is checked', async () => {
const mockSubmit = jest.fn();
renderComponent({ ...props, submitTransfer: mockSubmit });
// check current pubkey not shown
const keySelect = screen.getByLabelText<HTMLSelectElement>('To Vega key');
const pubKeyOptions = ['', pubKey, props.pubKeys[1]];
expect(keySelect.children).toHaveLength(pubKeyOptions.length);
expect(Array.from(keySelect.options).map((o) => o.value)).toEqual(
pubKeyOptions
);
await submit();
expect(await screen.findAllByText('Required')).toHaveLength(2); // pubkey set as default value
// Select a pubkey
await userEvent.selectOptions(
screen.getByLabelText('To Vega key'),
props.pubKeys[1]
);
// Select asset
await selectAsset(asset);
await userEvent.selectOptions(
screen.getByLabelText('From account'),
`${AccountType.ACCOUNT_TYPE_GENERAL}-${asset.id}`
);
const amountInput = screen.getByLabelText('Amount');
const checkbox = screen.getByTestId('include-transfer-fee');
// 1003-TRAN-022
expect(checkbox).not.toBeChecked();
await userEvent.clear(amountInput);
await userEvent.type(amountInput, amount);
await userEvent.click(checkbox);
expect(checkbox).toBeChecked();
const expectedFee = new BigNumber(amount)
.times(props.feeFactor)
.toFixed();
const expectedAmount = new BigNumber(amount).minus(expectedFee).toFixed();
// 1003-TRAN-020
expect(screen.getByTestId('transfer-fee')).toHaveTextContent(expectedFee);
expect(screen.getByTestId('transfer-amount')).toHaveTextContent(
expectedAmount
);
expect(screen.getByTestId('total-transfer-fee')).toHaveTextContent(
amount
);
await submit();
await waitFor(() => {
// 1003-TRAN-023
expect(mockSubmit).toHaveBeenCalledTimes(1);
expect(mockSubmit).toHaveBeenCalledWith({
fromAccountType: AccountType.ACCOUNT_TYPE_GENERAL,
toAccountType: AccountType.ACCOUNT_TYPE_GENERAL,
to: props.pubKeys[1],
asset: asset.id,
amount: removeDecimal(expectedAmount, asset.decimals),
oneOff: {},
});
});
});
it('validates fields when checkbox is not checked', async () => {
renderComponent(props);
@@ -497,11 +415,8 @@ describe('TransferForm', () => {
);
const amountInput = screen.getByLabelText('Amount');
const checkbox = screen.getByTestId('include-transfer-fee');
expect(checkbox).not.toBeChecked();
await userEvent.type(amountInput, amount);
expect(checkbox).not.toBeChecked();
const expectedFee = new BigNumber(amount)
.times(props.feeFactor)
.toFixed();
+12 -54
View File
@@ -15,7 +15,6 @@ import {
TradingRichSelect,
TradingSelect,
Tooltip,
TradingCheckbox,
TradingButton,
} from '@vegaprotocol/ui-toolkit';
import type { Transfer } from '@vegaprotocol/wallet';
@@ -135,32 +134,17 @@ export const TransferForm = ({
const accountBalance =
account && addDecimal(account.balance, account.asset.decimals);
const [includeFee, setIncludeFee] = useState(false);
// Max amount given selected asset and from account
const max = accountBalance ? new BigNumber(accountBalance) : new BigNumber(0);
const transferAmount = useMemo(() => {
if (!amount) return undefined;
if (includeFee && feeFactor) {
return new BigNumber(1).minus(feeFactor).times(amount).toString();
}
return amount;
}, [amount, includeFee, feeFactor]);
const fee = useMemo(() => {
if (!transferAmount) return undefined;
if (includeFee) {
return new BigNumber(amount).minus(transferAmount).toString();
}
return (
feeFactor && new BigNumber(feeFactor).times(transferAmount).toString()
);
}, [amount, includeFee, transferAmount, feeFactor]);
const fee = useMemo(
() => feeFactor && new BigNumber(feeFactor).times(amount).toString(),
[amount, feeFactor]
);
const onSubmit = useCallback(
(fields: FormFields) => {
if (!transferAmount) {
if (!amount) {
throw new Error('Submitted transfer with no amount selected');
}
@@ -173,7 +157,7 @@ export const TransferForm = ({
const transfer = normalizeTransfer(
fields.toVegaKey,
transferAmount,
amount,
type,
AccountType.ACCOUNT_TYPE_GENERAL, // field is readonly in the form
{
@@ -183,7 +167,7 @@ export const TransferForm = ({
);
submitTransfer(transfer);
},
[submitTransfer, transferAmount, assets]
[submitTransfer, amount, assets]
);
// reset for placeholder workaround https://github.com/radix-ui/primitives/issues/1569
@@ -279,7 +263,6 @@ export const TransferForm = ({
) {
setValue('toVegaKey', pubKey);
setToVegaKeyMode('select');
setIncludeFee(false);
}
}}
>
@@ -449,27 +432,9 @@ export const TransferForm = ({
</TradingInputError>
)}
</TradingFormGroup>
<div className="mb-4">
<Tooltip
description={t(
`The fee will be taken from the amount you are transferring.`
)}
>
<div>
<TradingCheckbox
name="include-transfer-fee"
disabled={!transferAmount || fromVested}
label={t('Include transfer fee')}
checked={includeFee}
onCheckedChange={() => setIncludeFee((x) => !x)}
/>
</div>
</Tooltip>
</div>
{transferAmount && fee && (
{amount && fee && (
<TransferFee
amount={transferAmount}
transferAmount={transferAmount}
amount={amount}
feeFactor={feeFactor}
fee={fromVested ? '0' : fee}
decimals={asset?.decimals}
@@ -484,29 +449,22 @@ export const TransferForm = ({
export const TransferFee = ({
amount,
transferAmount,
feeFactor,
fee,
decimals,
}: {
amount: string;
transferAmount: string;
feeFactor: string | null;
fee?: string;
decimals?: number;
}) => {
const t = useT();
if (!feeFactor || !amount || !transferAmount || !fee) return null;
if (
isNaN(Number(feeFactor)) ||
isNaN(Number(amount)) ||
isNaN(Number(transferAmount)) ||
isNaN(Number(fee))
) {
if (!feeFactor || !amount || !fee) return null;
if (isNaN(Number(feeFactor)) || isNaN(Number(amount)) || isNaN(Number(fee))) {
return null;
}
const totalValue = new BigNumber(transferAmount).plus(fee).toString();
const totalValue = new BigNumber(amount).plus(fee).toString();
return (
<div className="mb-4 flex flex-col gap-2 text-xs">
@@ -7,6 +7,7 @@ import { AssetsDocument, type AssetsQuery } from './__generated__/Assets';
import { AssetStatus } from '@vegaprotocol/types';
import { type Asset } from './asset-data-provider';
import { DENY_LIST } from './constants';
import { type AssetFieldsFragment } from './__generated__/Asset';
export interface BuiltinAssetSource {
__typename: 'BuiltinAsset';
@@ -89,3 +90,24 @@ export const useEnabledAssets = () => {
variables: undefined,
});
};
/** Wrapped ETH symbol */
const WETH = 'WETH';
type WETHDetails = Pick<AssetFieldsFragment, 'symbol' | 'decimals' | 'quantum'>;
/**
* Tries to find WETH asset configuration on Vega in order to provide its
* details, otherwise it returns hardcoded values.
*/
export const useWETH = (): WETHDetails => {
const { data } = useAssetsDataProvider();
if (data) {
const weth = data.find((a) => a.symbol.toUpperCase() === WETH);
if (weth) return weth;
}
return {
symbol: WETH,
decimals: 18,
quantum: '500000000000000', // 1 WETH ~= 2000 qUSD
};
};
@@ -3,27 +3,13 @@ import { getAsset, getQuoteName } from '@vegaprotocol/markets';
import { useVegaWallet } from '@vegaprotocol/wallet';
import { AccountBreakdownDialog } from '@vegaprotocol/accounts';
import { formatRange, formatValue } from '@vegaprotocol/utils';
import { marketMarginDataProvider } from '@vegaprotocol/accounts';
import { useDataProvider } from '@vegaprotocol/data-provider';
import * as AccordionPrimitive from '@radix-ui/react-accordion';
import * as Schema from '@vegaprotocol/types';
import {
MARGIN_DIFF_TOOLTIP_TEXT,
DEDUCTION_FROM_COLLATERAL_TOOLTIP_TEXT,
TOTAL_MARGIN_AVAILABLE,
LIQUIDATION_PRICE_ESTIMATE_TOOLTIP_TEXT,
EST_TOTAL_MARGIN_TOOLTIP_TEXT,
MARGIN_ACCOUNT_TOOLTIP_TEXT,
} from '../../constants';
import { KeyValue } from './key-value';
import {
Accordion,
AccordionChevron,
AccordionPanel,
ExternalLink,
Tooltip,
} from '@vegaprotocol/ui-toolkit';
import classNames from 'classnames';
import { ExternalLink } from '@vegaprotocol/ui-toolkit';
import { useT, ns } from '../../use-t';
import { Trans } from 'react-i18next';
import type { Market } from '@vegaprotocol/markets';
@@ -31,9 +17,9 @@ import { emptyValue } from './deal-ticket-fee-details';
import type { EstimatePositionQuery } from '@vegaprotocol/positions';
export interface DealTicketMarginDetailsProps {
generalAccountBalance?: string;
marginAccountBalance?: string;
orderMarginAccountBalance?: string;
generalAccountBalance: string;
marginAccountBalance: string;
orderMarginAccountBalance: string;
market: Market;
onMarketClick?: (marketId: string, metaKey?: boolean) => void;
assetSymbol: string;
@@ -54,118 +40,20 @@ export const DealTicketMarginDetails = ({
const t = useT();
const [breakdownDialog, setBreakdownDialog] = useState(false);
const { pubKey: partyId } = useVegaWallet();
const { data: currentMargins } = useDataProvider({
dataProvider: marketMarginDataProvider,
variables: { marketId: market.id, partyId: partyId || '' },
skip: !partyId,
});
const liquidationEstimate = positionEstimate?.liquidation;
const marginEstimate = positionEstimate?.margin;
const totalMarginAccountBalance =
BigInt(marginAccountBalance || '0') +
BigInt(orderMarginAccountBalance || '0');
const totalBalance =
BigInt(generalAccountBalance || '0') + totalMarginAccountBalance;
const asset = getAsset(market);
const { decimals: assetDecimals, quantum } = asset;
let marginRequiredBestCase: string | undefined = undefined;
let marginRequiredWorstCase: string | undefined = undefined;
const marginEstimateBestCase =
BigInt(marginEstimate?.bestCase.initialLevel ?? 0) +
BigInt(marginEstimate?.bestCase.orderMarginLevel ?? 0);
const marginEstimateWorstCase =
BigInt(marginEstimate?.worstCase.initialLevel ?? 0) +
BigInt(marginEstimate?.worstCase.orderMarginLevel ?? 0);
if (marginEstimate) {
if (currentMargins) {
const currentMargin =
BigInt(currentMargins.initialLevel) +
BigInt(currentMargins.orderMarginLevel);
marginRequiredBestCase = (
marginEstimateBestCase - currentMargin
).toString();
if (marginRequiredBestCase.startsWith('-')) {
marginRequiredBestCase = '0';
}
marginRequiredWorstCase = (
marginEstimateWorstCase - currentMargin
).toString();
if (marginRequiredWorstCase.startsWith('-')) {
marginRequiredWorstCase = '0';
}
} else {
marginRequiredBestCase = marginEstimateBestCase.toString();
marginRequiredWorstCase = marginEstimateWorstCase.toString();
}
}
const totalMarginAvailable = (
currentMargins
? totalBalance - BigInt(currentMargins.maintenanceLevel)
: totalBalance
).toString();
let deductionFromCollateral = null;
let projectedMargin = null;
if (totalMarginAccountBalance) {
const deductionFromCollateralBestCase =
marginEstimateBestCase - totalMarginAccountBalance;
const deductionFromCollateralWorstCase =
marginEstimateWorstCase - totalMarginAccountBalance;
deductionFromCollateral = (
<KeyValue
indent
label={t('Deduction from collateral')}
value={formatRange(
deductionFromCollateralBestCase > 0
? deductionFromCollateralBestCase.toString()
: '0',
deductionFromCollateralWorstCase > 0
? deductionFromCollateralWorstCase.toString()
: '0',
assetDecimals
)}
formattedValue={formatValue(
deductionFromCollateralWorstCase > 0
? deductionFromCollateralWorstCase.toString()
: '0',
assetDecimals,
quantum
)}
symbol={assetSymbol}
labelDescription={t(
'DEDUCTION_FROM_COLLATERAL_TOOLTIP_TEXT',
DEDUCTION_FROM_COLLATERAL_TOOLTIP_TEXT,
{ assetSymbol }
)}
/>
);
projectedMargin = (
<KeyValue
label={t('Projected margin')}
value={formatRange(
marginEstimateBestCase.toString(),
marginEstimateWorstCase.toString(),
assetDecimals
)}
formattedValue={formatValue(
marginEstimateWorstCase.toString(),
assetDecimals,
quantum
)}
symbol={assetSymbol}
labelDescription={t(
'EST_TOTAL_MARGIN_TOOLTIP_TEXT',
EST_TOTAL_MARGIN_TOOLTIP_TEXT
)}
/>
);
}
const collateralIncreaseEstimateBestCase = BigInt(
positionEstimate?.collateralIncreaseEstimate.bestCase ?? '0'
);
const collateralIncreaseEstimateWorstCase = BigInt(
positionEstimate?.collateralIncreaseEstimate.worstCase ?? '0'
);
let liquidationPriceEstimate = emptyValue;
let liquidationPriceEstimateRange = emptyValue;
@@ -222,128 +110,50 @@ export const DealTicketMarginDetails = ({
const quoteName = getQuoteName(market);
return (
<div className="flex flex-col w-full gap-2">
<Accordion>
<AccordionPanel
itemId="margin"
trigger={
<AccordionPrimitive.Trigger
data-testid="accordion-toggle"
className={classNames(
'w-full pt-2',
'flex items-center gap-2 text-xs',
'group'
)}
>
<div
data-testid={`deal-ticket-fee-margin-required`}
key={'value-dropdown'}
className="flex items-center justify-between w-full gap-2"
>
<div className="flex items-center text-left gap-1">
<Tooltip
description={t(
'MARGIN_DIFF_TOOLTIP_TEXT',
MARGIN_DIFF_TOOLTIP_TEXT,
{ assetSymbol }
)}
>
<span className="text-muted">{t('Margin required')}</span>
</Tooltip>
<AccordionChevron size={10} />
</div>
<Tooltip
description={
formatRange(
marginRequiredBestCase,
marginRequiredWorstCase,
assetDecimals
) ?? '-'
}
>
<div className="font-mono text-right">
{formatValue(
marginRequiredWorstCase,
assetDecimals,
quantum
)}{' '}
{assetSymbol || ''}
</div>
</Tooltip>
</div>
</AccordionPrimitive.Trigger>
}
>
<div className="flex flex-col w-full gap-2">
<KeyValue
label={t('Total margin available')}
indent
value={formatValue(totalMarginAvailable, assetDecimals)}
formattedValue={formatValue(
totalMarginAvailable,
assetDecimals,
quantum
)}
symbol={assetSymbol}
labelDescription={t(
'TOTAL_MARGIN_AVAILABLE',
TOTAL_MARGIN_AVAILABLE,
{
generalAccountBalance: formatValue(
generalAccountBalance,
assetDecimals,
quantum
),
marginAccountBalance: formatValue(
marginAccountBalance,
assetDecimals,
quantum
),
orderMarginAccountBalance: formatValue(
orderMarginAccountBalance,
assetDecimals,
quantum
),
marginMaintenance: formatValue(
currentMargins?.maintenanceLevel,
assetDecimals,
quantum
),
assetSymbol,
}
)}
/>
{deductionFromCollateral}
<KeyValue
label={t('Current margin allocation')}
indent
onClick={
generalAccountBalance
? () => setBreakdownDialog(true)
: undefined
}
value={formatValue(
totalMarginAccountBalance.toString(),
assetDecimals
)}
symbol={assetSymbol}
labelDescription={t(
'MARGIN_ACCOUNT_TOOLTIP_TEXT',
MARGIN_ACCOUNT_TOOLTIP_TEXT
)}
formattedValue={formatValue(
totalMarginAccountBalance.toString(),
assetDecimals,
quantum
)}
/>
</div>
</AccordionPanel>
</Accordion>
{projectedMargin}
<div className="flex flex-col w-full gap-2 mt-2">
<KeyValue
label={t('Liquidation')}
label={t('Current margin')}
onClick={
generalAccountBalance ? () => setBreakdownDialog(true) : undefined
}
value={formatValue(totalMarginAccountBalance.toString(), assetDecimals)}
symbol={assetSymbol}
labelDescription={t(
'MARGIN_ACCOUNT_TOOLTIP_TEXT',
MARGIN_ACCOUNT_TOOLTIP_TEXT
)}
formattedValue={formatValue(
totalMarginAccountBalance.toString(),
assetDecimals,
quantum
)}
/>
<KeyValue
label={t('Available collateral')}
value={formatValue(generalAccountBalance, assetDecimals)}
formattedValue={formatValue(
generalAccountBalance.toString(),
assetDecimals,
quantum
)}
symbol={assetSymbol}
/>
<KeyValue
label={t('Additional margin required')}
value={formatRange(
collateralIncreaseEstimateBestCase.toString(),
collateralIncreaseEstimateWorstCase.toString(),
assetDecimals
)}
formattedValue={formatValue(
collateralIncreaseEstimateBestCase.toString(),
assetDecimals,
quantum
)}
symbol={assetSymbol}
/>
<KeyValue
label={t('Liquidation estimate')}
value={liquidationPriceEstimateRange}
formattedValue={liquidationPriceEstimate}
symbol={quoteName}
@@ -73,7 +73,7 @@ import {
} from '../../hooks';
import { DealTicketSizeIceberg } from './deal-ticket-size-iceberg';
import noop from 'lodash/noop';
import { isNonPersistentOrder } from '../../utils/time-in-force-persistance';
import { isNonPersistentOrder } from '../../utils/time-in-force-persistence';
import { KeyValue } from './key-value';
import { DocsLinks } from '@vegaprotocol/environment';
import { useT } from '../../use-t';
@@ -177,12 +177,6 @@ export const DealTicket = ({
loading: loadingGeneralAccountBalance,
} = useAccountBalance(asset.id);
const balance = (
BigInt(marginAccountBalance) +
BigInt(generalAccountBalance) +
BigInt(orderMarginAccountBalance)
).toString();
const { marketState, marketTradingMode } = marketData;
const timeInForce = watch('timeInForce');
@@ -729,17 +723,11 @@ export const DealTicket = ({
error={summaryError}
asset={asset}
marketTradingMode={marketData.marketTradingMode}
balance={balance}
margin={(
BigInt(
positionEstimate?.estimatePosition?.margin.bestCase.initialLevel ||
'0'
) +
BigInt(
positionEstimate?.estimatePosition?.margin.bestCase
.orderMarginLevel || '0'
)
).toString()}
balance={generalAccountBalance}
margin={
positionEstimate?.estimatePosition?.collateralIncreaseEstimate
.bestCase || '0'
}
isReadOnly={isReadOnly}
pubKey={pubKey}
onDeposit={onDeposit}
@@ -1,5 +1,4 @@
import { Tooltip } from '@vegaprotocol/ui-toolkit';
import classnames from 'classnames';
import type { ReactNode } from 'react';
export interface KeyValuePros {
@@ -19,7 +18,6 @@ export const KeyValue = ({
value,
labelDescription,
symbol,
indent,
onClick,
formattedValue,
}: KeyValuePros) => {
@@ -43,10 +41,7 @@ export const KeyValue = ({
: id
}`}
key={typeof label === 'string' ? label : 'value-dropdown'}
className={classnames(
'text-xs flex justify-between items-center gap-4 flex-wrap text-right',
{ 'ml-2': indent }
)}
className="text-xs flex justify-between items-center gap-4 flex-wrap text-right"
>
<Tooltip description={labelDescription}>
<div className="text-muted text-left">{label}</div>
@@ -29,6 +29,7 @@ import { usePositionEstimate } from '../../hooks/use-position-estimate';
import { addDecimalsFormatNumber } from '@vegaprotocol/utils';
import { getAsset, useMarket } from '@vegaprotocol/markets';
import { NoWalletWarning } from './deal-ticket';
import { DealTicketMarginDetails } from './deal-ticket-margin-details';
const defaultLeverage = 10;
@@ -93,66 +94,78 @@ export const MarginChange = ({
},
skip
);
if (
!asset ||
!estimateMargin?.estimatePosition?.collateralIncreaseEstimate.worstCase ||
estimateMargin.estimatePosition.collateralIncreaseEstimate.worstCase === '0'
) {
if (!asset || !estimateMargin?.estimatePosition) {
return null;
}
const collateralIncreaseEstimate = BigInt(
estimateMargin.estimatePosition.collateralIncreaseEstimate.worstCase
);
if (!collateralIncreaseEstimate) {
return null;
}
let positionWarning = '';
if (orders?.length && openVolume !== '0') {
positionWarning = t(
'youHaveOpenPositionAndOrders',
'You have an existing position and open orders on this market.',
{
count: orders.length,
}
);
} else if (!orders?.length) {
positionWarning = t('You have an existing position on this market.');
} else {
positionWarning = t(
'youHaveOpenOrders',
'You have open orders on this market.',
{
count: orders.length,
}
);
}
let marginChangeWarning = '';
const amount = addDecimalsFormatNumber(
collateralIncreaseEstimate.toString(),
asset?.decimals
);
const { symbol } = asset;
const interpolation = { amount, symbol };
if (marginMode === Schema.MarginMode.MARGIN_MODE_CROSS_MARGIN) {
marginChangeWarning = t(
'Changing the margin mode will move {{amount}} {{symbol}} from your general account to fund the position.',
interpolation
);
} else {
marginChangeWarning = t(
'Changing the margin mode and leverage will move {{amount}} {{symbol}} from your general account to fund the position.',
interpolation
if (collateralIncreaseEstimate) {
if (orders?.length && openVolume !== '0') {
positionWarning = t(
'youHaveOpenPositionAndOrders',
'You have an existing position and open orders on this market.',
{
count: orders.length,
}
);
} else if (!orders?.length) {
positionWarning = t('You have an existing position on this market.');
} else {
positionWarning = t(
'youHaveOpenOrders',
'You have open orders on this market.',
{
count: orders.length,
}
);
}
const amount = addDecimalsFormatNumber(
collateralIncreaseEstimate.toString(),
asset?.decimals
);
const { symbol } = asset;
const interpolation = { amount, symbol };
if (marginMode === Schema.MarginMode.MARGIN_MODE_CROSS_MARGIN) {
marginChangeWarning = t(
'Changing the margin mode will move {{amount}} {{symbol}} from your general account to fund the position.',
interpolation
);
} else {
marginChangeWarning = t(
'Changing the margin mode and leverage will move {{amount}} {{symbol}} from your general account to fund the position.',
interpolation
);
}
}
return (
<div className="mb-2">
<Notification
intent={Intent.Warning}
message={
<>
<p>{positionWarning}</p>
<p>{marginChangeWarning}</p>
</>
{positionWarning && marginChangeWarning && (
<Notification
intent={Intent.Warning}
message={
<>
<p>{positionWarning}</p>
<p>{marginChangeWarning}</p>
</>
}
/>
)}
<DealTicketMarginDetails
marginAccountBalance={marginAccountBalance}
generalAccountBalance={generalAccountBalance}
orderMarginAccountBalance={orderMarginAccountBalance}
assetSymbol={asset.symbol}
market={market}
positionEstimate={estimateMargin.estimatePosition}
side={
openVolume.startsWith('-')
? Schema.Side.SIDE_SELL
: Schema.Side.SIDE_BUY
}
/>
</div>
@@ -9,7 +9,7 @@ import type {
} from '../hooks/use-form-values';
import * as Schema from '@vegaprotocol/types';
import { removeDecimal, toNanoSeconds } from '@vegaprotocol/utils';
import { isPersistentOrder } from './time-in-force-persistance';
import { isPersistentOrder } from './time-in-force-persistence';
export const mapFormValuesToOrderSubmission = (
order: OrderFormValues,
@@ -2,9 +2,9 @@ import { OrderTimeInForce } from '@vegaprotocol/types';
import {
isNonPersistentOrder,
isPersistentOrder,
} from './time-in-force-persistance';
} from './time-in-force-persistence';
it('isNonPeristentOrder', () => {
it('isNonPersistentOrder', () => {
expect(isNonPersistentOrder(OrderTimeInForce.TIME_IN_FORCE_FOK)).toBe(true);
expect(isNonPersistentOrder(OrderTimeInForce.TIME_IN_FORCE_IOC)).toBe(true);
expect(isNonPersistentOrder(OrderTimeInForce.TIME_IN_FORCE_GTC)).toBe(false);
@@ -13,7 +13,7 @@ it('isNonPeristentOrder', () => {
expect(isNonPersistentOrder(OrderTimeInForce.TIME_IN_FORCE_GFN)).toBe(false);
});
it('isPeristentOrder', () => {
it('isPersistentOrder', () => {
expect(isPersistentOrder(OrderTimeInForce.TIME_IN_FORCE_FOK)).toBe(false);
expect(isPersistentOrder(OrderTimeInForce.TIME_IN_FORCE_IOC)).toBe(false);
expect(isPersistentOrder(OrderTimeInForce.TIME_IN_FORCE_GTC)).toBe(true);
@@ -23,7 +23,6 @@ import {
SUBSCRIPTION_TIMEOUT,
useNodeBasicStatus,
useNodeSubscriptionStatus,
useResponseTime,
} from './row-data';
import { BLOCK_THRESHOLD, RowData } from './row-data';
import { CUSTOM_NODE_KEY } from '../../types';
@@ -162,19 +161,6 @@ describe('useNodeBasicStatus', () => {
});
});
describe('useResponseTime', () => {
it('returns response time when url is valid', () => {
const { result } = renderHook(() =>
useResponseTime('https://localhost:1234')
);
expect(result.current.responseTime).toBe(50);
});
it('does not return response time when url is invalid', () => {
const { result } = renderHook(() => useResponseTime('nope'));
expect(result.current.responseTime).toBeUndefined();
});
});
describe('RowData', () => {
const props = {
id: '0',
@@ -1,4 +1,3 @@
import { isValidUrl } from '@vegaprotocol/utils';
import { TradingRadio } from '@vegaprotocol/ui-toolkit';
import { useEffect, useState } from 'react';
import { CUSTOM_NODE_KEY } from '../../types';
@@ -8,6 +7,7 @@ import {
} from '../../utils/__generated__/NodeCheck';
import { LayoutCell } from './layout-cell';
import { useT } from '../../use-t';
import { useResponseTime } from '../../utils/time';
export const POLL_INTERVAL = 1000;
export const SUBSCRIPTION_TIMEOUT = 3000;
@@ -108,20 +108,6 @@ export const useNodeBasicStatus = () => {
};
};
export const useResponseTime = (url: string, trigger?: unknown) => {
const [responseTime, setResponseTime] = useState<number>();
useEffect(() => {
if (!isValidUrl(url)) return;
if (typeof window.performance.getEntriesByName !== 'function') return; // protection for test environment
const requestUrl = new URL(url);
const requests = window.performance.getEntriesByName(requestUrl.href);
const { duration } =
(requests.length && requests[requests.length - 1]) || {};
setResponseTime(duration);
}, [url, trigger]);
return { responseTime };
};
export const RowData = ({
id,
url,
@@ -10,6 +10,7 @@ import {
getUserEnabledFeatureFlags,
setUserEnabledFeatureFlag,
} from './use-environment';
import { canMeasureResponseTime, measureResponseTime } from '../utils/time';
const noop = () => {
/* no op*/
@@ -17,6 +18,10 @@ const noop = () => {
jest.mock('@vegaprotocol/apollo-client');
jest.mock('zustand');
jest.mock('../utils/time');
const mockCanMeasureResponseTime = canMeasureResponseTime as jest.Mock;
const mockMeasureResponseTime = measureResponseTime as jest.Mock;
const mockCreateClient = createClient as jest.Mock;
const createDefaultMockClient = () => {
@@ -155,6 +160,14 @@ describe('useEnvironment', () => {
const fastNode = 'https://api.n01.foo.vega.xyz';
const fastWait = 1000;
const nodes = [slowNode, fastNode];
mockCanMeasureResponseTime.mockImplementation(() => true);
mockMeasureResponseTime.mockImplementation((url: string) => {
if (url === slowNode) return slowWait;
if (url === fastNode) return fastWait;
return Infinity;
});
// @ts-ignore: typscript doesn't recognise the mock implementation
global.fetch.mockImplementation(setupFetch({ hosts: nodes }));
@@ -168,7 +181,7 @@ describe('useEnvironment', () => {
statistics: {
chainId: 'chain-id',
blockHeight: '100',
vegaTime: new Date().toISOString(),
vegaTime: new Date(1).toISOString(),
},
},
});
@@ -196,7 +209,8 @@ describe('useEnvironment', () => {
expect(result.current.nodes).toEqual(nodes);
});
jest.runAllTimers();
jest.advanceTimersByTime(2000);
// jest.runAllTimers();
await waitFor(() => {
expect(result.current.status).toEqual('success');
+78 -29
View File
@@ -19,6 +19,9 @@ import { compileErrors } from '../utils/compile-errors';
import { envSchema } from '../utils/validate-environment';
import { tomlConfigSchema } from '../utils/validate-configuration';
import uniq from 'lodash/uniq';
import orderBy from 'lodash/orderBy';
import first from 'lodash/first';
import { canMeasureResponseTime, measureResponseTime } from '../utils/time';
type Client = ReturnType<typeof createClient>;
type ClientCollection = {
@@ -38,8 +41,17 @@ export type EnvStore = Env & Actions;
const VERSION = 1;
export const STORAGE_KEY = `vega_url_${VERSION}`;
const QUERY_TIMEOUT = 3000;
const SUBSCRIPTION_TIMEOUT = 3000;
const raceAgainst = (timeout: number): Promise<false> =>
new Promise((resolve) => {
setTimeout(() => {
resolve(false);
}, timeout);
});
/**
* Fetch and validate a vega node configuration
*/
@@ -64,53 +76,88 @@ const fetchConfig = async (url?: string) => {
const findNode = async (clients: ClientCollection): Promise<string | null> => {
const tests = Object.entries(clients).map((args) => testNode(...args));
try {
const url = await Promise.any(tests);
return url;
} catch {
const nodes = await Promise.all(tests);
const responsiveNodes = nodes
.filter(([, q, s]) => q && s)
.map(([url, q]) => {
return {
url,
...q,
};
});
// more recent and faster at the top
const ordered = orderBy(
responsiveNodes,
[(n) => n.blockHeight, (n) => n.vegaTime, (n) => n.responseTime],
['desc', 'desc', 'asc']
);
const best = first(ordered);
return best ? best.url : null;
} catch (err) {
// All tests rejected, no suitable node found
return null;
}
};
type Maybe<T> = T | false;
type QueryTestResult = {
blockHeight: number;
vegaTime: Date;
responseTime: number;
};
type SubscriptionTestResult = true;
type NodeTestResult = [
/** url */
string,
Maybe<QueryTestResult>,
Maybe<SubscriptionTestResult>
];
/**
* Test a node for suitability for connection
*/
const testNode = async (
url: string,
client: Client
): Promise<string | null> => {
): Promise<NodeTestResult> => {
const results = await Promise.all([
// these promises will only resolve with true/false
testQuery(client),
testQuery(client, url),
testSubscription(client),
]);
if (results[0] && results[1]) {
return url;
}
const message = `Tests failed for node: ${url}`;
console.warn(message);
// throwing here will mean this tests is ignored and a different
// node that hopefully does resolve will fulfill the Promise.any
throw new Error(message);
return [url, ...results];
};
/**
* Run a test query on a client
*/
const testQuery = async (client: Client) => {
try {
const result = await client.query<NodeCheckQuery>({
query: NodeCheckDocument,
});
if (!result || result.error) {
return false;
}
return true;
} catch (err) {
return false;
}
const testQuery = (
client: Client,
url: string
): Promise<Maybe<QueryTestResult>> => {
const test: Promise<Maybe<QueryTestResult>> = new Promise((resolve) =>
client
.query<NodeCheckQuery>({
query: NodeCheckDocument,
})
.then((result) => {
if (result && !result.error) {
const res = {
blockHeight: Number(result.data.statistics.blockHeight),
vegaTime: new Date(result.data.statistics.vegaTime),
// only after a request has been sent we can retrieve the response time
responseTime: canMeasureResponseTime(url)
? measureResponseTime(url) || Infinity
: Infinity,
} as QueryTestResult;
resolve(res);
} else {
resolve(false);
}
})
.catch(() => resolve(false))
);
return Promise.race([test, raceAgainst(QUERY_TIMEOUT)]);
};
/**
@@ -118,7 +165,9 @@ const testQuery = async (client: Client) => {
* that takes longer than SUBSCRIPTION_TIMEOUT ms to respond
* is deemed a failure
*/
const testSubscription = (client: Client) => {
const testSubscription = (
client: Client
): Promise<Maybe<SubscriptionTestResult>> => {
return new Promise((resolve) => {
const sub = client
.subscribe<NodeCheckTimeUpdateSubscription>({
+22
View File
@@ -0,0 +1,22 @@
import { renderHook } from '@testing-library/react';
import { useResponseTime } from './time';
const mockResponseTime = 50;
global.performance.getEntriesByName = jest.fn().mockReturnValue([
{
duration: mockResponseTime,
},
]);
describe('useResponseTime', () => {
it('returns response time when url is valid', () => {
const { result } = renderHook(() =>
useResponseTime('https://localhost:1234')
);
expect(result.current.responseTime).toBe(50);
});
it('does not return response time when url is invalid', () => {
const { result } = renderHook(() => useResponseTime('nope'));
expect(result.current.responseTime).toBeUndefined();
});
});
+25
View File
@@ -0,0 +1,25 @@
import { isValidUrl } from '@vegaprotocol/utils';
import { useEffect, useState } from 'react';
export const useResponseTime = (url: string, trigger?: unknown) => {
const [responseTime, setResponseTime] = useState<number>();
useEffect(() => {
if (!canMeasureResponseTime(url)) return;
const duration = measureResponseTime(url);
setResponseTime(duration);
}, [url, trigger]);
return { responseTime };
};
export const canMeasureResponseTime = (url: string) => {
if (!isValidUrl(url)) return false;
if (typeof window.performance.getEntriesByName !== 'function') return false;
return true;
};
export const measureResponseTime = (url: string) => {
const requestUrl = new URL(url);
const requests = window.performance.getEntriesByName(requestUrl.href);
const { duration } = (requests.length && requests[requests.length - 1]) || {};
return duration;
};
-2
View File
@@ -21,7 +21,6 @@
"Deposited on the network, but not allocated to a market. Free to use for placing orders or providing liquidity.": "Deposited on the network, but not allocated to a market. Free to use for placing orders or providing liquidity.",
"Enter manually": "Enter manually",
"From account": "From account",
"Include transfer fee": "Include transfer fee",
"initial level": "initial level",
"maintenance level": "maintenance level",
"Margin health": "Margin health",
@@ -33,7 +32,6 @@
"release level": "release level",
"search level": "search level",
"Select from wallet": "Select from wallet",
"The fee will be taken from the amount you are transferring.": "The fee will be taken from the amount you are transferring.",
"The total amount of each asset on this key. Includes used and available collateral.": "The total amount of each asset on this key. Includes used and available collateral.",
"The total amount taken from your account. The amount to be transferred plus the fee.": "The total amount taken from your account. The amount to be transferred plus the fee.",
"The total amount to be transferred (without the fee)": "The total amount to be transferred (without the fee)",
+20 -1
View File
@@ -417,6 +417,7 @@
"myVolume_other": "My volume (last {{count}} epochs)",
"numberEpochs": "{{count}} epochs",
"numberEpochs_one": "{{count}} epoch",
"Rewards earned": "Rewards earned",
"Rewards paid out": "Rewards paid out",
"{{reward}}x": "{{reward}}x",
"userActive": "{{active}} trader: {{count}} epochs so far",
@@ -431,5 +432,23 @@
"{{assetSymbol}} Reward pot": "{{assetSymbol}} Reward pot",
"{{checkedAssets}} Assets": "{{checkedAssets}} Assets",
"{{distance}} ago": "{{distance}} ago",
"{{instrumentCode}} liquidity provision": "{{instrumentCode}} liquidity provision"
"{{instrumentCode}} liquidity provision": "{{instrumentCode}} liquidity provision",
"My team": "My team",
"Profile": "Profile",
"Last {{games}} games result_one": "Last game result",
"Last {{games}} games result_other": "Last {{games}} games result",
"Leaderboard": "Leaderboard",
"View all teams": "View all teams",
"Competitions": "Competitions",
"Be a team player! Participate in games and work together to rake in as much profit to win.": "Be a team player! Participate in games and work together to rake in as much profit to win.",
"Create a public team": "Create a public team",
"Create a private team": "Create a private team",
"Choose a team": "Choose a team",
"Join a team": "Join a team",
"Solo team / lone wolf": "Solo team / lone wolf",
"Choose a team to get involved": "Choose a team to get involved",
"Go back to the team's profile": "Go back to the team's profile",
"Go back to the competitions": "Go back to the competitions",
"Your team ID:": "Your team ID:",
"Changes successfully saved to your team.": "Changes successfully saved to your team."
}
+7 -1
View File
@@ -47,5 +47,11 @@
"Withdrawals of {{threshold}} {{symbol}} or more will be delayed for {{delay}}.": "Withdrawals of {{threshold}} {{symbol}} or more will be delayed for {{delay}}.",
"Withdrawals ready": "Withdrawals ready",
"You have no assets to withdraw": "You have no assets to withdraw",
"Your funds have been unlocked for withdrawal - <0>View in block explorer<0>": "Your funds have been unlocked for withdrawal - <0>View in block explorer<0>"
"Your funds have been unlocked for withdrawal - <0>View in block explorer<0>": "Your funds have been unlocked for withdrawal - <0>View in block explorer<0>",
"Gas fee": "Gas fee",
"Estimated gas fee for the withdrawal transaction (refreshes each 15 seconds)": "Estimated gas fee for the withdrawal transaction (refreshes each 15 seconds)",
"It seems that the current gas prices are exceeding the amount you're trying to withdraw": "It seems that the current gas prices are exceeding the amount you're trying to withdraw",
"The current gas price range": "The current gas price range",
"min": "min",
"max": "max"
}
-20
View File
@@ -67,26 +67,6 @@ query EstimatePosition(
# we can set this variable to true so that we can format with market.decimalPlaces
scaleLiquidationPriceToMarketDecimals: true
) {
margin {
worstCase {
maintenanceLevel
searchLevel
initialLevel
collateralReleaseLevel
marginMode
marginFactor
orderMarginLevel
}
bestCase {
maintenanceLevel
searchLevel
initialLevel
collateralReleaseLevel
marginMode
marginFactor
orderMarginLevel
}
}
collateralIncreaseEstimate {
worstCase
bestCase
+1 -21
View File
@@ -33,7 +33,7 @@ export type EstimatePositionQueryVariables = Types.Exact<{
}>;
export type EstimatePositionQuery = { __typename?: 'Query', estimatePosition?: { __typename?: 'PositionEstimate', margin: { __typename?: 'MarginEstimate', worstCase: { __typename?: 'MarginLevels', maintenanceLevel: string, searchLevel: string, initialLevel: string, collateralReleaseLevel: string, marginMode: Types.MarginMode, marginFactor: string, orderMarginLevel: string }, bestCase: { __typename?: 'MarginLevels', maintenanceLevel: string, searchLevel: string, initialLevel: string, collateralReleaseLevel: string, marginMode: Types.MarginMode, marginFactor: string, orderMarginLevel: string } }, collateralIncreaseEstimate: { __typename?: 'CollateralIncreaseEstimate', worstCase: string, bestCase: string }, liquidation?: { __typename?: 'LiquidationEstimate', worstCase: { __typename?: 'LiquidationPrice', open_volume_only: string, including_buy_orders: string, including_sell_orders: string }, bestCase: { __typename?: 'LiquidationPrice', open_volume_only: string, including_buy_orders: string, including_sell_orders: string } } | null } | null };
export type EstimatePositionQuery = { __typename?: 'Query', estimatePosition?: { __typename?: 'PositionEstimate', collateralIncreaseEstimate: { __typename?: 'CollateralIncreaseEstimate', worstCase: string, bestCase: string }, liquidation?: { __typename?: 'LiquidationEstimate', worstCase: { __typename?: 'LiquidationPrice', open_volume_only: string, including_buy_orders: string, including_sell_orders: string }, bestCase: { __typename?: 'LiquidationPrice', open_volume_only: string, including_buy_orders: string, including_sell_orders: string } } | null } | null };
export const PositionFieldsFragmentDoc = gql`
fragment PositionFields on Position {
@@ -144,26 +144,6 @@ export const EstimatePositionDocument = gql`
includeCollateralIncreaseInAvailableCollateral: $includeCollateralIncreaseInAvailableCollateral
scaleLiquidationPriceToMarketDecimals: true
) {
margin {
worstCase {
maintenanceLevel
searchLevel
initialLevel
collateralReleaseLevel
marginMode
marginFactor
orderMarginLevel
}
bestCase {
maintenanceLevel
searchLevel
initialLevel
collateralReleaseLevel
marginMode
marginFactor
orderMarginLevel
}
}
collateralIncreaseEstimate {
worstCase
bestCase
@@ -1,7 +1,6 @@
import type { PartialDeep } from 'type-fest';
import merge from 'lodash/merge';
import type { EstimatePositionQuery } from './__generated__/Positions';
import { MarginMode } from '@vegaprotocol/types';
export const estimatePositionQuery = (
override?: PartialDeep<EstimatePositionQuery>
@@ -9,26 +8,6 @@ export const estimatePositionQuery = (
const defaultResult: EstimatePositionQuery = {
estimatePosition: {
__typename: 'PositionEstimate',
margin: {
bestCase: {
collateralReleaseLevel: '1000000',
initialLevel: '500000',
maintenanceLevel: '200000',
searchLevel: '300000',
marginFactor: '1',
orderMarginLevel: '0',
marginMode: MarginMode.MARGIN_MODE_CROSS_MARGIN,
},
worstCase: {
collateralReleaseLevel: '1100000',
initialLevel: '600000',
maintenanceLevel: '300000',
searchLevel: '400000',
marginFactor: '1',
orderMarginLevel: '0',
marginMode: MarginMode.MARGIN_MODE_CROSS_MARGIN,
},
},
collateralIncreaseEstimate: {
bestCase: '0',
worstCase: '0',
@@ -30,26 +30,6 @@ describe('LiquidationPrice', () => {
result: {
data: {
estimatePosition: {
margin: {
worstCase: {
maintenanceLevel: '100',
searchLevel: '100',
initialLevel: '100',
collateralReleaseLevel: '100',
orderMarginLevel: '0',
marginFactor: '0',
marginMode: MarginMode.MARGIN_MODE_CROSS_MARGIN,
},
bestCase: {
maintenanceLevel: '100',
searchLevel: '100',
initialLevel: '100',
collateralReleaseLevel: '100',
orderMarginLevel: '0',
marginFactor: '0',
marginMode: MarginMode.MARGIN_MODE_CROSS_MARGIN,
},
},
collateralIncreaseEstimate: {
bestCase: '0',
worstCase: '0',
+3 -2
View File
@@ -41,16 +41,17 @@ export const TradingView = ({
const chartContainerRef = useRef<HTMLDivElement>(null);
const widgetRef = useRef<IChartingLibraryWidget>();
const datafeed = useDatafeed();
const prevMarketId = usePrevious(marketId);
const prevTheme = usePrevious(theme);
const datafeed = useDatafeed(marketId);
useEffect(() => {
// Widget already created
if (widgetRef.current !== undefined) {
// Update the symbol if changed
if (marketId !== prevMarketId) {
datafeed.setSymbol(marketId);
widgetRef.current.setSymbol(
marketId,
(interval ? interval : '15') as TVResolutionString,
+12 -4
View File
@@ -44,14 +44,22 @@ const configurationData: DatafeedConfiguration = {
supported_resolutions: supportedResolutions as ResolutionString[],
} as const;
export const useDatafeed = () => {
// HACK: local handle for market id
let requestedSymbol: string | undefined = undefined;
export const useDatafeed = (marketId: string) => {
const hasHistory = useRef(false);
const subRef = useRef<Subscription>();
const client = useApolloClient();
const datafeed = useMemo(() => {
const feed: IBasicDataFeed = {
const feed: IBasicDataFeed & { setSymbol: (symbol: string) => void } = {
setSymbol: (symbol: string) => {
// re-setting the symbol so it could be consumed by `resolveSymbol`
requestedSymbol = symbol;
},
onReady: (callback) => {
requestedSymbol = marketId;
setTimeout(() => callback(configurationData));
},
@@ -68,7 +76,7 @@ export const useDatafeed = () => {
const result = await client.query<SymbolQuery, SymbolQueryVariables>({
query: SymbolDocument,
variables: {
marketId,
marketId: requestedSymbol || marketId,
},
});
@@ -242,7 +250,7 @@ export const useDatafeed = () => {
};
return feed;
}, [client]);
}, [client, marketId]);
useEffect(() => {
return () => {
+42
View File
@@ -0,0 +1,42 @@
import BigNumber from 'bignumber.js';
import { EtherUnit, formatEther, unitiseEther } from './ether';
describe('unitiseEther', () => {
it.each([
[1, '1', EtherUnit.wei],
[999, '999', EtherUnit.wei],
[1000, '1', EtherUnit.kwei],
[9999, '9.999', EtherUnit.kwei],
[10000, '10', EtherUnit.kwei],
[999999, '999.999', EtherUnit.kwei],
[1000000, '1', EtherUnit.mwei],
[999999999, '999.999999', EtherUnit.mwei],
[1000000000, '1', EtherUnit.gwei],
['999999999999999999', '999999999.999999999', EtherUnit.gwei], // max gwei
[1e18, '1', EtherUnit.ether], // 1 ETH
[1234e18, '1234', EtherUnit.ether], // 1234 ETH
])('unitises %s to [%s, %s]', (value, expectedOutput, expectedUnit) => {
const [output, unit] = unitiseEther(value);
expect(output.toFixed()).toEqual(expectedOutput);
expect(unit).toEqual(expectedUnit);
});
it('unitises to requested unit', () => {
const [output, unit] = unitiseEther(1, EtherUnit.kwei);
expect(output).toEqual(BigNumber(0.001));
expect(unit).toEqual(EtherUnit.kwei);
});
});
describe('formatEther', () => {
it.each([
[1, EtherUnit.wei, '1 wei'],
[12, EtherUnit.kwei, '12 kwei'],
[123, EtherUnit.gwei, '123 gwei'],
[3, EtherUnit.ether, '3 ETH'],
[234.67776331, EtherUnit.gwei, '235 gwei'],
[12.12, EtherUnit.gwei, '12 gwei'],
])('formats [%s, %s] to "%s"', (value, unit, expectedOutput) => {
expect(formatEther([BigNumber(value), unit])).toEqual(expectedOutput);
});
});
+84
View File
@@ -0,0 +1,84 @@
import { formatNumber, toBigNum } from './number';
import type BigNumber from 'bignumber.js';
export enum EtherUnit {
/** 1 wei = 10^-18 ETH */
wei = '0',
/** 1 kwei = 1000 wei */
kwei = '3',
/** 1 mwei = 1000 kwei */
mwei = '6',
/** 1 gwei = 1000 kwei */
gwei = '9',
// other denominations:
// microether = '12', // aka szabo, µETH
// milliether = '15', // aka finney, mETH
/** 1 ETH = 1B gwei = 10^18 wei */
ether = '18',
}
export const etherUnitMapping: Record<EtherUnit, string> = {
[EtherUnit.wei]: 'wei',
[EtherUnit.kwei]: 'kwei',
[EtherUnit.mwei]: 'mwei',
[EtherUnit.gwei]: 'gwei',
// [EtherUnit.microether]: 'µETH', // szabo
// [EtherUnit.milliether]: 'mETH', // finney
[EtherUnit.ether]: 'ETH',
};
type InputValue = string | number | BigNumber;
type UnitisedTuple = [value: BigNumber, unit: EtherUnit];
/**
* Converts given raw value to the unitised tuple of amount and unit
*/
export const unitiseEther = (
input: InputValue,
forceUnit?: EtherUnit
): UnitisedTuple => {
const units = Object.values(EtherUnit).reverse();
let value = toBigNum(input, Number(forceUnit || EtherUnit.ether));
let unit = forceUnit || EtherUnit.ether;
if (!forceUnit) {
for (const u of units) {
const v = toBigNum(input, Number(u));
value = v;
unit = u;
if (v.isGreaterThanOrEqualTo(1)) break;
}
}
return [value, unit];
};
/**
* `formatNumber` wrapper for unitised ether values (attaches unit name)
*/
export const formatEther = (
input: UnitisedTuple,
decimals = 0,
noUnit = false
) => {
const [value, unit] = input;
const num = formatNumber(value, decimals);
const unitName = noUnit ? '' : etherUnitMapping[unit];
return `${num} ${unitName}`.trim();
};
/**
* Utility function that formats given raw amount as ETH.
* Example:
* Given value of `1` this will return `0.000000000000000001 ETH`
*/
export const asETH = (input: InputValue, noUnit = false) =>
formatEther(
unitiseEther(input, EtherUnit.ether),
Number(EtherUnit.ether),
noUnit
);
+1
View File
@@ -4,3 +4,4 @@ export * from './range';
export * from './size';
export * from './strings';
export * from './trigger';
export * from './ether';
+20
View File
@@ -13,6 +13,7 @@ import {
toDecimal,
toNumberParts,
formatNumberRounded,
toQUSD,
} from './number';
describe('number utils', () => {
@@ -282,3 +283,22 @@ describe('formatNumberRounded', () => {
);
});
});
describe('toQUSD', () => {
it.each([
[0, 0, 0],
[1, 1, 1],
[1, 10, 0.1],
[1, 100, 0.01],
// real life examples
[1000000, 1000000, 1], // USDC -> 1 USDC ~= 1 qUSD
[500000, 1000000, 0.5], // USDC => 0.6 USDC ~= 0.5 qUSD
[1e18, 1e18, 1], // VEGA -> 1 VEGA ~= 1 qUSD
[123.45e18, 1e18, 123.45], // VEGA -> 1 VEGA ~= 1 qUSD
[1e18, 5e14, 2000], // WETH -> 1 WETH ~= 2000 qUSD
[1e9, 5e14, 0.000002], // gwei -> 1 gwei ~= 0.000002 qUSD
[50000e9, 5e14, 0.1], // gwei -> 50000 gwei ~= 0.1 qUSD
])('converts (%d, %d) to %d qUSD', (amount, quantum, expected) => {
expect(toQUSD(amount, quantum).toNumber()).toEqual(expected);
});
});
+22 -1
View File
@@ -26,7 +26,7 @@ export function toDecimal(numberOfDecimals: number) {
}
export function toBigNum(
rawValue: string | number,
rawValue: string | number | BigNumber,
decimals: number
): BigNumber {
const divides = new BigNumber(10).exponentiatedBy(decimals);
@@ -233,3 +233,24 @@ export const formatNumberRounded = (
return value;
};
/**
* Converts given amount in one asset (determined by raw amount
* and quantum values) to qUSD.
* @param amount The raw amount
* @param quantum The quantum value of the asset.
*/
export const toQUSD = (
amount: string | number | BigNumber,
quantum: string | number
) => {
const value = new BigNumber(amount);
let q = new BigNumber(quantum);
if (q.isNaN() || q.isLessThanOrEqualTo(0)) {
q = new BigNumber(1);
}
const qUSD = value.dividedBy(q);
return qUSD;
};
+1
View File
@@ -19,6 +19,7 @@ export * from './lib/use-ethereum-transaction';
export * from './lib/use-ethereum-withdraw-approval-toasts';
export * from './lib/use-ethereum-withdraw-approvals-manager';
export * from './lib/use-ethereum-withdraw-approvals-store';
export * from './lib/use-gas-price';
export * from './lib/use-get-withdraw-delay';
export * from './lib/use-get-withdraw-threshold';
export * from './lib/use-token-contract';
+111
View File
@@ -0,0 +1,111 @@
import { useEffect, useState } from 'react';
import { useWeb3React } from '@web3-react/core';
import { useEthereumConfig } from './use-ethereum-config';
import BigNumber from 'bignumber.js';
const DEFAULT_INTERVAL = 15000; // 15 seconds
/**
* These are the hex values of the collateral bridge contract methods.
*
* Collateral bridge address: 0x23872549cE10B40e31D6577e0A920088B0E0666a
* Etherscan: https://etherscan.io/address/0x23872549cE10B40e31D6577e0A920088B0E0666a#writeContract
*/
export enum ContractMethod {
DEPOSIT_ASSET = '0xf7683932',
EXEMPT_DEPOSITOR = '0xb76fbb75',
GLOBAL_RESUME = '0xd72ed529',
GLOBAL_STOP = '0x9dfd3c88',
LIST_ASSET = '0x0ff3562c',
REMOVE_ASSET = '0xc76de358',
REVOKE_EXEMPT_DEPOSITOR = '0x6a1c6fa4',
SET_ASSET_LIMITS = '0x41fb776d',
SET_WITHDRAW_DELAY = '0x5a246728',
WITHDRAW_ASSET = '0x3ad90635',
}
export type GasData = {
/** The base (minimum) price of 1 unit of gas */
basePrice: BigNumber;
/** The maximum price of 1 unit of gas */
maxPrice: BigNumber;
/** The amount of gas (units) needed to process a transaction */
gas: BigNumber;
};
type Provider = NonNullable<ReturnType<typeof useWeb3React>['provider']>;
const retrieveGasData = async (
provider: Provider,
account: string,
contractAddress: string,
contractMethod: ContractMethod
) => {
try {
const data = await provider.getFeeData();
const estGasAmount = await provider.estimateGas({
to: account,
from: contractAddress,
data: contractMethod,
});
if (data.lastBaseFeePerGas && data.maxFeePerGas) {
return {
// converts also form ethers BigNumber to "normal" BigNumber
basePrice: BigNumber(data.lastBaseFeePerGas.toString()),
maxPrice: BigNumber(data.maxFeePerGas.toString()),
gas: BigNumber(estGasAmount.toString()),
};
}
} catch (err) {
// NOOP - could not get the estimated gas or the fee data from
// the network. This could happen if there's an issue with transaction
// request parameters (e.g. to/from mismatch)
}
return undefined;
};
/**
* Gets the "current" gas price from the ethereum network.
*/
export const useGasPrice = (
method: ContractMethod,
interval = DEFAULT_INTERVAL
): GasData | undefined => {
const [gas, setGas] = useState<GasData | undefined>(undefined);
const { provider, account } = useWeb3React();
const { config } = useEthereumConfig();
useEffect(() => {
if (!provider || !config || !account) return;
const retrieve = async () => {
retrieveGasData(
provider,
account,
config.collateral_bridge_contract.address,
method
).then((gasData) => {
if (gasData) {
setGas(gasData);
}
});
};
retrieve();
// Retrieves another estimation and prices in [interval] ms.
let i: ReturnType<typeof setInterval>;
if (interval > 0) {
i = setInterval(() => {
retrieve();
}, interval);
}
return () => {
if (i) clearInterval(i);
};
}, [account, config, interval, method, provider]);
return gas;
};
+4
View File
@@ -27,6 +27,7 @@ import { useForm, Controller, useWatch } from 'react-hook-form';
import { WithdrawLimits } from './withdraw-limits';
import {
ETHEREUM_EAGER_CONNECT,
type GasData,
useWeb3ConnectStore,
useWeb3Disconnect,
} from '@vegaprotocol/web3';
@@ -56,6 +57,7 @@ export interface WithdrawFormProps {
delay: number | undefined;
onSelectAsset: (assetId: string) => void;
submitWithdraw: (withdrawal: WithdrawalArgs) => void;
gasPrice?: GasData;
}
const WithdrawDelayNotification = ({
@@ -117,6 +119,7 @@ export const WithdrawForm = ({
delay,
onSelectAsset,
submitWithdraw,
gasPrice,
}: WithdrawFormProps) => {
const t = useT();
const ethereumAddress = useEthereumAddress();
@@ -247,6 +250,7 @@ export const WithdrawForm = ({
delay={delay}
balance={balance}
asset={selectedAsset}
gas={gasPrice}
/>
</div>
)}
+145 -1
View File
@@ -1,6 +1,6 @@
import type { Asset } from '@vegaprotocol/assets';
import { CompactNumber } from '@vegaprotocol/react-helpers';
import { WITHDRAW_THRESHOLD_TOOLTIP_TEXT } from '@vegaprotocol/assets';
import { WITHDRAW_THRESHOLD_TOOLTIP_TEXT, useWETH } from '@vegaprotocol/assets';
import {
KeyValueTable,
KeyValueTableRow,
@@ -9,6 +9,16 @@ import {
import BigNumber from 'bignumber.js';
import { formatDistanceToNow } from 'date-fns';
import { useT } from './use-t';
import { type GasData } from '@vegaprotocol/web3';
import {
asETH,
formatEther,
formatNumber,
removeDecimal,
toQUSD,
unitiseEther,
} from '@vegaprotocol/utils';
import classNames from 'classnames';
interface WithdrawLimitsProps {
amount: string;
@@ -16,6 +26,7 @@ interface WithdrawLimitsProps {
balance: BigNumber;
delay: number | undefined;
asset: Asset;
gas?: GasData;
}
export const WithdrawLimits = ({
@@ -24,6 +35,7 @@ export const WithdrawLimits = ({
balance,
delay,
asset,
gas,
}: WithdrawLimitsProps) => {
const t = useT();
const delayTime =
@@ -64,6 +76,24 @@ export const WithdrawLimits = ({
label: t('Delay time'),
value: threshold && delay ? delayTime : '-',
},
{
key: 'GAS_FEE',
tooltip: t(
'Estimated gas fee for the withdrawal transaction (refreshes each 15 seconds)'
),
label: t('Gas fee'),
value: gas ? (
<GasPrice
gasPrice={gas}
amount={{
value: removeDecimal(amount, asset.decimals),
quantum: asset.quantum,
}}
/>
) : (
'-'
),
},
];
return (
@@ -91,3 +121,117 @@ export const WithdrawLimits = ({
</KeyValueTable>
);
};
const GasPrice = ({
gasPrice,
amount,
}: {
gasPrice: WithdrawLimitsProps['gas'];
amount: { value: string; quantum: string };
}) => {
const t = useT();
const { quantum: wethQuantum } = useWETH();
const { value, quantum } = amount;
if (gasPrice) {
const {
basePrice: basePricePerGas,
maxPrice: maxPricePerGas,
gas,
} = gasPrice;
const basePrice = basePricePerGas.multipliedBy(gas);
const maxPrice = maxPricePerGas.multipliedBy(gas);
const basePriceQUSD = toQUSD(basePrice, wethQuantum);
const maxPriceQUSD = toQUSD(maxPrice, wethQuantum);
const withdrawalAmountQUSD = toQUSD(value, quantum);
const isExpensive =
!withdrawalAmountQUSD.isLessThanOrEqualTo(0) &&
withdrawalAmountQUSD.isLessThanOrEqualTo(maxPriceQUSD);
const expensiveClassNames = {
'text-vega-red-500':
isExpensive && withdrawalAmountQUSD.isLessThanOrEqualTo(basePriceQUSD),
'text-vega-orange-500':
isExpensive &&
withdrawalAmountQUSD.isGreaterThan(basePriceQUSD) &&
withdrawalAmountQUSD.isLessThanOrEqualTo(maxPriceQUSD),
};
const uBasePricePerGas = unitiseEther(basePricePerGas);
const uMaxPricePerGas = unitiseEther(
maxPricePerGas,
uBasePricePerGas[1] // forces the same unit as min price
);
const uBasePrice = unitiseEther(basePrice);
const uMaxPrice = unitiseEther(maxPrice, uBasePrice[1]);
let range = (
<span>
{formatEther(uBasePrice, 0, true)} - {formatEther(uMaxPrice)}
</span>
);
// displays range as ETH when it's greater that 1000000 gwei
if (uBasePrice[0].isGreaterThan(1e6)) {
range = (
<span className="flex flex-col font-mono md:text-[11px]">
<span>
{t('min')}: {asETH(basePrice)}
</span>
<span>
{t('max')}: {asETH(maxPrice)}
</span>
</span>
);
}
return (
<div className={classNames('flex flex-col items-end self-end')}>
<Tooltip description={t('The current gas price range')}>
<span>
{/* base price per gas unit */}
{formatEther(uBasePricePerGas, 0, true)} -{' '}
{formatEther(uMaxPricePerGas)} / gas
</span>
</Tooltip>
<Tooltip
description={
<div className="flex flex-col gap-1">
{isExpensive && (
<span className={classNames(expensiveClassNames)}>
{t(
"It seems that the current gas prices are exceeding the amount you're trying to withdraw"
)}{' '}
<strong>
(~{formatNumber(withdrawalAmountQUSD, 2)} qUSD)
</strong>
.
</span>
)}
<span>
{formatNumber(gas)} gas &times; {asETH(basePricePerGas)} <br />{' '}
= {asETH(basePrice)}
</span>
<span>
{formatNumber(gas)} gas &times; {asETH(maxPricePerGas)} <br /> ={' '}
{asETH(maxPrice)}
</span>
</div>
}
>
<span className={classNames(expensiveClassNames, 'text-xs')}>
{range}
</span>
</Tooltip>
<span className="text-muted text-xs">
~{formatNumber(basePriceQUSD, 2)} - {formatNumber(maxPriceQUSD, 2)}{' '}
qUSD
</span>
</div>
);
}
return '-';
};
@@ -38,6 +38,7 @@ jest.mock('@vegaprotocol/web3', () => ({
useGetWithdrawDelay: () => {
return () => Promise.resolve(10000);
},
useGasPrice: () => undefined,
}));
describe('WithdrawManager', () => {
@@ -4,6 +4,7 @@ import { WithdrawForm } from './withdraw-form';
import type { Asset } from '@vegaprotocol/assets';
import type { AccountFieldsFragment } from '@vegaprotocol/accounts';
import { useWithdrawAsset } from './use-withdraw-asset';
import { ContractMethod, useGasPrice } from '@vegaprotocol/web3';
export interface WithdrawManagerProps {
assets: Asset[];
@@ -20,6 +21,8 @@ export const WithdrawManager = ({
}: WithdrawManagerProps) => {
const { asset, balance, min, threshold, delay, handleSelectAsset } =
useWithdrawAsset(assets, accounts, assetId);
const gasPrice = useGasPrice(ContractMethod.WITHDRAW_ASSET);
return (
<WithdrawForm
selectedAsset={asset}
@@ -30,6 +33,7 @@ export const WithdrawManager = ({
submitWithdraw={submit}
threshold={threshold}
delay={delay}
gasPrice={gasPrice}
/>
);
};
+3 -1
View File
@@ -11,7 +11,9 @@
"echo $NX_VEGA_URL",
"echo $NX_TENDERMINT_URL",
"echo $NX_TENDERMINT_WEBSOCKET_URL",
"echo $NX_ETHEREUM_PROVIDER_URL"
"echo $NX_ETHEREUM_PROVIDER_URL",
"echo $NX_CHARTING_LIBRARY_PATH",
"echo $NX_CHARTING_LIBRARY_HASH"
]
}
}
-10
View File
@@ -40,22 +40,12 @@
## Transfer
- **Must** can select include transfer fee (<a name="1003-TRAN-015" href="#1003-TRAN-015">1003-TRAN-015</a>)
- **Must** display tooltip for "Include transfer fee" when hovered over.(<a name="1003-TRAN-016" href="#1003-TRAN-016">1003-TRAN-016</a>)
- **Must** display tooltip for "Transfer fee when hovered over.(<a name="1003-TRAN-017" href="#1003-TRAN-017">1003-TRAN-017</a>)
- **Must** display tooltip for "Amount to be transferred" when hovered over.(<a name="1003-TRAN-018" href="#1003-TRAN-018">1003-TRAN-018</a>)
- **Must** display tooltip for "Total amount (with fee)" when hovered over.(<a name="1003-TRAN-019" href="#1003-TRAN-019">1003-TRAN-019</a>)
- **Must** amount to be transferred and transfer fee update correctly when include transfer fee is selected (<a name="1003-TRAN-020" href="#1003-TRAN-020">1003-TRAN-020</a>)
- **Must** total amount with fee is correct with and without "Include transfer fee" selected (<a name="1003-TRAN-021" href="#1003-TRAN-021">1003-TRAN-021</a>)
- **Must** i cannot select include transfer fee unless amount is entered (<a name="1003-TRAN-022" href="#1003-TRAN-022">1003-TRAN-022</a>)
- **Must** With all fields entered correctly, clicking "confirm transfer" button will start transaction(<a name="1003-TRAN-023" href="#1003-TRAN-023">1003-TRAN-023</a>)
### Transfer page