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(skip)]
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
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) => Err(Error::Network),
108            (_, Some(_), None) => Err(Error::MissingNetworkPassphrase),
109            (_, None, Some(_)) => Err(Error::MissingRpcUrl),
110            (Some(network), None, None) => Ok(locator.read_network(network)?),
111            (_, Some(rpc_url), Some(network_passphrase)) => Ok(Network {
112                rpc_url,
113                rpc_headers: self.rpc_headers.clone(),
114                network_passphrase,
115            }),
116        }
117    }
118}
119
120#[derive(Debug, clap::Args, Serialize, Deserialize, Clone)]
121#[group(skip)]
122pub struct Network {
123    /// RPC server endpoint
124    #[arg(
125        long = "rpc-url",
126        env = "STELLAR_RPC_URL",
127        help_heading = HEADING_RPC,
128    )]
129    pub rpc_url: String,
130    /// Optional header (e.g. API Key) to include in requests to the RPC
131    #[arg(
132        long = "rpc-header",
133        env = "STELLAR_RPC_HEADERS",
134        help_heading = HEADING_RPC,
135        num_args = 1,
136        action = clap::ArgAction::Append,
137        value_delimiter = '\n',
138        value_parser = parse_http_header,
139    )]
140    pub rpc_headers: Vec<(String, String)>,
141    /// Network passphrase to sign the transaction sent to the rpc server
142    #[arg(
143            long,
144            env = "STELLAR_NETWORK_PASSPHRASE",
145            help_heading = HEADING_RPC,
146        )]
147    pub network_passphrase: String,
148}
149
150fn parse_http_header(header: &str) -> Result<(String, String), Error> {
151    let header_components = header.splitn(2, ':');
152
153    let (key, value) = header_components
154        .map(str::trim)
155        .next_tuple()
156        .ok_or_else(|| Error::InvalidHeader)?;
157
158    // Check that the headers are properly formatted
159    HeaderName::from_str(key)?;
160    HeaderValue::from_str(value)?;
161
162    Ok((key.to_string(), value.to_string()))
163}
164
165impl Network {
166    pub async fn helper_url(&self, addr: &str) -> Result<Url, Error> {
167        tracing::debug!("address {addr:?}");
168        let rpc_url = Url::from_str(&self.rpc_url)
169            .map_err(|_| Error::InvalidUrl(self.rpc_url.to_string()))?;
170        if self.network_passphrase.as_str() == passphrase::LOCAL {
171            let mut local_url = rpc_url;
172            local_url.set_path("/friendbot");
173            local_url.set_query(Some(&format!("addr={addr}")));
174            Ok(local_url)
175        } else {
176            let client = self.rpc_client()?;
177            let network = client.get_network().await?;
178            tracing::debug!("network {network:?}");
179            let url = client.friendbot_url().await?;
180            tracing::debug!("URL {url:?}");
181            let mut url = Url::from_str(&url).map_err(|e| {
182                tracing::error!("{e}");
183                Error::InvalidUrl(url.to_string())
184            })?;
185            url.query_pairs_mut().append_pair("addr", addr);
186            Ok(url)
187        }
188    }
189
190    #[allow(clippy::similar_names)]
191    pub async fn fund_address(&self, addr: &PublicKey) -> Result<(), Error> {
192        let uri = self.helper_url(&addr.to_string()).await?;
193        tracing::debug!("URL {uri:?}");
194        let response = http::client().get(uri.as_str()).send().await?;
195
196        let request_successful = response.status().is_success();
197        let body = response.bytes().await?;
198        let res = serde_json::from_slice::<serde_json::Value>(&body)
199            .map_err(|e| Error::FailedToParseJSON(uri.to_string(), e))?;
200        tracing::debug!("{res:#?}");
201        if !request_successful {
202            if let Some(detail) = res.get("detail").and_then(Value::as_str) {
203                if detail.contains("account already funded to starting balance") {
204                    // Don't error if friendbot indicated that the account is
205                    // already fully funded to the starting balance, because the
206                    // user's goal is to get funded, and the account is funded
207                    // so it is success much the same.
208                    tracing::debug!("already funded error ignored because account is funded");
209                } else {
210                    return Err(Error::FundingFailed(detail.to_string()));
211                }
212            } else {
213                return Err(Error::FundingFailed("unknown cause".to_string()));
214            }
215        }
216        Ok(())
217    }
218
219    pub fn rpc_uri(&self) -> Result<Url, Error> {
220        Url::from_str(&self.rpc_url).map_err(|_| Error::InvalidUrl(self.rpc_url.to_string()))
221    }
222
223    pub fn rpc_client(&self) -> Result<Client, Error> {
224        let mut header_hash_map = HashMap::new();
225        for (header_name, header_value) in &self.rpc_headers {
226            header_hash_map.insert(header_name.to_string(), header_value.to_string());
227        }
228
229        let header_map: HeaderMap = (&header_hash_map)
230            .try_into()
231            .map_err(|_| Error::InvalidHeader)?;
232
233        Ok(rpc::Client::new_with_headers(&self.rpc_url, header_map)?)
234    }
235}
236
237pub static DEFAULTS: phf::Map<&'static str, (&'static str, &'static str)> = phf_map! {
238    "local" => (
239        "http://localhost:8000/rpc",
240        passphrase::LOCAL,
241    ),
242    "futurenet" => (
243        "https://rpc-futurenet.stellar.org:443",
244        passphrase::FUTURENET,
245    ),
246    "testnet" => (
247        "https://soroban-testnet.stellar.org",
248        passphrase::TESTNET,
249    ),
250    "mainnet" => (
251        "Bring Your Own: https://developers.stellar.org/docs/data/rpc/rpc-providers",
252        passphrase::MAINNET,
253    ),
254};
255
256impl From<&(&str, &str)> for Network {
257    /// Convert the return value of `DEFAULTS.get()` into a Network
258    fn from(n: &(&str, &str)) -> Self {
259        Self {
260            rpc_url: n.0.to_string(),
261            rpc_headers: Vec::new(),
262            network_passphrase: n.1.to_string(),
263        }
264    }
265}
266
267#[cfg(test)]
268mod tests {
269    use super::*;
270    use mockito::Server;
271    use serde_json::json;
272
273    const INVALID_HEADER_NAME: &str = "api key";
274    const INVALID_HEADER_VALUE: &str = "cannot include a carriage return \r in the value";
275
276    #[tokio::test]
277    async fn test_helper_url_local_network() {
278        let network = Network {
279            rpc_url: "http://localhost:8000".to_string(),
280            network_passphrase: passphrase::LOCAL.to_string(),
281            rpc_headers: Vec::new(),
282        };
283
284        let result = network
285            .helper_url("GBZXN7PIRZGNMHGA7MUUUF4GWPY5AYPV6LY4UV2GL6VJGIQRXFDNMADI")
286            .await;
287
288        assert!(result.is_ok());
289        let url = result.unwrap();
290        assert_eq!(url.as_str(), "http://localhost:8000/friendbot?addr=GBZXN7PIRZGNMHGA7MUUUF4GWPY5AYPV6LY4UV2GL6VJGIQRXFDNMADI");
291    }
292
293    #[tokio::test]
294    async fn test_helper_url_test_network() {
295        let mut server = Server::new_async().await;
296        let _mock = server
297            .mock("POST", "/")
298            .with_body_from_request(|req| {
299                let body: Value = serde_json::from_slice(req.body().unwrap()).unwrap();
300                let id = body["id"].clone();
301                json!({
302                        "jsonrpc": "2.0",
303                        "id": id,
304                        "result": {
305                            "friendbotUrl": "https://friendbot.stellar.org/",
306                            "passphrase": passphrase::TESTNET.to_string(),
307                            "protocolVersion": 21
308                    }
309                })
310                .to_string()
311                .into()
312            })
313            .create_async()
314            .await;
315
316        let network = Network {
317            rpc_url: server.url(),
318            network_passphrase: passphrase::TESTNET.to_string(),
319            rpc_headers: Vec::new(),
320        };
321        let url = network
322            .helper_url("GBZXN7PIRZGNMHGA7MUUUF4GWPY5AYPV6LY4UV2GL6VJGIQRXFDNMADI")
323            .await
324            .unwrap();
325        assert_eq!(url.as_str(), "https://friendbot.stellar.org/?addr=GBZXN7PIRZGNMHGA7MUUUF4GWPY5AYPV6LY4UV2GL6VJGIQRXFDNMADI");
326    }
327
328    #[tokio::test]
329    async fn test_helper_url_test_network_with_path_and_params() {
330        let mut server = Server::new_async().await;
331        let _mock = server.mock("POST", "/")
332            .with_body_from_request(|req| {
333                let body: Value = serde_json::from_slice(req.body().unwrap()).unwrap();
334                let id = body["id"].clone();
335                json!({
336                        "jsonrpc": "2.0",
337                        "id": id,
338                        "result": {
339                            "friendbotUrl": "https://friendbot.stellar.org/secret?api_key=123456&user=demo",
340                            "passphrase": passphrase::TESTNET.to_string(),
341                            "protocolVersion": 21
342                    }
343                }).to_string().into()
344            })
345            .create_async().await;
346
347        let network = Network {
348            rpc_url: server.url(),
349            network_passphrase: passphrase::TESTNET.to_string(),
350            rpc_headers: Vec::new(),
351        };
352        let url = network
353            .helper_url("GBZXN7PIRZGNMHGA7MUUUF4GWPY5AYPV6LY4UV2GL6VJGIQRXFDNMADI")
354            .await
355            .unwrap();
356        assert_eq!(url.as_str(), "https://friendbot.stellar.org/secret?api_key=123456&user=demo&addr=GBZXN7PIRZGNMHGA7MUUUF4GWPY5AYPV6LY4UV2GL6VJGIQRXFDNMADI");
357    }
358
359    // testing parse_header function
360    #[tokio::test]
361    async fn test_parse_http_header_ok() {
362        let result = parse_http_header("Authorization: Bearer 1234");
363        assert!(result.is_ok());
364    }
365
366    #[tokio::test]
367    async fn test_parse_http_header_error_with_invalid_name() {
368        let invalid_header = format!("{INVALID_HEADER_NAME}: Bearer 1234");
369        let result = parse_http_header(&invalid_header);
370        assert!(result.is_err());
371        assert_eq!(
372            result.unwrap_err().to_string(),
373            format!("invalid HTTP header name")
374        );
375    }
376
377    #[tokio::test]
378    async fn test_parse_http_header_error_with_invalid_value() {
379        let invalid_header = format!("Authorization: {INVALID_HEADER_VALUE}");
380        let result = parse_http_header(&invalid_header);
381        assert!(result.is_err());
382        assert_eq!(
383            result.unwrap_err().to_string(),
384            format!("failed to parse header value")
385        );
386    }
387
388    // 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
389
390    #[tokio::test]
391    async fn test_rpc_client_is_ok_when_there_are_no_headers() {
392        let network = Network {
393            rpc_url: "http://localhost:1234".to_string(),
394            network_passphrase: "Network passphrase".to_string(),
395            rpc_headers: [].to_vec(),
396        };
397
398        let result = network.rpc_client();
399        assert!(result.is_ok());
400    }
401
402    #[tokio::test]
403    async fn test_rpc_client_is_ok_with_correctly_formatted_headers() {
404        let network = Network {
405            rpc_url: "http://localhost:1234".to_string(),
406            network_passphrase: "Network passphrase".to_string(),
407            rpc_headers: [("Authorization".to_string(), "Bearer 1234".to_string())].to_vec(),
408        };
409
410        let result = network.rpc_client();
411        assert!(result.is_ok());
412    }
413
414    #[tokio::test]
415    async fn test_rpc_client_is_ok_with_multiple_headers() {
416        let network = Network {
417            rpc_url: "http://localhost:1234".to_string(),
418            network_passphrase: "Network passphrase".to_string(),
419            rpc_headers: [
420                ("Authorization".to_string(), "Bearer 1234".to_string()),
421                ("api-key".to_string(), "5678".to_string()),
422            ]
423            .to_vec(),
424        };
425
426        let result = network.rpc_client();
427        assert!(result.is_ok());
428    }
429
430    #[tokio::test]
431    async fn test_rpc_client_returns_err_with_invalid_header_name() {
432        let network = Network {
433            rpc_url: "http://localhost:8000".to_string(),
434            network_passphrase: passphrase::LOCAL.to_string(),
435            rpc_headers: [(INVALID_HEADER_NAME.to_string(), "Bearer".to_string())].to_vec(),
436        };
437
438        let result = network.rpc_client();
439        assert!(result.is_err());
440        assert_eq!(
441            result.unwrap_err().to_string(),
442            format!("invalid HTTP header: must be in the form 'key:value'")
443        );
444    }
445
446    #[tokio::test]
447    async fn test_rpc_client_returns_err_with_invalid_header_value() {
448        let network = Network {
449            rpc_url: "http://localhost:8000".to_string(),
450            network_passphrase: passphrase::LOCAL.to_string(),
451            rpc_headers: [("api-key".to_string(), INVALID_HEADER_VALUE.to_string())].to_vec(),
452        };
453
454        let result = network.rpc_client();
455        assert!(result.is_err());
456        assert_eq!(
457            result.unwrap_err().to_string(),
458            format!("invalid HTTP header: must be in the form 'key:value'")
459        );
460    }
461}