@multiplatform.one/web3 7.31.1 → 7.32.0

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.
Files changed (135) hide show
  1. package/README.md +63 -0
  2. package/dist/cjs/clients/evm.cjs +52 -1
  3. package/dist/cjs/clients/evm.native.js +56 -1
  4. package/dist/cjs/clients/evm.native.js.map +1 -1
  5. package/dist/cjs/clients/hooks.cjs +5 -2
  6. package/dist/cjs/clients/hooks.native.js +7 -2
  7. package/dist/cjs/clients/hooks.native.js.map +1 -1
  8. package/dist/cjs/clients/index.cjs +4 -0
  9. package/dist/cjs/clients/index.native.js +4 -0
  10. package/dist/cjs/clients/index.native.js.map +1 -1
  11. package/dist/cjs/clients/preparedSend.cjs +96 -0
  12. package/dist/cjs/clients/preparedSend.native.js +225 -0
  13. package/dist/cjs/clients/preparedSend.native.js.map +1 -0
  14. package/dist/cjs/connectkit/connectors/metaMaskConnectConnector.cjs +108 -27
  15. package/dist/cjs/connectkit/connectors/metaMaskConnectConnector.native.js +105 -28
  16. package/dist/cjs/connectkit/connectors/metaMaskConnectConnector.native.js.map +1 -1
  17. package/dist/cjs/connectkit/index.cjs +2 -0
  18. package/dist/cjs/connectkit/index.native.js +2 -0
  19. package/dist/cjs/connectkit/index.native.js.map +1 -1
  20. package/dist/cjs/connectkit/openConnector/context.cjs +16 -3
  21. package/dist/cjs/connectkit/openConnector/context.native.js +19 -4
  22. package/dist/cjs/connectkit/openConnector/context.native.js.map +1 -1
  23. package/dist/cjs/connectkit/openConnector/launcher.cjs +2 -2
  24. package/dist/cjs/connectkit/openConnector/launcher.native.js +2 -2
  25. package/dist/cjs/connectkit/openConnector/launcher.native.js.map +1 -1
  26. package/dist/cjs/wagmi/driver.cjs +13 -9
  27. package/dist/cjs/wagmi/driver.native.js +17 -9
  28. package/dist/cjs/wagmi/driver.native.js.map +1 -1
  29. package/dist/esm/clients/evm.mjs +53 -2
  30. package/dist/esm/clients/evm.mjs.map +1 -1
  31. package/dist/esm/clients/evm.native.js +57 -2
  32. package/dist/esm/clients/evm.native.js.map +1 -1
  33. package/dist/esm/clients/hooks.mjs +5 -2
  34. package/dist/esm/clients/hooks.mjs.map +1 -1
  35. package/dist/esm/clients/hooks.native.js +7 -2
  36. package/dist/esm/clients/hooks.native.js.map +1 -1
  37. package/dist/esm/clients/index.mjs +2 -1
  38. package/dist/esm/clients/index.mjs.map +1 -1
  39. package/dist/esm/clients/index.native.js +2 -1
  40. package/dist/esm/clients/index.native.js.map +1 -1
  41. package/dist/esm/clients/preparedSend.mjs +69 -0
  42. package/dist/esm/clients/preparedSend.mjs.map +1 -0
  43. package/dist/esm/clients/preparedSend.native.js +195 -0
  44. package/dist/esm/clients/preparedSend.native.js.map +1 -0
  45. package/dist/esm/connectkit/connectors/metaMaskConnectConnector.mjs +107 -28
  46. package/dist/esm/connectkit/connectors/metaMaskConnectConnector.mjs.map +1 -1
  47. package/dist/esm/connectkit/connectors/metaMaskConnectConnector.native.js +104 -29
  48. package/dist/esm/connectkit/connectors/metaMaskConnectConnector.native.js.map +1 -1
  49. package/dist/esm/connectkit/index.mjs +2 -2
  50. package/dist/esm/connectkit/index.mjs.map +1 -1
  51. package/dist/esm/connectkit/index.native.js +2 -2
  52. package/dist/esm/connectkit/index.native.js.map +1 -1
  53. package/dist/esm/connectkit/openConnector/context.mjs +17 -4
  54. package/dist/esm/connectkit/openConnector/context.mjs.map +1 -1
  55. package/dist/esm/connectkit/openConnector/context.native.js +20 -5
  56. package/dist/esm/connectkit/openConnector/context.native.js.map +1 -1
  57. package/dist/esm/connectkit/openConnector/launcher.mjs +2 -2
  58. package/dist/esm/connectkit/openConnector/launcher.mjs.map +1 -1
  59. package/dist/esm/connectkit/openConnector/launcher.native.js +2 -2
  60. package/dist/esm/connectkit/openConnector/launcher.native.js.map +1 -1
  61. package/dist/esm/wagmi/driver.mjs +14 -10
  62. package/dist/esm/wagmi/driver.mjs.map +1 -1
  63. package/dist/esm/wagmi/driver.native.js +18 -10
  64. package/dist/esm/wagmi/driver.native.js.map +1 -1
  65. package/dist/jsx/clients/evm.mjs +53 -2
  66. package/dist/jsx/clients/evm.mjs.map +1 -1
  67. package/dist/jsx/clients/evm.native.js +56 -1
  68. package/dist/jsx/clients/evm.native.js.map +1 -1
  69. package/dist/jsx/clients/hooks.mjs +5 -2
  70. package/dist/jsx/clients/hooks.mjs.map +1 -1
  71. package/dist/jsx/clients/hooks.native.js +7 -2
  72. package/dist/jsx/clients/hooks.native.js.map +1 -1
  73. package/dist/jsx/clients/index.mjs +2 -1
  74. package/dist/jsx/clients/index.mjs.map +1 -1
  75. package/dist/jsx/clients/index.native.js +4 -0
  76. package/dist/jsx/clients/index.native.js.map +1 -1
  77. package/dist/jsx/clients/preparedSend.mjs +69 -0
  78. package/dist/jsx/clients/preparedSend.mjs.map +1 -0
  79. package/dist/jsx/clients/preparedSend.native.js +225 -0
  80. package/dist/jsx/clients/preparedSend.native.js.map +1 -0
  81. package/dist/jsx/connectkit/connectors/metaMaskConnectConnector.mjs +107 -28
  82. package/dist/jsx/connectkit/connectors/metaMaskConnectConnector.mjs.map +1 -1
  83. package/dist/jsx/connectkit/connectors/metaMaskConnectConnector.native.js +105 -28
  84. package/dist/jsx/connectkit/connectors/metaMaskConnectConnector.native.js.map +1 -1
  85. package/dist/jsx/connectkit/index.mjs +2 -2
  86. package/dist/jsx/connectkit/index.mjs.map +1 -1
  87. package/dist/jsx/connectkit/index.native.js +2 -0
  88. package/dist/jsx/connectkit/index.native.js.map +1 -1
  89. package/dist/jsx/connectkit/openConnector/context.mjs +17 -4
  90. package/dist/jsx/connectkit/openConnector/context.mjs.map +1 -1
  91. package/dist/jsx/connectkit/openConnector/context.native.js +19 -4
  92. package/dist/jsx/connectkit/openConnector/context.native.js.map +1 -1
  93. package/dist/jsx/connectkit/openConnector/launcher.mjs +2 -2
  94. package/dist/jsx/connectkit/openConnector/launcher.mjs.map +1 -1
  95. package/dist/jsx/connectkit/openConnector/launcher.native.js +2 -2
  96. package/dist/jsx/connectkit/openConnector/launcher.native.js.map +1 -1
  97. package/dist/jsx/wagmi/driver.mjs +14 -10
  98. package/dist/jsx/wagmi/driver.mjs.map +1 -1
  99. package/dist/jsx/wagmi/driver.native.js +17 -9
  100. package/dist/jsx/wagmi/driver.native.js.map +1 -1
  101. package/dist/keycloak/login.iife.js +382 -382
  102. package/package.json +6 -6
  103. package/src/clients/evm.ts +86 -0
  104. package/src/clients/hooks.spec.tsx +1 -0
  105. package/src/clients/hooks.ts +4 -1
  106. package/src/clients/index.ts +12 -0
  107. package/src/clients/preparedSend.spec.ts +214 -0
  108. package/src/clients/preparedSend.ts +136 -0
  109. package/src/connectkit/connectors/metaMaskConnectConnector.spec.ts +256 -4
  110. package/src/connectkit/connectors/metaMaskConnectConnector.ts +145 -33
  111. package/src/connectkit/index.ts +2 -0
  112. package/src/connectkit/openConnector/context.tsx +10 -5
  113. package/src/connectkit/openConnector/launcher.spec.ts +24 -2
  114. package/src/connectkit/openConnector/launcher.ts +10 -3
  115. package/src/core/types.ts +7 -0
  116. package/src/wagmi/driver.spec.ts +31 -0
  117. package/src/wagmi/driver.ts +11 -4
  118. package/types/clients/evm.d.ts +32 -0
  119. package/types/clients/evm.d.ts.map +1 -1
  120. package/types/clients/hooks.d.ts.map +1 -1
  121. package/types/clients/index.d.ts +2 -1
  122. package/types/clients/index.d.ts.map +1 -1
  123. package/types/clients/preparedSend.d.ts +71 -0
  124. package/types/clients/preparedSend.d.ts.map +1 -0
  125. package/types/connectkit/connectors/metaMaskConnectConnector.d.ts +27 -0
  126. package/types/connectkit/connectors/metaMaskConnectConnector.d.ts.map +1 -1
  127. package/types/connectkit/index.d.ts +1 -1
  128. package/types/connectkit/index.d.ts.map +1 -1
  129. package/types/connectkit/openConnector/context.d.ts.map +1 -1
  130. package/types/connectkit/openConnector/launcher.d.ts +8 -1
  131. package/types/connectkit/openConnector/launcher.d.ts.map +1 -1
  132. package/types/core/types.d.ts +7 -0
  133. package/types/core/types.d.ts.map +1 -1
  134. package/types/wagmi/driver.d.ts +1 -1
  135. package/types/wagmi/driver.d.ts.map +1 -1
