Commit f672d23b authored by tom goriunov's avatar tom goriunov Committed by GitHub

Support verification of Stylus contracts (#2450)

* add Arbitrum Sepolia dev preset

* base form layout

* add validation for repo url and commit hash

* improve global error text

* show stylus contract info

* add test for contract verification form

* hide "open in IDE" for stylus contracts

* [skip ci] adjust validation rules for GitHub URL

* change contract info items order

* fix error text styles
parent 16856107
......@@ -12,6 +12,7 @@ on:
- none
- arbitrum
- arbitrum_nova
- arbitrum_sepolia
- base
- celo_alfajores
- garnet
......
......@@ -12,6 +12,7 @@ on:
- none
- arbitrum
- arbitrum_nova
- arbitrum_sepolia
- base
- celo_alfajores
- garnet
......
......@@ -360,6 +360,7 @@
"main",
"localhost",
"arbitrum",
"arbitrum_sepolia",
"base",
"celo_alfajores",
"garnet",
......
# Set of ENVs for Arbitrum Sepolia network explorer
# https://arbitrum-sepolia.blockscout.com
# This is an auto-generated file. To update all values, run "yarn dev:preset:sync --name=arbitrum_sepolia"
# 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_ADMIN_SERVICE_API_HOST=https://admin-rs.services.blockscout.com
NEXT_PUBLIC_API_BASE_PATH=/
NEXT_PUBLIC_API_HOST=arbitrum-sepolia.blockscout.com
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_FEATURED_NETWORKS=https://raw.githubusercontent.com/blockscout/frontend-configs/main/configs/featured-networks/arbitrum-sepolia.json
NEXT_PUBLIC_GRAPHIQL_TRANSACTION=0xb730960249381c72588024f5e213abd8e032d968aeb9629103e70677b0850bfa
NEXT_PUBLIC_HAS_USER_OPS=true
NEXT_PUBLIC_HOMEPAGE_CHARTS=['daily_txs']
NEXT_PUBLIC_HOMEPAGE_HERO_BANNER_CONFIG={'background':['rgba(27, 74, 221, 1)']}
NEXT_PUBLIC_IS_ACCOUNT_SUPPORTED=true
NEXT_PUBLIC_IS_TESTNET=true
NEXT_PUBLIC_LOGOUT_URL=https://blockscout-arbitrum.us.auth0.com/v2/logout
NEXT_PUBLIC_MARKETPLACE_ENABLED=false
NEXT_PUBLIC_METADATA_SERVICE_API_HOST=https://metadata.services.blockscout.com
NEXT_PUBLIC_NETWORK_CURRENCY_DECIMALS=18
NEXT_PUBLIC_NETWORK_CURRENCY_NAME=ETH
NEXT_PUBLIC_NETWORK_CURRENCY_SYMBOL=ETH
NEXT_PUBLIC_NETWORK_ICON=https://raw.githubusercontent.com/blockscout/frontend-configs/main/configs/network-icons/arbitrum-sepolia.svg
NEXT_PUBLIC_NETWORK_ICON_DARK=https://raw.githubusercontent.com/blockscout/frontend-configs/main/configs/network-icons/arbitrum-sepolia-dark.svg
NEXT_PUBLIC_NETWORK_ID=421614
NEXT_PUBLIC_NETWORK_LOGO=https://raw.githubusercontent.com/blockscout/frontend-configs/main/configs/network-logos/arbitrum-sepolia.svg
NEXT_PUBLIC_NETWORK_LOGO_DARK=https://raw.githubusercontent.com/blockscout/frontend-configs/main/configs/network-logos/arbitrum-sepolia-dark.svg
NEXT_PUBLIC_NETWORK_NAME=Arbitrum Sepolia
NEXT_PUBLIC_NETWORK_RPC_URL=https://sepolia-rollup.arbitrum.io/rpc
NEXT_PUBLIC_NETWORK_SHORT_NAME=Arbitrum Sepolia
NEXT_PUBLIC_OG_ENHANCED_DATA_ENABLED=true
NEXT_PUBLIC_OG_IMAGE_URL=https://raw.githubusercontent.com/blockscout/frontend-configs/main/configs/og-images/arbitrum-sepolia.png
NEXT_PUBLIC_ROLLUP_L1_BASE_URL=https://eth-sepolia.blockscout.com
NEXT_PUBLIC_ROLLUP_TYPE=arbitrum
NEXT_PUBLIC_SENTRY_DSN=https://fdcd971162e04694bf03564c5be3d291@o1222505.ingest.sentry.io/4503902500421632
NEXT_PUBLIC_STATS_API_HOST=https://stats-arbitrum-sepolia.k8s-prod-2.blockscout.com
NEXT_PUBLIC_TRANSACTION_INTERPRETATION_PROVIDER=blockscout
NEXT_PUBLIC_VISUALIZE_API_HOST=https://visualizer.services.blockscout.com
\ No newline at end of file
export default function formatLanguageName(language: string) {
return language.replace(/_/g, ' ').replace(/\b\w/g, char => char.toUpperCase());
}
......@@ -106,6 +106,19 @@ export const zkSync: SmartContract = {
optimization_runs: 's',
};
export const stylusRust: SmartContract = {
...verified,
language: 'stylus_rust',
github_repository_metadata: {
commit: 'af5029f822815e32def0015bf8e591e769c62f34',
path_prefix: 'examples/erc20',
repository_url: 'https://github.com/blockscout/cargo-stylus-test-examples',
},
compiler_version: 'v0.5.6',
package_name: 'erc20',
evm_version: null,
};
export const nonVerified: SmartContract = {
is_verified: false,
is_blueprint: false,
......
......@@ -47,10 +47,36 @@ export const contract2: VerifiedContract = {
license_type: 'bsd_3_clause',
};
export const contract3: VerifiedContract = {
address: {
ens_domain_name: null,
hash: '0xf145e3A26c6706F64d95Dc8d9d45022D8b3D676B',
implementations: [],
is_contract: true,
is_verified: true,
metadata: null,
name: 'StylusTestToken',
private_tags: [],
public_tags: [],
watchlist_names: [],
},
certified: false,
coin_balance: '0',
compiler_version: 'v0.5.6',
has_constructor_args: false,
language: 'stylus_rust',
license_type: 'none',
market_cap: null,
optimization_enabled: false,
transaction_count: 0,
verified_at: '2024-12-03T14:05:42.796224Z',
};
export const baseResponse: VerifiedContractsResponse = {
items: [
contract1,
contract2,
contract3,
],
next_page_params: {
items_count: '50',
......
......@@ -56,6 +56,9 @@ export function app(): CspDev.DirectiveDescriptor {
// github (spec for api-docs page)
'raw.githubusercontent.com',
// github api (used for Stylus contract verification)
'api.github.com',
].filter(Boolean),
'script-src': [
......
......@@ -13,6 +13,7 @@ export function monaco(): CspDev.DirectiveDescriptor {
'https://cdn.jsdelivr.net/npm/monaco-editor@0.33.0/min/vs/basic-languages/elixir/elixir.js',
'https://cdn.jsdelivr.net/npm/monaco-editor@0.33.0/min/vs/basic-languages/javascript/javascript.js',
'https://cdn.jsdelivr.net/npm/monaco-editor@0.33.0/min/vs/basic-languages/typescript/typescript.js',
'https://cdn.jsdelivr.net/npm/monaco-editor@0.33.0/min/vs/basic-languages/rust/rust.js',
'https://cdn.jsdelivr.net/npm/monaco-editor@0.33.0/min/vs/language/json/jsonMode.js',
'https://cdn.jsdelivr.net/npm/monaco-editor@0.33.0/min/vs/language/json/jsonWorker.js',
'https://cdn.jsdelivr.net/npm/monaco-editor@0.33.0/min/vs/language/typescript/tsMode.js',
......
......@@ -4,6 +4,7 @@ import path from 'path';
/* eslint-disable no-console */
const PRESETS = {
arbitrum: 'https://arbitrum.blockscout.com',
arbitrum_sepolia: 'https://arbitrum-sepolia.blockscout.com',
base: 'https://base.blockscout.com',
blackfort_testnet: 'https://blackfort-testnet.blockscout.com',
celo_alfajores: 'https://celo-alfajores.blockscout.com',
......
......@@ -73,6 +73,12 @@ export interface SmartContract {
license_type: SmartContractLicenseType | null;
certified?: boolean;
zk_compiler_version?: string;
github_repository_metadata?: {
commit?: string;
path_prefix?: string;
repository_url?: string;
};
package_name?: string;
}
export type SmartContractDecodedConstructorArg = [
......@@ -92,13 +98,14 @@ export interface SmartContractExternalLibrary {
// VERIFICATION
export type SmartContractVerificationMethodApi = 'flattened-code' | 'standard-input' | 'sourcify' | 'multi-part'
| 'vyper-code' | 'vyper-multi-part' | 'vyper-standard-input';
| 'vyper-code' | 'vyper-multi-part' | 'vyper-standard-input' | 'stylus-github-repository';
export interface SmartContractVerificationConfigRaw {
solidity_compiler_versions: Array<string>;
solidity_evm_versions: Array<string>;
verification_options: Array<string>;
vyper_compiler_versions: Array<string>;
stylus_compiler_versions?: Array<string>;
vyper_evm_versions: Array<string>;
is_rust_verifier_microservice_enabled: boolean;
license_types: Record<SmartContractLicenseType, number>;
......
......@@ -6,7 +6,7 @@ export interface VerifiedContract {
certified?: boolean;
coin_balance: string;
compiler_version: string | null;
language: 'vyper' | 'yul' | 'solidity';
language: 'vyper' | 'yul' | 'solidity' | 'stylus_rust';
has_constructor_args: boolean;
optimization_enabled: boolean;
transaction_count: number | null;
......
......@@ -5,6 +5,7 @@ import type { SmartContract } from 'types/api/contract';
import { route } from 'nextjs-routes';
import formatLanguageName from 'lib/contracts/formatLanguageName';
import CopyToClipboard from 'ui/shared/CopyToClipboard';
import LinkInternal from 'ui/shared/links/LinkInternal';
import CodeEditor from 'ui/shared/monaco/CodeEditor';
......@@ -24,6 +25,10 @@ function getEditorData(contractInfo: SmartContract | undefined) {
return 'vy';
case 'yul':
return 'yul';
case 'scilla':
return 'scilla';
case 'stylus_rust':
return 'rs';
default:
return 'sol';
}
......@@ -51,7 +56,7 @@ export const ContractSourceCode = ({ data, isLoading, sourceAddress }: Props) =>
<Skeleton isLoaded={ !isLoading } fontWeight={ 500 }>
<span>Contract source code</span>
{ data?.language &&
<Text whiteSpace="pre" as="span" variant="secondary" textTransform="capitalize"> ({ data.language })</Text> }
<Text whiteSpace="pre" as="span" variant="secondary"> ({ formatLanguageName(data.language) })</Text> }
</Skeleton>
);
......@@ -73,7 +78,9 @@ export const ContractSourceCode = ({ data, isLoading, sourceAddress }: Props) =>
</Tooltip>
) : null;
const ides = <ContractCodeIdes hash={ sourceAddress } isLoading={ isLoading }/>;
const ides = data?.language && [ 'solidity', 'vyper', 'yul' ].includes(data.language) ?
<ContractCodeIdes hash={ sourceAddress } isLoading={ isLoading }/> :
null;
const copyToClipboard = data && editorData?.length === 1 ? (
<CopyToClipboard
......
......@@ -31,6 +31,18 @@ test('zkSync contract', async({ render, mockEnvs }) => {
await expect(component).toHaveScreenshot();
});
test('stylus rust contract', async({ render, mockEnvs }) => {
await mockEnvs(ENVS_MAP.zkSyncRollup);
const props = {
data: contractMock.stylusRust,
isLoading: false,
addressHash: addressMock.contract.hash,
};
const component = await render(<ContractDetailsInfo { ...props }/>);
await expect(component).toHaveScreenshot();
});
test.describe('with audits feature', () => {
test.beforeEach(async({ mockEnvs }) => {
......
......@@ -6,6 +6,7 @@ import type { SmartContract } from 'types/api/contract';
import config from 'configs/app';
import { CONTRACT_LICENSES } from 'lib/contracts/licenses';
import dayjs from 'lib/date/dayjs';
import { getGitHubOwnerAndRepo } from 'ui/contractVerification/utils';
import ContractCertifiedLabel from 'ui/shared/ContractCertifiedLabel';
import LinkExternal from 'ui/shared/links/LinkExternal';
......@@ -45,6 +46,25 @@ const ContractDetailsInfo = ({ data, isLoading, addressHash }: Props) => {
);
})();
const sourceCodeLink = (() => {
if (!data.github_repository_metadata?.repository_url || !data.github_repository_metadata?.commit) {
return null;
}
const { owner, repo } = getGitHubOwnerAndRepo(data.github_repository_metadata.repository_url) || {};
const repoUrl = data.github_repository_metadata.repository_url;
const commit = data.github_repository_metadata.commit;
const pathPrefix = data.github_repository_metadata.path_prefix;
return (
<LinkExternal href={ `${ repoUrl }/tree/${ commit }${ pathPrefix ? `/${ pathPrefix }` : '' }` }>
{ owner && repo ? `${ owner }/${ repo }` : data.github_repository_metadata.repository_url }
</LinkExternal>
);
})();
const isStylusContract = data.language === 'stylus_rust';
return (
<Grid templateColumns={{ base: '1fr', lg: '1fr 1fr' }} rowGap={ 4 } columnGap={ 6 } mb={ 8 }>
{ data.name && (
......@@ -84,20 +104,27 @@ const ContractDetailsInfo = ({ data, isLoading, addressHash }: Props) => {
isLoading={ isLoading }
/>
) }
{ typeof data.optimization_enabled === 'boolean' && (
{ typeof data.optimization_enabled === 'boolean' && !isStylusContract && (
<ContractDetailsInfoItem
label="Optimization enabled"
content={ data.optimization_enabled ? 'true' : 'false' }
isLoading={ isLoading }
/>
) }
{ data.optimization_runs !== null && (
{ data.optimization_runs !== null && !isStylusContract && (
<ContractDetailsInfoItem
label={ rollupFeature.isEnabled && rollupFeature.type === 'zkSync' ? 'Optimization mode' : 'Optimization runs' }
content={ String(data.optimization_runs) }
isLoading={ isLoading }
/>
) }
{ data.package_name && (
<ContractDetailsInfoItem
label="Package name"
content={ data.package_name }
isLoading={ isLoading }
/>
) }
{ data.verified_at && (
<ContractDetailsInfoItem
label="Verified at"
......@@ -106,7 +133,7 @@ const ContractDetailsInfo = ({ data, isLoading, addressHash }: Props) => {
isLoading={ isLoading }
/>
) }
{ data.file_path && (
{ data.file_path && !isStylusContract && (
<ContractDetailsInfoItem
label="Contract file path"
content={ data.file_path }
......@@ -114,6 +141,13 @@ const ContractDetailsInfo = ({ data, isLoading, addressHash }: Props) => {
isLoading={ isLoading }
/>
) }
{ sourceCodeLink && (
<ContractDetailsInfoItem
label="Source code"
content={ sourceCodeLink }
isLoading={ isLoading }
/>
) }
{ config.UI.hasContractAuditReports && (
<ContractDetailsInfoItem
label="Security audit"
......
......@@ -230,3 +230,74 @@ test('verification of zkSync contract', async({ render, mockEnvs }) => {
await expect(component).toHaveScreenshot();
});
test('verification of stylus rust contract', async({ render, page }) => {
const stylusRustFormConfig: SmartContractVerificationConfig = {
...formConfig,
stylus_compiler_versions: [ 'v0.5.0', 'v0.5.1', 'v0.5.2', 'v0.5.3' ],
verification_options: formConfig.verification_options.concat([ 'stylus-github-repository' ]),
};
const component = await render(<ContractVerificationForm config={ stylusRustFormConfig } hash={ hash }/>, { hooksConfig });
// select method
await component.getByLabel(/verification method/i).focus();
await component.getByLabel(/verification method/i).fill('stylus');
await page.getByRole('button', { name: /stylus/i }).click();
// check validation of github repository field
const githubRepositoryField = component.getByLabel(/github repository url/i);
await githubRepositoryField.focus();
await githubRepositoryField.fill('https://example.com');
await githubRepositoryField.blur();
await expect(component.getByText(/invalid github repository url/i)).toBeVisible();
const DUCK_COMMIT_HASH = '45dd018a19fff2651eb3c23037427a7531af6588';
const GOOSE_COMMIT_HASH = 'f7e5629';
await page.route('https://api.github.com/repos/tom2drum/not-duck/commits?per_page=1', (route) => {
return route.fulfill({ status: 404 });
}, { times: 1 });
await page.route('https://api.github.com/repos/tom2drum/duck/commits?per_page=1', (route) => {
return route.fulfill({ status: 200, json: [ { sha: DUCK_COMMIT_HASH } ] });
}, { times: 1 });
await githubRepositoryField.focus();
await githubRepositoryField.fill('https://github.com/tom2drum/not-duck');
await githubRepositoryField.blur();
await expect(component.getByText(/github repository not found/i)).toBeVisible();
await githubRepositoryField.focus();
await githubRepositoryField.fill('https://github.com/tom2drum/duck');
await githubRepositoryField.blur();
await expect(component.getByText(/github repository not found/i)).toBeHidden();
await expect(component.getByText(/we have found the latest commit hash/i)).toBeVisible();
await expect(component.getByText(DUCK_COMMIT_HASH.slice(0, 7))).toBeVisible();
// check validation of commit hash field
await page.route(`https://api.github.com/repos/tom2drum/duck/commits/${ GOOSE_COMMIT_HASH }`, (route) => {
return route.fulfill({ status: 404 });
}, { times: 1 });
await page.route(`https://api.github.com/repos/tom2drum/goose/commits/${ GOOSE_COMMIT_HASH }`, (route) => {
return route.fulfill({ status: 200, json: { sha: GOOSE_COMMIT_HASH } });
}, { times: 1 });
await page.route('https://api.github.com/repos/tom2drum/goose/commits?per_page=1', (route) => {
return route.fulfill({ status: 200, json: [ { sha: GOOSE_COMMIT_HASH } ] });
}, { times: 1 });
const commitHashField = component.getByLabel(/commit hash/i);
await commitHashField.focus();
await commitHashField.fill(GOOSE_COMMIT_HASH);
await commitHashField.blur();
await expect(component.getByText(/commit hash not found in the repository/i)).toBeVisible();
await githubRepositoryField.focus();
await githubRepositoryField.fill('https://github.com/tom2drum/goose');
await githubRepositoryField.blur();
await expect(component.getByText(/commit hash not found in the repository/i)).toBeHidden();
await expect(component).toHaveScreenshot();
});
import { Button, Grid, chakra, useUpdateEffect } from '@chakra-ui/react';
import { Button, Grid, Text, chakra, useUpdateEffect } from '@chakra-ui/react';
import React from 'react';
import type { SubmitHandler } from 'react-hook-form';
import { useForm, FormProvider } from 'react-hook-form';
......@@ -11,6 +11,7 @@ import type { SmartContractVerificationConfig } from 'types/client/contract';
import { route } from 'nextjs-routes';
import useApiFetch from 'lib/api/useApiFetch';
import capitalizeFirstLetter from 'lib/capitalizeFirstLetter';
import delay from 'lib/delay';
import getErrorObjStatusCode from 'lib/errors/getErrorObjStatusCode';
import useToast from 'lib/hooks/useToast';
......@@ -27,6 +28,7 @@ import ContractVerificationSolidityFoundry from './methods/ContractVerificationS
import ContractVerificationSolidityHardhat from './methods/ContractVerificationSolidityHardhat';
import ContractVerificationSourcify from './methods/ContractVerificationSourcify';
import ContractVerificationStandardInput from './methods/ContractVerificationStandardInput';
import ContractVerificationStylusGitHubRepo from './methods/ContractVerificationStylusGitHubRepo';
import ContractVerificationVyperContract from './methods/ContractVerificationVyperContract';
import ContractVerificationVyperMultiPartFile from './methods/ContractVerificationVyperMultiPartFile';
import ContractVerificationVyperStandardInput from './methods/ContractVerificationVyperStandardInput';
......@@ -43,7 +45,7 @@ const ContractVerificationForm = ({ method: methodFromQuery, config, hash }: Pro
mode: 'onBlur',
defaultValues: getDefaultValues(methodFromQuery, config, hash, null),
});
const { handleSubmit, watch, formState, setError, reset, getFieldState } = formApi;
const { handleSubmit, watch, formState, setError, reset, getFieldState, getValues, clearErrors } = formApi;
const submitPromiseResolver = React.useRef<(value: unknown) => void>();
const methodNameRef = React.useRef<string>();
......@@ -89,13 +91,26 @@ const ContractVerificationForm = ({ method: methodFromQuery, config, hash }: Pro
});
}, [ apiFetch, hash, setError ]);
const handleFormChange = React.useCallback(() => {
clearErrors('root');
}, [ clearErrors ]);
const address = watch('address');
const addressState = getFieldState('address');
const handleNewSocketMessage: SocketMessage.ContractVerification['handler'] = React.useCallback(async(payload) => {
if (payload.status === 'error') {
const errors = formatSocketErrors(payload.errors);
errors.filter(Boolean).forEach(([ field, error ]) => setError(field, error));
const existingErrors = errors.filter(Boolean).filter(([ field ]) => getValues(field));
if (existingErrors.length) {
existingErrors.forEach(([ field, error ]) => setError(field, error));
} else {
const globalErrors = Object.entries(payload.errors).map(([ , value ]) => value.join(', '));
const rootError = capitalizeFirstLetter(globalErrors.join('\n\n'));
setError('root', { message: rootError });
}
await delay(100); // have to wait a little bit, otherwise isSubmitting status will not be updated
submitPromiseResolver.current?.(null);
return;
......@@ -117,7 +132,7 @@ const ContractVerificationForm = ({ method: methodFromQuery, config, hash }: Pro
);
window.location.assign(route({ pathname: '/address/[hash]', query: { hash: address, tab: 'contract' } }));
}, [ setError, toast, address ]);
}, [ setError, toast, address, getValues ]);
const handleSocketError = React.useCallback(() => {
if (!formState.isSubmitting) {
......@@ -164,6 +179,7 @@ const ContractVerificationForm = ({ method: methodFromQuery, config, hash }: Pro
'vyper-standard-input': <ContractVerificationVyperStandardInput/>,
'solidity-hardhat': <ContractVerificationSolidityHardhat config={ config }/>,
'solidity-foundry': <ContractVerificationSolidityFoundry/>,
'stylus-github-repository': <ContractVerificationStylusGitHubRepo/>,
};
}, [ config ]);
const method = watch('method');
......@@ -187,6 +203,7 @@ const ContractVerificationForm = ({ method: methodFromQuery, config, hash }: Pro
<chakra.form
noValidate
onSubmit={ handleSubmit(onFormSubmit) }
onChange={ handleFormChange }
>
<Grid as="section" columnGap="30px" rowGap={{ base: 2, lg: 5 }} templateColumns={{ base: '1fr', lg: 'minmax(auto, 680px) minmax(0, 340px)' }}>
{ !hash && <ContractVerificationFieldAddress/> }
......@@ -194,6 +211,7 @@ const ContractVerificationForm = ({ method: methodFromQuery, config, hash }: Pro
<ContractVerificationFieldMethod methods={ config.verification_options }/>
</Grid>
{ content }
{ formState.errors.root?.message && <Text color="error"mt={ 4 } fontSize="sm" whiteSpace="pre-wrap">{ formState.errors.root.message }</Text> }
{ Boolean(method) && method.value !== 'solidity-hardhat' && method.value !== 'solidity-foundry' && (
<Button
variant="solid"
......
import { chakra, Code, Link } from '@chakra-ui/react';
import React from 'react';
import { useFormContext } from 'react-hook-form';
import type { FormFields } from '../types';
import delay from 'lib/delay';
import useFetch from 'lib/hooks/useFetch';
import FormFieldText from 'ui/shared/forms/fields/FormFieldText';
import ContractVerificationFormRow from '../ContractVerificationFormRow';
import { getGitHubOwnerAndRepo } from '../utils';
const COMMIT_HASH_PATTERN = /^([a-f0-9]{40}|[a-f0-9]{7})$/;
interface Props {
latestCommitHash: string | undefined;
}
const ContractVerificationFieldCommit = ({ latestCommitHash }: Props) => {
const hashErrorRef = React.useRef<string | undefined>(undefined);
const fetch = useFetch();
const { getValues, trigger, setValue, getFieldState } = useFormContext<FormFields>();
const handleUseLatestCommitClick = React.useCallback(() => {
if (latestCommitHash) {
setValue('commit_hash', latestCommitHash);
trigger('commit_hash');
}
}, [ setValue, trigger, latestCommitHash ]);
const handleBlur = React.useCallback(async() => {
await delay(100); // have to wait to properly trigger subsequent validation
const repositoryUrlValue = getValues('repository_url');
const repositoryUrlState = getFieldState('repository_url');
if (!repositoryUrlValue || repositoryUrlState.invalid) {
return;
}
const { error } = getFieldState('commit_hash');
if (error && error.type !== 'commitHash') {
return;
}
const commitHash = getValues('commit_hash');
if (!commitHash) {
hashErrorRef.current = undefined;
trigger('commit_hash');
return;
}
const gitHubData = getGitHubOwnerAndRepo(repositoryUrlValue);
if (gitHubData) {
try {
const response = await fetch<{ sha?: string }, unknown>(
`https://api.github.com/repos/${ gitHubData.owner }/${ gitHubData.repo }/commits/${ commitHash }`,
);
if ('sha' in response) {
hashErrorRef.current = undefined;
trigger('commit_hash');
return;
}
} catch (error) {}
}
hashErrorRef.current = 'Commit hash not found in the repository';
trigger('commit_hash');
}, [ fetch, getValues, trigger, getFieldState ]);
React.useEffect(() => {
if (latestCommitHash) {
// revalidate field every time the latest commit hash changes
// because the repository url field has changed
handleBlur();
}
}, [ handleBlur, latestCommitHash ]);
const commitHashValidator = React.useCallback(() => {
return hashErrorRef.current ? hashErrorRef.current : true;
}, []);
const rules = React.useMemo(() => {
return {
validate: {
commitHash: commitHashValidator,
},
pattern: COMMIT_HASH_PATTERN,
};
}, [ commitHashValidator ]);
return (
<ContractVerificationFormRow>
<FormFieldText<FormFields>
name="commit_hash"
placeholder="Commit hash"
isRequired
size={{ base: 'md', lg: 'lg' }}
onBlur={ handleBlur }
rules={ rules }
/>
{ latestCommitHash && (
<chakra.div>
<span >We have found the latest commit hash for the repository: </span>
<Code color="text_secondary">{ latestCommitHash.slice(0, 7) }</Code>
<span>. If you want to use it, </span>
<Link onClick={ handleUseLatestCommitClick }>click here</Link>
<span>.</span>
</chakra.div>
) }
</ContractVerificationFormRow>
);
};
export default React.memo(ContractVerificationFieldCommit);
......@@ -16,9 +16,10 @@ const OPTIONS_LIMIT = 50;
interface Props {
isVyper?: boolean;
isStylus?: boolean;
}
const ContractVerificationFieldCompiler = ({ isVyper }: Props) => {
const ContractVerificationFieldCompiler = ({ isVyper, isStylus }: Props) => {
const [ isNightly, setIsNightly ] = React.useState(false);
const { formState, getValues, resetField } = useFormContext<FormFields>();
const queryClient = useQueryClient();
......@@ -32,9 +33,19 @@ const ContractVerificationFieldCompiler = ({ isVyper }: Props) => {
setIsNightly(prev => !prev);
}, [ getValues, isNightly, resetField ]);
const options = React.useMemo(() => (
(isVyper ? config?.vyper_compiler_versions : config?.solidity_compiler_versions)?.map((option) => ({ label: option, value: option })) || []
), [ config?.solidity_compiler_versions, config?.vyper_compiler_versions, isVyper ]);
const options = React.useMemo(() => {
const versions = (() => {
if (isStylus) {
return config?.stylus_compiler_versions;
}
if (isVyper) {
return config?.vyper_compiler_versions;
}
return config?.solidity_compiler_versions;
})();
return versions?.map((option) => ({ label: option, value: option })) || [];
}, [ isStylus, isVyper, config?.solidity_compiler_versions, config?.stylus_compiler_versions, config?.vyper_compiler_versions ]);
const loadOptions = React.useCallback(async(inputValue: string) => {
return options
......@@ -46,7 +57,7 @@ const ContractVerificationFieldCompiler = ({ isVyper }: Props) => {
return (
<ContractVerificationFormRow>
<>
{ !isVyper && (
{ !isVyper && !isStylus && (
<Checkbox
size="lg"
mb={ 2 }
......@@ -66,7 +77,7 @@ const ContractVerificationFieldCompiler = ({ isVyper }: Props) => {
isAsync
/>
</>
{ isVyper ? null : (
{ isVyper || isStylus ? null : (
<chakra.div mt={{ base: 0, lg: 8 }}>
<span >The compiler version is specified in </span>
<Code color="text_secondary">pragma solidity X.X.X</Code>
......
import _get from 'lodash/get';
import React from 'react';
import { useFormContext } from 'react-hook-form';
import type { FormFields } from '../types';
import delay from 'lib/delay';
import useFetch from 'lib/hooks/useFetch';
import FormFieldUrl from 'ui/shared/forms/fields/FormFieldUrl';
import ContractVerificationFormRow from '../ContractVerificationFormRow';
import { getGitHubOwnerAndRepo } from '../utils';
interface Props {
onCommitHashChange: (commitHash?: string) => void;
}
const ContractVerificationFieldGitHubRepo = ({ onCommitHashChange }: Props) => {
const repoErrorRef = React.useRef<string | undefined>(undefined);
const fetch = useFetch();
const { getValues, trigger, getFieldState } = useFormContext<FormFields>();
const handleBlur = React.useCallback(async() => {
await delay(100); // have to wait to properly trigger subsequent validation
const repositoryUrl = getValues('repository_url');
const { error } = getFieldState('repository_url');
if (error && error.type !== 'repoUrl') {
return;
}
if (!repositoryUrl) {
repoErrorRef.current = undefined;
trigger('repository_url');
onCommitHashChange();
return;
}
const gitHubData = getGitHubOwnerAndRepo(repositoryUrl);
if (gitHubData && gitHubData.rest.length === 0 && !gitHubData.url.search && !gitHubData.url.hash) {
try {
const response = await fetch(`https://api.github.com/repos/${ gitHubData.owner }/${ gitHubData.repo }/commits?per_page=1`);
repoErrorRef.current = undefined;
trigger('repository_url');
onCommitHashChange(_get(response, '[0].sha'));
return;
} catch (error) {
repoErrorRef.current = 'GitHub repository not found';
}
} else {
repoErrorRef.current = 'Invalid GitHub repository URL';
}
trigger('repository_url');
onCommitHashChange();
}, [ fetch, getValues, getFieldState, onCommitHashChange, trigger ]);
const repoUrlValidator = React.useCallback(() => {
return repoErrorRef.current ? repoErrorRef.current : true;
}, []);
const rules = React.useMemo(() => {
return {
validate: {
repoUrl: repoUrlValidator,
},
};
}, [ repoUrlValidator ]);
return (
<ContractVerificationFormRow>
<FormFieldUrl<FormFields>
name="repository_url"
placeholder="GitHub repository URL"
isRequired
size={{ base: 'md', lg: 'lg' }}
onBlur={ handleBlur }
rules={ rules }
/>
</ContractVerificationFormRow>
);
};
export default React.memo(ContractVerificationFieldGitHubRepo);
......@@ -79,6 +79,8 @@ const ContractVerificationFieldMethod = ({ methods }: Props) => {
return <ListItem key={ method }>Verification through Hardhat plugin.</ListItem>;
case 'solidity-foundry':
return <ListItem key={ method }>Verification through Foundry.</ListItem>;
case 'stylus-github-repository':
return <ListItem key={ method }>Verification of Stylus contract via GitHub repository.</ListItem>;
}
}, []);
......
import React from 'react';
import type { FormFields } from '../types';
import FormFieldText from 'ui/shared/forms/fields/FormFieldText';
import ContractVerificationFormRow from '../ContractVerificationFormRow';
import ContractVerificationMethod from '../ContractVerificationMethod';
import ContractVerificationFieldCommit from '../fields/ContractVerificationFieldCommit';
import ContractVerificationFieldCompiler from '../fields/ContractVerificationFieldCompiler';
import ContractVerificationFieldGitHubRepo from '../fields/ContractVerificationFieldGitHubRepo';
const ContractVerificationStylusGitHubRepo = () => {
const [ latestCommitHash, setLatestCommitHash ] = React.useState<string | undefined>(undefined);
return (
<ContractVerificationMethod title="Contract verification via Stylus (GitHub repository) ">
<ContractVerificationFieldCompiler isStylus/>
<ContractVerificationFieldGitHubRepo onCommitHashChange={ setLatestCommitHash }/>
<ContractVerificationFieldCommit latestCommitHash={ latestCommitHash }/>
<ContractVerificationFormRow>
<FormFieldText<FormFields>
name="path_prefix"
placeholder="Path prefix"
size={{ base: 'md', lg: 'lg' }}
/>
<span>
The crate should be located in the root directory. If it is not the case, please specify the relative path from
the root to the crate directory.
</span>
</ContractVerificationFormRow>
</ContractVerificationMethod>
);
};
export default React.memo(ContractVerificationStylusGitHubRepo);
......@@ -17,9 +17,13 @@ export interface LicenseOption {
value: SmartContractLicenseType;
}
export interface FormFieldsFlattenSourceCode {
interface FormFieldsBase {
address: string;
method: MethodOption;
license_type: LicenseOption | null;
}
export interface FormFieldsFlattenSourceCode extends FormFieldsBase {
is_yul: boolean;
name: string | undefined;
compiler: Option | null;
......@@ -30,80 +34,65 @@ export interface FormFieldsFlattenSourceCode {
autodetect_constructor_args: boolean;
constructor_args: string;
libraries: Array<ContractLibrary>;
license_type: LicenseOption | null;
}
export interface FormFieldsStandardInput {
address: string;
method: MethodOption;
export interface FormFieldsStandardInput extends FormFieldsBase {
name: string;
compiler: Option | null;
sources: Array<File>;
autodetect_constructor_args: boolean;
constructor_args: string;
license_type: LicenseOption | null;
}
export interface FormFieldsStandardInputZk {
address: string;
method: MethodOption;
export interface FormFieldsStandardInputZk extends FormFieldsBase {
name: string;
compiler: Option | null;
zk_compiler: Option | null;
sources: Array<File>;
autodetect_constructor_args: boolean;
constructor_args: string;
license_type: LicenseOption | null;
}
export interface FormFieldsSourcify {
address: string;
method: MethodOption;
export interface FormFieldsSourcify extends FormFieldsBase {
sources: Array<File>;
contract_index?: Option;
license_type: LicenseOption | null;
}
export interface FormFieldsMultiPartFile {
address: string;
method: MethodOption;
export interface FormFieldsMultiPartFile extends FormFieldsBase {
compiler: Option | null;
evm_version: Option | null;
is_optimization_enabled: boolean;
optimization_runs: string;
sources: Array<File>;
libraries: Array<ContractLibrary>;
license_type: LicenseOption | null;
}
export interface FormFieldsVyperContract {
address: string;
method: MethodOption;
export interface FormFieldsVyperContract extends FormFieldsBase {
name: string;
evm_version: Option | null;
compiler: Option | null;
code: string;
constructor_args: string | undefined;
license_type: LicenseOption | null;
}
export interface FormFieldsVyperMultiPartFile {
address: string;
method: MethodOption;
export interface FormFieldsVyperMultiPartFile extends FormFieldsBase {
compiler: Option | null;
evm_version: Option | null;
sources: Array<File>;
interfaces: Array<File>;
license_type: LicenseOption | null;
}
export interface FormFieldsVyperStandardInput {
address: string;
method: MethodOption;
export interface FormFieldsVyperStandardInput extends FormFieldsBase {
compiler: Option | null;
sources: Array<File>;
license_type: LicenseOption | null;
}
export interface FormFieldsStylusGitHubRepo extends FormFieldsBase {
compiler: Option | null;
repository_url: string;
commit_hash: string;
path_prefix: string;
}
export type FormFields = FormFieldsFlattenSourceCode | FormFieldsStandardInput | FormFieldsStandardInputZk | FormFieldsSourcify |
FormFieldsMultiPartFile | FormFieldsVyperContract | FormFieldsVyperMultiPartFile | FormFieldsVyperStandardInput;
FormFieldsMultiPartFile | FormFieldsVyperContract | FormFieldsVyperMultiPartFile | FormFieldsVyperStandardInput | FormFieldsStylusGitHubRepo;
......@@ -8,6 +8,7 @@ import type {
FormFieldsSourcify,
FormFieldsStandardInput,
FormFieldsStandardInputZk,
FormFieldsStylusGitHubRepo,
FormFieldsVyperContract,
FormFieldsVyperMultiPartFile,
FormFieldsVyperStandardInput,
......@@ -19,6 +20,7 @@ import type {
import type { SmartContractVerificationConfig, SmartContractVerificationMethod } from 'types/client/contract';
import type { Params as FetchParams } from 'lib/hooks/useFetch';
import stripLeadingSlash from 'lib/stripLeadingSlash';
export const SUPPORTED_VERIFICATION_METHODS: Array<SmartContractVerificationMethod> = [
'flattened-code',
......@@ -30,6 +32,7 @@ export const SUPPORTED_VERIFICATION_METHODS: Array<SmartContractVerificationMeth
'vyper-code',
'vyper-multi-part',
'vyper-standard-input',
'stylus-github-repository',
];
export const METHOD_LABELS: Record<SmartContractVerificationMethod, string> = {
......@@ -42,6 +45,7 @@ export const METHOD_LABELS: Record<SmartContractVerificationMethod, string> = {
'vyper-standard-input': 'Vyper (Standard JSON input)',
'solidity-hardhat': 'Solidity (Hardhat)',
'solidity-foundry': 'Solidity (Foundry)',
'stylus-github-repository': 'Stylus (GitHub repository)',
};
export const DEFAULT_VALUES: Record<SmartContractVerificationMethod, FormFields> = {
......@@ -153,6 +157,18 @@ export const DEFAULT_VALUES: Record<SmartContractVerificationMethod, FormFields>
sources: [],
license_type: null,
},
'stylus-github-repository': {
address: '',
method: {
value: 'stylus-github-repository' as const,
label: METHOD_LABELS['stylus-github-repository'],
},
compiler: null,
repository_url: '',
commit_hash: '',
path_prefix: '',
license_type: null,
},
};
export function getDefaultValues(
......@@ -318,6 +334,18 @@ export function prepareRequestBody(data: FormFields): FetchParams['body'] {
return body;
}
case 'stylus-github-repository': {
const _data = data as FormFieldsStylusGitHubRepo;
return {
cargo_stylus_version: _data.compiler?.value,
repository_url: _data.repository_url,
commit: _data.commit_hash,
path_prefix: _data.path_prefix,
license_type: _data.license_type?.value ?? defaultLicenseType,
};
}
default: {
return {};
}
......@@ -365,3 +393,16 @@ export function formatSocketErrors(errors: SmartContractVerificationError): Arra
return [ API_ERROR_TO_FORM_FIELD[_key], { message: value.join(',') } ];
});
}
export function getGitHubOwnerAndRepo(repositoryUrl: string) {
try {
const urlObj = new URL(repositoryUrl);
if (urlObj.hostname !== 'github.com') {
throw new Error();
}
const [ owner, repo, ...rest ] = stripLeadingSlash(urlObj.pathname).split('/');
return { owner, repo, rest, url: urlObj };
} catch (error) {
return;
}
}
......@@ -72,6 +72,8 @@ const CodeEditor = ({ data, remappings, libraries, language, mainFile, contractN
return 'sol';
case 'scilla':
return 'scilla';
case 'stylus_rust':
return 'rust';
default:
return 'javascript';
}
......
......@@ -5,6 +5,7 @@ import React from 'react';
import type { VerifiedContract } from 'types/api/contracts';
import config from 'configs/app';
import formatLanguageName from 'lib/contracts/formatLanguageName';
import { CONTRACT_LICENSES } from 'lib/contracts/licenses';
import { currencyUnits } from 'lib/units';
import ContractCertifiedLabel from 'ui/shared/ContractCertifiedLabel';
......@@ -68,9 +69,9 @@ const VerifiedContractsListItem = ({ data, isLoading }: Props) => {
</Skeleton>
</Flex>
<Flex columnGap={ 3 }>
<Skeleton isLoaded={ !isLoading } fontWeight={ 500 } flexShrink="0">Compiler</Skeleton>
<Skeleton isLoaded={ !isLoading } fontWeight={ 500 } flexShrink="0">Language</Skeleton>
<Skeleton isLoaded={ !isLoading } display="flex" flexWrap="wrap">
<Box textTransform="capitalize">{ data.language }</Box>
<Box>{ formatLanguageName(data.language) }</Box>
<Box color="text_secondary" wordBreak="break-all" whiteSpace="pre-wrap"> ({ data.compiler_version })</Box>
</Skeleton>
</Flex>
......
......@@ -45,7 +45,7 @@ const VerifiedContractsTable = ({ data, sort, setSorting, isLoading }: Props) =>
Txs
</Link>
</Th>
<Th width="50%">Compiler / version</Th>
<Th width="50%">Language / Compiler version</Th>
<Th width="80px">Settings</Th>
<Th width="150px">Verified</Th>
<Th width="130px">License</Th>
......
......@@ -5,6 +5,7 @@ import React from 'react';
import type { VerifiedContract } from 'types/api/contracts';
import config from 'configs/app';
import formatLanguageName from 'lib/contracts/formatLanguageName';
import { CONTRACT_LICENSES } from 'lib/contracts/licenses';
import ContractCertifiedLabel from 'ui/shared/ContractCertifiedLabel';
import AddressEntity from 'ui/shared/entities/address/AddressEntity';
......@@ -65,7 +66,7 @@ const VerifiedContractsTableItem = ({ data, isLoading }: Props) => {
</Td>
<Td>
<Flex flexWrap="wrap" columnGap={ 2 }>
<Skeleton isLoaded={ !isLoading } textTransform="capitalize" my={ 1 }>{ data.language }</Skeleton>
<Skeleton isLoaded={ !isLoading } my={ 1 }>{ formatLanguageName(data.language) }</Skeleton>
{ data.compiler_version && (
<Skeleton isLoaded={ !isLoading } color="text_secondary" wordBreak="break-all" my={ 1 } cursor="pointer">
<Tooltip label={ data.compiler_version }>
......
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