• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

payjoin / rust-payjoin / 24134407852

08 Apr 2026 12:05PM UTC coverage: 79.263% (-5.1%) from 84.34%
24134407852

Pull #1469

github

web-flow
Merge 50b8c94d1 into 45d286f0e
Pull Request #1469: Add optional esplora wallet setup for payjoin-cli backend

189 of 364 new or added lines in 4 files covered. (51.92%)

529 existing lines in 9 files now uncovered.

10320 of 13020 relevant lines covered (79.26%)

396.39 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

55.01
/payjoin-cli/src/app/wallet.rs
1
use std::collections::HashMap;
2
use std::sync::Arc;
3

4
use anyhow::Result;
5
use payjoin::bitcoin::psbt::Psbt as PayjoinPsbt;
6
use payjoin::bitcoin::{Address, Amount, FeeRate, Network, Script, Transaction, Txid};
7
use payjoin::receive::InputPair;
8

9
pub trait PayjoinWallet: Send + Sync {
10
    fn create_psbt(
11
        &self,
12
        outputs: HashMap<String, Amount>,
13
        fee_rate: FeeRate,
14
        lock_unspent: bool,
15
    ) -> Result<PayjoinPsbt>;
16

17
    fn process_psbt(&self, psbt: &PayjoinPsbt) -> Result<PayjoinPsbt>;
18

19
    fn can_broadcast(&self, tx: &Transaction) -> Result<bool>;
20

21
    fn broadcast_tx(&self, tx: &Transaction) -> Result<Txid>;
22

23
    fn is_mine(&self, script: &Script) -> Result<bool>;
24

25
    #[cfg(feature = "v2")]
26
    fn get_raw_transaction(&self, txid: &Txid) -> Result<Option<payjoin::bitcoin::Transaction>>;
27

28
    fn get_new_address(&self) -> Result<Address>;
29

30
    fn list_unspent(&self) -> Result<Vec<InputPair>>;
31

32
    fn has_spendable_utxos(&self) -> Result<bool>;
33

34
    fn network(&self) -> Result<Network>;
35
}
36

