Skip to main content

bsv_wallet_toolbox/services/
services.rs

1//! Services struct implementing WalletServices trait.
2//!
3//! Wires together all providers (WhatsOnChain, ARC, Bitails, ChainTracker)
4//! into a single struct that consumers use via `Arc<dyn WalletServices>`.
5//!
6//! Ported from wallet-toolbox/src/services/Services.ts.
7
8use std::time::{Duration, SystemTime, UNIX_EPOCH};
9
10use async_trait::async_trait;
11use bsv::transaction::chain_tracker::ChainTracker;
12use bsv::transaction::Beef;
13use chrono::Utc;
14use tokio::sync::Mutex;
15
16use crate::error::{WalletError, WalletResult};
17use crate::types::Chain;
18
19use super::chaintracker::{ChaintracksChainTracker, ChaintracksServiceClient};
20use super::providers::exchange_rates::{fetch_bsv_exchange_rate, fetch_fiat_exchange_rates};
21use super::providers::{ArcProvider, Bitails, WhatsOnChain};
22use super::service_collection::ServiceCollection;
23use super::traits::{
24    GetMerklePathProvider, GetRawTxProvider, GetScriptHashHistoryProvider,
25    GetStatusForTxidsProvider, GetUtxoStatusProvider, PostBeefProvider, WalletServices,
26};
27use super::types::{
28    BlockHeader, BsvExchangeRate, FiatExchangeRates, GetMerklePathResult, GetRawTxResult,
29    GetScriptHashHistoryResult, GetStatusForTxidsResult, GetUtxoStatusOutputFormat,
30    GetUtxoStatusResult, NLockTimeInput, PostBeefMode, PostBeefResult, ServiceCall,
31    ServicesCallHistory, ServicesConfig,
32};
33use bsv::transaction::beef::BEEF_V1;
34
35/// The main services orchestrator struct.
36///
37/// Owns all provider instances and `ServiceCollection`s, exposes the
38/// `WalletServices` trait for consumption by Wallet/Monitor.
39pub struct Services {
40    config: ServicesConfig,
41    client: reqwest::Client,
42    get_merkle_path: Mutex<ServiceCollection<dyn GetMerklePathProvider>>,
43    get_raw_tx: Mutex<ServiceCollection<dyn GetRawTxProvider>>,
44    post_beef: Mutex<ServiceCollection<dyn PostBeefProvider>>,
45    get_utxo_status: Mutex<ServiceCollection<dyn GetUtxoStatusProvider>>,
46    get_status_for_txids: Mutex<ServiceCollection<dyn GetStatusForTxidsProvider>>,
47    get_script_hash_history: Mutex<ServiceCollection<dyn GetScriptHashHistoryProvider>>,
48    chain_tracker: ChaintracksChainTracker,
49    post_beef_mode: PostBeefMode,
50    bsv_exchange_rate: Mutex<BsvExchangeRate>,
51    fiat_exchange_rates: Mutex<FiatExchangeRates>,
52}
53
54impl Services {
55    /// Create a Services instance from a full configuration.
56    pub fn from_config(config: ServicesConfig) -> Self {
57        let client = reqwest::Client::new();
58        let chain = config.chain.clone();
59
60        let has_bitails = matches!(chain, Chain::Main | Chain::Test);
61
62        // -- getMerklePath collection --
63        let mut get_merkle_path_coll =
64            ServiceCollection::<dyn GetMerklePathProvider>::new("getMerklePath");
65        // Need a second WoC for getMerklePath since the first is consumed by getRawTx etc.
66        let woc_merkle = WhatsOnChain::new(
67            chain.clone(),
68            config.whats_on_chain_api_key.clone(),
69            client.clone(),
70        );
71        get_merkle_path_coll.add("WhatsOnChain", Box::new(woc_merkle));
72        if has_bitails {
73            let bitails = Bitails::new(
74                chain.clone(),
75                config.bitails_api_key.clone(),
76                client.clone(),
77            );
78            get_merkle_path_coll.add("Bitails", Box::new(bitails));
79        }
80
81        // -- getRawTx collection --
82        let mut get_raw_tx_coll = ServiceCollection::<dyn GetRawTxProvider>::new("getRawTx");
83        let woc_raw = WhatsOnChain::new(
84            chain.clone(),
85            config.whats_on_chain_api_key.clone(),
86            client.clone(),
87        );
88        get_raw_tx_coll.add("WhatsOnChain", Box::new(woc_raw));
89
90        // -- postBeef collection --
91        // Order matches TS: GorillaPool (if main), Taal, Bitails (if main/test), WoC
92        let mut post_beef_coll = ServiceCollection::<dyn PostBeefProvider>::new("postBeef");
93
94        if let Some(ref gp_url) = config.arc_gorilla_pool_url {
95            let gp_config = config.arc_gorilla_pool_config.clone().unwrap_or_default();
96            let arc_gp = ArcProvider::new("GorillaPoolArcBeef", gp_url, gp_config, client.clone());
97            post_beef_coll.add("GorillaPoolArcBeef", Box::new(arc_gp));
98        }
99
100        let arc_taal = ArcProvider::new(
101            "TaalArcBeef",
102            &config.arc_url,
103            config.arc_config.clone(),
104            client.clone(),
105        );
106        post_beef_coll.add("TaalArcBeef", Box::new(arc_taal));
107
108        if has_bitails {
109            let bitails_beef = Bitails::new(
110                chain.clone(),
111                config.bitails_api_key.clone(),
112                client.clone(),
113            );
114            post_beef_coll.add("Bitails", Box::new(bitails_beef));
115        }
116
117        let woc_beef = WhatsOnChain::new(
118            chain.clone(),
119            config.whats_on_chain_api_key.clone(),
120            client.clone(),
121        );
122        post_beef_coll.add("WhatsOnChain", Box::new(woc_beef));
123
124        // -- getUtxoStatus collection --
125        let mut get_utxo_status_coll =
126            ServiceCollection::<dyn GetUtxoStatusProvider>::new("getUtxoStatus");
127        let woc_utxo = WhatsOnChain::new(
128            chain.clone(),
129            config.whats_on_chain_api_key.clone(),
130            client.clone(),
131        );
132        get_utxo_status_coll.add("WhatsOnChain", Box::new(woc_utxo));
133
134        // -- getStatusForTxids collection --
135        let mut get_status_for_txids_coll =
136            ServiceCollection::<dyn GetStatusForTxidsProvider>::new("getStatusForTxids");
137        let woc_status = WhatsOnChain::new(
138            chain.clone(),
139            config.whats_on_chain_api_key.clone(),
140            client.clone(),
141        );
142        get_status_for_txids_coll.add("WhatsOnChain", Box::new(woc_status));
143
144        // -- getScriptHashHistory collection --
145        let mut get_script_hash_history_coll =
146            ServiceCollection::<dyn GetScriptHashHistoryProvider>::new("getScriptHashHistory");
147        let woc_history = WhatsOnChain::new(
148            chain.clone(),
149            config.whats_on_chain_api_key.clone(),
150            client.clone(),
151        );
152        get_script_hash_history_coll.add("WhatsOnChain", Box::new(woc_history));
153
154        // -- ChainTracker --
155        let chaintracks_url = config.chaintracks_url.as_deref();
156        let service_client =
157            ChaintracksServiceClient::new(chain.clone(), chaintracks_url, client.clone());
158        let chain_tracker = ChaintracksChainTracker::new(service_client);
159
160        Services {
161            bsv_exchange_rate: Mutex::new(config.bsv_exchange_rate.clone()),
162            fiat_exchange_rates: Mutex::new(config.fiat_exchange_rates.clone()),
163            config,
164            client,
165            get_merkle_path: Mutex::new(get_merkle_path_coll),
166            get_raw_tx: Mutex::new(get_raw_tx_coll),
167            post_beef: Mutex::new(post_beef_coll),
168            get_utxo_status: Mutex::new(get_utxo_status_coll),
169            get_status_for_txids: Mutex::new(get_status_for_txids_coll),
170            get_script_hash_history: Mutex::new(get_script_hash_history_coll),
171            chain_tracker,
172            post_beef_mode: PostBeefMode::UntilSuccess,
173        }
174    }
175
176    /// Create Services from a chain with default configuration.
177    pub fn from_chain(chain: Chain) -> Self {
178        let config = ServicesConfig::from(chain);
179        Self::from_config(config)
180    }
181
182    /// Access the underlying config.
183    pub fn config(&self) -> &ServicesConfig {
184        &self.config
185    }
186
187    /// Access the shared HTTP client.
188    pub fn client(&self) -> &reqwest::Client {
189        &self.client
190    }
191
192    /// Set the postBeef mode.
193    pub fn set_post_beef_mode(&mut self, mode: PostBeefMode) {
194        self.post_beef_mode = mode;
195    }
196}
197
198impl From<Chain> for Services {
199    fn from(chain: Chain) -> Self {
200        Services::from_chain(chain)
201    }
202}
203
204// ---------------------------------------------------------------------------
205// WalletServices trait implementation
206// ---------------------------------------------------------------------------
207
208#[async_trait]
209impl WalletServices for Services {
210    fn chain(&self) -> Chain {
211        self.config.chain.clone()
212    }
213
214    async fn get_chain_tracker(&self) -> WalletResult<Box<dyn ChainTracker>> {
215        let chaintracks_url = self.config.chaintracks_url.as_deref();
216        let service_client = ChaintracksServiceClient::new(
217            self.config.chain.clone(),
218            chaintracks_url,
219            self.client.clone(),
220        );
221        Ok(Box::new(ChaintracksChainTracker::new(service_client)))
222    }
223
224    async fn get_merkle_path(&self, txid: &str, use_next: bool) -> GetMerklePathResult {
225        let mut coll = self.get_merkle_path.lock().await;
226        if use_next {
227            coll.next();
228        }
229
230        let count = coll.len();
231        let mut r0 = GetMerklePathResult::default();
232
233        for _tries in 0..count {
234            let (provider, provider_name) = match coll.service_to_call() {
235                Some(p) => p,
236                None => break,
237            };
238            let provider_name = provider_name.to_string();
239
240            let start = Utc::now();
241            let result = provider.get_merkle_path(txid, self).await;
242            let elapsed = Utc::now().signed_duration_since(start).num_milliseconds();
243
244            if r0.name.is_none() {
245                r0.name = result.name.clone();
246            }
247
248            if result.merkle_path.is_some() {
249                let call = ServiceCall {
250                    when: start,
251                    msecs: elapsed,
252                    success: true,
253                    result: None,
254                    error: None,
255                };
256                coll.add_service_call_success(&provider_name, call, None);
257                return result;
258            }
259
260            if let Some(ref err_str) = result.error {
261                let call = ServiceCall {
262                    when: start,
263                    msecs: elapsed,
264                    success: false,
265                    result: None,
266                    error: None,
267                };
268                let err = WalletError::Internal(err_str.clone());
269                coll.add_service_call_error(&provider_name, call, &err);
270                if r0.error.is_none() {
271                    r0.error = result.error.clone();
272                }
273            } else {
274                let call = ServiceCall {
275                    when: start,
276                    msecs: elapsed,
277                    success: false,
278                    result: None,
279                    error: None,
280                };
281                coll.add_service_call_failure(&provider_name, call);
282            }
283
284            coll.next();
285        }
286
287        r0
288    }
289
290    async fn get_raw_tx(&self, txid: &str, use_next: bool) -> GetRawTxResult {
291        let mut coll = self.get_raw_tx.lock().await;
292        if use_next {
293            coll.next();
294        }
295
296        let count = coll.len();
297        let mut r0 = GetRawTxResult {
298            txid: txid.to_string(),
299            ..Default::default()
300        };
301
302        for _tries in 0..count {
303            let (provider, provider_name) = match coll.service_to_call() {
304                Some(p) => p,
305                None => break,
306            };
307            let provider_name = provider_name.to_string();
308
309            let start = Utc::now();
310            let result = provider.get_raw_tx(txid).await;
311            let elapsed = Utc::now().signed_duration_since(start).num_milliseconds();
312
313            if result.raw_tx.is_some() && result.error.is_none() {
314                let call = ServiceCall {
315                    when: start,
316                    msecs: elapsed,
317                    success: true,
318                    result: None,
319                    error: None,
320                };
321                coll.add_service_call_success(&provider_name, call, None);
322                return result;
323            }
324
325            if let Some(ref err_str) = result.error {
326                let call = ServiceCall {
327                    when: start,
328                    msecs: elapsed,
329                    success: false,
330                    result: None,
331                    error: None,
332                };
333                let err = WalletError::Internal(err_str.clone());
334                coll.add_service_call_error(&provider_name, call, &err);
335                if r0.error.is_none() {
336                    r0.error = result.error.clone();
337                }
338            } else if result.raw_tx.is_none() {
339                // Not found -- still a success for the provider
340                let call = ServiceCall {
341                    when: start,
342                    msecs: elapsed,
343                    success: true,
344                    result: Some("not found".to_string()),
345                    error: None,
346                };
347                coll.add_service_call_success(&provider_name, call, Some("not found".to_string()));
348            } else {
349                let call = ServiceCall {
350                    when: start,
351                    msecs: elapsed,
352                    success: false,
353                    result: None,
354                    error: None,
355                };
356                coll.add_service_call_failure(&provider_name, call);
357            }
358
359            coll.next();
360        }
361
362        r0
363    }
364
365    async fn post_beef(&self, beef: &[u8], txids: &[String]) -> Vec<PostBeefResult> {
366        // Implemented in Task 2
367        self.post_beef_impl(beef, txids).await
368    }
369
370    async fn get_utxo_status(
371        &self,
372        output: &str,
373        output_format: Option<GetUtxoStatusOutputFormat>,
374        outpoint: Option<&str>,
375        use_next: bool,
376    ) -> GetUtxoStatusResult {
377        let mut coll = self.get_utxo_status.lock().await;
378        if use_next {
379            coll.next();
380        }
381
382        let count = coll.len();
383        let mut r0 = GetUtxoStatusResult {
384            name: "<noservices>".to_string(),
385            status: "error".to_string(),
386            error: Some("WERR_INTERNAL: No services available.".to_string()),
387            is_utxo: None,
388            details: Vec::new(),
389        };
390
391        // Double retry loop (2 outer retries with 2000ms wait) matching TS
392        for _retry in 0..2u32 {
393            for _tries in 0..count {
394                let (provider, provider_name) = match coll.service_to_call() {
395                    Some(p) => p,
396                    None => break,
397                };
398                let provider_name = provider_name.to_string();
399
400                let start = Utc::now();
401                let result = provider
402                    .get_utxo_status(output, output_format.clone(), outpoint)
403                    .await;
404                let elapsed = Utc::now().signed_duration_since(start).num_milliseconds();
405
406                if result.status == "success" {
407                    let call = ServiceCall {
408                        when: start,
409                        msecs: elapsed,
410                        success: true,
411                        result: None,
412                        error: None,
413                    };
414                    coll.add_service_call_success(&provider_name, call, None);
415                    return result;
416                }
417
418                if let Some(ref err_str) = result.error {
419                    let call = ServiceCall {
420                        when: start,
421                        msecs: elapsed,
422                        success: false,
423                        result: None,
424                        error: None,
425                    };
426                    let err = WalletError::Internal(err_str.clone());
427                    coll.add_service_call_error(&provider_name, call, &err);
428                } else {
429                    let call = ServiceCall {
430                        when: start,
431                        msecs: elapsed,
432                        success: false,
433                        result: None,
434                        error: None,
435                    };
436                    coll.add_service_call_failure(&provider_name, call);
437                }
438
439                r0 = result;
440                coll.next();
441            }
442
443            if r0.status == "success" {
444                break;
445            }
446            tokio::time::sleep(Duration::from_millis(2000)).await;
447        }
448
449        r0
450    }
451
452    async fn get_status_for_txids(
453        &self,
454        txids: &[String],
455        use_next: bool,
456    ) -> GetStatusForTxidsResult {
457        let mut coll = self.get_status_for_txids.lock().await;
458        if use_next {
459            coll.next();
460        }
461
462        let count = coll.len();
463        let mut r0 = GetStatusForTxidsResult {
464            name: "<noservices>".to_string(),
465            status: "error".to_string(),
466            error: Some("WERR_INTERNAL: No services available.".to_string()),
467            results: Vec::new(),
468        };
469
470        for _tries in 0..count {
471            let (provider, provider_name) = match coll.service_to_call() {
472                Some(p) => p,
473                None => break,
474            };
475            let provider_name = provider_name.to_string();
476
477            let start = Utc::now();
478            let result = provider.get_status_for_txids(txids).await;
479            let elapsed = Utc::now().signed_duration_since(start).num_milliseconds();
480
481            if result.status == "success" {
482                let call = ServiceCall {
483                    when: start,
484                    msecs: elapsed,
485                    success: true,
486                    result: None,
487                    error: None,
488                };
489                coll.add_service_call_success(&provider_name, call, None);
490                return result;
491            }
492
493            if let Some(ref err_str) = result.error {
494                let call = ServiceCall {
495                    when: start,
496                    msecs: elapsed,
497                    success: false,
498                    result: None,
499                    error: None,
500                };
501                let err = WalletError::Internal(err_str.clone());
502                coll.add_service_call_error(&provider_name, call, &err);
503            } else {
504                let call = ServiceCall {
505                    when: start,
506                    msecs: elapsed,
507                    success: false,
508                    result: None,
509                    error: None,
510                };
511                coll.add_service_call_failure(&provider_name, call);
512            }
513
514            r0 = result;
515            coll.next();
516        }
517
518        r0
519    }
520
521    async fn get_script_hash_history(
522        &self,
523        hash: &str,
524        use_next: bool,
525    ) -> GetScriptHashHistoryResult {
526        let mut coll = self.get_script_hash_history.lock().await;
527        if use_next {
528            coll.next();
529        }
530
531        let count = coll.len();
532        let mut r0 = GetScriptHashHistoryResult {
533            name: "<noservices>".to_string(),
534            status: "error".to_string(),
535            error: Some("WERR_INTERNAL: No services available.".to_string()),
536            history: Vec::new(),
537        };
538
539        for _tries in 0..count {
540            let (provider, provider_name) = match coll.service_to_call() {
541                Some(p) => p,
542                None => break,
543            };
544            let provider_name = provider_name.to_string();
545
546            let start = Utc::now();
547            let result = provider.get_script_hash_history(hash).await;
548            let elapsed = Utc::now().signed_duration_since(start).num_milliseconds();
549
550            if result.status == "success" {
551                let call = ServiceCall {
552                    when: start,
553                    msecs: elapsed,
554                    success: true,
555                    result: None,
556                    error: None,
557                };
558                coll.add_service_call_success(&provider_name, call, None);
559                return result;
560            }
561
562            if let Some(ref err_str) = result.error {
563                let call = ServiceCall {
564                    when: start,
565                    msecs: elapsed,
566                    success: false,
567                    result: None,
568                    error: None,
569                };
570                let err = WalletError::Internal(err_str.clone());
571                coll.add_service_call_error(&provider_name, call, &err);
572            } else {
573                let call = ServiceCall {
574                    when: start,
575                    msecs: elapsed,
576                    success: false,
577                    result: None,
578                    error: None,
579                };
580                coll.add_service_call_failure(&provider_name, call);
581            }
582
583            r0 = result;
584            coll.next();
585        }
586
587        r0
588    }
589
590    async fn hash_to_header(&self, hash: &str) -> WalletResult<BlockHeader> {
591        self.chain_tracker.hash_to_header(hash).await
592    }
593
594    async fn get_header_for_height(&self, height: u32) -> WalletResult<Vec<u8>> {
595        let header = self.chain_tracker.get_header_for_height(height).await?;
596        Ok(serialize_block_header(&header))
597    }
598
599    async fn get_height(&self) -> WalletResult<u32> {
600        use bsv::transaction::chain_tracker::ChainTracker as _;
601        self.chain_tracker
602            .current_height()
603            .await
604            .map_err(|e| WalletError::Internal(format!("ChainTracker error: {}", e)))
605    }
606
607    async fn n_lock_time_is_final(&self, input: NLockTimeInput) -> WalletResult<bool> {
608        const MAXINT: u32 = 0xFFFF_FFFF;
609        const BLOCK_LIMIT: u32 = 500_000_000;
610
611        let n_lock_time = match input {
612            NLockTimeInput::Raw(locktime) => locktime,
613            NLockTimeInput::Transaction(tx) => {
614                // If all input sequences are MAXINT, transaction is final regardless
615                if tx.inputs.iter().all(|i| i.sequence == MAXINT) {
616                    return Ok(true);
617                }
618                tx.lock_time
619            }
620        };
621
622        if n_lock_time == 0 {
623            return Ok(true);
624        }
625
626        if n_lock_time >= BLOCK_LIMIT {
627            // Unix timestamp mode: compare to current time
628            let now_secs = SystemTime::now()
629                .duration_since(UNIX_EPOCH)
630                .unwrap_or_default()
631                .as_secs() as u32;
632            return Ok(n_lock_time < now_secs);
633        }
634
635        // Block height mode: compare to current chain height
636        let height = self.get_height().await?;
637        Ok(n_lock_time < height)
638    }
639
640    async fn get_bsv_exchange_rate(&self) -> WalletResult<BsvExchangeRate> {
641        self.get_bsv_exchange_rate_impl().await
642    }
643
644    async fn get_fiat_exchange_rate(
645        &self,
646        currency: &str,
647        base: Option<&str>,
648    ) -> WalletResult<f64> {
649        self.get_fiat_exchange_rate_impl(currency, base).await
650    }
651
652    async fn get_fiat_exchange_rates(
653        &self,
654        target_currencies: &[String],
655    ) -> WalletResult<FiatExchangeRates> {
656        self.get_fiat_exchange_rates_impl(target_currencies).await
657    }
658
659    fn get_services_call_history(&self, _reset: bool) -> ServicesCallHistory {
660        // This needs to be sync but our mutexes are tokio::sync::Mutex.
661        // We use try_lock; if contended, return empty history.
662        let mut services = Vec::new();
663
664        if let Ok(mut coll) = self.get_merkle_path.try_lock() {
665            services.push(coll.get_service_call_history(_reset));
666        }
667        if let Ok(mut coll) = self.get_raw_tx.try_lock() {
668            services.push(coll.get_service_call_history(_reset));
669        }
670        if let Ok(mut coll) = self.post_beef.try_lock() {
671            services.push(coll.get_service_call_history(_reset));
672        }
673        if let Ok(mut coll) = self.get_utxo_status.try_lock() {
674            services.push(coll.get_service_call_history(_reset));
675        }
676        if let Ok(mut coll) = self.get_status_for_txids.try_lock() {
677            services.push(coll.get_service_call_history(_reset));
678        }
679        if let Ok(mut coll) = self.get_script_hash_history.try_lock() {
680            services.push(coll.get_service_call_history(_reset));
681        }
682
683        ServicesCallHistory { services }
684    }
685
686    async fn get_beef_for_txid(&self, txid: &str) -> WalletResult<Beef> {
687        let raw_result = self.get_raw_tx(txid, false).await;
688        let raw_tx = raw_result.raw_tx.ok_or_else(|| {
689            WalletError::Internal(format!(
690                "Could not retrieve raw transaction for txid {}",
691                txid
692            ))
693        })?;
694
695        // Parse the raw transaction
696        let tx = bsv::transaction::Transaction::from_binary(&mut std::io::Cursor::new(&raw_tx))
697            .map_err(|e| {
698                WalletError::Internal(format!("Failed to parse transaction {}: {}", txid, e))
699            })?;
700
701        // Construct a simple Beef containing just this transaction
702        let beef_tx = bsv::transaction::beef_tx::BeefTx {
703            txid: txid.to_string(),
704            tx: Some(tx),
705            bump_index: None,
706            input_txids: Vec::new(),
707        };
708        let mut beef = Beef::new(BEEF_V1);
709        beef.txs.push(beef_tx);
710
711        Ok(beef)
712    }
713
714    fn hash_output_script(&self, script: &[u8]) -> String {
715        // SHA-256 the script bytes, then reverse to big-endian hex
716        let hash = bsv::primitives::hash::sha256(script);
717        let mut bytes = hash.to_vec();
718        bytes.reverse();
719        let mut hex = String::with_capacity(bytes.len() * 2);
720        for b in &bytes {
721            hex.push_str(&format!("{:02x}", b));
722        }
723        hex
724    }
725
726    async fn is_utxo(&self, locking_script: &[u8], txid: &str, vout: u32) -> WalletResult<bool> {
727        let hash = self.hash_output_script(locking_script);
728        let outpoint = format!("{}.{}", txid, vout);
729        let result = self
730            .get_utxo_status(&hash, None, Some(&outpoint), false)
731            .await;
732        Ok(result.is_utxo == Some(true))
733    }
734}
735
736// ---------------------------------------------------------------------------
737// postBeef orchestration and exchange rate caching
738// ---------------------------------------------------------------------------
739
740impl Services {
741    /// Post BEEF with UntilSuccess or PromiseAll orchestration.
742    async fn post_beef_impl(&self, beef: &[u8], txids: &[String]) -> Vec<PostBeefResult> {
743        let soft_timeout_ms = self.config.get_post_beef_soft_timeout_ms(beef.len());
744
745        match self.post_beef_mode {
746            PostBeefMode::UntilSuccess => {
747                self.post_beef_until_success(beef, txids, soft_timeout_ms)
748                    .await
749            }
750            PostBeefMode::PromiseAll => self.post_beef_promise_all(beef, txids).await,
751        }
752    }
753
754    /// UntilSuccess mode: try each provider sequentially with adaptive timeout.
755    async fn post_beef_until_success(
756        &self,
757        beef: &[u8],
758        txids: &[String],
759        soft_timeout_ms: u64,
760    ) -> Vec<PostBeefResult> {
761        let mut results: Vec<PostBeefResult> = Vec::new();
762
763        // Collect all provider names and references upfront, then release lock
764        let provider_names: Vec<String> = {
765            let coll = self.post_beef.lock().await;
766            coll.all_services()
767                .map(|(_, name)| name.to_string())
768                .collect()
769        };
770
771        for _provider_name in &provider_names {
772            // Get the current provider from the collection
773            let (prov_name, prov_ref_result) = {
774                let coll = self.post_beef.lock().await;
775                match coll.service_to_call() {
776                    Some((_provider, name)) => {
777                        let name = name.to_string();
778                        (name, true)
779                    }
780                    None => (String::new(), false),
781                }
782            };
783
784            if !prov_ref_result {
785                break;
786            }
787
788            // Call provider with soft timeout, releasing the mutex
789            let start = Utc::now();
790            let result = {
791                let coll = self.post_beef.lock().await;
792                match coll.service_to_call() {
793                    Some((provider, _)) => {
794                        let beef_owned = beef.to_vec();
795                        let txids_owned = txids.to_vec();
796                        // We need to call the provider without holding the lock.
797                        // Unfortunately, the provider is behind a reference tied to
798                        // the MutexGuard. We must hold the lock for the call duration
799                        // in UntilSuccess mode since we only call one at a time.
800                        if soft_timeout_ms > 0 {
801                            match tokio::time::timeout(
802                                Duration::from_millis(soft_timeout_ms),
803                                provider.post_beef(&beef_owned, &txids_owned),
804                            )
805                            .await
806                            {
807                                Ok(r) => r,
808                                Err(_) => {
809                                    PostBeefResult::timeout(&prov_name, txids, soft_timeout_ms)
810                                }
811                            }
812                        } else {
813                            provider.post_beef(&beef_owned, &txids_owned).await
814                        }
815                    }
816                    None => break,
817                }
818            };
819            let elapsed = Utc::now().signed_duration_since(start).num_milliseconds();
820
821            let is_success = result.status == "success";
822            let is_timeout = result
823                .error
824                .as_ref()
825                .map(|e| e.contains("timeout"))
826                .unwrap_or(false);
827
828            // Record call history
829            {
830                let mut coll = self.post_beef.lock().await;
831                let call = ServiceCall {
832                    when: start,
833                    msecs: elapsed,
834                    success: is_success,
835                    result: None,
836                    error: None,
837                };
838                if is_success {
839                    coll.add_service_call_success(&prov_name, call, None);
840                } else if let Some(ref err_str) = result.error {
841                    let err = WalletError::Internal(err_str.clone());
842                    coll.add_service_call_error(&prov_name, call, &err);
843                } else {
844                    coll.add_service_call_failure(&prov_name, call);
845                }
846            }
847
848            results.push(result);
849
850            if is_success {
851                break;
852            }
853
854            // On non-timeout service error, move provider to last
855            if !is_timeout {
856                let mut coll = self.post_beef.lock().await;
857                let all_service_error = results
858                    .last()
859                    .map(|r| {
860                        r.txid_results
861                            .iter()
862                            .all(|tr| tr.service_error == Some(true))
863                    })
864                    .unwrap_or(false);
865                if all_service_error {
866                    coll.move_service_to_last(&prov_name);
867                }
868            }
869
870            // Advance to next provider
871            {
872                let mut coll = self.post_beef.lock().await;
873                coll.next();
874            }
875        }
876
877        if results.is_empty() {
878            vec![PostBeefResult {
879                name: "<noservices>".to_string(),
880                status: "error".to_string(),
881                error: Some("No postBeef services available".to_string()),
882                txid_results: Vec::new(),
883            }]
884        } else {
885            results
886        }
887    }
888
889    /// PromiseAll mode: call all providers concurrently and collect results.
890    async fn post_beef_promise_all(&self, beef: &[u8], txids: &[String]) -> Vec<PostBeefResult> {
891        // Collect provider info while holding the lock briefly
892        let provider_count = {
893            let coll = self.post_beef.lock().await;
894            coll.len()
895        };
896
897        if provider_count == 0 {
898            return vec![PostBeefResult {
899                name: "<noservices>".to_string(),
900                status: "error".to_string(),
901                error: Some("No postBeef services available".to_string()),
902                txid_results: Vec::new(),
903            }];
904        }
905
906        // For PromiseAll we need to call all providers concurrently.
907        // We hold the lock for each individual call since each provider
908        // reference is behind the collection's mutex.
909        let beef_bytes = beef.to_vec();
910        let txids_vec = txids.to_vec();
911
912        // Since we can't easily extract providers from the collection without
913        // holding the lock, we iterate sequentially but spawn each call.
914        // With the current architecture, we call each provider one at a time
915        // from the collection (acquiring/releasing the lock for each).
916        // For true concurrency we'd need Arc<dyn PostBeefProvider> stored separately.
917        // Instead, we hold the lock and call all providers, which is the pragmatic
918        // approach matching the TS pattern (which also doesn't truly parallelize
919        // the mutex access).
920
921        let mut results = Vec::new();
922        {
923            let coll = self.post_beef.lock().await;
924            let providers: Vec<(&dyn PostBeefProvider, String)> = coll
925                .all_services()
926                .map(|(p, name)| (p, name.to_string()))
927                .collect();
928
929            // We must release the lock before calling providers... but we can't
930            // because the provider references borrow from the guard.
931            // For PromiseAll, we'll hold the lock and call sequentially.
932            // This is a known limitation; a future refactor could use Arc<dyn>.
933            for (provider, name) in &providers {
934                let start = Utc::now();
935                let result = provider.post_beef(&beef_bytes, &txids_vec).await;
936                let elapsed = Utc::now().signed_duration_since(start).num_milliseconds();
937                results.push((name.clone(), start, elapsed, result));
938            }
939        }
940
941        // Record history for all results
942        {
943            let mut coll = self.post_beef.lock().await;
944            for (name, start, elapsed, ref result) in &results {
945                let call = ServiceCall {
946                    when: *start,
947                    msecs: *elapsed,
948                    success: result.status == "success",
949                    result: None,
950                    error: None,
951                };
952                if result.status == "success" {
953                    coll.add_service_call_success(name, call, None);
954                } else if let Some(ref err_str) = result.error {
955                    let err = WalletError::Internal(err_str.clone());
956                    coll.add_service_call_error(name, call, &err);
957                } else {
958                    coll.add_service_call_failure(name, call);
959                }
960            }
961        }
962
963        results
964            .into_iter()
965            .map(|(_, _, _, result)| result)
966            .collect()
967    }
968
969    /// BSV exchange rate with caching.
970    ///
971    /// Returns cached rate if within `bsv_update_msecs`, otherwise fetches fresh.
972    async fn get_bsv_exchange_rate_impl(&self) -> WalletResult<BsvExchangeRate> {
973        let update_ms = self.config.bsv_update_msecs;
974
975        {
976            let cached = self.bsv_exchange_rate.lock().await;
977            let age_ms = Utc::now()
978                .signed_duration_since(cached.timestamp)
979                .num_milliseconds() as u64;
980            if cached.rate_usd > 0.0 && age_ms < update_ms {
981                return Ok(cached.clone());
982            }
983        }
984
985        // Fetch fresh rate
986        let rate = fetch_bsv_exchange_rate(&self.client).await?;
987        let new_rate = BsvExchangeRate {
988            timestamp: Utc::now(),
989            rate_usd: rate,
990        };
991
992        let mut cached = self.bsv_exchange_rate.lock().await;
993        *cached = new_rate.clone();
994        Ok(new_rate)
995    }
996
997    /// Single fiat exchange rate with caching.
998    async fn get_fiat_exchange_rate_impl(
999        &self,
1000        currency: &str,
1001        base: Option<&str>,
1002    ) -> WalletResult<f64> {
1003        let base = base.unwrap_or("USD");
1004        if currency == base {
1005            return Ok(1.0);
1006        }
1007
1008        // Determine which currencies we need
1009        let required: Vec<String> = if base == "USD" {
1010            vec![currency.to_string()]
1011        } else {
1012            vec![currency.to_string(), base.to_string()]
1013        };
1014
1015        // Update fiat rates (will use cache if fresh)
1016        self.update_fiat_exchange_rates(&required).await?;
1017
1018        let cached = self.fiat_exchange_rates.lock().await;
1019        let c = cached
1020            .rates
1021            .get(currency)
1022            .ok_or_else(|| WalletError::InvalidParameter {
1023                parameter: "currency".to_string(),
1024                must_be: format!("valid fiat currency '{}' with an exchange rate", currency),
1025            })?;
1026        let b = cached
1027            .rates
1028            .get(base)
1029            .ok_or_else(|| WalletError::InvalidParameter {
1030                parameter: "base".to_string(),
1031                must_be: format!("valid fiat currency '{}' with an exchange rate", base),
1032            })?;
1033
1034        Ok(c / b)
1035    }
1036
1037    /// Multiple fiat exchange rates with caching.
1038    async fn get_fiat_exchange_rates_impl(
1039        &self,
1040        target_currencies: &[String],
1041    ) -> WalletResult<FiatExchangeRates> {
1042        self.update_fiat_exchange_rates(target_currencies).await?;
1043
1044        let cached = self.fiat_exchange_rates.lock().await;
1045        let mut rates = std::collections::HashMap::new();
1046        for c in target_currencies {
1047            if let Some(v) = cached.rates.get(c.as_str()) {
1048                rates.insert(c.clone(), *v);
1049            }
1050        }
1051
1052        Ok(FiatExchangeRates {
1053            timestamp: cached.timestamp,
1054            base: "USD".to_string(),
1055            rates,
1056        })
1057    }
1058
1059    /// Internal: update fiat exchange rates cache if stale for any requested currencies.
1060    async fn update_fiat_exchange_rates(&self, target_currencies: &[String]) -> WalletResult<()> {
1061        let update_ms = self.config.fiat_update_msecs;
1062        let freshness_cutoff = Utc::now() - chrono::Duration::milliseconds(update_ms as i64);
1063
1064        let to_fetch: Vec<String> = {
1065            let cached = self.fiat_exchange_rates.lock().await;
1066            target_currencies
1067                .iter()
1068                .filter(|c| {
1069                    if c.as_str() == "USD" {
1070                        return false; // USD is always 1.0
1071                    }
1072                    match cached.rates.get(c.as_str()) {
1073                        Some(_) if cached.timestamp > freshness_cutoff => false,
1074                        _ => true,
1075                    }
1076                })
1077                .cloned()
1078                .collect()
1079        };
1080
1081        if to_fetch.is_empty() {
1082            // Ensure USD is always present
1083            let mut cached = self.fiat_exchange_rates.lock().await;
1084            cached.rates.entry("USD".to_string()).or_insert(1.0);
1085            return Ok(());
1086        }
1087
1088        // Fetch from provider
1089        let fetched = fetch_fiat_exchange_rates(
1090            &self.client,
1091            self.config.exchangeratesapi_key.as_deref(),
1092            "USD",
1093            &to_fetch,
1094        )
1095        .await?;
1096
1097        // Merge into cache
1098        let mut cached = self.fiat_exchange_rates.lock().await;
1099        for (currency, rate) in &fetched.rates {
1100            cached.rates.insert(currency.clone(), *rate);
1101        }
1102        cached.rates.entry("USD".to_string()).or_insert(1.0);
1103        if fetched.timestamp > cached.timestamp {
1104            cached.timestamp = fetched.timestamp;
1105        }
1106
1107        Ok(())
1108    }
1109}
1110
1111// ---------------------------------------------------------------------------
1112// Helpers
1113// ---------------------------------------------------------------------------
1114
1115/// Serialize a BlockHeader to 80 bytes in standard Bitcoin block header format.
1116///
1117/// Format: version(4) + prevHash(32) + merkleRoot(32) + time(4) + bits(4) + nonce(4) = 80 bytes.
1118/// Hash fields are written in reversed byte order (internal byte order).
1119fn serialize_block_header(header: &BlockHeader) -> Vec<u8> {
1120    let mut buf = Vec::with_capacity(80);
1121
1122    // version (4 bytes LE)
1123    buf.extend_from_slice(&header.version.to_le_bytes());
1124
1125    // previous hash (32 bytes, reversed from display hex)
1126    if let Ok(bytes) = hex_to_bytes_reversed(&header.previous_hash) {
1127        buf.extend_from_slice(&bytes);
1128    } else {
1129        buf.extend_from_slice(&[0u8; 32]);
1130    }
1131
1132    // merkle root (32 bytes, reversed from display hex)
1133    if let Ok(bytes) = hex_to_bytes_reversed(&header.merkle_root) {
1134        buf.extend_from_slice(&bytes);
1135    } else {
1136        buf.extend_from_slice(&[0u8; 32]);
1137    }
1138
1139    // time (4 bytes LE)
1140    buf.extend_from_slice(&header.time.to_le_bytes());
1141
1142    // bits (4 bytes LE)
1143    buf.extend_from_slice(&header.bits.to_le_bytes());
1144
1145    // nonce (4 bytes LE)
1146    buf.extend_from_slice(&header.nonce.to_le_bytes());
1147
1148    buf
1149}
1150
1151/// Decode a hex string into bytes with reversed byte order.
1152/// Used for block header hash fields which are displayed in reverse byte order.
1153fn hex_to_bytes_reversed(hex: &str) -> Result<Vec<u8>, WalletError> {
1154    if hex.len() % 2 != 0 {
1155        return Err(WalletError::InvalidParameter {
1156            parameter: "hex".to_string(),
1157            must_be: "an even-length hex string".to_string(),
1158        });
1159    }
1160    let mut bytes: Vec<u8> = (0..hex.len())
1161        .step_by(2)
1162        .map(|i| {
1163            u8::from_str_radix(&hex[i..i + 2], 16).map_err(|_| WalletError::InvalidParameter {
1164                parameter: "hex".to_string(),
1165                must_be: "valid hex characters".to_string(),
1166            })
1167        })
1168        .collect::<Result<Vec<u8>, _>>()?;
1169    bytes.reverse();
1170    Ok(bytes)
1171}