Skip to main content
Version: Alpha / Testnet (v5.0.0-rc.1)

Wallet Protocol

The Aztec wallet SDK defines a protocol for dApps to discover and communicate with wallets securely. This section covers how the extension implements this protocol.

Protocol Overview​

The wallet SDK protocol has three phases:

  1. Discovery - dApp broadcasts a request, wallets respond with their info
  2. Key Exchange - ECDH establishes a shared secret for encrypted messaging
  3. Secure Messaging - All subsequent messages are encrypted

This design:

  • Works with any number of wallets
  • Prevents eavesdropping on wallet calls
  • Allows user verification (emoji codes)

Discovery Phase​

When a dApp calls aztec.connect(), it broadcasts a discovery request:

// From dApp (simplified)
const wallet = await aztec.connect({
appId: 'my-dapp',
chainInfo: { chainId: 31337, version: 1 },
});

The content script receives this and forwards it to the background:

// Content script forwards to background
chrome.runtime.sendMessage({
type: 'DISCOVERY_REQUEST',
content: { appId, chainInfo, requestId },
});

The background service worker handles it:

callbacks
const callbacks: BackgroundConnectionCallbacks = {
onPendingDiscovery: async (discovery) => {
log.debug(
"[background] Pending discovery:",
discovery.requestId,
"from",
discovery.origin,
);

// Clean up stale sessions from this tab (e.g. page refresh creates a new
// discovery while the old session is still in activeSessions).
for (const session of handler.getActiveSessions()) {
if (session.tabId === discovery.tabId) {
log.debug(
"[background] Terminating stale session for tab:",
discovery.tabId,
session.sessionId,
);
capabilitiesApprovedSessions.delete(session.sessionId);
queuedMessages.delete(session.sessionId);
handler.terminateSession(session.sessionId);
}
}

// Deduplicate: reject any existing discovery from the same tab
const existing = handler
.getPendingDiscoveries()
.find(
(d) =>
d.tabId === discovery.tabId && d.requestId !== discovery.requestId,
);
if (existing) {
handler.rejectDiscovery(existing.requestId);
}

// Auto-approve if origin is already trusted (reconnection after page refresh)
if (await isTrustedOrigin(discovery.origin, discovery.appId)) {
log.debug(
"[background] Auto-approving trusted origin:",
discovery.origin,
);
handler.approveDiscovery(discovery.requestId);
return;
}

updateBadge();

openPopupWithFallback();
},

onSessionEstablished: async (session: ActiveSession) => {
log.debug("[background] Session established:", session.sessionId);

// Auto-confirm if origin is already trusted (skip emoji verification)
if (await isTrustedOrigin(session.origin, session.appId)) {
log.debug(
"[background] Auto-confirming trusted session:",
session.sessionId,
);

// Pre-approve capabilities if previously granted (enables seamless reconnect)
const savedCaps = await getStoredCapabilities(
session.origin,
session.appId,
);
if (savedCaps) {
capabilitiesApprovedSessions.add(session.sessionId);
}

// Flush any queued messages immediately (same logic as CONFIRM_SESSION handler)
const queued = queuedMessages.get(session.sessionId) ?? [];
queuedMessages.delete(session.sessionId);
for (const { session: s, message: msg } of queued) {
processWalletMessage(s, msg);
}
pushStateToPopup();
return;
}

// New origin — require emoji verification
log.debug(
"[background] Awaiting emoji verification for:",
session.sessionId,
);
// SDK automatically removes the discovery when key exchange completes.
// Show emojis in approvals so user can compare with the webapp
pendingSessionVerifications.push({
sessionId: session.sessionId,
origin: session.origin,
appId: session.appId,
verificationHash: session.verificationHash,
timestamp: Date.now(),
});
updateBadge();

// Only open popup if not already connected — calling openPopup() on an
// already-open popup rejects, and the fallback creates a second window
// that steals the popupPort from the original.
if (!popupPort) {
openPopupWithFallback();
}

pushStateToPopup();
},

/**
* Handles wallet method calls from the ExtensionWallet proxy.
* Messages are queued while emoji verification is pending — the extension
* user must confirm before any dApp calls are processed.
*/
onWalletMessage: async (session: ActiveSession, message: any) => {
log.debug(
"[background] Wallet message:",
message.type,
"from session:",
session.sessionId,
);

// Block wallet messages until the user confirms emoji verification in the extension.
// The dApp's calls (e.g. getAccounts) will wait until the extension user approves.
const awaitingVerification = pendingSessionVerifications.some(
(v) => v.sessionId === session.sessionId,
);
if (awaitingVerification) {
log.debug(
"[background] Session awaiting verification, queuing message:",
message.type,
);
const queue = queuedMessages.get(session.sessionId) ?? [];
queue.push({ session, message });
queuedMessages.set(session.sessionId, queue);
return;
}

await processWalletMessage(session, message);
},
};
Source code: docs/examples/webapp-tutorial/test-extension/src/background.ts#L744-L884

