Hunting 9.1 BTC with a piece of paper, two Rust programs, and an RTX 3090
A friend inherited a mystery: one sheet of paper, 24 cursive words, a header reading “Nano Ledger,” a note saying 9.1 BTC, and a dead hardware wallet that wiped itself after three wrong PIN attempts. The original owner died years ago. There are no wallet files or exchange accounts. There is only the paper.
This is the recovery so far, including the dead ends and the tools I built. No coins yet.
The person who inherited the material authorized the work. I’m not publishing the phrase, the owner’s identity, or anything that could help reconstruct either one.
The setup
A hardware wallet gives you a 24-word seed from a standard list of 2,048 English words (BIP39). Write those words down and you can restore the wallet on another device. Lose the device, no problem. Lose the words, game over.
The paper should have been the easy ending: enter the 24 words in a new wallet and carry on.
Except the words don’t work. Ten of the 24 aren’t in the BIP39 list. They aren’t fuzzy spellings of valid words either. They are ordinary English words that don’t exist in that dictionary. The scan is sharp, and the handwriting ambiguities have been studied to death. Whatever this is, it isn’t a valid BIP39 phrase as written.
The question changed from “how do I restore this wallet?” to “what the hell is this phrase?”
The forensics
The owner probably bought the coins around 2013 or 2014. The dollar amount on the paper works out to roughly $153 per BTC, which fits late 2013. The person who received the page in 2017 watched the owner write the Ledger header, but the 24 words were already there. They predate the hardware wallet.
That matters because the mnemonic world in 2013 was a zoo. BIP39 barely existed. Blockchain.info used its own schemes and lists. Electrum had another. Brainwallets, where a phrase is hashed directly into a private key, were popular and catastrophically unsafe. Twenty-four words from that period could mean almost anything.
There is one more clue. The margin has a circled mark and the sideways word “email.” That comes back later.
I built joegen and joerecover
The main theory, shared by a professional recovery service helping with the case, is still that this started as BIP39 and the odd words were copied badly. Testing that means generating a huge number of plausible phrases and checking every one against the blockchain. I wrote two Rust programs for the job.
joegen generates candidates
joegen is a word-permutation engine. The repository is called joegen; its binary is word-permutations. Each line of the input describes one slot in the phrase. The program writes every combination to standard output.
The useful part is the constraint syntax:
[len:5 first:t] # all 5-letter words starting with 't'
[last:est] # words ending in "est"
[has:at !has:e] # contains "at", no letter 'e'
[all] # the entire 2,048-word dictionary
toil town tone # or an explicit shortlist
That maps onto the handwriting. If a cursive word appears to start with a looped ascender, runs about six letters, and ends in something like -est, I can express that directly. Constraints combine, exclusions work, and --skip N can resume or divide a job without regenerating the earlier space.
I made candidate generation its own Unix-style tool because every existing recovery program wanted its own file format and its own compromise. In a search like this, the candidate list is where most of the intelligence lives.
joerecover checks them
joerecover reads phrases from standard input, one per line, then:
- validates the BIP39 checksum;
- derives keys along the common legacy, wrapped-SegWit, and native-SegWit paths;
- computes the Bitcoin addresses; and
- looks them up in a memory-mapped database of addresses that have appeared on-chain.
The database uses the btcrecover format. Mine covers 2011 through 2021 in an 8.6 GB file. Any match is flushed to found.txt, while progress goes to standard error with a rate and ETA:
[found: 0] processed: 100000 lines (10.0%) (~990000 lines/sec) ETA: 2.1h - Last: ...
The checker is multithreaded, with one secp256k1 context per worker, bounded channels, and pre-parsed derivation paths. On my 20-core machine it rejects checksum-invalid phrases at about 990,000 per second.
That number needs an enormous asterisk. BIP39 deliberately sends valid phrases through 2,048 rounds of PBKDF2-HMAC-SHA512 before deriving keys. Checksum-valid phrases, the expensive ones, run at only about 4,400 per second on 20 cores. A stream full of junk flies. A better generator produces valid phrases and hits the PBKDF2 wall. The smarter the search gets, the slower it runs.
Two options made a large difference:
--indices Nand--changescan more receive and change addresses while deriving the expensive account keys only once per phrase.--complete-lastaccepts a 23-word prefix and calculates the exactly eight checksum-valid final words directly instead of trying all 2,048.
The last word is often hardest to read because the handwriting trails off. With --complete-last, every valid ending costs roughly the same as testing one phrase.
The pipe
The two programs connect the boring way:
word-permutations tokens.txt 2>&1 | joerecover --addressdb addresses.db \
--threads 20 --indices 3 --change
Candidate logic goes on the left, cryptography goes on the right, and a pipe sits between them. Before pointing it at 180 billion possibilities, I used planted vectors end to end. A known seed inserted into a tiny address database had to be found, and a valid decoy had to stay missing. A broken search can burn a month with complete confidence.
Then I gave it a 3090
At 4,400 valid derivations per second, some search tiers took years. PBKDF2 is extremely parallel, so the next move was obvious.
I adopted XopMC’s open-source CUDA_Mnemonic_Recovery. It runs the whole path on the GPU, from mnemonic and PBKDF2 through BIP32, elliptic-curve math, address hashing, and lookup. It checks a 512 MB Bloom filter built from roughly 1.19 billion Bitcoin addresses that have ever been used. I confirmed its filter format matches brainflayer’s hex2blf, so both tools can share filters.
The build had one weird failure. Two CUDA files for currencies I don’t need, Solana and TON, hung NVIDIA’s optimizer for more than half an hour under fast-math. Building those two files at -O0 with precise math turned the hang into a build that took seconds.
One RTX 3090 now does:
- about 8.4 million candidate tests per second; and
- about 525,000 checksum-valid derivations per second.
That is roughly 120 times the 20-core CPU rate. joegen still narrows the handwritten slots on the CPU. The GPU expands the wildcard ending and filters checksums itself.
What didn’t work
Ruling out an idea cleanly is progress. Here is the scoreboard:
- Blockchain.info V2 and V3: structurally impossible. All 24 words appear in the huge V3 list, which wasted real time, but a valid phrase must begin with three V2 words encoding a version and checksum. This phrase can’t. Even a successful decode would produce login credentials rather than keys, which is useless without the old wallet file.
- Electrum 1.x and 2.x: no matches.
- Brainwallets: no matches. I tried the phrase directly through SHA-256 at several rounds, SHA-3, brainwallet.io’s method, and WarpWallet without a salt, across hundreds of spelling, formatting, and ordering variants.
- BIP39 with up to two substitutions: no matches. That covered the full dictionary and standard derivation paths.
- The large handwritten search: no matches. I varied hand-curated candidates for all ten non-BIP39 words at once. The first pass tested 93.3 million combinations. A wider pass tested 1.71 billion. Neither matched the 50-million-address funded set or the 1.19-billion-address used set.
Everything tractable with the current candidate lists has been exhausted.
Tracing the handwriting
I built a little web tool and traced all 24 words on an iPad with an Apple Pencil. It recorded stroke order and direction, not only the final shape. A Python renderer produced colored overlays with start points and arrows for comparison with the scan.

