Guide

Generating Wallets

One call for keys plus address. Import a key you already have. Walk a mnemonic down a path with a broken checksum if you must.

What a wallet is here

Keys plus the address they map to. Nothing else, no storage, no balance, no transactions.

ts
interface Wallet extends Keys {
  address: string;
  warnings?: readonly string[];
}

warnings only shows up when you asked for a derivation a stricter library would refuse. More on that below.

New wallet

js
import { useBlockchain } from "@agntn/keys";
import Bitcoin from "@agntn/keys/blockchains/bitcoin";

const chain = useBlockchain(new Bitcoin());
const wallet = chain.generateWallet();

wallet.keys.private;
wallet.keys.public;
wallet.address;

Two optional arguments: key options first, address type second. The key options go to generateKeys, the address type to getAddress.

js
chain.generateWallet({ compressed: false }); // uncompressed public key, legacy address
chain.generateWallet({}, "p2sh"); // starts with 3
chain.generateWallet({}, "taproot"); // starts with bc1p

That empty object in the middle is annoying but honest: the address type is the second parameter and there's no overload that lets you skip the first.

Ask for something the chain can't do and it throws instead of handing you a wallet with a wrong label. Solana, Ethereum and the other chains with one address format refuse any address type. compressed throws where it changes nothing. ed25519 keys have one form. Ethereum and TRON hash the uncompressed key anyway, so true throws there, and Sui secp256k1 writes only the compressed one, so false does.

A key you already have

js
const privateKey = "7f9e5b9e3bbed34a4c28c8c1665525fc2cd7afb4fdc7edca3eb93ddf8a31ef56";
const wallet = chain.deriveWallet(privateKey);
const segwitWallet = chain.deriveWallet(privateKey, {}, "segwit");

Same arguments as generateWallet, minus the randomness. Got a WIF instead of hex? decode from @agntn/keys/wif turns it into the hex this call wants, compression flag included. More on the keys page.

From a mnemonic

deriveHDWallet walks a BIP39 mnemonic down a derivation path and returns the wallet at the end:

js
const mnemonic =
  "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about";

chain.deriveHDWallet(mnemonic, "m/84'/0'/0'/0/0").address; // bc1q...
chain.deriveHDWallet(mnemonic, "m/44'/0'/0'/0/0", { passphrase: "TREZOR" }, "p2sh");
chain.deriveHDWallet(mnemonic, "m/84h/0h/0h/0/0").address; // same bc1q... as the first line

A hardened level takes ' or h. BIP380 descriptors write h, so the origin in [d34db33f/84h/0h/0h] goes in as it is: m instead of the fingerprint, plus the /0/0 you want below it. No hunting down every h to swap for a quote. Capital H gets refused, BIP380 lists it as invalid and so do we.

Italian phrase? Japanese? Load the list and pass it along:

js
import { loadWordlist } from "@agntn/keys/bip39";

const wordlist = await loadWordlist("italian");
const italian = "abaco abaco abaco abaco abaco abaco abaco abaco abaco abaco abaco abete";

chain.deriveHDWallet(italian, "m/44'/0'/0'/0/0", { wordlist }).address; // 16sisK5QAu6e1GHBLLEmZAHGQ9uj9He8SY

No guessing here. The list is the one you pass, English if you pass nothing. Each list loads on its own, so nobody downloads ten lists to read one.

Got the entropy but not the words? The four short helpers take the list too:

js
import { entropyToMnemonic, loadWordlist } from "@agntn/keys/bip39";

const french = await loadWordlist("french");
const entropy = Uint8Array.fromHex("199e96675bf360b47cd99136be09eec3");

entropyToMnemonic(entropy); // "book visa oil retire current focus vibrant cram cute utility knife make"
entropyToMnemonic(entropy, french);
// "banquier usure mensonge peuple combat espoir union citoyen conduire triomphe horizon jaune"

Same sixteen bytes, two phrases. Only the French one hides in a mural in Paris. generateMnemonic, validateMnemonic and mnemonicToEntropy take the list in the same slot, right after their usual argument.

Things that will bite you:

  • Every word has to be on the list, 12, 15, 18, 21 or 24 of them, and the checksum has to hold. A typo in word eleven fails before any derivation happens. The checksum part is negotiable, see the next section. So is the count, for Ian Coleman's phrases.
  • secp256k1 chains derive with BIP32, ed25519 chains with SLIP-10. SLIP-10 only has hardened children, so Solana, Stellar, Aptos, NEAR, and Sui on ed25519 need every segment hardened: m/44'/501'/0'/0'. chain.getDerivationPath(account) writes the path that chain's wallets use, hardened where it has to be and no deeper than the chain goes, so you don't have to remember which one stops where.
  • Bitcoin, Bitcoin Gold and Litecoin read the purpose when you don't pass an address type. m/44' gives legacy, m/49' p2sh, m/84' segwit, m/86' taproot. An explicit type wins. Any other purpose falls back to legacy, and the wallet's addressType says which one you got. Bitcoin Gold throws on m/86', since it has no taproot. Bitcoin Cash and eCash have only P2PKH, so they give CashAddr on any purpose. Bitcoin SV gives its 1 address on any purpose too, Dash its X address, Dogecoin its D address and Zcash its t1 address.
  • Polkadot doesn't walk BIP32 at all. It derives from the entropy, then //hard junctions like //polkadot//0, the way subkey does. Its page has the junction rules.
  • Monero throws. Its wallets restore from their own 25 words, not BIP39, so deriveSeedWallet takes those instead. Its page has the word rules.
  • Cardano throws. CIP-1852 starts from the entropy, not from the BIP39 seed, and pretending otherwise would hand you addresses that no Cardano wallet recognises.
  • Decred throws too. Its HD derivation strips leading zero bytes where BIP32 keeps them, so a plain BIP32 walk lands on the wrong key for some paths and looks fine doing it. deriveElectrumWallet throws with it. deriveWallet with a private key works on both.

The mnemonic above is the public BIP39 test vector. It's fine for docs and tests and for nothing else.

Puzzle mnemonics

A puzzle phrase with a broken checksum isn't a wrong answer, it's Tuesday. Without a flag deriveHDWallet throws on it. With the flag you get the wallet and a warning, the words exactly as given, nothing repaired:

js
const puzzle =
  "path mad alien apology escape spare miss goddess leopard crime visit clock start first blade guard close barrel term screen matrix toy ghost shine";

const wallet = chain.deriveHDWallet(puzzle, "m/84'/0'/0'/0/0", { allowInvalidChecksum: true });

wallet.address; // bc1q94ecsn0qk8lap2gefrycnms3ruepy889z969a6
wallet.warnings;
// [ 'BIP39 checksum is invalid. Derived from the supplied words without repairing the checksum.' ]

The flag only relaxes the checksum. Word count and dictionary are still enforced, whitespace still collapses and NFKD still applies, so it isn't a back door for arbitrary text. Valid phrases give the same result with or without it.

To see which check failed, ask before deriving:

js
import { inspect, getMnemonicWordCandidates } from "@agntn/keys/bip39";

inspect(puzzle);
// { valid: false, words: 24, wordCountValid: true, wordlistValid: true, checksumValid: false }

getMnemonicWordCandidates(puzzle.replace(/shine$/, "?"));
// [ 'aware', 'divide', 'embark', 'globe', 'pact', 'roof', 'solve', 'today' ]

inspect splits the verdict three ways and never echoes the phrase. checksumValid is null when the count or the dictionary already failed, because there's nothing to check yet. getMnemonicWordCandidates takes a phrase with exactly one ? and returns every word of the list that makes the checksum pass, in list order. shine isn't one of the eight, and each of the eight opens a different wallet, so the library won't pick one for you. That's how a "fixed" mnemonic loses a puzzle.

Every helper here takes any of the ten lists. Missing word in a Spanish phrase? getMnemonicWordCandidates(template, await loadWordlist("spanish")). inspect takes the list as its second argument too. The flag goes next to wordlist, skips that list's checksum and still wants every word on it. A typo beside the ? gets refused by its position. Zero candidates would look like a verdict, and a wrong one. The MCP and Pi tools expose the same flag and diagnostics, with language naming the list.

