Commit 6e98beeb authored by tom goriunov's avatar tom goriunov Committed by GitHub

Merge pull request #739 from blockscout/account/verified-addresses

address verification in my account
parents 4908184b 033aadcd
......@@ -50,6 +50,7 @@ NEXT_PUBLIC_API_PROTOCOL=__PLACEHOLDER_FOR_NEXT_PUBLIC_API_PROTOCOL__
NEXT_PUBLIC_API_PORT=__PLACEHOLDER_FOR_NEXT_PUBLIC_API_PORT__
NEXT_PUBLIC_STATS_API_HOST=__PLACEHOLDER_FOR_NEXT_PUBLIC_STATS_API_HOST__
NEXT_PUBLIC_VISUALIZE_API_HOST=__PLACEHOLDER_FOR_NEXT_PUBLIC_VISUALIZE_API_HOST__
NEXT_PUBLIC_CONTRACT_INFO_API_HOST=__PLACEHOLDER_FOR_NEXT_PUBLIC_CONTRACT_INFO_API_HOST__
NEXT_PUBLIC_API_SPEC_URL=__PLACEHOLDER_FOR_NEXT_PUBLIC_API_SPEC_URL__
# external services config
......
{
"typescript.tsdk": "node_modules/typescript/lib"
}
\ No newline at end of file
......@@ -99,9 +99,9 @@ The app instance could be customized by passing following variables to NodeJS en
| --- | --- | --- | --- |
| NEXT_PUBLIC_API_HOST | `string` *(optional)* | By default the API endpoint base URL will be set as `https://blockscout.com`. If it is not the case, pass the API host in this variable | `my-host.com` |
| NEXT_PUBLIC_API_BASE_PATH | `string` *(optional)* | Base path for API endpoint url | `/poa/core` |
| NEXT_PUBLIC_STATS_API_HOST | `string` *(optional)* | Pass the Stats API host in this variable | `https://my-host.com` |
| NEXT_PUBLIC_VISUALIZE_API_HOST | `string` *(optional)* | Pass the Visualize API host in this variable | `https://my-host.com` |
| NEXT_PUBLIC_STATS_API_HOST | `string` | Pass the Stats API host in this variable | `https://my-host.com` |
| NEXT_PUBLIC_VISUALIZE_API_HOST | `string` | Pass the Visualize API host in this variable | `https://my-host.com` |
| NEXT_PUBLIC_CONTRACT_INFO_API_HOST | `string` *(optional)* | Pass the Contract Info API host in this variable if token info submission feature should be available in the app | `https://my-host.com` |
### Featured network configuration properties
......
......@@ -26,7 +26,8 @@ const baseUrl = [
appPort && ':' + appPort,
].filter(Boolean).join('');
const authUrl = getEnvValue(process.env.NEXT_PUBLIC_AUTH_URL) || baseUrl;
const apiHost = getEnvValue(process.env.NEXT_PUBLIC_API_HOST);
// const apiHost = getEnvValue(process.env.NEXT_PUBLIC_API_HOST);
const apiHost = 'eth-goerli.blockscout.com';
const apiSchema = getEnvValue(process.env.NEXT_PUBLIC_API_PROTOCOL) || 'https';
const apiPort = getEnvValue(process.env.NEXT_PUBLIC_API_PORT);
const apiEndpoint = apiHost ? [
......@@ -118,6 +119,10 @@ const config = Object.freeze({
endpoint: getEnvValue(process.env.NEXT_PUBLIC_VISUALIZE_API_HOST),
basePath: '',
},
contractInfoApi: {
endpoint: getEnvValue(process.env.NEXT_PUBLIC_CONTRACT_INFO_API_HOST),
basePath: '',
},
homepage: {
charts: parseEnvJson<Array<ChainIndicatorId>>(getEnvValue(process.env.NEXT_PUBLIC_HOMEPAGE_CHARTS)) || [],
plateGradient: getEnvValue(process.env.NEXT_PUBLIC_HOMEPAGE_PLATE_GRADIENT) ||
......
......@@ -16,3 +16,4 @@ NEXT_PUBLIC_API_SPEC_URL=https://raw.githubusercontent.com/blockscout/blockscout
NEXT_PUBLIC_API_HOST=blockscout.com
NEXT_PUBLIC_STATS_API_HOST=https://stats-test.aws-k8s.blockscout.com
NEXT_PUBLIC_VISUALIZE_API_HOST=https://visualizer.aws-k8s.blockscout.com
NEXT_PUBLIC_CONTRACT_INFO_API_HOST=https://contracts-info-test.aws-k8s.blockscout.com
......@@ -19,3 +19,4 @@ NEXT_PUBLIC_IS_TESTNET=true
# api config
NEXT_PUBLIC_API_HOST=blockscout.com
NEXT_PUBLIC_STATS_API_HOST=https://stats-test.aws-k8s.blockscout.com
NEXT_PUBLIC_CONTRACT_INFO_API_HOST=https://contracts-info-test.aws-k8s.blockscout.com
......@@ -397,6 +397,8 @@ frontend:
_default: "true"
NEXT_PUBLIC_VISUALIZE_API_HOST:
_default: https://visualizer-optimism-goerli.test.aws-k8s.blockscout.com
NEXT_PUBLIC_CONTRACT_INFO_API_HOST:
_default: https://contracts-info-test.aws-k8s.blockscout.com
NEXT_PUBLIC_IS_L2_NETWORK:
_default: "true"
NEXT_PUBLIC_L1_BASE_URL:
......
......@@ -352,9 +352,11 @@ frontend:
NEXT_PUBLIC_API_HOST:
_default: blockscout.com/eth/goerli
NEXT_PUBLIC_STATS_API_HOST:
_default: https://stats-test.aws-k8s.blockscout.com/
_default: https://stats-test.aws-k8s.blockscout.com
NEXT_PUBLIC_VISUALIZE_API_HOST:
_default: https://visualizer.aws-k8s.blockscout.com
NEXT_PUBLIC_CONTRACT_INFO_API_HOST:
_default: https://contracts-info-test.aws-k8s.blockscout.com
NEXT_PUBLIC_APP_HOST:
_default: blockscout.com/eth/goerli
NEXT_PUBLIC_LOGOUT_URL:
......
......@@ -123,6 +123,8 @@ frontend:
_default: "true"
NEXT_PUBLIC_VISUALIZE_API_HOST:
_default: https://visualizer-optimism-goerli.test.aws-k8s.blockscout.com
NEXT_PUBLIC_CONTRACT_INFO_API_HOST:
_default: https://contracts-info-test.aws-k8s.blockscout.com
NEXT_PUBLIC_IS_L2_NETWORK:
_default: "true"
NEXT_PUBLIC_L1_BASE_URL:
......
......@@ -95,6 +95,8 @@ frontend:
_default: https://stats-test.aws-k8s.blockscout.com/
NEXT_PUBLIC_VISUALIZE_API_HOST:
_default: https://visualizer.aws-k8s.blockscout.com
NEXT_PUBLIC_CONTRACT_INFO_API_HOST:
_default: https://contracts-info-test.aws-k8s.blockscout.com
NEXT_PUBLIC_AUTH_URL:
_default: https://blockscout-main.test.aws-k8s.blockscout.com
NEXT_PUBLIC_API_BASE_PATH:
......
import appConfig from 'configs/app/config';
// import appConfig from 'configs/app/config';
// FIXME
// I was not able to figure out how to send CORS with credentials from localhost
// unsuccessfully tried different ways, even custom local dev domain
// so for local development we have to use next.js api as proxy server
export default function isNeedProxy() {
return appConfig.host === 'localhost' && appConfig.host !== appConfig.api.host;
return true;
// return appConfig.host === 'localhost' && appConfig.host !== appConfig.api.host;
}
import type { UserInfo, CustomAbis, PublicTags, AddressTags, TransactionTags, ApiKeys, WatchlistAddress } from 'types/api/account';
import type { UserInfo, CustomAbis, PublicTags, AddressTags, TransactionTags, ApiKeys, WatchlistAddress, VerifiedAddressResponse } from 'types/api/account';
import type {
Address,
AddressCounters,
......@@ -86,6 +86,21 @@ export const RESOURCES = {
pathParams: [ 'id' as const ],
},
// ACCOUNT: ADDRESS VERIFICATION & TOKEN INFO
address_verification: {
path: '/api/v1/chains/:chainId/verified-addresses:type',
pathParams: [ 'chainId' as const, 'type' as const ],
endpoint: appConfig.contractInfoApi.endpoint,
basePath: appConfig.contractInfoApi.basePath,
},
verified_addresses: {
path: '/api/v1/chains/:chainId/verified-addresses',
pathParams: [ 'chainId' as const ],
endpoint: appConfig.contractInfoApi.endpoint,
basePath: appConfig.contractInfoApi.basePath,
},
// STATS
stats_counters: {
path: '/api/v1/counters',
......@@ -484,6 +499,7 @@ Q extends 'private_tags_address' ? AddressTags :
Q extends 'private_tags_tx' ? TransactionTags :
Q extends 'api_keys' ? ApiKeys :
Q extends 'watchlist' ? Array<WatchlistAddress> :
Q extends 'verified_addresses' ? VerifiedAddressResponse :
Q extends 'homepage_stats' ? HomeStats :
Q extends 'homepage_chart_txs' ? ChartTransactionResponse :
Q extends 'homepage_chart_market' ? ChartMarketResponse :
......
......@@ -223,7 +223,14 @@ export default function useNavItems(): ReturnType {
isActive: pathname === '/account/custom_abi',
isNewUi: true,
},
];
appConfig.contractInfoApi.endpoint && {
text: 'Verified addrs',
nextRoute: { pathname: '/account/verified_addresses' as const },
icon: verifiedIcon,
isActive: pathname === '/account/verified_addresses',
isNewUi: true,
},
].filter(Boolean);
const profileItem = {
text: 'My profile',
......
export default function shortenString(string: string | null) {
if (!string) {
return '';
}
if (string.length <= 7) {
return string;
}
return string.slice(0, 4) + '...' + string.slice(-4);
}
export const SIGNATURE_REGEXP = /^0x[a-fA-F\d]{130}$/;
import type { NextPage } from 'next';
import Head from 'next/head';
import React from 'react';
import getNetworkTitle from 'lib/networks/getNetworkTitle';
import VerifiedAddresses from 'ui/pages/VerifiedAddresses';
const VerifiedAddressesPage: NextPage = () => {
const title = getNetworkTitle();
return (
<>
<Head><title>{ title }</title></Head>
<VerifiedAddresses/>
</>
);
};
export default VerifiedAddressesPage;
export { getServerSideProps } from 'lib/next/getServerSideProps';
import type { test } from '@playwright/experimental-ct-react';
import type { PlaywrightWorkerArgs } from '@playwright/test';
interface Env {
name: string;
......@@ -6,18 +7,22 @@ interface Env {
}
// keep in mind that all passed variables here should be present in env config files (.env.pw or .env.poa)
export default function createContextWithEnvs(envs: Array<Env>): Parameters<typeof test.extend>[0]['context'] {
export default function contextWithEnvsFixture(envs: Array<Env>): Parameters<typeof test.extend>[0]['context'] {
return async({ browser }, use) => {
const context = await browser.newContext({
storageState: {
origins: [
{ origin: 'http://localhost:3100', localStorage: envs },
],
cookies: [],
},
});
const context = await createContextWithEnvs(browser, envs);
await use(context);
await context.close();
};
}
export function createContextWithEnvs(browser: PlaywrightWorkerArgs['browser'], envs: Array<Env>) {
return browser.newContext({
storageState: {
origins: [
{ origin: 'http://localhost:3100', localStorage: envs },
],
cookies: [],
},
});
}
......@@ -3,7 +3,6 @@ const zIndices = {
auto: 'auto',
base: 0,
docked: 10,
tooltip: 900,
dropdown: 1000,
sticky: 1100,
sticky1: 1101,
......@@ -12,6 +11,7 @@ const zIndices = {
overlay: 1300,
modal: 1400,
popover: 1500,
tooltip: 1550, // otherwise tooltips will not be visible in modals
skipLink: 1600,
toast: 1700,
};
......
......@@ -160,3 +160,14 @@ export type PublicTagErrors = {
full_name: Array<string>;
tags: Array<string>;
}
export interface VerifiedAddress {
userId: string;
chainId: string;
contractAddress: string;
verifiedDate: string;
}
export interface VerifiedAddressResponse {
verifiedAddresses: Array<VerifiedAddress>;
}
......@@ -10,6 +10,7 @@ declare module "nextjs-routes" {
| StaticRoute<"/account/custom_abi">
| StaticRoute<"/account/public_tags_request">
| StaticRoute<"/account/tag_address">
| StaticRoute<"/account/verified_addresses">
| StaticRoute<"/account/watchlist">
| StaticRoute<"/accounts">
| DynamicRoute<"/address/[hash]/contract_verification", { "hash": string }>
......
import { useColorModeValue, useToken } from '@chakra-ui/react';
import {
EthereumClient,
modalConnectors,
walletConnectProvider,
} from '@web3modal/ethereum';
import { Web3Modal } from '@web3modal/react';
import React from 'react';
import type { Chain } from 'wagmi';
import { configureChains, createClient, WagmiConfig } from 'wagmi';
import type { RoutedSubTab } from 'ui/shared/RoutedTabs/types';
import appConfig from 'configs/app/config';
import { ContractContextProvider } from 'ui/address/contract/context';
import RoutedTabs from 'ui/shared/RoutedTabs/RoutedTabs';
import Web3ModalProvider from 'ui/shared/Web3ModalProvider';
interface Props {
tabs: Array<RoutedSubTab>;
addressHash?: string;
}
const { wagmiClient, ethereumClient } = (() => {
try {
const currentChain: Chain = {
id: Number(appConfig.network.id),
name: appConfig.network.name || '',
network: appConfig.network.name || '',
nativeCurrency: {
decimals: appConfig.network.currency.decimals,
name: appConfig.network.currency.name || '',
symbol: appConfig.network.currency.symbol || '',
},
rpcUrls: {
'default': {
http: [ appConfig.network.rpcUrl || '' ],
},
},
blockExplorers: {
'default': {
name: 'Blockscout',
url: appConfig.baseUrl,
},
},
};
const chains = [ currentChain ];
const { provider } = configureChains(chains, [
walletConnectProvider({ projectId: appConfig.walletConnect.projectId || '' }),
]);
const wagmiClient = createClient({
autoConnect: true,
connectors: modalConnectors({ appName: 'web3Modal', chains }),
provider,
});
const ethereumClient = new EthereumClient(wagmiClient, chains);
return { wagmiClient, ethereumClient };
} catch (error) {
return { wagmiClient: undefined, ethereumClient: undefined };
}
})();
const TAB_LIST_PROPS = {
columnGap: 3,
};
const AddressContract = ({ addressHash, tabs }: Props) => {
const modalZIndex = useToken<string>('zIndices', 'modal');
const web3ModalTheme = useColorModeValue('light', 'dark');
const noProviderTabs = React.useMemo(() => tabs.filter(({ id }) => id === 'contact_code'), [ tabs ]);
if (!wagmiClient || !ethereumClient) {
const fallback = React.useCallback(() => {
const noProviderTabs = tabs.filter(({ id }) => id === 'contact_code');
return <RoutedTabs tabs={ noProviderTabs } variant="outline" colorScheme="gray" size="sm" tabListProps={ TAB_LIST_PROPS }/>;
}
}, [ tabs ]);
return (
<WagmiConfig client={ wagmiClient }>
<Web3ModalProvider fallback={ fallback }>
<ContractContextProvider addressHash={ addressHash }>
<RoutedTabs tabs={ tabs } variant="outline" colorScheme="gray" size="sm" tabListProps={ TAB_LIST_PROPS }/>
</ContractContextProvider>
<Web3Modal
projectId={ appConfig.walletConnect.projectId }
ethereumClient={ ethereumClient }
themeZIndex={ Number(modalZIndex) }
themeMode={ web3ModalTheme }
themeBackground="themeColor"
/>
</WagmiConfig>
</Web3ModalProvider>
);
};
......
......@@ -221,7 +221,7 @@ const ContractCode = ({ addressHash, noSocket }: Props) => {
<RawDataSnippet
data={ data.creation_bytecode }
title="Contract creation code"
rightSlot={ data.is_verified ? null : verificationButton }
rightSlot={ data.is_verified || data.is_self_destructed ? null : verificationButton }
beforeSlot={ data.is_self_destructed ? (
<Alert status="info" whiteSpace="pre-wrap" mb={ 3 }>
Contracts that self destruct in their constructors have no contract code published and cannot be verified.
......
import { Icon, Modal, ModalBody, ModalCloseButton, ModalContent, ModalHeader, ModalOverlay, Link } from '@chakra-ui/react';
import { useQueryClient } from '@tanstack/react-query';
import React from 'react';
import type { AddressVerificationFormFirstStepFields, AddressCheckStatusSuccess } from './types';
import type { VerifiedAddress, VerifiedAddressResponse } from 'types/api/account';
import appConfig from 'configs/app/config';
import eastArrowIcon from 'icons/arrows/east.svg';
import { getResourceKey } from 'lib/api/useApiQuery';
import Web3ModalProvider from 'ui/shared/Web3ModalProvider';
import AddressVerificationStepAddress from './steps/AddressVerificationStepAddress';
import AddressVerificationStepSignature from './steps/AddressVerificationStepSignature';
import AddressVerificationStepSuccess from './steps/AddressVerificationStepSuccess';
interface Props {
isOpen: boolean;
onClose: () => void;
}
const AddressVerificationModal = ({ isOpen, onClose }: Props) => {
const [ stepIndex, setStepIndex ] = React.useState(0);
const [ data, setData ] = React.useState<AddressVerificationFormFirstStepFields & AddressCheckStatusSuccess>({ address: '', signingMessage: '' });
const queryClient = useQueryClient();
const handleGoToSecondStep = React.useCallback((firstStepResult: typeof data) => {
setData(firstStepResult);
setStepIndex((prev) => prev + 1);
}, []);
const handleGoToThirdStep = React.useCallback((newItem: VerifiedAddress) => {
queryClient.setQueryData(
getResourceKey('verified_addresses', { pathParams: { chainId: appConfig.network.id } }),
(prevData: VerifiedAddressResponse | undefined) => {
if (!prevData) {
return { verifiedAddresses: [ newItem ] };
}
return {
verifiedAddresses: [ newItem, ...prevData.verifiedAddresses ],
};
});
setStepIndex((prev) => prev + 1);
}, [ queryClient ]);
const handleGoToPrevStep = React.useCallback(() => {
setStepIndex((prev) => prev - 1);
}, []);
const handleClose = React.useCallback(() => {
onClose();
setStepIndex(0);
}, [ onClose ]);
const steps = [
{ title: 'Verify new address ownership', content: <AddressVerificationStepAddress onContinue={ handleGoToSecondStep }/> },
{ title: 'Copy and sign message', content: <AddressVerificationStepSignature { ...data } onContinue={ handleGoToThirdStep }/> },
{ title: 'Congrats! Address is verified.', content: <AddressVerificationStepSuccess onShowListClick={ handleClose } onAddTokenClick={ handleClose }/> },
];
const step = steps[stepIndex];
return (
<Modal isOpen={ isOpen } onClose={ handleClose } size={{ base: 'full', lg: 'md' }}>
<ModalOverlay/>
<ModalContent>
<ModalHeader fontWeight="500" textStyle="h3" mb={ 6 }>
{ stepIndex !== 0 && (
<Link mr={ 3 } onClick={ handleGoToPrevStep }>
<Icon as={ eastArrowIcon } boxSize={ 6 } transform="rotate(180deg)" verticalAlign="middle"/>
</Link>
) }
<span>{ step.title }</span>
</ModalHeader>
<ModalCloseButton/>
<ModalBody mb={ 0 }>
<Web3ModalProvider>
{ step.content }
</Web3ModalProvider>
</ModalBody>
</ModalContent>
</Modal>
);
};
export default React.memo(AddressVerificationModal);
import { FormControl, Input, useColorModeValue } from '@chakra-ui/react';
import React from 'react';
import type { Control, ControllerRenderProps, FormState } from 'react-hook-form';
import { Controller } from 'react-hook-form';
import type { AddressVerificationFormFirstStepFields, RootFields } from '../types';
import { ADDRESS_REGEXP, ADDRESS_LENGTH } from 'lib/validations/address';
import InputPlaceholder from 'ui/shared/InputPlaceholder';
type Fields = RootFields & AddressVerificationFormFirstStepFields;
interface Props {
formState: FormState<Fields>;
control: Control<Fields>;
}
const AddressVerificationFieldAddress = ({ formState, control }: Props) => {
const backgroundColor = useColorModeValue('white', 'gray.900');
const renderControl = React.useCallback(({ field }: {field: ControllerRenderProps<Fields, 'address'>}) => {
const error = 'address' in formState.errors ? formState.errors.address : undefined;
return (
<FormControl variant="floating" id={ field.name } isRequired size="md" backgroundColor={ backgroundColor } mt={ 8 }>
<Input
{ ...field }
required
isInvalid={ Boolean(error) }
maxLength={ ADDRESS_LENGTH }
isDisabled={ formState.isSubmitting }
autoComplete="off"
/>
<InputPlaceholder text="Smart contract address (0x...)" error={ error }/>
</FormControl>
);
}, [ formState.errors, formState.isSubmitting, backgroundColor ]);
return (
<Controller
name="address"
control={ control }
render={ renderControl }
rules={{ required: true, pattern: ADDRESS_REGEXP }}
/>
);
};
export default React.memo(AddressVerificationFieldAddress);
import { FormControl, Textarea, useColorModeValue } from '@chakra-ui/react';
import React from 'react';
import type { Control, ControllerRenderProps, FormState } from 'react-hook-form';
import { Controller } from 'react-hook-form';
import type { AddressVerificationFormSecondStepFields, RootFields } from '../types';
import InputPlaceholder from 'ui/shared/InputPlaceholder';
type Fields = RootFields & AddressVerificationFormSecondStepFields;
interface Props {
formState: FormState<Fields>;
control: Control<Fields>;
}
const AddressVerificationFieldMessage = ({ formState, control }: Props) => {
const backgroundColor = useColorModeValue('white', 'gray.900');
const renderControl = React.useCallback(({ field }: {field: ControllerRenderProps<Fields, 'message'>}) => {
const error = 'message' in formState.errors ? formState.errors.message : undefined;
return (
<FormControl variant="floating" id={ field.name } isRequired size="md" backgroundColor={ backgroundColor }>
<Textarea
{ ...field }
required
isInvalid={ Boolean(error) }
isDisabled
autoComplete="off"
maxH={{ base: '140px', lg: '80px' }}
/>
<InputPlaceholder text="Message to sign" error={ error } isInModal/>
</FormControl>
);
}, [ formState.errors, backgroundColor ]);
return (
<Controller
defaultValue="some value"
name="message"
control={ control }
render={ renderControl }
rules={{ required: true }}
/>
);
};
export default React.memo(AddressVerificationFieldMessage);
import { FormControl, Input, useColorModeValue } from '@chakra-ui/react';
import React from 'react';
import type { Control, ControllerRenderProps, FormState } from 'react-hook-form';
import { Controller } from 'react-hook-form';
import type { AddressVerificationFormSecondStepFields, RootFields } from '../types';
import { SIGNATURE_REGEXP } from 'lib/validations/signature';
import InputPlaceholder from 'ui/shared/InputPlaceholder';
type Fields = RootFields & AddressVerificationFormSecondStepFields;
interface Props {
formState: FormState<Fields>;
control: Control<Fields>;
}
const AddressVerificationFieldSignature = ({ formState, control }: Props) => {
const backgroundColor = useColorModeValue('white', 'gray.900');
const renderControl = React.useCallback(({ field }: {field: ControllerRenderProps<Fields, 'signature'>}) => {
const error = 'signature' in formState.errors ? formState.errors.signature : undefined;
return (
<FormControl variant="floating" id={ field.name } isRequired size="md" backgroundColor={ backgroundColor }>
<Input
{ ...field }
required
isInvalid={ Boolean(error) }
isDisabled={ formState.isSubmitting }
autoComplete="off"
/>
<InputPlaceholder text="Signature hash" error={ error }/>
</FormControl>
);
}, [ formState.errors, formState.isSubmitting, backgroundColor ]);
return (
<Controller
name="signature"
control={ control }
render={ renderControl }
rules={{ required: true, pattern: SIGNATURE_REGEXP }}
/>
);
};
export default React.memo(AddressVerificationFieldSignature);
import { Alert, Box, Button, Flex, Link } from '@chakra-ui/react';
import { route } from 'nextjs-routes';
import React from 'react';
import type { SubmitHandler } from 'react-hook-form';
import { useForm } from 'react-hook-form';
import type {
AddressVerificationResponseError,
AddressCheckResponseSuccess,
AddressCheckStatusSuccess,
AddressVerificationFormFirstStepFields,
RootFields,
} from '../types';
import appConfig from 'configs/app/config';
import type { ResourceError } from 'lib/api/resources';
import useApiFetch from 'lib/api/useApiFetch';
import LinkInternal from 'ui/shared/LinkInternal';
import AddressVerificationFieldAddress from '../fields/AddressVerificationFieldAddress';
type Fields = RootFields & AddressVerificationFormFirstStepFields;
interface Props {
onContinue: (data: AddressVerificationFormFirstStepFields & AddressCheckStatusSuccess) => void;
}
const AddressVerificationStepAddress = ({ onContinue }: Props) => {
const formApi = useForm<Fields>({
mode: 'onBlur',
});
const { handleSubmit, formState, control, setError, clearErrors, watch } = formApi;
const apiFetch = useApiFetch();
const address = watch('address');
React.useEffect(() => {
clearErrors('root');
}, [ address, clearErrors ]);
const onFormSubmit: SubmitHandler<Fields> = React.useCallback(async(data) => {
try {
const body = {
contractAddress: data.address,
};
const response = await apiFetch<'address_verification', AddressCheckResponseSuccess, AddressVerificationResponseError>('address_verification', {
fetchParams: { method: 'POST', body },
pathParams: { chainId: appConfig.network.id, type: ':prepare' },
});
if (response.status !== 'SUCCESS') {
const type = typeof response.status === 'number' ? 'UNKNOWN_ERROR' : response.status;
const message = ('payload' in response ? response.payload?.message : undefined) || 'Oops! Something went wrong';
return setError('root', { type, message });
}
onContinue({ ...response.result, address: data.address });
} catch (_error) {
const error = _error as ResourceError<AddressVerificationResponseError>;
setError('root', { type: 'manual', message: error.payload?.message || 'Oops! Something went wrong' });
}
}, [ apiFetch, onContinue, setError ]);
const onSubmit = handleSubmit(onFormSubmit);
const rootError = (() => {
switch (formState.errors.root?.type) {
case 'INVALID_ADDRESS_ERROR': {
return <span>Specified address either does not exist or is EOA.</span>;
}
case 'IS_OWNER_ERROR': {
return <span>Ownership of this contract address ownership is already verified by this account.</span>;
}
case 'OWNERSHIP_VERIFIED_ERROR': {
return <span>Ownership of this contract address is already verified by another account.</span>;
}
case 'SOURCE_CODE_NOT_VERIFIED_ERROR': {
const href = route({ pathname: '/address/[hash]/contract_verification', query: { hash: address } });
return (
<Box>
<span>The contract source code you entered is not yet verified. Please follow these steps to </span>
<LinkInternal href={ href }>verify the contract</LinkInternal>
<span>.</span>
</Box>
);
}
case undefined: {
return null;
}
default: {
return formState.errors.root?.message;
}
}
})();
return (
<form noValidate onSubmit={ onSubmit }>
<Box>Let’s check your address...</Box>
{ rootError && <Alert status="warning" mt={ 3 }>{ rootError }</Alert> }
<AddressVerificationFieldAddress formState={ formState } control={ control }/>
<Flex alignItems="center" mt={ 8 } columnGap={ 5 }>
<Button size="lg" type="submit" isDisabled={ formState.isSubmitting }>
Continue
</Button>
<Box>
<span>Contact </span>
<Link>support@blockscout.com</Link>
</Box>
</Flex>
</form>
);
};
export default React.memo(AddressVerificationStepAddress);
import { Alert, Box, Button, chakra, Flex, Link, Radio, RadioGroup } from '@chakra-ui/react';
import { useWeb3Modal } from '@web3modal/react';
import React from 'react';
import type { SubmitHandler } from 'react-hook-form';
import { useForm } from 'react-hook-form';
import { useSignMessage, useAccount } from 'wagmi';
import type {
AddressVerificationFormSecondStepFields,
AddressCheckStatusSuccess,
AddressVerificationFormFirstStepFields,
RootFields,
AddressVerificationResponseError,
AddressValidationResponseSuccess,
} from '../types';
import type { VerifiedAddress } from 'types/api/account';
import appConfig from 'configs/app/config';
import useApiFetch from 'lib/api/useApiFetch';
import shortenString from 'lib/shortenString';
import CopyToClipboard from 'ui/shared/CopyToClipboard';
import AddressVerificationFieldMessage from '../fields/AddressVerificationFieldMessage';
import AddressVerificationFieldSignature from '../fields/AddressVerificationFieldSignature';
type Fields = RootFields & AddressVerificationFormSecondStepFields;
interface Props extends AddressVerificationFormFirstStepFields, AddressCheckStatusSuccess{
onContinue: (newItem: VerifiedAddress) => void;
}
const AddressVerificationStepSignature = ({ address, signingMessage, contractCreator, contractOwner, onContinue }: Props) => {
const [ signMethod, setSignMethod ] = React.useState<'wallet' | 'manually'>('wallet');
const { open: openWeb3Modal } = useWeb3Modal();
const { isConnected } = useAccount();
const formApi = useForm<Fields>({
mode: 'onBlur',
defaultValues: {
message: signingMessage,
},
});
const { handleSubmit, formState, control, setValue, getValues, setError, clearErrors, watch } = formApi;
const apiFetch = useApiFetch();
const signature = watch('signature');
React.useEffect(() => {
clearErrors('root');
}, [ clearErrors, signature ]);
const onFormSubmit: SubmitHandler<Fields> = React.useCallback(async(data) => {
try {
const body = {
contractAddress: address,
message: data.message,
signature: data.signature,
};
const response = await apiFetch<'address_verification', AddressValidationResponseSuccess, AddressVerificationResponseError>('address_verification', {
fetchParams: { method: 'POST', body },
pathParams: { chainId: appConfig.network.id, type: ':verify' },
});
if (response.status !== 'SUCCESS') {
const type = typeof response.status === 'number' ? 'UNKNOWN_STATUS' : response.status;
return setError('root', { type, message: response.status === 'INVALID_SIGNER_ERROR' ? response.invalidSigner.signer : undefined });
}
onContinue(response.result.verifiedAddress);
} catch (error) {
setError('root', { type: 'UNKNOWN_STATUS' });
}
}, [ address, apiFetch, onContinue, setError ]);
const onSubmit = handleSubmit(onFormSubmit);
const { signMessage, isLoading: isSigning } = useSignMessage({
onSuccess: (data) => {
setValue('signature', data);
onSubmit();
},
onError: (error) => {
return setError('root', { type: 'SIGNING_FAIL', message: (error as Error)?.message || 'Oops! Something went wrong' });
},
});
const handleSignMethodChange = React.useCallback((value: typeof signMethod) => {
setSignMethod(value);
clearErrors('root');
}, [ clearErrors ]);
const handleOpenWeb3Modal = React.useCallback(() => {
openWeb3Modal();
}, [ openWeb3Modal ]);
const handleWeb3SignClick = React.useCallback(() => {
if (!isConnected) {
return setError('root', { type: 'manual', message: 'Please connect to your Web3 wallet first' });
}
const message = getValues('message');
signMessage({ message });
}, [ getValues, signMessage, isConnected, setError ]);
const handleManualSignClick = React.useCallback(() => {
onSubmit();
}, [ onSubmit ]);
const button = (() => {
if (signMethod === 'manually') {
return (
<Button
size="lg"
onClick={ handleManualSignClick }
isLoading={ formState.isSubmitting }
loadingText="Verifying"
>
Verify
</Button>
);
}
return (
<Button
size="lg"
onClick={ isConnected ? handleWeb3SignClick : handleOpenWeb3Modal }
isLoading={ formState.isSubmitting || isSigning }
loadingText={ isSigning ? 'Signing' : 'Verifying' }
>
{ isConnected ? 'Sign and verify' : 'Connect wallet' }
</Button>
);
})();
const contactUsLink = <Link>contact us</Link>;
const rootError = (() => {
switch (formState.errors.root?.type) {
case 'INVALID_SIGNATURE_ERROR': {
return <span>The signature could not be processed.</span>;
}
case 'VALIDITY_EXPIRED_ERROR': {
return <span>This verification message has expired. Add the contract address to restart the process.</span>;
}
case 'SIGNING_FAIL': {
return <span>{ formState.errors.root.message }</span>;
}
case 'INVALID_SIGNER_ERROR': {
const signer = shortenString(formState.errors.root.message || '');
const expectedSigners = [ contractCreator, contractOwner ].filter(Boolean).map(shortenString).join(', ');
return (
<Box>
<span>This address </span>
<span>{ signer }</span>
<span> is not a creator/owner of the requested contract and cannot claim ownership. Only </span>
<span>{ expectedSigners }2</span>
<span> can verify ownership of this contract.</span>
</Box>
);
}
case 'UNKNOWN_STATUS': {
return (
<Box>
<span>We are not able to process the verify account ownership for this contract address. Kindly </span>
{ contactUsLink }
<span> for further assistance.</span>
</Box>
);
}
case undefined: {
return null;
}
}
})();
return (
<form noValidate onSubmit={ onSubmit }>
{ rootError && <Alert status="warning" mb={ 6 }>{ rootError }</Alert> }
<Box mb={ 8 }>
<span>Please select the address to sign and copy the message and sign it using the Blockscout message provider of your choice. </span>
<Link href="https://docs.blockscout.com/for-users/my-account/verified-addresses/copy-and-sign-message" target="_blank">
Additional instructions
</Link>
<span>. If you do not see your address here but are sure that you are the owner of the contract, kindly </span>
{ contactUsLink }
<span> for further assistance.</span>
</Box>
{ (contractOwner || contractCreator) && (
<Flex flexDir="column" rowGap={ 4 } mb={ 4 }>
{ contractCreator && (
<Box>
<chakra.span fontWeight={ 600 }>Contract creator: </chakra.span>
<chakra.span>{ contractCreator }</chakra.span>
</Box>
) }
{ contractOwner && (
<Box>
<chakra.span fontWeight={ 600 }>Contract owner: </chakra.span>
<chakra.span>{ contractOwner }</chakra.span>
</Box>
) }
</Flex>
) }
<Flex rowGap={ 5 } flexDir="column">
<div>
<CopyToClipboard text={ signingMessage } ml="auto" display="block"/>
<AddressVerificationFieldMessage formState={ formState } control={ control }/>
</div>
<RadioGroup onChange={ handleSignMethodChange } value={ signMethod } display="flex" flexDir="column" rowGap={ 4 }>
<Radio value="wallet">Sign via Web3 wallet</Radio>
<Radio value="manually">Sign manually</Radio>
</RadioGroup>
{ signMethod === 'manually' && <AddressVerificationFieldSignature formState={ formState } control={ control }/> }
</Flex>
<Flex alignItems="center" mt={ 8 } columnGap={ 5 }>
{ button }
</Flex>
</form>
);
};
export default React.memo(AddressVerificationStepSignature);
import { Alert, Box, Button, chakra, Flex } from '@chakra-ui/react';
import React from 'react';
interface Props {
onShowListClick: () => void;
onAddTokenClick: () => void;
}
const AddressVerificationStepSuccess = ({ onAddTokenClick, onShowListClick }: Props) => {
return (
<Box>
<Alert status="success" flexWrap="wrap" whiteSpace="pre-wrap" wordBreak="break-word" mb={ 3 } display="inline-block">
<span>The address ownership for </span>
<chakra.span fontWeight={ 700 }>0xaba7161a7fb69c88e16ed9f455ce62b791ee4d03</chakra.span>
<span> is verified.</span>
</Alert>
<p>You may now submit the “Add token information” request</p>
<Flex alignItems="center" mt={ 8 } columnGap={ 5 } flexWrap="wrap" rowGap={ 5 }>
<Button size="lg" variant="outline" onClick={ onShowListClick }>
View my verified addresses
</Button>
<Button size="lg" onClick={ onAddTokenClick }>
Add token information
</Button>
</Flex>
</Box>
);
};
export default React.memo(AddressVerificationStepSuccess);
export interface AddressVerificationFormFirstStepFields {
address: string;
}
export interface AddressVerificationFormSecondStepFields {
signature: string;
message: string;
}
export interface RootFields {
root: string;
}
export interface AddressCheckStatusSuccess {
contractCreator?: string;
contractOwner?: string;
signingMessage: string;
}
export type AddressCheckResponseSuccess = {
status: 'SUCCESS';
result: AddressCheckStatusSuccess;
} |
{ status: 'IS_OWNER_ERROR' } |
{ status: 'OWNERSHIP_VERIFIED_ERROR' } |
{ status: 'SOURCE_CODE_NOT_VERIFIED_ERROR' } |
{ status: 'INVALID_ADDRESS_ERROR' };
export interface AddressVerificationResponseError {
code: number;
message: string;
}
export type AddressValidationResponseSuccess = {
status: 'SUCCESS';
result: {
verifiedAddress: {
chainId: string;
contractAddress: string;
userId: string;
verifiedDate: string;
};
};
} |
{
status: 'INVALID_SIGNER_ERROR';
invalidSigner: {
signer: string;
validAddresses: Array<string>;
};
} |
{ status: 'VALIDITY_EXPIRED_ERROR' } |
{ status: 'INVALID_SIGNATURE_ERROR' } |
{ status: 'UNKNOWN_STATUS' }
......@@ -38,8 +38,6 @@ const AddressPageContent = () => {
const appProps = useAppContext();
const hasGoBackLink = appProps.referrer && appProps.referrer.includes('/accounts');
const tabsScrollRef = React.useRef<HTMLDivElement>(null);
const hash = getQueryParamString(router.query.hash);
......@@ -98,6 +96,19 @@ const AddressPageContent = () => {
const content = addressQuery.isError ? null : <RoutedTabs tabs={ tabs } tabListProps={{ mt: 8 }}/>;
const backLink = React.useMemo(() => {
const hasGoBackLink = appProps.referrer && appProps.referrer.includes('/accounts');
if (!hasGoBackLink) {
return;
}
return {
label: 'Back to top accounts list',
url: appProps.referrer,
};
}, [ appProps.referrer ]);
return (
<Page>
{ addressQuery.isLoading ? <Skeleton h={{ base: 12, lg: 6 }} mb={ 6 } w="100%" maxW="680px"/> : <TextAd mb={ 6 }/> }
......@@ -107,8 +118,7 @@ const AddressPageContent = () => {
<PageTitle
text={ `${ addressQuery.data?.is_contract ? 'Contract' : 'Address' } details` }
additionalsRight={ tagsNode }
backLinkUrl={ hasGoBackLink ? appProps.referrer : undefined }
backLinkLabel="Back to top accounts list"
backLink={ backLink }
/>
) }
<AddressDetails addressQuery={ addressQuery } scrollRef={ tabsScrollRef }/>
......
......@@ -57,7 +57,18 @@ const BlockPageContent = () => {
const hasPagination = !isMobile && tab === 'txs' && blockTxsQuery.isPaginationVisible;
const hasGoBackLink = appProps.referrer && appProps.referrer.includes('/blocks');
const backLink = React.useMemo(() => {
const hasGoBackLink = appProps.referrer && appProps.referrer.includes('/blocks');
if (!hasGoBackLink) {
return;
}
return {
label: 'Back to blocks list',
url: appProps.referrer,
};
}, [ appProps.referrer ]);
return (
<>
......@@ -67,8 +78,7 @@ const BlockPageContent = () => {
) : (
<PageTitle
text={ `Block #${ blockQuery.data?.height }` }
backLinkUrl={ hasGoBackLink ? appProps.referrer : undefined }
backLinkLabel="Back to blocks list"
backLink={ backLink }
/>
) }
<RoutedTabs
......
......@@ -20,7 +20,6 @@ import PageTitle from 'ui/shared/Page/PageTitle';
const ContractVerification = () => {
const appProps = useAppContext();
const hasGoBackLink = appProps.referrer && appProps.referrer.includes('/address');
const router = useRouter();
const hash = getQueryParamString(router.query.hash);
......@@ -83,12 +82,24 @@ const ContractVerification = () => {
);
})();
const backLink = React.useMemo(() => {
const hasGoBackLink = appProps.referrer && appProps.referrer.includes('/address');
if (!hasGoBackLink) {
return;
}
return {
label: 'Back to contract',
url: appProps.referrer,
};
}, [ appProps.referrer ]);
return (
<Page>
<PageTitle
text="New smart contract verification"
backLinkUrl={ hasGoBackLink ? appProps.referrer : undefined }
backLinkLabel="Back to contract"
backLink={ backLink }
/>
{ hash && (
<Address mb={ 12 }>
......
......@@ -50,7 +50,6 @@ const CsvExport = () => {
const addressHash = router.query.address?.toString() || '';
const exportType = router.query.type?.toString() || '';
const hasGoBackLink = appProps.referrer && appProps.referrer.includes('/address');
const addressQuery = useApiQuery('address', {
pathParams: { hash: addressHash },
......@@ -59,6 +58,19 @@ const CsvExport = () => {
},
});
const backLink = React.useMemo(() => {
const hasGoBackLink = appProps.referrer && appProps.referrer.includes('/address');
if (!hasGoBackLink) {
return;
}
return {
label: 'Back to address',
url: appProps.referrer,
};
}, [ appProps.referrer ]);
if (!isCorrectExportType(exportType) || !addressHash || addressQuery.error?.status === 400) {
throw Error('Not found', { cause: { status: 404 } });
}
......@@ -79,8 +91,7 @@ const CsvExport = () => {
<Page>
<PageTitle
text="Export data to CSV file"
backLinkUrl={ hasGoBackLink ? appProps.referrer : undefined }
backLinkLabel="Back to address"
backLink={ backLink }
/>
<Flex mb={ 10 } whiteSpace="pre-wrap" flexWrap="wrap">
<span>Export { EXPORT_TYPES[exportType].text } for address </span>
......
......@@ -17,14 +17,25 @@ const Sol2Uml = () => {
const appProps = useAppContext();
const addressHash = router.query.address?.toString() || '';
const hasGoBackLink = appProps.referrer && appProps.referrer.includes('/address');
const backLink = React.useMemo(() => {
const hasGoBackLink = appProps.referrer && appProps.referrer.includes('/address');
if (!hasGoBackLink) {
return;
}
return {
label: 'Back to address',
url: appProps.referrer,
};
}, [ appProps.referrer ]);
return (
<Page>
<PageTitle
text="Solidity UML diagram"
backLinkUrl={ hasGoBackLink ? appProps.referrer : undefined }
backLinkLabel="Back to address"
backLink={ backLink }
/>
<Flex mb={ 10 }>
<span>For contract</span>
......
......@@ -41,8 +41,6 @@ const TokenPageContent = () => {
const appProps = useAppContext();
const hasGoBackLink = appProps.referrer && appProps.referrer.includes('/tokens');
const scrollRef = React.useRef<HTMLDivElement>(null);
const hashString = router.query.hash?.toString();
......@@ -194,6 +192,19 @@ const TokenPageContent = () => {
};
}, [ isMobile ]);
const backLink = React.useMemo(() => {
const hasGoBackLink = appProps.referrer && appProps.referrer.includes('/tokens');
if (!hasGoBackLink) {
return;
}
return {
label: 'Back to tokens list',
url: appProps.referrer,
};
}, [ appProps.referrer ]);
return (
<Page>
{ tokenQuery.isLoading ? (
......@@ -209,8 +220,7 @@ const TokenPageContent = () => {
<TextAd mb={ 6 }/>
<PageTitle
text={ `${ tokenQuery.data?.name || 'Unnamed' }${ tokenSymbolText } token` }
backLinkUrl={ hasGoBackLink ? appProps.referrer : undefined }
backLinkLabel="Back to tokens list"
backLink={ backLink }
additionalsLeft={ (
<TokenLogo hash={ tokenQuery.data?.address } name={ tokenQuery.data?.name } boxSize={ 6 }/>
) }
......
......@@ -33,7 +33,6 @@ const TransactionPageContent = () => {
const router = useRouter();
const appProps = useAppContext();
const hasGoBackLink = appProps.referrer && appProps.referrer.includes('/txs');
const hash = getQueryParamString(router.query.hash);
const { data } = useApiQuery('tx', {
......@@ -65,14 +64,26 @@ const TransactionPageContent = () => {
</Flex>
);
const backLink = React.useMemo(() => {
const hasGoBackLink = appProps.referrer && appProps.referrer.includes('/txs');
if (!hasGoBackLink) {
return;
}
return {
label: 'Back to transactions list',
url: appProps.referrer,
};
}, [ appProps.referrer ]);
return (
<Page>
<TextAd mb={ 6 }/>
<PageTitle
text="Transaction details"
additionalsRight={ additionals }
backLinkUrl={ hasGoBackLink ? appProps.referrer : undefined }
backLinkLabel="Back to transactions list"
backLink={ backLink }
/>
<RoutedTabs tabs={ TABS }/>
</Page>
......
import { OrderedList, ListItem, chakra, Button, useDisclosure, Show, Hide, Skeleton, Box } from '@chakra-ui/react';
import React from 'react';
import appConfig from 'configs/app/config';
import useApiQuery from 'lib/api/useApiQuery';
import useRedirectForInvalidAuthToken from 'lib/hooks/useRedirectForInvalidAuthToken';
import AddressVerificationModal from 'ui/addressVerification/AddressVerificationModal';
import AccountPageDescription from 'ui/shared/AccountPageDescription';
import DataListDisplay from 'ui/shared/DataListDisplay';
import Page from 'ui/shared/Page/Page';
import PageTitle from 'ui/shared/Page/PageTitle';
import SkeletonListAccount from 'ui/shared/skeletons/SkeletonListAccount';
import SkeletonTable from 'ui/shared/skeletons/SkeletonTable';
import TokenInfoForm from 'ui/tokenInfo/TokenInfoForm';
import VerifiedAddressesListItem from 'ui/verifiedAddresses/VerifiedAddressesListItem';
import VerifiedAddressesTable from 'ui/verifiedAddresses/VerifiedAddressesTable';
const VerifiedAddresses = () => {
useRedirectForInvalidAuthToken();
const [ submissionId, setSubmissionId ] = React.useState<number>();
const modalProps = useDisclosure();
const { data, isLoading, isError } = useApiQuery('verified_addresses', {
pathParams: { chainId: appConfig.network.id },
});
const handleGoBack = React.useCallback(() => {
setSubmissionId(undefined);
}, []);
const handleItemAdd = React.useCallback(() => {
setSubmissionId(NaN);
}, []);
const handleItemEdit = React.useCallback(() => {}, []);
const addButton = (
<Box marginTop={ 8 }>
<Button size="lg" onClick={ modalProps.onOpen }>
Add address
</Button>
</Box>
);
const skeleton = (
<>
<Box display={{ base: 'block', lg: 'none' }}>
<SkeletonListAccount/>
<Skeleton height="44px" width="156px" marginTop={ 8 }/>
</Box>
<Box display={{ base: 'none', lg: 'block' }}>
<SkeletonTable columns={ [ '100%', '180px', '260px', '160px' ] }/>
<Skeleton height="44px" width="156px" marginTop={ 8 }/>
</Box>
</>
);
const backLink = React.useMemo(() => {
if (submissionId === undefined) {
return;
}
return {
label: 'Back to my verified addresses',
onClick: handleGoBack,
};
}, [ handleGoBack, submissionId ]);
if (submissionId !== undefined) {
return (
<Page>
<PageTitle text="Token info application form" backLink={ backLink }/>
<TokenInfoForm id={ submissionId }/>
</Page>
);
}
const content = data?.verifiedAddresses ? (
<>
<Show below="lg" key="content-mobile" ssr={ false }>
{ data.verifiedAddresses.map((item) => (
<VerifiedAddressesListItem
key={ item.contractAddress }
item={ item }
onAdd={ handleItemAdd }
onEdit={ handleItemEdit }
/>
)) }
</Show>
<Hide below="lg" key="content-desktop" ssr={ false }>
<VerifiedAddressesTable data={ data.verifiedAddresses } onItemEdit={ handleItemEdit } onItemAdd={ handleItemAdd }/>
</Hide>
</>
) : null;
return (
<Page>
<PageTitle text="My verified addresses"/>
<AccountPageDescription allowCut={ false }>
<span>
Verify ownership of a smart contract address to easily update information in Blockscout.
You will sign a single message to verify contract ownership.
Once verified, you can update token information, address name tags, and address labels from the
Blockscout console without needing to sign additional messages.
</span>
<chakra.p fontWeight={ 600 } mt={ 5 }>
Before starting, make sure that:
</chakra.p>
<OrderedList>
<ListItem>The source code for the smart contract is deployed on “Network Name”.</ListItem>
<ListItem>The source code is verified (if not yet verified, you can use this tool).</ListItem>
</OrderedList>
<chakra.div mt={ 5 }>
Once these steps are complete, click the Add address button below to get started.
</chakra.div>
</AccountPageDescription>
<DataListDisplay
isLoading={ isLoading }
isError={ isError }
items={ data?.verifiedAddresses }
content={ content }
emptyText=""
skeletonProps={{ customSkeleton: skeleton }}
/>
{ addButton }
<AddressVerificationModal isOpen={ modalProps.isOpen } onClose={ modalProps.onClose }/>
</Page>
);
};
export default VerifiedAddresses;
import { Box, Text, useColorModeValue } from '@chakra-ui/react';
import { Box, useColorModeValue } from '@chakra-ui/react';
import _debounce from 'lodash/debounce';
import React, { useRef, useEffect, useState, useCallback } from 'react';
const CUT_HEIGHT = 144;
const AccountPageDescription = ({ children }: {children: React.ReactNode}) => {
const AccountPageDescription = ({ children, allowCut = true }: { children: React.ReactNode; allowCut?: boolean }) => {
const ref = useRef<HTMLParagraphElement>(null);
const [ needCut, setNeedCut ] = useState(false);
......@@ -20,6 +20,10 @@ const AccountPageDescription = ({ children }: {children: React.ReactNode}) => {
}, [ needCut ]);
useEffect(() => {
if (!allowCut) {
return;
}
calculateCut();
const resizeHandler = _debounce(calculateCut, 300);
window.addEventListener('resize', resizeHandler);
......@@ -40,15 +44,14 @@ const AccountPageDescription = ({ children }: {children: React.ReactNode}) => {
return (
<Box position="relative" marginBottom={{ base: 6, lg: 8 }}>
<Text
<Box
ref={ ref }
maxHeight={ needCut && !expanded ? `${ CUT_HEIGHT }px` : 'auto' }
overflow="hidden"
style={ needCut && !expanded ? { WebkitLineClamp: '6', WebkitBoxOrient: 'vertical', display: '-webkit-box' } : {} }
>
{ children }
</Text>
</Box>
{ needCut && !expanded && (
<Box
position="absolute"
......
......@@ -9,7 +9,7 @@ import AddressLink from 'ui/shared/address/AddressLink';
import CopyToClipboard from 'ui/shared/CopyToClipboard';
interface Props {
address: AddressParam;
address: Pick<AddressParam, 'hash' | 'is_contract' | 'implementation_name'>;
subtitle?: string;
}
......
......@@ -68,7 +68,7 @@ const DataListDisplay = (props: Props) => {
}
if (!props.items?.length) {
return <Text as="span">{ props.emptyText }</Text>;
return props.emptyText ? <Text as="span">{ props.emptyText }</Text> : null;
}
return (
......
import { Tooltip } from '@chakra-ui/react';
import React from 'react';
import shortenString from 'lib/shortenString';
interface Props {
hash: string;
isTooltipDisabled?: boolean;
......@@ -13,7 +15,7 @@ const HashStringShorten = ({ hash, isTooltipDisabled }: Props) => {
return (
<Tooltip label={ hash } isDisabled={ isTooltipDisabled }>
{ hash.slice(0, 4) + '...' + hash.slice(-4) }
{ shortenString(hash) }
</Tooltip>
);
};
......
......@@ -43,8 +43,7 @@ test('with text ad, back link and addons +@mobile +@dark-mode', async({ mount })
<PageTitle
text="Title"
withTextAd
backLinkLabel="Back"
backLinkUrl="back"
backLink={{ label: 'Back', url: 'back' }}
// additionalsLeft={ left }
additionalsRight="Privet"
/>
......@@ -60,8 +59,7 @@ test('long title with text ad, back link and addons +@mobile', async({ mount })
<PageTitle
text="This title is long, really long"
withTextAd
backLinkLabel="Back"
backLinkUrl="back"
backLink={{ label: 'Back', url: 'back' }}
additionalsRight="Privet, kak dela?"
/>
</TestApp>,
......
import { Heading, Flex, Grid, Tooltip, Icon, chakra } from '@chakra-ui/react';
import { Heading, Flex, Grid, Tooltip, Icon, Link, chakra } from '@chakra-ui/react';
import React from 'react';
import eastArrowIcon from 'icons/arrows/east.svg';
import TextAd from 'ui/shared/ad/TextAd';
import LinkInternal from 'ui/shared/LinkInternal';
type BackLinkProp = { label: string; url: string } | { label: string; onClick: () => void };
type Props = {
text: string;
additionalsLeft?: React.ReactNode;
additionalsRight?: React.ReactNode;
withTextAd?: boolean;
className?: string;
backLinkLabel?: string;
backLinkUrl?: string;
backLink?: BackLinkProp;
}
const PageTitle = ({ text, additionalsLeft, additionalsRight, withTextAd, backLinkUrl, backLinkLabel, className }: Props) => {
const PageTitle = ({ text, additionalsLeft, additionalsRight, withTextAd, backLink, className }: Props) => {
const title = (
<Heading
as="h1"
......@@ -27,6 +28,32 @@ const PageTitle = ({ text, additionalsLeft, additionalsRight, withTextAd, backLi
</Heading>
);
const backLinkComponent = (() => {
if (!backLink) {
return null;
}
const icon = <Icon as={ eastArrowIcon } boxSize={ 6 } transform="rotate(180deg)" margin="auto"/>;
if ('url' in backLink) {
return (
<Tooltip label={ backLink.label }>
<LinkInternal display="inline-flex" href={ backLink.url } h="40px">
{ icon }
</LinkInternal>
</Tooltip>
);
}
return (
<Tooltip label={ backLink.label }>
<Link display="inline-flex" onClick={ backLink.onClick } h="40px">
{ icon }
</Link>
</Tooltip>
);
})();
return (
<Flex
columnGap={ 3 }
......@@ -39,16 +66,10 @@ const PageTitle = ({ text, additionalsLeft, additionalsRight, withTextAd, backLi
>
<Flex flexWrap="wrap" columnGap={ 3 } alignItems="center" width={ withTextAd ? 'unset' : '100%' } flexShrink={ 0 }>
<Grid
templateColumns={ [ backLinkUrl && 'auto', additionalsLeft && 'auto', '1fr' ].filter(Boolean).join(' ') }
templateColumns={ [ backLinkComponent && 'auto', additionalsLeft && 'auto', '1fr' ].filter(Boolean).join(' ') }
columnGap={ 3 }
>
{ backLinkUrl && (
<Tooltip label={ backLinkLabel }>
<LinkInternal display="inline-flex" href={ backLinkUrl } h="40px">
<Icon as={ eastArrowIcon } boxSize={ 6 } transform="rotate(180deg)" margin="auto"/>
</LinkInternal>
</Tooltip>
) }
{ backLinkComponent }
{ additionalsLeft !== undefined && (
<Flex h="40px" alignItems="center">
{ additionalsLeft }
......
import { useColorModeValue, useToken } from '@chakra-ui/react';
import {
EthereumClient,
modalConnectors,
walletConnectProvider,
} from '@web3modal/ethereum';
import { Web3Modal } from '@web3modal/react';
import React from 'react';
import type { Chain } from 'wagmi';
import { configureChains, createClient, WagmiConfig } from 'wagmi';
import appConfig from 'configs/app/config';
const { wagmiClient, ethereumClient } = (() => {
try {
const currentChain: Chain = {
id: Number(appConfig.network.id),
name: appConfig.network.name || '',
network: appConfig.network.name || '',
nativeCurrency: {
decimals: appConfig.network.currency.decimals,
name: appConfig.network.currency.name || '',
symbol: appConfig.network.currency.symbol || '',
},
rpcUrls: {
'default': {
http: [ appConfig.network.rpcUrl || '' ],
},
},
blockExplorers: {
'default': {
name: 'Blockscout',
url: appConfig.baseUrl,
},
},
};
const chains = [ currentChain ];
const { provider } = configureChains(chains, [
walletConnectProvider({ projectId: appConfig.walletConnect.projectId || '' }),
]);
const wagmiClient = createClient({
autoConnect: true,
connectors: modalConnectors({ appName: 'web3Modal', chains }),
provider,
});
const ethereumClient = new EthereumClient(wagmiClient, chains);
return { wagmiClient, ethereumClient };
} catch (error) {
return { wagmiClient: undefined, ethereumClient: undefined };
}
})();
interface Props {
children: React.ReactNode;
fallback?: () => JSX.Element;
}
const Web3ModalProvider = ({ children, fallback }: Props) => {
const modalZIndex = useToken<string>('zIndices', 'modal');
const web3ModalTheme = useColorModeValue('light', 'dark');
if (!wagmiClient || !ethereumClient) {
return fallback?.() || null;
}
return (
<WagmiConfig client={ wagmiClient }>
{ children }
<Web3Modal
projectId={ appConfig.walletConnect.projectId }
ethereumClient={ ethereumClient }
themeZIndex={ Number(modalZIndex) }
themeMode={ web3ModalTheme }
themeBackground="themeColor"
/>
</WagmiConfig>
);
};
export default Web3ModalProvider;
......@@ -32,7 +32,7 @@ test('no auth +@desktop-xl +@dark-mode-xl', async({ mount }) => {
test.describe('auth', () => {
const extendedTest = test.extend({
context: ({ context }, use) => {
context: async({ context }, use) => {
authFixture(context);
use(context);
},
......
import { Box } from '@chakra-ui/react';
import React from 'react';
interface Props {
id: number;
}
const TokenInfoForm = ({ id }: Props) => {
return <Box>TokenInfoForm for { id }</Box>;
};
export default TokenInfoForm;
......@@ -32,8 +32,6 @@ const TokenInstanceContent = () => {
const id = router.query.id?.toString();
const tab = router.query.tab?.toString();
const hasGoBackLink = appProps.referrer && appProps.referrer.includes(`/token/${ hash }`) && !appProps.referrer.includes('instance');
const scrollRef = React.useRef<HTMLDivElement>(null);
const tokenInstanceQuery = useApiQuery('token_instance', {
......@@ -50,6 +48,19 @@ const TokenInstanceContent = () => {
},
});
const backLink = React.useMemo(() => {
const hasGoBackLink = appProps.referrer && appProps.referrer.includes(`/token/${ hash }`) && !appProps.referrer.includes('instance');
if (!hasGoBackLink) {
return;
}
return {
label: 'Back to token page',
url: appProps.referrer,
};
}, [ appProps.referrer, hash ]);
const tabs: Array<RoutedTab> = [
{ id: 'token_transfers', title: 'Token transfers', component: <TokenTransfer transfersQuery={ transfersQuery } tokenId={ id }/> },
// there is no api for this tab yet
......@@ -104,8 +115,7 @@ const TokenInstanceContent = () => {
<TextAd mb={ 6 }/>
<PageTitle
text={ `${ tokenInstanceQuery.data.token.name || 'Unnamed token' } #${ tokenInstanceQuery.data.id }` }
backLinkUrl={ hasGoBackLink ? appProps.referrer : undefined }
backLinkLabel="Back to token page"
backLink={ backLink }
additionalsLeft={ nftShieldIcon }
additionalsRight={ tokenTag }
/>
......
import { Link } from '@chakra-ui/react';
import React from 'react';
import type { VerifiedAddress } from 'types/api/account';
import AddressSnippet from 'ui/shared/AddressSnippet';
import ListItemMobileGrid from 'ui/shared/ListItemMobile/ListItemMobileGrid';
interface Props {
item: VerifiedAddress;
onAdd: (address: string) => void;
onEdit: (item: VerifiedAddress) => void;
}
const VerifiedAddressesListItem = ({ item, onAdd }: Props) => {
const handleAddClick = React.useCallback(() => {
onAdd(item.contractAddress);
}, [ item, onAdd ]);
return (
<ListItemMobileGrid.Container>
<ListItemMobileGrid.Label>Address</ListItemMobileGrid.Label>
<ListItemMobileGrid.Value py="3px">
<AddressSnippet address={{ hash: item.contractAddress, is_contract: true, implementation_name: null }}/>
</ListItemMobileGrid.Value>
<ListItemMobileGrid.Label>Token Info</ListItemMobileGrid.Label>
<ListItemMobileGrid.Value>
<Link onClick={ handleAddClick }>Add details</Link>
</ListItemMobileGrid.Value>
</ListItemMobileGrid.Container>
);
};
export default React.memo(VerifiedAddressesListItem);
import { Table, Tbody, Th, Thead, Tr } from '@chakra-ui/react';
import React from 'react';
import type { VerifiedAddress } from 'types/api/account';
import VerifiedAddressesTableItem from './VerifiedAddressesTableItem';
interface Props {
data: Array<VerifiedAddress>;
onItemAdd: (address: string) => void;
onItemEdit: (item: VerifiedAddress) => void;
}
const VerifiedAddressesTable = ({ data, onItemEdit, onItemAdd }: Props) => {
return (
<Table variant="simple">
<Thead>
<Tr>
<Th>Address</Th>
<Th w="180px">Token info</Th>
<Th w="260px">Request status</Th>
<Th w="160px">Actions</Th>
</Tr>
</Thead>
<Tbody>
{ data.map((item) => (
<VerifiedAddressesTableItem
key={ item.contractAddress }
item={ item }
onAdd={ onItemAdd }
onEdit={ onItemEdit }
/>
)) }
</Tbody>
</Table>
);
};
export default React.memo(VerifiedAddressesTable);
import { Td, Tr, Link } from '@chakra-ui/react';
import React from 'react';
import type { VerifiedAddress } from 'types/api/account';
import AddressSnippet from 'ui/shared/AddressSnippet';
interface Props {
item: VerifiedAddress;
onAdd: (address: string) => void;
onEdit: (item: VerifiedAddress) => void;
}
const VerifiedAddressesTableItem = ({ item, onAdd }: Props) => {
const handleAddClick = React.useCallback(() => {
onAdd(item.contractAddress);
}, [ item, onAdd ]);
return (
<Tr>
<Td>
<AddressSnippet address={{ hash: item.contractAddress, is_contract: true, implementation_name: null }}/>
</Td>
<Td>
<Link onClick={ handleAddClick }>Add details</Link>
</Td>
<Td></Td>
<Td></Td>
</Tr>
);
};
export default React.memo(VerifiedAddressesTableItem);
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment