verifier/
api.rs

1use std::{fmt::Display, fs, path::PathBuf, time::Duration};
2
3use backon::{BlockingRetryable, ExponentialBuilder};
4use reqwest::{
5    blocking::{self, multipart, Client},
6    StatusCode,
7};
8use semver;
9use serde_repr::{Deserialize_repr, Serialize_repr};
10use thiserror::Error;
11use url::Url;
12
13use crate::{
14    class_hash::ClassHash,
15    errors::{self, RequestFailure},
16};
17
18#[derive(Clone, Debug, Deserialize_repr, Eq, PartialEq, Serialize_repr)]
19#[repr(u8)]
20pub enum VerifyJobStatus {
21    Submitted = 0,
22    Compiled = 1,
23    CompileFailed = 2,
24    Fail = 3,
25    Success = 4,
26    Processing = 5,
27    #[serde(other)]
28    Unknown,
29}
30
31#[derive(Debug, Error)]
32pub enum VerificationError {
33    #[error("Compilation failed: {0}")]
34    CompilationFailure(String),
35
36    #[error("Compilation failed: {0}")]
37    VerificationFailure(String),
38}
39
40// TODO: Option blindness?
41type JobStatus = Option<VerificationJob>;
42
43impl Display for VerifyJobStatus {
44    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
45        match self {
46            Self::Submitted => write!(f, "Submitted"),
47            Self::Compiled => write!(f, "Compiled"),
48            Self::CompileFailed => write!(f, "CompileFailed"),
49            Self::Fail => write!(f, "Fail"),
50            Self::Success => write!(f, "Success"),
51            Self::Processing => write!(f, "Processing"),
52            Self::Unknown => write!(f, "Unknown"),
53        }
54    }
55}
56
57#[derive(Clone)]
58pub struct ApiClient {
59    base: Url,
60    client: Client,
61}
62
63#[derive(Error, Debug)]
64pub enum ApiClientError {
65    #[error("{0} cannot be base, provide valid URL")]
66    CannotBeBase(Url),
67
68    #[error(transparent)]
69    Reqwest(#[from] reqwest::Error),
70
71    #[error("Verification job is still in progress")]
72    InProgress,
73
74    #[error(transparent)]
75    Failure(#[from] errors::RequestFailure),
76
77    #[error("Job {0} not found")]
78    JobNotFound(String),
79
80    #[error(transparent)]
81    Verify(#[from] VerificationError),
82
83    #[error(transparent)]
84    IoError(#[from] std::io::Error),
85
86    #[error("URL cannot be a base: {0}")]
87    UrlCannotBeBase(#[from] url::ParseError),
88}
89
90/**
91 * Currently only `GetJobStatus` and `VerifyClass` are public available apis.
92 * In the future, the get class api should be moved to using public apis too.
93 * TODO: Change get class api to use public apis.
94 */
95impl ApiClient {
96    /// # Errors
97    ///
98    /// Fails if provided `Url` cannot be a base. We rely on that
99    /// invariant in other methods.
100    pub fn new(base: Url) -> Result<Self, ApiClientError> {
101        // Test here so that we are sure path_segments_mut succeeds
102        if base.cannot_be_a_base() {
103            Err(ApiClientError::CannotBeBase(base))
104        } else {
105            Ok(Self {
106                base,
107                client: blocking::Client::new(),
108            })
109        }
110    }
111
112    /// # Errors
113    ///
114    /// Will return `Err` if the URL cannot be a base.
115    pub fn get_class_url(&self, class_hash: &ClassHash) -> Result<Url, ApiClientError> {
116        let mut url = self.base.clone();
117        let url_clone = url.clone();
118        url.path_segments_mut()
119            .map_err(|_| ApiClientError::CannotBeBase(url_clone))?
120            .extend(&["classes", class_hash.as_ref()]);
121        Ok(url)
122    }
123
124    /// # Errors
125    ///
126    /// Returns `Err` if the required `class_hash` is not found or on
127    /// network failure.
128    pub fn get_class(&self, class_hash: &ClassHash) -> Result<bool, ApiClientError> {
129        let url = self.get_class_url(class_hash)?;
130        let result = self
131            .client
132            .get(url.clone())
133            .send()
134            .map_err(ApiClientError::from)?;
135
136        match result.status() {
137            StatusCode::OK => Ok(true),
138            StatusCode::NOT_FOUND => Ok(false),
139            _ => Err(ApiClientError::from(RequestFailure::new(
140                url,
141                result.status(),
142                result.text()?,
143            ))),
144        }
145    }
146
147    /// # Errors
148    ///
149    /// Will return `Err` if the URL cannot be a base.
150    pub fn verify_class_url(&self, class_hash: &ClassHash) -> Result<Url, ApiClientError> {
151        let mut url = self.base.clone();
152        let url_clone = url.clone();
153        url.path_segments_mut()
154            .map_err(|_| ApiClientError::CannotBeBase(url_clone))?
155            .extend(&["class-verify", class_hash.as_ref()]);
156        Ok(url)
157    }
158
159    /// # Errors
160    ///
161    /// Will return `Err` on network request failure or if can't
162    /// gather file contents for submission.
163    pub fn verify_class(
164        &self,
165        class_hash: &ClassHash,
166        license: Option<String>,
167        name: &str,
168        project_metadata: ProjectMetadataInfo,
169        files: &[FileInfo],
170    ) -> Result<String, ApiClientError> {
171        let mut body = multipart::Form::new()
172            .percent_encode_noop()
173            .text(
174                "compiler_version",
175                project_metadata.cairo_version.to_string(),
176            )
177            .text("scarb_version", project_metadata.scarb_version.to_string())
178            .text("package_name", project_metadata.package_name)
179            .text("name", name.to_string())
180            .text("contract_file", project_metadata.contract_file)
181            .text("project_dir_path", project_metadata.project_dir_path);
182
183        // Add license using raw SPDX identifier
184        let license_value = if let Some(lic) = license {
185            if lic == "MIT" {
186                "MIT".to_string() // Ensure MIT is formatted correctly
187            } else {
188                lic
189            }
190        } else {
191            "NONE".to_string()
192        };
193
194        body = body.text("license", license_value);
195
196        // Send each file as a separate field with files[] prefix
197        for file in files {
198            let file_content = fs::read_to_string(file.path.as_path())?;
199            body = body.text(format!("files[{}]", file.name), file_content);
200        }
201
202        let url = self.verify_class_url(class_hash)?;
203
204        let response = self
205            .client
206            .post(url.clone())
207            .multipart(body)
208            .send()
209            .map_err(ApiClientError::Reqwest)?;
210
211        match response.status() {
212            StatusCode::OK => (),
213            StatusCode::BAD_REQUEST => {
214                return Err(ApiClientError::from(RequestFailure::new(
215                    url,
216                    StatusCode::BAD_REQUEST,
217                    response.json::<Error>()?.error,
218                )));
219            }
220            status_code => {
221                return Err(ApiClientError::from(RequestFailure::new(
222                    url,
223                    status_code,
224                    response.text()?,
225                )));
226            }
227        }
228
229        Ok(response.json::<VerificationJobDispatch>()?.job_id)
230    }
231
232    /// # Errors
233    ///
234    /// Will return `Err` if the URL cannot be a base.
235    pub fn get_job_status_url(&self, job_id: impl AsRef<str>) -> Result<Url, ApiClientError> {
236        let mut url = self.base.clone();
237        let url_clone = url.clone();
238        url.path_segments_mut()
239            .map_err(|_| ApiClientError::CannotBeBase(url_clone))?
240            .extend(&["class-verify", "job", job_id.as_ref()]);
241        Ok(url)
242    }
243
244    /// # Errors
245    ///
246    /// Will return `Err` on network error or if the verification has
247    /// failed.
248    pub fn get_job_status(
249        &self,
250        job_id: impl Into<String> + Clone,
251    ) -> Result<JobStatus, ApiClientError> {
252        let url = self.get_job_status_url(job_id.clone().into())?;
253        let response = self.client.get(url.clone()).send()?;
254
255        match response.status() {
256            StatusCode::OK => (),
257            StatusCode::NOT_FOUND => return Err(ApiClientError::JobNotFound(job_id.into())),
258            status_code => {
259                return Err(ApiClientError::from(RequestFailure::new(
260                    url,
261                    status_code,
262                    response.text()?,
263                )));
264            }
265        }
266
267        let data = response.json::<VerificationJob>()?;
268        match data.status {
269            VerifyJobStatus::Success => Ok(Some(data)),
270            VerifyJobStatus::Fail => Err(ApiClientError::from(
271                VerificationError::VerificationFailure(
272                    data.status_description
273                        .unwrap_or_else(|| "unknown failure".to_owned()),
274                ),
275            )),
276            VerifyJobStatus::CompileFailed => {
277                Err(ApiClientError::from(VerificationError::CompilationFailure(
278                    data.status_description
279                        .unwrap_or_else(|| "unknown failure".to_owned()),
280                )))
281            }
282            VerifyJobStatus::Submitted
283            | VerifyJobStatus::Compiled
284            | VerifyJobStatus::Processing
285            | VerifyJobStatus::Unknown => Ok(None),
286        }
287    }
288}
289
290#[derive(Debug, serde::Deserialize)]
291pub struct Error {
292    error: String,
293}
294
295#[derive(Debug, serde::Deserialize)]
296pub struct VerificationJobDispatch {
297    job_id: String,
298}
299
300#[derive(Debug, serde::Deserialize)]
301pub struct VerificationJob {
302    job_id: String,
303    status: VerifyJobStatus,
304    status_description: Option<String>,
305    class_hash: String,
306    created_timestamp: Option<f64>,
307    updated_timestamp: Option<f64>,
308    address: Option<String>,
309    contract_file: Option<String>,
310    name: Option<String>,
311    version: Option<String>,
312    license: Option<String>,
313}
314
315impl VerificationJob {
316    pub const fn status(&self) -> &VerifyJobStatus {
317        &self.status
318    }
319
320    pub fn class_hash(&self) -> &str {
321        &self.class_hash
322    }
323
324    pub fn job_id(&self) -> &str {
325        &self.job_id
326    }
327
328    pub fn name(&self) -> Option<&str> {
329        self.name.as_deref()
330    }
331
332    pub fn contract_file(&self) -> Option<&str> {
333        self.contract_file.as_deref()
334    }
335
336    pub fn status_description(&self) -> Option<&str> {
337        self.status_description.as_deref()
338    }
339
340    pub const fn created_timestamp(&self) -> Option<f64> {
341        self.created_timestamp
342    }
343
344    pub const fn updated_timestamp(&self) -> Option<f64> {
345        self.updated_timestamp
346    }
347
348    pub fn address(&self) -> Option<&str> {
349        self.address.as_deref()
350    }
351
352    pub fn version(&self) -> Option<&str> {
353        self.version.as_deref()
354    }
355
356    pub fn license(&self) -> Option<&str> {
357        self.license.as_deref()
358    }
359}
360
361#[derive(Debug, Eq, PartialEq)]
362pub struct FileInfo {
363    pub name: String,
364    pub path: PathBuf,
365}
366
367#[derive(Debug, Clone)]
368pub struct ProjectMetadataInfo {
369    pub cairo_version: semver::Version,
370    pub scarb_version: semver::Version,
371    pub project_dir_path: String,
372    pub contract_file: String,
373    pub package_name: String,
374}
375
376pub enum Status {
377    InProgress,
378    Finished(ApiClientError),
379}
380
381const fn is_is_progress(status: &Status) -> bool {
382    match status {
383        Status::InProgress => true,
384        Status::Finished(_) => false,
385    }
386}
387
388/// # Errors
389///
390/// Will return `Err` on network error or if the verification has
391/// failed.
392pub fn poll_verification_status(
393    api: &ApiClient,
394    job_id: &str,
395) -> Result<VerificationJob, ApiClientError> {
396    let fetch = || -> Result<VerificationJob, Status> {
397        let result: Option<VerificationJob> = api
398            .get_job_status(job_id.to_owned())
399            .map_err(Status::Finished)?;
400
401        result.ok_or(Status::InProgress)
402    };
403
404    // So verbose because it has problems with inference
405    fetch
406        .retry(
407            ExponentialBuilder::default()
408                .with_max_times(0)
409                .with_min_delay(Duration::from_secs(2))
410                .with_max_delay(Duration::from_secs(300)) // 5 mins
411                .with_max_times(20),
412        )
413        .when(is_is_progress)
414        .notify(|_, dur: Duration| {
415            println!("Job: {job_id} didn't finish, retrying in {dur:?}");
416        })
417        .call()
418        .map_err(|err| match err {
419            Status::InProgress => ApiClientError::InProgress,
420            Status::Finished(e) => e,
421        })
422}