1use alloy_dyn_abi::{DynSolValue, JsonAbiExt};
4use alloy_json_abi::Constructor;
5use alloy_primitives::{Address, Bytes, keccak256};
6use eyre::{Result, bail, eyre};
7use forge_verify::sourcify::SOURCIFY_URL;
8use foundry_compilers::{artifacts::CompilerOutput, solc::Solc};
9use foundry_config::{Chain, NamedChain};
10use futures::StreamExt;
11use semver::Version;
12use serde::Deserialize;
13use serde_json::{Map, Value, json};
14use std::{
15 collections::{HashMap, HashSet},
16 process::Stdio,
17 sync::Arc,
18 time::Duration,
19};
20use tokio::{
21 io::{AsyncRead, AsyncReadExt, AsyncWriteExt},
22 process::Command,
23};
24
25const MAX_INPUT_SIZE: usize = 10 * 1024 * 1024;
26const MAX_SOURCES: usize = 256;
27const MAX_FETCHES: usize = 64;
28const MAX_COMPILER_VERSIONS: usize = 8;
29const MAX_COMPILATIONS: usize = 32;
30const MAX_CANDIDATES: usize = 512;
31const MAX_CREATION_BYTECODE: usize = 64 * 1024 * 1024;
32const MAX_RETAINED_METADATA: usize = 8 * 1024 * 1024;
33const MAX_RETAINED_SOURCE_INPUT: usize = 64 * 1024 * 1024;
34const MAX_SOURCE_PATH: usize = 4096;
35const MAX_COMPILER_VERSION: usize = 256;
36const MAX_CACHED_ERROR_CHARS: usize = 512;
37const MAX_STDOUT: usize = 32 * 1024 * 1024;
38const MAX_STDERR: usize = 1024 * 1024;
39const COMPILE_TIMEOUT: Duration = Duration::from_secs(60);
40pub(super) const MAX_PROVENANCE_ADDRESSES: usize = 16;
41
42#[derive(Clone, Debug, PartialEq, Eq, Hash)]
43pub(super) enum SourceProvider {
44 Etherscan { endpoint: String },
45 Sourcify { endpoint: String },
46}
47
48impl SourceProvider {
49 const fn metadata_len(&self) -> usize {
50 match self {
51 Self::Etherscan { endpoint } | Self::Sourcify { endpoint } => endpoint.len(),
52 }
53 }
54}
55
56impl std::fmt::Display for SourceProvider {
57 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
58 match self {
59 Self::Etherscan { .. } => f.write_str("Etherscan"),
60 Self::Sourcify { .. } => f.write_str("Sourcify"),
61 }
62 }
63}
64
65#[derive(Clone, Debug)]
66pub(super) struct ExternalSource {
67 pub input: Arc<Value>,
68 pub version: Version,
69 pub provider: SourceProvider,
70}
71
72#[derive(Clone, Debug)]
73pub(super) struct Candidate {
74 pub input: Arc<Value>,
75 pub fingerprint: String,
76 pub version: Version,
77 pub fqn: String,
78 pub constructor: Option<Constructor>,
79 pub creation_bytecode: Bytes,
80}
81
82#[derive(Clone, Debug)]
83pub(super) struct ExternalMatch {
84 pub input: Arc<Value>,
85 pub version: Version,
86 pub fqn: String,
87 pub creation_bytecode: Bytes,
88 pub constructor_args: Bytes,
89}
90
91#[derive(Clone, Debug)]
92pub(super) enum MatchResult {
93 None,
94 Unique(ExternalMatch),
95 Ambiguous(Vec<ExternalMatch>),
96}
97
98#[derive(Clone, Debug, PartialEq, Eq, Hash)]
99struct FetchKey {
100 chain: u64,
101 provider: SourceProvider,
102 address: Address,
103}
104
105type CompiledCandidates = Result<(Arc<[Candidate]>, bool), String>;
106
107pub(super) struct ExternalResolver {
110 http: reqwest::Client,
111 fetch_cache: HashMap<FetchKey, Result<Option<ExternalSource>, String>>,
112 compile_cache: HashMap<(Version, String), CompiledCandidates>,
113 compiler_versions: HashSet<Version>,
114 compilations: usize,
115 retained_source_input: usize,
116 retained_candidates: usize,
117 retained_metadata: usize,
118 retained_creation_bytecode: usize,
119}
120
121impl ExternalResolver {
122 pub(super) fn new() -> Result<Self> {
123 let http = reqwest::Client::builder()
124 .timeout(Duration::from_secs(15))
125 .connect_timeout(Duration::from_secs(15))
126 .redirect(reqwest::redirect::Policy::none())
127 .build()?;
128 Ok(Self {
129 http,
130 fetch_cache: HashMap::new(),
131 compile_cache: HashMap::new(),
132 compiler_versions: HashSet::new(),
133 compilations: 0,
134 retained_source_input: 0,
135 retained_candidates: 0,
136 retained_metadata: 0,
137 retained_creation_bytecode: 0,
138 })
139 }
140
141 pub(super) async fn resolve_etherscan(
142 &mut self,
143 chain: Chain,
144 address: Address,
145 endpoint: Option<&str>,
146 api_key: Option<&str>,
147 ) -> Result<Option<ExternalSource>, String> {
148 let endpoint = endpoint.ok_or_else(|| "Etherscan is not configured".to_string())?;
149 let provider = SourceProvider::Etherscan { endpoint: endpoint.to_string() };
150 let key = FetchKey { chain: chain.id(), provider: provider.clone(), address };
151 if let Some(cached) = self.fetch_cache.get(&key) {
152 return cached.clone();
153 }
154 if self.fetch_cache.len() >= MAX_FETCHES {
155 return Err("external source fetch limit exceeded".to_string());
156 }
157 let result = async {
158 let address = address.to_string();
159 let response = self
160 .http
161 .get(endpoint)
162 .query(&[
163 ("module", "contract"),
164 ("action", "getsourcecode"),
165 ("address", address.as_str()),
166 ("apikey", api_key.unwrap_or_default()),
167 ])
168 .send()
169 .await
170 .map_err(|_| "Etherscan request failed".to_string())?;
171 if !response.status().is_success() {
172 return Err(format!("Etherscan returned HTTP {}", response.status()));
173 }
174 if response.content_length().is_some_and(|length| length > MAX_INPUT_SIZE as u64) {
175 return Err("Etherscan response exceeds 10 MiB".to_string());
176 }
177 let body = read_capped_body(response, MAX_INPUT_SIZE, "Etherscan").await?;
178 let (input, version) = parse_etherscan_response(&body)?;
179 Ok(Some(ExternalSource { input: Arc::new(input), version, provider }))
180 }
181 .await;
182 self.cache_source_result(key, result)
183 }
184
185 pub(super) async fn resolve_sourcify(
186 &mut self,
187 chain: Chain,
188 address: Address,
189 endpoint: Option<&str>,
190 ) -> Result<Option<ExternalSource>, String> {
191 let Some(endpoint) = endpoint else { return Ok(None) };
192 if endpoint == SOURCIFY_URL
195 && matches!(
196 chain.named(),
197 Some(NamedChain::Dev | NamedChain::AnvilHardhat | NamedChain::Cannon)
198 )
199 {
200 return Ok(None);
201 }
202 let provider = SourceProvider::Sourcify { endpoint: endpoint.to_string() };
203 let key = FetchKey { chain: chain.id(), provider: provider.clone(), address };
204 if let Some(cached) = self.fetch_cache.get(&key) {
205 return cached.clone();
206 }
207 if self.fetch_cache.len() >= MAX_FETCHES {
208 return Err("external source fetch limit exceeded".to_string());
209 }
210 let result = async {
211 let endpoint = endpoint.trim_end_matches('/');
212 let url = format!(
213 "{endpoint}/v2/contract/{}/{address}?fields=stdJsonInput,compilation.compilerVersion",
214 chain.id()
215 );
216 let response = self.http.get(url).send().await.map_err(|_| "Sourcify request failed".to_string())?;
217 if response.status() == reqwest::StatusCode::NOT_FOUND {
218 return Ok(None)
219 }
220 if !response.status().is_success() {
221 return Err(format!("Sourcify returned HTTP {}", response.status()))
222 }
223 if response.content_length().is_some_and(|length| length > MAX_INPUT_SIZE as u64) {
224 return Err("Sourcify response exceeds 10 MiB".to_string())
225 }
226 let body = read_capped_body(response, MAX_INPUT_SIZE, "Sourcify").await?;
227 let response: SourcifyResponse =
228 serde_json::from_slice(&body).map_err(|e| format!("invalid Sourcify response: {e}"))?;
229 validate_input(&response.std_json_input).map_err(|e| e.to_string())?;
230 let version = parse_compiler_version(&response.compilation.compiler_version)
231 .map_err(|e| e.to_string())?;
232 Ok(Some(ExternalSource {
233 input: Arc::new(response.std_json_input),
234 version,
235 provider,
236 }))
237 }
238 .await;
239 self.cache_source_result(key, result)
240 }
241
242 pub(super) async fn compile(
243 &mut self,
244 source: &ExternalSource,
245 ) -> Result<(Arc<[Candidate]>, bool), String> {
246 let fingerprint = fingerprint(&source.input).map_err(|e| e.to_string())?;
247 let key = (source.version.clone(), fingerprint);
248 if let Some(cached) = self.compile_cache.get(&key) {
249 return cached.clone();
250 }
251 if self.compilations >= MAX_COMPILATIONS {
252 return Err("external compilation limit exceeded".to_string());
253 }
254 let new_version = !self.compiler_versions.contains(&source.version);
255 if new_version && self.compiler_versions.len() >= MAX_COMPILER_VERSIONS {
256 return Err("external compiler version limit exceeded".to_string());
257 }
258 let version_len = source.version.to_string().len();
259 let cache_metadata = key
260 .1
261 .len()
262 .saturating_add(version_len)
263 .saturating_add(if new_version { version_len } else { 0 });
264 self.compilations += 1;
265 let result = match compile_source(source).await {
266 Ok((candidates, has_unresolved_links)) => self
267 .charge_candidates(candidates, cache_metadata)
268 .map(|candidates| (candidates, has_unresolved_links)),
269 Err(err) => Err(err),
270 };
271 let result = match result {
272 Ok(candidates) => Ok(candidates),
273 Err(err) => {
274 let err = bound_cached_error(err);
275 self.charge_metadata(cache_metadata.saturating_add(err.len()))?;
276 Err(err)
277 }
278 };
279 self.compiler_versions.insert(source.version.clone());
280 self.compile_cache.insert(key, result.clone());
281 result
282 }
283
284 fn charge_candidates(
285 &mut self,
286 candidates: Vec<Candidate>,
287 cache_metadata: usize,
288 ) -> Result<Arc<[Candidate]>, String> {
289 let bytes = candidates.iter().fold(0usize, |total, candidate| {
290 total.saturating_add(candidate.creation_bytecode.len())
291 });
292 let metadata = candidates.iter().try_fold(cache_metadata, |total, candidate| {
293 let constructor = candidate
294 .constructor
295 .as_ref()
296 .map(serde_json::to_vec)
297 .transpose()
298 .map_err(|_| "failed to measure candidate constructor".to_string())?
299 .map_or(0, |constructor| constructor.len());
300 Ok::<_, String>(
301 total
302 .saturating_add(candidate.fqn.len())
303 .saturating_add(candidate.fingerprint.len())
304 .saturating_add(candidate.version.to_string().len())
305 .saturating_add(constructor),
306 )
307 })?;
308 if self.retained_candidates.saturating_add(candidates.len()) > MAX_CANDIDATES {
309 return Err("external cumulative candidate limit exceeded".to_string());
310 }
311 if self.retained_metadata.saturating_add(metadata) > MAX_RETAINED_METADATA {
312 return Err("external cumulative metadata limit exceeded".to_string());
313 }
314 if self.retained_creation_bytecode.saturating_add(bytes) > MAX_CREATION_BYTECODE {
315 return Err("external cumulative creation bytecode limit exceeded".to_string());
316 }
317 self.retained_candidates += candidates.len();
318 self.retained_metadata += metadata;
319 self.retained_creation_bytecode += bytes;
320 Ok(Arc::from(candidates))
321 }
322
323 fn cache_source_result(
324 &mut self,
325 key: FetchKey,
326 result: Result<Option<ExternalSource>, String>,
327 ) -> Result<Option<ExternalSource>, String> {
328 let result = result.map_err(bound_cached_error);
329 let source_bytes = match &result {
330 Ok(Some(source)) => serde_json::to_vec(&source.input)
331 .map_err(|_| "failed to measure Standard JSON input".to_string())?
332 .len(),
333 Ok(None) | Err(_) => 0,
334 };
335 let retained = key.provider.metadata_len().saturating_add(match &result {
336 Ok(Some(source)) => {
337 source.provider.metadata_len().saturating_add(source.version.to_string().len())
338 }
339 Ok(None) => 0,
340 Err(err) => err.len(),
341 });
342 if self.retained_source_input.saturating_add(source_bytes) > MAX_RETAINED_SOURCE_INPUT {
343 return Err("external cumulative source input limit exceeded".to_string());
344 }
345 if self.retained_metadata.saturating_add(retained) > MAX_RETAINED_METADATA {
346 return Err("external cumulative metadata limit exceeded".to_string());
347 }
348 self.retained_source_input += source_bytes;
349 self.retained_metadata += retained;
350 self.fetch_cache.insert(key, result.clone());
351 result
352 }
353
354 fn charge_metadata(&mut self, bytes: usize) -> Result<(), String> {
355 if self.retained_metadata.saturating_add(bytes) > MAX_RETAINED_METADATA {
356 return Err("external cumulative metadata limit exceeded".to_string());
357 }
358 self.retained_metadata += bytes;
359 Ok(())
360 }
361}
362
363fn bound_cached_error(error: String) -> String {
364 let mut chars = error.chars();
365 let error = chars.by_ref().take(MAX_CACHED_ERROR_CHARS).collect::<String>();
366 if chars.next().is_some() { format!("{error}…") } else { error }
367}
368
369async fn read_capped_body(
370 response: reqwest::Response,
371 limit: usize,
372 provider: &str,
373) -> Result<Vec<u8>, String> {
374 let mut body = Vec::new();
375 let mut stream = response.bytes_stream();
376 while let Some(chunk) = stream.next().await {
377 let chunk = chunk.map_err(|_| format!("invalid {provider} response"))?;
378 if !append_capped(&mut body, &chunk, limit) {
379 return Err(format!("{provider} response exceeds 10 MiB"));
380 }
381 }
382 Ok(body)
383}
384
385fn append_capped(output: &mut Vec<u8>, chunk: &[u8], limit: usize) -> bool {
386 if output.len().saturating_add(chunk.len()) > limit {
387 return false;
388 }
389 output.extend_from_slice(chunk);
390 true
391}
392
393fn parse_etherscan_response(body: &[u8]) -> Result<(Value, Version), String> {
394 let response: Value =
395 serde_json::from_slice(body).map_err(|e| format!("invalid Etherscan response: {e}"))?;
396 if response.get("status").and_then(Value::as_str) != Some("1") {
397 let message = response.get("result").and_then(Value::as_str).unwrap_or("request failed");
398 return Err(format!("Etherscan request failed: {message}"));
399 }
400 let item = response
401 .get("result")
402 .and_then(Value::as_array)
403 .and_then(|items| items.first())
404 .and_then(Value::as_object)
405 .ok_or_else(|| "empty Etherscan response".to_string())?;
406 let compiler_version = item
407 .get("CompilerVersion")
408 .and_then(Value::as_str)
409 .ok_or_else(|| "Etherscan compiler version is missing".to_string())?;
410 let compiler_version =
411 parse_compiler_version(compiler_version).map_err(|err| err.to_string())?;
412 let source =
413 item.get("SourceCode").ok_or_else(|| "Etherscan source code is missing".to_string())?;
414 let input = match source {
415 Value::Object(_) => source.clone(),
416 Value::String(source) => {
417 let wrapped = source.starts_with("{{") && source.ends_with("}}");
418 let source = if wrapped { &source[1..source.len() - 1] } else { source };
419 serde_json::from_str(source)
420 .map_err(|_| "Etherscan did not return Standard JSON sources".to_string())?
421 }
422 _ => return Err("Etherscan did not return Standard JSON sources".to_string()),
423 };
424 validate_input(&input).map_err(|e| e.to_string())?;
425 Ok((input, compiler_version))
426}
427
428#[derive(Deserialize)]
429#[serde(rename_all = "camelCase")]
430struct SourcifyResponse {
431 std_json_input: Value,
432 compilation: SourcifyCompilation,
433}
434
435#[derive(Deserialize)]
436#[serde(rename_all = "camelCase")]
437struct SourcifyCompilation {
438 compiler_version: String,
439}
440
441fn parse_compiler_version(version: &str) -> Result<Version> {
442 if version.len() > MAX_COMPILER_VERSION {
443 bail!("compiler version exceeds {MAX_COMPILER_VERSION} bytes");
444 }
445 Version::parse(version.trim_start_matches('v')).map_err(Into::into)
446}
447
448fn validate_input(input: &Value) -> Result<()> {
449 if serde_json::to_vec(input)?.len() > MAX_INPUT_SIZE {
450 bail!("Standard JSON input exceeds 10 MiB");
451 }
452 let object = input.as_object().ok_or_else(|| eyre!("Standard JSON input is not an object"))?;
453 if object.get("language").and_then(Value::as_str) != Some("Solidity") {
454 bail!("source language is not Solidity");
455 }
456 let sources = object
457 .get("sources")
458 .and_then(Value::as_object)
459 .ok_or_else(|| eyre!("Standard JSON sources are missing"))?;
460 if sources.is_empty() || sources.len() > MAX_SOURCES {
461 bail!("Standard JSON must contain 1 to 256 sources");
462 }
463 for (path, source) in sources {
464 validate_identifier(path, "source path")?;
465 let source = source.as_object().ok_or_else(|| eyre!("invalid source entry"))?;
466 if source.get("content").and_then(Value::as_str).is_none_or(str::is_empty) {
467 bail!("every source must contain nonempty literal content");
468 }
469 }
470 if !object.get("settings").is_some_and(Value::is_object) {
471 bail!("Standard JSON settings must be an object");
472 }
473 Ok(())
474}
475
476fn validate_identifier(value: &str, label: &str) -> Result<()> {
477 if value.is_empty() || value.len() > MAX_SOURCE_PATH {
478 bail!("{label} must contain 1 to 4096 bytes");
479 }
480 if !value.is_ascii() || value.bytes().any(|byte| byte.is_ascii_control()) {
481 bail!("{label} must contain printable ASCII characters");
482 }
483 Ok(())
484}
485
486fn compilation_input(input: &Value) -> Result<Value> {
487 validate_input(input)?;
488 let mut input = input.clone();
489 input["settings"]["outputSelection"] = json!({
490 "*": { "*": ["abi", "evm.bytecode.object", "evm.bytecode.linkReferences"] }
491 });
492 Ok(input)
493}
494
495fn canonicalize(value: &Value) -> Value {
496 match value {
497 Value::Object(object) => {
498 let mut keys = object.keys().collect::<Vec<_>>();
499 keys.sort_unstable();
500 Value::Object(
501 keys.into_iter()
502 .map(|key| (key.clone(), canonicalize(&object[key])))
503 .collect::<Map<_, _>>(),
504 )
505 }
506 Value::Array(values) => Value::Array(values.iter().map(canonicalize).collect()),
507 value => value.clone(),
508 }
509}
510
511fn fingerprint(input: &Value) -> Result<String> {
512 validate_input(input)?;
513 Ok(keccak256(serde_json::to_vec(&canonicalize(input))?).to_string())
514}
515
516fn compiler_matches(requested: &Version, actual: &Version) -> bool {
517 requested.major == actual.major
518 && requested.minor == actual.minor
519 && requested.patch == actual.patch
520 && requested.pre == actual.pre
521 && requested.build.as_str().split('.').take(2).eq(actual.build.as_str().split('.').take(2))
522}
523
524fn compiler_identity(version: &Version) -> String {
525 let build = version.build.as_str().split('.').take(2).collect::<Vec<_>>().join(".");
526 format!("{}.{}.{}-{}+{build}", version.major, version.minor, version.patch, version.pre)
527}
528
529async fn compile_source(source: &ExternalSource) -> Result<(Vec<Candidate>, bool), String> {
530 let input = compilation_input(&source.input).map_err(|e| e.to_string())?;
531 let svm_version =
532 Version::new(source.version.major, source.version.minor, source.version.patch);
533 let solc = match Solc::find_svm_installed_version(&svm_version).map_err(|e| e.to_string())? {
534 Some(solc) => solc,
535 None => tokio::time::timeout(Duration::from_secs(60), Solc::install(&svm_version))
536 .await
537 .map_err(|_| "solc installation timed out".to_string())?
538 .map_err(|e| e.to_string())?,
539 };
540 let mut version_command = Command::new(&solc.solc);
541 version_command.arg("--version");
542 let version_output =
543 run_bounded_command(version_command, None, 64 * 1024, 64 * 1024, Duration::from_secs(10))
544 .await?;
545 let actual = parse_solc_version_output(&version_output.0)?;
546 if !compiler_matches(&source.version, &actual) {
547 return Err(format!("installed solc {actual} does not match requested {}", source.version));
548 }
549 let workdir =
550 tempfile::tempdir().map_err(|_| "failed to create compiler sandbox".to_string())?;
551 let raw_input =
552 serde_json::to_vec(&input).map_err(|_| "failed to encode compiler input".to_string())?;
553 let mut command = Command::new(&solc.solc);
554 command
555 .arg("--standard-json")
556 .arg("--base-path")
557 .arg(workdir.path())
558 .current_dir(workdir.path())
559 .stdin(Stdio::piped())
560 .stdout(Stdio::piped())
561 .stderr(Stdio::piped())
562 .kill_on_drop(true);
563 if source.version >= Version::new(0, 8, 22) {
566 command.arg("--no-import-callback");
567 }
568 let (stdout, stderr, status) =
569 run_bounded_command(command, Some(raw_input), MAX_STDOUT, MAX_STDERR, COMPILE_TIMEOUT)
570 .await?;
571 if !status.success() {
572 return Err(format!("solc compilation failed: {}", sanitize_remote(&stderr)));
573 }
574 let output: CompilerOutput =
575 serde_json::from_slice(&stdout).map_err(|_| "solc returned invalid JSON".to_string())?;
576 if output.has_error() {
577 return Err("solc compilation failed".to_string());
578 }
579 candidates_from_output(source, output)
580}
581
582fn candidates_from_output(
583 source: &ExternalSource,
584 output: CompilerOutput,
585) -> Result<(Vec<Candidate>, bool), String> {
586 let fingerprint = fingerprint(&source.input).map_err(|e| e.to_string())?;
587 let mut candidates = Vec::new();
588 let mut creation_bytecode_bytes = 0usize;
589 let mut has_unresolved_links = false;
590 for (path, contracts) in output.contracts {
591 for (name, contract) in contracts {
592 let Some(abi) = contract.abi else { continue };
593 let Some(bytecode) = contract.evm.and_then(|evm| evm.bytecode) else { continue };
594 if !bytecode.link_references.is_empty() || bytecode.object.is_unlinked() {
595 has_unresolved_links = true;
596 continue;
597 }
598 let Some(bytes) = bytecode.object.into_bytes() else { continue };
599 if bytes.is_empty() {
600 continue;
601 }
602 validate_identifier(&path.to_string_lossy(), "compiler source path")
603 .map_err(|e| e.to_string())?;
604 validate_identifier(&name, "contract name").map_err(|e| e.to_string())?;
605 if candidates.len() >= MAX_CANDIDATES {
606 return Err("external candidate limit exceeded".to_string());
607 }
608 if creation_bytecode_bytes.saturating_add(bytes.len()) > MAX_CREATION_BYTECODE {
609 return Err("external creation bytecode limit exceeded".to_string());
610 }
611 creation_bytecode_bytes += bytes.len();
612 candidates.push(Candidate {
613 input: source.input.clone(),
614 fingerprint: fingerprint.clone(),
615 version: source.version.clone(),
616 fqn: format!("{}:{name}", path.display()),
617 constructor: abi.constructor().cloned(),
618 creation_bytecode: bytes,
619 });
620 }
621 }
622 Ok((candidates, has_unresolved_links))
623}
624
625fn parse_solc_version_output(output: &[u8]) -> Result<Version, String> {
626 let output =
627 std::str::from_utf8(output).map_err(|_| "invalid solc version output".to_string())?;
628 output
629 .lines()
630 .find_map(|line| line.trim().strip_prefix("Version: "))
631 .ok_or_else(|| "solc version output is missing Version line".to_string())
632 .and_then(|version| {
635 Version::parse(&version.replace(".g++", ".gcc"))
636 .map_err(|_| "invalid solc version".to_string())
637 })
638}
639
640async fn run_bounded_command(
641 mut command: Command,
642 input: Option<Vec<u8>>,
643 stdout_limit: usize,
644 stderr_limit: usize,
645 timeout: Duration,
646) -> Result<(Vec<u8>, Vec<u8>, std::process::ExitStatus), String> {
647 let deadline = tokio::time::Instant::now() + timeout;
648 let cleanup_window = std::cmp::min(timeout / 10, Duration::from_secs(1));
651 let execution_deadline = deadline - cleanup_window;
652 command
653 .stdin(if input.is_some() { Stdio::piped() } else { Stdio::null() })
654 .stdout(Stdio::piped())
655 .stderr(Stdio::piped())
656 .kill_on_drop(true);
657 let mut child = command.spawn().map_err(|_| "failed to start subprocess".to_string())?;
658 let stdout =
659 child.stdout.take().ok_or_else(|| "failed to open subprocess stdout".to_string())?;
660 let stderr =
661 child.stderr.take().ok_or_else(|| "failed to open subprocess stderr".to_string())?;
662 let mut input_task = input.map(|input| {
663 let mut stdin = child.stdin.take().expect("piped stdin");
664 tokio::spawn(async move {
665 stdin.write_all(&input).await?;
666 stdin.shutdown().await
667 })
668 });
669 let mut output_task = tokio::spawn(async move {
670 tokio::try_join!(read_capped(stdout, stdout_limit), read_capped(stderr, stderr_limit))
671 });
672 let mut input_joined = false;
673 let mut output_joined = false;
674 let lifecycle = async {
675 let mut completed_output = None;
676 let status = tokio::select! {
677 result = child.wait() => {
678 result.map_err(|_| "failed to wait for subprocess".to_string())?
679 }
680 output = &mut output_task => {
681 output_joined = true;
682 match output {
683 Ok(Ok(output)) => {
684 completed_output = Some(output);
685 child.wait().await.map_err(|_| "failed to wait for subprocess".to_string())?
686 }
687 Ok(Err(err)) => return Err(err),
688 Err(_) => return Err("subprocess output task failed".to_string()),
689 }
690 }
691 };
692 if let Some(task) = input_task.as_mut() {
693 let input = task.await;
694 input_joined = true;
695 input
696 .map_err(|_| "subprocess input task failed".to_string())?
697 .map_err(|_| "failed to write subprocess input".to_string())?;
698 }
699 let (stdout, stderr) = match completed_output {
700 Some(output) => output,
701 None => {
702 let output = (&mut output_task).await;
703 output_joined = true;
704 output.map_err(|_| "subprocess output task failed".to_string())??
705 }
706 };
707 Ok((stdout, stderr, status))
708 };
709 let result = match tokio::time::timeout_at(execution_deadline, lifecycle).await {
710 Ok(Ok(output)) => Ok(output),
711 Ok(Err(err)) => Err(err),
712 Err(_) => Err("subprocess timed out".to_string()),
713 };
714 if result.is_ok() {
715 return result;
716 }
717
718 if let Some(task) = &input_task {
719 task.abort();
720 }
721 output_task.abort();
722 let _ = child.start_kill();
723 let cleanup = async {
724 if !input_joined && let Some(task) = input_task {
725 let _ = task.await;
726 }
727 if !output_joined {
728 let _ = output_task.await;
729 }
730 let _ = child.wait().await;
731 };
732 let _ = tokio::time::timeout_at(deadline, cleanup).await;
735 result
736}
737
738async fn read_capped(mut reader: impl AsyncRead + Unpin, limit: usize) -> Result<Vec<u8>, String> {
739 let mut output = Vec::new();
740 let mut chunk = [0; 8192];
741 loop {
742 let read =
743 reader.read(&mut chunk).await.map_err(|_| "failed to read solc output".to_string())?;
744 if read == 0 {
745 return Ok(output);
746 }
747 if output.len().saturating_add(read) > limit {
748 return Err("solc output limit exceeded".to_string());
749 }
750 output.extend_from_slice(&chunk[..read]);
751 }
752}
753
754fn sanitize_remote(message: &[u8]) -> String {
755 let message = String::from_utf8_lossy(message);
756 let clean = message
757 .chars()
758 .map(|ch| if ch.is_control() { ' ' } else { ch })
759 .take(160)
760 .collect::<String>();
761 if clean.is_empty() { "no diagnostic".to_string() } else { clean }
762}
763
764fn is_canonical_value(value: &DynSolValue) -> bool {
765 match value {
766 DynSolValue::Uint(value, bits) => value.bit_len() <= *bits,
767 DynSolValue::Int(value, bits) => {
768 if *bits == 0 || *bits > 256 || bits % 8 != 0 {
769 return false;
770 }
771 let bytes = value.to_be_bytes::<32>();
772 let value_start = 32 - bits / 8;
773 let extension = if bytes[value_start] & 0x80 == 0 { 0 } else { 0xff };
774 bytes[..value_start].iter().all(|byte| *byte == extension)
775 }
776 DynSolValue::FixedBytes(value, size) => {
777 *size <= value.len() && value[*size..].iter().all(|byte| *byte == 0)
778 }
779 DynSolValue::Array(values)
780 | DynSolValue::FixedArray(values)
781 | DynSolValue::Tuple(values) => values.iter().all(is_canonical_value),
782 DynSolValue::CustomStruct { tuple, .. } => tuple.iter().all(is_canonical_value),
783 DynSolValue::Bool(_)
784 | DynSolValue::Address(_)
785 | DynSolValue::Function(_)
786 | DynSolValue::Bytes(_)
787 | DynSolValue::String(_) => true,
788 }
789}
790
791pub(super) fn match_candidates<'a>(
792 observed: &[u8],
793 candidates: impl IntoIterator<Item = &'a Candidate>,
794) -> MatchResult {
795 let mut matches = Vec::new();
796 for candidate in candidates {
797 let bytecode = candidate.creation_bytecode.as_ref();
798 if bytecode.is_empty() {
799 continue;
800 }
801 let Some(suffix) = observed.strip_prefix(bytecode) else { continue };
802 let valid = match &candidate.constructor {
803 None => true,
804 Some(constructor) if constructor.inputs.is_empty() => true,
805 Some(constructor) => constructor
806 .abi_decode_input(suffix)
807 .ok()
808 .filter(|values| values.iter().all(is_canonical_value))
809 .and_then(|values| constructor.abi_encode_input(&values).ok())
810 .is_some_and(|encoded| suffix.starts_with(&encoded)),
811 };
812 if valid
813 && !matches.iter().any(|item: &ExternalMatch| {
814 item.fqn == candidate.fqn
815 && compiler_identity(&item.version) == compiler_identity(&candidate.version)
816 && item.creation_bytecode == candidate.creation_bytecode
817 && item.constructor_args.as_ref() == suffix
818 })
819 {
820 matches.push(ExternalMatch {
821 input: candidate.input.clone(),
822 version: candidate.version.clone(),
823 fqn: candidate.fqn.clone(),
824 creation_bytecode: candidate.creation_bytecode.clone(),
825 constructor_args: Bytes::copy_from_slice(suffix),
826 });
827 }
828 }
829 match matches.len() {
830 0 => MatchResult::None,
831 1 => MatchResult::Unique(matches.pop().unwrap()),
832 _ => MatchResult::Ambiguous(matches),
833 }
834}
835
836#[cfg(test)]
837mod tests {
838 use super::*;
839 use alloy_json_abi::JsonAbi;
840 use tokio::net::TcpListener;
841
842 fn input() -> Value {
843 json!({
844 "language": "Solidity",
845 "sources": { "A.sol": { "content": "contract A {}", "custom": 1 } },
846 "settings": { "optimizer": { "enabled": true }, "outputSelection": {"old": []} },
847 "unknown": { "preserved": true }
848 })
849 }
850
851 fn candidate(fqn: &str, abi: JsonAbi) -> Candidate {
852 Candidate {
853 input: Arc::new(input()),
854 fingerprint: "fingerprint".into(),
855 version: Version::parse("0.8.30+commit.73712a01").unwrap(),
856 fqn: fqn.into(),
857 constructor: abi.constructor().cloned(),
858 creation_bytecode: Bytes::from_static(&[0x60, 0x00]),
859 }
860 }
861
862 #[test]
863 fn validation_preserves_unknown_fields_and_only_changes_output_selection() {
864 let original = input();
865 let compiled = compilation_input(&original).unwrap();
866 assert_eq!(compiled["unknown"], original["unknown"]);
867 assert_eq!(compiled["sources"], original["sources"]);
868 assert_eq!(compiled["settings"]["optimizer"], original["settings"]["optimizer"]);
869 assert_ne!(
870 compiled["settings"]["outputSelection"],
871 original["settings"]["outputSelection"]
872 );
873 assert_eq!(original["settings"]["outputSelection"], json!({"old": []}));
874
875 let mut url_only = input();
876 url_only["sources"]["A.sol"] = json!({"urls": ["ipfs://source"]});
877 assert!(validate_input(&url_only).is_err());
878 }
879
880 #[test]
881 fn source_paths_are_bounded_printable_ascii() {
882 assert!(validate_identifier("src/A.sol", "path").is_ok());
883 assert!(validate_identifier("bad\npath.sol", "path").is_err());
884 assert!(validate_identifier("café.sol", "path").is_err());
885 assert!(validate_identifier(&"x".repeat(MAX_SOURCE_PATH + 1), "path").is_err());
886 }
887
888 #[test]
889 fn cumulative_source_budget_charges_success_once() {
890 let mut resolver = ExternalResolver::new().unwrap();
891 let source = ExternalSource {
892 input: Arc::new(input()),
893 version: Version::new(0, 8, 30),
894 provider: SourceProvider::Sourcify { endpoint: SOURCIFY_URL.into() },
895 };
896 let key = FetchKey { chain: 1, provider: source.provider.clone(), address: Address::ZERO };
897 let charged = resolver.cache_source_result(key.clone(), Ok(Some(source))).unwrap();
898 assert!(charged.is_some());
899 let charged_bytes = resolver.retained_source_input;
900 assert!(resolver.fetch_cache.get(&key).unwrap().is_ok());
902 assert_eq!(resolver.retained_source_input, charged_bytes);
903
904 resolver.retained_source_input = MAX_RETAINED_SOURCE_INPUT;
905 let rejected_key = FetchKey { address: Address::with_last_byte(1), ..key };
906 assert!(
907 resolver
908 .cache_source_result(rejected_key.clone(), Ok(charged))
909 .unwrap_err()
910 .contains("cumulative")
911 );
912 assert!(!resolver.fetch_cache.contains_key(&rejected_key));
913 }
914
915 #[test]
916 fn cumulative_candidate_metadata_budget_is_enforced() {
917 let mut resolver = ExternalResolver::new().unwrap();
918 let retained_candidate = candidate("A.sol:A", JsonAbi::default());
919 let expected = retained_candidate.fqn.len()
920 + retained_candidate.fingerprint.len()
921 + retained_candidate.version.to_string().len();
922 resolver.charge_candidates(vec![retained_candidate], 0).unwrap();
923 assert_eq!(resolver.retained_metadata, expected);
924
925 let mut resolver = ExternalResolver::new().unwrap();
926 resolver.retained_metadata = MAX_RETAINED_METADATA;
927 let error = resolver
928 .charge_candidates(vec![candidate("A.sol:A", JsonAbi::default())], 0)
929 .unwrap_err();
930 assert!(error.contains("metadata"));
931 assert_eq!(resolver.retained_metadata, MAX_RETAINED_METADATA);
932 assert_eq!(resolver.retained_candidates, 0);
933 assert_eq!(resolver.retained_creation_bytecode, 0);
934
935 let mut resolver = ExternalResolver::new().unwrap();
936 resolver.retained_creation_bytecode = MAX_CREATION_BYTECODE;
937 let error = resolver
938 .charge_candidates(vec![candidate("A.sol:A", JsonAbi::default())], 0)
939 .unwrap_err();
940 assert!(error.contains("bytecode"));
941 assert_eq!(resolver.retained_metadata, 0);
942 assert_eq!(resolver.retained_candidates, 0);
943 assert_eq!(resolver.retained_creation_bytecode, MAX_CREATION_BYTECODE);
944 }
945
946 #[test]
947 fn candidate_constructor_metadata_is_charged() {
948 let abi: JsonAbi = serde_json::from_value(json!([{
949 "type": "constructor", "inputs": [{"name":"n", "type":"uint256"}]
950 }]))
951 .unwrap();
952 let candidate = candidate("A.sol:A", abi);
953 let constructor_bytes =
954 serde_json::to_vec(candidate.constructor.as_ref().unwrap()).unwrap();
955 let mut resolver = ExternalResolver::new().unwrap();
956 resolver.charge_candidates(vec![candidate], 0).unwrap();
957 assert!(resolver.retained_metadata >= constructor_bytes.len());
958 }
959
960 #[tokio::test]
961 async fn sourcify_source_discovery_uses_selected_endpoint_on_dev_chain() {
962 let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
963 let endpoint = format!("http://{}/private", listener.local_addr().unwrap());
964 let response = json!({
965 "stdJsonInput": input(),
966 "compilation": { "compilerVersion": "0.8.30+commit.73712a01" }
967 })
968 .to_string();
969 let server = tokio::spawn(async move {
970 let (mut socket, _) = listener.accept().await.unwrap();
971 let mut request = [0; 4096];
972 let bytes_read = socket.read(&mut request).await.unwrap();
973 let request = std::str::from_utf8(&request[..bytes_read]).unwrap();
974 assert!(request.starts_with("GET /private/v2/contract/31337/"), "{request}");
975 socket
976 .write_all(
977 format!(
978 "HTTP/1.1 200 OK\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{response}",
979 response.len()
980 )
981 .as_bytes(),
982 )
983 .await
984 .unwrap();
985 });
986
987 let source = ExternalResolver::new()
988 .unwrap()
989 .resolve_sourcify(Chain::from(31337), Address::ZERO, Some(&endpoint))
990 .await
991 .unwrap()
992 .unwrap();
993 server.await.unwrap();
994 assert_eq!(source.provider, SourceProvider::Sourcify { endpoint: endpoint.clone() });
995 assert_ne!(source.provider, SourceProvider::Sourcify { endpoint: SOURCIFY_URL.into() });
996 }
997
998 #[tokio::test]
999 async fn sourcify_cache_identity_includes_selected_endpoint() {
1000 let mut resolver = ExternalResolver::new().unwrap();
1001 for _ in 0..2 {
1002 let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
1003 let endpoint = format!("http://{}/private", listener.local_addr().unwrap());
1004 let server = tokio::spawn(async move {
1005 let (mut socket, _) = listener.accept().await.unwrap();
1006 let mut request = [0; 4096];
1007 assert!(socket.read(&mut request).await.unwrap() > 0);
1008 socket
1009 .write_all(
1010 b"HTTP/1.1 404 Not Found\r\nContent-Length: 0\r\nConnection: close\r\n\r\n",
1011 )
1012 .await
1013 .unwrap();
1014 });
1015 assert!(
1016 resolver
1017 .resolve_sourcify(Chain::from(31337), Address::ZERO, Some(&endpoint))
1018 .await
1019 .unwrap()
1020 .is_none()
1021 );
1022 server.await.unwrap();
1023 }
1024 assert_eq!(resolver.fetch_cache.len(), 2);
1025 }
1026
1027 #[test]
1028 fn remote_versions_and_cached_errors_are_bounded_and_accounted() {
1029 let long_version = format!("0.8.30+{}", "a".repeat(MAX_COMPILER_VERSION));
1030 assert!(Version::parse(&long_version).is_ok());
1031 assert!(parse_compiler_version(&long_version).is_err());
1032
1033 let mut resolver = ExternalResolver::new().unwrap();
1034 let endpoint = "https://explorer.invalid/api";
1035 let key = FetchKey {
1036 chain: 1,
1037 provider: SourceProvider::Etherscan { endpoint: endpoint.into() },
1038 address: Address::ZERO,
1039 };
1040 let error = resolver
1041 .cache_source_result(key, Err("x".repeat(MAX_CACHED_ERROR_CHARS * 2)))
1042 .unwrap_err();
1043 assert_eq!(error.chars().count(), MAX_CACHED_ERROR_CHARS + 1);
1044 assert_eq!(resolver.retained_metadata, endpoint.len() + error.len());
1045
1046 let mut resolver = ExternalResolver::new().unwrap();
1047 resolver.retained_metadata = MAX_RETAINED_METADATA;
1048 let key = FetchKey {
1049 chain: 1,
1050 provider: SourceProvider::Sourcify { endpoint: SOURCIFY_URL.into() },
1051 address: Address::ZERO,
1052 };
1053 let source = ExternalSource {
1054 input: Arc::new(input()),
1055 version: Version::new(0, 8, 30),
1056 provider: key.provider.clone(),
1057 };
1058 assert!(resolver.cache_source_result(key, Ok(Some(source))).is_err());
1059 assert_eq!(resolver.retained_source_input, 0);
1060 assert_eq!(resolver.retained_metadata, MAX_RETAINED_METADATA);
1061 }
1062
1063 #[tokio::test]
1064 async fn etherscan_source_discovery_does_not_follow_redirects() {
1065 let target = TcpListener::bind("127.0.0.1:0").await.unwrap();
1066 let source = TcpListener::bind("127.0.0.1:0").await.unwrap();
1067 let endpoint = format!("http://{}", source.local_addr().unwrap());
1068 let location = format!("http://{}", target.local_addr().unwrap());
1069 let server = tokio::spawn(async move {
1070 let (mut socket, _) = source.accept().await.unwrap();
1071 let mut request = [0; 4096];
1072 let bytes_read = socket.read(&mut request).await.unwrap();
1073 assert!(bytes_read > 0);
1074 socket
1075 .write_all(
1076 format!(
1077 "HTTP/1.1 307 Temporary Redirect\r\nLocation: {location}\r\nContent-Length: 0\r\nConnection: close\r\n\r\n"
1078 )
1079 .as_bytes(),
1080 )
1081 .await
1082 .unwrap();
1083 });
1084
1085 let error = ExternalResolver::new()
1086 .unwrap()
1087 .resolve_etherscan(Chain::mainnet(), Address::ZERO, Some(&endpoint), None)
1088 .await
1089 .unwrap_err();
1090 server.await.unwrap();
1091 assert!(error.contains("HTTP 307"), "unexpected discovery error: {error}");
1092 assert!(
1093 tokio::time::timeout(Duration::from_millis(50), target.accept()).await.is_err(),
1094 "external source discovery followed the redirect"
1095 );
1096 }
1097
1098 #[test]
1099 fn etherscan_parser_preserves_raw_standard_json() {
1100 let expected = json!({
1101 "language": "Solidity",
1102 "sources": { "A.sol": {
1103 "content": "contract A {}",
1104 "urls": ["dweb:/ipfs/source"],
1105 "keccak256": "0x1234"
1106 } },
1107 "settings": {},
1108 "unknown": { "preserved": true }
1109 });
1110 for source in [expected.clone(), Value::String(format!("{{{expected}}}"))] {
1111 let response = json!({
1112 "status": "1",
1113 "message": "OK",
1114 "result": [{ "SourceCode": source, "CompilerVersion": "v0.8.30+commit.73712a01" }]
1115 });
1116 let (parsed, version) =
1117 parse_etherscan_response(&serde_json::to_vec(&response).unwrap()).unwrap();
1118 assert_eq!(parsed, expected);
1119 assert_eq!(version, Version::parse("0.8.30+commit.73712a01").unwrap());
1120 }
1121
1122 let flat = json!({
1123 "status": "1", "result": [{
1124 "SourceCode": "contract A {}", "CompilerVersion": "v0.8.30+commit.73712a01"
1125 }]
1126 });
1127 assert!(parse_etherscan_response(&serde_json::to_vec(&flat).unwrap()).is_err());
1128 }
1129
1130 #[test]
1131 fn fingerprint_ignores_object_key_order() {
1132 let first = input();
1133 let second: Value = serde_json::from_str(
1134 r#"{"unknown":{"preserved":true},"settings":{"outputSelection":{"old":[]},"optimizer":{"enabled":true}},"sources":{"A.sol":{"custom":1,"content":"contract A {}"}},"language":"Solidity"}"#,
1135 )
1136 .unwrap();
1137 assert_eq!(fingerprint(&first).unwrap(), fingerprint(&second).unwrap());
1138
1139 let source_a = ExternalSource {
1140 input: Arc::new(first),
1141 version: Version::new(1, 2, 3),
1142 provider: SourceProvider::Sourcify { endpoint: SOURCIFY_URL.into() },
1143 };
1144 let source_b = ExternalSource {
1145 provider: SourceProvider::Etherscan { endpoint: "other".into() },
1146 ..source_a.clone()
1147 };
1148 assert_eq!(fingerprint(&source_a.input).unwrap(), fingerprint(&source_b.input).unwrap());
1149 }
1150
1151 #[test]
1152 fn compiler_version_requires_commit_but_allows_platform_suffix() {
1153 let requested = Version::parse("0.8.30+commit.73712a01").unwrap();
1154 assert!(compiler_matches(
1155 &requested,
1156 &Version::parse("0.8.30+commit.73712a01.Linux.gcc").unwrap()
1157 ));
1158 assert!(!compiler_matches(&requested, &Version::parse("0.8.30+commit.deadbeef").unwrap()));
1159 assert!(!compiler_matches(&Version::new(0, 8, 30), &requested));
1160 }
1161
1162 #[test]
1163 fn parses_real_solc_version_output() {
1164 let output = b"solc, the solidity compiler commandline interface\nVersion: 0.8.30+commit.73712a01.Darwin.appleclang\n";
1165 assert_eq!(
1166 parse_solc_version_output(output).unwrap(),
1167 Version::parse("0.8.30+commit.73712a01.Darwin.appleclang").unwrap()
1168 );
1169 let linux = b"Version: 0.8.30+commit.73712a01.Linux.g++\n";
1170 assert_eq!(
1171 parse_solc_version_output(linux).unwrap(),
1172 Version::parse("0.8.30+commit.73712a01.Linux.gcc").unwrap()
1173 );
1174 assert!(parse_solc_version_output(b"Version: forged").is_err());
1175 }
1176
1177 #[cfg(unix)]
1178 #[tokio::test]
1179 async fn bounded_process_times_out_and_caps_output() {
1180 let mut sleep = Command::new("sh");
1181 sleep.args(["-c", "sleep 2"]);
1182 let started = tokio::time::Instant::now();
1183 let error =
1184 run_bounded_command(sleep, None, 16, 16, Duration::from_millis(20)).await.unwrap_err();
1185 assert!(error.contains("timed out"));
1186 assert!(started.elapsed() < Duration::from_millis(500));
1187
1188 let mut closed_output = Command::new("sh");
1191 closed_output.args(["-c", "exec 1>&- 2>&-; sleep 2"]);
1192 assert!(
1193 run_bounded_command(closed_output, None, 16, 16, Duration::from_millis(20))
1194 .await
1195 .unwrap_err()
1196 .contains("timed out")
1197 );
1198
1199 let mut output = Command::new("sh");
1200 output.args(["-c", "printf 12345"]);
1201 assert!(
1202 run_bounded_command(output, None, 4, 16, Duration::from_secs(1))
1203 .await
1204 .unwrap_err()
1205 .contains("output limit")
1206 );
1207
1208 let mut inherited_output = Command::new("sh");
1211 inherited_output.args(["-c", "sleep 2 &"]);
1212 assert!(
1213 run_bounded_command(inherited_output, None, 16, 16, Duration::from_millis(20))
1214 .await
1215 .unwrap_err()
1216 .contains("timed out")
1217 );
1218
1219 let mut inherited_input = Command::new("sh");
1222 inherited_input.args(["-c", "exec 3<&0; sleep 2 >/dev/null 2>&1 &"]);
1223 assert!(
1224 run_bounded_command(
1225 inherited_input,
1226 Some(vec![0; 1024 * 1024]),
1227 16,
1228 16,
1229 Duration::from_millis(20)
1230 )
1231 .await
1232 .unwrap_err()
1233 .contains("timed out")
1234 );
1235 }
1236
1237 #[test]
1238 fn static_constructor_matching_accepts_trailing_data_and_preserves_full_suffix() {
1239 let constructor: JsonAbi = serde_json::from_value(json!([{
1240 "type": "constructor", "inputs": [{"name":"n", "type":"uint256"}]
1241 }]))
1242 .unwrap();
1243 let candidate = candidate("A.sol:A", constructor);
1244 let mut observed = vec![0x60, 0x00];
1245 observed.extend([0; 31]);
1246 observed.push(7);
1247 observed.extend([0xaa, 0xbb]);
1248 let MatchResult::Unique(found) =
1249 match_candidates(&observed, std::slice::from_ref(&candidate))
1250 else {
1251 panic!("expected match")
1252 };
1253 assert_eq!(found.constructor_args.as_ref(), &observed[2..]);
1254 assert!(matches!(match_candidates(&[0x60, 0x00, 7], &[candidate]), MatchResult::None));
1255 }
1256
1257 #[test]
1258 fn dynamic_constructor_matching_requires_a_canonical_prefix() {
1259 let constructor: JsonAbi = serde_json::from_value(json!([{
1260 "type": "constructor",
1261 "inputs": [{"name":"n", "type":"uint256"}, {"name":"data", "type":"bytes"}]
1262 }]))
1263 .unwrap();
1264 let candidate = candidate("A.sol:A", constructor);
1265 let mut args = vec![0; 128];
1266 args[31] = 7;
1267 args[63] = 64;
1268 args[95] = 2;
1269 args[96..98].copy_from_slice(&[0xaa, 0xbb]);
1270 let mut observed = candidate.creation_bytecode.to_vec();
1271 observed.extend_from_slice(&args);
1272 observed.extend([0xcc, 0xdd, 0xee]);
1273 let MatchResult::Unique(matched) =
1274 match_candidates(&observed, std::slice::from_ref(&candidate))
1275 else {
1276 panic!("expected match")
1277 };
1278 assert_eq!(matched.constructor_args.as_ref(), &observed[2..]);
1279
1280 args[127] = 1;
1281 let mut noncanonical = candidate.creation_bytecode.to_vec();
1282 noncanonical.extend(args);
1283 assert!(matches!(match_candidates(&noncanonical, &[candidate]), MatchResult::None));
1284 }
1285
1286 #[test]
1287 fn narrow_constructor_values_require_canonical_words() {
1288 let constructor: JsonAbi = serde_json::from_value(json!([{
1289 "type": "constructor", "inputs": [{"name":"n", "type":"uint8"}]
1290 }]))
1291 .unwrap();
1292 let candidate = candidate("A.sol:A", constructor);
1293 let mut observed = candidate.creation_bytecode.to_vec();
1294 observed.extend([0; 31]);
1295 observed.push(7);
1296 assert!(matches!(
1297 match_candidates(&observed, std::slice::from_ref(&candidate)),
1298 MatchResult::Unique(_)
1299 ));
1300
1301 observed[2] = 1;
1302 assert!(matches!(match_candidates(&observed, &[candidate]), MatchResult::None));
1303 }
1304
1305 #[test]
1306 fn struct_constructor_values_require_canonical_words() {
1307 let constructor: JsonAbi = serde_json::from_value(json!([{
1308 "type": "constructor",
1309 "inputs": [{
1310 "name": "s",
1311 "type": "tuple",
1312 "internalType": "struct A.S",
1313 "components": [{"name": "n", "type": "uint8", "internalType": "uint8"}]
1314 }]
1315 }]))
1316 .unwrap();
1317 let candidate = candidate("A.sol:A", constructor);
1318 let mut observed = candidate.creation_bytecode.to_vec();
1319 observed.extend([0; 31]);
1320 observed.push(7);
1321 assert!(matches!(
1322 match_candidates(&observed, std::slice::from_ref(&candidate)),
1323 MatchResult::Unique(_)
1324 ));
1325
1326 observed[2] = 1;
1327 assert!(matches!(match_candidates(&observed, &[candidate]), MatchResult::None));
1328 }
1329
1330 #[test]
1331 fn no_constructor_and_zero_inputs_accept_and_preserve_suffixes() {
1332 let none = candidate("A.sol:A", JsonAbi::default());
1333 assert!(matches!(
1334 match_candidates(&[0x60, 0x00], std::slice::from_ref(&none)),
1335 MatchResult::Unique(_)
1336 ));
1337 let MatchResult::Unique(matched) = match_candidates(&[0x60, 0x00, 0xaa], &[none]) else {
1338 panic!("expected match")
1339 };
1340 assert_eq!(matched.constructor_args.as_ref(), &[0xaa]);
1341
1342 let zero: JsonAbi =
1343 serde_json::from_value(json!([{"type":"constructor","inputs":[]}])).unwrap();
1344 assert!(matches!(
1345 match_candidates(&[0x60, 0x00], &[candidate("B.sol:B", zero.clone())]),
1346 MatchResult::Unique(_)
1347 ));
1348 let MatchResult::Unique(matched) =
1349 match_candidates(&[0x60, 0x00, 0xbb], &[candidate("B.sol:B", zero)])
1350 else {
1351 panic!("expected match")
1352 };
1353 assert_eq!(matched.constructor_args.as_ref(), &[0xbb]);
1354 }
1355
1356 #[test]
1357 fn ambiguity_collapses_equivalent_deployments_from_different_inputs() {
1358 let a = candidate("A.sol:A", JsonAbi::default());
1359 let first_input = a.input.clone();
1360 let mut duplicate = a.clone();
1361 duplicate.fingerprint = "different-provider-input".into();
1362 duplicate.input = Arc::new(json!({ "providerSpecific": true }));
1363 duplicate.version = Version::parse("0.8.30+commit.73712a01.Linux.gcc").unwrap();
1364 let MatchResult::Unique(matched) = match_candidates(&[0x60, 0x00], &[a.clone(), duplicate])
1365 else {
1366 panic!("equivalent deployments should be deduplicated")
1367 };
1368 assert!(Arc::ptr_eq(&matched.input, &first_input));
1369 let mut other_commit = a.clone();
1370 other_commit.version = Version::parse("0.8.30+commit.deadbeef").unwrap();
1371 assert!(matches!(
1372 match_candidates(&[0x60, 0x00], &[a.clone(), other_commit]),
1373 MatchResult::Ambiguous(_)
1374 ));
1375
1376 let constructor: JsonAbi = serde_json::from_value(json!([{
1377 "type": "constructor", "inputs": [{"name":"n", "type":"uint256"}]
1378 }]))
1379 .unwrap();
1380 let prefixed = candidate("A.sol:A", constructor);
1381 let mut observed = prefixed.creation_bytecode.to_vec();
1382 observed.extend([0; 32]);
1383 let exact = Candidate {
1384 constructor: None,
1385 creation_bytecode: Bytes::copy_from_slice(&observed),
1386 ..prefixed.clone()
1387 };
1388 assert!(matches!(
1389 match_candidates(&observed, &[prefixed, exact]),
1390 MatchResult::Ambiguous(_)
1391 ));
1392
1393 let b = candidate("B.sol:B", JsonAbi::default());
1394 let MatchResult::Ambiguous(matches) = match_candidates(&[0x60, 0x00], &[a, b]) else {
1395 panic!("expected ambiguity")
1396 };
1397 assert_eq!(matches.len(), 2);
1398 }
1399
1400 #[test]
1401 fn unresolved_library_links_are_flagged_without_dropping_other_candidates() {
1402 let placeholder = format!("__${}$__", "0".repeat(34));
1403 let output = serde_json::from_value(json!({
1404 "contracts": {
1405 "A.sol": {
1406 "Linked": {
1407 "abi": [],
1408 "evm": { "bytecode": {
1409 "object": "6000",
1410 "linkReferences": {
1411 "Lib.sol": { "Lib": [{ "start": 0, "length": 20 }] }
1412 }
1413 }}
1414 },
1415 "Placeholder": {
1416 "abi": [],
1417 "evm": { "bytecode": { "object": placeholder } }
1418 },
1419 "Plain": {
1420 "abi": [],
1421 "evm": { "bytecode": { "object": "6001" } }
1422 }
1423 }
1424 }
1425 }))
1426 .unwrap();
1427 let source = ExternalSource {
1428 input: Arc::new(input()),
1429 version: Version::new(0, 8, 30),
1430 provider: SourceProvider::Sourcify { endpoint: SOURCIFY_URL.into() },
1431 };
1432 let (candidates, has_unresolved_links) = candidates_from_output(&source, output).unwrap();
1433 assert!(has_unresolved_links);
1434 assert_eq!(candidates.len(), 1);
1435 assert_eq!(candidates[0].fqn, "A.sol:Plain");
1436 }
1437
1438 #[test]
1439 fn capped_chunk_aggregation_rejects_limit_plus_one() {
1440 let mut output = Vec::new();
1441 assert!(append_capped(&mut output, b"123", 4));
1442 assert!(!append_capped(&mut output, b"45", 4));
1443 assert_eq!(output, b"123");
1444 }
1445
1446 #[test]
1447 fn empty_creation_bytecode_never_matches() {
1448 let mut empty = candidate("A.sol:A", JsonAbi::default());
1449 empty.creation_bytecode = Bytes::new();
1450 assert!(matches!(match_candidates(&[], &[empty]), MatchResult::None));
1451 }
1452
1453 #[test]
1454 fn candidates_share_source_input() {
1455 let first = candidate("A.sol:A", JsonAbi::default());
1456 let second = Candidate { fqn: "A.sol:B".into(), ..first.clone() };
1457 assert!(Arc::ptr_eq(&first.input, &second.input));
1458 }
1459
1460 #[test]
1461 fn remote_diagnostics_are_bounded_and_sanitized() {
1462 let message = format!("secret\n{}", "x".repeat(300));
1463 let clean = sanitize_remote(message.as_bytes());
1464 assert!(!clean.contains('\n'));
1465 assert_eq!(clean.chars().count(), 160);
1466 }
1467}