diff --git a/.prettierrc.json b/.prettierrc.json index 07681f5b0af..967e2ecc37b 100644 --- a/.prettierrc.json +++ b/.prettierrc.json @@ -1,9 +1,7 @@ { "tabWidth": 2, "printWidth": 500, - "bracketSpacing": false, "useTabs": false, "semi": false, - "singleQuote": true, - "bracketSpacing": false + "singleQuote": true } diff --git a/apps/contract-verification/.babelrc b/apps/contract-verification/.babelrc new file mode 100644 index 00000000000..6df3e5be524 --- /dev/null +++ b/apps/contract-verification/.babelrc @@ -0,0 +1,5 @@ +{ + "presets": ["@babel/preset-env", ["@babel/preset-react", { "runtime": "automatic" }]], + "plugins": ["@babel/plugin-proposal-class-properties", "@babel/plugin-transform-runtime", "@babel/plugin-proposal-nullish-coalescing-operator"], + "ignore": ["**/node_modules/**"] +} diff --git a/apps/contract-verification/.browserslistrc b/apps/contract-verification/.browserslistrc new file mode 100644 index 00000000000..f1d12df4faa --- /dev/null +++ b/apps/contract-verification/.browserslistrc @@ -0,0 +1,16 @@ +# This file is used by: +# 1. autoprefixer to adjust CSS to support the below specified browsers +# 2. babel preset-env to adjust included polyfills +# +# For additional information regarding the format and rule options, please see: +# https://github.com/browserslist/browserslist#queries +# +# If you need to support different browsers in production, you may tweak the list below. + +last 1 Chrome version +last 1 Firefox version +last 2 Edge major versions +last 2 Safari major version +last 2 iOS major versions +Firefox ESR +not IE 9-11 # For IE 9-11 support, remove 'not'. \ No newline at end of file diff --git a/apps/contract-verification/.eslintrc b/apps/contract-verification/.eslintrc new file mode 100644 index 00000000000..be97c53fbbb --- /dev/null +++ b/apps/contract-verification/.eslintrc @@ -0,0 +1,3 @@ +{ + "extends": "../../.eslintrc.json" +} diff --git a/apps/contract-verification/.eslintrc.json b/apps/contract-verification/.eslintrc.json new file mode 100644 index 00000000000..a92d0f887ac --- /dev/null +++ b/apps/contract-verification/.eslintrc.json @@ -0,0 +1,34 @@ +{ + "extends": [ + "plugin:@nrwl/nx/react", + "../../.eslintrc.json" + ], + "ignorePatterns": [ + "!**/*" + ], + "overrides": [ + { + "files": [ + "*.ts", + "*.tsx", + "*.js", + "*.jsx" + ], + "rules": {} + }, + { + "files": [ + "*.ts", + "*.tsx" + ], + "rules": {} + }, + { + "files": [ + "*.js", + "*.jsx" + ], + "rules": {} + } + ] +} \ No newline at end of file diff --git a/apps/contract-verification/project.json b/apps/contract-verification/project.json new file mode 100644 index 00000000000..dee28fe3260 --- /dev/null +++ b/apps/contract-verification/project.json @@ -0,0 +1,69 @@ +{ + "name": "contract-verification", + "$schema": "../../node_modules/nx/schemas/project-schema.json", + "sourceRoot": "apps/contract-verification/src", + "projectType": "application", + "targets": { + "build": { + "executor": "@nrwl/webpack:webpack", + "outputs": ["{options.outputPath}"], + "defaultConfiguration": "development", + "options": { + "compiler": "babel", + "outputPath": "dist/apps/contract-verification", + "index": "apps/contract-verification/src/index.html", + "baseHref": "./", + "main": "apps/contract-verification/src/main.tsx", + "polyfills": "apps/contract-verification/src/polyfills.ts", + "tsConfig": "apps/contract-verification/tsconfig.app.json", + "assets": [ + "apps/contract-verification/src/favicon.ico", + "apps/contract-verification/src/assets", + "apps/contract-verification/src/profile.json" + ], + "styles": ["apps/contract-verification/src/styles.css"], + "scripts": [], + "webpackConfig": "apps/contract-verification/webpack.config.js" + }, + "configurations": { + "development": { + }, + "production": { + "fileReplacements": [ + { + "replace": "apps/contract-verification/src/environments/environment.ts", + "with": "apps/contract-verification/src/environments/environment.prod.ts" + } + ] + } + } + }, + "lint": { + "executor": "@nrwl/linter:eslint", + "outputs": ["{options.outputFile}"], + "options": { + "lintFilePatterns": ["apps/contract-verification/**/*.ts"], + "eslintConfig": "apps/contract-verification/.eslintrc" + } + }, + "serve": { + "executor": "@nrwl/webpack:dev-server", + "defaultConfiguration": "development", + "options": { + "buildTarget": "contract-verification:build", + "hmr": true, + "baseHref": "/" + }, + "configurations": { + "development": { + "buildTarget": "contract-verification:build:development", + "port": 5003 + }, + "production": { + "buildTarget": "contract-verification:build:production" + } + } + } + }, + "tags": [] +} diff --git a/apps/contract-verification/src/app/App.css b/apps/contract-verification/src/app/App.css new file mode 100644 index 00000000000..0ff37a0e7eb --- /dev/null +++ b/apps/contract-verification/src/app/App.css @@ -0,0 +1,10 @@ +html, body, #root { + height: 100%; +} + +body { + margin: 0; +} + +.fa-arrow-up-right-from-square::before { content: "\f08e"; } +.fa-xmark::before { content: "\f00d"; } diff --git a/apps/contract-verification/src/app/AppContext.tsx b/apps/contract-verification/src/app/AppContext.tsx new file mode 100644 index 00000000000..e4ffda0cee8 --- /dev/null +++ b/apps/contract-verification/src/app/AppContext.tsx @@ -0,0 +1,34 @@ +import React from 'react' +import type { ThemeType, Chain, SubmittedContracts, ContractVerificationSettings } from './types' +import { CompilerAbstract } from '@remix-project/remix-solidity' +import { ContractVerificationPluginClient } from './ContractVerificationPluginClient' +import { ContractDropdownSelection } from './components/ContractDropdown' + +// Define the type for the context +type AppContextType = { + themeType: ThemeType + setThemeType: (themeType: ThemeType) => void + clientInstance: ContractVerificationPluginClient + settings: ContractVerificationSettings + setSettings: React.Dispatch> + chains: Chain[] + compilationOutput: { [key: string]: CompilerAbstract } | undefined + submittedContracts: SubmittedContracts + setSubmittedContracts: React.Dispatch> +} + +// Provide a default value with the appropriate types +const defaultContextValue: AppContextType = { + themeType: 'dark', + setThemeType: (themeType: ThemeType) => {}, + clientInstance: {} as ContractVerificationPluginClient, + settings: { chains: {} }, + setSettings: () => {}, + chains: [], + compilationOutput: undefined, + submittedContracts: {}, + setSubmittedContracts: (submittedContracts: SubmittedContracts) => {}, +} + +// Create the context with the type +export const AppContext = React.createContext(defaultContextValue) diff --git a/apps/contract-verification/src/app/ContractVerificationPluginClient.ts b/apps/contract-verification/src/app/ContractVerificationPluginClient.ts new file mode 100644 index 00000000000..8554cb29f6b --- /dev/null +++ b/apps/contract-verification/src/app/ContractVerificationPluginClient.ts @@ -0,0 +1,18 @@ +import { PluginClient } from '@remixproject/plugin' +import { createClient } from '@remixproject/plugin-webview' +import EventManager from 'events' + +export class ContractVerificationPluginClient extends PluginClient { + public internalEvents: EventManager + + constructor() { + super() + this.internalEvents = new EventManager() + createClient(this) + this.onload() + } + + onActivation(): void { + this.internalEvents.emit('verification_activated') + } +} diff --git a/apps/contract-verification/src/app/Verifiers/AbstractVerifier.ts b/apps/contract-verification/src/app/Verifiers/AbstractVerifier.ts new file mode 100644 index 00000000000..480fc5bd9bd --- /dev/null +++ b/apps/contract-verification/src/app/Verifiers/AbstractVerifier.ts @@ -0,0 +1,16 @@ +import { CompilerAbstract } from '@remix-project/remix-solidity' +import type { LookupResponse, SubmittedContract, VerificationResponse } from '../types' + +// Optional function definitions +export interface AbstractVerifier { + verifyProxy(submittedContract: SubmittedContract): Promise + checkVerificationStatus?(receiptId: string): Promise + checkProxyVerificationStatus?(receiptId: string): Promise +} + +export abstract class AbstractVerifier { + constructor(public apiUrl: string, public explorerUrl: string) {} + + abstract verify(submittedContract: SubmittedContract, compilerAbstract: CompilerAbstract): Promise + abstract lookup(contractAddress: string, chainId: string): Promise +} diff --git a/apps/contract-verification/src/app/Verifiers/BlockscoutVerifier.ts b/apps/contract-verification/src/app/Verifiers/BlockscoutVerifier.ts new file mode 100644 index 00000000000..fd7b95563f6 --- /dev/null +++ b/apps/contract-verification/src/app/Verifiers/BlockscoutVerifier.ts @@ -0,0 +1,50 @@ +import { SourceFile } from '../types' +import { EtherscanVerifier } from './EtherscanVerifier' + +// Etherscan and Blockscout return different objects from the getsourcecode method +interface BlockscoutSource { + AdditionalSources: Array<{ SourceCode: string; Filename: string }> + ConstructorArguments: string + OptimizationRuns: number + IsProxy: string + SourceCode: string + ABI: string + ContractName: string + CompilerVersion: string + OptimizationUsed: string + Runs: string + EVMVersion: string + FileName: string + Address: string +} + +export class BlockscoutVerifier extends EtherscanVerifier { + LOOKUP_STORE_DIR = 'blockscout-verified' + + constructor(apiUrl: string) { + // apiUrl and explorerUrl are the same for Blockscout + super(apiUrl, apiUrl, undefined) + } + + getContractCodeUrl(address: string): string { + const url = new URL(this.explorerUrl + `/address/${address}`) + url.searchParams.append('tab', 'contract') + return url.href + } + + processReceivedFiles(source: unknown, contractAddress: string, chainId: string): { sourceFiles: SourceFile[]; targetFilePath?: string } { + const blockscoutSource = source as BlockscoutSource + + const result: SourceFile[] = [] + const filePrefix = `/${this.LOOKUP_STORE_DIR}/${chainId}/${contractAddress}` + + const targetFilePath = `${filePrefix}/${blockscoutSource.FileName}` + result.push({ content: blockscoutSource.SourceCode, path: targetFilePath }) + + for (const additional of blockscoutSource.AdditionalSources ?? []) { + result.push({ content: additional.SourceCode, path: `${filePrefix}/${additional.Filename}` }) + } + + return { sourceFiles: result, targetFilePath } + } +} diff --git a/apps/contract-verification/src/app/Verifiers/EtherscanVerifier.ts b/apps/contract-verification/src/app/Verifiers/EtherscanVerifier.ts new file mode 100644 index 00000000000..8d5a6198a24 --- /dev/null +++ b/apps/contract-verification/src/app/Verifiers/EtherscanVerifier.ts @@ -0,0 +1,289 @@ +import { CompilerAbstract } from '@remix-project/remix-solidity' +import { AbstractVerifier } from './AbstractVerifier' +import type { LookupResponse, SourceFile, SubmittedContract, VerificationResponse, VerificationStatus } from '../types' + +interface EtherscanRpcResponse { + status: '0' | '1' + message: string + result: string +} + +interface EtherscanCheckStatusResponse { + status: '0' | '1' + message: string + result: 'Pending in queue' | 'Pass - Verified' | 'Fail - Unable to verify' | 'Already Verified' | 'Unknown UID' +} + +interface EtherscanSource { + SourceCode: string + ABI: string + ContractName: string + CompilerVersion: string + OptimizationUsed: string + Runs: string + ConstructorArguments: string + EVMVersion: string + Library: string + LicenseType: string + Proxy: string + Implementation: string + SwarmSource: string +} + +interface EtherscanGetSourceCodeResponse { + status: '0' | '1' + message: string + result: EtherscanSource[] +} + +export class EtherscanVerifier extends AbstractVerifier { + LOOKUP_STORE_DIR = 'etherscan-verified' + + constructor(apiUrl: string, explorerUrl: string, protected apiKey?: string) { + super(apiUrl, explorerUrl) + } + + async verify(submittedContract: SubmittedContract, compilerAbstract: CompilerAbstract): Promise { + // TODO: Handle version Vyper contracts. This relies on Solidity metadata. + const metadata = JSON.parse(compilerAbstract.data.contracts[submittedContract.filePath][submittedContract.contractName].metadata) + const formData = new FormData() + formData.append('chainId', submittedContract.chainId) + formData.append('codeformat', 'solidity-standard-json-input') + formData.append('sourceCode', compilerAbstract.input.toString()) + formData.append('contractaddress', submittedContract.address) + formData.append('contractname', submittedContract.filePath + ':' + submittedContract.contractName) + formData.append('compilerversion', `v${metadata.compiler.version}`) + formData.append('constructorArguements', submittedContract.abiEncodedConstructorArgs?.replace('0x', '') ?? '') + + const url = new URL(this.apiUrl + '/api') + url.searchParams.append('module', 'contract') + url.searchParams.append('action', 'verifysourcecode') + if (this.apiKey) { + url.searchParams.append('apikey', this.apiKey) + } + + const response = await fetch(url.href, { + method: 'POST', + body: formData, + }) + + if (!response.ok) { + const responseText = await response.text() + console.error('Error on Etherscan API verification at ' + this.apiUrl + '\nStatus: ' + response.status + '\nResponse: ' + responseText) + throw new Error(responseText) + } + + const verificationResponse: EtherscanRpcResponse = await response.json() + + if (verificationResponse.result.includes('already verified')) { + return { status: 'already verified', receiptId: null, lookupUrl: this.getContractCodeUrl(submittedContract.address) } + } + + if (verificationResponse.status !== '1' || verificationResponse.message !== 'OK') { + console.error('Error on Etherscan API verification at ' + this.apiUrl + '\nStatus: ' + verificationResponse.status + '\nMessage: ' + verificationResponse.message + '\nResult: ' + verificationResponse.result) + throw new Error(verificationResponse.result) + } + + const lookupUrl = this.getContractCodeUrl(submittedContract.address) + return { status: 'pending', receiptId: verificationResponse.result, lookupUrl } + } + + async verifyProxy(submittedContract: SubmittedContract): Promise { + if (!submittedContract.proxyAddress) { + throw new Error('SubmittedContract does not have a proxyAddress') + } + + const formData = new FormData() + formData.append('address', submittedContract.proxyAddress) + formData.append('expectedimplementation', submittedContract.address) + + const url = new URL(this.apiUrl + '/api') + url.searchParams.append('module', 'contract') + url.searchParams.append('action', 'verifyproxycontract') + if (this.apiKey) { + url.searchParams.append('apikey', this.apiKey) + } + + const response = await fetch(url.href, { + method: 'POST', + body: formData, + }) + + if (!response.ok) { + const responseText = await response.text() + console.error('Error on Etherscan API proxy verification at ' + this.apiUrl + '\nStatus: ' + response.status + '\nResponse: ' + responseText) + throw new Error(responseText) + } + + const verificationResponse: EtherscanRpcResponse = await response.json() + + if (verificationResponse.status !== '1' || verificationResponse.message !== 'OK') { + console.error('Error on Etherscan API proxy verification at ' + this.apiUrl + '\nStatus: ' + verificationResponse.status + '\nMessage: ' + verificationResponse.message + '\nResult: ' + verificationResponse.result) + throw new Error(verificationResponse.result) + } + + return { status: 'pending', receiptId: verificationResponse.result } + } + + async checkVerificationStatus(receiptId: string): Promise { + const url = new URL(this.apiUrl + '/api') + url.searchParams.append('module', 'contract') + url.searchParams.append('action', 'checkverifystatus') + url.searchParams.append('guid', receiptId) + if (this.apiKey) { + url.searchParams.append('apikey', this.apiKey) + } + + const response = await fetch(url.href, { method: 'GET' }) + + if (!response.ok) { + const responseText = await response.text() + console.error('Error on Etherscan API check verification status at ' + this.apiUrl + '\nStatus: ' + response.status + '\nResponse: ' + responseText) + throw new Error(responseText) + } + + const checkStatusResponse: EtherscanCheckStatusResponse = await response.json() + + if (checkStatusResponse.result.startsWith('Fail - Unable to verify')) { + return { status: 'failed', receiptId, message: checkStatusResponse.result } + } + if (checkStatusResponse.result === 'Pending in queue') { + return { status: 'pending', receiptId } + } + if (checkStatusResponse.result === 'Pass - Verified') { + return { status: 'verified', receiptId } + } + if (checkStatusResponse.result === 'Already Verified') { + return { status: 'already verified', receiptId } + } + if (checkStatusResponse.result === 'Unknown UID') { + console.error('Error on Etherscan API check verification status at ' + this.apiUrl + '\nStatus: ' + checkStatusResponse.status + '\nMessage: ' + checkStatusResponse.message + '\nResult: ' + checkStatusResponse.result) + return { status: 'failed', receiptId, message: checkStatusResponse.result } + } + + if (checkStatusResponse.status !== '1' || !checkStatusResponse.message.startsWith('OK')) { + console.error('Error on Etherscan API check verification status at ' + this.apiUrl + '\nStatus: ' + checkStatusResponse.status + '\nMessage: ' + checkStatusResponse.message + '\nResult: ' + checkStatusResponse.result) + throw new Error(checkStatusResponse.result) + } + + return { status: 'unknown', receiptId } + } + + async checkProxyVerificationStatus(receiptId: string): Promise { + const url = new URL(this.apiUrl + '/api') + url.searchParams.append('module', 'contract') + url.searchParams.append('action', 'checkproxyverification') + url.searchParams.append('guid', receiptId) + if (this.apiKey) { + url.searchParams.append('apikey', this.apiKey) + } + + const response = await fetch(url.href, { method: 'GET' }) + + if (!response.ok) { + const responseText = await response.text() + console.error('Error on Etherscan API check verification status at ' + this.apiUrl + '\nStatus: ' + response.status + '\nResponse: ' + responseText) + throw new Error(responseText) + } + + const checkStatusResponse: EtherscanRpcResponse = await response.json() + + if (checkStatusResponse.result === 'A corresponding implementation contract was unfortunately not detected for the proxy address.' || checkStatusResponse.result === 'The provided expected results are different than the retrieved implementation address!' || checkStatusResponse.result === 'This contract does not look like it contains any delegatecall opcode sequence.') { + return { status: 'failed', receiptId, message: checkStatusResponse.result } + } + if (checkStatusResponse.result === 'Verification in progress') { + return { status: 'pending', receiptId } + } + if (checkStatusResponse.result.startsWith("The proxy's") && checkStatusResponse.result.endsWith('and is successfully updated.')) { + return { status: 'verified', receiptId } + } + if (checkStatusResponse.result === 'Unknown UID') { + console.error('Error on Etherscan API check proxy verification status at ' + this.apiUrl + '\nStatus: ' + checkStatusResponse.status + '\nMessage: ' + checkStatusResponse.message + '\nResult: ' + checkStatusResponse.result) + return { status: 'failed', receiptId, message: checkStatusResponse.result } + } + + if (checkStatusResponse.status !== '1' || !checkStatusResponse.message.startsWith('OK')) { + console.error('Error on Etherscan API check proxy verification status at ' + this.apiUrl + '\nStatus: ' + checkStatusResponse.status + '\nMessage: ' + checkStatusResponse.message + '\nResult: ' + checkStatusResponse.result) + throw new Error(checkStatusResponse.result) + } + + return { status: 'unknown', receiptId } + } + + async lookup(contractAddress: string, chainId: string): Promise { + const url = new URL(this.apiUrl + '/api') + url.searchParams.append('module', 'contract') + url.searchParams.append('action', 'getsourcecode') + url.searchParams.append('address', contractAddress) + if (this.apiKey) { + url.searchParams.append('apikey', this.apiKey) + } + + const response = await fetch(url.href, { method: 'GET' }) + + if (!response.ok) { + const responseText = await response.text() + console.error('Error on Etherscan API lookup at ' + this.apiUrl + '\nStatus: ' + response.status + '\nResponse: ' + responseText) + throw new Error(responseText) + } + + const lookupResponse: EtherscanGetSourceCodeResponse = await response.json() + + if (lookupResponse.status !== '1' || !lookupResponse.message.startsWith('OK')) { + const errorResponse = lookupResponse as unknown as EtherscanRpcResponse + console.error('Error on Etherscan API lookup at ' + this.apiUrl + '\nStatus: ' + errorResponse.status + '\nMessage: ' + errorResponse.message + '\nResult: ' + errorResponse.result) + throw new Error(errorResponse.result) + } + + if (lookupResponse.result[0].ABI === 'Contract source code not verified' || !lookupResponse.result[0].SourceCode) { + return { status: 'not verified' } + } + + const lookupUrl = this.getContractCodeUrl(contractAddress) + const { sourceFiles, targetFilePath } = this.processReceivedFiles(lookupResponse.result[0], contractAddress, chainId) + + return { status: 'verified', lookupUrl, sourceFiles, targetFilePath } + } + + getContractCodeUrl(address: string): string { + const url = new URL(this.explorerUrl + `/address/${address}#code`) + return url.href + } + + processReceivedFiles(source: EtherscanSource, contractAddress: string, chainId: string): { sourceFiles: SourceFile[]; targetFilePath?: string } { + const filePrefix = `/${this.LOOKUP_STORE_DIR}/${chainId}/${contractAddress}` + + // Covers the cases: + // SourceFile: {[FileName]: [content]} + // SourceFile: {{sources: {[FileName]: [content]}}} + let parsedFiles: any + try { + parsedFiles = JSON.parse(source.SourceCode) + } catch (e) { + try { + // Etherscan wraps the Object in one additional bracket + parsedFiles = JSON.parse(source.SourceCode.substring(1, source.SourceCode.length - 1)).sources + } catch (e) {} + } + + if (parsedFiles) { + const result: SourceFile[] = [] + let targetFilePath = '' + for (const [fileName, fileObj] of Object.entries(parsedFiles)) { + const path = `${filePrefix}/${fileName}` + + result.push({ path, content: fileObj.content }) + + if (path.endsWith(`/${source.ContractName}.sol`)) { + targetFilePath = path + } + } + return { sourceFiles: result, targetFilePath } + } + + // Parsing to JSON failed, SourceCode is the code itself + const targetFilePath = `${filePrefix}/${source.ContractName}.sol` + const sourceFiles: SourceFile[] = [{ content: source.SourceCode, path: targetFilePath }] + return { sourceFiles, targetFilePath } + } +} diff --git a/apps/contract-verification/src/app/Verifiers/SourcifyVerifier.ts b/apps/contract-verification/src/app/Verifiers/SourcifyVerifier.ts new file mode 100644 index 00000000000..ab5235e2aae --- /dev/null +++ b/apps/contract-verification/src/app/Verifiers/SourcifyVerifier.ts @@ -0,0 +1,169 @@ +import { CompilerAbstract, SourcesCode } from '@remix-project/remix-solidity' +import { AbstractVerifier } from './AbstractVerifier' +import type { LookupResponse, SourceFile, SubmittedContract, VerificationResponse, VerificationStatus } from '../types' +import { ethers } from 'ethers' + +interface SourcifyVerificationRequest { + address: string + chain: string + files: Record + creatorTxHash?: string + chosenContract?: string +} + +type SourcifyVerificationStatus = 'perfect' | 'full' | 'partial' | null + +interface SourcifyVerificationResponse { + result: [ + { + address: string + chainId: string + status: SourcifyVerificationStatus + libraryMap: { + [key: string]: string + } + message?: string + } + ] +} + +interface SourcifyErrorResponse { + error: string +} + +interface SourcifyFile { + name: string + path: string + content: string +} + +interface SourcifyLookupResponse { + status: Exclude + files: SourcifyFile[] +} + +export class SourcifyVerifier extends AbstractVerifier { + LOOKUP_STORE_DIR = 'sourcify-verified' + + async verify(submittedContract: SubmittedContract, compilerAbstract: CompilerAbstract): Promise { + const metadataStr = compilerAbstract.data.contracts[submittedContract.filePath][submittedContract.contractName].metadata + const sources = compilerAbstract.source.sources + + // from { "filename.sol": {content: "contract MyContract { ... }"} } + // to { "filename.sol": "contract MyContract { ... }" } + const formattedSources = Object.entries(sources).reduce((acc, [fileName, { content }]) => { + acc[fileName] = content + return acc + }, {}) + const body: SourcifyVerificationRequest = { + chain: submittedContract.chainId, + address: submittedContract.address, + files: { + 'metadata.json': metadataStr, + ...formattedSources, + }, + } + + const response = await fetch(new URL(this.apiUrl + '/verify').href, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify(body), + }) + + if (!response.ok) { + const errorResponse: SourcifyErrorResponse = await response.json() + console.error('Error on Sourcify verification at ' + this.apiUrl + '\nStatus: ' + response.status + '\nResponse: ' + JSON.stringify(errorResponse)) + throw new Error(errorResponse.error) + } + + const verificationResponse: SourcifyVerificationResponse = await response.json() + + if (verificationResponse.result[0].status === null) { + console.error('Error on Sourcify verification at ' + this.apiUrl + '\nStatus: ' + response.status + '\nResponse: ' + verificationResponse.result[0].message) + throw new Error(verificationResponse.result[0].message) + } + + // Map to a user-facing status message + let status: VerificationStatus = 'unknown' + let lookupUrl: string | undefined = undefined + if (verificationResponse.result[0].status === 'perfect' || verificationResponse.result[0].status === 'full') { + status = 'fully verified' + lookupUrl = this.getContractCodeUrl(submittedContract.address, submittedContract.chainId, true) + } else if (verificationResponse.result[0].status === 'partial') { + status = 'partially verified' + lookupUrl = this.getContractCodeUrl(submittedContract.address, submittedContract.chainId, false) + } + + return { status, receiptId: null, lookupUrl } + } + + async lookup(contractAddress: string, chainId: string): Promise { + const url = new URL(this.apiUrl + `/files/any/${chainId}/${contractAddress}`) + + const response = await fetch(url.href, { method: 'GET' }) + + if (!response.ok) { + const errorResponse: SourcifyErrorResponse = await response.json() + + if (errorResponse.error === 'Files have not been found!') { + return { status: 'not verified' } + } + + console.error('Error on Sourcify lookup at ' + this.apiUrl + '\nStatus: ' + response.status + '\nResponse: ' + JSON.stringify(errorResponse)) + throw new Error(errorResponse.error) + } + + const lookupResponse: SourcifyLookupResponse = await response.json() + + let status: VerificationStatus = 'unknown' + let lookupUrl: string | undefined = undefined + if (lookupResponse.status === 'perfect' || lookupResponse.status === 'full') { + status = 'fully verified' + lookupUrl = this.getContractCodeUrl(contractAddress, chainId, true) + } else if (lookupResponse.status === 'partial') { + status = 'partially verified' + lookupUrl = this.getContractCodeUrl(contractAddress, chainId, false) + } + + const { sourceFiles, targetFilePath } = this.processReceivedFiles(lookupResponse.files, contractAddress, chainId) + + return { status, lookupUrl, sourceFiles, targetFilePath } + } + + getContractCodeUrl(address: string, chainId: string, fullMatch: boolean): string { + const url = new URL(this.explorerUrl + `/contracts/${fullMatch ? 'full_match' : 'partial_match'}/${chainId}/${address}/`) + return url.href + } + + processReceivedFiles(files: SourcifyFile[], contractAddress: string, chainId: string): { sourceFiles: SourceFile[]; targetFilePath?: string } { + const result: SourceFile[] = [] + let targetFilePath: string + const filePrefix = `/${this.LOOKUP_STORE_DIR}/${chainId}/${contractAddress}` + + for (const file of files) { + let filePath: string + for (const a of [contractAddress, ethers.utils.getAddress(contractAddress)]) { + const matching = file.path.match(`/${a}/(.*)$`) + if (matching) { + filePath = matching[1] + break + } + } + + if (filePath) { + result.push({ path: `${filePrefix}/${filePath}`, content: file.content }) + } + + if (file.name === 'metadata.json') { + const metadata = JSON.parse(file.content) + const compilationTarget = metadata.settings.compilationTarget + const contractPath = Object.keys(compilationTarget)[0] + targetFilePath = `${filePrefix}/sources/${contractPath}` + } + } + + return { sourceFiles: result, targetFilePath } + } +} diff --git a/apps/contract-verification/src/app/Verifiers/index.ts b/apps/contract-verification/src/app/Verifiers/index.ts new file mode 100644 index 00000000000..23de8cd89d5 --- /dev/null +++ b/apps/contract-verification/src/app/Verifiers/index.ts @@ -0,0 +1,30 @@ +import type { VerifierIdentifier, VerifierSettings } from '../types' +import { AbstractVerifier } from './AbstractVerifier' +import { BlockscoutVerifier } from './BlockscoutVerifier' +import { EtherscanVerifier } from './EtherscanVerifier' +import { SourcifyVerifier } from './SourcifyVerifier' + +export { AbstractVerifier } from './AbstractVerifier' +export { BlockscoutVerifier } from './BlockscoutVerifier' +export { SourcifyVerifier } from './SourcifyVerifier' +export { EtherscanVerifier } from './EtherscanVerifier' + +export function getVerifier(identifier: VerifierIdentifier, settings: VerifierSettings): AbstractVerifier { + switch (identifier) { + case 'Sourcify': + if (!settings?.explorerUrl) { + throw new Error('The Sourcify verifier requires an explorer URL.') + } + return new SourcifyVerifier(settings.apiUrl, settings.explorerUrl) + case 'Etherscan': + if (!settings?.explorerUrl) { + throw new Error('The Etherscan verifier requires an explorer URL.') + } + if (!settings?.apiKey) { + throw new Error('The Etherscan verifier requires an API key.') + } + return new EtherscanVerifier(settings.apiUrl, settings.explorerUrl, settings.apiKey) + case 'Blockscout': + return new BlockscoutVerifier(settings.apiUrl) + } +} diff --git a/apps/contract-verification/src/app/VerifyFormContext.tsx b/apps/contract-verification/src/app/VerifyFormContext.tsx new file mode 100644 index 00000000000..14d146b0c10 --- /dev/null +++ b/apps/contract-verification/src/app/VerifyFormContext.tsx @@ -0,0 +1,46 @@ +import React from 'react' +import type { Chain } from './types' +import { ContractDropdownSelection } from './components/ContractDropdown' + +// Define the type for the context +type VerifyFormContextType = { + selectedChain: Chain | undefined + setSelectedChain: React.Dispatch> + contractAddress: string + setContractAddress: React.Dispatch> + contractAddressError: string + setContractAddressError: React.Dispatch> + selectedContract: ContractDropdownSelection | undefined + setSelectedContract: React.Dispatch> + proxyAddress: string + setProxyAddress: React.Dispatch> + proxyAddressError: string + setProxyAddressError: React.Dispatch> + abiEncodedConstructorArgs: string + setAbiEncodedConstructorArgs: React.Dispatch> + abiEncodingError: string + setAbiEncodingError: React.Dispatch> +} + +// Provide a default value with the appropriate types +const defaultContextValue: VerifyFormContextType = { + selectedChain: undefined, + setSelectedChain: (selectedChain: Chain) => {}, + contractAddress: '', + setContractAddress: (contractAddress: string) => {}, + contractAddressError: '', + setContractAddressError: (contractAddressError: string) => {}, + selectedContract: undefined, + setSelectedContract: (selectedContract: ContractDropdownSelection) => {}, + proxyAddress: '', + setProxyAddress: (proxyAddress: string) => {}, + proxyAddressError: '', + setProxyAddressError: (contractAddressError: string) => {}, + abiEncodedConstructorArgs: '', + setAbiEncodedConstructorArgs: (contractAddproxyAddressress: string) => {}, + abiEncodingError: '', + setAbiEncodingError: (contractAddressError: string) => {}, +} + +// Create the context with the type +export const VerifyFormContext = React.createContext(defaultContextValue) diff --git a/apps/contract-verification/src/app/app.tsx b/apps/contract-verification/src/app/app.tsx new file mode 100644 index 00000000000..0ea4332543d --- /dev/null +++ b/apps/contract-verification/src/app/app.tsx @@ -0,0 +1,154 @@ +import { useState, useEffect, useRef } from 'react' + +import { ContractVerificationPluginClient } from './ContractVerificationPluginClient' + +import { AppContext } from './AppContext' +import { VerifyFormContext } from './VerifyFormContext' +import DisplayRoutes from './routes' +import type { ContractVerificationSettings, ThemeType, Chain, SubmittedContracts, VerificationReceipt, VerificationResponse } from './types' +import { mergeChainSettingsWithDefaults } from './utils' + +import './App.css' +import { CompilerAbstract } from '@remix-project/remix-solidity' +import { useLocalStorage } from './hooks/useLocalStorage' +import { getVerifier } from './Verifiers' +import { ContractDropdownSelection } from './components/ContractDropdown' + +const plugin = new ContractVerificationPluginClient() + +const App = () => { + const [themeType, setThemeType] = useState('dark') + const [settings, setSettings] = useLocalStorage('contract-verification:settings', { chains: {} }) + const [submittedContracts, setSubmittedContracts] = useLocalStorage('contract-verification:submitted-contracts', {}) + const [chains, setChains] = useState([]) // State to hold the chains data + const [compilationOutput, setCompilationOutput] = useState<{ [key: string]: CompilerAbstract } | undefined>() + + // Form values: + const [selectedChain, setSelectedChain] = useState() + const [contractAddress, setContractAddress] = useState('') + const [contractAddressError, setContractAddressError] = useState('') + const [selectedContract, setSelectedContract] = useState() + const [proxyAddress, setProxyAddress] = useState('') + const [proxyAddressError, setProxyAddressError] = useState('') + const [abiEncodedConstructorArgs, setAbiEncodedConstructorArgs] = useState('') + const [abiEncodingError, setAbiEncodingError] = useState('') + + const timer = useRef(null) + + useEffect(() => { + plugin.internalEvents.on('verification_activated', () => { + // Fetch compiler artefacts initially + plugin.call('compilerArtefacts' as any, 'getAllCompilerAbstracts').then((obj: any) => { + setCompilationOutput(obj) + }) + + // Subscribe to compilations + plugin.on('compilerArtefacts' as any, 'compilationSaved', (compilerAbstracts: { [key: string]: CompilerAbstract }) => { + setCompilationOutput((prev) => ({ ...(prev || {}), ...compilerAbstracts })) + }) + + // Fetch chains.json and update state + fetch('https://chainid.network/chains.json') + .then((response) => response.json()) + .then((data) => setChains(data)) + .catch((error) => console.error('Failed to fetch chains.json:', error)) + }) + + // Clean up on unmount + return () => { + plugin.off('compilerArtefacts' as any, 'compilationSaved') + } + }, []) + + // Poll status of pending receipts frequently + useEffect(() => { + const getPendingReceipts = (submissions: SubmittedContracts) => { + const pendingReceipts: VerificationReceipt[] = [] + // Check statuses of receipts + for (const submission of Object.values(submissions)) { + for (const receipt of submission.receipts) { + if (receipt.status === 'pending') { + pendingReceipts.push(receipt) + } + } + for (const proxyReceipt of submission.proxyReceipts ?? []) { + if (proxyReceipt.status === 'pending') { + pendingReceipts.push(proxyReceipt) + } + } + } + return pendingReceipts + } + + let pendingReceipts = getPendingReceipts(submittedContracts) + + if (pendingReceipts.length > 0) { + if (timer.current) { + clearInterval(timer.current) + timer.current = null + } + + const pollStatus = async () => { + const changedSubmittedContracts = { ...submittedContracts } + + for (const receipt of pendingReceipts) { + await new Promise((resolve) => setTimeout(resolve, 500)) // avoid api rate limit exceed. + + const { verifierInfo, receiptId } = receipt + if (receiptId) { + const contract = changedSubmittedContracts[receipt.contractId] + const chainSettings = mergeChainSettingsWithDefaults(contract.chainId, settings) + const verifierSettings = chainSettings.verifiers[verifierInfo.name] + + // In case the user overwrites the API later, prefer the one stored in localStorage + const verifier = getVerifier(verifierInfo.name, { ...verifierSettings, apiUrl: verifierInfo.apiUrl }) + if (!verifier.checkVerificationStatus) { + continue + } + + try { + let response: VerificationResponse + if (receipt.isProxyReceipt) { + response = await verifier.checkProxyVerificationStatus(receiptId) + } else { + response = await verifier.checkVerificationStatus(receiptId) + } + const { status, message, lookupUrl } = response + receipt.status = status + receipt.message = message + if (lookupUrl) { + receipt.lookupUrl = lookupUrl + } + } catch (e) { + receipt.failedChecks++ + // Only retry 5 times + if (receipt.failedChecks >= 5) { + receipt.status = 'failed' + receipt.message = e.message + } + } + } + } + + pendingReceipts = getPendingReceipts(changedSubmittedContracts) + if (timer.current && pendingReceipts.length === 0) { + clearInterval(timer.current) + timer.current = null + } + setSubmittedContracts((prev) => Object.assign({}, prev, changedSubmittedContracts)) + } + + timer.current = setInterval(pollStatus, 1000) + } + }, [submittedContracts]) + + return ( + + + + + + ) +} + +export default App diff --git a/apps/contract-verification/src/app/components/AccordionReceipt.tsx b/apps/contract-verification/src/app/components/AccordionReceipt.tsx new file mode 100644 index 00000000000..33ee96a7ce8 --- /dev/null +++ b/apps/contract-verification/src/app/components/AccordionReceipt.tsx @@ -0,0 +1,105 @@ +import React, { useMemo } from 'react' +import { SubmittedContract, VerificationReceipt } from '../types' +import { shortenAddress, CustomTooltip } from '@remix-ui/helper' +import { AppContext } from '../AppContext' +import { CopyToClipboard } from '@remix-ui/clipboard' + +interface AccordionReceiptProps { + contract: SubmittedContract + index: number +} + +export const AccordionReceipt: React.FC = ({ contract, index }) => { + const { chains } = React.useContext(AppContext) + + const [expanded, setExpanded] = React.useState(false) + + const chain = useMemo(() => { + return chains.find((c) => c.chainId === parseInt(contract.chainId)) + }, [contract, chains]) + const chainName = chain?.name ?? 'Unknown Chain' + + const hasProxy = contract.proxyAddress && contract.proxyReceipts + + const toggleAccordion = () => { + setExpanded(!expanded) + } + + return ( +
+
+ + +
+ + + {contract.contractName} at {shortenAddress(contract.address)} {contract.proxyAddress ? 'with proxy' : ''} + + +
+ + +
+ +
+
+ Chain: + {chainName} ({contract.chainId}) +
+
+ File: + {contract.filePath} +
+
+ Submitted at: + {new Date(contract.date).toLocaleString()} +
+ +
+ Verified at: + +
+ + {hasProxy && ( + <> +
+ Proxy Address: + + {shortenAddress(contract.proxyAddress)} + + +
+
+ Proxy verified at: + +
+ + )} +
+
+ ) +} + +const ReceiptsBody = ({ receipts }: { receipts: VerificationReceipt[] }) => { + return ( +
    + {receipts.map((receipt) => ( +
  • + + {receipt.verifierInfo.name} + + + + {['verified', 'partially verified', 'already verified'].includes(receipt.status) ? : receipt.status === 'fully verified' ? : receipt.status === 'failed' ? : ['pending', 'awaiting implementation verification'].includes(receipt.status) ? : } + + + {!!receipt.lookupUrl && } +
  • + ))} +
+ ) +} diff --git a/apps/contract-verification/src/app/components/ConfigInput.tsx b/apps/contract-verification/src/app/components/ConfigInput.tsx new file mode 100644 index 00000000000..0737840115d --- /dev/null +++ b/apps/contract-verification/src/app/components/ConfigInput.tsx @@ -0,0 +1,69 @@ +import React, { useEffect, useState } from 'react' +import { CustomTooltip } from '@remix-ui/helper' + +interface ConfigInputProps { + label: string + id: string + secret: boolean + initialValue: string + saveResult: (result: string) => void +} + +// Chooses one contract from the compilation output. +export const ConfigInput: React.FC = ({ label, id, secret, initialValue, saveResult }) => { + const [value, setValue] = useState(initialValue) + const [enabled, setEnabled] = useState(false) + + // Reset state when initialValue changes + useEffect(() => { + setValue(initialValue) + setEnabled(false) + }, [initialValue]) + + const handleChange = () => { + setEnabled(true) + } + + const handleSave = () => { + setEnabled(false) + saveResult(value) + } + + const handleCancel = () => { + setEnabled(false) + setValue(initialValue) + } + + return ( +
+ +
+ setValue(e.target.value)} + disabled={!enabled} + /> + + { enabled ? ( + <> + + + + ) : ( + + + + )} +
+
+ ) +} diff --git a/apps/contract-verification/src/app/components/ConstructorArguments.tsx b/apps/contract-verification/src/app/components/ConstructorArguments.tsx new file mode 100644 index 00000000000..e2a4239eaae --- /dev/null +++ b/apps/contract-verification/src/app/components/ConstructorArguments.tsx @@ -0,0 +1,135 @@ +import { useContext, useEffect, useRef, useState } from 'react' +import { ethers } from 'ethers' + +import { AppContext } from '../AppContext' +import { ContractDropdownSelection } from './ContractDropdown' + +interface ConstructorArgumentsProps { + abiEncodedConstructorArgs: string + setAbiEncodedConstructorArgs: React.Dispatch> + abiEncodingError: string + setAbiEncodingError: React.Dispatch> + selectedContract: ContractDropdownSelection +} + +export const ConstructorArguments: React.FC = ({ abiEncodedConstructorArgs, setAbiEncodedConstructorArgs, abiEncodingError, setAbiEncodingError, selectedContract }) => { + const { compilationOutput } = useContext(AppContext) + const [toggleRawInput, setToggleRawInput] = useState(false) + + const { triggerFilePath, filePath, contractName } = selectedContract + const selectedCompilerAbstract = triggerFilePath && compilationOutput[triggerFilePath] + const compiledContract = selectedCompilerAbstract?.data?.contracts?.[filePath]?.[contractName] + const abi = compiledContract?.abi + + const constructorArgs = abi && abi.find((a) => a.type === 'constructor')?.inputs + + const decodeConstructorArgs = (value: string) => { + try { + const decodedObj = ethers.utils.defaultAbiCoder.decode( + constructorArgs.map((inp) => inp.type), + value + ) + const decoded = decodedObj.map((val) => JSON.stringify(val)) + return { decoded, errorMessage: '' } + } catch (e) { + console.error(e) + const errorMessage = 'Decoding error: ' + e.message + const decoded = Array(constructorArgs?.length ?? 0).fill('') + return { decoded, errorMessage } + } + } + + const [constructorArgsValues, setConstructorArgsValues] = useState(abiEncodedConstructorArgs ? decodeConstructorArgs(abiEncodedConstructorArgs).decoded : Array(constructorArgs?.length ?? 0).fill('')) + + const constructorArgsInInitialState = useRef(true) + useEffect(() => { + if (constructorArgsInInitialState.current) { + constructorArgsInInitialState.current = false + return + } + setAbiEncodedConstructorArgs('') + setAbiEncodingError('') + setConstructorArgsValues(Array(constructorArgs?.length ?? 0).fill('')) + }, [constructorArgs]) + + const handleConstructorArgs = (value: string, index: number) => { + const changedConstructorArgsValues = [...constructorArgsValues.slice(0, index), value, ...constructorArgsValues.slice(index + 1)] + setConstructorArgsValues(changedConstructorArgsValues) + + // if any constructorArgsValue is falsey (empty etc.), don't encode yet + if (changedConstructorArgsValues.some((value) => !value)) { + setAbiEncodedConstructorArgs('') + setAbiEncodingError('') + return + } + + const types = constructorArgs.map((inp) => inp.type) + const parsedArgsValues = [] + for (const arg of changedConstructorArgsValues) { + try { + parsedArgsValues.push(JSON.parse(arg)) + } catch (e) { + parsedArgsValues.push(arg) + } + } + + try { + const newAbiEncoding = ethers.utils.defaultAbiCoder.encode(types, parsedArgsValues) + setAbiEncodedConstructorArgs(newAbiEncoding) + setAbiEncodingError('') + } catch (e) { + console.error(e) + setAbiEncodedConstructorArgs('') + setAbiEncodingError('Encoding error: ' + e.message) + } + } + + const handleRawConstructorArgs = (value: string) => { + setAbiEncodedConstructorArgs(value) + const { decoded, errorMessage } = decodeConstructorArgs(value) + setConstructorArgsValues(decoded) + setAbiEncodingError(errorMessage) + } + + if (!selectedContract) return null + if (!compilationOutput && Object.keys(compilationOutput).length === 0) return null + // No render if no constructor args + if (!constructorArgs || constructorArgs.length === 0) return null + + return ( +
+ +
+ setToggleRawInput(!toggleRawInput)} /> + +
+ {toggleRawInput ? ( +
+ {' '} +