Key points:

  • Each discovery becomes a "pending discovery" awaiting user approval
  • The BackgroundConnectionHandler from wallet SDK manages the protocol state
  • onPendingDiscovery callback lets us show the connection request to users

Connection Approval​

When the user clicks "Connect" in the popup:

popup-messages
/**
* Handle messages from popup and offscreen for approvals and account management.
*/
chrome.runtime.onMessage.addListener((message, sender, sendResponse) => {
/**
* Storage proxy for the offscreen document. (#7)
* Validates that the request comes from the extension itself (not content scripts or external).
*/
if (message.type === "storage-get" || message.type === "storage-set") {
// Security: only allow storage proxy from extension pages (offscreen, popup) (#7)
// Content scripts have sender.tab set; extension pages (offscreen, popup) do not.
if (sender.tab) {
log.warn(
"[background] Rejected storage proxy from content script, tab:",
sender.tab.id,
);
sendResponse({
success: false,
error: "Storage proxy not allowed from content scripts",
});
return false;
}

if (message.type === "storage-get") {
chrome.storage.local
.get(message.key)
.then((result) => {
sendResponse({ success: true, result: result[message.key] });
})
.catch((err) => {
sendResponse({ success: false, error: err.message });
});
} else {
chrome.storage.local
.set(message.data)
.then(() => {
sendResponse({ success: true });
})
.catch((err) => {
sendResponse({ success: false, error: err.message });
});
}
return true; // async response (#23)
}

if (message.target !== MessageTarget.BACKGROUND) {
return false;
}

log.debug("[background] Popup message:", message.type);

// Reset auto-lock on any popup interaction (#28)
resetAutoLockTimer();

switch (message.type) {
case MessageTypes.APPROVE_CONNECTION: {
handler.approveDiscovery(message.requestId);
updateBadge();
sendResponse({ success: true });
return false; // sync response (#23)
}

case MessageTypes.REJECT_CONNECTION: {
handler.rejectDiscovery(message.requestId);
updateBadge();
sendResponse({ success: true });
return false;
}

case MessageTypes.APPROVE_TRANSACTION: {
const pending = pendingTransactions.find(
(t) => t.messageId === message.messageId,
);
if (pending) {
pendingTransactions = pendingTransactions.filter(
(t) => t.messageId !== message.messageId,
);
updateBadge();

const taskId = startBackgroundTask(
`tx:${pending.method}`,
handleTransactionApproval(pending),
);
sendResponse({ success: true, result: { taskId } });
} else {
sendResponse({ success: false, error: "Transaction not found" });
}
return false;
}

case MessageTypes.REJECT_TRANSACTION: {
const pending = pendingTransactions.find(
(t) => t.messageId === message.messageId,
);
if (pending) {
handler.sendResponse(pending.sessionId, {
messageId: pending.messageId,
error: "Transaction rejected by user",
walletId: WALLET_CONFIG.walletId,
});

pendingTransactions = pendingTransactions.filter(
(t) => t.messageId !== message.messageId,
);
updateBadge();
}
sendResponse({ success: true });
return false;
}

case MessageTypes.APPROVE_CAPABILITIES: {
const pending = pendingCapabilities.find(
(c) => c.messageId === message.messageId,
);
if (pending) {
pendingCapabilities = pendingCapabilities.filter(
(c) => c.messageId !== message.messageId,
);

// Build granted capabilities using the shared active-account helper
getGrantableAccounts()
.then((grantedAccounts) => {
const granted = pending.capabilities.map((cap: any) => {
if (cap.type === "accounts") {
return { ...cap, accounts: grantedAccounts };
}
return { ...cap };
});

return handler.sendResponse(pending.sessionId, {
messageId: pending.messageId,
result: {
version: "1.0",
granted,
wallet: {
name: WALLET_CONFIG.walletName,
version: WALLET_CONFIG.walletVersion,
},
},
walletId: WALLET_CONFIG.walletId,
});
})
.then(async () => {
capabilitiesApprovedSessions.add(pending.sessionId);

// Persist granted capabilities for auto-reconnect
const approvedSession = handler.getSession(pending.sessionId);
if (approvedSession) {
const trusted = await getTrustedOrigins();
const entry = trusted.find(
(t) =>
t.origin === approvedSession.origin &&
t.appId === approvedSession.appId,
);
if (entry) {
entry.grantedCapabilities = pending.capabilities.map(
(cap: any) => ({ ...cap }),
);
await chrome.storage.local.set({
[TRUSTED_ORIGINS_KEY]: trusted,
});
}
}

updateBadge();
sendResponse({ success: true });
})
.catch((err) => {
log.error("[background] Failed to approve capabilities:", err);
sendResponse({ success: false, error: getErrorMessage(err) });
});
} else {
sendResponse({ success: false, error: "Capability request not found" });
return false;
}
return true;
}

case MessageTypes.REJECT_CAPABILITIES: {
const pending = pendingCapabilities.find(
(c) => c.messageId === message.messageId,
);
if (pending) {
pendingCapabilities = pendingCapabilities.filter(
(c) => c.messageId !== message.messageId,
);

handler.sendResponse(pending.sessionId, {
messageId: pending.messageId,
result: {
version: "1.0",
granted: [],
wallet: {
name: WALLET_CONFIG.walletName,
version: WALLET_CONFIG.walletVersion,
},
},
walletId: WALLET_CONFIG.walletId,
});

updateBadge();
}
sendResponse({ success: true });
return false;
}

case MessageTypes.CONFIRM_SESSION: {
// User confirmed emojis match — session is now fully active.
// Flush any wallet messages that were queued while awaiting verification.
pendingSessionVerifications = pendingSessionVerifications.filter(
(v) => v.sessionId !== message.sessionId,
);
const queued = queuedMessages.get(message.sessionId) ?? [];
queuedMessages.delete(message.sessionId);
for (const { session, message: msg } of queued) {
log.debug("[background] Flushing queued message:", msg.type);
processWalletMessage(session, msg);
}

// Remember this origin as trusted for future reconnections (#30)
const confirmedSession = handler.getSession(message.sessionId);
if (confirmedSession) {
addTrustedOrigin(confirmedSession.origin, confirmedSession.appId);
}

updateBadge();
sendResponse({ success: true });
return false;
}

case MessageTypes.REJECT_SESSION: {
// User rejected emoji verification — reject queued messages and terminate the session.
pendingSessionVerifications = pendingSessionVerifications.filter(
(v) => v.sessionId !== message.sessionId,
);
const rejected = queuedMessages.get(message.sessionId) ?? [];
queuedMessages.delete(message.sessionId);
for (const { session, message: msg } of rejected) {
handler.sendResponse(session.sessionId, {
messageId: msg.messageId,
error: "Session verification rejected by user",
walletId: WALLET_CONFIG.walletId,
});
}
handler.terminateSession(message.sessionId);
updateBadge();
sendResponse({ success: true });
return false;
}

case MessageTypes.DISCONNECT_SESSION: {
// (#29) Allow users to disconnect a specific dApp session
// Remove from trusted origins so next connection requires full approval (#30)
const disconnectedSession = handler.getSession(message.sessionId);
if (disconnectedSession) {
removeTrustedOrigin(
disconnectedSession.origin,
disconnectedSession.appId,
);
}
capabilitiesApprovedSessions.delete(message.sessionId);
handler.terminateSession(message.sessionId);
pushStateToPopup();
sendResponse({ success: true });
return false;
}

case "getPendingItems": {
sendResponse({
success: true,
result: getFullState(),
});
return false;
}

case MessageTypes.GET_ACCOUNTS: {
chrome.storage.local
.get(STORAGE_KEYS.ACCOUNTS)
.then((data) => {
const accounts = (data[STORAGE_KEYS.ACCOUNTS] || []).map(
(acc: any) => ({
address: acc.address,
alias: acc.alias,
isDeployed: acc.isDeployed,
}),
);
sendResponse({ success: true, result: accounts });
})
.catch((error) =>
sendResponse({ success: false, error: error.message }),
);
return true; // async (#23)
}

case MessageTypes.GET_ACTIVE_ACCOUNT: {
chrome.storage.local
.get(STORAGE_KEYS.ACTIVE_ACCOUNT)
.then((data) => {
sendResponse({
success: true,
result: data[STORAGE_KEYS.ACTIVE_ACCOUNT] || null,
});
})
.catch((error) =>
sendResponse({ success: false, error: error.message }),
);
return true;
}

case MessageTypes.SET_ACTIVE_ACCOUNT: {
chrome.storage.local
.set({ [STORAGE_KEYS.ACTIVE_ACCOUNT]: message.address })
.then(() => sendResponse({ success: true }))
.catch((error) =>
sendResponse({ success: false, error: error.message }),
);
return true;
}

case MessageTypes.UNLOCK_WALLET: {
const taskId = startBackgroundTask(
"unlock",
sendToOffscreen({
type: MessageTypes.UNLOCK_WALLET,
password: message.password,
}).then((result) => {
walletUnlocked = true;
persistState();
resetAutoLockTimer();
return result;
}),
);
sendResponse({ success: true, result: { taskId } });
return false;
}

case MessageTypes.GET_WALLET_STATUS: {
chrome.storage.local
.get(STORAGE_KEYS.PASSWORD_DATA)
.then((data) => {
sendResponse({
success: true,
result: {
unlocked: walletUnlocked,
hasPassword: !!data[STORAGE_KEYS.PASSWORD_DATA],
},
});
})
.catch((error) =>
sendResponse({ success: false, error: error.message }),
);
return true;
}

case MessageTypes.SETUP_PASSWORD: {
const taskId = startBackgroundTask(
"setup-password",
sendToOffscreen({
type: MessageTypes.SETUP_PASSWORD,
password: message.password,
}).then((result) => {
walletUnlocked = true;
persistState();
resetAutoLockTimer();
return result;
}),
);
sendResponse({ success: true, result: { taskId } });
return false;
}

case MessageTypes.MARK_DEPLOYED: {
chrome.storage.local
.get(STORAGE_KEYS.ACCOUNTS)
.then((data) => {
const accounts = data[STORAGE_KEYS.ACCOUNTS] || [];
const account = accounts.find(
(a: any) => a.address === message.address,
);
if (account) {
account.isDeployed = true;
return chrome.storage.local.set({
[STORAGE_KEYS.ACCOUNTS]: accounts,
});
}
})
.then(() => sendResponse({ success: true, result: { success: true } }))
.catch((error) =>
sendResponse({ success: false, error: error.message }),
);
return true;
}

case MessageTypes.CREATE_ACCOUNT: {
const taskId = startBackgroundTask(
"create-account",
sendToOffscreen({
type: MessageTypes.CREATE_ACCOUNT,
alias: message.alias,
}),
);
sendResponse({ success: true, result: { taskId } });
return false;
}

case MessageTypes.DEPLOY_ACCOUNT: {
const taskId = startBackgroundTask(
"deploy-account",
sendToOffscreen({
type: MessageTypes.DEPLOY_ACCOUNT,
address: message.address,
}),
);
sendResponse({ success: true, result: { taskId } });
return false;
}

case MessageTypes.EXPORT_WALLET: {
const taskId = startBackgroundTask(
"export-wallet",
sendToOffscreen({ type: MessageTypes.EXPORT_WALLET }),
);
sendResponse({ success: true, result: { taskId } });
return false;
}

case MessageTypes.IMPORT_WALLET: {
// Wipe wallet data from chrome.storage.local and lock the wallet
chrome.storage.local.remove([
STORAGE_KEYS.ACCOUNTS,
STORAGE_KEYS.PASSWORD_DATA,
STORAGE_KEYS.ACTIVE_ACCOUNT,
]);
walletUnlocked = false;
persistState();
// Tell offscreen to clear cached key
sendToOffscreen({ type: MessageTypes.LOCK_WALLET }).catch(() => {});
sendResponse({ success: true, result: { success: true } });
return false;
}

case MessageTypes.IMPORT_WALLET_ACCOUNTS: {
const taskId = startBackgroundTask(
"import-wallet-accounts",
sendToOffscreen({
type: MessageTypes.IMPORT_WALLET_ACCOUNTS,
accounts: message.accounts,
activeAccount: message.activeAccount,
}).then((result) => {
walletUnlocked = true;
persistState();
resetAutoLockTimer();
return result;
}),
);
sendResponse({ success: true, result: { taskId } });
return false;
}

default: {
log.warn("[background] Unknown message type:", message.type);
return false;
}
}
});

