Chapter 1: Private Keys, Public Keys, and Address Encoding#
Understanding Bitcoin’s cryptographic foundation is essential before diving into Taproot. This chapter covers private keys, public keys, and address generation—the building blocks of all transactions.
# Chapter environment (load once, reuse in subsequent code cells)
from btcaaron import Key, TapTree
from bitcoinutils.setup import setup
from bitcoinutils.keys import PrivateKey, P2shAddress, P2wpkhAddress
from bitcoinutils.script import Script
The Cryptographic Hierarchy#
Bitcoin’s security is built on a one-way chain:
Private Key (256-bit) → Public Key (ECDSA point) → Address (encoded hash)
Each layer has a clear purpose:
Private keys: Prove ownership, used for signing
Public keys: Verify signatures, authorize payments
Addresses: Convenient for receiving, while hiding public keys
Private Keys: The Foundation of Ownership#
A Bitcoin private key is a 256-bit random integer used to prove ownership. How large is 2^256? More than all atoms in the observable universe.
Generating Private Keys#
Using btcaaron concise API (this book’s example library):
# Example 1: Generate private key (btcaaron)
# Reference: examples/ch01_keys_and_addresses.py
alice = Key.from_wif("cPeon9fBsW2BxwJTALj3hGzh9vm8C52Uqsce7MzXGS1iFJkPF4AT")
print(f"Private Key (WIF): {alice.wif}")
Sample output:
Private Key (HEX): e9873d79c6d87dc0fb6a5778633389dfa5c32fa27f99b5199abf2f9848ee0289
Private Key (WIF): L1aW4aubDFB7yfras2S1mN3bqg9w3KmCPSM3Qh4rQG9E1e84n5Bd
The hexadecimal representation contains 64 characters (4 bits each), totaling 256 bits or 32 bytes. This format is mathematically precise but not ideal for storage or import/export.
Wallet Import Format (WIF)#
Wallet Import Format (WIF) uses Base58Check encoding to make private keys more practical:
Adds checksum for error detection
Eliminates confusing characters (0, O, I, l)
Standardizes import/export across wallets
The WIF encoding process:
Add version prefix:
0x80for mainnet,0xEFfor testnet(Optional) Add compression flag: Append 0x01 if the public key will be compressed; this changes the final Base58 prefix
Calculate checksum: Apply SHA256(SHA256(data)) and take first 4 bytes
Apply Base58 encoding: Convert to human-readable string

Figure 1-1: WIF encoding converts 32-byte private key to Base58Check string
The resulting WIF string has distinctive prefixes:
L or K: Mainnet private keys
c: Testnet private keys
Public Keys: Cryptographic Verification Points#
A public key is a point on the secp256k1 elliptic curve, derived from the private key via elliptic curve multiplication. The math is complex but the code is simple.
ECDSA and secp256k1#
Bitcoin uses the secp256k1 curve for ECDSA. The curve is defined by:
y² = x³ + 7

