Skip to main content

forge_verify/
sourcify.rs

1use crate::{
2    provider::{
3        ExternalVerificationContext, VerificationContext, VerificationProvider,
4        VerificationProviderType,
5    },
6    utils::ensure_solc_build_metadata,
7    verify::{ContractLanguage, VerifyArgs, VerifyCheckArgs},
8};
9use alloy_primitives::Address;
10use async_trait::async_trait;
11use eyre::{Context, Result, eyre};
12use foundry_common::retry::RetryError;
13use futures::{FutureExt, StreamExt};
14use reqwest::StatusCode;
15use serde::{Deserialize, Serialize};
16use std::{future::Future as StdFuture, pin::Pin, time::Duration};
17use url::Url;
18
19pub static SOURCIFY_URL: &str = "https://sourcify.dev/server/";
20
21/// The type that can verify a contract on `sourcify`
22#[derive(Clone, Debug, Default)]
23#[non_exhaustive]
24pub struct SourcifyVerificationProvider;
25
26#[async_trait]
27impl VerificationProvider for SourcifyVerificationProvider {
28    fn provider_type(&self) -> VerificationProviderType {
29        VerificationProviderType::Sourcify
30    }
31
32    async fn preflight_verify_check(
33        &mut self,
34        args: VerifyArgs,
35        context: VerificationContext,
36    ) -> Result<()> {
37        let _ = self.prepare_verify_request(&args, &context).await?;
38        Ok(())
39    }
40
41    async fn submit(
42        &mut self,
43        args: VerifyArgs,
44        context: VerificationContext,
45    ) -> Result<Option<VerifyCheckArgs>> {
46        let body = self.prepare_verify_request(&args, &context).await?;
47        self.submit_verify_request(args, body, &context.target_name).await
48    }
49
50    fn submit_external(
51        &mut self,
52        args: VerifyArgs,
53        context: ExternalVerificationContext,
54    ) -> Pin<Box<dyn StdFuture<Output = Result<Option<VerifyCheckArgs>>> + '_>> {
55        Box::pin(async move {
56            let target = context.target.clone();
57            let body = Self::prepare_external_verify_request(&args, context);
58            self.submit_verify_request(args, body, &target).await
59        })
60    }
61
62    async fn check(&self, args: VerifyCheckArgs) -> Result<()> {
63        let url = Self::get_job_status_url(args.verifier.verifier_url.as_deref(), args.id.clone());
64
65        args.retry
66            .into_retry()
67            .run_async_until_break(|| async {
68                let response = verification_client()
69                    .map_err(RetryError::Break)?
70                    .get(&url)
71                    .send()
72                    .await
73                    .wrap_err("Failed to request verification status")
74                    .map_err(RetryError::Retry)?;
75
76                if response.status() == StatusCode::NOT_FOUND {
77                    return Err(RetryError::Break(eyre!(
78                        "No verification job found for ID {}",
79                        sanitize_remote_message(&args.id)
80                    )));
81                }
82
83                if !response.status().is_success() {
84                    return Err(RetryError::Retry(eyre!(
85                        "Failed to request verification status with status code {}",
86                        response.status()
87                    )));
88                }
89
90                let job_response: SourcifyJobResponse = serde_json::from_slice(
91                    &read_capped_body(response).await.map_err(RetryError::Retry)?,
92                )
93                .wrap_err("Failed to parse job response")
94                .map_err(RetryError::Retry)?;
95
96                if !job_response.is_job_completed {
97                    return Err(RetryError::Retry(eyre!("Verification is still pending...")));
98                }
99
100                if let Some(error) = job_response.error {
101                    if error.custom_code == "already_verified" {
102                        let _ = sh_status!("Contract source code already verified");
103                        return Ok(());
104                    }
105
106                    return Err(RetryError::Break(eyre!(
107                        "Verification job failed:\nError Code: `{}`\nMessage: `{}`",
108                        sanitize_remote_message(&error.custom_code),
109                        sanitize_remote_message(&error.message)
110                    )));
111                }
112
113                if let Some(contract_status) =
114                    job_response.contract.and_then(|contract| contract.match_status)
115                {
116                    let _ = sh_status!(
117                        "Contract successfully verified:\nStatus: `{}`",
118                        sanitize_remote_message(&contract_status),
119                    );
120                }
121                Ok(())
122            })
123            .await
124            .wrap_err("Checking verification result failed")
125    }
126}
127
128fn sanitize_remote_message(message: &str) -> String {
129    message.chars().map(|ch| if ch.is_control() { ' ' } else { ch }).take(512).collect()
130}
131
132const MAX_RESPONSE_BODY: usize = 1024 * 1024;
133
134fn verification_client() -> Result<reqwest::Client> {
135    reqwest::Client::builder()
136        .user_agent(foundry_common::DEFAULT_USER_AGENT)
137        .timeout(Duration::from_secs(30))
138        .connect_timeout(Duration::from_secs(10))
139        .build()
140        .wrap_err("Failed to create Sourcify HTTP client")
141}
142
143async fn read_capped_body(response: reqwest::Response) -> Result<Vec<u8>> {
144    let mut body = Vec::new();
145    let mut stream = response.bytes_stream();
146    while let Some(chunk) = stream.next().await {
147        let chunk = chunk?;
148        eyre::ensure!(
149            body.len().saturating_add(chunk.len()) <= MAX_RESPONSE_BODY,
150            "Sourcify response body exceeds 1 MiB"
151        );
152        body.extend_from_slice(&chunk);
153    }
154    Ok(body)
155}
156
157impl SourcifyVerificationProvider {
158    fn prepare_external_verify_request(
159        args: &VerifyArgs,
160        context: ExternalVerificationContext,
161    ) -> SourcifyVerifyRequest {
162        SourcifyVerifyRequest {
163            std_json_input: (*context.standard_json_input).clone(),
164            compiler_version: context.compiler_version.to_string(),
165            contract_identifier: context.target,
166            creation_transaction_hash: args.creation_transaction_hash.map(|hash| hash.to_string()),
167        }
168    }
169
170    async fn submit_verify_request(
171        &self,
172        args: VerifyArgs,
173        body: SourcifyVerifyRequest,
174        target: &str,
175    ) -> Result<Option<VerifyCheckArgs>> {
176        if !args.skip_is_verified_check && self.is_contract_verified(&args).await? {
177            sh_status!(
178                "Contract [{}] {:?} is already verified. Skipping verification.",
179                target,
180                args.address.to_string()
181            )?;
182
183            return Ok(None);
184        }
185
186        trace!(provider = "Sourcify", address = %args.address, target, "submitting verification request");
187
188        let chain_id = args.etherscan.chain.unwrap_or_default().id();
189        let url =
190            Self::get_verify_url(args.verifier.verifier_url.as_deref(), chain_id, args.address);
191        let client = verification_client()?;
192
193        let resp = args
194            .retry
195            .into_retry()
196            .run_async(|| {
197                async {
198                    sh_status!("Submitting verification for [{}] {}.", target, args.address)?;
199                    let response = client
200                        .post(&url)
201                        .header("Content-Type", "application/json")
202                        .body(serde_json::to_string(&body)?)
203                        .send()
204                        .await?;
205
206                    let status = response.status();
207                    match status {
208                        StatusCode::CONFLICT => {
209                            sh_status!("Contract source code already fully verified")?;
210                            Ok(None)
211                        }
212                        StatusCode::ACCEPTED => {
213                            let text = read_capped_body(response).await?;
214                            let verify_response: SourcifyVerificationResponse =
215                                serde_json::from_slice(&text)
216                                    .wrap_err("Failed to parse Sourcify verification response")?;
217                            Ok(Some(verify_response))
218                        }
219                        _ => {
220                            eyre::bail!(
221                                "Sourcify verification request for address ({}) failed with status code {status}",
222                                args.address,
223                            );
224                        }
225                    }
226                }
227                .boxed()
228            })
229            .await?;
230
231        if let Some(resp) = resp {
232            eyre::ensure!(
233                !resp.verification_id.is_empty()
234                    && resp.verification_id.len() <= 512
235                    && !matches!(resp.verification_id.as_str(), "." | ".."),
236                "Sourcify returned an invalid verification job ID"
237            );
238            let display_id = sanitize_remote_message(&resp.verification_id);
239            let job_url = Self::get_job_ui_url(
240                args.verifier.verifier_url.as_deref(),
241                resp.verification_id.clone(),
242            );
243            sh_status!(
244                "Submitted contract for verification:\n\tVerification Job ID: `{}`\n\tURL: {}",
245                display_id,
246                job_url
247            )?;
248            if args.print_submission_result_to_stdout {
249                sh_println!("{}\t{}", display_id, job_url)?;
250            }
251            Ok(Some(VerifyCheckArgs {
252                id: resp.verification_id,
253                etherscan: args.etherscan,
254                retry: args.retry,
255                verifier: args.verifier,
256            }))
257        } else {
258            Ok(None)
259        }
260    }
261
262    fn get_base_url(verifier_url: Option<&str>) -> Url {
263        // note(onbjerg): a little ugly but makes this infallible as we guarantee `SOURCIFY_URL` to
264        // be well formatted
265        let mut url = Url::parse(verifier_url.unwrap_or(SOURCIFY_URL))
266            .unwrap_or_else(|_| Url::parse(SOURCIFY_URL).unwrap());
267        if !url.path().ends_with('/') {
268            url.set_path(&format!("{}/", url.path()));
269        }
270        url
271    }
272
273    fn get_verify_url(
274        verifier_url: Option<&str>,
275        chain_id: u64,
276        contract_address: Address,
277    ) -> String {
278        let base_url = Self::get_base_url(verifier_url);
279        format!("{base_url}v2/verify/{chain_id}/{contract_address}")
280    }
281
282    fn get_job_status_url(verifier_url: Option<&str>, job_id: String) -> String {
283        Self::get_job_url(verifier_url, &["v2", "verify"], &job_id)
284    }
285
286    fn get_job_ui_url(verifier_url: Option<&str>, job_id: String) -> String {
287        Self::get_job_url(verifier_url, &["verify-ui", "jobs"], &job_id)
288    }
289
290    fn get_job_url(verifier_url: Option<&str>, path: &[&str], job_id: &str) -> String {
291        let base_url = Self::get_base_url(verifier_url);
292        let job_id = encode_path_segment(job_id);
293        format!("{base_url}{}/{job_id}", path.join("/"))
294    }
295
296    fn get_lookup_url(
297        verifier_url: Option<&str>,
298        chain_id: u64,
299        contract_address: Address,
300    ) -> String {
301        let base_url = Self::get_base_url(verifier_url);
302        format!("{base_url}v2/contract/{chain_id}/{contract_address}")
303    }
304
305    /// Configures the API request to the sourcify API using the given [`VerifyArgs`].
306    async fn prepare_verify_request(
307        &self,
308        args: &VerifyArgs,
309        context: &VerificationContext,
310    ) -> Result<SourcifyVerifyRequest> {
311        let lang = args.detect_language(context);
312        let contract_identifier = format!(
313            "{}:{}",
314            context
315                .target_path
316                .strip_prefix(context.project.root())
317                .unwrap_or(context.target_path.as_path())
318                .display(),
319            context.target_name
320        );
321        let creation_transaction_hash = args.creation_transaction_hash.map(|h| h.to_string());
322
323        match lang {
324            ContractLanguage::Solidity => {
325                let input = context.get_solc_standard_json_input()?;
326
327                let std_json_input = serde_json::to_value(&input)
328                    .wrap_err("Failed to serialize standard json input")?;
329                let compiler_version =
330                    ensure_solc_build_metadata(context.compiler_version.clone()).await?.to_string();
331
332                Ok(SourcifyVerifyRequest {
333                    std_json_input,
334                    compiler_version,
335                    contract_identifier,
336                    creation_transaction_hash,
337                })
338            }
339            ContractLanguage::Vyper => {
340                let input = context.get_vyper_standard_json_input()?;
341                let std_json_input = serde_json::to_value(&input)
342                    .wrap_err("Failed to serialize vyper json input")?;
343
344                let compiler_version = context.compiler_version.to_string();
345
346                Ok(SourcifyVerifyRequest {
347                    std_json_input,
348                    compiler_version,
349                    contract_identifier,
350                    creation_transaction_hash,
351                })
352            }
353        }
354    }
355
356    async fn is_contract_verified(&self, args: &VerifyArgs) -> Result<bool> {
357        let chain_id = args.etherscan.chain.unwrap_or_default().id();
358        let url =
359            Self::get_lookup_url(args.verifier.verifier_url.as_deref(), chain_id, args.address);
360
361        match verification_client()?.get(&url).send().await {
362            Ok(response) => {
363                if response.status().is_success() {
364                    let contract_response: SourcifyContractResponse =
365                        serde_json::from_slice(&read_capped_body(response).await?)
366                            .wrap_err("Failed to parse contract response")?;
367
368                    let creation_exact = contract_response
369                        .creation_match
370                        .as_ref()
371                        .is_some_and(|s| s == "exact_match");
372
373                    let runtime_exact = contract_response
374                        .runtime_match
375                        .as_ref()
376                        .is_some_and(|s| s == "exact_match");
377
378                    Ok(creation_exact && runtime_exact)
379                } else {
380                    Ok(false)
381                }
382            }
383            Err(error) => Err(error).wrap_err_with(|| {
384                format!("Failed to query verification status for {}", args.address)
385            }),
386        }
387    }
388}
389
390fn encode_path_segment(value: &str) -> String {
391    const HEX: &[u8; 16] = b"0123456789ABCDEF";
392    let mut encoded = String::with_capacity(value.len());
393    for byte in value.bytes() {
394        if byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_' | b'~') {
395            encoded.push(char::from(byte));
396        } else {
397            encoded.push('%');
398            encoded.push(char::from(HEX[(byte >> 4) as usize]));
399            encoded.push(char::from(HEX[(byte & 0x0f) as usize]));
400        }
401    }
402    encoded
403}
404
405#[derive(Debug, Serialize)]
406#[serde(rename_all = "camelCase")]
407pub struct SourcifyVerifyRequest {
408    std_json_input: serde_json::Value,
409    compiler_version: String,
410    contract_identifier: String,
411    #[serde(skip_serializing_if = "Option::is_none")]
412    creation_transaction_hash: Option<String>,
413}
414
415#[derive(Debug, Deserialize)]
416#[serde(rename_all = "camelCase")]
417pub struct SourcifyVerificationResponse {
418    verification_id: String,
419}
420
421#[derive(Debug, Deserialize)]
422#[serde(rename_all = "camelCase")]
423pub struct SourcifyJobResponse {
424    is_job_completed: bool,
425    contract: Option<SourcifyContractResponse>,
426    error: Option<SourcifyErrorResponse>,
427}
428
429#[derive(Debug, Deserialize)]
430#[serde(rename_all = "camelCase")]
431pub struct SourcifyContractResponse {
432    #[serde(rename = "match")]
433    match_status: Option<String>,
434    creation_match: Option<String>,
435    runtime_match: Option<String>,
436}
437
438#[derive(Debug, Deserialize)]
439#[serde(rename_all = "camelCase")]
440pub struct SourcifyErrorResponse {
441    custom_code: String,
442    message: String,
443}
444
445#[cfg(test)]
446mod tests {
447    use super::*;
448    use clap::Parser;
449    use foundry_config::Config;
450    use foundry_test_utils::{forgetest, util::SOLC_VERSION};
451    use serde_json::json;
452    use std::{
453        io::{Read, Write},
454        net::TcpListener,
455        thread,
456    };
457
458    #[test]
459    fn external_request_preserves_raw_input_and_target() {
460        let args =
461            VerifyArgs::parse_from(["foundry-cli", "0xd8509bee9c9bf012282ad33aba0d87241baf5064"]);
462        let input = json!({
463            "language": "Solidity",
464            "settings": { "unknownSetting": { "futureField": true } },
465            "unknownTopLevel": [1, 2, 3]
466        });
467        let context = ExternalVerificationContext {
468            config: Config::default(),
469            compiler_version: "0.8.30+commit.73712a01".parse().unwrap(),
470            standard_json_input: std::sync::Arc::new(input.clone()),
471            target: "contracts/Unknown.sol:ExactTarget".to_string(),
472        };
473
474        let request = SourcifyVerificationProvider::prepare_external_verify_request(&args, context);
475        assert_eq!(request.std_json_input, input);
476        assert_eq!(request.contract_identifier, "contracts/Unknown.sol:ExactTarget");
477        assert_eq!(request.compiler_version, "0.8.30+commit.73712a01");
478    }
479
480    #[test]
481    fn job_urls_encode_opaque_ids_as_one_path_segment() {
482        let id = "job a+b/part?query#fragment\n".to_string();
483        let status = SourcifyVerificationProvider::get_job_status_url(None, id.clone());
484        let ui = SourcifyVerificationProvider::get_job_ui_url(None, id);
485        let encoded = "job%20a%2Bb%2Fpart%3Fquery%23fragment%0A";
486        assert!(status.ends_with(&format!("v2/verify/{encoded}")), "{status}");
487        assert!(ui.ends_with(&format!("verify-ui/jobs/{encoded}")), "{ui}");
488        assert_eq!(encode_path_segment("."), "%2E");
489        assert_eq!(encode_path_segment(".."), "%2E%2E");
490    }
491
492    #[tokio::test]
493    async fn check_reports_error_with_null_contract() {
494        let body = json!({
495            "isJobCompleted": true,
496            "contract": null,
497            "error": {
498                "customCode": "internal_error",
499                "message": "too many connections from this IP"
500            }
501        })
502        .to_string();
503        let server = TcpListener::bind("127.0.0.1:0").unwrap();
504        let server_url = format!("http://{}/server", server.local_addr().unwrap());
505        let server_thread = thread::spawn(move || {
506            let (mut socket, _) = server.accept().unwrap();
507            let mut request = [0; 4096];
508            let bytes_read = socket.read(&mut request).unwrap();
509            assert!(
510                std::str::from_utf8(&request[..bytes_read])
511                    .unwrap()
512                    .starts_with("GET /server/v2/verify/job-id ")
513            );
514            socket
515                .write_all(
516                    format!(
517                        "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}",
518                        body.len()
519                    )
520                    .as_bytes(),
521                )
522                .unwrap();
523        });
524
525        let args = VerifyCheckArgs::parse_from([
526            "foundry-cli",
527            "job-id",
528            "--verifier-url",
529            &server_url,
530            "--retries",
531            "1",
532            "--delay",
533            "0",
534        ]);
535        let error = SourcifyVerificationProvider::default().check(args).await.unwrap_err();
536        server_thread.join().unwrap();
537
538        let error = format!("{error:?}");
539        assert!(error.contains("Error Code: `internal_error`"), "{error}");
540        assert!(error.contains("Message: `too many connections from this IP`"), "{error}");
541    }
542
543    #[tokio::test]
544    async fn verification_client_follows_redirects() {
545        let target = TcpListener::bind("127.0.0.1:0").unwrap();
546        let target_url = format!("http://{}", target.local_addr().unwrap());
547        let target_thread = thread::spawn(move || {
548            let (mut socket, _) = target.accept().unwrap();
549            let mut request = [0; 4096];
550            let bytes_read = socket.read(&mut request).unwrap();
551            assert!(std::str::from_utf8(&request[..bytes_read]).unwrap().starts_with("GET "));
552            socket
553                .write_all(
554                    b"HTTP/1.1 200 OK\r\nContent-Length: 10\r\nConnection: close\r\n\r\nredirected",
555                )
556                .unwrap();
557        });
558
559        let source = TcpListener::bind("127.0.0.1:0").unwrap();
560        let source_url = format!("http://{}", source.local_addr().unwrap());
561        let source_thread = thread::spawn(move || {
562            let (mut socket, _) = source.accept().unwrap();
563            let mut request = [0; 4096];
564            let bytes_read = socket.read(&mut request).unwrap();
565            assert!(bytes_read > 0);
566            socket
567                .write_all(
568                    format!(
569                        "HTTP/1.1 307 Temporary Redirect\r\nLocation: {target_url}\r\nContent-Length: 0\r\nConnection: close\r\n\r\n"
570                    )
571                    .as_bytes(),
572                )
573                .unwrap();
574        });
575
576        let response = verification_client()
577            .unwrap()
578            .get(source_url)
579            .send()
580            .await
581            .unwrap()
582            .text()
583            .await
584            .unwrap();
585        source_thread.join().unwrap();
586        target_thread.join().unwrap();
587        assert_eq!(response, "redirected");
588    }
589
590    #[forgetest]
591    async fn creates_correct_verify_request_body(prj: _) {
592        prj.add_source("Counter", "contract Counter {}");
593
594        let args = VerifyArgs::parse_from([
595            "foundry-cli",
596            "0xd8509bee9c9bf012282ad33aba0d87241baf5064",
597            "src/Counter.sol:Counter",
598            "--compiler-version",
599            SOLC_VERSION,
600            "--root",
601            &prj.root().to_string_lossy(),
602        ]);
603
604        let context = args.resolve_context().await.unwrap();
605        let provider = SourcifyVerificationProvider::default();
606        let request = provider.prepare_verify_request(&args, &context).await.unwrap();
607
608        assert_eq!(request.compiler_version, "0.8.35+commit.47b9dedd");
609        assert_eq!(request.contract_identifier, "src/Counter.sol:Counter");
610        assert!(request.creation_transaction_hash.is_none());
611
612        assert!(request.std_json_input.is_object());
613        let json_obj = request.std_json_input.as_object().unwrap();
614        assert!(json_obj.contains_key("sources"));
615        assert!(json_obj.contains_key("settings"));
616
617        let sources = json_obj.get("sources").unwrap().as_object().unwrap();
618        assert!(sources.contains_key("src/Counter.sol"));
619        let counter_source = sources.get("src/Counter.sol").unwrap().as_object().unwrap();
620        let content = counter_source.get("content").unwrap().as_str().unwrap();
621        assert!(content.contains("contract Counter {}"));
622    }
623}