Three words from Ian Coleman's tool?

Ian Coleman's BIP39 page will happily write three words. Or six, or forty-eight. It spells raw entropy as 3 words per 32 bits. Checksum included. BIP39 stops at 12 to 24, so strict mode calls those phrases broken. They aren't broken. They just aren't BIP39.

coleman: true reads them the way that page does:

js
import { inspect, mnemonicToEntropy } from "@agntn/keys/bip39";

const tiny = "abandon abandon ability";

inspect(tiny);
// { valid: false, words: 3, wordCountValid: false, wordlistValid: true, checksumValid: null }

inspect(tiny, undefined, { coleman: true });
// { valid: true, words: 3, wordCountValid: true, wordlistValid: true, checksumValid: true, standardLength: false }

mnemonicToEntropy(tiny, undefined, { coleman: true });
// Uint8Array(4) [ 0, 0, 0, 0 ]

chain.deriveHDWallet(tiny, "m/44'/0'/0'/0/0", { coleman: true }).warnings;
// [ "Not a BIP39 length. Derived the way Ian Coleman's tool reads it; wallet apps will refuse these words." ]

Any multiple of three from 3 to 768 passes the count. Why stop there? Past 768 words the checksum wants more bits than SHA-256 has. Coleman's page knows. Feed it 1028 bytes and it writes 771 words its own check refuses.

The checksum still has to hold, and allowInvalidChecksum still relaxes only that. standardLength says whether BIP39 itself would take the phrase. The seed is plain PBKDF2 at 2048 rounds. The three words above give 20da140d…aa0a58f3, same as Coleman's page. Polkadot says no, because subkey doesn't read these lengths either.

Thirty-two bits is about four billion phrases. That's a puzzle, not a vault. Agents pass the same coleman to keys_bip39_inspect, keys_bip39_entropy_encode, keys_bip39_seed_derive and keys_hd_wallet_derive.

Half a word, or the right words in the wrong order

Only four letters on the plate?

Steel plates often keep four letters per word. That's safe by design: no two English words share their first four. lookupPrefixes turns them back into words:

js
import { lookupPrefixes } from "@agntn/keys/bip39";

for (const { prefix, matches } of await lookupPrefixes(["abou", "acc", "act"]))
  console.log(prefix, matches.map((match) => match.word));
// abou [ 'about' ]
// acc [ 'access', 'accident', 'account', 'accuse' ]
// act [ 'act' ]

Three letters can be ambiguous, so you get every match. act is a whole word, so it stays act and never becomes action. Two letters match nothing. Agents don't need a flag: keys_bip39_words_lookup reads any missing word of three or more letters as a prefix.

Copied from a photo?

Photos and handwriting lie a little. A winnr, a yelow, an rn that was an m. Words off the list are easy to spot. repairWords tries nearby list words and keeps what the checksum accepts:

js
import { repairWords } from "@agntn/keys/bip39";

const written = "legal winnr thank year wave sausage worth useful legal winner thank yelow";

for (const { words, distance } of repairWords(written).slice(0, 2))
  console.log(distance, words.join(" "));
// 2 legal winner thank year wave sausage worth useful legal winner thank yellow
// 3 legal minor thank year wave sausage worth useful legal winner thank yellow

Nine words sit within two edits of winnr, six of yelow. That's 54 phrases, and 7 pass. Fewest edits come first, and here that's the test vector. A typo is one edit. So are two swapped letters, and rn for m, vv for w or cl for d. suggestWords stops before the checksum and lists the candidates per word.

What slips through? A wrong word that's still a real word. wing for wink looks fine, so it stays as written. No phrase opens the wallet? Swap the suspect for ? and ask getMnemonicWordCandidates.

Agents get keys_bip39_words_repair. Two words off the list per call, the combinations counted before the search, closest phrases first.

One word per clue?

