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