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