Popup is now a real React app (App.tsx/main.tsx via createRoot), wired through @wxt-dev/module-react. tsconfig.json needed an explicit jsx: "react-jsx" -- WXT's generated .wxt/tsconfig.json doesn't set it, so tsc --noEmit failed on JSX syntax even though the Vite build itself was fine. Root cause of the actually-reported bug (blank popup, predating the React port too): the backend defaulted to port 3000, the same port WXT's dev server uses for this extension. With both running, the popup's script tags pointed at the Vite dev server but the backend answered instead, so main.tsx never loaded -- "View Page Source" showed raw unbundled dev-mode HTML pointing at localhost:3000. Backend now binds :8080 (see backend commit), and BACKEND_HTTP_URL/BACKEND_WS_URL + host_permissions here follow it. Co-Authored-By: Claude Sonnet 5 <[email protected]> Claude-Session: https://claude.ai/code/session_01YXiHuScXrjxBh7yFGAPq3B
16 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 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.src/background/identity.ts— stub wallet: a locally-generated ed25519 keypair (viatweetnacl), persisted inbrowser.storage.local, used as a stand-in for a real Solana wallet signature. This is deliberately temporary — real wallet integration means bridging into the page's injectedwindow.solanaprovider on axiom.trade (content script + page-context script), which hasn't been built yet.getIdentity()/sign()is the seam that swap plugs into.src/background/backend-client.ts— the REST auth flow (POST /auth/nonce→ sign →POST /auth/verify→ session token), persisted viagetSessionToken().src/background/ws-client.ts— the WS client described above: connects to/ws?token=..., reconnects with the protocol's suggested backoff, force-refreshes the session token on4001/auth_expiredbefore retrying.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".
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
- Real wallet signing (Phantom et al. via axiom.trade's injected
window.solana) — auth currently uses a stub local keypair, see "Backend connection" above. - 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.