Fix wallet bridge on Firefox: use postMessage instead of CustomEvent
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
This commit is contained in:
@@ -95,12 +95,13 @@ Note the naming mismatch with the wire protocol below: the frontend's internal `
|
|||||||
|
|
||||||
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.
|
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/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)` 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/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-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).
|
- `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.
|
- **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.
|
- **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.
|
||||||
|
- **Still unverified**: the full approval-popup flow against real Phantom (does the gesture from the banner's click handler survive the isolated→main-world postMessage hop for Phantom's own internal "was this a user gesture" check; does `onlyIfTrusted` correctly persist across reloads) hasn't been confirmed end-to-end yet — the postMessage fix resolved the Firefox crash, but a full connect→sign→verify round-trip against live Phantom still needs a manual pass. `world: 'MAIN'` also needs Firefox 128+; confirm Zen's base version supports it.
|
||||||
|
|
||||||
## WebSocket protocol (backend wire contract)
|
## WebSocket protocol (backend wire contract)
|
||||||
|
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import { storeSessionToken } from './backend-client';
|
|||||||
* ws-client's own retry loop to keep trying getSessionToken()).
|
* ws-client's own retry loop to keep trying getSessionToken()).
|
||||||
*/
|
*/
|
||||||
export async function handleWalletConnected(tabId: number | undefined, walletAddress: string): Promise<void> {
|
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');
|
if (tabId == null) throw new Error('wallet-connected message had no source tab');
|
||||||
|
|
||||||
const nonceRes = await fetch(`${BACKEND_HTTP_URL}/auth/nonce`, {
|
const nonceRes = await fetch(`${BACKEND_HTTP_URL}/auth/nonce`, {
|
||||||
@@ -22,12 +23,14 @@ export async function handleWalletConnected(tabId: number | undefined, walletAdd
|
|||||||
});
|
});
|
||||||
if (!nonceRes.ok) throw new Error(`nonce request failed: ${nonceRes.status}`);
|
if (!nonceRes.ok) throw new Error(`nonce request failed: ${nonceRes.status}`);
|
||||||
const { nonce } = (await nonceRes.json()) as { nonce: string };
|
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, {
|
const signResult = (await browser.tabs.sendMessage(tabId, {
|
||||||
type: 'nexa:wallet-sign-request',
|
type: 'nexa:wallet-sign-request',
|
||||||
nonce,
|
nonce,
|
||||||
} satisfies NexaMessage)) as WalletSignResult;
|
} satisfies NexaMessage)) as WalletSignResult;
|
||||||
if ('error' in signResult) throw new Error(signResult.error);
|
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`, {
|
const verifyRes = await fetch(`${BACKEND_HTTP_URL}/auth/verify`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
@@ -38,6 +41,7 @@ export async function handleWalletConnected(tabId: number | undefined, walletAdd
|
|||||||
const { session_token: sessionToken } = (await verifyRes.json()) as { session_token: string };
|
const { session_token: sessionToken } = (await verifyRes.json()) as { session_token: string };
|
||||||
|
|
||||||
await storeSessionToken(sessionToken);
|
await storeSessionToken(sessionToken);
|
||||||
|
console.debug('[nexa/wallet-auth] session token stored');
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Prompts any open supported-site tab to attempt a silent (onlyIfTrusted) reconnect — used after the session is invalidated server-side. */
|
/** Prompts any open supported-site tab to attempt a silent (onlyIfTrusted) reconnect — used after the session is invalidated server-side. */
|
||||||
|
|||||||
@@ -5,12 +5,20 @@ import bs58 from 'bs58';
|
|||||||
* entrypoints/wallet-bridge.content.ts) — the only place `window.solana`
|
* entrypoints/wallet-bridge.content.ts) — the only place `window.solana`
|
||||||
* (Phantom's injected provider) is reachable, since content scripts run in
|
* (Phantom's injected provider) is reachable, since content scripts run in
|
||||||
* an isolated world that can't call into page-defined objects/functions
|
* an isolated world that can't call into page-defined objects/functions
|
||||||
* directly. Talks to the isolated-world side (wallet-bridge/relay.ts) purely
|
* directly. Talks to the isolated-world side (wallet-bridge/relay.ts) via
|
||||||
* via CustomEvents on `window`, since that's the one channel both worlds
|
* `window.postMessage`, NOT CustomEvents: on Firefox, a CustomEvent's
|
||||||
* share reliably across Chromium and Firefox.
|
* `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.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
interface WalletCallDetail {
|
const CALL_CHANNEL = 'nexa:wallet-call';
|
||||||
|
const RESULT_CHANNEL = 'nexa:wallet-result';
|
||||||
|
|
||||||
|
interface WalletCallMessage {
|
||||||
|
channel: typeof CALL_CHANNEL;
|
||||||
id: string;
|
id: string;
|
||||||
action: 'connect' | 'signMessage';
|
action: 'connect' | 'signMessage';
|
||||||
payload: { onlyIfTrusted?: boolean } | { message: string };
|
payload: { onlyIfTrusted?: boolean } | { message: string };
|
||||||
@@ -30,16 +38,22 @@ function getProvider(): PhantomProvider | undefined {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function respond(id: string, ok: boolean, dataOrError: unknown): void {
|
function respond(id: string, ok: boolean, dataOrError: unknown): void {
|
||||||
window.dispatchEvent(
|
window.postMessage(
|
||||||
new CustomEvent('nexa:wallet-result', {
|
ok ? { channel: RESULT_CHANNEL, id, ok, data: dataOrError } : { channel: RESULT_CHANNEL, id, ok, error: dataOrError },
|
||||||
detail: ok ? { id, ok, data: dataOrError } : { id, ok, error: dataOrError },
|
window.location.origin,
|
||||||
}),
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function initWalletBridgeInjected(): void {
|
export function initWalletBridgeInjected(): void {
|
||||||
window.addEventListener('nexa:wallet-call', (event) => {
|
console.debug('[nexa/wallet-bridge] injected script active on', window.location.href);
|
||||||
const { id, action, payload } = (event as CustomEvent<WalletCallDetail>).detail;
|
|
||||||
|
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 () => {
|
void (async () => {
|
||||||
try {
|
try {
|
||||||
@@ -49,15 +63,17 @@ export function initWalletBridgeInjected(): void {
|
|||||||
if (action === 'connect') {
|
if (action === 'connect') {
|
||||||
const { onlyIfTrusted } = payload as { onlyIfTrusted?: boolean };
|
const { onlyIfTrusted } = payload as { onlyIfTrusted?: boolean };
|
||||||
const result = await provider.connect(onlyIfTrusted ? { onlyIfTrusted: true } : undefined);
|
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() });
|
respond(id, true, { walletAddress: result.publicKey.toString() });
|
||||||
} else if (action === 'signMessage') {
|
} else if (action === 'signMessage') {
|
||||||
const { message } = payload as { message: string };
|
const { message: nonce } = payload as { message: string };
|
||||||
const { signature } = await provider.signMessage(new TextEncoder().encode(message), 'utf8');
|
const { signature } = await provider.signMessage(new TextEncoder().encode(nonce), 'utf8');
|
||||||
respond(id, true, { signature: bs58.encode(signature) });
|
respond(id, true, { signature: bs58.encode(signature) });
|
||||||
} else {
|
} else {
|
||||||
throw new Error(`Unknown wallet action: ${String(action)}`);
|
throw new Error(`Unknown wallet action: ${String(action)}`);
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
console.debug('[nexa/wallet-bridge] call failed:', err);
|
||||||
respond(id, false, err instanceof Error ? err.message : String(err));
|
respond(id, false, err instanceof Error ? err.message : String(err));
|
||||||
}
|
}
|
||||||
})();
|
})();
|
||||||
|
|||||||
@@ -1,11 +1,18 @@
|
|||||||
/**
|
/**
|
||||||
* Isolated-world side of the wallet bridge: calls into the page-world script
|
* Isolated-world side of the wallet bridge: calls into the page-world script
|
||||||
* (wallet-bridge/inject.ts) via CustomEvents and awaits its response by
|
* (wallet-bridge/inject.ts) via `window.postMessage` and awaits its response
|
||||||
* matching request/response ids. This is the only way to reach
|
* by matching request/response ids. postMessage, not CustomEvent — a
|
||||||
* `window.solana` from a normal (isolated-world) content script.
|
* 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.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
interface WalletResultDetail {
|
const CALL_CHANNEL = 'nexa:wallet-call';
|
||||||
|
const RESULT_CHANNEL = 'nexa:wallet-result';
|
||||||
|
|
||||||
|
interface WalletResultMessage {
|
||||||
|
channel: typeof RESULT_CHANNEL;
|
||||||
id: string;
|
id: string;
|
||||||
ok: boolean;
|
ok: boolean;
|
||||||
data?: unknown;
|
data?: unknown;
|
||||||
@@ -23,20 +30,22 @@ export function callWallet<T>(action: 'connect' | 'signMessage', payload: unknow
|
|||||||
reject(new Error('Wallet bridge timed out — is Phantom installed and unlocked?'));
|
reject(new Error('Wallet bridge timed out — is Phantom installed and unlocked?'));
|
||||||
}, WALLET_CALL_TIMEOUT_MS);
|
}, WALLET_CALL_TIMEOUT_MS);
|
||||||
|
|
||||||
function handleResult(event: Event): void {
|
function handleMessage(event: MessageEvent): void {
|
||||||
const detail = (event as CustomEvent<WalletResultDetail>).detail;
|
if (event.source !== window) return; // ignore iframes/other windows
|
||||||
if (detail.id !== id) return; // another concurrent call's response
|
const message = event.data as Partial<WalletResultMessage> | undefined;
|
||||||
|
if (message?.channel !== RESULT_CHANNEL || message.id !== id) return;
|
||||||
|
|
||||||
cleanup();
|
cleanup();
|
||||||
if (detail.ok) resolve(detail.data as T);
|
if (message.ok) resolve(message.data as T);
|
||||||
else reject(new Error(detail.error ?? 'Wallet call failed'));
|
else reject(new Error(message.error ?? 'Wallet call failed'));
|
||||||
}
|
}
|
||||||
|
|
||||||
function cleanup(): void {
|
function cleanup(): void {
|
||||||
clearTimeout(timeoutId);
|
clearTimeout(timeoutId);
|
||||||
window.removeEventListener('nexa:wallet-result', handleResult);
|
window.removeEventListener('message', handleMessage);
|
||||||
}
|
}
|
||||||
|
|
||||||
window.addEventListener('nexa:wallet-result', handleResult);
|
window.addEventListener('message', handleMessage);
|
||||||
window.dispatchEvent(new CustomEvent('nexa:wallet-call', { detail: { id, action, payload } }));
|
window.postMessage({ channel: CALL_CHANNEL, id, action, payload }, window.location.origin);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,18 +12,21 @@ interface SignResult {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function reportConnected(walletAddress: string): Promise<void> {
|
async function reportConnected(walletAddress: string): Promise<void> {
|
||||||
|
console.debug('[nexa/wallet-connect] connected', walletAddress);
|
||||||
hideConnectBanner();
|
hideConnectBanner();
|
||||||
await browser.runtime
|
await browser.runtime
|
||||||
.sendMessage({ type: 'nexa:wallet-connected', walletAddress } satisfies NexaMessage)
|
.sendMessage({ type: 'nexa:wallet-connected', walletAddress } satisfies NexaMessage)
|
||||||
.catch(() => undefined);
|
.catch((err) => console.debug('[nexa/wallet-connect] failed to notify background:', err));
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Real user gesture (banner button click) — required for Phantom to show its connect approval popup on a first-ever connect. */
|
/** 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> {
|
async function connectWithGesture(): Promise<void> {
|
||||||
|
console.debug('[nexa/wallet-connect] banner clicked, calling connect()');
|
||||||
try {
|
try {
|
||||||
const { walletAddress } = await callWallet<ConnectResult>('connect', {});
|
const { walletAddress } = await callWallet<ConnectResult>('connect', {});
|
||||||
await reportConnected(walletAddress);
|
await reportConnected(walletAddress);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
console.debug('[nexa/wallet-connect] connect() failed:', err);
|
||||||
setConnectBannerError(err instanceof Error ? err.message : String(err));
|
setConnectBannerError(err instanceof Error ? err.message : String(err));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -34,16 +37,19 @@ async function connectWithGesture(): Promise<void> {
|
|||||||
* connect. Falls back to the on-page banner (a real click) only when it isn't.
|
* connect. Falls back to the on-page banner (a real click) only when it isn't.
|
||||||
*/
|
*/
|
||||||
async function attemptSilentConnect(): Promise<void> {
|
async function attemptSilentConnect(): Promise<void> {
|
||||||
|
console.debug('[nexa/wallet-connect] attempting silent connect');
|
||||||
try {
|
try {
|
||||||
const { walletAddress } = await callWallet<ConnectResult>('connect', { onlyIfTrusted: true });
|
const { walletAddress } = await callWallet<ConnectResult>('connect', { onlyIfTrusted: true });
|
||||||
await reportConnected(walletAddress);
|
await reportConnected(walletAddress);
|
||||||
} catch {
|
} catch (err) {
|
||||||
|
console.debug('[nexa/wallet-connect] silent connect failed, showing banner:', err);
|
||||||
showConnectBanner(() => void connectWithGesture());
|
showConnectBanner(() => void connectWithGesture());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Wires wallet connect/sign into the page. Independent of any site adapter — runs regardless of whether a buy-button adapter matched. */
|
/** 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 {
|
export function initWalletConnect(): void {
|
||||||
|
console.debug('[nexa/wallet-connect] init on', window.location.href);
|
||||||
void attemptSilentConnect();
|
void attemptSilentConnect();
|
||||||
|
|
||||||
browser.runtime.onMessage.addListener((message: NexaMessage, _sender, sendResponse) => {
|
browser.runtime.onMessage.addListener((message: NexaMessage, _sender, sendResponse) => {
|
||||||
|
|||||||
@@ -34,7 +34,7 @@ export default defineBackground(() => {
|
|||||||
case 'nexa:wallet-connected':
|
case 'nexa:wallet-connected':
|
||||||
handleWalletConnected(sender.tab?.id, message.walletAddress)
|
handleWalletConnected(sender.tab?.id, message.walletAddress)
|
||||||
.then(() => ws.reconnectNow())
|
.then(() => ws.reconnectNow())
|
||||||
.catch(() => undefined); // signing/verify failed — ws-client's own retry loop keeps trying
|
.catch((err) => console.debug('[nexa/background] wallet auth failed:', err)); // ws-client's own retry loop keeps trying regardless
|
||||||
return false;
|
return false;
|
||||||
|
|
||||||
default:
|
default:
|
||||||
|
|||||||
Reference in New Issue
Block a user