A fast, MeshCore-compatible Ed25519 vanity public-key generator. Ships in two forms:
meshcore_vanity.py— a Python CLI with bech32/base58/base64 support and parallel multiprocessing.index.html— a zero-install browser app (PWA) that mines with Web Workers + libsodium WASM.
Inspired by and credited to MeshCore — a decentralized mesh networking project. This tool generates Ed25519 keypairs whose encoded public keys match a user-defined pattern, suitable for use with MeshCore devices and related tooling.
Generates Ed25519 cryptographic keypairs until the encoded public key matches a target pattern (prefix, suffix, or both). The search is optimized with a scalar-walk algorithm that avoids repeated hashing, making it significantly faster than naive approaches.
index.html is a self-contained PWA — no build step, no dependencies beyond the
checked-in libsodium.js. It needs to be served over HTTP(S), not opened as a
file:// URL, because browsers refuse to create blob Web Workers from file://.
python -m http.server 8000
# then open http://localhost:8000/Deploy it to any static host (GitHub Pages, Netlify, Cloudflare Pages). The service worker precaches the app shell and serves stale-while-revalidate, so the app works offline and picks up new deploys on the next load.
Browser-specific behaviour, for comparison with the CLI below:
| Browser app | Python CLI | |
|---|---|---|
| Pattern matching | hex only | hex, base64, base64url, base58, bech32 |
| Parallelism | auto-detected Web Workers (hardwareConcurrency - 1, capped at 16) |
--workers N processes |
| Key history | kept in localStorage, exportable as JSON/CSV |
none |
| Installable | yes (PWA with maskable icons) | n/a |
| Private key | shown per result, stored in history | only with --output-private |
| History display | newest first, scroll stays at the top | n/a |
Security: the browser app encrypts saved keys in
localStorageusing AES-256-GCM (authenticated encryption) with a per-entry random 12-byte nonce. The key is derived via HKDF-SHA256 from a 32-byte secret minted once and stored in IndexedDB — a different storage backend from the ciphertext. So liftinglocalStorageon its own (backup, sync, shared profile, a stray export) does not yield the key, whileoriginbinds the key to this site so a copy of both stores only decodes here.Because the key does not depend on anything the browser can change under the user, saved history survives browser updates that alter the user-agent string, timezone changes while travelling, and window resizes. (An earlier version derived the key purely from a hardware fingerprint and every one of those permanently orphaned the history.)
This is authenticated encryption, providing confidentiality and integrity (tamper detection). It does not protect against script running on the origin (XSS, a malicious extension), because such code can read IndexedDB. Treat the history as a secret store, and clear it when done.
Backward compatibility: existing XOR-obfuscated entries (version 0xef) are still decrypted on load. New entries use AES-GCM (version 0x01) with per-entry random 32-byte HKDF salt (stored alongside the 12-byte nonce). The ciphertext format is: byte 0: version (0x01 = AES-GCM, 0xef = legacy XOR) bytes 1-12: 12-byte nonce (random per entry) bytes 13-44: 32-byte HKDF salt (random per entry) bytes 45+: ciphertext || auth tag (AES-GCM output)
If IndexedDB is unavailable (private mode, storage disabled) the key falls back to
HKDF-SHA256(fingerprint), so history still round-trips rather than being lost. The chosen mode is recorded on first run and never re-decided, because switching modes would change the key and orphan every stored key.If no key can be derived at all, the page refuses to save rather than writing plaintext. A silently-degraded path would put saved private keys in
localStoragein the clear, which is the exact exposure this scheme exists to prevent; instead the session keeps keys in memory and says so.
Reserved hex prefixes 00 and ff are mined with a warning rather than
rejected, in both the browser app and the CLI — some users deliberately want
one. Set MESHCORE_VANITY_STRICT_RESERVED=1 to make the CLI reject them again.
tools/make_icons.pyregeneratesicon-192.png,icon-512.pngandicon-maskable-512.pngfrom code. Run it after changing the icon design:python tools/make_icons.py- When you add or remove a file from
sw.js'surlsToCache, bumpCACHE_VERSIONin the same commit so returning visitors get the new app shell. node tools/test_page_js.mjs <main.js> <worker.js>executes the page and worker JS against a mock DOM; CI runs it aftertools/check_inline_js.pyextracts them.
Six figures are shown while mining: attempts, rate, progress, workers actually running, cores, and ETA.
The ETA is split into fixed digit slots. Rendered as one string it reflowed
every time a field changed digit count — 9h 5m 3s becoming 9h 5m 13s moved
everything after it, so the row visibly hopped. Instead the duration is written
into separate elements, each reserving its width:
| Slot | Reserved | Why |
|---|---|---|
| days | 8ch |
the (~5 days) estimate leads, so the hours never shift under it |
| hours | 20ch |
a multi-week run reaches 4-5 figures; reserve the widest case so the layout is identical from the first minute to the last |
| minutes | 2ch |
always two digits, zero-padded |
| seconds | 2ch |
as above |
The panel also sets font-variant-numeric: tabular-nums, which is what actually
stops a digit changing width — the reserved min-widths then hold the columns.
On screens under 420px the hour slot drops to 10ch: still far more than a
three-week ETA needs (~500h), and still fixed rather than proportional, which is
the part that matters. Minutes and seconds are always padded to two digits, so
05m is exactly as wide as 15m.
The ETA is also smoothed over time. It is the most eye-catching number on the
page and the least stable, being an instantaneous rate divided by a large
remaining count. Shown raw it hopped several times a second, which reads as
instability and trains people to ignore the one figure they came for. It is now
withheld until four samples exist — the opening batches are always slower while
WASM warms up, so a figure derived from them would be misleading — and then eased
in with an EMA at alpha = 0.12. A 10x spike in the underlying rate moves the
displayed ETA by well under half, instead of tracking it linearly.
The browser miner spends almost all of its time inside libsodium, so the wrapper around each candidate must be close to free. Two things dominate it if done naively, and both were real defects that capped throughput at a few thousand keys/second regardless of how fast the crypto was:
- Yielding per batch. Browsers clamp nested
setTimeout(0)to ≥4 ms. Yielding after every batch of candidates therefore throttled each worker to ~4,000 candidates/s. The worker now yields on a 30 ms wall-clock budget instead. - Rebuilding hex strings per candidate. Converting the candidate and public
key to hex and running
startsWith/endsWithcosts more than the keygen. The walk state is now a 32-byte array incremented in place, and the pattern is pre-decoded to nibbles compared directly against the raw public-key bytes — no allocation and no string building in the hot loop.
Regression guards:
| Command | Checks |
|---|---|
node tools/verify_worker_math.mjs |
the byte-walk is arithmetically identical to the previous BigInt/hex implementation, including 2²⁵⁶ carry and wraparound |
node tools/bench_worker.mjs <worker.js> |
wrapper overhead with a stubbed (free) keygen; must stay far above the keygen cost |
Careful: never write
if (++bytes[i] !== 0). Incrementing aUint8Arrayelement returns the unclamped value (256, not0), so the carry test never fires and the counter silently stops at a byte boundary. Mask explicitly:bytes[i] = (bytes[i] + 1) & 0xff;then testbytes[i] !== 0.
The hot loop is one operation repeated tens of millions of times: derive an Ed25519 keypair, then match the target pattern against the public key's bytes. Everything else in the wrapper is cheap in comparison, so the choice of crypto implementation is the throughput ceiling. Candidates evaluated:
| Option | Verdict | Reason |
|---|---|---|
| libsodium.js (WASM) | chosen | The reference C implementation compiled to WebAssembly. Measured at ~13.5k keys/s/core in this app's worker. WASM avoids the per-operation type checks and boxing that dominate pure-JS scalar arithmetic. |
WebCrypto Ed25519 |
unusable | The API only returns a public key as a CryptoKey. A non-extractable key exposes no bytes at all, so there is nothing to pattern-match; making it extractable to read the bytes would leak exactly the value being mined for. A structural blocker, not a speed trade-off. |
| tweetnacl.js | rejected | Pure JS. Correct and small, but its scalar loop loses to the WASM path at this call rate. |
| noble-ed25519 | rejected | Pure JS and well maintained, but again slower than WASM on raw scalar-multiply throughput — the only metric that matters when it is called millions of times. |
| Hand-rolled JS bigint | rejected | This was the original approach and the baseline that WASM beats. Far slower, and a large correctness risk in hand-rolled curve arithmetic. |
Two caveats, stated plainly rather than buried:
- The ~13.5k keys/s/core figure measures the chosen path only, not a controlled head-to-head benchmark against every row above. The tweetnacl and noble-ed25519 rejections rest on them being pure-JS scalar loops against a WASM one, plus spot checks — not on a rigorous sweep on identical hardware.
- There is no faster primitive to reach for. WASM SIMD would not help: Ed25519
scalar multiplication is inherently serial field arithmetic, and libsodium
exposes no batched or vectorised
scalarmultentry point to call. No such primitive was found in the available builds, so the loop stays where it is.
Calibration measures one worker on one core, but the search then runs
navigator.hardwareConcurrency workers concurrently. Multiplying the
single-worker rate by the worker count overstates the result, because the
workers do not each get a core to themselves. This was measured rather than
assumed:
- Reference host: 4 physical cores / 8 logical (Intel i5-1155G7).
- Shipped miner (libsodium.wasm + 32-byte walk, nibble match, 500 ms reporting, 30 ms yield) was run at 1–8 concurrent workers, measuring aggregate throughput via delta-over-window.
- Aggregate throughput saturated at ≈2.9x the single-worker rate at 8 threads, not the 8x that linear scaling predicts.
Two effects explain the shortfall:
- Per-candidate JS wrapper work (32-byte walk, nibble match, postMessage, 500 ms reporting, 30 ms yield) that scales with wall-clock time rather than raw compute. This is the dominant limiter.
- Thermal/SMT behaviour on this particular CPU (the bare Ed25519 loop scales considerably further — ~3.3x at 8 threads — confirming the wrapper is the bottleneck, not the crypto).
| Workers | Aggregate speedup | Basis |
|---|---|---|
| 1 | 1.00x | baseline |
| 2 | 1.58x | fitted |
| 3 | 2.04x | fitted |
| 4 | 2.28x | fitted |
| 6 | 2.79x | fitted |
| 8 | 2.88x | measured |
Only the 8-thread end point was measured directly; the intermediate points come from a power-law fit anchored to that measurement:
scale(n) = n ** (log(2.88) / log(8)) # exponent ≈ 0.372
The curve is concave and capped at the measured ceiling, so it cannot predict more speedup than was actually observed. The browser estimate now folds this factor in and prints the multiplier inline instead of hedging:
workers share cores, improvement is only ~2.9x at 8 threads; scale derived from 2.88x at 8 threads
To re-measure on different hardware, update WORKER_SCALE_MEASURED and
WORKER_SCALE_EXPONENT in index.html together; nothing else needs to change.
tools/measure_scaling.py measures the shipped miner's worker-scaling curve
with delta-over-window methodology.
The multiplier is hardware-specific — 2.88x encodes this host's 4 physical / 8 logical topology and the wrapper overhead. A machine with different core counts or thermal behaviour must be re-measured rather than reusing 2.88x.
The miner runs entirely in the browser. Keys are generated, compared and stored locally; nothing about a search is transmitted.
The page makes no script requests to third parties. The one external request
is a visitor-counter badge — a plain <img> from visitorbadge.io, served as a
static SVG, with referrerpolicy="no-referrer" so the referring URL is not sent.
It is the same counter used on the author's other sites. No cookies, no
fingerprinting, no analytics, and it cannot execute. If it fails to load the page
is otherwise unaffected, which is why the browser smoke test asserts the badge's
presence and URL but deliberately does not assert that the image loads: a
flaky assertion about a third party's uptime should not be able to fail the build.
The service worker caches only same-origin files. The counter is never cached.
- Python 3.10+
PyNaClpackage
pip install -r requirements.txtRun these from the repository root (the directory containing
meshcore_vanity.py):
python meshcore_vanity.py <prefix> [options]Check you are running the right copy. If a copy of
meshcore_vanity.pyis left in a parent directory,python meshcore_vanity.pywill silently run that older copy — it produces valid-looking output but uses an older code path (for example a single worker instead of all cores), which looks like the tool "getting slower". Confirm what you are executing with:python meshcore_vanity.py --versionThis prints the version and the absolute path of the file that was actually loaded.
The encoded public key is printed to stdout. Progress and statistics go to stderr, so you can pipe the result directly:
python meshcore_vanity.py ab --encoding hex > mykey.txtBefore every search, the tool prints the expected attempt count, a locally measured keygen rate, and an ETA — and asks for confirmation on interactive terminals:
Estimate: 65,536 expected attempts (~35,062 keys/s (single-worker measurement x 1.7 for 4 workers; scale derived from 2.9x at 8 threads)) | ETA ~2s
Continue? [y/N]:
Piped/non-interactive runs skip the prompt automatically. Use -f / --force
to skip it explicitly in scripts:
python meshcore_vanity.py abcdef --encoding hex --workers 4 -fpython meshcore_vanity.py ab --encoding hexFinds a key whose hex-encoded public key starts with ab.
python meshcore_vanity.py mc1neoh --encoding bech32Finds a bech32-encoded key starting with mc1neoh.
python meshcore_vanity.py abc --encoding hex --suffixFinds a key whose hex encoding ends with abc.
python meshcore_vanity.py 0101 --encoding hex --bothFinds a key that both starts and ends with 0101.
python meshcore_vanity.py ab --encoding hex --seed 00112233445566778899aabbccddeeff00112233445566778899aabbccddeeffUsing the same seed always produces the same result. Omit --seed for random keys.
python meshcore_vanity.py ab --encoding hex --workers 4Uses 4 parallel processes to speed up the search.
python meshcore_vanity.py ab --encoding hex --output-privatePrints the private key in multiple formats to stderr:
- Base64-encoded raw private key
- MeshCore expanded private key (hex, uppercase)
- Ready-to-use
set prv.keycommand
| Option | Default | Description |
|---|---|---|
prefix (positional) |
— | Target pattern to match |
--encoding |
base64 |
Key encoding: hex, base64, base64url, base58, bech32 |
--hrp |
mc |
Human-readable part for bech32 encoding |
--case-sensitive |
off | Match prefix case-sensitively |
--suffix |
off | Match suffix instead of prefix |
--both |
off | Match both prefix and suffix |
--max-attempts |
unlimited | Stop after N attempts |
--progress-interval |
100000 | Progress report frequency |
--output-private |
off | Also output private key to stderr |
--seed |
random | 64 hex chars (32 bytes) for deterministic search |
--workers |
all CPU cores (max 256) | Number of parallel processes (forced to 1 for searches expected to finish in under a second) |
-f, --force |
off | Skip the pre-search estimate confirmation |
--version |
Print version and the loaded file path, then exit |
| Encoding | Example Output | Notes |
|---|---|---|
hex |
a1b2c3... (64 chars) |
Standard for MeshCore device import |
base64 |
q83v... (44 chars) |
Standard base64 with padding |
base64url |
q83v... (43 chars) |
URL-safe base64, no padding |
base58 |
2gV... (44 chars) |
Bitcoin alphabet |
bech32 |
mc1q... (61 chars) |
BIP-0173, MeshCore default |
Instead of hashing a seed + counter for every attempt (expensive), the tool:
- Derives an initial 256-bit scalar from the seed via SHA-256 (once)
- For each attempt, increments the scalar by 1 and uses it directly as the Ed25519 private key seed
- This avoids the SHA-256 per attempt, yielding significant speedup
With --workers N, the tool spawns N processes, each searching a disjoint subset of the scalar space. The first match wins.
Hex prefixes 00 and ff are reserved for MeshCore framework devices and are rejected by default.
- Search time grows exponentially with prefix length. A 2-char hex prefix takes ~1 second; a 6-char prefix may take hours. Use
--workersto parallelize. - No GPU acceleration. This is CPU-only. For very long prefixes, consider a GPU-based tool.
- Bech32 prefix must be compatible with HRP. Every bech32 key starts with
<hrp>1. A prefix likenewith--hrp mcwill be rejected because keys always start withmc1. - Private key is only shown with
--output-private. Without this flag, only the public key is printed. This is a safety measure. - Not a MeshCore node. This tool only generates keys. You still need MeshCore firmware or software to use them.
- Deterministic mode requires a seed. Without
--seed, each run produces different results. - Browser app is hex-only. It has no bech32/base58/base64 output; use the CLI for those encodings.
- Browser app must be served over HTTP(S). Blob Web Workers are blocked on
file://URLs. - Browser worker count is a heuristic. It uses
navigator.hardwareConcurrency - 1(capped at 16), which can over- or under-estimate on constrained or shared hardware. - Browser key history is AES-256-GCM encrypted with per-entry nonce. The key is derived via HKDF-SHA256 from an IndexedDB secret plus origin. Legacy XOR-obfuscated entries are still decrypted. Script on the origin can read IndexedDB, so this is not XSS protection. Clearing site data deletes the secret and orphans the history. History is never written in plaintext: if no key can be derived, saving is refused instead.
- Progress is not capped at 100%. Expected attempts are the mean of a geometric distribution, so ~37% of searches legitimately run past it. The CLI and browser show the overshoot as
+105.00%plus how far past the mean the search has run. Once past the mean there is no meaningful "time remaining", so the ETA is replaced by the overshoot instead of being dropped or shown negative. - The hot loop is already at the maths limit. The cost of a candidate is the Ed25519 scalar multiplication, not our code. Measured per core: Python/native libsodium ~25,000–32,000 keys/s, browser libsodium.wasm ~13,500 keys/s (
python tools/bench_mining.py,bun tools/bench_real_browser.mjs). Our wrapper adds per-candidate overhead measured at ~10M keys/s equivalent — roughly three orders of magnitude cheaper than the derivation it wraps — so optimising it further cannot help. OpenSSL (viacryptography) was measured as a separate backend and is ~20% slower than libsodium, so switching implementations is not a win either. The only throughput lever is core count. - Browser workers default to
hardwareConcurrency - 1(capped at 16), leaving one core for the UI. The hot loop uses adaptive yielding: it runs multiple batches before yielding, tracking frame budget to stay responsive without fixed timers. This avoids the Promise overhead of yielding every batch (which cost ~85% throughput with a free stub keygen). - The CLI uses every core by default.
--workersdefaults to all CPUs, but stays serial when the search is expected to finish in under a second, because creating aspawnpool costs a few tenths of a second and made short prefixes dramatically slower. Pass--workers Nto override. - Browser prefix + suffix are limited to 64 hex digits combined. A key is exactly 64 hex digits, so longer patterns would overlap and could never match; the app refuses to start such a search.
The encoded public key (the match). Pipe this to a file or another tool.
Progress messages, statistics, and (with --output-private) the private key in multiple formats.
Example:
Searching for hex public key starting with 'ab' (case-insensitive)...
attempts=100,000 rate=45,000/s elapsed=2.2s progress=38.15% eta=4s
Found in 123,456 attempts (2.75s, 44,893 keys/s)
- Never share your private key. The
--output-privateflag prints it to stderr. Redirect stderr separately if you need to capture only the public key. - Use a strong seed for deterministic mode. A predictable seed means predictable keys.
- Verify the generated key before using it in production.
- Clear the browser history when done.
Clear All Keysin the browser app wipeslocalStorage, but exported JSON/CSV files still contain private keys.
MIT