Розробка розширення браузера для гаманця
Браузерний гаманець — найскладніший тип крипто-клієнта не тому що там багато математики, а тому що він працює в кількох ізольованих контекстах одночасно та повинен бути 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 з MV2 на V3 створив серйозні проблеми. Background page (постійна) замінена на service worker (короткочасна). Service worker може бути terminated браузером в будь-який момент — не тримає стан постійно.
{
"manifest_version": 3,
"background": { "service_worker": "background.js", "type": "module" },
"content_scripts": [{
"matches": ["<all_urls>"],
"js": ["content-script.js"],
"run_at": "document_start",
"world": "ISOLATED"
}],
"permissions": ["storage"],
"host_permissions": ["<all_urls>"]
}
Service worker termination вирішується через chrome.storage та keep-alive ping:
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') {
// Запобігає termination SW
}
});
}
private async restoreState() {
const stored = await chrome.storage.session.get(['walletState']);
if (stored.walletState) {
this.state = stored.walletState;
}
}
}
Keystore: зберігання та шифрування ключів
Головне питання безпеки: як зберігати приватний ключ. Ключі ніколи не plaintext — тільки зашифровані:
interface EncryptedKeystore {
version: number;
crypto: {
ciphertext: string;
cipherparams: { iv: string };
kdf: string;
kdfparams: {
dklen: number;
salt: string;
n: number;
r: number;
p: number;
};
mac: string;
};
}
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({ accounts });
}
}
Механізм auto-lock
Ключі не повинні залишатися розшифрованими нескінченно:
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
);
}
}
EIP-1193 Provider: інтерфейс для dApp
window.ethereum — це EIP-1193 provider. dApp викликає window.ethereum.request({ method, params }) та отримує відповідь.
Content Script + Injected Script
Content script ізольований (немає доступу до window). Потрібен другий рівень — injected script виконується в контексті сторінки:
// 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();
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 ?? [] }
}));
setTimeout(() => {
if (this.pendingRequests.has(requestId)) {
this.pendingRequests.delete(requestId);
reject(new Error('Request timeout'));
}
}, 30000);
});
}
}
window.ethereum = new EIP1193Provider();
Обробка запитів у Background
class ProviderRequestHandler {
async handleRequest(method: string, params: unknown[], origin: string): Promise<unknown> {
switch (method) {
case 'eth_requestAccounts':
return this.requestAccounts(origin);
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);
default:
return this.forwardToRPC(method, params);
}
}
private async handleSendTransaction(tx: TransactionRequest, origin: string): Promise<string> {
await this.openConfirmationPopup('transaction', {
tx,
origin,
estimatedGas: await this.estimateGas(tx),
gasPrices: await this.getGasPrices()
});
const approved = await this.waitForUserApproval();
if (!approved) throw new Error('User rejected transaction');
const wallet = this.sessionManager.getWallet(tx.from);
const signedTx = await wallet.signTransaction(tx);
return await this.provider.broadcastTransaction(signedTx);
}
}
Popup UI: екрани підтвердження
interface TransactionDetails {
to: string;
value: bigint;
data: string;
estimatedGas: bigint;
gasPrice: bigint;
origin: string;
decoded?: { 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>
</div>
)}
<div>Gas fee: ~{formatETH(tx.estimatedGas * tx.gasPrice)} ETH</div>
<div>Total: {formatETH(totalCost)} ETH</div>
</div>
<div className="actions">
<button onClick={onReject}>Reject</button>
<button onClick={onApprove}>Confirm</button>
</div>
</div>
);
}
Захист від фішингу
async function checkPhishingDomain(origin: string): Promise<boolean> {
const domain = new URL(origin).hostname;
const response = await fetch(
`https://phishing-detection.metaswap.codefi.network/v1/domains/${domain}`
);
const data = await response.json();
return data.result === 'blocked';
}
function PhishingWarning({ domain }: { domain: string }) {
return (
<div className="phishing-warning">
<strong>Warning: Potential Phishing Site</strong>
<p>Do not approve any transactions on this site.</p>
</div>
);
}
Стек розробки
| Компонент | Технологія |
|---|---|
| Extension framework | Manifest V3, WXT або CRXJS |
| UI (popup) | React 18 + TypeScript + Tailwind |
| Crypto primitives | ethers.js v6 або viem |
| Key derivation | BIP-39, BIP-44 |
| Storage encryption | AES-256-GCM + scrypt |
| 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 методи, управління ланцюгом | 3–4 тижні |
| Popup UI | Управління аккаунтами, підтвердження tx | 4–6 тижнів |
| Security | Phishing detection, preview симуляції | 2–3 тижні |
| Мультиланцюг | Додати Solana, TON чи інші | 4–8 тижнів |
| Тестування | E2E з реальними dApps, security review | 3–4 тижні |
| Аудит | Crypto primitives + key storage | 3–4 тижні |
Сумісність зі store: Chrome Web Store строгі вимоги MV3. Firefox використовує MV2/MV3 з різницями. Збірки для обох браузерів — окремої задачі в pipeline.







