Crypto Wallet Development
A crypto wallet is not a coin storage. Coins live on the blockchain. The wallet stores private keys and signs transactions. This fundamental difference defines the entire architecture: the main task is secure key management, not balance storage. Improper key handling makes any beautiful UI meaningless — if the seed phrase is stored in localStorage or sent to a server, the wallet is insecure regardless of everything else.
Wallet development divides into two fundamentally different classes: custodial (service holds keys) and non-custodial (user holds keys). This is not just a technical choice — it's a legal and business decision with different compliance requirements.
Wallet Types and Architectural Solutions
HD Wallet (Hierarchical Deterministic, BIP-32/BIP-44)
The standard for most non-custodial wallets. From one seed phrase (12/24 BIP-39 words), a tree of keys is derived. Each chain, each account, each address — separate child key.
import { ethers } from 'ethers';
import * as bip39 from 'bip39';
// Seed phrase generation
const mnemonic = bip39.generateMnemonic(256); // 24 words
// "word1 word2 ... word24"
// HD wallet derivation
const hdNode = ethers.HDNodeWallet.fromPhrase(mnemonic);
// Account by standard derivation path (BIP-44)
// m/44'/60'/0'/0/0 — first Ethereum account
const wallet = hdNode.derivePath("m/44'/60'/0'/0/0");
console.log(wallet.address); // 0x...
console.log(wallet.privateKey); // 0x... (NEVER show)
// Next accounts
const wallet1 = hdNode.derivePath("m/44'/60'/0'/0/1");
const wallet2 = hdNode.derivePath("m/44'/60'/0'/0/2");
// Different chains (SLIP-44 coin types):
// ETH: m/44'/60'/...
// BTC: m/44'/0'/...
// Solana: m/44'/501'/...
// Cosmos: m/44'/118'/...
One seed phrase → all wallets for all chains. User only needs to remember 12/24 words.
Secure Key Storage on Device
The most critical place. Multiple layers of protection:
iOS/Android: Secure Enclave / Keystore
// React Native: expo-secure-store or @react-native-async-storage
// + OS-level encryption
import * as SecureStore from 'expo-secure-store';
import * as LocalAuthentication from 'expo-local-authentication';
import CryptoJS from 'crypto-js';
async function storeEncryptedMnemonic(mnemonic: string, pin: string): Promise<void> {
// Derive encryption key from PIN via PBKDF2
const salt = CryptoJS.lib.WordArray.random(128 / 8).toString();
const key = CryptoJS.PBKDF2(pin, salt, {
keySize: 256 / 32,
iterations: 100000, // 100K iterations — slow for attacker, acceptable for user
});
const encrypted = CryptoJS.AES.encrypt(mnemonic, key.toString()).toString();
// Save to Secure Enclave (iOS) or Android Keystore
await SecureStore.setItemAsync('encrypted_mnemonic', encrypted);
await SecureStore.setItemAsync('pbkdf2_salt', salt);
}
async function loadMnemonic(pin: string): Promise<string | null> {
// Optional: biometrics before decryption
const biometricResult = await LocalAuthentication.authenticateAsync({
promptMessage: 'Confirm identity',
});
if (!biometricResult.success) return null;
const encrypted = await SecureStore.getItemAsync('encrypted_mnemonic');
const salt = await SecureStore.getItemAsync('pbkdf2_salt');
if (!encrypted || !salt) return null;
const key = CryptoJS.PBKDF2(pin, salt, { keySize: 256/32, iterations: 100000 });
const bytes = CryptoJS.AES.decrypt(encrypted, key.toString());
return bytes.toString(CryptoJS.enc.Utf8);
}
Web/Extension: don't store private key in localStorage
Private key in browser extension is stored in encrypted Chrome storage (chrome.storage.local) with user password. MetaMask uses this approach with PBKDF2 + AES-256-GCM.
Important: key should be in memory only while wallet is unlocked. When locked — key is removed from memory, only encrypted blob remains.
Hardware Wallet Integration: HID/WebUSB
import TransportWebUSB from '@ledgerhq/hw-transport-webusb';
import Eth from '@ledgerhq/hw-app-eth';
async function signWithLedger(
derivationPath: string,
transaction: ethers.TransactionRequest
): Promise<string> {
const transport = await TransportWebUSB.create();
const eth = new Eth(transport);
// Get address from Ledger (verification)
const { address } = await eth.getAddress(derivationPath);
console.log('Ledger address:', address);
// Serialize transaction for signing
const unsignedTx = ethers.Transaction.from(transaction);
const serialized = ethers.getBytes(unsignedTx.unsignedSerialized);
// Sign on device — private key never leaves Ledger
const signature = await eth.signTransaction(derivationPath, Buffer.from(serialized).toString('hex'), null);
const signedTx = ethers.Transaction.from({
...transaction,
signature: {
r: '0x' + signature.r,
s: '0x' + signature.s,
v: parseInt(signature.v, 16),
},
});
return signedTx.serialized;
}
Multi-chain Support
Modern wallet supports EVM-compatible networks (Ethereum, Polygon, Arbitrum, BSC, Avalanche — one key, different RPCs) and non-EVM (Solana, Bitcoin, Cosmos — different signing algorithms).
EVM chains: single key
class EVMWalletProvider {
private wallet: ethers.Wallet;
constructor(privateKey: string) {
this.wallet = new ethers.Wallet(privateKey);
}
getProvider(chainId: number): ethers.Provider {
const rpcUrls: Record<number, string> = {
1: 'https://eth-mainnet.alchemyapi.io/v2/...',
137: 'https://polygon-mainnet.alchemyapi.io/v2/...',
42161: 'https://arb-mainnet.g.alchemy.com/v2/...',
8453: 'https://base-mainnet.g.alchemy.com/v2/...',
};
return new ethers.JsonRpcProvider(rpcUrls[chainId]);
}
async sendTransaction(
tx: ethers.TransactionRequest,
chainId: number
): Promise<ethers.TransactionResponse> {
const provider = this.getProvider(chainId);
const connectedWallet = this.wallet.connect(provider);
return connectedWallet.sendTransaction({ ...tx, chainId });
}
}
Token Balances: Request Batching
Separate RPC call per token per chain = huge delay. Multicall3 allows getting all balances in one call:
import { createPublicClient, http } from 'viem';
import { mainnet } from 'viem/chains';
import { erc20Abi } from 'viem';
async function getAllTokenBalances(
address: `0x${string}`,
tokenAddresses: `0x${string}`[]
) {
const client = createPublicClient({ chain: mainnet, transport: http(RPC_URL) });
// Multicall: one request for N tokens
const results = await client.multicall({
contracts: tokenAddresses.map(token => ({
address: token,
abi: erc20Abi,
functionName: 'balanceOf',
args: [address],
})),
});
return tokenAddresses.map((addr, i) => ({
token: addr,
balance: results[i].status === 'success' ? results[i].result : 0n,
}));
}
Transaction Simulation
Before sending, show user what will happen. Tenderly Simulation API or Alchemy's alchemy_simulateAssetChanges:
async function simulateTransaction(tx: ethers.TransactionRequest): Promise<SimulationResult> {
const response = await fetch(`https://eth-mainnet.g.alchemy.com/v2/${ALCHEMY_KEY}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
id: 1,
jsonrpc: '2.0',
method: 'alchemy_simulateAssetChanges',
params: [{
from: tx.from,
to: tx.to,
data: tx.data,
value: tx.value ? `0x${BigInt(tx.value).toString(16)}` : '0x0',
}],
}),
});
const result = await response.json();
// result.result.changes: list of balance changes
// result.result.error: if transaction reverts
return {
willSucceed: !result.result.error,
balanceChanges: result.result.changes,
gasEstimate: result.result.gasUsed,
};
}
If simulation shows the transaction reverts or unexpectedly drains the balance — warn the user before real sending.
WalletConnect v2 Integration
Standard wallet-to-dApp connection protocol:
import { WalletKit } from '@reown/walletkit';
import { Core } from '@walletconnect/core';
const core = new Core({ projectId: PROJECT_ID });
const walletKit = await WalletKit.init({ core, metadata: { name: 'My Wallet', ... } });
// Handle connection request from dApp
walletKit.on('session_proposal', async ({ id, params }) => {
// Show user: dApp requests connection
const userApproved = await showConnectionModal(params);
if (userApproved) {
await walletKit.approveSession({
id,
namespaces: {
eip155: {
chains: ['eip155:1', 'eip155:137'],
methods: ['eth_sendTransaction', 'personal_sign', 'eth_signTypedData_v4'],
events: ['accountsChanged', 'chainChanged'],
accounts: ['eip155:1:' + walletAddress, 'eip155:137:' + walletAddress],
},
},
});
}
});
// Handle signature request from dApp
walletKit.on('session_request', async ({ id, params }) => {
const { method, params: reqParams } = params.request;
if (method === 'eth_sendTransaction') {
const tx = reqParams[0];
const simulation = await simulateTransaction(tx);
// Show user: what does the transaction do
const userApproved = await showTransactionModal(tx, simulation);
if (userApproved) {
const signedTx = await wallet.sendTransaction(tx);
await walletKit.respondSessionRequest({ id, response: { result: signedTx.hash } });
}
}
});
Security: Checklist
| Item | Description |
|---|---|
| Seed storage | PBKDF2 + AES-256-GCM, storage in Secure Enclave/Keystore |
| Memory security | Private key in memory only when unlocked |
| Screen capture | Block screenshots when displaying seed phrase |
| Clipboard | Clear buffer after 60 seconds of copying |
| Transaction simulation | Warn on revert or drain |
| Phishing protection | Verify dApp URL, warn about unknown contracts |
| Biometrics | Optional biometric protection for opening |
| Transport | Only HTTPS/WSS, certificate pinning for mobile |
| Dependency audit | npm audit / Snyk on all dependencies |
Technology Stack
Mobile (React Native): React Native + expo-secure-store + ethers.js v6 + viem + WalletConnect SDK + Reown AppKit.
Browser Extension (Chrome/Firefox): React + WebExtension API + chrome.storage + MetaMask Snap API (for extending existing wallets).
Web-based (PWA): Next.js + wagmi + viem + WalletConnect — for custodial or MPC-based wallets where keys are not stored in browser.
Work Process
Architectural Decision (1 week). Custodial or non-custodial. Mobile, web or extension. List of supported chains and tokens. Hardware wallet integration.
Core Development (3-4 weeks). Key management, HD wallet, transaction signing, multi-chain support.
UI Development (2-3 weeks). Onboarding (seed generation/import), portfolio view, send/receive, transaction history, dApp browser.
Security Review (1-2 weeks). Penetration testing of key functions, seed storage audit.
Testing and Launch (1-2 weeks). TestFlight/Play Store beta, mainnet tests with small amounts.
Full cycle for mobile wallet: 3-4 months. Cost depends on feature set and platforms.







