From 8d104ef3cfb9bf08bbd01a361d1e4d46ee4b74da Mon Sep 17 00:00:00 2001 From: Klesti Selimaj Date: Mon, 7 Sep 2026 14:02:11 +0200 Subject: [PATCH] Fix duplicate signing; make account switching an explicit action Root cause of "requires a lot of signs, not just a single one": inject.ts wired Phantom's own accountChanged provider event to auto-trigger re-auth, but calling connect() ourselves also fires that same event -- so a normal silent reconnect raced its own event-triggered handler, producing two competing "wallet connected" reports that each independently asked Phantom to sign a fresh nonce. Removed that event wiring entirely (onWalletEvent, EVENT_CHANNEL) -- inject.ts now only responds to our own explicit calls, never reacts to unsolicited provider events. Per feedback, account switching isn't something that should be inferred from a Phantom event anyway; it's now its own explicit feature: a "Switch account" button in the popup (nexa:switch-account) that tells the content script to disconnect and immediately reconnect, so Phantom's connect UI reflects whichever account is currently active there. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01YXiHuScXrjxBh7yFGAPq3B --- CLAUDE.md | 5 ++- src/background/wallet-auth.ts | 5 +++ src/content-scripts/wallet-bridge/inject.ts | 40 ++++++------------- src/content-scripts/wallet-bridge/relay.ts | 17 -------- src/content-scripts/wallet-connect.ts | 44 ++++++++++++++------- src/entrypoints/background.ts | 22 +++++++---- src/entrypoints/popup/App.tsx | 15 +++++-- src/entrypoints/popup/style.css | 9 +++-- src/shared/messaging.ts | 6 ++- 9 files changed, 87 insertions(+), 76 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index dec5554..acb62e2 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -101,7 +101,10 @@ Real wallet signing, not a stub keypair. Phantom (and any wallet injecting a com - `src/content-scripts/wallet-bridge/banner.ts` — minimal plain-DOM "Connect your wallet to Nexa" prompt injected onto the page (bottom-right, fixed position) when a wallet isn't already trusted for this origin. No framework, kept deliberately tiny since it's living on someone else's page. - `src/content-scripts/wallet-connect.ts` — orchestrates the above, wired into `content-index.ts` (runs independently of site-adapter matching): on load, tries `connect({ onlyIfTrusted: true })` silently (succeeds with no user interaction if the user already approved this origin in Phantom before); on failure, shows the banner and only calls plain `connect()` from the banner button's own click handler, since **a real user gesture is required for Phantom to show its approval popup on a first-ever connect** — this is why the banner exists in the page rather than the extension popup (a click in the popup's UI doesn't count as a gesture on the axiom.trade page by the time it reaches the wallet, since it crosses an extension-messaging boundary asynchronously). Once connected, reports `{ walletAddress }` to the background via `nexa:wallet-connected`; also handles `nexa:wallet-sign-request` (background asks it to sign a nonce) and `nexa:request-wallet-connect` (background asks it to retry the silent connect, e.g. after a session was invalidated). - **Known gap**: this only works while an axiom.trade tab is open — there's no wallet connection path from the popup alone. That's intentional for now (matches the "only live while on a supported site" framing in backend/CLAUDE.md's RPC-subscription note), not an oversight. -- **Account switching and sign-out (implemented)**: `inject.ts` also wires Phantom's own `accountChanged`/`disconnect` provider events (not just our own request/response calls) and relays them as unsolicited messages, since the user can switch accounts or disconnect directly in Phantom's UI without ever touching our banner. `wallet-connect.ts` reacts by re-authenticating as the new account, or showing the connect banner again. A "Sign out" button in the popup (`nexa:sign-out` → `background.ts`) clears the stored session, force-closes the WS connection (`ws-client.ts`'s `disconnect()`, distinct from `reconnectNow()` — it also suppresses auto-reconnect until a new wallet connects), and asks the content script to call `provider.disconnect()`, which revokes Phantom's trust for the origin so the next silent connect correctly fails until the user reconnects. +- **Sign out and account switching (implemented) — both explicit user actions, not automatic.** An earlier version of this also wired Phantom's own `accountChanged` provider event to trigger re-auth automatically, but calling `connect()` ourselves also fires that same event — so a normal silent reconnect raced its own event-triggered handler and produced *two* competing "wallet connected" reports, each independently asking Phantom to sign a nonce (a popup every time). Removed entirely; `wallet-bridge/inject.ts` does not listen for any Phantom provider events, only responds to our own explicit calls. See its doc comment for the full story if this is ever reconsidered. + - "Sign out" (popup button → `nexa:sign-out` → `background.ts`'s `signOut()`) clears the stored session, force-closes the WS connection (`ws-client.ts`'s `disconnect()`, distinct from `reconnectNow()` — it also suppresses auto-reconnect until a new wallet connects), and asks the content script to call `provider.disconnect()`, which revokes Phantom's trust for the origin so the next silent connect correctly fails until the user reconnects. + - "Switch account" (popup button → `nexa:switch-account` → `wallet-connect.ts`'s `switchAccount()`) explicitly disconnects then immediately reconnects, so Phantom shows its connect approval UI for whichever account is currently active there. Not fully verified whether Phantom requires a fresh user gesture for this non-`onlyIfTrusted` connect call relayed from the popup (vs. a direct page click) — if it does, `switchAccount()` falls back to the on-page banner so the user can complete it with a real click. + - `background.ts`'s `nexa:wallet-connected` handler distinguishes "already signed in as this wallet" (no-op) from "signed in as a *different* wallet" (re-authenticate) by comparing the reported wallet address against `backend-client.ts`'s stored `{ token, walletAddress }` pair — a bare "do we have a token" check can't tell those apart and was the reason the account-switch case needed fixing in the first place. - **Session storage tracks which wallet it belongs to** (`backend-client.ts`'s `storeSession(token, walletAddress)`, not just a bare token) — `background.ts`'s `nexa:wallet-connected` handler needs this to tell "already signed in" apart from "signed in as a *different* wallet than the one that just connected" (an account switch); a bare "do we have any token" check can't distinguish those and would silently keep the stale session instead of re-authenticating as the new account. - **Firefox gotcha (hit during dev, now fixed)**: the isolated↔main-world bridge originally used `CustomEvent`s dispatched on `window`. That works on Chromium but throws `Uncaught Error: Permission denied to access property "id"` on Firefox — a `CustomEvent.detail` object created in one world can't have its properties read from the other (an Xray-wrapper security restriction specific to Firefox's extension model). Fixed by switching to `window.postMessage` for this bridge, which structured-clones its payload across the boundary correctly on both browsers — the same technique Phantom's own inpage↔content-script bridge uses. If you're extending this bridge, don't reach for `CustomEvent` again for isolated↔main-world data; `postMessage` (with a `channel` field to disambiguate from the page's own postMessage traffic, and an `event.source === window` check) is the pattern here. - **Verified working end-to-end against real Phantom** on Zen: connect → sign → `/auth/verify` → session token stored, confirmed via the `[nexa/wallet-*]` debug logs. `world: 'MAIN'` also needs Firefox 128+; confirmed fine on Zen's base version. diff --git a/src/background/wallet-auth.ts b/src/background/wallet-auth.ts index 958af1d..1104011 100644 --- a/src/background/wallet-auth.ts +++ b/src/background/wallet-auth.ts @@ -62,3 +62,8 @@ export async function requestWalletReconnect(): Promise { export async function requestWalletDisconnect(): Promise { await messageAllSupportedTabs({ type: 'nexa:wallet-disconnect-request' }); } + +/** Explicit "use a different account" — a deliberate user action (popup button), not something inferred from a wallet-provider event. See wallet-connect.ts's switchAccount(). */ +export async function requestAccountSwitch(): Promise { + await messageAllSupportedTabs({ type: 'nexa:switch-account-request' }); +} diff --git a/src/content-scripts/wallet-bridge/inject.ts b/src/content-scripts/wallet-bridge/inject.ts index eb32520..9a587f6 100644 --- a/src/content-scripts/wallet-bridge/inject.ts +++ b/src/content-scripts/wallet-bridge/inject.ts @@ -12,11 +12,17 @@ import bs58 from 'bs58'; * 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. + * + * Deliberately does NOT listen for Phantom's own `accountChanged`/`disconnect` + * provider events. Calling `connect()` ourselves also fires `accountChanged` + * as a side effect, so an automatic listener raced against our own call's + * resolution and produced duplicate "wallet connected" reports — each one + * independently triggering a fresh sign prompt. Account switching is a + * separate, explicit action instead (see wallet-connect.ts's `switchAccount`). */ const CALL_CHANNEL = 'nexa:wallet-call'; const RESULT_CHANNEL = 'nexa:wallet-result'; -const EVENT_CHANNEL = 'nexa:wallet-event'; interface WalletCallMessage { channel: typeof CALL_CHANNEL; @@ -30,7 +36,6 @@ interface PhantomProvider { connect(opts?: { onlyIfTrusted?: boolean }): Promise<{ publicKey: { toString(): string } }>; disconnect?(): Promise; signMessage(message: Uint8Array, display?: string): Promise<{ signature: Uint8Array }>; - on?(event: 'accountChanged' | 'disconnect', handler: (publicKey?: { toString(): string } | null) => void): void; } function getProvider(): PhantomProvider | undefined { @@ -47,30 +52,9 @@ function respond(id: string, ok: boolean, dataOrError: unknown): void { ); } -/** Wires Phantom's own account-switch/disconnect events so the isolated world finds out even when nothing on our side initiated it. */ -function wireProviderEvents(provider: PhantomProvider): void { - provider.on?.('accountChanged', (publicKey) => { - const walletAddress = publicKey ? publicKey.toString() : null; - console.debug('[nexa/wallet-bridge] accountChanged', walletAddress); - window.postMessage({ channel: EVENT_CHANNEL, name: 'accountChanged', walletAddress }, window.location.origin); - }); - - provider.on?.('disconnect', () => { - console.debug('[nexa/wallet-bridge] disconnect event'); - window.postMessage({ channel: EVENT_CHANNEL, name: 'disconnect', walletAddress: null }, window.location.origin); - }); -} - export function initWalletBridgeInjected(): void { console.debug('[nexa/wallet-bridge] injected script active on', window.location.href); - // Phantom usually injects by document_start too, but ordering between two - // extensions' content scripts isn't guaranteed — retry once shortly after - // if it wasn't there yet, so accountChanged/disconnect still get wired up. - const provider = getProvider(); - if (provider) wireProviderEvents(provider); - else setTimeout(() => getProvider() && wireProviderEvents(getProvider()!), 500); - window.addEventListener('message', (event) => { if (event.source !== window) return; // ignore iframes/other windows const message = event.data as Partial | undefined; @@ -81,20 +65,20 @@ export function initWalletBridgeInjected(): void { void (async () => { try { - const activeProvider = getProvider(); - if (!activeProvider) throw new Error('No Solana wallet (Phantom) detected on this page.'); + const provider = getProvider(); + if (!provider) throw new Error('No Solana wallet (Phantom) detected on this page.'); if (action === 'connect') { const { onlyIfTrusted } = payload as { onlyIfTrusted?: boolean }; - const result = await activeProvider.connect(onlyIfTrusted ? { onlyIfTrusted: true } : undefined); + 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: nonce } = payload as { message: string }; - const { signature } = await activeProvider.signMessage(new TextEncoder().encode(nonce), 'utf8'); + const { signature } = await provider.signMessage(new TextEncoder().encode(nonce), 'utf8'); respond(id, true, { signature: bs58.encode(signature) }); } else if (action === 'disconnect') { - await activeProvider.disconnect?.(); + await provider.disconnect?.(); respond(id, true, {}); } else { throw new Error(`Unknown wallet action: ${String(action)}`); diff --git a/src/content-scripts/wallet-bridge/relay.ts b/src/content-scripts/wallet-bridge/relay.ts index 3759050..850ba58 100644 --- a/src/content-scripts/wallet-bridge/relay.ts +++ b/src/content-scripts/wallet-bridge/relay.ts @@ -10,7 +10,6 @@ const CALL_CHANNEL = 'nexa:wallet-call'; const RESULT_CHANNEL = 'nexa:wallet-result'; -const EVENT_CHANNEL = 'nexa:wallet-event'; interface WalletResultMessage { channel: typeof RESULT_CHANNEL; @@ -20,12 +19,6 @@ interface WalletResultMessage { error?: string; } -interface WalletEventMessage { - channel: typeof EVENT_CHANNEL; - name: string; - walletAddress: string | null; -} - const WALLET_CALL_TIMEOUT_MS = 30_000; export function callWallet(action: 'connect' | 'signMessage' | 'disconnect', payload: unknown): Promise { @@ -56,13 +49,3 @@ export function callWallet(action: 'connect' | 'signMessage' | 'disconnect', window.postMessage({ channel: CALL_CHANNEL, id, action, payload }, window.location.origin); }); } - -/** Unsolicited events from Phantom itself (account switched in its UI, or disconnected there) — not a response to any `callWallet` request. */ -export function onWalletEvent(handler: (name: string, walletAddress: string | null) => void): void { - window.addEventListener('message', (event) => { - if (event.source !== window) return; - const message = event.data as Partial | undefined; - if (message?.channel !== EVENT_CHANNEL) return; - handler(message.name ?? '', message.walletAddress ?? null); - }); -} diff --git a/src/content-scripts/wallet-connect.ts b/src/content-scripts/wallet-connect.ts index c518e07..51c0855 100644 --- a/src/content-scripts/wallet-connect.ts +++ b/src/content-scripts/wallet-connect.ts @@ -1,6 +1,6 @@ import { browser } from 'wxt/browser'; import type { NexaMessage } from '@/shared/messaging'; -import { callWallet, onWalletEvent } from './wallet-bridge/relay'; +import { callWallet } from './wallet-bridge/relay'; import { hideConnectBanner, setConnectBannerError, showConnectBanner } from './wallet-bridge/banner'; interface ConnectResult { @@ -19,7 +19,7 @@ async function reportConnected(walletAddress: string): Promise { .catch((err) => console.debug('[nexa/wallet-connect] failed to notify background:', err)); } -/** Wallet disconnected or switched to no account — clears the backend session (tied to the old wallet) and re-shows the connect prompt. */ +/** Sign-out or a failed switch — clears the backend session and re-shows the connect prompt. */ async function reportDisconnected(): Promise { console.debug('[nexa/wallet-connect] disconnected'); await browser.runtime @@ -56,23 +56,32 @@ async function attemptSilentConnect(): Promise { } } +/** + * Explicit "use a different account" action (popup button, not automatic — + * see wallet-bridge/inject.ts for why this isn't driven by Phantom's own + * accountChanged event). Disconnects and immediately reconnects, so Phantom + * shows its connect approval UI again for whichever account is now active. + * May need a real click to succeed (Phantom can require a gesture for a + * non-onlyIfTrusted connect); if it fails for that reason, this falls back + * to the same on-page banner as a first-time connect would. + */ +async function switchAccount(): Promise { + console.debug('[nexa/wallet-connect] switch account requested'); + try { + await callWallet('disconnect', {}).catch(() => undefined); // best-effort — fine if already disconnected + const { walletAddress } = await callWallet('connect', {}); + await reportConnected(walletAddress); + } catch (err) { + console.debug('[nexa/wallet-connect] switch account failed:', 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(); - // Phantom's own account-switch/disconnect events — not initiated by us, so - // this catches the user changing accounts (or disconnecting) directly in - // Phantom's UI, not just our own sign-out flow below. - onWalletEvent((name, walletAddress) => { - console.debug('[nexa/wallet-connect] wallet event', name, walletAddress); - if (walletAddress) { - void reportConnected(walletAddress); // switched to a different account — re-auth as it - } else { - void reportDisconnected(); // accountChanged(null) or a 'disconnect' event - } - }); - browser.runtime.onMessage.addListener((message: NexaMessage, _sender, sendResponse) => { if (message?.type === 'nexa:wallet-sign-request') { callWallet('signMessage', { message: message.nonce }) @@ -89,13 +98,18 @@ export function initWalletConnect(): void { if (message?.type === 'nexa:wallet-disconnect-request') { // Sign-out, initiated from the popup (see background/wallet-auth.ts). // Phantom's disconnect() revokes this origin's trust, so the next - // onlyIfTrusted attempt correctly fails until the user connects again. + // onlyIfTrusted attempt correctly fails until the user reconnects. callWallet('disconnect', {}) .catch((err) => console.debug('[nexa/wallet-connect] disconnect() failed:', err)) .finally(() => void reportDisconnected()); return false; } + if (message?.type === 'nexa:switch-account-request') { + void switchAccount(); + return false; + } + return undefined; }); } diff --git a/src/entrypoints/background.ts b/src/entrypoints/background.ts index ea1e683..9b3eb8a 100644 --- a/src/entrypoints/background.ts +++ b/src/entrypoints/background.ts @@ -1,7 +1,12 @@ import { clearSessionToken, getSessionToken, getStoredSession } from '@/background/backend-client'; import { getConnectionStatus, setConnectionStatus } from '@/background/connection-status'; import { applyLockState, getLockState } from '@/background/lock-state'; -import { handleWalletConnected, requestWalletDisconnect, requestWalletReconnect } from '@/background/wallet-auth'; +import { + handleWalletConnected, + requestAccountSwitch, + requestWalletDisconnect, + requestWalletReconnect, +} from '@/background/wallet-auth'; import { connectWsClient } from '@/background/ws-client'; import type { NexaMessage } from '@/shared/messaging'; @@ -39,12 +44,11 @@ export default defineBackground(() => { case 'nexa:wallet-connected': // A content script reports this on every page load (it always tries - // a silent onlyIfTrusted connect first), AND whenever the user - // switches accounts in Phantom's own UI. Only re-authenticate (which - // means asking Phantom to sign a fresh nonce — a popup every time, - // unlike a silent connect) when this isn't the wallet we're already - // signed in as; a bare "do we have a token" check can't tell those - // apart from an account switch. + // a silent onlyIfTrusted connect first), and after an explicit + // account switch. Only re-authenticate (which means asking Phantom + // to sign a fresh nonce — a popup every time, unlike a silent + // connect) when this isn't the wallet we're already signed in as; a + // bare "do we have a token" check can't tell those apart. getStoredSession() .then((session) => { if (session?.walletAddress === message.walletAddress) return; @@ -63,6 +67,10 @@ export default defineBackground(() => { void signOut(); return false; + case 'nexa:switch-account': + void requestAccountSwitch(); + return false; + default: return undefined; } diff --git a/src/entrypoints/popup/App.tsx b/src/entrypoints/popup/App.tsx index ffcad81..e20586a 100644 --- a/src/entrypoints/popup/App.tsx +++ b/src/entrypoints/popup/App.tsx @@ -38,6 +38,10 @@ export function App() { void browser.runtime.sendMessage({ type: 'nexa:sign-out' } satisfies NexaMessage); } + function switchAccount(): void { + void browser.runtime.sendMessage({ type: 'nexa:switch-account' } satisfies NexaMessage); + } + return ( <>
@@ -64,9 +68,14 @@ export function App() { {connectionStatus === 'connected' && ( - +
+ + +
)} background). See entrypoints/popup/App.tsx. - | { type: 'nexa:sign-out' }; + | { type: 'nexa:switch-account-request' } + // Sign-out / switch account (popup -> background). See entrypoints/popup/App.tsx. + | { type: 'nexa:sign-out' } + | { type: 'nexa:switch-account' }; /** Response shape for 'nexa:wallet-sign-request', returned via sendResponse (not a dispatched NexaMessage). */ export type WalletSignResult = { signature: string } | { error: string };