Files
copilot/CLAUDE.md
T
selimaj-devandclaude b93a9c6f10 Support Phantom account switching and add sign-out
Wires Phantom's own accountChanged/disconnect provider events
(inject.ts) so switching accounts or disconnecting directly in
Phantom's UI is detected, not just our own connect/sign calls --
relayed as unsolicited postMessage events (relay.ts's onWalletEvent)
since they aren't a response to any request we made.

Adds a "Sign out" button in the popup (nexa:sign-out) that clears the
stored session, force-closes the WS connection via a new
ws-client.ts 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.

Fixes a real bug this surfaced: the existing "skip re-auth if a
session token exists" check in background.ts only checked for *any*
token, so switching Phantom accounts would have silently kept
authenticating as the old wallet. Session storage now tracks which
wallet it belongs to (backend-client.ts's storeSession(token,
walletAddress)) so the handler can tell "already signed in" apart
from "signed in as a different wallet than the one that just
connected."

Co-Authored-By: Claude Sonnet 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01YXiHuScXrjxBh7yFGAPq3B
2026-09-07 13:48:05 +02:00

23 KiB

CLAUDE.md

This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.

Repository state

Scaffolded with WXT (Vite-based, TypeScript-first, built on top of webextension-polyfill for the cross-browser browser.* API) — the popular framework for building Manifest V3 extensions that target both Firefox and Chromium from one codebase. Package manager: npm.

Commands:

  • npm install — install deps (also runs wxt prepare via postinstall to generate .wxt/ types).
  • npm run dev — dev build + watch, targets Chromium by default.
  • npm run dev:firefox — dev build + watch targeting Firefox (use this for Zen).
  • npm run build / npm run build:firefox — production build, output in .output/chrome-mv3/ or .output/firefox-mv3/.
  • npm run compile — tsc --noEmit type-check only.
  • npm run zip / npm run zip:firefox — production build packaged as a .zip for store upload.

Load-unpacked: run a build, then in Chromium go to chrome://extensions → enable Developer Mode → "Load unpacked" → select .output/chrome-mv3/. In Firefox/Zen go to about:debugging#/runtime/this-firefox → "Load Temporary Add-on" → select any file inside .output/firefox-mv3/ (or use npm run dev:firefox, which auto-opens a temporary profile with the extension loaded).

There is no lint/test setup yet — add one when the project needs it rather than pre-emptively scaffolding.

What this project is

Nexa is a blockchain-powered trading companion focused on the emotional/behavioral side of trading, not the trades themselves. Trades are already visible on-chain, so Nexa observes wallet activity rather than requiring manual logging. This repo is the open-source frontend: a browser extension. The backend (wallet/RPC listening via Solana/Helius, loss detection, auth, WebSocket push) is a separate private service and is out of scope here — treat it as an external API.

License: Apache 2.0. The backend stays closed-source; this frontend repo is the OSS component.

The behavioral spec and the backend WebSocket protocol spec (formerly NEXA_SPEC.md and NEXA_PROTOCOL_SPEC.md) have been folded into this file — this document is now the source of truth for both, not a summary of them.

Target platform

  • Built and tested primarily on Zen (Firefox fork), must also work cross-platform on Firefox and Chromium-based browsers (WebExtensions API / Manifest V3 where possible).
  • Avoid browser-specific APIs unless behind a compatibility shim.

Current milestone: "Lockout" feature

When the extension receives a "locked" state, it visually disables (not hides) buy-action elements on supported trading sites, shows a non-dismissible-to-unlock reason toast, and intercepts clicks that reach the underlying element. Sell/manage actions are never touched. Unlocking reverses all DOM changes cleanly with no leftover overlays/classes.

Escalation model: first disable new-position buy actions only; if circumvented, escalate to a full-site block (stub only for v1).

Required architecture (modularity is the core constraint)

This is the most important design rule in the spec: lock/unlock decision-making, site-specific detection, and enforcement/intervention must be three fully decoupled concerns.

  • Adding a new supported site = a new site adapter module, without touching core logic.
  • Adding a new intervention type (full-site block, delayed buy with countdown, confirmation modal) = a new intervention strategy module, without touching adapters.
  • The decision of when/how long/why to lock is decoupled from how it's enforced on the page and from how it's detected on the page.

