diff --git a/configs/app/apis.ts b/configs/app/apis.ts index 071bb22bf8..ea919451ea 100644 --- a/configs/app/apis.ts +++ b/configs/app/apis.ts @@ -19,6 +19,10 @@ export interface ApiPropsFull extends ApiPropsBase { const generalApi = (() => { const apiHost = getEnvValue('NEXT_PUBLIC_API_HOST'); + if (!apiHost) { + return; + } + const apiSchema = getEnvValue('NEXT_PUBLIC_API_PROTOCOL') || 'https'; const apiPort = getEnvValue('NEXT_PUBLIC_API_PORT'); const apiEndpoint = [ @@ -101,9 +105,10 @@ const rewardsApi = (() => { }); })(); -const multichainApi = (() => { +const multichainAggregatorApi = (() => { const apiHost = getEnvValue('NEXT_PUBLIC_MULTICHAIN_AGGREGATOR_API_HOST'); - if (!apiHost) { + const cluster = getEnvValue('NEXT_PUBLIC_MULTICHAIN_CLUSTER'); + if (!apiHost || !cluster) { return; } @@ -113,12 +118,22 @@ const multichainApi = (() => { return Object.freeze({ endpoint: apiHost, socketEndpoint: `wss://${ url.host }`, - basePath: stripTrailingSlash(getEnvValue('NEXT_PUBLIC_MULTICHAIN_AGGREGATOR_BASE_PATH') || ''), + basePath: `/api/v1/clusters/${ cluster }`, }); } catch (error) { return; } +})(); +const multichainStatsApi = (() => { + const apiHost = getEnvValue('NEXT_PUBLIC_MULTICHAIN_STATS_API_HOST'); + if (!apiHost) { + return; + } + + return Object.freeze({ + endpoint: apiHost, + }); })(); const statsApi = (() => { @@ -197,7 +212,7 @@ const zetachainApi = (() => { })(); export type Apis = { - general: ApiPropsFull; + general: ApiPropsFull | undefined; } & Partial, ApiPropsBase>>; const apis: Apis = Object.freeze({ @@ -207,7 +222,8 @@ const apis: Apis = Object.freeze({ clusters: clustersApi, contractInfo: contractInfoApi, metadata: metadataApi, - multichain: multichainApi, + multichainAggregator: multichainAggregatorApi, + multichainStats: multichainStatsApi, rewards: rewardsApi, stats: statsApi, tac: tacApi, diff --git a/configs/app/features/opSuperchain.ts b/configs/app/features/opSuperchain.ts index f46e64d19c..9251ff4bce 100644 --- a/configs/app/features/opSuperchain.ts +++ b/configs/app/features/opSuperchain.ts @@ -3,15 +3,22 @@ import type { Feature } from './types'; import apis from '../apis'; import { getEnvValue } from '../utils'; -const isEnabled = getEnvValue('NEXT_PUBLIC_OP_SUPERCHAIN_ENABLED') === 'true'; +const isEnabled = getEnvValue('NEXT_PUBLIC_MULTICHAIN_ENABLED') === 'true'; +const cluster = getEnvValue('NEXT_PUBLIC_MULTICHAIN_CLUSTER'); +// The feature was initially implemented for OP Superchain interop cluster +// but later the project was abandoned by Optimism team. +// Now it serves mainly for demo purposes of multichain explorer possible functionalities. +// So for now I have kept all naming in the code as it was initially done +// and later it could be changed when specific multichain cluster will be implemented. const title = 'OP Superchain interop explorer'; -const config: Feature<{ }> = (() => { - if (apis.multichain && isEnabled) { +const config: Feature<{ cluster: string }> = (() => { + if (apis.multichainAggregator && apis.multichainStats && isEnabled && cluster) { return Object.freeze({ title, isEnabled: true, + cluster, }); } diff --git a/configs/app/features/stats.ts b/configs/app/features/stats.ts index 58809dd83a..0254a8dfdd 100644 --- a/configs/app/features/stats.ts +++ b/configs/app/features/stats.ts @@ -1,11 +1,12 @@ import type { Feature } from './types'; import apis from '../apis'; +import opSuperchain from './opSuperchain'; const title = 'Blockchain statistics'; const config: Feature<{}> = (() => { - if (apis.stats) { + if (apis.stats || opSuperchain.isEnabled) { return Object.freeze({ title, isEnabled: true, diff --git a/configs/envs/.env.optimism_superchain b/configs/envs/.env.optimism_superchain index 805ebb5619..7c7ac7642a 100644 --- a/configs/envs/.env.optimism_superchain +++ b/configs/envs/.env.optimism_superchain @@ -8,24 +8,16 @@ NEXT_PUBLIC_APP_HOST=localhost NEXT_PUBLIC_APP_PORT=3000 NEXT_PUBLIC_APP_ENV=development -# Instance ENVs -# TODO @tom2drum make these envs optional for multichain (adjust docs) -NEXT_PUBLIC_API_WEBSOCKET_PROTOCOL=ws -NEXT_PUBLIC_API_BASE_PATH=/ -NEXT_PUBLIC_API_HOST=localhost -NEXT_PUBLIC_API_PORT=3001 -NEXT_PUBLIC_API_PROTOCOL=http -NEXT_PUBLIC_NETWORK_ID=10 - -# TODO @tom2drum New ENVs (add to docs) NEXT_PUBLIC_MULTICHAIN_AGGREGATOR_API_HOST=https://multichain-aggregator.k8s-dev.blockscout.com -NEXT_PUBLIC_MULTICHAIN_AGGREGATOR_BASE_PATH=/api/v1/clusters/interop -NEXT_PUBLIC_OP_SUPERCHAIN_ENABLED=true +NEXT_PUBLIC_MULTICHAIN_STATS_API_HOST=http://multichain-search-stats.k8s-dev.blockscout.com +NEXT_PUBLIC_MULTICHAIN_ENABLED=true +NEXT_PUBLIC_MULTICHAIN_CLUSTER=interop -# TODO @tom2drum remove this -SKIP_ENVS_VALIDATION=true +SKIP_ENVS_VALIDATION=false -NEXT_PUBLIC_API_SPEC_URL=none +NEXT_PUBLIC_HOMEPAGE_CHARTS=['daily_txs','coin_price','market_cap'] +NEXT_PUBLIC_HOMEPAGE_STATS=['total_txs','wallet_addresses'] +NEXT_PUBLIC_API_DOCS_TABS=[] NEXT_PUBLIC_FEATURED_NETWORKS=https://raw.githubusercontent.com/blockscout/frontend-configs/main/configs/featured-networks/optimism-mainnet.json NEXT_PUBLIC_FOOTER_LINKS=https://raw.githubusercontent.com/blockscout/frontend-configs/main/configs/footer-links/optimism.json NEXT_PUBLIC_HOMEPAGE_CHARTS=['daily_txs', 'coin_price', 'market_cap', 'secondary_coin_price'] @@ -38,7 +30,6 @@ NEXT_PUBLIC_NETWORK_NAME=OP Superchain NEXT_PUBLIC_NETWORK_SHORT_NAME=OP Superchain NEXT_PUBLIC_OG_IMAGE_URL=https://raw.githubusercontent.com/blockscout/frontend-configs/main/configs/og-images/optimism-mainnet.png NEXT_PUBLIC_GAS_TRACKER_ENABLED=false -NEXT_PUBLIC_NAVIGATION_HIDDEN_LINKS=['eth_rpc_api','rpc_api'] NEXT_PUBLIC_HIDE_INDEXING_ALERT_BLOCKS=true NEXT_PUBLIC_HIDE_INDEXING_ALERT_INT_TXS=true NEXT_PUBLIC_IS_ACCOUNT_SUPPORTED=false diff --git a/configs/envs/.env.pw b/configs/envs/.env.pw index 45183175b1..ace1fd3f21 100644 --- a/configs/envs/.env.pw +++ b/configs/envs/.env.pw @@ -49,10 +49,12 @@ NEXT_PUBLIC_ADMIN_SERVICE_API_HOST=http://localhost:3006 NEXT_PUBLIC_METADATA_SERVICE_API_HOST=http://localhost:3007 NEXT_PUBLIC_NAME_SERVICE_API_HOST=http://localhost:3008 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_MULTICHAIN_AGGREGATOR_API_HOST=http://localhost:3012 +NEXT_PUBLIC_MULTICHAIN_CLUSTER=test +NEXT_PUBLIC_MULTICHAIN_STATS_API_HOST=http://localhost:3013 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/essential-dapps-chains/index.ts b/configs/essential-dapps-chains/index.ts index 15ef4df768..133c6513d3 100644 --- a/configs/essential-dapps-chains/index.ts +++ b/configs/essential-dapps-chains/index.ts @@ -1,11 +1,11 @@ -import type { MultichainConfig } from 'types/multichain'; +import type { EssentialDappsChainConfig } from 'types/client/marketplace'; import config from 'configs/app'; import { isBrowser } from 'toolkit/utils/isBrowser'; const marketplaceFeature = config.features.marketplace; -const essentialDappsChains: () => MultichainConfig | undefined = () => { +const essentialDappsChains: () => { chains: Array } | undefined = () => { if (!marketplaceFeature.isEnabled || !marketplaceFeature.essentialDapps) { return; } diff --git a/deploy/tools/envs-validator/index.ts b/deploy/tools/envs-validator/index.ts index 89d2dae590..7adc69c22f 100644 --- a/deploy/tools/envs-validator/index.ts +++ b/deploy/tools/envs-validator/index.ts @@ -5,6 +5,7 @@ import type { ValidationError } from 'yup'; import { buildExternalAssetFilePath } from '../../../configs/app/utils'; import schema from './schema'; +import schemaMultichain from './schema_multichain'; const silent = process.argv.includes('--silent'); @@ -51,7 +52,12 @@ async function validateEnvs(appEnvs: Record) { } } - await schema.validate(appEnvs, { stripUnknown: false, abortEarly: false }); + if (appEnvs.NEXT_PUBLIC_MULTICHAIN_ENABLED === 'true') { + await schemaMultichain.validate(appEnvs, { stripUnknown: false, abortEarly: false }); + } else { + await schema.validate(appEnvs, { stripUnknown: false, abortEarly: false }); + } + !silent && console.log('👍 All good!'); } catch (_error) { if (typeof _error === 'object' && _error !== null && 'errors' in _error) { diff --git a/deploy/tools/envs-validator/schema.ts b/deploy/tools/envs-validator/schema.ts index 72079127d4..c748fcb18f 100644 --- a/deploy/tools/envs-validator/schema.ts +++ b/deploy/tools/envs-validator/schema.ts @@ -9,12 +9,8 @@ declare module 'yup' { import * as yup from 'yup'; -import type { AdButlerConfig } from '../../../types/client/adButlerConfig'; import type { AddressProfileAPIConfig } from '../../../types/client/addressProfileAPIConfig'; -import { SUPPORTED_AD_TEXT_PROVIDERS, SUPPORTED_AD_BANNER_PROVIDERS, SUPPORTED_AD_BANNER_ADDITIONAL_PROVIDERS } from '../../../types/client/adProviders'; -import type { AdTextProviders, AdBannerProviders, AdBannerAdditionalProviders } from '../../../types/client/adProviders'; import { SMART_CONTRACT_EXTRA_VERIFICATION_METHODS, SMART_CONTRACT_LANGUAGE_FILTERS, type ContractCodeIde, type SmartContractVerificationMethodExtra } from '../../../types/client/contract'; -import type { DeFiDropdownItem } from '../../../types/client/deFiDropdown'; import type { GasRefuelProviderConfig } from '../../../types/client/gasRefuelProviderConfig'; import { GAS_UNITS } from '../../../types/client/gasTracker'; import type { GasUnit } from '../../../types/client/gasTracker'; @@ -22,7 +18,6 @@ import type { MarketplaceAppBase, MarketplaceAppSocialInfo, EssentialDappsConfig import type { MultichainProviderConfig } from '../../../types/client/multichainProviderConfig'; import type { ApiDocsTabId } from '../../../types/views/apiDocs'; import { API_DOCS_TABS } from '../../../types/views/apiDocs'; -import type { NavItemExternal, NavigationLayout, NavigationPromoBannerConfig } from '../../../types/client/navigation'; import { ROLLUP_TYPES } from '../../../types/client/rollup'; import type { BridgedTokenChain, TokenBridge } from '../../../types/client/token'; import { PROVIDERS as TX_INTERPRETATION_PROVIDERS } from '../../../types/client/txInterpretation'; @@ -30,12 +25,7 @@ import { VALIDATORS_CHAIN_TYPE } from '../../../types/client/validators'; import type { ValidatorsChainType } from '../../../types/client/validators'; import type { WalletType } from '../../../types/client/wallets'; import { SUPPORTED_WALLETS } from '../../../types/client/wallets'; -import type { CustomLink, CustomLinksGroup } from '../../../types/footerLinks'; -import { CHAIN_INDICATOR_IDS, HOME_STATS_WIDGET_IDS } from '../../../types/homepage'; -import type { ChainIndicatorId, HeroBannerButtonState, HeroBannerConfig, HomeStatsWidgetId } from '../../../types/homepage'; -import { type NetworkVerificationTypeEnvs, type NetworkExplorer, type FeaturedNetwork, NETWORK_GROUPS } from '../../../types/networks'; -import { COLOR_THEME_IDS } from '../../../types/settings'; -import type { FontFamily } from '../../../types/ui'; +import { type NetworkVerificationTypeEnvs, type NetworkExplorer } from '../../../types/networks'; import type { AddressFormat, AddressViewId, Address3rdPartyWidget } from '../../../types/views/address'; import { ADDRESS_FORMATS, ADDRESS_VIEWS_IDS, IDENTICON_TYPES, ADDRESS_3RD_PARTY_WIDGET_PAGES } from '../../../types/views/address'; import { BLOCK_FIELDS_IDS } from '../../../types/views/block'; @@ -48,38 +38,11 @@ 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 { CrossChainInfo } from '../../../types/client/crossChainInfo'; - -const protocols = [ 'http', 'https' ]; - -const urlTest: yup.TestConfig = { - name: 'url', - test: (value: unknown) => { - if (!value) { - return true; - } - - try { - if (typeof value === 'string') { - new URL(value); - return true; - } - } catch (error) {} - - return false; - }, - message: '${path} is not a valid URL', - exclusive: true, -}; - -const getYupValidationErrorMessage = (error: unknown) => - typeof error === 'object' && - error !== null && - 'errors' in error && - Array.isArray(error.errors) ? - error.errors.join(', ') : - ''; +import type { ZetaChainChainsConfigEnv } from '../../../types/client/zetaChain'; +import { urlTest, protocols, getYupValidationErrorMessage } from './utils'; +import * as uiSchemas from './schemas/ui'; +import * as featuresSchemas from './schemas/features'; +import servicesSchemas from './schemas/services'; const marketplaceAppSchema: yup.ObjectSchema = yup .object({ @@ -452,20 +415,6 @@ const apiDocsScheme = yup .test(urlTest), }); -const userOpsSchema = yup - .object() - .shape({ - NEXT_PUBLIC_HAS_USER_OPS: yup.boolean(), - NEXT_PUBLIC_USER_OPS_INDEXER_API_HOST: yup - .string() - .test(urlTest) - .when('NEXT_PUBLIC_HAS_USER_OPS', { - is: (value: boolean) => value, - then: (schema) => schema, - otherwise: (schema) => schema.max(-1, 'NEXT_PUBLIC_USER_OPS_INDEXER_API_HOST can only be used if NEXT_PUBLIC_HAS_USER_OPS is set to \'true\''), - }), - }); - const mixpanelSchema = yup .object() .shape({ @@ -485,111 +434,12 @@ const mixpanelSchema = yup }), }); -const adButlerConfigSchema = yup - .object() - .transform(replaceQuotes) - .json() - .when('NEXT_PUBLIC_AD_BANNER_PROVIDER', { - is: (value: AdBannerProviders) => value === 'adbutler', - then: (schema) => schema - .shape({ - id: yup.string().required(), - width: yup.number().positive().required(), - height: yup.number().positive().required(), - }) - .required(), - }) - .when('NEXT_PUBLIC_AD_BANNER_ADDITIONAL_PROVIDER', { - is: (value: AdBannerProviders) => value === 'adbutler', - then: (schema) => schema - .shape({ - id: yup.string().required(), - width: yup.number().positive().required(), - height: yup.number().positive().required(), - }) - .required(), - }); - -const adsBannerSchema = yup - .object() - .shape({ - NEXT_PUBLIC_AD_BANNER_PROVIDER: yup.string().oneOf(SUPPORTED_AD_BANNER_PROVIDERS), - NEXT_PUBLIC_AD_BANNER_ADDITIONAL_PROVIDER: yup.string().oneOf(SUPPORTED_AD_BANNER_ADDITIONAL_PROVIDERS), - NEXT_PUBLIC_AD_ADBUTLER_CONFIG_DESKTOP: adButlerConfigSchema, - NEXT_PUBLIC_AD_ADBUTLER_CONFIG_MOBILE: adButlerConfigSchema, - NEXT_PUBLIC_AD_BANNER_ENABLE_SPECIFY: yup.boolean(), - }); - const accountSchema = yup .object() .shape({ NEXT_PUBLIC_IS_ACCOUNT_SUPPORTED: yup.boolean(), }); -const featuredNetworkSchema: yup.ObjectSchema = yup - .object() - .shape({ - title: yup.string().required(), - url: yup.string().test(urlTest).required(), - group: yup.string().oneOf(NETWORK_GROUPS).required(), - icon: yup.string().test(urlTest), - isActive: yup.boolean(), - invertIconInDarkMode: yup.boolean(), - }); - -const navItemExternalSchema: yup.ObjectSchema = yup - .object({ - text: yup.string().required(), - url: yup.string().test(urlTest).required(), - }); - -const fontFamilySchema: yup.ObjectSchema = yup - .object() - .transform(replaceQuotes) - .json() - .shape({ - name: yup.string().required(), - url: yup.string().test(urlTest).required(), - }); - -const heroBannerButtonStateSchema: yup.ObjectSchema = yup.object({ - background: yup.array().max(2).of(yup.string()), - text_color: yup.array().max(2).of(yup.string()), -}); - -const heroBannerSchema: yup.ObjectSchema = yup.object() - .transform(replaceQuotes) - .json() - .shape({ - background: yup.array().max(2).of(yup.string()), - text_color: yup.array().max(2).of(yup.string()), - border: yup.array().max(2).of(yup.string()), - button: yup.object({ - _default: heroBannerButtonStateSchema, - _hover: heroBannerButtonStateSchema, - _selected: heroBannerButtonStateSchema, - }), - search: yup.object({ - border_width: yup.array().max(2).of(yup.string()), - }), - }); - -const footerLinkSchema: yup.ObjectSchema = yup - .object({ - text: yup.string().required(), - url: yup.string().test(urlTest).required(), - iconUrl: yup.array().of(yup.string().required().test(urlTest)), - }); - -const footerLinkGroupSchema: yup.ObjectSchema = yup - .object({ - title: yup.string().required(), - links: yup - .array() - .of(footerLinkSchema) - .required(), - }); - const networkExplorerSchema: yup.ObjectSchema = yup .object({ title: yup.string().required(), @@ -675,17 +525,6 @@ const addressMetadataSchema = yup }), }); -const deFiDropdownItemSchema: yup.ObjectSchema = yup - .object({ - text: yup.string().required(), - icon: yup.string(), - dappId: yup.string(), - url: yup.string().test(urlTest), - }) - .test('oneOfRequired', 'NEXT_PUBLIC_DEFI_DROPDOWN_ITEMS: Either dappId or url is required', function(value) { - return Boolean(value.dappId) || Boolean(value.url); - }) as yup.ObjectSchema; - const multichainProviderConfigSchema: yup.ObjectSchema = yup.object({ name: yup.string().required(), url_template: yup.string().required(), @@ -693,7 +532,7 @@ const multichainProviderConfigSchema: yup.ObjectSchema dapp_id: yup.string(), }); -const zetaChainCCTXConfigSchema: yup.ObjectSchema = yup.object({ +const zetaChainCCTXConfigSchema: yup.ObjectSchema = yup.object({ chain_id: yup.number().required(), chain_name: yup.string().required(), chain_logo: yup.string(), @@ -865,129 +704,6 @@ const schema = yup NEXT_PUBLIC_API_BASE_PATH: yup.string(), NEXT_PUBLIC_API_WEBSOCKET_PROTOCOL: yup.string().oneOf([ 'ws', 'wss' ]), - // 4. UI configuration - // a. homepage - NEXT_PUBLIC_HOMEPAGE_CHARTS: yup - .array() - .transform(replaceQuotes) - .json() - .of(yup.string().oneOf(CHAIN_INDICATOR_IDS)) - .test( - 'stats-api-required', - 'NEXT_PUBLIC_STATS_API_HOST is required when daily_operational_txs is enabled in NEXT_PUBLIC_HOMEPAGE_CHARTS', - function(value) { - // daily_operational_txs is presented only in stats microservice - if (value?.includes('daily_operational_txs')) { - return Boolean(this.parent.NEXT_PUBLIC_STATS_API_HOST); - } - return true; - } - ), - NEXT_PUBLIC_HOMEPAGE_STATS: yup - .array() - .transform(replaceQuotes) - .json() - .of(yup.string().oneOf(HOME_STATS_WIDGET_IDS)) - .test( - 'stats-api-required', - 'NEXT_PUBLIC_STATS_API_HOST is required when total_operational_txs is enabled in NEXT_PUBLIC_HOMEPAGE_STATS', - function(value) { - // total_operational_txs is presented only in stats microservice - if (value?.includes('total_operational_txs')) { - return Boolean(this.parent.NEXT_PUBLIC_STATS_API_HOST); - } - return true; - } - ), - NEXT_PUBLIC_HOMEPAGE_HERO_BANNER_CONFIG: yup - .mixed() - .test( - 'shape', - (ctx) => { - try { - heroBannerSchema.validateSync(ctx.originalValue); - throw new Error('Unknown validation error'); - } catch (error: unknown) { - const message = getYupValidationErrorMessage(error); - return 'Invalid schema were provided for NEXT_PUBLIC_HOMEPAGE_HERO_BANNER_CONFIG' + (message ? `: ${ message }` : ''); - } - }, - (data) => { - const isUndefined = data === undefined; - return isUndefined || heroBannerSchema.isValidSync(data); - }), - - // b. sidebar - NEXT_PUBLIC_FEATURED_NETWORKS: yup - .array() - .json() - .of(featuredNetworkSchema), - NEXT_PUBLIC_FEATURED_NETWORKS_ALL_LINK: yup - .string() - .when('NEXT_PUBLIC_FEATURED_NETWORKS', { - is: (value: Array | undefined) => value && value.length > 0, - then: (schema) => schema.test(urlTest), - otherwise: (schema) => schema.max(-1, 'NEXT_PUBLIC_FEATURED_NETWORKS_ALL_LINK can only be set when NEXT_PUBLIC_FEATURED_NETWORKS is configured'), - }), - NEXT_PUBLIC_FEATURED_NETWORKS_MODE: yup - .string() - .when('NEXT_PUBLIC_FEATURED_NETWORKS', { - is: (value: Array | undefined) => value && value.length > 0, - then: (schema) => schema.oneOf([ 'tabs', 'list' ]), - otherwise: (schema) => schema.max(-1, 'NEXT_PUBLIC_FEATURED_NETWORKS_MODE can only be set when NEXT_PUBLIC_FEATURED_NETWORKS is configured'), - }), - NEXT_PUBLIC_OTHER_LINKS: yup - .array() - .transform(replaceQuotes) - .json() - .of(navItemExternalSchema), - NEXT_PUBLIC_NAVIGATION_HIGHLIGHTED_ROUTES: yup - .array() - .transform(replaceQuotes) - .json() - .of(yup.string()), - NEXT_PUBLIC_NAVIGATION_LAYOUT: yup.string().oneOf([ 'horizontal', 'vertical' ]), - NEXT_PUBLIC_NAVIGATION_PROMO_BANNER_CONFIG: yup - .mixed() - .test('shape', 'Invalid schema were provided for NEXT_PUBLIC_NAVIGATION_PROMO_BANNER_CONFIG, it should be either object with img_url, text, bg_color, text_color, link_url or object with img_url and link_url', (data) => { - const isUndefined = data === undefined; - const jsonSchema = yup.object().transform(replaceQuotes).json(); - - const valueSchema1 = jsonSchema.shape({ - img_url: yup.string().required(), - text: yup.string().required(), - bg_color: yup.object().shape({ - light: yup.string().required(), - dark: yup.string().required(), - }).required(), - text_color: yup.object().shape({ - light: yup.string().required(), - dark: yup.string().required(), - }).required(), - link_url: yup.string().required(), - }); - - const valueSchema2 = jsonSchema.shape({ - img_url: yup.object().shape({ - small: yup.string().required(), - large: yup.string().required(), - }).required(), - link_url: yup.string().required(), - }); - - return isUndefined || valueSchema1.isValidSync(data) || valueSchema2.isValidSync(data); - }), - NEXT_PUBLIC_NETWORK_LOGO: yup.string().test(urlTest), - NEXT_PUBLIC_NETWORK_LOGO_DARK: yup.string().test(urlTest), - NEXT_PUBLIC_NETWORK_ICON: yup.string().test(urlTest), - NEXT_PUBLIC_NETWORK_ICON_DARK: yup.string().test(urlTest), - - // c. footer - NEXT_PUBLIC_FOOTER_LINKS: yup - .array() - .json() - .of(footerLinkGroupSchema), - // d. views NEXT_PUBLIC_VIEWS_BLOCK_HIDDEN_FIELDS: yup .array() @@ -1061,7 +777,6 @@ const schema = yup NEXT_PUBLIC_VIEWS_TOKEN_SCAM_TOGGLE_ENABLED: yup.boolean(), NEXT_PUBLIC_HELIA_VERIFIED_FETCH_ENABLED: yup.boolean(), - // e. misc NEXT_PUBLIC_NETWORK_EXPLORERS: yup .array() .transform(replaceQuotes) @@ -1073,25 +788,6 @@ const schema = yup .json() .of(contractCodeIdeSchema), NEXT_PUBLIC_HAS_CONTRACT_AUDIT_REPORTS: yup.boolean(), - NEXT_PUBLIC_HIDE_INDEXING_ALERT_BLOCKS: yup.boolean(), - NEXT_PUBLIC_HIDE_INDEXING_ALERT_INT_TXS: yup.boolean(), - NEXT_PUBLIC_HIDE_NATIVE_COIN_PRICE: yup.boolean(), - NEXT_PUBLIC_MAINTENANCE_ALERT_MESSAGE: yup.string(), - NEXT_PUBLIC_COLOR_THEME_DEFAULT: yup.string().oneOf(COLOR_THEME_IDS), - NEXT_PUBLIC_COLOR_THEME_OVERRIDES: yup.object().transform(replaceQuotes).json(), - NEXT_PUBLIC_FONT_FAMILY_HEADING: yup - .mixed() - .test('shape', 'Invalid schema were provided for NEXT_PUBLIC_FONT_FAMILY_HEADING', (data) => { - const isUndefined = data === undefined; - return isUndefined || fontFamilySchema.isValidSync(data); - }), - NEXT_PUBLIC_FONT_FAMILY_BODY: yup - .mixed() - .test('shape', 'Invalid schema were provided for NEXT_PUBLIC_FONT_FAMILY_BODY', (data) => { - const isUndefined = data === undefined; - return isUndefined || fontFamilySchema.isValidSync(data); - }), - NEXT_PUBLIC_MAX_CONTENT_WIDTH_ENABLED: yup.boolean(), // 5. Features configuration NEXT_PUBLIC_STATS_API_HOST: yup.string().test(urlTest), @@ -1117,7 +813,6 @@ const schema = yup }), NEXT_PUBLIC_WEB3_DISABLE_ADD_TOKEN_TO_WALLET: yup.boolean(), NEXT_PUBLIC_TRANSACTION_INTERPRETATION_PROVIDER: yup.string().oneOf(TX_INTERPRETATION_PROVIDERS), - NEXT_PUBLIC_AD_TEXT_PROVIDER: yup.string().oneOf(SUPPORTED_AD_TEXT_PROVIDERS), NEXT_PUBLIC_PROMOTE_BLOCKSCOUT_IN_TITLE: yup.boolean(), NEXT_PUBLIC_OG_DESCRIPTION: yup.string(), NEXT_PUBLIC_OG_IMAGE_URL: yup.string().test(urlTest), @@ -1149,11 +844,6 @@ const schema = yup NEXT_PUBLIC_GAS_TRACKER_UNITS: yup.array().transform(replaceQuotes).json().of(yup.string().oneOf(GAS_UNITS)), NEXT_PUBLIC_DATA_AVAILABILITY_ENABLED: yup.boolean(), NEXT_PUBLIC_ADVANCED_FILTER_ENABLED: yup.boolean(), - NEXT_PUBLIC_DEFI_DROPDOWN_ITEMS: yup - .array() - .transform(replaceQuotes) - .json() - .of(deFiDropdownItemSchema), NEXT_PUBLIC_FAULT_PROOF_ENABLED: yup.boolean() .when('NEXT_PUBLIC_ROLLUP_TYPE', { is: 'optimistic', @@ -1227,19 +917,18 @@ const schema = yup return isUndefined || valueSchema.isValidSync(data); }), - // 6. External services envs - NEXT_PUBLIC_WALLET_CONNECT_PROJECT_ID: yup.string(), - NEXT_PUBLIC_RE_CAPTCHA_APP_SITE_KEY: yup.string(), - NEXT_PUBLIC_GOOGLE_ANALYTICS_PROPERTY_ID: yup.string(), - NEXT_PUBLIC_GROWTH_BOOK_CLIENT_KEY: yup.string(), - NEXT_PUBLIC_ROLLBAR_CLIENT_TOKEN: yup.string(), - - // Misc NEXT_PUBLIC_USE_NEXT_JS_PROXY: yup.boolean(), }) + .concat(uiSchemas.homepageSchema) + .concat(uiSchemas.navigationSchema) + .concat(uiSchemas.footerSchema) + .concat(uiSchemas.miscSchema) + .concat(featuresSchemas.adsSchema) + .concat(featuresSchemas.userOpsSchema) + .concat(featuresSchemas.defiDropdownSchema) + .concat(servicesSchemas) .concat(accountSchema) - .concat(adsBannerSchema) .concat(marketplaceSchema) .concat(rollupSchema) .concat(celoSchema) @@ -1251,7 +940,6 @@ const schema = yup .concat(megaEthSchema) .concat(address3rdPartyWidgetsConfigSchema) .concat(addressMetadataSchema) - .concat(userOpsSchema) .concat(flashblocksSchema) .concat(zetaChainSchema); diff --git a/deploy/tools/envs-validator/schema_multichain.ts b/deploy/tools/envs-validator/schema_multichain.ts new file mode 100644 index 0000000000..193a4333a3 --- /dev/null +++ b/deploy/tools/envs-validator/schema_multichain.ts @@ -0,0 +1,70 @@ +declare module 'yup' { + interface StringSchema { + // Yup's URL validator is not perfect so we made our own + // https://github.com/jquense/yup/pull/1859 + url(): never; + } + } + +import * as yup from 'yup'; +import { urlTest, protocols } from './utils'; +import * as uiSchemas from './schemas/ui'; +import * as featuresSchemas from './schemas/features'; +import servicesSchemas from './schemas/services'; +import { replaceQuotes } from '../../../configs/app/utils'; + +const schema = yup + .object() + .noUnknown(true, (params) => { + return `Unknown ENV variables were provided: ${ params.unknown }`; + }) + .shape({ + // I. Build-time ENVs + // ----------------- + NEXT_PUBLIC_GIT_TAG: yup.string(), + NEXT_PUBLIC_GIT_COMMIT_SHA: yup.string(), + + // II. Run-time ENVs + // ----------------- + // 1. App configuration + NEXT_PUBLIC_APP_HOST: yup.string().required(), + NEXT_PUBLIC_APP_PROTOCOL: yup.string().oneOf(protocols), + NEXT_PUBLIC_APP_PORT: yup.number().positive().integer(), + NEXT_PUBLIC_APP_ENV: yup.string(), + NEXT_PUBLIC_APP_INSTANCE: yup.string(), + + // 2. Blockchain parameters + NEXT_PUBLIC_NETWORK_NAME: yup.string().required(), + NEXT_PUBLIC_NETWORK_SHORT_NAME: yup.string(), + NEXT_PUBLIC_IS_TESTNET: yup.boolean(), + + // 5. Features configuration + // NOTE!: Not all features are supported in multichain mode, and some of them not relevant or enabled per chain basis + // Below listed supported features and the features that are enabled by default, so we have to turn them off + NEXT_PUBLIC_OG_DESCRIPTION: yup.string(), + NEXT_PUBLIC_OG_IMAGE_URL: yup.string().test(urlTest), + + NEXT_PUBLIC_GAS_TRACKER_ENABLED: yup.boolean().equals([false]), + NEXT_PUBLIC_ADVANCED_FILTER_ENABLED: yup.boolean().equals([false]), + NEXT_PUBLIC_IS_ACCOUNT_SUPPORTED: yup.boolean().equals([false]), + NEXT_PUBLIC_API_DOCS_TABS: yup.array().transform(replaceQuotes).json().max(0), + + // 6. Multichain configuration + NEXT_PUBLIC_MULTICHAIN_ENABLED: yup.boolean(), + NEXT_PUBLIC_MULTICHAIN_CLUSTER: yup.string(), + NEXT_PUBLIC_MULTICHAIN_AGGREGATOR_API_HOST: yup.string().test(urlTest), + NEXT_PUBLIC_MULTICHAIN_STATS_API_HOST: yup.string().test(urlTest), + + // Misc + NEXT_PUBLIC_USE_NEXT_JS_PROXY: yup.boolean(), + }) + .concat(uiSchemas.homepageSchema) + .concat(uiSchemas.navigationSchema) + .concat(uiSchemas.footerSchema) + .concat(uiSchemas.miscSchema) + .concat(featuresSchemas.adsSchema) + .concat(featuresSchemas.userOpsSchema) + .concat(featuresSchemas.defiDropdownSchema) + .concat(servicesSchemas); + +export default schema; diff --git a/deploy/tools/envs-validator/schemas/features.ts b/deploy/tools/envs-validator/schemas/features.ts new file mode 100644 index 0000000000..fc338985bf --- /dev/null +++ b/deploy/tools/envs-validator/schemas/features.ts @@ -0,0 +1,75 @@ +import * as yup from 'yup'; +import { replaceQuotes } from '../../../../configs/app/utils'; +import { SUPPORTED_AD_TEXT_PROVIDERS, SUPPORTED_AD_BANNER_PROVIDERS, SUPPORTED_AD_BANNER_ADDITIONAL_PROVIDERS } from '../../../../types/client/adProviders'; +import type { AdTextProviders, AdBannerProviders, AdBannerAdditionalProviders } from '../../../../types/client/adProviders'; +import type { AdButlerConfig } from '../../../../types/client/adButlerConfig'; +import { urlTest } from '../utils'; +import { IconName } from '../../../../ui/shared/IconSvg'; +import { DeFiDropdownItem } from '../../../../types/client/deFiDropdown'; + +const adButlerConfigSchema = yup + .object() + .transform(replaceQuotes) + .json() + .when('NEXT_PUBLIC_AD_BANNER_PROVIDER', { + is: (value: AdBannerProviders) => value === 'adbutler', + then: (schema) => schema + .shape({ + id: yup.string().required(), + width: yup.number().positive().required(), + height: yup.number().positive().required(), + }) + .required(), + }) + .when('NEXT_PUBLIC_AD_BANNER_ADDITIONAL_PROVIDER', { + is: (value: AdBannerProviders) => value === 'adbutler', + then: (schema) => schema + .shape({ + id: yup.string().required(), + width: yup.number().positive().required(), + height: yup.number().positive().required(), + }) + .required(), + }); + +export const adsSchema = yup.object({ + NEXT_PUBLIC_AD_TEXT_PROVIDER: yup.string().oneOf(SUPPORTED_AD_TEXT_PROVIDERS), + NEXT_PUBLIC_AD_BANNER_PROVIDER: yup.string().oneOf(SUPPORTED_AD_BANNER_PROVIDERS), + NEXT_PUBLIC_AD_BANNER_ADDITIONAL_PROVIDER: yup.string().oneOf(SUPPORTED_AD_BANNER_ADDITIONAL_PROVIDERS), + NEXT_PUBLIC_AD_ADBUTLER_CONFIG_DESKTOP: adButlerConfigSchema, + NEXT_PUBLIC_AD_ADBUTLER_CONFIG_MOBILE: adButlerConfigSchema, + NEXT_PUBLIC_AD_BANNER_ENABLE_SPECIFY: yup.boolean(), +}); + +export const userOpsSchema = yup + .object() + .shape({ + NEXT_PUBLIC_HAS_USER_OPS: yup.boolean(), + NEXT_PUBLIC_USER_OPS_INDEXER_API_HOST: yup + .string() + .test(urlTest) + .when('NEXT_PUBLIC_HAS_USER_OPS', { + is: (value: boolean) => value, + then: (schema) => schema, + otherwise: (schema) => schema.max(-1, 'NEXT_PUBLIC_USER_OPS_INDEXER_API_HOST can only be used if NEXT_PUBLIC_HAS_USER_OPS is set to \'true\''), + }), + }); + +const deFiDropdownItemSchema: yup.ObjectSchema = yup + .object({ + text: yup.string().required(), + icon: yup.string(), + dappId: yup.string(), + url: yup.string().test(urlTest), + }) + .test('oneOfRequired', 'NEXT_PUBLIC_DEFI_DROPDOWN_ITEMS: Either dappId or url is required', function(value) { + return Boolean(value.dappId) || Boolean(value.url); + }) as yup.ObjectSchema; + +export const defiDropdownSchema = yup.object({ + NEXT_PUBLIC_DEFI_DROPDOWN_ITEMS: yup + .array() + .transform(replaceQuotes) + .json() + .of(deFiDropdownItemSchema), +}); \ No newline at end of file diff --git a/deploy/tools/envs-validator/schemas/services.ts b/deploy/tools/envs-validator/schemas/services.ts new file mode 100644 index 0000000000..29102a9c26 --- /dev/null +++ b/deploy/tools/envs-validator/schemas/services.ts @@ -0,0 +1,10 @@ +import * as yup from 'yup'; + +// External services envs +export default yup.object({ + NEXT_PUBLIC_WALLET_CONNECT_PROJECT_ID: yup.string(), + NEXT_PUBLIC_RE_CAPTCHA_APP_SITE_KEY: yup.string(), + NEXT_PUBLIC_GOOGLE_ANALYTICS_PROPERTY_ID: yup.string(), + NEXT_PUBLIC_GROWTH_BOOK_CLIENT_KEY: yup.string(), + NEXT_PUBLIC_ROLLBAR_CLIENT_TOKEN: yup.string(), +}); \ No newline at end of file diff --git a/deploy/tools/envs-validator/schemas/ui.ts b/deploy/tools/envs-validator/schemas/ui.ts new file mode 100644 index 0000000000..4f8dfa5324 --- /dev/null +++ b/deploy/tools/envs-validator/schemas/ui.ts @@ -0,0 +1,220 @@ +import * as yup from 'yup'; +import { CHAIN_INDICATOR_IDS, ChainIndicatorId, HeroBannerConfig, HeroBannerButtonState, HOME_STATS_WIDGET_IDS, HomeStatsWidgetId } from '../../../../types/homepage'; +import { replaceQuotes } from '../../../../configs/app/utils'; +import { getYupValidationErrorMessage, urlTest } from '../utils'; +import { NavigationLayout, NavigationPromoBannerConfig, NavItemExternal } from '../../../../types/client/navigation'; +import { FeaturedNetwork, NETWORK_GROUPS } from '../../../../types/networks'; +import { CustomLink, CustomLinksGroup } from '../../../../types/footerLinks'; +import { COLOR_THEME_IDS } from '../../../../types/settings'; +import { FontFamily } from '../../../../types/ui'; + +const heroBannerButtonStateSchema: yup.ObjectSchema = yup.object({ + background: yup.array().max(2).of(yup.string()), + text_color: yup.array().max(2).of(yup.string()), + }); + +const heroBannerSchema: yup.ObjectSchema = yup.object() + .transform(replaceQuotes) + .json() + .shape({ + background: yup.array().max(2).of(yup.string()), + text_color: yup.array().max(2).of(yup.string()), + border: yup.array().max(2).of(yup.string()), + button: yup.object({ + _default: heroBannerButtonStateSchema, + _hover: heroBannerButtonStateSchema, + _selected: heroBannerButtonStateSchema, + }), + search: yup.object({ + border_width: yup.array().max(2).of(yup.string()), + }), + }); + +export const homepageSchema = yup.object({ + NEXT_PUBLIC_HOMEPAGE_CHARTS: yup + .array() + .transform(replaceQuotes) + .json() + .of(yup.string().oneOf(CHAIN_INDICATOR_IDS)) + .test( + 'stats-api-required', + 'NEXT_PUBLIC_STATS_API_HOST is required when daily_operational_txs is enabled in NEXT_PUBLIC_HOMEPAGE_CHARTS', + function(value) { + // daily_operational_txs is presented only in stats microservice + if (value?.includes('daily_operational_txs')) { + return Boolean(this.parent.NEXT_PUBLIC_STATS_API_HOST); + } + return true; + } + ), + NEXT_PUBLIC_HOMEPAGE_STATS: yup + .array() + .transform(replaceQuotes) + .json() + .of(yup.string().oneOf(HOME_STATS_WIDGET_IDS)) + .test( + 'stats-api-required', + 'NEXT_PUBLIC_STATS_API_HOST is required when total_operational_txs is enabled in NEXT_PUBLIC_HOMEPAGE_STATS', + function(value) { + // total_operational_txs is presented only in stats microservice + if (value?.includes('total_operational_txs')) { + return Boolean(this.parent.NEXT_PUBLIC_STATS_API_HOST); + } + return true; + } + ), + NEXT_PUBLIC_HOMEPAGE_HERO_BANNER_CONFIG: yup + .mixed() + .test( + 'shape', + (ctx) => { + try { + heroBannerSchema.validateSync(ctx.originalValue); + throw new Error('Unknown validation error'); + } catch (error: unknown) { + const message = getYupValidationErrorMessage(error); + return 'Invalid schema were provided for NEXT_PUBLIC_HOMEPAGE_HERO_BANNER_CONFIG' + (message ? `: ${ message }` : ''); + } + }, + (data) => { + const isUndefined = data === undefined; + return isUndefined || heroBannerSchema.isValidSync(data); + }), +}); + +const featuredNetworkSchema: yup.ObjectSchema = yup + .object() + .shape({ + title: yup.string().required(), + url: yup.string().test(urlTest).required(), + group: yup.string().oneOf(NETWORK_GROUPS).required(), + icon: yup.string().test(urlTest), + isActive: yup.boolean(), + invertIconInDarkMode: yup.boolean(), + }); + +const navItemExternalSchema: yup.ObjectSchema = yup + .object({ + text: yup.string().required(), + url: yup.string().test(urlTest).required(), + }); + +export const navigationSchema = yup.object({ + NEXT_PUBLIC_FEATURED_NETWORKS: yup + .array() + .json() + .of(featuredNetworkSchema), + NEXT_PUBLIC_FEATURED_NETWORKS_ALL_LINK: yup + .string() + .when('NEXT_PUBLIC_FEATURED_NETWORKS', { + is: (value: Array | undefined) => value && value.length > 0, + then: (schema) => schema.test(urlTest), + otherwise: (schema) => schema.max(-1, 'NEXT_PUBLIC_FEATURED_NETWORKS_ALL_LINK can only be set when NEXT_PUBLIC_FEATURED_NETWORKS is configured'), + }), + NEXT_PUBLIC_FEATURED_NETWORKS_MODE: yup + .string() + .when('NEXT_PUBLIC_FEATURED_NETWORKS', { + is: (value: Array | undefined) => value && value.length > 0, + then: (schema) => schema.oneOf([ 'tabs', 'list' ]), + otherwise: (schema) => schema.max(-1, 'NEXT_PUBLIC_FEATURED_NETWORKS_MODE can only be set when NEXT_PUBLIC_FEATURED_NETWORKS is configured'), + }), + NEXT_PUBLIC_OTHER_LINKS: yup + .array() + .transform(replaceQuotes) + .json() + .of(navItemExternalSchema), + NEXT_PUBLIC_NAVIGATION_HIGHLIGHTED_ROUTES: yup + .array() + .transform(replaceQuotes) + .json() + .of(yup.string()), + NEXT_PUBLIC_NAVIGATION_LAYOUT: yup.string().oneOf([ 'horizontal', 'vertical' ]), + NEXT_PUBLIC_NAVIGATION_PROMO_BANNER_CONFIG: yup + .mixed() + .test('shape', 'Invalid schema were provided for NEXT_PUBLIC_NAVIGATION_PROMO_BANNER_CONFIG, it should be either object with img_url, text, bg_color, text_color, link_url or object with img_url and link_url', (data) => { + const isUndefined = data === undefined; + const jsonSchema = yup.object().transform(replaceQuotes).json(); + + const valueSchema1 = jsonSchema.shape({ + img_url: yup.string().required(), + text: yup.string().required(), + bg_color: yup.object().shape({ + light: yup.string().required(), + dark: yup.string().required(), + }).required(), + text_color: yup.object().shape({ + light: yup.string().required(), + dark: yup.string().required(), + }).required(), + link_url: yup.string().required(), + }); + + const valueSchema2 = jsonSchema.shape({ + img_url: yup.object().shape({ + small: yup.string().required(), + large: yup.string().required(), + }).required(), + link_url: yup.string().required(), + }); + + return isUndefined || valueSchema1.isValidSync(data) || valueSchema2.isValidSync(data); + }), + NEXT_PUBLIC_NETWORK_LOGO: yup.string().test(urlTest), + NEXT_PUBLIC_NETWORK_LOGO_DARK: yup.string().test(urlTest), + NEXT_PUBLIC_NETWORK_ICON: yup.string().test(urlTest), + NEXT_PUBLIC_NETWORK_ICON_DARK: yup.string().test(urlTest), +}); + +const footerLinkSchema: yup.ObjectSchema = yup + .object({ + text: yup.string().required(), + url: yup.string().test(urlTest).required(), + iconUrl: yup.array().of(yup.string().required().test(urlTest)), + }); + +const footerLinkGroupSchema: yup.ObjectSchema = yup + .object({ + title: yup.string().required(), + links: yup + .array() + .of(footerLinkSchema) + .required(), + }); + +export const footerSchema = yup.object({ + NEXT_PUBLIC_FOOTER_LINKS: yup + .array() + .json() + .of(footerLinkGroupSchema), +}); + +const fontFamilySchema: yup.ObjectSchema = yup + .object() + .transform(replaceQuotes) + .json() + .shape({ + name: yup.string().required(), + url: yup.string().test(urlTest).required(), + }); + +export const miscSchema = yup.object({ + NEXT_PUBLIC_HIDE_INDEXING_ALERT_BLOCKS: yup.boolean(), + NEXT_PUBLIC_HIDE_INDEXING_ALERT_INT_TXS: yup.boolean(), + NEXT_PUBLIC_HIDE_NATIVE_COIN_PRICE: yup.boolean(), + NEXT_PUBLIC_MAINTENANCE_ALERT_MESSAGE: yup.string(), + NEXT_PUBLIC_COLOR_THEME_DEFAULT: yup.string().oneOf(COLOR_THEME_IDS), + NEXT_PUBLIC_COLOR_THEME_OVERRIDES: yup.object().transform(replaceQuotes).json(), + NEXT_PUBLIC_FONT_FAMILY_HEADING: yup + .mixed() + .test('shape', 'Invalid schema were provided for NEXT_PUBLIC_FONT_FAMILY_HEADING', (data) => { + const isUndefined = data === undefined; + return isUndefined || fontFamilySchema.isValidSync(data); + }), + NEXT_PUBLIC_FONT_FAMILY_BODY: yup + .mixed() + .test('shape', 'Invalid schema were provided for NEXT_PUBLIC_FONT_FAMILY_BODY', (data) => { + const isUndefined = data === undefined; + return isUndefined || fontFamilySchema.isValidSync(data); + }), + NEXT_PUBLIC_MAX_CONTENT_WIDTH_ENABLED: yup.boolean(), +}); diff --git a/deploy/tools/envs-validator/test.sh b/deploy/tools/envs-validator/test.sh index 46d3ea3fca..668219d84a 100755 --- a/deploy/tools/envs-validator/test.sh +++ b/deploy/tools/envs-validator/test.sh @@ -17,14 +17,16 @@ yarn build validate_file() { local test_file="$1" + local with_common="$2" echo echo "đŸ§ŋ Validating file '$test_file'..." - dotenv \ - -e $test_file \ - -e $common_file \ - yarn run validate -- --silent + if [ "$with_common" = "true" ]; then + dotenv -e "$test_file" -e "$common_file" yarn run validate -- --silent + else + dotenv -e "$test_file" yarn run validate -- --silent + fi if [ $? -eq 0 ]; then echo "👍 All good!" @@ -39,7 +41,12 @@ validate_file() { test_files=($(find "$test_folder" -maxdepth 1 -type f | grep -vE '\/\.env\.common$')) for file in "${test_files[@]}"; do - validate_file "$file" + if [[ "$file" == *".env.multichain" ]]; then + validate_file "$file" false + else + validate_file "$file" true + fi + if [ $? -eq 1 ]; then exit 1 fi diff --git a/deploy/tools/envs-validator/test/.env.multichain b/deploy/tools/envs-validator/test/.env.multichain new file mode 100644 index 0000000000..e7882269ed --- /dev/null +++ b/deploy/tools/envs-validator/test/.env.multichain @@ -0,0 +1,28 @@ +NEXT_PUBLIC_APP_HOST=localhost +NEXT_PUBLIC_NETWORK_NAME=Testnet +NEXT_PUBLIC_MULTICHAIN_AGGREGATOR_API_HOST=https://example.com +NEXT_PUBLIC_MULTICHAIN_STATS_API_HOST=http://example.com +NEXT_PUBLIC_MULTICHAIN_ENABLED=true +NEXT_PUBLIC_MULTICHAIN_CLUSTER=test + +NEXT_PUBLIC_HOMEPAGE_CHARTS=['daily_txs','coin_price','market_cap'] +NEXT_PUBLIC_HOMEPAGE_STATS=['total_txs','wallet_addresses'] +NEXT_PUBLIC_API_DOCS_TABS=[] +NEXT_PUBLIC_FEATURED_NETWORKS=http://example.com +NEXT_PUBLIC_FOOTER_LINKS=http://example.com +NEXT_PUBLIC_HOMEPAGE_HERO_BANNER_CONFIG={'background':['linear-gradient(90deg, rgb(232, 52, 53) 0%, rgb(139, 28, 232) 100%)'],'text_color':['rgb(255, 255, 255)']} +NEXT_PUBLIC_NETWORK_ICON=http://example.com +NEXT_PUBLIC_NETWORK_ICON_DARK=http://example.com +NEXT_PUBLIC_NETWORK_LOGO=http://example.com +NEXT_PUBLIC_NETWORK_LOGO_DARK=http://example.com +NEXT_PUBLIC_NETWORK_NAME=OP Superchain +NEXT_PUBLIC_NETWORK_SHORT_NAME=OP Superchain +NEXT_PUBLIC_OG_IMAGE_URL=http://example.com +NEXT_PUBLIC_GAS_TRACKER_ENABLED=false +NEXT_PUBLIC_HIDE_INDEXING_ALERT_BLOCKS=true +NEXT_PUBLIC_HIDE_INDEXING_ALERT_INT_TXS=true +NEXT_PUBLIC_IS_ACCOUNT_SUPPORTED=false +NEXT_PUBLIC_IS_TESTNET=true +NEXT_PUBLIC_USE_NEXT_JS_PROXY=true +NEXT_PUBLIC_HAS_USER_OPS=true +NEXT_PUBLIC_ADVANCED_FILTER_ENABLED=false \ No newline at end of file diff --git a/deploy/tools/envs-validator/utils.ts b/deploy/tools/envs-validator/utils.ts new file mode 100644 index 0000000000..7e5e73a7b3 --- /dev/null +++ b/deploy/tools/envs-validator/utils.ts @@ -0,0 +1,31 @@ +import * as yup from 'yup'; + +export const protocols = [ 'http', 'https' ]; + +export const urlTest: yup.TestConfig = { + name: 'url', + test: (value: unknown) => { + if (!value) { + return true; + } + + try { + if (typeof value === 'string') { + new URL(value); + return true; + } + } catch (error) {} + + return false; + }, + message: '${path} is not a valid URL', + exclusive: true, +}; + +export const getYupValidationErrorMessage = (error: unknown) => + typeof error === 'object' && + error !== null && + 'errors' in error && + Array.isArray(error.errors) ? + error.errors.join(', ') : + ''; \ No newline at end of file diff --git a/deploy/tools/essential-dapps-chains-config-generator/index.ts b/deploy/tools/essential-dapps-chains-config-generator/index.ts index dc775cb59d..7dcc74fe43 100644 --- a/deploy/tools/essential-dapps-chains-config-generator/index.ts +++ b/deploy/tools/essential-dapps-chains-config-generator/index.ts @@ -7,10 +7,11 @@ import * as viemChains from 'viem/chains'; import { pick } from 'es-toolkit'; import { EssentialDappsConfig } from 'types/client/marketplace'; -import { ChainConfig } from 'types/multichain'; import { getEnvValue, parseEnvJson } from 'configs/app/utils'; -import {uniq } from 'es-toolkit'; +import { uniq } from 'es-toolkit'; import currentChainConfig from 'configs/app'; +import appConfig from 'configs/app'; +import { EssentialDappsChainConfig } from 'types/client/marketplace'; const currentFilePath = fileURLToPath(import.meta.url); const currentDir = dirname(currentFilePath); @@ -36,21 +37,10 @@ async function getChainscoutInfo(externalChainIds: Array, currentChainId } } -function getSlug(chainName: string) { - return chainName.toLowerCase().replace(/ /g, '-').replace(/[^a-z0-9-]/g, ''); -} - -function trimChainConfig(config: ChainConfig['config'], logoUrl: string | undefined) { +function trimChainConfig(config: typeof appConfig, logoUrl: string | undefined) { return { ...pick(config, [ 'app', 'chain' ]), apis: pick(config.apis || {}, [ 'general' ]), - UI: { - navigation: { - icon: { - 'default': logoUrl, - } - }, - } }; } @@ -110,17 +100,22 @@ async function run() { const explorerUrls = chainscoutInfo.externals.map(({ explorerUrl }) => explorerUrl).filter(Boolean); console.log(`â„šī¸ For ${ explorerUrls.length } chains explorer url was found in static config. Fetching parameters for each chain...`); - const chainConfigs = await Promise.all(explorerUrls.map(computeChainConfig)) as Array; + const chainConfigs = await Promise.all(explorerUrls.map(computeChainConfig)) as Array; const result = { - chains: [ currentChainConfig, ...chainConfigs ].map((config, index) => { + chains: [ currentChainConfig, ...chainConfigs ].map((config) => { + const chainId = config.chain.id; + const chainInfo = [...chainscoutInfo.externals, chainscoutInfo.current].find((chain) => chain?.id === chainId); const logoUrl = [...chainscoutInfo.externals, chainscoutInfo.current].find((chain) => chain?.id === config.chain.id)?.logoUrl; - const chainName = (config as { chain: { name: string } })?.chain?.name ?? `Chain ${ index + 1 }`; + const chainName = (config as { chain: { name: string } })?.chain?.name ?? `Chain ${ chainId }`; return { - slug: getSlug(chainName), - config: trimChainConfig(config, logoUrl), + id: chainId || '', + name: chainName, + logo: chainInfo?.logoUrl, + explorer_url: chainInfo?.explorerUrl || '', + app_config: trimChainConfig(config, logoUrl), contracts: Object.values(viemChains).find(({ id }) => id === Number(config.chain.id))?.contracts - }; + } satisfies EssentialDappsChainConfig; }), }; diff --git a/deploy/tools/llms-txt-generator/index.ts b/deploy/tools/llms-txt-generator/index.ts index b76dde75f3..2c7c200b64 100644 --- a/deploy/tools/llms-txt-generator/index.ts +++ b/deploy/tools/llms-txt-generator/index.ts @@ -15,7 +15,7 @@ function run() { const chainName = config.chain.name ?? ''; const chainId = config.chain.id ?? ''; - const generalApiUrl = config.apis.general.endpoint + config.apis.general.basePath; + const generalApiUrl = config.apis.general? config.apis.general.endpoint + config.apis.general.basePath : ''; const statsApiUrl = config.apis.stats ? config.apis.stats.endpoint + config.apis.stats.basePath : undefined; const rollupFeature = config.features.rollup; diff --git a/deploy/tools/multichain-config-generator/index.ts b/deploy/tools/multichain-config-generator/index.ts index 9e7eee0e92..1fbe0d4600 100644 --- a/deploy/tools/multichain-config-generator/index.ts +++ b/deploy/tools/multichain-config-generator/index.ts @@ -3,6 +3,8 @@ import { dirname, resolve as resolvePath } from 'node:path'; import { fileURLToPath } from 'node:url'; import { Worker } from 'node:worker_threads'; +import { ClusterChainConfig } from 'types/multichain'; + const currentFilePath = fileURLToPath(import.meta.url); const currentDir = dirname(currentFilePath); @@ -21,6 +23,19 @@ function getSlug(chainName: string) { return chainName.toLowerCase().replace(/ /g, '-').replace(/[^a-z0-9-]/g, ''); } +async function getChainscoutInfo(chainIds: Array) { + const response = await fetch('https://chains.blockscout.com/api/chains'); + if (!response.ok) { + throw new Error(`Failed to fetch chains info from Chainscout API`); + } + const chainsInfo = await response.json() as Record; + + return chainIds.map((chainId) => ({ + id: chainId, + logoUrl: chainsInfo[chainId]?.logo, + })) +} + async function computeChainConfig(url: string): Promise { return new Promise((resolve, reject) => { const workerPath = resolvePath(currentDir, 'worker.js'); @@ -48,8 +63,9 @@ async function computeChainConfig(url: string): Promise { } async function getExplorerUrls() { + // return EXPLORER_URLS; try { - const basePath = (process.env.NEXT_PUBLIC_MULTICHAIN_AGGREGATOR_BASE_PATH ?? '') + '/chains'; + const basePath = `/api/v1/clusters/${ process.env.NEXT_PUBLIC_MULTICHAIN_CLUSTER }/chains`; const url = new URL(basePath, process.env.NEXT_PUBLIC_MULTICHAIN_AGGREGATOR_API_HOST); const response = await fetch(url.toString()); @@ -79,14 +95,20 @@ async function run() { } const configs = await Promise.all(explorerUrls.map(computeChainConfig)); + const chainscoutInfo = await getChainscoutInfo(configs.map((config) => config.chain.id)); const config = { chains: configs.map((config, index) => { - const chainName = (config as { chain: { name: string } })?.chain?.name ?? `Chain ${ index + 1 }`; + const chainId = config.chain.id; + const chainName = (config as { chain: { name: string } })?.chain?.name ?? `Chain ${ chainId }`; return { + id: chainId, + name: chainName, + logo: chainscoutInfo.find((chain) => chain.id === chainId)?.logoUrl, + explorer_url: explorerUrls[index], slug: getSlug(chainName), - config, - }; + app_config: config, + } satisfies ClusterChainConfig; }), }; diff --git a/deploy/tools/sitemap-generator/next-sitemap.config.js b/deploy/tools/sitemap-generator/next-sitemap.config.js index 2ea15c6323..90fe3579cf 100644 --- a/deploy/tools/sitemap-generator/next-sitemap.config.js +++ b/deploy/tools/sitemap-generator/next-sitemap.config.js @@ -137,7 +137,7 @@ module.exports = { } break; case '/api-docs': - if (process.env.NEXT_PUBLIC_API_SPEC_URL === 'none') { + if (process.env.NEXT_PUBLIC_API_DOCS_TABS === '[]') { return null; } break; @@ -161,6 +161,34 @@ module.exports = { return null; } break; + case '/epochs': + if (process.env.NEXT_PUBLIC_CELO_ENABLED !== 'true') { + return null; + } + break; + case '/operations': + if (!process.env.NEXT_PUBLIC_TAC_OPERATION_LIFECYCLE_API_HOST) { + return null; + } + break; + case '/public-tags/submit': + if (!process.env.NEXT_PUBLIC_ADMIN_SERVICE_API_HOST || !process.env.NEXT_PUBLIC_METADATA_SERVICE_API_HOST) { + return null; + } + break; + case '/txn-withdrawals': + if (!process.env.NEXT_PUBLIC_ROLLUP_TYPE || process.env.NEXT_PUBLIC_ROLLUP_TYPE !== 'arbitrum') { + return null; + } + break; + // disabled routes for multichain + case '/block/countdown': + case '/contract-verification': + case '/visualize/sol2uml': + if (process.env.NEXT_PUBLIC_MULTICHAIN_ENABLED === 'true') { + return null; + } + break; } return { @@ -172,7 +200,7 @@ module.exports = { }; }, additionalPaths: async(config) => { - if(process.env.NEXT_PUBLIC_OP_SUPERCHAIN_ENABLED === 'true'){ + if(process.env.NEXT_PUBLIC_MULTICHAIN_ENABLED === 'true'){ return; } diff --git a/deploy/values/review-l2/values.yaml.gotmpl b/deploy/values/review-l2/values.yaml.gotmpl index 69331a90fd..1a3dc3bf90 100644 --- a/deploy/values/review-l2/values.yaml.gotmpl +++ b/deploy/values/review-l2/values.yaml.gotmpl @@ -54,3 +54,4 @@ frontend: envFromSecret: NEXT_PUBLIC_WALLET_CONNECT_PROJECT_ID: ref+vault://deployment-values/blockscout/dev/review-l2?token_env=VAULT_TOKEN&address=https://vault.k8s.blockscout.com#/NEXT_PUBLIC_WALLET_CONNECT_PROJECT_ID NEXT_PUBLIC_RE_CAPTCHA_APP_SITE_KEY: ref+vault://deployment-values/blockscout/eth-sepolia/testnet?token_env=VAULT_TOKEN&address=https://vault.k8s.blockscout.com#/RE_CAPTCHA_CLIENT_KEY + NEXT_PUBLIC_MIXPANEL_PROJECT_TOKEN: ref+vault://deployment-values/blockscout/common?token_env=VAULT_TOKEN&address=https://vault.k8s.blockscout.com#/NEXT_PUBLIC_MIXPANEL_PROJECT_TOKEN diff --git a/docs/ENVS.md b/docs/ENVS.md index 48ced32d09..10c2c6023a 100644 --- a/docs/ENVS.md +++ b/docs/ENVS.md @@ -79,7 +79,8 @@ 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) + - [ZetaChain](#zetachain-cross-chain-transactions) + - [Multichain explorer](#multichain-explorer) - [3rd party services configuration](#external-services-configuration)   @@ -105,7 +106,7 @@ All json-like values should be single-quoted. If it contains a hash (`#`) or a d | --- | --- | --- | --- | --- | --- | --- | | NEXT_PUBLIC_NETWORK_NAME | `string` | Displayed name of the network | Required | - | `Gnosis Chain` | v1.0.x+ | | NEXT_PUBLIC_NETWORK_SHORT_NAME | `string` | Used for SEO attributes (e.g, page description) | - | - | `OoG` | v1.0.x+ | -| NEXT_PUBLIC_NETWORK_ID | `number` | Chain id, see [https://chainlist.org](https://chainlist.org) for the reference | Required | - | `99` | v1.0.x+ | +| NEXT_PUBLIC_NETWORK_ID | `number` | Chain id, see [https://chainlist.org](https://chainlist.org) for the reference | Required (except for multichain) | - | `99` | v1.0.x+ | | NEXT_PUBLIC_NETWORK_RPC_URL | `string \| Array` | Chain public RPC server url, see [https://chainlist.org](https://chainlist.org) for the reference. Can contain a single string value, or an array of urls. | - | - | `https://core.poa.network` | v1.0.x+ | | NEXT_PUBLIC_NETWORK_CURRENCY_NAME | `string` | Network currency name | - | - | `Ether` | v1.0.x+ | | NEXT_PUBLIC_NETWORK_CURRENCY_WEI_NAME | `string` | Name of the smallest unit of the native currency (e.g., 'wei' for Ethereum, where 1 ETH = 10^18 wei). Used for displaying gas prices and transaction fees in the smallest denomination. | - | `wei` | `duck` | v1.23.0+ | @@ -125,7 +126,7 @@ All json-like values should be single-quoted. If it contains a hash (`#`) or a d | Variable | Type| Description | Compulsoriness | Default value | Example value | Version | | --- | --- | --- | --- | --- | --- | --- | | NEXT_PUBLIC_API_PROTOCOL | `http \| https` | Main API protocol | - | `https` | `http` | v1.0.x+ | -| NEXT_PUBLIC_API_HOST | `string` | Main API host | Required | - | `blockscout.com` | v1.0.x+ | +| NEXT_PUBLIC_API_HOST | `string` | Main API host | Required (except for multichain) | - | `blockscout.com` | v1.0.x+ | | NEXT_PUBLIC_API_PORT | `number` | Port where API is running on the host | - | - | `3001` | v1.0.x+ | | NEXT_PUBLIC_API_BASE_PATH | `string` | Base path for Main API endpoint url | - | - | `/poa/core` | v1.0.x+ | | NEXT_PUBLIC_API_WEBSOCKET_PROTOCOL | `ws \| wss` | Main API websocket protocol | - | `wss` | `ws` | v1.0.x+ | @@ -1023,6 +1024,19 @@ This feature enables cross-chain transactions pages and views on ZetaChain insta | tx_url_template | `string` | Transaction url template on external explorer. `{hash}` will be replaced with the transaction hash | - | - | `'https://external.explorer.com/tx/{hash}'` | +  + +### Multichain explorer + +This feature enables the application to act as an explorer of multiple blockchains united in one cluster. Please note that this feature is currently in demo mode, and a major part of the cross-chain views is not implemented and serves as a placeholder. These will be developed in the future. + +| Variable | Type| Description | Compulsoriness | Default value | Example value | Version | +| --- | --- | --- | --- | --- | --- | --- | +| NEXT_PUBLIC_MULTICHAIN_ENABLED | `boolean` | The flag that enables the feature | Required | - | `true` | upcoming | +| NEXT_PUBLIC_MULTICHAIN_CLUSTER | `string` | Chain's cluster name; used to construct the full URL for requests to the aggregator API service | Required | - | `interop` | upcoming | +| NEXT_PUBLIC_MULTICHAIN_AGGREGATOR_API_HOST | `string` | Multichain aggregator API service host | Required | - | `https://multichain-aggregator.k8s-dev.blockscout.com` | upcoming | +| NEXT_PUBLIC_MULTICHAIN_STATS_API_HOST | `string` | Multichain statistics API service host | Required | - | `http://multichain-search-stats.k8s-dev.blockscout.com` | upcoming | +   ### Badge claim link @@ -1031,6 +1045,7 @@ This feature enables cross-chain transactions pages and views on ZetaChain insta | --- | --- | --- | --- | --- | --- | --- | | NEXT_PUBLIC_GAME_BADGE_CLAIM_LINK | `string` | Provide to enable the easter egg badge feature | - | - | `https://example.com` | v1.37.0+ | +  ### Puzzle game badge claim link diff --git a/global.d.ts b/global.d.ts index 56efb06936..6ca74db006 100644 --- a/global.d.ts +++ b/global.d.ts @@ -1,3 +1,4 @@ +import type { EssentialDappsChainConfig } from 'types/client/marketplace'; import type { MultichainConfig } from 'types/multichain'; import type { WalletProvider } from 'types/web3'; @@ -21,7 +22,7 @@ declare global { abkw: string; __envs: Record; __multichainConfig: MultichainConfig; - __essentialDappsChains: MultichainConfig; + __essentialDappsChains: { chains: Array }; } namespace NodeJS { diff --git a/lib/address/useAddressMetadataInfoQuery.ts b/lib/address/useAddressMetadataInfoQuery.ts index 3ef23aaab4..8e77b82f7a 100644 --- a/lib/address/useAddressMetadataInfoQuery.ts +++ b/lib/address/useAddressMetadataInfoQuery.ts @@ -11,8 +11,8 @@ export default function useAddressMetadataInfoQuery(addresses: Array, is const resource = 'metadata:info'; const multichainContext = useMultichainContext(); - const feature = multichainContext?.chain?.config.features.addressMetadata || config.features.addressMetadata; - const chainId = multichainContext?.chain?.config.chain.id || config.chain.id; + const feature = multichainContext?.chain?.app_config.features.addressMetadata || config.features.addressMetadata; + const chainId = multichainContext?.chain?.app_config.chain.id || config.chain.id; return useApiQuery(resource, { queryParams: { diff --git a/lib/api/buildUrl.ts b/lib/api/buildUrl.ts index 9199519a17..3f0ee2066d 100644 --- a/lib/api/buildUrl.ts +++ b/lib/api/buildUrl.ts @@ -1,6 +1,6 @@ import { compile } from 'path-to-regexp'; -import type { ChainConfig } from 'types/multichain'; +import type { ExternalChainExtended } from 'types/externalChains'; import config from 'configs/app'; @@ -13,7 +13,7 @@ export default function buildUrl( pathParams?: ResourcePathParams, queryParams?: Record | number | boolean | null | undefined>, noProxy?: boolean, - chain?: ChainConfig, + chain?: ExternalChainExtended, ): string { const { api, resource } = getResourceParams(resourceFullName, chain); const baseUrl = !noProxy && isNeedProxy() ? config.app.baseUrl : api.endpoint; diff --git a/lib/api/getResourceParams.ts b/lib/api/getResourceParams.ts index 4bfbcfb1cb..d2d179bf1a 100644 --- a/lib/api/getResourceParams.ts +++ b/lib/api/getResourceParams.ts @@ -1,17 +1,17 @@ import type { ApiName, ApiResource } from './types'; -import type { ChainConfig } from 'types/multichain'; +import type { ExternalChainExtended } from 'types/externalChains'; import config from 'configs/app'; import type { ResourceName } from './resources'; import { RESOURCES } from './resources'; -export default function getResourceParams(resourceFullName: ResourceName, chain?: ChainConfig) { +export default function getResourceParams(resourceFullName: ResourceName, chain?: ExternalChainExtended) { const [ apiName, resourceName ] = resourceFullName.split(':') as [ ApiName, string ]; const apiConfig = (() => { - if (chain) { - return chain.config.apis[apiName]; + if (chain?.app_config?.apis) { + return chain.app_config.apis[apiName as keyof typeof chain.app_config.apis]; } return config.apis[apiName]; diff --git a/lib/api/getSocketUrl.ts b/lib/api/getSocketUrl.ts index 676bfa0c9b..5475e4c6a7 100644 --- a/lib/api/getSocketUrl.ts +++ b/lib/api/getSocketUrl.ts @@ -1,5 +1,5 @@ import appConfig from 'configs/app'; export default function getSocketUrl(config: typeof appConfig = appConfig) { - return `${ config.apis.general.socketEndpoint }${ config.apis.general.basePath ?? '' }/socket/v2`; + return config.apis.general ? `${ config.apis.general.socketEndpoint }${ config.apis.general.basePath ?? '' }/socket/v2` : undefined; } diff --git a/lib/api/resources.ts b/lib/api/resources.ts index dddd07d58b..5ee0dce5bf 100644 --- a/lib/api/resources.ts +++ b/lib/api/resources.ts @@ -12,8 +12,14 @@ import { GENERAL_API_RESOURCES } from './services/general'; import type { GeneralApiResourceName, GeneralApiResourcePayload, GeneralApiPaginationFilters, GeneralApiPaginationSorting } from './services/general'; import type { MetadataApiResourceName, MetadataApiResourcePayload } from './services/metadata'; import { METADATA_API_RESOURCES } from './services/metadata'; -import type { MultichainApiPaginationFilters, MultichainApiResourceName, MultichainApiResourcePayload } from './services/multichain'; -import { MULTICHAIN_API_RESOURCES } from './services/multichain'; +import type { + MultichainAggregatorApiPaginationFilters, + MultichainAggregatorApiResourceName, + MultichainAggregatorApiResourcePayload, +} from './services/multichainAggregator'; +import { MULTICHAIN_AGGREGATOR_API_RESOURCES } from './services/multichainAggregator'; +import type { MultichainStatsApiResourcePayload, MultichainStatsApiResourceName } from './services/multichainStats'; +import { MULTICHAIN_STATS_API_RESOURCES } from './services/multichainStats'; import type { RewardsApiResourceName, RewardsApiResourcePayload } from './services/rewards'; import { REWARDS_API_RESOURCES } from './services/rewards'; import type { StatsApiResourceName, StatsApiResourcePayload } from './services/stats'; @@ -38,7 +44,8 @@ export const RESOURCES = { contractInfo: CONTRACT_INFO_API_RESOURCES, general: GENERAL_API_RESOURCES, metadata: METADATA_API_RESOURCES, - multichain: MULTICHAIN_API_RESOURCES, + multichainAggregator: MULTICHAIN_AGGREGATOR_API_RESOURCES, + multichainStats: MULTICHAIN_STATS_API_RESOURCES, rewards: REWARDS_API_RESOURCES, stats: STATS_API_RESOURCES, tac: TAC_OPERATION_LIFECYCLE_API_RESOURCES, @@ -73,7 +80,8 @@ R extends ClustersApiResourceName ? ClustersApiResourcePayload : R extends ContractInfoApiResourceName ? ContractInfoApiResourcePayload : R extends GeneralApiResourceName ? GeneralApiResourcePayload : R extends MetadataApiResourceName ? MetadataApiResourcePayload : -R extends MultichainApiResourceName ? MultichainApiResourcePayload : +R extends MultichainAggregatorApiResourceName ? MultichainAggregatorApiResourcePayload : +R extends MultichainStatsApiResourceName ? MultichainStatsApiResourcePayload : R extends RewardsApiResourceName ? RewardsApiResourcePayload : R extends StatsApiResourceName ? StatsApiResourcePayload : R extends TacOperationLifecycleApiResourceName ? TacOperationLifecycleApiResourcePayload : @@ -110,7 +118,7 @@ R extends BensApiResourceName ? BensApiPaginationFilters : R extends ClustersApiResourceName ? ClustersApiPaginationFilters : R extends GeneralApiResourceName ? GeneralApiPaginationFilters : R extends ContractInfoApiResourceName ? ContractInfoApiPaginationFilters : -R extends MultichainApiResourceName ? MultichainApiPaginationFilters : +R extends MultichainAggregatorApiResourceName ? MultichainAggregatorApiPaginationFilters : R extends TacOperationLifecycleApiResourceName ? TacOperationLifecycleApiPaginationFilters : R extends ZetaChainApiResourceName ? ZetaChainApiPaginationFilters : never; diff --git a/lib/api/services/multichain.ts b/lib/api/services/multichain.ts deleted file mode 100644 index 30749a7e9a..0000000000 --- a/lib/api/services/multichain.ts +++ /dev/null @@ -1,30 +0,0 @@ -import type { ApiResource } from '../types'; -import type * as multichain from '@blockscout/multichain-aggregator-types'; - -export const MULTICHAIN_API_RESOURCES = { - interop_messages: { - path: '/messages', - filterFields: [ 'address' as const ], - paginated: true, - }, - interop_messages_count: { - path: '/messages/count', - filterFields: [ 'address' as const ], - }, -} satisfies Record; - -export type MultichainApiResourceName = `multichain:${ keyof typeof MULTICHAIN_API_RESOURCES }`; - -/* eslint-disable @stylistic/indent */ -export type MultichainApiResourcePayload = -R extends 'multichain:interop_messages' ? multichain.ListInteropMessagesResponse : -R extends 'multichain:interop_messages_count' ? multichain.CountInteropMessagesResponse : -never; -/* eslint-enable @stylistic/indent */ - -/* eslint-disable @stylistic/indent */ -export type MultichainApiPaginationFilters = -R extends 'multichain:interop_messages' ? Partial : -R extends 'multichain:interop_messages_count' ? Partial : -never; -/* eslint-enable @stylistic/indent */ diff --git a/lib/api/services/multichainAggregator.ts b/lib/api/services/multichainAggregator.ts new file mode 100644 index 0000000000..2006f9dd2d --- /dev/null +++ b/lib/api/services/multichainAggregator.ts @@ -0,0 +1,85 @@ +import type { ApiResource } from '../types'; +import type * as multichain from '@blockscout/multichain-aggregator-types'; +import type { AddressTokensResponse, TokensResponse } from 'types/client/multichain-aggregator'; + +export const MULTICHAIN_AGGREGATOR_API_RESOURCES = { + address: { + path: '/addresses/:hash', + pathParams: [ 'hash' as const ], + }, + address_tokens: { + path: '/addresses/:hash/tokens', + pathParams: [ 'hash' as const ], + paginated: true, + filterFields: [ 'chain_id' as const, 'type' as const ], + }, + tokens: { + path: '/tokens', + filterFields: [ 'chain_id' as const, 'type' as const, 'query' as const ], + paginated: true, + }, + quick_search: { + path: '/search\\:quick', + filterFields: [ 'q' as const ], + }, + search_addresses: { + path: '/search/addresses', + filterFields: [ 'q' as const, 'chain_id' as const ], + paginated: true, + }, + search_blocks: { + path: '/search/blocks', + filterFields: [ 'q' as const, 'chain_id' as const ], + paginated: true, + }, + search_block_numbers: { + path: '/search/block-numbers', + filterFields: [ 'q' as const, 'chain_id' as const ], + paginated: true, + }, + search_transactions: { + path: '/search/transactions', + filterFields: [ 'q' as const, 'chain_id' as const ], + paginated: true, + }, + search_tokens: { + path: '/search/tokens', + filterFields: [ 'q' as const, 'chain_id' as const ], + paginated: true, + }, + search_nfts: { + path: '/search/nfts', + filterFields: [ 'q' as const, 'chain_id' as const ], + paginated: true, + }, + search_domains: { + path: '/search/domains', + filterFields: [ 'q' as const, 'chain_id' as const ], + paginated: true, + }, +} satisfies Record; + +export type MultichainAggregatorApiResourceName = `multichainAggregator:${ keyof typeof MULTICHAIN_AGGREGATOR_API_RESOURCES }`; + +/* eslint-disable @stylistic/indent */ +export type MultichainAggregatorApiResourcePayload = +R extends 'multichainAggregator:address' ? multichain.GetAddressResponse : +R extends 'multichainAggregator:address_tokens' ? AddressTokensResponse : +R extends 'multichainAggregator:tokens' ? TokensResponse : +R extends 'multichainAggregator:quick_search' ? multichain.ClusterQuickSearchResponse : +R extends 'multichainAggregator:search_addresses' ? multichain.SearchAddressesResponse : +R extends 'multichainAggregator:search_blocks' ? multichain.SearchBlocksResponse : +R extends 'multichainAggregator:search_block_numbers' ? multichain.SearchBlockNumbersResponse : +R extends 'multichainAggregator:search_transactions' ? multichain.SearchTransactionsResponse : +R extends 'multichainAggregator:search_tokens' ? multichain.SearchTokensResponse : +R extends 'multichainAggregator:search_nfts' ? multichain.SearchNftsResponse : +R extends 'multichainAggregator:search_domains' ? multichain.SearchDomainsResponse : +never; +/* eslint-enable @stylistic/indent */ + +/* eslint-disable @stylistic/indent */ +export type MultichainAggregatorApiPaginationFilters = +R extends 'multichainAggregator:address_tokens' ? Partial : +R extends 'multichainAggregator:tokens' ? Partial : +never; +/* eslint-enable @stylistic/indent */ diff --git a/lib/api/services/multichainStats.ts b/lib/api/services/multichainStats.ts new file mode 100644 index 0000000000..b0e7657ba8 --- /dev/null +++ b/lib/api/services/multichainStats.ts @@ -0,0 +1,16 @@ +import type { ApiResource } from '../types'; +import type * as stats from '@blockscout/stats-types'; + +export const MULTICHAIN_STATS_API_RESOURCES = { + pages_main: { + path: '/api/v1/pages/multichain/main', + }, +} satisfies Record; + +export type MultichainStatsApiResourceName = `multichainStats:${ keyof typeof MULTICHAIN_STATS_API_RESOURCES }`; + +/* eslint-disable @stylistic/indent */ +export type MultichainStatsApiResourcePayload = +R extends 'multichainStats:pages_main' ? stats.MainPageMultichainStats : +never; +/* eslint-enable @stylistic/indent */ diff --git a/lib/api/types.ts b/lib/api/types.ts index 91199d8add..458068c362 100644 --- a/lib/api/types.ts +++ b/lib/api/types.ts @@ -1,6 +1,6 @@ export type ApiName = 'general' | 'admin' | 'bens' | 'contractInfo' | 'clusters' | 'external' | -'metadata' | 'multichain' | 'rewards' | 'stats' | 'tac' | +'metadata' | 'multichainAggregator' | 'multichainStats' | 'rewards' | 'stats' | 'tac' | 'userOps' | 'visualize' | 'zetachain'; export interface ApiResource { diff --git a/lib/api/useApiFetch.tsx b/lib/api/useApiFetch.tsx index acf6515c56..afd614c714 100644 --- a/lib/api/useApiFetch.tsx +++ b/lib/api/useApiFetch.tsx @@ -3,7 +3,7 @@ import { omit, pickBy } from 'es-toolkit'; import React from 'react'; import type { CsrfData } from 'types/client/account'; -import type { ChainConfig } from 'types/multichain'; +import type { ExternalChainExtended } from 'types/externalChains'; import isBodyAllowed from 'lib/api/isBodyAllowed'; import isNeedProxy from 'lib/api/isNeedProxy'; @@ -21,7 +21,7 @@ export interface Params { queryParams?: Record | number | boolean | undefined | null>; fetchParams?: Pick; logError?: boolean; - chain?: ChainConfig; + chain?: ExternalChainExtended; } export default function useApiFetch() { diff --git a/lib/api/useApiInfiniteQuery.tsx b/lib/api/useApiInfiniteQuery.tsx index 9f44b27145..e52bd65a78 100644 --- a/lib/api/useApiInfiniteQuery.tsx +++ b/lib/api/useApiInfiniteQuery.tsx @@ -15,22 +15,24 @@ export interface Params { // eslint-disable-next-line max-len queryOptions?: Omit, TError, InfiniteData>, TQueryData, QueryKey, TPageParam>, 'queryKey' | 'queryFn' | 'getNextPageParam' | 'initialPageParam'>; pathParams?: ApiFetchParams['pathParams']; + queryParams?: Record | number | boolean | undefined | null>; } -type ReturnType = UseInfiniteQueryResult>, ResourceError>; +export type ReturnType = UseInfiniteQueryResult>, ResourceError>; export default function useApiInfiniteQuery({ resourceName, queryOptions, pathParams, + queryParams, }: Params): ReturnType { const apiFetch = useApiFetch(); return useInfiniteQuery, TError, InfiniteData>, QueryKey, TPageParam>({ - queryKey: getResourceKey(resourceName, { pathParams }), + queryKey: getResourceKey(resourceName, { pathParams, queryParams }), queryFn: (context) => { - const queryParams = 'pageParam' in context ? (context.pageParam || undefined) : undefined; - return apiFetch(resourceName, { pathParams, queryParams }) as Promise>; + const nextQueryParams = 'pageParam' in context ? (context.pageParam || undefined) : undefined; + return apiFetch(resourceName, { pathParams, queryParams: { ...queryParams, ...nextQueryParams } }) as Promise>; }, initialPageParam: null, getNextPageParam: (lastPage) => { diff --git a/lib/api/useApiQuery.tsx b/lib/api/useApiQuery.tsx index 51e47c992b..e3cbba398b 100644 --- a/lib/api/useApiQuery.tsx +++ b/lib/api/useApiQuery.tsx @@ -1,9 +1,8 @@ import type { UseQueryOptions } from '@tanstack/react-query'; import { useQuery } from '@tanstack/react-query'; -import type { ChainConfig } from 'types/multichain'; +import type { ExternalChainExtended } from 'types/externalChains'; -import multichainConfig from 'configs/multichain'; import { useMultichainContext } from 'lib/contexts/multichain'; import type { Params as FetchParams } from 'lib/hooks/useFetch'; @@ -12,37 +11,36 @@ import useApiFetch from './useApiFetch'; export interface Params> { pathParams?: ResourcePathParams; - queryParams?: Record | number | boolean | undefined>; + queryParams?: Record | number | boolean | undefined | null>; fetchParams?: Pick; queryOptions?: Partial, ResourceError, D>, 'queryFn'>>; logError?: boolean; - chainSlug?: string; - chain?: ChainConfig; + chain?: ExternalChainExtended; } export interface GetResourceKeyParams> extends Pick, 'pathParams' | 'queryParams'> { - chainSlug?: string; + chainId?: string; } -export function getResourceKey(resource: R, { pathParams, queryParams, chainSlug }: GetResourceKeyParams = {}) { +export function getResourceKey(resource: R, { pathParams, queryParams, chainId }: GetResourceKeyParams = {}) { if (pathParams || queryParams) { - return [ resource, chainSlug, { ...pathParams, ...queryParams } ].filter(Boolean); + return [ resource, chainId, { ...pathParams, ...queryParams } ].filter(Boolean); } - return [ resource, chainSlug ].filter(Boolean); + return [ resource, chainId ].filter(Boolean); } export default function useApiQuery>( resource: R, - { queryOptions, pathParams, queryParams, fetchParams, logError, chainSlug, chain: chainProp }: Params = {}, + { queryOptions, pathParams, queryParams, fetchParams, logError, chain: chainProp }: Params = {}, ) { const apiFetch = useApiFetch(); - const { chain } = useMultichainContext() || (chainProp && { chain: chainProp }) || - { chain: chainSlug ? multichainConfig()?.chains.find((chain) => chain.slug === chainSlug) : undefined }; + const multichainContext = useMultichainContext(); + const chain = chainProp || multichainContext?.chain; return useQuery, ResourceError, D>({ - queryKey: queryOptions?.queryKey || getResourceKey(resource, { pathParams, queryParams, chainSlug: chain?.slug }), + queryKey: queryOptions?.queryKey || getResourceKey(resource, { pathParams, queryParams, chainId: chain?.id }), queryFn: async({ signal }) => { // all errors and error typing is handled by react-query // so error response will never go to the data diff --git a/lib/contexts/multichain.tsx b/lib/contexts/multichain.tsx index de3b1f721d..009debf3dc 100644 --- a/lib/contexts/multichain.tsx +++ b/lib/contexts/multichain.tsx @@ -1,42 +1,39 @@ import { useRouter } from 'next/router'; import React from 'react'; -import type { ChainConfig } from 'types/multichain'; +import type { ClusterChainConfig } from 'types/multichain'; import multichainConfig from 'configs/multichain'; +import getChainIdFromSlug from 'lib/multichain/getChainIdFromSlug'; import getQueryParamString from 'lib/router/getQueryParamString'; -export type ContextLevel = 'page' | 'tab'; - interface MultichainProviderProps { children: React.ReactNode; - chainSlug?: string; - level?: ContextLevel; + chainId?: string; } export interface TMultichainContext { - chain: ChainConfig; - level?: ContextLevel; + chain: ClusterChainConfig; } export const MultichainContext = React.createContext(null); -export function MultichainProvider({ children, chainSlug: chainSlugProp, level: levelProp }: MultichainProviderProps) { +export function MultichainProvider({ children, chainId: chainIdProp }: MultichainProviderProps) { const router = useRouter(); - const chainSlugQueryParam = router.pathname.includes('chain-slug') ? getQueryParamString(router.query['chain-slug']) : undefined; + const chainSlugQueryParam = router.pathname.includes('chain_slug') ? getQueryParamString(router.query.chain_slug) : undefined; + const chainIdQueryParam = router.query.chain_id ? getQueryParamString(router.query.chain_id) : undefined; - const [ chainSlug, setChainSlug ] = React.useState(chainSlugProp ?? chainSlugQueryParam); - const [ level, setLevel ] = React.useState(levelProp); + const [ chainId, setChainId ] = React.useState( + chainIdProp ?? + chainIdQueryParam ?? + (chainSlugQueryParam ? getChainIdFromSlug(chainSlugQueryParam) : undefined), + ); React.useEffect(() => { - if (chainSlugProp) { - setChainSlug(chainSlugProp); + if (chainIdProp) { + setChainId(chainIdProp); } - }, [ chainSlugProp ]); - - React.useEffect(() => { - setLevel(levelProp); - }, [ levelProp ]); + }, [ chainIdProp ]); const chain = React.useMemo(() => { const config = multichainConfig(); @@ -44,12 +41,12 @@ export function MultichainProvider({ children, chainSlug: chainSlugProp, level: return; } - if (!chainSlug) { + if (!chainId) { return; } - return config.chains.find((chain) => chain.slug === chainSlug); - }, [ chainSlug ]); + return config.chains.find((chain) => chain.id === chainId); + }, [ chainId ]); const value = React.useMemo(() => { if (!chain) { @@ -58,9 +55,8 @@ export function MultichainProvider({ children, chainSlug: chainSlugProp, level: return { chain, - level, }; - }, [ chain, level ]); + }, [ chain ]); return ( diff --git a/lib/hooks/useNavItems.tsx b/lib/hooks/useNavItems.tsx index 1ee38a5fb3..96b3f58bbb 100644 --- a/lib/hooks/useNavItems.tsx +++ b/lib/hooks/useNavItems.tsx @@ -40,7 +40,7 @@ export default function useNavItems(): ReturnType { text: 'Blocks', nextRoute: { pathname: '/blocks' as const }, icon: 'block', - isActive: pathname === '/blocks' || pathname === '/block/[height_or_hash]' || pathname === '/chain/[chain-slug]/block/[height_or_hash]', + isActive: pathname === '/blocks' || pathname === '/block/[height_or_hash]' || pathname === '/chain/[chain_slug]/block/[height_or_hash]', }; const txs: NavItem | null = { text: 'Transactions', @@ -50,7 +50,7 @@ export default function useNavItems(): ReturnType { // sorry, but this is how it was designed (pathname === '/txs' && (!config.features.zetachain.isEnabled || !tab || !tab.includes('cctx'))) || pathname === '/tx/[hash]' || - pathname === '/chain/[chain-slug]/tx/[hash]', + pathname === '/chain/[chain_slug]/tx/[hash]', }; const cctxs: NavItem | null = config.features.zetachain.isEnabled ? { text: 'Cross-chain transactions', @@ -74,7 +74,7 @@ export default function useNavItems(): ReturnType { text: 'User operations', nextRoute: { pathname: '/ops' as const }, icon: 'user_op', - isActive: pathname === '/ops' || pathname === '/op/[hash]' || pathname === '/chain/[chain-slug]/op/[hash]', + isActive: pathname === '/ops' || pathname === '/op/[hash]' || pathname === '/chain/[chain_slug]/op/[hash]', } : null; const verifiedContracts: NavItem | null = @@ -310,7 +310,6 @@ export default function useNavItems(): ReturnType { const otherNavItems: Array | Array> = [ config.features.opSuperchain.isEnabled ? { text: 'Verify contract', - // TODO @tom2drum adjust URL to Vera url: 'https://vera.blockscout.com', } : { text: 'Verify contract', diff --git a/lib/metadata/getPageOgType.ts b/lib/metadata/getPageOgType.ts index de7482a600..30495eb4d9 100644 --- a/lib/metadata/getPageOgType.ts +++ b/lib/metadata/getPageOgType.ts @@ -70,16 +70,17 @@ const OG_TYPE_DICT: Record = { '/cc/tx/[hash]': 'Regular page', // multichain routes - '/chain/[chain-slug]/accounts/label/[slug]': 'Root page', - '/chain/[chain-slug]/advanced-filter': 'Regular page', - '/chain/[chain-slug]/block/[height_or_hash]': 'Regular page', - '/chain/[chain-slug]/block/countdown': 'Regular page', - '/chain/[chain-slug]/block/countdown/[height]': 'Regular page', - '/chain/[chain-slug]/csv-export': 'Regular page', - '/chain/[chain-slug]/op/[hash]': 'Regular page', - '/chain/[chain-slug]/token/[hash]': 'Regular page', - '/chain/[chain-slug]/token/[hash]/instance/[id]': 'Regular page', - '/chain/[chain-slug]/tx/[hash]': 'Regular page', + '/chain/[chain_slug]/accounts/label/[slug]': 'Root page', + '/chain/[chain_slug]/advanced-filter': 'Regular page', + '/chain/[chain_slug]/block/[height_or_hash]': 'Regular page', + '/chain/[chain_slug]/block/countdown': 'Regular page', + '/chain/[chain_slug]/block/countdown/[height]': 'Regular page', + '/chain/[chain_slug]/csv-export': 'Regular page', + '/chain/[chain_slug]/op/[hash]': 'Regular page', + '/chain/[chain_slug]/token/[hash]': 'Regular page', + '/chain/[chain_slug]/token/[hash]/instance/[id]': 'Regular page', + '/chain/[chain_slug]/tx/[hash]': 'Regular page', + '/chain/[chain_slug]/visualize/sol2uml': 'Regular page', // service routes, added only to make typescript happy '/login': 'Regular page', diff --git a/lib/metadata/templates/description.ts b/lib/metadata/templates/description.ts index bbb52227a9..164741a2e1 100644 --- a/lib/metadata/templates/description.ts +++ b/lib/metadata/templates/description.ts @@ -73,16 +73,17 @@ const TEMPLATE_MAP: Record = { '/cc/tx/[hash]': DEFAULT_TEMPLATE, // multichain routes - '/chain/[chain-slug]/accounts/label/[slug]': DEFAULT_TEMPLATE, - '/chain/[chain-slug]/advanced-filter': DEFAULT_TEMPLATE, - '/chain/[chain-slug]/block/[height_or_hash]': DEFAULT_TEMPLATE, - '/chain/[chain-slug]/block/countdown': DEFAULT_TEMPLATE, - '/chain/[chain-slug]/block/countdown/[height]': DEFAULT_TEMPLATE, - '/chain/[chain-slug]/csv-export': DEFAULT_TEMPLATE, - '/chain/[chain-slug]/op/[hash]': DEFAULT_TEMPLATE, - '/chain/[chain-slug]/token/[hash]': DEFAULT_TEMPLATE, - '/chain/[chain-slug]/token/[hash]/instance/[id]': DEFAULT_TEMPLATE, - '/chain/[chain-slug]/tx/[hash]': DEFAULT_TEMPLATE, + '/chain/[chain_slug]/accounts/label/[slug]': DEFAULT_TEMPLATE, + '/chain/[chain_slug]/advanced-filter': DEFAULT_TEMPLATE, + '/chain/[chain_slug]/block/[height_or_hash]': DEFAULT_TEMPLATE, + '/chain/[chain_slug]/block/countdown': DEFAULT_TEMPLATE, + '/chain/[chain_slug]/block/countdown/[height]': DEFAULT_TEMPLATE, + '/chain/[chain_slug]/csv-export': DEFAULT_TEMPLATE, + '/chain/[chain_slug]/op/[hash]': DEFAULT_TEMPLATE, + '/chain/[chain_slug]/token/[hash]': DEFAULT_TEMPLATE, + '/chain/[chain_slug]/token/[hash]/instance/[id]': DEFAULT_TEMPLATE, + '/chain/[chain_slug]/tx/[hash]': DEFAULT_TEMPLATE, + '/chain/[chain_slug]/visualize/sol2uml': DEFAULT_TEMPLATE, // service routes, added only to make typescript happy '/login': DEFAULT_TEMPLATE, diff --git a/lib/metadata/templates/title.ts b/lib/metadata/templates/title.ts index f47f45e21a..65abde5d5e 100644 --- a/lib/metadata/templates/title.ts +++ b/lib/metadata/templates/title.ts @@ -74,16 +74,17 @@ const TEMPLATE_MAP: Record = { '/cc/tx/[hash]': '%network_name% cross-chain transaction %hash% details', // multichain routes - '/chain/[chain-slug]/accounts/label/[slug]': '%network_name% addresses search by label', - '/chain/[chain-slug]/advanced-filter': '%network_name% advanced filter', - '/chain/[chain-slug]/block/[height_or_hash]': '%network_name% block %height_or_hash% details', - '/chain/[chain-slug]/block/countdown': '%network_name% block countdown index', - '/chain/[chain-slug]/block/countdown/[height]': '%network_name% block %height% countdown', - '/chain/[chain-slug]/csv-export': '%network_name% export data to CSV', - '/chain/[chain-slug]/op/[hash]': '%network_name% user operation %hash% details', - '/chain/[chain-slug]/token/[hash]': '%network_name% token details', - '/chain/[chain-slug]/token/[hash]/instance/[id]': '%network_name% token NFT instance', - '/chain/[chain-slug]/tx/[hash]': '%network_name% transaction %hash% details', + '/chain/[chain_slug]/accounts/label/[slug]': '%network_name% addresses search by label', + '/chain/[chain_slug]/advanced-filter': '%network_name% advanced filter', + '/chain/[chain_slug]/block/[height_or_hash]': '%network_name% block %height_or_hash% details', + '/chain/[chain_slug]/block/countdown': '%network_name% block countdown index', + '/chain/[chain_slug]/block/countdown/[height]': '%network_name% block %height% countdown', + '/chain/[chain_slug]/csv-export': '%network_name% export data to CSV', + '/chain/[chain_slug]/op/[hash]': '%network_name% user operation %hash% details', + '/chain/[chain_slug]/token/[hash]': '%network_name% token details', + '/chain/[chain_slug]/token/[hash]/instance/[id]': '%network_name% token NFT instance', + '/chain/[chain_slug]/tx/[hash]': '%network_name% transaction %hash% details', + '/chain/[chain_slug]/visualize/sol2uml': '%network_name% Solidity UML diagram', // service routes, added only to make typescript happy '/login': '%network_name% login', diff --git a/lib/mixpanel/getPageType.ts b/lib/mixpanel/getPageType.ts index ff9b4ce3e0..c2e1491edd 100644 --- a/lib/mixpanel/getPageType.ts +++ b/lib/mixpanel/getPageType.ts @@ -68,16 +68,17 @@ export const PAGE_TYPE_DICT: Record = { '/cc/tx/[hash]': 'Cross-chain transaction details', // multichain routes - '/chain/[chain-slug]/accounts/label/[slug]': 'Chain addresses search by label', - '/chain/[chain-slug]/advanced-filter': 'Chain advanced filter', - '/chain/[chain-slug]/block/[height_or_hash]': 'Chain block details', - '/chain/[chain-slug]/block/countdown': 'Chain block countdown index', - '/chain/[chain-slug]/block/countdown/[height]': 'Chain block countdown', - '/chain/[chain-slug]/csv-export': 'Chain export data to CSV', - '/chain/[chain-slug]/op/[hash]': 'Chain user operation details', - '/chain/[chain-slug]/token/[hash]': 'Chain token details', - '/chain/[chain-slug]/token/[hash]/instance/[id]': 'Chain token NFT instance', - '/chain/[chain-slug]/tx/[hash]': 'Chain transaction details', + '/chain/[chain_slug]/accounts/label/[slug]': 'Chain addresses search by label', + '/chain/[chain_slug]/advanced-filter': 'Chain advanced filter', + '/chain/[chain_slug]/block/[height_or_hash]': 'Chain block details', + '/chain/[chain_slug]/block/countdown': 'Chain block countdown index', + '/chain/[chain_slug]/block/countdown/[height]': 'Chain block countdown', + '/chain/[chain_slug]/csv-export': 'Chain export data to CSV', + '/chain/[chain_slug]/op/[hash]': 'Chain user operation details', + '/chain/[chain_slug]/token/[hash]': 'Chain token details', + '/chain/[chain_slug]/token/[hash]/instance/[id]': 'Chain token NFT instance', + '/chain/[chain_slug]/tx/[hash]': 'Chain transaction details', + '/chain/[chain_slug]/visualize/sol2uml': 'Chain Solidity UML diagram', // service routes, added only to make typescript happy '/login': 'Login', diff --git a/lib/mixpanel/useInit.tsx b/lib/mixpanel/useInit.tsx index ccb6675cc4..da2f849b48 100644 --- a/lib/mixpanel/useInit.tsx +++ b/lib/mixpanel/useInit.tsx @@ -12,6 +12,8 @@ import getQueryParamString from 'lib/router/getQueryParamString'; import * as userProfile from './userProfile'; +const opSuperchainFeature = config.features.opSuperchain; + export default function useMixpanelInit() { const [ isInited, setIsInited ] = React.useState(false); const router = useRouter(); @@ -44,6 +46,7 @@ export default function useMixpanelInit() { Language: window.navigator.language, 'Device type': capitalize(deviceType), 'User id': uuid, + ...(opSuperchainFeature.isEnabled ? { 'Cluster name': opSuperchainFeature.cluster } : {}), }); mixpanel.identify(uuid); userProfile.set({ diff --git a/lib/multichain/contract.ts b/lib/multichain/contract.ts new file mode 100644 index 0000000000..ea75f3576b --- /dev/null +++ b/lib/multichain/contract.ts @@ -0,0 +1,38 @@ +import type * as multichain from '@blockscout/multichain-aggregator-types'; + +export function getName(data: { chain_infos: Record }) { + const chainInfos = Object.values(data.chain_infos ?? {}); + const isContractChains = chainInfos.filter((chainInfo) => chainInfo.is_contract); + const names = isContractChains.map((chainInfo) => chainInfo.contract_name); + + if (names.some((name) => !name)) { + return; + } + + const uniqueNames = [ ...new Set(names) ]; + + if (uniqueNames.length === 1) { + return uniqueNames[0]; + } +} + +export function isContract(data: { chain_infos: Record } | undefined) { + if (!data) { + return false; + } + + const chainInfos = Object.values(data.chain_infos ?? {}); + const isContractChains = chainInfos.filter((chainInfo) => chainInfo.is_contract); + return isContractChains.length > chainInfos.length / 2; +} + +export function isVerified(data: { chain_infos: Record } | undefined) { + if (!data) { + return false; + } + + const chainInfos = Object.values(data.chain_infos ?? {}); + const isContractChains = chainInfos.filter((chainInfo) => chainInfo.is_contract); + const isVerifiedChains = chainInfos.filter((chainInfo) => chainInfo.is_verified); + return isVerifiedChains.length > 0 && isVerifiedChains.length === isContractChains.length; +} diff --git a/lib/multichain/getChainDataForList.ts b/lib/multichain/getChainDataForList.ts deleted file mode 100644 index 96ef82af5c..0000000000 --- a/lib/multichain/getChainDataForList.ts +++ /dev/null @@ -1,6 +0,0 @@ -import type { TMultichainContext } from 'lib/contexts/multichain'; - -export function getChainDataForList(multichainContext: TMultichainContext | null) { - // for now we only show chain icon in the list with chain selector (not in the entire local pages) - return multichainContext?.chain && multichainContext.level !== 'page' ? multichainContext.chain : undefined; -} diff --git a/lib/multichain/getChainIdFromSlug.ts b/lib/multichain/getChainIdFromSlug.ts new file mode 100644 index 0000000000..b9cff67092 --- /dev/null +++ b/lib/multichain/getChainIdFromSlug.ts @@ -0,0 +1,10 @@ +import multichainConfig from 'configs/multichain'; + +export default function getChainIdFromSlug(slug: string) { + const config = multichainConfig(); + if (!config) { + return undefined; + } + + return config.chains.find((chain) => chain.slug === slug)?.id; +} diff --git a/lib/multichain/getChainTooltipText.ts b/lib/multichain/getChainTooltipText.ts deleted file mode 100644 index 5c32a1e6ab..0000000000 --- a/lib/multichain/getChainTooltipText.ts +++ /dev/null @@ -1,5 +0,0 @@ -import type { ChainConfig } from 'types/multichain'; - -export default function getChainTooltipText(chain: ChainConfig, prefix: string = '') { - return `${ prefix }${ chain.config.chain.name } (Chain ID ${ chain.config.chain.id })`; -} diff --git a/lib/multichain/getChainValueFromQuery.ts b/lib/multichain/getChainValueFromQuery.ts index f85e591302..74d05646ed 100644 --- a/lib/multichain/getChainValueFromQuery.ts +++ b/lib/multichain/getChainValueFromQuery.ts @@ -3,12 +3,35 @@ import type { Router } from 'next/router'; import multichainConfig from 'configs/multichain'; import getQueryParamString from 'lib/router/getQueryParamString'; -export default function getChainValueFromQuery(query: Router['query']) { +export default function getChainValueFromQuery( + query: Router['query'], + chainIds?: Array, + withAllOption?: boolean, +) { const config = multichainConfig(); if (!config) { return undefined; } - const queryParam = getQueryParamString(query['chain-slug']); - return queryParam || config.chains[0].slug; + const chainId = getQueryParamString(query.chain_id); + const chainSlug = getQueryParamString(query.chain_slug); + + if (chainId) { + if (config.chains.some((chain) => chain.id === chainId)) { + return chainId; + } + } + + if (chainSlug) { + const chain = config.chains.find((chain) => chain.slug === chainSlug); + if (chain) { + return chain.id; + } + } + + if (withAllOption) { + return 'all'; + } + + return config.chains.filter((chain) => !chainIds || (chain.id && chainIds.includes(chain.id)))?.[0]?.id; } diff --git a/lib/multichain/getCurrencySymbol.ts b/lib/multichain/getCurrencySymbol.ts index 1606d9d6ac..9822a8cca9 100644 --- a/lib/multichain/getCurrencySymbol.ts +++ b/lib/multichain/getCurrencySymbol.ts @@ -1,6 +1,6 @@ import multichainConfig from 'configs/multichain'; export default function getCurrencySymbol() { - const nativeCurrency = multichainConfig()?.chains[0]?.config.chain.currency; + const nativeCurrency = multichainConfig()?.chains[0]?.app_config.chain.currency; return nativeCurrency?.symbol; } diff --git a/lib/multichain/getIconUrl.ts b/lib/multichain/getIconUrl.ts deleted file mode 100644 index 19a8749e95..0000000000 --- a/lib/multichain/getIconUrl.ts +++ /dev/null @@ -1,16 +0,0 @@ -import type { ChainConfig } from 'types/multichain'; - -export default function getIconUrl(config: ChainConfig) { - if (!config.config?.UI?.navigation?.icon || !config.config.app) { - return; - } - - const iconPath = config.config.UI.navigation.icon.default; - - if (iconPath?.startsWith('http')) { - return iconPath; - } - - const appUrl = config.config.app.baseUrl; - return `${ appUrl }${ iconPath }`; -} diff --git a/lib/multichain/useRoutedChainSelect.ts b/lib/multichain/useRoutedChainSelect.ts index e12b068a8f..284d623501 100644 --- a/lib/multichain/useRoutedChainSelect.ts +++ b/lib/multichain/useRoutedChainSelect.ts @@ -1,3 +1,4 @@ +import { pickBy } from 'es-toolkit'; import { useRouter } from 'next/router'; import React from 'react'; @@ -5,27 +6,40 @@ import getChainValueFromQuery from './getChainValueFromQuery'; interface Props { persistedParams?: Array; + isLoading?: boolean; + chainIds?: Array; + withAllOption?: boolean; } export default function useRoutedChainSelect(props?: Props) { const router = useRouter(); const [ value, setValue ] = React.useState | undefined>( - [ getChainValueFromQuery(router.query) ].filter(Boolean), + props?.isLoading ? undefined : [ getChainValueFromQuery(router.query, props?.chainIds, props?.withAllOption) ].filter(Boolean), ); + React.useEffect(() => { + if (!props?.isLoading) { + setValue([ getChainValueFromQuery(router.query, props?.chainIds, props?.withAllOption) ].filter(Boolean)); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [ props?.isLoading, props?.chainIds ]); + const onValueChange = React.useCallback(({ value }: { value: Array }) => { setValue(value); - const nextQuery = props?.persistedParams ? props.persistedParams.reduce((acc, param) => ({ ...acc, [param]: router.query[param] }), {}) : router.query; + const nextQuery = props?.persistedParams ? + props.persistedParams.reduce((acc, param) => ({ ...acc, [param]: router.query[param] || undefined }), {}) : + router.query; router.push({ pathname: router.pathname, - query: { + query: pickBy({ ...nextQuery, - 'chain-slug': value[0], - }, + chain_id: value[0], + }, (value) => value !== undefined), }); + }, [ props?.persistedParams, router ]); return React.useMemo(() => ({ diff --git a/lib/web3/chains.ts b/lib/web3/chains.ts index 3622d69d58..9d5b0647f6 100644 --- a/lib/web3/chains.ts +++ b/lib/web3/chains.ts @@ -4,7 +4,11 @@ import appConfig from 'configs/app'; import essentialDappsChainsConfig from 'configs/essential-dapps-chains'; import multichainConfig from 'configs/multichain'; -const getChainInfo = (config: typeof appConfig = appConfig, contracts?: Chain['contracts']): Chain => { +const getChainInfo = (config: Partial = appConfig, contracts?: Chain['contracts']): Chain | undefined => { + if (!config.chain || !config.app) { + return; + } + return { id: Number(config.chain.id), name: config.chain.name ?? '', @@ -70,7 +74,7 @@ export const clusterChains: Array | undefined = (() => { return; } - return config.chains.map(({ config, contracts }) => getChainInfo(config, contracts)).filter(Boolean); + return config.chains.map(({ app_config: config }) => getChainInfo(config)).filter(Boolean); })(); export const essentialDappsChains: Array | undefined = (() => { @@ -80,7 +84,7 @@ export const essentialDappsChains: Array | undefined = (() => { return; } - return config.chains.map(({ config, contracts }) => getChainInfo(config, contracts)).filter(Boolean); + return config.chains.map(({ app_config: config, contracts }) => getChainInfo(config, contracts)).filter(Boolean); })(); export const chains = (() => { diff --git a/lib/web3/client.ts b/lib/web3/client.ts index 08f00966d4..1e849e01de 100644 --- a/lib/web3/client.ts +++ b/lib/web3/client.ts @@ -3,7 +3,6 @@ import { createPublicClient, http } from 'viem'; import { currentChain } from './chains'; export const publicClient = (() => { - // TODO @tom2drum public clients for multichain (currently used only in degradation views) if (currentChain?.rpcUrls.default.http.filter(Boolean).length === 0) { return; } diff --git a/lib/web3/useAddChain.tsx b/lib/web3/useAddChain.tsx index 4864dbbfb1..610e0841c8 100644 --- a/lib/web3/useAddChain.tsx +++ b/lib/web3/useAddChain.tsx @@ -32,7 +32,7 @@ export default function useAddChain() { const { trackUsage } = useRewardsActivity(); const multichainContext = useMultichainContext(); - const chainConfig = multichainContext?.chain.config ?? config; + const chainConfig = multichainContext?.chain.app_config || config; return React.useCallback(async() => { if (!wallet || !provider) { diff --git a/lib/web3/useProvider.tsx b/lib/web3/useProvider.tsx index 0ca4502d80..ac95440b78 100644 --- a/lib/web3/useProvider.tsx +++ b/lib/web3/useProvider.tsx @@ -10,7 +10,7 @@ import useDetectWalletEip6963 from './useDetectWalletEip6963'; export default function useProvider() { const multichainContext = useMultichainContext(); - const feature = (multichainContext?.chain.config ?? config).features.web3Wallet; + const feature = (multichainContext?.chain.app_config ?? config).features.web3Wallet; const wallets = getFeaturePayload(feature)?.wallets; const { detect: detectWalletEip6963 } = useDetectWalletEip6963(); diff --git a/lib/web3/useSwitchChain.tsx b/lib/web3/useSwitchChain.tsx index 7d3153671c..0a8c61ac63 100644 --- a/lib/web3/useSwitchChain.tsx +++ b/lib/web3/useSwitchChain.tsx @@ -18,7 +18,7 @@ export default function useSwitchChain() { const { data: { wallet, provider } = {} } = useProvider(); const multichainContext = useMultichainContext(); - const chainConfig = multichainContext?.chain.config ?? config; + const chainConfig = multichainContext?.chain.app_config ?? config; return React.useCallback(() => { if (!wallet || !provider) { diff --git a/lib/web3/wagmiConfig.ts b/lib/web3/wagmiConfig.ts index adb8a2b7b3..6bf6716b37 100644 --- a/lib/web3/wagmiConfig.ts +++ b/lib/web3/wagmiConfig.ts @@ -11,15 +11,15 @@ import { chains, parentChain } from 'lib/web3/chains'; const feature = appConfig.features.blockchainInteraction; -const getChainTransportFromConfig = (config: typeof appConfig, readOnly?: boolean): Record => { - if (!config.chain.id) { +const getChainTransportFromConfig = (config: Partial | undefined, readOnly?: boolean): Record => { + if (!config?.chain?.id) { return {}; } return { [config.chain.id]: fallback( config.chain.rpcUrls - .concat(readOnly ? `${ config.apis.general.endpoint }/api/eth-rpc` : '') + .concat(readOnly && config.apis?.general ? `${ config.apis.general.endpoint }/api/eth-rpc` : '') .filter(Boolean) .map((url) => http(url, { batch: { wait: 100, batchSize: 5 } })), ), @@ -36,7 +36,7 @@ const reduceExternalChainsToTransportConfig = (readOnly: boolean): Record getChainTransportFromConfig(config, readOnly)) + .map(({ app_config: config }) => getChainTransportFromConfig(config, readOnly)) .reduce((result, item) => { Object.entries(item).map(([ id, transport ]) => { result[id] = transport; diff --git a/mocks/multichain/opSuperchain.ts b/mocks/multichain/opSuperchain.ts index 0573485d96..143547fa04 100644 --- a/mocks/multichain/opSuperchain.ts +++ b/mocks/multichain/opSuperchain.ts @@ -1,14 +1,18 @@ -import type { ChainConfig } from 'types/multichain'; +import type * as multichain from '@blockscout/multichain-aggregator-types'; +import type { AddressTokenItem } from 'types/client/multichain-aggregator'; +import type { ClusterChainConfig } from 'types/multichain'; export const chainDataA = { slug: 'op-mainnet', - config: { + name: 'OP Mainnet', + id: '420', + logo: 'https://example.com/logo_s.png', + explorer_url: 'https://op-mainnet.com', + app_config: { app: { baseUrl: 'https://op-mainnet.com', }, chain: { - name: 'OP Mainnet', - id: '420', currency: { name: 'Ether', symbol: 'ETH', @@ -18,13 +22,6 @@ export const chainDataA = { 'https://rpc.op-mainnet.com', ], }, - UI: { - navigation: { - icon: { - 'default': '/logo.png', - }, - }, - }, apis: { general: { host: 'localhost', @@ -44,4 +41,166 @@ export const chainDataA = { }, }, }, -} as ChainConfig; +} as ClusterChainConfig; + +export const chainDataB = { + ...chainDataA, + id: '421', + name: 'OP Testnet', + logo: 'https://example.com/logo_md.png', + slug: 'op-testnet', + explorer_url: 'https://op-testnet.com', + app_config: { + ...chainDataA.app_config, + app: { + baseUrl: 'https://op-testnet.com', + }, + apis: { + general: { + ...chainDataA.app_config?.apis?.general, + port: '4004', + endpoint: 'http://localhost:4004', + }, + }, + }, +} as ClusterChainConfig; + +export const chainDataC = { + ...chainDataA, + id: '422', + name: 'OP Devnet', + slug: 'op-devnet', + explorer_url: 'https://op-devnet.com', + logo: undefined, + app_config: { + ...chainDataA.app_config, + app: { + baseUrl: 'https://op-devnet.com', + }, + apis: { + general: { + ...chainDataA.app_config?.apis?.general, + port: '4005', + endpoint: 'http://localhost:4005', + }, + }, + }, +} as ClusterChainConfig; + +export const addressA: multichain.GetAddressResponse = { + hash: '0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045', + chain_infos: { + '420': { + coin_balance: '33298149965862412288', + is_contract: false, + is_verified: false, + contract_name: undefined, + }, + '421': { + coin_balance: '1836931848855642237', + is_contract: true, + is_verified: false, + contract_name: undefined, + }, + '422': { + coin_balance: '0', + is_contract: true, + is_verified: true, + contract_name: 'Test Contract', + }, + }, + has_tokens: true, + has_interop_message_transfers: false, + coin_balance: '35135081814718054525', + exchange_rate: '123.455', +}; + +export const tokenA: AddressTokenItem = { + token: { + address_hash: '0xaA8E23Fb1079EA71e0a56F48a2aA51851D8433D0', + circulating_market_cap: '10.01', + decimals: '6', + holders_count: '1250', + icon_url: null, + name: 'USDT', + symbol: 'USDT', + total_supply: '284529257047384793', + type: 'ERC-20', + exchange_rate: '123.456', + chain_infos: { + '420': { + holders_count: '1250', + total_supply: '184529257047384791', + is_verified: true, + contract_name: 'TestnetERC20', + }, + '421': { + holders_count: '1250', + total_supply: '100000000000000002', + is_verified: true, + contract_name: 'TestnetERC20', + }, + }, + reputation: null, + }, + token_instance: null, + token_id: null, + value: '9038000000', + chain_values: { + '420': '1038000000', + '421': '8000000000', + }, +}; + +export const searchAddressesA: multichain.GetAddressResponse = { + hash: '0x0000000002C5fE54822a1eD058AE2F937Fd42769', + chain_infos: { + '420': { + coin_balance: '0', + is_contract: false, + is_verified: false, + contract_name: undefined, + }, + }, + has_tokens: false, + has_interop_message_transfers: false, + coin_balance: '0', + exchange_rate: '123.456', +}; + +export const searchAddressesB: multichain.GetAddressResponse = { + hash: '0x00883b68A6EcF2ea3D47BD735E5125a0B7625B53', + chain_infos: { + '420': { + coin_balance: '0', + is_contract: true, + is_verified: true, + contract_name: 'USDT', + }, + }, + has_tokens: false, + has_interop_message_transfers: false, + coin_balance: '0', + exchange_rate: '123.456', +}; + +export const searchTokenA: multichain.AggregatedTokenInfo = { + address_hash: '0x94b008aA00579c1307B0EF2c499aD98a8ce58e58', + circulating_market_cap: '162513452583.22', + decimals: '6', + holders_count: '1148927', + icon_url: undefined, + name: 'Tether USD', + symbol: 'USDT', + total_supply: '1000000', + type: 'ERC-20' as multichain.TokenType, + exchange_rate: '1.00', + chain_infos: { + '420': { + holders_count: '1148927', + total_supply: '1000000', + is_verified: false, + contract_name: undefined, + }, + }, +}; diff --git a/nextjs/csp/policies/marketplace.ts b/nextjs/csp/policies/marketplace.ts index cb0afc2876..9df5bf7b99 100644 --- a/nextjs/csp/policies/marketplace.ts +++ b/nextjs/csp/policies/marketplace.ts @@ -11,8 +11,8 @@ export function marketplace(): CspDev.DirectiveDescriptor { } const chainsConfig = feature.essentialDapps && essentialDappsChains.getValue(); - const externalApiEndpoints = chainsConfig?.chains.map((chain) => chain.config.apis.general?.endpoint).filter(Boolean); - const defaultRpcUrls = chainsConfig?.chains.map((chain) => chain.config.chain.rpcUrls).flat(); + const externalApiEndpoints = chainsConfig?.chains.map((chain) => chain.app_config?.apis?.general?.endpoint).filter(Boolean); + const defaultRpcUrls = chainsConfig?.chains.map((chain) => chain.app_config?.chain?.rpcUrls).flat().filter(Boolean); const liFiHost = feature.essentialDapps?.swap ? 'li.quest' : ''; const multisenderHost = feature.essentialDapps?.multisend ? '*.multisender.app' : ''; diff --git a/nextjs/csp/policies/multichain.ts b/nextjs/csp/policies/multichain.ts index 6924a629cb..b82d69511a 100644 --- a/nextjs/csp/policies/multichain.ts +++ b/nextjs/csp/policies/multichain.ts @@ -9,18 +9,20 @@ export function multichain(): CspDev.DirectiveDescriptor { } const apiEndpoints = value.chains.map((chain) => { - return [ - ...Object.values(chain.config.apis).filter(Boolean).map((api) => api.endpoint), - ...Object.values(chain.config.apis).filter(Boolean).map((api) => api.socketEndpoint), - ].filter(Boolean); + return chain.app_config?.apis ? [ + ...Object.values(chain.app_config.apis).filter(Boolean).map((api) => api.endpoint), + ...Object.values(chain.app_config.apis).filter(Boolean).map((api) => api.socketEndpoint), + ].filter(Boolean) : []; }).flat(); - const rpcEndpoints = value.chains.map(({ config }) => config.chain.rpcUrls).flat(); + const rpcEndpoints = value.chains.map(({ app_config: config }) => config?.chain?.rpcUrls).flat().filter(Boolean); return { 'connect-src': [ ...apiEndpoints, ...rpcEndpoints, + // please see comment in the useFetchParentChainApi.tsx file + 'https://eth.blockscout.com', ], }; } diff --git a/nextjs/getServerSideProps/main.ts b/nextjs/getServerSideProps/main.ts index 91006ecb06..d0e16c6e2d 100644 --- a/nextjs/getServerSideProps/main.ts +++ b/nextjs/getServerSideProps/main.ts @@ -3,6 +3,8 @@ import { factory } from './utils'; export const base = factory([ ]); export const block = factory([ guards.notOpSuperchain ]); +export const tx = factory([ guards.notOpSuperchain ]); +export const token = factory([ guards.notOpSuperchain ]); export const account = factory([ guards.account ]); export const verifiedAddresses = factory([ guards.account, guards.verifiedAddresses ]); export const userOps = factory([ guards.userOps ]); diff --git a/nextjs/getServerSideProps/utils.ts b/nextjs/getServerSideProps/utils.ts index ae73347d1f..a632febf8a 100644 --- a/nextjs/getServerSideProps/utils.ts +++ b/nextjs/getServerSideProps/utils.ts @@ -35,15 +35,15 @@ export const factory = (guards: Array>, chainConfig = confi export const factoryMultichain = (guards: Array) => { return async(context: GetServerSidePropsContext) => { - const chainSlug = context.params?.['chain-slug']; + const chainSlug = context.params?.chain_slug; const chain = multichainConfig()?.chains.find((chain) => chain.slug === chainSlug); - if (!chain?.config) { + if (!chain?.app_config) { return { notFound: true, }; } - return factory(guards, chain.config)(context); + return factory(guards, chain.app_config)(context); }; }; diff --git a/nextjs/nextjs-routes.d.ts b/nextjs/nextjs-routes.d.ts index 5186af57da..cd7d01380d 100644 --- a/nextjs/nextjs-routes.d.ts +++ b/nextjs/nextjs-routes.d.ts @@ -39,16 +39,17 @@ declare module "nextjs-routes" { | 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 }> - | DynamicRoute<"/chain/[chain-slug]/block/countdown/[height]", { "chain-slug": string; "height": string }> - | DynamicRoute<"/chain/[chain-slug]/block/countdown", { "chain-slug": string }> - | DynamicRoute<"/chain/[chain-slug]/csv-export", { "chain-slug": string }> - | DynamicRoute<"/chain/[chain-slug]/op/[hash]", { "chain-slug": string; "hash": string }> - | DynamicRoute<"/chain/[chain-slug]/token/[hash]", { "chain-slug": string; "hash": string }> - | DynamicRoute<"/chain/[chain-slug]/token/[hash]/instance/[id]", { "chain-slug": string; "hash": string; "id": string }> - | DynamicRoute<"/chain/[chain-slug]/tx/[hash]", { "chain-slug": string; "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 }> + | DynamicRoute<"/chain/[chain_slug]/block/countdown/[height]", { "chain_slug": string; "height": string }> + | DynamicRoute<"/chain/[chain_slug]/block/countdown", { "chain_slug": string }> + | DynamicRoute<"/chain/[chain_slug]/csv-export", { "chain_slug": string }> + | DynamicRoute<"/chain/[chain_slug]/op/[hash]", { "chain_slug": string; "hash": string }> + | DynamicRoute<"/chain/[chain_slug]/token/[hash]", { "chain_slug": string; "hash": string }> + | DynamicRoute<"/chain/[chain_slug]/token/[hash]/instance/[id]", { "chain_slug": string; "hash": string; "id": string }> + | DynamicRoute<"/chain/[chain_slug]/tx/[hash]", { "chain_slug": string; "hash": string }> + | DynamicRoute<"/chain/[chain_slug]/visualize/sol2uml", { "chain_slug": string }> | StaticRoute<"/chakra"> | StaticRoute<"/contract-verification"> | StaticRoute<"/csv-export"> diff --git a/nextjs/routes.ts b/nextjs/routes.ts index c63a083cc7..9efb692743 100644 --- a/nextjs/routes.ts +++ b/nextjs/routes.ts @@ -1,16 +1,29 @@ +import type { ExternalChain } from 'types/externalChains'; + import type { Route } from 'nextjs-routes'; import { route as nextjsRoute } from 'nextjs-routes'; -import type { TMultichainContext } from 'lib/contexts/multichain'; +import { stripTrailingSlash } from 'toolkit/utils/url'; + +export interface RouteParams { + external?: boolean; + chain?: ExternalChain & { slug?: string }; +} + +export const route = (route: Route, params?: RouteParams | null) => { + const generatedRoute = nextjsRoute(routeParams(route, params)); + + if (params && params.chain && params.external) { + return stripTrailingSlash(params.chain.explorer_url) + generatedRoute; + } -export const route = (route: Route, multichainContext?: TMultichainContext | null) => { - return nextjsRoute(routeParams(route, multichainContext)); + return generatedRoute; }; -export const routeParams = (route: Route, multichainContext?: TMultichainContext | null): Route => { - if (multichainContext) { - const pathname = '/chain/[chain-slug]' + route.pathname; - return { ...route, pathname, query: { ...route.query, 'chain-slug': multichainContext.chain.slug } } as Route; +export const routeParams = (route: Route, params?: RouteParams | null): Route => { + if (!params?.external && params?.chain?.slug) { + const pathname = '/chain/[chain_slug]' + route.pathname; + return { ...route, pathname, query: { ...route.query, chain_slug: params.chain.slug } } as Route; } return route; }; diff --git a/package.json b/package.json index 1ffa5e2e79..1621a5e521 100644 --- a/package.json +++ b/package.json @@ -44,9 +44,9 @@ "dependencies": { "@bigmi/react": "0.5.2", "@blockscout/bens-types": "1.4.1", - "@blockscout/multichain-aggregator-types": "1.6.0-alpha.2", + "@blockscout/multichain-aggregator-types": "1.6.3-alpha.3", "@blockscout/points-types": "1.4.0-alpha.1", - "@blockscout/stats-types": "2.10.0-alpha", + "@blockscout/stats-types": "2.11.1", "@blockscout/tac-operation-lifecycle-types": "0.0.1-alpha.6", "@blockscout/visualizer-types": "0.2.0", "@blockscout/zetachain-cctx-types": "^1.0.0-rc.6", diff --git a/pages/api/proxy.ts b/pages/api/proxy.ts index 4eef749bc4..fad2ee22d3 100644 --- a/pages/api/proxy.ts +++ b/pages/api/proxy.ts @@ -13,7 +13,7 @@ const handler = async(nextReq: NextApiRequest, nextRes: NextApiResponse) => { const url = new URL( nextReq.url.replace(/^\/node-api\/proxy/, ''), - nextReq.headers['x-endpoint']?.toString() || appConfig.apis.general.endpoint, + nextReq.headers['x-endpoint']?.toString() || appConfig.apis.general?.endpoint, ); const apiRes = await fetchFactory(nextReq)( url.toString(), diff --git a/pages/chain/[chain-slug]/accounts/label/[slug].tsx b/pages/chain/[chain_slug]/accounts/label/[slug].tsx similarity index 84% rename from pages/chain/[chain-slug]/accounts/label/[slug].tsx rename to pages/chain/[chain_slug]/accounts/label/[slug].tsx index 60ca9b53f0..6b0e14244a 100644 --- a/pages/chain/[chain-slug]/accounts/label/[slug].tsx +++ b/pages/chain/[chain_slug]/accounts/label/[slug].tsx @@ -10,8 +10,8 @@ const AccountsLabelSearch = dynamic(() => import('ui/pages/AccountsLabelSearch') const Page: NextPage = () => { return ( - - + + diff --git a/pages/chain/[chain-slug]/advanced-filter/index.tsx b/pages/chain/[chain_slug]/advanced-filter/index.tsx similarity index 81% rename from pages/chain/[chain-slug]/advanced-filter/index.tsx rename to pages/chain/[chain_slug]/advanced-filter/index.tsx index 24aecc4a98..4f95f5dff3 100644 --- a/pages/chain/[chain-slug]/advanced-filter/index.tsx +++ b/pages/chain/[chain_slug]/advanced-filter/index.tsx @@ -8,8 +8,8 @@ import AdvancedFilter from 'ui/pages/AdvancedFilter'; const Page: NextPage = () => { return ( - - + + diff --git a/pages/chain/[chain-slug]/block/[height_or_hash].tsx b/pages/chain/[chain_slug]/block/[height_or_hash].tsx similarity index 60% rename from pages/chain/[chain-slug]/block/[height_or_hash].tsx rename to pages/chain/[chain_slug]/block/[height_or_hash].tsx index 5096635de8..821519243b 100644 --- a/pages/chain/[chain-slug]/block/[height_or_hash].tsx +++ b/pages/chain/[chain_slug]/block/[height_or_hash].tsx @@ -5,16 +5,12 @@ import React from 'react'; import type { Props } from 'nextjs/getServerSideProps/handlers'; import PageNextJs from 'nextjs/PageNextJs'; -import { MultichainProvider } from 'lib/contexts/multichain'; - -const Block = dynamic(() => import('ui/pages/Block'), { ssr: false }); +const OpSuperchainBlock = dynamic(() => import('ui/optimismSuperchain/block/OpSuperchainBlock'), { ssr: false }); const Page: NextPage = (props: Props) => { return ( - - - - + + ); }; diff --git a/pages/chain/[chain-slug]/block/countdown/[height].tsx b/pages/chain/[chain_slug]/block/countdown/[height].tsx similarity index 86% rename from pages/chain/[chain-slug]/block/countdown/[height].tsx rename to pages/chain/[chain_slug]/block/countdown/[height].tsx index d848bc6188..bffdedd5d6 100644 --- a/pages/chain/[chain-slug]/block/countdown/[height].tsx +++ b/pages/chain/[chain_slug]/block/countdown/[height].tsx @@ -11,8 +11,8 @@ const BlockCountdown = dynamic(() => import('ui/pages/BlockCountdown'), { ssr: f const Page: NextPage = (props: Props) => { return ( - - + + diff --git a/pages/chain/[chain-slug]/block/countdown/index.tsx b/pages/chain/[chain_slug]/block/countdown/index.tsx similarity index 86% rename from pages/chain/[chain-slug]/block/countdown/index.tsx rename to pages/chain/[chain_slug]/block/countdown/index.tsx index 2ddb56d7b8..1a7714a103 100644 --- a/pages/chain/[chain-slug]/block/countdown/index.tsx +++ b/pages/chain/[chain_slug]/block/countdown/index.tsx @@ -11,8 +11,8 @@ const BlockCountdownIndex = dynamic(() => import('ui/pages/BlockCountdownIndex') const Page: NextPage = (props: Props) => { return ( - - + + diff --git a/pages/chain/[chain-slug]/csv-export/index.tsx b/pages/chain/[chain_slug]/csv-export/index.tsx similarity index 81% rename from pages/chain/[chain-slug]/csv-export/index.tsx rename to pages/chain/[chain_slug]/csv-export/index.tsx index 3e6d5a557c..c3fd451e03 100644 --- a/pages/chain/[chain-slug]/csv-export/index.tsx +++ b/pages/chain/[chain_slug]/csv-export/index.tsx @@ -8,8 +8,8 @@ import CsvExport from 'ui/pages/CsvExport'; const Page: NextPage = () => { return ( - - + + diff --git a/pages/chain/[chain-slug]/op/[hash].tsx b/pages/chain/[chain_slug]/op/[hash].tsx similarity index 85% rename from pages/chain/[chain-slug]/op/[hash].tsx rename to pages/chain/[chain_slug]/op/[hash].tsx index 60aa7f2b9c..89e806bcc3 100644 --- a/pages/chain/[chain-slug]/op/[hash].tsx +++ b/pages/chain/[chain_slug]/op/[hash].tsx @@ -11,8 +11,8 @@ const UserOp = dynamic(() => import('ui/pages/UserOp'), { ssr: false }); const Page: NextPage = (props: Props) => { return ( - - + + diff --git a/pages/chain/[chain-slug]/token/[hash]/index.tsx b/pages/chain/[chain_slug]/token/[hash]/index.tsx similarity index 50% rename from pages/chain/[chain-slug]/token/[hash]/index.tsx rename to pages/chain/[chain_slug]/token/[hash]/index.tsx index d13cb70e72..73b9586849 100644 --- a/pages/chain/[chain-slug]/token/[hash]/index.tsx +++ b/pages/chain/[chain_slug]/token/[hash]/index.tsx @@ -1,29 +1,19 @@ import type { NextPage } from 'next'; +import dynamic from 'next/dynamic'; import React from 'react'; import type { Route } from 'nextjs-routes'; import type { Props } from 'nextjs/getServerSideProps/handlers'; import PageNextJs from 'nextjs/PageNextJs'; -import multichainConfig from 'configs/multichain'; -import getSocketUrl from 'lib/api/getSocketUrl'; -import { MultichainProvider } from 'lib/contexts/multichain'; -import { SocketProvider } from 'lib/socket/context'; -import Token from 'ui/pages/Token'; +const OpSuperchainToken = dynamic(() => import('ui/optimismSuperchain/token/OpSuperchainToken'), { ssr: false }); const pathname: Route['pathname'] = '/token/[hash]'; const Page: NextPage> = (props: Props) => { - const chainSlug = props.query?.['chain-slug']; - const chainData = multichainConfig()?.chains.find(chain => chain.slug === chainSlug); - return ( - - - - - + ); }; diff --git a/pages/chain/[chain-slug]/token/[hash]/instance/[id].tsx b/pages/chain/[chain_slug]/token/[hash]/instance/[id].tsx similarity index 50% rename from pages/chain/[chain-slug]/token/[hash]/instance/[id].tsx rename to pages/chain/[chain_slug]/token/[hash]/instance/[id].tsx index 57c2bde93b..d73aa7c31f 100644 --- a/pages/chain/[chain-slug]/token/[hash]/instance/[id].tsx +++ b/pages/chain/[chain_slug]/token/[hash]/instance/[id].tsx @@ -1,29 +1,19 @@ import type { NextPage } from 'next'; +import dynamic from 'next/dynamic'; import React from 'react'; import type { Route } from 'nextjs-routes'; import type { Props } from 'nextjs/getServerSideProps/handlers'; import PageNextJs from 'nextjs/PageNextJs'; -import multichainConfig from 'configs/multichain'; -import getSocketUrl from 'lib/api/getSocketUrl'; -import { MultichainProvider } from 'lib/contexts/multichain'; -import { SocketProvider } from 'lib/socket/context'; -import TokenInstance from 'ui/pages/TokenInstance'; +const OpSuperchainTokenInstance = dynamic(() => import('ui/optimismSuperchain/tokenInstance/OpSuperchainTokenInstance'), { ssr: false }); const pathname: Route['pathname'] = '/token/[hash]/instance/[id]'; const Page: NextPage> = (props: Props) => { - const chainSlug = props.query?.['chain-slug']; - const chainData = multichainConfig()?.chains.find(chain => chain.slug === chainSlug); - return ( - - - - - + ); }; diff --git a/pages/chain/[chain-slug]/tx/[hash]/index.tsx b/pages/chain/[chain_slug]/tx/[hash]/index.tsx similarity index 58% rename from pages/chain/[chain-slug]/tx/[hash]/index.tsx rename to pages/chain/[chain_slug]/tx/[hash]/index.tsx index 7ba3c9ab40..fa706556e3 100644 --- a/pages/chain/[chain-slug]/tx/[hash]/index.tsx +++ b/pages/chain/[chain_slug]/tx/[hash]/index.tsx @@ -5,16 +5,12 @@ import React from 'react'; import type { Props } from 'nextjs/getServerSideProps/handlers'; import PageNextJs from 'nextjs/PageNextJs'; -import { MultichainProvider } from 'lib/contexts/multichain'; - -const Transaction = dynamic(() => import('ui/pages/Transaction'), { ssr: false }); +const OpSuperchainTx = dynamic(() => import('ui/optimismSuperchain/tx/OpSuperchainTx'), { ssr: false }); const Page: NextPage = (props: Props) => { return ( - - - - + + ); }; diff --git a/pages/chain/[chain_slug]/visualize/sol2uml.tsx b/pages/chain/[chain_slug]/visualize/sol2uml.tsx new file mode 100644 index 0000000000..43c489009b --- /dev/null +++ b/pages/chain/[chain_slug]/visualize/sol2uml.tsx @@ -0,0 +1,21 @@ +import type { NextPage } from 'next'; +import React from 'react'; + +import PageNextJs from 'nextjs/PageNextJs'; + +import { MultichainProvider } from 'lib/contexts/multichain'; +import Sol2Uml from 'ui/pages/Sol2Uml'; + +const Page: NextPage = () => { + return ( + + + + + + ); +}; + +export default Page; + +export { base as getServerSideProps } from 'nextjs/getServerSideProps/multichain'; diff --git a/pages/search-results.tsx b/pages/search-results.tsx index 1e209e30b0..50d070561a 100644 --- a/pages/search-results.tsx +++ b/pages/search-results.tsx @@ -6,9 +6,16 @@ import type { NextPageWithLayout } from 'nextjs/types'; import type { Props } from 'nextjs/getServerSideProps/handlers'; import PageNextJs from 'nextjs/PageNextJs'; +import config from 'configs/app'; import LayoutSearchResults from 'ui/shared/layout/LayoutSearchResults'; -const SearchResults = dynamic(() => import('ui/pages/SearchResults'), { ssr: false }); +const SearchResults = dynamic(() => { + if (config.features.opSuperchain.isEnabled) { + return import('ui/optimismSuperchain/searchResults/SearchResults'); + } + + return import('ui/pages/SearchResults'); +}, { ssr: false }); const Page: NextPageWithLayout = (props: Props) => { return ( diff --git a/pages/stats/[id].tsx b/pages/stats/[id].tsx index 964da55849..abfb131616 100644 --- a/pages/stats/[id].tsx +++ b/pages/stats/[id].tsx @@ -10,6 +10,7 @@ import detectBotRequest from 'nextjs/utils/detectBotRequest'; import fetchApi from 'nextjs/utils/fetchApi'; import config from 'configs/app'; +import { MultichainProvider } from 'lib/contexts/multichain'; import dayjs from 'lib/date/dayjs'; import getQueryParamString from 'lib/router/getQueryParamString'; @@ -20,7 +21,13 @@ const pathname: Route['pathname'] = '/stats/[id]'; const Page: NextPage> = (props: Props) => { return ( - + { config.features.opSuperchain.isEnabled ? ( + + + + ) : ( + + ) } ); }; diff --git a/pages/stats/index.tsx b/pages/stats/index.tsx index 42f912b020..cd464aab02 100644 --- a/pages/stats/index.tsx +++ b/pages/stats/index.tsx @@ -3,12 +3,14 @@ import React from 'react'; import PageNextJs from 'nextjs/PageNextJs'; +import config from 'configs/app'; +import OpSuperchainStats from 'ui/optimismSuperchain/stats/OpSuperchainStats'; import Stats from 'ui/pages/Stats'; const Page: NextPage = () => { return ( - + { config.features.opSuperchain.isEnabled ? : } ); }; diff --git a/pages/token/[hash]/index.tsx b/pages/token/[hash]/index.tsx index 98ab1a91fb..ca28c5da26 100644 --- a/pages/token/[hash]/index.tsx +++ b/pages/token/[hash]/index.tsx @@ -10,7 +10,6 @@ import fetchApi from 'nextjs/utils/fetchApi'; import config from 'configs/app'; import getQueryParamString from 'lib/router/getQueryParamString'; -import OpSuperchainToken from 'ui/optimismSuperchain/token/OpSuperchainToken'; import Token from 'ui/pages/Token'; const pathname: Route['pathname'] = '/token/[hash]'; @@ -18,7 +17,7 @@ const pathname: Route['pathname'] = '/token/[hash]'; const Page: NextPage> = (props: Props) => { return ( - { config.features.opSuperchain.isEnabled ? : } + ); }; @@ -26,7 +25,7 @@ const Page: NextPage> = (props: Props) = export default Page; export const getServerSideProps: GetServerSideProps> = async(ctx) => { - const baseResponse = await gSSP.base(ctx); + const baseResponse = await gSSP.token(ctx); if ('props' in baseResponse && !config.features.opSuperchain.isEnabled) { if ( diff --git a/pages/token/[hash]/instance/[id].tsx b/pages/token/[hash]/instance/[id].tsx index 66259c3925..5d88d2ab3d 100644 --- a/pages/token/[hash]/instance/[id].tsx +++ b/pages/token/[hash]/instance/[id].tsx @@ -25,7 +25,7 @@ const Page: NextPage> = (props: Props) = export default Page; export const getServerSideProps: GetServerSideProps> = async(ctx) => { - const baseResponse = await gSSP.base(ctx); + const baseResponse = await gSSP.token(ctx); if (config.meta.og.enhancedDataEnabled && 'props' in baseResponse) { const botInfo = detectBotRequest(ctx.req); diff --git a/pages/tx/[hash].tsx b/pages/tx/[hash].tsx index e62415a502..2c1eed9a1c 100644 --- a/pages/tx/[hash].tsx +++ b/pages/tx/[hash].tsx @@ -5,13 +5,7 @@ import React from 'react'; import type { Props } from 'nextjs/getServerSideProps/handlers'; import PageNextJs from 'nextjs/PageNextJs'; -import config from 'configs/app'; - const Transaction = dynamic(() => { - if (config.features.opSuperchain.isEnabled) { - return import('ui/optimismSuperchain/tx/OpSuperchainTx'); - } - return import('ui/pages/Transaction'); }, { ssr: false }); @@ -25,4 +19,4 @@ const Page: NextPage = (props: Props) => { export default Page; -export { base as getServerSideProps } from 'nextjs/getServerSideProps/main'; +export { tx as getServerSideProps } from 'nextjs/getServerSideProps/main'; diff --git a/playwright/fixtures/mockEnvs.ts b/playwright/fixtures/mockEnvs.ts index 51547fa402..b0697d0df9 100644 --- a/playwright/fixtures/mockEnvs.ts +++ b/playwright/fixtures/mockEnvs.ts @@ -114,7 +114,10 @@ export const ENVS_MAP: Record> = { [ 'NEXT_PUBLIC_CELO_ENABLED', 'true' ], ], opSuperchain: [ - [ 'NEXT_PUBLIC_OP_SUPERCHAIN_ENABLED', 'true' ], + [ 'NEXT_PUBLIC_MULTICHAIN_ENABLED', 'true' ], + [ 'NEXT_PUBLIC_MULTICHAIN_CLUSTER', 'test' ], + [ 'NEXT_PUBLIC_MULTICHAIN_AGGREGATOR_API_HOST', 'http://localhost:3012' ], + [ 'NEXT_PUBLIC_MULTICHAIN_STATS_API_HOST', 'http://localhost:3013' ], ], clusters: [ [ 'NEXT_PUBLIC_CLUSTERS_API_HOST', 'https://api.clusters.xyz' ], diff --git a/playwright/fixtures/mockMultichainConfig.ts b/playwright/fixtures/mockMultichainConfig.ts index a13aea800f..690675fea0 100644 --- a/playwright/fixtures/mockMultichainConfig.ts +++ b/playwright/fixtures/mockMultichainConfig.ts @@ -10,6 +10,8 @@ const fixture: TestFixture = async( window.__multichainConfig = { chains: [ opSuperchainMock.chainDataA, + opSuperchainMock.chainDataB, + opSuperchainMock.chainDataC, ], }; }, [ opSuperchainMock ]); diff --git a/public/static/coming-soon.svg b/public/static/coming-soon.svg new file mode 100644 index 0000000000..e596c10074 --- /dev/null +++ b/public/static/coming-soon.svg @@ -0,0 +1,15 @@ + + + + + + + + + + + + + + + diff --git a/stubs/optimismSuperchain.ts b/stubs/optimismSuperchain.ts index 7f1490fbdf..a307c12e3d 100644 --- a/stubs/optimismSuperchain.ts +++ b/stubs/optimismSuperchain.ts @@ -1,23 +1,73 @@ -import * as multichain from '@blockscout/multichain-aggregator-types'; +import type * as multichain from '@blockscout/multichain-aggregator-types'; +import type * as stats from '@blockscout/stats-types'; +import type { AddressTokenItem } from 'types/client/multichain-aggregator'; import { ADDRESS_HASH } from './addressParams'; -import { TX_HASH } from './tx'; -export const INTEROP_MESSAGE: multichain.InteropMessage = { - sender: { - hash: ADDRESS_HASH, +export const ADDRESS: multichain.GetAddressResponse = { + hash: ADDRESS_HASH, + chain_infos: { + '420120000': { + coin_balance: '1000000000000000000000000', + is_contract: true, + is_verified: true, + }, }, - target: { - hash: ADDRESS_HASH, + has_tokens: true, + has_interop_message_transfers: false, + coin_balance: '1000000000000000000000000', + exchange_rate: '123.456', +}; + +export const TOKEN: AddressTokenItem = { + token: { + address_hash: ADDRESS_HASH, + circulating_market_cap: '10.01', + decimals: '6', + holders_count: '141268', + icon_url: null, + name: 'SR2USD', + symbol: 'sR2USD', + total_supply: '943969542711126', + type: 'ERC-20' as const, + exchange_rate: '123.456', + reputation: null, + chain_infos: { + '11155111': { + holders_count: '141268', + total_supply: '943969542711126', + is_verified: true, + }, + }, + }, + token_id: null, + token_instance: null, + value: '2860471393', + chain_values: { + '11155111': '2860471393', + }, +}; + +export const HOMEPAGE_STATS: stats.MainPageMultichainStats = { + total_multichain_txns: { + id: 'totalMultichainTxns', + value: '741682908', + title: 'Total transactions', + units: undefined, + description: 'Number of transactions across all chains in the cluster', + }, + total_multichain_addresses: { + id: 'totalMultichainAddresses', + value: '153519638', + title: 'Total addresses', + units: undefined, + description: 'Number of addresses across all chains in the cluster', + }, + yesterday_txns_multichain: { + id: 'yesterdayTxnsMultichain', + value: '1026175', + title: 'Yesterday txns', + units: undefined, + description: 'Number of transactions yesterday (0:00 - 23:59 UTC) across all chains in the cluste', }, - nonce: 4261, - init_chain_id: '420120000', - init_transaction_hash: TX_HASH, - timestamp: '2025-06-03T10:43:58.000Z', - relay_chain_id: '420120001', - relay_transaction_hash: TX_HASH, - payload: '0x4f0edcc90000000000000000000000004', - message_type: 'coin_transfer', - method: 'sendERC20', - status: multichain.InteropMessage_Status.PENDING, }; diff --git a/toolkit/chakra/collapsible.tsx b/toolkit/chakra/collapsible.tsx index cc9c82e7c0..2ffb9529b3 100644 --- a/toolkit/chakra/collapsible.tsx +++ b/toolkit/chakra/collapsible.tsx @@ -65,12 +65,13 @@ interface CollapsibleListProps extends FlexProps { renderItem: (item: T, index: number) => React.ReactNode; triggerProps?: LinkProps; cutLength?: number; + text?: [string, string]; } export const CollapsibleList = (props: CollapsibleListProps) => { const CUT_LENGTH = 3; - const { items, renderItem, triggerProps, cutLength = CUT_LENGTH, ...rest } = props; + const { items, renderItem, triggerProps, cutLength = CUT_LENGTH, text: textProp, ...rest } = props; const [ isExpanded, setIsExpanded ] = React.useState(false); @@ -78,6 +79,8 @@ export const CollapsibleList = (props: CollapsibleListProps) => { setIsExpanded((flag) => !flag); }, []); + const text = isExpanded ? (textProp?.[1] ?? 'Hide') : (textProp?.[0] ?? 'Show all'); + return ( { items.slice(0, isExpanded ? undefined : cutLength).map(renderItem) } @@ -91,7 +94,7 @@ export const CollapsibleList = (props: CollapsibleListProps) => { onClick={ handleToggle } { ...triggerProps } > - { isExpanded ? 'Hide' : 'Show all' } + { text } ) } diff --git a/toolkit/chakra/select.tsx b/toolkit/chakra/select.tsx index 4212bbd19a..ef930310da 100644 --- a/toolkit/chakra/select.tsx +++ b/toolkit/chakra/select.tsx @@ -101,11 +101,11 @@ export const SelectItem = React.forwardRef< >(function SelectItem(props, ref) { const { item, children, ...rest } = props; - const startElement = item.icon; + const { icon, ...itemProps } = item; return ( - - { startElement } + + { icon } { children } diff --git a/toolkit/components/AdaptiveTabs/AdaptiveTabsList.tsx b/toolkit/components/AdaptiveTabs/AdaptiveTabsList.tsx index f330ac76a7..f4658611f6 100644 --- a/toolkit/components/AdaptiveTabs/AdaptiveTabsList.tsx +++ b/toolkit/components/AdaptiveTabs/AdaptiveTabsList.tsx @@ -96,7 +96,7 @@ const AdaptiveTabsList = (props: Props) => { flexWrap="nowrap" alignItems="center" whiteSpace="nowrap" - bgColor={{ _light: 'white', _dark: 'black' }} + bgColor="bg.primary" marginBottom={ 6 } mx={{ base: '-12px', lg: 'unset' }} px={{ base: '12px', lg: 'unset' }} diff --git a/toolkit/components/RoutedTabs/RoutedTabs.tsx b/toolkit/components/RoutedTabs/RoutedTabs.tsx index 1b022d607d..fb4d234a0d 100644 --- a/toolkit/components/RoutedTabs/RoutedTabs.tsx +++ b/toolkit/components/RoutedTabs/RoutedTabs.tsx @@ -13,10 +13,10 @@ interface Props extends AdaptiveTabsProps { } const RoutedTabs = (props: Props) => { - const { tabs, onValueChange, preservedParams, ...rest } = props; + const { tabs, defaultTabId, onValueChange, preservedParams, ...rest } = props; const router = useRouter(); - const activeTab = useActiveTabFromQuery(props.tabs, props.defaultTabId); + const activeTab = useActiveTabFromQuery(tabs, defaultTabId); const tabsRef = React.useRef(null); const handleValueChange = React.useCallback(({ value }: { value: string }) => { diff --git a/toolkit/components/charts/Chart.tsx b/toolkit/components/charts/Chart.tsx index 08fe42acbf..cf37d14e17 100644 --- a/toolkit/components/charts/Chart.tsx +++ b/toolkit/components/charts/Chart.tsx @@ -18,7 +18,6 @@ import { useTimeChartController } from './utils/useTimeChartController'; export interface ChartProps { charts: TimeChartData; - title: string; zoomRange?: [ Date, Date ]; onZoom: (range: [ Date, Date ]) => void; margin?: ChartMargin; diff --git a/toolkit/components/charts/ChartFullscreenDialog.tsx b/toolkit/components/charts/ChartFullscreenDialog.tsx index 2c44be1b40..8a2444ad2c 100644 --- a/toolkit/components/charts/ChartFullscreenDialog.tsx +++ b/toolkit/components/charts/ChartFullscreenDialog.tsx @@ -78,7 +78,6 @@ const FullscreenChartModal = ({ charts={ charts } handleZoom={ handleZoom } zoomRange={ zoomRange } - title={ title } resolution={ resolution } /> diff --git a/toolkit/components/charts/ChartWidget.tsx b/toolkit/components/charts/ChartWidget.tsx index 6f8e7019e8..b4f4500418 100644 --- a/toolkit/components/charts/ChartWidget.tsx +++ b/toolkit/components/charts/ChartWidget.tsx @@ -94,7 +94,6 @@ export const ChartWidget = React.memo(({ charts={ displayedCharts } isError={ isError } isLoading={ isLoading } - title={ title } empty={ !hasNonEmptyCharts } emptyText={ emptyText } handleZoom={ handleZoom } diff --git a/toolkit/components/charts/ChartWidgetContent.tsx b/toolkit/components/charts/ChartWidgetContent.tsx index 3caf4404eb..7ca08e32cf 100644 --- a/toolkit/components/charts/ChartWidgetContent.tsx +++ b/toolkit/components/charts/ChartWidgetContent.tsx @@ -11,7 +11,6 @@ import { ChartWatermark } from './parts/ChartWatermark'; export interface ChartWidgetContentProps { charts: TimeChartData; - title: string; isLoading?: boolean; isError?: boolean; empty?: boolean; @@ -27,7 +26,6 @@ export interface ChartWidgetContentProps { export const ChartWidgetContent = React.memo(({ charts, - title, isLoading, isError, empty, @@ -78,7 +76,6 @@ export const ChartWidgetContent = React.memo(({ charts={ charts } zoomRange={ zoomRange } onZoom={ handleZoom } - title={ title } isEnlarged={ isEnlarged } noAnimation={ noAnimation } resolution={ resolution } diff --git a/toolkit/components/charts/parts/ChartMenu.tsx b/toolkit/components/charts/parts/ChartMenu.tsx index f0ab9e3f97..a6c04b0dd0 100644 --- a/toolkit/components/charts/parts/ChartMenu.tsx +++ b/toolkit/components/charts/parts/ChartMenu.tsx @@ -12,6 +12,7 @@ import CsvIcon from 'icons/files/csv.svg'; import ImageIcon from 'icons/files/image.svg'; import ScopeIcon from 'icons/scope.svg'; import ShareIcon from 'icons/share.svg'; +import { useMultichainContext } from 'lib/contexts/multichain'; import { useColorModeValue } from '../../../chakra/color-mode'; import { IconButton } from '../../../chakra/icon-button'; @@ -66,6 +67,12 @@ const ChartMenu = ({ const isInBrowser = isBrowser(); + const multichainContext = useMultichainContext(); + + const chainPostfix = React.useMemo(() => { + return multichainContext?.chain.name ? ` on ${ multichainContext.chain.name }` : ''; + }, [ multichainContext?.chain.name ]); + const showChartFullscreen = React.useCallback(() => { fullscreenDialog.onOpenChange({ open: true }); }, [ fullscreenDialog ]); @@ -89,14 +96,14 @@ const ChartMenu = ({ }) .then((dataUrl) => { const link = document.createElement('a'); - link.download = `${ title } (Blockscout chart).png`; + link.download = `${ title }${ chainPostfix } (Blockscout chart).png`; link.href = dataUrl; link.click(); link.remove(); }); } }, 100); - }, [ pngBackgroundColor, title, chartRef ]); + }, [ pngBackgroundColor, title, chainPostfix, chartRef ]); const handleSVGSavingClick = React.useCallback(() => { const headerRows = [ @@ -106,8 +113,8 @@ const ChartMenu = ({ item.dateLabel ?? dayjs(item.date).format('YYYY-MM-DD'), ...charts.map((chart) => String(chart.items[index].value)), ]); - saveAsCsv(headerRows, dataRows, `${ title } (Blockscout stats)`); - }, [ charts, title ]); + saveAsCsv(headerRows, dataRows, `${ title }${ chainPostfix } (Blockscout stats)`); + }, [ charts, title, chainPostfix ]); // TS thinks window.navigator.share can't be undefined, but it can // eslint-disable-next-line @typescript-eslint/no-explicit-any diff --git a/tools/scripts/dev.preset.sh b/tools/scripts/dev.preset.sh index b460b47d31..b4c7a8983e 100755 --- a/tools/scripts/dev.preset.sh +++ b/tools/scripts/dev.preset.sh @@ -29,13 +29,13 @@ dotenv \ if [[ "$preset_name" == "optimism_superchain" ]]; then dotenv \ -e $config_file \ - -- bash -c 'cd deploy/tools/multichain-config-generator && yarn install --silent && yarn build && yarn generate' + -- bash -c 'cd deploy/tools/multichain-config-generator && yarn install --silent && yarn build && yarn generate' || exit 1 fi # generate essential dapps chains config if marketplace essential dapps enabled dotenv \ -e $config_file \ - -- bash -c 'cd deploy/tools/essential-dapps-chains-config-generator && yarn install --silent && yarn build && yarn generate' + -- bash -c 'cd deploy/tools/essential-dapps-chains-config-generator && yarn install --silent && yarn build && yarn generate' || exit 1 source ./deploy/scripts/build_sprite.sh echo "" diff --git a/types/client/crossChainInfo.ts b/types/client/crossChainInfo.ts deleted file mode 100644 index 3f08abdd33..0000000000 --- a/types/client/crossChainInfo.ts +++ /dev/null @@ -1,10 +0,0 @@ -// chain info for external chains (not in the config) -// eg. zetachain cctx, interop txs, etc. -export type CrossChainInfo = { - 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/marketplace.ts b/types/client/marketplace.ts index a18658621c..01467be05f 100644 --- a/types/client/marketplace.ts +++ b/types/client/marketplace.ts @@ -1,3 +1,9 @@ +import type { Chain } from 'viem'; + +import type { ExternalChain } from 'types/externalChains'; + +import type config from 'configs/app'; + export type MarketplaceAppBase = { id: string; author: string; @@ -34,6 +40,13 @@ export enum MarketplaceCategory { FAVORITES = 'Favorites', } +export interface EssentialDappsChainConfig extends ExternalChain { + app_config?: Pick & { + apis: Pick; + }; + contracts?: Chain['contracts']; +} + export type EssentialDappsConfig = { swap?: { chains: Array; diff --git a/types/client/multichain-aggregator.ts b/types/client/multichain-aggregator.ts new file mode 100644 index 0000000000..0ea4e860d7 --- /dev/null +++ b/types/client/multichain-aggregator.ts @@ -0,0 +1,90 @@ +import type * as bens from '@blockscout/bens-types'; +import type * as multichain from '@blockscout/multichain-aggregator-types'; +import type { TokenType } from 'types/api/token'; + +// ts-proto generates the wrong token types for AggregatedTokenInfo +// moreover, the default values of the fields (= undefined) cannot be stripped off from the generated types +// so we need to manually re-define the type to match it with core API token info type +export interface AggregatedTokenInfo extends Pick { + type: TokenType; + circulating_market_cap: string | null; + decimals: string | null; + holders_count: string | null; + name: string | null; + symbol: string | null; + total_supply: string | null; + exchange_rate: string | null; + icon_url: string | null; + reputation: null; +} + +export interface AddressTokenItem extends Omit { + token: AggregatedTokenInfo; + token_id: string | null; + token_instance: null; +} + +export interface AddressTokensResponse extends Omit { + items: Array; +} + +export interface TokensResponse extends Omit { + items: Array; +} + +// types for quick search results +export type QuickSearchResultBlock = { + type: 'block'; + block_number: string; + block_hash: undefined; + chain_id: string; +} | { + type: 'block'; + block_number: undefined; + block_hash: string; + chain_id: string; +}; + +export interface QuickSearchResultTransaction { + type: 'transaction'; + transaction_hash: string; + chain_id: string; +} + +export interface QuickSearchResultAddress { + type: 'address'; + address_hash: string; + chain_infos: Record; +} + +export interface QuickSearchResultToken { + type: 'token'; + token_type: 'ERC-20' | 'ERC-721' | 'ERC-1155'; + name: string; + symbol: string; + address_hash: string; + icon_url: string | null; + is_smart_contract_verified: boolean; + chain_id: string; + reputation: null; + total_supply: string | null; + exchange_rate: string | null; + chain_infos: Record; +} + +export interface QuickSearchResultDomain { + type: 'ens_domain'; + ens_info: { + address_hash: string; + expiry_date?: string; + name: string; + protocol?: bens.ProtocolInfo; + }; + address_hash: string; +} + +export type QuickSearchResultItem = QuickSearchResultBlock | +QuickSearchResultTransaction | +QuickSearchResultAddress | +QuickSearchResultToken | +QuickSearchResultDomain; diff --git a/types/client/search.ts b/types/client/search.ts index 940c9d72ce..ef63c2d885 100644 --- a/types/client/search.ts +++ b/types/client/search.ts @@ -1,5 +1,6 @@ import type { CctxListItem } from '@blockscout/zetachain-cctx-types'; import type * as api from 'types/api/search'; +import type * as multichain from 'types/client/multichain-aggregator'; export interface SearchResultFutureBlock { type: 'block'; @@ -18,3 +19,5 @@ export interface SearchResultZetaChainCCTX { } export type SearchResultItem = api.SearchResultItem | SearchResultBlock | SearchResultZetaChainCCTX; + +export type QuickSearchResultItem = multichain.QuickSearchResultItem | SearchResultItem; diff --git a/types/client/zetaChain.ts b/types/client/zetaChain.ts index f3bf583512..07c07280d5 100644 --- a/types/client/zetaChain.ts +++ b/types/client/zetaChain.ts @@ -1,5 +1,7 @@ import type { AdvancedFilterAge } from 'types/api/advancedFilter'; +import type { ExternalChain } from '../externalChains'; + export const ZETA_CHAIN_CCTX_STATUS_REDUCED_FILTERS = [ 'Success', 'Pending', 'Failed' ] as const; export type StatusReducedFilters = typeof ZETA_CHAIN_CCTX_STATUS_REDUCED_FILTERS[number]; @@ -19,3 +21,20 @@ export type ZetaChainCCTXFilterParams = { coin_type?: Array | CoinTypeFilter; hash?: string; }; + +export interface ZetaChainChainsConfigEnv { + chain_id: number; + chain_name: string | null; + chain_logo?: string | null; + instance_url?: string; + address_url_template?: string; + tx_url_template?: string; +} + +export type ZetaChainExternalChainConfig = ExternalChain | { + id: string; + name: string; + logo: string | undefined; + address_url_template?: string; + tx_url_template?: string; +}; diff --git a/types/externalChains.ts b/types/externalChains.ts new file mode 100644 index 0000000000..0a7f5e1f0f --- /dev/null +++ b/types/externalChains.ts @@ -0,0 +1,12 @@ +import type { EssentialDappsChainConfig } from 'types/client/marketplace'; +import type { ClusterChainConfig } from 'types/multichain'; + +// minimal set of fields for external chains +export interface ExternalChain { + id: string; + name: string; + logo: string | undefined; + explorer_url: string; +} + +export type ExternalChainExtended = ClusterChainConfig | EssentialDappsChainConfig; diff --git a/types/multichain.ts b/types/multichain.ts index b3cf4cb2ce..af94ad9bb3 100644 --- a/types/multichain.ts +++ b/types/multichain.ts @@ -1,16 +1,12 @@ -import type { Chain } from 'viem'; +import type { ExternalChain } from 'types/externalChains'; import type config from 'configs/app'; -export interface ChainConfig { - // TODO @tom2drum make optional +export interface ClusterChainConfig extends ExternalChain { slug: string; - // TODO @tom2drum make partial - // TODO @tom2drum make chain id primary key - config: typeof config; - contracts?: Chain['contracts']; + app_config: typeof config; } export interface MultichainConfig { - chains: Array; + chains: Array; } diff --git a/ui/address/AddressAdvancedFilterLink.tsx b/ui/address/AddressAdvancedFilterLink.tsx index e0c35afc97..21acd11c15 100644 --- a/ui/address/AddressAdvancedFilterLink.tsx +++ b/ui/address/AddressAdvancedFilterLink.tsx @@ -4,7 +4,7 @@ import React from 'react'; import type { AddressFromToFilter } from 'types/api/address'; import { ADVANCED_FILTER_TYPES } from 'types/api/advancedFilter'; import type { TokenType } from 'types/api/token'; -import type { ChainConfig } from 'types/multichain'; +import type { ClusterChainConfig } from 'types/multichain'; import config from 'configs/app'; import { useMultichainContext } from 'lib/contexts/multichain'; @@ -16,16 +16,16 @@ interface Props { address: string; typeFilter: Array; directionFilter: AddressFromToFilter; - chainData?: ChainConfig; + chainData?: ClusterChainConfig; } const AddressAdvancedFilterLink = ({ isLoading, address, typeFilter, directionFilter, chainData }: Props) => { const isInitialLoading = useIsInitialLoading(isLoading); const multichainContext = useMultichainContext(); - const chainConfig = chainData?.config || multichainContext?.chain.config || config; + const chainConfig = chainData?.app_config || multichainContext?.chain.app_config || config; - if (!chainConfig.features.advancedFilter.isEnabled) { + if (!chainConfig?.features?.advancedFilter.isEnabled) { return null; } @@ -35,12 +35,12 @@ const AddressAdvancedFilterLink = ({ isLoading, address, typeFilter, directionFi transaction_types: typeFilter.length > 0 ? typeFilter : ADVANCED_FILTER_TYPES.filter((type) => type !== 'coin_transfer'), }, (value) => value !== undefined); - const linkContext = (chainData ? { chain: chainData } : undefined) ?? multichainContext; + const routeParams = (chainData ? { chain: chainData } : undefined) ?? multichainContext; return ( ); diff --git a/ui/address/AddressContract.tsx b/ui/address/AddressContract.tsx index 8b98f80f01..12b585e1c5 100644 --- a/ui/address/AddressContract.tsx +++ b/ui/address/AddressContract.tsx @@ -11,6 +11,7 @@ import useIsMobile from 'lib/hooks/useIsMobile'; import getQueryParamString from 'lib/router/getQueryParamString'; import useSocketChannel from 'lib/socket/useSocketChannel'; import useSocketMessage from 'lib/socket/useSocketMessage'; +import type { Props as RoutedTabsProps } from 'toolkit/components/AdaptiveTabs/AdaptiveTabs'; import RoutedTabs from 'toolkit/components/RoutedTabs/RoutedTabs'; import { SECOND } from 'toolkit/utils/consts'; @@ -19,13 +20,13 @@ import ContractAutoVerificationStatus from './contract/ContractAutoVerificationS import useContractTabs from './contract/useContractTabs'; import { CONTRACT_TAB_IDS } from './contract/utils'; -interface Props { +interface Props extends Pick { addressData: Address | undefined; isLoading?: boolean; hasMudTab?: boolean; } -const AddressContract = ({ addressData, isLoading = false, hasMudTab }: Props) => { +const AddressContract = ({ addressData, isLoading = false, hasMudTab, ...rest }: Props) => { const [ isQueryEnabled, setIsQueryEnabled ] = React.useState(false); const [ autoVerificationStatus, setAutoVerificationStatus ] = React.useState(null); @@ -97,6 +98,7 @@ const AddressContract = ({ addressData, isLoading = false, hasMudTab }: Props) = isLoading={ contractTabs.isLoading } rightSlot={ rightSlot } rightSlotProps={{ ml: contractTabs.tabs.length > 1 ? { base: 'auto', md: 6 } : 0 }} + { ...rest } /> ); }; diff --git a/ui/address/AddressCsvExportLink.tsx b/ui/address/AddressCsvExportLink.tsx index 46bb2aa22c..1cc6994602 100644 --- a/ui/address/AddressCsvExportLink.tsx +++ b/ui/address/AddressCsvExportLink.tsx @@ -2,7 +2,7 @@ import { chakra } from '@chakra-ui/react'; import React from 'react'; import type { CsvExportParams } from 'types/client/address'; -import type { ChainConfig } from 'types/multichain'; +import type { ClusterChainConfig } from 'types/multichain'; import { route } from 'nextjs/routes'; @@ -19,7 +19,7 @@ interface Props { params: CsvExportParams; className?: string; isLoading?: boolean; - chainData?: ChainConfig; + chainData?: ClusterChainConfig; } const AddressCsvExportLink = ({ className, address, params, isLoading, chainData }: Props) => { @@ -27,20 +27,18 @@ const AddressCsvExportLink = ({ className, address, params, isLoading, chainData const isInitialLoading = useIsInitialLoading(isLoading); const multichainContext = useMultichainContext(); - const chainConfig = chainData?.config || multichainContext?.chain.config || config; + const chainConfig = chainData?.app_config || multichainContext?.chain.app_config || config; if (!chainConfig.features.csvExport.isEnabled) { return null; } - const linkContext = (chainData ? { chain: chainData } : undefined) ?? multichainContext; - return ( const hasActiveFilters = Boolean(nftTokenTypes?.length); const tabs = [ - { id: 'tokens_erc20', title: `${ config.chain.tokenStandard }-20`, component: }, + { + id: 'tokens_erc20', + title: `${ config.chain.tokenStandard }-20`, + component: ( + + ), + }, { id: 'tokens_nfts', title: 'NFTs', diff --git a/ui/address/coinBalance/AddressCoinBalanceHistory.tsx b/ui/address/coinBalance/AddressCoinBalanceHistory.tsx index 58686ea40d..a74e32ffc7 100644 --- a/ui/address/coinBalance/AddressCoinBalanceHistory.tsx +++ b/ui/address/coinBalance/AddressCoinBalanceHistory.tsx @@ -7,7 +7,6 @@ import type { PaginationParams } from 'ui/shared/pagination/types'; import type { ResourceError } from 'lib/api/resources'; import { useMultichainContext } from 'lib/contexts/multichain'; -import { getChainDataForList } from 'lib/multichain/getChainDataForList'; import { currencyUnits } from 'lib/units'; import { TableBody, TableColumnHeader, TableHeaderSticky, TableRoot, TableRow } from 'toolkit/chakra/table'; import ActionBar, { ACTION_BAR_HEIGHT_DESKTOP } from 'ui/shared/ActionBar'; @@ -26,7 +25,7 @@ interface Props { const AddressCoinBalanceHistory = ({ query }: Props) => { const multichainContext = useMultichainContext(); - const chainData = getChainDataForList(multichainContext); + const chainData = multichainContext?.chain; const content = query.data?.items ? ( <> diff --git a/ui/address/coinBalance/AddressCoinBalanceListItem.tsx b/ui/address/coinBalance/AddressCoinBalanceListItem.tsx index 726d652351..c478b50968 100644 --- a/ui/address/coinBalance/AddressCoinBalanceListItem.tsx +++ b/ui/address/coinBalance/AddressCoinBalanceListItem.tsx @@ -3,7 +3,7 @@ import BigNumber from 'bignumber.js'; import React from 'react'; import type { AddressCoinBalanceHistoryItem } from 'types/api/address'; -import type { ChainConfig } from 'types/multichain'; +import type { ClusterChainConfig } from 'types/multichain'; import { currencyUnits } from 'lib/units'; import { Skeleton } from 'toolkit/chakra/skeleton'; @@ -16,7 +16,7 @@ import TimeWithTooltip from 'ui/shared/time/TimeWithTooltip'; type Props = AddressCoinBalanceHistoryItem & { page: number; isLoading: boolean; - chainData?: ChainConfig; + chainData?: ClusterChainConfig; }; const AddressCoinBalanceListItem = (props: Props) => { diff --git a/ui/address/coinBalance/AddressCoinBalanceTableItem.tsx b/ui/address/coinBalance/AddressCoinBalanceTableItem.tsx index 912d2bfaf0..f5fda8956c 100644 --- a/ui/address/coinBalance/AddressCoinBalanceTableItem.tsx +++ b/ui/address/coinBalance/AddressCoinBalanceTableItem.tsx @@ -3,20 +3,20 @@ import BigNumber from 'bignumber.js'; import React from 'react'; import type { AddressCoinBalanceHistoryItem } from 'types/api/address'; -import type { ChainConfig } from 'types/multichain'; +import type { ClusterChainConfig } from 'types/multichain'; import { Skeleton } from 'toolkit/chakra/skeleton'; import { TableCell, TableRow } from 'toolkit/chakra/table'; import { WEI, ZERO } from 'toolkit/utils/consts'; -import ChainIcon from 'ui/optimismSuperchain/components/ChainIcon'; import BlockEntity from 'ui/shared/entities/block/BlockEntity'; import TxEntity from 'ui/shared/entities/tx/TxEntity'; +import ChainIcon from 'ui/shared/externalChains/ChainIcon'; import TimeWithTooltip from 'ui/shared/time/TimeWithTooltip'; type Props = AddressCoinBalanceHistoryItem & { page: number; isLoading: boolean; - chainData?: ChainConfig; + chainData?: ClusterChainConfig; }; const AddressCoinBalanceTableItem = (props: Props) => { diff --git a/ui/address/contract/ContractDetailsVerificationButton.tsx b/ui/address/contract/ContractDetailsVerificationButton.tsx index 111633fa9f..e69303c3a6 100644 --- a/ui/address/contract/ContractDetailsVerificationButton.tsx +++ b/ui/address/contract/ContractDetailsVerificationButton.tsx @@ -3,6 +3,7 @@ import React from 'react'; import { route } from 'nextjs-routes'; import config from 'configs/app'; +import { useMultichainContext } from 'lib/contexts/multichain'; import { Button } from 'toolkit/chakra/button'; import type { LinkProps } from 'toolkit/chakra/link'; import { Link } from 'toolkit/chakra/link'; @@ -14,10 +15,16 @@ interface Props extends LinkProps { const ContractDetailsVerificationButton = ({ isLoading, addressHash, ...rest }: Props) => { - const href = config.features.opSuperchain.isEnabled ? - // TODO @tom2drum adjust URL to Vera - 'https://vera.blockscout.com' : - route({ pathname: '/address/[hash]/contract-verification', query: { hash: addressHash } }); + const multichainContext = useMultichainContext(); + + const href = (() => { + if (multichainContext) { + const searchParams = new URLSearchParams(); + searchParams.set('contracts', `${ multichainContext.chain.id }:${ addressHash }`); + return `https://vera.blockscout.com?${ searchParams.toString() }`; + } + return route({ pathname: '/address/[hash]/contract-verification', query: { hash: addressHash } }); + })(); return ( { + const multichainContext = useMultichainContext(); + const editorData = React.useMemo(() => { return getEditorData(data); }, [ data ]); @@ -71,7 +74,7 @@ export const ContractSourceCode = ({ data, isLoading, sourceAddress }: Props) => const diagramLink = data?.can_be_visualized_via_sol2uml ? ( diff --git a/ui/address/contract/info/ContractDetailsInfo.tsx b/ui/address/contract/info/ContractDetailsInfo.tsx index 7fe4aa5214..e4c76421a0 100644 --- a/ui/address/contract/info/ContractDetailsInfo.tsx +++ b/ui/address/contract/info/ContractDetailsInfo.tsx @@ -10,6 +10,7 @@ import { CONTRACT_LICENSES } from 'lib/contracts/licenses'; import dayjs from 'lib/date/dayjs'; import { Link } from 'toolkit/chakra/link'; import { getGitHubOwnerAndRepo } from 'ui/contractVerification/utils'; +import ContainerWithScrollY from 'ui/shared/ContainerWithScrollY'; import ContractCertifiedLabel from 'ui/shared/ContractCertifiedLabel'; import AddressEntity from 'ui/shared/entities/address/AddressEntity'; import TxEntity from 'ui/shared/entities/tx/TxEntity'; @@ -82,7 +83,7 @@ const ContractDetailsInfo = ({ data, isLoading, addressData }: Props) => { { contractNameWithCertifiedIcon } ) } - { multichainContext && multichainContext.level !== 'page' && addressData.creator_address_hash && addressData.creation_transaction_hash && ( + { multichainContext && addressData.creator_address_hash && addressData.creation_transaction_hash && ( { ) } + { !isLoading && multichainContext && addressData.implementations && addressData.implementations.length > 0 && ( + 1 ? 's' : '' }` }` } + isLoading={ isLoading } + contentProps={{ maxW: 'calc(100% - 194px)', position: 'relative' }} + > + + { addressData.implementations.map((item) => ( + + )) } + + + ) } { data.compiler_version && ( { +const ContractDetailsInfoItem = ({ label, children, className, isLoading, hint, contentProps }: Props) => { return ( - + { label } { hint && } - { children } + { children } ); }; diff --git a/ui/address/contract/methods/form/ContractMethodFieldInput.tsx b/ui/address/contract/methods/form/ContractMethodFieldInput.tsx index 2a6a22d1ff..e118313a04 100644 --- a/ui/address/contract/methods/form/ContractMethodFieldInput.tsx +++ b/ui/address/contract/methods/form/ContractMethodFieldInput.tsx @@ -47,7 +47,6 @@ const ContractMethodFieldInput = ({ data, hideLabel, path: name, className, isDi const { control, setValue, getValues } = useFormContext(); const { field, fieldState } = useController({ control, name, rules: { validate } }); - const inputBgColor = { _light: 'white', _dark: 'black' }; const nativeCoinRowBgColor = { _light: 'gray.100', _dark: 'gray.700' }; const hasMultiplyButton = argTypeMatchInt && Number(argTypeMatchInt.power) >= 64; @@ -172,7 +171,7 @@ const ContractMethodFieldInput = ({ data, hideLabel, path: name, className, isDi const inputProps = { ...field, size: 'sm' as const, - bgColor: inputBgColor, + bgColor: 'bg.primary', onChange: handleChange, required: !isOptional, placeholder: data.type, diff --git a/ui/address/contract/methods/useCallMethodPublicClient.ts b/ui/address/contract/methods/useCallMethodPublicClient.ts index dbbb93f7ae..41bfb6b08f 100644 --- a/ui/address/contract/methods/useCallMethodPublicClient.ts +++ b/ui/address/contract/methods/useCallMethodPublicClient.ts @@ -19,7 +19,7 @@ interface Params { export default function useCallMethodPublicClient(): (params: Params) => Promise { const multichainContext = useMultichainContext(); - const chainId = Number((multichainContext?.chain.config ?? config).chain.id); + const chainId = Number((multichainContext?.chain.app_config ?? config).chain?.id); const publicClient = usePublicClient({ chainId }); const { address: account } = useAccount(); diff --git a/ui/address/contract/methods/useCallMethodWalletClient.ts b/ui/address/contract/methods/useCallMethodWalletClient.ts index b6bb669835..a7c50a0c49 100644 --- a/ui/address/contract/methods/useCallMethodWalletClient.ts +++ b/ui/address/contract/methods/useCallMethodWalletClient.ts @@ -18,9 +18,9 @@ interface Params { export default function useCallMethodWalletClient(): (params: Params) => Promise { const multichainContext = useMultichainContext(); - const chainConfig = (multichainContext?.chain.config ?? config).chain; + const chainConfig = (multichainContext?.chain.app_config ?? config).chain; - const { data: walletClient } = useWalletClient({ chainId: Number(chainConfig.id) }); + const { data: walletClient } = useWalletClient({ chainId: Number(chainConfig?.id) }); const { isConnected, chainId, address: account } = useAccount(); const { switchChainAsync } = useSwitchChain(); const { trackTransaction, trackTransactionConfirm } = useRewardsActivity(); @@ -34,8 +34,8 @@ export default function useCallMethodWalletClient(): (params: Params) => Promise throw new Error('Wallet Client is not defined'); } - if (chainId && String(chainId) !== chainConfig.id) { - await switchChainAsync({ chainId: Number(chainConfig.id) }); + if (chainId && String(chainId) !== chainConfig?.id) { + await switchChainAsync({ chainId: Number(chainConfig?.id) }); } const address = getAddress(addressHash); diff --git a/ui/address/contract/useContractTabs.tsx b/ui/address/contract/useContractTabs.tsx index 550b921304..f6eccaafe0 100644 --- a/ui/address/contract/useContractTabs.tsx +++ b/ui/address/contract/useContractTabs.tsx @@ -2,6 +2,7 @@ import type { Channel } from 'phoenix'; import React from 'react'; import type { Address } from 'types/api/address'; +import type { ClusterChainConfig } from 'types/multichain'; import config from 'configs/app'; import useApiQuery from 'lib/api/useApiQuery'; @@ -34,10 +35,10 @@ interface Props { isEnabled: boolean; hasMudTab?: boolean; channel?: Channel; - chainSlug?: string; + chain?: ClusterChainConfig; } -export default function useContractTabs({ addressData, isEnabled, hasMudTab, channel, chainSlug }: Props): ReturnType { +export default function useContractTabs({ addressData, isEnabled, hasMudTab, channel, chain }: Props): ReturnType { const contractQuery = useApiQuery('general:contract', { pathParams: { hash: addressData?.hash }, queryOptions: { @@ -45,7 +46,7 @@ export default function useContractTabs({ addressData, isEnabled, hasMudTab, cha refetchOnMount: false, placeholderData: addressData?.is_verified ? stubs.CONTRACT_CODE_VERIFIED : stubs.CONTRACT_CODE_UNVERIFIED, }, - chainSlug, + chain, }); const mudSystemsQuery = useApiQuery('general:mud_systems', { @@ -62,22 +63,6 @@ export default function useContractTabs({ addressData, isEnabled, hasMudTab, cha }, [ addressData?.hash, addressData?.implementations ]); return React.useMemo(() => { - - // TODO @tom2drum remove this condition once the API will return is_contract flag - if (isEnabled && !addressData?.is_contract) { - return { - tabs: [ - { - id: 'contract_code' as const, - title: 'Code', - component:
Not a contract
, - }, - ], - isLoading: false, - isPartiallyVerified: false, - }; - } - return { tabs: [ addressData && { @@ -120,7 +105,6 @@ export default function useContractTabs({ addressData, isEnabled, hasMudTab, cha }; }, [ addressData, - isEnabled, contractQuery, channel, verifiedImplementations, diff --git a/ui/address/details/AddressCounterItem.tsx b/ui/address/details/AddressCounterItem.tsx index e8445dce15..9e6a0d6fb0 100644 --- a/ui/address/details/AddressCounterItem.tsx +++ b/ui/address/details/AddressCounterItem.tsx @@ -7,7 +7,6 @@ import type { AddressCounters } from 'types/api/address'; import { route } from 'nextjs/routes'; import type { ResourceError } from 'lib/api/resources'; -import { useMultichainContext } from 'lib/contexts/multichain'; import { Link } from 'toolkit/chakra/link'; import { Skeleton } from 'toolkit/chakra/skeleton'; @@ -26,8 +25,6 @@ const PROP_TO_TAB = { }; const AddressCounterItem = ({ prop, query, address, isAddressQueryLoading, isDegradedData }: Props) => { - const multichainContext = useMultichainContext(); - const handleClick = React.useCallback(() => { window.scrollTo({ top: 0, behavior: 'smooth' }); }, []); @@ -58,7 +55,7 @@ const AddressCounterItem = ({ prop, query, address, isAddressQueryLoading, isDeg return ( diff --git a/ui/address/tokenSelect/TokenSelect.tsx b/ui/address/tokenSelect/TokenSelect.tsx index 45ea0299cd..a0e318b885 100644 --- a/ui/address/tokenSelect/TokenSelect.tsx +++ b/ui/address/tokenSelect/TokenSelect.tsx @@ -30,7 +30,7 @@ const TokenSelect = () => { const multichainContext = useMultichainContext(); const addressHash = getQueryParamString(router.query.hash); - const addressResourceKey = getResourceKey('general:address', { pathParams: { hash: addressHash }, chainSlug: multichainContext?.chain?.slug }); + const addressResourceKey = getResourceKey('general:address', { pathParams: { hash: addressHash }, chainId: multichainContext?.chain?.id }); const addressQueryData = queryClient.getQueryData
(addressResourceKey); @@ -38,7 +38,7 @@ const TokenSelect = () => { const tokensResourceKey = getResourceKey('general:address_tokens', { pathParams: { hash: addressQueryData?.hash }, queryParams: { type: 'ERC-20' }, - chainSlug: multichainContext?.chain?.slug, + chainId: multichainContext?.chain?.id, }); const tokensIsFetching = useIsFetching({ queryKey: tokensResourceKey }); @@ -69,7 +69,7 @@ const TokenSelect = () => { } diff --git a/ui/address/tokenSelect/TokenSelectButton.tsx b/ui/address/tokenSelect/TokenSelectButton.tsx index 15a09846ad..2e1dcf0aa2 100644 --- a/ui/address/tokenSelect/TokenSelectButton.tsx +++ b/ui/address/tokenSelect/TokenSelectButton.tsx @@ -13,7 +13,7 @@ import { getTokensTotalInfo } from '../utils/tokenUtils'; interface Props { isOpen: boolean; - isLoading: boolean; + isLoading?: boolean; data: FormattedData; } @@ -64,7 +64,7 @@ const TokenSelectButton = ({ isOpen, isLoading, data, ...rest }: Props, ref: Rea position="absolute" top={ 0 } left={ 0 } - bgColor={{ _light: 'white', _dark: 'black' }} + bgColor="bg.primary" borderRadius="base" /> ) } diff --git a/ui/address/tokenSelect/TokenSelectDesktop.tsx b/ui/address/tokenSelect/TokenSelectDesktop.tsx index 4de14b2b81..bf5ee7f68e 100644 --- a/ui/address/tokenSelect/TokenSelectDesktop.tsx +++ b/ui/address/tokenSelect/TokenSelectDesktop.tsx @@ -11,7 +11,7 @@ import useTokenSelect from './useTokenSelect'; interface Props { data: FormattedData; - isLoading: boolean; + isLoading?: boolean; } const TokenSelectDesktop = ({ data, isLoading }: Props) => { diff --git a/ui/address/tokenSelect/TokenSelectItem.tsx b/ui/address/tokenSelect/TokenSelectItem.tsx index bae1852cc9..ea292f1a0d 100644 --- a/ui/address/tokenSelect/TokenSelectItem.tsx +++ b/ui/address/tokenSelect/TokenSelectItem.tsx @@ -2,9 +2,10 @@ import { chakra, Flex } from '@chakra-ui/react'; import BigNumber from 'bignumber.js'; import React from 'react'; -import { route } from 'nextjs-routes'; +import { route } from 'nextjs/routes'; import config from 'configs/app'; +import multichainConfig from 'configs/multichain'; import getCurrencyValue from 'lib/getCurrencyValue'; import { Link } from 'toolkit/chakra/link'; import NativeTokenTag from 'ui/shared/celo/NativeTokenTag'; @@ -22,6 +23,16 @@ const TokenSelectItem = ({ data }: Props) => { const isNativeToken = config.UI.views.address.nativeTokenAddress && data.token.address_hash.toLowerCase() === config.UI.views.address.nativeTokenAddress.toLowerCase(); + const chain = React.useMemo(() => { + if (!data.chain_values) { + return; + } + + const chainId = Object.keys(data.chain_values)[0]; + const chain = multichainConfig()?.chains.find((chain) => chain.id === chainId); + return chain; + }, [ data.chain_values ]); + const secondRow = (() => { switch (data.token.type) { case 'ERC-20': { @@ -73,7 +84,7 @@ const TokenSelectItem = ({ data }: Props) => { } })(); - const url = route({ pathname: '/token/[hash]', query: { hash: data.token.address_hash } }); + const url = route({ pathname: '/token/[hash]', query: { hash: data.token.address_hash } }, { chain }); return ( { fontSize="sm" href={ url } > - + { diff --git a/ui/address/tokens/AddressCollections.tsx b/ui/address/tokens/AddressCollections.tsx index 1a62f07df0..f7056969fb 100644 --- a/ui/address/tokens/AddressCollections.tsx +++ b/ui/address/tokens/AddressCollections.tsx @@ -52,7 +52,7 @@ const AddressCollections = ({ collectionsQuery, address, tokenTypes, onTokenType holder_address_hash: address, scroll_to_tabs: 'true', }, - }, multichainContext); + }, { chain: multichainContext?.chain }); const hasOverload = Number(item.amount) > item.token_instances.length; return ( @@ -89,6 +89,7 @@ const AddressCollections = ({ collectionsQuery, address, tokenTypes, onTokenType { ...instance } token={ item.token } isLoading={ isPlaceholderData } + chain={ multichainContext?.chain } /> ); }) } diff --git a/ui/address/tokens/ERC20Tokens.tsx b/ui/address/tokens/ERC20Tokens.tsx index fe64c1ef70..fd57335f9a 100644 --- a/ui/address/tokens/ERC20Tokens.tsx +++ b/ui/address/tokens/ERC20Tokens.tsx @@ -1,38 +1,42 @@ import { Box } from '@chakra-ui/react'; import React from 'react'; +import type { AddressTokenBalance } from 'types/api/address'; +import type { PaginationParams } from 'ui/shared/pagination/types'; + import useIsMobile from 'lib/hooks/useIsMobile'; import ActionBar from 'ui/shared/ActionBar'; import DataListDisplay from 'ui/shared/DataListDisplay'; import Pagination from 'ui/shared/pagination/Pagination'; -import type { QueryWithPagesResult } from 'ui/shared/pagination/useQueryWithPages'; import ERC20TokensListItem from './ERC20TokensListItem'; import ERC20TokensTable from './ERC20TokensTable'; type Props = { - tokensQuery: QueryWithPagesResult<'general:address_tokens'>; + items: Array> | undefined; + isLoading: boolean; + pagination: PaginationParams; + isError: boolean; + top?: number; }; -const ERC20Tokens = ({ tokensQuery }: Props) => { +const ERC20Tokens = ({ items, isLoading, pagination, isError, top }: Props) => { const isMobile = useIsMobile(); - const { isError, isPlaceholderData, data, pagination } = tokensQuery; - const actionBar = isMobile && pagination.isVisible && ( ); - const content = data?.items ? ( + const content = items ? ( <> - - { data.items.map((item, index) => ( + + { items.map((item, index) => ( )) } @@ -42,7 +46,7 @@ const ERC20Tokens = ({ tokensQuery }: Props) => { return ( diff --git a/ui/address/tokens/ERC20TokensListItem.tsx b/ui/address/tokens/ERC20TokensListItem.tsx index e459376733..096bffa850 100644 --- a/ui/address/tokens/ERC20TokensListItem.tsx +++ b/ui/address/tokens/ERC20TokensListItem.tsx @@ -1,9 +1,10 @@ import { Flex, HStack } from '@chakra-ui/react'; import React from 'react'; -import type { AddressTokenBalance } from 'types/api/address'; +import type { AddressTokensErc20Item } from './types'; import config from 'configs/app'; +import multichainConfig from 'configs/multichain'; import getCurrencyValue from 'lib/getCurrencyValue'; import { Skeleton } from 'toolkit/chakra/skeleton'; import AddressAddToWallet from 'ui/shared/address/AddressAddToWallet'; @@ -12,9 +13,9 @@ import AddressEntity from 'ui/shared/entities/address/AddressEntity'; import TokenEntity from 'ui/shared/entities/token/TokenEntity'; import ListItemMobile from 'ui/shared/ListItemMobile/ListItemMobile'; -type Props = AddressTokenBalance & { isLoading: boolean }; +type Props = AddressTokensErc20Item & { isLoading: boolean }; -const ERC20TokensListItem = ({ token, value, isLoading }: Props) => { +const ERC20TokensListItem = ({ token, value, isLoading, chain_values: chainValues }: Props) => { const { valueStr: tokenQuantity, @@ -24,11 +25,22 @@ const ERC20TokensListItem = ({ token, value, isLoading }: Props) => { const isNativeToken = config.UI.views.address.nativeTokenAddress && token.address_hash.toLowerCase() === config.UI.views.address.nativeTokenAddress.toLowerCase(); + const chainInfo = React.useMemo(() => { + if (!chainValues) { + return; + } + + const chainId = Object.keys(chainValues)[0]; + const chain = multichainConfig()?.chains.find((chain) => chain.id === chainId); + return chain; + }, [ chainValues ]); + return ( ; + data: Array; top: number; isLoading: boolean; } @@ -26,7 +26,11 @@ const ERC20TokensTable = ({ data, top, isLoading }: Props) => { { data.map((item, index) => ( - + )) } diff --git a/ui/address/tokens/ERC20TokensTableItem.tsx b/ui/address/tokens/ERC20TokensTableItem.tsx index a3a954dd53..b1eca4666b 100644 --- a/ui/address/tokens/ERC20TokensTableItem.tsx +++ b/ui/address/tokens/ERC20TokensTableItem.tsx @@ -1,9 +1,10 @@ import { Flex, HStack } from '@chakra-ui/react'; import React from 'react'; -import type { AddressTokenBalance } from 'types/api/address'; +import type { AddressTokensErc20Item } from './types'; import config from 'configs/app'; +import multichainConfig from 'configs/multichain'; import getCurrencyValue from 'lib/getCurrencyValue'; import { Skeleton } from 'toolkit/chakra/skeleton'; import { TableCell, TableRow } from 'toolkit/chakra/table'; @@ -12,11 +13,12 @@ import NativeTokenTag from 'ui/shared/celo/NativeTokenTag'; import AddressEntity from 'ui/shared/entities/address/AddressEntity'; import TokenEntity from 'ui/shared/entities/token/TokenEntity'; -type Props = AddressTokenBalance & { isLoading: boolean }; +type Props = AddressTokensErc20Item & { isLoading: boolean }; const ERC20TokensTableItem = ({ token, value, + chain_values: chainValues, isLoading, }: Props) => { @@ -28,12 +30,23 @@ const ERC20TokensTableItem = ({ const isNativeToken = config.UI.views.address.nativeTokenAddress && token.address_hash.toLowerCase() === config.UI.views.address.nativeTokenAddress.toLowerCase(); + const chainInfo = React.useMemo(() => { + if (!chainValues) { + return; + } + + const chainId = Object.keys(chainValues)[0]; + const chain = multichainConfig()?.chains.find((chain) => chain.id === chainId); + return chain; + }, [ chainValues ]); + return ( { const valueResult = token.decimals && value ? getCurrencyValue({ value, decimals: token.decimals, accuracy: 2 }).valueStr : value; const tokenInstanceLink = tokenInstance.id ? - route({ pathname: '/token/[hash]/instance/[id]', query: { hash: token.address_hash, id: tokenInstance.id } }) : + route({ pathname: '/token/[hash]/instance/[id]', query: { hash: token.address_hash, id: tokenInstance.id } }, chain ? { chain } : undefined) : undefined; return ( diff --git a/ui/address/tokens/TokenBalancesItem.tsx b/ui/address/tokens/TokenBalancesItem.tsx index 69760f6676..65fa2db71b 100644 --- a/ui/address/tokens/TokenBalancesItem.tsx +++ b/ui/address/tokens/TokenBalancesItem.tsx @@ -9,9 +9,10 @@ type Props = { icon: React.ReactNode; valueSecondary?: string; isLoading: boolean; + contentAfter?: React.ReactNode; }; -const TokenBalancesItem = ({ name, icon, value, valueSecondary, isLoading }: Props) => { +const TokenBalancesItem = ({ name, icon, value, valueSecondary, isLoading, contentAfter }: Props) => { return ( @@ -22,6 +23,7 @@ const TokenBalancesItem = ({ name, icon, value, valueSecondary, isLoading }: Pro { value } { Boolean(valueSecondary) && ({ valueSecondary }) } + { contentAfter } ); diff --git a/ui/address/tokens/types.ts b/ui/address/tokens/types.ts new file mode 100644 index 0000000000..715a40b3da --- /dev/null +++ b/ui/address/tokens/types.ts @@ -0,0 +1,5 @@ +import type { AddressTokenBalance } from 'types/api/address'; + +export type AddressTokensErc20Item = Pick & { + chain_values?: Record; +}; diff --git a/ui/address/useAddressInternalTxsQuery.ts b/ui/address/useAddressInternalTxsQuery.ts index 87fdac7095..df1fdc5f12 100644 --- a/ui/address/useAddressInternalTxsQuery.ts +++ b/ui/address/useAddressInternalTxsQuery.ts @@ -14,9 +14,10 @@ const getFilterValue = (getFilterValueFromQuery).bind(null, interface Props { enabled: boolean; isMultichain?: boolean; + chainIds?: Array; } -export default function useAddressInternalTxsQuery({ enabled, isMultichain }: Props) { +export default function useAddressInternalTxsQuery({ enabled, isMultichain, chainIds }: Props) { const router = useRouter(); const hash = getQueryParamString(router.query.hash); const [ filterValue, setFilterValue ] = React.useState(getFilterValue(router.query.filter)); @@ -41,6 +42,7 @@ export default function useAddressInternalTxsQuery({ enabled, isMultichain }: Pr ), }, isMultichain, + chainIds, }); const onFilterChange = React.useCallback((val: string | Array) => { diff --git a/ui/address/useAddressTokenTransfersQuery.ts b/ui/address/useAddressTokenTransfersQuery.ts index 2082282bea..9496cefc9e 100644 --- a/ui/address/useAddressTokenTransfersQuery.ts +++ b/ui/address/useAddressTokenTransfersQuery.ts @@ -23,9 +23,10 @@ interface Props { currentAddress: string; enabled?: boolean; isMultichain?: boolean; + chainIds?: Array; } -export default function useAddressTokenTransfersQuery({ currentAddress, enabled, isMultichain }: Props) { +export default function useAddressTokenTransfersQuery({ currentAddress, enabled, isMultichain, chainIds }: Props) { const router = useRouter(); const [ filters, setFilters ] = React.useState( @@ -48,6 +49,7 @@ export default function useAddressTokenTransfersQuery({ currentAddress, enabled, }), }, isMultichain, + chainIds, }); const onTypeFilterChange = React.useCallback((nextValue: Array) => { diff --git a/ui/address/useAddressTokenTransfersSocket.ts b/ui/address/useAddressTokenTransfersSocket.ts index f53422f7a8..4e4213b367 100644 --- a/ui/address/useAddressTokenTransfersSocket.ts +++ b/ui/address/useAddressTokenTransfersSocket.ts @@ -83,7 +83,7 @@ export default function useAddressTokenTransfersSocket({ filters, addressHash, d const queryKey = getResourceKey('general:address_token_transfers', { pathParams: { hash: addressHash }, queryParams: { ...filters }, - chainSlug: multichainContext?.chain?.slug, + chainId: multichainContext?.chain?.id, }); queryClient.setQueryData( queryKey, @@ -103,7 +103,7 @@ export default function useAddressTokenTransfersSocket({ filters, addressHash, d ); } - }, [ data?.items, overloadCount, enabled, filters, addressHash, multichainContext?.chain?.slug, queryClient, shouldHideScamTokens ]); + }, [ data?.items, overloadCount, enabled, filters, addressHash, multichainContext?.chain?.id, queryClient, shouldHideScamTokens ]); const handleSocketClose = React.useCallback(() => { setShowSocketAlert(true); diff --git a/ui/address/useAddressTxsQuery.ts b/ui/address/useAddressTxsQuery.ts index 742335b924..8f23510d78 100644 --- a/ui/address/useAddressTxsQuery.ts +++ b/ui/address/useAddressTxsQuery.ts @@ -19,9 +19,10 @@ interface Props { addressHash: string; enabled: boolean; isMultichain?: boolean; + chainIds?: Array; } -export default function useAddressTxsQuery({ addressHash, enabled, isMultichain }: Props) { +export default function useAddressTxsQuery({ addressHash, enabled, isMultichain, chainIds }: Props) { const router = useRouter(); const [ sort, setSort ] = React.useState(getSortValueFromQuery(router.query, SORT_OPTIONS) || 'default'); @@ -43,6 +44,7 @@ export default function useAddressTxsQuery({ addressHash, enabled, isMultichain } }), }, isMultichain, + chainIds, }); const onFilterChange = React.useCallback((val: string | Array) => { diff --git a/ui/address/utils/tokenUtils.ts b/ui/address/utils/tokenUtils.ts index 92758aaea0..e6cb6b1aee 100644 --- a/ui/address/utils/tokenUtils.ts +++ b/ui/address/utils/tokenUtils.ts @@ -1,4 +1,5 @@ import BigNumber from 'bignumber.js'; +import { mapValues } from 'es-toolkit'; import type { AddressTokenBalance } from 'types/api/address'; import type { TokenType } from 'types/api/token'; @@ -13,6 +14,7 @@ const isNativeToken = (token: TokenEnhancedData) => export type TokenEnhancedData = AddressTokenBalance & { usd?: BigNumber ; + chain_values?: Record; }; export type Sort = 'desc' | 'asc'; @@ -97,7 +99,13 @@ export const calculateUsdValue = (data: AddressTokenBalance): TokenEnhancedData }; }; -export const getTokensTotalInfo = (data: TokenSelectData) => { +export interface TokensTotalInfo { + usd: BigNumber; + num: number; + isOverflow: boolean; +} + +export const getTokensTotalInfo = (data: TokenSelectData): TokensTotalInfo => { const usd = Object.values(data) .map(({ items }) => items.filter((item) => !isNativeToken(item)).reduce(usdValueReducer, ZERO)) .reduce(sumBnReducer, ZERO); @@ -111,4 +119,17 @@ export const getTokensTotalInfo = (data: TokenSelectData) => { return { usd, num, isOverflow }; }; +export const getTokensTotalInfoByChain = (data: TokenSelectData, chainIds: Array) => { + return chainIds.reduce((result, chainId) => { + const filteredData = mapValues(data, (item) => ({ + ...item, + items: item.items.filter((item) => item.chain_values?.[chainId]), + })); + + result[chainId] = getTokensTotalInfo(filteredData); + + return result; + }, {} as Record); +}; + const usdValueReducer = (result: BigNumber, item: TokenEnhancedData) => !item.usd ? result : result.plus(BigNumber(item.usd)); diff --git a/ui/address/utils/useAddressCountersQuery.ts b/ui/address/utils/useAddressCountersQuery.ts index c62e13c9ba..165b108394 100644 --- a/ui/address/utils/useAddressCountersQuery.ts +++ b/ui/address/utils/useAddressCountersQuery.ts @@ -2,6 +2,7 @@ import type { UseQueryResult } from '@tanstack/react-query'; import { useQuery } from '@tanstack/react-query'; import type { AddressCounters } from 'types/api/address'; +import type { ClusterChainConfig } from 'types/multichain'; import type { ResourceError } from 'lib/api/resources'; import useApiQuery from 'lib/api/useApiQuery'; @@ -22,10 +23,10 @@ interface Params { isEnabled?: boolean; isLoading?: boolean; isDegradedData?: boolean; - chainSlug?: string; + chain?: ClusterChainConfig; } -export default function useAddressCountersQuery({ hash, isLoading, isDegradedData, isEnabled = true, chainSlug }: Params): AddressCountersQuery { +export default function useAddressCountersQuery({ hash, isLoading, isDegradedData, isEnabled = true, chain }: Params): AddressCountersQuery { const enabled = isEnabled && Boolean(hash) && !isLoading; const apiQuery = useApiQuery<'general:address_counters', { status: number }>('general:address_counters', { @@ -35,7 +36,7 @@ export default function useAddressCountersQuery({ hash, isLoading, isDegradedDat placeholderData: ADDRESS_COUNTERS, refetchOnMount: false, }, - chainSlug, + chain, }); const rpcQuery = useQuery({ diff --git a/ui/advancedFilter/ExportCSV.tsx b/ui/advancedFilter/ExportCSV.tsx index da287c7cca..87734228b1 100644 --- a/ui/advancedFilter/ExportCSV.tsx +++ b/ui/advancedFilter/ExportCSV.tsx @@ -4,6 +4,7 @@ import type { AdvancedFilterParams } from 'types/api/advancedFilter'; import config from 'configs/app'; import buildUrl from 'lib/api/buildUrl'; +import isNeedProxy from 'lib/api/isNeedProxy'; import { useMultichainContext } from 'lib/contexts/multichain'; import dayjs from 'lib/date/dayjs'; import { Button } from 'toolkit/chakra/button'; @@ -33,6 +34,7 @@ const ExportCSV = ({ filters }: Props) => { headers: { 'content-type': 'application/octet-stream', ...(recaptchaToken && { 'recaptcha-v2-response': recaptchaToken }), + ...(isNeedProxy() && multichainContext?.chain ? { 'x-endpoint': multichainContext.chain.app_config.apis.general?.endpoint } : {}), }, }); @@ -55,7 +57,7 @@ const ExportCSV = ({ filters }: Props) => { const blob = await response.blob(); - const chainText = multichainContext?.chain ? `${ multichainContext.chain.slug.replace(/-/g, '_') }_` : ''; + const chainText = multichainContext?.chain ? `${ multichainContext.chain.name.replace(' ', '-') }_` : ''; const fileName = `${ chainText }export-filtered-txs-${ dayjs().format('YYYY-MM-DD-HH-mm-ss') }.csv`; downloadBlob(blob, fileName); @@ -69,7 +71,7 @@ const ExportCSV = ({ filters }: Props) => { } }, [ apiFetchFactory, recaptcha, multichainContext?.chain ]); - const chainConfig = multichainContext?.chain.config || config; + const chainConfig = multichainContext?.chain.app_config || config; if (!chainConfig.services.reCaptchaV2.siteKey) { return null; diff --git a/ui/apiDocs/utils.ts b/ui/apiDocs/utils.ts index 830dad45a0..eb9ef7af24 100644 --- a/ui/apiDocs/utils.ts +++ b/ui/apiDocs/utils.ts @@ -25,6 +25,10 @@ export const REST_API_SECTIONS = [ swagger: { url: feature.coreApiSwaggerUrl, requestInterceptor: (req: SwaggerRequest) => { + if (!config.apis.general) { + return req; + } + const DEFAULT_SERVER = 'blockscout.com/poa/core'; const DEFAULT_SERVER_NEW = 'eth.blockscout.com'; diff --git a/ui/block/BlockDetails.tsx b/ui/block/BlockDetails.tsx index 031c365fae..2d110d032c 100644 --- a/ui/block/BlockDetails.tsx +++ b/ui/block/BlockDetails.tsx @@ -65,7 +65,7 @@ const BlockDetails = ({ query }: Props) => { const increment = direction === 'next' ? +1 : -1; const nextId = String(data.height + increment); - router.push(routeParams({ pathname: '/block/[height_or_hash]', query: { height_or_hash: nextId } }, multichainContext), undefined); + router.push(routeParams({ pathname: '/block/[height_or_hash]', query: { height_or_hash: nextId } }, { chain: multichainContext?.chain })); }, [ data, multichainContext, router ]); if (!data) { diff --git a/ui/blockCountdown/createGoogleCalendarLink.ts b/ui/blockCountdown/createGoogleCalendarLink.ts index 6e10ef93fb..0d91bb8102 100644 --- a/ui/blockCountdown/createGoogleCalendarLink.ts +++ b/ui/blockCountdown/createGoogleCalendarLink.ts @@ -14,7 +14,7 @@ const DATE_FORMAT = 'YYYYMMDDTHHmm'; export default function createGoogleCalendarLink({ timeFromNow, blockHeight, multichainContext }: Params): string { - const chainConfig = multichainContext?.chain.config || config; + const chainConfig = multichainContext?.chain.app_config || config; const date = dayjs().add(timeFromNow, 's'); const name = `Block #${ blockHeight } reminder | ${ chainConfig.chain.name }`; diff --git a/ui/blockCountdown/createIcsFileBlob.ts b/ui/blockCountdown/createIcsFileBlob.ts index e15c03009e..46b95a1573 100644 --- a/ui/blockCountdown/createIcsFileBlob.ts +++ b/ui/blockCountdown/createIcsFileBlob.ts @@ -13,7 +13,7 @@ interface Params { const DATE_FORMAT = 'YYYYMMDDTHHmmss'; export default function createIcsFileBlob({ date, blockHeight, multichainContext }: Params): Blob { - const chainConfig = multichainContext?.chain.config || config; + const chainConfig = multichainContext?.chain.app_config || config; const name = `Block #${ blockHeight } reminder | ${ chainConfig.chain.name }`; const description = `#${ blockHeight } block creation time on ${ chainConfig.chain.name } blockchain.`; diff --git a/ui/blocks/BlocksContent.tsx b/ui/blocks/BlocksContent.tsx index 90d0a0ba36..fe6029eae4 100644 --- a/ui/blocks/BlocksContent.tsx +++ b/ui/blocks/BlocksContent.tsx @@ -10,7 +10,6 @@ import { route } from 'nextjs/routes'; import { getResourceKey } from 'lib/api/useApiQuery'; import { useMultichainContext } from 'lib/contexts/multichain'; import useIsMobile from 'lib/hooks/useIsMobile'; -import { getChainDataForList } from 'lib/multichain/getChainDataForList'; import useSocketChannel from 'lib/socket/useSocketChannel'; import useSocketMessage from 'lib/socket/useSocketMessage'; import { Link } from 'toolkit/chakra/link'; @@ -45,7 +44,7 @@ const BlocksContent = ({ type, query, enableSocket = true, top }: Props) => { const handleNewBlockMessage: SocketMessage.NewBlock['handler'] = React.useCallback((payload) => { const queryKey = getResourceKey('general:blocks', { queryParams: { type }, - chainSlug: multichainContext?.chain?.slug, + chainId: multichainContext?.chain?.id, }); queryClient.setQueryData(queryKey, (prevData: BlocksResponse | undefined) => { @@ -70,7 +69,7 @@ const BlocksContent = ({ type, query, enableSocket = true, top }: Props) => { const newItems = [ payload.block, ...prevData.items ].sort((b1, b2) => b2.height - b1.height); return { ...prevData, items: newItems }; }); - }, [ multichainContext?.chain?.slug, queryClient, type ]); + }, [ multichainContext?.chain?.id, queryClient, type ]); const handleSocketClose = React.useCallback(() => { setShowSocketAlert(true); @@ -92,7 +91,7 @@ const BlocksContent = ({ type, query, enableSocket = true, top }: Props) => { handler: handleNewBlockMessage, }); - const chainData = getChainDataForList(multichainContext); + const chainData = multichainContext?.chain; const content = query.data?.items ? ( <> diff --git a/ui/blocks/BlocksList.tsx b/ui/blocks/BlocksList.tsx index 6cea14f30d..e9bc6b732e 100644 --- a/ui/blocks/BlocksList.tsx +++ b/ui/blocks/BlocksList.tsx @@ -2,7 +2,7 @@ import { Box } from '@chakra-ui/react'; import React from 'react'; import type { Block } from 'types/api/block'; -import type { ChainConfig } from 'types/multichain'; +import type { ClusterChainConfig } from 'types/multichain'; import useInitialList from 'lib/hooks/useInitialList'; import BlocksListItem from 'ui/blocks/BlocksListItem'; @@ -11,7 +11,7 @@ interface Props { data: Array; isLoading: boolean; page: number; - chainData?: ChainConfig; + chainData?: ClusterChainConfig; } const BlocksList = ({ data, isLoading, page, chainData }: Props) => { diff --git a/ui/blocks/BlocksListItem.tsx b/ui/blocks/BlocksListItem.tsx index 299480a298..0d3456bf2e 100644 --- a/ui/blocks/BlocksListItem.tsx +++ b/ui/blocks/BlocksListItem.tsx @@ -4,7 +4,7 @@ import { capitalize } from 'es-toolkit'; import React from 'react'; import type { Block } from 'types/api/block'; -import type { ChainConfig } from 'types/multichain'; +import type { ClusterChainConfig } from 'types/multichain'; import { route } from 'nextjs-routes'; @@ -31,7 +31,7 @@ interface Props { isLoading?: boolean; enableTimeIncrement?: boolean; animation?: string; - chainData?: ChainConfig; + chainData?: ClusterChainConfig; } const isRollup = config.features.rollup.isEnabled; diff --git a/ui/blocks/BlocksTable.tsx b/ui/blocks/BlocksTable.tsx index a92bff0318..3588513e8a 100644 --- a/ui/blocks/BlocksTable.tsx +++ b/ui/blocks/BlocksTable.tsx @@ -2,7 +2,7 @@ import { capitalize } from 'es-toolkit'; import React from 'react'; import type { Block } from 'types/api/block'; -import type { ChainConfig } from 'types/multichain'; +import type { ClusterChainConfig } from 'types/multichain'; import config from 'configs/app'; import { AddressHighlightProvider } from 'lib/contexts/addressHighlight'; @@ -22,7 +22,7 @@ interface Props { socketInfoNum?: number; showSocketErrorAlert?: boolean; showSocketInfo?: boolean; - chainData?: ChainConfig; + chainData?: ClusterChainConfig; } const VALIDATOR_COL_WEIGHT = 23; diff --git a/ui/blocks/BlocksTableItem.tsx b/ui/blocks/BlocksTableItem.tsx index c778084241..d68ec86950 100644 --- a/ui/blocks/BlocksTableItem.tsx +++ b/ui/blocks/BlocksTableItem.tsx @@ -3,7 +3,7 @@ import BigNumber from 'bignumber.js'; import React from 'react'; import type { Block } from 'types/api/block'; -import type { ChainConfig } from 'types/multichain'; +import type { ClusterChainConfig } from 'types/multichain'; import { route } from 'nextjs-routes'; @@ -14,11 +14,11 @@ import { Skeleton } from 'toolkit/chakra/skeleton'; import { TableCell, TableRow } from 'toolkit/chakra/table'; import { Tooltip } from 'toolkit/chakra/tooltip'; import { WEI } from 'toolkit/utils/consts'; -import ChainIcon from 'ui/optimismSuperchain/components/ChainIcon'; import BlockGasUsed from 'ui/shared/block/BlockGasUsed'; import BlockPendingUpdateHint from 'ui/shared/block/BlockPendingUpdateHint'; import AddressEntity from 'ui/shared/entities/address/AddressEntity'; import BlockEntity from 'ui/shared/entities/block/BlockEntity'; +import ChainIcon from 'ui/shared/externalChains/ChainIcon'; import IconSvg from 'ui/shared/IconSvg'; import TimeWithTooltip from 'ui/shared/time/TimeWithTooltip'; import Utilization from 'ui/shared/Utilization/Utilization'; @@ -30,7 +30,7 @@ interface Props { isLoading?: boolean; animation?: string; enableTimeIncrement?: boolean; - chainData?: ChainConfig; + chainData?: ClusterChainConfig; } const isRollup = config.features.rollup.isEnabled; diff --git a/ui/contractVerification/methods/ContractVerificationSolidityFoundry.tsx b/ui/contractVerification/methods/ContractVerificationSolidityFoundry.tsx index ba8c803844..ac34d5de96 100644 --- a/ui/contractVerification/methods/ContractVerificationSolidityFoundry.tsx +++ b/ui/contractVerification/methods/ContractVerificationSolidityFoundry.tsx @@ -16,9 +16,9 @@ const ContractVerificationSolidityFoundry = () => { const address = watch('address'); const codeSnippet = `forge verify-contract \\ - --rpc-url ${ config.chain.rpcUrls[0] || `${ config.apis.general.endpoint }/api/eth-rpc` } \\ + --rpc-url ${ config.chain.rpcUrls[0] || (config.apis.general ? `${ config.apis.general.endpoint }/api/eth-rpc` : '') } \\ --verifier blockscout \\ - --verifier-url '${ config.apis.general.endpoint }/api/' \\ + --verifier-url '${ config.apis.general ? `${ config.apis.general.endpoint }/api/` : '' }' \\ ${ address || '
' } \\ [contractFile]:[contractName]`; diff --git a/ui/contractVerification/methods/ContractVerificationSolidityHardhat.tsx b/ui/contractVerification/methods/ContractVerificationSolidityHardhat.tsx index 3c51e35ce0..a71f240523 100644 --- a/ui/contractVerification/methods/ContractVerificationSolidityHardhat.tsx +++ b/ui/contractVerification/methods/ContractVerificationSolidityHardhat.tsx @@ -23,7 +23,7 @@ const ContractVerificationSolidityHardhat = ({ config: formConfig }: { config: S solidity: "${ latestSolidityVersion || '0.8.24' }", // replace if necessary networks: { '${ chainNameSlug }': { - url: '${ config.chain.rpcUrls[0] || `${ config.apis.general.endpoint }/api/eth-rpc` }' + url: '${ config.chain.rpcUrls[0] || (config.apis.general ? `${ config.apis.general.endpoint }/api/eth-rpc` : '') }' }, }, etherscan: { @@ -35,7 +35,7 @@ const ContractVerificationSolidityHardhat = ({ config: formConfig }: { config: S network: "${ chainNameSlug }", chainId: ${ config.chain.id }, urls: { - apiURL: "${ config.apis.general.endpoint }/api", + apiURL: "${ config.apis.general ? `${ config.apis.general.endpoint }/api` : '' }", browserURL: "${ config.app.baseUrl }" } } diff --git a/ui/csvExport/CsvExportForm.tsx b/ui/csvExport/CsvExportForm.tsx index caa5acd523..afd67ef2c1 100644 --- a/ui/csvExport/CsvExportForm.tsx +++ b/ui/csvExport/CsvExportForm.tsx @@ -8,6 +8,7 @@ import type { CsvExportParams } from 'types/client/address'; import config from 'configs/app'; import buildUrl from 'lib/api/buildUrl'; +import isNeedProxy from 'lib/api/isNeedProxy'; import type { ResourceName } from 'lib/api/resources'; import { useMultichainContext } from 'lib/contexts/multichain'; import dayjs from 'lib/date/dayjs'; @@ -41,7 +42,7 @@ const CsvExportForm = ({ hash, resource, filterType, filterValue, fileNameTempla const recaptcha = useReCaptcha(); const multichainContext = useMultichainContext(); - const chainConfig = multichainContext?.chain.config || config; + const chainConfig = multichainContext?.chain.app_config || config; const apiFetchFactory = React.useCallback((data: FormFields) => { return async(recaptchaToken?: string) => { @@ -57,6 +58,7 @@ const CsvExportForm = ({ hash, resource, filterType, filterValue, fileNameTempla headers: { 'content-type': 'application/octet-stream', ...(recaptchaToken && { 'recaptcha-v2-response': recaptchaToken }), + ...(isNeedProxy() && multichainContext?.chain ? { 'x-endpoint': multichainContext.chain.app_config.apis.general?.endpoint } : {}), }, }); @@ -75,7 +77,7 @@ const CsvExportForm = ({ hash, resource, filterType, filterValue, fileNameTempla const onFormSubmit: SubmitHandler = React.useCallback(async(data) => { try { const response = await recaptcha.fetchProtectedResource(apiFetchFactory(data)); - const chainText = multichainContext?.chain ? `${ multichainContext.chain.slug.replace(/-/g, '_') }_` : ''; + const chainText = multichainContext?.chain ? `${ multichainContext.chain.name.replace(' ', '-') }_` : ''; const blob = await response.blob(); const fileName = exportType === 'holders' ? diff --git a/ui/home/Stats.tsx b/ui/home/Stats.tsx index 6b22a6d82c..b0cc8c369a 100644 --- a/ui/home/Stats.tsx +++ b/ui/home/Stats.tsx @@ -2,8 +2,6 @@ import { Grid } from '@chakra-ui/react'; import BigNumber from 'bignumber.js'; import React from 'react'; -import type { HomeStatsWidgetId } from 'types/homepage'; - import config from 'configs/app'; import useApiQuery from 'lib/api/useApiQuery'; import { HOMEPAGE_STATS, HOMEPAGE_STATS_MICROSERVICE } from 'stubs/stats'; @@ -11,9 +9,11 @@ import { WEI } from 'toolkit/utils/consts'; import GasInfoTooltip from 'ui/shared/gas/GasInfoTooltip'; import GasPrice from 'ui/shared/gas/GasPrice'; import IconSvg from 'ui/shared/IconSvg'; -import type { Props as StatsWidgetProps } from 'ui/shared/stats/StatsWidget'; import StatsWidget from 'ui/shared/stats/StatsWidget'; +import type { HomeStatsItem } from './utils'; +import { isHomeStatsItemEnabled, sortHomeStatsItems } from './utils'; + const rollupFeature = config.features.rollup; const isOptimisticRollup = rollupFeature.isEnabled && rollupFeature.type === 'optimistic'; const isArbitrumRollup = rollupFeature.isEnabled && rollupFeature.type === 'arbitrum'; @@ -90,14 +90,10 @@ const Stats = () => { const isLoading = isPlaceholderData || latestBatchQuery?.isPlaceholderData; - interface Item extends StatsWidgetProps { - id: HomeStatsWidgetId; - } - const apiData = apiQuery.data; const statsData = statsQuery.data; - const items: Array = (() => { + const items: Array = (() => { if (!statsData && !apiData) { return []; } @@ -208,18 +204,8 @@ const Stats = () => { }, ] .filter(Boolean) - .filter(({ id }) => config.UI.homepage.stats.includes(id)) - .sort((a, b) => { - const indexA = config.UI.homepage.stats.indexOf(a.id); - const indexB = config.UI.homepage.stats.indexOf(b.id); - if (indexA > indexB) { - return 1; - } - if (indexA < indexB) { - return -1; - } - return 0; - }); + .filter(isHomeStatsItemEnabled) + .sort(sortHomeStatsItems); })(); if (items.length === 0) { diff --git a/ui/home/indicators/ChainIndicatorItem.tsx b/ui/home/indicators/ChainIndicatorItem.tsx index 1b97dc4333..c0e68eb833 100644 --- a/ui/home/indicators/ChainIndicatorItem.tsx +++ b/ui/home/indicators/ChainIndicatorItem.tsx @@ -1,50 +1,45 @@ import { Text, Flex, Box } from '@chakra-ui/react'; import React from 'react'; +import type { TChainIndicator } from './types'; import type { ChainIndicatorId } from 'types/homepage'; import { Skeleton } from 'toolkit/chakra/skeleton'; - interface Props { - id: ChainIndicatorId; - title: string; - value?: string; - valueDiff?: number | null | undefined; - icon: React.ReactNode; + indicator: TChainIndicator; isSelected: boolean; onClick: (id: ChainIndicatorId) => void; isLoading: boolean; - hasData: boolean; } -const ChainIndicatorItem = ({ id, title, value, valueDiff, icon, isSelected, onClick, isLoading, hasData }: Props) => { +const ChainIndicatorItem = ({ indicator, isSelected, onClick, isLoading }: Props) => { const handleClick = React.useCallback(() => { - onClick(id); - }, [ id, onClick ]); + onClick(indicator.id); + }, [ indicator.id, onClick ]); const valueContent = (() => { - if (!hasData) { + if (indicator.value.includes('N/A')) { return no data; } return ( - { value } + { indicator.value } ); })(); const valueDiffContent = (() => { - if (valueDiff === undefined || valueDiff === null) { + if (indicator.valueDiff === undefined) { return null; } - const diffColor = valueDiff >= 0 ? 'green.500' : 'red.500'; + const diffColor = indicator.valueDiff >= 0 ? 'green.500' : 'red.500'; return ( - { valueDiff >= 0 ? '+' : '-' } - { Math.abs(valueDiff) }% + { indicator.valueDiff >= 0 ? '+' : '-' } + { Math.abs(indicator.valueDiff) }% ); })(); @@ -60,19 +55,19 @@ const ChainIndicatorItem = ({ id, title, value, valueDiff, icon, isSelected, onC borderRadius="base" cursor="pointer" color={ isSelected ? 'text.secondary' : 'link.primary' } - bgColor={ isSelected ? { _light: 'white', _dark: 'black' } : undefined } + bgColor={ isSelected ? 'bg.primary' : undefined } onClick={ handleClick } fontSize="xs" fontWeight={ 500 } _hover={{ - bgColor: { _light: 'white', _dark: 'black' }, + bgColor: 'bg.primary', color: isSelected ? 'text.secondary' : 'hover', zIndex: 1, }} > - { icon } + { indicator.icon } - { title } + { indicator.titleShort || indicator.title } { valueContent } { valueDiffContent } diff --git a/ui/home/indicators/ChainIndicators.tsx b/ui/home/indicators/ChainIndicators.tsx index d0f286c389..b0f8c0212c 100644 --- a/ui/home/indicators/ChainIndicators.tsx +++ b/ui/home/indicators/ChainIndicators.tsx @@ -1,44 +1,25 @@ -import { Flex, Text } from '@chakra-ui/react'; import React from 'react'; import type { TChainIndicator } from './types'; -import type { ChainIndicatorId } from 'types/homepage'; import config from 'configs/app'; import useApiQuery from 'lib/api/useApiQuery'; import { HOMEPAGE_STATS, HOMEPAGE_STATS_MICROSERVICE } from 'stubs/stats'; -import { Skeleton } from 'toolkit/chakra/skeleton'; -import { Hint } from 'toolkit/components/Hint/Hint'; import IconSvg from 'ui/shared/IconSvg'; +import NativeTokenIcon from 'ui/shared/NativeTokenIcon'; -import ChainIndicatorChartContainer from './ChainIndicatorChartContainer'; -import ChainIndicatorItem from './ChainIndicatorItem'; +import ChainIndicatorsChart from './ChainIndicatorsChart'; +import ChainIndicatorsContainer from './ChainIndicatorsContainer'; +import ChainIndicatorsList from './ChainIndicatorsList'; import useChartDataQuery from './useChartDataQuery'; -import getIndicatorValues from './utils/getIndicatorValues'; -import INDICATORS from './utils/indicators'; +import { isIndicatorEnabled, sortIndicators } from './utils/indicators'; const isStatsFeatureEnabled = config.features.stats.isEnabled; - -const indicators = INDICATORS - .filter(({ id }) => config.UI.homepage.charts.includes(id)) - .sort((a, b) => { - if (config.UI.homepage.charts.indexOf(a.id) > config.UI.homepage.charts.indexOf(b.id)) { - return 1; - } - - if (config.UI.homepage.charts.indexOf(a.id) < config.UI.homepage.charts.indexOf(b.id)) { - return -1; - } - - return 0; - }); +const rollupFeature = config.features.rollup; +const isOptimisticRollup = rollupFeature.isEnabled && rollupFeature.type === 'optimistic'; +const isArbitrumRollup = rollupFeature.isEnabled && rollupFeature.type === 'arbitrum'; const ChainIndicators = () => { - const [ selectedIndicator, selectIndicator ] = React.useState(indicators[0]?.id); - const selectedIndicatorData = indicators.find(({ id }) => id === selectedIndicator); - - const queryResult = useChartDataQuery(selectedIndicatorData?.id as ChainIndicatorId); - const statsMicroserviceQueryResult = useApiQuery('stats:pages_main', { queryOptions: { refetchOnMount: false, @@ -54,115 +35,151 @@ const ChainIndicators = () => { }, }); + const indicators: Array = React.useMemo(() => { + return [ + { + id: 'daily_txs' as const, + title: (() => { + if (isStatsFeatureEnabled && statsMicroserviceQueryResult?.data?.daily_new_transactions?.info?.title) { + return statsMicroserviceQueryResult.data.daily_new_transactions.info.title; + } + return 'Daily transactions'; + })(), + value: (() => { + const STRING_FORMAT = { maximumFractionDigits: 2, notation: 'compact' as const }; + if (isStatsFeatureEnabled) { + if (typeof statsMicroserviceQueryResult?.data?.yesterday_transactions?.value === 'string') { + return Number(statsMicroserviceQueryResult.data.yesterday_transactions.value).toLocaleString(undefined, STRING_FORMAT); + } + } else { + if (typeof statsApiQueryResult?.data?.transactions_today === 'string') { + return Number(statsApiQueryResult.data.transactions_today).toLocaleString(undefined, STRING_FORMAT); + } + } + return 'N/A'; + })(), + hint: (() => { + if (isStatsFeatureEnabled && statsMicroserviceQueryResult?.data?.daily_new_transactions?.info?.description) { + return statsMicroserviceQueryResult.data.daily_new_transactions.info.description; + } + return `Number of transactions yesterday (0:00 - 23:59 UTC). The chart displays daily transactions for the past 30 days.`; + })(), + icon: , + }, + { + id: 'daily_operational_txs' as const, + title: (() => { + if (isStatsFeatureEnabled) { + if (isArbitrumRollup && statsMicroserviceQueryResult?.data?.daily_new_operational_transactions?.info?.title) { + return statsMicroserviceQueryResult.data.daily_new_operational_transactions.info.title; + } + if (isOptimisticRollup && statsMicroserviceQueryResult?.data?.op_stack_daily_new_operational_transactions?.info?.title) { + return statsMicroserviceQueryResult.data.op_stack_daily_new_operational_transactions.info.title; + } + } + return 'Daily op txns'; + })(), + titleShort: 'Daily op txns', + value: (() => { + const STRING_FORMAT = { maximumFractionDigits: 2, notation: 'compact' as const }; + if (isStatsFeatureEnabled) { + if (isArbitrumRollup && typeof statsMicroserviceQueryResult?.data?.yesterday_operational_transactions?.value === 'string') { + return Number(statsMicroserviceQueryResult.data.yesterday_operational_transactions.value).toLocaleString(undefined, STRING_FORMAT); + } + if (isOptimisticRollup && typeof statsMicroserviceQueryResult?.data?.op_stack_yesterday_operational_transactions?.value === 'string') { + return Number(statsMicroserviceQueryResult.data.op_stack_yesterday_operational_transactions.value).toLocaleString(undefined, STRING_FORMAT); + } + } + return 'N/A'; + })(), + hint: (() => { + if (isStatsFeatureEnabled) { + if (isArbitrumRollup && statsMicroserviceQueryResult?.data?.daily_new_operational_transactions?.info?.description) { + return statsMicroserviceQueryResult.data.daily_new_operational_transactions.info.description; + } + if (isOptimisticRollup && statsMicroserviceQueryResult?.data?.op_stack_daily_new_operational_transactions?.info?.description) { + return statsMicroserviceQueryResult.data.op_stack_daily_new_operational_transactions.info.description; + } + } + return `Number of operational transactions yesterday (0:00 - 23:59 UTC). The chart displays daily operational transactions for the past 30 days.`; + })(), + icon: , + }, + { + id: 'coin_price' as const, + title: `${ config.chain.currency.symbol } price`, + value: typeof statsApiQueryResult.data?.coin_price !== 'string' ? + '$N/A' : + '$' + Number(statsApiQueryResult.data?.coin_price).toLocaleString(undefined, { minimumFractionDigits: 2, maximumFractionDigits: 6 }), + valueDiff: typeof statsApiQueryResult.data?.coin_price_change_percentage === 'number' ? + statsApiQueryResult.data.coin_price_change_percentage : + undefined, + hint: `${ config.chain.currency.symbol } token daily price in USD.`, + icon: , + }, + { + id: 'secondary_coin_price' as const, + title: `${ config.chain.secondaryCoin.symbol } price`, + value: typeof statsApiQueryResult.data?.secondary_coin_price !== 'string' ? + '$N/A' : + '$' + Number(statsApiQueryResult.data?.secondary_coin_price).toLocaleString(undefined, { minimumFractionDigits: 2, maximumFractionDigits: 6 }), + hint: `${ config.chain.secondaryCoin.symbol } token daily price in USD.`, + icon: , + }, + { + id: 'market_cap' as const, + title: 'Market cap', + value: typeof statsApiQueryResult.data?.market_cap !== 'string' ? + '$N/A' : + '$' + Number(statsApiQueryResult.data.market_cap).toLocaleString(undefined, { maximumFractionDigits: 2, notation: 'compact' }), + // eslint-disable-next-line max-len + hint: 'The total market value of a cryptocurrency\'s circulating supply. It is analogous to the free-float capitalization in the stock market. Market Cap = Current Price x Circulating Supply.', + icon: , + }, + { + id: 'tvl' as const, + title: 'Total value locked', + value: typeof statsApiQueryResult.data?.tvl !== 'string' ? + '$N/A' : + '$' + Number(statsApiQueryResult.data.tvl).toLocaleString(undefined, { maximumFractionDigits: 2, notation: 'compact' }), + hint: 'Total value of digital assets locked or staked in a chain', + icon: , + }, + ] + .filter(isIndicatorEnabled) + .sort(sortIndicators); + }, [ statsApiQueryResult?.data, statsMicroserviceQueryResult?.data ]); + + const [ selectedIndicatorId, selectIndicatorId ] = React.useState(indicators[0]?.id); + const selectedIndicator = indicators.find(({ id }) => id === selectedIndicatorId); + + const chartQuery = useChartDataQuery(selectedIndicatorId); + if (indicators.length === 0) { return null; } - const isPlaceholderData = (isStatsFeatureEnabled && statsMicroserviceQueryResult.isPlaceholderData) || statsApiQueryResult.isPlaceholderData; - const hasData = Boolean(statsApiQueryResult?.data || statsMicroserviceQueryResult?.data); - - const { value: indicatorValue, valueDiff: indicatorValueDiff } = - getIndicatorValues(selectedIndicatorData as TChainIndicator, statsMicroserviceQueryResult?.data, statsApiQueryResult?.data); - - const title = (() => { - let title: string | undefined; - if (isStatsFeatureEnabled && selectedIndicatorData?.titleMicroservice && statsMicroserviceQueryResult?.data) { - title = selectedIndicatorData.titleMicroservice(statsMicroserviceQueryResult.data); - } - - return title || selectedIndicatorData?.title; - })(); - - const hint = (() => { - let hint: string | undefined; - if (isStatsFeatureEnabled && selectedIndicatorData?.hintMicroservice && statsMicroserviceQueryResult?.data) { - hint = selectedIndicatorData.hintMicroservice(statsMicroserviceQueryResult.data); - } - - return hint || selectedIndicatorData?.hint; - })(); - - const valueTitle = (() => { - if (isPlaceholderData) { - return ; - } - - if (!hasData) { - return There is no data; - } - - return ( - - { indicatorValue } - - ); - })(); - - const valueDiff = (() => { - if (indicatorValueDiff === undefined || indicatorValueDiff === null) { - return null; - } - - const diffColor = indicatorValueDiff >= 0 ? 'green.500' : 'red.500'; - - return ( - - - { indicatorValueDiff }% - - ); - })(); + const isLoading = (isStatsFeatureEnabled && statsMicroserviceQueryResult.isPlaceholderData) || statsApiQueryResult.isPlaceholderData; return ( - - - - { title } - { hint && } - - - { valueTitle } - { valueDiff } - - - - - - { indicators.length > 1 && ( - - { indicators.map((indicator) => ( - - )) } - + + { selectedIndicator && ( + ) } - + + ); }; diff --git a/ui/home/indicators/ChainIndicatorsChart.tsx b/ui/home/indicators/ChainIndicatorsChart.tsx new file mode 100644 index 0000000000..e18d93740a --- /dev/null +++ b/ui/home/indicators/ChainIndicatorsChart.tsx @@ -0,0 +1,69 @@ +import { Flex, Text } from '@chakra-ui/react'; +import React from 'react'; + +import { Skeleton } from 'toolkit/chakra/skeleton'; +import { Hint } from 'toolkit/components/Hint/Hint'; +import IconSvg from 'ui/shared/IconSvg'; + +import ChainIndicatorChartContainer from './ChainIndicatorChartContainer'; +import type { UseFetchChartDataResult } from './useChartDataQuery'; + +interface Props { + isLoading: boolean; + value: string; + valueDiff?: number; + chartQuery: UseFetchChartDataResult; + title: string; + hint?: string; +} + +const ChainIndicatorsChart = ({ isLoading, value, valueDiff, chartQuery, title, hint }: Props) => { + const valueTitleElement = (() => { + if (isLoading) { + return ; + } + + if (value.includes('N/A')) { + return N/A; + } + + return ( + + { value } + + ); + })(); + + const valueDiffElement = (() => { + if (valueDiff === undefined) { + return null; + } + + const diffColor = valueDiff >= 0 ? 'green.500' : 'red.500'; + + return ( + + + { valueDiff }% + + ); + })(); + + return ( + + + { title } + { hint && } + + + { valueTitleElement } + { valueDiffElement } + + + + + + ); +}; + +export default React.memo(ChainIndicatorsChart); diff --git a/ui/home/indicators/ChainIndicatorsContainer.tsx b/ui/home/indicators/ChainIndicatorsContainer.tsx new file mode 100644 index 0000000000..bbd8dcba9e --- /dev/null +++ b/ui/home/indicators/ChainIndicatorsContainer.tsx @@ -0,0 +1,26 @@ +import { Flex } from '@chakra-ui/react'; +import React from 'react'; + +interface Props { + children: React.ReactNode; +} + +const ChainIndicatorsContainer = ({ children }: Props) => { + return ( + + { children } + + ); +}; + +export default React.memo(ChainIndicatorsContainer); diff --git a/ui/home/indicators/ChainIndicatorsList.tsx b/ui/home/indicators/ChainIndicatorsList.tsx new file mode 100644 index 0000000000..4454c13170 --- /dev/null +++ b/ui/home/indicators/ChainIndicatorsList.tsx @@ -0,0 +1,45 @@ +import { Flex } from '@chakra-ui/react'; +import React from 'react'; + +import type { TChainIndicator } from './types'; +import type { ChainIndicatorId } from 'types/homepage'; + +import ChainIndicatorItem from './ChainIndicatorItem'; + +interface Props { + indicators: Array; + isLoading: boolean; + selectedId: ChainIndicatorId; + onItemClick: (id: ChainIndicatorId) => void; +} + +const ChainIndicatorsList = ({ indicators, isLoading, selectedId, onItemClick }: Props) => { + if (indicators.length < 2) { + return null; + } + + return ( + + { indicators.map((indicator) => { + return ( + + ); + }) } + + ); +}; + +export default React.memo(ChainIndicatorsList); diff --git a/ui/home/indicators/__screenshots__/ChainIndicators.pw.tsx_default_no-data-1.png b/ui/home/indicators/__screenshots__/ChainIndicators.pw.tsx_default_no-data-1.png index 8f11b37821..6b22636e31 100644 Binary files a/ui/home/indicators/__screenshots__/ChainIndicators.pw.tsx_default_no-data-1.png and b/ui/home/indicators/__screenshots__/ChainIndicators.pw.tsx_default_no-data-1.png differ diff --git a/ui/home/indicators/__screenshots__/ChainIndicators.pw.tsx_default_partial-data-1.png b/ui/home/indicators/__screenshots__/ChainIndicators.pw.tsx_default_partial-data-1.png index c517491de5..d0094cb3aa 100644 Binary files a/ui/home/indicators/__screenshots__/ChainIndicators.pw.tsx_default_partial-data-1.png and b/ui/home/indicators/__screenshots__/ChainIndicators.pw.tsx_default_partial-data-1.png differ diff --git a/ui/home/indicators/types.ts b/ui/home/indicators/types.ts index 9bb8ad132c..ccc673e8c8 100644 --- a/ui/home/indicators/types.ts +++ b/ui/home/indicators/types.ts @@ -1,17 +1,13 @@ import type React from 'react'; -import type { MainPageStats } from '@blockscout/stats-types'; -import type { HomeStats } from 'types/api/stats'; import type { ChainIndicatorId } from 'types/homepage'; export interface TChainIndicator { id: ChainIndicatorId; - titleMicroservice?: (stats: MainPageStats) => string | undefined; title: string; - value: (stats: HomeStats) => string; - valueMicroservice?: (stats: MainPageStats) => string | undefined; - valueDiff?: (stats?: HomeStats) => number | null | undefined; + titleShort?: string; + value: string; + valueDiff?: number; icon: React.ReactNode; hint?: string; - hintMicroservice?: (stats: MainPageStats) => string | undefined; } diff --git a/ui/home/indicators/useChartDataQuery.tsx b/ui/home/indicators/useChartDataQuery.tsx index a0130a7e7b..e31faec892 100644 --- a/ui/home/indicators/useChartDataQuery.tsx +++ b/ui/home/indicators/useChartDataQuery.tsx @@ -1,60 +1,23 @@ -import type { TimeChartData, TimeChartDataItem, TimeChartItemRaw } from 'toolkit/components/charts/types'; +import type { TimeChartData } from 'toolkit/components/charts/types'; import type { ChainIndicatorId } from 'types/homepage'; import config from 'configs/app'; import useApiQuery from 'lib/api/useApiQuery'; -import prepareChartItems from './utils/prepareChartItems'; +import { getChartData } from './utils/chart'; const rollupFeature = config.features.rollup; const isOptimisticRollup = rollupFeature.isEnabled && rollupFeature.type === 'optimistic'; const isArbitrumRollup = rollupFeature.isEnabled && rollupFeature.type === 'arbitrum'; -const CHART_ITEMS: Record> = { - daily_txs: { - name: 'Tx/day', - valueFormatter: (x: number) => x.toLocaleString(undefined, { maximumFractionDigits: 2, notation: 'compact' }), - }, - daily_operational_txs: { - name: 'Tx/day', - valueFormatter: (x: number) => x.toLocaleString(undefined, { maximumFractionDigits: 2, notation: 'compact' }), - }, - coin_price: { - name: `${ config.chain.currency.symbol } price`, - valueFormatter: (x: number) => '$' + x.toLocaleString(undefined, { minimumFractionDigits: 2, maximumFractionDigits: 6 }), - }, - secondary_coin_price: { - name: `${ config.chain.currency.symbol } price`, - valueFormatter: (x: number) => '$' + x.toLocaleString(undefined, { minimumFractionDigits: 2, maximumFractionDigits: 6 }), - }, - market_cap: { - name: 'Market cap', - valueFormatter: (x: number) => '$' + x.toLocaleString(undefined, { maximumFractionDigits: 2 }), - }, - tvl: { - name: 'TVL', - valueFormatter: (x: number) => '$' + x.toLocaleString(undefined, { maximumFractionDigits: 2, notation: 'compact' }), - }, -}; - const isStatsFeatureEnabled = config.features.stats.isEnabled; -type UseFetchChartDataResult = { +export type UseFetchChartDataResult = { isError: boolean; isPending: boolean; data: TimeChartData; }; -function getChartData(indicatorId: ChainIndicatorId, data: Array): TimeChartData { - return [ { - id: indicatorId, - charts: [], - items: prepareChartItems(data), - name: CHART_ITEMS[indicatorId].name, - valueFormatter: CHART_ITEMS[indicatorId].valueFormatter, - } ]; -} - export default function useChartDataQuery(indicatorId: ChainIndicatorId): UseFetchChartDataResult { const statsDailyTxsQuery = useApiQuery('stats:pages_main', { queryOptions: { diff --git a/ui/home/indicators/utils/chart.ts b/ui/home/indicators/utils/chart.ts new file mode 100644 index 0000000000..567d8a0b4b --- /dev/null +++ b/ui/home/indicators/utils/chart.ts @@ -0,0 +1,60 @@ +import type { TimeChartData, TimeChartDataItem, TimeChartItemRaw, TimeChartItem } from 'toolkit/components/charts/types'; +import type { ChainIndicatorId } from 'types/homepage'; + +import config from 'configs/app'; +import getCurrencySymbol from 'lib/multichain/getCurrencySymbol'; +import { sortByDateDesc } from 'ui/shared/chart/utils'; + +const CHART_ITEMS: Record> = { + daily_txs: { + name: 'Tx/day', + valueFormatter: (x: number) => x.toLocaleString(undefined, { maximumFractionDigits: 2, notation: 'compact' }), + }, + daily_operational_txs: { + name: 'Tx/day', + valueFormatter: (x: number) => x.toLocaleString(undefined, { maximumFractionDigits: 2, notation: 'compact' }), + }, + coin_price: { + name: `${ getCurrencySymbol() || config.chain.currency.symbol } price`, + valueFormatter: (x: number) => '$' + x.toLocaleString(undefined, { minimumFractionDigits: 2, maximumFractionDigits: 6 }), + }, + secondary_coin_price: { + name: `${ config.chain.currency.symbol } price`, + valueFormatter: (x: number) => '$' + x.toLocaleString(undefined, { minimumFractionDigits: 2, maximumFractionDigits: 6 }), + }, + market_cap: { + name: 'Market cap', + valueFormatter: (x: number) => '$' + x.toLocaleString(undefined, { maximumFractionDigits: 2 }), + }, + tvl: { + name: 'TVL', + valueFormatter: (x: number) => '$' + x.toLocaleString(undefined, { maximumFractionDigits: 2, notation: 'compact' }), + }, +}; + +const nonNullTailReducer = (result: Array, item: TimeChartItemRaw) => { + if (item.value === null && result.length === 0) { + return result; + } + result.unshift(item); + return result; +}; + +const mapNullToZero: (item: TimeChartItemRaw) => TimeChartItem = (item) => ({ ...item, value: Number(item.value) }); + +export function prepareChartItems(items: Array) { + return items + .sort(sortByDateDesc) + .reduceRight(nonNullTailReducer, [] as Array) + .map(mapNullToZero); +} + +export function getChartData(indicatorId: ChainIndicatorId, data: Array): TimeChartData { + return [ { + id: indicatorId.replace(' ', '_'), + charts: [], + items: prepareChartItems(data), + name: CHART_ITEMS[indicatorId].name, + valueFormatter: CHART_ITEMS[indicatorId].valueFormatter, + } ]; +} diff --git a/ui/home/indicators/utils/getIndicatorValues.ts b/ui/home/indicators/utils/getIndicatorValues.ts deleted file mode 100644 index 1d3b7a51a2..0000000000 --- a/ui/home/indicators/utils/getIndicatorValues.ts +++ /dev/null @@ -1,28 +0,0 @@ -import type { TChainIndicator } from '../types'; -import type * as stats from '@blockscout/stats-types'; -import type { HomeStats } from 'types/api/stats'; - -import config from 'configs/app'; - -export default function getIndicatorValues(indicator: TChainIndicator, statsData?: stats.MainPageStats, statsApiData?: HomeStats) { - const value = (() => { - if (config.features.stats.isEnabled && indicator?.valueMicroservice && statsData) { - return indicator.valueMicroservice(statsData); - } - - if (statsApiData) { - return indicator?.value(statsApiData); - } - - return 'N/A'; - })(); - - // we have diffs only for coin and second coin price charts that get data from stats api - // so we don't check microservice data here, but may require to add it in the future - const valueDiff = indicator?.valueDiff ? indicator.valueDiff(statsApiData) : undefined; - - return { - value, - valueDiff, - }; -} diff --git a/ui/home/indicators/utils/indicators.ts b/ui/home/indicators/utils/indicators.ts new file mode 100644 index 0000000000..c0726d20e3 --- /dev/null +++ b/ui/home/indicators/utils/indicators.ts @@ -0,0 +1,17 @@ +import type { TChainIndicator } from '../types'; + +import config from 'configs/app'; + +export const isIndicatorEnabled = ({ id }: TChainIndicator) => config.UI.homepage.charts.includes(id); + +export const sortIndicators = (a: TChainIndicator, b: TChainIndicator) => { + if (config.UI.homepage.charts.indexOf(a.id) > config.UI.homepage.charts.indexOf(b.id)) { + return 1; + } + + if (config.UI.homepage.charts.indexOf(a.id) < config.UI.homepage.charts.indexOf(b.id)) { + return -1; + } + + return 0; +}; diff --git a/ui/home/indicators/utils/indicators.tsx b/ui/home/indicators/utils/indicators.tsx deleted file mode 100644 index 061b3850a6..0000000000 --- a/ui/home/indicators/utils/indicators.tsx +++ /dev/null @@ -1,97 +0,0 @@ -import React from 'react'; - -import type { TChainIndicator } from '../types'; - -import config from 'configs/app'; -import IconSvg from 'ui/shared/IconSvg'; -import NativeTokenIcon from 'ui/shared/NativeTokenIcon'; - -const rollupFeature = config.features.rollup; -const isOptimisticRollup = rollupFeature.isEnabled && rollupFeature.type === 'optimistic'; -const isArbitrumRollup = rollupFeature.isEnabled && rollupFeature.type === 'arbitrum'; - -const INDICATORS: Array = [ - { - id: 'daily_txs', - title: 'Daily transactions', - titleMicroservice: (stats) => stats.daily_new_transactions?.info?.title, - value: (stats) => stats.transactions_today === null ? - 'N/A' : - Number(stats.transactions_today).toLocaleString(undefined, { maximumFractionDigits: 2, notation: 'compact' }), - valueMicroservice: (stats) => stats.yesterday_transactions?.value === null ? - 'N/A' : - Number(stats.yesterday_transactions?.value).toLocaleString(undefined, { maximumFractionDigits: 2, notation: 'compact' }), - icon: , - hint: `Number of transactions yesterday (0:00 - 23:59 UTC). The chart displays daily transactions for the past 30 days.`, - hintMicroservice: (stats) => stats.daily_new_transactions?.info?.description, - }, - { - id: 'daily_operational_txs', - title: 'Daily op txns', - titleMicroservice: (stats) => { - if (isArbitrumRollup) { - return stats.daily_new_operational_transactions?.info?.title; - } else if (isOptimisticRollup) { - return stats.op_stack_daily_new_operational_transactions?.info?.title; - } - return ''; - }, - value: () => 'N/A', - valueMicroservice: (stats) => { - if (isArbitrumRollup) { - return stats.yesterday_operational_transactions?.value === null ? - 'N/A' : - Number(stats.yesterday_operational_transactions?.value).toLocaleString(undefined, { maximumFractionDigits: 2, notation: 'compact' }); - } else if (isOptimisticRollup) { - return stats.op_stack_yesterday_operational_transactions?.value === null ? - 'N/A' : - Number(stats.op_stack_yesterday_operational_transactions?.value).toLocaleString(undefined, { maximumFractionDigits: 2, notation: 'compact' }); - } - return; - }, - icon: , - hint: `Number of operational transactions yesterday (0:00 - 23:59 UTC). The chart displays daily operational transactions for the past 30 days.`, - hintMicroservice: (stats) => stats.daily_new_operational_transactions?.info?.description, - }, - { - id: 'coin_price', - title: `${ config.chain.currency.symbol } price`, - value: (stats) => stats.coin_price === null ? - '$N/A' : - '$' + Number(stats.coin_price).toLocaleString(undefined, { minimumFractionDigits: 2, maximumFractionDigits: 6 }), - valueDiff: (stats) => stats?.coin_price !== null ? stats?.coin_price_change_percentage : null, - icon: , - hint: `${ config.chain.currency.symbol } token daily price in USD.`, - }, - { - id: 'secondary_coin_price', - title: `${ config.chain.secondaryCoin.symbol } price`, - value: (stats) => !stats.secondary_coin_price || stats.secondary_coin_price === null ? - '$N/A' : - '$' + Number(stats.secondary_coin_price).toLocaleString(undefined, { minimumFractionDigits: 2, maximumFractionDigits: 6 }), - valueDiff: () => null, - icon: , - hint: `${ config.chain.secondaryCoin.symbol } token daily price in USD.`, - }, - { - id: 'market_cap', - title: 'Market cap', - value: (stats) => stats.market_cap === null ? - '$N/A' : - '$' + Number(stats.market_cap).toLocaleString(undefined, { maximumFractionDigits: 2, notation: 'compact' }), - icon: , - // eslint-disable-next-line max-len - hint: 'The total market value of a cryptocurrency\'s circulating supply. It is analogous to the free-float capitalization in the stock market. Market Cap = Current Price x Circulating Supply.', - }, - { - id: 'tvl', - title: 'Total value locked', - value: (stats) => stats.tvl === null ? - '$N/A' : - '$' + Number(stats.tvl).toLocaleString(undefined, { maximumFractionDigits: 2, notation: 'compact' }), - icon: , - hint: 'Total value of digital assets locked or staked in a chain', - }, -]; - -export default INDICATORS; diff --git a/ui/home/indicators/utils/prepareChartItems.ts b/ui/home/indicators/utils/prepareChartItems.ts deleted file mode 100644 index 937fca2432..0000000000 --- a/ui/home/indicators/utils/prepareChartItems.ts +++ /dev/null @@ -1,20 +0,0 @@ -import type { TimeChartItem, TimeChartItemRaw } from 'toolkit/components/charts/types'; - -import { sortByDateDesc } from 'ui/shared/chart/utils'; - -const nonNullTailReducer = (result: Array, item: TimeChartItemRaw) => { - if (item.value === null && result.length === 0) { - return result; - } - result.unshift(item); - return result; -}; - -const mapNullToZero: (item: TimeChartItemRaw) => TimeChartItem = (item) => ({ ...item, value: Number(item.value) }); - -export default function prepareChartItems(items: Array) { - return items - .sort(sortByDateDesc) - .reduceRight(nonNullTailReducer, [] as Array) - .map(mapNullToZero); -} diff --git a/ui/home/utils.ts b/ui/home/utils.ts new file mode 100644 index 0000000000..235bffc86d --- /dev/null +++ b/ui/home/utils.ts @@ -0,0 +1,22 @@ +import type { HomeStatsWidgetId } from 'types/homepage'; + +import config from 'configs/app'; +import type { Props as StatsWidgetProps } from 'ui/shared/stats/StatsWidget'; + +export interface HomeStatsItem extends StatsWidgetProps { + id: HomeStatsWidgetId; +} + +export const isHomeStatsItemEnabled = (item: HomeStatsItem) => config.UI.homepage.stats.includes(item.id); + +export const sortHomeStatsItems = (a: HomeStatsItem, b: HomeStatsItem) => { + const indexA = config.UI.homepage.stats.indexOf(a.id); + const indexB = config.UI.homepage.stats.indexOf(b.id); + if (indexA > indexB) { + return 1; + } + if (indexA < indexB) { + return -1; + } + return 0; +}; diff --git a/ui/internalTxs/InternalTxsList.tsx b/ui/internalTxs/InternalTxsList.tsx index 3021888af2..e367a428b9 100644 --- a/ui/internalTxs/InternalTxsList.tsx +++ b/ui/internalTxs/InternalTxsList.tsx @@ -4,7 +4,6 @@ import React from 'react'; import type { InternalTransaction } from 'types/api/internalTransaction'; import { useMultichainContext } from 'lib/contexts/multichain'; -import { getChainDataForList } from 'lib/multichain/getChainDataForList'; import InternalTxsListItem from './InternalTxsListItem'; @@ -17,7 +16,7 @@ type Props = { const InternalTxsList = ({ data, currentAddress, isLoading, showBlockInfo = true }: Props) => { const multichainContext = useMultichainContext(); - const chainData = getChainDataForList(multichainContext); + const chainData = multichainContext?.chain; return ( diff --git a/ui/internalTxs/InternalTxsListItem.tsx b/ui/internalTxs/InternalTxsListItem.tsx index b8f65085e0..e43a8276e4 100644 --- a/ui/internalTxs/InternalTxsListItem.tsx +++ b/ui/internalTxs/InternalTxsListItem.tsx @@ -3,7 +3,7 @@ import BigNumber from 'bignumber.js'; import React from 'react'; import type { InternalTransaction } from 'types/api/internalTransaction'; -import type { ChainConfig } from 'types/multichain'; +import type { ClusterChainConfig } from 'types/multichain'; import config from 'configs/app'; import { currencyUnits } from 'lib/units'; @@ -17,7 +17,7 @@ import TxStatus from 'ui/shared/statusTag/TxStatus'; import TimeWithTooltip from 'ui/shared/time/TimeWithTooltip'; import { TX_INTERNALS_ITEMS } from 'ui/tx/internals/utils'; -type Props = InternalTransaction & { currentAddress?: string; isLoading?: boolean; showBlockInfo?: boolean; chainData?: ChainConfig }; +type Props = InternalTransaction & { currentAddress?: string; isLoading?: boolean; showBlockInfo?: boolean; chainData?: ClusterChainConfig }; const InternalTxsListItem = ({ type, diff --git a/ui/internalTxs/InternalTxsTable.tsx b/ui/internalTxs/InternalTxsTable.tsx index 65a90c13d2..7f17603c77 100644 --- a/ui/internalTxs/InternalTxsTable.tsx +++ b/ui/internalTxs/InternalTxsTable.tsx @@ -4,7 +4,6 @@ import type { InternalTransaction } from 'types/api/internalTransaction'; import { AddressHighlightProvider } from 'lib/contexts/addressHighlight'; import { useMultichainContext } from 'lib/contexts/multichain'; -import { getChainDataForList } from 'lib/multichain/getChainDataForList'; import { currencyUnits } from 'lib/units'; import { TableBody, TableColumnHeader, TableHeaderSticky, TableRoot, TableRow } from 'toolkit/chakra/table'; import TimeFormatToggle from 'ui/shared/time/TimeFormatToggle'; @@ -21,7 +20,7 @@ interface Props { const InternalTxsTable = ({ data, currentAddress, isLoading, top, showBlockInfo = true }: Props) => { const multichainContext = useMultichainContext(); - const chainData = getChainDataForList(multichainContext); + const chainData = multichainContext?.chain; return ( diff --git a/ui/internalTxs/InternalTxsTableItem.tsx b/ui/internalTxs/InternalTxsTableItem.tsx index d8b45f8a6c..2f124a8902 100644 --- a/ui/internalTxs/InternalTxsTableItem.tsx +++ b/ui/internalTxs/InternalTxsTableItem.tsx @@ -3,21 +3,21 @@ import BigNumber from 'bignumber.js'; import React from 'react'; import type { InternalTransaction } from 'types/api/internalTransaction'; -import type { ChainConfig } from 'types/multichain'; +import type { ClusterChainConfig } from 'types/multichain'; import config from 'configs/app'; import { Badge } from 'toolkit/chakra/badge'; import { TableCell, TableRow } from 'toolkit/chakra/table'; -import ChainIcon from 'ui/optimismSuperchain/components/ChainIcon'; import AddressFromTo from 'ui/shared/address/AddressFromTo'; import BlockEntity from 'ui/shared/entities/block/BlockEntity'; import TxEntity from 'ui/shared/entities/tx/TxEntity'; +import ChainIcon from 'ui/shared/externalChains/ChainIcon'; import TxStatus from 'ui/shared/statusTag/TxStatus'; import TimeWithTooltip from 'ui/shared/time/TimeWithTooltip'; import TruncatedValue from 'ui/shared/TruncatedValue'; import { TX_INTERNALS_ITEMS } from 'ui/tx/internals/utils'; -type Props = InternalTransaction & { currentAddress?: string; isLoading?: boolean; showBlockInfo?: boolean; chainData?: ChainConfig }; +type Props = InternalTransaction & { currentAddress?: string; isLoading?: boolean; showBlockInfo?: boolean; chainData?: ClusterChainConfig }; const InternalTxsTableItem = ({ type, diff --git a/ui/marketplace/essentialDapps/multisend/Multisend.tsx b/ui/marketplace/essentialDapps/multisend/Multisend.tsx index b0b565d087..c22faf2f26 100644 --- a/ui/marketplace/essentialDapps/multisend/Multisend.tsx +++ b/ui/marketplace/essentialDapps/multisend/Multisend.tsx @@ -497,9 +497,9 @@ const Container = ({ children }: { children: React.ReactNode }) => ( ); const widgetConfig = Object.fromEntries(dappConfig?.chains.map((chainId) => { - const chainConfig = essentialDappsChainsConfig()?.chains.find((chain) => chain.config.chain.id === chainId); - const explorerUrl = chainConfig?.config.app.baseUrl; - const apiUrl = chainConfig?.config.apis.general?.endpoint; + const chainConfig = essentialDappsChainsConfig()?.chains.find((chain) => chain.id === chainId); + const explorerUrl = chainConfig?.app_config?.app?.baseUrl; + const apiUrl = chainConfig?.app_config?.apis?.general?.endpoint; return [ chainId, { diff --git a/ui/marketplace/essentialDapps/revoke/Revoke.pw.tsx b/ui/marketplace/essentialDapps/revoke/Revoke.pw.tsx index 480eca606d..52e47fe7e9 100644 --- a/ui/marketplace/essentialDapps/revoke/Revoke.pw.tsx +++ b/ui/marketplace/essentialDapps/revoke/Revoke.pw.tsx @@ -7,7 +7,7 @@ import { test, expect } from 'playwright/lib'; import Revoke from './Revoke'; const ESSENTIAL_DAPPS_CONFIG = JSON.stringify({ - revoke: { chains: [ opSuperchainMock.chainDataA.config.chain.id ] }, + revoke: { chains: [ opSuperchainMock.chainDataA.id ] }, }); test('base view +@dark-mode +@mobile', async({ render, mockEnvs, mockEssentialDappsChainsConfig }: TestFnArgs) => { diff --git a/ui/marketplace/essentialDapps/revoke/Revoke.tsx b/ui/marketplace/essentialDapps/revoke/Revoke.tsx index b013d204d3..c4fd80e2c6 100644 --- a/ui/marketplace/essentialDapps/revoke/Revoke.tsx +++ b/ui/marketplace/essentialDapps/revoke/Revoke.tsx @@ -46,7 +46,7 @@ const Revoke = () => { const [ searchAddress, setSearchAddress ] = useState(addressFromQuery || ''); const [ searchInputValue, setSearchInputValue ] = useState(''); - const selectedChain = essentialDappsChainsConfig()?.chains.find((chain) => chain.config.chain.id === selectedChainId[0]); + const selectedChain = essentialDappsChainsConfig()?.chains.find((chain) => chain.id === selectedChainId[0]); const approvalsQuery = useApprovalsQuery(selectedChain, searchAddress); const coinBalanceQuery = useCoinBalanceQuery(selectedChain, searchAddress); @@ -209,6 +209,7 @@ const Revoke = () => { diff --git a/ui/marketplace/essentialDapps/revoke/components/Approvals.tsx b/ui/marketplace/essentialDapps/revoke/components/Approvals.tsx index 8f3b45bc20..d12a1bcb3c 100644 --- a/ui/marketplace/essentialDapps/revoke/components/Approvals.tsx +++ b/ui/marketplace/essentialDapps/revoke/components/Approvals.tsx @@ -1,7 +1,7 @@ import { Box, Flex } from '@chakra-ui/react'; +import type { EssentialDappsChainConfig } from 'types/client/marketplace'; import type { AllowanceType } from 'types/client/revoke'; -import type { ChainConfig } from 'types/multichain'; import DataListDisplay from 'ui/shared/DataListDisplay'; @@ -9,7 +9,7 @@ import ApprovalsListItem from './ApprovalsListItem'; import ApprovalsTable from './ApprovalsTable'; type Props = { - selectedChain: ChainConfig | undefined; + selectedChain: EssentialDappsChainConfig | undefined; approvals: Array; isLoading?: boolean; isAddressMatch?: boolean; diff --git a/ui/marketplace/essentialDapps/revoke/components/ApprovalsListItem.tsx b/ui/marketplace/essentialDapps/revoke/components/ApprovalsListItem.tsx index f05e3f2c18..1f36bc6722 100644 --- a/ui/marketplace/essentialDapps/revoke/components/ApprovalsListItem.tsx +++ b/ui/marketplace/essentialDapps/revoke/components/ApprovalsListItem.tsx @@ -1,10 +1,8 @@ import { Text } from '@chakra-ui/react'; -import React, { useCallback, useState } from 'react'; +import { useCallback, useState } from 'react'; +import type { EssentialDappsChainConfig } from 'types/client/marketplace'; import type { AllowanceType } from 'types/client/revoke'; -import type { ChainConfig } from 'types/multichain'; - -import { route } from 'nextjs/routes'; import dayjs from 'lib/date/dayjs'; import { Button } from 'toolkit/chakra/button'; @@ -18,7 +16,7 @@ import useRevoke from '../hooks/useRevoke'; import formatAllowance from '../lib/formatAllowance'; type Props = { - selectedChain: ChainConfig | undefined; + selectedChain: EssentialDappsChainConfig | undefined; approval: AllowanceType; isLoading?: boolean; isAddressMatch?: boolean; @@ -39,12 +37,12 @@ export default function ApprovalsListItem({ const handleRevoke = useCallback(async() => { setIsPending(true); - const success = await revoke(approval, Number(selectedChain?.config.chain.id)); + const success = await revoke(approval, Number(selectedChain?.id)); if (success) { hideApproval(approval); } setIsPending(false); - }, [ revoke, hideApproval, approval, selectedChain?.config.chain.id ]); + }, [ revoke, hideApproval, approval, selectedChain?.id ]); return ( @@ -92,7 +90,7 @@ export default function ApprovalsListItem({ truncation="constant" noIcon isLoading={ isLoading } - href={ selectedChain?.config.app.baseUrl + route({ pathname: '/address/[hash]', query: { hash: approval.spender } }) } + chain={ selectedChain } link={{ noIcon: true, external: true }} /> diff --git a/ui/marketplace/essentialDapps/revoke/components/ApprovalsTable.tsx b/ui/marketplace/essentialDapps/revoke/components/ApprovalsTable.tsx index 0510f4f6d1..562e8322d9 100644 --- a/ui/marketplace/essentialDapps/revoke/components/ApprovalsTable.tsx +++ b/ui/marketplace/essentialDapps/revoke/components/ApprovalsTable.tsx @@ -1,7 +1,7 @@ import React from 'react'; +import type { EssentialDappsChainConfig } from 'types/client/marketplace'; import type { AllowanceType } from 'types/client/revoke'; -import type { ChainConfig } from 'types/multichain'; import { TableBody, TableColumnHeader, TableHeaderSticky, TableRoot, TableRow } from 'toolkit/chakra/table'; import TimeFormatToggle from 'ui/shared/time/TimeFormatToggle'; @@ -9,7 +9,7 @@ import TimeFormatToggle from 'ui/shared/time/TimeFormatToggle'; import ApprovalsTableItem from './ApprovalsTableItem'; type Props = { - selectedChain: ChainConfig | undefined; + selectedChain: EssentialDappsChainConfig | undefined; approvals: Array; isLoading?: boolean; isAddressMatch?: boolean; diff --git a/ui/marketplace/essentialDapps/revoke/components/ApprovalsTableItem.tsx b/ui/marketplace/essentialDapps/revoke/components/ApprovalsTableItem.tsx index bb1435118a..42525cdbe9 100644 --- a/ui/marketplace/essentialDapps/revoke/components/ApprovalsTableItem.tsx +++ b/ui/marketplace/essentialDapps/revoke/components/ApprovalsTableItem.tsx @@ -1,10 +1,8 @@ import { Flex } from '@chakra-ui/react'; import { useCallback, useState } from 'react'; +import type { EssentialDappsChainConfig } from 'types/client/marketplace'; import type { AllowanceType } from 'types/client/revoke'; -import type { ChainConfig } from 'types/multichain'; - -import { route } from 'nextjs/routes'; import { Button } from 'toolkit/chakra/button'; import { Skeleton } from 'toolkit/chakra/skeleton'; @@ -18,7 +16,7 @@ import useRevoke from '../hooks/useRevoke'; import formatAllowance from '../lib/formatAllowance'; type Props = { - selectedChain: ChainConfig | undefined; + selectedChain: EssentialDappsChainConfig | undefined; approval: AllowanceType; isLoading?: boolean; isAddressMatch?: boolean; @@ -39,12 +37,12 @@ export default function ApprovalsTableItem({ const handleRevoke = useCallback(async() => { setIsPending(true); - const success = await revoke(approval, Number(selectedChain?.config.chain.id)); + const success = await revoke(approval, Number(selectedChain?.id)); if (success) { hideApproval(approval); } setIsPending(false); - }, [ revoke, hideApproval, approval, selectedChain?.config.chain.id ]); + }, [ revoke, hideApproval, approval, selectedChain?.id ]); return ( @@ -64,7 +62,7 @@ export default function ApprovalsTableItem({ jointSymbol textStyle="sm" fontWeight="600" - href={ selectedChain?.config.app.baseUrl + route({ pathname: '/token/[hash]', query: { hash: approval.address } }) } + chain={ selectedChain } link={{ noIcon: true, external: true }} /> @@ -83,7 +81,7 @@ export default function ApprovalsTableItem({ truncation="constant" noIcon isLoading={ isLoading } - href={ selectedChain?.config.app.baseUrl + route({ pathname: '/address/[hash]', query: { hash: approval.spender } }) } + chain={ selectedChain } link={{ noIcon: true, external: true }} /> diff --git a/ui/marketplace/essentialDapps/revoke/components/ChainSelect.tsx b/ui/marketplace/essentialDapps/revoke/components/ChainSelect.tsx index de4ea15d3d..e0f30042a9 100644 --- a/ui/marketplace/essentialDapps/revoke/components/ChainSelect.tsx +++ b/ui/marketplace/essentialDapps/revoke/components/ChainSelect.tsx @@ -1,38 +1,14 @@ -import { createListCollection } from '@chakra-ui/react'; import React from 'react'; import essentialDappsChainsConfig from 'configs/essential-dapps-chains'; -import useIsInitialLoading from 'lib/hooks/useIsInitialLoading'; -import { Select } from 'toolkit/chakra/select'; -import type { SelectOption, SelectProps, ViewMode } from 'toolkit/chakra/select'; -import ChainIcon from 'ui/optimismSuperchain/components/ChainIcon'; - -const collection = createListCollection({ - items: essentialDappsChainsConfig()?.chains.map((chain) => ({ - value: chain.config.chain.id as string, - label: chain.config.chain.name || chain.slug, - icon: , - })) || [], -}); - -interface Props extends Omit { - loading?: boolean; - mode?: ViewMode; -} - -const ChainSelect = ({ loading, mode, ...props }: Props) => { - const isInitialLoading = useIsInitialLoading(loading); - - return ( - 0 ? [ collection.items[0].value ] : undefined } + placeholder="Select chain" + loading={ isInitialLoading } + mode={ isMobile && !mode ? 'compact' : mode } + w="fit-content" + flexShrink={ 0 } + { ...props } + /> + ); +}; + +export default React.memo(ChainSelect); diff --git a/ui/shared/externalChains/getChainTooltipText.ts b/ui/shared/externalChains/getChainTooltipText.ts new file mode 100644 index 0000000000..d214786d59 --- /dev/null +++ b/ui/shared/externalChains/getChainTooltipText.ts @@ -0,0 +1,8 @@ +import type { ExternalChain } from 'types/externalChains'; + +export default function getChainTooltipText(chain: Pick | undefined, prefix: string = '') { + if (!chain) { + return 'Unknown chain'; + } + return `${ prefix }${ chain.name } (Chain ID: ${ chain.id })`; +} diff --git a/ui/shared/gas/GasPrice.tsx b/ui/shared/gas/GasPrice.tsx index 8a9b1cc41f..75e6861785 100644 --- a/ui/shared/gas/GasPrice.tsx +++ b/ui/shared/gas/GasPrice.tsx @@ -23,7 +23,7 @@ interface Props { const GasPrice = ({ data, prefix, className, unitMode = 'primary' }: Props) => { const multichainContext = useMultichainContext(); - const feature = multichainContext?.chain?.config.features.gasTracker || config.features.gasTracker; + const feature = multichainContext?.chain?.app_config.features.gasTracker || config.features.gasTracker; if (!data || !feature.isEnabled) { return null; diff --git a/ui/shared/layout/components/Container.tsx b/ui/shared/layout/components/Container.tsx index 6c2ea5f5b8..6fd7a73351 100644 --- a/ui/shared/layout/components/Container.tsx +++ b/ui/shared/layout/components/Container.tsx @@ -12,7 +12,7 @@ const Container = ({ children, className }: Props) => { className={ className } minWidth={{ base: '100vw', lg: 'fit-content' }} m="0 auto" - bgColor={{ _light: 'white', _dark: 'black' }} + bgColor="bg.primary" > { children } diff --git a/ui/shared/links/AdvancedFilterLink.tsx b/ui/shared/links/AdvancedFilterLink.tsx index 4c1e9677c6..f973717f76 100644 --- a/ui/shared/links/AdvancedFilterLink.tsx +++ b/ui/shared/links/AdvancedFilterLink.tsx @@ -1,23 +1,23 @@ import { chakra } from '@chakra-ui/react'; import React from 'react'; +import type { RouteParams } from 'nextjs/routes'; import { route } from 'nextjs/routes'; -import type { TMultichainContext } from 'lib/contexts/multichain'; import type { LinkProps } from 'toolkit/chakra/link'; import { Link } from 'toolkit/chakra/link'; import IconSvg from 'ui/shared/IconSvg'; interface Props extends LinkProps { query?: Record | undefined>; - linkContext?: TMultichainContext | null; + routeParams?: RouteParams; adaptive?: boolean; } -const AdvancedFilterLink = ({ query, linkContext, adaptive = true, ...rest }: Props) => { +const AdvancedFilterLink = ({ query, routeParams, adaptive = true, ...rest }: Props) => { return ( ( diff --git a/ui/shared/multichain/ChainSelect.tsx b/ui/shared/multichain/ChainSelect.tsx deleted file mode 100644 index f5ff98f31c..0000000000 --- a/ui/shared/multichain/ChainSelect.tsx +++ /dev/null @@ -1,42 +0,0 @@ -import { createListCollection } from '@chakra-ui/react'; -import React from 'react'; - -import multichainConfig from 'configs/multichain'; -import useIsInitialLoading from 'lib/hooks/useIsInitialLoading'; -import useIsMobile from 'lib/hooks/useIsMobile'; -import { Select } from 'toolkit/chakra/select'; -import type { SelectOption, SelectProps, ViewMode } from 'toolkit/chakra/select'; -import ChainIcon from 'ui/optimismSuperchain/components/ChainIcon'; - -const collection = createListCollection({ - items: multichainConfig()?.chains.map((chain) => ({ - value: chain.slug, - label: chain.config.chain.name || chain.slug, - icon: , - })) || [], -}); - -interface Props extends Omit { - loading?: boolean; - mode?: ViewMode; -} - -const ChainSelect = ({ loading, mode, ...props }: Props) => { - const isInitialLoading = useIsInitialLoading(loading); - const isMobile = useIsMobile(); - - return ( - [ ...prev, { address: '' } ]); }, []); - const handleChainChange = React.useCallback((chains: Array) => { - setCurrentChainValue(chains); + const handleChainChange = React.useCallback(({ value }: { value: Array }) => { + setCurrentChainValue(value); }, []); const onReset = React.useCallback(() => { @@ -198,10 +171,10 @@ const ZetaChainAddressFilter = ({ > { currentValue.map((item, index) => ( diff --git a/ui/zetaChain/filters/ZetaChainFilterTags.tsx b/ui/zetaChain/filters/ZetaChainFilterTags.tsx index c026d3d63c..b74fe17f67 100644 --- a/ui/zetaChain/filters/ZetaChainFilterTags.tsx +++ b/ui/zetaChain/filters/ZetaChainFilterTags.tsx @@ -73,8 +73,8 @@ const ZetaChainFilterTags = ({ filters, onClearFilter, onClearAll }: Props) => { const sourceChainIds = filters.source_chain_id ? castArray(filters.source_chain_id) : []; if (sourceChainIds.length > 0) { const chainNames = sourceChainIds.map(chainId => { - const chain = chains.find(c => c.chain_id.toString() === chainId); - return chain?.chain_name || `Chain ${ chainId }`; + const chain = chains.find(c => c.id.toString() === chainId); + return chain?.name; }); filterTags.push({ key: 'source_chain_id', @@ -97,8 +97,8 @@ const ZetaChainFilterTags = ({ filters, onClearFilter, onClearAll }: Props) => { const targetChainIds = filters.target_chain_id ? castArray(filters.target_chain_id) : []; if (targetChainIds.length > 0) { const chainNames = targetChainIds.map(chainId => { - const chain = chains.find(c => c.chain_id.toString() === chainId); - return chain?.chain_name || `Chain ${ chainId }`; + const chain = chains.find(c => c.id.toString() === chainId); + return chain?.name; }); filterTags.push({ key: 'target_chain_id', diff --git a/ui/zetaChain/useZetaChainConfig.ts b/ui/zetaChain/useZetaChainConfig.ts index f3ea4f0f29..ee5c6f761f 100644 --- a/ui/zetaChain/useZetaChainConfig.ts +++ b/ui/zetaChain/useZetaChainConfig.ts @@ -1,7 +1,7 @@ import { useQuery } from '@tanstack/react-query'; import React from 'react'; -import type { CrossChainInfo } from 'types/client/crossChainInfo'; +import type { ZetaChainChainsConfigEnv, ZetaChainExternalChainConfig } from 'types/client/zetaChain'; import config from 'configs/app'; import type { ResourceError } from 'lib/api/resources'; @@ -12,9 +12,23 @@ const zetachainFeature = config.features.zetachain; export default function useZetaChainConfig() { const fetch = useFetch(); - const { isPending, data } = useQuery, Array>({ + const { isPending, data } = useQuery, ResourceError, Array>({ queryKey: [ 'zetachain-config' ], - queryFn: async() => fetch(zetachainFeature.isEnabled ? zetachainFeature.chainsConfigUrl : '', undefined, { resource: 'zetachain-config' }), + queryFn: async() => fetch( + zetachainFeature.isEnabled ? zetachainFeature.chainsConfigUrl : '', + undefined, + { resource: 'zetachain-config' }, + ) as Promise>, + select: (data) => { + return data.map((item) => ({ + id: item.chain_id.toString(), + name: item.chain_name || `Chain ${ item.chain_id }`, + logo: item.chain_logo || undefined, + explorer_url: item.instance_url, + address_url_template: item.address_url_template, + tx_url_template: item.tx_url_template, + })); + }, enabled: Boolean(zetachainFeature.isEnabled), staleTime: Infinity, }); diff --git a/yarn.lock b/yarn.lock index 64fed2cc7f..c670cbcde8 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1687,20 +1687,20 @@ resolved "https://registry.yarnpkg.com/@blockscout/bens-types/-/bens-types-1.4.1.tgz#9182a79d9015b7fa2339edf0bfa3cd0c32045e66" integrity sha512-TlZ1HVdZ2Cswm/CcvNoxS+Ydiht/YGaLo//PJR/UmkmihlEFoY4HfVJvVcUnOQXi+Si7FwJ486DPii889nTJsQ== -"@blockscout/multichain-aggregator-types@1.6.0-alpha.2": - version "1.6.0-alpha.2" - resolved "https://registry.yarnpkg.com/@blockscout/multichain-aggregator-types/-/multichain-aggregator-types-1.6.0-alpha.2.tgz#13f07652029a5d3bdcf4a07207c22599e3a5b08a" - integrity sha512-/013NEL+kC+vx3qOWWRTMhVFFifMjFXSRTjUkxnJPYrgdBZbYSNI1kB8vqe7z7nshu6uedkpgNMW2HpiVKUvQw== +"@blockscout/multichain-aggregator-types@1.6.3-alpha.3": + version "1.6.3-alpha.3" + resolved "https://registry.yarnpkg.com/@blockscout/multichain-aggregator-types/-/multichain-aggregator-types-1.6.3-alpha.3.tgz#76169e8276552130a1424cc9dc24bfb31bbb7e3d" + integrity sha512-i2w0M2BSFAnzwAOwQJwxN6hfqLZP44XiuZtlYGgmJtmRgakp6O0N1/nm+19CnyfQCrbBzUsDaVzKhcVjxBK/xw== "@blockscout/points-types@1.4.0-alpha.1": version "1.4.0-alpha.1" resolved "https://registry.yarnpkg.com/@blockscout/points-types/-/points-types-1.4.0-alpha.1.tgz#326885fcdda7c478440e4737efdfe105a29565fb" integrity sha512-DaN42ulieCLjoKyDqVc5jCuho/A8g1k7OUpOMnVgc7k8D1esSlCg+LclJ7vrcigdUXsJ5cx2nlCkySKUOfr4mA== -"@blockscout/stats-types@2.10.0-alpha": - version "2.10.0-alpha" - resolved "https://registry.yarnpkg.com/@blockscout/stats-types/-/stats-types-2.10.0-alpha.tgz#eb0babf59ef01dfd0ff9b7700aebf15905aa4d6e" - integrity sha512-DzffwodJrn5k4oENtEAmexKwK9ERqibq9dn17sxfhcEBemcUPEiu9eQJbd+bepSyt+OumWlynUv4hEWxSBaFPA== +"@blockscout/stats-types@2.11.1": + version "2.11.1" + resolved "https://registry.yarnpkg.com/@blockscout/stats-types/-/stats-types-2.11.1.tgz#686e387ee4d8f21db1c800279cc9209ef915e0b5" + integrity sha512-Ti9RbekRfLR7dUnOp2dWU0VBu7uRkutnkFqKphvfxvbrY9fiXUJ/tT6Qg+OTNewK3RbuQ0O1ZPxbqMX5eRr0OQ== "@blockscout/tac-operation-lifecycle-types@0.0.1-alpha.6": version "0.0.1-alpha.6"