diff --git a/.env.build.main b/.env.build.main index e5523f0..4ecd4ab 100644 --- a/.env.build.main +++ b/.env.build.main @@ -4,4 +4,6 @@ VITE_SOURCIFY_SERVER_URL=https://sourcify.dev/server # Sourcify repository URL for viewing verified contracts VITE_SOURCIFY_REPO_URL=https://repo.sourcify.dev -VITE_ENV=production \ No newline at end of file +VITE_ENV=production + +VITE_UMAMI_WEBSITE_ID=4077aff1-1363-442e-9d31-cadc613f267b \ No newline at end of file diff --git a/.env.build.staging b/.env.build.staging index 0ef9e6c..d4ad727 100644 --- a/.env.build.staging +++ b/.env.build.staging @@ -4,4 +4,6 @@ VITE_SOURCIFY_SERVER_URL=https://staging.sourcify.dev/server # Sourcify repository URL for viewing verified contracts VITE_SOURCIFY_REPO_URL=https://repo.staging.sourcify.dev -VITE_ENV=staging \ No newline at end of file +VITE_ENV=staging + +VITE_UMAMI_WEBSITE_ID=7b5b60c8-cf84-4083-94a8-0e7569799071 \ No newline at end of file diff --git a/app/components/PageLayout.tsx b/app/components/PageLayout.tsx index 5b2a234..0f778eb 100644 --- a/app/components/PageLayout.tsx +++ b/app/components/PageLayout.tsx @@ -3,6 +3,8 @@ import { useChains } from "../contexts/ChainsContext"; import { Tooltip } from "react-tooltip"; import { useServerConfig } from "~/contexts/ServerConfigContext"; import { removeCurrentServerUrl } from "../utils/serverStorage"; +import { Link } from "react-router"; +import { FaGithub } from "react-icons/fa"; interface PageLayoutProps { children: ReactNode; @@ -100,22 +102,40 @@ export default function PageLayout({ children, maxWidth = "max-w-4xl", title, su return ( <> -
-
-
-
- {renderHeader()} - {renderContent()} +
+
+ + Sourcify Logo + verify.sourcify.eth + + + + +
+
+
+
+
+
+
+ {renderHeader()} + {renderContent()} +
- - {/* Global Tooltip */} - ); } diff --git a/app/components/VerificationForm.tsx b/app/components/VerificationForm.tsx new file mode 100644 index 0000000..9d4cdf0 --- /dev/null +++ b/app/components/VerificationForm.tsx @@ -0,0 +1,488 @@ +import { useChains } from "../contexts/ChainsContext"; +import { useVerificationState } from "../hooks/useVerificationState"; +import { useFormValidation } from "../hooks/useFormValidation"; +import LanguageSelector from "./verification/LanguageSelector"; +import VerificationMethodSelector from "./verification/VerificationMethodSelector"; +import ChainAndAddress from "./verification/ChainAndAddress"; +import CompilerSelector from "./verification/CompilerSelector"; +import LicenseInfo from "./verification/LicenseInfo"; +import FileUpload from "./verification/FileUpload"; +import CompilerSettings from "./verification/CompilerSettings"; +import ContractIdentifier from "./verification/ContractIdentifier"; +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 { parseBuildInfoFile } from "../utils/buildInfoValidation"; +import { useCompilerVersions } from "../contexts/CompilerVersionsContext"; +import MetadataValidation from "./verification/MetadataValidation"; +import { saveJob } from "../utils/jobStorage"; +import React from "react"; +import Settings from "./verification/Settings"; +import ImportSources from "./verification/ImportSources"; +import SubmissionResultDisplay from "./verification/SubmissionResultDisplay"; +import { useServerConfig } from "../contexts/ServerConfigContext"; +import { IoSettings } from "react-icons/io5"; + +interface VerificationFormProps { + preselectedChainId?: string; + preselectedAddress?: string; +} + +export default function VerificationForm({ preselectedChainId, preselectedAddress }: VerificationFormProps) { + const { serverUrl } = useServerConfig(); + const { chains } = useChains(); + const { solidityVersions, vyperVersions } = useCompilerVersions(); + const [importError, setImportError] = React.useState(null); + const [importSuccess, setImportSuccess] = React.useState(null); + const [showSettingsModal, setShowSettingsModal] = React.useState(false); + const [isAddressValid, setIsAddressValid] = React.useState(false); + const [lastSubmittedValues, setLastSubmittedValues] = React.useState(null); + const [buildInfoError, setBuildInfoError] = React.useState(null); + + // Clear success message after 3 seconds + React.useEffect(() => { + if (importSuccess) { + const timer = setTimeout(() => setImportSuccess(null), 3000); + return () => clearTimeout(timer); + } + }, [importSuccess]); + + // Handle import error and clear any existing success + const handleImportError = (error: string) => { + setImportSuccess(null); + setImportError(error); + }; + + // Handle build-info file change (now integrated with regular file handling) + const handleBuildInfoFileChange = async (files: File[]) => { + setBuildInfoError(null); + + if (files.length === 0) { + handleFilesChange([]); + return; + } + + try { + const file = files[0]; + const content = await file.text(); + const availableVersions = selectedLanguage === 'vyper' ? vyperVersions : solidityVersions; + const parseResult = parseBuildInfoFile(content, availableVersions); + + if (!parseResult.isValid) { + setBuildInfoError(parseResult.error || 'Invalid build-info file'); + handleFilesChange([]); + return; + } + + // Auto-populate compiler version if available + if (parseResult.compilerVersion) { + handleCompilerVersionSelect(parseResult.compilerVersion); + } + + // Create a std-json file from the parsed build-info and store in regular uploadedFiles + if (parseResult.standardJson) { + const standardJsonContent = JSON.stringify(parseResult.standardJson, null, 2); + const stdJsonFile = new File([standardJsonContent], 'build-info.json', { type: 'application/json' }); + handleFilesChange([stdJsonFile]); + } + + } catch (error) { + setBuildInfoError('Error processing build-info file'); + handleFilesChange([]); + console.error('Build-info processing error:', error); + } + }; + + const { + selectedChainId, + contractAddress, + selectedLanguage, + selectedMethod, + selectedCompilerVersion, + uploadedFiles, + metadataFile, + evmVersion, + optimizerEnabled, + optimizerRuns, + contractIdentifier, + creationTransactionHash, + handleChainIdChange, + handleContractAddressChange, + handleLanguageSelect, + handleMethodSelect, + handleCompilerVersionSelect, + handleFilesChange, + handleMetadataFileChange, + handleEvmVersionChange, + handleOptimizerEnabledChange, + handleOptimizerRunsChange, + handleContractIdentifierChange, + handleCreationTransactionHashChange, + isSubmitting, + setIsSubmitting, + submissionResult, + setSubmissionResult, + } = useVerificationState(); + + // Check if selected method is a framework method + const isFrameworkMethod = frameworkMethods.some(method => method.id === selectedMethod); + + const { isFormValid, errors, getSubmissionErrors } = useFormValidation({ + isAddressValid, + selectedChainId, + contractAddress, + selectedLanguage, + selectedMethod, + selectedCompilerVersion, + contractIdentifier, + uploadedFiles, + metadataFile, + evmVersion, + }); + + // Create a hash of current form values to detect changes + const currentFormHash = React.useMemo(() => { + const formValues = { + selectedChainId, + contractAddress, + selectedLanguage, + selectedMethod, + selectedCompilerVersion, + contractIdentifier, + evmVersion, + uploadedFileNames: uploadedFiles.map((f) => f.name + f.size).join(","), + metadataFileName: metadataFile ? metadataFile.name + metadataFile.size : "", + }; + return JSON.stringify(formValues); + }, [ + selectedChainId, + contractAddress, + selectedLanguage, + selectedMethod, + selectedCompilerVersion, + contractIdentifier, + evmVersion, + uploadedFiles, + metadataFile, + ]); + + // Clear success messages when form values change + React.useEffect(() => { + const hasFormChanged = lastSubmittedValues !== currentFormHash; + if (lastSubmittedValues && hasFormChanged) { + // Clear submission success result + if (submissionResult?.success) { + setSubmissionResult(null); + } + // Clear import success message + if (importSuccess) { + setImportSuccess(null); + } + } + }, [currentFormHash, lastSubmittedValues, submissionResult?.success, importSuccess, setSubmissionResult]); + + // Check if current form values are the same as last submitted values + const hasFormChanged = lastSubmittedValues !== currentFormHash; + const canSubmit = isFormValid && hasFormChanged && !isSubmitting; + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + + if (!canSubmit) { + if (!isFormValid) { + const submissionErrors = getSubmissionErrors(); + console.log("Form submission blocked. Missing:", submissionErrors); + } else if (!hasFormChanged) { + console.log("Form submission blocked. No changes since last submission."); + } + return; + } + + setIsSubmitting(true); + setSubmissionResult(null); + + try { + let result; + + if (selectedMethod === "metadata-json") { + // Handle metadata-json method + if (!metadataFile) { + throw new Error("No metadata.json file uploaded"); + } + + const { sources, metadata } = await buildMetadataSubmissionSources(metadataFile, uploadedFiles); + + result = await submitMetadataVerification( + serverUrl, + selectedChainId, + contractAddress, + sources, + metadata, + creationTransactionHash || undefined + ); + } else if (selectedMethod === "std-json" || selectedMethod === "build-info") { + // For std-json method or build-info method, use the uploaded file directly + if (uploadedFiles.length === 0) { + const fileType = selectedMethod === "build-info" ? "build-info" : "standard JSON"; + throw new Error(`No ${fileType} file uploaded`); + } + + result = await submitStdJsonFile( + serverUrl, + selectedChainId, + contractAddress, + uploadedFiles[0], + selectedCompilerVersion, + contractIdentifier, + creationTransactionHash || undefined + ); + } else { + // For single-file and multiple-files methods, assemble standard JSON + if (uploadedFiles.length === 0) { + throw new Error("No files uploaded"); + } + + result = await assembleAndSubmitStandardJson( + serverUrl, + selectedChainId, + contractAddress, + uploadedFiles, + selectedLanguage!, + selectedCompilerVersion, + contractIdentifier, + { + evmVersion, + optimizerEnabled, + optimizerRuns, + }, + creationTransactionHash || undefined + ); + } + + setSubmissionResult({ + success: true, + verificationId: result.verificationId, + }); + + // Store current form values hash to prevent duplicate submissions + setLastSubmittedValues(currentFormHash); + + // Save job to localStorage + saveJob({ + verificationId: result.verificationId, + isJobCompleted: false, + jobStartTime: new Date().toISOString(), + submittedAt: new Date().toISOString(), + contract: { + chainId: selectedChainId, + address: contractAddress, + }, + }); + } catch (error) { + setSubmissionResult({ + success: false, + error: error instanceof Error ? error.message : "Unknown error occurred", + }); + } finally { + setIsSubmitting(false); + } + }; + + const getSubmitButtonTooltip = () => { + if (isFrameworkMethod) { + return "Framework helpers provide setup instructions only - please select a verification method above"; + } + if (!isFormValid) { + return "Please complete all required fields"; + } + if (!hasFormChanged) { + return "No changes since last submission"; + } + return "Submit verification"; + }; + + return ( + <> +
+ {/* Settings Button */} +
+ +
+ +
+ + + + + + + + + {selectedLanguage && ( + + )} + + {buildInfoError && ( +
+

{buildInfoError}

+
+ )} + + {!isFrameworkMethod && !!selectedMethod && ( + <> + + + {selectedMethod === "metadata-json" && ( + <> + {/* Metadata Validation - Show between metadata and source file uploads */} + {}} + /> + + {/* 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. */} + + + )} + + )} + + + + + + + {!isFrameworkMethod && !!selectedMethod && ( + + )} + + {/* Submission Result Feedback */} + {submissionResult && !submissionResult.isEtherscanSubmission && ( + setSubmissionResult(null)} + /> + )} + + {!isFrameworkMethod && ( + <> +
+ +
+ + {/* Validation Errors List */} + {!isFormValid && Object.keys(errors).length > 0 && ( +
+

Please complete the following fields:

+
    + {errors.chain &&
  • • {errors.chain}
  • } + {errors.address &&
  • • {errors.address}
  • } + {errors.language &&
  • • {errors.language}
  • } + {errors.method &&
  • • {errors.method}
  • } + {errors.files &&
  • • {errors.files}
  • } + {errors.compilerVersion &&
  • • {errors.compilerVersion}
  • } + {errors.evmVersion &&
  • • {errors.evmVersion}
  • } + {errors.contractIdentifier &&
  • • {errors.contractIdentifier}
  • } +
+
+ )} + + )} + +
+ + {/* Settings Modal */} + setShowSettingsModal(false)} /> + + ); +} \ No newline at end of file diff --git a/app/components/verification/ChainAndAddress.tsx b/app/components/verification/ChainAndAddress.tsx index 4a8fc3e..54806b9 100644 --- a/app/components/verification/ChainAndAddress.tsx +++ b/app/components/verification/ChainAndAddress.tsx @@ -17,6 +17,8 @@ interface ChainAndAddressProps { onContractAddressChange: (value: string) => void; chains: Chain[]; onValidationChange?: (isValid: boolean) => void; + preselectedChainId?: string; + preselectedAddress?: string; } export default function ChainAndAddress({ @@ -26,6 +28,8 @@ export default function ChainAndAddress({ onContractAddressChange, chains, onValidationChange, + preselectedChainId, + preselectedAddress, }: ChainAndAddressProps) { const { serverUrl } = useServerConfig(); const [addressError, setAddressError] = useState(""); @@ -35,6 +39,16 @@ export default function ChainAndAddress({ const [isLoadingCurrentChain, setIsLoadingCurrentChain] = useState(false); const [isModalOpen, setIsModalOpen] = useState(false); + const isPreselectedChainValid = preselectedChainId ? + chains.some(chain => chain.chainId === parseInt(preselectedChainId)) : + true; + const shouldShowChainSelect = !preselectedChainId || !isPreselectedChainValid; + + const isPreselectedAddressValid = preselectedAddress ? + isAddress(preselectedAddress) : + true; + const shouldShowAddressInput = !preselectedAddress || !isPreselectedAddressValid; + const handleFetchAllChains = async (address: string) => { setIsLoadingAllChains(true); try { @@ -69,6 +83,18 @@ export default function ChainAndAddress({ handleFetchCurrentChain(address, chainId); }; + useEffect(() => { + if (preselectedChainId && isPreselectedChainValid) { + onChainIdChange(preselectedChainId); + } + }, []); + + useEffect(() => { + if (preselectedAddress) { + onContractAddressChange(preselectedAddress); + } + }, []); + useEffect(() => { if (!contractAddress) { setAddressError(""); @@ -106,29 +132,50 @@ export default function ChainAndAddress({ return ( <> + {preselectedChainId && !isPreselectedChainValid && ( +
+

Chain ID {preselectedChainId} is not supported. Please select a valid chain.

+
+ )} +
- - + {shouldShowChainSelect ? ( + <> + + + + ) : ( +
+ Chain: {getChainName(chains, parseInt(selectedChainId))} ({selectedChainId}) +
+ )}
- - - {addressError &&

{addressError}

} + {shouldShowAddressInput ? ( + <> + + + {addressError &&

{addressError}

} + + ) : ( +
+ Contract Address: {contractAddress} +
+ )} {/* Show loading state for current chain */} {isLoadingCurrentChain && ( diff --git a/app/root.tsx b/app/root.tsx index e22186f..1e294e3 100644 --- a/app/root.tsx +++ b/app/root.tsx @@ -1,5 +1,5 @@ -import { isRouteErrorResponse, Links, Meta, Outlet, Scripts, ScrollRestoration, Link } from "react-router"; -import { FaGithub } from "react-icons/fa"; +import { isRouteErrorResponse, Links, Meta, Outlet, Scripts, ScrollRestoration } from "react-router"; +import { Tooltip } from "react-tooltip"; import type { Route } from "./+types/root"; import "./app.css"; @@ -10,6 +10,7 @@ import { CompilerVersionsProvider } from "./contexts/CompilerVersionsContext"; export const links: Route.LinksFunction = () => []; export function Layout({ children }: { children: React.ReactNode }) { + return ( @@ -17,38 +18,27 @@ export function Layout({ children }: { children: React.ReactNode }) { + {import.meta.env.VITE_UMAMI_WEBSITE_ID && ( +