@multiplatform.one/web3 7.30.0 → 7.30.1

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 (47) hide show
  1. package/README.md +6 -2
  2. package/dist/cjs/connectkit/connectors/coinbaseWalletConnector.cjs +79 -0
  3. package/dist/cjs/connectkit/connectors/coinbaseWalletConnector.native.js +101 -0
  4. package/dist/cjs/connectkit/connectors/coinbaseWalletConnector.native.js.map +1 -0
  5. package/dist/cjs/connectkit/connectors/metaMaskConnectConnector.cjs +37 -2
  6. package/dist/cjs/connectkit/connectors/metaMaskConnectConnector.native.js +38 -2
  7. package/dist/cjs/connectkit/connectors/metaMaskConnectConnector.native.js.map +1 -1
  8. package/dist/cjs/connectkit/defaultConnectors.cjs +2 -2
  9. package/dist/cjs/connectkit/defaultConnectors.native.js +2 -2
  10. package/dist/cjs/connectkit/defaultConnectors.native.js.map +1 -1
  11. package/dist/esm/connectkit/connectors/coinbaseWalletConnector.mjs +42 -0
  12. package/dist/esm/connectkit/connectors/coinbaseWalletConnector.mjs.map +1 -0
  13. package/dist/esm/connectkit/connectors/coinbaseWalletConnector.native.js +61 -0
  14. package/dist/esm/connectkit/connectors/coinbaseWalletConnector.native.js.map +1 -0
  15. package/dist/esm/connectkit/connectors/metaMaskConnectConnector.mjs +37 -3
  16. package/dist/esm/connectkit/connectors/metaMaskConnectConnector.mjs.map +1 -1
  17. package/dist/esm/connectkit/connectors/metaMaskConnectConnector.native.js +38 -3
  18. package/dist/esm/connectkit/connectors/metaMaskConnectConnector.native.js.map +1 -1
  19. package/dist/esm/connectkit/defaultConnectors.mjs +2 -2
  20. package/dist/esm/connectkit/defaultConnectors.mjs.map +1 -1
  21. package/dist/esm/connectkit/defaultConnectors.native.js +2 -2
  22. package/dist/esm/connectkit/defaultConnectors.native.js.map +1 -1
  23. package/dist/jsx/connectkit/connectors/coinbaseWalletConnector.mjs +42 -0
  24. package/dist/jsx/connectkit/connectors/coinbaseWalletConnector.mjs.map +1 -0
  25. package/dist/jsx/connectkit/connectors/coinbaseWalletConnector.native.js +101 -0
  26. package/dist/jsx/connectkit/connectors/coinbaseWalletConnector.native.js.map +1 -0
  27. package/dist/jsx/connectkit/connectors/metaMaskConnectConnector.mjs +37 -3
  28. package/dist/jsx/connectkit/connectors/metaMaskConnectConnector.mjs.map +1 -1
  29. package/dist/jsx/connectkit/connectors/metaMaskConnectConnector.native.js +38 -2
  30. package/dist/jsx/connectkit/connectors/metaMaskConnectConnector.native.js.map +1 -1
  31. package/dist/jsx/connectkit/defaultConnectors.mjs +2 -2
  32. package/dist/jsx/connectkit/defaultConnectors.mjs.map +1 -1
  33. package/dist/jsx/connectkit/defaultConnectors.native.js +2 -2
  34. package/dist/jsx/connectkit/defaultConnectors.native.js.map +1 -1
  35. package/dist/keycloak/login.iife.js +303 -303
  36. package/package.json +7 -6
  37. package/src/connectkit/connectors/coinbaseWalletConnector.spec.ts +141 -0
  38. package/src/connectkit/connectors/coinbaseWalletConnector.ts +71 -0
  39. package/src/connectkit/connectors/metaMaskConnectConnector.spec.ts +179 -3
  40. package/src/connectkit/connectors/metaMaskConnectConnector.ts +78 -2
  41. package/src/connectkit/defaultConnectors.ts +3 -2
  42. package/types/connectkit/connectors/coinbaseWalletConnector.d.ts +5 -0
  43. package/types/connectkit/connectors/coinbaseWalletConnector.d.ts.map +1 -0
  44. package/types/connectkit/connectors/metaMaskConnectConnector.d.ts +8 -0
  45. package/types/connectkit/connectors/metaMaskConnectConnector.d.ts.map +1 -1
  46. package/types/connectkit/defaultConnectors.d.ts +1 -1
  47. package/types/connectkit/defaultConnectors.d.ts.map +1 -1
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@multiplatform.one/web3",
3
- "version": "7.30.0",
3
+ "version": "7.30.1",
4
4
  "description": "Web3 wallet integration for multiplatform.one ecosystem with ConnectKit and wagmi",
5
5
  "keywords": [
6
6
  "connectkit",
@@ -112,11 +112,12 @@
112
112
  },
