@imtbl/wallet 2.12.7-alpha.8 → 2.12.7

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@imtbl/wallet",
3
- "version": "2.12.7-alpha.8",
3
+ "version": "2.12.7",
4
4
  "description": "Wallet SDK for Immutable",
5
5
  "author": "Immutable",
6
6
  "bugs": "https://github.com/immutable/ts-immutable-sdk/issues",
@@ -25,9 +25,9 @@
25
25
  }
26
26
  },
27
27
  "dependencies": {
28
- "@imtbl/auth": "2.12.7-alpha.8",
29
- "@imtbl/generated-clients": "2.12.7-alpha.8",
30
- "@imtbl/metrics": "2.12.7-alpha.8",
28
+ "@imtbl/auth": "2.12.7",
29
+ "@imtbl/generated-clients": "2.12.7",
30
+ "@imtbl/metrics": "2.12.7",
31
31
  "viem": "~2.18.0"
32
32
  },
33
33
  "devDependencies": {
@@ -1,17 +1,21 @@
1
- jest.mock('@imtbl/auth', () => {
2
- const Auth = jest.fn().mockImplementation(() => ({
3
- getConfig: jest.fn().mockReturnValue({
4
- authenticationDomain: 'https://auth.immutable.com',
5
- passportDomain: 'https://passport.immutable.com',
6
- oidcConfiguration: {
7
- clientId: 'client',
8
- redirectUri: 'https://redirect',
9
- },
10
- }),
11
- getUser: jest.fn().mockResolvedValue({ profile: { sub: 'user' } }),
12
- getUserOrLogin: jest.fn().mockResolvedValue({ profile: { sub: 'user' }, accessToken: 'token' }),
13
- }));
1
+ // Mock Auth with configurable behavior
2
+ const mockAuthInstance = {
3
+ getConfig: jest.fn().mockReturnValue({
4
+ authenticationDomain: 'https://auth.immutable.com',
5
+ passportDomain: 'https://passport.immutable.com',
6
+ oidcConfiguration: {
7
+ clientId: 'client',
8
+ redirectUri: 'https://redirect',
9
+ },
10
+ }),
11
+ getUser: jest.fn().mockResolvedValue({ profile: { sub: 'user' } }),
12
+ getUserOrLogin: jest.fn().mockResolvedValue({ profile: { sub: 'user' }, accessToken: 'token' }),
13
+ loginCallback: jest.fn().mockResolvedValue(undefined),
14
+ };
15
+
16
+ const Auth = jest.fn().mockImplementation(() => mockAuthInstance);
14
17
 
18
+ jest.mock('@imtbl/auth', () => {
15
19
  const TypedEventEmitter = jest.fn().mockImplementation(() => ({
16
20
  emit: jest.fn(),
17
21
  on: jest.fn(),
@@ -70,26 +74,461 @@ const createGetUserMock = () => jest.fn().mockResolvedValue({
70
74
  describe('connectWallet', () => {
71
75
  beforeEach(() => {
72
76
  jest.clearAllMocks();
77
+ Auth.mockClear();
78
+ mockAuthInstance.getUser.mockClear();
79
+ mockAuthInstance.getUserOrLogin.mockClear();
80
+ mockAuthInstance.loginCallback.mockClear();
73
81
  });
74
82
 
75
- it('announces provider by default', async () => {
76
- const getUser = createGetUserMock();
83
+ describe('with external getUser (existing tests)', () => {
84
+ it('announces provider by default', async () => {
85
+ const getUser = createGetUserMock();
86
+
87
+ const provider = await connectWallet({ getUser, chains: [zkEvmChain] });
88
+
89
+ expect(ZkEvmProvider).toHaveBeenCalled();
90
+ expect(announceProvider).toHaveBeenCalledWith({
91
+ info: expect.any(Object),
92
+ provider,
93
+ });
94
+ });
95
+
96
+ it('does not announce provider when disabled', async () => {
97
+ const getUser = createGetUserMock();
98
+
99
+ await connectWallet({ getUser, chains: [zkEvmChain], announceProvider: false });
77
100
 
78
- const provider = await connectWallet({ getUser, chains: [zkEvmChain] });
101
+ expect(announceProvider).not.toHaveBeenCalled();
102
+ });
103
+
104
+ it('uses provided getUser when supplied', async () => {
105
+ const getUser = createGetUserMock();
79
106
 
80
- expect(ZkEvmProvider).toHaveBeenCalled();
81
- expect(announceProvider).toHaveBeenCalledWith({
82
- info: expect.any(Object),
83
- provider,
107
+ await connectWallet({ getUser, chains: [zkEvmChain] });
108
+
109
+ // Should NOT create internal Auth instance
110
+ expect(Auth).not.toHaveBeenCalled();
111
+ // Should use the provided getUser
112
+ expect(getUser).toHaveBeenCalled();
84
113
  });
85
114
  });
86
115
 
87
- it('does not announce provider when disabled', async () => {
88
- const getUser = createGetUserMock();
116
+ describe('default auth (no getUser provided)', () => {
117
+ describe('Auth instance creation', () => {
118
+ it('creates internal Auth instance when getUser is not provided', async () => {
119
+ await connectWallet({ chains: [zkEvmChain] });
120
+
121
+ expect(Auth).toHaveBeenCalledTimes(1);
122
+ expect(Auth).toHaveBeenCalledWith(
123
+ expect.objectContaining({
124
+ scope: 'openid profile email offline_access transact',
125
+ audience: 'platform_api',
126
+ authenticationDomain: 'https://auth.immutable.com',
127
+ }),
128
+ );
129
+ });
130
+
131
+ it('uses getUserOrLogin from internal Auth', async () => {
132
+ await connectWallet({ chains: [zkEvmChain] });
133
+
134
+ // Internal Auth's getUserOrLogin should be called during setup
135
+ expect(mockAuthInstance.getUserOrLogin).toHaveBeenCalled();
136
+ });
137
+
138
+ it('derives passportDomain from chain apiUrl', async () => {
139
+ const customChain = {
140
+ ...zkEvmChain,
141
+ apiUrl: 'https://api.custom.immutable.com',
142
+ };
143
+
144
+ await connectWallet({ chains: [customChain] });
145
+
146
+ expect(Auth).toHaveBeenCalledWith(
147
+ expect.objectContaining({
148
+ passportDomain: 'https://passport.custom.immutable.com',
149
+ }),
150
+ );
151
+ });
152
+
153
+ it('uses provided passportDomain if specified in chain config', async () => {
154
+ const customChain = {
155
+ ...zkEvmChain,
156
+ passportDomain: 'https://custom-passport.immutable.com',
157
+ };
158
+
159
+ await connectWallet({ chains: [customChain] });
89
160
 
90
- await connectWallet({ getUser, chains: [zkEvmChain], announceProvider: false });
161
+ expect(Auth).toHaveBeenCalledWith(
162
+ expect.objectContaining({
163
+ passportDomain: 'https://custom-passport.immutable.com',
164
+ }),
165
+ );
166
+ });
91
167
 
92
- expect(announceProvider).not.toHaveBeenCalled();
168
+ it('uses default redirect URI fallback', async () => {
169
+ await connectWallet({ chains: [zkEvmChain] });
170
+
171
+ expect(Auth).toHaveBeenCalledWith(
172
+ expect.objectContaining({
173
+ redirectUri: 'https://auth.immutable.com/im-logged-in',
174
+ popupRedirectUri: 'https://auth.immutable.com/im-logged-in',
175
+ logoutRedirectUri: 'https://auth.immutable.com/im-logged-in',
176
+ }),
177
+ );
178
+ });
179
+
180
+ it('passes popupOverlayOptions to Auth', async () => {
181
+ const popupOverlayOptions = {
182
+ disableGenericPopupOverlay: true,
183
+ disableBlockedPopupOverlay: false,
184
+ };
185
+
186
+ await connectWallet({ chains: [zkEvmChain], popupOverlayOptions });
187
+
188
+ expect(Auth).toHaveBeenCalledWith(
189
+ expect.objectContaining({
190
+ popupOverlayOptions,
191
+ }),
192
+ );
193
+ });
194
+
195
+ it('passes crossSdkBridgeEnabled to Auth', async () => {
196
+ await connectWallet({ chains: [zkEvmChain], crossSdkBridgeEnabled: true });
197
+
198
+ expect(Auth).toHaveBeenCalledWith(
199
+ expect.objectContaining({
200
+ crossSdkBridgeEnabled: true,
201
+ }),
202
+ );
203
+ });
204
+ });
205
+
206
+ describe('clientId auto-detection', () => {
207
+ it('uses sandbox client ID for testnet chain (chainId 13473)', async () => {
208
+ const testnetChain = {
209
+ ...zkEvmChain,
210
+ chainId: 13473,
211
+ apiUrl: 'https://api.sandbox.immutable.com',
212
+ };
213
+
214
+ await connectWallet({ chains: [testnetChain] });
215
+
216
+ expect(Auth).toHaveBeenCalledWith(
217
+ expect.objectContaining({
218
+ clientId: 'mjtCL8mt06BtbxSkp2vbrYStKWnXVZfo', // Sandbox client ID
219
+ }),
220
+ );
221
+ });
222
+
223
+ it('uses production client ID for mainnet chain (chainId 13371)', async () => {
224
+ const mainnetChain = {
225
+ chainId: 13371,
226
+ rpcUrl: 'https://rpc.immutable.com',
227
+ relayerUrl: 'https://relayer.immutable.com',
228
+ apiUrl: 'https://api.immutable.com',
229
+ name: 'Immutable zkEVM Mainnet',
230
+ };
231
+
232
+ await connectWallet({ chains: [mainnetChain] });
233
+
234
+ expect(Auth).toHaveBeenCalledWith(
235
+ expect.objectContaining({
236
+ clientId: 'PtQRK4iRJ8GkXjiz6xfImMAYhPhW0cYk', // Production client ID
237
+ }),
238
+ );
239
+ });
240
+
241
+ it('detects sandbox from apiUrl containing "sandbox"', async () => {
242
+ const sandboxChain = {
243
+ chainId: 99999, // unknown chainId
244
+ rpcUrl: 'https://rpc.custom.com',
245
+ relayerUrl: 'https://relayer.custom.com',
246
+ apiUrl: 'https://api.sandbox.custom.com', // "sandbox" in URL
247
+ name: 'Custom Sandbox Chain',
248
+ magicPublishableApiKey: 'pk_test_123',
249
+ magicProviderId: 'provider-123',
250
+ };
251
+
252
+ await connectWallet({ chains: [sandboxChain] });
253
+
254
+ expect(Auth).toHaveBeenCalledWith(
255
+ expect.objectContaining({
256
+ clientId: 'mjtCL8mt06BtbxSkp2vbrYStKWnXVZfo', // Sandbox client ID
257
+ }),
258
+ );
259
+ });
260
+
261
+ it('detects sandbox from apiUrl containing "testnet"', async () => {
262
+ const testnetChain = {
263
+ chainId: 99999,
264
+ rpcUrl: 'https://rpc.testnet.custom.com',
265
+ relayerUrl: 'https://relayer.testnet.custom.com',
266
+ apiUrl: 'https://api.testnet.custom.com', // "testnet" in URL
267
+ name: 'Custom Testnet Chain',
268
+ magicPublishableApiKey: 'pk_test_123',
269
+ magicProviderId: 'provider-123',
270
+ };
271
+
272
+ await connectWallet({ chains: [testnetChain] });
273
+
274
+ expect(Auth).toHaveBeenCalledWith(
275
+ expect.objectContaining({
276
+ clientId: 'mjtCL8mt06BtbxSkp2vbrYStKWnXVZfo', // Sandbox client ID
277
+ }),
278
+ );
279
+ });
280
+
281
+ it('uses provided clientId when explicitly set', async () => {
282
+ const customClientId = 'custom-client-id-123';
283
+
284
+ await connectWallet({ chains: [zkEvmChain], clientId: customClientId });
285
+
286
+ expect(Auth).toHaveBeenCalledWith(
287
+ expect.objectContaining({
288
+ clientId: customClientId,
289
+ }),
290
+ );
291
+ });
292
+
293
+ it('prefers provided clientId over auto-detected one', async () => {
294
+ const customClientId = 'custom-client-id-456';
295
+ const mainnetChain = {
296
+ chainId: 13371,
297
+ rpcUrl: 'https://rpc.immutable.com',
298
+ relayerUrl: 'https://relayer.immutable.com',
299
+ apiUrl: 'https://api.immutable.com',
300
+ name: 'Immutable zkEVM Mainnet',
301
+ };
302
+
303
+ await connectWallet({ chains: [mainnetChain], clientId: customClientId });
304
+
305
+ // Should use custom, not production client ID
306
+ expect(Auth).toHaveBeenCalledWith(
307
+ expect.objectContaining({
308
+ clientId: customClientId,
309
+ }),
310
+ );
311
+ });
312
+ });
313
+
314
+ describe('popup callback handling', () => {
315
+ it('sets up message listener for popup callback', async () => {
316
+ const addEventListenerSpy = jest.spyOn(window, 'addEventListener');
317
+
318
+ await connectWallet({ chains: [zkEvmChain] });
319
+
320
+ expect(addEventListenerSpy).toHaveBeenCalledWith('message', expect.any(Function));
321
+
322
+ addEventListenerSpy.mockRestore();
323
+ });
324
+
325
+ it('handles OAuth callback message with code and state', async () => {
326
+ let messageHandler: ((event: MessageEvent) => void) | null = null;
327
+ const addEventListenerSpy = jest.spyOn(window, 'addEventListener').mockImplementation((event, handler) => {
328
+ if (event === 'message') {
329
+ messageHandler = handler as (event: MessageEvent) => void;
330
+ }
331
+ });
332
+
333
+ const replaceStateSpy = jest.spyOn(window.history, 'replaceState').mockImplementation(() => {});
334
+
335
+ await connectWallet({ chains: [zkEvmChain] });
336
+
337
+ expect(messageHandler).not.toBeNull();
338
+
339
+ // Simulate popup callback message
340
+ const callbackMessage = {
341
+ data: {
342
+ code: 'auth-code-123',
343
+ state: 'state-456',
344
+ },
345
+ } as MessageEvent;
346
+
347
+ // Trigger the message event
348
+ const promise = messageHandler!(callbackMessage);
349
+
350
+ // Wait for async operations
351
+ await promise;
352
+ await Promise.resolve();
353
+
354
+ // Should call Auth.loginCallback
355
+ expect(mockAuthInstance.loginCallback).toHaveBeenCalled();
356
+
357
+ // Should update browser history with code/state
358
+ expect(replaceStateSpy).toHaveBeenCalledTimes(2);
359
+
360
+ addEventListenerSpy.mockRestore();
361
+ replaceStateSpy.mockRestore();
362
+ });
363
+
364
+ it('ignores messages without code and state', async () => {
365
+ let messageHandler: ((event: MessageEvent) => void) | null = null;
366
+ const addEventListenerSpy = jest.spyOn(window, 'addEventListener').mockImplementation((event, handler) => {
367
+ if (event === 'message') {
368
+ messageHandler = handler as (event: MessageEvent) => void;
369
+ }
370
+ });
371
+
372
+ await connectWallet({ chains: [zkEvmChain] });
373
+
374
+ // Simulate non-callback message
375
+ const regularMessage = {
376
+ data: {
377
+ someOtherData: 'value',
378
+ },
379
+ } as MessageEvent;
380
+
381
+ messageHandler!(regularMessage);
382
+
383
+ await Promise.resolve();
384
+
385
+ // Should NOT call Auth.loginCallback
386
+ expect(mockAuthInstance.loginCallback).not.toHaveBeenCalled();
387
+
388
+ addEventListenerSpy.mockRestore();
389
+ });
390
+
391
+ it('updates query string with code and state during callback', async () => {
392
+ let messageHandler: ((event: MessageEvent) => void) | null = null;
393
+ const addEventListenerSpy = jest.spyOn(window, 'addEventListener').mockImplementation((event, handler) => {
394
+ if (event === 'message') {
395
+ messageHandler = handler as (event: MessageEvent) => void;
396
+ }
397
+ });
398
+
399
+ const replaceStateSpy = jest.spyOn(window.history, 'replaceState').mockImplementation(() => {});
400
+
401
+ // Mock window.location.search
402
+ Object.defineProperty(window, 'location', {
403
+ value: { search: '?existing=param' },
404
+ writable: true,
405
+ });
406
+
407
+ await connectWallet({ chains: [zkEvmChain] });
408
+
409
+ const callbackMessage = {
410
+ data: {
411
+ code: 'test-code',
412
+ state: 'test-state',
413
+ },
414
+ } as MessageEvent;
415
+
416
+ const promise = messageHandler!(callbackMessage);
417
+ await promise;
418
+ await Promise.resolve();
419
+
420
+ // First call: add code and state
421
+ expect(replaceStateSpy).toHaveBeenNthCalledWith(
422
+ 1,
423
+ null,
424
+ '',
425
+ expect.stringContaining('code=test-code'),
426
+ );
427
+ expect(replaceStateSpy).toHaveBeenNthCalledWith(
428
+ 1,
429
+ null,
430
+ '',
431
+ expect.stringContaining('state=test-state'),
432
+ );
433
+
434
+ // Second call: remove code and state after callback
435
+ expect(replaceStateSpy).toHaveBeenNthCalledWith(
436
+ 2,
437
+ null,
438
+ '',
439
+ expect.not.stringContaining('code='),
440
+ );
441
+
442
+ addEventListenerSpy.mockRestore();
443
+ replaceStateSpy.mockRestore();
444
+ });
445
+ });
446
+
447
+ describe('provider creation with default auth', () => {
448
+ it('creates provider successfully without getUser', async () => {
449
+ const provider = await connectWallet({ chains: [zkEvmChain] });
450
+
451
+ expect(provider).toBeDefined();
452
+ expect(ZkEvmProvider).toHaveBeenCalled();
453
+ });
454
+
455
+ it('passes getUser function to ZkEvmProvider', async () => {
456
+ await connectWallet({ chains: [zkEvmChain] });
457
+
458
+ const zkEvmProviderCall = (ZkEvmProvider as jest.Mock).mock.calls[0][0];
459
+ expect(zkEvmProviderCall.getUser).toEqual(expect.any(Function));
460
+ });
461
+
462
+ it('passes clientId to ZkEvmProvider', async () => {
463
+ await connectWallet({ chains: [zkEvmChain] });
464
+
465
+ const zkEvmProviderCall = (ZkEvmProvider as jest.Mock).mock.calls[0][0];
466
+ expect(zkEvmProviderCall.clientId).toBe('mjtCL8mt06BtbxSkp2vbrYStKWnXVZfo');
467
+ });
468
+
469
+ it('works with custom chain configuration', async () => {
470
+ const customChain = {
471
+ chainId: 99999,
472
+ rpcUrl: 'https://rpc.custom.com',
473
+ relayerUrl: 'https://relayer.custom.com',
474
+ apiUrl: 'https://api.custom.com',
475
+ passportDomain: 'https://passport.custom.com',
476
+ name: 'Custom Chain',
477
+ magicPublishableApiKey: 'pk_test_custom',
478
+ magicProviderId: 'provider-custom',
479
+ };
480
+
481
+ const provider = await connectWallet({ chains: [customChain] });
482
+
483
+ expect(provider).toBeDefined();
484
+ expect(Auth).toHaveBeenCalledWith(
485
+ expect.objectContaining({
486
+ passportDomain: 'https://passport.custom.com',
487
+ }),
488
+ );
489
+ });
490
+ });
491
+
492
+ describe('error handling', () => {
493
+ it('handles auth failure gracefully', async () => {
494
+ mockAuthInstance.getUserOrLogin.mockRejectedValueOnce(new Error('Auth failed'));
495
+
496
+ const provider = await connectWallet({ chains: [zkEvmChain] });
497
+
498
+ // Should still create provider (user will be null)
499
+ expect(provider).toBeDefined();
500
+ expect(ZkEvmProvider).toHaveBeenCalledWith(
501
+ expect.objectContaining({
502
+ user: null,
503
+ }),
504
+ );
505
+ });
506
+
507
+ it('handles loginCallback failure gracefully', async () => {
508
+ let messageHandler: ((event: MessageEvent) => void) | null = null;
509
+ const addEventListenerSpy = jest.spyOn(window, 'addEventListener').mockImplementation((event, handler) => {
510
+ if (event === 'message') {
511
+ messageHandler = handler as (event: MessageEvent) => void;
512
+ }
513
+ });
514
+
515
+ mockAuthInstance.loginCallback.mockRejectedValueOnce(new Error('Callback failed'));
516
+
517
+ await connectWallet({ chains: [zkEvmChain] });
518
+
519
+ const callbackMessage = {
520
+ data: {
521
+ code: 'test-code',
522
+ state: 'test-state',
523
+ },
524
+ } as MessageEvent;
525
+
526
+ // Should not throw (error is handled internally)
527
+ await expect(messageHandler!(callbackMessage)).rejects.toThrow('Callback failed');
528
+
529
+ addEventListenerSpy.mockRestore();
530
+ });
531
+ });
93
532
  });
94
533
 
95
534
  describe('provider selection', () => {