Skip to content

Commit

Permalink
Merge pull request #882 from blockscout/feat/contract-code-source-type
Browse files Browse the repository at this point in the history
contract code: source type selector
  • Loading branch information
tom2drum authored Jun 13, 2023
2 parents d0e132e + 22990d6 commit d1cb771
Show file tree
Hide file tree
Showing 7 changed files with 147 additions and 88 deletions.
11 changes: 5 additions & 6 deletions ui/address/AddressContract.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,6 @@ import React from 'react';

import type { RoutedSubTab } from 'ui/shared/Tabs/types';

import { ContractContextProvider } from 'ui/address/contract/context';
import RoutedTabs from 'ui/shared/Tabs/RoutedTabs';
import Web3ModalProvider from 'ui/shared/Web3ModalProvider';

Expand All @@ -15,17 +14,17 @@ const TAB_LIST_PROPS = {
columnGap: 3,
};

const AddressContract = ({ addressHash, tabs }: Props) => {
const AddressContract = ({ tabs }: Props) => {
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 }/>;
return (
<RoutedTabs tabs={ noProviderTabs } variant="outline" colorScheme="gray" size="sm" tabListProps={ TAB_LIST_PROPS }/>
);
}, [ tabs ]);

return (
<Web3ModalProvider fallback={ fallback }>
<ContractContextProvider addressHash={ addressHash }>
<RoutedTabs tabs={ tabs } variant="outline" colorScheme="gray" size="sm" tabListProps={ TAB_LIST_PROPS }/>
</ContractContextProvider>
<RoutedTabs tabs={ tabs } variant="outline" colorScheme="gray" size="sm" tabListProps={ TAB_LIST_PROPS }/>
</Web3ModalProvider>
);
};
Expand Down
10 changes: 2 additions & 8 deletions ui/address/contract/ContractCode.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -212,16 +212,10 @@ const ContractCode = ({ addressHash, noSocket }: Props) => {
isLoading={ isPlaceholderData }
/>
) }
{ data?.source_code && (
{ data?.is_verified && (
<ContractSourceCode
data={ data.source_code }
hasSol2Yml={ Boolean(data.can_be_visualized_via_sol2uml) }
address={ addressHash }
isViper={ Boolean(data.is_vyper_contract) }
filePath={ data.file_path }
additionalSource={ data.additional_sources }
remappings={ data.compiler_settings?.remappings }
isLoading={ isPlaceholderData }
implementationAddress={ addressInfo?.implementation_address ?? undefined }
/>
) }
{ data?.compiler_settings ? (
Expand Down
140 changes: 118 additions & 22 deletions ui/address/contract/ContractSourceCode.tsx
Original file line number Diff line number Diff line change
@@ -1,65 +1,161 @@
import { Flex, Skeleton, Text, Tooltip } from '@chakra-ui/react';
import { Box, Flex, Select, Skeleton, Text, Tooltip } from '@chakra-ui/react';
import { route } from 'nextjs-routes';
import React from 'react';

import type { SmartContract } from 'types/api/contract';
import type { ArrayElement } from 'types/utils';

import useApiQuery from 'lib/api/useApiQuery';
import * as stubs from 'stubs/contract';
import CopyToClipboard from 'ui/shared/CopyToClipboard';
import LinkInternal from 'ui/shared/LinkInternal';
import CodeEditor from 'ui/shared/monaco/CodeEditor';
import formatFilePath from 'ui/shared/monaco/utils/formatFilePath';

const SOURCE_CODE_OPTIONS = [
{ id: 'primary', label: 'Proxy' } as const,
{ id: 'secondary', label: 'Implementation' } as const,
];
type SourceCodeType = ArrayElement<typeof SOURCE_CODE_OPTIONS>['id'];

function getEditorData(contractInfo: SmartContract | undefined) {
if (!contractInfo || !contractInfo.source_code) {
return undefined;
}

const defaultName = contractInfo.is_vyper_contract ? '/index.vy' : '/index.sol';
return [
{ file_path: formatFilePath(contractInfo.file_path || defaultName), source_code: contractInfo.source_code },
...(contractInfo.additional_sources || []).map((source) => ({ ...source, file_path: formatFilePath(source.file_path) })),
];
}

interface Props {
data: string;
hasSol2Yml: boolean;
address?: string;
isViper: boolean;
filePath?: string;
additionalSource?: SmartContract['additional_sources'];
remappings?: Array<string>;
isLoading?: boolean;
implementationAddress?: string;
}

const ContractSourceCode = ({ data, hasSol2Yml, address, isViper, filePath, additionalSource, remappings, isLoading }: Props) => {
const ContractSourceCode = ({ address, implementationAddress }: Props) => {
const [ sourceType, setSourceType ] = React.useState<SourceCodeType>('primary');

const primaryContractQuery = useApiQuery('contract', {
pathParams: { hash: address },
queryOptions: {
enabled: Boolean(address),
refetchOnMount: false,
placeholderData: stubs.CONTRACT_CODE_VERIFIED,
},
});

const secondaryContractQuery = useApiQuery('contract', {
pathParams: { hash: implementationAddress },
queryOptions: {
enabled: Boolean(implementationAddress),
refetchOnMount: false,
placeholderData: stubs.CONTRACT_CODE_VERIFIED,
},
});

const isLoading = implementationAddress ?
primaryContractQuery.isPlaceholderData || secondaryContractQuery.isPlaceholderData :
primaryContractQuery.isPlaceholderData;

const primaryEditorData = React.useMemo(() => {
return getEditorData(primaryContractQuery.data);
}, [ primaryContractQuery.data ]);

const secondaryEditorData = React.useMemo(() => {
return getEditorData(secondaryContractQuery.data);
}, [ secondaryContractQuery.data ]);

const activeContract = sourceType === 'secondary' ? secondaryContractQuery.data : primaryContractQuery.data;
const activeContractData = sourceType === 'secondary' ? secondaryEditorData : primaryEditorData;

const heading = (
<Skeleton isLoaded={ !isLoading } fontWeight={ 500 }>
<span>Contract source code</span>
<Text whiteSpace="pre" as="span" variant="secondary"> ({ isViper ? 'Vyper' : 'Solidity' })</Text>
<Text whiteSpace="pre" as="span" variant="secondary"> ({ activeContract?.is_vyper_contract ? 'Vyper' : 'Solidity' })</Text>
</Skeleton>
);

const diagramLink = hasSol2Yml && address ? (
const diagramLinkAddress = (() => {
if (!activeContract?.can_be_visualized_via_sol2uml) {
return;
}
return sourceType === 'secondary' ? implementationAddress : address;
})();

const diagramLink = diagramLinkAddress ? (
<Tooltip label="Visualize contract code using Sol2Uml JS library">
<LinkInternal
href={ route({ pathname: '/visualize/sol2uml', query: { address } }) }
href={ route({ pathname: '/visualize/sol2uml', query: { address: diagramLinkAddress } }) }
ml="auto"
>
<Skeleton isLoaded={ !isLoading }>
View UML diagram
</Skeleton>
</LinkInternal>
</Tooltip>
) : <Box ml="auto"/>;

const copyToClipboard = activeContractData?.length === 1 ?
<CopyToClipboard text={ activeContractData[0].source_code } isLoading={ isLoading } ml={ 3 }/> :
null;

const handleSelectChange = React.useCallback((event: React.ChangeEvent<HTMLSelectElement>) => {
setSourceType(event.target.value as SourceCodeType);
}, []);

const editorSourceTypeSelector = !secondaryContractQuery.isPlaceholderData && secondaryContractQuery.data?.source_code ? (
<Select
size="xs"
value={ sourceType }
onChange={ handleSelectChange }
focusBorderColor="none"
w="auto"
ml={ 3 }
borderRadius="base"
>
{ SOURCE_CODE_OPTIONS.map((option) => <option key={ option.id } value={ option.id }>{ option.label }</option>) }
</Select>
) : null;

const editorData = React.useMemo(() => {
const defaultName = isViper ? '/index.vy' : '/index.sol';
return [
{ file_path: formatFilePath(filePath || defaultName), source_code: data },
...(additionalSource || []).map((source) => ({ ...source, file_path: formatFilePath(source.file_path) })) ];
}, [ additionalSource, data, filePath, isViper ]);
const content = (() => {
if (isLoading) {
return <Skeleton h="557px" w="100%"/>;
}

const copyToClipboard = editorData.length === 1 ?
<CopyToClipboard text={ editorData[0].source_code } isLoading={ isLoading } ml={ 3 }/> :
null;
if (!primaryEditorData) {
return null;
}

return (
<>
<Box display={ sourceType === 'primary' ? 'block' : 'none' }>
<CodeEditor data={ primaryEditorData } remappings={ primaryContractQuery.data?.compiler_settings?.remappings }/>
</Box>
{ secondaryEditorData && (
<Box display={ sourceType === 'secondary' ? 'block' : 'none' }>
<CodeEditor data={ secondaryEditorData } remappings={ secondaryContractQuery.data?.compiler_settings?.remappings }/>
</Box>
) }
</>
);
})();

if (!primaryEditorData) {
return null;
}

return (
<section>
<Flex justifyContent="space-between" alignItems="center" mb={ 3 }>
{ heading }
{ editorSourceTypeSelector }
{ diagramLink }
{ copyToClipboard }
</Flex>
{ isLoading ? <Skeleton h="557px" w="100%"/> : <CodeEditor data={ editorData } remappings={ remappings }/> }
{ content }
</section>
);
};
Expand Down
21 changes: 5 additions & 16 deletions ui/address/contract/ContractWrite.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,12 +9,12 @@ import ContractMethodsAccordion from 'ui/address/contract/ContractMethodsAccordi
import ContentLoader from 'ui/shared/ContentLoader';
import DataFetchAlert from 'ui/shared/DataFetchAlert';

import { useContractContext } from './context';
import ContractConnectWallet from './ContractConnectWallet';
import ContractCustomAbiAlert from './ContractCustomAbiAlert';
import ContractImplementationAddress from './ContractImplementationAddress';
import ContractMethodCallable from './ContractMethodCallable';
import ContractWriteResult from './ContractWriteResult';
import useContractAbi from './useContractAbi';
import { getNativeCoinValue } from './utils';

interface Props {
Expand All @@ -39,18 +39,7 @@ const ContractWrite = ({ addressHash, isProxy, isCustomAbi }: Props) => {
},
});

const { contractInfo, customInfo, proxyInfo } = useContractContext();
const abi = (() => {
if (isProxy) {
return proxyInfo?.abi;
}

if (isCustomAbi) {
return customInfo?.abi;
}

return contractInfo?.abi;
})();
const contractAbi = useContractAbi({ addressHash, isProxy, isCustomAbi });

const handleMethodFormSubmit = React.useCallback(async(item: SmartContractWriteMethod, args: Array<string | Array<unknown>>) => {
if (!isConnected) {
Expand All @@ -61,7 +50,7 @@ const ContractWrite = ({ addressHash, isProxy, isCustomAbi }: Props) => {
await switchNetworkAsync?.(Number(config.network.id));
}

if (!abi) {
if (!contractAbi) {
throw new Error('Something went wrong. Try again later.');
}

Expand All @@ -84,14 +73,14 @@ const ContractWrite = ({ addressHash, isProxy, isCustomAbi }: Props) => {

const hash = await walletClient?.writeContract({
args: _args,
abi: abi,
abi: contractAbi,
functionName: methodName,
address: addressHash as `0x${ string }`,
value: value as undefined,
});

return { hash };
}, [ isConnected, chain, abi, walletClient, addressHash, switchNetworkAsync ]);
}, [ isConnected, chain, contractAbi, walletClient, addressHash, switchNetworkAsync ]);

const renderContent = React.useCallback((item: SmartContractWriteMethod, index: number, id: number) => {
return (
Expand Down
Original file line number Diff line number Diff line change
@@ -1,29 +1,18 @@
import { useQueryClient } from '@tanstack/react-query';
import type { Abi } from 'abitype';
import React from 'react';

import type { Address } from 'types/api/address';
import type { SmartContract } from 'types/api/contract';

import useApiQuery, { getResourceKey } from 'lib/api/useApiQuery';

type ProviderProps = {
interface Params {
addressHash?: string;
children: React.ReactNode;
isProxy?: boolean;
isCustomAbi?: boolean;
}

type TContractContext = {
contractInfo: SmartContract | undefined;
proxyInfo: SmartContract | undefined;
customInfo: SmartContract | undefined;
};

const ContractContext = React.createContext<TContractContext>({
proxyInfo: undefined,
contractInfo: undefined,
customInfo: undefined,
});

export function ContractContextProvider({ addressHash, children }: ProviderProps) {
export default function useContractAbi({ addressHash, isProxy, isCustomAbi }: Params): Abi | undefined {
const queryClient = useQueryClient();

const { data: contractInfo } = useApiQuery('contract', {
Expand Down Expand Up @@ -55,23 +44,15 @@ export function ContractContextProvider({ addressHash, children }: ProviderProps
},
});

const value = React.useMemo(() => ({
proxyInfo,
contractInfo,
customInfo,
} as TContractContext), [ proxyInfo, contractInfo, customInfo ]);
return React.useMemo(() => {
if (isProxy) {
return proxyInfo?.abi ?? undefined;
}

return (
<ContractContext.Provider value={ value }>
{ children }
</ContractContext.Provider>
);
}
if (isCustomAbi) {
return customInfo;
}

export function useContractContext() {
const context = React.useContext(ContractContext);
if (context === undefined) {
throw new Error('useContractContext must be used within a ContractContextProvider');
}
return context;
return contractInfo?.abi ?? undefined;
}, [ contractInfo?.abi, customInfo, isCustomAbi, isProxy, proxyInfo?.abi ]);
}
4 changes: 2 additions & 2 deletions ui/pages/Address.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -86,11 +86,11 @@ const AddressPageContent = () => {

return 'Contract';
},
component: <AddressContract tabs={ contractTabs } addressHash={ hash }/>,
component: <AddressContract tabs={ contractTabs }/>,
subTabs: contractTabs.map(tab => tab.id),
} : undefined,
].filter(Boolean);
}, [ addressQuery.data, contractTabs, hash ]);
}, [ addressQuery.data, contractTabs ]);

const tags = (
<EntityTags
Expand Down
2 changes: 1 addition & 1 deletion ui/pages/Token.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -178,7 +178,7 @@ const TokenPageContent = () => {

return 'Contract';
},
component: <AddressContract tabs={ contractTabs } addressHash={ hashString }/>,
component: <AddressContract tabs={ contractTabs }/>,
subTabs: contractTabs.map(tab => tab.id),
} : undefined,
].filter(Boolean);
Expand Down

0 comments on commit d1cb771

Please sign in to comment.