]> Untitled Git - bdk-cli/commitdiff
ref(handlers):mv handler fns into offline & others
authorVihiga Tyonum <withtvpeter@gmail.com>
Tue, 21 Apr 2026 10:19:03 +0000 (11:19 +0100)
committerVihiga Tyonum <withtvpeter@gmail.com>
Fri, 5 Jun 2026 16:12:20 +0000 (17:12 +0100)
- move handler fns into offline, online, wallets
modules

src/handlers/offline.rs [new file with mode: 0644]
src/handlers/online.rs [new file with mode: 0644]
src/handlers/wallets.rs [new file with mode: 0644]

diff --git a/src/handlers/offline.rs b/src/handlers/offline.rs
new file mode 100644 (file)
index 0000000..9d564eb
--- /dev/null
@@ -0,0 +1,516 @@
+use crate::commands::OfflineWalletSubCommand::*;
+use crate::commands::{CliOpts, OfflineWalletSubCommand, WalletOpts};
+use crate::error::BDKCliError as Error;
+use crate::utils::shorten;
+use bdk_wallet::bitcoin::base64::Engine;
+use bdk_wallet::bitcoin::base64::prelude::BASE64_STANDARD;
+use bdk_wallet::bitcoin::consensus::encode::serialize_hex;
+use bdk_wallet::bitcoin::script::PushBytesBuf;
+use bdk_wallet::bitcoin::{Address, Amount, FeeRate, Psbt, Sequence, Txid};
+use bdk_wallet::chain::ChainPosition;
+use bdk_wallet::{KeychainKind, SignOptions, Wallet};
+use cli_table::format::Justify;
+use cli_table::{Cell, CellStruct, Style, Table};
+use serde_json::json;
+use std::collections::BTreeMap;
+use std::str::FromStr;
+
+/// Execute an offline wallet sub-command
+///
+/// Offline wallet sub-commands are described in [`OfflineWalletSubCommand`].
+pub fn handle_offline_wallet_subcommand(
+    wallet: &mut Wallet,
+    wallet_opts: &WalletOpts,
+    cli_opts: &CliOpts,
+    offline_subcommand: OfflineWalletSubCommand,
+) -> Result<String, Error> {
+    match offline_subcommand {
+        NewAddress => {
+            let addr = wallet.reveal_next_address(KeychainKind::External);
+            if cli_opts.pretty {
+                let table = vec![
+                    vec!["Address".cell().bold(true), addr.address.to_string().cell()],
+                    vec![
+                        "Index".cell().bold(true),
+                        addr.index.to_string().cell().justify(Justify::Right),
+                    ],
+                ]
+                .table()
+                .display()
+                .map_err(|e| Error::Generic(e.to_string()))?;
+                Ok(format!("{table}"))
+            } else if wallet_opts.verbose {
+                Ok(serde_json::to_string_pretty(&json!({
+                    "address": addr.address,
+                    "index": addr.index
+                }))?)
+            } else {
+                Ok(serde_json::to_string_pretty(&json!({
+                    "address": addr.address,
+                }))?)
+            }
+        }
+        UnusedAddress => {
+            let addr = wallet.next_unused_address(KeychainKind::External);
+
+            if cli_opts.pretty {
+                let table = vec![
+                    vec!["Address".cell().bold(true), addr.address.to_string().cell()],
+                    vec![
+                        "Index".cell().bold(true),
+                        addr.index.to_string().cell().justify(Justify::Right),
+                    ],
+                ]
+                .table()
+                .display()
+                .map_err(|e| Error::Generic(e.to_string()))?;
+                Ok(format!("{table}"))
+            } else if wallet_opts.verbose {
+                Ok(serde_json::to_string_pretty(&json!({
+                    "address": addr.address,
+                    "index": addr.index
+                }))?)
+            } else {
+                Ok(serde_json::to_string_pretty(&json!({
+                    "address": addr.address,
+                }))?)
+            }
+        }
+        Unspent => {
+            let utxos = wallet.list_unspent().collect::<Vec<_>>();
+            if cli_opts.pretty {
+                let mut rows: Vec<Vec<CellStruct>> = vec![];
+                for utxo in &utxos {
+                    let height = utxo
+                        .chain_position
+                        .confirmation_height_upper_bound()
+                        .map(|h| h.to_string())
+                        .unwrap_or("Pending".to_string());
+
+                    let block_hash = match &utxo.chain_position {
+                        ChainPosition::Confirmed { anchor, .. } => anchor.block_id.hash.to_string(),
+                        ChainPosition::Unconfirmed { .. } => "Unconfirmed".to_string(),
+                    };
+
+                    rows.push(vec![
+                        shorten(utxo.outpoint, 8, 10).cell(),
+                        utxo.txout
+                            .value
+                            .to_sat()
+                            .to_string()
+                            .cell()
+                            .justify(Justify::Right),
+                        Address::from_script(&utxo.txout.script_pubkey, cli_opts.network)
+                            .unwrap()
+                            .cell(),
+                        utxo.keychain.cell(),
+                        utxo.is_spent.cell(),
+                        utxo.derivation_index.cell(),
+                        height.to_string().cell().justify(Justify::Right),
+                        shorten(block_hash, 8, 8).cell().justify(Justify::Right),
+                    ]);
+                }
+                let table = rows
+                    .table()
+                    .title(vec![
+                        "Outpoint".cell().bold(true),
+                        "Output (sat)".cell().bold(true),
+                        "Output Address".cell().bold(true),
+                        "Keychain".cell().bold(true),
+                        "Is Spent".cell().bold(true),
+                        "Index".cell().bold(true),
+                        "Block Height".cell().bold(true),
+                        "Block Hash".cell().bold(true),
+                    ])
+                    .display()
+                    .map_err(|e| Error::Generic(e.to_string()))?;
+                Ok(format!("{table}"))
+            } else {
+                Ok(serde_json::to_string_pretty(&utxos)?)
+            }
+        }
+        Transactions => {
+            let transactions = wallet.transactions();
+
+            if cli_opts.pretty {
+                let txns = transactions
+                    .map(|tx| {
+                        let total_value = tx
+                            .tx_node
+                            .output
+                            .iter()
+                            .map(|output| output.value.to_sat())
+                            .sum::<u64>();
+                        (
+                            tx.tx_node.txid.to_string(),
+                            tx.tx_node.version,
+                            tx.tx_node.is_explicitly_rbf(),
+                            tx.tx_node.input.len(),
+                            tx.tx_node.output.len(),
+                            total_value,
+                        )
+                    })
+                    .collect::<Vec<_>>();
+                let mut rows: Vec<Vec<CellStruct>> = vec![];
+                for (txid, version, is_rbf, input_count, output_count, total_value) in txns {
+                    rows.push(vec![
+                        txid.cell(),
+                        version.to_string().cell().justify(Justify::Right),
+                        is_rbf.to_string().cell().justify(Justify::Center),
+                        input_count.to_string().cell().justify(Justify::Right),
+                        output_count.to_string().cell().justify(Justify::Right),
+                        total_value.to_string().cell().justify(Justify::Right),
+                    ]);
+                }
+                let table = rows
+                    .table()
+                    .title(vec![
+                        "Txid".cell().bold(true),
+                        "Version".cell().bold(true),
+                        "Is RBF".cell().bold(true),
+                        "Input Count".cell().bold(true),
+                        "Output Count".cell().bold(true),
+                        "Total Value (sat)".cell().bold(true),
+                    ])
+                    .display()
+                    .map_err(|e| Error::Generic(e.to_string()))?;
+                Ok(format!("{table}"))
+            } else {
+                let txns: Vec<_> = transactions
+                    .map(|tx| {
+                        json!({
+                            "txid": tx.tx_node.txid,
+                            "is_coinbase": tx.tx_node.is_coinbase(),
+                            "wtxid": tx.tx_node.compute_wtxid(),
+                            "version": tx.tx_node.version,
+                            "is_rbf": tx.tx_node.is_explicitly_rbf(),
+                            "inputs": tx.tx_node.input,
+                            "outputs": tx.tx_node.output,
+                        })
+                    })
+                    .collect();
+                Ok(serde_json::to_string_pretty(&txns)?)
+            }
+        }
+        Balance => {
+            let balance = wallet.balance();
+            if cli_opts.pretty {
+                let table = vec![
+                    vec!["Type".cell().bold(true), "Amount (sat)".cell().bold(true)],
+                    vec![
+                        "Total".cell(),
+                        balance
+                            .total()
+                            .to_sat()
+                            .to_string()
+                            .cell()
+                            .justify(Justify::Right),
+                    ],
+                    vec![
+                        "Confirmed".cell(),
+                        balance
+                            .confirmed
+                            .to_sat()
+                            .to_string()
+                            .cell()
+                            .justify(Justify::Right),
+                    ],
+                    vec![
+                        "Unconfirmed".cell(),
+                        balance
+                            .immature
+                            .to_sat()
+                            .to_string()
+                            .cell()
+                            .justify(Justify::Right),
+                    ],
+                    vec![
+                        "Trusted Pending".cell(),
+                        balance
+                            .trusted_pending
+                            .to_sat()
+                            .cell()
+                            .justify(Justify::Right),
+                    ],
+                    vec![
+                        "Untrusted Pending".cell(),
+                        balance
+                            .untrusted_pending
+                            .to_sat()
+                            .cell()
+                            .justify(Justify::Right),
+                    ],
+                ]
+                .table()
+                .display()
+                .map_err(|e| Error::Generic(e.to_string()))?;
+                Ok(format!("{table}"))
+            } else {
+                Ok(serde_json::to_string_pretty(
+                    &json!({"satoshi": wallet.balance()}),
+                )?)
+            }
+        }
+
+        CreateTx {
+            recipients,
+            send_all,
+            enable_rbf,
+            offline_signer,
+            utxos,
+            unspendable,
+            fee_rate,
+            external_policy,
+            internal_policy,
+            add_data,
+            add_string,
+        } => {
+            let mut tx_builder = wallet.build_tx();
+
+            if send_all {
+                tx_builder.drain_wallet().drain_to(recipients[0].0.clone());
+            } else {
+                let recipients = recipients
+                    .into_iter()
+                    .map(|(script, amount)| (script, Amount::from_sat(amount)))
+                    .collect();
+                tx_builder.set_recipients(recipients);
+            }
+
+            if !enable_rbf {
+                tx_builder.set_exact_sequence(Sequence::MAX);
+            }
+
+            if offline_signer {
+                tx_builder.include_output_redeem_witness_script();
+            }
+
+            if let Some(fee_rate) = fee_rate
+                && let Some(fee_rate) = FeeRate::from_sat_per_vb(fee_rate as u64)
+            {
+                tx_builder.fee_rate(fee_rate);
+            }
+
+            if let Some(utxos) = utxos {
+                tx_builder.add_utxos(&utxos[..]).unwrap();
+            }
+
+            if let Some(unspendable) = unspendable {
+                tx_builder.unspendable(unspendable);
+            }
+
+            if let Some(base64_data) = add_data {
+                let op_return_data = BASE64_STANDARD.decode(base64_data).unwrap();
+                tx_builder.add_data(&PushBytesBuf::try_from(op_return_data).unwrap());
+            } else if let Some(string_data) = add_string {
+                let data = PushBytesBuf::try_from(string_data.as_bytes().to_vec()).unwrap();
+                tx_builder.add_data(&data);
+            }
+
+            let policies = vec![
+                external_policy.map(|p| (p, KeychainKind::External)),
+                internal_policy.map(|p| (p, KeychainKind::Internal)),
+            ];
+
+            for (policy, keychain) in policies.into_iter().flatten() {
+                let policy = serde_json::from_str::<BTreeMap<String, Vec<usize>>>(&policy)?;
+                tx_builder.policy_path(policy, keychain);
+            }
+
+            let psbt = tx_builder.finish()?;
+
+            let psbt_base64 = BASE64_STANDARD.encode(psbt.serialize());
+
+            if wallet_opts.verbose {
+                Ok(serde_json::to_string_pretty(
+                    &json!({"psbt": psbt_base64, "details": psbt}),
+                )?)
+            } else {
+                Ok(serde_json::to_string_pretty(
+                    &json!({"psbt": psbt_base64 }),
+                )?)
+            }
+        }
+        BumpFee {
+            txid,
+            shrink_address,
+            offline_signer,
+            utxos,
+            unspendable,
+            fee_rate,
+        } => {
+            let txid = Txid::from_str(txid.as_str())?;
+
+            let mut tx_builder = wallet.build_fee_bump(txid)?;
+            let fee_rate =
+                FeeRate::from_sat_per_vb(fee_rate as u64).unwrap_or(FeeRate::BROADCAST_MIN);
+            tx_builder.fee_rate(fee_rate);
+
+            if let Some(address) = shrink_address {
+                let script_pubkey = address.script_pubkey();
+                tx_builder.drain_to(script_pubkey);
+            }
+
+            if offline_signer {
+                tx_builder.include_output_redeem_witness_script();
+            }
+
+            if let Some(utxos) = utxos {
+                tx_builder.add_utxos(&utxos[..]).unwrap();
+            }
+
+            if let Some(unspendable) = unspendable {
+                tx_builder.unspendable(unspendable);
+            }
+
+            let psbt = tx_builder.finish()?;
+
+            let psbt_base64 = BASE64_STANDARD.encode(psbt.serialize());
+
+            Ok(serde_json::to_string_pretty(
+                &json!({"psbt": psbt_base64 }),
+            )?)
+        }
+        Policies => {
+            let external_policy = wallet.policies(KeychainKind::External)?;
+            let internal_policy = wallet.policies(KeychainKind::Internal)?;
+            if cli_opts.pretty {
+                let table = vec![
+                    vec![
+                        "External".cell().bold(true),
+                        serde_json::to_string_pretty(&external_policy)?.cell(),
+                    ],
+                    vec![
+                        "Internal".cell().bold(true),
+                        serde_json::to_string_pretty(&internal_policy)?.cell(),
+                    ],
+                ]
+                .table()
+                .display()
+                .map_err(|e| Error::Generic(e.to_string()))?;
+                Ok(format!("{table}"))
+            } else {
+                Ok(serde_json::to_string_pretty(&json!({
+                    "external": external_policy,
+                    "internal": internal_policy,
+                }))?)
+            }
+        }
+        PublicDescriptor => {
+            let external = wallet.public_descriptor(KeychainKind::External).to_string();
+            let internal = wallet.public_descriptor(KeychainKind::Internal).to_string();
+
+            if cli_opts.pretty {
+                let table = vec![
+                    vec![
+                        "External Descriptor".cell().bold(true),
+                        external.to_string().cell(),
+                    ],
+                    vec![
+                        "Internal Descriptor".cell().bold(true),
+                        internal.to_string().cell(),
+                    ],
+                ]
+                .table()
+                .display()
+                .map_err(|e| Error::Generic(e.to_string()))?;
+                Ok(format!("{table}"))
+            } else {
+                Ok(serde_json::to_string_pretty(&json!({
+                    "external": external.to_string(),
+                    "internal": internal.to_string(),
+                }))?)
+            }
+        }
+        Sign {
+            psbt,
+            assume_height,
+            trust_witness_utxo,
+        } => {
+            let psbt_bytes = BASE64_STANDARD.decode(psbt)?;
+            let mut psbt = Psbt::deserialize(&psbt_bytes)?;
+            let signopt = SignOptions {
+                assume_height,
+                trust_witness_utxo: trust_witness_utxo.unwrap_or(false),
+                ..Default::default()
+            };
+            let finalized = wallet.sign(&mut psbt, signopt)?;
+            let psbt_base64 = BASE64_STANDARD.encode(psbt.serialize());
+            if wallet_opts.verbose {
+                Ok(serde_json::to_string_pretty(
+                    &json!({"psbt": &psbt_base64, "is_finalized": finalized, "serialized_psbt": &psbt}),
+                )?)
+            } else {
+                Ok(serde_json::to_string_pretty(
+                    &json!({"psbt": &psbt_base64, "is_finalized": finalized}),
+                )?)
+            }
+        }
+        ExtractPsbt { psbt } => {
+            let psbt_serialized = BASE64_STANDARD.decode(psbt)?;
+            let psbt = Psbt::deserialize(&psbt_serialized)?;
+            let raw_tx = psbt.extract_tx()?;
+            if cli_opts.pretty {
+                let table = vec![vec![
+                    "Raw Transaction".cell().bold(true),
+                    serialize_hex(&raw_tx).cell(),
+                ]]
+                .table()
+                .display()
+                .map_err(|e| Error::Generic(e.to_string()))?;
+                Ok(format!("{table}"))
+            } else {
+                Ok(serde_json::to_string_pretty(
+                    &json!({"raw_tx": serialize_hex(&raw_tx)}),
+                )?)
+            }
+        }
+        FinalizePsbt {
+            psbt,
+            assume_height,
+            trust_witness_utxo,
+        } => {
+            let psbt_bytes = BASE64_STANDARD.decode(psbt)?;
+            let mut psbt: Psbt = Psbt::deserialize(&psbt_bytes)?;
+
+            let signopt = SignOptions {
+                assume_height,
+                trust_witness_utxo: trust_witness_utxo.unwrap_or(false),
+                ..Default::default()
+            };
+            let finalized = wallet.finalize_psbt(&mut psbt, signopt)?;
+            if wallet_opts.verbose {
+                Ok(serde_json::to_string_pretty(
+                    &json!({ "psbt": BASE64_STANDARD.encode(psbt.serialize()), "is_finalized": finalized, "details": psbt}),
+                )?)
+            } else {
+                Ok(serde_json::to_string_pretty(
+                    &json!({ "psbt": BASE64_STANDARD.encode(psbt.serialize()), "is_finalized": finalized}),
+                )?)
+            }
+        }
+        CombinePsbt { psbt } => {
+            let mut psbts = psbt
+                .iter()
+                .map(|s| {
+                    let psbt = BASE64_STANDARD.decode(s)?;
+                    Ok(Psbt::deserialize(&psbt)?)
+                })
+                .collect::<Result<Vec<_>, Error>>()?;
+
+            let init_psbt = psbts
+                .pop()
+                .ok_or_else(|| Error::Generic("Invalid PSBT input".to_string()))?;
+            let final_psbt = psbts.into_iter().try_fold::<_, _, Result<Psbt, Error>>(
+                init_psbt,
+                |mut acc, x| {
+                    let _ = acc.combine(x);
+                    Ok(acc)
+                },
+            )?;
+            Ok(serde_json::to_string_pretty(
+                &json!({ "psbt": BASE64_STANDARD.encode(final_psbt.serialize()) }),
+            )?)
+        }
+    }
+}
diff --git a/src/handlers/online.rs b/src/handlers/online.rs
new file mode 100644 (file)
index 0000000..ec3ff2d
--- /dev/null
@@ -0,0 +1,327 @@
+#[cfg(feature = "electrum")]
+use crate::backend::BlockchainClient::Electrum;
+#[cfg(feature = "cbf")]
+use crate::backend::BlockchainClient::KyotoClient;
+#[cfg(feature = "rpc")]
+use crate::backend::BlockchainClient::RpcClient;
+#[cfg(feature = "esplora")]
+use {crate::backend::BlockchainClient::Esplora, bdk_esplora::EsploraAsyncExt};
+#[cfg(feature = "rpc")]
+use {
+    bdk_bitcoind_rpc::{Emitter, NO_EXPECTED_MEMPOOL_TXS, bitcoincore_rpc::RpcApi},
+    bdk_wallet::chain::{BlockId, CanonicalizationParams, CheckPoint},
+};
+#[cfg(any(feature = "electrum", feature = "esplora"))]
+use {bdk_wallet::KeychainKind, std::collections::HashSet, std::io::Write};
+
+#[cfg(any(
+    feature = "electrum",
+    feature = "esplora",
+    feature = "cbf",
+    feature = "rpc"
+))]
+use {
+    crate::backend::{BlockchainClient, sync_kyoto_client},
+    crate::commands::OnlineWalletSubCommand::*,
+    crate::error::BDKCliError as Error,
+    crate::payjoin::PayjoinManager,
+    crate::payjoin::ohttp::RelayManager,
+    crate::utils::is_final,
+    bdk_wallet::Wallet,
+    bdk_wallet::bitcoin::{
+        Psbt, Transaction, Txid, base64::Engine, base64::prelude::BASE64_STANDARD,
+        consensus::Decodable, hex::FromHex,
+    },
+    serde_json::json,
+    std::sync::{Arc, Mutex},
+};
+
+/// Execute an online wallet sub-command
+///
+/// Online wallet sub-commands are described in [`OnlineWalletSubCommand`].
+#[cfg(any(
+    feature = "electrum",
+    feature = "esplora",
+    feature = "cbf",
+    feature = "rpc"
+))]
+pub(crate) async fn handle_online_wallet_subcommand(
+    wallet: &mut Wallet,
+    client: &BlockchainClient,
+    online_subcommand: crate::commands::OnlineWalletSubCommand,
+) -> Result<String, Error> {
+    match online_subcommand {
+        FullScan {
+            stop_gap: _stop_gap,
+        } => {
+            #[cfg(any(feature = "electrum", feature = "esplora"))]
+            let request = wallet.start_full_scan().inspect({
+                let mut stdout = std::io::stdout();
+                let mut once = HashSet::<KeychainKind>::new();
+                move |k, spk_i, _| {
+                    if once.insert(k) {
+                        print!("\nScanning keychain [{k:?}]");
+                    }
+                    print!(" {spk_i:<3}");
+                    stdout.flush().expect("must flush");
+                }
+            });
+            match client {
+                #[cfg(feature = "electrum")]
+                Electrum { client, batch_size } => {
+                    // Populate the electrum client's transaction cache so it doesn't re-download transaction we
+                    // already have.
+                    client
+                        .populate_tx_cache(wallet.tx_graph().full_txs().map(|tx_node| tx_node.tx));
+
+                    let update = client.full_scan(request, _stop_gap, *batch_size, false)?;
+                    wallet.apply_update(update)?;
+                }
+                #[cfg(feature = "esplora")]
+                Esplora {
+                    client,
+                    parallel_requests,
+                } => {
+                    let update = client
+                        .full_scan(request, _stop_gap, *parallel_requests)
+                        .await
+                        .map_err(|e| *e)?;
+                    wallet.apply_update(update)?;
+                }
+
+                #[cfg(feature = "rpc")]
+                RpcClient { client } => {
+                    let blockchain_info = client.get_blockchain_info()?;
+
+                    let genesis_block =
+                        bdk_wallet::bitcoin::constants::genesis_block(wallet.network());
+                    let genesis_cp = CheckPoint::new(BlockId {
+                        height: 0,
+                        hash: genesis_block.block_hash(),
+                    });
+                    let mut emitter = Emitter::new(
+                        client.as_ref(),
+                        genesis_cp.clone(),
+                        genesis_cp.height(),
+                        NO_EXPECTED_MEMPOOL_TXS,
+                    );
+
+                    while let Some(block_event) = emitter.next_block()? {
+                        if block_event.block_height() % 10_000 == 0 {
+                            let percent_done = f64::from(block_event.block_height())
+                                / f64::from(blockchain_info.headers as u32)
+                                * 100f64;
+                            println!(
+                                "Applying block at height: {}, {:.2}% done.",
+                                block_event.block_height(),
+                                percent_done
+                            );
+                        }
+
+                        wallet.apply_block_connected_to(
+                            &block_event.block,
+                            block_event.block_height(),
+                            block_event.connected_to(),
+                        )?;
+                    }
+
+                    let mempool_txs = emitter.mempool()?;
+                    wallet.apply_unconfirmed_txs(mempool_txs.update);
+                }
+                #[cfg(feature = "cbf")]
+                KyotoClient { client } => {
+                    sync_kyoto_client(wallet, client).await?;
+                }
+            }
+            Ok(serde_json::to_string_pretty(&json!({}))?)
+        }
+        Sync => {
+            sync_wallet(client, wallet).await?;
+            Ok(serde_json::to_string_pretty(&json!({}))?)
+        }
+        Broadcast { psbt, tx } => {
+            let tx = match (psbt, tx) {
+                (Some(psbt), None) => {
+                    let psbt = BASE64_STANDARD
+                        .decode(psbt)
+                        .map_err(|e| Error::Generic(e.to_string()))?;
+                    let psbt: Psbt = Psbt::deserialize(&psbt)?;
+                    is_final(&psbt)?;
+                    psbt.extract_tx()?
+                }
+                (None, Some(tx)) => {
+                    let tx_bytes = Vec::<u8>::from_hex(&tx)?;
+                    Transaction::consensus_decode(&mut tx_bytes.as_slice())?
+                }
+                (Some(_), Some(_)) => panic!("Both `psbt` and `tx` options not allowed"),
+                (None, None) => panic!("Missing `psbt` and `tx` option"),
+            };
+            let txid = broadcast_transaction(client, tx).await?;
+            Ok(serde_json::to_string_pretty(&json!({ "txid": txid }))?)
+        }
+        ReceivePayjoin {
+            amount,
+            directory,
+            ohttp_relay,
+            max_fee_rate,
+        } => {
+            let relay_manager = Arc::new(Mutex::new(RelayManager::new()));
+            let mut payjoin_manager = PayjoinManager::new(wallet, relay_manager);
+            return payjoin_manager
+                .receive_payjoin(amount, directory, max_fee_rate, ohttp_relay, client)
+                .await;
+        }
+        SendPayjoin {
+            uri,
+            ohttp_relay,
+            fee_rate,
+        } => {
+            let relay_manager = Arc::new(Mutex::new(RelayManager::new()));
+            let mut payjoin_manager = PayjoinManager::new(wallet, relay_manager);
+            return payjoin_manager
+                .send_payjoin(uri, fee_rate, ohttp_relay, client)
+                .await;
+        }
+    }
+}
+
+#[cfg(any(
+    feature = "electrum",
+    feature = "esplora",
+    feature = "cbf",
+    feature = "rpc"
+))]
+/// Syncs a given wallet using the blockchain client.
+pub async fn sync_wallet(client: &BlockchainClient, wallet: &mut Wallet) -> Result<(), Error> {
+    // #[cfg(any(feature = "electrum", feature = "esplora"))]
+    let request = wallet
+        .start_sync_with_revealed_spks()
+        .inspect(|item, progress| {
+            let pc = (100 * progress.consumed()) as f32 / progress.total() as f32;
+            eprintln!("[ SCANNING {pc:03.0}% ] {item}");
+        });
+    match client {
+        #[cfg(feature = "electrum")]
+        Electrum { client, batch_size } => {
+            // Populate the electrum client's transaction cache so it doesn't re-download transaction we
+            // already have.
+            client.populate_tx_cache(wallet.tx_graph().full_txs().map(|tx_node| tx_node.tx));
+
+            let update = client.sync(request, *batch_size, false)?;
+            wallet
+                .apply_update(update)
+                .map_err(|e| Error::Generic(e.to_string()))
+        }
+        #[cfg(feature = "esplora")]
+        Esplora {
+            client,
+            parallel_requests,
+        } => {
+            let update = client
+                .sync(request, *parallel_requests)
+                .await
+                .map_err(|e| *e)?;
+            wallet
+                .apply_update(update)
+                .map_err(|e| Error::Generic(e.to_string()))
+        }
+        #[cfg(feature = "rpc")]
+        RpcClient { client } => {
+            let blockchain_info = client.get_blockchain_info()?;
+            let wallet_cp = wallet.latest_checkpoint();
+
+            // reload the last 200 blocks in case of a reorg
+            let emitter_height = wallet_cp.height().saturating_sub(200);
+            let mut emitter = Emitter::new(
+                client.as_ref(),
+                wallet_cp,
+                emitter_height,
+                wallet
+                    .tx_graph()
+                    .list_canonical_txs(
+                        wallet.local_chain(),
+                        wallet.local_chain().tip().block_id(),
+                        CanonicalizationParams::default(),
+                    )
+                    .filter(|tx| tx.chain_position.is_unconfirmed()),
+            );
+
+            while let Some(block_event) = emitter.next_block()? {
+                if block_event.block_height() % 10_000 == 0 {
+                    let percent_done = f64::from(block_event.block_height())
+                        / f64::from(blockchain_info.headers as u32)
+                        * 100f64;
+                    println!(
+                        "Applying block at height: {}, {:.2}% done.",
+                        block_event.block_height(),
+                        percent_done
+                    );
+                }
+
+                wallet.apply_block_connected_to(
+                    &block_event.block,
+                    block_event.block_height(),
+                    block_event.connected_to(),
+                )?;
+            }
+
+            let mempool_txs = emitter.mempool()?;
+            wallet.apply_unconfirmed_txs(mempool_txs.update);
+            Ok(())
+        }
+        #[cfg(feature = "cbf")]
+        KyotoClient { client } => sync_kyoto_client(wallet, client)
+            .await
+            .map_err(|e| Error::Generic(e.to_string())),
+    }
+}
+
+#[cfg(any(
+    feature = "electrum",
+    feature = "esplora",
+    feature = "cbf",
+    feature = "rpc"
+))]
+/// Broadcasts a given transaction using the blockchain client.
+pub async fn broadcast_transaction(
+    client: &BlockchainClient,
+    tx: Transaction,
+) -> Result<Txid, Error> {
+    match client {
+        #[cfg(feature = "electrum")]
+        Electrum {
+            client,
+            batch_size: _,
+        } => client
+            .transaction_broadcast(&tx)
+            .map_err(|e| Error::Generic(e.to_string())),
+        #[cfg(feature = "esplora")]
+        Esplora {
+            client,
+            parallel_requests: _,
+        } => client
+            .broadcast(&tx)
+            .await
+            .map(|()| tx.compute_txid())
+            .map_err(|e| Error::Generic(e.to_string())),
+        #[cfg(feature = "rpc")]
+        RpcClient { client } => client
+            .send_raw_transaction(&tx)
+            .map_err(|e| Error::Generic(e.to_string())),
+
+        #[cfg(feature = "cbf")]
+        KyotoClient { client } => {
+            let txid = tx.compute_txid();
+            let wtxid = client
+                .requester
+                .broadcast_random(tx.clone())
+                .await
+                .map_err(|_| {
+                    tracing::warn!("Broadcast was unsuccessful");
+                    Error::Generic("Transaction broadcast timed out after 30 seconds".into())
+                })?;
+            tracing::info!("Successfully broadcast WTXID: {wtxid}");
+            Ok(txid)
+        }
+    }
+}
diff --git a/src/handlers/wallets.rs b/src/handlers/wallets.rs
new file mode 100644 (file)
index 0000000..17c25c7
--- /dev/null
@@ -0,0 +1,37 @@
+use crate::config::WalletConfig;
+use crate::error::BDKCliError as Error;
+use cli_table::{Cell, CellStruct, Style, Table};
+use std::path::Path;
+
+/// Handle the top-level `wallets` command (lists all saved wallets)
+pub fn handle_wallets_subcommand(home_dir: &Path, pretty: bool) -> Result<String, Error> {
+    let config = match WalletConfig::load(home_dir)? {
+        Some(cfg) => cfg,
+        None => return Ok("No wallets configured yet.".to_string()),
+    };
+
+    if pretty {
+        let mut rows: Vec<Vec<CellStruct>> = vec![];
+        for (name, inner) in &config.wallets {
+            rows.push(vec![
+                name.cell(),
+                inner.network.clone().cell(),
+                inner.ext_descriptor[..30].to_string().cell(),
+            ]);
+        }
+
+        let table = rows
+            .table()
+            .title(vec![
+                "Wallet Name".cell().bold(true),
+                "Network".cell().bold(true),
+                "External Descriptor (truncated)".cell().bold(true),
+            ])
+            .display()
+            .map_err(|e| Error::Generic(e.to_string()))?;
+
+        Ok(format!("{table}"))
+    } else {
+        Ok(serde_json::to_string_pretty(&config.wallets)?)
+    }
+}