diff --git a/CLAUDE.md b/CLAUDE.md index b0ee0e4..92e92b9 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -24,7 +24,7 @@ Nexa is a blockchain-powered trading companion focused on the emotional/behavior License: Apache 2.0. The backend stays closed-source; this frontend repo is the OSS component. -Full behavioral spec lives in `NEXA_SPEC.md` — read it before implementing the lockout feature or any site adapter/intervention. Key points below are a summary, not a replacement. +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 @@ -45,7 +45,7 @@ This is the most important design rule in the spec: **lock/unlock decision-makin - 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 (from `NEXA_SPEC.md` §3.2): +Planned structure: ``` /src @@ -72,24 +72,88 @@ A site adapter's contract: `matches` (URL pattern), `findBuyElements()`, `getCon ## Axiom.trade adapter specifics (first target site) -- Axiom is a React SPA — buy elements mount/unmount without full page reloads. Use a `MutationObserver` on a stable root (e.g. `document.body`), not a one-time `querySelectorAll` on load. -- Quick Buy button: find via `closest('.buy-click-container')`, not a hardcoded ancestor depth (DOM depth is not stable). -- Noob Mode Buy button: match `button` elements with class `bg-increase` whose text matches `/^Buy\s/i`. Do not hardcode token names — they vary per token page. -- Full selector details and example markup are in `NEXA_SPEC.md` §3.3. +- 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' }`. For v1 (no real backend yet), this is driven by a mock/dev toggle (e.g. via popup or `browser.storage`). The background script must expose a single isolated seam (`getLockState()` / `setLockState()`) where the real WebSocket client will later plug in — do not scatter lock-state reads/writes across modules. +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"`). The future WebSocket client is exactly where that translation belongs — inside the `getLockState()`/`setLockState()` seam, not leaked into content scripts or the popup. + +## 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:///ws?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. +- 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). -- A click on a locked element (or its click-catcher) must trigger the same reason toast as lock activation. +- 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 real backend/WebSocket connection (mock state only). +- No real backend/WebSocket connection (mock state only) — see "WebSocket protocol" above for the wire contract to implement against when this milestone is picked up. - No cost-basis tracking logic (backend concern). - No full-site-block implementation beyond a stub module. - No threshold-setting UI (placeholder link only). diff --git a/NEXA_SPEC.md b/NEXA_SPEC.md deleted file mode 100644 index 1a38cb3..0000000 --- a/NEXA_SPEC.md +++ /dev/null @@ -1,140 +0,0 @@ -# Nexa — Frontend Browser Extension Spec - -## 1. Overview - -Nexa is a blockchain-powered trading companion that helps traders manage the emotional/behavioral side of trading rather than the trades themselves. Because trades are already visible on-chain, Nexa doesn't need the user to manually log anything — it observes wallet activity and reacts to it. - -This spec covers **only the frontend**: an open-source browser extension. The backend (wallet/RPC listening, loss detection, auth) is a separate private service and is treated here as an external API the extension talks to. - -**Target browser:** built and tested on Zen (Firefox fork), but must be cross-platform (Firefox + Chromium-based via WebExtensions API / manifest v3 where possible). Avoid browser-specific APIs unless behind a compatibility shim. - -**Current milestone:** the "Lockout" feature — disabling quick-buy interactions on supported trading sites after a qualifying loss, with a visible reason/notification, built in a modular way so more sites and more intervention types can be added later. - -## 2. Core Concept Recap (for context, not build scope) - -- User sets their own loss thresholds while calm (Ulysses-contract style). -- Backend watches the user's wallet (Solana RPC / Helius) and detects when a threshold-qualifying loss occurs. -- Backend pushes a lock/unlock signal to the extension (WebSocket). -- Extension enforces the lock in the DOM of the trading site the user is currently on. -- Escalation model: first disable new-position buy actions only (sell/manage stays enabled); if the user tries to circumvent it, escalate to fully blocking the site. - -## 3. Scope of This Spec: The Lockout Feature (v1) - -### 3.1 Behavior - -When the extension receives a "locked" state (from backend, or a mocked/local state for UI dev purposes): - -1. **Detect** buy-action elements on the current page for the active site adapter (see Site Adapters below). -2. **Visually disable** each detected buy element — do not just hide it. The user should see that an action exists but is blocked, not that it vanished. Preferred treatment: blur + reduced opacity + `pointer-events: none`, with a small lock icon overlay on hover/tap. -3. **Intercept** any click that somehow still reaches the underlying element (defense in depth) and prevent default/propagation. -4. **Show a reason.** On lock activation, surface a non-intrusive extension notification/popup (e.g. toast anchored near the browser action icon, or an injected banner) stating why it's locked, e.g. "Locked: -$62 loss on SOL/PIXELCAT crossed your $50 threshold." Include a way to view details (opens extension popup with more info) but no way to silently dismiss-and-unlock from that toast — unlocking is a deliberate action. -5. **Sell/manage actions remain untouched** — only buy-side elements are targeted. -6. When state changes to "unlocked," reverse all DOM modifications cleanly (no leftover classes/overlays). - -### 3.2 Modularity Requirement - -This must be built so that: -- Adding a new supported site = adding a new "site adapter" module, not touching core logic. -- Adding a new intervention type (e.g. full-site block, delayed buy with countdown, confirmation modal) = adding a new "intervention strategy" module, not touching site adapters. -- The lock/unlock *decision* (when to lock, for how long, why) is fully decoupled from *how it's enforced on the page* and from *how it's detected on the page*. - -Suggested architecture: - -``` -/src - /background - extension background/service worker: 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 - .ts - 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, structure only) - intervention-interface.ts - observer.ts - generic mutation observer that watches for adapter-declared selectors appearing/disappearing (SPA-safe) - content-index.ts - wires adapter + intervention + background messages together - /popup - extension popup UI: current status, reason for lock, threshold settings link - /notifications - toast/banner component injected into page or shown via browser notification API - /shared - messaging.ts - typed message contracts between background <-> content scripts <-> popup - types.ts -manifest.json -``` - -### 3.3 Site Adapter: Axiom.trade (first target) - -An adapter declares: -- `matches`: URL pattern(s) this adapter applies to. -- `findBuyElements()`: returns all current buy-action DOM nodes on the page. -- `getContainer(el)`: given a buy element, returns the stable ancestor container to apply lock styling to (so we style the whole control, not just an inner span). - -Known selectors/patterns to encode (from live markup on axiom.trade): - -**Quick Buy button** (small pill-style buy amount button, e.g. "0.04"): -```html -
0.04
-``` -- Detection: the reliable anchor is the ancestor with class `buy-click-container` — walk up from any `.border-increase\/50.text-increase` candidate, or more robustly, query `.buy-click-container` directly and treat it (or its clickable child) as the target. -- The 4th ancestor being `buy-click-container` is a *current* DOM depth fact, not something to hardcode as "4 levels up" — instead use `closest('.buy-click-container')` so it survives markup depth changes. - -**Noob Mode Buy button** (the big "Buy {token}" button): -```html -
- -
-``` -- Detection: match `button` elements whose class list includes `bg-increase` and whose text content matches `/^Buy\s/i`. Do not hardcode the token name ("pixelcat") — match the "Buy " prefix pattern instead, since it changes per token page. -- Fallback/secondary check: the button's own class `bg-increase` (their "increase/green" action color) combined with being inside a quick-buy/trade panel region, to avoid accidentally matching unrelated green buttons elsewhere on the page. - -**Note for Claude Code:** Axiom.trade is a React SPA — buy elements mount/unmount as the user navigates between tokens without full page reloads. Use a `MutationObserver` on a stable root (e.g. `document.body` or the main app root) rather than a one-time `querySelectorAll` on load, and re-apply/remove the lock treatment whenever matching elements appear. - -### 3.4 Lock Enforcement — Visual Treatment (v1: `blur-disable`) - -For each detected buy container: -- Apply a wrapper/overlay (don't mutate the site's own classes destructively — wrap or overlay so original markup can be restored exactly on unlock). -- Visual: `filter: blur(2-3px)`, reduced opacity (~0.5), cursor `not-allowed`. -- Overlay a small lock icon, centered, not blurred, so it reads clearly as "locked" rather than "broken." -- `pointer-events: none` on the original element or a transparent click-catcher div on top of it. -- Click-catcher (if used) triggers the same "show reason" notification on click/tap, so users who try to click get immediate feedback rather than nothing happening. - -### 3.5 Notification/Reason UI - -- Small, dismissible toast, injected into the page (top-right corner, non-blocking) or via the extension's own popup badge/notification. -- Content: short reason (e.g. threshold crossed, amount, token if relevant) + a "View details" affordance that opens the extension popup. -- Must not offer a one-click unlock from the toast itself. -- Extension popup (separate, already-scoped-elsewhere UI surface) shows: current lock status, reason, time locked, link to adjust thresholds (thresholds config itself is out of scope for this spec — just leave a hook/placeholder). - -### 3.6 State Source for This Milestone - -Since backend integration (WebSocket, RPC loss detection) is separate work: -- Build the content-script/background lock state as consuming a simple typed interface, e.g. `{ locked: boolean, reason?: string, scope: 'buy-only' | 'full-block' }`. -- For UI development purposes, wire this to a mock/dev toggle (e.g. via the extension popup, or a `chrome.storage`/`browser.storage` flag) so the lock/unlock visuals can be demoed and iterated on before the real backend WebSocket is wired in. -- The background script should have a clearly isolated function/module (`getLockState()`/`setLockState()`) that's the single seam where the future WebSocket client will plug in. - -## 4. Non-Goals for This Milestone - -- No real backend connection yet (mock state only). -- No cost-basis tracking logic (backend concern). -- No full-site-block implementation detail beyond a stub module (structure only, so it's easy to fill in later). -- No threshold-setting UI (placeholder link only). -- Only one site adapter (axiom.trade) needs to be functional; architecture just needs to make adding more trivial. - -## 5. Deliverable for This Pass - -A working browser extension (loadable unpacked in Zen/Firefox, and ideally Chromium) that: -1. On axiom.trade, detects quick-buy and noob-mode buy elements per section 3.3. -2. Applies the blur/disable visual treatment per 3.4 when a dev-mode "locked" toggle is on. -3. Shows a reason toast per 3.5 when lock activates, and on click of a locked element. -4. Cleanly reverses all changes when toggled back to unlocked. -5. Is structured per the modular architecture in 3.2 so a second site adapter and a second intervention type could be added without touching existing files beyond registration. - -## 6. Repo / Licensing Context - -- This frontend repo is the OSS component; description: "Your blockchain powered Agent to help with your trading emotions." -- License: Apache 2.0 (consistent with other OSS projects, and chosen partly to build trust given the extension interacts with a trading wallet). -- Backend stays private/closed-source.