diff --git a/.gitignore b/.gitignore index 872d5f6..5f9d03b 100644 --- a/.gitignore +++ b/.gitignore @@ -141,3 +141,7 @@ dist vite.config.js.timestamp-* vite.config.ts.timestamp-* .vite/ + +# WXT +.wxt/ +*.zip diff --git a/CLAUDE.md b/CLAUDE.md index 390a99c..620a57f 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -4,7 +4,21 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co ## Repository state -This repo currently contains only a spec (`NEXA_SPEC.md`), `README.md`, and `LICENSE` — no source code, `package.json`, or `manifest.json` exist yet. There are no build/lint/test commands to run because nothing has been scaffolded. When implementation begins, this file should be updated with the actual commands (build, lint, test, load-unpacked instructions) once a package manager and bundler are chosen. +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 @@ -12,7 +26,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 @@ -33,7 +47,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 @@ -60,24 +74,113 @@ 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. +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:8080` (dev only; `host_permissions` in `wxt.config.ts` must 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 with `http://localhost:3000/...` script tags in "View Page Source" that 404 or return something unexpected, this port collision is the first thing to check. +- **Firefox-only CSP override** in `wxt.config.ts`: Firefox's *implicit* default extension-pages CSP includes `upgrade-insecure-requests`, which silently rewrites the WS client's `ws://localhost:8080/ws` connection to `wss://` and breaks it against the plaintext local dev backend (no TLS in dev, deliberately — see backend/CLAUDE.md). Symptom: `Content-Security-Policy: Upgrading insecure request 'ws://...' to use 'wss'` in the console, followed by a failed connection, no other error. Fixed by declaring an explicit `content_security_policy.extension_pages` (otherwise identical to Firefox's own default) for the Firefox build only — an explicit CSP replaces the implicit one entirely, dropping the upgrade directive. Chrome doesn't have this behavior, so the override is gated on `browser === 'firefox'` in the manifest function. If the real deployed backend ever moves to plain `ws://` too (vs. `wss://` behind a real domain), this override needs to travel with it; if the backend gets TLS, this whole override becomes unnecessary and should be removed rather than left as dead configuration. +- `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". + +### 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 ` + + diff --git a/src/entrypoints/popup/main.tsx b/src/entrypoints/popup/main.tsx new file mode 100644 index 0000000..4b5ed3b --- /dev/null +++ b/src/entrypoints/popup/main.tsx @@ -0,0 +1,12 @@ +import { StrictMode } from 'react'; +import { createRoot } from 'react-dom/client'; +import { App } from './App'; + +const root = document.getElementById('app'); +if (!root) throw new Error('Popup root element #app not found.'); + +createRoot(root).render( + + + , +); diff --git a/src/entrypoints/popup/style.css b/src/entrypoints/popup/style.css new file mode 100644 index 0000000..d381bde --- /dev/null +++ b/src/entrypoints/popup/style.css @@ -0,0 +1,110 @@ +:root { + color-scheme: light dark; +} + +body { + width: 280px; + margin: 0; + padding: 16px; + font: 13px/1.4 -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; +} + +header h1 { + margin: 0; + font-size: 16px; +} + +header .subtitle { + margin: 2px 0 12px; + opacity: 0.65; +} + +.connection { + margin-bottom: 10px; + opacity: 0.75; + display: flex; + align-items: center; + gap: 6px; +} + +.connection-dot { + width: 8px; + height: 8px; + border-radius: 50%; + display: inline-block; + background: currentColor; +} + +.connection-connecting { + color: #ca8a04; +} + +.connection-connected { + color: #16a34a; +} + +.connection-disconnected, +.connection-auth-error { + color: #dc2626; +} + +.status { + margin-bottom: 16px; +} + +.badge { + display: inline-block; + padding: 3px 8px; + border-radius: 999px; + font-weight: 600; + font-size: 12px; +} + +.badge-locked { + background: rgba(220, 38, 38, 0.15); + color: #dc2626; +} + +.badge-unlocked { + background: rgba(22, 163, 74, 0.15); + color: #16a34a; +} + +.status .reason { + margin: 8px 0 2px; +} + +.status .scope { + margin: 0; + opacity: 0.65; +} + +.account-actions { + display: flex; + gap: 8px; +} + +.sign-out-button { + flex: 1; + padding: 6px 10px; + border: 1px solid rgba(128, 128, 128, 0.4); + border-radius: 6px; + background: transparent; + color: inherit; + font: inherit; + font-weight: 600; + cursor: pointer; +} + +.sign-out-button:hover { + background: rgba(128, 128, 128, 0.12); +} + +.thresholds-link { + display: block; + margin-top: 14px; + color: inherit; + opacity: 0.5; + text-decoration: none; + pointer-events: none; +} diff --git a/src/entrypoints/wallet-bridge.content.ts b/src/entrypoints/wallet-bridge.content.ts new file mode 100644 index 0000000..47fe517 --- /dev/null +++ b/src/entrypoints/wallet-bridge.content.ts @@ -0,0 +1,14 @@ +import { initWalletBridgeInjected } from '@/content-scripts/wallet-bridge/inject'; + +// world: 'MAIN' runs this in the page's own JS context (not the isolated +// content-script world) — the only place window.solana is reachable. See +// content-scripts/wallet-bridge/inject.ts for why and how it talks back to +// the isolated-world side. +export default defineContentScript({ + matches: ['https://axiom.trade/*'], + world: 'MAIN', + runAt: 'document_start', + main() { + initWalletBridgeInjected(); + }, +}); diff --git a/src/notifications/toast.ts b/src/notifications/toast.ts new file mode 100644 index 0000000..03a3a94 --- /dev/null +++ b/src/notifications/toast.ts @@ -0,0 +1,97 @@ +import { browser } from 'wxt/browser'; +import type { NexaMessage } from '@/shared/messaging'; + +const TOAST_ID = 'nexa-lock-toast'; +const STYLE_ID = 'nexa-lock-toast-styles'; +const AUTO_HIDE_MS = 5000; + +let hideTimer: ReturnType | undefined; + +function ensureStyles(): void { + if (document.getElementById(STYLE_ID)) return; + + const style = document.createElement('style'); + style.id = STYLE_ID; + style.textContent = ` + #${TOAST_ID} { + position: fixed; + top: 16px; + right: 16px; + z-index: 2147483647; + max-width: 320px; + display: flex; + gap: 10px; + align-items: flex-start; + background: #1a1a1a; + color: #fff; + border: 1px solid rgba(255, 255, 255, 0.12); + border-radius: 10px; + padding: 12px 14px; + font: 13px/1.4 -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; + box-shadow: 0 8px 24px rgba(0, 0, 0, 0.35); + } + #${TOAST_ID} .nexa-toast-icon { flex: none; margin-top: 1px; } + #${TOAST_ID} .nexa-toast-body { flex: 1; } + #${TOAST_ID} .nexa-toast-title { font-weight: 600; margin-bottom: 2px; } + #${TOAST_ID} .nexa-toast-reason { opacity: 0.85; } + #${TOAST_ID} .nexa-toast-action { + margin-top: 8px; + display: inline-block; + font-weight: 600; + color: #7ab8ff; + cursor: pointer; + } + #${TOAST_ID} .nexa-toast-action:hover { text-decoration: underline; } + `; + document.head.appendChild(style); +} + +function ensureToast(): HTMLElement { + ensureStyles(); + + let el = document.getElementById(TOAST_ID); + if (el) return el; + + el = document.createElement('div'); + el.id = TOAST_ID; + el.innerHTML = ` +
🔒
+
+
Nexa: buying locked
+
+
View details
+
+ `; + // No manual close button — the toast only goes away on its own (auto-hide + // timer below) or when the lock state actually changes; clicking it never + // unlocks anything. + el.querySelector('.nexa-toast-action')?.addEventListener('click', () => { + const message: NexaMessage = { type: 'nexa:open-popup' }; + browser.runtime.sendMessage(message).catch(() => undefined); + }); + + document.body.appendChild(el); + return el; +} + +export function showLockToast(reason: string | undefined): void { + const el = ensureToast(); + const reasonEl = el.querySelector('.nexa-toast-reason'); + if (reasonEl) { + reasonEl.textContent = reason ?? 'Buy actions are temporarily locked.'; + } + + if (hideTimer) clearTimeout(hideTimer); + hideTimer = setTimeout(() => { + hideTimer = undefined; + hideLockToast(); + }, AUTO_HIDE_MS); +} + +export function hideLockToast(): void { + if (hideTimer) { + clearTimeout(hideTimer); + hideTimer = undefined; + } + document.getElementById(TOAST_ID)?.remove(); +} diff --git a/src/shared/config.ts b/src/shared/config.ts new file mode 100644 index 0000000..426f95b --- /dev/null +++ b/src/shared/config.ts @@ -0,0 +1,11 @@ +/** + * Nexa backend location. Dev-only default — swap for the real deployed + * backend host before shipping (see backend/CLAUDE.md, "Relationship to the + * frontend"). `host_permissions` in wxt.config.ts must be kept in sync with + * this host. + */ +// Port 8080, deliberately not 3000 — that's WXT/Vite's dev server port for +// this extension, and colliding with it breaks the dev popup silently (its +// script tags point at Vite, but the backend answers instead). +export const BACKEND_HTTP_URL = 'http://localhost:8080'; +export const BACKEND_WS_URL = 'ws://localhost:8080/ws'; diff --git a/src/shared/messaging.ts b/src/shared/messaging.ts new file mode 100644 index 0000000..90e19d1 --- /dev/null +++ b/src/shared/messaging.ts @@ -0,0 +1,34 @@ +import type { ConnectionStatus, LockState } from './types'; + +/** browser.storage.local key backing the lock state seam in the background script. */ +export const LOCK_STATE_STORAGE_KEY = 'nexa:lockState'; + +/** + * Typed message contracts between background <-> content scripts <-> popup. + * All lock-state reads go through these so no module reaches into storage + * directly (see src/background/lock-state.ts). There is deliberately no + * client-writable "set lock state" message: the backend is the sole source + * of truth for lock state (see the WebSocket protocol's "Ownership" section + * in ../../CLAUDE.md) — only the WS client (ws-client.ts) may apply a new + * lock state, and only because the server told it to. + */ +export type NexaMessage = + | { type: 'nexa:get-lock-state' } + | { type: 'nexa:lock-state-changed'; state: LockState } + | { type: 'nexa:get-connection-status' } + | { type: 'nexa:connection-status-changed'; status: ConnectionStatus } + | { type: 'nexa:open-popup' } + // Wallet auth (content script <-> background). See + // content-scripts/wallet-connect.ts and background/wallet-auth.ts. + | { type: 'nexa:wallet-connected'; walletAddress: string } + | { type: 'nexa:wallet-disconnected' } + | { type: 'nexa:wallet-sign-request'; nonce: string } + | { type: 'nexa:request-wallet-connect' } + | { type: 'nexa:wallet-disconnect-request' } + | { type: 'nexa:switch-account-request' } + // Sign-out / switch account (popup -> background). See entrypoints/popup/App.tsx. + | { type: 'nexa:sign-out' } + | { type: 'nexa:switch-account' }; + +/** Response shape for 'nexa:wallet-sign-request', returned via sendResponse (not a dispatched NexaMessage). */ +export type WalletSignResult = { signature: string } | { error: string }; diff --git a/src/shared/types.ts b/src/shared/types.ts new file mode 100644 index 0000000..6f6ca52 --- /dev/null +++ b/src/shared/types.ts @@ -0,0 +1,19 @@ +export type LockScope = 'buy-only' | 'full-block'; + +export interface LockState { + locked: boolean; + reason?: string; + scope: LockScope; + /** epoch ms when this lock was activated; undefined while unlocked */ + lockedAt?: number; +} + +export const DEFAULT_LOCK_STATE: LockState = { + locked: false, + scope: 'buy-only', +}; + +/** Status of the background script's WS connection to the backend — surfaced in the popup. */ +export type ConnectionStatus = 'connecting' | 'connected' | 'disconnected' | 'auth-error'; + +export const DEFAULT_CONNECTION_STATUS: ConnectionStatus = 'connecting'; diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..6ad10a0 --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,6 @@ +{ + "extends": "./.wxt/tsconfig.json", + "compilerOptions": { + "jsx": "react-jsx" + } +} diff --git a/wxt.config.ts b/wxt.config.ts new file mode 100644 index 0000000..4c3e1a2 --- /dev/null +++ b/wxt.config.ts @@ -0,0 +1,34 @@ +import { defineConfig } from 'wxt'; + +// See https://wxt.dev/api/config.html +export default defineConfig({ + srcDir: 'src', + modules: ['@wxt-dev/module-react'], + // Target Manifest V3 on both Chromium and Firefox (modern Firefox / Zen support it). + manifestVersion: 3, + manifest: ({ browser }) => ({ + name: 'Nexa', + description: 'Your blockchain powered agent to help with your trading emotions.', + permissions: ['storage'], + // axiom.trade: the site adapter target. localhost:8080: the Nexa backend + // (dev only — swap/extend for the real backend host before shipping). + // Not 3000 — that's this extension's own Vite dev server port. + host_permissions: ['https://axiom.trade/*', 'http://localhost:8080/*'], + browser_specific_settings: { + gecko: { + // Placeholder id for local/dev builds; replace before publishing to AMO. + id: 'nexa-extension@nexa-sol.dev', + }, + }, + // Firefox's implicit default extension-pages CSP includes + // upgrade-insecure-requests, which silently rewrites our ws:// WS client + // connections to wss:// and breaks them against the plaintext local dev + // backend (no TLS in dev — see backend/CLAUDE.md). Declaring our own CSP + // (identical to the standard default otherwise) replaces Firefox's + // implicit one and drops that directive. Chrome doesn't have this + // behavior, so this is Firefox-only. + ...(browser === 'firefox' + ? { content_security_policy: { extension_pages: "script-src 'self'; object-src 'self'" } } + : {}), + }), +});