Replace stub wallet keypair with real Phantom sign-in

Auth now goes through an actual connected Solana wallet instead of a
locally-generated ed25519 keypair. Phantom's window.solana provider
is only reachable from a page's own JS world, not an isolated-world
content script, so this adds a second world:'MAIN' content script
(wallet-bridge.content.ts + wallet-bridge/inject.ts) that talks to
window.solana directly and relays to the isolated world via window
CustomEvents (wallet-bridge/relay.ts), matched by request id.

wallet-connect.ts orchestrates: try a silent onlyIfTrusted connect on
load; if that fails, show an on-page banner (wallet-bridge/banner.ts)
whose click handler is what actually calls connect() -- Phantom
requires a real user gesture for the approval popup on a first-ever
connect, which a click relayed from the extension popup wouldn't
count as by the time it reaches the wallet.

background/wallet-auth.ts runs the REST auth flow (nonce -> ask the
tab's content script to sign it -> verify -> store session token)
once a wallet reports connected. ws-client.ts no longer force-retries
with a known-bad token on auth failure; it calls onAuthExpired
instead (which clears the token and prompts a silent wallet
reconnect) and exposes reconnectNow() so background.ts can
short-circuit the backoff wait once a fresh token exists.

identity.ts and its tweetnacl dependency are gone -- no more stub
signer.

Untested against real Phantom (no browser automation available this
session) -- flagged in CLAUDE.md as needing manual verification,
along with a note that world:'MAIN' needs Firefox 128+.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01YXiHuScXrjxBh7yFGAPq3B
This commit is contained in:
2026-09-07 12:20:17 +02:00
co-authored by claude
parent 58e32caa5c
commit 56a2ff9930
15 changed files with 413 additions and 136 deletions
+14 -4
View File
@@ -86,11 +86,22 @@ Note the naming mismatch with the wire protocol below: the frontend's internal `
## Backend connection (implemented) ## 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. - `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.
- `src/background/identity.ts` — **stub wallet**: a locally-generated ed25519 keypair (via `tweetnacl`), persisted in `browser.storage.local`, used as a stand-in for a real Solana wallet signature. This is deliberately temporary — real wallet integration means bridging into the page's injected `window.solana` provider on axiom.trade (content script + page-context script), which hasn't been built yet. `getIdentity()`/`sign()` is the seam that swap plugs into. - `src/background/backend-client.ts` — session token storage only (`getSessionToken()`/`storeSessionToken()`/`clearSessionToken()`). Getting a token in the first place is `wallet-auth.ts`'s job.
- `src/background/backend-client.ts` — the REST auth flow (`POST /auth/nonce` → sign → `POST /auth/verify` → session token), persisted via `getSessionToken()`. - `src/background/wallet-auth.ts` — 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()`, used after the backend invalidates a session.
- `src/background/ws-client.ts` — the WS client described above: connects to `/ws?token=...`, reconnects with the protocol's suggested backoff, force-refreshes the session token on `4001`/`auth_expired` before retrying. - `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". - `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 purely via `window` `CustomEvent`s (`nexa:wallet-call` / `nexa:wallet-result`), matched by a request id — that's the one channel both worlds reliably share.
- `src/content-scripts/wallet-bridge/relay.ts` — isolated-world side of that bridge: `callWallet(action, payload)` dispatches the request event and returns a promise that resolves/rejects on the matching `nexa:wallet-result`, 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 `nexa:wallet-sign-request` (background asks it to sign a nonce) and `nexa:request-wallet-connect` (background asks it to retry the silent connect, e.g. after a session was invalidated).
- **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.
- **Untested**: the actual Phantom approval-popup flow (gesture propagation through the isolated→main-world `CustomEvent` dispatch, `onlyIfTrusted` persistence across page reloads) has not been exercised against real Phantom yet — verify manually before relying on it. `world: 'MAIN'` also needs Firefox 128+; confirm Zen's base version supports it.
## WebSocket protocol (backend wire contract) ## 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. 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.
@@ -161,7 +172,6 @@ Single source of truth for the wire protocol between the Nexa backend (Rust/Axum
## Non-goals for this milestone ## Non-goals for this milestone
- Real wallet signing (Phantom et al. via axiom.trade's injected `window.solana`) — auth currently uses a stub local keypair, see "Backend connection" above.
- No cost-basis tracking logic (backend concern). - No cost-basis tracking logic (backend concern).
- No full-site-block implementation beyond a stub module. - No full-site-block implementation beyond a stub module.
- No threshold-setting UI (placeholder link only). - No threshold-setting UI (placeholder link only).
+1 -8
View File
@@ -12,8 +12,7 @@
"@wxt-dev/module-react": "^1.2.2", "@wxt-dev/module-react": "^1.2.2",
"bs58": "^6.0.0", "bs58": "^6.0.0",
"react": "^19.2.8", "react": "^19.2.8",
"react-dom": "^19.2.8", "react-dom": "^19.2.8"
"tweetnacl": "^1.0.3"
}, },
"devDependencies": { "devDependencies": {
"@types/react": "^19.2.18", "@types/react": "^19.2.18",
@@ -3086,12 +3085,6 @@
"url": "https://github.com/sponsors/SuperchupuDev" "url": "https://github.com/sponsors/SuperchupuDev"
} }
}, },
"node_modules/tweetnacl": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/tweetnacl/-/tweetnacl-1.0.3.tgz",
"integrity": "sha512-6rt+RN7aOi1nGMyC4Xa5DdYiukl2UWCbcJft7YhxReBGQD7OAM8Pbxw6YMo4r2diNEA8FEmu32YOn9rhaiE5yw==",
"license": "Unlicense"
},
"node_modules/typescript": { "node_modules/typescript": {
"version": "7.0.2", "version": "7.0.2",
"resolved": "https://registry.npmjs.org/typescript/-/typescript-7.0.2.tgz", "resolved": "https://registry.npmjs.org/typescript/-/typescript-7.0.2.tgz",
+1 -2
View File
@@ -24,7 +24,6 @@
"@wxt-dev/module-react": "^1.2.2", "@wxt-dev/module-react": "^1.2.2",
"bs58": "^6.0.0", "bs58": "^6.0.0",
"react": "^19.2.8", "react": "^19.2.8",
"react-dom": "^19.2.8", "react-dom": "^19.2.8"
"tweetnacl": "^1.0.3"
} }
} }
+15 -45
View File
@@ -1,54 +1,24 @@
import bs58 from 'bs58';
import { browser } from 'wxt/browser'; import { browser } from 'wxt/browser';
import { BACKEND_HTTP_URL } from '@/shared/config';
import { getIdentity } from './identity';
const SESSION_TOKEN_STORAGE_KEY = 'nexa:sessionToken'; const SESSION_TOKEN_STORAGE_KEY = 'nexa:sessionToken';
/** /**
* Wallet-signature auth against the backend's REST flow (see * Session token storage. The token itself is obtained by wallet-auth.ts
* backend/CLAUDE.md, "Current milestone: authentication + user data"): * (nonce -> Phantom signature -> verify) once a wallet connects — this
* request a nonce for our wallet_address, sign it, exchange for a session * module just persists/retrieves it, since ws-client.ts and popup code
* token. That token is what the WS client (ws-client.ts) authenticates with. * shouldn't know how a token was obtained.
*/ */
export async function authenticate(): Promise<string> { export async function getSessionToken(): Promise<string> {
const identity = await getIdentity(); const stored = await browser.storage.local.get(SESSION_TOKEN_STORAGE_KEY);
const token = stored[SESSION_TOKEN_STORAGE_KEY] as string | undefined;
const nonceRes = await fetch(`${BACKEND_HTTP_URL}/auth/nonce`, { if (!token) throw new Error('Not authenticated yet — connect a wallet on a supported trading site.');
method: 'POST', return token;
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ wallet_address: identity.walletAddress }),
});
if (!nonceRes.ok) throw new Error(`nonce request failed: ${nonceRes.status}`);
const { nonce } = (await nonceRes.json()) as { nonce: string };
const signatureBytes = identity.sign(new TextEncoder().encode(nonce));
const verifyRes = await fetch(`${BACKEND_HTTP_URL}/auth/verify`, {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({
wallet_address: identity.walletAddress,
signature: bs58.encode(signatureBytes),
}),
});
if (!verifyRes.ok) throw new Error(`verify request failed: ${verifyRes.status}`);
const { session_token: sessionToken } = (await verifyRes.json()) as { session_token: string };
await browser.storage.local.set({ [SESSION_TOKEN_STORAGE_KEY]: sessionToken });
return sessionToken;
} }
/** export async function storeSessionToken(token: string): Promise<void> {
* Returns a usable session token, authenticating from scratch if none is await browser.storage.local.set({ [SESSION_TOKEN_STORAGE_KEY]: token });
* stored yet (or `forceRefresh` is set, e.g. after the backend told us via }
* `auth_expired`/close-4001 that the old one is no longer valid).
*/ export async function clearSessionToken(): Promise<void> {
export async function getSessionToken(forceRefresh = false): Promise<string> { await browser.storage.local.remove(SESSION_TOKEN_STORAGE_KEY);
if (!forceRefresh) {
const stored = await browser.storage.local.get(SESSION_TOKEN_STORAGE_KEY);
const token = stored[SESSION_TOKEN_STORAGE_KEY] as string | undefined;
if (token) return token;
}
return authenticate();
} }
-53
View File
@@ -1,53 +0,0 @@
import bs58 from 'bs58';
import nacl from 'tweetnacl';
import { browser } from 'wxt/browser';
const IDENTITY_STORAGE_KEY = 'nexa:identity';
/**
* Stand-in "wallet" identity for the backend's Sign-In-with-Solana-style
* auth (nonce + ed25519 signature) — a real Solana wallet keypair, just not
* one connected to an actual wallet extension yet. Generated once and
* persisted so the same wallet_address/session survives extension restarts.
*
* Deliberately temporary: real wallet integration (bridging into the page's
* injected window.solana provider on axiom.trade) is a follow-up iteration,
* not this one. Swap this module out for that without touching callers —
* `getIdentity()`/`sign()` is the seam.
*/
export interface Identity {
walletAddress: string;
sign(message: Uint8Array): Uint8Array;
}
interface StoredKeypair {
publicKey: string; // base58
secretKey: string; // base58
}
let cached: Identity | null = null;
export async function getIdentity(): Promise<Identity> {
if (cached) return cached;
const stored = await browser.storage.local.get(IDENTITY_STORAGE_KEY);
const persisted = stored[IDENTITY_STORAGE_KEY] as StoredKeypair | undefined;
const keyPair = persisted
? { publicKey: bs58.decode(persisted.publicKey), secretKey: bs58.decode(persisted.secretKey) }
: nacl.sign.keyPair();
if (!persisted) {
const toStore: StoredKeypair = {
publicKey: bs58.encode(keyPair.publicKey),
secretKey: bs58.encode(keyPair.secretKey),
};
await browser.storage.local.set({ [IDENTITY_STORAGE_KEY]: toStore });
}
cached = {
walletAddress: bs58.encode(keyPair.publicKey),
sign: (message) => nacl.sign.detached(message, keyPair.secretKey),
};
return cached;
}
+55
View File
@@ -0,0 +1,55 @@
import { browser } from 'wxt/browser';
import { BACKEND_HTTP_URL } from '@/shared/config';
import type { NexaMessage, WalletSignResult } from '@/shared/messaging';
import { storeSessionToken } 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> {
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 };
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);
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 storeSessionToken(sessionToken);
}
/** 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> {
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, { type: 'nexa:request-wallet-connect' } satisfies NexaMessage)
.catch(() => undefined),
),
);
}
+59 -20
View File
@@ -40,38 +40,58 @@ const WIRE_TO_INTERNAL_SCOPE: Record<WireScope, LockScope> = {
}; };
// Suggested backoff schedule from the protocol's connection-lifecycle section. // 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]; const RECONNECT_DELAYS_MS = [1000, 2000, 5000, 10000, 30000];
export interface WsClientOptions { export interface WsClientOptions {
/** Resolve a session token; pass `forceRefresh: true` to get a fresh one. */ getToken(): Promise<string>;
getToken(forceRefresh?: boolean): Promise<string>;
onLockState(state: LockState): void; onLockState(state: LockState): void;
onStatusChange(status: ConnectionStatus): 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;
} }
/** Starts the connection and keeps it alive; never resolves. */ export interface WsClientController {
export function connectWsClient(options: WsClientOptions): void { /** Cancels any pending backoff wait and connects immediately — call after obtaining a fresh token. */
let reconnectAttempt = 0; reconnectNow(): void;
}
function scheduleReconnect(forceRefresh: boolean) { export function connectWsClient(options: WsClientOptions): WsClientController {
let reconnectAttempt = 0;
let reconnectTimer: ReturnType<typeof setTimeout> | undefined;
let currentSocket: WebSocket | undefined;
function scheduleReconnect(): void {
const delay = RECONNECT_DELAYS_MS[Math.min(reconnectAttempt, RECONNECT_DELAYS_MS.length - 1)]; const delay = RECONNECT_DELAYS_MS[Math.min(reconnectAttempt, RECONNECT_DELAYS_MS.length - 1)];
reconnectAttempt += 1; reconnectAttempt += 1;
setTimeout(() => void connect(forceRefresh), delay); reconnectTimer = setTimeout(() => void connect(), delay);
} }
async function connect(forceRefresh = false): Promise<void> { async function connect(): Promise<void> {
options.onStatusChange('connecting'); options.onStatusChange('connecting');
let token: string; let token: string;
try { try {
token = await options.getToken(forceRefresh); token = await options.getToken();
} catch { } catch {
options.onStatusChange('auth-error'); // Not authenticated yet (no wallet connected) rather than a server-side
scheduleReconnect(true); // rejection — keep polling on the normal backoff until one connects.
options.onStatusChange('disconnected');
scheduleReconnect();
return; return;
} }
const socket = new WebSocket(`${BACKEND_WS_URL}?token=${encodeURIComponent(token)}`); const socket = new WebSocket(`${BACKEND_WS_URL}?token=${encodeURIComponent(token)}`);
currentSocket = socket;
let authFailure = false;
socket.addEventListener('open', () => { socket.addEventListener('open', () => {
reconnectAttempt = 0; reconnectAttempt = 0;
@@ -93,9 +113,15 @@ export function connectWsClient(options: WsClientOptions): void {
reason: message.reason ?? undefined, reason: message.reason ?? undefined,
lockedAt: message.triggered_at ? Date.parse(message.triggered_at) : undefined, lockedAt: message.triggered_at ? Date.parse(message.triggered_at) : undefined,
}); });
} else if (message.type === 'error' && message.code === 'auth_expired') { } else if (message.type === 'error') {
// Server already closes the socket for us on auth_expired; the if (message.code === 'auth_expired') {
// 'close' handler below does the reconnect-with-fresh-token. // 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 // 'ack' has nothing to react to yet — the client doesn't send
// unlock_request/escalate_request until lock-decision logic exists // unlock_request/escalate_request until lock-decision logic exists
@@ -103,12 +129,16 @@ export function connectWsClient(options: WsClientOptions): void {
}); });
socket.addEventListener('close', (event) => { socket.addEventListener('close', (event) => {
options.onStatusChange(event.code === 4001 ? 'auth-error' : 'disconnected'); if (currentSocket === socket) currentSocket = undefined;
// 4001 (auth failed) and session_revoked both mean the current token
// is bad — get a fresh one before retrying, per the protocol's if (authFailure || event.code === 4001) {
// connection-lifecycle notes on both cases. options.onStatusChange('auth-error');
const forceRefreshNext = event.code === 4001; options.onAuthExpired();
scheduleReconnect(forceRefreshNext); return; // no scheduleReconnect — wait for a fresh token + reconnectNow()
}
options.onStatusChange('disconnected');
scheduleReconnect();
}); });
socket.addEventListener('error', () => { socket.addEventListener('error', () => {
@@ -117,5 +147,14 @@ export function connectWsClient(options: WsClientOptions): void {
}); });
} }
function reconnectNow(): void {
if (reconnectTimer) clearTimeout(reconnectTimer);
reconnectAttempt = 0;
if (currentSocket && currentSocket.readyState === WebSocket.OPEN) return;
void connect();
}
void connect(); void connect();
return { reconnectNow };
} }
+5
View File
@@ -5,6 +5,7 @@ import { blurDisable } from './interventions/blur-disable';
import { fullBlock } from './interventions/full-block'; import { fullBlock } from './interventions/full-block';
import type { Intervention } from './interventions/intervention-interface'; import type { Intervention } from './interventions/intervention-interface';
import { observeForElements } from './observer'; import { observeForElements } from './observer';
import { initWalletConnect } from './wallet-connect';
import { showLockToast, hideLockToast } from '@/notifications/toast'; import { showLockToast, hideLockToast } from '@/notifications/toast';
import { DEFAULT_LOCK_STATE, type LockState } from '@/shared/types'; import { DEFAULT_LOCK_STATE, type LockState } from '@/shared/types';
import type { NexaMessage } from '@/shared/messaging'; import type { NexaMessage } from '@/shared/messaging';
@@ -22,6 +23,10 @@ const interventionsByScope: Record<LockState['scope'], Intervention> = {
/** Wires an adapter + intervention set + background messages together for the current page. */ /** Wires an adapter + intervention set + background messages together for the current page. */
export function initContentScript(): void { 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)); const matchedAdapter = adapters.find((candidate) => candidate.matches(window.location.href));
if (!matchedAdapter) return; if (!matchedAdapter) return;
// Re-bound with a non-optional type so nested closures below don't lose // Re-bound with a non-optional type so nested closures below don't lose
@@ -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,65 @@
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) purely
* via CustomEvents on `window`, since that's the one channel both worlds
* share reliably across Chromium and Firefox.
*/
interface WalletCallDetail {
id: string;
action: 'connect' | 'signMessage';
payload: { onlyIfTrusted?: boolean } | { message: string };
}
interface PhantomProvider {
isPhantom?: boolean;
connect(opts?: { onlyIfTrusted?: boolean }): Promise<{ publicKey: { toString(): string } }>;
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.dispatchEvent(
new CustomEvent('nexa:wallet-result', {
detail: ok ? { id, ok, data: dataOrError } : { id, ok, error: dataOrError },
}),
);
}
export function initWalletBridgeInjected(): void {
window.addEventListener('nexa:wallet-call', (event) => {
const { id, action, payload } = (event as CustomEvent<WalletCallDetail>).detail;
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);
respond(id, true, { walletAddress: result.publicKey.toString() });
} else if (action === 'signMessage') {
const { message } = payload as { message: string };
const { signature } = await provider.signMessage(new TextEncoder().encode(message), 'utf8');
respond(id, true, { signature: bs58.encode(signature) });
} else {
throw new Error(`Unknown wallet action: ${String(action)}`);
}
} catch (err) {
respond(id, false, err instanceof Error ? err.message : String(err));
}
})();
});
}
@@ -0,0 +1,42 @@
/**
* Isolated-world side of the wallet bridge: calls into the page-world script
* (wallet-bridge/inject.ts) via CustomEvents and awaits its response by
* matching request/response ids. This is the only way to reach
* `window.solana` from a normal (isolated-world) content script.
*/
interface WalletResultDetail {
id: string;
ok: boolean;
data?: unknown;
error?: string;
}
const WALLET_CALL_TIMEOUT_MS = 30_000;
export function callWallet<T>(action: 'connect' | 'signMessage', 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 handleResult(event: Event): void {
const detail = (event as CustomEvent<WalletResultDetail>).detail;
if (detail.id !== id) return; // another concurrent call's response
cleanup();
if (detail.ok) resolve(detail.data as T);
else reject(new Error(detail.error ?? 'Wallet call failed'));
}
function cleanup(): void {
clearTimeout(timeoutId);
window.removeEventListener('nexa:wallet-result', handleResult);
}
window.addEventListener('nexa:wallet-result', handleResult);
window.dispatchEvent(new CustomEvent('nexa:wallet-call', { detail: { id, action, payload } }));
});
}
+64
View File
@@ -0,0 +1,64 @@
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> {
hideConnectBanner();
await browser.runtime
.sendMessage({ type: 'nexa:wallet-connected', walletAddress } satisfies NexaMessage)
.catch(() => undefined);
}
/** 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> {
try {
const { walletAddress } = await callWallet<ConnectResult>('connect', {});
await reportConnected(walletAddress);
} catch (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> {
try {
const { walletAddress } = await callWallet<ConnectResult>('connect', { onlyIfTrusted: true });
await reportConnected(walletAddress);
} catch {
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 {
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;
}
return undefined;
});
}
+13 -3
View File
@@ -1,17 +1,21 @@
import { getSessionToken } from '@/background/backend-client'; import { clearSessionToken, getSessionToken } from '@/background/backend-client';
import { getConnectionStatus, setConnectionStatus } from '@/background/connection-status'; import { getConnectionStatus, setConnectionStatus } from '@/background/connection-status';
import { applyLockState, getLockState } from '@/background/lock-state'; import { applyLockState, getLockState } from '@/background/lock-state';
import { handleWalletConnected, requestWalletReconnect } from '@/background/wallet-auth';
import { connectWsClient } from '@/background/ws-client'; import { connectWsClient } from '@/background/ws-client';
import type { NexaMessage } from '@/shared/messaging'; import type { NexaMessage } from '@/shared/messaging';
export default defineBackground(() => { export default defineBackground(() => {
connectWsClient({ const ws = connectWsClient({
getToken: getSessionToken, getToken: getSessionToken,
onLockState: (state) => void applyLockState(state), onLockState: (state) => void applyLockState(state),
onStatusChange: (status) => void setConnectionStatus(status), onStatusChange: (status) => void setConnectionStatus(status),
onAuthExpired: () => {
void clearSessionToken().then(() => requestWalletReconnect());
},
}); });
browser.runtime.onMessage.addListener((message: NexaMessage, _sender, sendResponse) => { browser.runtime.onMessage.addListener((message: NexaMessage, sender, sendResponse) => {
switch (message?.type) { switch (message?.type) {
case 'nexa:get-lock-state': case 'nexa:get-lock-state':
getLockState().then(sendResponse); getLockState().then(sendResponse);
@@ -27,6 +31,12 @@ export default defineBackground(() => {
browser.action.openPopup().catch(() => undefined); browser.action.openPopup().catch(() => undefined);
return false; return false;
case 'nexa:wallet-connected':
handleWalletConnected(sender.tab?.id, message.walletAddress)
.then(() => ws.reconnectNow())
.catch(() => undefined); // signing/verify failed — ws-client's own retry loop keeps trying
return false;
default: default:
return undefined; return undefined;
} }
+14
View File
@@ -0,0 +1,14 @@
import { initWalletBridgeInjected } from '@/content-scripts/wallet-bridge/inject';
// world: 'MAIN' runs this in the page's own JS context (not the isolated
// content-script world) — the only place window.solana is reachable. See
// content-scripts/wallet-bridge/inject.ts for why and how it talks back to
// the isolated-world side.
export default defineContentScript({
matches: ['https://axiom.trade/*'],
world: 'MAIN',
runAt: 'document_start',
main() {
initWalletBridgeInjected();
},
});
+9 -1
View File
@@ -17,4 +17,12 @@ export type NexaMessage =
| { type: 'nexa:lock-state-changed'; state: LockState } | { type: 'nexa:lock-state-changed'; state: LockState }
| { type: 'nexa:get-connection-status' } | { type: 'nexa:get-connection-status' }
| { type: 'nexa:connection-status-changed'; status: ConnectionStatus } | { type: 'nexa:connection-status-changed'; status: ConnectionStatus }
| { type: 'nexa:open-popup' }; | { 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-sign-request'; nonce: string }
| { type: 'nexa:request-wallet-connect' };
/** Response shape for 'nexa:wallet-sign-request', returned via sendResponse (not a dispatched NexaMessage). */
export type WalletSignResult = { signature: string } | { error: string };