async function handleTransactionApproval(
pending: PendingTransaction,
): Promise<any> {
try {
const result = await sendToOffscreen({
type: MessageTypes.WALLET_METHOD,
method: pending.method,
args: pending.args,
});

await handler.sendResponse(pending.sessionId, {
messageId: pending.messageId,
result,
walletId: WALLET_CONFIG.walletId,
});

return result;
} catch (error: any) {
log.error(
"[background] Transaction approval failed:",
pending.method,
error,
);
await handler.sendResponse(pending.sessionId, {
messageId: pending.messageId,
error: error.message,
walletId: WALLET_CONFIG.walletId,
});
throw error;
}
}
Source code: docs/examples/webapp-tutorial/test-extension/src/background.ts#L904-L1402

The handler.approveDiscovery() call:

  1. Marks the discovery as approved
  2. Sends a discovery response to the dApp with wallet info
  3. Triggers the key exchange phase

Key Exchange Phase​

After approval, the dApp initiates key exchange by sending its ECDH public key. The BackgroundConnectionHandler handles this automatically — the pseudocode below shows the conceptual flow, not code you write:

// Conceptual flow inside BackgroundConnectionHandler (from wallet SDK)
async handleKeyExchangeRequest(sessionId, request) {
// Generate our ECDH key pair
const keyPair = await generateKeyPair();
const publicKey = await exportPublicKey(keyPair.publicKey);

// Derive shared secret from their public key
const appPublicKey = await importPublicKey(request.publicKey);
const sessionKeys = await deriveSessionKeys(keyPair, appPublicKey, false);

// Store session with shared encryption key
const session = {
sessionId,
sharedKey: sessionKeys.encryptionKey,
verificationHash: sessionKeys.verificationHash, // For emoji display
// ...
};

// Send our public key back
this.transport.sendToTab(tabId, {
type: 'KEY_EXCHANGE_RESPONSE',
publicKey,
});
}

