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 #[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 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 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
99pub(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#[derive(Clone, Debug, PartialEq, Eq)]
123#[cfg_attr(feature = "uniffi", derive(uniffi::Enum))]
124pub enum Outspend {
125 Unspent,
126 Spent {
129 txid: String,
130 vin: u32,
131 status: TxStatus,
132 },
133}
134
135#[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 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#[derive(Debug, Clone, Default)]
190#[cfg_attr(feature = "uniffi", derive(uniffi::Record))]
191pub struct NewRestChainServiceRequest {
192 #[cfg_attr(feature = "uniffi", uniffi(default = None))]
196 pub proxy: Option<crate::ProxyConfig>,
197}
198
199#[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 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}