diff --git a/apps/contract-verification/src/app/Verifiers/SourcifyV1Verifier.ts b/apps/contract-verification/src/app/Verifiers/SourcifyV1Verifier.ts new file mode 100644 index 00000000000..972a237a4fd --- /dev/null +++ b/apps/contract-verification/src/app/Verifiers/SourcifyV1Verifier.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 { getAddress } from 'ethers' + +interface SourcifyV1VerificationRequest { + address: string + chain: string + files: Record + creatorTxHash?: string + chosenContract?: string +} + +type SourcifyV1VerificationStatus = 'perfect' | 'full' | 'partial' | null + +interface SourcifyV1VerificationResponse { + result: [ + { + address: string + chainId: string + status: SourcifyV1VerificationStatus + libraryMap: { + [key: string]: string + } + message?: string + } + ] +} + +interface SourcifyV1ErrorResponse { + error: string +} + +interface SourcifyV1File { + name: string + path: string + content: string +} + +interface SourcifyV1LookupResponse { + status: Exclude + files: SourcifyV1File[] +} + +export class SourcifyV1Verifier 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: SourcifyV1VerificationRequest = { + 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: SourcifyV1ErrorResponse = 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: SourcifyV1VerificationResponse = 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 = 'exactly verified' + lookupUrl = this.getContractCodeUrl(submittedContract.address, submittedContract.chainId, true) + } else if (verificationResponse.result[0].status === 'partial') { + status = '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: SourcifyV1ErrorResponse = 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: SourcifyV1LookupResponse = await response.json() + + let status: VerificationStatus = 'unknown' + let lookupUrl: string | undefined = undefined + if (lookupResponse.status === 'perfect' || lookupResponse.status === 'full') { + status = 'exactly verified' + lookupUrl = this.getContractCodeUrl(contractAddress, chainId, true) + } else if (lookupResponse.status === 'partial') { + status = '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: SourcifyV1File[], 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, 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/SourcifyVerifier.ts b/apps/contract-verification/src/app/Verifiers/SourcifyVerifier.ts index 636c69e6896..2e0552d08eb 100644 --- a/apps/contract-verification/src/app/Verifiers/SourcifyVerifier.ts +++ b/apps/contract-verification/src/app/Verifiers/SourcifyVerifier.ts @@ -1,166 +1,223 @@ -import { CompilerAbstract, SourcesCode } from '@remix-project/remix-solidity' +import { CompilerAbstract } from '@remix-project/remix-solidity' import { AbstractVerifier } from './AbstractVerifier' import type { LookupResponse, SourceFile, SubmittedContract, VerificationResponse, VerificationStatus } from '../types' import { getAddress } from 'ethers' interface SourcifyVerificationRequest { - address: string - chain: string - files: Record - creatorTxHash?: string - chosenContract?: string + stdJsonInput: any + compilerVersion: string + contractIdentifier: string + creationTransactionHash?: string } -type SourcifyVerificationStatus = 'perfect' | 'full' | 'partial' | null +type SourcifyVerificationStatus = 'exact_match' | 'match' | null interface SourcifyVerificationResponse { - result: [ - { - address: string - chainId: string - status: SourcifyVerificationStatus - libraryMap: { - [key: string]: string - } - message?: string - } - ] + verificationId: string } -interface SourcifyErrorResponse { - error: string +interface SourcifyCheckStatusResponse { + isJobCompleted: boolean + verificationId: string + jobStartTime: string + jobFinishTime: string + contract: { + match: SourcifyVerificationStatus + creationMatch: SourcifyVerificationStatus + runtimeMatch: SourcifyVerificationStatus + chainId: string + address: string + } + error?: SourcifyErrorResponse } -interface SourcifyFile { - name: string - path: string - content: string +interface SourcifyErrorResponse { + customCode: string + errorId: string + message: string } +type Sources = Record + interface SourcifyLookupResponse { - status: Exclude - files: SourcifyFile[] + match: SourcifyVerificationStatus + creationMatch: SourcifyVerificationStatus + runtimeMatch: SourcifyVerificationStatus + chainId: string + address: string + verifiedAt: string + matchId: string + sources: Sources + compilation: { + fullyQualifiedName: string + } } export class SourcifyVerifier extends AbstractVerifier { LOOKUP_STORE_DIR = 'sourcify-verified' + constructor(apiUrl: string, explorerUrl: string, protected receiptsUrl?: string) { + super(apiUrl, explorerUrl) + } + 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 metadata = JSON.parse(compilerAbstract.data.contracts[submittedContract.filePath][submittedContract.contractName].metadata) + const compilerVersion = `v${metadata.compiler.version}` + const contractIdentifier = `${submittedContract.filePath}:${submittedContract.contractName}` + // The CompilerAbstract.input property seems to be wrongly typed + const stdJsonInput = JSON.parse(compilerAbstract.input as unknown as string) + const body: SourcifyVerificationRequest = { - chain: submittedContract.chainId, - address: submittedContract.address, - files: { - 'metadata.json': metadataStr, - ...formattedSources, - }, + stdJsonInput, + compilerVersion, + contractIdentifier, } - const response = await fetch(new URL(this.apiUrl + '/verify').href, { + const response = await fetch(`${this.apiUrl}/v2/verify/${submittedContract.chainId}/${submittedContract.address}`, { method: 'POST', headers: { 'Content-Type': 'application/json', }, - body: JSON.stringify(body), + body: JSON.stringify(body) }) if (!response.ok) { + if (response.status === 409) { + return { status: 'already verified', receiptId: null, lookupUrl: this.getContractCodeUrl(submittedContract.address, submittedContract.chainId) } + } + 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) + throw new Error(errorResponse.message || 'Verification failed') } 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) + return { + status: 'pending', + receiptId: verificationResponse.verificationId, + receiptLookupUrl: this.receiptLookupUrl(verificationResponse.verificationId), + lookupUrl: this.getContractCodeUrl(submittedContract.address, submittedContract.chainId), } + } - // 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) + receiptLookupUrl(receiptId: string): string { + return `${this.receiptsUrl}/${receiptId}` + } + + async checkVerificationStatus(receiptId: string, chainId: string): Promise { + const response = await fetch(`${this.apiUrl}/v2/verify/${receiptId}`, { + method: 'GET', + }) + + if (!response.ok) { + let errorResponse: SourcifyErrorResponse + try { + errorResponse = await response.json() + } catch { + //pass + } + if (errorResponse) { + console.error('Error checking Sourcify verification status at ' + this.apiUrl + '\nStatus: ' + response.status + '\nResponse: ' + JSON.stringify(errorResponse)) + throw new Error(errorResponse.message || 'Status check failed') + } else { + const responseText = await response.text() + console.error('Error checking Sourcify verification status at ' + this.apiUrl + '\nStatus: ' + response.status + '\nResponse: ' + responseText) + throw new Error(responseText) + } + } + + const checkStatusResponse: SourcifyCheckStatusResponse = await response.json() + + if (!checkStatusResponse.isJobCompleted) { + return { status: 'pending', receiptId } + } + + if (checkStatusResponse.error) { + if (checkStatusResponse.error.customCode === 'already_verified') { + return { status: 'already verified', receiptId, message: checkStatusResponse.error.message } + } + const message = checkStatusResponse.error.message || 'Unknown error' + return { status: 'failed', receiptId, message } } - return { status, receiptId: null, lookupUrl } + let status: VerificationStatus + switch (checkStatusResponse.contract.match) { + case 'exact_match': + status = 'exactly verified' + break + case 'match': + status = 'verified' + break + default: + status = 'not verified' + break + } + return { + status, + receiptId, + } } async lookup(contractAddress: string, chainId: string): Promise { - const url = new URL(this.apiUrl + `/files/any/${chainId}/${contractAddress}`) + const url = new URL(this.apiUrl + `/v2/contract/${chainId}/${contractAddress}`) + url.searchParams.set('fields', 'sources,compilation.fullyQualifiedName') 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!') { + if (response.status === 404) { return { status: 'not verified' } } + const errorResponse: SourcifyErrorResponse = await response.json() console.error('Error on Sourcify lookup at ' + this.apiUrl + '\nStatus: ' + response.status + '\nResponse: ' + JSON.stringify(errorResponse)) - throw new Error(errorResponse.error) + throw new Error(errorResponse.message || 'Lookup failed') } 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) + if (lookupResponse.match === 'exact_match') { + status = 'exactly verified' + lookupUrl = this.getContractCodeUrl(contractAddress, chainId) + } else if (lookupResponse.match === 'match') { + status = 'verified' + lookupUrl = this.getContractCodeUrl(contractAddress, chainId) } - const { sourceFiles, targetFilePath } = this.processReceivedFiles(lookupResponse.files, contractAddress, chainId) + let sourceFiles: SourceFile[] = [] + let targetFilePath: string | undefined = undefined + + if (lookupResponse.sources && Object.keys(lookupResponse.sources).length > 0) { + const processed = this.processReceivedFiles(lookupResponse.sources, lookupResponse.compilation.fullyQualifiedName, contractAddress, chainId) + sourceFiles = processed.sourceFiles + targetFilePath = processed.targetFilePath + } 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}/`) + getContractCodeUrl(address: string, chainId: string): string { + const url = new URL(this.explorerUrl + `/${chainId}/${address}/`) return url.href } - processReceivedFiles(files: SourcifyFile[], contractAddress: string, chainId: string): { sourceFiles: SourceFile[]; targetFilePath?: string } { + processReceivedFiles(sources: Sources, fullyQualifiedName: string, 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, getAddress(contractAddress)]) { - const matching = file.path.match(`/${a}/(.*)$`) - if (matching) { - filePath = matching[1] - break - } - } - - if (filePath) { - result.push({ path: `${filePrefix}/${filePath}`, content: file.content }) - } + // Extract contract path from fully qualified name (path can include colons) + const splitIdentifier = fullyQualifiedName.split(':') + const contractPath = splitIdentifier.slice(0, -1).join(':') - 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}` + for (const [filePath, fileData] of Object.entries(sources)) { + const path = `${filePrefix}/sources/${filePath}` + result.push({ path, content: fileData.content }) + if (filePath === contractPath) { + targetFilePath = path } } diff --git a/apps/contract-verification/src/app/Verifiers/index.ts b/apps/contract-verification/src/app/Verifiers/index.ts index 92de876f5b6..7652a96b033 100644 --- a/apps/contract-verification/src/app/Verifiers/index.ts +++ b/apps/contract-verification/src/app/Verifiers/index.ts @@ -3,11 +3,13 @@ import { AbstractVerifier } from './AbstractVerifier' import { BlockscoutVerifier } from './BlockscoutVerifier' import { EtherscanVerifier } from './EtherscanVerifier' import { SourcifyVerifier } from './SourcifyVerifier' +import { SourcifyV1Verifier } from './SourcifyV1Verifier' import { RoutescanVerifier } from './RoutescanVerifier' export { AbstractVerifier } from './AbstractVerifier' export { BlockscoutVerifier } from './BlockscoutVerifier' export { SourcifyVerifier } from './SourcifyVerifier' +export { SourcifyV1Verifier } from './SourcifyV1Verifier' export { EtherscanVerifier } from './EtherscanVerifier' export { RoutescanVerifier } from './RoutescanVerifier' @@ -17,7 +19,10 @@ export function getVerifier(identifier: VerifierIdentifier, settings: VerifierSe if (!settings?.explorerUrl) { throw new Error('The Sourcify verifier requires an explorer URL.') } - return new SourcifyVerifier(settings.apiUrl, settings.explorerUrl) + if (settings?.useV1API) { + return new SourcifyV1Verifier(settings.apiUrl, settings.explorerUrl) + } + return new SourcifyVerifier(settings.apiUrl, settings.explorerUrl, settings.receiptsUrl) case 'Etherscan': if (!settings?.explorerUrl) { throw new Error('The Etherscan verifier requires an explorer URL.') diff --git a/apps/contract-verification/src/app/components/AccordionReceipt.tsx b/apps/contract-verification/src/app/components/AccordionReceipt.tsx index fb9ed49b78c..d8cbb4ab68b 100644 --- a/apps/contract-verification/src/app/components/AccordionReceipt.tsx +++ b/apps/contract-verification/src/app/components/AccordionReceipt.tsx @@ -105,20 +105,27 @@ const ReceiptsBody = ({ receipts }: { receipts: VerificationReceipt[] }) => { {['verified', 'partially verified', 'already verified'].includes(receipt.status) ? : - receipt.status === 'fully verified' ? + receipt.status === 'exactly verified' || receipt.status === 'fully verified' ? : receipt.status === 'failed' ? : ['pending', 'awaiting implementation verification'].includes(receipt.status) ? - : - + : + }
- - {receipt.verifierInfo.name} - +
+ + {receipt.verifierInfo.name} + + { + !!receipt.receiptLookupUrl && + + + } +
{!!receipt.lookupUrl && receipt.verifierInfo.name === 'Blockscout' ? : diff --git a/apps/contract-verification/src/app/components/ConstructorArguments.tsx b/apps/contract-verification/src/app/components/ConstructorArguments.tsx index 800880ae350..3bd5c6de81f 100644 --- a/apps/contract-verification/src/app/components/ConstructorArguments.tsx +++ b/apps/contract-verification/src/app/components/ConstructorArguments.tsx @@ -109,7 +109,7 @@ export const ConstructorArguments: React.FC = ({ abiE return (
- +
setToggleRawInput(!toggleRawInput)} />