@silentswap/react 0.1.52 → 0.1.53

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.
@@ -0,0 +1,50 @@
1
+ import React from 'react';
2
+ import type { Connector } from 'wagmi';
3
+ import type { WalletClient } from 'viem';
4
+ import type { AuthResponse, SilentSwapClient } from '@silentswap/sdk';
5
+ /**
6
+ * Shape of the auth context exposed to consumers. Mirrors the subset of
7
+ * `useAuth` that callers actually need — keeping the surface narrow so
8
+ * the context doesn't have to track internal SDK changes.
9
+ */
10
+ export interface AuthContextValue {
11
+ /** Current auth response (null when not signed in). */
12
+ auth: AuthResponse | null;
13
+ /** True while a sign-in (including auto-auth retries) is in flight. */
14
+ isLoading: boolean;
15
+ /** Last sign-in error. */
16
+ error: Error | null;
17
+ /** True when SIWE auto-authentication exhausted all retries. */
18
+ authExhausted: boolean;
19
+ /** Imperatively start / retry SIWE sign-in. */
20
+ signIn: () => Promise<AuthResponse | null>;
21
+ /** Alias of {@link signIn} kept for callers that prefer the full name. */
22
+ authenticate: () => Promise<AuthResponse | null>;
23
+ /** Clear auth state for the current address. */
24
+ signOut: () => void;
25
+ /** True iff auth is present and not yet expired. */
26
+ isAuthenticated: () => boolean;
27
+ }
28
+ export declare function useAuthContext(): AuthContextValue;
29
+ export interface AuthProviderProps {
30
+ children: React.ReactNode;
31
+ client?: SilentSwapClient;
32
+ address?: `0x${string}`;
33
+ walletClient?: WalletClient;
34
+ connector?: Connector;
35
+ /** Whether to auto-authenticate as soon as the prerequisites are available. */
36
+ autoAuthenticate?: boolean;
37
+ /** Status callback for sign-in progress (e.g. to show toasts). */
38
+ onStatus?: (status: string) => void;
39
+ }
40
+ /**
41
+ * Wraps {@link useAuth} in a React context so multiple components can read
42
+ * the same auth state without each remounting its own hook instance.
43
+ *
44
+ * Two independent `useAuth()` callers race each other on mount — they each
45
+ * try to fire the same signMessage prompt, which is the origin of the
46
+ * "double sign-in dialog" bug on the hidden swap + order recovery pages.
47
+ * Centralising the hook behind this provider guarantees a single in-flight
48
+ * auth attempt per address.
49
+ */
50
+ export declare function AuthProvider({ children, client, address, walletClient, connector, autoAuthenticate, onStatus, }: AuthProviderProps): import("react/jsx-runtime").JSX.Element;
@@ -0,0 +1,49 @@
1
+ 'use client';
2
+ import { jsx as _jsx } from "react/jsx-runtime";
3
+ import { createContext, useContext } from 'react';
4
+ import { useAuth } from '../hooks/silent/useAuth.js';
5
+ const DEFAULT_AUTH_VALUE = {
6
+ auth: null,
7
+ isLoading: false,
8
+ error: null,
9
+ authExhausted: false,
10
+ signIn: async () => null,
11
+ authenticate: async () => null,
12
+ signOut: () => { },
13
+ isAuthenticated: () => false,
14
+ };
15
+ const AuthContext = createContext(DEFAULT_AUTH_VALUE);
16
+ export function useAuthContext() {
17
+ return useContext(AuthContext);
18
+ }
19
+ /**
20
+ * Wraps {@link useAuth} in a React context so multiple components can read
21
+ * the same auth state without each remounting its own hook instance.
22
+ *
23
+ * Two independent `useAuth()` callers race each other on mount — they each
24
+ * try to fire the same signMessage prompt, which is the origin of the
25
+ * "double sign-in dialog" bug on the hidden swap + order recovery pages.
26
+ * Centralising the hook behind this provider guarantees a single in-flight
27
+ * auth attempt per address.
28
+ */
29
+ export function AuthProvider({ children, client, address, walletClient, connector, autoAuthenticate = true, onStatus, }) {
30
+ const { auth, isLoading, error, authExhausted, signIn, authenticate, signOut, isAuthenticated, } = useAuth({
31
+ client,
32
+ address,
33
+ walletClient,
34
+ connector,
35
+ autoAuthenticate,
36
+ onStatus,
37
+ });
38
+ const value = {
39
+ auth,
40
+ isLoading,
41
+ error,
42
+ authExhausted,
43
+ signIn,
44
+ authenticate,
45
+ signOut,
46
+ isAuthenticated,
47
+ };
48
+ return _jsx(AuthContext.Provider, { value: value, children: children });
49
+ }
@@ -0,0 +1,46 @@
1
+ import React from 'react';
2
+ import { type SilentSwapWallet } from '../hooks/silent/useWallet.js';
3
+ import type { Connector } from 'wagmi';
4
+ import type { WalletClient } from 'viem';
5
+ import type { SilentSwapClient } from '@silentswap/sdk';
6
+ /**
7
+ * Shape of the private-wallet context exposed to consumers. Intentionally
8
+ * narrow — anything beyond this (entropy, facilitator groups, cache
9
+ * management) stays an implementation detail of {@link useWallet}.
10
+ */
11
+ export interface PrivateWalletContextValue {
12
+ /** Generated SilentSwap facilitator wallet (null until ready). */
13
+ wallet: SilentSwapWallet | null;
14
+ /** True while entropy generation / signTypedData is in flight. */
15
+ isLoading: boolean;
16
+ /** Last generation error (null when healthy). */
17
+ error: Error | null;
18
+ /** Imperatively (re)generate the private wallet. */
19
+ generateWallet: () => Promise<void>;
20
+ /** User-initiated retry: clears cache + regenerates, bypassing the in-flight guard. */
21
+ retryWallet: () => Promise<void>;
22
+ /** Drop the cached wallet for the current address. */
23
+ clearWallet: () => void;
24
+ }
25
+ export declare function usePrivateWalletContext(): PrivateWalletContextValue;
26
+ export interface PrivateWalletProviderProps {
27
+ children: React.ReactNode;
28
+ client: SilentSwapClient;
29
+ address?: `0x${string}`;
30
+ walletClient?: WalletClient;
31
+ connector?: Connector;
32
+ /** If true, includes all historical deposit nonces when regenerating. */
33
+ allDeposits?: boolean;
34
+ /** If true (default), auto-generates the wallet once auth is available. */
35
+ autoGenerate?: boolean;
36
+ /** Status callback for generation progress (e.g. to show toasts). */
37
+ onStatus?: (status: string) => void;
38
+ }
39
+ /**
40
+ * Wraps {@link useWallet} in a React context and auto-triggers generation
41
+ * once the upstream {@link AuthContext} reports a valid auth. Using a
42
+ * provider here (instead of re-calling the hook in every consumer) keeps
43
+ * exactly one in-flight signTypedData prompt per session — mirrors the
44
+ * rationale behind {@link AuthProvider}.
45
+ */
46
+ export declare function PrivateWalletProvider({ children, client, address, walletClient, connector, allDeposits, autoGenerate, onStatus, }: PrivateWalletProviderProps): import("react/jsx-runtime").JSX.Element;
@@ -0,0 +1,63 @@
1
+ 'use client';
2
+ import { jsx as _jsx } from "react/jsx-runtime";
3
+ import { createContext, useContext, useEffect, useRef } from 'react';
4
+ import { useWallet } from '../hooks/silent/useWallet.js';
5
+ import { useAuthContext } from './AuthContext.js';
6
+ const DEFAULT_WALLET_VALUE = {
7
+ wallet: null,
8
+ isLoading: false,
9
+ error: null,
10
+ generateWallet: async () => { },
11
+ retryWallet: async () => { },
12
+ clearWallet: () => { },
13
+ };
14
+ const PrivateWalletContext = createContext(DEFAULT_WALLET_VALUE);
15
+ export function usePrivateWalletContext() {
16
+ return useContext(PrivateWalletContext);
17
+ }
18
+ /**
19
+ * Wraps {@link useWallet} in a React context and auto-triggers generation
20
+ * once the upstream {@link AuthContext} reports a valid auth. Using a
21
+ * provider here (instead of re-calling the hook in every consumer) keeps
22
+ * exactly one in-flight signTypedData prompt per session — mirrors the
23
+ * rationale behind {@link AuthProvider}.
24
+ */
25
+ export function PrivateWalletProvider({ children, client, address, walletClient, connector, allDeposits = true, autoGenerate = true, onStatus, }) {
26
+ const { auth } = useAuthContext();
27
+ const { wallet, isLoading, error, generateWallet, clearWallet, refreshWallet, } = useWallet({
28
+ client,
29
+ address: address,
30
+ auth: auth || undefined,
31
+ walletClient,
32
+ connector,
33
+ allDeposits,
34
+ onStatus,
35
+ });
36
+ // Keep generateWallet in a ref so the auto-trigger effect below doesn't
37
+ // re-fire when useWallet rebuilds the callback (e.g. after ensureChain
38
+ // swaps the walletClient identity). Re-firing would race the in-flight
39
+ // signTypedData prompt — exactly the bug we fixed in the old inner
40
+ // provider that used to live in SilentSwapContext.
41
+ const generateWalletRef = useRef(generateWallet);
42
+ useEffect(() => {
43
+ generateWalletRef.current = generateWallet;
44
+ }, [generateWallet]);
45
+ useEffect(() => {
46
+ if (!autoGenerate)
47
+ return;
48
+ if (auth && walletClient && connector && !wallet && !isLoading && !error) {
49
+ generateWalletRef.current().catch((err) => {
50
+ console.error('Failed to generate private wallet:', err);
51
+ });
52
+ }
53
+ }, [autoGenerate, auth, walletClient, connector, wallet, isLoading, error]);
54
+ const value = {
55
+ wallet,
56
+ isLoading,
57
+ error,
58
+ generateWallet,
59
+ retryWallet: refreshWallet,
60
+ clearWallet,
61
+ };
62
+ return _jsx(PrivateWalletContext.Provider, { value: value, children: children });
63
+ }
@@ -45,10 +45,16 @@ export interface SilentSwapContextType {
45
45
  config: SilentSwapClientConfig;
46
46
  wallet: SilentSwapWallet | null;
47
47
  walletLoading: boolean;
48
+ /** Last error from private wallet generation (null when healthy) */
49
+ walletError: Error | null;
50
+ /** Manual trigger to retry wallet generation (e.g. after silent failure) */
51
+ retryWallet: () => Promise<void>;
48
52
  auth: AuthResponse | null;
49
53
  authLoading: boolean;
50
54
  /** True when SIWE auto-authentication exhausted all retries (WalletConnect connection lost) */
51
55
  authExhausted: boolean;
56
+ /** Manual trigger to start / retry SIWE sign-in */
57
+ signIn: () => Promise<AuthResponse | null>;
52
58
  executeSwap: (params: ExecuteSwapParams) => Promise<SwapResult | null>;
53
59
  swapLoading: boolean;
54
60
  isSwapping: boolean;
@@ -1,8 +1,8 @@
1
1
  'use client';
2
2
  import { jsx as _jsx } from "react/jsx-runtime";
3
3
  import { createContext, useContext, useEffect, useMemo, useState, useCallback } from 'react';
4
- import { useAuth } from '../hooks/silent/useAuth.js';
5
- import { useWallet } from '../hooks/silent/useWallet.js';
4
+ import { AuthProvider, useAuthContext } from './AuthContext.js';
5
+ import { PrivateWalletProvider, usePrivateWalletContext } from './PrivateWalletContext.js';
6
6
  import { useSilentQuote } from '../hooks/silent/useSilentQuote.js';
7
7
  import { useOrderTracking } from '../hooks/silent/useOrderTracking.js';
8
8
  import { usePrices } from '../hooks/usePrices.js';
@@ -27,9 +27,12 @@ const DEFAULT_CONTEXT = {
27
27
  },
28
28
  wallet: null,
29
29
  walletLoading: false,
30
+ walletError: null,
31
+ retryWallet: async () => { },
30
32
  auth: null,
31
33
  authLoading: false,
32
34
  authExhausted: false,
35
+ signIn: async () => null,
33
36
  executeSwap: async () => null,
34
37
  swapLoading: false,
35
38
  isSwapping: false,
@@ -81,26 +84,14 @@ export function useSilentSwap() {
81
84
  }
82
85
  return context;
83
86
  }
