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:
@@ -141,3 +141,7 @@ dist
|
||||
vite.config.js.timestamp-*
|
||||
vite.config.ts.timestamp-*
|
||||
.vite/
|
||||
|
||||
# WXT
|
||||
.wxt/
|
||||
*.zip
|
||||
|
||||
@@ -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 `<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`)
|
||||
|
||||
- Wrap or overlay the original element rather than mutating its classes destructively, so the original markup can be restored exactly on unlock.
|
||||
- Wrap or overlay the original element rather than mutating its classes destructively, so the original markup can be restored exactly on unlock (snapshot the full inline `style` attribute and restore it verbatim, rather than adding/removing individual classes).
|
||||
- `filter: blur(2-3px)`, ~0.5 opacity, `cursor: not-allowed`, a non-blurred centered lock icon overlay, and `pointer-events: none` (or a transparent click-catcher div).
|
||||
- A click on a locked element (or its click-catcher) must trigger the same reason toast as lock activation.
|
||||
- The click-catcher must intercept `pointerdown`, `mousedown`, `mouseup`, *and* `click` (capture phase, `stopImmediatePropagation` on each) — fast trading UIs commonly execute the trade on `mousedown`/`pointerdown` rather than waiting for `click`, so intercepting `click` alone lets the action through before the catcher ever runs. Show the reason toast on `click` only, to avoid firing it 3-4x per gesture.
|
||||
- The reason toast auto-hides ~5s after being shown (resets the timer on each re-trigger, e.g. a repeated locked click); it has no manual close/dismiss control, but it is not meant to persist indefinitely.
|
||||
|
||||
## Non-goals for this milestone
|
||||
|
||||
- No real backend/WebSocket connection (mock state only).
|
||||
- No cost-basis tracking logic (backend concern).
|
||||
- No full-site-block implementation beyond a stub module.
|
||||
- No threshold-setting UI (placeholder link only).
|
||||
|
||||
-140
@@ -1,140 +0,0 @@
|
||||
# Nexa — Frontend Browser Extension Spec
|
||||
|
||||
## 1. Overview
|
||||
|
||||
Nexa is a blockchain-powered trading companion that helps traders manage the emotional/behavioral side of trading rather than the trades themselves. Because trades are already visible on-chain, Nexa doesn't need the user to manually log anything — it observes wallet activity and reacts to it.
|
||||
|
||||
This spec covers **only the frontend**: an open-source browser extension. The backend (wallet/RPC listening, loss detection, auth) is a separate private service and is treated here as an external API the extension talks to.
|
||||
|
||||
**Target browser:** built and tested on Zen (Firefox fork), but must be cross-platform (Firefox + Chromium-based via WebExtensions API / manifest v3 where possible). Avoid browser-specific APIs unless behind a compatibility shim.
|
||||
|
||||
**Current milestone:** the "Lockout" feature — disabling quick-buy interactions on supported trading sites after a qualifying loss, with a visible reason/notification, built in a modular way so more sites and more intervention types can be added later.
|
||||
|
||||
## 2. Core Concept Recap (for context, not build scope)
|
||||
|
||||
- User sets their own loss thresholds while calm (Ulysses-contract style).
|
||||
- Backend watches the user's wallet (Solana RPC / Helius) and detects when a threshold-qualifying loss occurs.
|
||||
- Backend pushes a lock/unlock signal to the extension (WebSocket).
|
||||
- Extension enforces the lock in the DOM of the trading site the user is currently on.
|
||||
- Escalation model: first disable new-position buy actions only (sell/manage stays enabled); if the user tries to circumvent it, escalate to fully blocking the site.
|
||||
|
||||
## 3. Scope of This Spec: The Lockout Feature (v1)
|
||||
|
||||
### 3.1 Behavior
|
||||
|
||||
When the extension receives a "locked" state (from backend, or a mocked/local state for UI dev purposes):
|
||||
|
||||
1. **Detect** buy-action elements on the current page for the active site adapter (see Site Adapters below).
|
||||
2. **Visually disable** each detected buy element — do not just hide it. The user should see that an action exists but is blocked, not that it vanished. Preferred treatment: blur + reduced opacity + `pointer-events: none`, with a small lock icon overlay on hover/tap.
|
||||
3. **Intercept** any click that somehow still reaches the underlying element (defense in depth) and prevent default/propagation.
|
||||
4. **Show a reason.** On lock activation, surface a non-intrusive extension notification/popup (e.g. toast anchored near the browser action icon, or an injected banner) stating why it's locked, e.g. "Locked: -$62 loss on SOL/PIXELCAT crossed your $50 threshold." Include a way to view details (opens extension popup with more info) but no way to silently dismiss-and-unlock from that toast — unlocking is a deliberate action.
|
||||
5. **Sell/manage actions remain untouched** — only buy-side elements are targeted.
|
||||
6. When state changes to "unlocked," reverse all DOM modifications cleanly (no leftover classes/overlays).
|
||||
|
||||
### 3.2 Modularity Requirement
|
||||
|
||||
This must be built so that:
|
||||
- Adding a new supported site = adding a new "site adapter" module, not touching core logic.
|
||||
- Adding a new intervention type (e.g. full-site block, delayed buy with countdown, confirmation modal) = adding a new "intervention strategy" module, not touching site adapters.
|
||||
- The lock/unlock *decision* (when to lock, for how long, why) is fully decoupled from *how it's enforced on the page* and from *how it's detected on the page*.
|
||||
|
||||
Suggested architecture:
|
||||
|
||||
```
|
||||
/src
|
||||
/background - extension background/service worker: owns WebSocket connection to backend, holds current lock state, messages content scripts
|
||||
/content-scripts
|
||||
/adapters
|
||||
axiom.ts - site-specific selectors + DOM strategy for axiom.trade
|
||||
<future-site>.ts
|
||||
adapter-interface.ts - shared type/interface all adapters implement
|
||||
/interventions
|
||||
blur-disable.ts - visual lock treatment (v1 default)
|
||||
full-block.ts - escalation: full page block overlay (stub for v1, structure only)
|
||||
intervention-interface.ts
|
||||
observer.ts - generic mutation observer that watches for adapter-declared selectors appearing/disappearing (SPA-safe)
|
||||
content-index.ts - wires adapter + intervention + background messages together
|
||||
/popup - extension popup UI: current status, reason for lock, threshold settings link
|
||||
/notifications - toast/banner component injected into page or shown via browser notification API
|
||||
/shared
|
||||
messaging.ts - typed message contracts between background <-> content scripts <-> popup
|
||||
types.ts
|
||||
manifest.json
|
||||
```
|
||||
|
||||
### 3.3 Site Adapter: Axiom.trade (first target)
|
||||
|
||||
An adapter declares:
|
||||
- `matches`: URL pattern(s) this adapter applies to.
|
||||
- `findBuyElements()`: returns all current buy-action DOM nodes on the page.
|
||||
- `getContainer(el)`: given a buy element, returns the stable ancestor container to apply lock styling to (so we style the whole control, not just an inner span).
|
||||
|
||||
Known selectors/patterns to encode (from live markup on axiom.trade):
|
||||
|
||||
**Quick Buy button** (small pill-style buy amount button, e.g. "0.04"):
|
||||
```html
|
||||
<div class="flex min-h-[28px] flex-1 cursor-pointer items-center justify-center rounded-full text-[14px] font-medium leading-[18px] outline-none border border-increase/50 text-increase hover:bg-increase/5 hover:text-increaseHover touch-callout-none select-none">0.04</div>
|
||||
```
|
||||
- Detection: the reliable anchor is the ancestor with class `buy-click-container` — walk up from any `.border-increase\/50.text-increase` candidate, or more robustly, query `.buy-click-container` directly and treat it (or its clickable child) as the target.
|
||||
- The 4th ancestor being `buy-click-container` is a *current* DOM depth fact, not something to hardcode as "4 levels up" — instead use `closest('.buy-click-container')` so it survives markup depth changes.
|
||||
|
||||
**Noob Mode Buy button** (the big "Buy {token}" button):
|
||||
```html
|
||||
<div class="flex min-h-[4px] flex-1 flex-row items-center justify-center px-[16px] pb-[16px]">
|
||||
<button type="button" class="bg-increase hover:bg-increase/90 rounded-8 flex max-h-[36px] min-h-[36px] flex-1 flex-row items-center justify-center rounded-full p-[4px] undefined">
|
||||
<span class="text-[14px] font-bold leading-[18px] text-background">
|
||||
<span class="text-[#090909]">Buy pixelcat</span>
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
```
|
||||
- Detection: match `button` elements whose class list includes `bg-increase` and whose text content matches `/^Buy\s/i`. Do not hardcode the token name ("pixelcat") — match the "Buy " prefix pattern instead, since it changes per token page.
|
||||
- Fallback/secondary check: the button's own class `bg-increase` (their "increase/green" action color) combined with being inside a quick-buy/trade panel region, to avoid accidentally matching unrelated green buttons elsewhere on the page.
|
||||
|
||||
**Note for Claude Code:** Axiom.trade is a React SPA — buy elements mount/unmount as the user navigates between tokens without full page reloads. Use a `MutationObserver` on a stable root (e.g. `document.body` or the main app root) rather than a one-time `querySelectorAll` on load, and re-apply/remove the lock treatment whenever matching elements appear.
|
||||
|
||||
### 3.4 Lock Enforcement — Visual Treatment (v1: `blur-disable`)
|
||||
|
||||
For each detected buy container:
|
||||
- Apply a wrapper/overlay (don't mutate the site's own classes destructively — wrap or overlay so original markup can be restored exactly on unlock).
|
||||
- Visual: `filter: blur(2-3px)`, reduced opacity (~0.5), cursor `not-allowed`.
|
||||
- Overlay a small lock icon, centered, not blurred, so it reads clearly as "locked" rather than "broken."
|
||||
- `pointer-events: none` on the original element or a transparent click-catcher div on top of it.
|
||||
- Click-catcher (if used) triggers the same "show reason" notification on click/tap, so users who try to click get immediate feedback rather than nothing happening.
|
||||
|
||||
### 3.5 Notification/Reason UI
|
||||
|
||||
- Small, dismissible toast, injected into the page (top-right corner, non-blocking) or via the extension's own popup badge/notification.
|
||||
- Content: short reason (e.g. threshold crossed, amount, token if relevant) + a "View details" affordance that opens the extension popup.
|
||||
- Must not offer a one-click unlock from the toast itself.
|
||||
- Extension popup (separate, already-scoped-elsewhere UI surface) shows: current lock status, reason, time locked, link to adjust thresholds (thresholds config itself is out of scope for this spec — just leave a hook/placeholder).
|
||||
|
||||
### 3.6 State Source for This Milestone
|
||||
|
||||
Since backend integration (WebSocket, RPC loss detection) is separate work:
|
||||
- Build the content-script/background lock state as consuming a simple typed interface, e.g. `{ locked: boolean, reason?: string, scope: 'buy-only' | 'full-block' }`.
|
||||
- For UI development purposes, wire this to a mock/dev toggle (e.g. via the extension popup, or a `chrome.storage`/`browser.storage` flag) so the lock/unlock visuals can be demoed and iterated on before the real backend WebSocket is wired in.
|
||||
- The background script should have a clearly isolated function/module (`getLockState()`/`setLockState()`) that's the single seam where the future WebSocket client will plug in.
|
||||
|
||||
## 4. Non-Goals for This Milestone
|
||||
|
||||
- No real backend connection yet (mock state only).
|
||||
- No cost-basis tracking logic (backend concern).
|
||||
- No full-site-block implementation detail beyond a stub module (structure only, so it's easy to fill in later).
|
||||
- No threshold-setting UI (placeholder link only).
|
||||
- Only one site adapter (axiom.trade) needs to be functional; architecture just needs to make adding more trivial.
|
||||
|
||||
## 5. Deliverable for This Pass
|
||||
|
||||
A working browser extension (loadable unpacked in Zen/Firefox, and ideally Chromium) that:
|
||||
1. On axiom.trade, detects quick-buy and noob-mode buy elements per section 3.3.
|
||||
2. Applies the blur/disable visual treatment per 3.4 when a dev-mode "locked" toggle is on.
|
||||
3. Shows a reason toast per 3.5 when lock activates, and on click of a locked element.
|
||||
4. Cleanly reverses all changes when toggled back to unlocked.
|
||||
5. Is structured per the modular architecture in 3.2 so a second site adapter and a second intervention type could be added without touching existing files beyond registration.
|
||||
|
||||
## 6. Repo / Licensing Context
|
||||
|
||||
- This frontend repo is the OSS component; description: "Your blockchain powered Agent to help with your trading emotions."
|
||||
- License: Apache 2.0 (consistent with other OSS projects, and chosen partly to build trust given the extension interacts with a trading wallet).
|
||||
- Backend stays private/closed-source.
|
||||
Generated
+3478
File diff suppressed because it is too large
Load Diff
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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' });
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
},
|
||||
};
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
@@ -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;
|
||||
});
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,9 @@
|
||||
import { initContentScript } from '@/content-scripts/content-index';
|
||||
|
||||
export default defineContentScript({
|
||||
matches: ['https://axiom.trade/*'],
|
||||
runAt: 'document_idle',
|
||||
main() {
|
||||
initContentScript();
|
||||
},
|
||||
});
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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>,
|
||||
);
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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();
|
||||
},
|
||||
});
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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';
|
||||
@@ -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 };
|
||||
@@ -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';
|
||||
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"extends": "./.wxt/tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"jsx": "react-jsx"
|
||||
}
|
||||
}
|
||||
@@ -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'" } }
|
||||
: {}),
|
||||
}),
|
||||
});
|
||||
Reference in New Issue
Block a user