Recovering a BTC Wallet from Illegible Handwriting?
A friend recently asked for help with a peculiar problem. In 2021, they had created a BIP38-encrypted Bitcoin paper wallet. The password was remembered. The private key had been written by hand on graph paper. Time and questionable penmanship had rendered several characters ambiguous. This is of course an issue a lot of people might face: How to remember or recover secrets you only have partially access anymore if any.
The key should have been 58 characters. I could confidently read 52. The remaining characters existed in a state of interpretive uncertainty—was that an 'X' or a 'Y'? An 'o' or a 'c'? The handwriting offered no definitive answers.
This is the story of how I recovered it.
Understanding BIP38
BIP38 is a standard for encrypting Bitcoin private keys with a passphrase. Instead of storing a raw private key (which anyone who sees it can use), you store an encrypted version that requires a password to unlock.
The encrypted key is a 58-character Base58 string starting with "6P". Without the password, the underlying private key is cryptographically inaccessible.
But, this is the interesting part, not everything in a BIP38 key is encrypted.
The Structure I Exploited
A decoded BIP38 key has this structure:
Bytes 0-1: Prefix (identifies the key type)
Byte 2: Flag byte (compression settings)
Bytes 3-6: Address Hash (4 bytes, NOT encrypted)
Bytes 7-42: Encrypted dataThat Address Hash is crucial. It's the first 4 bytes of SHA256(SHA256(bitcoin_address)), stored in plaintext within the encrypted key.
Its original purpose is verification during decryption. Ff you enter the wrong password, the derived address won't match this hash. But for my purposes, it allowed me to verify that a recovered key matched a known Bitcoin address without ever knowing the password.
Base58Check: My Safety Net
Bitcoin uses Base58Check encoding, which appends a 4-byte checksum to encoded data. Any transcription error—a single wrong character—produces an invalid checksum. The probability of a random error still passing? About 1 in 4.3 billion.
The Base58 alphabet is worth noting:
123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyzMissing: 0 (zero), O (capital O), l (lowercase L), I (capital I). These were deliberately excluded to reduce visual ambiguity. A thoughtful design choice that nonetheless failed to account for my friend's handwriting.
The Recovery Process
Initial Transcription
I photographed the paper and transcribed every visible character. Underlined characters indicated uppercase in the original notation system. Some characters were written over corrections, suggesting the writer had made and fixed errors during the original transcription.
The result: 52 clear characters, with one section showing what appeared to be two overlapping sets of characters.
The Overlapping Section
One line showed characters written over other characters 'M' over 'k', 'T' over '4', and so on. Two interpretations:
- The second set replaced the first (5 characters total)
- Both sets were meant to be included (10 characters total)
Testing option 2 gave me 57 characters—one short of the required 58.
Searching for the Missing Character
With 57 characters, I needed to find where one character had been skipped or merged. The search space:
- 58 possible insertion positions
- 58 possible characters (the Base58 alphabet)
Total: 3,364 combinations. Trivial for a computer.
for position in range(len(key_57) + 1):
for char in BASE58_ALPHABET:
candidate = key_57[:position] + char + key_57[position:]
if verify_checksum(candidate):
print(f"Found: insert '{char}' at position {position}")No valid keys emerged. Either the 57-character base had an error, or my interpretation was wrong.
Expanding the Search
I added another dimension: what if one character was also wrong?
- 57 positions that might contain an error
- 58 possible corrections per position
- 58 insertion points
- 58 characters to insert
Approximately 11 million combinations. Still feasible—a few minutes of computation.
The result:
Found valid key:
- Insert 'j' at position 5
- Change position 19 from 'X' to 'Y'The handwritten 'Jj' had been read as a single 'J'. The 'Y' had looked like 'X'. Both reasonable misreadings.

Verification Without Decryption
I had a candidate key with a valid checksum. But did it belong to the expected Bitcoin address?
This is where the unencrypted Address Hash becomes useful:
def key_matches_address(bip38_key, bitcoin_address):
# Extract hash from key (bytes 3-6)
key_hash = base58_decode(bip38_key)[3:7]
# Calculate expected hash from address
addr_bytes = bitcoin_address.encode('utf-8')
addr_hash = sha256(sha256(addr_bytes))[:4]
return key_hash == addr_hashThe hashes matched. Mathematical confirmation that my recovered key corresponded to the known address, without any password or blockchain access required.
A Second Key, A Second Wallet
The handwritten notes contained a second BIP38 key. I applied the same process—this time, two characters were completely illegible, requiring a search through 58 × 58 = 3,364 combinations.
One candidate emerged with a valid checksum. But the Address Hash check revealed something unexpected: this key belonged to a different Bitcoin address entirely.
The writer had apparently documented two separate wallets. The second address remains unknown until decryption.
The Verification Scripts
For anyone facing a similar problem, here's the core code.
Checksum Verification
import hashlib
BASE58 = "123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz"
def base58_decode(s):
num = 0
for c in s:
num = num * 58 + BASE58.index(c)
result = []
while num > 0:
result.append(num % 256)
num //= 256
result.reverse()
return bytes([0] * (len(s) - len(s.lstrip('1'))) + result)
def double_sha256(data):
return hashlib.sha256(hashlib.sha256(data).digest()).digest()
def verify_checksum(key):
decoded = base58_decode(key)
payload, checksum = decoded[:-4], decoded[-4:]
return checksum == double_sha256(payload)[:4]Address Matching
def get_address_hash(bip38_key):
"""Extract address hash from BIP38 key (bytes 3-6)."""
return base58_decode(bip38_key)[3:7]
def calculate_address_hash(bitcoin_address):
"""Compute expected hash from a Bitcoin address string."""
return double_sha256(bitcoin_address.encode('utf-8'))[:4]
def key_matches_address(bip38_key, bitcoin_address):
"""Check if key belongs to address, without needing the password."""
return get_address_hash(bip38_key) == calculate_address_hash(bitcoin_address)Brute Force Search
def search_with_insertion_and_replacement(key_57):
"""Search for valid 58-char key by inserting one char and replacing one."""
for fix_pos in range(len(key_57)):
original = key_57[fix_pos]
for fix_char in BASE58:
if fix_char == original:
continue
modified = key_57[:fix_pos] + fix_char + key_57[fix_pos+1:]
for ins_pos in range(len(modified) + 1):
for ins_char in BASE58:
candidate = modified[:ins_pos] + ins_char + modified[ins_pos:]
if verify_checksum(candidate):
return candidate, fix_pos, fix_char, ins_pos, ins_char
return NoneReferences
- BIP-0038 Specification - The encrypted private key standard
- Base58Check Encoding - Bitcoin Wiki
- Learn Me a Bitcoin: Base58 - Visual explanation of the encoding
- bitaddress.org - Open source tool for offline key operations
Closing Thoughts
The keys were recovered. The funds were moved to more robust storage.
A few observations from the experience:
The Base58Check checksum made this recovery possible. Without it, I would have needed to test each candidate against the blockchain, dramatically slowing the process and requiring network access.
The unencrypted Address Hash in BIP38 is an underappreciated feature. It enables verification without secrets. Useful not just for password checking, but for confirming key-address relationships during recovery.
And finally: if you're storing cryptographic keys on paper, consider using a typewriter. Or at least print them. The human hand, whatever its other merits, was not optimised for producing unambiguous alphanumeric strings. Also it's in your own interest into not oversharing your wallet keys. So it was fun!