Skip to content
docs

Browser extension

extension-kit is the security core of a non-custodial Chromium (MV3) wallet extension. It is a library, not an extension. You build the popup, the approval window, the injected provider and the release pipeline. The kit gives you the parts that must be right:

  • Vault: every secret encrypted at rest in chrome.storage.local. A password opens it (scrypt), and a passkey can open it too (WebAuthn PRF).
  • Session: the unlocked key lives only in chrome.storage.session, with an expiry that is checked on every read and an alarm that locks the wallet.
  • Relay and router: page → content script → service worker. The worker trusts the origin the browser recorded for the port, never the one the page claims. Per-site permissions decide what a site may call.
  • Approval queue: one approval window at a time, at most three waiting requests per site, one waiting connect.
  • Signing checks: the signing address must be connected, typed data must be for the connected chain, eth_sign is never offered, contract deployments and site-made EIP-7702 delegations are refused. Nonce and fees come from your RPC.

The kit has no UI and makes no network requests of its own. It doesn’t need @types/chrome: its adapters take plain objects shaped like chrome.storage and chrome.alarms, so the same code runs in tests with in-memory fakes. Snippets come from examples/extension-kit, which our CI typechecks and tests.

Terminal window
npm install @anywalletkit/extension-kit @anywalletkit/keyring @anywalletkit/wallet-evm
import {
ApprovalQueue, NetworksTable, PAGE_TARGET, PORT_NAME, PermissionStore, PhishingDetector, RequestRouter, Session, Vault,
checkSenderOrigin, chromeAlarms, chromeJsonStorage, chromeKeyringStorage, chromeLocalVaultStorage,
chromeSessionStorage, createSignHandler, isRelayRequest, isRelayToContent, pageRequestFrom, stampOrigin,
toRelayError, vaultSecretStorage,
type AlarmsLike, type ApprovalRequest, type PageMessageEvent, type PageWindow, type ApprovalWindow, type EvmNetwork, type EvmSigner, type PermissionsState,
type PortSender, type RelayResponse, type RelayToContent, type StorageAreaLike,
} from '@anywalletkit/extension-kit'
import { Keyring } from '@anywalletkit/keyring'
import { WalletManagerEvm, type WalletAccountEvm } from '@anywalletkit/wallet-evm'

The service worker can be stopped at any moment, so it rebuilds everything from storage on start-up.

/** Service worker start-up. The worker can be stopped at any time; everything lives in storage. */
export async function openWallet(chrome: ChromeLike) {
const vault = new Vault({ storage: chromeLocalVaultStorage(chrome.storage.local) })
const session = new Session({
storage: chromeSessionStorage(chrome.storage.session), // memory-only, cleared when the browser quits
alarms: chromeAlarms(chrome.alarms), // locks on time even if the worker was asleep
})
const keyring = await Keyring.load({
storage: chromeKeyringStorage(chrome.storage.local), // names, addresses: no secrets
secrets: vaultSecretStorage(vault, session), // every secret read goes through the unlocked vault
chains: { evm: { Manager: WalletManagerEvm, config: {} } },
})
return { vault, session, keyring }
}
export type WalletParts = Awaited<ReturnType<typeof openWallet>>

The keyring keeps its account list in chrome.storage.local as plain JSON: names and addresses, no secrets. Every secret read goes through the vault and needs an unlocked session; a locked session throws VaultError with the code locked.

/** First run: create the vault, unlock the session, import the phrase. */
export async function createWallet({ vault, session, keyring }: WalletParts, password: string, phrase: string) {
const dek = await vault.create(password) // refuses if a vault exists or the password is under 8 characters
await session.unlock(dek) // keeps the key in session storage and zeroizes `dek`
return keyring.importMnemonic(phrase) // sealed in the vault, never in plain storage
}
  • vault.create refuses when a vault already exists. Resetting is a separate, explicit vault.destroy(); build it as its own screen with its own warning.
  • Passwords shorter than 8 characters are refused by the vault itself.
  • session.unlock zeroizes the key you pass in.
