Skip to content

Commit fd18dde

Browse files
mablrampagentzerosnacks
authored
feat(verify): preflight check verifier credentials before broadcasting (#14777)
* fix(verify): warn and fall back to Sourcify when `ETHERSCAN_API_KEY` is set on unsupported chain Co-authored-by: Amp <amp@ampcode.com> * fix: given fig's review * fix: mirror etherscan key fallback in preflight paths * fix: according Fig's comments * fix(verify): exclude custom-Sourcify chains from implicit Etherscan routing * feat(verify): preflight check verifier credentials before broadcasting Validate verifier credentials before deployment so users get an early, actionable error instead of deploying and then having verification fail. Two layers of validation are now applied in `forge script` and `forge create` before any transaction is broadcasted: 1. Presence check (`VerificationProviderType::client`): fails immediately if required credentials are obviously absent. 2. Connectivity check (`VerifierArgs::check_credentials`): makes a lightweight network call to confirm credentials are actually accepted. * fix: address preflight check review feedback * fix: make clippy happy * fix(verify): address preflight check correctness and robustness issues - Demote BlockedByCloudflare, CloudFlareSecurityChallenge, RateLimitExceeded, and InvalidApiVersion from hard-fail to warn-and-proceed; transient CDN challenges and version mismatches must not block deploys - Split Custom verifier into its own branch using a raw HTTP probe with 401/403 detection, fixing the catch-all bypass for non-Etherscan-shaped auth error responses (figtracer) - Add HTTP status check to Sourcify custom-URL probe (warn on non-2xx/non-404) - Restore etherscan_api_key global config fallback in broadcast preflight, fixing regression for users with a top-level key in foundry.toml - Fix effective_type() -> resolve() in broadcast presence check so implicit Etherscan selection (key from env/config, no --verifier flag) is validated - Remove duplicate create_fails_early_on_bad_verifier_credentials test - Strengthen script preflight test to assert no broadcast occurred * fix(verify): detect Etherscan-shaped auth failures from custom verifiers Custom verifiers that return HTTP 200 with {"result":"Invalid API Key"} were not caught by the credential check, which only inspected HTTP status codes (401/403). Now also inspect the response body for the invalid-API-key string so the preflight check correctly blocks deployment. Fixes create_preflight_fails_on_invalid_api_key and script_fails_early_on_bad_verifier_credentials tests. * fix(verify): probe custom verifier with api key * fix: according Steven's comments * fix: use `.context` consistently * fix: verify credential preflight probing * fix verifier preflight URL validation * chore: clean-up imports --------- Co-authored-by: Amp <amp@ampcode.com> Co-authored-by: zerosnacks <95942363+zerosnacks@users.noreply.github.com>
1 parent 9c7b2e6 commit fd18dde

7 files changed

Lines changed: 543 additions & 50 deletions

File tree

crates/forge/Cargo.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -106,6 +106,7 @@ tempfile.workspace = true
106106
[dev-dependencies]
107107
alloy-hardforks.workspace = true
108108
anvil.workspace = true
109+
axum.workspace = true
109110
forge-script-sequence.workspace = true
110111
foundry-test-utils.workspace = true
111112

crates/forge/src/cmd/create.rs

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -370,7 +370,16 @@ impl CreateArgs {
370370

371371
let context = verify.resolve_context().await?;
372372

373-
verify.verification_provider()?.preflight_verify_check(verify, context).await?;
373+
verify.verification_provider()?.preflight_verify_check(verify.clone(), context).await?;
374+
375+
let api_key = verify.verifier.resolve_api_key(verify.etherscan.key.as_deref());
376+
let chain = verify.etherscan.chain.context("chain ID not resolved")?;
377+
verify
378+
.verifier
379+
.check_credentials(api_key, chain, &config)
380+
.await
381+
.wrap_err("Verification preflight check failed")?;
382+
374383
Ok(())
375384
}
376385

crates/forge/tests/cli/verify.rs

Lines changed: 220 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,12 +2,16 @@
22
//! and Sourcify.
33
44
use crate::utils::{self, EnvExternalities};
5+
use alloy_primitives::hex;
6+
use anvil::{NodeConfig, spawn};
7+
use axum::{Router, extract::Query};
58
use foundry_common::retry::Retry;
69
use foundry_test_utils::{
7-
forgetest,
10+
forgetest, forgetest_async, str,
811
util::{OutputExt, TestCommand, TestProject},
912
};
10-
use std::time::Duration;
13+
use std::{collections::HashMap, time::Duration};
14+
use tokio::net::TcpListener;
1115

1216
/// Adds a `Unique` contract to the source directory of the project that can be imported as
1317
/// `import {Unique} from "./unique.sol";`
@@ -371,3 +375,217 @@ Contract [src/Counter.sol:Counter] "0x19b248616E4964f43F611b5871CE1250f360E9d3"
371375
372376
"#]]);
373377
});
378+
379+
// Tests that `forge script --broadcast --verify` fails before broadcasting when
380+
// the verifier rejects the API key (credential preflight check).
381+
forgetest_async!(script_fails_early_on_bad_verifier_credentials, |prj, cmd| {
382+
foundry_test_utils::util::initialize(prj.root());
383+
prj.add_script(
384+
"Deploy.s.sol",
385+
r#"
386+
import "forge-std/Script.sol";
387+
contract Noop {}
388+
contract Deploy is Script {
389+
function run() external {
390+
vm.startBroadcast();
391+
new Noop();
392+
vm.stopBroadcast();
393+
}
394+
}
395+
"#,
396+
);
397+
398+
let (_api, handle) = spawn(NodeConfig::test()).await;
399+
let wallet = handle.dev_wallets().next().unwrap();
400+
let pk = hex::encode(wallet.credential().to_bytes());
401+
402+
let (verifier_url, _server) =
403+
spawn_mock_verifier(r#"{"status":"0","message":"NOTOK","result":"Invalid API Key"}"#).await;
404+
405+
let output = cmd
406+
.forge_fuse()
407+
.args([
408+
"script",
409+
"script/Deploy.s.sol:Deploy",
410+
"--rpc-url",
411+
handle.http_endpoint().as_str(),
412+
"--private-key",
413+
pk.as_str(),
414+
"--broadcast",
415+
"--verify",
416+
"--verifier",
417+
"custom",
418+
"--verifier-url",
419+
verifier_url.as_str(),
420+
"--verifier-api-key",
421+
"FAKE_KEY_1234",
422+
])
423+
.execute();
424+
425+
assert!(!output.status.success(), "expected command to fail");
426+
let stderr = output.stderr_lossy();
427+
assert!(
428+
stderr.contains("Verification preflight check failed"),
429+
"expected preflight error in stderr, got: {stderr}"
430+
);
431+
// The broadcast phase prints "ONCHAIN EXECUTION COMPLETE" and "Sending transactions";
432+
// neither must appear if the preflight check stopped execution before broadcasting.
433+
let stdout = output.stdout_lossy();
434+
assert!(
435+
!stdout.contains("ONCHAIN EXECUTION COMPLETE") && !stdout.contains("Sending transactions"),
436+
"transactions were broadcast but preflight check should have prevented it: {stdout}"
437+
);
438+
});
439+
440+
/// Spawns a local HTTP server that returns the given body for Etherscan-style ABI requests.
441+
async fn spawn_mock_verifier(body: &'static str) -> (String, tokio::task::JoinHandle<()>) {
442+
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
443+
let addr = listener.local_addr().unwrap();
444+
let app =
445+
Router::new().fallback(move |Query(query): Query<HashMap<String, String>>| async move {
446+
if query.get("module").is_some_and(|value| value == "contract")
447+
&& query.get("action").is_some_and(|value| value == "getabi")
448+
&& query.contains_key("address")
449+
&& query.contains_key("apikey")
450+
{
451+
body
452+
} else {
453+
r#"{"status":"0","message":"NOTOK","result":"Contract source code not verified"}"#
454+
}
455+
});
456+
let handle = tokio::spawn(async move {
457+
axum::serve(listener, app).await.unwrap();
458+
});
459+
(format!("http://{addr}"), handle)
460+
}
461+
462+
// Tests that the preflight check passes (does not block deploy) when the verifier responds
463+
// with ContractCodeNotVerified (the normal "valid key, unknown address" response).
464+
forgetest_async!(create_preflight_passes_on_contract_not_verified, |prj, cmd| {
465+
prj.initialize_default_contracts();
466+
let (_api, handle) = spawn(NodeConfig::test()).await;
467+
let wallet = handle.dev_wallets().next().unwrap();
468+
let pk = hex::encode(wallet.credential().to_bytes());
469+
470+
// Server returns a well-formed "source code not verified" Etherscan response.
471+
let (verifier_url, _server) = spawn_mock_verifier(
472+
r#"{"status":"0","message":"NOTOK","result":"Contract source code not verified"}"#,
473+
)
474+
.await;
475+
476+
let output = cmd
477+
.forge_fuse()
478+
.args([
479+
"create",
480+
"src/Counter.sol:Counter",
481+
"--rpc-url",
482+
handle.http_endpoint().as_str(),
483+
"--private-key",
484+
pk.as_str(),
485+
"--verify",
486+
"--verifier",
487+
"custom",
488+
"--verifier-url",
489+
verifier_url.as_str(),
490+
"--verifier-api-key",
491+
"VALID_KEY",
492+
])
493+
.execute();
494+
495+
// Preflight must pass — the command may fail for other reasons (e.g. post-deploy
496+
// verification), but it must NOT fail with the preflight error.
497+
let stderr = output.stderr_lossy();
498+
assert!(
499+
!stderr.contains("Verification preflight check failed"),
500+
"preflight should not block on ContractCodeNotVerified, got: {stderr}"
501+
);
502+
});
503+
504+
// Tests that the preflight check fails (blocks deploy) when the verifier explicitly
505+
// rejects the API key with an InvalidApiKey response.
506+
forgetest_async!(create_preflight_fails_on_invalid_api_key, |prj, cmd| {
507+
prj.initialize_default_contracts();
508+
let (_api, handle) = spawn(NodeConfig::test()).await;
509+
let wallet = handle.dev_wallets().next().unwrap();
510+
let pk = hex::encode(wallet.credential().to_bytes());
511+
512+
// Server returns a well-formed "invalid API key" Etherscan response.
513+
let (verifier_url, _server) =
514+
spawn_mock_verifier(r#"{"status":"0","message":"NOTOK","result":"Invalid API Key"}"#).await;
515+
516+
let output = cmd
517+
.forge_fuse()
518+
.args([
519+
"create",
520+
"src/Counter.sol:Counter",
521+
"--rpc-url",
522+
handle.http_endpoint().as_str(),
523+
"--private-key",
524+
pk.as_str(),
525+
"--verify",
526+
"--verifier",
527+
"custom",
528+
"--verifier-url",
529+
verifier_url.as_str(),
530+
"--verifier-api-key",
531+
"BAD_KEY",
532+
])
533+
.execute();
534+
535+
assert!(!output.status.success(), "expected command to fail");
536+
let stderr = output.stderr_lossy();
537+
assert!(
538+
stderr.contains("Verification preflight check failed"),
539+
"expected preflight error in stderr, got: {stderr}"
540+
);
541+
let stdout = output.stdout_lossy();
542+
assert!(
543+
!stdout.contains("Contract Address"),
544+
"contract was deployed but preflight check should have prevented it"
545+
);
546+
});
547+
548+
// Tests that the preflight check does NOT block deployment when the verifier responds
549+
// with a rate-limit error (transient, not an auth failure).
550+
forgetest_async!(create_preflight_warns_on_rate_limit, |prj, cmd| {
551+
prj.initialize_default_contracts();
552+
let (_api, handle) = spawn(NodeConfig::test()).await;
553+
let wallet = handle.dev_wallets().next().unwrap();
554+
let pk = hex::encode(wallet.credential().to_bytes());
555+
556+
// Server returns a well-formed "rate limit exceeded" Etherscan response.
557+
let (verifier_url, _server) = spawn_mock_verifier(
558+
r#"{"status":"0","message":"NOTOK","result":"Max rate limit reached"}"#,
559+
)
560+
.await;
561+
562+
let output = cmd
563+
.forge_fuse()
564+
.args([
565+
"create",
566+
"src/Counter.sol:Counter",
567+
"--rpc-url",
568+
handle.http_endpoint().as_str(),
569+
"--private-key",
570+
pk.as_str(),
571+
"--verify",
572+
"--verifier",
573+
"blockscout",
574+
"--verifier-url",
575+
verifier_url.as_str(),
576+
"--verifier-api-key",
577+
"VALID_KEY",
578+
])
579+
.execute();
580+
581+
// Rate limit must not block the deploy.
582+
let stderr = output.stderr_lossy();
583+
assert!(
584+
!stderr.contains("Verification preflight check failed"),
585+
"preflight should not block on rate limit, got: {stderr}"
586+
);
587+
assert!(
588+
stderr.contains("verifier credential check inconclusive"),
589+
"preflight should warn on rate limit, got: {stderr}"
590+
);
591+
});

crates/script/src/broadcast.rs

Lines changed: 23 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,6 @@ use alloy_rpc_types::TransactionRequest;
2828
use alloy_signer::Signature;
2929
use eyre::{Context, Result, bail};
3030
use forge_script_sequence::ScriptSequence;
31-
use forge_verify::provider::VerificationProviderType;
3231
use foundry_cheatcodes::Wallets;
3332
use foundry_cli::utils::{has_batch_support, has_different_gas_calc};
3433
use foundry_common::{
@@ -854,31 +853,36 @@ impl<FEN: FoundryEvmNetwork> BundledState<FEN> {
854853
})
855854
}
856855

857-
pub fn verify_preflight_check(&self) -> Result<()> {
856+
pub async fn verify_preflight_check(&self) -> Result<()> {
858857
for sequence in self.sequence.sequences() {
859858
let chain: Chain = sequence.chain.into();
859+
// Resolve the API key: CLI arg first, then per-chain config, then global fallback.
860860
let etherscan_key = self
861861
.script_config
862862
.config
863863
.get_etherscan_api_key(Some(chain))
864864
.or_else(|| self.script_config.config.etherscan_api_key.clone());
865-
// Use the centralized resolver so the preflight reflects the provider that will
866-
// actually be used at verification time (not just the explicit CLI value).
867-
let resolved = self.args.verifier.resolve(etherscan_key.as_deref(), Some(chain));
868-
if resolved == VerificationProviderType::Etherscan {
869-
let has_etherscan_url = (chain.etherscan_urls().is_some()
870-
&& !chain.is_custom_sourcify())
871-
|| self.args.verifier.verifier_url.is_some();
872-
if !has_etherscan_url {
873-
eyre::bail!(
874-
"Chain {} has no known Etherscan API URL; pass --verifier-url <URL>",
875-
sequence.chain
876-
);
877-
}
878-
if etherscan_key.is_none() {
879-
eyre::bail!("Missing etherscan key for chain {}", sequence.chain);
880-
}
881-
}
865+
let api_key =
866+
self.args.verifier.resolve_api_key(etherscan_key.as_deref()).map(str::to_owned);
867+
let has_url = self.args.verifier.verifier_url.is_some();
868+
let is_explicit = self.args.verifier.is_explicitly_set();
869+
// Presence check: use the fully-resolved provider type so that implicit Etherscan
870+
// selection (key from env/config, no explicit --verifier flag) is validated too.
871+
self.args
872+
.verifier
873+
.resolve(api_key.as_deref(), Some(chain))
874+
.client(api_key.as_deref(), Some(chain), has_url, is_explicit)
875+
.wrap_err_with(|| {
876+
format!("Verification preflight check failed for chain {}", sequence.chain)
877+
})?;
878+
// Connectivity check: validates credentials are actually accepted by the verifier.
879+
self.args
880+
.verifier
881+
.check_credentials(api_key.as_deref(), chain, &self.script_config.config)
882+
.await
883+
.wrap_err_with(|| {
884+
format!("Verification preflight check failed for chain {}", sequence.chain)
885+
})?;
882886
}
883887

884888
Ok(())

crates/script/src/lib.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -463,7 +463,7 @@ impl ScriptArgs {
463463

464464
// Exit early if something is wrong with verification options.
465465
if bundled.args.verify {
466-
bundled.verify_preflight_check()?;
466+
bundled.verify_preflight_check().await?;
467467
}
468468

469469
Ok(Some(bundled))

crates/verify/Cargo.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,7 @@ alloy-evm = { workspace = true, features = ["rpc"] }
3636

3737
clap = { version = "4", features = ["derive", "env", "unicode", "wrap_help"] }
3838
reqwest = { workspace = true, features = ["json"] }
39+
tokio = { workspace = true, features = ["time"] }
3940
async-trait.workspace = true
4041
futures.workspace = true
4142
semver.workspace = true

0 commit comments

Comments
 (0)