OP Superchain explorer: aggregated views (#3040)

* refactor widgets on home page

* coming soon placeholder component

* top accounts page

* contract data

* address ERC-20 tokens list

* chain filter for address ERC-20 tokens

* token select for address detailed view

* address details fields

* tokens table

* stats widgets on home page

* chain indicators graph on the home page

* quick search

* search results all tab

* tabs and chain select on search results page

* search results mobile view

* add contract info to search and address details

* add text filter for tokens and search improvements

* load all results inside tab on search results page

* token balance info

* add support for domains in the full search

* Multichain explorer

Resolves #2989

* add envs validator for multichain

* add some tests

* fix search bar on search results page on mobile

* home stats fixes

* fix ts

* optimize amount of queries on address page

* add more contract info on address page

* add enter key hint to search input

* fix tests

* fix test

* update bg.primary css-variable on color theme change

* sitemap for multichain

* txs page: render first tab if blob tab is not available on the chain

* filter out in active chains from chain select on address page and user ops page

* add chain icon to advanced filter table

* fix tooltip on entity with shield

* fix ts

* show contract implementations

* fixes

* add chain icon to all lists

* refactor zetachain chains config type

* use chain id as primary option instead of chain slug

* refactor chain config type for multichain and essential dapps

* refactor chain icon and chain select

* rename chain-slug in chain_slug

* change default radius of chain icon and update screenshots for multichain tests

* change conditions for "isContract" and "isVerified"

* test fixes

* make shield optional for external chain entities

* add chain icon to approvals lists

* add stats page for multichain

* add chain postfix to chart .png and .csv assets name

* fix tests

* sync verified contracts language filter and fix loading state in the search results page

* fix sol2uml diagram and csv export

* regcred from helmfile for review

* link fixes

* fix state for unknown address

* fix incorrect chain in the context during spa transition to the same route on different chain

* add links to stats widgets

* add mixpanel to L2 demo values

* show message of on-demand contract verification

* fix screenshots

---------

Co-authored-by: Nick Zenchik <[email protected]>
This commit is contained in:
tom goriunov
2025-11-18 09:44:22 +01:00
committed by GitHub
co-authored by Nick Zenchik
parent 54c63614db
commit edc54514f1
386 changed files with 5946 additions and 2580 deletions
+21 -5
View File
@@ -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<Record<Exclude<ApiName, 'general'>, 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,
+10 -3
View File
@@ -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,
});
}
+2 -1
View File
@@ -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,
+7 -16
View File
@@ -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
+3 -1
View File
@@ -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']
+2 -2
View File
@@ -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<EssentialDappsChainConfig> } | undefined = () => {
if (!marketplaceFeature.isEnabled || !marketplaceFeature.essentialDapps) {
return;
}
+7 -1
View File
@@ -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<string, string>) {
}
}
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) {
+15 -327
View File
@@ -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<MarketplaceAppBase & MarketplaceAppSocialInfo> = 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<AdButlerConfig>()
.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<AdBannerProviders>().oneOf(SUPPORTED_AD_BANNER_PROVIDERS),
NEXT_PUBLIC_AD_BANNER_ADDITIONAL_PROVIDER: yup.string<AdBannerAdditionalProviders>().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<FeaturedNetwork> = 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<NavItemExternal> = yup
.object({
text: yup.string().required(),
url: yup.string().test(urlTest).required(),
});
const fontFamilySchema: yup.ObjectSchema<FontFamily> = yup
.object()
.transform(replaceQuotes)
.json()
.shape({
name: yup.string().required(),
url: yup.string().test(urlTest).required(),
});
const heroBannerButtonStateSchema: yup.ObjectSchema<HeroBannerButtonState> = yup.object({
background: yup.array().max(2).of(yup.string()),
text_color: yup.array().max(2).of(yup.string()),
});
const heroBannerSchema: yup.ObjectSchema<HeroBannerConfig> = 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<CustomLink> = 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<CustomLinksGroup> = yup
.object({
title: yup.string().required(),
links: yup
.array()
.of(footerLinkSchema)
.required(),
});
const networkExplorerSchema: yup.ObjectSchema<NetworkExplorer> = yup
.object({
title: yup.string().required(),
@@ -675,17 +525,6 @@ const addressMetadataSchema = yup
}),
});
const deFiDropdownItemSchema: yup.ObjectSchema<DeFiDropdownItem> = yup
.object({
text: yup.string().required(),
icon: yup.string<IconName>(),
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<DeFiDropdownItem>;
const multichainProviderConfigSchema: yup.ObjectSchema<MultichainProviderConfig> = yup.object({
name: yup.string().required(),
url_template: yup.string().required(),
@@ -693,7 +532,7 @@ const multichainProviderConfigSchema: yup.ObjectSchema<MultichainProviderConfig>
dapp_id: yup.string(),
});
const zetaChainCCTXConfigSchema: yup.ObjectSchema<CrossChainInfo> = yup.object({
const zetaChainCCTXConfigSchema: yup.ObjectSchema<ZetaChainChainsConfigEnv> = 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<ChainIndicatorId>().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<HomeStatsWidgetId>().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<unknown> | 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<unknown> | 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<NavigationLayout>().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<NavigationPromoBannerConfig>().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<AdTextProviders>().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<GasUnit>().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);
@@ -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;
@@ -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<AdButlerConfig>()
.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<AdTextProviders>().oneOf(SUPPORTED_AD_TEXT_PROVIDERS),
NEXT_PUBLIC_AD_BANNER_PROVIDER: yup.string<AdBannerProviders>().oneOf(SUPPORTED_AD_BANNER_PROVIDERS),
NEXT_PUBLIC_AD_BANNER_ADDITIONAL_PROVIDER: yup.string<AdBannerAdditionalProviders>().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<DeFiDropdownItem> = yup
.object({
text: yup.string().required(),
icon: yup.string<IconName>(),
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<DeFiDropdownItem>;
export const defiDropdownSchema = yup.object({
NEXT_PUBLIC_DEFI_DROPDOWN_ITEMS: yup
.array()
.transform(replaceQuotes)
.json()
.of(deFiDropdownItemSchema),
});
@@ -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(),
});
+220
View File
@@ -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<HeroBannerButtonState> = yup.object({
background: yup.array().max(2).of(yup.string()),
text_color: yup.array().max(2).of(yup.string()),
});
const heroBannerSchema: yup.ObjectSchema<HeroBannerConfig> = 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<ChainIndicatorId>().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<HomeStatsWidgetId>().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<FeaturedNetwork> = 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<NavItemExternal> = 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<unknown> | 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<unknown> | 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<NavigationLayout>().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<NavigationPromoBannerConfig>().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<CustomLink> = 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<CustomLinksGroup> = 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<FontFamily> = 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(),
});
+12 -5
View File
@@ -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
@@ -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
+31
View File
@@ -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(', ') :
'';
@@ -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<string>, 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<ChainConfig['config']>;
const chainConfigs = await Promise.all(explorerUrls.map(computeChainConfig)) as Array<typeof appConfig>;
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;
}),
};
+1 -1
View File
@@ -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;
@@ -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<string>) {
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<string, { explorers: [ { url: string } ], logo: string }>;
return chainIds.map((chainId) => ({
id: chainId,
logoUrl: chainsInfo[chainId]?.logo,
}))
}
async function computeChainConfig(url: string): Promise<unknown> {
return new Promise((resolve, reject) => {
const workerPath = resolvePath(currentDir, 'worker.js');
@@ -48,8 +63,9 @@ async function computeChainConfig(url: string): Promise<unknown> {
}
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;
}),
};
@@ -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;
}
@@ -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
+18 -3
View File
@@ -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)
&nbsp;
@@ -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<string>` | 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}'` |
&nbsp;
### 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 |
&nbsp;
### 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+ |
&nbsp;
### Puzzle game badge claim link
Vendored
+2 -1
View File
@@ -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<string, string>;
__multichainConfig: MultichainConfig;
__essentialDappsChains: MultichainConfig;
__essentialDappsChains: { chains: Array<EssentialDappsChainConfig> };
}
namespace NodeJS {
+2 -2
View File
@@ -11,8 +11,8 @@ export default function useAddressMetadataInfoQuery(addresses: Array<string>, 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<typeof resource, unknown, AddressMetadataInfoFormatted>(resource, {
queryParams: {
+2 -2
View File
@@ -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<R extends ResourceName>(
pathParams?: ResourcePathParams<R>,
queryParams?: Record<string, string | Array<string> | 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;
+4 -4
View File
@@ -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];
+1 -1
View File
@@ -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;
}
+13 -5
View File
@@ -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> :
R extends ContractInfoApiResourceName ? ContractInfoApiResourcePayload<R> :
R extends GeneralApiResourceName ? GeneralApiResourcePayload<R> :
R extends MetadataApiResourceName ? MetadataApiResourcePayload<R> :
R extends MultichainApiResourceName ? MultichainApiResourcePayload<R> :
R extends MultichainAggregatorApiResourceName ? MultichainAggregatorApiResourcePayload<R> :
R extends MultichainStatsApiResourceName ? MultichainStatsApiResourcePayload<R> :
R extends RewardsApiResourceName ? RewardsApiResourcePayload<R> :
R extends StatsApiResourceName ? StatsApiResourcePayload<R> :
R extends TacOperationLifecycleApiResourceName ? TacOperationLifecycleApiResourcePayload<R> :
@@ -110,7 +118,7 @@ R extends BensApiResourceName ? BensApiPaginationFilters<R> :
R extends ClustersApiResourceName ? ClustersApiPaginationFilters :
R extends GeneralApiResourceName ? GeneralApiPaginationFilters<R> :
R extends ContractInfoApiResourceName ? ContractInfoApiPaginationFilters<R> :
R extends MultichainApiResourceName ? MultichainApiPaginationFilters<R> :
R extends MultichainAggregatorApiResourceName ? MultichainAggregatorApiPaginationFilters<R> :
R extends TacOperationLifecycleApiResourceName ? TacOperationLifecycleApiPaginationFilters<R> :
R extends ZetaChainApiResourceName ? ZetaChainApiPaginationFilters<R> :
never;
-30
View File
@@ -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<string, ApiResource>;
export type MultichainApiResourceName = `multichain:${ keyof typeof MULTICHAIN_API_RESOURCES }`;
/* eslint-disable @stylistic/indent */
export type MultichainApiResourcePayload<R extends MultichainApiResourceName> =
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 MultichainApiResourceName> =
R extends 'multichain:interop_messages' ? Partial<multichain.ListInteropMessagesRequest> :
R extends 'multichain:interop_messages_count' ? Partial<multichain.CountInteropMessagesRequest> :
never;
/* eslint-enable @stylistic/indent */
+85
View File
@@ -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<string, ApiResource>;
export type MultichainAggregatorApiResourceName = `multichainAggregator:${ keyof typeof MULTICHAIN_AGGREGATOR_API_RESOURCES }`;
/* eslint-disable @stylistic/indent */
export type MultichainAggregatorApiResourcePayload<R extends MultichainAggregatorApiResourceName> =
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 MultichainAggregatorApiResourceName> =
R extends 'multichainAggregator:address_tokens' ? Partial<multichain.ListAddressTokensRequest> :
R extends 'multichainAggregator:tokens' ? Partial<multichain.ListClusterTokensRequest> :
never;
/* eslint-enable @stylistic/indent */
+16
View File
@@ -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<string, ApiResource>;
export type MultichainStatsApiResourceName = `multichainStats:${ keyof typeof MULTICHAIN_STATS_API_RESOURCES }`;
/* eslint-disable @stylistic/indent */
export type MultichainStatsApiResourcePayload<R extends MultichainStatsApiResourceName> =
R extends 'multichainStats:pages_main' ? stats.MainPageMultichainStats :
never;
/* eslint-enable @stylistic/indent */
+1 -1
View File
@@ -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 {
+2 -2
View File
@@ -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<R extends ResourceName> {
queryParams?: Record<string, string | Array<string> | number | boolean | undefined | null>;
fetchParams?: Pick<FetchParams, 'body' | 'method' | 'signal' | 'headers'>;
logError?: boolean;
chain?: ChainConfig;
chain?: ExternalChainExtended;
}
export default function useApiFetch() {
+6 -4
View File
@@ -15,22 +15,24 @@ export interface Params<R extends PaginatedResourceName> {
// eslint-disable-next-line max-len
queryOptions?: Omit<UseInfiniteQueryOptions<TQueryData<R>, TError, InfiniteData<TQueryData<R>>, TQueryData<R>, QueryKey, TPageParam<R>>, 'queryKey' | 'queryFn' | 'getNextPageParam' | 'initialPageParam'>;
pathParams?: ApiFetchParams<R>['pathParams'];
queryParams?: Record<string, string | Array<string> | number | boolean | undefined | null>;
}
type ReturnType<Resource extends PaginatedResourceName> = UseInfiniteQueryResult<InfiniteData<ResourcePayload<Resource>>, ResourceError<unknown>>;
export type ReturnType<Resource extends PaginatedResourceName> = UseInfiniteQueryResult<InfiniteData<ResourcePayload<Resource>>, ResourceError<unknown>>;
export default function useApiInfiniteQuery<R extends PaginatedResourceName>({
resourceName,
queryOptions,
pathParams,
queryParams,
}: Params<R>): ReturnType<R> {
const apiFetch = useApiFetch();
return useInfiniteQuery<TQueryData<R>, TError, InfiniteData<TQueryData<R>>, QueryKey, TPageParam<R>>({
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<TQueryData<R>>;
const nextQueryParams = 'pageParam' in context ? (context.pageParam || undefined) : undefined;
return apiFetch(resourceName, { pathParams, queryParams: { ...queryParams, ...nextQueryParams } }) as Promise<TQueryData<R>>;
},
initialPageParam: null,
getNextPageParam: (lastPage) => {
+11 -13
View File
@@ -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<R extends ResourceName, E = unknown, D = ResourcePayload<R>> {
pathParams?: ResourcePathParams<R>;
queryParams?: Record<string, string | Array<string> | number | boolean | undefined>;
queryParams?: Record<string, string | Array<string> | number | boolean | undefined | null>;
fetchParams?: Pick<FetchParams, 'body' | 'method' | 'headers'>;
queryOptions?: Partial<Omit<UseQueryOptions<ResourcePayload<R>, ResourceError<E>, D>, 'queryFn'>>;
logError?: boolean;
chainSlug?: string;
chain?: ChainConfig;
chain?: ExternalChainExtended;
}
export interface GetResourceKeyParams<R extends ResourceName, E = unknown, D = ResourcePayload<R>>
extends Pick<Params<R, E, D>, 'pathParams' | 'queryParams'> {
chainSlug?: string;
chainId?: string;
}
export function getResourceKey<R extends ResourceName>(resource: R, { pathParams, queryParams, chainSlug }: GetResourceKeyParams<R> = {}) {
export function getResourceKey<R extends ResourceName>(resource: R, { pathParams, queryParams, chainId }: GetResourceKeyParams<R> = {}) {
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<R extends ResourceName, E = unknown, D = ResourcePayload<R>>(
resource: R,
{ queryOptions, pathParams, queryParams, fetchParams, logError, chainSlug, chain: chainProp }: Params<R, E, D> = {},
{ queryOptions, pathParams, queryParams, fetchParams, logError, chain: chainProp }: Params<R, E, D> = {},
) {
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<ResourcePayload<R>, ResourceError<E>, 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
+19 -23
View File
@@ -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<TMultichainContext | null>(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<string | undefined>(chainSlugProp ?? chainSlugQueryParam);
const [ level, setLevel ] = React.useState<ContextLevel | undefined>(levelProp);
const [ chainId, setChainId ] = React.useState<string | undefined>(
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 (
<MultichainContext.Provider value={ value }>
+3 -4
View File
@@ -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<NavItem> | Array<Array<NavItem>> = [
config.features.opSuperchain.isEnabled ? {
text: 'Verify contract',
// TODO @tom2drum adjust URL to Vera
url: 'https://vera.blockscout.com',
} : {
text: 'Verify contract',
+11 -10
View File
@@ -70,16 +70,17 @@ const OG_TYPE_DICT: Record<Route['pathname'], OGPageType> = {
'/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',
+11 -10
View File
@@ -73,16 +73,17 @@ const TEMPLATE_MAP: Record<Route['pathname'], string> = {
'/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,
+11 -10
View File
@@ -74,16 +74,17 @@ const TEMPLATE_MAP: Record<Route['pathname'], string> = {
'/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',
+11 -10
View File
@@ -68,16 +68,17 @@ export const PAGE_TYPE_DICT: Record<Route['pathname'], string> = {
'/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',
+3
View File
@@ -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({
+38
View File
@@ -0,0 +1,38 @@
import type * as multichain from '@blockscout/multichain-aggregator-types';
export function getName(data: { chain_infos: Record<string, multichain.GetAddressResponse_ChainInfo> }) {
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<string, multichain.GetAddressResponse_ChainInfo> } | 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<string, multichain.GetAddressResponse_ChainInfo> } | 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;
}
-6
View File
@@ -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;
}
+10
View File
@@ -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;
}
-5
View File
@@ -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 })`;
}
+26 -3
View File
@@ -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<string>,
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;
}
+1 -1
View File
@@ -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;
}
-16
View File
@@ -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 }`;
}
+19 -5
View File
@@ -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<string>;
isLoading?: boolean;
chainIds?: Array<string>;
withAllOption?: boolean;
}
export default function useRoutedChainSelect(props?: Props) {
const router = useRouter();
const [ value, setValue ] = React.useState<Array<string> | 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<string> }) => {
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(() => ({
+7 -3
View File
@@ -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<typeof appConfig> = 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<Chain> | 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<Chain> | undefined = (() => {
@@ -80,7 +84,7 @@ export const essentialDappsChains: Array<Chain> | 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 = (() => {
-1
View File
@@ -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;
}
+1 -1
View File
@@ -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) {
+1 -1
View File
@@ -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();
+1 -1
View File
@@ -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) {
+4 -4
View File
@@ -11,15 +11,15 @@ import { chains, parentChain } from 'lib/web3/chains';
const feature = appConfig.features.blockchainInteraction;
const getChainTransportFromConfig = (config: typeof appConfig, readOnly?: boolean): Record<string, Transport> => {
if (!config.chain.id) {
const getChainTransportFromConfig = (config: Partial<typeof appConfig> | undefined, readOnly?: boolean): Record<string, Transport> => {
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<string
}
return chains
.map(({ config }) => getChainTransportFromConfig(config, readOnly))
.map(({ app_config: config }) => getChainTransportFromConfig(config, readOnly))
.reduce((result, item) => {
Object.entries(item).map(([ id, transport ]) => {
result[id] = transport;
+171 -12
View File
@@ -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,
},
},
};
+2 -2
View File
@@ -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' : '';
+7 -5
View File
@@ -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',
],
};
}
+2
View File
@@ -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 ]);
+3 -3
View File
@@ -35,15 +35,15 @@ export const factory = (guards: Array<Guard | Array<Guard>>, chainConfig = confi
export const factoryMultichain = (guards: Array<Guard>) => {
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);
};
};
+11 -10
View File
@@ -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">
+20 -7
View File
@@ -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 const route = (route: Route, multichainContext?: TMultichainContext | null) => {
return nextjsRoute(routeParams(route, multichainContext));
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;
}
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;
};
+2 -2
View File
@@ -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",
+1 -1
View File
@@ -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(),
@@ -10,8 +10,8 @@ const AccountsLabelSearch = dynamic(() => import('ui/pages/AccountsLabelSearch')
const Page: NextPage = () => {
return (
<PageNextJs pathname="/chain/[chain-slug]/accounts/label/[slug]">
<MultichainProvider level="page">
<PageNextJs pathname="/chain/[chain_slug]/accounts/label/[slug]">
<MultichainProvider>
<AccountsLabelSearch/>
</MultichainProvider>
</PageNextJs>
@@ -8,8 +8,8 @@ import AdvancedFilter from 'ui/pages/AdvancedFilter';
const Page: NextPage = () => {
return (
<PageNextJs pathname="/chain/[chain-slug]/advanced-filter">
<MultichainProvider level="page">
<PageNextJs pathname="/chain/[chain_slug]/advanced-filter">
<MultichainProvider>
<AdvancedFilter/>
</MultichainProvider>
</PageNextJs>
@@ -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: Props) => {
return (
<PageNextJs pathname="/chain/[chain-slug]/block/[height_or_hash]" query={ props.query }>
<MultichainProvider level="page">
<Block/>
</MultichainProvider>
<PageNextJs pathname="/chain/[chain_slug]/block/[height_or_hash]" query={ props.query }>
<OpSuperchainBlock/>
</PageNextJs>
);
};
@@ -11,8 +11,8 @@ const BlockCountdown = dynamic(() => import('ui/pages/BlockCountdown'), { ssr: f
const Page: NextPage<Props> = (props: Props) => {
return (
<PageNextJs pathname="/chain/[chain-slug]/block/countdown/[height]" query={ props.query }>
<MultichainProvider level="page">
<PageNextJs pathname="/chain/[chain_slug]/block/countdown/[height]" query={ props.query }>
<MultichainProvider>
<BlockCountdown/>
</MultichainProvider>
</PageNextJs>
@@ -11,8 +11,8 @@ const BlockCountdownIndex = dynamic(() => import('ui/pages/BlockCountdownIndex')
const Page: NextPage<Props> = (props: Props) => {
return (
<PageNextJs pathname="/chain/[chain-slug]/block/countdown" query={ props.query }>
<MultichainProvider level="page">
<PageNextJs pathname="/chain/[chain_slug]/block/countdown" query={ props.query }>
<MultichainProvider>
<BlockCountdownIndex/>
</MultichainProvider>
</PageNextJs>
@@ -8,8 +8,8 @@ import CsvExport from 'ui/pages/CsvExport';
const Page: NextPage = () => {
return (
<PageNextJs pathname="/chain/[chain-slug]/csv-export">
<MultichainProvider level="page">
<PageNextJs pathname="/chain/[chain_slug]/csv-export">
<MultichainProvider>
<CsvExport/>
</MultichainProvider>
</PageNextJs>
@@ -11,8 +11,8 @@ const UserOp = dynamic(() => import('ui/pages/UserOp'), { ssr: false });
const Page: NextPage<Props> = (props: Props) => {
return (
<PageNextJs pathname="/chain/[chain-slug]/op/[hash]" query={ props.query }>
<MultichainProvider level="page">
<PageNextJs pathname="/chain/[chain_slug]/op/[hash]" query={ props.query }>
<MultichainProvider>
<UserOp/>
</MultichainProvider>
</PageNextJs>
@@ -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<typeof pathname>> = (props: Props<typeof pathname>) => {
const chainSlug = props.query?.['chain-slug'];
const chainData = multichainConfig()?.chains.find(chain => chain.slug === chainSlug);
return (
<PageNextJs pathname={ pathname } query={ props.query } apiData={ props.apiData }>
<SocketProvider url={ getSocketUrl(chainData?.config) }>
<MultichainProvider level="page">
<Token/>
</MultichainProvider>
</SocketProvider>
<OpSuperchainToken/>
</PageNextJs>
);
};
@@ -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<typeof pathname>> = (props: Props<typeof pathname>) => {
const chainSlug = props.query?.['chain-slug'];
const chainData = multichainConfig()?.chains.find(chain => chain.slug === chainSlug);
return (
<PageNextJs pathname={ pathname } query={ props.query } apiData={ props.apiData }>
<SocketProvider url={ getSocketUrl(chainData?.config) }>
<MultichainProvider level="page">
<TokenInstance/>
</MultichainProvider>
</SocketProvider>
<OpSuperchainTokenInstance/>
</PageNextJs>
);
};
@@ -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: Props) => {
return (
<PageNextJs pathname="/chain/[chain-slug]/tx/[hash]" query={ props.query }>
<MultichainProvider level="page">
<Transaction/>
</MultichainProvider>
<PageNextJs pathname="/chain/[chain_slug]/tx/[hash]" query={ props.query }>
<OpSuperchainTx/>
</PageNextJs>
);
};
@@ -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 (
<PageNextJs pathname="/visualize/sol2uml">
<MultichainProvider>
<Sol2Uml/>
</MultichainProvider>
</PageNextJs>
);
};
export default Page;
export { base as getServerSideProps } from 'nextjs/getServerSideProps/multichain';
+8 -1
View File
@@ -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: Props) => {
return (
+8 -1
View File
@@ -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<typeof pathname>> = (props: Props<typeof pathname>) => {
return (
<PageNextJs pathname={ pathname } query={ props.query } apiData={ props.apiData }>
<Chart/>
{ config.features.opSuperchain.isEnabled ? (
<MultichainProvider chainId={ getQueryParamString(props.query?.chain_id) }>
<Chart/>
</MultichainProvider>
) : (
<Chart/>
) }
</PageNextJs>
);
};
+3 -1
View File
@@ -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 (
<PageNextJs pathname="/stats">
<Stats/>
{ config.features.opSuperchain.isEnabled ? <OpSuperchainStats/> : <Stats/> }
</PageNextJs>
);
};
+2 -3
View File
@@ -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<typeof pathname>> = (props: Props<typeof pathname>) => {
return (
<PageNextJs pathname={ pathname } query={ props.query } apiData={ props.apiData }>
{ config.features.opSuperchain.isEnabled ? <OpSuperchainToken/> : <Token/> }
<Token/>
</PageNextJs>
);
};
@@ -26,7 +25,7 @@ const Page: NextPage<Props<typeof pathname>> = (props: Props<typeof pathname>) =
export default Page;
export const getServerSideProps: GetServerSideProps<Props<typeof pathname>> = async(ctx) => {
const baseResponse = await gSSP.base<typeof pathname>(ctx);
const baseResponse = await gSSP.token<typeof pathname>(ctx);
if ('props' in baseResponse && !config.features.opSuperchain.isEnabled) {
if (
+1 -1
View File
@@ -25,7 +25,7 @@ const Page: NextPage<Props<typeof pathname>> = (props: Props<typeof pathname>) =
export default Page;
export const getServerSideProps: GetServerSideProps<Props<typeof pathname>> = async(ctx) => {
const baseResponse = await gSSP.base<typeof pathname>(ctx);
const baseResponse = await gSSP.token<typeof pathname>(ctx);
if (config.meta.og.enhancedDataEnabled && 'props' in baseResponse) {
const botInfo = detectBotRequest(ctx.req);
+1 -7
View File
@@ -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: Props) => {
export default Page;
export { base as getServerSideProps } from 'nextjs/getServerSideProps/main';
export { tx as getServerSideProps } from 'nextjs/getServerSideProps/main';
+4 -1
View File
@@ -114,7 +114,10 @@ export const ENVS_MAP: Record<string, Array<[string, string]>> = {
[ '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' ],
@@ -10,6 +10,8 @@ const fixture: TestFixture<MockMultichainConfigFixture, { page: Page }> = async(
window.__multichainConfig = {
chains: [
opSuperchainMock.chainDataA,
opSuperchainMock.chainDataB,
opSuperchainMock.chainDataC,
],
};
}, [ opSuperchainMock ]);
+15
View File
@@ -0,0 +1,15 @@
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 260 200">
<g clip-path="url(#a)">
<path fill="url(#b)" d="M130 163.385c11.598 0 21 9.402 21 21s-9.402 21-21 21-21-9.402-21-21 9.402-21 21-21Zm0 3c-9.941 0-18 8.059-18 18s8.059 18 18 18 18-8.059 18-18-8.059-18-18-18Zm-56-31c11.598 0 21 9.402 21 21s-9.402 21-21 21-21-9.402-21-21 9.402-21 21-21Zm112 0c11.598 0 21 9.402 21 21s-9.402 21-21 21-21-9.402-21-21 9.402-21 21-21Zm-112 3c-9.941 0-18 8.059-18 18s8.059 18 18 18 18-8.059 18-18-8.059-18-18-18Zm112 0c-9.941 0-18 8.059-18 18s8.059 18 18 18 18-8.059 18-18-8.059-18-18-18Zm-167-31c11.598 0 21 9.402 21 21s-9.402 21-21 21-21-9.402-21-21 9.402-21 21-21Zm128.234 33c-3.795 5.44-10.098 9-17.234 9-7.136 0-13.439-3.56-17.234-9h3.819c3.296 3.682 8.084 6 13.415 6a17.953 17.953 0 0 0 13.415-6h3.819Zm94.766-33c11.598 0 21 9.402 21 21s-9.402 21-21 21-21-9.402-21-21 9.402-21 21-21Zm-223 3c-9.941 0-18 8.059-18 18s8.059 18 18 18 18-8.059 18-18-8.059-18-18-18Zm223 0c-9.941 0-18 8.059-18 18s8.059 18 18 18 18-8.059 18-18-8.059-18-18-18Zm-168-31c.338 0 .674.008 1.008.024L74 80.385v2c-9.941 0-18 8.059-18 18s8.059 18 18 18v2l1.11.97c-.368.02-.738.03-1.11.03-11.598 0-21-9.402-21-21s9.402-21 21-21Zm113.876.084c10.719.949 19.124 9.95 19.124 20.916 0 11.171-8.723 20.303-19.729 20.96l1.729-.96v-2.251c8.513-1.429 15-8.83 15-17.749 0-8.92-6.487-16.322-15-17.75v-2.25l-1.124-.916ZM19 50.385c11.598 0 21 9.402 21 21s-9.402 21-21 21-21-9.402-21-21 9.402-21 21-21Zm223 0c11.598 0 21 9.402 21 21s-9.402 21-21 21-21-9.402-21-21 9.402-21 21-21Zm-223 3c-9.941 0-18 8.059-18 18 0 9.94 8.059 18 18 18s18-8.06 18-18c0-9.941-8.059-18-18-18Zm223 0c-9.941 0-18 8.059-18 18 0 9.94 8.059 18 18 18s18-8.06 18-18c0-9.941-8.059-18-18-18Zm-168-31c11.598 0 21 9.402 21 21s-9.402 21-21 21-21-9.402-21-21 9.402-21 21-21Zm112 0c11.598 0 21 9.402 21 21s-9.402 21-21 21-21-9.402-21-21 9.402-21 21-21Zm-112 3c-9.941 0-18 8.059-18 18 0 9.94 8.059 18 18 18s18-8.06 18-18c0-9.941-8.059-18-18-18Zm112 0c-9.941 0-18 8.059-18 18 0 9.94 8.059 18 18 18s18-8.06 18-18c0-9.941-8.059-18-18-18Zm-56 25c7.564 0 14.194 4 17.891 10h-3.643c-3.292-4.258-8.45-7-14.248-7s-10.956 2.742-14.248 7h-3.643c3.697-6 10.327-10 17.891-10Zm0-55c11.598 0 21 9.402 21 21s-9.402 21-21 21-21-9.402-21-21 9.402-21 21-21Zm0 3c-9.941 0-18 8.059-18 18 0 9.94 8.059 18 18 18s18-8.06 18-18c0-9.941-8.059-18-18-18Z"/>
<path fill="#E2E8F0" d="M103.588 76.385a24.004 24.004 0 0 1 19.661 10.238l16.056 22.936v.001a16.006 16.006 0 0 0 13.107 6.825h3.932l-5.172-5.172a4 4 0 1 1 5.656-5.656l11.98 11.979c.145.144.28.298.403.463.025.033.048.068.071.102.014.02.029.039.042.059l.065.1.042.068.042.072.062.112c.01.017.017.036.027.053.019.038.038.075.056.114l.034.076a1.71 1.71 0 0 1 .078.188l.039.102.014.043c.037.108.07.217.098.329l.014.06a4 4 0 0 1 .002 1.803c-.005.023-.009.047-.015.07a3.964 3.964 0 0 1-.095.322l-.018.051-.039.101c-.011.03-.022.061-.035.091l-.034.08a2.688 2.688 0 0 1-.099.207l-.028.055-.053.095a3.97 3.97 0 0 1-.157.255c-.013.021-.028.04-.042.06-.023.034-.046.068-.071.102a4.053 4.053 0 0 1-.383.444l-12 12a4 4 0 1 1-5.656-5.656l5.172-5.172h-3.932a24.016 24.016 0 0 1-11.084-2.713 24.01 24.01 0 0 1-8.154-6.94l-.423-.586-16.056-22.935v-.001a16.003 16.003 0 0 0-12.626-6.818l-.481-.007H94a4 4 0 0 1 0-8h9.588Zm13.814 32.163a4.001 4.001 0 0 1 6.556 4.586l-.708 1.012h-.001a24 24 0 0 1-19.661 10.239H94a4 4 0 0 1 0-8h9.588l.481-.007a16.003 16.003 0 0 0 12.625-6.818l.708-1.012Zm33.77-42.991a3.999 3.999 0 0 1 5.656 0l11.98 11.98c.145.143.28.297.403.462.025.033.048.068.071.102.014.02.029.039.042.06a3.959 3.959 0 0 1 .21.35l.028.054.056.114.034.076c.015.032.03.065.043.098a27.47 27.47 0 0 1 .074.192l.014.043c.037.108.07.217.098.329l.014.06a4.004 4.004 0 0 1-.111 2.201l-.015.045-.039.101c-.011.03-.022.061-.035.091-.013.033-.028.065-.043.098l-.034.076-.056.113-.028.055a3.77 3.77 0 0 1-.21.35l-.042.06c-.023.034-.046.068-.071.102a4.066 4.066 0 0 1-.383.444l-12 12a4 4 0 0 1-5.656-5.656l5.172-5.172h-3.932a16.003 16.003 0 0 0-13.103 6.818l.001.001-.708 1.016a4.001 4.001 0 0 1-6.564-4.575l.708-1.015.005-.007a24.004 24.004 0 0 1 19.661-10.238h3.932l-5.172-5.172a4 4 0 0 1 0-5.656Z"/>
</g>
<defs>
<radialGradient id="b" cx="0" cy="0" r="1" gradientTransform="scale(132.5 105) rotate(90 .014 .97)" gradientUnits="userSpaceOnUse">
<stop stop-color="#CBD5E0" stop-opacity=".8"/>
<stop offset="1" stop-color="#CBD5E0" stop-opacity="0"/>
</radialGradient>
<clipPath id="a">
<path fill="#fff" d="M0 0h260v200H0z"/>
</clipPath>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 4.4 KiB

+68 -18
View File
@@ -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,
},
},
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',
},
target: {
hash: ADDRESS_HASH,
},
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,
};
+5 -2
View File
@@ -65,12 +65,13 @@ interface CollapsibleListProps<T> extends FlexProps {
renderItem: (item: T, index: number) => React.ReactNode;
triggerProps?: LinkProps;
cutLength?: number;
text?: [string, string];
}
export const CollapsibleList = <T,>(props: CollapsibleListProps<T>) => {
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 = <T,>(props: CollapsibleListProps<T>) => {
setIsExpanded((flag) => !flag);
}, []);
const text = isExpanded ? (textProp?.[1] ?? 'Hide') : (textProp?.[0] ?? 'Show all');
return (
<Flex flexDir="column" w="100%" { ...rest }>
{ items.slice(0, isExpanded ? undefined : cutLength).map(renderItem) }
@@ -91,7 +94,7 @@ export const CollapsibleList = <T,>(props: CollapsibleListProps<T>) => {
onClick={ handleToggle }
{ ...triggerProps }
>
{ isExpanded ? 'Hide' : 'Show all' }
{ text }
</Link>
) }
</Flex>
+3 -3
View File
@@ -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 (
<ChakraSelect.Item key={ item.value } item={ item } { ...rest } ref={ ref }>
{ startElement }
<ChakraSelect.Item key={ itemProps.value } item={ itemProps } { ...rest } ref={ ref }>
{ icon }
{ children }
<ChakraSelect.ItemIndicator asChild>
<Icon boxSize={ 5 } flexShrink={ 0 } ml="auto"><CheckIcon/></Icon>
@@ -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' }}
+2 -2
View File
@@ -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<HTMLDivElement>(null);
const handleValueChange = React.useCallback(({ value }: { value: string }) => {
-1
View File
@@ -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;
@@ -78,7 +78,6 @@ const FullscreenChartModal = ({
charts={ charts }
handleZoom={ handleZoom }
zoomRange={ zoomRange }
title={ title }
resolution={ resolution }
/>
</DialogBody>
@@ -94,7 +94,6 @@ export const ChartWidget = React.memo(({
charts={ displayedCharts }
isError={ isError }
isLoading={ isLoading }
title={ title }
empty={ !hasNonEmptyCharts }
emptyText={ emptyText }
handleZoom={ handleZoom }
@@ -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 }
+11 -4
View File
@@ -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
+2 -2
View File
@@ -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 ""
-10
View File
@@ -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;
};
+13
View File
@@ -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<typeof config, 'app' | 'chain'> & {
apis: Pick<typeof config['apis'], 'general'>;
};
contracts?: Chain['contracts'];
}
export type EssentialDappsConfig = {
swap?: {
chains: Array<string>;
+90
View File
@@ -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<multichain.AggregatedTokenInfo, 'chain_infos' | 'address_hash'> {
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<multichain.ListAddressTokensResponse_AggregatedTokenBalanceInfo, 'token' | 'token_id'> {
token: AggregatedTokenInfo;
token_id: string | null;
token_instance: null;
}
export interface AddressTokensResponse extends Omit<multichain.ListAddressTokensResponse, 'items'> {
items: Array<AddressTokenItem>;
}
export interface TokensResponse extends Omit<multichain.ListClusterTokensResponse, 'items'> {
items: Array<AggregatedTokenInfo>;
}
// 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<string, multichain.GetAddressResponse_ChainInfo>;
}
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<string, multichain.AggregatedTokenInfo_ChainInfo>;
}
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;
+3
View File
@@ -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;
+19
View File
@@ -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> | 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;
};

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