Skip to main content
BeginnerEst6 minsdk-core15.4.1Project build checked

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
package.json
{
"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"
}
}
tsconfig.json
{
"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
// 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:

  1. Encrypt a key. UserWallet.fromSecretKey({ secretKey, password }) builds an encrypted wallet; .save(path) writes it. On disk it is JSON with version: 4, kind: "secretKey", cipher: aes-128-ctr, kdf: scrypt.
  2. Load it back. Account.newFromKeystore(path, password) returns an Account (synchronous). UserWallet.loadSecretKey(path, password) returns the raw UserSecretKey instead.
  3. The password is required. Loading with the wrong password throws.
  4. 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

Pitfall 1: addressIndex only matters for mnemonic keystores

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.

Pitfall 2: the JSON's address field is metadata, not proof of the key

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.

Pitfall 3: losing the password means losing the key

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