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        rpc::Client::new_with_headers(&self.rpc_url, header_map).map_err(|e| match e {
236            rpc::Error::InvalidRpcUrl(..) | rpc::Error::InvalidRpcUrlFromUriParts(..) => {
237                Error::InvalidUrl(self.rpc_url.clone())
238            }
239            other => Error::Rpc(other),
240        })
241    }
242}
243
244/// Default network key to use when no network is specified
245pub const DEFAULT_NETWORK_KEY: &str = "testnet";
246
247pub static DEFAULTS: phf::Map<&'static str, (&'static str, &'static str)> = phf_map! {
248    "local" => (
249        "http://localhost:8000/rpc",
250        passphrase::LOCAL,
251    ),
252    "futurenet" => (
253        "https://rpc-futurenet.stellar.org:443",
254        passphrase::FUTURENET,
255    ),
256    "testnet" => (
257        "https://soroban-testnet.stellar.org",
258        passphrase::TESTNET,
259    ),
260    "mainnet" => (
261        "Bring Your Own: https://developers.stellar.org/docs/data/rpc/rpc-providers",
262        passphrase::MAINNET,
263    ),
264};
265
266impl From<&(&str, &str)> for Network {
267    /// Convert the return value of `DEFAULTS.get()` into a Network
268    fn from(n: &(&str, &str)) -> Self {
269        Self {
270            rpc_url: n.0.to_string(),
271            rpc_headers: Vec::new(),
272            network_passphrase: n.1.to_string(),
273        }
274    }
275}
276
277#[cfg(test)]
278mod tests {
279    use super::*;
280    use mockito::Server;
281    use serde_json::json;
282
283    const INVALID_HEADER_NAME: &str = "api key";
284    const INVALID_HEADER_VALUE: &str = "cannot include a carriage return \r in the value";
285
286    #[tokio::test]
287    async fn test_helper_url_local_network() {
288        let network = Network {
289            rpc_url: "http://localhost:8000".to_string(),
290            network_passphrase: passphrase::LOCAL.to_string(),
291            rpc_headers: Vec::new(),
292        };
293
294        let result = network
295            .helper_url("GBZXN7PIRZGNMHGA7MUUUF4GWPY5AYPV6LY4UV2GL6VJGIQRXFDNMADI")
296            .await;
297
298        assert!(result.is_ok());
299        let url = result.unwrap();
300        assert_eq!(url.as_str(), "http://localhost:8000/friendbot?addr=GBZXN7PIRZGNMHGA7MUUUF4GWPY5AYPV6LY4UV2GL6VJGIQRXFDNMADI");
301    }
302
303    #[tokio::test]
304    async fn test_helper_url_test_network() {
305        let mut server = Server::new_async().await;
306        let _mock = server
307            .mock("POST", "/")
308            .with_body_from_request(|req| {
309                let body: Value = serde_json::from_slice(req.body().unwrap()).unwrap();
310                let id = body["id"].clone();
311                json!({
312                        "jsonrpc": "2.0",
313                        "id": id,
314                        "result": {
315                            "friendbotUrl": "https://friendbot.stellar.org/",
316                            "passphrase": passphrase::TESTNET.to_string(),
317                            "protocolVersion": 21
318                    }
319                })
320                .to_string()
321                .into()
322            })
323            .create_async()
324            .await;
325
326        let network = Network {
327            rpc_url: server.url(),
328            network_passphrase: passphrase::TESTNET.to_string(),
329            rpc_headers: Vec::new(),
330        };
331        let url = network
332            .helper_url("GBZXN7PIRZGNMHGA7MUUUF4GWPY5AYPV6LY4UV2GL6VJGIQRXFDNMADI")
333            .await
334            .unwrap();
335        assert_eq!(url.as_str(), "https://friendbot.stellar.org/?addr=GBZXN7PIRZGNMHGA7MUUUF4GWPY5AYPV6LY4UV2GL6VJGIQRXFDNMADI");
336    }
337
338    #[tokio::test]
339    async fn test_helper_url_test_network_with_path_and_params() {
340        let mut server = Server::new_async().await;
341        let _mock = server.mock("POST", "/")
342            .with_body_from_request(|req| {
343                let body: Value = serde_json::from_slice(req.body().unwrap()).unwrap();
344                let id = body["id"].clone();
345                json!({
346                        "jsonrpc": "2.0",
347                        "id": id,
348                        "result": {
349                            "friendbotUrl": "https://friendbot.stellar.org/secret?api_key=123456&user=demo",
350                            "passphrase": passphrase::TESTNET.to_string(),
351                            "protocolVersion": 21
352                    }
353                }).to_string().into()
354            })
355            .create_async().await;
356
357        let network = Network {
358            rpc_url: server.url(),
359            network_passphrase: passphrase::TESTNET.to_string(),
360            rpc_headers: Vec::new(),
361        };
362        let url = network
363            .helper_url("GBZXN7PIRZGNMHGA7MUUUF4GWPY5AYPV6LY4UV2GL6VJGIQRXFDNMADI")
364            .await
365            .unwrap();
366        assert_eq!(url.as_str(), "https://friendbot.stellar.org/secret?api_key=123456&user=demo&addr=GBZXN7PIRZGNMHGA7MUUUF4GWPY5AYPV6LY4UV2GL6VJGIQRXFDNMADI");
367    }
368
369    // testing parse_header function
370    #[tokio::test]
371    async fn test_parse_http_header_ok() {
372        let result = parse_http_header("Authorization: Bearer 1234");
373        assert!(result.is_ok());
374    }
375
376    #[tokio::test]
377    async fn test_parse_http_header_error_with_invalid_name() {
378        let invalid_header = format!("{INVALID_HEADER_NAME}: Bearer 1234");
379        let result = parse_http_header(&invalid_header);
380        assert!(result.is_err());
381        assert_eq!(
382            result.unwrap_err().to_string(),
383            format!("invalid HTTP header name")
384        );
385    }
386
387    #[tokio::test]
388    async fn test_parse_http_header_error_with_invalid_value() {
389        let invalid_header = format!("Authorization: {INVALID_HEADER_VALUE}");
390        let result = parse_http_header(&invalid_header);
391        assert!(result.is_err());
392        assert_eq!(
393            result.unwrap_err().to_string(),
394            format!("failed to parse header value")
395        );
396    }
397
398    // 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
399
400    #[tokio::test]
401    async fn test_rpc_client_is_ok_when_there_are_no_headers() {
402        let network = Network {
403            rpc_url: "http://localhost:1234".to_string(),
404            network_passphrase: "Network passphrase".to_string(),
405            rpc_headers: [].to_vec(),
406        };
407
408        let result = network.rpc_client();
409        assert!(result.is_ok());
410    }
411
412    #[tokio::test]
413    async fn test_rpc_client_is_ok_with_correctly_formatted_headers() {
414        let network = Network {
415            rpc_url: "http://localhost:1234".to_string(),
416            network_passphrase: "Network passphrase".to_string(),
417            rpc_headers: [("Authorization".to_string(), "Bearer 1234".to_string())].to_vec(),
418        };
419
420        let result = network.rpc_client();
421        assert!(result.is_ok());
422    }
423
424    #[tokio::test]
425    async fn test_rpc_client_is_ok_with_multiple_headers() {
426        let network = Network {
427            rpc_url: "http://localhost:1234".to_string(),
428            network_passphrase: "Network passphrase".to_string(),
429            rpc_headers: [
430                ("Authorization".to_string(), "Bearer 1234".to_string()),
431                ("api-key".to_string(), "5678".to_string()),
432            ]
433            .to_vec(),
434        };
435
436        let result = network.rpc_client();
437        assert!(result.is_ok());
438    }
439
440    #[tokio::test]
441    async fn test_rpc_client_returns_err_with_invalid_header_name() {
442        let network = Network {
443            rpc_url: "http://localhost:8000".to_string(),
444            network_passphrase: passphrase::LOCAL.to_string(),
445            rpc_headers: [(INVALID_HEADER_NAME.to_string(), "Bearer".to_string())].to_vec(),
446        };
447
448        let result = network.rpc_client();
449        assert!(result.is_err());
450        assert_eq!(
451            result.unwrap_err().to_string(),
452            format!("invalid HTTP header: must be in the form 'key:value'")
453        );
454    }
455
456    #[tokio::test]
457    async fn test_rpc_client_returns_err_with_invalid_header_value() {
458        let network = Network {
459            rpc_url: "http://localhost:8000".to_string(),
460            network_passphrase: passphrase::LOCAL.to_string(),
461            rpc_headers: [("api-key".to_string(), INVALID_HEADER_VALUE.to_string())].to_vec(),
462        };
463
464        let result = network.rpc_client();
465        assert!(result.is_err());
466        assert_eq!(
467            result.unwrap_err().to_string(),
468            format!("invalid HTTP header: must be in the form 'key:value'")
469        );
470    }
471
472    #[tokio::test]
473    async fn test_rpc_client_returns_err_with_bad_rpc_url() {
474        let network = Network {
475            rpc_url: "Bring Your Own: http://localhost:8000".to_string(),
476            network_passphrase: passphrase::LOCAL.to_string(),
477            rpc_headers: [].to_vec(),
478        };
479
480        let result = network.rpc_client();
481        assert!(result.is_err());
482        assert_eq!(
483            result.unwrap_err().to_string(),
484            format!("Invalid URL Bring Your Own: http://localhost:8000")
485        );
486    }
487
488    #[tokio::test]
489    async fn test_default_to_testnet_when_no_network_specified() {
490        use super::super::locator;
491
492        let args = Args::default(); // No network, rpc_url, or network_passphrase specified
493        let locator_args = locator::Args::default();
494
495        let result = args.get(&locator_args);
496        assert!(result.is_ok());
497
498        let network = result.unwrap();
499        assert_eq!(network.network_passphrase, passphrase::TESTNET);
500        assert_eq!(network.rpc_url, "https://soroban-testnet.stellar.org");
501    }
502
503    #[tokio::test]
504    async fn test_user_config_default_overrides_automatic_testnet() {
505        use super::super::locator;
506        use std::env;
507
508        // Override environment variables to prevent reading real user config
509        let original_home = env::var("HOME").ok();
510        let original_stellar_config_home = env::var("STELLAR_CONFIG_HOME").ok();
511
512        // Set to a non-existent directory to ensure Config::new() fails and we test the fallback
513        env::set_var("HOME", "/dev/null");
514        env::set_var("STELLAR_CONFIG_HOME", "/dev/null");
515
516        let args = Args::default(); // No network, rpc_url, or network_passphrase specified
517        let locator_args = locator::Args::default();
518
519        let result = args.get(&locator_args);
520        assert!(result.is_ok());
521
522        let network = result.unwrap();
523        // Should still default to testnet when config reading fails
524        assert_eq!(network.network_passphrase, passphrase::TESTNET);
525        assert_eq!(network.rpc_url, "https://soroban-testnet.stellar.org");
526
527        // Restore original environment variables
528        if let Some(home) = original_home {
529            env::set_var("HOME", home);
530        } else {
531            env::remove_var("HOME");
532        }
533        if let Some(config_home) = original_stellar_config_home {
534            env::set_var("STELLAR_CONFIG_HOME", config_home);
535        } else {
536            env::remove_var("STELLAR_CONFIG_HOME");
537        }
538    }
539}