Skip to main content

soroban_cli/config/
network.rs

1use itertools::Itertools;
2use phf::phf_map;
3use reqwest::header::HeaderMap;
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, url::redact_url};
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        hide_env_values = true,
80    )]
81    pub rpc_headers: Vec<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)) => {
114                let rpc_headers = self
115                    .rpc_headers
116                    .iter()
117                    .map(|h| parse_http_header(h))
118                    .collect::<Result<Vec<_>, _>>()?;
119                Ok(Network {
120                    rpc_url,
121                    rpc_headers,
122                    network_passphrase,
123                })
124            }
125        }
126    }
127}
128
129#[derive(clap::Args, Serialize, Deserialize, Clone)]
130#[group(skip)]
131pub struct Network {
132    /// RPC server endpoint
133    #[arg(
134        long = "rpc-url",
135        env = "STELLAR_RPC_URL",
136        help_heading = HEADING_RPC,
137    )]
138    pub rpc_url: String,
139    /// 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.
140    #[arg(
141        long = "rpc-header",
142        env = "STELLAR_RPC_HEADERS",
143        help_heading = HEADING_RPC,
144        num_args = 1,
145        action = clap::ArgAction::Append,
146        value_delimiter = '\n',
147        value_parser = accept_raw_rpc_header,
148        hide_env_values = true,
149    )]
150    pub rpc_headers: Vec<(String, String)>,
151    /// Network passphrase to sign the transaction sent to the rpc server
152    #[arg(
153            long,
154            env = "STELLAR_NETWORK_PASSPHRASE",
155            help_heading = HEADING_RPC,
156        )]
157    pub network_passphrase: String,
158}
159
160impl std::fmt::Debug for Network {
161    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
162        let concealed: Vec<(&str, &str)> = self
163            .rpc_headers
164            .iter()
165            .map(|(k, _)| (k.as_str(), "<concealed>"))
166            .collect();
167        f.debug_struct("Network")
168            .field("rpc_url", &redact_url(&self.rpc_url))
169            .field("rpc_headers", &concealed)
170            .field("network_passphrase", &self.network_passphrase)
171            .finish()
172    }
173}
174
175fn parse_http_header(header: &str) -> Result<(String, String), Error> {
176    let header_components = header.splitn(2, ':');
177
178    let (key, value) = header_components
179        .map(str::trim)
180        .next_tuple()
181        .ok_or_else(|| Error::InvalidHeader)?;
182
183    HeaderName::from_str(key)?;
184    HeaderValue::from_str(value)?;
185
186    Ok((key.to_string(), value.to_string()))
187}
188
189/// Clap value_parser for `Network::rpc_headers` that always succeeds, deferring
190/// validation to application code so clap never echoes the raw value in error messages.
191#[allow(clippy::unnecessary_wraps)]
192fn accept_raw_rpc_header(header: &str) -> Result<(String, String), std::convert::Infallible> {
193    match header.split_once(':') {
194        Some((key, value)) => Ok((key.trim().to_string(), value.trim().to_string())),
195        None => Ok((String::new(), header.to_string())),
196    }
197}
198
199fn validate_rpc_headers(headers: &[(String, String)]) -> Result<(), Error> {
200    for (key, value) in headers {
201        HeaderName::from_str(key).map_err(|_| Error::InvalidHeader)?;
202        HeaderValue::from_str(value).map_err(|_| Error::InvalidHeader)?;
203    }
204    Ok(())
205}
206
207impl Network {
208    pub fn validate_headers(&self) -> Result<(), Error> {
209        validate_rpc_headers(&self.rpc_headers)
210    }
211
212    pub async fn helper_url(&self, addr: &str) -> Result<Url, Error> {
213        tracing::debug!("address {addr:?}");
214        let rpc_url = Url::from_str(&self.rpc_url)
215            .map_err(|_| Error::InvalidUrl(redact_url(&self.rpc_url)))?;
216        if self.network_passphrase.as_str() == passphrase::LOCAL {
217            let mut local_url = rpc_url;
218            local_url.set_path("/friendbot");
219            local_url.set_query(Some(&format!("addr={addr}")));
220            Ok(local_url)
221        } else {
222            let client = self.rpc_client()?;
223            let network = client.get_network().await?;
224            tracing::debug!(
225                "network passphrase={:?} protocol_version={} friendbot_url={:?}",
226                network.passphrase,
227                network.protocol_version,
228                network.friendbot_url.as_deref().map(redact_url),
229            );
230            let url = client.friendbot_url().await?;
231            tracing::debug!("URL {}", redact_url(&url));
232            let mut url = Url::from_str(&url).map_err(|e| {
233                tracing::error!("{e}");
234                Error::InvalidUrl(redact_url(&url))
235            })?;
236            url.query_pairs_mut().append_pair("addr", addr);
237            Ok(url)
238        }
239    }
240
241    #[allow(clippy::similar_names)]
242    pub async fn fund_address(&self, addr: &PublicKey) -> Result<(), Error> {
243        let uri = self.helper_url(&addr.to_string()).await?;
244        tracing::debug!("URL {}", redact_url(uri.as_str()));
245        let response = http::client().get(uri.as_str()).send().await?;
246
247        let request_successful = response.status().is_success();
248        let body = response.bytes().await?;
249        let res = serde_json::from_slice::<serde_json::Value>(&body)
250            .map_err(|e| Error::FailedToParseJSON(redact_url(uri.as_str()), e))?;
251        tracing::debug!("{res:#?}");
252        if !request_successful {
253            if let Some(detail) = res.get("detail").and_then(Value::as_str) {
254                if detail.contains("account already funded to starting balance") {
255                    // Don't error if friendbot indicated that the account is
256                    // already fully funded to the starting balance, because the
257                    // user's goal is to get funded, and the account is funded
258                    // so it is success much the same.
259                    tracing::debug!("already funded error ignored because account is funded");
260                } else {
261                    return Err(Error::FundingFailed(detail.to_string()));
262                }
263            } else {
264                return Err(Error::FundingFailed("unknown cause".to_string()));
265            }
266        }
267        Ok(())
268    }
269
270    pub fn rpc_uri(&self) -> Result<Url, Error> {
271        Url::from_str(&self.rpc_url).map_err(|_| Error::InvalidUrl(redact_url(&self.rpc_url)))
272    }
273
274    pub fn rpc_client(&self) -> Result<Client, Error> {
275        let mut header_hash_map = HashMap::new();
276        for (header_name, header_value) in &self.rpc_headers {
277            header_hash_map.insert(header_name.clone(), header_value.clone());
278        }
279
280        let header_map: HeaderMap = (&header_hash_map)
281            .try_into()
282            .map_err(|_| Error::InvalidHeader)?;
283
284        rpc::Client::new_with_headers(&self.rpc_url, header_map).map_err(|e| match e {
285            rpc::Error::InvalidRpcUrl(..) | rpc::Error::InvalidRpcUrlFromUriParts(..) => {
286                Error::InvalidUrl(redact_url(&self.rpc_url))
287            }
288            other => Error::Rpc(other),
289        })
290    }
291}
292
293/// Default network key to use when no network is specified
294pub const DEFAULT_NETWORK_KEY: &str = "testnet";
295
296pub static DEFAULTS: phf::Map<&'static str, (&'static str, &'static str)> = phf_map! {
297    "local" => (
298        "http://localhost:8000/rpc",
299        passphrase::LOCAL,
300    ),
301    "futurenet" => (
302        "https://rpc-futurenet.stellar.org:443",
303        passphrase::FUTURENET,
304    ),
305    "testnet" => (
306        "https://soroban-testnet.stellar.org",
307        passphrase::TESTNET,
308    ),
309    "mainnet" => (
310        "Bring Your Own: https://developers.stellar.org/docs/data/rpc/rpc-providers",
311        passphrase::MAINNET,
312    ),
313};
314
315impl From<&(&str, &str)> for Network {
316    /// Convert the return value of `DEFAULTS.get()` into a Network
317    fn from(n: &(&str, &str)) -> Self {
318        Self {
319            rpc_url: n.0.to_string(),
320            rpc_headers: Vec::new(),
321            network_passphrase: n.1.to_string(),
322        }
323    }
324}
325
326#[cfg(test)]
327mod tests {
328    use super::*;
329    use mockito::Server;
330    use serde_json::json;
331
332    const INVALID_HEADER_NAME: &str = "api key";
333    const INVALID_HEADER_VALUE: &str = "cannot include a carriage return \r in the value";
334
335    #[tokio::test]
336    async fn test_helper_url_local_network() {
337        let network = Network {
338            rpc_url: "http://localhost:8000".to_string(),
339            network_passphrase: passphrase::LOCAL.to_string(),
340            rpc_headers: Vec::new(),
341        };
342
343        let result = network
344            .helper_url("GBZXN7PIRZGNMHGA7MUUUF4GWPY5AYPV6LY4UV2GL6VJGIQRXFDNMADI")
345            .await;
346
347        assert!(result.is_ok());
348        let url = result.unwrap();
349        assert_eq!(url.as_str(), "http://localhost:8000/friendbot?addr=GBZXN7PIRZGNMHGA7MUUUF4GWPY5AYPV6LY4UV2GL6VJGIQRXFDNMADI");
350    }
351
352    #[tokio::test]
353    async fn test_helper_url_test_network() {
354        let mut server = Server::new_async().await;
355        let _mock = server
356            .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/",
365                            "passphrase": passphrase::TESTNET.to_string(),
366                            "protocolVersion": 21
367                    }
368                })
369                .to_string()
370                .into()
371            })
372            .create_async()
373            .await;
374
375        let network = Network {
376            rpc_url: server.url(),
377            network_passphrase: passphrase::TESTNET.to_string(),
378            rpc_headers: Vec::new(),
379        };
380        let url = network
381            .helper_url("GBZXN7PIRZGNMHGA7MUUUF4GWPY5AYPV6LY4UV2GL6VJGIQRXFDNMADI")
382            .await
383            .unwrap();
384        assert_eq!(url.as_str(), "https://friendbot.stellar.org/?addr=GBZXN7PIRZGNMHGA7MUUUF4GWPY5AYPV6LY4UV2GL6VJGIQRXFDNMADI");
385    }
386
387    #[tokio::test]
388    async fn test_helper_url_test_network_with_path_and_params() {
389        let mut server = Server::new_async().await;
390        let _mock = server.mock("POST", "/")
391            .with_body_from_request(|req| {
392                let body: Value = serde_json::from_slice(req.body().unwrap()).unwrap();
393                let id = body["id"].clone();
394                json!({
395                        "jsonrpc": "2.0",
396                        "id": id,
397                        "result": {
398                            "friendbotUrl": "https://friendbot.stellar.org/secret?api_key=123456&user=demo",
399                            "passphrase": passphrase::TESTNET.to_string(),
400                            "protocolVersion": 21
401                    }
402                }).to_string().into()
403            })
404            .create_async().await;
405
406        let network = Network {
407            rpc_url: server.url(),
408            network_passphrase: passphrase::TESTNET.to_string(),
409            rpc_headers: Vec::new(),
410        };
411        let url = network
412            .helper_url("GBZXN7PIRZGNMHGA7MUUUF4GWPY5AYPV6LY4UV2GL6VJGIQRXFDNMADI")
413            .await
414            .unwrap();
415        assert_eq!(url.as_str(), "https://friendbot.stellar.org/secret?api_key=123456&user=demo&addr=GBZXN7PIRZGNMHGA7MUUUF4GWPY5AYPV6LY4UV2GL6VJGIQRXFDNMADI");
416    }
417
418    // testing parse_header function
419    #[tokio::test]
420    async fn test_parse_http_header_ok() {
421        let result = parse_http_header("Authorization: Bearer 1234");
422        assert!(result.is_ok());
423    }
424
425    #[tokio::test]
426    async fn test_parse_http_header_error_with_invalid_name() {
427        let invalid_header = format!("{INVALID_HEADER_NAME}: Bearer 1234");
428        let result = parse_http_header(&invalid_header);
429        assert!(result.is_err());
430        assert_eq!(
431            result.unwrap_err().to_string(),
432            format!("invalid HTTP header name")
433        );
434    }
435
436    #[tokio::test]
437    async fn test_parse_http_header_error_with_invalid_value() {
438        let invalid_header = format!("Authorization: {INVALID_HEADER_VALUE}");
439        let result = parse_http_header(&invalid_header);
440        assert!(result.is_err());
441        assert_eq!(
442            result.unwrap_err().to_string(),
443            format!("failed to parse header value")
444        );
445    }
446
447    // 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
448
449    #[tokio::test]
450    async fn test_rpc_client_is_ok_when_there_are_no_headers() {
451        let network = Network {
452            rpc_url: "http://localhost:1234".to_string(),
453            network_passphrase: "Network passphrase".to_string(),
454            rpc_headers: [].to_vec(),
455        };
456
457        let result = network.rpc_client();
458        assert!(result.is_ok());
459    }
460
461    #[tokio::test]
462    async fn test_rpc_client_is_ok_with_correctly_formatted_headers() {
463        let network = Network {
464            rpc_url: "http://localhost:1234".to_string(),
465            network_passphrase: "Network passphrase".to_string(),
466            rpc_headers: [("Authorization".to_string(), "Bearer 1234".to_string())].to_vec(),
467        };
468
469        let result = network.rpc_client();
470        assert!(result.is_ok());
471    }
472
473    #[tokio::test]
474    async fn test_rpc_client_is_ok_with_multiple_headers() {
475        let network = Network {
476            rpc_url: "http://localhost:1234".to_string(),
477            network_passphrase: "Network passphrase".to_string(),
478            rpc_headers: [
479                ("Authorization".to_string(), "Bearer 1234".to_string()),
480                ("api-key".to_string(), "5678".to_string()),
481            ]
482            .to_vec(),
483        };
484
485        let result = network.rpc_client();
486        assert!(result.is_ok());
487    }
488
489    #[tokio::test]
490    async fn test_rpc_client_returns_err_with_invalid_header_name() {
491        let network = Network {
492            rpc_url: "http://localhost:8000".to_string(),
493            network_passphrase: passphrase::LOCAL.to_string(),
494            rpc_headers: [(INVALID_HEADER_NAME.to_string(), "Bearer".to_string())].to_vec(),
495        };
496
497        let result = network.rpc_client();
498        assert!(result.is_err());
499        assert_eq!(
500            result.unwrap_err().to_string(),
501            format!("invalid HTTP header: must be in the form 'key:value'")
502        );
503    }
504
505    #[tokio::test]
506    async fn test_rpc_client_returns_err_with_invalid_header_value() {
507        let network = Network {
508            rpc_url: "http://localhost:8000".to_string(),
509            network_passphrase: passphrase::LOCAL.to_string(),
510            rpc_headers: [("api-key".to_string(), INVALID_HEADER_VALUE.to_string())].to_vec(),
511        };
512
513        let result = network.rpc_client();
514        assert!(result.is_err());
515        assert_eq!(
516            result.unwrap_err().to_string(),
517            format!("invalid HTTP header: must be in the form 'key:value'")
518        );
519    }
520
521    #[tokio::test]
522    async fn test_rpc_client_returns_err_with_bad_rpc_url() {
523        let network = Network {
524            rpc_url: "Bring Your Own: http://localhost:8000".to_string(),
525            network_passphrase: passphrase::LOCAL.to_string(),
526            rpc_headers: [].to_vec(),
527        };
528
529        let result = network.rpc_client();
530        assert!(result.is_err());
531        assert_eq!(
532            result.unwrap_err().to_string(),
533            format!("Invalid URL Bring Your Own: http://localhost:8000")
534        );
535    }
536
537    #[tokio::test]
538    async fn test_default_to_testnet_when_no_network_specified() {
539        use super::super::locator;
540
541        let args = Args::default(); // No network, rpc_url, or network_passphrase specified
542        let locator_args = locator::Args::default();
543
544        let result = args.get(&locator_args);
545        assert!(result.is_ok());
546
547        let network = result.unwrap();
548        assert_eq!(network.network_passphrase, passphrase::TESTNET);
549        assert_eq!(network.rpc_url, "https://soroban-testnet.stellar.org");
550    }
551
552    #[tokio::test]
553    async fn test_user_config_default_overrides_automatic_testnet() {
554        use super::super::locator;
555        use std::env;
556
557        // Override environment variables to prevent reading real user config
558        let original_home = env::var("HOME").ok();
559        let original_stellar_config_home = env::var("STELLAR_CONFIG_HOME").ok();
560
561        // Set to a non-existent directory to ensure Config::new() fails and we test the fallback
562        env::set_var("HOME", "/dev/null");
563        env::set_var("STELLAR_CONFIG_HOME", "/dev/null");
564
565        let args = Args::default(); // No network, rpc_url, or network_passphrase specified
566        let locator_args = locator::Args::default();
567
568        let result = args.get(&locator_args);
569        assert!(result.is_ok());
570
571        let network = result.unwrap();
572        // Should still default to testnet when config reading fails
573        assert_eq!(network.network_passphrase, passphrase::TESTNET);
574        assert_eq!(network.rpc_url, "https://soroban-testnet.stellar.org");
575
576        // Restore original environment variables
577        if let Some(home) = original_home {
578            env::set_var("HOME", home);
579        } else {
580            env::remove_var("HOME");
581        }
582        if let Some(config_home) = original_stellar_config_home {
583            env::set_var("STELLAR_CONFIG_HOME", config_home);
584        } else {
585            env::remove_var("STELLAR_CONFIG_HOME");
586        }
587    }
588
589    #[test]
590    fn test_malformed_rpc_header_accepted_by_clap_without_error() {
591        use crate::test_utils::with_env_guard;
592        use clap::Parser;
593
594        #[derive(clap::Parser)]
595        struct TestCmd {
596            #[command(flatten)]
597            args: Args,
598        }
599
600        let secret = "Authorization Bearer secret_poc_token_12345";
601        with_env_guard(&["STELLAR_RPC_HEADERS"], || {
602            std::env::set_var("STELLAR_RPC_HEADERS", secret);
603            let result = TestCmd::try_parse_from(["stellar"]);
604            assert!(
605                result.is_ok(),
606                "Clap must accept malformed RPC headers without error — validation is deferred to application code to prevent secrets from being echoed in clap error messages"
607            );
608        });
609    }
610
611    #[test]
612    fn test_validate_headers_rejects_missing_colon_without_exposing_value() {
613        // Simulates what accept_raw_rpc_header stores when no ':' is present.
614        let network = Network {
615            rpc_url: "http://localhost:8000".to_string(),
616            network_passphrase: "Test".to_string(),
617            rpc_headers: vec![(
618                String::new(),
619                "Authorization Bearer secret_token_xyz".to_string(),
620            )],
621        };
622
623        let result = network.validate_headers();
624        assert!(result.is_err());
625        let error_msg = result.unwrap_err().to_string();
626        assert_eq!(
627            error_msg,
628            "invalid HTTP header: must be in the form 'key:value'"
629        );
630        assert!(
631            !error_msg.contains("secret_token_xyz"),
632            "Error must not expose the raw header value, got: {error_msg}"
633        );
634    }
635
636    #[test]
637    fn test_malformed_rpc_header_app_error_does_not_expose_value() {
638        use super::super::locator;
639
640        let secret = "Authorization Bearer secret_poc_token_12345";
641        let args = Args {
642            rpc_url: Some("https://example.com".to_string()),
643            rpc_headers: vec![secret.to_string()],
644            network_passphrase: Some("Test SDF Network ; September 2015".to_string()),
645            network: None,
646        };
647
648        let result = args.get(&locator::Args::default());
649        assert!(result.is_err());
650        let error_msg = result.unwrap_err().to_string();
651        assert!(
652            !error_msg.contains("secret_poc_token_12345"),
653            "Application error must not expose secret header value, got: {error_msg}"
654        );
655    }
656
657    #[test]
658    fn test_debug_conceals_rpc_header_values() {
659        let network = Network {
660            rpc_url: "http://localhost:8000/rpc".to_string(),
661            network_passphrase: "Test Network".to_string(),
662            rpc_headers: vec![
663                ("Authorization".to_string(), "Bearer secret123".to_string()),
664                ("X-Api-Key".to_string(), "mykey".to_string()),
665            ],
666        };
667        assert_eq!(
668            format!("{network:?}"),
669            r#"Network { rpc_url: "http://localhost:8000/rpc", rpc_headers: [("Authorization", "<concealed>"), ("X-Api-Key", "<concealed>")], network_passphrase: "Test Network" }"#
670        );
671    }
672
673    #[test]
674    fn test_debug_conceals_rpc_url_password() {
675        let network = Network {
676            rpc_url: "https://alice:supersecret@rpc.example.com/soroban".to_string(),
677            network_passphrase: "Test Network".to_string(),
678            rpc_headers: Vec::new(),
679        };
680        let rendered = format!("{network:?}");
681        assert!(
682            !rendered.contains("supersecret"),
683            "password leaked into Debug output: {rendered}"
684        );
685        assert!(
686            rendered.contains("alice:redacted"),
687            "expected `alice:redacted` in Debug output: {rendered}"
688        );
689    }
690
691    #[tokio::test]
692    async fn fund_address_failed_to_parse_json_does_not_leak_credentialed_rpc_url() {
693        let mut server = Server::new_async().await;
694        // Friendbot returns a non-JSON body so serde_json::from_slice fails,
695        // triggering Error::FailedToParseJSON at the line we want to verify.
696        let _mock = server
697            .mock("GET", mockito::Matcher::Any)
698            .with_status(200)
699            .with_body("not valid json")
700            .create_async()
701            .await;
702
703        let host_port = server
704            .url()
705            .strip_prefix("http://")
706            .expect("mockito url starts with http://")
707            .to_string();
708        let credentialed_rpc_url = format!("http://alice:supersecret@{host_port}");
709
710        let network = Network {
711            rpc_url: credentialed_rpc_url,
712            network_passphrase: passphrase::LOCAL.to_string(),
713            rpc_headers: Vec::new(),
714        };
715
716        let addr =
717            PublicKey::from_string("GBZXN7PIRZGNMHGA7MUUUF4GWPY5AYPV6LY4UV2GL6VJGIQRXFDNMADI")
718                .unwrap();
719        let err = network
720            .fund_address(&addr)
721            .await
722            .expect_err("fund_address must return Err when friendbot replies with non-JSON body");
723        let rendered = err.to_string();
724        assert!(
725            !rendered.contains("supersecret"),
726            "password leaked into error display: {rendered}"
727        );
728        assert!(
729            rendered.contains("alice:redacted"),
730            "expected `alice:redacted` placeholder in error display: {rendered}"
731        );
732    }
733
734    #[tokio::test]
735    async fn helper_url_returned_credentialed_url_is_redactable_at_display_sinks() {
736        // Non-LOCAL passphrase branch: helper_url asks the RPC for the friendbot URL.
737        // The mocked RPC returns a parseable URL carrying userinfo, so Url::from_str
738        // succeeds and helper_url returns Ok(url). The InvalidUrl branch is therefore
739        // not exercised here — driving it would require an unparseable URL, which by
740        // design leaks unchanged (see PR discussion). This test only documents that
741        // the parseable URL returned from helper_url can be safely run through
742        // redact_url at any subsequent display sink.
743        let mut server = Server::new_async().await;
744        let _mock = server
745            .mock("POST", "/")
746            .with_body_from_request(|req| {
747                let body: Value = serde_json::from_slice(req.body().unwrap()).unwrap();
748                let id = body["id"].clone();
749                // Returned friendbot URL has userinfo + is parseable by url::Url.
750                // Url::from_str inside helper_url accepts it, so the InvalidUrl
751                // path at line 239 isn't exercised. Instead the URL flows into
752                // the tracing line and (after fund_address) into FailedToParseJSON.
753                json!({
754                    "jsonrpc": "2.0",
755                    "id": id,
756                    "result": {
757                        "friendbotUrl": "https://alice:supersecret@friendbot.example/",
758                        "passphrase": passphrase::TESTNET.to_string(),
759                        "protocolVersion": 21,
760                    }
761                })
762                .to_string()
763                .into()
764            })
765            .create_async()
766            .await;
767
768        let network = Network {
769            rpc_url: server.url(),
770            network_passphrase: passphrase::TESTNET.to_string(),
771            rpc_headers: Vec::new(),
772        };
773        let returned = network
774            .helper_url("GBZXN7PIRZGNMHGA7MUUUF4GWPY5AYPV6LY4UV2GL6VJGIQRXFDNMADI")
775            .await
776            .expect("helper_url should accept a parseable credentialed friendbot URL");
777        // The Url returned still carries the password — callers need it to authenticate.
778        assert_eq!(returned.password(), Some("supersecret"));
779        let redacted_for_display = redact_url(returned.as_str());
780        assert!(
781            !redacted_for_display.contains("supersecret"),
782            "redact_url failed to redact a parseable friendbot URL: {redacted_for_display}"
783        );
784    }
785}