Kaspa Wallets in the Browser: Mnemonic Import Demystified
A correction to Kaspa Made Simple guide with the right flow for creating and importing wallets in the browser
If you have read Kaspa Made Simple: A Beginner’s Guide to Mastering Kaspa WASM SDK in the Browser, then I need to make a correction as it doesn’t work when trying to import a wallet with mnemonic.
When I was creating a wallet before it was not actually telling the Kaspa WASM SDK to use the same mnemonic. After a few hours of bashing my head against the wall trying out various different combinations, I finally reverted back to the amazing examples provided by Aspect to pin-point exactly what I was doing wrong. It turns out this little commented out snippet from the example was exactly what I was missing:
let account = await wallet.accountsCreate({
walletSecret,
type:"bip32",
accountName:"Account-B",
prvKeyDataId: prvKeyData.prvKeyDataId
});I was previously and incorrectly using “wallet.accountsDefault” and the wrong type “new AccoundKind(‘bip32’)”:
// This is wrong, do not use it
const account = await wallet.accountsEnsureDefault({
walletSecret: password,
prvKeyDataId: prvKey.id,
type: new AccountKind('bip32')
});So, the correct flow to create a wallet that can be imported goes like this:
Initialize the wallet with an RPC client and networkId
wallet = new Wallet({
resident: false,
networkId,
resolver: rpcClient.resolver || undefined
});Create the wallet file
const descriptor = await wallet.walletCreate({
filename,
overwriteWalletStorage: false,
title: filename,
userHint,
walletSecret: password
});Create or import the mnemonic
const mnemonicPhrase = mnemonic || Mnemonic.random(wordCount).phrase;Open the newly created wallet
await wallet.walletOpen({ filename, walletSecret: password });Insert the mnemonic
let prvKeyData = await wallet.prvKeyDataCreate({
walletSecret,
kind: "mnemonic",
mnemonic: mnemonicPhrase
});Create first account
let account = await wallet.accountsCreate({
walletSecret,
type:"bip32",
accountName:"Account-B",
prvKeyDataId: prvKeyData.prvKeyDataId
});
// Store account id in memory for easy access
accountId = account.accountDescriptor.accountId;Connect and start the wallet
await wallet.connect();
await wallet.start();Optionally, discover other addresses if you used a 3rd party wallet before importing
const results = await wallet.accountsDiscovery({
accountScanExtent: 10, // scan first 10 accounts
addressScanExtent: 50, // scan first 50 addresses per account
bip39_mnemonic: mnemonicPhrase,
discoveryKind: AccountsDiscoveryKind.BIP44
});Activate the account to enable events like balance tracking
await wallet.accountsActivate({ accountId });Optionally, store encrypted extended private key/mnemonic
const xprv = await utilities.getXPrv(mnemonicPhrase);
const xPrvString = xprv.toString();
if (storeMnemonic) {
storeWalletData({ filename, mnemonic: mnemonicPhrase, xprv: xPrvString }, password);
} else {
storeWalletData({ filename, xprv: xPrvString }, password);
}I still need to store the encrypted extended private key to derive child keypairs for Diffie-Hellman encryption use-cases and I figure since I’m doing that anyways then I may as well just offer the option to store the encrypted mnemonic for convenience.
However, it should be noted that the norm here is to generate the mnemonic and show it to the user. Then after they’ve backed it up go ahead and discard it, but that makes no sense to me to discard the mnemonic since the Kaspa WASM SDK must store the encrypted extended private key no matter what to generate new addresses. I would just pull the extended private key from Kaspa WASM SDK, but as far as I can tell it can’t be done using just the browser as it requires nodejs.
I think the Kaspa WASM SDK should offer the ability to pre-generate X amount of addresses/keypairs and then it discards the extended private key along with the mnemonic so it only holds onto a bunch of keypairs and not the master root key. Then also allow discarding of each keypair so users could have its private key removed then fund the address and know that no one can access it unless they get the mnemonic. Running some rough estimates suggests 1 million addresses only takes a few minutes time and < 100MBs of storage and that many addresses are used for automated processes like exchanges so 1 million would be more than enough for a personal user.
The way I see it: either a sophisticated attacker with nearly unlimited resources has you targeted and they’re going to get your funds one way or another. Or they’re not a state-sponsored elite tier attacker with nearly unlimited resources and they wont be able to find sufficient vulnerabilities/crack XChaCha20-Poly1305.
For a little more context:
The mnemonic is just a human‑friendly way of encoding the entropy that produces the seed. Mnemonics create the xprv.
The xprv (extended private key) is the BIP32 root derived from that seed. Xprv cannot create mnemonics.
From the xprv, you can deterministically derive every child private key, every public key, and every address in the wallet tree.
So if an attacker gets the xprv, they don’t need the mnemonic as they can already regenerate the entire wallet structure and spend funds. In that sense, xprv ≡ mnemonic for access purposes.
There’s an Easier Way
If you just want to use this Kaspa JS Wrapper I’ve created then you can simply do this to both create a new wallet and import a wallet:
init({rpcClient: client, networkId: selectedNetworkId, balanceElementId: "balanceResult"});
const result = await createWallet({
password: "password",
mnemonic: mnemonic,
filename: "created_or_imported_demo"
});Just be sure to create a Kaspa client first and send it to the init function above:
client = await connect(null, networkId.value, {
onDisconnect: () => {
status.textContent = "Disconnected";
}
});
status.textContent = "Connected";And don’t forget the imports:
import { connect } from '../wrapper/kaspa_client.js';
import { init, createWallet } from '../wrapper/wallet_service.js';So, a complete bare-bones example using the Kaspa JS Wrapper looks like:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Kaspa Wallet Demo</title>
</head>
<body>
<h1>Kaspa Wallet Demo</h1>
<button id="createWalletBtn">Create Wallet</button>
<div id="status">Disconnected</div>
<div id="balanceResult"></div>
<script type="module">
import { connect } from '../wrapper/kaspa_client.js';
import { init, createWallet } from '../wrapper/wallet_service.js';
const status = document.getElementById("status");
const balanceElementId = "balanceResult";
const selectedNetworkId = "testnet-11"; // or "mainnet"
let client;
document.getElementById("createWalletBtn").addEventListener("click", async () => {
// Initialize wallet service
init({
rpcClient: client,
networkId: selectedNetworkId,
balanceElementId
});
// Create or import wallet
const result = await createWallet({
password: "password",
mnemonic: null, // or supply a mnemonic string
filename: "created_or_imported_demo"
});
console.log("Wallet created/imported:", result);
// Connect to Kaspa node
client = await connect(null, selectedNetworkId, {
onDisconnect: () => {
status.textContent = "Disconnected";
}
});
status.textContent = "Connected";
});
</script>
</body>
</html>

Really valuable catch on the accountsCreate vs accountsDefault issue. I ran into smiliar wallet recovery headaches a whle back and the difference between passing prvKeyDataId during account creation versus trying to backfill it later is subtle but critical. Reminds me the Kaspa SDK docs could be alot clearer about this initialization sequence, especially since most devs coming from Ethereum are used to just importing a private key and calling it done.