feat(explorer): add transfers tx view (#2902)

This commit is contained in:
Edd 2023-02-22 16:31:06 +00:00 committed by GitHub
parent 95f4e489b2
commit efeccc7972
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
38 changed files with 1445 additions and 61 deletions

View File

@ -0,0 +1,50 @@
query ExplorerEpoch($id: ID!) {
epoch(id: $id) {
id
timestamps {
start
end
firstBlock
lastBlock
}
}
}
query ExplorerFutureEpoch {
networkParameter(key: "validators.epoch.length") {
value
}
epoch {
id
timestamps {
start
}
}
}
# query ExplorerEpoch($id: ID!) {
#
##### This could be useful for calculating roughly when a future epoch will
##### occur, but epoch not exist results in a total error
# networkParameter(key: "validators.epoch.length") {
# value
# }
#
##### This could be useful for relating where we are in time, but as above
##### the total failure caused by epoch(id) not existing
##### means this is useful
# currentEpoch: epoch {
# id
# }
#
# epoch(id: $id) {
# id
# timestamps {
# start
# end
# firstBlock
# lastBlock
# }
# }
#}

View File

@ -0,0 +1,99 @@
import * as Types from '@vegaprotocol/types';
import { gql } from '@apollo/client';
import * as Apollo from '@apollo/client';
const defaultOptions = {} as const;
export type ExplorerEpochQueryVariables = Types.Exact<{
id: Types.Scalars['ID'];
}>;
export type ExplorerEpochQuery = { __typename?: 'Query', epoch: { __typename?: 'Epoch', id: string, timestamps: { __typename?: 'EpochTimestamps', start?: any | null, end?: any | null, firstBlock: string, lastBlock?: string | null } } };
export type ExplorerFutureEpochQueryVariables = Types.Exact<{ [key: string]: never; }>;
export type ExplorerFutureEpochQuery = { __typename?: 'Query', networkParameter?: { __typename?: 'NetworkParameter', value: string } | null, epoch: { __typename?: 'Epoch', id: string, timestamps: { __typename?: 'EpochTimestamps', start?: any | null } } };
export const ExplorerEpochDocument = gql`
query ExplorerEpoch($id: ID!) {
epoch(id: $id) {
id
timestamps {
start
end
firstBlock
lastBlock
}
}
}
`;
/**
* __useExplorerEpochQuery__
*
* To run a query within a React component, call `useExplorerEpochQuery` and pass it any options that fit your needs.
* When your component renders, `useExplorerEpochQuery` returns an object from Apollo Client that contains loading, error, and data properties
* you can use to render your UI.
*
* @param baseOptions options that will be passed into the query, supported options are listed on: https://www.apollographql.com/docs/react/api/react-hooks/#options;
*
* @example
* const { data, loading, error } = useExplorerEpochQuery({
* variables: {
* id: // value for 'id'
* },
* });
*/
export function useExplorerEpochQuery(baseOptions: Apollo.QueryHookOptions<ExplorerEpochQuery, ExplorerEpochQueryVariables>) {
const options = {...defaultOptions, ...baseOptions}
return Apollo.useQuery<ExplorerEpochQuery, ExplorerEpochQueryVariables>(ExplorerEpochDocument, options);
}
export function useExplorerEpochLazyQuery(baseOptions?: Apollo.LazyQueryHookOptions<ExplorerEpochQuery, ExplorerEpochQueryVariables>) {
const options = {...defaultOptions, ...baseOptions}
return Apollo.useLazyQuery<ExplorerEpochQuery, ExplorerEpochQueryVariables>(ExplorerEpochDocument, options);
}
export type ExplorerEpochQueryHookResult = ReturnType<typeof useExplorerEpochQuery>;
export type ExplorerEpochLazyQueryHookResult = ReturnType<typeof useExplorerEpochLazyQuery>;
export type ExplorerEpochQueryResult = Apollo.QueryResult<ExplorerEpochQuery, ExplorerEpochQueryVariables>;
export const ExplorerFutureEpochDocument = gql`
query ExplorerFutureEpoch {
networkParameter(key: "validators.epoch.length") {
value
}
epoch {
id
timestamps {
start
}
}
}
`;
/**
* __useExplorerFutureEpochQuery__
*
* To run a query within a React component, call `useExplorerFutureEpochQuery` and pass it any options that fit your needs.
* When your component renders, `useExplorerFutureEpochQuery` returns an object from Apollo Client that contains loading, error, and data properties
* you can use to render your UI.
*
* @param baseOptions options that will be passed into the query, supported options are listed on: https://www.apollographql.com/docs/react/api/react-hooks/#options;
*
* @example
* const { data, loading, error } = useExplorerFutureEpochQuery({
* variables: {
* },
* });
*/
export function useExplorerFutureEpochQuery(baseOptions?: Apollo.QueryHookOptions<ExplorerFutureEpochQuery, ExplorerFutureEpochQueryVariables>) {
const options = {...defaultOptions, ...baseOptions}
return Apollo.useQuery<ExplorerFutureEpochQuery, ExplorerFutureEpochQueryVariables>(ExplorerFutureEpochDocument, options);
}
export function useExplorerFutureEpochLazyQuery(baseOptions?: Apollo.LazyQueryHookOptions<ExplorerFutureEpochQuery, ExplorerFutureEpochQueryVariables>) {
const options = {...defaultOptions, ...baseOptions}
return Apollo.useLazyQuery<ExplorerFutureEpochQuery, ExplorerFutureEpochQueryVariables>(ExplorerFutureEpochDocument, options);
}
export type ExplorerFutureEpochQueryHookResult = ReturnType<typeof useExplorerFutureEpochQuery>;
export type ExplorerFutureEpochLazyQueryHookResult = ReturnType<typeof useExplorerFutureEpochLazyQuery>;
export type ExplorerFutureEpochQueryResult = Apollo.QueryResult<ExplorerFutureEpochQuery, ExplorerFutureEpochQueryVariables>;

View File

@ -0,0 +1,56 @@
import { render } from '@testing-library/react';
import { IconForEpoch } from './epoch';
const THE_PAST = 'Monday, 17 February 2022 11:44:09';
const THE_FUTURE = 'Monday, 17 February 3023 11:44:09';
describe('IconForEpoch', () => {
it('Handles malformed dates', () => {
const start = 'This is n0t a d4te';
const end = '📅';
const screen = render(<IconForEpoch start={start} end={end} />);
expect(screen.getByRole('img')).toHaveAttribute(
'aria-label',
'calendar icon'
);
});
it('defaults to a calendar icon', () => {
const start = null as unknown as string;
const end = null as unknown as string;
const screen = render(<IconForEpoch start={start} end={end} />);
expect(screen.getByRole('img')).toHaveAttribute(
'aria-label',
'calendar icon'
);
});
it('if start and end are both in the future, stick with calendar', () => {
const screen = render(<IconForEpoch start={THE_FUTURE} end={THE_FUTURE} />);
expect(screen.getByRole('img')).toHaveAttribute(
'aria-label',
'calendar icon'
);
});
it('if start is in the past and end is in the future, this is currently active', () => {
const screen = render(<IconForEpoch start={THE_PAST} end={THE_FUTURE} />);
expect(screen.getByRole('img')).toHaveAttribute(
'aria-label',
'circle icon'
);
});
it('if start and end are in the paste, this is done', () => {
const screen = render(<IconForEpoch start={THE_PAST} end={THE_PAST} />);
expect(screen.getByRole('img')).toHaveAttribute(
'aria-label',
'tick-circle icon'
);
});
});

View File

@ -0,0 +1,125 @@
import { MockedProvider } from '@apollo/client/testing';
import { render } from '@testing-library/react';
import EpochMissingOverview, { calculateEpochData } from './epoch-missing';
import { getSecondsFromInterval } from '@vegaprotocol/react-helpers';
const START_DATE_PAST = 'Monday, 17 February 2022 11:44:09';
describe('getSecondsFromInterval', () => {
it('returns 0 for bad data', () => {
expect(getSecondsFromInterval(null as unknown as string)).toEqual(0);
expect(getSecondsFromInterval('')).toEqual(0);
expect(getSecondsFromInterval('🧙')).toEqual(0);
expect(getSecondsFromInterval(2 as unknown as string)).toEqual(0);
});
it('parses out months from a capital M', () => {
expect(getSecondsFromInterval('2M')).toEqual(5184000);
});
it('parses out days from a capital D', () => {
expect(getSecondsFromInterval('1D')).toEqual(86400);
});
it('parses out hours from a lower case h', () => {
expect(getSecondsFromInterval('11h')).toEqual(39600);
});
it('parses out minutes from a lower case m', () => {
expect(getSecondsFromInterval('10m')).toEqual(600);
});
it('parses out seconds from a lower case s', () => {
expect(getSecondsFromInterval('99s')).toEqual(99);
});
it('parses complex examples', () => {
expect(getSecondsFromInterval('24h')).toEqual(86400);
expect(getSecondsFromInterval('1h30m')).toEqual(5400);
expect(getSecondsFromInterval('1D1h30m1s')).toEqual(91801);
});
});
describe('calculateEpochData', () => {
it('Handles bad data', () => {
const currentEpochId = null as unknown as string;
const missingEpochId = null as unknown as string;
const epochStart = null as unknown as string;
const epochLength = null as unknown as string;
const res = calculateEpochData(
currentEpochId,
missingEpochId,
epochStart,
epochLength
);
expect(res).toHaveProperty('label', 'Missing data');
expect(res).toHaveProperty('isInFuture', false);
});
it('Calculates that a bigger epoch number is in the future from basic data', () => {
const currentEpochId = '10';
const missingEpochId = '20';
const epochStart = '';
const epochLength = '';
const res = calculateEpochData(
currentEpochId,
missingEpochId,
epochStart,
epochLength
);
expect(res).toHaveProperty('isInFuture', true);
});
it('If it has an epoch length and a start time, it provides an estimate', () => {
const currentEpochId = '10';
const missingEpochId = '20';
const epochStart = START_DATE_PAST;
const epochLength = '1s';
const res = calculateEpochData(
currentEpochId,
missingEpochId,
epochStart,
epochLength
);
// 'Estimate: 17/02/2022, 11:44:19 - in less than a minute')
expect(res).toHaveProperty('label');
expect(res.label).toMatch(/^Estimate/);
expect(res.label).toMatch(/in less than a minute$/);
});
it('Provide decent string for past', () => {
const currentEpochId = '20';
const missingEpochId = '10';
const epochStart = START_DATE_PAST;
const epochLength = '1s';
const res = calculateEpochData(
currentEpochId,
missingEpochId,
epochStart,
epochLength
);
// 'Estimate: 17/02/2022, 11:44:19 - in less than a minute')
expect(res).toHaveProperty('label');
expect(res.label).toMatch(/^Estimate/);
expect(res.label).toMatch(/less than a minute ago$/);
});
});
describe('EpochMissingOverview', () => {
function renderComponent(missingEpochId: string) {
return render(
<MockedProvider>
<EpochMissingOverview missingEpochId={missingEpochId} />
</MockedProvider>
);
}
it('renders a - if no id is provided', () => {
const n = null as unknown as string;
const screen = renderComponent(n);
expect(screen.getByTestId('empty')).toBeInTheDocument();
});
});

View File

@ -0,0 +1,111 @@
import { useExplorerFutureEpochQuery } from './__generated__/Epoch';
import addSeconds from 'date-fns/addSeconds';
import formatDistance from 'date-fns/formatDistance';
import { Icon, Tooltip } from '@vegaprotocol/ui-toolkit';
import isFuture from 'date-fns/isFuture';
import { isValidDate } from '@vegaprotocol/react-helpers';
import { getSecondsFromInterval } from '@vegaprotocol/react-helpers';
export type EpochMissingOverviewProps = {
missingEpochId?: string;
};
/**
* Renders a set of details for an epoch that has no representation in the
* data node. This is primarily for one of two reasons:
*
* 1. The epoch hasn't happened yet
* 2. The epoch happened before a snapshot, and thus the details don't exist
*
* This component is used when the API has responded with no data for an epoch
* by ID, so we already know that we can't display start time/block etc.
*
* We can detect 1 if the epoch is a higher number than the current epoch
* We can detect 2 if the epoch is in the past, but we still get no response.
*/
const EpochMissingOverview = ({
missingEpochId,
}: EpochMissingOverviewProps) => {
const { data, error, loading } = useExplorerFutureEpochQuery();
// This should not happen, but it's easily handled
if (!missingEpochId) {
return <span data-testid="empty">-</span>;
}
// No data should also not happen - we've requested the current epoch. This
// could happen at chain restart, but shouldn't. If it does, fallback.
if (!data || loading || error) {
return <span data-testid="empty">{missingEpochId}</span>;
}
// If we have enough information to predict a future or past block time, let's do it
if (
!missingEpochId ||
!data.epoch.id ||
!data.epoch.timestamps.start ||
!data?.networkParameter?.value
) {
return <span data-testid="empty">{missingEpochId}</span>;
}
const { label, isInFuture } = calculateEpochData(
data.epoch.id,
missingEpochId,
data.epoch.timestamps.start,
data.networkParameter.value
);
return (
<Tooltip description={<p className="text-xs m-2">{label}</p>}>
<p>
{isInFuture ? (
<Icon name="calendar" className="mr-1" />
) : (
<Icon name="outdated" className="mr-1" />
)}
{missingEpochId}
</p>
</Tooltip>
);
};
export function calculateEpochData(
currentEpochId: string,
missingEpochId: string,
epochStart: string,
epochLength: string
) {
// Blank string will be return 0 seconds from getSecondsFromInterval
const epochLengthInSeconds = getSecondsFromInterval(epochLength);
if (!epochStart || !epochLength) {
// Let's just take a guess
return {
label: 'Missing data',
isInFuture: parseInt(missingEpochId) > parseInt(currentEpochId),
};
}
const startFrom = new Date(epochStart);
const diff = parseInt(missingEpochId) - parseInt(currentEpochId);
const futureDate = addSeconds(startFrom, diff * epochLengthInSeconds);
const label =
isValidDate(futureDate) && isValidDate(startFrom)
? `Estimate: ${futureDate.toLocaleString()} - ${formatDistance(
futureDate,
startFrom,
{ addSuffix: true }
)}`
: 'Missing data';
return {
label,
isInFuture: isFuture(futureDate),
};
}
export default EpochMissingOverview;

View File

@ -0,0 +1,127 @@
import { useExplorerEpochQuery } from './__generated__/Epoch';
import { t } from '@vegaprotocol/react-helpers';
import { BlockLink } from '../links';
import { Time } from '../time';
import { TimeAgo } from '../time-ago';
import EpochMissingOverview from './epoch-missing';
import { Icon, Tooltip } from '@vegaprotocol/ui-toolkit';
import type { IconProps } from '@vegaprotocol/ui-toolkit';
import isPast from 'date-fns/isPast';
const borderClass =
'border-solid border-2 border-vega-dark-200 border-collapse';
export type EpochOverviewProps = {
id?: string;
};
/**
* Displays detailed information about an epoch, given an ID. This
* works for past epochs and current epochs - future epochs, and a
* few other situations (see epoch-missing) will not return us
* enough information to render this.
*
* The details are hidden in a tooltip, behind the epoch number
*/
const EpochOverview = ({ id }: EpochOverviewProps) => {
const { data, error, loading } = useExplorerEpochQuery({
variables: { id: id || '' },
});
const ti = data?.epoch.timestamps;
if (
error?.message &&
error.message.includes('no resource corresponding to this id')
) {
return <EpochMissingOverview missingEpochId={id} />;
}
if (!ti || loading || error) {
return <span>{id}</span>;
}
const description = (
<table className="text-xs m-2">
<thead>
<tr>
<th></th>
<th className={`text-center ${borderClass}`}>{t('Block')}</th>
<th className={`text-center ${borderClass}`}>{t('Time')}</th>
</tr>
</thead>
<tbody>
<tr>
<th className={`px-2 ${borderClass}`}>{t('Start')}</th>
<td className={`px-2 ${borderClass}`}>
{ti.firstBlock ? <BlockLink height={ti.firstBlock} /> : '-'}
</td>
<td className={`px-2 ${borderClass}`}>
<Time date={ti.start} />
<br />
<TimeAgo date={ti.start} />
</td>
</tr>
<tr>
<th className={`px-2 ${borderClass}`}>{t('End')}</th>
<td className={`px-2 ${borderClass}`}>
{ti.lastBlock ? (
<BlockLink height={ti.lastBlock} />
) : (
t('In progress')
)}
</td>
<td className={`px-2 ${borderClass}`}>
{ti.end ? (
<>
<Time date={ti.end} />
<br />
<TimeAgo date={ti.end} />
</>
) : (
<span>{t('-')}</span>
)}
</td>
</tr>
</tbody>
</table>
);
return (
<Tooltip description={description}>
<p>
<IconForEpoch start={ti.start} end={ti.end} />
{id}
</p>
</Tooltip>
);
};
export type IconForEpochProps = {
start: string;
end: string;
};
/**
* Chooses an icon to display next to the epoch number, representing
* when the epoch is relative to now (i.e. not yet started, started,
* finished)
*/
export function IconForEpoch({ start, end }: IconForEpochProps) {
const startHasPassed = start ? isPast(new Date(start)) : false;
const endHasPassed = end ? isPast(new Date(end)) : false;
let i: IconProps['name'] = 'calendar';
if (!startHasPassed && !endHasPassed) {
i = 'calendar';
} else if (startHasPassed && !endHasPassed) {
i = 'circle';
} else if (startHasPassed && endHasPassed) {
i = 'tick-circle';
}
return <Icon name={i} className="mr-2" />;
}
export default EpochOverview;

View File

@ -11,14 +11,14 @@ export const InfoBlock = ({ title, subtitle, tooltipInfo }: InfoBlockProps) => {
return (
<div className="flex flex-col text-center ">
<h3 className="text-4xl">{title}</h3>
<p className="text-zinc-800 dark:text-zinc-300">
<p className="text-vega-dark-100 dark:text-vega-light-200">
{subtitle}
{tooltipInfo ? (
<Tooltip description={tooltipInfo} align="center">
<span>
<Icon
name="info-sign"
className="ml-2 text-zinc-400 dark:text-zinc-600"
className="ml-2 text-vega-light-300 dark:text-vega-dark-300"
/>
</span>
</Tooltip>

View File

@ -44,12 +44,12 @@ export const InfoPanel = ({
text={id}
startChars={visibleChars}
endChars={visibleChars}
className="text-black dark:text-zinc-200"
className="text-vega-dark-100 dark:text-vega-light-200"
/>
) : (
<p
title={id}
className="text-black dark:text-zinc-200 truncate ..."
className="text-vega-dark-100 dark:text-vega-light-200 truncate ..."
>
{id}
</p>
@ -70,7 +70,7 @@ export const InfoPanel = ({
</div>
{copy && (
<CopyWithTooltip text={id}>
<button className="bg-zinc-100 dark:bg-zinc-900 rounded-sm py-2 px-3">
<button className="bg-vega-light-100 dark:bg-vega-dark-100 rounded-sm py-2 px-3">
<Icon name="duplicate" />
</button>
</CopyWithTooltip>

View File

@ -11,6 +11,7 @@ import { Routes } from '../../../routes/route-names';
export type AssetLinkProps = Partial<ComponentProps<typeof ButtonLink>> & {
assetId: string;
asDialog?: boolean;
showAssetSymbol?: boolean;
};
/**
@ -18,12 +19,22 @@ export type AssetLinkProps = Partial<ComponentProps<typeof ButtonLink>> & {
* with a link to the assets modal. If the name does not come back
* it will use the ID instead.
*/
export const AssetLink = ({ assetId, asDialog, ...props }: AssetLinkProps) => {
export const AssetLink = ({
assetId,
asDialog,
showAssetSymbol = false,
...props
}: AssetLinkProps) => {
const { data: asset } = useAssetDataProvider(assetId);
const open = useAssetDetailsDialogStore((state) => state.open);
const navigate = useNavigate();
const label = asset?.name ? asset.name : assetId;
const label = asset
? showAssetSymbol
? asset?.symbol
: asset?.name
: assetId;
return (
<ButtonLink
data-testid="asset-link"

View File

@ -0,0 +1,39 @@
import { render } from '@testing-library/react';
import { MemoryRouter } from 'react-router-dom';
import PartyLink from './party-link';
describe('PartyLink', () => {
it('renders Network for 000.000 party', () => {
const zeroes =
'0000000000000000000000000000000000000000000000000000000000000000';
const screen = render(<PartyLink id={zeroes} />);
expect(screen.getByText('Network')).toBeInTheDocument();
});
it('renders Network for network party', () => {
const screen = render(<PartyLink id="network" />);
expect(screen.getByText('Network')).toBeInTheDocument();
});
it('renders ID with no link for invalid party', () => {
const screen = render(<PartyLink id="this-party-is-not-valid" />);
expect(screen.getByTestId('invalid-party')).toBeInTheDocument();
});
it('links a valid party to the party page', () => {
const aValidParty =
'13464e35bcb8e8a2900ca0f87acaf252d50cf2ab2fc73694845a16b7c8a0dc6e';
const screen = render(
<MemoryRouter>
<PartyLink id={aValidParty} />
</MemoryRouter>
);
const el = screen.getByText(aValidParty);
expect(el).toBeInTheDocument();
// The text should be a link that points to the party's page
expect(el.parentElement?.tagName).toEqual('A');
expect(el.parentElement?.getAttribute('href')).toContain(aValidParty);
});
});

View File

@ -3,19 +3,47 @@ import { Link } from 'react-router-dom';
import type { ComponentProps } from 'react';
import Hash from '../hash';
import { t } from '@vegaprotocol/react-helpers';
import { isValidPartyId } from '../../../routes/parties/id/components/party-id-error';
import { truncateMiddle } from '@vegaprotocol/ui-toolkit';
export const SPECIAL_CASE_NETWORK_ID =
'0000000000000000000000000000000000000000000000000000000000000000';
export const SPECIAL_CASE_NETWORK = 'network';
export type PartyLinkProps = Partial<ComponentProps<typeof Link>> & {
id: string;
truncate?: boolean;
};
const PartyLink = ({ id, ...props }: PartyLinkProps) => {
const PartyLink = ({ id, truncate = false, ...props }: PartyLinkProps) => {
// Some transactions will involve the 'network' party, which is alias for '000...000'
// The party page does not handle this nicely, so in this case we render the word 'Network'
if (id === SPECIAL_CASE_NETWORK || id === SPECIAL_CASE_NETWORK_ID) {
return (
<span className="font-mono" data-testid="network">
{t('Network')}
</span>
);
}
// If the party doesn't look correct, there's no point in linking to id. Just render
// the ID as it was given to us
if (!isValidPartyId(id)) {
return (
<span className="font-mono" data-testid="invalid-party">
{id}
</span>
);
}
return (
<Link
className="underline font-mono"
{...props}
to={`/${Routes.PARTIES}/${id}`}
>
<Hash text={id} />
<Hash text={truncate ? truncateMiddle(id) : id} />
</Link>
);
};

View File

@ -13,7 +13,7 @@ export interface DeterministicOrderDetailsProps {
}
export const wrapperClasses =
'grid lg:grid-cols-1 flex items-center max-w-xl border border-zinc-200 dark:border-zinc-800 rounded-md pv-2 ph-5 mb-5';
'grid lg:grid-cols-1 flex items-center max-w-xl border border-vega-light-200 dark:border-vega-dark-150 rounded-md pv-2 ph-5 mb-5';
/**
* This component renders the *current* details for an order
@ -42,7 +42,7 @@ const DeterministicOrderDetails = ({
<h2 className="text-3xl font-bold mb-4 display-5">
{t('Order not found')}
</h2>
<p className="text-gray-500 mb-12">
<p className="text-vega-light-400 mb-12">
{t('No order created from this transaction')}
</p>
</div>

View File

@ -38,7 +38,7 @@ export const PageHeader = ({
</h2>
{copy && (
<CopyWithTooltip data-testid="copy-to-clipboard" text={title}>
<button className="bg-zinc-100 dark:bg-zinc-900 rounded-sm py-2 px-3">
<button className="bg-vega-light-100 dark:bg-vega-dark-100 rounded-sm py-2 px-3">
<Icon name="duplicate" className="" />
</button>
</CopyWithTooltip>

View File

@ -8,7 +8,7 @@ interface PanelProps {
export const Panel = ({ children, className }: PanelProps) => (
<div
className={classNames(
'border border-zinc-200 dark:border-zinc-800 rounded-md p-5 mb-5',
'border border-vega-light-150 dark:border-vega-dark-150 rounded-md p-5 mb-5',
className
)}
>

View File

@ -0,0 +1,43 @@
import { useAssetDataProvider } from '@vegaprotocol/assets';
import { addDecimalsFormatNumber } from '@vegaprotocol/react-helpers';
import { AssetLink } from '../links';
export type DecimalSource = 'ASSET';
export type SizeInAssetProps = {
assetId: string;
size?: string | number;
decimalSource?: DecimalSource;
};
/**
* Given a market ID and an order size it will fetch the market
* order size, and format the size accordingly
*/
const SizeInAsset = ({
assetId,
size,
decimalSource = 'ASSET',
}: SizeInAssetProps) => {
const { data } = useAssetDataProvider(assetId);
if (!size) {
return <span>-</span>;
}
let label = size;
if (data) {
if (decimalSource === 'ASSET' && data.decimals) {
label = addDecimalsFormatNumber(size, data.decimals);
}
}
return (
<p>
<span>{label}</span>&nbsp;
<AssetLink assetId={assetId} showAssetSymbol={true} asDialog={true} />
</p>
);
};
export default SizeInAsset;

View File

@ -3,7 +3,7 @@ import { useExplorerMarketQuery } from '../links/market-link/__generated__/Marke
export type DecimalSource = 'MARKET';
export type PriceInMarketProps = {
export type SizeInMarketProps = {
marketId: string;
size?: string | number;
decimalSource?: DecimalSource;
@ -17,7 +17,7 @@ const SizeInMarket = ({
marketId,
size,
decimalSource = 'MARKET',
}: PriceInMarketProps) => {
}: SizeInMarketProps) => {
const { data } = useExplorerMarketQuery({
variables: { id: marketId },
fetchPolicy: 'cache-first',

View File

@ -10,7 +10,7 @@ import { MemoryRouter } from 'react-router-dom';
type Deposit = components['schemas']['vegaBuiltinAssetDeposit'];
const fullMock: Deposit = {
partyId: 'party123',
partyId: '0000000000000000000000000000000000000000000000000000000000000001',
vegaAssetId: 'asset123',
amount: 'amount123',
};

View File

@ -10,7 +10,7 @@ import { MemoryRouter } from 'react-router-dom';
type Withdrawal = components['schemas']['vegaBuiltinAssetWithdrawal'];
const fullMock: Withdrawal = {
partyId: 'party123',
partyId: '0000000000000000000000000000000000000000000000000000000000000001',
vegaAssetId: 'asset123',
amount: 'amount123',
};
@ -67,11 +67,12 @@ describe('Chain Event: Builtin asset withdrawal', () => {
expect(screen.getByText(`${fullMock.amount}`)).toBeInTheDocument();
expect(screen.getByText(t('Recipient'))).toBeInTheDocument();
const partyLink = screen.getByText(`${fullMock.partyId}`);
expect(partyLink).toBeInTheDocument();
if (!partyLink.parentElement) {
throw new Error('Party link does not exist');
}
expect(partyLink.parentElement.tagName).toEqual('A');
expect(partyLink.parentElement.getAttribute('href')).toEqual(
`/parties/${fullMock.partyId}`

View File

@ -10,10 +10,12 @@ import { TxDetailsChainEventDeposit } from './tx-erc20-deposit';
type Deposit = components['schemas']['vegaERC20Deposit'];
const fullMock: Deposit = {
vegaAssetId: 'asset123',
vegaAssetId:
'0000000000000000000000000000000000000000000000000000000000000002',
amount: 'amount123',
sourceEthereumAddress: 'eth123',
targetPartyId: 'vega123',
targetPartyId:
'0000000000000000000000000000000000000000000000000000000000000001',
};
describe('Chain Event: ERC20 asset deposit', () => {

View File

@ -13,7 +13,8 @@ const fullMock: Deposit = {
amount: 'amount123',
blockTime: 'block123',
ethereumAddress: 'eth123',
vegaPublicKey: 'vega123',
vegaPublicKey:
'0000000000000000000000000000000000000000000000000000000000000001',
};
describe('Chain Event: Stake deposit', () => {

View File

@ -13,7 +13,8 @@ const fullMock: Remove = {
amount: 'amount123',
blockTime: 'block123',
ethereumAddress: 'eth123',
vegaPublicKey: 'vega123',
vegaPublicKey:
'0000000000000000000000000000000000000000000000000000000000000001',
};
describe('Chain Event: Stake remove', () => {

View File

@ -36,7 +36,7 @@ export const ChainResponseCode = ({
error && error.length > 100 ? error.replace(/,/g, ',\r\n') : error;
return (
<div title={`Response code: ${code} - ${label}`}>
<div title={`Response code: ${code} - ${label}`} className="inline-block">
<span
className="mr-2"
aria-label={isSuccess ? 'Success' : 'Warning'}

View File

@ -14,10 +14,14 @@ interface TxDetailsSharedProps {
txData: BlockExplorerTransactionResult | undefined;
pubKey: string | undefined;
blockData: TendermintBlocksResponse | undefined;
// A transitional property used in some complex TX types to display more detailed type information
// than the shared component can derive
hideTypeRow?: boolean;
}
// Applied to all header cells
const sharedHeaderProps = {
export const sharedHeaderProps = {
// Ensures that multi line contents still have the header aligned to the first line
className: 'align-top',
};
@ -31,6 +35,7 @@ export const TxDetailsShared = ({
txData,
pubKey,
blockData,
hideTypeRow = false,
}: TxDetailsSharedProps) => {
if (!txData) {
return <>{t('Awaiting Block Explorer transaction details')}</>;
@ -41,10 +46,12 @@ export const TxDetailsShared = ({
return (
<>
<TableRow modifier="bordered">
<TableCell {...sharedHeaderProps}>{t('Type')}</TableCell>
<TableCell>{txData.type}</TableCell>
</TableRow>
{hideTypeRow === false ? (
<TableRow modifier="bordered">
<TableCell {...sharedHeaderProps}>{t('Type')}</TableCell>
<TableCell>{txData.type}</TableCell>
</TableRow>
) : null}
<TableRow modifier="bordered">
<TableCell {...sharedHeaderProps}>{t('Hash')}</TableCell>
<TableCell>

View File

@ -0,0 +1,138 @@
import { t } from '@vegaprotocol/react-helpers';
import { Icon, Tooltip } from '@vegaprotocol/ui-toolkit';
import { PartyLink } from '../../../../links';
import {
SPECIAL_CASE_NETWORK,
SPECIAL_CASE_NETWORK_ID,
} from '../../../../links/party-link/party-link';
import SizeInAsset from '../../../../size-in-asset/size-in-asset';
import { AccountTypeMapping } from '@vegaprotocol/types';
import { AccountType } from '@vegaprotocol/types';
import { headerClasses, wrapperClasses } from '../transfer-details';
import type { Transfer } from '../transfer-details';
interface TransferParticipantsProps {
transfer: Transfer;
from: string;
}
/**
* Renders a box containing the To, From and amount of a
* transfer. This is shown for all transfers, including
* recurring and reward transfers.
*
* @param transfer A recurring transfer object
* @param from The sender is not in the transaction, but comes from the Transaction submitter
*/
export function TransferParticipants({
transfer,
from,
}: TransferParticipantsProps) {
// This mapping is required as the global account types require a type to be set, while
// the underlying protobufs allow for every field to be undefined.
const fromAcct =
transfer.fromAccountType &&
transfer.fromAccountType !== 'ACCOUNT_TYPE_UNSPECIFIED'
? AccountType[transfer.fromAccountType]
: AccountType.ACCOUNT_TYPE_GENERAL;
const fromAccountTypeLabel = transfer.fromAccountType
? AccountTypeMapping[fromAcct]
: 'Unknown';
const toAcct =
transfer.toAccountType &&
transfer.toAccountType !== 'ACCOUNT_TYPE_UNSPECIFIED'
? AccountType[transfer.toAccountType]
: AccountType.ACCOUNT_TYPE_GENERAL;
const toAccountTypeLabel = transfer.fromAccountType
? AccountTypeMapping[toAcct]
: 'Unknown';
return (
<div className={wrapperClasses}>
<h2 className={headerClasses}>{t('Transfer')}</h2>
<div className="relative block rounded-lg py-6 text-center">
<PartyLink id={from} truncate={true} />
<Tooltip
description={
<p>{`${t('From account')}: ${fromAccountTypeLabel}`}</p>
}
>
<span>
<Icon className="ml-3" name={'bank-account'} />
</span>
</Tooltip>
<br />
{/* This block of divs is used to render the inset arrow containing the transfer amount */}
<div className="bg-vega-light-200 dark:vega-dark-200 flex items-center justify-center my-4 relative">
<div className="bg-vega-light-200 dark:bg-vega-dark-200 border w-full pt-5 pb-3 px-3 border-vega-light-200 dark:border-vega-dark-150 relative">
<div className="text-xs z-20 relative leading-none">
{transfer.asset ? (
<SizeInAsset assetId={transfer.asset} size={transfer.amount} />
) : null}
</div>
{/* Empty divs for the top arrow and the bottom arrow of the transfer inset */}
<div className="z-10 absolute top-[-1px] left-1/2 w-4 h-4">
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 16 9"
className="fill-vega-light-100 dark:fill-black"
>
<path d="M0,0L8,9l8,-9Z" />
</svg>
</div>
<div className="z-10 absolute bottom-[-16px] left-1/2 w-4 h-4">
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 16 9"
className="fill-vega-light-100 dark:fill-vega-dark-200"
>
<path d="M0,0L8,9l8,-9Z" />
</svg>
</div>
{/*
<div className="z-10 absolute top-0 left-1/2 transform -translate-x-1/2 -translate-y-1/2 rotate-45 w-4 h-4 dark:border-vega-dark-200 border-vega-light-200 bg-white dark:bg-black border-r border-b"></div>
<div className="z-10 absolute bottom-0 left-1/2 transform -translate-x-1/2 translate-y-1/2 rotate-45 w-4 h-4 border-vega-light-200 dark:border-vega-dark-200 bg-vega-light-200 dark:bg-vega-dark-200 border-r border-b"></div>
*/}
</div>
</div>
<TransferRecurringRecipient to={transfer.to} />
<Tooltip
description={<p>{`${t('To account')}: ${toAccountTypeLabel}`}</p>}
>
<span>
<Icon className="ml-3" name={'bank-account'} />
</span>
</Tooltip>
<br />
</div>
</div>
);
}
interface TransferRecurringRecipientProps {
to?: string;
}
/**
* If the transfer is to 000...000, then this is a transfer to the
* Rewards Pool rather than the network. This component saves this
* logic from complicating the To section of the participants block
*
* @param markets String[] IDs of markets for this dispatch strategy
*/
export function TransferRecurringRecipient({
to,
}: TransferRecurringRecipientProps) {
if (to === SPECIAL_CASE_NETWORK || to === SPECIAL_CASE_NETWORK_ID) {
return <span>{t('Rewards pool')}</span>;
} else if (to) {
return <PartyLink id={to} truncate={true} />;
}
// Fallback should not happen
return null;
}

View File

@ -0,0 +1,86 @@
import { t } from '@vegaprotocol/react-helpers';
import { Icon } from '@vegaprotocol/ui-toolkit';
import EpochOverview from '../../../../epoch-overview/epoch';
import { useExplorerFutureEpochQuery } from '../../../../epoch-overview/__generated__/Epoch';
import { headerClasses, wrapperClasses } from '../transfer-details';
import type { IconProps } from '@vegaprotocol/ui-toolkit';
import type { Recurring } from '../transfer-details';
interface TransferRepeatProps {
recurring: Recurring;
}
/**
* Renderer for a transfer. These can vary quite
* widely, essentially every field can be null.
*
* @param transfer A recurring transfer object
*/
export function TransferRepeat({ recurring }: TransferRepeatProps) {
const { data } = useExplorerFutureEpochQuery();
if (!recurring) {
return null;
}
return (
<div className={wrapperClasses}>
<h2 className={headerClasses}>{t('Active epochs')}</h2>
<div className="relative block rounded-lg py-6 text-center p-6">
<p>
<EpochOverview id={recurring.startEpoch} />
</p>
<p className="leading-10 my-2">
<IconForEpoch
start={recurring.startEpoch}
end={recurring.endEpoch}
current={data?.epoch.id}
/>
</p>
<p>
{recurring.endEpoch ? (
<EpochOverview id={recurring.endEpoch} />
) : (
<span>{t('Forever')}</span>
)}
</p>
</div>
</div>
);
}
export type IconForTransferProps = {
current?: string;
start?: string;
end?: string;
};
/**
* Pick an icon rto represent the state of the repetition for this recurring
* transfer. It can be unstarted, in progress, or complete.
*
* @param start The epoch in which the transfer first occurs
* @param end The last epoch in which the transfer occurs
* @param current The current epoch
*/
function IconForEpoch({ start, end, current }: IconForTransferProps) {
let i: IconProps['name'] = 'repeat';
if (current && start && end) {
const startEpoch = parseInt(start);
const endEpoch = parseInt(end);
const currentEpoch = parseInt(current);
if (currentEpoch > endEpoch) {
// If we've finished
i = 'updated';
} else if (startEpoch > currentEpoch) {
// If we haven't yet started
i = 'time';
} else if (startEpoch < currentEpoch && endEpoch > currentEpoch) {
i = 'repeat';
}
}
return <Icon name={i} className="mr-2" />;
}

View File

@ -0,0 +1,97 @@
import { t } from '@vegaprotocol/react-helpers';
import { AssetLink, MarketLink } from '../../../../links';
import { headerClasses, wrapperClasses } from '../transfer-details';
import type { components } from '../../../../../../types/explorer';
import type { Recurring } from '../transfer-details';
import { DispatchMetricLabels } from '@vegaprotocol/types';
export type Metric = components['schemas']['vegaDispatchMetric'];
export type Strategy = components['schemas']['vegaDispatchStrategy'];
const metricLabels = {
DISPATCH_METRIC_UNSPECIFIED: 'Unknown metric',
...DispatchMetricLabels,
};
interface TransferRewardsProps {
recurring: Recurring;
}
/**
* Renderer for a transfer. These can vary quite
* widely, essentially every field can be null.
*
* @param transfer A recurring transfer object
*/
export function TransferRewards({ recurring }: TransferRewardsProps) {
const metric =
recurring?.dispatchStrategy?.metric || 'DISPATCH_METRIC_UNSPECIFIED';
if (!recurring || !recurring.dispatchStrategy) {
return null;
}
return (
<div className={wrapperClasses}>
<h2 className={headerClasses}>{t('Reward metrics')}</h2>
<ul className="relative block rounded-lg py-6 text-center p-6">
{recurring.dispatchStrategy.assetForMetric ? (
<li>
<strong>{t('Asset')}</strong>:{' '}
<AssetLink assetId={recurring.dispatchStrategy.assetForMetric} />
</li>
) : null}
<li>
<strong>{t('Metric')}</strong>: {metricLabels[metric]}
</li>
{recurring.dispatchStrategy.markets &&
recurring.dispatchStrategy.markets.length > 0 ? (
<li>
<strong>{t('Markets in scope')}</strong>:
<ul>
{recurring.dispatchStrategy.markets.map((m) => (
<li key={m}>
<MarketLink id={m} />
</li>
))}
</ul>
</li>
) : null}
<li>
<strong>{t('Factor')}</strong>: {recurring.factor}
</li>
</ul>
</div>
);
}
interface TransferRecurringStrategyProps {
strategy: Strategy;
}
/**
* Simple renderer for a dispatch strategy in a recurring transfer
*
* @param strategy Dispatch strategy object
*/
export function TransferRecurringStrategy({
strategy,
}: TransferRecurringStrategyProps) {
if (!strategy) {
return null;
}
return (
<>
{strategy.assetForMetric ? (
<li>
<strong>{t('Asset for metric')}</strong>:{' '}
<AssetLink assetId={strategy.assetForMetric} />
</li>
) : null}
<li>
<strong>{t('Metric')}</strong>: {strategy.metric}
</li>
</>
);
}

View File

@ -0,0 +1,39 @@
import type { components } from '../../../../../types/explorer';
import { TransferRepeat } from './blocks/transfer-repeat';
import { TransferRewards } from './blocks/transfer-rewards';
import { TransferParticipants } from './blocks/transfer-participants';
export type Recurring = components['schemas']['v1RecurringTransfer'];
export type Metric = components['schemas']['vegaDispatchMetric'];
export const wrapperClasses =
'border border-vega-light-150 dark:border-vega-dark-200 rounded-md pv-2 mb-5 w-full sm:w-1/4 min-w-[200px] ';
export const headerClasses =
'bg-solid bg-vega-light-150 dark:bg-vega-dark-150 border-vega-light-150 text-center text-xl py-2 font-alpha';
export type Transfer = components['schemas']['commandsv1Transfer'];
interface TransferDetailsProps {
transfer: Transfer;
from: string;
}
/**
* Renderer for a transfer. These can vary quite
* widely, essentially every field can be null.
*
* @param transfer A recurring transfer object
*/
export function TransferDetails({ transfer, from }: TransferDetailsProps) {
const recurring = transfer.recurring;
return (
<div className="flex gap-5 flex-wrap">
<TransferParticipants from={from} transfer={transfer} />
{recurring ? <TransferRepeat recurring={transfer.recurring} /> : null}
{recurring && recurring.dispatchStrategy ? (
<TransferRewards recurring={transfer.recurring} />
) : null}
</div>
);
}

View File

@ -23,6 +23,7 @@ import { TxDetailsProtocolUpgrade } from './tx-details-protocol-upgrade';
import { TxDetailsIssueSignatures } from './tx-issue-signatures';
import { TxDetailsNodeAnnounce } from './tx-node-announce';
import { TxDetailsStateVariable } from './tx-state-variable-proposal';
import { TxDetailsTransfer } from './tx-transfer';
interface TxDetailsWrapperProps {
txData: BlockExplorerTransactionResult | undefined;
@ -108,6 +109,8 @@ function getTransactionComponent(txData?: BlockExplorerTransactionResult) {
return TxDetailsUndelegate;
case 'State Variable Proposal':
return TxDetailsStateVariable;
case 'Transfer Funds':
return TxDetailsTransfer;
default:
return TxDetailsGeneric;
}

View File

@ -0,0 +1,125 @@
import { t } from '@vegaprotocol/react-helpers';
import type { BlockExplorerTransactionResult } from '../../../routes/types/block-explorer-response';
import type { TendermintBlocksResponse } from '../../../routes/blocks/tendermint-blocks-response';
import { sharedHeaderProps, TxDetailsShared } from './shared/tx-details-shared';
import { TableRow, TableCell, TableWithTbody } from '../../table';
import type { components } from '../../../../types/explorer';
import { PartyLink } from '../../links';
import SizeInAsset from '../../size-in-asset/size-in-asset';
import { TransferDetails } from './transfer/transfer-details';
import {
SPECIAL_CASE_NETWORK,
SPECIAL_CASE_NETWORK_ID,
} from '../../links/party-link/party-link';
type Transfer = components['schemas']['commandsv1Transfer'];
interface TxDetailsNodeAnnounceProps {
txData: BlockExplorerTransactionResult | undefined;
pubKey: string | undefined;
blockData: TendermintBlocksResponse | undefined;
}
/**
* Displays the details of a transfer. Broadly there are three distinct
* types of transfer, listed below in order of complexity:
*
* - A one off transfer
* - A recurring transfer
* - A recurring rewards pool transfer
*
* One off transfers are simple, really the important data is the amount
* and who sent it to whom. This is rendered as one distinct box.
*
* A recurring transfer has two components - the same as above, and an
* additional box that shows details about how it repeats. This is defined
* as a start epoch and and end epoch. The Epoch/MissingEpoch components
* render slightly differently depending on if the epoch is in the past,
* current or in the future.
*
* Finally rewards pool transfers get the two boxes above, and an additional
* one that describes how the reward is distributed.
*
* The information is split up in to three boxes to allow for the reuse across
* all the types of transfer above.
*/
export const TxDetailsTransfer = ({
txData,
pubKey,
blockData,
}: TxDetailsNodeAnnounceProps) => {
if (!txData) {
return <>{t('Awaiting Block Explorer transaction details')}</>;
}
const transfer: Transfer = txData.command.transfer;
if (!transfer) {
return <>{t('Transfer data missing')}</>;
}
const from = txData.submitter;
return (
<>
<TableWithTbody className="mb-8" allowWrap={true}>
<TableRow modifier="bordered">
<TableCell {...sharedHeaderProps}>{t('Type')}</TableCell>
<TableCell>{getTypeLabelForTransfer(transfer)}</TableCell>
</TableRow>
<TxDetailsShared
txData={txData}
pubKey={pubKey}
blockData={blockData}
hideTypeRow={true}
/>
{from ? (
<TableRow modifier="bordered">
<TableCell>{t('From')}</TableCell>
<TableCell>
<PartyLink id={from} />
</TableCell>
</TableRow>
) : null}
{transfer.to ? (
<TableRow modifier="bordered">
<TableCell>{t('To')}</TableCell>
<TableCell>
<PartyLink id={transfer.to} />
</TableCell>
</TableRow>
) : null}
{transfer.asset && transfer.amount ? (
<TableRow modifier="bordered">
<TableCell>{t('Amount')}</TableCell>
<TableCell>
<SizeInAsset assetId={transfer.asset} size={transfer.amount} />
</TableCell>
</TableRow>
) : null}
</TableWithTbody>
<TransferDetails from={from} transfer={transfer} />
</>
);
};
/**
* Gets a string description of this transfer
* @param txData A full transfer
* @returns string Transfer label
*/
export function getTypeLabelForTransfer(tx: Transfer) {
if (tx.to === SPECIAL_CASE_NETWORK || tx.to === SPECIAL_CASE_NETWORK_ID) {
if (tx.recurring && tx.recurring.dispatchStrategy) {
return 'Reward top up transfer';
}
// Else: we don't know that it's a reward transfer, so let's not guess
} else if (tx.recurring) {
return 'Recurring transfer';
} else if (tx.oneOff) {
// Currently redundant, but could be used to indicate something more specific
return 'Transfer';
}
return 'Transfer';
}

View File

@ -14,14 +14,17 @@ interface StringMap {
// Using https://github.com/vegaprotocol/protos/blob/e0f646ce39aab1fc66a9200ceec0262306d3beb3/commands/transaction.go#L93 as a reference
const displayString: StringMap = {
OrderSubmission: 'Order Submission',
'Submit Order': 'Order',
OrderCancellation: 'Order Cancellation',
OrderAmendment: 'Order Amendment',
VoteSubmission: 'Vote Submission',
WithdrawSubmission: 'Withdraw Submission',
Withdraw: 'Withdraw Request',
LiquidityProvisionSubmission: 'Liquidity Provision',
LiquidityProvisionCancellation: 'Liquidity Cancellation',
LiquidityProvisionAmendment: 'Liquidity Amendment',
LiquidityProvisionSubmission: 'LP order',
'Liquidity Provision Order': 'LP order',
LiquidityProvisionCancellation: 'LP cancel',
LiquidityProvisionAmendment: 'LP update',
'Amend LiquidityProvision Order': 'Amend LP',
ProposalSubmission: 'Governance Proposal',
AnnounceNode: 'Node Announcement',
NodeVote: 'Node Vote',
@ -31,10 +34,11 @@ const displayString: StringMap = {
DelegateSubmission: 'Delegation',
UndelegateSubmission: 'Undelegation',
KeyRotateSubmission: 'Key Rotation',
StateVariableProposal: 'State Variable Proposal',
StateVariableProposal: 'State Variable',
Transfer: 'Transfer',
CancelTransfer: 'Cancel Transfer',
ValidatorHeartbeat: 'Validator Heartbeat',
ValidatorHeartbeat: 'Heartbeat',
'Batch Market Instructions': 'Batch',
};
/**
@ -132,7 +136,8 @@ export function getLabelForChainEvent(
export const TxOrderType = ({ orderType, command }: TxOrderTypeProps) => {
let type = displayString[orderType] || orderType;
let colours = 'text-white dark:text-white bg-zinc-800 dark:bg-zinc-800';
let colours =
'text-white dark:text-white bg-vega-dark-150 dark:bg-vega-dark-150';
// This will get unwieldy and should probably produce a different colour of tag
if (type === 'Chain Event' && !!command?.chainEvent) {

View File

@ -0,0 +1,58 @@
import { getTypeLabelForTransfer } from './details/tx-transfer';
import type { components } from '../../../types/explorer';
type Transfer = components['schemas']['commandsv1Transfer'];
describe('TX: Transfer: getLabelForTransfer', () => {
it('renders reward top up label if the TO party is 000', () => {
const mock: Transfer = {
to: '0000000000000000000000000000000000000000000000000000000000000000',
recurring: {
dispatchStrategy: {},
},
};
expect(getTypeLabelForTransfer(mock)).toEqual('Reward top up transfer');
});
it('renders reward top up label if the TO party is network', () => {
const mock = {
to: 'network',
recurring: {
dispatchStrategy: {},
},
};
expect(getTypeLabelForTransfer(mock)).toEqual('Reward top up transfer');
});
it('renders recurring label if the tx has a recurring property', () => {
const mock: Transfer = {
to: '0000000000000000000000000000000000000000000000000000000000000001',
recurring: {
startEpoch: '0',
},
};
expect(getTypeLabelForTransfer(mock)).toEqual('Recurring transfer');
});
it('renders one off label if the tx has a oneOff property', () => {
const mock: Transfer = {
to: '0000000000000000000000000000000000000000000000000000000000000001',
oneOff: {
deliverOn: '0',
},
};
expect(getTypeLabelForTransfer(mock)).toEqual('Transfer');
});
it('renders one off label otherwise', () => {
const mock: Transfer = {
to: '0000000000000000000000000000000000000000000000000000000000000001',
};
expect(getTypeLabelForTransfer(mock)).toEqual('Transfer');
});
});

View File

@ -37,7 +37,9 @@ export const TxsInfiniteListItem = ({
className="text-sm col-span-10 xl:col-span-3 leading-none"
data-testid="tx-hash"
>
<span className="xl:hidden uppercase text-zinc-500">ID:&nbsp;</span>
<span className="xl:hidden uppercase text-vega-dark-300">
ID:&nbsp;
</span>
<TruncatedLink
to={`/${Routes.TX}/${toHex(hash)}`}
text={hash}
@ -49,7 +51,9 @@ export const TxsInfiniteListItem = ({
className="text-sm col-span-10 xl:col-span-3 leading-none"
data-testid="pub-key"
>
<span className="xl:hidden uppercase text-zinc-500">By:&nbsp;</span>
<span className="xl:hidden uppercase text-vega-dark-300">
By:&nbsp;
</span>
<TruncatedLink
to={`/${Routes.PARTIES}/${submitter}`}
text={submitter}
@ -64,7 +68,9 @@ export const TxsInfiniteListItem = ({
className="text-sm col-span-3 xl:col-span-1 leading-none flex items-center"
data-testid="tx-block"
>
<span className="xl:hidden uppercase text-zinc-500">Block:&nbsp;</span>
<span className="xl:hidden uppercase text-vega-dark-300">
Block:&nbsp;
</span>
<TruncatedLink
to={`/${Routes.BLOCKS}/${block}`}
text={block}
@ -76,7 +82,7 @@ export const TxsInfiniteListItem = ({
className="text-sm col-span-2 xl:col-span-1 leading-none flex items-center"
data-testid="tx-success"
>
<span className="xl:hidden uppercase text-zinc-500">
<span className="xl:hidden uppercase text-vega-dark-300">
Success:&nbsp;
</span>
{isNumber(code) ? (

View File

@ -94,7 +94,7 @@ export const TxsInfiniteList = ({
return (
<div className={className} data-testid="transactions-list">
<div className="xl:grid grid-cols-10 w-full mb-3 hidden text-zinc-500 uppercase">
<div className="xl:grid grid-cols-10 w-full mb-3 hidden text-vega-dark-300 uppercase">
<div className="col-span-3">
<span className="hidden xl:inline">Transaction &nbsp;</span>
<span>ID</span>

View File

@ -68,7 +68,7 @@ const Party = () => {
return (
<section>
<h1
className="font-alpha uppercase font-xl mb-4 text-zinc-800 dark:text-zinc-200"
className="font-alpha uppercase font-xl mb-4 text-vega-dark-100 dark:text-vega-light-100"
data-testid="parties-header"
>
{t('Public key')}

View File

@ -15,6 +15,7 @@ type OneOf<T extends any[]> = T extends [infer Only]
? OneOf<[XOR<A, B>, ...Rest]>
: never;
/* eslint-enable @typescript-eslint/no-explicit-any */
export interface paths {
'/info': {
/**
@ -40,8 +41,6 @@ export interface paths {
};
}
export type webhooks = Record<string, never>;
export interface components {
schemas: {
/**
@ -66,7 +65,7 @@ export interface components {
| 'OPERATOR_LESS_THAN'
| 'OPERATOR_LESS_THAN_OR_EQUAL';
/**
* The supported Oracle sources
* The supported oracle sources
* @description - ORACLE_SOURCE_UNSPECIFIED: The default value
* - ORACLE_SOURCE_OPEN_ORACLE: Specifies that the payload will be base64 encoded JSON conforming to the Open Oracle standard
* - ORACLE_SOURCE_JSON: Specifies that the payload will be base64 encoded JSON, but does not specify the shape of the data
@ -174,7 +173,7 @@ export interface components {
readonly '@type'?: string;
[key: string]: unknown | undefined;
};
/** Used announce a node as a new pending validator */
/** Used to announce a node as a new pending validator */
readonly v1AnnounceNode: {
/** AvatarURL of the validator */
readonly avatarUrl?: string;
@ -269,13 +268,13 @@ export interface components {
readonly v1ETHAddress: {
readonly address?: string;
};
/** A transaction to allow validator to rotate their ethereum keys */
/** A transaction to allow a validator to rotate their ethereum keys */
readonly v1EthereumKeyRotateSubmission: {
/** Currently used public address */
readonly currentAddress?: string;
/** Signature that can be verified using the new ethereum address */
readonly ethereumSignature?: components['schemas']['v1Signature'];
/** The new adress to rotate to */
/** The new address to rotate to */
readonly newAddress?: string;
/** Ethereum public key to use as a submitter to allow automatic signature generation */
readonly submitterAddress?: string;
@ -309,7 +308,9 @@ export interface components {
readonly version?: string;
};
readonly v1InputData: {
/** A command used by a node operator to announce its node as a pending validator */
readonly announceNode?: components['schemas']['v1AnnounceNode'];
/** A command to submit a batch of order instructions to a market */
readonly batchMarketInstructions?: components['schemas']['v1BatchMarketInstructions'];
/**
* Format: uint64
@ -323,17 +324,35 @@ export interface components {
* `block_height` prevents replay attacks in conjunction with `nonce` (see above).
*/
readonly blockHeight?: string;
/** A command to request cancelling a recurring transfer */
readonly cancelTransfer?: components['schemas']['v1CancelTransfer'];
/**
* Command used by a validator to submit an event forwarded to the Vega network to provide information
* on events happening on other networks, to be used by a foreign chain
* to recognise a decision taken by the Vega network
*/
readonly chainEvent?: components['schemas']['v1ChainEvent'];
/** Command to delegate tokens to a validator */
readonly delegateSubmission?: components['schemas']['v1DelegateSubmission'];
/** Command used by a validator to allow given validator to rotate their Ethereum keys */
readonly ethereumKeyRotateSubmission?: components['schemas']['v1EthereumKeyRotateSubmission'];
/** Command used by a validator to submit signatures to a smart contract */
readonly issueSignatures?: components['schemas']['v1IssueSignatures'];
/** Command used by a validator to allow given validator to rotate their Vega keys */
readonly keyRotateSubmission?: components['schemas']['v1KeyRotateSubmission'];
/** Command to request amending a liquidity commitment */
readonly liquidityProvisionAmendment?: components['schemas']['v1LiquidityProvisionAmendment'];
/** Command to request cancelling a liquidity commitment */
readonly liquidityProvisionCancellation?: components['schemas']['v1LiquidityProvisionCancellation'];
/** Command to submit a liquidity commitment */
readonly liquidityProvisionSubmission?: components['schemas']['v1LiquidityProvisionSubmission'];
/** Command used by a validator to submit a signature, to be used by a foreign chain to recognise a decision taken by the Vega network */
readonly nodeSignature?: components['schemas']['v1NodeSignature'];
/** Validator commands */
/**
* Validator commands
* Command used by a validator when a node votes for validating that a given resource exists or is valid,
* for example, an ERC20 deposit is valid and exists on ethereum
*/
readonly nodeVote?: components['schemas']['v1NodeVote'];
/**
* Format: uint64
@ -349,30 +368,50 @@ export interface components {
* slightly differently, causing a different hash.
*/
readonly nonce?: string;
/** Oracles */
/**
* Oracles
* Command to submit new oracle data from third party providers
*/
readonly oracleDataSubmission?: components['schemas']['v1OracleDataSubmission'];
/** Command to amend an order */
readonly orderAmendment?: components['schemas']['v1OrderAmendment'];
/**
* User commands
* Command to cancel an order
*/
readonly orderCancellation?: components['schemas']['v1OrderCancellation'];
/** User commands */
/** A command for submitting an order */
readonly orderSubmission?: components['schemas']['v1OrderSubmission'];
/** Command to submit a governance proposal */
readonly proposalSubmission?: components['schemas']['v1ProposalSubmission'];
/** Command used by a validator to propose a protocol upgrade */
readonly protocolUpgradeProposal?: components['schemas']['v1ProtocolUpgradeProposal'];
/** Command used by a validator to submit a floating point value */
readonly stateVariableProposal?: components['schemas']['v1StateVariableProposal'];
/** Command to submit a transfer */
readonly transfer?: components['schemas']['commandsv1Transfer'];
/** Command to remove tokens delegated to a validator */
readonly undelegateSubmission?: components['schemas']['v1UndelegateSubmission'];
/**
* Command used by a validator to signal they are still online and validating blocks
* or ready to validate blocks when they are still a pending validator
*/
readonly validatorHeartbeat?: components['schemas']['v1ValidatorHeartbeat'];
/** Command to submit a vote on a governance proposal */
readonly voteSubmission?: components['schemas']['v1VoteSubmission'];
/** Command to submit a withdrawal */
readonly withdrawSubmission?: components['schemas']['v1WithdrawSubmission'];
};
/** A transaction for a validator to submit signatures to a smart contract */
readonly v1IssueSignatures: {
/** The kind of signatures to generate, namely for whether a signer is being added or removed */
readonly kind?: components['schemas']['v1NodeSignatureKind'];
/** The ethereum address which will submit the signatures to the smart-contract */
/** The ethereum address which will submit the signatures to the smart contract */
readonly submitter?: string;
/** The ID of the node that will be signed in or out of the smartcontract */
/** The ID of the node that will be signed in or out of the smart contract */
readonly validatorNodeId?: string;
};
/** A transaction to allow validator to rotate their Vega keys */
/** A transaction to allow a validator to rotate their Vega keys */
readonly v1KeyRotateSubmission: {
/** Hash of currently used public key */
readonly currentPubKeyHash?: string;
@ -496,8 +535,8 @@ export interface components {
/** Specific details for a one off transfer */
readonly v1OneOffTransfer: {
/**
* A unix timestamp in second. Time at which the
* transfer should be delivered in the to account
* A unix timestamp in seconds. Time at which the
* transfer should be delivered into the To account
* Format: int64
*/
readonly deliverOn?: string;
@ -512,7 +551,7 @@ export interface components {
readonly payload?: string;
/**
* @description The source from which the data is coming from. Must be base64 encoded.
* Oracle data a type of external data source data.
* Oracle data is a type of external data source data.
*/
readonly source?: components['schemas']['OracleDataSubmissionOracleSource'];
};
@ -603,7 +642,7 @@ export interface components {
/** Type for the order, required field - See `Order.Type` */
readonly type?: components['schemas']['vegaOrderType'];
};
/** @description PropertyKey describes the property key contained in an data source data. */
/** @description PropertyKey describes the property key contained in data source data. */
readonly v1PropertyKey: {
/** @description name is the name of the property. */
readonly name?: string;
@ -650,6 +689,7 @@ export interface components {
/** Proposal configuration and the actual change that is meant to be executed when proposal is enacted */
readonly terms?: components['schemas']['vegaProposalTerms'];
};
/** A transaction for a validator to suggest a protocol upgrade */
readonly v1ProtocolUpgradeProposal: {
/**
* The block height at which to perform the upgrade
@ -708,6 +748,7 @@ export interface components {
*/
readonly pubKey?: components['schemas']['v1PubKey'];
};
/** A transaction for a validator to submit a floating point value */
readonly v1StateVariableProposal: {
/** The state value proposal details */
readonly proposal?: components['schemas']['vegaStateValueProposal'];
@ -969,7 +1010,7 @@ export interface components {
readonly sourceEthereumAddress?: string;
/** The Vega network internal identifier of the asset */
readonly vegaAssetId?: string;
/** The updated withdraw threshold */
/** The updated withdrawal threshold */
readonly withdrawThreshold?: string;
};
/** An asset allow-listing for an ERC20 token */
@ -1064,7 +1105,7 @@ export interface components {
/** The ethereum address of the old signer */
readonly oldSigner?: string;
};
/** The threshold have been updated on the multisigcontrol */
/** The threshold has been updated on the multisig control */
readonly vegaERC20ThresholdSet: {
/**
* Format: int64
@ -1078,13 +1119,13 @@ export interface components {
* Format: int64
*/
readonly newThreshold?: number;
/** The nonce create by the vega network */
/** The nonce created by the Vega network */
readonly nonce?: string;
};
readonly vegaERC20Update: {
/**
* The lifetime limits deposit per address.
* This is will be interpreted against the asset decimals.
* This will be interpreted against the asset decimals.
* note: this is a temporary measure that can be changed by governance
*/
readonly lifetimeLimit?: string;
@ -1237,7 +1278,7 @@ export interface components {
* price levels over which automated liquidity provision orders will be deployed
*/
readonly lpPriceRange?: string;
/** Optional new market meta data, tags */
/** Optional new market metadata, tags */
readonly metadata?: readonly string[];
/**
* Decimal places for order sizes, sets what size the smallest order / position on the market can be

View File

@ -0,0 +1,36 @@
import { getSecondsFromInterval } from './time';
describe('getSecondsFromInterval', () => {
it('returns 0 for bad data', () => {
expect(getSecondsFromInterval(null as unknown as string)).toEqual(0);
expect(getSecondsFromInterval('')).toEqual(0);
expect(getSecondsFromInterval('🧙')).toEqual(0);
expect(getSecondsFromInterval(2 as unknown as string)).toEqual(0);
});
it('parses out months from a capital M', () => {
expect(getSecondsFromInterval('2M')).toEqual(5184000);
});
it('parses out days from a capital D', () => {
expect(getSecondsFromInterval('1D')).toEqual(86400);
});
it('parses out hours from a lower case h', () => {
expect(getSecondsFromInterval('11h')).toEqual(39600);
});
it('parses out minutes from a lower case m', () => {
expect(getSecondsFromInterval('10m')).toEqual(600);
});
it('parses out seconds from a lower case s', () => {
expect(getSecondsFromInterval('99s')).toEqual(99);
});
it('parses complex examples', () => {
expect(getSecondsFromInterval('24h')).toEqual(86400);
expect(getSecondsFromInterval('1h30m')).toEqual(5400);
expect(getSecondsFromInterval('1D1h30m1s')).toEqual(91801);
});
});

View File

@ -8,3 +8,41 @@ export const fromNanoSeconds = (ts: string) => {
const val = parseISO(ts);
return new Date(isValid(val) ? val : 0);
};
/**
* Parses the interval string we get for the epoch length from the
* network parameter API. These are in the format '1D2H3m' for 1 day,
* 2 hours and 3 minutes.
*
* @param str Interval string
* @returns integer the number of seconds the interval represents
*/
export function getSecondsFromInterval(str: string) {
let seconds = 0;
if (!str || !str.match) {
return seconds;
}
const months = str.match(/(\d+)\s*M/);
const days = str.match(/(\d+)\s*D/);
const hours = str.match(/(\d+)\s*h/);
const minutes = str.match(/(\d+)\s*m/);
const secs = str.match(/(\d+)\s*s/);
if (months) {
seconds += parseInt(months[1]) * 86400 * 30;
}
if (days) {
seconds += parseInt(days[1]) * 86400;
}
if (hours) {
seconds += parseInt(hours[1]) * 3600;
}
if (minutes) {
seconds += parseInt(minutes[1]) * 60;
}
if (secs) {
seconds += parseInt(secs[1]);
}
return seconds;
}

View File

@ -20,6 +20,7 @@ import type {
ValidatorStatus,
VoteValue,
WithdrawalStatus,
DispatchMetric,
} from './__generated__/types';
export const AccountTypeMapping: {
@ -434,3 +435,13 @@ export const DescriptionTransferTypeMapping: TransferTypeMap = {
TRANSFER_TYPE_UNSPECIFIED: 'Default value, always invalid',
TRANSFER_TYPE_CHECKPOINT_BALANCE_RESTORE: `Balances are being restored to the user's account following a checkpoint restart of the network`,
};
type DispatchMetricLabel = {
[T in DispatchMetric]: string;
};
export const DispatchMetricLabels: DispatchMetricLabel = {
DISPATCH_METRIC_LP_FEES_RECEIVED: 'Liquidity Provision fees received',
DISPATCH_METRIC_MAKER_FEES_PAID: 'Price maker fees paid',
DISPATCH_METRIC_MAKER_FEES_RECEIVED: 'Price maker fees earned',
DISPATCH_METRIC_MARKET_VALUE: 'Total market Value',
};