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