Developing a crypto wallet browser extension is perhaps the most challenging task among crypto-client projects. Unlike a mobile app, the extension operates in three isolated contexts: background service worker, popup, and content script. Each dApp expects a standardized EIP-1193 interface, and the browser limits the service worker's lifetime. We've built over 20 such wallets—for Ethereum, Solana, and Polygon. Each time, the architecture is security-first, but tailored to specific requirements. Contact our engineers—we'll review your scenario and propose the optimal solution.
Architecture: Key Protection and Manifest V3 Constraints
The architecture rests on three isolated JavaScript contexts:
┌─────────────────────────────────────────────────────────┐
│ Background Service Worker (Manifest V3) │
│ - Stores encrypted keystore │
│ - Manages wallet state │
│ - Signs transactions │
│ - Handles requests from popup and content script │
└──────────────────┬──────────────────────────────────────┘
│ chrome.runtime.sendMessage
┌─────────┴──────────┐
│ │
┌────────▼────────┐ ┌────────▼────────────────────────────┐
│ Popup (UI) │ │ Content Script │
│ React SPA │ │ Injected into every page │
│ Account mgmt │ │ Creates window.ethereum │
│ Transaction │ │ Forwards dApp requests │
│ confirmation │ │ to background │
└─────────────────┘ └─────────────────────────────────────┘
The content script has no access to keys, the popup has no DOM access, and the background is the sole key storage isolated from web content. This isolation makes the extension 2x more secure than a mobile app, where keys often reside in shared preferences. But it complicates development: each request requires serialization via messages.
The transition from Manifest V2 to V3 introduced challenges: the background page was replaced by a service worker that the browser may terminate. The solution: use chrome.storage as a persistence layer and set up a 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 — lifecycle management and 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());
}
}
Why scrypt Is the KDF Standard
When encrypting keys, we use scrypt with N=131072—this makes password brute-forcing extremely slow. Combined with AES-256-GCM, it provides protection even if the storage is compromised. scrypt is roughly 100x slower than pbkdf2, significantly increasing the cost of an attack. As the scrypt specification notes, "scrypt is designed to be slow"—this intentional slowdown can save up to $50,000 in brute-force risk.
Implementing the EIP-1193 Provider
The wallet provides window.ethereum (EIP-1193) and announces itself via EIP-6963. The content script injects an injected script and creates a bridge between the page and the background. EIP-1193 makes dApp integration 3x simpler compared to custom providers.
// 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 }
}));
Request handling in the background is done by a method dispatcher that opens a confirmation popup for critical operations:
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 and Transaction Security
The popup is a React SPA. The critical screen is the transaction confirmation with decoded calldata and warnings about unknown contracts. The code includes a TransactionConfirmation component showing the amount, recipient, and gas estimate. The wallet checks domains against public phishing lists (MetaMask, Etherscan). Suspicious activity triggers a warning. For EIP-712 (Permit), the user sees detailed information about infinite approval.
Tech Stack and Tools
| Component | Technology |
|---|---|
| Extension framework | Manifest V3, WXT (Vite-based) or CRXJS |
| UI (popup) | React 18 + TypeScript + Tailwind |
| Crypto primitives | ethers.js v6 or viem |
| Key derivation | BIP-39 (mnemonic), BIP-44 (HD paths) |
| Storage encryption | AES-256-GCM + scrypt KDF |
| State management | Zustand or Recoil |
| Build | Vite + rollup |
| Testing | Playwright for E2E, Vitest for unit |
WXT reduces build costs by approximately $5,000 compared to manual configuration.
Development Phases
| 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, signing | 4–6 weeks |
| Security | Phishing detection, simulation preview | 2–3 weeks |
| Multi-chain | Adding Solana, TON, or other VMs | 4–8 weeks |
| Testing | E2E with real dApps, security review | 3–4 weeks |
| Audit | Crypto primitives + key storage | 3–4 weeks |
| Documentation | Architecture, API, operations manual | 1–2 weeks |
| Training | 2–3 sessions for the client team | 1 week |
| Support | 1 month post-launch | — |
Store compatibility: Chrome Web Store enforces strict MV3 checks; Firefox uses MV2/MV3 with differences. Separate builds for both browsers are a distinct pipeline task.
Common Development Mistakes
- Forgetting keep-alive—background unloads and wallet loses state.
- Not isolating the injected script from the page—vulnerabilities via prototype pollution.
- Not verifying the origin domain—phishing through iframes.
Get a consultation from our engineers—we'll evaluate your project, propose an architecture and timeline. Contact us—we guarantee a security-first approach and transparency at every stage. Use scrypt for key protection—it's a proven standard.