The key exchange uses:

  • ECDH (Elliptic Curve Diffie-Hellman) for shared secret derivation
  • AES-GCM for subsequent message encryption
  • Verification hash that can be displayed as emojis for visual confirmation

Emoji Verification​

The verification hash can be converted to emojis for users to confirm they're talking to the right wallet:

// Convert hash to emoji sequence
function hashToEmojis(hash: string): string {
const emojis = ['🔐', '🎮', '🚀', '⭐', '🎯', '💎', '🔥', '🌟'];
return hash
.slice(0, 8)
.split('')
.map((c) => emojis[parseInt(c, 16) % emojis.length])
.join('');
}

Both the dApp and wallet should display the same emoji sequence, confirming the connection is secure.

Secure Messaging​

Once key exchange completes, all messages are encrypted:

// In BackgroundConnectionHandler
async handleEncryptedMessage(sessionId, encrypted) {
const session = this.activeSessions.get(sessionId);
if (!session) return;

// Decrypt using shared key
const message = await decrypt(session.sharedKey, encrypted);

// Call our handler
this.callbacks.onWalletMessage?.(session, message);
}

async sendResponse(sessionId, response) {
const session = this.activeSessions.get(sessionId);
if (!session) return;

// Encrypt response
const encrypted = await encrypt(session.sharedKey, JSON.stringify(response));

// Send to content script
this.transport.sendToTab(session.tabId, {
type: 'SECURE_RESPONSE',
sessionId,
content: encrypted,
});
}

