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}
{
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 */}
+
+ );
+}
+
+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 (
+
+ );
+}
+
+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 (
+