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#[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>) -> 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 async fn prepare_verify_request(
306 &self,
307 args: &VerifyArgs,
308 context: &VerificationContext,
309 ) -> Result<SourcifyVerifyRequest> {
310 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}