Save and load a keystore (encrypted JSON)
The keystore is the safe at-rest wallet format: a JSON file whose key material is encrypted with your password (scrypt + AES-128-CTR), so the key never touches disk in plaintext. This recipe saves and reloads both keystore kinds, a single secret key and a full mnemonic, fully offline. The only I/O is to a throwaway temp directory it cleans up.
Prerequisites
- Node.js >= 20.19.0.
- Nothing else. This recipe generates its own throwaway keys and writes to a temp directory it cleans up.
Install
mkdir keystore-save-load
cd keystore-save-load
# Create the project files shown on this page.
npm install
npm run build
npm start
Complete project files omitted from the main walkthrough
{
"name": "cookbook-recipe-keystore-save-load",
"version": "1.0.0",
"private": true,
"description": "Cookbook recipe — save a wallet to an encrypted keystore (JSON) and load it back with a password. Fully offline: local file I/O only, no network.",
"scripts": {
"build": "tsc",
"start": "node dist/index.js",
"typecheck": "tsc --noEmit --strict"
},
"dependencies": {
"@multiversx/sdk-core": "15.4.1",
"bignumber.js": "9.3.1",
"protobufjs": "7.6.5"
},
"devDependencies": {
"@types/node": "20.19.43",
"typescript": "5.9.3"
},
"engines": {
"node": ">=20.19.0"
}
}
{
"compilerOptions": {
"target": "ES2022",
"lib": ["esnext"],
"module": "commonjs",
"moduleResolution": "node",
"outDir": "dist",
"rootDir": "src",
"skipLibCheck": true,
"strict": true,
"noImplicitAny": true,
"strictNullChecks": true,
"noUncheckedIndexedAccess": true,
"exactOptionalPropertyTypes": true,
"noImplicitReturns": true,
"noFallthroughCasesInSwitch": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"forceConsistentCasingInFileNames": true,
"esModuleInterop": true,
"resolveJsonModule": true,
"types": ["node"]
},
"include": ["src/**/*.ts"],
"exclude": ["node_modules", "dist"]
}
The code
// src/index.ts — save a wallet to an encrypted keystore (JSON) and load it
// back, for both keystore kinds: secret-key and mnemonic. Prove the password
// is really required, and show how addressIndex selects an account from a
// mnemonic keystore.
//
// Fully offline. The only I/O is to a throwaway temp directory (cleaned up
// at the end); no devnet, no network. Fresh keys each run, so exact
// addresses differ; the equivalences printed do not.
//
// Grounded in the installed @multiversx/sdk-core v15.4.1: UserWallet
// (wallet/userWallet.d.ts) and Account.newFromKeystore (accounts/account.d.ts).
import { Account, Mnemonic, UserWallet } from '@multiversx/sdk-core';
import { mkdtempSync, readFileSync, rmSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
const PASSWORD = 'correct horse battery staple';
function main(): void {
const dir = mkdtempSync(join(tmpdir(), 'mvx-keystore-'));
try {
// === 1. Secret-key keystore: encrypt one key. ===
const mnemonic = Mnemonic.generate();
const secretKey = mnemonic.deriveKey(0);
const expectedAddress = secretKey.generatePublicKey().toAddress().toBech32();
const wallet = UserWallet.fromSecretKey({ secretKey, password: PASSWORD });
const keystorePath = join(dir, 'wallet.json');
wallet.save(keystorePath);
// The file on disk is encrypted JSON — never plaintext key material.
const onDisk = JSON.parse(readFileSync(keystorePath, 'utf8')) as {
version: number;
kind: string;
crypto: { cipher: string; kdf: string };
};
console.log(
`1. Saved keystore: version ${onDisk.version}, kind "${onDisk.kind}", cipher ${onDisk.crypto.cipher}, kdf ${onDisk.crypto.kdf}.`,
);
// === 2. Load it back with the password. ===
// Account.newFromKeystore handles both keystore kinds and is synchronous.
const loaded = Account.newFromKeystore(keystorePath, PASSWORD);
console.log(`2. Loaded address matches the original: ${loaded.address.toBech32() === expectedAddress}`);
// UserWallet.loadSecretKey returns the raw key instead of an Account.
const loadedKey = UserWallet.loadSecretKey(keystorePath, PASSWORD);
console.log(` UserWallet.loadSecretKey recovered the same key: ${loadedKey.hex() === secretKey.hex()}`);
// === 3. The password really is required. ===
let wrongPasswordRejected = false;
try {
Account.newFromKeystore(keystorePath, 'wrong password');
} catch {
wrongPasswordRejected = true;
}
console.log(`3. Loading with the wrong password throws: ${wrongPasswordRejected}`);
// === 4. Mnemonic keystore: encrypt the whole phrase. ===
// A mnemonic keystore holds the seed phrase, so one file yields many
// accounts — addressIndex picks which one on load.
const mnemonicWallet = UserWallet.fromMnemonic({ mnemonic: mnemonic.toString(), password: PASSWORD });
const mnemonicPath = join(dir, 'mnemonic.json');
mnemonicWallet.save(mnemonicPath);
const account0 = Account.newFromKeystore(mnemonicPath, PASSWORD, 0);
const account1 = Account.newFromKeystore(mnemonicPath, PASSWORD, 1);
const derived0 = mnemonic.deriveKey(0).generatePublicKey().toAddress().toBech32();
const derived1 = mnemonic.deriveKey(1).generatePublicKey().toAddress().toBech32();
console.log(
`4. Mnemonic keystore, addressIndex 0 and 1 match direct derivation: ${account0.address.toBech32() === derived0 && account1.address.toBech32() === derived1}`,
);
console.log(` The two indices are different accounts: ${account0.address.toBech32() !== account1.address.toBech32()}`);
console.log('\nExpected: four "true" confirmations (2, 2b, 3, 4) plus the "different accounts" line.');
} finally {
// Clean up the temp directory regardless of outcome.
rmSync(dir, { recursive: true, force: true });
}
}
main();
Run it
Keys are generated fresh each run; the pattern of confirmations is stable:
1. Saved keystore: version 4, kind "secretKey", cipher aes-128-ctr, kdf scrypt.
2. Loaded address matches the original: true
UserWallet.loadSecretKey recovered the same key: true
3. Loading with the wrong password throws: true
4. Mnemonic keystore, addressIndex 0 and 1 match direct derivation: true
The two indices are different accounts: true
How it works
Grounded in the installed @multiversx/sdk-core v15.4.1 UserWallet
(wallet/userWallet.d.ts) and Account.newFromKeystore:
- Encrypt a key.
UserWallet.fromSecretKey({ secretKey, password })builds an encrypted wallet;.save(path)writes it. On disk it is JSON withversion: 4,kind: "secretKey",cipher: aes-128-ctr,kdf: scrypt. - Load it back.
Account.newFromKeystore(path, password)returns anAccount(synchronous).UserWallet.loadSecretKey(path, password)returns the rawUserSecretKeyinstead. - The password is required. Loading with the wrong password throws.
- Mnemonic keystore.
UserWallet.fromMnemonic({ mnemonic, password })encrypts the whole phrase, so one file yields many accounts;Account.newFromKeystore(path, password, addressIndex)picks which one.
Pitfalls
A secretKey keystore holds exactly one key, so addressIndex is irrelevant
there. For a mnemonic keystore it selects the derived account (0, 1, ...).
Account.newFromKeystore handles both kinds with the same call, but only the
mnemonic kind honors the index.
The public address field is metadata; the actual key is the encrypted crypto
section, recoverable only with the password. Always round-trip (decrypt) to
confirm you hold the right key, as step 2 does.
There is no recovery path for an encrypted keystore. Scrypt is deliberately slow to resist brute force, which also means a forgotten password is unrecoverable. For real keys, back up the mnemonic separately.
See also
- Save and load a PEM (dev only) is the unencrypted counterpart; use the keystore for anything real.
- Create an Account from a KeyPair, secret key, or mnemonic covers the in-memory constructors.
- Generate a mnemonic + derive keys is the phrase a mnemonic keystore encrypts.