diff --git a/.github/workflows/deploy-review-l2.yml b/.github/workflows/deploy-review-l2.yml index 1b34c2ef3e..fda7230a9d 100644 --- a/.github/workflows/deploy-review-l2.yml +++ b/.github/workflows/deploy-review-l2.yml @@ -33,6 +33,8 @@ on: - scroll_sepolia - shibarium - stability + - zetachain + - zetachain_testnet - zkevm - zilliqa - zksync diff --git a/.github/workflows/deploy-review.yml b/.github/workflows/deploy-review.yml index 0222fecb5d..83a622f7d7 100644 --- a/.github/workflows/deploy-review.yml +++ b/.github/workflows/deploy-review.yml @@ -38,6 +38,8 @@ on: - stability - tac - tac_turin + - zetachain + - zetachain_testnet - zkevm - zilliqa - zksync diff --git a/.vscode/tasks.json b/.vscode/tasks.json index a70c95e625..27114e1c11 100644 --- a/.vscode/tasks.json +++ b/.vscode/tasks.json @@ -388,6 +388,8 @@ "stability_testnet", "tac", "tac_turin", + "zetachain", + "zetachain_testnet", "zkevm", "zilliqa", "zksync", diff --git a/configs/app/apis.ts b/configs/app/apis.ts index f99f6c8093..7f85849324 100644 --- a/configs/app/apis.ts +++ b/configs/app/apis.ts @@ -167,6 +167,24 @@ const visualizeApi = (() => { }); })(); +const zetachainApi = (() => { + const apiHost = getEnvValue('NEXT_PUBLIC_ZETACHAIN_SERVICE_API_HOST'); + if (!apiHost) { + return; + } + + try { + const url = new URL(apiHost); + + return Object.freeze({ + endpoint: apiHost, + socketEndpoint: `wss://${ url.host }/socket`, + }); + } catch (error) { + return; + } +})(); + export type Apis = { general: ApiPropsFull; } & Partial, ApiPropsBase>>; @@ -183,6 +201,7 @@ const apis: Apis = Object.freeze({ tac: tacApi, userOps: userOpsApi, visualize: visualizeApi, + zetachain: zetachainApi, }); export default apis; diff --git a/configs/app/features/index.ts b/configs/app/features/index.ts index c2fe7836cb..3c3d533811 100644 --- a/configs/app/features/index.ts +++ b/configs/app/features/index.ts @@ -47,3 +47,4 @@ export { default as validators } from './validators'; export { default as verifiedTokens } from './verifiedTokens'; export { default as web3Wallet } from './web3Wallet'; export { default as xStarScore } from './xStarScore'; +export { default as zetachain } from './zetachain'; diff --git a/configs/app/features/zetachain.ts b/configs/app/features/zetachain.ts new file mode 100644 index 0000000000..2a0a42be49 --- /dev/null +++ b/configs/app/features/zetachain.ts @@ -0,0 +1,29 @@ +import type { Feature } from './types'; + +import apis from '../apis'; +import { getEnvValue, getExternalAssetFilePath } from '../utils'; + +const title = 'ZetaChain transactions'; + +const chainsConfigUrl = getExternalAssetFilePath('NEXT_PUBLIC_ZETACHAIN_SERVICE_CHAINS_CONFIG_URL'); +const cosmosTxUrlTemplate = getEnvValue('NEXT_PUBLIC_ZETACHAIN_COSMOS_TX_URL_TEMPLATE'); +const cosmosAddressUrlTemplate = getEnvValue('NEXT_PUBLIC_ZETACHAIN_COSMOS_ADDRESS_URL_TEMPLATE'); + +const config: Feature<{ chainsConfigUrl: string; cosmosTxUrlTemplate: string; cosmosAddressUrlTemplate: string }> = (() => { + if (apis.zetachain && chainsConfigUrl && cosmosTxUrlTemplate && cosmosAddressUrlTemplate) { + return Object.freeze({ + title, + isEnabled: true, + chainsConfigUrl, + cosmosTxUrlTemplate, + cosmosAddressUrlTemplate, + }); + } + + return Object.freeze({ + title, + isEnabled: false, + }); +})(); + +export default config; diff --git a/configs/envs/.env.pw b/configs/envs/.env.pw index 901ff6ebb3..b8e96981fc 100644 --- a/configs/envs/.env.pw +++ b/configs/envs/.env.pw @@ -55,6 +55,7 @@ NEXT_PUBLIC_REWARDS_SERVICE_API_HOST=http://localhost:3009 NEXT_PUBLIC_MULTICHAIN_AGGREGATOR_API_HOST=http://localhost:3010 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_RE_CAPTCHA_APP_SITE_KEY=xxx NEXT_PUBLIC_WALLET_CONNECT_PROJECT_ID=xxx NEXT_PUBLIC_VIEWS_ADDRESS_FORMAT=['base16','bech32'] diff --git a/configs/envs/.env.zetachain b/configs/envs/.env.zetachain new file mode 100644 index 0000000000..a4d1e26c72 --- /dev/null +++ b/configs/envs/.env.zetachain @@ -0,0 +1,65 @@ +# Set of ENVs for ZetaChain Mainnet network explorer +# https://zetachain.blockscout.com +# This is an auto-generated file. To update all values, run "yarn dev:preset:sync --name=zetachain" + +# 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 + +# Instance ENVs +NEXT_PUBLIC_AD_BANNER_PROVIDER=none +NEXT_PUBLIC_AD_TEXT_PROVIDER=none +NEXT_PUBLIC_ADDRESS_3RD_PARTY_WIDGETS=['talentprotocol', 'efp', 'webacy', 'deepdao','bankless', 'blockscoutbadges'] +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=zetachain.blockscout.com +NEXT_PUBLIC_API_SPEC_URL=https://raw.githubusercontent.com/blockscout/blockscout-api-v2-swagger/main/swagger.yaml +NEXT_PUBLIC_CONTRACT_CODE_IDES=[{'title':'Remix IDE','url':'https://remix.ethereum.org/?address={hash}&blockscout={domain}','icon_url':'https://raw.githubusercontent.com/blockscout/frontend-configs/main/configs/ide-icons/remix.png'}] +NEXT_PUBLIC_CONTRACT_INFO_API_HOST=https://contracts-info.services.blockscout.com +NEXT_PUBLIC_DEFI_DROPDOWN_ITEMS=[{'text':'Revokescout','icon':'integration/partial','dappId':'revokescout'}, {'text':'Get gas','icon':'gas','dappId':'smol-refuel'}] +NEXT_PUBLIC_DEX_POOLS_ENABLED=true +NEXT_PUBLIC_FEATURED_NETWORKS=https://raw.githubusercontent.com/blockscout/frontend-configs/main/configs/featured-networks/zetachain-mainnet.json +NEXT_PUBLIC_GAME_BADGE_CLAIM_LINK=https://badges.blockscout.com/mint/sherblockHolmesBadge +NEXT_PUBLIC_GRAPHIQL_TRANSACTION=0x1a2da552c0082540ffa356eec24db742e9aa18e072a643feec6a958a76b02fdf +NEXT_PUBLIC_HAS_CONTRACT_AUDIT_REPORTS=true +NEXT_PUBLIC_HOMEPAGE_CHARTS=['daily_txs'] +NEXT_PUBLIC_HOMEPAGE_HERO_BANNER_CONFIG={'background':['linear-gradient(0, rgb(0,87,65), rgb(0,87,65))'],'text_color':['rgb(220, 254, 118)']} +NEXT_PUBLIC_IS_ACCOUNT_SUPPORTED=true +NEXT_PUBLIC_LOGOUT_URL=https://zetachain.us.auth0.com/v2/logout +NEXT_PUBLIC_MARKETPLACE_CATEGORIES_URL=https://raw.githubusercontent.com/blockscout/frontend-configs/main/configs/marketplace-categories/default.json +NEXT_PUBLIC_MARKETPLACE_ENABLED=true +NEXT_PUBLIC_MARKETPLACE_FEATURED_APP=rubic +NEXT_PUBLIC_MARKETPLACE_RATING_AIRTABLE_BASE_ID=appGkvtmKI7fXE4Vs +NEXT_PUBLIC_MARKETPLACE_SUBMIT_FORM=https://airtable.com/appiy5yijZpMMSKjT/shr6uMGPKjj1DK7NL +NEXT_PUBLIC_MARKETPLACE_SUGGEST_IDEAS_FORM=https://airtable.com/appiy5yijZpMMSKjT/pag3t82DUCyhGRZZO/form +NEXT_PUBLIC_METADATA_SERVICE_API_HOST=https://metadata.services.blockscout.com +NEXT_PUBLIC_NAME_SERVICE_API_HOST=https://bens.services.blockscout.com +NEXT_PUBLIC_NAVIGATION_HIGHLIGHTED_ROUTES=['/pools'] +NEXT_PUBLIC_NETWORK_CURRENCY_DECIMALS=18 +NEXT_PUBLIC_NETWORK_CURRENCY_NAME=ZETA +NEXT_PUBLIC_NETWORK_CURRENCY_SYMBOL=ZETA +NEXT_PUBLIC_NETWORK_EXPLORERS=[{'title':'GeckoTerminal','logo':'https://raw.githubusercontent.com/blockscout/frontend-configs/main/configs/explorer-logos/geckoterminal.png','baseUrl':'https://www.geckoterminal.com/','paths':{'token':'/zetachain/pools'}}] +NEXT_PUBLIC_NETWORK_ICON=https://raw.githubusercontent.com/blockscout/frontend-configs/main/configs/network-icons/zetachain.svg +NEXT_PUBLIC_NETWORK_ID=7000 +NEXT_PUBLIC_NETWORK_LOGO=https://raw.githubusercontent.com/blockscout/frontend-configs/main/configs/network-logos/zetachain.svg +NEXT_PUBLIC_NETWORK_NAME=ZetaChain Mainnet +NEXT_PUBLIC_NETWORK_RPC_URL=https://zetachain-evm.blockpi.network/v1/rpc/public +NEXT_PUBLIC_NETWORK_SHORT_NAME=ZetaChain Mainnet +NEXT_PUBLIC_OG_ENHANCED_DATA_ENABLED=true +NEXT_PUBLIC_OG_IMAGE_URL=https://raw.githubusercontent.com/blockscout/frontend-configs/main/configs/og-images/zetachain-mainnet.png +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://zetachain.blockscout.com +NEXT_PUBLIC_TRANSACTION_INTERPRETATION_PROVIDER=noves +NEXT_PUBLIC_VIEWS_ADDRESS_IDENTICON_TYPE=blockie +NEXT_PUBLIC_VIEWS_CONTRACT_SOLIDITYSCAN_ENABLED=true +NEXT_PUBLIC_VIEWS_TOKEN_SCAM_TOGGLE_ENABLED=true +NEXT_PUBLIC_VISUALIZE_API_HOST=https://visualizer.services.blockscout.com +NEXT_PUBLIC_ZETACHAIN_SERVICE_API_HOST=https://zetachain-cctx-mainnet.k8s-prod-2.blockscout.com +NEXT_PUBLIC_ZETACHAIN_SERVICE_CHAINS_CONFIG_URL=https://raw.githubusercontent.com/blockscout/frontend-configs/refs/heads/main/configs/cross-chain/zetachain-mainnet.json +NEXT_PUBLIC_ZETACHAIN_COSMOS_TX_URL_TEMPLATE=https://www.mintscan.io/cosmos/tx/{hash} +NEXT_PUBLIC_ZETACHAIN_COSMOS_ADDRESS_URL_TEMPLATE=https://www.mintscan.io/cosmos/address/{hash} \ No newline at end of file diff --git a/configs/envs/.env.zetachain_testnet b/configs/envs/.env.zetachain_testnet new file mode 100644 index 0000000000..da699e8cee --- /dev/null +++ b/configs/envs/.env.zetachain_testnet @@ -0,0 +1,56 @@ +# Set of ENVs for ZetaChain testnet network explorer +# https://zetachain-testnet.blockscout.com +# This is an auto-generated file. To update all values, run "yarn dev:preset:sync --name=zetachain_testnet" + +# 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 + +# Instance ENVs +NEXT_PUBLIC_AD_BANNER_PROVIDER=none +NEXT_PUBLIC_AD_TEXT_PROVIDER=none +NEXT_PUBLIC_ADDRESS_3RD_PARTY_WIDGETS=['talentprotocol', 'efp', 'webacy', 'deepdao', 'humanpassport', 'bankless', 'blockscoutbadges'] +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=zetachain-testnet.blockscout.com +NEXT_PUBLIC_API_SPEC_URL=https://raw.githubusercontent.com/blockscout/blockscout-api-v2-swagger/main/swagger.yaml +NEXT_PUBLIC_CONTRACT_CODE_IDES=[{'title':'Remix IDE','url':'https://remix.ethereum.org/?address={hash}&blockscout={domain}','icon_url':'https://raw.githubusercontent.com/blockscout/frontend-configs/main/configs/ide-icons/remix.png'}] +NEXT_PUBLIC_CONTRACT_INFO_API_HOST=https://contracts-info.services.blockscout.com +NEXT_PUBLIC_FEATURED_NETWORKS=https://raw.githubusercontent.com/blockscout/frontend-configs/main/configs/featured-networks/zetachain-athens-3.json +NEXT_PUBLIC_GAME_BADGE_CLAIM_LINK=https://badges.blockscout.com/mint/sherblockHolmesBadge +NEXT_PUBLIC_GRAPHIQL_TRANSACTION=0x1a2da552c0082540ffa356eec24db742e9aa18e072a643feec6a958a76b02fdf +NEXT_PUBLIC_HOMEPAGE_CHARTS=['daily_txs'] +NEXT_PUBLIC_HOMEPAGE_HERO_BANNER_CONFIG={'background':['linear-gradient(0, rgb(0,87,65), rgb(0,87,65))'],'text_color':['rgb(220, 254, 118)']} +NEXT_PUBLIC_IS_ACCOUNT_SUPPORTED=true +NEXT_PUBLIC_IS_TESTNET=true +NEXT_PUBLIC_LOGOUT_URL=https://zetachain.us.auth0.com/v2/logout +NEXT_PUBLIC_MARKETPLACE_ENABLED=false +NEXT_PUBLIC_METADATA_SERVICE_API_HOST=https://metadata.services.blockscout.com +NEXT_PUBLIC_NETWORK_CURRENCY_DECIMALS=18 +NEXT_PUBLIC_NETWORK_CURRENCY_NAME=tZETA +NEXT_PUBLIC_NETWORK_CURRENCY_SYMBOL=tZETA +NEXT_PUBLIC_NETWORK_ICON=https://raw.githubusercontent.com/blockscout/frontend-configs/main/configs/network-icons/zetachain.svg +NEXT_PUBLIC_NETWORK_ID=7001 +NEXT_PUBLIC_NETWORK_LOGO=https://raw.githubusercontent.com/blockscout/frontend-configs/main/configs/network-logos/zetachain.svg +NEXT_PUBLIC_NETWORK_NAME=ZetaChain testnet +NEXT_PUBLIC_NETWORK_RPC_URL=https://zetachain-athens-evm.blockpi.network/v1/rpc/public +NEXT_PUBLIC_NETWORK_SHORT_NAME=ZetaChain testnet +NEXT_PUBLIC_OG_ENHANCED_DATA_ENABLED=true +NEXT_PUBLIC_OG_IMAGE_URL=https://raw.githubusercontent.com/blockscout/frontend-configs/main/configs/og-images/zetachain-testnet.png +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://zetachain-testnet.blockscout.com +NEXT_PUBLIC_TRANSACTION_INTERPRETATION_PROVIDER=noves +NEXT_PUBLIC_VIEWS_ADDRESS_IDENTICON_TYPE=blockie +NEXT_PUBLIC_VIEWS_CONTRACT_SOLIDITYSCAN_ENABLED=true +NEXT_PUBLIC_VIEWS_TOKEN_SCAM_TOGGLE_ENABLED=true +NEXT_PUBLIC_VISUALIZE_API_HOST=https://visualizer.services.blockscout.com +NEXT_PUBLIC_ZETACHAIN_SERVICE_API_HOST=https://zetachain-cctx-testnet.k8s-prod-1.blockscout.com +NEXT_PUBLIC_ZETACHAIN_SERVICE_CHAINS_CONFIG_URL=https://raw.githubusercontent.com/blockscout/frontend-configs/refs/heads/main/configs/cross-chain/zetachain-testnet.json +NEXT_PUBLIC_ZETACHAIN_COSMOS_TX_URL_TEMPLATE=https://www.mintscan.io/cosmos/tx/{hash} +NEXT_PUBLIC_ZETACHAIN_COSMOS_ADDRESS_URL_TEMPLATE=https://www.mintscan.io/cosmos/address/{hash} +NEXT_PUBLIC_DATA_AVAILABILITY_ENABLED=true \ No newline at end of file diff --git a/deploy/scripts/download_assets.sh b/deploy/scripts/download_assets.sh index 5b95ac5a08..e8565eb60a 100755 --- a/deploy/scripts/download_assets.sh +++ b/deploy/scripts/download_assets.sh @@ -26,6 +26,7 @@ ASSETS_ENVS=( "NEXT_PUBLIC_NETWORK_ICON_DARK" "NEXT_PUBLIC_OG_IMAGE_URL" "NEXT_PUBLIC_ADDRESS_3RD_PARTY_WIDGETS_CONFIG_URL" + "NEXT_PUBLIC_ZETACHAIN_SERVICE_CHAINS_CONFIG_URL" ) # Create the assets directory if it doesn't exist diff --git a/deploy/tools/envs-validator/index.ts b/deploy/tools/envs-validator/index.ts index 2a1bfd4fbe..7383f4ec08 100644 --- a/deploy/tools/envs-validator/index.ts +++ b/deploy/tools/envs-validator/index.ts @@ -42,6 +42,7 @@ async function validateEnvs(appEnvs: Record) { 'NEXT_PUBLIC_MARKETPLACE_GRAPH_LINKS_URL', 'NEXT_PUBLIC_FOOTER_LINKS', 'NEXT_PUBLIC_ADDRESS_3RD_PARTY_WIDGETS_CONFIG_URL', + 'NEXT_PUBLIC_ZETACHAIN_SERVICE_CHAINS_CONFIG_URL', ]; for await (const envName of envsWithJsonConfig) { diff --git a/deploy/tools/envs-validator/schema.ts b/deploy/tools/envs-validator/schema.ts index 1a86aa30fb..e06a1e1477 100644 --- a/deploy/tools/envs-validator/schema.ts +++ b/deploy/tools/envs-validator/schema.ts @@ -49,6 +49,7 @@ import type { TxExternalTxsConfig } from '../../../types/client/externalTxsConfi import { replaceQuotes } from '../../../configs/app/utils'; import * as regexp from '../../../toolkit/utils/regexp'; import type { IconName } from '../../../ui/shared/IconSvg'; +import type { ChainInfo } from '../../../types/client/chainInfo'; const protocols = [ 'http', 'https' ]; @@ -679,12 +680,67 @@ const multichainProviderConfigSchema: yup.ObjectSchema dapp_id: yup.string(), }); -const externalTxsConfigSchema: yup.ObjectSchema = yup.object({ +const zetaChainCCTXConfigSchema: yup.ObjectSchema = yup.object({ + chain_id: yup.number().required(), chain_name: yup.string().required(), - chain_logo_url: yup.string().required(), - explorer_url_template: yup.string().required(), + chain_logo: yup.string(), + instance_url: yup.string(), + address_url_template: yup.string(), + tx_url_template: yup.string(), }); +const zetaChainSchema = yup + .object() + .shape({ + NEXT_PUBLIC_ZETACHAIN_SERVICE_API_HOST: yup.string().test(urlTest), + NEXT_PUBLIC_ZETACHAIN_SERVICE_CHAINS_CONFIG_URL: yup + .array() + .transform(replaceQuotes) + .json() + .of(zetaChainCCTXConfigSchema) + .when('NEXT_PUBLIC_ZETACHAIN_SERVICE_API_HOST', { + is: (value: string) => Boolean(value), + then: (schema) => schema, + otherwise: (schema) => schema.test( + 'not-exist', + 'NEXT_PUBLIC_ZETACHAIN_SERVICE_CHAINS_CONFIG_URL cannot be used if NEXT_PUBLIC_ZETACHAIN_SERVICE_API_HOST is not set', + value => value === undefined, + ), + }), + NEXT_PUBLIC_ZETACHAIN_COSMOS_TX_URL_TEMPLATE: yup + .string() + .when('NEXT_PUBLIC_ZETACHAIN_SERVICE_API_HOST', { + is: (value: string) => Boolean(value), + then: (schema) => schema, + otherwise: (schema) => schema.test( + 'not-exist', + 'NEXT_PUBLIC_ZETACHAIN_COSMOS_TX_URL_TEMPLATE cannot be used if NEXT_PUBLIC_ZETACHAIN_SERVICE_API_HOST is not set', + value => value === undefined, + ), + }), + NEXT_PUBLIC_ZETACHAIN_COSMOS_ADDRESS_URL_TEMPLATE: yup + .string() + .when('NEXT_PUBLIC_ZETACHAIN_SERVICE_API_HOST', { + is: (value: string) => Boolean(value), + then: (schema) => schema, + otherwise: (schema) => schema.test( + 'not-exist', + 'NEXT_PUBLIC_ZETACHAIN_COSMOS_ADDRESS_URL_TEMPLATE cannot be used if NEXT_PUBLIC_ZETACHAIN_SERVICE_API_HOST is not set', + value => value === undefined, + ), + }), + }) + .test('zetachain-api-host-dependency', 'NEXT_PUBLIC_ZETACHAIN_SERVICE_API_HOST cannot be used without NEXT_PUBLIC_ZETACHAIN_SERVICE_CHAINS_CONFIG_URL', function(value) { + const hasApiHost = Boolean(value?.NEXT_PUBLIC_ZETACHAIN_SERVICE_API_HOST); + const hasChainsConfig = Boolean(value?.NEXT_PUBLIC_ZETACHAIN_SERVICE_CHAINS_CONFIG_URL?.length); + + if (hasApiHost && !hasChainsConfig) { + return this.createError({ message: 'NEXT_PUBLIC_ZETACHAIN_SERVICE_API_HOST cannot be used without NEXT_PUBLIC_ZETACHAIN_SERVICE_CHAINS_CONFIG_URL' }); + } + + return true; + }); + const address3rdPartyWidgetsConfigSchema = yup .object() .shape({ @@ -1152,6 +1208,7 @@ const schema = yup NEXT_PUBLIC_GROWTH_BOOK_CLIENT_KEY: yup.string(), NEXT_PUBLIC_ROLLBAR_CLIENT_TOKEN: yup.string(), + // Misc NEXT_PUBLIC_USE_NEXT_JS_PROXY: yup.boolean(), }) @@ -1169,6 +1226,7 @@ const schema = yup .concat(address3rdPartyWidgetsConfigSchema) .concat(addressMetadataSchema) .concat(userOpsSchema) - .concat(flashblocksSchema); + .concat(flashblocksSchema) + .concat(zetaChainSchema); export default schema; diff --git a/deploy/tools/envs-validator/test/.env.zetachain b/deploy/tools/envs-validator/test/.env.zetachain new file mode 100644 index 0000000000..d75d446c93 --- /dev/null +++ b/deploy/tools/envs-validator/test/.env.zetachain @@ -0,0 +1,3 @@ + +NEXT_PUBLIC_ZETACHAIN_SERVICE_API_HOST=https://zetachain-indexer.duckdns.org +NEXT_PUBLIC_ZETACHAIN_SERVICE_CHAINS_CONFIG_URL=https://example.com \ No newline at end of file diff --git a/deploy/tools/envs-validator/test/assets/configs/zetachain_service_chains_config.json b/deploy/tools/envs-validator/test/assets/configs/zetachain_service_chains_config.json new file mode 100644 index 0000000000..5f83cad4dd --- /dev/null +++ b/deploy/tools/envs-validator/test/assets/configs/zetachain_service_chains_config.json @@ -0,0 +1,14 @@ +[ + { + "chain_id": 7000, + "chain_name": "ZetaChain Athens", + "chain_logo": "https://example.com/zetachain-logo.svg", + "instance_url": "https://zetachain-indexer.duckdns.org" + }, + { + "chain_id": 7001, + "chain_name": "ZetaChain Athens Testnet", + "chain_logo": "https://example.com/zetachain-testnet-logo.svg", + "instance_url": "https://zetachain-testnet-indexer.duckdns.org" + } +] diff --git a/docs/ENVS.md b/docs/ENVS.md index 9f042370c8..d8fd647b02 100644 --- a/docs/ENVS.md +++ b/docs/ENVS.md @@ -79,6 +79,7 @@ All json-like values should be single-quoted. If it contains a hash (`#`) or a d - [DEX pools](#dex-pools) - [Flashblocks](#flashblocks) - [Address 3rd party widgets](#address-3rd-party-widgets) + - [ZetaChain](#zetachain) - [3rd party services configuration](#external-services-configuration)   @@ -949,7 +950,7 @@ This feature allows users to view [Flashblocks](https://docs.base.org/base-chain   -### Address 3rd party widgets +#### Address 3rd party widget This feature allows to display widgets on the address page with data from 3rd party services. @@ -971,6 +972,33 @@ This feature allows to display widgets on the address page with data from 3rd pa | pages | `Array<'eoa' \| 'contract' \| 'token'>` | List of pages where the widget should be displayed | Required | - | `['eoa']` | | chainIds | `Record` | Mapping of chain IDs to custom values that will be used in `url` template | - | - | `{'1': 'eth', '10': 'op'}` | +  + + +### ZetaChain cross-chain transactions + +This feature enables cross-chain transactions pages and views on zetaChain instances + +| Variable | Type| Description | Compulsoriness | Default value | Example value | Version | +| --- | --- | --- | --- | --- | --- | --- | +| NEXT_PUBLIC_ZETACHAIN_SERVICE_API_HOST | `string` | ZetaChain cross-chain transactions service API endpoint url | - | - | `https://zetachain-cctx.services.blockscout.com` | v2.3.0+ | +| NEXT_PUBLIC_ZETACHAIN_SERVICE_CHAINS_CONFIG_URL | `string` | URL of configuration file (`.json` format only) which contains chains info for the supported chains. | - | - | `https://example.com/zetachain_chains_config.json` | v2.3.0+ | +| NEXT_PUBLIC_ZETACHAIN_COSMOS_TX_URL_TEMPLATE | `string` | URL template to redirect cosmos tx search. | - | - | `https://example.com/cosmos/tx/{hash}` | v2.3.0+ | +| NEXT_PUBLIC_ZETACHAIN_COSMOS_ADDRESS_URL_TEMPLATE | `string` URL template to redirect cosmos address search. | - | - | `https://example.com/cosmos/address/{hash}` | v2.3.0+ | + + +#### ZetaChain supported cain configuration properties + +| Property | Type | Description | Compulsoriness | Example value | +| --- | --- | --- | --- | --- | +| chain_id | `string` | Id of the chain | Required | - | `'11155111'` | +| chain_name | `string` | Displayed name of the chain | Required | - | `'Sepolia Testnet'` | +| chain_logo | `string` | Chain logo URL. Image should be at least 40x40 px | - | - | `'https://example.com/logo.svg'` | +| instance_url | `string` | Base URL of the blockscout explorer for the chain | - | - | `'https://eth-sepolia.blockscout.com/'` | +| address_url_template | `string` | Address url template on external explorer. `{hash}` will be replaced with the address hash | - | - | `'https://external.explorer.com/address/{hash}'` | +| tx_url_template | `string` | Transaction url template on external explorer. `{hash}` will be replaced with the transaction hash | - | - | `'https://external.explorer.com/tx/{hash}'` | + +   ### Badge claim link diff --git a/icons/interop_slim.svg b/icons/interop_slim.svg new file mode 100644 index 0000000000..415404e5b8 --- /dev/null +++ b/icons/interop_slim.svg @@ -0,0 +1,4 @@ + + + + diff --git a/lib/address/cosmos.ts b/lib/address/cosmos.ts new file mode 100644 index 0000000000..0db3696778 --- /dev/null +++ b/lib/address/cosmos.ts @@ -0,0 +1,20 @@ +import config from 'configs/app'; + +const COSMOS_TX_HASH_REGEXP = /^[A-F0-9]{64}$/i; +const COSMOS_ADDRESS_REGEXP = /^cosmos[a-z0-9]{39}$/i; + +export type CosmosHashType = 'tx' | 'address' | null; + +export function checkCosmosHash(hash: string): CosmosHashType { + if (config.features.zetachain.isEnabled) { + if (COSMOS_TX_HASH_REGEXP.test(hash)) { + return 'tx'; + } + + if (COSMOS_ADDRESS_REGEXP.test(hash)) { + return 'address'; + } + } + + return null; +} diff --git a/lib/api/resources.ts b/lib/api/resources.ts index 2025a8eca5..b080da9ccc 100644 --- a/lib/api/resources.ts +++ b/lib/api/resources.ts @@ -26,6 +26,8 @@ import { USER_OPS_API_RESOURCES } from './services/userOps'; import type { IsPaginated } from './services/utils'; import { VISUALIZE_API_RESOURCES } from './services/visualize'; import type { VisualizeApiResourceName, VisualizeApiResourcePayload } from './services/visualize'; +import { ZETA_CHAIN_API_RESOURCES } from './services/zetaChain'; +import type { ZetaChainApiPaginationFilters, ZetaChainApiResourceName, ZetaChainApiResourcePayload } from './services/zetaChain'; export const RESOURCES = { admin: ADMIN_API_RESOURCES, @@ -39,6 +41,7 @@ export const RESOURCES = { tac: TAC_OPERATION_LIFECYCLE_API_RESOURCES, userOps: USER_OPS_API_RESOURCES, visualize: VISUALIZE_API_RESOURCES, + zetachain: ZETA_CHAIN_API_RESOURCES, } satisfies Record>; export const resourceKey = (x: ResourceName) => x; @@ -61,6 +64,7 @@ R extends RewardsApiResourceName ? RewardsApiResourcePayload : R extends StatsApiResourceName ? StatsApiResourcePayload : R extends TacOperationLifecycleApiResourceName ? TacOperationLifecycleApiResourcePayload : R extends VisualizeApiResourceName ? VisualizeApiResourcePayload : +R extends ZetaChainApiResourceName ? ZetaChainApiResourcePayload : never; /* eslint-enable @stylistic/indent */ @@ -93,6 +97,7 @@ R extends GeneralApiResourceName ? GeneralApiPaginationFilters : R extends ContractInfoApiResourceName ? ContractInfoApiPaginationFilters : R extends MultichainApiResourceName ? MultichainApiPaginationFilters : R extends TacOperationLifecycleApiResourceName ? TacOperationLifecycleApiPaginationFilters : +R extends ZetaChainApiResourceName ? ZetaChainApiPaginationFilters : never; /* eslint-enable @stylistic/indent */ diff --git a/lib/api/services/zetaChain.ts b/lib/api/services/zetaChain.ts new file mode 100644 index 0000000000..19c3564f8c --- /dev/null +++ b/lib/api/services/zetaChain.ts @@ -0,0 +1,46 @@ +import type { ApiResource } from '../types'; +import type { ZetaChainCCTXFilterParams, ZetaChainCCTXListResponse, ZetaChainCCTXResponse, ZetaChainTokensResponse } from 'types/api/zetaChain'; + +export const ZETA_CHAIN_API_RESOURCES = { + transactions: { + path: '/api/v1/CctxInfo\\:list', + filterFields: [ + 'limit' as const, + 'offset' as const, + 'status_reduced' as const, + 'start_timestamp' as const, + 'end_timestamp' as const, + 'sender_address' as const, + 'receiver_address' as const, + 'source_chain_id' as const, + 'target_chain_id' as const, + 'token_symbol' as const, + 'hash' as const, + 'age' as const, // frontend only + ], + paginated: true, + }, + transaction: { + path: '/api/v1/CctxInfo\\:get', + filterFields: [ 'cctx_id' as const ], + }, + tokens: { + path: '/api/v1/TokenInfo\\:list', + }, +} satisfies Record; + +export type ZetaChainApiResourceName = `zetachain:${ keyof typeof ZETA_CHAIN_API_RESOURCES }`; + +/* eslint-disable @stylistic/indent */ +export type ZetaChainApiResourcePayload = +R extends 'zetachain:transactions' ? ZetaChainCCTXListResponse : +R extends 'zetachain:transaction' ? ZetaChainCCTXResponse : +R extends 'zetachain:tokens' ? ZetaChainTokensResponse : +never; +/* eslint-enable @stylistic/indent */ + +/* eslint-disable @stylistic/indent */ +export type ZetaChainApiPaginationFilters = +R extends 'zetachain:transactions' ? ZetaChainCCTXFilterParams : +never; +/* eslint-enable @stylistic/indent */ diff --git a/lib/api/types.ts b/lib/api/types.ts index dcee835b85..ee7a4a084a 100644 --- a/lib/api/types.ts +++ b/lib/api/types.ts @@ -1,4 +1,16 @@ -export type ApiName = 'general' | 'admin' | 'bens' | 'contractInfo' | 'metadata' | 'multichain' | 'rewards' | 'stats' | 'tac' | 'userOps' | 'visualize'; +export type ApiName = +'general' | +'admin' | +'bens' | +'contractInfo' | +'metadata' | +'multichain' | +'rewards' | +'stats' | +'tac' | +'userOps' | +'visualize' | +'zetachain'; export interface ApiResource { path: string; diff --git a/lib/api/useApiFetch.tsx b/lib/api/useApiFetch.tsx index f4a1a861f9..d7ed1f367a 100644 --- a/lib/api/useApiFetch.tsx +++ b/lib/api/useApiFetch.tsx @@ -36,7 +36,6 @@ export default function useApiFetch() { { pathParams, queryParams, fetchParams, logError, chain }: Params = {}, ) => { const apiToken = cookies.get(cookies.NAMES.API_TOKEN); - const { api, apiName, resource } = getResourceParams(resourceName, chain); const url = buildUrl(resourceName, pathParams, queryParams, undefined, chain); const withBody = isBodyAllowed(fetchParams?.method); diff --git a/lib/base64ToHex.ts b/lib/base64ToHex.ts new file mode 100644 index 0000000000..fa719a0a9c --- /dev/null +++ b/lib/base64ToHex.ts @@ -0,0 +1,6 @@ +import bytesToHex from './bytesToHex'; + +export default function base64ToHex(base64: string): string { + const bytes = Uint8Array.from(atob(base64), c => c.charCodeAt(0)); + return bytesToHex(bytes, false); +} diff --git a/lib/bytesToHex.ts b/lib/bytesToHex.ts index be2a1c2757..43a7f4fb3b 100644 --- a/lib/bytesToHex.ts +++ b/lib/bytesToHex.ts @@ -1,8 +1,8 @@ -export default function bytesToBase64(bytes: Uint8Array) { +export default function bytesToBase64(bytes: Uint8Array, addPrefix = true) { let result = ''; for (const byte of bytes) { result += Number(byte).toString(16).padStart(2, '0'); } - return `0x${ result }`; + return addPrefix ? `0x${ result }` : result; } diff --git a/lib/hooks/useNavItems.tsx b/lib/hooks/useNavItems.tsx index 4972ed9fe4..7679006b62 100644 --- a/lib/hooks/useNavItems.tsx +++ b/lib/hooks/useNavItems.tsx @@ -22,6 +22,7 @@ export function isInternalItem(item: NavItem): item is NavItemInternal { export default function useNavItems(): ReturnType { const router = useRouter(); const pathname = router.pathname; + const query = router.query; return React.useMemo(() => { let blockchainNavItems: Array | Array> = []; @@ -42,8 +43,18 @@ export default function useNavItems(): ReturnType { text: 'Transactions', nextRoute: { pathname: '/txs' as const }, icon: 'transactions', - isActive: pathname === '/txs' || pathname === '/tx/[hash]' || pathname === '/chain/[chain-slug]/tx/[hash]', + isActive: + // sorry, but this is how it was designed + (pathname === '/txs' && (!config.features.zetachain.isEnabled || !query.tab || !query.tab.includes('cctx'))) || + pathname === '/tx/[hash]' || + pathname === '/chain/[chain-slug]/tx/[hash]', }; + const cctxs: NavItem | null = config.features.zetachain.isEnabled ? { + text: 'Cross-chain transactions', + nextRoute: { pathname: '/txs' as const, query: { tab: 'cctx' } }, + icon: 'interop', + isActive: pathname === '/cc/tx/[hash]' || (pathname === '/txs' && query.tab?.includes('cctx')), + } : null; const operations: NavItem | null = config.features.tac.isEnabled ? { text: 'Operations', nextRoute: { pathname: '/operations' as const }, @@ -200,6 +211,7 @@ export default function useNavItems(): ReturnType { txs, operations, internalTxs, + cctxs, userOps, blocks, epochs, @@ -346,5 +358,5 @@ export default function useNavItems(): ReturnType { ].filter(Boolean); return { mainNavItems, accountNavItems }; - }, [ pathname ]); + }, [ pathname, query ]); } diff --git a/lib/metadata/getPageOgType.ts b/lib/metadata/getPageOgType.ts index aaeee00ed9..c4e86b1765 100644 --- a/lib/metadata/getPageOgType.ts +++ b/lib/metadata/getPageOgType.ts @@ -64,6 +64,7 @@ const OG_TYPE_DICT: Record = { '/interop-messages': 'Root page', '/operations': 'Root page', '/operation/[id]': 'Regular page', + '/cc/tx/[hash]': 'Regular page', // multichain routes '/chain/[chain-slug]/accounts/label/[slug]': 'Root page', diff --git a/lib/metadata/templates/description.ts b/lib/metadata/templates/description.ts index df5852be0a..9b187a3fbb 100644 --- a/lib/metadata/templates/description.ts +++ b/lib/metadata/templates/description.ts @@ -67,6 +67,7 @@ const TEMPLATE_MAP: Record = { '/interop-messages': DEFAULT_TEMPLATE, '/operations': DEFAULT_TEMPLATE, '/operation/[id]': DEFAULT_TEMPLATE, + '/cc/tx/[hash]': DEFAULT_TEMPLATE, // multichain routes '/chain/[chain-slug]/accounts/label/[slug]': DEFAULT_TEMPLATE, diff --git a/lib/metadata/templates/title.ts b/lib/metadata/templates/title.ts index fcb9376d65..c3cc92fe06 100644 --- a/lib/metadata/templates/title.ts +++ b/lib/metadata/templates/title.ts @@ -64,6 +64,7 @@ const TEMPLATE_MAP: Record = { '/interop-messages': '%network_name% interop messages', '/operations': '%network_name% operations', '/operation/[id]': '%network_name% operation %id%', + '/cc/tx/[hash]': '%network_name% cross-chain transaction %hash% details', // multichain routes '/chain/[chain-slug]/accounts/label/[slug]': '%network_name% addresses search by label', diff --git a/lib/mixpanel/getPageType.ts b/lib/mixpanel/getPageType.ts index 0f41dcbd11..6a3e482ae7 100644 --- a/lib/mixpanel/getPageType.ts +++ b/lib/mixpanel/getPageType.ts @@ -62,6 +62,7 @@ export const PAGE_TYPE_DICT: Record = { '/interop-messages': 'Interop messages', '/operations': 'Operations', '/operation/[id]': 'Operation details', + '/cc/tx/[hash]': 'Cross-chain transaction details', // multichain routes '/chain/[chain-slug]/accounts/label/[slug]': 'Chain addresses search by label', diff --git a/lib/socket/context.tsx b/lib/socket/context.tsx index 97c231c9a5..5b459fdb3a 100644 --- a/lib/socket/context.tsx +++ b/lib/socket/context.tsx @@ -5,18 +5,29 @@ import React, { useEffect, useState } from 'react'; type ChannelRegistry = Record; -export const SocketContext = React.createContext<{ +const socketContexts = new Map; -} | null>(null); +} | null>>(); + +function getSocketContext(name: string) { + if (!socketContexts.has(name)) { + socketContexts.set(name, React.createContext<{ + socket: Socket | null; + channelRegistry: React.MutableRefObject; + } | null>(null)); + } + return socketContexts.get(name)!; +} interface SocketProviderProps { children: React.ReactNode; url?: string; options?: Partial; + name?: string; } -export function SocketProvider({ children, options, url }: SocketProviderProps) { +export function SocketProvider({ children, options, url, name = 'default' }: SocketProviderProps) { const [ socket, setSocket ] = useState(null); const channelRegistry = React.useRef({}); @@ -40,6 +51,8 @@ export function SocketProvider({ children, options, url }: SocketProviderProps) channelRegistry, }), [ socket, channelRegistry ]); + const SocketContext = getSocketContext(name); + return ( { children } @@ -47,10 +60,12 @@ export function SocketProvider({ children, options, url }: SocketProviderProps) ); } -export function useSocket() { +// Hook to use a specific named socket +export function useSocket(name: string = 'default') { + const SocketContext = getSocketContext(name); const context = React.useContext(SocketContext); if (context === undefined) { - throw new Error('useSocket must be used within a SocketProvider'); + throw new Error(`useSocket must be used within a SocketProvider with name "${ name }"`); } return context; } diff --git a/lib/socket/types.ts b/lib/socket/types.ts index 189b320b62..93016abdbf 100644 --- a/lib/socket/types.ts +++ b/lib/socket/types.ts @@ -9,6 +9,7 @@ import type { RawTracesResponse } from 'types/api/rawTrace'; import type { TokenInstanceMetadataSocketMessage } from 'types/api/token'; import type { TokenTransfer } from 'types/api/tokenTransfer'; import type { Transaction } from 'types/api/transaction'; +import type { ZetaChainCCTX } from 'types/api/zetaChain'; import type { NewZkEvmBatchSocketResponse } from 'types/api/zkEvmL2'; export type SocketMessageParams = SocketMessage.NewBlock | @@ -44,6 +45,7 @@ SocketMessage.TokenInstanceMetadataFetched | SocketMessage.ContractVerification | SocketMessage.NewZkEvmL2Batch | SocketMessage.NewArbitrumL2Batch | +SocketMessage.NewZetaChainCCTXs | SocketMessage.Unknown; interface SocketMessageParamsGeneric { @@ -87,5 +89,6 @@ export namespace SocketMessage { export type ContractVerification = SocketMessageParamsGeneric<'verification_result', SmartContractVerificationResponse>; export type NewZkEvmL2Batch = SocketMessageParamsGeneric<'new_zkevm_confirmed_batch', NewZkEvmBatchSocketResponse>; export type NewArbitrumL2Batch = SocketMessageParamsGeneric<'new_arbitrum_batch', NewArbitrumBatchSocketResponse>; + export type NewZetaChainCCTXs = SocketMessageParamsGeneric<'new_cctxs', Array>; export type Unknown = SocketMessageParamsGeneric; } diff --git a/lib/socket/useSocketChannel.tsx b/lib/socket/useSocketChannel.tsx index 665f70c95c..49b0e2425f 100644 --- a/lib/socket/useSocketChannel.tsx +++ b/lib/socket/useSocketChannel.tsx @@ -10,10 +10,11 @@ interface Params { onJoin?: (channel: Channel, message: unknown) => void; onSocketClose?: () => void; onSocketError?: () => void; + socketName?: string; } -export default function useSocketChannel({ topic, params, isDisabled, onJoin, onSocketClose, onSocketError }: Params) { - const { socket, channelRegistry } = useSocket() || {}; +export default function useSocketChannel({ topic, params, isDisabled, onJoin, onSocketClose, onSocketError, socketName }: Params) { + const { socket, channelRegistry } = useSocket(socketName) || {}; const [ channel, setChannel ] = useState(); const onCloseRef = useRef(undefined); const onErrorRef = useRef(undefined); diff --git a/mocks/zetaChain/zetaChainCCTX.ts b/mocks/zetaChain/zetaChainCCTX.ts new file mode 100644 index 0000000000..851d6a11fb --- /dev/null +++ b/mocks/zetaChain/zetaChainCCTX.ts @@ -0,0 +1,265 @@ +import type { ZetaChainCCTX, ZetaChainCCTXResponse } from 'types/api/zetaChain'; + +export const zetaChainCCTXItem: ZetaChainCCTX = { + index: '0xaea405aa63353312727dcc471e3242d3b8de0a181d6e35fe905fff4084bd3cc1', + status: 'PENDING_OUTBOUND', + status_reduced: 'PENDING', + amount: '185354164223052', + source_chain_id: 7001, + target_chain_id: 11155111, + created_timestamp: '1641139800', + last_update_timestamp: '1641139818', + sender_address: '0xbE8b5d82DDE00677cCdb9dc22071CF635d459223', + receiver_address: '0xcd54C6C6daEF72B04747F10A853a10c9Bef63286', + asset: '0x1234567890123456789012345678901234567890', + coin_type: 'ERC20', + token_symbol: 'USDT.ARBSEP', +}; + +export const zetaChainCCTX: ZetaChainCCTXResponse = { + creator: '', + index: '0x1c1e7410d7dfefe6173cc11efa47221e85587d3831c69108121198e0b2a86657', + zeta_fees: '0', + relayed_message: '0x0000000000000000000000000000000000000000000000000000000000000000', + cctx_status_reduced: 'SUCCESS', + token_symbol: 'USDT.ARBSEP', + token_name: 'USDT.ARBSEP', + zrc20_contract_address: '0x1234567890123456789012345678901234567890', + icon_url: 'https://example.com/icon.png', + decimals: 6, + cctx_status: { + status: 'OUTBOUND_MINED', + status_message: '', + error_message: '', + last_update_timestamp: '1641139818', + is_abort_refunded: false, + created_timestamp: '0', + error_message_revert: '', + error_message_abort: '', + status_reduced: 'SUCCESS', + }, + inbound_params: { + sender: '0x44D1F1f9289DBA1Cf5824bd667184cEBE020aA1c', + sender_chain_id: 7001, + tx_origin: '0xcf558D29999C119425d28bF1c07ba97FfF39e387', + coin_type: 'GAS', + asset: '', + amount: '434880247204065094', + observed_hash: '0x0001150419abd8d8383fae702f3a8415e57c96e78c9815756d15e1f1f5c0f466', + observed_external_height: '1345648', + ballot_index: '0x1c1e7410d7dfefe6173cc11efa47221e85587d3831c69108121198e0b2a86657', + finalized_zeta_height: '0', + tx_finalization_status: 'NOT_FINALIZED', + is_cross_chain_call: false, + status: 'SUCCESS', + confirmation_mode: 'SAFE', + }, + outbound_params: [ + { + receiver: '0xcf558D29999C119425d28bF1c07ba97FfF39e387', + receiver_chain_id: 97, + coin_type: 'GAS', + amount: '434880247204065094', + tss_nonce: '62305', + gas_limit: '21000', + gas_price: '4000000000', + gas_priority_fee: '', + hash: '0xe209ab8ee452d5ee3bc98e5035d33eb4d3f314880f8c956be2c0f78a26bfc37d', + ballot_index: '0x9c5cc2a0ad2abba365105f1414ea261153634a5882bede9a883e0bb4f982cc55', + observed_external_height: '32787262', + gas_used: '0', + effective_gas_price: '0', + effective_gas_limit: '0', + tss_pubkey: '', + tx_finalization_status: 'NOT_FINALIZED', + call_options: { + gas_limit: '0', + is_arbitrary_call: false, + }, + confirmation_mode: 'SAFE', + }, + ], + protocol_contract_version: 'V1', + related_cctxs: [ + { + index: '0x0001150419abd8d8383fae702f3a8415e57c96e78c9815756d15e1f1f5c0f466', + depth: 0, + source_chain_id: 5, + status: 'OUTBOUND_MINED', + inbound_amount: '100000000000000000', + inbound_coin_type: 'GAS', + outbound_params: [ + { amount: '0', chain_id: 7001, coin_type: 'GAS' }, + ], + status_reduced: 'SUCCESS', + }, + ], +}; + +export const zetaChainCCTXFailed: ZetaChainCCTXResponse = { + creator: 'zeta18pksjzclks34qkqyaahf2rakss80mnusju77cm', + index: '0x004d60b58cbbead6ddc0a100e17b88484f72e4f47b10c4b560a41cadd3315c4c', + zeta_fees: '0', + // eslint-disable-next-line max-len + relayed_message: '00000000000000000000000000000000000000000000000000000000000000400000000000000000000000006b513b40ebc0b4d7b197730476ed3324346f28220000000000000000000000000000000000000000000000000000000000000014dcbfa87533a478743b3f507e76170ea6f26fa69a000000000000000000000000', + cctx_status_reduced: 'FAILED', + token_symbol: 'UPKRW.SEPOLIA', + token_name: 'ZetaChain ZRC20 UPKRW on Sepolia', + zrc20_contract_address: '0xA614Aebf7924A3Eb4D066aDCA5595E4980407f1d', + icon_url: null, + decimals: 6, + cctx_status: { + status: 'REVERTED', + status_message: '', + // eslint-disable-next-line max-len + error_message: '{"type":"contract_call_error","message":"contract call failed when calling EVM with data","error":"execution reverted: ret 0x: evm transaction execution failed","method":"depositAndCall0","contract":"0x6c533f7fE93fAE114d0954697069Df33C9B74fD7","args":"[{[220 191 168 117 51 164 120 116 59 63 80 126 118 23 14 166 242 111 166 154] 0xDCbFA87533A478743B3f507e76170eA6F26FA69a 43113} 0xE8d7796535F1cd63F0fe8D631E68eACe6839869B 10000000 0xDCbFA87533A478743B3f507e76170eA6F26FA69a [0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 64 0 0 0 0 0 0 0 0 0 0 0 0 107 81 59 64 235 192 180 215 177 151 115 4 118 237 51 36 52 111 40 34 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 20 220 191 168 117 51 164 120 116 59 63 80 126 118 23 14 166 242 111 166 154 0 0 0 0 0 0 0 0 0 0 0 0]]"}', + last_update_timestamp: '1641139818', + is_abort_refunded: false, + created_timestamp: '1641139810', + error_message_revert: '', + error_message_abort: '', + status_reduced: 'FAILED', + }, + inbound_params: { + sender: '0xDCbFA87533A478743B3f507e76170eA6F26FA69a', + sender_chain_id: 43113, + tx_origin: '0xDCbFA87533A478743B3f507e76170eA6F26FA69a', + coin_type: 'ERC20', + asset: '0x6B513B40eBc0B4D7B197730476ed3324346F2822', + amount: '10000000', + observed_hash: '0x8ecc5913b637b2756258e8155e2b22dc5afa820698163c1627d372e187e6e65b', + observed_external_height: '45237981', + ballot_index: '0x004d60b58cbbead6ddc0a100e17b88484f72e4f47b10c4b560a41cadd3315c4c', + finalized_zeta_height: '12324831', + tx_finalization_status: 'EXECUTED', + is_cross_chain_call: true, + status: 'SUCCESS', + confirmation_mode: 'SAFE', + }, + outbound_params: [ + { + receiver: '0xDCbFA87533A478743B3f507e76170eA6F26FA69a', + receiver_chain_id: 7001, + coin_type: 'ERC20', + amount: '0', + tss_nonce: '0', + gas_limit: '0', + gas_price: '', + gas_priority_fee: '', + hash: '0x3c426a57742fb55338fbb79318298cc18ca1778ba83196993548921badbe40bb', + ballot_index: '', + observed_external_height: '12324831', + gas_used: '0', + effective_gas_price: '0', + effective_gas_limit: '0', + tss_pubkey: 'zetapub1addwnpepq28c57cvcs0a2htsem5zxr6qnlvq9mzhmm76z3jncsnzz32rclangr2g35p', + tx_finalization_status: 'EXECUTED', + call_options: { + gas_limit: '1500000', + is_arbitrary_call: false, + }, + confirmation_mode: 'SAFE', + }, + { + receiver: '0xDCbFA87533A478743B3f507e76170eA6F26FA69a', + receiver_chain_id: 43113, + coin_type: 'ERC20', + amount: '9999999', + tss_nonce: '842', + gas_limit: '0', + gas_price: '2', + gas_priority_fee: '0', + hash: '0xa17a2ecf87ac2527f49751d487ef20cb44de8965a6bc3817cd188705744a2041', + ballot_index: '', + observed_external_height: '0', + gas_used: '61955', + effective_gas_price: '2', + effective_gas_limit: '100000', + tss_pubkey: 'zetapub1addwnpepq28c57cvcs0a2htsem5zxr6qnlvq9mzhmm76z3jncsnzz32rclangr2g35p', + tx_finalization_status: 'EXECUTED', + call_options: { + gas_limit: '21000', + is_arbitrary_call: false, + }, + confirmation_mode: 'SAFE', + }, + ], + protocol_contract_version: 'V2', + revert_options: { + revert_address: '0x0000000000000000000000000000000000000000', + call_on_revert: false, + abort_address: '0x0000000000000000000000000000000000000000', + // eslint-disable-next-line max-len + revert_message: 'QUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBRUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBWUFBQUFBQUFBQUFBQUFBQUFGQjFQS05KWTJ5b2N5ZGk2TXp3VjlPWm1KR2dBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUNob2RIUndjem92TDJGd2FTNWxlR0Z0Y0d4bExtTnZiUzl0WlhSaFpHRjBZUzh2TVM1cWMyOXVBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQQ==', + revert_gas_limit: '0', + }, + related_cctxs: [ + { + index: '0x004d60b58cbbead6ddc0a100e17b88484f72e4f47b10c4b560a41cadd3315c4c', + depth: 0, + source_chain_id: 43113, + status: 'REVERTED', + status_reduced: 'FAILED', + inbound_amount: '10000000', + inbound_coin_type: 'ERC20', + outbound_params: [ + { + amount: '0', + chain_id: 7001, + coin_type: 'ERC20', + gas_used: '0', + }, + { + amount: '9999999', + chain_id: 43113, + coin_type: 'ERC20', + gas_used: '61955', + }, + ], + token_symbol: 'UPKRW.SEPOLIA', + token_name: 'ZetaChain ZRC20 UPKRW on Sepolia', + token_decimals: 6, + token_zrc20_contract_address: '0xA614Aebf7924A3Eb4D066aDCA5595E4980407f1d', + token_icon_url: null, + created_timestamp: '1641139810', + }, + ], +}; + +export const zetaChainCCTXPending: ZetaChainCCTXResponse = { + ...zetaChainCCTX, + index: '0x5f5f7410d7dfefe6173cc11efa47221e85587d3831c69108121198e0b2a86661', + cctx_status_reduced: 'PENDING', + cctx_status: { + ...zetaChainCCTX.cctx_status, + status: 'PENDING_INBOUND', + status_message: 'Waiting for inbound confirmation', + status_reduced: 'PENDING', + }, + outbound_params: [], +}; + +export const zetaChainCCTXList = { + items: [ + zetaChainCCTXItem, + { + ...zetaChainCCTXItem, + index: '0x2d2e7410d7dfefe6173cc11efa47221e85587d3831c69108121198e0b2a86658', + status: 'OUTBOUND_MINED' as const, + status_reduced: 'SUCCESS' as const, + amount: '500000000000000000', + sender_address: '0x1234567890123456789012345678901234567890', + receiver_address: '0xabcdefabcdefabcdefabcdefabcdefabcdefabcd', + }, + { + ...zetaChainCCTXItem, + index: '0x3f3f7410d7dfefe6173cc11efa47221e85587d3831c69108121198e0b2a86659', + status: 'PENDING_INBOUND' as const, + status_reduced: 'PENDING' as const, + amount: '750000000000000000', + sender_address: '0xabcdefabcdefabcdefabcdefabcdefabcdefabcd', + receiver_address: '0x9876543210987654321098765432109876543210', + }, + ], + next_page_params: { page_index: 1, offset: 50, direction: 'DESC' as const }, +}; diff --git a/mocks/zetaChain/zetaChainCCTXConfig.ts b/mocks/zetaChain/zetaChainCCTXConfig.ts new file mode 100644 index 0000000000..d12d6669b5 --- /dev/null +++ b/mocks/zetaChain/zetaChainCCTXConfig.ts @@ -0,0 +1,26 @@ +export const zetaChainCCTXConfig = [ + { + chain_id: 7000, + chain_name: 'ZetaChain Athens', + chain_logo: 'https://example.com/zetachain-logo.svg', + instance_url: 'https://zetachain-indexer.duckdns.org', + }, + { + chain_id: 7001, + chain_name: 'ZetaChain Athens Testnet', + chain_logo: 'https://example.com/zetachain-testnet-logo.svg', + instance_url: 'https://zetachain-testnet-indexer.duckdns.org', + }, + { + chain_id: 11155111, + chain_name: 'Sepolia Testnet', + chain_logo: 'https://example.com/sepolia-logo.svg', + instance_url: 'https://sepolia.etherscan.io', + }, + { + chain_id: 97, + chain_name: 'BSC Testnet', + chain_logo: 'https://example.com/bsc-testnet-logo.svg', + instance_url: 'https://testnet.bscscan.com', + }, +]; diff --git a/nextjs/csp/generateCspPolicy.ts b/nextjs/csp/generateCspPolicy.ts index 9026880ef8..2bb561e541 100644 --- a/nextjs/csp/generateCspPolicy.ts +++ b/nextjs/csp/generateCspPolicy.ts @@ -22,6 +22,7 @@ function generateCspPolicy() { descriptors.safe(), descriptors.usernameApi(), descriptors.walletConnect(), + descriptors.zetachain(), ); return makePolicyString(policyDescriptor); diff --git a/nextjs/csp/policies/index.ts b/nextjs/csp/policies/index.ts index fc69e7dd49..f965315807 100644 --- a/nextjs/csp/policies/index.ts +++ b/nextjs/csp/policies/index.ts @@ -17,3 +17,4 @@ export { rollup } from './rollup'; export { safe } from './safe'; export { usernameApi } from './usernameApi'; export { walletConnect } from './walletConnect'; +export { zetachain } from './zetachain'; diff --git a/nextjs/csp/policies/zetachain.ts b/nextjs/csp/policies/zetachain.ts new file mode 100644 index 0000000000..3ad50756de --- /dev/null +++ b/nextjs/csp/policies/zetachain.ts @@ -0,0 +1,18 @@ +import type CspDev from 'csp-dev'; + +import config from 'configs/app'; + +const zetachainFeature = config.features.zetachain; + +export function zetachain(): CspDev.DirectiveDescriptor { + if (!zetachainFeature.isEnabled || !config.apis.zetachain?.socketEndpoint) { + return {}; + } + + return { + 'connect-src': [ + `${ config.apis.zetachain.socketEndpoint }/websocket`, + config.apis.zetachain.endpoint, + ], + }; +} diff --git a/nextjs/nextjs-routes.d.ts b/nextjs/nextjs-routes.d.ts index 0c7d77948d..82c139db9e 100644 --- a/nextjs/nextjs-routes.d.ts +++ b/nextjs/nextjs-routes.d.ts @@ -38,6 +38,7 @@ declare module "nextjs-routes" { | DynamicRoute<"/block/countdown/[height]", { "height": string }> | StaticRoute<"/block/countdown"> | StaticRoute<"/blocks"> + | DynamicRoute<"/cc/tx/[hash]", { "hash": string }> | DynamicRoute<"/chain/[chain-slug]/accounts/label/[slug]", { "chain-slug": string; "slug": string }> | DynamicRoute<"/chain/[chain-slug]/advanced-filter", { "chain-slug": string }> | DynamicRoute<"/chain/[chain-slug]/block/[height_or_hash]", { "chain-slug": string; "height_or_hash": string }> diff --git a/nextjs/redirects.js b/nextjs/redirects.js index 5fd13b4af1..df33a846d8 100644 --- a/nextjs/redirects.js +++ b/nextjs/redirects.js @@ -340,6 +340,12 @@ const ETHERSCAN_URLS = [ source: '/txsEnqueued', destination: '/deposits', }, + + // CROSS CHAIN TRANSACTIONS + { + source: '/cc/txs', + destination: '/txs?tab=cctx', + }, ]; const DEPRECATED_ROUTES = [ diff --git a/package.json b/package.json index bb6e894422..61289b0171 100644 --- a/package.json +++ b/package.json @@ -45,7 +45,7 @@ "@blockscout/bens-types": "1.4.1", "@blockscout/multichain-aggregator-types": "1.6.0-alpha.2", "@blockscout/points-types": "1.3.0-alpha.2", - "@blockscout/stats-types": "^2.9.0", + "@blockscout/stats-types": "2.10.0-alpha", "@blockscout/tac-operation-lifecycle-types": "0.0.1-alpha.6", "@blockscout/visualizer-types": "0.2.0", "@chakra-ui/react": "3.15.0", diff --git a/pages/cc/tx/[hash].tsx b/pages/cc/tx/[hash].tsx new file mode 100644 index 0000000000..97e53ca29b --- /dev/null +++ b/pages/cc/tx/[hash].tsx @@ -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 ZetaChainCCTX = dynamic(() => import('ui/pages/ZetaChainCCTX'), { ssr: false }); + +const Page: NextPage = (props: Props) => { + return ( + + + + ); +}; + +export default Page; + +export { base as getServerSideProps } from 'nextjs/getServerSideProps/main'; diff --git a/pages/txs/index.tsx b/pages/txs/index.tsx index 118fec052a..3a7bb408e2 100644 --- a/pages/txs/index.tsx +++ b/pages/txs/index.tsx @@ -11,10 +11,15 @@ const Transactions = dynamic(() => { return import('ui/optimismSuperchain/txs/OpSuperchainTxs'); } + if (config.features.zetachain.isEnabled) { + return import('ui/pages/TransactionsZetaChain'); + } + return import('ui/pages/Transactions'); }, { ssr: false }); const Page: NextPage = () => { + return ( diff --git a/playwright/fixtures/mockEnvs.ts b/playwright/fixtures/mockEnvs.ts index c67b045ab3..dcc140070e 100644 --- a/playwright/fixtures/mockEnvs.ts +++ b/playwright/fixtures/mockEnvs.ts @@ -118,6 +118,12 @@ export const ENVS_MAP: Record> = { opSuperchain: [ [ 'NEXT_PUBLIC_OP_SUPERCHAIN_ENABLED', 'true' ], ], + zetaChain: [ + [ 'NEXT_PUBLIC_ZETACHAIN_SERVICE_API_HOST', 'http://localhost:3111' ], + [ 'NEXT_PUBLIC_ZETACHAIN_SERVICE_CHAINS_CONFIG_URL', 'http://localhost:3000/zeta-config.json' ], + [ 'NEXT_PUBLIC_ZETACHAIN_COSMOS_TX_URL_TEMPLATE', 'https://example.com/cosmos/tx/{hash}' ], + [ 'NEXT_PUBLIC_ZETACHAIN_COSMOS_ADDRESS_URL_TEMPLATE', 'https://example.com/cosmos/address/{hash}' ], + ], navigationPromoBannerText: [ [ 'NEXT_PUBLIC_NAVIGATION_PROMO_BANNER_CONFIG', '{"img_url": "http://localhost:3000/image.svg", "text": "Try the DUCK!", "bg_color": {"light": "rgb(150, 211, 255)", "dark": "rgb(68, 51, 122)"}, "text_color": {"light": "rgb(69, 69, 69)", "dark": "rgb(233, 216, 253)"}, "link_url": "https://example.com"}' ], ], diff --git a/public/icons/name.d.ts b/public/icons/name.d.ts index 06b734a9d4..23d14171c0 100644 --- a/public/icons/name.d.ts +++ b/public/icons/name.d.ts @@ -90,6 +90,7 @@ | "integration/full" | "integration/partial" | "internal_txns" + | "interop_slim" | "interop" | "key" | "lightning_navbar" diff --git a/toolkit/components/RoutedTabs/RoutedTabs.tsx b/toolkit/components/RoutedTabs/RoutedTabs.tsx index 6c68c06946..1b022d607d 100644 --- a/toolkit/components/RoutedTabs/RoutedTabs.tsx +++ b/toolkit/components/RoutedTabs/RoutedTabs.tsx @@ -9,13 +9,14 @@ import useActiveTabFromQuery from './useActiveTabFromQuery'; interface Props extends AdaptiveTabsProps { preservedParams?: Array; + defaultTabId?: string; } const RoutedTabs = (props: Props) => { const { tabs, onValueChange, preservedParams, ...rest } = props; const router = useRouter(); - const activeTab = useActiveTabFromQuery(props.tabs); + const activeTab = useActiveTabFromQuery(props.tabs, props.defaultTabId); const tabsRef = React.useRef(null); const handleValueChange = React.useCallback(({ value }: { value: string }) => { diff --git a/toolkit/components/RoutedTabs/useActiveTabFromQuery.tsx b/toolkit/components/RoutedTabs/useActiveTabFromQuery.tsx index b5c51a0769..8a02a5a769 100644 --- a/toolkit/components/RoutedTabs/useActiveTabFromQuery.tsx +++ b/toolkit/components/RoutedTabs/useActiveTabFromQuery.tsx @@ -4,11 +4,15 @@ import type { TabItem } from '../AdaptiveTabs/types'; import { castToString } from '../../utils/guards'; -export default function useActiveTabFromQuery(tabs: Array) { +export default function useActiveTabFromQuery(tabs: Array, defaultTabId?: string) { const router = useRouter(); const tabFromQuery = castToString(router.query.tab); if (!tabFromQuery) { + if (defaultTabId) { + return tabs.find((tab) => tab.id === defaultTabId); + } + return; } diff --git a/toolkit/theme/foundations/semanticTokens.ts b/toolkit/theme/foundations/semanticTokens.ts index 1608e5a40b..c590d07247 100644 --- a/toolkit/theme/foundations/semanticTokens.ts +++ b/toolkit/theme/foundations/semanticTokens.ts @@ -42,6 +42,7 @@ const semanticTokens: ThemingConfig['semanticTokens'] = { primary: { value: { _light: '{colors.theme.text.primary._light}', _dark: '{colors.theme.text.primary._dark}' } }, secondary: { value: { _light: '{colors.theme.text.secondary._light}', _dark: '{colors.theme.text.secondary._dark}' } }, error: { value: '{colors.red.500}' }, + success: { value: { _light: '{colors.green.500}', _dark: '{colors.green.200}' } }, }, bg: { primary: { value: { _light: '{colors.theme.bg.primary._light}', _dark: '{colors.theme.bg.primary._dark}' } }, diff --git a/toolkit/theme/recipes/badge.recipe.ts b/toolkit/theme/recipes/badge.recipe.ts index c9cebae5e9..3a32834320 100644 --- a/toolkit/theme/recipes/badge.recipe.ts +++ b/toolkit/theme/recipes/badge.recipe.ts @@ -67,6 +67,12 @@ export const recipe = defineRecipe({ }, }, size: { + sm: { + textStyle: 'xs', + p: '1', + h: '4.5', + minH: '4.5', + }, md: { textStyle: 'sm', px: '1', diff --git a/tools/preset-sync/index.ts b/tools/preset-sync/index.ts index 24cdc240c0..f35b58f32e 100755 --- a/tools/preset-sync/index.ts +++ b/tools/preset-sync/index.ts @@ -30,6 +30,8 @@ const PRESETS = { stability_testnet: 'https://stability-testnet.blockscout.com', tac: 'https://explorer.tac.build', tac_turin: 'https://tac-turin.blockscout.com', + zetachain: 'https://zetachain.blockscout.com', + zetachain_testnet: 'https://zetachain-testnet.blockscout.com', zkevm: 'https://zkevm.blockscout.com', zksync: 'https://zksync.blockscout.com', zilliqa: 'https://zilliqa.blockscout.com', diff --git a/types/api/zetaChain.ts b/types/api/zetaChain.ts new file mode 100644 index 0000000000..22b92b435f --- /dev/null +++ b/types/api/zetaChain.ts @@ -0,0 +1,176 @@ +import type { AdvancedFilterAge } from './advancedFilter'; + +export type ZetaChainCCTXListResponse = { + items: Array; + next_page_params: { + page_index: number; + offset: number; + direction: 'ASC' | 'DESC'; + limit?: number; + } | null; +}; + +export type ZetaChainCCTX = { + index: string; + amount: string; + coin_type: ZetaChainCCTXCoinType; + created_timestamp?: string; + last_update_timestamp: string; + receiver_address: string; + sender_address: string; + source_chain_id: number; + status: ZetaChainCCTXStatus; + status_reduced: ZetaChainCCTXStatusReduced; + target_chain_id: number; + token_symbol?: string; + asset?: string; + decimals?: string | null; +}; + +export type ZetaChainCCTXResponse = { + creator: string; + index: string; + zeta_fees: string; + relayed_message: string; + cctx_status_reduced: ZetaChainCCTXStatusReduced; + cctx_status: { + status: ZetaChainCCTXStatus; + status_reduced: ZetaChainCCTXStatusReduced; + status_message: string; + error_message: string; + last_update_timestamp: string; + is_abort_refunded: boolean; + created_timestamp?: string; + error_message_revert: string; + error_message_abort: string; + }; + inbound_params: { + sender: string; + sender_chain_id: number; + tx_origin: string; + coin_type: ZetaChainCCTXCoinType; + asset: string; + amount: string; + observed_hash: string; + observed_external_height: string; + ballot_index: string; + finalized_zeta_height: string; + tx_finalization_status: ZetaChainCCTXFinalizationStatus; + is_cross_chain_call: boolean; + status: ZetaChainCCTXInboundStatus; + confirmation_mode: ZetaChainCCTXConfirmationMode; + }; + outbound_params: Array; + protocol_contract_version: string; + revert_options?: { + revert_address: string; + call_on_revert: boolean; + abort_address: string; + revert_message: string; + revert_gas_limit: string; + }; + related_cctxs: Array; + token_symbol?: string; + token_name?: string; + token_decimals?: number; + token_zrc20_contract_address?: string; + token_icon_url?: string | null; + created_timestamp?: string; + zrc20_contract_address?: string; + icon_url?: string | null; + decimals?: number; +}; + +export type ZetaChainRelatedCCTX = { + index: string; + depth: number; + source_chain_id: number; + status: ZetaChainCCTXStatus; + status_reduced: ZetaChainCCTXStatusReduced; + inbound_amount: string; + inbound_coin_type: ZetaChainCCTXCoinType; + outbound_params: Array<{ + amount: string; + chain_id: number; + coin_type: ZetaChainCCTXCoinType; + gas_used?: string; + }>; + token_symbol?: string; + token_name?: string; + token_decimals?: number; + token_zrc20_contract_address?: string; + token_icon_url?: string | null; + created_timestamp?: string; + zrc20_contract_address?: string; + icon_url?: string | null; + decimals?: number; +}; + +export type ZetaChainCCTXStatus = 'PENDING_OUTBOUND' | 'PENDING_INBOUND' | 'OUTBOUND_MINED' | 'PENDING_REVERT' | 'ABORTED' | 'REVERTED'; + +export type ZetaChainCCTXStatusReduced = 'SUCCESS' | 'PENDING' | 'FAILED'; + +// API filter values (capitalized for API endpoint) +export const ZETA_CHAIN_CCTX_STATUS_REDUCED_FILTERS = [ 'Success', 'Pending', 'Failed' ] as const; +export type ZetaChainCCTXStatusReducedFilter = typeof ZETA_CHAIN_CCTX_STATUS_REDUCED_FILTERS[number]; + +export type ZetaChainCCTXCoinType = 'ZETA' | 'GAS' | 'ERC20' | 'CMD' | 'NO_ASSET_CALL'; + +export type ZetaChainCCTXFinalizationStatus = 'EXECUTED' | 'NOT_FINALIZED'; + +export type ZetaChainCCTXConfirmationMode = 'SAFE' | 'FAST'; + +export type ZetaChainCCTXInboundStatus = 'SUCCESS' | 'INSUFFICIENT_DEPOSITOR_FEE' | 'INVALID_RECEIVER_ADDRESS' | 'INVALID_MEMO'; + +export type ZetaChainCCTXOutboundParams = { + receiver: string; + receiver_chain_id?: number; + coin_type: ZetaChainCCTXCoinType; + amount: string; + tss_nonce: string; + gas_limit: string; + gas_price: string; + gas_priority_fee: string; + hash: string | null; + ballot_index: string; + observed_external_height: string; + gas_used: string; + effective_gas_price: string; + effective_gas_limit: string; + tss_pubkey: string; + tx_finalization_status: ZetaChainCCTXFinalizationStatus; + call_options: { + gas_limit: string; + is_arbitrary_call: boolean; + }; + confirmation_mode: ZetaChainCCTXConfirmationMode; +}; + +// Filter types for ZetaChain CCTX +export type ZetaChainCCTXFilterParams = { + start_timestamp?: string; + end_timestamp?: string; + age?: AdvancedFilterAge | ''; /* frontend only */ + status_reduced?: Array | ZetaChainCCTXStatusReducedFilter; + sender_address?: Array | string; + receiver_address?: Array | string; + source_chain_id?: Array | string; + target_chain_id?: Array | string; + token_symbol?: Array | string; + hash?: string; +}; + +// Token types for ZetaChain +export type ZetaChainTokensResponse = { + tokens: Array; +}; + +export type ZetaChainTokenInfo = { + foreign_chain_id: number; + decimals: number; + name: string; + symbol: string; + zrc20_contract_address: string; + icon_url: string | null; + coin_type: string; +}; diff --git a/types/client/chainInfo.ts b/types/client/chainInfo.ts new file mode 100644 index 0000000000..22495d3e62 --- /dev/null +++ b/types/client/chainInfo.ts @@ -0,0 +1,10 @@ +// chain info for external chains (not in the config) +// eg. zetachain cctx, interop txs, etc. +export type ChainInfo = { + chain_id: number; + chain_name: string | null; + chain_logo?: string | null; + instance_url?: string; + address_url_template?: string; + tx_url_template?: string; +}; diff --git a/types/client/search.ts b/types/client/search.ts index ca02af1e3e..4e7f3f8b98 100644 --- a/types/client/search.ts +++ b/types/client/search.ts @@ -1,4 +1,5 @@ import type * as api from 'types/api/search'; +import type { ZetaChainCCTX } from 'types/api/zetaChain'; export interface SearchResultFutureBlock { type: 'block'; @@ -11,4 +12,9 @@ export interface SearchResultFutureBlock { export type SearchResultBlock = api.SearchResultBlock | SearchResultFutureBlock; -export type SearchResultItem = api.SearchResultItem | SearchResultBlock; +export interface SearchResultZetaChainCCTX { + type: 'zetaChainCCTX'; + cctx: ZetaChainCCTX; +} + +export type SearchResultItem = api.SearchResultItem | SearchResultBlock | SearchResultZetaChainCCTX; diff --git a/types/zetaChainCCTXChainInfo.ts b/types/zetaChainCCTXChainInfo.ts new file mode 100644 index 0000000000..fb19fddb75 --- /dev/null +++ b/types/zetaChainCCTXChainInfo.ts @@ -0,0 +1,10 @@ +export interface ZetaChainCCTXChainInfo { + chain_id: number; + chain_name: string | null; + chain_logo: string | null; + instance_url: string; + native_currency: { + symbol: string; + decimals: number; + }; +} diff --git a/ui/advancedFilter/filters/AgeFilter.tsx b/ui/advancedFilter/filters/AgeFilter.tsx index 176469472f..92b7c17fd3 100644 --- a/ui/advancedFilter/filters/AgeFilter.tsx +++ b/ui/advancedFilter/filters/AgeFilter.tsx @@ -1,139 +1,37 @@ -import { Flex, Text } from '@chakra-ui/react'; -import { isEqual } from 'es-toolkit'; -import type { ChangeEvent } from 'react'; -import React from 'react'; +import type { AdvancedFilterAge, AdvancedFilterParams } from 'types/api/advancedFilter'; -import { ADVANCED_FILTER_AGES, type AdvancedFilterAge, type AdvancedFilterParams } from 'types/api/advancedFilter'; - -import dayjs from 'lib/date/dayjs'; -import { Input } from 'toolkit/chakra/input'; -import { PopoverCloseTriggerWrapper } from 'toolkit/chakra/popover'; -import { ndash } from 'toolkit/utils/htmlEntities'; -import TableColumnFilter from 'ui/shared/filters/TableColumnFilter'; -import TagGroupSelect from 'ui/shared/tagGroupSelect/TagGroupSelect'; - -import { getDurationFromAge } from '../lib'; +import BaseAgeFilter, { type DateConverter } from './BaseAgeFilter'; const FILTER_PARAM_FROM = 'age_from'; const FILTER_PARAM_TO = 'age_to'; const FILTER_PARAM_AGE = 'age'; -const defaultValue = { age: '', from: '', to: '' } as const; -type AgeFromToValue = { age: AdvancedFilterAge | ''; from: string; to: string }; +const dateConverter: DateConverter = { + toFilterValue: (date: string) => date, // Keep as ISO string + fromFilterValue: (value: string | undefined) => value || '', + getCurrentValue: (value: string | undefined) => value || '', +}; type Props = { - value?: AgeFromToValue; - handleFilterChange: (filed: keyof AdvancedFilterParams, value?: string) => void; + value?: { age: AdvancedFilterAge | ''; from: string; to: string }; + handleFilterChange: (field: keyof AdvancedFilterParams, value?: string) => void; columnName: string; isLoading?: boolean; onClose?: () => void; }; -const DateInput = ({ value, onChange, placeholder, max }: { value: string; onChange: (value: string) => void; placeholder: string; max: string }) => { - const [ tempValue, setTempValue ] = React.useState(value ? dayjs(value).format('YYYY-MM-DD') : ''); - - React.useEffect(() => { - // reset - if (!value) { - setTempValue(''); - } - }, [ value ]); - - const handleChange = React.useCallback((event: ChangeEvent) => { - setTempValue(event.target.value); - onChange(event.target.value); - }, [ onChange ]); - +const AgeFilter = (props: Props) => { return ( - + { ...props } + fieldNames={{ + from: FILTER_PARAM_FROM, + to: FILTER_PARAM_TO, + age: FILTER_PARAM_AGE, + }} + dateConverter={ dateConverter } /> ); }; -const AgeFilter = ({ value = defaultValue, handleFilterChange, onClose }: Props) => { - const [ currentValue, setCurrentValue ] = React.useState({ - age: value.age || '', - from: value.age ? '' : (value.from || ''), - to: value.age ? '' : (value.to || ''), - }); - - const handleFromChange = React.useCallback((newValue: string) => { - setCurrentValue(prev => ({ age: '', to: prev.to, from: newValue })); - }, []); - - const handleToChange = React.useCallback((newValue: string) => { - setCurrentValue(prev => ({ age: '', from: prev.from, to: newValue })); - }, []); - - const onPresetChange = React.useCallback((age: AdvancedFilterAge) => { - const from = dayjs((dayjs().valueOf() - getDurationFromAge(age))).toISOString(); - handleFilterChange(FILTER_PARAM_FROM, from); - const to = dayjs().toISOString(); - handleFilterChange(FILTER_PARAM_TO, to); - handleFilterChange(FILTER_PARAM_AGE, age); - onClose?.(); - }, [ handleFilterChange, onClose ]); - - const onReset = React.useCallback(() => setCurrentValue(defaultValue), []); - - const onFilter = React.useCallback(() => { - if (!currentValue.age && !currentValue.to && !currentValue.from) { - handleFilterChange(FILTER_PARAM_FROM, undefined); - handleFilterChange(FILTER_PARAM_TO, undefined); - handleFilterChange(FILTER_PARAM_AGE, undefined); - return; - } - const from = currentValue.age ? - dayjs((dayjs().valueOf() - getDurationFromAge(currentValue.age))).toISOString() : - dayjs(currentValue.from || undefined).startOf('day').toISOString(); - handleFilterChange(FILTER_PARAM_FROM, from); - const to = currentValue.age ? dayjs().toISOString() : dayjs(currentValue.to || undefined).endOf('day').toISOString(); - handleFilterChange(FILTER_PARAM_TO, to); - handleFilterChange(FILTER_PARAM_AGE, currentValue.age); - }, [ handleFilterChange, currentValue ]); - - return ( - - - - - items={ ADVANCED_FILTER_AGES.map(val => ({ id: val, title: val })) } - onChange={ onPresetChange } - value={ currentValue.age || undefined } - /> - - - - - { ndash } - - - - ); -}; - export default AgeFilter; diff --git a/ui/advancedFilter/filters/BaseAgeFilter.tsx b/ui/advancedFilter/filters/BaseAgeFilter.tsx new file mode 100644 index 0000000000..56a7f1f394 --- /dev/null +++ b/ui/advancedFilter/filters/BaseAgeFilter.tsx @@ -0,0 +1,196 @@ +import { Flex, Text } from '@chakra-ui/react'; +import { isEqual } from 'es-toolkit'; +import type { ChangeEvent } from 'react'; +import React from 'react'; + +import { ADVANCED_FILTER_AGES, type AdvancedFilterAge } from 'types/api/advancedFilter'; + +import dayjs from 'lib/date/dayjs'; +import { Input } from 'toolkit/chakra/input'; +import { PopoverCloseTriggerWrapper } from 'toolkit/chakra/popover'; +import { ndash } from 'toolkit/utils/htmlEntities'; +import TableColumnFilter from 'ui/shared/filters/TableColumnFilter'; +import TagGroupSelect from 'ui/shared/tagGroupSelect/TagGroupSelect'; + +import { getDurationFromAge } from '../lib'; + +const defaultValue = { age: '', from: '', to: '' } as const; +type AgeFromToValue = { age: AdvancedFilterAge | ''; from: string; to: string }; + +type DateConverter = { + toFilterValue: (date: string) => string; + fromFilterValue: (value: string | undefined) => string; + getCurrentValue: (value: string | undefined) => string; +}; + +type Props = { + value?: AgeFromToValue; + handleFilterChange: (field: keyof T, value?: string) => void; + columnName: string; + isLoading?: boolean; + onClose?: () => void; + fieldNames: { + from: keyof T; + to: keyof T; + age: keyof T; + }; + dateConverter: DateConverter; +}; + +const DateInput = ({ value, onChange, placeholder, max }: { value: string; onChange: (value: string) => void; placeholder: string; max: string }) => { + const [ tempValue, setTempValue ] = React.useState(value || ''); + + // reset + React.useEffect(() => { + if (!value) { + setTempValue(''); + } + }, [ value ]); + + const handleChange = React.useCallback((event: ChangeEvent) => { + setTempValue(event.target.value); + onChange(event.target.value); + }, [ onChange ]); + + return ( + + ); +}; + +function BaseAgeFilter({ + value = defaultValue, + handleFilterChange, + onClose, + fieldNames, + dateConverter, +}: Props) { + const getFromValue = () => { + if (value.age) return ''; + return value.from ? dateConverter.getCurrentValue(value.from) : ''; + }; + + const getToValue = () => { + if (value.age) return ''; + return value.to ? dateConverter.getCurrentValue(value.to) : ''; + }; + + const [ currentValue, setCurrentValue ] = React.useState({ + age: value.age || '', + from: getFromValue(), + to: getToValue(), + }); + + const handleFromChange = React.useCallback((newValue: string) => { + setCurrentValue(prev => ({ age: '', to: prev.to, from: newValue })); + }, []); + + const handleToChange = React.useCallback((newValue: string) => { + setCurrentValue(prev => ({ age: '', from: prev.from, to: newValue })); + }, []); + + const onPresetChange = React.useCallback((age: AdvancedFilterAge) => { + const from = dateConverter.toFilterValue(dayjs((dayjs().valueOf() - getDurationFromAge(age))).toISOString()); + handleFilterChange(fieldNames.from, from); + const to = dateConverter.toFilterValue(dayjs().toISOString()); + handleFilterChange(fieldNames.to, to); + handleFilterChange(fieldNames.age, age); + onClose?.(); + }, [ handleFilterChange, onClose, fieldNames, dateConverter ]); + + const onReset = React.useCallback(() => { + setCurrentValue(defaultValue); + }, []); + + const onFilter = React.useCallback(() => { + if (!currentValue.age && !currentValue.to && !currentValue.from) { + handleFilterChange(fieldNames.from, undefined); + handleFilterChange(fieldNames.to, undefined); + handleFilterChange(fieldNames.age, undefined); + return; + } + + if (currentValue.age) { + // Age preset is selected, calculate timestamps + const from = dateConverter.toFilterValue(dayjs((dayjs().valueOf() - getDurationFromAge(currentValue.age))).toISOString()); + const to = dateConverter.toFilterValue(dayjs().toISOString()); + handleFilterChange(fieldNames.from, from); + handleFilterChange(fieldNames.to, to); + handleFilterChange(fieldNames.age, currentValue.age); + } else { + // Custom date range is selected + const from = currentValue.from ? dateConverter.toFilterValue(dayjs(currentValue.from).startOf('day').toISOString()) : undefined; + const to = currentValue.to ? dateConverter.toFilterValue(dayjs(currentValue.to).endOf('day').toISOString()) : undefined; + handleFilterChange(fieldNames.from, from); + handleFilterChange(fieldNames.to, to); + handleFilterChange(fieldNames.age, undefined); + } + }, [ handleFilterChange, currentValue, fieldNames, dateConverter ]); + + // Check if the current values differ from the original values + const isTouched = React.useMemo(() => { + if (currentValue.age) { + return value.age !== currentValue.age; + } + + // If both current values are empty and both original values are empty, not touched + if (!currentValue.from && !currentValue.to && !value.from && !value.to) { + return false; + } + + // Convert original values to date strings for comparison + const originalValueAsDates = { + from: value.from ? dateConverter.getCurrentValue(value.from) : '', + to: value.to ? dateConverter.getCurrentValue(value.to) : '', + }; + + // Compare the date strings directly + return !isEqual(currentValue, originalValueAsDates); + }, [ currentValue, value, dateConverter ]); + + return ( + + + + + items={ ADVANCED_FILTER_AGES.map(val => ({ id: val, title: val })) } + onChange={ onPresetChange } + value={ currentValue.age || undefined } + /> + + + + + { ndash } + + + + ); +} + +export default BaseAgeFilter; +export type { Props, DateConverter }; diff --git a/ui/home/LatestTxs.tsx b/ui/home/LatestTxs.tsx index cf381a7bbe..20df5f1d91 100644 --- a/ui/home/LatestTxs.tsx +++ b/ui/home/LatestTxs.tsx @@ -3,6 +3,7 @@ import React from 'react'; import { route } from 'nextjs-routes'; +import config from 'configs/app'; import useApiQuery from 'lib/api/useApiQuery'; import { AddressHighlightProvider } from 'lib/contexts/addressHighlight'; import useIsMobile from 'lib/hooks/useIsMobile'; @@ -14,6 +15,8 @@ import useNewTxsSocket from 'ui/txs/socket/useTxsSocketTypeAll'; import LatestTxsItem from './LatestTxsItem'; import LatestTxsItemMobile from './LatestTxsItemMobile'; +const zetachainFeature = config.features.zetachain; + const LatestTransactions = () => { const isMobile = useIsMobile(); const txsCount = isMobile ? 2 : 6; @@ -30,7 +33,7 @@ const LatestTransactions = () => { } if (data) { - const txsUrl = route({ pathname: '/txs' }); + const txsUrl = route({ pathname: `/txs`, query: zetachainFeature.isEnabled ? { tab: 'evm' } : undefined }); return ( <> diff --git a/ui/home/Transactions.tsx b/ui/home/Transactions.tsx index c5ee6f1ec2..f0fa4b7b09 100644 --- a/ui/home/Transactions.tsx +++ b/ui/home/Transactions.tsx @@ -1,22 +1,34 @@ import React from 'react'; import config from 'configs/app'; +import { SocketProvider } from 'lib/socket/context'; import { Heading } from 'toolkit/chakra/heading'; import AdaptiveTabs from 'toolkit/components/AdaptiveTabs/AdaptiveTabs'; import LatestOptimisticDeposits from 'ui/home/latestDeposits/LatestOptimisticDeposits'; import LatestTxs from 'ui/home/LatestTxs'; import LatestWatchlistTxs from 'ui/home/LatestWatchlistTxs'; +import LatestZetaChainCCTXs from 'ui/home/latestZetaChainCCTX/LatestZetahainCCTXs'; import useAuth from 'ui/snippets/auth/useIsAuth'; import LatestArbitrumDeposits from './latestDeposits/LatestArbitrumDeposits'; const rollupFeature = config.features.rollup; +const zetachainFeature = config.features.zetachain; const TransactionsHome = () => { const isAuth = useAuth(); - if ((rollupFeature.isEnabled && (rollupFeature.type === 'optimistic' || rollupFeature.type === 'arbitrum')) || isAuth) { + if ((rollupFeature.isEnabled && (rollupFeature.type === 'optimistic' || rollupFeature.type === 'arbitrum')) || isAuth || zetachainFeature.isEnabled) { const tabs = [ - { id: 'txn', title: 'Latest txn', component: }, + zetachainFeature.isEnabled && { + id: 'cctx', + title: 'Cross-chain', + component: ( + + + + ), + }, + { id: 'txn', title: zetachainFeature.isEnabled ? 'ZetaChain EVM' : 'Latest txn', component: }, rollupFeature.isEnabled && rollupFeature.type === 'optimistic' && { id: 'deposits', title: 'Deposits (L1→L2 txn)', component: }, rollupFeature.isEnabled && rollupFeature.type === 'arbitrum' && diff --git a/ui/home/latestZetaChainCCTX/LatestZetaChainCCTXItem.tsx b/ui/home/latestZetaChainCCTX/LatestZetaChainCCTXItem.tsx new file mode 100644 index 0000000000..4f78001e16 --- /dev/null +++ b/ui/home/latestZetaChainCCTX/LatestZetaChainCCTXItem.tsx @@ -0,0 +1,53 @@ +import { Grid } from '@chakra-ui/react'; +import React from 'react'; + +import type { ZetaChainCCTX } from 'types/api/zetaChain'; + +import AddressFromTo from 'ui/shared/address/AddressFromTo'; +import CCTxEntityZetaChain from 'ui/shared/entities/tx/CCTxEntityZetaChain'; +import TimeWithTooltip from 'ui/shared/time/TimeWithTooltip'; +import ZetaChainCCTXReducedStatus from 'ui/shared/zetaChain/ZetaChainCCTXReducedStatus'; +import ZetaChainCCTXValue from 'ui/shared/zetaChain/ZetaChainCCTXValue'; + +type Props = { + tx: ZetaChainCCTX; + isLoading?: boolean; + animation?: string; +}; + +const LatestZetaChainCCTXItem = ({ tx, isLoading, animation }: Props) => { + return ( + + + + + + + + ); +}; + +export default React.memo(LatestZetaChainCCTXItem); diff --git a/ui/home/latestZetaChainCCTX/LatestZetaChainCCTXs.pw.tsx b/ui/home/latestZetaChainCCTX/LatestZetaChainCCTXs.pw.tsx new file mode 100644 index 0000000000..e976496d6d --- /dev/null +++ b/ui/home/latestZetaChainCCTX/LatestZetaChainCCTXs.pw.tsx @@ -0,0 +1,29 @@ +import React from 'react'; + +import { zetaChainCCTXList } from 'mocks/zetaChain/zetaChainCCTX'; +import { zetaChainCCTXConfig } from 'mocks/zetaChain/zetaChainCCTXConfig'; +import { ENVS_MAP } from 'playwright/fixtures/mockEnvs'; +import { test, expect } from 'playwright/lib'; + +import LatestZetahainCCTXs from './LatestZetahainCCTXs'; + +const CCTX_CONFIG_URL = 'http://localhost:3000/zeta-config.json'; + +test.beforeEach(async({ mockEnvs, mockConfigResponse }) => { + await mockEnvs(ENVS_MAP.zetaChain); + await mockConfigResponse('NEXT_PUBLIC_ZETACHAIN_SERVICE_CHAINS_CONFIG_URL', CCTX_CONFIG_URL, zetaChainCCTXConfig); +}); + +test('base view +@dark-mode +@mobile', async({ render, mockApiResponse }) => { + await mockApiResponse('zetachain:transactions', zetaChainCCTXList, { + queryParams: { + limit: 10, + offset: 0, + direction: 'DESC', + }, + }); + + const component = await render(); + + await expect(component).toHaveScreenshot(); +}); diff --git a/ui/home/latestZetaChainCCTX/LatestZetahainCCTXs.tsx b/ui/home/latestZetaChainCCTX/LatestZetahainCCTXs.tsx new file mode 100644 index 0000000000..d5645b673a --- /dev/null +++ b/ui/home/latestZetaChainCCTX/LatestZetahainCCTXs.tsx @@ -0,0 +1,139 @@ +import { Box, Flex, Text } from '@chakra-ui/react'; +import { useQueryClient } from '@tanstack/react-query'; +import React from 'react'; + +import type { SocketMessage } from 'lib/socket/types'; +import type { ZetaChainCCTXListResponse } from 'types/api/zetaChain'; + +import { route } from 'nextjs-routes'; + +import useApiQuery, { getResourceKey } from 'lib/api/useApiQuery'; +import { AddressHighlightProvider } from 'lib/contexts/addressHighlight'; +import useInitialList from 'lib/hooks/useInitialList'; +import useIsMobile from 'lib/hooks/useIsMobile'; +import useSocketChannel from 'lib/socket/useSocketChannel'; +import useSocketMessage from 'lib/socket/useSocketMessage'; +import { zetaChainCCTXItem } from 'mocks/zetaChain/zetaChainCCTX'; +import { Link } from 'toolkit/chakra/link'; +import SocketNewItemsNotice from 'ui/shared/SocketNewItemsNotice'; +import ZetaChainCCTXListItem from 'ui/txs/zetaChain/ZetaChainCCTXListItem'; + +import LatestZetaChainCCTXItem from './LatestZetaChainCCTXItem'; + +const LatestZetahainCCTXs = () => { + const isMobile = useIsMobile(); + const txsCount = isMobile ? 3 : 8; + const { data, isPlaceholderData, isError } = useApiQuery('zetachain:transactions', { + queryOptions: { + placeholderData: { items: Array(10).fill(zetaChainCCTXItem), next_page_params: { page_index: 0, offset: 0, direction: 'DESC' } }, + }, + queryParams: { + // we request 10 items though we need less because socket sends 10 items at once + limit: 10, + offset: 0, + direction: 'DESC', + }, + }); + + const initialList = useInitialList({ + data: data?.items ?? [], + idFn: (tx) => tx.index, + enabled: !isPlaceholderData, + }); + + const queryClient = useQueryClient(); + + const channel = useSocketChannel({ + topic: 'cctxs:new_cctxs', + isDisabled: Boolean(isPlaceholderData), + socketName: 'zetachain', + }); + + const handleNewCCTXMessage: SocketMessage.NewZetaChainCCTXs['handler'] = React.useCallback((payload) => { + queryClient.setQueryData( + getResourceKey('zetachain:transactions', { + queryParams: { + limit: 10, + offset: 0, + direction: 'DESC', + }, + }), + (prevData: ZetaChainCCTXListResponse | undefined) => { + if (!prevData) { + return { + items: payload, + next_page_params: null, + }; + } + + const existingItemsMap = new Map( + prevData.items.map(item => [ item.index, item ]), + ); + + payload.forEach(newItem => { + existingItemsMap.set(newItem.index, newItem); + }); + + const mergedItems = Array.from(existingItemsMap.values()) + .sort((a, b) => Number(b.last_update_timestamp) - Number(a.last_update_timestamp)) + .slice(0, 10); + + return { + ...prevData, + items: mergedItems, + }; + }, + ); + }, [ queryClient ]); + + useSocketMessage({ + channel, + event: 'new_cctxs', + handler: handleNewCCTXMessage, + }); + + if (isError) { + return No data. Please reload the page.; + } + + if (data) { + const cctxsUrl = route({ pathname: '/txs', query: { tab: 'cctx' } }); + return ( + <> + + + { data.items.slice(0, txsCount).map(((tx, index) => ( + + ))) } + + + + { data.items.slice(0, txsCount).map(((tx, index) => ( + + ))) } + + + + View all cross chain transactions + + + ); + } + + return null; +}; + +export default LatestZetahainCCTXs; diff --git a/ui/home/latestZetaChainCCTX/__screenshots__/LatestZetaChainCCTXs.pw.tsx_dark-color-mode_base-view-dark-mode-mobile-1.png b/ui/home/latestZetaChainCCTX/__screenshots__/LatestZetaChainCCTXs.pw.tsx_dark-color-mode_base-view-dark-mode-mobile-1.png new file mode 100644 index 0000000000..9232f44908 Binary files /dev/null and b/ui/home/latestZetaChainCCTX/__screenshots__/LatestZetaChainCCTXs.pw.tsx_dark-color-mode_base-view-dark-mode-mobile-1.png differ diff --git a/ui/home/latestZetaChainCCTX/__screenshots__/LatestZetaChainCCTXs.pw.tsx_default_base-view-dark-mode-mobile-1.png b/ui/home/latestZetaChainCCTX/__screenshots__/LatestZetaChainCCTXs.pw.tsx_default_base-view-dark-mode-mobile-1.png new file mode 100644 index 0000000000..6cdb908383 Binary files /dev/null and b/ui/home/latestZetaChainCCTX/__screenshots__/LatestZetaChainCCTXs.pw.tsx_default_base-view-dark-mode-mobile-1.png differ diff --git a/ui/home/latestZetaChainCCTX/__screenshots__/LatestZetaChainCCTXs.pw.tsx_mobile_base-view-dark-mode-mobile-1.png b/ui/home/latestZetaChainCCTX/__screenshots__/LatestZetaChainCCTXs.pw.tsx_mobile_base-view-dark-mode-mobile-1.png new file mode 100644 index 0000000000..bbc6074f72 Binary files /dev/null and b/ui/home/latestZetaChainCCTX/__screenshots__/LatestZetaChainCCTXs.pw.tsx_mobile_base-view-dark-mode-mobile-1.png differ diff --git a/ui/pages/SearchResults.tsx b/ui/pages/SearchResults.tsx index a2770663f8..35cefaeb58 100644 --- a/ui/pages/SearchResults.tsx +++ b/ui/pages/SearchResults.tsx @@ -34,7 +34,7 @@ import useSearchQuery from 'ui/snippets/searchBar/useSearchQuery'; const SearchResultsPageContent = () => { const router = useRouter(); const withRedirectCheck = getQueryParamString(router.query.redirect) === 'true'; - const { query, redirectCheckQuery, searchTerm, debouncedSearchTerm, handleSearchTermChange } = useSearchQuery(withRedirectCheck); + const { query, redirectCheckQuery, searchTerm, debouncedSearchTerm, handleSearchTermChange, zetaChainCCTXQuery } = useSearchQuery(withRedirectCheck); const { data, isError, isPlaceholderData, pagination } = query; const [ showContent, setShowContent ] = React.useState(!withRedirectCheck); @@ -130,11 +130,25 @@ const SearchResultsPageContent = () => { return [ ...(pagination.page === 1 && !isLoading ? marketplaceApps.displayedApps.map((item) => ({ type: 'app' as const, app: item })) : []), + ...( + config.features.zetachain.isEnabled && + pagination.page === 1 && + !isLoading && + zetaChainCCTXQuery.data ? + zetaChainCCTXQuery.data.items.map((item) => ({ type: 'zetaChainCCTX' as const, cctx: item })) : []), futureBlockItem, ...apiData, ].filter(Boolean); - - }, [ data?.items, data?.next_page_params, isPlaceholderData, pagination.page, debouncedSearchTerm, marketplaceApps.displayedApps, isLoading ]); + }, [ + data?.items, + data?.next_page_params, + isPlaceholderData, + pagination.page, + debouncedSearchTerm, + marketplaceApps.displayedApps, + isLoading, + zetaChainCCTXQuery.data, + ]); const content = (() => { if (isError) { diff --git a/ui/pages/TransactionsZetaChain.tsx b/ui/pages/TransactionsZetaChain.tsx new file mode 100644 index 0000000000..9907aeb11f --- /dev/null +++ b/ui/pages/TransactionsZetaChain.tsx @@ -0,0 +1,162 @@ +import { useRouter } from 'next/router'; +import React from 'react'; + +import type { TabItemRegular } from 'toolkit/components/AdaptiveTabs/types'; + +import { route } from 'nextjs-routes'; + +import config from 'configs/app'; +import useIsMobile from 'lib/hooks/useIsMobile'; +import getQueryParamString from 'lib/router/getQueryParamString'; +import { SocketProvider } from 'lib/socket/context'; +import { TX } from 'stubs/tx'; +import { generateListStub } from 'stubs/utils'; +import { Link } from 'toolkit/chakra/link'; +import RoutedTabs from 'toolkit/components/RoutedTabs/RoutedTabs'; +import ActionBar from 'ui/shared/ActionBar'; +import IconSvg from 'ui/shared/IconSvg'; +import PageTitle from 'ui/shared/Page/PageTitle'; +import Pagination from 'ui/shared/pagination/Pagination'; +import useQueryWithPages from 'ui/shared/pagination/useQueryWithPages'; +import useIsAuth from 'ui/snippets/auth/useIsAuth'; +import TxsStats from 'ui/txs/TxsStats'; +import TxsWatchlist from 'ui/txs/TxsWatchlist'; +import TxsWithFrontendSorting from 'ui/txs/TxsWithFrontendSorting'; +import ZetaChainCCTXsTab from 'ui/txs/zetaChain/ZetaChainCCTXsTab'; +import ZetaChainEvmTransactions from 'ui/txs/zetaChain/ZetaChainEvmTransactions'; + +const ZETACHAIN_TABS = [ 'zetachain_validated', 'zetachain_pending' ]; +const CROSS_CHAIN_TABS = [ 'cctx_pending', 'cctx_mined' ]; + +const TransactionsZetaChain = () => { + const router = useRouter(); + const isMobile = useIsMobile(); + const tab = getQueryParamString(router.query.tab); + + const txsWithBlobsQuery = useQueryWithPages({ + resourceName: 'general:txs_with_blobs', + filters: { type: 'blob_transaction' }, + options: { + enabled: config.features.dataAvailability.isEnabled && tab === 'blob_txs', + placeholderData: generateListStub<'general:txs_with_blobs'>(TX, 50, { next_page_params: { + block_number: 10602877, + index: 8, + items_count: 50, + } }), + }, + }); + + const txsWatchlistQuery = useQueryWithPages({ + resourceName: 'general:txs_watchlist', + options: { + enabled: tab === 'watchlist', + placeholderData: generateListStub<'general:txs_watchlist'>(TX, 50, { next_page_params: { + block_number: 9005713, + index: 5, + items_count: 50, + } }), + }, + }); + + const isAuth = useIsAuth(); + + // for cctxs and evm txs we show pagination with the secondary tabs + const pagination = (() => { + switch (tab) { + case 'watchlist': return txsWatchlistQuery.pagination; + case 'blob_txs': return txsWithBlobsQuery.pagination; + default: return null; + } + })(); + + const topRow = (() => { + if (isMobile) { + return null; + } + + if (tab !== 'blob_txs' && tab !== 'watchlist') { + return null; + } + + const isAdvancedFilterEnabled = config.features.advancedFilter.isEnabled; + + if (!isAdvancedFilterEnabled && !pagination?.isVisible) { + return null; + } + + return ( + + { isAdvancedFilterEnabled && ( + + + Advanced filter + + ) } + { pagination?.isVisible && } + + ); + })(); + + const tabs: Array = [ + { + id: 'cctx', + title: 'Cross chain', + component: ( + + + + ), + subTabs: CROSS_CHAIN_TABS, + }, + { + id: 'zetachain', + title: 'ZetaChain EVM', + component: , + subTabs: ZETACHAIN_TABS, + }, + config.features.dataAvailability.isEnabled && { + id: 'blob_txs', + title: 'Blob txns', + component: ( + <> + + { topRow } + + + ), + }, + isAuth ? { + id: 'watchlist', + title: 'Watch list', + component: ( + <> + + { topRow } + + + ), + } : undefined, + ].filter(Boolean); + + return ( + <> + + + + ); +}; + +export default TransactionsZetaChain; diff --git a/ui/pages/ZetaChainCCTX.pw.tsx b/ui/pages/ZetaChainCCTX.pw.tsx new file mode 100644 index 0000000000..1489203ded --- /dev/null +++ b/ui/pages/ZetaChainCCTX.pw.tsx @@ -0,0 +1,43 @@ +import React from 'react'; + +import { zetaChainCCTX, zetaChainCCTXFailed } from 'mocks/zetaChain/zetaChainCCTX'; +import { zetaChainCCTXConfig } from 'mocks/zetaChain/zetaChainCCTXConfig'; +import { ENVS_MAP } from 'playwright/fixtures/mockEnvs'; +import { test, expect } from 'playwright/lib'; + +import ZetaChainCCTX from './ZetaChainCCTX'; + +const CCTX_CONFIG_URL = 'http://localhost:3000/zeta-config.json'; +const CCTX_HASH = '0x1c1e7410d7dfefe6173cc11efa47221e85587d3831c69108121198e0b2a86657'; + +const hooksConfig = { + router: { + query: { hash: CCTX_HASH }, + }, +}; + +test.beforeEach(async({ mockEnvs, mockTextAd, mockConfigResponse }) => { + await mockEnvs(ENVS_MAP.zetaChain); + await mockTextAd(); + await mockConfigResponse('NEXT_PUBLIC_ZETACHAIN_SERVICE_CHAINS_CONFIG_URL', CCTX_CONFIG_URL, zetaChainCCTXConfig); +}); + +test('successful transaction +@dark-mode +@mobile', async({ render, mockApiResponse }) => { + await mockApiResponse('zetachain:transaction', zetaChainCCTX, { + queryParams: { cctx_id: CCTX_HASH }, + }); + + const component = await render(, { hooksConfig }); + + await expect(component).toHaveScreenshot(); +}); + +test('failed transaction +@dark-mode +@mobile', async({ render, mockApiResponse }) => { + await mockApiResponse('zetachain:transaction', zetaChainCCTXFailed, { + queryParams: { cctx_id: CCTX_HASH }, + }); + + const component = await render(, { hooksConfig }); + + await expect(component).toHaveScreenshot(); +}); diff --git a/ui/pages/ZetaChainCCTX.tsx b/ui/pages/ZetaChainCCTX.tsx new file mode 100644 index 0000000000..fadf1d7539 --- /dev/null +++ b/ui/pages/ZetaChainCCTX.tsx @@ -0,0 +1,39 @@ +import { useRouter } from 'next/router'; +import React from 'react'; + +import useApiQuery from 'lib/api/useApiQuery'; +import throwOnResourceLoadError from 'lib/errors/throwOnResourceLoadError'; +import getQueryParamString from 'lib/router/getQueryParamString'; +import { zetaChainCCTX } from 'mocks/zetaChain/zetaChainCCTX'; +import TextAd from 'ui/shared/ad/TextAd'; +import CCTxEntityZetaChain from 'ui/shared/entities/tx/CCTxEntityZetaChain'; +import PageTitle from 'ui/shared/Page/PageTitle'; +import ZetaChainCCTXDetails from 'ui/txs/zetaChain/ZetaChainCCTXDetails'; + +const ZetaChainCCTX = () => { + const router = useRouter(); + + const hash = getQueryParamString(router.query.hash); + + const cctxQuery = useApiQuery('zetachain:transaction', { + queryParams: { cctx_id: hash }, + queryOptions: { + placeholderData: zetaChainCCTX, + }, + }); + + throwOnResourceLoadError(cctxQuery); + + return ( + <> + + } + /> + + + ); +}; + +export default ZetaChainCCTX; diff --git a/ui/pages/__screenshots__/ZetaChainCCTX.pw.tsx_dark-color-mode_failed-transaction-dark-mode-mobile-1.png b/ui/pages/__screenshots__/ZetaChainCCTX.pw.tsx_dark-color-mode_failed-transaction-dark-mode-mobile-1.png new file mode 100644 index 0000000000..b6fd7a9ed6 Binary files /dev/null and b/ui/pages/__screenshots__/ZetaChainCCTX.pw.tsx_dark-color-mode_failed-transaction-dark-mode-mobile-1.png differ diff --git a/ui/pages/__screenshots__/ZetaChainCCTX.pw.tsx_dark-color-mode_successful-transaction-dark-mode-mobile-1.png b/ui/pages/__screenshots__/ZetaChainCCTX.pw.tsx_dark-color-mode_successful-transaction-dark-mode-mobile-1.png new file mode 100644 index 0000000000..942104b7a0 Binary files /dev/null and b/ui/pages/__screenshots__/ZetaChainCCTX.pw.tsx_dark-color-mode_successful-transaction-dark-mode-mobile-1.png differ diff --git a/ui/pages/__screenshots__/ZetaChainCCTX.pw.tsx_default_failed-transaction-dark-mode-mobile-1.png b/ui/pages/__screenshots__/ZetaChainCCTX.pw.tsx_default_failed-transaction-dark-mode-mobile-1.png new file mode 100644 index 0000000000..1444a7f1d0 Binary files /dev/null and b/ui/pages/__screenshots__/ZetaChainCCTX.pw.tsx_default_failed-transaction-dark-mode-mobile-1.png differ diff --git a/ui/pages/__screenshots__/ZetaChainCCTX.pw.tsx_default_successful-transaction-dark-mode-mobile-1.png b/ui/pages/__screenshots__/ZetaChainCCTX.pw.tsx_default_successful-transaction-dark-mode-mobile-1.png new file mode 100644 index 0000000000..6ebef2dd42 Binary files /dev/null and b/ui/pages/__screenshots__/ZetaChainCCTX.pw.tsx_default_successful-transaction-dark-mode-mobile-1.png differ diff --git a/ui/pages/__screenshots__/ZetaChainCCTX.pw.tsx_mobile_failed-transaction-dark-mode-mobile-1.png b/ui/pages/__screenshots__/ZetaChainCCTX.pw.tsx_mobile_failed-transaction-dark-mode-mobile-1.png new file mode 100644 index 0000000000..bedc34c6d6 Binary files /dev/null and b/ui/pages/__screenshots__/ZetaChainCCTX.pw.tsx_mobile_failed-transaction-dark-mode-mobile-1.png differ diff --git a/ui/pages/__screenshots__/ZetaChainCCTX.pw.tsx_mobile_successful-transaction-dark-mode-mobile-1.png b/ui/pages/__screenshots__/ZetaChainCCTX.pw.tsx_mobile_successful-transaction-dark-mode-mobile-1.png new file mode 100644 index 0000000000..faada218d5 Binary files /dev/null and b/ui/pages/__screenshots__/ZetaChainCCTX.pw.tsx_mobile_successful-transaction-dark-mode-mobile-1.png differ diff --git a/ui/searchResults/SearchResultListItem.tsx b/ui/searchResults/SearchResultListItem.tsx index 4ba4c1658d..a896c84bc8 100644 --- a/ui/searchResults/SearchResultListItem.tsx +++ b/ui/searchResults/SearchResultListItem.tsx @@ -216,6 +216,27 @@ const SearchResultListItem = ({ data, searchTerm, isLoading, addressFormat }: Pr ); } + case 'zetaChainCCTX': { + return ( + + + + + + + ); + } + case 'tac_operation': { return ( @@ -349,6 +370,11 @@ const SearchResultListItem = ({ data, searchTerm, isLoading, addressFormat }: Pr { dayjs(data.timestamp).format('llll') } ); } + case 'zetaChainCCTX': { + return ( + { dayjs(Number(data.cctx.last_update_timestamp) * 1000).format('llll') } + ); + } case 'tac_operation': { return ( { dayjs(data.tac_operation.timestamp).format('llll') } diff --git a/ui/searchResults/SearchResultTableItem.tsx b/ui/searchResults/SearchResultTableItem.tsx index 3879597cd4..30c81c255d 100644 --- a/ui/searchResults/SearchResultTableItem.tsx +++ b/ui/searchResults/SearchResultTableItem.tsx @@ -326,6 +326,34 @@ const SearchResultTableItem = ({ data, searchTerm, isLoading, addressFormat }: P ); } + case 'zetaChainCCTX': { + return ( + <> + + + + + + + + + + { dayjs(Number(data.cctx.last_update_timestamp) * 1000).format('llll') } + + + ); + } + case 'tac_operation': { return ( <> diff --git a/ui/shared/ChainIconWithTooltip.tsx b/ui/shared/ChainIconWithTooltip.tsx new file mode 100644 index 0000000000..baf4430043 --- /dev/null +++ b/ui/shared/ChainIconWithTooltip.tsx @@ -0,0 +1,37 @@ +import { Box } from '@chakra-ui/react'; +import React from 'react'; + +import type { ChainInfo } from 'types/client/chainInfo'; + +import { Image } from 'toolkit/chakra/image'; +import { Tooltip } from 'toolkit/chakra/tooltip'; +import IconSvg from 'ui/shared/IconSvg'; + +type Props = { + chain?: ChainInfo; + isLoading?: boolean; +}; + +const Placeholder = () => { + return ( + + ); +}; + +Placeholder.displayName = 'Placeholder'; + +const ChainIconWithTooltip = ({ chain }: Props) => { + return ( + + + { chain?.chain_logo ? }/> : } + + + ); +}; + +export default ChainIconWithTooltip; diff --git a/ui/shared/SocketNewItemsNotice.tsx b/ui/shared/SocketNewItemsNotice.tsx index 2efa9f3368..85ff7030ef 100644 --- a/ui/shared/SocketNewItemsNotice.tsx +++ b/ui/shared/SocketNewItemsNotice.tsx @@ -11,7 +11,7 @@ interface InjectedProps { } interface Props { - type?: 'transaction' | 'token_transfer' | 'deposit' | 'block' | 'flashblock'; + type?: 'transaction' | 'token_transfer' | 'deposit' | 'block' | 'flashblock' | 'cross_chain_transaction'; children?: (props: InjectedProps) => React.JSX.Element; className?: string; url?: string; @@ -46,6 +46,9 @@ const SocketNewItemsNotice = chakra(({ children, className, url, num, showErrorA case 'flashblock': name = 'flashblock'; break; + case 'cross_chain_transaction': + name = 'cross chain transaction'; + break; default: name = 'transaction'; break; @@ -55,6 +58,15 @@ const SocketNewItemsNotice = chakra(({ children, className, url, num, showErrorA return `scanning new ${ name }s...`; } + if (type === 'cross_chain_transaction') { + return ( + <> + More { name }s available + have come in + + ); + } + return ( <> { num.toLocaleString() } more { name }{ num > 1 ? 's' : '' } diff --git a/ui/shared/address/AddressFromTo.tsx b/ui/shared/address/AddressFromTo.tsx index a5d6b2dca3..79bdf2c404 100644 --- a/ui/shared/address/AddressFromTo.tsx +++ b/ui/shared/address/AddressFromTo.tsx @@ -6,6 +6,7 @@ import type { EntityProps } from 'ui/shared/entities/address/AddressEntity'; import AddressEntity from 'ui/shared/entities/address/AddressEntity'; import AddressEntityWithTokenFilter from 'ui/shared/entities/address/AddressEntityWithTokenFilter'; +import AddressEntityZetaChain from '../entities/address/AddressEntityZetaChain'; import AddressFromToIcon from './AddressFromToIcon'; import { getTxCourseType } from './utils'; @@ -22,9 +23,18 @@ interface Props { tokenSymbol?: string; truncation?: EntityProps['truncation']; noIcon?: boolean; + zetaChainFromChainId?: string; + zetaChainToChainId?: string; } -const AddressFromTo = ({ from, to, current, mode: modeProp, className, isLoading, tokenHash = '', tokenSymbol = '', noIcon }: Props) => { +const AddressFromTo = ({ + from, + zetaChainFromChainId, + to, + zetaChainToChainId, + current, + mode: modeProp, + className, isLoading, tokenHash = '', tokenSymbol = '', noIcon }: Props) => { const mode = useBreakpointValue( { base: (typeof modeProp === 'object' && 'base' in modeProp ? modeProp.base : modeProp), @@ -33,7 +43,26 @@ const AddressFromTo = ({ from, to, current, mode: modeProp, className, isLoading }, ) ?? 'long'; - const Entity = tokenHash && tokenSymbol ? AddressEntityWithTokenFilter : AddressEntity; + const EntityFrom = (() => { + if (zetaChainFromChainId !== undefined) { + return AddressEntityZetaChain; + } + if (tokenHash && tokenSymbol) { + return AddressEntityWithTokenFilter; + } + return AddressEntity; + })(); + + const EntityTo = (() => { + if (zetaChainToChainId !== undefined) { + return AddressEntityZetaChain; + } + if (tokenHash && tokenSymbol) { + return AddressEntityWithTokenFilter; + } + return AddressEntity; + })(); + const isOutgoing = current ? current.toLowerCase() === from.hash.toLowerCase() : false; const isIncoming = current ? current.toLowerCase() === to?.hash?.toLowerCase() : false; @@ -46,7 +75,7 @@ const AddressFromTo = ({ from, to, current, mode: modeProp, className, isLoading type={ getTxCourseType(from.hash, to?.hash, current) } transform="rotate(90deg)" /> - { to && ( - ) } @@ -82,7 +113,7 @@ const AddressFromTo = ({ from, to, current, mode: modeProp, className, isLoading return ( - { to && ( - ) } diff --git a/ui/shared/entities/address/AddressEntityInterop.tsx b/ui/shared/entities/address/AddressEntityInterop.tsx index 8710b9881e..38a62858cd 100644 --- a/ui/shared/entities/address/AddressEntityInterop.tsx +++ b/ui/shared/entities/address/AddressEntityInterop.tsx @@ -11,6 +11,7 @@ import IconSvg from 'ui/shared/IconSvg'; import { distributeEntityProps } from '../base/utils'; import * as AddressEntity from './AddressEntity'; + interface Props extends Omit { chain: ChainInfo | null; } @@ -40,7 +41,7 @@ const IconStub = () => { ); }; -const AddressEntryInterop = ({ chain, ...props }: Props) => { +const AddressEntityInterop = ({ chain, ...props }: Props) => { const partsProps = distributeEntityProps(props); const href = chain?.instance_url ? chain.instance_url.replace(/\/$/, '') + route({ @@ -62,6 +63,7 @@ const AddressEntryInterop = ({ chain, ...props }: Props) => { right="4px" src={ chain.chain_logo } alt={ chain.chain_name || 'external chain logo' } + fallback={ } width="14px" height="14px" borderRadius="base" @@ -86,11 +88,13 @@ const AddressEntryInterop = ({ chain, ...props }: Props) => { ) : ( - + + + ) } ); }; -export default chakra(AddressEntryInterop); +export default chakra(AddressEntityInterop); diff --git a/ui/shared/entities/address/AddressEntityZetaChain.pw.tsx b/ui/shared/entities/address/AddressEntityZetaChain.pw.tsx new file mode 100644 index 0000000000..0692a70b4b --- /dev/null +++ b/ui/shared/entities/address/AddressEntityZetaChain.pw.tsx @@ -0,0 +1,59 @@ +import React from 'react'; + +import * as addressMock from 'mocks/address/address'; +import { zetaChainCCTXConfig } from 'mocks/zetaChain/zetaChainCCTXConfig'; +import { ENVS_MAP } from 'playwright/fixtures/mockEnvs'; +import { test, expect } from 'playwright/lib'; + +import AddressEntityZetaChain from './AddressEntityZetaChain'; + +test.use({ viewport: { width: 180, height: 140 } }); + +const CCTX_CONFIG_URL = 'http://localhost:3000/zeta-config.json'; + +test.beforeEach(async({ mockEnvs, mockConfigResponse }) => { + await mockEnvs([ + ...ENVS_MAP.zetaChain, + [ 'NEXT_PUBLIC_NETWORK_ID', '7001' ], + [ 'NEXT_PUBLIC_NETWORK_ICON', 'https://example.com/zeta.svg' ], + [ 'NEXT_PUBLIC_NETWORK_ICON_DARK', 'https://example.com/zeta-dark.jpg' ], + ]); + await mockConfigResponse('NEXT_PUBLIC_ZETACHAIN_SERVICE_CHAINS_CONFIG_URL', CCTX_CONFIG_URL, zetaChainCCTXConfig); +}); + +test('with chain icon', async({ render, mockAssetResponse }) => { + await mockAssetResponse('https://example.com/sepolia-logo.svg', './playwright/mocks/image_svg.svg'); + const component = await render( + , + ); + + await expect(component).toHaveScreenshot(); +}); + +test('with chain icon stub +@dark-mode', async({ render }) => { + const component = await render( + , + ); + + await expect(component).toHaveScreenshot(); +}); + +test('with current chain icon +@dark-mode', async({ render, mockConfigResponse }) => { + await mockConfigResponse('NEXT_PUBLIC_NETWORK_ICON', 'https://example.com/zeta.svg', './playwright/mocks/image_svg.svg', true); + await mockConfigResponse('NEXT_PUBLIC_NETWORK_ICON_DARK', 'https://example.com/zeta-dark.jpg', './playwright/mocks/image_s.jpg', true); + + const component = await render( + , + ); + + await expect(component).toHaveScreenshot(); +}); diff --git a/ui/shared/entities/address/AddressEntityZetaChain.tsx b/ui/shared/entities/address/AddressEntityZetaChain.tsx new file mode 100644 index 0000000000..2729fa3d14 --- /dev/null +++ b/ui/shared/entities/address/AddressEntityZetaChain.tsx @@ -0,0 +1,125 @@ +import { Box, chakra, Flex } from '@chakra-ui/react'; +import React from 'react'; + +import { route } from 'nextjs-routes'; + +import config from 'configs/app'; +import { useColorModeValue } from 'toolkit/chakra/color-mode'; +import { Image } from 'toolkit/chakra/image'; +import { SkeletonCircle } from 'toolkit/chakra/skeleton'; +import { Tooltip } from 'toolkit/chakra/tooltip'; +import { unknownAddress } from 'ui/shared/address/utils'; +import IconSvg from 'ui/shared/IconSvg'; +import useZetaChainConfig from 'ui/zetaChain/useZetaChainConfig'; + +import { distributeEntityProps, getIconProps } from '../base/utils'; +import * as AddressEntityBase from './AddressEntity'; + +interface Props extends Omit { + chainId?: string; + address: { hash: string }; +} + +const AddressEntityZetaChain = ({ chainId, ...props }: Props) => { + const { data: chainsConfig } = useZetaChainConfig(); + + const addressFull = { ...unknownAddress, hash: props.address.hash }; + const addressEntityProps = { addressFull, ...props }; + + const partsProps = distributeEntityProps(addressEntityProps); + const chain = chainsConfig?.find((chain) => chain.chain_id.toString() === chainId); + + const isCurrentChain = chainId === config.chain.id; + + const href = (() => { + const blockscoutAddressRoute = route({ + pathname: '/address/[hash]', + query: { + ...props.query, + hash: props.address.hash, + }, + }); + if (isCurrentChain) { + return blockscoutAddressRoute; + } + if (chain?.instance_url) { + return chain.instance_url.replace(/\/$/, '') + blockscoutAddressRoute; + } + if (chain?.address_url_template) { + return chain.address_url_template.replace('{hash}', props.address.hash); + } + return null; + })(); + + const zetaChainIcon = useColorModeValue(config.UI.navigation.icon.default, config.UI.navigation.icon.dark || config.UI.navigation.icon.default); + const chainLogo = isCurrentChain ? zetaChainIcon : chain?.chain_logo; + const chainName = isCurrentChain ? config.chain.name : chain?.chain_name; + const iconStyles = getIconProps(partsProps.icon, false); + + const addressIcon = (() => { + if (props.isLoading) { + return ; + } + + const iconStub = ( + + ); + + if (chainLogo) { + return ( + + chain logo + + ); + } + + return ( + + { iconStub } + + ); + })(); + + return ( + + + { addressIcon } + + { href ? ( + + + + ) : ( + + + + ) } + + + ); +}; + +export default chakra(AddressEntityZetaChain); diff --git a/ui/shared/entities/address/__screenshots__/AddressEntityInterop.pw.tsx_dark-color-mode_with-chain-icon-stub-dark-mode-1.png b/ui/shared/entities/address/__screenshots__/AddressEntityInterop.pw.tsx_dark-color-mode_with-chain-icon-stub-dark-mode-1.png index dde526f9cc..de26a4df35 100644 Binary files a/ui/shared/entities/address/__screenshots__/AddressEntityInterop.pw.tsx_dark-color-mode_with-chain-icon-stub-dark-mode-1.png and b/ui/shared/entities/address/__screenshots__/AddressEntityInterop.pw.tsx_dark-color-mode_with-chain-icon-stub-dark-mode-1.png differ diff --git a/ui/shared/entities/address/__screenshots__/AddressEntityInterop.pw.tsx_default_with-chain-icon-1.png b/ui/shared/entities/address/__screenshots__/AddressEntityInterop.pw.tsx_default_with-chain-icon-1.png index 1d6327035b..ce5724fc19 100644 Binary files a/ui/shared/entities/address/__screenshots__/AddressEntityInterop.pw.tsx_default_with-chain-icon-1.png and b/ui/shared/entities/address/__screenshots__/AddressEntityInterop.pw.tsx_default_with-chain-icon-1.png differ diff --git a/ui/shared/entities/address/__screenshots__/AddressEntityInterop.pw.tsx_default_with-chain-icon-stub-dark-mode-1.png b/ui/shared/entities/address/__screenshots__/AddressEntityInterop.pw.tsx_default_with-chain-icon-stub-dark-mode-1.png index 395febc68e..48b5dc16c1 100644 Binary files a/ui/shared/entities/address/__screenshots__/AddressEntityInterop.pw.tsx_default_with-chain-icon-stub-dark-mode-1.png and b/ui/shared/entities/address/__screenshots__/AddressEntityInterop.pw.tsx_default_with-chain-icon-stub-dark-mode-1.png differ diff --git a/ui/shared/entities/address/__screenshots__/AddressEntityZetaChain.pw.tsx_dark-color-mode_with-chain-icon-stub-dark-mode-1.png b/ui/shared/entities/address/__screenshots__/AddressEntityZetaChain.pw.tsx_dark-color-mode_with-chain-icon-stub-dark-mode-1.png new file mode 100644 index 0000000000..2b42a2de25 Binary files /dev/null and b/ui/shared/entities/address/__screenshots__/AddressEntityZetaChain.pw.tsx_dark-color-mode_with-chain-icon-stub-dark-mode-1.png differ diff --git a/ui/shared/entities/address/__screenshots__/AddressEntityZetaChain.pw.tsx_dark-color-mode_with-current-chain-icon-dark-mode-1.png b/ui/shared/entities/address/__screenshots__/AddressEntityZetaChain.pw.tsx_dark-color-mode_with-current-chain-icon-dark-mode-1.png new file mode 100644 index 0000000000..f3f2296779 Binary files /dev/null and b/ui/shared/entities/address/__screenshots__/AddressEntityZetaChain.pw.tsx_dark-color-mode_with-current-chain-icon-dark-mode-1.png differ diff --git a/ui/shared/entities/address/__screenshots__/AddressEntityZetaChain.pw.tsx_default_with-chain-icon-1.png b/ui/shared/entities/address/__screenshots__/AddressEntityZetaChain.pw.tsx_default_with-chain-icon-1.png new file mode 100644 index 0000000000..16b5136bc9 Binary files /dev/null and b/ui/shared/entities/address/__screenshots__/AddressEntityZetaChain.pw.tsx_default_with-chain-icon-1.png differ diff --git a/ui/shared/entities/address/__screenshots__/AddressEntityZetaChain.pw.tsx_default_with-chain-icon-stub-dark-mode-1.png b/ui/shared/entities/address/__screenshots__/AddressEntityZetaChain.pw.tsx_default_with-chain-icon-stub-dark-mode-1.png new file mode 100644 index 0000000000..330387e889 Binary files /dev/null and b/ui/shared/entities/address/__screenshots__/AddressEntityZetaChain.pw.tsx_default_with-chain-icon-stub-dark-mode-1.png differ diff --git a/ui/shared/entities/address/__screenshots__/AddressEntityZetaChain.pw.tsx_default_with-current-chain-icon-dark-mode-1.png b/ui/shared/entities/address/__screenshots__/AddressEntityZetaChain.pw.tsx_default_with-current-chain-icon-dark-mode-1.png new file mode 100644 index 0000000000..23bbaa6af5 Binary files /dev/null and b/ui/shared/entities/address/__screenshots__/AddressEntityZetaChain.pw.tsx_default_with-current-chain-icon-dark-mode-1.png differ diff --git a/ui/shared/entities/tx/CCTxEntityZetaChain.tsx b/ui/shared/entities/tx/CCTxEntityZetaChain.tsx new file mode 100644 index 0000000000..36cc33c0e9 --- /dev/null +++ b/ui/shared/entities/tx/CCTxEntityZetaChain.tsx @@ -0,0 +1,14 @@ +import { chakra } from '@chakra-ui/react'; +import React from 'react'; + +import { route } from 'nextjs/routes'; + +import * as TxEntity from './TxEntity'; + +const CCTxEntityZetaChain = (props: TxEntity.EntityProps) => { + const defaultHref = route({ pathname: '/cc/tx/[hash]', query: { hash: props.hash } }); + + return ; +}; + +export default chakra(CCTxEntityZetaChain); diff --git a/ui/shared/entities/tx/TxEntityInterop.tsx b/ui/shared/entities/tx/TxEntityInterop.tsx index 7d2e84782e..683b0a2640 100644 --- a/ui/shared/entities/tx/TxEntityInterop.tsx +++ b/ui/shared/entities/tx/TxEntityInterop.tsx @@ -1,7 +1,7 @@ import { Box, chakra } from '@chakra-ui/react'; import React from 'react'; -import type { ChainInfo } from 'types/api/interop'; +import type { ChainInfo } from 'types/client/chainInfo'; import { route } from 'nextjs-routes'; @@ -15,7 +15,7 @@ import { distributeEntityProps } from '../base/utils'; import * as TxEntity from './TxEntity'; type Props = { - chain: ChainInfo | null; + chain?: ChainInfo | null; hash?: string | null; } & Omit; @@ -56,7 +56,7 @@ const TxEntityInterop = ({ chain, hash, ...props }: Props) => { return ( - { chain && ( + { chain && !props.noIcon && ( { chain.chain_logo ? ( @@ -74,7 +74,7 @@ const TxEntityInterop = ({ chain, hash, ...props }: Props) => { ) } - { !chain && ( + { !chain && !props.noIcon && ( ) } { hash && ( @@ -84,7 +84,9 @@ const TxEntityInterop = ({ chain, hash, ...props }: Props) => { ) : ( - + + + ) } diff --git a/ui/shared/entities/tx/TxEntityZetaChainExternal.tsx b/ui/shared/entities/tx/TxEntityZetaChainExternal.tsx new file mode 100644 index 0000000000..1380e2664b --- /dev/null +++ b/ui/shared/entities/tx/TxEntityZetaChainExternal.tsx @@ -0,0 +1,31 @@ +import { chakra } from '@chakra-ui/react'; +import React from 'react'; + +import { route } from 'nextjs/routes'; + +import useZetaChainConfig from 'ui/zetaChain/useZetaChainConfig'; + +import * as TxEntity from './TxEntity'; + +type Props = { + chainId: string; +} & Omit; + +const TxEntityZetaChainExternal = (props: Props) => { + const { data: chainsConfig } = useZetaChainConfig(); + const chain = chainsConfig?.find((chain) => chain.chain_id.toString() === props.chainId); + + const defaultHref = (() => { + if (chain?.instance_url) { + return chain.instance_url.replace(/\/$/, '') + route({ pathname: '/tx/[hash]', query: { hash: props.hash } }); + } + if (chain?.tx_url_template) { + return chain.tx_url_template.replace('{hash}', props.hash); + } + return; + })(); + + return ; +}; + +export default chakra(TxEntityZetaChainExternal); diff --git a/ui/shared/entities/tx/__screenshots__/TxEntityInterop.pw.tsx_default_with-chain-icon-stub-dark-mode-1.png b/ui/shared/entities/tx/__screenshots__/TxEntityInterop.pw.tsx_default_with-chain-icon-stub-dark-mode-1.png index dab1681106..894e07e143 100644 Binary files a/ui/shared/entities/tx/__screenshots__/TxEntityInterop.pw.tsx_default_with-chain-icon-stub-dark-mode-1.png and b/ui/shared/entities/tx/__screenshots__/TxEntityInterop.pw.tsx_default_with-chain-icon-stub-dark-mode-1.png differ diff --git a/ui/shared/entities/tx/__screenshots__/TxEntityWithExternalChain.pw.tsx_dark-color-mode_with-chain-icon-stub-dark-mode-1.png b/ui/shared/entities/tx/__screenshots__/TxEntityWithExternalChain.pw.tsx_dark-color-mode_with-chain-icon-stub-dark-mode-1.png new file mode 100644 index 0000000000..4561157675 Binary files /dev/null and b/ui/shared/entities/tx/__screenshots__/TxEntityWithExternalChain.pw.tsx_dark-color-mode_with-chain-icon-stub-dark-mode-1.png differ diff --git a/ui/shared/entities/tx/__screenshots__/TxEntityWithExternalChain.pw.tsx_default_with-chain-icon-1.png b/ui/shared/entities/tx/__screenshots__/TxEntityWithExternalChain.pw.tsx_default_with-chain-icon-1.png new file mode 100644 index 0000000000..92c295d6ff Binary files /dev/null and b/ui/shared/entities/tx/__screenshots__/TxEntityWithExternalChain.pw.tsx_default_with-chain-icon-1.png differ diff --git a/ui/shared/entities/tx/__screenshots__/TxEntityWithExternalChain.pw.tsx_default_with-chain-icon-stub-dark-mode-1.png b/ui/shared/entities/tx/__screenshots__/TxEntityWithExternalChain.pw.tsx_default_with-chain-icon-stub-dark-mode-1.png new file mode 100644 index 0000000000..894e07e143 Binary files /dev/null and b/ui/shared/entities/tx/__screenshots__/TxEntityWithExternalChain.pw.tsx_default_with-chain-icon-stub-dark-mode-1.png differ diff --git a/ui/shared/pagination/useQueryWithPages.ts b/ui/shared/pagination/useQueryWithPages.ts index 424c0b35ee..4c9b8abb8a 100644 --- a/ui/shared/pagination/useQueryWithPages.ts +++ b/ui/shared/pagination/useQueryWithPages.ts @@ -21,6 +21,7 @@ import getQueryParamString from 'lib/router/getQueryParamString'; export interface Params { resourceName: Resource; options?: UseApiQueryParams['queryOptions']; + queryParams?: UseApiQueryParams['queryParams']; pathParams?: UseApiQueryParams['pathParams']; filters?: PaginationFilters; sorting?: PaginationSorting; @@ -70,6 +71,7 @@ export default function useQueryWithPages): QueryWithPagesResult { @@ -89,7 +91,7 @@ export default function useQueryWithPages { scrollRef?.current ? scrollRef.current.scrollIntoView(true) : animateScroll.scrollToTop({ duration: 0 }); @@ -235,7 +237,15 @@ export default function useQueryWithPages 0 : false; + let hasNextPage = false; + if (nextPageParams) { + // ¯\_(ツ)_/¯ + if (resourceName === 'zetachain:transactions' && 'limit' in nextPageParams) { + hasNextPage = Boolean(nextPageParams.limit && nextPageParams.limit > 0); + } else { + hasNextPage = Object.keys(nextPageParams).length > 0; + } + } const pagination = { page, diff --git a/ui/shared/search/utils.ts b/ui/shared/search/utils.ts index 836eaa6bf8..a61eec0b7e 100644 --- a/ui/shared/search/utils.ts +++ b/ui/shared/search/utils.ts @@ -1,14 +1,16 @@ +import type { ZetaChainCCTX } from 'types/api/zetaChain'; import type { MarketplaceApp } from 'types/client/marketplace'; import type { SearchResultItem } from 'types/client/search'; import config from 'configs/app'; export type ApiCategory = 'token' | 'nft' | 'address' | 'public_tag' | 'transaction' | 'block' | 'user_operation' | 'blob' | 'domain' | 'tac_operation'; -export type Category = ApiCategory | 'app'; +export type Category = ApiCategory | 'app' | 'zetaChainCCTX'; export type ItemsCategoriesMap = Record> & -Record<'app', Array>; +Record<'app', Array> & +Record<'zetaChainCCTX', Array>; export type SearchResultAppItem = { type: 'app'; @@ -24,6 +26,7 @@ export const searchCategories: Array<{ id: Category; title: string }> = [ { id: 'transaction', title: 'Transactions' }, { id: 'block', title: 'Blocks' }, { id: 'tac_operation', title: 'Operations' }, + { id: 'zetaChainCCTX', title: 'CCTXs' }, ]; if (config.features.userOps.isEnabled) { @@ -50,6 +53,7 @@ export const searchItemTitles: Record { let icon: IconName; let colorPalette: BadgeProps['colorPalette']; - const capitalizedText = capitalizeFirstLetter(text); - switch (type) { case 'ok': icon = 'status/success'; @@ -36,11 +34,21 @@ const StatusTag = ({ type, text, errorText, ...rest }: Props) => { break; } - const startElement = ; + const iconElement = ; + + if (!text) { + return ( + + { iconElement } + + ); + } + + const capitalizedText = capitalizeFirstLetter(text); return ( - + { capitalizedText } diff --git a/ui/shared/zetaChain/ZetaChainCCTXReducedStatus.tsx b/ui/shared/zetaChain/ZetaChainCCTXReducedStatus.tsx new file mode 100644 index 0000000000..2996e37a31 --- /dev/null +++ b/ui/shared/zetaChain/ZetaChainCCTXReducedStatus.tsx @@ -0,0 +1,46 @@ +import React from 'react'; + +import type { ZetaChainCCTXStatusReduced } from 'types/api/zetaChain'; + +import StatusTag, { type StatusTagType } from 'ui/shared/statusTag/StatusTag'; + +type Props = { + status: ZetaChainCCTXStatusReduced; + isLoading?: boolean; + type?: 'reduced' | 'full'; +}; + +const ZetaChainCCTXReducedStatus = ({ status, isLoading, type = 'reduced' }: Props) => { + let statusTagType: StatusTagType; + switch (status) { + case 'SUCCESS': + statusTagType = 'ok'; + break; + case 'PENDING': + statusTagType = 'pending'; + break; + case 'FAILED': + statusTagType = 'error'; + break; + } + + if (type === 'full') { + let text: string; + switch (status) { + case 'SUCCESS': + text = 'Success'; + break; + case 'PENDING': + text = 'Pending'; + break; + case 'FAILED': + text = 'Failed'; + break; + } + return ; + } + + return ; +}; + +export default ZetaChainCCTXReducedStatus; diff --git a/ui/shared/zetaChain/ZetaChainCCTXStatusTag.tsx b/ui/shared/zetaChain/ZetaChainCCTXStatusTag.tsx new file mode 100644 index 0000000000..48fa5e6283 --- /dev/null +++ b/ui/shared/zetaChain/ZetaChainCCTXStatusTag.tsx @@ -0,0 +1,29 @@ +import React from 'react'; + +import type { ZetaChainCCTXStatus } from 'types/api/zetaChain'; + +import { Tag } from 'toolkit/chakra/tag'; + +type Props = { + status: ZetaChainCCTXStatus; + isLoading?: boolean; +}; + +const TagText: Record = { + PENDING_OUTBOUND: 'Pending outbound', + PENDING_INBOUND: 'Pending inbound', + OUTBOUND_MINED: 'Outbound mined', + PENDING_REVERT: 'Pending revert', + ABORTED: 'Aborted', + REVERTED: 'Reverted', +}; + +const ZetaChainCCTXStatusTag = ({ status, isLoading }: Props) => { + return ( + + { TagText[status] } + + ); +}; + +export default React.memo(ZetaChainCCTXStatusTag); diff --git a/ui/shared/zetaChain/ZetaChainCCTXValue.tsx b/ui/shared/zetaChain/ZetaChainCCTXValue.tsx new file mode 100644 index 0000000000..34721ca10c --- /dev/null +++ b/ui/shared/zetaChain/ZetaChainCCTXValue.tsx @@ -0,0 +1,49 @@ +import { Text, chakra } from '@chakra-ui/react'; +import React from 'react'; + +import type { ZetaChainCCTXCoinType } from 'types/api/zetaChain'; + +import config from 'configs/app'; +import getCurrencyValue from 'lib/getCurrencyValue'; +import { Skeleton } from 'toolkit/chakra/skeleton'; + +type Props = { + coinType: ZetaChainCCTXCoinType; + tokenSymbol?: string; + amount: string; + decimals?: string | null; + isLoading?: boolean; + className?: string; + accuracy?: number; +}; + +const ZetaChainCCTXValue = ({ coinType, tokenSymbol, amount, decimals, isLoading, className, accuracy = 8 }: Props) => { + let unit: string; + let value: string | undefined; + switch (coinType) { + case 'ERC20': + unit = tokenSymbol || 'Unnamed token'; + value = getCurrencyValue({ value: amount, decimals: decimals || '18', accuracy }).valueStr; + break; + case 'ZETA': + unit = config.chain.currency.symbol || config.chain.currency.name || ''; + value = getCurrencyValue({ value: amount, decimals: config.chain.currency.decimals.toString() || '18', accuracy }).valueStr; + break; + case 'GAS': + unit = tokenSymbol || 'Unnamed token'; + value = getCurrencyValue({ value: amount, decimals: decimals || '18', accuracy }).valueStr; + break; + default: + unit = '-'; + break; + } + + return ( + + { value } + { unit } + + ); +}; + +export default React.memo(chakra(ZetaChainCCTXValue)); diff --git a/ui/snippets/navigation/vertical/NavLinkGroup.tsx b/ui/snippets/navigation/vertical/NavLinkGroup.tsx index 21a2999c28..0e55ab3ba1 100644 --- a/ui/snippets/navigation/vertical/NavLinkGroup.tsx +++ b/ui/snippets/navigation/vertical/NavLinkGroup.tsx @@ -25,7 +25,7 @@ const NavLinkGroup = ({ item, isCollapsed }: Props) => { const isHighlighted = checkRouteHighlight(item.subItems); const content = ( - + { item.text } diff --git a/ui/snippets/navigation/vertical/__screenshots__/NavigationDesktop.pw.tsx_default_with-highlighted-routes-with-submenu-1.png b/ui/snippets/navigation/vertical/__screenshots__/NavigationDesktop.pw.tsx_default_with-highlighted-routes-with-submenu-1.png index 92dbfdba6f..0228405cf5 100644 Binary files a/ui/snippets/navigation/vertical/__screenshots__/NavigationDesktop.pw.tsx_default_with-highlighted-routes-with-submenu-1.png and b/ui/snippets/navigation/vertical/__screenshots__/NavigationDesktop.pw.tsx_default_with-highlighted-routes-with-submenu-1.png differ diff --git a/ui/snippets/navigation/vertical/__screenshots__/NavigationDesktop.pw.tsx_default_with-submenu-base-view-1.png b/ui/snippets/navigation/vertical/__screenshots__/NavigationDesktop.pw.tsx_default_with-submenu-base-view-1.png index 048f07a0c4..5b748e6241 100644 Binary files a/ui/snippets/navigation/vertical/__screenshots__/NavigationDesktop.pw.tsx_default_with-submenu-base-view-1.png and b/ui/snippets/navigation/vertical/__screenshots__/NavigationDesktop.pw.tsx_default_with-submenu-base-view-1.png differ diff --git a/ui/snippets/navigation/vertical/__screenshots__/NavigationDesktop.pw.tsx_default_with-submenu-xl-screen-base-view-1.png b/ui/snippets/navigation/vertical/__screenshots__/NavigationDesktop.pw.tsx_default_with-submenu-xl-screen-base-view-1.png index 272553acf6..9f01c8e2cb 100644 Binary files a/ui/snippets/navigation/vertical/__screenshots__/NavigationDesktop.pw.tsx_default_with-submenu-xl-screen-base-view-1.png and b/ui/snippets/navigation/vertical/__screenshots__/NavigationDesktop.pw.tsx_default_with-submenu-xl-screen-base-view-1.png differ diff --git a/ui/snippets/searchBar/SearchBar.tsx b/ui/snippets/searchBar/SearchBar.tsx index 418b675fbc..e66d6f46d9 100644 --- a/ui/snippets/searchBar/SearchBar.tsx +++ b/ui/snippets/searchBar/SearchBar.tsx @@ -38,7 +38,7 @@ const SearchBar = ({ isHomepage }: Props) => { const recentSearchKeywords = getRecentSearchKeywords(); - const { searchTerm, debouncedSearchTerm, handleSearchTermChange, query } = useQuickSearchQuery(); + const { searchTerm, debouncedSearchTerm, handleSearchTermChange, query, zetaChainCCTXQuery, cosmosHashType } = useQuickSearchQuery(); const handleSubmit = React.useCallback((event: FormEvent) => { event.preventDefault(); @@ -131,6 +131,11 @@ const SearchBar = ({ isHomepage }: Props) => { }; }, [ calculateMenuWidth ]); + const showAllResultsLink = searchTerm.trim().length > 0 && ( + (query.data && query.data.length >= 50) || + (zetaChainCCTXQuery.data && zetaChainCCTXQuery.data?.items.length > 10) + ); + return ( <> { searchTerm={ debouncedSearchTerm } onItemClick={ handleItemClick } containerId={ SCROLL_CONTAINER_ID } + zetaChainCCTXQuery={ zetaChainCCTXQuery } + cosmosHashType={ cosmosHashType } /> ) } - { searchTerm.trim().length > 0 && query.data && query.data.length >= 50 && ( + { showAllResultsLink && ( , ResourceError>; + zetaChainCCTXQuery: UseQueryResult>; + cosmosHashType: CosmosHashType; searchTerm: string; onItemClick: (event: React.MouseEvent) => void; containerId: string; } -const SearchBarSuggest = ({ query, searchTerm, onItemClick, containerId }: Props) => { +const SearchBarSuggest = ({ query, zetaChainCCTXQuery, cosmosHashType, searchTerm, onItemClick, containerId }: Props) => { const isMobile = useIsMobile(); const marketplaceApps = useMarketplaceApps(searchTerm); @@ -41,7 +48,7 @@ const SearchBarSuggest = ({ query, searchTerm, onItemClick, containerId }: Props const handleScroll = React.useCallback(() => { const container = document.getElementById(containerId); - if (!container || !query.data?.length) { + if (!container || (!query.data?.length && !zetaChainCCTXQuery.data?.items.length)) { return; } const topLimit = container.getBoundingClientRect().y + (tabsRef.current?.clientHeight || 0) + 24; @@ -63,7 +70,7 @@ const SearchBarSuggest = ({ query, searchTerm, onItemClick, containerId }: Props break; } } - }, [ containerId, query.data ]); + }, [ containerId, query.data, zetaChainCCTXQuery.data ]); React.useEffect(() => { const container = document.getElementById(containerId); @@ -79,7 +86,7 @@ const SearchBarSuggest = ({ query, searchTerm, onItemClick, containerId }: Props }, [ containerId, handleScroll ]); const itemsGroups = React.useMemo(() => { - if (!query.data && !marketplaceApps.displayedApps) { + if (!query.data && !zetaChainCCTXQuery.data?.items.length && !marketplaceApps.displayedApps) { return {}; } @@ -100,6 +107,10 @@ const SearchBarSuggest = ({ query, searchTerm, onItemClick, containerId }: Props map.app = marketplaceApps.displayedApps; } + if (zetaChainCCTXQuery.data?.items.length) { + map.zetaChainCCTX = zetaChainCCTXQuery.data.items; + } + if (Object.keys(map).length > 0 && !map.block && regexp.BLOCK_HEIGHT.test(searchTerm)) { map['block'] = [ { type: 'block', @@ -111,7 +122,7 @@ const SearchBarSuggest = ({ query, searchTerm, onItemClick, containerId }: Props } return map; - }, [ query.data, marketplaceApps.displayedApps, searchTerm ]); + }, [ query.data, marketplaceApps.displayedApps, searchTerm, zetaChainCCTXQuery.data?.items ]); React.useEffect(() => { categoriesRefs.current = Array(Object.keys(itemsGroups).length).fill('').map((_, i) => categoriesRefs.current[i] || React.createRef()); @@ -130,7 +141,7 @@ const SearchBarSuggest = ({ query, searchTerm, onItemClick, containerId }: Props }, [ containerId ]); const content = (() => { - if (query.isPending || marketplaceApps.isPlaceholderData) { + if (query.isPending || marketplaceApps.isPlaceholderData || (config.features.zetachain.isEnabled && zetaChainCCTXQuery.isPending)) { return ; } @@ -140,7 +151,7 @@ const SearchBarSuggest = ({ query, searchTerm, onItemClick, containerId }: Props const resultCategories = searchCategories.filter(cat => itemsGroups[cat.id]); - if (resultCategories.length === 0) { + if (resultCategories.length === 0 && !cosmosHashType) { if (regexp.BLOCK_HEIGHT.test(searchTerm)) { return ; } @@ -187,7 +198,7 @@ const SearchBarSuggest = ({ query, searchTerm, onItemClick, containerId }: Props > { cat.title } - { cat.id !== 'app' && itemsGroups[cat.id]?.map((item, index) => ( + { cat.id !== 'app' && cat.id !== 'zetaChainCCTX' && itemsGroups[cat.id]?.map((item, index) => ( , ) } + { cat.id === 'zetaChainCCTX' && itemsGroups[cat.id]?.map((item, index) => + , + ) } ); }) } + { cosmosHashType && } ); })(); diff --git a/ui/snippets/searchBar/SearchBarSuggest/SearchBarSuggestCosmosNotice.tsx b/ui/snippets/searchBar/SearchBarSuggest/SearchBarSuggestCosmosNotice.tsx new file mode 100644 index 0000000000..752a12d270 --- /dev/null +++ b/ui/snippets/searchBar/SearchBarSuggest/SearchBarSuggestCosmosNotice.tsx @@ -0,0 +1,40 @@ +import { Text } from '@chakra-ui/react'; +import React from 'react'; + +import config from 'configs/app'; +import type { CosmosHashType } from 'lib/address/cosmos'; +import { Link } from 'toolkit/chakra/link'; + +const zetaChainFeature = config.features.zetachain; + +interface Props { + cosmosHash: string; + type: CosmosHashType; +} + +const SearchBarSuggestCosmosNotice = ({ cosmosHash, type }: Props) => { + if (!zetaChainFeature.isEnabled || !type) { + return null; + } + + const url = (() => { + if (type === 'tx') { + return zetaChainFeature.cosmosTxUrlTemplate.replace('{hash}', cosmosHash); + } + + return zetaChainFeature.cosmosAddressUrlTemplate.replace('{hash}', cosmosHash); + })(); + + return ( + <> + + It looks like you are searching for a Cosmos SDK style hash. This information is best served by the MintScan explorer. + + + Click here to be redirected + + + ); +}; + +export default React.memo(SearchBarSuggestCosmosNotice); diff --git a/ui/snippets/searchBar/SearchBarSuggest/SearchBarSuggestZetaChainCCTX.tsx b/ui/snippets/searchBar/SearchBarSuggest/SearchBarSuggestZetaChainCCTX.tsx new file mode 100644 index 0000000000..a6a36afa08 --- /dev/null +++ b/ui/snippets/searchBar/SearchBarSuggest/SearchBarSuggestZetaChainCCTX.tsx @@ -0,0 +1,68 @@ +import { Flex, Text, chakra } from '@chakra-ui/react'; +import React from 'react'; + +import type { ZetaChainCCTX } from 'types/api/zetaChain'; + +import { route } from 'nextjs/routes'; + +import dayjs from 'lib/date/dayjs'; +import HashStringShortenDynamic from 'ui/shared/HashStringShortenDynamic'; +import IconSvg from 'ui/shared/IconSvg'; + +import SearchBarSuggestItemLink from './SearchBarSuggestItemLink'; + +interface Props { + data: ZetaChainCCTX; + isMobile: boolean | undefined; + searchTerm: string; + onClick: (event: React.MouseEvent) => void; +} + +const SearchBarSuggestZetaChainCCTX = ({ data, isMobile, searchTerm, onClick }: Props) => { + const icon = ; + + // search term can be either cctx hash or observed hash (hash from another chain) + const hash = searchTerm === data.index ? ( + + + + ) : ( + + + + ); + + const date = dayjs(Number(data.last_update_timestamp) * 1000).format('llll'); + + let content; + + if (isMobile) { + content = ( + <> + + { icon } + { hash } + + { date } + + ); + } else { + content = ( + + + { icon } + { hash } + + { date } + + ); + } + + return ( + + { content } + + ); +}; + +export default React.memo(SearchBarSuggestZetaChainCCTX); diff --git a/ui/snippets/searchBar/useQuickSearchQuery.tsx b/ui/snippets/searchBar/useQuickSearchQuery.tsx index 81b62587c4..c86c8a2c8c 100644 --- a/ui/snippets/searchBar/useQuickSearchQuery.tsx +++ b/ui/snippets/searchBar/useQuickSearchQuery.tsx @@ -1,6 +1,8 @@ import React from 'react'; +import config from 'configs/app'; import { isBech32Address, fromBech32Address } from 'lib/address/bech32'; +import { checkCosmosHash } from 'lib/address/cosmos'; import useApiQuery from 'lib/api/useApiQuery'; import useDebounce from 'lib/hooks/useDebounce'; @@ -21,11 +23,23 @@ export default function useQuickSearchQuery() { queryOptions: { enabled: Boolean(debouncedSearchTerm) }, }); + const zetaChainCCTXQuery = useApiQuery('zetachain:transactions', { + queryParams: { + hash: debouncedSearchTerm, + limit: 10, + offset: 0, + direction: 'DESC', + }, + queryOptions: { enabled: debouncedSearchTerm.trim().length > 0 && config.features.zetachain.isEnabled }, + }); + return React.useMemo(() => ({ searchTerm, debouncedSearchTerm, handleSearchTermChange: setSearchTerm, query, redirectCheckQuery, - }), [ debouncedSearchTerm, query, redirectCheckQuery, searchTerm ]); + cosmosHashType: checkCosmosHash(debouncedSearchTerm), + zetaChainCCTXQuery, + }), [ debouncedSearchTerm, query, redirectCheckQuery, searchTerm, zetaChainCCTXQuery ]); } diff --git a/ui/snippets/searchBar/useSearchQuery.tsx b/ui/snippets/searchBar/useSearchQuery.tsx index 58c8432683..800a524f0d 100644 --- a/ui/snippets/searchBar/useSearchQuery.tsx +++ b/ui/snippets/searchBar/useSearchQuery.tsx @@ -1,6 +1,7 @@ import { useRouter } from 'next/router'; import React from 'react'; +import config from 'configs/app'; import { fromBech32Address, isBech32Address } from 'lib/address/bech32'; import useApiQuery from 'lib/api/useApiQuery'; import useDebounce from 'lib/hooks/useDebounce'; @@ -35,6 +36,16 @@ export default function useSearchQuery(withRedirectCheck?: boolean) { queryOptions: { enabled: Boolean(q.current) && withRedirectCheck }, }); + const zetaChainCCTXQuery = useApiQuery('zetachain:transactions', { + queryParams: { + hash: debouncedSearchTerm, + limit: 50, + offset: 0, + direction: 'DESC', + }, + queryOptions: { enabled: config.features.zetachain.isEnabled }, + }); + useUpdateValueEffect(() => { query.onFilterChange({ q: debouncedSearchTerm }); }, debouncedSearchTerm); @@ -46,5 +57,6 @@ export default function useSearchQuery(withRedirectCheck?: boolean) { query, redirectCheckQuery, pathname, - }), [ debouncedSearchTerm, pathname, query, redirectCheckQuery, searchTerm ]); + zetaChainCCTXQuery, + }), [ debouncedSearchTerm, pathname, query, redirectCheckQuery, searchTerm, zetaChainCCTXQuery ]); } diff --git a/ui/txs/zetaChain/ZetaChainCCTXDetails.tsx b/ui/txs/zetaChain/ZetaChainCCTXDetails.tsx new file mode 100644 index 0000000000..2985ac7dfe --- /dev/null +++ b/ui/txs/zetaChain/ZetaChainCCTXDetails.tsx @@ -0,0 +1,332 @@ +import { Box, Flex, Grid, VStack, Text } from '@chakra-ui/react'; +import React from 'react'; + +import type { ZetaChainCCTXResponse } from 'types/api/zetaChain'; + +import useApiQuery from 'lib/api/useApiQuery'; +import base64ToHex from 'lib/base64ToHex'; +import { currencyUnits } from 'lib/units'; +import { HOMEPAGE_STATS } from 'stubs/stats'; +import { CollapsibleDetails } from 'toolkit/chakra/collapsible'; +import { useColorModeValue } from 'toolkit/chakra/color-mode'; +import { Skeleton } from 'toolkit/chakra/skeleton'; +import CopyToClipboard from 'ui/shared/CopyToClipboard'; +import CurrencyValue from 'ui/shared/CurrencyValue'; +import * as DetailedInfo from 'ui/shared/DetailedInfo/DetailedInfo'; +import DetailedInfoTimestamp from 'ui/shared/DetailedInfo/DetailedInfoTimestamp'; +import AddressEntityZetaChain from 'ui/shared/entities/address/AddressEntityZetaChain'; +import HashStringShortenDynamic from 'ui/shared/HashStringShortenDynamic'; +import IconSvg from 'ui/shared/IconSvg'; +import RawDataSnippet from 'ui/shared/RawDataSnippet'; +import ZetaChainCCTXReducedStatus from 'ui/shared/zetaChain/ZetaChainCCTXReducedStatus'; +import ZetaChainCCTXStatusTag from 'ui/shared/zetaChain/ZetaChainCCTXStatusTag'; +import ZetaChainCCTXValue from 'ui/shared/zetaChain/ZetaChainCCTXValue'; + +import ZetaChainCCTXDetailsLifecycleIn from './ZetaChainCCTXDetailsLifecycleIn'; +import ZetaChainCCTXDetailsLifecycleOut from './ZetaChainCCTXDetailsLifecycleOut'; +import ZetaChainCCTXDetailsRelatedTx from './ZetaChainCCTXDetailsRelatedTx'; + +type Props = { + data?: ZetaChainCCTXResponse; + isLoading: boolean; +}; + +const ZetaChainCCTXDetails = ({ data, isLoading }: Props) => { + const statsQuery = useApiQuery('general:stats', { + queryOptions: { + placeholderData: HOMEPAGE_STATS, + }, + }); + + const bgColor = useColorModeValue('white', 'black'); + + if (!data) { + return null; + } + + // Separate related transactions into before and after current + const currentIndex = data.index; + const relatedTransactions = data?.related_cctxs || []; + + // Find the index of current transaction in the related_cctxs array + const currentTransactionIndex = relatedTransactions.findIndex(tx => tx.index === currentIndex); + + const transactionsBefore = currentTransactionIndex > 0 ? relatedTransactions.slice(0, currentTransactionIndex) : []; + const transactionsAfter = currentTransactionIndex >= 0 && currentTransactionIndex < relatedTransactions.length - 1 ? + relatedTransactions.slice(currentTransactionIndex + 1) : + []; + + return ( + + + Sender + + + + + + Receiver + + + + + + Asset transferred + + + + + + Cross-chain fee + + + + + { data.relayed_message && ( + <> + + Message + + + + + + ) } + + CCTX hash + + + + + + + + + + + Status and state + + + + + + + { data.cctx_status.error_message && ( + + + + ) } + + { data.cctx_status.status_message && ( + <> + + Status message + + + + + + ) } + { Boolean(Number(data.cctx_status.created_timestamp)) && ( + <> + + Created + + + + + + ) } + { Boolean(Number(data.cctx_status.last_update_timestamp)) && ( + <> + + Last updated + + + + + + ) } + + Lifecycle + + + + + { transactionsBefore.length > 0 && ( + <> + + + { transactionsBefore.map((tx) => ( + + )) } + + + ) } + { /* Current Transaction */ } + + { data.outbound_params.map((param, index) => ( + 0 } + /> + )) } + { /* Transactions After Current */ } + { transactionsAfter.length > 0 && ( + <> + + + + + { transactionsAfter.map((tx) => ( + + )) } + + + ) } + + + { data.revert_options && ( + <> + + Revert options + + + + Abort address + + Call + { data.revert_options.call_on_revert.toString() } + Revert address + + { data.revert_options.revert_message && ( + <> + Message + + + { base64ToHex(data.revert_options.revert_message) } + + + + + ) } + Gas limit + { Number(data.revert_options.revert_gas_limit).toLocaleString() } + + + + ) } + + + ); +}; + +export default ZetaChainCCTXDetails; diff --git a/ui/txs/zetaChain/ZetaChainCCTXDetailsLifecycleIn.tsx b/ui/txs/zetaChain/ZetaChainCCTXDetailsLifecycleIn.tsx new file mode 100644 index 0000000000..417431e563 --- /dev/null +++ b/ui/txs/zetaChain/ZetaChainCCTXDetailsLifecycleIn.tsx @@ -0,0 +1,87 @@ +import { Flex, Grid, Text } from '@chakra-ui/react'; +import React from 'react'; + +import type { ZetaChainCCTXResponse } from 'types/api/zetaChain'; + +import config from 'configs/app'; +import { useColorModeValue } from 'toolkit/chakra/color-mode'; +import { Skeleton } from 'toolkit/chakra/skeleton'; +import AddressEntityZetaChain from 'ui/shared/entities/address/AddressEntityZetaChain'; +import CCTxEntityZetaChain from 'ui/shared/entities/tx/CCTxEntityZetaChain'; +import TxEntity from 'ui/shared/entities/tx/TxEntity'; +import TxEntityZetaChainExternal from 'ui/shared/entities/tx/TxEntityZetaChainExternal'; +import IconSvg from 'ui/shared/IconSvg'; +import StatusTag from 'ui/shared/statusTag/StatusTag'; +import ZetaChainCCTXValue from 'ui/shared/zetaChain/ZetaChainCCTXValue'; +import useZetaChainConfig from 'ui/zetaChain/useZetaChainConfig'; + +type Props = { + tx: ZetaChainCCTXResponse; + isLoading: boolean; +}; + +const ZetaChainCCTXDetailsLifecycleIn = ({ tx, isLoading }: Props) => { + const { data: chainsConfig } = useZetaChainConfig(); + const inboundParams = tx.inbound_params; + const chainFromId = inboundParams.sender_chain_id.toString(); + const chainFrom = chainsConfig?.find((chain) => chain.chain_id.toString() === chainFromId); + const bgColor = useColorModeValue('white', 'black'); + + const isCCTX = tx.related_cctxs.some((cctx) => cctx.index === inboundParams.observed_hash); + + return ( + <> + + + + { `Sender tx from ${ chainFrom?.chain_name || 'unknown chain' }` } + + + { isCCTX ? ( + <> + CCTX + + + ) : ( + <> + Transaction + { chainFromId !== config.chain.id ? ( + + ) : ( + + ) } + + ) } + Status + + Sender + + Transferred + + + + + ); +}; + +export default ZetaChainCCTXDetailsLifecycleIn; diff --git a/ui/txs/zetaChain/ZetaChainCCTXDetailsLifecycleOut.tsx b/ui/txs/zetaChain/ZetaChainCCTXDetailsLifecycleOut.tsx new file mode 100644 index 0000000000..d64dc8aab4 --- /dev/null +++ b/ui/txs/zetaChain/ZetaChainCCTXDetailsLifecycleOut.tsx @@ -0,0 +1,244 @@ +import { Flex, Grid, Text } from '@chakra-ui/react'; +import { BigNumber } from 'bignumber.js'; +import React from 'react'; + +import type { ZetaChainCCTXOutboundParams, ZetaChainCCTXResponse } from 'types/api/zetaChain'; + +import config from 'configs/app'; +import { useColorModeValue } from 'toolkit/chakra/color-mode'; +import { Skeleton } from 'toolkit/chakra/skeleton'; +import AddressEntityZetaChain from 'ui/shared/entities/address/AddressEntityZetaChain'; +import CCTxEntityZetaChain from 'ui/shared/entities/tx/CCTxEntityZetaChain'; +import TxEntity from 'ui/shared/entities/tx/TxEntity'; +import TxEntityZetaChainExternal from 'ui/shared/entities/tx/TxEntityZetaChainExternal'; +import IconSvg from 'ui/shared/IconSvg'; +import StatusTag from 'ui/shared/statusTag/StatusTag'; +import ZetaChainCCTXValue from 'ui/shared/zetaChain/ZetaChainCCTXValue'; +import useZetaChainConfig from 'ui/zetaChain/useZetaChainConfig'; + +type Props = { + outboundParam: ZetaChainCCTXOutboundParams; + tx: ZetaChainCCTXResponse; + isLoading: boolean; + isLast: boolean; + hasTxAfter: boolean; +}; + +const ZetaChainCCTXDetailsLifecycleOut = ({ outboundParam, tx, isLoading, isLast, hasTxAfter }: Props) => { + const { data: chainsConfig } = useZetaChainConfig(); + const chainToId = outboundParam.receiver_chain_id?.toString() || ''; + const chainTo = chainsConfig?.find((chain) => chain.chain_id.toString() === chainToId); + + const gasDecimals = config.chain.currency.decimals; + + const bgColor = useColorModeValue('white', 'black'); + + if (tx.cctx_status.status === 'PENDING_INBOUND') { + return null; + } + + let content: React.ReactNode = null; + let text: string = ''; + let color: string = ''; + + const transactionOrCCTX = (() => { + if (!outboundParam.hash) { + return null; + } + const isCCTX = tx.related_cctxs.some((cctx) => cctx.index === outboundParam.hash); + if (isCCTX) { + return ( + <> + CCTX + + + ); + } + return ( + <> + Transaction + { chainToId !== config.chain.id ? ( + + ) : ( + + ) } + + ); + })(); + + if (tx.cctx_status.status === 'OUTBOUND_MINED') { + content = ( + <> + { transactionOrCCTX } + Status + + Receiver + + Transferred + + Gas used + { BigNumber(outboundParam.gas_used || 0).div(10 ** gasDecimals).toFormat() } + + ); + text = `Sent tx to ${ chainTo?.chain_name || 'Unknown chain' }`; + color = 'text.success'; + } else if (tx.cctx_status.status === 'PENDING_REVERT') { + if (!isLast) { + content = ( + <> + { transactionOrCCTX } + Status + + + ); + text = `Destination tx failed`; + color = 'text.error'; + } else { + content = ( + <> + Reverting to + + + ); + text = `Waiting for revert to ${ chainTo?.chain_name || 'Unknown chain' }`; + color = 'text.secondary'; + } + } else if (tx.cctx_status.status === 'PENDING_OUTBOUND') { + content = ( + <> + Destination + + Nonce + { outboundParam.tss_nonce } + + ); + text = `Waiting for outbound tx to ${ chainTo?.chain_name || 'Unknown chain' }`; + color = 'text.secondary'; + } else if (tx.cctx_status.status === 'REVERTED') { + if (!isLast) { + content = ( + <> + { transactionOrCCTX } + Status + + + ); + text = `Destination tx failed`; + color = 'text.error'; + } else { + content = ( + <> + Origin + + { transactionOrCCTX } + Status + + Transferred + + Gas used + { BigNumber(outboundParam.gas_used || 0).div(10 ** gasDecimals).toFormat() } { config.chain.currency.symbol } + + ); + text = `Reverted to ${ chainTo?.chain_name || 'Unknown chain' }`; + color = 'text.success'; + } + } else if (tx.cctx_status.status === 'ABORTED') { + if (!isLast) { + content = ( + <> + Receiver + + + ); + text = `Destination tx failed`; + color = 'text.error'; + } else { + content = ( + <> + Sender + + + ); + text = `Abort executed`; + color = 'text.success'; + } + } + + return ( + <> + + + + + + { text } + + + { content } + + + + ); +}; + +export default ZetaChainCCTXDetailsLifecycleOut; diff --git a/ui/txs/zetaChain/ZetaChainCCTXDetailsRelatedTx.tsx b/ui/txs/zetaChain/ZetaChainCCTXDetailsRelatedTx.tsx new file mode 100644 index 0000000000..38e196279d --- /dev/null +++ b/ui/txs/zetaChain/ZetaChainCCTXDetailsRelatedTx.tsx @@ -0,0 +1,53 @@ +import { Box } from '@chakra-ui/react'; +import React from 'react'; + +import type { ZetaChainRelatedCCTX } from 'types/api/zetaChain'; + +import { Skeleton } from 'toolkit/chakra/skeleton'; +import ChainIconWithTooltip from 'ui/shared/ChainIconWithTooltip'; +import CCTxEntityZetaChain from 'ui/shared/entities/tx/CCTxEntityZetaChain'; +import IconSvg from 'ui/shared/IconSvg'; +import ZetaChainCCTXReducedStatus from 'ui/shared/zetaChain/ZetaChainCCTXReducedStatus'; +import useZetaChainConfig from 'ui/zetaChain/useZetaChainConfig'; + +type Props = { + tx: ZetaChainRelatedCCTX; + isLoading: boolean; +}; + +const ZetaChainCCTXDetailsRelatedTx = ({ tx, isLoading }: Props) => { + const { data: chainsConfig } = useZetaChainConfig(); + const chainFrom = chainsConfig?.find((chain) => chain.chain_id === tx.source_chain_id); + + const chainsTo = tx.outbound_params.map((p) => chainsConfig?.find((chain) => chain.chain_id === p.chain_id)); + + const color = (() => { + if (tx.status_reduced === 'SUCCESS') { + return 'text.success'; + } + if (tx.status_reduced === 'FAILED') { + return 'text.error'; + } + return 'text.secondary'; + })(); + + return ( + + + + { chainsTo.map((chain, index) => ) } + CCTX + + + + ); +}; + +export default ZetaChainCCTXDetailsRelatedTx; diff --git a/ui/txs/zetaChain/ZetaChainCCTXListItem.tsx b/ui/txs/zetaChain/ZetaChainCCTXListItem.tsx new file mode 100644 index 0000000000..d4a726eda5 --- /dev/null +++ b/ui/txs/zetaChain/ZetaChainCCTXListItem.tsx @@ -0,0 +1,68 @@ +import { Flex, Grid, VStack, Text } from '@chakra-ui/react'; +import React from 'react'; + +import type { ZetaChainCCTX } from 'types/api/zetaChain'; + +import dayjs from 'lib/date/dayjs'; +import { useColorModeValue } from 'toolkit/chakra/color-mode'; +import AddressEntityZetaChain from 'ui/shared/entities/address/AddressEntityZetaChain'; +import CCTxEntityZetaChain from 'ui/shared/entities/tx/CCTxEntityZetaChain'; +import TextSeparator from 'ui/shared/TextSeparator'; +import ZetaChainCCTXReducedStatus from 'ui/shared/zetaChain/ZetaChainCCTXReducedStatus'; +import ZetaChainCCTXValue from 'ui/shared/zetaChain/ZetaChainCCTXValue'; + +type Props = { + tx: ZetaChainCCTX; + isLoading?: boolean; + animation?: string; +}; + +const LatestZetaChainCCTXItem = ({ tx, isLoading, animation }: Props) => { + const separatorColor = useColorModeValue('blackAlpha.100', 'whiteAlpha.100'); + return ( + + + + + { dayjs(Number(tx.last_update_timestamp) * 1000).fromNow() } + + { dayjs(Number(tx.last_update_timestamp) * 1000).format('llll') } + + + Sender + + Receiver + + Asset + + + + ); +}; + +export default React.memo(LatestZetaChainCCTXItem); diff --git a/ui/txs/zetaChain/ZetaChainCCTXsStats.tsx b/ui/txs/zetaChain/ZetaChainCCTXsStats.tsx new file mode 100644 index 0000000000..07968785d0 --- /dev/null +++ b/ui/txs/zetaChain/ZetaChainCCTXsStats.tsx @@ -0,0 +1,77 @@ +import type { BoxProps } from '@chakra-ui/react'; +import { Box } from '@chakra-ui/react'; +import React from 'react'; + +import config from 'configs/app'; +import useApiQuery from 'lib/api/useApiQuery'; +import { TXS_STATS_MICROSERVICE } from 'stubs/tx'; +import StatsWidget from 'ui/shared/stats/StatsWidget'; + +interface Props extends BoxProps {} + +const ZetaChainCCTXsStats = (props: Props) => { + const isStatsFeatureEnabled = config.features.stats.isEnabled; + + const txsStatsQuery = useApiQuery('stats:pages_transactions', { + queryOptions: { + enabled: isStatsFeatureEnabled, + placeholderData: isStatsFeatureEnabled ? TXS_STATS_MICROSERVICE : undefined, + }, + }); + + if (!txsStatsQuery.data) { + return null; + } + + const isLoading = txsStatsQuery.isPlaceholderData; + + const cctxCountTotal = txsStatsQuery.data?.total_zetachain_cross_chain_txns; + const cctxPendingCountTotal = txsStatsQuery.data?.pending_zetachain_cross_chain_txns; + const cctxCount24h = txsStatsQuery.data?.new_zetachain_cross_chain_txns_24h; + + const itemsCount = [ + cctxCountTotal, + cctxPendingCountTotal, + cctxCount24h, + ].filter(item => item !== null && item !== undefined).length; + + return ( + + { cctxCountTotal && ( + + ) } + { cctxPendingCountTotal && ( + + ) } + { cctxCount24h && ( + + ) } + + ); +}; + +function getLabelFromTitle(title: string) { + return title.replace(/\s*\([^)]*\)\s*$/, ''); +} + +export default React.memo(ZetaChainCCTXsStats); diff --git a/ui/txs/zetaChain/ZetaChainCCTXsTab.pw.tsx b/ui/txs/zetaChain/ZetaChainCCTXsTab.pw.tsx new file mode 100644 index 0000000000..cc0cede25f --- /dev/null +++ b/ui/txs/zetaChain/ZetaChainCCTXsTab.pw.tsx @@ -0,0 +1,68 @@ +import { Box } from '@chakra-ui/react'; +import React from 'react'; + +import { zetaChainCCTXList } from 'mocks/zetaChain/zetaChainCCTX'; +import { zetaChainCCTXConfig } from 'mocks/zetaChain/zetaChainCCTXConfig'; +import { ENVS_MAP } from 'playwright/fixtures/mockEnvs'; +import { test, expect, devices } from 'playwright/lib'; + +import ZetaChainCCTXsTab from './ZetaChainCCTXsTab'; + +const CCTX_CONFIG_URL = 'http://localhost:3000/zeta-config.json'; + +test.beforeEach(async({ mockEnvs, mockConfigResponse }) => { + await mockEnvs(ENVS_MAP.zetaChain); + await mockConfigResponse('NEXT_PUBLIC_ZETACHAIN_SERVICE_CHAINS_CONFIG_URL', CCTX_CONFIG_URL, zetaChainCCTXConfig); +}); + +test('base view +@dark-mode', async({ render, mockApiResponse }) => { + await mockApiResponse('zetachain:transactions', zetaChainCCTXList, { + queryParams: { + status_reduced: [ 'Success', 'Failed' ], + limit: 50, + offset: 0, + direction: 'DESC', + }, + }); + + const component = await render( + + + , { + hooksConfig: { + router: { + query: { tab: 'cctx' }, + }, + }, + }); + + await expect(component).toHaveScreenshot(); +}); + +test.describe('mobile', () => { + test.use({ viewport: devices['iPhone 13 Pro'].viewport }); + + test('base view +@dark-mode', async({ render, mockApiResponse }) => { + await mockApiResponse('zetachain:transactions', zetaChainCCTXList, { + queryParams: { + status_reduced: [ 'Success', 'Failed' ], + limit: 50, + offset: 0, + direction: 'DESC', + }, + }); + + const component = await render( + + + , { + hooksConfig: { + router: { + query: { tab: 'cctx' }, + }, + }, + }); + + await expect(component).toHaveScreenshot(); + }); +}); diff --git a/ui/txs/zetaChain/ZetaChainCCTXsTab.tsx b/ui/txs/zetaChain/ZetaChainCCTXsTab.tsx new file mode 100644 index 0000000000..e7329b5452 --- /dev/null +++ b/ui/txs/zetaChain/ZetaChainCCTXsTab.tsx @@ -0,0 +1,176 @@ +import { capitalize, omit } from 'es-toolkit/compat'; +import { useRouter } from 'next/router'; +import React from 'react'; + +import type { TabItemRegular } from 'toolkit/components/AdaptiveTabs/types'; +import { ADVANCED_FILTER_AGES, type AdvancedFilterAge } from 'types/api/advancedFilter'; +import { ZETA_CHAIN_CCTX_STATUS_REDUCED_FILTERS, type ZetaChainCCTXFilterParams, type ZetaChainCCTXStatusReducedFilter } from 'types/api/zetaChain'; + +import dayjs from 'lib/date/dayjs'; +import getFilterValueFromQuery from 'lib/getFilterValueFromQuery'; +import getValuesArrayFromQuery from 'lib/getValuesArrayFromQuery'; +import useIsMobile from 'lib/hooks/useIsMobile'; +import getNetworkValidationActionText from 'lib/networks/getNetworkValidationActionText'; +import getQueryParamString from 'lib/router/getQueryParamString'; +import { zetaChainCCTXItem } from 'mocks/zetaChain/zetaChainCCTX'; +import RoutedTabs from 'toolkit/components/RoutedTabs/RoutedTabs'; +import { getDurationFromAge } from 'ui/advancedFilter/lib'; +import Pagination from 'ui/shared/pagination/Pagination'; +import useQueryWithPages from 'ui/shared/pagination/useQueryWithPages'; + +import ZetaChainFilterTags from './filters/ZetaChainFilterTags'; +import ZetaChainCCTxs from './ZetaChainCCTxs'; +import ZetaChainCCTXsStats from './ZetaChainCCTXsStats'; + +const TAB_LIST_PROPS = { + marginBottom: 0, + pt: 3, + pb: 3, + marginTop: -2, +}; +const TABS_HEIGHT = 64; + +const ZetaChainEvmTransactions = () => { + const router = useRouter(); + const tab = getQueryParamString(router.query.tab); + const isMobile = useIsMobile(); + + const [ filters, setFilters ] = React.useState(() => { + const age = getFilterValueFromQuery(ADVANCED_FILTER_AGES, router.query.age); + const startTimestampFromQuery = getQueryParamString(router.query.start_timestamp) ? getQueryParamString(router.query.start_timestamp) : undefined; + const endTimestampFromQuery = getQueryParamString(router.query.end_timestamp) ? getQueryParamString(router.query.end_timestamp) : undefined; + return { + end_timestamp: age ? dayjs().unix().toString() : endTimestampFromQuery, + start_timestamp: age ? dayjs((dayjs().valueOf() - getDurationFromAge(age))).unix().toString() : startTimestampFromQuery, + age, + status_reduced: getFilterValueFromQuery(ZETA_CHAIN_CCTX_STATUS_REDUCED_FILTERS, router.query.status_reduced), + sender_address: getValuesArrayFromQuery(router.query.sender_address), + receiver_address: getValuesArrayFromQuery(router.query.receiver_address), + source_chain_id: getValuesArrayFromQuery(router.query.source_chain_id), + target_chain_id: getValuesArrayFromQuery(router.query.target_chain_id), + token_symbol: getValuesArrayFromQuery(router.query.token_symbol), + }; + }); + + const cctxsValidatedQuery = useQueryWithPages({ + resourceName: 'zetachain:transactions', + queryParams: { + ...filters, + limit: 50, + offset: 0, + status_reduced: filters.status_reduced ?? [ 'Success', 'Failed' ], + direction: 'DESC', + }, + options: { + placeholderData: { items: Array(50).fill(zetaChainCCTXItem), next_page_params: { page_index: 0, offset: 0, direction: 'DESC' } }, + enabled: tab === 'cctx' || tab === 'cctx_mined', + }, + }); + + const cctxsPendingQuery = useQueryWithPages({ + resourceName: 'zetachain:transactions', + queryParams: { + ...filters, + limit: 50, + offset: 0, + status_reduced: filters.status_reduced ?? [ 'Pending' ], + direction: 'DESC', + }, + options: { + placeholderData: { items: Array(50).fill(zetaChainCCTXItem), next_page_params: { page_index: 0, offset: 0, direction: 'DESC' } }, + enabled: tab === 'cctx_pending', + }, + }); + + const query = tab === 'cctx_mined' ? cctxsValidatedQuery : cctxsPendingQuery; + + const handleFilterChange = React.useCallback((field: T, val: ZetaChainCCTXFilterParams[T]) => { + setFilters(prevState => { + const newState = { ...prevState }; + newState[field] = val; + query.onFilterChange(newState.age ? omit(newState, [ 'start_timestamp', 'end_timestamp' ]) : newState); + + return newState; + }); + }, [ query ]); + + const onClearFilter = React.useCallback((key: keyof ZetaChainCCTXFilterParams) => () => { + if (key === 'age') { + handleFilterChange('start_timestamp', undefined); + handleFilterChange('end_timestamp', undefined); + } + handleFilterChange(key, undefined); + }, + [ handleFilterChange ], + ); + + const clearAllFilters = React.useCallback(() => { + setFilters({}); + query.onFilterChange({}); + }, [ query ]); + + const verifiedTitle = capitalize(getNetworkValidationActionText()); + + const tabs: Array = [ + { + id: 'cctx_mined', + title: verifiedTitle, + component: + }, + { + id: 'cctx_pending', + title: 'Pending', + component: ( + + ), + }, + ]; + + const pagination = (() => { + switch (tab) { + case 'cctx_pending': return cctxsPendingQuery.pagination; + default: return cctxsValidatedQuery.pagination; + } + })(); + + return ( + <> + + + } + listProps={ isMobile ? undefined : TAB_LIST_PROPS } + /> + + ); +}; + +export default ZetaChainEvmTransactions; diff --git a/ui/txs/zetaChain/ZetaChainCCTxs.tsx b/ui/txs/zetaChain/ZetaChainCCTxs.tsx new file mode 100644 index 0000000000..56e5b4cea2 --- /dev/null +++ b/ui/txs/zetaChain/ZetaChainCCTxs.tsx @@ -0,0 +1,192 @@ +import { Box } from '@chakra-ui/react'; +import { useQueryClient } from '@tanstack/react-query'; +import React from 'react'; + +import type { SocketMessage } from 'lib/socket/types'; +import type { ZetaChainCCTX, ZetaChainCCTXFilterParams, ZetaChainCCTXListResponse } from 'types/api/zetaChain'; +import type { PaginationParams } from 'ui/shared/pagination/types'; + +import { getResourceKey } from 'lib/api/useApiQuery'; +import useInitialList from 'lib/hooks/useInitialList'; +import useIsMobile from 'lib/hooks/useIsMobile'; +import useSocketChannel from 'lib/socket/useSocketChannel'; +import useSocketMessage from 'lib/socket/useSocketMessage'; +import ActionBar 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 ZetaChainCCTxsListItem from './ZetaChainCCTXListItem'; +import ZetaChainCCTxsTable from './ZetaChainCCTxsTable'; + +const OVERLOAD_COUNT = 75; + +type Props = { + pagination: PaginationParams; + top?: number; + items?: Array; + isPlaceholderData: boolean; + isError: boolean; + filters?: ZetaChainCCTXFilterParams; + onFilterChange: (field: T, val: ZetaChainCCTXFilterParams[T]) => void; + showStatusFilter?: boolean; + type: 'pending' | 'mined'; +}; + +const ZetaChainCCTxs = ({ + pagination, + top, + items, + isPlaceholderData, + isError, + filters = {}, + onFilterChange, + showStatusFilter = true, + type, +}: Props) => { + const isMobile = useIsMobile(); + const queryClient = useQueryClient(); + const [ showSocketErrorAlert, setShowSocketErrorAlert ] = React.useState(false); + const [ showOverloadNotice, setShowOverloadNotice ] = React.useState(false); + + const initialList = useInitialList({ + data: items ?? [], + idFn: (item) => item.index, + enabled: !isPlaceholderData, + }); + + // Socket handling for new CCTX messages + const handleNewCCTXMessage: SocketMessage.NewZetaChainCCTXs['handler'] = React.useCallback((payload) => { + const currentQueryKey = getResourceKey('zetachain:transactions', { + queryParams: { + limit: 50, + offset: 0, + status_reduced: type === 'pending' ? [ 'Pending' ] : [ 'Success', 'Failed' ], + direction: 'DESC', + }, + }); + + const filteredPayload = type === 'pending' ? + payload.filter(tx => tx.status_reduced === 'PENDING') : + payload.filter(tx => tx.status_reduced === 'SUCCESS' || tx.status_reduced === 'FAILED'); + + queryClient.setQueryData(currentQueryKey, (prevData: ZetaChainCCTXListResponse | undefined) => { + if (!prevData) { + return { + items: filteredPayload, + next_page_params: null, + }; + } + + if (filteredPayload.length === 0) { + return prevData; // No relevant transactions to add + } + + // Create a map of existing items by index for quick lookup + const existingItemsMap = new Map( + prevData.items.map((item) => [ item.index, item ]), + ); + + // Update or add new items from filtered payload + filteredPayload.forEach((newItem) => { + existingItemsMap.set(newItem.index, newItem); + }); + + // Convert back to array, sort by last_update_timestamp (newest first) + const mergedItems = Array.from(existingItemsMap.values()) + .sort((a, b) => Number(b.last_update_timestamp) - Number(a.last_update_timestamp)); + + // Check if we've reached overload count + if (mergedItems.length >= OVERLOAD_COUNT) { + setShowOverloadNotice(true); + return prevData; // Don't update the list when overloaded + } + + return { + ...prevData, + items: mergedItems, + }; + }); + }, [ queryClient, type ]); + + const handleSocketClose = React.useCallback(() => { + setShowSocketErrorAlert(true); + }, []); + + const handleSocketError = React.useCallback(() => { + setShowSocketErrorAlert(true); + }, []); + + // Socket channel for CCTX updates + const hasFilters = Object.values(filters).some(value => value !== undefined && value !== ''); + + const channel = useSocketChannel({ + topic: 'cctxs:new_cctxs', + isDisabled: hasFilters, // Disable when filters are applied + onSocketClose: handleSocketClose, + onSocketError: handleSocketError, + socketName: 'zetachain', + }); + + useSocketMessage({ + channel, + event: 'new_cctxs', + handler: handleNewCCTXMessage, + }); + + const content = ( + <> + + { pagination.page === 1 && !hasFilters && ( + + ) } + { (items || []).map((item, index) => ( + + )) } + + + + + + ); + + const actionBar = (isMobile && pagination.isVisible) ? ( + + + + ) : null; + + return ( + + { content } + + ); +}; + +export default ZetaChainCCTxs; diff --git a/ui/txs/zetaChain/ZetaChainCCTxsList.tsx b/ui/txs/zetaChain/ZetaChainCCTxsList.tsx new file mode 100644 index 0000000000..eb053075ed --- /dev/null +++ b/ui/txs/zetaChain/ZetaChainCCTxsList.tsx @@ -0,0 +1,67 @@ +import React from 'react'; + +import type { ZetaChainCCTX } from 'types/api/zetaChain'; + +import { AddressHighlightProvider } from 'lib/contexts/addressHighlight'; +import useInitialList from 'lib/hooks/useInitialList'; +import useLazyRenderedList from 'lib/hooks/useLazyRenderedList'; +import { TableBody, TableColumnHeader, TableHeaderSticky, TableRoot, TableRow } from 'toolkit/chakra/table'; +import TimeFormatToggle from 'ui/shared/time/TimeFormatToggle'; + +import ZetaChainCCTxsTableItem from './ZetaChainCCTxsTableItem'; + +type Props = { + txs: Array; + top: number; + enableTimeIncrement?: boolean; + isLoading?: boolean; +}; + +const TxsTable = ({ + txs, + top, + enableTimeIncrement, + isLoading, +}: Props) => { + const { cutRef, renderedItemsNum } = useLazyRenderedList(txs, !isLoading); + const initialList = useInitialList({ + data: txs ?? [], + idFn: (item) => item.index, + enabled: !isLoading, + }); + + return ( + + + + + + CCTx hash + + + + Status + + Sender + Receiver + Value + + + + { txs.slice(0, renderedItemsNum).map((item, index) => ( + + )) } + + +
+ + ); +}; + +export default React.memo(TxsTable); diff --git a/ui/txs/zetaChain/ZetaChainCCTxsTable.tsx b/ui/txs/zetaChain/ZetaChainCCTxsTable.tsx new file mode 100644 index 0000000000..f84bd294e1 --- /dev/null +++ b/ui/txs/zetaChain/ZetaChainCCTxsTable.tsx @@ -0,0 +1,147 @@ +import { Flex, chakra } from '@chakra-ui/react'; +import React from 'react'; + +import type { ZetaChainCCTX, ZetaChainCCTXFilterParams } from 'types/api/zetaChain'; + +import { AddressHighlightProvider } from 'lib/contexts/addressHighlight'; +import useInitialList from 'lib/hooks/useInitialList'; +import useLazyRenderedList from 'lib/hooks/useLazyRenderedList'; +import { TableBody, TableColumnHeader, TableHeaderSticky, TableRoot, TableRow } from 'toolkit/chakra/table'; +import * as SocketNewItemsNotice from 'ui/shared/SocketNewItemsNotice'; +import TimeFormatToggle from 'ui/shared/time/TimeFormatToggle'; + +import ZetaChainFilterByColumn from './filters/ZetaChainFilterByColumn'; +import ZetaChainCCTxsTableItem from './ZetaChainCCTxsTableItem'; + +type Props = { + txs: Array; + top: number; + enableTimeIncrement?: boolean; + isLoading?: boolean; + filters?: ZetaChainCCTXFilterParams; + onFilterChange: (field: T, val: ZetaChainCCTXFilterParams[T]) => void; + isPlaceholderData?: boolean; + showStatusFilter?: boolean; + showSocketInfo?: boolean; + showSocketErrorAlert?: boolean; + socketInfoNum?: number; +}; + +const ZetaChainCCTxsTable = ({ + txs, + top, + enableTimeIncrement, + isLoading, + filters = {}, + onFilterChange, + isPlaceholderData, + showStatusFilter = true, + showSocketInfo = false, + showSocketErrorAlert = false, + socketInfoNum = 0, +}: Props) => { + const { cutRef, renderedItemsNum } = useLazyRenderedList(txs, !isLoading); + const initialList = useInitialList({ + data: txs ?? [], + idFn: (item) => item.index, + enabled: !isLoading, + }); + + return ( + + + + + + + + CCTx hash + + + + + + + + Status + + { showStatusFilter && ( + + ) } + + + + Sender + + + + + + Receiver + + + + + + Value + + + + + + + { showSocketInfo && ( + + ) } + { txs.slice(0, renderedItemsNum).map((item, index) => ( + + )) } + + +
+ + ); +}; + +export default React.memo(ZetaChainCCTxsTable); diff --git a/ui/txs/zetaChain/ZetaChainCCTxsTableItem.tsx b/ui/txs/zetaChain/ZetaChainCCTxsTableItem.tsx new file mode 100644 index 0000000000..10b98d436a --- /dev/null +++ b/ui/txs/zetaChain/ZetaChainCCTxsTableItem.tsx @@ -0,0 +1,67 @@ +import { HStack } from '@chakra-ui/react'; +import React from 'react'; + +import type { ZetaChainCCTX } from 'types/api/zetaChain'; + +import { TableCell, TableRow } from 'toolkit/chakra/table'; +import AddressFromTo from 'ui/shared/address/AddressFromTo'; +import CCTxEntityZetaChain from 'ui/shared/entities/tx/CCTxEntityZetaChain'; +import TimeWithTooltip from 'ui/shared/time/TimeWithTooltip'; +import ZetaChainCCTXReducedStatus from 'ui/shared/zetaChain/ZetaChainCCTXReducedStatus'; +import ZetaChainCCTXValue from 'ui/shared/zetaChain/ZetaChainCCTXValue'; + +type Props = { + tx: ZetaChainCCTX; + enableTimeIncrement?: boolean; + isLoading?: boolean; + animation?: string; +}; + +const ZetaChainCCTxsTableItem = ({ tx, enableTimeIncrement, isLoading, animation }: Props) => { + return ( + + + + + + + + + + + + + + + + + + ); +}; + +export default React.memo(ZetaChainCCTxsTableItem); diff --git a/ui/txs/zetaChain/ZetaChainEvmTransactions.tsx b/ui/txs/zetaChain/ZetaChainEvmTransactions.tsx new file mode 100644 index 0000000000..3fe9246733 --- /dev/null +++ b/ui/txs/zetaChain/ZetaChainEvmTransactions.tsx @@ -0,0 +1,142 @@ +import { Flex } from '@chakra-ui/react'; +import { capitalize } from 'es-toolkit/compat'; +import { useRouter } from 'next/router'; +import React from 'react'; + +import type { TabItemRegular } from 'toolkit/components/AdaptiveTabs/types'; + +import { route } from 'nextjs-routes'; + +import config from 'configs/app'; +import useIsMobile from 'lib/hooks/useIsMobile'; +import getNetworkValidationActionText from 'lib/networks/getNetworkValidationActionText'; +import getQueryParamString from 'lib/router/getQueryParamString'; +import { TX } from 'stubs/tx'; +import { generateListStub } from 'stubs/utils'; +import { Link } from 'toolkit/chakra/link'; +import RoutedTabs from 'toolkit/components/RoutedTabs/RoutedTabs'; +import IconSvg from 'ui/shared/IconSvg'; +import Pagination from 'ui/shared/pagination/Pagination'; +import useQueryWithPages from 'ui/shared/pagination/useQueryWithPages'; +import TxsStats from 'ui/txs/TxsStats'; + +import TxsWithFrontendSorting from '../TxsWithFrontendSorting'; + +const TAB_LIST_PROPS = { + marginBottom: 0, + pt: 6, + pb: 6, + marginTop: -5, +}; +const TABS_HEIGHT = 88; + +const ZetaChainEvmTransactions = () => { + const router = useRouter(); + const tab = getQueryParamString(router.query.tab); + const isMobile = useIsMobile(); + + const txsValidatedQuery = useQueryWithPages({ + resourceName: 'general:txs_validated', + filters: { filter: 'validated' }, + options: { + enabled: !tab || tab === 'zetachain' || tab === 'zetachain_validated', + placeholderData: generateListStub<'general:txs_validated'>(TX, 50, { next_page_params: { + block_number: 9005713, + index: 5, + items_count: 50, + filter: 'validated', + } }), + }, + }); + + const txsPendingQuery = useQueryWithPages({ + resourceName: 'general:txs_pending', + filters: { filter: 'pending' }, + options: { + enabled: tab === 'zetachain_pending', + placeholderData: generateListStub<'general:txs_pending'>(TX, 50, { next_page_params: { + inserted_at: '2024-02-05T07:04:47.749818Z', + hash: '0x00', + filter: 'pending', + } }), + }, + }); + + const verifiedTitle = capitalize(getNetworkValidationActionText()); + + const tabs: Array = [ + { + id: 'zetachain_validated', + title: verifiedTitle, + component: + }, + { + id: 'zetachain_pending', + title: 'Pending', + component: ( + + ), + }, + ]; + + const pagination = (() => { + switch (tab) { + case 'zetachain_pending': return txsPendingQuery.pagination; + default: return txsValidatedQuery.pagination; + } + })(); + + const rightSlot = (() => { + if (isMobile) { + return null; + } + + const isAdvancedFilterEnabled = config.features.advancedFilter.isEnabled; + + if (!isAdvancedFilterEnabled && !pagination.isVisible) { + return null; + } + + return ( + + { isAdvancedFilterEnabled && ( + + + Advanced filter + + ) } + { pagination.isVisible && } + + ); + })(); + + return ( + <> + + + + ); +}; + +export default ZetaChainEvmTransactions; diff --git a/ui/txs/zetaChain/__screenshots__/ZetaChainCCTXsTab.pw.tsx_dark-color-mode_base-view-dark-mode-1.png b/ui/txs/zetaChain/__screenshots__/ZetaChainCCTXsTab.pw.tsx_dark-color-mode_base-view-dark-mode-1.png new file mode 100644 index 0000000000..21c05f429c Binary files /dev/null and b/ui/txs/zetaChain/__screenshots__/ZetaChainCCTXsTab.pw.tsx_dark-color-mode_base-view-dark-mode-1.png differ diff --git a/ui/txs/zetaChain/__screenshots__/ZetaChainCCTXsTab.pw.tsx_dark-color-mode_mobile-base-view-dark-mode-1.png b/ui/txs/zetaChain/__screenshots__/ZetaChainCCTXsTab.pw.tsx_dark-color-mode_mobile-base-view-dark-mode-1.png new file mode 100644 index 0000000000..44b0c4394c Binary files /dev/null and b/ui/txs/zetaChain/__screenshots__/ZetaChainCCTXsTab.pw.tsx_dark-color-mode_mobile-base-view-dark-mode-1.png differ diff --git a/ui/txs/zetaChain/__screenshots__/ZetaChainCCTXsTab.pw.tsx_default_base-view-dark-mode-1.png b/ui/txs/zetaChain/__screenshots__/ZetaChainCCTXsTab.pw.tsx_default_base-view-dark-mode-1.png new file mode 100644 index 0000000000..adaa9dccd0 Binary files /dev/null and b/ui/txs/zetaChain/__screenshots__/ZetaChainCCTXsTab.pw.tsx_default_base-view-dark-mode-1.png differ diff --git a/ui/txs/zetaChain/__screenshots__/ZetaChainCCTXsTab.pw.tsx_default_mobile-base-view-dark-mode-1.png b/ui/txs/zetaChain/__screenshots__/ZetaChainCCTXsTab.pw.tsx_default_mobile-base-view-dark-mode-1.png new file mode 100644 index 0000000000..2a10a5521a Binary files /dev/null and b/ui/txs/zetaChain/__screenshots__/ZetaChainCCTXsTab.pw.tsx_default_mobile-base-view-dark-mode-1.png differ diff --git a/ui/txs/zetaChain/filters/ZetaChainAddressFilter.tsx b/ui/txs/zetaChain/filters/ZetaChainAddressFilter.tsx new file mode 100644 index 0000000000..4ecd3b7e15 --- /dev/null +++ b/ui/txs/zetaChain/filters/ZetaChainAddressFilter.tsx @@ -0,0 +1,224 @@ +import { Flex, VStack, createListCollection } from '@chakra-ui/react'; +import { isEqual } from 'es-toolkit'; +import type { ChangeEvent } from 'react'; +import React from 'react'; + +import type { ZetaChainCCTXFilterParams } from 'types/api/zetaChain'; +import type { ChainInfo } from 'types/client/chainInfo'; + +import { Image } from 'toolkit/chakra/image'; +import { Input } from 'toolkit/chakra/input'; +import { InputGroup } from 'toolkit/chakra/input-group'; +import { Select } from 'toolkit/chakra/select'; +import type { SelectOption } from 'toolkit/chakra/select'; +import AddButton from 'toolkit/components/buttons/AddButton'; +import { ClearButton } from 'toolkit/components/buttons/ClearButton'; +import TableColumnFilter from 'ui/shared/filters/TableColumnFilter'; +import IconSvg from 'ui/shared/IconSvg'; +import useZetaChainConfig from 'ui/zetaChain/useZetaChainConfig'; + +type Props = { + value?: Array; + chainValue?: Array; + handleFilterChange: (field: keyof ZetaChainCCTXFilterParams, value?: Array | Array) => void; + columnName: string; + isLoading?: boolean; + onClose?: () => void; + filterParam: keyof ZetaChainCCTXFilterParams; + chainFilterParam: keyof ZetaChainCCTXFilterParams; + title: string; + placeholder: string; +}; + +type InputProps = { + address?: string; + isLast: boolean; + onChange: (event: ChangeEvent) => void; + onClear: () => void; + onAddFieldClick: () => void; + placeholder: string; +}; + +type ChainSelectProps = { + selectedChains: Array; + onChainChange: (chains: Array) => void; + chains: Array; + isLoading: boolean; +}; + +const AddressFilterInput = ({ address, onChange, onClear, isLast, onAddFieldClick, placeholder }: InputProps) => { + return ( + + } + > + + + { isLast && ( + + ) } + + ); +}; + +const ChainSelect = ({ selectedChains, onChainChange, chains, isLoading }: ChainSelectProps) => { + const collection = React.useMemo(() => { + const options: Array = [ + { value: 'all', label: 'All chains' }, + ...chains.map(chain => ({ + value: chain.chain_id.toString(), + label: chain.chain_name || `Chain ${ chain.chain_id }`, + renderLabel: () => ( + + { chain.chain_logo ? ( + { + ) : ( + + ) } + { chain.chain_name || `Chain ${ chain.chain_id }` } + + ), + })), + ]; + return createListCollection({ items: options }); + }, [ chains ]); + + const handleValueChange = React.useCallback(({ value }: { value: Array }) => { + const chainIds = value.filter(v => v !== 'all'); + onChainChange(chainIds); + }, [ onChainChange ]); + + const selectedValues = React.useMemo(() => { + if (selectedChains.length === 0) { + return [ 'all' ]; + } + return selectedChains.map(id => id.toString()); + }, [ selectedChains ]); + + return ( +