Разработка криптокошелька-расширения для браузера

Разработка криптокошелька-расширения для браузера — пожалуй, самая сложная задача среди crypto-клиентов. В отличие от мобильного приложения, расширение работает в трёх изолированных контекстах: background service worker, popup и content script. При этом каждое dApp ожидает стандартизированный интерф

Направления блокчейн-разработки

Часто задаваемые вопросы

Последние работы

  • image_website-b2b-advance_0.webp
    Разработка сайта компании B2B ADVANCE
    1441
  • image_web-applications_feedme_466_0.webp
    Разработка веб-приложения для компании FEEDME
    1301
  • image_websites_belfingroup_462_0.webp
    Разработка веб-сайта для компании БЕЛФИНГРУПП
    998
  • image_ecommerce_furnoro_435_0.webp
    Разработка интернет магазина для компании FURNORO
    1267
  • image_logo-advance_0.webp
    Разработка логотипа компании B2B Advance
    713
  • image_crm_enviok_479_0.webp
    Разработка веб-приложения для компании Enviok
    1003

Разработка криптокошелька-расширения для браузера — пожалуй, самая сложная задача среди 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 для защиты ключей — это проверенный стандарт.