Documents the React popup port (missing entirely before -- including the tsconfig jsx flag gotcha), and fixes several stale references left over from earlier edits: backend-client.ts's storeSessionToken -> storeSession(token, walletAddress) rename, wallet-auth.ts's newer requestWalletDisconnect()/requestAccountSwitch() helpers, and a duplicated/outdated description of the wallet-connected re-auth gate that still described the "any token" check after it was replaced with a wallet-address comparison. Co-Authored-By: Claude Sonnet 5 <[email protected]> Claude-Session: https://claude.ai/code/session_01YXiHuScXrjxBh7yFGAPq3B
25 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.
The popup is a real React app (@wxt-dev/module-react in wxt.config.ts, src/entrypoints/popup/App.tsx + main.tsx mounted via createRoot) — not plain DOM manipulation. Note: WXT's generated .wxt/tsconfig.json doesn't set compilerOptions.jsx, so the root tsconfig.json sets "jsx": "react-jsx" explicitly; without it tsc --noEmit fails on JSX syntax even though the Vite build itself is fine (esbuild doesn't need the tsconfig flag).
Commands:
npm install— install deps (also runswxt prepareviapostinstallto 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 --noEmittype-check only.npm run zip/npm run zip:firefox— production build packaged as a.zipfor 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.tswatcheschildList,attributes(classonly), andcharacterData, all withsubtree: true, ondocument.body— achildList-only observer misses in-place toggles entirely. - Quick Buy:
.buy-click-containeris 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 alwayswrapper.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-increasewhose 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 — andcontent-index.ts'sreconcile()must actively callintervention.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 — bothfindBuyElements()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 tolocalhost:8080(dev only;host_permissionsinwxt.config.tsmust 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 withhttp://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 includesupgrade-insecure-requests, which silently rewrites the WS client'sws://localhost:8080/wsconnection towss://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 explicitcontent_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 onbrowser === 'firefox'in the manifest function. If the real deployed backend ever moves to plainws://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 storage only (getSessionToken()/getStoredSession()/storeSession(token, walletAddress)/clearSessionToken()). Stores the wallet address alongside the token, not just the token — see "Session storage tracks which wallet it belongs to" below for why. Getting a token in the first place iswallet-auth.ts's job.src/background/wallet-auth.ts—handleWalletConnected()runs the REST auth flow (POST /auth/nonce→ Phantom signature →POST /auth/verify→ session token) once a content script reports a connected wallet. AlsorequestWalletReconnect()(silent reconnect after the backend invalidates a session),requestWalletDisconnect()(sign-out),requestAccountSwitch()(explicit account switch) — all three just message whichever tabs are on a supported site;wallet-connect.tsin the content script does the actual work.src/background/ws-client.ts— the WS client described above: connects to/ws?token=....connectWsClient()returns a controller withreconnectNow()so the background script can short-circuit the backoff wait right after a fresh token arrives. Auth failures (4001close,auth_expired/session_revokederrors) do not auto-retry with backoff — they callonAuthExpired()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 — noweb_accessible_resources/dynamic<script>tag needed). This is the only file that toucheswindow.solana/window.phantom.solanadirectly:connect()andsignMessage(). Talks back to the isolated world viawindow.postMessage(channel-tagged messages, matched by request id) — notCustomEvent, 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 intocontent-index.ts(runs independently of site-adapter matching): on load, triesconnect({ 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 plainconnect()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 vianexa:wallet-connected. Also handles, all from the background:nexa:wallet-sign-request(sign a nonce),nexa:request-wallet-connect(retry the silent connect, e.g. after a session was invalidated),nexa:wallet-disconnect-request(sign-out) andnexa:switch-account-request(explicit account switch) — see the sign-out/switch-account bullet below for those two.- 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.
- Sign out and account switching (implemented) — both explicit user actions, not automatic. An earlier version of this also wired Phantom's own
accountChangedprovider event to trigger re-auth automatically, but callingconnect()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.tsdoes 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'ssignOut()) clears the stored session, force-closes the WS connection (ws-client.ts'sdisconnect(), distinct fromreconnectNow()— it also suppresses auto-reconnect until a new wallet connects), and asks the content script to callprovider.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'sswitchAccount()) 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-onlyIfTrustedconnect 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'snexa:wallet-connectedhandler distinguishes "already signed in as this wallet" (no-op) from "signed in as a different wallet" (re-authenticate) by comparing the reported wallet address againstbackend-client.ts's stored{ token, walletAddress }pair (storeSession(), not just a bare token) — 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.
- "Sign out" (popup button →
- Firefox gotcha (hit during dev, now fixed): the isolated↔main-world bridge originally used
CustomEvents dispatched onwindow. That works on Chromium but throwsUncaught Error: Permission denied to access property "id"on Firefox — aCustomEvent.detailobject 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 towindow.postMessagefor 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 forCustomEventagain for isolated↔main-world data;postMessage(with achannelfield to disambiguate from the page's own postMessage traffic, and anevent.source === windowcheck) 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. - A content script reports
wallet-connectedon every page load (it always tries a silentonlyIfTrustedconnect first), andsignMessage()shows a fresh Phantom approval popup every single time it's called, unlikeconnect(), which is silent once trusted — so the re-auth gate on that message matters a lot for not spamming signature prompts. An earlier version of the gate only checked "is there any token stored," which correctly avoided re-signing on ordinary page loads but broke account switching (see above — it's now a wallet-address comparison instead).
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
/wson 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
typefield (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 whenlocked: false— represents the scope that would/last applied, so the client never has to guess).reasonandtriggered_at(ISO 8601 UTC string) are nullable, null/omitted whenlocked: false. -
ack— response to every client request message (§ below), exactly one per request. A resultinglock_state(if the request changed state) is sent separately, after theack:{ "type": "ack", "for": "unlock_request", "success": true, "error": null }forechoes the request'stype.erroris 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 }confirmedmust betrue— 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) ifconfirmedisn'ttrue. Approved requests get anackfollowed by alock_statewithlocked: false. -
escalate_request— frontend detected a circumvention attempt:{ "type": "escalate_request", "reason": "circumvention_detected", "detail": "buy element re-clicked 3x after lock applied" }reasonis a short machine-readable code (circumvention_detectedis the only defined value in v1, kept open-ended for future reasons).detailis free-text for logging only, never shown to the user. Accepted escalations get anackfollowed by alock_statewithscope: "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
- Client opens
wss://.../ws?token=.... - Server validates the token; on failure, closes with code
4001(custom: auth failed) — it must not silently accept and send anerrormessage, since the connection itself was never established. - On success, server immediately sends
lock_state(current state). - Client and server exchange messages per the message types above for the life of the connection.
- 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_statesend handles resync. - If the server needs to force a disconnect (e.g. session revoked), it sends
errorwithcode: "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
styleattribute 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, andpointer-events: none(or a transparent click-catcher div).- The click-catcher must intercept
pointerdown,mousedown,mouseup, andclick(capture phase,stopImmediatePropagationon each) — fast trading UIs commonly execute the trade onmousedown/pointerdownrather than waiting forclick, so interceptingclickalone lets the action through before the catcher ever runs. Show the reason toast onclickonly, 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.