export async function unlockWithPassword({ vault, session }: WalletParts, password: string) {
await session.unlock(await vault.unlock(password)) // VaultError 'wrong-password' on a bad password
}
/**
* Passkey unlock. Your extension page runs navigator.credentials.get() with the WebAuthn PRF
* extension and passes the 32-byte PRF output here; the vault zeroizes it after use.
* The password stays the recovery path.
*/
export async function addPasskey({ vault }: WalletParts, password: string, passkey: { id: string; prf: Uint8Array; label: string }) {
const dek = await vault.unlock(password) // the user confirms with the password once
try {
await vault.addPasskey(dek, passkey)
} finally {
dek.fill(0)
}
}
export async function unlockWithPasskey({ vault, session }: WalletParts, id: string, prf: Uint8Array) {
await session.unlock(await vault.unlockWithPasskey(id, prf))
}
VaultError code When
wrong-password Wrong password, wrong passkey, or an envelope that was edited on disk. AES-GCM can’t tell these apart.
locked A secret was read after the session locked, or setSecret got a key that does not open the vault (one rotateVaultKey retired)
exists create while a vault exists
weak-kdf The stored KDF settings are below the floor this build accepts
unsupported-envelope An envelope of an unknown shape, or one whose KDF cost or size is above VAULT_LIMITS (refused before any KDF runs)
invalid-input Empty or short password, bad key length, bad id

A passkey is a second key to the same vault, not a replacement for the password. Keep the password as the recovery path, and let users remove a passkey (vault.removePasskey).

changePassword re-wraps the same vault key, and removing a passkey does not take back a key it already opened. After a suspected compromise (a copied browser profile, a leaked session, a lost passkey), offer re-key the vault: vault.rotateVaultKey(password) makes a new vault key, re-seals every secret under it and writes the vault once, so a failure leaves the old one as it was. Hand the key it returns to session.unlock, which replaces the old one. Passkeys are dropped, because they wrap the old key; ask the user to add them again. The old key opens nothing in the new vault, and setSecret refuses it.

await session.unlock(await vault.rotateVaultKey(password))

The relay drops a request whose method is not a method name (letters, digits and _, at most 128 characters, isRelayMethod). Reject such a request in your injected provider with -32600, so the page gets an answer.

Auto-lock defaults to 15 minutes (SESSION_DEFAULT_AUTOLOCK_MS). Change it with session.setAutoLockMs(ms), and call session.touch() when the user is active in your UI.

Everything a site can ask for goes through RequestRouter. You give it your RPC transport, your approval window, and the signer for an address.

/** Everything a dApp can ask for goes through this router. */
export function createRouter(wallet: WalletParts, options: WorkerOptions) {
const { keyring, session } = wallet
const local = options.chrome.storage.local
const phishing = new PhishingDetector(options.phishing)
// A site on the shipped phishing list is refused (4100) before any window opens; lookalikes and
// punycode only add warnings to the sheet.
const approvals = new ApprovalQueue(options.window, undefined, { annotate: (origin) => phishing.check(origin).warnings, refuse: (origin) => phishing.refusal(origin) })
const ports = new Map<string, Set<(message: RelayToContent) => void>>()
// Signing: the account lives only for one signature, then its key is wiped.
const signerFor = async (address: string) => {
const meta = keyring.listAccounts().find((a) => a.addresses.evm?.toLowerCase() === address.toLowerCase())
if (!meta) throw new Error('This address is not in the wallet.')
const handle = await keyring.getAccount<WalletAccountEvm>(meta.id, 'evm')
const signer: EvmSigner = {
signMessage: (m) => handle.account.sign(m),
signTypedData: (t) => handle.account.signTypedData(t),
signTransaction: (tx) => handle.account.signTransaction(tx),
}
return { signer, dispose: () => handle.dispose() }
}
const router = new RequestRouter({
permissions: new PermissionStore(chromeJsonStorage<PermissionsState>(local, 'my-wallet.permissions')),
networks: new NetworksTable(chromeJsonStorage<EvmNetwork[]>(local, 'my-wallet.networks')),
approvals,
refuse: (origin) => phishing.refusal(origin), // reads and an earlier permission included
// A locked wallet shows no accounts; eth_requestAccounts then opens the window to unlock.
accounts: async () => {
const address = keyring.activeAccount?.addresses.evm
return (await session.isUnlocked()) && address ? [address] : []
},
rpc: options.rpc, // reads go to your RPC, never to the site's
emit: (origin, event, data) => { for (const post of ports.get(origin) ?? []) post({ kind: 'event', chain: 'eip155', event, data }) },
sign: createSignHandler({ approvals, rpc: options.rpc, signerFor }),
})
// Locking the wallet answers every waiting request with "declined".
session.onLock(() => approvals.declineAll())
return { router, approvals, ports }
}
export type WorkerParts = ReturnType<typeof createRouter>

