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 <[email protected]>
Claude-Session: https://claude.ai/code/session_01YXiHuScXrjxBh7yFGAPq3B
This commit is contained in:
2026-09-07 13:08:18 +02:00
co-authored by claude
parent 140616a3e4
commit 3bb1c2f0c8
2 changed files with 14 additions and 4 deletions
+2 -1
View File
@@ -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)
+10 -1
View File
@@ -32,9 +32,18 @@ export default defineBackground(() => {
return false;
case 'nexa:wallet-connected':
// 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
.catch((err) => console.debug('[nexa/background] wallet auth failed:', err)), // ws-client's own retry loop keeps trying regardless
);
return false;
default: