soroban_cli/config/
network.rs

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