What the router does with a request:

Request What happens
eth_chainId, eth_accounts Answered without a prompt. A site that is not connected, or a locked wallet, gets []
eth_requestAccounts Opens the approval window (your window asks to unlock first when the wallet is locked) and stores the permission for that origin
wallet_switchEthereumChain, wallet_addEthereumChain Through the approval window; only catalog chains, https only, eth_chainId verified
Read-only RPC (READ_METHODS) Sent to your RPC for the site’s chain, never to an endpoint the site chose
personal_sign, eth_signTypedData_v4, eth_sendTransaction Checked, reviewed in the approval window, signed, and a transaction is broadcast through your RPC
Anything else Error 4200 (unsupported method)

Errors come back as EIP-1193 codes: 4001 the user declined, 4100 unauthorized, 4900 disconnected, -32002 a connect request is already waiting, -32005 too many waiting requests. A node’s own error (a revert: code 3 with its data) passes through without the RPC URL. Anything else reaches the page as -32603 Internal error: pass log to toRelayError to keep the details in the extension’s console, since transport errors name the RPC URL and often the provider key in it.

The signer is built for one signature and its key is wiped right after (handle.dispose()). Pass a preview function to createSignHandler to show a simulation in the approval window; Transaction preview builds one.

The queue shows one request at a time. Your approval page reads the request, shows it, and answers with approvals.resolve(id, value) or approvals.decline(id). Closing the window declines the request.

/** One popup per request. Your approval page reads the request by id and answers through the queue. */
export function popupApprovalWindow(windows: WindowsLike, pageUrl: string): ApprovalWindow & { shown: Map<string, ApprovalRequest> } {
const byWindow = new Map<number, string>()
const shown = new Map<string, ApprovalRequest>()
const closed: Array<(requestId: string) => void> = []
windows.onRemoved.addListener((windowId) => {
const requestId = byWindow.get(windowId)
if (requestId === undefined) return
byWindow.delete(windowId)
shown.delete(requestId)
for (const handler of closed) handler(requestId) // closing the popup declines the request
})
return {
shown,
async open(request) {
shown.set(request.id, request)
const win = await windows.create({ url: `${pageUrl}?request=${encodeURIComponent(request.id)}`, type: 'popup', width: 380, height: 640 })
if (win.id !== undefined) byWindow.set(win.id, request.id)
},
async close(requestId) {
shown.delete(requestId)
for (const [windowId, id] of byWindow) if (id === requestId) { byWindow.delete(windowId); await windows.remove(windowId) }
},
onClosed(handler) { closed.push(handler) },
}
}

Show on every approval:

  • The origin from the request (request.origin), which the browser recorded. Never show a name or URL the page sent about itself.
  • request.originWarnings: lookalike and punycode warnings from your annotate hook. The router snippet wires PhishingDetector, which reads a list in the eth-phishing-detect format. Ship the list inside your build; the kit never downloads it. A site ON the list never reaches the sheet: refuse: (origin) => phishing.refusal(origin) on the queue and the router answers it with 4100 before any window opens.
  • What will happen: for transactions, the prepared fee (payload.prepared.maxFee) and your preview. The nonce on the sheet is a guess; the one signed is read after the approval. When the network’s fee outgrew the shown cap while the sheet was open, the same transaction comes back with payload.repriced: true and the new numbers: say the fee went up.

The worker side checks the origin of every message against the port’s sender:

