Skip to main content

cdk_bdk/chain/
mod.rs

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/// Configuration for connecting to Bitcoin RPC
18#[derive(Clone)]
19pub struct BitcoinRpcConfig {
20    /// Bitcoin RPC server hostname or IP address
21    pub host: String,
22    /// Bitcoin RPC server port number
23    pub port: u16,
24    /// Username for Bitcoin RPC authentication
25    pub user: String,
26    /// Password for Bitcoin RPC authentication
27    pub password: String,
28    /// Optional wallet birthday height used when creating a fresh wallet.
29    ///
30    /// If unset, a fresh wallet starts at the current Bitcoin Core tip. Set
31    /// this when restoring a wallet from seed to scan from a known birthday
32    /// height. Existing wallets are never rewound.
33    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/// Configuration for connecting to Esplora
49#[derive(Clone)]
50pub struct EsploraConfig {
51    /// URL of the Esplora server endpoint
52    pub url: String,
53    /// Number of parallel requests to use during sync
54    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/// Configuration for connecting to Electrum
67#[derive(Clone)]
68pub struct ElectrumConfig {
69    /// URL of the Electrum server endpoint
70    pub url: String,
71    /// Number of scripts to request in each Electrum batch
72    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/// Source of blockchain data for the BDK wallet
85#[derive(Clone)]
86pub enum ChainSource {
87    /// Use an Esplora server for blockchain data
88    #[cfg(feature = "esplora")]
89    Esplora(EsploraConfig),
90    /// Use an Electrum server for blockchain data
91    #[cfg(feature = "electrum")]
92    Electrum(ElectrumConfig),
93    /// Use Bitcoin Core RPC for blockchain data
94    #[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/// Classified result of submitting a transaction to a chain backend.
114#[derive(Debug, Clone, Copy, PartialEq, Eq)]
115pub(crate) enum BroadcastOutcome {
116    /// Backend accepted the transaction.
117    Accepted,
118    /// Backend already knows the transaction; this is success-equivalent.
119    AlreadyKnown,
120}
121
122/// Classification for broadcast errors.
123#[derive(Debug, Clone, Copy, PartialEq, Eq)]
124pub(crate) enum BroadcastErrorKind {
125    /// Deterministic backend rejection.
126    Rejected,
127    /// Network or upstream failure expected to resolve on retry.
128    Transient,
129    /// Ambiguous or unrecognized error; retry conservatively.
130    Unknown,
131}
132
133/// A classified broadcast failure.
134#[derive(Debug, Clone, PartialEq, Eq)]
135pub(crate) struct BroadcastFailure {
136    /// Failure class.
137    pub kind: BroadcastErrorKind,
138    /// Human-readable backend error.
139    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}