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







