Skip to content

About

Bitcoin operation tools written in pure Mojo (with some GPU acceleration)

Resources

Stars

1 star

Watchers

0 watching

Forks

Repository files navigation

mojo-bitcoin

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...

What it does

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

Install

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.sh

pixi 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.

Examples

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.mojo

Create a seed phrase

from 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-32

Derive a path

from 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()))

Search for an address containing a substring

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)

Supported

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.

The vanity search

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

GPU

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.

Quantum-safe transaction pinning

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.

What the puzzle is

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:

  1. The locking script hardcodes an ECDSA signature (r, s).
  2. Because (r, s) are fixed, the public key that satisfies OP_CHECKSIG is not chosen — it is determined by the transaction's sighash, through ordinary key recovery. No secret is involved; anyone can compute it.
  3. The script takes OP_RIPEMD160 of 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.

Solving one

$ ./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

CPU vs GPU

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.

Verified by bitcoinjs-lib

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.

What is not here

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.

Correctness

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.sh

Security

Read 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.

Contributing

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.

Licence

MIT. The secp256k1 dependency is a derivative work of libsecp256k1 and carries Pieter Wuille's copyright notice; see its LICENSE.

About

Bitcoin operation tools written in pure Mojo (with some GPU acceleration)

Resources

Stars

1 star

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages