From efeccc79728d2cc684310048577bb767e4addb53 Mon Sep 17 00:00:00 2001 From: Edd Date: Wed, 22 Feb 2023 16:31:06 +0000 Subject: [PATCH] feat(explorer): add transfers tx view (#2902) --- .../components/epoch-overview/Epoch.graphql | 50 +++++++ .../epoch-overview/__generated__/Epoch.ts | 99 +++++++++++++ .../components/epoch-overview/epoc.spec.tsx | 56 +++++++ .../epoch-overview/epoch-missing.spec.tsx | 125 ++++++++++++++++ .../epoch-overview/epoch-missing.tsx | 111 ++++++++++++++ .../app/components/epoch-overview/epoch.tsx | 127 ++++++++++++++++ .../app/components/info-block/info-block.tsx | 4 +- .../app/components/info-panel/info-panel.tsx | 6 +- .../links/asset-link/asset-link.tsx | 15 +- .../links/party-link/party-link.spec.tsx | 39 +++++ .../links/party-link/party-link.tsx | 32 +++- .../deterministic-order-details.tsx | 4 +- .../components/page-header/page-header.tsx | 2 +- .../src/app/components/panel/panel.tsx | 2 +- .../size-in-asset/size-in-asset.tsx | 43 ++++++ .../size-in-market/size-in-market.tsx | 4 +- .../chain-events/tx-builtin-deposit.spec.tsx | 2 +- .../tx-builtin-withdrawal.spec.tsx | 5 +- .../chain-events/tx-erc20-deposit.spec.tsx | 6 +- .../chain-events/tx-stake-deposit.spec.tsx | 3 +- .../chain-events/tx-stake-remove.spec.tsx | 3 +- .../chain-reponse.code.tsx | 2 +- .../txs/details/shared/tx-details-shared.tsx | 17 ++- .../transfer/blocks/transfer-participants.tsx | 138 ++++++++++++++++++ .../transfer/blocks/transfer-repeat.tsx | 86 +++++++++++ .../transfer/blocks/transfer-rewards.tsx | 97 ++++++++++++ .../txs/details/transfer/transfer-details.tsx | 39 +++++ .../txs/details/tx-details-wrapper.tsx | 3 + .../components/txs/details/tx-transfer.tsx | 125 ++++++++++++++++ .../src/app/components/txs/tx-order-type.tsx | 17 ++- .../app/components/txs/tx-transfer.spec.tsx | 58 ++++++++ .../components/txs/txs-infinite-list-item.tsx | 14 +- .../app/components/txs/txs-infinite-list.tsx | 2 +- .../src/app/routes/parties/id/index.tsx | 2 +- apps/explorer/src/types/explorer.d.ts | 83 ++++++++--- libs/react-helpers/src/lib/time.spec.ts | 36 +++++ libs/react-helpers/src/lib/time.ts | 38 +++++ libs/types/src/global-types-mappings.ts | 11 ++ 38 files changed, 1445 insertions(+), 61 deletions(-) create mode 100644 apps/explorer/src/app/components/epoch-overview/Epoch.graphql create mode 100644 apps/explorer/src/app/components/epoch-overview/__generated__/Epoch.ts create mode 100644 apps/explorer/src/app/components/epoch-overview/epoc.spec.tsx create mode 100644 apps/explorer/src/app/components/epoch-overview/epoch-missing.spec.tsx create mode 100644 apps/explorer/src/app/components/epoch-overview/epoch-missing.tsx create mode 100644 apps/explorer/src/app/components/epoch-overview/epoch.tsx create mode 100644 apps/explorer/src/app/components/links/party-link/party-link.spec.tsx create mode 100644 apps/explorer/src/app/components/size-in-asset/size-in-asset.tsx create mode 100644 apps/explorer/src/app/components/txs/details/transfer/blocks/transfer-participants.tsx create mode 100644 apps/explorer/src/app/components/txs/details/transfer/blocks/transfer-repeat.tsx create mode 100644 apps/explorer/src/app/components/txs/details/transfer/blocks/transfer-rewards.tsx create mode 100644 apps/explorer/src/app/components/txs/details/transfer/transfer-details.tsx create mode 100644 apps/explorer/src/app/components/txs/details/tx-transfer.tsx create mode 100644 apps/explorer/src/app/components/txs/tx-transfer.spec.tsx create mode 100644 libs/react-helpers/src/lib/time.spec.ts diff --git a/apps/explorer/src/app/components/epoch-overview/Epoch.graphql b/apps/explorer/src/app/components/epoch-overview/Epoch.graphql new file mode 100644 index 000000000..4f8bd56f5 --- /dev/null +++ b/apps/explorer/src/app/components/epoch-overview/Epoch.graphql @@ -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 +# } +# } +#} diff --git a/apps/explorer/src/app/components/epoch-overview/__generated__/Epoch.ts b/apps/explorer/src/app/components/epoch-overview/__generated__/Epoch.ts new file mode 100644 index 000000000..a2a12ebee --- /dev/null +++ b/apps/explorer/src/app/components/epoch-overview/__generated__/Epoch.ts @@ -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) { + const options = {...defaultOptions, ...baseOptions} + return Apollo.useQuery(ExplorerEpochDocument, options); + } +export function useExplorerEpochLazyQuery(baseOptions?: Apollo.LazyQueryHookOptions) { + const options = {...defaultOptions, ...baseOptions} + return Apollo.useLazyQuery(ExplorerEpochDocument, options); + } +export type ExplorerEpochQueryHookResult = ReturnType; +export type ExplorerEpochLazyQueryHookResult = ReturnType; +export type ExplorerEpochQueryResult = Apollo.QueryResult; +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) { + const options = {...defaultOptions, ...baseOptions} + return Apollo.useQuery(ExplorerFutureEpochDocument, options); + } +export function useExplorerFutureEpochLazyQuery(baseOptions?: Apollo.LazyQueryHookOptions) { + const options = {...defaultOptions, ...baseOptions} + return Apollo.useLazyQuery(ExplorerFutureEpochDocument, options); + } +export type ExplorerFutureEpochQueryHookResult = ReturnType; +export type ExplorerFutureEpochLazyQueryHookResult = ReturnType; +export type ExplorerFutureEpochQueryResult = Apollo.QueryResult; \ No newline at end of file diff --git a/apps/explorer/src/app/components/epoch-overview/epoc.spec.tsx b/apps/explorer/src/app/components/epoch-overview/epoc.spec.tsx new file mode 100644 index 000000000..ba9d3bcae --- /dev/null +++ b/apps/explorer/src/app/components/epoch-overview/epoc.spec.tsx @@ -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(); + + 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(); + + 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(); + + 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(); + + expect(screen.getByRole('img')).toHaveAttribute( + 'aria-label', + 'circle icon' + ); + }); + + it('if start and end are in the paste, this is done', () => { + const screen = render(); + + expect(screen.getByRole('img')).toHaveAttribute( + 'aria-label', + 'tick-circle icon' + ); + }); +}); diff --git a/apps/explorer/src/app/components/epoch-overview/epoch-missing.spec.tsx b/apps/explorer/src/app/components/epoch-overview/epoch-missing.spec.tsx new file mode 100644 index 000000000..5bfa05c36 --- /dev/null +++ b/apps/explorer/src/app/components/epoch-overview/epoch-missing.spec.tsx @@ -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( + + + + ); + } + + it('renders a - if no id is provided', () => { + const n = null as unknown as string; + const screen = renderComponent(n); + expect(screen.getByTestId('empty')).toBeInTheDocument(); + }); +}); diff --git a/apps/explorer/src/app/components/epoch-overview/epoch-missing.tsx b/apps/explorer/src/app/components/epoch-overview/epoch-missing.tsx new file mode 100644 index 000000000..099f93807 --- /dev/null +++ b/apps/explorer/src/app/components/epoch-overview/epoch-missing.tsx @@ -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 -; + } + + // 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 {missingEpochId}; + } + + // 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 {missingEpochId}; + } + + const { label, isInFuture } = calculateEpochData( + data.epoch.id, + missingEpochId, + data.epoch.timestamps.start, + data.networkParameter.value + ); + + return ( + {label}

}> +

