Skip to content

Commit

Permalink
Merge pull request #2263 from blockscout/fe-2255
Browse files Browse the repository at this point in the history
Zora: implement custom tag
  • Loading branch information
isstuev authored Sep 27, 2024
2 parents 6d054b0 + fff248a commit 10e5e93
Show file tree
Hide file tree
Showing 12 changed files with 244 additions and 2 deletions.
45 changes: 45 additions & 0 deletions configs/app/features/addressProfileAPI.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
import type { Feature } from './types';
import type { AddressProfileAPIConfig } from 'types/client/addressProfileAPIConfig';

import { getEnvValue, parseEnvJson } from '../utils';

const value = parseEnvJson<AddressProfileAPIConfig>(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;
1 change: 1 addition & 0 deletions configs/app/features/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
60 changes: 60 additions & 0 deletions configs/envs/.env.zora
Original file line number Diff line number Diff line change
@@ -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)'}
15 changes: 15 additions & 0 deletions deploy/tools/envs-validator/schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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<AddressProfileAPIConfig>().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(),
Expand Down
23 changes: 23 additions & 0 deletions docs/ENVS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -653,6 +654,28 @@ For the smart contract addresses which are [Safe{Core} accounts](https://safe.gl

&nbsp;

### 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+ |

&nbsp;

#### 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` |

&nbsp;

### 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.
Expand Down
44 changes: 44 additions & 0 deletions lib/hooks/useAddressProfileApiQuery.tsx
Original file line number Diff line number Diff line change
@@ -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<typeof AddressInfoSchema>;

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<unknown, ResourceError<unknown>, 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;
},
});
}
1 change: 1 addition & 0 deletions nextjs/csp/generateCspPolicy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ function generateCspPolicy() {
descriptors.monaco(),
descriptors.safe(),
descriptors.sentry(),
descriptors.usernameApi(),
descriptors.walletConnect(),
);

Expand Down
1 change: 1 addition & 0 deletions nextjs/csp/policies/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
26 changes: 26 additions & 0 deletions nextjs/csp/policies/usernameApi.ts
Original file line number Diff line number Diff line change
@@ -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,
],
};
}
1 change: 1 addition & 0 deletions tools/preset-sync/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
};
Expand Down
7 changes: 7 additions & 0 deletions types/client/addressProfileAPIConfig.ts
Original file line number Diff line number Diff line change
@@ -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;
};
22 changes: 20 additions & 2 deletions ui/pages/Address.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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();
Expand Down Expand Up @@ -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 },
Expand Down Expand Up @@ -248,6 +251,8 @@ const AddressPageContent = () => {
mudTablesCountQuery.data,
]);

const usernameApiTag = userPropfileApiQuery.data?.user_profile?.username;

const tags: Array<EntityTag> = React.useMemo(() => {
return [
...(addressQuery.data?.public_tags?.map((tag) => ({ slug: tag.label, name: tag.display_name, tagType: 'custom' as const, ordinal: -1 })) || []),
Expand All @@ -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,
Expand All @@ -267,15 +284,16 @@ 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 = (
<EntityTags
tags={ tags }
isLoading={
isLoading ||
(config.features.userOps.isEnabled && userOpsAccountQuery.isPlaceholderData) ||
(config.features.addressMetadata.isEnabled && addressMetadataQuery.isPending)
(config.features.addressMetadata.isEnabled && addressMetadataQuery.isPending) ||
(addressProfileAPIFeature.isEnabled && userPropfileApiQuery.isPending)
}
/>
);
Expand Down

0 comments on commit 10e5e93

Please sign in to comment.