Desktop Crypto Wallet Development
Desktop crypto wallet is not just a convenient wrapper over web3 library. It's an application claiming high security that stores private keys in environment potentially infected with malware. Task: ensure secure key storage and convenient UX simultaneously — task where these requirements often contradict.
Examples: Rabby Wallet (Electron + TypeScript), Ledger Live (Electron, hardware wallet integration), Frame (native desktop, privacy focus), Electrum (Bitcoin-specific, one of oldest).
Technology Stack Choice
Electron vs Tauri vs Native
Electron: Node.js + Chromium. Largest ecosystem, any npm-dependencies available. Downsides: large bundle (100–200MB), higher attack surface from Chromium, historical CSP issues.
Tauri: Rust backend + system WebView (WebKit/WebView2/GTK). Small bundle (5–15MB), more secure (Rust memory safety, smaller surface), TypeScript/React frontend via IPC. Recommended for new projects.
Qt / Swift / WPF: native stack. Maximum performance and security, but high development costs, separate codebase for each platform.
For most new desktop wallets — choice between Electron and Tauri depends on size and security requirements.
// Tauri: backend command for transaction signing
// src-tauri/src/main.rs
#[command]
async fn sign_transaction(
tx_hash: String,
wallet_id: String,
password: String
) -> Result<SignatureResult, String> {
let keystore = state.keystore.lock().await;
let wallet: LocalWallet = keystore
.load_wallet(&wallet_id, &password)
.map_err(|e| format!("Failed to load wallet: {}", e))?;
let hash = H256::from_slice(&hex::decode(&tx_hash)?);
let signature = wallet.sign_hash(hash).await?;
Ok(SignatureResult {
v: signature.v,
r: format!("{:x}", signature.r),
s: format!("{:x}", signature.s),
})
}
// Key principle: private key never passed to JavaScript
// All crypto operations — only in Rust backend
Secure Key Storage
Keystore Format and Encryption
Standard approach — ethereum keystore format (EIP-55): key encrypted with user password, result stored in JSON file.
pub struct KeystoreManager {
keystore_dir: PathBuf,
}
impl KeystoreManager {
pub fn create_wallet(&self, password: &str) -> Result<WalletInfo, KeystoreError> {
let mut rng = rand::thread_rng();
let mut private_key = [0u8; 32];
rng.fill_bytes(&mut private_key);
let signing_key = SigningKey::from_bytes(&private_key)?;
let wallet = LocalWallet::from(signing_key);
let address = wallet.address();
let filename = format!("wallet-{}.json", address);
let keystore_path = self.keystore_dir.join(&filename);
encrypt_key(&keystore_path, &mut rng, private_key, password, Some(&filename))?;
private_key.zeroize();
Ok(WalletInfo {
id: filename,
address: format!("{:?}", address),
})
}
}
OS-level Keychain Integration
Additional layer — master password or encryption key in system keychain (macOS Keychain, Windows Credential Manager, Linux Secret Service).
use keyring::Entry;
pub fn store_master_password(wallet_id: &str, password: &str) -> Result<(), KeyringError> {
let entry = Entry::new("crypto-wallet", wallet_id)?;
entry.set_password(password)?;
Ok(())
}
Protects from naive file reading — password inaccessible without system keychain. But doesn't protect from malware with privilege escalation.
Seed Phrase: Storage and Display
BIP-39 mnemonic — only way to recover wallet. Algorithm: mnemonic → seed (PBKDF2) → HD wallet (BIP-32).
// Show seed phrase ONLY in protected window
import * as bip39 from '@scure/bip39';
import { wordlist } from '@scure/bip39/wordlists/english';
import { HDKey } from '@scure/bip32';
function generateWallet() {
return window.__TAURI__.invoke('generate_new_wallet');
}
// Seed displayed only in isolated component
// with explicit user action and auto-hide timer
function SeedPhraseDisplay({ onConfirmed }) {
const [revealed, setRevealed] = useState(false);
const [timeLeft, setTimeLeft] = useState(60);
useEffect(() => {
if (revealed) {
const timer = setInterval(() => {
setTimeLeft(t => {
if (t <= 1) {
setRevealed(false);
return 60;
}
return t - 1;
});
}, 1000);
return () => clearInterval(timer);
}
}, [revealed]);
return (
<div style={{ userSelect: 'none' }}>
{revealed ? (
<>
<p>Hidden in: {timeLeft}s</p>
<SeedWords />
</>
) : (
<button onClick={() => setRevealed(true)}>
Show seed phrase
</button>
)}
</div>
);
}
HD Wallet and Account Management
BIP-44 defines hierarchy: m/44'/60'/account'/change/index for Ethereum. User sees "accounts" — behind them is key tree from single seed.
pub fn derive_accounts(
mnemonic: &str,
count: u32
) -> Result<Vec<DerivedAccount>, DerivationError> {
let mnemonic = Mnemonic::from_phrase(mnemonic, Language::English)?;
let seed = mnemonic.to_seed("");
let root = XPriv::root_from_seed(&seed, None)?;
let mut accounts = Vec::new();
for index in 0..count {
let path = format!("m/44'/60'/0'/0/{}", index);
let child_key = root.derive_path(&path)?;
let wallet = LocalWallet::from(SigningKey::from(child_key.as_ref()));
accounts.push(DerivedAccount {
index,
address: format!("{:?}", wallet.address()),
path,
});
}
Ok(accounts)
}
Network Connection and RPC Providers
Desktop wallet supports multiple networks (Ethereum mainnet, L2: Arbitrum, Optimism, Base, Polygon) with custom RPC ability.
interface NetworkConfig {
chainId: number;
name: string;
rpcUrls: string[];
nativeCurrency: { symbol: string; decimals: number };
blockExplorer?: string;
}
class MultiChainProvider {
private providers: Map<number, ethers.JsonRpcProvider[]> = new Map();
async getProvider(chainId: number): Promise<ethers.JsonRpcProvider> {
const providers = this.providers.get(chainId);
if (!providers?.length) throw new Error(`Unknown chain ${chainId}`);
for (const provider of providers) {
try {
await provider.getBlockNumber();
return provider;
} catch {
continue;
}
}
throw new Error(`No available RPC for chain ${chainId}`);
}
}
Privacy consideration: public RPC providers (Infura, Alchemy) log IP-addresses and wallet addresses. Privacy-oriented wallets use own light client nodes or Tor.
Transaction Signing UI and Phishing Protection
Transaction signing moment — critical UX point. User must understand what's being signed.
interface TransactionPreview {
type: 'ETH_TRANSFER' | 'TOKEN_TRANSFER' | 'CONTRACT_INTERACTION' | 'APPROVE';
to: string;
toLabel?: string;
toRisk: 'safe' | 'unknown' | 'suspicious';
value?: string;
tokenAmount?: string;
estimatedGasUSD: string;
totalCostUSD: string;
decodedCalldata?: { methodName: string; params: Record<string, string> };
warnings: string[];
}
async function buildTransactionPreview(
tx: ethers.TransactionRequest
): Promise<TransactionPreview> {
const warnings: string[] = [];
if (tx.data && tx.data !== '0x') {
const decoded = await decodeTransactionData(tx.data, tx.to);
if (decoded?.methodName === 'approve') {
const amount = decoded.params.amount;
if (BigInt(amount) === MaxUint256) {
warnings.push('⚠️ Unlimited approval');
}
}
}
const toRisk = await checkAddressRisk(tx.to);
if (toRisk === 'suspicious') {
warnings.push('🚨 Address flagged as suspicious');
}
return {
type: determineTransactionType(tx),
to: tx.to,
estimatedGasUSD: await estimateGasInUSD(tx),
totalCostUSD: await calculateTotalCostUSD(tx),
warnings
};
}
dApp Browser and WalletConnect
Modern desktop wallet interacts with dApps via WalletConnect v2: standard protocol via QR-code or deep link. Most universal — supported by thousands dApps.
import { Web3Wallet } from "@walletconnect/web3wallet";
const web3wallet = await Web3Wallet.init({
core: new Core({ projectId: WC_PROJECT_ID }),
metadata: { name: 'My Desktop Wallet', ... }
});
web3wallet.on('session_request', async ({ id, topic, params }) => {
const { request } = params;
if (request.method === 'eth_sendTransaction') {
const preview = await buildTransactionPreview(request.params[0]);
const approved = await showConfirmationDialog(preview);
if (approved) {
const signedTx = await signTransaction(request.params[0]);
await web3wallet.respondSessionRequest({
topic,
response: { id, result: signedTx, jsonrpc: "2.0" }
});
}
}
});
Hardware Wallet Integration
Desktop wallet without Ledger and Trezor support — niche product. Most serious users store significant amounts on hardware.
import TransportNodeHid from '@ledgerhq/hw-transport-node-hid';
import Eth from '@ledgerhq/hw-app-eth';
async function signWithLedger(
txHash: string,
derivationPath: string = "44'/60'/0'/0/0"
): Promise<{ v: number; r: string; s: string }> {
const transport = await TransportNodeHid.create(5000);
const eth = new Eth(transport);
const result = await eth.signTransaction(derivationPath, txHash, null);
return { v: parseInt(result.v, 16), r: result.r, s: result.s };
}
Auto-update and Supply Chain Security
Desktop app updates — each potentially unsafe if compromised. Mandatory measures:
Code signing: Apple Developer (macOS) and EV code signing (Windows). OS warns on unsigned app.
Reproducible builds: CI/CD public, anyone can reproduce and verify hash.
Auto-update via Sparkle (macOS) / Squirrel (Windows): updates with signature check. Tauri has built-in updater.
{
"tauri": {
"updater": {
"active": true,
"endpoints": ["https://releases.mydesktopwallet.com/{{target}}/{{current_version}}"],
"dialog": true,
"pubkey": "PUBLICKEYHERE"
}
}
}
Security Testing
- Verify private key doesn't reach JavaScript context
- Memory dump tests (key not left after use)
- IPC permissions check (Tauri capabilities)
- Penetration testing XSS → key extraction (critical for Electron)
- RPC response spoofing tests (MITM on local network)
Development Stack
Frontend: React 18 + TypeScript + Tailwind CSS, wagmi for web3 abstractions.
Backend: Tauri + Rust for crypto and key storage, or Electron + Node.js with native addons for sensitive ops.
Crypto libraries: ethers.js (JS), ethers-rs (Rust), @scure/bip39 and @scure/bip32 for HD wallet.
Testing: Playwright for E2E, Vitest for unit.
MVP with one network, basic key management, transaction sending — 3–4 months for 2–3 developer team. Production-ready with multi-chain, WalletConnect, hardware wallet, code signing and security audit — 8–12 months.







