diff --git a/configs/app/features/addressProfileAPI.ts b/configs/app/features/addressProfileAPI.ts new file mode 100644 index 0000000000..e46301ee6f --- /dev/null +++ b/configs/app/features/addressProfileAPI.ts @@ -0,0 +1,45 @@ +import type { Feature } from './types'; +import type { AddressProfileAPIConfig } from 'types/client/addressProfileAPIConfig'; + +import { getEnvValue, parseEnvJson } from '../utils'; + +const value = parseEnvJson(getEnvValue('NEXT_PUBLIC_ADDRESS_USERNAME_TAG')); + +function checkApiUrlTemplate(apiUrlTemplate: string): boolean { + try { + const testUrl = apiUrlTemplate.replace('{address}', '0x0000000000000000000000000000000000000000'); + new URL(testUrl).toString(); + return true; + } catch (error) { + return false; + } +} + +const title = 'User profile API'; + +const config: Feature<{ + apiUrlTemplate: string; + tagLinkTemplate?: string; + tagIcon?: string; + tagBgColor?: string; + tagTextColor?: string; +}> = (() => { + if (value && checkApiUrlTemplate(value.api_url_template)) { + return Object.freeze({ + title, + isEnabled: true, + apiUrlTemplate: value.api_url_template, + tagLinkTemplate: value.tag_link_template, + tagIcon: value.tag_icon, + tagBgColor: value.tag_bg_color, + tagTextColor: value.tag_text_color, + }); + } + + return Object.freeze({ + title, + isEnabled: false, + }); +})(); + +export default config; diff --git a/configs/app/features/index.ts b/configs/app/features/index.ts index 08dbd2fe57..6a7954da1d 100644 --- a/configs/app/features/index.ts +++ b/configs/app/features/index.ts @@ -32,6 +32,7 @@ export { default as stats } from './stats'; export { default as suave } from './suave'; export { default as txInterpretation } from './txInterpretation'; export { default as userOps } from './userOps'; +export { default as addressProfileAPI } from './addressProfileAPI'; export { default as validators } from './validators'; export { default as verifiedTokens } from './verifiedTokens'; export { default as web3Wallet } from './web3Wallet'; diff --git a/configs/envs/.env.zora b/configs/envs/.env.zora new file mode 100644 index 0000000000..1ad405ea6b --- /dev/null +++ b/configs/envs/.env.zora @@ -0,0 +1,60 @@ +# Set of ENVs for Zora Mainnet network explorer +# https://explorer.zora.energy +# This is an auto-generated file. To update all values, run "yarn dev:preset:sync --name=zora" + +# Local ENVs +NEXT_PUBLIC_APP_PROTOCOL=http +NEXT_PUBLIC_APP_HOST=localhost +NEXT_PUBLIC_APP_PORT=3000 +NEXT_PUBLIC_APP_ENV=development +NEXT_PUBLIC_API_WEBSOCKET_PROTOCOL=ws + +# Instance ENVs +NEXT_PUBLIC_AD_BANNER_PROVIDER=none +NEXT_PUBLIC_AD_TEXT_PROVIDER=none +NEXT_PUBLIC_ADMIN_SERVICE_API_HOST=https://admin-rs.services.blockscout.com +NEXT_PUBLIC_API_BASE_PATH=/ +NEXT_PUBLIC_API_HOST=explorer.zora.energy +NEXT_PUBLIC_API_SPEC_URL=https://raw.githubusercontent.com/blockscout/blockscout-api-v2-swagger/main/swagger.yaml +NEXT_PUBLIC_CONTRACT_CODE_IDES=[{'title':'Remix IDE','url':'https://remix.ethereum.org/?address={hash}&blockscout={domain}','icon_url':'https://raw.githubusercontent.com/blockscout/frontend-configs/main/configs/ide-icons/remix.png'}] +NEXT_PUBLIC_CONTRACT_INFO_API_HOST=https://contracts-info.services.blockscout.com +NEXT_PUBLIC_DEFI_DROPDOWN_ITEMS=[{'text':'Swap','icon':'swap','dappId':'uniswap'}] +NEXT_PUBLIC_FEATURED_NETWORKS=https://raw.githubusercontent.com/blockscout/frontend-configs/main/configs/featured-networks/zora.json +NEXT_PUBLIC_GRAPHIQL_TRANSACTION=0x6d54c0226a57f5bc854f8aa589bb15113388f984f318c9e1b2722115e4e35873 +NEXT_PUBLIC_HAS_CONTRACT_AUDIT_REPORTS=true +NEXT_PUBLIC_HAS_USER_OPS=true +NEXT_PUBLIC_HOMEPAGE_CHARTS=['daily_txs'] +NEXT_PUBLIC_HOMEPAGE_PLATE_BACKGROUND=linear-gradient(89deg, rgb(63, 36, 22) 0.56%, rgb(44, 56, 105) 98.31%) +NEXT_PUBLIC_IS_ACCOUNT_SUPPORTED=true +NEXT_PUBLIC_LOGOUT_URL=https://zora-blockscout.us.auth0.com/v2/logout +NEXT_PUBLIC_MARKETPLACE_ENABLED=true +NEXT_PUBLIC_MARKETPLACE_RATING_AIRTABLE_API_KEY=patbqG4V2CI998jAq.9810c58c9de973ba2650621c94559088cbdfa1a914498e385621ed035d33c0d0 +NEXT_PUBLIC_MARKETPLACE_RATING_AIRTABLE_BASE_ID=appGkvtmKI7fXE4Vs +NEXT_PUBLIC_MARKETPLACE_SUBMIT_FORM=https://airtable.com/appiy5yijZpMMSKjT/shr6uMGPKjj1DK7NL +NEXT_PUBLIC_MARKETPLACE_SUGGEST_IDEAS_FORM=https://airtable.com/appiy5yijZpMMSKjT/pag3t82DUCyhGRZZO/form +NEXT_PUBLIC_METADATA_SERVICE_API_HOST=https://metadata.services.blockscout.com +NEXT_PUBLIC_NAME_SERVICE_API_HOST=https://bens.services.blockscout.com +NEXT_PUBLIC_NAVIGATION_HIGHLIGHTED_ROUTES=['/apps'] +NEXT_PUBLIC_NETWORK_CURRENCY_DECIMALS=18 +NEXT_PUBLIC_NETWORK_CURRENCY_NAME=Ether +NEXT_PUBLIC_NETWORK_CURRENCY_SYMBOL=ETH +NEXT_PUBLIC_NETWORK_EXPLORERS=[{'title':'GeckoTerminal','logo':'https://raw.githubusercontent.com/blockscout/frontend-configs/main/configs/explorer-logos/geckoterminal.png','baseUrl':'https://www.geckoterminal.com/','paths':{'token':'/zora-network/pools'}}] +NEXT_PUBLIC_NETWORK_ICON=https://raw.githubusercontent.com/blockscout/frontend-configs/main/configs/network-icons/zora.svg +NEXT_PUBLIC_NETWORK_ICON_DARK=https://raw.githubusercontent.com/blockscout/frontend-configs/main/configs/network-icons/zora-dark.svg +NEXT_PUBLIC_NETWORK_ID=7777777 +NEXT_PUBLIC_NETWORK_LOGO=https://raw.githubusercontent.com/blockscout/frontend-configs/main/configs/network-logos/zora.svg +NEXT_PUBLIC_NETWORK_LOGO_DARK=https://raw.githubusercontent.com/blockscout/frontend-configs/main/configs/network-logos/zora-dark.svg +NEXT_PUBLIC_NETWORK_NAME=Zora Mainnet +NEXT_PUBLIC_NETWORK_RPC_URL=https://rpc.zora.energy +NEXT_PUBLIC_NETWORK_SHORT_NAME=Zora Mainnet +NEXT_PUBLIC_NETWORK_VERIFICATION_TYPE=validation +NEXT_PUBLIC_OG_ENHANCED_DATA_ENABLED=true +NEXT_PUBLIC_OG_IMAGE_URL=https://raw.githubusercontent.com/blockscout/frontend-configs/main/configs/og-images/zora-mainnet.png +NEXT_PUBLIC_ROLLUP_L1_BASE_URL=https://eth.blockscout.com/ +NEXT_PUBLIC_ROLLUP_L2_WITHDRAWAL_URL=https://bridge.zora.energy +NEXT_PUBLIC_ROLLUP_TYPE=optimistic +NEXT_PUBLIC_STATS_API_HOST=https://stats-l2-zora-mainnet.k8s-prod-1.blockscout.com +NEXT_PUBLIC_TRANSACTION_INTERPRETATION_PROVIDER=blockscout +NEXT_PUBLIC_VISUALIZE_API_HOST=https://visualizer.services.blockscout.com +NEXT_PUBLIC_MULTICHAIN_BALANCE_PROVIDER_CONFIG={'name': 'zerion', 'dapp_id': 'zerion', 'url_template': 'https://app.zerion.io/{address}/overview?utm_source=blockscout&utm_medium=address', 'logo': 'https://raw.githubusercontent.com/blockscout/frontend-configs/main/configs/marketplace-logos/zerion.svg'} +NEXT_PUBLIC_ADDRESS_USERNAME_TAG={'api_url_template': 'https://api.zora.co/discover/user/{address}', 'tag_link_template': 'httpszora.co/{username}', 'tag_icon': 'https://raw.githubusercontent.com/blockscout/frontend-configs/main/configs/network-icons/zora.svg', 'tag_bg_color': 'rgba(0,0,0)', 'tag_text_color': 'rgba(255,255,255)'} \ No newline at end of file diff --git a/deploy/tools/envs-validator/schema.ts b/deploy/tools/envs-validator/schema.ts index 3a332fc0a0..4ed440eef5 100644 --- a/deploy/tools/envs-validator/schema.ts +++ b/deploy/tools/envs-validator/schema.ts @@ -10,6 +10,7 @@ declare module 'yup' { import * as yup from 'yup'; import type { AdButlerConfig } from '../../../types/client/adButlerConfig'; +import type { AddressProfileAPIConfig } from '../../../types/client/addressProfileAPIConfig'; import { SUPPORTED_AD_TEXT_PROVIDERS, SUPPORTED_AD_BANNER_PROVIDERS, SUPPORTED_AD_BANNER_ADDITIONAL_PROVIDERS } from '../../../types/client/adProviders'; import type { AdTextProviders, AdBannerProviders, AdBannerAdditionalProviders } from '../../../types/client/adProviders'; import { SMART_CONTRACT_EXTRA_VERIFICATION_METHODS, type ContractCodeIde, type SmartContractVerificationMethodExtra } from '../../../types/client/contract'; @@ -803,6 +804,20 @@ const schema = yup ), }), NEXT_PUBLIC_SAVE_ON_GAS_ENABLED: yup.boolean(), + NEXT_PUBLIC_ADDRESS_USERNAME_TAG: yup + .mixed() + .test('shape', 'Invalid schema were provided for NEXT_PUBLIC_ADDRESS_USERNAME_TAG, it should have api_url_template', (data) => { + const isUndefined = data === undefined; + const valueSchema = yup.object().transform(replaceQuotes).json().shape({ + api_url_template: yup.string().required(), + tag_link_template: yup.string(), + tag_icon: yup.string(), + tag_bg_color: yup.string(), + tag_text_color: yup.string(), + }); + + return isUndefined || valueSchema.isValidSync(data); + }), // 6. External services envs NEXT_PUBLIC_WALLET_CONNECT_PROJECT_ID: yup.string(), diff --git a/docs/ENVS.md b/docs/ENVS.md index 4b8d6c10fc..2cafeee2dd 100644 --- a/docs/ENVS.md +++ b/docs/ENVS.md @@ -54,6 +54,7 @@ Please be aware that all environment variables prefixed with `NEXT_PUBLIC_` will - [Data availability](ENVS.md#data-availability) - [Bridged tokens](ENVS.md#bridged-tokens) - [Safe{Core} address tags](ENVS.md#safecore-address-tags) + - [Address profile API](ENVS.md#address-profile-api) - [SUAVE chain](ENVS.md#suave-chain) - [MetaSuites extension](ENVS.md#metasuites-extension) - [Validators list](ENVS.md#validators-list) @@ -653,6 +654,28 @@ For the smart contract addresses which are [Safe{Core} accounts](https://safe.gl   +### Address profile API + +This feature allows the integration of an external API to fetch user info for addresses or contracts. When configured, if the API returns a username, a public tag with a custom link will be displayed in the address page header. + +| Variable | Type| Description | Compulsoriness | Default value | Example value | Version | +| --- | --- | --- | --- | --- | --- | --- | +| NEXT_PUBLIC_ADDRESS_USERNAME_TAG | `{api_url: string; tag_link_template: string; tag_icon: string; tag_bg_color: string; tag_text_color: string}` | Address profile API tag configuration properties. See [below](#user-profile-api-configuration-properties). | - | - | `uniswap` | v1.35.0+ | + +  + +#### Address profile API configuration properties + +| Variable | Type| Description | Compulsoriness | Default value | Example value | +| --- | --- | --- | --- | --- | --- | +| api_url_template | `string` | User profile API URL. Should be a template with `{address}` variable | Required | - | `https://example-api.com/{address}` | +| tag_link_template | `string` | External link to the profile. Should be a template with `{username}` variable | - | - | `https://example.com/{address}` | +| tag_icon | `string` | Public tag icon (.svg) url | - | - | `https://example.com/icon.svg` | +| tag_bg_color | `string` | Public tag background color (escape "#" symbol if you use HEX color codes or use rgba-value instead) | - | - | `\#000000` | +| tag_text_color | `string` | Public tag text color (escape "#" symbol if you use HEX color codes or use rgba-value instead) | - | - | `\#FFFFFF` | + +  + ### SUAVE chain For blockchains that implement SUAVE architecture additional fields will be shown on the transaction page ("Allowed peekers", "Kettle"). Users also will be able to see the list of all transactions for a particular Kettle in the separate view. diff --git a/lib/hooks/useAddressProfileApiQuery.tsx b/lib/hooks/useAddressProfileApiQuery.tsx new file mode 100644 index 0000000000..26298637fc --- /dev/null +++ b/lib/hooks/useAddressProfileApiQuery.tsx @@ -0,0 +1,44 @@ +import { useQuery } from '@tanstack/react-query'; +import * as v from 'valibot'; + +import config from 'configs/app'; +import type { ResourceError } from 'lib/api/resources'; +import useFetch from 'lib/hooks/useFetch'; + +const feature = config.features.addressProfileAPI; + +type AddressInfoApiQueryResponse = v.InferOutput; + +const AddressInfoSchema = v.object({ + user_profile: v.object({ + username: v.union([ v.string(), v.null() ]), + }), +}); + +const ERROR_NAME = 'Invalid response schema'; + +export default function useAddressProfileApiQuery(hash: string | undefined, isEnabled = true) { + const fetch = useFetch(); + + return useQuery, AddressInfoApiQueryResponse>({ + queryKey: [ 'username_api', hash ], + queryFn: async() => { + if (!feature.isEnabled || !hash) { + return Promise.reject(); + } + + return fetch(feature.apiUrlTemplate.replace('{address}', hash), undefined, { omitSentryErrorLog: true }); + }, + enabled: isEnabled && Boolean(hash), + refetchOnMount: false, + select: (response) => { + const parsedResponse = v.safeParse(AddressInfoSchema, response); + + if (!parsedResponse.success) { + throw Error(ERROR_NAME); + } + + return parsedResponse.output; + }, + }); +} diff --git a/nextjs/csp/generateCspPolicy.ts b/nextjs/csp/generateCspPolicy.ts index 044f267083..149ab93c68 100644 --- a/nextjs/csp/generateCspPolicy.ts +++ b/nextjs/csp/generateCspPolicy.ts @@ -16,6 +16,7 @@ function generateCspPolicy() { descriptors.monaco(), descriptors.safe(), descriptors.sentry(), + descriptors.usernameApi(), descriptors.walletConnect(), ); diff --git a/nextjs/csp/policies/index.ts b/nextjs/csp/policies/index.ts index 7fc5a5a68e..b483f63490 100644 --- a/nextjs/csp/policies/index.ts +++ b/nextjs/csp/policies/index.ts @@ -11,4 +11,5 @@ export { mixpanel } from './mixpanel'; export { monaco } from './monaco'; export { safe } from './safe'; export { sentry } from './sentry'; +export { usernameApi } from './usernameApi'; export { walletConnect } from './walletConnect'; diff --git a/nextjs/csp/policies/usernameApi.ts b/nextjs/csp/policies/usernameApi.ts new file mode 100644 index 0000000000..4b2c2bd912 --- /dev/null +++ b/nextjs/csp/policies/usernameApi.ts @@ -0,0 +1,26 @@ +import type CspDev from 'csp-dev'; + +import config from 'configs/app'; + +const feature = config.features.addressProfileAPI; + +export function usernameApi(): CspDev.DirectiveDescriptor { + if (!feature.isEnabled) { + return {}; + } + + const apiOrigin = (() => { + try { + const url = new URL(feature.apiUrlTemplate); + return url.origin; + } catch (error) { + return ''; + } + })(); + + return { + 'connect-src': [ + apiOrigin, + ], + }; +} diff --git a/tools/preset-sync/index.ts b/tools/preset-sync/index.ts index 5991d1cb7b..701910f61b 100755 --- a/tools/preset-sync/index.ts +++ b/tools/preset-sync/index.ts @@ -19,6 +19,7 @@ const PRESETS = { stability_testnet: 'https://stability-testnet.blockscout.com', zkevm: 'https://zkevm.blockscout.com', zksync: 'https://zksync.blockscout.com', + zora: 'https://explorer.zora.energy', // main === staging main: 'https://eth-sepolia.k8s-dev.blockscout.com', }; diff --git a/types/client/addressProfileAPIConfig.ts b/types/client/addressProfileAPIConfig.ts new file mode 100644 index 0000000000..66078b3fcd --- /dev/null +++ b/types/client/addressProfileAPIConfig.ts @@ -0,0 +1,7 @@ +export type AddressProfileAPIConfig = { + api_url_template: string; + tag_link_template?: string; + tag_icon?: string; + tag_bg_color?: string; + tag_text_color?: string; +}; diff --git a/ui/pages/Address.tsx b/ui/pages/Address.tsx index 4914c79e1e..233a365c1e 100644 --- a/ui/pages/Address.tsx +++ b/ui/pages/Address.tsx @@ -10,6 +10,7 @@ import getCheckedSummedAddress from 'lib/address/getCheckedSummedAddress'; import useAddressMetadataInfoQuery from 'lib/address/useAddressMetadataInfoQuery'; import useApiQuery from 'lib/api/useApiQuery'; import { useAppContext } from 'lib/contexts/app'; +import useAddressProfileApiQuery from 'lib/hooks/useAddressProfileApiQuery'; import useContractTabs from 'lib/hooks/useContractTabs'; import useIsSafeAddress from 'lib/hooks/useIsSafeAddress'; import getNetworkValidationActionText from 'lib/networks/getNetworkValidationActionText'; @@ -54,6 +55,7 @@ import RoutedTabs from 'ui/shared/Tabs/RoutedTabs'; const TOKEN_TABS = [ 'tokens_erc20', 'tokens_nfts', 'tokens_nfts_collection', 'tokens_nfts_list' ]; const txInterpretation = config.features.txInterpretation; +const addressProfileAPIFeature = config.features.addressProfileAPI; const AddressPageContent = () => { const router = useRouter(); @@ -92,6 +94,7 @@ const AddressPageContent = () => { const addressesForMetadataQuery = React.useMemo(() => ([ hash ].filter(Boolean)), [ hash ]); const addressMetadataQuery = useAddressMetadataInfoQuery(addressesForMetadataQuery, areQueriesEnabled); + const userPropfileApiQuery = useAddressProfileApiQuery(hash, addressProfileAPIFeature.isEnabled && areQueriesEnabled); const addressEnsDomainsQuery = useApiQuery('addresses_lookup', { pathParams: { chainId: config.chain.id }, @@ -248,6 +251,8 @@ const AddressPageContent = () => { mudTablesCountQuery.data, ]); + const usernameApiTag = userPropfileApiQuery.data?.user_profile?.username; + const tags: Array = React.useMemo(() => { return [ ...(addressQuery.data?.public_tags?.map((tag) => ({ slug: tag.label, name: tag.display_name, tagType: 'custom' as const, ordinal: -1 })) || []), @@ -258,6 +263,18 @@ const AddressPageContent = () => { addressQuery.data?.implementations?.length ? { slug: 'proxy', name: 'Proxy', tagType: 'custom' as const, ordinal: -1 } : undefined, addressQuery.data?.token ? { slug: 'token', name: 'Token', tagType: 'custom' as const, ordinal: -1 } : undefined, isSafeAddress ? { slug: 'safe', name: 'Multisig: Safe', tagType: 'custom' as const, ordinal: -10 } : undefined, + addressProfileAPIFeature.isEnabled && usernameApiTag ? { + slug: 'username_api', + name: usernameApiTag, + tagType: 'custom' as const, + ordinal: 11, + meta: { + tagIcon: addressProfileAPIFeature.tagIcon, + bgColor: addressProfileAPIFeature.tagBgColor, + textColor: addressProfileAPIFeature.tagTextColor, + tagUrl: addressProfileAPIFeature.tagLinkTemplate ? addressProfileAPIFeature.tagLinkTemplate.replace('{username}', usernameApiTag) : undefined, + }, + } : undefined, config.features.userOps.isEnabled && userOpsAccountQuery.data ? { slug: 'user_ops_acc', name: 'Smart contract wallet', tagType: 'custom' as const, ordinal: -10 } : undefined, @@ -267,7 +284,7 @@ const AddressPageContent = () => { ...formatUserTags(addressQuery.data), ...(addressMetadataQuery.data?.addresses?.[hash.toLowerCase()]?.tags || []), ].filter(Boolean).sort(sortEntityTags); - }, [ addressMetadataQuery.data, addressQuery.data, hash, isSafeAddress, userOpsAccountQuery.data, mudTablesCountQuery.data ]); + }, [ addressMetadataQuery.data, addressQuery.data, hash, isSafeAddress, userOpsAccountQuery.data, mudTablesCountQuery.data, usernameApiTag ]); const titleContentAfter = ( { isLoading={ isLoading || (config.features.userOps.isEnabled && userOpsAccountQuery.isPlaceholderData) || - (config.features.addressMetadata.isEnabled && addressMetadataQuery.isPending) + (config.features.addressMetadata.isEnabled && addressMetadataQuery.isPending) || + (addressProfileAPIFeature.isEnabled && userPropfileApiQuery.isPending) } /> );