Figure 1-2: secp256k1 elliptic curve used by Bitcoin
Without diving into the math, understand:
Each private key
kgenerates a unique point(x, y)on the curveThis relationship is computationally irreversible
The curve’s mathematical properties ensure security
Compressed vs Uncompressed Public Keys#
Public keys can be represented in two formats:
Uncompressed format (65 bytes):
04 + x-coordinate (32 bytes) + y-coordinate (32 bytes)
Compressed format (33 bytes):
02/03 + x-coordinate (32 bytes)
Why compress? The elliptic curve property: given x-coordinate and y-parity, the full y can be derived:
02prefix: y-coordinate is even03prefix: y-coordinate is odd
# Example 2: Generate public key + X-only (btcaaron)
# Reference: examples/ch01_keys_and_addresses.py
# Compressed (33 bytes) and x-only (32 bytes) public keys
print(f"Compressed (33 bytes): {alice.pubkey}")
print(f"X-only (32 bytes): {alice.xonly}")
print(f"Verify: x-only = compressed pubkey minus 02/03 prefix → {alice.pubkey[2:]} == {alice.xonly}")
Sample output:
Compressed: 0250be5fc44ec580c387bf45df275aaa8b27e2d7716af31f10eeed357d126bb4d3
Uncompressed: 0450be5fc44ec580c387bf45df275aaa8b27e2d7716af31f10eeed357d126bb4d3...
Modern Bitcoin software uses only compressed public keys—smaller and equally secure.
X-Only Public Keys: Taproot’s Innovation#
Taproot introduces x-only public keys—only the x-coordinate (32 bytes), no y-parity. Benefits:
Smaller transactions
Faster verification
Enables key aggregation
# Example 3: Taproot X-Only public key (reusing alice from examples 1/2)
print(f"Taproot X-only public key (32 bytes): {alice.xonly}")
This is key to Taproot’s efficiency gains; we’ll detail it in later chapters.
Address Generation: From Public Key to Payment Destination#
Bitcoin addresses are not public keys—they are encoded hashes of public keys. Benefits:
Privacy: Public key hidden until spending
Post-quantum resistance: Hash adds extra protection
Error detection: Encoding includes checksum
Address Generation Process#
Address generation follows a similar pattern:
Hash the public key: SHA256 then RIPEMD160 (Hash160)
Add version byte: Indicates address type
Add checksum: For input error detection
Encode: To Base58Check or Bech32 string

Figure 1-3: Traditional Bitcoin address generation flow
# Example 4: Generate different address types
# Legacy/SegWit/P2SH: bitcoinutils | Taproot: btcaaron
setup('mainnet')
priv = PrivateKey()
pub = priv.get_public_key()
# Legacy / SegWit / P2SH-P2WPKH (bitcoin-utils)
legacy_address = pub.get_address()
segwit_native = pub.get_segwit_address()
segwit_p2sh = P2shAddress.from_script(segwit_native.to_script_pub_key())
# Taproot: btcaaron one-liner
program = TapTree(internal_key=Key.from_wif(priv.to_wif())).build()
print(f"Legacy (P2PKH): {legacy_address.to_string()}")
print(f"SegWit Native: {segwit_native.to_string()}")
print(f"SegWit P2SH: {segwit_p2sh.to_string()}")
print(f"Taproot (P2TR): {program.address}")
Sample output:
Legacy (P2PKH): 1A1zP1eP5QGefi2DMPTfTL5SLmv7DivfNa
SegWit Native: bc1qw508d6qejxtdg4y5r3zarvary0c5xw7kygt080
SegWit P2SH: 3J98t1WpEZ73CNmQviecrnyiWrnqRhWNLy
Taproot (P2TR): bc1p53ncq9ytax924ps66z6al3wfhy6a29w8h6xfu27xem06t98zkmvsakd43h (~62 chars)
Address Types and Encoding Formats#
Base58Check Encoding#
Base58Check for legacy addresses—eliminates confusing characters and includes checksum:
Excluded characters: 0 (zero), O (capital o), I (capital i), l (lowercase L)
P2PKH (Pay-to-Public-Key-Hash):
Prefix:
1Format: Base58Check encoding
Usage: Original Bitcoin address format
Example:
1A1zP1eP5QGefi2DMPTfTL5SLmv7DivfNa
P2SH (Pay-to-Script-Hash):
Prefix:
3Format: Base58Check encoding
Usage: Multi-signature and wrapped SegWit addresses
Example:
3J98t1WpEZ73CNmQviecrnyiWrnqRhWNLy
Bech32 Encoding: SegWit’s Innovation#
Bech32 was introduced with SegWit—better error detection:
P2WPKH (Pay-to-Witness-Public-Key-Hash):
Prefix:
bc1qFormat: Bech32 encoding
Advantages: Lower fees, better error detection
Example:
bc1qw508d6qejxtdg4y5r3zarvary0c5xw7kygt080
Bech32m Encoding: Taproot’s Enhancement#
Taproot uses Bech32m, an improved Bech32:
P2TR (Pay-to-Taproot):
Prefix:
bc1pFormat: Bech32m encoding
Advantages: Enhanced privacy, script flexibility
Example:
bc1p53ncq9ytax924ps66z6al3wfhy6a29w8h6xfu27xem06t98zkmvsakd43h(~62 chars)
Address Format Comparison#
Address Type |
Encoding |
Data Size |
Address Length |
Prefix |
Primary Use Case |
|---|---|---|---|---|---|
P2PKH |
Base58Check |
25 bytes |
~34 chars |
|
Legacy payments |
P2SH |
Base58Check |
25 bytes |
~34 chars |
|
Multi-sig, wrapped SegWit |
P2WPKH |
Bech32 |
21 bytes |
42-46 chars |
|
SegWit payments |
P2TR |
Bech32m |
33 bytes |
58-62 chars |
|
Taproot payments |
# Example 5: Verify address formats (brief)
# Reference: code/chapter01/05_verify_addresses.py
setup('mainnet')
priv = PrivateKey()
pub = priv.get_public_key()
legacy = pub.get_address()
segwit = pub.get_segwit_address()
taproot = pub.get_taproot_address()
print(f"P2PKH: {len(legacy.to_string())} chars | P2WPKH: {len(segwit.to_string())} chars | P2TR: {len(taproot.to_string())} chars")
print(f"P2TR ScriptPubKey: OP_1 + 32B x-only = {len(taproot.to_script_pub_key().to_hex())//2} bytes")
Address encoding has many details—version bytes, checksums, different encodings—but one core idea:
👉 Addresses are for humans. They’re just friendly representations of locking scripts (scriptPubKey); the protocol doesn’t need them.
The prefix (1, 3, bc1q, bc1p) reveals the script. Nodes don’t see ‘addresses’—only scripts.
Later chapters focus on what matters: the scriptPubKey behind each address type. That’s where the logic lives—Bitcoin Script and programmability. Infer the script behind an address to understand how it’s spent.
Derivation Model#
Understanding the full derivation from private key to address is important. The diagram shows the flow from private key to on-chain script. Users see addresses; developers must trace each step to understand how Bitcoin verifies ownership and spends.

