Skip to main content

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(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
152impl std::fmt::Debug for Network {
153    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
154        let concealed: Vec<(&str, &str)> = self
155            .rpc_headers
156            .iter()
157            .map(|(k, _)| (k.as_str(), "<concealed>"))
158            .collect();
159        f.debug_struct("Network")
160            .field("rpc_url", &self.rpc_url)
161            .field("rpc_headers", &concealed)
162            .field("network_passphrase", &self.network_passphrase)
163            .finish()
164    }
165}
166
167fn parse_http_header(header: &str) -> Result<(String, String), Error> {
168    let header_components = header.splitn(2, ':');
169
170    let (key, value) = header_components
171        .map(str::trim)
172        .next_tuple()
173        .ok_or_else(|| Error::InvalidHeader)?;
174
175    // Check that the headers are properly formatted
176    HeaderName::from_str(key)?;
177    HeaderValue::from_str(value)?;
178
179    Ok((key.to_string(), value.to_string()))
180}
181
182impl Network {
183    pub async fn helper_url(&self, addr: &str) -> Result<Url, Error> {
184        tracing::debug!("address {addr:?}");
185        let rpc_url =
186            Url::from_str(&self.rpc_url).map_err(|_| Error::InvalidUrl(self.rpc_url.clone()))?;
187        if self.network_passphrase.as_str() == passphrase::LOCAL {
188            let mut local_url = rpc_url;
189            local_url.set_path("/friendbot");
190            local_url.set_query(Some(&format!("addr={addr}")));
191            Ok(local_url)
192        } else {
193            let client = self.rpc_client()?;
194            let network = client.get_network().await?;
195            tracing::debug!("network {network:?}");
196            let url = client.friendbot_url().await?;
197            tracing::debug!("URL {url:?}");
198            let mut url = Url::from_str(&url).map_err(|e| {
199                tracing::error!("{e}");
200                Error::InvalidUrl(url.clone())
201            })?;
202            url.query_pairs_mut().append_pair("addr", addr);
203            Ok(url)
204        }
205    }
206
207    #[allow(clippy::similar_names)]
208    pub async fn fund_address(&self, addr: &PublicKey) -> Result<(), Error> {
209        let uri = self.helper_url(&addr.to_string()).await?;
210        tracing::debug!("URL {uri:?}");
211        let response = http::client().get(uri.as_str()).send().await?;
212
213        let request_successful = response.status().is_success();
214        let body = response.bytes().await?;
215        let res = serde_json::from_slice::<serde_json::Value>(&body)
216            .map_err(|e| Error::FailedToParseJSON(uri.to_string(), e))?;
217        tracing::debug!("{res:#?}");
218        if !request_successful {
219            if let Some(detail) = res.get("detail").and_then(Value::as_str) {
220                if detail.contains("account already funded to starting balance") {
221                    // Don't error if friendbot indicated that the account is
222                    // already fully funded to the starting balance, because the
223                    // user's goal is to get funded, and the account is funded
224                    // so it is success much the same.
225                    tracing::debug!("already funded error ignored because account is funded");
226                } else {
227                    return Err(Error::FundingFailed(detail.to_string()));
228                }
229            } else {
230                return Err(Error::FundingFailed("unknown cause".to_string()));
231            }
232        }
233        Ok(())
234    }
235
236    pub fn rpc_uri(&self) -> Result<Url, Error> {
237        Url::from_str(&self.rpc_url).map_err(|_| Error::InvalidUrl(self.rpc_url.clone()))
238    }
239
240    pub fn rpc_client(&self) -> Result<Client, Error> {
241        let mut header_hash_map = HashMap::new();
242        for (header_name, header_value) in &self.rpc_headers {
243            header_hash_map.insert(header_name.clone(), header_value.clone());
244        }
245
246        let header_map: HeaderMap = (&header_hash_map)
247            .try_into()
248            .map_err(|_| Error::InvalidHeader)?;
249
250        rpc::Client::new_with_headers(&self.rpc_url, header_map).map_err(|e| match e {
251            rpc::Error::InvalidRpcUrl(..) | rpc::Error::InvalidRpcUrlFromUriParts(..) => {
252                Error::InvalidUrl(self.rpc_url.clone())
253            }
254            other => Error::Rpc(other),
255        })
256    }
257}
258
259/// Default network key to use when no network is specified
260pub const DEFAULT_NETWORK_KEY: &str = "testnet";
261
262pub static DEFAULTS: phf::Map<&'static str, (&'static str, &'static str)> = phf_map! {
263    "local" => (
264        "http://localhost:8000/rpc",
265        passphrase::LOCAL,
266    ),
267    "futurenet" => (
268        "https://rpc-futurenet.stellar.org:443",
269        passphrase::FUTURENET,
270    ),
271    "testnet" => (
272        "https://soroban-testnet.stellar.org",
273        passphrase::TESTNET,
274    ),
275    "mainnet" => (
276        "Bring Your Own: https://developers.stellar.org/docs/data/rpc/rpc-providers",
277        passphrase::MAINNET,
278    ),
279};
280
281impl From<&(&str, &str)> for Network {
282    /// Convert the return value of `DEFAULTS.get()` into a Network
283    fn from(n: &(&str, &str)) -> Self {
284        Self {
285            rpc_url: n.0.to_string(),
286            rpc_headers: Vec::new(),
287            network_passphrase: n.1.to_string(),
288        }
289    }
290}
291
292#[cfg(test)]
293mod tests {
294    use super::*;
295    use mockito::Server;
296    use serde_json::json;
297
298    const INVALID_HEADER_NAME: &str = "api key";
299    const INVALID_HEADER_VALUE: &str = "cannot include a carriage return \r in the value";
300
301    #[tokio::test]
302    async fn test_helper_url_local_network() {
303        let network = Network {
304            rpc_url: "http://localhost:8000".to_string(),
305            network_passphrase: passphrase::LOCAL.to_string(),
306            rpc_headers: Vec::new(),
307        };
308
309        let result = network
310            .helper_url("GBZXN7PIRZGNMHGA7MUUUF4GWPY5AYPV6LY4UV2GL6VJGIQRXFDNMADI")
311            .await;
312
313        assert!(result.is_ok());
314        let url = result.unwrap();
315        assert_eq!(url.as_str(), "http://localhost:8000/friendbot?addr=GBZXN7PIRZGNMHGA7MUUUF4GWPY5AYPV6LY4UV2GL6VJGIQRXFDNMADI");
316    }
317
318    #[tokio::test]
319    async fn test_helper_url_test_network() {
320        let mut server = Server::new_async().await;
321        let _mock = server
322            .mock("POST", "/")
323            .with_body_from_request(|req| {
324                let body: Value = serde_json::from_slice(req.body().unwrap()).unwrap();
325                let id = body["id"].clone();
326                json!({
327                        "jsonrpc": "2.0",
328                        "id": id,
329                        "result": {
330                            "friendbotUrl": "https://friendbot.stellar.org/",
331                            "passphrase": passphrase::TESTNET.to_string(),
332                            "protocolVersion": 21
333                    }
334                })
335                .to_string()
336                .into()
337            })
338            .create_async()
339            .await;
340
341        let network = Network {
342            rpc_url: server.url(),
343            network_passphrase: passphrase::TESTNET.to_string(),
344            rpc_headers: Vec::new(),
345        };
346        let url = network
347            .helper_url("GBZXN7PIRZGNMHGA7MUUUF4GWPY5AYPV6LY4UV2GL6VJGIQRXFDNMADI")
348            .await
349            .unwrap();
350        assert_eq!(url.as_str(), "https://friendbot.stellar.org/?addr=GBZXN7PIRZGNMHGA7MUUUF4GWPY5AYPV6LY4UV2GL6VJGIQRXFDNMADI");
351    }
352
353    #[tokio::test]
354    async fn test_helper_url_test_network_with_path_and_params() {
355        let mut server = Server::new_async().await;
356        let _mock = server.mock("POST", "/")
357            .with_body_from_request(|req| {
358                let body: Value = serde_json::from_slice(req.body().unwrap()).unwrap();
359                let id = body["id"].clone();
360                json!({
361                        "jsonrpc": "2.0",
362                        "id": id,
363                        "result": {
364                            "friendbotUrl": "https://friendbot.stellar.org/secret?api_key=123456&user=demo",
365                            "passphrase": passphrase::TESTNET.to_string(),
366                            "protocolVersion": 21
367                    }
368                }).to_string().into()
369            })
370            .create_async().await;
371
372        let network = Network {
373            rpc_url: server.url(),
374            network_passphrase: passphrase::TESTNET.to_string(),
375            rpc_headers: Vec::new(),
376        };
377        let url = network
378            .helper_url("GBZXN7PIRZGNMHGA7MUUUF4GWPY5AYPV6LY4UV2GL6VJGIQRXFDNMADI")
379            .await
380            .unwrap();
381        assert_eq!(url.as_str(), "https://friendbot.stellar.org/secret?api_key=123456&user=demo&addr=GBZXN7PIRZGNMHGA7MUUUF4GWPY5AYPV6LY4UV2GL6VJGIQRXFDNMADI");
382    }
383
384    // testing parse_header function
385    #[tokio::test]
386    async fn test_parse_http_header_ok() {
387        let result = parse_http_header("Authorization: Bearer 1234");
388        assert!(result.is_ok());
389    }
390
391    #[tokio::test]
392    async fn test_parse_http_header_error_with_invalid_name() {
393        let invalid_header = format!("{INVALID_HEADER_NAME}: Bearer 1234");
394        let result = parse_http_header(&invalid_header);
395        assert!(result.is_err());
396        assert_eq!(
397            result.unwrap_err().to_string(),
398            format!("invalid HTTP header name")
399        );
400    }
401
402    #[tokio::test]
403    async fn test_parse_http_header_error_with_invalid_value() {
404        let invalid_header = format!("Authorization: {INVALID_HEADER_VALUE}");
405        let result = parse_http_header(&invalid_header);
406        assert!(result.is_err());
407        assert_eq!(
408            result.unwrap_err().to_string(),
409            format!("failed to parse header value")
410        );
411    }
412
413    // 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
414
415    #[tokio::test]
416    async fn test_rpc_client_is_ok_when_there_are_no_headers() {
417        let network = Network {
418            rpc_url: "http://localhost:1234".to_string(),
419            network_passphrase: "Network passphrase".to_string(),
420            rpc_headers: [].to_vec(),
421        };
422
423        let result = network.rpc_client();
424        assert!(result.is_ok());
425    }
426
427    #[tokio::test]
428    async fn test_rpc_client_is_ok_with_correctly_formatted_headers() {
429        let network = Network {
430            rpc_url: "http://localhost:1234".to_string(),
431            network_passphrase: "Network passphrase".to_string(),
432            rpc_headers: [("Authorization".to_string(), "Bearer 1234".to_string())].to_vec(),
433        };
434
435        let result = network.rpc_client();
436        assert!(result.is_ok());
437    }
438
439    #[tokio::test]
440    async fn test_rpc_client_is_ok_with_multiple_headers() {
441        let network = Network {
442            rpc_url: "http://localhost:1234".to_string(),
443            network_passphrase: "Network passphrase".to_string(),
444            rpc_headers: [
445                ("Authorization".to_string(), "Bearer 1234".to_string()),
446                ("api-key".to_string(), "5678".to_string()),
447            ]
448            .to_vec(),
449        };
450
451        let result = network.rpc_client();
452        assert!(result.is_ok());
453    }
454
455    #[tokio::test]
456    async fn test_rpc_client_returns_err_with_invalid_header_name() {
457        let network = Network {
458            rpc_url: "http://localhost:8000".to_string(),
459            network_passphrase: passphrase::LOCAL.to_string(),
460            rpc_headers: [(INVALID_HEADER_NAME.to_string(), "Bearer".to_string())].to_vec(),
461        };
462
463        let result = network.rpc_client();
464        assert!(result.is_err());
465        assert_eq!(
466            result.unwrap_err().to_string(),
467            format!("invalid HTTP header: must be in the form 'key:value'")
468        );
469    }
470
471    #[tokio::test]
472    async fn test_rpc_client_returns_err_with_invalid_header_value() {
473        let network = Network {
474            rpc_url: "http://localhost:8000".to_string(),
475            network_passphrase: passphrase::LOCAL.to_string(),
476            rpc_headers: [("api-key".to_string(), INVALID_HEADER_VALUE.to_string())].to_vec(),
477        };
478
479        let result = network.rpc_client();
480        assert!(result.is_err());
481        assert_eq!(
482            result.unwrap_err().to_string(),
483            format!("invalid HTTP header: must be in the form 'key:value'")
484        );
485    }
486
487    #[tokio::test]
488    async fn test_rpc_client_returns_err_with_bad_rpc_url() {
489        let network = Network {
490            rpc_url: "Bring Your Own: http://localhost:8000".to_string(),
491            network_passphrase: passphrase::LOCAL.to_string(),
492            rpc_headers: [].to_vec(),
493        };
494
495        let result = network.rpc_client();
496        assert!(result.is_err());
497        assert_eq!(
498            result.unwrap_err().to_string(),
499            format!("Invalid URL Bring Your Own: http://localhost:8000")
500        );
501    }
502
503    #[tokio::test]
504    async fn test_default_to_testnet_when_no_network_specified() {
505        use super::super::locator;
506
507        let args = Args::default(); // No network, rpc_url, or network_passphrase specified
508        let locator_args = locator::Args::default();
509
510        let result = args.get(&locator_args);
511        assert!(result.is_ok());
512
513        let network = result.unwrap();
514        assert_eq!(network.network_passphrase, passphrase::TESTNET);
515        assert_eq!(network.rpc_url, "https://soroban-testnet.stellar.org");
516    }
517
518    #[tokio::test]
519    async fn test_user_config_default_overrides_automatic_testnet() {
520        use super::super::locator;
521        use std::env;
522
523        // Override environment variables to prevent reading real user config
524        let original_home = env::var("HOME").ok();
525        let original_stellar_config_home = env::var("STELLAR_CONFIG_HOME").ok();
526
527        // Set to a non-existent directory to ensure Config::new() fails and we test the fallback
528        env::set_var("HOME", "/dev/null");
529        env::set_var("STELLAR_CONFIG_HOME", "/dev/null");
530
531        let args = Args::default(); // No network, rpc_url, or network_passphrase specified
532        let locator_args = locator::Args::default();
533
534        let result = args.get(&locator_args);
535        assert!(result.is_ok());
536
537        let network = result.unwrap();
538        // Should still default to testnet when config reading fails
539        assert_eq!(network.network_passphrase, passphrase::TESTNET);
540        assert_eq!(network.rpc_url, "https://soroban-testnet.stellar.org");
541
542        // Restore original environment variables
543        if let Some(home) = original_home {
544            env::set_var("HOME", home);
545        } else {
546            env::remove_var("HOME");
547        }
548        if let Some(config_home) = original_stellar_config_home {
549            env::set_var("STELLAR_CONFIG_HOME", config_home);
550        } else {
551            env::remove_var("STELLAR_CONFIG_HOME");
552        }
553    }
554
555    #[test]
556    fn test_debug_conceals_rpc_header_values() {
557        let network = Network {
558            rpc_url: "http://localhost:8000/rpc".to_string(),
559            network_passphrase: "Test Network".to_string(),
560            rpc_headers: vec![
561                ("Authorization".to_string(), "Bearer secret123".to_string()),
562                ("X-Api-Key".to_string(), "mykey".to_string()),
563            ],
564        };
565        assert_eq!(
566            format!("{network:?}"),
567            r#"Network { rpc_url: "http://localhost:8000/rpc", rpc_headers: [("Authorization", "<concealed>"), ("X-Api-Key", "<concealed>")], network_passphrase: "Test Network" }"#
568        );
569    }
570}