From 3bb1c2f0c86efa1cec156f5cae83bbd1dd11873f Mon Sep 17 00:00:00 2001 From: Klesti Selimaj Date: Mon, 7 Sep 2026 13:08:18 +0200 Subject: [PATCH] Skip re-signing when a session token already exists A content script reports 'wallet-connected' on every page load (it always tries a silent onlyIfTrusted connect first), and the handler was unconditionally running the full nonce/sign/verify cycle every time. signMessage() shows a fresh Phantom approval popup on every call, unlike connect() which is silent once trusted -- so this meant a new signature prompt on every single axiom.trade page load, caught during manual testing ("signing appears every time"). Now the handler checks for an existing valid session token first and only re-authenticates when there isn't one. Confirmed end-to-end against real Phantom on Zen: connect -> sign -> verify -> session token stored, via the wallet-auth debug logs. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01YXiHuScXrjxBh7yFGAPq3B --- CLAUDE.md | 3 ++- src/entrypoints/background.ts | 15 ++++++++++++--- 2 files changed, 14 insertions(+), 4 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 84ee045..4ff19ac 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -101,7 +101,8 @@ Real wallet signing, not a stub keypair. Phantom (and any wallet injecting a com - `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. - **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. -- **Still unverified**: the full approval-popup flow against real Phantom (does the gesture from the banner's click handler survive the isolated→main-world postMessage hop for Phantom's own internal "was this a user gesture" check; does `onlyIfTrusted` correctly persist across reloads) hasn't been confirmed end-to-end yet — the postMessage fix resolved the Firefox crash, but a full connect→sign→verify round-trip against live Phantom still needs a manual pass. `world: 'MAIN'` also needs Firefox 128+; confirm Zen's base version supports it. +- **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. +- `background.ts`'s `nexa:wallet-connected` handler only runs the nonce/sign/verify cycle if there's no session token stored yet (`getSessionToken()` succeeding short-circuits it). This matters because a content script reports `wallet-connected` on **every page load** (it always tries a silent `onlyIfTrusted` connect first) — `signMessage()` shows a fresh Phantom approval popup every single time it's called, unlike `connect()`, which is silent once trusted. Without this check, every axiom.trade page load would prompt a new signature approval even with an already-valid session — caught during manual testing. ## WebSocket protocol (backend wire contract) diff --git a/src/entrypoints/background.ts b/src/entrypoints/background.ts index 5609220..24c8536 100644 --- a/src/entrypoints/background.ts +++ b/src/entrypoints/background.ts @@ -32,9 +32,18 @@ export default defineBackground(() => { return false; case 'nexa:wallet-connected': - handleWalletConnected(sender.tab?.id, message.walletAddress) - .then(() => ws.reconnectNow()) - .catch((err) => console.debug('[nexa/background] wallet auth failed:', err)); // ws-client's own retry loop keeps trying regardless + // A content script reports this on every page load (it always tries + // a silent onlyIfTrusted connect first) — but re-authenticating + // means asking Phantom to sign a fresh nonce, which shows its own + // approval popup every time, unlike a silent connect. Only pay that + // cost when we don't already have a usable session. + getSessionToken() + .then(() => undefined) // already authenticated — nothing to do + .catch(() => + handleWalletConnected(sender.tab?.id, message.walletAddress) + .then(() => ws.reconnectNow()) + .catch((err) => console.debug('[nexa/background] wallet auth failed:', err)), // ws-client's own retry loop keeps trying regardless + ); return false; default: