@abstraxn/signer-react 3.3.6 → 3.3.8

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -5,6 +5,16 @@ All notable changes to this project will be documented in this file.
5
5
  The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
6
6
  and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
7
 
8
+ ## [3.3.8] - 2026-09-16
9
+
10
+ ### Fixed
11
+ - **Create Proposal never opened MetaMask (loader stuck)** – 3.3.7 force-restarted the WalletConnect relay after every return from the wallet, including after *connect*. That restart raced the next `session_request`, so Create Proposal never deeplinked to MetaMask on iOS/Android. Relay restart now happens only for an in-flight approval. New transactions send first, then explicitly open the connected wallet app so the confirmation popup appears.
12
+
13
+ ## [3.3.7] - 2026-09-15
14
+
15
+ ### Fixed
16
+ - **WalletConnect loader stuck after MetaMask approve (create proposal / deposit)** – Returning from the wallet left a zombie relay socket (`connected === true` on a dead WebSocket). The SDK skipped `restartTransport()`, so the tx hash never reached the dapp even though MetaMask confirmed on-chain. After a backgrounded deeplink the relay is now force-restarted, receipt polling retries when the tab is visible again, and React Query refetches on reconnect/focus so status can update without a manual refresh.
17
+
8
18
  ## [3.3.6] - 2026-09-14
9
19
 
10
20
  ### Fixed
@@ -68,8 +68,11 @@ export function useQueryClientSafe() {
68
68
  return new QueryClient({
69
69
  defaultOptions: {
70
70
  queries: {
71
- refetchOnWindowFocus: false,
72
- retry: false,
71
+ // Recover waitForTransactionReceipt / balance queries after returning
72
+ // from a mobile wallet deeplink (tab was frozen / network dropped).
73
+ refetchOnWindowFocus: true,
74
+ refetchOnReconnect: true,
75
+ retry: 2,
73
76
  },
74
77
  },
75
78
  });
package/dist/src/hooks.js CHANGED
@@ -12,6 +12,7 @@ import { useWalletClient as useWagmiWalletClient, useAccount, useConfig, useChai
12
12
  import { getWalletClient, switchChain } from '@wagmi/core';
13
13
  import { getConnectorMeta } from './connectors';
14
14
  import { getChainById } from './chains';
15
+ import { waitUntilDocumentVisible } from './walletConnectMobile';
15
16
  /**
16
17
  * Hook to check if wallet is connected
17
18
  */
@@ -2064,15 +2065,40 @@ export function useWaitForTxnReceipt(provider) {
2064
2065
  throw new Error('Transaction hash is required');
2065
2066
  }
2066
2067
  try {
2067
- // waitForTransactionReceipt is a method on PublicClient
2068
- const receipt = await provider.waitForTransactionReceipt({
2069
- hash,
2070
- confirmations,
2071
- timeout,
2072
- });
2073
- return receipt;
2068
+ // After MetaMask deeplink the tab was frozen; wait until we are visible
2069
+ // again and retry receipt polls so the UI is not stuck on a loader.
2070
+ await waitUntilDocumentVisible();
2071
+ const overallTimeout = timeout ?? 180_000;
2072
+ const startedAt = Date.now();
2073
+ let lastError;
2074
+ while (Date.now() - startedAt < overallTimeout) {
2075
+ const remaining = overallTimeout - (Date.now() - startedAt);
2076
+ try {
2077
+ const receipt = await provider.waitForTransactionReceipt({
2078
+ hash,
2079
+ confirmations,
2080
+ timeout: Math.min(20_000, remaining),
2081
+ pollingInterval: 1_500,
2082
+ });
2083
+ return receipt;
2084
+ }
2085
+ catch (error) {
2086
+ lastError = error;
2087
+ await waitUntilDocumentVisible();
2088
+ await new Promise((resolve) => setTimeout(resolve, 1_000));
2089
+ }
2090
+ }
2091
+ if (lastError instanceof Error) {
2092
+ throw new Error(`Failed to wait for transaction receipt: ${lastError.message}`);
2093
+ }
2094
+ throw lastError instanceof Error
2095
+ ? lastError
2096
+ : new Error('Failed to wait for transaction receipt');
2074
2097
  }
2075
2098
  catch (error) {
2099
+ if (error instanceof Error && error.message.startsWith('Failed to wait for transaction receipt')) {
2100
+ throw error;
2101
+ }
2076
2102
  if (error instanceof Error) {
2077
2103
  throw new Error(`Failed to wait for transaction receipt: ${error.message}`);
2078
2104
  }
@@ -34,6 +34,12 @@ export declare function getDappReturnUrl(): string;
34
34
  * navigating Safari to a new URL (which reloads and drops in-memory state).
35
35
  */
36
36
  export declare function getSourceBrowserNativeRedirect(): string | undefined;
37
+ /**
38
+ * Open the already-connected wallet app so the user sees the confirmation UI.
39
+ * WalletConnect often sends the session_request but does not deeplink on a
40
+ * later tx if the QR modal is hidden — Create Proposal then sits on a loader.
41
+ */
42
+ export declare function openConnectedWalletApp(provider: any): void;
37
43
  /**
38
44
  * Lazy metadata so url/redirect are read at connect/request time, not at
39
45
  * provider mount (which is often the homepage in Next.js layouts).
@@ -64,8 +70,16 @@ export declare function syncWalletConnectDappRedirect(provider: any): void;
64
70
  /**
65
71
  * Re-open the WalletConnect relay after the mobile browser comes back
66
72
  * from the wallet app so pending approve/reject responses can be delivered.
73
+ *
74
+ * `force` must be used after the tab was backgrounded: iOS/Android often leave
75
+ * `relayer.connected === true` on a dead socket, so a ping/early-return skips
76
+ * the restart and the in-flight tx hash never arrives (loader hangs until refresh).
67
77
  */
68
- export declare function resumeWalletConnectRelayer(provider: any): Promise<void>;
78
+ export declare function resumeWalletConnectRelayer(provider: any, options?: {
79
+ force?: boolean;
80
+ }): Promise<void>;
81
+ /** Resolves when the dapp tab is in the foreground again (mobile wallet return). */
82
+ export declare function waitUntilDocumentVisible(): Promise<void>;
69
83
  /**
70
84
  * Undo the inline `display: none !important` we apply after connect so the
71
85
  * WalletConnect modal can show "Continue in wallet" and process the response.
@@ -16,6 +16,8 @@
16
16
  */
17
17
  const PATCHED_FLAG = "__abstraxnWcMobilePatched";
18
18
  const PENDING_FLAG = "__abstraxnWcPendingApprovals";
19
+ const NEEDS_RESUME_FLAG = "__abstraxnWcNeedsResume";
20
+ const RESUME_LOCK = "__abstraxnWcResumeLock";
19
21
  const RETURN_HREF_KEY = "abstraxn_wc_return_href";
20
22
  const RETURN_AT_KEY = "abstraxn_wc_return_at";
21
23
  const RETURN_TTL_MS = 3 * 60 * 1000;
@@ -121,6 +123,79 @@ export function getSourceBrowserNativeRedirect() {
121
123
  return "googlechrome://";
122
124
  return undefined;
123
125
  }
126
+ function isAndroid() {
127
+ if (typeof navigator === "undefined")
128
+ return false;
129
+ return /Android/i.test(navigator.userAgent || "");
130
+ }
131
+ function isMobileDevice() {
132
+ return isIOS() || isAndroid();
133
+ }
134
+ function isWalletHttpDeepLink(url) {
135
+ try {
136
+ const host = new URL(url).hostname.toLowerCase();
137
+ return (host.includes("metamask.app.link") ||
138
+ host.includes("link.metamask.io") ||
139
+ host.includes("rainbow.me") ||
140
+ host.includes("link.trustwallet.com") ||
141
+ host.includes("walletconnect.com"));
142
+ }
143
+ catch {
144
+ return false;
145
+ }
146
+ }
147
+ function canOpenAsWalletApp(url) {
148
+ if (!url)
149
+ return false;
150
+ if (/^https?:\/\//i.test(url))
151
+ return isWalletHttpDeepLink(url);
152
+ return true;
153
+ }
154
+ /**
155
+ * Open the already-connected wallet app so the user sees the confirmation UI.
156
+ * WalletConnect often sends the session_request but does not deeplink on a
157
+ * later tx if the QR modal is hidden — Create Proposal then sits on a loader.
158
+ */
159
+ export function openConnectedWalletApp(provider) {
160
+ if (typeof window === "undefined" || !isMobileDevice())
161
+ return;
162
+ restoreWalletConnectModal();
163
+ try {
164
+ if (typeof provider?.modal?.open === "function") {
165
+ void provider.modal.open();
166
+ }
167
+ }
168
+ catch {
169
+ // Modal may not exist after we force-hid it post-connect.
170
+ }
171
+ const peer = provider?.session?.peer?.metadata ??
172
+ provider?.signer?.session?.peer?.metadata;
173
+ const native = String(peer?.redirect?.native || "");
174
+ const universal = String(peer?.redirect?.universal || "");
175
+ const name = String(peer?.name || "").toLowerCase();
176
+ let target = "";
177
+ if (canOpenAsWalletApp(native))
178
+ target = native;
179
+ else if (canOpenAsWalletApp(universal))
180
+ target = universal;
181
+ else if (name.includes("metamask") || !name) {
182
+ target = isIOS() ? "metamask://" : "https://metamask.app.link/wc";
183
+ }
184
+ else if (name.includes("rainbow")) {
185
+ target = "rainbow://";
186
+ }
187
+ else if (name.includes("trust")) {
188
+ target = "trust://";
189
+ }
190
+ if (!target)
191
+ return;
192
+ try {
193
+ window.location.href = target;
194
+ }
195
+ catch {
196
+ // Ignore
197
+ }
198
+ }
124
199
  function getWalletConnectRedirect() {
125
200
  const native = getSourceBrowserNativeRedirect();
126
201
  return native ? { native } : undefined;
@@ -249,60 +324,99 @@ function isRelayerConnected(relayer) {
249
324
  relayer?.provider?.connected === true ||
250
325
  relayer?.provider?.connection?.connected === true);
251
326
  }
327
+ async function restartWalletConnectTransport(relayer) {
328
+ if (typeof relayer.restartTransport === "function") {
329
+ await relayer.restartTransport();
330
+ return;
331
+ }
332
+ if (typeof relayer.transportOpen === "function") {
333
+ try {
334
+ await relayer.transportClose?.();
335
+ }
336
+ catch {
337
+ // Ignore close errors and still try to open.
338
+ }
339
+ await relayer.transportOpen();
340
+ }
341
+ }
252
342
  /**
253
343
  * Re-open the WalletConnect relay after the mobile browser comes back
254
344
  * from the wallet app so pending approve/reject responses can be delivered.
345
+ *
346
+ * `force` must be used after the tab was backgrounded: iOS/Android often leave
347
+ * `relayer.connected === true` on a dead socket, so a ping/early-return skips
348
+ * the restart and the in-flight tx hash never arrives (loader hangs until refresh).
255
349
  */
256
- export async function resumeWalletConnectRelayer(provider) {
350
+ export async function resumeWalletConnectRelayer(provider, options) {
257
351
  if (!provider)
258
352
  return;
259
353
  const relayer = getWalletConnectRelayer(provider);
260
354
  if (!relayer)
261
355
  return;
262
- try {
263
- if (isRelayerConnected(relayer)) {
264
- try {
265
- const ping = relayer.provider?.connection?.ping;
266
- if (typeof ping === "function") {
267
- await Promise.race([
268
- ping.call(relayer.provider.connection),
269
- new Promise((_, reject) => setTimeout(() => reject(new Error("ping timeout")), 1500)),
270
- ]);
271
- }
272
- return;
273
- }
274
- catch {
275
- // Ping failed — transport looks stale, restart below.
276
- }
356
+ const existing = provider[RESUME_LOCK];
357
+ if (existing && !options?.force) {
358
+ try {
359
+ await existing;
277
360
  }
278
- if (typeof relayer.restartTransport === "function") {
279
- await relayer.restartTransport();
361
+ catch {
362
+ // Previous resume failed.
280
363
  }
281
- else if (typeof relayer.transportOpen === "function") {
282
- try {
283
- await relayer.transportClose?.();
364
+ return;
365
+ }
366
+ const run = (async () => {
367
+ try {
368
+ // New requests must not restart a live session — that races the
369
+ // session_request and MetaMask never shows a confirmation popup.
370
+ if (!options?.force) {
371
+ if (isRelayerConnected(relayer))
372
+ return;
373
+ await restartWalletConnectTransport(relayer);
374
+ return;
284
375
  }
285
- catch {
286
- // Ignore close errors and still try to open.
376
+ await restartWalletConnectTransport(relayer);
377
+ const session = provider?.session ?? provider?.signer?.session;
378
+ const topic = session?.topic;
379
+ const client = provider?.signer?.client || provider?.client;
380
+ if (topic && typeof client?.ping === "function") {
381
+ try {
382
+ await client.ping({ topic });
383
+ }
384
+ catch {
385
+ // Session ping is best-effort; the request may still complete.
386
+ }
287
387
  }
288
- await relayer.transportOpen();
289
388
  }
290
- const session = provider?.session ?? provider?.signer?.session;
291
- const topic = session?.topic;
292
- const client = provider?.signer?.client || provider?.client;
293
- if (topic && typeof client?.ping === "function") {
294
- try {
295
- await client.ping({ topic });
296
- }
297
- catch {
298
- // Session ping is best-effort; the request may still complete.
299
- }
389
+ catch {
390
+ // Best-effort: a failed resume should not break the original request.
300
391
  }
392
+ })();
393
+ provider[RESUME_LOCK] = run;
394
+ try {
395
+ await run;
301
396
  }
302
- catch {
303
- // Best-effort: a failed resume should not break the original request.
397
+ finally {
398
+ if (provider[RESUME_LOCK] === run)
399
+ provider[RESUME_LOCK] = null;
304
400
  }
305
401
  }
402
+ /** Resolves when the dapp tab is in the foreground again (mobile wallet return). */
403
+ export function waitUntilDocumentVisible() {
404
+ if (typeof document === "undefined")
405
+ return Promise.resolve();
406
+ if (document.visibilityState === "visible")
407
+ return Promise.resolve();
408
+ return new Promise((resolve) => {
409
+ const onVis = () => {
410
+ if (document.visibilityState === "visible") {
411
+ document.removeEventListener("visibilitychange", onVis);
412
+ window.removeEventListener("pageshow", onVis);
413
+ resolve();
414
+ }
415
+ };
416
+ document.addEventListener("visibilitychange", onVis);
417
+ window.addEventListener("pageshow", onVis);
418
+ });
419
+ }
306
420
  /**
307
421
  * Undo the inline `display: none !important` we apply after connect so the
308
422
  * WalletConnect modal can show "Continue in wallet" and process the response.
@@ -347,18 +461,42 @@ export function patchWalletConnectProviderForMobile(provider) {
347
461
  provider[PENDING_FLAG] = 0;
348
462
  provider.request = async (...args) => {
349
463
  const method = getRequestMethod(args);
350
- if (!isUserApprovalRpcMethod(method)) {
464
+ const isApproval = isUserApprovalRpcMethod(method);
465
+ const wasBackgrounded = !!provider[NEEDS_RESUME_FLAG];
466
+ if (!isApproval && !wasBackgrounded) {
467
+ return originalRequest(...args);
468
+ }
469
+ if (!isApproval && wasBackgrounded) {
470
+ try {
471
+ await resumeWalletConnectRelayer(provider, { force: true });
472
+ }
473
+ finally {
474
+ provider[NEEDS_RESUME_FLAG] = false;
475
+ }
351
476
  return originalRequest(...args);
352
477
  }
353
- provider[PENDING_FLAG] = (provider[PENDING_FLAG] || 0) + 1;
354
- restoreWalletConnectModal();
355
- syncWalletConnectDappRedirect(provider);
478
+ if (isApproval) {
479
+ provider[PENDING_FLAG] = (provider[PENDING_FLAG] || 0) + 1;
480
+ restoreWalletConnectModal();
481
+ syncWalletConnectDappRedirect(provider);
482
+ }
356
483
  try {
357
- await resumeWalletConnectRelayer(provider);
358
- return await originalRequest(...args);
484
+ // Never force-restart right before sending a new tx — that is what
485
+ // blocked the MetaMask popup on Create Proposal after 3.3.7.
486
+ await Promise.race([
487
+ resumeWalletConnectRelayer(provider, { force: false }),
488
+ new Promise((resolve) => setTimeout(resolve, 1000)),
489
+ ]);
490
+ const resultPromise = originalRequest(...args);
491
+ if (typeof window !== "undefined") {
492
+ window.setTimeout(() => openConnectedWalletApp(provider), 400);
493
+ }
494
+ return await resultPromise;
359
495
  }
360
496
  finally {
361
- provider[PENDING_FLAG] = Math.max(0, (provider[PENDING_FLAG] || 1) - 1);
497
+ if (isApproval) {
498
+ provider[PENDING_FLAG] = Math.max(0, (provider[PENDING_FLAG] || 1) - 1);
499
+ }
362
500
  }
363
501
  };
364
502
  }
@@ -373,15 +511,27 @@ export function subscribeWalletConnectForegroundResume(provider) {
373
511
  const resume = () => {
374
512
  if (timer)
375
513
  clearTimeout(timer);
514
+ const pending = hasPendingWalletConnectApproval(provider);
515
+ const needsResume = pending || !!provider[NEEDS_RESUME_FLAG];
516
+ // Do not restart after connect-only backgrounding — that broke the next
517
+ // session_request so MetaMask never opened for Create Proposal.
518
+ if (!needsResume) {
519
+ clearWalletReturnIfSameDocument();
520
+ return;
521
+ }
376
522
  timer = setTimeout(() => {
377
523
  clearWalletReturnIfSameDocument();
378
- void resumeWalletConnectRelayer(provider);
379
- if (hasPendingWalletConnectApproval(provider)) {
380
- restoreWalletConnectModal();
381
- }
382
- }, 250);
524
+ void resumeWalletConnectRelayer(provider, { force: true });
525
+ restoreWalletConnectModal();
526
+ }, 50);
383
527
  };
384
528
  const onVisibility = () => {
529
+ if (document.visibilityState === "hidden") {
530
+ if (hasPendingWalletConnectApproval(provider)) {
531
+ provider[NEEDS_RESUME_FLAG] = true;
532
+ }
533
+ return;
534
+ }
385
535
  if (document.visibilityState === "visible")
386
536
  resume();
387
537
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@abstraxn/signer-react",
3
- "version": "3.3.6",
3
+ "version": "3.3.8",
4
4
  "description": "React SDK for Abstraxn Wallet - React components, hooks, and providers for seamless Web3 wallet integration",
5
5
  "main": "./dist/src/index.js",
6
6
  "module": "./dist/src/index.js",