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 #[arg(
66 long = "rpc-url",
67 env = "STELLAR_RPC_URL",
68 help_heading = HEADING_RPC,
69 )]
70 pub rpc_url: Option<String>,
71 #[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 #[arg(
84 long = "network-passphrase",
85 env = "STELLAR_NETWORK_PASSPHRASE",
86 help_heading = HEADING_RPC,
87 )]
88 pub network_passphrase: Option<String>,
89 #[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 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 #[arg(
127 long = "rpc-url",
128 env = "STELLAR_RPC_URL",
129 help_heading = HEADING_RPC,
130 )]
131 pub rpc_url: String,
132 #[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 #[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 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 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
259pub 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 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 #[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 #[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(); 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 let original_home = env::var("HOME").ok();
525 let original_stellar_config_home = env::var("STELLAR_CONFIG_HOME").ok();
526
527 env::set_var("HOME", "/dev/null");
529 env::set_var("STELLAR_CONFIG_HOME", "/dev/null");
530
531 let args = Args::default(); 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 assert_eq!(network.network_passphrase, passphrase::TESTNET);
540 assert_eq!(network.rpc_url, "https://soroban-testnet.stellar.org");
541
542 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}