Compare commits

..
Author SHA1 Message Date
asiaznik dfb78f0af5 fix(trading): mark as updated when tx confirmed 2024-02-01 14:06:24 +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
28 changed files with 727 additions and 168 deletions
@@ -125,7 +125,7 @@ const CreateTeamFormContainer = ({ isSolo }: { isSolo: boolean }) => {
onSubmit={onSubmit}
status={status}
err={err}
isSolo={isSolo}
isCreatingSoloTeam={isSolo}
/>
);
};
@@ -31,10 +31,7 @@ export const CompetitionsHome = () => {
currentEpoch,
});
const { data: teamsData, loading: teamsLoading } = useTeams({
sortByField: ['totalQuantumRewards'],
order: 'desc',
});
const { data: teamsData, loading: teamsLoading } = useTeams();
return (
<ErrorBoundary>
@@ -38,12 +38,11 @@ export const CompetitionsTeam = () => {
const TeamPageContainer = ({ teamId }: { teamId: string | undefined }) => {
const t = useT();
const { pubKey } = useVegaWallet();
const { team, partyTeam, stats, members, games, loading, refetch } = useTeam(
teamId,
pubKey || undefined
);
const { data, team, partyTeam, stats, members, games, loading, refetch } =
useTeam(teamId, pubKey || undefined);
if (loading) {
// only show spinner on first load so when users join teams its smoother
if (!data && loading) {
return (
<Splash>
<Loader />
@@ -186,7 +185,10 @@ const Members = ({ members }: { members?: Member[] }) => {
const data = orderBy(
members.map((m) => ({
referee: <RefereeLink pubkey={m.referee} />,
referee: <RefereeLink pubkey={m.referee} isCreator={m.isCreator} />,
rewards: formatNumber(m.totalQuantumRewards),
volume: formatNumber(m.totalQuantumVolume),
gamesPlayed: formatNumber(m.totalGamesPlayed),
joinedAt: getDateTimeFormat().format(new Date(m.joinedAt)),
joinedAtEpoch: Number(m.joinedAtEpoch),
})),
@@ -197,7 +199,10 @@ const Members = ({ members }: { members?: Member[] }) => {
return (
<Table
columns={[
{ name: 'referee', displayName: t('Referee') },
{ name: 'referee', displayName: t('Member ID') },
{ name: 'rewards', displayName: t('Rewards earned') },
{ name: 'volume', displayName: t('Total volume') },
{ name: 'gamesPlayed', displayName: t('Games played') },
{
name: 'joinedAt',
displayName: t('Joined at'),
@@ -213,14 +218,24 @@ const Members = ({ members }: { members?: Member[] }) => {
);
};
const RefereeLink = ({ pubkey }: { pubkey: string }) => {
const RefereeLink = ({
pubkey,
isCreator,
}: {
pubkey: string;
isCreator: boolean;
}) => {
const t = useT();
const linkCreator = useLinks(DApp.Explorer);
const link = linkCreator(EXPLORER_PARTIES.replace(':id', pubkey));
return (
<Link to={link} target="_blank" className="underline underline-offset-4">
{truncateMiddle(pubkey)}
</Link>
<>
<Link to={link} target="_blank" className="underline underline-offset-4">
{truncateMiddle(pubkey)}
</Link>{' '}
<span className="text-muted text-xs">{isCreator ? t('Owner') : ''}</span>
</>
);
};
@@ -17,10 +17,7 @@ export const CompetitionsTeams = () => {
usePageTitle([t('Competitions'), t('Teams')]);
const { data: teamsData, loading: teamsLoading } = useTeams({
sortByField: ['totalQuantumRewards'],
order: 'desc',
});
const { data: teamsData, loading: teamsLoading } = useTeams();
const inputRef = useRef<HTMLInputElement>(null);
const [filter, setFilter] = useState<string | null | undefined>(undefined);
@@ -98,7 +98,7 @@ const UpdateTeamFormContainer = ({
type={TransactionType.UpdateReferralSet}
status={status}
err={err}
isSolo={team.closed}
isCreatingSoloTeam={team.closed}
onSubmit={onSubmit}
defaultValues={defaultValues}
/>
@@ -6,11 +6,7 @@ import {
VegaIcon,
VegaIconNames,
} from '@vegaprotocol/ui-toolkit';
import {
useSimpleTransaction,
useVegaWallet,
type Status,
} from '@vegaprotocol/wallet';
import { useSimpleTransaction, useVegaWallet } from '@vegaprotocol/wallet';
import { useT } from '../../lib/use-t';
import { type Team } from '../../lib/hooks/use-team';
import { useState } from 'react';
@@ -27,19 +23,8 @@ export const JoinTeam = ({
refetch: () => void;
}) => {
const { pubKey, isReadOnly } = useVegaWallet();
const { send, status } = useSimpleTransaction({
onSuccess: refetch,
});
const [confirmDialog, setConfirmDialog] = useState<JoinType>();
const joinTeam = () => {
send({
joinTeam: {
id: team.teamId,
},
});
};
return (
<>
<JoinButton
@@ -56,11 +41,10 @@ export const JoinTeam = ({
{confirmDialog !== undefined && (
<DialogContent
type={confirmDialog}
status={status}
team={team}
partyTeam={partyTeam}
onConfirm={joinTeam}
onCancel={() => setConfirmDialog(undefined)}
refetch={refetch}
/>
)}
</Dialog>
@@ -110,7 +94,7 @@ export const JoinButton = ({
// Not creator of the team, but still can't switch because
// creators cannot leave their own team
return (
<Tooltip description="As a team creator, you cannot switch teams">
<Tooltip description={t('As a team creator, you cannot switch teams')}>
<Button intent={Intent.Primary} disabled={true}>
{t('Switch team')}{' '}
</Button>
@@ -149,21 +133,39 @@ export const JoinButton = ({
const DialogContent = ({
type,
status,
team,
partyTeam,
onConfirm,
onCancel,
refetch,
}: {
type: JoinType;
status: Status;
team: Team;
partyTeam?: Team;
onConfirm: () => void;
onCancel: () => void;
refetch: () => void;
}) => {
const t = useT();
const { send, status, error } = useSimpleTransaction({
onSuccess: refetch,
});
const joinTeam = () => {
send({
joinTeam: {
id: team.teamId,
},
});
};
if (error) {
return (
<p className="text-vega-red break-words first-letter:capitalize">
{error}
</p>
);
}
if (status === 'requested') {
return <p>{t('Confirm in wallet...')}</p>;
}
@@ -213,7 +215,7 @@ const DialogContent = ({
</>
)}
<div className="flex justify-between gap-2">
<Button onClick={onConfirm} intent={Intent.Success}>
<Button onClick={joinTeam} intent={Intent.Success}>
{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;
@@ -28,8 +32,8 @@ export type FormFields = {
};
export enum TransactionType {
CreateReferralSet,
UpdateReferralSet,
CreateReferralSet = 'CreateReferralSet',
UpdateReferralSet = 'UpdateReferralSet',
}
const prepareTransaction = (
@@ -75,14 +79,14 @@ export const TeamForm = ({
type,
status,
err,
isSolo,
isCreatingSoloTeam,
onSubmit,
defaultValues,
}: {
type: TransactionType;
status: ReturnType<typeof useReferralSetTransaction>['status'];
err: ReturnType<typeof useReferralSetTransaction>['err'];
isSolo: boolean;
isCreatingSoloTeam: boolean;
onSubmit: ReturnType<typeof useReferralSetTransaction>['onSubmit'];
defaultValues?: FormFields;
}) => {
@@ -96,8 +100,7 @@ export const TeamForm = ({
formState: { errors },
} = useForm<FormFields>({
defaultValues: {
private: isSolo,
allowList: '',
private: isCreatingSoloTeam,
...defaultValues,
},
});
@@ -110,12 +113,7 @@ export const TeamForm = ({
return (
<form onSubmit={handleSubmit(sendTransaction)}>
<input
type="hidden"
{...register('id', {
disabled: true,
})}
/>
<input type="hidden" {...register('id')} />
<TradingFormGroup label={t('Team name')} labelFor="name">
<TradingInput {...register('name', { required: t('Required') })} />
{errors.name?.message && (
@@ -161,62 +159,65 @@ export const TeamForm = ({
</TradingInputError>
)}
</TradingFormGroup>
{!isSolo && (
<>
<TradingFormGroup
label={t('Make team private')}
labelFor="private"
hideLabel={true}
>
<Controller
name="private"
control={control}
render={({ field }) => {
return (
<TradingCheckbox
label={t('Make team private')}
checked={field.value}
onCheckedChange={(value) => {
field.onChange(value);
}}
disabled={isSolo}
/>
);
}}
/>
</TradingFormGroup>
{isPrivate && (
{
// allow changing to private/public if editing, but don't show these options if making a solo team
(type === TransactionType.UpdateReferralSet || !isCreatingSoloTeam) && (
<>
<TradingFormGroup
label={t('Public key allow list')}
labelFor="allowList"
labelDescription={t(
'Use a comma separated list to allow only specific public keys to join the team'
)}
label={t('Make team private')}
labelFor="private"
hideLabel={true}
>
<TextArea
{...register('allowList', {
required: t('Required'),
disabled: isSolo,
validate: {
allowList: (value) => {
const publicKeys = parseAllowListText(value);
if (publicKeys.every((pk) => isValidVegaPublicKey(pk))) {
return true;
}
return t('Invalid public key found in allow list');
},
},
})}
<Controller
name="private"
control={control}
render={({ field }) => {
return (
<TradingCheckbox
label={t('Make team private')}
checked={field.value}
onCheckedChange={(value) => {
field.onChange(value);
}}
/>
);
}}
/>
{errors.allowList?.message && (
<TradingInputError forInput="avatarUrl">
{errors.allowList.message}
</TradingInputError>
)}
</TradingFormGroup>
)}
</>
)}
{isPrivate && (
<TradingFormGroup
label={t('Public key allow list')}
labelFor="allowList"
labelDescription={t(
'Use a comma separated list to allow only specific public keys to join the team'
)}
>
<TextArea
{...register('allowList', {
required: t('Required'),
validate: {
allowList: (value) => {
const publicKeys = parseAllowListText(value);
if (
publicKeys.every((pk) => isValidVegaPublicKey(pk))
) {
return true;
}
return t('Invalid public key found in allow list');
},
},
})}
/>
{errors.allowList?.message && (
<TradingInputError forInput="avatarUrl">
{errors.allowList.message}
</TradingInputError>
)}
</TradingFormGroup>
)}
</>
)
}
{err && (
<p className="text-danger text-xs mb-4 first-letter:capitalize">
{err}
@@ -242,20 +243,56 @@ 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}>
{text}
</TradingButton>
{status === 'confirmed' && confirmed}
</div>
);
};
const parseAllowListText = (str: string) => {
const parseAllowListText = (str: string = '') => {
return str
.split(',')
.map((v) => v.trim())
+1 -1
View File
@@ -1,3 +1,3 @@
CONSOLE_IMAGE_NAME=vegaprotocol/trading:latest
VEGA_VERSION=v0.74.0-preview.7
VEGA_VERSION=v0.74.0-preview.8
LOCAL_SERVER=false
+3 -2
View File
@@ -137,6 +137,7 @@ 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")
@@ -152,7 +153,7 @@ def test_team_page_games_table(team_page: Page):
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 (3)")
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")
@@ -161,7 +162,7 @@ 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("3")
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"
+18 -1
View File
@@ -17,7 +17,7 @@ fragment TeamStatsFields on TeamStatistics {
totalGamesPlayed
quantumRewards {
epoch
total_quantum_rewards
totalQuantumRewards
}
gamesPlayed
}
@@ -51,6 +51,13 @@ fragment TeamGameFields on Game {
}
}
fragment TeamMemberStatsFields on TeamMemberStatistics {
partyId
totalQuantumVolume
totalQuantumRewards
totalGamesPlayed
}
query Team($teamId: ID!, $partyId: ID, $aggregationEpochs: Int) {
teams(teamId: $teamId) {
edges {
@@ -87,4 +94,14 @@ query Team($teamId: ID!, $partyId: ID, $aggregationEpochs: Int) {
}
}
}
teamMembersStatistics(
teamId: $teamId
aggregationEpochs: $aggregationEpochs
) {
edges {
node {
...TeamMemberStatsFields
}
}
}
}
+22 -4
View File
@@ -5,7 +5,7 @@ import * as Apollo from '@apollo/client';
const defaultOptions = {} as const;
export type TeamFieldsFragment = { __typename?: 'Team', teamId: string, referrer: string, name: string, teamUrl: string, avatarUrl: string, createdAt: any, createdAtEpoch: number, closed: boolean, allowList: Array<string> };
export type TeamStatsFieldsFragment = { __typename?: 'TeamStatistics', teamId: string, totalQuantumVolume: string, totalQuantumRewards: string, totalGamesPlayed: number, gamesPlayed: Array<string>, quantumRewards: Array<{ __typename?: 'QuantumRewardsPerEpoch', epoch: number, total_quantum_rewards: string }> };
export type TeamStatsFieldsFragment = { __typename?: 'TeamStatistics', teamId: string, totalQuantumVolume: string, totalQuantumRewards: string, totalGamesPlayed: number, gamesPlayed: Array<string>, quantumRewards: Array<{ __typename?: 'QuantumRewardsPerEpoch', epoch: number, totalQuantumRewards: string }> };
export type TeamRefereeFieldsFragment = { __typename?: 'TeamReferee', teamId: string, referee: string, joinedAt: any, joinedAtEpoch: number };
@@ -13,6 +13,8 @@ export type TeamEntityFragment = { __typename?: 'TeamGameEntity', rank: number,
export type TeamGameFieldsFragment = { __typename?: 'Game', id: string, epoch: number, numberOfParticipants: number, entities: Array<{ __typename?: 'IndividualGameEntity' } | { __typename?: 'TeamGameEntity', rank: number, volume: string, rewardMetric: Types.DispatchMetric, rewardEarned: string, totalRewardsEarned: string, team: { __typename?: 'TeamParticipation', teamId: string } }> };
export type TeamMemberStatsFieldsFragment = { __typename?: 'TeamMemberStatistics', partyId: string, totalQuantumVolume: string, totalQuantumRewards: string, totalGamesPlayed: number };
export type TeamQueryVariables = Types.Exact<{
teamId: Types.Scalars['ID'];
partyId?: Types.InputMaybe<Types.Scalars['ID']>;
@@ -20,7 +22,7 @@ export type TeamQueryVariables = Types.Exact<{
}>;
export type TeamQuery = { __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, allowList: Array<string> } }> } | null, partyTeams?: { __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, allowList: Array<string> } }> } | null, teamsStatistics?: { __typename?: 'TeamsStatisticsConnection', edges: Array<{ __typename?: 'TeamStatisticsEdge', node: { __typename?: 'TeamStatistics', teamId: string, totalQuantumVolume: string, totalQuantumRewards: string, totalGamesPlayed: number, gamesPlayed: Array<string>, quantumRewards: Array<{ __typename?: 'QuantumRewardsPerEpoch', epoch: number, total_quantum_rewards: string }> } }> } | null, teamReferees?: { __typename?: 'TeamRefereeConnection', edges: Array<{ __typename?: 'TeamRefereeEdge', node: { __typename?: 'TeamReferee', teamId: string, referee: string, joinedAt: any, joinedAtEpoch: number } }> } | null, games: { __typename?: 'GamesConnection', edges?: Array<{ __typename?: 'GameEdge', node: { __typename?: 'Game', id: string, epoch: number, numberOfParticipants: number, entities: Array<{ __typename?: 'IndividualGameEntity' } | { __typename?: 'TeamGameEntity', rank: number, volume: string, rewardMetric: Types.DispatchMetric, rewardEarned: string, totalRewardsEarned: string, team: { __typename?: 'TeamParticipation', teamId: string } }> } } | null> | null } };
export type TeamQuery = { __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, allowList: Array<string> } }> } | null, partyTeams?: { __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, allowList: Array<string> } }> } | null, teamsStatistics?: { __typename?: 'TeamsStatisticsConnection', edges: Array<{ __typename?: 'TeamStatisticsEdge', node: { __typename?: 'TeamStatistics', teamId: string, totalQuantumVolume: string, totalQuantumRewards: string, totalGamesPlayed: number, gamesPlayed: Array<string>, quantumRewards: Array<{ __typename?: 'QuantumRewardsPerEpoch', epoch: number, totalQuantumRewards: string }> } }> } | null, teamReferees?: { __typename?: 'TeamRefereeConnection', edges: Array<{ __typename?: 'TeamRefereeEdge', node: { __typename?: 'TeamReferee', teamId: string, referee: string, joinedAt: any, joinedAtEpoch: number } }> } | null, games: { __typename?: 'GamesConnection', edges?: Array<{ __typename?: 'GameEdge', node: { __typename?: 'Game', id: string, epoch: number, numberOfParticipants: number, entities: Array<{ __typename?: 'IndividualGameEntity' } | { __typename?: 'TeamGameEntity', rank: number, volume: string, rewardMetric: Types.DispatchMetric, rewardEarned: string, totalRewardsEarned: string, team: { __typename?: 'TeamParticipation', teamId: string } }> } } | null> | null }, teamMembersStatistics?: { __typename?: 'TeamMembersStatisticsConnection', edges: Array<{ __typename?: 'TeamMemberStatisticsEdge', node: { __typename?: 'TeamMemberStatistics', partyId: string, totalQuantumVolume: string, totalQuantumRewards: string, totalGamesPlayed: number } }> } | null };
export const TeamFieldsFragmentDoc = gql`
fragment TeamFields on Team {
@@ -43,7 +45,7 @@ export const TeamStatsFieldsFragmentDoc = gql`
totalGamesPlayed
quantumRewards {
epoch
total_quantum_rewards
totalQuantumRewards
}
gamesPlayed
}
@@ -80,6 +82,14 @@ export const TeamGameFieldsFragmentDoc = gql`
}
}
${TeamEntityFragmentDoc}`;
export const TeamMemberStatsFieldsFragmentDoc = gql`
fragment TeamMemberStatsFields on TeamMemberStatistics {
partyId
totalQuantumVolume
totalQuantumRewards
totalGamesPlayed
}
`;
export const TeamDocument = gql`
query Team($teamId: ID!, $partyId: ID, $aggregationEpochs: Int) {
teams(teamId: $teamId) {
@@ -117,11 +127,19 @@ export const TeamDocument = gql`
}
}
}
teamMembersStatistics(teamId: $teamId, aggregationEpochs: $aggregationEpochs) {
edges {
node {
...TeamMemberStatsFields
}
}
}
}
${TeamFieldsFragmentDoc}
${TeamStatsFieldsFragmentDoc}
${TeamRefereeFieldsFragmentDoc}
${TeamGameFieldsFragmentDoc}`;
${TeamGameFieldsFragmentDoc}
${TeamMemberStatsFieldsFragmentDoc}`;
/**
* __useTeamQuery__
+49 -10
View File
@@ -6,17 +6,24 @@ import {
type TeamStatsFieldsFragment,
type TeamRefereeFieldsFragment,
type TeamEntityFragment,
type TeamMemberStatsFieldsFragment,
} from './__generated__/Team';
import { DEFAULT_AGGREGATION_EPOCHS } from './use-teams';
export type Team = TeamFieldsFragment;
export type TeamStats = TeamStatsFieldsFragment;
export type Member = TeamRefereeFieldsFragment;
export type Member = TeamRefereeFieldsFragment & {
isCreator: boolean;
totalGamesPlayed: number;
totalQuantumVolume: string;
totalQuantumRewards: string;
};
export type TeamEntity = TeamEntityFragment;
export type TeamGame = ReturnType<typeof useTeam>['games'][number];
export type MemberStats = TeamMemberStatsFieldsFragment;
export const useTeam = (teamId?: string, partyId?: string) => {
const { data, loading, error, refetch } = useTeamQuery({
const queryResult = useTeamQuery({
variables: {
teamId: teamId || '',
partyId,
@@ -26,7 +33,11 @@ export const useTeam = (teamId?: string, partyId?: string) => {
fetchPolicy: 'cache-and-network',
});
const { data } = queryResult;
const teamEdge = data?.teams?.edges.find((e) => e.node.teamId === teamId);
const team = teamEdge?.node;
const partyTeam = data?.partyTeams?.edges?.length
? data.partyTeams.edges[0].node
: undefined;
@@ -34,9 +45,40 @@ export const useTeam = (teamId?: string, partyId?: string) => {
const teamStatsEdge = data?.teamsStatistics?.edges.find(
(e) => e.node.teamId === teamId
);
const members = data?.teamReferees?.edges
.filter((e) => e.node.teamId === teamId)
.map((e) => e.node);
const memberStats = data?.teamMembersStatistics?.edges.length
? data.teamMembersStatistics.edges.map((e) => e.node)
: [];
const members: Member[] = data?.teamReferees?.edges.length
? data.teamReferees.edges
.filter((e) => e.node.teamId === teamId)
.map((e) => {
const member = e.node;
const stats = memberStats.find((m) => m.partyId === member.referee);
return {
...member,
isCreator: false,
totalQuantumVolume: stats ? stats.totalQuantumVolume : '0',
totalQuantumRewards: stats ? stats.totalQuantumRewards : '0',
totalGamesPlayed: stats ? stats.totalGamesPlayed : 0,
};
})
: [];
if (team) {
const ownerStats = memberStats.find((m) => m.partyId === team.referrer);
members.unshift({
teamId: team.teamId,
referee: team.referrer,
joinedAt: team?.createdAt,
joinedAtEpoch: team?.createdAtEpoch,
isCreator: true,
totalQuantumVolume: ownerStats ? ownerStats.totalQuantumVolume : '0',
totalQuantumRewards: ownerStats ? ownerStats.totalQuantumRewards : '0',
totalGamesPlayed: ownerStats ? ownerStats.totalGamesPlayed : 0,
});
}
// Find games where the current team participated in
const gamesWithTeam = compact(data?.games.edges).map((edge) => {
@@ -60,12 +102,9 @@ export const useTeam = (teamId?: string, partyId?: string) => {
const games = orderBy(compact(gamesWithTeam), 'epoch', 'desc');
return {
data,
loading,
error,
refetch,
...queryResult,
stats: teamStatsEdge?.node,
team: teamEdge?.node,
team,
members,
games,
partyTeam,
+6 -32
View File
@@ -1,34 +1,12 @@
import orderBy from 'lodash/orderBy';
import { useMemo } from 'react';
import { type TeamsQuery, useTeamsQuery } from './__generated__/Teams';
import {
type TeamsStatisticsQuery,
useTeamsStatisticsQuery,
} from './__generated__/TeamsStatistics';
import { useTeamsQuery } from './__generated__/Teams';
import { useTeamsStatisticsQuery } from './__generated__/TeamsStatistics';
import compact from 'lodash/compact';
import sortBy from 'lodash/sortBy';
import { type ArrayElement } from 'type-fest/source/internal';
type SortableField = keyof Omit<
ArrayElement<NonNullable<TeamsQuery['teams']>['edges']>['node'] &
ArrayElement<
NonNullable<TeamsStatisticsQuery['teamsStatistics']>['edges']
>['node'],
'__typename'
>;
type UseTeamsArgs = {
aggregationEpochs?: number;
sortByField?: SortableField[];
order?: 'asc' | 'desc';
};
export const DEFAULT_AGGREGATION_EPOCHS = 10;
export const useTeams = ({
aggregationEpochs = DEFAULT_AGGREGATION_EPOCHS,
sortByField = ['createdAtEpoch'],
order = 'asc',
}: UseTeamsArgs) => {
export const useTeams = (aggregationEpochs = DEFAULT_AGGREGATION_EPOCHS) => {
const {
data: teamsData,
loading: teamsLoading,
@@ -57,12 +35,8 @@ export const useTeams = ({
...stats.find((s) => s.teamId === t.teamId),
}));
const sorted = sortBy(data, sortByField);
if (order === 'desc') {
return sorted.reverse();
}
return sorted;
}, [teams, sortByField, order, stats]);
return orderBy(data, (d) => Number(d.totalQuantumRewards || 0), 'desc');
}, [teams, stats]);
return {
data,
@@ -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
};
};
+2 -1
View File
@@ -182,6 +182,7 @@
"Markets": "Markets",
"Members": "Members",
"Members ({{count}})": "Members ({{count}})",
"Member ID": "Member ID",
"Menu": "Menu",
"Metamask Snap <0>quick start</0>": "Metamask Snap <0>quick start</0>",
"Min. epochs": "Min. epochs",
@@ -364,7 +365,7 @@
"Type": "Type",
"Unknown": "Unknown",
"Unknown settlement date": "Unknown settlement date",
"Update team":, "Update team",
"Update team": "Update team",
"URL": "URL",
"Use a comma separated list to allow only specific public keys to join the team": "Use a comma separated list to allow only specific public keys to join the team",
"Vega chart": "Vega chart",
+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"
}
+1 -1
View File
@@ -4682,7 +4682,7 @@ export type QuantumRewardsPerEpoch = {
/** Epoch for which this information is valid. */
epoch: Scalars['Int'];
/** Total of rewards accumulated over the epoch period expressed in quantum value. */
total_quantum_rewards: Scalars['String'];
totalQuantumRewards: Scalars['String'];
};
/** Queries allow a caller to read data and filter data via GraphQL. */
+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}
/>
);
};