Fold spec docs into CLAUDE.md
Consolidate NEXA_SPEC.md and NEXA_PROTOCOL_SPEC.md into CLAUDE.md as the single source of truth, updating the behavioral notes to reflect what live debugging on axiom.trade actually found (rather than the original spec's assumptions) and adding the backend WebSocket wire contract. Co-Authored-By: Claude Sonnet 5 <[email protected]> Claude-Session: https://claude.ai/code/session_01CHESr7MTKG5Dc3mRPvWPn7
This commit is contained in:
@@ -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://<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.
|
||||
- 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).
|
||||
|
||||
-140
@@ -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
|
||||
<future-site>.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
|
||||
<div class="flex min-h-[28px] flex-1 cursor-pointer items-center justify-center rounded-full text-[14px] font-medium leading-[18px] outline-none border border-increase/50 text-increase hover:bg-increase/5 hover:text-increaseHover touch-callout-none select-none">0.04</div>
|
||||
```
|
||||
- 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
|
||||
<div class="flex min-h-[4px] flex-1 flex-row items-center justify-center px-[16px] pb-[16px]">
|
||||
<button type="button" class="bg-increase hover:bg-increase/90 rounded-8 flex max-h-[36px] min-h-[36px] flex-1 flex-row items-center justify-center rounded-full p-[4px] undefined">
|
||||
<span class="text-[14px] font-bold leading-[18px] text-background">
|
||||
<span class="text-[#090909]">Buy pixelcat</span>
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
```
|
||||
- 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.
|
||||
Reference in New Issue
Block a user