diff --git a/apps/explorer/src/app/components/asset-balance/asset-balance.tsx b/apps/explorer/src/app/components/asset-balance/asset-balance.tsx index 758fed005..8315fa16b 100644 --- a/apps/explorer/src/app/components/asset-balance/asset-balance.tsx +++ b/apps/explorer/src/app/components/asset-balance/asset-balance.tsx @@ -7,6 +7,7 @@ export type AssetBalanceProps = { price: string; showAssetLink?: boolean; showAssetSymbol?: boolean; + rounded?: boolean; }; /** @@ -18,12 +19,17 @@ const AssetBalance = ({ price, showAssetLink = true, showAssetSymbol = false, + rounded = false, }: AssetBalanceProps) => { const { data: asset, loading } = useAssetDataProvider(assetId); const label = !loading && asset && asset.decimals - ? addDecimalsFixedFormatNumber(price, asset.decimals) + ? addDecimalsFixedFormatNumber( + price, + asset.decimals, + rounded ? 0 : undefined + ) : price; return ( diff --git a/apps/explorer/src/app/components/header/header.tsx b/apps/explorer/src/app/components/header/header.tsx index 142083569..06930895c 100644 --- a/apps/explorer/src/app/components/header/header.tsx +++ b/apps/explorer/src/app/components/header/header.tsx @@ -41,6 +41,7 @@ export const Header = () => { Routes.ASSETS, Routes.MARKETS, Routes.GOVERNANCE, + Routes.TREASURY, Routes.NETWORK_PARAMETERS, Routes.GENESIS, ].map((n) => pages.find((r) => r.path === n)) 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 c52cac8d2..84ae423b0 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 @@ -32,9 +32,17 @@ export function getNameForParty(id: string, data?: ExplorerNodeNamesQuery) { export type PartyLinkProps = Partial> & { id: string; truncate?: boolean; + networkLabel?: string; + truncateLength?: number; }; -const PartyLink = ({ id, truncate = false, ...props }: PartyLinkProps) => { +const PartyLink = ({ + id, + truncate = false, + truncateLength = 4, + networkLabel = t('Network'), + ...props +}: PartyLinkProps) => { const { data } = useExplorerNodeNamesQuery(); const name = useMemo(() => getNameForParty(id, data), [data, id]); const useName = name !== id; @@ -44,7 +52,7 @@ const PartyLink = ({ id, truncate = false, ...props }: PartyLinkProps) => { if (id === SPECIAL_CASE_NETWORK || id === SPECIAL_CASE_NETWORK_ID) { return ( - {t('Network')} + {networkLabel} ); } @@ -70,7 +78,11 @@ const PartyLink = ({ id, truncate = false, ...props }: PartyLinkProps) => { {useName ? ( name ) : ( - + )} diff --git a/apps/explorer/src/app/components/txs/details/transfer/blocks/transfer-status.tsx b/apps/explorer/src/app/components/txs/details/transfer/blocks/transfer-status.tsx index 959e2126b..b3d367430 100644 --- a/apps/explorer/src/app/components/txs/details/transfer/blocks/transfer-status.tsx +++ b/apps/explorer/src/app/components/txs/details/transfer/blocks/transfer-status.tsx @@ -34,10 +34,7 @@ export function TransferStatusView({ status, loading }: TransferStatusProps) { ) : ( <>

- +

{TransferStatusMapping[status]}

@@ -47,6 +44,21 @@ export function TransferStatusView({ status, loading }: TransferStatusProps) { ); } +interface TransferStatusIconProps { + status: TransferStatus; +} + +export function TransferStatusIcon({ status }: TransferStatusIconProps) { + return ( + + + + ); +} + /** * Simple mapping from status to icon name * @param status TransferStatus @@ -60,6 +72,8 @@ export function getIconForStatus(status: TransferStatus): IconName { return IconNames.TICK; case TransferStatus.STATUS_REJECTED: return IconNames.CROSS; + case TransferStatus.STATUS_CANCELLED: + return IconNames.CROSS; default: return IconNames.TIME; } @@ -78,6 +92,8 @@ export function getColourForStatus(status: TransferStatus): string { return 'text-green-500'; case TransferStatus.STATUS_REJECTED: return 'text-red-500'; + case TransferStatus.STATUS_CANCELLED: + return 'text-red-600'; default: return 'text-yellow-500'; } diff --git a/apps/explorer/src/app/routes/route-names.tsx b/apps/explorer/src/app/routes/route-names.tsx index 7e2f2175c..fe0482d8c 100644 --- a/apps/explorer/src/app/routes/route-names.tsx +++ b/apps/explorer/src/app/routes/route-names.tsx @@ -12,4 +12,5 @@ export const Routes = { ORACLES: 'oracles', NETWORK_PARAMETERS: 'network-parameters', DISCLAIMER: 'disclaimer', + TREASURY: 'treasury', }; diff --git a/apps/explorer/src/app/routes/router-config.tsx b/apps/explorer/src/app/routes/router-config.tsx index 8cc9ecd79..f39b87daa 100644 --- a/apps/explorer/src/app/routes/router-config.tsx +++ b/apps/explorer/src/app/routes/router-config.tsx @@ -30,6 +30,7 @@ import { PartyAccountsByAsset } from './parties/id/accounts'; import { Disclaimer } from './pages/disclaimer'; import { useFeatureFlags } from '@vegaprotocol/environment'; import RestrictedPage from './restricted'; +import { NetworkTreasury } from './treasury'; export type Navigable = { path: string; @@ -229,6 +230,17 @@ export const useRouterConfig = () => { ] : []; + const treasuryRoutes: Route[] = [ + { + path: Routes.TREASURY, + handle: { + name: t('Treasury'), + text: t('Treasury'), + breadcrumb: () => {t('Treasury')}, + }, + element: , + }, + ]; const validators: Route[] = featureFlags.EXPLORER_VALIDATORS ? [ { @@ -358,6 +370,7 @@ export const useRouterConfig = () => { ...marketsRoutes, ...networkParametersRoutes, ...validators, + ...treasuryRoutes, ], }, { diff --git a/apps/explorer/src/app/routes/treasury/Treasury.graphql b/apps/explorer/src/app/routes/treasury/Treasury.graphql new file mode 100644 index 000000000..f4762f093 --- /dev/null +++ b/apps/explorer/src/app/routes/treasury/Treasury.graphql @@ -0,0 +1,12 @@ +query ExplorerTreasury { + assetsConnection(pagination: { last: 1000 }) { + edges { + node { + id + networkTreasuryAccount { + balance + } + } + } + } +} diff --git a/apps/explorer/src/app/routes/treasury/TreasuryTransfers.graphql b/apps/explorer/src/app/routes/treasury/TreasuryTransfers.graphql new file mode 100644 index 000000000..b8dbad53c --- /dev/null +++ b/apps/explorer/src/app/routes/treasury/TreasuryTransfers.graphql @@ -0,0 +1,44 @@ +query ExplorerTreasuryTransfers { + transfersConnection( + partyId: "network" + direction: ToOrFrom + pagination: { last: 200 } + ) { + pageInfo { + hasNextPage + } + edges { + node { + transfer { + timestamp + from + amount + to + status + reason + toAccountType + fromAccountType + asset { + id + } + id + status + kind { + ... on OneOffTransfer { + deliverOn + } + ... on RecurringTransfer { + startEpoch + } + ... on OneOffGovernanceTransfer { + deliverOn + } + ... on RecurringGovernanceTransfer { + endEpoch + } + } + } + } + } + } +} diff --git a/apps/explorer/src/app/routes/treasury/__generated__/Treasury.ts b/apps/explorer/src/app/routes/treasury/__generated__/Treasury.ts new file mode 100644 index 000000000..afb98e8be --- /dev/null +++ b/apps/explorer/src/app/routes/treasury/__generated__/Treasury.ts @@ -0,0 +1,52 @@ +import * as Types from '@vegaprotocol/types'; + +import { gql } from '@apollo/client'; +import * as Apollo from '@apollo/client'; +const defaultOptions = {} as const; +export type ExplorerTreasuryQueryVariables = Types.Exact<{ [key: string]: never; }>; + + +export type ExplorerTreasuryQuery = { __typename?: 'Query', assetsConnection?: { __typename?: 'AssetsConnection', edges?: Array<{ __typename?: 'AssetEdge', node: { __typename?: 'Asset', id: string, networkTreasuryAccount?: { __typename?: 'AccountBalance', balance: string } | null } } | null> | null } | null }; + + +export const ExplorerTreasuryDocument = gql` + query ExplorerTreasury { + assetsConnection(pagination: {last: 1000}) { + edges { + node { + id + networkTreasuryAccount { + balance + } + } + } + } +} + `; + +/** + * __useExplorerTreasuryQuery__ + * + * To run a query within a React component, call `useExplorerTreasuryQuery` and pass it any options that fit your needs. + * When your component renders, `useExplorerTreasuryQuery` 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 } = useExplorerTreasuryQuery({ + * variables: { + * }, + * }); + */ +export function useExplorerTreasuryQuery(baseOptions?: Apollo.QueryHookOptions) { + const options = {...defaultOptions, ...baseOptions} + return Apollo.useQuery(ExplorerTreasuryDocument, options); + } +export function useExplorerTreasuryLazyQuery(baseOptions?: Apollo.LazyQueryHookOptions) { + const options = {...defaultOptions, ...baseOptions} + return Apollo.useLazyQuery(ExplorerTreasuryDocument, options); + } +export type ExplorerTreasuryQueryHookResult = ReturnType; +export type ExplorerTreasuryLazyQueryHookResult = ReturnType; +export type ExplorerTreasuryQueryResult = Apollo.QueryResult; \ No newline at end of file diff --git a/apps/explorer/src/app/routes/treasury/__generated__/TreasuryTransfers.ts b/apps/explorer/src/app/routes/treasury/__generated__/TreasuryTransfers.ts new file mode 100644 index 000000000..49a0133b2 --- /dev/null +++ b/apps/explorer/src/app/routes/treasury/__generated__/TreasuryTransfers.ts @@ -0,0 +1,84 @@ +import * as Types from '@vegaprotocol/types'; + +import { gql } from '@apollo/client'; +import * as Apollo from '@apollo/client'; +const defaultOptions = {} as const; +export type ExplorerTreasuryTransfersQueryVariables = Types.Exact<{ [key: string]: never; }>; + + +export type ExplorerTreasuryTransfersQuery = { __typename?: 'Query', transfersConnection?: { __typename?: 'TransferConnection', pageInfo: { __typename?: 'PageInfo', hasNextPage: boolean }, edges?: Array<{ __typename?: 'TransferEdge', node: { __typename?: 'TransferNode', transfer: { __typename?: 'Transfer', timestamp: any, from: string, amount: string, to: string, status: Types.TransferStatus, reason?: string | null, toAccountType: Types.AccountType, fromAccountType: Types.AccountType, id: string, asset?: { __typename?: 'Asset', id: string } | null, kind: { __typename?: 'OneOffGovernanceTransfer', deliverOn?: any | null } | { __typename?: 'OneOffTransfer', deliverOn?: any | null } | { __typename?: 'RecurringGovernanceTransfer', endEpoch?: number | null } | { __typename?: 'RecurringTransfer', startEpoch: number } } } } | null> | null } | null }; + + +export const ExplorerTreasuryTransfersDocument = gql` + query ExplorerTreasuryTransfers { + transfersConnection( + partyId: "network" + direction: ToOrFrom + pagination: {last: 200} + ) { + pageInfo { + hasNextPage + } + edges { + node { + transfer { + timestamp + from + amount + to + status + reason + toAccountType + fromAccountType + asset { + id + } + id + status + kind { + ... on OneOffTransfer { + deliverOn + } + ... on RecurringTransfer { + startEpoch + } + ... on OneOffGovernanceTransfer { + deliverOn + } + ... on RecurringGovernanceTransfer { + endEpoch + } + } + } + } + } + } +} + `; + +/** + * __useExplorerTreasuryTransfersQuery__ + * + * To run a query within a React component, call `useExplorerTreasuryTransfersQuery` and pass it any options that fit your needs. + * When your component renders, `useExplorerTreasuryTransfersQuery` 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 } = useExplorerTreasuryTransfersQuery({ + * variables: { + * }, + * }); + */ +export function useExplorerTreasuryTransfersQuery(baseOptions?: Apollo.QueryHookOptions) { + const options = {...defaultOptions, ...baseOptions} + return Apollo.useQuery(ExplorerTreasuryTransfersDocument, options); + } +export function useExplorerTreasuryTransfersLazyQuery(baseOptions?: Apollo.LazyQueryHookOptions) { + const options = {...defaultOptions, ...baseOptions} + return Apollo.useLazyQuery(ExplorerTreasuryTransfersDocument, options); + } +export type ExplorerTreasuryTransfersQueryHookResult = ReturnType; +export type ExplorerTreasuryTransfersLazyQueryHookResult = ReturnType; +export type ExplorerTreasuryTransfersQueryResult = Apollo.QueryResult; \ No newline at end of file diff --git a/apps/explorer/src/app/routes/treasury/components/asset-icon/index.tsx b/apps/explorer/src/app/routes/treasury/components/asset-icon/index.tsx new file mode 100644 index 000000000..bfecd7ea8 --- /dev/null +++ b/apps/explorer/src/app/routes/treasury/components/asset-icon/index.tsx @@ -0,0 +1,37 @@ +// NOTE: These are a temporary measure, pulled from an old branch on console. + +import { IconNames } from '@blueprintjs/icons'; +import { Icon } from '@vegaprotocol/ui-toolkit'; +import { USDc } from './usdc'; +import { Vega } from './vega'; +import { USDt } from './usdt'; + +export interface AssetIconProps { + symbol: string; +} + +/** + * A poorly implemented, limited support for asset icons. + * + * These are committed as 'deprecated' to discourage use outside the Treasury page. Rather + * than use this, a better approach would be to use source contract addresses to match assets. + * This will be done separately. + * + * @deprecated + */ +export function AssetIcon({ symbol }: AssetIconProps) { + const s = symbol.toLowerCase(); + switch (s) { + case 'a4a16e250a09a86061ec83c2f9466fc9dc33d332f86876ee74b6f128a5cd6710': // mainnet + case 'c9fe6fc24fce121b2cc72680543a886055abb560043fda394ba5376203b7527d': // mainnet + return ; + case 'd1984e3d365faa05bcafbe41f50f90e3663ee7c0da22bb1e24b164e9532691b2': // mainnet + case 'fc7fd956078fb1fc9db5c19b88f0874c4299b2a7639ad05a47a28c0aef291b55': // testnet + return ; + case 'bf1e88d19db4b3ca0d1d5bdb73718a01686b18cf731ca26adedf3c8b83802bba': // mainnet + case 'ede4076aef07fd79502d14326c54ab3911558371baaf697a19d077f4f89de399': // testnet + return ; + default: + return ; + } +} diff --git a/apps/explorer/src/app/routes/treasury/components/asset-icon/usdc.tsx b/apps/explorer/src/app/routes/treasury/components/asset-icon/usdc.tsx new file mode 100644 index 000000000..35b282a77 --- /dev/null +++ b/apps/explorer/src/app/routes/treasury/components/asset-icon/usdc.tsx @@ -0,0 +1,24 @@ +/** + * See note in index.tsx. This component is intended as a placeholder for a + * better, more generic solution. + * + * @deprecated + */ +export const USDc = ({ size = 16 }: { size?: number }) => { + return ( + + + + + + ); +}; diff --git a/apps/explorer/src/app/routes/treasury/components/asset-icon/usdt.tsx b/apps/explorer/src/app/routes/treasury/components/asset-icon/usdt.tsx new file mode 100644 index 000000000..82ef42fdd --- /dev/null +++ b/apps/explorer/src/app/routes/treasury/components/asset-icon/usdt.tsx @@ -0,0 +1,20 @@ +/** + * See note in index.tsx. This component is intended as a placeholder for a + * better, more generic solution. + * + * @deprecated + */ +export const USDt = ({ size = 16 }: { size?: number }) => { + return ( + + + + + ); +}; diff --git a/apps/explorer/src/app/routes/treasury/components/asset-icon/vega.tsx b/apps/explorer/src/app/routes/treasury/components/asset-icon/vega.tsx new file mode 100644 index 000000000..06c0f18dc --- /dev/null +++ b/apps/explorer/src/app/routes/treasury/components/asset-icon/vega.tsx @@ -0,0 +1,28 @@ +/** + * See note in index.tsx. This component is intended as a placeholder for a + * better, more generic solution. + * + * @deprecated + */ +export const Vega = ({ size = 16 }: { size?: number }) => { + return ( + + + + + + + + + + ); +}; diff --git a/apps/explorer/src/app/routes/treasury/components/network-accounts-table.spec.tsx b/apps/explorer/src/app/routes/treasury/components/network-accounts-table.spec.tsx new file mode 100644 index 000000000..a00dcd6c8 --- /dev/null +++ b/apps/explorer/src/app/routes/treasury/components/network-accounts-table.spec.tsx @@ -0,0 +1,171 @@ +import type { DeepPartial } from '@apollo/client/utilities'; +import { parseResultsToAccounts } from './network-accounts-table'; +import { + ExplorerTreasuryDocument, + type ExplorerTreasuryQuery, +} from '../__generated__/Treasury'; +import { render, screen } from '@testing-library/react'; +import { MockedProvider } from '@apollo/client/testing'; +import { MemoryRouter } from 'react-router-dom'; +import { NetworkAccountsTable } from './network-accounts-table'; + +describe('parseResultsToAccounts', () => { + it('should return an array of non-zero treasury accounts', () => { + const data: DeepPartial = { + assetsConnection: { + edges: [ + { + node: { + id: 'asset1', + networkTreasuryAccount: { + balance: '100', + }, + }, + }, + { + node: { + id: 'has0assets', + networkTreasuryAccount: { + balance: '0', + }, + }, + }, + { + node: { + id: 'asset3', + networkTreasuryAccount: { + balance: '50', + }, + }, + }, + { + node: { + id: 'hasnonetworktreasuryaccount', + }, + }, + ], + }, + }; + + const result = parseResultsToAccounts(data as ExplorerTreasuryQuery); + + expect(result).toHaveLength(2); + expect(result).toEqual([ + { + assetId: 'asset1', + balance: '100', + type: 'ACCOUNT_TYPE_NETWORK_TREASURY', + }, + { + assetId: 'asset3', + balance: '50', + type: 'ACCOUNT_TYPE_NETWORK_TREASURY', + }, + ]); + }); + + it('should return an empty array if no non-zero accounts are found', () => { + const data: DeepPartial = { + assetsConnection: { + edges: [ + { + node: { + id: 'asset1', + networkTreasuryAccount: { + balance: '0', + }, + }, + }, + { + node: { + id: 'asset2', + networkTreasuryAccount: { + balance: '0', + }, + }, + }, + ], + }, + }; + + const result = parseResultsToAccounts(data as ExplorerTreasuryQuery); + + expect(result).toHaveLength(0); + expect(result).toEqual([]); + }); + + it('should handle missing data', () => { + const result = parseResultsToAccounts( + undefined as unknown as ExplorerTreasuryQuery + ); + + expect(result).toHaveLength(0); + expect(result).toEqual([]); + }); +}); + +describe('NetworkAccountsTable', () => { + const mockData: ExplorerTreasuryQuery = { + assetsConnection: { + edges: [ + { + node: { + id: 'asset1', + networkTreasuryAccount: { + balance: '100', + }, + }, + }, + { + node: { + id: 'asset2', + networkTreasuryAccount: { + balance: '50', + }, + }, + }, + ], + }, + }; + + const mocks = [ + { + request: { + query: ExplorerTreasuryDocument, + }, + result: { + data: mockData, + }, + }, + ]; + + it('should render network accounts (as many as match - often just 1)', async () => { + render( + + + + + + ); + + // Wait for the data to load + await screen.findByText('Loading...'); + + // Assert that the network accounts are rendered + expect(screen.getByText('asset1')).toBeInTheDocument(); + expect(screen.getByText('asset2')).toBeInTheDocument(); + }); + + it('should handle loading state', async () => { + render( + + + + + + ); + + // Assert that the loading state is rendered + expect(screen.getByText('Loading...')).toBeInTheDocument(); + }); +}); diff --git a/apps/explorer/src/app/routes/treasury/components/network-accounts-table.tsx b/apps/explorer/src/app/routes/treasury/components/network-accounts-table.tsx new file mode 100644 index 000000000..3d1a9f278 --- /dev/null +++ b/apps/explorer/src/app/routes/treasury/components/network-accounts-table.tsx @@ -0,0 +1,87 @@ +import { AsyncRenderer } from '@vegaprotocol/ui-toolkit'; +import { + type ExplorerTreasuryQuery, + useExplorerTreasuryQuery, +} from '../__generated__/Treasury'; +import AssetBalance from '../../../components/asset-balance/asset-balance'; +import { AssetLink } from '../../../components/links'; +import { useMemo } from 'react'; +import { useScreenDimensions } from '@vegaprotocol/react-helpers'; +import { AssetIcon } from './asset-icon'; +import { type NonZeroAccount } from '../network-treasury'; +import { AccountType } from '@vegaprotocol/types'; +import { removePaginationWrapper } from '@vegaprotocol/utils'; + +export const NetworkAccountsTable = () => { + const { data, loading, error } = useExplorerTreasuryQuery({ + // This needs to ignore error as old assets may no longer properly resolve + errorPolicy: 'ignore', + }); + const { screenSize } = useScreenDimensions(); + const shouldRound = useMemo( + () => ['xs', 'sm', 'md', 'lg'].includes(screenSize), + [screenSize] + ); + + return ( + { + const c = parseResultsToAccounts(data); + return ( +
+ {c.map((a) => ( +
+
+
+

+ +

+

+ +

+
+
+ +
+
+
+ ))} +
+ ); + }} + /> + ); +}; + +export function parseResultsToAccounts( + data: ExplorerTreasuryQuery +): NonZeroAccount[] { + const nonZeroAccounts: NonZeroAccount[] = []; + if (data?.assetsConnection?.edges) { + const edges = removePaginationWrapper(data?.assetsConnection?.edges); + if (edges) { + edges.forEach((edge) => { + if ( + edge.networkTreasuryAccount && + edge.networkTreasuryAccount?.balance !== '0' + ) { + nonZeroAccounts.push({ + assetId: edge.id, + balance: edge.networkTreasuryAccount?.balance, + type: AccountType.ACCOUNT_TYPE_NETWORK_TREASURY, + }); + } + }); + } + } + + return nonZeroAccounts; +} diff --git a/apps/explorer/src/app/routes/treasury/components/network-transfers-table.spec.tsx b/apps/explorer/src/app/routes/treasury/components/network-transfers-table.spec.tsx new file mode 100644 index 000000000..64d10b0f3 --- /dev/null +++ b/apps/explorer/src/app/routes/treasury/components/network-transfers-table.spec.tsx @@ -0,0 +1,262 @@ +import { AccountType } from '@vegaprotocol/types'; +import { + typeLabel, + getToAccountTypeLabel, + filterAccountTransfers, +} from './network-transfers-table'; +import { render, screen } from '@testing-library/react'; +import { NetworkTransfersTable } from './network-transfers-table'; +import { MockedProvider } from '@apollo/client/testing'; +import { MemoryRouter } from 'react-router-dom'; +import { + ExplorerTreasuryTransfersDocument, + type ExplorerTreasuryTransfersQuery, +} from '../__generated__/TreasuryTransfers'; +import type { DeepPartial } from '@apollo/client/utilities'; + +describe('typeLabel', () => { + it('should return "Transfer" for "OneOffTransfer" kind', () => { + expect(typeLabel('OneOffTransfer')).toBe('Transfer'); + }); + + it('should return "Transfer" for "RecurringTransfer" kind', () => { + expect(typeLabel('RecurringTransfer')).toBe('Transfer'); + }); + + it('should return "Governance" for "OneOffGovernanceTransfer" kind', () => { + expect(typeLabel('OneOffGovernanceTransfer')).toBe('Governance'); + }); + + it('should return "Governance" for "RecurringGovernanceTransfer" kind', () => { + expect(typeLabel('RecurringGovernanceTransfer')).toBe('Governance'); + }); + + it('should return "Unknown" for unknown kind', () => { + expect(typeLabel()).toBe('Unknown'); + expect(typeLabel('')).toBe('Unknown'); + expect(typeLabel('InvalidKind')).toBe('Unknown'); + }); +}); + +describe('getToAccountTypeLabel', () => { + it('should return "Treasury" when type is ACCOUNT_TYPE_NETWORK_TREASURY', () => { + expect( + getToAccountTypeLabel(AccountType.ACCOUNT_TYPE_NETWORK_TREASURY) + ).toBe('Treasury'); + }); + + it('should return "Fees" when type is any of the fee account types', () => { + expect( + getToAccountTypeLabel(AccountType.ACCOUNT_TYPE_FEES_INFRASTRUCTURE) + ).toBe('Fees'); + expect(getToAccountTypeLabel(AccountType.ACCOUNT_TYPE_FEES_MAKER)).toBe( + 'Fees' + ); + expect(getToAccountTypeLabel(AccountType.ACCOUNT_TYPE_FEES_LIQUIDITY)).toBe( + 'Fees' + ); + expect( + getToAccountTypeLabel(AccountType.ACCOUNT_TYPE_LP_LIQUIDITY_FEES) + ).toBe('Fees'); + expect( + getToAccountTypeLabel( + AccountType.ACCOUNT_TYPE_PENDING_FEE_REFERRAL_REWARD + ) + ).toBe('Fees'); + }); + + it('should return "Insurance" when type is ACCOUNT_TYPE_GLOBAL_INSURANCE', () => { + expect( + getToAccountTypeLabel(AccountType.ACCOUNT_TYPE_GLOBAL_INSURANCE) + ).toBe('Insurance'); + }); + + it('should return "Rewards" when type is any of the reward account types', () => { + expect(getToAccountTypeLabel(AccountType.ACCOUNT_TYPE_GLOBAL_REWARD)).toBe( + 'Rewards' + ); + expect( + getToAccountTypeLabel(AccountType.ACCOUNT_TYPE_REWARD_AVERAGE_POSITION) + ).toBe('Rewards'); + expect( + getToAccountTypeLabel(AccountType.ACCOUNT_TYPE_REWARD_LP_RECEIVED_FEES) + ).toBe('Rewards'); + expect( + getToAccountTypeLabel(AccountType.ACCOUNT_TYPE_REWARD_MAKER_PAID_FEES) + ).toBe('Rewards'); + expect( + getToAccountTypeLabel(AccountType.ACCOUNT_TYPE_REWARD_MAKER_RECEIVED_FEES) + ).toBe('Rewards'); + expect( + getToAccountTypeLabel(AccountType.ACCOUNT_TYPE_REWARD_MARKET_PROPOSERS) + ).toBe('Rewards'); + expect( + getToAccountTypeLabel(AccountType.ACCOUNT_TYPE_REWARD_RELATIVE_RETURN) + ).toBe('Rewards'); + expect( + getToAccountTypeLabel(AccountType.ACCOUNT_TYPE_REWARD_RETURN_VOLATILITY) + ).toBe('Rewards'); + expect( + getToAccountTypeLabel(AccountType.ACCOUNT_TYPE_REWARD_VALIDATOR_RANKING) + ).toBe('Rewards'); + expect(getToAccountTypeLabel(AccountType.ACCOUNT_TYPE_VESTED_REWARDS)).toBe( + 'Rewards' + ); + expect( + getToAccountTypeLabel(AccountType.ACCOUNT_TYPE_VESTING_REWARDS) + ).toBe('Rewards'); + }); + + it('should return "Other" for any other type', () => { + expect(getToAccountTypeLabel(undefined)).toBe('Other'); + expect(getToAccountTypeLabel('unknown' as AccountType)).toBe('Other'); + }); +}); + +describe('filterAccountTransfers', () => { + it('filters out transactions that are not to or from a treasury account', () => { + const data: DeepPartial = { + transfersConnection: { + edges: [ + { + node: { + transfer: { + toAccountType: AccountType.ACCOUNT_TYPE_NETWORK_TREASURY, + fromAccountType: AccountType.ACCOUNT_TYPE_FEES_INFRASTRUCTURE, + }, + }, + }, + { + node: { + transfer: { + toAccountType: AccountType.ACCOUNT_TYPE_NETWORK_TREASURY, + fromAccountType: + AccountType.ACCOUNT_TYPE_REWARD_AVERAGE_POSITION, + }, + }, + }, + { + node: { + transfer: { + toAccountType: AccountType.ACCOUNT_TYPE_FEES_INFRASTRUCTURE, + fromAccountType: AccountType.ACCOUNT_TYPE_NETWORK_TREASURY, + }, + }, + }, + { + node: { + transfer: { + toAccountType: AccountType.ACCOUNT_TYPE_REWARD_AVERAGE_POSITION, + fromAccountType: + AccountType.ACCOUNT_TYPE_REWARD_LP_RECEIVED_FEES, + }, + }, + }, + ], + }, + }; + + const result = filterAccountTransfers( + data as ExplorerTreasuryTransfersQuery + ); + + expect(result).toHaveLength(3); + }); + + it('should return an empty array if no transfers match the filter', () => { + const data: DeepPartial = { + transfersConnection: { + edges: [ + { + node: { + transfer: { + toAccountType: AccountType.ACCOUNT_TYPE_FEES_INFRASTRUCTURE, + fromAccountType: AccountType.ACCOUNT_TYPE_FEES_INFRASTRUCTURE, + }, + }, + }, + { + node: { + transfer: { + toAccountType: AccountType.ACCOUNT_TYPE_REWARD_AVERAGE_POSITION, + fromAccountType: + AccountType.ACCOUNT_TYPE_REWARD_AVERAGE_POSITION, + }, + }, + }, + ], + }, + }; + + const result = filterAccountTransfers( + data as ExplorerTreasuryTransfersQuery + ); + + expect(result).toHaveLength(0); + }); +}); + +describe('NetworkTransfersTable', () => { + it('renders table headers correctly', async () => { + const mocks = [ + { + request: { + query: ExplorerTreasuryTransfersDocument, + }, + result: { + data: { + transfersConnection: { + edges: [ + { + node: { + transfer: { + id: '123', + toAccountType: AccountType.ACCOUNT_TYPE_NETWORK_TREASURY, + fromAccountType: + AccountType.ACCOUNT_TYPE_NETWORK_TREASURY, + amount: '100', + asset: { + id: '1', + }, + timestamp: '2022-01-01T00:00:00Z', + from: 'network', + to: '7100a8a82ef45adb9efa070cc821c6c5c48172d6dc5f842431549490fe5897a0', + reason: '', + status: 'COMPLETED', + kind: { + __typename: 'OneOffGovernanceTransfer', + deliverOn: '123', + }, + }, + }, + }, + ], + }, + }, + }, + }, + ]; + + render( + + + + + + ); + + expect(await screen.findByText('Amount')).toBeInTheDocument(); + expect(screen.getByText('Asset')).toBeInTheDocument(); + expect(screen.getByText('Age')).toBeInTheDocument(); + expect(screen.getByText('From')).toBeInTheDocument(); + expect(screen.getByText('To')).toBeInTheDocument(); + expect(screen.getByText('Status')).toBeInTheDocument(); + expect(screen.getByText('Type')).toBeInTheDocument(); + + expect(screen.getByTestId('from-account').textContent).toEqual('Treasury'); + expect(screen.getByTestId('to-account').textContent).toEqual('7100…97a0'); + expect(screen.getByTestId('transfer-kind').textContent).toEqual( + 'Governance' + ); + }); +}); diff --git a/apps/explorer/src/app/routes/treasury/components/network-transfers-table.tsx b/apps/explorer/src/app/routes/treasury/components/network-transfers-table.tsx new file mode 100644 index 000000000..0b6226c7c --- /dev/null +++ b/apps/explorer/src/app/routes/treasury/components/network-transfers-table.tsx @@ -0,0 +1,253 @@ +import { AsyncRenderer, Icon } from '@vegaprotocol/ui-toolkit'; +import AssetBalance from '../../../components/asset-balance/asset-balance'; +import { AccountType, AccountTypeMapping } from '@vegaprotocol/types'; +import { AssetLink, PartyLink } from '../../../components/links'; +import { + type ExplorerTreasuryTransfersQuery, + useExplorerTreasuryTransfersQuery, +} from '../__generated__/TreasuryTransfers'; +import { TimeAgo } from '../../../components/time-ago'; +import { TransferStatusIcon } from '../../../components/txs/details/transfer/blocks/transfer-status'; +import { t } from '@vegaprotocol/i18n'; +import { IconNames } from '@blueprintjs/icons'; +import { useMemo } from 'react'; +import { useScreenDimensions } from '@vegaprotocol/react-helpers'; + +export const colours = { + INCOMING: '!fill-vega-green-600 text-vega-green-600 mr-2', + OUTGOING: '!fill-vega-pink-600 text-vega-pink-600 mr-2', +}; + +export const theadClasses = + 'py-2 border text-center bg-vega-light-150 dark:bg-vega-dark-150'; + +export function getToAccountTypeLabel(type?: AccountType): string { + switch (type) { + case AccountType.ACCOUNT_TYPE_NETWORK_TREASURY: + return t('Treasury'); + case AccountType.ACCOUNT_TYPE_FEES_INFRASTRUCTURE: + case AccountType.ACCOUNT_TYPE_FEES_MAKER: + case AccountType.ACCOUNT_TYPE_FEES_LIQUIDITY: + case AccountType.ACCOUNT_TYPE_LP_LIQUIDITY_FEES: + case AccountType.ACCOUNT_TYPE_PENDING_FEE_REFERRAL_REWARD: + return t('Fees'); + case AccountType.ACCOUNT_TYPE_GLOBAL_INSURANCE: + return t('Insurance'); + case AccountType.ACCOUNT_TYPE_GLOBAL_REWARD: + case AccountType.ACCOUNT_TYPE_REWARD_AVERAGE_POSITION: + case AccountType.ACCOUNT_TYPE_REWARD_LP_RECEIVED_FEES: + case AccountType.ACCOUNT_TYPE_REWARD_MAKER_PAID_FEES: + case AccountType.ACCOUNT_TYPE_REWARD_MAKER_RECEIVED_FEES: + case AccountType.ACCOUNT_TYPE_REWARD_MARKET_PROPOSERS: + case AccountType.ACCOUNT_TYPE_REWARD_RELATIVE_RETURN: + case AccountType.ACCOUNT_TYPE_REWARD_RETURN_VOLATILITY: + case AccountType.ACCOUNT_TYPE_REWARD_VALIDATOR_RANKING: + case AccountType.ACCOUNT_TYPE_VESTED_REWARDS: + case AccountType.ACCOUNT_TYPE_VESTING_REWARDS: + return t('Rewards'); + default: + return t('Other'); + } +} + +export function typeLabel(kind?: string): string { + switch (kind) { + case 'OneOffTransfer': + case 'RecurringTransfer': + return t('Transfer'); + case 'OneOffGovernanceTransfer': + case 'RecurringGovernanceTransfer': + return t('Governance'); + default: + return t('Unknown'); + } +} + +export function filterAccountTransfers(data: ExplorerTreasuryTransfersQuery) { + return data.transfersConnection?.edges + ?.filter((edge) => { + if ( + edge?.node.transfer.toAccountType === + AccountType.ACCOUNT_TYPE_NETWORK_TREASURY + ) { + return true; + } else if ( + edge?.node.transfer.fromAccountType === + AccountType.ACCOUNT_TYPE_NETWORK_TREASURY + ) { + return true; + } + + return false; + }) + .map((edge) => { + return edge?.node.transfer; + }); +} + +export const NetworkTransfersTable = () => { + const { data, loading, error } = useExplorerTreasuryTransfersQuery({ + // This needs to ignore error as old assets may no longer properly resolve + errorPolicy: 'ignore', + }); + + const { screenSize } = useScreenDimensions(); + const shouldRound = useMemo( + () => ['xs', 'sm', 'md', 'lg'].includes(screenSize), + [screenSize] + ); + const shouldTruncate = useMemo( + () => ['xs', 'sm', 'md', 'lg', 'xl'].includes(screenSize), + [screenSize] + ); + const shouldHideColumns = useMemo( + () => ['xs', 'sm'].includes(screenSize), + [screenSize] + ); + + return ( +
+ { + const c = filterAccountTransfers(data); + if (!c) { + return null; + } + return ( + + + + + + + + + + + + + + {c.map((a) => { + const isIncoming = + a?.toAccountType === + AccountType.ACCOUNT_TYPE_NETWORK_TREASURY; + return ( + + {a && a.amount && a.asset && ( + + )} + + + + + + + + ); + })} + +
{t('Amount')}{t('Asset')}{t('Age')}{t('From')}{t('To')} + {t('Status')} + + {t('Type')} +
+ {a && + a.toAccountType === + AccountType.ACCOUNT_TYPE_NETWORK_TREASURY ? ( + + ) : ( + + )} + + + {a && a.amount && a.asset && ( + + )} + + {a && a.timestamp && } + + {a && a.from && ( + + )} + + {a && a.to && ( + + )} + {a && !a.to && ( + + {getToAccountTypeLabel(a.toAccountType)} + + )} + + {a && a.status && ( + + )} + + + {a && typeLabel(a.kind.__typename)} + +
+ ); + }} + /> +
+ ); +}; diff --git a/apps/explorer/src/app/routes/treasury/index.tsx b/apps/explorer/src/app/routes/treasury/index.tsx new file mode 100644 index 000000000..ad33f6def --- /dev/null +++ b/apps/explorer/src/app/routes/treasury/index.tsx @@ -0,0 +1 @@ +export * from './network-treasury'; diff --git a/apps/explorer/src/app/routes/treasury/network-treasury.tsx b/apps/explorer/src/app/routes/treasury/network-treasury.tsx new file mode 100644 index 000000000..cb5df32b7 --- /dev/null +++ b/apps/explorer/src/app/routes/treasury/network-treasury.tsx @@ -0,0 +1,28 @@ +import { useDocumentTitle } from '../../hooks/use-document-title'; +import type { AccountType } from '@vegaprotocol/types'; +import { t } from '@vegaprotocol/i18n'; +import { RouteTitle } from '../../components/route-title'; +import { NetworkAccountsTable } from './components/network-accounts-table'; +import { NetworkTransfersTable } from './components/network-transfers-table'; + +export type NonZeroAccount = { + assetId: string; + balance: string; + type: AccountType; +}; + +export const NetworkTreasury = () => { + useDocumentTitle(['Network Treasury']); + return ( +
+ {t(`Treasury`)} +
+ +
+
+

{t('Transfers')}

+ +
+
+ ); +};