diff --git a/configs/app/features/index.ts b/configs/app/features/index.ts index f343062abb..2a73471e54 100644 --- a/configs/app/features/index.ts +++ b/configs/app/features/index.ts @@ -38,6 +38,7 @@ export { default as rewards } from './rewards'; export { default as rollbar } from './rollbar'; export { default as rollup } from './rollup'; export { default as safe } from './safe'; +export { default as signetActivity } from './signetActivity'; export { default as sol2uml } from './sol2uml'; export { default as stats } from './stats'; export { default as suave } from './suave'; diff --git a/configs/app/features/signetActivity.ts b/configs/app/features/signetActivity.ts new file mode 100644 index 0000000000..d5ca98b505 --- /dev/null +++ b/configs/app/features/signetActivity.ts @@ -0,0 +1,24 @@ +import type { Feature } from './types'; + +import { getEnvValue } from '../utils'; + +const title = 'Signet activity'; + +const config: Feature<{ + readonly apiEndpoint: string | undefined; +}> = (() => { + if (getEnvValue('NEXT_PUBLIC_SIGNET_ACTIVITY_ENABLED') === 'true') { + return Object.freeze({ + title, + isEnabled: true, + apiEndpoint: getEnvValue('NEXT_PUBLIC_SIGNET_ACTIVITY_API_ENDPOINT'), + }); + } + + return Object.freeze({ + title, + isEnabled: false, + }); +})(); + +export default config; diff --git a/types/api/signetActivity.ts b/types/api/signetActivity.ts new file mode 100644 index 0000000000..2146da826f --- /dev/null +++ b/types/api/signetActivity.ts @@ -0,0 +1,41 @@ +export const ORDER_STATUSES = { + filled: 'filled', + pending: 'pending', + expired: 'expired', +} as const; + +export type OrderStatus = typeof ORDER_STATUSES[keyof typeof ORDER_STATUSES]; + +export interface SignetTokenAmount { + readonly token_symbol: string; + readonly token_address: string; + readonly amount: string; + readonly decimals: number; +} + +export interface SignetOutput extends SignetTokenAmount { + readonly chain_id: string | null; + readonly recipient: string; +} + +export interface SignetOrder { + readonly order_hash: string; + readonly status: OrderStatus; + readonly inputs: ReadonlyArray; + readonly outputs: ReadonlyArray; + readonly deadline: string; + readonly sender: string; + readonly fill_tx_hash: string | null; +} + +export interface SignetFill { + readonly fill_tx_hash: string; + readonly filler: string; + readonly outputs: ReadonlyArray; + readonly order_hash: string; +} + +export interface BlockSignetActivity { + readonly orders: ReadonlyArray; + readonly fills: ReadonlyArray; +} diff --git a/ui/block/signetActivity/FillCard.tsx b/ui/block/signetActivity/FillCard.tsx new file mode 100644 index 0000000000..ee920c070a --- /dev/null +++ b/ui/block/signetActivity/FillCard.tsx @@ -0,0 +1,82 @@ +import { Box, Flex, Grid } from '@chakra-ui/react'; +import React from 'react'; + +import type { SignetFill } from 'types/api/signetActivity'; + +import { route } from 'nextjs/routes'; + +import { Link } from 'toolkit/chakra/link'; +import { Skeleton } from 'toolkit/chakra/skeleton'; +import { Tooltip } from 'toolkit/chakra/tooltip'; +import AddressEntity from 'ui/shared/entities/address/AddressEntity'; +import HashStringShortenDynamic from 'ui/shared/HashStringShortenDynamic'; +import IconSvg from 'ui/shared/IconSvg'; + +import formatTokenAmount from './formatTokenAmount'; + +interface Props { + readonly fill: SignetFill; + readonly isLoading: boolean; +} + +const FillCard = ({ fill, isLoading }: Props) => { + return ( + + + + Fill + + + + + + + + + + + + + Filler + + + + + Outputs + + + { fill.outputs.map((output, idx) => ( + + + { formatTokenAmount(output) } + + { output.chain_id && ( + + + + ) } + + )) } + + + + Order + + + + + + + ); +}; + +export default React.memo(FillCard); diff --git a/ui/block/signetActivity/OrderCard.tsx b/ui/block/signetActivity/OrderCard.tsx new file mode 100644 index 0000000000..d5649acb3d --- /dev/null +++ b/ui/block/signetActivity/OrderCard.tsx @@ -0,0 +1,118 @@ +import { Box, Flex, Grid, Text } from '@chakra-ui/react'; +import React from 'react'; + +import type { SignetOrder } from 'types/api/signetActivity'; + +import { route } from 'nextjs/routes'; + +import dayjs from 'lib/date/dayjs'; +import { Link } from 'toolkit/chakra/link'; +import { Skeleton } from 'toolkit/chakra/skeleton'; +import { Tooltip } from 'toolkit/chakra/tooltip'; +import CopyToClipboard from 'ui/shared/CopyToClipboard'; +import AddressEntity from 'ui/shared/entities/address/AddressEntity'; +import HashStringShortenDynamic from 'ui/shared/HashStringShortenDynamic'; +import IconSvg from 'ui/shared/IconSvg'; + +import formatTokenAmount from './formatTokenAmount'; +import OrderStatusBadge from './OrderStatusBadge'; + +interface Props { + readonly order: SignetOrder; + readonly isLoading: boolean; +} + +const OrderCard = ({ order, isLoading }: Props) => { + const deadlineDate = dayjs(order.deadline); + const isExpired = deadlineDate.isBefore(dayjs()); + + return ( + + + + + Order + + + + + + + + + + + + + + Sender + + + + + Inputs + + + { order.inputs.map((input, idx) => ( + + { formatTokenAmount(input) } + + )) } + + + + Outputs + + + { order.outputs.map((output, idx) => ( + + + { formatTokenAmount(output) } + + { output.chain_id && ( + + + + ) } + + )) } + + + + Deadline + + + + + { deadlineDate.fromNow() } + + + + + { order.fill_tx_hash && ( + <> + + Fill tx + + + + + + + + ) } + + + ); +}; + +export default React.memo(OrderCard); diff --git a/ui/block/signetActivity/OrderStatusBadge.tsx b/ui/block/signetActivity/OrderStatusBadge.tsx new file mode 100644 index 0000000000..877996d480 --- /dev/null +++ b/ui/block/signetActivity/OrderStatusBadge.tsx @@ -0,0 +1,30 @@ +import React from 'react'; + +import type { OrderStatus } from 'types/api/signetActivity'; + +import type { BadgeProps } from 'toolkit/chakra/badge'; +import { Badge } from 'toolkit/chakra/badge'; +import IconSvg from 'ui/shared/IconSvg'; + +interface Props extends BadgeProps { + readonly status: OrderStatus; +} + +const STATUS_CONFIG = { + filled: { colorPalette: 'green', icon: 'status/success', label: 'Filled' }, + pending: { colorPalette: 'yellow', icon: 'status/pending', label: 'Pending' }, + expired: { colorPalette: 'red', icon: 'status/error', label: 'Expired' }, +} as const; + +const OrderStatusBadge = ({ status, ...rest }: Props) => { + const cfg = STATUS_CONFIG[status]; + const iconElement = ; + + return ( + + { cfg.label } + + ); +}; + +export default React.memo(OrderStatusBadge); diff --git a/ui/block/signetActivity/SignetActivityTab.tsx b/ui/block/signetActivity/SignetActivityTab.tsx new file mode 100644 index 0000000000..7801f4bfd3 --- /dev/null +++ b/ui/block/signetActivity/SignetActivityTab.tsx @@ -0,0 +1,102 @@ +import { Box, Flex, Grid, Text } from '@chakra-ui/react'; +import React from 'react'; + +import { Badge } from 'toolkit/chakra/badge'; +import { Skeleton } from 'toolkit/chakra/skeleton'; +import DataListDisplay from 'ui/shared/DataListDisplay'; + +import FillCard from './FillCard'; +import OrderCard from './OrderCard'; +import type { BlockSignetActivityQuery } from './useBlockSignetActivityQuery'; + +interface Props { + readonly query: BlockSignetActivityQuery; +} + +const SignetActivityTab = ({ query }: Props) => { + const { data, isPlaceholderData, isError } = query; + + const orderCount = data?.orders.length ?? 0; + const fillCount = data?.fills.length ?? 0; + const totalCount = orderCount + fillCount; + + const content = data ? ( + + + + + Total activity + { totalCount } + + + + + Orders + { orderCount } + + + + + Fills + { fillCount } + + + + + { orderCount > 0 && ( + + Orders + + { data.orders.map((order) => ( + + )) } + + + ) } + + { fillCount > 0 && ( + + Fills + + { data.fills.map((fill) => ( + + )) } + + + ) } + + ) : null; + + return ( + + { content } + + ); +}; + +export default React.memo(SignetActivityTab); diff --git a/ui/block/signetActivity/formatTokenAmount.ts b/ui/block/signetActivity/formatTokenAmount.ts new file mode 100644 index 0000000000..50e8dc512f --- /dev/null +++ b/ui/block/signetActivity/formatTokenAmount.ts @@ -0,0 +1,8 @@ +import { formatUnits } from 'viem'; + +import type { SignetTokenAmount } from 'types/api/signetActivity'; + +export default function formatTokenAmount(token: SignetTokenAmount): string { + const formatted = formatUnits(BigInt(token.amount), token.decimals); + return `${ formatted } ${ token.token_symbol }`; +} diff --git a/ui/block/signetActivity/useBlockSignetActivityQuery.tsx b/ui/block/signetActivity/useBlockSignetActivityQuery.tsx new file mode 100644 index 0000000000..d4e702ff88 --- /dev/null +++ b/ui/block/signetActivity/useBlockSignetActivityQuery.tsx @@ -0,0 +1,73 @@ +import { useQuery } from '@tanstack/react-query'; + +import type { BlockSignetActivity } from 'types/api/signetActivity'; + +import config from 'configs/app'; + +interface Params { + readonly heightOrHash: string; + readonly tab: string; + readonly isBlockLoaded: boolean; +} + +export interface BlockSignetActivityQuery { + readonly data: BlockSignetActivity | undefined; + readonly isLoading: boolean; + readonly isPlaceholderData: boolean; + readonly isError: boolean; +} + +const PLACEHOLDER_DATA: BlockSignetActivity = { + orders: Array.from({ length: 3 }, (_, i) => ({ + order_hash: `0x${ String(i).padStart(64, '0') }`, + status: 'pending' as const, + inputs: [{ token_symbol: 'ETH', token_address: '0x0', amount: '1000000000000000000', decimals: 18 }], + outputs: [{ token_symbol: 'USDC', token_address: '0x0', amount: '1000000', decimals: 6, chain_id: null, recipient: '0x0' }], + deadline: new Date().toISOString(), + sender: '0x0000000000000000000000000000000000000000', + fill_tx_hash: null, + })), + fills: Array.from({ length: 2 }, (_, i) => ({ + fill_tx_hash: `0x${ String(i).padStart(64, 'f') }`, + filler: '0x0000000000000000000000000000000000000000', + outputs: [{ token_symbol: 'ETH', token_address: '0x0', amount: '1000000000000000000', decimals: 18, chain_id: null, recipient: '0x0' }], + order_hash: `0x${ String(i).padStart(64, '0') }`, + })), +}; + +async function fetchSignetActivity(heightOrHash: string, baseUrl: string): Promise { + const url = `${ baseUrl }/api/v2/blocks/${ heightOrHash }/signet-activity`; + const response = await fetch(url); + + if (!response.ok) { + throw new Error(`Failed to fetch signet activity: ${ response.statusText }`); + } + + return response.json() as Promise; +} + +export default function useBlockSignetActivityQuery({ heightOrHash, tab, isBlockLoaded }: Params): BlockSignetActivityQuery { + const feature = config.features.signetActivity; + + const query = useQuery({ + queryKey: [ 'signet_activity', { heightOrHash } ], + queryFn: () => { + if (!feature.isEnabled) { + throw new Error('Signet activity feature is not enabled'); + } + + const baseUrl = feature.apiEndpoint || ''; + return fetchSignetActivity(heightOrHash, baseUrl); + }, + enabled: Boolean(feature.isEnabled && tab === 'signet_activity' && isBlockLoaded), + placeholderData: PLACEHOLDER_DATA, + refetchOnMount: false, + }); + + return { + data: query.data, + isLoading: query.isLoading, + isPlaceholderData: query.isPlaceholderData, + isError: query.isError, + }; +} diff --git a/ui/pages/Block.tsx b/ui/pages/Block.tsx index bc90561649..509dedfcf9 100644 --- a/ui/pages/Block.tsx +++ b/ui/pages/Block.tsx @@ -22,6 +22,8 @@ import BlockDeposits from 'ui/block/BlockDeposits'; import BlockDetails from 'ui/block/BlockDetails'; import BlockInternalTxs from 'ui/block/BlockInternalTxs'; import BlockWithdrawals from 'ui/block/BlockWithdrawals'; +import SignetActivityTab from 'ui/block/signetActivity/SignetActivityTab'; +import useBlockSignetActivityQuery from 'ui/block/signetActivity/useBlockSignetActivityQuery'; import useBlockBlobTxsQuery from 'ui/block/useBlockBlobTxsQuery'; import useBlockDepositsQuery from 'ui/block/useBlockDepositsQuery'; import useBlockInternalTxsQuery from 'ui/block/useBlockInternalTxsQuery'; @@ -47,6 +49,7 @@ const TAB_LIST_PROPS = { const TABS_HEIGHT = 88; const beaconChainFeature = config.features.beaconChain; +const signetActivityFeature = config.features.signetActivity; const BlockPageContent = () => { const router = useRouter(); @@ -61,6 +64,7 @@ const BlockPageContent = () => { const blockDepositsQuery = useBlockDepositsQuery({ heightOrHash, blockQuery, tab }); const blockBlobTxsQuery = useBlockBlobTxsQuery({ heightOrHash, blockQuery, tab }); const blockInternalTxsQuery = useBlockInternalTxsQuery({ heightOrHash, blockQuery, tab }); + const blockSignetActivityQuery = useBlockSignetActivityQuery({ heightOrHash, tab, isBlockLoaded: !blockQuery.isPlaceholderData }); const hasPagination = !isMobile && ( (tab === 'txs' && blockTxsQuery.pagination.isVisible) || @@ -134,7 +138,15 @@ const BlockPageContent = () => { ), } : null, - ].filter(Boolean)), [ blockBlobTxsQuery, blockDepositsQuery, blockInternalTxsQuery, blockQuery, blockTxsQuery, blockWithdrawalsQuery, hasPagination ]); + signetActivityFeature.isEnabled ? + { + id: 'signet_activity', + title: 'Signet Activity', + component: ( + + ), + } : null, + ].filter(Boolean)), [ blockBlobTxsQuery, blockDepositsQuery, blockInternalTxsQuery, blockQuery, blockSignetActivityQuery, blockTxsQuery, blockWithdrawalsQuery, hasPagination ]); let pagination; if (tab === 'txs') {