Some puzzles hand out the words one at a time. One per riddle, one per photo. You end up with all twelve and no idea where half of them go. Put the ones you're sure of in a template and mark the rest with ?. orderWords tries every order of the loose ones:

js
import { blockchains } from "@agntn/keys";
import { countWordOrders, orderWords } from "@agntn/keys/bip39";

const chain = await blockchains.bitcoin()();
const target = "bc1qgkju4yvvtuz0s8vqn837q396jezu2h8ex7gk98";
const template = "legal winner thank year wave sausage ? ? ? ? ? ?";
const words = ["yellow", "thank", "winner", "legal", "useful", "worth"];

countWordOrders(words, { template }); // 720n

for (const order of orderWords(words, { template })) {
  const phrase = order.join(" ");
  if (chain.deriveHDWallet(phrase, "m/84'/0'/0'/0/0").address === target) {
    console.log(phrase);
    break;
  }
}
// legal winner thank year wave sausage worth useful legal winner thank yellow

That's the public BIP39 test vector again. Only 51 of the 720 orders pass the checksum, so the loop derives 51 wallets at most. Here it hit on the 37th. The checksum filter is cheap. Deriving is not, every candidate pays for a full PBKDF2 run.

Twelve open words is 479 million orders. Bring snacks. Better yet, pin every position you can before you start. onProgress gets the count every 65,536 orders, and a break stops the search. A word that shows up twice goes in twice, and each order still comes out once. wordlist picks the list, same as everywhere else.

Agents get keys_bip39_words_order with the same template. It says up front how many orders it checked and how many passed, then lists the first ones. A million orders is the cap per call, enough for nine open spots. Deriving the survivors is your job.

Which path was it?

Right words, no coins. Usually that's the path, not the phrase. BIP44 or BIP84? Account 0 or 2? Electrum? By hand that's a dozen calls per candidate. And the one path you skip is the one that hits.

Agents get keys_hd_wallet_scan. A chain, the phrase, the address you expect:

text
Chain: bitcoin (mainnet)
Match: bip44, 1 address checked
Path: m/44'/0'/0'/0/0
Address type: legacy
Public key: 0312b422a56647895f549b81db0f36a964479696ec86b374be8b353167257825dd
Address: 1HbUcHKfpkUSssNtcfS3vKzdTMue3EByMQ

That's a solved quizchain block. First try, too.

The list is fixed and every entry has a name. Bitcoin walks bip44, bip49, bip84 and bip86. Ethereum adds ledger-live and ledger-legacy. eCash adds cashtab, Bitcoin SV electrumsv. A Bitcoin phrase that's really an Electrum seed gets its own paths, old seeds included. Each scheme walks three accounts and twenty indices, receiving and change. accounts and indices go wider.

No hit? Then you get the whole list:

text
Match: none, 480 addresses checked
Tried: bip44 m/44'/0'/{0..2}'/{0,1}/{0..19} legacy
Tried: bip49 m/49'/0'/{0..2}'/{0,1}/{0..19} p2sh
Tried: bip84 m/84'/0'/{0..2}'/{0,1}/{0..19} segwit
Tried: bip86 m/86'/0'/{0..2}'/{0,1}/{0..19} taproot
Skipped: electrum. Not an Electrum seed

A miss rules out those paths. Not the phrase. Nothing outside the list gets tried, so "not found" always says what it covers. On testnet the UTXO chains walk coin type 1 first, the one testnet wallets and getDerivationPath write, as bip84-testnet and friends. Then the mainnet coin type, for wallets that never switched.

The answer is public: path, key, address. The phrase still lands in the transcript, so public puzzles only.

Watching from an xpub

An account's extended public key is enough to list its addresses. deriveXpubWallet walks the normal levels under it, m/0/i for receiving and m/1/i for change:

js
import Bitcoin from "@agntn/keys/blockchains/bitcoin";

const zpub =
  "zpub6rFR7y4Q2AijBEqTUquhVz398htDFrtymD9xYYfG1m4wAcvPhXNfE3EfH1r1ADqtfSdVCToUG868RvUUkgDKf31mGDtKsAYz2oz2AGutZYs";