113
113
  "dependencies": {
114
114
  "@aave/account": "^0.2.0",
115
+ "@coinbase/wallet-sdk": "^4.3.6",
115
116
  "@metamask/connect-evm": "^2.1.1",
116
- "@multiplatform.one/connectkit": "7.30.0",
117
- "@multiplatform.one/gpg": "7.30.0",
118
- "@multiplatform.one/platform": "7.30.0",
119
- "@multiplatform.one/theme": "7.30.0",
117
+ "@multiplatform.one/connectkit": "7.30.1",
118
+ "@multiplatform.one/gpg": "7.30.1",
119
+ "@multiplatform.one/platform": "7.30.1",
120
+ "@multiplatform.one/theme": "7.30.1",
120
121
  "@noble/curves": "^1.9.7",
121
122
  "@solana/kit": "^8.3.0",
122
123
  "@solana/wallet-adapter-base": "^0.9.27",
@@ -138,7 +139,7 @@
138
139
  "wagmi": "^3.7.1"
139
140
  },
140
141
  "devDependencies": {
141
- "@multiplatform.one/config": "7.30.0",
142
+ "@multiplatform.one/config": "7.30.1",
142
143
  "@tamagui/build": "2.7.6",
143
144
  "@tanstack/react-query": "^5.100.7",
144
145
  "@testing-library/jest-dom": "^6.9.1",
@@ -0,0 +1,141 @@
1
+ // @vitest-environment happy-dom
2
+ import { createConfig, createStorage } from "wagmi";
3
+ import { reconnect } from "wagmi/actions";
4
+ import { mainnet } from "viem/chains";
5
+ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
6
+
7
+ const { sdkLoaded } = vi.hoisted(() => ({ sdkLoaded: vi.fn() }));
8
+
9
+ vi.mock("@metamask/connect-evm", () => ({ createEVMClient: vi.fn(() => new Promise(() => {})) }));
10
+ vi.mock("@coinbase/wallet-sdk", async (importOriginal) => {
11
+ sdkLoaded();
12
+ return importOriginal();
13
+ });
14
+
15
+ import getDefaultConfig from "../defaultConfig";
16
+ import { coinbaseWalletConnectorId } from "./coinbaseWalletConnector";
17
+
18
+ const loginPath = "/realms/main/protocol/openid-connect/auth";
19
+
20
+ const memory = () => {
21
+ const store = new Map<string, string>();
22
+ return {
23
+ getItem: (key: string) => store.get(key) ?? null,
24
+ setItem: (key: string, value: string) => void store.set(key, value),
25
+ removeItem: (key: string) => void store.delete(key),
26
+ };
27
+ };
28
+
29
+ // The Coinbase Wallet SDK reads the page's Cross-Origin-Opener-Policy with a HEAD
30
+ // request for the page's own path the moment it is built. Keycloak's auth endpoint
31
+ // answers that 400, since the request carries none of its query.
32
+ const fetchSpy = vi.fn(
33
+ async (_input: RequestInfo | URL, _init?: RequestInit) => new Response(null, { status: 400 }),
34
+ );
35
+ const pageHeads = () =>
36
+ fetchSpy.mock.calls.filter(
37
+ ([url, init]) => String(url).endsWith(loginPath) && init?.method === "HEAD",
38
+ );
39
+
40
+ function setup(storage = createStorage({ storage: memory() })) {
41
+ const config = createConfig({
42
+ ...getDefaultConfig({ appName: "Sign in", chains: [mainnet], enableWallet: false }),
43
+ storage,
44
+ });
45
+ const coinbase = config.connectors.find((c) => c.id === coinbaseWalletConnectorId)!;
46
+ return { config, coinbase };
47
+ }
48
+
49
+ describe("Coinbase Wallet connector", () => {
50
+ beforeEach(() => {
51
+ vi.resetModules();
52
+ sdkLoaded.mockClear();
53
+ fetchSpy.mockClear();
54
+ vi.stubGlobal("fetch", fetchSpy);
55
+ vi.spyOn(window, "open").mockReturnValue(null);
56
+ window.history.replaceState(null, "", `${loginPath}?client_id=quickcam`);
57
+ });
58
+ afterEach(() => {
59
+ vi.unstubAllGlobals();
60
+ vi.restoreAllMocks();
61
+ });
62
+
63
+ it("loads the SDK's module as the config is built, without a HEAD", async () => {
64
+ setup();
65
+ await vi.waitFor(() => expect(sdkLoaded).toHaveBeenCalledOnce());
66
+ await new Promise((resolve) => setTimeout(resolve, 0));
67
+ expect(pageHeads()).toEqual([]);
68
+ });
69
+
70
+ it("sends no HEAD for a Keycloak login page when wagmi reconnects on load", async () => {
71
+ const { config, coinbase } = setup();
72
+ expect(coinbase).toBeDefined();
73
+ await reconnect(config, { connectors: [coinbase] });
74
+ await new Promise((resolve) => setTimeout(resolve, 0));
75
+ expect(pageHeads()).toEqual([]);
76
+ expect(await coinbase.getProvider()).toBeUndefined();
77
+ expect(await coinbase.isAuthorized()).toBe(false);
78
+ });
79
+
80
+ it("builds the SDK when the user picks Coinbase Wallet", async () => {
81
+ const { coinbase } = setup();
82
+ void coinbase.connect().catch(() => undefined);
83
+ await vi.waitFor(() => expect(pageHeads()).toHaveLength(1));
84
+ expect(await coinbase.getProvider()).toBeDefined();
85
+ await vi.waitFor(() => expect(window.open).toHaveBeenCalled());
86
+ });
87
+
88
+ it("keeps a connect the user started while the load's storage read was pending", async () => {
89
+ let release!: (value: string | null) => void;
90
+ const recent = new Promise<string | null>((resolve) => (release = resolve));
91
+ const base = memory();
92
+ const { coinbase } = setup(
93
+ createStorage({
94
+ storage: {
95
+ ...base,
96
+ getItem: (key: string) =>
97
+ key.endsWith("recentConnectorId") ? recent : base.getItem(key),
98
+ },
99
+ }),
100
+ );
101
+ const onLoad = coinbase.getProvider();
102
+ void coinbase.connect().catch(() => undefined);
103
+ await vi.waitFor(() => expect(pageHeads()).toHaveLength(1));
104
+ release(null);
105
+ expect(await onLoad).toBeDefined();
106
+ expect(await coinbase.getProvider()).toBeDefined();
107
+ });
108
+
109
+ it("builds the SDK to restore a Coinbase Wallet connection wagmi still holds", async () => {
110
+ const storage = createStorage({ storage: memory() });
111
+ await storage.setItem("recentConnectorId", "metaMaskSDK");
112
+ // The page before the reload: a Coinbase Wallet connection wagmi persisted, current
113
+ // again after a later wallet closed, with recentConnectorId still naming that wallet.
114
+ const before = setup(storage).config;
115
+ const connector = {
116
+ id: coinbaseWalletConnectorId,
117
+ name: "Coinbase Wallet",
118
+ type: "coinbaseWallet",
119
+ uid: "cb",
120
+ };
121
+ before.setState((state) => ({
122
+ ...state,
123
+ connections: new Map([
124
+ ["cb", { accounts: ["0x0000000000000000000000000000000000000001"], chainId: 1, connector }],
125
+ ]) as never,
126
+ current: "cb",
127
+ }));
128
+ fetchSpy.mockClear();
129
+ const { coinbase } = setup(storage);
130
+ expect(await coinbase.getProvider()).toBeDefined();
131
+ expect(pageHeads()).toHaveLength(1);
132
+ });
133
+
134
+ it("builds the SDK to reconnect when Coinbase Wallet was the last connector", async () => {
135
+ const storage = createStorage({ storage: memory() });
136
+ await storage.setItem("recentConnectorId", coinbaseWalletConnectorId);
137
+ const { coinbase } = setup(storage);
138
+ expect(await coinbase.getProvider()).toBeDefined();
139
+ expect(pageHeads()).toHaveLength(1);
140
+ });
141
+ });
@@ -0,0 +1,71 @@
1
+ // wagmi's coinbaseWallet() builds the Coinbase Wallet SDK in getProvider(), and
2
+ // wagmi's reconnect on load asks every connector for its provider. Building the
3
+ // SDK sends a HEAD request for the page's own path to read its
4
+ // Cross-Origin-Opener-Policy. On a Keycloak login page that path is the auth
5
+ // endpoint, which answers 400 without its query, so every load logged two console
6
+ // errors (PROMA-123). This builds the SDK only when the user picks Coinbase
7
+ // Wallet, or when the page restores a Coinbase Wallet connection: the last one
8
+ // wagmi connected, or one wagmi still holds.
9
+ import type { CreateConnectorFn, Storage } from "wagmi";
10
+ import { coinbaseWallet, type CoinbaseWalletParameters } from "wagmi/connectors/coinbaseWallet";
11
+
12
+ export const coinbaseWalletConnectorId = "coinbaseWalletSDK";
13
+
14
+ type PersistedConnection = { connector?: { id?: string } };
15
+
16
+ // wagmi persists its connections under "store" as a Map, which its storage
17
+ // revives on read.
18
+ async function heldConnectorIds(storage: Storage | null | undefined): Promise<string[]> {
19
+ const persisted = (await storage?.getItem("store" as never)) as
20
+ | { state?: { connections?: unknown } }
21
+ | null
22
+ | undefined;
23
+ const connections = persisted?.state?.connections;
24
+ if (!(connections instanceof Map)) return [];
25
+ return [...(connections as Map<string, PersistedConnection>).values()].flatMap(
26
+ (connection) => connection?.connector?.id ?? [],
27
+ );
28
+ }
29
+
30
+ export function coinbaseWalletConnector(parameters?: CoinbaseWalletParameters): CreateConnectorFn {
31
+ const base = coinbaseWallet(parameters);
32
+
33
+ return (config) => {
34
+ const connector = base(config);
35
+ // Read while the connector is built: createConfig then writes its initial state
36
+ // over the persisted one, which stays empty until wagmi rehydrates.
37
+ const heldOnLoad = heldConnectorIds(config.storage).catch((): string[] => []);
38
+ let picked = false;
39
+ const due = async () => {
40
+ if (picked) return true;
41
+ const [recent, held] = await Promise.all([
42
+ config.storage?.getItem("recentConnectorId"),
43
+ heldOnLoad,
44
+ ]);
45
+ if (recent === coinbaseWalletConnectorId || held.includes(coinbaseWalletConnectorId))
46
+ picked = true;
47
+ return picked;
48
+ };
49
+ return {
50
+ ...connector,
51
+ async setup() {
52
+ // The SDK opens its popup only inside the tap's user activation, which a cold
53
+ // chunk load between tap and popup can outlast. Loading the module builds no SDK.
54
+ if (typeof window !== "undefined")
55
+ void import("@coinbase/wallet-sdk").catch(() => undefined);
56
+ },
57
+ async connect(parameters) {
58
+ picked = true;
59
+ return connector.connect.call(this, parameters);
60
+ },
61
+ async getProvider(parameters) {
62
+ if (!(await due())) return undefined;
63
+ return connector.getProvider.call(this, parameters);
64
+ },
65
+ async isAuthorized() {
66
+ if (!(await due())) return false;
67
+ return connector.isAuthorized.call(this);
68
+ },
69
+ } as ReturnType<typeof base>;
70
+ };
71
+ }
@@ -1,10 +1,10 @@
1
1
  import type { EventHandlers } from "@metamask/connect-evm";
2
2
  import type { Chain } from "viem";
3
3
  import { getAddress, SwitchChainError } from "viem";
4
- import { base, mainnet, polygon } from "viem/chains";
4
+ import { base, baseSepolia, mainnet, polygon, polygonAmoy } from "viem/chains";
5
5
  import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
6
6
  import { createConfig, http } from "wagmi";
7
- import { connect, reconnect } from "wagmi/actions";
7
+ import { connect, reconnect, sendTransaction, signMessage, signTypedData } from "wagmi/actions";
8
8
 
9
9
  import {
10
10
  metaMaskConnect,
@@ -122,6 +122,80 @@ describe("metaMaskConnect", () => {
122
122
  expect(result.chainId).toBe(base.id);
123
123
  });
124
124
 
125
+ it("tells MetaMask about Ethereum mainnet, which it adds to every connect, after the app's chains", async () => {
126
+ const connector = build([baseSepolia, polygonAmoy]);
127
+ await connector.setup?.();
128
+ await connector.connect({ chainId: baseSepolia.id });
129
+
130
+ const options = createEVMClient.mock.calls[0]![0] as {
131
+ api: { supportedNetworks: Record<string, string> };
132
+ };
133
+ expect(Object.keys(options.api.supportedNetworks)).toEqual([
134
+ hex(baseSepolia.id),
135
+ hex(polygonAmoy.id),
136
+ hex(mainnet.id),
137
+ ]);
138
+ expect(options.api.supportedNetworks[hex(mainnet.id)]).toBe(mainnet.rpcUrls.default.http[0]);
139
+ expect(client.connect.mock.calls[0]![0].chainIds).toEqual([
140
+ hex(baseSepolia.id),
141
+ hex(polygonAmoy.id),
142
+ ]);
143
+ });
144
+
145
+ it("falls back to mainnet's default URL when the app's mainnet transport has no usable one", async () => {
146
+ const connector = metaMaskConnect({ dapp: { name: "test", url: "https://example.test" } })({
147
+ chains: [mainnet, baseSepolia],
148
+ emitter: { uid: "metaMaskSDK", emit: vi.fn(), on: vi.fn(), once: vi.fn(), off: vi.fn() },
149
+ transports: { [mainnet.id]: http("/rpc/eth"), [baseSepolia.id]: http() },
150
+ } as never);
151
+ await connector.setup?.();
152
+
153
+ const options = createEVMClient.mock.calls[0]![0] as {
154
+ api: { supportedNetworks: Record<string, string> };
155
+ };
156
+ expect(options.api.supportedNetworks[hex(mainnet.id)]).toBe(mainnet.rpcUrls.default.http[0]);
157
+ });
158
+
159
+ it("keeps the app's own mainnet URL when the app lists mainnet", async () => {
160
+ const connector = metaMaskConnect({ dapp: { name: "test", url: "https://example.test" } })({
161
+ chains: [mainnet, base],
162
+ emitter: { uid: "metaMaskSDK", emit: vi.fn(), on: vi.fn(), once: vi.fn(), off: vi.fn() },
163
+ transports: { [mainnet.id]: http("https://mainnet.example.test"), [base.id]: http() },
164
+ } as never);
165
+ await connector.setup?.();
166
+
167
+ const options = createEVMClient.mock.calls[0]![0] as {
168
+ api: { supportedNetworks: Record<string, string> };
169
+ };
170
+ expect(Object.keys(options.api.supportedNetworks)).toEqual([hex(mainnet.id), hex(base.id)]);
171
+ expect(options.api.supportedNetworks[hex(mainnet.id)]).toBe("https://mainnet.example.test");
172
+ });
173
+
174
+ it("takes mainnet's URL from a mainnet transport the app gives without listing mainnet", async () => {
175
+ const connector = metaMaskConnect({ dapp: { name: "test", url: "https://example.test" } })({
176
+ chains: [baseSepolia],
177
+ emitter: { uid: "metaMaskSDK", emit: vi.fn(), on: vi.fn(), once: vi.fn(), off: vi.fn() },
178
+ transports: {
179
+ [baseSepolia.id]: http(),
180
+ [mainnet.id]: http("https://mainnet.example.test"),
181
+ },
182
+ } as never);
183
+ await connector.setup?.();
184
+
185
+ const options = createEVMClient.mock.calls[0]![0] as {
186
+ api: { supportedNetworks: Record<string, string> };
187
+ };
188
+ expect(options.api.supportedNetworks[hex(mainnet.id)]).toBe("https://mainnet.example.test");
189
+ });
190
+
191
+ it("still refuses to switch to mainnet when the app does not list it", async () => {
192
+ const connector = build([baseSepolia]);
193
+ await expect(connector.switchChain!({ chainId: mainnet.id })).rejects.toBeInstanceOf(
194
+ SwitchChainError,
195
+ );
196
+ expect(client.switchChain).not.toHaveBeenCalled();
197
+ });
198
+
125
199
  it("refuses to switch MetaMask to a synthetic chain", async () => {
126
200
  const connector = build([mainnet, ...syntheticChains]);
127
201
  await expect(connector.switchChain!({ chainId: SUI_CHAIN_ID })).rejects.toBeInstanceOf(
@@ -147,7 +221,11 @@ describe("metaMaskConnect under wagmi", () => {
147
221
  client.accounts = [];
148
222
  });
149
223
 
150
- afterEach(() => resetMetaMaskConnectClient());
224
+ afterEach(() => {
225
+ resetMetaMaskConnectClient();
226
+ client.getChainId.mockImplementation(() => "0x2105");
227
+ client.getProvider.mockImplementation(() => ({ request: vi.fn() }));
228
+ });
151
229
 
152
230
  function wagmiConfig() {
153
231
  return createConfig({
@@ -221,6 +299,104 @@ describe("metaMaskConnect under wagmi", () => {
221
299
  expect(config.state.connections.get(connector.uid)?.accounts).toEqual([getAddress(second)]);
222
300
  });
223
301
 
302
+ it("signs on mainnet for a testnet app when the phone wallet granted only mainnet", async () => {
303
+ // MetaMask Connect refuses a request on a chain it was not given.
304
+ const request = vi.fn(async ({ method }: { method: string }) => {
305
+ const { api } = createEVMClient.mock.calls[0]![0] as {
306
+ api: { supportedNetworks: Record<string, string> };
307
+ };
308
+ const chainId = client.getChainId();
309
+ if (!api.supportedNetworks[chainId])
310
+ throw new Error(`Chain eip155:${Number(chainId)} is not configured in supportedNetworks`);
311
+ return method === "personal_sign" ? "0x5167" : null;
312
+ });
313
+ client.getChainId.mockReturnValue("0x1");
314
+ client.getProvider.mockReturnValue({ request } as never);
315
+ client.connect.mockImplementationOnce(async () => ({ accounts: [first], chainId: "0x1" }));
316
+ const config = createConfig({
317
+ chains: [baseSepolia],
318
+ connectors: [metaMaskConnect({ dapp: { name: "test", url: "https://example.test" } })],
319
+ transports: { [baseSepolia.id]: http() },
320
+ multiInjectedProviderDiscovery: false,
321
+ storage: null,
322
+ });
323
+
324
+ const connected = await connect(config, { connector: config.connectors[0]! });
325
+ const signature = await signMessage(config, { message: "prove it" });
326
+
327
+ expect(connected.chainId).toBe(mainnet.id);
328
+ expect(signature).toBe("0x5167");
329
+ expect(request.mock.calls.map(([call]) => call.method)).toContain("personal_sign");
330
+ });
331
+
332
+ it("sends no transaction or typed signature on mainnet for an app that does not list it", async () => {
333
+ const request = vi.fn(async ({ method }: { method: string }) =>
334
+ method === "eth_chainId" ? "0x1" : "0xabc",
335
+ );
336
+ client.getChainId.mockReturnValue("0x1");
337
+ client.getProvider.mockReturnValue({ request } as never);
338
+ client.connect.mockImplementationOnce(async () => ({ accounts: [first], chainId: "0x1" }));
339
+ const config = createConfig({
340
+ chains: [baseSepolia],
341
+ connectors: [metaMaskConnect({ dapp: { name: "test", url: "https://example.test" } })],
342
+ transports: { [baseSepolia.id]: http() },
343
+ multiInjectedProviderDiscovery: false,
344
+ storage: null,
345
+ });
346
+ await connect(config, { connector: config.connectors[0]! });
347
+
348
+ await expect(sendTransaction(config, { to: second, value: 1n, gas: 21000n })).rejects.toThrow();
349
+ await expect(
350
+ signTypedData(config, {
351
+ domain: { name: "t", chainId: 1 },
352
+ types: { Mail: [{ name: "body", type: "string" }] },
353
+ primaryType: "Mail",
354
+ message: { body: "x" },
355
+ }),
356
+ ).rejects.toThrow();
357
+
358
+ const sent = request.mock.calls.map(([call]) => call.method);
359
+ expect(sent).not.toContain("eth_sendTransaction");
360
+ expect(sent).not.toContain("eth_signTypedData_v4");
361
+ });
362
+
363
+ it("checks each config's own chains on the page's one provider, whichever wraps it first", async () => {
364
+ const request = vi.fn(async ({ method }: { method: string }) =>
365
+ method === "eth_chainId" ? "0x1" : "0xabc",
366
+ );
367
+ const provider = { request };
368
+ client.getChainId.mockReturnValue("0x1");
369
+ client.getProvider.mockReturnValue(provider as never);
370
+ const configFor = (chains: readonly [Chain, ...Chain[]]) =>
371
+ createConfig({
372
+ chains,
373
+ connectors: [metaMaskConnect({ dapp: { name: "test", url: "https://example.test" } })],
374
+ transports: Object.fromEntries(chains.map((chain) => [chain.id, http()])) as never,
375
+ multiInjectedProviderDiscovery: false,
376
+ storage: null,
377
+ });
378
+ const listsMainnet = configFor([mainnet, base]);
379
+ const testnetOnly = configFor([baseSepolia]);
380
+ const send = (config: ReturnType<typeof configFor>) =>
381
+ sendTransaction(config, { to: second, value: 1n, gas: 21000n });
382
+
383
+ client.connect.mockImplementation(async () => ({ accounts: [first], chainId: "0x1" }));
384
+ await connect(listsMainnet, { connector: listsMainnet.connectors[0]! });
385
+ await connect(testnetOnly, { connector: testnetOnly.connectors[0]! });
386
+
387
+ await expect(send(listsMainnet)).resolves.toBe("0xabc");
388
+ await expect(send(testnetOnly)).rejects.toThrow();
389
+ await expect(send(listsMainnet)).resolves.toBe("0xabc");
390
+ expect(
391
+ request.mock.calls.filter(([call]) => call.method === "eth_sendTransaction"),
392
+ ).toHaveLength(2);
393
+ client.connect.mockReset();
394
+ client.connect.mockImplementation(async () => ({
395
+ accounts: ["0x0000000000000000000000000000000000000001"],
396
+ chainId: "0x2105",
397
+ }));
398
+ });
399
+
224
400
  it("ignores a connect that carries no account", async () => {
225
401
  const config = wagmiConfig();
226
402
  const events = await clientEvents();
@@ -23,6 +23,7 @@ import {
23
23
  SwitchChainError,
24
24
  UserRejectedRequestError,
25
25
  } from "viem";
26
+ import { mainnet } from "wagmi/chains";
26
27
 
27
28
  import { isSyntheticChainId } from "./syntheticChains";
28
29
 
@@ -59,6 +60,73 @@ export function metaMaskNetworks(
59
60
  return networks;
60
61
  }
61
62
 
63
+ /**
64
+ * `networks` and Ethereum mainnet, for createEVMClient. MetaMask Connect adds
65
+ * mainnet to every connect, and a phone wallet with none of the app's chains
66
+ * enabled grants only mainnet. The session then sits on chain 1, and MetaMask
67
+ * Connect refuses every request on a chain it was not given, personal_sign
68
+ * included, so the wallet could never prove itself (PROMA-121).
69
+ */
70
+ export function metaMaskSupportedNetworks(
71
+ networks: Record<Hex, string>,
72
+ transports?: ConnectorConfig["transports"],
73
+ ): Record<Hex, string> {
74
+ const id = numberToHex(mainnet.id);
75
+ if (networks[id]) return networks;
76
+ return {
77
+ ...networks,
78
+ [id]: metaMaskNetworks([mainnet], transports)[id] ?? mainnet.rpcUrls.default.http[0]!,
79
+ };
80
+ }
81
+
82
+ // What a session may still ask for on a chain the app does not list, such as
83
+ // the mainnet a phone wallet grants on its own: the wallet's bookkeeping, a
84
+ // switch to a listed chain, and personal_sign, which no chain scopes. A
85
+ // transaction or a typed signature there would reach a network the app never
86
+ // chose.
87
+ const offListMethods = new Set([
88
+ "personal_sign",
89
+ "eth_accounts",
90
+ "eth_coinbase",
91
+ "eth_chainId",
92
+ "eth_requestAccounts",
93
+ "net_version",
94
+ "wallet_getPermissions",
95
+ "wallet_requestPermissions",
96
+ "wallet_revokePermissions",
97
+ "wallet_switchEthereumChain",
98
+ "wallet_addEthereumChain",
99
+ "metamask_getProviderState",
100
+ "metamask_sendDomainMetadata",
101
+ "metamask_logWeb3ShimUsage",
102
+ "wallet_registerOnboarding",
103
+ ]);
104
+
105
+ function listedChainsOnly(
106
+ provider: EIP1193Provider,
107
+ mm: MetamaskConnectEVM,
108
+ networks: Record<Hex, string>,
109
+ ): EIP1193Provider {
110
+ const request = (async (args: { method: string; params?: unknown }) => {
111
+ const chainId = mm.getChainId();
112
+ if (chainId && !networks[numberToHex(Number(chainId))] && !offListMethods.has(args.method))
113
+ throw Object.assign(
114
+ new Error(
115
+ `MetaMask is on chain ${Number(chainId)}, which this app does not list, so ${args.method} was not sent`,
116
+ ),
117
+ { code: 4901 },
118
+ );
119
+ return provider.request(args as never);
120
+ }) as EIP1193Provider["request"];
121
+ return new Proxy(provider, {
122
+ get(target, property) {
123
+ if (property === "request") return request;
124
+ const value = Reflect.get(target, property);
125
+ return typeof value === "function" ? value.bind(target) : value;
126
+ },
127
+ });
128
+ }
129
+
62
130
  /**
63
131
  * The URL wagmi's transport for `chain` talks to, the first one of a `fallback`.
64
132
  * @wagmi/core's extractRpcUrls does this, and wagmi does not re-export it.
@@ -174,8 +242,10 @@ export function metaMaskConnect(parameters: MetaMaskConnectParameters) {
174
242
  return createConnector<EIP1193Provider>((config) => {
175
243
  owner ??= config.emitter;
176
244
  const networks = metaMaskNetworks(config.chains, config.transports);
245
+ const supportedNetworks = metaMaskSupportedNetworks(networks, config.transports);
177
246
  const fallbackChainId = Number(Object.keys(networks)[0] ?? 1);
178
- const instance = () => getClient(parameters, networks);
247
+ const instance = () => getClient(parameters, supportedNetworks);
248
+ let guarded: { provider: EIP1193Provider; wrapped: EIP1193Provider } | undefined;
179
249
  // Every call wagmi makes on the connector it uses claims the client's events;
180
250
  // setup() runs for every built config, so it does not.
181
251
  const claim = () => {
@@ -225,7 +295,13 @@ export function metaMaskConnect(parameters: MetaMaskConnectParameters) {
225
295
  return chainIdOf(await claim());
226
296
  },
227
297
  async getProvider() {
228
- return (await claim()).getProvider();
298
+ // The page has one provider but each config its own chains, so each
299
+ // connector wraps it for itself.
300
+ const mm = await claim();
301
+ const provider = mm.getProvider();
302
+ if (guarded?.provider !== provider)
303
+ guarded = { provider, wrapped: listedChainsOnly(provider, mm, networks) };
304
+ return guarded.wrapped;
229
305
  },
230
306
  async isAuthorized() {
231
307
  // createEVMClient has already resumed a stored session, so its cached
@@ -1,7 +1,7 @@
1
1
  import { CreateConnectorFn } from "wagmi";
2
2
  // Per-connector subpaths: the package root re-exports every wagmi connector, and a
3
3
  // bundler that doesn't tree-shake (the webview, the login bundles) ships them all.
4
- import { coinbaseWallet, type CoinbaseWalletParameters } from "wagmi/connectors/coinbaseWallet";
4
+ import type { CoinbaseWalletParameters } from "wagmi/connectors/coinbaseWallet";
5
5
  import { safe } from "wagmi/connectors/safe";
6
6
  import { metaMaskConnect } from "./connectors/metaMaskConnectConnector";
7
7
  import {
@@ -11,6 +11,7 @@ import {
11
11
 
12
12
  import type { EthereumProviderOptions as AaveAccountOptions } from "@aave/account";
13
13
  import { aaveAccountConnector } from "./connectors/aaveAccountConnector";
14
+ import { coinbaseWalletConnector } from "./connectors/coinbaseWalletConnector";
14
15
 
15
16
  type DefaultConnectorsProps = {
16
17
  app: {
@@ -59,7 +60,7 @@ const defaultConnectors = ({
59
60
  },
60
61
  ...(isEmbeddedDesktopWebview() ? metaMaskOptionsForEmbeddedDesktop() : {}),
61
62
  }),
62
- coinbaseWallet({
63
+ coinbaseWalletConnector({
63
64
  appName: app.name,
64
65
  appLogoUrl: app.icon,
65
66
  preference: {
@@ -0,0 +1,5 @@
1
+ import type { CreateConnectorFn } from "wagmi";
2
+ import { type CoinbaseWalletParameters } from "wagmi/connectors/coinbaseWallet";
3
+ export declare const coinbaseWalletConnectorId = "coinbaseWalletSDK";
4
+ export declare function coinbaseWalletConnector(parameters?: CoinbaseWalletParameters): CreateConnectorFn;
5
+ //# sourceMappingURL=coinbaseWalletConnector.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"coinbaseWalletConnector.d.ts","sourceRoot":"","sources":["../../../src/connectkit/connectors/coinbaseWalletConnector.ts"],"names":[],"mappings":"AAQA,OAAO,KAAK,EAAE,iBAAiB,EAAW,MAAM,OAAO,CAAC;AACxD,OAAO,EAAkB,KAAK,wBAAwB,EAAE,MAAM,iCAAiC,CAAC;AAEhG,eAAO,MAAM,yBAAyB,sBAAsB,CAAC;AAkB7D,wBAAgB,uBAAuB,CAAC,UAAU,CAAC,EAAE,wBAAwB,GAAG,iBAAiB,CAyChG"}
@@ -23,6 +23,14 @@ type ConnectorConfig = Parameters<CreateConnectorFn>[0];
23
23
  * kills connect() before it ever emits `display_uri`.
24
24
  */
25
25
  export declare function metaMaskNetworks(chains: readonly Chain[], transports?: ConnectorConfig["transports"]): Record<Hex, string>;
26
+ /**
27
+ * `networks` and Ethereum mainnet, for createEVMClient. MetaMask Connect adds
28
+ * mainnet to every connect, and a phone wallet with none of the app's chains
29
+ * enabled grants only mainnet. The session then sits on chain 1, and MetaMask
30
+ * Connect refuses every request on a chain it was not given, personal_sign
31
+ * included, so the wallet could never prove itself (PROMA-121).
32
+ */
33
+ export declare function metaMaskSupportedNetworks(networks: Record<Hex, string>, transports?: ConnectorConfig["transports"]): Record<Hex, string>;
26
34
  /** The chain ids a connect asks MetaMask for, the requested one first (it becomes the active chain). */
27
35
  export declare function metaMaskConnectChainIds(networks: Record<Hex, string>, chainId?: number): Hex[];
28
36
  /** Test seam: forget the page's client so the next connector builds a fresh one. */
@@ -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;AACjF,OAAO,EAA2B,KAAK,iBAAiB,EAAmB,MAAM,OAAO,CAAC;AACzF,OAAO,EAEL,KAAK,KAAK,EAEV,KAAK,GAAG,EAKT,MAAM,MAAM,CAAC;AAId,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;CACjB,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;AAwBD,wGAAwG;AACxG,wBAAgB,uBAAuB,CAAC,QAAQ,EAAE,MAAM,CAAC,GAAG,EAAE,MAAM,CAAC,EAAE,OAAO,CAAC,EAAE,MAAM,GAAG,GAAG,EAAE,CAK9F;AAoED,oFAAoF;AACpF,wBAAgB,0BAA0B,IAAI,IAAI,CAGjD;AAWD,wBAAgB,eAAe,CAAC,UAAU,EAAE,yBAAyB,wFA2GpE;yBA3Ge,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;AACjF,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;CACjB,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;AAwED,wGAAwG;AACxG,wBAAgB,uBAAuB,CAAC,QAAQ,EAAE,MAAM,CAAC,GAAG,EAAE,MAAM,CAAC,EAAE,OAAO,CAAC,EAAE,MAAM,GAAG,GAAG,EAAE,CAK9F;AAoED,oFAAoF;AACpF,wBAAgB,0BAA0B,IAAI,IAAI,CAGjD;AAWD,wBAAgB,eAAe,CAAC,UAAU,EAAE,yBAAyB,wFAmHpE;yBAnHe,eAAe"}
@@ -1,5 +1,5 @@
1
1
  import { CreateConnectorFn } from "wagmi";
2
- import { type CoinbaseWalletParameters } from "wagmi/connectors/coinbaseWallet";
2
+ import type { CoinbaseWalletParameters } from "wagmi/connectors/coinbaseWallet";
3
3
  import type { EthereumProviderOptions as AaveAccountOptions } from "@aave/account";
4
4
  type DefaultConnectorsProps = {
5
5
  app: {
@@ -1 +1 @@
1
- {"version":3,"file":"defaultConnectors.d.ts","sourceRoot":"","sources":["../../src/connectkit/defaultConnectors.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,iBAAiB,EAAE,MAAM,OAAO,CAAC;AAG1C,OAAO,EAAkB,KAAK,wBAAwB,EAAE,MAAM,iCAAiC,CAAC;AAQhG,OAAO,KAAK,EAAE,uBAAuB,IAAI,kBAAkB,EAAE,MAAM,eAAe,CAAC;AAGnF,KAAK,sBAAsB,GAAG;IAC5B,GAAG,EAAE;QACH,IAAI,EAAE,MAAM,CAAC;QACb,IAAI,CAAC,EAAE,MAAM,CAAC;QACd,WAAW,CAAC,EAAE,MAAM,CAAC;QACrB,GAAG,CAAC,EAAE,MAAM,CAAC;KACd,CAAC;IACF,wBAAwB,CAAC,EAAE,wBAAwB,CAAC,GAAG,CAAC,CAAC,YAAY,CAAC,CAAC;IACvE,iBAAiB,CAAC,EAAE,OAAO,CAAC;IAC5B,kBAAkB,CAAC,EAAE,kBAAkB,CAAC;CACzC,CAAC;AAEF,QAAA,MAAM,iBAAiB,GAAI,2EAKxB,sBAAsB,KAAG,iBAAiB,EAkD5C,CAAC;AAEF,eAAe,iBAAiB,CAAC"}
1
+ {"version":3,"file":"defaultConnectors.d.ts","sourceRoot":"","sources":["../../src/connectkit/defaultConnectors.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,iBAAiB,EAAE,MAAM,OAAO,CAAC;AAG1C,OAAO,KAAK,EAAE,wBAAwB,EAAE,MAAM,iCAAiC,CAAC;AAQhF,OAAO,KAAK,EAAE,uBAAuB,IAAI,kBAAkB,EAAE,MAAM,eAAe,CAAC;AAInF,KAAK,sBAAsB,GAAG;IAC5B,GAAG,EAAE;QACH,IAAI,EAAE,MAAM,CAAC;QACb,IAAI,CAAC,EAAE,MAAM,CAAC;QACd,WAAW,CAAC,EAAE,MAAM,CAAC;QACrB,GAAG,CAAC,EAAE,MAAM,CAAC;KACd,CAAC;IACF,wBAAwB,CAAC,EAAE,wBAAwB,CAAC,GAAG,CAAC,CAAC,YAAY,CAAC,CAAC;IACvE,iBAAiB,CAAC,EAAE,OAAO,CAAC;IAC5B,kBAAkB,CAAC,EAAE,kBAAkB,CAAC;CACzC,CAAC;AAEF,QAAA,MAAM,iBAAiB,GAAI,2EAKxB,sBAAsB,KAAG,iBAAiB,EAkD5C,CAAC;AAEF,eAAe,iBAAiB,CAAC"}