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 #[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) => 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 #[arg(
125 long = "rpc-url",
126 env = "STELLAR_RPC_URL",
127 help_heading = HEADING_RPC,
128 )]
129 pub rpc_url: String,
130 #[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 #[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 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 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 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 #[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 #[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}