Add cross-chain transaction tracking and visualization (#3227)

* envs and feature setup

* home page layout

* cross-chain txs page

* cross-chain transfers page

* cross-chain transfers on tx page

* cross-chain messages on tx page

* cross-chain tx page

* cross-chain tx details token transfers

* stats and counters for lists

* migrate to new API version

* add swagger

* mobile lists

* placeholder views on address page

* tests

* fix adaptive tabs initial state when there is only one tab in the list

* tweaks

* bug fixes

* fix tests

* update screenshots

* migrate to new api version

* add chain label tooltip, copy button to txs in lists and same address highlight

* list views on address page

* fix tests

* fix links to current address in the list views

* update tooltips

* update tables layout

* review fixes

* fix spelling

* update screenshots

* update screenshot
This commit is contained in:
tom goriunov
2026-02-19 15:14:32 +01:00
committed by GitHub
parent 157226fdac
commit e6e83ee4e7
152 changed files with 4472 additions and 755 deletions
+1
View File
@@ -24,6 +24,7 @@ on:
- immutable
- mega_eth
- neon_devnet
- numine
- optimism
- optimism_sepolia
- optimism_superchain
+1
View File
@@ -26,6 +26,7 @@ on:
- mega_eth
- mekong
- neon_devnet
- numine
- optimism
- optimism_sepolia
- optimism_superchain
+1
View File
@@ -355,6 +355,7 @@
"mega_eth",
"mekong",
"neon_devnet",
"numine",
"optimism",
"optimism_interop_0",
"optimism_sepolia",
+12
View File
@@ -83,6 +83,17 @@ const contractInfoApi = (() => {
});
})();
const interchainIndexerApi = (() => {
const apiHost = getEnvValue('NEXT_PUBLIC_INTERCHAIN_INDEXER_API_HOST');
if (!apiHost) {
return;
}
return Object.freeze({
endpoint: apiHost,
});
})();
const metadataApi = (() => {
const apiHost = getEnvValue('NEXT_PUBLIC_METADATA_SERVICE_API_HOST');
if (!apiHost) {
@@ -221,6 +232,7 @@ const apis: Apis = Object.freeze({
bens: bensApi,
clusters: clustersApi,
contractInfo: contractInfoApi,
interchainIndexer: interchainIndexerApi,
metadata: metadataApi,
multichainAggregator: multichainAggregatorApi,
multichainStats: multichainStatsApi,
+22
View File
@@ -0,0 +1,22 @@
import type { Feature } from './types';
import apis from '../apis';
import { getEnvValue } from '../utils';
const title = 'Cross-chain transactions';
const config: Feature<{}> = (() => {
if (getEnvValue('NEXT_PUBLIC_CROSS_CHAIN_TXS_ENABLED') === 'true' && apis.interchainIndexer) {
return Object.freeze({
title,
isEnabled: true,
});
}
return Object.freeze({
title,
isEnabled: false,
});
})();
export default config;
+1
View File
@@ -10,6 +10,7 @@ export { default as beaconChain } from './beaconChain';
export { default as bridgedTokens } from './bridgedTokens';
export { default as blockchainInteraction } from './blockchainInteraction';
export { default as celo } from './celo';
export { default as crossChainTxs } from './crossChainTxs';
export { default as csvExport } from './csvExport';
export { default as dataAvailability } from './dataAvailability';
export { default as deFiDropdown } from './deFiDropdown';
+40
View File
@@ -0,0 +1,40 @@
# Set of ENVs for Numine network explorer
# https://numine.blockscout.com
# This is an auto-generated file. To update all values, run "yarn dev:preset:sync --name=numine"
# Local ENVs
NEXT_PUBLIC_APP_PROTOCOL=http
NEXT_PUBLIC_APP_HOST=localhost
NEXT_PUBLIC_APP_PORT=3000
NEXT_PUBLIC_APP_ENV=development
NEXT_PUBLIC_API_WEBSOCKET_PROTOCOL=ws
NEXT_PUBLIC_CROSS_CHAIN_TXS_ENABLED=true
NEXT_PUBLIC_INTERCHAIN_INDEXER_API_HOST=https://interchain-indexer.k8s-dev.blockscout.com
# Instance ENVs
NEXT_PUBLIC_AD_BANNER_ENABLE_SPECIFY=true
NEXT_PUBLIC_ADDRESS_3RD_PARTY_WIDGETS=['talentprotocol','drops','blockscoutbadges','gitpoap','efp','etherscore','webacy','humanpassport','trustblock','smartmuv','humanode']
NEXT_PUBLIC_ADDRESS_3RD_PARTY_WIDGETS_CONFIG_URL=https://raw.githubusercontent.com/blockscout/frontend-configs/main/configs/widgets/config.json
NEXT_PUBLIC_ADMIN_SERVICE_API_HOST=https://admin-rs.services.blockscout.com
NEXT_PUBLIC_API_BASE_PATH=/
NEXT_PUBLIC_API_HOST=numine.blockscout.com
NEXT_PUBLIC_API_SPEC_URL=https://raw.githubusercontent.com/blockscout/blockscout-api-v2-swagger/main/swagger.yaml
NEXT_PUBLIC_CONTRACT_INFO_API_HOST=https://contracts-info.services.blockscout.com
NEXT_PUBLIC_GAME_BADGE_CLAIM_LINK=https://badges.blockscout.com/mint/sherblockHolmesBadge
NEXT_PUBLIC_HOMEPAGE_CHARTS=['daily_txs']
NEXT_PUBLIC_METADATA_SERVICE_API_HOST=https://metadata.services.blockscout.com
NEXT_PUBLIC_MIXPANEL_CONFIG_OVERRIDES={"record_sessions_percent": 0.5,"record_heatmap_data": true}
NEXT_PUBLIC_NETWORK_CURRENCY_DECIMALS=18
NEXT_PUBLIC_NETWORK_CURRENCY_NAME=NUMINE
NEXT_PUBLIC_NETWORK_CURRENCY_SYMBOL=NUMINE
NEXT_PUBLIC_NETWORK_ID=8021
NEXT_PUBLIC_NETWORK_NAME=Numine
NEXT_PUBLIC_NETWORK_RPC_URL=https://subnets.avax.network/numi/mainnet/rpc
NEXT_PUBLIC_OG_ENHANCED_DATA_ENABLED=true
NEXT_PUBLIC_PUZZLE_GAME_BADGE_CLAIM_LINK=https://badges.blockscout.com/mint/capyPuzzleBadge
NEXT_PUBLIC_STATS_API_BASE_PATH=/stats-service
NEXT_PUBLIC_STATS_API_HOST=https://numine.blockscout.com
NEXT_PUBLIC_TRANSACTION_INTERPRETATION_PROVIDER=blockscout
NEXT_PUBLIC_VIEWS_TOKEN_SCAM_TOGGLE_ENABLED=true
NEXT_PUBLIC_VISUALIZE_API_HOST=https://visualizer.services.blockscout.com
+2 -1
View File
@@ -53,8 +53,9 @@ NEXT_PUBLIC_TAC_OPERATION_LIFECYCLE_API_HOST=http://localhost:3100
NEXT_PUBLIC_USER_OPS_INDEXER_API_HOST=http://localhost:3110
NEXT_PUBLIC_ZETACHAIN_SERVICE_API_HOST=http://localhost:3111
NEXT_PUBLIC_MULTICHAIN_AGGREGATOR_API_HOST=http://localhost:3012
NEXT_PUBLIC_MULTICHAIN_CLUSTER=test
NEXT_PUBLIC_MULTICHAIN_STATS_API_HOST=http://localhost:3013
NEXT_PUBLIC_INTERCHAIN_INDEXER_API_HOST=http://localhost:3014
NEXT_PUBLIC_MULTICHAIN_CLUSTER=test
NEXT_PUBLIC_RE_CAPTCHA_APP_SITE_KEY=xxx
NEXT_PUBLIC_WALLET_CONNECT_PROJECT_ID=xxx
NEXT_PUBLIC_VIEWS_ADDRESS_FORMAT=['base16','bech32']
+3 -1
View File
@@ -27,7 +27,7 @@
// Posthog project key
"phc_\\w+",
// GitHub account names
"@(\\w|-)+",
"@(\\w|-)+"
],
// words - list of words to be always considered correct
"words": [
@@ -125,6 +125,7 @@
"healthz",
"helia",
"hyfi",
"ICTT",
"identicons",
"IERC",
"Ihnatsyeu",
@@ -168,6 +169,7 @@
"noopener",
"noreferrer",
"noves",
"numine",
"nums",
"okex",
"okhttp",
+1
View File
@@ -165,6 +165,7 @@ const schema = yup
.concat(featuresSchemas.apiDocsSchema)
.concat(featuresSchemas.beaconChainSchema)
.concat(featuresSchemas.bridgedTokensSchema)
.concat(featuresSchemas.crossChainTxsSchema)
.concat(featuresSchemas.defiDropdownSchema)
.concat(featuresSchemas.highlightsConfigSchema)
.concat(featuresSchemas.marketplaceSchema)
@@ -0,0 +1,19 @@
import * as yup from 'yup';
import { urlTest } from '../../utils';
export const crossChainTxsSchema = yup
.object()
.shape({
NEXT_PUBLIC_CROSS_CHAIN_TXS_ENABLED: yup.boolean(),
NEXT_PUBLIC_INTERCHAIN_INDEXER_API_HOST: yup
.string()
.when('NEXT_PUBLIC_CROSS_CHAIN_TXS_ENABLED', {
is: (value: boolean) => value,
then: (schema) => schema.test(urlTest),
otherwise: (schema) => schema.test(
'not-exist',
'NEXT_PUBLIC_INTERCHAIN_INDEXER_API_HOST can only be used with NEXT_PUBLIC_CROSS_CHAIN_TXS_ENABLED',
value => value === undefined,
),
}),
});
@@ -4,6 +4,7 @@ export * from './ads';
export * from './apiDocs';
export * from './beaconChain';
export * from './bridgedToken';
export * from './crossChainTxs';
export * from './defiDropdown';
export * from './highlights';
export * from './marketplace';
@@ -0,0 +1,2 @@
NEXT_PUBLIC_CROSS_CHAIN_TXS_ENABLED=true
NEXT_PUBLIC_INTERCHAIN_INDEXER_API_HOST=https://api.example.com
@@ -0,0 +1,18 @@
[
{
"id": "8021",
"name": "Numine",
"explorer_url": "https://example.com/blockchain/numine",
"route_templates": {
"tx": "/transaction/{hash}",
"address": "/wallet/{hash}",
"token": "/address/{hash}"
}
},
{
"id": "43114",
"name": "C-Chain",
"logo": "https://example.com/logo.svg",
"explorer_url": "https://example.com/blockchain/c"
}
]
+12
View File
@@ -1073,6 +1073,18 @@ This feature enables the application to act as an explorer of multiple blockchai
| NEXT_PUBLIC_MULTICHAIN_AGGREGATOR_API_HOST | `string` | Multichain aggregator API service host | Required | - | `https://multichain-aggregator.k8s-dev.blockscout.com` | v2.5.0+ |
| NEXT_PUBLIC_MULTICHAIN_STATS_API_HOST | `string` | Multichain statistics API service host | Required | - | `http://multichain-search-stats.k8s-dev.blockscout.com` | v2.5.0+ |
&nbsp;
### Cross-chain transactions
This feature enables cross-chain transaction tracking and visualization, allowing users to view transactions that span multiple blockchains. It provides detailed information about cross-chain operations and links to related transactions on different chains.
| Variable | Type| Description | Compulsoriness | Default value | Example value | Version |
| --- | --- | --- | --- | --- | --- | --- |
| NEXT_PUBLIC_CROSS_CHAIN_TXS_ENABLED | `boolean` | The flag that enables the feature | Required | - | `true` | upcoming |
| NEXT_PUBLIC_INTERCHAIN_INDEXER_API_HOST | `string` | Interchain indexer API service host used to fetch cross-chain transaction data and metadata | Required | - | `https://interchain-indexer.k8s-dev.blockscout.com` | upcoming |
&nbsp;
### Badge claim link
+9
View File
@@ -10,6 +10,12 @@ import { CONTRACT_INFO_API_RESOURCES } from './services/contractInfo';
import type { ContractInfoApiPaginationFilters, ContractInfoApiResourceName, ContractInfoApiResourcePayload } from './services/contractInfo';
import { GENERAL_API_RESOURCES } from './services/general';
import type { GeneralApiResourceName, GeneralApiResourcePayload, GeneralApiPaginationFilters, GeneralApiPaginationSorting } from './services/general';
import type {
InterchainIndexerApiPaginationFilters,
InterchainIndexerApiResourceName,
InterchainIndexerApiResourcePayload,
} from './services/interchainIndexer';
import { INTERCHAIN_INDEXER_API_RESOURCES } from './services/interchainIndexer';
import type { MetadataApiResourceName, MetadataApiResourcePayload } from './services/metadata';
import { METADATA_API_RESOURCES } from './services/metadata';
import type {
@@ -43,6 +49,7 @@ export const RESOURCES = {
clusters: CLUSTERS_API_RESOURCES,
contractInfo: CONTRACT_INFO_API_RESOURCES,
general: GENERAL_API_RESOURCES,
interchainIndexer: INTERCHAIN_INDEXER_API_RESOURCES,
metadata: METADATA_API_RESOURCES,
multichainAggregator: MULTICHAIN_AGGREGATOR_API_RESOURCES,
multichainStats: MULTICHAIN_STATS_API_RESOURCES,
@@ -79,6 +86,7 @@ R extends BensApiResourceName ? BensApiResourcePayload<R> :
R extends ClustersApiResourceName ? ClustersApiResourcePayload<R> :
R extends ContractInfoApiResourceName ? ContractInfoApiResourcePayload<R> :
R extends GeneralApiResourceName ? GeneralApiResourcePayload<R> :
R extends InterchainIndexerApiResourceName ? InterchainIndexerApiResourcePayload<R> :
R extends MetadataApiResourceName ? MetadataApiResourcePayload<R> :
R extends MultichainAggregatorApiResourceName ? MultichainAggregatorApiResourcePayload<R> :
R extends MultichainStatsApiResourceName ? MultichainStatsApiResourcePayload<R> :
@@ -118,6 +126,7 @@ R extends BensApiResourceName ? BensApiPaginationFilters<R> :
R extends ClustersApiResourceName ? ClustersApiPaginationFilters :
R extends GeneralApiResourceName ? GeneralApiPaginationFilters<R> :
R extends ContractInfoApiResourceName ? ContractInfoApiPaginationFilters<R> :
R extends InterchainIndexerApiResourceName ? InterchainIndexerApiPaginationFilters<R> :
R extends MultichainAggregatorApiResourceName ? MultichainAggregatorApiPaginationFilters<R> :
R extends TacOperationLifecycleApiResourceName ? TacOperationLifecycleApiPaginationFilters<R> :
R extends ZetaChainApiResourceName ? ZetaChainApiPaginationFilters<R> :
+71
View File
@@ -0,0 +1,71 @@
import type { ApiResource } from '../types';
import type * as interchainIndexer from '@blockscout/interchain-indexer-types';
import type { CrossChainMessageFilters, CrossChainTransferFilters } from 'types/api/interchainIndexer';
export const INTERCHAIN_INDEXER_API_RESOURCES = {
messages: {
path: '/api/v1/interchain/messages',
filterFields: [ 'q' as const ],
paginated: true,
},
message: {
path: '/api/v1/interchain/messages/:id',
pathParams: [ 'id' as const ],
},
tx_messages: {
path: '/api/v1/interchain/messages\\:byTx/:hash',
pathParams: [ 'hash' as const ],
filterFields: [ 'q' as const ],
paginated: true,
},
address_messages: {
path: '/api/v1/interchain/messages\\:byAddress/:hash',
pathParams: [ 'hash' as const ],
paginated: true,
},
transfers: {
path: '/api/v1/interchain/transfers',
filterFields: [ 'q' as const ],
paginated: true,
},
tx_transfers: {
path: '/api/v1/interchain/transfers\\:byTx/:hash',
pathParams: [ 'hash' as const ],
filterFields: [ 'q' as const ],
paginated: true,
},
address_transfers: {
path: '/api/v1/interchain/transfers\\:byAddress/:hash',
pathParams: [ 'hash' as const ],
paginated: true,
},
stats_daily: {
path: '/api/v1/stats/daily',
},
stats_common: {
path: '/api/v1/stats/common',
},
} satisfies Record<string, ApiResource>;
export type InterchainIndexerApiResourceName = `interchainIndexer:${ keyof typeof INTERCHAIN_INDEXER_API_RESOURCES }`;
/* eslint-disable @stylistic/indent */
export type InterchainIndexerApiResourcePayload<R extends InterchainIndexerApiResourceName> =
R extends 'interchainIndexer:messages' ? interchainIndexer.GetMessagesResponse :
R extends 'interchainIndexer:message' ? interchainIndexer.InterchainMessage :
R extends 'interchainIndexer:tx_messages' ? interchainIndexer.GetMessagesResponse :
R extends 'interchainIndexer:address_messages' ? interchainIndexer.GetMessagesResponse :
R extends 'interchainIndexer:transfers' ? interchainIndexer.GetTransfersResponse :
R extends 'interchainIndexer:tx_transfers' ? interchainIndexer.GetTransfersResponse :
R extends 'interchainIndexer:address_transfers' ? interchainIndexer.GetTransfersResponse :
R extends 'interchainIndexer:stats_daily' ? interchainIndexer.GetDailyStatisticsResponse :
R extends 'interchainIndexer:stats_common' ? interchainIndexer.GetCommonStatisticsResponse :
never;
/* eslint-enable @stylistic/indent */
/* eslint-disable @stylistic/indent */
export type InterchainIndexerApiPaginationFilters<R extends InterchainIndexerApiResourceName> =
R extends 'interchainIndexer:messages' ? CrossChainMessageFilters :
R extends 'interchainIndexer:transfers' ? CrossChainTransferFilters :
never;
/* eslint-enable @stylistic/indent */
+1 -1
View File
@@ -1,5 +1,5 @@
export type ApiName =
'general' | 'admin' | 'bens' | 'contractInfo' | 'clusters' | 'external' |
'general' | 'admin' | 'bens' | 'contractInfo' | 'clusters' | 'external' | 'interchainIndexer' |
'metadata' | 'multichainAggregator' | 'multichainStats' | 'rewards' | 'stats' | 'tac' |
'userOps' | 'visualize' | 'zetachain';
+1
View File
@@ -69,6 +69,7 @@ const OG_TYPE_DICT: Record<Route['pathname'], OGPageType> = {
'/operations': 'Root page',
'/operation/[id]': 'Regular page',
'/cc/tx/[hash]': 'Regular page',
'/cross-chain-tx/[id]': 'Regular page',
// multichain routes
'/chain/[chain_slug]/accounts/label/[slug]': 'Root page',
+1
View File
@@ -72,6 +72,7 @@ const TEMPLATE_MAP: Record<Route['pathname'], string> = {
'/operations': DEFAULT_TEMPLATE,
'/operation/[id]': DEFAULT_TEMPLATE,
'/cc/tx/[hash]': DEFAULT_TEMPLATE,
'/cross-chain-tx/[id]': DEFAULT_TEMPLATE,
// multichain routes
'/chain/[chain_slug]/accounts/label/[slug]': DEFAULT_TEMPLATE,
+1
View File
@@ -73,6 +73,7 @@ const TEMPLATE_MAP: Record<Route['pathname'], string> = {
'/operations': '%network_name% operations',
'/operation/[id]': '%network_name% operation %id%',
'/cc/tx/[hash]': '%network_name% cross-chain transaction %hash% details',
'/cross-chain-tx/[id]': '%network_name% cross-chain transaction %id% details',
// multichain routes
'/chain/[chain_slug]/accounts/label/[slug]': '%network_name% addresses search by label',
+1
View File
@@ -67,6 +67,7 @@ export const PAGE_TYPE_DICT: Record<Route['pathname'], string> = {
'/operations': 'Operations',
'/operation/[id]': 'Operation details',
'/cc/tx/[hash]': 'Cross-chain transaction details',
'/cross-chain-tx/[id]': 'Cross-chain transaction details',
// multichain routes
'/chain/[chain_slug]/accounts/label/[slug]': 'Chain addresses search by label',
+27
View File
@@ -0,0 +1,27 @@
import type { ExternalChain } from 'types/externalChains';
export const config: Array<ExternalChain> = [
{
id: '43114',
name: 'C-Chain',
logo: 'https://c-chain.com/logo.svg',
explorer_url: 'https://c-chain.com/explorer',
route_templates: {
tx: '/transaction/{hash}',
address: '/wallet/{hash}',
token: '/address/{hash}',
},
},
{
id: '8021',
name: 'Numine',
logo: 'https://numine.com/logo.svg',
explorer_url: 'https://numine.com',
},
{
id: '4444',
name: 'Duck chain',
logo: undefined,
explorer_url: 'https://duck.com',
},
];
+73
View File
@@ -0,0 +1,73 @@
import type { GetTransfersResponse, InterchainTransfer } from '@blockscout/interchain-indexer-types';
import { MessageStatus } from '@blockscout/interchain-indexer-types';
import { config } from './config';
export const transferA = {
bridge: {
name: 'Avalanche ICTT',
ui_url: 'https://app.avax.network/',
},
message_id: '0x057b42bbbfbb4900e155a554ae67632cb21e6f5a64d815fcad7f33abe552c059',
status: MessageStatus.MESSAGE_STATUS_COMPLETED,
source_chain: config[0],
destination_chain: config[1],
source_token: {
address_hash: '0x33a31e0f62c0ddf25090b61ef21a70d5f48725b7',
name: 'Wrapped AVAX',
symbol: 'WAVAX',
decimals: '18',
icon_url: 'https://app.avax.network/logo.svg',
},
source_amount: '509700000000000000',
source_transaction_hash: '0x866a70cb1c8c33d259c819473d7b419c0de67770755bf07dee14dd2d0c6dc8ab',
sender: {
hash: '0xd7e63822d0e386fb65f28f41e8c1aa8d844ba018',
ens_domain_name: 'kitty.kitty.kitty.kitty.cat.eth',
},
send_timestamp: '2022-01-13T12:06:24.000Z',
destination_token: {
address_hash: '0x012cb6651cb29c7d5dc96173756a773f7fb87cfb',
name: 'Wrapped AVAX',
symbol: 'WAVAX',
decimals: '18',
},
destination_amount: '509700000000000000',
destination_transaction_hash: '0xdbdf690cfde8af2ee855bb90bfa9977a2d8ba36c9ae1a2010c67fe3774832213',
recipient: {
hash: '0xd7e63822d0e386fb65f28f41e8c1aa8d844ba018',
},
receive_timestamp: '2022-01-13T12:06:30.000Z',
} satisfies InterchainTransfer;
export const transferB = {
...transferA,
source_chain: {
id: '420',
name: 'Unknown chain',
logo: undefined,
explorer_url: undefined,
},
source_transaction_hash: '0x866a70cb1c8c33d259c819473d7b419c0de67770755bf07dee14dd2d0c6dc800',
source_token: {
address_hash: '0x33a31e0f62c0ddf25090b61ef21a70d5f48725b7',
name: 'Circle USD',
symbol: 'USDC',
decimals: '6',
},
destination_transaction_hash: '0xdbdf690cfde8af2ee855bb90bfa9977a2d8ba36c9ae1a2010c67fe3774832214',
status: MessageStatus.MESSAGE_STATUS_FAILED,
bridge: {
name: 'Optimism Superchain',
},
} satisfies InterchainTransfer;
export const listResponse = {
items: [
transferA,
transferB,
],
next_page_params: {
page_token: 'token',
},
} satisfies GetTransfersResponse;
+75
View File
@@ -0,0 +1,75 @@
import type { GetMessagesResponse, InterchainMessage } from '@blockscout/interchain-indexer-types';
import { MessageStatus } from '@blockscout/interchain-indexer-types';
import { config } from './config';
import { transferA, transferB } from './transfers';
/* eslint-disable max-len */
export const base = {
bridge: {
name: 'Avalanche ICTT',
ui_url: 'https://app.avax.network/',
},
message_id: '0x057b42bbbfbb4900e155a554ae67632cb21e6f5a64d815fcad7f33abe552c059',
status: MessageStatus.MESSAGE_STATUS_COMPLETED,
source_chain: config[0],
send_timestamp: '2022-01-13T12:06:24.000Z',
sender: {
hash: '0x33a31e0f62c0ddf25090b61ef21a70d5f48725b7',
},
source_transaction_hash: '0x866a70cb1c8c33d259c819473d7b419c0de67770755bf07dee14dd2d0c6dc8ab',
destination_chain: config[1],
receive_timestamp: '2022-02-13T12:06:30.000Z',
recipient: {
hash: '0x012cb6651cb29c7d5dc96173756a773f7fb87cfb',
ens_domain_name: 'duck-duck.eth',
},
destination_transaction_hash: '0xdbdf690cfde8af2ee855bb90bfa9977a2d8ba36c9ae1a2010c67fe3774832213',
payload: '0x0000000000000000000000000000000000000000000000000000000000000020000000000000000000000000000000000000000000000000000000000000000100000000000000000000000000000000000000000000000000000000000000400000000000000000000000000000000000000000000000000000000000000040000000000000000000000000d7e63822d0e386fb65f28f41e8c1aa8d844ba0180000000000000000000000000000000000000000000000000712d17312044000',
extra: {},
transfers: [
transferA,
transferB,
],
} satisfies InterchainMessage;
export const pending = {
...base,
message_id: '0x057b42bbbfbb4900e155a554ae67632cb21e6f5a64d815fcad7f33abe552c05a',
status: MessageStatus.MESSAGE_STATUS_INITIATED,
source_chain: config[1],
destination_chain: config[2],
destination_transaction_hash: undefined,
transfers: [
transferA,
],
} satisfies InterchainMessage;
export const failed = {
...base,
message_id: '0x057b42bbbfbb4900e155a554ae67632cb21e6f5a64d815fcad7f33abe552c05b',
status: MessageStatus.MESSAGE_STATUS_FAILED,
source_chain: {
id: '420',
name: 'Unknown chain',
logo: undefined,
explorer_url: undefined,
},
transfers: [
transferB,
],
bridge: {
name: 'Goose bridge',
},
} satisfies InterchainMessage;
export const listResponse = {
items: [
base,
pending,
failed,
],
next_page_params: {
page_token: '1',
},
} satisfies GetMessagesResponse;
+8
View File
@@ -328,6 +328,14 @@ export const interopMessages: Guard = (chainConfig: typeof config) => async() =>
}
};
export const crossChainTxs: Guard = (chainConfig: typeof config) => async() => {
if (!chainConfig.features.crossChainTxs.isEnabled) {
return {
notFound: true,
};
}
};
export const opSuperchain: Guard = () => async() => {
if (!config.features.opSuperchain.isEnabled) {
return {
+1
View File
@@ -32,6 +32,7 @@ export const publicTagsSubmit = factory([ guards.publicTagsSubmit ]);
export const pools = factory([ guards.pools ]);
export const megaEth = factory([ guards.megaEth ]);
export const zetaChainCCTX = factory([ guards.zetaChainCCTX ]);
export const crossChainTxs = factory([ guards.notOpSuperchain, guards.crossChainTxs ]);
// ROLLUPS
export const rollup = factory([ guards.rollup ]);
+1
View File
@@ -52,6 +52,7 @@ declare module "nextjs-routes" {
| DynamicRoute<"/chain/[chain_slug]/visualize/sol2uml", { "chain_slug": string }>
| StaticRoute<"/chakra">
| StaticRoute<"/contract-verification">
| DynamicRoute<"/cross-chain-tx/[id]", { "id": string }>
| StaticRoute<"/csv-export">
| StaticRoute<"/deposits">
| StaticRoute<"/dispute-games">
+1 -1
View File
@@ -13,7 +13,7 @@ export interface RouteParams {
export const route = (route: Route, params?: RouteParams | null) => {
const generatedRoute = nextjsRoute(routeParams(route, params));
if (params && params.chain && params.external) {
if (params && params.chain && params.external && params.chain.explorer_url) {
return stripTrailingSlash(params.chain.explorer_url) + generatedRoute;
}
+1
View File
@@ -44,6 +44,7 @@
},
"dependencies": {
"@blockscout/bens-types": "1.4.1",
"@blockscout/interchain-indexer-types": "0.0.10",
"@blockscout/multichain-aggregator-types": "1.6.3-alpha.3",
"@blockscout/points-types": "1.4.0-alpha.1",
"@blockscout/stats-types": "2.11.1",
+20
View File
@@ -0,0 +1,20 @@
import type { NextPage } from 'next';
import dynamic from 'next/dynamic';
import React from 'react';
import type { Props } from 'nextjs/getServerSideProps/handlers';
import PageNextJs from 'nextjs/PageNextJs';
const TxCrossChain = dynamic(() => import('ui/crossChain/tx/TxCrossChain'), { ssr: false });
const Page: NextPage<Props> = (props: Props) => {
return (
<PageNextJs pathname="/cross-chain-tx/[id]" query={ props.query }>
<TxCrossChain/>
</PageNextJs>
);
};
export default Page;
export { crossChainTxs as getServerSideProps } from 'nextjs/getServerSideProps/main';
+4
View File
@@ -15,6 +15,10 @@ const Transactions = dynamic(() => {
return import('ui/pages/TransactionsZetaChain');
}
if (config.features.crossChainTxs.isEnabled) {
return import('ui/crossChain/txs/Transactions');
}
return import('ui/pages/Transactions');
}, { ssr: false });
+4
View File
@@ -136,4 +136,8 @@ export const ENVS_MAP: Record<string, Array<[string, string]>> = {
colorThemeOverrides: [
[ 'NEXT_PUBLIC_COLOR_THEME_OVERRIDES', '{"bg":{"primary":{"_light":{"value":"rgba(254,253,253)"},"_dark":{"value":"rgba(25,25,26)"}}},"text":{"primary":{"_light":{"value":"rgba(16,17,18,0.80)"},"_dark":{"value":"rgba(222,217,217)"}},"secondary":{"_light":{"value":"rgba(138,136,136)"},"_dark":{"value":"rgba(133,133,133)"}}},"hover":{"_light":{"value":"rgba(104,200,158)"},"_dark":{"value":"rgba(104,200,158)"}},"selected":{"control":{"text":{"_light":{"value":"rgba(25,25,26)"},"_dark":{"value":"rgba(247,250,252)"}},"bg":{"_light":{"value":"rgba(242,239,239)"},"_dark":{"value":"rgba(255,255,255,0.06)"}}},"option":{"bg":{"_light":{"value":"rgba(84,75,75)"},"_dark":{"value":"rgba(87,87,87)"}}}},"icon":{"primary":{"_light":{"value":"rgba(138,136,136)"},"_dark":{"value":"rgba(133,133,133)"}},"secondary":{"_light":{"value":"rgba(176,176,176)"},"_dark":{"value":"rgba(105,103,103)"}}},"button":{"primary":{"_light":{"value":"rgba(105,103,103)"},"_dark":{"value":"rgba(133,133,133)"}}},"link":{"primary":{"_light":{"value":"rgba(57,146,108)"},"_dark":{"value":"rgba(57,146,108)"}}},"graph":{"line":{"_light":{"value":"rgba(105,103,103)"},"_dark":{"value":"rgba(57,146,108)"}},"gradient":{"start":{"_light":{"value":"rgba(105,103,103,0.3)"},"_dark":{"value":"rgba(57,146,108,0.3)"}},"stop":{"_light":{"value":"rgba(105,103,103,0)"},"_dark":{"value":"rgba(57,146,108,0)"}}}},"stats":{"bg":{"_light":{"value":"rgba(242,239,239)"},"_dark":{"value":"rgba(255,255,255,0.06)"}}},"topbar":{"bg":{"_light":{"value":"rgba(242,239,239)"},"_dark":{"value":"rgba(255,255,255,0.06)"}}},"navigation":{"text":{"selected":{"_light":{"value":"rgba(25,25,26)"},"_dark":{"value":"rgba(247,250,252)"}}},"bg":{"selected":{"_light":{"value":"rgba(242,239,239)"},"_dark":{"value":"rgba(255,255,255,0.06)"}}}},"tabs":{"text":{"primary":{"_light":{"value":"rgba(25,25,26)"},"_dark":{"value":"rgba(222,217,217)"}}}}}' ],
],
crossChainTxs: [
[ 'NEXT_PUBLIC_INTERCHAIN_INDEXER_API_HOST', 'http://localhost:3014' ],
[ 'NEXT_PUBLIC_CROSS_CHAIN_TXS_ENABLED', 'true' ],
],
};
Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 34 KiB

+91
View File
@@ -0,0 +1,91 @@
import type {
ChainInfo,
GetCommonStatisticsResponse,
GetDailyStatisticsResponse,
InterchainMessage,
InterchainTransfer,
} from '@blockscout/interchain-indexer-types';
import { MessageStatus } from '@blockscout/interchain-indexer-types';
import { ADDRESS_HASH } from './addressParams';
import { TX_HASH } from './tx';
const MESSAGE_ID = '0xde5c33c6b3424cec51ea6f5d081f64719d531eec74f2a2408141274c117c5f44';
const CHAIN = {
id: '8021',
name: 'Avalanche',
logo: undefined,
explorer_url: 'https://avalanche.blockscout.com/',
} satisfies ChainInfo;
export const INTERCHAIN_TRANSFER = {
bridge: {
name: 'Avalanche ICTT',
},
message_id: MESSAGE_ID,
status: MessageStatus.MESSAGE_STATUS_COMPLETED,
source_token: {
address_hash: ADDRESS_HASH,
name: 'Wrapped AVAX',
symbol: 'WAVAX',
decimals: '18',
},
source_amount: '80800000000000000',
source_transaction_hash: TX_HASH,
source_chain: CHAIN,
sender: {
hash: ADDRESS_HASH,
},
send_timestamp: '2026-01-07T18:41:50.000Z',
destination_token: {
address_hash: ADDRESS_HASH,
name: 'Wrapped AVAX',
symbol: 'WAVAX',
decimals: '18',
},
destination_amount: '80800000000000000',
destination_transaction_hash: TX_HASH,
destination_chain: CHAIN,
recipient: {
hash: ADDRESS_HASH,
},
receive_timestamp: '2026-01-07T18:41:53.000Z',
} satisfies InterchainTransfer;
export const INTERCHAIN_MESSAGE = {
bridge: {
name: 'Avalanche ICTT',
},
message_id: MESSAGE_ID,
status: MessageStatus.MESSAGE_STATUS_COMPLETED,
source_chain: CHAIN,
send_timestamp: '2026-01-07T18:41:50.000Z',
sender: {
hash: ADDRESS_HASH,
},
source_transaction_hash: TX_HASH,
destination_chain: CHAIN,
receive_timestamp: '2026-01-07T18:41:53.000Z',
recipient: {
hash: ADDRESS_HASH,
},
destination_transaction_hash: TX_HASH,
payload: '0x00',
extra: {},
transfers: [
INTERCHAIN_TRANSFER,
],
} satisfies InterchainMessage;
export const INTERCHAIN_STATS_DAILY = {
date: '2026-01-12',
daily_messages: 75,
daily_transfers: 75,
} satisfies GetDailyStatisticsResponse;
export const INTERCHAIN_STATS_COMMON = {
timestamp: '2026-01-12T11:49:59.380Z',
total_messages: 10823,
total_transfers: 10822,
} satisfies GetCommonStatisticsResponse;
+1 -1
View File
@@ -16,7 +16,7 @@ export const Badge = React.forwardRef<HTMLDivElement, BadgeProps>(
function Badge(props, ref) {
const { loading, startElement, children, asChild = true, truncated = false, endElement, ...rest } = props;
const child = <chakra.span overflow="hidden" textOverflow="ellipsis">{ children }</chakra.span>;
const child = children ? <chakra.span overflow="hidden" textOverflow="ellipsis">{ children }</chakra.span> : null;
const childrenElement = truncated ? (
<TruncatedTextTooltip label={ children }>
@@ -79,7 +79,7 @@ const AdaptiveTabsList = (props: Props) => {
return [ ...tabs, menuButton ];
}, [ tabs ]);
const { tabsCut, tabsRefs, listRef, rightSlotRef, leftSlotRef } = useAdaptiveTabs(tabsList, isLoading || isMobile);
const { tabsCut, tabsRefs, listRef, rightSlotRef, leftSlotRef } = useAdaptiveTabs(tabsList, isLoading || isMobile || tabs.length === 1);
const isSticky = useIsSticky(listRef, 5, stickyEnabled);
const activeTabIndex = tabsList.findIndex((tab) => getTabValue(tab) === activeTab) ?? 0;
useScrollToActiveTab({ activeTabIndex, listRef, tabsRefs, isMobile, isLoading });
@@ -130,6 +130,7 @@ const AdaptiveTabsList = (props: Props) => {
ref={ leftSlotRef }
{ ...leftSlotProps }
flexGrow={ leftSlotProps?.widthAllocation === 'available' && tabsCut !== undefined ? 1 : undefined }
opacity={ tabsCut !== undefined ? 1 : 0 }
>
{ leftSlot }
</Box>
@@ -193,6 +194,7 @@ const AdaptiveTabsList = (props: Props) => {
ml="auto"
{ ...rightSlotProps }
flexGrow={ rightSlotProps?.widthAllocation === 'available' && tabsCut !== undefined ? 1 : undefined }
opacity={ tabsCut !== undefined ? 1 : 0 }
>
{ rightSlot }
</Box>
+2 -2
View File
@@ -7,12 +7,12 @@ import AdaptiveTabs from '../AdaptiveTabs/AdaptiveTabs';
import { getTabValue } from '../AdaptiveTabs/utils';
import useActiveTabFromQuery from './useActiveTabFromQuery';
interface Props extends AdaptiveTabsProps {
export interface RoutedTabsProps extends AdaptiveTabsProps {
preservedParams?: Array<string>;
defaultTabId?: string;
}
const RoutedTabs = (props: Props) => {
const RoutedTabs = (props: RoutedTabsProps) => {
const { tabs, defaultTabId, onValueChange, preservedParams, ...rest } = props;
const router = useRouter();
+1
View File
@@ -18,6 +18,7 @@ const PRESETS = {
mega_eth: 'https://megaeth.blockscout.com',
mekong: 'https://mekong.blockscout.com',
neon_devnet: 'https://neon-devnet.blockscout.com',
numine: 'https://numine.blockscout.com',
optimism: 'https://optimism.blockscout.com',
optimism_interop_0: 'https://optimism-interop-alpha-0.blockscout.com',
optimism_sepolia: 'https://optimism-sepolia.blockscout.com',
+7
View File
@@ -0,0 +1,7 @@
export interface CrossChainMessageFilters {
q?: string;
}
export interface CrossChainTransferFilters {
q?: string;
}
+7 -2
View File
@@ -5,8 +5,13 @@ import type { ClusterChainConfig } from 'types/multichain';
export interface ExternalChain {
id: string;
name: string;
logo: string | undefined;
explorer_url: string;
logo?: string;
explorer_url?: string;
route_templates?: {
tx?: string;
address?: string;
token?: string;
};
}
export type ExternalChainExtended = ClusterChainConfig | EssentialDappsChainConfig;
+5 -5
View File
@@ -10,7 +10,7 @@ import AddressTokenTransfers from './AddressTokenTransfers';
const CURRENT_ADDRESS = '0xd789a607CEac2f0E14867de4EB15b15C9FFB5859';
const hooksConfig = {
router: {
query: { hash: CURRENT_ADDRESS },
query: { hash: CURRENT_ADDRESS, tab: 'token_transfers' },
},
};
@@ -91,7 +91,7 @@ test.describe('socket', () => {
test('without overload', async({ render, mockApiResponse, createSocket, page }) => {
const hooksConfigNoToken = {
router: {
query: { hash: CURRENT_ADDRESS },
query: { hash: CURRENT_ADDRESS, tab: 'token_transfers' },
},
};
await mockApiResponse('general:address_token_transfers', tokenTransfersWithPagination, {
@@ -124,7 +124,7 @@ test.describe('socket', () => {
test('with overload', async({ render, mockApiResponse, page, createSocket }) => {
const hooksConfigNoToken = {
router: {
query: { hash: CURRENT_ADDRESS },
query: { hash: CURRENT_ADDRESS, tab: 'token_transfers' },
},
};
await mockApiResponse('general:address_token_transfers', tokenTransfersWithPagination, {
@@ -160,7 +160,7 @@ test.describe('socket', () => {
test('without overload, with filters', async({ render, mockApiResponse, page, createSocket }) => {
const hooksConfigWithFilter = {
router: {
query: { hash: CURRENT_ADDRESS, type: 'ERC-1155' },
query: { hash: CURRENT_ADDRESS, type: 'ERC-1155', tab: 'token_transfers' },
},
};
await mockApiResponse('general:address_token_transfers', tokenTransfersWithPagination, {
@@ -194,7 +194,7 @@ test.describe('socket', () => {
test('with overload, with filters', async({ render, mockApiResponse, page, createSocket }) => {
const hooksConfigWithFilter = {
router: {
query: { hash: CURRENT_ADDRESS, type: 'ERC-1155' },
query: { hash: CURRENT_ADDRESS, type: 'ERC-1155', tab: 'token_transfers' },
},
};
await mockApiResponse('general:address_token_transfers', tokenTransfersWithPagination, {
+145 -94
View File
@@ -1,130 +1,181 @@
import { Box, Flex } from '@chakra-ui/react';
import { HStack } from '@chakra-ui/react';
import { useRouter } from 'next/router';
import React from 'react';
import config from 'configs/app';
import useIsMobile from 'lib/hooks/useIsMobile';
import useIsMounted from 'lib/hooks/useIsMounted';
import getQueryParamString from 'lib/router/getQueryParamString';
import { INTERCHAIN_TRANSFER } from 'stubs/interchainIndexer';
import { generateListStub } from 'stubs/utils';
import RoutedTabs from 'toolkit/components/RoutedTabs/RoutedTabs';
import TokenTransfersCrossChainContent from 'ui/crossChain/transfers/TokenTransfersCrossChainContent';
import ActionBar, { ACTION_BAR_HEIGHT_DESKTOP } from 'ui/shared/ActionBar';
import DataListDisplay from 'ui/shared/DataListDisplay';
import Pagination from 'ui/shared/pagination/Pagination';
import * as SocketNewItemsNotice from 'ui/shared/SocketNewItemsNotice';
import useQueryWithPages from 'ui/shared/pagination/useQueryWithPages';
import TokenTransferFilter from 'ui/shared/TokenTransfer/TokenTransferFilter';
import TokenTransferList from 'ui/shared/TokenTransfer/TokenTransferList';
import TokenTransferTable from 'ui/shared/TokenTransfer/TokenTransferTable';
import AddressAdvancedFilterLink from './AddressAdvancedFilterLink';
import AddressCsvExportLink from './AddressCsvExportLink';
import AddressTokenTransfersLocal from './AddressTokenTransfersLocal';
import useAddressTokenTransfersQuery from './useAddressTokenTransfersQuery';
import useAddressTokenTransfersSocket from './useAddressTokenTransfersSocket';
type Props = {
export const ADDRESS_TOKEN_TRANSFERS_TAB_IDS = [ 'token_transfers_local' as const, 'token_transfers_cross_chain' as const ];
const TAB_LIST_PROPS = {
marginBottom: 0,
pt: 6,
pb: 3,
marginTop: -6,
};
interface Props {
shouldRender?: boolean;
isQueryEnabled?: boolean;
// for tests only
overloadCount?: number;
};
}
const AddressTokenTransfers = ({ overloadCount, shouldRender = true, isQueryEnabled = true }: Props) => {
const AddressTokenTransfers = ({ shouldRender = true, overloadCount, isQueryEnabled = true }: Props) => {
const router = useRouter();
const isMounted = useIsMounted();
const isMobile = useIsMobile();
const hash = getQueryParamString(router.query.hash);
const tab = getQueryParamString(router.query.tab) as typeof ADDRESS_TOKEN_TRANSFERS_TAB_IDS[number] | 'token_transfers' | undefined;
const currentAddress = getQueryParamString(router.query.hash);
const isLocalTab = tab === 'token_transfers_local' || tab === 'token_transfers';
const { query, filters, onTypeFilterChange, onAddressFilterChange } = useAddressTokenTransfersQuery({ currentAddress, enabled: isQueryEnabled });
const { data, isPlaceholderData, isError, pagination } = query;
const { showSocketAlert, newItemsCount } = useAddressTokenTransfersSocket({
filters,
addressHash: currentAddress,
data,
overloadCount,
enabled: isQueryEnabled && pagination.page === 1,
const localQuery = useAddressTokenTransfersQuery({
currentAddress: hash,
enabled: isQueryEnabled && isLocalTab,
});
const crossChainQuery = useQueryWithPages({
resourceName: 'interchainIndexer:address_transfers',
pathParams: { hash },
options: {
placeholderData: generateListStub<'interchainIndexer:address_transfers'>(INTERCHAIN_TRANSFER, 50, { next_page_params: undefined }),
enabled: isQueryEnabled && !isLocalTab,
},
});
const handleTabValueChange = React.useCallback(({ value }: { value: string }) => {
if (value === 'token_transfers_local') {
localQuery.setFilters({ type: [], filter: undefined });
}
}, [ localQuery ]);
if (!isMounted || !shouldRender) {
return null;
}
const numActiveFilters = (filters.type?.length || 0) + (filters.filter ? 1 : 0);
const isActionBarHidden = !numActiveFilters && !data?.items.length && !currentAddress;
const content = data?.items ? (
<>
<Box hideBelow="lg">
<TokenTransferTable
data={ data?.items }
baseAddress={ currentAddress }
showTxInfo
top={ isActionBarHidden ? 0 : ACTION_BAR_HEIGHT_DESKTOP }
enableTimeIncrement
showSocketInfo={ pagination.page === 1 }
showSocketErrorAlert={ showSocketAlert }
socketInfoNum={ newItemsCount }
isLoading={ isPlaceholderData }
const tabs = [
{
id: [ 'token_transfers_local', 'token_transfers' ],
title: 'Transfers',
component: (
<AddressTokenTransfersLocal
query={ localQuery.query }
filters={ localQuery.filters }
onTypeFilterChange={ localQuery.onTypeFilterChange }
onAddressFilterChange={ localQuery.onAddressFilterChange }
addressHash={ hash }
overloadCount={ overloadCount }
/>
</Box>
<Box hideFrom="lg">
{ pagination.page === 1 && (
<SocketNewItemsNotice.Mobile
num={ newItemsCount }
showErrorAlert={ showSocketAlert }
type="token_transfer"
isLoading={ isPlaceholderData }
),
},
config.features.crossChainTxs.isEnabled && {
id: 'token_transfers_cross_chain',
title: 'Cross-chain transfers',
component: (
<>
{ isMobile && crossChainQuery.pagination.isVisible && (
<ActionBar>
<Pagination ml="auto" { ...crossChainQuery.pagination }/>
</ActionBar>
) }
<TokenTransfersCrossChainContent
items={ crossChainQuery.data?.items }
isLoading={ crossChainQuery.isPlaceholderData }
isError={ crossChainQuery.isError }
pagination={ crossChainQuery.pagination }
tableTop={ ACTION_BAR_HEIGHT_DESKTOP }
currentAddress={ hash }
/>
) }
<TokenTransferList
data={ data?.items }
baseAddress={ currentAddress }
showTxInfo
enableTimeIncrement
isLoading={ isPlaceholderData }
/>
</Box>
</>
) : null;
</>
),
},
].filter(Boolean);
const actionBar = !isActionBarHidden ? (
<ActionBar mt={ -6 }>
<TokenTransferFilter
defaultTypeFilters={ filters.type }
onTypeFilterChange={ onTypeFilterChange }
appliedFiltersNum={ numActiveFilters }
withAddressFilter
onAddressFilterChange={ onAddressFilterChange }
defaultAddressFilter={ filters.filter }
isLoading={ isPlaceholderData }
/>
<Flex columnGap={{ base: 2, lg: 6 }} ml={{ base: 2, lg: 'auto' }} _empty={{ display: 'none' }}>
<AddressAdvancedFilterLink
isLoading={ isPlaceholderData }
address={ currentAddress }
typeFilter={ filters.type }
directionFilter={ filters.filter }
/>
<AddressCsvExportLink
address={ currentAddress }
params={{ type: 'token-transfers', filterType: 'address', filterValue: filters.filter }}
isLoading={ isPlaceholderData }
/>
</Flex>
<Pagination ml={{ base: 'auto', lg: 8 }} { ...pagination }/>
</ActionBar>
) : null;
const rightSlot = (() => {
if (isLocalTab) {
if (isMobile) {
return null;
}
const numActiveFilters = (localQuery.filters.type?.length || 0) + (localQuery.filters.filter ? 1 : 0);
return (
<>
<HStack gap={ 2 }>
<TokenTransferFilter
defaultTypeFilters={ localQuery.filters.type }
onTypeFilterChange={ localQuery.onTypeFilterChange }
appliedFiltersNum={ numActiveFilters }
withAddressFilter
onAddressFilterChange={ localQuery.onAddressFilterChange }
defaultAddressFilter={ localQuery.filters.filter }
isLoading={ localQuery.query.isPlaceholderData }
/>
</HStack>
<HStack gap={{ base: 2, lg: 6 }} ml={{ base: 2, lg: 'auto' }} _empty={{ display: 'none' }}>
<AddressAdvancedFilterLink
isLoading={ localQuery.query.isPlaceholderData }
address={ hash }
typeFilter={ localQuery.filters.type }
directionFilter={ localQuery.filters.filter }
/>
<AddressCsvExportLink
address={ hash }
params={{ type: 'token-transfers', filterType: 'address', filterValue: localQuery.filters.filter }}
isLoading={ localQuery.query.isPlaceholderData }
/>
</HStack>
<Pagination ml={{ base: 'auto', lg: 8 }} { ...localQuery.query.pagination }/>
</>
);
}
if (config.features.crossChainTxs.isEnabled) {
if (isMobile) {
return null;
}
return <Pagination ml="auto" { ...crossChainQuery.pagination }/>;
}
return null;
})();
const rightSlotProps = (() => {
return {
display: 'flex',
justifyContent: { base: 'flex-end', lg: 'space-between' },
ml: tabs.length > 1 ? { base: 0, lg: 4 } : 0,
widthAllocation: 'available' as const,
};
})();
return (
<DataListDisplay
isError={ isError }
itemsNum={ data?.items?.length }
emptyText="There are no token transfers."
hasActiveFilters={ Boolean(numActiveFilters) }
emptyStateProps={{
term: 'token transfer',
}}
actionBar={ actionBar }
>
{ content }
</DataListDisplay>
<RoutedTabs
variant="secondary"
size="sm"
tabs={ tabs }
onValueChange={ handleTabValueChange }
rightSlot={ rightSlot }
rightSlotProps={ rightSlotProps }
listProps={ isMobile ? undefined : TAB_LIST_PROPS }
stickyEnabled={ !isMobile }
/>
);
};
export default AddressTokenTransfers;
export default React.memo(AddressTokenTransfers);
@@ -4,10 +4,6 @@ import React from 'react';
import type { TokenType } from 'types/api/token';
import useIsMobile from 'lib/hooks/useIsMobile';
import AddressAdvancedFilterLink from 'ui/address/AddressAdvancedFilterLink';
import AddressCsvExportLink from 'ui/address/AddressCsvExportLink';
import type { Filters } from 'ui/address/useAddressTokenTransfersQuery';
import useAddressTokenTransfersSocket from 'ui/address/useAddressTokenTransfersSocket';
import ActionBar, { ACTION_BAR_HEIGHT_DESKTOP } from 'ui/shared/ActionBar';
import DataListDisplay from 'ui/shared/DataListDisplay';
import Pagination from 'ui/shared/pagination/Pagination';
@@ -17,15 +13,22 @@ import TokenTransferFilter from 'ui/shared/TokenTransfer/TokenTransferFilter';
import TokenTransferList from 'ui/shared/TokenTransfer/TokenTransferList';
import TokenTransferTable from 'ui/shared/TokenTransfer/TokenTransferTable';
import AddressAdvancedFilterLink from './AddressAdvancedFilterLink';
import AddressCsvExportLink from './AddressCsvExportLink';
import type { Filters } from './useAddressTokenTransfersQuery';
import useAddressTokenTransfersSocket from './useAddressTokenTransfersSocket';
interface Props {
query: QueryWithPagesResult<'general:address_token_transfers'>;
filters: Filters;
addressHash: string;
onTypeFilterChange: (type: Array<TokenType>) => void;
onAddressFilterChange: (filter: string) => void;
// for tests only
overloadCount?: number;
}
const TokenTransfersLocal = ({ query, filters, addressHash, onTypeFilterChange, onAddressFilterChange }: Props) => {
const TokenTransfersLocal = ({ query, filters, addressHash, onTypeFilterChange, onAddressFilterChange, overloadCount }: Props) => {
const { isError, isPlaceholderData, data, pagination } = query;
const isMobile = useIsMobile();
@@ -33,6 +36,7 @@ const TokenTransfersLocal = ({ query, filters, addressHash, onTypeFilterChange,
filters,
addressHash,
data,
overloadCount,
enabled: pagination.page === 1,
});
@@ -79,7 +83,7 @@ const TokenTransfersLocal = ({ query, filters, addressHash, onTypeFilterChange,
<TokenTransferFilter
defaultTypeFilters={ filters.type }
onTypeFilterChange={ onTypeFilterChange }
appliedFiltersNum={ filters.type.length }
appliedFiltersNum={ numActiveFilters }
withAddressFilter
onAddressFilterChange={ onAddressFilterChange }
defaultAddressFilter={ filters.filter }
+3 -3
View File
@@ -13,7 +13,7 @@ const CURRENT_ADDRESS = '0xd789a607CEac2f0E14867de4EB15b15C9FFB5859';
const hooksConfig = {
router: {
query: { hash: CURRENT_ADDRESS },
query: { hash: CURRENT_ADDRESS, tab: 'txs' },
},
};
const DEFAULT_PAGINATION = { block_number: 1, index: 1, items_count: 1 };
@@ -168,7 +168,7 @@ test.describe('socket', () => {
test('without overload, with filters', async({ render, mockApiResponse, page, createSocket }) => {
const hooksConfigWithFilter = {
router: {
query: { hash: CURRENT_ADDRESS, filter: 'from' },
query: { hash: CURRENT_ADDRESS, filter: 'from', tab: 'txs' },
},
};
@@ -204,7 +204,7 @@ test.describe('socket', () => {
test('with overload, with filters', async({ render, mockApiResponse, page, createSocket }) => {
const hooksConfigWithFilter = {
router: {
query: { hash: CURRENT_ADDRESS, filter: 'from' },
query: { hash: CURRENT_ADDRESS, filter: 'from', tab: 'txs' },
},
};
+136 -45
View File
@@ -1,78 +1,169 @@
import { HStack } from '@chakra-ui/react';
import { useRouter } from 'next/router';
import React from 'react';
import config from 'configs/app';
import useIsMobile from 'lib/hooks/useIsMobile';
import useIsMounted from 'lib/hooks/useIsMounted';
import getQueryParamString from 'lib/router/getQueryParamString';
import ActionBar, { ACTION_BAR_HEIGHT_DESKTOP } from 'ui/shared/ActionBar';
import { INTERCHAIN_MESSAGE } from 'stubs/interchainIndexer';
import { generateListStub } from 'stubs/utils';
import RoutedTabs from 'toolkit/components/RoutedTabs/RoutedTabs';
import AddressTxsCrossChain from 'ui/crossChain/address/AddressTxsCrossChain';
import { ACTION_BAR_HEIGHT_DESKTOP } from 'ui/shared/ActionBar';
import Pagination from 'ui/shared/pagination/Pagination';
import useQueryWithPages from 'ui/shared/pagination/useQueryWithPages';
import TxsWithAPISorting from 'ui/txs/TxsWithAPISorting';
import AddressCsvExportLink from './AddressCsvExportLink';
import AddressTxsFilter from './AddressTxsFilter';
import useAddressTxsQuery from './useAddressTxsQuery';
type Props = {
export const ADDRESS_TXS_TAB_IDS = [ 'txs_local' as const, 'txs_cross_chain' as const ];
const TAB_LIST_PROPS = {
marginBottom: 0,
pt: 6,
pb: 3,
marginTop: -6,
};
interface Props {
shouldRender?: boolean;
isQueryEnabled?: boolean;
};
}
const AddressTxs = ({ shouldRender = true, isQueryEnabled = true }: Props) => {
const router = useRouter();
const isMounted = useIsMounted();
const isMobile = useIsMobile();
const currentAddress = getQueryParamString(router.query.hash);
const hash = getQueryParamString(router.query.hash);
const tab = getQueryParamString(router.query.tab) as typeof ADDRESS_TXS_TAB_IDS[number] | 'txs' | undefined;
const { query, filterValue, initialFilterValue, onFilterChange, sort, setSort } = useAddressTxsQuery({
addressHash: currentAddress,
enabled: isQueryEnabled,
const isLocalTab = tab === 'txs_local' || tab === 'txs';
const localQuery = useAddressTxsQuery({
addressHash: hash,
enabled: isQueryEnabled && isLocalTab,
});
const crossChainQuery = useQueryWithPages({
resourceName: 'interchainIndexer:address_messages',
pathParams: { hash },
options: {
placeholderData: generateListStub<'interchainIndexer:address_messages'>(INTERCHAIN_MESSAGE, 50, { next_page_params: undefined }),
enabled: isQueryEnabled && !isLocalTab,
},
});
const handleTabValueChange = React.useCallback(({ value }: { value: string }) => {
if (value === 'txs_local') {
localQuery.setFilterValue(undefined);
}
}, [ localQuery ]);
const txsLocalFilter = isLocalTab ? (
<AddressTxsFilter
initialValue={ localQuery.initialFilterValue }
onFilterChange={ localQuery.onFilterChange }
hasActiveFilter={ Boolean(localQuery.filterValue) }
isLoading={ localQuery.query.pagination.isLoading }
/>
) : null;
if (!isMounted || !shouldRender) {
return null;
}
const filter = (
<AddressTxsFilter
initialValue={ initialFilterValue }
onFilterChange={ onFilterChange }
hasActiveFilter={ Boolean(filterValue) }
isLoading={ query.pagination.isLoading }
/>
);
const tabs = [
{
id: [ 'txs_local', 'txs' ],
title: 'Txns',
component: (
<TxsWithAPISorting
filter={ txsLocalFilter }
filterValue={ localQuery.filterValue }
query={ localQuery.query }
currentAddress={ hash }
enableTimeIncrement
socketType="address_txs"
top={ ACTION_BAR_HEIGHT_DESKTOP }
sorting={ localQuery.sort }
setSort={ localQuery.setSort }
showBlockInfo
showTableViewButton
/>
),
},
config.features.crossChainTxs.isEnabled && {
id: 'txs_cross_chain',
title: 'Cross-chain txns',
component: (
<AddressTxsCrossChain
pagination={ crossChainQuery.pagination }
items={ crossChainQuery.data?.items }
isLoading={ crossChainQuery.isPlaceholderData }
isError={ crossChainQuery.isError }
currentAddress={ hash }
/>
),
},
].filter(Boolean);
const csvExportLink = (
<AddressCsvExportLink
address={ currentAddress }
params={{ type: 'transactions', filterType: 'address', filterValue }}
ml="auto"
isLoading={ query.pagination.isLoading }
/>
);
const rightSlot = (() => {
if (isLocalTab) {
if (isMobile) {
return null;
}
return (
<>
<HStack gap={ 2 }>
{ txsLocalFilter }
</HStack>
<HStack gap={ 6 }>
<AddressCsvExportLink
address={ hash }
params={{ type: 'transactions', filterType: 'address', filterValue: localQuery.filterValue }}
isLoading={ localQuery.query.pagination.isLoading }
/>
<Pagination { ...localQuery.query.pagination }/>
</HStack>
</>
);
}
if (config.features.crossChainTxs.isEnabled) {
if (isMobile) {
return null;
}
return <Pagination { ...crossChainQuery.pagination } ml="auto"/>;
}
return null;
})();
const rightSlotProps = (() => {
return {
display: 'flex',
justifyContent: { base: 'flex-end', lg: 'space-between' },
ml: tabs.length > 1 ? { base: 0, lg: 4 } : 0,
widthAllocation: 'available' as const,
};
})();
return (
<>
{ !isMobile && (
<ActionBar>
{ filter }
{ currentAddress && csvExportLink }
<Pagination { ...query.pagination } ml={ 8 }/>
</ActionBar>
) }
<TxsWithAPISorting
filter={ filter }
filterValue={ filterValue }
query={ query }
currentAddress={ typeof currentAddress === 'string' ? currentAddress : undefined }
enableTimeIncrement
socketType="address_txs"
top={ ACTION_BAR_HEIGHT_DESKTOP }
sorting={ sort }
setSort={ setSort }
showTableViewButton
/>
</>
<RoutedTabs
variant="secondary"
size="sm"
tabs={ tabs }
onValueChange={ handleTabValueChange }
defaultTabId="txs_local"
rightSlot={ rightSlot }
rightSlotProps={ rightSlotProps }
listProps={ isMobile ? undefined : TAB_LIST_PROPS }
stickyEnabled={ !isMobile }
/>
);
};
export default AddressTxs;
export default React.memo(AddressTxs);
Binary file not shown.

Before

Width:  |  Height:  |  Size: 20 KiB

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 45 KiB

After

Width:  |  Height:  |  Size: 45 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 47 KiB

After

Width:  |  Height:  |  Size: 47 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 46 KiB

After

Width:  |  Height:  |  Size: 46 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 16 KiB

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 16 KiB

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 15 KiB

After

Width:  |  Height:  |  Size: 15 KiB

+2 -1
View File
@@ -66,7 +66,8 @@ export default function useAddressTokenTransfersQuery({ currentAddress, enabled,
return React.useMemo(() => ({
query,
filters,
setFilters,
onTypeFilterChange,
onAddressFilterChange,
}), [ query, filters, onTypeFilterChange, onAddressFilterChange ]);
}), [ query, filters, setFilters, onTypeFilterChange, onAddressFilterChange ]);
}
+2 -1
View File
@@ -56,9 +56,10 @@ export default function useAddressTxsQuery({ addressHash, enabled, isMultichain,
return React.useMemo(() => ({
query,
filterValue,
setFilterValue,
initialFilterValue,
onFilterChange,
sort,
setSort,
}), [ query, filterValue, initialFilterValue, onFilterChange, sort ]);
}), [ query, filterValue, initialFilterValue, setFilterValue, onFilterChange, sort, setSort ]);
}
+8
View File
@@ -91,4 +91,12 @@ export const REST_API_SECTIONS = [
requestInterceptor: microserviceRequestInterceptorFactory(config.apis.zetachain),
},
},
config.apis.interchainIndexer && {
id: 'interchain-indexer-api',
title: 'Interchain indexer API',
swagger: {
url: getMicroserviceSwaggerUrl(config.apis.interchainIndexer),
requestInterceptor: microserviceRequestInterceptorFactory(config.apis.interchainIndexer),
},
},
].filter(Boolean);
@@ -0,0 +1,43 @@
import React from 'react';
import useIsMobile from 'lib/hooks/useIsMobile';
import useTableViewValue from 'lib/hooks/useTableViewValue';
import ActionBar from 'ui/shared/ActionBar';
import Pagination from 'ui/shared/pagination/Pagination';
import TableViewToggleButton from 'ui/shared/TableViewToggleButton';
import type { Props } from '../txs/TransactionsCrossChainContent';
import TransactionsCrossChainContent from '../txs/TransactionsCrossChainContent';
const AddressCrossChainTxs = ({ pagination, isLoading: isLoadingProp, ...rest }: Props) => {
const isMobile = useIsMobile();
const tableViewFlag = useTableViewValue();
const isTableView = isMobile ? !tableViewFlag.isLoading && tableViewFlag.value : true;
const isLoading = isLoadingProp || tableViewFlag.isLoading;
return (
<>
{ isMobile && (
<ActionBar>
<TableViewToggleButton
value={ tableViewFlag.value }
onClick={ tableViewFlag.onToggle }
loading={ isLoading }
/>
{ pagination?.isVisible && <Pagination { ...pagination } ml="auto"/> }
</ActionBar>
) }
<TransactionsCrossChainContent
pagination={ pagination }
isLoading={ isLoading }
isTableView={ isTableView }
stickyHeader={ !isMobile && isTableView }
{ ...rest }
/>
</>
);
};
export default React.memo(AddressCrossChainTxs);
@@ -0,0 +1,48 @@
import { Box } from '@chakra-ui/react';
import * as crossChainConfigMock from 'mocks/crossChain/config';
import * as crossChainTransfersMock from 'mocks/crossChain/transfers';
import { ENVS_MAP } from 'playwright/fixtures/mockEnvs';
import { test, expect } from 'playwright/lib';
import * as pwConfig from 'playwright/utils/config';
import TokenTransfersCrossChain from './TokenTransfersCrossChain';
test.describe('txs', () => {
test.beforeEach(async({ mockEnvs, mockApiResponse, mockAssetResponse }) => {
await mockEnvs([
...ENVS_MAP.crossChainTxs,
[ 'NEXT_PUBLIC_NETWORK_ID', crossChainConfigMock.config[0].id ],
]);
await mockApiResponse('interchainIndexer:transfers', crossChainTransfersMock.listResponse);
await mockApiResponse('interchainIndexer:stats_common', { total_messages: 100, total_transfers: 101, timestamp: '2022-01-13T12:00:00.000Z' });
await mockApiResponse('interchainIndexer:stats_daily', { daily_messages: 42, daily_transfers: 55, date: '2022-01-13' });
await mockAssetResponse(crossChainConfigMock.config[0].logo as string, './playwright/mocks/duck.png');
await mockAssetResponse(crossChainConfigMock.config[1].logo as string, './playwright/mocks/goose.png');
await mockAssetResponse(crossChainTransfersMock.transferA.source_token.icon_url as string, './playwright/mocks/image_s.jpg');
});
test.describe('desktop', () => {
test.use({ viewport: pwConfig.viewport.xl });
test('base view +@dark-mode', async({ render }) => {
const component = await render(
<Box pt={ 10 }>
<TokenTransfersCrossChain/>
</Box>,
);
await expect(component).toHaveScreenshot();
});
});
test.describe('mobile', () => {
test.use({ viewport: pwConfig.viewport.mobile });
test.slow();
test('base view', async({ render }) => {
const component = await render(<TokenTransfersCrossChain/>);
await expect(component).toHaveScreenshot({ timeout: 30_000 });
});
});
});
@@ -0,0 +1,45 @@
import React from 'react';
import useApiQuery from 'lib/api/useApiQuery';
import { INTERCHAIN_STATS_COMMON, INTERCHAIN_TRANSFER } from 'stubs/interchainIndexer';
import { generateListStub } from 'stubs/utils';
import { Skeleton } from 'toolkit/chakra/skeleton';
import useQueryWithPages from 'ui/shared/pagination/useQueryWithPages';
import StickyPaginationWithText from 'ui/shared/StickyPaginationWithText';
import TokenTransfersCrossChainContent from './TokenTransfersCrossChainContent';
const TokenTransfersCrossChain = () => {
const { data, isPlaceholderData, isError, pagination } = useQueryWithPages({
resourceName: 'interchainIndexer:transfers',
options: {
placeholderData: generateListStub<'interchainIndexer:transfers'>(INTERCHAIN_TRANSFER, 50, { next_page_params: { page_token: 'token' } }),
},
});
const statsQuery = useApiQuery('interchainIndexer:stats_common', {
queryOptions: {
placeholderData: INTERCHAIN_STATS_COMMON,
},
});
const actionBarText = (
<Skeleton loading={ statsQuery.isPlaceholderData || isPlaceholderData }>
A total of { Number(statsQuery.data?.total_transfers).toLocaleString() } cross-chain token transfers found
</Skeleton>
);
const actionBar = <StickyPaginationWithText text={ actionBarText } pagination={ pagination }/>;
return (
<TokenTransfersCrossChainContent
items={ data?.items }
isLoading={ isPlaceholderData }
pagination={ pagination }
isError={ isError }
itemsNum={ data?.items.length }
actionBar={ actionBar }
/>
);
};
export default React.memo(TokenTransfersCrossChain);
@@ -0,0 +1,64 @@
import { Box } from '@chakra-ui/react';
import React from 'react';
import type { InterchainTransfer } from '@blockscout/interchain-indexer-types';
import type { PaginationParams } from 'ui/shared/pagination/types';
import { ACTION_BAR_HEIGHT_DESKTOP } from 'ui/shared/ActionBar';
import DataListDisplay, { type Props as DataListDisplayProps } from 'ui/shared/DataListDisplay';
import TokenTransfersCrossChainListItem from './TokenTransfersCrossChainListItem';
import TokenTransfersCrossChainTable from './TokenTransfersCrossChainTable';
import { getItemKey } from './utils';
interface Props extends Omit<DataListDisplayProps, 'children'> {
items?: Array<InterchainTransfer>;
isLoading?: boolean;
pagination?: PaginationParams;
currentAddress?: string;
tableTop?: number;
}
const TokenTransfersCrossChainContent = ({ items, isLoading, pagination, currentAddress, tableTop, ...rest }: Props) => {
const content = items ? (
<>
<Box hideFrom="lg">
{ items.map((item, index) => (
<TokenTransfersCrossChainListItem
key={ getItemKey(item, isLoading ? index : undefined) }
data={ item }
isLoading={ isLoading }
currentAddress={ currentAddress }
py={ 4 }
textStyle="sm"
rowGap="14px"
/>
)) }
</Box>
<Box hideBelow="lg">
<TokenTransfersCrossChainTable
data={ items }
isLoading={ isLoading }
top={ tableTop ?? (pagination?.isVisible ? ACTION_BAR_HEIGHT_DESKTOP : 0) }
currentAddress={ currentAddress }
/>
</Box>
</>
) : null;
return (
<DataListDisplay
itemsNum={ items?.length }
emptyText="There are no cross-chain token transfers."
emptyStateProps={{
term: 'token transfer',
}}
{ ...rest }
>
{ content }
</DataListDisplay>
);
};
export default React.memo(TokenTransfersCrossChainContent);
@@ -0,0 +1,135 @@
import { chakra, Grid, HStack, type JsxStyleProps } from '@chakra-ui/react';
import React from 'react';
import type { InterchainTransfer } from '@blockscout/interchain-indexer-types';
import config from 'configs/app';
import dayjs from 'lib/date/dayjs';
import { Skeleton } from 'toolkit/chakra/skeleton';
import { mdash } from 'toolkit/utils/htmlEntities';
import CrossChainBridgeLink from 'ui/shared/crossChain/CrossChainBridgeLink';
import CrossChainFromToTag from 'ui/shared/crossChain/CrossChainFromToTag';
import AddressEntityInterchain from 'ui/shared/entities/address/AddressEntityInterchain';
import CrossChainMessageEntity from 'ui/shared/entities/crossChainMessage/CrossChainMessageEntity';
import TxEntityInterchain from 'ui/shared/entities/tx/TxEntityInterchain';
import ListItemMobile from 'ui/shared/ListItemMobile/ListItemMobile';
import CrossChainTxsStatusTag from 'ui/shared/statusTag/CrossChainTxsStatusTag';
import TextSeparator from 'ui/shared/TextSeparator';
import Time from 'ui/shared/time/Time';
import TokenValueInterchain from 'ui/shared/value/TokenValueInterchain';
interface Props extends JsxStyleProps {
data: InterchainTransfer;
isLoading?: boolean;
currentAddress?: string;
}
const TokenTransfersCrossChainListItem = ({ data, isLoading, rowGap = 3, currentAddress, ...rest }: Props) => {
const timestamp = data.send_timestamp || data.receive_timestamp;
const dashElement = <chakra.span color="text.secondary">{ mdash }</chakra.span>;
return (
<ListItemMobile rowGap={ rowGap } { ...rest }>
<HStack>
<CrossChainTxsStatusTag status={ data.status } loading={ isLoading } mode="full"/>
{ currentAddress && (
<CrossChainFromToTag
type={ data.sender?.hash.toLowerCase() === currentAddress.toLowerCase() && config.chain.id === data.source_chain?.id ? 'out' : 'in' }
isLoading={ isLoading }
/>
) }
</HStack>
{ timestamp && (
<Skeleton loading={ isLoading } display="flex" alignItems="center" color="text.secondary">
<div>{ dayjs(timestamp).fromNow() }</div>
<TextSeparator/>
<Time timestamp={ timestamp } format="lll_s"/>
</Skeleton>
) }
<Grid templateColumns="100px 1fr" columnGap={ 2 } rowGap={ rowGap }>
<Skeleton loading={ isLoading }>
Source token
</Skeleton>
{ data.source_token ? (
<TokenValueInterchain
token={ data.source_token }
amount={ data.source_amount }
chain={ data.source_chain }
loading={ isLoading }
/>
) : dashElement }
<Skeleton loading={ isLoading }>
Sender
</Skeleton>
{ data.sender ? (
<AddressEntityInterchain
chain={ data.source_chain }
address={ data.sender }
isLoading={ isLoading }
noIcon
currentAddress={ currentAddress }
/>
) : dashElement }
<Skeleton loading={ isLoading }>
Target token
</Skeleton>
{ data.destination_token ? (
<TokenValueInterchain
token={ data.destination_token }
amount={ data.destination_amount }
chain={ data.destination_chain }
loading={ isLoading }
/>
) : dashElement }
<Skeleton loading={ isLoading }>
Recipient
</Skeleton>
{ data.recipient ? (
<AddressEntityInterchain
chain={ data.destination_chain }
address={ data.recipient }
isLoading={ isLoading }
noIcon
currentAddress={ currentAddress }
/>
) : dashElement }
<Skeleton loading={ isLoading }>
Source tx
</Skeleton>
{ data.source_transaction_hash ? (
<TxEntityInterchain
chain={ data.source_chain }
hash={ data.source_transaction_hash }
isLoading={ isLoading }
/>
) : dashElement }
<Skeleton loading={ isLoading }>
Destination tx
</Skeleton>
{ data.destination_transaction_hash ? (
<TxEntityInterchain
chain={ data.destination_chain }
hash={ data.destination_transaction_hash }
isLoading={ isLoading }
/>
) : dashElement }
<Skeleton loading={ isLoading }>
Protocol
</Skeleton>
{ data.bridge ? (
<CrossChainBridgeLink data={ data.bridge } isLoading={ isLoading }/>
) : dashElement }
<Skeleton loading={ isLoading }>
Message
</Skeleton>
{ data.message_id ? (
<CrossChainMessageEntity id={ data.message_id } isLoading={ isLoading }/>
) : dashElement }
</Grid>
</ListItemMobile>
);
};
export default React.memo(TokenTransfersCrossChainListItem);
@@ -0,0 +1,58 @@
import { Flex } from '@chakra-ui/react';
import React from 'react';
import type { InterchainTransfer } from '@blockscout/interchain-indexer-types';
import { AddressHighlightProvider } from 'lib/contexts/addressHighlight';
import { TableBody, TableColumnHeader, TableHeaderSticky, TableRoot, TableRow } from 'toolkit/chakra/table';
import TimeFormatToggle from 'ui/shared/time/TimeFormatToggle';
import TokenTransfersCrossChainTableItem from './TokenTransfersCrossChainTableItem';
import { getItemKey } from './utils';
interface Props {
data: Array<InterchainTransfer>;
isLoading?: boolean;
top?: number;
currentAddress?: string;
}
const TokenTransfersCrossChainTable = ({ data, isLoading, top, currentAddress }: Props) => {
return (
<AddressHighlightProvider>
<TableRoot tableLayout="auto">
<TableHeaderSticky top={ top }>
<TableRow>
<TableColumnHeader w="42px"/>
{ currentAddress && <TableColumnHeader w="44px"/> }
<TableColumnHeader>Source token</TableColumnHeader>
<TableColumnHeader/>
<TableColumnHeader>Target token</TableColumnHeader>
<TableColumnHeader>Source tx</TableColumnHeader>
<TableColumnHeader>Dest tx</TableColumnHeader>
<TableColumnHeader>Protocol</TableColumnHeader>
<TableColumnHeader>Message</TableColumnHeader>
<TableColumnHeader>
<Flex alignItems="center" flexWrap="nowrap">
Timestamp
<TimeFormatToggle/>
</Flex>
</TableColumnHeader>
</TableRow>
</TableHeaderSticky>
<TableBody>
{ data.map((item, index) => (
<TokenTransfersCrossChainTableItem
key={ getItemKey(item, isLoading ? index : undefined) }
data={ item }
isLoading={ isLoading }
currentAddress={ currentAddress }
/>
)) }
</TableBody>
</TableRoot>
</AddressHighlightProvider>
);
};
export default React.memo(TokenTransfersCrossChainTable);
@@ -0,0 +1,159 @@
import { chakra, VStack } from '@chakra-ui/react';
import React from 'react';
import type { InterchainTransfer } from '@blockscout/interchain-indexer-types';
import config from 'configs/app';
import { TableCell, TableRow } from 'toolkit/chakra/table';
import { mdash } from 'toolkit/utils/htmlEntities';
import AddressFromToIcon from 'ui/shared/address/AddressFromToIcon';
import CrossChainBridgeLink from 'ui/shared/crossChain/CrossChainBridgeLink';
import CrossChainFromToTag from 'ui/shared/crossChain/CrossChainFromToTag';
import AddressEntityInterchain from 'ui/shared/entities/address/AddressEntityInterchain';
import CrossChainMessageEntity from 'ui/shared/entities/crossChainMessage/CrossChainMessageEntity';
import TxEntityInterchain from 'ui/shared/entities/tx/TxEntityInterchain';
import ChainLabel from 'ui/shared/externalChains/ChainLabel';
import CrossChainTxsStatusTag from 'ui/shared/statusTag/CrossChainTxsStatusTag';
import TimeWithTooltip from 'ui/shared/time/TimeWithTooltip';
import TokenValueInterchain from 'ui/shared/value/TokenValueInterchain';
interface Props {
data: InterchainTransfer;
isLoading?: boolean;
currentAddress?: string;
}
const TokenTransfersCrossChainTableItem = ({ data, isLoading, currentAddress }: Props) => {
const dashElement = <chakra.span color="text.secondary" lineHeight="24px">{ mdash }</chakra.span>;
return (
<TableRow>
<TableCell w="42px">
<CrossChainTxsStatusTag status={ data.status } loading={ isLoading }/>
</TableCell>
{ currentAddress && (
<TableCell>
<CrossChainFromToTag
type={ data.sender?.hash.toLowerCase() === currentAddress.toLowerCase() && config.chain.id === data.source_chain?.id ? 'out' : 'in' }
isLoading={ isLoading }
/>
</TableCell>
) }
<TableCell maxW="150px">
<VStack alignItems="start">
{ data.source_token && (
<TokenValueInterchain
token={ data.source_token }
amount={ data.source_amount }
chain={ data.source_chain }
loading={ isLoading }
lineHeight="24px"
/>
) }
{
data.sender ? (
<AddressEntityInterchain
chain={ data.source_chain }
address={ data.sender }
isLoading={ isLoading }
truncation="constant"
noIcon
currentAddress={ currentAddress }
textStyle="xs"
/>
) : dashElement
}
</VStack>
</TableCell>
<TableCell>
<AddressFromToIcon type="unspecified" isLoading={ isLoading } mt={ 0.5 }/>
</TableCell>
<TableCell maxW="150px">
<VStack alignItems="start">
{ data.destination_token && (
<TokenValueInterchain
token={ data.destination_token }
amount={ data.destination_amount }
chain={ data.destination_chain }
loading={ isLoading }
lineHeight="24px"
/>
) }
{
data.recipient ? (
<AddressEntityInterchain
chain={ data.destination_chain }
address={ data.recipient }
isLoading={ isLoading }
truncation="constant"
noIcon
currentAddress={ currentAddress }
textStyle="xs"
/>
) : dashElement
}
</VStack>
</TableCell>
<TableCell maxW="150px">
<VStack alignItems="start">
{ data.source_transaction_hash ? (
<TxEntityInterchain
chain={ data.source_chain }
hash={ data.source_transaction_hash }
isLoading={ isLoading }
noIcon
truncation="constant"
lineHeight="24px"
/>
) : dashElement }
<ChainLabel
data={ data.source_chain }
isLoading={ isLoading }
color="text.secondary"
textStyle="xs"
gap={ 1 }
/>
</VStack>
</TableCell>
<TableCell maxW="150px">
<VStack alignItems="start">
{ data.destination_transaction_hash ? (
<TxEntityInterchain
chain={ data.destination_chain }
hash={ data.destination_transaction_hash }
isLoading={ isLoading }
noIcon
truncation="constant"
lineHeight="24px"
/>
) : dashElement }
<ChainLabel
data={ data.destination_chain }
isLoading={ isLoading }
color="text.secondary"
textStyle="xs"
gap={ 1 }
/>
</VStack>
</TableCell>
<TableCell>
<CrossChainBridgeLink data={ data.bridge } isLoading={ isLoading } lineHeight="24px"/>
</TableCell>
<TableCell>
<CrossChainMessageEntity id={ data.message_id } isLoading={ isLoading } lineHeight="24px"/>
</TableCell>
<TableCell>
<TimeWithTooltip
timestamp={ data.send_timestamp || data.receive_timestamp }
isLoading={ isLoading }
color="text.secondary"
lineHeight="24px"
whiteSpace="nowrap"
/>
</TableCell>
</TableRow>
);
};
export default React.memo(TokenTransfersCrossChainTableItem);
Binary file not shown.

After

Width:  |  Height:  |  Size: 68 KiB

+14
View File
@@ -0,0 +1,14 @@
import type { InterchainTransfer } from '@blockscout/interchain-indexer-types';
export const getItemKey = (data: InterchainTransfer, index?: number) => {
return [
data.message_id,
data.sender?.hash,
data.source_token?.address_hash,
data.source_amount,
data.source_chain?.id,
index,
]
.filter((item) => item !== undefined)
.join('-');
};
+34
View File
@@ -0,0 +1,34 @@
import * as crossChainConfigMock from 'mocks/crossChain/config';
import * as crossChainTransfersMock from 'mocks/crossChain/transfers';
import * as crossChainTxMock from 'mocks/crossChain/txs';
import { ENVS_MAP } from 'playwright/fixtures/mockEnvs';
import { test, expect } from 'playwright/lib';
import TxCrossChain from './TxCrossChain';
const hooksConfig = {
router: {
query: { id: crossChainTxMock.base.message_id },
},
};
test('successful tx +@mobile', async({ render, mockEnvs, mockApiResponse, mockAssetResponse, mockTextAd }) => {
await mockTextAd();
await mockEnvs([
...ENVS_MAP.crossChainTxs,
[ 'NEXT_PUBLIC_NETWORK_ID', crossChainConfigMock.config[0].id ],
]);
await mockApiResponse('interchainIndexer:message', crossChainTxMock.base, {
pathParams: {
id: crossChainTxMock.base.message_id,
},
});
await mockAssetResponse(crossChainConfigMock.config[0].logo as string, './playwright/mocks/duck.png');
await mockAssetResponse(crossChainConfigMock.config[1].logo as string, './playwright/mocks/goose.png');
await mockAssetResponse(crossChainTransfersMock.transferA.source_token.icon_url as string, './playwright/mocks/image_s.jpg');
const component = await render(<TxCrossChain/>, { hooksConfig });
await expect(component).toHaveScreenshot();
});
+69
View File
@@ -0,0 +1,69 @@
import { useRouter } from 'next/router';
import React from 'react';
import type { TabItemRegular } from 'toolkit/components/AdaptiveTabs/types';
import useApiQuery from 'lib/api/useApiQuery';
import throwOnResourceLoadError from 'lib/errors/throwOnResourceLoadError';
import getQueryParamString from 'lib/router/getQueryParamString';
import { INTERCHAIN_MESSAGE } from 'stubs/interchainIndexer';
import RoutedTabs from 'toolkit/components/RoutedTabs/RoutedTabs';
import TextAd from 'ui/shared/ad/TextAd';
import CrossChainMessageEntity from 'ui/shared/entities/crossChainMessage/CrossChainMessageEntity';
import PageTitle from 'ui/shared/Page/PageTitle';
import TxCrossChainDetails from './TxCrossChainDetails';
import TxCrossChainTransfers from './TxCrossChainTransfers';
const TxCrossChain = () => {
const router = useRouter();
const messageId = getQueryParamString(router.query.id);
const query = useApiQuery('interchainIndexer:message', {
pathParams: {
id: messageId,
},
queryOptions: {
placeholderData: INTERCHAIN_MESSAGE,
},
});
const tabs: Array<TabItemRegular> = [
{
id: 'index',
title: 'Details',
component: <TxCrossChainDetails data={ query.data } isLoading={ query.isPlaceholderData }/>,
},
{
id: 'transfers',
title: 'Token transfers',
component: <TxCrossChainTransfers data={ query.data?.transfers } isLoading={ query.isPlaceholderData } isError={ query.isError }/>,
},
];
throwOnResourceLoadError(query);
const titleSecondRow = (
<CrossChainMessageEntity
id={ messageId }
isLoading={ query.isPlaceholderData }
variant="subheading"
truncation="dynamic"
noIcon={ false }
noLink
/>
);
return (
<>
<TextAd mb={ 6 }/>
<PageTitle
title="Cross-chain tx details"
secondRow={ titleSecondRow }
/>
<RoutedTabs tabs={ tabs } isLoading={ query.isPlaceholderData }/>
</>
);
};
export default React.memo(TxCrossChain);
+119
View File
@@ -0,0 +1,119 @@
import React from 'react';
import type { InterchainMessage } from '@blockscout/interchain-indexer-types';
import CrossChainBridgeLink from 'ui/shared/crossChain/CrossChainBridgeLink';
import DataFetchAlert from 'ui/shared/DataFetchAlert';
import * as DetailedInfo from 'ui/shared/DetailedInfo/DetailedInfo';
import DetailedInfoTimestamp from 'ui/shared/DetailedInfo/DetailedInfoTimestamp';
import AddressEntityInterchain from 'ui/shared/entities/address/AddressEntityInterchain';
import RawInputData from 'ui/shared/RawInputData';
import CrossChainTxsStatusTag from 'ui/shared/statusTag/CrossChainTxsStatusTag';
import TxCrossChainDetailsLifecycle from './TxCrossChainDetailsLifecycle';
import TxCrossChainDetailsTransfers from './TxCrossChainDetailsTransfers';
interface Props {
data: InterchainMessage | undefined;
isLoading?: boolean;
}
const TxCrossChainDetails = ({ data, isLoading }: Props) => {
if (!data) {
return <DataFetchAlert/>;
}
return (
<DetailedInfo.Container>
{ data.sender && (
<>
<DetailedInfo.ItemLabel
hint="Address that initiated the cross-chain operation on the source chain"
isLoading={ isLoading }
>
Sender
</DetailedInfo.ItemLabel>
<DetailedInfo.ItemValue>
<AddressEntityInterchain
address={ data.sender }
isLoading={ isLoading }
chain={ data.source_chain }
/>
</DetailedInfo.ItemValue>
</>
) }
{ data.recipient && (
<>
<DetailedInfo.ItemLabel
hint="Recipient address on the destination chain, usually the account or contract that receives tokens or the message"
isLoading={ isLoading }
>
Target
</DetailedInfo.ItemLabel>
<DetailedInfo.ItemValue>
<AddressEntityInterchain
address={ data.recipient }
isLoading={ isLoading }
chain={ data.destination_chain }
/>
</DetailedInfo.ItemValue>
</>
) }
<DetailedInfo.ItemLabel
hint="Cross-chain protocol used to route the message between chains (bridge or messaging layer)"
isLoading={ isLoading }
>
Protocol
</DetailedInfo.ItemLabel>
<DetailedInfo.ItemValue>
<CrossChainBridgeLink data={ data.bridge } isLoading={ isLoading }/>
</DetailedInfo.ItemValue>
{ data.transfers.length > 0 && <TxCrossChainDetailsTransfers data={ data.transfers } id={ data.message_id } isLoading={ isLoading }/> }
<DetailedInfo.ItemLabel
hint="Current state of the cross-chain operation"
isLoading={ isLoading }
>
Status
</DetailedInfo.ItemLabel>
<DetailedInfo.ItemValue>
<CrossChainTxsStatusTag status={ data.status } loading={ isLoading } mode="full"/>
</DetailedInfo.ItemValue>
<DetailedInfo.ItemLabel
hint="Date and time when the cross-chain operation was completed or initiated"
isLoading={ isLoading }
>
Timestamp
</DetailedInfo.ItemLabel>
<DetailedInfo.ItemValue>
<DetailedInfoTimestamp timestamp={ data.receive_timestamp || data.send_timestamp } isLoading={ isLoading }/>
</DetailedInfo.ItemValue>
<DetailedInfo.ItemLabel
hint="Source and destination transactions linked to this cross-chain operation"
isLoading={ isLoading }
>
Lifecycle
</DetailedInfo.ItemLabel>
<DetailedInfo.ItemValue py={ 1 }>
<TxCrossChainDetailsLifecycle data={ data } isLoading={ isLoading }/>
</DetailedInfo.ItemValue>
{ data.payload && (
<>
<DetailedInfo.ItemLabel
hint="Message data sent as part of the cross-chain operation"
isLoading={ isLoading }
>
Payload
</DetailedInfo.ItemLabel>
<DetailedInfo.ItemValue>
<RawInputData hex={ data.payload } isLoading={ isLoading }/>
</DetailedInfo.ItemValue>
</>
) }
</DetailedInfo.Container>
);
};
export default React.memo(TxCrossChainDetails);
@@ -0,0 +1,44 @@
import * as crossChainConfigMock from 'mocks/crossChain/config';
import * as crossChainTransfersMock from 'mocks/crossChain/transfers';
import * as crossChainTxMock from 'mocks/crossChain/txs';
import { ENVS_MAP } from 'playwright/fixtures/mockEnvs';
import { test, expect } from 'playwright/lib';
import TxCrossChainDetailsLifecycle from './TxCrossChainDetailsLifecycle';
test.beforeEach(async({ mockEnvs, mockAssetResponse }) => {
await mockEnvs([
...ENVS_MAP.crossChainTxs,
[ 'NEXT_PUBLIC_NETWORK_ID', crossChainConfigMock.config[0].id ],
]);
await mockAssetResponse(crossChainConfigMock.config[0].logo as string, './playwright/mocks/duck.png');
await mockAssetResponse(crossChainConfigMock.config[1].logo as string, './playwright/mocks/goose.png');
await mockAssetResponse(crossChainTransfersMock.transferA.source_token.icon_url as string, './playwright/mocks/image_s.jpg');
});
test('successful tx +@mobile +@dark-mode', async({ render }) => {
const component = await render(<TxCrossChainDetailsLifecycle data={ crossChainTxMock.base }/>);
await component.getByRole('button', { name: 'Initiated' }).click();
await component.getByRole('button', { name: 'Completed' }).click();
await expect(component).toHaveScreenshot();
});
test('initiated tx', async({ render }) => {
const component = await render(<TxCrossChainDetailsLifecycle data={ crossChainTxMock.pending }/>);
await component.getByRole('button', { name: 'Initiated' }).click();
await expect(component).toHaveScreenshot();
});
test('failed on first step', async({ render }) => {
const component = await render(<TxCrossChainDetailsLifecycle data={{
...crossChainTxMock.failed,
destination_transaction_hash: undefined,
}}/>);
await component.getByRole('button', { name: 'Initiated' }).click();
await expect(component).toHaveScreenshot();
});
@@ -0,0 +1,114 @@
import React from 'react';
import type { InterchainMessage } from '@blockscout/interchain-indexer-types';
import { MessageStatus } from '@blockscout/interchain-indexer-types';
import DetailedInfoTimestamp from 'ui/shared/DetailedInfo/DetailedInfoTimestamp';
import TxEntityInterchain from 'ui/shared/entities/tx/TxEntityInterchain';
import ChainLabel from 'ui/shared/externalChains/ChainLabel';
import { Root, Item, Trigger, ItemContent, ItemBody, ItemRow } from 'ui/shared/lifecycle/LifecycleAccordion';
import TxStatus from 'ui/shared/statusTag/TxStatus';
interface Props {
data: InterchainMessage;
isLoading?: boolean;
}
const TxCrossChainDetailsLifecycle = ({ data, isLoading }: Props) => {
const isError = data.status === MessageStatus.MESSAGE_STATUS_FAILED;
const firstStepContent = (() => {
if (!data.source_transaction_hash) {
return <Trigger status="unfinalized" text="Initiated" isFirst isLast isLoading={ isLoading } isDisabled/>;
}
const isLast = isError && !data.destination_transaction_hash;
return (
<>
<Trigger
status={ isError ? 'error' : 'success' }
text="Initiated"
isFirst
isLast={ isLast }
isLoading={ isLoading }
/>
<ItemContent isLast={ isLast }>
<ItemBody>
<ItemRow label="Chain">
<ChainLabel data={ data.source_chain } isLoading={ isLoading } py="6px"/>
</ItemRow>
<ItemRow label="Transaction">
<TxEntityInterchain
chain={ data.source_chain }
hash={ data.source_transaction_hash }
isLoading={ isLoading }
noIcon
py="6px"
/>
</ItemRow>
<ItemRow label="Timestamp">
<DetailedInfoTimestamp timestamp={ data.send_timestamp } isLoading={ isLoading } flexWrap={{ base: 'wrap', lg: 'nowrap' }} py="6px"/>
</ItemRow>
<ItemRow label="Status">
<TxStatus status={ isError ? 'error' : 'ok' } isLoading={ isLoading } my="4px"/>
</ItemRow>
</ItemBody>
</ItemContent>
</>
);
})();
const secondStepContent = (() => {
if (!data.destination_transaction_hash) {
if (isError) {
return null;
}
return <Trigger status="unfinalized" text="Completed" isFirst={ false } isLast isLoading={ isLoading } isDisabled/>;
}
return (
<>
<Trigger status="success" text="Completed" isFirst={ false } isLast isLoading={ isLoading }/>
<ItemContent isLast>
<ItemBody>
<ItemRow label="Chain">
<ChainLabel data={ data.destination_chain } isLoading={ isLoading } py="6px"/>
</ItemRow>
<ItemRow label="Transaction">
<TxEntityInterchain
chain={ data.destination_chain }
hash={ data.destination_transaction_hash }
isLoading={ isLoading }
noIcon
py="6px"
/>
</ItemRow>
{ data.receive_timestamp && (
<ItemRow label="Timestamp">
<DetailedInfoTimestamp timestamp={ data.receive_timestamp } isLoading={ isLoading } flexWrap={{ base: 'wrap', lg: 'nowrap' }} py="6px"/>
</ItemRow>
) }
<ItemRow label="Status">
<TxStatus status={ isError ? 'error' : 'ok' } isLoading={ isLoading } my="4px"/>
</ItemRow>
</ItemBody>
</ItemContent>
</>
);
})();
return (
<Root>
<Item value="initiated">
{ firstStepContent }
</Item>
<Item value="completed">
{ secondStepContent }
</Item>
</Root>
);
};
export default React.memo(TxCrossChainDetailsLifecycle);
@@ -0,0 +1,112 @@
import { chakra, Flex, GridItem } from '@chakra-ui/react';
import React from 'react';
import type { InterchainTransfer } from '@blockscout/interchain-indexer-types';
import { route } from 'nextjs-routes';
import { Link } from 'toolkit/chakra/link';
import { Skeleton } from 'toolkit/chakra/skeleton';
import AddressFromToIcon from 'ui/shared/address/AddressFromToIcon';
import * as DetailedInfo from 'ui/shared/DetailedInfo/DetailedInfo';
import AddressEntityInterchain from 'ui/shared/entities/address/AddressEntityInterchain';
import IconSvg from 'ui/shared/IconSvg';
import TokenValueInterchain from 'ui/shared/value/TokenValueInterchain';
const MAX_NUM = 5;
interface Props {
data: Array<InterchainTransfer>;
id: string;
isLoading?: boolean;
}
const TxCrossChainDetailsTransfers = ({ data, id, isLoading }: Props) => {
return (
<>
<DetailedInfo.ItemLabel
hint="Tokens moved as part of the cross-chain operation"
isLoading={ isLoading }
>
Token transferred
</DetailedInfo.ItemLabel>
<DetailedInfo.ItemValue position="relative" multiRow>
<Flex
flexDirection="column"
alignItems="flex-start"
rowGap={ 1 }
w="100%"
overflow="hidden"
>
{ data.slice(0, MAX_NUM).map((item, index) => {
return (
<Flex key={ index } alignItems="center" columnGap={ 2 } rowGap={ 0 } flexWrap="wrap">
{ item.sender ? (
<AddressEntityInterchain
address={ item.sender }
isLoading={ isLoading }
chain={ item.source_chain }
noIcon
truncation="constant"
/>
) : <chakra.span color="text.secondary">Unknown</chakra.span> }
<AddressFromToIcon
isLoading={ isLoading }
type="unspecified"
/>
{ item.recipient ? (
<AddressEntityInterchain
address={ item.recipient }
isLoading={ isLoading }
chain={ item.destination_chain }
noIcon
truncation="constant"
/>
) : <chakra.span color="text.secondary">Unknown</chakra.span> }
<Skeleton loading={ isLoading } color="text.secondary">
<span>for</span>
</Skeleton>
{ item.source_token ? (
<TokenValueInterchain
token={ item.source_token }
amount={ item.source_amount }
chain={ item.source_chain }
loading={ isLoading }
/>
) : <chakra.span color="text.secondary">Unknown</chakra.span> }
<AddressFromToIcon
isLoading={ isLoading }
type="unspecified"
/>
{ item.destination_token ? (
<TokenValueInterchain
token={ item.destination_token }
amount={ item.destination_amount }
chain={ item.destination_chain }
loading={ isLoading }
/>
) : <chakra.span color="text.secondary">Unknown</chakra.span> }
</Flex>
);
}) }
</Flex>
</DetailedInfo.ItemValue>
{ data.length > MAX_NUM && (
<>
<GridItem hideBelow="lg"/>
<GridItem fontSize="sm" alignItems="center" display="inline-flex" pl={{ base: '28px', lg: 0 }}>
{ /* FIXME use non-navigation icon */ }
<IconSvg name="navigation/tokens" boxSize={ 6 }/>
<Link
href={ route({ pathname: '/cross-chain-tx/[id]', query: { id, tab: 'transfers' } }) }
>
View all
</Link>
</GridItem>
</>
) }
</>
);
};
export default React.memo(TxCrossChainDetailsTransfers);
@@ -0,0 +1,57 @@
import { Box } from '@chakra-ui/react';
import React from 'react';
import type { InterchainTransfer } from '@blockscout/interchain-indexer-types';
import DataListDisplay from 'ui/shared/DataListDisplay';
import TokenTransfersCrossChainListItem from '../transfers/TokenTransfersCrossChainListItem';
import TokenTransfersCrossChainTable from '../transfers/TokenTransfersCrossChainTable';
import { getItemKey } from '../transfers/utils';
interface Props {
data: Array<InterchainTransfer> | undefined;
isLoading?: boolean;
isError: boolean;
}
const TxCrossChainTransfers = ({ data, isLoading, isError }: Props) => {
const content = data ? (
<>
<Box hideFrom="lg">
{ data.map((item, index) => (
<TokenTransfersCrossChainListItem
key={ getItemKey(item, isLoading ? index : undefined) }
data={ item }
isLoading={ isLoading }
py={ 4 }
textStyle="sm"
rowGap="14px"
_first={{
borderTopWidth: '0',
paddingTop: '0',
}}
/>
)) }
</Box>
<Box hideBelow="lg">
<TokenTransfersCrossChainTable data={ data } isLoading={ isLoading }/>
</Box>
</>
) : null;
return (
<DataListDisplay
isError={ isError }
itemsNum={ data?.length }
emptyText="There are no cross-chain token transfers."
emptyStateProps={{
term: 'token transfer',
}}
>
{ content }
</DataListDisplay>
);
};
export default React.memo(TxCrossChainTransfers);
Binary file not shown.

After

Width:  |  Height:  |  Size: 79 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 98 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

+57
View File
@@ -0,0 +1,57 @@
import React from 'react';
import type { TabItemRegular } from 'toolkit/components/AdaptiveTabs/types';
import RoutedTabs from 'toolkit/components/RoutedTabs/RoutedTabs';
import { ACTION_BAR_HEIGHT_DESKTOP } from 'ui/shared/ActionBar';
import PageTitle from 'ui/shared/Page/PageTitle';
import TxsStats from 'ui/txs/TxsStats';
import TxsTabs, { getTabId } from 'ui/txs/TxsTabs';
import TransactionsCrossChain from './TransactionsCrossChain';
const LOCAL_TAB_LIST_PROPS = {
marginBottom: 0,
pt: 6,
pb: { base: 9, lg: 3 },
};
const LOCAL_TAB_ID = 'txs_local';
const LOCAL_SUB_TABS = [ getTabId('validated', LOCAL_TAB_ID), getTabId('pending', LOCAL_TAB_ID), getTabId('blob_txs', LOCAL_TAB_ID) ];
const Transactions = () => {
const tabs: Array<TabItemRegular> = React.useMemo(() => {
return [
{
id: LOCAL_TAB_ID,
title: 'Txns',
component: (
<>
<TxsStats mb={ 0 }/>
<TxsTabs
parentTab={ LOCAL_TAB_ID }
variant="secondary"
size="sm"
tabsHeight={ ACTION_BAR_HEIGHT_DESKTOP }
listProps={ LOCAL_TAB_LIST_PROPS }
/>
</>
),
subTabs: LOCAL_SUB_TABS,
},
{
id: 'txs_cross_chain',
title: 'Cross-chain txns',
component: <TransactionsCrossChain/>,
},
];
}, [ ]);
return (
<>
<PageTitle withTextAd title="Transactions"/>
<RoutedTabs tabs={ tabs }/>
</>
);
};
export default React.memo(Transactions);
@@ -0,0 +1,44 @@
import * as crossChainConfigMock from 'mocks/crossChain/config';
import * as crossChainTransfersMock from 'mocks/crossChain/transfers';
import * as crossChainTxMock from 'mocks/crossChain/txs';
import { ENVS_MAP } from 'playwright/fixtures/mockEnvs';
import { test, expect } from 'playwright/lib';
import * as pwConfig from 'playwright/utils/config';
import TransactionsCrossChain from './TransactionsCrossChain';
test.describe('txs', () => {
test.slow();
test.beforeEach(async({ mockEnvs, mockApiResponse, mockAssetResponse }) => {
await mockEnvs([
...ENVS_MAP.crossChainTxs,
[ 'NEXT_PUBLIC_NETWORK_ID', crossChainConfigMock.config[0].id ],
]);
await mockApiResponse('interchainIndexer:messages', crossChainTxMock.listResponse);
await mockApiResponse('interchainIndexer:stats_common', { total_messages: 100, total_transfers: 101, timestamp: '2022-01-13T12:00:00.000Z' });
await mockApiResponse('interchainIndexer:stats_daily', { daily_messages: 42, daily_transfers: 55, date: '2022-01-13' });
await mockAssetResponse(crossChainConfigMock.config[0].logo as string, './playwright/mocks/duck.png');
await mockAssetResponse(crossChainConfigMock.config[1].logo as string, './playwright/mocks/goose.png');
await mockAssetResponse(crossChainTransfersMock.transferA.source_token.icon_url as string, './playwright/mocks/image_s.jpg');
});
test.describe('desktop', () => {
test.use({ viewport: pwConfig.viewport.xl });
test('base view +@dark-mode', async({ render }) => {
const component = await render(<TransactionsCrossChain/>);
await expect(component).toHaveScreenshot({ timeout: 30_000 });
});
});
test.describe('mobile', () => {
test.use({ viewport: pwConfig.viewport.mobile });
test('base view', async({ render }) => {
const component = await render(<TransactionsCrossChain/>);
await expect(component).toHaveScreenshot({ timeout: 30_000 });
});
});
});
@@ -0,0 +1,48 @@
import React from 'react';
import useApiQuery from 'lib/api/useApiQuery';
import { INTERCHAIN_MESSAGE, INTERCHAIN_STATS_COMMON } from 'stubs/interchainIndexer';
import { generateListStub } from 'stubs/utils';
import { Skeleton } from 'toolkit/chakra/skeleton';
import useQueryWithPages from 'ui/shared/pagination/useQueryWithPages';
import StickyPaginationWithText from 'ui/shared/StickyPaginationWithText';
import TransactionsCrossChainContent from './TransactionsCrossChainContent';
import TransactionsCrossChainStats from './TransactionsCrossChainStats';
const TransactionsCrossChain = () => {
const { data, isPlaceholderData, isError, pagination } = useQueryWithPages({
resourceName: 'interchainIndexer:messages',
options: {
placeholderData: generateListStub<'interchainIndexer:messages'>(INTERCHAIN_MESSAGE, 50, { next_page_params: { page_token: 'token' } }),
},
});
const statsQuery = useApiQuery('interchainIndexer:stats_common', {
queryOptions: {
placeholderData: INTERCHAIN_STATS_COMMON,
},
});
const actionBarText = (
<Skeleton loading={ statsQuery.isPlaceholderData || isPlaceholderData }>
A total of { Number(statsQuery.data?.total_messages).toLocaleString() } cross-chain transactions found
</Skeleton>
);
const actionBar = <StickyPaginationWithText text={ actionBarText } pagination={ pagination }/>;
return (
<>
<TransactionsCrossChainStats/>
<TransactionsCrossChainContent
items={ data?.items }
isLoading={ isPlaceholderData }
pagination={ pagination }
isError={ isError }
actionBar={ actionBar }
/>
</>
);
};
export default React.memo(TransactionsCrossChain);
@@ -0,0 +1,67 @@
import { Box } from '@chakra-ui/react';
import React from 'react';
import type { InterchainMessage } from '@blockscout/interchain-indexer-types';
import type { PaginationParams } from 'ui/shared/pagination/types';
import { ACTION_BAR_HEIGHT_DESKTOP } from 'ui/shared/ActionBar';
import type { Props as DataListDisplayProps } from 'ui/shared/DataListDisplay';
import DataListDisplay from 'ui/shared/DataListDisplay';
import TransactionsCrossChainListItem from './TransactionsCrossChainListItem';
import TransactionsCrossChainTable from './TransactionsCrossChainTable';
export interface Props extends Omit<DataListDisplayProps, 'children'> {
items?: Array<InterchainMessage>;
isLoading?: boolean;
pagination?: PaginationParams;
isTableView?: boolean;
stickyHeader?: boolean;
currentAddress?: string;
}
const TransactionsCrossChainContent = ({ items, isLoading, pagination, isTableView, stickyHeader = true, currentAddress, ...rest }: Props) => {
const content = items ? (
<>
<Box display={{ base: isTableView ? 'none' : 'block', lg: 'none' }}>
{ items.map((item, index) => (
<TransactionsCrossChainListItem
key={ item.message_id + (isLoading ? index : '') }
data={ item }
isLoading={ isLoading }
currentAddress={ currentAddress }
/>
)) }
</Box>
<Box
display={{ base: isTableView ? 'block' : 'none', lg: 'block' }}
overflowX={{ base: 'scroll', lg: 'initial' }}
mx={{ base: -3, lg: 0 }}
px={{ base: 3, lg: 0 }}
>
<TransactionsCrossChainTable
data={ items }
isLoading={ isLoading }
top={ ACTION_BAR_HEIGHT_DESKTOP }
stickyHeader={ stickyHeader }
currentAddress={ currentAddress }
/>
</Box>
</>
) : null;
return (
<DataListDisplay
itemsNum={ items?.length }
emptyText="There are no cross-chain transactions."
emptyStateProps={{
term: 'transaction',
}}
{ ...rest }
>
{ content }
</DataListDisplay>
);
};
export default React.memo(TransactionsCrossChainContent);
@@ -0,0 +1,172 @@
import type { JsxStyleProps } from '@chakra-ui/react';
import { chakra, Grid, HStack } from '@chakra-ui/react';
import React from 'react';
import type { InterchainMessage } from '@blockscout/interchain-indexer-types';
import { route } from 'nextjs-routes';
import config from 'configs/app';
import dayjs from 'lib/date/dayjs';
import { Link } from 'toolkit/chakra/link';
import { Skeleton } from 'toolkit/chakra/skeleton';
import { mdash } from 'toolkit/utils/htmlEntities';
import CrossChainBridgeLink from 'ui/shared/crossChain/CrossChainBridgeLink';
import CrossChainFromToTag from 'ui/shared/crossChain/CrossChainFromToTag';
import AddressEntityInterchain from 'ui/shared/entities/address/AddressEntityInterchain';
import CrossChainMessageEntity from 'ui/shared/entities/crossChainMessage/CrossChainMessageEntity';
import TxEntityInterchain from 'ui/shared/entities/tx/TxEntityInterchain';
import ListItemMobile from 'ui/shared/ListItemMobile/ListItemMobile';
import CrossChainTxsStatusTag from 'ui/shared/statusTag/CrossChainTxsStatusTag';
import TextSeparator from 'ui/shared/TextSeparator';
import Time from 'ui/shared/time/Time';
import TokenValueInterchain from 'ui/shared/value/TokenValueInterchain';
interface Props extends JsxStyleProps {
data: InterchainMessage;
isLoading?: boolean;
currentAddress?: string;
}
const TransactionsCrossChainListItem = ({ data, isLoading, rowGap = 3, currentAddress, ...rest }: Props) => {
const timestamp = data.send_timestamp || data.receive_timestamp;
const firstTransfer = data.transfers.length > 0 ? data.transfers[0] : null;
const txHashWithTransfers = (() => {
if (data.transfers.length === 0) {
return;
}
if (config.chain.id === data.source_chain?.id) {
return data.source_transaction_hash;
}
if (config.chain.id === data.destination_chain?.id) {
return data.destination_transaction_hash;
}
})();
const dashElement = <chakra.span color="text.secondary">{ mdash }</chakra.span>;
return (
<ListItemMobile rowGap={ rowGap } { ...rest }>
<HStack>
<CrossChainTxsStatusTag status={ data.status } loading={ isLoading } mode="full"/>
{ currentAddress && (
<CrossChainFromToTag
type={ data.sender?.hash.toLowerCase() === currentAddress.toLowerCase() && config.chain.id === data.source_chain?.id ? 'out' : 'in' }
isLoading={ isLoading }
/>
) }
</HStack>
<CrossChainMessageEntity id={ data.message_id } isLoading={ isLoading } fontWeight={ 600 } noIcon={ false } truncation="dynamic" w="100%"/>
{ timestamp && (
<Skeleton loading={ isLoading } display="flex" alignItems="center" color="text.secondary">
<div>{ dayjs(timestamp).fromNow() }</div>
<TextSeparator/>
<Time timestamp={ timestamp } format="lll_s"/>
</Skeleton>
) }
<Grid templateColumns="120px 1fr" columnGap={ 2 } rowGap={ rowGap }>
<Skeleton loading={ isLoading }>
Source tx
</Skeleton>
{ data.source_transaction_hash ? (
<TxEntityInterchain
chain={ data.source_chain }
hash={ data.source_transaction_hash }
isLoading={ isLoading }
/>
) : dashElement }
<Skeleton loading={ isLoading }>
Destination tx
</Skeleton>
{ data.destination_transaction_hash ? (
<TxEntityInterchain
chain={ data.destination_chain }
hash={ data.destination_transaction_hash }
isLoading={ isLoading }
/>
) : dashElement }
<Skeleton loading={ isLoading }>
Msg sender
</Skeleton>
{ data.sender ? (
<AddressEntityInterchain
chain={ data.source_chain }
address={ data.sender }
isLoading={ isLoading }
noIcon
currentAddress={ currentAddress }
/>
) : dashElement }
<Skeleton loading={ isLoading }>
Transf
</Skeleton>
{ txHashWithTransfers ? (
<Link
href={ route({ pathname: '/tx/[hash]', query: { hash: txHashWithTransfers, tab: 'token_transfers_cross_chain' } }) }
loading={ isLoading }
>
{ data.transfers.length }
</Link>
) : (
<Skeleton loading={ isLoading } color="text.secondary">{ data.transfers.length }</Skeleton>
) }
<Skeleton loading={ isLoading }>
Sender
</Skeleton>
{ firstTransfer?.sender ? (
<AddressEntityInterchain
chain={ data.source_chain }
address={ firstTransfer.sender }
isLoading={ isLoading }
noIcon
currentAddress={ currentAddress }
/>
) : dashElement }
<Skeleton loading={ isLoading }>
Source token
</Skeleton>
{ firstTransfer?.source_token ? (
<TokenValueInterchain
token={ firstTransfer.source_token }
amount={ firstTransfer.source_amount }
chain={ firstTransfer.source_chain }
loading={ isLoading }
/>
) : dashElement }
<Skeleton loading={ isLoading }>
Recipient
</Skeleton>
{ firstTransfer?.recipient ? (
<AddressEntityInterchain
chain={ data.destination_chain }
address={ firstTransfer.recipient }
isLoading={ isLoading }
noIcon
currentAddress={ currentAddress }
/>
) : dashElement }
<Skeleton loading={ isLoading }>
Target token
</Skeleton>
{ firstTransfer?.destination_token ? (
<TokenValueInterchain
token={ firstTransfer.destination_token }
amount={ firstTransfer.destination_amount }
chain={ firstTransfer.destination_chain }
loading={ isLoading }
/>
) : dashElement }
<Skeleton loading={ isLoading }>
Protocol
</Skeleton>
{ data.bridge ? (
<CrossChainBridgeLink data={ data.bridge } isLoading={ isLoading }/>
) : dashElement }
</Grid>
</ListItemMobile>
);
};
export default React.memo(TransactionsCrossChainListItem);
@@ -0,0 +1,40 @@
import { Box } from '@chakra-ui/react';
import React from 'react';
import useApiQuery from 'lib/api/useApiQuery';
import { INTERCHAIN_STATS_DAILY } from 'stubs/interchainIndexer';
import StatsWidget from 'ui/shared/stats/StatsWidget';
interface Props {}
const TransactionsCrossChainStats = (props: Props) => {
const { data, isPlaceholderData, isError } = useApiQuery('interchainIndexer:stats_daily', {
queryOptions: {
placeholderData: INTERCHAIN_STATS_DAILY,
},
});
if (isError || !data) {
return null;
}
return (
<Box
display="grid"
gridTemplateColumns="1fr"
rowGap={ 3 }
columnGap={ 3 }
mb={ 6 }
{ ...props }
>
<StatsWidget
label="Cross-chain txns"
value={ Number(data.daily_messages).toLocaleString() }
period="24h"
isLoading={ isPlaceholderData }
/>
</Box>
);
};
export default React.memo(TransactionsCrossChainStats);
@@ -0,0 +1,62 @@
import { Flex } from '@chakra-ui/react';
import React from 'react';
import type { InterchainMessage } from '@blockscout/interchain-indexer-types';
import { AddressHighlightProvider } from 'lib/contexts/addressHighlight';
import { TableBody, TableColumnHeader, TableHeader, TableHeaderSticky, TableRoot, TableRow } from 'toolkit/chakra/table';
import TimeFormatToggle from 'ui/shared/time/TimeFormatToggle';
import TransactionsCrossChainTableItem from './TransactionsCrossChainTableItem';
interface Props {
data: Array<InterchainMessage>;
isLoading?: boolean;
top?: number;
stickyHeader?: boolean;
currentAddress?: string;
}
const TransactionsCrossChainTable = ({ data, isLoading, top, stickyHeader, currentAddress }: Props) => {
const TableHeaderComponent = stickyHeader ? TableHeaderSticky : TableHeader;
return (
<AddressHighlightProvider>
<TableRoot tableLayout="auto">
<TableHeaderComponent top={ stickyHeader ? top : undefined }>
<TableRow>
<TableColumnHeader w="42px"/>
{ currentAddress && <TableColumnHeader w="44px"/> }
<TableColumnHeader>Message</TableColumnHeader>
<TableColumnHeader>
<Flex alignItems="center" flexWrap="nowrap">
Timestamp
<TimeFormatToggle/>
</Flex>
</TableColumnHeader>
<TableColumnHeader>Msg sender</TableColumnHeader>
<TableColumnHeader>Source tx</TableColumnHeader>
<TableColumnHeader>Dest tx</TableColumnHeader>
<TableColumnHeader>Transf</TableColumnHeader>
<TableColumnHeader>Sender</TableColumnHeader>
<TableColumnHeader/>
<TableColumnHeader>Recipient</TableColumnHeader>
<TableColumnHeader>Protocol</TableColumnHeader>
</TableRow>
</TableHeaderComponent>
<TableBody>
{ data.map((item, index) => (
<TransactionsCrossChainTableItem
key={ item.message_id + (isLoading ? String(index) : '') }
data={ item }
isLoading={ isLoading }
currentAddress={ currentAddress }
/>
)) }
</TableBody>
</TableRoot>
</AddressHighlightProvider>
);
};
export default React.memo(TransactionsCrossChainTable);
@@ -0,0 +1,230 @@
import { chakra, VStack } from '@chakra-ui/react';
import React from 'react';
import type { InterchainMessage } from '@blockscout/interchain-indexer-types';
import { route } from 'nextjs-routes';
import config from 'configs/app';
import { Link } from 'toolkit/chakra/link';
import { Skeleton } from 'toolkit/chakra/skeleton';
import { TableCell, TableRow } from 'toolkit/chakra/table';
import { mdash } from 'toolkit/utils/htmlEntities';
import AddressFromToIcon from 'ui/shared/address/AddressFromToIcon';
import CrossChainBridgeLink from 'ui/shared/crossChain/CrossChainBridgeLink';
import CrossChainFromToTag from 'ui/shared/crossChain/CrossChainFromToTag';
import AddressEntityInterchain from 'ui/shared/entities/address/AddressEntityInterchain';
import CrossChainMessageEntity from 'ui/shared/entities/crossChainMessage/CrossChainMessageEntity';
import TxEntityInterchain from 'ui/shared/entities/tx/TxEntityInterchain';
import ChainLabel from 'ui/shared/externalChains/ChainLabel';
import CrossChainTxsStatusTag from 'ui/shared/statusTag/CrossChainTxsStatusTag';
import TimeWithTooltip from 'ui/shared/time/TimeWithTooltip';
import TokenValueInterchain from 'ui/shared/value/TokenValueInterchain';
interface Props {
data: InterchainMessage;
isLoading?: boolean;
currentAddress?: string;
}
const TransactionsCrossChainTableItem = ({ data, isLoading, currentAddress }: Props) => {
const firstTransfer = data.transfers.length > 0 ? data.transfers[0] : null;
const txHashWithTransfers = (() => {
if (data.transfers.length === 0) {
return;
}
if (config.chain.id === data.source_chain?.id) {
return data.source_transaction_hash;
}
if (config.chain.id === data.destination_chain?.id) {
return data.destination_transaction_hash;
}
})();
const dashElement = <chakra.span color="text.secondary" lineHeight="24px">{ mdash }</chakra.span>;
return (
<TableRow>
<TableCell w="42px">
<CrossChainTxsStatusTag status={ data.status } loading={ isLoading }/>
</TableCell>
{ currentAddress && (
<TableCell>
<CrossChainFromToTag
type={ data.sender?.hash.toLowerCase() === currentAddress.toLowerCase() && config.chain.id === data.source_chain?.id ? 'out' : 'in' }
isLoading={ isLoading }
/>
</TableCell>
) }
<TableCell>
<CrossChainMessageEntity id={ data.message_id } isLoading={ isLoading } lineHeight="24px" fontWeight={ 700 }/>
</TableCell>
<TableCell>
<TimeWithTooltip
timestamp={ data.send_timestamp || data.receive_timestamp }
isLoading={ isLoading }
enableIncrement
color="text.secondary"
lineHeight="24px"
whiteSpace="nowrap"
/>
</TableCell>
<TableCell maxW="150px">
{ data.sender ? (
<AddressEntityInterchain
chain={ data.source_chain }
address={ data.sender }
isLoading={ isLoading }
truncation="constant"
noIcon
currentAddress={ currentAddress }
lineHeight="24px"
maxW="100%"
w="fit-content"
/>
) : dashElement }
</TableCell>
<TableCell maxW="150px">
<VStack alignItems="start">
{ data.source_transaction_hash ? (
<TxEntityInterchain
chain={ data.source_chain }
hash={ data.source_transaction_hash }
isLoading={ isLoading }
noIcon
truncation="constant"
lineHeight="24px"
/>
) : dashElement }
<ChainLabel
data={ data.source_chain }
isLoading={ isLoading }
color="text.secondary"
textStyle="xs"
gap={ 1 }
fallback={ data.source_transaction_hash ? <chakra.span color="text.secondary">{ mdash }</chakra.span> : null }
/>
</VStack>
</TableCell>
<TableCell maxW="150px">
<VStack alignItems="start">
{ data.destination_transaction_hash ? (
<TxEntityInterchain
chain={ data.destination_chain }
hash={ data.destination_transaction_hash }
isLoading={ isLoading }
noIcon
truncation="constant"
lineHeight="24px"
/>
) : dashElement }
<ChainLabel
data={ data.destination_chain }
isLoading={ isLoading }
color="text.secondary"
textStyle="xs"
gap={ 1 }
fallback={ data.destination_transaction_hash ? <chakra.span color="text.secondary">{ mdash }</chakra.span> : null }
/>
</VStack>
</TableCell>
<TableCell>
{ txHashWithTransfers ? (
<Link
href={ route({ pathname: '/tx/[hash]', query: { hash: txHashWithTransfers, tab: 'token_transfers_cross_chain' } }) }
loading={ isLoading }
lineHeight="24px"
minW="24px"
>
{ data.transfers.length }
</Link>
) : (
<Skeleton loading={ isLoading } color="text.secondary" lineHeight="24px">
<span>
{ data.transfers.length }
</span>
</Skeleton>
) }
</TableCell>
<TableCell maxW="150px">
<VStack alignItems="start">
{
firstTransfer?.sender ? (
<AddressEntityInterchain
chain={ firstTransfer.source_chain }
address={ firstTransfer.sender }
isLoading={ isLoading }
truncation="constant"
noIcon
currentAddress={ currentAddress }
lineHeight="24px"
maxW="100%"
/>
) : dashElement
}
{ firstTransfer?.source_token && (
<TokenValueInterchain
token={ firstTransfer.source_token }
amount={ firstTransfer.source_amount }
chain={ firstTransfer.source_chain }
loading={ isLoading }
textStyle="xs"
color="text.secondary"
/>
) }
{ data.transfers.length > 1 && (
<Link
variant="secondary"
textDecorationStyle="dashed"
textDecorationLine="underline"
mt={ 2 }
href={ route({ pathname: '/cross-chain-tx/[id]', query: { id: data.message_id, tab: 'transfers' } }) }
textStyle="xs"
>
View all
</Link>
) }
</VStack>
</TableCell>
<TableCell>
<AddressFromToIcon type="unspecified" isLoading={ isLoading } mt={ 0.5 }/>
</TableCell>
<TableCell maxW="150px">
<VStack alignItems="start">
{
firstTransfer?.recipient ? (
<AddressEntityInterchain
chain={ firstTransfer.destination_chain }
address={ firstTransfer.recipient }
isLoading={ isLoading }
truncation="constant"
noIcon
currentAddress={ currentAddress }
lineHeight="24px"
maxW="100%"
/>
) : dashElement
}
{ firstTransfer?.destination_token && (
<TokenValueInterchain
token={ firstTransfer.destination_token }
amount={ firstTransfer.destination_amount }
chain={ firstTransfer.destination_chain }
loading={ isLoading }
textStyle="xs"
color="text.secondary"
/>
) }
</VStack>
</TableCell>
<TableCell>
<CrossChainBridgeLink data={ data.bridge } isLoading={ isLoading } lineHeight="24px"/>
</TableCell>
</TableRow>
);
};
export default React.memo(TransactionsCrossChainTableItem);
Binary file not shown.

After

Width:  |  Height:  |  Size: 60 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 129 KiB

+17
View File
@@ -10,13 +10,16 @@ import LatestWatchlistTxs from 'ui/home/LatestWatchlistTxs';
import LatestZetaChainCCTXs from 'ui/home/latestZetaChainCCTX/LatestZetaChainCCTXs';
import useAuth from 'ui/snippets/auth/useIsAuth';
import LatestCrossChainTxs from './latestCrossChainTxs/LatestCrossChainTxs';
import LatestArbitrumDeposits from './latestDeposits/LatestArbitrumDeposits';
const rollupFeature = config.features.rollup;
const zetachainFeature = config.features.zetachain;
const crossChainTxsFeature = config.features.crossChainTxs;
const Transactions = () => {
const isAuth = useAuth();
if ((rollupFeature.isEnabled && (rollupFeature.type === 'optimistic' || rollupFeature.type === 'arbitrum')) || isAuth || zetachainFeature.isEnabled) {
const tabs = [
zetachainFeature.isEnabled && {
@@ -43,6 +46,20 @@ const Transactions = () => {
);
}
if (crossChainTxsFeature.isEnabled) {
const tabs = [
{ id: 'txs', title: 'Txns', component: <LatestTxs/> },
{ id: 'cross_chain_txs', title: 'Cross-chain txns', component: <LatestCrossChainTxs/> },
];
return (
<>
<Heading level="3" mb={ 3 }>Latest transactions</Heading>
<AdaptiveTabs tabs={ tabs } unmountOnExit={ false } listProps={{ mb: 3 }}/>
</>
);
}
return (
<>
<Heading level="3" mb={ 3 }>Latest transactions</Heading>
@@ -0,0 +1,73 @@
import { Box, Flex, Text } from '@chakra-ui/react';
import React from 'react';
import { route } from 'nextjs-routes';
import useApiQuery from 'lib/api/useApiQuery';
import useIsMobile from 'lib/hooks/useIsMobile';
import { INTERCHAIN_MESSAGE } from 'stubs/interchainIndexer';
import { generateListStub } from 'stubs/utils';
import { Link } from 'toolkit/chakra/link';
import { TableBody, TableRoot } from 'toolkit/chakra/table';
import TransactionsCrossChainListItem from 'ui/crossChain/txs/TransactionsCrossChainListItem';
import LatestCrossChainTxsItemDesktop from './LatestCrossChainTxsItemDesktop';
const LatestCrossChainTxs = () => {
const isMobile = useIsMobile();
const txsCount = isMobile ? 2 : 5;
const { data, isPlaceholderData, isError } = useApiQuery('interchainIndexer:messages', {
queryOptions: {
placeholderData: generateListStub<'interchainIndexer:messages'>(
INTERCHAIN_MESSAGE,
txsCount,
{
next_page_params: undefined,
},
),
},
});
if (isError || !data) {
return <Text mt={ 4 }>No data. Please reload the page.</Text>;
}
return (
<>
<Box mb={ 3 } hideFrom="lg" textStyle="sm">
{ data.items.slice(0, txsCount).map(((tx, index) => (
<TransactionsCrossChainListItem
key={ tx.message_id + (isPlaceholderData ? index : '') }
data={ tx }
isLoading={ isPlaceholderData }
py={ 4 }
textStyle="sm"
rowGap="14px"
_first={{
borderTopWidth: '0',
paddingTop: '4px',
}}
/>
))) }
</Box>
<Box mb={ 3 } hideBelow="lg" textStyle="sm">
<TableRoot minWidth="750px">
<TableBody>
{ data.items.slice(0, txsCount).map(((tx, index) => (
<LatestCrossChainTxsItemDesktop
key={ tx.message_id + (isPlaceholderData ? index : '') }
data={ tx }
isLoading={ isPlaceholderData }
/>
))) }
</TableBody>
</TableRoot>
</Box>
<Flex justifyContent="center">
<Link textStyle="sm" href={ route({ pathname: '/txs', query: { tab: 'txs_cross_chain' } }) }>View all transactions</Link>
</Flex>
</>
);
};
export default React.memo(LatestCrossChainTxs);
@@ -0,0 +1,86 @@
import { chakra, VStack } from '@chakra-ui/react';
import React from 'react';
import type { InterchainMessage } from '@blockscout/interchain-indexer-types';
import { TableCell, TableRow } from 'toolkit/chakra/table';
import { mdash } from 'toolkit/utils/htmlEntities';
import CrossChainBridgeLink from 'ui/shared/crossChain/CrossChainBridgeLink';
import CrossChainMessageEntity from 'ui/shared/entities/crossChainMessage/CrossChainMessageEntity';
import TxEntityInterchain from 'ui/shared/entities/tx/TxEntityInterchain';
import ChainLabel from 'ui/shared/externalChains/ChainLabel';
import CrossChainTxsStatusTag from 'ui/shared/statusTag/CrossChainTxsStatusTag';
import TimeWithTooltip from 'ui/shared/time/TimeWithTooltip';
interface Props {
data: InterchainMessage;
isLoading?: boolean;
}
const LatestCrossChainTxsItemDesktop = ({ data, isLoading }: Props) => {
return (
<TableRow>
<TableCell w="42px">
<CrossChainTxsStatusTag status={ data.status } loading={ isLoading }/>
</TableCell>
<TableCell>
<VStack alignItems="start">
<CrossChainMessageEntity id={ data.message_id } isLoading={ isLoading } lineHeight="24px" fontWeight={ 700 }/>
<TimeWithTooltip timestamp={ data.send_timestamp || data.receive_timestamp } isLoading={ isLoading } color="text.secondary" timeFormat="absolute"/>
</VStack>
</TableCell>
<TableCell>
<VStack alignItems="start">
{ data.source_transaction_hash ? (
<TxEntityInterchain
chain={ data.source_chain }
hash={ data.source_transaction_hash }
isLoading={ isLoading }
noIcon
noCopy
truncation="constant"
lineHeight="24px"
/>
) : (
<chakra.span color="text.secondary">{ mdash }</chakra.span>
) }
<ChainLabel
data={ data.source_chain }
isLoading={ isLoading }
color="text.secondary"
textStyle="xs"
/>
</VStack>
</TableCell>
<TableCell>
<VStack alignItems="start">
{ data.destination_transaction_hash ? (
<TxEntityInterchain
chain={ data.destination_chain }
hash={ data.destination_transaction_hash }
isLoading={ isLoading }
noIcon
noCopy
truncation="constant"
lineHeight="24px"
/>
) : (
<chakra.span color="text.secondary">{ mdash }</chakra.span>
) }
<ChainLabel
data={ data.destination_chain }
isLoading={ isLoading }
color="text.secondary"
textStyle="xs"
/>
</VStack>
</TableCell>
<TableCell>
<CrossChainBridgeLink data={ data.bridge } isLoading={ isLoading } lineHeight="24px"/>
</TableCell>
</TableRow>
);
};
export default React.memo(LatestCrossChainTxsItemDesktop);
@@ -2,10 +2,10 @@ import React from 'react';
import * as tac from '@blockscout/tac-operation-lifecycle-types';
import { AccordionItem, AccordionRoot } from 'toolkit/chakra/accordion';
import { STATUS_LABELS } from 'lib/operations/tac';
import { Root, Item, Trigger } from 'ui/shared/lifecycle/LifecycleAccordion';
import TacOperationLifecycleAccordionItemContent from './TacOperationLifecycleAccordionItemContent';
import TacOperationLifecycleAccordionItemTrigger from './TacOperationLifecycleAccordionItemTrigger';
interface Props {
data: tac.OperationDetails['status_history'];
@@ -17,37 +17,38 @@ const TacOperationLifecycleAccordion = ({ data, isLoading, type }: Props) => {
const isPending = type === tac.OperationType.PENDING && !isLoading;
return (
<AccordionRoot maxW="800px" display="flex" flexDirection="column" rowGap={ 6 } lazyMount>
<Root>
{ data.map((item, index) => {
const isLast = index === data.length - 1 && !isPending;
return (
<AccordionItem key={ index } value={ item.type } borderBottomWidth="0px">
<TacOperationLifecycleAccordionItemTrigger
status={ item.type }
<Item key={ index } value={ item.type }>
<Trigger
status={ item.is_success ? 'success' : 'error' }
text={ STATUS_LABELS[item.type] }
isFirst={ index === 0 }
isLast={ isLast }
isLoading={ isLoading }
isSuccess={ item.is_success ?? false }
/>
<TacOperationLifecycleAccordionItemContent
isLast={ isLast }
data={ item }
/>
</AccordionItem>
</Item>
);
}) }
{ isPending && (
<AccordionItem value="pending" borderBottomWidth="0px">
<TacOperationLifecycleAccordionItemTrigger
<Item value="pending">
<Trigger
status="pending"
text="Pending"
isFirst={ false }
isLast={ true }
isLoading={ isLoading }
isSuccess={ false }
isDisabled
/>
</AccordionItem>
</Item>
) }
</AccordionRoot>
</Root>
);
};
@@ -1,4 +1,4 @@
import { AccordionItemContent, Grid, GridItem } from '@chakra-ui/react';
import { Box } from '@chakra-ui/react';
import React from 'react';
import * as tac from '@blockscout/tac-operation-lifecycle-types';
@@ -6,6 +6,7 @@ import * as tac from '@blockscout/tac-operation-lifecycle-types';
import DetailedInfoTimestamp from 'ui/shared/DetailedInfo/DetailedInfoTimestamp';
import TxEntity from 'ui/shared/entities/tx/TxEntity';
import TxEntityTon from 'ui/shared/entities/tx/TxEntityTon';
import { ItemContent, ItemBody, ItemRow } from 'ui/shared/lifecycle/LifecycleAccordion';
import StatusTag from 'ui/shared/statusTag/StatusTag';
interface Props {
@@ -15,75 +16,42 @@ interface Props {
const TacOperationLifecycleAccordionItemContent = ({ isLast, data }: Props) => {
return (
<AccordionItemContent
ml={{ base: 0, lg: '9px' }}
pl={{ base: 0, lg: '17px' }}
pt={ 2 }
borderLeftWidth={{ base: 0, lg: isLast ? undefined : '2px' }}
borderColor="border.divider"
>
<Grid
gridTemplateColumns="112px minmax(0, 1fr)"
alignItems="flex-start"
columnGap={ 3 }
rowGap={ 1 }
bgColor={{ _light: 'blackAlpha.50', _dark: 'whiteAlpha.50' }}
p="6px"
pl="18px"
textStyle="sm"
borderBottomLeftRadius="base"
borderBottomRightRadius="base"
>
<GridItem color="text.secondary" py="6px">
Status
</GridItem>
<GridItem py={ 1 }>
<StatusTag type={ data.is_success ? 'ok' : 'error' } text={ data.is_success ? 'Success' : 'Failed' }/>
</GridItem>
<ItemContent isLast={ isLast }>
<ItemBody>
<ItemRow label="Status">
<StatusTag type={ data.is_success ? 'ok' : 'error' } text={ data.is_success ? 'Success' : 'Failed' } my={ 1 }/>
</ItemRow>
{ data.timestamp && (
<>
<GridItem color="text.secondary" py="6px">
Timestamp
</GridItem>
<GridItem
display="inline-flex"
alignItems="center"
py="6px"
>
<DetailedInfoTimestamp timestamp={ data.timestamp } isLoading={ false } flexWrap={{ base: 'wrap', lg: 'nowrap' }}/>
</GridItem>
</>
<ItemRow label="Timestamp">
<DetailedInfoTimestamp timestamp={ data.timestamp } isLoading={ false } flexWrap={{ base: 'wrap', lg: 'nowrap' }} py="6px"/>
</ItemRow>
) }
<GridItem color="text.secondary" py="6px">
Transactions
</GridItem>
<GridItem
display="flex"
flexDirection="column"
rowGap={ 3 }
py="6px"
width="100%"
overflow="hidden"
>
{
data.transactions.map((tx) => {
if (tx.type === tac.BlockchainType.TON) {
return <TxEntityTon key={ tx.hash } hash={ tx.hash }/>;
}
<ItemRow label="Transactions">
<Box
display="flex"
flexDirection="column"
rowGap={ 3 }
py="6px"
width="100%"
overflow="hidden"
>
{
data.transactions.map((tx) => {
if (tx.type === tac.BlockchainType.TON) {
return <TxEntityTon key={ tx.hash } hash={ tx.hash }/>;
}
return <TxEntity key={ tx.hash } hash={ tx.hash } icon={{ name: 'brands/tac' }}/>;
})
}
</GridItem>
return <TxEntity key={ tx.hash } hash={ tx.hash } icon={{ name: 'brands/tac' }}/>;
})
}
</Box>
</ItemRow>
{ data.note && (
<>
<GridItem color="text.secondary" py="6px">
Note
</GridItem>
<GridItem
<ItemRow label="Note">
<Box
display="inline-flex"
alignItems="center"
py="6px"
@@ -91,11 +59,11 @@ const TacOperationLifecycleAccordionItemContent = ({ isLast, data }: Props) => {
wordBreak="break-word"
>
{ data.note }
</GridItem>
</>
</Box>
</ItemRow>
) }
</Grid>
</AccordionItemContent>
</ItemBody>
</ItemContent>
);
};
@@ -1,88 +0,0 @@
import { Box, HStack, Spinner } from '@chakra-ui/react';
import React from 'react';
import type * as tac from '@blockscout/tac-operation-lifecycle-types';
import { STATUS_LABELS } from 'lib/operations/tac';
import { AccordionItemTrigger } from 'toolkit/chakra/accordion';
import { Skeleton } from 'toolkit/chakra/skeleton';
import IconSvg from 'ui/shared/IconSvg';
interface Props {
status: tac.OperationStage_StageType | 'pending';
isFirst: boolean;
isLast: boolean;
isLoading?: boolean;
isSuccess: boolean;
}
const TacOperationLifecycleAccordionItemTrigger = ({ status, isFirst, isLast, isSuccess, isLoading }: Props) => {
const content = (() => {
switch (status) {
case 'pending': {
return (
<HStack gap={ 2 }>
<Spinner size="md"/>
<Box color="text.secondary">
Pending
</Box>
</HStack>
);
}
default: {
return (
<HStack gap={ 2 } color={ isSuccess ? 'green.500' : 'red.600' }>
<IconSvg name={ isSuccess ? 'verification-steps/finalized' : 'verification-steps/error' } boxSize={ 5 } isLoading={ isLoading }/>
<Skeleton loading={ isLoading }>
{ STATUS_LABELS[status] }
</Skeleton>
</HStack>
);
}
}
})();
return (
<AccordionItemTrigger
position="relative"
pt={ isFirst ? 0 : 1 }
pb={ 1 }
_before={ !isFirst ? {
position: 'absolute',
left: '9px',
bottom: 'calc(100% - 6px)',
width: '0',
height: '30px',
borderColor: 'border.divider',
borderLeftWidth: '2px',
content: '""',
} : undefined }
_after={ !isLast ? {
position: 'absolute',
left: '9px',
top: 'calc(100% - 6px)',
width: '0',
height: '6px',
borderColor: 'border.divider',
borderLeftWidth: '2px',
content: '""',
} : undefined }
_open={{
_after: {
height: { base: '14px', lg: '6px' },
},
}}
disabled={ isLoading || status === 'pending' }
noIndicator={ isLoading || status === 'pending' }
cursor={ status === 'pending' ? 'default' : 'pointer' }
_disabled={{
opacity: status === 'pending' ? 1 : 'control.disabled',
}}
>
{ content }
</AccordionItemTrigger>
);
};
export default React.memo(TacOperationLifecycleAccordionItemTrigger);
@@ -15,6 +15,7 @@ import { EmptyState } from 'toolkit/chakra/empty-state';
import RoutedTabs from 'toolkit/components/RoutedTabs/RoutedTabs';
import AddressAdvancedFilterLink from 'ui/address/AddressAdvancedFilterLink';
import AddressCsvExportLink from 'ui/address/AddressCsvExportLink';
import AddressTokenTransfersLocal from 'ui/address/AddressTokenTransfersLocal';
import useAddressTokenTransfersQuery from 'ui/address/useAddressTokenTransfersQuery';
import useAddressCountersQuery from 'ui/address/utils/useAddressCountersQuery';
import ChainSelect from 'ui/optimismSuperchain/components/ChainSelect';
@@ -23,7 +24,6 @@ import TokenTransferFilter from 'ui/shared/TokenTransfer/TokenTransferFilter';
import ListCounterText from '../components/ListCounterText';
import getAvailableChainIds from './getAvailableChainIds';
import TokenTransfersLocal from './tokenTransfers/TokenTransfersLocal';
export const ADDRESS_OP_SUPERCHAIN_TOKEN_TRANSFERS_TAB_IDS = [ 'token_transfers_cross_chain' as const, 'token_transfers_local' as const ];
const TABS_RIGHT_SLOT_PROPS = {
@@ -156,7 +156,7 @@ const OpSuperchainAddressTokenTransfers = ({ addressData, isLoading }: Props) =>
<MultichainProvider chainId={ transfersQueryLocal.query.chainValue?.[0] }>
<SocketProvider url={ getSocketUrl(chainData?.app_config) }>
{ isMobile && countersText }
<TokenTransfersLocal
<AddressTokenTransfersLocal
query={ transfersQueryLocal.query }
filters={ transfersQueryLocal.filters }
onTypeFilterChange={ transfersQueryLocal.onTypeFilterChange }

Some files were not shown because too many files have changed in this diff Show More