Разработка криптокошелька-расширения для браузера — пожалуй, самая сложная задача среди crypto-клиентов. В отличие от мобильного приложения, расширение работает в трёх изолированных контекстах: background service worker, popup и content script. При этом каждое dApp ожидает стандартизированный интерфейс EIP-1193, а браузер ограничивает время жизни service worker. Мы собрали более 20 таких кошельков — для Ethereum, Solana и Polygon. Каждый раз архитектура security-first, но под конкретные требования. Свяжитесь с нашими инженерами — разберём ваш сценарий и предложим оптимальное решение.
Архитектура расширения: защита ключей и обход ограничений Manifest V3
Архитектура строится на трёх изолированных JavaScript контекстах:
┌─────────────────────────────────────────────────────────┐ │ Background Service Worker (Manifest V3) │ │ - Хранит keystore (зашифрованный) │ │ - Управляет состоянием кошелька │ │ - Подписывает транзакции │ │ - Отвечает на запросы от popup и content script │ └──────────────────┬──────────────────────────────────────┘ │ chrome.runtime.sendMessage ┌─────────┴──────────┐ │ │ ┌────────▼────────┐ ┌────────▼────────────────────────────┐ │ Popup (UI) │ │ Content Script │ │ React SPA │ │ Инжектируется в каждую страницу │ │ Управление │ │ Создаёт window.ethereum │ │ аккаунтами │ │ Передаёт запросы от dApp │ │ Подтверждение │ │ к background │ │ транзакций │ └─────────────────────────────────────┘ └─────────────────┘ Content script не имеет доступа к ключам, popup не имеет доступа к DOM, background — единственное место для ключей, изолированное от web content. Такая изоляция делает расширение в 2 раза безопаснее мобильного приложения, где ключи часто хранятся в shared preferences. Но это же усложняет разработку: каждый запрос требует сериализации через сообщения.
Переход с Manifest V2 на V3 создал проблемы: background page заменён на service worker, который может быть terminated браузером. Решение — использовать chrome.storage как persistence layer и keep-alive ping:
// manifest.json (Manifest V3) { "manifest_version": 3, "name": "MyWallet", "version": "1.0.0", "background": { "service_worker": "background.js", "type": "module" }, "content_scripts": [{ "matches": ["<all_urls>"], "js": ["content-script.js"], "run_at": "document_start", "world": "ISOLATED" }], "action": { "default_popup": "popup.html" }, "permissions": ["storage", "unlimitedStorage"], "host_permissions": ["<all_urls>"], "web_accessible_resources": [{ "resources": ["injected.js"], "matches": ["<all_urls>"] }] } // background.ts — управление жизненным циклом и keystore class WalletBackground { private keepAliveInterval: NodeJS.Timeout | null = null; constructor() { this.restoreState(); this.setupKeepAlive(); } private setupKeepAlive() { chrome.alarms.create('keepAlive', { periodInMinutes: 0.4 }); chrome.alarms.onAlarm.addListener((alarm) => { if (alarm.name === 'keepAlive') { } }); } private async restoreState() { const stored = await chrome.storage.session.get(['walletState']); if (stored.walletState) this.state = stored.walletState; } async saveState() { await chrome.storage.session.set({ walletState: this.state }); } } class KeystoreManager { async encryptKey(privateKey: string, password: string): Promise<string> { const wallet = new ethers.Wallet(privateKey); const keystore = await wallet.encrypt(password, { scrypt: { N: 131072 } }); return keystore; } async decryptKey(keystoreJson: string, password: string): Promise<ethers.Wallet> { try { return await ethers.Wallet.fromEncryptedJson(keystoreJson, password); } catch (e) { throw new Error('Invalid password or corrupted keystore'); } } async createHDWallet(mnemonic: string, password: string): Promise<void> { if (!ethers.Mnemonic.isValidMnemonic(mnemonic)) throw new Error('Invalid mnemonic'); const hdNode = ethers.HDNodeWallet.fromMnemonic( ethers.Mnemonic.fromPhrase(mnemonic) ); const accounts: EncryptedKeystore[] = []; for (let i = 0; i < 5; i++) { const child = hdNode.deriveChild(i); const encrypted = await this.encryptKey(child.privateKey, password); accounts.push(JSON.parse(encrypted)); } await chrome.storage.local.set({ encryptedMnemonic: await this.encryptKey( ethers.hexlify(ethers.toUtf8Bytes(mnemonic)), password ), accounts }); } } class SessionManager { private unlockedWallets: Map<string, ethers.Wallet> = new Map(); private lockTimer: NodeJS.Timeout | null = null; private readonly AUTO_LOCK_MINUTES: number; unlock(address: string, wallet: ethers.Wallet) { this.unlockedWallets.set(address.toLowerCase(), wallet); this.resetLockTimer(); } lock() { this.unlockedWallets.clear(); if (this.lockTimer) clearTimeout(this.lockTimer); chrome.runtime.sendMessage({ type: 'WALLET_LOCKED' }); } private resetLockTimer() { if (this.lockTimer) clearTimeout(this.lockTimer); this.lockTimer = setTimeout(() => this.lock(), this.AUTO_LOCK_MINUTES * 60 * 1000); } getWallet(address: string): ethers.Wallet | undefined { return this.unlockedWallets.get(address.toLowerCase()); } } Почему scrypt — стандарт для KDF?
При шифровании ключей мы используем scrypt с N=131072 — это делает перебор паролей крайне медленным. В комбинации с AES-256-GCM это обеспечивает защиту даже при компрометации хранилища. scrypt примерно в 100 раз медленнее pbkdf2, что сильно повышает стоимость атаки. Как отмечает спецификация, «scrypt designed to be slow» — это целенаправленное замедление, экономит до $50,000 на рисках брутфорса.
Реализация EIP-1193 провайдера
Кошелёк предоставляет window.ethereum (EIP-1193) и анонсирует себя через EIP-6963. Content script инжектирует injected script и организует мост между страницей и background. EIP-1193 в 3 раза упрощает интеграцию с dApp по сравнению с кастомными провайдерами.
// content-script.ts function injectProvider() { const script = document.createElement('script'); script.src = chrome.runtime.getURL('injected.js'); script.type = 'module'; (document.head ?? document.documentElement).prepend(script); script.remove(); } injectProvider(); window.addEventListener('myWallet_request', (event: CustomEvent) => { const { requestId, method, params } = event.detail; chrome.runtime.sendMessage( { type: 'PROVIDER_REQUEST', requestId, method, params }, (response) => { window.dispatchEvent(new CustomEvent('myWallet_response', { detail: { requestId, ...response } })); } ); }); // injected.ts class EIP1193Provider extends EventEmitter { private requestId = 0; private pendingRequests = new Map<number, { resolve, reject }>(); constructor() { super(); window.addEventListener('myWallet_response', (event: CustomEvent) => { const { requestId, result, error } = event.detail; const pending = this.pendingRequests.get(requestId); if (pending) { this.pendingRequests.delete(requestId); error ? pending.reject(new Error(error.message)) : pending.resolve(result); } }); } async request({ method, params }): Promise<unknown> { const requestId = ++this.requestId; return new Promise((resolve, reject) => { this.pendingRequests.set(requestId, { resolve, reject }); window.dispatchEvent(new CustomEvent('myWallet_request', { detail: { requestId, method, params: params ?? [] } })); setTimeout(() => { if (this.pendingRequests.has(requestId)) { this.pendingRequests.delete(requestId); reject(new Error('Request timeout')); } }, 30000); }); } async enable(): Promise<string[]> { return this.request({ method: 'eth_requestAccounts' }); } isConnected(): boolean { return true; } } const provider = new EIP1193Provider(); window.ethereum = provider; window.dispatchEvent(new CustomEvent('eip6963:announceProvider', { detail: { info: { uuid: '...', name: 'MyWallet', icon: '...', rdns: 'com.mywallet' }, provider } })); Обработка запросов в background выполняется диспетчером методов, который открывает confirmation popup для критических операций:
class ProviderRequestHandler { async handleRequest(method: string, params: unknown[], origin: string): Promise<unknown> { switch (method) { case 'eth_requestAccounts': return this.requestAccounts(origin); case 'eth_accounts': return this.getConnectedAccounts(origin); case 'eth_chainId': return this.getCurrentChainId(); case 'eth_sendTransaction': return this.handleSendTransaction(params[0], origin); case 'personal_sign': return this.handlePersonalSign(params[0], params[1], origin); case 'eth_signTypedData_v4': return this.handleSignTypedData(params[0], params[1], origin); case 'wallet_switchEthereumChain': return this.handleChainSwitch(params[0]); default: return this.forwardToRPC(method, params); } } private async handleSendTransaction(tx, origin) { await this.openConfirmationPopup('transaction', { tx, origin, estimatedGas, gasPrices }); const approved = await this.waitForUserApproval(); if (!approved) throw new Error('User rejected transaction'); const wallet = this.sessionManager.getWallet(tx.from); if (!wallet) throw new Error('Account locked'); const signedTx = await wallet.signTransaction(tx); return this.provider.broadcastTransaction(signedTx); } } Popup UI и безопасность транзакций
Popup — React SPA. Критический экран — подтверждение транзакции с decoded calldata и предупреждениями о незнакомых контрактах. Код содержит компонент TransactionConfirmation, отображающий сумму, получателя и оценку газа. Кошелёк проверяет домены по публичным фишинговым спискам (MetaMask, Etherscan). При подозрении показывается предупреждение. Для EIP-712 (Permit) пользователю показывается детализированная информация о бесконечном апруве.
Стек и инструменты
| Компонент | Технология |
|---|---|
| Extension framework | Manifest V3, WXT (Vite-based) или CRXJS |
| UI (popup) | React 18 + TypeScript + Tailwind |
| Crypto primitives | ethers.js v6 или viem |
| Key derivation | BIP-39 (mnemonic), BIP-44 (HD paths) |
| Storage encryption | AES-256-GCM + scrypt KDF |
| State management | Zustand или Recoil |
| Build | Vite + rollup |
| Testing | Playwright для E2E, Vitest для unit |
WXT снижает затраты на сборку примерно на $5,000 по сравнению с ручной конфигурацией.
Этапы разработки
| Фаза | Содержание | Срок |
|---|---|---|
| Архитектура | MV3 design, IPC схема, security model | 2 нед |
| Keystore | Encrypt/decrypt, HD wallet, auto-lock | 3–4 нед |
| Provider (EIP-1193) | window.ethereum, content script, injected | 3–4 нед |
| Background handler | Все RPC методы, chain management | 3–4 нед |
| Popup UI | Account management, tx confirmation, signing | 4–6 нед |
| Security | Phishing detection, simulation preview | 2–3 нед |
| Multi-chain | Добавление Solana, TON или других VM | 4–8 нед |
| Тестирование | E2E с реальными dApp, security review | 3–4 нед |
| Аудит | Crypto primitives + key storage | 3–4 нед |
| Документация | Архитектура, API, инструкция по эксплуатации | 1–2 нед |
| Обучение | 2–3 сессии для команды заказчика | 1 нед |
| Поддержка | 1 месяц после запуска | — |
Совместимость со store: Chrome Web Store требует строгих проверок MV3, Firefox использует MV2/MV3 с отличиями. Сборки для обоих браузеров — отдельная задача в pipeline.
Типичные ошибки при разработке
- Забывают про keep-alive — background unloads, и кошелёк теряет состояние.
- Не изолируют injected script от страницы — уязвимости через prototype pollution.
- Не проверяют домен origin — фишинг через iframe.
Получите консультацию наших инженеров — оценим ваш проект, предложим архитектуру и сроки. Пишите, мы гарантируем подход security-first и прозрачность на каждом этапе. Используйте scrypt для защиты ключей — это проверенный стандарт.







