Skip to main content

scope/compliance/
etherscan.rs

1//! # Etherscan access for compliance
2//!
3//! The one place that knows where the Etherscan API key comes from, and
4//! the one way to build a risk engine with or without Etherscan data.
5
6use super::datasource::BlockchainDataClient;
7use super::risk::RiskEngine;
8
9/// The environment variable that holds the Etherscan API key.
10pub const ETHERSCAN_KEY_ENV: &str = "ETHERSCAN_API_KEY";
11
12/// The Etherscan API key: [`ETHERSCAN_KEY_ENV`] when it is set, else
13/// `chains.api_keys.etherscan` in the config file. Reading only the
14/// variable ignored the key that `scope setup` saves, so compliance ran
15/// without data while the rest of scope used the key.
16pub fn etherscan_key() -> Option<String> {
17    let configured = || {
18        crate::config::Config::load(None)
19            .ok()
20            .and_then(|c| c.chains.api_keys.get("etherscan").cloned())
21    };
22    key_from(std::env::var(ETHERSCAN_KEY_ENV).ok(), configured)
23}
24
25/// The first non-empty key: `env`, then `configured()`.
26fn key_from(env: Option<String>, configured: impl FnOnce() -> Option<String>) -> Option<String> {
27    env.filter(|k| !k.trim().is_empty())
28        .or_else(configured)
29        .filter(|k| !k.trim().is_empty())
30}
31
32impl RiskEngine {
33    /// A risk engine that uses Etherscan data when there is a `client`,
34    /// and basic scoring when there is none.
35    pub fn with_optional_client(client: Option<BlockchainDataClient>) -> Self {
36        match client {
37            Some(c) => Self::with_data_client(c),
38            None => Self::new(),
39        }
40    }
41
42    /// A risk engine with Etherscan data when [`ETHERSCAN_KEY_ENV`] is set.
43    #[deprecated(
44        since = "0.8.0",
45        note = "pass the transport: use `RiskEngine::from_env_with_http`"
46    )]
47    pub fn from_env() -> Self {
48        Self::from_env_with_http(crate::http::shared::fallback())
49    }
50
51    /// [`RiskEngine::from_env`], sending Etherscan requests through `http`.
52    pub fn from_env_with_http(http: std::sync::Arc<dyn crate::http::HttpClient>) -> Self {
53        Self::with_optional_client(BlockchainDataClient::from_env_opt_with_http(http))
54    }
55}
56
57#[cfg(test)]
58mod tests {
59    use super::*;
60    use crate::compliance::datasource::DataSources;
61
62    #[test]
63    fn test_optional_client_picks_the_engine() {
64        // Both shapes must build; the data client is used only when given.
65        let _basic = RiskEngine::with_optional_client(None);
66        let client = BlockchainDataClient::new_with_http(
67            DataSources::new("k".to_string()),
68            crate::http::test_transport(),
69        );
70        let _rich = RiskEngine::with_optional_client(Some(client));
71        assert_eq!(ETHERSCAN_KEY_ENV, "ETHERSCAN_API_KEY");
72    }
73
74    #[test]
75    fn test_the_variable_wins_and_the_config_key_is_the_fallback() {
76        let config = || Some("from-config".to_string());
77        assert_eq!(
78            key_from(Some("from-env".into()), config).as_deref(),
79            Some("from-env")
80        );
81        // The key `scope setup` saved must be used when the variable is unset.
82        assert_eq!(key_from(None, config).as_deref(), Some("from-config"));
83        // An empty variable or key is no key.
84        assert_eq!(
85            key_from(Some("  ".into()), config).as_deref(),
86            Some("from-config")
87        );
88        assert_eq!(key_from(None, || Some(String::new())), None);
89    }
90}