Planned structure:

/src
  /background            - owns WebSocket connection to backend, holds current lock state, messages content scripts
  /content-scripts
    /adapters
      axiom.ts            - site-specific selectors + DOM strategy for axiom.trade
      adapter-interface.ts - shared type/interface all adapters implement
    /interventions
      blur-disable.ts       - visual lock treatment (v1 default)
      full-block.ts         - escalation: full page block overlay (stub for v1)
      intervention-interface.ts
    observer.ts           - generic MutationObserver watching for adapter-declared selectors (SPA-safe)
    content-index.ts      - wires adapter + intervention + background messages together
  /popup                 - status, reason for lock, threshold settings link
  /notifications         - toast/banner injected into page or via browser notification API
  /shared
    messaging.ts          - typed message contracts between background <-> content scripts <-> popup
    types.ts
manifest.json

A site adapter's contract: matches (URL pattern), findBuyElements(), getContainer(el) (stable ancestor to style, so the whole control is styled rather than an inner span).

Axiom.trade adapter specifics (first target site)

  • Axiom is a React SPA — buy elements mount/unmount without full page reloads, and some controls (see Buy/Sell toggle below) mutate class/text on the same node in place instead of remounting. observer.ts watches childList, attributes (class only), and characterData, all with subtree: true, on document.body — a childList-only observer misses in-place toggles entirely.
  • Quick Buy: .buy-click-container is a shared wrapper around both the buy and sell quick-amount pills as siblings — it is not buy-specific, despite the name. The buy pill is always wrapper.firstElementChild; never treat the wrapper itself as the styleable element, or the sell pill gets dragged in with it. A secondary check requires the candidate first child to carry an [class*="increase"] marker (Axiom's buy/green color token) as a sanity guard.
  • Noob Mode Buy button: button.bg-increase whose own text matches /^Buy\s/i. Do not hardcode token names — they vary per token page. The Buy/Sell toggle here is the same button element switching state in place (not two separate buttons), which is exactly the in-place-mutation case the observer above exists for — and content-index.ts's reconcile() must actively call intervention.remove() on a still-connected container that stops matching (e.g. toggled to Sell), not just stop tracking nodes that get removed from the DOM entirely.
  • getContainer(el) for this adapter is intentionally the identity function — both findBuyElements() branches above already resolve to exactly the buy-side element, so there's no ancestor left to climb to (and climbing is what caused the sell-touching bugs in the first place).

Lock state contract

The lock/unlock state is a simple typed interface: { locked: boolean, reason?: string, scope: 'buy-only' | 'full-block' }. Implemented: the background script (src/background/lock-state.ts) exposes the single isolated seam (getLockState() / applyLockState()) — nothing else reads or writes lock state directly. applyLockState() is called exclusively by the real WS client (src/background/ws-client.ts) on an incoming lock_state message; there is no mock/dev toggle anymore since a real backend exists (the popup used to have one — removed once the backend connection landed).

Note the naming mismatch with the wire protocol below: the frontend's internal LockScope uses kebab-case ('buy-only' | 'full-block'), while the wire protocol (§ "WebSocket protocol") uses snake_case ("buy_only" | "full_block"). ws-client.ts is exactly where that translation happens — not leaked into content scripts or the popup.

Backend connection (implemented)

  • src/shared/config.ts — BACKEND_HTTP_URL/BACKEND_WS_URL, currently hardcoded to localhost:8080 (dev only; host_permissions in wxt.config.ts must stay in sync with whatever host is configured here). Deliberately not :3000 — that's this extension's own Vite dev server port (npm run dev), and running the backend on the same port breaks the dev popup silently: its script tags point at Vite, but the backend answers instead, so nothing ever renders. If you see a blank popup with http://localhost:3000/... script tags in "View Page Source" that 404 or return something unexpected, this port collision is the first thing to check.
  • Firefox-only CSP override in wxt.config.ts: Firefox's implicit default extension-pages CSP includes upgrade-insecure-requests, which silently rewrites the WS client's ws://localhost:8080/ws connection to wss:// and breaks it against the plaintext local dev backend (no TLS in dev, deliberately — see backend/CLAUDE.md). Symptom: Content-Security-Policy: Upgrading insecure request 'ws://...' to use 'wss' in the console, followed by a failed connection, no other error. Fixed by declaring an explicit content_security_policy.extension_pages (otherwise identical to Firefox's own default) for the Firefox build only — an explicit CSP replaces the implicit one entirely, dropping the upgrade directive. Chrome doesn't have this behavior, so the override is gated on browser === 'firefox' in the manifest function. If the real deployed backend ever moves to plain ws:// too (vs. wss:// behind a real domain), this override needs to travel with it; if the backend gets TLS, this whole override becomes unnecessary and should be removed rather than left as dead configuration.
  • src/background/backend-client.ts — session token storage only (getSessionToken()/storeSessionToken()/clearSessionToken()). Getting a token in the first place is wallet-auth.ts's job.
  • src/background/wallet-auth.ts — runs the REST auth flow (POST /auth/nonce → Phantom signature → POST /auth/verify → session token) once a content script reports a connected wallet; also requestWalletReconnect(), used after the backend invalidates a session.
  • src/background/ws-client.ts — the WS client described above: connects to /ws?token=.... connectWsClient() returns a controller with reconnectNow() so the background script can short-circuit the backoff wait right after a fresh token arrives. Auth failures (4001 close, auth_expired/session_revoked errors) do not auto-retry with backoff — they call onAuthExpired() instead, since retrying with a known-bad token can't succeed; only real disconnects (network drop, backgrounded browser) use the protocol's suggested backoff schedule.
  • src/background/connection-status.ts — separate from lock state; the popup surfaces this (connecting/connected/disconnected/auth-error) alongside the lock state so a broken connection isn't silently indistinguishable from "unlocked".

Wallet auth (Phantom) — implemented

Real wallet signing, not a stub keypair. Phantom (and any wallet injecting a compatible window.solana) is only reachable from a page's own JS world — a normal (isolated-world) content script cannot call into it directly, hence the two-content-script bridge below. This is the standard pattern for extensions that need to talk to page-injected wallet providers.

  • src/entrypoints/wallet-bridge.content.ts + src/content-scripts/wallet-bridge/inject.ts — a second, world: 'MAIN' content script on axiom.trade (Manifest V3 native main-world injection — no web_accessible_resources/dynamic <script> tag needed). This is the only file that touches window.solana/window.phantom.solana directly: connect() and signMessage(). Talks back to the isolated world via window.postMessage (channel-tagged messages, matched by request id) — not CustomEvent, see the callout below.
  • src/content-scripts/wallet-bridge/relay.ts — isolated-world side of that bridge: callWallet(action, payload) posts the request message and returns a promise that resolves/rejects on the matching result message, with a 30s timeout.
  • 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.
  • 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 CustomEvents 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.
  • 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)

