@abstraxn/signer-react 3.3.8 → 3.3.9

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,19 @@ 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.9] - 2026-09-17
9
+
10
+ ### Fixed
11
+ - **Mobile WalletConnect `writeContract` loader stuck after MetaMask confirm** – On iOS/Android the browser often drops the WalletConnect relay while MetaMask is open, so `eth_sendTransaction` never returns even though the tx is on-chain. `useWriteContract` now races the wallet call against an RPC scan for the matching outgoing contract tx (`from` / `to` / function selector) and clears pending WC mobile flags when the hash is recovered that way.
12
+ - **Stacked “Open in MetaMask?” / “All Wallets” after an already-connected session** – Auto-deeplink and WalletConnect modal restore are skipped when a WC session already exists; only the connect flow may schedule a single wallet open. Connected sessions hide the picker instead of reopening it.
13
+
14
+ ### Changed
15
+ - **Chain-poll recovery is gated** – The on-chain hash poll runs only for WalletConnect when the device is mobile or the document is `hidden`. Desktop injected wallets and idle desktop WC keep the plain `writeContract` path (no always-on poll).
16
+ - **`gasLimit` on `useWriteContract`** – Mapped to viem’s `gas` option on the direct `walletClient.writeContract` path so callers that pass `gasLimit` are honored.
17
+
18
+ ### Added
19
+ - Public helpers: `pollForOutgoingTx`, `clearWalletConnectPendingState`, `waitUntilDocumentVisible`, `isWalletConnectConnector`, `isMobileDevice`.
20
+
8
21
  ## [3.3.8] - 2026-09-16
9
22
 
10
23
  ### Fixed