37
#[cfg(feature = "esplora")]
38
mod esplora_backend {
39
    use std::collections::HashMap;
40
    use std::str::FromStr;
41
    use std::sync::{Arc, Mutex};
42

43
    use anyhow::{anyhow, Result};
44
    use bdk_esplora::esplora_client::Builder;
45
    use bdk_esplora::{esplora_client, EsploraAsyncExt};
46
    use bdk_wallet::bitcoin::ScriptBuf;
47
    use bdk_wallet::signer::SignOptions;
48
    use bdk_wallet::{KeychainKind, Wallet as BdkWalletInner};
49
    use payjoin::bitcoin::psbt::{Input, Psbt};
50
    use payjoin::bitcoin::{
51
        Address, Amount, FeeRate, Network, OutPoint, Transaction, TxIn, TxOut, Txid,
52
    };
53
    use payjoin::receive::InputPair;
54

55
    use crate::app::wallet::PayjoinWallet;
56

57
    #[derive(Clone)]
58
    pub struct BdkWallet {
59
        wallet: Arc<Mutex<BdkWalletInner>>,
60
        esplora_client: Arc<esplora_client::AsyncClient>,
61
    }
62

63
    impl BdkWallet {
64
        pub fn new(
2✔
65
            descriptor: &str,
2✔
66
            change_descriptor: Option<&str>,
2✔
67
            network: Network,
2✔
68
            esplora_url: &str,
2✔
69
        ) -> Result<Self> {
2✔
70
            let bdk_network = match network {
2✔
NEW
71
                Network::Bitcoin => bdk_wallet::bitcoin::Network::Bitcoin,
×
NEW
72
                Network::Testnet => bdk_wallet::bitcoin::Network::Testnet,
×
NEW
73
                Network::Signet => bdk_wallet::bitcoin::Network::Signet,
×
74
                Network::Regtest => bdk_wallet::bitcoin::Network::Regtest,
2✔
NEW
75
                Network::Testnet4 => bdk_wallet::bitcoin::Network::Testnet4,
×
76
            };
77

78
            let change_desc = super::derive_change_descriptor(descriptor, change_descriptor);
2✔
79

80
            let wallet = BdkWalletInner::create(descriptor.to_owned(), change_desc)
2✔
81
                .network(bdk_network)
2✔
82
                .create_wallet_no_persist()
2✔
83
                .map_err(|e| anyhow!("Failed to create wallet: {}", e))?;
2✔
84

NEW
85
            let esplora_client = Builder::new(esplora_url)
×
NEW
86
                .build_async()
×
NEW
87
                .map_err(|e| anyhow!("Failed to create esplora client: {}", e))?;
×
88

NEW
89
            let this = Self {
×
NEW
90
                wallet: Arc::new(Mutex::new(wallet)),
×
NEW
91
                esplora_client: Arc::new(esplora_client),
×
NEW
92
            };
×
NEW
93
            this.sync()?;
×
NEW
94
            Ok(this)
×
95
        }
2✔
96

NEW
97
        pub fn sync(&self) -> Result<()> {
×
NEW
98
            let request = {
×
NEW
99
                let wallet = self.wallet.lock().map_err(|e| anyhow!("Lock error: {}", e))?;
×
NEW
100
                wallet.start_full_scan()
×
101
            };
102

NEW
103
            let update = tokio::task::block_in_place(|| {
×
NEW
104
                tokio::runtime::Handle::current().block_on(async {
×
NEW
105
                    self.esplora_client
×
NEW
106
                        .full_scan(request, 10, 5)
×
NEW
107
                        .await
×
NEW
108
                        .map_err(|e| anyhow!("Failed to sync wallet: {}", e))
×
NEW
109
                })
×
NEW
110
            })?;
×
111

NEW
112
            let mut wallet = self.wallet.lock().map_err(|e| anyhow!("Lock error: {}", e))?;
×
NEW
113
            wallet.apply_update(update).map_err(|e| anyhow!("Failed to apply update: {}", e))?;
×
NEW
114
            Ok(())
×
NEW
115
        }
×
116
    }
117

118
    impl PayjoinWallet for BdkWallet {
NEW
119
        fn create_psbt(
×
NEW
120
            &self,
×
NEW
121
            outputs: HashMap<String, Amount>,
×
NEW
122
            fee_rate: FeeRate,
×
NEW
123
            _lock_unspent: bool,
×
NEW
124
        ) -> Result<Psbt> {
×
NEW
125
            let mut wallet = self.wallet.lock().map_err(|e| anyhow!("Lock error: {}", e))?;
×
126

NEW
127
            let mut builder = wallet.build_tx();
×
NEW
128
            for (address, amount) in outputs {
×
NEW
129
                let bdk_addr = bdk_wallet::bitcoin::Address::from_str(&address)
×
NEW
130
                    .map_err(|e| anyhow!("Invalid address: {}", e))?;
×
NEW
131
                let checked_addr = bdk_addr.assume_checked();
×
NEW
132
                builder.add_recipient(checked_addr.script_pubkey(), amount);
×
133
            }
NEW
134
            builder.fee_rate(fee_rate);
×
135

NEW
136
            let psbt =
×
NEW
137
                builder.finish().map_err(|e| anyhow!("Failed to build transaction: {}", e))?;
×
NEW
138
            let psbt_hex = psbt.to_string();
×
NEW
139
            let psbt: Psbt =
×
NEW
140
                Psbt::from_str(&psbt_hex).map_err(|e| anyhow!("Failed to parse PSBT: {}", e))?;
×
NEW
141
            Ok(psbt)
×
NEW
142
        }
×
143

NEW
144
        fn process_psbt(&self, psbt: &Psbt) -> Result<Psbt> {
×
NEW
145
            let wallet = self.wallet.lock().map_err(|e| anyhow!("Lock error: {}", e))?;
×
NEW
146
            let psbt_hex = psbt.to_string();
×
NEW
147
            let mut bdk_psbt = bdk_wallet::bitcoin::Psbt::from_str(&psbt_hex)
×
NEW
148
                .map_err(|e| anyhow!("Failed to parse PSBT: {}", e))?;
×
NEW
149
            let sign_options = SignOptions { trust_witness_utxo: true, ..SignOptions::default() };
×
150
            // In payjoin, sign() will return finalized=false because the
151
            // counterparty's inputs are not yet signed — that's fine. We only
152
            // need BDK to sign the inputs it owns.
NEW
153
            wallet
×
NEW
154
                .sign(&mut bdk_psbt, sign_options)
×
NEW
155
                .map_err(|e| anyhow!("Failed to sign PSBT: {}", e))?;
×
NEW
156
            let signed_psbt_hex = bdk_psbt.to_string();
×
NEW
157
            let signed_psbt: Psbt = Psbt::from_str(&signed_psbt_hex)
×
NEW
158
                .map_err(|e| anyhow!("Failed to parse signed PSBT: {}", e))?;
×
NEW
159
            Ok(signed_psbt)
×
NEW
160
        }
×
161

NEW
162
        fn can_broadcast(&self, _tx: &Transaction) -> Result<bool> { Ok(true) }
×
163

NEW
164
        fn broadcast_tx(&self, tx: &Transaction) -> Result<Txid> {
×
NEW
165
            tokio::task::block_in_place(|| {
×
NEW
166
                tokio::runtime::Handle::current().block_on(async {
×
NEW
167
                    self.esplora_client
×
NEW
168
                        .broadcast(tx)
×
NEW
169
                        .await
×
NEW
170
                        .map_err(|e| anyhow!("Failed to broadcast transaction: {}", e))
×
NEW
171
                })
×
NEW
172
            })?;
×
NEW
173
            Ok(tx.compute_txid())
×
NEW
174
        }
×
175

NEW
176
        fn is_mine(&self, script: &payjoin::bitcoin::Script) -> Result<bool> {
×
NEW
177
            let wallet = self.wallet.lock().map_err(|e| anyhow!("Lock error: {}", e))?;
×
NEW
178
            let script = ScriptBuf::from_bytes(script.as_bytes().to_vec());
×
NEW
179
            Ok(wallet.is_mine(script))
×
NEW
180
        }
×
181

182
        #[cfg(feature = "v2")]
NEW
183
        fn get_raw_transaction(
×
NEW
184
            &self,
×
NEW
185
            _txid: &Txid,
×
NEW
186
        ) -> Result<Option<payjoin::bitcoin::Transaction>> {
×
NEW
187
            Ok(None)
×
NEW
188
        }
×
189

NEW
190
        fn get_new_address(&self) -> Result<Address> {
×
NEW
191
            let mut wallet = self.wallet.lock().map_err(|e| anyhow!("Lock error: {}", e))?;
×
NEW
192
            let address_info = wallet.reveal_next_address(KeychainKind::External);
×
NEW
193
            let addr_str = address_info.address.to_string();
×
NEW
194
            Ok(Address::from_str(&addr_str)?.assume_checked())
×
NEW
195
        }
×
196

NEW
197
        fn list_unspent(&self) -> Result<Vec<InputPair>> {
×
NEW
198
            let wallet = self.wallet.lock().map_err(|e| anyhow!("Lock error: {}", e))?;
×
NEW
199
            let unspents: Vec<_> = wallet.list_unspent().collect();
×
200

NEW
201
            unspents
×
NEW
202
                .into_iter()
×
NEW
203
                .map(|utxo| {
×
NEW
204
                    let psbtin = Input {
×
NEW
205
                        witness_utxo: Some(TxOut {
×
NEW
206
                            value: utxo.txout.value,
×
NEW
207
                            script_pubkey: utxo.txout.script_pubkey,
×
NEW
208
                        }),
×
NEW
209
                        ..Default::default()
×
NEW
210
                    };
×
NEW
211
                    let txin = TxIn {
×
NEW
212
                        previous_output: OutPoint {
×
NEW
213
                            txid: utxo.outpoint.txid,
×
NEW
214
                            vout: utxo.outpoint.vout,
×
NEW
215
                        },
×
NEW
216
                        ..Default::default()
×
NEW
217
                    };
×
NEW
218
                    InputPair::new(txin, psbtin, None)
×
NEW
219
                        .map_err(|e| anyhow!("Invalid input pair: {}", e))
×
NEW
220
                })
×
NEW
221
                .collect()
×
NEW
222
        }
×
223

NEW
224
        fn has_spendable_utxos(&self) -> Result<bool> {
×
NEW
225
            let unspent = self.list_unspent()?;
×
NEW
226
            Ok(!unspent.is_empty())
×
NEW
227
        }
×
228

NEW
229
        fn network(&self) -> Result<Network> {
×
NEW
230
            let wallet = self.wallet.lock().map_err(|e| anyhow!("Lock error: {}", e))?;
×
NEW
231
            match wallet.network() {
×
NEW
232
                bdk_wallet::bitcoin::Network::Bitcoin => Ok(Network::Bitcoin),
×
NEW
233
                bdk_wallet::bitcoin::Network::Testnet => Ok(Network::Testnet),
×
NEW
234
                bdk_wallet::bitcoin::Network::Signet => Ok(Network::Signet),
×
NEW
235
                bdk_wallet::bitcoin::Network::Regtest => Ok(Network::Regtest),
×
NEW
236
                _ => Ok(Network::Testnet),
×
237
            }
NEW
238
        }
×
239
    }
240

241
    pub use BdkWallet as BdkWalletImpl;
242
}
243

