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:
@@ -86,11 +86,22 @@ Note the naming mismatch with the wire protocol below: the frontend's internal `
|
||||
## 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/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` — the REST auth flow (`POST /auth/nonce` → sign → `POST /auth/verify` → session token), persisted via `getSessionToken()`.
|
||||
- `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/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/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=...`. `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 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)
|
||||
|
||||
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
|
||||
|
||||
- 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 full-site-block implementation beyond a stub module.
|
||||
- No threshold-setting UI (placeholder link only).
|
||||
|
||||
Generated
+1
-8
@@ -12,8 +12,7 @@
|
||||
"@wxt-dev/module-react": "^1.2.2",
|
||||
"bs58": "^6.0.0",
|
||||
"react": "^19.2.8",
|
||||
"react-dom": "^19.2.8",
|
||||
"tweetnacl": "^1.0.3"
|
||||
"react-dom": "^19.2.8"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/react": "^19.2.18",
|
||||
@@ -3086,12 +3085,6 @@
|
||||
"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": {
|
||||
"version": "7.0.2",
|
||||
"resolved": "https://registry.npmjs.org/typescript/-/typescript-7.0.2.tgz",
|
||||
|
||||
+1
-2
@@ -24,7 +24,6 @@
|
||||
"@wxt-dev/module-react": "^1.2.2",
|
||||
"bs58": "^6.0.0",
|
||||
"react": "^19.2.8",
|
||||
"react-dom": "^19.2.8",
|
||||
"tweetnacl": "^1.0.3"
|
||||
"react-dom": "^19.2.8"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,54 +1,24 @@
|
||||
import bs58 from 'bs58';
|
||||
import { browser } from 'wxt/browser';
|
||||
import { BACKEND_HTTP_URL } from '@/shared/config';
|
||||
import { getIdentity } from './identity';
|
||||
|
||||
const SESSION_TOKEN_STORAGE_KEY = 'nexa:sessionToken';
|
||||
|
||||
/**
|
||||
* Wallet-signature auth against the backend's REST flow (see
|
||||
* backend/CLAUDE.md, "Current milestone: authentication + user data"):
|
||||
* request a nonce for our wallet_address, sign it, exchange for a session
|
||||
* token. That token is what the WS client (ws-client.ts) authenticates with.
|
||||
* Session token 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.
|
||||
*/
|
||||
export async function authenticate(): Promise<string> {
|
||||
const identity = await getIdentity();
|
||||
|
||||
const nonceRes = await fetch(`${BACKEND_HTTP_URL}/auth/nonce`, {
|
||||
method: 'POST',
|
||||
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;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a usable session token, authenticating from scratch if none is
|
||||
* 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 getSessionToken(forceRefresh = false): Promise<string> {
|
||||
if (!forceRefresh) {
|
||||
export async function getSessionToken(): Promise<string> {
|
||||
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;
|
||||
if (!token) throw new Error('Not authenticated yet — connect a wallet on a supported trading site.');
|
||||
return token;
|
||||
}
|
||||
return authenticate();
|
||||
|
||||
export async function storeSessionToken(token: string): Promise<void> {
|
||||
await browser.storage.local.set({ [SESSION_TOKEN_STORAGE_KEY]: token });
|
||||
}
|
||||
|
||||
export async function clearSessionToken(): Promise<void> {
|
||||
await browser.storage.local.remove(SESSION_TOKEN_STORAGE_KEY);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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
@@ -40,38 +40,58 @@ const WIRE_TO_INTERNAL_SCOPE: Record<WireScope, LockScope> = {
|
||||
};
|
||||
|
||||
// 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 {
|
||||
/** Resolve a session token; pass `forceRefresh: true` to get a fresh one. */
|
||||
getToken(forceRefresh?: boolean): Promise<string>;
|
||||
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;
|
||||
}
|
||||
|
||||
/** Starts the connection and keeps it alive; never resolves. */
|
||||
export function connectWsClient(options: WsClientOptions): void {
|
||||
let reconnectAttempt = 0;
|
||||
export interface WsClientController {
|
||||
/** Cancels any pending backoff wait and connects immediately — call after obtaining a fresh token. */
|
||||
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)];
|
||||
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');
|
||||
|
||||
let token: string;
|
||||
try {
|
||||
token = await options.getToken(forceRefresh);
|
||||
token = await options.getToken();
|
||||
} catch {
|
||||
options.onStatusChange('auth-error');
|
||||
scheduleReconnect(true);
|
||||
// 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;
|
||||
@@ -93,9 +113,15 @@ export function connectWsClient(options: WsClientOptions): void {
|
||||
reason: message.reason ?? undefined,
|
||||
lockedAt: message.triggered_at ? Date.parse(message.triggered_at) : undefined,
|
||||
});
|
||||
} else if (message.type === 'error' && message.code === 'auth_expired') {
|
||||
// Server already closes the socket for us on auth_expired; the
|
||||
// 'close' handler below does the reconnect-with-fresh-token.
|
||||
} 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
|
||||
@@ -103,12 +129,16 @@ export function connectWsClient(options: WsClientOptions): void {
|
||||
});
|
||||
|
||||
socket.addEventListener('close', (event) => {
|
||||
options.onStatusChange(event.code === 4001 ? 'auth-error' : 'disconnected');
|
||||
// 4001 (auth failed) and session_revoked both mean the current token
|
||||
// is bad — get a fresh one before retrying, per the protocol's
|
||||
// connection-lifecycle notes on both cases.
|
||||
const forceRefreshNext = event.code === 4001;
|
||||
scheduleReconnect(forceRefreshNext);
|
||||
if (currentSocket === socket) currentSocket = undefined;
|
||||
|
||||
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', () => {
|
||||
@@ -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();
|
||||
|
||||
return { reconnectNow };
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ 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';
|
||||
@@ -22,6 +23,10 @@ const interventionsByScope: Record<LockState['scope'], Intervention> = {
|
||||
|
||||
/** 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
|
||||
|
||||
@@ -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 } }));
|
||||
});
|
||||
}
|
||||
@@ -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;
|
||||
});
|
||||
}
|
||||
@@ -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 { applyLockState, getLockState } from '@/background/lock-state';
|
||||
import { handleWalletConnected, requestWalletReconnect } from '@/background/wallet-auth';
|
||||
import { connectWsClient } from '@/background/ws-client';
|
||||
import type { NexaMessage } from '@/shared/messaging';
|
||||
|
||||
export default defineBackground(() => {
|
||||
connectWsClient({
|
||||
const ws = connectWsClient({
|
||||
getToken: getSessionToken,
|
||||
onLockState: (state) => void applyLockState(state),
|
||||
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) {
|
||||
case 'nexa:get-lock-state':
|
||||
getLockState().then(sendResponse);
|
||||
@@ -27,6 +31,12 @@ export default defineBackground(() => {
|
||||
browser.action.openPopup().catch(() => undefined);
|
||||
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:
|
||||
return undefined;
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
},
|
||||
});
|
||||
@@ -17,4 +17,12 @@ export type NexaMessage =
|
||||
| { type: 'nexa:lock-state-changed'; state: LockState }
|
||||
| { type: 'nexa:get-connection-status' }
|
||||
| { 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 };
|
||||
|
||||
Reference in New Issue
Block a user