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 = "electrum")]
152 Self::Electrum(config) if config.batch_size == 0 => {
153 return Err(Error::InvalidConfig(
154 "Electrum batch_size must be greater than zero".to_string(),
155 ));
156 }
157 #[allow(unreachable_patterns)]
158 _ => {}
159 }
160
161 Ok(())
162 }
163
164 pub(crate) fn initial_checkpoint(&self) -> Result<Option<BlockId>, Error> {
165 match self {
166 #[cfg(feature = "bitcoin-rpc")]
167 Self::BitcoinRpc(config) => bitcoin_rpc::initial_checkpoint(config).map(Some),
168 #[allow(unreachable_patterns)]
169 _ => Ok(None),
170 }
171 }
172
173 pub async fn sync_wallet(
174 &self,
175 cdk_bdk: &crate::CdkBdk,
176 cancel_token: CancellationToken,
177 ) -> Result<(), Error> {
178 match self {
179 #[cfg(feature = "esplora")]
180 ChainSource::Esplora(config) => {
181 esplora::sync_esplora(cdk_bdk, config, cancel_token).await
182 }
183 #[cfg(feature = "electrum")]
184 ChainSource::Electrum(config) => {
185 electrum::sync_electrum(cdk_bdk, config, cancel_token).await
186 }
187 #[cfg(feature = "bitcoin-rpc")]
188 ChainSource::BitcoinRpc(config) => {
189 bitcoin_rpc::sync_bitcoin_rpc(cdk_bdk, config, cancel_token).await
190 }
191 #[allow(unreachable_patterns)]
192 _ => unreachable!("ChainSource must have at least one feature enabled"),
193 }
194 }
195
196 pub(crate) async fn broadcast(
197 &self,
198 tx: Transaction,
199 ) -> Result<BroadcastOutcome, BroadcastFailure> {
200 match self {
201 #[cfg(feature = "esplora")]
202 ChainSource::Esplora(config) => esplora::broadcast_esplora(config, tx).await,
203 #[cfg(feature = "electrum")]
204 ChainSource::Electrum(config) => electrum::broadcast_electrum(config, tx).await,
205 #[cfg(feature = "bitcoin-rpc")]
206 ChainSource::BitcoinRpc(config) => bitcoin_rpc::broadcast_bitcoin_rpc(config, tx).await,
207 #[allow(unreachable_patterns)]
208 _ => unreachable!("ChainSource must have at least one feature enabled"),
209 }
210 }
211
212 pub async fn fetch_fee_rate(&self, target_blocks: u16) -> Result<f64, Error> {
213 match self {
214 #[cfg(feature = "esplora")]
215 ChainSource::Esplora(config) => {
216 esplora::fetch_fee_rate_esplora(config, target_blocks).await
217 }
218 #[cfg(feature = "electrum")]
219 ChainSource::Electrum(config) => {
220 electrum::fetch_fee_rate_electrum(config, target_blocks).await
221 }
222 #[cfg(feature = "bitcoin-rpc")]
223 ChainSource::BitcoinRpc(config) => {
224 bitcoin_rpc::fetch_fee_rate_bitcoin_rpc(config, target_blocks).await
225 }
226 #[allow(unreachable_patterns)]
227 _ => unreachable!("ChainSource must have at least one feature enabled"),
228 }
229 }
230}
231
232#[cfg(test)]
233mod tests {
234 use super::*;
235
236 #[cfg(feature = "bitcoin-rpc")]
237 #[test]
238 fn bitcoin_rpc_debug_redacts_password() {
239 let config = BitcoinRpcConfig {
240 host: "127.0.0.1".to_string(),
241 port: 8332,
242 user: "rpc-user".to_string(),
243 password: "rpc-password-secret".to_string(),
244 wallet_rescan_from_height: Some(800_000),
245 };
246
247 let debug = format!("{config:?}");
248
249 assert!(debug.contains("127.0.0.1"));
250 assert!(debug.contains("rpc-user"));
251 assert!(debug.contains("[REDACTED]"));
252 assert!(!debug.contains("rpc-password-secret"));
253 }
254
255 #[cfg(feature = "esplora")]
256 #[test]
257 fn esplora_debug_redacts_url_credentials() {
258 let source = ChainSource::Esplora(EsploraConfig {
259 url: "https://esplora-user:esplora-secret@example.com/api".to_string(),
260 parallel_requests: 4,
261 });
262
263 let debug = format!("{source:?}");
264
265 assert!(debug.contains("https://example.com/api"));
266 assert!(!debug.contains("esplora-user"));
267 assert!(!debug.contains("esplora-secret"));
268 }
269
270 #[cfg(feature = "electrum")]
271 #[test]
272 fn electrum_debug_redacts_url_credentials() {
273 let source = ChainSource::Electrum(ElectrumConfig {
274 url: "ssl://electrum-user:electrum-secret@example.com:50002".to_string(),
275 batch_size: 5,
276 });
277
278 let debug = format!("{source:?}");
279
280 assert!(debug.contains("ssl://example.com:50002"));
281 assert!(!debug.contains("electrum-user"));
282 assert!(!debug.contains("electrum-secret"));
283 }
284
285 #[cfg(feature = "electrum")]
286 #[test]
287 fn rejects_zero_electrum_batch_size() {
288 let chain_source = ChainSource::Electrum(ElectrumConfig {
289 url: "tcp://127.0.0.1:50001".to_string(),
290 batch_size: 0,
291 });
292
293 let error = chain_source
294 .validate()
295 .expect_err("zero Electrum batch size should fail");
296
297 assert!(matches!(error, Error::InvalidConfig(_)));
298 }
299}