Chapter 1: Private Keys, Public Keys, and Address Encoding#
Every Bitcoin spend traces back to one chain of derivations: a private key produces a public key, and a public key produces an address. This chapter walks that chain end to end — generate a key, encode it, and turn it into the four address formats you will meet through the rest of the book. None of it is Taproot-specific yet, but the two pieces introduced at the end — x-only public keys and Bech32m — are exactly what Taproot is built on.
# 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 Derivation Chain#
Bitcoin’s ownership model is a one-way chain. Each arrow is cheap to compute forwards and infeasible to run backwards:
Private Key (256-bit) → Public Key (ECDSA point) → Address (encoded hash)
The three pieces divide the work cleanly: the private key signs, the public key lets anyone verify that signature, and the address is the short string you hand out to receive funds — with the public key itself kept out of sight until the moment you spend.
Private Keys: The Foundation of Ownership#
A Bitcoin private key is just a 256-bit number — a random integer drawn from a space of 2^256. That space is the entire security argument: it is on the order of the estimated number of atoms in the observable universe, so guessing someone else’s key is not a strategy anyone can mount.
Generating Private Keys#
Using the btcaaron 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 hex form is exactly 64 characters — 256 bits, 32 bytes — and it is what the math actually operates on. It is also unforgiving: mistype one character and you get a different, perfectly valid-looking key with no warning. WIF exists to close that gap.
Wallet Import Format (WIF)#
WIF wraps the raw key in Base58Check. That wrapping adds a checksum so a typo is caught before it costs you anything, drops the visually ambiguous characters (0, O, I, l), and gives every wallet one standard string to import and export.
The encoding runs in four steps:
Add a version prefix:
0x80for mainnet,0xEFfor testnet.(Optional) Add a compression flag: if the matching public key will be compressed, append
0x01to the payload. This single byte is what changes the final Base58 prefix of the WIF.Calculate the checksum: take
SHA256(SHA256(data))and keep the first 4 bytes.Base58-encode the result into the human-readable string.

Figure 1-1: WIF encoding transforms a 32-byte private key into a Base58Check encoded string
The prefix tells you what you are holding at a glance:
L or K: mainnet private key (compressed)
c: testnet private key
Public Keys: Cryptographic Verification Points#
A public key is a point on the secp256k1 elliptic curve, obtained by multiplying the private key into the curve’s fixed base point. The arithmetic behind that multiplication is what makes the step irreversible; in code it is a single attribute access.
ECDSA and secp256k1#
Bitcoin signs with ECDSA over the secp256k1 curve, defined by:
y² = x³ + 7

Figure 1-2: The secp256k1 elliptic curve used by Bitcoin
For everything in this book, two properties are enough: every private key k maps to exactly one point (x, y) on the curve, and that map cannot be run in reverse.
Compressed vs Uncompressed Public Keys#
A public key can be written two ways.
Uncompressed (65 bytes):
04 + x-coordinate (32 bytes) + y-coordinate (32 bytes)
Compressed (33 bytes):
02/03 + x-coordinate (32 bytes)
Compression works because the curve equation lets you recover y from x alone, once you know whether y is even or odd — and that single bit rides in the prefix:
02: y is even03: y 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...
Everything modern uses the compressed form: half the bytes, identical security.
X-Only Public Keys: Taproot’s Innovation#
Taproot drops the prefix entirely and works with x-only public keys — the bare 32-byte x-coordinate. The parity byte disappears because Taproot fixes the convention (a key is always taken with even y), which trims a byte off every key and is what lets Schnorr key aggregation stay clean. From Chapter 5 onward, this is the format in play.
# 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 the format behind Taproot’s efficiency gains; later chapters detail it.
Address Generation: From Public Keys to Payment Destinations#
A Bitcoin address is not a public key. It is an encoded hash of one, and that extra hashing step buys three things at once:
Privacy: the public key stays hidden until you spend.
A hedge against the curve breaking: a hash sits in front of the elliptic-curve key, so a future weakness in secp256k1 does not immediately expose unspent funds.
Error detection: the encoding carries a checksum.
The Address Generation Process#
Every Bitcoin address is built the same way:
Hash the public key: SHA256 followed by RIPEMD160 (together, Hash160).
Add metadata: version bytes and script-type information.
Add a checksum: the error-detection bytes.
Encode: Base58Check or Bech32 / Bech32m.

Figure 1-3: Converting a public key to a Bitcoin address through hashing and encoding in the legacy way
# 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, used for legacy addresses, drops visually similar characters and folds in a checksum.
Excluded characters: 0 (zero), O (capital o), I (capital i), l (lowercase L)
P2PKH (Pay-to-Public-Key-Hash):
Prefix:
1Format: Base58Check encoded
Usage: the original Bitcoin address format
Example:
1A1zP1eP5QGefi2DMPTfTL5SLmv7DivfNa
P2SH (Pay-to-Script-Hash):
Prefix:
3Format: Base58Check encoded
Usage: multi-signature and wrapped SegWit addresses
Example:
3J98t1WpEZ73CNmQviecrnyiWrnqRhWNLy
Bech32 Encoding: SegWit’s Innovation#
Bech32, introduced with SegWit, not only detects common typos but can often point at which character is wrong.
P2WPKH (Pay-to-Witness-Public-Key-Hash):
Prefix:
bc1qFormat: Bech32 encoded
Benefits: lower fees, stronger error detection
Example:
bc1qw508d6qejxtdg4y5r3zarvary0c5xw7kygt080
Bech32m Encoding: Taproot’s Enhancement#
Taproot addresses use Bech32m, a tweaked Bech32 that fixes an edge case in the original checksum.
P2TR (Pay-to-Taproot):
Prefix:
bc1pFormat: Bech32m encoded
Benefits: key-path and script-path spends share one address format
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 plenty of fiddly rules — version bytes, checksums, three different schemes — but the idea underneath is simpler than the rules suggest:
Addresses are for humans. They are a user-friendly stand-in for a locking script (scriptPubKey), not a part of the protocol itself. Once you recognize the prefix (1, 3, bc1q, bc1p), you already know which script sits behind it. From the node’s point of view, Bitcoin never stores addresses — only scripts.
Later chapters keep their attention on that script — the actual scriptPubKey behind each address type. That is where the real logic lives, and where Bitcoin’s programmability begins. If you can predict the script behind an address, you can reason about how it is spent.
The Derivation Model#
One diagram ties the whole chain together — from generating a key down to the script that actually lands on-chain. Wallet users only ever see the address; as a developer you need the full path, because that path is what the node enforces.

Figure 1-4: The derivation relationships between private keys, public keys, addresses, and WIF format
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)
The chain is asymmetric by design:
Forward: each step is cheap to compute.
Reverse: each step is computationally infeasible.
Collision resistance: two different public keys producing the same address is vanishingly unlikely.
What Carries Into Taproot#
Three things from this chapter resurface the moment Taproot appears: x-only public keys (Chapter 5), Bech32m as the address format for P2TR, and the idea that an address is only ever a stand-in for a locking script. Hold onto that last point in particular — from here on the interesting question is never “what is the address” but “what script is behind it, and how is it spent.” Chapter 2 starts answering that by introducing Bitcoin Script itself.
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.