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 ? (
+ <>
+
+
+
+ >
+ ) : (
+ {t('-')}
+ )}
+
+
+
+
+ );
+
+ 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 (