|
2 | 2 | //! and Sourcify. |
3 | 3 |
|
4 | 4 | use crate::utils::{self, EnvExternalities}; |
| 5 | +use alloy_primitives::hex; |
| 6 | +use anvil::{NodeConfig, spawn}; |
| 7 | +use axum::{Router, extract::Query}; |
5 | 8 | use foundry_common::retry::Retry; |
6 | 9 | use foundry_test_utils::{ |
7 | | - forgetest, |
| 10 | + forgetest, forgetest_async, str, |
8 | 11 | util::{OutputExt, TestCommand, TestProject}, |
9 | 12 | }; |
10 | | -use std::time::Duration; |
| 13 | +use std::{collections::HashMap, time::Duration}; |
| 14 | +use tokio::net::TcpListener; |
11 | 15 |
|
12 | 16 | /// Adds a `Unique` contract to the source directory of the project that can be imported as |
13 | 17 | /// `import {Unique} from "./unique.sol";` |
@@ -371,3 +375,217 @@ Contract [src/Counter.sol:Counter] "0x19b248616E4964f43F611b5871CE1250f360E9d3" |
371 | 375 |
|
372 | 376 | "#]]); |
373 | 377 | }); |
| 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 | +}); |
0 commit comments