Merge pull request #2 from nexa-sol/feat/real-wallet-auth-and-backend-connection

Real Phantom wallet auth and live backend connection
This commit is contained in:
2026-09-07 09:42:37 -04:00
committed by GitHub
34 changed files with 5177 additions and 151 deletions
+4
View File
@@ -141,3 +141,7 @@ dist
vite.config.js.timestamp-* vite.config.js.timestamp-*
vite.config.ts.timestamp-* vite.config.ts.timestamp-*
.vite/ .vite/
# WXT
.wxt/
*.zip
+114 -11
View File
@@ -4,7 +4,21 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co
## Repository state ## 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 ## 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. 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 ## 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. - 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*. - 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 /src
@@ -60,24 +74,113 @@ A site adapter's contract: `matches` (URL pattern), `findBuyElements()`, `getCon
## Axiom.trade adapter specifics (first target site) ## 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. - 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 button: find via `closest('.buy-click-container')`, not a hardcoded ancestor depth (DOM depth is not stable). - **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: match `button` elements with class `bg-increase` whose text matches `/^Buy\s/i`. Do not hardcode token names — they vary per token page. - **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.
- Full selector details and example markup are in `NEXA_SPEC.md` §3.3. - `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 ## 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 `<script>` tag needed). This is the only file that touches `window.solana`/`window.phantom.solana` directly: `connect()` and `signMessage()`. Talks back to the isolated world via `window.postMessage` (channel-tagged messages, matched by request id) — **not `CustomEvent`**, see the callout below.
- `src/content-scripts/wallet-bridge/relay.ts` — isolated-world side of that bridge: `callWallet(action, payload)` posts the request message and returns a promise that resolves/rejects on the matching result message, with a 30s timeout.
- `src/content-scripts/wallet-bridge/banner.ts` — minimal plain-DOM "Connect your wallet to Nexa" prompt injected onto the page (bottom-right, fixed position) when a wallet isn't already trusted for this origin. No framework, kept deliberately tiny since it's living on someone else's page.
- `src/content-scripts/wallet-connect.ts` — orchestrates the above, wired into `content-index.ts` (runs independently of site-adapter matching): on load, tries `connect({ onlyIfTrusted: true })` silently (succeeds with no user interaction if the user already approved this origin in Phantom before); on failure, shows the banner and only calls plain `connect()` from the banner button's own click handler, since **a real user gesture is required for Phantom to show its approval popup on a first-ever connect** — this is why the banner exists in the page rather than the extension popup (a click in the popup's UI doesn't count as a gesture on the axiom.trade page by the time it reaches the wallet, since it crosses an extension-messaging boundary asynchronously). Once connected, reports `{ walletAddress }` to the background via `nexa:wallet-connected`. Also handles, all from the background: `nexa:wallet-sign-request` (sign a nonce), `nexa:request-wallet-connect` (retry the silent connect, e.g. after a session was invalidated), `nexa:wallet-disconnect-request` (sign-out) and `nexa:switch-account-request` (explicit account switch) — see the sign-out/switch-account bullet below for those two.
- **Known gap**: this only works while an axiom.trade tab is open — there's no wallet connection path from the popup alone. That's intentional for now (matches the "only live while on a supported site" framing in backend/CLAUDE.md's RPC-subscription note), not an oversight.
- **Sign out and account switching (implemented) — both explicit user actions, not automatic.** An earlier version of this also wired Phantom's own `accountChanged` provider event to trigger re-auth automatically, but calling `connect()` ourselves also fires that same event — so a normal silent reconnect raced its own event-triggered handler and produced *two* competing "wallet connected" reports, each independently asking Phantom to sign a nonce (a popup every time). Removed entirely; `wallet-bridge/inject.ts` does not listen for any Phantom provider events, only responds to our own explicit calls. See its doc comment for the full story if this is ever reconsidered.
- "Sign out" (popup button → `nexa:sign-out` → `background.ts`'s `signOut()`) clears the stored session, force-closes the WS connection (`ws-client.ts`'s `disconnect()`, distinct from `reconnectNow()` — it also suppresses auto-reconnect until a new wallet connects), and asks the content script to call `provider.disconnect()`, which revokes Phantom's trust for the origin so the next silent connect correctly fails until the user reconnects.
- "Switch account" (popup button → `nexa:switch-account` → `wallet-connect.ts`'s `switchAccount()`) explicitly disconnects then immediately reconnects, so Phantom shows its connect approval UI for whichever account is currently active there. Not fully verified whether Phantom requires a fresh user gesture for this non-`onlyIfTrusted` connect call relayed from the popup (vs. a direct page click) — if it does, `switchAccount()` falls back to the on-page banner so the user can complete it with a real click.
- `background.ts`'s `nexa:wallet-connected` handler distinguishes "already signed in as this wallet" (no-op) from "signed in as a *different* wallet" (re-authenticate) by comparing the reported wallet address against `backend-client.ts`'s stored `{ token, walletAddress }` pair (`storeSession()`, not just a bare token) — a bare "do we have a token" check can't tell those apart and was the reason the account-switch case needed fixing in the first place.
- **Firefox gotcha (hit during dev, now fixed)**: the isolated↔main-world bridge originally used `CustomEvent`s dispatched on `window`. That works on Chromium but throws `Uncaught Error: Permission denied to access property "id"` on Firefox — a `CustomEvent.detail` object created in one world can't have its properties read from the other (an Xray-wrapper security restriction specific to Firefox's extension model). Fixed by switching to `window.postMessage` for this bridge, which structured-clones its payload across the boundary correctly on both browsers — the same technique Phantom's own inpage↔content-script bridge uses. If you're extending this bridge, don't reach for `CustomEvent` again for isolated↔main-world data; `postMessage` (with a `channel` field to disambiguate from the page's own postMessage traffic, and an `event.source === window` check) is the pattern here.
- **Verified working end-to-end against real Phantom** on Zen: connect → sign → `/auth/verify` → session token stored, confirmed via the `[nexa/wallet-*]` debug logs. `world: 'MAIN'` also needs Firefox 128+; confirmed fine on Zen's base version.
- **A content script reports `wallet-connected` on every page load** (it always tries a silent `onlyIfTrusted` connect first), and `signMessage()` shows a fresh Phantom approval popup every single time it's called, unlike `connect()`, which is silent once trusted — so the re-auth gate on that message matters a lot for not spamming signature prompts. An earlier version of the gate only checked "is there any token stored," which correctly avoided re-signing on ordinary page loads but broke account switching (see above — it's now a wallet-address comparison instead).
## 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`) ## 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). - `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 ## Non-goals for this milestone
- No real backend/WebSocket connection (mock state only).
- No cost-basis tracking logic (backend concern). - No cost-basis tracking logic (backend concern).
- No full-site-block implementation beyond a stub module. - No full-site-block implementation beyond a stub module.
- No threshold-setting UI (placeholder link only). - No threshold-setting UI (placeholder link only).
-140
View File
@@ -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.
+3478
View File
File diff suppressed because it is too large Load Diff
+29
View File
@@ -0,0 +1,29 @@
{
"name": "nexa-extension",
"private": true,
"version": "0.1.0",
"description": "Nexa browser extension frontend — your blockchain powered agent to help with your trading emotions.",
"type": "module",
"scripts": {
"dev": "wxt",
"dev:firefox": "wxt -b firefox",
"build": "wxt build",
"build:firefox": "wxt build -b firefox",
"zip": "wxt zip",
"zip:firefox": "wxt zip -b firefox",
"compile": "tsc --noEmit",
"postinstall": "wxt prepare"
},
"devDependencies": {
"@types/react": "^19.2.18",
"@types/react-dom": "^19.2.7",
"typescript": "^7.0.2",
"wxt": "^0.21.4"
},
"dependencies": {
"@wxt-dev/module-react": "^1.2.2",
"bs58": "^6.0.0",
"react": "^19.2.8",
"react-dom": "^19.2.8"
}
}
+36
View File
@@ -0,0 +1,36 @@
import { browser } from 'wxt/browser';
const SESSION_STORAGE_KEY = 'nexa:session';
interface StoredSession {
token: string;
walletAddress: string;
}
/**
* Session storage. The token itself is obtained by wallet-auth.ts
* (nonce -> Phantom signature -> verify) once a wallet connects — this
* module just persists/retrieves it, since ws-client.ts and popup code
* shouldn't know how a token was obtained. Stored alongside the wallet
* address it belongs to so callers can tell "already signed in" apart from
* "signed in as a *different* wallet than the one now connected" (account
* switching in Phantom) — a bare token can't distinguish those.
*/
export async function getSessionToken(): Promise<string> {
const session = await getStoredSession();
if (!session) throw new Error('Not authenticated yet — connect a wallet on a supported trading site.');
return session.token;
}
export async function getStoredSession(): Promise<StoredSession | undefined> {
const stored = await browser.storage.local.get(SESSION_STORAGE_KEY);
return stored[SESSION_STORAGE_KEY] as StoredSession | undefined;
}
export async function storeSession(token: string, walletAddress: string): Promise<void> {
await browser.storage.local.set({ [SESSION_STORAGE_KEY]: { token, walletAddress } satisfies StoredSession });
}
export async function clearSessionToken(): Promise<void> {
await browser.storage.local.remove(SESSION_STORAGE_KEY);
}
+18
View File
@@ -0,0 +1,18 @@
import { browser } from 'wxt/browser';
import { DEFAULT_CONNECTION_STATUS, type ConnectionStatus } from '@/shared/types';
import type { NexaMessage } from '@/shared/messaging';
/** In-memory only (not persisted) — always reflects the live WS connection, not a stale guess. */
let currentStatus: ConnectionStatus = DEFAULT_CONNECTION_STATUS;
export function getConnectionStatus(): ConnectionStatus {
return currentStatus;
}
export async function setConnectionStatus(next: ConnectionStatus): Promise<void> {
if (next === currentStatus) return;
currentStatus = next;
const message: NexaMessage = { type: 'nexa:connection-status-changed', status: next };
await browser.runtime.sendMessage(message).catch(() => undefined);
}
+52
View File
@@ -0,0 +1,52 @@
import { browser } from 'wxt/browser';
import { DEFAULT_LOCK_STATE, type LockState } from '@/shared/types';
import { LOCK_STATE_STORAGE_KEY, type NexaMessage } from '@/shared/messaging';
/**
* Single isolated seam for lock state. Nothing else in the extension reads
* or writes lock state directly. `applyLockState` is called exclusively by
* ws-client.ts when the backend sends a `lock_state` message — the backend
* is the sole source of truth (see the wire protocol's "Ownership" section),
* so there is no other writer.
*/
let currentLockState: LockState = DEFAULT_LOCK_STATE;
let initialized: Promise<void> | null = null;
function loadPersisted(): Promise<void> {
return browser.storage.local.get(LOCK_STATE_STORAGE_KEY).then((stored) => {
const persisted = stored[LOCK_STATE_STORAGE_KEY] as LockState | undefined;
if (persisted) currentLockState = persisted;
});
}
function ensureInitialized(): Promise<void> {
if (!initialized) initialized = loadPersisted();
return initialized;
}
export async function getLockState(): Promise<LockState> {
await ensureInitialized();
return currentLockState;
}
export async function applyLockState(next: LockState): Promise<void> {
await ensureInitialized();
currentLockState = next;
await browser.storage.local.set({ [LOCK_STATE_STORAGE_KEY]: next });
await broadcastLockState(next);
}
async function broadcastLockState(state: LockState): Promise<void> {
const message: NexaMessage = { type: 'nexa:lock-state-changed', state };
const tabs = await browser.tabs.query({});
await Promise.allSettled(
tabs
.filter((tab): tab is typeof tab & { id: number } => tab.id != null)
.map((tab) => browser.tabs.sendMessage(tab.id, message).catch(() => undefined)),
);
// Also notify any open extension surfaces (e.g. the popup) listening via runtime messages.
await browser.runtime.sendMessage(message).catch(() => undefined);
}
+69
View File
@@ -0,0 +1,69 @@
import { browser } from 'wxt/browser';
import { BACKEND_HTTP_URL } from '@/shared/config';
import type { NexaMessage, WalletSignResult } from '@/shared/messaging';
import { storeSession } from './backend-client';
/**
* Runs the REST auth flow (backend/CLAUDE.md, "Current milestone:
* authentication + user data") once a content script reports a connected
* wallet: fetch a nonce, ask that same tab's content script to sign it with
* Phantom (the only place window.solana is reachable — see
* content-scripts/wallet-connect.ts), then verify and store the session
* token. Throws on any failure; caller decides what to do (currently: leave
* ws-client's own retry loop to keep trying getSessionToken()).
*/
export async function handleWalletConnected(tabId: number | undefined, walletAddress: string): Promise<void> {
console.debug('[nexa/wallet-auth] wallet connected', walletAddress, 'tab', tabId);
if (tabId == null) throw new Error('wallet-connected message had no source tab');
const nonceRes = await fetch(`${BACKEND_HTTP_URL}/auth/nonce`, {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ wallet_address: walletAddress }),
});
if (!nonceRes.ok) throw new Error(`nonce request failed: ${nonceRes.status}`);
const { nonce } = (await nonceRes.json()) as { nonce: string };
console.debug('[nexa/wallet-auth] got nonce, asking tab to sign');
const signResult = (await browser.tabs.sendMessage(tabId, {
type: 'nexa:wallet-sign-request',
nonce,
} satisfies NexaMessage)) as WalletSignResult;
if ('error' in signResult) throw new Error(signResult.error);
console.debug('[nexa/wallet-auth] got signature, verifying');
const verifyRes = await fetch(`${BACKEND_HTTP_URL}/auth/verify`, {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ wallet_address: walletAddress, signature: signResult.signature }),
});
if (!verifyRes.ok) throw new Error(`verify request failed: ${verifyRes.status}`);
const { session_token: sessionToken } = (await verifyRes.json()) as { session_token: string };
await storeSession(sessionToken, walletAddress);
console.debug('[nexa/wallet-auth] session token stored');
}
async function messageAllSupportedTabs(message: NexaMessage): Promise<void> {
const tabs = await browser.tabs.query({ url: 'https://axiom.trade/*' });
await Promise.allSettled(
tabs
.filter((tab): tab is typeof tab & { id: number } => tab.id != null)
.map((tab) => browser.tabs.sendMessage(tab.id, message).catch(() => undefined)),
);
}
/** Prompts any open supported-site tab to attempt a silent (onlyIfTrusted) reconnect — used after the session is invalidated server-side. */
export async function requestWalletReconnect(): Promise<void> {
await messageAllSupportedTabs({ type: 'nexa:request-wallet-connect' });
}
/** Sign-out: tells any open supported-site tab to disconnect from Phantom (revokes this origin's trust, so the next silent connect correctly fails until the user reconnects). */
export async function requestWalletDisconnect(): Promise<void> {
await messageAllSupportedTabs({ type: 'nexa:wallet-disconnect-request' });
}
/** Explicit "use a different account" — a deliberate user action (popup button), not something inferred from a wallet-provider event. See wallet-connect.ts's switchAccount(). */
export async function requestAccountSwitch(): Promise<void> {
await messageAllSupportedTabs({ type: 'nexa:switch-account-request' });
}
+176
View File
@@ -0,0 +1,176 @@
import { BACKEND_WS_URL } from '@/shared/config';
import type { ConnectionStatus, LockScope, LockState } from '@/shared/types';
/**
* Client for the backend wire protocol. See ../../CLAUDE.md's "WebSocket
* protocol" section (source of truth, shared with backend/CLAUDE.md) for
* the full spec — this is the one place that protocol is translated into
* this extension's internal `LockState`/`LockScope` (kebab-case), per the
* note there that this translation belongs at the WS client, nowhere else.
*/
type WireScope = 'buy_only' | 'full_block';
interface WireLockState {
type: 'lock_state';
locked: boolean;
scope: WireScope;
reason?: string | null;
triggered_at?: string | null;
}
interface WireAck {
type: 'ack';
for: string;
success: boolean;
error: string | null;
}
interface WireError {
type: 'error';
code: string;
message: string;
}
type WireMessage = WireLockState | WireAck | WireError;
const WIRE_TO_INTERNAL_SCOPE: Record<WireScope, LockScope> = {
buy_only: 'buy-only',
full_block: 'full-block',
};
// Suggested backoff schedule from the protocol's connection-lifecycle section.
// Only used for real disconnects (network drop, backgrounded browser) — auth
// failures go through onAuthExpired instead of blind backoff-retry, since
// retrying with the same known-bad token can't succeed.
const RECONNECT_DELAYS_MS = [1000, 2000, 5000, 10000, 30000];
export interface WsClientOptions {
getToken(): Promise<string>;
onLockState(state: LockState): void;
onStatusChange(status: ConnectionStatus): void;
/**
* Called when the current session token is no longer valid (WS upgrade
* rejected with close code 4001, or an `auth_expired`/`session_revoked`
* error mid-connection). No automatic backoff-reconnect follows this —
* the caller is expected to get a fresh token (e.g. by prompting a wallet
* reconnect) and call `reconnectNow()` once it has one.
*/
onAuthExpired(): void;
}
export interface WsClientController {
/** Cancels any pending backoff wait and connects immediately — call after obtaining a fresh token. */
reconnectNow(): void;
/** Closes the current connection (if any) and stops auto-reconnecting until `reconnectNow()` is called — e.g. on sign-out. */
disconnect(): void;
}
export function connectWsClient(options: WsClientOptions): WsClientController {
let reconnectAttempt = 0;
let reconnectTimer: ReturnType<typeof setTimeout> | undefined;
let currentSocket: WebSocket | undefined;
let manuallyDisconnected = false;
function scheduleReconnect(): void {
const delay = RECONNECT_DELAYS_MS[Math.min(reconnectAttempt, RECONNECT_DELAYS_MS.length - 1)];
reconnectAttempt += 1;
reconnectTimer = setTimeout(() => void connect(), delay);
}
async function connect(): Promise<void> {
options.onStatusChange('connecting');
let token: string;
try {
token = await options.getToken();
} catch {
// Not authenticated yet (no wallet connected) rather than a server-side
// rejection — keep polling on the normal backoff until one connects.
options.onStatusChange('disconnected');
scheduleReconnect();
return;
}
const socket = new WebSocket(`${BACKEND_WS_URL}?token=${encodeURIComponent(token)}`);
currentSocket = socket;
let authFailure = false;
socket.addEventListener('open', () => {
reconnectAttempt = 0;
options.onStatusChange('connected');
});
socket.addEventListener('message', (event) => {
let message: WireMessage;
try {
message = JSON.parse(event.data as string);
} catch {
return; // malformed frame — ignore rather than crash the background script
}
if (message.type === 'lock_state') {
options.onLockState({
locked: message.locked,
scope: WIRE_TO_INTERNAL_SCOPE[message.scope],
reason: message.reason ?? undefined,
lockedAt: message.triggered_at ? Date.parse(message.triggered_at) : undefined,
});
} else if (message.type === 'error') {
if (message.code === 'auth_expired') {
// Protocol: client should close and re-auth on auth_expired (the
// server doesn't close this one for us, unlike session_revoked).
authFailure = true;
socket.close();
} else if (message.code === 'session_revoked') {
authFailure = true; // server closes on its own right after this
}
}
// 'ack' has nothing to react to yet — the client doesn't send
// unlock_request/escalate_request until lock-decision logic exists
// server-side (see backend/CLAUDE.md non-goals for this milestone).
});
socket.addEventListener('close', (event) => {
if (currentSocket === socket) currentSocket = undefined;
if (manuallyDisconnected) {
options.onStatusChange('disconnected');
return; // sign-out — wait for reconnectNow() after a new wallet connects
}
if (authFailure || event.code === 4001) {
options.onStatusChange('auth-error');
options.onAuthExpired();
return; // no scheduleReconnect — wait for a fresh token + reconnectNow()
}
options.onStatusChange('disconnected');
scheduleReconnect();
});
socket.addEventListener('error', () => {
// The 'close' event still fires after 'error', so reconnect logic
// lives there only — this listener just prevents an unhandled error.
});
}
function reconnectNow(): void {
manuallyDisconnected = false;
if (reconnectTimer) clearTimeout(reconnectTimer);
reconnectAttempt = 0;
if (currentSocket && currentSocket.readyState === WebSocket.OPEN) return;
void connect();
}
function disconnect(): void {
manuallyDisconnected = true;
if (reconnectTimer) clearTimeout(reconnectTimer);
options.onStatusChange('disconnected');
currentSocket?.close(1000, 'client sign-out');
}
void connect();
return { reconnectNow, disconnect };
}
@@ -0,0 +1,15 @@
/**
* Contract every site adapter implements. Adapters only know how to *find*
* buy elements on their site — they never decide whether to lock, and never
* apply any visual treatment themselves.
*/
export interface SiteAdapter {
/** Unique identifier, e.g. the site's hostname. */
id: string;
/** Whether this adapter applies to the given page URL. */
matches(url: string): boolean;
/** All current buy-action DOM nodes on the page (or within `root`). */
findBuyElements(root: ParentNode): HTMLElement[];
/** Stable ancestor of `el` to apply lock styling to, so the whole control is styled. */
getContainer(el: HTMLElement): HTMLElement;
}
+60
View File
@@ -0,0 +1,60 @@
import type { SiteAdapter } from './adapter-interface';
/** Matches "Buy", "Buy pixelcat", etc. — never a hardcoded token name. */
const NOOB_MODE_BUY_TEXT = /^Buy\s/i;
/** Axiom's "increase"/"decrease" Tailwind color tokens are its buy(green)/sell(red) convention. */
const INCREASE_MARKER_SELECTOR = '[class*="increase"]';
function looksLikeBuyPill(el: HTMLElement): boolean {
return el.matches(INCREASE_MARKER_SELECTOR) || el.querySelector(INCREASE_MARKER_SELECTOR) !== null;
}
function findQuickBuyContainers(root: ParentNode): HTMLElement[] {
// `.buy-click-container` is a *shared* wrapper around both the buy and
// sell quick-amount pills as siblings — it is NOT buy-only. The buy pill
// is always its first child element; the sell pill is another sibling.
// Never treat the wrapper itself as the buy element/container.
const buyPills: HTMLElement[] = [];
for (const wrapper of root.querySelectorAll<HTMLElement>('.buy-click-container')) {
const firstChild = wrapper.firstElementChild;
if (firstChild instanceof HTMLElement && looksLikeBuyPill(firstChild)) {
buyPills.push(firstChild);
}
}
return buyPills;
}
function findNoobModeBuyButtons(root: ParentNode): HTMLElement[] {
// Buy and Sell noob-mode buttons are siblings too; the buy one is
// identified by its own text starting with "Buy", never by position.
return Array.from(root.querySelectorAll<HTMLElement>('button.bg-increase')).filter((button) =>
NOOB_MODE_BUY_TEXT.test(button.textContent?.trim() ?? ''),
);
}
export const axiomAdapter: SiteAdapter = {
id: 'axiom.trade',
matches(url) {
try {
return new URL(url).hostname.endsWith('axiom.trade');
} catch {
return false;
}
},
findBuyElements(root) {
return [...findQuickBuyContainers(root), ...findNoobModeBuyButtons(root)];
},
getContainer(el) {
// findBuyElements() above already resolves to exactly the buy-side
// element in both cases — the quick-buy pill (first child of the shared
// buy/sell wrapper) or the noob-mode button whose own text is "Buy …".
// Do NOT climb to any ancestor (e.g. via closest('.buy-click-container'))
// — that wrapper holds the sell pill too, so treating it as the
// container would blur/disable sell right along with buy.
return el;
},
};
+111
View File
@@ -0,0 +1,111 @@
import { browser } from 'wxt/browser';
import { axiomAdapter } from './adapters/axiom';
import type { SiteAdapter } from './adapters/adapter-interface';
import { blurDisable } from './interventions/blur-disable';
import { fullBlock } from './interventions/full-block';
import type { Intervention } from './interventions/intervention-interface';
import { observeForElements } from './observer';
import { initWalletConnect } from './wallet-connect';
import { showLockToast, hideLockToast } from '@/notifications/toast';
import { DEFAULT_LOCK_STATE, type LockState } from '@/shared/types';
import type { NexaMessage } from '@/shared/messaging';
// Registering a new site = adding it here. Nothing below this line needs to
// change to support another adapter.
const adapters: SiteAdapter[] = [axiomAdapter];
// Registering a new intervention = adding it here (and to LockState['scope']).
// Nothing below this line needs to change to support another intervention.
const interventionsByScope: Record<LockState['scope'], Intervention> = {
'buy-only': blurDisable,
'full-block': fullBlock,
};
/** Wires an adapter + intervention set + background messages together for the current page. */
export function initContentScript(): void {
// Independent of adapter matching below — wallet auth should work even if
// no buy-button adapter exists for this page yet.
initWalletConnect();
const matchedAdapter = adapters.find((candidate) => candidate.matches(window.location.href));
if (!matchedAdapter) return;
// Re-bound with a non-optional type so nested closures below don't lose
// the narrowing TS can't carry across function boundaries.
const adapter: SiteAdapter = matchedAdapter;
let currentState: LockState = DEFAULT_LOCK_STATE;
const lockedContainers = new Set<HTMLElement>();
const activeIntervention = () => interventionsByScope[currentState.scope];
const onLockedInteraction = (reason: string | undefined) => {
showLockToast(reason);
};
function reconcile(buyElements: HTMLElement[]) {
if (!currentState.locked) return;
const containers = new Set(buyElements.map((el) => adapter.getContainer(el)));
for (const container of containers) {
if (!lockedContainers.has(container)) {
activeIntervention().apply(container, currentState.reason, onLockedInteraction);
lockedContainers.add(container);
}
}
for (const container of lockedContainers) {
if (!containers.has(container)) {
// No longer a qualifying buy element — either it unmounted (SPA
// route/token change), or an in-place SPA toggle turned it into
// something else (e.g. a Buy/Sell mode switch on the same node).
// Reverse the intervention if the node is still on the page; if
// it's gone there's nothing left to restore.
if (container.isConnected) {
activeIntervention().remove(container);
}
lockedContainers.delete(container);
}
}
}
function applyLockState(next: LockState) {
const scopeChanged = next.scope !== currentState.scope;
const wasLocked = currentState.locked;
if (wasLocked && (!next.locked || scopeChanged)) {
const previousIntervention = interventionsByScope[currentState.scope];
for (const container of lockedContainers) {
previousIntervention.remove(container);
}
lockedContainers.clear();
}
if (wasLocked && !next.locked) {
hideLockToast();
}
currentState = next;
if (next.locked) {
showLockToast(next.reason);
}
reconcile(adapter.findBuyElements(document.body));
}
const handle = observeForElements(document.body, () => adapter.findBuyElements(document.body), reconcile);
browser.runtime
.sendMessage({ type: 'nexa:get-lock-state' } satisfies NexaMessage)
.then((state: LockState) => applyLockState(state))
.catch(() => undefined);
browser.runtime.onMessage.addListener((message: NexaMessage) => {
if (message?.type === 'nexa:lock-state-changed') {
applyLockState(message.state);
}
});
window.addEventListener('pagehide', () => handle.disconnect(), { once: true });
}
@@ -0,0 +1,101 @@
import type { Intervention } from './intervention-interface';
interface LockedRecord {
originalStyleAttr: string | null;
overlay: HTMLElement;
onOverlayEvent: (event: Event) => void;
}
// Tracked outside the DOM so remove() can restore each container exactly,
// without relying on any marker class/attribute the site itself might touch.
const locked = new WeakMap<HTMLElement, LockedRecord>();
// Fast trading UIs (Axiom included) often fire the actual buy on
// `pointerdown`/`mousedown` rather than waiting for `click`, to shave off
// the mouseup round-trip. Intercepting only `click` lets those through —
// so every stage of a click gesture is captured and swallowed here, and the
// reason toast is surfaced on `click` once the gesture completes.
const INTERCEPTED_EVENT_TYPES = ['pointerdown', 'mousedown', 'mouseup', 'click'] as const;
const LOCK_ICON_SVG =
'<svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="4" y="10" width="16" height="10" rx="2"></rect><path d="M8 10V7a4 4 0 0 1 8 0v3"></path></svg>';
const OVERLAY_STYLE = [
'position:absolute',
'inset:0',
'display:flex',
'align-items:center',
'justify-content:center',
'pointer-events:auto',
'cursor:not-allowed',
'z-index:2147483000',
'filter:none',
'opacity:1',
'color:#fff',
'background:transparent',
].join(';');
/**
* v1 default visual treatment: blur + reduced opacity + a non-blurred lock
* icon overlay that also acts as a click-catcher. The original container is
* never mutated destructively — its full inline `style` attribute is
* snapshotted and restored verbatim on remove().
*/
export const blurDisable: Intervention = {
id: 'blur-disable',
apply(container, reason, onLockedInteraction) {
if (locked.has(container)) return;
const originalStyleAttr = container.getAttribute('style');
const computedPosition = getComputedStyle(container).position;
container.style.filter = 'blur(3px)';
container.style.opacity = '0.5';
container.style.cursor = 'not-allowed';
container.style.pointerEvents = 'none';
if (computedPosition === 'static') {
// Needed so the overlay below can position itself against this
// container instead of the nearest existing positioned ancestor.
container.style.position = 'relative';
}
const overlay = document.createElement('div');
overlay.dataset.nexaOverlay = 'true';
overlay.style.cssText = OVERLAY_STYLE;
overlay.innerHTML = LOCK_ICON_SVG;
const onOverlayEvent = (event: Event) => {
event.preventDefault();
event.stopPropagation();
event.stopImmediatePropagation();
if (event.type === 'click') {
onLockedInteraction(reason);
}
};
for (const type of INTERCEPTED_EVENT_TYPES) {
overlay.addEventListener(type, onOverlayEvent, true);
}
container.appendChild(overlay);
locked.set(container, { originalStyleAttr, overlay, onOverlayEvent });
},
remove(container) {
const record = locked.get(container);
if (!record) return;
for (const type of INTERCEPTED_EVENT_TYPES) {
record.overlay.removeEventListener(type, record.onOverlayEvent, true);
}
record.overlay.remove();
if (record.originalStyleAttr === null) {
container.removeAttribute('style');
} else {
container.setAttribute('style', record.originalStyleAttr);
}
locked.delete(container);
},
};
@@ -0,0 +1,16 @@
import type { Intervention } from './intervention-interface';
/**
* Escalation intervention: full-site block. Stub only for v1 — structure so
* a future pass can fill in a real full-page overlay without touching
* anything outside this file.
*/
export const fullBlock: Intervention = {
id: 'full-block',
apply(_container, _reason, _onLockedInteraction) {
console.warn('[nexa] full-block intervention is a v1 stub and is not yet implemented.');
},
remove(_container) {},
};
@@ -0,0 +1,22 @@
/**
* Contract every intervention strategy implements. Interventions only know
* how to visually enforce a lock on a given container — they never detect
* buy elements (that's the adapter's job) and never decide lock/unlock
* timing (that's the background lock-state seam's job).
*/
export interface Intervention {
/** Unique identifier, e.g. 'blur-disable'. */
id: string;
/**
* Apply the lock treatment to `container`. Must be idempotent — calling
* apply() twice on the same container without an intervening remove()
* should not double-apply.
*
* `onLockedInteraction` should be called whenever the user tries to
* interact with the locked container, so the caller can surface the
* reason toast.
*/
apply(container: HTMLElement, reason: string | undefined, onLockedInteraction: (reason: string | undefined) => void): void;
/** Reverse apply() on `container`, restoring its original markup exactly. */
remove(container: HTMLElement): void;
}
+36
View File
@@ -0,0 +1,36 @@
export interface ObserverHandle {
disconnect(): void;
}
/**
* Generic, adapter-agnostic MutationObserver. SPA-safe: re-runs
* `findElements` on every DOM mutation under `root` (rather than a one-time
* querySelectorAll on load) so elements that mount/unmount client-side are
* still caught. Also watches class/text changes on existing nodes — some
* SPA toggles (e.g. a Buy/Sell mode switch) mutate a node in place rather
* than replacing it, which a childList-only observer would miss entirely.
*/
export function observeForElements(
root: Node,
findElements: () => HTMLElement[],
onChange: (elements: HTMLElement[]) => void,
): ObserverHandle {
const emit = () => onChange(findElements());
const observer = new MutationObserver(() => emit());
observer.observe(root, {
childList: true,
subtree: true,
attributes: true,
attributeFilter: ['class'],
characterData: true,
});
emit();
return {
disconnect() {
observer.disconnect();
},
};
}
@@ -0,0 +1,56 @@
/** Minimal on-page "connect your wallet" prompt — deliberately plain DOM/inline styles, no framework, to stay a tiny footprint on someone else's page. */
let bannerEl: HTMLDivElement | null = null;
export function showConnectBanner(onConnect: () => void): void {
if (bannerEl) return;
bannerEl = document.createElement('div');
bannerEl.id = 'nexa-connect-banner';
Object.assign(bannerEl.style, {
position: 'fixed',
bottom: '16px',
right: '16px',
zIndex: '2147483000',
display: 'flex',
alignItems: 'center',
gap: '10px',
padding: '10px 14px',
borderRadius: '8px',
background: '#111',
color: '#fff',
font: '13px/1.4 -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif',
boxShadow: '0 4px 16px rgba(0, 0, 0, 0.3)',
});
const label = document.createElement('span');
label.textContent = 'Connect your wallet to Nexa';
const button = document.createElement('button');
button.type = 'button';
button.textContent = 'Connect';
Object.assign(button.style, {
background: '#fff',
color: '#111',
border: 'none',
borderRadius: '6px',
padding: '6px 10px',
cursor: 'pointer',
fontWeight: '600',
font: 'inherit',
});
button.addEventListener('click', onConnect);
bannerEl.append(label, button);
document.body.appendChild(bannerEl);
}
export function setConnectBannerError(message: string): void {
const label = bannerEl?.querySelector('span');
if (label) label.textContent = message;
}
export function hideConnectBanner(): void {
bannerEl?.remove();
bannerEl = null;
}
@@ -0,0 +1,92 @@
import bs58 from 'bs58';
/**
* Runs in the page's own JS world (world: 'MAIN', see
* entrypoints/wallet-bridge.content.ts) — the only place `window.solana`
* (Phantom's injected provider) is reachable, since content scripts run in
* an isolated world that can't call into page-defined objects/functions
* directly. Talks to the isolated-world side (wallet-bridge/relay.ts) via
* `window.postMessage`, NOT CustomEvents: on Firefox, a CustomEvent's
* `detail` object created in one world can't have its properties read from
* the other (Xray wrapper "Permission denied to access property" errors) —
* postMessage structured-clones its payload across that boundary correctly
* on both Firefox and Chromium, which is why every wallet-injection bridge
* (Phantom's own inpage script included) uses it instead.
*
* Deliberately does NOT listen for Phantom's own `accountChanged`/`disconnect`
* provider events. Calling `connect()` ourselves also fires `accountChanged`
* as a side effect, so an automatic listener raced against our own call's
* resolution and produced duplicate "wallet connected" reports — each one
* independently triggering a fresh sign prompt. Account switching is a
* separate, explicit action instead (see wallet-connect.ts's `switchAccount`).
*/
const CALL_CHANNEL = 'nexa:wallet-call';
const RESULT_CHANNEL = 'nexa:wallet-result';
interface WalletCallMessage {
channel: typeof CALL_CHANNEL;
id: string;
action: 'connect' | 'signMessage' | 'disconnect';
payload: { onlyIfTrusted?: boolean } | { message: string } | Record<string, never>;
}
interface PhantomProvider {
isPhantom?: boolean;
connect(opts?: { onlyIfTrusted?: boolean }): Promise<{ publicKey: { toString(): string } }>;
disconnect?(): Promise<void>;
signMessage(message: Uint8Array, display?: string): Promise<{ signature: Uint8Array }>;
}
function getProvider(): PhantomProvider | undefined {
const w = window as unknown as { solana?: PhantomProvider; phantom?: { solana?: PhantomProvider } };
if (w.solana?.isPhantom) return w.solana;
if (w.phantom?.solana) return w.phantom.solana;
return undefined;
}
function respond(id: string, ok: boolean, dataOrError: unknown): void {
window.postMessage(
ok ? { channel: RESULT_CHANNEL, id, ok, data: dataOrError } : { channel: RESULT_CHANNEL, id, ok, error: dataOrError },
window.location.origin,
);
}
export function initWalletBridgeInjected(): void {
console.debug('[nexa/wallet-bridge] injected script active on', window.location.href);
window.addEventListener('message', (event) => {
if (event.source !== window) return; // ignore iframes/other windows
const message = event.data as Partial<WalletCallMessage> | undefined;
if (message?.channel !== CALL_CHANNEL) return; // not ours — the page may postMessage for its own reasons
const { id, action, payload } = message as WalletCallMessage;
console.debug('[nexa/wallet-bridge] call', action, payload);
void (async () => {
try {
const provider = getProvider();
if (!provider) throw new Error('No Solana wallet (Phantom) detected on this page.');
if (action === 'connect') {
const { onlyIfTrusted } = payload as { onlyIfTrusted?: boolean };
const result = await provider.connect(onlyIfTrusted ? { onlyIfTrusted: true } : undefined);
console.debug('[nexa/wallet-bridge] connect ok', result.publicKey.toString());
respond(id, true, { walletAddress: result.publicKey.toString() });
} else if (action === 'signMessage') {
const { message: nonce } = payload as { message: string };
const { signature } = await provider.signMessage(new TextEncoder().encode(nonce), 'utf8');
respond(id, true, { signature: bs58.encode(signature) });
} else if (action === 'disconnect') {
await provider.disconnect?.();
respond(id, true, {});
} else {
throw new Error(`Unknown wallet action: ${String(action)}`);
}
} catch (err) {
console.debug('[nexa/wallet-bridge] call failed:', err);
respond(id, false, err instanceof Error ? err.message : String(err));
}
})();
});
}
@@ -0,0 +1,51 @@
/**
* Isolated-world side of the wallet bridge: calls into the page-world script
* (wallet-bridge/inject.ts) via `window.postMessage` and awaits its response
* by matching request/response ids. postMessage, not CustomEvent — a
* CustomEvent's `detail` object can't have its properties read across the
* isolated/main-world boundary on Firefox (Xray wrapper "Permission denied"
* errors); postMessage structured-clones its payload correctly on both
* Firefox and Chromium. See wallet-bridge/inject.ts for the other side.
*/
const CALL_CHANNEL = 'nexa:wallet-call';
const RESULT_CHANNEL = 'nexa:wallet-result';
interface WalletResultMessage {
channel: typeof RESULT_CHANNEL;
id: string;
ok: boolean;
data?: unknown;
error?: string;
}
const WALLET_CALL_TIMEOUT_MS = 30_000;
export function callWallet<T>(action: 'connect' | 'signMessage' | 'disconnect', payload: unknown): Promise<T> {
return new Promise((resolve, reject) => {
const id = crypto.randomUUID();
const timeoutId = setTimeout(() => {
cleanup();
reject(new Error('Wallet bridge timed out — is Phantom installed and unlocked?'));
}, WALLET_CALL_TIMEOUT_MS);
function handleMessage(event: MessageEvent): void {
if (event.source !== window) return; // ignore iframes/other windows
const message = event.data as Partial<WalletResultMessage> | undefined;
if (message?.channel !== RESULT_CHANNEL || message.id !== id) return;
cleanup();
if (message.ok) resolve(message.data as T);
else reject(new Error(message.error ?? 'Wallet call failed'));
}
function cleanup(): void {
clearTimeout(timeoutId);
window.removeEventListener('message', handleMessage);
}
window.addEventListener('message', handleMessage);
window.postMessage({ channel: CALL_CHANNEL, id, action, payload }, window.location.origin);
});
}
+115
View File
@@ -0,0 +1,115 @@
import { browser } from 'wxt/browser';
import type { NexaMessage } from '@/shared/messaging';
import { callWallet } from './wallet-bridge/relay';
import { hideConnectBanner, setConnectBannerError, showConnectBanner } from './wallet-bridge/banner';
interface ConnectResult {
walletAddress: string;
}
interface SignResult {
signature: string;
}
async function reportConnected(walletAddress: string): Promise<void> {
console.debug('[nexa/wallet-connect] connected', walletAddress);
hideConnectBanner();
await browser.runtime
.sendMessage({ type: 'nexa:wallet-connected', walletAddress } satisfies NexaMessage)
.catch((err) => console.debug('[nexa/wallet-connect] failed to notify background:', err));
}
/** Sign-out or a failed switch — clears the backend session and re-shows the connect prompt. */
async function reportDisconnected(): Promise<void> {
console.debug('[nexa/wallet-connect] disconnected');
await browser.runtime
.sendMessage({ type: 'nexa:wallet-disconnected' } satisfies NexaMessage)
.catch((err) => console.debug('[nexa/wallet-connect] failed to notify background:', err));
showConnectBanner(() => void connectWithGesture());
}
/** Real user gesture (banner button click) — required for Phantom to show its connect approval popup on a first-ever connect. */
async function connectWithGesture(): Promise<void> {
console.debug('[nexa/wallet-connect] banner clicked, calling connect()');
try {
const { walletAddress } = await callWallet<ConnectResult>('connect', {});
await reportConnected(walletAddress);
} catch (err) {
console.debug('[nexa/wallet-connect] connect() failed:', err);
setConnectBannerError(err instanceof Error ? err.message : String(err));
}
}
/**
* `onlyIfTrusted` succeeds without any user interaction if this origin was
* already approved in a previous session — the normal case after the first
* connect. Falls back to the on-page banner (a real click) only when it isn't.
*/
async function attemptSilentConnect(): Promise<void> {
console.debug('[nexa/wallet-connect] attempting silent connect');
try {
const { walletAddress } = await callWallet<ConnectResult>('connect', { onlyIfTrusted: true });
await reportConnected(walletAddress);
} catch (err) {
console.debug('[nexa/wallet-connect] silent connect failed, showing banner:', err);
showConnectBanner(() => void connectWithGesture());
}
}
/**
* Explicit "use a different account" action (popup button, not automatic —
* see wallet-bridge/inject.ts for why this isn't driven by Phantom's own
* accountChanged event). Disconnects and immediately reconnects, so Phantom
* shows its connect approval UI again for whichever account is now active.
* May need a real click to succeed (Phantom can require a gesture for a
* non-onlyIfTrusted connect); if it fails for that reason, this falls back
* to the same on-page banner as a first-time connect would.
*/
async function switchAccount(): Promise<void> {
console.debug('[nexa/wallet-connect] switch account requested');
try {
await callWallet('disconnect', {}).catch(() => undefined); // best-effort — fine if already disconnected
const { walletAddress } = await callWallet<ConnectResult>('connect', {});
await reportConnected(walletAddress);
} catch (err) {
console.debug('[nexa/wallet-connect] switch account failed:', err);
showConnectBanner(() => void connectWithGesture());
}
}
/** Wires wallet connect/sign into the page. Independent of any site adapter — runs regardless of whether a buy-button adapter matched. */
export function initWalletConnect(): void {
console.debug('[nexa/wallet-connect] init on', window.location.href);
void attemptSilentConnect();
browser.runtime.onMessage.addListener((message: NexaMessage, _sender, sendResponse) => {
if (message?.type === 'nexa:wallet-sign-request') {
callWallet<SignResult>('signMessage', { message: message.nonce })
.then((result) => sendResponse(result))
.catch((err) => sendResponse({ error: err instanceof Error ? err.message : String(err) }));
return true;
}
if (message?.type === 'nexa:request-wallet-connect') {
void attemptSilentConnect();
return false;
}
if (message?.type === 'nexa:wallet-disconnect-request') {
// Sign-out, initiated from the popup (see background/wallet-auth.ts).
// Phantom's disconnect() revokes this origin's trust, so the next
// onlyIfTrusted attempt correctly fails until the user reconnects.
callWallet('disconnect', {})
.catch((err) => console.debug('[nexa/wallet-connect] disconnect() failed:', err))
.finally(() => void reportDisconnected());
return false;
}
if (message?.type === 'nexa:switch-account-request') {
void switchAccount();
return false;
}
return undefined;
});
}
+78
View File
@@ -0,0 +1,78 @@
import { clearSessionToken, getSessionToken, getStoredSession } from '@/background/backend-client';
import { getConnectionStatus, setConnectionStatus } from '@/background/connection-status';
import { applyLockState, getLockState } from '@/background/lock-state';
import {
handleWalletConnected,
requestAccountSwitch,
requestWalletDisconnect,
requestWalletReconnect,
} from '@/background/wallet-auth';
import { connectWsClient } from '@/background/ws-client';
import type { NexaMessage } from '@/shared/messaging';
export default defineBackground(() => {
const ws = connectWsClient({
getToken: getSessionToken,
onLockState: (state) => void applyLockState(state),
onStatusChange: (status) => void setConnectionStatus(status),
onAuthExpired: () => {
void clearSessionToken().then(() => requestWalletReconnect());
},
});
async function signOut(): Promise<void> {
await clearSessionToken();
ws.disconnect();
await requestWalletDisconnect();
}
browser.runtime.onMessage.addListener((message: NexaMessage, sender, sendResponse) => {
switch (message?.type) {
case 'nexa:get-lock-state':
getLockState().then(sendResponse);
return true; // keep the message channel open for the async response
case 'nexa:get-connection-status':
sendResponse(getConnectionStatus());
return false;
case 'nexa:open-popup':
// Best-effort: not all browsers/contexts allow programmatic popup
// opening outside a direct user gesture on the action icon.
browser.action.openPopup().catch(() => undefined);
return false;
case 'nexa:wallet-connected':
// A content script reports this on every page load (it always tries
// a silent onlyIfTrusted connect first), and after an explicit
// account switch. Only re-authenticate (which means asking Phantom
// to sign a fresh nonce — a popup every time, unlike a silent
// connect) when this isn't the wallet we're already signed in as; a
// bare "do we have a token" check can't tell those apart.
getStoredSession()
.then((session) => {
if (session?.walletAddress === message.walletAddress) return;
return handleWalletConnected(sender.tab?.id, message.walletAddress).then(() => ws.reconnectNow());
})
.catch((err) => console.debug('[nexa/background] wallet auth failed:', err)); // ws-client's own retry loop keeps trying regardless
return false;
case 'nexa:wallet-disconnected':
// Disconnected directly in Phantom's UI (not via our own sign-out
// flow, which already clears/disconnects itself) — treat the same way.
void clearSessionToken().then(() => ws.disconnect());
return false;
case 'nexa:sign-out':
void signOut();
return false;
case 'nexa:switch-account':
void requestAccountSwitch();
return false;
default:
return undefined;
}
});
});
+9
View File
@@ -0,0 +1,9 @@
import { initContentScript } from '@/content-scripts/content-index';
export default defineContentScript({
matches: ['https://axiom.trade/*'],
runAt: 'document_idle',
main() {
initContentScript();
},
});
+90
View File
@@ -0,0 +1,90 @@
import { useEffect, useState } from 'react';
import { browser } from 'wxt/browser';
import { DEFAULT_CONNECTION_STATUS, DEFAULT_LOCK_STATE, type ConnectionStatus, type LockState } from '@/shared/types';
import type { NexaMessage } from '@/shared/messaging';
const CONNECTION_LABELS: Record<ConnectionStatus, string> = {
connecting: 'Connecting…',
connected: 'Connected',
disconnected: 'Disconnected — retrying',
'auth-error': 'Sign-in failed — retrying',
};
export function App() {
const [lockState, setLockState] = useState<LockState>(DEFAULT_LOCK_STATE);
const [connectionStatus, setConnectionStatus] = useState<ConnectionStatus>(DEFAULT_CONNECTION_STATUS);
useEffect(() => {
browser.runtime
.sendMessage({ type: 'nexa:get-lock-state' } satisfies NexaMessage)
.then((state: LockState | undefined) => setLockState(state ?? DEFAULT_LOCK_STATE));
browser.runtime
.sendMessage({ type: 'nexa:get-connection-status' } satisfies NexaMessage)
.then((status: ConnectionStatus | undefined) => setConnectionStatus(status ?? DEFAULT_CONNECTION_STATUS));
const listener = (message: NexaMessage) => {
if (message?.type === 'nexa:lock-state-changed') {
setLockState(message.state);
} else if (message?.type === 'nexa:connection-status-changed') {
setConnectionStatus(message.status);
}
};
browser.runtime.onMessage.addListener(listener);
return () => browser.runtime.onMessage.removeListener(listener);
}, []);
function signOut(): void {
void browser.runtime.sendMessage({ type: 'nexa:sign-out' } satisfies NexaMessage);
}
function switchAccount(): void {
void browser.runtime.sendMessage({ type: 'nexa:switch-account' } satisfies NexaMessage);
}
return (
<>
<header>
<h1>Nexa</h1>
<p className="subtitle">Trading companion</p>
</header>
<section className="connection">
<span className={`connection-dot connection-${connectionStatus}`} />
{' '}
{CONNECTION_LABELS[connectionStatus]}
</section>
<section className="status">
{lockState.locked ? (
<>
<div className="badge badge-locked">🔒 Locked</div>
<p className="reason">{lockState.reason ?? 'No reason provided.'}</p>
<p className="scope">Scope: {lockState.scope}</p>
</>
) : (
<div className="badge badge-unlocked">Unlocked</div>
)}
</section>
{connectionStatus === 'connected' && (
<div className="account-actions">
<button type="button" className="sign-out-button" onClick={switchAccount}>
Switch account
</button>
<button type="button" className="sign-out-button" onClick={signOut}>
Sign out
</button>
</div>
)}
<a
className="thresholds-link"
href="#"
title="Threshold settings are not part of this milestone"
>
Adjust loss thresholds →
</a>
</>
);
}
+12
View File
@@ -0,0 +1,12 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<title>Nexa</title>
<link rel="stylesheet" href="./style.css" />
</head>
<body>
<div id="app"></div>
<script type="module" src="./main.tsx"></script>
</body>
</html>
+12
View File
@@ -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(
<StrictMode>
<App />
</StrictMode>,
);
+110
View File
@@ -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;
}
+14
View File
@@ -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();
},
});
+97
View File
@@ -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<typeof setTimeout> | 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 = `
<div class="nexa-toast-icon">🔒</div>
<div class="nexa-toast-body">
<div class="nexa-toast-title">Nexa: buying locked</div>
<div class="nexa-toast-reason"></div>
<div class="nexa-toast-action">View details</div>
</div>
`;
// 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();
}
+11
View File
@@ -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';
+34
View File
@@ -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 };
+19
View File
@@ -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';
+6
View File
@@ -0,0 +1,6 @@
{
"extends": "./.wxt/tsconfig.json",
"compilerOptions": {
"jsx": "react-jsx"
}
}
+34
View File
@@ -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: '[email protected]',
},
},
// 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'" } }
: {}),
}),
});