Разработка кошелька-расширения для браузера
Браузерный кошелёк — самый сложный тип crypto-клиента не потому что там много математики, а потому что он работает в нескольких изолированных контекстах одновременно и должен быть security-first при взаимодействии с любым сайтом в интернете. MetaMask, Phantom, Rabby — каждый из них решает одну и ту же задачу: дать dApp доступ к подписанию транзакций не давая dApp доступа к приватному ключу.
Архитектура расширения: три контекста
Браузерное расширение существует в трёх изолированных JavaScript контекстах с разными привилегиями и ограничениями:
┌─────────────────────────────────────────────────────────┐
│ Background Service Worker (Manifest V3) │
│ - Хранит keystore (зашифрованный) │
│ - Управляет состоянием кошелька │
│ - Подписывает транзакции │
│ - Отвечает на запросы от popup и content script │
└──────────────────┬──────────────────────────────────────┘
│ chrome.runtime.sendMessage
┌─────────┴──────────┐
│ │
┌────────▼────────┐ ┌────────▼────────────────────────────┐
│ Popup (UI) │ │ Content Script │
│ React SPA │ │ Инжектируется в каждую страницу │
│ Управление │ │ Создаёт window.ethereum │
│ аккаунтами │ │ Передаёт запросы от dApp │
│ Подтверждение │ │ к background │
│ транзакций │ └─────────────────────────────────────┘
└─────────────────┘
Это не детали реализации — это security model. Content script не имеет доступа к ключам. Popup не имеет доступа к DOM страницы. Background — единственное место где живут ключи, и он изолирован от web content полностью.
Manifest V3: ограничения и обходы
Переход Chrome с Manifest V2 на V3 создал серьёзные проблемы для кошельков. Background page (persistent) заменён на service worker (ephemeral). Service worker может быть terminated браузером в любой момент — он не держит state постоянно.
// 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>"]
}]
}
Service worker termination проблема решается через chrome.storage как persistence layer и keep-alive ping:
// background.ts — управление жизненным циклом service worker
class WalletBackground {
private keepAliveInterval: NodeJS.Timeout | null = null;
constructor() {
// Восстанавливаем state после возможного restart
this.restoreState();
// Keep-alive для Chrome MV3 (SW может быть terminated)
this.setupKeepAlive();
}
private setupKeepAlive() {
// Periodic alarm не даёт SW быть terminated надолго
chrome.alarms.create('keepAlive', { periodInMinutes: 0.4 });
chrome.alarms.onAlarm.addListener((alarm) => {
if (alarm.name === 'keepAlive') {
// Просто активируем SW
}
});
}
private async restoreState() {
// State хранится в chrome.storage.session (не persists через restart браузера)
// или chrome.storage.local (persists, но зашифровать обязательно)
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 });
}
}
Keystore: хранение и шифрование приватных ключей
Главный вопрос безопасности: как хранить приватный ключ. Ключи никогда не хранятся в plaintext — только в зашифрованном виде.
import { ethers } from 'ethers';
interface EncryptedKeystore {
version: number;
id: string;
crypto: {
ciphertext: string;
cipherparams: { iv: string };
cipher: string;
kdf: string;
kdfparams: {
dklen: number;
salt: string;
n: number; // N для scrypt — работа KDF
r: number;
p: number;
};
mac: string;
};
}
class KeystoreManager {
// Encrypt: password → encrypts private key → keystore JSON
async encryptKey(privateKey: string, password: string): Promise<string> {
// ethers.js использует EIP-55 keystore формат (совместим с geth)
const wallet = new ethers.Wallet(privateKey);
// N=131072 для production (медленнее, но безопаснее против brute force)
// N=1024 для тестов (быстрее)
const keystore = await wallet.encrypt(password, {
scrypt: { N: 131072 }
});
return keystore;
}
// Decrypt: password + keystore → private key (только в памяти, не сохраняем)
async decryptKey(keystoreJson: string, password: string): Promise<ethers.Wallet> {
try {
const wallet = await ethers.Wallet.fromEncryptedJson(keystoreJson, password);
return wallet;
} catch (e) {
throw new Error('Invalid password or corrupted keystore');
}
}
// HD wallet из mnemonic (BIP-39 + BIP-44)
async createHDWallet(mnemonic: string, password: string): Promise<void> {
// Validate mnemonic
if (!ethers.Mnemonic.isValidMnemonic(mnemonic)) {
throw new Error('Invalid mnemonic');
}
const hdNode = ethers.HDNodeWallet.fromMnemonic(
ethers.Mnemonic.fromPhrase(mnemonic)
);
// Derivation path: m/44'/60'/0'/0/N (MetaMask compatible)
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));
}
// Храним encrypted mnemonic и keystores
const encryptedMnemonic = await this.encryptKey(
ethers.hexlify(ethers.toUtf8Bytes(mnemonic)),
password
);
await chrome.storage.local.set({
encryptedMnemonic,
accounts: accounts.map((k, i) => ({
index: i,
address: ethers.computeAddress(
'0x' + JSON.parse(encryptedMnemonic).address
),
keystore: k
}))
});
}
}
Auto-lock механизм
Ключи не должны оставаться расшифрованными бесконечно:
class SessionManager {
private unlockedWallets: Map<string, ethers.Wallet> = new Map();
private lockTimer: NodeJS.Timeout | null = null;
// Настраиваемый auto-lock: 1, 5, 15, 60 минут
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);
// Сигнализируем popup о lock
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());
}
}
EIP-1193 Provider: интерфейс для dApp
window.ethereum — это EIP-1193 провайдер. dApp вызывает window.ethereum.request({ method, params }) и получает ответ. Кошелёк должен реализовывать этот интерфейс.
Content script + injected script
Проблема: content script изолирован (не имеет доступа к window страницы). Нужен второй слой — injected script, который выполняется в контексте страницы.
// content-script.ts — выполняется в ISOLATED world, имеет доступ к DOM
// Инжектирует провайдер в window страницы
function injectProvider() {
const script = document.createElement('script');
script.src = chrome.runtime.getURL('injected.js');
script.type = 'module';
// Важно: inject до выполнения страничных скриптов
(document.head ?? document.documentElement).prepend(script);
script.remove();
}
injectProvider();
// Мост между injected.js (page context) и background
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 — выполняется в контексте страницы, имеет доступ к window
// Создаёт window.ethereum
class EIP1193Provider extends EventEmitter {
private requestId = 0;
private pendingRequests = new Map<number, {
resolve: (value: unknown) => void;
reject: (error: unknown) => void;
}>();
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);
if (error) {
pending.reject(new Error(error.message));
} else {
pending.resolve(result);
}
}
});
}
async request({ method, params }: { method: string; params?: unknown[] }): 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 ?? [] }
}));
// Timeout для зависших запросов
setTimeout(() => {
if (this.pendingRequests.has(requestId)) {
this.pendingRequests.delete(requestId);
reject(new Error('Request timeout'));
}
}, 30000);
});
}
// Legacy MetaMask compatible methods
async enable(): Promise<string[]> {
return this.request({ method: 'eth_requestAccounts' }) as Promise<string[]>;
}
isConnected(): boolean {
return true;
}
}
// EIP-6963: современный способ анонсирования провайдера
const provider = new EIP1193Provider();
window.ethereum = provider;
// Анонсируем через EIP-6963
window.dispatchEvent(new CustomEvent('eip6963:announceProvider', {
detail: {
info: {
uuid: 'your-unique-uuid',
name: 'MyWallet',
icon: 'data:image/svg+xml,...',
rdns: 'com.mywallet'
},
provider
}
}));
Обработка запросов в background
// background.ts — обработка EIP-1193 запросов
class ProviderRequestHandler {
private keystoreManager: KeystoreManager;
private sessionManager: SessionManager;
private connectedSites: Map<string, string[]>; // origin → [addresses]
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] as TransactionRequest, origin);
case 'personal_sign':
return this.handlePersonalSign(params[0] as string, params[1] as string, origin);
case 'eth_signTypedData_v4':
return this.handleSignTypedData(params[0] as string, params[1] as string, origin);
case 'wallet_switchEthereumChain':
return this.handleChainSwitch(params[0] as { chainId: string });
default:
// Форвардим RPC запросы к провайдеру
return this.forwardToRPC(method, params);
}
}
private async handleSendTransaction(tx: TransactionRequest, origin: string): Promise<string> {
// Показываем popup для подтверждения
await this.openConfirmationPopup('transaction', {
tx,
origin,
estimatedGas: await this.estimateGas(tx),
gasPrices: await this.getGasPrices()
});
// Ждём ответа от пользователя через popup
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);
const txHash = await this.provider.broadcastTransaction(signedTx);
return txHash;
}
}
Popup UI: confirmation экраны
Popup — это отдельное React SPA. Критические экраны:
// TransactionConfirmation.tsx
interface TransactionDetails {
to: string;
value: bigint;
data: string;
estimatedGas: bigint;
gasPrice: bigint;
origin: string;
decoded?: { // если удалось декодировать calldata
function: string;
args: Record<string, unknown>;
};
}
function TransactionConfirmation({ tx }: { tx: TransactionDetails }) {
const isContract = tx.data !== '0x' && tx.data !== '';
const totalCost = tx.value + tx.estimatedGas * tx.gasPrice;
return (
<div className="confirm-tx">
<div className="origin-badge">
<img src={getFavicon(tx.origin)} />
<span>{tx.origin}</span>
</div>
{/* Предупреждение для незнакомых контрактов */}
{isContract && !isKnownContract(tx.to) && (
<div className="warning">
Unverified contract interaction
</div>
)}
<div className="tx-details">
<div>To: <Address address={tx.to} /></div>
<div>Value: {formatETH(tx.value)} ETH</div>
{tx.decoded && (
<div className="decoded-call">
<span>Function: {tx.decoded.function}</span>
{/* Показываем decoded args в понятном виде */}
</div>
)}
<div>Gas fee: ~{formatETH(tx.estimatedGas * tx.gasPrice)} ETH</div>
<div>Total: {formatETH(totalCost)} ETH</div>
</div>
<div className="actions">
<button onClick={onReject} className="btn-reject">Reject</button>
<button onClick={onApprove} className="btn-approve">Confirm</button>
</div>
</div>
);
}
Безопасность: защита от фишинга
Кошелёк должен защищать пользователей от фишинговых сайтов:
// Список известных фишинговых доменов (MetaMask публично поддерживает список)
async function checkPhishingDomain(origin: string): Promise<boolean> {
const domain = new URL(origin).hostname;
// Проверяем против etherscan PhishDetector или MetaMask списка
const response = await fetch(
`https://phishing-detection.metaswap.codefi.network/v1/domains/${domain}`
);
const data = await response.json();
return data.result === 'blocked';
}
// Визуальное предупреждение в popup при работе с suspicious сайтом
function PhishingWarning({ domain }: { domain: string }) {
return (
<div className="phishing-warning">
<strong>Warning: Potential Phishing Site</strong>
<p>{domain} has been flagged as a phishing site.</p>
<p>Do not approve any transactions on this site.</p>
</div>
);
}
EIP-712 typed data signing: безопасный approve
async function handleSignTypedData(
address: string,
typedDataJson: string,
origin: string
): Promise<string> {
const typedData = JSON.parse(typedDataJson);
// Декодируем что подписывается — показываем пользователю в понятном виде
const decoded = decodeTypedData(typedData);
// Специальная обработка permit (ERC-2612) — пользователь должен знать
// что он подписывает infinite approval
if (typedData.primaryType === 'Permit') {
const spender = typedData.message.spender;
const value = typedData.message.value;
const isInfinite = value === ethers.MaxUint256;
await showPermitWarning({ spender, value, isInfinite, origin });
}
const approved = await waitForUserApproval(decoded);
if (!approved) throw new Error('User rejected');
const wallet = sessionManager.getWallet(address);
return wallet.signTypedData(
typedData.domain,
{ [typedData.primaryType]: typedData.types[typedData.primaryType] },
typedData.message
);
}
Стек и инструменты
| Компонент | Технология |
|---|---|
| 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 |
Этапы разработки
| Фаза | Содержание | Срок |
|---|---|---|
| Архитектура | 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 нед |
Совместимость со store: Chrome Web Store имеет строгие требования к MV3, требует проверку extension. Firefox использует MV2/MV3 с отличиями. Сборки для обоих браузеров — отдельная задача в pipeline.







