Back to writing
April 21, 2026· 5 min read

Generate a Solana keypair once, persist it to wallet.json, reload on every future run, check devnet balance. PKCS8 export quirks + 64-byte secret key format explained.


Part of my 100 Days of Solana challenge (MLH).

Goal

Generate a Solana keypair once, save it to disk, reload it every future run. Print address + devnet balance. Two consecutive runs must produce the same address = persistence works.

Key things I learned

1. generateKeyPair(true) — the true matters

By default, Ed25519 keypairs from Web Crypto are non-extractable. You cannot read the private key bytes out of them. To persist a key to disk, pass true:

JAVASCRIPT
const keyPair = await generateKeyPair(true);

Without true, you get a usable signer in memory but no way to serialize it.

2. Node cannot export Ed25519 private keys as raw

Node's Web Crypto rejects raw export for Ed25519 private keys. You must export as PKCS8 (a DER-encoded ASN.1 blob). The actual 32-byte seed lives in the last 32 bytes of that buffer, so you slice it off:

JAVASCRIPT
const pkcs8 = new Uint8Array(
  await crypto.subtle.exportKey('pkcs8', keyPair.privateKey)
);
const privateKeyBytes = pkcs8.slice(-32);

The browser supports raw export, Node does not. Know your runtime.

3. Solana's 64-byte secret key format

Solana wallets (same format solana-keygen produces) store a 64-byte array:

  • bytes 0–31 → private key seed
  • bytes 32–63 → public key
JAVASCRIPT
const secretKey = new Uint8Array(64);
secretKey.set(privateKeyBytes, 0);
secretKey.set(publicKeyBytes, 32);
await writeFile('wallet.json', JSON.stringify(Array.from(secretKey)));

That's the [12, 34, ...]-style array you've seen in ~/.config/solana/id.json.

4. Reload with createKeyPairSignerFromBytes

Once you have the 64-byte array back from disk, reloading is a one-liner:

JAVASCRIPT
const bytes = Uint8Array.from(JSON.parse(await readFile('wallet.json', 'utf8')));
const signer = await createKeyPairSignerFromBytes(bytes);
// signer.address is ready to use

5. Lamports, not SOL

rpc.getBalance(address) returns lamports as a bigint. 1 SOL = 1,000,000,000 lamports.

JAVASCRIPT
const { value: balance } = await rpc.getBalance(signer.address).send();
const sol = Number(balance) / 1_000_000_000;

Gotchas

  • wallet.json is a raw secret key. Never commit it. Add to .gitignore.
  • Run with Node, not Bun — @solana/rpc uses setMaxListeners(n, AbortSignal), which Bun doesn't implement.
  • Same wallet address on run 1 and run 2 = persistence works. If they differ, the load path is broken.

Verification

First run:

JAVASCRIPT
No wallet file found. Generating new keypair...
Saved new wallet to wallet.json - EucuY6QR6qK6kbjHwgs795ZQRbEByjmfJYJND5hRs9dL
Wallet address: EucuY6QR6qK6kbjHwgs795ZQRbEByjmfJYJND5hRs9dL
Balance: 0 SOL

Second run:

JAVASCRIPT
Loaded existing wallet from wallet.json
Wallet address: EucuY6QR6qK6kbjHwgs795ZQRbEByjmfJYJND5hRs9dL
Balance: 0 SOL

Same address → ✅.

Full script

JAVASCRIPT
import { readFile, writeFile } from 'node:fs/promises';
import {
  createKeyPairSignerFromBytes,
  createSolanaRpc,
  devnet,
  generateKeyPair,
} from '@solana/kit';

const WALLET_FILE = 'wallet.json';

async function loadOrCreateWallet() {
  try {
    const raw = await readFile(WALLET_FILE, 'utf8');
    const bytes = Uint8Array.from(JSON.parse(raw));
    return await createKeyPairSignerFromBytes(bytes);
  } catch (err) {
    if (err.code !== 'ENOENT') throw err;

    const keyPair = await generateKeyPair(true);
    const pkcs8 = new Uint8Array(
      await crypto.subtle.exportKey('pkcs8', keyPair.privateKey),
    );
    const privateKeyBytes = pkcs8.slice(-32);
    const publicKeyBytes = new Uint8Array(
      await crypto.subtle.exportKey('raw', keyPair.publicKey),
    );

    const secretKey = new Uint8Array(64);
    secretKey.set(privateKeyBytes, 0);
    secretKey.set(publicKeyBytes, 32);

    await writeFile(WALLET_FILE, JSON.stringify(Array.from(secretKey)));
    return await createKeyPairSignerFromBytes(secretKey);
  }
}

const signer = await loadOrCreateWallet();
const rpc = createSolanaRpc(devnet('https://api.devnet.solana.com'));
const { value: balance } = await rpc.getBalance(signer.address).send();
console.log('Wallet address:', signer.address);
console.log(`Balance: ${Number(balance) / 1_000_000_000} SOL`);

Takeaway

Extractable keys + PKCS8 slice + 64-byte [seed || pubkey] layout. That's the whole trick behind a persistent Solana wallet in Node.

    — Writing