pzza.works

Privacy Architecture in VoidDex: Integrating Railgun's zkSNARK System

Berke (pzzaworks)Berke (pzzaworks)

January 3rd, 2026

A privacy-first DEX aggregator lives or dies on one trick: zero-knowledge proofs that hide the details of a transaction while still letting the chain verify it. VoidDex gets that layer from Railgun, which lets users swap tokens without exposing their activity on the blockchain.

Client-Side First: Your Keys Never Leave

The most important architectural decision comes before any of the technical detail: all sensitive operations happen in your browser. The VoidDex server never sees your wallet private key. It never sees your Railgun viewing key, which decrypts your private balance, or your Railgun spending key, which generates proofs. It never sees your unencrypted private balance, and it never learns which UTXOs belong to you.

What the server does handle is the non-sensitive work: fetching DEX quotes, optimizing routes, and relaying transactions that are already signed. Zero-knowledge proofs are generated entirely in the browser with WASM modules. That is less a security feature than the foundation the whole privacy model stands on. If the server could see your keys, there would be no privacy.

The Privacy Problem

Every transaction on Ethereum is public. Swap tokens on Uniswap and anyone can see your address, the tokens involved and the amounts. Some of the damage is immediate: MEV bots see your pending transaction and front-run it by executing their own first, or sandwich it by moving the price before and after your swap. Some of it accumulates. Your entire financial history is linked to your address, which is surveillance, and exchanges and services can analyze your trading patterns, which is profiling.

Railgun's answer is zkSNARKs (Zero-Knowledge Succinct Non-Interactive Arguments of Knowledge), which let you prove you have tokens without revealing which specific tokens are yours.

How Railgun Privacy Works

Railgun maintains a privacy pool of shielded tokens. When you shield tokens, they enter this pool and become indistinguishable from other shielded tokens. The system uses a UTXO model similar to Bitcoin but with zero-knowledge proofs.

Your private balance is a set of encrypted UTXOs (Unspent Transaction Outputs) that only your viewing key can decrypt. To spend, you generate a proof that says three things at once: you own valid UTXOs in the pool, those UTXOs have not been spent before, and the amounts add up correctly. The proof reveals none of the actual UTXO details.

Client-Side Architecture

Every privacy operation in VoidDex runs in the browser, on top of the WASM modules the Railgun SDK loads for its cryptography:

TypeScript
// Initialize Railgun SDK in the browser import { startRailgunEngine, loadProvider, createRailgunWallet } from '@railgun-community/wallet'; async function initializePrivacy(chainId: number) { // Load the proving system (downloads ~40MB of circuit data) await startRailgunEngine( 'voiddex', {}, // No database persistence false, // Not a mobile environment undefined, { // Artifact download configuration downloadUrl: 'https://voiddex.com/artifacts', } ); // Connect to blockchain const provider = await loadProvider({ chainId, provider: window.ethereum, }); return provider; }

Creating the wallet generates the keys that everything else depends on:

