diff --git a/api/Cargo.lock b/api/Cargo.lock index 65fac467..37b20dae 100644 --- a/api/Cargo.lock +++ b/api/Cargo.lock @@ -934,6 +934,16 @@ version = "0.3.17" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" +[[package]] +name = "mime_guess" +version = "2.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f7c44f8e672c00fe5308fa235f821cb4198414e1c77935c1ab6948d3fd78550e" +dependencies = [ + "mime", + "unicase", +] + [[package]] name = "miniz_oxide" version = "0.7.4" @@ -1351,6 +1361,7 @@ dependencies = [ "js-sys", "log", "mime", + "mime_guess", "native-tls", "once_cell", "percent-encoding", @@ -2033,6 +2044,12 @@ dependencies = [ "version_check", ] +[[package]] +name = "unicase" +version = "2.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75b844d17643ee918803943289730bec8aac480150456169e647ed0b576ba539" + [[package]] name = "unicode-ident" version = "1.0.12" diff --git a/api/Cargo.toml b/api/Cargo.toml index a4936db5..190746ba 100644 --- a/api/Cargo.toml +++ b/api/Cargo.toml @@ -20,7 +20,7 @@ fmt = "0.1.0" thiserror = "1.0.50" chrono = "0.4.31" prometheus = "0.13.4" -reqwest = { version = "0.11", features = ["json"] } +reqwest = { version = "0.11", features = ["json", "multipart"] } lazy_static = "1.4.0" tokio = { version = "1.0", features = ["full"] } semver = "1.0" \ No newline at end of file diff --git a/api/Dockerfile b/api/Dockerfile index a7f3f58d..936afcbf 100644 --- a/api/Dockerfile +++ b/api/Dockerfile @@ -36,14 +36,6 @@ WORKDIR /app COPY . . # Compile necessary Rust packages -# If you have to specify a specific version of cairo -# RUN rm cairo -rf -# RUN git clone https://github.com/starkware-libs/cairo.git -# RUN cd cairo; git checkout v1.0.0-alpha.6 -# Or if you chose to copy the whole repo -# RUN git submodule update --init - -#RUN cd cairo; cargo build --bin starknet-compile; cargo build --bin starknet-sierra-compile RUN cd cairo_compilers; chmod +x build.sh; ./build.sh RUN cargo build --release diff --git a/api/src/errors.rs b/api/src/errors.rs index 878a1d20..263e0cbb 100644 --- a/api/src/errors.rs +++ b/api/src/errors.rs @@ -104,6 +104,10 @@ pub enum NetworkError { FailedToGetClientIp, #[error("Too many requests")] TooManyRequests, + #[error("Failed to verify contract: {0}")] + VerificationFailed(String), + #[error("Failed to get verification status: {0}")] + VerificationStatusFailed(String), } impl From for ApiError { diff --git a/api/src/handlers/utils.rs b/api/src/handlers/utils.rs index f1c1ec7f..853bdef3 100644 --- a/api/src/handlers/utils.rs +++ b/api/src/handlers/utils.rs @@ -6,7 +6,7 @@ use tracing::{info, instrument}; use crate::errors::{ApiError, ExecutionError, FileError, Result, SystemError}; use crate::handlers::compile::{default_scarb_toml, scarb_toml_with_version}; -use crate::metrics::{Metrics, COMPILATION_LABEL_VALUE}; +use crate::metrics::{Metrics, COMPILATION_LABEL_VALUE, VERIFY_LABEL_VALUE}; use super::scarb_version::do_scarb_version; use super::types::{BaseRequest, CompilationRequest, FileContentMap, Successful}; @@ -128,7 +128,7 @@ pub async fn dispatch_command(command: ApiCommand, metrics: &Metrics) -> Result< { Ok(result) => Ok(ApiCommandResult::Compile(result)), Err(e) => Err(e), - }, + } } } diff --git a/api/src/handlers/verify.rs b/api/src/handlers/verify.rs new file mode 100644 index 00000000..75f75c87 --- /dev/null +++ b/api/src/handlers/verify.rs @@ -0,0 +1,156 @@ +use crate::errors::{NetworkError, Result}; +use crate::handlers::process::{do_process_command, fetch_process_result}; +use crate::handlers::types::VerifyResponseGetter; +use crate::handlers::types::{ApiCommand, IntoTypedResponse}; +use crate::handlers::utils::{init_directories, AutoCleanUp}; +use crate::metrics::Metrics; +use crate::rate_limiter::RateLimited; +use crate::worker::WorkerEngine; +use reqwest::multipart; +use rocket::serde::json::Json; +use rocket::{tokio, State}; +use serde::{Deserialize, Serialize}; +use std::collections::HashMap; +use std::time::Duration; +use tracing::{debug, error, info, instrument}; + +use super::types::{ApiResponse, VerifyRequest, VerifyResponse}; + +const VOYAGER_API_BASE: &str = "https://api.voyager.online/beta"; +const VERIFICATION_POLL_INTERVAL: Duration = Duration::from_secs(5); +const VERIFICATION_TIMEOUT: Duration = Duration::from_secs(300); + +#[derive(Debug, Deserialize)] +struct VoyagerVerifyResponse { + job_id: String, +} + +#[derive(Debug, Deserialize)] +struct VoyagerVerifyStatus { + status: i32, + status_description: String, +} + +#[instrument(skip(request_json, _rate_limited, engine))] +#[post("/verify-async", data = "")] +pub async fn verify_async( + request_json: Json, + _rate_limited: RateLimited, + engine: &State, +) -> ApiResponse { + info!("/verify"); + do_process_command( + ApiCommand::Verify { + verify_request: request_json.0, + }, + engine, + ) +} + +#[instrument(skip(engine))] +#[get("/verify-async/")] +pub async fn get_verify_result(process_id: &str, engine: &State) -> VerifyResponse { + info!("/verify-result/{:?}", process_id); + fetch_process_result::(process_id, engine) + .map(|result| result.0) + .unwrap_or_else(|err| err.into_typed()) +} + +pub async fn do_verify( + verify_request: VerifyRequest, + _metrics: &Metrics, +) -> Result { + debug!("verify_request: {:?}", verify_request); + + // Create temporary directories + let temp_dir = init_directories(&verify_request.base_request) + .await + .map_err(|e| { + error!("Failed to initialize directories: {:?}", e); + e + })?; + + let auto_clean_up = AutoCleanUp { + dirs: vec![&temp_dir], + }; + + // Find main contract file + let contract_file = verify_request + .base_request + .files + .iter() + .find(|f| f.file_name.ends_with(".cairo")) + .ok_or_else(|| NetworkError::VerificationFailed("No Cairo contract file found".to_string()))?; + + // Create multipart form + let mut form = multipart::Form::new() + .text("name", verify_request.contract_name.clone()) + .text("classHash", verify_request.contract_address.clone()) + .text("license", "MIT".to_string()) + .text("compilerVersion", "2.6.4".to_string()); // TODO: Make this configurable + + // Add files to form + for file in &verify_request.base_request.files { + let part = multipart::Part::text(file.file_content.clone()) + .file_name(file.file_name.clone()); + form = form.part(file.file_name.clone(), part); + } + + let client = reqwest::Client::new(); + + // Start verification + let verify_response = client + .post(&format!("{}/verification/send", VOYAGER_API_BASE)) + .multipart(form) + .send() + .await + .map_err(|e| NetworkError::VerificationFailed(e.to_string()))? + .json::() + .await + .map_err(|e| NetworkError::VerificationFailed(e.to_string()))?; + + let job_id = verify_response.job_id; + debug!("Verification job started with ID: {}", job_id); + + // Poll for verification status + let start_time = std::time::Instant::now(); + loop { + if start_time.elapsed() > VERIFICATION_TIMEOUT { + auto_clean_up.clean_up().await; + return Ok(ApiResponse::ok(()) + .with_status("Timeout".to_string()) + .with_code(408) + .with_message("Verification timed out after 5 minutes".to_string())); + } + + let status = client + .get(&format!("{}/class-verify/job/{}", VOYAGER_API_BASE, job_id)) + .send() + .await + .map_err(|e| NetworkError::VerificationStatusFailed(e.to_string()))? + .json::() + .await + .map_err(|e| NetworkError::VerificationStatusFailed(e.to_string()))?; + + match status.status { + 0 => { + tokio::time::sleep(VERIFICATION_POLL_INTERVAL).await; + continue; + } + 1 => { + auto_clean_up.clean_up().await; + return Ok(ApiResponse::ok(()) + .with_status("Success".to_string()) + .with_code(200) + .with_message("Contract verified successfully".to_string())); + } + _ => { + auto_clean_up.clean_up().await; + return Ok(ApiResponse::ok(()) + .with_status("Failed".to_string()) + .with_code(400) + .with_message(format!("Verification failed: {}", status.status_description))); + } + } + } +} diff --git a/api/src/metrics.rs b/api/src/metrics.rs index bf6465ce..ed74a7d6 100644 --- a/api/src/metrics.rs +++ b/api/src/metrics.rs @@ -8,7 +8,7 @@ use tracing::instrument; const NAMESPACE: &str = "starknet_api"; pub(crate) const COMPILATION_LABEL_VALUE: &str = "compilation"; - +pub(crate) const VERIFY_LABEL_VALUE: &str = "verify"; // Action - compile/verify(once supported) #[derive(Clone, Debug)] pub struct Metrics { diff --git a/plugin/src/atoms/verify.ts b/plugin/src/atoms/verify.ts new file mode 100644 index 00000000..8882f646 --- /dev/null +++ b/plugin/src/atoms/verify.ts @@ -0,0 +1,5 @@ +import { atom } from "jotai"; + +export type VerifyStatus = "loading" | "success" | "error" | ""; + +export const verifyStatusAtom = atom(""); diff --git a/plugin/src/components/ManualAccount/index.tsx b/plugin/src/components/ManualAccount/index.tsx index 7a592e66..d1092b13 100644 --- a/plugin/src/components/ManualAccount/index.tsx +++ b/plugin/src/components/ManualAccount/index.tsx @@ -18,7 +18,7 @@ import { accountAtom, networkAtom, selectedAccountAtom } from "../../atoms/manua import { envAtom } from "../../atoms/environment"; import useAccount from "../../hooks/useAccount"; import useProvider from "../../hooks/useProvider"; -import useRemixClient from "../../hooks/useRemixClient"; +import { useRemixClient } from "../../hooks/useRemixClient"; import { getProvider } from "../../utils/misc"; import { declTxHashAtom, deployTxHashAtom } from "../../atoms/deployment"; import { invokeTxHashAtom } from "../../atoms/interaction"; diff --git a/plugin/src/components/ui_components/Select/index.tsx b/plugin/src/components/ui_components/Select/index.tsx index d174b96e..26d89faf 100644 --- a/plugin/src/components/ui_components/Select/index.tsx +++ b/plugin/src/components/ui_components/Select/index.tsx @@ -8,6 +8,11 @@ interface CommonProps { children: React.ReactNode; } +interface CreatableSelectProps extends Omit, CommonProps { + onCreateOption: (value: string) => void; + createPlaceholder?: string; +} + export const Root = SelectPrimitive.Root; export const Trigger: React.FC = ({ @@ -15,8 +20,8 @@ export const Trigger: React.FC = ({ className, ...props }) => ( - - + + {children} ); @@ -25,8 +30,13 @@ export const Content: React.FC = ({ className, ...props }) => ( - -
{children}
+ + {children} ); @@ -35,8 +45,11 @@ export const Item: React.FC = ({ className, ...props }) => ( - -
{children}
+ + {children} ); @@ -50,7 +63,7 @@ export const Icon: React.FC<{ className, ...props }) => ( - + {children} ); @@ -62,7 +75,7 @@ export const Viewport: React.FC = ({ className, ...props }) => ( - + {children} ); @@ -72,7 +85,61 @@ export const ItemText: React.FC = ({ className, ...props }) => ( - + {children} ); + +export const CreatableSelect: React.FC = ({ + children, + onCreateOption, + createPlaceholder = "Type to add...", + ...props +}): JSX.Element => { + const [inputValue, setInputValue] = React.useState(""); + const [isCreating, setIsCreating] = React.useState(false); + + const handleKeyDown = (event: React.KeyboardEvent): void => { + if (event.key === "Enter" && inputValue !== "") { + event.preventDefault(); + onCreateOption(inputValue); + setInputValue(""); + setIsCreating(false); + } + }; + + return ( +
+ {!isCreating + ? ( + + {children} + { + setIsCreating(true); + }} + > + + Add new option + + + ) + : ( + { + setInputValue(e.target.value); + }} + onKeyDown={handleKeyDown} + placeholder={createPlaceholder} + autoFocus + onBlur={(): void => { + setIsCreating(false); + }} + /> + ) + } +
+ ); +}; diff --git a/plugin/src/components/ui_components/Select/styles.css b/plugin/src/components/ui_components/Select/styles.css index 8d4a9f36..89412601 100644 --- a/plugin/src/components/ui_components/Select/styles.css +++ b/plugin/src/components/ui_components/Select/styles.css @@ -31,8 +31,15 @@ button { background-color: var(--bgPrimary); overflow: auto; animation: fadeInScaleUp 0.3s ease-out; + } +.SelectContent { + width: var(--radix-select-trigger-width); + min-width: var(--radix-select-trigger-width); + } + + /* Style individual select items */ .SelectItem { padding: 8px 15px; @@ -66,17 +73,4 @@ button { .SelectItemWithDelete .deleteButton:hover{ border: 1px solid var(--secondary); -} - - -/* Keyframe animations */ -@keyframes fadeInScaleUp { - from { - opacity: 0; - transform: scale(0.95); - } - to { - opacity: 1; - transform: scale(1); - } -} +} \ No newline at end of file diff --git a/plugin/src/features/Compilation/index.tsx b/plugin/src/features/Compilation/index.tsx index 5923c130..50c3e8a8 100644 --- a/plugin/src/features/Compilation/index.tsx +++ b/plugin/src/features/Compilation/index.tsx @@ -1,6 +1,6 @@ /* eslint-disable multiline-ternary */ import React, { useEffect } from "react"; -import { artifactFolder, getFileNameFromPath, isValidCairo } from "../../utils/utils"; +import { artifactFolder, getFileNameFromPath, getFolderFilemapRecursive, isValidCairo } from "../../utils/utils"; import "./styles.css"; import Container from "../../components/ui_components/Container"; import { type AccordianTabs } from "../Plugin"; @@ -18,7 +18,7 @@ import { statusAtom, tomlPathsAtom } from "../../atoms/compilation"; -import useRemixClient from "../../hooks/useRemixClient"; +import { useRemixClient } from "../../hooks/useRemixClient"; import { useIcon } from "../../hooks/useIcons"; import { type Contract } from "../../utils/types/contracts"; import { compiledContractsAtom } from "../../atoms/compiledContracts"; @@ -284,31 +284,6 @@ const Compilation: React.FC = ({ setAccordian }) => { return resTomlPaths; } - const getFolderFilemapRecursive = async ( - workspacePath: string, - dirPath = "" - ): Promise => { - const files = [] as FileContentMap[]; - const pathFiles = await remixClient.fileManager.readdir(`${workspacePath}/${dirPath}`); - for (const [path, entry] of Object.entries(pathFiles)) { - if (entry.isDirectory === true) { - const deps = await getFolderFilemapRecursive(workspacePath, path); - for (const dep of deps) files.push(dep); - continue; - } - - const content = await remixClient.fileManager.readFile(path); - - if (!path.endsWith(".cairo") && !path.endsWith("Scarb.toml")) continue; - - files.push({ - file_name: path, - file_content: content - }); - } - return files; - }; - const updateTomlPaths = (): void => { // eslint-disable-next-line @typescript-eslint/no-misused-promises setTimeout(async () => { @@ -593,7 +568,7 @@ const Compilation: React.FC = ({ setAccordian }) => { async function testScarb (workspacePath: string, scarbPath: string): Promise { try { const compilationRequest: TestRequest = { - files: await getFolderFilemapRecursive(workspacePath, scarbPath), + files: await getFolderFilemapRecursive(workspacePath, remixClient, scarbPath), test_engine: testEngine ?? "scarb" }; @@ -625,7 +600,7 @@ const Compilation: React.FC = ({ setAccordian }) => { async function compileScarb (workspacePath: string, scarbPath: string): Promise { try { const compilationRequest: CompilationRequest = { - files: await getFolderFilemapRecursive(workspacePath, scarbPath), + files: await getFolderFilemapRecursive(workspacePath, remixClient, scarbPath), version: null }; diff --git a/plugin/src/features/Deployment/index.tsx b/plugin/src/features/Deployment/index.tsx index 3867e05b..63dfe871 100644 --- a/plugin/src/features/Deployment/index.tsx +++ b/plugin/src/features/Deployment/index.tsx @@ -21,7 +21,7 @@ import { import { envAtom } from "../../atoms/environment"; import useAccount from "../../hooks/useAccount"; import useProvider from "../../hooks/useProvider"; -import useRemixClient from "../../hooks/useRemixClient"; +import { useRemixClient } from "../../hooks/useRemixClient"; import { constructorInputsAtom, declStatusAtom, diff --git a/plugin/src/features/Interaction/index.tsx b/plugin/src/features/Interaction/index.tsx index 933ed49f..c6cf3fb1 100644 --- a/plugin/src/features/Interaction/index.tsx +++ b/plugin/src/features/Interaction/index.tsx @@ -13,7 +13,7 @@ import { deployedContractsAtom, selectedDeployedContract } from "../../atoms/com import { envAtom } from "../../atoms/environment"; import useAccount from "../../hooks/useAccount"; import useProvider from "../../hooks/useProvider"; -import useRemixClient from "../../hooks/useRemixClient"; +import { useRemixClient } from "../../hooks/useRemixClient"; import { ABIForm, type CallbackReturnType } from "starknet-abi-forms"; import "starknet-abi-forms/index.css"; diff --git a/plugin/src/features/Plugin/index.tsx b/plugin/src/features/Plugin/index.tsx index 3ab0132a..f1d38b2e 100644 --- a/plugin/src/features/Plugin/index.tsx +++ b/plugin/src/features/Plugin/index.tsx @@ -15,7 +15,7 @@ import { useCurrentExplorer } from "../../components/ExplorerSelector"; import { useAtom, useAtomValue, useSetAtom } from "jotai"; import { deploymentAtom, isDelcaringAtom } from "../../atoms/deployment"; import { pluginLoaded as atomPluginLoaded } from "../../atoms/remixClient"; -import useRemixClient from "../../hooks/useRemixClient"; +import { useRemixClient } from "../../hooks/useRemixClient"; import * as Tabs from "@radix-ui/react-tabs"; import { Settings } from "../../components/Settings"; import { cairoVersionAtom, versionsAtom } from "../../atoms/cairoVersion"; @@ -33,8 +33,10 @@ import { } from "../../atoms/environment"; import useAccount from "../../hooks/useAccount"; import useProvider from "../../hooks/useProvider"; +import Verify from "../Verify"; +import { verifyStatusAtom } from "../../atoms/verify"; -export type AccordianTabs = "compile" | "deploy" | "interaction" | "transactions" | ""; +export type AccordianTabs = "compile" | "deploy" | "interaction" | "transactions" | "verify" | ""; const Plugin: React.FC = () => { const status = useAtomValue(statusAtom); @@ -97,6 +99,8 @@ const Plugin: React.FC = () => { const explorerHook = useCurrentExplorer(); + const verifyStatus = useAtomValue(verifyStatusAtom); + const setPluginLoaded = useSetAtom(atomPluginLoaded); useEffect(() => { @@ -323,6 +327,26 @@ const Plugin: React.FC = () => { + + + { + handleTabView("verify"); + }} + > + + 4 +

Verify

+ +
+
+ + + +
diff --git a/plugin/src/features/Verify/index.tsx b/plugin/src/features/Verify/index.tsx new file mode 100644 index 00000000..97552e26 --- /dev/null +++ b/plugin/src/features/Verify/index.tsx @@ -0,0 +1,380 @@ +import React, { useEffect, useState } from "react"; +import Container from "../../components/ui_components/Container"; +import { type IExplorerSelector } from "../../utils/misc"; +import { verifyStatusAtom } from "../../atoms/verify"; +import { useAtom, useAtomValue } from "jotai"; +import * as Select from "../../components/ui_components/Select"; +import { BsChevronDown } from "react-icons/bs"; +import "./styles.css"; +import { activeTomlPathAtom } from "../../atoms/compilation"; +import { getFolderFilemapRecursive } from "../../utils/utils"; +import { useRemixClient } from "../../hooks/useRemixClient"; +import type { VoyagerLicense } from "../../utils/types/voyager"; +import { VoyagerAPI } from "../../utils/voyager"; +import { VoyagerVerificationStatus } from "../../utils/types/voyager"; + +const SUPPORTED_LICENSES: Array<{ id: VoyagerLicense; name: VoyagerLicense }> = [ + { id: "No License (None)", name: "No License (None)" }, + { id: "The Unlicense (Unlicense)", name: "The Unlicense (Unlicense)" }, + { id: "MIT License (MIT)", name: "MIT License (MIT)" }, + { id: "GNU General Public License v2.0 (GNU GPLv2)", name: "GNU General Public License v2.0 (GNU GPLv2)" }, + { id: "GNU General Public License v3.0 (GNU GPLv3)", name: "GNU General Public License v3.0 (GNU GPLv3)" }, + { id: "GNU Lesser General Public License v2.1 (GNU LGPLv2.1)", name: "GNU Lesser General Public License v2.1 (GNU LGPLv2.1)" }, + { id: "GNU Lesser General Public License v3.0 (GNU LGPLv3)", name: "GNU Lesser General Public License v3.0 (GNU LGPLv3)" }, + { id: "BSD 2-clause \"Simplified\" license (BSD-2-Clause)", name: "BSD 2-clause \"Simplified\" license (BSD-2-Clause)" }, + { id: "BSD 3-clause \"New\" Or \"Revisited license (BSD-3-Clause)", name: "BSD 3-clause \"New\" Or \"Revisited license (BSD-3-Clause)" }, + { id: "Mozilla Public License 2.0 (MPL-2.0)", name: "Mozilla Public License 2.0 (MPL-2.0)" }, + { id: "Open Software License 3.0 (OSL-3.0)", name: "Open Software License 3.0 (OSL-3.0)" }, + { id: "Apache 2.0 (Apache-2.0)", name: "Apache 2.0 (Apache-2.0)" }, + { id: "GNU Affero General Public License (GNU AGPLv3)", name: "GNU Affero General Public License (GNU AGPLv3)" }, + { id: "Business Source License (BSL 1.1)", name: "Business Source License (BSL 1.1)" } +]; + +const Verify: React.FC = (props) => { + const [verifyStatus, setVerifyStatus] = useAtom(verifyStatusAtom); + const [contractNames, setContractNames] = useState([]); + const [activeContractName, setActiveContractName] = useState(""); + const [classHash, setClassHash] = useState(""); + const [network, setNetwork] = useState<"mainnet" | "sepolia">("sepolia"); + const [compilerVersion, setCompilerVersion] = useState(""); + const [scarbVersion, setScarbVersion] = useState(""); + const [license, setLicense] = useState("MIT License (MIT)"); + const [isAccountContract, setIsAccountContract] = useState(false); + const [verificationMessage, setVerificationMessage] = useState(""); + + const { remixClient } = useRemixClient(); + const activeTomlPath = useAtomValue(activeTomlPathAtom); + const voyagerApi = new VoyagerAPI(); + + useEffect(() => { + if (activeContractName === "" || !contractNames.includes(activeContractName)) { + setActiveContractName(contractNames[0] ?? ""); + } + }, [contractNames]); + + const fetchContractNames = async (): Promise => { + const currentWorkspacePath = await remixClient.filePanel.getCurrentWorkspace(); + + const files = await getFolderFilemapRecursive( + currentWorkspacePath.absolutePath, + remixClient, + activeTomlPath + ); + + const contractNames = []; + + for (const file of files) { + const fileName = file.file_name; + const content = file.file_content; + + if (fileName.endsWith(".cairo")) { + const contractRegex = /#\[(starknet::)?contract\]\s*mod\s+(\w+)/; + const match = content.match(contractRegex); + const contractName = match?.[2]; + if (contractName !== undefined) { + contractNames.push(contractName); + } + } + } + + setContractNames(contractNames); + }; + + useEffect(() => { + const fetchAndHandleError = async (): Promise => { + try { + await fetchContractNames(); + } catch (error) { + console.error(error); + } + }; + + void fetchAndHandleError(); + }, []); + + useEffect(() => { + const fetchAndHandleError = async (): Promise => { + try { + await fetchContractNames(); + } catch (error) { + console.error(error); + } + }; + + void fetchAndHandleError(); + }, [activeTomlPath]); + + useEffect(() => { + remixClient.on("fileManager", "fileSaved", fetchContractNames); + }, [remixClient]); + + const handleVerify = (e: React.FormEvent): void => { + e.preventDefault(); + setVerifyStatus("loading"); + setVerificationMessage(""); + + const verifyContract = async (): Promise => { + try { + const currentWorkspacePath = await remixClient.filePanel.getCurrentWorkspace(); + const files = await getFolderFilemapRecursive( + currentWorkspacePath.absolutePath, + remixClient, + activeTomlPath + ); + + const contractFile = files.find((file) => { + const match = file.file_content.match(/#\[(starknet::)?contract\]\s*mod\s+(\w+)/); + const contractName = match?.[2]; + return contractName === activeContractName; + }); + + if (contractFile == null) { + throw new Error("Contract file not found"); + } + + const request = voyagerApi.prepareVerificationRequest( + activeContractName, + contractFile.file_content, + compilerVersion, + scarbVersion, + license, + isAccountContract + ); + + const response = await voyagerApi.verifyContract(request, network, classHash); + const jobId = response.job_id; + + // Poll for verification status + const pollStatus = async (): Promise => { + try { + const status = await voyagerApi.getVerificationStatus(jobId, network); + setVerificationMessage(status.message ?? status.status_description); + + console.log(status.status); + + switch (status.status) { + case VoyagerVerificationStatus.SUBMITTED: + case VoyagerVerificationStatus.COMPILED: + setTimeout(() => { + void pollStatus(); + }, 5000); + break; + case VoyagerVerificationStatus.SUCCESS: + setVerifyStatus("success"); + break; + case VoyagerVerificationStatus.COMPILE_FAILED: + case VoyagerVerificationStatus.FAIL: + setVerifyStatus("error"); + break; + default: + setVerifyStatus("error"); + setVerificationMessage("Unknown verification status"); + } + } catch (error) { + console.error("Error polling status:", error); + setVerifyStatus("error"); + setVerificationMessage(error instanceof Error ? error.message : "Failed to check verification status"); + } + }; + + void pollStatus(); + } catch (error) { + console.error("Verification failed:", error); + setVerifyStatus("error"); + if (error instanceof Error) { + setVerificationMessage(error.message); + } else if (typeof error === "object" && error !== null && "message" in error) { + setVerificationMessage(String(error.message)); + } else { + setVerificationMessage("Unknown error occurred during verification"); + } + } + }; + + void verifyContract(); + }; + + return ( + +
+
+
+
+ + { + setClassHash(e.target.value); + }} + placeholder="0x..." + required + /> +
+ +
+ + { + setNetwork(value); + }} + > + + + {network.charAt(0).toUpperCase() + network.slice(1)} + + + + + + + + + + Mainnet + + + Sepolia + + + + + +
+ +
+ + { + setActiveContractName(value); + }} + > + + + {activeContractName} + + + + + + + + + {contractNames.map((name) => ( + + {name} + + ))} + + + + +
+ +
+ + { + setLicense(value); + }} + > + + + {SUPPORTED_LICENSES.find((l) => l.id === license)?.name} + + + + + + + + + {SUPPORTED_LICENSES.map((license) => ( + + {license.name} + + ))} + + + + +
+ +
+ + { + setCompilerVersion(e.target.value); + }} + placeholder="e.g. 2.6.4" + required + /> +
+ +
+ + { + setScarbVersion(e.target.value); + }} + placeholder="e.g. 2.6.4" + required + /> +
+ +
+
+ { + setIsAccountContract(e.target.checked); + }} + className="checkbox-tiny" + /> + +
+
+ + +
+
+ + {verificationMessage !== "" && ( +
+ {verificationMessage} +
+ )} +
+
+ ); +}; + +export default Verify; diff --git a/plugin/src/features/Verify/styles.css b/plugin/src/features/Verify/styles.css new file mode 100644 index 00000000..c75455e4 --- /dev/null +++ b/plugin/src/features/Verify/styles.css @@ -0,0 +1,138 @@ +.verify-form { + padding: 1rem; + width: 100%; + max-width: 100%; + box-sizing: border-box; +} + +.verify-form form { + width: 100%; + max-width: 100%; + box-sizing: border-box; +} + +.verify-form-input-box { + width: 100%; + box-sizing: border-box; + margin-bottom: 1rem; +} + +.verify-form input { + all: unset; + width: calc(100% - 30px); + box-sizing: border-box; + padding: 10px 15px; + border-radius: 4px; + font-size: 13px; + color: var(--text); + background-color: var(--bgPrimary); + border: 1px solid var(--secondary); + display: block; +} + +.verify-form input:focus { + border: 1px solid var(--info); +} + +.verify-form input::placeholder { + color: var(--text); + opacity: 0.5; +} + +.verify-form label { + font-size: 12px; + font-weight: 700; + line-height: 18px; + letter-spacing: 0.6px; + color: var(--text); +} + +.verify-status { + padding: 10px 15px; + border-radius: 4px; + font-size: 13px; + margin-top: 1rem; + -webkit-border-radius: 4px; + -moz-border-radius: 4px; + -ms-border-radius: 4px; + -o-border-radius: 4px; +} + +.verify-status.success { + color: var(--green); + border: 1px solid var(--green); +} + +.verify-status.error { + color: var(--red); + border: 1px solid var(--red); +} + +.verify-form-input-box .SelectTrigger { + background-color: var(--bgPrimary) !important; + box-sizing: border-box !important; + height: 45px !important; +} + +.verify-form-input-box input { + width: 100% !important; + height: 45px !important; +} + +/* Checkbox styles */ +.verify-form .account-contract-box { + margin-bottom: 0; +} + +.verify-form .checkbox-tiny { + all: unset; + width: 12px !important; + height: 12px !important; + border: 1px solid var(--secondary); + border-radius: 3px; + display: inline-flex; + align-items: center; + justify-content: center; + cursor: pointer; + position: relative; + background-color: var(--bgPrimary); + margin-top: 1px; +} + +.verify-form .checkbox-tiny:checked { + background-color: var(--primary); + border-color: var(--primary); +} + +.checkbox-container { + + align-items: center; + vertical-align: middle; + display: flex; + gap: 0.5rem; +} + +.checkbox-container input { + margin: 0; +} + +.checkbox-container label { + margin: 0; +} + +.verify-form .checkbox-tiny:checked::after { + content: "✓"; + color: white; + font-size: 8px; + position: absolute; + top: 50%; + left: 50%; + transform: translate(-50%, -50%); +} + +.verify-form .checkbox-tiny + label { + cursor: pointer; + color: var(--text); + opacity: 0.8; + line-height: 1; +} \ No newline at end of file diff --git a/plugin/src/hooks/starknetWindow.ts b/plugin/src/hooks/starknetWindow.ts index a5133471..55f1b884 100644 --- a/plugin/src/hooks/starknetWindow.ts +++ b/plugin/src/hooks/starknetWindow.ts @@ -8,7 +8,7 @@ import { import { useEffect, useState } from "react"; import useAccount from "./useAccount"; import useProvider from "./useProvider"; -import useRemixClient from "./useRemixClient"; +import { useRemixClient } from "./useRemixClient"; import { starknetWindowObject as stObj } from "../atoms/connection"; import { useAtom } from "jotai"; diff --git a/plugin/src/hooks/useIcons.ts b/plugin/src/hooks/useIcons.ts index 9d5d88e2..9b3708ec 100644 --- a/plugin/src/hooks/useIcons.ts +++ b/plugin/src/hooks/useIcons.ts @@ -1,5 +1,5 @@ import { useState, useEffect } from "react"; -import useRemixClient from "./useRemixClient"; +import { useRemixClient } from "./useRemixClient"; export const useIcon = (name: string): string => { const { remixClient } = useRemixClient(); diff --git a/plugin/src/hooks/useRemixClient.ts b/plugin/src/hooks/useRemixClient.ts index aea74b92..cf00c32d 100644 --- a/plugin/src/hooks/useRemixClient.ts +++ b/plugin/src/hooks/useRemixClient.ts @@ -63,8 +63,11 @@ export class RemixClient extends PluginClient { } } } + const remixClient = createClient(new RemixClient()); +type RemixClientType = typeof remixClient; + async function remixWriteFiles(files: RemixFileInfo[]): Promise { for (const file of files) { const filePath = @@ -156,4 +159,4 @@ const useRemixClient = (): { return { remixClient }; }; -export default useRemixClient; +export { type RemixClientType, useRemixClient }; diff --git a/plugin/src/utils/api.ts b/plugin/src/utils/api.ts index bf0f696e..544abdc5 100644 --- a/plugin/src/utils/api.ts +++ b/plugin/src/utils/api.ts @@ -30,12 +30,21 @@ export interface TestRequest extends BaseRequest { test_engine: TestEngine; } +export interface VerifyRequest extends BaseRequest { + contract_name: string; + contract_address: string; + network: string; +} + export interface CompilationResponse extends ApiResponse { } export interface TestResponse extends ApiResponse { } +export interface VerifyResponse extends ApiResponse { +} + export interface VersionResponse extends ApiResponse { } @@ -71,9 +80,6 @@ export class Api { const pid = responseJson.data; - console.log("pid: ", pid); - console.log(`${this.apiUrl}/${getterMethod}/${pid}`); - try { await this.waitProcess(pid); @@ -94,7 +100,6 @@ export class Api { private async waitProcess (pid: string): Promise { const response: ApiResponse = await this.rawGetRequest(`process_status/${pid}`); - console.log("response: ", response); if (response.data === "Completed") { return response.data; } @@ -133,6 +138,10 @@ export class Api { return await this.asyncFetch("test-async", "test-async", request); } + public async verify (request: VerifyRequest): Promise { + return await this.asyncFetch("verify-async", "verify-async", request); + } + public async version (): Promise { return await this.asyncFetch("scarb-version-async", "scarb-version-async"); } diff --git a/plugin/src/utils/types/voyager.ts b/plugin/src/utils/types/voyager.ts new file mode 100644 index 00000000..63e8105e --- /dev/null +++ b/plugin/src/utils/types/voyager.ts @@ -0,0 +1,53 @@ +export type VoyagerLicense = + | "No License (None)" + | "The Unlicense (Unlicense)" + | "MIT License (MIT)" + | "GNU General Public License v2.0 (GNU GPLv2)" + | "GNU General Public License v3.0 (GNU GPLv3)" + | "GNU Lesser General Public License v2.1 (GNU LGPLv2.1)" + | "GNU Lesser General Public License v3.0 (GNU LGPLv3)" + | "BSD 2-clause \"Simplified\" license (BSD-2-Clause)" + | "BSD 3-clause \"New\" Or \"Revisited license (BSD-3-Clause)" + | "Mozilla Public License 2.0 (MPL-2.0)" + | "Open Software License 3.0 (OSL-3.0)" + | "Apache 2.0 (Apache-2.0)" + | "GNU Affero General Public License (GNU AGPLv3)" + | "Business Source License (BSL 1.1)"; + +export interface VoyagerVerifyRequest { + compiler_version: string; + license: VoyagerLicense; + contract_file: string; + scarb_version: string; + name: string; + account_contract: boolean; + project_dir_path: string; + files: Record; +} + +export interface VoyagerVerifyResponse { + job_id: string; +} + +export enum VoyagerVerificationStatus { + SUBMITTED = 0, + COMPILED = 1, + COMPILE_FAILED = 2, + FAIL = 3, + SUCCESS = 4, +} + +export interface VoyagerJobStatusResponse { + status: VoyagerVerificationStatus; + job_id: string; + class_hash: string; + created_timestamp: number; + updated_timestamp: number; + status_description: string; + message?: string; + address: string; + contract_file: string; + name: string; + version: string; + license: VoyagerLicense; +} diff --git a/plugin/src/utils/utils.ts b/plugin/src/utils/utils.ts index 264dc9b7..aeb2bd8b 100644 --- a/plugin/src/utils/utils.ts +++ b/plugin/src/utils/utils.ts @@ -1,5 +1,7 @@ import { type Abi, type AbiElement } from "./types/contracts"; import { type Network, networkExplorerUrls } from "./constants"; +import type { FileContentMap } from "./api"; +import { type RemixClientType } from "../hooks/useRemixClient"; function isValidCairo (filename: string): boolean { return filename?.endsWith(".cairo") ?? false; @@ -44,6 +46,32 @@ const trimStr = (str?: string, strip?: number): string => { return `${str?.slice(0, lStrip)}...${str?.slice(length - lStrip)}`; }; +const getFolderFilemapRecursive = async ( + workspacePath: string, + remixClient: RemixClientType, + dirPath = "" +): Promise => { + const files = [] as FileContentMap[]; + const pathFiles = await remixClient.fileManager.readdir(`${workspacePath}/${dirPath}`); + for (const [path, entry] of Object.entries(pathFiles)) { + if (entry.isDirectory === true) { + const deps = await getFolderFilemapRecursive(workspacePath, remixClient, path); + for (const dep of deps) files.push(dep); + continue; + } + + const content = await remixClient.fileManager.readFile(path); + + if (!path.endsWith(".cairo") && !path.endsWith("Scarb.toml")) continue; + + files.push({ + file_name: path, + file_content: content + }); + } + return files; +}; + export { isValidCairo, getFileNameFromPath, @@ -54,5 +82,6 @@ export { getRoundedNumber, weiToEth, getExplorerUrl, - trimStr + trimStr, + getFolderFilemapRecursive }; diff --git a/plugin/src/utils/voyager.ts b/plugin/src/utils/voyager.ts new file mode 100644 index 00000000..bd2e6911 --- /dev/null +++ b/plugin/src/utils/voyager.ts @@ -0,0 +1,81 @@ +import type { + VoyagerVerifyRequest, + VoyagerVerifyResponse, + VoyagerJobStatusResponse, + VoyagerLicense +} from "./types/voyager"; + +export type VoyagerNetwork = "mainnet" | "sepolia"; + +export class VoyagerAPI { + private getBaseUrl(network: VoyagerNetwork): string { + return network === "mainnet" + ? "https://api.voyager.online/beta" + : "https://sepolia-api.voyager.online/beta"; + } + + async verifyContract(request: VoyagerVerifyRequest, network: VoyagerNetwork, classHash: string): Promise { + console.log(request); + + const response = await fetch(`${this.getBaseUrl(network)}/class-verify/${classHash}`, { + method: "POST", + headers: { + "Content-Type": "application/json" + }, + body: JSON.stringify(request) + }); + + if (!response.ok) { + throw new Error(`Verification request failed: ${response.statusText}`); + } + + return await response.json(); + } + + async getVerificationStatus(jobId: string, network: VoyagerNetwork): Promise { + const response = await fetch(`${this.getBaseUrl(network)}/class-verify/job/${jobId}`); + + if (!response.ok) { + throw new Error(`Failed to get verification status: ${response.statusText}`); + } + + return await response.json(); + } + + createScarbToml(contractName: string, starknetVersion: string): string { + return `[package] +name = "${contractName.toLowerCase()}" +version = "0.1.0" + +[dependencies] +starknet = "${starknetVersion}" + +[tool.voyager] +contract = { path = "src/lib.cairo" }`; + } + + prepareVerificationRequest( + contractName: string, + contractContent: string, + compilerVersion: string, + scarbVersion: string, + license: VoyagerLicense, + isAccountContract: boolean + ): VoyagerVerifyRequest { + const scarbToml = this.createScarbToml(contractName, compilerVersion); + + return { + compiler_version: compilerVersion, + license, + contract_file: "src/lib.cairo", + scarb_version: scarbVersion, + name: contractName, + account_contract: isAccountContract, + project_dir_path: ".", + files: { + "Scarb.toml": scarbToml, + "src/lib.cairo": contractContent + } + }; + } +}