Building Desktop Apps on The Solana Blockchain with Electron
26/06/2026
|
20 min read
|
Share

Around 3 weeks ago at the time of writing this post, I was asked by a friend "Can you build me a trading bot that can trade meme coins?", I then thought to myself "well why not, how hard can that possibly be?, if I keep it simple and avoid over engineering this", so I asked what capabilities you want your bot to have, he said "just the basics, buy/sell and if you can add a volume bot to it also". Well, on the surface especially when you're not familiar with blockchain tech and its ecosystem that sounds easy, and a great learning experience, so I agreed. Guess what? I was brutally wrong. And only realized that when I started researching this whole topic. You can find the final App in its website or Github.
Researching
I started my research on what meme coins even are, how they are created, and how one can buy or sell these. What I found were mostly existing trading platforms, that all point to creating/importing wallets, adding SOL to them and start trading. So I searched "what sols are", and discovered Solana, and that sols are a cryptocurrency, and Solana is the primary blockchain of these, and that wallets are the thing you keep these sols in and trade with, and they consist of key pairs of public key (the address you use to receive funds) and a private key (the secret key you use to sign transactions and send funds). So I figured out that my bots, are actually wallets, and the thing that powers them is SOL.
After exploring the platform's docs, and examples, I started experimenting creating wallets, adding SOL to them with Airdrops via the public rpc endpoint (specifically the devnet endpoint), and sending them around between wallets with transactions.
And that "meme coins" are Token mints with metaplex metadata. You use your wallets to sell and receive tokens, from these mints, with a DEX such as Raydium.
At this point I got almost all of that already figured out, what I realized more about this ecosystem, is the scam potential it has over people that have absolutely no idea, how easy it is, to pull off rug pulls, manipulate pools and control your coins as a developer. Which most of this ecosystem is built around this idea "it's just part of the game", and completely accept that. What I found out more, is that there are huge enterprises who know all that and build their entire business model around these scams at scale.
With the research done, I started creating the dev environment, and with the idea of keeping it simple but usable and easy enough for my friend to use, I picked Jupiter as the DEX aggregator to easily get the best prices and quotes, and avoid complex math and logic, they have generous rate limits (at the time of writing) 1/rps and unlimited usage, which is more than enough for my friend. I told him "it will be a cli without any UI at first, see how you get around that, and if you find it challenging, I'll create you a desktop app".
Why not? I have a good knowledge of Vue, React, and Electron shouldn't be that hard to navigate, as it's just web technologies, and their build tool, Electron forge can be used with Vite, React, Vue and TypeScript, plus their docs state "It combines many single-purpose packages to create a full build pipeline that works out of the box" so it should be easy right?...
Development
Starting with the CLI, I set up my eslint config installed citty as the CLI builder, drizzle with better-sqlite3 for wallets storage, and started the development. I defined the schema to be:
import { blob, integer, sqliteTable, text } from "drizzle-orm/sqlite-core";
import { sql } from "drizzle-orm";
export const walletTable = sqliteTable('wallets', {
id: integer('id', {mode: 'number'}).primaryKey({ autoIncrement: true }),
publicAddress: text('public_address').unique(),
isMainWallet: integer('is_main_wallet', {mode: 'boolean'}),
privateKeyEncrypted: blob('private_key_encrypted').notNull(),
createdAt: integer('created_at').notNull().default(sql`(cast(unixepoch() as int))`),
});
Nothing crazy, except that the isMainWallet column filters a wallet as a main one, only a single wallet exists in the system that is flagged as main. This wallet is directly used to distribute funds to sub-wallets which their isMainWallet is 0. And to drain sub-wallets back to main.
Creating Wallets
Obviously, the private key of a wallet is a sensitive field, and should be kept secret, I needed a way to keep that field:
- Encrypted when a new wallet is stored
- Decrypted on demand when a sensitive action is requested
- Needs to be simple enough for a non-technical user to use and navigate
So I thought, why not use aes-256-gcm? It gives you data integrity (eg. authentication) and I can ask the user for a "password", which is hashed and salted with scrypt to derive a cryptographic key. This derived key is then fed directly into the AES algorithm to encrypt the wallet's private key. Then, for the decryption function I can ask for that same password with the encrypted value. So that's what I ended up with:
const SCRYPT_CONFIG = {
keyLen: 32,
saltLen: 32,
params: { N: 16384, r: 8, p: 1 }
};
export const encryptPrivateKey = (privateKey: Uint8Array, password: string): Buffer => {
try {
const iv = crypto.randomBytes(12);
const salt = crypto.randomBytes(SCRYPT_CONFIG.saltLen);
const key = crypto.scryptSync(password, salt, SCRYPT_CONFIG.keyLen, SCRYPT_CONFIG.params);
const cipher = crypto.createCipheriv('aes-256-gcm', key, iv);
const encrypted = Buffer.concat([cipher.update(privateKey), cipher.final()]);
const tag = cipher.getAuthTag();
return Buffer.concat([salt, iv, tag, encrypted]);
} catch (err) {
throw new Error(`Encryption failed: ${(err as Error).message}`);
}
};
export const decryptPrivateKey = (encrypted: Buffer, password: string): Uint8Array => {
try {
const saltLen = SCRYPT_CONFIG.saltLen;
const ivLen = 12;
const tagLen = 16;
const minLength = saltLen + ivLen + tagLen;
if (encrypted.length < minLength) {
throw new Error('Invalid encrypted data format');
}
let offset = 0;
const salt = encrypted.subarray(offset, offset += saltLen);
const iv = encrypted.subarray(offset, offset += ivLen);
const tag = encrypted.subarray(offset, offset += tagLen);
const ciphertext = encrypted.subarray(offset);
const key = crypto.scryptSync(password, salt, SCRYPT_CONFIG.keyLen, SCRYPT_CONFIG.params);
const decipher = crypto.createDecipheriv('aes-256-gcm', key, iv);
decipher.setAuthTag(tag);
return new Uint8Array(Buffer.concat([
decipher.update(ciphertext),
decipher.final()
]));
} catch (error: unknown) {
const err = error instanceof Error ? error : null;
const code = (error as NodeJS.ErrnoException).code;
if (code === 'ERR_CRYPTO_OPERATION_FAILED' || err?.message.toLowerCase().includes('unsupported state')) {
throw new Error('Incorrect password or data');
}
consola.log(`Decryption failure`);
throw error;
}
};
Now every time a new sub-wallet is created, or a main wallet is stored or updated I call encryptPrivateKey when I transfer funds, make transactions, etc, I call decryptPrivateKey, perfect. This pair can probably be fed also to my utils package later for some other weird use case I guess.
However the most notable problems with the above approach are:
- There is no recovery mechanism, if you lose your password, you lose access to your wallets.
- You need to provide your password every time you make sensitive actions, which can be insecure in cli contexts (AI tools, background processes, etc.).
But that is still more than enough for its purpose.
Then the code for creating wallets becomes really simple you just call Keypair.generate() method from the @solana/web3.js package:
export async function createWallet(password: string) {
const newWallet = Keypair.generate();
const encP = encryptPrivateKey(newWallet.secretKey, password);
await db.insert(walletTable).values({
publicAddress: newWallet.publicKey.toBase58(),
privateKeyEncrypted: encP,
isMainWallet: false
});
consola.success('New Wallet created successfully', newWallet.publicKey);
return;
}
For storing the main wallet, I thought to let a user provide a file path, since its a cli right now. And because the format of the key can come as a base58 and Uint8Array the code should detect that, try to parse it, and forbid overriding an existing main key if one already exists:
export async function storeMain(password: string, secretPath: string) {
const [main] = await db.select().from(walletTable).where(eq(walletTable.isMainWallet, true));
if (main?.privateKeyEncrypted || main?.publicAddress) {
consola.error(`MAIN wallet already exists!`);
throw new Error(`MAIN wallet already exists!`);
}
const filePath = path.resolve(process.cwd(), secretPath);
const secretKeyString = fs.readFileSync(filePath, "utf8");
if (!secretKeyString) {
consola.error(`File path didn't found at ${secretPath}`);
throw new Error(`File path didn't found at ${secretPath}`);
}
try {
const trimmed = secretKeyString.trim();
let secretKeyBytes: Uint8Array;
try {
const parsed = JSON.parse(trimmed) as number[] | string;
if (Array.isArray(parsed) && parsed.every((n) => typeof n === 'number')) {
secretKeyBytes = Uint8Array.from(parsed);
} else if (typeof parsed === 'string') {
secretKeyBytes = bs58.decode(parsed);
} else {
throw new Error('Unsupported JSON secret format');
}
} catch {
secretKeyBytes = bs58.decode(trimmed);
}
const pub = Keypair.fromSecretKey(secretKeyBytes);
const encP = encryptPrivateKey(pub.secretKey, password);
await db.insert(walletTable).values({
publicAddress: pub.publicKey.toBase58(),
privateKeyEncrypted: encP,
isMainWallet: true
});
consola.success('Main Wallet stored successfully', pub.publicKey.toBase58());
return;
} catch (err) {
consola.error("Invalid secret key format. Ensure it is a JSON array of numbers, a base58 string, base64, or hex.");
consola.error(err);
throw new Error("Invalid secret key format. Ensure it is a JSON array of numbers, a base58 string, base64, or hex.");
}
}
At this point I got the foundation of the bots set up.
- Only one main wallet can exist at a time
- Sub wallets can be created as much as you want (eg the bots)
- Everything is encrypted and decrypted on demand
- Everything except the user password is stored in sqlite
I got up and continued with the wallets logic:
drainWalletsToMainto drain all sub-wallets funds back to the main wallet, that accepts the user password for decryption, calculates the solana fees and skips wallets that don't have enough lamports (or SOL. 1 SOL === 1,000,000,000).distributeGasto split SOL to sub-wallets that accepts also the password for decryption, and the amount of the SOL to split to each wallet. This one calculates the total fees for all transactions that will be performed + the specified amount if there is not enough SOL it throws.getByPubkeyTo read metadata by a specific public key of a wallet such as SOL in, creation date etc.getAllWalletsViewSame asgetByPubkeybut to get the view of all wallets, including the main one.
Both also accept the password.
Setting up the Trade Logic
With the wallet functions wired, I first focused heavily on the core features:
- Buy: To start a mass buy using the sub-wallets with configurable wallet amounts to use, SOL amounts to use from each wallet, and the target mint.
- Sell: To start a mass sell, also with the number of wallets to use, the target mint, and the percentage to sell from each wallet.
- Out: To panic out from all positions.
All operations also accept the password to decrypt the wallets that are going to be used.
Because the CLI runs entirely locally, and needs to manage user specific apis, I made another table:
import { blob, integer, sqliteTable, text } from "drizzle-orm/sqlite-core";
import { sql } from "drizzle-orm";
export const apiAndRpcTable = sqliteTable('api_rpc', {
id: integer('id', {mode: 'number'}).primaryKey({ autoIncrement: true }),
createdAt: integer('created_at').notNull().default(sql`(cast(unixepoch() as int))`),
encryptedApiKey: blob('private_key_encrypted').notNull(),
rpc: text('rpc', {mode: 'text' }).notNull(),
});
The encryptedApiKey field is the Jupiter API key, which is also encrypted using the mechanism described above, and decrypted with the password inside the trade operations.
The rpc field is the URL of the RPC provider being used; this field is not encrypted.
While I am not going to paste all the code for these functions in this post (you can find it here), the main challenge I found difficult to solve was rate limits.
The solutions were either to use a paid provider or to break down the logic to be rarely rate-limited by the public RPC.
You already guessed it, I chose the second option. To stop the bot from spamming the RPC and Jupiter API all at once, I wrote a custom fetch wrapper. It catches 429 errors and applies an exponential backoff with a bit of random jitter before it retries.
import consola from "consola";
export async function fetchWithRetry(url: string, retries = 5, delay = 1000, init?: RequestInit) {
try {
const res = await fetch(url, init);
if (res.status === 429 && retries > 0) {
const jitter = Math.random() * (delay * 0.5);
const totalWait = delay + jitter;
consola.warn(`[429] Rate limited on ${url}. Retrying in ${(totalWait / 1000).toFixed(2)}s...`);
await new Promise(res => setTimeout(res, totalWait));
return await fetchWithRetry(url, retries - 1, delay * 2, init);
}
return res;
} catch (err) {
if (retries > 0) {
consola.error(`[Network Error] ${url}. Retrying...`);
return fetchWithRetry(url, retries - 1, delay * 2, init);
}
throw err;
}
}
Next, in the mass operation functions themselves (like massBuy, massSell, and massOut), instead of executing every wallet transaction simultaneously with a giant Promise.all, I chunked the processes. By grouping the wallets into batches and adding a sleep delay between them, it gives the public API time to breathe.
Here is a snippet of how that processing loop looks inside massBuy.ts:
await chunkProcess(walletData, chunksToProcess, async (chunk) => {
await Promise.all(
chunk.map(async ({balance, wallet}) => {
// balance checks and URL setup
const orderResponse = await fetchWithRetry(String(orderUrl), 5, 500, {
headers: {
"x-api-key": API_KEY
}
});
// transaction signing
const executeResponse = await fetchWithRetry(`${JUPITER_BASE_URL}/execute`, 5, 500, {
method: "POST",
headers: {
"Content-Type": "application/json",
"x-api-key": API_KEY,
},
body: JSON.stringify({
signedTransaction: Buffer.from(transaction.serialize()).toString("base64"),
requestId: orderResult.requestId,
}),
});
})
);
// Sleep after each chunk to avoid hitting rate limits on the next batch
await sleep(1000);
});
This combination of chunking the workload and adding retry logic lets the bot handle concurrent mass operations safely and reliably, even when relying entirely on public nodes.
chunkProcess function comes also from my utils packageBut EVEN THAT was not enough for the sell functions (massOut, and massSell) to succeed. Because at first the logic in these functions was heavily using the rpc's methods, then refactored to make a single call with the getProgramAccounts method to get all accounts and parse it locally (find the wallet, and the target mint), which also didn't work (rate limited), and was error prone.
So I implemented a persistent positions tracker, with the following schema:
import { sql } from 'drizzle-orm';
import { sqliteTable, text, integer, numeric } from 'drizzle-orm/sqlite-core';
import { walletTable } from './wallets';
export const positionsTable = sqliteTable('positions', {
id: integer('id').primaryKey({ autoIncrement: true }),
walletAddress: text('wallet_address').notNull().references(() => walletTable.publicAddress, { onDelete: 'cascade' }),
targetMintAddress: text('target_mint_address').notNull(),
solAmounts: numeric('sol_amounts', { mode: 'bigint' }),
tokenAmount: numeric('token_amount', { mode: 'bigint' }),
walletRemainingBalance: numeric("wallet_remaining_balance", { mode: 'bigint' }),
updatedAt: integer('updated_at', {mode: 'timestamp'}).default(sql`(cast(unixepoch() as int))`),
});
This schema completely eliminated the need to ask the RPC node, "what tokens do these 50 wallets hold?" Instead of relying on the network for state, the application tracks it locally.
Every time a massBuy operation succeeds on-chain, it immediately records the exact resulting position, down to the specific token amount received, into this local database:
// inside the execution loop
if (result.status === "Success") {
log.success(`Buy Success! https://solscan.io/tx/${result.signature}`);
try {
await db.insert(positionsTable).values({
walletAddress: wallet.publicKey.toBase58(),
targetMintAddress: targetMintAddress,
solAmounts: lamportsPerWallet,
walletRemainingBalance: BigInt(balance),
tokenAmount: BigInt(result.outputAmountResult), // The exact amount received
});
} catch (err) {
log.error("Database Error (Swap succeeded on-chain, but failed to record)", err);
}
}
Then, when it is time to trigger a massSell or a panic massOut, the bot skips the heavy network queries entirely. It simply reads the active positions directly from SQLite, matches them with the correct encrypted private keys, and goes straight to building the sell orders:
const filters: SQL[] = [];
filters.push(eq(positionsTable.targetMintAddress, targetMintAddress));
// optional filtering logic if specific wallets were chosen
const limit = (chosenWallets && chosenWallets.length > 0) ? chosenWallets.length : numberOfWallets;
const positions = await db.select().from(positionsTable).where(and(...filters)).limit(limit);
if (positions.length === 0) {
log.warn(`No positions found in database for mint: ${targetMintAddress}`);
return {
ok: false,
reason: `No positions found in database for mint: ${targetMintAddress}`
};
}
Adding this local state lets the sell operations instantly know exactly which wallets hold the target mint and exactly how much they hold. This drastically reduced the total number of RPC requests, completely bypassing the rate-limit errors I was hitting with getParsedTokenAccountsByOwner and getProgramAccounts before even reaching the Jupiter API.
If a sell succeeds, the local database simply deletes that position row (or updates the balance if it was a partial sell percentage).
With the core already figured out, and tested locally with a small amount of SOL in a live environment, it was time to add that Volume bot. This part actually wasn't that challenging after those three core functions (massBuy, massSell, massOut) were in place. A volume bot simply needs to reuse these three functions in a continuous loop to generate trading activity.
The main challenge here shifted from blockchain logic to process management. I needed the volume bot to run continuously in the background, independent of the main CLI, without locking up the user's terminal.
To achieve this, I used Node's child_process.spawn to start a detached background worker. To keep things simple and avoid complex IPC (Inter-Process Communication) at this stage, I passed the configuration down to the worker via environment variables:
import { spawn } from 'node:child_process';
import fs from 'node:fs/promises';
const child = spawn(process.execPath, process.argv.slice(1), {
detached: true,
stdio: "ignore",
env: {
...process.env,
SOLANA_BOTS_INTERNAL: "volume-worker",
TARGET_MINT: args.target,
WALLETS: args.wallets,
AMOUNT: args.amount,
PASSWORD: args.password,
INTERVAL: args.interval,
TTL: args.ttl ?? "",
},
});
child.unref();
await fs.writeFile(PID_FILE, String(child.pid));
consola.success(`Volume bot started in background. PID: ${child.pid}`);
By setting detached: true and calling child.unref(), the worker detaches from the parent terminal. I then save its Process ID to a local .pid file so the CLI can keep track of it and shut it down later.
Inside the worker process itself, it runs a continuous while loop until a specified TTL expires or it receives a stop signal. But I couldn't just grab the same wallets every single loop, if a user has 50 wallets but only wants to run volume with 5 at a time, the bot should cycle through them to distribute the trading activity naturally.
To solve this, I implemented a round-robin rotation logic using a walletOffset index. In each cycle, it grabs a pool of eligible wallets (wallets that actually have enough SOL to cover the trade amount plus the network fees), and then slices out the requested batch:
let walletOffset = 0;
while (!stopping && Date.now() < expiresAt) {
// Fetch all wallets that can afford the trade + fees
const pool = await getEligibleWallets(password, maxSolPerWallet + NETWORK_FEE_BUFFER);
if (pool.length < numberOfWallets) {
consola.error(`Only ${pool.length} wallets have enough SOL. Need ${numberOfWallets}.`);
await sleep(5000);
continue;
}
// rotation logic
const selectedWallets = [];
for (let i = 0; i < numberOfWallets; i++) {
const index = (walletOffset + i) % pool.length;
selectedWallets.push(pool[index]);
}
// Shift offset
walletOffset = (walletOffset + numberOfWallets) % pool.length;
const selectedKeypairs = selectedWallets.map(entry => entry.wallet);
try {
// 3. Execute Mass Buy
const buyResult = await massBuy(targetMintAddress, maxSolPerWallet, numberOfWallets, password, 1, selectedKeypairs);
} catch (err) {
}
// Mass Sell
await sleep(interval ?? 0);
}
Using the modulo operator, the walletOffset seamlessly wraps back to the beginning of the array once it reaches the end. This ensures every funded sub-wallet gets used evenly, creating much more organic-looking volume on-chain.
Because the worker is detached and has no standard output to the terminal, it continuously writes its current status (buy/sell counts, P&L, current action) to a local volumeBot.stats.json file. The CLI can then read this file via a volume-status command to show updates.
Trading with real money means you can't just forcefully kill a process midway through a loop. You might leave wallets holding positions that will crash.
When the user issues the stop command, the CLI sends a SIGTERM signal to the saved process ID. The worker script listens for this exact signal, flips the stopping boolean to true, which breaks the while loop, and executes a final massOutForAllWallets to panic-sell and clean up any lingering positions before fully shutting down:
process.on('SIGTERM', () => {
stopping = true;
await fs.unlink(PID_FILE).catch(() => {});
});
finally {
if (shouldMassOut) {
try {
consola.info("Closing all positions before shutdown...");
await massOutForAllWallets(password);
consola.success("Mass out completed.");
} catch (err) {
consola.error("Mass out failed:", err);
}
}
await cleanup();
}
With all that was all set, I defined the cli commands with citty which is pretty straightforward. Example of the volume bot's start command:
const dataDir =
process.platform === "win32"
? path.join(process.env["APPDATA"] ?? path.join(os.homedir(), 'AppData', 'Roaming'), "SolanaBot")
: path.join(os.homedir(), ".solana-bot");
const PID_FILE = path.join(dataDir, 'volumeBot.pid');
export const volumeStart = defineCommand({
meta: {
name: 'Start Volume Bot',
description: 'Start the volume bot in the background',
},
args: {
target: {
type: "string",
required: true,
description: "The SPL token mint address to target (The coin).",
valueHint: "..."
},
wallets: {
type: "string",
required: false,
default: "1",
description: "Number of sub-wallets to use for volume trading.",
valueHint: "1"
},
amount: {
type: "string",
required: true,
description: "Amount of SOL to use per trade for each wallet.",
default: "0.01",
valueHint: "0.01"
},
password: {
type: "string",
required: true,
description: "Master password used to decrypt your stored wallets.",
valueHint: "mySecurePassword"
},
interval: {
type: "string",
required: false,
description: "Run a cycle every this much time (ms)",
valueHint: "1000 for every 1s (default)"
},
ttl: {
type: "string",
required: false,
default: "60",
description: "Optional time-to-live for the bot to run in seconds. if not added the bot will stop after 60s",
valueHint: "60"
},
},
async run({ args }) {
try {
const existingPid = Number(await fs.readFile(PID_FILE, 'utf8'));
process.kill(existingPid, 0);
consola.warn(`Volume bot already running: ${existingPid}`);
return;
} catch { }
await fs.mkdir(dataDir, { recursive: true });
const child = spawn(process.execPath, process.argv.slice(1), {
detached: true,
stdio: "ignore",
env: {
...process.env,
SOLANA_BOTS_INTERNAL: "volume-worker",
TARGET_MINT: args.target,
WALLETS: args.wallets,
AMOUNT: args.amount,
PASSWORD: args.password,
INTERVAL: args.interval,
TTL: args.ttl ?? "",
},
});
child.unref();
await fs.writeFile(PID_FILE, String(child.pid));
consola.success(`Volume bot started in background. PID: ${child.pid}`);
},
});
You then simply import all your commands into a single main.ts file, and call runMain(main):
if (process.env['SOLANA_BOTS_INTERNAL'] === 'volume-worker') {
try {
await volumeBot(
process.env['TARGET_MINT']!,
Number(process.env['AMOUNT']),
Number(process.env['WALLETS']),
process.env['PASSWORD']!,
Number(process.env['INTERVAL']),
(process.env['MASS_OUT'] === 'true' ? true : false),
process.env['TTL'] ? Number(process.env['TTL']) : undefined,
);
} catch (err) {
consola.error(err);
process.exitCode = 1;
}
process.exit(0);
}
const start = defineCommand({
meta: {
name: 'Solana Bots',
description: 'Start the wizard',
},
subCommands: {
main: storeMainWallet,
create: createWallets,
'api-rpc': apiAndRpc,
drain: drainWallets,
split: splitFounds,
'read-all': readAllWallets,
read: readWalletView,
'volume-start': volumeStart,
'volume-stop': volumeStop,
'volume-status': volumeStatus,
airdrop: getAirDropC,
buy,
sell,
out: massOut
},
async run({args}) {
if (args._ && args._.length > 0) {
return;
}
consola.box('Solana Bots - Starter');
// you got the idea
}
})
await runMain(start);
citty automatically generates a help menu for you, for every single command.
I was then configuring pkg which was a pain in the ass (I didn't notice it was archived), it doesn't support esm, newer versions of node, and the list goes on, so I ditched it, and used bun instead, that resulted in a faster build, and cli overall. That also means I needed to switch to bun's built in sqlite instead of better-sqlite3.
My friend tried it. As expected, managing multiple configuration flags, reading JSON logs, and interacting via terminal commands was a bit too much for a non-technical user. It was time to wrap all of this into a nice, friendly desktop app using Electron.
The Electron Experience
While Electron is extremely powerful, and really an awesome framework (I was really impressed by it), its complexity comes from the boilerplate it requires you to wire, and its build tools.
Setting up the dev environment is pretty easy, you run:
npx create-electron-app@latest my-new-app --template=vite-typescript
That creates the Vite setup using electron forge template, and since I used Vue, for this app I followed the Vue integration guide.
After exploring and reading the forge docs a bit, I realized that the build process should end up pretty simple, so I continued with the frontend development without thinking much about it. I used Nuxt UI Tailwind and Vue Router, so I was able to move fast and complete the frontend pretty quickly.
I then started to test it to see how the backend of the system interacts with the frontend. Discovered Electron's IPC model that you should provide your renderer.ts explicitly the methods you want it to have from the preload.ts and context isolation. And communication between the frontend and the main process or vice versa, only via preload.ts.
So that is what I did. I wired everything up so that the components and forms from the renderer call the methods defined in preload.ts, which in turn trigger the main process via ipcRenderer.invoke().
However, for the Volume Bot, the approach had to be a bit different.
Because the volume bot runs a continuous, heavy background loop dealing with cryptography, network requests, and database writes, running it directly inside Electron's main process would completely choke up the application. If the main process blocks, the entire UI freezes. In the CLI version, I used Node's child_process.spawn, but Electron provides a dedicated API specifically designed for running heavy, Node.js-dependent background tasks: the utilityProcess.
To bridge the gap between this detached utility process and the Vue frontend, I needed a way to stream real-time stats and logs so the user could see what the bot was doing.
Instead of setting up complex message ports, I used a simpler approach: standard output stream parsing. Inside the worker script, whenever the bot updates its stats, it simply logs a stringified JSON object prefixed with a special token (__BOT_METRIC__:).
Here is how I implemented the bridge in the main Electron process:
import { ipcMain, safeStorage, utilityProcess } from 'electron';
import readline from 'node:readline';
// ...
export function setupVolumeBotBridge(mainWindow: BrowserWindow, getVault: () => string | null) {
ipcMain.handle('trade:volume-bot', async (_event, targetMintAddress, maxSolPerWallet, numberOfWallets, interval, shouldMassOut, ttl) => {
const vault = getVault();
if (!vault) return { ok: false, reason: 'UNAUTHORIZED'};
const decryptedPassword = safeStorage.decryptString(Buffer.from(vault, 'base64'));
//fork heavy worker
workerThread = utilityProcess.fork(cliScriptPath, [], {
env: {
...process.env,
SOLANA_BOTS_INTERNAL: 'volume-worker',
TARGET_MINT: targetMintAddress,
AMOUNT: String(maxSolPerWallet),
WALLETS: String(numberOfWallets),
PASSWORD: decryptedPassword,
INTERVAL: String(interval ?? 0),
MASS_OUT: String(shouldMassOut),
TTL: ttl ? String(ttl) : ""
},
stdio: 'pipe'
});
if (workerThread.stdout) {
// read the output
const stdoutReader = readline.createInterface({ input: workerThread.stdout });
stdoutReader.on('line', (line) => {
const cleanLine = line.trim();
if (!cleanLine) return;
// metric token
if (cleanLine.startsWith('__BOT_METRIC__:')) {
try {
const event = JSON.parse(cleanLine.replace('__BOT_METRIC__:', ''));
// Route the data to the frontend
switch (event.type) {
case 'stats':
mainWindow.webContents.send('volume-bot:stats', event.payload);
break;
case 'log':
mainWindow.webContents.send('volume-bot:log', event.payload);
break;
case 'activity':
mainWindow.webContents.send('volume-bot:activity', event.payload);
break;
case 'status':
mainWindow.webContents.send('volume-bot:status', event.payload);
break;
}
} catch (err) {
console.error('Failed to parse bot metric line:', err);
}
} else {
// pass standard console logs directly to the UI log viewer
mainWindow.webContents.send('volume-bot:raw-console', cleanLine);
}
});
}
workerThread.on('exit', () => {
mainWindow.webContents.send('volume-bot:status', { status: 'stopped', stoppedAt: Date.now() });
workerThread = null;
});
return { ok: true, data: 'SUCCESS'};
});
// stop logic
}
The Vue application then simply sets up ipcRenderer.on listeners for events like volume-bot:stats. When the utility process completes a trade, it outputs a metric line. The main process intercepts it, strips the prefix, parses the JSON, and pushes it directly into the Vue component's state.
It results in a smooth desktop UI where the user can watch their P&L, successful trades, and active logs update in real time, while the heavy cryptographic signing and network retry loops run isolated in the background. Stopping the bot is equally safe, the frontend sends a trade:volume-bot:stop command, the main process calls workerThread.kill(), and the worker performs its graceful shutdown logic before exiting.
You may also notice the getVault: () => string | null function. Remember the decrypt and encrypt functions? They require the master password the user has defined (in this case when first opening the app) that it needs to "provide his password every time you make sensitive actions", well with Electron I completely eliminated that. When the app first starts, after the first onboarding, it asks the user for its password, and saves it in Electron's native safeStorage.
safeStorage is an API built directly into Electron that allows applications to store data securely by leveraging OS-level encryption (like Keychain on macOS, DPAPI on Windows, and Secret Service API on Linux).
Instead of forcing the user to type their master password every single time they want to buy, sell, or view a wallet's private key, the app asks for it once per session. It then encrypts that master password using safeStorage and holds it in memory within the main process as a base64 string (the vault variable).
Here is how that looks in main.ts:
import { ipcMain, safeStorage } from 'electron';
let vault: string | null = null;
// inside app.whenReady()
ipcMain.handle('save-password', (_event, password: string) => {
// verify the OS supports native encryption
if (!safeStorage.isEncryptionAvailable()) {
return { ok: false, reason: 'OS_NOT_SUPPORTED' };
}
try {
// Encrypt the password
const encrypted = safeStorage.encryptString(password);
// Keep it in memory for the duration of the session
vault = encrypted.toString('base64');
return { ok: true, data: 'Password saved' };
} catch {
return { ok: false, reason: 'ENCRYPTION_FAILED' };
}
});
// A quick helper to let the UI know if the user is "logged in"
ipcMain.handle('password-status', () => vault ? true : false);
This completely solves the UX problem of the CLI. When the user initiates a sensitive action (like mass selling or starting the volume bot), the main process simply decrypts the vault string back into the master password and passes it quietly to the background workers. The user gets a seamless "one-click" trading experience, while the underlying design remains completely encrypted at rest.
The Operations Worker
With the volume bot isolated in its own utility process and the password securely stored, I noticed one final performance bottleneck. Even standard operations, like decrypting 50 wallets just to view their public addresses in the UI table, or parsing database positions, were causing slight UI stutters because they were running on the main Electron process.
Increasing that number to 100, and boom, the app crashed.
To fix this, I completely removed all database and heavy cryptographic logic from the main process. I spawned a second background utility process dedicated entirely to handling SQLite database reads/writes and standard trade executions.
I created a wrapper called routeOps that generates a unique UUID for each request, sends the action and the decrypted master password to the background worker, and waits for the specific response to come back:
import { utilityProcess } from "electron";
import crypto from 'node:crypto';
// ... (worker spawning logic) ...
export const routeOps = <T>(action: string, decryptedPassword?: string, args: unknown[] = []) => {
return new Promise<ResultsWithId<T>>((resolve) => {
const requestId = crypto.randomUUID();
const dbProcess = getDbProcess();
const handleResponse = (payload: unknown) => {
const data = payload as ResultsWithId<T>;
if (data.id !== requestId) return; // ignore messages not meant for this request
dbProcess.off('message', handleResponse);
resolve(data);
};
dbProcess.on('message', handleResponse);
// send the work to the background process
dbProcess.postMessage({
id: requestId,
action,
decryptedPassword,
args,
});
});
};
Then, inside the OperationWorker.ts file, an if/else block intercepts these actions, runs the heavy scrypt hashing to unlock the SQLite wallets, interacts with the blockchain, and sends the payload back:
process.parentPort.on('message', async (event) => {
const { id, action, args, decryptedPassword } = event.data as WorkerRequest;
try {
let result;
if (action === 'get:wallets:all') {
result = await getAllWalletsView(decryptedPassword);
}
else if (action === 'get:wallets:management') {
result = await getWalletsManagementView(decryptedPassword);
}
else if (action === 'get:wallets:one') {
if (!args[0] || typeof args[0] !== 'string') {
throw new Error('Invalid arguments: Expected a string public key');
} else {
result = await getByPubkey(decryptedPassword, args[0]);
}
}
// ... other actions
// catch all errors and normalize results
if (result !== null && typeof result === 'object' && 'ok' in result) {
if (!result.ok) {
throw new Error(result.reason ?? 'Underlying operation failed');
}
if ('data' in result) result = result.data;
else {
throw new Error(result.reason ?? 'Unexpected data type');
}
}
// send
process.parentPort.postMessage({
id,
ok: true,
date: new Date().toISOString(),
data: result
});
} catch (err) {
// send caught error
const message = err instanceof Error ? err.message : String(err);
process.parentPort.postMessage({
id,
ok: false,
date: new Date().toISOString(),
reason: message
});
}
})
The Build Process
What started as a "simple script to trade meme coins" quickly grew into a deep dive into cryptography, state management, exponential backoffs, IPC bridges, and multi-process architecture.
Building a local-first application on Solana that handles high-concurrency mass operations securely is what I discovered a complex challenge. You cannot rely strictly on the blockchain RPCs for state when dealing with mass operations, and you cannot run heavy cryptography on a UI thread.
Using SQLite for local state tracking, aes-256-gcm for wallet encryption, Electron's safeStorage for session memory, and detached workers for execution, the resulting application ended up, resilient to rate limits, and safe enough for non-technical users.
With that said, when it was time to finally package all of this with Electron Forge, it very quickly became a massive headache.
Multi-Process Vite & The ASAR Archive
Because I was using multiple detached utility processes (one for the Volume Bot, one for the Database Operations), each worker needed its own isolated entry point in the final compiled app.asar archive.
Vite is great for frontends, but handling multiple Node.js backend scripts required creating completely separate configurations (vite.opworker.config.ts, vite.worker.config.ts). I had to explicitly configure Vite to bundle them for a Node environment by using SSR flags and preventing externalization:
import { ignoreBunPlugin } from './vite.main.config';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
export default defineConfig({
resolve: {
alias: {
'@solana-bots': path.resolve(__dirname, '../src'),
'@': path.resolve(__dirname, 'src'),
},
},
ssr: {
noExternal: true // Bundle everything
},
plugins: [ignoreBunPlugin],
optimizeDeps: {
exclude: ['bun:sqlite', 'drizzle-orm/bun-sqlite', 'drizzle-orm/bun-sqlite/migrator'] // vite still trys to optimize these even if they are external
},
build: {
outDir: 'dist-worker/ops',
ssr: true,
target: 'node20',
lib: {
entry: 'src/electron/utils/OperationWorker.ts',
formats: ['es'],
},
rollupOptions: {
external: [
'electron',
'better-sqlite3', // ignore native dependencies
'bun:sqlite',
'drizzle-orm/bun-sqlite', // ignore the cli adapters
'drizzle-orm/bun-sqlite/migrator',
],
output: {
entryFileNames: '[name].mjs',
inlineDynamicImports: true,
},
},
},
});
Both workers' configs look identical; what's different are the paths and entry points.
The ignoreBunPlugin plugin is a vite plugin defined in vite.main.config.ts to FORCE Vite to ignore bun:
export const ignoreBunPlugin = {
name: 'ignore-bun-modules',
resolveId(id: string) {
if (id === 'bun:sqlite' || id.includes('bun-sqlite')) {
return { id, external: true };
}
}
};
Furthermore, Electron's default electron-squirrel-startup module was crashing Vite's ESM build completely. I ended up having to write a custom ESM implementation of the startup script (electronStartEsm.ts) using Node's child_process.spawn just to handle Windows shortcut creation properly without breaking the Vite pipeline.
The Native Dependency
The biggest headache by far was SQLite.
For the CLI version, I used bun:sqlite because it is fast, built-in, and because compiling the CLI with better-sqlite3 using Bun would outright crash the binary. But Electron requires a Node-compatible native module, so for the desktop app, I had to use better-sqlite3. (While I could have used the Node adapter for everything, it would have required rewiring some of the queries and relying on a beta version of Drizzle ORM).
This requirement forced a dual-adapter pattern in the database connection file that checks the environment at runtime:
const isBun = (typeof process.versions !== 'undefined' && process.versions.bun !== undefined) || process.env['CLI_BUILD'];
let db: AppDatabase;
if (isBun) {
const { Database } = await import(/* @vite-ignore */ "bun:sqlite");
// ... bun setup
} else {
const Database = (await import('better-sqlite3')).default;
// better-sqlite3 setup
}
This split immediately complicated database migrations. Because the CLI is compiled into a standalone binary and the UI is packaged into a read-only ASAR archive, the raw .sql migration files generated by Drizzle had to be manually copied around during the build process and referenced differently depending on the environment. I had to write custom logic to locate the migrations folder dynamically—whether it was running in dev mode, from the compiled Bun executable directory, or from Electron's process.resourcesPath.
This caused chaos during packaging. The Electron Forge Vite plugin couldn't properly detect and package the native C++ bindings for better-sqlite3. It aggressively stripped node_modules during the ASAR creation, leaving the production app without a working database.
After checking github issues, I found the solution: you have to use a specific regex in the forge.config.ts ignore field to forcefully prevent Forge from discarding better-sqlite3 and its bindings:
packagerConfig: {
name: 'Solana Bots',
asar: true,
ignore: [
// force electron Forge to KEEP better-sqlite3 and its bindings
/node_modules\/(?!(better-sqlite3|bindings|file-uri-to-path)\/)/,
/^\/src($|\/)/,
// ...
]
}
To make matters funnier, I found out that the latest versions of better-sqlite3 clash with Electron's internal Chromium/OpenGL APIs on certain operating systems, causing hard crashes. The fix for me was rolling back better-sqlite3 to version 12.9.0 and pinning Electron to version 41.0.0 instead of the latest 42.2.0.
CI/CD, Inno Setup, and Cloudflare R2
Because better-sqlite3 relies on native binaries, you can't build the Windows installer on a Mac/Linux or vice versa. It must be compiled on the target OS. I moved the entire build pipeline into GitHub Actions, starting up both an Ubuntu and Windows runner.
For Windows, Electron's default Maker (Squirrel) is extremely poor for UX, it doesn't let users choose install directories and feels crappy. So I deleted it and switched to Inno Setup which I later discovered.
I wanted the installer to include both the Electron Desktop App and the compiled CLI tool, alongside those migration files. And I wanted the CLI tool to automatically be added to the user's system PATH so they could just open their terminal and type solana-bots.
But modifying the system registry is risky; an installer shouldn't just inject environment variables, it needs to clean up after itself when uninstalled. With some help from AI, I wrote a custom Pascal script inside installer.iss that safely adds the CLI to the PATH, broadcasts the change to the OS, and parses and removes only that specific path string during uninstallation:
[Files]
Source: "ui\out\Solana Bots-win32-x64\*"; DestDir: "{app}"; Flags: ignoreversion recursesubdirs createallsubdirs
Source: "build\cli\solana-bots-win.exe"; DestDir: "{app}\cli"; DestName: "solana-bots.exe"; Flags: ignoreversion
Source: "build\migrations\*"; DestDir: "{app}\migrations"; Flags: ignoreversion recursesubdirs
[Code]
const
MY_HWND_BROADCAST = $FFFF;
MY_WM_SETTINGCHANGE = $001A;
MY_SMTO_ABORTIFHUNG = 2;
function SendMessageTimeout(hWnd: LongInt; Msg: LongInt; wParam: LongInt; lParam: String; fuFlags: LongInt; uTimeout: LongInt; var lpdwResult: LongInt): LongInt;
external '[email protected] stdcall';
procedure RefreshEnvironment;
var
Dummy: LongInt;
begin
SendMessageTimeout(MY_HWND_BROADCAST, MY_WM_SETTINGCHANGE, 0, 'Environment', MY_SMTO_ABORTIFHUNG, 5000, Dummy);
end;
procedure AddToPath;
var
OldPath: String;
NewPath: String;
AppPath: String;
begin
AppPath := ExpandConstant('{app}\cli');
if RegQueryStringValue(HKLM, 'SYSTEM\CurrentControlSet\Control\Session Manager\Environment', 'Path', OldPath) then
begin
if Pos(Lowercase(AppPath), Lowercase(OldPath)) = 0 then
begin
NewPath := OldPath + ';' + AppPath;
RegWriteExpandStringValue(HKLM, 'SYSTEM\CurrentControlSet\Control\Session Manager\Environment', 'Path', NewPath);
end;
end;
end;
procedure CurStepChanged(CurStep: TSetupStep);
begin
if CurStep = ssPostInstall then
begin
AddToPath();
RefreshEnvironment();
end;
end;
procedure CurUninstallStepChanged(CurUninstallStep: TUninstallStep);
var
sCurrent: String;
sAppPath: String;
sCurLower: String;
sAppLower: String;
p, startIdx, endIdx: Integer;
begin
if CurUninstallStep = usPostUninstall then
begin
sAppPath := ExpandConstant('{app}\cli');
if RegQueryStringValue(HKLM, 'SYSTEM\CurrentControlSet\Control\Session Manager\Environment', 'Path', sCurrent) then
begin
sCurLower := Lowercase(sCurrent);
sAppLower := Lowercase(sAppPath);
p := Pos(sAppLower, sCurLower);
// Cleanly parse and remove the specific CLI path
while p > 0 do
begin
startIdx := p;
endIdx := p + Length(sAppLower) - 1;
if (startIdx > 1) and (sCurLower[startIdx-1] = ';') then
startIdx := startIdx - 1;
if (endIdx < Length(sCurLower)) and (sCurLower[endIdx+1] = ';') then
endIdx := endIdx + 1;
Delete(sCurrent, startIdx, endIdx - startIdx + 1);
sCurLower := Lowercase(sCurrent);
p := Pos(sAppLower, sCurLower);
end;
// Clean up trailing/double semicolons
while Pos(';;', sCurrent) > 0 do
Delete(sCurrent, Pos(';;', sCurrent), 1);
while (Length(sCurrent) > 0) and (sCurrent[1] = ';') do
Delete(sCurrent, 1, 1);
while (Length(sCurrent) > 0) and (sCurrent[Length(sCurrent)] = ';') do
Delete(sCurrent, Length(sCurrent), 1);
if RegWriteExpandStringValue(HKLM, 'SYSTEM\CurrentControlSet\Control\Session Manager\Environment', 'Path', sCurrent) then
begin
RefreshEnvironment();
end;
end;
end;
end;
Finally, rather than using standard GitHub releases for distribution, I wanted to host the binaries myself to control the update flow. In the GitHub Actions workflow, I used rclone to automatically push the compiled artifacts directly to a Cloudflare R2 bucket, alongside a Bash script that parses the directory names to manage versioning:
- name: Upload to R2
env:
CLOUDFLARE_R2_ACCESS_KEY_ID: ${{ secrets.CLOUDFLARE_R2_ACCESS_KEY_ID }}
# ...
run: |
VERSION_STR="${{ needs.build-linux.outputs.version }}"
R2_PREFIX="releases/${VERSION_STR}"
curl -fsSL https://rclone.org/install.sh | sudo bash
# ... rclone configuration ...
rclone copy build/ "r2:${CLOUDFLARE_R2_BUCKET}/${R2_PREFIX}" --progress --no-traverse
Finally
What started as a favor for a friend evolved into a full-scale application bridging the gap between low-level blockchain cryptography, desktop frameworks, and automated deployment pipelines. It was an absolute headache to piece together, but an incredibly awesome learning experience.
Give it a try, you can download it from its website, and send me feedback later.