Figure 1-4: Full derivation model from private key to address
Private Key (k)
↓ ECDSA multiplication
Public Key (x, y)
↓ SHA256 + RIPEMD160
Public Key Hash (20 bytes)
↓ Version + Checksum + Encoding
Address (Base58/Bech32)
↓ Decoded by wallet/node
ScriptPubKey (locking script on-chain)
Security properties:
Forward: Each step is quick to compute
Reverse: Each step is infeasible to reverse
Collision resistance: Different pubkeys producing same address is negligible
Practical Implications for Taproot#
As we’ll see, Taproot builds on these foundations:
X-only public keys: Save space, enable key aggregation
Bech32m encoding: Better error detection
Unified address appearance: Multi-sig and single-sig look identical—better privacy
Mastering these encodings and key formats prepares you for Taproot’s advanced features—multiple spending conditions can hide in one address.
Chapter Summary#
This chapter covered Bitcoin’s cryptographic foundation:
✅ Private keys are 256-bit random numbers, WIF-encoded for import/export
✅ Public keys are elliptic curve points; compressed format is standard
✅ Addresses are encoded hashes of public keys, not pubkeys themselves
✅ Different address types use different encodings: Base58Check, Bech32, Bech32m
✅ Taproot introduces x-only pubkeys and Bech32m for greater efficiency
These components—keys, hashes, encodings—are what Bitcoin Script operates on. Next chapter: how they work with Bitcoin Script—the language that defines spending conditions and underpins Taproot.