TypeScript
async function createPrivateWallet(mnemonic: string) { const wallet = await createRailgunWallet( mnemonic, { // Encryption key for local storage encryptionKey: await deriveEncryptionKey(password), } ); return { // For receiving private transfers railgunAddress: wallet.railgunAddress, // For decrypting your balance viewingKey: wallet.viewingPrivateKey, // For spending (never leaves the browser) spendingKey: wallet.spendingPrivateKey, }; }

The Shield Operation

Shielding moves tokens from your public wallet into the privacy pool:

TypeScript
import { generateShieldProof, populateShield } from '@railgun-community/wallet'; async function shieldTokens( tokenAddress: string, amount: bigint, railgunAddress: string ) { // Step 1: Generate the proof (happens locally) const proof = await generateShieldProof( { chainId, tokenAddress, tokenAmount: amount, railgunAddress, } ); // Step 2: Build the transaction const { transaction } = await populateShield( chainId, proof, ); // Step 3: User signs and broadcasts normally const signer = await getSigner(); const tx = await signer.sendTransaction(transaction); return tx.hash; }

The shield transaction itself is public, so everyone sees that you deposited tokens. What you do with those tokens afterwards is not.

Private Transfers

Once tokens are shielded, you can transfer them privately to other Railgun addresses:

TypeScript
import { generateTransferProof, populateProvedTransaction } from '@railgun-community/wallet'; async function privateTransfer( tokenAddress: string, amount: bigint, recipientRailgunAddress: string ) { // Generate proof that you have sufficient balance const proof = await generateTransferProof( { chainId, tokenAddress, tokenAmounts: [{ tokenAddress, amount }], recipientAddress: recipientRailgunAddress, } ); // Build transaction with proof embedded const { transaction } = await populateProvedTransaction( chainId, proof, ); // Broadcast through Waku P2P (more private than direct RPC) const txHash = await broadcastViaWaku(transaction); return txHash; }

The blockchain sees that someone transferred some amount of tokens, but not who or how much specifically.

Private Swaps via Adapt Contracts

The piece that makes VoidDex possible is Railgun's "adapt contract" system, which lets private funds interact with any DeFi protocol:

Your browser generates a proof that spends your private UTXOs and names the VoidDex Router as the "adapt contract". Railgun verifies that proof and calls the router with the tokens. The router executes the swap on Uniswap, Curve or wherever the route points, and the output tokens are re-shielded into your private balance automatically. The tokens are public only for the length of that one call.

TypeScript
import { generateCrossContractCallProof } from '@railgun-community/wallet'; async function privateSwap( fromToken: string, toToken: string, amount: bigint, routeData: RouteData ) { // Encode the swap call for VoidDex Router const swapCalldata = encodeSwapCall({ tokenIn: fromToken, tokenOut: toToken, amountIn: amount, minAmountOut: routeData.minOutput, dexId: routeData.dexId, dexData: routeData.encodedPath, }); // Generate proof that includes the cross-contract call const proof = await generateCrossContractCallProof( { chainId, tokenAddress: fromToken, tokenAmount: amount, crossContractCalls: [{ to: VOIDDEX_ROUTER_ADDRESS, data: swapCalldata, value: 0n, }], // Where to receive output tokens (back to your private balance) receiverRailgunAddress: myRailgunAddress, receiverTokenAddress: toToken, } ); return broadcastViaWaku(proof); }

Waku P2P Broadcasting

A direct RPC call lets your RPC provider see what you send. For maximum privacy, VoidDex broadcasts over Waku P2P instead:

TypeScript
// Browser sends to Waku network import { Waku } from 'js-waku'; async function broadcastViaWaku(transaction: string) { const waku = await Waku.create(); // Connect to VoidDex broadcaster nodes await waku.dial('/dns4/broadcaster.voiddex.com/tcp/8000/wss/p2p/...'); // Encrypt transaction for broadcaster const encrypted = await encryptForBroadcaster(transaction); // Send to the broadcaster topic await waku.relay.send({ contentTopic: '/voiddex/1/broadcast/proto', payload: encrypted, }); // Wait for confirmation return waitForConfirmation(waku); }

Broadcaster nodes receive the encrypted transactions, verify the proofs, pay the gas and submit to the blockchain. The user pays for that through two fees: a fixed 0.25% Railgun unshield fee that goes to the Railgun protocol, and a dynamic broadcaster fee that varies per transaction with current gas costs.

Balance Scanning

The chain does not store your private balance anywhere you can just read it. The SDK has to scan for encrypted notes addressed to your viewing key and decrypt them:

TypeScript
import { refreshBalances, getWalletBalances } from '@railgun-community/wallet'; async function getPrivateBalance(walletId: string, chainId: number) { // Scan recent blocks for new encrypted notes await refreshBalances(chainId, walletId); // Decrypt notes that belong to you const balances = await getWalletBalances(walletId, chainId); return balances.map(b => ({ token: b.tokenAddress, amount: b.balance, utxoCount: b.utxoCount, })); }

The first load is computationally heavy because it has to process historical blocks. After that the scan is incremental.

Security Considerations

The viewing key is the first thing to protect, because anyone holding it can see your balance. VoidDex encrypts it with your password before storing it in localStorage:

TypeScript
async function secureViewingKey(viewingKey: string, password: string) { const salt = crypto.getRandomValues(new Uint8Array(16)); const key = await deriveKey(password, salt); const encrypted = await encrypt(viewingKey, key); localStorage.setItem('voiddex_vk', JSON.stringify({ salt: Array.from(salt), ciphertext: encrypted, })); }

The spending key never touches VoidDex servers at all. It is derived from your mnemonic and used only in the browser, for proof generation. And whatever the browser sends, every proof is verified on-chain by Railgun's contracts, so an invalid proof is rejected and a malicious input goes nowhere.

Privacy Limitations

Railgun's privacy has edges. Shield and unshield transactions are public, so observers know you are using Railgun. A sophisticated observer might correlate the timing of a shield with a later unshield. If pool liquidity is low, amounts might be inferable. And your RPC provider sees your queries, which Waku mitigates but only if you use it.

Each of those maps to a habit:

  1. Shield tokens and wait before using them
  2. Don't unshield exact amounts you shielded
  3. Use the Waku broadcaster instead of direct RPC
  4. Maintain some shielded balance at all times

Keep reading