Co-Authored-By: Claude Sonnet 5 <[email protected]> Claude-Session: https://claude.ai/code/session_01YXiHuScXrjxBh7yFGAPq3B
169 lines
16 KiB
Markdown
169 lines
16 KiB
Markdown
# 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.
|
|
|
|
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:3000` (dev only; `host_permissions` in `wxt.config.ts` must stay in sync with whatever host is configured here).
|
|
- `src/background/identity.ts` — **stub wallet**: a locally-generated ed25519 keypair (via `tweetnacl`), persisted in `browser.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 injected `window.solana` provider 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 via `getSessionToken()`.
|
|
- `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 on `4001`/`auth_expired` before 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 `/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):
|
|
```json
|
|
{ "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`:
|
|
```json
|
|
{ "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):
|
|
```json
|
|
{ "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:
|
|
```json
|
|
{ "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:
|
|
```json
|
|
{ "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
|
|
|
|
- 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.
|