244
#[cfg(all(feature = "bitcoind", not(feature = "esplora")))]
245
mod bitcoind_backend {
246
    use std::collections::HashMap;
247
    use std::sync::Arc;
248

249
    use anyhow::{anyhow, Context, Result};
250
    use bitcoind_async_client::corepc_types::model::ListUnspentItem;
251
    use bitcoind_async_client::traits::{Broadcaster, Reader, Signer, Wallet};
252
    use bitcoind_async_client::types::{CreateRawTransactionOutput, WalletCreateFundedPsbtOptions};
253
    use bitcoind_async_client::{Auth, Client as AsyncBitcoinRpc};
254
    use payjoin::bitcoin::psbt::{Input, Psbt};
255
    use payjoin::bitcoin::{
256
        Address, Amount, FeeRate, Network, OutPoint, Script, Transaction, TxIn, TxOut, Txid,
257
    };
258
    use payjoin::receive::InputPair;
259

260
    use super::PayjoinWallet;
261

262
    #[derive(Clone)]
263
    pub struct BitcoindWallet {
264
        rpc: Arc<AsyncBitcoinRpc>,
265
    }
266

267
    impl BitcoindWallet {
268
        pub fn new(config: &crate::app::config::BitcoindConfig) -> Result<Self> {
2✔
269
            let auth = match &config.cookie {
2✔
270
                Some(cookie) if cookie.as_os_str().is_empty() =>
2✔
NEW
271
                    return Err(anyhow!(
×
NEW
272
                        "Cookie authentication enabled but no cookie path provided in config.toml"
×
NEW
273
                    )),
×
274
                Some(cookie) => Auth::CookieFile(cookie.into()),
2✔
NEW
275
                None => Auth::UserPass(config.rpcuser.clone(), config.rpcpassword.clone()),
×
276
            };
277

278
            let rpc = AsyncBitcoinRpc::new(config.rpchost.to_string(), auth, None, None, None)?;
2✔
279

280
            Ok(Self { rpc: Arc::new(rpc) })
2✔
281
        }
2✔
282
    }
283

284
    impl PayjoinWallet for BitcoindWallet {
285
        fn create_psbt(
1✔
286
            &self,
1✔
287
            outputs: HashMap<String, Amount>,
1✔
288
            fee_rate: FeeRate,
1✔
289
            lock_unspent: bool,
1✔
290
        ) -> Result<Psbt> {
1✔
291
            let fee_sat_per_vb = fee_rate.to_sat_per_vb_ceil();
1✔
292
            tracing::debug!("Fee rate sat/vb: {}", fee_sat_per_vb);
1✔
293

294
            let options = WalletCreateFundedPsbtOptions {
1✔
295
                fee_rate: Some(fee_sat_per_vb as f64),
1✔
296
                lock_unspents: Some(lock_unspent),
1✔
297
                replaceable: None,
1✔
298
                conf_target: None,
1✔
299
            };
1✔
300

301
            let locktime = Some(tokio::task::block_in_place(|| {
1✔
302
                tokio::runtime::Handle::current()
1✔
303
                    .block_on(async { self.rpc.get_block_count().await })
1✔
304
            })? as u32);
1✔
305

306
            let result = tokio::task::block_in_place(|| {
1✔
307
                tokio::runtime::Handle::current().block_on(async {
1✔
308
                    self.rpc
1✔
309
                        .wallet_create_funded_psbt(
1✔
310
                            &[],
1✔
311
                            &outputs
1✔
312
                                .iter()
1✔
313
                                .map(|(k, v)| CreateRawTransactionOutput::AddressAmount {
1✔
314
                                    address: k.clone(),
1✔
315
                                    amount: v.to_btc(),
1✔
316
                                })
1✔
317
                                .collect::<Vec<_>>(),
1✔
318
                            locktime,
1✔
319
                            Some(options),
1✔
320
                            None,
1✔
321
                        )
322
                        .await
1✔
323
                })
1✔
324
            })?;
1✔
325

326
            let processed = tokio::task::block_in_place(|| {
1✔
327
                tokio::runtime::Handle::current().block_on(async {
1✔
328
                    self.rpc.wallet_process_psbt(&result.psbt.to_string(), None, None, None).await
1✔
329
                })
1✔
330
            })?
1✔
331
            .psbt;
332

333
            Ok(processed)
1✔
334
        }
1✔
335

336
        fn process_psbt(&self, psbt: &Psbt) -> Result<Psbt> {
2✔
337
            let psbt_str = psbt.to_string();
2✔
338
            let processed = tokio::task::block_in_place(|| {
2✔
339
                tokio::runtime::Handle::current().block_on(async {
2✔
340
                    self.rpc.wallet_process_psbt(&psbt_str, Some(true), None, None).await
2✔
341
                })
2✔
342
            })?;
2✔
343
            Ok(processed.psbt)
2✔
344
        }
2✔
345

346
        fn can_broadcast(&self, tx: &Transaction) -> Result<bool> {
1✔
347
            let mempool_results = tokio::task::block_in_place(|| {
1✔
348
                tokio::runtime::Handle::current()
1✔
349
                    .block_on(async { self.rpc.test_mempool_accept(tx).await })
1✔
350
            })?;
1✔
351

352
            mempool_results
1✔
353
                .results
1✔
354
                .first()
1✔
355
                .map(|result| result.reject_reason.is_none())
1✔
356
                .ok_or_else(|| anyhow!("No mempool results returned on broadcast check"))
1✔
357
        }
1✔
358

359
        fn broadcast_tx(&self, tx: &Transaction) -> Result<Txid> {
1✔
360
            tokio::task::block_in_place(|| {
1✔
361
                tokio::runtime::Handle::current()
1✔
362
                    .block_on(async { self.rpc.send_raw_transaction(tx).await })
1✔
363
            })
1✔
364
            .context("Failed to broadcast transaction")
1✔
365
        }
1✔
366

367
        fn is_mine(&self, script: &Script) -> Result<bool> {
3✔
368
            if let Ok(address) = Address::from_script(script, self.network()?) {
3✔
369
                let info = tokio::task::block_in_place(|| {
3✔
370
                    tokio::runtime::Handle::current()
3✔
371
                        .block_on(async { self.rpc.get_address_info(&address).await })
3✔
372
                })
3✔
373
                .context("Failed to get address info")?;
3✔
374
                Ok(info.is_mine)
3✔
375
            } else {
NEW
376
                Ok(false)
×
377
            }
378
        }
3✔
379

380
        #[cfg(feature = "v2")]
381
        fn get_raw_transaction(
382
            &self,
383
            txid: &Txid,
384
        ) -> Result<Option<payjoin::bitcoin::Transaction>> {
385
            let raw_tx = tokio::task::block_in_place(|| {
386
                tokio::runtime::Handle::current().block_on(async {
387
                    match self.rpc.get_transaction(txid).await {
388
                        Ok(rpc_res) => Ok(Some(rpc_res.tx)),
389
                        Err(e) =>
390
                            if e.is_tx_not_found() {
391
                                Ok(None)
392
                            } else {
393
                                Err(e)
394
                            },
395
                    }
396
                })
397
            })?;
398
            Ok(raw_tx)
399
        }
400

401
        fn get_new_address(&self) -> Result<Address> {
2✔
402
            let addr = tokio::task::block_in_place(|| {
2✔
403
                tokio::runtime::Handle::current()
2✔
404
                    .block_on(async { self.rpc.get_new_address().await })
2✔
405
            })
2✔
406
            .context("Failed to get new address")?;
2✔
407
            Ok(addr)
2✔
408
        }
2✔
409

410
        fn list_unspent(&self) -> Result<Vec<InputPair>> {
2✔
411
            let unspent = tokio::task::block_in_place(|| {
2✔
412
                tokio::runtime::Handle::current()
2✔
413
                    .block_on(async { self.rpc.list_unspent(None, None, None, None, None).await })
2✔
414
            })
2✔
415
            .context("Failed to list unspent")?;
2✔
416
            Ok(unspent.0.into_iter().map(input_pair_from_corepc).collect())
2✔
417
        }
2✔
418

419
        fn has_spendable_utxos(&self) -> Result<bool> {
1✔
420
            let unspent = self.list_unspent()?;
1✔
421
            Ok(!unspent.is_empty())
1✔
422
        }
1✔
423

424
        fn network(&self) -> Result<Network> {
5✔
425
            tokio::task::block_in_place(|| {
5✔
426
                tokio::runtime::Handle::current().block_on(async { self.rpc.network().await })
5✔
427
            })
5✔
428
            .map_err(|e| anyhow!("Failed to get blockchain info: {e}"))
5✔
429
        }
5✔
430
    }
431

432
    fn input_pair_from_corepc(utxo: ListUnspentItem) -> InputPair {
2✔
433
        let psbtin = Input {
2✔
434
            witness_utxo: Some(TxOut {
2✔
435
                value: Amount::from_btc(utxo.amount.to_btc()).expect("Valid amount"),
2✔
436
                script_pubkey: utxo.script_pubkey,
2✔
437
            }),
2✔
438
            redeem_script: None,
2✔
439
            witness_script: None,
2✔
440
            ..Default::default()
2✔
441
        };
2✔
442
        let txin = TxIn {
2✔
443
            previous_output: OutPoint { txid: utxo.txid, vout: utxo.vout },
2✔
444
            ..Default::default()
2✔
445
        };
2✔
446
        InputPair::new(txin, psbtin, None).expect("Input pair should be valid")
2✔
447
    }
2✔
448

449
    pub use BitcoindWallet as BitcoindWalletImpl;
450
}
451

452
#[cfg(feature = "esplora")]
453
pub(crate) fn parse_network(name: Option<&str>) -> Result<Network> {
16✔
454
    use anyhow::anyhow;
455
    match name {
16✔
456
        Some("mainnet") | Some("bitcoin") | None => Ok(Network::Bitcoin),
14✔
457
        Some("testnet") => Ok(Network::Testnet),
10✔
458
        Some("testnet4") => Ok(Network::Testnet4),
8✔
459
        Some("signet") => Ok(Network::Signet),
6✔
460
        Some("regtest") => Ok(Network::Regtest),
4✔
461
        Some(n) => Err(anyhow!("Unknown network: {}", n)),
2✔
462
    }
463
}
16✔
464

465
/// Derive a change descriptor from the receive descriptor when one is not
466
/// supplied. Assumes BIP44-family `.../0/*` external chain convention and
467
/// rewrites the final step to `1/*`. If the descriptor does not end in
468
/// `/0/*`, returns the original descriptor unchanged (BDK will then use the
469
/// same descriptor for both keychains).
470
#[cfg(feature = "esplora")]
471
pub(crate) fn derive_change_descriptor(descriptor: &str, change: Option<&str>) -> String {
8✔
472
    if let Some(c) = change {
8✔
473
        return c.to_owned();
2✔
474
    }
6✔
475
    if let Some(base) = descriptor.strip_suffix("/0/*") {
6✔
476
        format!("{}/1/*", base)
2✔
477
    } else {
478
        descriptor.to_owned()
4✔
479
    }
480
}
8✔
481