new Bitcoin().deriveXpubWallet(zpub, "m/0/0");
// { keys: { public: '0330d5…af3c' }, address: 'bc1qcr8te4kr609gcawutmrza0j4xv80jy8z306fyu', prefix: 'zpub', addressType: 'segwit' }

That's SLIP-0132's zpub for abandon … about, and the address is the one m/84'/0'/0'/0/0 gives from the mnemonic. The prefix picks the type on Bitcoin, Bitcoin Gold and Litecoin: xpub is legacy, ypub P2SH, zpub segwit, and tpub, upub, vpub the same on testnet. Litecoin also reads its own Ltub and Mtub. Every other secp256k1 chain takes xpub, or tpub on testnet.

Don't trust the prefix blindly. Plenty of wallets export a BIP84 account as a plain xpub, so its prefix says legacy while the coins sit on segwit. Pass the type as the third argument and it wins, like it does over a path purpose in deriveHDWallet.

It never touches a secret. An xprv is refused, and so is a hardened level, since walking one needs the private key. The ed25519 chains throw, since SLIP-10 has no public derivation, and so do Sui and the XRP Ledger, even on secp256k1. Decred throws like it does on deriveHDWallet. MCP and Pi call it keys_xpub_wallet_derive. Keep in mind the key itself reveals every address of the account to whoever reads the transcript.

One leaked child opens the whole account

An xpub and the private key of one normal child below it? That's the parent private key. BIP32 gets each normal child by adding a tweak to the parent key. The tweak comes from the xpub alone, so anyone can subtract it again. Every sibling follows. Classic CTF setup, and recoverParent from @agntn/keys/bip32 does the math:

js
import { recoverParent } from "@agntn/keys/bip32";

const xpub =
  "xpub661MyMwAqRbcFW31YEwpkMuc5THy2PSt5bDMsktWQcFF8syAmRUapSCGu8ED9W6oDMSgv6Zz8idoc4a6mr8BDzTJY47LJhkJ8UB7WEGuduB";
const child =
  "xprv9vHkqa6EV4sPZHYqZznhT2NPtPCjKuDKGY38FBWLvgaDx45zo9WQRUT3dKYnjwih2yJD9mkrocEZXo1ex8G81dwSM1fwqWpWkeS3v86pgKt";
const { parent, index } = recoverParent(xpub, child);
parent.privateExtendedKey;
// 'xprv9s21ZrQH143K31xYSDQpPDxsXRTUcvj2iNHm5NUtrGiGG5e2DtALGdso3pGz6ssrdK4PFmM8NSpSBHNqPqm55Qn3LqFtT2emdEXVYsCzC2U'
index; // 0

That's BIP32 test vector 2, its master and m/0. An xprv carries its own index. A bare 32 byte key needs the index as the third argument. The private prefix follows the xpub, so a zpub gives a zprv and an Ltub an Ltpv.

Hardened children are safe. Their tweak hashes the parent private key, and there's nothing to subtract without it. So they're refused, and so is an index the xprv contradicts. A child from some other wallet throws BIP32ChildMismatchError with the index it checked. Want it from an agent? keys_bip32_parent_recover takes the child as an xprv, a WIF or hex. It answers with the parent fingerprint. The parent xprv comes back only with revealKey: true.

Electrum is a different scheme

allowInvalidChecksum doesn't turn BIP39 into Electrum. Electrum seeds have their own version prefix and their own PBKDF2. deriveSeed from @agntn/keys/electrum reads the phrase. The chain walks the seed:

js
import { deriveSeed } from "@agntn/keys/electrum";
import Bitcoin from "@agntn/keys/blockchains/bitcoin";

const phrase = "wild father tree among universe such mobile favorite target dynamic credit identify";
const wallet = new Bitcoin().deriveElectrumWallet(deriveSeed(phrase), "m/0'/0/0");
wallet.address; // bc1q4794m2uuw9jmjszmplfj4wvvr5j272fpnx2cse
wallet.addressType; // "segwit"

