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 = "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}