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