Single source of truth for the wire protocol between the Nexa backend (Rust/Axum) and this extension's background script. Backend and frontend repos both implement against this section — not against each other's internal types. If either side needs to change a message shape, this doc changes first.

Transport

  • WebSocket, endpoint /ws on the backend.
  • One connection per authenticated user session (a user may have multiple extension instances connected; backend broadcasts to all of that user's active connections).
  • Auth: session token obtained via the REST auth flow, passed as a query param on the WS upgrade request: wss://<host>/ws?token=<session_token>. No cookies (browser extension client).
  • JSON text frames only (no binary frames in v1). Every message has a top-level type field (snake_case string) that determines the rest of the shape.
  • Wire field naming is snake_case throughout, regardless of language-side convention on either end (Rust: #[serde(rename_all = "snake_case")]; TS/JS: use the wire names directly, converting only at the frontend's lock-state seam — see note above).

Shared enum: type LockScope = "buy_only" | "full_block". buy_only disables new-position buy actions only (sell/manage stay enabled); full_block blocks the site entirely (escalation state).

Server → client messages

  • lock_state — sent immediately on connect (so a freshly opened browser isn't out of sync) and on every state transition (new lock, escalation, unlock):

    { "type": "lock_state", "locked": true, "scope": "buy_only", "reason": "Down $62 on PIXELCAT, past your $50 threshold", "triggered_at": "2026-09-06T12:00:00Z" }
    

    locked (bool, required). scope (required, present even when locked: false — represents the scope that would/last applied, so the client never has to guess). reason and triggered_at (ISO 8601 UTC string) are nullable, null/omitted when locked: false.

  • ack — response to every client request message (§ below), exactly one per request. A resulting lock_state (if the request changed state) is sent separately, after the ack:

    { "type": "ack", "for": "unlock_request", "success": true, "error": null }
    

    for echoes the request's type. error is a human-readable failure reason (e.g. "unlock requires confirmation"), null on success.

  • error — protocol-level error not tied to a specific request (e.g. expired auth mid-connection):

    { "type": "error", "code": "auth_expired", "message": "Session token expired, reconnect required." }
    

    Client should close and re-auth on auth_expired.

Client → server messages

  • unlock_request — user explicitly chose to unlock:

    { "type": "unlock_request", "confirmed": true }
    

    confirmed must be true — this forces the extension UI to have gone through an explicit confirmation step before this message is even sent; the backend still independently rejects (ack.success: false) if confirmed isn't true. Approved requests get an ack followed by a lock_state with locked: false.

  • escalate_request — frontend detected a circumvention attempt:

    { "type": "escalate_request", "reason": "circumvention_detected", "detail": "buy element re-clicked 3x after lock applied" }
    

    reason is a short machine-readable code (circumvention_detected is the only defined value in v1, kept open-ended for future reasons). detail is free-text for logging only, never shown to the user. Accepted escalations get an ack followed by a lock_state with scope: "full_block".

  • ping (optional fallback) — { "type": "ping" }, server replies { "type": "pong" }. Prefer native WebSocket ping/pong frames if the client library supports them; this JSON-level version is only a fallback if it doesn't.

Connection lifecycle

  1. Client opens wss://.../ws?token=....
  2. Server validates the token; on failure, closes with code 4001 (custom: auth failed) — it must not silently accept and send an error message, since the connection itself was never established.
  3. On success, server immediately sends lock_state (current state).
  4. Client and server exchange messages per the message types above for the life of the connection.
  5. On disconnect (network drop, backgrounded browser, etc.), client reconnects with backoff (suggested: 1s, 2s, 5s, 10s, capped at 30s). Lock state lives server-side — reconnecting is resuming, not starting a new session; no client-side "resume" message is needed, the post-connect lock_state send handles resync.
  6. If the server needs to force a disconnect (e.g. session revoked), it sends error with code: "session_revoked" then closes the socket; client should not auto-reconnect in that case and should route the user back to re-auth.

Ownership: Backend decides when to lock/unlock/escalate (loss detection, threshold logic) and authorizes unlock/escalate requests — it is the single source of truth for lock state. Frontend decides how a lock is enforced visually on a given site (the site-adapter/intervention-strategy architecture above) and detects circumvention attempts to send escalate_request. Neither side should infer state on its own — the frontend's getLockState()/setLockState() seam should be a thin wrapper that just reflects whatever the last lock_state message said, nothing more.

Versioning: v1 has no version field on the wire yet. If a breaking change is needed later, add a protocol_version field to all messages and negotiate on connect rather than assuming both sides redeploy simultaneously — the frontend is OSS/user-installed, so it can lag behind backend deploys.

Visual treatment for v1 (blur-disable)

  • Wrap or overlay the original element rather than mutating its classes destructively, so the original markup can be restored exactly on unlock (snapshot the full inline style attribute and restore it verbatim, rather than adding/removing individual classes).
  • filter: blur(2-3px), ~0.5 opacity, cursor: not-allowed, a non-blurred centered lock icon overlay, and pointer-events: none (or a transparent click-catcher div).
  • The click-catcher must intercept pointerdown, mousedown, mouseup, and click (capture phase, stopImmediatePropagation on each) — fast trading UIs commonly execute the trade on mousedown/pointerdown rather than waiting for click, so intercepting click alone lets the action through before the catcher ever runs. Show the reason toast on click only, to avoid firing it 3-4x per gesture.
  • The reason toast auto-hides ~5s after being shown (resets the timer on each re-trigger, e.g. a repeated locked click); it has no manual close/dismiss control, but it is not meant to persist indefinitely.

Non-goals for this milestone

  • No cost-basis tracking logic (backend concern).
  • No full-site-block implementation beyond a stub module.
  • No threshold-setting UI (placeholder link only).
  • Only the axiom.trade adapter needs to be functional; the architecture just needs to make adding more sites trivial.