diff --git a/app/components/verification/ExternalVerifierStatuses.tsx b/app/components/verification/ExternalVerifierStatuses.tsx new file mode 100644 index 0000000..3900553 --- /dev/null +++ b/app/components/verification/ExternalVerifierStatuses.tsx @@ -0,0 +1,340 @@ +import { useCallback, useEffect, useRef, useState } from "react"; +import { IoOpenOutline } from "react-icons/io5"; +import type { ExternalVerifications } from "~/utils/sourcifyApi"; +import { + buildStatus, + buildContractStatus, + requestExternalVerifierContract, + requestExternalVerifierStatus, + type ExternalVerifierContractState, + type ExternalVerifierContractStatus, + type ExternalVerifierKey, + type ExternalVerifierState, + type ExternalVerifierStatus, +} from "~/utils/externalVerifiers"; + +const EXTERNAL_VERIFIER_LABELS: Record = { + etherscan: "Etherscan", + blockscout: "Blockscout", + routescan: "Routescan", +}; + +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", + 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", + 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; +} + +const ExternalVerifierStatuses = ({ + verifications, + refreshRateSeconds = DEFAULT_REFRESH_SECONDS, +}: 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), + 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, 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_LABELS[aKey as ExternalVerifierKey] ?? aKey).localeCompare( + EXTERNAL_VERIFIER_LABELS[bKey as ExternalVerifierKey] ?? bKey + ) + ) + .map(([key, verifierData]) => { + const typedKey = key as ExternalVerifierKey; + const label = EXTERNAL_VERIFIER_LABELS[typedKey] ?? key; + 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 ( +
+
+
+

{label}

+ {verifierData?.verificationId && ( +

+ Verification ID: {verifierData.verificationId} +

+ )} + {verifierData?.explorerUrl && ( + + View contract + + + )} +
+
+
+
+
Verification
+
Contract
+
+ + {STATUS_LABELS[status.state]} + +
+
+ + {CONTRACT_STATUS_LABELS[contractStatus.state]} + +
+
+
+
+
+
+ ); + })} +
+
+ ); +}; + +export default ExternalVerifierStatuses; diff --git a/app/routes/jobs.$jobId.tsx b/app/routes/jobs.$jobId.tsx index f53923d..f7097cb 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(); @@ -328,6 +377,8 @@ export default function JobDetails() { )} + + {/* Error Details */} {jobData.error && (
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..782fbf7 --- /dev/null +++ b/app/utils/externalVerifiers.ts @@ -0,0 +1,256 @@ +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" + | "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; +} + +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.status === "1" || payload.message.startsWith("ok")) { + return buildStatus("success", result); + } + + if (payload.status === "0") { + 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" + ); +}; + +export const requestExternalVerifierStatus = async ( + verifierKey: ExternalVerifierKey, + verificationData?: ExternalVerifications[ExternalVerifierKey] +): 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 (!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/sourcifyApi.ts b/app/utils/sourcifyApi.ts index b33206b..0bfbd73 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; @@ -274,6 +288,7 @@ export interface VerificationJobStatus { verifiedAt?: string; matchId?: string; }; + externalVerifications?: ExternalVerifications; } export async function getVerificationJobStatus(