84
- function SilentSwapInnerProvider({ children, client, evmAddress, solAddress, bitcoinAddress, tronAddress, solanaConnector, solanaConnection, bitcoinConnector, bitcoinConnection, tronConnector, tronConnection, environment, config, solanaRpcUrl, connector, isConnected = false, walletClient, proId, requestWalletConnect, forceBridgeProvider, onAuthStatus, onWalletStatus, }) {
85
- // Authentication hook - only for EVM
86
- const { auth, isLoading: authLoading, authenticate, authExhausted } = useAuth({
87
- client,
88
- address: evmAddress,
89
- walletClient: walletClient,
90
- connector: connector,
91
- autoAuthenticate: true,
92
- onStatus: onAuthStatus,
93
- });
94
- // Wallet management hook
95
- const { wallet, generateWallet, refreshWallet, isLoading: walletLoading, error: walletError, } = useWallet({
96
- client,
97
- address: evmAddress,
98
- auth: auth || undefined,
99
- walletClient: walletClient,
100
- connector: connector,
101
- allDeposits: true,
102
- onStatus: onWalletStatus,
103
- });
87
+ function SilentSwapInnerProvider({ children, client, evmAddress, solAddress, bitcoinAddress, tronAddress, solanaConnector, solanaConnection, bitcoinConnector, bitcoinConnection, tronConnector, tronConnection, environment, config, solanaRpcUrl, connector, isConnected = false, walletClient, proId, requestWalletConnect, forceBridgeProvider, }) {
88
+ // Auth + private-wallet state now come from their own dedicated contexts
89
+ // (see AuthProvider / PrivateWalletProvider wrapping this inner provider
90
+ // in SilentSwapProvider below). This keeps exactly one useAuth + one
91
+ // useWallet instance per session, so two consumers on the same page can
92
+ // never race each other into duplicate signMessage / signTypedData prompts.
93
+ const { auth, isLoading: authLoading, authenticate, signIn, authExhausted, } = useAuthContext();
94
+ const { wallet, generateWallet, retryWallet: refreshWallet, isLoading: walletLoading, error: walletError, } = usePrivateWalletContext();
104
95
  const { setFacilitatorGroups } = useOrdersContext();
105
96
  useWalletFacilitatorGroups(wallet, setFacilitatorGroups);
106
97
  const tokenIn = useSwap((state) => state.tokenIn);
@@ -128,14 +119,7 @@ function SilentSwapInnerProvider({ children, client, evmAddress, solAddress, bit
128
119
  overheadUsd,
129
120
  setInputAmount, // Pass setInputAmount for reverse calculation
130
121
  });
131
- useEffect(() => {
132
- console.log('[SilentSwapInnerProvider] useEffect', { auth, walletClient, connector, wallet, walletLoading });
133
- if (auth && walletClient && connector && !wallet && !walletLoading) {
134
- generateWallet().catch((err) => {
135
- console.error('Failed to generate wallet:', err);
136
- });
137
- }
138
- }, [auth, walletClient, connector, wallet, walletLoading, generateWallet]);
122
+ // (Auto-trigger for generateWallet now lives inside PrivateWalletProvider.)
139
123
  const { executeSwap, isLoading: swapLoading, isSwapping, currentStep, error: swapError, quote, orderId, viewingAuth, clearQuote, depositAmountUsdc, loadingAmounts, } = useSilentQuote({
140
124
  client,
141
125
  address: effectiveQuoteAddress,
@@ -222,9 +206,12 @@ function SilentSwapInnerProvider({ children, client, evmAddress, solAddress, bit
222
206
  config,
223
207
  wallet,
224
208
  walletLoading,
209
+ walletError,
210
+ retryWallet: refreshWallet,
225
211
  auth,
226
212
  authLoading,
227
213
  authExhausted,
214
+ signIn,
228
215
  executeSwap,
229
216
  swapLoading,
230
217
  isSwapping,
@@ -278,5 +265,5 @@ export function SilentSwapProvider({ children, client, evmAddress, solAddress, c
278
265
  baseUrl: computedBaseUrl,
279
266
  };
280
267
  }, [environment, baseUrl]);
281
- return (_jsx(AssetsProvider, { children: _jsx(PricesProvider, { children: _jsx(BalancesProvider, { evmAddress: evmAddress, solAddress: solAddress, solanaRpcUrl: solanaRpcUrl, bitcoinAddress: bitcoinAddress, bitcoinRpcUrl: bitcoinRpcUrl, tronAddress: tronAddress, tronRpcUrl: tronRpcUrl, children: _jsx(OrdersProvider, { baseUrl: config.baseUrl, children: _jsx(SilentSwapInnerProvider, { client: client, connector: connector, isConnected: isConnected, evmAddress: evmAddress, solAddress: solAddress, bitcoinAddress: bitcoinAddress, tronAddress: tronAddress, solanaConnector: solanaConnector, solanaConnection: solanaConnection, bitcoinConnector: bitcoinConnector, bitcoinConnection: bitcoinConnection, tronConnector: tronConnector, tronConnection: tronConnection, environment: environment, config: config, solanaRpcUrl: solanaRpcUrl, walletClient: walletClient, proId: proId, requestWalletConnect: requestWalletConnect, forceBridgeProvider: forceBridgeProvider, onAuthStatus: onAuthStatus, onWalletStatus: onWalletStatus, children: children }) }) }) }) }));
268
+ return (_jsx(AssetsProvider, { children: _jsx(PricesProvider, { children: _jsx(BalancesProvider, { evmAddress: evmAddress, solAddress: solAddress, solanaRpcUrl: solanaRpcUrl, bitcoinAddress: bitcoinAddress, bitcoinRpcUrl: bitcoinRpcUrl, tronAddress: tronAddress, tronRpcUrl: tronRpcUrl, children: _jsx(OrdersProvider, { baseUrl: config.baseUrl, children: _jsx(AuthProvider, { client: client, address: evmAddress, walletClient: walletClient, connector: connector, autoAuthenticate: true, onStatus: onAuthStatus, children: _jsx(PrivateWalletProvider, { client: client, address: evmAddress, walletClient: walletClient, connector: connector, allDeposits: true, autoGenerate: true, onStatus: onWalletStatus, children: _jsx(SilentSwapInnerProvider, { client: client, connector: connector, isConnected: isConnected, evmAddress: evmAddress, solAddress: solAddress, bitcoinAddress: bitcoinAddress, tronAddress: tronAddress, solanaConnector: solanaConnector, solanaConnection: solanaConnection, bitcoinConnector: bitcoinConnector, bitcoinConnection: bitcoinConnection, tronConnector: tronConnector, tronConnection: tronConnection, environment: environment, config: config, solanaRpcUrl: solanaRpcUrl, walletClient: walletClient, proId: proId, requestWalletConnect: requestWalletConnect, forceBridgeProvider: forceBridgeProvider, children: children }) }) }) }) }) }) }));
282
269
  }
@@ -68,6 +68,12 @@ export function useAuth({ client, address, walletClient, connector, domain = typ
68
68
  const hasAutoAuthenticatedRef = useRef(false);
69
69
  // Track retry count for auto-authentication (WalletConnect relay can drop requests)
70
70
  const autoAuthRetryCountRef = useRef(0);
71
+ // Track the pending retry timer so we can clear it on unmount / signOut
72
+ const retryTimerRef = useRef(null);
73
+ // Synchronous guard to prevent concurrent authenticate calls.
74
+ // React state (isLoading) is async/batched, so Strict Mode double-fire or
75
+ // rapid effect re-runs can trigger multiple signMessage prompts.
76
+ const inFlightRef = useRef(false);
71
77
  const AUTO_AUTH_MAX_RETRIES = 6;
72
78
  const AUTO_AUTH_RETRY_DELAY_MS = 5000;
73
79
  // Normalize address
@@ -136,6 +142,12 @@ export function useAuth({ client, address, walletClient, connector, domain = typ
136
142
  * Perform SIWE authentication
137
143
  */
138
144
  const authenticate = useCallback(async () => {
145
+ // Synchronous guard — blocks concurrent callers in the same tick.
146
+ // Must be checked and set before any `await` to beat React's state batching.
147
+ if (inFlightRef.current) {
148
+ console.log('[useAuth:authenticate] skipped, already in flight');
149
+ return null;
150
+ }
139
151
  if (!client) {
140
152
  throw new Error('Client required for authentication');
141
153
  }
@@ -145,6 +157,7 @@ export function useAuth({ client, address, walletClient, connector, domain = typ
145
157
  if (!walletClient) {
146
158
  throw new Error('Wallet client required for authentication');
147
159
  }
160
+ inFlightRef.current = true;
148
161
  setIsLoading(true);
149
162
  setError(null);
150
163
  try {
@@ -169,34 +182,51 @@ export function useAuth({ client, address, walletClient, connector, domain = typ
169
182
  // the ping above tests relay connectivity, but a real RPC round-trip catches
170
183
  // cases where the relay is alive but the peer wallet is unresponsive.
171
184
  const LIVENESS_TIMEOUT_MS = 5_000;
185
+ let livenessTimeout;
172
186
  try {
173
187
  await Promise.race([
174
188
  walletClient.request({ method: 'eth_chainId' }),
175
- new Promise((_, reject) => setTimeout(() => reject(new Error('Wallet liveness check timed out')), LIVENESS_TIMEOUT_MS)),
189
+ new Promise((_, reject) => {
190
+ livenessTimeout = setTimeout(() => reject(new Error('Wallet liveness check timed out')), LIVENESS_TIMEOUT_MS);
191
+ }),
176
192
  ]);
177
193
  }
178
194
  catch (livenessErr) {
179
195
  throw new Error(`Wallet not responding to requests: ${livenessErr.message}`);
180
196
  }
197
+ finally {
198
+ if (livenessTimeout)
199
+ clearTimeout(livenessTimeout);
200
+ }
181
201
  // Create SIWE message
182
202
  const siweMessage = createSignInMessageCallback(normalizedAddress, nonceValue, domain);
183
203
  // Notify consumer that a wallet signature is about to be requested
184
204
  onStatus?.('Please check your wallet and approve the sign-in request. This uses to load orders your orders from blockchain.');
185
205
  // Sign the message with timeout — WalletConnect relay can silently drop requests.
186
206
  const SIGN_TIMEOUT_MS = 30_000;
187
- const signature = await Promise.race([
188
- walletClient.signMessage({
189
- account: normalizedAddress,
190
- message: siweMessage.message,
191
- }),
192
- new Promise((_, reject) => setTimeout(() => reject(new Error('Wallet did not respond to sign-in request. The request may not have reached your wallet.')), SIGN_TIMEOUT_MS)),
193
- ]);
207
+ let signTimeout;
208
+ let signature;
209
+ try {
210
+ signature = (await Promise.race([
211
+ walletClient.signMessage({
212
+ account: normalizedAddress,
213
+ message: siweMessage.message,
214
+ }),
215
+ new Promise((_, reject) => {
216
+ signTimeout = setTimeout(() => reject(new Error('Wallet did not respond to sign-in request. The request may not have reached your wallet.')), SIGN_TIMEOUT_MS);
217
+ }),
218
+ ]));
219
+ }
220
+ finally {
221
+ if (signTimeout)
222
+ clearTimeout(signTimeout);
223
+ }
194
224
  onStatus?.('Sign-in approved. Authenticating...');
195
225
  // Authenticate with server
196
226
  const [authError, authResponse] = await client.authenticate({
197
227
  siwe: {
198
228
  message: siweMessage.message,
199
- signature: signature,
229
+ signature,
200
230
  },
201
231
  });
202
232
  if (authError || !authResponse) {
@@ -214,9 +244,10 @@ export function useAuth({ client, address, walletClient, connector, domain = typ
214
244
  return null;
215
245
  }
216
246
  finally {
247
+ inFlightRef.current = false;
217
248
  setIsLoading(false);
218
249
  }
219
- }, [client, normalizedAddress, walletClient, nonce, getNonce, createSignInMessageCallback, domain, saveAuthCallback]);
250
+ }, [client, normalizedAddress, walletClient, connector, nonce, getNonce, createSignInMessageCallback, domain, saveAuthCallback, onStatus]);
220
251
  /**
221
252
  * Sign in (alias for authenticate for convenience)
222
253
  */
@@ -234,8 +265,14 @@ export function useAuth({ client, address, walletClient, connector, domain = typ
234
265
  setNonce(null);
235
266
  setError(null);
236
267
  setIsAutoAuthenticating(false);
237
- // Reset auto-authentication flag
268
+ setAuthExhausted(false);
269
+ // Reset auto-authentication flags + pending retry
238
270
  hasAutoAuthenticatedRef.current = false;
271
+ autoAuthRetryCountRef.current = 0;
272
+ if (retryTimerRef.current) {
273
+ clearTimeout(retryTimerRef.current);
274
+ retryTimerRef.current = null;
275
+ }
239
276
  }, [normalizedAddress]);
240
277
  /**
241
278
  * Check if user is authenticated
@@ -254,6 +291,10 @@ export function useAuth({ client, address, walletClient, connector, domain = typ
254
291
  hasAutoAuthenticatedRef.current = false;
255
292
  autoAuthRetryCountRef.current = 0;
256
293
  setAuthExhausted(false);
294
+ if (retryTimerRef.current) {
295
+ clearTimeout(retryTimerRef.current);
296
+ retryTimerRef.current = null;
297
+ }
257
298
  }
258
299
  prevAddressRef.current = normalizedAddress;
259
300
  }, [normalizedAddress]);
@@ -269,6 +310,10 @@ export function useAuth({ client, address, walletClient, connector, domain = typ
269
310
  hasAutoAuthenticatedRef.current = false;
270
311
  autoAuthRetryCountRef.current = 0;
271
312
  setAuthExhausted(false);
313
+ if (retryTimerRef.current) {
314
+ clearTimeout(retryTimerRef.current);
315
+ retryTimerRef.current = null;
316
+ }
272
317
  }
273
318
  }
274
319
  prevWalletClientRef.current = walletClient;
@@ -317,8 +362,11 @@ export function useAuth({ client, address, walletClient, connector, domain = typ
317
362
  const attemptNumber = autoAuthRetryCountRef.current + 1;
318
363
  console.log(`[useAuth] auto-authenticate attempt ${attemptNumber}/${AUTO_AUTH_MAX_RETRIES}`);
319
364
  setIsAutoAuthenticating(true);
365
+ let cancelled = false;
320
366
  authenticate()
321
367
  .then((result) => {
368
+ if (cancelled)
369
+ return;
322
370
  // authenticate() swallows errors and returns null on failure,
323
371
  // so only mark as completed if it actually succeeded.
324
372
  if (result) {
@@ -330,7 +378,10 @@ export function useAuth({ client, address, walletClient, connector, domain = typ
330
378
  // Retry after a delay by clearing error to re-trigger the effect.
331
379
  autoAuthRetryCountRef.current += 1;
332
380
  console.log(`[useAuth] auto-auth failed, scheduling retry ${autoAuthRetryCountRef.current + 1}/${AUTO_AUTH_MAX_RETRIES} in ${AUTO_AUTH_RETRY_DELAY_MS}ms`);
333
- setTimeout(() => {
381
+ if (retryTimerRef.current)
382
+ clearTimeout(retryTimerRef.current);
383
+ retryTimerRef.current = setTimeout(() => {
384
+ retryTimerRef.current = null;
334
385
  setError(null);
335
386
  }, AUTO_AUTH_RETRY_DELAY_MS);
336
387
  }
@@ -344,9 +395,22 @@ export function useAuth({ client, address, walletClient, connector, domain = typ
344
395
  console.error('Auto-authentication failed:', err);
345
396
  })
346
397
  .finally(() => {
347
- setIsAutoAuthenticating(false);
398
+ if (!cancelled)
399
+ setIsAutoAuthenticating(false);
348
400
  });
401
+ return () => {
402
+ cancelled = true;
403
+ };
349
404
  }, [autoAuthenticate, normalizedAddress, walletClient, auth, isLoading, isAutoAuthenticating, error, authenticate, loadCachedAuthCallback]);
405
+ // Clear pending retry timer on unmount
406
+ useEffect(() => {
407
+ return () => {
408
+ if (retryTimerRef.current) {
409
+ clearTimeout(retryTimerRef.current);
410
+ retryTimerRef.current = null;
411
+ }
412
+ };
413
+ }, []);
350
414
  return {
351
415
  // State
352
416
  auth,
@@ -47,8 +47,10 @@ export function useWallet({ client, address, auth, walletClient, connector, allD
47
47
  const [isLoading, setIsLoading] = useState(false);
48
48
  const [error, setError] = useState(null);
49
49
  // Synchronous guard to prevent concurrent generateWallet calls.
50
- // React state (isLoading) is async/batched, so multiple callers can see
50
+ // React state (isLoading) is async/batched, so multiple callers (Strict Mode
51
+ // double-fire, effect re-runs from callback identity changes) can see
51
52
  // isLoading=false in the same tick and both trigger signTypedData prompts.
53
+ const inFlightRef = useRef(false);
52
54
  // Normalize address
53
55
  const normalizedAddress = address ? getAddress(address) : null;
54
56
  /**
@@ -158,31 +160,41 @@ export function useWallet({ client, address, auth, walletClient, connector, allD
158
160
  // the closure's walletClient which may be scoped to the pre-switch chain.
159
161
  // Race against a timeout: WalletConnect can silently drop signTypedData requests
160
162
  // (e.g. TrustWallet when the chain scope doesn't match), leaving users stuck forever.
161
- console.log('[useWallet:generateEntropy] calling signTypedData ...', {
162
- account: signingClient.account?.address,
163
- domainChainId: String(eip712Doc.domain?.chainId),
164
- });
165
163
  const SIGN_TIMEOUT_MS = 60_000;
166
- const signature = await Promise.race([
167
- signingClient.signTypedData({
168
- ...eip712Doc,
169
- account: signingClient.account,
170
- }),
171
- new Promise((_, reject) => setTimeout(() => reject(new Error('Wallet did not respond to the signature request. '
172
- + 'Please check your wallet app — you may need to manually switch to Ethereum mainnet and retry.')), SIGN_TIMEOUT_MS)),
173
- ]);
174
- console.log('[useWallet:generateEntropy] signTypedData succeeded, signature length:', signature.length);
175
- onStatus?.('Signature approved. Setting up wallet...');
176
- return hexToBytes(signature);
164
+ let timeoutHandle;
165
+ try {
166
+ const signature = await Promise.race([
167
+ signingClient.signTypedData({
168
+ ...eip712Doc,
169
+ account: signingClient.account,
170
+ }),
171
+ new Promise((_, reject) => {
172
+ timeoutHandle = setTimeout(() => reject(new Error('Wallet did not respond to the signature request. '
173
+ + 'Please check your wallet app — you may need to manually switch to Ethereum mainnet and retry.')), SIGN_TIMEOUT_MS);
174
+ }),
175
+ ]);
176
+ onStatus?.('Signature approved. Setting up wallet...');
177
+ return hexToBytes(signature);
178
+ }
179
+ finally {
180
+ if (timeoutHandle)
181
+ clearTimeout(timeoutHandle);
182
+ }
177
183
  }, [auth, walletClient, connector, address, onStatus]);
178
184
  /**
179
185
  * Generate new wallet with facilitator accounts
180
186
  */
181
187
  const generateWallet = useCallback(async () => {
182
- console.log('[useWallet:generateWallet] called', { normalizedAddress, allDeposits });
188
+ // Synchronous guard blocks concurrent callers in the same tick.
189
+ // Must be checked and set before any `await` to beat React's state batching.
190
+ if (inFlightRef.current) {
191
+ console.log('[useWallet:generateWallet] skipped, already in flight');
192
+ return;
193
+ }
183
194
  if (!normalizedAddress) {
184
195
  throw new Error('Address required for wallet generation');
185
196
  }
197
+ inFlightRef.current = true;
186
198
  setIsLoading(true);
187
199
  setError(null);
188
200
  try {
@@ -246,6 +258,7 @@ export function useWallet({ client, address, auth, walletClient, connector, allD
246
258
  throw error;
247
259
  }
248
260
  finally {
261
+ inFlightRef.current = false;
249
262
  setIsLoading(false);
250
263
  }
251
264
  }, [normalizedAddress, getCachedWallet, generateEntropy, allDeposits, client.s0xGatewayAddress]);
@@ -259,18 +272,16 @@ export function useWallet({ client, address, auth, walletClient, connector, allD
259
272
  setWallet(null);
260
273
  }, [normalizedAddress]);
261
274
  /**
262
- * Refresh wallet (regenerate if needed)
275
+ * Refresh wallet (regenerate if needed).
276
+ * User-initiated retry — bypasses the in-flight guard so a stuck
277
+ * signTypedData request (e.g. dropped silently by an in-app browser)
278
+ * doesn't permanently block future attempts.
263
279
  */
264
280
  const refreshWallet = useCallback(async () => {
281
+ inFlightRef.current = false;
265
282
  clearWallet();
266
283
  await generateWallet();
267
284
  }, [clearWallet, generateWallet]);
268
- // When walletClient or connector changes (e.g. WalletConnect chain switch causes
269
- // wagmi to recreate the walletClient), reset the generation guard. Any in-flight
270
- // generation using stale WalletConnect references will hang on signTypedData,
271
- // permanently blocking future calls.
272
- useEffect(() => {
273
- }, [walletClient, connector]);
274
285
  // Clear wallet when address changes (e.g. user switched from MetaMask to Phantom)
275
286
  const prevAddressRef = useRef(normalizedAddress);
276
287
  useEffect(() => {
package/dist/index.d.ts CHANGED
@@ -11,6 +11,10 @@ export { OrdersProvider, useOrdersContext, useWalletFacilitatorGroups } from './
11
11
  export type { OrdersContextOrderMetadata, OrdersContextOrder, OrderDestination, FacilitatorGroup, OrdersContextType } from './contexts/OrdersContext.js';
12
12
  export { SilentSwapProvider, useSilentSwap } from './contexts/SilentSwapContext.js';
13
13
  export type { SilentSwapContextType, SilentSwapStatusData, SilentSwapFees, SilentSwapOrderTracking, SilentSwapEstimates, } from './contexts/SilentSwapContext.js';
14
+ export { AuthProvider, useAuthContext } from './contexts/AuthContext.js';
15
+ export type { AuthContextValue, AuthProviderProps } from './contexts/AuthContext.js';
16
+ export { PrivateWalletProvider, usePrivateWalletContext } from './contexts/PrivateWalletContext.js';
17
+ export type { PrivateWalletContextValue, PrivateWalletProviderProps } from './contexts/PrivateWalletContext.js';
14
18
  export { useSilentOrders } from './hooks/silent/useSilentOrders.js';
15
19
  export { useAuth } from './hooks/silent/useAuth.js';
16
20
  export { useWallet } from './hooks/silent/useWallet.js';
package/dist/index.js CHANGED
@@ -5,6 +5,8 @@ export { AssetsProvider, useAssetsContext } from './contexts/AssetsContext.js';
5
5
  export { BalancesProvider, useBalancesContext } from './contexts/BalancesContext.js';
6
6
  export { OrdersProvider, useOrdersContext, useWalletFacilitatorGroups } from './contexts/OrdersContext.js';
7
7
  export { SilentSwapProvider, useSilentSwap } from './contexts/SilentSwapContext.js';
8
+ export { AuthProvider, useAuthContext } from './contexts/AuthContext.js';
9
+ export { PrivateWalletProvider, usePrivateWalletContext } from './contexts/PrivateWalletContext.js';
8
10
  export { useSilentOrders } from './hooks/silent/useSilentOrders.js';
9
11
  export { useAuth } from './hooks/silent/useAuth.js';
10
12
  export { useWallet } from './hooks/silent/useWallet.js';
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@silentswap/react",
3
3
  "type": "module",
4
- "version": "0.1.52",
4
+ "version": "0.1.53",
5
5
  "license": "MIT",
6
6
  "main": "dist/index.js",
7
7
  "types": "dist/index.d.ts",
@@ -24,8 +24,8 @@
24
24
  "dependencies": {
25
25
  "@bigmi/core": "^0.6.5",
26
26
  "@ensdomains/ensjs": "^4.2.0",
27
- "@silentswap/sdk": "0.1.52",
28
- "@silentswap/ui-kit": "0.1.52",
27
+ "@silentswap/sdk": "0.1.53",
28
+ "@silentswap/ui-kit": "0.1.53",
29
29
  "@solana/codecs-strings": "^5.1.0",
30
30
  "@solana/kit": "^5.1.0",
31
31
  "@solana/rpc": "^5.1.0",