A browser-based HD wallet demo for Ethereum and Solana. One seed phrase, multiple accounts on both chains — all keys derived and stored locally, nothing ever leaves your browser.
npm install
npm run dev
# → http://localhost:3000Generate a 12-word seed phrase → stretch it into a 512-bit master seed → derive ETH and SOL accounts from that seed using standardized paths. That's the whole flow. The sections below go deeper on each step.
The wallet generates 128 bits of random entropy using window.crypto.getRandomValues(), then converts it to a human-readable 12-word mnemonic. The process:
- Take 128 random bits, hash them with SHA-256, append the first 4 bits as a checksum → 132 bits total
- Split into 12 groups of 11 bits each
- Map each group to a word from the BIP-39 wordlist (2048 words)
That 12-word phrase encodes 2¹²⁸ possible wallets — effectively impossible to brute force.
The mnemonic isn't used for derivation directly. It first gets stretched into a 512-bit seed via PBKDF2-HMAC-SHA512 (2048 rounds, salt = "mnemonic"). This is the actual root from which all keys are derived.
Ethereum follows BIP-44, which defines a 5-level derivation path:
m / purpose' / coin_type' / account' / change / index
m / 44' / 60' / 0' / 0 / n
44'— BIP-44 standard60'— Ethereum's registered coin type (SLIP-44)0'— first account group0— external/receiving addressesn— individual account index (0, 1, 2…)
Under the hood, each derivation step runs HMAC-SHA512 on the parent's chain code + key material, splits the 64-byte output in half, and tweaks the left half into a new private key via modular addition on the secp256k1 curve. The right half becomes the next chain code.
Address derivation from a private key:
private key → secp256k1 multiply → public key (64 bytes) → keccak256 → last 20 bytes → EIP-55 checksum
EIP-55 checksums encode error detection into the address itself by using mixed casing based on the hash of the lowercase address.
ethers v6 handles all of this:
ethers.HDNodeWallet.fromMnemonic(
ethers.Mnemonic.fromPhrase(mnemonic),
`m/44'/60'/0'/0/${i}`
)Here's where it gets interesting. BIP-32 was designed for the secp256k1 curve and can't be used directly with Solana's Ed25519 curve — the math doesn't transfer cleanly. SLIP-0010 fixes this.
The key differences from BIP-32:
- The master seed label changes from
"Bitcoin seed"to"ed25519 seed" - Child key derivation skips the modular addition step —
I_Lbecomes the private key directly - All path segments must be hardened (no non-hardened derivation possible with Ed25519)
Solana's path:
m / 44' / 501' / n' / 0'
Notice every segment has ' — that's not optional, it's a requirement of the curve. The account index n moves to the third level (unlike ETH where it's the last).
The full SLIP-0010 derivation runs entirely in-browser using @noble/hashes:
// Master key
let I = hmac(sha512, "ed25519 seed", seed)
let kL = I[0:32] // private key
let kR = I[32:64] // chain code
// Each path segment (all hardened)
data = 0x00 + kL + ser32(index | 0x80000000)
I = hmac(sha512, kR, data)
kL = I[0:32] // new private key — used as-is, no tweaking
kR = I[32:64] // new chain codeThe final private key is passed to ed25519.getPublicKey() which does scalar multiplication on the curve, producing a 32-byte compressed point. That point is Base58-encoded to get the familiar Solana address format.
Both are elliptic curves used for signing, but they're meaningfully different:
| secp256k1 (ETH) | Ed25519 (SOL) | |
|---|---|---|
| Type | Weierstrass | Twisted Edwards |
| Signatures | ECDSA | EdDSA |
| Public key size | 33 or 65 bytes | 32 bytes (always) |
| Signature size | ~71 bytes | 64 bytes (always) |
| Deterministic | Optional (RFC 6979) | Always |
| Malleable | Yes (historically) | No |
Ed25519 is faster, produces smaller fixed-size signatures, and is deterministic by design — the same message + key always produces the same signature, removing a whole class of bugs.
A quick note since it comes up a lot: the ' in a path means hardened. The difference matters:
- Non-hardened (
/0/1/2) — uses the parent public key in the HMAC. Enables child public key derivation without the private key (useful for watch-only wallets), but if a child private key leaks alongside the chain code, the parent private key can be reconstructed. - Hardened (
/0'/1'/2') — uses the parent private key. No derivation is possible without it, completely isolating the branch.
This is why BIP-44 hardens purpose, coin_type, and account but keeps address_index non-hardened for Ethereum — it allows xpub-based account scanning without exposing the root. Solana can't offer that option at all given Ed25519's constraints.
| Package | Why |
|---|---|
bip39 |
BIP-39 mnemonic generation + PBKDF2 seed stretching |
ethers v6 |
BIP-32/44 HD tree for Ethereum, battle-tested |
@noble/hashes |
Browser-native HMAC-SHA512 for SLIP-0010 |
@noble/curves |
Ed25519 scalar multiplication for Solana public keys |
bs58 |
Base58 encoding for Solana addresses |
@solana/web3.js was intentionally skipped — it pulls in Node.js crypto internals that require webpack polyfills in Next.js. The @noble libraries are purpose-built for browsers, audited, and give us exactly the two primitives we need with no overhead.
All imports are dynamic (await import(...)) so they only run client-side and don't break Next.js SSR.
Below are some references which you can read and understand how things like derivations paths, seed generation, etc. works.