diff --git a/.nvmrc b/.nvmrc new file mode 100644 index 0000000000..9a2a0e219c --- /dev/null +++ b/.nvmrc @@ -0,0 +1 @@ +v20 diff --git a/.prettierrc.json b/.prettierrc.json index 07681f5b0a..967e2ecc37 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 0000000000..6df3e5be52 --- /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 0000000000..f1d12df4fa --- /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 0000000000..be97c53fbb --- /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 0000000000..a92d0f887a --- /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 0000000000..dee28fe326 --- /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 0000000000..0ff37a0e7e --- /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 0000000000..e4ffda0cee --- /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 0000000000..8554cb29f6 --- /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 0000000000..480fc5bd9b --- /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 0000000000..fd7b95563f --- /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 0000000000..8d5a6198a2 --- /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 0000000000..ab5235e2aa --- /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 0000000000..23de8cd89d --- /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 0000000000..14d146b0c1 --- /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 0000000000..0ea4332543 --- /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 0000000000..33ee96a7ce --- /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 0000000000..0737840115 --- /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 0000000000..e2a4239eaa --- /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 ? ( +
+ {' '} + +
+ otherwise +
) } @@ -236,3 +255,29 @@ export function AccountUI(props: AccountProps) {
) } + +const EIP712_Example = { + domain: { + chainId: 1, + name: "Example App", + verifyingContract: "0xCcCCccccCCCCcCCCCCCcCcCccCcCCCcCcccccccC", + version: "1", + }, + message: { + prompt: "Welcome! In order to authenticate to this website, sign this request and your public address will be sent to the server in a verifiable way.", + createdAt: 1718570375196, + }, + primaryType: 'AuthRequest', + types: { + EIP712Domain: [ + { name: 'name', type: 'string' }, + { name: 'version', type: 'string' }, + { name: 'chainId', type: 'uint256' }, + { name: 'verifyingContract', type: 'address' }, + ], + AuthRequest: [ + { name: 'prompt', type: 'string' }, + { name: 'createdAt', type: 'uint256' }, + ], + }, +} \ No newline at end of file diff --git a/libs/remix-ui/run-tab/src/lib/components/settingsUI.tsx b/libs/remix-ui/run-tab/src/lib/components/settingsUI.tsx index d3d8ed5957..f9e09246f9 100644 --- a/libs/remix-ui/run-tab/src/lib/components/settingsUI.tsx +++ b/libs/remix-ui/run-tab/src/lib/components/settingsUI.tsx @@ -15,6 +15,7 @@ export function SettingsUI(props: SettingsProps) {
{ }; setupEvents(): void; getCurrentNetworkStatus(): { - name: string; - id: string; network?: { name: string; id: string; }; + error?: string; }; setupProviders(): void; providers: any; @@ -35,8 +34,8 @@ export class Blockchain extends Plugin { determineGasPrice(cb: any): void; getInputs(funABI: any): any; fromWei(value: any, doTypeConversion: any, unit: any): string; - toWei(value: any, unit: any): import("bn.js"); - calculateFee(gas: any, gasPrice: any, unit: any): import("bn.js"); + toWei(value: any, unit: any): string; + calculateFee(gas: any, gasPrice: any, unit: any): bigint; determineGasFees(tx: any): (gasPrice: any, cb: any) => void; changeExecutionContext(context: any, confirmCb: any, infoCb: any, cb: any): Promise; detectNetwork(cb: any): void; @@ -58,7 +57,6 @@ export class Blockchain extends Plugin { removeProvider(name: any): void; /** Listen on New Transaction. (Cannot be done inside constructor because txlistener doesn't exist yet) */ startListening(txlistener: any): void; - resetEnvironment(): Promise; /** * Create a VM Account * @param {{privateKey: string, balance: string}} newAccount The new account to create diff --git a/libs/remix-ui/run-tab/src/lib/types/execution-context.d.ts b/libs/remix-ui/run-tab/src/lib/types/execution-context.d.ts index 83d3df2d55..598c4f8f7d 100644 --- a/libs/remix-ui/run-tab/src/lib/types/execution-context.d.ts +++ b/libs/remix-ui/run-tab/src/lib/types/execution-context.d.ts @@ -14,7 +14,6 @@ export class ExecutionContext { txs: any; customWeb3: any; init(config: any): void; - askPermission(): void; getProvider(): any; getCurrentFork(): string; isVM(): boolean; diff --git a/libs/remix-ui/run-tab/src/lib/types/index.ts b/libs/remix-ui/run-tab/src/lib/types/index.ts index 62b2cba078..35229e6e31 100644 --- a/libs/remix-ui/run-tab/src/lib/types/index.ts +++ b/libs/remix-ui/run-tab/src/lib/types/index.ts @@ -6,7 +6,7 @@ import { SolcInput, SolcOutput } from '@openzeppelin/upgrades-core' import { LayoutCompatibilityReport } from '@openzeppelin/upgrades-core/dist/storage/report' export interface RunTabProps { plugin: RunTab, - initialState: RunTabState + initialState?: RunTabState } export interface Contract { @@ -145,6 +145,7 @@ export interface SettingsProps { isSuccessful: boolean, error: string }, + addFile: (path: string, content: string) => void, setExecutionContext: (executionContext: { context: string, fork: string }) => void, createNewBlockchainAccount: (cbMessage: JSX.Element) => void, setPassphrase: (passphrase: string) => void, @@ -180,6 +181,7 @@ export interface AccountProps { isSuccessful: boolean, error: string }, + addFile: (path: string, content: string) => void, setAccount: (account: string) => void, personalMode: boolean, createNewBlockchainAccount: (cbMessage: JSX.Element) => void, diff --git a/libs/remix-ui/run-tab/src/lib/types/run-tab.d.ts b/libs/remix-ui/run-tab/src/lib/types/run-tab.d.ts index 91de380ad9..40ae73ac9e 100644 --- a/libs/remix-ui/run-tab/src/lib/types/run-tab.d.ts +++ b/libs/remix-ui/run-tab/src/lib/types/run-tab.d.ts @@ -1,11 +1,11 @@ -export class RunTab extends ViewPlugin { - constructor(blockchain: any, config: any, fileManager: any, editor: any, filePanel: any, compilersArtefacts: any, networkModule: any, mainView: any, fileProvider: any); +import type { CompilerArtefacts } from '@remix-project/core-plugin' +export interface RunTab extends ViewPlugin { + // constructor(blockchain: Blockchain, config: any, fileManager: any, editor: any, filePanel: any, compilersArtefacts: CompilerArtefacts, networkModule: any, fileProvider: any, engine: any); event: any; config: any; blockchain: Blockchain; fileManager: any; editor: any; - logCallback: (msg: any) => void; filePanel: any; compilersArtefacts: any; networkModule: any; @@ -19,24 +19,9 @@ export class RunTab extends ViewPlugin { sendTransaction(tx: any): any; getAccounts(cb: any): any; pendingTransactionsCount(): any; - renderInstanceContainer(): void; - instanceContainer: any; - noInstancesText: any; - renderSettings(): void; - settingsUI: any; - renderDropdown(udappUI: any, fileManager: any, compilersArtefacts: any, config: any, editor: any, logCallback: any): void; - contractDropdownUI: any; - renderRecorder(udappUI: any, fileManager: any, config: any, logCallback: any): void; - recorderCount: any; - recorderInterface: any; - renderRecorderCard(): void; - recorderCard: any; - udappUI: any; - renderComponent(): void; onReady(api: any): void; onInitDone(): void; recorder: Recorder; - // syncContracts(): void } import { ViewPlugin } from "@remixproject/engine-web"; import { Blockchain } from "./blockchain"; diff --git a/libs/remix-ui/workspace/src/lib/actions/index.ts b/libs/remix-ui/workspace/src/lib/actions/index.tsx similarity index 96% rename from libs/remix-ui/workspace/src/lib/actions/index.ts rename to libs/remix-ui/workspace/src/lib/actions/index.tsx index e10fa20d43..cc66374596 100644 --- a/libs/remix-ui/workspace/src/lib/actions/index.ts +++ b/libs/remix-ui/workspace/src/lib/actions/index.tsx @@ -10,8 +10,7 @@ import { fetchContractFromEtherscan, fetchContractFromBlockscout } from '@remix- import JSZip from 'jszip' import { Actions, FileTree } from '../types' import IpfsHttpClient from 'ipfs-http-client' -import { AppModal } from '@remix-ui/app' -import { MessageWrapper } from '../components/file-explorer' +import { AppModal, ModalTypes } from '@remix-ui/app' export * from './events' export * from './workspace' @@ -510,6 +509,32 @@ export const runScript = async (path: string) => { }) } +export const signTypedData = async (path: string) => { + const typedData = await plugin.call('fileManager', 'readFile', path) + const web3 = await plugin.call('blockchain', 'web3') + const settings = await plugin.call('udapp', 'getSettings') + let parsed + try { + parsed = JSON.parse(typedData) + } catch (err) { + dispatch(displayPopUp(`${path} isn't a valid JSON.`)) + return + } + + try { + const result = await web3.currentProvider.request({ + method: 'eth_signTypedData_v4', + params: [settings.selectedAccount, parsed] + }) + + plugin.call('terminal', 'log', { type: 'log', value: `${path} signature using ${settings.selectedAccount} : ${result}` }) + } catch (e) { + console.error(e) + plugin.call('terminal', 'log', { type: 'error', value: `error while signing ${path}: ${e.message}` }) + dispatch(displayPopUp(e.message)) + } +} + export const emitContextMenuEvent = async (cmd: customAction) => { await plugin.call(cmd.id, cmd.name, cmd) } diff --git a/libs/remix-ui/workspace/src/lib/components/file-explorer-context-menu.tsx b/libs/remix-ui/workspace/src/lib/components/file-explorer-context-menu.tsx index b3a39f974c..58969c959d 100644 --- a/libs/remix-ui/workspace/src/lib/components/file-explorer-context-menu.tsx +++ b/libs/remix-ui/workspace/src/lib/components/file-explorer-context-menu.tsx @@ -41,6 +41,7 @@ export const FileExplorerContextMenu = (props: FileExplorerContextMenuProps) => downloadPath, uploadFile, publishManyFilesToGist, + signTypedData, ...otherProps } = props const contextMenuRef = useRef(null) @@ -234,6 +235,10 @@ export const FileExplorerContextMenu = (props: FileExplorerContextMenuProps) => _paq.push(['trackEvent', 'fileExplorer', 'contextMenu', 'publishWorkspace']) publishFolderToGist(path) break + case 'Sign Typed Data': + _paq.push(['trackEvent', 'fileExplorer', 'contextMenu', 'signTypedData']) + signTypedData(path) + break default: _paq.push(['trackEvent', 'fileExplorer', 'contextMenu', `${item.id}/${item.name}`]) emit && emit({ ...item, path: [path]} as customAction) diff --git a/libs/remix-ui/workspace/src/lib/components/file-explorer-menu.tsx b/libs/remix-ui/workspace/src/lib/components/file-explorer-menu.tsx index 71278a2a48..470a6565d0 100644 --- a/libs/remix-ui/workspace/src/lib/components/file-explorer-menu.tsx +++ b/libs/remix-ui/workspace/src/lib/components/file-explorer-menu.tsx @@ -54,13 +54,6 @@ export const FileExplorerMenu = (props: FileExplorerMenuProps) => { placement: 'top', platforms: [appPlatformTypes.web, appPlatformTypes.desktop] }, - { - action: 'connectToLocalFileSystem', - title: 'Connect to local filesystem using remixd', - icon: 'fa-solid fa-desktop', - placement: 'top', - platforms: [appPlatformTypes.web] - }, { action: 'initializeWorkspaceAsGitRepo', title: 'Initialize workspace as a git repository', @@ -154,7 +147,7 @@ export const FileExplorerMenu = (props: FileExplorerMenuProps) => { return ( } key={`index-${action}-${placement}-${icon}`} @@ -162,7 +155,7 @@ export const FileExplorerMenu = (props: FileExplorerMenuProps) => {
diff --git a/libs/remix-ui/workspace/src/lib/components/workspace-hamburger.tsx b/libs/remix-ui/workspace/src/lib/components/workspace-hamburger.tsx index c83313cb65..2f5090b599 100644 --- a/libs/remix-ui/workspace/src/lib/components/workspace-hamburger.tsx +++ b/libs/remix-ui/workspace/src/lib/components/workspace-hamburger.tsx @@ -99,6 +99,17 @@ export function HamburgerMenu(props: HamburgerMenuProps) { platforms={[appPlatformTypes.web]} > + { + props.handleRemixdWorkspace() + props.hideIconsMenu(!showIconsMenu) + }} + platforms={[appPlatformTypes.web]} + > + - - { - props.handleRemixdWorkspace() - props.hideIconsMenu(!showIconsMenu) - }} - platforms={[appPlatformTypes.web]} - > ) } diff --git a/libs/remix-ui/workspace/src/lib/contexts/index.ts b/libs/remix-ui/workspace/src/lib/contexts/index.ts index 04e1b7f8a3..dbbae11c20 100644 --- a/libs/remix-ui/workspace/src/lib/contexts/index.ts +++ b/libs/remix-ui/workspace/src/lib/contexts/index.ts @@ -34,6 +34,7 @@ export const FileSystemContext = createContext<{ dispatchCopyShareURL: (path: string) => Promise, dispatchCopyFolder: (src: string, dest: string) => Promise, dispatchRunScript: (path: string) => Promise, + dispatchSignTypedData: (path: string) => Promise, dispatchEmitContextMenuEvent: (cmd: customAction) => Promise, dispatchHandleClickFile: (path: string, type: 'file' | 'folder' ) => Promise dispatchHandleExpandPath: (paths: string[]) => Promise, diff --git a/libs/remix-ui/workspace/src/lib/providers/FileSystemProvider.tsx b/libs/remix-ui/workspace/src/lib/providers/FileSystemProvider.tsx index 12c5dc516f..3e923c7b76 100644 --- a/libs/remix-ui/workspace/src/lib/providers/FileSystemProvider.tsx +++ b/libs/remix-ui/workspace/src/lib/providers/FileSystemProvider.tsx @@ -25,6 +25,7 @@ import { copyShareURL, copyFolder, runScript, + signTypedData, emitContextMenuEvent, handleClickFile, handleExpandPath, @@ -171,6 +172,10 @@ export const FileSystemProvider = (props: WorkspaceProps) => { await runScript(path) } + const dispatchSignTypedData = async (path: string) => { + await signTypedData(path) + } + const dispatchEmitContextMenuEvent = async (cmd: customAction) => { await emitContextMenuEvent(cmd) } @@ -358,6 +363,7 @@ export const FileSystemProvider = (props: WorkspaceProps) => { dispatchCopyShareURL, dispatchCopyFolder, dispatchRunScript, + dispatchSignTypedData, dispatchEmitContextMenuEvent, dispatchHandleClickFile, dispatchHandleExpandPath, diff --git a/libs/remix-ui/workspace/src/lib/remix-ui-workspace.tsx b/libs/remix-ui/workspace/src/lib/remix-ui-workspace.tsx index 25e27183b5..f7f79dbf72 100644 --- a/libs/remix-ui/workspace/src/lib/remix-ui-workspace.tsx +++ b/libs/remix-ui/workspace/src/lib/remix-ui-workspace.tsx @@ -350,9 +350,9 @@ export function Workspace() { }) global.modal( intl.formatMessage({ id: 'filePanel.workspace.createBlank' }), - await createModalMessage(workspace ? workspace: `blank - ${counter}`, gitNotSet, (value) => { workspace = value }, (value) => gitInit = false), + await createModalMessage(workspace ? workspace: `blank - ${counter}`, gitNotSet, (value) => { workspace = value }, (value) => {gitInit = value === 'on'}), intl.formatMessage({ id: 'filePanel.ok' }), - () => global.dispatchCreateWorkspace(workspace ? workspace: `blank - ${counter}`, 'blank', false), + () => global.dispatchCreateWorkspace(workspace ? workspace: `blank - ${counter}`, 'blank', null, gitInit), intl.formatMessage({ id: 'filePanel.cancel' }) ) } @@ -706,6 +706,14 @@ export function Workspace() { } } + const signTypedData = async (path: string) => { + try { + global.dispatchSignTypedData(path) + } catch (error) { + global.toast(intl.formatMessage({ id: 'filePanel.signTypedDataError' })) + } + } + const emitContextMenuEvent = (cmd: customAction) => { try { global.dispatchEmitContextMenuEvent(cmd) @@ -1014,7 +1022,7 @@ export function Workspace() {
- {currentWorkspace !== LOCALHOST ? ( + { currentWorkspace !== LOCALHOST ? ( hideIconsMenu(!showIconsMenu)} show={showIconsMenu}> switchWorkspace(LOCALHOST)} canPaste={canPaste} hasCopied={hasCopied} setHasCopied={setHasCopied} @@ -1263,6 +1271,7 @@ export function Workspace() { dispatchCopyFolder={global.dispatchCopyFolder} dispatchPublishToGist={global.dispatchPublishToGist} dispatchRunScript={global.dispatchRunScript} + dispatchSignTypedData={global.dispatchSignTypedData} // dispatchEmitContextMenuEvent={global.dispatchEmitContextMenuEvent} dispatchHandleClickFile={global.dispatchHandleClickFile} dispatchSetFocusElement={global.dispatchSetFocusElement} @@ -1339,7 +1348,7 @@ export function Workspace() { placement="right" tooltipId="branchesDropdown" tooltipClasses="text-nowrap" - tooltipText={'Current branch: ' + currentBranch || 'Branches'} + tooltipText={'Current branch: ' + (currentBranch && currentBranch.name) || 'Branches'} >
@@ -1437,6 +1446,7 @@ export function Workspace() { deletePath={deletePath} renamePath={editModeOn} runScript={runScript} + signTypedData={signTypedData} copy={handleCopyClick} paste={handlePasteClick} copyFileName={handleCopyFileNameClick} diff --git a/libs/remix-ui/workspace/src/lib/types/index.ts b/libs/remix-ui/workspace/src/lib/types/index.ts index 71fb5f7271..ba23f4cdc2 100644 --- a/libs/remix-ui/workspace/src/lib/types/index.ts +++ b/libs/remix-ui/workspace/src/lib/types/index.ts @@ -128,6 +128,7 @@ export interface FileExplorerProps { dispatchCopyShareURL: (path:string) => Promise, dispatchCopyFolder: (src: string, dest: string) => Promise, dispatchRunScript: (path: string) => Promise, + dispatchSignTypedData: (path: string) => Promise, dispatchPublishToGist: (path?: string, type?: string) => Promise, dispatchEmitContextMenuEvent: (cmd: customAction) => Promise, dispatchHandleClickFile: (path: string, type: WorkspaceElement) => Promise, @@ -159,7 +160,6 @@ export interface FileExplorerProps { dragStatus: (status: boolean) => void importFromIpfs: any importFromHttps: any - connectToLocalFileSystem?: any handleGitInit?: () => Promise handleMultiCopies: any feTarget: { key: string, type: 'file' | 'folder' }[] @@ -198,6 +198,7 @@ export interface FileExplorerContextMenuProps { pushChangesToGist?: (path?: string) => void publishFolderToGist?: (path?: string) => void publishFileToGist?: (path?: string) => void + signTypedData?: (path?: string) => void runScript?: (path: string) => void emit?: (cmd: customAction) => void pageX: number diff --git a/libs/remix-ui/workspace/src/lib/utils/index.ts b/libs/remix-ui/workspace/src/lib/utils/index.ts index a0dfc8df6a..033c50bb55 100644 --- a/libs/remix-ui/workspace/src/lib/utils/index.ts +++ b/libs/remix-ui/workspace/src/lib/utils/index.ts @@ -80,6 +80,13 @@ export const contextMenuActions: MenuItems = [{ multiselect: false, label: '', group: 3 +}, { + id: 'signTypedData', + name: 'Sign Typed Data', + extension: ['.json'], + multiselect: false, + label: '', + group: 3 }, { id: 'publishFolderToGist', name: 'Publish folder to gist', diff --git a/package.json b/package.json index 1e64e83258..4afcbd2f45 100644 --- a/package.json +++ b/package.json @@ -157,6 +157,7 @@ "formik": "^2.4.5", "from-exponential": "1.1.1", "fs-extra": "^3.0.1", + "fuse.js": "^7.0.0", "ganache": "^7.9.1", "graphql": "^16.8.1", "html-react-parser": "^3.0.4", diff --git a/yarn.lock b/yarn.lock index 62f9266413..9dc4b91ab1 100644 --- a/yarn.lock +++ b/yarn.lock @@ -16556,6 +16556,11 @@ functions-have-names@^1.2.2, functions-have-names@^1.2.3: resolved "https://registry.yarnpkg.com/functions-have-names/-/functions-have-names-1.2.3.tgz#0404fe4ee2ba2f607f0e0ec3c80bae994133b834" integrity sha512-xckBUXyTIqT97tq2x2AMb+g163b5JFysYk0x4qxNFwbfQkmNZoiRHb6sPzI9/QV33WeuvVYBUIiD4NzNIyqaRQ== +fuse.js@^7.0.0: + version "7.0.0" + resolved "https://registry.yarnpkg.com/fuse.js/-/fuse.js-7.0.0.tgz#6573c9fcd4c8268e403b4fc7d7131ffcf99a9eb2" + integrity sha512-14F4hBIxqKvD4Zz/XjDc3y94mNZN6pRv3U13Udo0lNLCWRBUsrMv2xwcF/y/Z5sV6+FQW+/ow68cHpm4sunt8Q== + galactus@^0.2.1: version "0.2.1" resolved "https://registry.yarnpkg.com/galactus/-/galactus-0.2.1.tgz#cbed2d20a40c1f5679a35908e2b9415733e78db9"