1use std::sync::Arc;
2
3use anyhow::Result;
4use sdk_common::prelude::*;
5use serde::{Deserialize, Serialize};
6
7use crate::bitcoin::OutPoint;
8use crate::error::{SdkError, SdkResult};
9
10pub const DEFAULT_MEMPOOL_SPACE_URL: &str = "https://mempool.space/api";
11
12#[tonic::async_trait]
13pub trait ChainService: Send + Sync {
14 async fn recommended_fees(&self) -> SdkResult<RecommendedFees>;
15 async fn address_transactions(&self, address: String) -> SdkResult<Vec<OnchainTx>>;
19 async fn current_tip(&self) -> SdkResult<u32>;
20 async fn transaction_outspends(&self, txid: String) -> SdkResult<Vec<Outspend>>;
24 async fn broadcast_transaction(&self, tx: Vec<u8>) -> SdkResult<String>;
26}
27
28pub trait RedundantChainServiceTrait: ChainService {
29 fn from_base_urls(rest_client: Arc<dyn RestClient>, base_urls: Vec<String>) -> Self;
30}
31
32#[derive(Clone)]
33pub struct RedundantChainService {
34 instances: Vec<MempoolSpace>,
35}
36impl RedundantChainServiceTrait for RedundantChainService {
37 fn from_base_urls(rest_client: Arc<dyn RestClient>, base_urls: Vec<String>) -> Self {
38 Self {
39 instances: base_urls
40 .iter()
41 .map(|url: &String| url.trim_end_matches('/'))
42 .map(|url| MempoolSpace::from_base_url(rest_client.clone(), url))
43 .collect(),
44 }
45 }
46}
47
48#[tonic::async_trait]
49impl ChainService for RedundantChainService {
50 async fn recommended_fees(&self) -> SdkResult<RecommendedFees> {
51 for inst in &self.instances {
52 match inst.recommended_fees().await {
53 Ok(res) => {
54 return Ok(res);
55 }
56 Err(e) => error!("Call to chain service {} failed: {e}", inst.base_url),
57 }
58 }
59 Err(SdkError::service_connectivity(
60 "All chain service instances failed",
61 ))
62 }
63
64 async fn address_transactions(&self, address: String) -> SdkResult<Vec<OnchainTx>> {
65 for inst in &self.instances {
66 match inst.address_transactions(address.clone()).await {
67 Ok(res) => {
68 return Ok(res);
69 }
70 Err(e) => error!("Call to chain service {} failed: {e}", inst.base_url),
71 }
72 }
73 Err(SdkError::service_connectivity(
74 "All chain service instances failed",
75 ))
76 }
77
78 async fn current_tip(&self) -> SdkResult<u32> {
79 for inst in &self.instances {
80 match inst.current_tip().await {
81 Ok(res) => {
82 return Ok(res);
83 }
84 Err(e) => error!("Call to chain service {} failed: {e}", inst.base_url),
85 }
86 }
87 Err(SdkError::service_connectivity(
88 "All chain service instances failed",
89 ))
90 }
91
92 async fn transaction_outspends(&self, txid: String) -> SdkResult<Vec<Outspend>> {
93 for inst in &self.instances {
94 match inst.transaction_outspends(txid.clone()).await {
95 Ok(res) => {
96 return Ok(res);
97 }
98 Err(e) => error!("Call to chain service {} failed: {e}", inst.base_url),
99 }
100 }
101 Err(SdkError::service_connectivity(
102 "All chain service instances failed",
103 ))
104 }
105
106 async fn broadcast_transaction(&self, tx: Vec<u8>) -> SdkResult<String> {
107 for inst in &self.instances {
108 match inst.broadcast_transaction(tx.clone()).await {
109 Ok(res) => {
110 return Ok(res);
111 }
112 Err(e) => error!("Call to chain service {} failed: {e}", inst.base_url),
113 }
114 }
115 Err(SdkError::service_connectivity(
116 "All chain service instances failed",
117 ))
118 }
119}
120
121#[derive(Clone)]
122pub struct Utxo {
123 pub out: OutPoint,
124 pub value: u64,
125 pub block_height: Option<u32>,
126}
127
128#[derive(Clone)]
129pub struct AddressUtxos {
130 pub confirmed: Vec<Utxo>,
131}
132
133impl AddressUtxos {
134 pub(crate) fn _confirmed_block(&self) -> u32 {
136 self.confirmed.iter().fold(0, |b, item| {
137 let confirmed_block = item.block_height.unwrap_or_default();
138 if confirmed_block != 0 || confirmed_block < b {
139 confirmed_block
140 } else {
141 b
142 }
143 })
144 }
145}
146
147pub(crate) fn get_utxos(
151 address: String,
152 transactions: Vec<OnchainTx>,
153 include_unconfirmed_spends: bool,
154) -> Result<AddressUtxos> {
155 let mut spent_outputs: Vec<OutPoint> = Vec::new();
156 let mut utxos: Vec<Utxo> = Vec::new();
157 for tx in transactions.iter() {
158 for vin in tx.vin.iter() {
159 if vin.prevout.scriptpubkey_address == address.clone()
160 && (include_unconfirmed_spends || tx.status.confirmed)
161 {
162 spent_outputs.push(OutPoint {
163 txid: vin.txid.parse()?,
164 vout: vin.vout,
165 })
166 }
167 }
168 }
169
170 for tx in transactions.iter() {
171 for (index, vout) in tx.vout.iter().enumerate() {
172 if vout.scriptpubkey_address == address {
173 let outpoint = OutPoint {
174 txid: tx.txid.parse()?,
175 vout: index as u32,
176 };
177 if !spent_outputs.contains(&outpoint) {
178 utxos.push(Utxo {
179 out: outpoint,
180 value: vout.value,
181 block_height: tx.status.block_height,
182 });
183 }
184 }
185 }
186 }
187 let address_utxos = AddressUtxos {
188 confirmed: utxos
189 .clone()
190 .into_iter()
191 .filter(|u| u.block_height.is_some())
192 .collect(),
193 };
194 Ok(address_utxos)
195}
196
197#[derive(Clone)]
198pub(crate) struct MempoolSpace {
199 rest_client: Arc<dyn RestClient>,
200 pub(crate) base_url: String,
201}
202
203#[derive(Deserialize, Serialize, Clone, Debug)]
205pub struct RecommendedFees {
206 #[serde(rename(deserialize = "fastestFee"))]
207 pub fastest_fee: u64,
208
209 #[serde(rename(deserialize = "halfHourFee"))]
210 pub half_hour_fee: u64,
211
212 #[serde(rename(deserialize = "hourFee"))]
213 pub hour_fee: u64,
214
215 #[serde(rename(deserialize = "economyFee"))]
216 pub economy_fee: u64,
217
218 #[serde(rename(deserialize = "minimumFee"))]
219 pub minimum_fee: u64,
220}
221
222#[derive(Default, Deserialize, Serialize, Clone, Debug)]
223pub struct OnchainTx {
224 pub txid: String,
225 pub version: u32,
226 pub locktime: u32,
227 pub vin: Vec<Vin>,
228 pub vout: Vec<Vout>,
229 pub size: u32,
230 pub weight: u32,
231 pub fee: u32,
232 pub status: TxStatus,
233}
234
235#[derive(Default, Deserialize, Serialize, Clone, Debug)]
236pub struct TxStatus {
237 pub confirmed: bool,
238 pub block_height: Option<u32>,
239 pub block_hash: Option<String>,
240 pub block_time: Option<u64>,
241}
242
243#[derive(Default, Deserialize, Serialize, Clone, Debug)]
244pub struct Vout {
245 pub scriptpubkey: String,
246 pub scriptpubkey_asm: String,
247 pub scriptpubkey_type: String,
248 pub scriptpubkey_address: String,
249 pub value: u64,
250}
251
252#[derive(Default, Deserialize, Serialize, Clone, Debug)]
253pub struct Vin {
254 pub txid: String,
255 pub vout: u32,
256 pub prevout: Vout,
257 pub scriptsig: String,
258 pub scriptsig_asm: String,
259 pub witness: Option<Vec<String>>,
260 pub is_coinbase: bool,
261 pub sequence: u32,
262}
263
264#[derive(Serialize, Deserialize, Clone, Debug)]
269pub struct Outspend {
270 pub spent: bool,
271 pub txid: Option<String>,
272 pub vin: Option<u32>,
273 pub status: Option<TxStatus>,
274}
275
276impl MempoolSpace {
277 #[allow(dead_code)]
278 pub fn new(rest_client: Arc<dyn RestClient>) -> MempoolSpace {
279 MempoolSpace {
280 rest_client,
281 base_url: DEFAULT_MEMPOOL_SPACE_URL.into(),
282 }
283 }
284
285 pub fn from_base_url(rest_client: Arc<dyn RestClient>, base_url: &str) -> MempoolSpace {
286 MempoolSpace {
287 rest_client,
288 base_url: base_url.into(),
289 }
290 }
291}
292
293#[tonic::async_trait]
294impl ChainService for MempoolSpace {
295 async fn recommended_fees(&self) -> SdkResult<RecommendedFees> {
296 let (response, _) = get_and_check_success(
297 self.rest_client.as_ref(),
298 &format!("{}/v1/fees/recommended", self.base_url),
299 )
300 .await?;
301 Ok(parse_json(&response)?)
302 }
303
304 async fn address_transactions(&self, address: String) -> SdkResult<Vec<OnchainTx>> {
305 let (response, _) = get_and_check_success(
306 self.rest_client.as_ref(),
307 &format!("{}/address/{address}/txs", self.base_url),
308 )
309 .await?;
310 Ok(parse_json(&response)?)
311 }
312
313 async fn current_tip(&self) -> SdkResult<u32> {
314 let (response, _) = get_and_check_success(
315 self.rest_client.as_ref(),
316 &format!("{}/blocks/tip/height", self.base_url),
317 )
318 .await?;
319 Ok(parse_json(&response)?)
320 }
321
322 async fn transaction_outspends(&self, txid: String) -> SdkResult<Vec<Outspend>> {
323 let (response, _) = get_and_check_success(
324 self.rest_client.as_ref(),
325 &format!("{}/tx/{txid}/outspends", self.base_url),
326 )
327 .await?;
328 Ok(parse_json(&response)?)
329 }
330
331 async fn broadcast_transaction(&self, tx: Vec<u8>) -> SdkResult<String> {
332 let (txid_or_error, _) = self
333 .rest_client
334 .post(
335 &format!("{}/tx", self.base_url),
336 None,
337 Some(hex::encode(tx)),
338 )
339 .await?;
340 match txid_or_error.contains("error") {
341 true => Err(SdkError::Generic {
342 err: format!("Error fetching tx: {txid_or_error}"),
343 }),
344 false => Ok(txid_or_error),
345 }
346 }
347}
348#[cfg(test)]
349mod tests {
350 use std::sync::Arc;
351
352 use crate::{
353 chain::{MempoolSpace, OnchainTx, RedundantChainService, RedundantChainServiceTrait},
354 error::SdkError,
355 };
356 use anyhow::Result;
357 use sdk_common::prelude::{MockResponse, MockRestClient, RestClient};
358 use serde_json::json;
359 use tokio::test;
360
361 use super::ChainService;
362
363 #[test]
364 async fn test_recommended_fees() -> Result<()> {
365 let mock_rest_client = MockRestClient::new();
366
367 let response_body = json!({
368 "economyFee": 2,
369 "fastestFee": 3,
370 "halfHourFee": 2,
371 "hourFee": 2,
372 "minimumFee": 1,
373 })
374 .to_string();
375
376 mock_rest_client.add_response(MockResponse::new(200, response_body));
377 let rest_client: Arc<dyn RestClient> = Arc::new(mock_rest_client);
378
379 let ms = MempoolSpace::new(rest_client);
380 let fees = ms.recommended_fees().await?;
381 assert_eq!(fees.economy_fee, 2);
382 assert_eq!(fees.fastest_fee, 3);
383 assert_eq!(fees.half_hour_fee, 2);
384 assert_eq!(fees.hour_fee, 2);
385 assert_eq!(fees.minimum_fee, 1);
386
387 Ok(())
388 }
389
390 #[test]
391 async fn test_recommended_fees_with_fallback() -> Result<()> {
392 let mock_rest_client = MockRestClient::new();
393
394 let unreachable_response_body = "";
395 let response_body = json!({
396 "economyFee": 2,
397 "fastestFee": 3,
398 "halfHourFee": 2,
399 "hourFee": 2,
400 "minimumFee": 1,
401 });
402
403 mock_rest_client.add_response(MockResponse::new(
404 400,
405 unreachable_response_body.to_string(),
406 ));
407 mock_rest_client.add_response(MockResponse::new(
408 400,
409 unreachable_response_body.to_string(),
410 ));
411 mock_rest_client.add_response(MockResponse::new(200, response_body.to_string()));
412 mock_rest_client.add_response(MockResponse::new(
413 400,
414 unreachable_response_body.to_string(),
415 ));
416 mock_rest_client.add_response(MockResponse::new(
417 400,
418 unreachable_response_body.to_string(),
419 ));
420 mock_rest_client.add_response(MockResponse::new(
421 400,
422 unreachable_response_body.to_string(),
423 ));
424 mock_rest_client.add_response(MockResponse::new(
425 400,
426 unreachable_response_body.to_string(),
427 ));
428 mock_rest_client.add_response(MockResponse::new(200, response_body.to_string()));
429
430 let rest_client: Arc<dyn RestClient> = Arc::new(mock_rest_client);
431
432 let ms = RedundantChainService::from_base_urls(
433 rest_client.clone(),
434 vec!["https://mempool-url-unreachable.space/api/".into()],
435 );
436 assert!(ms.recommended_fees().await.is_err());
437
438 let ms = RedundantChainService::from_base_urls(
439 rest_client.clone(),
440 vec![
441 "https://mempool-url-unreachable.space/api/".into(),
442 "https://mempool.emzy.de/api/".into(),
443 ],
444 );
445 assert!(ms.recommended_fees().await.is_ok());
446
447 let ms = RedundantChainService::from_base_urls(
448 rest_client.clone(),
449 vec![
450 "https://mempool-url-unreachable.space/api/".into(),
451 "https://another-mempool-url-unreachable.space/api/".into(),
452 ],
453 );
454 assert!(ms.recommended_fees().await.is_err());
455
456 let ms = RedundantChainService::from_base_urls(
457 rest_client,
458 vec![
459 "https://mempool-url-unreachable.space/api/".into(),
460 "https://another-mempool-url-unreachable.space/api/".into(),
461 "https://mempool.emzy.de/api/".into(),
462 ],
463 );
464 assert!(ms.recommended_fees().await.is_ok());
465
466 Ok(())
467 }
468
469 #[test]
470 async fn test_address_transactions() -> Result<()> {
471 let mock_rest_client = MockRestClient::new();
472
473 let address_transactions_response_body = r#"[{"txid":"5e0668bf1cd24f2f8656ee82d4886f5303a06b26838e24b7db73afc59e228985","version":2,"locktime":0,"vin":[{"txid":"07c9d3fbffc20f96ea7c93ef3bcdf346c8a8456c25850ea76be62b24a7cf690c","vout":0,"prevout":{"scriptpubkey":"001465c96c830168b8f0b584294d3b9716bb8584c2d8","scriptpubkey_asm":"OP_0 OP_PUSHBYTES_20 65c96c830168b8f0b584294d3b9716bb8584c2d8","scriptpubkey_type":"v0_p2wpkh","scriptpubkey_address":"bc1qvhykeqcpdzu0pdvy99xnh9ckhwzcfskct6h6l2","value":263216},"scriptsig":"","scriptsig_asm":"","witness":["3045022100a2f0ac810ce88625890f7e212d175eb1cd6b7c73ffed95a2bec06b38e0b2de060220036675c6a5c89845988cc27e7acba772e7655f2abb0575449471d8323d5900b301","026b815dddaf1687a05349d75d25911c9b6e2381e55ba72148009cfa0a577c89d9"],"is_coinbase":false,"sequence":0},{"txid":"6d6766c283093e2d043ae877bb915175b3d8672a20f0459300267aaab1b5766a","vout":0,"prevout":{"scriptpubkey":"001485b33c1937058ed08b5b122e30caf18e67ccb282","scriptpubkey_asm":"OP_0 OP_PUSHBYTES_20 85b33c1937058ed08b5b122e30caf18e67ccb282","scriptpubkey_type":"v0_p2wpkh","scriptpubkey_address":"bc1qskencxfhqk8dpz6mzghrpjh33enuev5zh0mrjw","value":33247},"scriptsig":"","scriptsig_asm":"","witness":["304402200272cac1a312aae2a4ee64150e5b26e611a56509a467176e38c905b632d3ce56022005497d0d3ff14911214cb0fbb22a1aa16830ba669f6ff38723684750ceb4b11a01","0397d3b72557bd2044508ee3b22d1216b3f871c0963500f8c8dc6a143ee7a6a206"],"is_coinbase":false,"sequence":0},{"txid":"81af33ae00a9dadeb83b915b05742e986a470fff7456540e3f018deb94abda0e","vout":1,"prevout":{"scriptpubkey":"001431505647092347abb0e4d2a34f6773b74a999d45","scriptpubkey_asm":"OP_0 OP_PUSHBYTES_20 31505647092347abb0e4d2a34f6773b74a999d45","scriptpubkey_type":"v0_p2wpkh","scriptpubkey_address":"bc1qx9g9v3cfydr6hv8y62357emnka9fn8294e73yl","value":172952},"scriptsig":"","scriptsig_asm":"","witness":["30450221008426c1b3d535f10c7cbccec6be3ea9be3514f3a86bf234584722665325283f35022010b6a617a465d1d7eea45562632f0ab80b0894da44b67fab65191a98fd9d3acb01","0221250914423379d3caf662297e8069621ca2c362cf92107388483929f4d9eb67"],"is_coinbase":false,"sequence":0}],"vout":[{"scriptpubkey":"001459c70c09f22b1bb007439af43b6809d6a2bc31b5","scriptpubkey_asm":"OP_0 OP_PUSHBYTES_20 59c70c09f22b1bb007439af43b6809d6a2bc31b5","scriptpubkey_type":"v0_p2wpkh","scriptpubkey_address":"bc1qt8rscz0j9vdmqp6rnt6rk6qf663tcvd44f6gxa","value":2920},{"scriptpubkey":"00202c404e6e9c4d032267a29a6074c5db9333c6ccae0c9d430ced666316233d8c2f","scriptpubkey_asm":"OP_0 OP_PUSHBYTES_32 2c404e6e9c4d032267a29a6074c5db9333c6ccae0c9d430ced666316233d8c2f","scriptpubkey_type":"v0_p2wsh","scriptpubkey_address":"bc1q93qyum5uf5pjyeaznfs8f3wmjveudn9wpjw5xr8dve33vgea3shs9jhvww","value":442557}],"size":532,"weight":1153,"fee":23938,"status":{"confirmed":true,"block_height":674358,"block_hash":"00000000000000000004c6171622f56692cc480d3c76ecae4355e69699a6ae44","block_time":1615595727}},{"txid":"07c9d3fbffc20f96ea7c93ef3bcdf346c8a8456c25850ea76be62b24a7cf690c","version":2,"locktime":0,"vin":[{"txid":"9332d8d11d81c3b674caff75db5543491e7f22e619ecc034bedf4a007518fe3a","vout":0,"prevout":{"scriptpubkey":"001415f0dad74806b03612687038d4f5bab200afcf8e","scriptpubkey_asm":"OP_0 OP_PUSHBYTES_20 15f0dad74806b03612687038d4f5bab200afcf8e","scriptpubkey_type":"v0_p2wpkh","scriptpubkey_address":"bc1qzhcd446gq6crvyngwqudfad6kgq2lnuw9r2a86","value":470675},"scriptsig":"","scriptsig_asm":"","witness":["3045022100f30d84532f96b5e489047174e81394883cd519d427ca8f4facc2366f718cc678022007c083634402f40708c645cd0c1a2757b56de2076ca6ee856e514859381cd93801","02942b44eb4289e3af0aeeb73dfa82b0a5c8a3a06ae85bfd22aa3dcfcd64096462"],"is_coinbase":false,"sequence":0},{"txid":"c62da0c2d1929ab2a2c04d4fbae2a6e4e947f867cba584d1f80c4a1a62f4a75f","vout":1,"prevout":{"scriptpubkey":"0014f0c1d6b471d5e4a483fc146d4220a4e81587bf11","scriptpubkey_asm":"OP_0 OP_PUSHBYTES_20 f0c1d6b471d5e4a483fc146d4220a4e81587bf11","scriptpubkey_type":"v0_p2wpkh","scriptpubkey_address":"bc1q7rqaddr36hj2fqluz3k5yg9yaq2c00c3tw4qy5","value":899778},"scriptsig":"","scriptsig_asm":"","witness":["304402202da0eac25786003181526c4fe1592f982aa8d0f32c642a5103cdebbf4aa8b5a80220750cd6859bfb9a7df8d7c4d79a70e17a6df87f150fe1fdaade4650332ef0f47c01","02ecab80fcfe949633064c25fc33854fd09b8730decdf679db1f429bce201ec685"],"is_coinbase":false,"sequence":0}],"vout":[{"scriptpubkey":"001465c96c830168b8f0b584294d3b9716bb8584c2d8","scriptpubkey_asm":"OP_0 OP_PUSHBYTES_20 65c96c830168b8f0b584294d3b9716bb8584c2d8","scriptpubkey_type":"v0_p2wpkh","scriptpubkey_address":"bc1qvhykeqcpdzu0pdvy99xnh9ckhwzcfskct6h6l2","value":263216},{"scriptpubkey":"00200cea60ae9eea43e64b17ba65a4c17bd3acf9dac307825deda85d5a093181dbc0","scriptpubkey_asm":"OP_0 OP_PUSHBYTES_32 0cea60ae9eea43e64b17ba65a4c17bd3acf9dac307825deda85d5a093181dbc0","scriptpubkey_type":"v0_p2wsh","scriptpubkey_address":"bc1qpn4xpt57afp7vjchhfj6fstm6wk0nkkrq7p9mmdgt4dqjvvpm0qqlxqrns","value":1088924}],"size":383,"weight":881,"fee":18313,"status":{"confirmed":true,"block_height":674357,"block_hash":"00000000000000000008d0d007995a8bc9d60de17bd6b55e28a6e4c6918cb206","block_time":1615594996}}]"#;
474 let transaction_outspends_response_body = r#"[{"spent":true,"txid":"4da22eff957b855c8bde2d8b61bdb9e10add799a04c709dd7142cc796cee0b65","vin":1,"status":{"confirmed":true,"block_height":674365,"block_hash":"000000000000000000038f780364221846a3c11e2a5b33eee69029afe5775a0f","block_time":1615598852}},{"spent":true,"txid":"61585c400d8cfe490d3d3c6e1e3177edb9b6f43e337772530ab32ea4e54db3b4","vin":0,"status":{"confirmed":true,"block_height":797168,"block_hash":"0000000000000000000569b9dca483f10ed6c2bf9245b5a9b45519dd4f3dd40d","block_time":1688489603}}]"#;
475 mock_rest_client.add_response(MockResponse::new(
476 200,
477 address_transactions_response_body.to_string(),
478 ));
479 mock_rest_client.add_response(MockResponse::new(
480 200,
481 transaction_outspends_response_body.to_string(),
482 ));
483 mock_rest_client.add_response(MockResponse::new(404, "".to_string()));
484
485 let rest_client: Arc<dyn RestClient> = Arc::new(mock_rest_client);
486
487 let ms = MempoolSpace::new(rest_client);
488 let txs = ms
489 .address_transactions("bc1qvhykeqcpdzu0pdvy99xnh9ckhwzcfskct6h6l2".to_string())
490 .await?;
491 let serialized_res = serde_json::to_string(&txs)?;
492
493 let expected_txs: Vec<OnchainTx> =
494 serde_json::from_str(address_transactions_response_body)?;
495 let expected_serialized = serde_json::to_string(&expected_txs)?;
496
497 assert_eq!(expected_serialized, serialized_res);
498
499 let outspends = ms
500 .transaction_outspends(
501 "5e0668bf1cd24f2f8656ee82d4886f5303a06b26838e24b7db73afc59e228985".to_string(),
502 )
503 .await?;
504 assert_eq!(outspends.len(), 2);
505
506 let outspends = ms
507 .transaction_outspends(
508 "07c9d3fbffc20f96ea7c93ef3bcdf346c8a8456c25850ea76be62b24a7cf6901".to_string(),
509 )
510 .await;
511 match outspends {
512 Ok(_) => panic!("Expected an error"),
513 Err(e) => match e {
514 SdkError::ServiceConnectivity { err } => {
515 assert_eq!(err, "GET request https://mempool.space/api/tx/07c9d3fbffc20f96ea7c93ef3bcdf346c8a8456c25850ea76be62b24a7cf6901/outspends failed with status: 404")
516 }
517 _ => panic!("Expected a service connectivity error"),
518 },
519 };
520
521 Ok(())
522 }
523
524 }