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 #[command(flatten)]
23 pub common: RpcCommonOpts,
24
25 #[arg(long)]
31 pub flashbots: bool,
32
33 #[arg(long, env = "ETH_RPC_JWT_SECRET")]
43 pub jwt_secret: Option<String>,
44
45 #[arg(long, alias = "headers", env = "ETH_RPC_HEADERS", value_delimiter(','))]
47 pub rpc_headers: Option<Vec<String>>,
48
49 #[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 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 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 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 #[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 #[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 pub fn has_key(&self) -> bool {
159 self.key.as_ref().filter(|key| !key.trim().is_empty()).is_some()
160 }
161
162 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
195impl 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 #[test]
231 fn named_chain_dict_inserts_numeric_id() {
232 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}