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 #[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 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 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#[derive(Clone, Debug, PartialEq, Eq)]
96#[cfg_attr(feature = "uniffi", derive(uniffi::Enum))]
97pub enum Outspend {
98 Unspent,
99 Spent {
102 txid: String,
103 vin: u32,
104 status: TxStatus,
105 },
106}
107
108#[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 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#[derive(Debug, Clone, Default)]
163#[cfg_attr(feature = "uniffi", derive(uniffi::Record))]
164pub struct NewRestChainServiceRequest {
165 #[cfg_attr(feature = "uniffi", uniffi(default = None))]
169 pub proxy: Option<crate::ProxyConfig>,
170}
171
172#[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 assert!(status(404).to_string().contains("body"));
235 assert!(status(400).to_string().contains("body"));
236 }
237}