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