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
@@ -24,6 +24,7 @@ on:
|
||||
- immutable
|
||||
- mega_eth
|
||||
- neon_devnet
|
||||
- numine
|
||||
- optimism
|
||||
- optimism_sepolia
|
||||
- optimism_superchain
|
||||
|
||||
@@ -26,6 +26,7 @@ on:
|
||||
- mega_eth
|
||||
- mekong
|
||||
- neon_devnet
|
||||
- numine
|
||||
- optimism
|
||||
- optimism_sepolia
|
||||
- optimism_superchain
|
||||
|
||||
@@ -355,6 +355,7 @@
|
||||
"mega_eth",
|
||||
"mekong",
|
||||
"neon_devnet",
|
||||
"numine",
|
||||
"optimism",
|
||||
"optimism_interop_0",
|
||||
"optimism_sepolia",
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
@@ -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';
|
||||
|
||||
@@ -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
|
||||
@@ -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']
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
]
|
||||
@@ -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+ |
|
||||
|
||||
|
||||
|
||||
### 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 |
|
||||
|
||||
|
||||
|
||||
|
||||
### Badge claim link
|
||||
|
||||
@@ -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> :
|
||||
|
||||
@@ -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,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';
|
||||
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
},
|
||||
];
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 ]);
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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';
|
||||
@@ -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 });
|
||||
|
||||
|
||||
@@ -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' ],
|
||||
],
|
||||
};
|
||||
|
||||
|
After Width: | Height: | Size: 14 KiB |
|
After Width: | Height: | Size: 34 KiB |
@@ -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;
|
||||
@@ -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>
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
export interface CrossChainMessageFilters {
|
||||
q?: string;
|
||||
}
|
||||
|
||||
export interface CrossChainTransferFilters {
|
||||
q?: string;
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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, {
|
||||
|
||||
@@ -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 }
|
||||
@@ -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' },
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
Before Width: | Height: | Size: 20 KiB After Width: | Height: | Size: 20 KiB |
|
Before Width: | Height: | Size: 45 KiB After Width: | Height: | Size: 45 KiB |
|
Before Width: | Height: | Size: 47 KiB After Width: | Height: | Size: 47 KiB |
|
Before Width: | Height: | Size: 46 KiB After Width: | Height: | Size: 46 KiB |
|
Before Width: | Height: | Size: 16 KiB After Width: | Height: | Size: 16 KiB |
|
Before Width: | Height: | Size: 16 KiB After Width: | Height: | Size: 16 KiB |
|
Before Width: | Height: | Size: 15 KiB After Width: | Height: | Size: 15 KiB |
@@ -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 ]);
|
||||
}
|
||||
|
||||
@@ -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 ]);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
After Width: | Height: | Size: 42 KiB |
|
After Width: | Height: | Size: 42 KiB |
|
After Width: | Height: | Size: 68 KiB |
@@ -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('-');
|
||||
};
|
||||
@@ -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();
|
||||
});
|
||||
@@ -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);
|
||||
@@ -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);
|
||||
|
After Width: | Height: | Size: 79 KiB |
|
After Width: | Height: | Size: 98 KiB |
|
After Width: | Height: | Size: 37 KiB |
|
After Width: | Height: | Size: 18 KiB |
|
After Width: | Height: | Size: 20 KiB |
|
After Width: | Height: | Size: 37 KiB |
|
After Width: | Height: | Size: 42 KiB |
@@ -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);
|
||||
|
After Width: | Height: | Size: 61 KiB |
|
After Width: | Height: | Size: 60 KiB |
|
After Width: | Height: | Size: 129 KiB |
@@ -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 }
|
||||
|
||||