This did resolve real ambiguities. The writer’s lowercase t is a curved, looped ascender that often has no crossbar, so several words had been read wrong. Repeated letters elsewhere on the page confirmed disputed first letters and killed one entire candidate family when a final character turned out to be d.
The bad news is that the ten stubborn words are still not BIP39. The handwriting isn’t the problem. The scheme is.
The word in the margin
The strongest surviving lead is “email.” WarpWallet was a popular 2013-era brainwallet designed to make brute force expensive. It takes an optional salt, and the usual salt was your email address. A circled “email” next to a key sheet from that period is difficult to dismiss.
Every WarpWallet search so far was unsalted. With the right address, the problem drops from impossible to an afternoon. Finding that email is now genealogy rather than computation.
If that fails, the remaining options get stranger: much wider candidate lists, or the possibility that the words never encoded a private key at all.
If you are doing this too
- Test the entire pipe with known answers. A search that can’t find a planted seed will happily find nothing forever.
- Measure the actual bottleneck. Invalid-candidate speed is a vanity number. Valid PBKDF2 derivations are what cost time.
- Rule out formats from their structure. An afternoon reading old wallet source code can beat a year of brute force.
- Treat the paper as more than words. Marginal notes, dates, cost-basis math, stroke order, and different inks can all change the search.
- Plan for the GPU early. One used consumer card turned years into days.
The strangest part is that I still can’t point at the coins. Without a valid decoding, there are no addresses to inspect. I can’t prove that the 9.1 BTC is still where the owner left it, or that it was ever there. All I have is a dead man’s note saying it was.
Tools: joerecover and joegen (mine, Rust), btcrecover, brainflayer, and XopMC’s CUDA_Mnemonic_Recovery. If you’re working on something similar, feel free to reach out.