@@ -360,15 +360,15 @@ export declare function usePublicClient(chain: Chain, rpcUrl: string): {
360
360
  transactions: includeTransactions extends true ? ({
361
361
  input: import("viem").Hex;
362
362
  s: import("viem").Hex;
363
- type: "legacy";
363
+ from: Address;
364
+ to: Address | null;
364
365
  value: bigint;
366
+ hash: import("viem").Hash;
367
+ type: "legacy";
365
368
  yParity?: undefined | undefined;
366
- from: Address;
367
369
  gas: bigint;
368
- hash: import("viem").Hash;
369
370
  nonce: number;
370
371
  r: import("viem").Hex;
371
- to: Address | null;
372
372
  typeHex: import("viem").Hex | null;
373
373
  v: bigint;
374
374
  accessList?: undefined | undefined;
@@ -385,15 +385,15 @@ export declare function usePublicClient(chain: Chain, rpcUrl: string): {
385
385
  } | {
386
386
  input: import("viem").Hex;
387
387
  s: import("viem").Hex;
388
- type: "eip2930";
388
+ from: Address;
389
+ to: Address | null;
389
390
  value: bigint;
391
+ hash: import("viem").Hash;
392
+ type: "eip2930";
390
393
  yParity: number;
391
- from: Address;
392
394
  gas: bigint;
393
- hash: import("viem").Hash;
394
395
  nonce: number;
395
396
  r: import("viem").Hex;
396
- to: Address | null;
397
397
  typeHex: import("viem").Hex | null;
398
398
  v: bigint;
399
399
  accessList: import("viem").AccessList;
@@ -410,15 +410,15 @@ export declare function usePublicClient(chain: Chain, rpcUrl: string): {
410
410
  } | {
411
411
  input: import("viem").Hex;
412
412
  s: import("viem").Hex;
413
- type: "eip1559";
413
+ from: Address;
414
+ to: Address | null;
414
415
  value: bigint;
416
+ hash: import("viem").Hash;
417
+ type: "eip1559";
415
418
  yParity: number;
416
- from: Address;
417
419
  gas: bigint;
418
- hash: import("viem").Hash;
419
420
  nonce: number;
420
421
  r: import("viem").Hex;
421
- to: Address | null;
422
422
  typeHex: import("viem").Hex | null;
423
423
  v: bigint;
424
424
  accessList: import("viem").AccessList;
@@ -435,15 +435,15 @@ export declare function usePublicClient(chain: Chain, rpcUrl: string): {
435
435
  } | {
436
436
  input: import("viem").Hex;
437
437
  s: import("viem").Hex;
438
- type: "eip4844";
438
+ from: Address;
439
+ to: Address | null;
439
440
  value: bigint;
441
+ hash: import("viem").Hash;
442
+ type: "eip4844";
440
443
  yParity: number;
441
- from: Address;
442
444
  gas: bigint;
443
- hash: import("viem").Hash;
444
445
  nonce: number;
445
446
  r: import("viem").Hex;
446
- to: Address | null;
447
447
  typeHex: import("viem").Hex | null;
448
448
  v: bigint;
449
449
  accessList: import("viem").AccessList;
@@ -460,15 +460,15 @@ export declare function usePublicClient(chain: Chain, rpcUrl: string): {
460
460
  } | {
461
461
  input: import("viem").Hex;
462
462
  s: import("viem").Hex;
463
- type: "eip7702";
463
+ from: Address;
464
+ to: Address | null;
464
465
  value: bigint;
466
+ hash: import("viem").Hash;
467
+ type: "eip7702";
465
468
  yParity: number;
466
- from: Address;
467
469
  gas: bigint;
468
- hash: import("viem").Hash;
469
470
  nonce: number;
470
471
  r: import("viem").Hex;
471
- to: Address | null;
472
472
  typeHex: import("viem").Hex | null;
473
473
  v: bigint;
474
474
  accessList: import("viem").AccessList;
@@ -510,15 +510,15 @@ export declare function usePublicClient(chain: Chain, rpcUrl: string): {
510
510
  getTransaction: <blockTag extends import("viem").BlockTag = "latest">(args: import("viem").GetTransactionParameters<blockTag>) => Promise<{
511
511
  input: import("viem").Hex;
512
512
  s: import("viem").Hex;
513
- type: "legacy";
513
+ from: Address;
514
+ to: Address | null;
514
515
  value: bigint;
516
+ hash: import("viem").Hash;
517
+ type: "legacy";
515
518
  yParity?: undefined | undefined;
516
- from: Address;
517
519
  gas: bigint;
518
- hash: import("viem").Hash;
519
520
  nonce: number;
520
521
  r: import("viem").Hex;
521
- to: Address | null;
522
522
  typeHex: import("viem").Hex | null;
523
523
  v: bigint;
524
524
  accessList?: undefined | undefined;
@@ -535,15 +535,15 @@ export declare function usePublicClient(chain: Chain, rpcUrl: string): {
535
535
  } | {
536
536
  input: import("viem").Hex;
537
537
  s: import("viem").Hex;
538
- type: "eip2930";
538
+ from: Address;
539
+ to: Address | null;
539
540
  value: bigint;
541
+ hash: import("viem").Hash;
542
+ type: "eip2930";
540
543
  yParity: number;
541
- from: Address;
542
544
  gas: bigint;
543
- hash: import("viem").Hash;
544
545
  nonce: number;
545
546
  r: import("viem").Hex;
546
- to: Address | null;
547
547
  typeHex: import("viem").Hex | null;
548
548
  v: bigint;
549
549
  accessList: import("viem").AccessList;
@@ -560,15 +560,15 @@ export declare function usePublicClient(chain: Chain, rpcUrl: string): {
560
560
  } | {
561
561
  input: import("viem").Hex;
562
562
  s: import("viem").Hex;
563
- type: "eip1559";
563
+ from: Address;
564
+ to: Address | null;
564
565
  value: bigint;
566
+ hash: import("viem").Hash;
567
+ type: "eip1559";
565
568
  yParity: number;
566
- from: Address;
567
569
  gas: bigint;
568
- hash: import("viem").Hash;
569
570
  nonce: number;
570
571
  r: import("viem").Hex;
571
- to: Address | null;
572
572
  typeHex: import("viem").Hex | null;
573
573
  v: bigint;
574
574
  accessList: import("viem").AccessList;
@@ -585,15 +585,15 @@ export declare function usePublicClient(chain: Chain, rpcUrl: string): {
585
585
  } | {
586
586
  input: import("viem").Hex;
587
587
  s: import("viem").Hex;
588
- type: "eip4844";
588
+ from: Address;
589
+ to: Address | null;
589
590
  value: bigint;
591
+ hash: import("viem").Hash;
592
+ type: "eip4844";
590
593
  yParity: number;
591
- from: Address;
592
594
  gas: bigint;
593
- hash: import("viem").Hash;
594
595
  nonce: number;
595
596
  r: import("viem").Hex;
596
- to: Address | null;
597
597
  typeHex: import("viem").Hex | null;
598
598
  v: bigint;
599
599
  accessList: import("viem").AccessList;
@@ -610,15 +610,15 @@ export declare function usePublicClient(chain: Chain, rpcUrl: string): {
610
610
  } | {
611
611
  input: import("viem").Hex;
612
612
  s: import("viem").Hex;
613
- type: "eip7702";
613
+ from: Address;
614
+ to: Address | null;
614
615
  value: bigint;
616
+ hash: import("viem").Hash;
617
+ type: "eip7702";
615
618
  yParity: number;
616
- from: Address;
617
619
  gas: bigint;
618
- hash: import("viem").Hash;
619
620
  nonce: number;
620
621
  r: import("viem").Hex;
621
- to: Address | null;
622
622
  typeHex: import("viem").Hex | null;
623
623
  v: bigint;
624
624
  accessList: import("viem").AccessList;
package/dist/src/hooks.js CHANGED
@@ -9,10 +9,10 @@ import { toAccount } from 'viem/accounts';
9
9
  import { Connection as SolanaConnection, Transaction as SolanaTransaction, SystemProgram, PublicKey, LAMPORTS_PER_SOL, TransactionInstruction, } from '@solana/web3.js';
10
10
  import { SolanaRelayer } from '@abstraxn/solana-relayer';
11
11
  import { useWalletClient as useWagmiWalletClient, useAccount, useConfig, useChainId as useWagmiChainId, useSwitchChain as useWagmiSwitchChain, useSignMessage as useWagmiSignMessage, useReconnect as useWagmiReconnect } from 'wagmi';
12
- import { getWalletClient, switchChain } from '@wagmi/core';
12
+ import { getWalletClient, switchChain, getPublicClient } from '@wagmi/core';
13
13
  import { getConnectorMeta } from './connectors';
14
14
  import { getChainById } from './chains';
15
- import { waitUntilDocumentVisible } from './walletConnectMobile';
15
+ import { waitUntilDocumentVisible, pollForOutgoingTx, clearWalletConnectPendingState, isWalletConnectConnector, isMobileDevice, } from './walletConnectMobile';
16
16
  /**
17
17
  * Hook to check if wallet is connected
18
18
  */
@@ -2430,69 +2430,129 @@ export function useWriteContract() {
2430
2430
  }
2431
2431
  // Cast to any to avoid type issues with different viem versions
2432
2432
  const walletClientAny = activeClient;
2433
- // Try using writeContract method directly if available
2434
- if (walletClientAny && typeof walletClientAny.writeContract === 'function') {
2435
- return walletClientAny.writeContract({
2433
+ const fromAddress = account ||
2434
+ walletClientAny?.account?.address;
2435
+ const resolvedGas = gas !== undefined ? gas : gasLimit !== undefined ? gasLimit : undefined;
2436
+ const sendWrite = async () => {
2437
+ // Try using writeContract method directly if available
2438
+ if (walletClientAny && typeof walletClientAny.writeContract === 'function') {
2439
+ return walletClientAny.writeContract({
2440
+ address,
2441
+ abi: abi,
2442
+ functionName: functionName,
2443
+ args: args,
2444
+ value,
2445
+ account,
2446
+ gas: resolvedGas,
2447
+ gasPrice,
2448
+ maxFeePerGas,
2449
+ maxPriorityFeePerGas,
2450
+ nonce,
2451
+ chain: null, // Let the client determine the chain (or use current)
2452
+ });
2453
+ }
2454
+ // If writeContract is not available, use getContract to create a contract instance
2455
+ // This works with wagmi's connectorClient
2456
+ const contract = getContract({
2436
2457
  address,
2458
+ abi: abi,
2459
+ client: walletClientAny,
2460
+ });
2461
+ // Get the write function from the contract
2462
+ const writeFunction = contract.write?.[functionName];
2463
+ if (!writeFunction || typeof writeFunction !== 'function') {
2464
+ throw new Error(`Function "${functionName}" not found in contract ABI or is not a write function. ` +
2465
+ 'Make sure the function exists in the ABI and is a state-changing function.');
2466
+ }
2467
+ // Prepare the call options (viem contract write methods accept options as the last parameter)
2468
+ const hasOptions = value !== undefined || account !== undefined || resolvedGas !== undefined ||
2469
+ gasPrice !== undefined || maxFeePerGas !== undefined ||
2470
+ maxPriorityFeePerGas !== undefined || nonce !== undefined;
2471
+ // Viem contract write methods expect args as an array, not spread
2472
+ const functionArgs = args || [];
2473
+ if (hasOptions) {
2474
+ const callOptions = {};
2475
+ if (value !== undefined)
2476
+ callOptions.value = value;
2477
+ if (account !== undefined)
2478
+ callOptions.account = account;
2479
+ if (resolvedGas !== undefined)
2480
+ callOptions.gas = resolvedGas;
2481
+ if (gasPrice !== undefined)
2482
+ callOptions.gasPrice = gasPrice;
2483
+ if (maxFeePerGas !== undefined)
2484
+ callOptions.maxFeePerGas = maxFeePerGas;
2485
+ if (maxPriorityFeePerGas !== undefined)
2486
+ callOptions.maxPriorityFeePerGas = maxPriorityFeePerGas;
2487
+ if (nonce !== undefined)
2488
+ callOptions.nonce = nonce;
2489
+ return writeFunction(functionArgs, callOptions);
2490
+ }
2491
+ return writeFunction(functionArgs);
2492
+ };
2493
+ // Mobile WalletConnect: MetaMask confirms on-chain but eth_sendTransaction
2494
+ // never resolves if the relay WS died while the browser was backgrounded.
2495
+ // Only race an RPC scan in that scenario — not for desktop / injected wallets.
2496
+ const publicClient = getPublicClient(config, {
2497
+ chainId: currentChainId,
2498
+ });
2499
+ const documentHidden = typeof document !== "undefined" &&
2500
+ document.visibilityState === "hidden";
2501
+ const shouldPollForWcHang = isWalletConnectConnector(connector) &&
2502
+ (isMobileDevice() || documentHidden);
2503
+ if (!shouldPollForWcHang || !publicClient || !fromAddress) {
2504
+ return sendWrite();
2505
+ }
2506
+ let startBlock = 0n;
2507
+ try {
2508
+ startBlock = await publicClient.getBlockNumber();
2509
+ }
2510
+ catch {
2511
+ // ignore — poll will use a recent window
2512
+ }
2513
+ let dataPrefix;
2514
+ try {
2515
+ const data = encodeFunctionData({
2437
2516
  abi: abi,
2438
2517
  functionName: functionName,
2439
2518
  args: args,
2440
- value,
2441
- account,
2442
- gas,
2443
- gasPrice,
2444
- maxFeePerGas,
2445
- maxPriorityFeePerGas,
2446
- nonce,
2447
- chain: null, // Let the client determine the chain (or use current)
2448
2519
  });
2520
+ dataPrefix = data.slice(0, 10);
2449
2521
  }
2450
- // If writeContract is not available, use getContract to create a contract instance
2451
- // This works with wagmi's connectorClient
2452
- const contract = getContract({
2453
- address,
2454
- abi: abi,
2455
- client: walletClientAny,
2522
+ catch {
2523
+ // Matching without selector is still useful (from + to).
2524
+ }
2525
+ const cancelPoll = { cancelled: false };
2526
+ const sendPromise = sendWrite();
2527
+ const pollPromise = pollForOutgoingTx({
2528
+ publicClient,
2529
+ from: fromAddress,
2530
+ to: address,
2531
+ dataPrefix,
2532
+ value,
2533
+ startBlock,
2534
+ signal: cancelPoll,
2535
+ initialDelayMs: 10_000,
2536
+ timeoutMs: 90_000,
2456
2537
  });
2457
- // Get the write function from the contract
2458
- const writeFunction = contract.write?.[functionName];
2459
- if (!writeFunction || typeof writeFunction !== 'function') {
2460
- throw new Error(`Function "${functionName}" not found in contract ABI or is not a write function. ` +
2461
- 'Make sure the function exists in the ABI and is a state-changing function.');
2538
+ try {
2539
+ const hash = await Promise.race([sendPromise, pollPromise]);
2540
+ // Poll may win while WC is still hanging — ignore a late reject.
2541
+ void sendPromise.then(() => undefined, () => undefined);
2542
+ return hash;
2462
2543
  }
2463
- // Prepare the call options (viem contract write methods accept options as the last parameter)
2464
- const hasOptions = value !== undefined || account !== undefined || gas !== undefined || gasLimit !== undefined ||
2465
- gasPrice !== undefined || maxFeePerGas !== undefined ||
2466
- maxPriorityFeePerGas !== undefined || nonce !== undefined;
2467
- // Viem contract write methods expect args as an array, not spread
2468
- const functionArgs = args || [];
2469
- if (hasOptions) {
2470
- const callOptions = {};
2471
- if (value !== undefined)
2472
- callOptions.value = value;
2473
- if (account !== undefined)
2474
- callOptions.account = account;
2475
- // `gas` in viem is the gas *limit*. Allow either `gas` or `gasLimit` from the hook caller.
2476
- if (gas !== undefined) {
2477
- callOptions.gas = gas;
2544
+ finally {
2545
+ cancelPoll.cancelled = true;
2546
+ try {
2547
+ const getProvider = connector?.getProvider;
2548
+ if (typeof getProvider === 'function') {
2549
+ const provider = await getProvider();
2550
+ clearWalletConnectPendingState(provider);
2551
+ }
2478
2552
  }
2479
- else if (gasLimit !== undefined) {
2480
- callOptions.gas = gasLimit;
2553
+ catch {
2554
+ // ignore
2481
2555
  }
2482
- if (gasPrice !== undefined)
2483
- callOptions.gasPrice = gasPrice;
2484
- if (maxFeePerGas !== undefined)
2485
- callOptions.maxFeePerGas = maxFeePerGas;
2486
- if (maxPriorityFeePerGas !== undefined)
2487
- callOptions.maxPriorityFeePerGas = maxPriorityFeePerGas;
2488
- if (nonce !== undefined)
2489
- callOptions.nonce = nonce;
2490
- // Call the write function with args array and options object
2491
- return writeFunction(functionArgs, callOptions);
2492
- }
2493
- else {
2494
- // Call the write function with args array only
2495
- return writeFunction(functionArgs);
2496
2556
  }
2497
2557
  })();
2498
2558
  }, [hookClient, isPending, isError, error, isConnected, config, currentChainId, reconnect, connector]);
@@ -16,3 +16,4 @@ export type { OnboardingUIComponentProps, OnboardingUIComponentRef } from './Onb
16
16
  export { ALL_CHAINS, EVM_CHAINS, SOLANA_CHAINS, getChainById, getChainsByType, getDefaultChains, toCoreChain, type ChainData, } from './chains';
17
17
  export { CONNECTORS, getConnectorMeta, isOAuthConnector, isWalletConnector, isAuthConnector, type ConnectorType, type ConnectorMeta, } from './connectors';
18
18
  export { createWagmiConfig } from './wagmiConfig';
19
+ export { pollForOutgoingTx, clearWalletConnectPendingState, waitUntilDocumentVisible, isWalletConnectConnector, isMobileDevice, } from './walletConnectMobile';
package/dist/src/index.js CHANGED
@@ -11,4 +11,5 @@ export { OnboardingUIComponent as OnboardingUI } from './OnboardingUI';
11
11
  export { ALL_CHAINS, EVM_CHAINS, SOLANA_CHAINS, getChainById, getChainsByType, getDefaultChains, toCoreChain, } from './chains';
12
12
  export { CONNECTORS, getConnectorMeta, isOAuthConnector, isWalletConnector, isAuthConnector, } from './connectors';
13
13
  export { createWagmiConfig } from './wagmiConfig';
14
+ export { pollForOutgoingTx, clearWalletConnectPendingState, waitUntilDocumentVisible, isWalletConnectConnector, isMobileDevice, } from './walletConnectMobile';
14
15
  //# sourceMappingURL=index.js.map
@@ -34,12 +34,34 @@ 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
+ export declare function isMobileDevice(): boolean;
38
+ /**
39
+ * Undo the inline `display: none !important` we apply after connect so the
40
+ * WalletConnect modal can show "Continue in wallet" and process the response.
41
+ * Prefer hideWalletConnectModal() once a session already exists — restoring
42
+ * during a later tx reopens the "All Wallets" picker on top of MetaMask.
43
+ */
44
+ export declare function restoreWalletConnectModal(): void;
45
+ /** Force-close / hide Reown AppKit + WalletConnect modal DOM after connect or tx. */
46
+ export declare function hideWalletConnectModal(provider?: any): void;
37
47
  /**
38
48
  * Open the already-connected wallet app so the user sees the confirmation UI.
39
49
  * WalletConnect often sends the session_request but does not deeplink on a
40
50
  * later tx if the QR modal is hidden — Create Proposal then sits on a loader.
51
+ *
52
+ * When a session already exists, do NOT reopen the WC "All Wallets" modal —
53
+ * that stacks under the iOS "Open in MetaMask?" prompt and stays after confirm.
54
+ *
55
+ * Returns false if skipped (already backgrounded / already opened once).
56
+ */
57
+ export declare function openConnectedWalletApp(provider: any): boolean;
58
+ /**
59
+ * At most one MetaMask deeplink per approval request.
60
+ * Extra retries queue iOS "Open in MetaMask?" prompts that stay on screen
61
+ * after the tx is already confirmed (JS cannot dismiss that system dialog).
62
+ * Cancel remaining work as soon as the tab is backgrounded (user opened MM).
41
63
  */
42
- export declare function openConnectedWalletApp(provider: any): void;
64
+ export declare function scheduleOpenConnectedWalletApp(provider: any, delaysMs?: number[]): () => void;
43
65
  /**
44
66
  * Lazy metadata so url/redirect are read at connect/request time, not at
45
67
  * provider mount (which is often the homepage in Next.js layouts).
@@ -81,10 +103,36 @@ export declare function resumeWalletConnectRelayer(provider: any, options?: {
81
103
  /** Resolves when the dapp tab is in the foreground again (mobile wallet return). */
82
104
  export declare function waitUntilDocumentVisible(): Promise<void>;
83
105
  /**
84
- * Undo the inline `display: none !important` we apply after connect so the
85
- * WalletConnect modal can show "Continue in wallet" and process the response.
106
+ * Clear mobile WC pending flags after we recover the tx hash from chain
107
+ * (poll won while eth_sendTransaction is still hanging).
86
108
  */
87
- export declare function restoreWalletConnectModal(): void;
109
+ export declare function clearWalletConnectPendingState(provider: any): void;
110
+ /**
111
+ * When WalletConnect never returns eth_sendTransaction after MetaMask approve,
112
+ * scan recent blocks for a matching outgoing tx (from/to + optional calldata prefix).
113
+ */
114
+ export declare function pollForOutgoingTx(params: {
115
+ publicClient: {
116
+ getBlockNumber: () => Promise<bigint>;
117
+ getBlock: (args: {
118
+ blockNumber: bigint;
119
+ includeTransactions: boolean;
120
+ }) => Promise<{
121
+ transactions?: unknown[];
122
+ } | null>;
123
+ };
124
+ from: string;
125
+ to: string;
126
+ /** Match tx.input prefix (e.g. 0x + 4-byte selector). */
127
+ dataPrefix?: string;
128
+ value?: bigint;
129
+ startBlock: bigint;
130
+ signal: {
131
+ cancelled: boolean;
132
+ };
133
+ timeoutMs?: number;
134
+ initialDelayMs?: number;
135
+ }): Promise<`0x${string}`>;
88
136
  /**
89
137
  * Patch the EIP-1193 provider so signing/tx requests resume the relay first.
90
138
  * Covers wagmi hooks, viem walletClient, and direct provider.request calls.
@@ -128,7 +128,7 @@ function isAndroid() {
128
128
  return false;
129
129
  return /Android/i.test(navigator.userAgent || "");
130
130
  }
131
- function isMobileDevice() {
131
+ export function isMobileDevice() {
132
132
  return isIOS() || isAndroid();
133
133
  }
134
134
  function isWalletHttpDeepLink(url) {
@@ -151,50 +151,185 @@ function canOpenAsWalletApp(url) {
151
151
  return isWalletHttpDeepLink(url);
152
152
  return true;
153
153
  }
154
+ function navigateToWalletDeepLink(target) {
155
+ if (!target || typeof window === "undefined")
156
+ return;
157
+ try {
158
+ // Prefer an invisible <a> click — more reliable than location.href behind
159
+ // iOS "open another application?" prompts and after a prior soft navigation.
160
+ const a = document.createElement("a");
161
+ a.href = target;
162
+ a.rel = "noreferrer";
163
+ a.style.display = "none";
164
+ document.body.appendChild(a);
165
+ a.click();
166
+ a.remove();
167
+ }
168
+ catch {
169
+ try {
170
+ window.location.href = target;
171
+ }
172
+ catch {
173
+ // Ignore
174
+ }
175
+ }
176
+ }
177
+ /**
178
+ * Undo the inline `display: none !important` we apply after connect so the
179
+ * WalletConnect modal can show "Continue in wallet" and process the response.
180
+ * Prefer hideWalletConnectModal() once a session already exists — restoring
181
+ * during a later tx reopens the "All Wallets" picker on top of MetaMask.
182
+ */
183
+ export function restoreWalletConnectModal() {
184
+ if (typeof document === "undefined")
185
+ return;
186
+ WALLETCONNECT_MODAL_SELECTORS.forEach((sel) => {
187
+ try {
188
+ document.querySelectorAll(sel).forEach((el) => {
189
+ const htmlEl = el;
190
+ if (htmlEl?.style) {
191
+ htmlEl.style.removeProperty("display");
192
+ }
193
+ });
194
+ }
195
+ catch {
196
+ // Ignore invalid selectors in older browsers.
197
+ }
198
+ });
199
+ }
200
+ /** Force-close / hide Reown AppKit + WalletConnect modal DOM after connect or tx. */
201
+ export function hideWalletConnectModal(provider) {
202
+ try {
203
+ if (typeof provider?.modal?.close === "function") {
204
+ provider.modal.close();
205
+ }
206
+ }
207
+ catch {
208
+ // Ignore
209
+ }
210
+ if (typeof document === "undefined")
211
+ return;
212
+ WALLETCONNECT_MODAL_SELECTORS.forEach((sel) => {
213
+ try {
214
+ document.querySelectorAll(sel).forEach((el) => {
215
+ try {
216
+ if (typeof el.close === "function") {
217
+ el.close();
218
+ }
219
+ }
220
+ catch {
221
+ // Ignore
222
+ }
223
+ const htmlEl = el;
224
+ if (htmlEl?.style) {
225
+ htmlEl.style.setProperty("display", "none", "important");
226
+ }
227
+ });
228
+ }
229
+ catch {
230
+ // Ignore invalid selectors
231
+ }
232
+ });
233
+ }
154
234
  /**
155
235
  * Open the already-connected wallet app so the user sees the confirmation UI.
156
236
  * WalletConnect often sends the session_request but does not deeplink on a
157
237
  * later tx if the QR modal is hidden — Create Proposal then sits on a loader.
238
+ *
239
+ * When a session already exists, do NOT reopen the WC "All Wallets" modal —
240
+ * that stacks under the iOS "Open in MetaMask?" prompt and stays after confirm.
241
+ *
242
+ * Returns false if skipped (already backgrounded / already opened once).
158
243
  */
159
244
  export function openConnectedWalletApp(provider) {
160
245
  if (typeof window === "undefined" || !isMobileDevice())
161
- return;
162
- restoreWalletConnectModal();
163
- try {
164
- if (typeof provider?.modal?.open === "function") {
165
- void provider.modal.open();
246
+ return false;
247
+ // Never queue another metamask:// while the tab is already leaving / hidden —
248
+ // those become stuck "Open in MetaMask?" dialogs after the user returns.
249
+ if (typeof document !== "undefined" && document.visibilityState === "hidden") {
250
+ return false;
251
+ }
252
+ if (provider?.__abstraxnWcDeeplinkOpened) {
253
+ return false;
254
+ }
255
+ const hasSession = hasActiveWalletConnectSession(provider);
256
+ // Only touch the WC modal when there is no session yet (connect flow).
257
+ // For later txs, deeplink straight into the peer wallet app.
258
+ if (!hasSession) {
259
+ restoreWalletConnectModal();
260
+ try {
261
+ if (typeof provider?.modal?.open === "function") {
262
+ void provider.modal.open();
263
+ }
264
+ }
265
+ catch {
266
+ // Modal may not exist after we force-hid it post-connect.
166
267
  }
167
268
  }
168
- catch {
169
- // Modal may not exist after we force-hid it post-connect.
269
+ else {
270
+ // Keep the picker closed while MetaMask opens.
271
+ hideWalletConnectModal(provider);
170
272
  }
171
273
  const peer = provider?.session?.peer?.metadata ??
172
274
  provider?.signer?.session?.peer?.metadata;
173
275
  const native = String(peer?.redirect?.native || "");
174
276
  const universal = String(peer?.redirect?.universal || "");
175
277
  const name = String(peer?.name || "").toLowerCase();
176
- let target = "";
278
+ const candidates = [];
177
279
  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";
280
+ candidates.push(native);
281
+ if (canOpenAsWalletApp(universal))
282
+ candidates.push(universal);
283
+ if (name.includes("metamask") || !name) {
284
+ candidates.push(isIOS() ? "metamask://" : "https://metamask.app.link/wc", "https://metamask.app.link/wc");
183
285
  }
184
286
  else if (name.includes("rainbow")) {
185
- target = "rainbow://";
287
+ candidates.push("rainbow://");
186
288
  }
187
289
  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
290
+ candidates.push("trust://");
197
291
  }
292
+ const unique = [...new Set(candidates.filter(Boolean))];
293
+ if (!unique.length)
294
+ return false;
295
+ provider.__abstraxnWcDeeplinkOpened = true;
296
+ navigateToWalletDeepLink(unique[0]);
297
+ return true;
298
+ }
299
+ /**
300
+ * At most one MetaMask deeplink per approval request.
301
+ * Extra retries queue iOS "Open in MetaMask?" prompts that stay on screen
302
+ * after the tx is already confirmed (JS cannot dismiss that system dialog).
303
+ * Cancel remaining work as soon as the tab is backgrounded (user opened MM).
304
+ */
305
+ export function scheduleOpenConnectedWalletApp(provider, delaysMs = [150]) {
306
+ if (typeof window === "undefined" || !isMobileDevice())
307
+ return () => { };
308
+ provider.__abstraxnWcDeeplinkOpened = false;
309
+ const timers = [];
310
+ let cancelled = false;
311
+ const cancel = () => {
312
+ cancelled = true;
313
+ timers.splice(0).forEach((id) => window.clearTimeout(id));
314
+ if (typeof document !== "undefined") {
315
+ document.removeEventListener("visibilitychange", onHidden);
316
+ }
317
+ };
318
+ const onHidden = () => {
319
+ if (document.visibilityState === "hidden") {
320
+ // User is in (or opening) MetaMask — stop any further deeplinks.
321
+ cancel();
322
+ }
323
+ };
324
+ document.addEventListener("visibilitychange", onHidden);
325
+ delaysMs.forEach((ms) => {
326
+ timers.push(window.setTimeout(() => {
327
+ if (cancelled)
328
+ return;
329
+ openConnectedWalletApp(provider);
330
+ }, ms));
331
+ });
332
+ return cancel;
198
333
  }
199
334
  function getWalletConnectRedirect() {
200
335
  const native = getSourceBrowserNativeRedirect();
@@ -418,25 +553,83 @@ export function waitUntilDocumentVisible() {
418
553
  });
419
554
  }
420
555
  /**
421
- * Undo the inline `display: none !important` we apply after connect so the
422
- * WalletConnect modal can show "Continue in wallet" and process the response.
556
+ * Clear mobile WC pending flags after we recover the tx hash from chain
557
+ * (poll won while eth_sendTransaction is still hanging).
423
558
  */
424
- export function restoreWalletConnectModal() {
425
- if (typeof document === "undefined")
559
+ export function clearWalletConnectPendingState(provider) {
560
+ if (!provider)
426
561
  return;
427
- WALLETCONNECT_MODAL_SELECTORS.forEach((sel) => {
562
+ try {
563
+ provider[PENDING_FLAG] = 0;
564
+ provider[NEEDS_RESUME_FLAG] = false;
565
+ provider.__abstraxnWcDeeplinkOpened = true;
566
+ hideWalletConnectModal(provider);
567
+ }
568
+ catch {
569
+ // ignore
570
+ }
571
+ }
572
+ /**
573
+ * When WalletConnect never returns eth_sendTransaction after MetaMask approve,
574
+ * scan recent blocks for a matching outgoing tx (from/to + optional calldata prefix).
575
+ */
576
+ export async function pollForOutgoingTx(params) {
577
+ const { publicClient, from, to, dataPrefix, value, startBlock, signal, timeoutMs = 90_000, initialDelayMs = 10_000, } = params;
578
+ if (initialDelayMs > 0) {
579
+ await new Promise((r) => setTimeout(r, initialDelayMs));
580
+ }
581
+ if (signal.cancelled) {
582
+ throw new Error("cancelled");
583
+ }
584
+ await waitUntilDocumentVisible();
585
+ const fromLc = from.toLowerCase();
586
+ const toLc = to.toLowerCase();
587
+ const prefixLc = dataPrefix ? dataPrefix.toLowerCase() : undefined;
588
+ const started = Date.now();
589
+ while (!signal.cancelled && Date.now() - started < timeoutMs) {
428
590
  try {
429
- document.querySelectorAll(sel).forEach((el) => {
430
- const htmlEl = el;
431
- if (htmlEl?.style) {
432
- htmlEl.style.removeProperty("display");
591
+ const latest = await publicClient.getBlockNumber();
592
+ const fromBlock = startBlock > 0n
593
+ ? startBlock
594
+ : latest > 12n
595
+ ? latest - 12n
596
+ : 0n;
597
+ for (let b = fromBlock; b <= latest; b++) {
598
+ if (signal.cancelled)
599
+ break;
600
+ const block = await publicClient.getBlock({
601
+ blockNumber: b,
602
+ includeTransactions: true,
603
+ });
604
+ const txs = (block?.transactions || []);
605
+ for (const tx of txs) {
606
+ if (!tx || typeof tx === "string")
607
+ continue;
608
+ if (String(tx.from || "").toLowerCase() !== fromLc)
609
+ continue;
610
+ if (String(tx.to || "").toLowerCase() !== toLc)
611
+ continue;
612
+ if (value !== undefined && BigInt(tx.value ?? 0) !== value) {
613
+ continue;
614
+ }
615
+ if (prefixLc) {
616
+ const input = String(tx.input || tx.data || "").toLowerCase();
617
+ if (!input.startsWith(prefixLc))
618
+ continue;
619
+ }
620
+ const hash = tx.hash;
621
+ if (typeof hash === "string" && hash.startsWith("0x")) {
622
+ return hash;
623
+ }
433
624
  }
434
- });
625
+ }
435
626
  }
436
627
  catch {
437
- // Ignore invalid selectors in older browsers.
628
+ // RPC blip — keep polling
438
629
  }
439
- });
630
+ await new Promise((r) => setTimeout(r, 2_000));
631
+ }
632
+ throw new Error("No WalletConnect response and no matching on-chain transaction found. If MetaMask showed confirmed, refresh and check the explorer.");
440
633
  }
441
634
  function getRequestMethod(args) {
442
635
  const payload = args[0];
@@ -475,10 +668,20 @@ export function patchWalletConnectProviderForMobile(provider) {
475
668
  }
476
669
  return originalRequest(...args);
477
670
  }
671
+ let cancelDeeplinks;
478
672
  if (isApproval) {
479
673
  provider[PENDING_FLAG] = (provider[PENDING_FLAG] || 0) + 1;
480
- restoreWalletConnectModal();
674
+ // Already connected: never re-open "All Wallets". Only restore modal
675
+ // during connect (no session yet).
676
+ if (hasActiveWalletConnectSession(provider)) {
677
+ hideWalletConnectModal(provider);
678
+ }
679
+ else {
680
+ restoreWalletConnectModal();
681
+ }
481
682
  syncWalletConnectDappRedirect(provider);
683
+ // Mark before deeplink so a fast backgrounding still triggers force-resume.
684
+ provider[NEEDS_RESUME_FLAG] = true;
482
685
  }
483
686
  try {
484
687
  // Never force-restart right before sending a new tx — that is what
@@ -488,14 +691,31 @@ export function patchWalletConnectProviderForMobile(provider) {
488
691
  new Promise((resolve) => setTimeout(resolve, 1000)),
489
692
  ]);
490
693
  const resultPromise = originalRequest(...args);
491
- if (typeof window !== "undefined") {
492
- window.setTimeout(() => openConnectedWalletApp(provider), 400);
694
+ if (isApproval) {
695
+ // Do NOT auto-deeplink when a session already exists.
696
+ // WC ethereum-provider already opens the peer wallet; our extra
697
+ // metamask:// calls queue iOS "Open in MetaMask?" dialogs that stay
698
+ // on screen after the tx is confirmed (JS cannot dismiss them).
699
+ // Connect flow (no session) may still use a single scheduled open.
700
+ if (!hasActiveWalletConnectSession(provider)) {
701
+ cancelDeeplinks = scheduleOpenConnectedWalletApp(provider);
702
+ }
703
+ else {
704
+ hideWalletConnectModal(provider);
705
+ }
493
706
  }
494
707
  return await resultPromise;
495
708
  }
496
709
  finally {
710
+ cancelDeeplinks?.();
497
711
  if (isApproval) {
498
712
  provider[PENDING_FLAG] = Math.max(0, (provider[PENDING_FLAG] || 1) - 1);
713
+ provider.__abstraxnWcDeeplinkOpened = false;
714
+ // Close picker that may have been left open after approve/reject.
715
+ hideWalletConnectModal(provider);
716
+ if (!hasPendingWalletConnectApproval(provider)) {
717
+ provider[NEEDS_RESUME_FLAG] = false;
718
+ }
499
719
  }
500
720
  }
501
721
  };
@@ -507,10 +727,9 @@ export function patchWalletConnectProviderForMobile(provider) {
507
727
  export function subscribeWalletConnectForegroundResume(provider) {
508
728
  if (typeof window === "undefined" || !provider)
509
729
  return () => { };
510
- let timer = null;
730
+ const timers = [];
511
731
  const resume = () => {
512
- if (timer)
513
- clearTimeout(timer);
732
+ timers.splice(0).forEach((t) => clearTimeout(t));
514
733
  const pending = hasPendingWalletConnectApproval(provider);
515
734
  const needsResume = pending || !!provider[NEEDS_RESUME_FLAG];
516
735
  // Do not restart after connect-only backgrounding — that broke the next
@@ -519,11 +738,30 @@ export function subscribeWalletConnectForegroundResume(provider) {
519
738
  clearWalletReturnIfSameDocument();
520
739
  return;
521
740
  }
522
- timer = setTimeout(() => {
523
- clearWalletReturnIfSameDocument();
524
- void resumeWalletConnectRelayer(provider, { force: true });
525
- restoreWalletConnectModal();
526
- }, 50);
741
+ // iOS often reports "visible" before networking is ready; retry force-resume.
742
+ const delays = [50, 300, 1000, 2000, 4000];
743
+ delays.forEach((ms) => {
744
+ timers.push(setTimeout(() => {
745
+ if (!hasPendingWalletConnectApproval(provider) &&
746
+ !provider[NEEDS_RESUME_FLAG]) {
747
+ return;
748
+ }
749
+ clearWalletReturnIfSameDocument();
750
+ void resumeWalletConnectRelayer(provider, { force: true }).then(() => {
751
+ if (!hasPendingWalletConnectApproval(provider)) {
752
+ provider[NEEDS_RESUME_FLAG] = false;
753
+ hideWalletConnectModal(provider);
754
+ }
755
+ });
756
+ // Do not restoreWalletConnectModal here — that reopens "All Wallets".
757
+ if (!hasActiveWalletConnectSession(provider)) {
758
+ restoreWalletConnectModal();
759
+ }
760
+ else {
761
+ hideWalletConnectModal(provider);
762
+ }
763
+ }, ms));
764
+ });
527
765
  };
528
766
  const onVisibility = () => {
529
767
  if (document.visibilityState === "hidden") {
@@ -539,8 +777,7 @@ export function subscribeWalletConnectForegroundResume(provider) {
539
777
  window.addEventListener("pageshow", resume);
540
778
  window.addEventListener("focus", resume);
541
779
  return () => {
542
- if (timer)
543
- clearTimeout(timer);
780
+ timers.splice(0).forEach((t) => clearTimeout(t));
544
781
  document.removeEventListener("visibilitychange", onVisibility);
545
782
  window.removeEventListener("pageshow", resume);
546
783
  window.removeEventListener("focus", resume);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@abstraxn/signer-react",
3
- "version": "3.3.8",
3
+ "version": "3.3.9",
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",