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, parse_http_verifier_url},
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    /// Returns the Sourcify base URL, failing on an invalid explicit URL instead of falling back
263    /// to the public endpoint.
264    fn get_base_url(verifier_url: Option<&str>) -> Result<Url> {
265        let mut url = parse_http_verifier_url(verifier_url.unwrap_or(SOURCIFY_URL), "Sourcify")?;
266        if !url.path().ends_with('/') {
267            url.set_path(&format!("{}/", url.path()));
268        }
269        Ok(url)
270    }
271
272    fn get_verify_url(
273        verifier_url: Option<&str>,
274        chain_id: u64,
275        contract_address: Address,
276    ) -> Result<String> {
277        let base_url = Self::get_base_url(verifier_url)?;
278        Ok(format!("{base_url}v2/verify/{chain_id}/{contract_address}"))
279    }
280
281    fn get_job_status_url(verifier_url: Option<&str>, job_id: String) -> Result<String> {
282        Self::get_job_url(verifier_url, &["v2", "verify"], &job_id)
283    }
284
285    fn get_job_ui_url(verifier_url: Option<&str>, job_id: String) -> Result<String> {
286        Self::get_job_url(verifier_url, &["verify-ui", "jobs"], &job_id)
287    }
288
289    fn get_job_url(verifier_url: Option<&str>, path: &[&str], job_id: &str) -> Result<String> {
290        let base_url = Self::get_base_url(verifier_url)?;
291        let job_id = encode_path_segment(job_id);
292        Ok(format!("{base_url}{}/{job_id}", path.join("/")))
293    }
294
295    fn get_lookup_url(
296        verifier_url: Option<&str>,
297        chain_id: u64,
298        contract_address: Address,
299    ) -> Result<String> {
300        let base_url = Self::get_base_url(verifier_url)?;
301        Ok(format!("{base_url}v2/contract/{chain_id}/{contract_address}"))
302    }
303
304    /// Configures the API request to the sourcify API using the given [`VerifyArgs`].
305    async fn prepare_verify_request(
306        &self,
307        args: &VerifyArgs,
308        context: &VerificationContext,
309    ) -> Result<SourcifyVerifyRequest> {
310        // Reject an invalid explicit URL before preparing any source.
311        Self::get_base_url(args.verifier.verifier_url.as_deref())?;
312        let lang = args.detect_language(context);
313        let contract_identifier = format!(
314            "{}:{}",
315            context
316                .target_path
317                .strip_prefix(context.project.root())
318                .unwrap_or(context.target_path.as_path())
319                .display(),
320            context.target_name
321        );
322        let creation_transaction_hash = args.creation_transaction_hash.map(|h| h.to_string());
323
324        match lang {
325            ContractLanguage::Solidity => {
326                let input = context.get_solc_standard_json_input()?;
327
328                let std_json_input = serde_json::to_value(&input)
329                    .wrap_err("Failed to serialize standard json input")?;
330                let compiler_version =
331                    ensure_solc_build_metadata(context.compiler_version.clone()).await?.to_string();
332
333                Ok(SourcifyVerifyRequest {
334                    std_json_input,
335                    compiler_version,
336                    contract_identifier,
337                    creation_transaction_hash,
338                })
339            }
340            ContractLanguage::Vyper => {
341                let input = context.get_vyper_standard_json_input()?;
342                let std_json_input = serde_json::to_value(&input)
343                    .wrap_err("Failed to serialize vyper json input")?;
344
345                let compiler_version = context.compiler_version.to_string();
346
347                Ok(SourcifyVerifyRequest {
348                    std_json_input,
349                    compiler_version,
350                    contract_identifier,
351                    creation_transaction_hash,
352                })
353            }
354        }
355    }
356
357    async fn is_contract_verified(&self, args: &VerifyArgs) -> Result<bool> {
358        let chain_id = args.etherscan.chain.unwrap_or_default().id();
359        let url =
360            Self::get_lookup_url(args.verifier.verifier_url.as_deref(), chain_id, args.address)?;
361
362        match verification_client()?.get(&url).send().await {
363            Ok(response) => {
364                if response.status().is_success() {
365                    let contract_response: SourcifyContractResponse =
366                        serde_json::from_slice(&read_capped_body(response).await?)
367                            .wrap_err("Failed to parse contract response")?;
368
369                    let creation_exact = contract_response
370                        .creation_match
371                        .as_ref()
372                        .is_some_and(|s| s == "exact_match");
373
374                    let runtime_exact = contract_response
375                        .runtime_match
376                        .as_ref()
377                        .is_some_and(|s| s == "exact_match");
378
379                    Ok(creation_exact && runtime_exact)
380                } else {
381                    Ok(false)
382                }
383            }
384            Err(error) => Err(error).wrap_err_with(|| {
385                format!("Failed to query verification status for {}", args.address)
386            }),
387        }
388    }
389}
390
391fn encode_path_segment(value: &str) -> String {
392    const HEX: &[u8; 16] = b"0123456789ABCDEF";
393    let mut encoded = String::with_capacity(value.len());
394    for byte in value.bytes() {
395        if byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_' | b'~') {
396            encoded.push(char::from(byte));
397        } else {
398            encoded.push('%');
399            encoded.push(char::from(HEX[(byte >> 4) as usize]));
400            encoded.push(char::from(HEX[(byte & 0x0f) as usize]));
401        }
402    }
403    encoded
404}
405
406#[derive(Debug, Serialize)]
407#[serde(rename_all = "camelCase")]
408pub struct SourcifyVerifyRequest {
409    std_json_input: serde_json::Value,
410    compiler_version: String,
411    contract_identifier: String,
412    #[serde(skip_serializing_if = "Option::is_none")]
413    creation_transaction_hash: Option<String>,
414}
415
416#[derive(Debug, Deserialize)]
417#[serde(rename_all = "camelCase")]
418pub struct SourcifyVerificationResponse {
419    verification_id: String,
420}
421
422#[derive(Debug, Deserialize)]
423#[serde(rename_all = "camelCase")]
424pub struct SourcifyJobResponse {
425    is_job_completed: bool,
426    contract: Option<SourcifyContractResponse>,
427    error: Option<SourcifyErrorResponse>,
428}
429
430#[derive(Debug, Deserialize)]
431#[serde(rename_all = "camelCase")]
432pub struct SourcifyContractResponse {
433    #[serde(rename = "match")]
434    match_status: Option<String>,
435    creation_match: Option<String>,
436    runtime_match: Option<String>,
437}
438
439#[derive(Debug, Deserialize)]
440#[serde(rename_all = "camelCase")]
441pub struct SourcifyErrorResponse {
442    custom_code: String,
443    message: String,
444}
445
446#[cfg(test)]
447mod tests {
448    use super::*;
449    use clap::Parser;
450    use foundry_config::Config;
451    use foundry_test_utils::{forgetest, util::SOLC_VERSION};
452    use serde_json::json;
453    use std::{
454        io::{Read, Write},
455        net::TcpListener,
456        thread,
457    };
458
459    #[test]
460    fn external_request_preserves_raw_input_and_target() {
461        let args =
462            VerifyArgs::parse_from(["foundry-cli", "0xd8509bee9c9bf012282ad33aba0d87241baf5064"]);
463        let input = json!({
464            "language": "Solidity",
465            "settings": { "unknownSetting": { "futureField": true } },
466            "unknownTopLevel": [1, 2, 3]
467        });
468        let context = ExternalVerificationContext {
469            config: Config::default(),
470            compiler_version: "0.8.30+commit.73712a01".parse().unwrap(),
471            standard_json_input: std::sync::Arc::new(input.clone()),
472            target: "contracts/Unknown.sol:ExactTarget".to_string(),
473        };
474
475        let request = SourcifyVerificationProvider::prepare_external_verify_request(&args, context);
476        assert_eq!(request.std_json_input, input);
477        assert_eq!(request.contract_identifier, "contracts/Unknown.sol:ExactTarget");
478        assert_eq!(request.compiler_version, "0.8.30+commit.73712a01");
479    }
480
481    #[test]
482    fn job_urls_encode_opaque_ids_as_one_path_segment() {
483        let id = "job a+b/part?query#fragment\n".to_string();
484        let status = SourcifyVerificationProvider::get_job_status_url(None, id.clone()).unwrap();
485        let ui = SourcifyVerificationProvider::get_job_ui_url(None, id).unwrap();
486        let encoded = "job%20a%2Bb%2Fpart%3Fquery%23fragment%0A";
487        assert!(status.ends_with(&format!("v2/verify/{encoded}")), "{status}");
488        assert!(ui.ends_with(&format!("verify-ui/jobs/{encoded}")), "{ui}");
489        assert_eq!(encode_path_segment("."), "%2E");
490        assert_eq!(encode_path_segment(".."), "%2E%2E");
491    }
492
493    #[tokio::test]
494    async fn check_reports_error_with_null_contract() {
495        let body = json!({
496            "isJobCompleted": true,
497            "contract": null,
498            "error": {
499                "customCode": "internal_error",
500                "message": "too many connections from this IP"
501            }
502        })
503        .to_string();
504        let server = TcpListener::bind("127.0.0.1:0").unwrap();
505        let server_url = format!("http://{}/server", server.local_addr().unwrap());
506        let server_thread = thread::spawn(move || {
507            let (mut socket, _) = server.accept().unwrap();
508            let mut request = [0; 4096];
509            let bytes_read = socket.read(&mut request).unwrap();
510            assert!(
511                std::str::from_utf8(&request[..bytes_read])
512                    .unwrap()
513                    .starts_with("GET /server/v2/verify/job-id ")
514            );
515            socket
516                .write_all(
517                    format!(
518                        "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}",
519                        body.len()
520                    )
521                    .as_bytes(),
522                )
523                .unwrap();
524        });
525
526        let args = VerifyCheckArgs::parse_from([
527            "foundry-cli",
528            "job-id",
529            "--verifier-url",
530            &server_url,
531            "--retries",
532            "1",
533            "--delay",
534            "0",
535        ]);
536        let error = SourcifyVerificationProvider::default().check(args).await.unwrap_err();
537        server_thread.join().unwrap();
538
539        let error = format!("{error:?}");
540        assert!(error.contains("Error Code: `internal_error`"), "{error}");
541        assert!(error.contains("Message: `too many connections from this IP`"), "{error}");
542    }
543
544    #[tokio::test]
545    async fn check_rejects_invalid_explicit_url_instead_of_public_fallback() {
546        for (verifier_url, expected) in [
547            ("127.0.0.1:5555", "invalid Sourcify URL: relative URL without a base"),
548            ("", "invalid Sourcify URL: relative URL without a base"),
549            (
550                "https://user:password-secret@example.invalid:bad/server?api_key=query-secret",
551                "invalid Sourcify URL: invalid port number",
552            ),
553            (
554                "ftp://user:password-secret@example.invalid/server?api_key=query-secret",
555                "invalid Sourcify URL: URL scheme must be http or https",
556            ),
557        ] {
558            let args = VerifyCheckArgs::parse_from([
559                "foundry-cli",
560                "job-id",
561                "--verifier-url",
562                verifier_url,
563                "--retries",
564                "1",
565                "--delay",
566                "0",
567            ]);
568            let error = SourcifyVerificationProvider::default().check(args).await.unwrap_err();
569            assert_eq!(format!("{error:#}"), expected);
570        }
571    }
572
573    #[tokio::test]
574    async fn verification_client_follows_redirects() {
575        let target = TcpListener::bind("127.0.0.1:0").unwrap();
576        let target_url = format!("http://{}", target.local_addr().unwrap());
577        let target_thread = thread::spawn(move || {
578            let (mut socket, _) = target.accept().unwrap();
579            let mut request = [0; 4096];
580            let bytes_read = socket.read(&mut request).unwrap();
581            assert!(std::str::from_utf8(&request[..bytes_read]).unwrap().starts_with("GET "));
582            socket
583                .write_all(
584                    b"HTTP/1.1 200 OK\r\nContent-Length: 10\r\nConnection: close\r\n\r\nredirected",
585                )
586                .unwrap();
587        });
588
589        let source = TcpListener::bind("127.0.0.1:0").unwrap();
590        let source_url = format!("http://{}", source.local_addr().unwrap());
591        let source_thread = thread::spawn(move || {
592            let (mut socket, _) = source.accept().unwrap();
593            let mut request = [0; 4096];
594            let bytes_read = socket.read(&mut request).unwrap();
595            assert!(bytes_read > 0);
596            socket
597                .write_all(
598                    format!(
599                        "HTTP/1.1 307 Temporary Redirect\r\nLocation: {target_url}\r\nContent-Length: 0\r\nConnection: close\r\n\r\n"
600                    )
601                    .as_bytes(),
602                )
603                .unwrap();
604        });
605
606        let response = verification_client()
607            .unwrap()
608            .get(source_url)
609            .send()
610            .await
611            .unwrap()
612            .text()
613            .await
614            .unwrap();
615        source_thread.join().unwrap();
616        target_thread.join().unwrap();
617        assert_eq!(response, "redirected");
618    }
619
620    #[forgetest]
621    async fn creates_correct_verify_request_body(prj: _) {
622        prj.add_source("Counter", "contract Counter {}");
623
624        let args = VerifyArgs::parse_from([
625            "foundry-cli",
626            "0xd8509bee9c9bf012282ad33aba0d87241baf5064",
627            "src/Counter.sol:Counter",
628            "--compiler-version",
629            SOLC_VERSION,
630            "--root",
631            &prj.root().to_string_lossy(),
632        ]);
633
634        let context = args.resolve_context().await.unwrap();
635        let provider = SourcifyVerificationProvider::default();
636        let request = provider.prepare_verify_request(&args, &context).await.unwrap();
637
638        assert_eq!(request.compiler_version, "0.8.38+commit.1835d247");
639        assert_eq!(request.contract_identifier, "src/Counter.sol:Counter");
640        assert!(request.creation_transaction_hash.is_none());
641
642        assert!(request.std_json_input.is_object());
643        let json_obj = request.std_json_input.as_object().unwrap();
644        assert!(json_obj.contains_key("sources"));
645        assert!(json_obj.contains_key("settings"));
646
647        let sources = json_obj.get("sources").unwrap().as_object().unwrap();
648        assert!(sources.contains_key("src/Counter.sol"));
649        let counter_source = sources.get("src/Counter.sol").unwrap().as_object().unwrap();
650        let content = counter_source.get("content").unwrap().as_str().unwrap();
651        assert!(content.contains("contract Counter {}"));
652    }
653}