@multiplatform.one/web3 7.37.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
@@ -0,0 +1,206 @@
1
+ import { IDBFactory } from "fake-indexeddb";
2
+ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
3
+ import {
4
+ metaMaskConnectKV,
5
+ multichainBrowserBuild,
6
+ SessionStore,
7
+ storedSession,
8
+ } from "../../../tests/fixtures/metaMaskConnectStore";
9
+ import {
10
+ forgetStoredSessions,
11
+ MetaMaskSessionLostError,
12
+ metaMaskConnectSessionStore,
13
+ type PageLike,
14
+ type RestoredSessionTimeout,
15
+ untilSilent,
16
+ } from "./restoredSession";
17
+
18
+ const timeout: RestoredSessionTimeout = { afterReturnMs: 20_000, withoutTripMs: 45_000 };
19
+
20
+ function page(): PageLike & { show(state: "hidden" | "visible"): void } {
21
+ const listeners = new Set<() => void>();
22
+ const p = {
23
+ visibilityState: "visible",
24
+ addEventListener: (_: "visibilitychange", listener: () => void) => void listeners.add(listener),
25
+ removeEventListener: (_: "visibilitychange", listener: () => void) =>
26
+ void listeners.delete(listener),
27
+ show(state: "hidden" | "visible") {
28
+ p.visibilityState = state;
29
+ for (const listener of [...listeners]) listener();
30
+ },
31
+ };
32
+ return p;
33
+ }
34
+
35
+ function deferred<T>() {
36
+ let resolve!: (value: T) => void;
37
+ let reject!: (error: unknown) => void;
38
+ const promise = new Promise<T>((res, rej) => {
39
+ resolve = res;
40
+ reject = rej;
41
+ });
42
+ return { promise, resolve, reject };
43
+ }
44
+
45
+ function track<T>(promise: Promise<T>) {
46
+ const state: { value?: T; error?: unknown; settled: boolean } = { settled: false };
47
+ promise.then(
48
+ (value) => Object.assign(state, { value, settled: true }),
49
+ (error: unknown) => Object.assign(state, { error, settled: true }),
50
+ );
51
+ return state;
52
+ }
53
+
54
+ describe("untilSilent", () => {
55
+ beforeEach(() => vi.useFakeTimers());
56
+ afterEach(() => vi.useRealTimers());
57
+
58
+ it("drops the session once the page has been back from MetaMask for afterReturnMs with no answer", async () => {
59
+ const tab = page();
60
+ const drop = vi.fn(async () => undefined);
61
+ const result = track(untilSilent(deferred().promise, timeout, drop, tab));
62
+
63
+ tab.show("hidden");
64
+ await vi.advanceTimersByTimeAsync(120_000);
65
+ tab.show("visible");
66
+ await vi.advanceTimersByTimeAsync(timeout.afterReturnMs - 1);
67
+ expect(drop).not.toHaveBeenCalled();
68
+ expect(result.settled).toBe(false);
69
+
70
+ await vi.advanceTimersByTimeAsync(1);
71
+ expect(drop).toHaveBeenCalledOnce();
72
+ expect(result.error).toBeInstanceOf(MetaMaskSessionLostError);
73
+ expect(result.error).toMatchObject({ code: 4900 });
74
+ });
75
+
76
+ it("drops it after withoutTripMs when the page never left for MetaMask", async () => {
77
+ const drop = vi.fn(async () => undefined);
78
+ const result = track(untilSilent(deferred().promise, timeout, drop, page()));
79
+
80
+ await vi.advanceTimersByTimeAsync(timeout.withoutTripMs - 1);
81
+ expect(drop).not.toHaveBeenCalled();
82
+ await vi.advanceTimersByTimeAsync(1);
83
+ expect(drop).toHaveBeenCalledOnce();
84
+ expect(result.error).toBeInstanceOf(MetaMaskSessionLostError);
85
+ });
86
+
87
+ it("restarts the wait when the user goes back to MetaMask", async () => {
88
+ const tab = page();
89
+ const drop = vi.fn(async () => undefined);
90
+ untilSilent(deferred().promise, timeout, drop, tab).catch(() => undefined);
91
+
92
+ tab.show("hidden");
93
+ tab.show("visible");
94
+ await vi.advanceTimersByTimeAsync(timeout.afterReturnMs - 5_000);
95
+ tab.show("hidden");
96
+ await vi.advanceTimersByTimeAsync(60_000);
97
+ expect(drop).not.toHaveBeenCalled();
98
+ tab.show("visible");
99
+ await vi.advanceTimersByTimeAsync(timeout.afterReturnMs - 1);
100
+ expect(drop).not.toHaveBeenCalled();
101
+ await vi.advanceTimersByTimeAsync(1);
102
+ expect(drop).toHaveBeenCalledOnce();
103
+ });
104
+
105
+ it("passes the wallet's answer through and drops nothing", async () => {
106
+ const tab = page();
107
+ const answer = deferred<string>();
108
+ const drop = vi.fn(async () => undefined);
109
+ const result = track(untilSilent(answer.promise, timeout, drop, tab));
110
+
111
+ tab.show("hidden");
112
+ tab.show("visible");
113
+ answer.resolve("0x5167");
114
+ await vi.advanceTimersByTimeAsync(timeout.withoutTripMs * 2);
115
+ expect(result.value).toBe("0x5167");
116
+ expect(drop).not.toHaveBeenCalled();
117
+ });
118
+
119
+ it("passes the wallet's rejection through", async () => {
120
+ const answer = deferred<string>();
121
+ const result = track(untilSilent(answer.promise, timeout, vi.fn(), page()));
122
+ const rejected = Object.assign(new Error("User rejected the request."), { code: 4001 });
123
+ answer.reject(rejected);
124
+ await vi.advanceTimersByTimeAsync(0);
125
+ expect(result.error).toBe(rejected);
126
+ });
127
+
128
+ it("still rejects when dropping the session fails", async () => {
129
+ const drop = vi.fn(async () => {
130
+ throw new Error("relay gone");
131
+ });
132
+ const result = track(untilSilent(deferred().promise, timeout, drop, page()));
133
+ await vi.advanceTimersByTimeAsync(timeout.withoutTripMs);
134
+ expect(result.error).toBeInstanceOf(MetaMaskSessionLostError);
135
+ });
136
+
137
+ it("with no document, waits withoutTripMs", async () => {
138
+ const drop = vi.fn(async () => undefined);
139
+ const result = track(untilSilent(deferred().promise, timeout, drop));
140
+ await vi.advanceTimersByTimeAsync(timeout.withoutTripMs);
141
+ expect(result.error).toBeInstanceOf(MetaMaskSessionLostError);
142
+ });
143
+ });
144
+
145
+ describe("forgetStoredSessions", () => {
146
+ it("deletes every session MetaMask Connect could resume, and nothing else it stored", async () => {
147
+ const idb = new IDBFactory();
148
+ const kv = metaMaskConnectKV(idb);
149
+ const sessions = await SessionStore.create(kv);
150
+ await sessions.set(storedSession("stale"));
151
+ await sessions.set(storedSession("older"));
152
+ await kv.set("multichain-transport", "mwp");
153
+ await kv.set("cache_eth_accounts", '["0xaa"]');
154
+ expect((await sessions.list()).map((s) => s.id)).toEqual(["stale", "older"]);
155
+
156
+ await forgetStoredSessions(idb);
157
+
158
+ expect(await (await SessionStore.create(kv)).list()).toEqual([]);
159
+ expect(await kv.get("session:stale")).toBeNull();
160
+ expect(await kv.get("multichain-transport")).toBe("mwp");
161
+ expect(await kv.get("cache_eth_accounts")).toBe('["0xaa"]');
162
+ await kv.close();
163
+ });
164
+
165
+ it("creates no database where MetaMask Connect has none", async () => {
166
+ const idb = new IDBFactory();
167
+ await forgetStoredSessions(idb);
168
+ expect(await idb.databases()).toEqual([]);
169
+ });
170
+
171
+ it("settles when IndexedDB fails inside its callbacks", async () => {
172
+ const open: { result?: unknown; onsuccess?: () => void } = {};
173
+ const idb = { open: () => open } as unknown as IDBFactory;
174
+ const forgotten = forgetStoredSessions(idb);
175
+ open.result = {
176
+ objectStoreNames: {
177
+ contains() {
178
+ throw new Error("InvalidStateError");
179
+ },
180
+ },
181
+ close: () => undefined,
182
+ };
183
+ open.onsuccess?.();
184
+ await expect(forgotten).resolves.toBeUndefined();
185
+ });
186
+
187
+ it("does nothing without IndexedDB", async () => {
188
+ await expect(forgetStoredSessions(undefined)).resolves.toBeUndefined();
189
+ });
190
+
191
+ it("names the store the installed MetaMask Connect keeps and resumes its sessions from", () => {
192
+ // StoreAdapterWeb opens `${DB_NAME}${dbNameSuffix}` and uses stores[0].
193
+ const build = multichainBrowserBuild();
194
+ expect(build).toContain('_StoreAdapterWeb.DB_NAME = "mmconnect";');
195
+ expect(build).toContain(
196
+ 'constructor(dbNameSuffix = "-kv-store", storeName = _StoreAdapterWeb.stores[0])',
197
+ );
198
+ expect(build).toContain(`_StoreAdapterWeb.stores = ["${metaMaskConnectSessionStore.store}"`);
199
+ expect(build).toContain("const adapter = new StoreAdapterWeb2();");
200
+ expect(metaMaskConnectSessionStore.database).toBe("mmconnect-kv-store");
201
+ // MWPTransport.connect resumes the first session SessionStore lists there.
202
+ expect(build).toContain("const session = yield this.getActiveSession();");
203
+ expect(build).toContain("const sessionStore = yield SessionStore.create(kvstore);");
204
+ expect(build).toContain("const [activeSession] = yield sessionStore.list();");
205
+ });
206
+ });
@@ -0,0 +1,165 @@
1
+ // MetaMask forgets a connection on "Disconnect all" (or a reinstall) and tells
2
+ // the page nothing: no message reaches the session's relay channel. The page
3
+ // resumes the session it stored, sends each request on a channel no wallet
4
+ // reads, and deeplinks a session id MetaMask answers with "Connection Not
5
+ // Found". The request then waits out the relay timer, ten minutes with
6
+ // metaMaskConnectTimeouts. A connect is no way out either: MetaMask Connect
7
+ // resumes any stored session and answers it from its cache (FZ-7655).
8
+
9
+ export type RestoredSessionTimeout = {
10
+ /** How long the page may be back in front after a trip to MetaMask with no answer. */
11
+ afterReturnMs: number;
12
+ /** How long a request may go unanswered when the page never left for MetaMask. */
13
+ withoutTripMs: number;
14
+ };
15
+
16
+ export const defaultRestoredSessionTimeout: RestoredSessionTimeout = {
17
+ afterReturnMs: 20_000,
18
+ withoutTripMs: 45_000,
19
+ };
20
+
21
+ /**
22
+ * The request a dropped session failed. viem reports it to callers as
23
+ * ProviderDisconnectedError (4900), with this as its `cause`.
24
+ */
25
+ export class MetaMaskSessionLostError extends Error {
26
+ /** EIP-1193 "Disconnected". */
27
+ readonly code = 4900;
28
+ constructor() {
29
+ super(
30
+ "MetaMask never answered on the stored connection, so it was dropped. Connect your wallet again.",
31
+ );
32
+ this.name = "MetaMaskSessionLostError";
33
+ }
34
+ }
35
+
36
+ // Where MetaMask Connect keeps its Mobile Wallet Protocol sessions: the IndexedDB
37
+ // store of @metamask/connect-multichain's StoreAdapterWeb, under the keys of
38
+ // @metamask/mobile-wallet-protocol-core's SessionStore. A connect resumes the
39
+ // first session listed there.
40
+ export const metaMaskConnectSessionStore = {
41
+ database: "mmconnect-kv-store",
42
+ store: "sdk-kv-store",
43
+ sessionsKey: "sessions:master-list",
44
+ sessionKey: (id: string) => `session:${id}`,
45
+ };
46
+
47
+ /**
48
+ * Deletes every Mobile Wallet Protocol session MetaMask Connect stored, so no
49
+ * connect can resume one. MetaMask Connect's own disconnect deletes the session
50
+ * only once it has closed the relay socket. Never rejects; with no IndexedDB, or
51
+ * nothing stored, there is nothing to do.
52
+ */
53
+ export function forgetStoredSessions(
54
+ idb: IDBFactory | undefined = (globalThis as { indexedDB?: IDBFactory }).indexedDB,
55
+ ): Promise<void> {
56
+ const { database, store, sessionsKey, sessionKey } = metaMaskConnectSessionStore;
57
+ return new Promise<void>((resolve) => {
58
+ let open: IDBOpenDBRequest;
59
+ try {
60
+ if (!idb) throw new Error("no IndexedDB");
61
+ open = idb.open(database);
62
+ } catch {
63
+ resolve();
64
+ return;
65
+ }
66
+ // A database MetaMask Connect never created stays uncreated: it creates its
67
+ // object stores only when it makes the database.
68
+ open.onupgradeneeded = () => open.transaction?.abort();
69
+ open.onerror = () => resolve();
70
+ open.onsuccess = () => {
71
+ const db = open.result;
72
+ const done = () => {
73
+ db.close();
74
+ resolve();
75
+ };
76
+ try {
77
+ if (!db.objectStoreNames.contains(store)) return done();
78
+ const tx = db.transaction(store, "readwrite");
79
+ tx.oncomplete = done;
80
+ tx.onabort = done;
81
+ const sessions = tx.objectStore(store);
82
+ const listed = sessions.get(sessionsKey);
83
+ listed.onsuccess = () => {
84
+ try {
85
+ let ids: unknown;
86
+ try {
87
+ ids = JSON.parse(String(listed.result ?? "[]"));
88
+ } catch {
89
+ ids = [];
90
+ }
91
+ for (const id of Array.isArray(ids) ? ids : [])
92
+ if (typeof id === "string") sessions.delete(sessionKey(id));
93
+ sessions.delete(sessionsKey);
94
+ } catch {
95
+ done();
96
+ }
97
+ };
98
+ } catch {
99
+ done();
100
+ }
101
+ };
102
+ });
103
+ }
104
+
105
+ export interface PageLike {
106
+ visibilityState: string;
107
+ addEventListener(type: "visibilitychange", listener: () => void): void;
108
+ removeEventListener(type: "visibilitychange", listener: () => void): void;
109
+ }
110
+
111
+ /**
112
+ * Settles as `request` does unless the wallet stays silent: `timeout.afterReturnMs`
113
+ * once the page is back from MetaMask (leaving again restarts the wait), or
114
+ * `timeout.withoutTripMs` when it never left. Then `drop` runs and the promise
115
+ * rejects with MetaMaskSessionLostError. The page cannot ask the wallet whether
116
+ * it still holds the session, so a live one whose user comes back before
117
+ * approving, or whose relay takes longer than afterReturnMs to reconnect, is
118
+ * dropped too and costs a fresh connect.
119
+ */
120
+ export function untilSilent<T>(
121
+ request: Promise<T>,
122
+ timeout: RestoredSessionTimeout,
123
+ drop: () => Promise<void>,
124
+ page: PageLike | undefined = (globalThis as { document?: PageLike }).document,
125
+ ): Promise<T> {
126
+ return new Promise<T>((resolve, reject) => {
127
+ let settled = false;
128
+ let leftPage = page?.visibilityState === "hidden";
129
+ let timer: ReturnType<typeof setTimeout> | undefined;
130
+ const settle = () => {
131
+ settled = true;
132
+ clearTimeout(timer);
133
+ page?.removeEventListener("visibilitychange", arm);
134
+ };
135
+ const silent = () => {
136
+ if (settled) return;
137
+ settle();
138
+ void drop()
139
+ .catch(() => undefined)
140
+ .then(() => reject(new MetaMaskSessionLostError()));
141
+ };
142
+ function arm() {
143
+ clearTimeout(timer);
144
+ if (page?.visibilityState === "hidden") {
145
+ leftPage = true;
146
+ return;
147
+ }
148
+ timer = setTimeout(silent, leftPage ? timeout.afterReturnMs : timeout.withoutTripMs);
149
+ }
150
+ page?.addEventListener("visibilitychange", arm);
151
+ arm();
152
+ request.then(
153
+ (value) => {
154
+ if (settled) return;
155
+ settle();
156
+ resolve(value);
157
+ },
158
+ (error: unknown) => {
159
+ if (settled) return;
160
+ settle();
161
+ reject(error);
162
+ },
163
+ );
164
+ });
165
+ }
@@ -5,6 +5,7 @@ import { EthereumProviderOptions as AaveAccountOptions } from "@aave/account";
5
5
 