Message Routing​

The extension checks whether a wallet method needs user approval before forwarding:

// sendTx always requires approval — it's a state-changing operation.
// batch requires approval only if it contains a sendTx.
// Read-only calls (getAccounts, simulateTx, executeUtility, etc.) auto-execute.
const needsApproval =
message.type === 'sendTx' ||
(message.type === 'batch' &&
Array.isArray(message.args?.[0]) &&
message.args[0].some((m: any) => m.name === 'sendTx'));

requestCapabilities has its own approval flow — the wallet stores it as pending and shows a capability grant prompt.

For methods that don't need approval, the extension forwards directly to offscreen via a persistent port. The port uses messageId-based request/response correlation with a 5-minute timeout and automatic retry if the offscreen document is torn down by Chrome:

send-to-offscreen
/**
* Persistent port to the offscreen document.
* Unlike chrome.runtime.sendMessage() (broadcast), a port gives us:
* - Point-to-point channel (no broadcast to all extension pages)
* - Automatic disconnect detection (offscreen teardown)
* - No `return true`/`false` landmine for async responses
*/
let offscreenPort: chrome.runtime.Port | null = null;
const pendingOffscreenCalls = new Map<
string,
{
resolve: (value: any) => void;
reject: (error: Error) => void;
timer: ReturnType<typeof setTimeout>;
}
>();
let offscreenMessageId = 0;

