Commit 843a0471 authored by tom goriunov's avatar tom goriunov Committed by GitHub

OP Superchain Explorer: MVP (#2786)

* context for storing subchain config and tx view for subchain

* address page and block page for subchain

* address aggregated page: local txs tab

* connect MultichainSelect to context

* home page placeholder

* refactor subchain select state

* envs for demo

* fix ts

* clear value for l2 review

* subchain widgets on home page

* csp policies

* sockets, duck and goose

* fix socket on subchain address page

* link builder for subchain views

* update home widget

* fix time increment

* enable tx interpretator and metadata service

* generate multichain config based on every chain app config

* Fix the multichain config to work in Docker

* multichain socket test

* rename subchain-id to subchain-slug path param

* refactoring

* update chain icons on entities

* home page: latest local txs

* latest cross-chain txs

* minor improvements

* renaming, pt. 1

* rename chain routes

* enable blockchain interaction

* add loading state to icon shield

* fix build

* fix tests

* update types package
parent a01299b6
...@@ -25,6 +25,7 @@ on: ...@@ -25,6 +25,7 @@ on:
- neon_devnet - neon_devnet
- optimism - optimism
- optimism_sepolia - optimism_sepolia
- optimism_superchain
- polygon - polygon
- rootstock - rootstock
- scroll_sepolia - scroll_sepolia
......
...@@ -27,6 +27,7 @@ on: ...@@ -27,6 +27,7 @@ on:
- neon_devnet - neon_devnet
- optimism - optimism
- optimism_sepolia - optimism_sepolia
- optimism_superchain
- polygon - polygon
- rari_testnet - rari_testnet
- rootstock - rootstock
......
...@@ -14,6 +14,7 @@ ...@@ -14,6 +14,7 @@
/out/ /out/
/public/assets/envs.js /public/assets/envs.js
/public/assets/configs /public/assets/configs
/public/assets/multichain
/public/icons/sprite.svg /public/icons/sprite.svg
/public/icons/sprite.*.svg /public/icons/sprite.*.svg
/public/icons/registry.json /public/icons/registry.json
......
...@@ -377,6 +377,7 @@ ...@@ -377,6 +377,7 @@
"optimism", "optimism",
"optimism_interop_0", "optimism_interop_0",
"optimism_sepolia", "optimism_sepolia",
"optimism_superchain",
"polygon", "polygon",
"rari_testnet", "rari_testnet",
"rootstock_testnet", "rootstock_testnet",
......
...@@ -45,6 +45,12 @@ WORKDIR /sitemap-generator ...@@ -45,6 +45,12 @@ WORKDIR /sitemap-generator
COPY ./deploy/tools/sitemap-generator/package.json ./deploy/tools/sitemap-generator/yarn.lock ./ COPY ./deploy/tools/sitemap-generator/package.json ./deploy/tools/sitemap-generator/yarn.lock ./
RUN yarn --frozen-lockfile --network-timeout 100000 RUN yarn --frozen-lockfile --network-timeout 100000
### MULTICHAIN CONFIG GENERATOR
# Install dependencies
WORKDIR /multichain-config-generator
COPY ./deploy/tools/multichain-config-generator/package.json ./deploy/tools/multichain-config-generator/yarn.lock ./
RUN yarn --frozen-lockfile --network-timeout 100000
# ***************************** # *****************************
# ****** STAGE 2: Build ******* # ****** STAGE 2: Build *******
...@@ -106,6 +112,11 @@ COPY --from=deps /favicon-generator/node_modules ./deploy/tools/favicon-generato ...@@ -106,6 +112,11 @@ COPY --from=deps /favicon-generator/node_modules ./deploy/tools/favicon-generato
# Copy dependencies and source code # Copy dependencies and source code
COPY --from=deps /sitemap-generator/node_modules ./deploy/tools/sitemap-generator/node_modules COPY --from=deps /sitemap-generator/node_modules ./deploy/tools/sitemap-generator/node_modules
### MULTICHAIN CONFIG GENERATOR
# Copy dependencies and source code, then build
COPY --from=deps /multichain-config-generator/node_modules ./deploy/tools/multichain-config-generator/node_modules
RUN cd ./deploy/tools/multichain-config-generator && yarn build
# ***************************** # *****************************
# ******* STAGE 3: Run ******** # ******* STAGE 3: Run ********
...@@ -130,8 +141,11 @@ RUN chown nextjs:nodejs .next ...@@ -130,8 +141,11 @@ RUN chown nextjs:nodejs .next
COPY --from=builder /app/next.config.js ./ COPY --from=builder /app/next.config.js ./
COPY --from=builder /app/public ./public COPY --from=builder /app/public ./public
COPY --from=builder /app/package.json ./package.json COPY --from=builder /app/package.json ./package.json
# Copy tools
COPY --from=builder /app/deploy/tools/envs-validator/index.js ./envs-validator.js COPY --from=builder /app/deploy/tools/envs-validator/index.js ./envs-validator.js
COPY --from=builder /app/deploy/tools/feature-reporter/index.js ./feature-reporter.js COPY --from=builder /app/deploy/tools/feature-reporter/index.js ./feature-reporter.js
COPY --from=builder /app/deploy/tools/multichain-config-generator/dist ./deploy/tools/multichain-config-generator/dist
# Copy scripts # Copy scripts
## Entripoint ## Entripoint
......
...@@ -7,6 +7,7 @@ import { getEnvValue } from './utils'; ...@@ -7,6 +7,7 @@ import { getEnvValue } from './utils';
export interface ApiPropsBase { export interface ApiPropsBase {
endpoint: string; endpoint: string;
basePath?: string; basePath?: string;
socketEndpoint?: string;
} }
export interface ApiPropsFull extends ApiPropsBase { export interface ApiPropsFull extends ApiPropsBase {
...@@ -100,6 +101,25 @@ const rewardsApi = (() => { ...@@ -100,6 +101,25 @@ const rewardsApi = (() => {
}); });
})(); })();
const multichainApi = (() => {
const apiHost = getEnvValue('NEXT_PUBLIC_MULTICHAIN_AGGREGATOR_API_HOST');
if (!apiHost) {
return;
}
try {
const url = new URL(apiHost);
return Object.freeze({
endpoint: apiHost,
socketEndpoint: `wss://${ url.host }`,
});
} catch (error) {
return;
}
})();
const statsApi = (() => { const statsApi = (() => {
const apiHost = getEnvValue('NEXT_PUBLIC_STATS_API_HOST'); const apiHost = getEnvValue('NEXT_PUBLIC_STATS_API_HOST');
if (!apiHost) { if (!apiHost) {
...@@ -135,7 +155,7 @@ const visualizeApi = (() => { ...@@ -135,7 +155,7 @@ const visualizeApi = (() => {
}); });
})(); })();
type Apis = { export type Apis = {
general: ApiPropsFull; general: ApiPropsFull;
} & Partial<Record<Exclude<ApiName, 'general'>, ApiPropsBase>>; } & Partial<Record<Exclude<ApiName, 'general'>, ApiPropsBase>>;
...@@ -145,6 +165,7 @@ const apis: Apis = Object.freeze({ ...@@ -145,6 +165,7 @@ const apis: Apis = Object.freeze({
bens: bensApi, bens: bensApi,
contractInfo: contractInfoApi, contractInfo: contractInfoApi,
metadata: metadataApi, metadata: metadataApi,
multichain: multichainApi,
rewards: rewardsApi, rewards: rewardsApi,
stats: statsApi, stats: statsApi,
tac: tacApi, tac: tacApi,
......
...@@ -2,6 +2,7 @@ import type { Feature } from './types'; ...@@ -2,6 +2,7 @@ import type { Feature } from './types';
import chain from '../chain'; import chain from '../chain';
import { getEnvValue } from '../utils'; import { getEnvValue } from '../utils';
import opSuperchain from './opSuperchain';
const walletConnectProjectId = getEnvValue('NEXT_PUBLIC_WALLET_CONNECT_PROJECT_ID'); const walletConnectProjectId = getEnvValue('NEXT_PUBLIC_WALLET_CONNECT_PROJECT_ID');
...@@ -9,15 +10,21 @@ const title = 'Blockchain interaction (writing to contract, etc.)'; ...@@ -9,15 +10,21 @@ const title = 'Blockchain interaction (writing to contract, etc.)';
const config: Feature<{ walletConnect: { projectId: string } }> = (() => { const config: Feature<{ walletConnect: { projectId: string } }> = (() => {
if (
// all chain parameters are required for wagmi provider // all chain parameters are required for wagmi provider
// @wagmi/chains/dist/index.d.ts // @wagmi/chains/dist/index.d.ts
const isSingleChain = Boolean(
chain.id && chain.id &&
chain.name && chain.name &&
chain.currency.name && chain.currency.name &&
chain.currency.symbol && chain.currency.symbol &&
chain.currency.decimals && chain.currency.decimals &&
chain.rpcUrls.length > 0 && chain.rpcUrls.length > 0,
);
const isOpSuperchain = opSuperchain.isEnabled;
if (
(isSingleChain || isOpSuperchain) &&
walletConnectProjectId walletConnectProjectId
) { ) {
return Object.freeze({ return Object.freeze({
......
...@@ -26,6 +26,7 @@ export { default as mixpanel } from './mixpanel'; ...@@ -26,6 +26,7 @@ export { default as mixpanel } from './mixpanel';
export { default as mudFramework } from './mudFramework'; export { default as mudFramework } from './mudFramework';
export { default as multichainButton } from './multichainButton'; export { default as multichainButton } from './multichainButton';
export { default as nameService } from './nameService'; export { default as nameService } from './nameService';
export { default as opSuperchain } from './opSuperchain';
export { default as pools } from './pools'; export { default as pools } from './pools';
export { default as publicTagsSubmission } from './publicTagsSubmission'; export { default as publicTagsSubmission } from './publicTagsSubmission';
export { default as restApiDocs } from './restApiDocs'; export { default as restApiDocs } from './restApiDocs';
......
import type { Feature } from './types';
import apis from '../apis';
import { getEnvValue } from '../utils';
const isEnabled = getEnvValue('NEXT_PUBLIC_OP_SUPERCHAIN_ENABLED') === 'true';
const title = 'OP Superchain interop explorer';
const config: Feature<{ }> = (() => {
if (apis.multichain && isEnabled) {
return Object.freeze({
title,
isEnabled: true,
});
}
return Object.freeze({
title,
isEnabled: false,
});
})();
export default config;
# Set of ENVs for OP Mainnet network explorer
# https://xxx.blockscout.com
# This is an auto-generated file. To update all values, run "yarn dev:preset:sync --name=optimism_superchain"
# Local ENVs
NEXT_PUBLIC_APP_PROTOCOL=http
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_OP_SUPERCHAIN_ENABLED=true
# TODO @tom2drum remove this
SKIP_ENVS_VALIDATION=true
NEXT_PUBLIC_API_SPEC_URL=none
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_GRAPHIQL_TRANSACTION=none
NEXT_PUBLIC_HOMEPAGE_CHARTS=['daily_txs', 'coin_price', 'market_cap', 'secondary_coin_price']
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=https://raw.githubusercontent.com/blockscout/frontend-configs/main/configs/network-icons/optimism-mainnet-light.svg
NEXT_PUBLIC_NETWORK_ICON_DARK=https://raw.githubusercontent.com/blockscout/frontend-configs/main/configs/network-icons/optimism-mainnet-dark.svg
NEXT_PUBLIC_NETWORK_LOGO=https://raw.githubusercontent.com/blockscout/frontend-configs/main/configs/network-logos/optimism.svg
NEXT_PUBLIC_NETWORK_LOGO_DARK=https://raw.githubusercontent.com/blockscout/frontend-configs/main/configs/network-logos/optimism.svg
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
NEXT_PUBLIC_IS_TESTNET=true
NEXT_PUBLIC_USE_NEXT_JS_PROXY=false
\ No newline at end of file
/* eslint-disable no-restricted-properties */
import type { MultichainConfig } from 'types/multichain';
import config from 'configs/app';
function isRunningInDocker() {
return process.env.HOSTNAME !== undefined;
}
let value: MultichainConfig | undefined = undefined;
async function fetchConfig() {
if (process.env.NEXT_RUNTIME !== 'edge') {
throw new Error('NEXT_RUNTIME is not edge');
}
// In edge runtime, we need to use absolute URLs
// When in Docker, use the internal hostname
const baseUrl = isRunningInDocker() ?
`http://${ process.env.HOSTNAME }:${ config.app.port || 3000 }` :
config.app.baseUrl;
const url = baseUrl + '/assets/multichain/config.json';
const response = await fetch(url);
const json = await response.json();
value = json as MultichainConfig;
return value;
}
export async function load() {
if (!value) {
return new Promise<MultichainConfig | undefined>((resolve, reject) => {
fetchConfig()
.then((value) => {
resolve(value);
}).catch((error) => {
reject(error);
});
});
}
return Promise.resolve(value);
}
export function getValue() {
return value;
}
import type { MultichainConfig } from 'types/multichain';
let value: MultichainConfig | undefined = undefined;
function readFileConfig() {
// eslint-disable-next-line no-restricted-properties
if (process.env.NEXT_RUNTIME !== 'nodejs') {
throw new Error('NEXT_RUNTIME is not nodejs');
}
try {
const path = require('path');
const { readFileSync } = require('fs');
const publicFolder = path.resolve('public');
const configPath = path.resolve(publicFolder, 'assets/multichain/config.json');
const config = readFileSync(configPath, 'utf8');
value = JSON.parse(config) as MultichainConfig;
return value;
} catch (error) {
return;
}
}
export async function load() {
if (!value) {
return new Promise<MultichainConfig | undefined>((resolve) => {
const value = readFileConfig();
resolve(value);
});
}
return Promise.resolve(value);
}
export function getValue() {
if (!value) {
return readFileConfig();
}
return value;
}
import type { MultichainConfig } from 'types/multichain';
import config from 'configs/app';
import * as multichainConfigNodejs from 'configs/multichain/config.nodejs';
import { isBrowser } from 'toolkit/utils/isBrowser';
const multichainConfig: () => MultichainConfig | undefined = () => {
if (!config.features.opSuperchain.isEnabled) {
return;
}
if (isBrowser()) {
return window.__multichainConfig;
}
return multichainConfigNodejs.getValue();
};
export default multichainConfig;
...@@ -64,6 +64,9 @@ node --no-warnings ./og_image_generator.js ...@@ -64,6 +64,9 @@ node --no-warnings ./og_image_generator.js
# Create envs.js file with run-time environment variables for the client app # Create envs.js file with run-time environment variables for the client app
./make_envs_script.sh ./make_envs_script.sh
# Generate multichain config
node ./deploy/tools/multichain-config-generator/dist/index.js
# Generate sitemap.xml and robots.txt files # Generate sitemap.xml and robots.txt files
./sitemap_generator.sh ./sitemap_generator.sh
......
node_modules
dist
*.log
.DS_Store
\ No newline at end of file
import { mkdirSync, writeFileSync } from 'node:fs';
import { dirname, resolve as resolvePath } from 'node:path';
import { fileURLToPath } from 'node:url';
import { Worker } from 'node:worker_threads';
const currentFilePath = fileURLToPath(import.meta.url);
const currentDir = dirname(currentFilePath);
const EXPLORER_URLS = [
'https://optimism-interop-alpha-0.blockscout.com',
'https://optimism-interop-alpha-1.blockscout.com',
];
function getSlug(url: string) {
return new URL(url).hostname.replace('.blockscout.com', '').replace('.k8s-dev', '');
}
async function computeChainConfig(url: string): Promise<unknown> {
return new Promise((resolve, reject) => {
const workerPath = resolvePath(currentDir, 'worker.js');
const worker = new Worker(workerPath, {
workerData: { url },
env: {} // Start with empty environment
});
worker.on('message', (config) => {
resolve(config);
});
worker.on('error', (error) => {
console.error('Worker error:', error);
reject(error);
});
worker.on('exit', (code) => {
if (code !== 0) {
reject(new Error(`Worker stopped with exit code ${ code }`));
}
});
});
}
async function run() {
try {
if (!process.env.NEXT_PUBLIC_MULTICHAIN_AGGREGATOR_API_HOST) {
console.log('ℹ️ NEXT_PUBLIC_MULTICHAIN_AGGREGATOR_API_HOST is not set, skipping multichain config generation\n');
return;
}
console.log('🌀 Generating multichain config...');
const configs = await Promise.all(EXPLORER_URLS.map(computeChainConfig));
const config = {
chains: configs.map((config, index) => {
return {
slug: getSlug(EXPLORER_URLS[index]),
config,
};
}),
};
const outputDir = resolvePath(currentDir, '../../../../public/assets/multichain');
mkdirSync(outputDir, { recursive: true });
const outputPathJson = resolvePath(outputDir, 'config.json');
writeFileSync(outputPathJson, JSON.stringify(config, null, 2));
const outputPathJs = resolvePath(outputDir, 'config.js');
writeFileSync(outputPathJs, `window.__multichainConfig = ${ JSON.stringify(config) };`);
console.log('👍 Done!\n');
} catch (error) {
console.error('🚨 Error generating multichain config:', error);
console.log('\n');
process.exit(1);
}
}
run();
{
"name": "@blockscout/multichain-config-generator",
"version": "1.0.0",
"private": true,
"type": "module",
"scripts": {
"build": "vite build --logLevel error",
"generate": "node dist/index.js"
},
"dependencies": {
},
"devDependencies": {
"typescript": "5.4.2",
"vite": "6.3.5",
"vite-plugin-dts": "4.5.4",
"@types/node": "22.12.0"
}
}
\ No newline at end of file
{
"compilerOptions": {
"target": "ES2022",
"module": "ESNext",
"moduleResolution": "bundler",
"esModuleInterop": true,
"strict": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"outDir": "./dist",
"baseUrl": ".",
"paths": {
"configs/*": ["../../../configs/*"],
"lib/*": ["../../../lib/*"],
"toolkit/*": ["../../../toolkit/*"],
"types/*": ["../../../types/*"]
},
"types": ["node"],
"allowImportingTsExtensions": true,
"noEmit": true
},
"include": [
"index.ts",
"worker.ts",
"../app/**/*"
],
"exclude": [
"node_modules",
"dist"
]
}
\ No newline at end of file
import { resolve } from 'path';
import { defineConfig } from 'vite';
export default defineConfig({
build: {
lib: {
entry: {
index: resolve(__dirname, 'index.ts'),
worker: resolve(__dirname, 'worker.ts'),
},
formats: [ 'es' ],
fileName: (format, entryName) => `${ entryName }.js`,
},
rollupOptions: {
external: [ 'node:worker_threads', 'node:url', 'node:path', 'node:fs' ],
output: {
dir: 'dist',
entryFileNames: '[name].js',
chunkFileNames: '[name].js',
assetFileNames: '[name].[ext]',
},
},
},
resolve: {
alias: {
configs: resolve(__dirname, '../../../configs'),
lib: resolve(__dirname, '../../../lib'),
toolkit: resolve(__dirname, '../../../toolkit'),
types: resolve(__dirname, '../../../types'),
},
preserveSymlinks: true,
},
});
/* eslint-disable no-console */
import { parentPort, workerData } from 'node:worker_threads';
interface WorkerData {
url: string;
}
interface ChainConfig {
envs: Record<string, string>;
}
async function fetchChainConfig(url: string): Promise<ChainConfig> {
const response = await fetch(`${ url }/node-api/config`);
if (!response.ok) {
throw new Error(`Failed to fetch config from ${ url }: ${ response.statusText }`);
}
const config = await response.json();
return config as ChainConfig;
}
async function computeConfig() {
try {
const { url } = workerData as WorkerData;
console.log(' ⏳ Fetching chain config from:', url);
// 1. Fetch chain config
const chainConfig = await fetchChainConfig(url);
// 2. Set environment variables
Object.entries(chainConfig.envs).forEach(([ key, value ]) => {
// eslint-disable-next-line no-restricted-properties
process.env[key] = value;
});
// 3. Import and compute app config
const { 'default': appConfig } = await import('configs/app/index');
console.log(' ✅ Config computed for:', url);
// 4. Send config back to main thread
parentPort?.postMessage(appConfig);
} catch (error) {
console.error(' ❌ Worker error:', error);
process.exit(1);
}
}
computeConfig();
This diff is collapsed.
...@@ -48,32 +48,8 @@ frontend: ...@@ -48,32 +48,8 @@ frontend:
cpu: 250m cpu: 250m
env: env:
NEXT_PUBLIC_APP_ENV: review NEXT_PUBLIC_APP_ENV: review
NEXT_PUBLIC_NETWORK_LOGO: https://raw.githubusercontent.com/blockscout/frontend-configs/main/configs/network-logos/base.svg
NEXT_PUBLIC_NETWORK_ICON: https://raw.githubusercontent.com/blockscout/frontend-configs/main/configs/network-icons/base.svg
NEXT_PUBLIC_FEATURED_NETWORKS: https://raw.githubusercontent.com/blockscout/frontend-configs/main/configs/featured-networks/base-mainnet.json
NEXT_PUBLIC_API_HOST: base.blockscout.com
NEXT_PUBLIC_LOGOUT_URL: https://blockscoutcom.us.auth0.com/v2/logout
NEXT_PUBLIC_STATS_API_HOST: https://stats-l2-base-mainnet.k8s-prod-1.blockscout.com
NEXT_PUBLIC_HOMEPAGE_PLATE_BACKGROUND: "linear-gradient(136.9deg,rgb(107 94 236) 1.5%,rgb(0 82 255) 56.84%,rgb(82 62 231) 98.54%)"
NEXT_PUBLIC_NETWORK_RPC_URL: https://mainnet.base.org
NEXT_PUBLIC_WEB3_WALLETS: "['coinbase']"
NEXT_PUBLIC_WEB3_DISABLE_ADD_TOKEN_TO_WALLET: true
NEXT_PUBLIC_HOMEPAGE_CHARTS: "['daily_txs']"
NEXT_PUBLIC_VISUALIZE_API_HOST: https://visualizer.services.blockscout.com
NEXT_PUBLIC_CONTRACT_INFO_API_HOST: https://contracts-info.services.blockscout.com
NEXT_PUBLIC_ADMIN_SERVICE_API_HOST: https://admin-rs.services.blockscout.com
NEXT_PUBLIC_NAME_SERVICE_API_HOST: https://bens.services.blockscout.com
NEXT_PUBLIC_METADATA_SERVICE_API_HOST: https://metadata.services.blockscout.com
NEXT_PUBLIC_ROLLUP_TYPE: optimistic
NEXT_PUBLIC_ROLLUP_L1_BASE_URL: https://eth.blockscout.com
NEXT_PUBLIC_ROLLUP_L2_WITHDRAWAL_URL: https://app.optimism.io/bridge/withdraw
NEXT_PUBLIC_GRAPHIQL_TRANSACTION: 0x4a0ed8ddf751a7cb5297f827699117b0f6d21a0b2907594d300dc9fed75c7e62
NEXT_PUBLIC_USE_NEXT_JS_PROXY: true NEXT_PUBLIC_USE_NEXT_JS_PROXY: true
NEXT_PUBLIC_NAVIGATION_LAYOUT: horizontal SKIP_ENVS_VALIDATION: true
NEXT_PUBLIC_NAVIGATION_HIGHLIGHTED_ROUTES: "['/blocks','/name-domains']"
envFromSecret: envFromSecret:
NEXT_PUBLIC_AUTH0_CLIENT_ID: ref+vault://deployment-values/blockscout/dev/review-l2?token_env=VAULT_TOKEN&address=https://vault.k8s.blockscout.com#/NEXT_PUBLIC_AUTH0_CLIENT_ID
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_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_GOOGLE_ANALYTICS_PROPERTY_ID: ref+vault://deployment-values/blockscout/dev/review-l2?token_env=VAULT_TOKEN&address=https://vault.k8s.blockscout.com#/NEXT_PUBLIC_GOOGLE_ANALYTICS_PROPERTY_ID
NEXT_PUBLIC_OG_IMAGE_URL: https://raw.githubusercontent.com/blockscout/frontend-configs/main/configs/og-images/base-mainnet.png
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_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
import type { MultichainConfig } from 'types/multichain';
import type { WalletProvider } from 'types/web3'; import type { WalletProvider } from 'types/web3';
type CPreferences = { type CPreferences = {
...@@ -19,6 +20,7 @@ declare global { ...@@ -19,6 +20,7 @@ declare global {
}; };
abkw: string; abkw: string;
__envs: Record<string, string>; __envs: Record<string, string>;
__multichainConfig: MultichainConfig;
} }
namespace NodeJS { namespace NodeJS {
......
export async function register() { export async function register() {
if (process.env.NEXT_RUNTIME === 'nodejs' && process.env.NEXT_OPEN_TELEMETRY_ENABLED === 'true') { if (process.env.NEXT_RUNTIME === 'nodejs') {
if (process.env.NEXT_OPEN_TELEMETRY_ENABLED === 'true') {
await import('./instrumentation.node'); await import('./instrumentation.node');
} }
await import('./startup.node');
}
} }
...@@ -2,6 +2,7 @@ import type { AddressMetadataInfoFormatted, AddressMetadataTagFormatted } from ' ...@@ -2,6 +2,7 @@ import type { AddressMetadataInfoFormatted, AddressMetadataTagFormatted } from '
import config from 'configs/app'; import config from 'configs/app';
import useApiQuery from 'lib/api/useApiQuery'; import useApiQuery from 'lib/api/useApiQuery';
import { useMultichainContext } from 'lib/contexts/multichain';
import parseMetaPayload from './parseMetaPayload'; import parseMetaPayload from './parseMetaPayload';
...@@ -9,14 +10,18 @@ export default function useAddressMetadataInfoQuery(addresses: Array<string>, is ...@@ -9,14 +10,18 @@ export default function useAddressMetadataInfoQuery(addresses: Array<string>, is
const resource = 'metadata:info'; 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;
return useApiQuery<typeof resource, unknown, AddressMetadataInfoFormatted>(resource, { return useApiQuery<typeof resource, unknown, AddressMetadataInfoFormatted>(resource, {
queryParams: { queryParams: {
addresses, addresses,
chainId: config.chain.id, chainId,
tagsLimit: '20', tagsLimit: '20',
}, },
queryOptions: { queryOptions: {
enabled: isEnabled && addresses.length > 0 && config.features.addressMetadata.isEnabled, enabled: isEnabled && addresses.length > 0 && feature.isEnabled && Boolean(chainId),
select: (data) => { select: (data) => {
const addresses = Object.entries(data.addresses) const addresses = Object.entries(data.addresses)
.map(([ address, { tags, reputation } ]) => { .map(([ address, { tags, reputation } ]) => {
......
import { compile } from 'path-to-regexp'; import { compile } from 'path-to-regexp';
import type { ChainConfig } from 'types/multichain';
import config from 'configs/app'; import config from 'configs/app';
import getResourceParams from './getResourceParams'; import getResourceParams from './getResourceParams';
...@@ -11,8 +13,9 @@ export default function buildUrl<R extends ResourceName>( ...@@ -11,8 +13,9 @@ export default function buildUrl<R extends ResourceName>(
pathParams?: ResourcePathParams<R>, pathParams?: ResourcePathParams<R>,
queryParams?: Record<string, string | Array<string> | number | boolean | null | undefined>, queryParams?: Record<string, string | Array<string> | number | boolean | null | undefined>,
noProxy?: boolean, noProxy?: boolean,
chain?: ChainConfig,
): string { ): string {
const { api, resource } = getResourceParams(resourceFullName); const { api, resource } = getResourceParams(resourceFullName, chain);
const baseUrl = !noProxy && isNeedProxy() ? config.app.baseUrl : api.endpoint; const baseUrl = !noProxy && isNeedProxy() ? config.app.baseUrl : api.endpoint;
const basePath = api.basePath ?? ''; const basePath = api.basePath ?? '';
const path = !noProxy && isNeedProxy() ? '/node-api/proxy' + basePath + resource.path : basePath + resource.path; const path = !noProxy && isNeedProxy() ? '/node-api/proxy' + basePath + resource.path : basePath + resource.path;
......
import type { ApiName, ApiResource } from './types'; import type { ApiName, ApiResource } from './types';
import type { ChainConfig } from 'types/multichain';
import config from 'configs/app'; import config from 'configs/app';
import type { ResourceName } from './resources'; import type { ResourceName } from './resources';
import { RESOURCES } from './resources'; import { RESOURCES } from './resources';
export default function getResourceParams(resourceFullName: ResourceName) { export default function getResourceParams(resourceFullName: ResourceName, chain?: ChainConfig) {
const [ apiName, resourceName ] = resourceFullName.split(':') as [ ApiName, string ]; const [ apiName, resourceName ] = resourceFullName.split(':') as [ ApiName, string ];
const apiConfig = config.apis[apiName];
const apiConfig = (() => {
if (chain) {
return chain.config.apis[apiName];
}
return config.apis[apiName];
})();
if (!apiConfig) { if (!apiConfig) {
throw new Error(`API config for ${ apiName } not found`); throw new Error(`API config for ${ apiName } not found`);
......
import appConfig from 'configs/app';
export default function getSocketUrl(config: typeof appConfig = appConfig) {
return `${ config.apis.general.socketEndpoint }${ config.apis.general.basePath ?? '' }/socket/v2`;
}
...@@ -10,6 +10,8 @@ import { GENERAL_API_RESOURCES } from './services/general'; ...@@ -10,6 +10,8 @@ import { GENERAL_API_RESOURCES } from './services/general';
import type { GeneralApiResourceName, GeneralApiResourcePayload, GeneralApiPaginationFilters, GeneralApiPaginationSorting } from './services/general'; import type { GeneralApiResourceName, GeneralApiResourcePayload, GeneralApiPaginationFilters, GeneralApiPaginationSorting } from './services/general';
import type { MetadataApiResourceName, MetadataApiResourcePayload } from './services/metadata'; import type { MetadataApiResourceName, MetadataApiResourcePayload } from './services/metadata';
import { METADATA_API_RESOURCES } from './services/metadata'; import { METADATA_API_RESOURCES } from './services/metadata';
import type { MultichainApiResourceName, MultichainApiResourcePayload } from './services/multichain';
import { MULTICHAIN_API_RESOURCES } from './services/multichain';
import type { RewardsApiResourceName, RewardsApiResourcePayload } from './services/rewards'; import type { RewardsApiResourceName, RewardsApiResourcePayload } from './services/rewards';
import { REWARDS_API_RESOURCES } from './services/rewards'; import { REWARDS_API_RESOURCES } from './services/rewards';
import type { StatsApiResourceName, StatsApiResourcePayload } from './services/stats'; import type { StatsApiResourceName, StatsApiResourcePayload } from './services/stats';
...@@ -30,6 +32,7 @@ export const RESOURCES = { ...@@ -30,6 +32,7 @@ export const RESOURCES = {
contractInfo: CONTRACT_INFO_API_RESOURCES, contractInfo: CONTRACT_INFO_API_RESOURCES,
general: GENERAL_API_RESOURCES, general: GENERAL_API_RESOURCES,
metadata: METADATA_API_RESOURCES, metadata: METADATA_API_RESOURCES,
multichain: MULTICHAIN_API_RESOURCES,
rewards: REWARDS_API_RESOURCES, rewards: REWARDS_API_RESOURCES,
stats: STATS_API_RESOURCES, stats: STATS_API_RESOURCES,
tac: TAC_OPERATION_LIFECYCLE_API_RESOURCES, tac: TAC_OPERATION_LIFECYCLE_API_RESOURCES,
...@@ -51,6 +54,7 @@ R extends BensApiResourceName ? BensApiResourcePayload<R> : ...@@ -51,6 +54,7 @@ R extends BensApiResourceName ? BensApiResourcePayload<R> :
R extends ContractInfoApiResourceName ? ContractInfoApiResourcePayload<R> : R extends ContractInfoApiResourceName ? ContractInfoApiResourcePayload<R> :
R extends GeneralApiResourceName ? GeneralApiResourcePayload<R> : R extends GeneralApiResourceName ? GeneralApiResourcePayload<R> :
R extends MetadataApiResourceName ? MetadataApiResourcePayload<R> : R extends MetadataApiResourceName ? MetadataApiResourcePayload<R> :
R extends MultichainApiResourceName ? MultichainApiResourcePayload<R> :
R extends RewardsApiResourceName ? RewardsApiResourcePayload<R> : R extends RewardsApiResourceName ? RewardsApiResourcePayload<R> :
R extends StatsApiResourceName ? StatsApiResourcePayload<R> : R extends StatsApiResourceName ? StatsApiResourcePayload<R> :
R extends TacOperationLifecycleApiResourceName ? TacOperationLifecycleApiResourcePayload<R> : R extends TacOperationLifecycleApiResourceName ? TacOperationLifecycleApiResourcePayload<R> :
......
import type { ApiResource } from '../types';
import type * as multichain from '@blockscout/multichain-aggregator-types';
export const MULTICHAIN_API_RESOURCES = {
interop_messages: {
path: '/api/v1/interop/messages',
paginated: true,
},
} 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 :
never;
/* eslint-enable @stylistic/indent */
export type ApiName = 'general' | 'admin' | 'bens' | 'contractInfo' | 'metadata' | 'rewards' | 'stats' | 'visualize' | 'tac'; export type ApiName = 'general' | 'admin' | 'bens' | 'contractInfo' | 'metadata' | 'multichain' | 'rewards' | 'stats' | 'tac' | 'visualize';
export interface ApiResource { export interface ApiResource {
path: string; path: string;
......
...@@ -3,6 +3,7 @@ import { omit, pickBy } from 'es-toolkit'; ...@@ -3,6 +3,7 @@ import { omit, pickBy } from 'es-toolkit';
import React from 'react'; import React from 'react';
import type { CsrfData } from 'types/client/account'; import type { CsrfData } from 'types/client/account';
import type { ChainConfig } from 'types/multichain';
import config from 'configs/app'; import config from 'configs/app';
import isBodyAllowed from 'lib/api/isBodyAllowed'; import isBodyAllowed from 'lib/api/isBodyAllowed';
...@@ -21,21 +22,23 @@ export interface Params<R extends ResourceName> { ...@@ -21,21 +22,23 @@ export interface Params<R extends ResourceName> {
queryParams?: Record<string, string | Array<string> | number | boolean | undefined | null>; queryParams?: Record<string, string | Array<string> | number | boolean | undefined | null>;
fetchParams?: Pick<FetchParams, 'body' | 'method' | 'signal' | 'headers'>; fetchParams?: Pick<FetchParams, 'body' | 'method' | 'signal' | 'headers'>;
logError?: boolean; logError?: boolean;
chain?: ChainConfig;
} }
export default function useApiFetch() { export default function useApiFetch() {
const fetch = useFetch(); const fetch = useFetch();
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const { token: csrfToken } = queryClient.getQueryData<CsrfData>(getResourceKey('general:csrf')) || {}; const { token: csrfToken } = queryClient.getQueryData<CsrfData>(getResourceKey('general:csrf')) || {};
return React.useCallback(<R extends ResourceName, SuccessType = unknown, ErrorType = unknown>( return React.useCallback(<R extends ResourceName, SuccessType = unknown, ErrorType = unknown>(
resourceName: R, resourceName: R,
{ pathParams, queryParams, fetchParams, logError }: Params<R> = {}, { pathParams, queryParams, fetchParams, logError, chain }: Params<R> = {},
) => { ) => {
const apiToken = cookies.get(cookies.NAMES.API_TOKEN); const apiToken = cookies.get(cookies.NAMES.API_TOKEN);
const { api, apiName, resource } = getResourceParams(resourceName); const { api, apiName, resource } = getResourceParams(resourceName, chain);
const url = buildUrl(resourceName, pathParams, queryParams); const url = buildUrl(resourceName, pathParams, queryParams, undefined, chain);
const withBody = isBodyAllowed(fetchParams?.method); const withBody = isBodyAllowed(fetchParams?.method);
const headers = pickBy({ const headers = pickBy({
'x-endpoint': api.endpoint && apiName !== 'general' && isNeedProxy() ? api.endpoint : undefined, 'x-endpoint': api.endpoint && apiName !== 'general' && isNeedProxy() ? api.endpoint : undefined,
......
import type { UseQueryOptions } from '@tanstack/react-query'; import type { UseQueryOptions } from '@tanstack/react-query';
import { useQuery } from '@tanstack/react-query'; import { useQuery } from '@tanstack/react-query';
import multichainConfig from 'configs/multichain';
import { useMultichainContext } from 'lib/contexts/multichain';
import type { Params as FetchParams } from 'lib/hooks/useFetch'; import type { Params as FetchParams } from 'lib/hooks/useFetch';
import type { ResourceError, ResourceName, ResourcePathParams, ResourcePayload } from './resources'; import type { ResourceError, ResourceName, ResourcePathParams, ResourcePayload } from './resources';
...@@ -12,29 +14,37 @@ export interface Params<R extends ResourceName, E = unknown, D = ResourcePayload ...@@ -12,29 +14,37 @@ export interface Params<R extends ResourceName, E = unknown, D = ResourcePayload
fetchParams?: Pick<FetchParams, 'body' | 'method' | 'headers'>; fetchParams?: Pick<FetchParams, 'body' | 'method' | 'headers'>;
queryOptions?: Partial<Omit<UseQueryOptions<ResourcePayload<R>, ResourceError<E>, D>, 'queryFn'>>; queryOptions?: Partial<Omit<UseQueryOptions<ResourcePayload<R>, ResourceError<E>, D>, 'queryFn'>>;
logError?: boolean; logError?: boolean;
chainSlug?: string;
} }
export function getResourceKey<R extends ResourceName>(resource: R, { pathParams, queryParams }: Params<R> = {}) { export interface GetResourceKeyParams<R extends ResourceName, E = unknown, D = ResourcePayload<R>>
extends Pick<Params<R, E, D>, 'pathParams' | 'queryParams'> {
chainSlug?: string;
}
export function getResourceKey<R extends ResourceName>(resource: R, { pathParams, queryParams, chainSlug }: GetResourceKeyParams<R> = {}) {
if (pathParams || queryParams) { if (pathParams || queryParams) {
return [ resource, { ...pathParams, ...queryParams } ]; return [ resource, chainSlug, { ...pathParams, ...queryParams } ].filter(Boolean);
} }
return [ resource ]; return [ resource, chainSlug ].filter(Boolean);
} }
export default function useApiQuery<R extends ResourceName, E = unknown, D = ResourcePayload<R>>( export default function useApiQuery<R extends ResourceName, E = unknown, D = ResourcePayload<R>>(
resource: R, resource: R,
{ queryOptions, pathParams, queryParams, fetchParams, logError }: Params<R, E, D> = {}, { queryOptions, pathParams, queryParams, fetchParams, logError, chainSlug }: Params<R, E, D> = {},
) { ) {
const apiFetch = useApiFetch(); const apiFetch = useApiFetch();
const { chain } = useMultichainContext() ||
{ chain: chainSlug ? multichainConfig()?.chains.find((chain) => chain.slug === chainSlug) : undefined };
return useQuery<ResourcePayload<R>, ResourceError<E>, D>({ return useQuery<ResourcePayload<R>, ResourceError<E>, D>({
queryKey: queryOptions?.queryKey || getResourceKey(resource, { pathParams, queryParams }), queryKey: queryOptions?.queryKey || getResourceKey(resource, { pathParams, queryParams, chainSlug: chain?.slug }),
queryFn: async({ signal }) => { queryFn: async({ signal }) => {
// all errors and error typing is handled by react-query // all errors and error typing is handled by react-query
// so error response will never go to the data // so error response will never go to the data
// that's why we are safe here to do type conversion "as Promise<ResourcePayload<R>>" // that's why we are safe here to do type conversion "as Promise<ResourcePayload<R>>"
return apiFetch(resource, { pathParams, queryParams, logError, fetchParams: { ...fetchParams, signal } }) as Promise<ResourcePayload<R>>; return apiFetch(resource, { pathParams, queryParams, chain, logError, fetchParams: { ...fetchParams, signal } }) as Promise<ResourcePayload<R>>;
}, },
...queryOptions, ...queryOptions,
}); });
......
import { useRouter } from 'next/router';
import React from 'react';
import type { ChainConfig } from 'types/multichain';
import multichainConfig from 'configs/multichain';
import getQueryParamString from 'lib/router/getQueryParamString';
interface MultichainProviderProps {
children: React.ReactNode;
chainSlug?: string;
}
export interface TMultichainContext {
chain: ChainConfig;
}
export const MultichainContext = React.createContext<TMultichainContext | null>(null);
export function MultichainProvider({ children, chainSlug: chainSlugProp }: MultichainProviderProps) {
const router = useRouter();
const chainSlugQueryParam = router.pathname.includes('chain-slug') ? getQueryParamString(router.query['chain-slug']) : undefined;
const [ chainSlug, setChainSlug ] = React.useState<string | undefined>(chainSlugProp ?? chainSlugQueryParam);
React.useEffect(() => {
if (chainSlugProp) {
setChainSlug(chainSlugProp);
}
}, [ chainSlugProp ]);
const chain = React.useMemo(() => {
const config = multichainConfig();
if (!config) {
return;
}
if (!chainSlug) {
return;
}
return config.chains.find((chain) => chain.slug === chainSlug);
}, [ chainSlug ]);
const value = React.useMemo(() => {
if (!chain) {
return null;
}
return {
chain,
};
}, [ chain ]);
return (
<MultichainContext.Provider value={ value }>
{ children }
</MultichainContext.Provider>
);
}
export function useMultichainContext(disabled: boolean = !multichainConfig) {
const context = React.useContext(MultichainContext);
if (context === undefined || disabled) {
return null;
}
return context;
}
...@@ -76,6 +76,8 @@ export default function useTimeAgoIncrement(ts: string | number | null, isEnable ...@@ -76,6 +76,8 @@ export default function useTimeAgoIncrement(ts: string | number | null, isEnable
timeouts.push(endTimeoutId); timeouts.push(endTimeoutId);
}; };
setValue(dayjs(ts).fromNow());
isEnabled && startIncrement(); isEnabled && startIncrement();
!isEnabled && setValue(dayjs(ts).fromNow()); !isEnabled && setValue(dayjs(ts).fromNow());
......
...@@ -63,6 +63,10 @@ const OG_TYPE_DICT: Record<Route['pathname'], OGPageType> = { ...@@ -63,6 +63,10 @@ const OG_TYPE_DICT: Record<Route['pathname'], OGPageType> = {
'/pools': 'Root page', '/pools': 'Root page',
'/pools/[hash]': 'Regular page', '/pools/[hash]': 'Regular page',
'/interop-messages': 'Root page', '/interop-messages': 'Root page',
'/chain/[chain-slug]/accounts/label/[slug]': 'Root page',
'/chain/[chain-slug]/address/[hash]': 'Regular page',
'/chain/[chain-slug]/block/[height_or_hash]': 'Regular page',
'/chain/[chain-slug]/tx/[hash]': 'Regular page',
'/operations': 'Root page', '/operations': 'Root page',
'/operation/[id]': 'Regular page', '/operation/[id]': 'Regular page',
......
...@@ -66,6 +66,10 @@ const TEMPLATE_MAP: Record<Route['pathname'], string> = { ...@@ -66,6 +66,10 @@ const TEMPLATE_MAP: Record<Route['pathname'], string> = {
'/pools': DEFAULT_TEMPLATE, '/pools': DEFAULT_TEMPLATE,
'/pools/[hash]': DEFAULT_TEMPLATE, '/pools/[hash]': DEFAULT_TEMPLATE,
'/interop-messages': DEFAULT_TEMPLATE, '/interop-messages': DEFAULT_TEMPLATE,
'/chain/[chain-slug]/accounts/label/[slug]': DEFAULT_TEMPLATE,
'/chain/[chain-slug]/address/[hash]': DEFAULT_TEMPLATE,
'/chain/[chain-slug]/block/[height_or_hash]': DEFAULT_TEMPLATE,
'/chain/[chain-slug]/tx/[hash]': DEFAULT_TEMPLATE,
'/operations': DEFAULT_TEMPLATE, '/operations': DEFAULT_TEMPLATE,
'/operation/[id]': DEFAULT_TEMPLATE, '/operation/[id]': DEFAULT_TEMPLATE,
......
...@@ -63,6 +63,10 @@ const TEMPLATE_MAP: Record<Route['pathname'], string> = { ...@@ -63,6 +63,10 @@ const TEMPLATE_MAP: Record<Route['pathname'], string> = {
'/pools': '%network_name% DEX pools', '/pools': '%network_name% DEX pools',
'/pools/[hash]': '%network_name% pool details', '/pools/[hash]': '%network_name% pool details',
'/interop-messages': '%network_name% interop messages', '/interop-messages': '%network_name% interop messages',
'/chain/[chain-slug]/accounts/label/[slug]': '%network_name% addresses search by label',
'/chain/[chain-slug]/address/[hash]': '%network_name% address details for %hash%',
'/chain/[chain-slug]/block/[height_or_hash]': '%network_name% block %height_or_hash% details',
'/chain/[chain-slug]/tx/[hash]': '%network_name% transaction %hash% details',
'/operations': '%network_name% operations', '/operations': '%network_name% operations',
'/operation/[id]': '%network_name% operation %id%', '/operation/[id]': '%network_name% operation %id%',
......
...@@ -61,6 +61,10 @@ export const PAGE_TYPE_DICT: Record<Route['pathname'], string> = { ...@@ -61,6 +61,10 @@ export const PAGE_TYPE_DICT: Record<Route['pathname'], string> = {
'/pools': 'DEX pools', '/pools': 'DEX pools',
'/pools/[hash]': 'Pool details', '/pools/[hash]': 'Pool details',
'/interop-messages': 'Interop messages', '/interop-messages': 'Interop messages',
'/chain/[chain-slug]/accounts/label/[slug]': 'Chain addresses search by label',
'/chain/[chain-slug]/address/[hash]': 'Chain address details',
'/chain/[chain-slug]/block/[height_or_hash]': 'Chain block details',
'/chain/[chain-slug]/tx/[hash]': 'Chain transaction details',
'/operations': 'Operations', '/operations': 'Operations',
'/operation/[id]': 'Operation details', '/operation/[id]': 'Operation details',
......
// https://hexdocs.pm/phoenix/js/ // https://hexdocs.pm/phoenix/js/
import type { SocketConnectOption } from 'phoenix'; import type { Channel, SocketConnectOption } from 'phoenix';
import { Socket } from 'phoenix'; import { Socket } from 'phoenix';
import React, { useEffect, useState } from 'react'; import React, { useEffect, useState } from 'react';
export const SocketContext = React.createContext<Socket | null>(null); type ChannelRegistry = Record<string, { channel: Channel; subscribers: number }>;
export const SocketContext = React.createContext<{
socket: Socket | null;
channelRegistry: React.MutableRefObject<ChannelRegistry>;
} | null>(null);
interface SocketProviderProps { interface SocketProviderProps {
children: React.ReactNode; children: React.ReactNode;
...@@ -13,6 +18,7 @@ interface SocketProviderProps { ...@@ -13,6 +18,7 @@ interface SocketProviderProps {
export function SocketProvider({ children, options, url }: SocketProviderProps) { export function SocketProvider({ children, options, url }: SocketProviderProps) {
const [ socket, setSocket ] = useState<Socket | null>(null); const [ socket, setSocket ] = useState<Socket | null>(null);
const channelRegistry = React.useRef<ChannelRegistry>({});
useEffect(() => { useEffect(() => {
if (!url) { if (!url) {
...@@ -29,8 +35,13 @@ export function SocketProvider({ children, options, url }: SocketProviderProps) ...@@ -29,8 +35,13 @@ export function SocketProvider({ children, options, url }: SocketProviderProps)
}; };
}, [ options, url ]); }, [ options, url ]);
const value = React.useMemo(() => ({
socket,
channelRegistry,
}), [ socket, channelRegistry ]);
return ( return (
<SocketContext.Provider value={ socket }> <SocketContext.Provider value={ value }>
{ children } { children }
</SocketContext.Provider> </SocketContext.Provider>
); );
......
import type { Channel } from 'phoenix'; import type { Channel } from 'phoenix';
import type * as multichain from '@blockscout/multichain-aggregator-types';
import type { AddressCoinBalanceHistoryItem, AddressTokensBalancesSocketMessage } from 'types/api/address'; import type { AddressCoinBalanceHistoryItem, AddressTokensBalancesSocketMessage } from 'types/api/address';
import type { NewArbitrumBatchSocketResponse } from 'types/api/arbitrumL2'; import type { NewArbitrumBatchSocketResponse } from 'types/api/arbitrumL2';
import type { NewBlockSocketResponse } from 'types/api/block'; import type { NewBlockSocketResponse } from 'types/api/block';
...@@ -11,11 +12,13 @@ import type { Transaction } from 'types/api/transaction'; ...@@ -11,11 +12,13 @@ import type { Transaction } from 'types/api/transaction';
import type { NewZkEvmBatchSocketResponse } from 'types/api/zkEvmL2'; import type { NewZkEvmBatchSocketResponse } from 'types/api/zkEvmL2';
export type SocketMessageParams = SocketMessage.NewBlock | export type SocketMessageParams = SocketMessage.NewBlock |
SocketMessage.NewBlockMultichain |
SocketMessage.BlocksIndexStatus | SocketMessage.BlocksIndexStatus |
SocketMessage.InternalTxsIndexStatus | SocketMessage.InternalTxsIndexStatus |
SocketMessage.TxStatusUpdate | SocketMessage.TxStatusUpdate |
SocketMessage.TxRawTrace | SocketMessage.TxRawTrace |
SocketMessage.NewTx | SocketMessage.NewTx |
SocketMessage.NewInteropMessage |
SocketMessage.NewPendingTx | SocketMessage.NewPendingTx |
SocketMessage.NewOptimisticDeposits | SocketMessage.NewOptimisticDeposits |
SocketMessage.NewArbitrumDeposits | SocketMessage.NewArbitrumDeposits |
...@@ -49,11 +52,13 @@ interface SocketMessageParamsGeneric<Event extends string | undefined, Payload e ...@@ -49,11 +52,13 @@ interface SocketMessageParamsGeneric<Event extends string | undefined, Payload e
export namespace SocketMessage { export namespace SocketMessage {
export type NewBlock = SocketMessageParamsGeneric<'new_block', NewBlockSocketResponse>; export type NewBlock = SocketMessageParamsGeneric<'new_block', NewBlockSocketResponse>;
export type NewBlockMultichain = SocketMessageParamsGeneric<'new_blocks', Array<{ block_number: number; chain_id: number }>>;
export type BlocksIndexStatus = SocketMessageParamsGeneric<'index_status', { finished: boolean; ratio: string }>; export type BlocksIndexStatus = SocketMessageParamsGeneric<'index_status', { finished: boolean; ratio: string }>;
export type InternalTxsIndexStatus = SocketMessageParamsGeneric<'index_status', { finished: boolean; ratio: string }>; export type InternalTxsIndexStatus = SocketMessageParamsGeneric<'index_status', { finished: boolean; ratio: string }>;
export type TxStatusUpdate = SocketMessageParamsGeneric<'collated', NewBlockSocketResponse>; export type TxStatusUpdate = SocketMessageParamsGeneric<'collated', NewBlockSocketResponse>;
export type TxRawTrace = SocketMessageParamsGeneric<'raw_trace', RawTracesResponse>; export type TxRawTrace = SocketMessageParamsGeneric<'raw_trace', RawTracesResponse>;
export type NewTx = SocketMessageParamsGeneric<'transaction', { transaction: number }>; export type NewTx = SocketMessageParamsGeneric<'transaction', { transaction: number }>;
export type NewInteropMessage = SocketMessageParamsGeneric<'new_messages', Array<multichain.InteropMessage>>;
export type NewPendingTx = SocketMessageParamsGeneric<'pending_transaction', { pending_transaction: number }>; export type NewPendingTx = SocketMessageParamsGeneric<'pending_transaction', { pending_transaction: number }>;
export type NewOptimisticDeposits = SocketMessageParamsGeneric<'new_optimism_deposits', { deposits: number }>; export type NewOptimisticDeposits = SocketMessageParamsGeneric<'new_optimism_deposits', { deposits: number }>;
export type NewArbitrumDeposits = SocketMessageParamsGeneric<'new_messages_to_rollup_amount', { new_messages_to_rollup_amount: number }>; export type NewArbitrumDeposits = SocketMessageParamsGeneric<'new_messages_to_rollup_amount', { new_messages_to_rollup_amount: number }>;
......
...@@ -3,8 +3,6 @@ import { useEffect, useRef, useState } from 'react'; ...@@ -3,8 +3,6 @@ import { useEffect, useRef, useState } from 'react';
import { useSocket } from './context'; import { useSocket } from './context';
const CHANNEL_REGISTRY: Record<string, { channel: Channel; subscribers: number }> = {};
interface Params { interface Params {
topic: string | undefined; topic: string | undefined;
params?: object; params?: object;
...@@ -15,7 +13,7 @@ interface Params { ...@@ -15,7 +13,7 @@ interface Params {
} }
export default function useSocketChannel({ topic, params, isDisabled, onJoin, onSocketClose, onSocketError }: Params) { export default function useSocketChannel({ topic, params, isDisabled, onJoin, onSocketClose, onSocketError }: Params) {
const socket = useSocket(); const { socket, channelRegistry } = useSocket() || {};
const [ channel, setChannel ] = useState<Channel>(); const [ channel, setChannel ] = useState<Channel>();
const onCloseRef = useRef<string>(undefined); const onCloseRef = useRef<string>(undefined);
const onErrorRef = useRef<string>(undefined); const onErrorRef = useRef<string>(undefined);
...@@ -47,18 +45,18 @@ export default function useSocketChannel({ topic, params, isDisabled, onJoin, on ...@@ -47,18 +45,18 @@ export default function useSocketChannel({ topic, params, isDisabled, onJoin, on
}, [ channel, isDisabled ]); }, [ channel, isDisabled ]);
useEffect(() => { useEffect(() => {
if (socket === null || isDisabled || !topic) { if (!socket || isDisabled || !topic || !channelRegistry) {
return; return;
} }
let ch: Channel; let ch: Channel;
if (CHANNEL_REGISTRY[topic]) { if (channelRegistry.current[topic]) {
ch = CHANNEL_REGISTRY[topic].channel; ch = channelRegistry.current[topic].channel;
CHANNEL_REGISTRY[topic].subscribers++; channelRegistry.current[topic].subscribers++;
onJoinRef.current?.(ch, ''); onJoinRef.current?.(ch, '');
} else { } else {
ch = socket.channel(topic); ch = socket.channel(topic);
CHANNEL_REGISTRY[topic] = { channel: ch, subscribers: 1 }; channelRegistry.current[topic] = { channel: ch, subscribers: 1 };
ch.join() ch.join()
.receive('ok', (message) => onJoinRef.current?.(ch, message)) .receive('ok', (message) => onJoinRef.current?.(ch, message))
.receive('error', () => { .receive('error', () => {
...@@ -68,18 +66,20 @@ export default function useSocketChannel({ topic, params, isDisabled, onJoin, on ...@@ -68,18 +66,20 @@ export default function useSocketChannel({ topic, params, isDisabled, onJoin, on
setChannel(ch); setChannel(ch);
const currentRegistry = channelRegistry.current;
return () => { return () => {
if (CHANNEL_REGISTRY[topic]) { if (currentRegistry[topic]) {
CHANNEL_REGISTRY[topic].subscribers > 0 && CHANNEL_REGISTRY[topic].subscribers--; currentRegistry[topic].subscribers > 0 && currentRegistry[topic].subscribers--;
if (CHANNEL_REGISTRY[topic].subscribers === 0) { if (currentRegistry[topic].subscribers === 0) {
ch.leave(); ch.leave();
delete CHANNEL_REGISTRY[topic]; delete currentRegistry[topic];
} }
} }
setChannel(undefined); setChannel(undefined);
}; };
}, [ socket, topic, params, isDisabled, onSocketError ]); }, [ socket, topic, params, isDisabled, onSocketError, channelRegistry ]);
return channel; return channel;
} }
import { type Chain } from 'viem'; import { type Chain } from 'viem';
import config from 'configs/app'; import appConfig from 'configs/app';
import multichainConfig from 'configs/multichain';
export const currentChain: Chain = { const getChainInfo = (config: typeof appConfig = appConfig) => {
return {
id: Number(config.chain.id), id: Number(config.chain.id),
name: config.chain.name ?? '', name: config.chain.name ?? '',
nativeCurrency: { nativeCurrency: {
...@@ -22,10 +24,13 @@ export const currentChain: Chain = { ...@@ -22,10 +24,13 @@ export const currentChain: Chain = {
}, },
}, },
testnet: config.chain.isTestnet, testnet: config.chain.isTestnet,
};
}; };
export const currentChain: Chain | undefined = !appConfig.features.opSuperchain.isEnabled ? getChainInfo() : undefined;
export const parentChain: Chain | undefined = (() => { export const parentChain: Chain | undefined = (() => {
const rollupFeature = config.features.rollup; const rollupFeature = appConfig.features.rollup;
const parentChain = rollupFeature.isEnabled && rollupFeature.parentChain; const parentChain = rollupFeature.isEnabled && rollupFeature.parentChain;
...@@ -55,3 +60,13 @@ export const parentChain: Chain | undefined = (() => { ...@@ -55,3 +60,13 @@ export const parentChain: Chain | undefined = (() => {
testnet: parentChain.isTestnet, testnet: parentChain.isTestnet,
}; };
})(); })();
export const clusterChains: Array<Chain> | undefined = (() => {
const config = multichainConfig();
if (!config) {
return;
}
return config.chains.map(({ config }) => getChainInfo(config)).filter(Boolean);
})();
...@@ -3,7 +3,8 @@ import { createPublicClient, http } from 'viem'; ...@@ -3,7 +3,8 @@ import { createPublicClient, http } from 'viem';
import { currentChain } from './chains'; import { currentChain } from './chains';
export const publicClient = (() => { export const publicClient = (() => {
if (currentChain.rpcUrls.default.http.filter(Boolean).length === 0) { // TODO @tom2drum public clients for multichain (currently used only in degradation views)
if (currentChain?.rpcUrls.default.http.filter(Boolean).length === 0) {
return; return;
} }
......
import { WagmiAdapter } from '@reown/appkit-adapter-wagmi'; import { WagmiAdapter } from '@reown/appkit-adapter-wagmi';
import type { AppKitNetwork } from '@reown/appkit/networks'; import type { AppKitNetwork } from '@reown/appkit/networks';
import type { Chain } from 'viem'; import type { Chain, Transport } from 'viem';
import { fallback, http } from 'viem'; import { fallback, http } from 'viem';
import { createConfig } from 'wagmi'; import { createConfig } from 'wagmi';
import config from 'configs/app'; import appConfig from 'configs/app';
import { currentChain, parentChain } from 'lib/web3/chains'; import multichainConfig from 'configs/multichain';
import { currentChain, parentChain, clusterChains } from 'lib/web3/chains';
const feature = config.features.blockchainInteraction; const feature = appConfig.features.blockchainInteraction;
const chains = [ currentChain, parentChain ].filter(Boolean); const chains = [ currentChain, parentChain, ...(clusterChains ?? []) ].filter(Boolean);
const getChainTransportFromConfig = (config: typeof appConfig, 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` : '')
.filter(Boolean)
.map((url) => http(url, { batch: { wait: 100 } })),
),
};
};
const reduceClusterChainsToTransportConfig = (readOnly: boolean): Record<string, Transport> => {
const config = multichainConfig();
if (!config) {
return {};
}
return config.chains
.map(({ config }) => getChainTransportFromConfig(config, readOnly))
.reduce((result, item) => {
Object.entries(item).map(([ id, transport ]) => {
result[id] = transport;
});
return result;
}, {} as Record<string, Transport>);
};
const wagmi = (() => { const wagmi = (() => {
...@@ -17,12 +50,9 @@ const wagmi = (() => { ...@@ -17,12 +50,9 @@ const wagmi = (() => {
const wagmiConfig = createConfig({ const wagmiConfig = createConfig({
chains: chains as [Chain, ...Array<Chain>], chains: chains as [Chain, ...Array<Chain>],
transports: { transports: {
[currentChain.id]: fallback( ...getChainTransportFromConfig(appConfig, true),
config.chain.rpcUrls
.map((url) => http(url))
.concat(http(`${ config.apis.general.endpoint }/api/eth-rpc`)),
),
...(parentChain ? { [parentChain.id]: http(parentChain.rpcUrls.default.http[0]) } : {}), ...(parentChain ? { [parentChain.id]: http(parentChain.rpcUrls.default.http[0]) } : {}),
...reduceClusterChainsToTransportConfig(true),
}, },
ssr: true, ssr: true,
batch: { multicall: { wait: 100 } }, batch: { multicall: { wait: 100 } },
...@@ -35,8 +65,9 @@ const wagmi = (() => { ...@@ -35,8 +65,9 @@ const wagmi = (() => {
networks: chains as Array<AppKitNetwork>, networks: chains as Array<AppKitNetwork>,
multiInjectedProviderDiscovery: true, multiInjectedProviderDiscovery: true,
transports: { transports: {
[currentChain.id]: fallback(config.chain.rpcUrls.map((url) => http(url))), ...getChainTransportFromConfig(appConfig, false),
...(parentChain ? { [parentChain.id]: http() } : {}), ...(parentChain ? { [parentChain.id]: http() } : {}),
...reduceClusterChainsToTransportConfig(false),
}, },
projectId: feature.walletConnect.projectId, projectId: feature.walletConnect.projectId,
ssr: true, ssr: true,
......
import type { NextRequest } from 'next/server'; import type { NextRequest } from 'next/server';
import { NextResponse } from 'next/server'; import { NextResponse } from 'next/server';
import generateCspPolicy from 'nextjs/csp/generateCspPolicy'; import * as csp from 'nextjs/csp/index';
import * as middlewares from 'nextjs/middlewares/index'; import * as middlewares from 'nextjs/middlewares/index';
const cspPolicy = generateCspPolicy(); export async function middleware(req: NextRequest) {
export function middleware(req: NextRequest) {
const isPageRequest = req.headers.get('accept')?.includes('text/html'); const isPageRequest = req.headers.get('accept')?.includes('text/html');
const start = Date.now(); const start = Date.now();
...@@ -27,7 +25,9 @@ export function middleware(req: NextRequest) { ...@@ -27,7 +25,9 @@ export function middleware(req: NextRequest) {
const end = Date.now(); const end = Date.now();
res.headers.append('Content-Security-Policy', cspPolicy); const cspHeader = await csp.get();
res.headers.append('Content-Security-Policy', cspHeader);
res.headers.append('Server-Timing', `middleware;dur=${ end - start }`); res.headers.append('Server-Timing', `middleware;dur=${ end - start }`);
res.headers.append('Docker-ID', process.env.HOSTNAME || ''); res.headers.append('Docker-ID', process.env.HOSTNAME || '');
......
...@@ -15,6 +15,7 @@ function generateCspPolicy() { ...@@ -15,6 +15,7 @@ function generateCspPolicy() {
descriptors.marketplace(), descriptors.marketplace(),
descriptors.mixpanel(), descriptors.mixpanel(),
descriptors.monaco(), descriptors.monaco(),
descriptors.multichain(),
descriptors.rollbar(), descriptors.rollbar(),
descriptors.safe(), descriptors.safe(),
descriptors.usernameApi(), descriptors.usernameApi(),
......
import appConfig from 'configs/app';
import * as multichainConfig from 'configs/multichain/config.edge';
import generateCspPolicy from './generateCspPolicy';
let cspPolicy: string | undefined = undefined;
export async function get() {
if (!cspPolicy) {
appConfig.features.opSuperchain.isEnabled && await multichainConfig.load();
cspPolicy = generateCspPolicy();
}
return cspPolicy;
}
...@@ -41,7 +41,7 @@ export function app(): CspDev.DirectiveDescriptor { ...@@ -41,7 +41,7 @@ export function app(): CspDev.DirectiveDescriptor {
// APIs // APIs
...Object.values(config.apis).filter(Boolean).map((api) => api.endpoint), ...Object.values(config.apis).filter(Boolean).map((api) => api.endpoint),
config.apis.general.socketEndpoint, ...Object.values(config.apis).filter(Boolean).map((api) => api.socketEndpoint),
// chain RPC server // chain RPC server
...config.chain.rpcUrls, ...config.chain.rpcUrls,
......
...@@ -10,6 +10,7 @@ export { helia } from './helia'; ...@@ -10,6 +10,7 @@ export { helia } from './helia';
export { marketplace } from './marketplace'; export { marketplace } from './marketplace';
export { mixpanel } from './mixpanel'; export { mixpanel } from './mixpanel';
export { monaco } from './monaco'; export { monaco } from './monaco';
export { multichain } from './multichain';
export { rollbar } from './rollbar'; export { rollbar } from './rollbar';
export { safe } from './safe'; export { safe } from './safe';
export { usernameApi } from './usernameApi'; export { usernameApi } from './usernameApi';
......
import type CspDev from 'csp-dev';
import * as multichainConfig from 'configs/multichain/config.edge';
export function multichain(): CspDev.DirectiveDescriptor {
const value = multichainConfig.getValue();
if (!value) {
return {};
}
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);
}).flat();
const rpcEndpoints = value.chains.map(({ config }) => config.chain.rpcUrls).flat();
return {
'connect-src': [
...apiEndpoints,
...rpcEndpoints,
],
};
}
...@@ -6,12 +6,14 @@ import type { RollupType } from 'types/client/rollup'; ...@@ -6,12 +6,14 @@ import type { RollupType } from 'types/client/rollup';
import type { Route } from 'nextjs-routes'; import type { Route } from 'nextjs-routes';
import config from 'configs/app'; import config from 'configs/app';
const rollupFeature = config.features.rollup; import multichainConfig from 'configs/multichain';
const adBannerFeature = config.features.adsBanner;
import isNeedProxy from 'lib/api/isNeedProxy'; import isNeedProxy from 'lib/api/isNeedProxy';
import * as cookies from 'lib/cookies'; import * as cookies from 'lib/cookies';
import type * as metadata from 'lib/metadata'; import type * as metadata from 'lib/metadata';
const rollupFeature = config.features.rollup;
const adBannerFeature = config.features.adsBanner;
export interface Props<Pathname extends Route['pathname'] = never> { export interface Props<Pathname extends Route['pathname'] = never> {
query: Route['query']; query: Route['query'];
cookies: string; cookies: string;
...@@ -411,6 +413,29 @@ export const interopMessages: GetServerSideProps<Props> = async(context) => { ...@@ -411,6 +413,29 @@ export const interopMessages: GetServerSideProps<Props> = async(context) => {
return base(context); return base(context);
}; };
export const opSuperchain: GetServerSideProps<Props> = async(context) => {
if (!config.features.opSuperchain.isEnabled) {
return {
notFound: true,
};
}
return base(context);
};
export const opSuperchainAccountsLabelSearch: GetServerSideProps<Props> = async(context) => {
const chainSlug = context.params?.['chain-slug'];
const chain = multichainConfig()?.chains.find((chain) => chain.slug === chainSlug);
if (!chain?.config.features.addressMetadata.isEnabled || !context.query.tagType) {
return {
notFound: true,
};
}
return opSuperchain(context);
};
export const pools: GetServerSideProps<Props> = async(context) => { export const pools: GetServerSideProps<Props> = async(context) => {
if (!config.features.pools.isEnabled) { if (!config.features.pools.isEnabled) {
return { return {
......
...@@ -38,6 +38,10 @@ declare module "nextjs-routes" { ...@@ -38,6 +38,10 @@ declare module "nextjs-routes" {
| DynamicRoute<"/block/countdown/[height]", { "height": string }> | DynamicRoute<"/block/countdown/[height]", { "height": string }>
| StaticRoute<"/block/countdown"> | StaticRoute<"/block/countdown">
| StaticRoute<"/blocks"> | StaticRoute<"/blocks">
| DynamicRoute<"/chain/[chain-slug]/accounts/label/[slug]", { "chain-slug": string; "slug": string }>
| DynamicRoute<"/chain/[chain-slug]/address/[hash]", { "chain-slug": string; "hash": string }>
| DynamicRoute<"/chain/[chain-slug]/block/[height_or_hash]", { "chain-slug": string; "height_or_hash": string }>
| DynamicRoute<"/chain/[chain-slug]/tx/[hash]", { "chain-slug": string; "hash": string }>
| StaticRoute<"/chakra"> | StaticRoute<"/chakra">
| StaticRoute<"/contract-verification"> | StaticRoute<"/contract-verification">
| StaticRoute<"/csv-export"> | StaticRoute<"/csv-export">
......
import type { Route } from 'nextjs-routes';
import { route as nextjsRoute } from 'nextjs-routes';
import type { TMultichainContext } from 'lib/contexts/multichain';
export const route = (route: Route, multichainContext?: TMultichainContext | null) => {
return nextjsRoute(routeParams(route, multichainContext));
};
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;
}
return route;
};
...@@ -13,7 +13,7 @@ ...@@ -13,7 +13,7 @@
"dev:preset:sync": "tsc -p ./tools/preset-sync/tsconfig.json && node ./tools/preset-sync/index.js", "dev:preset:sync": "tsc -p ./tools/preset-sync/tsconfig.json && node ./tools/preset-sync/index.js",
"build": "next build", "build": "next build",
"build:next": "./deploy/scripts/download_assets.sh ./public/assets/configs && yarn svg:build-sprite && ./deploy/scripts/make_envs_script.sh && next build", "build:next": "./deploy/scripts/download_assets.sh ./public/assets/configs && yarn svg:build-sprite && ./deploy/scripts/make_envs_script.sh && next build",
"build:docker": "docker build --build-arg GIT_COMMIT_SHA=$(git rev-parse --short HEAD) --build-arg GIT_TAG=$(git describe --tags --abbrev=0) -t blockscout-frontend:local ./", "build:docker": "./tools/scripts/build.docker.sh",
"start": "next start", "start": "next start",
"start:docker:local": "docker run -p 3000:3000 --env-file .env.local blockscout-frontend:local", "start:docker:local": "docker run -p 3000:3000 --env-file .env.local blockscout-frontend:local",
"start:docker:preset": "./tools/scripts/docker.preset.sh", "start:docker:preset": "./tools/scripts/docker.preset.sh",
...@@ -43,6 +43,7 @@ ...@@ -43,6 +43,7 @@
}, },
"dependencies": { "dependencies": {
"@blockscout/bens-types": "1.4.1", "@blockscout/bens-types": "1.4.1",
"@blockscout/multichain-aggregator-types": "1.6.0-alpha.0",
"@blockscout/points-types": "1.3.0-alpha.2", "@blockscout/points-types": "1.3.0-alpha.2",
"@blockscout/stats-types": "^2.9.0", "@blockscout/stats-types": "^2.9.0",
"@blockscout/tac-operation-lifecycle-types": "0.0.1-alpha.6", "@blockscout/tac-operation-lifecycle-types": "0.0.1-alpha.6",
......
...@@ -8,6 +8,7 @@ import React from 'react'; ...@@ -8,6 +8,7 @@ import React from 'react';
import type { NextPageWithLayout } from 'nextjs/types'; import type { NextPageWithLayout } from 'nextjs/types';
import config from 'configs/app'; import config from 'configs/app';
import getSocketUrl from 'lib/api/getSocketUrl';
import useQueryClientConfig from 'lib/api/useQueryClientConfig'; import useQueryClientConfig from 'lib/api/useQueryClientConfig';
import { AppContextProvider } from 'lib/contexts/app'; import { AppContextProvider } from 'lib/contexts/app';
import { MarketplaceContextProvider } from 'lib/contexts/marketplace'; import { MarketplaceContextProvider } from 'lib/contexts/marketplace';
...@@ -72,6 +73,8 @@ function MyApp({ Component, pageProps }: AppPropsWithLayout) { ...@@ -72,6 +73,8 @@ function MyApp({ Component, pageProps }: AppPropsWithLayout) {
); );
})(); })();
const socketUrl = !config.features.opSuperchain.isEnabled ? getSocketUrl() : undefined;
return ( return (
<ChakraProvider> <ChakraProvider>
<RollbarProvider config={ rollbarConfig }> <RollbarProvider config={ rollbarConfig }>
...@@ -84,7 +87,7 @@ function MyApp({ Component, pageProps }: AppPropsWithLayout) { ...@@ -84,7 +87,7 @@ function MyApp({ Component, pageProps }: AppPropsWithLayout) {
<QueryClientProvider client={ queryClient }> <QueryClientProvider client={ queryClient }>
<GrowthBookProvider growthbook={ growthBook }> <GrowthBookProvider growthbook={ growthBook }>
<ScrollDirectionProvider> <ScrollDirectionProvider>
<SocketProvider url={ `${ config.apis.general.socketEndpoint }${ config.apis.general.basePath ?? '' }/socket/v2` }> <SocketProvider url={ socketUrl }>
<RewardsContextProvider> <RewardsContextProvider>
<MarketplaceContextProvider> <MarketplaceContextProvider>
<SettingsContextProvider> <SettingsContextProvider>
......
...@@ -44,6 +44,12 @@ class MyDocument extends Document { ...@@ -44,6 +44,12 @@ class MyDocument extends Document {
{ /* eslint-disable-next-line @next/next/no-sync-scripts */ } { /* eslint-disable-next-line @next/next/no-sync-scripts */ }
<script src="/assets/envs.js"/> <script src="/assets/envs.js"/>
{ config.features.opSuperchain.isEnabled && (
<>
{ /* eslint-disable-next-line @next/next/no-sync-scripts */ }
<script src="/assets/multichain/config.js"/>
</>
) }
{ /* FAVICON */ } { /* FAVICON */ }
<link rel="icon" type="image/png" sizes="16x16" href="/assets/favicon/favicon-16x16.png"/> <link rel="icon" type="image/png" sizes="16x16" href="/assets/favicon/favicon-16x16.png"/>
......
...@@ -10,6 +10,7 @@ import fetchApi from 'nextjs/utils/fetchApi'; ...@@ -10,6 +10,7 @@ import fetchApi from 'nextjs/utils/fetchApi';
import config from 'configs/app'; import config from 'configs/app';
import getQueryParamString from 'lib/router/getQueryParamString'; import getQueryParamString from 'lib/router/getQueryParamString';
import AddressOpSuperchain from 'ui/optimismSuperchain/address/AddressOpSuperchain';
import Address from 'ui/pages/Address'; import Address from 'ui/pages/Address';
const pathname: Route['pathname'] = '/address/[hash]'; const pathname: Route['pathname'] = '/address/[hash]';
...@@ -17,7 +18,7 @@ const pathname: Route['pathname'] = '/address/[hash]'; ...@@ -17,7 +18,7 @@ const pathname: Route['pathname'] = '/address/[hash]';
const Page: NextPage<Props<typeof pathname>> = (props: Props<typeof pathname>) => { const Page: NextPage<Props<typeof pathname>> = (props: Props<typeof pathname>) => {
return ( return (
<PageNextJs pathname={ pathname } query={ props.query } apiData={ props.apiData }> <PageNextJs pathname={ pathname } query={ props.query } apiData={ props.apiData }>
<Address/> { config.features.opSuperchain.isEnabled ? <AddressOpSuperchain/> : <Address/> }
</PageNextJs> </PageNextJs>
); );
}; };
...@@ -27,7 +28,7 @@ export default Page; ...@@ -27,7 +28,7 @@ export default Page;
export const getServerSideProps: GetServerSideProps<Props<typeof pathname>> = async(ctx) => { export const getServerSideProps: GetServerSideProps<Props<typeof pathname>> = async(ctx) => {
const baseResponse = await gSSP.base<typeof pathname>(ctx); const baseResponse = await gSSP.base<typeof pathname>(ctx);
if (config.meta.og.enhancedDataEnabled && 'props' in baseResponse) { if (config.meta.og.enhancedDataEnabled && 'props' in baseResponse && !config.features.opSuperchain.isEnabled) {
const botInfo = detectBotRequest(ctx.req); const botInfo = detectBotRequest(ctx.req);
if (botInfo?.type === 'social_preview') { if (botInfo?.type === 'social_preview') {
......
import type { NextPage } from 'next';
import dynamic from 'next/dynamic';
import React from 'react';
import PageNextJs from 'nextjs/PageNextJs';
import { MultichainProvider } from 'lib/contexts/multichain';
const AccountsLabelSearch = dynamic(() => import('ui/pages/AccountsLabelSearch'), { ssr: false });
const Page: NextPage = () => {
return (
<PageNextJs pathname="/chain/[chain-slug]/accounts/label/[slug]">
<MultichainProvider>
<AccountsLabelSearch/>
</MultichainProvider>
</PageNextJs>
);
};
export default Page;
export { opSuperchainAccountsLabelSearch as getServerSideProps } from 'nextjs/getServerSideProps';
import type { NextPage } from 'next';
import React from 'react';
import type { Route } from 'nextjs-routes';
import type { Props } from 'nextjs/getServerSideProps';
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 Address from 'ui/pages/Address';
const pathname: Route['pathname'] = '/chain/[chain-slug]/address/[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>
<Address/>
</MultichainProvider>
</SocketProvider>
</PageNextJs>
);
};
export default Page;
export { opSuperchain as getServerSideProps } from 'nextjs/getServerSideProps';
import type { NextPage } from 'next';
import dynamic from 'next/dynamic';
import React from 'react';
import type { Props } from 'nextjs/getServerSideProps';
import PageNextJs from 'nextjs/PageNextJs';
import { MultichainProvider } from 'lib/contexts/multichain';
const Block = dynamic(() => import('ui/pages/Block'), { ssr: false });
const Page: NextPage<Props> = (props: Props) => {
return (
<PageNextJs pathname="/chain/[chain-slug]/block/[height_or_hash]" query={ props.query }>
<MultichainProvider>
<Block/>
</MultichainProvider>
</PageNextJs>
);
};
export default Page;
export { opSuperchain as getServerSideProps } from 'nextjs/getServerSideProps';
import type { NextPage } from 'next';
import dynamic from 'next/dynamic';
import React from 'react';
import type { Props } from 'nextjs/getServerSideProps';
import PageNextJs from 'nextjs/PageNextJs';
import { MultichainProvider } from 'lib/contexts/multichain';
const Transaction = dynamic(() => import('ui/pages/Transaction'), { ssr: false });
const Page: NextPage<Props> = (props: Props) => {
return (
<PageNextJs pathname="/chain/[chain-slug]/tx/[hash]" query={ props.query }>
<MultichainProvider>
<Transaction/>
</MultichainProvider>
</PageNextJs>
);
};
export default Page;
export { opSuperchain as getServerSideProps } from 'nextjs/getServerSideProps';
...@@ -4,13 +4,15 @@ import type { NextPageWithLayout } from 'nextjs/types'; ...@@ -4,13 +4,15 @@ import type { NextPageWithLayout } from 'nextjs/types';
import PageNextJs from 'nextjs/PageNextJs'; import PageNextJs from 'nextjs/PageNextJs';
import config from 'configs/app';
import HomeOpSuperchain from 'ui/optimismSuperchain/home/HomeOpSuperchain';
import Home from 'ui/pages/Home'; import Home from 'ui/pages/Home';
import LayoutHome from 'ui/shared/layout/LayoutHome'; import LayoutHome from 'ui/shared/layout/LayoutHome';
const Page: NextPageWithLayout = () => { const Page: NextPageWithLayout = () => {
return ( return (
<PageNextJs pathname="/"> <PageNextJs pathname="/">
<Home/> { config.features.opSuperchain.isEnabled ? <HomeOpSuperchain/> : <Home/> }
</PageNextJs> </PageNextJs>
); );
}; };
......
...@@ -47,7 +47,7 @@ const defaultMarketplaceContext = { ...@@ -47,7 +47,7 @@ const defaultMarketplaceContext = {
setIsAutoConnectDisabled: () => {}, setIsAutoConnectDisabled: () => {},
}; };
const wagmiConfig = createConfig({ const wagmiConfig = currentChain ? createConfig({
chains: [ currentChain ], chains: [ currentChain ],
connectors: [ connectors: [
mock({ mock({
...@@ -59,7 +59,7 @@ const wagmiConfig = createConfig({ ...@@ -59,7 +59,7 @@ const wagmiConfig = createConfig({
transports: { transports: {
[currentChain.id]: http(), [currentChain.id]: http(),
}, },
}); }) : undefined;
const TestApp = ({ children, withSocket, appContext = defaultAppContext, marketplaceContext = defaultMarketplaceContext }: Props) => { const TestApp = ({ children, withSocket, appContext = defaultAppContext, marketplaceContext = defaultMarketplaceContext }: Props) => {
const [ queryClient ] = React.useState(() => new QueryClient({ const [ queryClient ] = React.useState(() => new QueryClient({
...@@ -79,7 +79,7 @@ const TestApp = ({ children, withSocket, appContext = defaultAppContext, marketp ...@@ -79,7 +79,7 @@ const TestApp = ({ children, withSocket, appContext = defaultAppContext, marketp
<MarketplaceContext.Provider value={ marketplaceContext }> <MarketplaceContext.Provider value={ marketplaceContext }>
<SettingsContextProvider> <SettingsContextProvider>
<GrowthBookProvider> <GrowthBookProvider>
<WagmiProvider config={ wagmiConfig }> <WagmiProvider config={ wagmiConfig! }>
<RewardsContextProvider> <RewardsContextProvider>
{ children } { children }
</RewardsContextProvider> </RewardsContextProvider>
......
import config from 'configs/app';
import * as multichainConfig from 'configs/multichain/config.nodejs';
(async() => {
config.features.opSuperchain.isEnabled && await multichainConfig.load();
})();
import * as multichain from '@blockscout/multichain-aggregator-types';
import { ADDRESS_HASH } from './addressParams';
import { TX_HASH } from './tx';
export const INTEROP_MESSAGE: multichain.InteropMessage = {
sender: {
hash: ADDRESS_HASH,
},
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,
};
#!/bin/bash
# remove previous assets
rm -rf ./public/assets/configs
rm -rf ./public/assets/multichain
rm -rf ./public/assets/envs.js
docker build --progress=plain --build-arg GIT_COMMIT_SHA=$(git rev-parse --short HEAD) --build-arg GIT_TAG=$(git describe --tags --abbrev=0) -t blockscout-frontend:local ./
\ No newline at end of file
...@@ -14,11 +14,23 @@ if [ ! -f "$config_file" ]; then ...@@ -14,11 +14,23 @@ if [ ! -f "$config_file" ]; then
exit 1 exit 1
fi fi
# remove previous assets
rm -rf ./public/assets/configs
rm -rf ./public/assets/multichain
rm -rf ./public/assets/envs.js
# download assets for the running instance # download assets for the running instance
dotenv \ dotenv \
-e $config_file \ -e $config_file \
-- bash -c './deploy/scripts/download_assets.sh ./public/assets/configs' -- bash -c './deploy/scripts/download_assets.sh ./public/assets/configs'
# generate multichain config (adjust condition accordingly)
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'
fi
source ./deploy/scripts/build_sprite.sh source ./deploy/scripts/build_sprite.sh
echo "" echo ""
......
#!/bin/bash #!/bin/bash
# remove previous assets
rm -rf ./public/assets/configs
rm -rf ./public/assets/multichain
rm -rf ./public/assets/envs.js
# download assets for the running instance # download assets for the running instance
dotenv \ dotenv \
-e .env.development.local \ -e .env.development.local \
......
...@@ -18,5 +18,5 @@ ...@@ -18,5 +18,5 @@
"types": ["node", "jest"], "types": ["node", "jest"],
}, },
"include": ["next-env.d.ts", "**/*.ts", "**/*.node.ts", "**/*.tsx", "**/*.pw.tsx", "decs.d.ts", "global.d.ts"], "include": ["next-env.d.ts", "**/*.ts", "**/*.node.ts", "**/*.tsx", "**/*.pw.tsx", "decs.d.ts", "global.d.ts"],
"exclude": ["node_modules", "node_modules_linux", "./deploy/tools/envs-validator", "./deploy/tools/favicon-generator", "./toolkit/package"], "exclude": ["node_modules", "node_modules_linux", "./deploy/tools/envs-validator", "./deploy/tools/favicon-generator", "./deploy/tools/multichain-config-generator", "./toolkit/package"],
} }
import type config from 'configs/app';
export interface ChainConfig {
slug: string;
config: typeof config;
}
export interface MultichainConfig {
chains: Array<ChainConfig>;
}
import { useRouter } from 'next/router'; import { useRouter } from 'next/router';
import React from 'react'; import React from 'react';
import type { AddressFromToFilter } from 'types/api/address';
import { AddressFromToFilterValues } from 'types/api/address';
import type { TransactionsSortingField, TransactionsSortingValue, TransactionsSorting } from 'types/api/transaction';
import getFilterValueFromQuery from 'lib/getFilterValueFromQuery';
import useIsMobile from 'lib/hooks/useIsMobile'; import useIsMobile from 'lib/hooks/useIsMobile';
import useIsMounted from 'lib/hooks/useIsMounted'; import useIsMounted from 'lib/hooks/useIsMounted';
import getQueryParamString from 'lib/router/getQueryParamString'; import getQueryParamString from 'lib/router/getQueryParamString';
import { TX } from 'stubs/tx';
import { generateListStub } from 'stubs/utils';
import ActionBar, { ACTION_BAR_HEIGHT_DESKTOP } from 'ui/shared/ActionBar'; import ActionBar, { ACTION_BAR_HEIGHT_DESKTOP } from 'ui/shared/ActionBar';
import Pagination from 'ui/shared/pagination/Pagination'; import Pagination from 'ui/shared/pagination/Pagination';
import useQueryWithPages from 'ui/shared/pagination/useQueryWithPages';
import getSortParamsFromValue from 'ui/shared/sort/getSortParamsFromValue';
import getSortValueFromQuery from 'ui/shared/sort/getSortValueFromQuery';
import TxsWithAPISorting from 'ui/txs/TxsWithAPISorting'; import TxsWithAPISorting from 'ui/txs/TxsWithAPISorting';
import { SORT_OPTIONS } from 'ui/txs/useTxsSort';
import AddressCsvExportLink from './AddressCsvExportLink'; import AddressCsvExportLink from './AddressCsvExportLink';
import AddressTxsFilter from './AddressTxsFilter'; import AddressTxsFilter from './AddressTxsFilter';
import useAddressTxsQuery from './useAddressTxsQuery';
const getFilterValue = (getFilterValueFromQuery<AddressFromToFilter>).bind(null, AddressFromToFilterValues);
type Props = { type Props = {
shouldRender?: boolean; shouldRender?: boolean;
...@@ -32,37 +20,14 @@ type Props = { ...@@ -32,37 +20,14 @@ type Props = {
const AddressTxs = ({ shouldRender = true, isQueryEnabled = true }: Props) => { const AddressTxs = ({ shouldRender = true, isQueryEnabled = true }: Props) => {
const router = useRouter(); const router = useRouter();
const isMounted = useIsMounted(); const isMounted = useIsMounted();
const [ sort, setSort ] = React.useState<TransactionsSortingValue>(getSortValueFromQuery<TransactionsSortingValue>(router.query, SORT_OPTIONS) || 'default');
const isMobile = useIsMobile(); const isMobile = useIsMobile();
const currentAddress = getQueryParamString(router.query.hash); const currentAddress = getQueryParamString(router.query.hash);
const initialFilterValue = getFilterValue(router.query.filter); const { query, filterValue, initialFilterValue, onFilterChange, sort, setSort } = useAddressTxsQuery({
const [ filterValue, setFilterValue ] = React.useState<AddressFromToFilter>(initialFilterValue); addressHash: currentAddress,
const addressTxsQuery = useQueryWithPages({
resourceName: 'general:address_txs',
pathParams: { hash: currentAddress },
filters: { filter: filterValue },
sorting: getSortParamsFromValue<TransactionsSortingValue, TransactionsSortingField, TransactionsSorting['order']>(sort),
options: {
enabled: isQueryEnabled, enabled: isQueryEnabled,
placeholderData: generateListStub<'general:address_txs'>(TX, 50, { next_page_params: {
block_number: 9005713,
index: 5,
items_count: 50,
} }),
},
}); });
const handleFilterChange = React.useCallback((val: string | Array<string>) => {
const newVal = getFilterValue(val);
setFilterValue(newVal);
addressTxsQuery.onFilterChange({ filter: newVal });
}, [ addressTxsQuery ]);
if (!isMounted || !shouldRender) { if (!isMounted || !shouldRender) {
return null; return null;
} }
...@@ -70,9 +35,9 @@ const AddressTxs = ({ shouldRender = true, isQueryEnabled = true }: Props) => { ...@@ -70,9 +35,9 @@ const AddressTxs = ({ shouldRender = true, isQueryEnabled = true }: Props) => {
const filter = ( const filter = (
<AddressTxsFilter <AddressTxsFilter
initialValue={ initialFilterValue } initialValue={ initialFilterValue }
onFilterChange={ handleFilterChange } onFilterChange={ onFilterChange }
hasActiveFilter={ Boolean(filterValue) } hasActiveFilter={ Boolean(filterValue) }
isLoading={ addressTxsQuery.pagination.isLoading } isLoading={ query.pagination.isLoading }
/> />
); );
...@@ -81,7 +46,7 @@ const AddressTxs = ({ shouldRender = true, isQueryEnabled = true }: Props) => { ...@@ -81,7 +46,7 @@ const AddressTxs = ({ shouldRender = true, isQueryEnabled = true }: Props) => {
address={ currentAddress } address={ currentAddress }
params={{ type: 'transactions', filterType: 'address', filterValue }} params={{ type: 'transactions', filterType: 'address', filterValue }}
ml="auto" ml="auto"
isLoading={ addressTxsQuery.pagination.isLoading } isLoading={ query.pagination.isLoading }
/> />
); );
...@@ -91,13 +56,13 @@ const AddressTxs = ({ shouldRender = true, isQueryEnabled = true }: Props) => { ...@@ -91,13 +56,13 @@ const AddressTxs = ({ shouldRender = true, isQueryEnabled = true }: Props) => {
<ActionBar> <ActionBar>
{ filter } { filter }
{ currentAddress && csvExportLink } { currentAddress && csvExportLink }
<Pagination { ...addressTxsQuery.pagination } ml={ 8 }/> <Pagination { ...query.pagination } ml={ 8 }/>
</ActionBar> </ActionBar>
) } ) }
<TxsWithAPISorting <TxsWithAPISorting
filter={ filter } filter={ filter }
filterValue={ filterValue } filterValue={ filterValue }
query={ addressTxsQuery } query={ query }
currentAddress={ typeof currentAddress === 'string' ? currentAddress : undefined } currentAddress={ typeof currentAddress === 'string' ? currentAddress : undefined }
enableTimeIncrement enableTimeIncrement
socketType="address_txs" socketType="address_txs"
......
...@@ -5,6 +5,7 @@ import { usePublicClient } from 'wagmi'; ...@@ -5,6 +5,7 @@ import { usePublicClient } from 'wagmi';
import type { FormSubmitResult, MethodCallStrategy, SmartContractMethod } from './types'; import type { FormSubmitResult, MethodCallStrategy, SmartContractMethod } from './types';
import config from 'configs/app'; import config from 'configs/app';
import { useMultichainContext } from 'lib/contexts/multichain';
import useAccount from 'lib/web3/useAccount'; import useAccount from 'lib/web3/useAccount';
import { getNativeCoinValue } from './utils'; import { getNativeCoinValue } from './utils';
...@@ -17,7 +18,9 @@ interface Params { ...@@ -17,7 +18,9 @@ interface Params {
} }
export default function useCallMethodPublicClient(): (params: Params) => Promise<FormSubmitResult> { export default function useCallMethodPublicClient(): (params: Params) => Promise<FormSubmitResult> {
const publicClient = usePublicClient({ chainId: Number(config.chain.id) }); const multichainContext = useMultichainContext();
const chainId = Number((multichainContext?.chain.config ?? config).chain.id);
const publicClient = usePublicClient({ chainId });
const { address: account } = useAccount(); const { address: account } = useAccount();
return React.useCallback(async({ args, item, addressHash, strategy }) => { return React.useCallback(async({ args, item, addressHash, strategy }) => {
......
...@@ -5,6 +5,7 @@ import { useAccount, useWalletClient, useSwitchChain } from 'wagmi'; ...@@ -5,6 +5,7 @@ import { useAccount, useWalletClient, useSwitchChain } from 'wagmi';
import type { FormSubmitResult, SmartContractMethod } from './types'; import type { FormSubmitResult, SmartContractMethod } from './types';
import config from 'configs/app'; import config from 'configs/app';
import { useMultichainContext } from 'lib/contexts/multichain';
import useRewardsActivity from 'lib/hooks/useRewardsActivity'; import useRewardsActivity from 'lib/hooks/useRewardsActivity';
import { getNativeCoinValue } from './utils'; import { getNativeCoinValue } from './utils';
...@@ -16,7 +17,10 @@ interface Params { ...@@ -16,7 +17,10 @@ interface Params {
} }
export default function useCallMethodWalletClient(): (params: Params) => Promise<FormSubmitResult> { export default function useCallMethodWalletClient(): (params: Params) => Promise<FormSubmitResult> {
const { data: walletClient } = useWalletClient(); const multichainContext = useMultichainContext();
const chainConfig = (multichainContext?.chain.config ?? config).chain;
const { data: walletClient } = useWalletClient({ chainId: Number(chainConfig.id) });
const { isConnected, chainId, address: account } = useAccount(); const { isConnected, chainId, address: account } = useAccount();
const { switchChainAsync } = useSwitchChain(); const { switchChainAsync } = useSwitchChain();
const { trackTransaction, trackTransactionConfirm } = useRewardsActivity(); const { trackTransaction, trackTransactionConfirm } = useRewardsActivity();
...@@ -30,8 +34,8 @@ export default function useCallMethodWalletClient(): (params: Params) => Promise ...@@ -30,8 +34,8 @@ export default function useCallMethodWalletClient(): (params: Params) => Promise
throw new Error('Wallet Client is not defined'); throw new Error('Wallet Client is not defined');
} }
if (chainId && String(chainId) !== config.chain.id) { if (chainId && String(chainId) !== chainConfig.id) {
await switchChainAsync({ chainId: Number(config.chain.id) }); await switchChainAsync({ chainId: Number(chainConfig.id) });
} }
const address = getAddress(addressHash); const address = getAddress(addressHash);
...@@ -81,5 +85,5 @@ export default function useCallMethodWalletClient(): (params: Params) => Promise ...@@ -81,5 +85,5 @@ export default function useCallMethodWalletClient(): (params: Params) => Promise
} }
return { source: 'wallet_client', data: { hash } }; return { source: 'wallet_client', data: { hash } };
}, [ chainId, isConnected, switchChainAsync, walletClient, account, trackTransaction, trackTransactionConfirm ]); }, [ chainId, chainConfig, isConnected, switchChainAsync, walletClient, account, trackTransaction, trackTransactionConfirm ]);
} }
...@@ -4,9 +4,10 @@ import React from 'react'; ...@@ -4,9 +4,10 @@ import React from 'react';
import type { AddressCounters } from 'types/api/address'; import type { AddressCounters } from 'types/api/address';
import { route } from 'nextjs-routes'; import { route } from 'nextjs/routes';
import type { ResourceError } from 'lib/api/resources'; import type { ResourceError } from 'lib/api/resources';
import { useMultichainContext } from 'lib/contexts/multichain';
import { Link } from 'toolkit/chakra/link'; import { Link } from 'toolkit/chakra/link';
import { Skeleton } from 'toolkit/chakra/skeleton'; import { Skeleton } from 'toolkit/chakra/skeleton';
...@@ -25,6 +26,7 @@ const PROP_TO_TAB = { ...@@ -25,6 +26,7 @@ const PROP_TO_TAB = {
}; };
const AddressCounterItem = ({ prop, query, address, isAddressQueryLoading, isDegradedData }: Props) => { const AddressCounterItem = ({ prop, query, address, isAddressQueryLoading, isDegradedData }: Props) => {
const multichainContext = useMultichainContext();
const handleClick = React.useCallback(() => { const handleClick = React.useCallback(() => {
window.scrollTo({ top: 0, behavior: 'smooth' }); window.scrollTo({ top: 0, behavior: 'smooth' });
...@@ -56,7 +58,7 @@ const AddressCounterItem = ({ prop, query, address, isAddressQueryLoading, isDeg ...@@ -56,7 +58,7 @@ const AddressCounterItem = ({ prop, query, address, isAddressQueryLoading, isDeg
return ( return (
<Link <Link
href={ route({ pathname: '/address/[hash]', query: { hash: address, tab: PROP_TO_TAB[prop] } }) } href={ route({ pathname: '/address/[hash]', query: { hash: address, tab: PROP_TO_TAB[prop] } }, multichainContext) }
scroll={ false } scroll={ false }
onClick={ handleClick } onClick={ handleClick }
> >
......
...@@ -6,9 +6,10 @@ import React from 'react'; ...@@ -6,9 +6,10 @@ import React from 'react';
import type { Address } from 'types/api/address'; import type { Address } from 'types/api/address';
import { route } from 'nextjs-routes'; import { route } from 'nextjs/routes';
import { getResourceKey } from 'lib/api/useApiQuery'; import { getResourceKey } from 'lib/api/useApiQuery';
import { useMultichainContext } from 'lib/contexts/multichain';
import useIsMobile from 'lib/hooks/useIsMobile'; import useIsMobile from 'lib/hooks/useIsMobile';
import * as mixpanel from 'lib/mixpanel/index'; import * as mixpanel from 'lib/mixpanel/index';
import getQueryParamString from 'lib/router/getQueryParamString'; import getQueryParamString from 'lib/router/getQueryParamString';
...@@ -26,14 +27,19 @@ const TokenSelect = () => { ...@@ -26,14 +27,19 @@ const TokenSelect = () => {
const router = useRouter(); const router = useRouter();
const isMobile = useIsMobile(); const isMobile = useIsMobile();
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const multichainContext = useMultichainContext();
const addressHash = getQueryParamString(router.query.hash); const addressHash = getQueryParamString(router.query.hash);
const addressResourceKey = getResourceKey('general:address', { pathParams: { hash: addressHash } }); const addressResourceKey = getResourceKey('general:address', { pathParams: { hash: addressHash }, chainSlug: multichainContext?.chain?.slug });
const addressQueryData = queryClient.getQueryData<Address>(addressResourceKey); const addressQueryData = queryClient.getQueryData<Address>(addressResourceKey);
const { data, isError, isPending } = useFetchTokens({ hash: addressQueryData?.hash }); const { data, isError, isPending } = useFetchTokens({ hash: addressQueryData?.hash });
const tokensResourceKey = getResourceKey('general:address_tokens', { pathParams: { hash: addressQueryData?.hash }, queryParams: { type: 'ERC-20' } }); const tokensResourceKey = getResourceKey('general:address_tokens', {
pathParams: { hash: addressQueryData?.hash },
queryParams: { type: 'ERC-20' },
chainSlug: multichainContext?.chain?.slug,
});
const tokensIsFetching = useIsFetching({ queryKey: tokensResourceKey }); const tokensIsFetching = useIsFetching({ queryKey: tokensResourceKey });
const handleIconButtonClick = React.useCallback(() => { const handleIconButtonClick = React.useCallback(() => {
...@@ -63,7 +69,7 @@ const TokenSelect = () => { ...@@ -63,7 +69,7 @@ const TokenSelect = () => {
} }
<Tooltip content="Show all tokens"> <Tooltip content="Show all tokens">
<Link <Link
href={ route({ pathname: '/address/[hash]', query: { hash: addressHash, tab: 'tokens' } }) } href={ route({ pathname: '/address/[hash]', query: { hash: addressHash, tab: 'tokens' } }, multichainContext) }
asChild asChild
scroll={ false } scroll={ false }
> >
......
import { useRouter } from 'next/router';
import React from 'react';
import type { AddressFromToFilter } from 'types/api/address';
import { AddressFromToFilterValues } from 'types/api/address';
import type { TransactionsSorting, TransactionsSortingField, TransactionsSortingValue } from 'types/api/transaction';
import getFilterValueFromQuery from 'lib/getFilterValueFromQuery';
import { TX } from 'stubs/tx';
import { generateListStub } from 'stubs/utils';
import useQueryWithPages from 'ui/shared/pagination/useQueryWithPages';
import getSortParamsFromValue from 'ui/shared/sort/getSortParamsFromValue';
import getSortValueFromQuery from 'ui/shared/sort/getSortValueFromQuery';
import { SORT_OPTIONS } from 'ui/txs/useTxsSort';
const getFilterValue = (getFilterValueFromQuery<AddressFromToFilter>).bind(null, AddressFromToFilterValues);
interface Props {
addressHash: string;
enabled: boolean;
}
export default function useAddressTxsQuery({ addressHash, enabled }: Props) {
const router = useRouter();
const [ sort, setSort ] = React.useState<TransactionsSortingValue>(getSortValueFromQuery<TransactionsSortingValue>(router.query, SORT_OPTIONS) || 'default');
const initialFilterValue = getFilterValue(router.query.filter);
const [ filterValue, setFilterValue ] = React.useState<AddressFromToFilter>(initialFilterValue);
const query = useQueryWithPages({
resourceName: 'general:address_txs',
pathParams: { hash: addressHash },
filters: { filter: filterValue },
sorting: getSortParamsFromValue<TransactionsSortingValue, TransactionsSortingField, TransactionsSorting['order']>(sort),
options: {
enabled: enabled,
placeholderData: generateListStub<'general:address_txs'>(TX, 50, { next_page_params: {
block_number: 9005713,
index: 5,
items_count: 50,
} }),
},
});
const onFilterChange = React.useCallback((val: string | Array<string>) => {
const newVal = getFilterValue(val);
setFilterValue(newVal);
query.onFilterChange({ filter: newVal });
}, [ query ]);
return React.useMemo(() => ({
query,
filterValue,
initialFilterValue,
onFilterChange,
sort,
setSort,
}), [ query, filterValue, initialFilterValue, onFilterChange, sort ]);
}
...@@ -6,10 +6,11 @@ import React from 'react'; ...@@ -6,10 +6,11 @@ import React from 'react';
import { ZKSYNC_L2_TX_BATCH_STATUSES } from 'types/api/zkSyncL2'; import { ZKSYNC_L2_TX_BATCH_STATUSES } from 'types/api/zkSyncL2';
import { route } from 'nextjs-routes'; import { route, routeParams } from 'nextjs/routes';
import config from 'configs/app'; import config from 'configs/app';
import getBlockReward from 'lib/block/getBlockReward'; import getBlockReward from 'lib/block/getBlockReward';
import { useMultichainContext } from 'lib/contexts/multichain';
import getNetworkValidationActionText from 'lib/networks/getNetworkValidationActionText'; import getNetworkValidationActionText from 'lib/networks/getNetworkValidationActionText';
import getNetworkValidatorTitle from 'lib/networks/getNetworkValidatorTitle'; import getNetworkValidatorTitle from 'lib/networks/getNetworkValidatorTitle';
import * as arbitrum from 'lib/rollups/arbitrum'; import * as arbitrum from 'lib/rollups/arbitrum';
...@@ -53,6 +54,7 @@ const rollupFeature = config.features.rollup; ...@@ -53,6 +54,7 @@ const rollupFeature = config.features.rollup;
const BlockDetails = ({ query }: Props) => { const BlockDetails = ({ query }: Props) => {
const router = useRouter(); const router = useRouter();
const heightOrHash = getQueryParamString(router.query.height_or_hash); const heightOrHash = getQueryParamString(router.query.height_or_hash);
const multichainContext = useMultichainContext();
const { data, isPlaceholderData } = query; const { data, isPlaceholderData } = query;
...@@ -64,8 +66,8 @@ const BlockDetails = ({ query }: Props) => { ...@@ -64,8 +66,8 @@ const BlockDetails = ({ query }: Props) => {
const increment = direction === 'next' ? +1 : -1; const increment = direction === 'next' ? +1 : -1;
const nextId = String(data.height + increment); const nextId = String(data.height + increment);
router.push({ pathname: '/block/[height_or_hash]', query: { height_or_hash: nextId } }, undefined); router.push(routeParams({ pathname: '/block/[height_or_hash]', query: { height_or_hash: nextId } }, multichainContext), undefined);
}, [ data, router ]); }, [ data, multichainContext, router ]);
if (!data) { if (!data) {
return null; return null;
...@@ -113,7 +115,7 @@ const BlockDetails = ({ query }: Props) => { ...@@ -113,7 +115,7 @@ const BlockDetails = ({ query }: Props) => {
const txsNum = (() => { const txsNum = (() => {
const blockTxsNum = ( const blockTxsNum = (
<Link href={ route({ pathname: '/block/[height_or_hash]', query: { height_or_hash: heightOrHash, tab: 'txs' } }) }> <Link href={ route({ pathname: '/block/[height_or_hash]', query: { height_or_hash: heightOrHash, tab: 'txs' } }, multichainContext) }>
{ data.transactions_count } txn{ data.transactions_count === 1 ? '' : 's' } { data.transactions_count } txn{ data.transactions_count === 1 ? '' : 's' }
</Link> </Link>
); );
...@@ -121,7 +123,7 @@ const BlockDetails = ({ query }: Props) => { ...@@ -121,7 +123,7 @@ const BlockDetails = ({ query }: Props) => {
const blockBlobTxsNum = (config.features.dataAvailability.isEnabled && data.blob_transaction_count) ? ( const blockBlobTxsNum = (config.features.dataAvailability.isEnabled && data.blob_transaction_count) ? (
<> <>
<span> including </span> <span> including </span>
<Link href={ route({ pathname: '/block/[height_or_hash]', query: { height_or_hash: heightOrHash, tab: 'blob_txs' } }) }> <Link href={ route({ pathname: '/block/[height_or_hash]', query: { height_or_hash: heightOrHash, tab: 'blob_txs' } }, multichainContext) }>
{ data.blob_transaction_count } blob txn{ data.blob_transaction_count === 1 ? '' : 's' } { data.blob_transaction_count } blob txn{ data.blob_transaction_count === 1 ? '' : 's' }
</Link> </Link>
</> </>
...@@ -266,7 +268,7 @@ const BlockDetails = ({ query }: Props) => { ...@@ -266,7 +268,7 @@ const BlockDetails = ({ query }: Props) => {
</DetailedInfo.ItemLabel> </DetailedInfo.ItemLabel>
<DetailedInfo.ItemValue> <DetailedInfo.ItemValue>
<Skeleton loading={ isPlaceholderData }> <Skeleton loading={ isPlaceholderData }>
<Link href={ route({ pathname: '/block/[height_or_hash]', query: { height_or_hash: heightOrHash, tab: 'withdrawals' } }) }> <Link href={ route({ pathname: '/block/[height_or_hash]', query: { height_or_hash: heightOrHash, tab: 'withdrawals' } }, multichainContext) }>
{ data.withdrawals_count } withdrawal{ data.withdrawals_count === 1 ? '' : 's' } { data.withdrawals_count } withdrawal{ data.withdrawals_count === 1 ? '' : 's' }
</Link> </Link>
</Skeleton> </Skeleton>
...@@ -666,7 +668,7 @@ const BlockDetails = ({ query }: Props) => { ...@@ -666,7 +668,7 @@ const BlockDetails = ({ query }: Props) => {
</DetailedInfo.ItemLabel> </DetailedInfo.ItemLabel>
<DetailedInfo.ItemValue flexWrap="nowrap"> <DetailedInfo.ItemValue flexWrap="nowrap">
<Link <Link
href={ route({ pathname: '/block/[height_or_hash]', query: { height_or_hash: String(data.height - 1) } }) } href={ route({ pathname: '/block/[height_or_hash]', query: { height_or_hash: String(data.height - 1) } }, multichainContext) }
overflow="hidden" overflow="hidden"
whiteSpace="nowrap" whiteSpace="nowrap"
> >
......
...@@ -162,6 +162,8 @@ export default function useBlockTxsQuery({ heightOrHash, blockQuery, tab }: Para ...@@ -162,6 +162,8 @@ export default function useBlockTxsQuery({ heightOrHash, blockQuery, tab }: Para
pagination: emptyPagination, pagination: emptyPagination,
onFilterChange: () => {}, onFilterChange: () => {},
onSortingChange: () => {}, onSortingChange: () => {},
chainValue: undefined,
onChainValueChange: () => {},
}; };
const query = isRpcQuery ? rpcQueryWithPages : apiQuery; const query = isRpcQuery ? rpcQueryWithPages : apiQuery;
......
...@@ -129,6 +129,8 @@ export default function useBlockWithdrawalsQuery({ heightOrHash, blockQuery, tab ...@@ -129,6 +129,8 @@ export default function useBlockWithdrawalsQuery({ heightOrHash, blockQuery, tab
pagination: emptyPagination, pagination: emptyPagination,
onFilterChange: () => {}, onFilterChange: () => {},
onSortingChange: () => {}, onSortingChange: () => {},
chainValue: undefined,
onChainValueChange: () => {},
}; };
const query = isRpcQuery ? rpcQueryWithPages : apiQuery; const query = isRpcQuery ? rpcQueryWithPages : apiQuery;
......
import { Flex } from '@chakra-ui/react';
import { useRouter } from 'next/router';
import React from 'react';
import type { TabItemRegular } from 'toolkit/components/AdaptiveTabs/types';
import getCheckedSummedAddress from 'lib/address/getCheckedSummedAddress';
import getQueryParamString from 'lib/router/getQueryParamString';
import RoutedTabs from 'toolkit/components/RoutedTabs/RoutedTabs';
import AddressQrCode from 'ui/address/details/AddressQrCode';
import TextAd from 'ui/shared/ad/TextAd';
import AddressEntity from 'ui/shared/entities/address/AddressEntity';
import PageTitle from 'ui/shared/Page/PageTitle';
import AddressOpSuperchainTxs, { ADDRESS_OP_SUPERCHAIN_TXS_TAB_IDS } from './AddressOpSuperchainTxs';
const AddressOpSuperchain = () => {
const router = useRouter();
const hash = getQueryParamString(router.query.hash);
const isLoading = false;
const addressQuery = {
data: {
hash: undefined,
},
};
const checkSummedHash = React.useMemo(() => addressQuery.data?.hash ?? getCheckedSummedAddress(hash), [ hash, addressQuery.data?.hash ]);
const tabs: Array<TabItemRegular> = React.useMemo(() => {
return [
{
id: 'index',
title: 'Details',
component: <div>Coming soon 🔜</div>,
},
{
id: 'txs',
title: 'Transactions',
component: <AddressOpSuperchainTxs/>,
subTabs: ADDRESS_OP_SUPERCHAIN_TXS_TAB_IDS,
},
];
}, []);
const titleSecondRow = (
<Flex alignItems="center" w="100%" columnGap={ 2 } rowGap={ 2 } flexWrap={{ base: 'wrap', lg: 'nowrap' }}>
<AddressEntity
address={{
...addressQuery.data,
hash: checkSummedHash,
name: '',
ens_domain_name: '',
implementations: null,
}}
isLoading={ isLoading }
variant="subheading"
noLink
/>
<AddressQrCode hash={ checkSummedHash } isLoading={ isLoading }/>
</Flex>
);
return (
<>
<TextAd mb={ 6 }/>
<PageTitle
title="Address details"
isLoading={ isLoading }
secondRow={ titleSecondRow }
/>
<RoutedTabs tabs={ tabs } isLoading={ isLoading }/>
</>
);
};
export default React.memo(AddressOpSuperchain);
import { HStack } from '@chakra-ui/react';
import { useRouter } from 'next/router';
import React from 'react';
import type { TabItemRegular } from 'toolkit/components/AdaptiveTabs/types';
import multichainConfig from 'configs/multichain';
import getSocketUrl from 'lib/api/getSocketUrl';
import { MultichainProvider } from 'lib/contexts/multichain';
import getQueryParamString from 'lib/router/getQueryParamString';
import { SocketProvider } from 'lib/socket/context';
import RoutedTabs from 'toolkit/components/RoutedTabs/RoutedTabs';
// import AddressCsvExportLink from 'ui/address/AddressCsvExportLink';
import AddressTxsFilter from 'ui/address/AddressTxsFilter';
import useAddressTxsQuery from 'ui/address/useAddressTxsQuery';
import ChainSelect from 'ui/shared/multichain/ChainSelect';
import Pagination from 'ui/shared/pagination/Pagination';
import TxsWithAPISorting from 'ui/txs/TxsWithAPISorting';
export const ADDRESS_OP_SUPERCHAIN_TXS_TAB_IDS = [ 'cross_chain_txs', 'local_txs' ];
const TAB_LIST_PROPS = {
marginBottom: 0,
pt: 6,
pb: 3,
marginTop: -6,
};
const ACTION_BAR_HEIGHT_DESKTOP = 68;
const AddressOpSuperchainTxs = () => {
const router = useRouter();
const hash = getQueryParamString(router.query.hash);
const tab = getQueryParamString(router.query.tab);
const txsQueryLocal = useAddressTxsQuery({
addressHash: hash,
enabled: tab === 'local_txs',
});
const txsLocalFilter = tab === 'local_txs' ? (
<AddressTxsFilter
initialValue={ txsQueryLocal.filterValue }
onFilterChange={ txsQueryLocal.onFilterChange }
hasActiveFilter={ Boolean(txsQueryLocal.filterValue) }
isLoading={ txsQueryLocal.query.pagination.isLoading }
/>
) : null;
const rightSlot = tab === 'local_txs' ? (
<>
<HStack gap={ 2 }>
{ txsLocalFilter }
<ChainSelect
loading={ txsQueryLocal.query.pagination.isLoading }
value={ txsQueryLocal.query.chainValue }
onValueChange={ txsQueryLocal.query.onChainValueChange }
/>
</HStack>
<HStack gap={ 6 }>
{ /* <AddressCsvExportLink
address={ hash }
params={{ type: 'transactions', filterType: 'address', filterValue: txsQueryLocal.filterValue }}
ml="auto"
isLoading={ txsQueryLocal.query.pagination.isLoading }
/> */ }
<Pagination { ...txsQueryLocal.query.pagination } ml={ 8 }/>
</HStack>
</>
) : null;
const chainData = multichainConfig()?.chains.find(chain => chain.slug === txsQueryLocal.query.chainValue?.[0]);
const tabs: Array<TabItemRegular> = [
{
id: 'cross_chain_txs',
title: 'Cross-chain',
component: <div>Coming soon 🔜</div>,
},
{
id: 'local_txs',
title: 'Local',
component: (
<SocketProvider url={ getSocketUrl(chainData?.config) }>
<MultichainProvider chainSlug={ txsQueryLocal.query.chainValue?.[0] }>
<TxsWithAPISorting
filter={ txsLocalFilter }
filterValue={ txsQueryLocal.filterValue }
query={ txsQueryLocal.query }
currentAddress={ hash }
enableTimeIncrement
socketType="address_txs"
top={ ACTION_BAR_HEIGHT_DESKTOP }
sorting={ txsQueryLocal.sort }
setSort={ txsQueryLocal.setSort }
/>
</MultichainProvider>
</SocketProvider>
),
},
];
return (
<RoutedTabs
variant="secondary"
size="sm"
tabs={ tabs }
rightSlot={ rightSlot }
rightSlotProps={{ display: 'flex', justifyContent: 'space-between', ml: 8, widthAllocation: 'available' }}
listProps={ TAB_LIST_PROPS }
stickyEnabled
/>
);
};
export default React.memo(AddressOpSuperchainTxs);
import React from 'react';
import * as multichain from '@blockscout/multichain-aggregator-types';
import type { BadgeProps } from 'toolkit/chakra/badge';
import StatusTag from 'ui/shared/statusTag/StatusTag';
interface Props extends BadgeProps {
status: multichain.InteropMessage_Status;
}
const CrossChainTxStatusTag = ({ status: statusProp, ...rest }: Props) => {
const { status, text } = (() => {
switch (statusProp) {
case multichain.InteropMessage_Status.SUCCESS:
return { status: 'ok' as const, text: 'Relayed' };
case multichain.InteropMessage_Status.FAILED:
return { status: 'error' as const, text: 'Failed' };
case multichain.InteropMessage_Status.PENDING:
return { status: 'pending' as const, text: 'Sent' };
default:
return { status: undefined, text: undefined };
}
})();
if (!status || !text) {
return null;
}
return <StatusTag type={ status } text={ text } { ...rest }/>;
};
export default React.memo(CrossChainTxStatusTag);
import React from 'react';
import type * as multichain from '@blockscout/multichain-aggregator-types';
import type { TxsSocketType } from 'ui/txs/socket/types';
import { AddressHighlightProvider } from 'lib/contexts/addressHighlight';
import { TableBody, TableColumnHeader, TableHeaderSticky, TableRoot, TableRow } from 'toolkit/chakra/table';
import TimeFormatToggle from 'ui/shared/time/TimeFormatToggle';
import TxsSocketNotice from 'ui/txs/socket/TxsSocketNotice';
import CrossChainTxsTableItem from './CrossChainTxsTableItem';
interface Props {
items: Array<multichain.InteropMessage>;
isLoading: boolean;
socketType?: TxsSocketType;
}
const CrossChainTxsTable = ({ items, isLoading, socketType }: Props) => {
return (
<AddressHighlightProvider>
<TableRoot minW="1150px">
<TableHeaderSticky top={ 68 }>
<TableRow>
<TableColumnHeader width="52px"/>
<TableColumnHeader w="180px">
Message
<TimeFormatToggle/>
</TableColumnHeader>
<TableColumnHeader w="130px">Type</TableColumnHeader>
<TableColumnHeader w="130px">Method</TableColumnHeader>
<TableColumnHeader w="25%">Source tx</TableColumnHeader>
<TableColumnHeader w="25%">Destination tx</TableColumnHeader>
<TableColumnHeader w="25%">Sender</TableColumnHeader>
<TableColumnHeader w="32px"/>
<TableColumnHeader w="25%">Target</TableColumnHeader>
<TableColumnHeader w="130px">Value</TableColumnHeader>
</TableRow>
</TableHeaderSticky>
<TableBody>
{ socketType && <TxsSocketNotice type={ socketType } place="table" isLoading={ isLoading }/> }
{ items.map((item, index) => (
<CrossChainTxsTableItem
key={ String(item.nonce) + (isLoading ? index : '') }
item={ item }
isLoading={ isLoading }
/>
)) }
</TableBody>
</TableRoot>
</AddressHighlightProvider>
);
};
export default React.memo(CrossChainTxsTable);
import { Spinner, VStack } from '@chakra-ui/react';
import React from 'react';
import type * as multichain from '@blockscout/multichain-aggregator-types';
import type { TransactionType } from 'types/api/transaction';
import multichainConfig from 'configs/multichain';
import getCurrencyValue from 'lib/getCurrencyValue';
import { Badge } from 'toolkit/chakra/badge';
import { Link } from 'toolkit/chakra/link';
import { TableCell, TableRow } from 'toolkit/chakra/table';
import CrossChainTxStatusTag from 'ui/optimismSuperchain/components/CrossChainTxStatusTag';
import AdditionalInfoButton from 'ui/shared/AdditionalInfoButton';
import AddressFromToIcon from 'ui/shared/address/AddressFromToIcon';
import AddressEntity from 'ui/shared/entities/address/AddressEntity';
import TxEntity from 'ui/shared/entities/tx/TxEntity';
import TimeWithTooltip from 'ui/shared/time/TimeWithTooltip';
import TxType from 'ui/txs/TxType';
interface Props {
item: multichain.InteropMessage;
isLoading: boolean;
animation?: string;
}
const CrossChainTxsTableItem = ({ item, isLoading, animation }: Props) => {
const sourceChain = React.useMemo(() => {
const config = multichainConfig();
return config?.chains.find((chain) => chain.config.chain.id === item.init_chain_id);
}, [ item ]);
const targetChain = React.useMemo(() => {
const config = multichainConfig();
return config?.chains.find((chain) => chain.config.chain.id === item.relay_chain_id);
}, [ item ]);
const value = getCurrencyValue({
value: item.transfer?.total?.value ?? '0',
decimals: '18',
});
return (
<TableRow animation={ animation }>
<TableCell pl={ 4 }>
<AdditionalInfoButton loading={ isLoading }/>
</TableCell>
<TableCell>
<VStack alignItems="start">
<Link fontWeight="700" loading={ isLoading }>{ item.nonce }</Link>
<TimeWithTooltip
timestamp={ item.timestamp }
isLoading={ isLoading }
color="text.secondary"
/>
</VStack>
</TableCell>
<TableCell>
<VStack alignItems="start">
<TxType types={ [ item.message_type as TransactionType ] } isLoading={ isLoading }/>
<CrossChainTxStatusTag status={ item.status } loading={ isLoading }/>
</VStack>
</TableCell>
<TableCell>
<Badge colorPalette="gray" loading={ isLoading } truncated>{ item.method }</Badge>
</TableCell>
<TableCell>
{ item.init_transaction_hash ? (
<TxEntity
hash={ item.init_transaction_hash }
isLoading={ isLoading }
truncation="constant"
chain={ sourceChain }
/>
) :
<Spinner size="md"/>
}
</TableCell>
<TableCell>
{ item.relay_transaction_hash ? (
<TxEntity
hash={ item.relay_transaction_hash }
isLoading={ isLoading }
truncation="constant"
chain={ targetChain }
/>
) :
<Spinner size="md"/>
}
</TableCell>
<TableCell>
{ item.sender ? (
<AddressEntity
address={{ hash: item.sender.hash }}
isLoading={ isLoading }
chain={ sourceChain }
/>
) : '-' }
</TableCell>
<TableCell>
<AddressFromToIcon isLoading={ isLoading } type="unspecified"/>
</TableCell>
<TableCell>
{ item.target ? (
<AddressEntity
address={{ hash: item.target.hash }}
isLoading={ isLoading }
chain={ targetChain }
/>
) : '-' }
</TableCell>
<TableCell>
{ value.valueStr }
</TableCell>
</TableRow>
);
};
export default React.memo(CrossChainTxsTableItem);
import { Box, HStack } from '@chakra-ui/react';
import { useQueryClient } from '@tanstack/react-query';
import React from 'react';
import type { SocketMessage } from 'lib/socket/types';
import { route } from 'nextjs-routes';
import useApiQuery, { getResourceKey } from 'lib/api/useApiQuery';
import useSocketChannel from 'lib/socket/useSocketChannel';
import useSocketMessage from 'lib/socket/useSocketMessage';
import { BLOCK } from 'stubs/block';
import { Link } from 'toolkit/chakra/link';
import TimeWithTooltip from 'ui/shared/time/TimeWithTooltip';
interface Props {
slug: string;
}
const ChainLatestBlockInfo = ({ slug }: Props) => {
const queryClient = useQueryClient();
const blocksQuery = useApiQuery('general:homepage_blocks', {
chainSlug: slug,
queryOptions: {
placeholderData: [ BLOCK ],
},
});
const handleNewBlockMessage: SocketMessage.NewBlock['handler'] = React.useCallback((payload) => {
const queryKey = getResourceKey('general:homepage_blocks', { chainSlug: slug });
queryClient.setQueryData(queryKey, () => {
return [ payload.block ];
});
}, [ queryClient, slug ]);
const channel = useSocketChannel({
topic: 'blocks:new_block',
isDisabled: blocksQuery.isPlaceholderData || blocksQuery.isError,
});
useSocketMessage({
channel,
event: 'new_block',
handler: handleNewBlockMessage,
});
if (!blocksQuery.data?.[0]) {
return null;
}
return (
<HStack gap={ 2 }>
<Box color="text.secondary">Latest block</Box>
<Link
loading={ blocksQuery.isPlaceholderData }
href={ route({
pathname: '/chain/[chain-slug]/block/[height_or_hash]',
query: {
'chain-slug': slug,
height_or_hash: blocksQuery.data[0].height.toString(),
},
}) }
>
{ blocksQuery.data[0].height }
</Link>
<TimeWithTooltip
timestamp={ blocksQuery.data[0].timestamp }
enableIncrement={ !blocksQuery.isPlaceholderData }
isLoading={ blocksQuery.isPlaceholderData }
color="text.secondary"
flexShrink={ 0 }
timeFormat="relative"
/>
</HStack>
);
};
export default React.memo(ChainLatestBlockInfo);
import { Box, HStack, VStack } from '@chakra-ui/react';
import React from 'react';
import type { ChainConfig } from 'types/multichain';
import useApiQuery from 'lib/api/useApiQuery';
import { HOMEPAGE_STATS } from 'stubs/stats';
import { Heading } from 'toolkit/chakra/heading';
import { Image } from 'toolkit/chakra/image';
import { Link } from 'toolkit/chakra/link';
import { Skeleton } from 'toolkit/chakra/skeleton';
import CopyToClipboard from 'ui/shared/CopyToClipboard';
import GasPrice from 'ui/shared/gas/GasPrice';
import IconSvg from 'ui/shared/IconSvg';
import ChainLatestBlockInfo from './ChainLatestBlockInfo';
interface Props {
data: ChainConfig;
}
const ChainWidget = ({ data }: Props) => {
const statsQuery = useApiQuery('general:stats', {
chainSlug: data.slug,
queryOptions: {
placeholderData: HOMEPAGE_STATS,
},
});
return (
<Box
bgColor={{ _light: 'blackAlpha.50', _dark: 'whiteAlpha.50' }}
borderRadius="xl"
border="1px solid"
borderColor={{ _light: 'gray.200', _dark: 'gray.900' }}
p={ 4 }
flexBasis="50%"
textStyle="sm"
>
<HStack justifyContent="space-between">
<Image src={ data.config.UI.navigation.icon.default } alt={ data.config.chain.name } boxSize="30px" borderRadius="full"/>
<Link
href={ data.config.app.baseUrl }
target="_blank"
p={ 1 }
color="gray.500"
_hover={{
color: 'link.primary.hover',
}}
bgColor={{ _light: 'blackAlpha.50', _dark: 'whiteAlpha.50' }}
borderRadius="base"
>
<IconSvg name="globe" boxSize={ 6 }/>
</Link>
</HStack>
<Heading mt={ 3 } level="3">{ data.config.chain.name }</Heading>
<VStack gap={ 2 } mt={ 3 } alignItems="flex-start">
<HStack gap={ 2 }>
<Box color="text.secondary">Chain ID</Box>
<Box>{ data.config.chain.id }</Box>
<CopyToClipboard text={ String(data.config.chain.id) } ml={ 0 }/>
</HStack>
<ChainLatestBlockInfo slug={ data.slug }/>
{ statsQuery.data && statsQuery.data.gas_prices && data.config.features.gasTracker.isEnabled && (
<HStack gap={ 2 }>
<Box color="text.secondary">Gas price</Box>
<Skeleton loading={ statsQuery.isPlaceholderData }>
<GasPrice data={ statsQuery.data.gas_prices.average }/>
</Skeleton>
</HStack>
) }
</VStack>
</Box>
);
};
export default React.memo(ChainWidget);
import { Box, HStack } from '@chakra-ui/react';
import React from 'react';
import multichainConfig from 'configs/multichain';
import getSocketUrl from 'lib/api/getSocketUrl';
import { MultichainProvider } from 'lib/contexts/multichain';
import { SocketProvider } from 'lib/socket/context';
import HeroBanner from 'ui/home/HeroBanner';
import ChainWidget from './ChainWidget';
import LatestTxs from './LatestTxs';
const HomeOpSuperchain = () => {
return (
<Box as="main">
<HeroBanner/>
<HStack mt={ 3 } gap={ 6 }>
{ multichainConfig()?.chains.map(chain => {
return (
<MultichainProvider key={ chain.slug } chainSlug={ chain.slug }>
<SocketProvider url={ getSocketUrl(chain.config) }>
<ChainWidget data={ chain }/>
</SocketProvider>
</MultichainProvider>
);
}) }
</HStack>
<LatestTxs/>
</Box>
);
};
export default React.memo(HomeOpSuperchain);
import { Box } from '@chakra-ui/react';
import { useRouter } from 'next/router';
import React from 'react';
import multichainConfig from 'configs/multichain';
import getQueryParamString from 'lib/router/getQueryParamString';
import { Heading } from 'toolkit/chakra/heading';
import RoutedTabs from 'toolkit/components/RoutedTabs/RoutedTabs';
import ChainSelect from 'ui/shared/multichain/ChainSelect';
import LatestTxsCrossChain from './LatestTxsCrossChain';
import LatestTxsLocal from './LatestTxsLocal';
const LatestTxs = () => {
const router = useRouter();
const tab = getQueryParamString(router.query.tab);
const [ chainValue, setChainValue ] = React.useState<Array<string> | undefined>(
[ getQueryParamString(router.query['chain-slug']) ?? multichainConfig()?.chains[0]?.slug ].filter(Boolean),
);
const handleChainValueChange = React.useCallback(({ value }: { value: Array<string> }) => {
setChainValue(value);
router.push({
query: {
...router.query,
'chain-slug': value[0],
},
}, undefined, { shallow: true });
}, [ router ]);
const tabs = [
{
id: 'cross_chain_txs',
title: 'Cross-chain',
component: <LatestTxsCrossChain/>,
},
{
id: 'local_txs',
title: 'Local',
component: chainValue ? <LatestTxsLocal key={ chainValue[0] } chainSlug={ chainValue[0] }/> : null,
},
];
const rightSlot = tab === 'local_txs' ? (
<ChainSelect
loading={ false }
value={ chainValue }
onValueChange={ handleChainValueChange }
w="fit-content"
/>
) : null;
return (
<Box as="section" mt={ 8 }>
<Heading level="3" mb={ 6 }>Latest transactions</Heading>
<RoutedTabs
tabs={ tabs }
rightSlot={ rightSlot }
/>
</Box>
);
};
export default React.memo(LatestTxs);
import { Box } from '@chakra-ui/react';
import React from 'react';
import config from 'configs/app';
import useApiQuery from 'lib/api/useApiQuery';
import { SocketProvider } from 'lib/socket/context';
import { INTEROP_MESSAGE } from 'stubs/optimismSuperchain';
import { generateListStub } from 'stubs/utils';
import CrossChainTxsTable from 'ui/optimismSuperchain/crossChainTxs/CrossChainTxsTable';
import DataListDisplay from 'ui/shared/DataListDisplay';
const socketUrl = config.apis.multichain?.socketEndpoint ? `${ config.apis.multichain.socketEndpoint }/socket` : undefined;
const LatestTxsCrossChain = () => {
const { data, isError, isPlaceholderData } = useApiQuery('multichain:interop_messages', {
queryOptions: {
placeholderData: generateListStub<'multichain:interop_messages'>(INTEROP_MESSAGE, 5, { next_page_params: undefined }),
select: (data) => ({ ...data, items: data.items.slice(0, 5) }),
},
});
const content = data?.items ? (
<>
<Box hideFrom="lg">
Coming soon 🔜
</Box>
<Box hideBelow="lg">
<CrossChainTxsTable
isLoading={ isPlaceholderData }
items={ data.items }
socketType="txs_home_cross_chain"
/>
</Box>
</>
) : null;
return (
<SocketProvider url={ socketUrl }>
<DataListDisplay
itemsNum={ data?.items?.length }
isError={ isError }
emptyText="There are no cross-chain transactions."
>
{ content }
</DataListDisplay>
</SocketProvider>
);
};
export default React.memo(LatestTxsCrossChain);
import { noop } from 'es-toolkit';
import React from 'react';
import type { PaginationParams } from 'ui/shared/pagination/types';
import multichainConfig from 'configs/multichain';
import getSocketUrl from 'lib/api/getSocketUrl';
import useApiQuery from 'lib/api/useApiQuery';
import { MultichainProvider } from 'lib/contexts/multichain';
import { SocketProvider } from 'lib/socket/context';
import { TX } from 'stubs/tx';
import TxsContent from 'ui/txs/TxsContent';
const PAGINATION_PARAMS: PaginationParams = {
page: 1,
isVisible: false,
isLoading: false,
hasPages: false,
hasNextPage: false,
canGoBackwards: false,
onNextPageClick: () => {},
onPrevPageClick: () => {},
resetPage: () => {},
};
interface Props {
chainSlug: string;
}
const LatestTxsLocal = ({ chainSlug }: Props) => {
const query = useApiQuery('general:homepage_txs', {
chainSlug,
queryOptions: {
placeholderData: Array(5).fill(TX),
select: (data) => data.slice(0, 5),
},
});
const chainData = multichainConfig()?.chains.find(chain => chain.slug === chainSlug);
return (
<MultichainProvider chainSlug={ chainSlug }>
<SocketProvider url={ getSocketUrl(chainData?.config) }>
<TxsContent
items={ query.data || [] }
isPlaceholderData={ query.isPlaceholderData }
isError={ query.isError }
pagination={ PAGINATION_PARAMS }
setSorting={ noop }
sort="default"
socketType="txs_home"
/>
</SocketProvider>
</MultichainProvider>
);
};
export default React.memo(LatestTxsLocal);
...@@ -11,6 +11,7 @@ import useAddressMetadataInfoQuery from 'lib/address/useAddressMetadataInfoQuery ...@@ -11,6 +11,7 @@ import useAddressMetadataInfoQuery from 'lib/address/useAddressMetadataInfoQuery
import useAddressMetadataInitUpdate from 'lib/address/useAddressMetadataInitUpdate'; import useAddressMetadataInitUpdate from 'lib/address/useAddressMetadataInitUpdate';
import useApiQuery from 'lib/api/useApiQuery'; import useApiQuery from 'lib/api/useApiQuery';
import { useAppContext } from 'lib/contexts/app'; import { useAppContext } from 'lib/contexts/app';
import { useMultichainContext } from 'lib/contexts/multichain';
import useAddressProfileApiQuery from 'lib/hooks/useAddressProfileApiQuery'; import useAddressProfileApiQuery from 'lib/hooks/useAddressProfileApiQuery';
import useIsSafeAddress from 'lib/hooks/useIsSafeAddress'; import useIsSafeAddress from 'lib/hooks/useIsSafeAddress';
import getNetworkValidationActionText from 'lib/networks/getNetworkValidationActionText'; import getNetworkValidationActionText from 'lib/networks/getNetworkValidationActionText';
...@@ -69,6 +70,7 @@ const xScoreFeature = config.features.xStarScore; ...@@ -69,6 +70,7 @@ const xScoreFeature = config.features.xStarScore;
const AddressPageContent = () => { const AddressPageContent = () => {
const router = useRouter(); const router = useRouter();
const appProps = useAppContext(); const appProps = useAppContext();
const { chain } = useMultichainContext() || {};
const hash = getQueryParamString(router.query.hash); const hash = getQueryParamString(router.query.hash);
...@@ -437,11 +439,13 @@ const AddressPageContent = () => { ...@@ -437,11 +439,13 @@ const AddressPageContent = () => {
</Flex> </Flex>
); );
const chainText = chain ? ` on ${ chain.config.chain.name }` : '';
return ( return (
<> <>
<TextAd mb={ 6 }/> <TextAd mb={ 6 }/>
<PageTitle <PageTitle
title={ `${ addressQuery.data?.is_contract && addressQuery.data?.proxy_type !== 'eip7702' ? 'Contract' : 'Address' } details` } title={ `${ addressQuery.data?.is_contract && addressQuery.data?.proxy_type !== 'eip7702' ? 'Contract' : 'Address' } details${ chainText }` }
backLink={ backLink } backLink={ backLink }
contentAfter={ titleContentAfter } contentAfter={ titleContentAfter }
secondRow={ titleSecondRow } secondRow={ titleSecondRow }
......
...@@ -8,6 +8,7 @@ import type { PaginationParams } from 'ui/shared/pagination/types'; ...@@ -8,6 +8,7 @@ import type { PaginationParams } from 'ui/shared/pagination/types';
import config from 'configs/app'; import config from 'configs/app';
import { useAppContext } from 'lib/contexts/app'; import { useAppContext } from 'lib/contexts/app';
import { useMultichainContext } from 'lib/contexts/multichain';
import throwOnAbsentParamError from 'lib/errors/throwOnAbsentParamError'; import throwOnAbsentParamError from 'lib/errors/throwOnAbsentParamError';
import throwOnResourceLoadError from 'lib/errors/throwOnResourceLoadError'; import throwOnResourceLoadError from 'lib/errors/throwOnResourceLoadError';
import useIsMobile from 'lib/hooks/useIsMobile'; import useIsMobile from 'lib/hooks/useIsMobile';
...@@ -46,6 +47,7 @@ const BlockPageContent = () => { ...@@ -46,6 +47,7 @@ const BlockPageContent = () => {
const appProps = useAppContext(); const appProps = useAppContext();
const heightOrHash = getQueryParamString(router.query.height_or_hash); const heightOrHash = getQueryParamString(router.query.height_or_hash);
const tab = getQueryParamString(router.query.tab); const tab = getQueryParamString(router.query.tab);
const { chain } = useMultichainContext() || {};
const blockQuery = useBlockQuery({ heightOrHash }); const blockQuery = useBlockQuery({ heightOrHash });
const blockTxsQuery = useBlockTxsQuery({ heightOrHash, blockQuery, tab }); const blockTxsQuery = useBlockTxsQuery({ heightOrHash, blockQuery, tab });
...@@ -145,15 +147,17 @@ const BlockPageContent = () => { ...@@ -145,15 +147,17 @@ const BlockPageContent = () => {
} }
const title = (() => { const title = (() => {
const chainText = chain ? ` on ${ chain.config.chain.name }` : '';
switch (blockQuery.data?.type) { switch (blockQuery.data?.type) {
case 'reorg': case 'reorg':
return `Reorged block #${ blockQuery.data?.height }`; return `Reorged block #${ blockQuery.data?.height }${ chainText }`;
case 'uncle': case 'uncle':
return `Uncle block #${ blockQuery.data?.height }`; return `Uncle block #${ blockQuery.data?.height }${ chainText }`;
default: default:
return `Block #${ blockQuery.data?.height }`; return `Block #${ blockQuery.data?.height }${ chainText }`;
} }
})(); })();
......
...@@ -7,6 +7,7 @@ import type { EntityTag as TEntityTag } from 'ui/shared/EntityTags/types'; ...@@ -7,6 +7,7 @@ import type { EntityTag as TEntityTag } from 'ui/shared/EntityTags/types';
import config from 'configs/app'; import config from 'configs/app';
import useApiQuery from 'lib/api/useApiQuery'; import useApiQuery from 'lib/api/useApiQuery';
import { useAppContext } from 'lib/contexts/app'; import { useAppContext } from 'lib/contexts/app';
import { useMultichainContext } from 'lib/contexts/multichain';
import throwOnResourceLoadError from 'lib/errors/throwOnResourceLoadError'; import throwOnResourceLoadError from 'lib/errors/throwOnResourceLoadError';
import getQueryParamString from 'lib/router/getQueryParamString'; import getQueryParamString from 'lib/router/getQueryParamString';
import useEtherscanRedirects from 'lib/router/useEtherscanRedirects'; import useEtherscanRedirects from 'lib/router/useEtherscanRedirects';
...@@ -38,6 +39,7 @@ const tacFeature = config.features.tac; ...@@ -38,6 +39,7 @@ const tacFeature = config.features.tac;
const TransactionPageContent = () => { const TransactionPageContent = () => {
const router = useRouter(); const router = useRouter();
const appProps = useAppContext(); const appProps = useAppContext();
const { chain } = useMultichainContext() || {};
const hash = getQueryParamString(router.query.hash); const hash = getQueryParamString(router.query.hash);
...@@ -133,7 +135,7 @@ const TransactionPageContent = () => { ...@@ -133,7 +135,7 @@ const TransactionPageContent = () => {
<> <>
<TextAd mb={ 6 }/> <TextAd mb={ 6 }/>
<PageTitle <PageTitle
title="Transaction details" title={ chain ? `Transaction details on ${ chain.config.chain.name }` : 'Transaction details' }
backLink={ backLink } backLink={ backLink }
contentAfter={ tags } contentAfter={ tags }
secondRow={ titleSecondRow } secondRow={ titleSecondRow }
......
...@@ -3,6 +3,7 @@ import React from 'react'; ...@@ -3,6 +3,7 @@ import React from 'react';
import type { EntityTag as TEntityTag } from './types'; import type { EntityTag as TEntityTag } from './types';
import { useMultichainContext } from 'lib/contexts/multichain';
import * as mixpanel from 'lib/mixpanel/index'; import * as mixpanel from 'lib/mixpanel/index';
import { Link, LinkExternalIcon } from 'toolkit/chakra/link'; import { Link, LinkExternalIcon } from 'toolkit/chakra/link';
import { Skeleton } from 'toolkit/chakra/skeleton'; import { Skeleton } from 'toolkit/chakra/skeleton';
...@@ -19,8 +20,9 @@ interface Props extends HTMLChakraProps<'span'> { ...@@ -19,8 +20,9 @@ interface Props extends HTMLChakraProps<'span'> {
} }
const EntityTag = ({ data, isLoading, noLink, ...rest }: Props) => { const EntityTag = ({ data, isLoading, noLink, ...rest }: Props) => {
const multichainContext = useMultichainContext();
const linkParams = !noLink ? getTagLinkParams(data) : undefined; const linkParams = !noLink ? getTagLinkParams(data, multichainContext) : undefined;
const hasLink = Boolean(linkParams); const hasLink = Boolean(linkParams);
const iconColor = data.meta?.textColor ?? 'gray.400'; const iconColor = data.meta?.textColor ?? 'gray.400';
......
import type { EntityTag } from './types'; import type { EntityTag } from './types';
import { route } from 'nextjs-routes'; import { route } from 'nextjs/routes';
export function getTagLinkParams(data: EntityTag): { type: 'external' | 'internal'; href: string } | undefined { import type { TMultichainContext } from 'lib/contexts/multichain';
export function getTagLinkParams(data: EntityTag, multichainContext?: TMultichainContext | null): { type: 'external' | 'internal'; href: string } | undefined {
if (data.meta?.warpcastHandle) { if (data.meta?.warpcastHandle) {
return { return {
type: 'external', type: 'external',
...@@ -20,7 +22,7 @@ export function getTagLinkParams(data: EntityTag): { type: 'external' | 'interna ...@@ -20,7 +22,7 @@ export function getTagLinkParams(data: EntityTag): { type: 'external' | 'interna
if (data.tagType === 'generic' || data.tagType === 'protocol') { if (data.tagType === 'generic' || data.tagType === 'protocol') {
return { return {
type: 'internal', type: 'internal',
href: route({ pathname: '/accounts/label/[slug]', query: { slug: data.slug, tagType: data.tagType, tagName: data.name } }), href: route({ pathname: '/accounts/label/[slug]', query: { slug: data.slug, tagType: data.tagType, tagName: data.name } }, multichainContext),
}; };
} }
} }
...@@ -4,7 +4,7 @@ import React from 'react'; ...@@ -4,7 +4,7 @@ import React from 'react';
import { WagmiProvider } from 'wagmi'; import { WagmiProvider } from 'wagmi';
import config from 'configs/app'; import config from 'configs/app';
import { currentChain, parentChain } from 'lib/web3/chains'; import { currentChain, parentChain, clusterChains } from 'lib/web3/chains';
import wagmiConfig from 'lib/web3/wagmiConfig'; import wagmiConfig from 'lib/web3/wagmiConfig';
import { useColorMode } from 'toolkit/chakra/color-mode'; import { useColorMode } from 'toolkit/chakra/color-mode';
import colors from 'toolkit/theme/foundations/colors'; import colors from 'toolkit/theme/foundations/colors';
...@@ -19,9 +19,11 @@ const init = () => { ...@@ -19,9 +19,11 @@ const init = () => {
return; return;
} }
const networks = [ currentChain, parentChain, ...(clusterChains ?? []) ].filter(Boolean) as [AppKitNetwork, ...Array<AppKitNetwork>];
createAppKit({ createAppKit({
adapters: [ wagmiConfig.adapter ], adapters: [ wagmiConfig.adapter ],
networks: [ currentChain, parentChain ].filter(Boolean) as [AppKitNetwork, ...Array<AppKitNetwork>], networks,
metadata: { metadata: {
name: `${ config.chain.name } explorer`, name: `${ config.chain.name } explorer`,
description: `${ config.chain.name } explorer`, description: `${ config.chain.name } explorer`,
......
...@@ -3,10 +3,11 @@ import React from 'react'; ...@@ -3,10 +3,11 @@ import React from 'react';
import type { AddressParam } from 'types/api/addressParams'; import type { AddressParam } from 'types/api/addressParams';
import { route } from 'nextjs-routes'; import { route } from 'nextjs/routes';
import { toBech32Address } from 'lib/address/bech32'; import { toBech32Address } from 'lib/address/bech32';
import { useAddressHighlightContext } from 'lib/contexts/addressHighlight'; import { useAddressHighlightContext } from 'lib/contexts/addressHighlight';
import { useMultichainContext } from 'lib/contexts/multichain';
import { useSettingsContext } from 'lib/contexts/settings'; import { useSettingsContext } from 'lib/contexts/settings';
import { Skeleton } from 'toolkit/chakra/skeleton'; import { Skeleton } from 'toolkit/chakra/skeleton';
import { Tooltip } from 'toolkit/chakra/tooltip'; import { Tooltip } from 'toolkit/chakra/tooltip';
...@@ -24,7 +25,10 @@ const getDisplayedAddress = (address: AddressProp, altHash?: string) => { ...@@ -24,7 +25,10 @@ const getDisplayedAddress = (address: AddressProp, altHash?: string) => {
}; };
const Link = chakra((props: LinkProps) => { const Link = chakra((props: LinkProps) => {
const defaultHref = route({ pathname: '/address/[hash]', query: { ...props.query, hash: props.address.hash } }); const defaultHref = route(
{ pathname: '/address/[hash]', query: { ...props.query, hash: props.address.hash } },
props.chain ? { chain: props.chain } : undefined,
);
return ( return (
<EntityBase.Link <EntityBase.Link
...@@ -43,7 +47,10 @@ const Icon = (props: IconProps) => { ...@@ -43,7 +47,10 @@ const Icon = (props: IconProps) => {
return null; return null;
} }
const marginRight = props.marginRight ?? (props.shield ? '18px' : '8px'); const shield = props.shield ?? (props.chain ? { src: props.chain.config.UI.navigation.icon.default } : undefined);
const hintPostfix: string = props.hintPostfix ?? (props.chain ? ` on ${ props.chain.config.chain.name } (Chain ID: ${ props.chain.config.chain.id })` : '');
const marginRight = props.marginRight ?? (shield ? '18px' : '8px');
const styles = { const styles = {
...getIconProps(props.variant), ...getIconProps(props.variant),
marginRight, marginRight,
...@@ -60,6 +67,7 @@ const Icon = (props: IconProps) => { ...@@ -60,6 +67,7 @@ const Icon = (props: IconProps) => {
return ( return (
<EntityBase.Icon <EntityBase.Icon
{ ...props } { ...props }
shield={ shield }
name="brands/safe" name="brands/safe"
/> />
); );
...@@ -68,11 +76,12 @@ const Icon = (props: IconProps) => { ...@@ -68,11 +76,12 @@ const Icon = (props: IconProps) => {
const isProxy = Boolean(props.address.implementations?.length); const isProxy = Boolean(props.address.implementations?.length);
const isVerified = isProxy ? props.address.is_verified && props.address.implementations?.every(({ name }) => Boolean(name)) : props.address.is_verified; const isVerified = isProxy ? props.address.is_verified && props.address.implementations?.every(({ name }) => Boolean(name)) : props.address.is_verified;
const contractIconName: EntityBase.IconBaseProps['name'] = props.address.is_verified ? 'contracts/verified' : 'contracts/regular'; const contractIconName: EntityBase.IconBaseProps['name'] = props.address.is_verified ? 'contracts/verified' : 'contracts/regular';
const label = (isVerified ? 'verified ' : '') + (isProxy ? 'proxy contract' : 'contract') + (props.hintPostfix ?? ''); const label = (isVerified ? 'verified ' : '') + (isProxy ? 'proxy contract' : 'contract') + hintPostfix;
return ( return (
<EntityBase.Icon <EntityBase.Icon
{ ...props } { ...props }
shield={ shield }
name={ isProxy ? 'contracts/proxy' : contractIconName } name={ isProxy ? 'contracts/proxy' : contractIconName }
color={ isVerified ? 'green.500' : undefined } color={ isVerified ? 'green.500' : undefined }
borderRadius={ 0 } borderRadius={ 0 }
...@@ -83,7 +92,11 @@ const Icon = (props: IconProps) => { ...@@ -83,7 +92,11 @@ const Icon = (props: IconProps) => {
const label = (() => { const label = (() => {
if (isDelegatedAddress) { if (isDelegatedAddress) {
return (props.address.is_verified ? 'EOA + verified code' : 'EOA + code') + (props.hintPostfix ?? ''); return (props.address.is_verified ? 'EOA + verified code' : 'EOA + code') + hintPostfix;
}
if (props.chain) {
return 'Address' + hintPostfix;
} }
return props.hint; return props.hint;
...@@ -94,14 +107,14 @@ const Icon = (props: IconProps) => { ...@@ -94,14 +107,14 @@ const Icon = (props: IconProps) => {
content={ label } content={ label }
disabled={ !label } disabled={ !label }
interactive={ props.tooltipInteractive } interactive={ props.tooltipInteractive }
positioning={ props.shield ? { offset: { mainAxis: 8 } } : undefined } positioning={ shield ? { offset: { mainAxis: 8 } } : undefined }
> >
<Flex marginRight={ styles.marginRight } position="relative"> <Flex marginRight={ styles.marginRight } position="relative">
<AddressIdenticon <AddressIdenticon
size={ props.variant === 'heading' ? 30 : 20 } size={ props.variant === 'heading' ? 30 : 20 }
hash={ getDisplayedAddress(props.address) } hash={ getDisplayedAddress(props.address) }
/> />
{ props.shield && <EntityBase.IconShield { ...props.shield }/> } { shield && <EntityBase.IconShield { ...shield }/> }
{ isDelegatedAddress && <AddressIconDelegated isVerified={ Boolean(props.address.is_verified) }/> } { isDelegatedAddress && <AddressIconDelegated isVerified={ Boolean(props.address.is_verified) }/> }
</Flex> </Flex>
</Tooltip> </Tooltip>
...@@ -183,7 +196,10 @@ const AddressEntity = (props: EntityProps) => { ...@@ -183,7 +196,10 @@ const AddressEntity = (props: EntityProps) => {
const partsProps = distributeEntityProps(props); const partsProps = distributeEntityProps(props);
const highlightContext = useAddressHighlightContext(props.noHighlight); const highlightContext = useAddressHighlightContext(props.noHighlight);
const settingsContext = useSettingsContext(); const settingsContext = useSettingsContext();
const multichainContext = useMultichainContext();
const altHash = !props.noAltHash && settingsContext?.addressFormat === 'bech32' ? toBech32Address(props.address.hash) : undefined; const altHash = !props.noAltHash && settingsContext?.addressFormat === 'bech32' ? toBech32Address(props.address.hash) : undefined;
const chain = props.chain ?? multichainContext?.chain;
// inside highlight context all tooltips should be interactive // inside highlight context all tooltips should be interactive
// because non-interactive ones will not pass 'onMouseLeave' event to the parent component // because non-interactive ones will not pass 'onMouseLeave' event to the parent component
...@@ -201,8 +217,8 @@ const AddressEntity = (props: EntityProps) => { ...@@ -201,8 +217,8 @@ const AddressEntity = (props: EntityProps) => {
position="relative" position="relative"
zIndex={ 0 } zIndex={ 0 }
> >
<Icon { ...partsProps.icon } tooltipInteractive={ Boolean(highlightContext) }/> <Icon { ...partsProps.icon } tooltipInteractive={ Boolean(highlightContext) } chain={ chain }/>
{ props.noLink ? content : <Link { ...partsProps.link }>{ content }</Link> } { props.noLink ? content : <Link { ...partsProps.link } chain={ chain }>{ content }</Link> }
<Copy { ...partsProps.copy } altHash={ altHash } tooltipInteractive={ Boolean(highlightContext) }/> <Copy { ...partsProps.copy } altHash={ altHash } tooltipInteractive={ Boolean(highlightContext) }/>
</Container> </Container>
); );
......
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment