1use core::fmt;
2
3use bdk_wallet::bitcoin::Transaction;
4use bdk_wallet::chain::BlockId;
5use cdk_common::redact::url_for_logs;
6use tokio_util::sync::CancellationToken;
7
8use crate::error::Error;
9
10#[cfg(feature = "bitcoin-rpc")]
11pub mod bitcoin_rpc;
12#[cfg(feature = "electrum")]
13pub mod electrum;
14#[cfg(feature = "esplora")]
15pub mod esplora;
16
17#[derive(Clone)]
19pub struct BitcoinRpcConfig {
20 pub host: String,
22 pub port: u16,
24 pub user: String,
26 pub password: String,
28 pub wallet_rescan_from_height: Option<u32>,
34}
35
36impl fmt::Debug for BitcoinRpcConfig {
37 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
38 f.debug_struct("BitcoinRpcConfig")
39 .field("host", &self.host)
40 .field("port", &self.port)
41 .field("user", &self.user)
42 .field("password", &"[REDACTED]")
43 .field("wallet_rescan_from_height", &self.wallet_rescan_from_height)
44 .finish()
45 }
46}
47
48#[derive(Clone)]
50pub struct EsploraConfig {
51 pub url: String,
53 pub parallel_requests: usize,
55}
56
57impl fmt::Debug for EsploraConfig {
58 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
59 f.debug_struct("EsploraConfig")
60 .field("url", &url_for_logs(&self.url))
61 .field("parallel_requests", &self.parallel_requests)
62 .finish()
63 }
64}
65
66#[derive(Clone)]
68pub struct ElectrumConfig {
69 pub url: String,
71 pub batch_size: usize,
73}
74
75impl fmt::Debug for ElectrumConfig {
76 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
77 f.debug_struct("ElectrumConfig")
78 .field("url", &url_for_logs(&self.url))
79 .field("batch_size", &self.batch_size)
80 .finish()
81 }
82}
83
84#[derive(Clone)]
86pub enum ChainSource {
87 #[cfg(feature = "esplora")]
89 Esplora(EsploraConfig),
90 #[cfg(feature = "electrum")]
92 Electrum(ElectrumConfig),
93 #[cfg(feature = "bitcoin-rpc")]
95 BitcoinRpc(BitcoinRpcConfig),
96}
97
98impl fmt::Debug for ChainSource {
99 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
100 match self {
101 #[cfg(feature = "esplora")]
102 Self::Esplora(config) => f.debug_tuple("Esplora").field(config).finish(),
103 #[cfg(feature = "electrum")]
104 Self::Electrum(config) => f.debug_tuple("Electrum").field(config).finish(),
105 #[cfg(feature = "bitcoin-rpc")]
106 Self::BitcoinRpc(config) => f.debug_tuple("BitcoinRpc").field(config).finish(),
107 #[allow(unreachable_patterns)]
108 _ => f.write_str("ChainSource"),
109 }
110 }
111}
112
113#[derive(Debug, Clone, Copy, PartialEq, Eq)]
115pub(crate) enum BroadcastOutcome {
116 Accepted,
118 AlreadyKnown,
120}
121
122#[derive(Debug, Clone, Copy, PartialEq, Eq)]
124pub(crate) enum BroadcastErrorKind {
125 Rejected,
127 Transient,
129 Unknown,
131}
132
133#[derive(Debug, Clone, PartialEq, Eq)]
135pub(crate) struct BroadcastFailure {
136 pub kind: BroadcastErrorKind,
138 pub message: String,
140}
141
142impl BroadcastFailure {
143 pub(crate) fn new(kind: BroadcastErrorKind, message: String) -> Self {
144 Self { kind, message }
145 }
146}
147
148impl ChainSource {
149 pub(crate) fn validate(&self) -> Result<(), Error> {
150 match self {
151 #[cfg(feature = "esplora")]
152 Self::Esplora(config) if config.parallel_requests == 0 => {
153 return Err(Error::InvalidConfig(
154 "Esplora parallel_requests must be greater than zero".to_string(),
155 ));
156 }
157 #[cfg(feature = "electrum")]
158 Self::Electrum(config) if config.batch_size == 0 => {
159 return Err(Error::InvalidConfig(
160 "Electrum batch_size must be greater than zero".to_string(),
161 ));
162 }
163 #[allow(unreachable_patterns)]
164 _ => {}
165 }
166
167 Ok(())
168 }
169
170 pub(crate) fn initial_checkpoint(&self) -> Result<Option<BlockId>, Error> {
171 match self {
172 #[cfg(feature = "bitcoin-rpc")]
173 Self::BitcoinRpc(config) => bitcoin_rpc::initial_checkpoint(config).map(Some),
174 #[allow(unreachable_patterns)]
175 _ => Ok(None),
176 }
177 }
178
179 pub async fn sync_wallet(
180 &self,
181 cdk_bdk: &crate::CdkBdk,
182 cancel_token: CancellationToken,
183 ) -> Result<(), Error> {
184 match self {
185 #[cfg(feature = "esplora")]
186 ChainSource::Esplora(config) => {
187 esplora::sync_esplora(cdk_bdk, config, cancel_token).await
188 }
189 #[cfg(feature = "electrum")]
190 ChainSource::Electrum(config) => {
191 electrum::sync_electrum(cdk_bdk, config, cancel_token).await
192 }
193 #[cfg(feature = "bitcoin-rpc")]
194 ChainSource::BitcoinRpc(config) => {
195 bitcoin_rpc::sync_bitcoin_rpc(cdk_bdk, config, cancel_token).await
196 }
197 #[allow(unreachable_patterns)]
198 _ => unreachable!("ChainSource must have at least one feature enabled"),
199 }
200 }
201
202 pub(crate) async fn broadcast(
203 &self,
204 tx: Transaction,
205 ) -> Result<BroadcastOutcome, BroadcastFailure> {
206 match self {
207 #[cfg(feature = "esplora")]
208 ChainSource::Esplora(config) => esplora::broadcast_esplora(config, tx).await,
209 #[cfg(feature = "electrum")]
210 ChainSource::Electrum(config) => electrum::broadcast_electrum(config, tx).await,
211 #[cfg(feature = "bitcoin-rpc")]
212 ChainSource::BitcoinRpc(config) => bitcoin_rpc::broadcast_bitcoin_rpc(config, tx).await,
213 #[allow(unreachable_patterns)]
214 _ => unreachable!("ChainSource must have at least one feature enabled"),
215 }
216 }
217
218 pub async fn fetch_fee_rate(&self, target_blocks: u16) -> Result<f64, Error> {
219 match self {
220 #[cfg(feature = "esplora")]
221 ChainSource::Esplora(config) => {
222 esplora::fetch_fee_rate_esplora(config, target_blocks).await
223 }
224 #[cfg(feature = "electrum")]
225 ChainSource::Electrum(config) => {
226 electrum::fetch_fee_rate_electrum(config, target_blocks).await
227 }
228 #[cfg(feature = "bitcoin-rpc")]
229 ChainSource::BitcoinRpc(config) => {
230 bitcoin_rpc::fetch_fee_rate_bitcoin_rpc(config, target_blocks).await
231 }
232 #[allow(unreachable_patterns)]
233 _ => unreachable!("ChainSource must have at least one feature enabled"),
234 }
235 }
236}
237
238#[cfg(test)]
239mod tests {
240 use super::*;
241
242 #[cfg(feature = "bitcoin-rpc")]
243 #[test]
244 fn bitcoin_rpc_debug_redacts_password() {
245 let config = BitcoinRpcConfig {
246 host: "127.0.0.1".to_string(),
247 port: 8332,
248 user: "rpc-user".to_string(),
249 password: "rpc-password-secret".to_string(),
250 wallet_rescan_from_height: Some(800_000),
251 };
252
253 let debug = format!("{config:?}");
254
255 assert!(debug.contains("127.0.0.1"));
256 assert!(debug.contains("rpc-user"));
257 assert!(debug.contains("[REDACTED]"));
258 assert!(!debug.contains("rpc-password-secret"));
259 }
260
261 #[cfg(feature = "esplora")]
262 #[test]
263 fn esplora_debug_redacts_url_credentials() {
264 let source = ChainSource::Esplora(EsploraConfig {
265 url: "https://esplora-user:esplora-secret@example.com/api".to_string(),
266 parallel_requests: 4,
267 });
268
269 let debug = format!("{source:?}");
270
271 assert!(debug.contains("https://example.com/api"));
272 assert!(!debug.contains("esplora-user"));
273 assert!(!debug.contains("esplora-secret"));
274 }
275
276 #[cfg(feature = "electrum")]
277 #[test]
278 fn electrum_debug_redacts_url_credentials() {
279 let source = ChainSource::Electrum(ElectrumConfig {
280 url: "ssl://electrum-user:electrum-secret@example.com:50002".to_string(),
281 batch_size: 5,
282 });
283
284 let debug = format!("{source:?}");
285
286 assert!(debug.contains("ssl://example.com:50002"));
287 assert!(!debug.contains("electrum-user"));
288 assert!(!debug.contains("electrum-secret"));
289 }
290
291 #[cfg(feature = "electrum")]
292 #[test]
293 fn rejects_zero_electrum_batch_size() {
294 let chain_source = ChainSource::Electrum(ElectrumConfig {
295 url: "tcp://127.0.0.1:50001".to_string(),
296 batch_size: 0,
297 });
298
299 let error = chain_source
300 .validate()
301 .expect_err("zero Electrum batch size should fail");
302
303 assert!(matches!(error, Error::InvalidConfig(_)));
304 }
305
306 #[cfg(feature = "esplora")]
307 #[test]
308 fn rejects_zero_esplora_parallel_requests() {
309 for parallel_requests in [0, 1, 4] {
310 let source = ChainSource::Esplora(EsploraConfig {
311 url: "http://127.0.0.1:1".to_owned(),
312 parallel_requests,
313 });
314 match parallel_requests {
315 0 => assert!(matches!(source.validate(), Err(Error::InvalidConfig(_)))),
316 _ => source.validate().expect("positive concurrency"),
317 }
318 }
319 }
320}