# CLAUDE.md This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. ## Repository state Scaffolded with [WXT](https://wxt.dev) (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 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`, pointed at the real deployed backend (`api.osias.trade`, TLS). `host_permissions` in `wxt.config.ts` must stay in sync with whatever host is configured here. - The Firefox-only CSP override that used to live in `wxt.config.ts` (working around Firefox upgrading a plaintext `ws://` dev connection to `wss://`) has been removed now that the backend is real `wss://` behind TLS — `upgrade-insecure-requests` has nothing to upgrade. Re-add it, scoped to `browser === 'firefox'`, only if a plaintext dev backend comes back into the loop. - `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 is `wallet-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. Also `requestWalletReconnect()` (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.ts` in 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 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". - `src/background/settings-client.ts` — `GET`/`PATCH /me/settings` (per-user loss-detection thresholds; see backend/CLAUDE.md, "Account settings"), same direct-fetch-with-bearer-token shape as `wallet-auth.ts`. Popup -> background dispatch is `nexa:get-settings`/`nexa:update-settings` in `background.ts`, responses shaped `{ settings } | { error }` (`SettingsResult` in `shared/messaging.ts`). The popup's "Loss thresholds" panel (`entrypoints/popup/App.tsx`) is the only place percent fields (`near_full_exit_fraction` and both loss thresholds) get converted between the backend's fractional wire representation and the whole-number percentages shown in the form — same "convert only at the seam" rule as `LockScope`'s kebab/snake split below. ### 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 `