Skip to main content

foundry_cli/opts/
rpc.rs

1use crate::opts::{ChainValueParser, RpcCommonOpts};
2use clap::Parser;
3use eyre::Result;
4use foundry_config::{
5    Chain, Config, FigmentProviders,
6    figment::{
7        self, Figment, Metadata, Profile,
8        value::{Dict, Map},
9    },
10    find_project_root, impl_figment_convert_cast,
11};
12use foundry_wallets::WalletOpts;
13use serde::Serialize;
14use std::borrow::Cow;
15
16const FLASHBOTS_URL: &str = "https://rpc.flashbots.net/fast";
17
18#[derive(Clone, Debug, Default, Parser)]
19#[command(next_help_heading = "Rpc options")]
20pub struct RpcOpts {
21    /// Common RPC options (URL, timeout, rate limiting, etc.).
22    #[command(flatten)]
23    pub common: RpcCommonOpts,
24
25    /// Use the Flashbots RPC URL with fast mode (<https://rpc.flashbots.net/fast>).
26    ///
27    /// This shares the transaction privately with all registered builders.
28    ///
29    /// See: <https://docs.flashbots.net/flashbots-protect/quick-start#faster-transactions>
30    #[arg(long)]
31    pub flashbots: bool,
32
33    /// JWT Secret for the RPC endpoint.
34    ///
35    /// The JWT secret will be used to create a JWT for a RPC. For example, the following can be
36    /// used to simulate a CL `engine_forkchoiceUpdated` call:
37    ///
38    /// cast rpc --jwt-secret <JWT_SECRET> engine_forkchoiceUpdatedV2
39    /// '["0x6bb38c26db65749ab6e472080a3d20a2f35776494e72016d1e339593f21c59bc",
40    /// "0x6bb38c26db65749ab6e472080a3d20a2f35776494e72016d1e339593f21c59bc",
41    /// "0x6bb38c26db65749ab6e472080a3d20a2f35776494e72016d1e339593f21c59bc"]'
42    #[arg(long, env = "ETH_RPC_JWT_SECRET")]
43    pub jwt_secret: Option<String>,
44
45    /// Specify custom headers for RPC requests.
46    #[arg(long, alias = "headers", env = "ETH_RPC_HEADERS", value_delimiter(','))]
47    pub rpc_headers: Option<Vec<String>>,
48
49    /// Print the equivalent curl command instead of making the RPC request.
50    #[arg(long)]
51    pub curl: bool,
52}
53
54impl_figment_convert_cast!(RpcOpts);
55
56impl figment::Provider for RpcOpts {
57    fn metadata(&self) -> Metadata {
58        Metadata::named("RpcOpts")
59    }
60
61    fn data(&self) -> Result<Map<Profile, Dict>, figment::Error> {
62        Ok(Map::from([(Config::selected_profile(), self.dict())]))
63    }
64}
65
66impl RpcOpts {
67    /// Returns the RPC endpoint.
68    pub fn url<'a>(&'a self, config: Option<&'a Config>) -> Result<Option<Cow<'a, str>>> {
69        self.url_with_env(config, std::env::var("ETH_RPC_URL").ok())
70    }
71
72    fn url_with_env<'a>(
73        &'a self,
74        config: Option<&'a Config>,
75        env_url: Option<String>,
76    ) -> Result<Option<Cow<'a, str>>> {
77        if self.flashbots {
78            Ok(Some(Cow::Borrowed(FLASHBOTS_URL)))
79        } else if let Some(url) = self.common.rpc_url.as_deref() {
80            Ok(Some(Cow::Borrowed(url)))
81        } else if let Some(url) = env_url {
82            Ok(Some(Cow::Owned(url)))
83        } else {
84            self.common.url(config)
85        }
86    }
87
88    /// Returns the JWT secret.
89    pub fn jwt<'a>(&'a self, config: Option<&'a Config>) -> Result<Option<Cow<'a, str>>> {
90        let jwt = match (self.jwt_secret.as_deref(), config) {
91            (Some(jwt), _) => Some(Cow::Borrowed(jwt)),
92            (None, Some(config)) => config.get_rpc_jwt_secret()?,
93            (None, None) => None,
94        };
95        Ok(jwt)
96    }
97
98    pub fn dict(&self) -> Dict {
99        let mut dict = self.common.dict();
100        // `self.url(None)` already accounts for `flashbots` and the `ETH_RPC_URL` env var,
101        // so a single insert here covers both.
102        if let Ok(Some(url)) = self.url(None) {
103            dict.insert("eth_rpc_url".into(), url.into_owned().into());
104        }
105        if let Ok(Some(jwt)) = self.jwt(None) {
106            dict.insert("eth_rpc_jwt".into(), jwt.into_owned().into());
107        }
108        if let Some(headers) = &self.rpc_headers {
109            dict.insert("eth_rpc_headers".into(), headers.clone().into());
110        }
111        if self.curl {
112            dict.insert("eth_rpc_curl".into(), true.into());
113        }
114        dict
115    }
116
117    pub fn into_figment(self, all: bool) -> Figment {
118        let root = find_project_root(None).expect("could not determine project root");
119        Config::with_root(&root)
120            .to_figment(if all { FigmentProviders::All } else { FigmentProviders::Cast })
121            .merge(self)
122    }
123}
124
125#[derive(Clone, Debug, Default, Serialize, Parser)]
126pub struct EtherscanOpts {
127    /// The Etherscan (or equivalent) API key.
128    #[arg(short = 'e', long = "etherscan-api-key", alias = "api-key", env = "ETHERSCAN_API_KEY")]
129    #[serde(rename = "etherscan_api_key", skip_serializing_if = "Option::is_none")]
130    pub key: Option<String>,
131
132    /// The chain name or EIP-155 chain ID.
133    #[arg(
134        short,
135        long,
136        alias = "chain-id",
137        env = "CHAIN",
138        value_parser = ChainValueParser::default(),
139    )]
140    #[serde(rename = "chain_id", skip_serializing_if = "Option::is_none")]
141    pub chain: Option<Chain>,
142}
143
144impl_figment_convert_cast!(EtherscanOpts);
145
146impl figment::Provider for EtherscanOpts {
147    fn metadata(&self) -> Metadata {
148        Metadata::named("EtherscanOpts")
149    }
150
151    fn data(&self) -> Result<Map<Profile, Dict>, figment::Error> {
152        Ok(Map::from([(Config::selected_profile(), self.dict())]))
153    }
154}
155
156impl EtherscanOpts {
157    /// Returns true if the Etherscan API key is set.
158    pub fn has_key(&self) -> bool {
159        self.key.as_ref().filter(|key| !key.trim().is_empty()).is_some()
160    }
161
162    /// Returns the Etherscan API key.
163    pub fn key(&self) -> Option<String> {
164        self.key.as_ref().filter(|key| !key.trim().is_empty()).cloned()
165    }
166
167    pub fn dict(&self) -> Dict {
168        let mut dict = Dict::new();
169        if let Some(key) = self.key() {
170            dict.insert("etherscan_api_key".into(), key.into());
171        }
172
173        if let Some(chain) = self.chain {
174            dict.insert("chain_id".into(), chain.id().into());
175        }
176        dict
177    }
178}
179
180#[derive(Clone, Debug, Default, Parser)]
181#[command(next_help_heading = "Ethereum options")]
182pub struct EthereumOpts {
183    #[command(flatten)]
184    pub rpc: RpcOpts,
185
186    #[command(flatten)]
187    pub etherscan: EtherscanOpts,
188
189    #[command(flatten)]
190    pub wallet: WalletOpts,
191}
192
193impl_figment_convert_cast!(EthereumOpts);
194
195// Make this args a `Figment` so that it can be merged into the `Config`
196impl figment::Provider for EthereumOpts {
197    fn metadata(&self) -> Metadata {
198        Metadata::named("Ethereum Opts Provider")
199    }
200
201    fn data(&self) -> Result<Map<Profile, Dict>, figment::Error> {
202        let mut dict = self.etherscan.dict();
203        dict.extend(self.rpc.dict());
204
205        if let Some(from) = self.wallet.from {
206            dict.insert("sender".to_string(), from.to_string().into());
207        }
208
209        Ok(Map::from([(Config::selected_profile(), dict)]))
210    }
211}
212
213#[cfg(test)]
214mod tests {
215    use super::*;
216    use clap::CommandFactory;
217
218    #[test]
219    fn parse_etherscan_opts() {
220        let args: EtherscanOpts =
221            EtherscanOpts::parse_from(["foundry-cli", "--etherscan-api-key", "dummykey"]);
222        assert_eq!(args.key(), Some("dummykey".to_string()));
223
224        let args: EtherscanOpts =
225            EtherscanOpts::parse_from(["foundry-cli", "--etherscan-api-key", ""]);
226        assert!(!args.has_key());
227    }
228
229    // <https://github.com/foundry-rs/foundry/issues/14314>
230    #[test]
231    fn named_chain_dict_inserts_numeric_id() {
232        // Chain 9745 is recognized as NamedChain::Plasma by alloy-chains.
233        // Previously, dict() would insert chain_id as the string "plasma",
234        // causing deserialization failure when EvmOpts expects u64.
235        let args = EtherscanOpts::parse_from(["foundry-cli", "--chain", "9745"]);
236        let dict = args.dict();
237        let chain_id = dict.get("chain_id").expect("chain_id should be present");
238        let id: u64 = chain_id.deserialize().expect("chain_id should deserialize as u64");
239        assert_eq!(id, 9745);
240    }
241
242    #[test]
243    fn rpc_url_arg_does_not_read_eth_rpc_url_env() {
244        let command = RpcOpts::command();
245        let rpc_url =
246            command.get_arguments().find(|arg| arg.get_id() == "rpc_url").expect("rpc_url arg");
247
248        assert!(rpc_url.get_env().is_none());
249    }
250
251    #[test]
252    fn rpc_url_resolves_eth_rpc_url_env() {
253        let args = RpcOpts::default();
254        let url = args
255            .url_with_env(None, Some("http://127.0.0.1:8545".to_string()))
256            .expect("url")
257            .expect("url");
258
259        assert_eq!(url.as_ref(), "http://127.0.0.1:8545");
260    }
261
262    #[test]
263    fn explicit_rpc_url_takes_precedence_over_eth_rpc_url_env() {
264        let args = RpcOpts {
265            common: RpcCommonOpts {
266                rpc_url: Some("http://127.0.0.1:8546".to_string()),
267                ..Default::default()
268            },
269            ..Default::default()
270        };
271        let url = args
272            .url_with_env(None, Some("http://127.0.0.1:8545".to_string()))
273            .expect("url")
274            .expect("url");
275
276        assert_eq!(url.as_ref(), "http://127.0.0.1:8546");
277    }
278}