Compare commits

...
49 changed files with 2173 additions and 337 deletions
@@ -31,7 +31,7 @@ export const CompetitionsCreateTeam = () => {
<div className="mx-auto md:w-2/3 max-w-xl">
<Box className="flex flex-col gap-4">
<h1 className="calt text-2xl lg:text-3xl xl:text-4xl">
{t('Create a team')}
{isSolo ? t('Create solo team') : t('Create a team')}
</h1>
{pubKey && !isReadOnly ? (
<CreateTeamFormContainer isSolo={isSolo} />
@@ -71,18 +71,27 @@ 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>
<span
className="font-mono break-all"
data-testid="team-id-display"
>
{code}
</span>
</p>
<TradingAnchorButton
href={Links.COMPETITIONS_TEAM(code)}
intent={Intent.Info}
size="small"
data-testid="view-team-button"
>
{t('View team')}
</TradingAnchorButton>
@@ -125,7 +134,7 @@ const CreateTeamFormContainer = ({ isSolo }: { isSolo: boolean }) => {
onSubmit={onSubmit}
status={status}
err={err}
isSolo={isSolo}
isCreatingSoloTeam={isSolo}
/>
);
};
@@ -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();
@@ -31,10 +33,14 @@ export const CompetitionsHome = () => {
currentEpoch,
});
const { data: teamsData, loading: teamsLoading } = useTeams({
sortByField: ['totalQuantumRewards'],
order: 'desc',
});
const { data: teamsData, loading: teamsLoading } = useTeams();
const {
team: myTeam,
stats: myTeamStats,
games: myTeamGames,
rank: myTeamRank,
} = useMyTeam();
return (
<ErrorBoundary>
@@ -46,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>
@@ -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 />
@@ -100,8 +99,10 @@ const TeamPage = ({
>
{team.name}
</h1>
<JoinTeam team={team} partyTeam={partyTeam} refetch={refetch} />
<UpdateTeamButton team={team} />
<div className="flex gap-2">
<JoinTeam team={team} partyTeam={partyTeam} refetch={refetch} />
<UpdateTeamButton team={team} />
</div>
</div>
</header>
<TeamStats stats={stats} members={members} games={games} />
@@ -184,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),
})),
@@ -195,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'),
@@ -211,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>
@@ -121,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>
);
@@ -149,21 +137,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 +219,11 @@ const DialogContent = ({
</>
)}
<div className="flex justify-between gap-2">
<Button onClick={onConfirm} 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;
@@ -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,7 +100,7 @@ export const TeamForm = ({
formState: { errors },
} = useForm<FormFields>({
defaultValues: {
private: isSolo,
private: isCreatingSoloTeam,
...defaultValues,
},
});
@@ -109,16 +113,14 @@ 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') })} />
<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>
)}
@@ -134,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>
)}
@@ -153,66 +156,86 @@ 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>
)}
</TradingFormGroup>
<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);
{
// 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('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);
}}
data-testid="team-private-checkbox"
/>
);
}}
disabled={isSolo}
/>
);
}}
/>
</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'),
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');
},
},
})}
/>
{errors.allowList?.message && (
<TradingInputError forInput="avatarUrl">
{errors.allowList.message}
</TradingInputError>
)}
</TradingFormGroup>
</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');
},
},
})}
data-testid="team-allow-list-textarea"
/>
{errors.allowList?.message && (
<TradingInputError
forInput="avatarUrl"
data-testid="team-allow-list-error"
>
{errors.allowList.message}
</TradingInputError>
)}
</TradingFormGroup>
)}
</>
)
}
{err && (
<p className="text-danger text-xs mb-4 first-letter:capitalize">
{err}
</p>
)}
{err && <p className="text-danger text-xs mb-4 capitalize">{err}</p>}
<SubmitButton type={type} status={status} />
</form>
);
@@ -233,20 +256,61 @@ 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>
);
};
const parseAllowListText = (str: string) => {
const parseAllowListText = (str: string = '') => {
return str
.split(',')
.map((v) => v.trim())
@@ -1,18 +1,30 @@
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}
/>
>
{t('Update team')}
</TradingAnchorButton>
);
}
+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>
);
};
@@ -3,7 +3,7 @@ import classNames from 'classnames';
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
@@ -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) =>
@@ -371,6 +375,7 @@ export const ActiveRewardCard = ({
'rounded-lg',
gradientClassName
)}
data-testid="active-rewards-card"
>
<div
className={classNames(
@@ -382,7 +387,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 +395,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 +416,7 @@ export const ActiveRewardCard = ({
)}
underline={true}
>
<span className="text-xs">
<span className="text-xs" data-testid="distribution-strategy">
{
DistributionStrategyMapping[
dispatchStrategy.distributionStrategy
@@ -429,7 +434,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,7 +446,7 @@ 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}
@@ -458,8 +466,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 +478,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 +521,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 +530,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 +545,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,
+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
@@ -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()
+153 -42
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,19 @@ 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 +57,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 +86,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 +107,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 +169,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 +192,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,65 +209,103 @@ def create_team(vega: VegaServiceNull):
return team_name
def test_team_page_games_table(team_page: Page):
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("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("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("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 (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")
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("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"
)
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")
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")
+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
}
}
}
}
+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
}
}
}
+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__
+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 };
};
+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,
+8 -33
View File
@@ -1,34 +1,13 @@
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'
>;
// 192
export const DEFAULT_AGGREGATION_EPOCHS = 192;
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 +36,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,
+4 -1
View File
@@ -33,6 +33,7 @@ import { PartyActiveOrdersHandler } from './party-active-orders-handler';
import { MaybeConnectEagerly } from './maybe-connect-eagerly';
import { TransactionHandlers } from './transaction-handlers';
import { useT } from '../lib/use-t';
import { Test } from './test/test';
const Title = () => {
const t = useT();
@@ -135,4 +136,6 @@ function VegaTradingApp(props: AppProps) {
);
}
export default VegaTradingApp;
export default function () {
return <Test />;
}
+17
View File
@@ -0,0 +1,17 @@
export type Chain = {
id: string;
testnet: boolean;
name: string;
};
export const fairground = {
id: 'vega-fairground-202305051805',
testnet: true,
name: 'Fairground',
};
export const stagnet = {
id: 'vega-stagnet1-202307191148',
testnet: true,
name: 'Stagnet',
};
+47
View File
@@ -0,0 +1,47 @@
import {
type Transaction,
type TransactionResponse,
} from '@vegaprotocol/wallet';
export enum JsonRpcMethod {
ConnectWallet = 'client.connect_wallet',
DisconnectWallet = 'client.disconnect_wallet',
ListKeys = 'client.list_keys',
SignTransaction = 'client.sign_transaction',
SendTransaction = 'client.send_transaction',
GetChainId = 'client.get_chain_id',
}
export interface IWalletError {
error: string;
}
export interface TransactionParams {
publicKey: string;
transaction: Transaction;
sendingMode: 'TYPE_SYNC';
}
export interface Connector {
id: string;
connectWallet(chainId: string): Promise<{ success: boolean } | IWalletError>;
disconnectWallet(): Promise<{ success: boolean } | IWalletError>;
getChainId(): Promise<{ chainId: string } | IWalletError>;
listKeys(): Promise<
Array<{ publicKey: string; name: string }> | IWalletError
>;
isConnected(): Promise<{ connected: boolean } | IWalletError>;
sendTransaction(
params: TransactionParams
): Promise<TransactionResponse | IWalletError>;
on(event: string, callback: () => void): void;
off(event: string, callback: () => void): void;
}
@@ -0,0 +1,75 @@
import { TransactionParams, type Connector } from '.';
export class InjectedConnector implements Connector {
id = 'injected';
async connectWallet(chainId: string) {
try {
await window.vega.connectWallet({ chainId });
return { success: true };
} catch (err) {
return { error: 'failed to connect' };
}
}
async disconnectWallet() {
try {
await window.vega.disconnectWallet();
return { success: true };
} catch (err) {
return { error: 'failed to disconnect' };
}
}
// deprecated, pass chain on connect
async getChainId() {
try {
const res = await window.vega.getChainId();
return { chainId: res.chainID };
} catch (err) {
return { error: 'failed to get chain id' };
}
}
async listKeys() {
try {
const res = await window.vega.listKeys();
return res.keys;
} catch (err) {
return { error: 'failed to list keys' };
}
}
async isConnected() {
try {
const res = await window.vega.isConnected();
return { connected: res };
} catch (err) {
return { error: 'failed to check isConnected' };
}
}
async sendTransaction(params: TransactionParams) {
try {
const res = await window.vega.sendTransaction(params);
return {
transactionHash: res.transactionHash,
signature: res.transaction.signature.value,
receivedAt: res.receivedAt,
sentAt: res.sentAt,
};
} catch (err) {
console.error(err);
return { error: 'failed to send transaction' };
}
}
on(event: 'client.disconnected', callback: () => void) {
window.vega.on(event, callback);
}
off(event: 'client.disconnected') {
window.vega.off(event);
}
}
@@ -0,0 +1,126 @@
import { JsonRpcMethod, type Connector, type TransactionParams } from '.';
type JsonRpcConnectorConfig = { url: string };
export class JsonRpcConnector implements Connector {
id = 'json-rpc';
config: JsonRpcConnectorConfig;
requestId: number = 0;
token: string | null = null;
constructor(config: JsonRpcConnectorConfig) {
this.config = config;
}
async connectWallet(desiredChainId: string) {
try {
const { chainId } = await this.getChainId();
if (chainId !== desiredChainId) {
throw new Error('incorrect chain id');
}
const { response } = await this.request(JsonRpcMethod.ConnectWallet, {
hostname: window.location.hostname,
});
const token = response.headers.get('Authorization');
if (response.ok && token) {
this.token = token;
return { success: true };
}
return { error: 'failed to connect' };
} catch (err) {
return { error: 'failed to connect' };
}
}
async disconnectWallet() {
try {
await this.request(JsonRpcMethod.DisconnectWallet);
return { success: true };
} catch (err) {
return { error: 'failed to disconnect' };
}
}
// deprecated, pass chain on connect
async getChainId() {
try {
const { data } = await this.request(JsonRpcMethod.GetChainId);
return { chainId: data.result.chainID };
} catch (err) {
return { error: 'failed to get chain id' };
}
}
async listKeys() {
try {
const { data } = await this.request(JsonRpcMethod.ListKeys);
return data.result.keys as Array<{ publicKey: string; name: string }>;
} catch (err) {
return { error: 'failed to list keys' };
}
}
async isConnected() {
try {
await this.listKeys();
return { connected: true };
} catch (err) {
return { error: 'failed to check isConnected' };
}
}
async sendTransaction(params: TransactionParams) {
try {
const { data } = await this.request(
JsonRpcMethod.SendTransaction,
params
);
return {
transactionHash: data.result.transactionHash,
signature: data.result.transaction.signature.value,
receivedAt: data.result.receivedAt,
sentAt: data.result.sentAt,
};
} catch (err) {
return { error: 'failed to send transaction' };
}
}
private async request(method: JsonRpcMethod, params?: any) {
const headers = new Headers();
if (this.token) {
headers.set('Authorization', this.token);
}
const response = await fetch(this.config.url, {
method: 'POST',
headers,
body: JSON.stringify({
id: `${this.requestId++}`,
jsonrpc: '2.0',
method,
params,
}),
});
const data = await response.json();
return {
data,
response,
};
}
on() {
console.warn('events are not supported in json rpc wallet');
}
off() {
console.warn('events are not supported in json rpc wallet');
}
}
+141
View File
@@ -0,0 +1,141 @@
import { JsonRpcMethod, type Connector, type TransactionParams } from '.';
enum EthereumMethod {
RequestSnaps = 'wallet_requestSnaps',
GetSnaps = 'wallet_getSnaps',
InvokeSnap = 'wallet_invokeSnap',
}
type SnapConfig = {
id: string;
node: string;
version: string;
};
export class SnapConnector implements Connector {
id = 'snap';
config: SnapConfig;
constructor(config: SnapConfig) {
this.config = config;
}
async connectWallet(desiredChainId: string) {
try {
await this.requestSnap();
const { chainId } = await this.getChainId();
if (chainId !== desiredChainId) {
throw new Error('incorrect chain id');
}
return { success: true };
} catch (err) {
return { error: 'failed to connect' };
}
}
async disconnectWallet() {
console.warn('disconnectWallet not implemented');
return { error: 'failed to disconnect' };
}
// deprecated, pass chain on connect
async getChainId() {
try {
const res = await this.invokeSnap(JsonRpcMethod.GetChainId, {
networkEndpoints: [this.config.node],
});
return { chainId: res.chainID };
} catch (err) {
return { error: 'failed to get chain id' };
}
}
async listKeys() {
try {
const res = await this.invokeSnap(JsonRpcMethod.ListKeys);
return res.keys as Array<{ publicKey: string; name: string }>;
} catch (err) {
return { error: 'failed to list keys' };
}
}
async isConnected() {
console.warn('isConnected not implemented');
return { error: 'failed to check if connected' };
}
async sendTransaction(params: TransactionParams) {
try {
const res = await this.invokeSnap(JsonRpcMethod.SendTransaction, {
publicKey: params.publicKey,
sendingMode: params.sendingMode,
transaction: params.transaction,
networkEndpoints: [this.config.node],
});
return {
transactionHash: res.transactionHash,
signature: res.transaction.signature.value,
receivedAt: res.receivedAt,
sentAt: res.sentAt,
};
} catch (err) {
console.error(err);
return { error: 'failed to send transaction' };
}
}
////////////////////////////////////
// Snap methods
////////////////////////////////////
async requestSnap() {
const res = await this.request(EthereumMethod.RequestSnaps, {
[this.config.id]: {
version: this.config.version,
},
});
console.log(res);
}
async getSnap() {
const snaps = await this.request(EthereumMethod.GetSnaps);
return Object.values(snaps).find(
(s) => s.id === this.config.id && s.version === this.config.version
);
}
async invokeSnap(method: JsonRpcMethod, params?: any) {
return await this.request(EthereumMethod.InvokeSnap, {
snapId: this.config.id,
request: {
method: method,
params,
},
});
}
async request(method: EthereumMethod, params?: any) {
if (!window.ethereum) {
throw new Error('no window.ethereum');
}
// @ts-ignore ethereum types a bit mangled from web3-react
return window.ethereum.request({
method,
params,
});
}
on() {
console.warn('events are not supported in json rpc wallet');
}
off() {
console.warn('events are not supported in json rpc wallet');
}
}
+333
View File
@@ -0,0 +1,333 @@
import { StoreApi, createStore } from 'zustand/vanilla';
import { useStore } from 'zustand';
import { InjectedConnector } from './injected-connector';
import { JsonRpcConnector } from './json-rpc-connector';
import { SnapConnector } from './snap-connector';
import { Chain, fairground, stagnet } from './chains';
import { Connector, TransactionParams } from '.';
import {
PropsWithChildren,
createContext,
createElement,
useContext,
useEffect,
useRef,
useState,
} from 'react';
import { Dialog } from '@vegaprotocol/ui-toolkit';
import { TransactionResponse } from '@vegaprotocol/wallet';
import { AccountType } from '@vegaprotocol/types';
const injected = new InjectedConnector();
const jsonRpc = new JsonRpcConnector({
url: 'http://localhost:1789/api/v2/requests',
});
const snap = new SnapConnector({
node: 'https://api.n08.testnet.vega.rocks',
id: 'npm:@vegaprotocol/snap',
version: '0.3.1',
});
type Key = {
publicKey: string;
name: string;
};
type Store = {
chainId: string;
status: 'disconnected' | 'connecting' | 'connected';
current: string | undefined;
keys: Key[];
setKeys: (keys: Key[]) => void;
};
type Config = { chains: Chain[]; connectors: Connector[] };
type Wallet = {
store: StoreApi<Store>;
connectors: Connector[];
connect: (id: string) => Promise<void>;
disconnect: () => Promise<void>;
sendTransaction: (params: TransactionParams) => Promise<TransactionResponse>;
};
function createConfig(cfg: Config): Wallet {
const connectors = createStore(() => cfg.connectors);
const store = createStore<Store>((set) => ({
chainId: cfg.chains[0].id,
status: 'disconnected',
current: undefined,
keys: [],
setKeys: (keys) => {
set({ keys });
},
}));
async function connect(id: string) {
if (store.getState().status === 'connecting') {
return;
}
const connector = connectors.getState().find((x) => x.id === id);
if (!connector) return;
try {
store.setState({ status: 'connecting', current: id });
const connectWalletRes = await connector.connectWallet(
store.getState().chainId
);
if ('error' in connectWalletRes) {
throw new Error('failed to connect');
}
const listKeysRes = await connector.listKeys();
if ('error' in listKeysRes) {
throw new Error('failed to get keys');
}
store.setState({
keys: listKeysRes,
status: 'connected',
});
} catch (err) {
console.error(err);
store.setState({ status: 'disconnected', current: undefined, keys: [] });
}
}
async function disconnect() {
const connector = connectors
.getState()
.find((x) => x.id === store.getState().current);
if (!connector) return;
store.setState({ status: 'disconnected', current: undefined, keys: [] });
}
async function sendTransaction(params: TransactionParams) {
const connector = connectors
.getState()
.find((x) => x.id === store.getState().current);
if (!connector) return;
try {
const res = await connector.sendTransaction(params);
return res;
console.log('res', res);
} catch (err) {
return {
error: 'failed to send',
};
console.error(err);
}
}
return {
store,
connect,
disconnect,
sendTransaction,
get connectors() {
return connectors.getState();
},
};
}
const config = createConfig({
chains: [fairground, stagnet],
connectors: [injected, jsonRpc, snap],
});
function useWallet<T>(selector: (store: Store) => T) {
const store = useStore(config.store, selector);
return store;
}
function useConfig() {
const context = useContext(VegaWalletContext);
if (context === undefined) {
throw new Error('must be used within VegaWalletProvider');
}
return context;
}
function useConnect() {
const config = useConfig();
return {
connectors: config.connectors,
connect: config.connect,
};
}
function useDisconnect() {
const config = useConfig();
return {
disconnect: config.disconnect,
};
}
function useSendTransaction() {
const config = useConfig();
return {
sendTransaction: config.sendTransaction,
};
}
const VegaWalletContext = createContext<Wallet | undefined>(undefined);
function VegaWalletProvider({
children,
config,
}: PropsWithChildren<{ config: Wallet }>) {
return createElement(VegaWalletContext.Provider, { value: config }, children);
}
export function Test() {
return (
<VegaWalletProvider config={config}>
<App />
</VegaWalletProvider>
);
}
function App() {
const [open, setOpen] = useState(false);
const { connectors, connect } = useConnect();
const { disconnect } = useDisconnect();
const { sendTransaction } = useSendTransaction();
const status = useWallet((store) => store.status);
return (
<div>
<button onClick={() => setOpen(true)}>Connect</button>
<Dialog open={open}>
<div>
{status === 'disconnected' && (
<div className="flex gap-2">
{connectors.map((c) => (
<button
key={c.id}
onClick={async () => {
try {
await connect(c.id);
setTimeout(() => {
setOpen(false);
}, 1000);
} catch (err) {}
}}
>
connect {c.id}
</button>
))}
</div>
)}
{status === 'connecting' && <div>Approve connection in wallet</div>}
{status === 'connected' && <div>Connected!</div>}
</div>
</Dialog>
<hr className="border-t pt-2 mt-2" />
<div className="flex flex-col items-start gap-2">
<button onClick={() => disconnect()}>diconnectWallet</button>
<button
onClick={async () => {
const fromKey =
'846b2ed2fff0ddf219a5302a3856f9ac279652c4d92e72e7df762117886e69cc'; // snap
const toKey =
'5a703397f6ac1f62513af1f5d0237d1b9f8b2c93ef19fbe9fa87771a2b7f55da'; // browser
const res = await sendTransaction({
publicKey: fromKey,
sendingMode: 'TYPE_SYNC',
transaction: {
transfer: {
fromAccountType: AccountType.ACCOUNT_TYPE_GENERAL,
to: toKey,
toAccountType: AccountType.ACCOUNT_TYPE_GENERAL,
asset:
'16ae5dbb1fd7aa2ddef725703bfe66b3647a4da7b844bfdd04e985756f53d9d6',
amount: '100',
oneOff: {},
},
},
});
console.log(res);
}}
>
submit transfer
</button>
</div>
<div>{status}</div>
<Keys />
{/* <NoReact /> */}
</div>
);
}
const Keys = () => {
const keys = useWallet((store) => store.keys);
if (!keys.length) {
return <p>No keys</p>;
}
return (
<ul>
{keys.map((k) => {
return (
<li key={k.publicKey}>
{k.name} {k.publicKey}
</li>
);
})}
</ul>
);
};
const NoReact = () => {
const divRef = useRef<HTMLDivElement | null>(null);
useEffect(() => {
if (!divRef.current) return;
const div = divRef.current;
const button = document.createElement('button');
button.addEventListener('click', () => {
config.connect('injected'); // should fail
});
button.textContent = 'Connect injected';
const status = document.createElement('div');
const keys = document.createElement('ul');
config.store.subscribe((state) => {
status.textContent = state.status;
state.keys.forEach((key) => {
const li = document.createElement('li');
li.textContent = `${key.name} ${key.publicKey}`;
keys.appendChild(li);
});
});
div.appendChild(button);
div.appendChild(status);
div.appendChild(keys);
}, []);
return (
<div>
<h1>No react</h1>
<div ref={divRef} />
</div>
);
};
+65
View File
@@ -0,0 +1,65 @@
import { InjectedConnector } from './injected-connector';
import { JsonRpcConnector } from './json-rpc-connector';
import { SnapConnector } from './snap-connector';
export class Wallet {
connector?: InjectedConnector | JsonRpcConnector | SnapConnector;
async connectWallet(
connector: InjectedConnector | JsonRpcConnector | SnapConnector
) {
this.connector = connector;
const res = await this.connector.connectWallet();
if (res.success !== true) {
// TODO: something better
return;
}
const keys = await this.connector.listKeys();
console.log(keys);
return keys;
}
async disconnectWallet() {
if (!this.connector) {
throw new Error('no connector');
}
await this.connector.disconnectWallet();
this.connector = undefined;
}
async listKeys() {
if (!this.connector) {
throw new Error('no connector');
}
return this.connector.listKeys();
}
async getChainId() {
if (!this.connector) {
throw new Error('no connector');
}
return this.connector.getChainId();
}
async isConnected() {
if (!this.connector) {
throw new Error('no connector');
}
return this.connector.isConnected();
}
// sendTransaction(...args: Parameters<typeof window.vega.sendTransaction>) {
// if (!this.connector) {
// throw new Error('no connector');
// }
//
// return this.connector.sendTransaction(...args);
// }
}
@@ -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
};
};
@@ -59,6 +59,9 @@ export const DealTicketMarginDetails = ({
variables: { marketId: market.id, partyId: partyId || '' },
skip: !partyId,
});
const isInIsolatedMode =
positionEstimate?.margin.bestCase.marginMode ===
Schema.MarginMode.MARGIN_MODE_ISOLATED_MARGIN;
const liquidationEstimate = positionEstimate?.liquidation;
const marginEstimate = positionEstimate?.margin;
const totalMarginAccountBalance =
@@ -70,18 +73,25 @@ export const DealTicketMarginDetails = ({
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);
const collateralIncreaseEstimateBestCase = BigInt(
positionEstimate?.collateralIncreaseEstimate.bestCase ?? '0'
);
const collateralIncreaseEstimateWorstCase = BigInt(
positionEstimate?.collateralIncreaseEstimate.worstCase ?? '0'
);
const marginEstimateBestCase = isInIsolatedMode
? totalMarginAccountBalance + collateralIncreaseEstimateBestCase
: BigInt(marginEstimate?.bestCase.initialLevel ?? 0);
const marginEstimateWorstCase = isInIsolatedMode
? totalMarginAccountBalance + collateralIncreaseEstimateWorstCase
: BigInt(marginEstimate?.worstCase.initialLevel ?? 0);
if (isInIsolatedMode) {
marginRequiredBestCase = collateralIncreaseEstimateBestCase.toString();
marginRequiredWorstCase = collateralIncreaseEstimateWorstCase.toString();
} else if (marginEstimate) {
if (currentMargins) {
const currentMargin = BigInt(currentMargins.initialLevel);
marginRequiredBestCase = (
marginEstimateBestCase - currentMargin
).toString();
+19 -1
View File
@@ -63,6 +63,7 @@
"Create": "Create",
"Create a team": "Create a team",
"Create a simple referral code to enjoy the referrer commission outlined in the current referral program": "Create a simple referral code to enjoy the referrer commission outlined in the current referral program",
"Create solo team": "Create solo team",
"Make your referral code a Team to compete in Competitions with your friends, appear in leaderboards on the <0>Competitions Homepage</0>, and earn rewards": "Make your referral code a Team to compete in Competitions with your friends, appear in leaderboards on the <0>Competitions Homepage</0>, and earn rewards",
"Current tier": "Current tier",
"DISCLAIMER_P1": "Vega is a decentralised peer-to-peer protocol that can be used to trade derivatives with cryptoassets. The Vega Protocol is an implementation layer (layer one) protocol made of free, public, open-source or source-available software. Use of the Vega Protocol involves various risks, including but not limited to, losses while digital assets are supplied to the Vega Protocol and losses due to the fluctuation of prices of assets.",
@@ -181,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",
@@ -363,6 +365,7 @@
"Type": "Type",
"Unknown": "Unknown",
"Unknown settlement date": "Unknown settlement date",
"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",
@@ -414,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",
@@ -428,5 +432,19 @@
"{{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"
}
+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;
};
@@ -37,7 +37,9 @@ declare global {
}>;
on: (event: VegaWalletEvent, callback: () => void) => void;
isConnected?: () => Promise<boolean>;
off: (event: VegaWalletEvent) => void;
isConnected: () => Promise<boolean>;
getChainId: () => Promise<{ chainID: string }>;
}
interface Window {
+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}
/>
);
};