const OFFSCREEN_TIMEOUT_MS = 5 * 60 * 1000; // 5 minutes

function connectOffscreenPort() {
const port = chrome.runtime.connect({ name: "offscreen" });
offscreenPort = port;

port.onMessage.addListener((message: any) => {
// Progress updates — relay to popup
if (message.type === "task-progress") {
const runningTask = backgroundTasks.find((t) => t.status === "running");
if (runningTask) {
runningTask.progress = message.stage;
notifyPopup({ type: "task-update", task: { ...runningTask } });
}
return;
}

// Request/response correlation
const pending = pendingOffscreenCalls.get(message.messageId);
if (!pending) return;
pendingOffscreenCalls.delete(message.messageId);
clearTimeout(pending.timer);

if (message.success) {
pending.resolve(message.result);
} else {
pending.reject(new Error(message.error || "Unknown error"));
}
});

port.onDisconnect.addListener(() => {
log.debug("[background] Offscreen port disconnected");
offscreenPort = null;
// Reject all pending calls — sendToOffscreen will retry
for (const [id, pending] of pendingOffscreenCalls) {
clearTimeout(pending.timer);
pending.reject(new Error("Offscreen port disconnected"));
pendingOffscreenCalls.delete(id);
}
});
}

/**
* Sends a message to the offscreen document and waits for response.
* Uses a persistent port with request/response correlation via messageId.
* Retries once if the offscreen document was torn down. (#15)
*/
async function sendToOffscreen(message: any, _retried = false): Promise<any> {
await ensureOffscreenDocument();

if (!offscreenPort) {
connectOffscreenPort();
}

const messageId = `off-${++offscreenMessageId}`;

return new Promise((resolve, reject) => {
const timer = setTimeout(() => {
pendingOffscreenCalls.delete(messageId);
reject(new Error(`Offscreen call timed out: ${message.type}`));
}, OFFSCREEN_TIMEOUT_MS);

pendingOffscreenCalls.set(messageId, { resolve, reject, timer });

try {
if (!offscreenPort) {
throw new Error("Offscreen port not connected");
}
offscreenPort.postMessage({ ...message, messageId });
} catch (err: unknown) {
pendingOffscreenCalls.delete(messageId);
clearTimeout(timer);

// Port may have disconnected — retry once
if (!_retried) {
log.warn("[background] Offscreen port send failed, retrying...");
offscreenPort = null;
offscreenCreating = null;
sendToOffscreen(message, true).then(resolve, reject);
} else {
reject(err instanceof Error ? err : new Error(String(err)));
}
}
});
}
Source code: docs/examples/webapp-tutorial/test-extension/src/background.ts#L72-L176

