From 5866d5f546109b9cee317ba36cb8cd204e80d361 Mon Sep 17 00:00:00 2001 From: isstuev Date: Tue, 24 Sep 2024 14:05:14 +0200 Subject: [PATCH] Zora: implement custom tag --- configs/app/features/index.ts | 1 + configs/app/features/userProfileAPI.ts | 45 +++++++++++++++++++ configs/envs/.env.zora | 60 ++++++++++++++++++++++++++ deploy/tools/envs-validator/schema.ts | 15 +++++++ docs/ENVS.md | 23 ++++++++++ lib/hooks/useUserProfileApiQuery.tsx | 48 +++++++++++++++++++++ nextjs/csp/generateCspPolicy.ts | 1 + nextjs/csp/policies/index.ts | 1 + nextjs/csp/policies/usernameApi.ts | 26 +++++++++++ tools/preset-sync/index.ts | 1 + types/client/userProfileAPIConfig.ts | 7 +++ ui/pages/Address.tsx | 22 +++++++++- 12 files changed, 248 insertions(+), 2 deletions(-) create mode 100644 configs/app/features/userProfileAPI.ts create mode 100644 configs/envs/.env.zora create mode 100644 lib/hooks/useUserProfileApiQuery.tsx create mode 100644 nextjs/csp/policies/usernameApi.ts create mode 100644 types/client/userProfileAPIConfig.ts diff --git a/configs/app/features/index.ts b/configs/app/features/index.ts index 08dbd2fe57..4d10ac49fe 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 userProfileAPI } from './userProfileAPI'; export { default as validators } from './validators'; export { default as verifiedTokens } from './verifiedTokens'; export { default as web3Wallet } from './web3Wallet'; diff --git a/configs/app/features/userProfileAPI.ts b/configs/app/features/userProfileAPI.ts new file mode 100644 index 0000000000..04097c4021 --- /dev/null +++ b/configs/app/features/userProfileAPI.ts @@ -0,0 +1,45 @@ +import type { Feature } from './types'; +import type { UserProfileAPIConfig } from 'types/client/userProfileAPIConfig'; + +import { getEnvValue, parseEnvJson } from '../utils'; + +const value = parseEnvJson(getEnvValue('NEXT_PUBLIC_USER_PROFILE_API')); + +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/envs/.env.zora b/configs/envs/.env.zora new file mode 100644 index 0000000000..f92fbb8876 --- /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_USER_PROFILE_API={'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..4b59fb3417 100644 --- a/deploy/tools/envs-validator/schema.ts +++ b/deploy/tools/envs-validator/schema.ts @@ -24,6 +24,7 @@ import type { NavItemExternal, NavigationLinkId, NavigationLayout } from '../../ 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'; +import type { UserProfileAPIConfig } from '../../../types/client/userProfileAPIConfig'; import { VALIDATORS_CHAIN_TYPE } from '../../../types/client/validators'; import type { ValidatorsChainType } from '../../../types/client/validators'; import type { WalletType } from '../../../types/client/wallets'; @@ -803,6 +804,20 @@ const schema = yup ), }), NEXT_PUBLIC_SAVE_ON_GAS_ENABLED: yup.boolean(), + NEXT_PUBLIC_USER_PROFILE_API: yup + .mixed() + .test('shape', 'Invalid schema were provided for NEXT_PUBLIC_USER_PROFILE_API, 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..dc59dddd5a 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) + - [User profile API](ENVS.md#user-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   +### User profile API + +This feature allows the integration of an external API to fetch usernames 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_USER_PROFILE_API | `{api_url: string; tag_link_template: string; tag_icon: string; tag_bg_color: string; tag_text_color: string}` | User profile API tag configuration properties. See [below](#user-profile-api-configuration-properties). | - | - | `uniswap` | v1.35.0+ | + +  + +#### User 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/useUserProfileApiQuery.tsx b/lib/hooks/useUserProfileApiQuery.tsx new file mode 100644 index 0000000000..853624a64e --- /dev/null +++ b/lib/hooks/useUserProfileApiQuery.tsx @@ -0,0 +1,48 @@ +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.userProfileAPI; + +type AddressInfoApiQueryResponse = { + user_profile: { + username: string | null; + }; +}; + +const AddressInfoSchema = v.object({ + user_profile: v.object({ + username: v.union([ v.string(), v.null() ]), + }), +}); + +const ERROR_NAME = 'Invalid response schema'; + +export default function useUserProfileApiQuery(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..91a0e99ea4 --- /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.userProfileAPI; + +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/userProfileAPIConfig.ts b/types/client/userProfileAPIConfig.ts new file mode 100644 index 0000000000..115c607724 --- /dev/null +++ b/types/client/userProfileAPIConfig.ts @@ -0,0 +1,7 @@ +export type UserProfileAPIConfig = { + 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..2a3c0fc73b 100644 --- a/ui/pages/Address.tsx +++ b/ui/pages/Address.tsx @@ -12,6 +12,7 @@ import useApiQuery from 'lib/api/useApiQuery'; import { useAppContext } from 'lib/contexts/app'; import useContractTabs from 'lib/hooks/useContractTabs'; import useIsSafeAddress from 'lib/hooks/useIsSafeAddress'; +import useUserProfileApiQuery from 'lib/hooks/useUserProfileApiQuery'; import getNetworkValidationActionText from 'lib/networks/getNetworkValidationActionText'; import getQueryParamString from 'lib/router/getQueryParamString'; import useSocketChannel from 'lib/socket/useSocketChannel'; @@ -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 userProfileAPIFeature = config.features.userProfileAPI; 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 = useUserProfileApiQuery(hash, userProfileAPIFeature.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, + userProfileAPIFeature.isEnabled && usernameApiTag ? { + slug: 'username_api', + name: usernameApiTag, + tagType: 'custom' as const, + ordinal: 11, + meta: { + tagIcon: userProfileAPIFeature.tagIcon, + bgColor: userProfileAPIFeature.tagBgColor, + textColor: userProfileAPIFeature.tagTextColor, + tagUrl: userProfileAPIFeature.tagLinkTemplate ? userProfileAPIFeature.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) || + (userProfileAPIFeature.isEnabled && userPropfileApiQuery.isPending) } /> );