soroban_cli/config/
network.rs

1use clap::arg;
2use itertools::Itertools;
3use jsonrpsee_http_client::HeaderMap;
4use phf::phf_map;
5use reqwest::header::{HeaderName, HeaderValue, InvalidHeaderName, InvalidHeaderValue};
6use serde::{Deserialize, Serialize};
7use serde_json::Value;
8use std::collections::HashMap;
9use std::str::FromStr;
10use stellar_strkey::ed25519::PublicKey;
11use url::Url;
12
13use super::locator;
14use crate::utils::http;
15use crate::{
16    commands::HEADING_RPC,
17    rpc::{self, Client},
18};
19pub mod passphrase;
20
21#[derive(thiserror::Error, Debug)]
22pub enum Error {
23    #[error(transparent)]
24    Config(#[from] locator::Error),
25    #[error(
26        r#"Access to the network is required
27`--network` or `--rpc-url` and `--network-passphrase` are required if using the network.
28Network configuration can also be set using `network use` subcommand. For example, to use
29testnet, run `stellar network use testnet`.
30Alternatively you can use their corresponding environment variables:
31STELLAR_NETWORK, STELLAR_RPC_URL and STELLAR_NETWORK_PASSPHRASE"#
32    )]
33    Network,
34    #[error(
35        "rpc-url is used but network passphrase is missing, use `--network-passphrase` or `STELLAR_NETWORK_PASSPHRASE`"
36    )]
37    MissingNetworkPassphrase,
38    #[error(
39        "network passphrase is used but rpc-url is missing, use `--rpc-url` or `STELLAR_RPC_URL`"
40    )]
41    MissingRpcUrl,
42    #[error("cannot use both `--rpc-url` and `--network`")]
43    CannotUseBothRpcAndNetwork,
44    #[error(transparent)]
45    Rpc(#[from] rpc::Error),
46    #[error(transparent)]
47    HttpClient(#[from] reqwest::Error),
48    #[error("Failed to parse JSON from {0}, {1}")]
49    FailedToParseJSON(String, serde_json::Error),
50    #[error("Invalid URL {0}")]
51    InvalidUrl(String),
52    #[error("funding failed: {0}")]
53    FundingFailed(String),
54    #[error(transparent)]
55    InvalidHeaderName(#[from] InvalidHeaderName),
56    #[error(transparent)]
57    InvalidHeaderValue(#[from] InvalidHeaderValue),
58    #[error("invalid HTTP header: must be in the form 'key:value'")]
59    InvalidHeader,
60}
61
62#[derive(Debug, clap::Args, Clone, Default)]
63#[group(id = "network-args")]
64pub struct Args {
65    /// RPC server endpoint
66    #[arg(
67        long = "rpc-url",
68        env = "STELLAR_RPC_URL",
69        help_heading = HEADING_RPC,
70    )]
71    pub rpc_url: Option<String>,
72    /// RPC Header(s) to include in requests to the RPC provider, example: "X-API-Key: abc123". Multiple headers can be added by passing the option multiple times.
73    #[arg(
74        long = "rpc-header",
75        env = "STELLAR_RPC_HEADERS",
76        help_heading = HEADING_RPC,
77        num_args = 1,
78        action = clap::ArgAction::Append,
79        value_delimiter = '\n',
80        value_parser = parse_http_header,
81    )]
82    pub rpc_headers: Vec<(String, String)>,
83    /// Network passphrase to sign the transaction sent to the rpc server
84    #[arg(
85        long = "network-passphrase",
86        env = "STELLAR_NETWORK_PASSPHRASE",
87        help_heading = HEADING_RPC,
88    )]
89    pub network_passphrase: Option<String>,
90    /// Name of network to use from config
91    #[arg(
92        long,
93        short = 'n',
94        env = "STELLAR_NETWORK",
95        help_heading = HEADING_RPC,
96    )]
97    pub network: Option<String>,
98}
99
100impl Args {
101    pub fn get(&self, locator: &locator::Args) -> Result<Network, Error> {
102        match (
103            self.network.as_deref(),
104            self.rpc_url.clone(),
105            self.network_passphrase.clone(),
106        ) {
107            (None, None, None) => {
108                // Fall back to testnet as the default network if no config default is set
109                Ok(DEFAULTS.get(DEFAULT_NETWORK_KEY).unwrap().into())
110            }
111            (_, Some(_), None) => Err(Error::MissingNetworkPassphrase),
112            (_, None, Some(_)) => Err(Error::MissingRpcUrl),
113            (Some(network), None, None) => Ok(locator.read_network(network)?),
114            (_, Some(rpc_url), Some(network_passphrase)) => Ok(Network {
115                rpc_url,
116                rpc_headers: self.rpc_headers.clone(),
117                network_passphrase,
118            }),
119        }
120    }
121}
122
123#[derive(Debug, clap::Args, Serialize, Deserialize, Clone)]
124#[group(skip)]
125pub struct Network {
126    /// RPC server endpoint
127    #[arg(
128        long = "rpc-url",
129        env = "STELLAR_RPC_URL",
130        help_heading = HEADING_RPC,
131    )]
132    pub rpc_url: String,
133    /// Optional header to include in requests to the RPC, example: "X-API-Key: abc123". Multiple headers can be added by passing the option multiple times.
134    #[arg(
135        long = "rpc-header",
136        env = "STELLAR_RPC_HEADERS",
137        help_heading = HEADING_RPC,
138        num_args = 1,
139        action = clap::ArgAction::Append,
140        value_delimiter = '\n',
141        value_parser = parse_http_header,
142    )]
143    pub rpc_headers: Vec<(String, String)>,
144    /// Network passphrase to sign the transaction sent to the rpc server
145    #[arg(
146            long,
147            env = "STELLAR_NETWORK_PASSPHRASE",
148            help_heading = HEADING_RPC,
149        )]
150    pub network_passphrase: String,
151}
152
153fn parse_http_header(header: &str) -> Result<(String, String), Error> {
154    let header_components = header.splitn(2, ':');
155
156    let (key, value) = header_components
157        .map(str::trim)
158        .next_tuple()
159        .ok_or_else(|| Error::InvalidHeader)?;
160
161    // Check that the headers are properly formatted
162    HeaderName::from_str(key)?;
163    HeaderValue::from_str(value)?;
164
165    Ok((key.to_string(), value.to_string()))
166}
167
168impl Network {
169    pub async fn helper_url(&self, addr: &str) -> Result<Url, Error> {
170        tracing::debug!("address {addr:?}");
171        let rpc_url =
172            Url::from_str(&self.rpc_url).map_err(|_| Error::InvalidUrl(self.rpc_url.clone()))?;
173        if self.network_passphrase.as_str() == passphrase::LOCAL {
174            let mut local_url = rpc_url;
175            local_url.set_path("/friendbot");
176            local_url.set_query(Some(&format!("addr={addr}")));
177            Ok(local_url)
178        } else {
179            let client = self.rpc_client()?;
180            let network = client.get_network().await?;
181            tracing::debug!("network {network:?}");
182            let url = client.friendbot_url().await?;
183            tracing::debug!("URL {url:?}");
184            let mut url = Url::from_str(&url).map_err(|e| {
185                tracing::error!("{e}");
186                Error::InvalidUrl(url.clone())
187            })?;
188            url.query_pairs_mut().append_pair("addr", addr);
189            Ok(url)
190        }
191    }
192
193    #[allow(clippy::similar_names)]
194    pub async fn fund_address(&self, addr: &PublicKey) -> Result<(), Error> {
195        let uri = self.helper_url(&addr.to_string()).await?;
196        tracing::debug!("URL {uri:?}");
197        let response = http::client().get(uri.as_str()).send().await?;
198
199        let request_successful = response.status().is_success();
200        let body = response.bytes().await?;
201        let res = serde_json::from_slice::<serde_json::Value>(&body)
202            .map_err(|e| Error::FailedToParseJSON(uri.to_string(), e))?;
203        tracing::debug!("{res:#?}");
204        if !request_successful {
205            if let Some(detail) = res.get("detail").and_then(Value::as_str) {
206                if detail.contains("account already funded to starting balance") {
207                    // Don't error if friendbot indicated that the account is
208                    // already fully funded to the starting balance, because the
209                    // user's goal is to get funded, and the account is funded
210                    // so it is success much the same.
211                    tracing::debug!("already funded error ignored because account is funded");
212                } else {
213                    return Err(Error::FundingFailed(detail.to_string()));
214                }
215            } else {
216                return Err(Error::FundingFailed("unknown cause".to_string()));
217            }
218        }
219        Ok(())
220    }
221
222    pub fn rpc_uri(&self) -> Result<Url, Error> {
223        Url::from_str(&self.rpc_url).map_err(|_| Error::InvalidUrl(self.rpc_url.clone()))
224    }
225
226    pub fn rpc_client(&self) -> Result<Client, Error> {
227        let mut header_hash_map = HashMap::new();
228        for (header_name, header_value) in &self.rpc_headers {
229            header_hash_map.insert(header_name.clone(), header_value.clone());
230        }
231
232        let header_map: HeaderMap = (&header_hash_map)
233            .try_into()
234            .map_err(|_| Error::InvalidHeader)?;
235
236        Ok(rpc::Client::new_with_headers(&self.rpc_url, header_map)?)
237    }
238}
239
240/// Default network key to use when no network is specified
241pub const DEFAULT_NETWORK_KEY: &str = "testnet";
242
243pub static DEFAULTS: phf::Map<&'static str, (&'static str, &'static str)> = phf_map! {
244    "local" => (
245        "http://localhost:8000/rpc",
246        passphrase::LOCAL,
247    ),
248    "futurenet" => (
249        "https://rpc-futurenet.stellar.org:443",
250        passphrase::FUTURENET,
251    ),
252    "testnet" => (
253        "https://soroban-testnet.stellar.org",
254        passphrase::TESTNET,
255    ),
256    "mainnet" => (
257        "Bring Your Own: https://developers.stellar.org/docs/data/rpc/rpc-providers",
258        passphrase::MAINNET,
259    ),
260};
261
262impl From<&(&str, &str)> for Network {
263    /// Convert the return value of `DEFAULTS.get()` into a Network
264    fn from(n: &(&str, &str)) -> Self {
265        Self {
266            rpc_url: n.0.to_string(),
267            rpc_headers: Vec::new(),
268            network_passphrase: n.1.to_string(),
269        }
270    }
271}
272
273#[cfg(test)]
274mod tests {
275    use super::*;
276    use mockito::Server;
277    use serde_json::json;
278
279    const INVALID_HEADER_NAME: &str = "api key";
280    const INVALID_HEADER_VALUE: &str = "cannot include a carriage return \r in the value";
281
282    #[tokio::test]
283    async fn test_helper_url_local_network() {
284        let network = Network {
285            rpc_url: "http://localhost:8000".to_string(),
286            network_passphrase: passphrase::LOCAL.to_string(),
287            rpc_headers: Vec::new(),
288        };
289
290        let result = network
291            .helper_url("GBZXN7PIRZGNMHGA7MUUUF4GWPY5AYPV6LY4UV2GL6VJGIQRXFDNMADI")
292            .await;
293
294        assert!(result.is_ok());
295        let url = result.unwrap();
296        assert_eq!(url.as_str(), "http://localhost:8000/friendbot?addr=GBZXN7PIRZGNMHGA7MUUUF4GWPY5AYPV6LY4UV2GL6VJGIQRXFDNMADI");
297    }
298
299    #[tokio::test]
300    async fn test_helper_url_test_network() {
301        let mut server = Server::new_async().await;
302        let _mock = server
303            .mock("POST", "/")
304            .with_body_from_request(|req| {
305                let body: Value = serde_json::from_slice(req.body().unwrap()).unwrap();
306                let id = body["id"].clone();
307                json!({
308                        "jsonrpc": "2.0",
309                        "id": id,
310                        "result": {
311                            "friendbotUrl": "https://friendbot.stellar.org/",
312                            "passphrase": passphrase::TESTNET.to_string(),
313                            "protocolVersion": 21
314                    }
315                })
316                .to_string()
317                .into()
318            })
319            .create_async()
320            .await;
321
322        let network = Network {
323            rpc_url: server.url(),
324            network_passphrase: passphrase::TESTNET.to_string(),
325            rpc_headers: Vec::new(),
326        };
327        let url = network
328            .helper_url("GBZXN7PIRZGNMHGA7MUUUF4GWPY5AYPV6LY4UV2GL6VJGIQRXFDNMADI")
329            .await
330            .unwrap();
331        assert_eq!(url.as_str(), "https://friendbot.stellar.org/?addr=GBZXN7PIRZGNMHGA7MUUUF4GWPY5AYPV6LY4UV2GL6VJGIQRXFDNMADI");
332    }
333
334    #[tokio::test]
335    async fn test_helper_url_test_network_with_path_and_params() {
336        let mut server = Server::new_async().await;
337        let _mock = server.mock("POST", "/")
338            .with_body_from_request(|req| {
339                let body: Value = serde_json::from_slice(req.body().unwrap()).unwrap();
340                let id = body["id"].clone();
341                json!({
342                        "jsonrpc": "2.0",
343                        "id": id,
344                        "result": {
345                            "friendbotUrl": "https://friendbot.stellar.org/secret?api_key=123456&user=demo",
346                            "passphrase": passphrase::TESTNET.to_string(),
347                            "protocolVersion": 21
348                    }
349                }).to_string().into()
350            })
351            .create_async().await;
352
353        let network = Network {
354            rpc_url: server.url(),
355            network_passphrase: passphrase::TESTNET.to_string(),
356            rpc_headers: Vec::new(),
357        };
358        let url = network
359            .helper_url("GBZXN7PIRZGNMHGA7MUUUF4GWPY5AYPV6LY4UV2GL6VJGIQRXFDNMADI")
360            .await
361            .unwrap();
362        assert_eq!(url.as_str(), "https://friendbot.stellar.org/secret?api_key=123456&user=demo&addr=GBZXN7PIRZGNMHGA7MUUUF4GWPY5AYPV6LY4UV2GL6VJGIQRXFDNMADI");
363    }
364
365    // testing parse_header function
366    #[tokio::test]
367    async fn test_parse_http_header_ok() {
368        let result = parse_http_header("Authorization: Bearer 1234");
369        assert!(result.is_ok());
370    }
371
372    #[tokio::test]
373    async fn test_parse_http_header_error_with_invalid_name() {
374        let invalid_header = format!("{INVALID_HEADER_NAME}: Bearer 1234");
375        let result = parse_http_header(&invalid_header);
376        assert!(result.is_err());
377        assert_eq!(
378            result.unwrap_err().to_string(),
379            format!("invalid HTTP header name")
380        );
381    }
382
383    #[tokio::test]
384    async fn test_parse_http_header_error_with_invalid_value() {
385        let invalid_header = format!("Authorization: {INVALID_HEADER_VALUE}");
386        let result = parse_http_header(&invalid_header);
387        assert!(result.is_err());
388        assert_eq!(
389            result.unwrap_err().to_string(),
390            format!("failed to parse header value")
391        );
392    }
393
394    // testing rpc_client function - we're testing this and the parse_http_header function separately because when a user has their network already configured in a toml file, the parse_http_header function is not called and we want to make sure that if the toml file is correctly formatted, the rpc_client function will work as expected
395
396    #[tokio::test]
397    async fn test_rpc_client_is_ok_when_there_are_no_headers() {
398        let network = Network {
399            rpc_url: "http://localhost:1234".to_string(),
400            network_passphrase: "Network passphrase".to_string(),
401            rpc_headers: [].to_vec(),
402        };
403
404        let result = network.rpc_client();
405        assert!(result.is_ok());
406    }
407
408    #[tokio::test]
409    async fn test_rpc_client_is_ok_with_correctly_formatted_headers() {
410        let network = Network {
411            rpc_url: "http://localhost:1234".to_string(),
412            network_passphrase: "Network passphrase".to_string(),
413            rpc_headers: [("Authorization".to_string(), "Bearer 1234".to_string())].to_vec(),
414        };
415
416        let result = network.rpc_client();
417        assert!(result.is_ok());
418    }
419
420    #[tokio::test]
421    async fn test_rpc_client_is_ok_with_multiple_headers() {
422        let network = Network {
423            rpc_url: "http://localhost:1234".to_string(),
424            network_passphrase: "Network passphrase".to_string(),
425            rpc_headers: [
426                ("Authorization".to_string(), "Bearer 1234".to_string()),
427                ("api-key".to_string(), "5678".to_string()),
428            ]
429            .to_vec(),
430        };
431
432        let result = network.rpc_client();
433        assert!(result.is_ok());
434    }
435
436    #[tokio::test]
437    async fn test_rpc_client_returns_err_with_invalid_header_name() {
438        let network = Network {
439            rpc_url: "http://localhost:8000".to_string(),
440            network_passphrase: passphrase::LOCAL.to_string(),
441            rpc_headers: [(INVALID_HEADER_NAME.to_string(), "Bearer".to_string())].to_vec(),
442        };
443
444        let result = network.rpc_client();
445        assert!(result.is_err());
446        assert_eq!(
447            result.unwrap_err().to_string(),
448            format!("invalid HTTP header: must be in the form 'key:value'")
449        );
450    }
451
452    #[tokio::test]
453    async fn test_rpc_client_returns_err_with_invalid_header_value() {
454        let network = Network {
455            rpc_url: "http://localhost:8000".to_string(),
456            network_passphrase: passphrase::LOCAL.to_string(),
457            rpc_headers: [("api-key".to_string(), INVALID_HEADER_VALUE.to_string())].to_vec(),
458        };
459
460        let result = network.rpc_client();
461        assert!(result.is_err());
462        assert_eq!(
463            result.unwrap_err().to_string(),
464            format!("invalid HTTP header: must be in the form 'key:value'")
465        );
466    }
467
468    #[tokio::test]
469    async fn test_default_to_testnet_when_no_network_specified() {
470        use super::super::locator;
471
472        let args = Args::default(); // No network, rpc_url, or network_passphrase specified
473        let locator_args = locator::Args::default();
474
475        let result = args.get(&locator_args);
476        assert!(result.is_ok());
477
478        let network = result.unwrap();
479        assert_eq!(network.network_passphrase, passphrase::TESTNET);
480        assert_eq!(network.rpc_url, "https://soroban-testnet.stellar.org");
481    }
482
483    #[tokio::test]
484    async fn test_user_config_default_overrides_automatic_testnet() {
485        use super::super::locator;
486        use std::env;
487
488        // Override environment variables to prevent reading real user config
489        let original_home = env::var("HOME").ok();
490        let original_stellar_config_home = env::var("STELLAR_CONFIG_HOME").ok();
491
492        // Set to a non-existent directory to ensure Config::new() fails and we test the fallback
493        env::set_var("HOME", "/dev/null");
494        env::set_var("STELLAR_CONFIG_HOME", "/dev/null");
495
496        let args = Args::default(); // No network, rpc_url, or network_passphrase specified
497        let locator_args = locator::Args::default();
498
499        let result = args.get(&locator_args);
500        assert!(result.is_ok());
501
502        let network = result.unwrap();
503        // Should still default to testnet when config reading fails
504        assert_eq!(network.network_passphrase, passphrase::TESTNET);
505        assert_eq!(network.rpc_url, "https://soroban-testnet.stellar.org");
506
507        // Restore original environment variables
508        if let Some(home) = original_home {
509            env::set_var("HOME", home);
510        } else {
511            env::remove_var("HOME");
512        }
513        if let Some(config_home) = original_stellar_config_home {
514            env::set_var("STELLAR_CONFIG_HOME", config_home);
515        } else {
516            env::remove_var("STELLAR_CONFIG_HOME");
517        }
518    }
519}