That's a public Electrum test vector. A SegWit seed writes P2WPKH, a standard one P2PKH, same as Electrum. An explicit address type in the fourth slot still wins. Electrum keeps SegWit addresses under m/0'/0/i and standard ones under m/0/i. Still, pass the exact path of the wallet you're importing.

Why a method on the chain instead of BIP32 glue? The glue skips the chain's rules. It happily handed Decred a key that deriveHDWallet refuses. Now Decred refuses an Electrum seed for the same reason. The ed25519 chains throw, since Electrum walks secp256k1 only. Sui walks it on secp256k1, the XRP Ledger refuses its ed25519 side.

And why not hand the phrase straight to the chain? Telling an old seed apart takes Electrum's 1626-word list. Every chain would drag it along. Even an app that only verifies BIP322 signatures. So the phrase stays with deriveSeed. Hand the method anything else and it throws a TypeError.

deriveSeed returns scheme, seedType and a 64-byte seed. That seed is no master private key. inspect names the version without deriving anything. It also recognizes old, 2fa and 2fa_segwit. deriveSeed rejects all three, along with unknown. Old seeds get their own functions, see below.

The optional second argument of deriveSeed is an Electrum passphrase. Both inputs undergo NFKD, lowercasing, removal of nonzero combining classes, whitespace collapsing and removal of spaces between Electrum's CJK characters. Unlike BIP39, passphrase case, accents and surrounding spaces don't distinguish wallets here. Unicode normalization uses the JavaScript runtime's Unicode tables, so newly assigned characters can differ from older Python/Electrum runtimes.

What about seeds from before Electrum 2.0?

Old seeds have no version prefix. They have no BIP32 either. Electrum 1.x decoded twelve or twenty-four words from its own 1626-word list into hex. Then it hashed that hex, as text, 100,000 times. That's the master private key. Each address adds SHA256d("index:change:" + MPK) times G to the master public key. No path, no hardened levels. Just a chain and an index.

js
import { deriveOldMasterPublicKey, deriveOldPublicKey } from "@agntn/keys/electrum";
import Bitcoin from "@agntn/keys/blockchains/bitcoin";

const phrase = "powerful random nobody notice nothing important anyway look away hidden message over";
const mpk = deriveOldMasterPublicKey(phrase);
const publicKey = Buffer.from(deriveOldPublicKey(mpk, 0, 0)).toString("hex");
const address = new Bitcoin().getAddress(publicKey, "legacy");

That's the vector from Electrum's own tests, and it gives 1FJEEB8ihPMbzs2SkLmr37dHyRFzakqUmo. Change 0 is receiving, 1 is change. The keys come out uncompressed, like every address Electrum made back then. Hash the compressed form instead and you get a valid address that holds nothing. Electrum also took the seed as 32 or 64 hex digits, and so does this. acb740e454c3134901d7c8f16497cc1c is the same wallet. Spaced hex isn't. Electrum recognizes it and then fails to open it, so deriveOldMasterPublicKey throws.

Why bother with the MPK? It's what an old watching wallet stores. Match its 128 hex digits and you know the seed is right before you check a single address.

MCP and Pi expose keys_electrum_wallet_derive for every seed above. A standard or SegWit seed takes the exact path. An old seed takes change and index instead, both 0 by default, and the answer adds the master public key. Standard and old seeds get P2PKH, SegWit gets P2WPKH. Only public wallet material comes back, plus the scheme and seed type. The tool doesn't search paths, repair phrases or check balances. Use only public or disposable inputs. Real wallet secrets don't belong in an agent transcript.

Salted brainwallets

A brainwallet is a key you remember instead of storing it. The old ones were plain sha256(passphrase), cheap to guess by the million. So later sites stretched the passphrase first. brainwallet.io runs scrypt over the passphrase and a salt, then hashes the result once more. Which result, though? The raw bytes, or their hex as text? Pick wrong and you get a perfectly valid key to somebody else's empty address.

So derive from @agntn/keys/brainwallet makes you name every step:

js
import { derive } from "@agntn/keys/brainwallet";
import Bitcoin from "@agntn/keys/blockchains/bitcoin";