482
#[cfg(feature = "esplora")]
NEW
483
pub fn create_wallet(config: &super::config::Config) -> Result<Arc<dyn PayjoinWallet>> {
×
484
    use anyhow::anyhow;
485

486
    use crate::app::wallet::esplora_backend::BdkWalletImpl;
NEW
487
    let wallet_config = config
×
NEW
488
        .wallet
×
NEW
489
        .as_ref()
×
NEW
490
        .ok_or_else(|| anyhow!("wallet config required. Set --descriptor and --esplora-url"))?;
×
NEW
491
    let network = parse_network(wallet_config.network.as_deref())?;
×
NEW
492
    let descriptor = wallet_config
×
NEW
493
        .descriptor
×
NEW
494
        .as_ref()
×
NEW
495
        .ok_or_else(|| anyhow!("--descriptor required for esplora backend"))?;
×
NEW
496
    let esplora_url = wallet_config
×
NEW
497
        .esplora_url
×
NEW
498
        .as_ref()
×
NEW
499
        .ok_or_else(|| anyhow!("--esplora-url required for esplora backend"))?;
×
NEW
500
    Ok(Arc::new(BdkWalletImpl::new(
×
NEW
501
        descriptor,
×
NEW
502
        wallet_config.change_descriptor.as_deref(),
×
NEW
503
        network,
×
NEW
504
        esplora_url,
×
NEW
505
    )?))
×
NEW
506
}
×
507

