Fix wallet bridge on Firefox: use postMessage instead of CustomEvent

Manual testing on Zen/Firefox surfaced "Uncaught Error: Permission
denied to access property 'id'" the moment the isolated-world relay
dispatched a CustomEvent to the world:'MAIN' injected script. That's
a Firefox-specific Xray-wrapper restriction: a CustomEvent's `detail`
object created in one world can't have its properties read from the
other, even though the event itself fires fine. Chromium doesn't
enforce this, which is why it wasn't caught until testing on the
actual target browser (Zen).

Switched both sides of the bridge (wallet-bridge/inject.ts,
wallet-bridge/relay.ts) to window.postMessage with a `channel` field
and same-window source check, since postMessage structured-clones
its payload across the boundary correctly on both browsers -- the
same approach Phantom's own inpage<->content-script bridge uses.

Also added [nexa/...]-prefixed console.debug breadcrumbs through the
wallet-connect/wallet-bridge/wallet-auth chain, since diagnosing this
without them (previous commit shipped none) took several rounds of
"nothing happened" back and forth.

Still not fully verified end-to-end against live Phantom -- the
crash is fixed, but a full connect -> sign -> verify round trip
hasn't been confirmed yet. See CLAUDE.md.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01YXiHuScXrjxBh7yFGAPq3B
This commit is contained in:
2026-09-07 12:59:04 +02:00
co-authored by claude
parent 56a2ff9930
commit 140616a3e4
6 changed files with 66 additions and 30 deletions
+4
View File
@@ -13,6 +13,7 @@ import { storeSessionToken } from './backend-client';
* ws-client's own retry loop to keep trying getSessionToken()).
*/
export async function handleWalletConnected(tabId: number | undefined, walletAddress: string): Promise<void> {
console.debug('[nexa/wallet-auth] wallet connected', walletAddress, 'tab', tabId);
if (tabId == null) throw new Error('wallet-connected message had no source tab');
const nonceRes = await fetch(`${BACKEND_HTTP_URL}/auth/nonce`, {
@@ -22,12 +23,14 @@ export async function handleWalletConnected(tabId: number | undefined, walletAdd
});
if (!nonceRes.ok) throw new Error(`nonce request failed: ${nonceRes.status}`);
const { nonce } = (await nonceRes.json()) as { nonce: string };
console.debug('[nexa/wallet-auth] got nonce, asking tab to sign');
const signResult = (await browser.tabs.sendMessage(tabId, {
type: 'nexa:wallet-sign-request',
nonce,
} satisfies NexaMessage)) as WalletSignResult;
if ('error' in signResult) throw new Error(signResult.error);
console.debug('[nexa/wallet-auth] got signature, verifying');
const verifyRes = await fetch(`${BACKEND_HTTP_URL}/auth/verify`, {
method: 'POST',
@@ -38,6 +41,7 @@ export async function handleWalletConnected(tabId: number | undefined, walletAdd
const { session_token: sessionToken } = (await verifyRes.json()) as { session_token: string };
await storeSessionToken(sessionToken);
console.debug('[nexa/wallet-auth] session token stored');
}
/** Prompts any open supported-site tab to attempt a silent (onlyIfTrusted) reconnect — used after the session is invalidated server-side. */
+28 -12
View File
@@ -5,12 +5,20 @@ import bs58 from 'bs58';
* entrypoints/wallet-bridge.content.ts) — the only place `window.solana`
* (Phantom's injected provider) is reachable, since content scripts run in
* an isolated world that can't call into page-defined objects/functions
* directly. Talks to the isolated-world side (wallet-bridge/relay.ts) purely
* via CustomEvents on `window`, since that's the one channel both worlds
* share reliably across Chromium and Firefox.
* directly. Talks to the isolated-world side (wallet-bridge/relay.ts) via
* `window.postMessage`, NOT CustomEvents: on Firefox, a CustomEvent's
* `detail` object created in one world can't have its properties read from
* the other (Xray wrapper "Permission denied to access property" errors) —
* postMessage structured-clones its payload across that boundary correctly
* on both Firefox and Chromium, which is why every wallet-injection bridge
* (Phantom's own inpage script included) uses it instead.
*/
interface WalletCallDetail {
const CALL_CHANNEL = 'nexa:wallet-call';
const RESULT_CHANNEL = 'nexa:wallet-result';
interface WalletCallMessage {
channel: typeof CALL_CHANNEL;
id: string;
action: 'connect' | 'signMessage';
payload: { onlyIfTrusted?: boolean } | { message: string };
@@ -30,16 +38,22 @@ function getProvider(): PhantomProvider | undefined {
}
function respond(id: string, ok: boolean, dataOrError: unknown): void {
window.dispatchEvent(
new CustomEvent('nexa:wallet-result', {
detail: ok ? { id, ok, data: dataOrError } : { id, ok, error: dataOrError },
}),
window.postMessage(
ok ? { channel: RESULT_CHANNEL, id, ok, data: dataOrError } : { channel: RESULT_CHANNEL, id, ok, error: dataOrError },
window.location.origin,
);
}
export function initWalletBridgeInjected(): void {
window.addEventListener('nexa:wallet-call', (event) => {
const { id, action, payload } = (event as CustomEvent<WalletCallDetail>).detail;
console.debug('[nexa/wallet-bridge] injected script active on', window.location.href);
window.addEventListener('message', (event) => {
if (event.source !== window) return; // ignore iframes/other windows
const message = event.data as Partial<WalletCallMessage> | undefined;
if (message?.channel !== CALL_CHANNEL) return; // not ours — the page may postMessage for its own reasons
const { id, action, payload } = message as WalletCallMessage;
console.debug('[nexa/wallet-bridge] call', action, payload);
void (async () => {
try {
@@ -49,15 +63,17 @@ export function initWalletBridgeInjected(): void {
if (action === 'connect') {
const { onlyIfTrusted } = payload as { onlyIfTrusted?: boolean };
const result = await provider.connect(onlyIfTrusted ? { onlyIfTrusted: true } : undefined);
console.debug('[nexa/wallet-bridge] connect ok', result.publicKey.toString());
respond(id, true, { walletAddress: result.publicKey.toString() });
} else if (action === 'signMessage') {
const { message } = payload as { message: string };
const { signature } = await provider.signMessage(new TextEncoder().encode(message), 'utf8');
const { message: nonce } = payload as { message: string };
const { signature } = await provider.signMessage(new TextEncoder().encode(nonce), 'utf8');
respond(id, true, { signature: bs58.encode(signature) });
} else {
throw new Error(`Unknown wallet action: ${String(action)}`);
}
} catch (err) {
console.debug('[nexa/wallet-bridge] call failed:', err);
respond(id, false, err instanceof Error ? err.message : String(err));
}
})();
+21 -12
View File
@@ -1,11 +1,18 @@
/**
* Isolated-world side of the wallet bridge: calls into the page-world script
* (wallet-bridge/inject.ts) via CustomEvents and awaits its response by
* matching request/response ids. This is the only way to reach
* `window.solana` from a normal (isolated-world) content script.
* (wallet-bridge/inject.ts) via `window.postMessage` and awaits its response
* by matching request/response ids. postMessage, not CustomEvent — a
* CustomEvent's `detail` object can't have its properties read across the
* isolated/main-world boundary on Firefox (Xray wrapper "Permission denied"
* errors); postMessage structured-clones its payload correctly on both
* Firefox and Chromium. See wallet-bridge/inject.ts for the other side.
*/
interface WalletResultDetail {
const CALL_CHANNEL = 'nexa:wallet-call';
const RESULT_CHANNEL = 'nexa:wallet-result';
interface WalletResultMessage {
channel: typeof RESULT_CHANNEL;
id: string;
ok: boolean;
data?: unknown;
@@ -23,20 +30,22 @@ export function callWallet<T>(action: 'connect' | 'signMessage', payload: unknow
reject(new Error('Wallet bridge timed out — is Phantom installed and unlocked?'));
}, WALLET_CALL_TIMEOUT_MS);
function handleResult(event: Event): void {
const detail = (event as CustomEvent<WalletResultDetail>).detail;
if (detail.id !== id) return; // another concurrent call's response
function handleMessage(event: MessageEvent): void {
if (event.source !== window) return; // ignore iframes/other windows
const message = event.data as Partial<WalletResultMessage> | undefined;
if (message?.channel !== RESULT_CHANNEL || message.id !== id) return;
cleanup();
if (detail.ok) resolve(detail.data as T);
else reject(new Error(detail.error ?? 'Wallet call failed'));
if (message.ok) resolve(message.data as T);
else reject(new Error(message.error ?? 'Wallet call failed'));
}
function cleanup(): void {
clearTimeout(timeoutId);
window.removeEventListener('nexa:wallet-result', handleResult);
window.removeEventListener('message', handleMessage);
}
window.addEventListener('nexa:wallet-result', handleResult);
window.dispatchEvent(new CustomEvent('nexa:wallet-call', { detail: { id, action, payload } }));
window.addEventListener('message', handleMessage);
window.postMessage({ channel: CALL_CHANNEL, id, action, payload }, window.location.origin);
});
}
+8 -2
View File
@@ -12,18 +12,21 @@ interface SignResult {
}
async function reportConnected(walletAddress: string): Promise<void> {
console.debug('[nexa/wallet-connect] connected', walletAddress);
hideConnectBanner();
await browser.runtime
.sendMessage({ type: 'nexa:wallet-connected', walletAddress } satisfies NexaMessage)
.catch(() => undefined);
.catch((err) => console.debug('[nexa/wallet-connect] failed to notify background:', err));
}
/** Real user gesture (banner button click) — required for Phantom to show its connect approval popup on a first-ever connect. */
async function connectWithGesture(): Promise<void> {
console.debug('[nexa/wallet-connect] banner clicked, calling connect()');
try {
const { walletAddress } = await callWallet<ConnectResult>('connect', {});
await reportConnected(walletAddress);
} catch (err) {
console.debug('[nexa/wallet-connect] connect() failed:', err);
setConnectBannerError(err instanceof Error ? err.message : String(err));
}
}
@@ -34,16 +37,19 @@ async function connectWithGesture(): Promise<void> {
* connect. Falls back to the on-page banner (a real click) only when it isn't.
*/
async function attemptSilentConnect(): Promise<void> {
console.debug('[nexa/wallet-connect] attempting silent connect');
try {
const { walletAddress } = await callWallet<ConnectResult>('connect', { onlyIfTrusted: true });
await reportConnected(walletAddress);
} catch {
} catch (err) {
console.debug('[nexa/wallet-connect] silent connect failed, showing banner:', err);
showConnectBanner(() => void connectWithGesture());
}
}
/** Wires wallet connect/sign into the page. Independent of any site adapter — runs regardless of whether a buy-button adapter matched. */
export function initWalletConnect(): void {
console.debug('[nexa/wallet-connect] init on', window.location.href);
void attemptSilentConnect();
browser.runtime.onMessage.addListener((message: NexaMessage, _sender, sendResponse) => {
+1 -1
View File
@@ -34,7 +34,7 @@ export default defineBackground(() => {
case 'nexa:wallet-connected':
handleWalletConnected(sender.tab?.id, message.walletAddress)
.then(() => ws.reconnectNow())
.catch(() => undefined); // signing/verify failed — ws-client's own retry loop keeps trying
.catch((err) => console.debug('[nexa/background] wallet auth failed:', err)); // ws-client's own retry loop keeps trying regardless
return false;
default: