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:
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:
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
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:
const bytes = Uint8Array.from(JSON.parse(await readFile('wallet.json', 'utf8')));
const signer = await createKeyPairSignerFromBytes(bytes);
// signer.address is ready to use5. Lamports, not SOL
rpc.getBalance(address) returns lamports as a bigint. 1 SOL = 1,000,000,000 lamports.
const { value: balance } = await rpc.getBalance(signer.address).send();
const sol = Number(balance) / 1_000_000_000;Gotchas
wallet.jsonis a raw secret key. Never commit it. Add to.gitignore.- Run with Node, not Bun —
@solana/rpcusessetMaxListeners(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:
No wallet file found. Generating new keypair...
Saved new wallet to wallet.json - EucuY6QR6qK6kbjHwgs795ZQRbEByjmfJYJND5hRs9dL
Wallet address: EucuY6QR6qK6kbjHwgs795ZQRbEByjmfJYJND5hRs9dL
Balance: 0 SOLSecond run:
Loaded existing wallet from wallet.json
Wallet address: EucuY6QR6qK6kbjHwgs795ZQRbEByjmfJYJND5hRs9dL
Balance: 0 SOLSame address → ✅.
Full script
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.