diff --git a/.github/workflows/checks.yml b/.github/workflows/checks.yml index 73da65b6c9..9ae52bfe39 100644 --- a/.github/workflows/checks.yml +++ b/.github/workflows/checks.yml @@ -45,6 +45,10 @@ jobs: if: steps.cache-node-modules.outputs.cache-hit != 'true' run: yarn --frozen-lockfile + - name: Generate Chakra types + if: steps.cache-node-modules.outputs.cache-hit == 'true' + run: yarn chakra:typegen + - name: Run ESLint run: echo "skip lint:eslint for now" @@ -77,6 +81,10 @@ jobs: if: steps.cache-node-modules.outputs.cache-hit != 'true' run: yarn --frozen-lockfile + - name: Generate Chakra types + if: steps.cache-node-modules.outputs.cache-hit == 'true' + run: yarn chakra:typegen + - name: Install package dependencies run: | cd ./toolkit/package @@ -136,6 +144,10 @@ jobs: if: steps.cache-node-modules.outputs.cache-hit != 'true' run: yarn --frozen-lockfile + - name: Generate Chakra types + if: steps.cache-node-modules.outputs.cache-hit == 'true' + run: yarn chakra:typegen + - name: Install script dependencies run: cd ./deploy/tools/envs-validator && yarn --frozen-lockfile @@ -175,6 +187,10 @@ jobs: if: steps.cache-node-modules.outputs.cache-hit != 'true' run: yarn --frozen-lockfile + - name: Generate Chakra types + if: steps.cache-node-modules.outputs.cache-hit == 'true' + run: yarn chakra:typegen + - name: Run Jest run: yarn test:jest ${{ github.event_name == 'pull_request' && '--changedSince=origin/main' || '' }} --passWithNoTests @@ -207,6 +223,10 @@ jobs: if: steps.cache-node-modules.outputs.cache-hit != 'true' run: yarn --frozen-lockfile + - name: Generate Chakra types + if: steps.cache-node-modules.outputs.cache-hit == 'true' + run: yarn chakra:typegen + - name: Install script dependencies run: cd ./deploy/tools/affected-tests && yarn --frozen-lockfile @@ -264,6 +284,10 @@ jobs: if: steps.cache-node-modules.outputs.cache-hit != 'true' run: yarn --frozen-lockfile + - name: Generate Chakra types + if: steps.cache-node-modules.outputs.cache-hit == 'true' + run: yarn chakra:typegen + - name: Download affected tests list if: ${{ needs.pw_affected_tests.result == 'success' }} uses: actions/download-artifact@v4 diff --git a/.github/workflows/deploy-review-l2.yml b/.github/workflows/deploy-review-l2.yml index 3482219b76..2a5716b95a 100644 --- a/.github/workflows/deploy-review-l2.yml +++ b/.github/workflows/deploy-review-l2.yml @@ -25,6 +25,7 @@ on: - neon_devnet - optimism - optimism_sepolia + - optimism_superchain - polygon - rootstock - scroll_sepolia diff --git a/.github/workflows/deploy-review.yml b/.github/workflows/deploy-review.yml index 86e21cfbaa..e5777fbf62 100644 --- a/.github/workflows/deploy-review.yml +++ b/.github/workflows/deploy-review.yml @@ -27,12 +27,14 @@ on: - neon_devnet - optimism - optimism_sepolia + - optimism_superchain - polygon - rari_testnet - rootstock - shibarium - scroll_sepolia - stability + - tac - tac_turin - zkevm - zilliqa_prototestnet diff --git a/.github/workflows/upload-source-maps.yml b/.github/workflows/upload-source-maps.yml index 6c0ec2fa04..a97a1fc21d 100644 --- a/.github/workflows/upload-source-maps.yml +++ b/.github/workflows/upload-source-maps.yml @@ -37,6 +37,10 @@ jobs: if: steps.cache-node-modules.outputs.cache-hit != 'true' run: yarn --frozen-lockfile + - name: Generate Chakra types + if: steps.cache-node-modules.outputs.cache-hit == 'true' + run: yarn chakra:typegen + - name: Make production build with source maps run: yarn build env: diff --git a/.gitignore b/.gitignore index 3742f9ed45..6978d3dfeb 100644 --- a/.gitignore +++ b/.gitignore @@ -14,6 +14,7 @@ /out/ /public/assets/envs.js /public/assets/configs +/public/assets/multichain /public/icons/sprite.svg /public/icons/sprite.*.svg /public/icons/registry.json diff --git a/.vscode/settings.json b/.vscode/settings.json index ee3f389ee4..4190103cf2 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -1,11 +1,12 @@ { - "typescript.tsdk": "node_modules/typescript/lib", - "javascript.preferences.autoImportFileExcludePatterns": [ - "./toolkit/package/**", - "./toolkit/components/**/index.ts", - ], - "typescript.preferences.autoImportFileExcludePatterns": [ - "./toolkit/package/**", - "./toolkit/components/**/index.ts", - ] -} \ No newline at end of file + "typescript.tsdk": "node_modules/typescript/lib", + "javascript.preferences.autoImportFileExcludePatterns": [ + "./toolkit/package/**", + "./toolkit/components/**/index.ts" + ], + "typescript.preferences.autoImportFileExcludePatterns": [ + "./toolkit/package/**", + "./toolkit/components/**/index.ts" + ], + "editor.formatOnSave": false +} diff --git a/.vscode/tasks.json b/.vscode/tasks.json index e64d1e63c5..e57b5044f2 100644 --- a/.vscode/tasks.json +++ b/.vscode/tasks.json @@ -377,12 +377,14 @@ "optimism", "optimism_interop_0", "optimism_sepolia", + "optimism_superchain", "polygon", "rari_testnet", "rootstock_testnet", "scroll_sepolia", "shibarium", "stability_testnet", + "tac", "tac_turin", "zkevm", "zilliqa_prototestnet", diff --git a/Dockerfile b/Dockerfile index 36ca785c84..d8f9a3236a 100644 --- a/Dockerfile +++ b/Dockerfile @@ -45,6 +45,12 @@ WORKDIR /sitemap-generator COPY ./deploy/tools/sitemap-generator/package.json ./deploy/tools/sitemap-generator/yarn.lock ./ 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 ******* @@ -106,6 +112,11 @@ COPY --from=deps /favicon-generator/node_modules ./deploy/tools/favicon-generato # Copy dependencies and source code 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 ******** @@ -130,8 +141,11 @@ RUN chown nextjs:nodejs .next COPY --from=builder /app/next.config.js ./ COPY --from=builder /app/public ./public 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/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 ## Entripoint diff --git a/configs/app/apis.ts b/configs/app/apis.ts index d62917aea4..60144e5a81 100644 --- a/configs/app/apis.ts +++ b/configs/app/apis.ts @@ -7,6 +7,7 @@ import { getEnvValue } from './utils'; export interface ApiPropsBase { endpoint: string; basePath?: string; + socketEndpoint?: string; } export interface ApiPropsFull extends ApiPropsBase { @@ -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 apiHost = getEnvValue('NEXT_PUBLIC_STATS_API_HOST'); if (!apiHost) { @@ -123,6 +143,17 @@ const tacApi = (() => { }); })(); +const userOpsApi = (() => { + const apiHost = getEnvValue('NEXT_PUBLIC_USER_OPS_INDEXER_API_HOST'); + if (!apiHost) { + return; + } + + return Object.freeze({ + endpoint: apiHost, + }); +})(); + const visualizeApi = (() => { const apiHost = getEnvValue('NEXT_PUBLIC_VISUALIZE_API_HOST'); if (!apiHost) { @@ -135,7 +166,7 @@ const visualizeApi = (() => { }); })(); -type Apis = { +export type Apis = { general: ApiPropsFull; } & Partial, ApiPropsBase>>; @@ -145,9 +176,11 @@ const apis: Apis = Object.freeze({ bens: bensApi, contractInfo: contractInfoApi, metadata: metadataApi, + multichain: multichainApi, rewards: rewardsApi, stats: statsApi, tac: tacApi, + userOps: userOpsApi, visualize: visualizeApi, }); diff --git a/configs/app/features/address3rdPartyWidgets.ts b/configs/app/features/address3rdPartyWidgets.ts new file mode 100644 index 0000000000..8b84fc09e8 --- /dev/null +++ b/configs/app/features/address3rdPartyWidgets.ts @@ -0,0 +1,27 @@ +import type { Feature } from './types'; + +import { getEnvValue, getExternalAssetFilePath, parseEnvJson } from '../utils'; + +// config file will be downloaded at run-time and saved in the public folder +const widgets = parseEnvJson>(getEnvValue('NEXT_PUBLIC_ADDRESS_3RD_PARTY_WIDGETS')); +const configUrl = getExternalAssetFilePath('NEXT_PUBLIC_ADDRESS_3RD_PARTY_WIDGETS_CONFIG_URL'); + +const title = 'Address 3rd party widgets'; + +const config: Feature<{ widgets: Array; configUrl: string }> = (() => { + if (widgets && widgets.length > 0 && configUrl) { + return Object.freeze({ + title, + isEnabled: true, + widgets, + configUrl, + }); + } + + return Object.freeze({ + title, + isEnabled: false, + }); +})(); + +export default config; diff --git a/configs/app/features/addressMetadata.ts b/configs/app/features/addressMetadata.ts index 3854320373..11f0d8fc4e 100644 --- a/configs/app/features/addressMetadata.ts +++ b/configs/app/features/addressMetadata.ts @@ -1,14 +1,16 @@ import type { Feature } from './types'; import apis from '../apis'; +import { getEnvValue } from '../utils'; const title = 'Address metadata'; -const config: Feature<{}> = (() => { +const config: Feature<{ isAddressTagsUpdateEnabled: boolean }> = (() => { if (apis.metadata) { return Object.freeze({ title, isEnabled: true, + isAddressTagsUpdateEnabled: getEnvValue('NEXT_PUBLIC_METADATA_ADDRESS_TAGS_UPDATE_ENABLED') !== 'false', }); } diff --git a/configs/app/features/apiDocs.ts b/configs/app/features/apiDocs.ts new file mode 100644 index 0000000000..0f59de9f52 --- /dev/null +++ b/configs/app/features/apiDocs.ts @@ -0,0 +1,40 @@ +import type { Feature } from './types'; +import type { ApiDocsTabId } from 'types/views/apiDocs'; +import { API_DOCS_TABS } from 'types/views/apiDocs'; + +import { getEnvValue, parseEnvJson } from '../utils'; + +const graphqlDefaultTxnHash = getEnvValue('NEXT_PUBLIC_GRAPHIQL_TRANSACTION'); + +const tabs = (() => { + const value = (parseEnvJson>(getEnvValue('NEXT_PUBLIC_API_DOCS_TABS')) || API_DOCS_TABS) + .filter((tab) => API_DOCS_TABS.includes(tab)) + .filter((tab) => !graphqlDefaultTxnHash && tab === 'graphql_api' ? false : true); + + return value.length > 0 ? value : undefined; +})(); + +const title = 'API documentation'; + +const config: Feature<{ + tabs: Array; + coreApiSwaggerUrl: string; + graphqlDefaultTxnHash?: string; +}> = (() => { + if (tabs) { + return Object.freeze({ + title, + isEnabled: true, + tabs, + coreApiSwaggerUrl: getEnvValue('NEXT_PUBLIC_API_SPEC_URL') || `https://raw.githubusercontent.com/blockscout/blockscout-api-v2-swagger/main/swagger.yaml`, + graphqlDefaultTxnHash, + }); + } + + return Object.freeze({ + title, + isEnabled: false, + }); +})(); + +export default config; diff --git a/configs/app/features/blockchainInteraction.ts b/configs/app/features/blockchainInteraction.ts index 6700126089..ce8709f6b6 100644 --- a/configs/app/features/blockchainInteraction.ts +++ b/configs/app/features/blockchainInteraction.ts @@ -2,6 +2,7 @@ import type { Feature } from './types'; import chain from '../chain'; import { getEnvValue } from '../utils'; +import opSuperchain from './opSuperchain'; const walletConnectProjectId = getEnvValue('NEXT_PUBLIC_WALLET_CONNECT_PROJECT_ID'); @@ -9,15 +10,21 @@ const title = 'Blockchain interaction (writing to contract, etc.)'; const config: Feature<{ walletConnect: { projectId: string } }> = (() => { - if ( - // all chain parameters are required for wagmi provider - // @wagmi/chains/dist/index.d.ts + // all chain parameters are required for wagmi provider + // @wagmi/chains/dist/index.d.ts + const isSingleChain = Boolean( chain.id && chain.name && chain.currency.name && chain.currency.symbol && chain.currency.decimals && - chain.rpcUrls.length > 0 && + chain.rpcUrls.length > 0, + ); + + const isOpSuperchain = opSuperchain.isEnabled; + + if ( + (isSingleChain || isOpSuperchain) && walletConnectProjectId ) { return Object.freeze({ diff --git a/configs/app/features/celo.ts b/configs/app/features/celo.ts index 9d169e4220..37af6f144b 100644 --- a/configs/app/features/celo.ts +++ b/configs/app/features/celo.ts @@ -4,14 +4,12 @@ import { getEnvValue } from '../utils'; const title = 'Celo chain'; -const config: Feature<{ L2UpgradeBlock: number | undefined; BLOCKS_PER_EPOCH: number }> = (() => { +const config: Feature<{ }> = (() => { if (getEnvValue('NEXT_PUBLIC_CELO_ENABLED') === 'true') { return Object.freeze({ title, isEnabled: true, - L2UpgradeBlock: getEnvValue('NEXT_PUBLIC_CELO_L2_UPGRADE_BLOCK') ? Number(getEnvValue('NEXT_PUBLIC_CELO_L2_UPGRADE_BLOCK')) : undefined, - BLOCKS_PER_EPOCH: 17_280, }); } diff --git a/configs/app/features/graphqlApiDocs.ts b/configs/app/features/graphqlApiDocs.ts deleted file mode 100644 index d26c3bfde3..0000000000 --- a/configs/app/features/graphqlApiDocs.ts +++ /dev/null @@ -1,25 +0,0 @@ -import type { Feature } from './types'; - -import { getEnvValue } from '../utils'; - -const defaultTxHash = getEnvValue('NEXT_PUBLIC_GRAPHIQL_TRANSACTION'); - -const title = 'GraphQL API documentation'; - -const config: Feature<{ defaultTxHash: string | undefined }> = (() => { - - if (defaultTxHash === 'none') { - return Object.freeze({ - title, - isEnabled: false, - }); - } - - return Object.freeze({ - title, - isEnabled: true, - defaultTxHash, - }); -})(); - -export default config; diff --git a/configs/app/features/index.ts b/configs/app/features/index.ts index 9613e60c00..9f3d936821 100644 --- a/configs/app/features/index.ts +++ b/configs/app/features/index.ts @@ -2,8 +2,10 @@ export { default as advancedFilter } from './advancedFilter'; export { default as account } from './account'; export { default as addressVerification } from './addressVerification'; export { default as addressMetadata } from './addressMetadata'; +export { default as address3rdPartyWidgets } from './address3rdPartyWidgets'; export { default as adsBanner } from './adsBanner'; export { default as adsText } from './adsText'; +export { default as apiDocs } from './apiDocs'; export { default as beaconChain } from './beaconChain'; export { default as bridgedTokens } from './bridgedTokens'; export { default as blockchainInteraction } from './blockchainInteraction'; @@ -18,7 +20,6 @@ export { default as faultProofSystem } from './faultProofSystem'; export { default as gasTracker } from './gasTracker'; export { default as getGasButton } from './getGasButton'; export { default as googleAnalytics } from './googleAnalytics'; -export { default as graphqlApiDocs } from './graphqlApiDocs'; export { default as growthBook } from './growthBook'; export { default as marketplace } from './marketplace'; export { default as metasuites } from './metasuites'; @@ -26,9 +27,9 @@ export { default as mixpanel } from './mixpanel'; export { default as mudFramework } from './mudFramework'; export { default as multichainButton } from './multichainButton'; export { default as nameService } from './nameService'; +export { default as opSuperchain } from './opSuperchain'; export { default as pools } from './pools'; export { default as publicTagsSubmission } from './publicTagsSubmission'; -export { default as restApiDocs } from './restApiDocs'; export { default as rewards } from './rewards'; export { default as rollbar } from './rollbar'; export { default as rollup } from './rollup'; diff --git a/configs/app/features/opSuperchain.ts b/configs/app/features/opSuperchain.ts new file mode 100644 index 0000000000..f46e64d19c --- /dev/null +++ b/configs/app/features/opSuperchain.ts @@ -0,0 +1,24 @@ +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; diff --git a/configs/app/features/restApiDocs.ts b/configs/app/features/restApiDocs.ts deleted file mode 100644 index ae25f05c0a..0000000000 --- a/configs/app/features/restApiDocs.ts +++ /dev/null @@ -1,25 +0,0 @@ -import type { Feature } from './types'; - -import { getEnvValue } from '../utils'; - -const DEFAULT_URL = `https://raw.githubusercontent.com/blockscout/blockscout-api-v2-swagger/main/swagger.yaml`; -const envValue = getEnvValue('NEXT_PUBLIC_API_SPEC_URL'); - -const title = 'REST API documentation'; - -const config: Feature<{ specUrl: string }> = (() => { - if (envValue === 'none') { - return Object.freeze({ - title, - isEnabled: false, - }); - } - - return Object.freeze({ - title, - isEnabled: true, - specUrl: envValue || DEFAULT_URL, - }); -})(); - -export default config; diff --git a/configs/app/ui.ts b/configs/app/ui.ts index 01889cce7c..e01e5534a3 100644 --- a/configs/app/ui.ts +++ b/configs/app/ui.ts @@ -1,5 +1,5 @@ import type { ContractCodeIde } from 'types/client/contract'; -import { NAVIGATION_LINK_IDS, type NavItemExternal, type NavigationLinkId, type NavigationLayout } from 'types/client/navigation'; +import { type NavItemExternal, type NavigationLayout } from 'types/client/navigation'; import { HOME_STATS_WIDGET_IDS, type ChainIndicatorId, type HeroBannerConfig, type HomeStatsWidgetId } from 'types/homepage'; import type { NetworkExplorer } from 'types/networks'; import type { ColorThemeId } from 'types/settings'; @@ -11,21 +11,6 @@ import * as features from './features'; import * as views from './ui/views'; import { getEnvValue, getExternalAssetFilePath, parseEnvJson } from './utils'; -const hiddenLinks = (() => { - const parsedValue = parseEnvJson>(getEnvValue('NEXT_PUBLIC_NAVIGATION_HIDDEN_LINKS')) || []; - - if (!Array.isArray(parsedValue)) { - return undefined; - } - - const result = NAVIGATION_LINK_IDS.reduce((result, item) => { - result[item] = parsedValue.includes(item); - return result; - }, {} as Record); - - return result; -})(); - const homePageStats: Array = (() => { const parsedValue = parseEnvJson>(getEnvValue('NEXT_PUBLIC_HOMEPAGE_STATS')); @@ -43,7 +28,7 @@ const homePageStats: Array = (() => { })(); const highlightedRoutes = (() => { - const parsedValue = parseEnvJson>(getEnvValue('NEXT_PUBLIC_NAVIGATION_HIGHLIGHTED_ROUTES')); + const parsedValue = parseEnvJson>(getEnvValue('NEXT_PUBLIC_NAVIGATION_HIGHLIGHTED_ROUTES')); return Array.isArray(parsedValue) ? parsedValue : []; })(); @@ -62,7 +47,6 @@ const UI = Object.freeze({ 'default': getExternalAssetFilePath('NEXT_PUBLIC_NETWORK_ICON'), dark: getExternalAssetFilePath('NEXT_PUBLIC_NETWORK_ICON_DARK'), }, - hiddenLinks, highlightedRoutes, otherLinks: parseEnvJson>(getEnvValue('NEXT_PUBLIC_OTHER_LINKS')) || [], featuredNetworks: getExternalAssetFilePath('NEXT_PUBLIC_FEATURED_NETWORKS') ?? '/assets/configs/featured_networks.json', diff --git a/configs/envs/.env.celo_alfajores b/configs/envs/.env.celo_alfajores index 42aea78fc0..74ceed4672 100644 --- a/configs/envs/.env.celo_alfajores +++ b/configs/envs/.env.celo_alfajores @@ -15,7 +15,6 @@ NEXT_PUBLIC_API_BASE_PATH=/ NEXT_PUBLIC_API_HOST=celo-alfajores.blockscout.com NEXT_PUBLIC_API_SPEC_URL=https://raw.githubusercontent.com/blockscout/blockscout-api-v2-swagger/main/swagger.yaml NEXT_PUBLIC_CELO_ENABLED=true -NEXT_PUBLIC_CELO_L2_UPGRADE_BLOCK=26369280 NEXT_PUBLIC_CONTRACT_CODE_IDES=[{'title':'Remix IDE','url':'https://remix.ethereum.org/?address={hash}&blockscout={domain}','icon_url':'https://raw.githubusercontent.com/blockscout/frontend-configs/main/configs/ide-icons/remix.png'}] NEXT_PUBLIC_CONTRACT_INFO_API_HOST=https://contracts-info.services.blockscout.com NEXT_PUBLIC_FEATURED_NETWORKS=https://raw.githubusercontent.com/blockscout/frontend-configs/main/configs/featured-networks/celo.json @@ -24,6 +23,7 @@ NEXT_PUBLIC_GAS_TRACKER_ENABLED=false NEXT_PUBLIC_GRAPHIQL_TRANSACTION=0x9767ce30754afad2a3279b9df2d13257f467c3dad4e0e601271e66d16dfd1641 NEXT_PUBLIC_HAS_USER_OPS=true NEXT_PUBLIC_HOMEPAGE_CHARTS=['daily_txs'] +NEXT_PUBLIC_HOMEPAGE_STATS=['total_blocks','average_block_time','total_txs','wallet_addresses','current_epoch'] NEXT_PUBLIC_HOMEPAGE_HERO_BANNER_CONFIG={'background':['rgba(252, 255, 82, 1)'],'text_color':['rgba(0, 0, 0, 1)']} NEXT_PUBLIC_IS_TESTNET=true NEXT_PUBLIC_MARKETPLACE_ENABLED=false diff --git a/configs/envs/.env.eth b/configs/envs/.env.eth index 7b4ee6e017..683e5296d6 100644 --- a/configs/envs/.env.eth +++ b/configs/envs/.env.eth @@ -9,6 +9,8 @@ NEXT_PUBLIC_APP_PORT=3000 NEXT_PUBLIC_APP_ENV=development NEXT_PUBLIC_API_WEBSOCKET_PROTOCOL=ws +NEXT_PUBLIC_USER_OPS_INDEXER_API_HOST=https://user-ops-indexer-base-mainnet.k8s-prod-2.blockscout.com + # Instance ENVs NEXT_PUBLIC_ADMIN_SERVICE_API_HOST=https://admin-rs.services.blockscout.com NEXT_PUBLIC_API_BASE_PATH=/ @@ -68,4 +70,6 @@ NEXT_PUBLIC_VIEWS_CONTRACT_SOLIDITYSCAN_ENABLED=true NEXT_PUBLIC_VIEWS_NFT_MARKETPLACES=[{'name':'OpenSea','collection_url':'https://opensea.io/assets/ethereum/{hash}','instance_url':'https://opensea.io/assets/ethereum/{hash}/{id}','logo_url':'https://opensea.io/static/images/logos/opensea-logo.svg'},{'name':'Rarible','collection_url':'https://rarible.com/collection/{hash}/items','instance_url':'https://rarible.com/token/{hash}:{id}','logo_url':'https://raw.githubusercontent.com/blockscout/frontend-configs/main/configs/nft-marketplace-logos/rarible.png'},{'name':'Blur','collection_url':'https://blur.io/eth/collection/{hash}','instance_url':'https://blur.io/eth/asset/{hash}/{id}','logo_url':'https://raw.githubusercontent.com/blockscout/frontend-configs/main/configs/nft-marketplace-logos/blur.png'},{'name':'MagicEden','collection_url':'https://magiceden.io/collections/ethereum/{hash}','instance_url':'https://magiceden.io/item-details/ethereum/{hash}/{id}','logo_url':'https://raw.githubusercontent.com/blockscout/frontend-configs/main/configs/nft-marketplace-logos/magiceden.png'}] NEXT_PUBLIC_VIEWS_TOKEN_SCAM_TOGGLE_ENABLED=true NEXT_PUBLIC_VISUALIZE_API_HOST=https://visualizer.services.blockscout.com -NEXT_PUBLIC_XSTAR_SCORE_URL=https://docs.xname.app/the-solution-adaptive-proof-of-humanity-on-blockchain/xhs-scoring-algorithm?utm_source=blockscout&utm_medium=address \ No newline at end of file +NEXT_PUBLIC_XSTAR_SCORE_URL=https://docs.xname.app/the-solution-adaptive-proof-of-humanity-on-blockchain/xhs-scoring-algorithm?utm_source=blockscout&utm_medium=address +NEXT_PUBLIC_ADDRESS_3RD_PARTY_WIDGETS=['talentprotocol', 'efp', 'webacy', 'deepdao', 'humanpassport', 'trustblock', 'bankless'] +NEXT_PUBLIC_ADDRESS_3RD_PARTY_WIDGETS_CONFIG_URL=https://raw.githubusercontent.com/blockscout/frontend-configs/main/configs/widgets/config.json diff --git a/configs/envs/.env.eth_sepolia b/configs/envs/.env.eth_sepolia index 1fa16df765..8e55fec296 100644 --- a/configs/envs/.env.eth_sepolia +++ b/configs/envs/.env.eth_sepolia @@ -9,6 +9,8 @@ NEXT_PUBLIC_APP_PORT=3000 NEXT_PUBLIC_APP_ENV=development NEXT_PUBLIC_API_WEBSOCKET_PROTOCOL=ws +NEXT_PUBLIC_HAS_CONTRACT_AUDIT_REPORTS=true + # Instance ENVs NEXT_PUBLIC_AD_ADBUTLER_CONFIG_DESKTOP={ "id": "632019", "width": "728", "height": "90" } NEXT_PUBLIC_AD_ADBUTLER_CONFIG_MOBILE={ "id": "632018", "width": "320", "height": "100" } diff --git a/configs/envs/.env.optimism_superchain b/configs/envs/.env.optimism_superchain new file mode 100644 index 0000000000..f14e0e81a6 --- /dev/null +++ b/configs/envs/.env.optimism_superchain @@ -0,0 +1,46 @@ +# 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 diff --git a/configs/envs/.env.pw b/configs/envs/.env.pw index 65457a3352..5b996815f3 100644 --- a/configs/envs/.env.pw +++ b/configs/envs/.env.pw @@ -54,6 +54,7 @@ NEXT_PUBLIC_METADATA_SERVICE_API_HOST=http://localhost:3007 NEXT_PUBLIC_NAME_SERVICE_API_HOST=http://localhost:3008 NEXT_PUBLIC_REWARDS_SERVICE_API_HOST=http://localhost:3009 NEXT_PUBLIC_TAC_OPERATION_LIFECYCLE_API_HOST=http://localhost:3100 +NEXT_PUBLIC_USER_OPS_INDEXER_API_HOST=http://localhost:3110 NEXT_PUBLIC_RE_CAPTCHA_APP_SITE_KEY=xxx NEXT_PUBLIC_WALLET_CONNECT_PROJECT_ID=xxx NEXT_PUBLIC_VIEWS_ADDRESS_FORMAT=['base16','bech32'] diff --git a/configs/envs/.env.rari_testnet b/configs/envs/.env.rari_testnet index 8d64dd5e52..cb360d121e 100644 --- a/configs/envs/.env.rari_testnet +++ b/configs/envs/.env.rari_testnet @@ -22,7 +22,6 @@ NEXT_PUBLIC_HOMEPAGE_CHARTS=['daily_txs'] NEXT_PUBLIC_HOMEPAGE_PLATE_BACKGROUND=radial-gradient(farthest-corner at 0% 0%, rgba(183, 148, 244, 0.80) 0%, rgba(0, 163, 196, 0.80) 100%) NEXT_PUBLIC_HOMEPAGE_PLATE_TEXT_COLOR=rgb(255,255,255) NEXT_PUBLIC_IS_TESTNET=true -NEXT_PUBLIC_NAVIGATION_HIDDEN_LINKS=[] NEXT_PUBLIC_NAVIGATION_LAYOUT=vertical NEXT_PUBLIC_NETWORK_CURRENCY_DECIMALS=18 NEXT_PUBLIC_NETWORK_CURRENCY_NAME=ETH diff --git a/configs/envs/.env.tac b/configs/envs/.env.tac new file mode 100644 index 0000000000..89fcc374f2 --- /dev/null +++ b/configs/envs/.env.tac @@ -0,0 +1,45 @@ +# Set of ENVs for TAC network explorer +# https://explorer.tac.build +# This is an auto-generated file. To update all values, run "yarn dev:preset:sync --name=tac" + +# Local ENVs +NEXT_PUBLIC_APP_PROTOCOL=http +NEXT_PUBLIC_APP_HOST=localhost +NEXT_PUBLIC_APP_PORT=3000 +NEXT_PUBLIC_APP_ENV=development +NEXT_PUBLIC_API_WEBSOCKET_PROTOCOL=ws + +# Instance ENVs +NEXT_PUBLIC_ADMIN_SERVICE_API_HOST=https://admin-rs.services.blockscout.com +NEXT_PUBLIC_API_BASE_PATH=/ +NEXT_PUBLIC_API_HOST=explorer.tac.build +NEXT_PUBLIC_API_SPEC_URL=https://raw.githubusercontent.com/blockscout/blockscout-api-v2-swagger/main/swagger.yaml +NEXT_PUBLIC_CONTRACT_INFO_API_HOST=https://contracts-info.services.blockscout.com +NEXT_PUBLIC_FOOTER_LINKS=https://raw.githubusercontent.com/blockscout/frontend-configs/main/configs/footer-links/tac.json +NEXT_PUBLIC_GAME_BADGE_CLAIM_LINK=https://badges.blockscout.com/mint/sherblockHolmesBadge +NEXT_PUBLIC_GRAPHIQL_TRANSACTION=0x9e2386a15e263fffc7b481ae1a6b8d71aa94b87c7683b3f2d756bb912622d638 +NEXT_PUBLIC_HOMEPAGE_CHARTS=['daily_txs'] +NEXT_PUBLIC_HOMEPAGE_HERO_BANNER_CONFIG={'background':['no-repeat center/100% auto url(https://raw.githubusercontent.com/blockscout/frontend-configs/main/configs/network-skins/tac.jpg)'],'text_color':['rgba(242,235,255,1)'],'button':{'_default':{'background':['rgba(30,23,44,1)']},'_hover':{'background':['rgba(66,14,70,1)']}}} +NEXT_PUBLIC_IS_ACCOUNT_SUPPORTED=true +NEXT_PUBLIC_METADATA_SERVICE_API_HOST=https://metadata.services.blockscout.com +NEXT_PUBLIC_NAME_SERVICE_API_HOST=https://bens.services.blockscout.com +NEXT_PUBLIC_NETWORK_CURRENCY_DECIMALS=18 +NEXT_PUBLIC_NETWORK_CURRENCY_NAME=TAC +NEXT_PUBLIC_NETWORK_CURRENCY_SYMBOL=TAC +NEXT_PUBLIC_NETWORK_ICON=https://raw.githubusercontent.com/blockscout/frontend-configs/main/configs/network-icons/tac-light.svg +NEXT_PUBLIC_NETWORK_ICON_DARK=https://raw.githubusercontent.com/blockscout/frontend-configs/main/configs/network-icons/tac-dark.svg +NEXT_PUBLIC_NETWORK_ID=239 +NEXT_PUBLIC_NETWORK_LOGO=https://raw.githubusercontent.com/blockscout/frontend-configs/main/configs/network-logos/tac-light.svg +NEXT_PUBLIC_NETWORK_LOGO_DARK=https://raw.githubusercontent.com/blockscout/frontend-configs/main/configs/network-logos/tac-dark.svg +NEXT_PUBLIC_NETWORK_NAME=TAC +NEXT_PUBLIC_NETWORK_RPC_URL=https://rpc.ankr.com/tac +NEXT_PUBLIC_NETWORK_SHORT_NAME=TAC +NEXT_PUBLIC_OG_ENHANCED_DATA_ENABLED=true +NEXT_PUBLIC_OG_IMAGE_URL=https://raw.githubusercontent.com/blockscout/frontend-configs/main/configs/og-images/tac-turin.png +NEXT_PUBLIC_STATS_API_BASE_PATH=/stats-service +NEXT_PUBLIC_STATS_API_HOST=https://explorer.tac.build +NEXT_PUBLIC_TAC_OPERATION_LIFECYCLE_API_HOST=https://tac-operation-lifecycle.k8s.blockscout.com +NEXT_PUBLIC_TAC_TON_EXPLORER_URL=https://tonviewer.com +NEXT_PUBLIC_TRANSACTION_INTERPRETATION_PROVIDER=blockscout +NEXT_PUBLIC_VIEWS_TOKEN_SCAM_TOGGLE_ENABLED=true +NEXT_PUBLIC_VISUALIZE_API_HOST=https://visualizer.services.blockscout.com \ No newline at end of file diff --git a/configs/multichain/config.edge.ts b/configs/multichain/config.edge.ts new file mode 100644 index 0000000000..dbc97055a2 --- /dev/null +++ b/configs/multichain/config.edge.ts @@ -0,0 +1,48 @@ +/* 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((resolve, reject) => { + fetchConfig() + .then((value) => { + resolve(value); + }).catch((error) => { + reject(error); + }); + }); + } + + return Promise.resolve(value); +} + +export function getValue() { + return value; +} diff --git a/configs/multichain/config.nodejs.ts b/configs/multichain/config.nodejs.ts new file mode 100644 index 0000000000..cc0953d348 --- /dev/null +++ b/configs/multichain/config.nodejs.ts @@ -0,0 +1,43 @@ +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((resolve) => { + const value = readFileConfig(); + resolve(value); + }); + } + + return Promise.resolve(value); +} + +export function getValue() { + if (!value) { + return readFileConfig(); + } + + return value; +} diff --git a/configs/multichain/index.ts b/configs/multichain/index.ts new file mode 100644 index 0000000000..81066dd411 --- /dev/null +++ b/configs/multichain/index.ts @@ -0,0 +1,19 @@ +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; diff --git a/deploy/scripts/download_assets.sh b/deploy/scripts/download_assets.sh index 811f56d690..66ef15120d 100755 --- a/deploy/scripts/download_assets.sh +++ b/deploy/scripts/download_assets.sh @@ -27,6 +27,7 @@ ASSETS_ENVS=( "NEXT_PUBLIC_NETWORK_ICON" "NEXT_PUBLIC_NETWORK_ICON_DARK" "NEXT_PUBLIC_OG_IMAGE_URL" + "NEXT_PUBLIC_ADDRESS_3RD_PARTY_WIDGETS_CONFIG_URL" ) # Create the assets directory if it doesn't exist diff --git a/deploy/scripts/entrypoint.sh b/deploy/scripts/entrypoint.sh index 6abcb1fa6c..fe358d1d6f 100755 --- a/deploy/scripts/entrypoint.sh +++ b/deploy/scripts/entrypoint.sh @@ -64,6 +64,9 @@ node --no-warnings ./og_image_generator.js # Create envs.js file with run-time environment variables for the client app ./make_envs_script.sh +# Generate multichain config +node ./deploy/tools/multichain-config-generator/dist/index.js + # Generate sitemap.xml and robots.txt files ./sitemap_generator.sh diff --git a/deploy/tools/affected-tests/yarn.lock b/deploy/tools/affected-tests/yarn.lock index ebfe2bd8be..60c434f4e6 100644 --- a/deploy/tools/affected-tests/yarn.lock +++ b/deploy/tools/affected-tests/yarn.lock @@ -83,9 +83,9 @@ balanced-match@^1.0.0: integrity sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw== brace-expansion@^1.1.7: - version "1.1.11" - resolved "https://registry.yarnpkg.com/brace-expansion/-/brace-expansion-1.1.11.tgz#3c7fcbf529d87226f3d2f52b966ff5271eb441dd" - integrity sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA== + version "1.1.12" + resolved "https://registry.yarnpkg.com/brace-expansion/-/brace-expansion-1.1.12.tgz#ab9b454466e5a8cc3a187beaad580412a9c5b843" + integrity sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg== dependencies: balanced-match "^1.0.0" concat-map "0.0.1" diff --git a/deploy/tools/envs-validator/index.ts b/deploy/tools/envs-validator/index.ts index fdb0a068e0..39119b6cdd 100644 --- a/deploy/tools/envs-validator/index.ts +++ b/deploy/tools/envs-validator/index.ts @@ -43,6 +43,7 @@ async function validateEnvs(appEnvs: Record) { 'NEXT_PUBLIC_MARKETPLACE_SECURITY_REPORTS_URL', 'NEXT_PUBLIC_MARKETPLACE_GRAPH_LINKS_URL', 'NEXT_PUBLIC_FOOTER_LINKS', + 'NEXT_PUBLIC_ADDRESS_3RD_PARTY_WIDGETS_CONFIG_URL', ]; for await (const envName of envsWithJsonConfig) { diff --git a/deploy/tools/envs-validator/schema.ts b/deploy/tools/envs-validator/schema.ts index ab6bf4368b..300428748c 100644 --- a/deploy/tools/envs-validator/schema.ts +++ b/deploy/tools/envs-validator/schema.ts @@ -20,8 +20,9 @@ import { GAS_UNITS } from '../../../types/client/gasTracker'; import type { GasUnit } from '../../../types/client/gasTracker'; import type { MarketplaceAppOverview, MarketplaceAppSecurityReportRaw, MarketplaceAppSecurityReport } from '../../../types/client/marketplace'; import type { MultichainProviderConfig } from '../../../types/client/multichainProviderConfig'; -import { NAVIGATION_LINK_IDS } from '../../../types/client/navigation'; -import type { NavItemExternal, NavigationLinkId, NavigationLayout } from '../../../types/client/navigation'; +import type { ApiDocsTabId } from '../../../types/views/apiDocs'; +import { API_DOCS_TABS } from '../../../types/views/apiDocs'; +import type { NavItemExternal, NavigationLayout } from '../../../types/client/navigation'; import { ROLLUP_TYPES } from '../../../types/client/rollup'; import type { BridgedTokenChain, TokenBridge } from '../../../types/client/token'; import { PROVIDERS as TX_INTERPRETATION_PROVIDERS } from '../../../types/client/txInterpretation'; @@ -35,8 +36,8 @@ import type { ChainIndicatorId, HeroBannerButtonState, HeroBannerConfig, HomeSta import { type NetworkVerificationTypeEnvs, type NetworkExplorer, type FeaturedNetwork } from '../../../types/networks'; import { COLOR_THEME_IDS } from '../../../types/settings'; import type { FontFamily } from '../../../types/ui'; -import type { AddressFormat, AddressViewId } from '../../../types/views/address'; -import { ADDRESS_FORMATS, ADDRESS_VIEWS_IDS, IDENTICON_TYPES } from '../../../types/views/address'; +import type { AddressFormat, AddressViewId, Address3rdPartyWidget } from '../../../types/views/address'; +import { ADDRESS_FORMATS, ADDRESS_VIEWS_IDS, IDENTICON_TYPES, ADDRESS_3RD_PARTY_WIDGET_PAGES } from '../../../types/views/address'; import { BLOCK_FIELDS_IDS } from '../../../types/views/block'; import type { BlockFieldId } from '../../../types/views/block'; import type { NftMarketplaceItem } from '../../../types/views/nft'; @@ -71,12 +72,12 @@ const urlTest: yup.TestConfig = { exclusive: true, }; -const getYupValidationErrorMessage = (error: unknown) => - typeof error === 'object' && - error !== null && - 'errors' in error && - Array.isArray(error.errors) ? - error.errors.join(', ') : +const getYupValidationErrorMessage = (error: unknown) => + typeof error === 'object' && + error !== null && + 'errors' in error && + Array.isArray(error.errors) ? + error.errors.join(', ') : ''; const marketplaceAppSchema: yup.ObjectSchema = yup @@ -448,15 +449,34 @@ const celoSchema = yup .object() .shape({ NEXT_PUBLIC_CELO_ENABLED: yup.boolean(), - NEXT_PUBLIC_CELO_L2_UPGRADE_BLOCK: yup + }); + +const apiDocsScheme = yup + .object() + .shape({ + NEXT_PUBLIC_API_DOCS_TABS: yup.array() + .transform(replaceQuotes) + .json() + .of(yup.string().oneOf(API_DOCS_TABS)), + NEXT_PUBLIC_API_SPEC_URL: yup .string() - .when('NEXT_PUBLIC_CELO_ENABLED', { + .test(urlTest), + NEXT_PUBLIC_GRAPHIQL_TRANSACTION: yup + .string() + .matches(regexp.HEX_REGEXP), + }); + +const userOpsSchema = yup + .object() + .shape({ + NEXT_PUBLIC_HAS_USER_OPS: yup.boolean(), + NEXT_PUBLIC_USER_OPS_INDEXER_API_HOST: yup + .string() + .test(urlTest) + .when('NEXT_PUBLIC_HAS_USER_OPS', { is: (value: boolean) => value, - then: (schema) => schema.min(0).optional(), - otherwise: (schema) => schema.max( - -1, - 'NEXT_PUBLIC_CELO_L2_UPGRADE_BLOCK cannot not be used if NEXT_PUBLIC_CELO_ENABLED is not set to "true"', - ), + then: (schema) => schema, + otherwise: (schema) => schema.max(-1, 'NEXT_PUBLIC_USER_OPS_INDEXER_API_HOST can only be used if NEXT_PUBLIC_HAS_USER_OPS is set to \'true\''), }), }); @@ -590,6 +610,7 @@ const footerLinkSchema: yup.ObjectSchema = yup .object({ text: yup.string().required(), url: yup.string().test(urlTest).required(), + iconUrl: yup.array().of(yup.string().required().test(urlTest)), }); const footerLinkGroupSchema: yup.ObjectSchema = yup @@ -666,6 +687,25 @@ const bridgedTokensSchema = yup }), }); +const addressMetadataSchema = yup + .object() + .shape({ + NEXT_PUBLIC_METADATA_SERVICE_API_HOST: yup + .string() + .test(urlTest), + NEXT_PUBLIC_METADATA_ADDRESS_TAGS_UPDATE_ENABLED: yup + .boolean() + .when('NEXT_PUBLIC_METADATA_SERVICE_API_HOST', { + is: (value: string) => Boolean(value), + then: (schema) => schema, + otherwise: (schema) => schema.test( + 'not-exist', + 'NEXT_PUBLIC_METADATA_ADDRESS_TAGS_UPDATE_ENABLED cannot not be used if NEXT_PUBLIC_METADATA_SERVICE_API_HOST is not defined', + value => value === undefined, + ), + }), + }); + const deFiDropdownItemSchema: yup.ObjectSchema = yup .object({ text: yup.string().required(), @@ -690,6 +730,46 @@ const externalTxsConfigSchema: yup.ObjectSchema = yup.objec explorer_url_template: yup.string().required(), }); +const address3rdPartyWidgetsConfigSchema = yup + .object() + .shape({ + NEXT_PUBLIC_ADDRESS_3RD_PARTY_WIDGETS_CONFIG_URL: yup + .mixed() + .test('shape', 'Invalid schema were provided for NEXT_PUBLIC_ADDRESS_3RD_PARTY_WIDGETS_CONFIG_URL, it should have name, url, icon, title, value', (data) => { + const isUndefined = data === undefined; + const parsedData = typeof data === 'string' ? JSON.parse(data) : data; + const valueSchema = yup.lazy((objValue) => { + let schema = yup.object(); + Object.keys(objValue).forEach((key) => { + schema = schema.shape({ + [key]: yup.object().shape({ + name: yup.string().required(), + url: yup.string().required(), + icon: yup.string().required(), + title: yup.string().required(), + hint: yup.string().optional(), + valuePath: yup.string().required(), + pages: yup.array().of(yup.string().oneOf(ADDRESS_3RD_PARTY_WIDGET_PAGES)).required(), + chainIds: yup.object>().optional(), + }), + }); + }); + return schema; + }); + return isUndefined || valueSchema.isValidSync(parsedData); + }), + NEXT_PUBLIC_ADDRESS_3RD_PARTY_WIDGETS: yup + .array() + .transform(replaceQuotes) + .json() + .of(yup.string()) + .when('NEXT_PUBLIC_ADDRESS_3RD_PARTY_WIDGETS_CONFIG_URL', { + is: (value: string) => value, + then: (schema) => schema, + otherwise: (schema) => schema.max(-1, 'NEXT_PUBLIC_ADDRESS_3RD_PARTY_WIDGETS cannot not be used if NEXT_PUBLIC_ADDRESS_3RD_PARTY_WIDGETS_CONFIG_URL is not provided'), + }), + }); + const schema = yup .object() .noUnknown(true, (params) => { @@ -820,11 +900,6 @@ const schema = yup .transform(replaceQuotes) .json() .of(navItemExternalSchema), - NEXT_PUBLIC_NAVIGATION_HIDDEN_LINKS: yup - .array() - .transform(replaceQuotes) - .json() - .of(yup.string().oneOf(NAVIGATION_LINK_IDS)), NEXT_PUBLIC_NAVIGATION_HIGHLIGHTED_ROUTES: yup .array() .transform(replaceQuotes) @@ -938,30 +1013,13 @@ const schema = yup NEXT_PUBLIC_MAX_CONTENT_WIDTH_ENABLED: yup.boolean(), // 5. Features configuration - NEXT_PUBLIC_API_SPEC_URL: yup - .mixed() - .test('shape', 'Invalid schema were provided for NEXT_PUBLIC_API_SPEC_URL, it should be either URL-string or "none" string literal', (data) => { - const isNoneSchema = yup.string().oneOf([ 'none' ]); - const isUrlStringSchema = yup.string().test(urlTest); - - return isNoneSchema.isValidSync(data) || isUrlStringSchema.isValidSync(data); - }), NEXT_PUBLIC_STATS_API_HOST: yup.string().test(urlTest), NEXT_PUBLIC_STATS_API_BASE_PATH: yup.string(), NEXT_PUBLIC_VISUALIZE_API_HOST: yup.string().test(urlTest), NEXT_PUBLIC_VISUALIZE_API_BASE_PATH: yup.string(), NEXT_PUBLIC_CONTRACT_INFO_API_HOST: yup.string().test(urlTest), NEXT_PUBLIC_NAME_SERVICE_API_HOST: yup.string().test(urlTest), - NEXT_PUBLIC_METADATA_SERVICE_API_HOST: yup.string().test(urlTest), NEXT_PUBLIC_ADMIN_SERVICE_API_HOST: yup.string().test(urlTest), - NEXT_PUBLIC_GRAPHIQL_TRANSACTION: yup - .mixed() - .test('shape', 'Invalid schema were provided for NEXT_PUBLIC_GRAPHIQL_TRANSACTION, it should be either Hex-string or "none" string literal', (data) => { - const isNoneSchema = yup.string().oneOf([ 'none' ]); - const isHashStringSchema = yup.string().matches(regexp.HEX_REGEXP); - - return isNoneSchema.isValidSync(data) || isHashStringSchema.isValidSync(data); - }), NEXT_PUBLIC_WEB3_WALLETS: yup .mixed() .test('shape', 'Invalid schema were provided for NEXT_PUBLIC_WEB3_WALLETS, it should be either array or "none" string literal', (data) => { @@ -984,7 +1042,6 @@ const schema = yup NEXT_PUBLIC_SEO_ENHANCED_DATA_ENABLED: yup.boolean(), NEXT_PUBLIC_SAFE_TX_SERVICE_URL: yup.string().test(urlTest), NEXT_PUBLIC_IS_SUAVE_CHAIN: yup.boolean(), - NEXT_PUBLIC_HAS_USER_OPS: yup.boolean(), NEXT_PUBLIC_METASUITES_ENABLED: yup.boolean(), NEXT_PUBLIC_MULTICHAIN_BALANCE_PROVIDER_CONFIG: yup .array() @@ -1107,6 +1164,10 @@ const schema = yup .concat(beaconChainSchema) .concat(bridgedTokensSchema) .concat(sentrySchema) - .concat(tacSchema); + .concat(apiDocsScheme) + .concat(tacSchema) + .concat(address3rdPartyWidgetsConfigSchema) + .concat(addressMetadataSchema) + .concat(userOpsSchema); export default schema; diff --git a/deploy/tools/envs-validator/test/.env.alt b/deploy/tools/envs-validator/test/.env.alt index 11e6bf78fb..be2db5a7bd 100644 --- a/deploy/tools/envs-validator/test/.env.alt +++ b/deploy/tools/envs-validator/test/.env.alt @@ -1,5 +1,4 @@ -NEXT_PUBLIC_GRAPHIQL_TRANSACTION=none -NEXT_PUBLIC_API_SPEC_URL=none +NEXT_PUBLIC_API_DOCS_TABS=[] NEXT_PUBLIC_VIEWS_CONTRACT_EXTRA_VERIFICATION_METHODS=none NEXT_PUBLIC_HOMEPAGE_STATS=[] NEXT_PUBLIC_VIEWS_ADDRESS_FORMAT=['base16','bech32'] @@ -7,3 +6,7 @@ NEXT_PUBLIC_VIEWS_ADDRESS_BECH_32_PREFIX=foo NEXT_PUBLIC_RE_CAPTCHA_APP_SITE_KEY=xxx NEXT_PUBLIC_RE_CAPTCHA_V3_APP_SITE_KEY=deprecated NEXT_PUBLIC_NETWORK_RPC_URL=['https://example.com','https://example2.com'] +NEXT_PUBLIC_METADATA_SERVICE_API_HOST=https://example.com +NEXT_PUBLIC_METADATA_ADDRESS_TAGS_UPDATE_ENABLED=false +NEXT_PUBLIC_HAS_USER_OPS=true +NEXT_PUBLIC_USER_OPS_INDEXER_API_HOST=https://example.com \ No newline at end of file diff --git a/deploy/tools/envs-validator/test/.env.base b/deploy/tools/envs-validator/test/.env.base index debb88fd4e..04db4d54b2 100644 --- a/deploy/tools/envs-validator/test/.env.base +++ b/deploy/tools/envs-validator/test/.env.base @@ -46,7 +46,6 @@ NEXT_PUBLIC_IS_TESTNET=true NEXT_PUBLIC_MAINTENANCE_ALERT_MESSAGE='Hello' NEXT_PUBLIC_METADATA_SERVICE_API_HOST=https://example.com NEXT_PUBLIC_METASUITES_ENABLED=true -NEXT_PUBLIC_NAVIGATION_HIDDEN_LINKS=['eth_rpc_api','rpc_api'] NEXT_PUBLIC_NETWORK_CURRENCY_DECIMALS=18 NEXT_PUBLIC_NETWORK_CURRENCY_NAME=Ether NEXT_PUBLIC_NETWORK_CURRENCY_SYMBOL=ETH @@ -89,3 +88,5 @@ NEXT_PUBLIC_MULTICHAIN_BALANCE_PROVIDER_CONFIG=[{'name': 'zerion', 'url_template NEXT_PUBLIC_GAS_REFUEL_PROVIDER_CONFIG={'name': 'Need gas?', 'dapp_id': 'smol-refuel', 'url_template': 'https://smolrefuel.com/?outboundChain={chainId}&partner=blockscout&utm_source=blockscout&utm_medium=address&disableBridges=true', 'logo': 'https://blockscout-content.s3.amazonaws.com/smolrefuel-logo-action-button.png'} NEXT_PUBLIC_SAVE_ON_GAS_ENABLED=true NEXT_PUBLIC_REWARDS_SERVICE_API_HOST=https://example.com +NEXT_PUBLIC_ADDRESS_3RD_PARTY_WIDGETS=['widget-1', 'widget-2'] +NEXT_PUBLIC_ADDRESS_3RD_PARTY_WIDGETS_CONFIG_URL=https://example.com diff --git a/deploy/tools/envs-validator/test/.env.celo b/deploy/tools/envs-validator/test/.env.celo index 1082044208..6c9015d822 100644 --- a/deploy/tools/envs-validator/test/.env.celo +++ b/deploy/tools/envs-validator/test/.env.celo @@ -1,2 +1 @@ -NEXT_PUBLIC_CELO_ENABLED=true -NEXT_PUBLIC_CELO_L2_UPGRADE_BLOCK=420 \ No newline at end of file +NEXT_PUBLIC_CELO_ENABLED=true \ No newline at end of file diff --git a/deploy/tools/envs-validator/test/assets/configs/address_3rd_party_widgets_config.json b/deploy/tools/envs-validator/test/assets/configs/address_3rd_party_widgets_config.json new file mode 100644 index 0000000000..a323d46c84 --- /dev/null +++ b/deploy/tools/envs-validator/test/assets/configs/address_3rd_party_widgets_config.json @@ -0,0 +1,31 @@ +{ + "widget-1": { + "name": "Widget 1", + "url": "https://example.com/widget-1/{address}", + "icon": "https://example.com/icon.svg", + "title": "Widget 1", + "hint": "Widget 1 hint", + "valuePath": "result.value", + "pages": [ "eoa", "contract", "token" ] + }, + "widget-2": { + "name": "Widget 2", + "url": "https://example.com/widget-2/{address}", + "icon": "https://example.com/icon.svg", + "title": "Widget 2", + "valuePath": "value", + "pages": [ "eoa" ] + }, + "widget-3": { + "name": "Widget 3", + "url": "https://example.com/widget-3/{address}?chainId={chainId}", + "icon": "https://example.com/icon.svg", + "title": "Widget 3", + "valuePath": "result.length", + "pages": [ "token" ], + "chainIds": { + "1": "eth", + "10": "op" + } + } +} diff --git a/deploy/tools/envs-validator/test/assets/configs/footer_links.json b/deploy/tools/envs-validator/test/assets/configs/footer_links.json index d3f7e7d437..864295b952 100644 --- a/deploy/tools/envs-validator/test/assets/configs/footer_links.json +++ b/deploy/tools/envs-validator/test/assets/configs/footer_links.json @@ -17,7 +17,11 @@ "links": [ { "text": "Develop", - "url": "https://example.com" + "url": "https://example.com", + "iconUrl": [ + "https://example.com/mocks/image_s.jpg", + "https://example.com/mocks/image_svg.svg" + ] }, { "text": "Grants", diff --git a/deploy/tools/multichain-config-generator/.gitignore b/deploy/tools/multichain-config-generator/.gitignore new file mode 100644 index 0000000000..d72e53a180 --- /dev/null +++ b/deploy/tools/multichain-config-generator/.gitignore @@ -0,0 +1,4 @@ +node_modules +dist +*.log +.DS_Store \ No newline at end of file diff --git a/deploy/tools/multichain-config-generator/index.ts b/deploy/tools/multichain-config-generator/index.ts new file mode 100644 index 0000000000..b959374e6b --- /dev/null +++ b/deploy/tools/multichain-config-generator/index.ts @@ -0,0 +1,80 @@ +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 { + 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(); diff --git a/deploy/tools/multichain-config-generator/package.json b/deploy/tools/multichain-config-generator/package.json new file mode 100644 index 0000000000..a75e1312be --- /dev/null +++ b/deploy/tools/multichain-config-generator/package.json @@ -0,0 +1,18 @@ +{ + "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 diff --git a/deploy/tools/multichain-config-generator/tsconfig.json b/deploy/tools/multichain-config-generator/tsconfig.json new file mode 100644 index 0000000000..975d121767 --- /dev/null +++ b/deploy/tools/multichain-config-generator/tsconfig.json @@ -0,0 +1,31 @@ +{ + "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 diff --git a/deploy/tools/multichain-config-generator/vite.config.ts b/deploy/tools/multichain-config-generator/vite.config.ts new file mode 100644 index 0000000000..5b243ec31a --- /dev/null +++ b/deploy/tools/multichain-config-generator/vite.config.ts @@ -0,0 +1,33 @@ +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, + }, +}); diff --git a/deploy/tools/multichain-config-generator/worker.ts b/deploy/tools/multichain-config-generator/worker.ts new file mode 100644 index 0000000000..449b390339 --- /dev/null +++ b/deploy/tools/multichain-config-generator/worker.ts @@ -0,0 +1,48 @@ +/* eslint-disable no-console */ +import { parentPort, workerData } from 'node:worker_threads'; + +interface WorkerData { + url: string; +} + +interface ChainConfig { + envs: Record; +} + +async function fetchChainConfig(url: string): Promise { + 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(); diff --git a/deploy/tools/multichain-config-generator/yarn.lock b/deploy/tools/multichain-config-generator/yarn.lock new file mode 100644 index 0000000000..e5f46d99b3 --- /dev/null +++ b/deploy/tools/multichain-config-generator/yarn.lock @@ -0,0 +1,978 @@ +# THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY. +# yarn lockfile v1 + + +"@babel/helper-string-parser@^7.27.1": + version "7.27.1" + resolved "https://registry.yarnpkg.com/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz#54da796097ab19ce67ed9f88b47bb2ec49367687" + integrity sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA== + +"@babel/helper-validator-identifier@^7.27.1": + version "7.27.1" + resolved "https://registry.yarnpkg.com/@babel/helper-validator-identifier/-/helper-validator-identifier-7.27.1.tgz#a7054dcc145a967dd4dc8fee845a57c1316c9df8" + integrity sha512-D2hP9eA+Sqx1kBZgzxZh0y1trbuU+JoDkiEwqhQ36nodYqJwyEIhPSdMNd7lOm/4io72luTPWH20Yda0xOuUow== + +"@babel/parser@^7.27.2": + version "7.27.5" + resolved "https://registry.yarnpkg.com/@babel/parser/-/parser-7.27.5.tgz#ed22f871f110aa285a6fd934a0efed621d118826" + integrity sha512-OsQd175SxWkGlzbny8J3K8TnnDD0N3lrIUtB92xwyRpzaenGZhxDvxN/JgU00U3CDZNj9tPuDJ5H0WS4Nt3vKg== + dependencies: + "@babel/types" "^7.27.3" + +"@babel/types@^7.27.3": + version "7.27.3" + resolved "https://registry.yarnpkg.com/@babel/types/-/types-7.27.3.tgz#c0257bedf33aad6aad1f406d35c44758321eb3ec" + integrity sha512-Y1GkI4ktrtvmawoSq+4FCVHNryea6uR+qUQy0AGxLSsjCX0nVmkYQMBLHDkXZuo5hGx7eYdnIaslsdBFm7zbUw== + dependencies: + "@babel/helper-string-parser" "^7.27.1" + "@babel/helper-validator-identifier" "^7.27.1" + +"@esbuild/aix-ppc64@0.25.5": + version "0.25.5" + resolved "https://registry.yarnpkg.com/@esbuild/aix-ppc64/-/aix-ppc64-0.25.5.tgz#4e0f91776c2b340e75558f60552195f6fad09f18" + integrity sha512-9o3TMmpmftaCMepOdA5k/yDw8SfInyzWWTjYTFCX3kPSDJMROQTb8jg+h9Cnwnmm1vOzvxN7gIfB5V2ewpjtGA== + +"@esbuild/android-arm64@0.25.5": + version "0.25.5" + resolved "https://registry.yarnpkg.com/@esbuild/android-arm64/-/android-arm64-0.25.5.tgz#bc766407f1718923f6b8079c8c61bf86ac3a6a4f" + integrity sha512-VGzGhj4lJO+TVGV1v8ntCZWJktV7SGCs3Pn1GRWI1SBFtRALoomm8k5E9Pmwg3HOAal2VDc2F9+PM/rEY6oIDg== + +"@esbuild/android-arm@0.25.5": + version "0.25.5" + resolved "https://registry.yarnpkg.com/@esbuild/android-arm/-/android-arm-0.25.5.tgz#4290d6d3407bae3883ad2cded1081a234473ce26" + integrity sha512-AdJKSPeEHgi7/ZhuIPtcQKr5RQdo6OO2IL87JkianiMYMPbCtot9fxPbrMiBADOWWm3T2si9stAiVsGbTQFkbA== + +"@esbuild/android-x64@0.25.5": + version "0.25.5" + resolved "https://registry.yarnpkg.com/@esbuild/android-x64/-/android-x64-0.25.5.tgz#40c11d9cbca4f2406548c8a9895d321bc3b35eff" + integrity sha512-D2GyJT1kjvO//drbRT3Hib9XPwQeWd9vZoBJn+bu/lVsOZ13cqNdDeqIF/xQ5/VmWvMduP6AmXvylO/PIc2isw== + +"@esbuild/darwin-arm64@0.25.5": + version "0.25.5" + resolved "https://registry.yarnpkg.com/@esbuild/darwin-arm64/-/darwin-arm64-0.25.5.tgz#49d8bf8b1df95f759ac81eb1d0736018006d7e34" + integrity sha512-GtaBgammVvdF7aPIgH2jxMDdivezgFu6iKpmT+48+F8Hhg5J/sfnDieg0aeG/jfSvkYQU2/pceFPDKlqZzwnfQ== + +"@esbuild/darwin-x64@0.25.5": + version "0.25.5" + resolved "https://registry.yarnpkg.com/@esbuild/darwin-x64/-/darwin-x64-0.25.5.tgz#e27a5d92a14886ef1d492fd50fc61a2d4d87e418" + integrity sha512-1iT4FVL0dJ76/q1wd7XDsXrSW+oLoquptvh4CLR4kITDtqi2e/xwXwdCVH8hVHU43wgJdsq7Gxuzcs6Iq/7bxQ== + +"@esbuild/freebsd-arm64@0.25.5": + version "0.25.5" + resolved "https://registry.yarnpkg.com/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.5.tgz#97cede59d638840ca104e605cdb9f1b118ba0b1c" + integrity sha512-nk4tGP3JThz4La38Uy/gzyXtpkPW8zSAmoUhK9xKKXdBCzKODMc2adkB2+8om9BDYugz+uGV7sLmpTYzvmz6Sw== + +"@esbuild/freebsd-x64@0.25.5": + version "0.25.5" + resolved "https://registry.yarnpkg.com/@esbuild/freebsd-x64/-/freebsd-x64-0.25.5.tgz#71c77812042a1a8190c3d581e140d15b876b9c6f" + integrity sha512-PrikaNjiXdR2laW6OIjlbeuCPrPaAl0IwPIaRv+SMV8CiM8i2LqVUHFC1+8eORgWyY7yhQY+2U2fA55mBzReaw== + +"@esbuild/linux-arm64@0.25.5": + version "0.25.5" + resolved "https://registry.yarnpkg.com/@esbuild/linux-arm64/-/linux-arm64-0.25.5.tgz#f7b7c8f97eff8ffd2e47f6c67eb5c9765f2181b8" + integrity sha512-Z9kfb1v6ZlGbWj8EJk9T6czVEjjq2ntSYLY2cw6pAZl4oKtfgQuS4HOq41M/BcoLPzrUbNd+R4BXFyH//nHxVg== + +"@esbuild/linux-arm@0.25.5": + version "0.25.5" + resolved "https://registry.yarnpkg.com/@esbuild/linux-arm/-/linux-arm-0.25.5.tgz#2a0be71b6cd8201fa559aea45598dffabc05d911" + integrity sha512-cPzojwW2okgh7ZlRpcBEtsX7WBuqbLrNXqLU89GxWbNt6uIg78ET82qifUy3W6OVww6ZWobWub5oqZOVtwolfw== + +"@esbuild/linux-ia32@0.25.5": + version "0.25.5" + resolved "https://registry.yarnpkg.com/@esbuild/linux-ia32/-/linux-ia32-0.25.5.tgz#763414463cd9ea6fa1f96555d2762f9f84c61783" + integrity sha512-sQ7l00M8bSv36GLV95BVAdhJ2QsIbCuCjh/uYrWiMQSUuV+LpXwIqhgJDcvMTj+VsQmqAHL2yYaasENvJ7CDKA== + +"@esbuild/linux-loong64@0.25.5": + version "0.25.5" + resolved "https://registry.yarnpkg.com/@esbuild/linux-loong64/-/linux-loong64-0.25.5.tgz#428cf2213ff786a502a52c96cf29d1fcf1eb8506" + integrity sha512-0ur7ae16hDUC4OL5iEnDb0tZHDxYmuQyhKhsPBV8f99f6Z9KQM02g33f93rNH5A30agMS46u2HP6qTdEt6Q1kg== + +"@esbuild/linux-mips64el@0.25.5": + version "0.25.5" + resolved "https://registry.yarnpkg.com/@esbuild/linux-mips64el/-/linux-mips64el-0.25.5.tgz#5cbcc7fd841b4cd53358afd33527cd394e325d96" + integrity sha512-kB/66P1OsHO5zLz0i6X0RxlQ+3cu0mkxS3TKFvkb5lin6uwZ/ttOkP3Z8lfR9mJOBk14ZwZ9182SIIWFGNmqmg== + +"@esbuild/linux-ppc64@0.25.5": + version "0.25.5" + resolved "https://registry.yarnpkg.com/@esbuild/linux-ppc64/-/linux-ppc64-0.25.5.tgz#0d954ab39ce4f5e50f00c4f8c4fd38f976c13ad9" + integrity sha512-UZCmJ7r9X2fe2D6jBmkLBMQetXPXIsZjQJCjgwpVDz+YMcS6oFR27alkgGv3Oqkv07bxdvw7fyB71/olceJhkQ== + +"@esbuild/linux-riscv64@0.25.5": + version "0.25.5" + resolved "https://registry.yarnpkg.com/@esbuild/linux-riscv64/-/linux-riscv64-0.25.5.tgz#0e7dd30730505abd8088321e8497e94b547bfb1e" + integrity sha512-kTxwu4mLyeOlsVIFPfQo+fQJAV9mh24xL+y+Bm6ej067sYANjyEw1dNHmvoqxJUCMnkBdKpvOn0Ahql6+4VyeA== + +"@esbuild/linux-s390x@0.25.5": + version "0.25.5" + resolved "https://registry.yarnpkg.com/@esbuild/linux-s390x/-/linux-s390x-0.25.5.tgz#5669af81327a398a336d7e40e320b5bbd6e6e72d" + integrity sha512-K2dSKTKfmdh78uJ3NcWFiqyRrimfdinS5ErLSn3vluHNeHVnBAFWC8a4X5N+7FgVE1EjXS1QDZbpqZBjfrqMTQ== + +"@esbuild/linux-x64@0.25.5": + version "0.25.5" + resolved "https://registry.yarnpkg.com/@esbuild/linux-x64/-/linux-x64-0.25.5.tgz#b2357dd153aa49038967ddc1ffd90c68a9d2a0d4" + integrity sha512-uhj8N2obKTE6pSZ+aMUbqq+1nXxNjZIIjCjGLfsWvVpy7gKCOL6rsY1MhRh9zLtUtAI7vpgLMK6DxjO8Qm9lJw== + +"@esbuild/netbsd-arm64@0.25.5": + version "0.25.5" + resolved "https://registry.yarnpkg.com/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.5.tgz#53b4dfb8fe1cee93777c9e366893bd3daa6ba63d" + integrity sha512-pwHtMP9viAy1oHPvgxtOv+OkduK5ugofNTVDilIzBLpoWAM16r7b/mxBvfpuQDpRQFMfuVr5aLcn4yveGvBZvw== + +"@esbuild/netbsd-x64@0.25.5": + version "0.25.5" + resolved "https://registry.yarnpkg.com/@esbuild/netbsd-x64/-/netbsd-x64-0.25.5.tgz#a0206f6314ce7dc8713b7732703d0f58de1d1e79" + integrity sha512-WOb5fKrvVTRMfWFNCroYWWklbnXH0Q5rZppjq0vQIdlsQKuw6mdSihwSo4RV/YdQ5UCKKvBy7/0ZZYLBZKIbwQ== + +"@esbuild/openbsd-arm64@0.25.5": + version "0.25.5" + resolved "https://registry.yarnpkg.com/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.5.tgz#2a796c87c44e8de78001d808c77d948a21ec22fd" + integrity sha512-7A208+uQKgTxHd0G0uqZO8UjK2R0DDb4fDmERtARjSHWxqMTye4Erz4zZafx7Di9Cv+lNHYuncAkiGFySoD+Mw== + +"@esbuild/openbsd-x64@0.25.5": + version "0.25.5" + resolved "https://registry.yarnpkg.com/@esbuild/openbsd-x64/-/openbsd-x64-0.25.5.tgz#28d0cd8909b7fa3953af998f2b2ed34f576728f0" + integrity sha512-G4hE405ErTWraiZ8UiSoesH8DaCsMm0Cay4fsFWOOUcz8b8rC6uCvnagr+gnioEjWn0wC+o1/TAHt+It+MpIMg== + +"@esbuild/sunos-x64@0.25.5": + version "0.25.5" + resolved "https://registry.yarnpkg.com/@esbuild/sunos-x64/-/sunos-x64-0.25.5.tgz#a28164f5b997e8247d407e36c90d3fd5ddbe0dc5" + integrity sha512-l+azKShMy7FxzY0Rj4RCt5VD/q8mG/e+mDivgspo+yL8zW7qEwctQ6YqKX34DTEleFAvCIUviCFX1SDZRSyMQA== + +"@esbuild/win32-arm64@0.25.5": + version "0.25.5" + resolved "https://registry.yarnpkg.com/@esbuild/win32-arm64/-/win32-arm64-0.25.5.tgz#6eadbead38e8bd12f633a5190e45eff80e24007e" + integrity sha512-O2S7SNZzdcFG7eFKgvwUEZ2VG9D/sn/eIiz8XRZ1Q/DO5a3s76Xv0mdBzVM5j5R639lXQmPmSo0iRpHqUUrsxw== + +"@esbuild/win32-ia32@0.25.5": + version "0.25.5" + resolved "https://registry.yarnpkg.com/@esbuild/win32-ia32/-/win32-ia32-0.25.5.tgz#bab6288005482f9ed2adb9ded7e88eba9a62cc0d" + integrity sha512-onOJ02pqs9h1iMJ1PQphR+VZv8qBMQ77Klcsqv9CNW2w6yLqoURLcgERAIurY6QE63bbLuqgP9ATqajFLK5AMQ== + +"@esbuild/win32-x64@0.25.5": + version "0.25.5" + resolved "https://registry.yarnpkg.com/@esbuild/win32-x64/-/win32-x64-0.25.5.tgz#7fc114af5f6563f19f73324b5d5ff36ece0803d1" + integrity sha512-TXv6YnJ8ZMVdX+SXWVBo/0p8LTcrUYngpWjvm91TMjjBQii7Oz11Lw5lbDV5Y0TzuhSJHwiH4hEtC1I42mMS0g== + +"@jridgewell/sourcemap-codec@^1.5.0": + version "1.5.0" + resolved "https://registry.yarnpkg.com/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.0.tgz#3188bcb273a414b0d215fd22a58540b989b9409a" + integrity sha512-gv3ZRaISU3fjPAgNsriBRqGWQL6quFx04YMPW/zD8XMLsU32mhCCbfbO6KZFLjvYpCZ8zyDEgqsgf+PwPaM7GQ== + +"@microsoft/api-extractor-model@7.30.6": + version "7.30.6" + resolved "https://registry.yarnpkg.com/@microsoft/api-extractor-model/-/api-extractor-model-7.30.6.tgz#cd9c434521dda3b226cc0f6aefb9c20afaf99c92" + integrity sha512-znmFn69wf/AIrwHya3fxX6uB5etSIn6vg4Q4RB/tb5VDDs1rqREc+AvMC/p19MUN13CZ7+V/8pkYPTj7q8tftg== + dependencies: + "@microsoft/tsdoc" "~0.15.1" + "@microsoft/tsdoc-config" "~0.17.1" + "@rushstack/node-core-library" "5.13.1" + +"@microsoft/api-extractor@^7.50.1": + version "7.52.8" + resolved "https://registry.yarnpkg.com/@microsoft/api-extractor/-/api-extractor-7.52.8.tgz#7cc944f44ca1b1ad9d7272ab5d98e81987c1f8ca" + integrity sha512-cszYIcjiNscDoMB1CIKZ3My61+JOhpERGlGr54i6bocvGLrcL/wo9o+RNXMBrb7XgLtKaizZWUpqRduQuHQLdg== + dependencies: + "@microsoft/api-extractor-model" "7.30.6" + "@microsoft/tsdoc" "~0.15.1" + "@microsoft/tsdoc-config" "~0.17.1" + "@rushstack/node-core-library" "5.13.1" + "@rushstack/rig-package" "0.5.3" + "@rushstack/terminal" "0.15.3" + "@rushstack/ts-command-line" "5.0.1" + lodash "~4.17.15" + minimatch "~3.0.3" + resolve "~1.22.1" + semver "~7.5.4" + source-map "~0.6.1" + typescript "5.8.2" + +"@microsoft/tsdoc-config@~0.17.1": + version "0.17.1" + resolved "https://registry.yarnpkg.com/@microsoft/tsdoc-config/-/tsdoc-config-0.17.1.tgz#e0f0b50628f4ad7fe121ca616beacfe6a25b9335" + integrity sha512-UtjIFe0C6oYgTnad4q1QP4qXwLhe6tIpNTRStJ2RZEPIkqQPREAwE5spzVxsdn9UaEMUqhh0AqSx3X4nWAKXWw== + dependencies: + "@microsoft/tsdoc" "0.15.1" + ajv "~8.12.0" + jju "~1.4.0" + resolve "~1.22.2" + +"@microsoft/tsdoc@0.15.1", "@microsoft/tsdoc@~0.15.1": + version "0.15.1" + resolved "https://registry.yarnpkg.com/@microsoft/tsdoc/-/tsdoc-0.15.1.tgz#d4f6937353bc4568292654efb0a0e0532adbcba2" + integrity sha512-4aErSrCR/On/e5G2hDP0wjooqDdauzEbIq8hIkIe5pXV0rtWJZvdCEKL0ykZxex+IxIwBp0eGeV48hQN07dXtw== + +"@rollup/pluginutils@^5.1.4": + version "5.1.4" + resolved "https://registry.yarnpkg.com/@rollup/pluginutils/-/pluginutils-5.1.4.tgz#bb94f1f9eaaac944da237767cdfee6c5b2262d4a" + integrity sha512-USm05zrsFxYLPdWWq+K3STlWiT/3ELn3RcV5hJMghpeAIhxfsUIg6mt12CBJBInWMV4VneoV7SfGv8xIwo2qNQ== + dependencies: + "@types/estree" "^1.0.0" + estree-walker "^2.0.2" + picomatch "^4.0.2" + +"@rollup/rollup-android-arm-eabi@4.41.1": + version "4.41.1" + resolved "https://registry.yarnpkg.com/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.41.1.tgz#f39f09f60d4a562de727c960d7b202a2cf797424" + integrity sha512-NELNvyEWZ6R9QMkiytB4/L4zSEaBC03KIXEghptLGLZWJ6VPrL63ooZQCOnlx36aQPGhzuOMwDerC1Eb2VmrLw== + +"@rollup/rollup-android-arm64@4.41.1": + version "4.41.1" + resolved "https://registry.yarnpkg.com/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.41.1.tgz#d19af7e23760717f1d879d4ca3d2cd247742dff2" + integrity sha512-DXdQe1BJ6TK47ukAoZLehRHhfKnKg9BjnQYUu9gzhI8Mwa1d2fzxA1aw2JixHVl403bwp1+/o/NhhHtxWJBgEA== + +"@rollup/rollup-darwin-arm64@4.41.1": + version "4.41.1" + resolved "https://registry.yarnpkg.com/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.41.1.tgz#1c3a2fbf205d80641728e05f4a56c909e95218b7" + integrity sha512-5afxvwszzdulsU2w8JKWwY8/sJOLPzf0e1bFuvcW5h9zsEg+RQAojdW0ux2zyYAz7R8HvvzKCjLNJhVq965U7w== + +"@rollup/rollup-darwin-x64@4.41.1": + version "4.41.1" + resolved "https://registry.yarnpkg.com/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.41.1.tgz#aa66d2ba1a25e609500e13bef06dc0e71cc0c0d4" + integrity sha512-egpJACny8QOdHNNMZKf8xY0Is6gIMz+tuqXlusxquWu3F833DcMwmGM7WlvCO9sB3OsPjdC4U0wHw5FabzCGZg== + +"@rollup/rollup-freebsd-arm64@4.41.1": + version "4.41.1" + resolved "https://registry.yarnpkg.com/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.41.1.tgz#df10a7b6316a0ef1028c6ca71a081124c537e30d" + integrity sha512-DBVMZH5vbjgRk3r0OzgjS38z+atlupJ7xfKIDJdZZL6sM6wjfDNo64aowcLPKIx7LMQi8vybB56uh1Ftck/Atg== + +"@rollup/rollup-freebsd-x64@4.41.1": + version "4.41.1" + resolved "https://registry.yarnpkg.com/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.41.1.tgz#a3fdce8a05e95b068cbcb46e4df5185e407d0c35" + integrity sha512-3FkydeohozEskBxNWEIbPfOE0aqQgB6ttTkJ159uWOFn42VLyfAiyD9UK5mhu+ItWzft60DycIN1Xdgiy8o/SA== + +"@rollup/rollup-linux-arm-gnueabihf@4.41.1": + version "4.41.1" + resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.41.1.tgz#49f766c55383bd0498014a9d76924348c2f3890c" + integrity sha512-wC53ZNDgt0pqx5xCAgNunkTzFE8GTgdZ9EwYGVcg+jEjJdZGtq9xPjDnFgfFozQI/Xm1mh+D9YlYtl+ueswNEg== + +"@rollup/rollup-linux-arm-musleabihf@4.41.1": + version "4.41.1" + resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.41.1.tgz#1d4d7d32fc557e17d52e1857817381ea365e2959" + integrity sha512-jwKCca1gbZkZLhLRtsrka5N8sFAaxrGz/7wRJ8Wwvq3jug7toO21vWlViihG85ei7uJTpzbXZRcORotE+xyrLA== + +"@rollup/rollup-linux-arm64-gnu@4.41.1": + version "4.41.1" + resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.41.1.tgz#f4fc317268441e9589edad3be8f62b6c03009bc1" + integrity sha512-g0UBcNknsmmNQ8V2d/zD2P7WWfJKU0F1nu0k5pW4rvdb+BIqMm8ToluW/eeRmxCared5dD76lS04uL4UaNgpNA== + +"@rollup/rollup-linux-arm64-musl@4.41.1": + version "4.41.1" + resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.41.1.tgz#63a1f1b0671cb17822dabae827fef0e443aebeb7" + integrity sha512-XZpeGB5TKEZWzIrj7sXr+BEaSgo/ma/kCgrZgL0oo5qdB1JlTzIYQKel/RmhT6vMAvOdM2teYlAaOGJpJ9lahg== + +"@rollup/rollup-linux-loongarch64-gnu@4.41.1": + version "4.41.1" + resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-loongarch64-gnu/-/rollup-linux-loongarch64-gnu-4.41.1.tgz#c659b01cc6c0730b547571fc3973e1e955369f98" + integrity sha512-bkCfDJ4qzWfFRCNt5RVV4DOw6KEgFTUZi2r2RuYhGWC8WhCA8lCAJhDeAmrM/fdiAH54m0mA0Vk2FGRPyzI+tw== + +"@rollup/rollup-linux-powerpc64le-gnu@4.41.1": + version "4.41.1" + resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-powerpc64le-gnu/-/rollup-linux-powerpc64le-gnu-4.41.1.tgz#612e746f9ad7e58480f964d65e0d6c3f4aae69a8" + integrity sha512-3mr3Xm+gvMX+/8EKogIZSIEF0WUu0HL9di+YWlJpO8CQBnoLAEL/roTCxuLncEdgcfJcvA4UMOf+2dnjl4Ut1A== + +"@rollup/rollup-linux-riscv64-gnu@4.41.1": + version "4.41.1" + resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.41.1.tgz#4610dbd1dcfbbae32fbc10c20ae7387acb31110c" + integrity sha512-3rwCIh6MQ1LGrvKJitQjZFuQnT2wxfU+ivhNBzmxXTXPllewOF7JR1s2vMX/tWtUYFgphygxjqMl76q4aMotGw== + +"@rollup/rollup-linux-riscv64-musl@4.41.1": + version "4.41.1" + resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.41.1.tgz#054911fab40dc83fafc21e470193c058108f19d8" + integrity sha512-LdIUOb3gvfmpkgFZuccNa2uYiqtgZAz3PTzjuM5bH3nvuy9ty6RGc/Q0+HDFrHrizJGVpjnTZ1yS5TNNjFlklw== + +"@rollup/rollup-linux-s390x-gnu@4.41.1": + version "4.41.1" + resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.41.1.tgz#98896eca8012547c7f04bd07eaa6896825f9e1a5" + integrity sha512-oIE6M8WC9ma6xYqjvPhzZYk6NbobIURvP/lEbh7FWplcMO6gn7MM2yHKA1eC/GvYwzNKK/1LYgqzdkZ8YFxR8g== + +"@rollup/rollup-linux-x64-gnu@4.41.1": + version "4.41.1" + resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.41.1.tgz#01cf56844a1e636ee80dfb364e72c2b7142ad896" + integrity sha512-cWBOvayNvA+SyeQMp79BHPK8ws6sHSsYnK5zDcsC3Hsxr1dgTABKjMnMslPq1DvZIp6uO7kIWhiGwaTdR4Og9A== + +"@rollup/rollup-linux-x64-musl@4.41.1": + version "4.41.1" + resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.41.1.tgz#e67c7531df6dff0b4c241101d4096617fbca87c3" + integrity sha512-y5CbN44M+pUCdGDlZFzGGBSKCA4A/J2ZH4edTYSSxFg7ce1Xt3GtydbVKWLlzL+INfFIZAEg1ZV6hh9+QQf9YQ== + +"@rollup/rollup-win32-arm64-msvc@4.41.1": + version "4.41.1" + resolved "https://registry.yarnpkg.com/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.41.1.tgz#7eeada98444e580674de6989284e4baacd48ea65" + integrity sha512-lZkCxIrjlJlMt1dLO/FbpZbzt6J/A8p4DnqzSa4PWqPEUUUnzXLeki/iyPLfV0BmHItlYgHUqJe+3KiyydmiNQ== + +"@rollup/rollup-win32-ia32-msvc@4.41.1": + version "4.41.1" + resolved "https://registry.yarnpkg.com/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.41.1.tgz#516c4b54f80587b4a390aaf4940b40870271d35d" + integrity sha512-+psFT9+pIh2iuGsxFYYa/LhS5MFKmuivRsx9iPJWNSGbh2XVEjk90fmpUEjCnILPEPJnikAU6SFDiEUyOv90Pg== + +"@rollup/rollup-win32-x64-msvc@4.41.1": + version "4.41.1" + resolved "https://registry.yarnpkg.com/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.41.1.tgz#848f99b0d9936d92221bb6070baeff4db6947a30" + integrity sha512-Wq2zpapRYLfi4aKxf2Xff0tN+7slj2d4R87WEzqw7ZLsVvO5zwYCIuEGSZYiK41+GlwUo1HiR+GdkLEJnCKTCw== + +"@rushstack/node-core-library@5.13.1": + version "5.13.1" + resolved "https://registry.yarnpkg.com/@rushstack/node-core-library/-/node-core-library-5.13.1.tgz#e56b915ecb08b5a92711acac6b233417353a32dc" + integrity sha512-5yXhzPFGEkVc9Fu92wsNJ9jlvdwz4RNb2bMso+/+TH0nMm1jDDDsOIf4l8GAkPxGuwPw5DH24RliWVfSPhlW/Q== + dependencies: + ajv "~8.13.0" + ajv-draft-04 "~1.0.0" + ajv-formats "~3.0.1" + fs-extra "~11.3.0" + import-lazy "~4.0.0" + jju "~1.4.0" + resolve "~1.22.1" + semver "~7.5.4" + +"@rushstack/rig-package@0.5.3": + version "0.5.3" + resolved "https://registry.yarnpkg.com/@rushstack/rig-package/-/rig-package-0.5.3.tgz#ea4d8a3458540b1295500149c04e645f23134e5d" + integrity sha512-olzSSjYrvCNxUFZowevC3uz8gvKr3WTpHQ7BkpjtRpA3wK+T0ybep/SRUMfr195gBzJm5gaXw0ZMgjIyHqJUow== + dependencies: + resolve "~1.22.1" + strip-json-comments "~3.1.1" + +"@rushstack/terminal@0.15.3": + version "0.15.3" + resolved "https://registry.yarnpkg.com/@rushstack/terminal/-/terminal-0.15.3.tgz#365e0ae5ac73bb4883b096ae36c5011f52911861" + integrity sha512-DGJ0B2Vm69468kZCJkPj3AH5nN+nR9SPmC0rFHtzsS4lBQ7/dgOwtwVxYP7W9JPDMuRBkJ4KHmWKr036eJsj9g== + dependencies: + "@rushstack/node-core-library" "5.13.1" + supports-color "~8.1.1" + +"@rushstack/ts-command-line@5.0.1": + version "5.0.1" + resolved "https://registry.yarnpkg.com/@rushstack/ts-command-line/-/ts-command-line-5.0.1.tgz#e147394b5ce87ef79db95b5b4f155461d6f2c50e" + integrity sha512-bsbUucn41UXrQK7wgM8CNM/jagBytEyJqXw/umtI8d68vFm1Jwxh1OtLrlW7uGZgjCWiiPH6ooUNa1aVsuVr3Q== + dependencies: + "@rushstack/terminal" "0.15.3" + "@types/argparse" "1.0.38" + argparse "~1.0.9" + string-argv "~0.3.1" + +"@types/argparse@1.0.38": + version "1.0.38" + resolved "https://registry.yarnpkg.com/@types/argparse/-/argparse-1.0.38.tgz#a81fd8606d481f873a3800c6ebae4f1d768a56a9" + integrity sha512-ebDJ9b0e702Yr7pWgB0jzm+CX4Srzz8RcXtLJDJB+BSccqMa36uyH/zUsSYao5+BD1ytv3k3rPYCq4mAE1hsXA== + +"@types/estree@1.0.7", "@types/estree@^1.0.0": + version "1.0.7" + resolved "https://registry.yarnpkg.com/@types/estree/-/estree-1.0.7.tgz#4158d3105276773d5b7695cd4834b1722e4f37a8" + integrity sha512-w28IoSUCJpidD/TGviZwwMJckNESJZXFu7NBZ5YJ4mEUnNraUn9Pm8HSZm/jDF1pDWYKspWE7oVphigUPRakIQ== + +"@types/node@22.12.0": + version "22.12.0" + resolved "https://registry.yarnpkg.com/@types/node/-/node-22.12.0.tgz#bf8af3b2af0837b5a62a368756ff2b705ae0048c" + integrity sha512-Fll2FZ1riMjNmlmJOdAyY5pUbkftXslB5DgEzlIuNaiWhXd00FhWxVC/r4yV/4wBb9JfImTu+jiSvXTkJ7F/gA== + dependencies: + undici-types "~6.20.0" + +"@volar/language-core@2.4.14", "@volar/language-core@~2.4.11": + version "2.4.14" + resolved "https://registry.yarnpkg.com/@volar/language-core/-/language-core-2.4.14.tgz#dac7573014d4f3bafb186cb16888ffea5698be71" + integrity sha512-X6beusV0DvuVseaOEy7GoagS4rYHgDHnTrdOj5jeUb49fW5ceQyP9Ej5rBhqgz2wJggl+2fDbbojq1XKaxDi6w== + dependencies: + "@volar/source-map" "2.4.14" + +"@volar/source-map@2.4.14": + version "2.4.14" + resolved "https://registry.yarnpkg.com/@volar/source-map/-/source-map-2.4.14.tgz#cdcecd533c2e767449b2414cc22327d2bda7ef95" + integrity sha512-5TeKKMh7Sfxo8021cJfmBzcjfY1SsXsPMMjMvjY7ivesdnybqqS+GxGAoXHAOUawQTwtdUxgP65Im+dEmvWtYQ== + +"@volar/typescript@^2.4.11": + version "2.4.14" + resolved "https://registry.yarnpkg.com/@volar/typescript/-/typescript-2.4.14.tgz#b99a1025dd6a8b751e96627ebcb0739ceed0e5f1" + integrity sha512-p8Z6f/bZM3/HyCdRNFZOEEzts51uV8WHeN8Tnfnm2EBv6FDB2TQLzfVx7aJvnl8ofKAOnS64B2O8bImBFaauRw== + dependencies: + "@volar/language-core" "2.4.14" + path-browserify "^1.0.1" + vscode-uri "^3.0.8" + +"@vue/compiler-core@3.5.16": + version "3.5.16" + resolved "https://registry.yarnpkg.com/@vue/compiler-core/-/compiler-core-3.5.16.tgz#2f95f4f17c16c09c57bbf64399075b921506630b" + integrity sha512-AOQS2eaQOaaZQoL1u+2rCJIKDruNXVBZSiUD3chnUrsoX5ZTQMaCvXlWNIfxBJuU15r1o7+mpo5223KVtIhAgQ== + dependencies: + "@babel/parser" "^7.27.2" + "@vue/shared" "3.5.16" + entities "^4.5.0" + estree-walker "^2.0.2" + source-map-js "^1.2.1" + +"@vue/compiler-dom@^3.5.0": + version "3.5.16" + resolved "https://registry.yarnpkg.com/@vue/compiler-dom/-/compiler-dom-3.5.16.tgz#151d8390252975c0b1a773029220fdfcfaa2d743" + integrity sha512-SSJIhBr/teipXiXjmWOVWLnxjNGo65Oj/8wTEQz0nqwQeP75jWZ0n4sF24Zxoht1cuJoWopwj0J0exYwCJ0dCQ== + dependencies: + "@vue/compiler-core" "3.5.16" + "@vue/shared" "3.5.16" + +"@vue/compiler-vue2@^2.7.16": + version "2.7.16" + resolved "https://registry.yarnpkg.com/@vue/compiler-vue2/-/compiler-vue2-2.7.16.tgz#2ba837cbd3f1b33c2bc865fbe1a3b53fb611e249" + integrity sha512-qYC3Psj9S/mfu9uVi5WvNZIzq+xnXMhOwbTFKKDD7b1lhpnn71jXSFdTQ+WsIEk0ONCd7VV2IMm7ONl6tbQ86A== + dependencies: + de-indent "^1.0.2" + he "^1.2.0" + +"@vue/language-core@2.2.0": + version "2.2.0" + resolved "https://registry.yarnpkg.com/@vue/language-core/-/language-core-2.2.0.tgz#e48c54584f889f78b120ce10a050dfb316c7fcdf" + integrity sha512-O1ZZFaaBGkKbsRfnVH1ifOK1/1BUkyK+3SQsfnh6PmMmD4qJcTU8godCeA96jjDRTL6zgnK7YzCHfaUlH2r0Mw== + dependencies: + "@volar/language-core" "~2.4.11" + "@vue/compiler-dom" "^3.5.0" + "@vue/compiler-vue2" "^2.7.16" + "@vue/shared" "^3.5.0" + alien-signals "^0.4.9" + minimatch "^9.0.3" + muggle-string "^0.4.1" + path-browserify "^1.0.1" + +"@vue/shared@3.5.16", "@vue/shared@^3.5.0": + version "3.5.16" + resolved "https://registry.yarnpkg.com/@vue/shared/-/shared-3.5.16.tgz#d5ea7671182742192938a4b4cbf86ef12bef7418" + integrity sha512-c/0fWy3Jw6Z8L9FmTyYfkpM5zklnqqa9+a6dz3DvONRKW2NEbh46BP0FHuLFSWi2TnQEtp91Z6zOWNrU6QiyPg== + +acorn@^8.14.0: + version "8.14.1" + resolved "https://registry.yarnpkg.com/acorn/-/acorn-8.14.1.tgz#721d5dc10f7d5b5609a891773d47731796935dfb" + integrity sha512-OvQ/2pUDKmgfCg++xsTX1wGxfTaszcHVcTctW4UJB4hibJx2HXxxO5UmVgyjMa+ZDsiaf5wWLXYpRWMmBI0QHg== + +ajv-draft-04@~1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/ajv-draft-04/-/ajv-draft-04-1.0.0.tgz#3b64761b268ba0b9e668f0b41ba53fce0ad77fc8" + integrity sha512-mv00Te6nmYbRp5DCwclxtt7yV/joXJPGS7nM+97GdxvuttCOfgI3K4U25zboyeX0O+myI8ERluxQe5wljMmVIw== + +ajv-formats@~3.0.1: + version "3.0.1" + resolved "https://registry.yarnpkg.com/ajv-formats/-/ajv-formats-3.0.1.tgz#3d5dc762bca17679c3c2ea7e90ad6b7532309578" + integrity sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ== + dependencies: + ajv "^8.0.0" + +ajv@^8.0.0: + version "8.17.1" + resolved "https://registry.yarnpkg.com/ajv/-/ajv-8.17.1.tgz#37d9a5c776af6bc92d7f4f9510eba4c0a60d11a6" + integrity sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g== + dependencies: + fast-deep-equal "^3.1.3" + fast-uri "^3.0.1" + json-schema-traverse "^1.0.0" + require-from-string "^2.0.2" + +ajv@~8.12.0: + version "8.12.0" + resolved "https://registry.yarnpkg.com/ajv/-/ajv-8.12.0.tgz#d1a0527323e22f53562c567c00991577dfbe19d1" + integrity sha512-sRu1kpcO9yLtYxBKvqfTeh9KzZEwO3STyX1HT+4CaDzC6HpTGYhIhPIzj9XuKU7KYDwnaeh5hcOwjy1QuJzBPA== + dependencies: + fast-deep-equal "^3.1.1" + json-schema-traverse "^1.0.0" + require-from-string "^2.0.2" + uri-js "^4.2.2" + +ajv@~8.13.0: + version "8.13.0" + resolved "https://registry.yarnpkg.com/ajv/-/ajv-8.13.0.tgz#a3939eaec9fb80d217ddf0c3376948c023f28c91" + integrity sha512-PRA911Blj99jR5RMeTunVbNXMF6Lp4vZXnk5GQjcnUWUTsrXtekg/pnmFFI2u/I36Y/2bITGS30GZCXei6uNkA== + dependencies: + fast-deep-equal "^3.1.3" + json-schema-traverse "^1.0.0" + require-from-string "^2.0.2" + uri-js "^4.4.1" + +alien-signals@^0.4.9: + version "0.4.14" + resolved "https://registry.yarnpkg.com/alien-signals/-/alien-signals-0.4.14.tgz#9ff8f72a272300a51692f54bd9bbbada78fbf539" + integrity sha512-itUAVzhczTmP2U5yX67xVpsbbOiquusbWVyA9N+sy6+r6YVbFkahXvNCeEPWEOMhwDYwbVbGHFkVL03N9I5g+Q== + +argparse@~1.0.9: + version "1.0.10" + resolved "https://registry.yarnpkg.com/argparse/-/argparse-1.0.10.tgz#bcd6791ea5ae09725e17e5ad988134cd40b3d911" + integrity sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg== + dependencies: + sprintf-js "~1.0.2" + +balanced-match@^1.0.0: + version "1.0.2" + resolved "https://registry.yarnpkg.com/balanced-match/-/balanced-match-1.0.2.tgz#e83e3a7e3f300b34cb9d87f615fa0cbf357690ee" + integrity sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw== + +brace-expansion@^1.1.7: + version "1.1.11" + resolved "https://registry.yarnpkg.com/brace-expansion/-/brace-expansion-1.1.11.tgz#3c7fcbf529d87226f3d2f52b966ff5271eb441dd" + integrity sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA== + dependencies: + balanced-match "^1.0.0" + concat-map "0.0.1" + +brace-expansion@^2.0.1: + version "2.0.1" + resolved "https://registry.yarnpkg.com/brace-expansion/-/brace-expansion-2.0.1.tgz#1edc459e0f0c548486ecf9fc99f2221364b9a0ae" + integrity sha512-XnAIvQ8eM+kC6aULx6wuQiwVsnzsi9d3WxzV3FpWTGA19F621kwdbsAcFKXgKUHZWsy+mY6iL1sHTxWEFCytDA== + dependencies: + balanced-match "^1.0.0" + +compare-versions@^6.1.1: + version "6.1.1" + resolved "https://registry.yarnpkg.com/compare-versions/-/compare-versions-6.1.1.tgz#7af3cc1099ba37d244b3145a9af5201b629148a9" + integrity sha512-4hm4VPpIecmlg59CHXnRDnqGplJFrbLG4aFEl5vl6cK1u76ws3LLvX7ikFnTDl5vo39sjWD6AaDPYodJp/NNHg== + +concat-map@0.0.1: + version "0.0.1" + resolved "https://registry.yarnpkg.com/concat-map/-/concat-map-0.0.1.tgz#d8a96bd77fd68df7793a73036a3ba0d5405d477b" + integrity sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg== + +confbox@^0.1.8: + version "0.1.8" + resolved "https://registry.yarnpkg.com/confbox/-/confbox-0.1.8.tgz#820d73d3b3c82d9bd910652c5d4d599ef8ff8b06" + integrity sha512-RMtmw0iFkeR4YV+fUOSucriAQNb9g8zFR52MWCtl+cCZOFRNL6zeB395vPzFhEjjn4fMxXudmELnl/KF/WrK6w== + +confbox@^0.2.1: + version "0.2.2" + resolved "https://registry.yarnpkg.com/confbox/-/confbox-0.2.2.tgz#8652f53961c74d9e081784beed78555974a9c110" + integrity sha512-1NB+BKqhtNipMsov4xI/NnhCKp9XG9NamYp5PVm9klAT0fsrNPjaFICsCFhNhwZJKNh7zB/3q8qXz0E9oaMNtQ== + +de-indent@^1.0.2: + version "1.0.2" + resolved "https://registry.yarnpkg.com/de-indent/-/de-indent-1.0.2.tgz#b2038e846dc33baa5796128d0804b455b8c1e21d" + integrity sha512-e/1zu3xH5MQryN2zdVaF0OrdNLUbvWxzMbi+iNA6Bky7l1RoP8a2fIbRocyHclXt/arDrrR6lL3TqFD9pMQTsg== + +debug@^4.4.0: + version "4.4.1" + resolved "https://registry.yarnpkg.com/debug/-/debug-4.4.1.tgz#e5a8bc6cbc4c6cd3e64308b0693a3d4fa550189b" + integrity sha512-KcKCqiftBJcZr++7ykoDIEwSa3XWowTfNPo92BYxjXiyYEVrUQh2aLyhxBCwww+heortUFxEJYcRzosstTEBYQ== + dependencies: + ms "^2.1.3" + +entities@^4.5.0: + version "4.5.0" + resolved "https://registry.yarnpkg.com/entities/-/entities-4.5.0.tgz#5d268ea5e7113ec74c4d033b79ea5a35a488fb48" + integrity sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw== + +esbuild@^0.25.0: + version "0.25.5" + resolved "https://registry.yarnpkg.com/esbuild/-/esbuild-0.25.5.tgz#71075054993fdfae76c66586f9b9c1f8d7edd430" + integrity sha512-P8OtKZRv/5J5hhz0cUAdu/cLuPIKXpQl1R9pZtvmHWQvrAUVd0UNIPT4IB4W3rNOqVO0rlqHmCIbSwxh/c9yUQ== + optionalDependencies: + "@esbuild/aix-ppc64" "0.25.5" + "@esbuild/android-arm" "0.25.5" + "@esbuild/android-arm64" "0.25.5" + "@esbuild/android-x64" "0.25.5" + "@esbuild/darwin-arm64" "0.25.5" + "@esbuild/darwin-x64" "0.25.5" + "@esbuild/freebsd-arm64" "0.25.5" + "@esbuild/freebsd-x64" "0.25.5" + "@esbuild/linux-arm" "0.25.5" + "@esbuild/linux-arm64" "0.25.5" + "@esbuild/linux-ia32" "0.25.5" + "@esbuild/linux-loong64" "0.25.5" + "@esbuild/linux-mips64el" "0.25.5" + "@esbuild/linux-ppc64" "0.25.5" + "@esbuild/linux-riscv64" "0.25.5" + "@esbuild/linux-s390x" "0.25.5" + "@esbuild/linux-x64" "0.25.5" + "@esbuild/netbsd-arm64" "0.25.5" + "@esbuild/netbsd-x64" "0.25.5" + "@esbuild/openbsd-arm64" "0.25.5" + "@esbuild/openbsd-x64" "0.25.5" + "@esbuild/sunos-x64" "0.25.5" + "@esbuild/win32-arm64" "0.25.5" + "@esbuild/win32-ia32" "0.25.5" + "@esbuild/win32-x64" "0.25.5" + +estree-walker@^2.0.2: + version "2.0.2" + resolved "https://registry.yarnpkg.com/estree-walker/-/estree-walker-2.0.2.tgz#52f010178c2a4c117a7757cfe942adb7d2da4cac" + integrity sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w== + +exsolve@^1.0.1: + version "1.0.5" + resolved "https://registry.yarnpkg.com/exsolve/-/exsolve-1.0.5.tgz#1f5b6b4fe82ad6b28a173ccb955a635d77859dcf" + integrity sha512-pz5dvkYYKQ1AHVrgOzBKWeP4u4FRb3a6DNK2ucr0OoNwYIU4QWsJ+NM36LLzORT+z845MzKHHhpXiUF5nvQoJg== + +fast-deep-equal@^3.1.1, fast-deep-equal@^3.1.3: + version "3.1.3" + resolved "https://registry.yarnpkg.com/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz#3a7d56b559d6cbc3eb512325244e619a65c6c525" + integrity sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q== + +fast-uri@^3.0.1: + version "3.0.6" + resolved "https://registry.yarnpkg.com/fast-uri/-/fast-uri-3.0.6.tgz#88f130b77cfaea2378d56bf970dea21257a68748" + integrity sha512-Atfo14OibSv5wAp4VWNsFYE1AchQRTv9cBGWET4pZWHzYshFSS9NQI6I57rdKn9croWVMbYFbLhJ+yJvmZIIHw== + +fdir@^6.4.4: + version "6.4.5" + resolved "https://registry.yarnpkg.com/fdir/-/fdir-6.4.5.tgz#328e280f3a23699362f95f2e82acf978a0c0cb49" + integrity sha512-4BG7puHpVsIYxZUbiUE3RqGloLaSSwzYie5jvasC4LWuBWzZawynvYouhjbQKw2JuIGYdm0DzIxl8iVidKlUEw== + +fs-extra@~11.3.0: + version "11.3.0" + resolved "https://registry.yarnpkg.com/fs-extra/-/fs-extra-11.3.0.tgz#0daced136bbaf65a555a326719af931adc7a314d" + integrity sha512-Z4XaCL6dUDHfP/jT25jJKMmtxvuwbkrD1vNSMFlo9lNLY2c5FHYSQgHPRZUjAB26TpDEoW9HCOgplrdbaPV/ew== + dependencies: + graceful-fs "^4.2.0" + jsonfile "^6.0.1" + universalify "^2.0.0" + +fsevents@~2.3.2, fsevents@~2.3.3: + version "2.3.3" + resolved "https://registry.yarnpkg.com/fsevents/-/fsevents-2.3.3.tgz#cac6407785d03675a2a5e1a5305c697b347d90d6" + integrity sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw== + +function-bind@^1.1.2: + version "1.1.2" + resolved "https://registry.yarnpkg.com/function-bind/-/function-bind-1.1.2.tgz#2c02d864d97f3ea6c8830c464cbd11ab6eab7a1c" + integrity sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA== + +graceful-fs@^4.1.6, graceful-fs@^4.2.0: + version "4.2.11" + resolved "https://registry.yarnpkg.com/graceful-fs/-/graceful-fs-4.2.11.tgz#4183e4e8bf08bb6e05bbb2f7d2e0c8f712ca40e3" + integrity sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ== + +has-flag@^4.0.0: + version "4.0.0" + resolved "https://registry.yarnpkg.com/has-flag/-/has-flag-4.0.0.tgz#944771fd9c81c81265c4d6941860da06bb59479b" + integrity sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ== + +hasown@^2.0.2: + version "2.0.2" + resolved "https://registry.yarnpkg.com/hasown/-/hasown-2.0.2.tgz#003eaf91be7adc372e84ec59dc37252cedb80003" + integrity sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ== + dependencies: + function-bind "^1.1.2" + +he@^1.2.0: + version "1.2.0" + resolved "https://registry.yarnpkg.com/he/-/he-1.2.0.tgz#84ae65fa7eafb165fddb61566ae14baf05664f0f" + integrity sha512-F/1DnUGPopORZi0ni+CvrCgHQ5FyEAHRLSApuYWMmrbSwoN2Mn/7k+Gl38gJnR7yyDZk6WLXwiGod1JOWNDKGw== + +import-lazy@~4.0.0: + version "4.0.0" + resolved "https://registry.yarnpkg.com/import-lazy/-/import-lazy-4.0.0.tgz#e8eb627483a0a43da3c03f3e35548be5cb0cc153" + integrity sha512-rKtvo6a868b5Hu3heneU+L4yEQ4jYKLtjpnPeUdK7h0yzXGmyBTypknlkCvHFBqfX9YlorEiMM6Dnq/5atfHkw== + +is-core-module@^2.16.0: + version "2.16.1" + resolved "https://registry.yarnpkg.com/is-core-module/-/is-core-module-2.16.1.tgz#2a98801a849f43e2add644fbb6bc6229b19a4ef4" + integrity sha512-UfoeMA6fIJ8wTYFEUjelnaGI67v6+N7qXJEvQuIGa99l4xsCruSYOVSQ0uPANn4dAzm8lkYPaKLrrijLq7x23w== + dependencies: + hasown "^2.0.2" + +jju@~1.4.0: + version "1.4.0" + resolved "https://registry.yarnpkg.com/jju/-/jju-1.4.0.tgz#a3abe2718af241a2b2904f84a625970f389ae32a" + integrity sha512-8wb9Yw966OSxApiCt0K3yNJL8pnNeIv+OEq2YMidz4FKP6nonSRoOXc80iXY4JaN2FC11B9qsNmDsm+ZOfMROA== + +json-schema-traverse@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz#ae7bcb3656ab77a73ba5c49bf654f38e6b6860e2" + integrity sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug== + +jsonfile@^6.0.1: + version "6.1.0" + resolved "https://registry.yarnpkg.com/jsonfile/-/jsonfile-6.1.0.tgz#bc55b2634793c679ec6403094eb13698a6ec0aae" + integrity sha512-5dgndWOriYSm5cnYaJNhalLNDKOqFwyDB/rr1E9ZsGciGvKPs8R2xYGCacuf3z6K1YKDz182fd+fY3cn3pMqXQ== + dependencies: + universalify "^2.0.0" + optionalDependencies: + graceful-fs "^4.1.6" + +kolorist@^1.8.0: + version "1.8.0" + resolved "https://registry.yarnpkg.com/kolorist/-/kolorist-1.8.0.tgz#edddbbbc7894bc13302cdf740af6374d4a04743c" + integrity sha512-Y+60/zizpJ3HRH8DCss+q95yr6145JXZo46OTpFvDZWLfRCE4qChOyk1b26nMaNpfHHgxagk9dXT5OP0Tfe+dQ== + +local-pkg@^1.0.0: + version "1.1.1" + resolved "https://registry.yarnpkg.com/local-pkg/-/local-pkg-1.1.1.tgz#f5fe74a97a3bd3c165788ee08ca9fbe998dc58dd" + integrity sha512-WunYko2W1NcdfAFpuLUoucsgULmgDBRkdxHxWQ7mK0cQqwPiy8E1enjuRBrhLtZkB5iScJ1XIPdhVEFK8aOLSg== + dependencies: + mlly "^1.7.4" + pkg-types "^2.0.1" + quansync "^0.2.8" + +lodash@~4.17.15: + version "4.17.21" + resolved "https://registry.yarnpkg.com/lodash/-/lodash-4.17.21.tgz#679591c564c3bffaae8454cf0b3df370c3d6911c" + integrity sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg== + +lru-cache@^6.0.0: + version "6.0.0" + resolved "https://registry.yarnpkg.com/lru-cache/-/lru-cache-6.0.0.tgz#6d6fe6570ebd96aaf90fcad1dafa3b2566db3a94" + integrity sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA== + dependencies: + yallist "^4.0.0" + +magic-string@^0.30.17: + version "0.30.17" + resolved "https://registry.yarnpkg.com/magic-string/-/magic-string-0.30.17.tgz#450a449673d2460e5bbcfba9a61916a1714c7453" + integrity sha512-sNPKHvyjVf7gyjwS4xGTaW/mCnF8wnjtifKBEhxfZ7E/S8tQ0rssrwGNn6q8JH/ohItJfSQp9mBtQYuTlH5QnA== + dependencies: + "@jridgewell/sourcemap-codec" "^1.5.0" + +minimatch@^9.0.3: + version "9.0.5" + resolved "https://registry.yarnpkg.com/minimatch/-/minimatch-9.0.5.tgz#d74f9dd6b57d83d8e98cfb82133b03978bc929e5" + integrity sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow== + dependencies: + brace-expansion "^2.0.1" + +minimatch@~3.0.3: + version "3.0.8" + resolved "https://registry.yarnpkg.com/minimatch/-/minimatch-3.0.8.tgz#5e6a59bd11e2ab0de1cfb843eb2d82e546c321c1" + integrity sha512-6FsRAQsxQ61mw+qP1ZzbL9Bc78x2p5OqNgNpnoAFLTrX8n5Kxph0CsnhmKKNXTWjXqU5L0pGPR7hYk+XWZr60Q== + dependencies: + brace-expansion "^1.1.7" + +mlly@^1.7.4: + version "1.7.4" + resolved "https://registry.yarnpkg.com/mlly/-/mlly-1.7.4.tgz#3d7295ea2358ec7a271eaa5d000a0f84febe100f" + integrity sha512-qmdSIPC4bDJXgZTCR7XosJiNKySV7O215tsPtDN9iEO/7q/76b/ijtgRu/+epFXSJhijtTCCGp3DWS549P3xKw== + dependencies: + acorn "^8.14.0" + pathe "^2.0.1" + pkg-types "^1.3.0" + ufo "^1.5.4" + +ms@^2.1.3: + version "2.1.3" + resolved "https://registry.yarnpkg.com/ms/-/ms-2.1.3.tgz#574c8138ce1d2b5861f0b44579dbadd60c6615b2" + integrity sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA== + +muggle-string@^0.4.1: + version "0.4.1" + resolved "https://registry.yarnpkg.com/muggle-string/-/muggle-string-0.4.1.tgz#3b366bd43b32f809dc20659534dd30e7c8a0d328" + integrity sha512-VNTrAak/KhO2i8dqqnqnAHOa3cYBwXEZe9h+D5h/1ZqFSTEFHdM65lR7RoIqq3tBBYavsOXV84NoHXZ0AkPyqQ== + +nanoid@^3.3.11: + version "3.3.11" + resolved "https://registry.yarnpkg.com/nanoid/-/nanoid-3.3.11.tgz#4f4f112cefbe303202f2199838128936266d185b" + integrity sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w== + +path-browserify@^1.0.1: + version "1.0.1" + resolved "https://registry.yarnpkg.com/path-browserify/-/path-browserify-1.0.1.tgz#d98454a9c3753d5790860f16f68867b9e46be1fd" + integrity sha512-b7uo2UCUOYZcnF/3ID0lulOJi/bafxa1xPe7ZPsammBSpjSWQkjNxlt635YGS2MiR9GjvuXCtz2emr3jbsz98g== + +path-parse@^1.0.7: + version "1.0.7" + resolved "https://registry.yarnpkg.com/path-parse/-/path-parse-1.0.7.tgz#fbc114b60ca42b30d9daf5858e4bd68bbedb6735" + integrity sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw== + +pathe@^2.0.1, pathe@^2.0.3: + version "2.0.3" + resolved "https://registry.yarnpkg.com/pathe/-/pathe-2.0.3.tgz#3ecbec55421685b70a9da872b2cff3e1cbed1716" + integrity sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w== + +picocolors@^1.1.1: + version "1.1.1" + resolved "https://registry.yarnpkg.com/picocolors/-/picocolors-1.1.1.tgz#3d321af3eab939b083c8f929a1d12cda81c26b6b" + integrity sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA== + +picomatch@^4.0.2: + version "4.0.2" + resolved "https://registry.yarnpkg.com/picomatch/-/picomatch-4.0.2.tgz#77c742931e8f3b8820946c76cd0c1f13730d1dab" + integrity sha512-M7BAV6Rlcy5u+m6oPhAPFgJTzAioX/6B0DxyvDlo9l8+T3nLKbrczg2WLUyzd45L8RqfUMyGPzekbMvX2Ldkwg== + +pkg-types@^1.3.0: + version "1.3.1" + resolved "https://registry.yarnpkg.com/pkg-types/-/pkg-types-1.3.1.tgz#bd7cc70881192777eef5326c19deb46e890917df" + integrity sha512-/Jm5M4RvtBFVkKWRu2BLUTNP8/M2a+UwuAX+ae4770q1qVGtfjG+WTCupoZixokjmHiry8uI+dlY8KXYV5HVVQ== + dependencies: + confbox "^0.1.8" + mlly "^1.7.4" + pathe "^2.0.1" + +pkg-types@^2.0.1: + version "2.1.0" + resolved "https://registry.yarnpkg.com/pkg-types/-/pkg-types-2.1.0.tgz#70c9e1b9c74b63fdde749876ee0aa007ea9edead" + integrity sha512-wmJwA+8ihJixSoHKxZJRBQG1oY8Yr9pGLzRmSsNms0iNWyHHAlZCa7mmKiFR10YPZuz/2k169JiS/inOjBCZ2A== + dependencies: + confbox "^0.2.1" + exsolve "^1.0.1" + pathe "^2.0.3" + +postcss@^8.5.3: + version "8.5.4" + resolved "https://registry.yarnpkg.com/postcss/-/postcss-8.5.4.tgz#d61014ac00e11d5f58458ed7247d899bd65f99c0" + integrity sha512-QSa9EBe+uwlGTFmHsPKokv3B/oEMQZxfqW0QqNCyhpa6mB1afzulwn8hihglqAb2pOw+BJgNlmXQ8la2VeHB7w== + dependencies: + nanoid "^3.3.11" + picocolors "^1.1.1" + source-map-js "^1.2.1" + +punycode@^2.1.0: + version "2.3.1" + resolved "https://registry.yarnpkg.com/punycode/-/punycode-2.3.1.tgz#027422e2faec0b25e1549c3e1bd8309b9133b6e5" + integrity sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg== + +quansync@^0.2.8: + version "0.2.10" + resolved "https://registry.yarnpkg.com/quansync/-/quansync-0.2.10.tgz#32053cf166fa36511aae95fc49796116f2dc20e1" + integrity sha512-t41VRkMYbkHyCYmOvx/6URnN80H7k4X0lLdBMGsz+maAwrJQYB1djpV6vHrQIBE0WBSGqhtEHrK9U3DWWH8v7A== + +require-from-string@^2.0.2: + version "2.0.2" + resolved "https://registry.yarnpkg.com/require-from-string/-/require-from-string-2.0.2.tgz#89a7fdd938261267318eafe14f9c32e598c36909" + integrity sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw== + +resolve@~1.22.1, resolve@~1.22.2: + version "1.22.10" + resolved "https://registry.yarnpkg.com/resolve/-/resolve-1.22.10.tgz#b663e83ffb09bbf2386944736baae803029b8b39" + integrity sha512-NPRy+/ncIMeDlTAsuqwKIiferiawhefFJtkNSW0qZJEqMEb+qBt/77B/jGeeek+F0uOeN05CDa6HXbbIgtVX4w== + dependencies: + is-core-module "^2.16.0" + path-parse "^1.0.7" + supports-preserve-symlinks-flag "^1.0.0" + +rollup@^4.34.9: + version "4.41.1" + resolved "https://registry.yarnpkg.com/rollup/-/rollup-4.41.1.tgz#46ddc1b33cf1b0baa99320d3b0b4973dc2253b6a" + integrity sha512-cPmwD3FnFv8rKMBc1MxWCwVQFxwf1JEmSX3iQXrRVVG15zerAIXRjMFVWnd5Q5QvgKF7Aj+5ykXFhUl+QGnyOw== + dependencies: + "@types/estree" "1.0.7" + optionalDependencies: + "@rollup/rollup-android-arm-eabi" "4.41.1" + "@rollup/rollup-android-arm64" "4.41.1" + "@rollup/rollup-darwin-arm64" "4.41.1" + "@rollup/rollup-darwin-x64" "4.41.1" + "@rollup/rollup-freebsd-arm64" "4.41.1" + "@rollup/rollup-freebsd-x64" "4.41.1" + "@rollup/rollup-linux-arm-gnueabihf" "4.41.1" + "@rollup/rollup-linux-arm-musleabihf" "4.41.1" + "@rollup/rollup-linux-arm64-gnu" "4.41.1" + "@rollup/rollup-linux-arm64-musl" "4.41.1" + "@rollup/rollup-linux-loongarch64-gnu" "4.41.1" + "@rollup/rollup-linux-powerpc64le-gnu" "4.41.1" + "@rollup/rollup-linux-riscv64-gnu" "4.41.1" + "@rollup/rollup-linux-riscv64-musl" "4.41.1" + "@rollup/rollup-linux-s390x-gnu" "4.41.1" + "@rollup/rollup-linux-x64-gnu" "4.41.1" + "@rollup/rollup-linux-x64-musl" "4.41.1" + "@rollup/rollup-win32-arm64-msvc" "4.41.1" + "@rollup/rollup-win32-ia32-msvc" "4.41.1" + "@rollup/rollup-win32-x64-msvc" "4.41.1" + fsevents "~2.3.2" + +semver@~7.5.4: + version "7.5.4" + resolved "https://registry.yarnpkg.com/semver/-/semver-7.5.4.tgz#483986ec4ed38e1c6c48c34894a9182dbff68a6e" + integrity sha512-1bCSESV6Pv+i21Hvpxp3Dx+pSD8lIPt8uVjRrxAUt/nbswYc+tK6Y2btiULjd4+fnq15PX+nqQDC7Oft7WkwcA== + dependencies: + lru-cache "^6.0.0" + +source-map-js@^1.2.1: + version "1.2.1" + resolved "https://registry.yarnpkg.com/source-map-js/-/source-map-js-1.2.1.tgz#1ce5650fddd87abc099eda37dcff024c2667ae46" + integrity sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA== + +source-map@~0.6.1: + version "0.6.1" + resolved "https://registry.yarnpkg.com/source-map/-/source-map-0.6.1.tgz#74722af32e9614e9c287a8d0bbde48b5e2f1a263" + integrity sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g== + +sprintf-js@~1.0.2: + version "1.0.3" + resolved "https://registry.yarnpkg.com/sprintf-js/-/sprintf-js-1.0.3.tgz#04e6926f662895354f3dd015203633b857297e2c" + integrity sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g== + +string-argv@~0.3.1: + version "0.3.2" + resolved "https://registry.yarnpkg.com/string-argv/-/string-argv-0.3.2.tgz#2b6d0ef24b656274d957d54e0a4bbf6153dc02b6" + integrity sha512-aqD2Q0144Z+/RqG52NeHEkZauTAUWJO8c6yTftGJKO3Tja5tUgIfmIl6kExvhtxSDP7fXB6DvzkfMpCd/F3G+Q== + +strip-json-comments@~3.1.1: + version "3.1.1" + resolved "https://registry.yarnpkg.com/strip-json-comments/-/strip-json-comments-3.1.1.tgz#31f1281b3832630434831c310c01cccda8cbe006" + integrity sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig== + +supports-color@~8.1.1: + version "8.1.1" + resolved "https://registry.yarnpkg.com/supports-color/-/supports-color-8.1.1.tgz#cd6fc17e28500cff56c1b86c0a7fd4a54a73005c" + integrity sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q== + dependencies: + has-flag "^4.0.0" + +supports-preserve-symlinks-flag@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz#6eda4bd344a3c94aea376d4cc31bc77311039e09" + integrity sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w== + +tinyglobby@^0.2.13: + version "0.2.14" + resolved "https://registry.yarnpkg.com/tinyglobby/-/tinyglobby-0.2.14.tgz#5280b0cf3f972b050e74ae88406c0a6a58f4079d" + integrity sha512-tX5e7OM1HnYr2+a2C/4V0htOcSQcoSTH9KgJnVvNm5zm/cyEWKJ7j7YutsH9CxMdtOkkLFy2AHrMci9IM8IPZQ== + dependencies: + fdir "^6.4.4" + picomatch "^4.0.2" + +typescript@5.4.2: + version "5.4.2" + resolved "https://registry.yarnpkg.com/typescript/-/typescript-5.4.2.tgz#0ae9cebcfae970718474fe0da2c090cad6577372" + integrity sha512-+2/g0Fds1ERlP6JsakQQDXjZdZMM+rqpamFZJEKh4kwTIn3iDkgKtby0CeNd5ATNZ4Ry1ax15TMx0W2V+miizQ== + +typescript@5.8.2: + version "5.8.2" + resolved "https://registry.yarnpkg.com/typescript/-/typescript-5.8.2.tgz#8170b3702f74b79db2e5a96207c15e65807999e4" + integrity sha512-aJn6wq13/afZp/jT9QZmwEjDqqvSGp1VT5GVg+f/t6/oVyrgXM6BY1h9BRh/O5p3PlUPAe+WuiEZOmb/49RqoQ== + +ufo@^1.5.4: + version "1.6.1" + resolved "https://registry.yarnpkg.com/ufo/-/ufo-1.6.1.tgz#ac2db1d54614d1b22c1d603e3aef44a85d8f146b" + integrity sha512-9a4/uxlTWJ4+a5i0ooc1rU7C7YOw3wT+UGqdeNNHWnOF9qcMBgLRS+4IYUqbczewFx4mLEig6gawh7X6mFlEkA== + +undici-types@~6.20.0: + version "6.20.0" + resolved "https://registry.yarnpkg.com/undici-types/-/undici-types-6.20.0.tgz#8171bf22c1f588d1554d55bf204bc624af388433" + integrity sha512-Ny6QZ2Nju20vw1SRHe3d9jVu6gJ+4e3+MMpqu7pqE5HT6WsTSlce++GQmK5UXS8mzV8DSYHrQH+Xrf2jVcuKNg== + +universalify@^2.0.0: + version "2.0.1" + resolved "https://registry.yarnpkg.com/universalify/-/universalify-2.0.1.tgz#168efc2180964e6386d061e094df61afe239b18d" + integrity sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw== + +uri-js@^4.2.2, uri-js@^4.4.1: + version "4.4.1" + resolved "https://registry.yarnpkg.com/uri-js/-/uri-js-4.4.1.tgz#9b1a52595225859e55f669d928f88c6c57f2a77e" + integrity sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg== + dependencies: + punycode "^2.1.0" + +vite-plugin-dts@4.5.4: + version "4.5.4" + resolved "https://registry.yarnpkg.com/vite-plugin-dts/-/vite-plugin-dts-4.5.4.tgz#51b60aaaa760d9cf5c2bb3676c69d81910d6b08c" + integrity sha512-d4sOM8M/8z7vRXHHq/ebbblfaxENjogAAekcfcDCCwAyvGqnPrc7f4NZbvItS+g4WTgerW0xDwSz5qz11JT3vg== + dependencies: + "@microsoft/api-extractor" "^7.50.1" + "@rollup/pluginutils" "^5.1.4" + "@volar/typescript" "^2.4.11" + "@vue/language-core" "2.2.0" + compare-versions "^6.1.1" + debug "^4.4.0" + kolorist "^1.8.0" + local-pkg "^1.0.0" + magic-string "^0.30.17" + +vite@6.3.5: + version "6.3.5" + resolved "https://registry.yarnpkg.com/vite/-/vite-6.3.5.tgz#fec73879013c9c0128c8d284504c6d19410d12a3" + integrity sha512-cZn6NDFE7wdTpINgs++ZJ4N49W2vRp8LCKrn3Ob1kYNtOo21vfDoaV5GzBfLU4MovSAB8uNRm4jgzVQZ+mBzPQ== + dependencies: + esbuild "^0.25.0" + fdir "^6.4.4" + picomatch "^4.0.2" + postcss "^8.5.3" + rollup "^4.34.9" + tinyglobby "^0.2.13" + optionalDependencies: + fsevents "~2.3.3" + +vscode-uri@^3.0.8: + version "3.1.0" + resolved "https://registry.yarnpkg.com/vscode-uri/-/vscode-uri-3.1.0.tgz#dd09ec5a66a38b5c3fffc774015713496d14e09c" + integrity sha512-/BpdSx+yCQGnCvecbyXdxHDkuk55/G3xwnC0GqY4gmQ3j+A+g8kzzgB4Nk/SINjqn6+waqw3EgbVF2QKExkRxQ== + +yallist@^4.0.0: + version "4.0.0" + resolved "https://registry.yarnpkg.com/yallist/-/yallist-4.0.0.tgz#9bb92790d9c0effec63be73519e11a35019a3a72" + integrity sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A== diff --git a/deploy/tools/sitemap-generator/next-sitemap.config.js b/deploy/tools/sitemap-generator/next-sitemap.config.js index 820046fc41..936298dbba 100644 --- a/deploy/tools/sitemap-generator/next-sitemap.config.js +++ b/deploy/tools/sitemap-generator/next-sitemap.config.js @@ -59,7 +59,7 @@ module.exports = { { userAgent: '*', allow: '/', - disallow: ['/auth/*', '/login', '/chakra', '/sprite', '/account/*'], + disallow: ['/auth/*', '/login', '/chakra', '/sprite', '/account/*', '/csv-export'], }, ], }, @@ -71,6 +71,7 @@ module.exports = { '/login', '/sprite', '/chakra', + '/csv-export', ], transform: async(config, path) => { switch (path) { diff --git a/deploy/values/review-l2/values.yaml.gotmpl b/deploy/values/review-l2/values.yaml.gotmpl index 3a91738499..9af2b7b1b3 100644 --- a/deploy/values/review-l2/values.yaml.gotmpl +++ b/deploy/values/review-l2/values.yaml.gotmpl @@ -48,32 +48,8 @@ frontend: cpu: 250m env: 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_NAVIGATION_LAYOUT: horizontal - NEXT_PUBLIC_NAVIGATION_HIGHLIGHTED_ROUTES: "['/blocks','/name-domains']" + SKIP_ENVS_VALIDATION: true 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_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 diff --git a/docs/DEPRECATED_ENVS.md b/docs/DEPRECATED_ENVS.md index 171df270f9..1e97e271e3 100644 --- a/docs/DEPRECATED_ENVS.md +++ b/docs/DEPRECATED_ENVS.md @@ -12,3 +12,5 @@ | NEXT_PUBLIC_NETWORK_GOVERNANCE_TOKEN_SYMBOL | `string` | Network governance token symbol | - | - | `GNO` | v1.12.0 | v1.29.0 | Replaced by NEXT_PUBLIC_NETWORK_SECONDARY_COIN_SYMBOL | | NEXT_PUBLIC_SWAP_BUTTON_URL | `string` | Application ID in the marketplace or website URL | - | - | `uniswap` | v1.24.0 | v1.31.0 | Replaced by NEXT_PUBLIC_DEFI_DROPDOWN_ITEMS | | NEXT_PUBLIC_HOMEPAGE_SHOW_AVG_BLOCK_TIME | `boolean` | Set to false if average block time is useless for the network | - | `true` | `false` | v1.0.x+ | v1.35.0 | Replaced by NEXT_PUBLIC_HOMEPAGE_STATS | +| NEXT_PUBLIC_CELO_L2_UPGRADE_BLOCK | `number` | Indicates the block number when the Celo-type chain transitioned to L2. This is used to display links to the Epoch block page from a regular block page. | - | - | `26369280` | v1.37.0+ | v2.2.0 | Removed; configuration done on the API side | +| NEXT_PUBLIC_NAVIGATION_HIDDEN_LINKS | `Array` | List of external links hidden in the navigation. Supported ids are `eth_rpc_api`, `rpc_api` | - | - | `['eth_rpc_api']` | v1.16.0+ | v2.3.0 | Use NEXT_PUBLIC_API_DOCS_TABS instead to hide tabs on the API docs page. | diff --git a/docs/ENVS.md b/docs/ENVS.md index e6889e73d9..872667cf07 100644 --- a/docs/ENVS.md +++ b/docs/ENVS.md @@ -47,7 +47,7 @@ All json-like values should be single-quoted. If it contains a hash (`#`) or a d - [Mixpanel analytics](#mixpanel-analytics) - [GrowthBook feature flagging and A/B testing](#growthbook-feature-flagging-and-ab-testing) - [GraphQL API documentation](#graphql-api-documentation) - - [REST API documentation](#rest-api-documentation) + - [API documentation](#api-documentation) - [Marketplace](#marketplace) - [Solidity to UML diagrams](#solidity-to-uml-diagrams) - [Blockchain statistics](#blockchain-statistics) @@ -76,6 +76,7 @@ All json-like values should be single-quoted. If it contains a hash (`#`) or a d - [Save on gas with GasHawk](#save-on-gas-with-gashawk) - [Rewards service API](#rewards-service-api) - [DEX pools](#dex-pools) + - [Address 3rd party widgets](#address-3rd-party-widgets) - [3rd party services configuration](#external-services-configuration)   @@ -162,7 +163,6 @@ _Note_ Here, all values are arrays of up to two strings. The first string repres | NEXT_PUBLIC_NETWORK_ICON_DARK | `string` | Network icon for dark color mode; if not provided, **inverted** regular icon will be used instead | - | - | `https://placekitten.com/60/60` | v1.0.x+ | | NEXT_PUBLIC_FEATURED_NETWORKS | `string` | URL of configuration file (`.json` format only) or file content string representation. It contains list of featured networks that will be shown in the network menu. See [below](#featured-network-configuration-properties) list of available properties for particular network | - | - | `https://example.com/featured_networks_config.json` \| `[{'title':'Astar(EVM)','url':'https://astar.blockscout.com/','group':'Mainnets','icon':'https://example.com/astar.svg'}]` | v1.0.x+ | | NEXT_PUBLIC_OTHER_LINKS | `Array<{url: string; text: string}>` | List of links for the "Other" navigation menu | - | - | `[{'url':'https://blockscout.com','text':'Blockscout'}]` | v1.0.x+ | -| NEXT_PUBLIC_NAVIGATION_HIDDEN_LINKS | `Array` | List of external links hidden in the navigation. Supported ids are `eth_rpc_api`, `rpc_api` | - | - | `['eth_rpc_api']` | v1.16.0+ | | NEXT_PUBLIC_NAVIGATION_HIGHLIGHTED_ROUTES | `Array` | List of menu item routes that should have a lightning label | - | - | `['/accounts']` | v1.31.0+ | | NEXT_PUBLIC_NAVIGATION_LAYOUT | `vertical \| horizontal` | Navigation menu layout type | - | `vertical` | `horizontal` | v1.32.0+ | @@ -192,7 +192,7 @@ The app version shown in the footer is derived from build-time ENV variables `NE | Variable | Type| Description | Compulsoriness | Default value | Example value | | --- | --- | --- | --- | --- | --- | | title | `string` | Title of link group | Required | - | `Company` | -| links | `Array<{'text':string;'url':string;}>` | list of links | Required | - | `[{'text':'Homepage','url':'https://www.blockscout.com'}]` | +| links | `Array<{'text':string;'url':string;'iconUrl'?:[string,string]}>` | An array contains a list of links in the column. Each link can optionally have an `icon_url` property, which should include an array of two external image URLs for light and dark themes, respectively. If only one URL is provided, it will be used for both color schemes. We expect the icons to be square, with a minimum size of 40px by 40px or in SVG format. | Required | - | `[{'text':'Homepage','url':'https://www.blockscout.com'}]` |   @@ -459,6 +459,7 @@ This feature is **enabled by default** with the `coinzilla` ads provider. To swi | Variable | Type| Description | Compulsoriness | Default value | Example value | Version | | --- | --- | --- | --- | --- | --- | --- | | NEXT_PUBLIC_HAS_USER_OPS | `boolean` | Set to true to show user operations related data and pages | - | - | `true` | v1.23.0+ | +| NEXT_PUBLIC_USER_OPS_INDEXER_API_HOST | `boolean` | The user operations indexer API host; pass to show API documentation for the service | - | - | `true` | v2.3.0+ |   @@ -526,23 +527,13 @@ This feature is **enabled by default** with the `coinzilla` ads provider. To swi   -### GraphQL API documentation - -This feature is **always enabled**, but you can disable it by passing `none` value to `NEXT_PUBLIC_GRAPHIQL_TRANSACTION` variable. - -| Variable | Type| Description | Compulsoriness | Default value | Example value | Version | -| --- | --- | --- | --- | --- | --- | --- | -| NEXT_PUBLIC_GRAPHIQL_TRANSACTION | `string` | Txn hash for default query at GraphQl playground page. Pass `none` to disable the feature. | - | - | `0x4a0ed8ddf751a7cb5297f827699117b0f6d21a0b2907594d300dc9fed75c7e62` | v1.0.x+ | - -  - -### REST API documentation - -This feature is **always enabled**, but you can disable it by passing `none` value to `NEXT_PUBLIC_API_SPEC_URL` variable. +### API documentation | Variable | Type| Description | Compulsoriness | Default value | Example value | Version | | --- | --- | --- | --- | --- | --- | --- | -| NEXT_PUBLIC_API_SPEC_URL | `string` | Spec to be displayed on `/api-docs` page. Pass `none` to disable the feature. | - | `https://raw.githubusercontent.com/blockscout/blockscout-api-v2-swagger/main/swagger.yaml` | `https://raw.githubusercontent.com/blockscout/blockscout-api-v2-swagger/main/swagger.yaml` | v1.0.x+ | +| NEXT_PUBLIC_API_DOCS_TABS | `Array` | Controls which tabs appear on the API documentation page. Possible values for `TabId` are `rest_api`, `eth_rpc_api`, `rpc_api`, and `graphql_api`. **Note** that this variable has a default value, so the feature is enabled by default. Pass an empty array to disable it. | - | `['rest_api','eth_rpc_api','rpc_api','graphql_api']` | `[]` | v2.3.x+ | +| NEXT_PUBLIC_API_SPEC_URL | `string` | Spec of Blockscout core API to be displayed on the page. | - | `https://raw.githubusercontent.com/blockscout/blockscout-api-v2-swagger/main/swagger.yaml` | `https://raw.githubusercontent.com/blockscout/blockscout-api-v2-swagger/main/swagger.yaml` | v1.0.x+ | +| NEXT_PUBLIC_GRAPHIQL_TRANSACTION | `string` | Txn hash for default query at GraphQl API. | - | - | `0x4a0ed8ddf751a7cb5297f827699117b0f6d21a0b2907594d300dc9fed75c7e62` | v1.0.x+ |   @@ -657,6 +648,7 @@ This feature allows name tags and other public tags for addresses. | Variable | Type| Description | Compulsoriness | Default value | Example value | Version | | --- | --- | --- | --- | --- | --- | --- | | NEXT_PUBLIC_METADATA_SERVICE_API_HOST | `string` | Metadata Service API endpoint url | Required | - | `https://metadata.services.blockscout.com` | v1.30.0+ | +| NEXT_PUBLIC_METADATA_ADDRESS_TAGS_UPDATE_ENABLED | `boolean` | Enables requests to the Metadata Service to schedule an update for address tags after the user visits the address page in the app. | - | `true` | `false` | v2.2.0+ |   @@ -771,7 +763,6 @@ For blockchains that use the Celo platform. _Note_, that once the Celo mainnet b | Variable | Type| Description | Compulsoriness | Default value | Example value | Version | | --- | --- | --- | --- | --- | --- | --- | | NEXT_PUBLIC_CELO_ENABLED | `boolean` | Indicates that it is a Celo-based chain. | - | - | `true` | v1.37.0+ | -| NEXT_PUBLIC_CELO_L2_UPGRADE_BLOCK | `number` | Indicates the block number when the Celo-type chain transitioned to L2. This is used to display links to the Epoch block page from a regular block page. | - | - | `26369280` | v1.37.0+ |   @@ -917,6 +908,30 @@ This feature enables Blockscout Merits program. It requires that the [My account   +### Address 3rd party widgets + +This feature allows to display widgets on the address page with data from 3rd party services. + +| Variable | Type| Description | Compulsoriness | Default value | Example value | Version | +| --- | --- | --- | --- | --- | --- | --- | +| NEXT_PUBLIC_ADDRESS_3RD_PARTY_WIDGETS | `Array` | Array of widget ids to be displayed | - | - | `['widget-1', 'widget-2']` | v2.2.0+ | +| NEXT_PUBLIC_ADDRESS_3RD_PARTY_WIDGETS_CONFIG_URL | `string` | URL of configuration file (`.json` format only) which contains mapping of widget names to their configuration. See [below](#address-3rd-party-widget-configuration-properties) list of available properties for a widget. | - | - | `https://example.com/address_3rd_party_widgets_config.json` | v2.2.0+ | + +#### Address 3rd party widget configuration properties + +| Property | Type | Description | Compulsoriness | Example value | +| --- | --- | --- | --- | --- | +| name | `string` | Displayed name of the widget | Required | - | `'Widget'` | +| url | `string` | Link URL for widget card. Can contain `{address}`, `{addressLowercase}` and `{chainId}` variables | Required | - | `'https://example.com/widget/{address}?chainId={chainId}'` | +| icon | `string` | Widget icon URL | Required | - | `'https://example.com/icon.svg'` | +| title | `string` | Title of displayed data | Required | - | `'Multichain balance'` | +| hint | `string` | Hint for displayed data | - | - | `'Widget hint'` | +| valuePath | `string` | Path to the field in the API response that contains the value to be displayed | Required | - | `'result.balance'` | +| pages | `Array<'eoa' \| 'contract' \| 'token'>` | List of pages where the widget should be displayed | Required | - | `['eoa']` | +| chainIds | `Record` | Mapping of chain IDs to custom values that will be used in `url` template | - | - | `{'1': 'eth', '10': 'op'}` | + +  + ### Badge claim link | Variable | Type| Description | Compulsoriness | Default value | Example value | Version | diff --git a/eslint.config.mjs b/eslint.config.mjs index 115efb9eb1..8b46c44812 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -60,7 +60,7 @@ const gitignorePath = path.resolve(__dirname, '.gitignore'); export default tseslint.config( includeIgnoreFile(gitignorePath), - { files: [ '**/*.{js,mjs,cjs,ts,jsx,tsx}', '**/*.pw.tsx' ] }, + { files: [ '**/*.{js,mjs,cjs,ts,jsx,tsx}' ] }, { ignores: [ 'deploy/tools/', @@ -69,6 +69,7 @@ export default tseslint.config( 'next.config.js', './toolkit/theme/design-system/dist', './toolkit/theme/design-system/build', + '**/*.pw.tsx', ] }, { languageOptions: { globals: { ...globals.browser, ...globals.node } } }, diff --git a/global.d.ts b/global.d.ts index 94cfa4677d..574bb0d1bc 100644 --- a/global.d.ts +++ b/global.d.ts @@ -1,3 +1,4 @@ +import type { MultichainConfig } from 'types/multichain'; import type { WalletProvider } from 'types/web3'; type CPreferences = { @@ -19,6 +20,7 @@ declare global { }; abkw: string; __envs: Record; + __multichainConfig: MultichainConfig; } namespace NodeJS { diff --git a/icons/graphQL.svg b/icons/graphQL.svg deleted file mode 100644 index 9332276ac2..0000000000 --- a/icons/graphQL.svg +++ /dev/null @@ -1,3 +0,0 @@ - - - diff --git a/icons/hourglass.svg b/icons/hourglass.svg index 7ebd6d78b2..7914f95d0e 100644 --- a/icons/hourglass.svg +++ b/icons/hourglass.svg @@ -1,3 +1,4 @@ - - + + + diff --git a/icons/hourglass_slim.svg b/icons/hourglass_slim.svg new file mode 100644 index 0000000000..7ebd6d78b2 --- /dev/null +++ b/icons/hourglass_slim.svg @@ -0,0 +1,3 @@ + + + diff --git a/instrumentation.ts b/instrumentation.ts index dc366667cb..b7d4cbad27 100644 --- a/instrumentation.ts +++ b/instrumentation.ts @@ -1,5 +1,8 @@ export async function register() { - if (process.env.NEXT_RUNTIME === 'nodejs' && process.env.NEXT_OPEN_TELEMETRY_ENABLED === 'true') { - await import('./instrumentation.node'); + if (process.env.NEXT_RUNTIME === 'nodejs') { + if (process.env.NEXT_OPEN_TELEMETRY_ENABLED === 'true') { + await import('./instrumentation.node'); + } + await import('./startup.node'); } } diff --git a/lib/address/useAddressMetadataInfoQuery.ts b/lib/address/useAddressMetadataInfoQuery.ts index b6114c2621..3ef23aaab4 100644 --- a/lib/address/useAddressMetadataInfoQuery.ts +++ b/lib/address/useAddressMetadataInfoQuery.ts @@ -2,6 +2,7 @@ import type { AddressMetadataInfoFormatted, AddressMetadataTagFormatted } from ' import config from 'configs/app'; import useApiQuery from 'lib/api/useApiQuery'; +import { useMultichainContext } from 'lib/contexts/multichain'; import parseMetaPayload from './parseMetaPayload'; @@ -9,14 +10,18 @@ export default function useAddressMetadataInfoQuery(addresses: Array, is const resource = 'metadata:info'; + const multichainContext = useMultichainContext(); + const feature = multichainContext?.chain?.config.features.addressMetadata || config.features.addressMetadata; + const chainId = multichainContext?.chain?.config.chain.id || config.chain.id; + return useApiQuery(resource, { queryParams: { addresses, - chainId: config.chain.id, + chainId, tagsLimit: '20', }, queryOptions: { - enabled: isEnabled && addresses.length > 0 && config.features.addressMetadata.isEnabled, + enabled: isEnabled && addresses.length > 0 && feature.isEnabled && Boolean(chainId), select: (data) => { const addresses = Object.entries(data.addresses) .map(([ address, { tags, reputation } ]) => { diff --git a/lib/address/useAddressMetadataInitUpdate.ts b/lib/address/useAddressMetadataInitUpdate.ts new file mode 100644 index 0000000000..d39cfdd377 --- /dev/null +++ b/lib/address/useAddressMetadataInitUpdate.ts @@ -0,0 +1,40 @@ +import React from 'react'; + +import type { AddressCounters } from 'types/api/address'; + +import config from 'configs/app'; +import useApiFetch from 'lib/api/useApiFetch'; + +const feature = config.features.addressMetadata; + +interface Params { + address: string | undefined; + counters: AddressCounters | undefined; + isEnabled: boolean; +} + +const TXS_THRESHOLD = 500; + +export default function useAddressMetadataInitUpdate({ address, counters, isEnabled }: Params) { + + const apiFetch = useApiFetch(); + + React.useEffect(() => { + if ( + feature.isEnabled && + feature.isAddressTagsUpdateEnabled && + address && + isEnabled && + counters?.transactions_count && Number(counters.transactions_count) > TXS_THRESHOLD + ) { + apiFetch('metadata:address_submit', { + fetchParams: { + method: 'POST', + body: { + addresses: [ address ], + }, + }, + }); + } + }, [ address, apiFetch, counters?.transactions_count, isEnabled ]); +} diff --git a/lib/api/buildUrl.ts b/lib/api/buildUrl.ts index 33bc8dca75..9199519a17 100644 --- a/lib/api/buildUrl.ts +++ b/lib/api/buildUrl.ts @@ -1,5 +1,7 @@ import { compile } from 'path-to-regexp'; +import type { ChainConfig } from 'types/multichain'; + import config from 'configs/app'; import getResourceParams from './getResourceParams'; @@ -11,8 +13,9 @@ export default function buildUrl( pathParams?: ResourcePathParams, queryParams?: Record | number | boolean | null | undefined>, noProxy?: boolean, + chain?: ChainConfig, ): string { - const { api, resource } = getResourceParams(resourceFullName); + const { api, resource } = getResourceParams(resourceFullName, chain); const baseUrl = !noProxy && isNeedProxy() ? config.app.baseUrl : api.endpoint; const basePath = api.basePath ?? ''; const path = !noProxy && isNeedProxy() ? '/node-api/proxy' + basePath + resource.path : basePath + resource.path; diff --git a/lib/api/getResourceParams.ts b/lib/api/getResourceParams.ts index 1dca71201d..4bfbcfb1cb 100644 --- a/lib/api/getResourceParams.ts +++ b/lib/api/getResourceParams.ts @@ -1,13 +1,21 @@ import type { ApiName, ApiResource } from './types'; +import type { ChainConfig } from 'types/multichain'; import config from 'configs/app'; import type { ResourceName } 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 apiConfig = config.apis[apiName]; + + const apiConfig = (() => { + if (chain) { + return chain.config.apis[apiName]; + } + + return config.apis[apiName]; + })(); if (!apiConfig) { throw new Error(`API config for ${ apiName } not found`); diff --git a/lib/api/getSocketUrl.ts b/lib/api/getSocketUrl.ts new file mode 100644 index 0000000000..676bfa0c9b --- /dev/null +++ b/lib/api/getSocketUrl.ts @@ -0,0 +1,5 @@ +import appConfig from 'configs/app'; + +export default function getSocketUrl(config: typeof appConfig = appConfig) { + return `${ config.apis.general.socketEndpoint }${ config.apis.general.basePath ?? '' }/socket/v2`; +} diff --git a/lib/api/resources.ts b/lib/api/resources.ts index 711242be09..e8071de1a7 100644 --- a/lib/api/resources.ts +++ b/lib/api/resources.ts @@ -10,6 +10,8 @@ import { GENERAL_API_RESOURCES } from './services/general'; import type { GeneralApiResourceName, GeneralApiResourcePayload, GeneralApiPaginationFilters, GeneralApiPaginationSorting } from './services/general'; import type { MetadataApiResourceName, MetadataApiResourcePayload } from './services/metadata'; import { METADATA_API_RESOURCES } from './services/metadata'; +import type { MultichainApiResourceName, MultichainApiResourcePayload } from './services/multichain'; +import { MULTICHAIN_API_RESOURCES } from './services/multichain'; import type { RewardsApiResourceName, RewardsApiResourcePayload } from './services/rewards'; import { REWARDS_API_RESOURCES } from './services/rewards'; import type { StatsApiResourceName, StatsApiResourcePayload } from './services/stats'; @@ -20,6 +22,7 @@ import type { TacOperationLifecycleApiResourceName, TacOperationLifecycleApiResourcePayload, } from './services/tac-operation-lifecycle'; +import { USER_OPS_API_RESOURCES } from './services/userOps'; import type { IsPaginated } from './services/utils'; import { VISUALIZE_API_RESOURCES } from './services/visualize'; import type { VisualizeApiResourceName, VisualizeApiResourcePayload } from './services/visualize'; @@ -30,9 +33,11 @@ export const RESOURCES = { contractInfo: CONTRACT_INFO_API_RESOURCES, general: GENERAL_API_RESOURCES, metadata: METADATA_API_RESOURCES, + multichain: MULTICHAIN_API_RESOURCES, rewards: REWARDS_API_RESOURCES, stats: STATS_API_RESOURCES, tac: TAC_OPERATION_LIFECYCLE_API_RESOURCES, + userOps: USER_OPS_API_RESOURCES, visualize: VISUALIZE_API_RESOURCES, } satisfies Record>; @@ -51,6 +56,7 @@ R extends BensApiResourceName ? BensApiResourcePayload : R extends ContractInfoApiResourceName ? ContractInfoApiResourcePayload : R extends GeneralApiResourceName ? GeneralApiResourcePayload : R extends MetadataApiResourceName ? MetadataApiResourcePayload : +R extends MultichainApiResourceName ? MultichainApiResourcePayload : R extends RewardsApiResourceName ? RewardsApiResourcePayload : R extends StatsApiResourceName ? StatsApiResourcePayload : R extends TacOperationLifecycleApiResourceName ? TacOperationLifecycleApiResourcePayload : diff --git a/lib/api/services/general/address.ts b/lib/api/services/general/address.ts index e06cef624f..3587358bb9 100644 --- a/lib/api/services/general/address.ts +++ b/lib/api/services/general/address.ts @@ -116,7 +116,7 @@ export const GENERAL_API_ADDRESS_RESOURCES = { paginated: true, }, address_epoch_rewards: { - path: '/api/v2/addresses/:hash/election-rewards', + path: '/api/v2/addresses/:hash/celo/election-rewards', pathParams: [ 'hash' as const ], filterFields: [], paginated: true, @@ -125,6 +125,33 @@ export const GENERAL_API_ADDRESS_RESOURCES = { path: '/api/v2/proxy/3dparty/xname/addresses/:hash', pathParams: [ 'hash' as const ], }, + address_3rd_party_info: { + path: '/api/v2/proxy/3dparty/:name', + pathParams: [ 'name' as const ], + filterFields: [ 'address' as const, 'chain_id' as const ], + }, + + // CSV EXPORTS + address_csv_export_txs: { + path: '/api/v2/addresses/:hash/transactions/csv', + pathParams: [ 'hash' as const ], + }, + address_csv_export_internal_txs: { + path: '/api/v2/addresses/:hash/internal-transactions/csv', + pathParams: [ 'hash' as const ], + }, + address_csv_export_token_transfers: { + path: '/api/v2/addresses/:hash/token-transfers/csv', + pathParams: [ 'hash' as const ], + }, + address_csv_export_logs: { + path: '/api/v2/addresses/:hash/logs/csv', + pathParams: [ 'hash' as const ], + }, + address_csv_export_celo_election_rewards: { + path: '/api/v2/addresses/:hash/celo/election-rewards/csv', + pathParams: [ 'hash' as const ], + }, } satisfies Record; export type GeneralApiAddressResourceName = `general:${ keyof typeof GENERAL_API_ADDRESS_RESOURCES }`; @@ -149,6 +176,7 @@ R extends 'general:address_collections' ? AddressCollectionsResponse : R extends 'general:address_withdrawals' ? AddressWithdrawalsResponse : R extends 'general:address_epoch_rewards' ? AddressEpochRewardsResponse : R extends 'general:address_xstar_score' ? AddressXStarResponse : +R extends 'general:address_3rd_party_info' ? unknown : never; /* eslint-enable @stylistic/indent */ diff --git a/lib/api/services/general/block.ts b/lib/api/services/general/block.ts index 69d2c30c97..9196505719 100644 --- a/lib/api/services/general/block.ts +++ b/lib/api/services/general/block.ts @@ -6,8 +6,6 @@ import type { BlockFilters, BlockWithdrawalsResponse, BlockCountdownResponse, - BlockEpoch, - BlockEpochElectionRewardDetailsResponse, BlockInternalTransactionsResponse, } from 'types/api/block'; import type { TTxsWithBlobsFilters } from 'types/api/txsFilters'; @@ -39,17 +37,6 @@ export const GENERAL_API_BLOCK_RESOURCES = { filterFields: [], paginated: true, }, - block_epoch: { - path: '/api/v2/blocks/:height_or_hash/epoch', - pathParams: [ 'height_or_hash' as const ], - filterFields: [], - }, - block_election_rewards: { - path: '/api/v2/blocks/:height_or_hash/election-rewards/:reward_type', - pathParams: [ 'height_or_hash' as const, 'reward_type' as const ], - filterFields: [], - paginated: true, - }, } satisfies Record; export type GeneralApiBlockResourceName = `general:${ keyof typeof GENERAL_API_BLOCK_RESOURCES }`; @@ -62,8 +49,6 @@ R extends 'general:block_countdown' ? BlockCountdownResponse : R extends 'general:block_txs' ? BlockTransactionsResponse : R extends 'general:block_internal_txs' ? BlockInternalTransactionsResponse : R extends 'general:block_withdrawals' ? BlockWithdrawalsResponse : -R extends 'general:block_epoch' ? BlockEpoch : -R extends 'general:block_election_rewards' ? BlockEpochElectionRewardDetailsResponse : never; /* eslint-enable @stylistic/indent */ diff --git a/lib/api/services/general/misc.ts b/lib/api/services/general/misc.ts index 1751f9f9b1..e47bc0654b 100644 --- a/lib/api/services/general/misc.ts +++ b/lib/api/services/general/misc.ts @@ -7,7 +7,8 @@ import type { import type { Blob } from 'types/api/blobs'; import type { Block } from 'types/api/block'; import type { ChartMarketResponse, ChartSecondaryCoinPriceResponse, ChartTransactionResponse } from 'types/api/charts'; -import type { BackendVersionConfig, CsvExportConfig } from 'types/api/configs'; +import type { BackendVersionConfig, CeloConfig, CsvExportConfig } from 'types/api/configs'; +import type { CeloEpochDetails, CeloEpochElectionRewardDetailsResponse, CeloEpochListResponse } from 'types/api/epochs'; import type { IndexingStatus } from 'types/api/indexingStatus'; import type { NovesAccountHistoryResponse, NovesDescribeTxsResponse, NovesResponseData } from 'types/api/noves'; import type { @@ -181,6 +182,23 @@ export const GENERAL_API_MISC_RESOURCES = { pathParams: [ 'hash' as const ], }, + // EPOCHS + epochs_celo: { + path: '/api/v2/celo/epochs', + filterFields: [], + paginated: true, + }, + epoch_celo: { + path: '/api/v2/celo/epochs/:number', + pathParams: [ 'number' as const ], + }, + epoch_celo_election_rewards: { + path: '/api/v2/celo/epochs/:number/election-rewards/:reward_type', + pathParams: [ 'number' as const, 'reward_type' as const ], + filterFields: [], + paginated: true, + }, + // ADVANCED FILTER advanced_filter: { path: '/api/v2/advanced-filters', @@ -224,11 +242,8 @@ export const GENERAL_API_MISC_RESOURCES = { config_csv_export: { path: '/api/v2/config/csv-export', }, - - // CSV EXPORT - csv_export_token_holders: { - path: '/api/v2/tokens/:hash/holders/csv', - pathParams: [ 'hash' as const ], + config_celo: { + path: '/api/v2/config/celo', }, // OTHER @@ -261,6 +276,7 @@ R extends 'general:search' ? SearchResult : R extends 'general:search_check_redirect' ? SearchRedirectResult : R extends 'general:config_backend_version' ? BackendVersionConfig : R extends 'general:config_csv_export' ? CsvExportConfig : +R extends 'general:config_celo' ? CeloConfig : R extends 'general:blob' ? Blob : R extends 'general:validators_stability' ? ValidatorsStabilityResponse : R extends 'general:validators_stability_counters' ? ValidatorsStabilityCountersResponse : @@ -268,6 +284,9 @@ R extends 'general:validators_blackfort' ? ValidatorsBlackfortResponse : R extends 'general:validators_blackfort_counters' ? ValidatorsBlackfortCountersResponse : R extends 'general:validators_zilliqa' ? ValidatorsZilliqaResponse : R extends 'general:validator_zilliqa' ? ValidatorZilliqa : +R extends 'general:epochs_celo' ? CeloEpochListResponse : +R extends 'general:epoch_celo' ? CeloEpochDetails : +R extends 'general:epoch_celo_election_rewards' ? CeloEpochElectionRewardDetailsResponse : R extends 'general:user_ops' ? UserOpsResponse : R extends 'general:user_op' ? UserOp : R extends 'general:user_ops_account' ? UserOpsAccount : diff --git a/lib/api/services/general/token.ts b/lib/api/services/general/token.ts index 9c8f62b370..46e3619a76 100644 --- a/lib/api/services/general/token.ts +++ b/lib/api/services/general/token.ts @@ -49,6 +49,10 @@ export const GENERAL_API_TOKEN_RESOURCES = { filterFields: [ 'q' as const, 'chain_ids' as const ], paginated: true, }, + token_csv_export_holders: { + path: '/api/v2/tokens/:hash/holders/csv', + pathParams: [ 'hash' as const ], + }, // TOKEN INSTANCE token_instance: { diff --git a/lib/api/services/general/v1.ts b/lib/api/services/general/v1.ts index 4f78e96b9b..740852735c 100644 --- a/lib/api/services/general/v1.ts +++ b/lib/api/services/general/v1.ts @@ -2,21 +2,6 @@ import type { ApiResource } from '../../types'; import type { BlockCountdownResponse } from 'types/api/block'; export const GENERAL_API_V1_RESOURCES = { - csv_export_txs: { - path: '/api/v1/transactions-csv', - }, - csv_export_internal_txs: { - path: '/api/v1/internal-transactions-csv', - }, - csv_export_token_transfers: { - path: '/api/v1/token-transfers-csv', - }, - csv_export_logs: { - path: '/api/v1/logs-csv', - }, - csv_export_epoch_rewards: { - path: '/api/v1/celo-election-rewards-csv', - }, graphql: { path: '/api/v1/graphql', }, diff --git a/lib/api/services/metadata.ts b/lib/api/services/metadata.ts index 9061793a98..2f88d09de4 100644 --- a/lib/api/services/metadata.ts +++ b/lib/api/services/metadata.ts @@ -11,6 +11,9 @@ export const METADATA_API_RESOURCES = { public_tag_types: { path: '/api/v1/public-tag-types', }, + address_submit: { + path: '/api/v1/addresses\\:submit', + }, } satisfies Record; export type MetadataApiResourceName = `metadata:${ keyof typeof METADATA_API_RESOURCES }`; diff --git a/lib/api/services/multichain.ts b/lib/api/services/multichain.ts new file mode 100644 index 0000000000..3821cc1b5b --- /dev/null +++ b/lib/api/services/multichain.ts @@ -0,0 +1,17 @@ +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; + +export type MultichainApiResourceName = `multichain:${ keyof typeof MULTICHAIN_API_RESOURCES }`; + +/* eslint-disable @stylistic/indent */ +export type MultichainApiResourcePayload = +R extends 'multichain:interop_messages' ? multichain.ListInteropMessagesResponse : +never; +/* eslint-enable @stylistic/indent */ diff --git a/lib/api/services/userOps.ts b/lib/api/services/userOps.ts new file mode 100644 index 0000000000..6e10b1aafd --- /dev/null +++ b/lib/api/services/userOps.ts @@ -0,0 +1,8 @@ +import type { ApiResource } from '../types'; + +export const USER_OPS_API_RESOURCES = { +} satisfies Record; + +export type UserOpsApiResourceName = `userOps:${ keyof typeof USER_OPS_API_RESOURCES }`; + +export type UserOpsApiResourcePayload = never; diff --git a/lib/api/types.ts b/lib/api/types.ts index 4135650bca..dcee835b85 100644 --- a/lib/api/types.ts +++ b/lib/api/types.ts @@ -1,4 +1,4 @@ -export type ApiName = 'general' | 'admin' | 'bens' | 'contractInfo' | 'metadata' | 'rewards' | 'stats' | 'visualize' | 'tac'; +export type ApiName = 'general' | 'admin' | 'bens' | 'contractInfo' | 'metadata' | 'multichain' | 'rewards' | 'stats' | 'tac' | 'userOps' | 'visualize'; export interface ApiResource { path: string; diff --git a/lib/api/useApiFetch.tsx b/lib/api/useApiFetch.tsx index 1c8fa6ebf6..be9b117b50 100644 --- a/lib/api/useApiFetch.tsx +++ b/lib/api/useApiFetch.tsx @@ -3,6 +3,7 @@ import { omit, pickBy } from 'es-toolkit'; import React from 'react'; import type { CsrfData } from 'types/client/account'; +import type { ChainConfig } from 'types/multichain'; import config from 'configs/app'; import isBodyAllowed from 'lib/api/isBodyAllowed'; @@ -21,21 +22,23 @@ export interface Params { queryParams?: Record | number | boolean | undefined | null>; fetchParams?: Pick; logError?: boolean; + chain?: ChainConfig; } export default function useApiFetch() { const fetch = useFetch(); const queryClient = useQueryClient(); + const { token: csrfToken } = queryClient.getQueryData(getResourceKey('general:csrf')) || {}; return React.useCallback(( resourceName: R, - { pathParams, queryParams, fetchParams, logError }: Params = {}, + { pathParams, queryParams, fetchParams, logError, chain }: Params = {}, ) => { const apiToken = cookies.get(cookies.NAMES.API_TOKEN); - const { api, apiName, resource } = getResourceParams(resourceName); - const url = buildUrl(resourceName, pathParams, queryParams); + const { api, apiName, resource } = getResourceParams(resourceName, chain); + const url = buildUrl(resourceName, pathParams, queryParams, undefined, chain); const withBody = isBodyAllowed(fetchParams?.method); const headers = pickBy({ 'x-endpoint': api.endpoint && apiName !== 'general' && isNeedProxy() ? api.endpoint : undefined, diff --git a/lib/api/useApiQuery.tsx b/lib/api/useApiQuery.tsx index 6cd58c024e..c60abbe6d3 100644 --- a/lib/api/useApiQuery.tsx +++ b/lib/api/useApiQuery.tsx @@ -1,6 +1,8 @@ import type { UseQueryOptions } 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 { ResourceError, ResourceName, ResourcePathParams, ResourcePayload } from './resources'; @@ -12,29 +14,37 @@ export interface Params; queryOptions?: Partial, ResourceError, D>, 'queryFn'>>; logError?: boolean; + chainSlug?: string; } -export function getResourceKey(resource: R, { pathParams, queryParams }: Params = {}) { +export interface GetResourceKeyParams> + extends Pick, 'pathParams' | 'queryParams'> { + chainSlug?: string; +} + +export function getResourceKey(resource: R, { pathParams, queryParams, chainSlug }: GetResourceKeyParams = {}) { 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>( resource: R, - { queryOptions, pathParams, queryParams, fetchParams, logError }: Params = {}, + { queryOptions, pathParams, queryParams, fetchParams, logError, chainSlug }: Params = {}, ) { const apiFetch = useApiFetch(); + const { chain } = useMultichainContext() || + { chain: chainSlug ? multichainConfig()?.chains.find((chain) => chain.slug === chainSlug) : undefined }; return useQuery, ResourceError, D>({ - queryKey: queryOptions?.queryKey || getResourceKey(resource, { pathParams, queryParams }), + queryKey: queryOptions?.queryKey || getResourceKey(resource, { pathParams, queryParams, chainSlug: chain?.slug }), queryFn: async({ signal }) => { // all errors and error typing is handled by react-query // so error response will never go to the data // that's why we are safe here to do type conversion "as Promise>" - return apiFetch(resource, { pathParams, queryParams, logError, fetchParams: { ...fetchParams, signal } }) as Promise>; + return apiFetch(resource, { pathParams, queryParams, chain, logError, fetchParams: { ...fetchParams, signal } }) as Promise>; }, ...queryOptions, }); diff --git a/lib/contexts/multichain.tsx b/lib/contexts/multichain.tsx new file mode 100644 index 0000000000..885e3ce624 --- /dev/null +++ b/lib/contexts/multichain.tsx @@ -0,0 +1,68 @@ +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(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(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 ( + + { children } + + ); +} + +export function useMultichainContext(disabled: boolean = !multichainConfig) { + const context = React.useContext(MultichainContext); + if (context === undefined || disabled) { + return null; + } + return context; +} diff --git a/lib/hooks/useFetch.tsx b/lib/hooks/useFetch.tsx index ea1977f251..e42ce36ef9 100644 --- a/lib/hooks/useFetch.tsx +++ b/lib/hooks/useFetch.tsx @@ -54,6 +54,9 @@ export default function useFetch() { const error = { status: response.status, statusText: response.statusText, + rateLimits: { + bypassOptions: response.headers.get('bypass-429-option'), + }, }; if (meta?.logError && rollbar) { @@ -67,18 +70,16 @@ export default function useFetch() { if (!isJson) { return response.text().then( (textError) => Promise.reject({ + ...error, payload: textError, - status: response.status, - statusText: response.statusText, }), ); } return response.json().then( (jsonError) => Promise.reject({ + ...error, payload: jsonError as Error, - status: response.status, - statusText: response.statusText, }), () => { return Promise.reject(error); diff --git a/lib/hooks/useNavItems.tsx b/lib/hooks/useNavItems.tsx index 1c34d3ccf1..092866f818 100644 --- a/lib/hooks/useNavItems.tsx +++ b/lib/hooks/useNavItems.tsx @@ -118,6 +118,12 @@ export default function useNavItems(): ReturnType { icon: 'MUD_menu', isActive: pathname === '/mud-worlds', } : null; + const epochs = config.features.celo.isEnabled ? { + text: 'Epochs', + nextRoute: { pathname: '/epochs' as const }, + icon: 'hourglass', + isActive: pathname.startsWith('/epochs'), + } : null; const rollupFeature = config.features.rollup; @@ -196,6 +202,7 @@ export default function useNavItems(): ReturnType { internalTxs, userOps, blocks, + epochs, topAccounts, validators, verifiedContracts, @@ -230,30 +237,12 @@ export default function useNavItems(): ReturnType { }, ].filter(Boolean); - const apiNavItems: Array = [ - config.features.restApiDocs.isEnabled ? { - text: 'REST API', - nextRoute: { pathname: '/api-docs' as const }, - icon: 'restAPI', - isActive: pathname === '/api-docs', - } : null, - config.features.graphqlApiDocs.isEnabled ? { - text: 'GraphQL', - nextRoute: { pathname: '/graphiql' as const }, - icon: 'graphQL', - isActive: pathname === '/graphiql', - } : null, - !config.UI.navigation.hiddenLinks?.rpc_api && { - text: 'RPC API', - icon: 'RPC', - url: 'https://docs.blockscout.com/for-users/api/rpc-endpoints', - }, - !config.UI.navigation.hiddenLinks?.eth_rpc_api && { - text: 'Eth RPC API', - icon: 'RPC', - url: ' https://docs.blockscout.com/for-users/api/eth-rpc', - }, - ].filter(Boolean); + const apiNavItem: NavItem | null = config.features.apiDocs.isEnabled ? { + text: 'API', + nextRoute: { pathname: '/api-docs' as const }, + icon: 'restAPI', + isActive: pathname.startsWith('/api-docs'), + } : null; const otherNavItems: Array | Array> = [ { @@ -304,12 +293,7 @@ export default function useNavItems(): ReturnType { icon: 'stats', isActive: pathname.startsWith('/stats'), } : null, - apiNavItems.length > 0 && { - text: 'API', - icon: 'restAPI', - isActive: apiNavItems.some(item => isInternalItem(item) && item.isActive), - subItems: apiNavItems, - }, + apiNavItem, { text: 'Other', icon: 'gear', diff --git a/lib/hooks/useTimeAgoIncrement.tsx b/lib/hooks/useTimeAgoIncrement.tsx index 3963752eeb..97ace77542 100644 --- a/lib/hooks/useTimeAgoIncrement.tsx +++ b/lib/hooks/useTimeAgoIncrement.tsx @@ -76,6 +76,8 @@ export default function useTimeAgoIncrement(ts: string | number | null = 0, isEn timeouts.push(endTimeoutId); }; + setValue(dayjs(ts).fromNow()); + isEnabled && startIncrement(); !isEnabled && setValue(dayjs(ts).fromNow()); diff --git a/lib/metadata/getCanonicalUrl.ts b/lib/metadata/getCanonicalUrl.ts index 2a868419a8..9fcaef9c69 100644 --- a/lib/metadata/getCanonicalUrl.ts +++ b/lib/metadata/getCanonicalUrl.ts @@ -12,7 +12,6 @@ const CANONICAL_ROUTES: Array = [ '/tokens', '/stats', '/api-docs', - '/graphiql', '/gas-tracker', '/apps', ]; diff --git a/lib/metadata/getPageOgType.ts b/lib/metadata/getPageOgType.ts index 2554afd7d0..6273f7a497 100644 --- a/lib/metadata/getPageOgType.ts +++ b/lib/metadata/getPageOgType.ts @@ -26,7 +26,6 @@ const OG_TYPE_DICT: Record = { '/stats': 'Root page', '/stats/[id]': 'Regular page', '/api-docs': 'Regular page', - '/graphiql': 'Regular page', '/search-results': 'Regular page', '/auth/profile': 'Root page', '/account/merits': 'Regular page', @@ -54,6 +53,8 @@ const OG_TYPE_DICT: Record = { '/name-domains/[name]': 'Regular page', '/validators': 'Root page', '/validators/[id]': 'Regular page', + '/epochs': 'Root page', + '/epochs/[number]': 'Regular page', '/gas-tracker': 'Root page', '/mud-worlds': 'Root page', '/token-transfers': 'Root page', @@ -61,6 +62,10 @@ const OG_TYPE_DICT: Record = { '/pools': 'Root page', '/pools/[hash]': 'Regular 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', '/operation/[id]': 'Regular page', diff --git a/lib/metadata/templates/description.ts b/lib/metadata/templates/description.ts index 1a74f36f48..b2f8538ad9 100644 --- a/lib/metadata/templates/description.ts +++ b/lib/metadata/templates/description.ts @@ -29,7 +29,6 @@ const TEMPLATE_MAP: Record = { '/stats': DEFAULT_TEMPLATE, '/stats/[id]': DEFAULT_TEMPLATE, '/api-docs': DEFAULT_TEMPLATE, - '/graphiql': DEFAULT_TEMPLATE, '/search-results': DEFAULT_TEMPLATE, '/auth/profile': DEFAULT_TEMPLATE, '/account/merits': DEFAULT_TEMPLATE, @@ -57,6 +56,8 @@ const TEMPLATE_MAP: Record = { '/name-domains/[name]': DEFAULT_TEMPLATE, '/validators': DEFAULT_TEMPLATE, '/validators/[id]': DEFAULT_TEMPLATE, + '/epochs': DEFAULT_TEMPLATE, + '/epochs/[number]': DEFAULT_TEMPLATE, '/gas-tracker': 'Explore real-time %network_title% gas fees with Blockscout\'s advanced gas fee tracker. Get accurate %network_gwei% estimates and track transaction costs live.', '/mud-worlds': DEFAULT_TEMPLATE, '/token-transfers': DEFAULT_TEMPLATE, @@ -64,6 +65,10 @@ const TEMPLATE_MAP: Record = { '/pools': DEFAULT_TEMPLATE, '/pools/[hash]': 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, '/operation/[id]': DEFAULT_TEMPLATE, diff --git a/lib/metadata/templates/title.ts b/lib/metadata/templates/title.ts index b5dadd2ddd..30487ce539 100644 --- a/lib/metadata/templates/title.ts +++ b/lib/metadata/templates/title.ts @@ -26,7 +26,6 @@ const TEMPLATE_MAP: Record = { '/stats': '%network_name% stats - %network_name% network insights', '/stats/[id]': '%network_name% stats - %id% chart', '/api-docs': '%network_name% API docs - %network_name% developer tools', - '/graphiql': 'GraphQL for %network_name% - %network_name% data query', '/search-results': '%network_name% search result for %q%', '/auth/profile': '%network_name% - my profile', '/account/merits': '%network_name% - Merits', @@ -54,6 +53,8 @@ const TEMPLATE_MAP: Record = { '/name-domains/[name]': '%network_name% %name% domain details', '/validators': '%network_name% validators list', '/validators/[id]': '%network_name% validator %id% details', + '/epochs': '%network_name% epochs', + '/epochs/[number]': '%network_name% epoch %number% details', '/gas-tracker': 'Track %network_name% gas fees in %network_gwei%', '/mud-worlds': '%network_name% MUD worlds list', '/token-transfers': '%network_name% token transfers', @@ -61,6 +62,10 @@ const TEMPLATE_MAP: Record = { '/pools': '%network_name% DEX pools', '/pools/[hash]': '%network_name% pool details', '/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', '/operation/[id]': '%network_name% operation %id%', diff --git a/lib/mixpanel/getPageType.ts b/lib/mixpanel/getPageType.ts index b1e3ab3154..3a7312e768 100644 --- a/lib/mixpanel/getPageType.ts +++ b/lib/mixpanel/getPageType.ts @@ -24,7 +24,6 @@ export const PAGE_TYPE_DICT: Record = { '/stats': 'Stats', '/stats/[id]': 'Stats chart', '/api-docs': 'REST API', - '/graphiql': 'GraphQL', '/search-results': 'Search results', '/auth/profile': 'Profile', '/account/merits': 'Merits', @@ -52,6 +51,8 @@ export const PAGE_TYPE_DICT: Record = { '/name-domains/[name]': 'Domain details', '/validators': 'Validators list', '/validators/[id]': 'Validator details', + '/epochs': 'Epochs', + '/epochs/[number]': 'Epoch details', '/gas-tracker': 'Gas tracker', '/mud-worlds': 'MUD worlds', '/token-transfers': 'Token transfers', @@ -59,6 +60,10 @@ export const PAGE_TYPE_DICT: Record = { '/pools': 'DEX pools', '/pools/[hash]': 'Pool details', '/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', '/operation/[id]': 'Operation details', diff --git a/lib/mixpanel/useInit.tsx b/lib/mixpanel/useInit.tsx index fd4c4bcba8..9c68c66a7d 100644 --- a/lib/mixpanel/useInit.tsx +++ b/lib/mixpanel/useInit.tsx @@ -27,6 +27,7 @@ export default function useMixpanelInit() { const mixpanelConfig: Partial = { debug: Boolean(debugFlagQuery.current || debugFlagCookie), + persistence: 'localStorage', }; const isAuth = Boolean(cookies.get(cookies.NAMES.API_TOKEN)); diff --git a/lib/mixpanel/utils.ts b/lib/mixpanel/utils.ts index 42fa625c27..ec2a40840d 100644 --- a/lib/mixpanel/utils.ts +++ b/lib/mixpanel/utils.ts @@ -24,6 +24,7 @@ export enum EventTypes { BUTTON_CLICK = 'Button click', PROMO_BANNER = 'Promo banner', APP_FEEDBACK = 'App feedback', + ADDRESS_WIDGET = 'Address widget', } /* eslint-disable @stylistic/indent */ @@ -169,5 +170,8 @@ Type extends EventTypes.APP_FEEDBACK ? { AppId: string; Score: number; } : +Type extends EventTypes.ADDRESS_WIDGET ? { + Name: string; +} : undefined; /* eslint-enable @stylistic/indent */ diff --git a/lib/router/useEtherscanRedirects.ts b/lib/router/useEtherscanRedirects.ts new file mode 100644 index 0000000000..b41258ee83 --- /dev/null +++ b/lib/router/useEtherscanRedirects.ts @@ -0,0 +1,137 @@ +import { useRouter } from 'next/router'; +import React from 'react'; + +import getQueryParamString from './getQueryParamString'; + +export default function useEtherscanRedirects() { + const router = useRouter(); + + // The browser does not send the segment hash (or anchor) to the server, + // so we have to handle such redirects for the etherscan-like links on the client side. + React.useEffect(() => { + const segmentHash = window.location.hash; + + if (segmentHash) { + switch (router.route) { + case '/tx/[hash]': { + const hash = getQueryParamString(router.query.hash); + + switch (segmentHash) { + case '#statechange': + router.replace({ + pathname: '/tx/[hash]', + query: { hash, tab: 'state' }, + }); + break; + case '#eventlog': + router.replace({ + pathname: '/tx/[hash]', + query: { hash, tab: 'logs' }, + }); + break; + case '#aa': + router.replace({ + pathname: '/tx/[hash]', + query: { hash, tab: 'user_ops' }, + }); + break; + case '#internal': + router.replace({ + pathname: '/tx/[hash]', + query: { hash, tab: 'internal' }, + }); + break; + } + break; + } + + case '/address/[hash]': { + const hash = getQueryParamString(router.query.hash); + + switch (segmentHash) { + case '#internaltx': + router.replace({ + pathname: '/address/[hash]', + query: { hash, tab: 'internal_txns' }, + }); + break; + case '#tokentxns': + router.replace({ + pathname: '/address/[hash]', + query: { hash, tab: 'token_transfers' }, + }); + break; + case '#asset-tokens': + router.replace({ + pathname: '/address/[hash]', + query: { hash, tab: 'tokens' }, + }); + break; + case '#asset-nfts': + router.replace({ + pathname: '/address/[hash]', + query: { hash, tab: 'tokens_nfts' }, + }); + break; + case '#aatx': + router.replace({ + pathname: '/address/[hash]', + query: { hash, tab: 'user_ops' }, + }); + break; + case '#code': + router.replace({ + pathname: '/address/[hash]', + query: { hash, tab: 'contract' }, + }); + break; + case '#readContract': + router.replace({ + pathname: '/address/[hash]', + query: { hash, tab: 'read_contract' }, + }); + break; + case '#writeContract': + router.replace({ + pathname: '/address/[hash]', + query: { hash, tab: 'write_contract' }, + }); + break; + } + break; + } + + case '/token/[hash]': { + const hash = getQueryParamString(router.query.hash); + + switch (segmentHash) { + case '#balances': + router.replace({ + pathname: '/token/[hash]', + query: { hash, tab: 'holders' }, + }); + break; + } + break; + } + + case '/stats': { + switch (segmentHash) { + case '#section-contracts-data': + router.replace({ + pathname: '/stats', + hash: 'contracts', + }); + break; + } + break; + } + + default: + break; + } + } + // run only on mount + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [ ]); +} diff --git a/lib/shortenString.ts b/lib/shortenString.ts index 4125ec06f2..2f5474351e 100644 --- a/lib/shortenString.ts +++ b/lib/shortenString.ts @@ -7,5 +7,7 @@ export default function shortenString(string: string | null, charNumber: number return string; } - return string.slice(0, charNumber - 4) + '...' + string.slice(-4); + const tailLength = charNumber < 8 ? 2 : 4; + + return string.slice(0, charNumber - tailLength) + '...' + string.slice(-tailLength); } diff --git a/lib/socket/context.tsx b/lib/socket/context.tsx index 55c3620311..97c231c9a5 100644 --- a/lib/socket/context.tsx +++ b/lib/socket/context.tsx @@ -1,9 +1,14 @@ // https://hexdocs.pm/phoenix/js/ -import type { SocketConnectOption } from 'phoenix'; +import type { Channel, SocketConnectOption } from 'phoenix'; import { Socket } from 'phoenix'; import React, { useEffect, useState } from 'react'; -export const SocketContext = React.createContext(null); +type ChannelRegistry = Record; + +export const SocketContext = React.createContext<{ + socket: Socket | null; + channelRegistry: React.MutableRefObject; +} | null>(null); interface SocketProviderProps { children: React.ReactNode; @@ -13,6 +18,7 @@ interface SocketProviderProps { export function SocketProvider({ children, options, url }: SocketProviderProps) { const [ socket, setSocket ] = useState(null); + const channelRegistry = React.useRef({}); useEffect(() => { if (!url) { @@ -29,8 +35,13 @@ export function SocketProvider({ children, options, url }: SocketProviderProps) }; }, [ options, url ]); + const value = React.useMemo(() => ({ + socket, + channelRegistry, + }), [ socket, channelRegistry ]); + return ( - + { children } ); diff --git a/lib/socket/types.ts b/lib/socket/types.ts index 5674dd1449..a6c7063708 100644 --- a/lib/socket/types.ts +++ b/lib/socket/types.ts @@ -1,5 +1,6 @@ import type { Channel } from 'phoenix'; +import type * as multichain from '@blockscout/multichain-aggregator-types'; import type { AddressCoinBalanceHistoryItem, AddressTokensBalancesSocketMessage } from 'types/api/address'; import type { NewArbitrumBatchSocketResponse } from 'types/api/arbitrumL2'; import type { NewBlockSocketResponse } from 'types/api/block'; @@ -11,11 +12,13 @@ import type { Transaction } from 'types/api/transaction'; import type { NewZkEvmBatchSocketResponse } from 'types/api/zkEvmL2'; export type SocketMessageParams = SocketMessage.NewBlock | +SocketMessage.NewBlockMultichain | SocketMessage.BlocksIndexStatus | SocketMessage.InternalTxsIndexStatus | SocketMessage.TxStatusUpdate | SocketMessage.TxRawTrace | SocketMessage.NewTx | +SocketMessage.NewInteropMessage | SocketMessage.NewPendingTx | SocketMessage.NewOptimisticDeposits | SocketMessage.NewArbitrumDeposits | @@ -49,11 +52,13 @@ interface SocketMessageParamsGeneric; + 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 InternalTxsIndexStatus = SocketMessageParamsGeneric<'index_status', { finished: boolean; ratio: string }>; export type TxStatusUpdate = SocketMessageParamsGeneric<'collated', NewBlockSocketResponse>; export type TxRawTrace = SocketMessageParamsGeneric<'raw_trace', RawTracesResponse>; export type NewTx = SocketMessageParamsGeneric<'transaction', { transaction: number }>; + export type NewInteropMessage = SocketMessageParamsGeneric<'new_messages', Array>; export type NewPendingTx = SocketMessageParamsGeneric<'pending_transaction', { pending_transaction: 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 }>; diff --git a/lib/socket/useSocketChannel.tsx b/lib/socket/useSocketChannel.tsx index 8400c9d9fa..665f70c95c 100644 --- a/lib/socket/useSocketChannel.tsx +++ b/lib/socket/useSocketChannel.tsx @@ -3,8 +3,6 @@ import { useEffect, useRef, useState } from 'react'; import { useSocket } from './context'; -const CHANNEL_REGISTRY: Record = {}; - interface Params { topic: string | undefined; params?: object; @@ -15,7 +13,7 @@ interface Params { } export default function useSocketChannel({ topic, params, isDisabled, onJoin, onSocketClose, onSocketError }: Params) { - const socket = useSocket(); + const { socket, channelRegistry } = useSocket() || {}; const [ channel, setChannel ] = useState(); const onCloseRef = useRef(undefined); const onErrorRef = useRef(undefined); @@ -47,18 +45,18 @@ export default function useSocketChannel({ topic, params, isDisabled, onJoin, on }, [ channel, isDisabled ]); useEffect(() => { - if (socket === null || isDisabled || !topic) { + if (!socket || isDisabled || !topic || !channelRegistry) { return; } let ch: Channel; - if (CHANNEL_REGISTRY[topic]) { - ch = CHANNEL_REGISTRY[topic].channel; - CHANNEL_REGISTRY[topic].subscribers++; + if (channelRegistry.current[topic]) { + ch = channelRegistry.current[topic].channel; + channelRegistry.current[topic].subscribers++; onJoinRef.current?.(ch, ''); } else { ch = socket.channel(topic); - CHANNEL_REGISTRY[topic] = { channel: ch, subscribers: 1 }; + channelRegistry.current[topic] = { channel: ch, subscribers: 1 }; ch.join() .receive('ok', (message) => onJoinRef.current?.(ch, message)) .receive('error', () => { @@ -68,18 +66,20 @@ export default function useSocketChannel({ topic, params, isDisabled, onJoin, on setChannel(ch); + const currentRegistry = channelRegistry.current; + return () => { - if (CHANNEL_REGISTRY[topic]) { - CHANNEL_REGISTRY[topic].subscribers > 0 && CHANNEL_REGISTRY[topic].subscribers--; - if (CHANNEL_REGISTRY[topic].subscribers === 0) { + if (currentRegistry[topic]) { + currentRegistry[topic].subscribers > 0 && currentRegistry[topic].subscribers--; + if (currentRegistry[topic].subscribers === 0) { ch.leave(); - delete CHANNEL_REGISTRY[topic]; + delete currentRegistry[topic]; } } setChannel(undefined); }; - }, [ socket, topic, params, isDisabled, onSocketError ]); + }, [ socket, topic, params, isDisabled, onSocketError, channelRegistry ]); return channel; } diff --git a/lib/web3/chains.ts b/lib/web3/chains.ts index 0342d620c1..513f299ce0 100644 --- a/lib/web3/chains.ts +++ b/lib/web3/chains.ts @@ -1,31 +1,36 @@ import { type Chain } from 'viem'; -import config from 'configs/app'; - -export const currentChain: Chain = { - id: Number(config.chain.id), - name: config.chain.name ?? '', - nativeCurrency: { - decimals: config.chain.currency.decimals, - name: config.chain.currency.name ?? '', - symbol: config.chain.currency.symbol ?? '', - }, - rpcUrls: { - 'default': { - http: config.chain.rpcUrls, +import appConfig from 'configs/app'; +import multichainConfig from 'configs/multichain'; + +const getChainInfo = (config: typeof appConfig = appConfig) => { + return { + id: Number(config.chain.id), + name: config.chain.name ?? '', + nativeCurrency: { + decimals: config.chain.currency.decimals, + name: config.chain.currency.name ?? '', + symbol: config.chain.currency.symbol ?? '', }, - }, - blockExplorers: { - 'default': { - name: 'Blockscout', - url: config.app.baseUrl, + rpcUrls: { + 'default': { + http: config.chain.rpcUrls, + }, + }, + blockExplorers: { + 'default': { + name: 'Blockscout', + url: config.app.baseUrl, + }, }, - }, - testnet: config.chain.isTestnet, + testnet: config.chain.isTestnet, + }; }; +export const currentChain: Chain | undefined = !appConfig.features.opSuperchain.isEnabled ? getChainInfo() : undefined; + export const parentChain: Chain | undefined = (() => { - const rollupFeature = config.features.rollup; + const rollupFeature = appConfig.features.rollup; const parentChain = rollupFeature.isEnabled && rollupFeature.parentChain; @@ -55,3 +60,13 @@ export const parentChain: Chain | undefined = (() => { testnet: parentChain.isTestnet, }; })(); + +export const clusterChains: Array | undefined = (() => { + const config = multichainConfig(); + + if (!config) { + return; + } + + return config.chains.map(({ config }) => getChainInfo(config)).filter(Boolean); +})(); diff --git a/lib/web3/client.ts b/lib/web3/client.ts index 262066d12f..08f00966d4 100644 --- a/lib/web3/client.ts +++ b/lib/web3/client.ts @@ -3,7 +3,8 @@ import { createPublicClient, http } from 'viem'; import { currentChain } from './chains'; 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; } diff --git a/lib/web3/wagmiConfig.ts b/lib/web3/wagmiConfig.ts index a4d8b27caf..0abf507c50 100644 --- a/lib/web3/wagmiConfig.ts +++ b/lib/web3/wagmiConfig.ts @@ -1,15 +1,48 @@ import { WagmiAdapter } from '@reown/appkit-adapter-wagmi'; import type { AppKitNetwork } from '@reown/appkit/networks'; -import type { Chain } from 'viem'; +import type { Chain, Transport } from 'viem'; import { fallback, http } from 'viem'; import { createConfig } from 'wagmi'; -import config from 'configs/app'; -import { currentChain, parentChain } from 'lib/web3/chains'; +import appConfig from 'configs/app'; +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 => { + 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 => { + 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); +}; const wagmi = (() => { @@ -17,12 +50,9 @@ const wagmi = (() => { const wagmiConfig = createConfig({ chains: chains as [Chain, ...Array], transports: { - [currentChain.id]: fallback( - config.chain.rpcUrls - .map((url) => http(url)) - .concat(http(`${ config.apis.general.endpoint }/api/eth-rpc`)), - ), + ...getChainTransportFromConfig(appConfig, true), ...(parentChain ? { [parentChain.id]: http(parentChain.rpcUrls.default.http[0]) } : {}), + ...reduceClusterChainsToTransportConfig(true), }, ssr: true, batch: { multicall: { wait: 100 } }, @@ -35,8 +65,9 @@ const wagmi = (() => { networks: chains as Array, multiInjectedProviderDiscovery: true, transports: { - [currentChain.id]: fallback(config.chain.rpcUrls.map((url) => http(url))), + ...getChainTransportFromConfig(appConfig, false), ...(parentChain ? { [parentChain.id]: http() } : {}), + ...reduceClusterChainsToTransportConfig(false), }, projectId: feature.walletConnect.projectId, ssr: true, diff --git a/middleware.ts b/middleware.ts index 8443292593..ea2eb843a2 100644 --- a/middleware.ts +++ b/middleware.ts @@ -1,12 +1,10 @@ import type { NextRequest } 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'; -const cspPolicy = generateCspPolicy(); - -export function middleware(req: NextRequest) { +export async function middleware(req: NextRequest) { const isPageRequest = req.headers.get('accept')?.includes('text/html'); const start = Date.now(); @@ -27,7 +25,9 @@ export function middleware(req: NextRequest) { 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('Docker-ID', process.env.HOSTNAME || ''); diff --git a/mocks/address/address.ts b/mocks/address/address.ts index fbc81bf171..c6358afc0e 100644 --- a/mocks/address/address.ts +++ b/mocks/address/address.ts @@ -81,6 +81,7 @@ export const token: Address = { coin_balance: '1', creation_transaction_hash: '0xc38cf7377bf72d6436f63c37b01b24d032101f20ec1849286dc703c712f10c98', creator_address_hash: '0x34A9c688512ebdB575e82C50c9803F6ba2916E72', + creation_status: 'success', exchange_rate: '0.04311', has_logs: false, has_token_transfers: true, @@ -94,6 +95,7 @@ export const eoa: Address = { coin_balance: '2782650189688719421432220500', creation_transaction_hash: '0xf2aff6501b632604c39978b47d309813d8a1bcca721864bbe86abf59704f195e', creator_address_hash: '0x803ad3F50b9e1fF68615e8B053A186e1be288943', + creation_status: null, exchange_rate: '0.04311', has_logs: true, has_token_transfers: false, @@ -117,6 +119,7 @@ export const contract: Address = { coin_balance: '27826501896887194214322205', creation_transaction_hash: '0xf2aff6501b632604c39978b47d309813d8a1bcca721864bbe86abf59704f195e', creator_address_hash: '0x803ad3F50b9e1fF68615e8B053A186e1be288943', + creation_status: 'success', exchange_rate: '0.04311', has_logs: true, has_token_transfers: false, @@ -142,6 +145,7 @@ export const validator: Address = { coin_balance: '22910462800601256910890', creation_transaction_hash: null, creator_address_hash: null, + creation_status: null, exchange_rate: '0.00432018', has_logs: false, has_token_transfers: false, diff --git a/mocks/address/epochRewards.ts b/mocks/address/epochRewards.ts index 5b22a66cd8..5b42bf3a46 100644 --- a/mocks/address/epochRewards.ts +++ b/mocks/address/epochRewards.ts @@ -11,8 +11,6 @@ export const epochRewards: AddressEpochRewardsResponse = { amount: '136609473658452408568', account: withName, associated_account: withName, - block_hash: '0x', - block_number: 26369280, block_timestamp: '2022-05-15T13:16:24Z', epoch_number: 1526, token: tokenInfo, @@ -22,8 +20,6 @@ export const epochRewards: AddressEpochRewardsResponse = { amount: '117205842355246195095', account: withoutName, associated_account: withoutName, - block_hash: '0x', - block_number: 26352000, block_timestamp: '2022-05-15T13:16:24Z', epoch_number: 1525, token: tokenInfo, @@ -33,8 +29,6 @@ export const epochRewards: AddressEpochRewardsResponse = { amount: '125659647325556554060', account: withEns, associated_account: withEns, - block_hash: '0x', - block_number: 26300160, block_timestamp: '2022-05-15T13:16:24Z', epoch_number: 1524, token: tokenInfo, @@ -43,7 +37,7 @@ export const epochRewards: AddressEpochRewardsResponse = { next_page_params: { amount: '71952055594478242556', associated_account_address_hash: '0x30d060f129817c4de5fbc1366d53e19f43c8c64f', - block_number: 25954560, + epoch_number: 25954560, items_count: 50, type: 'delegated_payment', }, diff --git a/mocks/address/widgets.ts b/mocks/address/widgets.ts new file mode 100644 index 0000000000..849b0dfab5 --- /dev/null +++ b/mocks/address/widgets.ts @@ -0,0 +1,95 @@ +import type { Address3rdPartyWidget } from 'types/views/address'; + +export const widgets = [ + 'widget-1', + 'widget-2', + 'widget-3', + 'widget-4', + 'widget-5', + 'widget-6', + 'widget-7', + 'widget-8', + 'widget-9', +] as const; + +export const values = [ 0, 2534783, 75.34, undefined, 1553.5, 100, 0.99, 333, undefined ]; + +export const config: Record = { + 'widget-1': { + name: 'Widget 1', + url: 'https://www.example.com', + pages: [ 'eoa', 'contract', 'token' ], + icon: 'http://localhost:3000/widget-logo.png', + title: 'Value', + hint: 'Hint', + valuePath: 'value', + }, + 'widget-2': { + name: 'Widget 2', + url: 'https://www.example.com', + pages: [ 'eoa', 'contract', 'token' ], + icon: 'http://localhost:3000/widget-logo.png', + title: 'Another value', + valuePath: 'value', + }, + 'widget-3': { + name: 'Widget 3', + url: 'https://www.example.com', + pages: [ 'eoa', 'contract', 'token' ], + icon: 'http://localhost:3000/widget-logo.png', + title: 'One more value', + hint: 'Hint', + valuePath: 'value', + }, + 'widget-4': { + name: 'Widget 4', + url: 'https://www.example.com', + pages: [ 'eoa', 'contract', 'token' ], + icon: 'http://localhost:3000/widget-logo.png', + title: 'Empty value', + valuePath: 'another_value', + }, + 'widget-5': { + name: 'Widget 5', + url: 'https://www.example.com', + pages: [ 'eoa', 'contract', 'token' ], + icon: 'http://localhost:3000/widget-logo.png', + title: 'Test value', + hint: 'Hint', + valuePath: 'value', + }, + 'widget-6': { + name: 'Widget 6', + url: 'https://www.example.com', + pages: [ 'eoa', 'contract', 'token' ], + icon: 'http://localhost:3000/widget-logo.png', + title: 'Another test value', + valuePath: 'value', + }, + 'widget-7': { + name: 'Widget 7', + url: 'https://www.example.com', + pages: [ 'eoa', 'contract', 'token' ], + icon: 'http://localhost:3000/widget-logo.png', + title: 'One more test value', + hint: 'Hint', + valuePath: 'value', + }, + 'widget-8': { + name: 'Widget 8', + url: 'https://www.example.com', + pages: [ 'eoa', 'contract', 'token' ], + icon: 'http://localhost:3000/widget-logo.png', + title: 'Final test value', + valuePath: 'value', + }, + 'widget-9': { + name: 'Widget 9', + url: 'https://www.example.com', + pages: [ 'eoa', 'contract', 'token' ], + icon: 'http://localhost:3000/widget-logo.png', + title: 'Another empty value', + hint: 'Hint', + valuePath: 'value', + }, +}; diff --git a/mocks/blocks/block.ts b/mocks/blocks/block.ts index f4171e090f..37fa968a44 100644 --- a/mocks/blocks/block.ts +++ b/mocks/blocks/block.ts @@ -168,7 +168,7 @@ export const celo: Block = { recipient: addressMock.contract, }, epoch_number: 1486, - is_epoch_block: true, + l1_era_finalized_epoch_number: 1485, }, }; diff --git a/mocks/blocks/epoch.ts b/mocks/blocks/epoch.ts deleted file mode 100644 index 165660c7c1..0000000000 --- a/mocks/blocks/epoch.ts +++ /dev/null @@ -1,57 +0,0 @@ -import { padStart } from 'es-toolkit/compat'; - -import type { BlockEpoch, BlockEpochElectionRewardDetails, BlockEpochElectionRewardDetailsResponse } from 'types/api/block'; - -import * as addressMock from '../address/address'; -import * as tokenMock from '../tokens/tokenInfo'; -import * as tokenTransferMock from '../tokens/tokenTransfer'; - -export const blockEpoch1: BlockEpoch = { - number: 1486, - distribution: { - carbon_offsetting_transfer: tokenTransferMock.erc20, - community_transfer: tokenTransferMock.erc20, - reserve_bolster_transfer: null, - }, - aggregated_election_rewards: { - delegated_payment: { - count: 0, - total: '71210001063118670575', - token: tokenMock.tokenInfoERC20d, - }, - group: { - count: 10, - total: '157705500305820107521', - token: tokenMock.tokenInfoERC20b, - }, - validator: { - count: 10, - total: '1348139501689262297152', - token: tokenMock.tokenInfoERC20c, - }, - voter: { - count: 38, - total: '2244419545166303388', - token: tokenMock.tokenInfoERC20a, - }, - }, -}; - -function getRewardDetailsItem(index: number): BlockEpochElectionRewardDetails { - return { - amount: `${ 100 - index }210001063118670575`, - account: { - ...addressMock.withoutName, - hash: `0x30D060F129817c4DE5fBc1366d53e19f43c8c6${ padStart(String(index), 2, '0') }`, - }, - associated_account: { - ...addressMock.withoutName, - hash: `0x456f41406B32c45D59E539e4BBA3D7898c3584${ padStart(String(index), 2, '0') }`, - }, - }; -} - -export const electionRewardDetails1: BlockEpochElectionRewardDetailsResponse = { - items: Array(15).fill('').map((item, index) => getRewardDetailsItem(index)), - next_page_params: null, -}; diff --git a/mocks/config/footerLinks.ts b/mocks/config/footerLinks.ts index 5ec3bdf9bf..4188db770c 100644 --- a/mocks/config/footerLinks.ts +++ b/mocks/config/footerLinks.ts @@ -79,6 +79,10 @@ export const FOOTER_LINKS: Array = [ { text: 'MetaDock', url: 'https://blocksec.com/metadock', + iconUrl: [ + 'http://localhost:3000/mocks/image_s.jpg', + 'http://localhost:3000/mocks/image_svg.svg', + ], }, { text: 'Sourcify', diff --git a/mocks/contract/info.ts b/mocks/contract/info.ts index b9a06aa243..bcaf60f901 100644 --- a/mocks/contract/info.ts +++ b/mocks/contract/info.ts @@ -7,6 +7,7 @@ export const verified: SmartContract = { compiler_version: 'v0.5.16+commit.9c3226ce', constructor_args: 'constructor_args', creation_bytecode: 'creation_bytecode', + creation_status: 'success', deployed_bytecode: 'deployed_bytecode', compiler_settings: { evmVersion: 'london', @@ -33,7 +34,6 @@ export const verified: SmartContract = { ], language: 'solidity', license_type: 'gnu_gpl_v3', - is_self_destructed: false, is_verified_via_eth_bytecode_db: null, is_changed_bytecode: null, is_verified_via_sourcify: null, @@ -88,7 +88,7 @@ export const withProxyAddress: SmartContract = { export const selfDestructed: SmartContract = { ...verified, - is_self_destructed: true, + creation_status: 'selfdestructed', }; export const withChangedByteCode: SmartContract = { @@ -122,7 +122,7 @@ export const nonVerified: SmartContract = { is_blueprint: false, creation_bytecode: 'creation_bytecode', deployed_bytecode: 'deployed_bytecode', - is_self_destructed: false, + creation_status: 'success', abi: null, compiler_version: null, evm_version: null, diff --git a/mocks/epochs/celo.ts b/mocks/epochs/celo.ts new file mode 100644 index 0000000000..9a85846a78 --- /dev/null +++ b/mocks/epochs/celo.ts @@ -0,0 +1,124 @@ +import { padStart } from 'es-toolkit/compat'; + +import type { CeloEpochDetails, CeloEpochElectionRewardDetails, CeloEpochElectionRewardDetailsResponse, CeloEpochListResponse } from 'types/api/epochs'; + +import * as addressMock from '../address/address'; +import * as tokenMock from '../tokens/tokenInfo'; +import * as tokenTransferMock from '../tokens/tokenTransfer'; + +export const epoch1: CeloEpochDetails = { + number: 1739, + is_finalized: true, + type: 'L1', + timestamp: '2022-06-10T01:27:52.000000Z', + start_block_number: 48477132, + start_processing_block_hash: '0x9dece1eb0e26a95fdf57d2f3a65a6f2e00ca0192e8e3dd157eca0cd323670fa1', + start_processing_block_number: 48563546, + end_processing_block_hash: '0x9dece1eb0e26a95fdf57d2f3a65a6f2e00ca0192e8e3dd157eca0cd323670fa2', + end_processing_block_number: 48563552, + end_block_number: 48563551, + distribution: { + carbon_offsetting_transfer: tokenTransferMock.erc20, + community_transfer: tokenTransferMock.erc20, + transfers_total: { + token: tokenMock.tokenInfoERC20a, + total: { + value: '1000000000000000000', + decimals: '18', + }, + }, + }, + aggregated_election_rewards: { + delegated_payment: { + count: 0, + total: '71210001063118670575', + token: tokenMock.tokenInfoERC20d, + }, + group: { + count: 10, + total: '157705500305820107521', + token: tokenMock.tokenInfoERC20b, + }, + validator: { + count: 10, + total: '1348139501689262297152', + token: tokenMock.tokenInfoERC20c, + }, + voter: { + count: 38, + total: '2244419545166303388', + token: tokenMock.tokenInfoERC20a, + }, + }, +}; + +export const epochUnfinalized: CeloEpochDetails = { + number: 1740, + is_finalized: false, + type: 'L2', + timestamp: null, + start_block_number: 48477132, + start_processing_block_hash: null, + start_processing_block_number: null, + end_processing_block_hash: null, + end_processing_block_number: null, + end_block_number: null, + distribution: null, + aggregated_election_rewards: null, +}; + +export const list: CeloEpochListResponse = { + items: [ + { + timestamp: '2022-11-10T01:27:52.000000Z', + number: 1739, + type: 'L2', + is_finalized: false, + start_block_number: 48477132, + end_block_number: null, + distribution: null, + }, + { + timestamp: '2022-06-09T01:27:32.000000Z', + number: 1738, + type: 'L1', + is_finalized: true, + end_block_number: 18477131, + start_block_number: 18390714, + distribution: { + carbon_offsetting_transfer: { + decimals: '18', + value: '1723199576750509130678', + }, + community_transfer: { + decimals: '18', + value: '68927983070020365227', + }, + transfers_total: { + decimals: '18', + value: '1792127559820529495905', + }, + }, + }, + ], + next_page_params: null, +}; + +function getRewardDetailsItem(index: number): CeloEpochElectionRewardDetails { + return { + amount: `${ 100 - index }210001063118670575`, + account: { + ...addressMock.withoutName, + hash: `0x30D060F129817c4DE5fBc1366d53e19f43c8c6${ padStart(String(index), 2, '0') }`, + }, + associated_account: { + ...addressMock.withoutName, + hash: `0x456f41406B32c45D59E539e4BBA3D7898c3584${ padStart(String(index), 2, '0') }`, + }, + }; +} + +export const electionRewardDetails1: CeloEpochElectionRewardDetailsResponse = { + items: Array(15).fill('').map((item, index) => getRewardDetailsItem(index)), + next_page_params: null, +}; diff --git a/nextjs/csp/generateCspPolicy.ts b/nextjs/csp/generateCspPolicy.ts index 891a03af5d..d67a98d290 100644 --- a/nextjs/csp/generateCspPolicy.ts +++ b/nextjs/csp/generateCspPolicy.ts @@ -15,6 +15,7 @@ function generateCspPolicy() { descriptors.marketplace(), descriptors.mixpanel(), descriptors.monaco(), + descriptors.multichain(), descriptors.rollbar(), descriptors.safe(), descriptors.usernameApi(), diff --git a/nextjs/csp/index.ts b/nextjs/csp/index.ts new file mode 100644 index 0000000000..7c1a95707f --- /dev/null +++ b/nextjs/csp/index.ts @@ -0,0 +1,15 @@ +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; +} diff --git a/nextjs/csp/policies/app.ts b/nextjs/csp/policies/app.ts index d7daca1ca9..7af39de2bf 100644 --- a/nextjs/csp/policies/app.ts +++ b/nextjs/csp/policies/app.ts @@ -41,7 +41,7 @@ export function app(): CspDev.DirectiveDescriptor { // APIs ...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 ...config.chain.rpcUrls, diff --git a/nextjs/csp/policies/index.ts b/nextjs/csp/policies/index.ts index 387a58b2d0..df792a0342 100644 --- a/nextjs/csp/policies/index.ts +++ b/nextjs/csp/policies/index.ts @@ -10,6 +10,7 @@ export { helia } from './helia'; export { marketplace } from './marketplace'; export { mixpanel } from './mixpanel'; export { monaco } from './monaco'; +export { multichain } from './multichain'; export { rollbar } from './rollbar'; export { safe } from './safe'; export { usernameApi } from './usernameApi'; diff --git a/nextjs/csp/policies/multichain.ts b/nextjs/csp/policies/multichain.ts new file mode 100644 index 0000000000..6924a629cb --- /dev/null +++ b/nextjs/csp/policies/multichain.ts @@ -0,0 +1,26 @@ +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, + ], + }; +} diff --git a/nextjs/getServerSideProps.ts b/nextjs/getServerSideProps.ts index d766d2773c..f663c3528a 100644 --- a/nextjs/getServerSideProps.ts +++ b/nextjs/getServerSideProps.ts @@ -6,12 +6,16 @@ import type { RollupType } from 'types/client/rollup'; import type { Route } from 'nextjs-routes'; import config from 'configs/app'; -const rollupFeature = config.features.rollup; -const adBannerFeature = config.features.adsBanner; +import multichainConfig from 'configs/multichain'; import isNeedProxy from 'lib/api/isNeedProxy'; import * as cookies from 'lib/cookies'; import type * as metadata from 'lib/metadata'; +import detectBotRequest from './utils/detectBotRequest'; + +const rollupFeature = config.features.rollup; +const adBannerFeature = config.features.adsBanner; + export interface Props { query: Route['query']; cookies: string; @@ -45,8 +49,9 @@ Promise>> => { } const isTrackingDisabled = process.env.DISABLE_TRACKING === 'true'; + const isBot = Boolean(detectBotRequest(req)); - if (!isTrackingDisabled) { + if (!isTrackingDisabled && !isBot) { // log pageview const hostname = req.headers.host; const timestamp = new Date().toISOString(); @@ -188,17 +193,7 @@ Promise>> => { }; export const apiDocs: GetServerSideProps = async(context) => { - if (!config.features.restApiDocs.isEnabled) { - return { - notFound: true, - }; - } - - return base(context); -}; - -export const graphIQl: GetServerSideProps = async(context) => { - if (!config.features.graphqlApiDocs.isEnabled) { + if (!config.features.apiDocs.isEnabled) { return { notFound: true, }; @@ -390,6 +385,16 @@ export const tac: GetServerSideProps = async(context) => { return base(context); }; +export const celo: GetServerSideProps = async(context) => { + if (!config.features.celo.isEnabled) { + return { + notFound: true, + }; + } + + return base(context); +}; + export const interopMessages: GetServerSideProps = async(context) => { const rollupFeature = config.features.rollup; if (!rollupFeature.isEnabled || !rollupFeature.interopEnabled) { @@ -401,6 +406,29 @@ export const interopMessages: GetServerSideProps = async(context) => { return base(context); }; +export const opSuperchain: GetServerSideProps = async(context) => { + if (!config.features.opSuperchain.isEnabled) { + return { + notFound: true, + }; + } + + return base(context); +}; + +export const opSuperchainAccountsLabelSearch: GetServerSideProps = 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 = async(context) => { if (!config.features.pools.isEnabled) { return { diff --git a/nextjs/nextjs-routes.d.ts b/nextjs/nextjs-routes.d.ts index 973758fcb2..36a777555e 100644 --- a/nextjs/nextjs-routes.d.ts +++ b/nextjs/nextjs-routes.d.ts @@ -38,13 +38,18 @@ declare module "nextjs-routes" { | DynamicRoute<"/block/countdown/[height]", { "height": string }> | StaticRoute<"/block/countdown"> | 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<"/contract-verification"> | StaticRoute<"/csv-export"> | StaticRoute<"/deposits"> | StaticRoute<"/dispute-games"> + | DynamicRoute<"/epochs/[number]", { "number": string }> + | StaticRoute<"/epochs"> | StaticRoute<"/gas-tracker"> - | StaticRoute<"/graphiql"> | StaticRoute<"/"> | StaticRoute<"/internal-txs"> | StaticRoute<"/interop-messages"> diff --git a/nextjs/redirects.js b/nextjs/redirects.js index d995fb3957..5fd13b4af1 100644 --- a/nextjs/redirects.js +++ b/nextjs/redirects.js @@ -1,4 +1,4 @@ -const oldUrls = [ +const OLD_UI_URLS = [ // ACCOUNT { source: '/account/tag_address', @@ -266,9 +266,95 @@ const oldUrls = [ }, ]; +const ETHERSCAN_URLS = [ + { + source: '/txsAA', + destination: '/ops', + }, + { + source: '/txs', + has: [ + { type: 'query', key: 'block' }, + ], + destination: '/block/:block?tab=txs', + }, + { + source: '/txsInternal', + has: [ + { type: 'query', key: 'block' }, + ], + destination: '/block/:block?tab=internal_txs', + }, + { + source: '/txsInternal', + destination: '/internal-txs', + }, + { + source: '/blocks_forked', + destination: '/blocks?tab=reorgs', + }, + { + source: '/contractsVerified', + destination: '/verified-contracts', + }, + { + source: '/verifyContract', + has: [ + { type: 'query', key: 'a' }, + ], + destination: '/address/:a/contract-verification', + }, + { + source: '/verifyContract', + destination: '/contract-verification', + }, + { + source: '/tokentxns', + destination: '/token-transfers', + }, + { + source: '/nft/:hash/:id', + destination: '/token/:hash/instance/:id', + }, + { + source: '/charts', + destination: '/stats', + }, + { + source: '/nft-latest-mints', + destination: '/advanced-filter?transaction_types=ERC-1155%2CERC-721&methods=0xa0712d68&methods_names=mint', + }, + { + source: '/nft-transfers', + destination: '/advanced-filter?transaction_types=ERC-1155%2CERC-721', + }, + { + source: '/name-lookup-search', + destination: '/name-domains', + }, + { + source: '/txsExit', + destination: '/withdrawals', + }, + { + source: '/txsEnqueued', + destination: '/deposits', + }, +]; + +const DEPRECATED_ROUTES = [ + { + source: '/graphiql', + destination: '/api-docs?tab=graphql_api', + permanent: false, + }, +]; + async function redirects() { return [ - ...oldUrls.map((item) => ({ ...item, permanent: false })), + ...OLD_UI_URLS.map((item) => ({ ...item, permanent: false })), + ...ETHERSCAN_URLS.map((item) => ({ ...item, permanent: true })), + ...DEPRECATED_ROUTES, ]; } diff --git a/nextjs/routes.ts b/nextjs/routes.ts new file mode 100644 index 0000000000..c63a083cc7 --- /dev/null +++ b/nextjs/routes.ts @@ -0,0 +1,16 @@ +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; +}; diff --git a/nextjs/utils/fetchProxy.ts b/nextjs/utils/fetchProxy.ts index ee1d4be138..ff6b7fd4d3 100644 --- a/nextjs/utils/fetchProxy.ts +++ b/nextjs/utils/fetchProxy.ts @@ -23,6 +23,8 @@ export default function fetchFactory( cookie, ...pick(_req.headers, [ 'x-csrf-token', + 'recaptcha-v2-response', + 'user-agent', 'Authorization', // the old value, just in case 'authorization', // Node.js automatically lowercases headers // feature flags diff --git a/package.json b/package.json index 1a96c3e4a3..a6b3b5a9f8 100644 --- a/package.json +++ b/package.json @@ -13,7 +13,7 @@ "dev:preset:sync": "tsc -p ./tools/preset-sync/tsconfig.json && node ./tools/preset-sync/index.js", "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: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:docker:local": "docker run -p 3000:3000 --env-file .env.local blockscout-frontend:local", "start:docker:preset": "./tools/scripts/docker.preset.sh", @@ -43,6 +43,7 @@ }, "dependencies": { "@blockscout/bens-types": "1.4.1", + "@blockscout/multichain-aggregator-types": "1.6.0-alpha.0", "@blockscout/points-types": "1.3.0-alpha.2", "@blockscout/stats-types": "^2.9.0", "@blockscout/tac-operation-lifecycle-types": "0.0.1-alpha.6", @@ -89,7 +90,7 @@ "es-toolkit": "1.31.0", "focus-visible": "^5.2.0", "gradient-avatar": "git+https://github.com/blockscout/gradient-avatar.git", - "graphiql": "^2.2.0", + "graphiql": "^4.1.2", "graphql": "^16.8.1", "graphql-ws": "^5.11.3", "js-cookie": "^3.0.1", diff --git a/pages/_app.tsx b/pages/_app.tsx index ae20f4168c..aa5c883c4b 100644 --- a/pages/_app.tsx +++ b/pages/_app.tsx @@ -8,6 +8,7 @@ import React from 'react'; import type { NextPageWithLayout } from 'nextjs/types'; import config from 'configs/app'; +import getSocketUrl from 'lib/api/getSocketUrl'; import useQueryClientConfig from 'lib/api/useQueryClientConfig'; import { AppContextProvider } from 'lib/contexts/app'; import { MarketplaceContextProvider } from 'lib/contexts/marketplace'; @@ -72,6 +73,8 @@ function MyApp({ Component, pageProps }: AppPropsWithLayout) { ); })(); + const socketUrl = !config.features.opSuperchain.isEnabled ? getSocketUrl() : undefined; + return ( @@ -84,7 +87,7 @@ function MyApp({ Component, pageProps }: AppPropsWithLayout) { - + diff --git a/pages/_document.tsx b/pages/_document.tsx index 9032774cbe..19dc62d4ef 100644 --- a/pages/_document.tsx +++ b/pages/_document.tsx @@ -44,6 +44,12 @@ class MyDocument extends Document { { /* eslint-disable-next-line @next/next/no-sync-scripts */ } + { config.features.opSuperchain.isEnabled && ( + <> + { /* eslint-disable-next-line @next/next/no-sync-scripts */ } + + + ) } { /* FAVICON */ } @@ -53,6 +59,9 @@ class MyDocument extends Document { + + { /* Prevent auto zoom in inputs on mobile */ } +
diff --git a/pages/address/[hash]/index.tsx b/pages/address/[hash]/index.tsx index 1ee916a062..48ebce81ca 100644 --- a/pages/address/[hash]/index.tsx +++ b/pages/address/[hash]/index.tsx @@ -10,6 +10,7 @@ import fetchApi from 'nextjs/utils/fetchApi'; import config from 'configs/app'; import getQueryParamString from 'lib/router/getQueryParamString'; +import AddressOpSuperchain from 'ui/optimismSuperchain/address/AddressOpSuperchain'; import Address from 'ui/pages/Address'; const pathname: Route['pathname'] = '/address/[hash]'; @@ -17,7 +18,7 @@ const pathname: Route['pathname'] = '/address/[hash]'; const Page: NextPage> = (props: Props) => { return ( -
+ { config.features.opSuperchain.isEnabled ? :
} ); }; @@ -27,7 +28,7 @@ export default Page; export const getServerSideProps: GetServerSideProps> = async(ctx) => { const baseResponse = await gSSP.base(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); if (botInfo?.type === 'social_preview') { diff --git a/pages/api-docs.tsx b/pages/api-docs.tsx index 64148d64e2..9a71d65d5f 100644 --- a/pages/api-docs.tsx +++ b/pages/api-docs.tsx @@ -1,19 +1,15 @@ import type { NextPage } from 'next'; +import dynamic from 'next/dynamic'; import React from 'react'; import PageNextJs from 'nextjs/PageNextJs'; -import config from 'configs/app'; -import SwaggerUI from 'ui/apiDocs/SwaggerUI'; -import PageTitle from 'ui/shared/Page/PageTitle'; +const ApiDocs = dynamic(() => import('ui/pages/ApiDocs'), { ssr: false }); const Page: NextPage = () => { return ( - - + ); }; diff --git a/pages/api/proxy.ts b/pages/api/proxy.ts index c8f02bb982..8552169d8e 100644 --- a/pages/api/proxy.ts +++ b/pages/api/proxy.ts @@ -21,14 +21,24 @@ const handler = async(nextReq: NextApiRequest, nextRes: NextApiResponse) => { ); // proxy some headers from API - const requestId = apiRes.headers.get('x-request-id'); - requestId && nextRes.setHeader('x-request-id', requestId); + const HEADERS_TO_PROXY = [ + 'x-request-id', + 'content-type', + 'bypass-429-option', + 'x-ratelimit-limit', + 'x-ratelimit-remaining', + 'x-ratelimit-reset', + ]; + + HEADERS_TO_PROXY.forEach((header) => { + const value = apiRes.headers.get(header); + value && nextRes.setHeader(header, value); + }); const setCookie = apiRes.headers.raw()['set-cookie']; setCookie?.forEach((value) => { nextRes.appendHeader('set-cookie', value); }); - nextRes.setHeader('content-type', apiRes.headers.get('content-type') || ''); nextRes.status(apiRes.status).send(apiRes.body); }; diff --git a/pages/chain/[chain-slug]/accounts/label/[slug].tsx b/pages/chain/[chain-slug]/accounts/label/[slug].tsx new file mode 100644 index 0000000000..b3808d5537 --- /dev/null +++ b/pages/chain/[chain-slug]/accounts/label/[slug].tsx @@ -0,0 +1,23 @@ +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 ( + + + + + + ); +}; + +export default Page; + +export { opSuperchainAccountsLabelSearch as getServerSideProps } from 'nextjs/getServerSideProps'; diff --git a/pages/chain/[chain-slug]/address/[hash]/index.tsx b/pages/chain/[chain-slug]/address/[hash]/index.tsx new file mode 100644 index 0000000000..a3b6ac1f23 --- /dev/null +++ b/pages/chain/[chain-slug]/address/[hash]/index.tsx @@ -0,0 +1,33 @@ +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: Props) => { + const chainSlug = props.query?.['chain-slug']; + const chainData = multichainConfig()?.chains.find(chain => chain.slug === chainSlug); + + return ( + + + +
+ + + + ); +}; + +export default Page; + +export { opSuperchain as getServerSideProps } from 'nextjs/getServerSideProps'; diff --git a/pages/chain/[chain-slug]/block/[height_or_hash].tsx b/pages/chain/[chain-slug]/block/[height_or_hash].tsx new file mode 100644 index 0000000000..9613dff1b6 --- /dev/null +++ b/pages/chain/[chain-slug]/block/[height_or_hash].tsx @@ -0,0 +1,24 @@ +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) => { + return ( + + + + + + ); +}; + +export default Page; + +export { opSuperchain as getServerSideProps } from 'nextjs/getServerSideProps'; diff --git a/pages/chain/[chain-slug]/tx/[hash]/index.tsx b/pages/chain/[chain-slug]/tx/[hash]/index.tsx new file mode 100644 index 0000000000..26fee25d01 --- /dev/null +++ b/pages/chain/[chain-slug]/tx/[hash]/index.tsx @@ -0,0 +1,24 @@ +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) => { + return ( + + + + + + ); +}; + +export default Page; + +export { opSuperchain as getServerSideProps } from 'nextjs/getServerSideProps'; diff --git a/pages/epochs/[number].tsx b/pages/epochs/[number].tsx new file mode 100644 index 0000000000..c476651c6e --- /dev/null +++ b/pages/epochs/[number].tsx @@ -0,0 +1,20 @@ +import type { NextPage } from 'next'; +import dynamic from 'next/dynamic'; +import React from 'react'; + +import type { Props } from 'nextjs/getServerSideProps'; +import PageNextJs from 'nextjs/PageNextJs'; + +const Epoch = dynamic(() => import('ui/pages/Epoch'), { ssr: false }); + +const Page: NextPage = (props: Props) => { + return ( + + + + ); +}; + +export default Page; + +export { celo as getServerSideProps } from 'nextjs/getServerSideProps'; diff --git a/pages/epochs/index.tsx b/pages/epochs/index.tsx new file mode 100644 index 0000000000..66ceae836c --- /dev/null +++ b/pages/epochs/index.tsx @@ -0,0 +1,19 @@ +import type { NextPage } from 'next'; +import dynamic from 'next/dynamic'; +import React from 'react'; + +import PageNextJs from 'nextjs/PageNextJs'; + +const Epochs = dynamic(() => import('ui/pages/Epochs'), { ssr: false }); + +const Page: NextPage = () => { + return ( + + + + ); +}; + +export default Page; + +export { celo as getServerSideProps } from 'nextjs/getServerSideProps'; diff --git a/pages/graphiql.tsx b/pages/graphiql.tsx deleted file mode 100644 index 8bcff9cf93..0000000000 --- a/pages/graphiql.tsx +++ /dev/null @@ -1,30 +0,0 @@ -import type { NextPage } from 'next'; -import dynamic from 'next/dynamic'; -import React from 'react'; - -import PageNextJs from 'nextjs/PageNextJs'; - -import config from 'configs/app'; -import ContentLoader from 'ui/shared/ContentLoader'; -import PageTitle from 'ui/shared/Page/PageTitle'; - -const GraphQL = dynamic(() => import('ui/graphQL/GraphQL'), { - loading: () => , - ssr: false, -}); - -const Page: NextPage = () => { - - return ( - - - - - ); -}; - -export default Page; - -export { graphIQl as getServerSideProps } from 'nextjs/getServerSideProps'; diff --git a/pages/index.tsx b/pages/index.tsx index cce48a3df7..6382dc6f75 100644 --- a/pages/index.tsx +++ b/pages/index.tsx @@ -4,13 +4,15 @@ import type { NextPageWithLayout } from 'nextjs/types'; import PageNextJs from 'nextjs/PageNextJs'; +import config from 'configs/app'; +import HomeOpSuperchain from 'ui/optimismSuperchain/home/HomeOpSuperchain'; import Home from 'ui/pages/Home'; import LayoutHome from 'ui/shared/layout/LayoutHome'; const Page: NextPageWithLayout = () => { return ( - + { config.features.opSuperchain.isEnabled ? : } ); }; diff --git a/playwright/TestApp.tsx b/playwright/TestApp.tsx index 7eaedeb6b2..d4df6dfbfa 100644 --- a/playwright/TestApp.tsx +++ b/playwright/TestApp.tsx @@ -47,7 +47,7 @@ const defaultMarketplaceContext = { setIsAutoConnectDisabled: () => {}, }; -const wagmiConfig = createConfig({ +const wagmiConfig = currentChain ? createConfig({ chains: [ currentChain ], connectors: [ mock({ @@ -59,7 +59,7 @@ const wagmiConfig = createConfig({ transports: { [currentChain.id]: http(), }, -}); +}) : undefined; const TestApp = ({ children, withSocket, appContext = defaultAppContext, marketplaceContext = defaultMarketplaceContext }: Props) => { const [ queryClient ] = React.useState(() => new QueryClient({ @@ -79,7 +79,7 @@ const TestApp = ({ children, withSocket, appContext = defaultAppContext, marketp - + { children } diff --git a/playwright/fixtures/mockEnvs.ts b/playwright/fixtures/mockEnvs.ts index 77bbc6e1ac..9e16a0c48a 100644 --- a/playwright/fixtures/mockEnvs.ts +++ b/playwright/fixtures/mockEnvs.ts @@ -53,6 +53,7 @@ export const ENVS_MAP: Record> = { ], userOps: [ [ 'NEXT_PUBLIC_HAS_USER_OPS', 'true' ], + [ 'NEXT_PUBLIC_USER_OPS_INDEXER_API_HOST', 'http://localhost:3110' ], ], hasContractAuditReports: [ [ 'NEXT_PUBLIC_HAS_CONTRACT_AUDIT_REPORTS', 'true' ], @@ -109,4 +110,7 @@ export const ENVS_MAP: Record> = { [ 'NEXT_PUBLIC_TAC_OPERATION_LIFECYCLE_API_HOST', 'http://localhost:3100' ], [ 'NEXT_PUBLIC_TAC_TON_EXPLORER_URL', 'https://testnet.tonviewer.com' ], ], + celo: [ + [ 'NEXT_PUBLIC_CELO_ENABLED', 'true' ], + ], }; diff --git a/playwright/mocks/ui/shared/recaptcha/useReCaptcha.js b/playwright/mocks/ui/shared/recaptcha/useReCaptcha.js index 7d4b9de477..e8c1731ed3 100644 --- a/playwright/mocks/ui/shared/recaptcha/useReCaptcha.js +++ b/playwright/mocks/ui/shared/recaptcha/useReCaptcha.js @@ -2,6 +2,10 @@ const useReCaptcha = () => { return { ref: { current: null }, executeAsync: () => Promise.resolve('recaptcha_token'), + fetchProtectedResource: async(fetcher) => { + const result = await fetcher(); + return result; + }, }; }; diff --git a/public/icons/name.d.ts b/public/icons/name.d.ts index 74a05345bc..7600478d8a 100644 --- a/public/icons/name.d.ts +++ b/public/icons/name.d.ts @@ -92,9 +92,9 @@ | "gear" | "globe-b" | "globe" - | "graphQL" | "heart_filled" | "heart_outline" + | "hourglass_slim" | "hourglass" | "info_filled" | "info" diff --git a/public/static/duck.png b/public/static/duck.png new file mode 100644 index 0000000000..ef5729c5a8 Binary files /dev/null and b/public/static/duck.png differ diff --git a/public/static/goose.png b/public/static/goose.png new file mode 100644 index 0000000000..d6e4af6f08 Binary files /dev/null and b/public/static/goose.png differ diff --git a/startup.node.ts b/startup.node.ts new file mode 100644 index 0000000000..f034843fff --- /dev/null +++ b/startup.node.ts @@ -0,0 +1,6 @@ +import config from 'configs/app'; +import * as multichainConfig from 'configs/multichain/config.nodejs'; + +(async() => { + config.features.opSuperchain.isEnabled && await multichainConfig.load(); +})(); diff --git a/stubs/address.ts b/stubs/address.ts index 07c8ce2734..7931e552c9 100644 --- a/stubs/address.ts +++ b/stubs/address.ts @@ -21,6 +21,7 @@ export const ADDRESS_INFO: Address = { coin_balance: '810941268802273085757', creation_transaction_hash: null, creator_address_hash: ADDRESS_HASH, + creation_status: 'success', exchange_rate: null, has_logs: true, has_token_transfers: false, @@ -119,11 +120,9 @@ export const ADDRESS_MUD_TABLE_ITEM: AddressMudTableItem = { export const EPOCH_REWARD_ITEM: AddressEpochRewardsItem = { amount: '136609473658452408568', - block_number: 10355938, block_timestamp: '2022-05-15T13:16:24Z', type: 'voter', token: TOKEN_INFO_ERC_20, - block_hash: '0x5956a847d8089e254e02e5111cad6992b99ceb9e5c2dc4343fd53002834c4dc6', account: ADDRESS_PARAMS, epoch_number: 1234, associated_account: ADDRESS_PARAMS, diff --git a/stubs/address3rdPartyWidgets.ts b/stubs/address3rdPartyWidgets.ts new file mode 100644 index 0000000000..8f69e95cc2 --- /dev/null +++ b/stubs/address3rdPartyWidgets.ts @@ -0,0 +1,11 @@ +import type { Address3rdPartyWidget } from 'types/views/address'; + +export const WIDGET_CONFIG: Address3rdPartyWidget = { + name: 'name', + url: 'url', + icon: 'icon', + title: 'title', + hint: 'hint', + pages: [ 'eoa' ], + valuePath: 'valuePath', +}; diff --git a/stubs/block.ts b/stubs/block.ts index 60e6146c47..0861bd3625 100644 --- a/stubs/block.ts +++ b/stubs/block.ts @@ -1,7 +1,6 @@ -import type { Block, BlockEpochElectionReward, BlockEpoch } from 'types/api/block'; +import type { Block } from 'types/api/block'; import { ADDRESS_PARAMS } from './addressParams'; -import { TOKEN_INFO_ERC_20, TOKEN_TRANSFER_ERC_20 } from './token'; export const BLOCK_HASH = '0x8fa7b9e5e5e79deeb62d608db22ba9a5cb45388c7ebb9223ae77331c6080dc70'; @@ -37,24 +36,3 @@ export const BLOCK: Block = { type: 'block', uncles_hashes: [], }; - -const BLOCK_EPOCH_REWARD: BlockEpochElectionReward = { - count: 10, - total: '157705500305820107521', - token: TOKEN_INFO_ERC_20, -}; - -export const BLOCK_EPOCH: BlockEpoch = { - number: 1486, - aggregated_election_rewards: { - group: BLOCK_EPOCH_REWARD, - validator: BLOCK_EPOCH_REWARD, - voter: BLOCK_EPOCH_REWARD, - delegated_payment: BLOCK_EPOCH_REWARD, - }, - distribution: { - carbon_offsetting_transfer: TOKEN_TRANSFER_ERC_20, - community_transfer: TOKEN_TRANSFER_ERC_20, - reserve_bolster_transfer: TOKEN_TRANSFER_ERC_20, - }, -}; diff --git a/stubs/contract.ts b/stubs/contract.ts index 90b3429d27..271b0f1efd 100644 --- a/stubs/contract.ts +++ b/stubs/contract.ts @@ -10,7 +10,7 @@ import { STATS_COUNTER } from './stats'; export const CONTRACT_CODE_UNVERIFIED = { creation_bytecode: '0x60806040526e', deployed_bytecode: '0x608060405233', - is_self_destructed: false, + creation_status: 'success', } as SmartContract; export const CONTRACT_CODE_VERIFIED = { diff --git a/stubs/epoch.ts b/stubs/epoch.ts new file mode 100644 index 0000000000..23b0c96923 --- /dev/null +++ b/stubs/epoch.ts @@ -0,0 +1,51 @@ +import type { CeloEpochListItem, CeloEpochDetails, CeloEpochElectionReward } from 'types/api/epochs'; + +import { BLOCK_HASH } from './block'; +import { TOKEN_INFO_ERC_20, TOKEN_TRANSFER_ERC_20, TOKEN_TRANSFER_ERC_20_TOTAL } from './token'; + +export const CELO_EPOCH_ITEM: CeloEpochListItem = { + timestamp: '2025-06-10T01:27:52.000000Z', + number: 1739, + end_block_number: 48563551, + start_block_number: 48477132, + type: 'L1', + is_finalized: true, + distribution: { + carbon_offsetting_transfer: TOKEN_TRANSFER_ERC_20_TOTAL, + community_transfer: TOKEN_TRANSFER_ERC_20_TOTAL, + transfers_total: TOKEN_TRANSFER_ERC_20_TOTAL, + }, +}; + +const CELO_EPOCH_REWARD: CeloEpochElectionReward = { + count: 10, + total: '157705500305820107521', + token: TOKEN_INFO_ERC_20, +}; + +export const CELO_EPOCH: CeloEpochDetails = { + timestamp: '2025-06-10T01:27:52.000000Z', + number: 1739, + start_block_number: 48477132, + start_processing_block_hash: BLOCK_HASH, + start_processing_block_number: 48563546, + end_processing_block_hash: BLOCK_HASH, + end_processing_block_number: 48563552, + end_block_number: 48563551, + type: 'L1', + is_finalized: true, + distribution: { + carbon_offsetting_transfer: TOKEN_TRANSFER_ERC_20, + community_transfer: TOKEN_TRANSFER_ERC_20, + transfers_total: { + token: TOKEN_INFO_ERC_20, + total: TOKEN_TRANSFER_ERC_20_TOTAL, + }, + }, + aggregated_election_rewards: { + group: CELO_EPOCH_REWARD, + validator: CELO_EPOCH_REWARD, + voter: CELO_EPOCH_REWARD, + delegated_payment: CELO_EPOCH_REWARD, + }, +}; diff --git a/stubs/optimismSuperchain.ts b/stubs/optimismSuperchain.ts new file mode 100644 index 0000000000..7f1490fbdf --- /dev/null +++ b/stubs/optimismSuperchain.ts @@ -0,0 +1,23 @@ +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, +}; diff --git a/stubs/token.ts b/stubs/token.ts index 357fb77b31..e16fd2406f 100644 --- a/stubs/token.ts +++ b/stubs/token.ts @@ -8,7 +8,7 @@ import type { TokenType, } from 'types/api/token'; import type { TokenInstanceTransferPagination, TokenInstanceTransferResponse } from 'types/api/tokens'; -import type { TokenTransfer, TokenTransferPagination, TokenTransferResponse } from 'types/api/tokenTransfer'; +import type { Erc20TotalPayload, TokenTransfer, TokenTransferPagination, TokenTransferResponse } from 'types/api/tokenTransfer'; import { ADDRESS_PARAMS, ADDRESS_HASH } from './addressParams'; import { TX_HASH } from './tx'; @@ -89,6 +89,11 @@ export const getTokenInstanceHoldersStub = (type?: TokenType, pagination: TokenH } }; +export const TOKEN_TRANSFER_ERC_20_TOTAL: Erc20TotalPayload = { + decimals: '18', + value: '9851351626684503', +}; + export const TOKEN_TRANSFER_ERC_20: TokenTransfer = { block_hash: BLOCK_HASH, block_number: '123456', @@ -98,10 +103,7 @@ export const TOKEN_TRANSFER_ERC_20: TokenTransfer = { timestamp: '2022-06-24T10:22:11.000000Z', to: ADDRESS_PARAMS, token: TOKEN_INFO_ERC_20, - total: { - decimals: '18', - value: '9851351626684503', - }, + total: TOKEN_TRANSFER_ERC_20_TOTAL, transaction_hash: TX_HASH, type: 'token_minting', }; diff --git a/toolkit/chakra/accordion.tsx b/toolkit/chakra/accordion.tsx index 333aa6ee66..2922d941b1 100644 --- a/toolkit/chakra/accordion.tsx +++ b/toolkit/chakra/accordion.tsx @@ -1,5 +1,6 @@ import { Accordion, Icon } from '@chakra-ui/react'; import * as React from 'react'; +import { scroller } from 'react-scroll'; import IndicatorIcon from 'icons/arrows/east-mini.svg'; @@ -89,3 +90,37 @@ export const AccordionRoot = (props: Accordion.RootProps) => { }; export const AccordionItem = Accordion.Item; + +export function useAccordion(items: Array<{ id: string }>) { + const [ value, setValue ] = React.useState>([]); + + const onValueChange = React.useCallback(({ value }: { value: Array }) => { + setValue(value); + }, []); + + const scrollToItemFromUrl = React.useCallback(() => { + const hash = window.location.hash.replace('#', ''); + + if (!hash) { + return; + } + + const itemToScroll = items.find((item) => item.id === hash); + if (itemToScroll) { + scroller.scrollTo(itemToScroll.id, { + duration: 500, + smooth: true, + offset: -100, + }); + setValue([ itemToScroll.id ]); + } + }, [ items ]); + + return React.useMemo(() => { + return { + value, + onValueChange, + scrollToItemFromUrl, + }; + }, [ value, onValueChange, scrollToItemFromUrl ]); +} diff --git a/toolkit/chakra/input-group.tsx b/toolkit/chakra/input-group.tsx index 7f493ade9e..6817d1a39e 100644 --- a/toolkit/chakra/input-group.tsx +++ b/toolkit/chakra/input-group.tsx @@ -59,7 +59,7 @@ export const InputGroup = React.forwardRef( return ( { startElement && ( - + { startElement } ) } @@ -76,7 +76,7 @@ export const InputGroup = React.forwardRef( }); }) } { endElement && ( - + { endElement } ) } diff --git a/toolkit/chakra/toaster.tsx b/toolkit/chakra/toaster.tsx index ac0cafed19..26a417ab4a 100644 --- a/toolkit/chakra/toaster.tsx +++ b/toolkit/chakra/toaster.tsx @@ -38,8 +38,10 @@ export const Toaster = () => { ) : null } { toast.title && { toast.title } } - { toast.description && ( - { toast.description } + { (toast.meta?.renderDescription || toast.description) && ( + + { toast.meta?.renderDescription?.() || toast.description } + ) } { toast.action && ( diff --git a/toolkit/package/package.json b/toolkit/package/package.json index c60a76462f..4534d60f39 100644 --- a/toolkit/package/package.json +++ b/toolkit/package/package.json @@ -1,6 +1,6 @@ { "name": "@blockscout/ui-toolkit", - "version": "0.0.1-alpha.4", + "version": "2.2.0-alpha", "description": "A comprehensive collection of reusable Chakra UI components and theme system for Blockscout's projects", "type": "module", "main": "./dist/index.js", diff --git a/toolkit/theme/foundations/semanticTokens.ts b/toolkit/theme/foundations/semanticTokens.ts index d7707e636f..e54a050de6 100644 --- a/toolkit/theme/foundations/semanticTokens.ts +++ b/toolkit/theme/foundations/semanticTokens.ts @@ -376,13 +376,13 @@ const semanticTokens: ThemingConfig['semanticTokens'] = { }, }, placeholder: { - DEFAULT: { value: '{colors.gray.500}' }, + DEFAULT: { value: { _light: '{colors.gray.400}', _dark: '{colors.gray.500}' } }, error: { value: '{colors.red.500}' }, }, }, field: { placeholder: { - DEFAULT: { value: '{colors.gray.500}' }, + DEFAULT: { value: { _light: '{colors.gray.400}', _dark: '{colors.gray.500}' } }, disabled: { value: '{colors.gray.500/20}' }, error: { value: '{colors.red.500}' }, }, @@ -413,12 +413,12 @@ const semanticTokens: ThemingConfig['semanticTokens'] = { }, indicator: { fg: { - DEFAULT: { value: '{colors.gray.500}' }, + DEFAULT: { value: { _light: '{colors.gray.400}', _dark: '{colors.gray.500}' } }, }, }, placeholder: { fg: { - DEFAULT: { value: '{colors.gray.500}' }, + DEFAULT: { value: { _light: '{colors.gray.400}', _dark: '{colors.gray.500}' } }, error: { value: '{colors.red.500}' }, }, }, diff --git a/toolkit/theme/recipes/input.recipe.ts b/toolkit/theme/recipes/input.recipe.ts index b5af17ef30..79c99dd376 100644 --- a/toolkit/theme/recipes/input.recipe.ts +++ b/toolkit/theme/recipes/input.recipe.ts @@ -14,6 +14,7 @@ export const recipe = defineRecipe({ color: 'input.fg', '--focus-color': 'colors.border.error', '--error-color': 'colors.border.error', + fontWeight: 'medium', _invalid: { focusRingColor: 'var(--error-color)', borderColor: 'var(--error-color)', diff --git a/toolkit/theme/recipes/select.recipe.ts b/toolkit/theme/recipes/select.recipe.ts index e272ab49d0..824729e17e 100644 --- a/toolkit/theme/recipes/select.recipe.ts +++ b/toolkit/theme/recipes/select.recipe.ts @@ -20,7 +20,7 @@ export const recipe = defineSlotRecipe({ borderRadius: 'base', userSelect: 'none', textAlign: 'start', - fontWeight: 'semibold', + fontWeight: 'medium', cursor: 'pointer', focusVisibleRing: 'none', _disabled: { diff --git a/tools/preset-sync/index.ts b/tools/preset-sync/index.ts index 5171fadf24..2a606e31a4 100755 --- a/tools/preset-sync/index.ts +++ b/tools/preset-sync/index.ts @@ -26,6 +26,7 @@ const PRESETS = { scroll_sepolia: 'https://scroll-sepolia.blockscout.com', shibarium: 'https://www.shibariumscan.io', stability_testnet: 'https://stability-testnet.blockscout.com', + tac: 'https://explorer.tac.build', tac_turin: 'https://tac-turin.blockscout.com', zkevm: 'https://zkevm.blockscout.com', zksync: 'https://zksync.blockscout.com', diff --git a/tools/scripts/build.docker.sh b/tools/scripts/build.docker.sh new file mode 100755 index 0000000000..816cfd0aca --- /dev/null +++ b/tools/scripts/build.docker.sh @@ -0,0 +1,8 @@ +#!/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 diff --git a/tools/scripts/dev.preset.sh b/tools/scripts/dev.preset.sh index f9ba4c5db5..62dbea9f4a 100755 --- a/tools/scripts/dev.preset.sh +++ b/tools/scripts/dev.preset.sh @@ -14,11 +14,23 @@ if [ ! -f "$config_file" ]; then exit 1 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 dotenv \ -e $config_file \ -- 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 echo "" diff --git a/tools/scripts/dev.sh b/tools/scripts/dev.sh index 7897742b47..7d7609671a 100755 --- a/tools/scripts/dev.sh +++ b/tools/scripts/dev.sh @@ -1,5 +1,10 @@ #!/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 dotenv \ -e .env.development.local \ diff --git a/tsconfig.json b/tsconfig.json index 0343765492..b94d190304 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -25,6 +25,7 @@ "./deploy/tools/envs-validator", "./deploy/tools/favicon-generator", "./toolkit/package", + "./deploy/tools/multichain-config-generator", "./toolkit/theme/design-system/build", "./toolkit/theme/design-system/dist", "**/*.pw.tsx" diff --git a/types/api/address.ts b/types/api/address.ts index 14e7c10647..44d4ad9968 100644 --- a/types/api/address.ts +++ b/types/api/address.ts @@ -1,8 +1,9 @@ import type { Transaction } from 'types/api/transaction'; import type { UserTags, AddressImplementation, AddressParam, AddressFilecoinParams } from './addressParams'; -import type { Block, EpochRewardsType } from './block'; -import type { SmartContractProxyType } from './contract'; +import type { Block } from './block'; +import type { SmartContractCreationStatus, SmartContractProxyType } from './contract'; +import type { CeloEpochRewardsType } from './epochs'; import type { InternalTransaction } from './internalTransaction'; import type { MudWorldSchema, MudWorldTable } from './mudWorlds'; import type { NFTTokenType, TokenInfo, TokenInstance, TokenType } from './token'; @@ -14,6 +15,7 @@ export interface Address extends UserTags { creator_address_hash: string | null; creator_filecoin_robust_address?: string | null; creation_transaction_hash: string | null; + creation_status: SmartContractCreationStatus | null; exchange_rate: string | null; ens_domain_name: string | null; filecoin?: AddressFilecoinParams; @@ -260,18 +262,16 @@ export type AddressEpochRewardsResponse = { next_page_params: { amount: string; associated_account_address_hash: string; - block_number: number; + epoch_number: number; items_count: number; - type: EpochRewardsType; + type: CeloEpochRewardsType; } | null; }; export type AddressEpochRewardsItem = { - type: EpochRewardsType; + type: CeloEpochRewardsType; token: TokenInfo; amount: string; - block_number: number; - block_hash: string; block_timestamp: string; account: AddressParam; epoch_number: number; diff --git a/types/api/block.ts b/types/api/block.ts index 3d0519e71f..1c62d7a5fa 100644 --- a/types/api/block.ts +++ b/types/api/block.ts @@ -6,7 +6,6 @@ import type { ArbitrumBatchStatus, ArbitrumL2TxData } from './arbitrumL2'; import type { InternalTransaction } from './internalTransaction'; import type { OptimisticL2BatchDataContainer, OptimisticL2BlobTypeEip4844, OptimisticL2BlobTypeCelestia } from './optimisticL2'; import type { TokenInfo } from './token'; -import type { TokenTransfer } from './tokenTransfer'; import type { ZkSyncBatchesItem } from './zkSyncL2'; export type BlockType = 'block' | 'reorg' | 'uncle'; @@ -66,7 +65,7 @@ export interface Block { // CELO FIELDS celo?: { epoch_number: number; - is_epoch_block: boolean; + l1_era_finalized_epoch_number: number | null; base_fee?: BlockBaseFeeCelo; }; // ZILLIQA FIELDS @@ -167,32 +166,3 @@ export interface BlockCountdownResponse { RemainingBlock: string; } | null; } - -export interface BlockEpochElectionReward { - count: number; - token: TokenInfo<'ERC-20'>; - total: string; -} - -export type EpochRewardsType = 'group' | 'validator' | 'delegated_payment' | 'voter'; - -export interface BlockEpoch { - number: number; - distribution: { - carbon_offsetting_transfer: TokenTransfer | null; - community_transfer: TokenTransfer | null; - reserve_bolster_transfer: TokenTransfer | null; - } | null; - aggregated_election_rewards: Record | null; -} - -export interface BlockEpochElectionRewardDetails { - account: AddressParam; - amount: string; - associated_account: AddressParam; -} - -export interface BlockEpochElectionRewardDetailsResponse { - items: Array; - next_page_params: null; -} diff --git a/types/api/configs.ts b/types/api/configs.ts index b190c0f2b9..bceb83a7f5 100644 --- a/types/api/configs.ts +++ b/types/api/configs.ts @@ -5,3 +5,7 @@ export interface BackendVersionConfig { export interface CsvExportConfig { limit: number; } + +export interface CeloConfig { + l2_migration_block: number; +} diff --git a/types/api/contract.ts b/types/api/contract.ts index 4b8cf23059..ac3ee7f519 100644 --- a/types/api/contract.ts +++ b/types/api/contract.ts @@ -3,6 +3,8 @@ import type { Abi, AbiType } from 'abitype'; export type SmartContractMethodArgType = AbiType; export type SmartContractMethodStateMutability = 'view' | 'nonpayable' | 'payable'; +export type SmartContractCreationStatus = 'success' | 'failed' | 'selfdestructed'; + export type SmartContractLicenseType = 'none' | 'unlicense' | @@ -39,7 +41,7 @@ export type SmartContractProxyType = export interface SmartContract { deployed_bytecode: string | null; creation_bytecode: string | null; - is_self_destructed: boolean; + creation_status: SmartContractCreationStatus | null; abi: Abi | null; compiler_version: string | null; evm_version: string | null; diff --git a/types/api/epochs.ts b/types/api/epochs.ts new file mode 100644 index 0000000000..8f994a1208 --- /dev/null +++ b/types/api/epochs.ts @@ -0,0 +1,68 @@ +import type { AddressParam } from './addressParams'; +import type { TokenInfo } from './token'; +import type { Erc20TotalPayload, TokenTransfer } from './tokenTransfer'; + +export type CeloEpochType = 'L1' | 'L2'; + +export type CeloEpochListItem = { + number: number; + type: CeloEpochType; + is_finalized: boolean; + start_block_number: number; + end_block_number: number | null; + timestamp: string | null; + distribution: { + carbon_offsetting_transfer: Erc20TotalPayload | null; + community_transfer: Erc20TotalPayload | null; + transfers_total: Erc20TotalPayload | null; + } | null; +}; + +export type CeloEpochListResponse = { + items: Array; + next_page_params: { + items_count: number; + number: number; + } | null; +}; + +export type CeloEpochDetails = { + number: number; + type: CeloEpochType; + is_finalized: boolean; + timestamp: string | null; + start_block_number: number; + start_processing_block_hash: string | null; + start_processing_block_number: number | null; + end_block_number: number | null; + end_processing_block_hash: string | null; + end_processing_block_number: number | null; + distribution: { + carbon_offsetting_transfer: TokenTransfer | null; + community_transfer: TokenTransfer | null; + transfers_total: { + token: TokenInfo<'ERC-20'> | null; + total: Erc20TotalPayload | null; + } | null; + } | null; + aggregated_election_rewards: Record | null; +}; + +export interface CeloEpochElectionReward { + count: number; + token: TokenInfo<'ERC-20'>; + total: string; +} + +export type CeloEpochRewardsType = 'group' | 'validator' | 'delegated_payment' | 'voter'; + +export interface CeloEpochElectionRewardDetails { + account: AddressParam; + amount: string; + associated_account: AddressParam; +} + +export interface CeloEpochElectionRewardDetailsResponse { + items: Array; + next_page_params: null; +} diff --git a/types/client/navigation.ts b/types/client/navigation.ts index 5085615267..a503da8661 100644 --- a/types/client/navigation.ts +++ b/types/client/navigation.ts @@ -31,9 +31,4 @@ export type NavGroupItem = NavItemCommon & { subItems: Array | Array>; }; -import type { ArrayElement } from '../utils'; - -export const NAVIGATION_LINK_IDS = [ 'rpc_api', 'eth_rpc_api' ] as const; -export type NavigationLinkId = ArrayElement; - export type NavigationLayout = 'vertical' | 'horizontal'; diff --git a/types/footerLinks.ts b/types/footerLinks.ts index 78dccf2af4..4e507359c7 100644 --- a/types/footerLinks.ts +++ b/types/footerLinks.ts @@ -1,6 +1,7 @@ export type CustomLink = { text: string; url: string; + iconUrl?: Array; }; export type CustomLinksGroup = { diff --git a/types/multichain.ts b/types/multichain.ts new file mode 100644 index 0000000000..d0ed6b3475 --- /dev/null +++ b/types/multichain.ts @@ -0,0 +1,10 @@ +import type config from 'configs/app'; + +export interface ChainConfig { + slug: string; + config: typeof config; +} + +export interface MultichainConfig { + chains: Array; +} diff --git a/types/views/address.ts b/types/views/address.ts index 5dc5b7904b..b726a930eb 100644 --- a/types/views/address.ts +++ b/types/views/address.ts @@ -18,3 +18,16 @@ export type AddressViewId = ArrayElement; export const ADDRESS_FORMATS = [ 'base16', 'bech32' ] as const; export type AddressFormat = typeof ADDRESS_FORMATS[ number ]; + +export const ADDRESS_3RD_PARTY_WIDGET_PAGES = [ 'eoa', 'contract', 'token' ] as const; + +export type Address3rdPartyWidget = { + name: string; + url: string; + icon: string; + title: string; + hint?: string; + valuePath: string; + pages: Array; + chainIds?: Record; +}; diff --git a/types/views/apiDocs.ts b/types/views/apiDocs.ts new file mode 100644 index 0000000000..e5f4cd2bb9 --- /dev/null +++ b/types/views/apiDocs.ts @@ -0,0 +1,8 @@ +export const API_DOCS_TABS = [ + 'rest_api', + 'eth_rpc_api', + 'rpc_api', + 'graphql_api', +] as const; + +export type ApiDocsTabId = typeof API_DOCS_TABS[ number ]; diff --git a/ui/address/Address3rdPartyWidgets.pw.tsx b/ui/address/Address3rdPartyWidgets.pw.tsx new file mode 100644 index 0000000000..9cc523e71f --- /dev/null +++ b/ui/address/Address3rdPartyWidgets.pw.tsx @@ -0,0 +1,53 @@ +import React from 'react'; + +import * as addressMock from 'mocks/address/address'; +import * as widgetsMock from 'mocks/address/widgets'; +import type { TestFnArgs } from 'playwright/lib'; +import { test, expect, devices } from 'playwright/lib'; +import * as pwConfig from 'playwright/utils/config'; + +import Address3rdPartyWidgets from './Address3rdPartyWidgets'; + +const WIDGETS_CONFIG_URL = 'http://localhost:4000/address-3rd-party-widgets-config.json'; +const ADDRESS_HASH = addressMock.hash; +const hooksConfig = { + router: { + query: { hash: ADDRESS_HASH }, + }, +}; + +const testFn = (isMobile: boolean) => async({ render, mockConfigResponse, mockAssetResponse, mockEnvs, mockApiResponse, page }: TestFnArgs) => { + await mockEnvs([ + [ 'NEXT_PUBLIC_ADDRESS_3RD_PARTY_WIDGETS', JSON.stringify(widgetsMock.widgets) ], + [ 'NEXT_PUBLIC_ADDRESS_3RD_PARTY_WIDGETS_CONFIG_URL', WIDGETS_CONFIG_URL ], + ]); + await mockConfigResponse('NEXT_PUBLIC_ADDRESS_3RD_PARTY_WIDGETS_CONFIG_URL', WIDGETS_CONFIG_URL, widgetsMock.config); + + await Promise.all(widgetsMock.widgets.map((widget, i) => + mockApiResponse( + 'general:address_3rd_party_info', + { value: widgetsMock.values[i] }, + { pathParams: { name: widget }, queryParams: { address: ADDRESS_HASH, chain_id: '1' } }, + ), + )); + await mockAssetResponse(widgetsMock.config[widgetsMock.widgets[0]].icon, './playwright/mocks/image_s.jpg'); + + const component = await render(, { hooksConfig }); + + if (!isMobile) { + await page.getByText(widgetsMock.config[widgetsMock.widgets[0]].name).hover({ force: true }); // eslint-disable-line playwright/no-force-option + } + + await expect(component).toHaveScreenshot({ + mask: [ page.locator(pwConfig.adsBannerSelector) ], + maskColor: pwConfig.maskColor, + }); +}; + +test('base view +@dark-mode', testFn(false)); + +test.describe('mobile', () => { + test.use({ viewport: devices['iPhone 13 Pro'].viewport }); + + test('base view', testFn(true)); +}); diff --git a/ui/address/Address3rdPartyWidgets.tsx b/ui/address/Address3rdPartyWidgets.tsx new file mode 100644 index 0000000000..7f867aeb13 --- /dev/null +++ b/ui/address/Address3rdPartyWidgets.tsx @@ -0,0 +1,82 @@ +import { Grid, Flex } from '@chakra-ui/react'; +import { useRouter } from 'next/router'; +import { useMemo } from 'react'; + +import type { Address3rdPartyWidget } from 'types/views/address'; + +import { route } from 'nextjs-routes'; + +import useIsMounted from 'lib/hooks/useIsMounted'; +import getQueryParamString from 'lib/router/getQueryParamString'; +import { Link } from 'toolkit/chakra/link'; + +import Address3rdPartyWidgetCard from './address3rdPartyWidgets/Address3rdPartyWidgetCard'; +import useAddress3rdPartyWidgets from './address3rdPartyWidgets/useAddress3rdPartyWidgets'; + +type Props = { + shouldRender?: boolean; + isQueryEnabled?: boolean; + addressType: Address3rdPartyWidget['pages'][number]; + showAll?: boolean; + isLoading?: boolean; +}; + +const NUMBER_OF_WIDGETS_TO_DISPLAY = 8; + +const Address3rdPartyWidgets = ({ shouldRender = true, isQueryEnabled = true, addressType, isLoading = false, showAll }: Props) => { + const router = useRouter(); + const isMounted = useIsMounted(); + + const addressHash = getQueryParamString(router.query.hash); + + const { items: widgets, configQuery } = useAddress3rdPartyWidgets(addressType, isLoading, isQueryEnabled); + + const displayedWidgets = useMemo(() => { + return showAll ? widgets : widgets.slice(0, NUMBER_OF_WIDGETS_TO_DISPLAY); + }, [ widgets, showAll ]); + + const shouldShowViewAllLink = !showAll && !isLoading && !configQuery.isPlaceholderData && widgets.length > displayedWidgets.length; + + if (!isMounted || !shouldRender) { + return null; + } + + return ( + + + { displayedWidgets.map((name) => ( + + )) } + + { shouldShowViewAllLink && ( + + View all + + ) } + + ); +}; + +export default Address3rdPartyWidgets; diff --git a/ui/address/AddressBlocksValidated.tsx b/ui/address/AddressBlocksValidated.tsx index e4a2de6d6e..cebe99b3c5 100644 --- a/ui/address/AddressBlocksValidated.tsx +++ b/ui/address/AddressBlocksValidated.tsx @@ -33,7 +33,7 @@ interface Props { } const AddressBlocksValidated = ({ shouldRender = true, isQueryEnabled = true }: Props) => { - const [ socketAlert, setSocketAlert ] = React.useState(''); + const [ showSocketAlert, setShowSocketAlert ] = React.useState(false); const [ newItemsCount, setNewItemsCount ] = React.useState(0); const queryClient = useQueryClient(); @@ -60,11 +60,11 @@ const AddressBlocksValidated = ({ shouldRender = true, isQueryEnabled = true }: }); const handleSocketError = React.useCallback(() => { - setSocketAlert('An error has occurred while fetching new blocks. Please refresh the page to load new blocks.'); + setShowSocketAlert(true); }, []); const handleNewSocketMessage: SocketMessage.NewBlock['handler'] = React.useCallback((payload) => { - setSocketAlert(''); + setShowSocketAlert(false); queryClient.setQueryData( getResourceKey('general:address_blocks_validated', { pathParams: { hash: addressHash } }), @@ -121,7 +121,7 @@ const AddressBlocksValidated = ({ shouldRender = true, isQueryEnabled = true }: @@ -140,7 +140,7 @@ const AddressBlocksValidated = ({ shouldRender = true, isQueryEnabled = true }: { query.pagination.page === 1 && ( diff --git a/ui/address/AddressDetails.pw.tsx b/ui/address/AddressDetails.pw.tsx index 47acd3d24c..b7110df1bc 100644 --- a/ui/address/AddressDetails.pw.tsx +++ b/ui/address/AddressDetails.pw.tsx @@ -3,13 +3,17 @@ import React from 'react'; import * as addressMock from 'mocks/address/address'; import * as countersMock from 'mocks/address/counters'; import * as tokensMock from 'mocks/address/tokens'; +import * as widgetsMock from 'mocks/address/widgets'; +import type { TestFnArgs } from 'playwright/lib'; import { test, expect, devices } from 'playwright/lib'; import * as pwConfig from 'playwright/utils/config'; import AddressDetails from './AddressDetails'; import MockAddressPage from './testUtils/MockAddressPage'; +import type { AddressCountersQuery } from './utils/useAddressCountersQuery'; import type { AddressQuery } from './utils/useAddressQuery'; +const WIDGETS_CONFIG_URL = 'http://localhost:4000/address-3rd-party-widgets-config.json'; const ADDRESS_HASH = addressMock.hash; const hooksConfig = { router: { @@ -17,14 +21,55 @@ const hooksConfig = { }, }; +const testWidgetsFn = (isMobile: boolean) => async({ render, mockConfigResponse, mockAssetResponse, mockEnvs, mockApiResponse, page }: TestFnArgs) => { + await mockEnvs([ + [ 'NEXT_PUBLIC_ADDRESS_3RD_PARTY_WIDGETS', JSON.stringify(widgetsMock.widgets) ], + [ 'NEXT_PUBLIC_ADDRESS_3RD_PARTY_WIDGETS_CONFIG_URL', WIDGETS_CONFIG_URL ], + ]); + await mockConfigResponse('NEXT_PUBLIC_ADDRESS_3RD_PARTY_WIDGETS_CONFIG_URL', WIDGETS_CONFIG_URL, widgetsMock.config); + + await mockApiResponse('general:address', addressMock.contract, { pathParams: { hash: ADDRESS_HASH } }); + await mockApiResponse('general:address_counters', countersMock.forContract, { pathParams: { hash: ADDRESS_HASH } }); + await Promise.all(widgetsMock.widgets.map((widget, i) => + mockApiResponse( + 'general:address_3rd_party_info', + { value: widgetsMock.values[i] }, + { pathParams: { name: widget }, queryParams: { address: ADDRESS_HASH, chain_id: '1' } }, + ), + )); + await mockAssetResponse(widgetsMock.config[widgetsMock.widgets[0]].icon, './playwright/mocks/image_s.jpg'); + + const component = await render( + , + { hooksConfig }, + ); + + if (!isMobile) { + await page.getByText(widgetsMock.config[widgetsMock.widgets[0]].name).hover({ force: true }); // eslint-disable-line playwright/no-force-option + } + + await expect(component).toHaveScreenshot({ + mask: [ page.locator(pwConfig.adsBannerSelector) ], + maskColor: pwConfig.maskColor, + }); +}; + test.describe('mobile', () => { test.use({ viewport: devices['iPhone 13 Pro'].viewport }); test('contract', async({ render, mockApiResponse, page }) => { await mockApiResponse('general:address', addressMock.contract, { pathParams: { hash: ADDRESS_HASH } }); - await mockApiResponse('general:address_counters', countersMock.forContract, { pathParams: { hash: ADDRESS_HASH } }); - const component = await render(, { hooksConfig }); + const component = await render( + , + { hooksConfig }, + ); await expect(component).toHaveScreenshot({ mask: [ page.locator(pwConfig.adsBannerSelector) ], @@ -34,9 +79,14 @@ test.describe('mobile', () => { test('validator', async({ render, page, mockApiResponse }) => { await mockApiResponse('general:address', addressMock.validator, { pathParams: { hash: ADDRESS_HASH } }); - await mockApiResponse('general:address_counters', countersMock.forValidator, { pathParams: { hash: ADDRESS_HASH } }); - const component = await render(, { hooksConfig }); + const component = await render( + , + { hooksConfig }, + ); await expect(component).toHaveScreenshot({ mask: [ page.locator(pwConfig.adsBannerSelector) ], @@ -46,22 +96,34 @@ test.describe('mobile', () => { test('filecoin', async({ render, mockApiResponse, page }) => { await mockApiResponse('general:address', addressMock.filecoin, { pathParams: { hash: ADDRESS_HASH } }); - await mockApiResponse('general:address_counters', countersMock.forValidator, { pathParams: { hash: ADDRESS_HASH } }); - const component = await render(, { hooksConfig }); + const component = await render( + , + { hooksConfig }, + ); await expect(component).toHaveScreenshot({ mask: [ page.locator(pwConfig.adsBannerSelector) ], maskColor: pwConfig.maskColor, }); }); + + test('with widgets', testWidgetsFn(true)); }); test('contract', async({ render, page, mockApiResponse }) => { await mockApiResponse('general:address', addressMock.contract, { pathParams: { hash: ADDRESS_HASH } }); - await mockApiResponse('general:address_counters', countersMock.forContract, { pathParams: { hash: ADDRESS_HASH } }); - const component = await render(, { hooksConfig }); + const component = await render( + , + { hooksConfig }, + ); await expect(component).toHaveScreenshot({ mask: [ page.locator(pwConfig.adsBannerSelector) ], @@ -72,7 +134,6 @@ test('contract', async({ render, page, mockApiResponse }) => { // there's an unexpected timeout occurred in this test test.fixme('token', async({ render, mockApiResponse, injectMetaMaskProvider, page }) => { await mockApiResponse('general:address', addressMock.token, { pathParams: { hash: ADDRESS_HASH } }); - await mockApiResponse('general:address_counters', countersMock.forToken, { pathParams: { hash: ADDRESS_HASH } }); await mockApiResponse('general:address_tokens', tokensMock.erc20List, { pathParams: { hash: ADDRESS_HASH }, queryParams: { type: 'ERC-20' }, times: 1 }); await mockApiResponse('general:address_tokens', tokensMock.erc721List, { pathParams: { hash: ADDRESS_HASH }, queryParams: { type: 'ERC-721' }, times: 1 }); await mockApiResponse('general:address_tokens', tokensMock.erc1155List, { pathParams: { hash: ADDRESS_HASH }, queryParams: { type: 'ERC-1155' }, times: 1 }); @@ -81,7 +142,7 @@ test.fixme('token', async({ render, mockApiResponse, injectMetaMaskProvider, pag const component = await render( - + , { hooksConfig }, ); @@ -94,9 +155,14 @@ test.fixme('token', async({ render, mockApiResponse, injectMetaMaskProvider, pag test('validator', async({ render, mockApiResponse, page }) => { await mockApiResponse('general:address', addressMock.validator, { pathParams: { hash: ADDRESS_HASH } }); - await mockApiResponse('general:address_counters', countersMock.forValidator, { pathParams: { hash: ADDRESS_HASH } }); - const component = await render(, { hooksConfig }); + const component = await render( + , + { hooksConfig }, + ); await expect(component).toHaveScreenshot({ mask: [ page.locator(pwConfig.adsBannerSelector) ], @@ -106,12 +172,19 @@ test('validator', async({ render, mockApiResponse, page }) => { test('filecoin', async({ render, mockApiResponse, page }) => { await mockApiResponse('general:address', addressMock.filecoin, { pathParams: { hash: ADDRESS_HASH } }); - await mockApiResponse('general:address_counters', countersMock.forValidator, { pathParams: { hash: ADDRESS_HASH } }); - const component = await render(, { hooksConfig }); + const component = await render( + , + { hooksConfig }, + ); await expect(component).toHaveScreenshot({ mask: [ page.locator(pwConfig.adsBannerSelector) ], maskColor: pwConfig.maskColor, }); }); + +test('with widgets', testWidgetsFn(false)); diff --git a/ui/address/AddressDetails.tsx b/ui/address/AddressDetails.tsx index caec177a10..7fb052765f 100644 --- a/ui/address/AddressDetails.tsx +++ b/ui/address/AddressDetails.tsx @@ -17,7 +17,10 @@ import DetailedInfoSponsoredItem from 'ui/shared/DetailedInfo/DetailedInfoSponso import AddressEntity from 'ui/shared/entities/address/AddressEntity'; import BlockEntity from 'ui/shared/entities/block/BlockEntity'; import TxEntity from 'ui/shared/entities/tx/TxEntity'; +import ContractCreationStatus from 'ui/shared/statusTag/ContractCreationStatus'; +import Address3rdPartyWidgets from './Address3rdPartyWidgets'; +import useAddress3rdPartyWidgets from './address3rdPartyWidgets/useAddress3rdPartyWidgets'; import AddressAlternativeFormat from './details/AddressAlternativeFormat'; import AddressBalance from './details/AddressBalance'; import AddressImplementations from './details/AddressImplementations'; @@ -26,23 +29,21 @@ import AddressNetWorth from './details/AddressNetWorth'; import AddressSaveOnGas from './details/AddressSaveOnGas'; import FilecoinActorTag from './filecoin/FilecoinActorTag'; import TokenSelect from './tokenSelect/TokenSelect'; -import useAddressCountersQuery from './utils/useAddressCountersQuery'; +import type { AddressCountersQuery } from './utils/useAddressCountersQuery'; import type { AddressQuery } from './utils/useAddressQuery'; interface Props { addressQuery: AddressQuery; + countersQuery: AddressCountersQuery; isLoading?: boolean; } -const AddressDetails = ({ addressQuery, isLoading }: Props) => { +const AddressDetails = ({ addressQuery, countersQuery, isLoading }: Props) => { const router = useRouter(); const addressHash = getQueryParamString(router.query.hash); - const countersQuery = useAddressCountersQuery({ - hash: addressHash, - addressQuery, - }); + const address3rdPartyWidgets = useAddress3rdPartyWidgets(addressQuery.data?.is_contract ? 'contract' : 'eoa', addressQuery.isPlaceholderData); const error404Data = React.useMemo(() => ({ hash: addressHash || '', @@ -152,6 +153,7 @@ const AddressDetails = ({ addressQuery, isLoading }: Props) => { /> at txn + { data.creation_status && } ) } @@ -304,6 +306,23 @@ const AddressDetails = ({ addressQuery, isLoading }: Props) => { ) } + + { (address3rdPartyWidgets.isEnabled && address3rdPartyWidgets.items.length > 0) && ( + <> + + Widgets + + + + + + ) } ); diff --git a/ui/address/AddressEpochRewards.tsx b/ui/address/AddressEpochRewards.tsx index 5511801988..c1fe5f3fdb 100644 --- a/ui/address/AddressEpochRewards.tsx +++ b/ui/address/AddressEpochRewards.tsx @@ -12,7 +12,7 @@ import DataListDisplay from 'ui/shared/DataListDisplay'; import Pagination from 'ui/shared/pagination/Pagination'; import useQueryWithPages from 'ui/shared/pagination/useQueryWithPages'; -import AddressCsvExportLink from './AddressCsvExportLink'; +// import AddressCsvExportLink from './AddressCsvExportLink'; import AddressEpochRewardsListItem from './epochRewards/AddressEpochRewardsListItem'; type Props = { @@ -38,7 +38,7 @@ const AddressEpochRewards = ({ shouldRender = true, isQueryEnabled = true }: Pro items_count: 50, type: 'voter', associated_account_address_hash: '1', - block_number: 10355938, + epoch_number: 10355938, } }), }, }); @@ -59,7 +59,7 @@ const AddressEpochRewards = ({ shouldRender = true, isQueryEnabled = true }: Pro { rewardsQuery.data.items.map((item, index) => ( @@ -70,13 +70,17 @@ const AddressEpochRewards = ({ shouldRender = true, isQueryEnabled = true }: Pro const actionBar = rewardsQuery.pagination.isVisible ? ( - */ } + - ) : null; diff --git a/ui/address/AddressTokenTransfers.tsx b/ui/address/AddressTokenTransfers.tsx index 8855a04aa9..2acf4b22f3 100644 --- a/ui/address/AddressTokenTransfers.tsx +++ b/ui/address/AddressTokenTransfers.tsx @@ -73,7 +73,7 @@ const AddressTokenTransfers = ({ overloadCount = OVERLOAD_COUNT, shouldRender = const currentAddress = getQueryParamString(router.query.hash); - const [ socketAlert, setSocketAlert ] = React.useState(''); + const [ showSocketAlert, setShowSocketAlert ] = React.useState(false); const [ newItemsCount, setNewItemsCount ] = React.useState(0); const [ filters, setFilters ] = React.useState( @@ -109,7 +109,7 @@ const AddressTokenTransfers = ({ overloadCount = OVERLOAD_COUNT, shouldRender = }, [ filters, onFilterChange ]); const handleNewSocketMessage: SocketMessage.AddressTokenTransfer['handler'] = (payload) => { - setSocketAlert(''); + setShowSocketAlert(false); const newItems: Array = []; let newCount = 0; @@ -152,11 +152,11 @@ const AddressTokenTransfers = ({ overloadCount = OVERLOAD_COUNT, shouldRender = }; const handleSocketClose = React.useCallback(() => { - setSocketAlert('Connection is lost. Please refresh the page to load new token transfers.'); + setShowSocketAlert(true); }, []); const handleSocketError = React.useCallback(() => { - setSocketAlert('An error has occurred while fetching new token transfers. Please refresh the page.'); + setShowSocketAlert(true); }, []); const channel = useSocketChannel({ @@ -189,7 +189,7 @@ const AddressTokenTransfers = ({ overloadCount = OVERLOAD_COUNT, shouldRender = top={ isActionBarHidden ? 0 : ACTION_BAR_HEIGHT_DESKTOP } enableTimeIncrement showSocketInfo={ pagination.page === 1 } - socketInfoAlert={ socketAlert } + showSocketErrorAlert={ showSocketAlert } socketInfoNum={ newItemsCount } isLoading={ isPlaceholderData } /> @@ -198,7 +198,7 @@ const AddressTokenTransfers = ({ overloadCount = OVERLOAD_COUNT, shouldRender = { pagination.page === 1 && ( diff --git a/ui/address/AddressTxs.tsx b/ui/address/AddressTxs.tsx index 6df872fad6..e8dd35624c 100644 --- a/ui/address/AddressTxs.tsx +++ b/ui/address/AddressTxs.tsx @@ -1,28 +1,16 @@ import { useRouter } from 'next/router'; 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 useIsMounted from 'lib/hooks/useIsMounted'; 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 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 { SORT_OPTIONS } from 'ui/txs/useTxsSort'; import AddressCsvExportLink from './AddressCsvExportLink'; import AddressTxsFilter from './AddressTxsFilter'; - -const getFilterValue = (getFilterValueFromQuery).bind(null, AddressFromToFilterValues); +import useAddressTxsQuery from './useAddressTxsQuery'; type Props = { shouldRender?: boolean; @@ -32,37 +20,14 @@ type Props = { const AddressTxs = ({ shouldRender = true, isQueryEnabled = true }: Props) => { const router = useRouter(); const isMounted = useIsMounted(); - - const [ sort, setSort ] = React.useState(getSortValueFromQuery(router.query, SORT_OPTIONS) || 'default'); - const isMobile = useIsMobile(); const currentAddress = getQueryParamString(router.query.hash); - const initialFilterValue = getFilterValue(router.query.filter); - const [ filterValue, setFilterValue ] = React.useState(initialFilterValue); - - const addressTxsQuery = useQueryWithPages({ - resourceName: 'general:address_txs', - pathParams: { hash: currentAddress }, - filters: { filter: filterValue }, - sorting: getSortParamsFromValue(sort), - options: { - enabled: isQueryEnabled, - placeholderData: generateListStub<'general:address_txs'>(TX, 50, { next_page_params: { - block_number: 9005713, - index: 5, - items_count: 50, - } }), - }, + const { query, filterValue, initialFilterValue, onFilterChange, sort, setSort } = useAddressTxsQuery({ + addressHash: currentAddress, + enabled: isQueryEnabled, }); - const handleFilterChange = React.useCallback((val: string | Array) => { - - const newVal = getFilterValue(val); - setFilterValue(newVal); - addressTxsQuery.onFilterChange({ filter: newVal }); - }, [ addressTxsQuery ]); - if (!isMounted || !shouldRender) { return null; } @@ -70,9 +35,9 @@ const AddressTxs = ({ shouldRender = true, isQueryEnabled = true }: Props) => { const filter = ( ); @@ -81,7 +46,7 @@ const AddressTxs = ({ shouldRender = true, isQueryEnabled = true }: Props) => { address={ currentAddress } params={{ type: 'transactions', filterType: 'address', filterValue }} ml="auto" - isLoading={ addressTxsQuery.pagination.isLoading } + isLoading={ query.pagination.isLoading } /> ); @@ -91,13 +56,13 @@ const AddressTxs = ({ shouldRender = true, isQueryEnabled = true }: Props) => { { filter } { currentAddress && csvExportLink } - + ) } ) { + return tpl.replace(/\{\s*(\w+)\s*\}/g, (_, key) => ctx[key] ?? ''); +} + +const Address3rdPartyWidgetCard = ({ name, config, address, ...props }: Props) => { + const { data, isLoading: isDataLoading } = useWidgetData(name, config?.valuePath, address, props.isLoading); + + const isLoading = props.isLoading || isDataLoading; + + const handleClick = useCallback(() => { + mixpanel.logEvent(mixpanel.EventTypes.ADDRESS_WIDGET, { Name: name }); + }, [ name ]); + + if (!config) { + return null; + } + + const url = formatUrl(config.url, { + address, + addressLowercase: address.toLowerCase(), + chainId: config.chainIds?.[chainId] ?? chainId, + }); + + const [ integer, decimal ] = data?.split('.') || []; + + const content = isLoading ? ( + <> + + + + + + + + + ) : ( + <> + + { data ? ( + + { integer } + { decimal && ( + <> + . + + { decimal } + + + ) } + + ) : ( + { ndash } + ) } + + { config.title } + { config.hint && ( + + ) } + + + + { + + + { config.name } + + + + + + ); + + return ( + + + { content } + + + ); +}; + +export default Address3rdPartyWidgetCard; diff --git a/ui/address/address3rdPartyWidgets/useAddress3rdPartyWidgets.tsx b/ui/address/address3rdPartyWidgets/useAddress3rdPartyWidgets.tsx new file mode 100644 index 0000000000..16802573b8 --- /dev/null +++ b/ui/address/address3rdPartyWidgets/useAddress3rdPartyWidgets.tsx @@ -0,0 +1,31 @@ +import { useMemo } from 'react'; + +import type { Address3rdPartyWidget } from 'types/views/address'; + +import config from 'configs/app'; + +import useWidgetsConfigQuery from './useWidgetsConfigQuery'; + +const feature = config.features.address3rdPartyWidgets; +const widgets = (feature.isEnabled && feature.widgets) || []; + +export default function useAddress3rdPartyWidgets( + addressType: Address3rdPartyWidget['pages'][number], + isLoading = false, + isQueryEnabled = true, +) { + const configQuery = useWidgetsConfigQuery(isQueryEnabled); + + const items = useMemo(() => { + if (configQuery.isPlaceholderData || isLoading) { + return widgets; + } + return widgets.filter((widget) => configQuery.data?.[widget]?.pages.includes(addressType)); + }, [ configQuery, isLoading, addressType ]); + + return { + isEnabled: feature.isEnabled, + items, + configQuery, + }; +} diff --git a/ui/address/address3rdPartyWidgets/useWidgetData.ts b/ui/address/address3rdPartyWidgets/useWidgetData.ts new file mode 100644 index 0000000000..2c81941355 --- /dev/null +++ b/ui/address/address3rdPartyWidgets/useWidgetData.ts @@ -0,0 +1,39 @@ +import { get } from 'es-toolkit/compat'; + +import config from 'configs/app'; +import useApiQuery from 'lib/api/useApiQuery'; + +const RESOURCE_NAME = 'general:address_3rd_party_info'; + +const formatValue = (value: unknown): string | undefined => { + if (typeof value !== 'number' && typeof value !== 'string') { + return undefined; + } + + const num = Number(value); + if (!isNaN(num)) { + return num.toLocaleString(); + } + + return String(value); +}; + +export default function useWidgetData(name: string, valuePath: string | undefined, address: string, isLoading: boolean) { + const query = useApiQuery(RESOURCE_NAME, { + pathParams: { name }, + queryParams: { address, chain_id: config.chain.id }, + queryOptions: { + select: (response) => { + try { + const value = get(response, valuePath || ''); + return formatValue(value); + } catch { + return undefined; + } + }, + enabled: !isLoading && Boolean(valuePath), + }, + }); + + return query; +} diff --git a/ui/address/address3rdPartyWidgets/useWidgetsConfigQuery.tsx b/ui/address/address3rdPartyWidgets/useWidgetsConfigQuery.tsx new file mode 100644 index 0000000000..c2d53cea36 --- /dev/null +++ b/ui/address/address3rdPartyWidgets/useWidgetsConfigQuery.tsx @@ -0,0 +1,24 @@ +import { useQuery } from '@tanstack/react-query'; + +import type { Address3rdPartyWidget } from 'types/views/address'; + +import config from 'configs/app'; +import type { ResourceError } from 'lib/api/resources'; +import useApiFetch from 'lib/hooks/useFetch'; +import { WIDGET_CONFIG } from 'stubs/address3rdPartyWidgets'; + +const feature = config.features.address3rdPartyWidgets; +const configUrl = (feature.isEnabled && feature.configUrl) || ''; +const widgets = (feature.isEnabled && feature.widgets) || []; + +export default function useWidgetsConfigQuery(isQueryEnabled = true) { + const apiFetch = useApiFetch(); + + return useQuery, Record>({ + queryKey: [ 'address-3rd-party-widgets-config' ], + queryFn: async() => apiFetch(configUrl, undefined, { resource: 'address-3rd-party-widgets-config' }), + placeholderData: widgets.reduce((acc, widget) => ({ ...acc, [widget]: WIDGET_CONFIG }), {}), + staleTime: Infinity, + enabled: Boolean(configUrl) && isQueryEnabled, + }); +} diff --git a/ui/address/contract/ContractDetailsByteCode.tsx b/ui/address/contract/ContractDetailsByteCode.tsx new file mode 100644 index 0000000000..0e1a91688a --- /dev/null +++ b/ui/address/contract/ContractDetailsByteCode.tsx @@ -0,0 +1,69 @@ +import { Flex } from '@chakra-ui/react'; +import React from 'react'; + +import type { Address } from 'types/api/address'; +import type { SmartContract } from 'types/api/contract'; + +import { Alert } from 'toolkit/chakra/alert'; +import RawDataSnippet from 'ui/shared/RawDataSnippet'; + +import ContractDetailsVerificationButton from './ContractDetailsVerificationButton'; + +interface Props { + data: SmartContract; + isLoading: boolean; + addressData: Address; +} + +const ContractDetailsByteCode = ({ data, isLoading, addressData }: Props) => { + const canBeVerified = ![ 'selfdestructed', 'failed' ].includes(data.creation_status || '') && !data?.is_verified && addressData.proxy_type !== 'eip7702'; + + const verificationButton = ( + + ); + + const creationStatusText = (() => { + switch (data.creation_status) { + case 'selfdestructed': + return 'This contract self-destructed after deployment and there is no runtime bytecode. Below is the raw creation bytecode.'; + case 'failed': + return 'Contract creation failed and there is no runtime bytecode. Below is the raw creation bytecode.'; + default: + return null; + } + })(); + + return ( + + { data?.creation_bytecode && ( + + { creationStatusText } + + ) : null } + textareaMaxHeight="300px" + isLoading={ isLoading } + /> + ) } + { data?.deployed_bytecode && ( + + ) } + + ); +}; + +export default React.memo(ContractDetailsByteCode); diff --git a/ui/address/contract/__screenshots__/ContractDetails.pw.tsx_dark-color-mode_full-view-source-code-dark-mode-1.png b/ui/address/contract/__screenshots__/ContractDetails.pw.tsx_dark-color-mode_full-view-source-code-dark-mode-1.png index 538bcf6e89..7ad787865d 100644 Binary files a/ui/address/contract/__screenshots__/ContractDetails.pw.tsx_dark-color-mode_full-view-source-code-dark-mode-1.png and b/ui/address/contract/__screenshots__/ContractDetails.pw.tsx_dark-color-mode_full-view-source-code-dark-mode-1.png differ diff --git a/ui/address/contract/__screenshots__/ContractDetails.pw.tsx_default_full-view-abi-1.png b/ui/address/contract/__screenshots__/ContractDetails.pw.tsx_default_full-view-abi-1.png index e7b1772fb6..0aa7080517 100644 Binary files a/ui/address/contract/__screenshots__/ContractDetails.pw.tsx_default_full-view-abi-1.png and b/ui/address/contract/__screenshots__/ContractDetails.pw.tsx_default_full-view-abi-1.png differ diff --git a/ui/address/contract/__screenshots__/ContractDetails.pw.tsx_default_full-view-bytecode-1.png b/ui/address/contract/__screenshots__/ContractDetails.pw.tsx_default_full-view-bytecode-1.png index a999d45939..05829a2710 100644 Binary files a/ui/address/contract/__screenshots__/ContractDetails.pw.tsx_default_full-view-bytecode-1.png and b/ui/address/contract/__screenshots__/ContractDetails.pw.tsx_default_full-view-bytecode-1.png differ diff --git a/ui/address/contract/__screenshots__/ContractDetails.pw.tsx_default_full-view-compiler-1.png b/ui/address/contract/__screenshots__/ContractDetails.pw.tsx_default_full-view-compiler-1.png index 24c7955e64..9e7cccc3f6 100644 Binary files a/ui/address/contract/__screenshots__/ContractDetails.pw.tsx_default_full-view-compiler-1.png and b/ui/address/contract/__screenshots__/ContractDetails.pw.tsx_default_full-view-compiler-1.png differ diff --git a/ui/address/contract/__screenshots__/ContractDetails.pw.tsx_default_full-view-source-code-dark-mode-1.png b/ui/address/contract/__screenshots__/ContractDetails.pw.tsx_default_full-view-source-code-dark-mode-1.png index 2d840ff7c7..5bcb2c08b2 100644 Binary files a/ui/address/contract/__screenshots__/ContractDetails.pw.tsx_default_full-view-source-code-dark-mode-1.png and b/ui/address/contract/__screenshots__/ContractDetails.pw.tsx_default_full-view-source-code-dark-mode-1.png differ diff --git a/ui/address/contract/__screenshots__/ContractDetails.pw.tsx_default_mobile-view-source-code-1.png b/ui/address/contract/__screenshots__/ContractDetails.pw.tsx_default_mobile-view-source-code-1.png index 6772283e9c..d63b48b0ee 100644 Binary files a/ui/address/contract/__screenshots__/ContractDetails.pw.tsx_default_mobile-view-source-code-1.png and b/ui/address/contract/__screenshots__/ContractDetails.pw.tsx_default_mobile-view-source-code-1.png differ diff --git a/ui/address/contract/__screenshots__/ContractDetails.pw.tsx_default_non-verified-1.png b/ui/address/contract/__screenshots__/ContractDetails.pw.tsx_default_non-verified-1.png index b3c60bd9c6..14a4007343 100644 Binary files a/ui/address/contract/__screenshots__/ContractDetails.pw.tsx_default_non-verified-1.png and b/ui/address/contract/__screenshots__/ContractDetails.pw.tsx_default_non-verified-1.png differ diff --git a/ui/address/contract/__screenshots__/ContractDetails.pw.tsx_default_self-destructed-1.png b/ui/address/contract/__screenshots__/ContractDetails.pw.tsx_default_self-destructed-1.png index c15b922be6..fd8098cf68 100644 Binary files a/ui/address/contract/__screenshots__/ContractDetails.pw.tsx_default_self-destructed-1.png and b/ui/address/contract/__screenshots__/ContractDetails.pw.tsx_default_self-destructed-1.png differ diff --git a/ui/address/contract/audits/__screenshots__/ContractSubmitAuditForm.pw.tsx_default_base-view-1.png b/ui/address/contract/audits/__screenshots__/ContractSubmitAuditForm.pw.tsx_default_base-view-1.png index 1445414820..e6e9ff2927 100644 Binary files a/ui/address/contract/audits/__screenshots__/ContractSubmitAuditForm.pw.tsx_default_base-view-1.png and b/ui/address/contract/audits/__screenshots__/ContractSubmitAuditForm.pw.tsx_default_base-view-1.png differ diff --git a/ui/address/contract/methods/__screenshots__/ContractMethodsCustom.pw.tsx_default_with-data-1.png b/ui/address/contract/methods/__screenshots__/ContractMethodsCustom.pw.tsx_default_with-data-1.png index 86cab0c619..b075a238ef 100644 Binary files a/ui/address/contract/methods/__screenshots__/ContractMethodsCustom.pw.tsx_default_with-data-1.png and b/ui/address/contract/methods/__screenshots__/ContractMethodsCustom.pw.tsx_default_with-data-1.png differ diff --git a/ui/address/contract/methods/__screenshots__/ContractMethodsProxy.pw.tsx_default_with-multiple-implementations-mobile-1.png b/ui/address/contract/methods/__screenshots__/ContractMethodsProxy.pw.tsx_default_with-multiple-implementations-mobile-1.png index 2be9114a97..71dc5bfbad 100644 Binary files a/ui/address/contract/methods/__screenshots__/ContractMethodsProxy.pw.tsx_default_with-multiple-implementations-mobile-1.png and b/ui/address/contract/methods/__screenshots__/ContractMethodsProxy.pw.tsx_default_with-multiple-implementations-mobile-1.png differ diff --git a/ui/address/contract/methods/__screenshots__/ContractMethodsProxy.pw.tsx_default_with-one-implementation-mobile-1.png b/ui/address/contract/methods/__screenshots__/ContractMethodsProxy.pw.tsx_default_with-one-implementation-mobile-1.png index 0a8ff65e16..f58d4513fb 100644 Binary files a/ui/address/contract/methods/__screenshots__/ContractMethodsProxy.pw.tsx_default_with-one-implementation-mobile-1.png and b/ui/address/contract/methods/__screenshots__/ContractMethodsProxy.pw.tsx_default_with-one-implementation-mobile-1.png differ diff --git a/ui/address/contract/methods/__screenshots__/ContractMethodsProxy.pw.tsx_mobile_with-multiple-implementations-mobile-1.png b/ui/address/contract/methods/__screenshots__/ContractMethodsProxy.pw.tsx_mobile_with-multiple-implementations-mobile-1.png index 7eb8caeb4f..01b3593b43 100644 Binary files a/ui/address/contract/methods/__screenshots__/ContractMethodsProxy.pw.tsx_mobile_with-multiple-implementations-mobile-1.png and b/ui/address/contract/methods/__screenshots__/ContractMethodsProxy.pw.tsx_mobile_with-multiple-implementations-mobile-1.png differ diff --git a/ui/address/contract/methods/__screenshots__/ContractMethodsProxy.pw.tsx_mobile_with-one-implementation-mobile-1.png b/ui/address/contract/methods/__screenshots__/ContractMethodsProxy.pw.tsx_mobile_with-one-implementation-mobile-1.png index 745fb38f98..28454447cd 100644 Binary files a/ui/address/contract/methods/__screenshots__/ContractMethodsProxy.pw.tsx_mobile_with-one-implementation-mobile-1.png and b/ui/address/contract/methods/__screenshots__/ContractMethodsProxy.pw.tsx_mobile_with-one-implementation-mobile-1.png differ diff --git a/ui/address/contract/methods/__screenshots__/ContractMethodsRegular.pw.tsx_dark-color-mode_all-methods-dark-mode-1.png b/ui/address/contract/methods/__screenshots__/ContractMethodsRegular.pw.tsx_dark-color-mode_all-methods-dark-mode-1.png index 1c81892f3a..5d8947f750 100644 Binary files a/ui/address/contract/methods/__screenshots__/ContractMethodsRegular.pw.tsx_dark-color-mode_all-methods-dark-mode-1.png and b/ui/address/contract/methods/__screenshots__/ContractMethodsRegular.pw.tsx_dark-color-mode_all-methods-dark-mode-1.png differ diff --git a/ui/address/contract/methods/__screenshots__/ContractMethodsRegular.pw.tsx_default_all-methods-dark-mode-1.png b/ui/address/contract/methods/__screenshots__/ContractMethodsRegular.pw.tsx_default_all-methods-dark-mode-1.png index 5a4b2f3c69..045759fb2f 100644 Binary files a/ui/address/contract/methods/__screenshots__/ContractMethodsRegular.pw.tsx_default_all-methods-dark-mode-1.png and b/ui/address/contract/methods/__screenshots__/ContractMethodsRegular.pw.tsx_default_all-methods-dark-mode-1.png differ diff --git a/ui/address/contract/methods/__screenshots__/ContractMethodsRegular.pw.tsx_default_all-methods-mobile-1.png b/ui/address/contract/methods/__screenshots__/ContractMethodsRegular.pw.tsx_default_all-methods-mobile-1.png index 02ce6b5084..86041851dd 100644 Binary files a/ui/address/contract/methods/__screenshots__/ContractMethodsRegular.pw.tsx_default_all-methods-mobile-1.png and b/ui/address/contract/methods/__screenshots__/ContractMethodsRegular.pw.tsx_default_all-methods-mobile-1.png differ diff --git a/ui/address/contract/methods/form/__screenshots__/ContractMethodForm.pw.tsx_dark-color-mode_base-view-mobile-dark-mode-1.png b/ui/address/contract/methods/form/__screenshots__/ContractMethodForm.pw.tsx_dark-color-mode_base-view-mobile-dark-mode-1.png index 232d39e1cd..19542f413c 100644 Binary files a/ui/address/contract/methods/form/__screenshots__/ContractMethodForm.pw.tsx_dark-color-mode_base-view-mobile-dark-mode-1.png and b/ui/address/contract/methods/form/__screenshots__/ContractMethodForm.pw.tsx_dark-color-mode_base-view-mobile-dark-mode-1.png differ diff --git a/ui/address/contract/methods/form/__screenshots__/ContractMethodForm.pw.tsx_default_base-view-mobile-dark-mode-1.png b/ui/address/contract/methods/form/__screenshots__/ContractMethodForm.pw.tsx_default_base-view-mobile-dark-mode-1.png index d128dfc389..d3c5e18b05 100644 Binary files a/ui/address/contract/methods/form/__screenshots__/ContractMethodForm.pw.tsx_default_base-view-mobile-dark-mode-1.png and b/ui/address/contract/methods/form/__screenshots__/ContractMethodForm.pw.tsx_default_base-view-mobile-dark-mode-1.png differ diff --git a/ui/address/contract/methods/form/__screenshots__/ContractMethodForm.pw.tsx_mobile_base-view-mobile-dark-mode-1.png b/ui/address/contract/methods/form/__screenshots__/ContractMethodForm.pw.tsx_mobile_base-view-mobile-dark-mode-1.png index 5317b163e3..381c1aef0b 100644 Binary files a/ui/address/contract/methods/form/__screenshots__/ContractMethodForm.pw.tsx_mobile_base-view-mobile-dark-mode-1.png and b/ui/address/contract/methods/form/__screenshots__/ContractMethodForm.pw.tsx_mobile_base-view-mobile-dark-mode-1.png differ diff --git a/ui/address/contract/methods/useCallMethodPublicClient.ts b/ui/address/contract/methods/useCallMethodPublicClient.ts index 7d68766aa4..a6ab71ee52 100644 --- a/ui/address/contract/methods/useCallMethodPublicClient.ts +++ b/ui/address/contract/methods/useCallMethodPublicClient.ts @@ -5,6 +5,7 @@ import { usePublicClient } from 'wagmi'; import type { FormSubmitResult, MethodCallStrategy, SmartContractMethod } from './types'; import config from 'configs/app'; +import { useMultichainContext } from 'lib/contexts/multichain'; import useAccount from 'lib/web3/useAccount'; import { getNativeCoinValue } from './utils'; @@ -17,7 +18,9 @@ interface Params { } export default function useCallMethodPublicClient(): (params: Params) => Promise { - 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(); return React.useCallback(async({ args, item, addressHash, strategy }) => { diff --git a/ui/address/contract/methods/useCallMethodWalletClient.ts b/ui/address/contract/methods/useCallMethodWalletClient.ts index 77aa6a5527..146e025c59 100644 --- a/ui/address/contract/methods/useCallMethodWalletClient.ts +++ b/ui/address/contract/methods/useCallMethodWalletClient.ts @@ -5,6 +5,7 @@ import { useAccount, useWalletClient, useSwitchChain } from 'wagmi'; import type { FormSubmitResult, SmartContractMethod } from './types'; import config from 'configs/app'; +import { useMultichainContext } from 'lib/contexts/multichain'; import useRewardsActivity from 'lib/hooks/useRewardsActivity'; import { getNativeCoinValue } from './utils'; @@ -16,7 +17,10 @@ interface Params { } export default function useCallMethodWalletClient(): (params: Params) => Promise { - 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 { switchChainAsync } = useSwitchChain(); const { trackTransaction, trackTransactionConfirm } = useRewardsActivity(); @@ -30,8 +34,8 @@ export default function useCallMethodWalletClient(): (params: Params) => Promise throw new Error('Wallet Client is not defined'); } - if (chainId && String(chainId) !== config.chain.id) { - await switchChainAsync({ chainId: Number(config.chain.id) }); + if (chainId && String(chainId) !== chainConfig.id) { + await switchChainAsync({ chainId: Number(chainConfig.id) }); } const address = getAddress(addressHash); @@ -81,5 +85,5 @@ export default function useCallMethodWalletClient(): (params: Params) => Promise } return { source: 'wallet_client', data: { hash } }; - }, [ chainId, isConnected, switchChainAsync, walletClient, account, trackTransaction, trackTransactionConfirm ]); + }, [ chainId, chainConfig, isConnected, switchChainAsync, walletClient, account, trackTransaction, trackTransactionConfirm ]); } diff --git a/ui/address/contract/useContractDetailsTabs.tsx b/ui/address/contract/useContractDetailsTabs.tsx index 836ba74713..47f21cb06d 100644 --- a/ui/address/contract/useContractDetailsTabs.tsx +++ b/ui/address/contract/useContractDetailsTabs.tsx @@ -4,12 +4,10 @@ import React from 'react'; import type { Address } from 'types/api/address'; import type { SmartContract } from 'types/api/contract'; -import { Alert } from 'toolkit/chakra/alert'; import CodeViewSnippet from 'ui/shared/CodeViewSnippet'; -import RawDataSnippet from 'ui/shared/RawDataSnippet'; +import ContractDetailsByteCode from './ContractDetailsByteCode'; import ContractDetailsConstructorArgs from './ContractDetailsConstructorArgs'; -import ContractDetailsVerificationButton from './ContractDetailsVerificationButton'; import ContractSourceCode from './ContractSourceCode'; import type { CONTRACT_DETAILS_TAB_IDS } from './utils'; @@ -28,16 +26,7 @@ interface Props { export default function useContractDetailsTabs({ data, isLoading, addressData, sourceAddress }: Props): Array { - const canBeVerified = !data?.is_self_destructed && !data?.is_verified && addressData?.proxy_type !== 'eip7702'; - return React.useMemo(() => { - const verificationButton = ( - - ); return [ (data?.constructor_args || data?.source_code) ? { @@ -87,36 +76,9 @@ export default function useContractDetailsTabs({ data, isLoading, addressData, s (data?.creation_bytecode || data?.deployed_bytecode) ? { id: 'contract_bytecode' as const, - title: 'ByteCode', - component: ( - - { data?.creation_bytecode && ( - - Contracts that self destruct in their constructors have no contract code published and cannot be verified. - Displaying the init data provided of the creating transaction. - - ) : null } - textareaMaxHeight="300px" - isLoading={ isLoading } - /> - ) } - { data?.deployed_bytecode && ( - - ) } - - ), + title: 'Bytecode', + component: , } : undefined, ].filter(Boolean); - }, [ isLoading, addressData, data, sourceAddress, canBeVerified ]); + }, [ isLoading, addressData, data, sourceAddress ]); } diff --git a/ui/address/details/AddressCounterItem.tsx b/ui/address/details/AddressCounterItem.tsx index 465767029d..e8445dce15 100644 --- a/ui/address/details/AddressCounterItem.tsx +++ b/ui/address/details/AddressCounterItem.tsx @@ -4,9 +4,10 @@ import React from 'react'; 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 { useMultichainContext } from 'lib/contexts/multichain'; import { Link } from 'toolkit/chakra/link'; import { Skeleton } from 'toolkit/chakra/skeleton'; @@ -25,6 +26,7 @@ const PROP_TO_TAB = { }; const AddressCounterItem = ({ prop, query, address, isAddressQueryLoading, isDegradedData }: Props) => { + const multichainContext = useMultichainContext(); const handleClick = React.useCallback(() => { window.scrollTo({ top: 0, behavior: 'smooth' }); @@ -56,7 +58,7 @@ const AddressCounterItem = ({ prop, query, address, isAddressQueryLoading, isDeg return ( diff --git a/ui/address/epochRewards/AddressEpochRewardsListItem.tsx b/ui/address/epochRewards/AddressEpochRewardsListItem.tsx index daadf6a54e..c770b521d5 100644 --- a/ui/address/epochRewards/AddressEpochRewardsListItem.tsx +++ b/ui/address/epochRewards/AddressEpochRewardsListItem.tsx @@ -5,7 +5,7 @@ import type { AddressEpochRewardsItem } from 'types/api/address'; import getCurrencyValue from 'lib/getCurrencyValue'; import { Skeleton } from 'toolkit/chakra/skeleton'; import AddressEntity from 'ui/shared/entities/address/AddressEntity'; -import BlockEntity from 'ui/shared/entities/block/BlockEntity'; +import EpochEntity from 'ui/shared/entities/epoch/EpochEntity'; import TokenEntity from 'ui/shared/entities/token/TokenEntity'; import EpochRewardTypeTag from 'ui/shared/EpochRewardTypeTag'; import ListItemMobileGrid from 'ui/shared/ListItemMobile/ListItemMobileGrid'; @@ -21,20 +21,9 @@ const AddressEpochRewardsListItem = ({ item, isLoading }: Props) => { return ( - Block - - - - Epoch # - - { item.epoch_number } - + Age diff --git a/ui/address/epochRewards/AddressEpochRewardsTable.tsx b/ui/address/epochRewards/AddressEpochRewardsTable.tsx index b9a98c6240..a90ac628fb 100644 --- a/ui/address/epochRewards/AddressEpochRewardsTable.tsx +++ b/ui/address/epochRewards/AddressEpochRewardsTable.tsx @@ -19,7 +19,7 @@ const AddressEpochRewardsTable = ({ items, isLoading, top }: Props) => { - Block + Epoch Reward type @@ -31,7 +31,7 @@ const AddressEpochRewardsTable = ({ items, isLoading, top }: Props) => { { items.map((item, index) => { return ( diff --git a/ui/address/epochRewards/AddressEpochRewardsTableItem.tsx b/ui/address/epochRewards/AddressEpochRewardsTableItem.tsx index 4020e26a3b..0589f732ef 100644 --- a/ui/address/epochRewards/AddressEpochRewardsTableItem.tsx +++ b/ui/address/epochRewards/AddressEpochRewardsTableItem.tsx @@ -1,13 +1,15 @@ -import { Flex, Text } from '@chakra-ui/react'; +import { Flex } from '@chakra-ui/react'; import React from 'react'; import type { AddressEpochRewardsItem } from 'types/api/address'; +import { route } from 'nextjs-routes'; + import getCurrencyValue from 'lib/getCurrencyValue'; +import { Link } from 'toolkit/chakra/link'; import { Skeleton } from 'toolkit/chakra/skeleton'; import { TableCell, TableRow } from 'toolkit/chakra/table'; import AddressEntity from 'ui/shared/entities/address/AddressEntity'; -import BlockEntity from 'ui/shared/entities/block/BlockEntity'; import TokenEntity from 'ui/shared/entities/token/TokenEntity'; import EpochRewardTypeTag from 'ui/shared/EpochRewardTypeTag'; import TimeWithTooltip from 'ui/shared/time/TimeWithTooltip'; @@ -23,10 +25,12 @@ const AddressEpochRewardsTableItem = ({ item, isLoading }: Props) => { - - - { `Epoch # ${ item.epoch_number }` } - + + { item.epoch_number } + diff --git a/ui/address/tokenSelect/TokenSelect.tsx b/ui/address/tokenSelect/TokenSelect.tsx index 5d1fae5e61..15dbe80b5b 100644 --- a/ui/address/tokenSelect/TokenSelect.tsx +++ b/ui/address/tokenSelect/TokenSelect.tsx @@ -6,9 +6,10 @@ import React from 'react'; import type { Address } from 'types/api/address'; -import { route } from 'nextjs-routes'; +import { route } from 'nextjs/routes'; import { getResourceKey } from 'lib/api/useApiQuery'; +import { useMultichainContext } from 'lib/contexts/multichain'; import useIsMobile from 'lib/hooks/useIsMobile'; import * as mixpanel from 'lib/mixpanel/index'; import getQueryParamString from 'lib/router/getQueryParamString'; @@ -26,14 +27,19 @@ const TokenSelect = () => { const router = useRouter(); const isMobile = useIsMobile(); const queryClient = useQueryClient(); + const multichainContext = useMultichainContext(); 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
(addressResourceKey); 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 handleIconButtonClick = React.useCallback(() => { @@ -63,7 +69,7 @@ const TokenSelect = () => { } diff --git a/ui/address/tokenSelect/__screenshots__/TokenSelect.pw.tsx_dark-color-mode_base-view-dark-mode-1.png b/ui/address/tokenSelect/__screenshots__/TokenSelect.pw.tsx_dark-color-mode_base-view-dark-mode-1.png index 30392f5041..8f3c700f27 100644 Binary files a/ui/address/tokenSelect/__screenshots__/TokenSelect.pw.tsx_dark-color-mode_base-view-dark-mode-1.png and b/ui/address/tokenSelect/__screenshots__/TokenSelect.pw.tsx_dark-color-mode_base-view-dark-mode-1.png differ diff --git a/ui/address/tokenSelect/__screenshots__/TokenSelect.pw.tsx_default_base-view-dark-mode-1.png b/ui/address/tokenSelect/__screenshots__/TokenSelect.pw.tsx_default_base-view-dark-mode-1.png index 076d22f62b..7367a20713 100644 Binary files a/ui/address/tokenSelect/__screenshots__/TokenSelect.pw.tsx_default_base-view-dark-mode-1.png and b/ui/address/tokenSelect/__screenshots__/TokenSelect.pw.tsx_default_base-view-dark-mode-1.png differ diff --git a/ui/address/tokenSelect/__screenshots__/TokenSelect.pw.tsx_default_filter-1.png b/ui/address/tokenSelect/__screenshots__/TokenSelect.pw.tsx_default_filter-1.png index d7fc7c4c43..203835bc0a 100644 Binary files a/ui/address/tokenSelect/__screenshots__/TokenSelect.pw.tsx_default_filter-1.png and b/ui/address/tokenSelect/__screenshots__/TokenSelect.pw.tsx_default_filter-1.png differ diff --git a/ui/address/tokenSelect/__screenshots__/TokenSelect.pw.tsx_default_long-values-1.png b/ui/address/tokenSelect/__screenshots__/TokenSelect.pw.tsx_default_long-values-1.png index a83905daf0..ea5ee9f83a 100644 Binary files a/ui/address/tokenSelect/__screenshots__/TokenSelect.pw.tsx_default_long-values-1.png and b/ui/address/tokenSelect/__screenshots__/TokenSelect.pw.tsx_default_long-values-1.png differ diff --git a/ui/address/tokenSelect/__screenshots__/TokenSelect.pw.tsx_default_mobile-base-view-1.png b/ui/address/tokenSelect/__screenshots__/TokenSelect.pw.tsx_default_mobile-base-view-1.png index eb424b2b18..57561bda91 100644 Binary files a/ui/address/tokenSelect/__screenshots__/TokenSelect.pw.tsx_default_mobile-base-view-1.png and b/ui/address/tokenSelect/__screenshots__/TokenSelect.pw.tsx_default_mobile-base-view-1.png differ diff --git a/ui/address/tokenSelect/__screenshots__/TokenSelect.pw.tsx_default_sort-1.png b/ui/address/tokenSelect/__screenshots__/TokenSelect.pw.tsx_default_sort-1.png index bc8a479c2e..0a1a5aba0a 100644 Binary files a/ui/address/tokenSelect/__screenshots__/TokenSelect.pw.tsx_default_sort-1.png and b/ui/address/tokenSelect/__screenshots__/TokenSelect.pw.tsx_default_sort-1.png differ diff --git a/ui/address/useAddressTxsQuery.ts b/ui/address/useAddressTxsQuery.ts new file mode 100644 index 0000000000..61744f6d0e --- /dev/null +++ b/ui/address/useAddressTxsQuery.ts @@ -0,0 +1,60 @@ +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).bind(null, AddressFromToFilterValues); + +interface Props { + addressHash: string; + enabled: boolean; +} + +export default function useAddressTxsQuery({ addressHash, enabled }: Props) { + const router = useRouter(); + + const [ sort, setSort ] = React.useState(getSortValueFromQuery(router.query, SORT_OPTIONS) || 'default'); + + const initialFilterValue = getFilterValue(router.query.filter); + const [ filterValue, setFilterValue ] = React.useState(initialFilterValue); + + const query = useQueryWithPages({ + resourceName: 'general:address_txs', + pathParams: { hash: addressHash }, + filters: { filter: filterValue }, + sorting: getSortParamsFromValue(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) => { + 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 ]); +} diff --git a/ui/address/utils/useAddressQuery.ts b/ui/address/utils/useAddressQuery.ts index 99794de944..29f43bf395 100644 --- a/ui/address/utils/useAddressQuery.ts +++ b/ui/address/utils/useAddressQuery.ts @@ -75,6 +75,7 @@ export default function useAddressQuery({ hash, isEnabled = true }: Params): Add coin_balance: balance.toString(), creator_address_hash: null, creation_transaction_hash: null, + creation_status: null, exchange_rate: null, ens_domain_name: null, has_logs: false, diff --git a/ui/addressVerification/steps/__screenshots__/AddressVerificationStepAddress.pw.tsx_default_SOURCE-CODE-NOT-VERIFIED-ERROR-view-mobile-1.png b/ui/addressVerification/steps/__screenshots__/AddressVerificationStepAddress.pw.tsx_default_SOURCE-CODE-NOT-VERIFIED-ERROR-view-mobile-1.png index 7d65ecde56..26c2f3d73c 100644 Binary files a/ui/addressVerification/steps/__screenshots__/AddressVerificationStepAddress.pw.tsx_default_SOURCE-CODE-NOT-VERIFIED-ERROR-view-mobile-1.png and b/ui/addressVerification/steps/__screenshots__/AddressVerificationStepAddress.pw.tsx_default_SOURCE-CODE-NOT-VERIFIED-ERROR-view-mobile-1.png differ diff --git a/ui/addressVerification/steps/__screenshots__/AddressVerificationStepAddress.pw.tsx_default_base-view-1.png b/ui/addressVerification/steps/__screenshots__/AddressVerificationStepAddress.pw.tsx_default_base-view-1.png index d972fe41da..8e975f982e 100644 Binary files a/ui/addressVerification/steps/__screenshots__/AddressVerificationStepAddress.pw.tsx_default_base-view-1.png and b/ui/addressVerification/steps/__screenshots__/AddressVerificationStepAddress.pw.tsx_default_base-view-1.png differ diff --git a/ui/addressVerification/steps/__screenshots__/AddressVerificationStepAddress.pw.tsx_mobile_SOURCE-CODE-NOT-VERIFIED-ERROR-view-mobile-1.png b/ui/addressVerification/steps/__screenshots__/AddressVerificationStepAddress.pw.tsx_mobile_SOURCE-CODE-NOT-VERIFIED-ERROR-view-mobile-1.png index b92de7c713..c5fa128c11 100644 Binary files a/ui/addressVerification/steps/__screenshots__/AddressVerificationStepAddress.pw.tsx_mobile_SOURCE-CODE-NOT-VERIFIED-ERROR-view-mobile-1.png and b/ui/addressVerification/steps/__screenshots__/AddressVerificationStepAddress.pw.tsx_mobile_SOURCE-CODE-NOT-VERIFIED-ERROR-view-mobile-1.png differ diff --git a/ui/addressVerification/steps/__screenshots__/AddressVerificationStepSignature.pw.tsx_default_INVALID-SIGNER-ERROR-view-mobile-1.png b/ui/addressVerification/steps/__screenshots__/AddressVerificationStepSignature.pw.tsx_default_INVALID-SIGNER-ERROR-view-mobile-1.png index 39720430a0..b2c7cc5cd8 100644 Binary files a/ui/addressVerification/steps/__screenshots__/AddressVerificationStepSignature.pw.tsx_default_INVALID-SIGNER-ERROR-view-mobile-1.png and b/ui/addressVerification/steps/__screenshots__/AddressVerificationStepSignature.pw.tsx_default_INVALID-SIGNER-ERROR-view-mobile-1.png differ diff --git a/ui/addressVerification/steps/__screenshots__/AddressVerificationStepSignature.pw.tsx_default_base-view-1.png b/ui/addressVerification/steps/__screenshots__/AddressVerificationStepSignature.pw.tsx_default_base-view-1.png index a6f6816b59..f31d0756a5 100644 Binary files a/ui/addressVerification/steps/__screenshots__/AddressVerificationStepSignature.pw.tsx_default_base-view-1.png and b/ui/addressVerification/steps/__screenshots__/AddressVerificationStepSignature.pw.tsx_default_base-view-1.png differ diff --git a/ui/addressVerification/steps/__screenshots__/AddressVerificationStepSignature.pw.tsx_mobile_INVALID-SIGNER-ERROR-view-mobile-1.png b/ui/addressVerification/steps/__screenshots__/AddressVerificationStepSignature.pw.tsx_mobile_INVALID-SIGNER-ERROR-view-mobile-1.png index be55c0d5ee..6290283b8b 100644 Binary files a/ui/addressVerification/steps/__screenshots__/AddressVerificationStepSignature.pw.tsx_mobile_INVALID-SIGNER-ERROR-view-mobile-1.png and b/ui/addressVerification/steps/__screenshots__/AddressVerificationStepSignature.pw.tsx_mobile_INVALID-SIGNER-ERROR-view-mobile-1.png differ diff --git a/ui/advancedFilter/ExportCSV.tsx b/ui/advancedFilter/ExportCSV.tsx index b54db895e5..6e67e82a6f 100644 --- a/ui/advancedFilter/ExportCSV.tsx +++ b/ui/advancedFilter/ExportCSV.tsx @@ -20,29 +20,35 @@ const ExportCSV = ({ filters }: Props) => { const recaptcha = useReCaptcha(); const [ isLoading, setIsLoading ] = React.useState(false); - const handleExportCSV = React.useCallback(async() => { - try { - setIsLoading(true); - const token = await recaptcha.executeAsync(); + const apiFetchFactory = React.useCallback(async(recaptchaToken?: string) => { + const url = buildUrl('general:advanced_filter_csv', undefined, { + ...filters, + recaptcha_response: recaptchaToken, + }); - if (!token) { - throw new Error('ReCaptcha is not solved'); - } + const response = await fetch(url, { + headers: { + 'content-type': 'application/octet-stream', + ...(recaptchaToken && { 'recaptcha-v2-response': recaptchaToken }), + }, + }); - const url = buildUrl('general:advanced_filter_csv', undefined, { - ...filters, - recaptcha_response: token, - }); - - const response = await fetch(url, { - headers: { - 'content-type': 'application/octet-stream', + if (!response.ok) { + throw new Error(response.statusText, { + cause: { + status: response.status, }, }); + } + + return response; + }, [ filters ]); + + const handleExportCSV = React.useCallback(async() => { + try { + setIsLoading(true); - if (!response.ok) { - throw new Error(); - } + const response = await recaptcha.fetchProtectedResource(apiFetchFactory); const blob = await response.blob(); const fileName = `export-filtered-txs-${ dayjs().format('YYYY-MM-DD-HH-mm-ss') }.csv`; @@ -56,7 +62,7 @@ const ExportCSV = ({ filters }: Props) => { } finally { setIsLoading(false); } - }, [ filters, recaptcha ]); + }, [ apiFetchFactory, recaptcha ]); if (!config.services.reCaptchaV2.siteKey) { return null; diff --git a/ui/advancedFilter/__screenshots__/FilterByColumn.pw.tsx_dark-color-mode_age-filter-dark-mode-1.png b/ui/advancedFilter/__screenshots__/FilterByColumn.pw.tsx_dark-color-mode_age-filter-dark-mode-1.png index 78fe983e00..1553d10d79 100644 Binary files a/ui/advancedFilter/__screenshots__/FilterByColumn.pw.tsx_dark-color-mode_age-filter-dark-mode-1.png and b/ui/advancedFilter/__screenshots__/FilterByColumn.pw.tsx_dark-color-mode_age-filter-dark-mode-1.png differ diff --git a/ui/advancedFilter/__screenshots__/FilterByColumn.pw.tsx_dark-color-mode_amount-filter-dark-mode-1.png b/ui/advancedFilter/__screenshots__/FilterByColumn.pw.tsx_dark-color-mode_amount-filter-dark-mode-1.png index f0b1028a93..a3a18f3541 100644 Binary files a/ui/advancedFilter/__screenshots__/FilterByColumn.pw.tsx_dark-color-mode_amount-filter-dark-mode-1.png and b/ui/advancedFilter/__screenshots__/FilterByColumn.pw.tsx_dark-color-mode_amount-filter-dark-mode-1.png differ diff --git a/ui/advancedFilter/__screenshots__/FilterByColumn.pw.tsx_dark-color-mode_asset-filter-dark-mode-1.png b/ui/advancedFilter/__screenshots__/FilterByColumn.pw.tsx_dark-color-mode_asset-filter-dark-mode-1.png index 6793be24f3..9cd3f5ba65 100644 Binary files a/ui/advancedFilter/__screenshots__/FilterByColumn.pw.tsx_dark-color-mode_asset-filter-dark-mode-1.png and b/ui/advancedFilter/__screenshots__/FilterByColumn.pw.tsx_dark-color-mode_asset-filter-dark-mode-1.png differ diff --git a/ui/advancedFilter/__screenshots__/FilterByColumn.pw.tsx_dark-color-mode_from-filter-dark-mode-1.png b/ui/advancedFilter/__screenshots__/FilterByColumn.pw.tsx_dark-color-mode_from-filter-dark-mode-1.png index 0f23e3df1a..0d6da8f6cd 100644 Binary files a/ui/advancedFilter/__screenshots__/FilterByColumn.pw.tsx_dark-color-mode_from-filter-dark-mode-1.png and b/ui/advancedFilter/__screenshots__/FilterByColumn.pw.tsx_dark-color-mode_from-filter-dark-mode-1.png differ diff --git a/ui/advancedFilter/__screenshots__/FilterByColumn.pw.tsx_dark-color-mode_method-filter-dark-mode-1.png b/ui/advancedFilter/__screenshots__/FilterByColumn.pw.tsx_dark-color-mode_method-filter-dark-mode-1.png index fff1146638..a0e4fc5dc8 100644 Binary files a/ui/advancedFilter/__screenshots__/FilterByColumn.pw.tsx_dark-color-mode_method-filter-dark-mode-1.png and b/ui/advancedFilter/__screenshots__/FilterByColumn.pw.tsx_dark-color-mode_method-filter-dark-mode-1.png differ diff --git a/ui/advancedFilter/__screenshots__/FilterByColumn.pw.tsx_dark-color-mode_to-filter-dark-mode-1.png b/ui/advancedFilter/__screenshots__/FilterByColumn.pw.tsx_dark-color-mode_to-filter-dark-mode-1.png index 297787947c..658ea8f19b 100644 Binary files a/ui/advancedFilter/__screenshots__/FilterByColumn.pw.tsx_dark-color-mode_to-filter-dark-mode-1.png and b/ui/advancedFilter/__screenshots__/FilterByColumn.pw.tsx_dark-color-mode_to-filter-dark-mode-1.png differ diff --git a/ui/advancedFilter/__screenshots__/FilterByColumn.pw.tsx_default_age-filter-dark-mode-1.png b/ui/advancedFilter/__screenshots__/FilterByColumn.pw.tsx_default_age-filter-dark-mode-1.png index 52a8305f92..8993ca5d06 100644 Binary files a/ui/advancedFilter/__screenshots__/FilterByColumn.pw.tsx_default_age-filter-dark-mode-1.png and b/ui/advancedFilter/__screenshots__/FilterByColumn.pw.tsx_default_age-filter-dark-mode-1.png differ diff --git a/ui/advancedFilter/__screenshots__/FilterByColumn.pw.tsx_default_amount-filter-dark-mode-1.png b/ui/advancedFilter/__screenshots__/FilterByColumn.pw.tsx_default_amount-filter-dark-mode-1.png index 2d29f7f107..2db73e7129 100644 Binary files a/ui/advancedFilter/__screenshots__/FilterByColumn.pw.tsx_default_amount-filter-dark-mode-1.png and b/ui/advancedFilter/__screenshots__/FilterByColumn.pw.tsx_default_amount-filter-dark-mode-1.png differ diff --git a/ui/advancedFilter/__screenshots__/FilterByColumn.pw.tsx_default_asset-filter-dark-mode-1.png b/ui/advancedFilter/__screenshots__/FilterByColumn.pw.tsx_default_asset-filter-dark-mode-1.png index 4a772da550..8a6b4dfa6c 100644 Binary files a/ui/advancedFilter/__screenshots__/FilterByColumn.pw.tsx_default_asset-filter-dark-mode-1.png and b/ui/advancedFilter/__screenshots__/FilterByColumn.pw.tsx_default_asset-filter-dark-mode-1.png differ diff --git a/ui/advancedFilter/__screenshots__/FilterByColumn.pw.tsx_default_from-filter-dark-mode-1.png b/ui/advancedFilter/__screenshots__/FilterByColumn.pw.tsx_default_from-filter-dark-mode-1.png index bfd996a41f..1ba50703f0 100644 Binary files a/ui/advancedFilter/__screenshots__/FilterByColumn.pw.tsx_default_from-filter-dark-mode-1.png and b/ui/advancedFilter/__screenshots__/FilterByColumn.pw.tsx_default_from-filter-dark-mode-1.png differ diff --git a/ui/advancedFilter/__screenshots__/FilterByColumn.pw.tsx_default_method-filter-dark-mode-1.png b/ui/advancedFilter/__screenshots__/FilterByColumn.pw.tsx_default_method-filter-dark-mode-1.png index b6efbe8c6b..433a29f3ea 100644 Binary files a/ui/advancedFilter/__screenshots__/FilterByColumn.pw.tsx_default_method-filter-dark-mode-1.png and b/ui/advancedFilter/__screenshots__/FilterByColumn.pw.tsx_default_method-filter-dark-mode-1.png differ diff --git a/ui/advancedFilter/__screenshots__/FilterByColumn.pw.tsx_default_to-filter-dark-mode-1.png b/ui/advancedFilter/__screenshots__/FilterByColumn.pw.tsx_default_to-filter-dark-mode-1.png index 78560c6149..173f52e990 100644 Binary files a/ui/advancedFilter/__screenshots__/FilterByColumn.pw.tsx_default_to-filter-dark-mode-1.png and b/ui/advancedFilter/__screenshots__/FilterByColumn.pw.tsx_default_to-filter-dark-mode-1.png differ diff --git a/ui/apiDocs/EthRpcApi.tsx b/ui/apiDocs/EthRpcApi.tsx new file mode 100644 index 0000000000..199f5687e1 --- /dev/null +++ b/ui/apiDocs/EthRpcApi.tsx @@ -0,0 +1,19 @@ +import { Box, Text } from '@chakra-ui/react'; +import React from 'react'; + +import { Link } from 'toolkit/chakra/link'; + +const EthRpcApi = () => { + return ( + + + In addition to the custom RPC endpoints documented here, + the Blockscout ETH RPC API supports 3 methods in the exact format specified for Ethereum nodes, + ee the Ethereum JSON-RPC Specification for more details. + + View examples + + ); +}; + +export default React.memo(EthRpcApi); diff --git a/ui/graphQL/GraphQL.tsx b/ui/apiDocs/GraphQL.tsx similarity index 92% rename from ui/graphQL/GraphQL.tsx rename to ui/apiDocs/GraphQL.tsx index 1a04d094ce..2cb7080ebc 100644 --- a/ui/graphQL/GraphQL.tsx +++ b/ui/apiDocs/GraphQL.tsx @@ -9,7 +9,7 @@ import 'graphiql/graphiql.css'; import { useColorMode } from 'toolkit/chakra/color-mode'; import { isBrowser } from 'toolkit/utils/isBrowser'; -const feature = config.features.graphqlApiDocs; +const feature = config.features.apiDocs; const graphQLStyle = { '.graphiql-container': { @@ -35,13 +35,13 @@ const GraphQL = () => { } }, [ colorMode, graphqlTheme ]); - if (!feature.isEnabled) { + if (!feature.isEnabled || !feature.graphqlDefaultTxnHash) { return null; } const initialQuery = `{ transaction( - hash: "${ feature.defaultTxHash }" + hash: "${ feature.graphqlDefaultTxnHash }" ) { hash blockNumber diff --git a/ui/apiDocs/RestApi.tsx b/ui/apiDocs/RestApi.tsx new file mode 100644 index 0000000000..4ed6099389 --- /dev/null +++ b/ui/apiDocs/RestApi.tsx @@ -0,0 +1,52 @@ +import React from 'react'; + +import { route } from 'nextjs-routes'; + +import config from 'configs/app'; +import { AccordionItem, AccordionItemContent, AccordionItemTrigger, AccordionRoot, useAccordion } from 'toolkit/chakra/accordion'; +import CopyToClipboard from 'ui/shared/CopyToClipboard'; + +import SwaggerUI from './SwaggerUI'; +import { REST_API_SECTIONS } from './utils'; + +const RestApi = () => { + const { value, onValueChange, scrollToItemFromUrl } = useAccordion(REST_API_SECTIONS); + + React.useEffect(() => { + scrollToItemFromUrl(); + // runs only on mount + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [ ]); + + if (REST_API_SECTIONS.length === 0) { + return null; + } + + if (REST_API_SECTIONS.length === 1) { + return ; + } + + return ( + + { REST_API_SECTIONS.map((section, index) => ( + + + + { section.title } + + + + + + )) } + + ); +}; + +export default React.memo(RestApi); diff --git a/ui/apiDocs/RpcApi.tsx b/ui/apiDocs/RpcApi.tsx new file mode 100644 index 0000000000..f681693693 --- /dev/null +++ b/ui/apiDocs/RpcApi.tsx @@ -0,0 +1,18 @@ +import { Box, Text } from '@chakra-ui/react'; +import React from 'react'; + +import { Link } from 'toolkit/chakra/link'; + +const RpcApi = () => { + return ( + + + This API is provided for developers transitioning applications from Etherscan to BlockScout and applications requiring general API and data support. + It supports GET and POST requests. + + View modules + + ); +}; + +export default React.memo(RpcApi); diff --git a/ui/apiDocs/SwaggerUI.tsx b/ui/apiDocs/SwaggerUI.tsx index c71e26f677..0ad0dd9b66 100644 --- a/ui/apiDocs/SwaggerUI.tsx +++ b/ui/apiDocs/SwaggerUI.tsx @@ -9,15 +9,12 @@ import { Box, useToken } from '@chakra-ui/react'; import dynamic from 'next/dynamic'; import React from 'react'; -import config from 'configs/app'; +import type { SwaggerRequest } from './types'; + import ContentLoader from 'ui/shared/ContentLoader'; import 'swagger-ui-react/swagger-ui.css'; -const feature = config.features.restApiDocs; - -const DEFAULT_SERVER = 'blockscout.com/poa/core'; - const NeverShowInfoPlugin = () => { return { components: { @@ -28,7 +25,12 @@ const NeverShowInfoPlugin = () => { }; }; -const SwaggerUI = () => { +interface Props { + url: string; + requestInterceptor?: (request: SwaggerRequest) => SwaggerRequest; +} + +const SwaggerUI = ({ url, requestInterceptor }: Props) => { const mainColor = { _light: 'blackAlpha.800', _dark: 'whiteAlpha.800' }; const borderColor = useToken('colors', 'border.divider'); const mainBgColor = { _light: 'blackAlpha.100', _dark: 'whiteAlpha.200' }; @@ -111,32 +113,12 @@ const SwaggerUI = () => { }, }; - // eslint-disable-next-line @typescript-eslint/no-explicit-any - const reqInterceptor = React.useCallback((req: any) => { - if (!req.loadSpec) { - const newUrl = new URL(req.url.replace(DEFAULT_SERVER, config.apis.general.host)); - - newUrl.protocol = config.apis.general.protocol + ':'; - - if (config.apis.general.port) { - newUrl.port = config.apis.general.port; - } - - req.url = newUrl.toString(); - } - return req; - }, []); - - if (!feature.isEnabled) { - return null; - } - return ( ); diff --git a/ui/apiDocs/types.ts b/ui/apiDocs/types.ts new file mode 100644 index 0000000000..a9d1c4ac0d --- /dev/null +++ b/ui/apiDocs/types.ts @@ -0,0 +1,4 @@ +export interface SwaggerRequest { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + [k: string]: any; +} diff --git a/ui/apiDocs/utils.ts b/ui/apiDocs/utils.ts new file mode 100644 index 0000000000..55eb9a4564 --- /dev/null +++ b/ui/apiDocs/utils.ts @@ -0,0 +1,77 @@ +import type { SwaggerRequest } from './types'; + +import config from 'configs/app'; +import type { ApiPropsBase } from 'configs/app/apis'; + +const feature = config.features.apiDocs; + +const microserviceRequestInterceptorFactory = (api: ApiPropsBase) => (req: SwaggerRequest) => { + try { + const url = new URL(req.url); + if (api?.basePath && !url.pathname.includes(api.basePath)) { + url.pathname = (api?.basePath ?? '') + url.pathname; + } + req.url = url.toString(); + } catch (error) {} + return req; +}; + +const getMicroserviceSwaggerUrl = (api: ApiPropsBase) => `${ api.endpoint }${ api.basePath ?? '' }/api/v1/docs/swagger.yaml`; + +export const REST_API_SECTIONS = [ + feature.isEnabled && { + id: 'blockscout-core-api', + title: 'Blockscout core API', + swagger: { + url: feature.coreApiSwaggerUrl, + requestInterceptor: (req: SwaggerRequest) => { + const DEFAULT_SERVER = 'blockscout.com/poa/core'; + + if (!req.loadSpec) { + const newUrl = new URL(req.url.replace(DEFAULT_SERVER, config.apis.general.host)); + + newUrl.protocol = config.apis.general.protocol + ':'; + + if (config.apis.general.port) { + newUrl.port = config.apis.general.port; + } + + req.url = newUrl.toString(); + } + return req; + }, + }, + }, + config.apis.stats && { + id: 'stats-api', + title: 'Stats API', + swagger: { + url: getMicroserviceSwaggerUrl(config.apis.stats), + requestInterceptor: microserviceRequestInterceptorFactory(config.apis.stats), + }, + }, + config.apis.bens && { + id: 'bens-api', + title: 'Name service API', + swagger: { + url: getMicroserviceSwaggerUrl(config.apis.bens), + requestInterceptor: microserviceRequestInterceptorFactory(config.apis.bens), + }, + }, + config.apis.userOps && { + id: 'user-ops-api', + title: 'User ops indexer API', + swagger: { + url: getMicroserviceSwaggerUrl(config.apis.userOps), + requestInterceptor: microserviceRequestInterceptorFactory(config.apis.userOps), + }, + }, + config.apis.tac && { + id: 'tac-api', + title: 'TAC operation lifecycle API', + swagger: { + url: getMicroserviceSwaggerUrl(config.apis.tac), + requestInterceptor: microserviceRequestInterceptorFactory(config.apis.tac), + }, + }, +].filter(Boolean); diff --git a/ui/blob/__screenshots__/BlobData.pw.tsx_default_image-1.png b/ui/blob/__screenshots__/BlobData.pw.tsx_default_image-1.png index fe769e75fa..1b9c34bbec 100644 Binary files a/ui/blob/__screenshots__/BlobData.pw.tsx_default_image-1.png and b/ui/blob/__screenshots__/BlobData.pw.tsx_default_image-1.png differ diff --git a/ui/blob/__screenshots__/BlobData.pw.tsx_default_image-2.png b/ui/blob/__screenshots__/BlobData.pw.tsx_default_image-2.png index 253b1afd0f..3c483db6eb 100644 Binary files a/ui/blob/__screenshots__/BlobData.pw.tsx_default_image-2.png and b/ui/blob/__screenshots__/BlobData.pw.tsx_default_image-2.png differ diff --git a/ui/blob/__screenshots__/BlobData.pw.tsx_default_image-blob-with-zeroes-bytes-1.png b/ui/blob/__screenshots__/BlobData.pw.tsx_default_image-blob-with-zeroes-bytes-1.png index d6d066f4be..9ae230c89f 100644 Binary files a/ui/blob/__screenshots__/BlobData.pw.tsx_default_image-blob-with-zeroes-bytes-1.png and b/ui/blob/__screenshots__/BlobData.pw.tsx_default_image-blob-with-zeroes-bytes-1.png differ diff --git a/ui/blob/__screenshots__/BlobData.pw.tsx_default_text-1.png b/ui/blob/__screenshots__/BlobData.pw.tsx_default_text-1.png index 1a6bfe29a5..36b00c62fb 100644 Binary files a/ui/blob/__screenshots__/BlobData.pw.tsx_default_text-1.png and b/ui/blob/__screenshots__/BlobData.pw.tsx_default_text-1.png differ diff --git a/ui/blob/__screenshots__/BlobData.pw.tsx_default_text-2.png b/ui/blob/__screenshots__/BlobData.pw.tsx_default_text-2.png index ad718e0537..38ff6abe72 100644 Binary files a/ui/blob/__screenshots__/BlobData.pw.tsx_default_text-2.png and b/ui/blob/__screenshots__/BlobData.pw.tsx_default_text-2.png differ diff --git a/ui/block/BlockCeloEpochTag.tsx b/ui/block/BlockCeloEpochTag.tsx index 89403533df..16f7573f43 100644 --- a/ui/block/BlockCeloEpochTag.tsx +++ b/ui/block/BlockCeloEpochTag.tsx @@ -1,8 +1,8 @@ +import { HStack } from '@chakra-ui/react'; import React from 'react'; import { route } from 'nextjs-routes'; -import config from 'configs/app'; import { Link } from 'toolkit/chakra/link'; import { Tag } from 'toolkit/chakra/tag'; import { Tooltip } from 'toolkit/chakra/tooltip'; @@ -13,39 +13,44 @@ interface Props { blockQuery: BlockQuery; } -const BlockCeloEpochTag = ({ blockQuery }: Props) => { +const BlockCeloEpochTagRegular = ({ blockQuery }: Props) => { if (!blockQuery.data?.celo) { return null; } - if (!blockQuery.data.celo.is_epoch_block) { - const celoConfig = config.features.celo; - const epochBlockNumber = celoConfig.isEnabled && celoConfig.L2UpgradeBlock && blockQuery.data.height <= celoConfig.L2UpgradeBlock ? - blockQuery.data.celo.epoch_number * celoConfig.BLOCKS_PER_EPOCH : - undefined; - const content = epochBlockNumber ? ( - + return ( + + Epoch #{ blockQuery.data.celo.epoch_number } - ) : Epoch #{ blockQuery.data.celo.epoch_number }; + + ); +}; - return ( - - { content } - - ); +const BlockCeloEpochTag = ({ blockQuery }: Props) => { + if (!blockQuery.data?.celo) { + return null; + } + + if (!blockQuery.data.celo.l1_era_finalized_epoch_number) { + return ; } return ( - - Finalized epoch #{ blockQuery.data.celo.epoch_number } - + + + + Finalized epoch #{ blockQuery.data.celo.l1_era_finalized_epoch_number } + + + + ); }; diff --git a/ui/block/BlockDetails.tsx b/ui/block/BlockDetails.tsx index b7eed13aef..687fa1fcbf 100644 --- a/ui/block/BlockDetails.tsx +++ b/ui/block/BlockDetails.tsx @@ -6,10 +6,11 @@ import React from 'react'; 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 getBlockReward from 'lib/block/getBlockReward'; +import { useMultichainContext } from 'lib/contexts/multichain'; import getNetworkValidationActionText from 'lib/networks/getNetworkValidationActionText'; import getNetworkValidatorTitle from 'lib/networks/getNetworkValidatorTitle'; import * as arbitrum from 'lib/rollups/arbitrum'; @@ -53,6 +54,7 @@ const rollupFeature = config.features.rollup; const BlockDetails = ({ query }: Props) => { const router = useRouter(); const heightOrHash = getQueryParamString(router.query.height_or_hash); + const multichainContext = useMultichainContext(); const { data, isPlaceholderData } = query; @@ -64,8 +66,8 @@ const BlockDetails = ({ query }: Props) => { const increment = direction === 'next' ? +1 : -1; const nextId = String(data.height + increment); - router.push({ pathname: '/block/[height_or_hash]', query: { height_or_hash: nextId } }, undefined); - }, [ data, router ]); + router.push(routeParams({ pathname: '/block/[height_or_hash]', query: { height_or_hash: nextId } }, multichainContext), undefined); + }, [ data, multichainContext, router ]); if (!data) { return null; @@ -113,7 +115,7 @@ const BlockDetails = ({ query }: Props) => { const txsNum = (() => { const blockTxsNum = ( - + { data.transactions_count } txn{ data.transactions_count === 1 ? '' : 's' } ); @@ -121,7 +123,7 @@ const BlockDetails = ({ query }: Props) => { const blockBlobTxsNum = (config.features.dataAvailability.isEnabled && data.blob_transaction_count) ? ( <> including - + { data.blob_transaction_count } blob txn{ data.blob_transaction_count === 1 ? '' : 's' } @@ -266,7 +268,7 @@ const BlockDetails = ({ query }: Props) => { - + { data.withdrawals_count } withdrawal{ data.withdrawals_count === 1 ? '' : 's' } @@ -666,7 +668,7 @@ const BlockDetails = ({ query }: Props) => { diff --git a/ui/block/BlockEpochRewards.tsx b/ui/block/BlockEpochRewards.tsx deleted file mode 100644 index 79fa107cef..0000000000 --- a/ui/block/BlockEpochRewards.tsx +++ /dev/null @@ -1,40 +0,0 @@ -import React from 'react'; - -import useApiQuery from 'lib/api/useApiQuery'; -import { BLOCK_EPOCH } from 'stubs/block'; -import DataFetchAlert from 'ui/shared/DataFetchAlert'; - -import BlockEpochElectionRewards from './epochRewards/BlockEpochElectionRewards'; -import BlockEpochRewardsDistribution from './epochRewards/BlockEpochRewardsDistribution'; - -interface Props { - heightOrHash: string; -} - -const BlockEpochRewards = ({ heightOrHash }: Props) => { - const query = useApiQuery('general:block_epoch', { - pathParams: { - height_or_hash: heightOrHash, - }, - queryOptions: { - placeholderData: BLOCK_EPOCH, - }, - }); - - if (query.isError) { - return ; - } - - if (!query.data || (!query.data.aggregated_election_rewards && !query.data.distribution)) { - return No block epoch rewards data; - } - - return ( - <> - - - - ); -}; - -export default React.memo(BlockEpochRewards); diff --git a/ui/block/epochRewards/BlockEpochElectionRewards.pw.tsx b/ui/block/epochRewards/BlockEpochElectionRewards.pw.tsx deleted file mode 100644 index 07641a7fac..0000000000 --- a/ui/block/epochRewards/BlockEpochElectionRewards.pw.tsx +++ /dev/null @@ -1,35 +0,0 @@ -import React from 'react'; - -import * as blockEpochMock from 'mocks/blocks/epoch'; -import { test, expect } from 'playwright/lib'; - -import BlockEpochElectionRewards from './BlockEpochElectionRewards'; - -const heightOrHash = '1234'; -const hooksConfig = { - router: { - query: { height_or_hash: heightOrHash }, - }, -}; - -test('base view', async({ render, mockApiResponse }) => { - await mockApiResponse( - 'general:block_election_rewards', - blockEpochMock.electionRewardDetails1, - { pathParams: { height_or_hash: heightOrHash, reward_type: 'voter' } }, - ); - const component = await render(, { hooksConfig }); - await component.getByRole('cell', { name: 'Voting rewards' }).click(); - await expect(component).toHaveScreenshot(); -}); - -test('base view +@mobile -@default', async({ render, mockApiResponse }) => { - await mockApiResponse( - 'general:block_election_rewards', - blockEpochMock.electionRewardDetails1, - { pathParams: { height_or_hash: heightOrHash, reward_type: 'voter' } }, - ); - const component = await render(, { hooksConfig }); - await component.locator('div').filter({ hasText: 'Voting rewards' }).nth(3).click(); - await expect(component).toHaveScreenshot(); -}); diff --git a/ui/block/epochRewards/BlockEpochRewardsDistribution.tsx b/ui/block/epochRewards/BlockEpochRewardsDistribution.tsx deleted file mode 100644 index 6e1a105456..0000000000 --- a/ui/block/epochRewards/BlockEpochRewardsDistribution.tsx +++ /dev/null @@ -1,76 +0,0 @@ -import { Grid } from '@chakra-ui/react'; -import React from 'react'; - -import type { BlockEpoch } from 'types/api/block'; - -import useIsMobile from 'lib/hooks/useIsMobile'; -import * as DetailedInfo from 'ui/shared/DetailedInfo/DetailedInfo'; -import TokenTransferSnippet from 'ui/shared/TokenTransferSnippet/TokenTransferSnippet'; - -interface Props { - data: BlockEpoch; - isLoading?: boolean; -} - -const BlockEpochRewardsDistribution = ({ data, isLoading }: Props) => { - const isMobile = useIsMobile(); - - if (!data.distribution) { - return null; - } - - if (!data.distribution.community_transfer && !data.distribution.carbon_offsetting_transfer && !data.distribution.reserve_bolster_transfer) { - return null; - } - - return ( - - { data.distribution.community_transfer && ( - <> - - Community fund - - - - - - ) } - { data.distribution.carbon_offsetting_transfer && ( - <> - - Carbon offset fund - - - - - - ) } - { data.distribution.reserve_bolster_transfer && ( - <> - - Reserve bolster - - - - - - ) } - - ); -}; - -export default React.memo(BlockEpochRewardsDistribution); diff --git a/ui/block/epochRewards/__screenshots__/BlockEpochElectionRewards.pw.tsx_mobile_base-view-mobile---default-1.png b/ui/block/epochRewards/__screenshots__/BlockEpochElectionRewards.pw.tsx_mobile_base-view-mobile---default-1.png deleted file mode 100644 index 6f4c0746f8..0000000000 Binary files a/ui/block/epochRewards/__screenshots__/BlockEpochElectionRewards.pw.tsx_mobile_base-view-mobile---default-1.png and /dev/null differ diff --git a/ui/block/useBlockTxsQuery.tsx b/ui/block/useBlockTxsQuery.tsx index 8e8f29017d..10a81d1e0c 100644 --- a/ui/block/useBlockTxsQuery.tsx +++ b/ui/block/useBlockTxsQuery.tsx @@ -162,6 +162,8 @@ export default function useBlockTxsQuery({ heightOrHash, blockQuery, tab }: Para pagination: emptyPagination, onFilterChange: () => {}, onSortingChange: () => {}, + chainValue: undefined, + onChainValueChange: () => {}, }; const query = isRpcQuery ? rpcQueryWithPages : apiQuery; diff --git a/ui/block/useBlockWithdrawalsQuery.tsx b/ui/block/useBlockWithdrawalsQuery.tsx index 8551cf717b..89336a96f6 100644 --- a/ui/block/useBlockWithdrawalsQuery.tsx +++ b/ui/block/useBlockWithdrawalsQuery.tsx @@ -129,6 +129,8 @@ export default function useBlockWithdrawalsQuery({ heightOrHash, blockQuery, tab pagination: emptyPagination, onFilterChange: () => {}, onSortingChange: () => {}, + chainValue: undefined, + onChainValueChange: () => {}, }; const query = isRpcQuery ? rpcQueryWithPages : apiQuery; diff --git a/ui/blocks/BlocksContent.tsx b/ui/blocks/BlocksContent.tsx index 08c3ea7763..b515f4b146 100644 --- a/ui/blocks/BlocksContent.tsx +++ b/ui/blocks/BlocksContent.tsx @@ -34,7 +34,7 @@ interface Props { const BlocksContent = ({ type, query, enableSocket = true, top }: Props) => { const queryClient = useQueryClient(); const isMobile = useIsMobile(); - const [ socketAlert, setSocketAlert ] = React.useState(''); + const [ showSocketAlert, setShowSocketAlert ] = React.useState(false); const [ newItemsCount, setNewItemsCount ] = React.useState(0); @@ -66,11 +66,11 @@ const BlocksContent = ({ type, query, enableSocket = true, top }: Props) => { }, [ queryClient, type ]); const handleSocketClose = React.useCallback(() => { - setSocketAlert('Connection is lost. Please refresh the page to load new blocks.'); + setShowSocketAlert(true); }, []); const handleSocketError = React.useCallback(() => { - setSocketAlert('An error has occurred while fetching new blocks. Please refresh the page to load new blocks.'); + setShowSocketAlert(true); }, []); const channel = useSocketChannel({ @@ -91,7 +91,7 @@ const BlocksContent = ({ type, query, enableSocket = true, top }: Props) => { { query.pagination.page === 1 && enableSocket && ( @@ -106,7 +106,7 @@ const BlocksContent = ({ type, query, enableSocket = true, top }: Props) => { isLoading={ query.isPlaceholderData } showSocketInfo={ query.pagination.page === 1 && enableSocket } socketInfoNum={ newItemsCount } - socketInfoAlert={ socketAlert } + showSocketErrorAlert={ showSocketAlert } /> @@ -115,7 +115,7 @@ const BlocksContent = ({ type, query, enableSocket = true, top }: Props) => { const actionBar = isMobile ? ( - + Block countdown diff --git a/ui/blocks/BlocksListItem.tsx b/ui/blocks/BlocksListItem.tsx index 579cc24d94..dd821ede49 100644 --- a/ui/blocks/BlocksListItem.tsx +++ b/ui/blocks/BlocksListItem.tsx @@ -51,8 +51,8 @@ const BlocksListItem = ({ data, isLoading, enableTimeIncrement, animation }: Pro noIcon fontWeight={ 600 } /> - { data.celo?.is_epoch_block && ( - + { data.celo?.l1_era_finalized_epoch_number && ( + ) } diff --git a/ui/blocks/BlocksTabSlot.tsx b/ui/blocks/BlocksTabSlot.tsx index 4f6bc1ab74..5a522fcb45 100644 --- a/ui/blocks/BlocksTabSlot.tsx +++ b/ui/blocks/BlocksTabSlot.tsx @@ -37,7 +37,7 @@ const BlocksTabSlot = ({ pagination }: Props) => { ) } - + Block countdown diff --git a/ui/blocks/BlocksTable.tsx b/ui/blocks/BlocksTable.tsx index 34b18f330d..e1dfc1a840 100644 --- a/ui/blocks/BlocksTable.tsx +++ b/ui/blocks/BlocksTable.tsx @@ -19,7 +19,7 @@ interface Props { top: number; page: number; socketInfoNum?: number; - socketInfoAlert?: string; + showSocketErrorAlert?: boolean; showSocketInfo?: boolean; } @@ -30,7 +30,7 @@ const FEES_COL_WEIGHT = 22; const isRollup = config.features.rollup.isEnabled; -const BlocksTable = ({ data, isLoading, top, page, showSocketInfo, socketInfoNum, socketInfoAlert }: Props) => { +const BlocksTable = ({ data, isLoading, top, page, showSocketInfo, socketInfoNum, showSocketErrorAlert }: Props) => { const initialList = useInitialList({ data: data ?? [], idFn: (item) => item.height, @@ -71,7 +71,7 @@ const BlocksTable = ({ data, isLoading, top, page, showSocketInfo, socketInfoNum { showSocketInfo && ( - { data.celo?.is_epoch_block && ( - + { data.celo?.l1_era_finalized_epoch_number && ( + ) } diff --git a/ui/contractVerification/__screenshots__/ContractVerificationForm.pw.tsx_dark-color-mode_flatten-source-code-method-dark-mode-mobile-1.png b/ui/contractVerification/__screenshots__/ContractVerificationForm.pw.tsx_dark-color-mode_flatten-source-code-method-dark-mode-mobile-1.png index d890253f8d..8e8d2a609d 100644 Binary files a/ui/contractVerification/__screenshots__/ContractVerificationForm.pw.tsx_dark-color-mode_flatten-source-code-method-dark-mode-mobile-1.png and b/ui/contractVerification/__screenshots__/ContractVerificationForm.pw.tsx_dark-color-mode_flatten-source-code-method-dark-mode-mobile-1.png differ diff --git a/ui/contractVerification/__screenshots__/ContractVerificationForm.pw.tsx_dark-color-mode_sourcify-method-dark-mode-mobile-1.png b/ui/contractVerification/__screenshots__/ContractVerificationForm.pw.tsx_dark-color-mode_sourcify-method-dark-mode-mobile-1.png deleted file mode 100644 index 8beaac9287..0000000000 Binary files a/ui/contractVerification/__screenshots__/ContractVerificationForm.pw.tsx_dark-color-mode_sourcify-method-dark-mode-mobile-1.png and /dev/null differ diff --git a/ui/contractVerification/__screenshots__/ContractVerificationForm.pw.tsx_default_flatten-source-code-method-dark-mode-mobile-1.png b/ui/contractVerification/__screenshots__/ContractVerificationForm.pw.tsx_default_flatten-source-code-method-dark-mode-mobile-1.png index 9d507dba0e..bc4dd0f3c5 100644 Binary files a/ui/contractVerification/__screenshots__/ContractVerificationForm.pw.tsx_default_flatten-source-code-method-dark-mode-mobile-1.png and b/ui/contractVerification/__screenshots__/ContractVerificationForm.pw.tsx_default_flatten-source-code-method-dark-mode-mobile-1.png differ diff --git a/ui/contractVerification/__screenshots__/ContractVerificationForm.pw.tsx_default_multi-part-files-method-1.png b/ui/contractVerification/__screenshots__/ContractVerificationForm.pw.tsx_default_multi-part-files-method-1.png index 3fc5824fa5..84aa8140c8 100644 Binary files a/ui/contractVerification/__screenshots__/ContractVerificationForm.pw.tsx_default_multi-part-files-method-1.png and b/ui/contractVerification/__screenshots__/ContractVerificationForm.pw.tsx_default_multi-part-files-method-1.png differ diff --git a/ui/contractVerification/__screenshots__/ContractVerificationForm.pw.tsx_default_solidity-foundry-method-1.png b/ui/contractVerification/__screenshots__/ContractVerificationForm.pw.tsx_default_solidity-foundry-method-1.png index 9940b7dc09..77525fd8f8 100644 Binary files a/ui/contractVerification/__screenshots__/ContractVerificationForm.pw.tsx_default_solidity-foundry-method-1.png and b/ui/contractVerification/__screenshots__/ContractVerificationForm.pw.tsx_default_solidity-foundry-method-1.png differ diff --git a/ui/contractVerification/__screenshots__/ContractVerificationForm.pw.tsx_default_solidity-hardhat-method-1.png b/ui/contractVerification/__screenshots__/ContractVerificationForm.pw.tsx_default_solidity-hardhat-method-1.png index dd90e1b17c..1c1f39e589 100644 Binary files a/ui/contractVerification/__screenshots__/ContractVerificationForm.pw.tsx_default_solidity-hardhat-method-1.png and b/ui/contractVerification/__screenshots__/ContractVerificationForm.pw.tsx_default_solidity-hardhat-method-1.png differ diff --git a/ui/contractVerification/__screenshots__/ContractVerificationForm.pw.tsx_default_sourcify-with-multiple-contracts-1.png b/ui/contractVerification/__screenshots__/ContractVerificationForm.pw.tsx_default_sourcify-with-multiple-contracts-1.png index d45b5ceb3b..b2a495a4d5 100644 Binary files a/ui/contractVerification/__screenshots__/ContractVerificationForm.pw.tsx_default_sourcify-with-multiple-contracts-1.png and b/ui/contractVerification/__screenshots__/ContractVerificationForm.pw.tsx_default_sourcify-with-multiple-contracts-1.png differ diff --git a/ui/contractVerification/__screenshots__/ContractVerificationForm.pw.tsx_default_sourcify-with-multiple-contracts-2.png b/ui/contractVerification/__screenshots__/ContractVerificationForm.pw.tsx_default_sourcify-with-multiple-contracts-2.png index 68df6354a1..079ae203c3 100644 Binary files a/ui/contractVerification/__screenshots__/ContractVerificationForm.pw.tsx_default_sourcify-with-multiple-contracts-2.png and b/ui/contractVerification/__screenshots__/ContractVerificationForm.pw.tsx_default_sourcify-with-multiple-contracts-2.png differ diff --git a/ui/contractVerification/__screenshots__/ContractVerificationForm.pw.tsx_default_standard-input-json-method-1.png b/ui/contractVerification/__screenshots__/ContractVerificationForm.pw.tsx_default_standard-input-json-method-1.png index 78b2357cc5..0ff13032f2 100644 Binary files a/ui/contractVerification/__screenshots__/ContractVerificationForm.pw.tsx_default_standard-input-json-method-1.png and b/ui/contractVerification/__screenshots__/ContractVerificationForm.pw.tsx_default_standard-input-json-method-1.png differ diff --git a/ui/contractVerification/__screenshots__/ContractVerificationForm.pw.tsx_default_verification-of-stylus-rust-contract-1.png b/ui/contractVerification/__screenshots__/ContractVerificationForm.pw.tsx_default_verification-of-stylus-rust-contract-1.png index 6cfa36e96b..37c2b32988 100644 Binary files a/ui/contractVerification/__screenshots__/ContractVerificationForm.pw.tsx_default_verification-of-stylus-rust-contract-1.png and b/ui/contractVerification/__screenshots__/ContractVerificationForm.pw.tsx_default_verification-of-stylus-rust-contract-1.png differ diff --git a/ui/contractVerification/__screenshots__/ContractVerificationForm.pw.tsx_default_verification-of-zkSync-contract-1.png b/ui/contractVerification/__screenshots__/ContractVerificationForm.pw.tsx_default_verification-of-zkSync-contract-1.png index 0c45590e26..1ed0c8a97b 100644 Binary files a/ui/contractVerification/__screenshots__/ContractVerificationForm.pw.tsx_default_verification-of-zkSync-contract-1.png and b/ui/contractVerification/__screenshots__/ContractVerificationForm.pw.tsx_default_verification-of-zkSync-contract-1.png differ diff --git a/ui/contractVerification/__screenshots__/ContractVerificationForm.pw.tsx_default_vyper-contract-method-1.png b/ui/contractVerification/__screenshots__/ContractVerificationForm.pw.tsx_default_vyper-contract-method-1.png index e423ae4e37..469084e26c 100644 Binary files a/ui/contractVerification/__screenshots__/ContractVerificationForm.pw.tsx_default_vyper-contract-method-1.png and b/ui/contractVerification/__screenshots__/ContractVerificationForm.pw.tsx_default_vyper-contract-method-1.png differ diff --git a/ui/contractVerification/__screenshots__/ContractVerificationForm.pw.tsx_default_vyper-multi-part-method-1.png b/ui/contractVerification/__screenshots__/ContractVerificationForm.pw.tsx_default_vyper-multi-part-method-1.png index af3326f0c5..5676b77f54 100644 Binary files a/ui/contractVerification/__screenshots__/ContractVerificationForm.pw.tsx_default_vyper-multi-part-method-1.png and b/ui/contractVerification/__screenshots__/ContractVerificationForm.pw.tsx_default_vyper-multi-part-method-1.png differ diff --git a/ui/contractVerification/__screenshots__/ContractVerificationForm.pw.tsx_default_vyper-vyper-standard-input-method-1.png b/ui/contractVerification/__screenshots__/ContractVerificationForm.pw.tsx_default_vyper-vyper-standard-input-method-1.png index 1ab134e6ea..40a50dd13a 100644 Binary files a/ui/contractVerification/__screenshots__/ContractVerificationForm.pw.tsx_default_vyper-vyper-standard-input-method-1.png and b/ui/contractVerification/__screenshots__/ContractVerificationForm.pw.tsx_default_vyper-vyper-standard-input-method-1.png differ diff --git a/ui/contractVerification/__screenshots__/ContractVerificationForm.pw.tsx_mobile_flatten-source-code-method-dark-mode-mobile-1.png b/ui/contractVerification/__screenshots__/ContractVerificationForm.pw.tsx_mobile_flatten-source-code-method-dark-mode-mobile-1.png index ecf51a3411..56cde724fa 100644 Binary files a/ui/contractVerification/__screenshots__/ContractVerificationForm.pw.tsx_mobile_flatten-source-code-method-dark-mode-mobile-1.png and b/ui/contractVerification/__screenshots__/ContractVerificationForm.pw.tsx_mobile_flatten-source-code-method-dark-mode-mobile-1.png differ diff --git a/ui/csvExport/CsvExportForm.tsx b/ui/csvExport/CsvExportForm.tsx index 2665f401b4..3a9b6740a6 100644 --- a/ui/csvExport/CsvExportForm.tsx +++ b/ui/csvExport/CsvExportForm.tsx @@ -39,33 +39,40 @@ const CsvExportForm = ({ hash, resource, filterType, filterValue, fileNameTempla const { handleSubmit, formState } = formApi; const recaptcha = useReCaptcha(); - const onFormSubmit: SubmitHandler = React.useCallback(async(data) => { - try { - const token = await recaptcha.executeAsync(); - - if (!token) { - throw new Error('ReCaptcha is not solved'); - } - + const apiFetchFactory = React.useCallback((data: FormFields) => { + return async(recaptchaToken?: string) => { const url = buildUrl(resource, { hash } as never, { address_id: hash, from_period: exportType !== 'holders' ? dayjs(data.from).toISOString() : null, to_period: exportType !== 'holders' ? dayjs(data.to).toISOString() : null, filter_type: filterType, filter_value: filterValue, - recaptcha_response: token, + recaptcha_response: recaptchaToken, }); const response = await fetch(url, { headers: { 'content-type': 'application/octet-stream', + ...(recaptchaToken && { 'recaptcha-v2-response': recaptchaToken }), }, }); if (!response.ok) { - throw new Error(); + throw new Error(response.statusText, { + cause: { + status: response.status, + }, + }); } + return response; + }; + }, [ resource, hash, exportType, filterType, filterValue ]); + + const onFormSubmit: SubmitHandler = React.useCallback(async(data) => { + try { + const response = await recaptcha.fetchProtectedResource(apiFetchFactory(data)); + const blob = await response.blob(); const fileName = exportType === 'holders' ? `${ fileNameTemplate }_${ hash }.csv` : @@ -80,7 +87,7 @@ const CsvExportForm = ({ hash, resource, filterType, filterValue, fileNameTempla }); } - }, [ recaptcha, resource, hash, exportType, filterType, filterValue, fileNameTemplate ]); + }, [ recaptcha, apiFetchFactory, exportType, fileNameTemplate, hash, filterType, filterValue ]); if (!config.services.reCaptchaV2.siteKey) { return ( diff --git a/ui/epochs/EpochDetails.tsx b/ui/epochs/EpochDetails.tsx new file mode 100644 index 0000000000..fa8fc66389 --- /dev/null +++ b/ui/epochs/EpochDetails.tsx @@ -0,0 +1,152 @@ +import { Box, Grid, chakra } from '@chakra-ui/react'; +import React from 'react'; + +import type { CeloEpochDetails } from 'types/api/epochs'; + +import config from 'configs/app'; +import getCurrencyValue from 'lib/getCurrencyValue'; +import useIsMobile from 'lib/hooks/useIsMobile'; +import { Skeleton } from 'toolkit/chakra/skeleton'; +import * as DetailedInfo from 'ui/shared/DetailedInfo/DetailedInfo'; +import DetailedInfoTimestamp from 'ui/shared/DetailedInfo/DetailedInfoTimestamp'; +import BlockEntity from 'ui/shared/entities/block/BlockEntity'; +import TokenEntity from 'ui/shared/entities/token/TokenEntity'; +import CeloEpochStatus from 'ui/shared/statusTag/CeloEpochStatus'; +import TokenTransferSnippet from 'ui/shared/TokenTransferSnippet/TokenTransferSnippet'; + +import EpochElectionRewards from './electionRewards/EpochElectionRewards'; + +interface Props { + data: CeloEpochDetails; + isLoading?: boolean; +} + +const EpochDetails = ({ data, isLoading }: Props) => { + const isMobile = useIsMobile(); + + const totalFunRewards = data.distribution?.transfers_total?.total ? getCurrencyValue({ + value: data.distribution?.transfers_total.total.value, + decimals: data.distribution?.transfers_total.total.decimals, + }) : null; + + const processingRange = (() => { + if (!data.start_processing_block_number || !data.end_processing_block_number) { + return N/A; + } + + if (data.start_processing_block_number === data.end_processing_block_number) { + return ; + } + + return ( + <> + + - + + + ); + })(); + + return ( + <> + + + Status + + + + + + Timestamp + + + { data.timestamp ? + : + Epochs are finalized approximately once a day } + + + Processing range + + + { processingRange } + + + Community fund + + + { data.distribution?.community_transfer ? ( + + ) : ( + N/A + ) } + + + Carbon offset fund + + + { data.distribution?.carbon_offsetting_transfer ? ( + + ) : ( + N/A + ) } + + + Total fund rewards + + + { totalFunRewards ? ( + <> + + { totalFunRewards.valueStr } + + { data.distribution?.transfers_total?.token ? ( + + ) : + config.chain.currency.symbol } + + ) : ( + N/A + ) } + + + + + ); +}; + +export default React.memo(EpochDetails); diff --git a/ui/epochs/EpochsListItem.tsx b/ui/epochs/EpochsListItem.tsx new file mode 100644 index 0000000000..39503104e2 --- /dev/null +++ b/ui/epochs/EpochsListItem.tsx @@ -0,0 +1,82 @@ +import { HStack } from '@chakra-ui/react'; +import React from 'react'; + +import type { CeloEpochListItem } from 'types/api/epochs'; + +import config from 'configs/app'; +import getCurrencyValue from 'lib/getCurrencyValue'; +import { Skeleton } from 'toolkit/chakra/skeleton'; +import DetailedInfoTimestamp from 'ui/shared/DetailedInfo/DetailedInfoTimestamp'; +import EpochEntity from 'ui/shared/entities/epoch/EpochEntity'; +import ListItemMobile from 'ui/shared/ListItemMobile/ListItemMobile'; +import CeloEpochStatus from 'ui/shared/statusTag/CeloEpochStatus'; + +interface Props { + item: CeloEpochListItem; + isLoading?: boolean; +} + +const EpochsListItem = ({ item, isLoading }: Props) => { + const communityReward = getCurrencyValue({ + value: item.distribution?.community_transfer?.value ?? '0', + decimals: item.distribution?.community_transfer?.decimals, + accuracy: 8, + }); + const carbonOffsettingReward = getCurrencyValue({ + value: item.distribution?.carbon_offsetting_transfer?.value ?? '0', + decimals: item.distribution?.carbon_offsetting_transfer?.decimals, + accuracy: 8, + }); + const totalReward = getCurrencyValue({ + value: item.distribution?.transfers_total?.value ?? '0', + decimals: item.distribution?.transfers_total?.decimals, + accuracy: 8, + }); + + return ( + + + + { item.type } + + + { item.timestamp && ( + + + + ) } + + Block range + + { item.start_block_number } - { item.end_block_number || '' } + + + { item.distribution?.community_transfer ? ( + + Community + + { communityReward.valueStr } { config.chain.currency.symbol } + + + ) : null } + { item.distribution?.carbon_offsetting_transfer ? ( + + Carbon offset + + { carbonOffsettingReward.valueStr } { config.chain.currency.symbol } + + + ) : null } + { item.distribution?.transfers_total ? ( + + Total + + { totalReward.valueStr } { config.chain.currency.symbol } + + + ) : null } + + ); +}; + +export default EpochsListItem; diff --git a/ui/epochs/EpochsTable.tsx b/ui/epochs/EpochsTable.tsx new file mode 100644 index 0000000000..9c53f5d3e5 --- /dev/null +++ b/ui/epochs/EpochsTable.tsx @@ -0,0 +1,48 @@ +import React from 'react'; + +import type { CeloEpochListItem } from 'types/api/epochs'; + +import config from 'configs/app'; +import { TableBody, TableColumnHeader, TableHeaderSticky, TableRoot, TableRow } from 'toolkit/chakra/table'; +import TimeFormatToggle from 'ui/shared/time/TimeFormatToggle'; + +import EpochsTableItem from './EpochsTableItem'; + +interface Props { + items: Array; + isLoading?: boolean; + top: number; +}; + +const EpochsTable = ({ items, isLoading, top }: Props) => { + return ( + + + + + Epoch + + + Status + Block range + Community { config.chain.currency.symbol } + Carbon offset { config.chain.currency.symbol } + Total { config.chain.currency.symbol } + + + + { items.map((item, index) => { + return ( + + ); + }) } + + + ); +}; + +export default EpochsTable; diff --git a/ui/epochs/EpochsTableItem.tsx b/ui/epochs/EpochsTableItem.tsx new file mode 100644 index 0000000000..c2d7a5baed --- /dev/null +++ b/ui/epochs/EpochsTableItem.tsx @@ -0,0 +1,81 @@ +import { HStack } from '@chakra-ui/react'; +import React from 'react'; + +import type { CeloEpochListItem } from 'types/api/epochs'; + +import getCurrencyValue from 'lib/getCurrencyValue'; +import { Skeleton } from 'toolkit/chakra/skeleton'; +import { TableCell, TableRow } from 'toolkit/chakra/table'; +import EpochEntity from 'ui/shared/entities/epoch/EpochEntity'; +import CeloEpochStatus from 'ui/shared/statusTag/CeloEpochStatus'; +import TimeWithTooltip from 'ui/shared/time/TimeWithTooltip'; + +interface Props { + item: CeloEpochListItem; + isLoading?: boolean; +}; + +const EpochsTableItem = ({ item, isLoading }: Props) => { + + const communityReward = getCurrencyValue({ + value: item.distribution?.community_transfer?.value ?? '0', + decimals: item.distribution?.community_transfer?.decimals, + accuracy: 8, + }); + const carbonOffsettingReward = getCurrencyValue({ + value: item.distribution?.carbon_offsetting_transfer?.value ?? '0', + decimals: item.distribution?.carbon_offsetting_transfer?.decimals, + accuracy: 8, + }); + const totalReward = getCurrencyValue({ + value: item.distribution?.transfers_total?.value ?? '0', + decimals: item.distribution?.transfers_total?.decimals, + accuracy: 8, + }); + + return ( + + + + + { item.type } + + + + + + + + + { item.start_block_number } - { item.end_block_number || '' } + + + + + { item.distribution?.community_transfer ? communityReward.valueStr : '-' } + + + + + { item.distribution?.carbon_offsetting_transfer ? carbonOffsettingReward.valueStr : '-' } + + + + + { item.distribution?.transfers_total ? totalReward.valueStr : '-' } + + + + ); +}; + +export default EpochsTableItem; diff --git a/ui/block/epochRewards/BlockEpochElectionRewardDetailsDesktop.tsx b/ui/epochs/electionRewards/EpochElectionRewardDetailsDesktop.tsx similarity index 85% rename from ui/block/epochRewards/BlockEpochElectionRewardDetailsDesktop.tsx rename to ui/epochs/electionRewards/EpochElectionRewardDetailsDesktop.tsx index 1417ae8da3..e2b47d3f83 100644 --- a/ui/block/epochRewards/BlockEpochElectionRewardDetailsDesktop.tsx +++ b/ui/epochs/electionRewards/EpochElectionRewardDetailsDesktop.tsx @@ -2,7 +2,7 @@ import { Box, Grid, GridItem, Text } from '@chakra-ui/react'; import { useRouter } from 'next/router'; import React from 'react'; -import type { BlockEpoch } from 'types/api/block'; +import type { CeloEpochDetails } from 'types/api/epochs'; import type { TokenInfo } from 'types/api/token'; import getCurrencyValue from 'lib/getCurrencyValue'; @@ -14,20 +14,20 @@ import useLazyLoadedList from 'ui/shared/pagination/useLazyLoadedList'; import { formatRewardType, getRewardDetailsTableTitles } from './utils'; interface Props { - type: keyof BlockEpoch['aggregated_election_rewards']; + type: keyof CeloEpochDetails['aggregated_election_rewards']; token: TokenInfo; } -const BlockEpochElectionRewardDetailsDesktop = ({ type, token }: Props) => { +const CeloEpochElectionRewardDetailsDesktop = ({ type, token }: Props) => { const rootRef = React.useRef(null); const router = useRouter(); - const heightOrHash = getQueryParamString(router.query.height_or_hash); + const number = getQueryParamString(router.query.number); const { cutRef, query } = useLazyLoadedList({ rootRef, - resourceName: 'general:block_election_rewards', - pathParams: { height_or_hash: heightOrHash, reward_type: formatRewardType(type) }, + resourceName: 'general:epoch_celo_election_rewards', + pathParams: { number: number, reward_type: formatRewardType(type) }, queryOptions: { refetchOnMount: false, }, @@ -97,4 +97,4 @@ const BlockEpochElectionRewardDetailsDesktop = ({ type, token }: Props) => { ); }; -export default React.memo(BlockEpochElectionRewardDetailsDesktop); +export default React.memo(CeloEpochElectionRewardDetailsDesktop); diff --git a/ui/block/epochRewards/BlockEpochElectionRewardDetailsMobile.tsx b/ui/epochs/electionRewards/EpochElectionRewardDetailsMobile.tsx similarity index 83% rename from ui/block/epochRewards/BlockEpochElectionRewardDetailsMobile.tsx rename to ui/epochs/electionRewards/EpochElectionRewardDetailsMobile.tsx index ef8842a4c7..86a814ac07 100644 --- a/ui/block/epochRewards/BlockEpochElectionRewardDetailsMobile.tsx +++ b/ui/epochs/electionRewards/EpochElectionRewardDetailsMobile.tsx @@ -2,7 +2,7 @@ import { Box, Flex, Text } from '@chakra-ui/react'; import { useRouter } from 'next/router'; import React from 'react'; -import type { BlockEpoch } from 'types/api/block'; +import type { CeloEpochDetails } from 'types/api/epochs'; import type { TokenInfo } from 'types/api/token'; import getCurrencyValue from 'lib/getCurrencyValue'; @@ -15,20 +15,20 @@ import useLazyLoadedList from 'ui/shared/pagination/useLazyLoadedList'; import { formatRewardType } from './utils'; interface Props { - type: keyof BlockEpoch['aggregated_election_rewards']; + type: keyof CeloEpochDetails['aggregated_election_rewards']; token: TokenInfo; } -const BlockEpochElectionRewardDetailsMobile = ({ type, token }: Props) => { +const CeloEpochElectionRewardDetailsMobile = ({ type, token }: Props) => { const rootRef = React.useRef(null); const router = useRouter(); - const heightOrHash = getQueryParamString(router.query.height_or_hash); + const number = getQueryParamString(router.query.number); const { cutRef, query } = useLazyLoadedList({ rootRef, - resourceName: 'general:block_election_rewards', - pathParams: { height_or_hash: heightOrHash, reward_type: formatRewardType(type) }, + resourceName: 'general:epoch_celo_election_rewards', + pathParams: { number: number, reward_type: formatRewardType(type) }, queryOptions: { refetchOnMount: false, }, @@ -80,4 +80,4 @@ const BlockEpochElectionRewardDetailsMobile = ({ type, token }: Props) => { ); }; -export default React.memo(BlockEpochElectionRewardDetailsMobile); +export default React.memo(CeloEpochElectionRewardDetailsMobile); diff --git a/ui/epochs/electionRewards/EpochElectionRewards.pw.tsx b/ui/epochs/electionRewards/EpochElectionRewards.pw.tsx new file mode 100644 index 0000000000..42b5434fd6 --- /dev/null +++ b/ui/epochs/electionRewards/EpochElectionRewards.pw.tsx @@ -0,0 +1,35 @@ +import React from 'react'; + +import * as celoEpochMock from 'mocks/epochs/celo'; +import { test, expect } from 'playwright/lib'; + +import EpochElectionRewards from './EpochElectionRewards'; + +const number = '1234'; +const hooksConfig = { + router: { + query: { number }, + }, +}; + +test('base view', async({ render, mockApiResponse }) => { + await mockApiResponse( + 'general:epoch_celo_election_rewards', + celoEpochMock.electionRewardDetails1, + { pathParams: { number, reward_type: 'voter' } }, + ); + const component = await render(, { hooksConfig }); + await component.getByRole('cell', { name: 'Voting rewards' }).click(); + await expect(component).toHaveScreenshot(); +}); + +test('base view +@mobile -@default', async({ render, mockApiResponse }) => { + await mockApiResponse( + 'general:epoch_celo_election_rewards', + celoEpochMock.electionRewardDetails1, + { pathParams: { number, reward_type: 'voter' } }, + ); + const component = await render(, { hooksConfig }); + await component.locator('div').filter({ hasText: 'Voting rewards' }).nth(3).click(); + await expect(component).toHaveScreenshot(); +}); diff --git a/ui/block/epochRewards/BlockEpochElectionRewards.tsx b/ui/epochs/electionRewards/EpochElectionRewards.tsx similarity index 72% rename from ui/block/epochRewards/BlockEpochElectionRewards.tsx rename to ui/epochs/electionRewards/EpochElectionRewards.tsx index 7e923fc317..ed4d618df3 100644 --- a/ui/block/epochRewards/BlockEpochElectionRewards.tsx +++ b/ui/epochs/electionRewards/EpochElectionRewards.tsx @@ -1,26 +1,26 @@ import { Box } from '@chakra-ui/react'; import React from 'react'; -import type { BlockEpoch } from 'types/api/block'; +import type { CeloEpochDetails } from 'types/api/epochs'; import { Heading } from 'toolkit/chakra/heading'; import { TableBody, TableColumnHeader, TableHeaderSticky, TableRoot, TableRow } from 'toolkit/chakra/table'; -import BlockEpochElectionRewardsListItem from './BlockEpochElectionRewardsListItem'; -import BlockEpochElectionRewardsTableItem from './BlockEpochElectionRewardsTableItem'; +import EpochElectionRewardsListItem from './EpochElectionRewardsListItem'; +import EpochElectionRewardsTableItem from './EpochElectionRewardsTableItem'; interface Props { - data: BlockEpoch; + data: CeloEpochDetails; isLoading?: boolean; } -const BlockEpochElectionRewards = ({ data, isLoading }: Props) => { +const EpochElectionRewards = ({ data, isLoading }: Props) => { if (!data.aggregated_election_rewards) { return null; } return ( - + Election rewards @@ -34,7 +34,7 @@ const BlockEpochElectionRewards = ({ data, isLoading }: Props) => { { Object.entries(data.aggregated_election_rewards).map((entry) => { - const key = entry[0] as keyof BlockEpoch['aggregated_election_rewards']; + const key = entry[0] as keyof CeloEpochDetails['aggregated_election_rewards']; const value = entry[1]; if (!value) { @@ -42,7 +42,7 @@ const BlockEpochElectionRewards = ({ data, isLoading }: Props) => { } return ( - { { Object.entries(data.aggregated_election_rewards).map((entry) => { - const key = entry[0] as keyof BlockEpoch['aggregated_election_rewards']; + const key = entry[0] as keyof CeloEpochDetails['aggregated_election_rewards']; const value = entry[1]; if (!value) { @@ -63,7 +63,7 @@ const BlockEpochElectionRewards = ({ data, isLoading }: Props) => { } return ( - { ); }; -export default React.memo(BlockEpochElectionRewards); +export default React.memo(EpochElectionRewards); diff --git a/ui/block/epochRewards/BlockEpochElectionRewardsListItem.tsx b/ui/epochs/electionRewards/EpochElectionRewardsListItem.tsx similarity index 77% rename from ui/block/epochRewards/BlockEpochElectionRewardsListItem.tsx rename to ui/epochs/electionRewards/EpochElectionRewardsListItem.tsx index 74a31a61c4..215380b0d7 100644 --- a/ui/block/epochRewards/BlockEpochElectionRewardsListItem.tsx +++ b/ui/epochs/electionRewards/EpochElectionRewardsListItem.tsx @@ -1,7 +1,7 @@ import { Box, Flex } from '@chakra-ui/react'; import React from 'react'; -import type { BlockEpoch, BlockEpochElectionReward } from 'types/api/block'; +import type { CeloEpochElectionReward, CeloEpochDetails } from 'types/api/epochs'; import getCurrencyValue from 'lib/getCurrencyValue'; import { IconButton } from 'toolkit/chakra/icon-button'; @@ -11,15 +11,16 @@ import TokenEntity from 'ui/shared/entities/token/TokenEntity'; import EpochRewardTypeTag from 'ui/shared/EpochRewardTypeTag'; import IconSvg from 'ui/shared/IconSvg'; -import BlockEpochElectionRewardDetailsMobile from './BlockEpochElectionRewardDetailsMobile'; +import EpochElectionRewardDetailsMobile from './EpochElectionRewardDetailsMobile'; +import { getRewardNumText } from './utils'; interface Props { - data: BlockEpochElectionReward; - type: keyof BlockEpoch['aggregated_election_rewards']; + data: CeloEpochElectionReward; + type: keyof CeloEpochDetails['aggregated_election_rewards']; isLoading?: boolean; } -const BlockEpochElectionRewardsListItem = ({ data, isLoading, type }: Props) => { +const EpochElectionRewardsListItem = ({ data, isLoading, type }: Props) => { const section = useDisclosure(); const { valueStr } = getCurrencyValue({ @@ -55,7 +56,7 @@ const BlockEpochElectionRewardsListItem = ({ data, isLoading, type }: Props) => ) : } - { data.count } + { getRewardNumText(type, data.count) } { valueStr } { section.open && ( - + ) } ); }; -export default React.memo(BlockEpochElectionRewardsListItem); +export default React.memo(EpochElectionRewardsListItem); diff --git a/ui/block/epochRewards/BlockEpochElectionRewardsTableItem.tsx b/ui/epochs/electionRewards/EpochElectionRewardsTableItem.tsx similarity index 84% rename from ui/block/epochRewards/BlockEpochElectionRewardsTableItem.tsx rename to ui/epochs/electionRewards/EpochElectionRewardsTableItem.tsx index 4913810d01..ea85264d01 100644 --- a/ui/block/epochRewards/BlockEpochElectionRewardsTableItem.tsx +++ b/ui/epochs/electionRewards/EpochElectionRewardsTableItem.tsx @@ -1,7 +1,7 @@ import { Flex } from '@chakra-ui/react'; import React from 'react'; -import type { BlockEpoch, BlockEpochElectionReward } from 'types/api/block'; +import type { CeloEpochDetails, CeloEpochElectionReward } from 'types/api/epochs'; import getCurrencyValue from 'lib/getCurrencyValue'; import { IconButton } from 'toolkit/chakra/icon-button'; @@ -12,16 +12,16 @@ import TokenEntity from 'ui/shared/entities/token/TokenEntity'; import EpochRewardTypeTag from 'ui/shared/EpochRewardTypeTag'; import IconSvg from 'ui/shared/IconSvg'; -import BlockEpochElectionRewardDetailsDesktop from './BlockEpochElectionRewardDetailsDesktop'; +import EpochElectionRewardDetailsDesktop from './EpochElectionRewardDetailsDesktop'; import { getRewardNumText } from './utils'; interface Props { - data: BlockEpochElectionReward; - type: keyof BlockEpoch['aggregated_election_rewards']; + data: CeloEpochElectionReward; + type: keyof CeloEpochDetails['aggregated_election_rewards']; isLoading?: boolean; } -const BlockEpochElectionRewardsTableItem = ({ isLoading, data, type }: Props) => { +const EpochElectionRewardsTableItem = ({ isLoading, data, type }: Props) => { const section = useDisclosure(); const { valueStr } = getCurrencyValue({ @@ -79,7 +79,7 @@ const BlockEpochElectionRewardsTableItem = ({ isLoading, data, type }: Props) => - + ) } @@ -87,4 +87,4 @@ const BlockEpochElectionRewardsTableItem = ({ isLoading, data, type }: Props) => ); }; -export default React.memo(BlockEpochElectionRewardsTableItem); +export default React.memo(EpochElectionRewardsTableItem); diff --git a/ui/block/epochRewards/__screenshots__/BlockEpochElectionRewards.pw.tsx_default_base-view-1.png b/ui/epochs/electionRewards/__screenshots__/EpochElectionRewards.pw.tsx_default_base-view-1.png similarity index 100% rename from ui/block/epochRewards/__screenshots__/BlockEpochElectionRewards.pw.tsx_default_base-view-1.png rename to ui/epochs/electionRewards/__screenshots__/EpochElectionRewards.pw.tsx_default_base-view-1.png diff --git a/ui/epochs/electionRewards/__screenshots__/EpochElectionRewards.pw.tsx_mobile_base-view-mobile---default-1.png b/ui/epochs/electionRewards/__screenshots__/EpochElectionRewards.pw.tsx_mobile_base-view-mobile---default-1.png new file mode 100644 index 0000000000..ffb3dc5909 Binary files /dev/null and b/ui/epochs/electionRewards/__screenshots__/EpochElectionRewards.pw.tsx_mobile_base-view-mobile---default-1.png differ diff --git a/ui/block/epochRewards/utils.ts b/ui/epochs/electionRewards/utils.ts similarity index 73% rename from ui/block/epochRewards/utils.ts rename to ui/epochs/electionRewards/utils.ts index f28bc595e7..8eb981025d 100644 --- a/ui/block/epochRewards/utils.ts +++ b/ui/epochs/electionRewards/utils.ts @@ -1,7 +1,7 @@ -import type { BlockEpoch } from 'types/api/block'; +import type { CeloEpochDetails } from 'types/api/epochs'; import type { ExcludeNull } from 'types/utils'; -export function getRewardNumText(type: keyof BlockEpoch['aggregated_election_rewards'], num: number) { +export function getRewardNumText(type: keyof CeloEpochDetails['aggregated_election_rewards'], num: number) { const postfix1 = num !== 1 ? 's' : ''; const postfix2 = num !== 1 ? 'es' : ''; @@ -27,7 +27,7 @@ export function getRewardNumText(type: keyof BlockEpoch['aggregated_election_rew return `${ num } ${ text }`; } -export function getRewardDetailsTableTitles(type: keyof ExcludeNull): [string, string] { +export function getRewardDetailsTableTitles(type: keyof ExcludeNull): [string, string] { switch (type) { case 'delegated_payment': return [ 'Beneficiary', 'Validator' ]; @@ -40,6 +40,6 @@ export function getRewardDetailsTableTitles(type: keyof ExcludeNull) { +export function formatRewardType(type: keyof ExcludeNull) { return type.replaceAll('_', '-'); } diff --git a/ui/home/LatestBlocksItem.tsx b/ui/home/LatestBlocksItem.tsx index 0810c7d420..4810d1af37 100644 --- a/ui/home/LatestBlocksItem.tsx +++ b/ui/home/LatestBlocksItem.tsx @@ -38,8 +38,8 @@ const LatestBlocksItem = ({ block, isLoading, animation }: Props) => { fontWeight={ 500 } mr="auto" /> - { block.celo?.is_epoch_block && ( - + { block.celo?.l1_era_finalized_epoch_number && ( + ) } diff --git a/ui/home/LatestTxs.tsx b/ui/home/LatestTxs.tsx index 46f65c0d02..cf381a7bbe 100644 --- a/ui/home/LatestTxs.tsx +++ b/ui/home/LatestTxs.tsx @@ -23,7 +23,7 @@ const LatestTransactions = () => { }, }); - const { num, alertText } = useNewTxsSocket({ type: 'txs_home', isLoading: isPlaceholderData }); + const { num, showErrorAlert } = useNewTxsSocket({ type: 'txs_home', isLoading: isPlaceholderData }); if (isError) { return No data. Please reload the page.; @@ -33,7 +33,7 @@ const LatestTransactions = () => { const txsUrl = route({ pathname: '/txs' }); return ( <> - + { data.slice(0, txsCount).map(((tx, index) => ( { }, apiData?.celo && { id: 'current_epoch' as const, - icon: 'hourglass' as const, + icon: 'hourglass_slim' as const, label: 'Current epoch', value: `#${ apiData.celo.epoch_number }`, + href: { pathname: '/epochs/[number]' as const, query: { number: String(apiData.celo.epoch_number) } }, isLoading, }, ] diff --git a/ui/home/__screenshots__/HeroBanner.pw.tsx_dark-color-mode_customization-dark-mode-1.png b/ui/home/__screenshots__/HeroBanner.pw.tsx_dark-color-mode_customization-dark-mode-1.png index c0226017d9..d2d6d64587 100644 Binary files a/ui/home/__screenshots__/HeroBanner.pw.tsx_dark-color-mode_customization-dark-mode-1.png and b/ui/home/__screenshots__/HeroBanner.pw.tsx_dark-color-mode_customization-dark-mode-1.png differ diff --git a/ui/home/__screenshots__/HeroBanner.pw.tsx_default_customization-dark-mode-1.png b/ui/home/__screenshots__/HeroBanner.pw.tsx_default_customization-dark-mode-1.png index 297071f2df..7b1decf963 100644 Binary files a/ui/home/__screenshots__/HeroBanner.pw.tsx_default_customization-dark-mode-1.png and b/ui/home/__screenshots__/HeroBanner.pw.tsx_default_customization-dark-mode-1.png differ diff --git a/ui/home/latestDeposits/LatestArbitrumDeposits.tsx b/ui/home/latestDeposits/LatestArbitrumDeposits.tsx index 2db3a4bf64..36c48f6d24 100644 --- a/ui/home/latestDeposits/LatestArbitrumDeposits.tsx +++ b/ui/home/latestDeposits/LatestArbitrumDeposits.tsx @@ -22,14 +22,14 @@ const LatestArbitrumDeposits = () => { }); const [ num, setNum ] = useGradualIncrement(0); - const [ socketAlert, setSocketAlert ] = React.useState(''); + const [ showSocketErrorAlert, setShowSocketErrorAlert ] = React.useState(false); const handleSocketClose = React.useCallback(() => { - setSocketAlert('Connection is lost. Please reload the page.'); + setShowSocketErrorAlert(true); }, []); const handleSocketError = React.useCallback(() => { - setSocketAlert('An error has occurred while fetching new transactions. Please reload the page.'); + setShowSocketErrorAlert(true); }, []); const handleNewDepositMessage: SocketMessage.NewArbitrumDeposits['handler'] = React.useCallback((payload) => { @@ -66,7 +66,7 @@ const LatestArbitrumDeposits = () => { )) } isLoading={ isPlaceholderData } socketItemsNum={ num } - socketAlert={ socketAlert } + showSocketErrorAlert={ showSocketErrorAlert } /> ); } diff --git a/ui/home/latestDeposits/LatestDeposits.tsx b/ui/home/latestDeposits/LatestDeposits.tsx index 0a7999067e..926907a5f7 100644 --- a/ui/home/latestDeposits/LatestDeposits.tsx +++ b/ui/home/latestDeposits/LatestDeposits.tsx @@ -28,7 +28,7 @@ type Props = { isLoading?: boolean; items: Array; socketItemsNum: number; - socketAlert?: string; + showSocketErrorAlert?: boolean; }; type ItemProps = { @@ -151,11 +151,18 @@ const LatestDepositsItem = ({ item, isLoading }: ItemProps) => { ); }; -const LatestDeposits = ({ isLoading, items, socketAlert, socketItemsNum }: Props) => { +const LatestDeposits = ({ isLoading, items, showSocketErrorAlert, socketItemsNum }: Props) => { const depositsUrl = route({ pathname: '/deposits' }); return ( <> - + { items.map(((item, index) => ( { }); const [ num, setNum ] = useGradualIncrement(0); - const [ socketAlert, setSocketAlert ] = React.useState(''); + const [ showSocketErrorAlert, setShowSocketErrorAlert ] = React.useState(false); const handleSocketClose = React.useCallback(() => { - setSocketAlert('Connection is lost. Please reload the page.'); + setShowSocketErrorAlert(true); }, []); const handleSocketError = React.useCallback(() => { - setSocketAlert('An error has occurred while fetching new transactions. Please reload the page.'); + setShowSocketErrorAlert(true); }, []); const handleNewDepositMessage: SocketMessage.NewOptimisticDeposits['handler'] = React.useCallback((payload) => { @@ -61,7 +61,7 @@ const LatestOptimisticDeposits = () => { )) } isLoading={ isPlaceholderData } socketItemsNum={ num } - socketAlert={ socketAlert } + showSocketErrorAlert={ showSocketErrorAlert } /> ); } diff --git a/ui/myProfile/MyProfileEmail.tsx b/ui/myProfile/MyProfileEmail.tsx index da1e39e2bb..b71370059a 100644 --- a/ui/myProfile/MyProfileEmail.tsx +++ b/ui/myProfile/MyProfileEmail.tsx @@ -46,19 +46,21 @@ const MyProfileEmail = ({ profileQuery }: Props) => { }, }); + const authFetchFactory = React.useCallback((email: string) => (recaptchaToken?: string) => { + return apiFetch('general:auth_send_otp', { + fetchParams: { + method: 'POST', + body: { email, recaptcha_response: recaptchaToken }, + headers: { + ...(recaptchaToken && { 'recaptcha-v2-response': recaptchaToken }), + }, + }, + }); + }, [ apiFetch ]); + const onFormSubmit: SubmitHandler = React.useCallback(async(formData) => { try { - const token = await recaptcha.executeAsync(); - - await apiFetch('general:auth_send_otp', { - fetchParams: { - method: 'POST', - body: { - email: formData.email, - recaptcha_response: token, - }, - }, - }); + await recaptcha.fetchProtectedResource(authFetchFactory(formData.email)); mixpanel.logEvent(mixpanel.EventTypes.ACCOUNT_LINK_INFO, { Source: 'Profile', Status: 'OTP sent', @@ -72,7 +74,7 @@ const MyProfileEmail = ({ profileQuery }: Props) => { description: apiError?.message || getErrorMessage(error) || 'Something went wrong', }); } - }, [ apiFetch, authModal, recaptcha ]); + }, [ authFetchFactory, authModal, recaptcha ]); const hasDirtyFields = Object.keys(formApi.formState.dirtyFields).length > 0; diff --git a/ui/optimismSuperchain/address/AddressOpSuperchain.tsx b/ui/optimismSuperchain/address/AddressOpSuperchain.tsx new file mode 100644 index 0000000000..7350878cac --- /dev/null +++ b/ui/optimismSuperchain/address/AddressOpSuperchain.tsx @@ -0,0 +1,79 @@ +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 = React.useMemo(() => { + return [ + { + id: 'index', + title: 'Details', + component:
Coming soon 🔜
, + }, + { + id: 'txs', + title: 'Transactions', + component: , + subTabs: ADDRESS_OP_SUPERCHAIN_TXS_TAB_IDS, + }, + ]; + }, []); + + const titleSecondRow = ( + + + + + ); + + return ( + <> + + + + + ); +}; + +export default React.memo(AddressOpSuperchain); diff --git a/ui/optimismSuperchain/address/AddressOpSuperchainTxs.tsx b/ui/optimismSuperchain/address/AddressOpSuperchainTxs.tsx new file mode 100644 index 0000000000..943b53145d --- /dev/null +++ b/ui/optimismSuperchain/address/AddressOpSuperchainTxs.tsx @@ -0,0 +1,115 @@ +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' ? ( + + ) : null; + + const rightSlot = tab === 'local_txs' ? ( + <> + + { txsLocalFilter } + + + + { /* */ } + + + + ) : null; + + const chainData = multichainConfig()?.chains.find(chain => chain.slug === txsQueryLocal.query.chainValue?.[0]); + + const tabs: Array = [ + { + id: 'cross_chain_txs', + title: 'Cross-chain', + component:
Coming soon 🔜
, + }, + { + id: 'local_txs', + title: 'Local', + component: ( + + + + + + ), + }, + ]; + + return ( + + ); +}; + +export default React.memo(AddressOpSuperchainTxs); diff --git a/ui/optimismSuperchain/components/CrossChainTxStatusTag.tsx b/ui/optimismSuperchain/components/CrossChainTxStatusTag.tsx new file mode 100644 index 0000000000..92e8e024c5 --- /dev/null +++ b/ui/optimismSuperchain/components/CrossChainTxStatusTag.tsx @@ -0,0 +1,34 @@ +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 ; +}; + +export default React.memo(CrossChainTxStatusTag); diff --git a/ui/optimismSuperchain/crossChainTxs/CrossChainTxsTable.tsx b/ui/optimismSuperchain/crossChainTxs/CrossChainTxsTable.tsx new file mode 100644 index 0000000000..e551c8416e --- /dev/null +++ b/ui/optimismSuperchain/crossChainTxs/CrossChainTxsTable.tsx @@ -0,0 +1,56 @@ +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; + isLoading: boolean; + socketType?: TxsSocketType; +} + +const CrossChainTxsTable = ({ items, isLoading, socketType }: Props) => { + + return ( + + + + + + + Message + + + Type + Method + Source tx + Destination tx + Sender + + Target + Value + + + + { socketType && } + { items.map((item, index) => ( + + )) } + + + + ); +}; + +export default React.memo(CrossChainTxsTable); diff --git a/ui/optimismSuperchain/crossChainTxs/CrossChainTxsTableItem.tsx b/ui/optimismSuperchain/crossChainTxs/CrossChainTxsTableItem.tsx new file mode 100644 index 0000000000..1ebcf7b118 --- /dev/null +++ b/ui/optimismSuperchain/crossChainTxs/CrossChainTxsTableItem.tsx @@ -0,0 +1,119 @@ +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 ( + + + + + + + { item.nonce } + + + + + + + + + + + { item.method } + + + { item.init_transaction_hash ? ( + + ) : + + } + + + { item.relay_transaction_hash ? ( + + ) : + + } + + + { item.sender ? ( + + ) : '-' } + + + + + + { item.target ? ( + + ) : '-' } + + + { value.valueStr } + + + ); +}; + +export default React.memo(CrossChainTxsTableItem); diff --git a/ui/optimismSuperchain/home/ChainLatestBlockInfo.tsx b/ui/optimismSuperchain/home/ChainLatestBlockInfo.tsx new file mode 100644 index 0000000000..21c3df3398 --- /dev/null +++ b/ui/optimismSuperchain/home/ChainLatestBlockInfo.tsx @@ -0,0 +1,78 @@ +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 ( + + Latest block + + { blocksQuery.data[0].height } + + + + ); +}; + +export default React.memo(ChainLatestBlockInfo); diff --git a/ui/optimismSuperchain/home/ChainWidget.tsx b/ui/optimismSuperchain/home/ChainWidget.tsx new file mode 100644 index 0000000000..2b14cd1ed1 --- /dev/null +++ b/ui/optimismSuperchain/home/ChainWidget.tsx @@ -0,0 +1,77 @@ +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 ( + + + { + + + + + { data.config.chain.name } + + + Chain ID + { data.config.chain.id } + + + + { statsQuery.data && statsQuery.data.gas_prices && data.config.features.gasTracker.isEnabled && ( + + Gas price + + + + + ) } + + + ); +}; + +export default React.memo(ChainWidget); diff --git a/ui/optimismSuperchain/home/HomeOpSuperchain.tsx b/ui/optimismSuperchain/home/HomeOpSuperchain.tsx new file mode 100644 index 0000000000..5d75adceb6 --- /dev/null +++ b/ui/optimismSuperchain/home/HomeOpSuperchain.tsx @@ -0,0 +1,33 @@ +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 ( + + + + { multichainConfig()?.chains.map(chain => { + return ( + + + + + + ); + }) } + + + + ); +}; + +export default React.memo(HomeOpSuperchain); diff --git a/ui/optimismSuperchain/home/LatestTxs.tsx b/ui/optimismSuperchain/home/LatestTxs.tsx new file mode 100644 index 0000000000..ab7c47bff0 --- /dev/null +++ b/ui/optimismSuperchain/home/LatestTxs.tsx @@ -0,0 +1,65 @@ +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 | undefined>( + [ getQueryParamString(router.query['chain-slug']) ?? multichainConfig()?.chains[0]?.slug ].filter(Boolean), + ); + + const handleChainValueChange = React.useCallback(({ value }: { value: Array }) => { + 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: , + }, + { + id: 'local_txs', + title: 'Local', + component: chainValue ? : null, + }, + ]; + + const rightSlot = tab === 'local_txs' ? ( + + ) : null; + + return ( + + Latest transactions + + + ); +}; + +export default React.memo(LatestTxs); diff --git a/ui/optimismSuperchain/home/LatestTxsCrossChain.tsx b/ui/optimismSuperchain/home/LatestTxsCrossChain.tsx new file mode 100644 index 0000000000..d5f117f927 --- /dev/null +++ b/ui/optimismSuperchain/home/LatestTxsCrossChain.tsx @@ -0,0 +1,51 @@ +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 ? ( + <> + + Coming soon 🔜 + + + + + + ) : null; + + return ( + + + { content } + + + ); +}; + +export default React.memo(LatestTxsCrossChain); diff --git a/ui/optimismSuperchain/home/LatestTxsLocal.tsx b/ui/optimismSuperchain/home/LatestTxsLocal.tsx new file mode 100644 index 0000000000..5ae003be42 --- /dev/null +++ b/ui/optimismSuperchain/home/LatestTxsLocal.tsx @@ -0,0 +1,58 @@ +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 ( + + + + + + ); +}; + +export default React.memo(LatestTxsLocal); diff --git a/ui/pages/Address.tsx b/ui/pages/Address.tsx index 6aed9cb4aa..77829e3735 100644 --- a/ui/pages/Address.tsx +++ b/ui/pages/Address.tsx @@ -8,18 +8,23 @@ import type { EntityTag } from 'ui/shared/EntityTags/types'; import config from 'configs/app'; import getCheckedSummedAddress from 'lib/address/getCheckedSummedAddress'; import useAddressMetadataInfoQuery from 'lib/address/useAddressMetadataInfoQuery'; +import useAddressMetadataInitUpdate from 'lib/address/useAddressMetadataInitUpdate'; import useApiQuery from 'lib/api/useApiQuery'; import { useAppContext } from 'lib/contexts/app'; +import { useMultichainContext } from 'lib/contexts/multichain'; import useAddressProfileApiQuery from 'lib/hooks/useAddressProfileApiQuery'; import useIsSafeAddress from 'lib/hooks/useIsSafeAddress'; import getNetworkValidationActionText from 'lib/networks/getNetworkValidationActionText'; import getQueryParamString from 'lib/router/getQueryParamString'; +import useEtherscanRedirects from 'lib/router/useEtherscanRedirects'; import useSocketChannel from 'lib/socket/useSocketChannel'; import useSocketMessage from 'lib/socket/useSocketMessage'; import useFetchXStarScore from 'lib/xStarScore/useFetchXStarScore'; import { ADDRESS_TABS_COUNTERS } from 'stubs/address'; import { USER_OPS_ACCOUNT } from 'stubs/userOps'; import RoutedTabs from 'toolkit/components/RoutedTabs/RoutedTabs'; +import Address3rdPartyWidgets from 'ui/address/Address3rdPartyWidgets'; +import useAddress3rdPartyWidgets from 'ui/address/address3rdPartyWidgets/useAddress3rdPartyWidgets'; import AddressAccountHistory from 'ui/address/AddressAccountHistory'; import AddressBlocksValidated from 'ui/address/AddressBlocksValidated'; import AddressCoinBalance from 'ui/address/AddressCoinBalance'; @@ -41,6 +46,7 @@ import AddressMetadataAlert from 'ui/address/details/AddressMetadataAlert'; import AddressQrCode from 'ui/address/details/AddressQrCode'; import AddressEnsDomains from 'ui/address/ensDomains/AddressEnsDomains'; import SolidityscanReport from 'ui/address/SolidityscanReport'; +import useAddressCountersQuery from 'ui/address/utils/useAddressCountersQuery'; import useAddressQuery from 'ui/address/utils/useAddressQuery'; import useCheckAddressFormat from 'ui/address/utils/useCheckAddressFormat'; import useCheckDomainNameParam from 'ui/address/utils/useCheckDomainNameParam'; @@ -66,11 +72,15 @@ const xScoreFeature = config.features.xStarScore; const AddressPageContent = () => { const router = useRouter(); const appProps = useAppContext(); + const { chain } = useMultichainContext() || {}; const hash = getQueryParamString(router.query.hash); const checkDomainName = useCheckDomainNameParam(hash); const checkAddressFormat = useCheckAddressFormat(hash); + + useEtherscanRedirects(); + const areQueriesEnabled = !checkDomainName && !checkAddressFormat; const addressQuery = useAddressQuery({ hash, isEnabled: areQueriesEnabled }); @@ -82,6 +92,11 @@ const AddressPageContent = () => { }, }); + const countersQuery = useAddressCountersQuery({ + hash, + addressQuery, + }); + const userOpsAccountQuery = useApiQuery('general:user_ops_account', { pathParams: { hash }, queryOptions: { @@ -119,10 +134,17 @@ const AddressPageContent = () => { addressEnsDomainsQuery.data?.items.find((domain) => domain.name === addressQuery.data?.ens_domain_name) : undefined; + const address3rdPartyWidgets = useAddress3rdPartyWidgets( + addressQuery.data?.is_contract ? 'contract' : 'eoa', + addressQuery.isPlaceholderData, + areQueriesEnabled, + ); + const isLoading = addressQuery.isPlaceholderData; const isTabsLoading = isLoading || addressTabsCountersQuery.isPlaceholderData || + (address3rdPartyWidgets.isEnabled && address3rdPartyWidgets.configQuery.isPlaceholderData) || (config.features.userOps.isEnabled && userOpsAccountQuery.isPlaceholderData) || (config.features.mudFramework.isEnabled && mudTablesCountQuery.isPlaceholderData); @@ -140,6 +162,12 @@ const AddressPageContent = () => { handler: handleFetchedBytecodeMessage, }); + useAddressMetadataInitUpdate({ + address: hash, + counters: countersQuery.data, + isEnabled: !countersQuery.isPlaceholderData && !countersQuery.isDegradedData, + }); + const isSafeAddress = useIsSafeAddress(!addressQuery.isPlaceholderData && addressQuery.data?.is_contract ? hash : undefined); const xStarQuery = useFetchXStarScore({ hash }); @@ -155,7 +183,7 @@ const AddressPageContent = () => { { id: 'index', title: 'Details', - component: , + component: , }, addressQuery.data?.is_contract ? { id: 'contract', @@ -263,15 +291,31 @@ const AddressPageContent = () => { component: , } : undefined, + (address3rdPartyWidgets.isEnabled && address3rdPartyWidgets.items.length > 0) ? { + id: 'widgets', + title: 'Widgets', + count: address3rdPartyWidgets.items.length, + component: ( + + ), + } : undefined, ].filter(Boolean); }, [ addressQuery, + countersQuery, contractTabs, addressTabsCountersQuery.data, userOpsAccountQuery.data, isTabsLoading, areQueriesEnabled, mudTablesCountQuery.data, + address3rdPartyWidgets, ]); const usernameApiTag = userPropfileApiQuery.data?.user_profile?.username; @@ -419,11 +463,13 @@ const AddressPageContent = () => {
); + const chainText = chain ? ` on ${ chain.config.chain.name }` : ''; + return ( <> { + + const tabs: Array = [ + { id: 'rest_api', title: 'REST API', component: , count: REST_API_SECTIONS.length }, + { id: 'eth_rpc_api', title: 'ETH RPC API', component: }, + { id: 'rpc_api', title: 'RPC API endpoints', component: }, + { id: 'graphql_api', title: 'GraphQL API', component: }, + ].filter(({ id }) => feature.isEnabled && feature.tabs.includes(id)); + + return ( + <> + + { tabs.length > 0 ? : No API documentation available } + + ); +}; + +export default React.memo(ApiDocs); diff --git a/ui/pages/Block.tsx b/ui/pages/Block.tsx index 08e4211a11..1870bca7f2 100644 --- a/ui/pages/Block.tsx +++ b/ui/pages/Block.tsx @@ -8,6 +8,7 @@ import type { PaginationParams } from 'ui/shared/pagination/types'; import config from 'configs/app'; import { useAppContext } from 'lib/contexts/app'; +import { useMultichainContext } from 'lib/contexts/multichain'; import throwOnAbsentParamError from 'lib/errors/throwOnAbsentParamError'; import throwOnResourceLoadError from 'lib/errors/throwOnResourceLoadError'; import useIsMobile from 'lib/hooks/useIsMobile'; @@ -17,7 +18,6 @@ import { Skeleton } from 'toolkit/chakra/skeleton'; import RoutedTabs from 'toolkit/components/RoutedTabs/RoutedTabs'; import BlockCeloEpochTag from 'ui/block/BlockCeloEpochTag'; import BlockDetails from 'ui/block/BlockDetails'; -import BlockEpochRewards from 'ui/block/BlockEpochRewards'; import BlockInternalTxs from 'ui/block/BlockInternalTxs'; import BlockWithdrawals from 'ui/block/BlockWithdrawals'; import useBlockBlobTxsQuery from 'ui/block/useBlockBlobTxsQuery'; @@ -47,6 +47,7 @@ const BlockPageContent = () => { const appProps = useAppContext(); const heightOrHash = getQueryParamString(router.query.height_or_hash); const tab = getQueryParamString(router.query.tab); + const { chain } = useMultichainContext() || {}; const blockQuery = useBlockQuery({ heightOrHash }); const blockTxsQuery = useBlockTxsQuery({ heightOrHash, blockQuery, tab }); @@ -110,12 +111,7 @@ const BlockPageContent = () => { ), } : null, - blockQuery.data?.celo?.is_epoch_block ? { - id: 'epoch_rewards', - title: 'Epoch rewards', - component: , - } : null, - ].filter(Boolean)), [ blockBlobTxsQuery, blockInternalTxsQuery, blockQuery, blockTxsQuery, blockWithdrawalsQuery, hasPagination, heightOrHash ]); + ].filter(Boolean)), [ blockBlobTxsQuery, blockInternalTxsQuery, blockQuery, blockTxsQuery, blockWithdrawalsQuery, hasPagination ]); let pagination; if (tab === 'txs') { @@ -151,15 +147,17 @@ const BlockPageContent = () => { } const title = (() => { + const chainText = chain ? ` on ${ chain.config.chain.name }` : ''; + switch (blockQuery.data?.type) { case 'reorg': - return `Reorged block #${ blockQuery.data?.height }`; + return `Reorged block #${ blockQuery.data?.height }${ chainText }`; case 'uncle': - return `Uncle block #${ blockQuery.data?.height }`; + return `Uncle block #${ blockQuery.data?.height }${ chainText }`; default: - return `Block #${ blockQuery.data?.height }`; + return `Block #${ blockQuery.data?.height }${ chainText }`; } })(); diff --git a/ui/pages/CsvExport.tsx b/ui/pages/CsvExport.tsx index a99b7e54dd..d725c6ba48 100644 --- a/ui/pages/CsvExport.tsx +++ b/ui/pages/CsvExport.tsx @@ -29,39 +29,39 @@ interface ExportTypeEntity { const EXPORT_TYPES: Record = { transactions: { text: 'transactions', - resource: 'general:csv_export_txs', + resource: 'general:address_csv_export_txs', fileNameTemplate: 'transactions', filterType: 'address', filterValues: AddressFromToFilterValues, }, 'internal-transactions': { text: 'internal transactions', - resource: 'general:csv_export_internal_txs', + resource: 'general:address_csv_export_internal_txs', fileNameTemplate: 'internal_transactions', filterType: 'address', filterValues: AddressFromToFilterValues, }, 'token-transfers': { text: 'token transfers', - resource: 'general:csv_export_token_transfers', + resource: 'general:address_csv_export_token_transfers', fileNameTemplate: 'token_transfers', filterType: 'address', filterValues: AddressFromToFilterValues, }, logs: { text: 'logs', - resource: 'general:csv_export_logs', + resource: 'general:address_csv_export_logs', fileNameTemplate: 'logs', filterType: 'topic', }, holders: { text: 'holders', - resource: 'general:csv_export_token_holders', + resource: 'general:token_csv_export_holders', fileNameTemplate: 'holders', }, 'epoch-rewards': { text: 'epoch rewards', - resource: 'general:csv_export_epoch_rewards', + resource: 'general:address_csv_export_celo_election_rewards', fileNameTemplate: 'epoch_rewards', }, }; diff --git a/ui/pages/Epoch.pw.tsx b/ui/pages/Epoch.pw.tsx new file mode 100644 index 0000000000..d15334a7f8 --- /dev/null +++ b/ui/pages/Epoch.pw.tsx @@ -0,0 +1,39 @@ +import React from 'react'; + +import * as epochMock from 'mocks/epochs/celo'; +import { ENVS_MAP } from 'playwright/fixtures/mockEnvs'; +import { test, expect } from 'playwright/lib'; + +import Epoch from './Epoch'; + +test('base view +@mobile', async({ render, mockEnvs, mockTextAd, mockApiResponse }) => { + const hooksConfig = { + router: { + query: { number: String(epochMock.epoch1.number) }, + }, + }; + + await mockEnvs(ENVS_MAP.celo); + await mockTextAd(); + await mockApiResponse('general:epoch_celo', epochMock.epoch1, { pathParams: { number: String(epochMock.epoch1.number) } }); + + const component = await render(, { hooksConfig }); + + await expect(component).toHaveScreenshot(); +}); + +test('unfinalized epoch', async({ render, mockEnvs, mockTextAd, mockApiResponse }) => { + const hooksConfig = { + router: { + query: { number: String(epochMock.epochUnfinalized.number) }, + }, + }; + + await mockEnvs(ENVS_MAP.celo); + await mockTextAd(); + await mockApiResponse('general:epoch_celo', epochMock.epochUnfinalized, { pathParams: { number: String(epochMock.epochUnfinalized.number) } }); + + const component = await render(, { hooksConfig }); + + await expect(component).toHaveScreenshot(); +}); diff --git a/ui/pages/Epoch.tsx b/ui/pages/Epoch.tsx new file mode 100644 index 0000000000..d65df4bd08 --- /dev/null +++ b/ui/pages/Epoch.tsx @@ -0,0 +1,110 @@ +import { Box, HStack } from '@chakra-ui/react'; +import { useRouter } from 'next/router'; +import React from 'react'; + +import useApiQuery from 'lib/api/useApiQuery'; +import { useAppContext } from 'lib/contexts/app'; +import throwOnResourceLoadError from 'lib/errors/throwOnResourceLoadError'; +import useIsMobile from 'lib/hooks/useIsMobile'; +import getQueryParamString from 'lib/router/getQueryParamString'; +import { CELO_EPOCH } from 'stubs/epoch'; +import { Tag } from 'toolkit/chakra/tag'; +import { Tooltip } from 'toolkit/chakra/tooltip'; +import EpochDetails from 'ui/epochs/EpochDetails'; +import TextAd from 'ui/shared/ad/TextAd'; +import BlockEntity from 'ui/shared/entities/block/BlockEntity'; +import PageTitle from 'ui/shared/Page/PageTitle'; + +const EpochPageContent = () => { + const isMobile = useIsMobile(); + const appProps = useAppContext(); + const router = useRouter(); + const number = getQueryParamString(router.query.number); + + const epochQuery = useApiQuery('general:epoch_celo', { + pathParams: { + number: number, + }, + queryOptions: { + placeholderData: CELO_EPOCH, + }, + }); + + throwOnResourceLoadError(epochQuery); + + const isLoading = epochQuery.isPlaceholderData; + + const backLink = React.useMemo(() => { + const hasGoBackLink = appProps.referrer && appProps.referrer.includes('/epochs'); + + if (!hasGoBackLink) { + return; + } + + return { + label: 'Back to epochs list', + url: appProps.referrer, + }; + }, [ appProps.referrer ]); + + const titleContentAfter = (() => { + switch (epochQuery.data?.type) { + case 'L1': + return ( + + { epochQuery.data.type } + + ); + case 'L2': + return ( + + { epochQuery.data.type } + + ); + } + + return null; + })(); + + const titleSecondRow = (() => { + if (!epochQuery.data || epochQuery.data?.start_block_number === null) { + return null; + } + + const isTruncated = isMobile && Boolean(epochQuery.data.end_block_number); + const truncationProps = isTruncated ? { truncation: 'constant' as const, truncationMaxSymbols: 6 } : undefined; + + return ( + + Ranging from + + { epochQuery.data.end_block_number && ( + <> + to + + + ) } + + ); + })(); + + return ( + <> + + + { epochQuery.data && } + + ); +}; + +export default EpochPageContent; diff --git a/ui/pages/Epochs.pw.tsx b/ui/pages/Epochs.pw.tsx new file mode 100644 index 0000000000..569832fc65 --- /dev/null +++ b/ui/pages/Epochs.pw.tsx @@ -0,0 +1,17 @@ +import React from 'react'; + +import { list as epochsList } from 'mocks/epochs/celo'; +import { ENVS_MAP } from 'playwright/fixtures/mockEnvs'; +import { test, expect } from 'playwright/lib'; + +import Epochs from './Epochs'; + +test('base view +@mobile', async({ render, mockEnvs, mockTextAd, mockApiResponse }) => { + await mockEnvs(ENVS_MAP.celo); + await mockTextAd(); + await mockApiResponse('general:epochs_celo', epochsList); + + const component = await render(); + + await expect(component).toHaveScreenshot(); +}); diff --git a/ui/pages/Epochs.tsx b/ui/pages/Epochs.tsx new file mode 100644 index 0000000000..dc81372667 --- /dev/null +++ b/ui/pages/Epochs.tsx @@ -0,0 +1,76 @@ +import { Box } from '@chakra-ui/react'; +import React from 'react'; + +import { CELO_EPOCH_ITEM } from 'stubs/epoch'; +import { generateListStub } from 'stubs/utils'; +import EpochsListItem from 'ui/epochs/EpochsListItem'; +import EpochsTable from 'ui/epochs/EpochsTable'; +import ActionBar, { ACTION_BAR_HEIGHT_DESKTOP } from 'ui/shared/ActionBar'; +import DataFetchAlert from 'ui/shared/DataFetchAlert'; +import DataListDisplay from 'ui/shared/DataListDisplay'; +import PageTitle from 'ui/shared/Page/PageTitle'; +import Pagination from 'ui/shared/pagination/Pagination'; +import useQueryWithPages from 'ui/shared/pagination/useQueryWithPages'; + +const EpochsPageContent = () => { + const epochsQuery = useQueryWithPages({ + resourceName: 'general:epochs_celo', + options: { + placeholderData: generateListStub<'general:epochs_celo'>(CELO_EPOCH_ITEM, 50, { next_page_params: { + number: 1739, + items_count: 50, + } }), + }, + }); + + const actionBar = epochsQuery.pagination.isVisible ? ( + + + + ) : null; + + const isLoading = epochsQuery.isPlaceholderData; + + const content = (() => { + if (epochsQuery.isError) { + return ; + } + + return epochsQuery.data?.items ? ( + <> + + + + + { epochsQuery.data.items.map((item, index) => ( + + )) } + + + ) : null; + })(); + + return ( + <> + + + { content } + + + ); +}; + +export default EpochsPageContent; diff --git a/ui/pages/Stats.tsx b/ui/pages/Stats.tsx index 9b8e8ec869..852544e349 100644 --- a/ui/pages/Stats.tsx +++ b/ui/pages/Stats.tsx @@ -2,6 +2,7 @@ import { Box } from '@chakra-ui/react'; import React from 'react'; import config from 'configs/app'; +import useEtherscanRedirects from 'lib/router/useEtherscanRedirects'; import PageTitle from 'ui/shared/Page/PageTitle'; import ChartsWidgetsList from '../stats/ChartsWidgetsList'; @@ -24,6 +25,8 @@ const Stats = () => { initialFilterQuery, } = useStats(); + useEtherscanRedirects(); + return ( <> { const tab = getQueryParamString(router.query.tab); const ownerFilter = getQueryParamString(router.query.holder_address_hash) || undefined; + useEtherscanRedirects(); const queryClient = useQueryClient(); const tokenQuery = useTokenQuery(hashString); @@ -160,7 +164,13 @@ const TokenPageContent = () => { }, }); - const isLoading = tokenQuery.isPlaceholderData || addressQuery.isPlaceholderData; + const address3rdPartyWidgets = useAddress3rdPartyWidgets('token', false, isQueryEnabled); + + const isLoading = + tokenQuery.isPlaceholderData || + addressQuery.isPlaceholderData || + (address3rdPartyWidgets.isEnabled && address3rdPartyWidgets.configQuery.isPlaceholderData); + const contractTabs = useContractTabs(addressQuery.data, addressQuery.isPlaceholderData); const tabs: Array = [ @@ -196,6 +206,12 @@ const TokenPageContent = () => { component: , subTabs: CONTRACT_TAB_IDS, } : undefined, + (address3rdPartyWidgets.isEnabled && address3rdPartyWidgets.items.length > 0) ? { + id: 'widgets', + title: 'Widgets', + count: address3rdPartyWidgets.items.length, + component: , + } : undefined, ].filter(Boolean); let pagination: PaginationParams | undefined; diff --git a/ui/pages/Transaction.tsx b/ui/pages/Transaction.tsx index 0a3eb72f86..57bc48ab2a 100644 --- a/ui/pages/Transaction.tsx +++ b/ui/pages/Transaction.tsx @@ -7,8 +7,10 @@ import type { EntityTag as TEntityTag } from 'ui/shared/EntityTags/types'; import config from 'configs/app'; import useApiQuery from 'lib/api/useApiQuery'; import { useAppContext } from 'lib/contexts/app'; +import { useMultichainContext } from 'lib/contexts/multichain'; import throwOnResourceLoadError from 'lib/errors/throwOnResourceLoadError'; import getQueryParamString from 'lib/router/getQueryParamString'; +import useEtherscanRedirects from 'lib/router/useEtherscanRedirects'; import { publicClient } from 'lib/web3/client'; import RoutedTabs from 'toolkit/components/RoutedTabs/RoutedTabs'; import TextAd from 'ui/shared/ad/TextAd'; @@ -37,8 +39,12 @@ const tacFeature = config.features.tac; const TransactionPageContent = () => { const router = useRouter(); const appProps = useAppContext(); + const { chain } = useMultichainContext() || {}; const hash = getQueryParamString(router.query.hash); + + useEtherscanRedirects(); + const txQuery = useTxQuery(); const tacOperationQuery = useApiQuery('tac:operation_by_tx_hash', { @@ -129,7 +135,7 @@ const TransactionPageContent = () => { <> { switch (statusCode) { case 429: { - return ; + const rateLimits = getErrorProp(error, 'rateLimits'); + const bypassOptions = typeof rateLimits === 'object' && rateLimits && 'bypassOptions' in rateLimits ? rateLimits.bypassOptions : undefined; + return ; } default: { diff --git a/ui/shared/AppError/custom/AppErrorTooManyRequests.tsx b/ui/shared/AppError/custom/AppErrorTooManyRequests.tsx index 36890db4a0..caacac5fac 100644 --- a/ui/shared/AppError/custom/AppErrorTooManyRequests.tsx +++ b/ui/shared/AppError/custom/AppErrorTooManyRequests.tsx @@ -12,18 +12,30 @@ import useReCaptcha from 'ui/shared/reCaptcha/useReCaptcha'; import AppErrorIcon from '../AppErrorIcon'; import AppErrorTitle from '../AppErrorTitle'; -const AppErrorTooManyRequests = () => { +interface Props { + bypassOptions?: string; +} + +const AppErrorTooManyRequests = ({ bypassOptions }: Props) => { const fetch = useFetch(); const recaptcha = useReCaptcha(); const handleSubmit = React.useCallback(async() => { try { const token = await recaptcha.executeAsync(); + + if (!token) { + throw new Error('ReCaptcha is not solved'); + } + const url = buildUrl('general:api_v2_key'); await fetch(url, { method: 'POST', body: { recaptcha_response: token }, + headers: { + 'recaptcha-v2-response': token, + }, credentials: 'include', }, { resource: 'general:api_v2_key', @@ -52,7 +64,7 @@ const AppErrorTooManyRequests = () => { You have exceeded the request rate for a given time period. Please reduce the number of requests and try again soon. - + { bypassOptions !== 'no_bypass' && } ); }; diff --git a/ui/shared/DetailedInfo/DetailedInfoTimestamp.tsx b/ui/shared/DetailedInfo/DetailedInfoTimestamp.tsx index 590a693852..ac1bebaa4c 100644 --- a/ui/shared/DetailedInfo/DetailedInfoTimestamp.tsx +++ b/ui/shared/DetailedInfo/DetailedInfoTimestamp.tsx @@ -10,16 +10,17 @@ type Props = { timestamp: string | number; isLoading?: boolean; noIcon?: boolean; + gap?: number; }; -const DetailedInfoTimestamp = ({ timestamp, isLoading, noIcon }: Props) => { +const DetailedInfoTimestamp = ({ timestamp, isLoading, noIcon, gap }: Props) => { return ( <> { !noIcon && } { dayjs(timestamp).fromNow() } - + { dayjs(timestamp).format('llll') } diff --git a/ui/shared/EntityTags/EntityTag.tsx b/ui/shared/EntityTags/EntityTag.tsx index a35fe246b8..7a4d7f23dd 100644 --- a/ui/shared/EntityTags/EntityTag.tsx +++ b/ui/shared/EntityTags/EntityTag.tsx @@ -3,6 +3,7 @@ import React from 'react'; import type { EntityTag as TEntityTag } from './types'; +import { useMultichainContext } from 'lib/contexts/multichain'; import * as mixpanel from 'lib/mixpanel/index'; import { Link, LinkExternalIcon } from 'toolkit/chakra/link'; import { Skeleton } from 'toolkit/chakra/skeleton'; @@ -19,8 +20,9 @@ interface Props extends HTMLChakraProps<'span'> { } 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 iconColor = data.meta?.textColor ?? 'gray.400'; diff --git a/ui/shared/EntityTags/utils.ts b/ui/shared/EntityTags/utils.ts index 4454d87872..f7965e4e03 100644 --- a/ui/shared/EntityTags/utils.ts +++ b/ui/shared/EntityTags/utils.ts @@ -1,8 +1,10 @@ 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) { return { type: 'external', @@ -20,7 +22,7 @@ export function getTagLinkParams(data: EntityTag): { type: 'external' | 'interna if (data.tagType === 'generic' || data.tagType === 'protocol') { return { 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), }; } } diff --git a/ui/shared/EpochRewardTypeTag.tsx b/ui/shared/EpochRewardTypeTag.tsx index e051822319..a8f262045b 100644 --- a/ui/shared/EpochRewardTypeTag.tsx +++ b/ui/shared/EpochRewardTypeTag.tsx @@ -1,17 +1,17 @@ import React from 'react'; -import type { EpochRewardsType } from 'types/api/block'; +import type { CeloEpochRewardsType } from 'types/api/epochs'; import type { BadgeProps } from 'toolkit/chakra/badge'; import { Badge } from 'toolkit/chakra/badge'; import { Tooltip } from 'toolkit/chakra/tooltip'; type Props = { - type: EpochRewardsType; + type: CeloEpochRewardsType; isLoading?: boolean; }; -const TYPE_TAGS: Record = { +const TYPE_TAGS: Record = { group: { text: 'Validator group rewards', // eslint-disable-next-line max-len diff --git a/ui/shared/HashStringShorten.tsx b/ui/shared/HashStringShorten.tsx index 6c46d389da..9d6a8f9b0a 100644 --- a/ui/shared/HashStringShorten.tsx +++ b/ui/shared/HashStringShorten.tsx @@ -9,11 +9,12 @@ interface Props { noTooltip?: boolean; tooltipInteractive?: boolean; type?: 'long' | 'short'; + maxSymbols?: number; as?: React.ElementType; } -const HashStringShorten = ({ hash, noTooltip, as = 'span', type, tooltipInteractive }: Props) => { - const charNumber = type === 'long' ? 16 : 8; +const HashStringShorten = ({ hash, noTooltip, as = 'span', type, tooltipInteractive, maxSymbols }: Props) => { + const charNumber = maxSymbols ?? (type === 'long' ? 16 : 8); if (hash.length <= charNumber) { return { hash }; } diff --git a/ui/shared/SocketNewItemsNotice.pw.tsx b/ui/shared/SocketNewItemsNotice.pw.tsx index f0bfdd6d23..5d73910fbf 100644 --- a/ui/shared/SocketNewItemsNotice.pw.tsx +++ b/ui/shared/SocketNewItemsNotice.pw.tsx @@ -17,7 +17,7 @@ test('2 new items in validated txs list +@dark-mode', async({ render }) => { }); test('connection loss', async({ render }) => { - const component = await render(, { hooksConfig }); + const component = await render(, { hooksConfig }); await expect(component).toHaveScreenshot(); }); diff --git a/ui/shared/SocketNewItemsNotice.tsx b/ui/shared/SocketNewItemsNotice.tsx index 0ff39eb8f3..470db9754e 100644 --- a/ui/shared/SocketNewItemsNotice.tsx +++ b/ui/shared/SocketNewItemsNotice.tsx @@ -15,19 +15,19 @@ interface Props { children?: (props: InjectedProps) => React.JSX.Element; className?: string; url?: string; - alert?: string; + showErrorAlert?: boolean; num?: number; isLoading?: boolean; } -const SocketNewItemsNotice = chakra(({ children, className, url, num, alert, type = 'transaction', isLoading }: Props) => { +const SocketNewItemsNotice = chakra(({ children, className, url, num, showErrorAlert, type = 'transaction', isLoading }: Props) => { const handleLinkClick = React.useCallback(() => { window.location.reload(); }, []); const alertContent = (() => { - if (alert) { - return alert; + if (showErrorAlert) { + return 'Live updates temporarily delayed'; } let name; diff --git a/ui/shared/TokenTransfer/TokenTransferTable.tsx b/ui/shared/TokenTransfer/TokenTransferTable.tsx index be3d719710..89ae4427db 100644 --- a/ui/shared/TokenTransfer/TokenTransferTable.tsx +++ b/ui/shared/TokenTransfer/TokenTransferTable.tsx @@ -15,7 +15,7 @@ interface Props { top: number; enableTimeIncrement?: boolean; showSocketInfo?: boolean; - socketInfoAlert?: string; + showSocketErrorAlert?: boolean; socketInfoNum?: number; isLoading?: boolean; } @@ -27,7 +27,7 @@ const TokenTransferTable = ({ top, enableTimeIncrement, showSocketInfo, - socketInfoAlert, + showSocketErrorAlert, socketInfoNum, isLoading, }: Props) => { @@ -53,7 +53,7 @@ const TokenTransferTable = ({ { showSocketInfo && ( { return; } + const networks = [ currentChain, parentChain, ...(clusterChains ?? []) ].filter(Boolean) as [AppKitNetwork, ...Array]; + createAppKit({ adapters: [ wagmiConfig.adapter ], - networks: [ currentChain, parentChain ].filter(Boolean) as [AppKitNetwork, ...Array], + networks, metadata: { name: `${ config.chain.name } explorer`, description: `${ config.chain.name } explorer`, diff --git a/ui/shared/__screenshots__/SocketNewItemsNotice.pw.tsx_default_connection-loss-1.png b/ui/shared/__screenshots__/SocketNewItemsNotice.pw.tsx_default_connection-loss-1.png index c97e02a307..0198a78fec 100644 Binary files a/ui/shared/__screenshots__/SocketNewItemsNotice.pw.tsx_default_connection-loss-1.png and b/ui/shared/__screenshots__/SocketNewItemsNotice.pw.tsx_default_connection-loss-1.png differ diff --git a/ui/shared/entities/address/AddressEntity.tsx b/ui/shared/entities/address/AddressEntity.tsx index e8285b0d75..f3c0ce3e5a 100644 --- a/ui/shared/entities/address/AddressEntity.tsx +++ b/ui/shared/entities/address/AddressEntity.tsx @@ -3,10 +3,11 @@ import React from 'react'; import type { AddressParam } from 'types/api/addressParams'; -import { route } from 'nextjs-routes'; +import { route } from 'nextjs/routes'; import { toBech32Address } from 'lib/address/bech32'; import { useAddressHighlightContext } from 'lib/contexts/addressHighlight'; +import { useMultichainContext } from 'lib/contexts/multichain'; import { useSettingsContext } from 'lib/contexts/settings'; import { Skeleton } from 'toolkit/chakra/skeleton'; import { Tooltip } from 'toolkit/chakra/tooltip'; @@ -24,7 +25,10 @@ const getDisplayedAddress = (address: AddressProp, altHash?: string) => { }; 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 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 = { ...getIconProps(props.variant), marginRight, @@ -60,6 +67,7 @@ const Icon = (props: IconProps) => { return ( ); @@ -68,11 +76,12 @@ const Icon = (props: IconProps) => { 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 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 ( { const label = (() => { 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; @@ -94,14 +107,14 @@ const Icon = (props: IconProps) => { content={ label } disabled={ !label } interactive={ props.tooltipInteractive } - positioning={ props.shield ? { offset: { mainAxis: 8 } } : undefined } + positioning={ shield ? { offset: { mainAxis: 8 } } : undefined } > - { props.shield && } + { shield && } { isDelegatedAddress && }
@@ -184,7 +197,10 @@ const AddressEntity = (props: EntityProps) => { const partsProps = distributeEntityProps(props); const highlightContext = useAddressHighlightContext(props.noHighlight); const settingsContext = useSettingsContext(); + const multichainContext = useMultichainContext(); + 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 // because non-interactive ones will not pass 'onMouseLeave' event to the parent component @@ -202,8 +218,8 @@ const AddressEntity = (props: EntityProps) => { position="relative" zIndex={ 0 } > - - { props.noLink ? content : { content } } + + { props.noLink ? content : { content } } ); diff --git a/ui/shared/entities/address/AddressEntityInterop.tsx b/ui/shared/entities/address/AddressEntityInterop.tsx index 030d4a4165..3f3a179517 100644 --- a/ui/shared/entities/address/AddressEntityInterop.tsx +++ b/ui/shared/entities/address/AddressEntityInterop.tsx @@ -11,7 +11,7 @@ import IconSvg from 'ui/shared/IconSvg'; import { distributeEntityProps } from '../base/utils'; import * as AddressEntity from './AddressEntity'; -interface Props extends AddressEntity.EntityProps { +interface Props extends Omit { chain: ChainInfo | null; } @@ -40,10 +40,10 @@ const IconStub = () => { ); }; -const AddressEntryInterop = (props: Props) => { +const AddressEntryInterop = ({ chain, ...props }: Props) => { const partsProps = distributeEntityProps(props); - const href = props.chain?.instance_url ? props.chain.instance_url.replace(/\/$/, '') + route({ + const href = chain?.instance_url ? chain.instance_url.replace(/\/$/, '') + route({ pathname: '/address/[hash]', query: { ...props.query, @@ -55,13 +55,13 @@ const AddressEntryInterop = (props: Props) => { { !props.isLoading && ( - props.chain?.chain_logo ? ( + chain?.chain_logo ? ( { { return ( - { props.chain && ( - + { chain && ( + { addressIcon } ) } - { !props.chain && addressIcon } + { !chain && addressIcon } { href ? ( diff --git a/ui/shared/entities/address/AddressEntityTacTon.tsx b/ui/shared/entities/address/AddressEntityTacTon.tsx index 991ded7cae..dce73c0588 100644 --- a/ui/shared/entities/address/AddressEntityTacTon.tsx +++ b/ui/shared/entities/address/AddressEntityTacTon.tsx @@ -27,7 +27,7 @@ const AddressEntityTacTon = (props: Props) => { pathname: '/address/[hash]', query: { ...props.query, - hash: props.address.hash, + hash: encodeURIComponent(props.address.hash), }, }); case tac.BlockchainType.TAC: diff --git a/ui/shared/entities/base/components.tsx b/ui/shared/entities/base/components.tsx index a18e9acbc1..593258e33e 100644 --- a/ui/shared/entities/base/components.tsx +++ b/ui/shared/entities/base/components.tsx @@ -2,6 +2,8 @@ import { Box, chakra, Flex } from '@chakra-ui/react'; import type { IconProps } from '@chakra-ui/react'; import React from 'react'; +import type { ChainConfig } from 'types/multichain'; + import type { ImageProps } from 'toolkit/chakra/image'; import { Image } from 'toolkit/chakra/image'; import type { LinkProps } from 'toolkit/chakra/link'; @@ -35,8 +37,10 @@ export interface EntityBaseProps { tailLength?: number; target?: React.HTMLAttributeAnchorTarget; truncation?: Truncation; + truncationMaxSymbols?: number; variant?: 'content' | 'heading' | 'subheading'; linkVariant?: LinkProps['variant']; + chain?: ChainConfig; } export interface ContainerBaseProps extends Pick { @@ -58,7 +62,7 @@ const Container = chakra(({ className, children, ...props }: ContainerBaseProps) ); }); -export interface LinkBaseProps extends Pick { +export interface LinkBaseProps extends Pick { children: React.ReactNode; variant?: LinkProps['variant']; } @@ -96,7 +100,7 @@ interface EntityIconProps extends Pick, EntityIconProps {} +export interface IconBaseProps extends Pick, EntityIconProps {} const Icon = ({ isLoading, noIcon, variant, name, color, borderRadius, marginRight, boxSize, shield, hint, tooltipInteractive }: IconBaseProps) => { if (noIcon || !name) { @@ -135,12 +139,12 @@ const Icon = ({ isLoading, noIcon, variant, name, color, borderRadius, marginRig return ( { iconElementWithHint } - + ); }; -type IconShieldProps = (ImageProps | IconSvgProps); +type IconShieldProps = (ImageProps | IconSvgProps) & { isLoading?: boolean }; const IconShield = (props: IconShieldProps) => { @@ -156,18 +160,21 @@ const IconShield = (props: IconShieldProps) => { // Because the highlighted styles are described as CSS classes, we must do the same for the shield border color. // borderColor: 'global.body.bg', // backgroundColor: 'global.body.bg', + className: 'entity__shield', }; if ('src' in props) { - return ; + return props.isLoading ? : ; } const svgProps = props as IconSvgProps; - return ; + return ; }; -export interface ContentBaseProps extends Pick { +export interface ContentBaseProps extends Pick< + EntityBaseProps, 'className' | 'isLoading' | 'truncation' | 'tailLength' | 'noTooltip' | 'variant' | 'truncationMaxSymbols' +> { asProp?: React.ElementType; text: string; tooltipInteractive?: boolean; @@ -179,6 +186,7 @@ const Content = chakra(({ asProp, text, truncation = 'dynamic', + truncationMaxSymbols, tailLength, variant, noTooltip, @@ -208,6 +216,7 @@ const Content = chakra(({ type="long" noTooltip={ noTooltip } tooltipInteractive={ tooltipInteractive } + maxSymbols={ truncationMaxSymbols } /> ); case 'constant': @@ -217,6 +226,7 @@ const Content = chakra(({ as={ asProp } noTooltip={ noTooltip } tooltipInteractive={ tooltipInteractive } + maxSymbols={ truncationMaxSymbols } /> ); case 'dynamic': diff --git a/ui/shared/entities/block/BlockEntity.tsx b/ui/shared/entities/block/BlockEntity.tsx index d38c56ae16..3c9e08c57f 100644 --- a/ui/shared/entities/block/BlockEntity.tsx +++ b/ui/shared/entities/block/BlockEntity.tsx @@ -1,8 +1,9 @@ import { chakra } from '@chakra-ui/react'; import React from 'react'; -import { route } from 'nextjs-routes'; +import { route } from 'nextjs/routes'; +import { useMultichainContext } from 'lib/contexts/multichain'; import * as EntityBase from 'ui/shared/entities/base/components'; import { distributeEntityProps } from '../base/utils'; @@ -11,7 +12,10 @@ type LinkProps = EntityBase.LinkBaseProps & Partial { const heightOrHash = props.hash ?? String(props.number); - const defaultHref = route({ pathname: '/block/[height_or_hash]', query: { height_or_hash: heightOrHash } }); + const defaultHref = route( + { pathname: '/block/[height_or_hash]', query: { height_or_hash: heightOrHash } }, + props.chain ? { chain: props.chain } : undefined, + ); return ( { + const multichainContext = useMultichainContext(); const partsProps = distributeEntityProps(props); const content = ; @@ -59,7 +64,7 @@ const BlockEntity = (props: EntityProps) => { return ( - { props.noLink ? content : { content } } + { props.noLink ? content : { content } } ); }; diff --git a/ui/shared/entities/epoch/EpochEntity.tsx b/ui/shared/entities/epoch/EpochEntity.tsx new file mode 100644 index 0000000000..d85789bd21 --- /dev/null +++ b/ui/shared/entities/epoch/EpochEntity.tsx @@ -0,0 +1,85 @@ +import { chakra } from '@chakra-ui/react'; +import React from 'react'; + +import { route } from 'nextjs-routes'; + +import * as EntityBase from 'ui/shared/entities/base/components'; + +import { distributeEntityProps } from '../base/utils'; + +type LinkProps = EntityBase.LinkBaseProps & Pick; + +const Link = chakra((props: LinkProps) => { + const defaultHref = route({ pathname: '/epochs/[number]', query: { number: props.number } }); + + return ( + + { props.children } + + ); +}); + +const Icon = (props: EntityBase.IconBaseProps) => { + return ( + + ); +}; + +type ContentProps = Omit & Pick; + +const Content = chakra((props: ContentProps) => { + return ( + + ); +}); + +type CopyProps = Omit & Pick; + +const Copy = (props: CopyProps) => { + return ( + + ); +}; + +const Container = EntityBase.Container; + +export interface EntityProps extends EntityBase.EntityBaseProps { + number: string; +} + +const EpochEntity = (props: EntityProps) => { + const partsProps = distributeEntityProps(props); + const content = ; + + return ( + + + { props.noLink ? content : { content } } + + + ); +}; + +export default React.memo(chakra(EpochEntity)); + +export { + Container, + Link, + Icon, + Content, + Copy, +}; diff --git a/ui/shared/entities/tx/TxEntity.tsx b/ui/shared/entities/tx/TxEntity.tsx index 15c2913733..29aad70e54 100644 --- a/ui/shared/entities/tx/TxEntity.tsx +++ b/ui/shared/entities/tx/TxEntity.tsx @@ -1,8 +1,9 @@ import { chakra } from '@chakra-ui/react'; import React from 'react'; -import { route } from 'nextjs-routes'; +import { route } from 'nextjs/routes'; +import { useMultichainContext } from 'lib/contexts/multichain'; import * as EntityBase from 'ui/shared/entities/base/components'; import { distributeEntityProps } from '../base/utils'; @@ -10,7 +11,10 @@ import { distributeEntityProps } from '../base/utils'; type LinkProps = EntityBase.LinkBaseProps & Pick; const Link = chakra((props: LinkProps) => { - const defaultHref = route({ pathname: '/tx/[hash]', query: { hash: props.hash } }); + const defaultHref = route( + { pathname: '/tx/[hash]', query: { hash: props.hash } }, + props.chain ? { chain: props.chain } : undefined, + ); return ( { ); }; @@ -63,13 +71,16 @@ export interface EntityProps extends EntityBase.EntityBaseProps { } const TxEntity = (props: EntityProps) => { + const multichainContext = useMultichainContext(); const partsProps = distributeEntityProps(props); + + const chain = props.chain ?? multichainContext?.chain; const content = ; return ( - { props.noLink ? content : { content } } + { props.noLink ? content : { content } } ); diff --git a/ui/shared/entities/tx/TxEntityInterop.tsx b/ui/shared/entities/tx/TxEntityInterop.tsx index 0f551af112..ee2b046ae8 100644 --- a/ui/shared/entities/tx/TxEntityInterop.tsx +++ b/ui/shared/entities/tx/TxEntityInterop.tsx @@ -17,7 +17,7 @@ import * as TxEntity from './TxEntity'; type Props = { chain: ChainInfo | null; hash?: string | null; -} & Omit; +} & Omit; const IconStub = ({ isLoading }: { isLoading?: boolean }) => { return ( diff --git a/ui/shared/gas/GasPrice.tsx b/ui/shared/gas/GasPrice.tsx index a42553eb98..8a9b1cc41f 100644 --- a/ui/shared/gas/GasPrice.tsx +++ b/ui/shared/gas/GasPrice.tsx @@ -5,11 +5,10 @@ import type { GasPriceInfo } from 'types/api/stats'; import type { GasUnit } from 'types/client/gasTracker'; import config from 'configs/app'; +import { useMultichainContext } from 'lib/contexts/multichain'; import formatGasValue from './formatGasValue'; -const feature = config.features.gasTracker; - const UNITS_TO_API_FIELD_MAP: Record = { gwei: 'price', usd: 'fiat_price', @@ -23,6 +22,9 @@ interface Props { } const GasPrice = ({ data, prefix, className, unitMode = 'primary' }: Props) => { + const multichainContext = useMultichainContext(); + const feature = multichainContext?.chain?.config.features.gasTracker || config.features.gasTracker; + if (!data || !feature.isEnabled) { return null; } diff --git a/ui/shared/logs/__screenshots__/LogItem.pw.tsx_dark-color-mode_with-decoded-input-data-mobile-dark-mode-1.png b/ui/shared/logs/__screenshots__/LogItem.pw.tsx_dark-color-mode_with-decoded-input-data-mobile-dark-mode-1.png index 95aa0ef3e3..e1cf9da527 100644 Binary files a/ui/shared/logs/__screenshots__/LogItem.pw.tsx_dark-color-mode_with-decoded-input-data-mobile-dark-mode-1.png and b/ui/shared/logs/__screenshots__/LogItem.pw.tsx_dark-color-mode_with-decoded-input-data-mobile-dark-mode-1.png differ diff --git a/ui/shared/logs/__screenshots__/LogItem.pw.tsx_default_with-decoded-input-data-mobile-dark-mode-1.png b/ui/shared/logs/__screenshots__/LogItem.pw.tsx_default_with-decoded-input-data-mobile-dark-mode-1.png index d81591c0f3..91940b7c60 100644 Binary files a/ui/shared/logs/__screenshots__/LogItem.pw.tsx_default_with-decoded-input-data-mobile-dark-mode-1.png and b/ui/shared/logs/__screenshots__/LogItem.pw.tsx_default_with-decoded-input-data-mobile-dark-mode-1.png differ diff --git a/ui/shared/logs/__screenshots__/LogItem.pw.tsx_default_with-default-data-type-1.png b/ui/shared/logs/__screenshots__/LogItem.pw.tsx_default_with-default-data-type-1.png index 4078694917..9f749b09d7 100644 Binary files a/ui/shared/logs/__screenshots__/LogItem.pw.tsx_default_with-default-data-type-1.png and b/ui/shared/logs/__screenshots__/LogItem.pw.tsx_default_with-default-data-type-1.png differ diff --git a/ui/shared/logs/__screenshots__/LogItem.pw.tsx_default_without-decoded-input-data-mobile-1.png b/ui/shared/logs/__screenshots__/LogItem.pw.tsx_default_without-decoded-input-data-mobile-1.png index 02d33ac9bd..8bba2eddce 100644 Binary files a/ui/shared/logs/__screenshots__/LogItem.pw.tsx_default_without-decoded-input-data-mobile-1.png and b/ui/shared/logs/__screenshots__/LogItem.pw.tsx_default_without-decoded-input-data-mobile-1.png differ diff --git a/ui/shared/logs/__screenshots__/LogItem.pw.tsx_mobile_with-decoded-input-data-mobile-dark-mode-1.png b/ui/shared/logs/__screenshots__/LogItem.pw.tsx_mobile_with-decoded-input-data-mobile-dark-mode-1.png index bad5d3e830..24bd1d7541 100644 Binary files a/ui/shared/logs/__screenshots__/LogItem.pw.tsx_mobile_with-decoded-input-data-mobile-dark-mode-1.png and b/ui/shared/logs/__screenshots__/LogItem.pw.tsx_mobile_with-decoded-input-data-mobile-dark-mode-1.png differ diff --git a/ui/shared/logs/__screenshots__/LogItem.pw.tsx_mobile_without-decoded-input-data-mobile-1.png b/ui/shared/logs/__screenshots__/LogItem.pw.tsx_mobile_without-decoded-input-data-mobile-1.png index 273fcd8ff0..2517e3c361 100644 Binary files a/ui/shared/logs/__screenshots__/LogItem.pw.tsx_mobile_without-decoded-input-data-mobile-1.png and b/ui/shared/logs/__screenshots__/LogItem.pw.tsx_mobile_without-decoded-input-data-mobile-1.png differ diff --git a/ui/shared/logs/__screenshots__/LogTopic.pw.tsx_mobile_address-view-mobile---default-1.png b/ui/shared/logs/__screenshots__/LogTopic.pw.tsx_mobile_address-view-mobile---default-1.png index 613eecca76..5d0d5b5b77 100644 Binary files a/ui/shared/logs/__screenshots__/LogTopic.pw.tsx_mobile_address-view-mobile---default-1.png and b/ui/shared/logs/__screenshots__/LogTopic.pw.tsx_mobile_address-view-mobile---default-1.png differ diff --git a/ui/shared/logs/__screenshots__/LogTopic.pw.tsx_mobile_hex-view-mobile---default-1.png b/ui/shared/logs/__screenshots__/LogTopic.pw.tsx_mobile_hex-view-mobile---default-1.png index 8b1e7684cb..6cac6a733f 100644 Binary files a/ui/shared/logs/__screenshots__/LogTopic.pw.tsx_mobile_hex-view-mobile---default-1.png and b/ui/shared/logs/__screenshots__/LogTopic.pw.tsx_mobile_hex-view-mobile---default-1.png differ diff --git a/ui/shared/multichain/ChainSelect.tsx b/ui/shared/multichain/ChainSelect.tsx new file mode 100644 index 0000000000..b70133ae72 --- /dev/null +++ b/ui/shared/multichain/ChainSelect.tsx @@ -0,0 +1,36 @@ +import { createListCollection } from '@chakra-ui/react'; +import React from 'react'; + +import multichainConfig from 'configs/multichain'; +import useIsInitialLoading from 'lib/hooks/useIsInitialLoading'; +import { Image } from 'toolkit/chakra/image'; +import { Select } from 'toolkit/chakra/select'; +import type { SelectOption, SelectProps } from 'toolkit/chakra/select'; + +const collection = createListCollection({ + items: multichainConfig()?.chains.map((chain) => ({ + value: chain.slug, + label: chain.config.chain.name || chain.slug, + icon: {, + })) || [], +}); + +interface Props extends Omit { + loading?: boolean; +} + +const ChainSelect = ({ loading, ...props }: Props) => { + const isInitialLoading = useIsInitialLoading(loading); + + return ( +