6
6
  import { setAppMeta } from "@multiplatform.one/connectkit";
7
7
  import defaultConnectors from "./defaultConnectors";
8
+ import type { RestoredSessionTimeout } from "./connectors/restoredSession";
8
9
  import { discoverWallets, type WalletEcosystem } from "./adapters/discover";
9
10
  import { getWalletConnectors } from "./connectors/getWalletConnectors";
10
11
  import { ecosystemBySyntheticChainId, syntheticChains } from "./connectors/syntheticChains";
@@ -26,6 +27,11 @@ type DefaultConfigProps = {
26
27
  // Aave Account options
27
28
  enableAaveAccount?: boolean;
28
29
  aaveAccountOptions?: AaveAccountOptions;
30
+ /**
31
+ * On a phone, drop a MetaMask session restored from storage that leaves a
32
+ * wallet request unanswered this long, so the next connect starts fresh.
33
+ */
34
+ metaMaskRestoredSessionTimeout?: RestoredSessionTimeout;
29
35
  // wallet: append shim-connectors for any detected non-EVM wallet (Solana/Sui/
30
36
  // Bitcoin) alongside the EVM connectors. No-op when none are installed. A list
31
37
  // limits it to those ecosystems.
@@ -52,6 +58,7 @@ const defaultConfig = ({
52
58
  client,
53
59
  enableAaveAccount = false,
54
60
  aaveAccountOptions,
61
+ metaMaskRestoredSessionTimeout,
55
62
  enableWallet = true,
56
63
  ...props
57
64
  }: DefaultConfigProps): CreateConfigParameters => {
@@ -87,6 +94,7 @@ const defaultConfig = ({
87
94
  coinbaseWalletPreference,
88
95
  enableAaveAccount,
89
96
  aaveAccountOptions,
97
+ metaMaskRestoredSessionTimeout,
90
98
  });
91
99
 
92
100
  // Append wallet shim-connectors for detected non-EVM wallets. discoverWallets()
@@ -4,6 +4,7 @@ import { CreateConnectorFn } from "wagmi";
4
4
  import type { CoinbaseWalletParameters } from "wagmi/connectors/coinbaseWallet";
5
5
  import { safe } from "wagmi/connectors/safe";
6
6
  import { metaMaskConnect } from "./connectors/metaMaskConnectConnector";
7
+ import type { RestoredSessionTimeout } from "./connectors/restoredSession";
7
8
  import {
8
9
  isEmbeddedDesktopWebview,
9
10
  metaMaskOptionsForEmbeddedDesktop,
@@ -23,6 +24,7 @@ type DefaultConnectorsProps = {
23
24
  coinbaseWalletPreference?: CoinbaseWalletParameters<"4">["preference"];
24
25
  enableAaveAccount?: boolean;
25
26
  aaveAccountOptions?: AaveAccountOptions;
27
+ metaMaskRestoredSessionTimeout?: RestoredSessionTimeout;
26
28
  };
27
29
 
28
30
  const defaultConnectors = ({
@@ -30,6 +32,7 @@ const defaultConnectors = ({
30
32
  coinbaseWalletPreference,
31
33
  enableAaveAccount,
32
34
  aaveAccountOptions,
35
+ metaMaskRestoredSessionTimeout,
33
36
  }: DefaultConnectorsProps): CreateConnectorFn[] => {
34
37
  const shouldUseSafeConnector = !(typeof window === "undefined") && window?.parent !== window;
35
38
 
@@ -58,6 +61,9 @@ const defaultConnectors = ({
58
61
  url: app.url,
59
62
  iconUrl: app.icon,
60
63
  },
64
+ ...(metaMaskRestoredSessionTimeout
65
+ ? { restoredSessionTimeout: metaMaskRestoredSessionTimeout }
66
+ : {}),
61
67
  ...(isEmbeddedDesktopWebview() ? metaMaskOptionsForEmbeddedDesktop() : {}),
62
68
  }),
63
69
  coinbaseWalletConnector({
@@ -10,6 +10,11 @@ export {
10
10
  reopenMetaMask,
11
11
  type MetaMaskConnectParameters,
12
12
  } from "./connectors/metaMaskConnectConnector";
13
+ export {
14
+ defaultRestoredSessionTimeout,
15
+ MetaMaskSessionLostError,
16
+ type RestoredSessionTimeout,
17
+ } from "./connectors/restoredSession";
13
18
 
14
19
  // web3's wrapper (generic connectkit useModal + wallet/SIWE openers) keeps the
15
20
  // consumer-facing `useModal` API. The agnostic base lives in ./hooks/useModal.
@@ -3,7 +3,7 @@ import { cleanup, fireEvent, render, screen, waitFor } from "@testing-library/re
3
3
  import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
4
4
  import { createConfig, createStorage, http } from "wagmi";
5
5
  import { mainnet } from "wagmi/chains";
6
- import { connect } from "wagmi/actions";
6
+ import { connect, disconnect } from "wagmi/actions";
7
7
  import { mock } from "wagmi/connectors";
8
8
  import { verifyMessage } from "viem";
9
9
  import { resetConnectIntentPage, setConnectIntent } from "../connectkit/siwe/connectIntent";
@@ -205,6 +205,56 @@ describe("KeycloakWalletLogin wallet sign-in", () => {
205
205
  });
206
206
  });
207
207
 
208
+ describe("KeycloakWalletLogin when the wallet connection drops during the signature", () => {
209
+ it("says so, and the next tap connects afresh to a clean sign-in step", async () => {
210
+ setUserAgent(ANDROID_UA);
211
+ setUserActivation(true);
212
+ let failSign!: (error: Error) => void;
213
+ vi.stubGlobal(
214
+ "fetch",
215
+ vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => {
216
+ if (String(input).endsWith("/nonce")) return new Response("nonce123abc");
217
+ const body = String(
218
+ init?.body ?? (input instanceof Request ? await input.clone().text() : ""),
219
+ );
220
+ if (/"method":"(eth_sign|personal_sign)"/.test(body))
221
+ return new Promise<Response>((_, reject) => (failSign = reject));
222
+ return new Response("not found", { status: 404 });
223
+ }),
224
+ );
225
+ // A session restored from storage, as on a page MetaMask has since forgotten.
226
+ const { config, connectSpy, walletButton } = renderLogin({
227
+ mobile: { preferredConnector: "mock" },
228
+ wagmiConfig: makeConfig(memoryStorage(), true),
229
+ });
230
+ await waitFor(() => expect(config.state.status).toBe("connected"));
231
+ connectSpy.mockClear();
232
+
233
+ fireEvent.click(walletButton);
234
+ await waitFor(() => expect(walletButton.textContent).toBe("Signing…"));
235
+ await waitFor(() => expect(failSign).toBeTypeOf("function"));
236
+
237
+ // What the MetaMask connector does once MetaMask stays silent: wagmi lets go,
238
+ // then the signature request fails.
239
+ await disconnect(config);
240
+ failSign(Object.assign(new Error("MetaMask never answered"), { code: 4900 }));
241
+
242
+ const notice = await screen.findByRole("status");
243
+ expect(notice.id).toBe("mpo-wallet-login-dropped");
244
+ expect(notice.textContent).toContain("Tap Sign in with Wallet to connect again");
245
+ await waitFor(() => expect(walletButton.textContent).toBe("Sign in with Wallet"));
246
+ expect(walletButton.disabled).toBe(false);
247
+
248
+ setUserActivation(false);
249
+ fireEvent.click(walletButton);
250
+ await waitFor(() => expect(connectSpy).toHaveBeenCalledTimes(1));
251
+ expect(document.getElementById("mpo-wallet-login-dropped")).toBeNull();
252
+ await screen.findByText("Sign In With Wallet");
253
+ expect(screen.queryByText("Unknown Error")).toBeNull();
254
+ expect(screen.getByText("Sign In")).not.toBeNull();
255
+ });
256
+ });
257
+
208
258
  describe("KeycloakWalletLogin target", () => {
209
259
  it("{ action } posts the credential to a keycloakify page's login action", async () => {
210
260
  const submit = vi
@@ -4,6 +4,7 @@ import { type CSSProperties, useMemo } from "react";
4
4
  import type { Chain } from "viem";
5
5
  import { type Config, WagmiProvider, createConfig } from "wagmi";
6
6
  import { arbitrum, mainnet, optimism, polygon } from "wagmi/chains";
7
+ import { defaultRestoredSessionTimeout } from "../connectkit/connectors/restoredSession";
7
8
  import getDefaultConfig from "../connectkit/defaultConfig";
8
9
  import { SIWEProvider } from "../connectkit/siwe";
9
10
  import { StatusState } from "../connectkit/siwe/SIWEContext";
@@ -69,6 +70,9 @@ export function createKeycloakWalletConfig({
69
70
  // Aave Account signs through an app.family.co iframe, which a Keycloak
70
71
  // page's default CSP (frame-src 'self') blocks.
71
72
  enableAaveAccount: false,
73
+ // A login MetaMask forgot ("Disconnect all") would otherwise sit on
74
+ // "Signing…" for the ten-minute relay timer (FZ-7655).
75
+ metaMaskRestoredSessionTimeout: defaultRestoredSessionTimeout,
72
76
  }),
73
77
  );
74
78
  }
@@ -88,6 +92,8 @@ const buttonStyle: CSSProperties = {
88
92
  background: "#fff",
89
93
  };
90
94
 
95
+ const noticeStyle: CSSProperties = { margin: "8px 0 0", fontSize: 13 };
96
+
91
97
  /**
92
98
  * Wallet and GPG sign-in for a Keycloak login page, on the browser flow: the
93
99
  * signed credential posts to the page's authenticator. Carries its own wagmi,
@@ -169,7 +175,7 @@ function LoginButtons({
169
175
  className,
170
176
  buttonClassName,
171
177
  }: KeycloakWalletLoginProps) {
172
- const { login, status } = useWalletLogin();
178
+ const { login, status, dropped } = useWalletLogin();
173
179
  const { openGpg } = useWeb3Modal();
174
180
  const style = buttonClassName ? undefined : buttonStyle;
175
181
  const busy = status === StatusState.LOADING || status === StatusState.SUCCESS;
@@ -189,6 +195,11 @@ function LoginButtons({
189
195
  <WalletIcon />
190
196
  <span>{busy ? "Signing…" : walletLabel}</span>
191
197
  </button>
198
+ {dropped && !busy && (
199
+ <p id="mpo-wallet-login-dropped" role="status" style={noticeStyle}>
200
+ The wallet connection was lost. Tap {walletLabel} to connect again.
201
+ </p>
202
+ )}
192
203
  {gpg && (
193
204
  <button
194
205
  type="button"
@@ -1,5 +1,5 @@
1
1
  import { useModal } from "@multiplatform.one/connectkit";
2
- import { useCallback, useContext, useEffect, useRef } from "react";
2
+ import { useCallback, useContext, useEffect, useRef, useState } from "react";
3
3
  import { useAccount } from "wagmi";
4
4
  import { useConnectorLauncher } from "../connectkit/openConnector";
5
5
  import { SIWEContext, StatusState } from "../connectkit/siwe/SIWEContext";
@@ -23,6 +23,12 @@ export interface UseWalletLoginResult {
23
23
  status: StatusState;
24
24
  isConnected: boolean;
25
25
  address?: string;
26
+ /**
27
+ * The wallet connection went away while a signature waited on it, as when
28
+ * MetaMask never answered a session it no longer holds. The next `login()`
29
+ * connects afresh and clears it.
30
+ */
31
+ dropped: boolean;
26
32
  }
27
33
 
28
34
  function readIntent(key: string, ttl: number): boolean {
@@ -72,6 +78,7 @@ export function useWalletLogin(opts: UseWalletLoginOptions = {}): UseWalletLogin
72
78
  const isConnected = onStore ? connection.status === "connected" : wagmiAccount.isConnected;
73
79
  const address = onStore ? (connection.address ?? undefined) : wagmiAccount.address;
74
80
  const signingRef = useRef(false);
81
+ const [dropped, setDropped] = useState(false);
75
82
 
76
83
  const sign = useCallback(async () => {
77
84
  const signer = signIn ?? siwe;
@@ -88,6 +95,10 @@ export function useWalletLogin(opts: UseWalletLoginOptions = {}): UseWalletLogin
88
95
  }, [signIn, siwe]);
89
96
 
90
97
  const login = useCallback(async () => {
98
+ setDropped(false);
99
+ // A failed signature leaves SIWE on ERROR, which the sign-in step would
100
+ // show the next attempt as "Unknown Error".
101
+ if (!signIn && siwe?.status === StatusState.ERROR) siwe.resetStatus();
91
102
  if (isConnected) {
92
103
  await sign();
93
104
  return;
@@ -96,7 +107,7 @@ export function useWalletLogin(opts: UseWalletLoginOptions = {}): UseWalletLogin
96
107
  // No await above: a phone's preferred wallet opens from inside the tap.
97
108
  if (launcher?.openPreferredConnector()) return;
98
109
  setOpen(true);
99
- }, [isConnected, sign, intentKey, setOpen, launcher]);
110
+ }, [isConnected, sign, intentKey, setOpen, launcher, signIn, siwe]);
100
111
 
101
112
  useEffect(() => {
102
113
  if (!isConnected || !readIntent(intentKey, intentTtlMs)) return;
@@ -107,11 +118,16 @@ export function useWalletLogin(opts: UseWalletLoginOptions = {}): UseWalletLogin
107
118
 
108
119
  useEffect(() => () => writeIntent(intentKey, null), [intentKey]);
109
120
 
121
+ useEffect(() => {
122
+ if (!isConnected && signingRef.current) setDropped(true);
123
+ }, [isConnected]);
124
+
110
125
  return {
111
126
  login,
112
127
  // SignInStatus holds StatusState's values, string for string.
113
128
  status: signIn ? (signIn.status as StatusState) : (siwe?.status ?? StatusState.ERROR),
114
129
  isConnected,
115
130
  address,
131
+ dropped,
116
132
  };
117
133
  }
@@ -1,6 +1,7 @@
1
1
  import type { EIP1193Provider } from "@metamask/connect-evm";
2
2
  import { type CreateConnectorFn } from "wagmi";
3
3
  import { type Chain, type Hex } from "viem";
4
+ import { type RestoredSessionTimeout } from "./restoredSession";
4
5
  export type MetaMaskConnectParameters = {
5
6
  dapp: {
6
7
  name: string;
@@ -26,6 +27,14 @@ export type MetaMaskConnectParameters = {
26
27
  * rather than to switch to it. Defaults to `metaMaskMayLackChain`.
27
28
  */
28
29
  addChainFirst?: (chain: Chain) => boolean;
30
+ /**
31
+ * On a phone, give up on a session restored from storage once a wallet
32
+ * request goes unanswered this long (see restoredSession.ts): the request
33
+ * fails with MetaMaskSessionLostError, wagmi disconnects, the stored session is
34
+ * deleted and MetaMask Connect disconnects, so the next connect starts a new
35
+ * one. Off by default.
36
+ */
37
+ restoredSessionTimeout?: RestoredSessionTimeout;
29
38
  };
30
39
  type ConnectorConfig = Parameters<CreateConnectorFn>[0];
31
40
  /**
@@ -1 +1 @@
1
- {"version":3,"file":"metaMaskConnectConnector.d.ts","sourceRoot":"","sources":["../../../src/connectkit/connectors/metaMaskConnectConnector.ts"],"names":[],"mappings":"AAaA,OAAO,KAAK,EAAE,eAAe,EAAsB,MAAM,uBAAuB,CAAC;AAEjF,OAAO,EAA2B,KAAK,iBAAiB,EAAmB,MAAM,OAAO,CAAC;AACzF,OAAO,EAEL,KAAK,KAAK,EAEV,KAAK,GAAG,EAKT,MAAM,MAAM,CAAC;AAKd,MAAM,MAAM,yBAAyB,GAAG;IACtC,IAAI,EAAE;QAAE,IAAI,EAAE,MAAM,CAAC;QAAC,GAAG,CAAC,EAAE,MAAM,CAAC;QAAC,OAAO,CAAC,EAAE,MAAM,CAAA;KAAE,CAAC;IACvD,6FAA6F;IAC7F,QAAQ,CAAC,EAAE,OAAO,CAAC;IACnB,mFAAmF;IACnF,iBAAiB,CAAC,EAAE,CAAC,QAAQ,EAAE,MAAM,EAAE,MAAM,CAAC,EAAE,MAAM,KAAK,IAAI,CAAC;IAChE,6EAA6E;IAC7E,WAAW,CAAC,EAAE,OAAO,CAAC;IACtB,KAAK,CAAC,EAAE,OAAO,CAAC;IAChB;;;;OAIG;IACH,MAAM,CAAC,EAAE,OAAO,CAAC;IACjB;;;;OAIG;IACH,aAAa,CAAC,EAAE,CAAC,KAAK,EAAE,KAAK,KAAK,OAAO,CAAC;CAC3C,CAAC;AAEF,KAAK,eAAe,GAAG,UAAU,CAAC,iBAAiB,CAAC,CAAC,CAAC,CAAC,CAAC;AAGxD;;;;;GAKG;AACH,wBAAgB,gBAAgB,CAC9B,MAAM,EAAE,SAAS,KAAK,EAAE,EACxB,UAAU,CAAC,EAAE,eAAe,CAAC,YAAY,CAAC,GACzC,MAAM,CAAC,GAAG,EAAE,MAAM,CAAC,CAQrB;AAED;;;;;;GAMG;AACH,wBAAgB,yBAAyB,CACvC,QAAQ,EAAE,MAAM,CAAC,GAAG,EAAE,MAAM,CAAC,EAC7B,UAAU,CAAC,EAAE,eAAe,CAAC,YAAY,CAAC,GACzC,MAAM,CAAC,GAAG,EAAE,MAAM,CAAC,CAOrB;AAsGD;;;;;GAKG;AACH,wBAAgB,oBAAoB,CAAC,KAAK,EAAE,KAAK,GAAG,OAAO,CAE1D;AAOD,wGAAwG;AACxG,wBAAgB,uBAAuB,CAAC,QAAQ,EAAE,MAAM,CAAC,GAAG,EAAE,MAAM,CAAC,EAAE,OAAO,CAAC,EAAE,MAAM,GAAG,GAAG,EAAE,CAK9F;AAqBD;;;;;;GAMG;AACH,wBAAgB,cAAc,IAAI,OAAO,CAIxC;AAgFD,oFAAoF;AACpF,wBAAgB,0BAA0B,IAAI,IAAI,CAIjD;AAWD,wBAAgB,eAAe,CAAC,UAAU,EAAE,yBAAyB,wFAwJpE;yBAxJe,eAAe"}
1
+ {"version":3,"file":"metaMaskConnectConnector.d.ts","sourceRoot":"","sources":["../../../src/connectkit/connectors/metaMaskConnectConnector.ts"],"names":[],"mappings":"AAaA,OAAO,KAAK,EAAE,eAAe,EAAsB,MAAM,uBAAuB,CAAC;AAEjF,OAAO,EAA2B,KAAK,iBAAiB,EAAmB,MAAM,OAAO,CAAC;AACzF,OAAO,EAEL,KAAK,KAAK,EAEV,KAAK,GAAG,EAKT,MAAM,MAAM,CAAC;AAGd,OAAO,EAAwB,KAAK,sBAAsB,EAAe,MAAM,mBAAmB,CAAC;AAGnG,MAAM,MAAM,yBAAyB,GAAG;IACtC,IAAI,EAAE;QAAE,IAAI,EAAE,MAAM,CAAC;QAAC,GAAG,CAAC,EAAE,MAAM,CAAC;QAAC,OAAO,CAAC,EAAE,MAAM,CAAA;KAAE,CAAC;IACvD,6FAA6F;IAC7F,QAAQ,CAAC,EAAE,OAAO,CAAC;IACnB,mFAAmF;IACnF,iBAAiB,CAAC,EAAE,CAAC,QAAQ,EAAE,MAAM,EAAE,MAAM,CAAC,EAAE,MAAM,KAAK,IAAI,CAAC;IAChE,6EAA6E;IAC7E,WAAW,CAAC,EAAE,OAAO,CAAC;IACtB,KAAK,CAAC,EAAE,OAAO,CAAC;IAChB;;;;OAIG;IACH,MAAM,CAAC,EAAE,OAAO,CAAC;IACjB;;;;OAIG;IACH,aAAa,CAAC,EAAE,CAAC,KAAK,EAAE,KAAK,KAAK,OAAO,CAAC;IAC1C;;;;;;OAMG;IACH,sBAAsB,CAAC,EAAE,sBAAsB,CAAC;CACjD,CAAC;AAEF,KAAK,eAAe,GAAG,UAAU,CAAC,iBAAiB,CAAC,CAAC,CAAC,CAAC,CAAC;AAGxD;;;;;GAKG;AACH,wBAAgB,gBAAgB,CAC9B,MAAM,EAAE,SAAS,KAAK,EAAE,EACxB,UAAU,CAAC,EAAE,eAAe,CAAC,YAAY,CAAC,GACzC,MAAM,CAAC,GAAG,EAAE,MAAM,CAAC,CAQrB;AAED;;;;;;GAMG;AACH,wBAAgB,yBAAyB,CACvC,QAAQ,EAAE,MAAM,CAAC,GAAG,EAAE,MAAM,CAAC,EAC7B,UAAU,CAAC,EAAE,eAAe,CAAC,YAAY,CAAC,GACzC,MAAM,CAAC,GAAG,EAAE,MAAM,CAAC,CAOrB;AAsGD;;;;;GAKG;AACH,wBAAgB,oBAAoB,CAAC,KAAK,EAAE,KAAK,GAAG,OAAO,CAE1D;AAOD,wGAAwG;AACxG,wBAAgB,uBAAuB,CAAC,QAAQ,EAAE,MAAM,CAAC,GAAG,EAAE,MAAM,CAAC,EAAE,OAAO,CAAC,EAAE,MAAM,GAAG,GAAG,EAAE,CAK9F;AAmGD;;;;;;GAMG;AACH,wBAAgB,cAAc,IAAI,OAAO,CAIxC;AAmFD,oFAAoF;AACpF,wBAAgB,0BAA0B,IAAI,IAAI,CAQjD;AAWD,wBAAgB,eAAe,CAAC,UAAU,EAAE,yBAAyB,wFAsMpE;yBAtMe,eAAe"}