diff --git a/app/components/VerificationForm.tsx b/app/components/VerificationForm.tsx index 6338c97..66c24f1 100644 --- a/app/components/VerificationForm.tsx +++ b/app/components/VerificationForm.tsx @@ -13,7 +13,8 @@ import OptionalFields from "./verification/OptionalFields"; import { frameworkMethods } from "../data/verificationMethods"; import type { VerificationMethod } from "../types/verification"; import { assembleAndSubmitStandardJson, submitStdJsonFile, submitMetadataVerification } from "../utils/sourcifyApi"; -import { buildMetadataSubmissionSources } from "../utils/metadataValidation"; +import { buildMetadataSubmissionSources, validateMetadataSources } from "../utils/metadataValidation"; +import type { ValidationSummary } from "../utils/metadataValidation"; import { parseBuildInfoFile } from "../utils/buildInfoValidation"; import { useCompilerVersions } from "../contexts/CompilerVersionsContext"; import MetadataValidation from "./verification/MetadataValidation"; @@ -41,6 +42,9 @@ export default function VerificationForm({ preselectedChainId, preselectedAddres const [isAddressValid, setIsAddressValid] = React.useState(false); const [lastSubmittedValues, setLastSubmittedValues] = React.useState(null); const [buildInfoError, setBuildInfoError] = React.useState(null); + const [metadataValidationResult, setMetadataValidationResult] = React.useState(null); + const [metadataValidationError, setMetadataValidationError] = React.useState(null); + const [isMetadataValidating, setIsMetadataValidating] = React.useState(false); // Clear success message after 3 seconds React.useEffect(() => { @@ -130,6 +134,47 @@ export default function VerificationForm({ preselectedChainId, preselectedAddres // Check if selected method is a framework method const isFrameworkMethod = frameworkMethods.some(method => method.id === selectedMethod); + React.useEffect(() => { + if (selectedMethod !== "metadata-json") { + setMetadataValidationResult(null); + setMetadataValidationError(null); + setIsMetadataValidating(false); + return; + } + + if (!metadataFile) { + setMetadataValidationResult(null); + setMetadataValidationError(null); + setIsMetadataValidating(false); + return; + } + + let cancelled = false; + const runValidation = async () => { + setIsMetadataValidating(true); + setMetadataValidationError(null); + + try { + const result = await validateMetadataSources(metadataFile, uploadedFiles); + if (cancelled) return; + setMetadataValidationResult(result); + } catch (error) { + if (cancelled) return; + setMetadataValidationResult(null); + setMetadataValidationError(error instanceof Error ? error.message : "Validation failed"); + } finally { + if (cancelled) return; + setIsMetadataValidating(false); + } + }; + + runValidation(); + + return () => { + cancelled = true; + }; + }, [selectedMethod, metadataFile, uploadedFiles]); + const { isFormValid, errors, getSubmissionErrors } = useFormValidation({ isAddressValid, selectedChainId, @@ -141,6 +186,9 @@ export default function VerificationForm({ preselectedChainId, preselectedAddres uploadedFiles, metadataFile, evmVersion, + metadataValidationResult, + metadataValidationError, + isMetadataValidationPending: isMetadataValidating, }); // Create a hash of current form values to detect changes @@ -384,8 +432,9 @@ export default function VerificationForm({ preselectedChainId, preselectedAddres {/* Metadata Validation - Show between metadata and source file uploads */} { }} + validationResult={metadataValidationResult} + validationError={metadataValidationError} + isValidating={isMetadataValidating} /> {/* Render an additional file upload for the sources when the method is metadata-json. We can treat the sources' file upload as a multiple-files case. */} @@ -486,4 +535,4 @@ export default function VerificationForm({ preselectedChainId, preselectedAddres setShowSettingsModal(false)} hideImportSettings={hideImport} /> ); -} \ No newline at end of file +} diff --git a/app/components/verification/ExternalVerifierStatuses.tsx b/app/components/verification/ExternalVerifierStatuses.tsx new file mode 100644 index 0000000..9eaf7b9 --- /dev/null +++ b/app/components/verification/ExternalVerifierStatuses.tsx @@ -0,0 +1,351 @@ +import { useCallback, useEffect, useRef, useState } from "react"; +import { IoOpenOutline } from "react-icons/io5"; +import type { ExternalVerifications } from "~/utils/sourcifyApi"; +import { + buildStatus, + buildContractStatus, + requestExternalVerifierContract, + requestExternalVerifierStatus, + EXTERNAL_VERIFIER_METADATA, + type ExternalVerifierContractState, + type ExternalVerifierContractStatus, + type ExternalVerifierKey, + type ExternalVerifierState, + type ExternalVerifierStatus, +} from "~/utils/externalVerifiers"; + +const STATUS_BADGE_STYLES: Record = { + success: "bg-green-100 text-green-800", + pending: "bg-yellow-100 text-yellow-800", + error: "bg-red-100 text-red-800", + expired: "bg-gray-100 text-gray-800", + unknown: "bg-gray-100 text-gray-800", + no_api_key: "bg-gray-100 text-grey-800", + already_verified: "bg-green-50 text-grey-600", +}; + +const STATUS_LABELS: Record = { + success: "Successful", + pending: "Pending", + error: "Error", + expired: "Expired", + unknown: "Status unknown", + no_api_key: "Missing API key", + already_verified: "Already verified", +}; + +const CONTRACT_STATUS_BADGE_STYLES: Record = { + verified: "bg-green-100 text-green-800", + not_verified: "bg-red-100 text-red-800", + error: "bg-red-100 text-red-800", + unknown: "bg-gray-100 text-gray-800", + no_api_key: "bg-gray-100 text-grey-800", +}; + +const CONTRACT_STATUS_LABELS: Record = { + verified: "Verified", + not_verified: "Not verified", + error: "Contract status error", + unknown: "Status unknown", + no_api_key: "Missing API key", +}; + +const DEFAULT_REFRESH_SECONDS = 3; + +type ExternalVerifierStatusMap = Partial>; +type ExternalVerifierContractStatusMap = Partial>; + +interface ExternalVerifierStatusesProps { + verifications?: ExternalVerifications; + refreshRateSeconds?: number; + jobFinishTime?: string; +} + +const ExternalVerifierStatuses = ({ + verifications, + refreshRateSeconds = DEFAULT_REFRESH_SECONDS, + jobFinishTime, +}: ExternalVerifierStatusesProps) => { + const [externalVerifierStatuses, setExternalVerifierStatuses] = useState({}); + const [externalVerifierContractStatuses, setExternalVerifierContractStatuses] = + useState({}); + const [countdown, setCountdown] = useState(null); + const externalVerifierStatusesRef = useRef({}); + const externalVerifierContractStatusesRef = useRef({}); + const refreshTimeoutIdRef = useRef(null); + const countdownIntervalIdRef = useRef(null); + + const clearCountdownTimers = useCallback(() => { + if (refreshTimeoutIdRef.current) { + window.clearTimeout(refreshTimeoutIdRef.current); + refreshTimeoutIdRef.current = null; + } + if (countdownIntervalIdRef.current) { + window.clearInterval(countdownIntervalIdRef.current); + countdownIntervalIdRef.current = null; + } + }, []); + + useEffect(() => { + if (!verifications) { + externalVerifierStatusesRef.current = {}; + externalVerifierContractStatusesRef.current = {}; + setExternalVerifierStatuses({}); + setExternalVerifierContractStatuses({}); + clearCountdownTimers(); + setCountdown(null); + return; + } + + const verifierEntries = Object.entries(verifications).filter(([, data]) => !!data) as Array< + [ExternalVerifierKey, ExternalVerifications[ExternalVerifierKey]] + >; + + if (verifierEntries.length === 0) { + externalVerifierStatusesRef.current = {}; + externalVerifierContractStatusesRef.current = {}; + setExternalVerifierStatuses({}); + setExternalVerifierContractStatuses({}); + clearCountdownTimers(); + setCountdown(null); + return; + } + + const activeKeys = new Set(verifierEntries.map(([key]) => key)); + const preservedStatusesEntries = Object.entries(externalVerifierStatusesRef.current).filter(([key]) => + activeKeys.has(key as ExternalVerifierKey) + ); + const preservedStatuses = Object.fromEntries(preservedStatusesEntries); + externalVerifierStatusesRef.current = preservedStatuses; + setExternalVerifierStatuses(preservedStatuses); + const preservedContractStatusesEntries = Object.entries(externalVerifierContractStatusesRef.current).filter( + ([key]) => activeKeys.has(key as ExternalVerifierKey) + ); + const preservedContractStatuses = Object.fromEntries(preservedContractStatusesEntries); + externalVerifierContractStatusesRef.current = preservedContractStatuses; + setExternalVerifierContractStatuses(preservedContractStatuses); + + let isCancelled = false; + clearCountdownTimers(); + + const shouldFetchKey = (key: ExternalVerifierKey, data: ExternalVerifications[ExternalVerifierKey]) => { + const status = externalVerifierStatusesRef.current[key]; + const contractStatus = externalVerifierContractStatusesRef.current[key]; + const messageIndicatesPending = + typeof status?.message === "string" && status.message.toLowerCase().includes("pending"); + const needsVerificationStatus = + !status || status.state === "pending" || status.state === "unknown" || messageIndicatesPending; + const hasContractApiUrl = Boolean(data?.contractApiUrl); + const needsContractStatus = hasContractApiUrl && (!contractStatus || contractStatus.state === "unknown"); + return needsVerificationStatus || needsContractStatus; + }; + + const updateStatuses = async (): Promise => { + const keysToFetch = verifierEntries.filter(([key, data]) => shouldFetchKey(key, data)); + + if (keysToFetch.length === 0) { + return verifierEntries.some(([key, data]) => shouldFetchKey(key, data)); + } + + const results = await Promise.all( + keysToFetch.map(async ([key, data]) => { + const [status, contractStatus] = await Promise.all([ + requestExternalVerifierStatus(key, data, jobFinishTime), + requestExternalVerifierContract(key, data), + ]); + return [key, status, contractStatus] as const; + }) + ); + + if (isCancelled) { + return false; + } + + const next = { ...externalVerifierStatusesRef.current }; + const nextContractStatuses = { ...externalVerifierContractStatusesRef.current }; + results.forEach(([key, status, contractStatus]) => { + next[key] = status; + nextContractStatuses[key] = contractStatus; + }); + + externalVerifierStatusesRef.current = next; + externalVerifierContractStatusesRef.current = nextContractStatuses; + setExternalVerifierStatuses(next); + setExternalVerifierContractStatuses(nextContractStatuses); + + return verifierEntries.some(([key, data]) => shouldFetchKey(key, data)); + }; + + const startCountdown = () => { + setCountdown(refreshRateSeconds); + if (countdownIntervalIdRef.current) { + window.clearInterval(countdownIntervalIdRef.current); + } + countdownIntervalIdRef.current = window.setInterval(() => { + setCountdown((prev) => { + if (prev === null) return null; + if (prev <= 1) { + if (countdownIntervalIdRef.current) { + window.clearInterval(countdownIntervalIdRef.current); + countdownIntervalIdRef.current = null; + } + return 0; + } + return prev - 1; + }); + }, 1000); + }; + + const scheduleNextPoll = () => { + if (isCancelled) return; + if (refreshTimeoutIdRef.current) { + window.clearTimeout(refreshTimeoutIdRef.current); + } + startCountdown(); + refreshTimeoutIdRef.current = window.setTimeout(() => { + setCountdown(null); + pollStatuses(); + }, refreshRateSeconds * 1000); + }; + + const pollStatuses = async () => { + const hasPending = await updateStatuses(); + if (isCancelled) return; + if (hasPending) { + scheduleNextPoll(); + } else { + clearCountdownTimers(); + setCountdown(null); + } + }; + + pollStatuses(); + + return () => { + isCancelled = true; + clearCountdownTimers(); + }; + }, [verifications, refreshRateSeconds, jobFinishTime, clearCountdownTimers]); + + if (!verifications || !Object.values(verifications).some((value) => !!value)) { + return null; + } + + return ( +
+
+
+

Other Verifiers

+

Sourcify automatically shares contracts with other known verifiers

+
+ {countdown !== null && ( +

+ Next refresh in: {countdown} seconds +

+ )} +
+
+ {Object.entries(verifications) + .filter(([, value]) => !!value) + .sort(([aKey], [bKey]) => + (EXTERNAL_VERIFIER_METADATA[aKey as ExternalVerifierKey]?.label ?? aKey).localeCompare( + EXTERNAL_VERIFIER_METADATA[bKey as ExternalVerifierKey]?.label ?? bKey + ) + ) + .map(([key, verifierData]) => { + const typedKey = key as ExternalVerifierKey; + const metadata = EXTERNAL_VERIFIER_METADATA[typedKey]; + const label = metadata?.label ?? key; + const icon = metadata?.icon; + const isAlreadyVerified = verifierData?.verificationId === "VERIFIER_ALREADY_VERIFIED"; + const fallbackStatus = verifierData?.error + ? buildStatus("error", verifierData.error) + : verifierData?.verificationId + ? buildStatus("pending", `Awaiting verifier response (${verifierData.verificationId})`) + : buildStatus("unknown", "Waiting for verifier response"); + const status = externalVerifierStatuses[typedKey] ?? fallbackStatus; + const fallbackContractStatus = verifierData?.error + ? buildContractStatus("not_verified", verifierData.error) + : isAlreadyVerified + ? buildContractStatus("verified", "Already verified") + : verifierData?.contractApiUrl + ? buildContractStatus("unknown", "Checking contract verification status") + : buildContractStatus("unknown", "No contract verification status available"); + const contractStatus = externalVerifierContractStatuses[typedKey] ?? fallbackContractStatus; + + return ( +
+
+
+
+ {icon ? {icon.alt} : null} +

{label}

+
+ {verifierData?.verificationId && ( +

Job ID: {verifierData.verificationId}

+ )} +
+
+
+
+
+ {verifierData?.statusUrl ? ( + + Job Status + + + ) : ( + "Job Status" + )} +
+
+ {verifierData?.explorerUrl ? ( + + Contract + + + ) : ( + "Contract" + )} +
+
+ + {STATUS_LABELS[status.state]} + +
+
+ + {CONTRACT_STATUS_LABELS[contractStatus.state]} + +
+
+
+
+
+
+ ); + })} +
+
+ ); +}; + +export default ExternalVerifierStatuses; diff --git a/app/components/verification/FileUpload.tsx b/app/components/verification/FileUpload.tsx index 61f840a..155e3bb 100644 --- a/app/components/verification/FileUpload.tsx +++ b/app/components/verification/FileUpload.tsx @@ -95,19 +95,24 @@ export default function FileUpload({ setShowPasteMode(false); }, [selectedMethod]); + const getExpectedExtension = () => { + if (["std-json", "metadata-json", "build-info"].includes(selectedMethod)) { + return ".json"; + } + return selectedLanguage === "vyper" ? ".vy" : ".sol"; + }; + const validateFileName = (fileName: string): string | null => { - // For std-json, filename is optional - if (selectedMethod === "std-json") { + // For std-json and metadata-json, filename is optional + if (["std-json", "metadata-json"].includes(selectedMethod)) { if (!fileName.trim()) { return null; // No error for empty filename in std-json } - } else { - if (!fileName.trim()) { - return "File name is required"; - } + } else if (!fileName.trim()) { + return "File name is required"; } - const expectedExtension = selectedMethod === "std-json" ? ".json" : selectedLanguage === "vyper" ? ".vy" : ".sol"; + const expectedExtension = getExpectedExtension(); if (!fileName.endsWith(expectedExtension)) { return `File name must end with ${expectedExtension}`; } @@ -395,7 +400,7 @@ export default function FileUpload({ {requirements.maxFiles === 1 && showPasteMode && (
- {selectedMethod !== "std-json" && ( + {selectedMethod !== "std-json" && selectedMethod !== "metadata-json" && (
); -} \ No newline at end of file +} diff --git a/app/hooks/useFormValidation.ts b/app/hooks/useFormValidation.ts index b689a37..0d532c7 100644 --- a/app/hooks/useFormValidation.ts +++ b/app/hooks/useFormValidation.ts @@ -1,4 +1,5 @@ import { useMemo, useCallback, useState, useEffect } from "react"; +import type { ValidationSummary } from "../utils/metadataValidation"; import type { Language, SelectedMethod } from "../types/verification"; interface ValidationParams { @@ -12,6 +13,9 @@ interface ValidationParams { uploadedFiles: File[]; metadataFile: File | null; evmVersion: string; + metadataValidationResult: ValidationSummary | null; + metadataValidationError: string | null; + isMetadataValidationPending: boolean; } interface ValidationErrors { @@ -38,14 +42,17 @@ export function useFormValidation({ uploadedFiles, metadataFile, evmVersion, + metadataValidationResult, + metadataValidationError, + isMetadataValidationPending, }: ValidationParams) { // JSON validation state for std-json method const [isJsonValid, setIsJsonValid] = useState(true); - // Validate JSON files when uploaded for std-json, build-info, or metadata-json methods + // Validate JSON files when uploaded for std-json, build-info methods useEffect(() => { const validateJsonFile = async () => { - if ((selectedMethod === "std-json" || selectedMethod === "metadata-json" || selectedMethod === "build-info") && uploadedFiles.length > 0) { + if ((selectedMethod === "std-json" || selectedMethod === "build-info") && uploadedFiles.length > 0) { try { const file = uploadedFiles[0]; const content = await file.text(); @@ -88,8 +95,13 @@ export function useFormValidation({ if (!areFilesRequired) return true; if (selectedMethod === "metadata-json") { - // metadata-json requires both metadata file and source files - return metadataFile !== null && uploadedFiles.length > 0; + // metadata-json requires a metadata file and valid sources (sources can be embedded) + return ( + metadataFile !== null && + !isMetadataValidationPending && + !metadataValidationError && + !!metadataValidationResult?.allRequiredFound + ); } else if (selectedMethod === "std-json" || selectedMethod === "build-info") { // std-json and build-info require uploaded files and valid JSON return uploadedFiles.length > 0 && isJsonValid; @@ -136,12 +148,16 @@ export function useFormValidation({ // File validation if (areFilesRequired && !validateFiles()) { if (selectedMethod === "metadata-json") { - if (!metadataFile && uploadedFiles.length === 0) { - newErrors.files = "Please upload metadata.json and source files"; - } else if (!metadataFile) { + if (!metadataFile) { newErrors.files = "Please upload metadata.json file"; - } else if (uploadedFiles.length === 0) { - newErrors.files = "Please upload source files"; + } else if (isMetadataValidationPending) { + newErrors.files = "Validating metadata sources..."; + } else if (metadataValidationError) { + newErrors.files = metadataValidationError; + } else if (metadataValidationResult && !metadataValidationResult.allRequiredFound) { + newErrors.files = metadataValidationResult.message; + } else { + newErrors.files = "Metadata sources validation failed"; } } else if (selectedMethod === "std-json") { if (uploadedFiles.length === 0) { @@ -184,6 +200,9 @@ export function useFormValidation({ isEvmVersionRequired, evmVersion, isJsonValid, + metadataValidationResult, + metadataValidationError, + isMetadataValidationPending, ]); // Calculate overall form validity @@ -223,7 +242,21 @@ export function useFormValidation({ submissionErrors.push("Contract identifier is required"); } if (areFilesRequired && !validateFiles()) { - submissionErrors.push("Required files are missing"); + if (selectedMethod === "metadata-json") { + if (!metadataFile) { + submissionErrors.push("Metadata file is required"); + } else if (isMetadataValidationPending) { + submissionErrors.push("Waiting for metadata sources validation"); + } else if (metadataValidationError) { + submissionErrors.push(metadataValidationError); + } else if (metadataValidationResult && !metadataValidationResult.allRequiredFound) { + submissionErrors.push(metadataValidationResult.message); + } else { + submissionErrors.push("Metadata sources validation failed"); + } + } else { + submissionErrors.push("Required files are missing"); + } } if (isEvmVersionRequired && !evmVersion) { submissionErrors.push("EVM version selection is required"); @@ -246,6 +279,9 @@ export function useFormValidation({ isEvmVersionRequired, evmVersion, isJsonValid, + metadataValidationResult, + metadataValidationError, + isMetadataValidationPending, ]); return { diff --git a/app/routes/jobs.$jobId.tsx b/app/routes/jobs.$jobId.tsx index f53923d..3f38235 100644 --- a/app/routes/jobs.$jobId.tsx +++ b/app/routes/jobs.$jobId.tsx @@ -1,17 +1,23 @@ import type { Route } from "./+types/jobs.$jobId"; import { useParams } from "react-router"; -import { useEffect, useState } from "react"; -import { IoCheckmarkDoneCircle, IoCheckmarkCircle, IoOpenOutline, IoClose, IoBugOutline } from "react-icons/io5"; +import { useEffect, useRef, useState } from "react"; +import { IoOpenOutline, IoClose, IoBugOutline } from "react-icons/io5"; import { TbArrowsDiff } from "react-icons/tb"; import { useChains } from "../contexts/ChainsContext"; import { getChainName } from "../utils/chains"; -import { getVerificationJobStatus, type VerificationJobStatus } from "../utils/sourcifyApi"; +import { + getVerificationJobStatus, + type VerificationJobStatus, + type ExternalVerifications, +} from "../utils/sourcifyApi"; import PageLayout from "../components/PageLayout"; import BytecodeDiffModal from "../components/verification/BytecodeDiffModal"; import MatchBadge from "../components/verification/MatchBadge"; +import ExternalVerifierStatuses from "../components/verification/ExternalVerifierStatuses"; import { Dialog, DialogPanel, DialogTitle } from "@headlessui/react"; import { useServerConfig } from "../contexts/ServerConfigContext"; import { generateGitHubIssueUrl } from "../utils/githubIssue"; +import { type ExternalVerifierKey } from "~/utils/externalVerifiers"; export function meta({ }: Route.MetaArgs) { const { jobId } = useParams<{ jobId: string }>(); @@ -27,6 +33,13 @@ export function meta({ }: Route.MetaArgs) { } const DEFAULT_COUNTDOWN = 5; +const MAX_EXTERNAL_VERIFICATION_RETRIES = 3; + +const REQUIRED_EXTERNAL_VERIFIER_KEYS: ExternalVerifierKey[] = ["etherscan", "blockscout", "routescan"]; + +const hasAllRequiredExternalVerifications = (verifications?: ExternalVerifications) => { + return REQUIRED_EXTERNAL_VERIFIER_KEYS.every((key) => verifications?.[key] != null); +}; export default function JobDetails() { const { jobId } = useParams<{ jobId: string }>(); @@ -46,6 +59,13 @@ export default function JobDetails() { const [expandedErrors, setExpandedErrors] = useState>(new Set()); const [expandedModalErrors, setExpandedModalErrors] = useState>(new Set()); const { serverUrl } = useServerConfig(); + const hasExternalVerificationData = hasAllRequiredExternalVerifications(jobData?.externalVerifications); + const isJobCompletedWithExternalVerifications = + Boolean(jobData?.isJobCompleted) && hasExternalVerificationData; + const missingExternalVerificationData = Boolean(jobData?.isJobCompleted && !hasExternalVerificationData); + const externalVerificationRetryCountRef = useRef(0); + const hasReachedExternalVerificationRetryLimit = + missingExternalVerificationData && externalVerificationRetryCountRef.current >= MAX_EXTERNAL_VERIFICATION_RETRIES; const fetchJobStatus = async () => { if (!jobId) return; @@ -62,18 +82,42 @@ export default function JobDetails() { } }; - // Initial fetch + const initialFetchJobIdRef = useRef(null); + useEffect(() => { - fetchJobStatus(); + if (!missingExternalVerificationData) { + externalVerificationRetryCountRef.current = 0; + } + }, [missingExternalVerificationData]); + + useEffect(() => { + externalVerificationRetryCountRef.current = 0; + }, [jobId]); + + useEffect(() => { + if (!jobId) return; + const alreadyFetched = initialFetchJobIdRef.current === jobId; + initialFetchJobIdRef.current = jobId; + if (!alreadyFetched) { + fetchJobStatus(); + } }, [jobId]); // Auto-refresh for pending jobs with countdown useEffect(() => { - if (!jobData || jobData.isJobCompleted) return; + // Old jobs don't have external verifications, so we need a mechanism to stop retrying if the value is not set + if (!jobData || isJobCompletedWithExternalVerifications || hasReachedExternalVerificationRetryLimit) return; const interval = setInterval(() => { setCountdown((prev) => { if (prev <= 1) { + if (missingExternalVerificationData) { + if (externalVerificationRetryCountRef.current >= MAX_EXTERNAL_VERIFICATION_RETRIES) { + clearInterval(interval); + return DEFAULT_COUNTDOWN; + } + externalVerificationRetryCountRef.current += 1; + } fetchJobStatus(); return DEFAULT_COUNTDOWN; // Reset countdown } @@ -82,7 +126,12 @@ export default function JobDetails() { }, 1000); return () => clearInterval(interval); - }, [jobData]); + }, [ + jobData, + isJobCompletedWithExternalVerifications, + missingExternalVerificationData, + hasReachedExternalVerificationRetryLimit, + ]); const handleRefresh = () => { fetchJobStatus(); @@ -159,6 +208,20 @@ export default function JobDetails() { ); }; + const ViewInRepositoryButton = ({ chainId, address }: { chainId: string; address: string }) => ( + + ); + if (loading && !jobData) { return ( @@ -311,23 +374,18 @@ export default function JobDetails() { />
{(jobData.contract.runtimeMatch || jobData.contract.creationMatch) && ( - + )} {jobData.contract.matchId && } )} + + {/* Error Details */} {jobData.error && (
@@ -344,6 +402,9 @@ export default function JobDetails() { } /> + {jobData.error.customCode === 'already_verified' && jobData.contract && ( + + )} {/* Compiler Errors */} {jobData.error.errorData?.compilerErrors && (
diff --git a/app/utils/etherscanApi.ts b/app/utils/etherscanApi.ts index 11965ba..c0f4d60 100644 --- a/app/utils/etherscanApi.ts +++ b/app/utils/etherscanApi.ts @@ -81,3 +81,46 @@ export const processEtherscanResult = async ( throw error; } }; + +export interface EtherscanVerificationStatusResponse { + status: string; + message: string; + result: string; +} + +export const fetchEtherscanVerificationStatus = async ( + statusUrl: string, + apiKey: string +): Promise => { + if (!statusUrl) { + throw new Error("Missing Etherscan status URL"); + } + + if (!apiKey) { + throw new Error("Missing Etherscan API key"); + } + + const url = new URL(statusUrl); + url.searchParams.set("apikey", apiKey); + + const response = await fetch(url.toString()); + + if (!response.ok) { + const errorText = await response.text(); + throw new Error( + errorText || `Etherscan status request failed (${response.status})` + ); + } + + const data = (await response.json()) as Partial; + + if ( + !data || + typeof data.status === "undefined" || + typeof data.message === "undefined" + ) { + throw new Error("Unexpected response from Etherscan status API"); + } + + return data as EtherscanVerificationStatusResponse; +}; diff --git a/app/utils/externalVerifiers.ts b/app/utils/externalVerifiers.ts new file mode 100644 index 0000000..c25c86e --- /dev/null +++ b/app/utils/externalVerifiers.ts @@ -0,0 +1,328 @@ +import { + fetchEtherscanVerificationStatus, + type EtherscanVerificationStatusResponse, +} from "./etherscanApi"; +import { getEtherscanApiKey } from "./etherscanStorage"; +import type { ExternalVerifications } from "./sourcifyApi"; + +export type ExternalVerifierKey = keyof ExternalVerifications; +export type ExternalVerifierState = + | "pending" + | "success" + | "error" + | "no_api_key" + | "expired" + | "already_verified" + | "unknown"; +export type ExternalVerifierContractState = + | "verified" + | "not_verified" + | "error" + | "no_api_key" + | "unknown"; + +export interface ExternalVerifierStatus { + state: ExternalVerifierState; + message: string; + lastUpdated: number; +} + +export interface ExternalVerifierContractStatus { + state: ExternalVerifierContractState; + message: string; + lastUpdated: number; +} + +interface ExternalVerifierIcon { + src: string; + alt: string; + className?: string; +} + +interface ExternalVerifierMetadata { + label: string; + icon?: ExternalVerifierIcon; +} + +export const EXTERNAL_VERIFIER_METADATA: Record< + ExternalVerifierKey, + ExternalVerifierMetadata +> = { + etherscan: { + label: "Etherscan", + icon: { + src: "/etherscan.webp", + alt: "Etherscan", + className: "w-5 h-5 bg-white p-[1px] rounded-full", + }, + }, + blockscout: { + label: "Blockscout", + icon: { + src: "/blockscout.png", + alt: "Blockscout", + className: "w-5 h-5 bg-white p-[1px]", + }, + }, + routescan: { + label: "Routescan", + icon: { + src: "/routescan.png", + alt: "Routescan", + className: "w-5 h-5 bg-white p-[1px] rounded-full", + }, + }, +}; + +const EXTERNAL_VERIFIER_EXPIRATION_MINUTES: Partial< + Record +> = { + routescan: 24, +}; + +export const buildStatus = ( + state: ExternalVerifierState, + message: string +): ExternalVerifierStatus => ({ + state, + message, + lastUpdated: Date.now(), +}); + +export const buildContractStatus = ( + state: ExternalVerifierContractState, + message: string +): ExternalVerifierContractStatus => ({ + state, + message, + lastUpdated: Date.now(), +}); + +const interpretExternalVerifierStatus = ( + payload: EtherscanVerificationStatusResponse +): ExternalVerifierStatus => { + const result = payload.result?.trim(); + const lowerResult = result?.toLowerCase(); + + if (lowerResult) { + if (lowerResult.startsWith("fail - unable to verify")) { + return buildStatus("error", result); + } + + if (lowerResult === "pending in queue") { + return buildStatus("pending", result); + } + + if (lowerResult === "pass - verified") { + return buildStatus("success", result); + } + + if (lowerResult === "already verified") { + return buildStatus("already_verified", result); + } + + if (lowerResult === "unknown uid") { + return buildStatus("error", result); + } + } + + if (payload.message.startsWith("OK")) { + return buildStatus("success", result); + } + + if (payload.message.startsWith("NOTOK")) { + return buildStatus("error", result); + } + + return buildStatus("unknown", result); +}; + +interface ExternalVerifierContractStatusResponse { + status?: string; + result?: unknown; +} + +const interpretExternalVerifierContractStatus = ( + payload: ExternalVerifierContractStatusResponse +): ExternalVerifierContractStatus => { + if (payload.status === "1") { + return buildContractStatus("verified", "Contract verified"); + } + + if (payload.status === "0") { + return buildContractStatus("not_verified", "Contract not verified"); + } + + return buildContractStatus( + "unknown", + "Contract verification status unavailable" + ); +}; + +const isExternalVerifierExpired = ( + verifierKey: ExternalVerifierKey, + jobFinishTime?: string +): boolean => { + const expirationMinutes = EXTERNAL_VERIFIER_EXPIRATION_MINUTES[verifierKey]; + if (!expirationMinutes) return false; + + if (!jobFinishTime) return false; + + const finishedAtMs = Date.parse(jobFinishTime); + if (Number.isNaN(finishedAtMs)) return false; + + const elapsedMinutes = (Date.now() - finishedAtMs) / (60 * 1000); + return elapsedMinutes >= expirationMinutes; +}; + +export const requestExternalVerifierStatus = async ( + verifierKey: ExternalVerifierKey, + verificationData?: ExternalVerifications[ExternalVerifierKey], + jobFinishTime?: string +): Promise => { + if (!verificationData) { + return buildStatus("unknown", "No verification data available"); + } + + if (verificationData.error) { + return buildStatus("error", verificationData.error); + } + + if (verificationData.verificationId === "VERIFIER_ALREADY_VERIFIED") { + return buildStatus("already_verified", "Already verified"); + } + + if (isExternalVerifierExpired(verifierKey, jobFinishTime)) { + return buildStatus( + "expired", + `Status expired after ${EXTERNAL_VERIFIER_EXPIRATION_MINUTES[verifierKey]} minutes` + ); + } + + if (!verificationData.statusUrl) { + if (verificationData.verificationId) { + return buildStatus( + "pending", + `Awaiting verifier response (${verificationData.verificationId})` + ); + } + return buildStatus("unknown", "No status URL provided"); + } + + try { + let payload: EtherscanVerificationStatusResponse; + + // Special case for etherscan in which we need + // to pass the EtherscanKey to check the verification result + if (verifierKey === "etherscan") { + const apiKey = getEtherscanApiKey(); + if (!apiKey) { + return buildStatus( + "no_api_key", + "Add your Etherscan API key in Settings to fetch the status." + ); + } + payload = await fetchEtherscanVerificationStatus( + verificationData.statusUrl, + apiKey + ); + } else { + const response = await fetch(verificationData.statusUrl); + const rawBody = await response.text(); + + if (!response.ok) { + throw new Error( + rawBody || `Status request failed (${response.status})` + ); + } + + try { + payload = JSON.parse(rawBody) as EtherscanVerificationStatusResponse; + } catch { + throw new Error("Unexpected status response format"); + } + } + + return interpretExternalVerifierStatus(payload); + } catch (err) { + return buildStatus( + "error", + err instanceof Error ? err.message : "Failed to fetch status" + ); + } +}; + +export const requestExternalVerifierContract = async ( + verifierKey: ExternalVerifierKey, + verificationData?: ExternalVerifications[ExternalVerifierKey] +): Promise => { + if (!verificationData) { + return buildContractStatus("unknown", "No verification data available"); + } + + if (verificationData.error) { + return buildContractStatus("not_verified", verificationData.error); + } + + if (verificationData.verificationId === "VERIFIER_ALREADY_VERIFIED") { + return buildContractStatus("verified", "Already verified"); + } + + if (!verificationData.contractApiUrl) { + return buildContractStatus("unknown", "No contract status URL provided"); + } + + try { + let payload: ExternalVerifierContractStatusResponse; + + if (verifierKey === "etherscan") { + const apiKey = getEtherscanApiKey(); + if (!apiKey) { + return buildContractStatus( + "no_api_key", + "Add your Etherscan API key in Settings to fetch the contract status." + ); + } + const url = new URL(verificationData.contractApiUrl); + url.searchParams.set("apikey", apiKey); + const response = await fetch(url.toString()); + const rawBody = await response.text(); + + if (!response.ok) { + throw new Error( + rawBody || `Contract status request failed (${response.status})` + ); + } + + try { + payload = JSON.parse(rawBody) as ExternalVerifierContractStatusResponse; + } catch { + throw new Error("Unexpected contract status response format"); + } + } else { + const response = await fetch(verificationData.contractApiUrl); + const rawBody = await response.text(); + + if (!response.ok) { + throw new Error( + rawBody || `Contract status request failed (${response.status})` + ); + } + + try { + payload = JSON.parse(rawBody) as ExternalVerifierContractStatusResponse; + } catch { + throw new Error("Unexpected contract status response format"); + } + } + + return interpretExternalVerifierContractStatus(payload); + } catch (err) { + return buildContractStatus( + "error", + err instanceof Error + ? err.message + : "Failed to fetch contract verification status" + ); + } +}; diff --git a/app/utils/metadataValidation.ts b/app/utils/metadataValidation.ts index fec504c..e65ad60 100644 --- a/app/utils/metadataValidation.ts +++ b/app/utils/metadataValidation.ts @@ -1,13 +1,13 @@ import { id as keccak256str } from "ethers"; -interface MetadataSource { +export interface MetadataSource { keccak256: string; content?: string; license?: string; urls?: string[]; } -interface SourceValidationResult { +export interface SourceValidationResult { expectedFileName: string; matchedFileName?: string; // Actual uploaded file name that matched the hash status: "found" | "missing" | "embedded"; @@ -18,7 +18,7 @@ interface SourceValidationResult { content?: string; // For embedded sources } -interface ValidationSummary { +export interface ValidationSummary { allRequiredFound: boolean; missingCount: number; unnecessaryCount: number; diff --git a/app/utils/sourcifyApi.ts b/app/utils/sourcifyApi.ts index b33206b..9f11223 100644 --- a/app/utils/sourcifyApi.ts +++ b/app/utils/sourcifyApi.ts @@ -234,6 +234,20 @@ export async function submitMetadataVerification( return response.json(); } +export interface ExternalVerification { + statusUrl?: string; + contractApiUrl?: string; + explorerUrl?: string; + verificationId?: string; + error?: string; +} + +export interface ExternalVerifications { + etherscan?: ExternalVerification; + blockscout?: ExternalVerification; + routescan?: ExternalVerification; +} + // Verification Job Status Types export interface VerificationJobStatus { isJobCompleted: boolean; @@ -265,7 +279,7 @@ export interface VerificationJobStatus { jobStartTime: string; jobFinishTime?: string; compilationTime?: string; - contract?: { + contract: { match: "match" | "exact_match" | null; creationMatch: "match" | "exact_match" | null; runtimeMatch: "match" | "exact_match" | null; @@ -274,6 +288,7 @@ export interface VerificationJobStatus { verifiedAt?: string; matchId?: string; }; + externalVerifications?: ExternalVerifications; } export async function getVerificationJobStatus( diff --git a/public/blockscout.png b/public/blockscout.png new file mode 100644 index 0000000..392afb6 Binary files /dev/null and b/public/blockscout.png differ diff --git a/public/routescan.png b/public/routescan.png new file mode 100644 index 0000000..c54c023 Binary files /dev/null and b/public/routescan.png differ