/** chrome.runtime.onConnect.addListener((port) => onPort(worker, port)) */
export function onPort({ router, ports }: WorkerParts, port: PortLike) {
if (port.name !== PORT_NAME) return
let origin: string | null = null
const post = (message: RelayToContent) => port.postMessage(message)
port.onMessage.addListener(async (message) => {
if (!isRelayRequest(message)) return
// The origin the browser recorded for this port, not the one the page claims.
const trusted = checkSenderOrigin(port.sender, message.origin)
if (!trusted) return // forged: dropped, never answered
if (origin === null) {
origin = trusted
if (!ports.has(origin)) ports.set(origin, new Set())
ports.get(origin)!.add(post)
}
let response: RelayResponse
try {
response = { kind: 'response', id: message.id, result: (await router.handle(trusted, message.chain, message.method, message.params)) ?? null }
} catch (e) {
// The page gets the EIP-1193 code and a URL-free message; anything internal is
// "Internal error" there, and the details stay in the extension's own log.
response = { kind: 'response', id: message.id, error: toRelayError(e, { log: (err) => console.error('[wallet] request failed', err) }) }
}
post(response)
})
port.onDisconnect.addListener(() => { if (origin) ports.get(origin)?.delete(post) })
}

The content script only stamps the page’s origin and forwards messages both ways:

/** Content script: stamps the page's origin and forwards both ways. It holds no wallet logic. */
export function startContentScript(env: ContentLike) {
const port = env.connect(PORT_NAME)
env.addPageListener((event) => {
// Only what the page itself posted: an iframe's parent.postMessage is not the site speaking.
const request = pageRequestFrom(event, env.page)
if (request) port.postMessage(stampOrigin(request, env.page.location.origin))
})
port.onMessage.addListener((message) => {
if (!isRelayToContent(message)) return
if (message.kind === 'response') {
env.postToPage({ target: PAGE_TARGET.toPage, id: message.id, ...('result' in message ? { result: message.result } : {}), ...(message.error ? { error: message.error } : {}) })
} else {
env.postToPage({ target: PAGE_TARGET.toPage, chain: message.chain, event: message.event, data: message.data })
}
})
}

Your injected provider (the window.ethereum object, EIP-6963 announcements) posts PageRequest messages with target: PAGE_TARGET.toContent and listens for responses and events. Keep it small: it runs in the page’s world, where the page can see and change it.

  • The popup, the onboarding and unlock screens, and the approval page.
  • The injected providers for the chains you support.
  • The extension’s content security policy: script-src 'self', no remote scripts or images.
  • An allowlist of hosts your worker’s fetch may reach.
  • A reproducible release build, and the store listing below.

The SDK ships no store listing. The store account is yours, and whoever controls it can push an update to every user of your wallet. Treat it like a signing key.

The account

  • Use a dedicated Google account for publishing, not someone’s personal mail.
  • Turn on 2-Step Verification with a passkey or a hardware security key, not SMS. The store requires 2-Step Verification before anyone can publish or update an item (policy).
  • Working as a team: invite members to the publisher with the right roles instead of sharing one login (publisher members).
  • The developer email can’t be changed after the account is created (registration).

Identity and disclosures

  • Every publisher declares whether it is a trader. A trader’s legal name, phone number and address are shown on the listing (trader FAQ).
  • Verify your publisher identity. The “Established Publisher” badge comes later, on its own, after a track record (discovery).
  • Fill in the Privacy tab: a single purpose, a reason for every permission, the remote code declaration, the data use disclosures, and a privacy policy URL (privacy fields).

The package

  • Ask for as few permissions as you can. Broad host permissions (<all_urls>, https://*/*) and powerful ones (tabs, cookies, webRequest) get a longer review (review process).
  • Bundle all code. MV3 doesn’t allow code loaded from outside the extension’s own files (remote hosted code). Submit readable code: obfuscation is not allowed.

Releasing

  • Publish by hand from the dashboard. Don’t keep a publish token (an OAuth refresh token for the Web Store API) in CI: anyone who gets it can ship an update to all your users.
  • Use deferred publishing to choose when a reviewed version goes live; you have 30 days. Once the item has more than 10,000 weekly users, roll out to a percentage of users first (updates).
  • Plan for review time. Most reviews take a few days, some take a few weeks. After three weeks, contact developer support.