Skip to main content

breez_sdk_spark/chain/
mod.rs

1use std::sync::Arc;
2
3use platform_utils::HttpClient;
4use serde::{Deserialize, Serialize};
5use thiserror::Error;
6
7use crate::{
8    Credentials, Network,
9    chain::rest_client::{BasicAuth, ChainApiType, RestClientChainService},
10};
11
12pub mod rest_client;
13mod validating;
14
15pub(crate) use validating::ValidatingChainService;
16
17#[derive(Debug, Error, Clone)]
18#[cfg_attr(feature = "uniffi", derive(uniffi::Error))]
19pub enum ChainServiceError {
20    #[error("Invalid address: {0}")]
21    InvalidAddress(String),
22    #[error("Service connectivity: {0}")]
23    ServiceConnectivity(String),
24    /// The backend does not know this transaction or output. Distinct from a
25    /// connectivity failure: it is an answer, not the absence of one.
26    #[error("Not found: {0}")]
27    NotFound(String),
28    #[error("Generic: {0}")]
29    Generic(String),
30}
31
32impl From<platform_utils::HttpError> for ChainServiceError {
33    fn from(value: platform_utils::HttpError) -> Self {
34        match value.status() {
35            Some(404) => ChainServiceError::NotFound(value.to_string()),
36            _ => ChainServiceError::ServiceConnectivity(value.to_string()),
37        }
38    }
39}
40
41impl From<bitcoin::address::ParseError> for ChainServiceError {
42    fn from(value: bitcoin::address::ParseError) -> Self {
43        ChainServiceError::InvalidAddress(value.to_string())
44    }
45}
46
47#[cfg_attr(feature = "uniffi", uniffi::export(with_foreign))]
48#[macros::async_trait]
49pub trait BitcoinChainService: Send + Sync {
50    async fn get_address_utxos(&self, address: String) -> Result<Vec<Utxo>, ChainServiceError>;
51    /// Every output ever paid to `address`, spent or not, unlike
52    /// [`get_address_utxos`](Self::get_address_utxos) which omits spent ones.
53    /// Recovers an output's outpoint and value after it has been spent, so a
54    /// swept refund can still be distinguished from one never broadcast.
55    async fn get_address_txos(&self, address: String) -> Result<Vec<Utxo>, ChainServiceError>;
56    async fn get_transaction_status(&self, txid: String) -> Result<TxStatus, ChainServiceError>;
57    /// Height of the chain tip. Paired with a transaction's
58    /// [`TxStatus::block_height`] it gives that transaction's confirmation count,
59    /// which no single call reports.
60    async fn tip_height(&self) -> Result<u32, ChainServiceError>;
61    async fn get_transaction_hex(&self, txid: String) -> Result<String, ChainServiceError>;
62    async fn get_outspend(&self, txid: String, vout: u32) -> Result<Outspend, ChainServiceError>;
63    async fn broadcast_transaction(&self, tx: String) -> Result<(), ChainServiceError>;
64    async fn recommended_fees(&self) -> Result<RecommendedFees, ChainServiceError>;
65}
66
67#[derive(Deserialize, Serialize, Clone, Debug, PartialEq, Eq)]
68#[cfg_attr(feature = "uniffi", derive(uniffi::Record))]
69pub struct TxStatus {
70    pub confirmed: bool,
71    pub block_height: Option<u32>,
72    pub block_time: Option<u64>,
73}
74
75#[derive(Deserialize, Serialize, Clone, Debug, PartialEq, Eq)]
76#[cfg_attr(feature = "uniffi", derive(uniffi::Record))]
77pub struct Utxo {
78    pub txid: String,
79    pub vout: u32,
80    pub value: u64,
81    pub status: TxStatus,
82}
83
84#[derive(Deserialize, Serialize, Clone, Debug, PartialEq, Eq)]
85#[cfg_attr(feature = "uniffi", derive(uniffi::Record))]
86pub struct RecommendedFees {
87    pub fastest_fee: u64,
88    pub half_hour_fee: u64,
89    pub hour_fee: u64,
90    pub economy_fee: u64,
91    pub minimum_fee: u64,
92}
93
94/// The spend status of a transaction output.
95#[derive(Clone, Debug, PartialEq, Eq)]
96#[cfg_attr(feature = "uniffi", derive(uniffi::Enum))]
97pub enum Outspend {
98    Unspent,
99    /// The output is spent by input `vin` of transaction `txid`; `status` is
100    /// that spending transaction's confirmation status.
101    Spent {
102        txid: String,
103        vin: u32,
104        status: TxStatus,
105    },
106}
107
108/// Flat Esplora wire form, converted to/from [`Outspend`].
109#[derive(Deserialize, Serialize)]
110struct RawOutspend {
111    spent: bool,
112    #[serde(default, skip_serializing_if = "Option::is_none")]
113    txid: Option<String>,
114    #[serde(default, skip_serializing_if = "Option::is_none")]
115    vin: Option<u32>,
116    #[serde(default, skip_serializing_if = "Option::is_none")]
117    status: Option<TxStatus>,
118}
119
120impl<'de> Deserialize<'de> for Outspend {
121    fn deserialize<D: serde::Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
122        use serde::de::Error;
123        let raw = RawOutspend::deserialize(deserializer)?;
124        if !raw.spent {
125            return Ok(Outspend::Unspent);
126        }
127        Ok(Outspend::Spent {
128            txid: raw.txid.ok_or_else(|| Error::missing_field("txid"))?,
129            vin: raw.vin.ok_or_else(|| Error::missing_field("vin"))?,
130            // A spent output whose spender status is omitted is treated as
131            // unconfirmed rather than rejected.
132            status: raw.status.unwrap_or(TxStatus {
133                confirmed: false,
134                block_height: None,
135                block_time: None,
136            }),
137        })
138    }
139}
140
141impl Serialize for Outspend {
142    fn serialize<S: serde::Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
143        let raw = match self {
144            Outspend::Unspent => RawOutspend {
145                spent: false,
146                txid: None,
147                vin: None,
148                status: None,
149            },
150            Outspend::Spent { txid, vin, status } => RawOutspend {
151                spent: true,
152                txid: Some(txid.clone()),
153                vin: Some(*vin),
154                status: Some(status.clone()),
155            },
156        };
157        raw.serialize(serializer)
158    }
159}
160
161/// Options for [`new_rest_chain_service`].
162#[derive(Debug, Clone, Default)]
163#[cfg_attr(feature = "uniffi", derive(uniffi::Record))]
164pub struct NewRestChainServiceRequest {
165    /// Routes the chain service through a SOCKS5 proxy. Pass the same value as
166    /// [`Config::proxy`](crate::Config::proxy): this service is built outside
167    /// the SDK, so it cannot pick the setting up on its own.
168    #[cfg_attr(feature = "uniffi", uniffi(default = None))]
169    pub proxy: Option<crate::ProxyConfig>,
170}
171
172/// Constructs a shareable REST-based [`BitcoinChainService`].
173///
174/// Pass the returned `Arc` to multiple [`SdkBuilder`](crate::SdkBuilder)s via
175/// [`SdkBuilder::with_chain_service`](crate::SdkBuilder::with_chain_service)
176/// to reuse a single underlying HTTP client (and its connection pool) across
177/// SDK instances. All SDKs sharing the service must use the same `network`.
178///
179/// For one-off, non-shared use, prefer
180/// [`SdkBuilder::with_rest_chain_service`](crate::SdkBuilder::with_rest_chain_service),
181/// which builds on the SDK's own client and inherits its proxy automatically.
182#[cfg_attr(feature = "uniffi", uniffi::export(async_runtime = "tokio"))]
183pub async fn new_rest_chain_service(
184    url: String,
185    network: Network,
186    api_type: ChainApiType,
187    credentials: Option<Credentials>,
188    request: NewRestChainServiceRequest,
189) -> Result<Arc<dyn BitcoinChainService>, crate::SdkError> {
190    let http_client: Arc<dyn HttpClient> =
191        crate::ProxyConfig::http_client(request.proxy.as_ref(), None)?;
192    Ok(Arc::new(RestClientChainService::new(
193        url,
194        network,
195        5,
196        http_client,
197        credentials.map(|c| BasicAuth::new(c.username, c.password)),
198        api_type,
199    )))
200}
201
202#[cfg(test)]
203mod tests {
204    use super::ChainServiceError;
205    use platform_utils::HttpError;
206
207    fn status(status: u16) -> ChainServiceError {
208        HttpError::Status {
209            status,
210            body: "body".to_string(),
211        }
212        .into()
213    }
214
215    #[test]
216    fn a_404_is_an_answer_not_a_connectivity_failure() {
217        assert!(matches!(status(404), ChainServiceError::NotFound(_)));
218    }
219
220    #[test]
221    fn every_other_status_stays_connectivity() {
222        for code in [400, 429, 500, 503] {
223            assert!(
224                matches!(status(code), ChainServiceError::ServiceConnectivity(_)),
225                "{code} should not read as not-found"
226            );
227        }
228    }
229
230    #[test]
231    fn the_body_survives_either_way() {
232        // The error-string classifiers match on the response body, so both
233        // variants have to keep carrying it.
234        assert!(status(404).to_string().contains("body"));
235        assert!(status(400).to_string().contains("body"));
236    }
237}