+ {isInFuture ? ( + + ) : ( + + )} + {missingEpochId} +

+
+ ); +}; + +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; diff --git a/apps/explorer/src/app/components/epoch-overview/epoch.tsx b/apps/explorer/src/app/components/epoch-overview/epoch.tsx new file mode 100644 index 000000000..3321045bb --- /dev/null +++ b/apps/explorer/src/app/components/epoch-overview/epoch.tsx @@ -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 ; + } + + if (!ti || loading || error) { + return {id}; + } + + const description = ( + + + + + + + + + + + + + + + + + + + + +
{t('Block')}{t('Time')}
{t('Start')} + {ti.firstBlock ? : '-'} + +
{t('End')} + {ti.lastBlock ? ( + + ) : ( + t('In progress') + )} + + {ti.end ? ( + <> +
+ ); + + return ( + +

+ + {id} +

+
+ ); +}; + +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 ; +} + +export default EpochOverview; diff --git a/apps/explorer/src/app/components/info-block/info-block.tsx b/apps/explorer/src/app/components/info-block/info-block.tsx index 4705dbe7c..8a347bc81 100644 --- a/apps/explorer/src/app/components/info-block/info-block.tsx +++ b/apps/explorer/src/app/components/info-block/info-block.tsx @@ -11,14 +11,14 @@ export const InfoBlock = ({ title, subtitle, tooltipInfo }: InfoBlockProps) => { return (

{title}

-

+

{subtitle} {tooltipInfo ? ( diff --git a/apps/explorer/src/app/components/info-panel/info-panel.tsx b/apps/explorer/src/app/components/info-panel/info-panel.tsx index 23e9ba856..f5a07c811 100644 --- a/apps/explorer/src/app/components/info-panel/info-panel.tsx +++ b/apps/explorer/src/app/components/info-panel/info-panel.tsx @@ -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" /> ) : (

{id}

@@ -70,7 +70,7 @@ export const InfoPanel = ({
{copy && ( - diff --git a/apps/explorer/src/app/components/links/asset-link/asset-link.tsx b/apps/explorer/src/app/components/links/asset-link/asset-link.tsx index 1da7d7bf9..232296b2d 100644 --- a/apps/explorer/src/app/components/links/asset-link/asset-link.tsx +++ b/apps/explorer/src/app/components/links/asset-link/asset-link.tsx @@ -11,6 +11,7 @@ import { Routes } from '../../../routes/route-names'; export type AssetLinkProps = Partial> & { assetId: string; asDialog?: boolean; + showAssetSymbol?: boolean; }; /** @@ -18,12 +19,22 @@ export type AssetLinkProps = Partial> & { * 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 ( { + it('renders Network for 000.000 party', () => { + const zeroes = + '0000000000000000000000000000000000000000000000000000000000000000'; + const screen = render(); + expect(screen.getByText('Network')).toBeInTheDocument(); + }); + + it('renders Network for network party', () => { + const screen = render(); + expect(screen.getByText('Network')).toBeInTheDocument(); + }); + + it('renders ID with no link for invalid party', () => { + const screen = render(); + expect(screen.getByTestId('invalid-party')).toBeInTheDocument(); + }); + + it('links a valid party to the party page', () => { + const aValidParty = + '13464e35bcb8e8a2900ca0f87acaf252d50cf2ab2fc73694845a16b7c8a0dc6e'; + + const screen = render( + + + + ); + + 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); + }); +}); diff --git a/apps/explorer/src/app/components/links/party-link/party-link.tsx b/apps/explorer/src/app/components/links/party-link/party-link.tsx index 1040c495f..726b323a9 100644 --- a/apps/explorer/src/app/components/links/party-link/party-link.tsx +++ b/apps/explorer/src/app/components/links/party-link/party-link.tsx @@ -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> & { 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 ( + + {t('Network')} + + ); + } + + // 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 ( + + {id} + + ); + } + return ( - + ); }; diff --git a/apps/explorer/src/app/components/order-details/deterministic-order-details.tsx b/apps/explorer/src/app/components/order-details/deterministic-order-details.tsx index bdf44f518..b8f64176a 100644 --- a/apps/explorer/src/app/components/order-details/deterministic-order-details.tsx +++ b/apps/explorer/src/app/components/order-details/deterministic-order-details.tsx @@ -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 = ({

{t('Order not found')}

-

+

{t('No order created from this transaction')}

diff --git a/apps/explorer/src/app/components/page-header/page-header.tsx b/apps/explorer/src/app/components/page-header/page-header.tsx index 48b23af49..33c55fbb1 100644 --- a/apps/explorer/src/app/components/page-header/page-header.tsx +++ b/apps/explorer/src/app/components/page-header/page-header.tsx @@ -38,7 +38,7 @@ export const PageHeader = ({ {copy && ( - diff --git a/apps/explorer/src/app/components/panel/panel.tsx b/apps/explorer/src/app/components/panel/panel.tsx index 9bab0c695..2f9d69b49 100644 --- a/apps/explorer/src/app/components/panel/panel.tsx +++ b/apps/explorer/src/app/components/panel/panel.tsx @@ -8,7 +8,7 @@ interface PanelProps { export const Panel = ({ children, className }: PanelProps) => (
diff --git a/apps/explorer/src/app/components/size-in-asset/size-in-asset.tsx b/apps/explorer/src/app/components/size-in-asset/size-in-asset.tsx new file mode 100644 index 000000000..9f7231621 --- /dev/null +++ b/apps/explorer/src/app/components/size-in-asset/size-in-asset.tsx @@ -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 -; + } + + let label = size; + + if (data) { + if (decimalSource === 'ASSET' && data.decimals) { + label = addDecimalsFormatNumber(size, data.decimals); + } + } + + return ( +

+ {label}  + +

+ ); +}; + +export default SizeInAsset; diff --git a/apps/explorer/src/app/components/size-in-market/size-in-market.tsx b/apps/explorer/src/app/components/size-in-market/size-in-market.tsx index 6bdb3f6ef..4c993f426 100644 --- a/apps/explorer/src/app/components/size-in-market/size-in-market.tsx +++ b/apps/explorer/src/app/components/size-in-market/size-in-market.tsx @@ -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', diff --git a/apps/explorer/src/app/components/txs/details/chain-events/tx-builtin-deposit.spec.tsx b/apps/explorer/src/app/components/txs/details/chain-events/tx-builtin-deposit.spec.tsx index 3fbd701c9..8ba5e0fb0 100644 --- a/apps/explorer/src/app/components/txs/details/chain-events/tx-builtin-deposit.spec.tsx +++ b/apps/explorer/src/app/components/txs/details/chain-events/tx-builtin-deposit.spec.tsx @@ -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', }; diff --git a/apps/explorer/src/app/components/txs/details/chain-events/tx-builtin-withdrawal.spec.tsx b/apps/explorer/src/app/components/txs/details/chain-events/tx-builtin-withdrawal.spec.tsx index 2103b3d62..73f572316 100644 --- a/apps/explorer/src/app/components/txs/details/chain-events/tx-builtin-withdrawal.spec.tsx +++ b/apps/explorer/src/app/components/txs/details/chain-events/tx-builtin-withdrawal.spec.tsx @@ -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}` diff --git a/apps/explorer/src/app/components/txs/details/chain-events/tx-erc20-deposit.spec.tsx b/apps/explorer/src/app/components/txs/details/chain-events/tx-erc20-deposit.spec.tsx index fd0c435eb..9452f615e 100644 --- a/apps/explorer/src/app/components/txs/details/chain-events/tx-erc20-deposit.spec.tsx +++ b/apps/explorer/src/app/components/txs/details/chain-events/tx-erc20-deposit.spec.tsx @@ -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', () => { diff --git a/apps/explorer/src/app/components/txs/details/chain-events/tx-stake-deposit.spec.tsx b/apps/explorer/src/app/components/txs/details/chain-events/tx-stake-deposit.spec.tsx index 48bf2c27b..2f00e0314 100644 --- a/apps/explorer/src/app/components/txs/details/chain-events/tx-stake-deposit.spec.tsx +++ b/apps/explorer/src/app/components/txs/details/chain-events/tx-stake-deposit.spec.tsx @@ -13,7 +13,8 @@ const fullMock: Deposit = { amount: 'amount123', blockTime: 'block123', ethereumAddress: 'eth123', - vegaPublicKey: 'vega123', + vegaPublicKey: + '0000000000000000000000000000000000000000000000000000000000000001', }; describe('Chain Event: Stake deposit', () => { diff --git a/apps/explorer/src/app/components/txs/details/chain-events/tx-stake-remove.spec.tsx b/apps/explorer/src/app/components/txs/details/chain-events/tx-stake-remove.spec.tsx index 8e98cb74e..555049abf 100644 --- a/apps/explorer/src/app/components/txs/details/chain-events/tx-stake-remove.spec.tsx +++ b/apps/explorer/src/app/components/txs/details/chain-events/tx-stake-remove.spec.tsx @@ -13,7 +13,8 @@ const fullMock: Remove = { amount: 'amount123', blockTime: 'block123', ethereumAddress: 'eth123', - vegaPublicKey: 'vega123', + vegaPublicKey: + '0000000000000000000000000000000000000000000000000000000000000001', }; describe('Chain Event: Stake remove', () => { diff --git a/apps/explorer/src/app/components/txs/details/chain-response-code/chain-reponse.code.tsx b/apps/explorer/src/app/components/txs/details/chain-response-code/chain-reponse.code.tsx index 1ac4577c4..d1f95db5c 100644 --- a/apps/explorer/src/app/components/txs/details/chain-response-code/chain-reponse.code.tsx +++ b/apps/explorer/src/app/components/txs/details/chain-response-code/chain-reponse.code.tsx @@ -36,7 +36,7 @@ export const ChainResponseCode = ({ error && error.length > 100 ? error.replace(/,/g, ',\r\n') : error; return ( -
+
{ if (!txData) { return <>{t('Awaiting Block Explorer transaction details')}; @@ -41,10 +46,12 @@ export const TxDetailsShared = ({ return ( <> - - {t('Type')} - {txData.type} - + {hideTypeRow === false ? ( + + {t('Type')} + {txData.type} + + ) : null} {t('Hash')} diff --git a/apps/explorer/src/app/components/txs/details/transfer/blocks/transfer-participants.tsx b/apps/explorer/src/app/components/txs/details/transfer/blocks/transfer-participants.tsx new file mode 100644 index 000000000..c49ae257b --- /dev/null +++ b/apps/explorer/src/app/components/txs/details/transfer/blocks/transfer-participants.tsx @@ -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 ( +
+

{t('Transfer')}

+
+ + {`${t('From account')}: ${fromAccountTypeLabel}`}

+ } + > + + + +
+
+ + {/* This block of divs is used to render the inset arrow containing the transfer amount */} +
+
+
+ {transfer.asset ? ( + + ) : null} +
+ + {/* Empty divs for the top arrow and the bottom arrow of the transfer inset */} +
+ + + +
+
+ + + +
+ {/* +
+
+ */} +
+
+ + + {`${t('To account')}: ${toAccountTypeLabel}`}

} + > + + + +
+
+
+
+ ); +} + +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 {t('Rewards pool')}; + } else if (to) { + return ; + } + + // Fallback should not happen + return null; +} diff --git a/apps/explorer/src/app/components/txs/details/transfer/blocks/transfer-repeat.tsx b/apps/explorer/src/app/components/txs/details/transfer/blocks/transfer-repeat.tsx new file mode 100644 index 000000000..e29cf25ed --- /dev/null +++ b/apps/explorer/src/app/components/txs/details/transfer/blocks/transfer-repeat.tsx @@ -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 ( +
+

{t('Active epochs')}

+
+

+ +

+

+ +

+

+ {recurring.endEpoch ? ( + + ) : ( + {t('Forever')} + )} +

+
+
+ ); +} + +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 ; +} diff --git a/apps/explorer/src/app/components/txs/details/transfer/blocks/transfer-rewards.tsx b/apps/explorer/src/app/components/txs/details/transfer/blocks/transfer-rewards.tsx new file mode 100644 index 000000000..b9d9bb710 --- /dev/null +++ b/apps/explorer/src/app/components/txs/details/transfer/blocks/transfer-rewards.tsx @@ -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 ( +
+

{t('Reward metrics')}

+
    + {recurring.dispatchStrategy.assetForMetric ? ( +
  • + {t('Asset')}:{' '} + +
  • + ) : null} +
  • + {t('Metric')}: {metricLabels[metric]} +
  • + {recurring.dispatchStrategy.markets && + recurring.dispatchStrategy.markets.length > 0 ? ( +
  • + {t('Markets in scope')}: +
      + {recurring.dispatchStrategy.markets.map((m) => ( +
    • + +
    • + ))} +
    +
  • + ) : null} +
  • + {t('Factor')}: {recurring.factor} +
  • +
+
+ ); +} + +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 ? ( +
  • + {t('Asset for metric')}:{' '} + +
  • + ) : null} +
  • + {t('Metric')}: {strategy.metric} +
  • + + ); +} diff --git a/apps/explorer/src/app/components/txs/details/transfer/transfer-details.tsx b/apps/explorer/src/app/components/txs/details/transfer/transfer-details.tsx new file mode 100644 index 000000000..0c6da4dfe --- /dev/null +++ b/apps/explorer/src/app/components/txs/details/transfer/transfer-details.tsx @@ -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 ( +
    + + {recurring ? : null} + {recurring && recurring.dispatchStrategy ? ( + + ) : null} +
    + ); +} diff --git a/apps/explorer/src/app/components/txs/details/tx-details-wrapper.tsx b/apps/explorer/src/app/components/txs/details/tx-details-wrapper.tsx index add84f009..23aaf671f 100644 --- a/apps/explorer/src/app/components/txs/details/tx-details-wrapper.tsx +++ b/apps/explorer/src/app/components/txs/details/tx-details-wrapper.tsx @@ -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; } diff --git a/apps/explorer/src/app/components/txs/details/tx-transfer.tsx b/apps/explorer/src/app/components/txs/details/tx-transfer.tsx new file mode 100644 index 000000000..9329abc27 --- /dev/null +++ b/apps/explorer/src/app/components/txs/details/tx-transfer.tsx @@ -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 ( + <> + + + {t('Type')} + {getTypeLabelForTransfer(transfer)} + + + {from ? ( + + {t('From')} + + + + + ) : null} + {transfer.to ? ( + + {t('To')} + + + + + ) : null} + {transfer.asset && transfer.amount ? ( + + {t('Amount')} + + + + + ) : null} + + + + ); +}; + +/** + * 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'; +} diff --git a/apps/explorer/src/app/components/txs/tx-order-type.tsx b/apps/explorer/src/app/components/txs/tx-order-type.tsx index 143d06c12..41ef7b68e 100644 --- a/apps/explorer/src/app/components/txs/tx-order-type.tsx +++ b/apps/explorer/src/app/components/txs/tx-order-type.tsx @@ -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) { diff --git a/apps/explorer/src/app/components/txs/tx-transfer.spec.tsx b/apps/explorer/src/app/components/txs/tx-transfer.spec.tsx new file mode 100644 index 000000000..7dfc972c6 --- /dev/null +++ b/apps/explorer/src/app/components/txs/tx-transfer.spec.tsx @@ -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'); + }); +}); diff --git a/apps/explorer/src/app/components/txs/txs-infinite-list-item.tsx b/apps/explorer/src/app/components/txs/txs-infinite-list-item.tsx index 7888808fb..bcf01c4eb 100644 --- a/apps/explorer/src/app/components/txs/txs-infinite-list-item.tsx +++ b/apps/explorer/src/app/components/txs/txs-infinite-list-item.tsx @@ -37,7 +37,9 @@ export const TxsInfiniteListItem = ({ className="text-sm col-span-10 xl:col-span-3 leading-none" data-testid="tx-hash" > - ID:  + + ID:  + - By:  + + By:  + - Block:  + + Block:  + - + Success:  {isNumber(code) ? ( diff --git a/apps/explorer/src/app/components/txs/txs-infinite-list.tsx b/apps/explorer/src/app/components/txs/txs-infinite-list.tsx index e215d468a..1ca72187d 100644 --- a/apps/explorer/src/app/components/txs/txs-infinite-list.tsx +++ b/apps/explorer/src/app/components/txs/txs-infinite-list.tsx @@ -94,7 +94,7 @@ export const TxsInfiniteList = ({ return (
    -
    +
    Transaction   ID diff --git a/apps/explorer/src/app/routes/parties/id/index.tsx b/apps/explorer/src/app/routes/parties/id/index.tsx index 030dec39b..395cfea4d 100644 --- a/apps/explorer/src/app/routes/parties/id/index.tsx +++ b/apps/explorer/src/app/routes/parties/id/index.tsx @@ -68,7 +68,7 @@ const Party = () => { return (

    {t('Public key')} diff --git a/apps/explorer/src/types/explorer.d.ts b/apps/explorer/src/types/explorer.d.ts index 50536c2c5..9023ad67d 100644 --- a/apps/explorer/src/types/explorer.d.ts +++ b/apps/explorer/src/types/explorer.d.ts @@ -15,6 +15,7 @@ type OneOf = T extends [infer Only] ? OneOf<[XOR, ...Rest]> : never; /* eslint-enable @typescript-eslint/no-explicit-any */ + export interface paths { '/info': { /** @@ -40,8 +41,6 @@ export interface paths { }; } -export type webhooks = Record; - 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 diff --git a/libs/react-helpers/src/lib/time.spec.ts b/libs/react-helpers/src/lib/time.spec.ts new file mode 100644 index 000000000..1c2518d4c --- /dev/null +++ b/libs/react-helpers/src/lib/time.spec.ts @@ -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); + }); +}); diff --git a/libs/react-helpers/src/lib/time.ts b/libs/react-helpers/src/lib/time.ts index 828e1cff3..1ac28755f 100644 --- a/libs/react-helpers/src/lib/time.ts +++ b/libs/react-helpers/src/lib/time.ts @@ -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; +} diff --git a/libs/types/src/global-types-mappings.ts b/libs/types/src/global-types-mappings.ts index 3f408cf63..09bd6ced9 100644 --- a/libs/types/src/global-types-mappings.ts +++ b/libs/types/src/global-types-mappings.ts @@ -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', +};