Using BDK with hardware wallets
# Introduction
The bitcoindevkit organization maintains rust-hwi (opens new window), a Rust wrapper around Bitcoin Core's HWI (opens new window). rust-hwi makes it possible to use hardware wallets with BDK, which is exactly what we're going to do in this tutorial.
# Prerequisites
To follow along you'll need the hwi
(opens new window) python package installed on your system, and a hardware wallet.
Never use a hardware wallet with real funds for testing! Either buy a separate one to be used only for tests, or use a hardware wallet emulator, such as:
To check if hwi
is installed, open a python terminal and try to import it:
$ python3
Python 3.9.13 (main, May 17 2022, 14:19:07)
[GCC 11.3.0] on linux
Type "help", "copyright", "credits" or "license" for more information.
>>> import hwilib
If nothing happens, you're set! Instead, if you get a ModuleNotFoundError
, follow the instructions in HWI's README.md (opens new window) for installing.
Warning: if you're using macOS and virtualenv
, you may encounter some problems with rust-hwi
, as we internally use PyO3
: https://github.com/PyO3/pyo3/issues/1741
# Initial setup
Start by creating a new Rust project:
$ cargo init bdk-hwi
Created binary (application) package
$ cd bdk-hwi
Add bdk
with the hardware-signer
feature as a dependency in the Cargo.toml
:
[package]
name = "bdk-hwi"
version = "0.1.0"
edition = "2021"
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
[dependencies]
bdk = { version = "0.24.0", features = [ "hardware-signer", ] }
(bdk
re-exports rust-hwi
since version 0.24.0
- if you're using bdk
<= 0.23.0
, you have to separately declare rust-hwi
as a dependency)
Now, open src/main.rs
and slightly modify the fn main()
method to return a Result
:
fn main() -> Result<(), Box<dyn std::error::Error>> {
println!("Hello, world!");
Ok(())
}
and add these imports at the start of the file:
use bdk::bitcoin::{Address, Network};
use bdk::blockchain::{Blockchain, ElectrumBlockchain};
use bdk::database::MemoryDatabase;
use bdk::electrum_client::Client;
use bdk::hwi::{types::HWIChain, HWIClient};
use bdk::signer::SignerOrdering;
use bdk::wallet::{hardwaresigner::HWISigner, AddressIndex};
use bdk::{FeeRate, KeychainKind, SignOptions, SyncOptions, Wallet};
use std::str::FromStr;
use std::sync::Arc;
These little changes will come in handy later, as we won't have to care about imports or error handling.
Build and run the project - if everything goes smoothly it will print some warnings about the unused imports (no worries, we'll use them eventually), and a "Hello, world!".
$ cargo run
warning: unused import: ...
warning: unused import: ...
warning: unused import: ...
Hello, world!
# Finding the hardware wallet
In this step we'll make sure that hwi
can see your hardware wallet. If you're using a physical HW, connect it to your laptop; if it's an emulator, start it.
We start by printing all the available hardware wallets:
fn main() -> Result<(), Box<dyn std::error::Error>> {
// Listing all the available hardware wallet devices...
let devices = HWIClient::enumerate()?;
println!("{:?}", &devices);
Ok(())
}
When run, it should print an array of HWIDevice
with one element:
$ cargo run
[HWIDevice { ... }]
If the array is empty instead, hwi
is having troubles recognizing your device. Common issues are: the device is locked (unlock with the pin and open the "Bitcoin" app, if needed) or the udev rules aren't set.
# Receiving funds
In order to be able to receive funds we need to create the BDK Wallet
using the HW descriptors.
We start by creating a HWIClient
from the HWIDevice
we found in the last step:
// Listing all the available hardware wallet devices...
let devices = HWIClient::enumerate()?;
let first_device = devices
.first()
.expect("No devices found. Either plug in a hardware wallet, or start a simulator.");
// ...and creating a client out of the first one
let client = HWIClient::get_client(first_device, true, HWIChain::Test)?;
println!("Look what I found, a {}!", first_device.model);
We then use the HWIClient
to get the descriptors:
// Getting the HW's public descriptors
let descriptors = client.get_descriptors(None)?;
println!(
"The hardware wallet's descriptor is: {}",
descriptors.receive[0]
);
Now that we have the descriptors, we use BDK as we always do: we create a Wallet
, we sync it, we check the balance, and if there aren't funds on it, we ask the user to send some:
let mut wallet = Wallet::new(
&descriptors.receive[0],
Some(&descriptors.internal[0]),
Network::Testnet,
MemoryDatabase::default(),
)?;
// create client for Blockstream's testnet electrum server
let blockchain =
ElectrumBlockchain::from(Client::new("ssl://electrum.blockstream.info:60002")?);
println!("Syncing the wallet...");
wallet.sync(&blockchain, SyncOptions::default())?;
// get deposit address
let deposit_address = wallet.get_address(AddressIndex::New)?;
let balance = wallet.get_balance()?;
println!("Wallet balances in SATs: {}", balance);
if balance.get_total() < 10000 {
println!(
"Send some sats from the u01.net testnet faucet to address '{addr}'.\nFaucet URL: https://bitcoinfaucet.uo1.net/?to={addr}",
addr = deposit_address.address
);
return Ok(());
}
Use a testnet faucet to send funds to the specified address, and then re-run the program to check that they arrived. You don't have to wait for them to be confirmed before going to the next step.
# Spending funds
We're going to send back the sats we just received to the testnet faucet. As always, we need to start by creating the transaction:
let return_address = Address::from_str("tb1ql7w62elx9ucw4pj5lgw4l028hmuw80sndtntxt")?;
let (mut psbt, _details) = {
let mut builder = wallet.build_tx();
builder
.drain_wallet()
.drain_to(return_address.script_pubkey())
.enable_rbf()
.fee_rate(FeeRate::from_sat_per_vb(5.0));
builder.finish()?
};
We can't just call sign
on the psbt
as we'd normally do though, as the Wallet
doesn't have any private keys, and doesn't even know that it's supposed to sign with the hardware wallet. (Go on and try to call sign()
, if you're curious!)
We need to create a HWISigner
object, and then manually add it to the Wallet
, using add_signer
. add_signer
requires a SignerOrdering
, which BDK uses to know which signer call first - in this case we just use the default, as we only have one signer.
// Creating a custom signer from the device
let custom_signer = HWISigner::from_device(first_device, HWIChain::Test)?;
// Adding the hardware signer to the BDK wallet
wallet.add_signer(
KeychainKind::External,
SignerOrdering::default(),
Arc::new(custom_signer),
);
We can now sign and broadcast psbt
:
// `sign` will call the hardware wallet asking for a signature
assert!(
wallet.sign(&mut psbt, SignOptions::default())?,
"The hardware wallet couldn't finalize the transaction :("
);
println!("Let's broadcast your tx...");
let raw_transaction = psbt.extract_tx();
let txid = raw_transaction.txid();
blockchain.broadcast(&raw_transaction)?;
println!("Transaction broadcasted! TXID: {txid}.\nExplorer URL: https://mempool.space/testnet/tx/{txid}", txid = txid);
# Conclusion
We just received coins on a hardware wallet and spent from it - how cool is that?!
See the hardware signer example (opens new window) for the full code, and, if you have any questions or suggestions, head to our Discord (opens new window). See you there!