I started this project to deeply understand how Bitcoin wallets work — not just from the UI side, but from the protocol level. From generating private keys to scanning UTXOs and soon signing transactions, this blog documents the evolution of my Rust-based CLI wallet project and shares what I've learned.
🧱 Project Architecture
rust_cli_wallet/
├── src/
│ ├── main.rs // Entry point: loads wallet, checks balance
│ ├── address.rs // BitcoinAddress struct, key generation
│ ├── wallet.rs // Wallet struct: loading, saving, updating balances
│ ├── utxo.rs // UTXO querying via Esplora API
│ └── transaction.rs // (Upcoming) Transaction creation and signing
├── wallet.json // Stores addresses and balance info persistently
🔄 Development Flow Summary
✅ Phase 1: Key Generation & Address Creation (address.rs
)
We generate a keypair using secp256k1
and derive a P2PKH testnet address like this:
let secp = Secp256k1::new();
let mut rng = OsRng;
let (sk, _) = secp.generate_keypair(&mut rng);
let priv_key = PrivateKey::new(
bitcoin::secp256k1::SecretKey::from_slice(&sk[..]).unwrap(),
network,
);
let pub_key = priv_key.public_key(&Secp256k1::new());
let address = Address::p2pkh(&pub_key, network);
This forms the core identity of our wallet.
✅ Phase 2: Wallet Loading, Saving, and New Address Creation (wallet.rs
)
The wallet tries to load from a wallet.json
file. If it’s missing or empty, we generate a new address:
let wallet_path = Path::new("wallet.json");
let mut wallet = Wallet::load(wallet_path)?;
if wallet.addresses.is_empty() {
let new_address = BitcoinAddress::new(Network::Testnet);
wallet.add_address(new_address);
wallet.save(wallet_path)?;
}
This makes the wallet persistent across sessions.
✅ Phase 3: Fetch UTXOs and Balance Updates (wallet.rs
+ utxo.rs
)
Using the Blockstream Esplora API, we scan each address and retrieve the associated UTXOs.
In wallet.rs
, the method update_balances()
looks something like this:
pub async fn update_balances(&mut self) -> Result<(), Box<dyn std::error::Error>> {
for (i, address) in self.addresses.iter().enumerate() {
let utxos = fetch_utxos(&address.to_string()).await?;
self.balances[i].update_from_utxos(&utxos);
}
Ok(())
}
It calls the fetch_utxos
function in utxo.rs
, which makes the actual HTTP request:
pub async fn fetch_utxos(address: &str) -> Result<Vec<Utxo>, Box<dyn std::error::Error>> {
let url = format!("https://blockstream.info/testnet/api/address/{}/utxo", address);
let response = reqwest::get(&url).await?.json::<Vec<Utxo>>().await?;
Ok(response)
}
Each balance is updated by summing UTXO values fetched from the Esplora API. The balance is stored in a Vec
, and each index in the vector corresponds to the associated address index. This line shows the update logic:
self.balances[i].update_from_utxos(&utxos);
We use async networking via reqwest
, and call it from main.rs
using:
let rt = tokio::runtime::Runtime::new()?;
rt.block_on(wallet.update_balances())?;
This lets us run async functions from sync main()
. (I'll write more about async/await
in Rust system programming posts later.)
✅ Phase 4: Displaying Wallet Info (main.rs
)
After fetching balances, the wallet prints out all known addresses and their total testnet Bitcoin.
wallet.display_all();
📸 Example Output
🔐 Loaded Wallet:
- Address: tb1qxyz...abc
Balance: 0.00100500 tBTC
- Address: tb1qabc...xyz
Balance: 0.00000000 tBTC
🔮 Coming Next
- Transaction and Signing