const key = derive("example passphrase", {
  kdf: "scrypt",
  N: 1024,
  r: 8,
  p: 1,
  salt: "example salt",
  hashed: "hex",
});
const bitcoin = new Bitcoin();
const wallet = bitcoin.deriveWallet(key.toHex(), { compressed: false }, "legacy");
wallet.address;

That's 12j4woaXhDT5YfdmHSaC8hDUG4X8di7JAM. Switch hashed to "bytes" and it's 1J9JW57dqgrH6TGuXRnLp8BKSP3QWLjt72. Same passphrase, same salt, different coins. brainwallet.io itself uses N: 262144 and an uncompressed key, which costs most of a second and 256 MiB of memory.

kdf: "pbkdf2" works too. It takes iterations and a digest, sha256 or sha512. keyLength sets the KDF output, 32 bytes unless you say otherwise. The passphrase goes in as UTF-8, exactly as typed. Nothing gets trimmed, so a trailing space is a different wallet.

And the plain ones?

Old, but puzzles still use them. brainwallet.org took one SHA-256 of the passphrase and called it a key. No salt, no KDF, nothing to tune. derive takes that too:

js
import { derive } from "@agntn/keys/brainwallet";
import Bitcoin from "@agntn/keys/blockchains/bitcoin";

const key = derive("correct horse battery staple", { kdf: "sha256" });
const bitcoin = new Bitcoin();
const wallet = bitcoin.deriveWallet(key.toHex(), { compressed: false }, "legacy");
wallet.address;

That's 1JwSSubhmg6iPtRjtyqhUYYH7bZg3Lfy1T. Yes, the xkcd password. Hashed more than once? iterations: 3 runs SHA-256 three times, each round over the previous 32 bytes. Ethereum brainwallets went with kdf: "keccak256". Hand that key to Ethereum instead of Bitcoin and you get the 0x address.

What about WarpWallet?

WarpWallet came from Keybase, and it didn't trust one KDF. So it runs two. scrypt reads the passphrase and the salt, each with a 0x01 byte on the end. PBKDF2-HMAC-SHA256 reads them with 0x02. XOR the two outputs and that's the key. No SHA-256 after it. The costs are baked in, so derive only wants the salt:

js
import { derive } from "@agntn/keys/brainwallet";
import Bitcoin from "@agntn/keys/blockchains/bitcoin";

const key = derive("Je", { kdf: "warpwallet", salt: "" });
const bitcoin = new Bitcoin();
const wallet = bitcoin.deriveWallet(key.toHex(), { compressed: false }, "legacy");
wallet.address;

That's 1JKb1617p68H5MPkoNaMtaJCqKDU3h8qSn, WarpWallet challenge 1. Two characters guarded 0.1 BTC. It lasted under eight hours. The salt was meant to be your email, and empty works too. Each call takes about a second and 256 MiB.

MCP and Pi call it keys_brainwallet_derive, and there the key never comes back. You get the public key, the address and, given a target, a match or no match. Bitcoin P2PKH by default, or chain: "ethereum". Nice for checking a public puzzle without a private key in the transcript. The passphrase is still in it, so keep real ones out.

Security, again

The library doesn't store keys or forget them. encrypt from @agntn/keys/store locks one in a keystore file. Keeping that file safe is your job. Whatever calls it holds the private key in memory as a plain string, and so does every log line that prints a wallet object. Generate throwaway keys for tests, keep real ones on hardware, and be twice as careful in a browser tab where an extension can read the page.

The whole method

This is all generateWallet is:

ts
generateWallet(options?: KeyOptions, addressType?: AddressType): Wallet {
  this.checkCompressed(options);
  const keys = this.generateKeys(options);
  const type = addressType ?? this.defaultAddressType;

  return {
    ...keys,
    address: this.getAddress(keys.keys.public, type),
    ...(type === undefined ? {} : { addressType: type }),
  };
}

It lives on AbstractBlockchain, so every chain, including one you write yourself, gets it for free. Override the defaultAddressType getter if your chain writes more than one format, and the wallet will say which one it picked.