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: