@multiplatform.one/web3 7.36.0 → 7.38.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 (90) hide show
  1. package/dist/cjs/connectkit/connectors/metaMaskConnectConnector.cjs +79 -8
  2. package/dist/cjs/connectkit/connectors/metaMaskConnectConnector.native.js +90 -5
  3. package/dist/cjs/connectkit/connectors/metaMaskConnectConnector.native.js.map +1 -1
  4. package/dist/cjs/connectkit/connectors/restoredSession.cjs +136 -0
  5. package/dist/cjs/connectkit/connectors/restoredSession.native.js +297 -0
  6. package/dist/cjs/connectkit/connectors/restoredSession.native.js.map +1 -0
  7. package/dist/cjs/connectkit/defaultConfig.cjs +3 -1
  8. package/dist/cjs/connectkit/defaultConfig.native.js +3 -1
  9. package/dist/cjs/connectkit/defaultConfig.native.js.map +1 -1
  10. package/dist/cjs/connectkit/defaultConnectors.cjs +5 -1
  11. package/dist/cjs/connectkit/defaultConnectors.native.js +5 -1
  12. package/dist/cjs/connectkit/defaultConnectors.native.js.map +1 -1
  13. package/dist/cjs/connectkit/index.cjs +3 -0
  14. package/dist/cjs/connectkit/index.native.js +3 -0
  15. package/dist/cjs/connectkit/index.native.js.map +1 -1
  16. package/dist/cjs/keycloak/KeycloakWalletLogin.cjs +16 -2
  17. package/dist/cjs/keycloak/useWalletLogin.cjs +9 -2
  18. package/dist/esm/connectkit/connectors/metaMaskConnectConnector.mjs +79 -8
  19. package/dist/esm/connectkit/connectors/metaMaskConnectConnector.mjs.map +1 -1
  20. package/dist/esm/connectkit/connectors/metaMaskConnectConnector.native.js +90 -5
  21. package/dist/esm/connectkit/connectors/metaMaskConnectConnector.native.js.map +1 -1
  22. package/dist/esm/connectkit/connectors/restoredSession.mjs +107 -0
  23. package/dist/esm/connectkit/connectors/restoredSession.mjs.map +1 -0
  24. package/dist/esm/connectkit/connectors/restoredSession.native.js +265 -0
  25. package/dist/esm/connectkit/connectors/restoredSession.native.js.map +1 -0
  26. package/dist/esm/connectkit/defaultConfig.mjs +3 -1
  27. package/dist/esm/connectkit/defaultConfig.mjs.map +1 -1
  28. package/dist/esm/connectkit/defaultConfig.native.js +3 -1
  29. package/dist/esm/connectkit/defaultConfig.native.js.map +1 -1
  30. package/dist/esm/connectkit/defaultConnectors.mjs +5 -1
  31. package/dist/esm/connectkit/defaultConnectors.mjs.map +1 -1
  32. package/dist/esm/connectkit/defaultConnectors.native.js +5 -1
  33. package/dist/esm/connectkit/defaultConnectors.native.js.map +1 -1
  34. package/dist/esm/connectkit/index.mjs +2 -1
  35. package/dist/esm/connectkit/index.mjs.map +1 -1
  36. package/dist/esm/connectkit/index.native.js +2 -1
  37. package/dist/esm/connectkit/index.native.js.map +1 -1
  38. package/dist/esm/keycloak/KeycloakWalletLogin.mjs +16 -2
  39. package/dist/esm/keycloak/KeycloakWalletLogin.mjs.map +1 -1
  40. package/dist/esm/keycloak/useWalletLogin.mjs +10 -3
  41. package/dist/esm/keycloak/useWalletLogin.mjs.map +1 -1
  42. package/dist/jsx/connectkit/connectors/metaMaskConnectConnector.mjs +79 -8
  43. package/dist/jsx/connectkit/connectors/metaMaskConnectConnector.mjs.map +1 -1
  44. package/dist/jsx/connectkit/connectors/metaMaskConnectConnector.native.js +90 -5
  45. package/dist/jsx/connectkit/connectors/metaMaskConnectConnector.native.js.map +1 -1
  46. package/dist/jsx/connectkit/connectors/restoredSession.mjs +107 -0
  47. package/dist/jsx/connectkit/connectors/restoredSession.mjs.map +1 -0
  48. package/dist/jsx/connectkit/connectors/restoredSession.native.js +297 -0
  49. package/dist/jsx/connectkit/connectors/restoredSession.native.js.map +1 -0
  50. package/dist/jsx/connectkit/defaultConfig.mjs +3 -1
  51. package/dist/jsx/connectkit/defaultConfig.mjs.map +1 -1
  52. package/dist/jsx/connectkit/defaultConfig.native.js +3 -1
  53. package/dist/jsx/connectkit/defaultConfig.native.js.map +1 -1
  54. package/dist/jsx/connectkit/defaultConnectors.mjs +5 -1
  55. package/dist/jsx/connectkit/defaultConnectors.mjs.map +1 -1
  56. package/dist/jsx/connectkit/defaultConnectors.native.js +5 -1
  57. package/dist/jsx/connectkit/defaultConnectors.native.js.map +1 -1
  58. package/dist/jsx/connectkit/index.mjs +2 -1
  59. package/dist/jsx/connectkit/index.mjs.map +1 -1
  60. package/dist/jsx/connectkit/index.native.js +3 -0
  61. package/dist/jsx/connectkit/index.native.js.map +1 -1
  62. package/dist/jsx/keycloak/KeycloakWalletLogin.mjs +16 -2
  63. package/dist/jsx/keycloak/KeycloakWalletLogin.mjs.map +1 -1
  64. package/dist/jsx/keycloak/useWalletLogin.mjs +10 -3
  65. package/dist/jsx/keycloak/useWalletLogin.mjs.map +1 -1
  66. package/dist/keycloak/login.iife.js +356 -356
  67. package/package.json +6 -6
  68. package/src/connectkit/connectors/metaMaskConnectConnector.spec.ts +316 -0
  69. package/src/connectkit/connectors/metaMaskConnectConnector.ts +143 -3
  70. package/src/connectkit/connectors/restoredSession.spec.ts +206 -0
  71. package/src/connectkit/connectors/restoredSession.ts +165 -0
  72. package/src/connectkit/defaultConfig.ts +8 -0
  73. package/src/connectkit/defaultConnectors.ts +6 -0
  74. package/src/connectkit/index.ts +5 -0
  75. package/src/keycloak/KeycloakWalletLogin.spec.tsx +51 -1
  76. package/src/keycloak/KeycloakWalletLogin.tsx +12 -1
  77. package/src/keycloak/useWalletLogin.ts +18 -2
  78. package/types/connectkit/connectors/metaMaskConnectConnector.d.ts +9 -0
  79. package/types/connectkit/connectors/metaMaskConnectConnector.d.ts.map +1 -1
  80. package/types/connectkit/connectors/restoredSession.d.ts +45 -0
  81. package/types/connectkit/connectors/restoredSession.d.ts.map +1 -0
  82. package/types/connectkit/defaultConfig.d.ts +7 -1
  83. package/types/connectkit/defaultConfig.d.ts.map +1 -1
  84. package/types/connectkit/defaultConnectors.d.ts +3 -1
  85. package/types/connectkit/defaultConnectors.d.ts.map +1 -1
  86. package/types/connectkit/index.d.ts +1 -0
  87. package/types/connectkit/index.d.ts.map +1 -1
  88. package/types/keycloak/KeycloakWalletLogin.d.ts.map +1 -1
  89. package/types/keycloak/useWalletLogin.d.ts +6 -0
  90. package/types/keycloak/useWalletLogin.d.ts.map +1 -1
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@multiplatform.one/web3",
3
- "version": "7.36.0",
3
+ "version": "7.38.0",
4
4
  "description": "Web3 wallet integration for multiplatform.one ecosystem with ConnectKit and wagmi",
5
5
  "keywords": [
6
6
  "connectkit",
@@ -120,10 +120,10 @@
120
120
  "@aave/account": "^0.2.1",
121
121
  "@coinbase/wallet-sdk": "^4.4.0",
122
122
  "@metamask/connect-evm": "^2.1.1",
123
- "@multiplatform.one/connectkit": "^7.36.0",
124
- "@multiplatform.one/gpg": "^7.36.0",
125
- "@multiplatform.one/platform": "^7.36.0",
126
- "@multiplatform.one/theme": "^7.36.0",
123
+ "@multiplatform.one/connectkit": "^7.38.0",
124
+ "@multiplatform.one/gpg": "^7.38.0",
125
+ "@multiplatform.one/platform": "^7.38.0",
126
+ "@multiplatform.one/theme": "^7.38.0",
127
127
  "@noble/ciphers": "^1.3.0",
128
128
  "@noble/curves": "^1.9.7",
129
129
  "@solana/kit": "^8.5.0",
@@ -146,7 +146,7 @@
146
146
  "wagmi": "^3.7.1"
147
147
  },
148
148
  "devDependencies": {
149
- "@multiplatform.one/config": "7.36.0",
149
+ "@multiplatform.one/config": "7.38.0",
150
150
  "@tamagui/build": "2.7.6",
151
151
  "@tanstack/react-query": "^5.104.3",
152
152
  "@testing-library/jest-dom": "^7.0.1",
@@ -3,9 +3,16 @@ import type { Chain } from "viem";
3
3
  import { getAddress, SwitchChainError, UserRejectedRequestError } from "viem";
4
4
  import { base, baseSepolia, bscTestnet, mainnet, polygon, polygonAmoy, sepolia } from "viem/chains";
5
5
  import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
6
+ import { IDBFactory } from "fake-indexeddb";
6
7
  import { createConfig, http } from "wagmi";
7
8
  import { connect, reconnect, sendTransaction, signMessage, signTypedData } from "wagmi/actions";
8
9
 
10
+ import {
11
+ metaMaskConnectKV,
12
+ SessionStore,
13
+ storedSession,
14
+ } from "../../../tests/fixtures/metaMaskConnectStore";
15
+
9
16
  import {
10
17
  metaMaskConnect,
11
18
  metaMaskConnectChainIds,
@@ -666,6 +673,315 @@ describe("metaMaskConnect switchChain on a phone", () => {
666
673
  });
667
674
  });
668
675
 
676
+ describe("metaMaskConnect on a session MetaMask no longer holds", () => {
677
+ const account = "0x00000000000000000000000000000000000000aa";
678
+ const timeout = { afterReturnMs: 20_000, withoutTripMs: 45_000 };
679
+ const listeners = new Set<() => void>();
680
+ const tab = {
681
+ visibilityState: "visible",
682
+ addEventListener: (_: string, listener: () => void) => void listeners.add(listener),
683
+ removeEventListener: (_: string, listener: () => void) => void listeners.delete(listener),
684
+ };
685
+ const show = (state: "hidden" | "visible") => {
686
+ tab.visibilityState = state;
687
+ for (const listener of [...listeners]) listener();
688
+ };
689
+ // A relay channel no wallet reads: the request is sent and nothing ever comes back.
690
+ const unanswered = vi.fn(
691
+ ({ method }: { method: string }) =>
692
+ new Promise((resolve) => {
693
+ if (method !== "personal_sign") resolve(null);
694
+ }),
695
+ );
696
+
697
+ beforeEach(() => {
698
+ resetMetaMaskConnectClient();
699
+ vi.clearAllMocks();
700
+ vi.useFakeTimers();
701
+ vi.stubGlobal("document", tab);
702
+ tab.visibilityState = "visible";
703
+ listeners.clear();
704
+ client.getChainId.mockReturnValue("0x1");
705
+ client.getProvider.mockReturnValue({ request: unanswered } as never);
706
+ // createEVMClient resumed the stored session: its cached accounts are there.
707
+ client.accounts = [account];
708
+ });
709
+
710
+ afterEach(() => {
711
+ vi.useRealTimers();
712
+ vi.unstubAllGlobals();
713
+ resetMetaMaskConnectClient();
714
+ client.disconnect.mockReset();
715
+ client.disconnect.mockImplementation(async () => undefined);
716
+ client.accounts = [];
717
+ client.getChainId.mockImplementation(() => "0x2105");
718
+ client.getProvider.mockImplementation(() => ({ request: vi.fn() }));
719
+ });
720
+
721
+ function restoredConfig(options: { mobile?: boolean; restoredSessionTimeout?: typeof timeout }) {
722
+ return createConfig({
723
+ chains: [mainnet],
724
+ connectors: [
725
+ metaMaskConnect({ dapp: { name: "test", url: "https://example.test" }, ...options }),
726
+ ],
727
+ transports: { [mainnet.id]: http() },
728
+ multiInjectedProviderDiscovery: false,
729
+ storage: null,
730
+ });
731
+ }
732
+
733
+ async function signOnRestoredSession(config: ReturnType<typeof restoredConfig>) {
734
+ await reconnect(config);
735
+ expect(config.state.status).toBe("connected");
736
+ const signing = signMessage(config, { message: "Sign in" });
737
+ const outcome: { error?: unknown; settled: boolean } = { settled: false };
738
+ signing.then(
739
+ () => (outcome.settled = true),
740
+ (error: unknown) => Object.assign(outcome, { error, settled: true }),
741
+ );
742
+ await vi.advanceTimersByTimeAsync(0);
743
+ return outcome;
744
+ }
745
+
746
+ it("gives up 20 s after the page is back from MetaMask, drops the session and connects afresh", async () => {
747
+ const config = restoredConfig({ mobile: true, restoredSessionTimeout: timeout });
748
+ const outcome = await signOnRestoredSession(config);
749
+
750
+ // The deeplink takes the user to MetaMask ("Connection Not Found") and back.
751
+ show("hidden");
752
+ await vi.advanceTimersByTimeAsync(30_000);
753
+ show("visible");
754
+ await vi.advanceTimersByTimeAsync(timeout.afterReturnMs - 1);
755
+ expect(outcome.settled).toBe(false);
756
+ expect(client.disconnect).not.toHaveBeenCalled();
757
+
758
+ await vi.advanceTimersByTimeAsync(1);
759
+ // viem reports EIP-1193's 4900 as ProviderDisconnectedError, ours as its cause.
760
+ expect(outcome.error).toMatchObject({
761
+ code: 4900,
762
+ cause: { name: "MetaMaskSessionLostError" },
763
+ });
764
+ expect(client.disconnect).toHaveBeenCalledOnce();
765
+ expect(config.state.status).toBe("disconnected");
766
+ expect(config.state.connections.size).toBe(0);
767
+
768
+ // MetaMask Connect has forgotten the session, so the next connect is a new one.
769
+ client.accounts = [];
770
+ await connect(config, { connector: config.connectors[0]! });
771
+ expect(client.connect).toHaveBeenCalledOnce();
772
+ });
773
+
774
+ it("gives up after 45 s when the page never got to MetaMask", async () => {
775
+ const config = restoredConfig({ mobile: true, restoredSessionTimeout: timeout });
776
+ const outcome = await signOnRestoredSession(config);
777
+
778
+ await vi.advanceTimersByTimeAsync(timeout.withoutTripMs - 1);
779
+ expect(outcome.settled).toBe(false);
780
+ await vi.advanceTimersByTimeAsync(1);
781
+ expect(outcome.error).toMatchObject({ code: 4900 });
782
+ expect(config.state.status).toBe("disconnected");
783
+ });
784
+
785
+ it("lets go of the page even when MetaMask Connect's disconnect never finishes", async () => {
786
+ client.disconnect.mockImplementationOnce(() => new Promise(() => undefined));
787
+ const config = restoredConfig({ mobile: true, restoredSessionTimeout: timeout });
788
+ const outcome = await signOnRestoredSession(config);
789
+
790
+ await vi.advanceTimersByTimeAsync(timeout.withoutTripMs);
791
+ expect(config.state.status).toBe("disconnected");
792
+ expect(outcome.settled).toBe(false);
793
+ await vi.advanceTimersByTimeAsync(5_000);
794
+ expect(outcome.error).toMatchObject({ code: 4900 });
795
+ });
796
+
797
+ it("connects afresh while a dropped session's disconnect still runs, never on that session", async () => {
798
+ // fake-indexeddb runs its requests on setImmediate.
799
+ vi.useRealTimers();
800
+ vi.useFakeTimers({ toFake: ["setTimeout", "clearTimeout", "Date"] });
801
+ // MetaMask Connect's stored session, which its connect would resume.
802
+ const idb = new IDBFactory();
803
+ vi.stubGlobal("indexedDB", idb);
804
+ const kv = metaMaskConnectKV(idb);
805
+ await (await SessionStore.create(kv)).set(storedSession("stale"));
806
+ client.disconnect.mockImplementationOnce(() => new Promise<undefined>(() => undefined));
807
+ let resumable: string[] | undefined;
808
+ client.connect.mockImplementationOnce(async () => {
809
+ resumable = (await (await SessionStore.create(kv)).list()).map((s) => s.id);
810
+ return { accounts: [account], chainId: "0x1" };
811
+ });
812
+ const config = restoredConfig({ mobile: true, restoredSessionTimeout: timeout });
813
+ const outcome = await signOnRestoredSession(config);
814
+
815
+ // The tap lands as the session is dropped, before the request has even failed.
816
+ await vi.advanceTimersByTimeAsync(timeout.withoutTripMs);
817
+ expect(config.state.status).toBe("disconnected");
818
+ expect(client.disconnect).toHaveBeenCalledOnce();
819
+ client.accounts = [];
820
+ let connected = false;
821
+ connect(config, { connector: config.connectors[0]! }).then(() => (connected = true));
822
+ for (let i = 0; i < 100 && !connected; i++) {
823
+ await new Promise((resolve) => setImmediate(resolve));
824
+ await vi.advanceTimersByTimeAsync(50);
825
+ }
826
+
827
+ // MetaMask Connect's disconnect never finished.
828
+ expect(client.connect).toHaveBeenCalledOnce();
829
+ expect(resumable).toEqual([]);
830
+ expect(config.state.status).toBe("connected");
831
+ await vi.advanceTimersByTimeAsync(5_000);
832
+ expect(outcome.error).toMatchObject({ code: 4900 });
833
+ await kv.close();
834
+ });
835
+
836
+ it("makes a connect wait for MetaMask Connect's disconnect still running, then go ahead", async () => {
837
+ let finishDisconnect!: () => void;
838
+ client.disconnect.mockImplementationOnce(
839
+ () => new Promise<undefined>((resolve) => (finishDisconnect = () => resolve(undefined))),
840
+ );
841
+ const config = restoredConfig({ mobile: true, restoredSessionTimeout: timeout });
842
+ const outcome = await signOnRestoredSession(config);
843
+ await vi.advanceTimersByTimeAsync(timeout.withoutTripMs + 5_000);
844
+ expect(outcome.error).toMatchObject({ code: 4900 });
845
+
846
+ client.accounts = [];
847
+ const connecting = connect(config, { connector: config.connectors[0]! });
848
+ await vi.advanceTimersByTimeAsync(500);
849
+ expect(client.connect).not.toHaveBeenCalled();
850
+ finishDisconnect();
851
+ await connecting;
852
+ expect(client.connect).toHaveBeenCalledOnce();
853
+ });
854
+
855
+ it("waits at most a second for a disconnect that never finishes", async () => {
856
+ client.disconnect.mockImplementationOnce(() => new Promise<undefined>(() => undefined));
857
+ const config = restoredConfig({ mobile: true, restoredSessionTimeout: timeout });
858
+ const outcome = await signOnRestoredSession(config);
859
+ await vi.advanceTimersByTimeAsync(timeout.withoutTripMs + 5_000);
860
+ expect(outcome.error).toMatchObject({ code: 4900 });
861
+
862
+ client.accounts = [];
863
+ const connecting = connect(config, { connector: config.connectors[0]! });
864
+ await vi.advanceTimersByTimeAsync(999);
865
+ expect(client.connect).not.toHaveBeenCalled();
866
+ await vi.advanceTimersByTimeAsync(1);
867
+ await connecting;
868
+ expect(client.connect).toHaveBeenCalledOnce();
869
+ });
870
+
871
+ it("fails the request and lets a connect through when IndexedDB never answers", async () => {
872
+ // An open request that never fires, as IndexedDB sometimes does on iOS.
873
+ vi.stubGlobal("indexedDB", { open: () => ({}) });
874
+ const config = restoredConfig({ mobile: true, restoredSessionTimeout: timeout });
875
+ const outcome = await signOnRestoredSession(config);
876
+ await vi.advanceTimersByTimeAsync(timeout.withoutTripMs);
877
+ expect(config.state.status).toBe("disconnected");
878
+
879
+ client.accounts = [];
880
+ const connecting = connect(config, { connector: config.connectors[0]! });
881
+ await vi.advanceTimersByTimeAsync(1_499);
882
+ expect(outcome.settled).toBe(false);
883
+ expect(client.connect).not.toHaveBeenCalled();
884
+ await vi.advanceTimersByTimeAsync(1);
885
+ await connecting;
886
+ expect(client.connect).toHaveBeenCalledOnce();
887
+ expect(outcome.error).toMatchObject({ code: 4900 });
888
+ });
889
+
890
+ it("drops the session once when several requests give up on it together", async () => {
891
+ let finishDisconnect!: () => void;
892
+ client.disconnect.mockImplementationOnce(
893
+ () => new Promise<undefined>((resolve) => (finishDisconnect = () => resolve(undefined))),
894
+ );
895
+ const config = restoredConfig({ mobile: true, restoredSessionTimeout: timeout });
896
+ const first = await signOnRestoredSession(config);
897
+ const second: { error?: unknown } = {};
898
+ signMessage(config, { message: "again" }).catch((error: unknown) => (second.error = error));
899
+
900
+ await vi.advanceTimersByTimeAsync(timeout.withoutTripMs);
901
+ finishDisconnect();
902
+ await vi.advanceTimersByTimeAsync(0);
903
+ expect(first.error).toMatchObject({ code: 4900 });
904
+ expect(second.error).toMatchObject({ code: 4900 });
905
+ expect(client.disconnect).toHaveBeenCalledOnce();
906
+ });
907
+
908
+ it("still watches a restored session whose only answers came from MetaMask Connect's cache", async () => {
909
+ // eth_requestAccounts on a stored session resolves from cache, never from MetaMask.
910
+ const request = vi.fn(
911
+ ({ method }: { method: string }): Promise<unknown> =>
912
+ method === "personal_sign" ? new Promise(() => undefined) : Promise.resolve([account]),
913
+ );
914
+ client.getProvider.mockReturnValue({ request } as never);
915
+ const config = restoredConfig({ mobile: true, restoredSessionTimeout: timeout });
916
+ await reconnect(config);
917
+ const provider = (await config.connectors[0]!.getProvider()) as {
918
+ request(args: { method: string }): Promise<unknown>;
919
+ };
920
+ await provider.request({ method: "eth_requestAccounts" });
921
+ await provider.request({ method: "wallet_switchEthereumChain" });
922
+
923
+ const outcome = await signOnRestoredSession(config);
924
+ await vi.advanceTimersByTimeAsync(timeout.withoutTripMs);
925
+ expect(outcome.error).toMatchObject({ code: 4900 });
926
+ });
927
+
928
+ it("keeps a restored session MetaMask answers, and stops watching it", async () => {
929
+ const request = vi.fn(
930
+ async ({ method }: { method: string }): Promise<unknown> =>
931
+ method === "personal_sign" ? "0x5167" : null,
932
+ );
933
+ client.getProvider.mockReturnValue({ request } as never);
934
+ const config = restoredConfig({ mobile: true, restoredSessionTimeout: timeout });
935
+ await reconnect(config);
936
+
937
+ expect(await signMessage(config, { message: "Sign in" })).toBe("0x5167");
938
+
939
+ // Answered once: a later request that MetaMask holds a long time is left alone.
940
+ request.mockImplementation(() => new Promise(() => undefined));
941
+ signMessage(config, { message: "again" }).catch(() => undefined);
942
+ await vi.advanceTimersByTimeAsync(timeout.withoutTripMs * 4);
943
+ expect(client.disconnect).not.toHaveBeenCalled();
944
+ expect(config.state.status).toBe("connected");
945
+ });
946
+
947
+ it("leaves a session alone that this page's own connect handshook", async () => {
948
+ client.accounts = [];
949
+ const config = restoredConfig({ mobile: true, restoredSessionTimeout: timeout });
950
+ const events = await (async () => {
951
+ await vi.waitFor(() => expect(createEVMClient).toHaveBeenCalledTimes(1));
952
+ return (createEVMClient.mock.calls[0]![0] as { eventHandlers: EventHandlers }).eventHandlers;
953
+ })();
954
+ client.connect.mockImplementationOnce(async () => {
955
+ events.displayUri("metamask://connect/mwp?p=new");
956
+ return { accounts: [account], chainId: "0x1" };
957
+ });
958
+ await connect(config, { connector: config.connectors[0]! });
959
+
960
+ signMessage(config, { message: "Sign in" }).catch(() => undefined);
961
+ await vi.advanceTimersByTimeAsync(timeout.withoutTripMs * 4);
962
+ expect(client.disconnect).not.toHaveBeenCalled();
963
+ expect(config.state.status).toBe("connected");
964
+ });
965
+
966
+ it("waits on the relay as before without restoredSessionTimeout", async () => {
967
+ const config = restoredConfig({ mobile: true });
968
+ const outcome = await signOnRestoredSession(config);
969
+ show("hidden");
970
+ show("visible");
971
+ await vi.advanceTimersByTimeAsync(timeout.withoutTripMs * 4);
972
+ expect(outcome.settled).toBe(false);
973
+ expect(client.disconnect).not.toHaveBeenCalled();
974
+ });
975
+
976
+ it("leaves the desktop alone, where MetaMask is the extension", async () => {
977
+ const config = restoredConfig({ mobile: false, restoredSessionTimeout: timeout });
978
+ const outcome = await signOnRestoredSession(config);
979
+ await vi.advanceTimersByTimeAsync(timeout.withoutTripMs * 4);
980
+ expect(outcome.settled).toBe(false);
981
+ expect(client.disconnect).not.toHaveBeenCalled();
982
+ });
983
+ });
984
+
669
985
  describe("metaMaskMayLackChain", () => {
670
986
  it("is true for a testnet MetaMask does not ship and false for mainnets and its own testnets", () => {
671
987
  expect(metaMaskMayLackChain(baseSepolia)).toBe(true);
@@ -26,6 +26,7 @@ import {
26
26
  } from "viem";
27
27
  import { mainnet } from "wagmi/chains";
28
28
 
29
+ import { forgetStoredSessions, type RestoredSessionTimeout, untilSilent } from "./restoredSession";
29
30
  import { isSyntheticChainId } from "./syntheticChains";
30
31
 
31
32
  export type MetaMaskConnectParameters = {
@@ -49,6 +50,14 @@ export type MetaMaskConnectParameters = {
49
50
  * rather than to switch to it. Defaults to `metaMaskMayLackChain`.
50
51
  */
51
52
  addChainFirst?: (chain: Chain) => boolean;
53
+ /**
54
+ * On a phone, give up on a session restored from storage once a wallet
55
+ * request goes unanswered this long (see restoredSession.ts): the request
56
+ * fails with MetaMaskSessionLostError, wagmi disconnects, the stored session is
57
+ * deleted and MetaMask Connect disconnects, so the next connect starts a new
58
+ * one. Off by default.
59
+ */
60
+ restoredSessionTimeout?: RestoredSessionTimeout;
52
61
  };
53
62
 
54
63
  type ConnectorConfig = Parameters<CreateConnectorFn>[0];
@@ -227,11 +236,89 @@ let owner: Emitter | undefined;
227
236
  const permittedChains = new Set<number>();
228
237
  const unrecognizedChains = new Set<number>();
229
238
  let lastDeeplink: { url: string; open: (url: string, target?: string) => void } | undefined;
239
+ // Whether MetaMask has answered on this page's session: a connect that ran a
240
+ // handshake (MetaMask Connect raises display_uri only for a new session
241
+ // request) or a wallet request it settled. A session resumed from storage
242
+ // starts unanswered.
243
+ let handshakes = 0;
244
+ let answered = false;
230
245
 
231
246
  function forgetChains(): void {
232
247
  permittedChains.clear();
233
248
  unrecognizedChains.clear();
234
249
  lastDeeplink = undefined;
250
+ answered = false;
251
+ }
252
+
253
+ // What only the wallet answers, so a reply proves MetaMask still holds the
254
+ // session. Not eth_requestAccounts or a switch to a permitted chain: MetaMask
255
+ // Connect answers those from the stored session without asking the wallet.
256
+ const walletMethods = new Set([
257
+ "personal_sign",
258
+ "eth_sign",
259
+ "eth_signTypedData",
260
+ "eth_signTypedData_v3",
261
+ "eth_signTypedData_v4",
262
+ "eth_sendTransaction",
263
+ "wallet_requestPermissions",
264
+ "wallet_addEthereumChain",
265
+ "wallet_watchAsset",
266
+ "wallet_sendCalls",
267
+ ]);
268
+
269
+ function watchRestoredSession(
270
+ provider: EIP1193Provider,
271
+ timeout: RestoredSessionTimeout,
272
+ drop: () => Promise<void>,
273
+ ): EIP1193Provider {
274
+ const request = ((args: { method: string; params?: unknown }) => {
275
+ const sent = provider.request(args as never) as Promise<unknown>;
276
+ if (!walletMethods.has(args.method)) return sent;
277
+ const heard = sent.then(
278
+ (value) => {
279
+ answered = true;
280
+ return value;
281
+ },
282
+ (err: unknown) => {
283
+ if ((err as { code?: number })?.code === UserRejectedRequestError.code) answered = true;
284
+ throw err;
285
+ },
286
+ );
287
+ return answered ? heard : untilSilent(heard, timeout, drop);
288
+ }) as EIP1193Provider["request"];
289
+ return new Proxy(provider, {
290
+ get(target, property) {
291
+ if (property === "request") return request;
292
+ const value = Reflect.get(target, property);
293
+ return typeof value === "function" ? value.bind(target) : value;
294
+ },
295
+ });
296
+ }
297
+
298
+ // IndexedDB can stop answering (a known iOS failure), so the deletion is capped
299
+ // too; past it, the request still fails and a connect still goes ahead.
300
+ const forgetDeadlineMs = 1_500;
301
+ const dropDeadlineMs = 5_000;
302
+ const disconnectWaitMs = 1_000;
303
+ // The drop under way, shared by every request that gives up meanwhile. It ends
304
+ // once the stored session is deleted and MetaMask Connect's disconnect is done,
305
+ // or at the deadlines.
306
+ let dropping: Promise<void> | undefined;
307
+ // The dropped session's deletion from storage, while it runs.
308
+ let forgetting: Promise<void> | undefined;
309
+ // MetaMask Connect's disconnect, while it runs. One that finishes under a new
310
+ // connect clears the transport that connect handshakes on, so a connect waits
311
+ // for it, briefly: a tap gets this far only once the drop has given up on it.
312
+ let disconnecting: Promise<void> | undefined;
313
+
314
+ function withDeadline<T>(promise: Promise<T>, ms: number): Promise<T> {
315
+ let timer: ReturnType<typeof setTimeout> | undefined;
316
+ return Promise.race([
317
+ promise,
318
+ new Promise<never>((_, reject) => {
319
+ timer = setTimeout(() => reject(new Error(`timed out after ${ms} ms`)), ms);
320
+ }),
321
+ ]).finally(() => clearTimeout(timer));
235
322
  }
236
323
 
237
324
  /**
@@ -315,7 +402,10 @@ function getClient(
315
402
  forgetChains();
316
403
  owner?.emit("disconnect");
317
404
  },
318
- displayUri: (uri) => owner?.emit("message", { type: "display_uri", data: uri }),
405
+ displayUri: (uri) => {
406
+ handshakes += 1;
407
+ owner?.emit("message", { type: "display_uri", data: uri });
408
+ },
319
409
  },
320
410
  }),
321
411
  );
@@ -329,6 +419,10 @@ function getClient(
329
419
  export function resetMetaMaskConnectClient(): void {
330
420
  client = undefined;
331
421
  owner = undefined;
422
+ handshakes = 0;
423
+ dropping = undefined;
424
+ forgetting = undefined;
425
+ disconnecting = undefined;
332
426
  forgetChains();
333
427
  }
334
428
 
@@ -349,6 +443,7 @@ export function metaMaskConnect(parameters: MetaMaskConnectParameters) {
349
443
  const fallbackChainId = Number(Object.keys(networks)[0] ?? 1);
350
444
  const mobile = parameters.mobile ?? isMobile();
351
445
  const addChainFirst = parameters.addChainFirst ?? metaMaskMayLackChain;
446
+ const restoredSessionTimeout = mobile ? parameters.restoredSessionTimeout : undefined;
352
447
  const instance = () => getClient(parameters, supportedNetworks);
353
448
  let guarded: { provider: EIP1193Provider; wrapped: EIP1193Provider } | undefined;
354
449
  // Every call wagmi makes on the connector it uses claims the client's events;
@@ -362,6 +457,37 @@ export function metaMaskConnect(parameters: MetaMaskConnectParameters) {
362
457
  const hex = mm.getChainId();
363
458
  return hex ? Number(hex) : fallbackChainId;
364
459
  };
460
+ // wagmi lets go first, so the page stops waiting even if the relay is gone
461
+ // too. The stored session goes before the request fails and the page offers
462
+ // a new connect, so that connect cannot resume it whatever the disconnect
463
+ // is doing; the deadlines keep a dead relay or IndexedDB from holding the
464
+ // request open.
465
+ const dropSession = (): Promise<void> => {
466
+ if (dropping) return dropping;
467
+ forgetChains();
468
+ config.emitter.emit("disconnect");
469
+ const forgotten: Promise<void> = withDeadline(forgetStoredSessions(), forgetDeadlineMs)
470
+ .catch(() => undefined)
471
+ .finally(() => {
472
+ if (forgetting === forgotten) forgetting = undefined;
473
+ });
474
+ forgetting = forgotten;
475
+ const disconnected: Promise<void> = instance()
476
+ .then((mm) => mm.disconnect())
477
+ .catch(() => undefined)
478
+ .finally(() => {
479
+ if (disconnecting === disconnected) disconnecting = undefined;
480
+ });
481
+ disconnecting = disconnected;
482
+ const drop: Promise<void> = forgotten
483
+ .then(() => withDeadline(disconnected, dropDeadlineMs))
484
+ .catch(() => undefined)
485
+ .finally(() => {
486
+ if (dropping === drop) dropping = undefined;
487
+ });
488
+ dropping = drop;
489
+ return drop;
490
+ };
365
491
 
366
492
  return {
367
493
  id: "metaMaskSDK",
@@ -376,13 +502,23 @@ export function metaMaskConnect(parameters: MetaMaskConnectParameters) {
376
502
  async connect({ chainId, isReconnecting } = {}) {
377
503
  // No separate chain switch: the requested chain leads the permission request
378
504
  // and becomes the active chain, so connecting stays one wallet round trip.
505
+ // Nothing slow comes before mm.connect(): iOS Safari opens the MetaMask
506
+ // link only within the tap's user activation.
507
+ if (forgetting) await forgetting;
508
+ // A tap reaches this wait only when the disconnect outlasted the drop's
509
+ // deadline. Up to 1 s of the tap's activation can go here; iOS may then
510
+ // block the link and cost the user one more tap, which beats a hang.
511
+ if (disconnecting)
512
+ await withDeadline(disconnecting, disconnectWaitMs).catch(() => undefined);
379
513
  const mm = await claim();
380
514
  try {
381
515
  let accounts = isReconnecting ? accountsOf(mm) : [];
382
516
  if (!accounts.length) {
517
+ const before = handshakes;
383
518
  const result = await mm.connect({
384
519
  chainIds: metaMaskConnectChainIds(networks, chainId),
385
520
  });
521
+ if (handshakes !== before) answered = true;
386
522
  accounts = result.accounts.map((a) => getAddress(a));
387
523
  }
388
524
  const current = chainIdOf(mm);
@@ -407,11 +543,15 @@ export function metaMaskConnect(parameters: MetaMaskConnectParameters) {
407
543
  // connector wraps it for itself.
408
544
  const mm = await claim();
409
545
  const provider = mm.getProvider();
410
- if (guarded?.provider !== provider)
546
+ if (guarded?.provider !== provider) {
547
+ const listed = listedChainsOnly(provider, mm, networks, supportedNetworks);
411
548
  guarded = {
412
549
  provider,
413
- wrapped: listedChainsOnly(provider, mm, networks, supportedNetworks),
550
+ wrapped: restoredSessionTimeout
551
+ ? watchRestoredSession(listed, restoredSessionTimeout, dropSession)
552
+ : listed,
414
553
  };
554
+ }
415
555
  return guarded.wrapped;
416
556
  },
417
557
  async isAuthorized() {