A Bitcoin wallet in Mojo: seed phrases from kernel randomness, key derivation down a path, a vanity address search built to run on the GPU, and the proof of work behind the first quantum-safe Bitcoin transaction.
Built on mojo-secp256k1 — a port of libsecp256k1 — which it consumes as a precompiled package.
$ ./tools/mojo.sh run examples/wallet.mojo
network : mainnet
mnemonic : ridge vault fossil ... shrimp
native segwit (P2WPKH) — m/84'/0'/0'/0/i
0 bc1qeq6r7f0m5m4t3qz9jw8vs4hkxdcvzsq7hn3rml L2xTf...
1 bc1q7g8v0trkjs5m2wtdlwv9gxz3nqhc7f0y6pw4az Kzr9d...
Seed phrases from real randomness. Entropy comes from getentropy(2) — the
kernel CSPRNG — and nothing else. There is no userspace PRNG anywhere in this
library, no clock seeding, and no fallback path: if the kernel call fails, the
generator raises rather than quietly producing something weaker.
Key pairs from a derivation path. Full BIP-32 private derivation, hardened and not, with paths written the usual way:
var ctx = Secp256k1()
var seed = mnemonic_to_seed(mnemonic, "")
var master = ExtendedPrivkey.from_seed(seed)
var key = derive_path(ctx, master, "m/84'/0'/0'/0/0")
print(p2wpkh_address(key.pubkey(ctx), Network.mainnet()))
print(wif_encode(key.secret_bytes(), Network.mainnet()))Vanity addresses that belong to your seed. Not a search for a lucky
keypair — a walk down m/84'/0'/0'/0/i looking for an address containing what
you asked for. Whatever it finds is spendable by the seed phrase you already
have; import the words into any wallet and let it scan to the index printed
with the hit.
$ ./tools/mojo.sh run examples/vanity.mojo -- --target zap
chain : m/84'/0'/0'/0/i
looking for: zap anywhere in the address
expected : about 910 addresses per hit
found at index 9
address : bc1qgswpjzsqgrm2qkfkf9kzqpw6642ptrgzapvh9y
path : m/84'/0'/0'/0/9
wif : L5ViR9iWB9GcidHex2ebJyYSZJxAnud1Zffrsn5MWRWtY5rHRhfo
There is no Mojo package registry yet, so the dependency is a git submodule
that gets precompiled into a local .mojopkg:
git clone --recurse-submodules https://github.com/AlexBrou/mojo-bitcoin
cd mojo-bitcoin
./tools/build_deps.sh # precompiles the dependency into .deps/
./run_tests.shpixi provides the toolchain. tools/mojo.sh wraps mojo with the right
include path and builds the dependency if it is stale, so most of the time you
just run that.
Five runnable files in examples/, smallest first:
| file | what it shows |
|---|---|
seed.mojo |
create a seed phrase, check it, turn it into a BIP-32 seed |
derive.mojo |
derive keys and addresses from a derivation path |
find_address.mojo |
search for a substring on the CPU |
find_address_gpu.mojo |
the same search on the GPU |
wallet.mojo |
a small CLI: generate a wallet, print its first addresses |
vanity.mojo |
the full search CLI, with --gpu, --prefix, --kind |
qsb_pinning.mojo |
solve a quantum-safe transaction-pinning puzzle |
node/verify_pinning.js |
re-check a solve with bitcoinjs-lib |
./tools/mojo.sh run examples/seed.mojo
./tools/mojo.sh run examples/derive.mojo
./tools/mojo.sh run examples/find_address_gpu.mojofrom bitcoin.bip39 import generate_mnemonic, mnemonic_to_seed
from bitcoin.wordlist import Wordlist
var wl = Wordlist()
var mnemonic = generate_mnemonic(wl, 12) # entropy from getentropy(2)
var seed = mnemonic_to_seed(mnemonic, "") # 64 bytes, feeds BIP-32from bitcoin.address import Network, p2wpkh_address, wif_encode
from bitcoin.bip32 import ExtendedPrivkey, derive_path
from secp256k1.api import Secp256k1
var ctx = Secp256k1() # owns the generator table; keep it
var master = ExtendedPrivkey.from_seed(seed)
var key = derive_path(ctx, master, "m/84'/0'/0'/0/0")
print(p2wpkh_address(key.pubkey(ctx), Network.mainnet()))
print(wif_encode(key.secret_bytes(), Network.mainnet()))from bitcoin.addrfast import AddrKind
from bitcoin.vanity import ChainKey, VanityTarget, search_cpu
var net = Network.mainnet()
var ck = ChainKey.at_path(ctx, master, "m/84'/0'/0'/0")
var target = VanityTarget("peach", AddrKind.P2WPKH, net)
var hits = search_cpu(ctx, ck, target, net, 0, 2000000, 1)
print(hits[0].address, hits[0].path, hits[0].wif)The same search on the GPU is the same call on a different object:
from bitcoin.vanity_gpu import GpuVanity
var gpu = GpuVanity(ctx) # uploads the table, self-tests
var hits = gpu.search(ctx, ck, target, net, 0, 2000000, 1)| Seed phrases | BIP-39 English, 12 / 15 / 18 / 21 / 24 words, optional passphrase |
| Derivation | BIP-32 private derivation, any path, hardened or not |
| Addresses | P2PKH (1…), P2SH-P2WPKH (3…), P2WPKH (bc1q…) |
| Networks | mainnet and testnet |
| Export | WIF, and xprv/xpub (also zprv/zpub, yprv/ypub) |
Also: QSB transaction pinning (see below), on the CPU and the GPU.
Not supported: Taproot, watch-only derivation from an xpub, non-English wordlists, and building or signing transactions.
Per candidate address the work is:
IL = HMAC-SHA512(chain code, parent pubkey || i)
k = IL + parent key (mod n)
P = k * G <-- 76% of the time
addr = encode(RIPEMD160(SHA256(P)))
On one core that is 8.7 us per address, about 113,000 addresses per second.
k*G dominates, and every index is independent of every other — which is what
makes the whole thing a GPU problem.
Difficulty, for a bech32 address (32-character alphabet):
| target | as a prefix | anywhere in the address |
|---|---|---|
| 3 characters | 32,768 | 910 |
| 4 characters | 1,048,576 | 29,959 |
| 5 characters | 33,554,432 | 986,895 |
| 6 characters | 1,073,741,824 | 32,537,631 |
(VanityTarget.expected_attempts() computes these; the substring column treats
the candidate positions as independent, so it slightly underestimates targets
with repeated characters.)
Substring search is far cheaper than prefix search because a 42-character
address offers ~37 places to match instead of one. Both are supported;
--prefix selects the anchored form.
A target that cannot occur is rejected before any work starts rather than
searched for forever — bech32 has no 1, b, i or o, and base58 has no
0, O, I or l:
$ ./tools/mojo.sh run examples/vanity.mojo -- --target bob
Error: 'bob' can never appear in a bech32 address: the character 'b' is not in
the bech32 charset, which omits 1, b, i and o
One kernel thread does the entire pipeline for one derivation index — HMAC,
scalar add, k*G, field inversion, HASH160, bech32, substring test — and
writes back a single byte. Nothing else crosses the bus.
$ ./tools/mojo.sh run examples/vanity.mojo -- --target peach --gpu
found at index 897575
address : bc1qvqr4peach3vgw49eqx5pn5l0gw45cdw6dn9lfn
path : m/84'/0'/0'/0/897575
searched 1048576 addresses in 0.79 s = 1326753 addresses/second (GPU)
Measured on an Apple M5 Pro, bench/bench_gpu.mojo:
| batch | CPU (1 core) | GPU | speedup |
|---|---|---|---|
| 16,384 | 110.7 k/s | 792.7 k/s | 7.2× |
| 65,536 | 112.3 k/s | 1.06 M/s | 9.4× |
| 262,144 | 114.2 k/s | 1.28 M/s | 11.2× |
| 1,048,576 | 113.3 k/s | 1.37 M/s | 12.1× |
Searching for the three targets, same seed, same indices on both sides:
| target | hits | CPU | GPU | speedup |
|---|---|---|---|---|
sky (65,536 addresses) |
63 | 0.58 s | 0.06 s | 9.4× |
alex (262,144) |
15 | 2.33 s | 0.21 s | 11.2× |
peach (2,097,152) |
2 | 18.78 s | 1.54 s | 12.2× |
bc1qtfjuya2fk90x94e7cqgkskyeud2fwug03afv6s m/84'/0'/0'/0/2268
bc1ql7alexmt76tdrs4et5y9m9qguydnekc3f4n958 m/84'/0'/0'/0/1868
bc1qvqr4peach3vgw49eqx5pn5l0gw45cdw6dn9lfn m/84'/0'/0'/0/897575
The benchmark refuses to print a timing until both sides agree on the hit count, so a speedup over a kernel that quietly did less work cannot slip through.
How much headroom is left. The dependency's own GpuBatch.mul_gen — which
does nothing but k*G on the same device, and still ships 96 bytes per point
back to the host — runs at 2.07 M/s. The fused kernel reaches 1.37 M/s while
also doing an HMAC-SHA512, a per-thread field inversion, a HASH160, a bech32
encode and a substring scan. Two thirds of the raw point-multiplication rate
for that much extra work means the kernel is not where the easy wins are.
The remaining cost worth naming is the inversion. The dependency's kernels
never invert: they emit Jacobian points and let the host finish the batch with
one inversion for the whole set. Here each thread inverts its own z by
Fermat exponentiation, roughly 40% on top of k*G — the price of not moving
2 GB of points off the device to find two addresses. A Montgomery batch
inversion in threadgroup memory would recover most of it and is the obvious
next step.
Small batches lose: at 16k candidates the launch and the round trip dominate
and the GPU is only 7× ahead. --gpu is worth it from about 64k candidates,
which is any target of four characters or more.
In August 2026 StarkWare mined the first quantum-safe Bitcoin transaction, using a scheme by Avihu Levy that needs no protocol change. This library implements its proof of work — phase one, transaction pinning — on the CPU and the GPU.
A Bitcoin address hides your public key until you spend. The moment you do, it sits in the mempool for ten minutes — the window a quantum computer with Shor's algorithm would need. QSB closes it:
- The locking script hardcodes an ECDSA signature
(r, s). - Because
(r, s)are fixed, the public key that satisfiesOP_CHECKSIGis not chosen — it is determined by the transaction's sighash, through ordinary key recovery. No secret is involved; anyone can compute it. - The script takes
OP_RIPEMD160of that key and requires the 20 bytes to parse as a DER signature. Twenty random bytes do so with probability 2⁻⁴⁶·⁴.
So changing any part of the transaction changes the key, changes the hash, and breaks the puzzle. An adversary who recovers your private key with Shor still faces a RIPEMD-160 pre-image search to redirect the funds — and Shor does not help with hashes. ECDSA is used as a vehicle, not as a security assumption.
$ ./tools/mojo.sh run examples/qsb_pinning.mojo -- --gpu --level 3
puzzle : level 3 of 7, 2^-24
device : Apple M5 Pro
SOLVED in 2.893105 s
sequence : 2644244
derived key : 03431b4cefb87e04758815a183cae1121c20e7aa69320672a0ac33abb34136a7f6
RIPEMD160 : 301102fd8efe8ce09b69e22e5919a60d5dfe23af
^^ ^^ ^^ DER SEQUENCE, length 17, INTEGER tag
--level walks a ladder of genuine prefixes of the mainnet puzzle — the same
bytes, checked as far as the level goes. Level 7 is the real thing:
| level | pins down | odds | expected on this GPU |
|---|---|---|---|
| 1 | 0x30 |
2⁻⁸ | instant |
| 2 | + length 17 |
2⁻¹⁶ | instant |
| 3 | + 0x02 for r |
2⁻²⁴ | ~3 s |
| 4 | + r length in range | 2⁻²⁸·⁴ | ~3 min |
| 5 | + 0x02 for s |
2⁻³⁶·⁴ | ~12 hours |
| 7 | + r, s positive and minimal | 2⁻⁴⁶·⁴ | ~540 days |
Grinding is a geometric process, so a given run lands anywhere around the mean. Two solves actually produced here:
level 3 2.99 s 360 020 candidates 301102fd8efe8ce09b69e22e5919a60d5dfe23af
level 4 334.49 s 359 979 975 3011020c1d46bb3453fabb42fda2fdfea34fb382
^^ ^^ ^^ ^^
| | | +-- r length 12, in range
| | +----- INTEGER tag
| +-------- length 17
+----------- SEQUENCE
bench/bench_pinning.mojo, 5 KB scriptCode, on an M5 Pro:
| batch | CPU (1 core) | GPU | speedup |
|---|---|---|---|
| 16,384 | 117.4 k/s | 610.5 k/s | 5.2× |
| 262,144 | 117.4 k/s | 964.6 k/s | 8.2× |
| 1,048,576 | 117.4 k/s | 1.03 M/s | 8.8× |
Each candidate tests two keys — recovery yields one for R and one for −R,
and both satisfy OP_CHECKSIG — so that is 2.07 M puzzle evaluations/second
on the GPU against 235 k/s on a core.
A full 2⁻⁴⁶·⁴ solve is ~540 days here, against a reported 4.7 days on one RTX PRO 6000 and ~6 hours on a fleet of eight. This is not a machine for mining one; it is an implementation you can read, test and check.
A solve is written to JSON and re-checked by a completely separate stack — bitcoinjs-lib for the sighash and RIPEMD-160, @noble/curves for the EC:
$ cd examples/node && npm install && node verify_pinning.js ../../qsb_pinning.json
1. the transaction hashes to the claimed sighash
PASS bitcoinjs hashForSignature
2. the derived key is what ECDSA recovery gives
PASS @noble/curves recoverPublicKey
3. the hardcoded signature verifies against that key
PASS secp256k1.verify(sigNonce, sighash, keyNonce)
4. RIPEMD-160 of that key is the claimed puzzle output
PASS bitcoinjs crypto.ripemd160
5. those 20 bytes read as a DER signature
3011020c1d46bb3453fabb42fda2fdfea34fb382
[x] level 1: 0x30 SEQUENCE tag
[x] level 2: + length byte 17
[x] level 3: + 0x02 INTEGER tag for r
[x] level 4: + r length in range
[ ] level 5: + 0x02 INTEGER tag for s
...
ALL CHECKS PASSED — bitcoinjs-lib and @noble/curves agree with mojo-bitcoin.
Those five checks are exactly what the locking script does. The cross-check has
already paid for itself: the example originally filled its scriptCode with
0xab, which is OP_CODESEPARATOR — a byte the legacy sighash strips before
hashing. bitcoinjs caught the disagreement immediately.
Only pinning. A complete QSB spend also needs two digest rounds (subset
selection over ~150 dummy signatures, with FindAndDelete changing the
scriptCode per candidate), HORS one-time signatures, a 10 KB script generator
inside a 201-opcode budget, and transaction assembly. None of that is GPU work,
and all of it is a project in its own right.
Nothing here is checked against itself. Every expected value in
tests/vectors/ comes from an independent source: OpenSSL for the hashes, the
published BIP-32 / BIP-39 / BIP-49 / BIP-84 / BIP-173 vectors, and a small
pure-Python secp256k1 in tools/gen_vectors.py that shares no code with the
Mojo implementation. That Python reference was itself verified against the
BIP-32 text — it reproduces all 34 extended keys — before being trusted.
./run_tests.shRead src/bitcoin/rng.mojo. It is short, and it is the file that decides
whether a wallet from this library is safe.
Seeds, chain codes and private keys are wiped with volatile stores, and
tools/check_scrub.sh checks at the assembly level that the optimizer does not
delete the wipes.
This is unaudited software. It is not constant time end to end: Base58 long division, Bech32 and the wordlist lookup all branch on data. That is the usual trade-off for key generation and address derivation, but do not extend it into signing on a machine where someone can measure it. Think hard before putting real money behind any of it.
CLAUDE.md explains how the internals fit together and which mistakes this
project has already made. NEXT.md is the current backlog, in measured
priority order.
MIT. The secp256k1 dependency is a derivative work of libsecp256k1 and carries Pieter Wuille's copyright notice; see its LICENSE.