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