Firefox's implicit default extension-pages CSP includes
upgrade-insecure-requests, which rewrites the WS client's plain
ws://localhost:8080/ws connection to wss:// -- which nothing is
listening on, since the local dev backend has no TLS (deliberately;
see backend/CLAUDE.md). Symptom was silent: a CSP console message
about the upgrade, then a failed connection with no other signal.
Declaring an explicit content_security_policy.extension_pages in
wxt.config.ts (otherwise identical to Firefox's own default) replaces
the implicit one and drops the upgrade directive. Gated to
browser === 'firefox' since Chrome doesn't have this behavior and its
MV3 CSP can't be loosened this way regardless.
Co-Authored-By: Claude Sonnet 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01YXiHuScXrjxBh7yFGAPq3B
A content script reports 'wallet-connected' on every page load (it
always tries a silent onlyIfTrusted connect first), and the handler
was unconditionally running the full nonce/sign/verify cycle every
time. signMessage() shows a fresh Phantom approval popup on every
call, unlike connect() which is silent once trusted -- so this meant
a new signature prompt on every single axiom.trade page load, caught
during manual testing ("signing appears every time").
Now the handler checks for an existing valid session token first and
only re-authenticates when there isn't one.
Confirmed end-to-end against real Phantom on Zen: connect -> sign ->
verify -> session token stored, via the wallet-auth debug logs.
Co-Authored-By: Claude Sonnet 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01YXiHuScXrjxBh7yFGAPq3B
Manual testing on Zen/Firefox surfaced "Uncaught Error: Permission
denied to access property 'id'" the moment the isolated-world relay
dispatched a CustomEvent to the world:'MAIN' injected script. That's
a Firefox-specific Xray-wrapper restriction: a CustomEvent's `detail`
object created in one world can't have its properties read from the
other, even though the event itself fires fine. Chromium doesn't
enforce this, which is why it wasn't caught until testing on the
actual target browser (Zen).
Switched both sides of the bridge (wallet-bridge/inject.ts,
wallet-bridge/relay.ts) to window.postMessage with a `channel` field
and same-window source check, since postMessage structured-clones
its payload across the boundary correctly on both browsers -- the
same approach Phantom's own inpage<->content-script bridge uses.
Also added [nexa/...]-prefixed console.debug breadcrumbs through the
wallet-connect/wallet-bridge/wallet-auth chain, since diagnosing this
without them (previous commit shipped none) took several rounds of
"nothing happened" back and forth.
Still not fully verified end-to-end against live Phantom -- the
crash is fixed, but a full connect -> sign -> verify round trip
hasn't been confirmed yet. See CLAUDE.md.
Co-Authored-By: Claude Sonnet 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01YXiHuScXrjxBh7yFGAPq3B
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
Popup is now a real React app (App.tsx/main.tsx via createRoot),
wired through @wxt-dev/module-react. tsconfig.json needed an explicit
jsx: "react-jsx" -- WXT's generated .wxt/tsconfig.json doesn't set
it, so tsc --noEmit failed on JSX syntax even though the Vite build
itself was fine.
Root cause of the actually-reported bug (blank popup, predating the
React port too): the backend defaulted to port 3000, the same port
WXT's dev server uses for this extension. With both running, the
popup's script tags pointed at the Vite dev server but the backend
answered instead, so main.tsx never loaded -- "View Page Source"
showed raw unbundled dev-mode HTML pointing at localhost:3000.
Backend now binds :8080 (see backend commit), and
BACKEND_HTTP_URL/BACKEND_WS_URL + host_permissions here follow it.
Co-Authored-By: Claude Sonnet 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01YXiHuScXrjxBh7yFGAPq3B
Adds the wire-protocol WebSocket client (ws-client.ts) and the REST
auth flow (backend-client.ts) against the now-live backend, using a
locally-generated ed25519 keypair (identity.ts) as a stand-in wallet
signer until real wallet-extension integration is built.
lock-state.ts's setLockState is now applyLockState, called only by
the WS client on an incoming lock_state message -- the backend is
the sole source of truth for lock state, so there's no other writer
anymore. The popup's dev mock controls are replaced with a live
connection-status + lock-state display.
Verified end-to-end against the real backend (nonce -> verify -> /me
-> ws lock_state) using the same tweetnacl/bs58 libs shipped in the
extension.
Co-Authored-By: Claude Sonnet 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01YXiHuScXrjxBh7yFGAPq3B
Consolidate NEXA_SPEC.md and NEXA_PROTOCOL_SPEC.md into CLAUDE.md as the
single source of truth, updating the behavioral notes to reflect what
live debugging on axiom.trade actually found (rather than the original
spec's assumptions) and adding the backend WebSocket wire contract.
Co-Authored-By: Claude Sonnet 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01CHESr7MTKG5Dc3mRPvWPn7