@@ -1,7 +1,7 @@
1
1
  import type { EventHandlers } from "@metamask/connect-evm";
2
2
  import type { Chain } from "viem";
3
- import { getAddress, SwitchChainError } from "viem";
4
- import { base, baseSepolia, mainnet, polygon, polygonAmoy } from "viem/chains";
3
+ import { getAddress, SwitchChainError, UserRejectedRequestError } from "viem";
4
+ import { base, baseSepolia, bscTestnet, mainnet, polygon, polygonAmoy, sepolia } from "viem/chains";
5
5
  import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
6
6
  import { createConfig, http } from "wagmi";
7
7
  import { connect, reconnect, sendTransaction, signMessage, signTypedData } from "wagmi/actions";
@@ -9,9 +9,12 @@ import { connect, reconnect, sendTransaction, signMessage, signTypedData } from
9
9
  import {
10
10
  metaMaskConnect,
11
11
  metaMaskConnectChainIds,
12
+ metaMaskMayLackChain,
12
13
  metaMaskNetworks,
14
+ reopenMetaMask,
13
15
  resetMetaMaskConnectClient,
14
16
  } from "./metaMaskConnectConnector";
17
+ import { wagmiDriver } from "../../wagmi/driver";
15
18
  import {
16
19
  BITCOIN_CHAIN_ID,
17
20
  SOLANA_CHAIN_ID,
@@ -39,9 +42,16 @@ vi.mock("@metamask/connect-evm", () => ({ createEVMClient }));
39
42
  const synthetic = [SOLANA_CHAIN_ID, SUI_CHAIN_ID, BITCOIN_CHAIN_ID];
40
43
  const hex = (id: number) => `0x${id.toString(16)}`;
41
44
 
42
- function build(chains: readonly Chain[]) {
45
+ function build(
46
+ chains: readonly Chain[],
47
+ options: {
48
+ mobile?: boolean;
49
+ addChainFirst?: (chain: Chain) => boolean;
50
+ useDeeplink?: boolean;
51
+ } = {},
52
+ ) {
43
53
  const transports = Object.fromEntries(chains.map((chain) => [chain.id, http()]));
44
- return metaMaskConnect({ dapp: { name: "test", url: "https://example.test" } })({
54
+ return metaMaskConnect({ dapp: { name: "test", url: "https://example.test" }, ...options })({
45
55
  chains: chains as [Chain, ...Chain[]],
46
56
  emitter: { uid: "metaMaskSDK", emit: vi.fn(), on: vi.fn(), once: vi.fn(), off: vi.fn() },
47
57
  transports,
@@ -397,6 +407,22 @@ describe("metaMaskConnect under wagmi", () => {
397
407
  }));
398
408
  });
399
409
 
410
+ it("connects through the connection driver on the chain the page names, asking MetaMask for it first", async () => {
411
+ const config = createConfig({
412
+ chains: [mainnet, base, baseSepolia],
413
+ connectors: [metaMaskConnect({ dapp: { name: "test", url: "https://example.test" } })],
414
+ transports: { [mainnet.id]: http(), [base.id]: http(), [baseSepolia.id]: http() },
415
+ multiInjectedProviderDiscovery: false,
416
+ storage: null,
417
+ });
418
+ client.getChainId.mockReturnValue(hex(baseSepolia.id));
419
+
420
+ const account = await wagmiDriver(config).connect("metaMaskSDK", { chainId: baseSepolia.id });
421
+
422
+ expect(client.connect.mock.calls[0]![0].chainIds[0]).toBe(hex(baseSepolia.id));
423
+ expect(account.chainId).toBe(baseSepolia.id);
424
+ });
425
+
400
426
  it("ignores a connect that carries no account", async () => {
401
427
  const config = wagmiConfig();
402
428
  const events = await clientEvents();
@@ -404,3 +430,229 @@ describe("metaMaskConnect under wagmi", () => {
404
430
  expect(config.state.status).toBe("disconnected");
405
431
  });
406
432
  });
433
+
434
+ // What MetaMask Connect does on a phone, read from @metamask/connect-evm 2.1.1 and
435
+ // @metamask/connect-multichain 1.2.0: a request the wallet answers goes over the
436
+ // relay, and 10 ms later MetaMask Connect opens metamask://connect/mwp?id=<session>.
437
+ // switchChain does that for wallet_switchEthereumChain, but for a chain MetaMask
438
+ // lacks the wallet only answers "Unrecognized chain ID", and MetaMask Connect then
439
+ // sends wallet_addEthereumChain with a second deeplink that no tap started.
440
+ // Chrome on Android drops it, so the add waits in the relay and the switch never
441
+ // settles (PROMA-121, reproduced on an Android 16 emulator with MetaMask 8.1.0).
442
+ describe("metaMaskConnect switchChain on a phone", () => {
443
+ let request: ReturnType<typeof vi.fn>;
444
+
445
+ beforeEach(() => {
446
+ resetMetaMaskConnectClient();
447
+ vi.clearAllMocks();
448
+ client.accounts = [];
449
+ request = vi.fn(async () => null);
450
+ client.getProvider.mockReturnValue({ request } as never);
451
+ client.getChainId.mockReturnValue("0x1");
452
+ client.connect.mockImplementation(async () => ({
453
+ accounts: ["0x0000000000000000000000000000000000000001"],
454
+ chainId: "0x1",
455
+ }));
456
+ });
457
+
458
+ afterEach(() => {
459
+ resetMetaMaskConnectClient();
460
+ client.getChainId.mockImplementation(() => "0x2105");
461
+ client.getProvider.mockImplementation(() => ({ request: vi.fn() }));
462
+ client.switchChain.mockImplementation(async () => undefined);
463
+ client.connect.mockImplementation(async () => ({
464
+ accounts: ["0x0000000000000000000000000000000000000001"],
465
+ chainId: "0x2105",
466
+ }));
467
+ });
468
+
469
+ const addCalls = () =>
470
+ request.mock.calls.filter(
471
+ ([call]) => (call as { method: string }).method === "wallet_addEthereumChain",
472
+ );
473
+
474
+ it("adds a testnet MetaMask does not ship in one wallet request, not a switch then an add", async () => {
475
+ const connector = build([baseSepolia, bscTestnet], { mobile: true });
476
+ await connector.connect();
477
+
478
+ await expect(connector.switchChain!({ chainId: baseSepolia.id })).resolves.toBe(baseSepolia);
479
+
480
+ expect(client.switchChain).not.toHaveBeenCalled();
481
+ expect(addCalls()).toHaveLength(1);
482
+ expect(addCalls()[0]![0]).toMatchObject({
483
+ params: [
484
+ {
485
+ chainId: hex(baseSepolia.id),
486
+ chainName: baseSepolia.name,
487
+ rpcUrls: [baseSepolia.rpcUrls.default.http[0]],
488
+ },
489
+ ],
490
+ });
491
+ });
492
+
493
+ it("switches to a chain MetaMask ships without a chain configuration, so no second request can follow", async () => {
494
+ const connector = build([base, sepolia], { mobile: true });
495
+ await connector.connect();
496
+
497
+ await connector.switchChain!({ chainId: base.id });
498
+ await connector.switchChain!({ chainId: sepolia.id });
499
+
500
+ expect(client.switchChain.mock.calls).toEqual([
501
+ [{ chainId: hex(base.id) }],
502
+ [{ chainId: hex(sepolia.id) }],
503
+ ]);
504
+ expect(addCalls()).toHaveLength(0);
505
+ });
506
+
507
+ it("fails a switch MetaMask cannot place with 4902, then adds the chain on the next switch", async () => {
508
+ const connector = build([base, polygonAmoy], { mobile: true, addChainFirst: () => false });
509
+ await connector.connect();
510
+ client.switchChain.mockRejectedValueOnce(
511
+ Object.assign(new Error('Unrecognized chain ID "0x13882".'), { code: 4902 }),
512
+ );
513
+
514
+ const failed = await connector.switchChain!({ chainId: polygonAmoy.id }).catch((e) => e);
515
+ expect(failed).toBeInstanceOf(SwitchChainError);
516
+ expect(failed.code).toBe(4902);
517
+ expect(String(failed.details)).toMatch(/Switch again from a tap/);
518
+ expect(client.switchChain).toHaveBeenCalledTimes(1);
519
+ expect((client.switchChain.mock.calls[0] as unknown[])[0]).not.toHaveProperty(
520
+ "chainConfiguration",
521
+ );
522
+
523
+ await connector.switchChain!({ chainId: polygonAmoy.id });
524
+ expect(client.switchChain).toHaveBeenCalledTimes(1);
525
+ expect(addCalls()).toHaveLength(1);
526
+ });
527
+
528
+ it("keeps a switch to a chain the session already permits in the page", async () => {
529
+ const connector = build([baseSepolia, bscTestnet], { mobile: true });
530
+ await connector.connect();
531
+ await connector.switchChain!({ chainId: baseSepolia.id });
532
+ await connector.switchChain!({ chainId: bscTestnet.id });
533
+ request.mockClear();
534
+
535
+ await connector.switchChain!({ chainId: baseSepolia.id });
536
+
537
+ expect(addCalls()).toHaveLength(0);
538
+ expect(client.switchChain).toHaveBeenCalledWith({ chainId: hex(baseSepolia.id) });
539
+ });
540
+
541
+ it("sends nothing when MetaMask is already on the chain", async () => {
542
+ client.getChainId.mockReturnValue(hex(baseSepolia.id));
543
+ const connector = build([baseSepolia], { mobile: true });
544
+
545
+ await expect(connector.switchChain!({ chainId: baseSepolia.id })).resolves.toBe(baseSepolia);
546
+
547
+ expect(client.switchChain).not.toHaveBeenCalled();
548
+ expect(request).not.toHaveBeenCalled();
549
+ });
550
+
551
+ it("turns a rejection in MetaMask into UserRejectedRequestError, on either path", async () => {
552
+ const rejected = Object.assign(new Error("User rejected the request."), { code: 4001 });
553
+ const connector = build([base, baseSepolia], { mobile: true });
554
+ await connector.connect();
555
+
556
+ request.mockRejectedValueOnce(rejected);
557
+ await expect(connector.switchChain!({ chainId: baseSepolia.id })).rejects.toBeInstanceOf(
558
+ UserRejectedRequestError,
559
+ );
560
+ client.switchChain.mockRejectedValueOnce(rejected);
561
+ await expect(connector.switchChain!({ chainId: base.id })).rejects.toBeInstanceOf(
562
+ UserRejectedRequestError,
563
+ );
564
+ });
565
+
566
+ it("forgets what the session permitted when it disconnects", async () => {
567
+ const connector = build([baseSepolia], { mobile: true });
568
+ await connector.connect();
569
+ await connector.switchChain!({ chainId: baseSepolia.id });
570
+ await connector.disconnect();
571
+ await connector.connect();
572
+ request.mockClear();
573
+
574
+ await connector.switchChain!({ chainId: baseSepolia.id });
575
+
576
+ expect(addCalls()).toHaveLength(1);
577
+ });
578
+
579
+ it("keeps the desktop switch as MetaMask Connect does it, with the chain configuration", async () => {
580
+ const connector = build([baseSepolia], { mobile: false });
581
+ await connector.connect();
582
+
583
+ await connector.switchChain!({ chainId: baseSepolia.id });
584
+
585
+ expect(addCalls()).toHaveLength(0);
586
+ expect(client.switchChain).toHaveBeenCalledWith({
587
+ chainId: hex(baseSepolia.id),
588
+ chainConfiguration: expect.objectContaining({ chainId: hex(baseSepolia.id) }),
589
+ });
590
+ });
591
+ });
592
+
593
+ describe("metaMaskMayLackChain", () => {
594
+ it("is true for a testnet MetaMask does not ship and false for mainnets and its own testnets", () => {
595
+ expect(metaMaskMayLackChain(baseSepolia)).toBe(true);
596
+ expect(metaMaskMayLackChain(bscTestnet)).toBe(true);
597
+ expect(metaMaskMayLackChain(sepolia)).toBe(false);
598
+ expect(metaMaskMayLackChain(base)).toBe(false);
599
+ expect(metaMaskMayLackChain(mainnet)).toBe(false);
600
+ });
601
+ });
602
+
603
+ describe("reopenMetaMask", () => {
604
+ beforeEach(() => {
605
+ resetMetaMaskConnectClient();
606
+ vi.clearAllMocks();
607
+ });
608
+ afterEach(() => resetMetaMaskConnectClient());
609
+
610
+ const mobileOptions = () =>
611
+ (
612
+ createEVMClient.mock.calls[0]![0] as {
613
+ mobile: { preferredOpenLink?: (url: string, target?: string) => void };
614
+ }
615
+ ).mobile;
616
+
617
+ it("opens the last MetaMask link again, through the app's own opener", async () => {
618
+ const open = vi.fn();
619
+ const connector = metaMaskConnect({
620
+ dapp: { name: "test", url: "https://example.test" },
621
+ preferredOpenLink: open,
622
+ })({
623
+ chains: [baseSepolia],
624
+ emitter: { uid: "metaMaskSDK", emit: vi.fn(), on: vi.fn(), once: vi.fn(), off: vi.fn() },
625
+ transports: { [baseSepolia.id]: http() },
626
+ } as never);
627
+ await connector.setup?.();
628
+ expect(reopenMetaMask()).toBe(false);
629
+
630
+ mobileOptions().preferredOpenLink!("metamask://connect/mwp?id=abc", "_self");
631
+ open.mockClear();
632
+
633
+ expect(reopenMetaMask()).toBe(true);
634
+ expect(open).toHaveBeenCalledWith("metamask://connect/mwp?id=abc", "_self");
635
+ });
636
+
637
+ it("forgets the link when the session ends", async () => {
638
+ const connector = metaMaskConnect({
639
+ dapp: { name: "test", url: "https://example.test" },
640
+ preferredOpenLink: vi.fn(),
641
+ })({
642
+ chains: [baseSepolia],
643
+ emitter: { uid: "metaMaskSDK", emit: vi.fn(), on: vi.fn(), once: vi.fn(), off: vi.fn() },
644
+ transports: { [baseSepolia.id]: http() },
645
+ } as never);
646
+ await connector.setup?.();
647
+ mobileOptions().preferredOpenLink!("metamask://connect/mwp?id=abc");
648
+
649
+ await connector.disconnect();
650
+
651
+ expect(reopenMetaMask()).toBe(false);
652
+ });
653
+
654
+ it("leaves MetaMask Connect's universal-link opener alone when deeplinks are off", async () => {
655
+ await build([baseSepolia], { useDeeplink: false }).setup?.();
656
+ expect(mobileOptions()).toEqual({ useDeeplink: false });
657
+ });
658
+ });
@@ -12,6 +12,7 @@
12
12
  // and ConnectWithInjector's display_uri listener all key off it, and so does
13
13
  // every consumer's persisted "recent connector".
14
14
  import type { EIP1193Provider, MetamaskConnectEVM } from "@metamask/connect-evm";
15
+ import { isMobile } from "@multiplatform.one/connectkit";
15
16
  import { ChainNotConfiguredError, type CreateConnectorFn, createConnector } from "wagmi";
16
17
  import {
17
18
  type Address,
@@ -36,6 +37,18 @@ export type MetaMaskConnectParameters = {
36
37
  /** `metamask://` instead of the https://metamask.app.link universal link. */
37
38
  useDeeplink?: boolean;
38
39
  debug?: boolean;
40
+ /**
41
+ * MetaMask is the phone app, reached over the relay: every request it has to
42
+ * approve is a trip to the app that only a tap can start. Defaults to
43
+ * connectkit's `isMobile()`.
44
+ */
45
+ mobile?: boolean;
46
+ /**
47
+ * On a phone, whether a switch to `chain` asks MetaMask to add it
48
+ * (`wallet_addEthereumChain`, which adds the network and switches in one trip)
49
+ * rather than to switch to it. Defaults to `metaMaskMayLackChain`.
50
+ */
51
+ addChainFirst?: (chain: Chain) => boolean;
39
52
  };
40
53
 
41
54
  type ConnectorConfig = Parameters<CreateConnectorFn>[0];
@@ -149,6 +162,26 @@ function isUrl(value: string): boolean {
149
162
  }
150
163
  }
151
164
 
165
+ // The testnets MetaMask 8 lists in its own network picker. An add request for
166
+ // one of these shows "Update <network>" with a warning that the RPC URL is not a
167
+ // known provider, so a switch asks for them instead.
168
+ const metaMaskBuiltInTestnets = new Set([11155111, 59141, 10143, 6342, 6343]);
169
+
170
+ /**
171
+ * Whether a phone's MetaMask may not have `chain` yet: a testnet it does not
172
+ * ship. A switch to a chain MetaMask lacks only answers "Unrecognized chain ID",
173
+ * and the add request that has to follow is a second trip to the app, which
174
+ * Chrome on Android drops when no tap started it.
175
+ */
176
+ export function metaMaskMayLackChain(chain: Chain): boolean {
177
+ return chain.testnet === true && !metaMaskBuiltInTestnets.has(chain.id);
178
+ }
179
+
180
+ function isUnrecognizedChain(err: unknown): boolean {
181
+ const { code, message } = (err ?? {}) as { code?: number; message?: unknown };
182
+ return code === 4902 || (typeof message === "string" && message.includes("Unrecognized chain"));
183
+ }
184
+
152
185
  /** The chain ids a connect asks MetaMask for, the requested one first (it becomes the active chain). */
153
186
  export function metaMaskConnectChainIds(networks: Record<Hex, string>, chainId?: number): Hex[] {
154
187
  const ids = Object.keys(networks) as Hex[];
@@ -164,6 +197,43 @@ export function metaMaskConnectChainIds(networks: Record<Hex, string>, chainId?:
164
197
  // build connectors that wagmi never uses, and one of those may be the newest.
165
198
  let client: Promise<MetamaskConnectEVM> | undefined;
166
199
  let owner: Emitter | undefined;
200
+ // Chains the session is known to permit, where a switch stays in the page, and
201
+ // chains MetaMask answered "Unrecognized chain ID" for, which a switch adds.
202
+ const permittedChains = new Set<number>();
203
+ const unrecognizedChains = new Set<number>();
204
+ let lastDeeplink: { url: string; open: (url: string, target?: string) => void } | undefined;
205
+
206
+ function forgetChains(): void {
207
+ permittedChains.clear();
208
+ unrecognizedChains.clear();
209
+ lastDeeplink = undefined;
210
+ }
211
+
212
+ /**
213
+ * Opens the MetaMask app again on the request it was last sent to. Call it
214
+ * synchronously from a tap, for an "Open MetaMask" button the page shows while
215
+ * a request waits: MetaMask can open before the request reaches it and show
216
+ * nothing, and a request sent after an await or a trip back opens nothing on
217
+ * Chrome for Android. Returns false when there is nothing to reopen.
218
+ */
219
+ export function reopenMetaMask(): boolean {
220
+ if (!lastDeeplink) return false;
221
+ lastDeeplink.open(lastDeeplink.url, "_self");
222
+ return true;
223
+ }
224
+
225
+ function recordingOpenLink(
226
+ open: (url: string, target?: string) => void,
227
+ ): (url: string, target?: string) => void {
228
+ return (url, target) => {
229
+ lastDeeplink = { url, open };
230
+ open(url, target);
231
+ };
232
+ }
233
+
234
+ function navigate(url: string): void {
235
+ if (typeof window !== "undefined") window.location.href = url;
236
+ }
167
237
 
168
238
  function getClient(
169
239
  parameters: MetaMaskConnectParameters,
@@ -184,18 +254,17 @@ function getClient(
184
254
  skipAutoAnnounce: true,
185
255
  debug: parameters.debug,
186
256
  ...(parameters.headless ? { ui: { headless: true } } : {}),
187
- ...(parameters.preferredOpenLink || parameters.useDeeplink !== undefined
188
- ? {
189
- mobile: {
190
- ...(parameters.preferredOpenLink
191
- ? { preferredOpenLink: parameters.preferredOpenLink }
192
- : {}),
193
- ...(parameters.useDeeplink !== undefined
194
- ? { useDeeplink: parameters.useDeeplink }
195
- : {}),
196
- },
197
- }
198
- : {}),
257
+ mobile: {
258
+ // MetaMask Connect's own opener does the same location change for a
259
+ // metamask:// link; going through one here keeps the link to reopen.
260
+ // With useDeeplink false it clicks a universal link instead, left as is.
261
+ ...(parameters.preferredOpenLink
262
+ ? { preferredOpenLink: recordingOpenLink(parameters.preferredOpenLink) }
263
+ : parameters.useDeeplink === false
264
+ ? {}
265
+ : { preferredOpenLink: recordingOpenLink(navigate) }),
266
+ ...(parameters.useDeeplink !== undefined ? { useDeeplink: parameters.useDeeplink } : {}),
267
+ },
199
268
  eventHandlers: {
200
269
  // A session approved outside connect(), in another tab say. wagmi drops
201
270
  // `change` for a connector it has no connection for, so only `connect`
@@ -208,11 +277,19 @@ function getClient(
208
277
  });
209
278
  },
210
279
  accountsChanged: (accounts) => {
211
- if (accounts.length === 0) owner?.emit("disconnect");
212
- else owner?.emit("change", { accounts: accounts.map((a) => getAddress(a)) });
280
+ if (accounts.length === 0) {
281
+ forgetChains();
282
+ owner?.emit("disconnect");
283
+ } else owner?.emit("change", { accounts: accounts.map((a) => getAddress(a)) });
284
+ },
285
+ chainChanged: (chainId) => {
286
+ permittedChains.add(Number(chainId));
287
+ owner?.emit("change", { chainId: Number(chainId) });
288
+ },
289
+ disconnect: () => {
290
+ forgetChains();
291
+ owner?.emit("disconnect");
213
292
  },
214
- chainChanged: (chainId) => owner?.emit("change", { chainId: Number(chainId) }),
215
- disconnect: () => owner?.emit("disconnect"),
216
293
  displayUri: (uri) => owner?.emit("message", { type: "display_uri", data: uri }),
217
294
  },
218
295
  }),
@@ -227,6 +304,7 @@ function getClient(
227
304
  export function resetMetaMaskConnectClient(): void {
228
305
  client = undefined;
229
306
  owner = undefined;
307
+ forgetChains();
230
308
  }
231
309
 
232
310
  function rethrow(err: unknown): never {
@@ -244,6 +322,8 @@ export function metaMaskConnect(parameters: MetaMaskConnectParameters) {
244
322
  const networks = metaMaskNetworks(config.chains, config.transports);
245
323
  const supportedNetworks = metaMaskSupportedNetworks(networks, config.transports);
246
324
  const fallbackChainId = Number(Object.keys(networks)[0] ?? 1);
325
+ const mobile = parameters.mobile ?? isMobile();
326
+ const addChainFirst = parameters.addChainFirst ?? metaMaskMayLackChain;
247
327
  const instance = () => getClient(parameters, supportedNetworks);
248
328
  let guarded: { provider: EIP1193Provider; wrapped: EIP1193Provider } | undefined;
249
329
  // Every call wagmi makes on the connector it uses claims the client's events;
@@ -280,12 +360,15 @@ export function metaMaskConnect(parameters: MetaMaskConnectParameters) {
280
360
  });
281
361
  accounts = result.accounts.map((a) => getAddress(a));
282
362
  }
283
- return { accounts: accounts as never, chainId: chainIdOf(mm) };
363
+ const current = chainIdOf(mm);
364
+ permittedChains.add(current);
365
+ return { accounts: accounts as never, chainId: current };
284
366
  } catch (err) {
285
367
  rethrow(err);
286
368
  }
287
369
  },
288
370
  async disconnect() {
371
+ forgetChains();
289
372
  await (await claim()).disconnect();
290
373
  },
291
374
  async getAccounts() {
@@ -317,27 +400,56 @@ export function metaMaskConnect(parameters: MetaMaskConnectParameters) {
317
400
  const chain = config.chains.find((c) => c.id === chainId);
318
401
  if (!chain || !networks[numberToHex(chainId)])
319
402
  throw new SwitchChainError(new ChainNotConfiguredError());
403
+ const chainConfiguration = {
404
+ chainId: numberToHex(chainId),
405
+ chainName: addEthereumChainParameter?.chainName ?? chain.name,
406
+ nativeCurrency: addEthereumChainParameter?.nativeCurrency ?? chain.nativeCurrency,
407
+ rpcUrls: addEthereumChainParameter?.rpcUrls
408
+ ? [...addEthereumChainParameter.rpcUrls]
409
+ : [networks[numberToHex(chainId)]!],
410
+ ...(chain.blockExplorers?.default.url
411
+ ? { blockExplorerUrls: [chain.blockExplorers.default.url] }
412
+ : {}),
413
+ };
414
+ const mm = await claim();
415
+ if (mm.getChainId() === numberToHex(chainId)) return chain;
320
416
  try {
321
- await (
322
- await claim()
323
- ).switchChain({
324
- chainId: numberToHex(chainId),
325
- chainConfiguration: {
326
- chainId: numberToHex(chainId),
327
- chainName: addEthereumChainParameter?.chainName ?? chain.name,
328
- nativeCurrency: addEthereumChainParameter?.nativeCurrency ?? chain.nativeCurrency,
329
- rpcUrls: addEthereumChainParameter?.rpcUrls
330
- ? [...addEthereumChainParameter.rpcUrls]
331
- : [networks[numberToHex(chainId)]],
332
- ...(chain.blockExplorers?.default.url
333
- ? { blockExplorerUrls: [chain.blockExplorers.default.url] }
334
- : {}),
335
- },
336
- });
417
+ if (!mobile) {
418
+ await mm.switchChain({ chainId: numberToHex(chainId), chainConfiguration });
419
+ } else if (
420
+ !permittedChains.has(chainId) &&
421
+ (unrecognizedChains.has(chainId) || addChainFirst(chain))
422
+ ) {
423
+ // One trip that adds the network and permits it. MetaMask Connect
424
+ // takes the chain on once the wallet answers.
425
+ await mm.getProvider().request({
426
+ method: "wallet_addEthereumChain",
427
+ params: [chainConfiguration],
428
+ } as never);
429
+ } else {
430
+ // Without a configuration, so a chain MetaMask lacks fails here
431
+ // instead of MetaMask Connect sending the add request as a second
432
+ // trip, which no tap started and Chrome on Android drops.
433
+ await mm.switchChain({ chainId: numberToHex(chainId) });
434
+ }
435
+ permittedChains.add(chainId);
436
+ unrecognizedChains.delete(chainId);
337
437
  return chain;
338
438
  } catch (err) {
339
439
  if ((err as { code?: number })?.code === UserRejectedRequestError.code)
340
440
  throw new UserRejectedRequestError(err as Error);
441
+ if (mobile && isUnrecognizedChain(err)) {
442
+ unrecognizedChains.add(chainId);
443
+ throw new SwitchChainError(
444
+ Object.assign(
445
+ new Error(
446
+ `MetaMask does not have ${chain.name} yet. Switch again from a tap to add it.`,
447
+ { cause: err },
448
+ ),
449
+ { code: 4902 },
450
+ ),
451
+ );
452
+ }
341
453
  throw new SwitchChainError(err as Error);
342
454
  }
343
455
  },
@@ -6,6 +6,8 @@ export { default as getDefaultConnectors } from "./defaultConnectors";
6
6
  export { wallets } from "./wallets";
7
7
  export {
8
8
  metaMaskConnect,
9
+ metaMaskMayLackChain,
10
+ reopenMetaMask,
9
11
  type MetaMaskConnectParameters,
10
12
  } from "./connectors/metaMaskConnectConnector";
11
13
 
@@ -7,7 +7,7 @@ import React, {
7
7
  useRef,
8
8
  useSyncExternalStore,
9
9
  } from "react";
10
- import { useAccountEffect, type Connector } from "wagmi";
10
+ import { ChainNotConfiguredError, useAccountEffect, useConfig, type Connector } from "wagmi";
11
11
  import {
12
12
  routes,
13
13
  useContext,
@@ -96,10 +96,12 @@ export const ConnectorLauncherProvider: React.FC<{ children: React.ReactNode }>
96
96
  const wallets = useWallets();
97
97
  const { connectAsync } = useConnect();
98
98
  const connectors = useConnectors();
99
+ const { chains } = useConfig();
99
100
 
100
101
  const depsRef = useRef<LauncherDeps>(null as unknown as LauncherDeps);
101
102
  depsRef.current = {
102
- connect: (connector) => connectAsync({ connector }),
103
+ connect: (connector, chainId) =>
104
+ connectAsync(chainId === undefined ? { connector } : { connector, chainId }),
103
105
  showConnecting: (walletId) => {
104
106
  context.setConnector({ id: walletId });
105
107
  context.setRoute(routes.CONNECT);
@@ -111,8 +113,8 @@ export const ConnectorLauncherProvider: React.FC<{ children: React.ReactNode }>
111
113
  },
112
114
  onFailed: () => clearConnectIntent(),
113
115
  };
114
- const latest = useRef({ wallets, connectors, options: context.options });
115
- latest.current = { wallets, connectors, options: context.options };
116
+ const latest = useRef({ wallets, connectors, chains, options: context.options });
117
+ latest.current = { wallets, connectors, chains, options: context.options };
116
118
 
117
119
  const launcher = useMemo(() => createConnectorLauncher(() => depsRef.current), []);
118
120
  useEffect(() => () => launcher.cancel(), [launcher]);
@@ -133,7 +135,9 @@ export const ConnectorLauncherProvider: React.FC<{ children: React.ReactNode }>
133
135
 
134
136
  const openConnector = useCallback(
135
137
  (id: string, options: OpenConnectorOptions = {}) => {
136
- const { wallets, connectors } = latest.current;
138
+ const { wallets, connectors, chains } = latest.current;
139
+ if (options.chainId !== undefined && !chains.some((chain) => chain.id === options.chainId))
140
+ throw new ChainNotConfiguredError();
137
141
  const wallet = resolveWallet(wallets, connectors, id);
138
142
  if (!wallet) {
139
143
  context.log(`openConnector: no connector ${id}, showing the wallet list.`);
@@ -145,6 +149,7 @@ export const ConnectorLauncherProvider: React.FC<{ children: React.ReactNode }>
145
149
  setConnectIntent();
146
150
  launcher.open(wallet.id, wallet.connector, {
147
151
  fallbackMs: options.fallbackMs ?? defaultOpenConnectorFallbackMs(),
152
+ ...(options.chainId === undefined ? {} : { chainId: options.chainId }),
148
153
  });
149
154
  },
150
155
  // context.log is stable enough for a debug line; the rest is read through refs.
@@ -68,7 +68,7 @@ describe("createConnectorLauncher", () => {
68
68
  launcher.open("metaMaskSDK", mm, { fallbackMs: 6000 });
69
69
 
70
70
  expect(deps.connect).toHaveBeenCalledTimes(1);
71
- expect(deps.connect).toHaveBeenCalledWith(mm);
71
+ expect(deps.connect).toHaveBeenCalledWith(mm, undefined);
72
72
  expect(calls).toEqual(["connect:metaMaskSDK", "connecting:metaMaskSDK"]);
73
73
  expect(launcher.pending()?.walletId).toBe("metaMaskSDK");
74
74
  });
@@ -146,7 +146,7 @@ describe("createConnectorLauncher", () => {
146
146
  launcher.open("metaMaskSDK", mm, { fallbackMs: 6000 });
147
147
 
148
148
  expect(deps.connect).toHaveBeenCalledTimes(2);
149
- expect(deps.connect).toHaveBeenLastCalledWith(mm);
149
+ expect(deps.connect).toHaveBeenLastCalledWith(mm, undefined);
150
150
  expect(launcher.pending()).toBe(first);
151
151
  expect(vi.getTimerCount()).toBe(1);
152
152
 
@@ -323,3 +323,25 @@ describe("createConnectorLauncher", () => {
323
323
  expect(deps.showList).not.toHaveBeenCalled();
324
324
  });
325
325
  });
326
+
327
+ describe("createConnectorLauncher with a chainId", () => {
328
+ it("hands the chain to connect() inside the same synchronous call", () => {
329
+ const { launcher, deps } = setup();
330
+ const mm = fakeConnector("metaMaskSDK");
331
+
332
+ launcher.open("metaMaskSDK", mm, { fallbackMs: 0, chainId: 84532 });
333
+
334
+ expect(deps.connect).toHaveBeenCalledTimes(1);
335
+ expect(deps.connect).toHaveBeenCalledWith(mm, 84532);
336
+ });
337
+
338
+ it("re-issues a pending connect on the chain the new tap asks for", () => {
339
+ const { launcher, deps } = setup();
340
+ const mm = fakeConnector("metaMaskSDK");
341
+ launcher.open("metaMaskSDK", mm, { fallbackMs: 0, chainId: 84532 });
342
+
343
+ launcher.open("metaMaskSDK", mm, { fallbackMs: 0, chainId: 84532 });
344
+
345
+ expect(deps.connect).toHaveBeenNthCalledWith(2, mm, 84532);
346
+ });
347
+ });