508
#[cfg(all(test, feature = "esplora"))]
509
mod tests {
510
    use super::*;
511

512
    #[test]
513
    fn parse_network_known_values() {
2✔
514
        assert_eq!(parse_network(None).unwrap(), Network::Bitcoin);
2✔
515
        assert_eq!(parse_network(Some("mainnet")).unwrap(), Network::Bitcoin);
2✔
516
        assert_eq!(parse_network(Some("bitcoin")).unwrap(), Network::Bitcoin);
2✔
517
        assert_eq!(parse_network(Some("testnet")).unwrap(), Network::Testnet);
2✔
518
        assert_eq!(parse_network(Some("testnet4")).unwrap(), Network::Testnet4);
2✔
519
        assert_eq!(parse_network(Some("signet")).unwrap(), Network::Signet);
2✔
520
        assert_eq!(parse_network(Some("regtest")).unwrap(), Network::Regtest);
2✔
521
    }
2✔
522

523
    #[test]
524
    fn parse_network_rejects_unknown() {
2✔
525
        let err = parse_network(Some("liquid")).unwrap_err();
2✔
526
        assert!(err.to_string().contains("Unknown network"));
2✔
527
    }
2✔
528

529
    #[test]
530
    fn derive_change_descriptor_uses_explicit_when_provided() {
2✔
531
        let receive = "wpkh(tprv.../0/*)";
2✔
532
        let explicit = "wpkh(tprv.../1/*)";
2✔
533
        assert_eq!(derive_change_descriptor(receive, Some(explicit)), explicit);
2✔
534
    }
2✔
535

536
    #[test]
537
    fn derive_change_descriptor_swaps_external_to_internal() {
2✔
538
        // The function operates on the raw descriptor string; we only assert
539
        // that a `/0/*` suffix gets rewritten to `/1/*`.
540
        // The helper operates on a raw `.../0/*` suffix; descriptors that
541
        // wrap the key in `wpkh(...)` end in `)` and fall through to the
542
        // passthrough branch (covered by the next test). Here we cover the
543
        // raw-suffix case.
544
        let receive = "[fingerprint/84h/1h/0h]tprvFOO/0/*";
2✔
545
        assert_eq!(derive_change_descriptor(receive, None), "[fingerprint/84h/1h/0h]tprvFOO/1/*");
2✔
546
    }
2✔
547

548
    #[test]
549
    fn derive_change_descriptor_passthrough_when_no_external_suffix() {
2✔
550
        let receive = "wpkh(tprv.../*)";
2✔
551
        assert_eq!(derive_change_descriptor(receive, None), receive);
2✔
552
    }
2✔
553

554
    #[test]
555
    fn bdk_wallet_new_rejects_invalid_descriptor() {
2✔
556
        // Constructing a BdkWallet with garbage should fail before any network
557
        // I/O is attempted, so this test is hermetic.
558
        use super::esplora_backend::BdkWallet;
559
        let res = BdkWallet::new("not a descriptor", None, Network::Regtest, "http://127.0.0.1:1");
2✔
560
        assert!(res.is_err());
2✔
561
    }
2✔
562
}
563

564
#[cfg(all(feature = "bitcoind", not(feature = "esplora")))]
565
pub fn create_wallet(config: &super::config::Config) -> Result<Arc<dyn PayjoinWallet>> {
2✔
566
    use crate::app::wallet::bitcoind_backend::BitcoindWalletImpl;
567
    Ok(Arc::new(BitcoindWalletImpl::new(&config.bitcoind)?))
2✔
568
}
2✔
569

570
#[cfg(all(not(feature = "esplora"), not(feature = "bitcoind")))]
571
pub fn create_wallet(_config: &super::config::Config) -> Result<Arc<dyn PayjoinWallet>> {
572
    Err(anyhow::anyhow!("No wallet backend enabled. Enable esplora or bitcoind feature."))
573
}
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc