Skip to content
Open
Show file tree
Hide file tree
Changes from 6 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
194 changes: 194 additions & 0 deletions app/components/verification/ExternalVerifierStatuses.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,194 @@
import { useEffect, useRef, useState } from "react";
import { IoOpenOutline } from "react-icons/io5";
import type { ExternalVerifications } from "~/utils/sourcifyApi";
import {
buildStatus,
requestExternalVerifierStatus,
type ExternalVerifierKey,
type ExternalVerifierState,
type ExternalVerifierStatus,
} from "~/utils/externalVerifiers";

const EXTERNAL_VERIFIER_LABELS: Record<ExternalVerifierKey, string> = {
etherscan: "Etherscan",
blockscout: "Blockscout",
routescan: "Routescan",
};

const STATUS_BADGE_STYLES: Record<ExternalVerifierState, string> = {
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",
};

const STATUS_LABELS: Record<ExternalVerifierState, string> = {
success: "Verification successful",
pending: "Verification pending",
error: "Verification error",
unknown: "Status unknown",
};

const DEFAULT_REFRESH_SECONDS = 3;

type ExternalVerifierStatusMap = Partial<Record<ExternalVerifierKey, ExternalVerifierStatus>>;

interface ExternalVerifierStatusesProps {
verifications?: ExternalVerifications;
refreshRateSeconds?: number;
}

const ExternalVerifierStatuses = ({
verifications,
refreshRateSeconds = DEFAULT_REFRESH_SECONDS,
}: ExternalVerifierStatusesProps) => {
const [externalVerifierStatuses, setExternalVerifierStatuses] = useState<ExternalVerifierStatusMap>({});
const externalVerifierStatusesRef = useRef<ExternalVerifierStatusMap>({});

useEffect(() => {
if (!verifications) {
externalVerifierStatusesRef.current = {};
setExternalVerifierStatuses({});
return;
}

const verifierEntries = Object.entries(verifications).filter(
([, data]) => !!data
) as Array<[ExternalVerifierKey, ExternalVerifications[ExternalVerifierKey]]>;

if (verifierEntries.length === 0) {
externalVerifierStatusesRef.current = {};
setExternalVerifierStatuses({});
return;
}

const activeKeys = new Set<ExternalVerifierKey>(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);

let isCancelled = false;
let timeoutId: number | null = null;

const shouldFetchKey = (key: ExternalVerifierKey) => {
const status = externalVerifierStatusesRef.current[key];
if (!status) {
return true;
}
const messageIndicatesPending =
typeof status.message === "string" && status.message.toLowerCase().includes("pending");
return status.state === "pending" || status.state === "unknown" || messageIndicatesPending;
};

const updateStatuses = async (): Promise<boolean> => {
const keysToFetch = verifierEntries.filter(([key]) => shouldFetchKey(key));

if (keysToFetch.length === 0) {
return verifierEntries.some(([key]) => shouldFetchKey(key));
}

const results = await Promise.all(
keysToFetch.map(async ([key, data]) => {
const status = await requestExternalVerifierStatus(key, data);
return [key, status] as const;
})
);

if (isCancelled) {
return false;
}

const next = { ...externalVerifierStatusesRef.current };
results.forEach(([key, status]) => {
next[key] = status;
});

externalVerifierStatusesRef.current = next;
setExternalVerifierStatuses(next);

return Object.values(next).some((status) => status.state === "pending" || status.state === "unknown");
};

const pollStatuses = async () => {
const hasPending = await updateStatuses();
if (isCancelled) return;
if (hasPending) {
timeoutId = window.setTimeout(pollStatuses, refreshRateSeconds * 1000);
}
};

pollStatuses();

return () => {
isCancelled = true;
if (timeoutId) {
window.clearTimeout(timeoutId);
}
};
}, [verifications, refreshRateSeconds]);

if (!verifications || !Object.values(verifications).some((value) => !!value)) {
return null;
}

return (
<div className="bg-white rounded-lg shadow-sm border border-gray-200 p-4 md:p-6 my-6 md:my-8">
<div className="flex flex-col md:flex-row md:items-center md:justify-between gap-2 md:gap-4 mb-4">
<h2 className="text-lg md:text-xl font-bold text-gray-900">Verification on other verifiers</h2>
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Let's just say "Other Verifiers" in the title and add a small subtitle saying "Sourcify automatically shares contracts with other known verifiers"

<p className="text-sm text-gray-500">Statuses refresh every {refreshRateSeconds} seconds.</p>
</div>
<div className="space-y-4">
{Object.entries(verifications)
.filter(([, value]) => !!value)
.map(([key, verifierData]) => {
const typedKey = key as ExternalVerifierKey;
const label = EXTERNAL_VERIFIER_LABELS[typedKey] ?? key;
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;

return (
<div
key={key}
className="flex flex-col md:flex-row md:items-center md:justify-between gap-3 border border-gray-200 rounded-lg p-4"
>
<div>
<p className="text-base md:text-lg font-semibold text-gray-900">{label}</p>
<p className="text-sm text-gray-600 break-words">{status.message}</p>
{verifierData?.verificationId && (
<p className="text-xs text-gray-500 mt-1 break-all">Verification ID: {verifierData.verificationId}</p>
)}
{verifierData?.explorerUrl && (
<a
href={verifierData.explorerUrl}
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center gap-2 text-sm text-cerulean-blue-600 hover:text-cerulean-blue-800 mt-2"
>
View on explorer
<IoOpenOutline className="w-4 h-4" />
</a>
)}
</div>
<span
className={`inline-flex items-center justify-center px-3 py-1 rounded-full text-xs font-semibold ${
STATUS_BADGE_STYLES[status.state]
}`}
>
{STATUS_LABELS[status.state]}
</span>
</div>
);
})}
</div>
</div>
);
};

export default ExternalVerifierStatuses;
59 changes: 52 additions & 7 deletions app/routes/jobs.$jobId.tsx
Original file line number Diff line number Diff line change
@@ -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 }>();
Expand All @@ -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 }>();
Expand All @@ -46,6 +59,12 @@ export default function JobDetails() {
const [expandedErrors, setExpandedErrors] = useState<Set<number>>(new Set());
const [expandedModalErrors, setExpandedModalErrors] = useState<Set<number>>(new Set());
const { serverUrl } = useServerConfig();
const hasExternalVerificationData = hasAllRequiredExternalVerifications(jobData?.externalVerifications);
const isJobFullyCompleted = 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;
Expand All @@ -62,18 +81,42 @@ export default function JobDetails() {
}
};

// Initial fetch
const initialFetchJobIdRef = useRef<string | null>(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 || isJobFullyCompleted || hasReachedExternalVerificationRetryLimit) return;

Comment on lines 108 to 110
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The name isJobFullyCompleted is a bit misleading here. I spend a good 10 mins trying to understand how this variable relates to the comment above. Finally realized it also has a check if the externalVerification field exists, which I can't understand from the variable name

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
}
Expand All @@ -82,7 +125,7 @@ export default function JobDetails() {
}, 1000);

return () => clearInterval(interval);
}, [jobData]);
}, [jobData, isJobFullyCompleted, missingExternalVerificationData, hasReachedExternalVerificationRetryLimit]);

const handleRefresh = () => {
fetchJobStatus();
Expand Down Expand Up @@ -328,6 +371,8 @@ export default function JobDetails() {
</div>
)}

<ExternalVerifierStatuses verifications={jobData.externalVerifications} />

{/* Error Details */}
{jobData.error && (
<div className="bg-white rounded-lg shadow-sm border border-gray-200 p-4 md:p-6">
Expand Down
43 changes: 43 additions & 0 deletions app/utils/etherscanApi.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<EtherscanVerificationStatusResponse> => {
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<EtherscanVerificationStatusResponse>;

if (
!data ||
typeof data.status === "undefined" ||
typeof data.message === "undefined"
) {
throw new Error("Unexpected response from Etherscan status API");
}

return data as EtherscanVerificationStatusResponse;
};
Loading