Browser Extension Wallet Development
Browser wallet is the most complex type of crypto client not because there's much math, but because it works in several isolated contexts simultaneously and must be security-first when interacting with any website on internet. MetaMask, Phantom, Rabby — each solves same problem: give dApp access to sign transactions without giving dApp access to private key.
Extension Architecture: Three Contexts
Browser extension exists in three isolated JavaScript contexts with different privileges:
┌─────────────────────────────────────────────────────────┐
│ Background Service Worker (Manifest V3) │
│ - Stores keystore (encrypted) │
│ - Manages wallet state │
│ - Signs transactions │
│ - Responds to popup and content script │
└──────────────────┬──────────────────────────────────────┘
│ chrome.runtime.sendMessage
┌─────────┴──────────┐
│ │
┌────────▼────────┐ ┌────────▼────────────────────────────┐
│ Popup (UI) │ │ Content Script │
│ React SPA │ │ Injected into every page │
│ Manage │ │ Creates window.ethereum │
│ accounts │ │ Forwards dApp requests to background│
│ Confirm tx │ │ │
└─────────────────┘ └─────────────────────────────────────┘
Not implementation details — security model. Content script has no key access. Popup has no DOM access. Background — only place with keys, isolated from web content completely.
Manifest V3: Challenges
Chrome transition from MV2 to V3 created issues. Background page (persistent) replaced with service worker (ephemeral). Service worker terminated by browser anytime — doesn't hold state permanently.
{
"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 solved via chrome.storage and 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') {
// Keeps SW from terminating
}
});
}
private async restoreState() {
const stored = await chrome.storage.session.get(['walletState']);
if (stored.walletState) {
this.state = stored.walletState;
}
}
}
Keystore: Key Storage and Encryption
Main security question: how to store private key. Keys never plaintext — only encrypted:
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 Mechanism
Keys shouldn't remain decrypted indefinitely:
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 Interface
window.ethereum is EIP-1193 provider. dApp calls window.ethereum.request({ method, params }) and gets response.
Content Script + Injected Script
Content script is isolated (no window access). Need second layer — injected script executed in page context:
// 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 — page context
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();
Request Handling in 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: Confirmation Screens
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>
);
}
Phishing Protection
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>
);
}
Development Stack
| Component | Technology |
|---|---|
| Extension framework | Manifest V3, WXT or CRXJS |
| UI (popup) | React 18 + TypeScript + Tailwind |
| Crypto primitives | ethers.js v6 or viem |
| Key derivation | BIP-39, BIP-44 |
| Storage encryption | AES-256-GCM + scrypt |
| State management | Zustand or Recoil |
| Build | Vite + rollup |
| Testing | Playwright for E2E, Vitest for unit |
Development Stages
| Phase | Content | Timeline |
|---|---|---|
| Architecture | MV3 design, IPC scheme, security model | 2 weeks |
| Keystore | Encrypt/decrypt, HD wallet, auto-lock | 3–4 weeks |
| Provider (EIP-1193) | window.ethereum, content script, injected | 3–4 weeks |
| Background handler | All RPC methods, chain management | 3–4 weeks |
| Popup UI | Account management, tx confirmation | 4–6 weeks |
| Security | Phishing detection, simulation preview | 2–3 weeks |
| Multi-chain | Add Solana, TON or others | 4–8 weeks |
| Testing | E2E with real dApps, security review | 3–4 weeks |
| Audit | Crypto primitives + key storage | 3–4 weeks |
Store compatibility: Chrome Web Store strict MV3 requirements. Firefox uses MV2/MV3 with differences. Builds for both browsers — separate pipeline task.







