Разработка десктопного криптокошелька
Десктопный криптокошелёк — это не просто удобная обёртка над web3 библиотекой. Это приложение с претензией на высокую безопасность, которое хранит приватные ключи в среде, потенциально заражённой malware. Задача: обеспечить безопасное хранение ключей и удобный UX одновременно — задача, в которой эти два требования часто противоречат друг другу.
Примеры эталонных решений: Rabby Wallet (Electron + TypeScript), Ledger Live (Electron, интеграция с hardware wallet), Frame (нативный desktop, фокус на privacy), Electrum (Bitcoin-специфичный, один из старейших).
Выбор технологического стека
Electron vs Tauri vs нативный
Electron: Node.js + Chromium. Наибольшая экосистема, любые npm-зависимости доступны. Недостатки: большой размер бандла (100–200MB), выше attack surface за счёт Chromium, исторически проблемы с CSP.
Tauri: Rust backend + системный WebView (WebKit/WebView2/GTK). Маленький бандл (5–15MB), более безопасный (Rust memory safety, меньший surface), TypeScript/React frontend через IPC. Рекомендован для новых проектов.
Qt / Swift / WPF: нативный стек. Максимальная производительность и безопасность, но высокие затраты разработки, отдельный codebase для каждой платформы.
Для большинства новых десктопных кошельков выбор между Electron и Tauri зависит от требований к размеру и security posture.
// Tauri: backend команда для подписания транзакции
// src-tauri/src/main.rs
use tauri::command;
use ethers::signers::{LocalWallet, Signer};
use ethers::types::{Transaction, H256};
use keystore::KeystoreManager;
#[command]
async fn sign_transaction(
tx_hash: String,
wallet_id: String,
password: String,
state: tauri::State<'_, AppState>
) -> 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).map_err(|e| e.to_string())?
);
// Подпись происходит в Rust процессе, TypeScript не видит ключ
let signature = wallet.sign_hash(hash).await
.map_err(|e| format!("Signing failed: {}", e))?;
Ok(SignatureResult {
v: signature.v,
r: format!("{:x}", signature.r),
s: format!("{:x}", signature.s),
})
}
// Ключевой принцип: приватный ключ никогда не передаётся в JavaScript контекст
// Все криптографические операции — только в Rust backend
Безопасное хранение ключей
Keystore формат и шифрование
Стандартный подход — ethereum keystore формат (EIP-55): ключ шифруется паролем пользователя, результат хранится в JSON файле.
use eth_keystore::{encrypt_key, decrypt_key};
use rand::RngCore;
pub struct KeystoreManager {
keystore_dir: PathBuf,
}
impl KeystoreManager {
pub fn create_wallet(&self, password: &str) -> Result<WalletInfo, KeystoreError> {
// Генерация ключа с OS CSPRNG
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)
)?;
// Очищаем ключ из памяти (best effort в Rust без зашифрованной памяти)
private_key.zeroize();
Ok(WalletInfo {
id: filename,
address: format!("{:?}", address),
})
}
pub fn load_wallet(&self, wallet_id: &str, password: &str)
-> Result<LocalWallet, KeystoreError>
{
let keystore_path = self.keystore_dir.join(wallet_id);
let private_key = decrypt_key(&keystore_path, password)?;
let wallet = LocalWallet::from(
SigningKey::from_bytes(&private_key)?
);
Ok(wallet)
}
}
OS-level Keychain интеграция
Для дополнительного слоя защиты — хранение мастер-пароля или encryption key в системном keychain (macOS Keychain, Windows Credential Manager, Linux Secret Service / KWallet).
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(())
}
pub fn load_master_password(wallet_id: &str) -> Result<String, KeyringError> {
let entry = Entry::new("crypto-wallet", wallet_id)?;
entry.get_password()
}
Это защищает от наивного чтения файлов — без системного keychain пароль недоступен. Но не защищает от malware с privilege escalation.
Seed phrase: хранение и отображение
BIP-39 mnemonic — единственный способ восстановить кошелёк. Алгоритм деривации: mnemonic → seed (PBKDF2) → HD wallet (BIP-32).
// Frontend: показываем seed phrase ТОЛЬКО в защищённом окне
// Не копируем в clipboard без явного разрешения пользователя
import * as bip39 from '@scure/bip39';
import { wordlist } from '@scure/bip39/wordlists/english';
import { HDKey } from '@scure/bip32';
function generateWallet() {
// Генерация происходит через Tauri backend
// Frontend получает только адреса и публичные ключи
return window.__TAURI__.invoke('generate_new_wallet');
}
// Seed phrase отображается только в изолированном компоненте
// с явным user action для отображения и таймером автоскрытия
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]);
// Блокируем скриншоты через CSS (не 100% защита, но лучше чем ничего)
return (
<div style={{ userSelect: 'none', WebkitUserSelect: 'none' }}>
{revealed ? (
<>
<p>Скрывается через: {timeLeft}с</p>
<SeedWords />
</>
) : (
<button onClick={() => setRevealed(true)}>
Показать seed phrase
</button>
)}
</div>
);
}
HD Wallet и управление аккаунтами
BIP-44 определяет иерархию деривации: m/44'/60'/account'/change/index для Ethereum. Пользователь видит «аккаунты», за которыми стоят дерево дочерних ключей от одного seed.
use bip39::{Mnemonic, Language};
use hdpath::StandardHDPath;
use coins_bip32::XPriv;
pub fn derive_accounts(
mnemonic: &str,
password: &str,
count: u32
) -> Result<Vec<DerivedAccount>, DerivationError> {
let mnemonic = Mnemonic::from_phrase(mnemonic, Language::English)?;
let seed = mnemonic.to_seed(password);
let root = XPriv::root_from_seed(&seed, None)?;
let mut accounts = Vec::new();
for index in 0..count {
// BIP-44 путь: m/44'/60'/0'/0/index
let path = format!("m/44'/60'/0'/0/{}", index);
let child_key = root.derive_path(&path)?;
let signing_key = child_key.as_ref();
let wallet = LocalWallet::from(SigningKey::from(signing_key));
accounts.push(DerivedAccount {
index,
address: format!("{:?}", wallet.address()),
path,
});
}
Ok(accounts)
}
Подключение к сетям и RPC провайдеры
Десктопный кошелёк должен поддерживать несколько сетей (Ethereum mainnet, L2: Arbitrum, Optimism, Base, Polygon) с возможностью добавления кастомных RPC.
interface NetworkConfig {
chainId: number;
name: string;
rpcUrls: string[]; // Несколько URL для failover
nativeCurrency: {
symbol: string;
decimals: number;
};
blockExplorer?: string;
}
const DEFAULT_NETWORKS: NetworkConfig[] = [
{
chainId: 1,
name: 'Ethereum Mainnet',
rpcUrls: [
'https://eth.llamarpc.com',
'https://cloudflare-eth.com',
'https://rpc.ankr.com/eth'
],
nativeCurrency: { symbol: 'ETH', decimals: 18 },
blockExplorer: 'https://etherscan.io'
},
{
chainId: 42161,
name: 'Arbitrum One',
rpcUrls: ['https://arb1.arbitrum.io/rpc', 'https://arbitrum.llamarpc.com'],
nativeCurrency: { symbol: 'ETH', decimals: 18 },
blockExplorer: 'https://arbiscan.io'
}
];
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}`);
// Проверяем провайдеры по очереди (failover)
for (const provider of providers) {
try {
await provider.getBlockNumber();
return provider;
} catch {
continue;
}
}
throw new Error(`No available RPC for chain ${chainId}`);
}
}
Privacy соображения: публичные RPC провайдеры (Infura, Alchemy, QuickNode) логируют IP-адреса и адреса кошельков. Privacy-ориентированные кошельки используют собственные light client ноды или onion-routing (через Tor).
Transaction signing UI и защита от phishing
Момент подписания транзакции — критическая точка UX. Пользователь должен понимать, что именно подписывает.
interface TransactionPreview {
type: 'ETH_TRANSFER' | 'TOKEN_TRANSFER' | 'CONTRACT_INTERACTION' | 'APPROVE';
to: string;
toLabel?: string; // ENS или адресная книга
toRisk: 'safe' | 'unknown' | 'suspicious'; // Проверка по базам scam адресов
value?: string; // Количество ETH
tokenAmount?: string; // Количество токена
tokenSymbol?: string;
estimatedGasUSD: string;
totalCostUSD: string;
decodedCalldata?: { // Декодированные параметры вызова
methodName: string;
params: Record<string, string>;
};
warnings: string[]; // Предупреждения (unlimited approve, suspicious contract и т.д.)
}
async function buildTransactionPreview(
tx: ethers.TransactionRequest,
chainId: number
): Promise<TransactionPreview> {
const warnings: string[] = [];
// Декодируем calldata если есть
let decodedCalldata;
if (tx.data && tx.data !== '0x') {
decodedCalldata = await decodeTransactionData(tx.data, tx.to, chainId);
// Предупреждение на unlimited approve
if (decodedCalldata?.methodName === 'approve') {
const amount = decodedCalldata.params.amount;
if (BigInt(amount) === MaxUint256) {
warnings.push('⚠️ Unlimited approval: это позволяет контракту тратить все ваши токены');
}
}
}
// Проверка адреса получателя по blocklist
const toRisk = await checkAddressRisk(tx.to, chainId);
if (toRisk === 'suspicious') {
warnings.push('🚨 Этот адрес отмечен как подозрительный в базах scam-репортов');
}
// ENS резолюция
const toLabel = await resolveENS(tx.to, chainId);
return {
type: determineTransactionType(tx),
to: tx.to,
toLabel,
toRisk,
estimatedGasUSD: await estimateGasInUSD(tx, chainId),
totalCostUSD: await calculateTotalCostUSD(tx, chainId),
decodedCalldata,
warnings
};
}
DApp Browser и WalletConnect
Современный десктопный кошелёк должен взаимодействовать с dApps. Два пути:
WalletConnect v2: стандартный протокол соединения кошелька с dApp через QR-код или deep link. Наиболее универсален — поддерживается тысячами dApps.
import { WalletConnectModal } from '@walletconnect/modal';
import { Web3Wallet } from '@walletconnect/web3wallet';
const web3wallet = await Web3Wallet.init({
core: new Core({ projectId: WALLETCONNECT_PROJECT_ID }),
metadata: {
name: 'My Desktop Wallet',
description: 'Secure Desktop Crypto Wallet',
url: 'https://mydesktopwallet.com',
icons: ['https://mydesktopwallet.com/icon.png']
}
});
// Обработка запросов от dApp
web3wallet.on('session_request', async ({ id, topic, params }) => {
const { request } = params;
if (request.method === 'eth_sendTransaction') {
// Показываем TransactionPreview пользователю
const preview = await buildTransactionPreview(request.params[0], chainId);
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' }
});
} else {
await web3wallet.respondSessionRequest({
topic,
response: {
id,
error: { code: 4001, message: 'User rejected' },
jsonrpc: '2.0'
}
});
}
}
});
Интеграция с Hardware Wallets
Десктопный кошелёк без поддержки Ledger и Trezor — это нишевый продукт. Большинство серьёзных пользователей хранят значительные суммы на 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);
try {
// Показываем пользователю инструкцию подтвердить на устройстве
const result = await eth.signTransaction(
derivationPath,
txHash,
null
);
return {
v: parseInt(result.v, 16),
r: result.r,
s: result.s
};
} finally {
await transport.close();
}
}
Auto-update и безопасность поставок
Десктопное приложение обновляется — и каждое обновление потенциально небезопасно, если процесс скомпрометирован. Обязательные меры:
Code signing: подпись бинарников сертификатами Apple Developer (macOS) и EV code signing certificate (Windows). Операционная система предупреждает пользователя при запуске неподписанного приложения.
Reproducible builds: CI/CD конфигурация публична, любой может воспроизвести сборку и верифицировать hash бинарника.
Auto-update через Sparkle (macOS) / Squirrel (Windows): обновления с проверкой подписи. Tauri имеет встроенный updater.
// tauri.conf.json — updater config
{
"tauri": {
"updater": {
"active": true,
"endpoints": [
"https://releases.mydesktopwallet.com/{{target}}/{{current_version}}"
],
"dialog": true,
"pubkey": "PUBLICKEYHERE"
}
}
}
Тестирование безопасности
Специфические тесты для десктопного кошелька:
- Проверка, что приватный ключ не попадает в JavaScript контекст
- Тест на memory dumps (ключ не остаётся в памяти после использования)
- Проверка IPC permissions (Tauri: capabilities config — какие backend команды доступны frontend)
- Penetration testing на XSS → key extraction (для Electron — наиболее критичный вектор)
- Тест на подмену RPC ответов (man-in-the-middle на уровне локальной сети)
Стек и сроки разработки
Frontend: React 18 + TypeScript + Tailwind CSS, wagmi для web3 абстракций.
Backend: Tauri + Rust для криптографии и хранения ключей, или Electron + Node.js с native addons для sensitive операций.
Крипто-библиотеки: ethers.js (JS), ethers-rs / alloy (Rust), @scure/bip39 и @scure/bip32 для HD wallet.
Тестирование: Playwright для E2E, Vitest для unit тестов.
MVP с поддержкой одной сети, базовым управлением ключами и отправкой транзакций — 3–4 месяца для команды из 2–3 разработчиков. Production-ready кошелёк с multi-chain, WalletConnect, hardware wallet интеграцией, code signing и аудитом безопасности — 8–12 месяцев.







