Skip to main content

bsv_wallet_toolbox/services/
types.rs

1//! Result types, config structs, call history types, and enums for the services layer.
2//!
3//! Ported from wallet-toolbox/src/sdk/WalletServices.interfaces.ts.
4
5use std::collections::HashMap;
6
7use chrono::{DateTime, Utc};
8use serde::{Deserialize, Serialize};
9
10use crate::error::WalletError;
11use crate::types::Chain;
12
13// ---------------------------------------------------------------------------
14// Block Header
15// ---------------------------------------------------------------------------
16
17/// Fields of an 80-byte serialized block header whose double SHA-256 hash
18/// produces the block hash.
19///
20/// All hash values are 32-byte hex strings with byte order reversed from
21/// the serialized byte order.
22#[derive(Debug, Clone, Serialize, Deserialize)]
23#[serde(rename_all = "camelCase")]
24pub struct BlockHeader {
25    /// Block header version value (4 bytes serialized).
26    pub version: u32,
27    /// Hash of the previous block header (32 bytes serialized).
28    pub previous_hash: String,
29    /// Root hash of the merkle tree of all transactions (32 bytes serialized).
30    pub merkle_root: String,
31    /// Block header time value (4 bytes serialized).
32    pub time: u32,
33    /// Block header bits (difficulty target) value (4 bytes serialized).
34    pub bits: u32,
35    /// Block header nonce value (4 bytes serialized).
36    pub nonce: u32,
37    /// Height of the header, starting from zero.
38    pub height: u32,
39    /// The double SHA-256 hash of the serialized base header fields.
40    pub hash: String,
41}
42
43// ---------------------------------------------------------------------------
44// GetMerklePath
45// ---------------------------------------------------------------------------
46
47/// Result returned from `WalletServices::get_merkle_path`.
48#[derive(Debug, Clone, Serialize, Deserialize, Default)]
49#[serde(rename_all = "camelCase")]
50pub struct GetMerklePathResult {
51    /// The name of the service returning the proof, if any.
52    #[serde(skip_serializing_if = "Option::is_none")]
53    pub name: Option<String>,
54    /// The merkle path (proof) for the transaction, if found.
55    #[serde(skip_serializing_if = "Option::is_none")]
56    pub merkle_path: Option<Vec<u8>>,
57    /// Block header associated with the merkle path.
58    #[serde(skip_serializing_if = "Option::is_none")]
59    pub header: Option<BlockHeader>,
60    /// The first error that occurred during processing, if any.
61    #[serde(skip_serializing_if = "Option::is_none")]
62    pub error: Option<String>,
63}
64
65// ---------------------------------------------------------------------------
66// GetRawTx
67// ---------------------------------------------------------------------------
68
69/// Result returned from `WalletServices::get_raw_tx`.
70#[derive(Debug, Clone, Serialize, Deserialize, Default)]
71#[serde(rename_all = "camelCase")]
72pub struct GetRawTxResult {
73    /// Transaction hash of the request.
74    pub txid: String,
75    /// The name of the service returning the raw tx, if any.
76    #[serde(skip_serializing_if = "Option::is_none")]
77    pub name: Option<String>,
78    /// Raw transaction bytes, if found.
79    #[serde(skip_serializing_if = "Option::is_none")]
80    pub raw_tx: Option<Vec<u8>>,
81    /// The first error that occurred during processing, if any.
82    #[serde(skip_serializing_if = "Option::is_none")]
83    pub error: Option<String>,
84}
85
86// ---------------------------------------------------------------------------
87// PostBeef
88// ---------------------------------------------------------------------------
89
90/// Result for a single txid in a post beef response.
91#[derive(Debug, Clone, Serialize, Deserialize)]
92#[serde(rename_all = "camelCase")]
93pub struct PostTxResultForTxid {
94    /// Transaction ID.
95    pub txid: String,
96    /// "success" or "error"
97    pub status: String,
98    /// True if the transaction was already known to the service.
99    #[serde(skip_serializing_if = "Option::is_none")]
100    pub already_known: Option<bool>,
101    /// True if the broadcast double-spends at least one input.
102    #[serde(skip_serializing_if = "Option::is_none")]
103    pub double_spend: Option<bool>,
104    /// Block hash, if the transaction has been mined.
105    #[serde(skip_serializing_if = "Option::is_none")]
106    pub block_hash: Option<String>,
107    /// Block height, if the transaction has been mined.
108    #[serde(skip_serializing_if = "Option::is_none")]
109    pub block_height: Option<u32>,
110    /// TXIDs of competing (double-spend) transactions.
111    #[serde(skip_serializing_if = "Option::is_none")]
112    pub competing_txs: Option<Vec<String>>,
113    /// True if the service was unable to process a potentially valid transaction.
114    #[serde(skip_serializing_if = "Option::is_none")]
115    pub service_error: Option<bool>,
116}
117
118/// Result returned from `WalletServices::post_beef` (per provider).
119#[derive(Debug, Clone, Serialize, Deserialize)]
120#[serde(rename_all = "camelCase")]
121pub struct PostBeefResult {
122    /// The name of the service the transaction was submitted to.
123    pub name: String,
124    /// "success" if all txids returned success; "error" otherwise.
125    pub status: String,
126    /// The first error that occurred, if any.
127    #[serde(skip_serializing_if = "Option::is_none")]
128    pub error: Option<String>,
129    /// Per-txid results.
130    pub txid_results: Vec<PostTxResultForTxid>,
131}
132
133impl PostBeefResult {
134    /// Create an error result for a service timeout.
135    pub fn timeout(provider_name: &str, txids: &[String], timeout_ms: u64) -> Self {
136        PostBeefResult {
137            name: provider_name.to_string(),
138            status: "error".to_string(),
139            error: Some(format!("Service timeout after {}ms", timeout_ms)),
140            txid_results: txids
141                .iter()
142                .map(|txid| PostTxResultForTxid {
143                    txid: txid.clone(),
144                    status: "error".to_string(),
145                    already_known: None,
146                    double_spend: None,
147                    block_hash: None,
148                    block_height: None,
149                    competing_txs: None,
150                    service_error: Some(true),
151                })
152                .collect(),
153        }
154    }
155}
156
157/// Mode for posting BEEF to broadcast services.
158#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
159#[serde(rename_all = "camelCase")]
160pub enum PostBeefMode {
161    /// Try providers sequentially until one succeeds.
162    UntilSuccess,
163    /// Send to all providers concurrently.
164    PromiseAll,
165}
166
167// ---------------------------------------------------------------------------
168// GetUtxoStatus
169// ---------------------------------------------------------------------------
170
171/// Output format for getUtxoStatus queries.
172#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
173pub enum GetUtxoStatusOutputFormat {
174    /// Little-endian SHA-256 hash of output script.
175    #[serde(rename = "hashLE")]
176    HashLE,
177    /// Big-endian SHA-256 hash of output script.
178    #[serde(rename = "hashBE")]
179    HashBE,
180    /// Entire transaction output script.
181    #[serde(rename = "script")]
182    Script,
183}
184
185/// Details about an occurrence of an output script as a UTXO.
186#[derive(Debug, Clone, Serialize, Deserialize)]
187#[serde(rename_all = "camelCase")]
188pub struct GetUtxoStatusDetails {
189    /// Transaction ID containing the UTXO.
190    #[serde(skip_serializing_if = "Option::is_none")]
191    pub txid: Option<String>,
192    /// Output index within the transaction.
193    #[serde(skip_serializing_if = "Option::is_none")]
194    pub index: Option<u32>,
195    /// Value of the UTXO in satoshis.
196    #[serde(skip_serializing_if = "Option::is_none")]
197    pub satoshis: Option<u64>,
198    /// Block height where the transaction was mined.
199    #[serde(skip_serializing_if = "Option::is_none")]
200    pub height: Option<u32>,
201}
202
203/// Result returned from `WalletServices::get_utxo_status`.
204#[derive(Debug, Clone, Serialize, Deserialize)]
205#[serde(rename_all = "camelCase")]
206pub struct GetUtxoStatusResult {
207    /// The name of the service returning the result.
208    pub name: String,
209    /// "success" or "error".
210    pub status: String,
211    /// Error details, if any.
212    #[serde(skip_serializing_if = "Option::is_none")]
213    pub error: Option<String>,
214    /// Whether the output is associated with at least one unspent tx output.
215    #[serde(skip_serializing_if = "Option::is_none")]
216    pub is_utxo: Option<bool>,
217    /// Additional details about occurrences of this output script as a UTXO.
218    pub details: Vec<GetUtxoStatusDetails>,
219}
220
221// ---------------------------------------------------------------------------
222// GetStatusForTxids
223// ---------------------------------------------------------------------------
224
225/// Status result for a single txid.
226#[derive(Debug, Clone, Serialize, Deserialize)]
227#[serde(rename_all = "camelCase")]
228pub struct StatusForTxidResult {
229    /// Transaction ID.
230    pub txid: String,
231    /// Roughly the depth of the block containing txid from chain tip.
232    #[serde(skip_serializing_if = "Option::is_none")]
233    pub depth: Option<u32>,
234    /// "mined" if depth > 0, "known" if depth == 0, "unknown" if depth is None.
235    pub status: String,
236}
237
238/// Result returned from `WalletServices::get_status_for_txids`.
239#[derive(Debug, Clone, Serialize, Deserialize)]
240#[serde(rename_all = "camelCase")]
241pub struct GetStatusForTxidsResult {
242    /// The name of the service returning results.
243    pub name: String,
244    /// "success" or "error".
245    pub status: String,
246    /// Error details, if any.
247    #[serde(skip_serializing_if = "Option::is_none")]
248    pub error: Option<String>,
249    /// Per-txid status results.
250    pub results: Vec<StatusForTxidResult>,
251}
252
253// ---------------------------------------------------------------------------
254// GetScriptHashHistory
255// ---------------------------------------------------------------------------
256
257/// A single entry in a script hash history.
258#[derive(Debug, Clone, Serialize, Deserialize)]
259#[serde(rename_all = "camelCase")]
260pub struct ScriptHashHistoryEntry {
261    /// Transaction ID.
262    pub txid: String,
263    /// Block height (None for unconfirmed transactions).
264    #[serde(skip_serializing_if = "Option::is_none")]
265    pub height: Option<u32>,
266}
267
268/// Result returned from `WalletServices::get_script_hash_history`.
269#[derive(Debug, Clone, Serialize, Deserialize)]
270#[serde(rename_all = "camelCase")]
271pub struct GetScriptHashHistoryResult {
272    /// The name of the service returning results.
273    pub name: String,
274    /// "success" or "error".
275    pub status: String,
276    /// Error details, if any.
277    #[serde(skip_serializing_if = "Option::is_none")]
278    pub error: Option<String>,
279    /// Transaction history entries.
280    pub history: Vec<ScriptHashHistoryEntry>,
281}
282
283// ---------------------------------------------------------------------------
284// Call History Types
285// ---------------------------------------------------------------------------
286
287/// Maximum number of individual call records kept per provider.
288pub const MAX_CALL_HISTORY: usize = 32;
289
290/// Maximum number of reset count intervals kept per provider.
291pub const MAX_RESET_COUNTS: usize = 32;
292
293/// Error details for a failed service call.
294#[derive(Debug, Clone, Serialize, Deserialize)]
295#[serde(rename_all = "camelCase")]
296pub struct ServiceCallError {
297    /// Error message text.
298    pub message: String,
299    /// WERR error code string.
300    pub code: String,
301}
302
303impl ServiceCallError {
304    /// Create from a `WalletError`.
305    pub fn from_wallet_error(err: &WalletError) -> Self {
306        ServiceCallError {
307            message: err.to_string(),
308            code: err.code().to_string(),
309        }
310    }
311}
312
313/// Minimum data tracked for each service call.
314#[derive(Debug, Clone, Serialize, Deserialize)]
315#[serde(rename_all = "camelCase")]
316pub struct ServiceCall {
317    /// When the call was made.
318    pub when: DateTime<Utc>,
319    /// Duration of the call in milliseconds.
320    pub msecs: i64,
321    /// True if the service provider successfully processed the request.
322    pub success: bool,
323    /// Simple text summary of the result.
324    #[serde(skip_serializing_if = "Option::is_none")]
325    pub result: Option<String>,
326    /// Error code and message if success is false and an exception was thrown.
327    #[serde(skip_serializing_if = "Option::is_none")]
328    pub error: Option<ServiceCallError>,
329}
330
331/// Counts of service calls over a time interval.
332#[derive(Debug, Clone, Serialize, Deserialize)]
333#[serde(rename_all = "camelCase")]
334pub struct ServiceCallHistoryCounts {
335    /// Count of calls returning success true.
336    pub success: u32,
337    /// Count of calls returning success false.
338    pub failure: u32,
339    /// Of failures, count of calls with a valid error code and message.
340    pub error: u32,
341    /// Start of the counting interval.
342    pub since: DateTime<Utc>,
343    /// End of the counting interval (bumped with each new call).
344    pub until: DateTime<Utc>,
345}
346
347impl ServiceCallHistoryCounts {
348    /// Create a new zero-count interval starting now.
349    pub fn new_now() -> Self {
350        let now = Utc::now();
351        Self {
352            success: 0,
353            failure: 0,
354            error: 0,
355            since: now,
356            until: now,
357        }
358    }
359
360    /// Create a new zero-count interval starting at a specific time.
361    pub fn new_at(since: DateTime<Utc>) -> Self {
362        Self {
363            success: 0,
364            failure: 0,
365            error: 0,
366            since,
367            until: since,
368        }
369    }
370}
371
372/// History of service calls for a single service, single provider.
373#[derive(Debug, Clone, Serialize, Deserialize)]
374#[serde(rename_all = "camelCase")]
375pub struct ProviderCallHistory {
376    /// Name of the service (e.g., "postBeef", "getMerklePath").
377    pub service_name: String,
378    /// Name of the provider (e.g., "TAAL", "WhatsOnChain").
379    pub provider_name: String,
380    /// Most recent service calls (limited to MAX_CALL_HISTORY).
381    pub calls: Vec<ServiceCall>,
382    /// Counts since creation of the Services instance.
383    pub total_counts: ServiceCallHistoryCounts,
384    /// Entry at index 0 is the current interval. On reset, a new zero-count entry is prepended.
385    /// Limited to MAX_RESET_COUNTS.
386    pub reset_counts: Vec<ServiceCallHistoryCounts>,
387}
388
389/// History of service calls for a single service, all providers.
390#[derive(Debug, Clone, Serialize, Deserialize)]
391#[serde(rename_all = "camelCase")]
392pub struct ServiceCallHistory {
393    /// Name of the service.
394    pub service_name: String,
395    /// Per-provider history keyed by provider name.
396    pub history_by_provider: HashMap<String, ProviderCallHistory>,
397}
398
399/// Aggregated call history across all service collections.
400#[derive(Debug, Clone, Serialize, Deserialize)]
401#[serde(rename_all = "camelCase")]
402pub struct ServicesCallHistory {
403    /// History for each service type.
404    pub services: Vec<ServiceCallHistory>,
405}
406
407// ---------------------------------------------------------------------------
408// Exchange Rates
409// ---------------------------------------------------------------------------
410
411/// Fiat exchange rates with a base currency.
412#[derive(Debug, Clone, Serialize, Deserialize)]
413#[serde(rename_all = "camelCase")]
414pub struct FiatExchangeRates {
415    /// When the rates were last updated.
416    pub timestamp: DateTime<Utc>,
417    /// Base currency code (e.g., "USD").
418    pub base: String,
419    /// Exchange rates keyed by currency code (relative to base).
420    pub rates: HashMap<String, f64>,
421}
422
423impl Default for FiatExchangeRates {
424    fn default() -> Self {
425        Self {
426            timestamp: Utc::now(),
427            base: "USD".to_string(),
428            rates: HashMap::new(),
429        }
430    }
431}
432
433/// BSV exchange rate (USD per BSV).
434#[derive(Debug, Clone, Serialize, Deserialize)]
435#[serde(rename_all = "camelCase")]
436pub struct BsvExchangeRate {
437    /// When the rate was last updated.
438    pub timestamp: DateTime<Utc>,
439    /// BSV price in USD.
440    pub rate_usd: f64,
441}
442
443impl Default for BsvExchangeRate {
444    fn default() -> Self {
445        Self {
446            timestamp: Utc::now(),
447            rate_usd: 0.0,
448        }
449    }
450}
451
452// ---------------------------------------------------------------------------
453// ARC SSE Event
454// ---------------------------------------------------------------------------
455
456/// A single event received from the ARC SSE stream.
457#[derive(Debug, Clone, Serialize, Deserialize)]
458#[serde(rename_all = "camelCase")]
459pub struct ArcSseEvent {
460    /// Transaction ID the event relates to.
461    pub txid: String,
462    /// Transaction status string (e.g., "SEEN_ON_NETWORK", "MINED").
463    pub tx_status: String,
464    /// ISO 8601 timestamp of the event.
465    pub timestamp: String,
466}
467
468// ---------------------------------------------------------------------------
469// NLockTime
470// ---------------------------------------------------------------------------
471
472/// Input for `n_lock_time_is_final` -- either a raw locktime value or a transaction.
473pub enum NLockTimeInput {
474    /// A raw nLockTime u32 value.
475    Raw(u32),
476    /// A full transaction (locktime extracted from the transaction).
477    Transaction(bsv::transaction::Transaction),
478}
479
480// ---------------------------------------------------------------------------
481// Config Types
482// ---------------------------------------------------------------------------
483
484/// Configuration for an ARC broadcast service endpoint.
485#[derive(Debug, Clone, Serialize, Deserialize)]
486#[serde(rename_all = "camelCase")]
487pub struct ArcConfig {
488    /// API key for authentication.
489    #[serde(skip_serializing_if = "Option::is_none")]
490    pub api_key: Option<String>,
491    /// Deployment ID for tracking (default: "wallet-toolbox").
492    pub deployment_id: String,
493    /// Callback URL for transaction status updates.
494    #[serde(skip_serializing_if = "Option::is_none")]
495    pub callback_url: Option<String>,
496    /// Callback authentication token.
497    #[serde(skip_serializing_if = "Option::is_none")]
498    pub callback_token: Option<String>,
499    /// Optional HTTP client identifier.
500    #[serde(skip_serializing_if = "Option::is_none")]
501    pub http_client: Option<String>,
502}
503
504impl Default for ArcConfig {
505    fn default() -> Self {
506        Self {
507            api_key: None,
508            deployment_id: "wallet-toolbox".to_string(),
509            callback_url: None,
510            callback_token: None,
511            http_client: None,
512        }
513    }
514}
515
516/// Configuration for the wallet services layer.
517#[derive(Debug, Clone, Serialize, Deserialize)]
518#[serde(rename_all = "camelCase")]
519pub struct ServicesConfig {
520    /// Which BSV chain to use.
521    pub chain: Chain,
522    /// TAAL ARC service URL.
523    pub arc_url: String,
524    /// TAAL ARC configuration.
525    pub arc_config: ArcConfig,
526    /// GorillaPool ARC service URL (mainnet only).
527    #[serde(skip_serializing_if = "Option::is_none")]
528    pub arc_gorilla_pool_url: Option<String>,
529    /// GorillaPool ARC configuration.
530    #[serde(skip_serializing_if = "Option::is_none")]
531    pub arc_gorilla_pool_config: Option<ArcConfig>,
532    /// API key for WhatsOnChain.
533    #[serde(skip_serializing_if = "Option::is_none")]
534    pub whats_on_chain_api_key: Option<String>,
535    /// API key for Bitails.
536    #[serde(skip_serializing_if = "Option::is_none")]
537    pub bitails_api_key: Option<String>,
538    /// Chaintracks service URL.
539    #[serde(skip_serializing_if = "Option::is_none")]
540    pub chaintracks_url: Option<String>,
541    /// Chaintracks fiat exchange rates endpoint URL.
542    #[serde(skip_serializing_if = "Option::is_none")]
543    pub chaintracks_fiat_exchange_rates_url: Option<String>,
544    /// API key for exchangeratesapi.io.
545    #[serde(skip_serializing_if = "Option::is_none")]
546    pub exchangeratesapi_key: Option<String>,
547    /// Current BSV exchange rate.
548    pub bsv_exchange_rate: BsvExchangeRate,
549    /// Update interval for BSV exchange rate in milliseconds (default: 15 minutes).
550    pub bsv_update_msecs: u64,
551    /// Current fiat exchange rates.
552    pub fiat_exchange_rates: FiatExchangeRates,
553    /// Update interval for fiat exchange rates in milliseconds (default: 24 hours).
554    pub fiat_update_msecs: u64,
555    /// Base timeout for postBeef in milliseconds.
556    pub post_beef_soft_timeout_ms: u64,
557    /// Additional timeout per KiB of BEEF payload.
558    pub post_beef_soft_timeout_per_kb_ms: u64,
559    /// Maximum timeout for postBeef in milliseconds.
560    pub post_beef_soft_timeout_max_ms: u64,
561}
562
563impl From<Chain> for ServicesConfig {
564    fn from(chain: Chain) -> Self {
565        let (arc_url, gorilla_pool_url, chaintracks_url) = match chain {
566            Chain::Main => (
567                "https://arc.taal.com".to_string(),
568                Some("https://arc.gorillapool.io".to_string()),
569                "https://mainnet-chaintracks.babbage.systems".to_string(),
570            ),
571            Chain::Test => (
572                "https://arc-test.taal.com".to_string(),
573                None,
574                "https://testnet-chaintracks.babbage.systems".to_string(),
575            ),
576        };
577
578        let gorilla_pool_config = gorilla_pool_url.as_ref().map(|_| ArcConfig::default());
579
580        Self {
581            chain,
582            arc_url,
583            arc_config: ArcConfig::default(),
584            arc_gorilla_pool_url: gorilla_pool_url,
585            arc_gorilla_pool_config: gorilla_pool_config,
586            whats_on_chain_api_key: None,
587            bitails_api_key: None,
588            chaintracks_url: Some(chaintracks_url),
589            chaintracks_fiat_exchange_rates_url: None,
590            exchangeratesapi_key: None,
591            bsv_exchange_rate: BsvExchangeRate::default(),
592            bsv_update_msecs: 15 * 60 * 1000, // 15 minutes
593            fiat_exchange_rates: FiatExchangeRates::default(),
594            fiat_update_msecs: 24 * 60 * 60 * 1000, // 24 hours
595            post_beef_soft_timeout_ms: 5000,
596            post_beef_soft_timeout_per_kb_ms: 50,
597            post_beef_soft_timeout_max_ms: 30000,
598        }
599    }
600}
601
602impl ServicesConfig {
603    /// Calculate the adaptive soft timeout for postBeef based on BEEF payload size.
604    pub fn get_post_beef_soft_timeout_ms(&self, beef_bytes_len: usize) -> u64 {
605        let base_ms = self.post_beef_soft_timeout_ms;
606        let per_kb_ms = self.post_beef_soft_timeout_per_kb_ms;
607        let max_ms = self.post_beef_soft_timeout_max_ms.max(base_ms);
608        if per_kb_ms == 0 {
609            return base_ms.min(max_ms);
610        }
611        let extra_ms = ((beef_bytes_len as f64 / 1024.0) * per_kb_ms as f64).ceil() as u64;
612        (base_ms + extra_ms).min(max_ms)
613    }
614}