For methods that need approval, the extension stores them as pending and waits for user action:

// Store pending transaction
const pending = {
sessionId: session.sessionId,
messageId: message.messageId,
method: message.type,
args: message.args,
from: message.args?.options?.from,
origin: session.origin,
timestamp: Date.now(),
};
pendingTransactions.push(pending);
updateBadge();

Transport Implementation​

The transport bridges Chrome's messaging APIs:

transport
const transport: BackgroundTransport = {
sendToTab: (tabId, message) => {
log.debug(
"[background] sendToTab:",
tabId,
message.type,
message.sessionId,
);
chrome.tabs.sendMessage(tabId, message);
},
addContentListener: (handler) => {
chrome.runtime.onMessage.addListener((message, sender) => {
// Skip targeted messages (popup, offscreen), storage proxy, and progress updates
if (message.target) return;
if (message.type === "storage-get" || message.type === "storage-set")
return;

log.debug(
"[background] Content message received:",
message.origin,
message.type,
"from tab:",
sender.tab?.id,
);
handler(message, {
tab: sender.tab
? { id: sender.tab.id, url: sender.tab.url }
: undefined,
});
});
},
};
Source code: docs/examples/webapp-tutorial/test-extension/src/background.ts#L499-L532

Key points:

  • sendToTab uses chrome.tabs.sendMessage to reach content scripts
  • addContentListener uses chrome.runtime.onMessage to receive content script messages
  • The listener skips messages with a target field (those are popup → background) and storage proxy messages (those are offscreen → background via broadcast)

Session Lifecycle​

Sessions are cleaned up automatically when:

  • Page refresh — onPendingDiscovery terminates stale sessions from the same tab before processing the new discovery
  • Tab closed — chrome.tabs.onRemoved calls handler.terminateForTab() to remove all sessions and discoveries for the tab
  • User disconnects — The popup sends DISCONNECT_SESSION, which also removes the origin from trusted origins

Next Steps​

With the protocol in place, let's set up PXE Integration - running a full Private eXecution Environment inside the extension.