@multiplatform.one/web3 7.32.0 → 7.33.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 (61) hide show
  1. package/README.md +21 -0
  2. package/dist/cjs/connectkit/connectors/metaMaskConnectConnector.cjs +14 -3
  3. package/dist/cjs/connectkit/connectors/metaMaskConnectConnector.native.js +14 -3
  4. package/dist/cjs/connectkit/connectors/metaMaskConnectConnector.native.js.map +1 -1
  5. package/dist/cjs/phantom/evm.cjs +133 -0
  6. package/dist/cjs/phantom/evm.native.js +269 -0
  7. package/dist/cjs/phantom/evm.native.js.map +1 -0
  8. package/dist/cjs/phantom/index.cjs +36 -0
  9. package/dist/cjs/phantom/index.native.js +39 -0
  10. package/dist/cjs/phantom/index.native.js.map +1 -0
  11. package/dist/cjs/phantom/urls.cjs +33 -0
  12. package/dist/cjs/phantom/urls.native.js +38 -0
  13. package/dist/cjs/phantom/urls.native.js.map +1 -0
  14. package/dist/esm/connectkit/connectors/metaMaskConnectConnector.mjs +14 -3
  15. package/dist/esm/connectkit/connectors/metaMaskConnectConnector.mjs.map +1 -1
  16. package/dist/esm/connectkit/connectors/metaMaskConnectConnector.native.js +14 -3
  17. package/dist/esm/connectkit/connectors/metaMaskConnectConnector.native.js.map +1 -1
  18. package/dist/esm/phantom/evm.mjs +103 -0
  19. package/dist/esm/phantom/evm.mjs.map +1 -0
  20. package/dist/esm/phantom/evm.native.js +236 -0
  21. package/dist/esm/phantom/evm.native.js.map +1 -0
  22. package/dist/esm/phantom/index.mjs +4 -0
  23. package/dist/esm/phantom/index.mjs.map +1 -0
  24. package/dist/esm/phantom/index.native.js +4 -0
  25. package/dist/esm/phantom/index.native.js.map +1 -0
  26. package/dist/esm/phantom/urls.mjs +7 -0
  27. package/dist/esm/phantom/urls.mjs.map +1 -0
  28. package/dist/esm/phantom/urls.native.js +9 -0
  29. package/dist/esm/phantom/urls.native.js.map +1 -0
  30. package/dist/jsx/connectkit/connectors/metaMaskConnectConnector.mjs +14 -3
  31. package/dist/jsx/connectkit/connectors/metaMaskConnectConnector.mjs.map +1 -1
  32. package/dist/jsx/connectkit/connectors/metaMaskConnectConnector.native.js +14 -3
  33. package/dist/jsx/connectkit/connectors/metaMaskConnectConnector.native.js.map +1 -1
  34. package/dist/jsx/phantom/evm.mjs +103 -0
  35. package/dist/jsx/phantom/evm.mjs.map +1 -0
  36. package/dist/jsx/phantom/evm.native.js +269 -0
  37. package/dist/jsx/phantom/evm.native.js.map +1 -0
  38. package/dist/jsx/phantom/index.mjs +4 -0
  39. package/dist/jsx/phantom/index.mjs.map +1 -0
  40. package/dist/jsx/phantom/index.native.js +39 -0
  41. package/dist/jsx/phantom/index.native.js.map +1 -0
  42. package/dist/jsx/phantom/urls.mjs +7 -0
  43. package/dist/jsx/phantom/urls.mjs.map +1 -0
  44. package/dist/jsx/phantom/urls.native.js +38 -0
  45. package/dist/jsx/phantom/urls.native.js.map +1 -0
  46. package/dist/keycloak/login.iife.js +63 -63
  47. package/package.json +12 -6
  48. package/src/connectkit/connectors/metaMaskConnectConnector.spec.ts +44 -0
  49. package/src/connectkit/connectors/metaMaskConnectConnector.ts +30 -2
  50. package/src/phantom/evm.spec.ts +195 -0
  51. package/src/phantom/evm.ts +163 -0
  52. package/src/phantom/index.ts +16 -0
  53. package/src/phantom/urls.spec.ts +29 -0
  54. package/src/phantom/urls.ts +16 -0
  55. package/types/connectkit/connectors/metaMaskConnectConnector.d.ts.map +1 -1
  56. package/types/phantom/evm.d.ts +76 -0
  57. package/types/phantom/evm.d.ts.map +1 -0
  58. package/types/phantom/index.d.ts +3 -0
  59. package/types/phantom/index.d.ts.map +1 -0
  60. package/types/phantom/urls.d.ts +11 -0
  61. package/types/phantom/urls.d.ts.map +1 -0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@multiplatform.one/web3",
3
- "version": "7.32.0",
3
+ "version": "7.33.1",
4
4
  "description": "Web3 wallet integration for multiplatform.one ecosystem with ConnectKit and wagmi",
5
5
  "keywords": [
6
6
  "connectkit",
@@ -105,6 +105,12 @@
105
105
  "import": "./dist/esm/solana.mjs",
106
106
  "require": "./dist/cjs/solana.cjs"
107
107
  },
108
+ "./phantom": {
109
+ "source": "./src/phantom/index.ts",
110
+ "types": "./types/phantom/index.d.ts",
111
+ "import": "./dist/esm/phantom/index.mjs",
112
+ "require": "./dist/cjs/phantom/index.cjs"
113
+ },
108
114
  "./keycloak/login.iife.js": "./dist/keycloak/login.iife.js"
109
115
  },
110
116
  "publishConfig": {
@@ -114,10 +120,10 @@
114
120
  "@aave/account": "^0.2.0",
115
121
  "@coinbase/wallet-sdk": "^4.3.6",
116
122
  "@metamask/connect-evm": "^2.1.1",
117
- "@multiplatform.one/connectkit": "7.32.0",
118
- "@multiplatform.one/gpg": "7.32.0",
119
- "@multiplatform.one/platform": "7.32.0",
120
- "@multiplatform.one/theme": "7.32.0",
123
+ "@multiplatform.one/connectkit": "7.33.1",
124
+ "@multiplatform.one/gpg": "7.33.1",
125
+ "@multiplatform.one/platform": "7.33.1",
126
+ "@multiplatform.one/theme": "7.33.1",
121
127
  "@noble/curves": "^1.9.7",
122
128
  "@solana/kit": "^8.3.0",
123
129
  "@solana/wallet-adapter-base": "^0.9.27",
@@ -139,7 +145,7 @@
139
145
  "wagmi": "^3.7.1"
140
146
  },
141
147
  "devDependencies": {
142
- "@multiplatform.one/config": "7.32.0",
148
+ "@multiplatform.one/config": "7.33.1",
143
149
  "@tamagui/build": "2.7.6",
144
150
  "@tanstack/react-query": "^5.100.7",
145
151
  "@testing-library/jest-dom": "^6.9.1",
@@ -339,6 +339,50 @@ describe("metaMaskConnect under wagmi", () => {
339
339
  expect(request.mock.calls.map(([call]) => call.method)).toContain("personal_sign");
340
340
  });
341
341
 
342
+ it("signs under mainnet's scope when the desktop extension sits on a chain the app does not list", async () => {
343
+ // As @metamask/connect-evm's EIP1193Provider: the scope comes from
344
+ // selectedChainId before the first await, and a chain outside
345
+ // supportedNetworks is refused there.
346
+ const scopes: string[] = [];
347
+ const provider = {
348
+ selectedChainId: "0x2105" as string | undefined,
349
+ request: vi.fn(({ method }: { method: string }) => {
350
+ const chainId = provider.selectedChainId!;
351
+ const { api } = createEVMClient.mock.calls[0]![0] as {
352
+ api: { supportedNetworks: Record<string, string> };
353
+ };
354
+ if (!api.supportedNetworks[chainId])
355
+ return Promise.reject(
356
+ new Error(`Chain eip155:${Number(chainId)} is not configured in supportedNetworks`),
357
+ );
358
+ scopes.push(`eip155:${Number(chainId)}`);
359
+ return Promise.resolve(method === "personal_sign" ? "0x5167" : "0xabc");
360
+ }),
361
+ };
362
+ client.getChainId.mockImplementation(() => provider.selectedChainId!);
363
+ client.getProvider.mockReturnValue(provider as never);
364
+ client.connect.mockImplementationOnce(async () => ({ accounts: [first], chainId: "0x1" }));
365
+ const config = createConfig({
366
+ chains: [polygon, mainnet],
367
+ connectors: [metaMaskConnect({ dapp: { name: "test", url: "https://example.test" } })],
368
+ transports: { [polygon.id]: http(), [mainnet.id]: http() },
369
+ multiInjectedProviderDiscovery: false,
370
+ storage: null,
371
+ });
372
+ await connect(config, { connector: config.connectors[0]! });
373
+ (await clientEvents()).chainChanged("0x2105");
374
+
375
+ const signature = await signMessage(config, { message: "prove it" });
376
+
377
+ expect(signature).toBe("0x5167");
378
+ expect(scopes).toEqual(["eip155:1"]);
379
+ expect(provider.selectedChainId).toBe("0x2105");
380
+ await expect(sendTransaction(config, { to: second, value: 1n, gas: 21000n })).rejects.toThrow();
381
+ expect(provider.request.mock.calls.map(([call]) => call.method)).not.toContain(
382
+ "eth_sendTransaction",
383
+ );
384
+ });
385
+
342
386
  it("sends no transaction or typed signature on mainnet for an app that does not list it", async () => {
343
387
  const request = vi.fn(async ({ method }: { method: string }) =>
344
388
  method === "eth_chainId" ? "0x1" : "0xabc",
@@ -115,20 +115,45 @@ const offListMethods = new Set([
115
115
  "wallet_registerOnboarding",
116
116
  ]);
117
117
 
118
+ /**
119
+ * MetaMask Connect scopes a request to the provider's selected chain and refuses
120
+ * one outside supportedNetworks. The desktop extension moves that chain to
121
+ * wherever the user's MetaMask sits, Base say, so personal_sign there never
122
+ * reached the wallet (MPO-494). It goes under mainnet's scope instead, which
123
+ * MetaMask Connect adds to every session. The provider reads the scope before
124
+ * its first await, so the selected chain is put back before anything else runs.
125
+ */
126
+ function signUnderMainnet(
127
+ provider: EIP1193Provider,
128
+ args: { method: string; params?: unknown },
129
+ ): Promise<unknown> {
130
+ const selected = provider.selectedChainId;
131
+ provider.selectedChainId = numberToHex(mainnet.id);
132
+ try {
133
+ return provider.request(args as never);
134
+ } finally {
135
+ provider.selectedChainId = selected;
136
+ }
137
+ }
138
+
118
139
  function listedChainsOnly(
119
140
  provider: EIP1193Provider,
120
141
  mm: MetamaskConnectEVM,
121
142
  networks: Record<Hex, string>,
143
+ supportedNetworks: Record<Hex, string>,
122
144
  ): EIP1193Provider {
123
145
  const request = (async (args: { method: string; params?: unknown }) => {
124
146
  const chainId = mm.getChainId();
125
- if (chainId && !networks[numberToHex(Number(chainId))] && !offListMethods.has(args.method))
147
+ const current = chainId ? numberToHex(Number(chainId)) : undefined;
148
+ if (current && !networks[current] && !offListMethods.has(args.method))
126
149
  throw Object.assign(
127
150
  new Error(
128
151
  `MetaMask is on chain ${Number(chainId)}, which this app does not list, so ${args.method} was not sent`,
129
152
  ),
130
153
  { code: 4901 },
131
154
  );
155
+ if (current && !supportedNetworks[current] && args.method === "personal_sign")
156
+ return signUnderMainnet(provider, args);
132
157
  return provider.request(args as never);
133
158
  }) as EIP1193Provider["request"];
134
159
  return new Proxy(provider, {
@@ -383,7 +408,10 @@ export function metaMaskConnect(parameters: MetaMaskConnectParameters) {
383
408
  const mm = await claim();
384
409
  const provider = mm.getProvider();
385
410
  if (guarded?.provider !== provider)
386
- guarded = { provider, wrapped: listedChainsOnly(provider, mm, networks) };
411
+ guarded = {
412
+ provider,
413
+ wrapped: listedChainsOnly(provider, mm, networks, supportedNetworks),
414
+ };
387
415
  return guarded.wrapped;
388
416
  },
389
417
  async isAuthorized() {
@@ -0,0 +1,195 @@
1
+ import { baseSepolia } from "viem/chains";
2
+ import { describe, expect, it, vi } from "vitest";
3
+ import {
4
+ connectPhantomEvm,
5
+ discoverPhantomEthereumProvider,
6
+ openInPhantom,
7
+ phantomEthereumProvider,
8
+ phantomEvmRoute,
9
+ PhantomNotInjectedError,
10
+ type PhantomEthereumProvider,
11
+ } from "./evm";
12
+
13
+ const account = "0x1111111111111111111111111111111111111111";
14
+
15
+ function fakeProvider(chainId: number, opts: { unknownChain?: boolean } = {}) {
16
+ let current = chainId;
17
+ let known = !opts.unknownChain;
18
+ const calls: { method: string; params?: unknown }[] = [];
19
+ const request = vi.fn(async ({ method, params }: { method: string; params?: unknown }) => {
20
+ calls.push({ method, params });
21
+ switch (method) {
22
+ case "eth_requestAccounts":
23
+ case "eth_accounts":
24
+ return [account];
25
+ case "eth_chainId":
26
+ return `0x${current.toString(16)}`;
27
+ case "wallet_switchEthereumChain": {
28
+ if (!known) throw Object.assign(new Error("Unrecognized chain"), { code: 4902 });
29
+ current = Number((params as [{ chainId: string }])[0].chainId);
30
+ return null;
31
+ }
32
+ case "wallet_addEthereumChain":
33
+ known = true;
34
+ return null;
35
+ default:
36
+ throw new Error(`unexpected ${method}`);
37
+ }
38
+ });
39
+ const provider = { isPhantom: true, request, on: vi.fn(), removeListener: vi.fn() };
40
+ return { provider: provider as unknown as PhantomEthereumProvider, calls };
41
+ }
42
+
43
+ describe("phantom evm", () => {
44
+ it("finds window.phantom.ethereum, then a Phantom window.ethereum, and nothing else", () => {
45
+ const own = { isPhantom: true } as unknown as PhantomEthereumProvider;
46
+ const other = { isMetaMask: true } as unknown as PhantomEthereumProvider;
47
+ expect(phantomEthereumProvider({ phantom: { ethereum: own }, ethereum: other })).toBe(own);
48
+ expect(phantomEthereumProvider({ ethereum: own })).toBe(own);
49
+ expect(phantomEthereumProvider({ ethereum: other })).toBeUndefined();
50
+ expect(phantomEvmRoute({ ethereum: other })).toBe("browse");
51
+ expect(phantomEvmRoute({ phantom: { ethereum: own } })).toBe("injected");
52
+ });
53
+
54
+ it("takes Phantom's EIP-6963 announcement", async () => {
55
+ const provider = { isPhantom: true } as unknown as PhantomEthereumProvider;
56
+ const target = new EventTarget();
57
+ target.addEventListener("eip6963:requestProvider", () => {
58
+ target.dispatchEvent(
59
+ Object.assign(new Event("eip6963:announceProvider"), {
60
+ detail: { info: { rdns: "io.metamask" }, provider: {} },
61
+ }),
62
+ );
63
+ target.dispatchEvent(
64
+ Object.assign(new Event("eip6963:announceProvider"), {
65
+ detail: { info: { rdns: "app.phantom" }, provider },
66
+ }),
67
+ );
68
+ });
69
+ const win = {
70
+ addEventListener: target.addEventListener.bind(target),
71
+ removeEventListener: target.removeEventListener.bind(target),
72
+ dispatchEvent: target.dispatchEvent.bind(target),
73
+ };
74
+ await expect(discoverPhantomEthereumProvider({ window: win as never })).resolves.toBe(provider);
75
+ });
76
+
77
+ it("resolves undefined when nothing announces", async () => {
78
+ const target = new EventTarget();
79
+ const win = {
80
+ addEventListener: target.addEventListener.bind(target),
81
+ removeEventListener: target.removeEventListener.bind(target),
82
+ dispatchEvent: target.dispatchEvent.bind(target),
83
+ };
84
+ await expect(
85
+ discoverPhantomEthereumProvider({ window: win as never, timeout: 5 }),
86
+ ).resolves.toBeUndefined();
87
+ });
88
+
89
+ it("opens this page in Phantom's browser synchronously", () => {
90
+ const assign = vi.fn();
91
+ const link = openInPhantom({
92
+ window: {
93
+ location: {
94
+ href: "https://dev.promanager.vip/u/buy?mobile=1",
95
+ origin: "https://dev.promanager.vip",
96
+ assign,
97
+ },
98
+ },
99
+ });
100
+ expect(link).toBe(
101
+ "https://phantom.com/ul/browse/https%3A%2F%2Fdev.promanager.vip%2Fu%2Fbuy%3Fmobile%3D1?ref=https%3A%2F%2Fdev.promanager.vip",
102
+ );
103
+ expect(assign).toHaveBeenCalledWith(link);
104
+ });
105
+
106
+ it("connects, switches to Base Sepolia, and returns a client on it", async () => {
107
+ const { provider, calls } = fakeProvider(1);
108
+ const client = await connectPhantomEvm({ chain: baseSepolia, provider });
109
+ expect(client.address.toLowerCase()).toBe(account);
110
+ expect(client.chainId).toBe(84532);
111
+ expect(calls.map((c) => c.method)).toEqual([
112
+ "eth_requestAccounts",
113
+ "eth_chainId",
114
+ "wallet_switchEthereumChain",
115
+ ]);
116
+ expect(calls[2].params).toEqual([{ chainId: "0x14a34" }]);
117
+ });
118
+
119
+ it("adds the chain when Phantom does not know it", async () => {
120
+ const { provider, calls } = fakeProvider(1, { unknownChain: true });
121
+ await connectPhantomEvm({ chain: baseSepolia, provider });
122
+ expect(calls.map((c) => c.method)).toEqual([
123
+ "eth_requestAccounts",
124
+ "eth_chainId",
125
+ "wallet_switchEthereumChain",
126
+ "wallet_addEthereumChain",
127
+ "wallet_switchEthereumChain",
128
+ ]);
129
+ expect((calls[3].params as [{ chainId: string; rpcUrls: string[] }])[0]).toMatchObject({
130
+ chainId: "0x14a34",
131
+ rpcUrls: [...baseSepolia.rpcUrls.default.http],
132
+ });
133
+ });
134
+
135
+ it("skips the switch when already on the chain", async () => {
136
+ const { provider, calls } = fakeProvider(84532);
137
+ await connectPhantomEvm({ chain: baseSepolia, provider });
138
+ expect(calls.map((c) => c.method)).toEqual(["eth_requestAccounts", "eth_chainId"]);
139
+ });
140
+
141
+ it("sends an ERC-20 transfer to Phantom as eth_sendTransaction", async () => {
142
+ const { provider } = fakeProvider(84532);
143
+ const sent: unknown[] = [];
144
+ const request = provider.request as unknown as ReturnType<typeof vi.fn>;
145
+ const base = request.getMockImplementation() as (a: {
146
+ method: string;
147
+ params?: unknown;
148
+ }) => Promise<unknown>;
149
+ request.mockImplementation(async (args: { method: string; params?: unknown }) => {
150
+ if (args.method === "eth_sendTransaction") {
151
+ sent.push(args.params);
152
+ return `0x${"ab".repeat(32)}`;
153
+ }
154
+ return base(args);
155
+ });
156
+ const client = await connectPhantomEvm({ chain: baseSepolia, provider });
157
+ const hash = await client.walletClient.writeContract({
158
+ address: "0xc6fe3521644e0f5439fe345f64d372cd6ed37491",
159
+ abi: [
160
+ {
161
+ type: "function",
162
+ name: "transfer",
163
+ stateMutability: "nonpayable",
164
+ inputs: [
165
+ { name: "to", type: "address" },
166
+ { name: "amount", type: "uint256" },
167
+ ],
168
+ outputs: [{ type: "bool" }],
169
+ },
170
+ ],
171
+ functionName: "transfer",
172
+ args: ["0x2222222222222222222222222222222222222222", 10n ** 18n],
173
+ gas: 100000n,
174
+ });
175
+ expect(hash).toBe(`0x${"ab".repeat(32)}`);
176
+ expect(sent[0]).toEqual([
177
+ expect.objectContaining({
178
+ from: account,
179
+ to: "0xc6fe3521644e0f5439fe345f64d372cd6ed37491",
180
+ data: expect.stringMatching(/^0xa9059cbb0{24}2{40}/),
181
+ }),
182
+ ]);
183
+ });
184
+
185
+ it("says to reopen in Phantom when nothing is injected", async () => {
186
+ vi.stubGlobal("phantom", undefined);
187
+ try {
188
+ await expect(connectPhantomEvm({ chain: baseSepolia })).rejects.toBeInstanceOf(
189
+ PhantomNotInjectedError,
190
+ );
191
+ } finally {
192
+ vi.unstubAllGlobals();
193
+ }
194
+ });
195
+ });
@@ -0,0 +1,163 @@
1
+ import { numberToHex, type Chain, type EIP1193Provider, type PublicClient } from "viem";
2
+ import { evmClient, type EvmClient } from "../clients/evm";
3
+ import { phantomBrowseUrl } from "./urls";
4
+
5
+ export type PhantomEthereumProvider = EIP1193Provider & { isPhantom?: boolean };
6
+
7
+ interface PhantomWindow {
8
+ phantom?: { ethereum?: PhantomEthereumProvider };
9
+ ethereum?: PhantomEthereumProvider;
10
+ location?: { href: string; origin: string; assign(url: string): void };
11
+ addEventListener?: (type: string, listener: (event: Event) => void) => void;
12
+ removeEventListener?: (type: string, listener: (event: Event) => void) => void;
13
+ dispatchEvent?: (event: Event) => boolean;
14
+ }
15
+
16
+ const phantomRdns = "app.phantom";
17
+
18
+ /**
19
+ * Phantom's injected EVM provider: `window.phantom.ethereum`, else a
20
+ * `window.ethereum` that says it is Phantom. Present in Phantom's desktop
21
+ * extension and its in-app browser, never in Chrome or Safari on a phone.
22
+ */
23
+ export function phantomEthereumProvider(
24
+ win: PhantomWindow = globalThis as PhantomWindow,
25
+ ): PhantomEthereumProvider | undefined {
26
+ const own = win.phantom?.ethereum;
27
+ if (own?.isPhantom) return own;
28
+ if (win.ethereum?.isPhantom) return win.ethereum;
29
+ return undefined;
30
+ }
31
+
32
+ /**
33
+ * phantomEthereumProvider, or Phantom's EIP-6963 announcement (rdns
34
+ * `app.phantom`) when the provider is not on `window` yet. Resolves undefined
35
+ * after `timeout` ms.
36
+ */
37
+ export function discoverPhantomEthereumProvider(
38
+ options: { timeout?: number; window?: PhantomWindow } = {},
39
+ ): Promise<PhantomEthereumProvider | undefined> {
40
+ const win = options.window ?? (globalThis as PhantomWindow);
41
+ const found = phantomEthereumProvider(win);
42
+ if (found || !win.addEventListener || !win.dispatchEvent) return Promise.resolve(found);
43
+ return new Promise((resolve) => {
44
+ const onAnnounce = (event: Event) => {
45
+ const detail = (
46
+ event as CustomEvent<{ info?: { rdns?: string }; provider?: PhantomEthereumProvider }>
47
+ ).detail;
48
+ if (detail?.info?.rdns !== phantomRdns || !detail.provider) return;
49
+ done(detail.provider);
50
+ };
51
+ const timer = setTimeout(() => done(phantomEthereumProvider(win)), options.timeout ?? 500);
52
+ const done = (provider: PhantomEthereumProvider | undefined) => {
53
+ clearTimeout(timer);
54
+ win.removeEventListener?.("eip6963:announceProvider", onAnnounce);
55
+ resolve(provider);
56
+ };
57
+ win.addEventListener?.("eip6963:announceProvider", onAnnounce);
58
+ win.dispatchEvent?.(new Event("eip6963:requestProvider"));
59
+ });
60
+ }
61
+
62
+ /**
63
+ * How this page reaches Phantom's EVM wallet: `injected` when Phantom's
64
+ * provider is here (extension or Phantom's browser), `browse` when the page
65
+ * must first reopen inside Phantom with openInPhantom.
66
+ */
67
+ export function phantomEvmRoute(win: PhantomWindow = globalThis as PhantomWindow) {
68
+ return phantomEthereumProvider(win) ? ("injected" as const) : ("browse" as const);
69
+ }
70
+
71
+ export interface OpenInPhantomOptions {
72
+ /** The page to open in Phantom's browser. Defaults to this page. */
73
+ url?: string;
74
+ /** The requesting app. Defaults to this page's origin. */
75
+ ref?: string;
76
+ /** Defaults to `location.assign`. */
77
+ navigate?: (url: string) => void;
78
+ base?: string;
79
+ window?: PhantomWindow;
80
+ }
81
+
82
+ /**
83
+ * Reopens the page inside Phantom's in-app browser. Call it straight from the
84
+ * tap handler with nothing awaited first: mobile browsers only follow a link
85
+ * into another app without a prompt inside the tap's user activation.
86
+ * Returns the link, for an `<a href>` instead.
87
+ */
88
+ export function openInPhantom(options: OpenInPhantomOptions = {}): string {
89
+ const win = options.window ?? (globalThis as PhantomWindow);
90
+ const url = options.url ?? win.location?.href;
91
+ const ref = options.ref ?? win.location?.origin ?? url;
92
+ if (!url || !ref) throw new Error("openInPhantom: no page URL here; pass `url` and `ref`");
93
+ const link = phantomBrowseUrl(url, ref, { base: options.base });
94
+ (options.navigate ?? ((target: string) => win.location?.assign(target)))(link);
95
+ return link;
96
+ }
97
+
98
+ export interface ConnectPhantomEvmOptions {
99
+ chain: Chain;
100
+ /** Defaults to phantomEthereumProvider(). */
101
+ provider?: PhantomEthereumProvider;
102
+ publicClient?: PublicClient;
103
+ rpcUrl?: string;
104
+ }
105
+
106
+ export class PhantomNotInjectedError extends Error {
107
+ constructor() {
108
+ super(
109
+ "Phantom's EVM provider is not on this page: on a phone, reopen it in Phantom with openInPhantom()",
110
+ );
111
+ this.name = "PhantomNotInjectedError";
112
+ }
113
+ }
114
+
115
+ const errorCode = (error: unknown) =>
116
+ (error as { code?: unknown; cause?: { code?: unknown } } | undefined)?.code ??
117
+ (error as { cause?: { code?: unknown } } | undefined)?.cause?.code;
118
+
119
+ async function ensureChain(provider: PhantomEthereumProvider, chain: Chain) {
120
+ const current = await provider.request({ method: "eth_chainId" });
121
+ if (Number(current) === chain.id) return;
122
+ const chainId = numberToHex(chain.id);
123
+ try {
124
+ await provider.request({ method: "wallet_switchEthereumChain", params: [{ chainId }] });
125
+ } catch (error) {
126
+ if (errorCode(error) !== 4902) throw error;
127
+ await provider.request({
128
+ method: "wallet_addEthereumChain",
129
+ params: [
130
+ {
131
+ chainId,
132
+ chainName: chain.name,
133
+ nativeCurrency: chain.nativeCurrency,
134
+ rpcUrls: [...chain.rpcUrls.default.http],
135
+ blockExplorerUrls: chain.blockExplorers ? [chain.blockExplorers.default.url] : undefined,
136
+ },
137
+ ],
138
+ });
139
+ await provider.request({ method: "wallet_switchEthereumChain", params: [{ chainId }] });
140
+ }
141
+ }
142
+
143
+ /**
144
+ * Connects Phantom's injected EVM wallet, moves it to `chain`, and returns
145
+ * the mpo EVM client on it, so `erc20.transfer` and `writeContract` go to
146
+ * Phantom for approval. Testnets such as Base Sepolia need Phantom's Testnet
147
+ * Mode on.
148
+ */
149
+ export async function connectPhantomEvm(options: ConnectPhantomEvmOptions): Promise<EvmClient> {
150
+ const provider = options.provider ?? phantomEthereumProvider();
151
+ if (!provider) throw new PhantomNotInjectedError();
152
+ const accounts = await provider.request({ method: "eth_requestAccounts" });
153
+ const account = accounts[0];
154
+ if (!account) throw new Error("Phantom returned no EVM account");
155
+ await ensureChain(provider, options.chain);
156
+ return evmClient({
157
+ provider,
158
+ account,
159
+ chain: options.chain,
160
+ publicClient: options.publicClient,
161
+ rpcUrl: options.rpcUrl,
162
+ });
163
+ }
@@ -0,0 +1,16 @@
1
+ // `@multiplatform.one/web3/phantom`: Phantom's EVM wallet from a phone's own
2
+ // browser. Phantom's deeplinks carry no EVM methods, so the page reopens in
3
+ // Phantom's in-app browser (openInPhantom) and connects the provider Phantom
4
+ // injects there (connectPhantomEvm).
5
+ export {
6
+ connectPhantomEvm,
7
+ discoverPhantomEthereumProvider,
8
+ openInPhantom,
9
+ phantomEthereumProvider,
10
+ phantomEvmRoute,
11
+ PhantomNotInjectedError,
12
+ type ConnectPhantomEvmOptions,
13
+ type OpenInPhantomOptions,
14
+ type PhantomEthereumProvider,
15
+ } from "./evm";
16
+ export { phantomBrowseUrl, phantomUniversalLinkBase } from "./urls";
@@ -0,0 +1,29 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { phantomBrowseUrl } from "./urls";
3
+
4
+ describe("phantomBrowseUrl", () => {
5
+ it("builds the browse link Phantom documents", () => {
6
+ expect(
7
+ phantomBrowseUrl(
8
+ "https://magiceden.io/item-details/8yjN8iRuoiYiKW487cnW9vn6mLBR5E8aCNKsBRmTP9vN",
9
+ "https://magiceden.io",
10
+ ),
11
+ ).toBe(
12
+ "https://phantom.com/ul/browse/https%3A%2F%2Fmagiceden.io%2Fitem-details%2F8yjN8iRuoiYiKW487cnW9vn6mLBR5E8aCNKsBRmTP9vN?ref=https%3A%2F%2Fmagiceden.io",
13
+ );
14
+ });
15
+
16
+ it("keeps a page's own query inside the browse link", () => {
17
+ expect(
18
+ phantomBrowseUrl(
19
+ "https://dev.promanager.vip/u/buy?mobile=1&x=a b",
20
+ "https://dev.promanager.vip",
21
+ {
22
+ base: "https://phantom.app/ul/",
23
+ },
24
+ ),
25
+ ).toBe(
26
+ "https://phantom.app/ul/browse/https%3A%2F%2Fdev.promanager.vip%2Fu%2Fbuy%3Fmobile%3D1%26x%3Da%20b?ref=https%3A%2F%2Fdev.promanager.vip",
27
+ );
28
+ });
29
+ });
@@ -0,0 +1,16 @@
1
+ /** Phantom's universal-link host. `https://phantom.app/ul` still answers; Phantom asks new code to use this one. */
2
+ export const phantomUniversalLinkBase = "https://phantom.com/ul";
3
+
4
+ /**
5
+ * Opens `url` inside Phantom's in-app browser, where Phantom injects
6
+ * `window.phantom.ethereum`. Phantom's deeplinks carry no EVM methods, so
7
+ * this is how a phone's browser reaches its EVM wallet.
8
+ */
9
+ export function phantomBrowseUrl(
10
+ url: string,
11
+ ref: string,
12
+ options: { base?: string } = {},
13
+ ): string {
14
+ const base = (options.base ?? phantomUniversalLinkBase).replace(/\/+$/, "");
15
+ return `${base}/browse/${encodeURIComponent(url)}?ref=${encodeURIComponent(ref)}`;
16
+ }
@@ -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;AA6ED;;;;;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,wFAqJpE;yBArJe,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;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"}
@@ -0,0 +1,76 @@
1
+ import { type Chain, type EIP1193Provider, type PublicClient } from "viem";
2
+ import { type EvmClient } from "../clients/evm";
3
+ export type PhantomEthereumProvider = EIP1193Provider & {
4
+ isPhantom?: boolean;
5
+ };
6
+ interface PhantomWindow {
7
+ phantom?: {
8
+ ethereum?: PhantomEthereumProvider;
9
+ };
10
+ ethereum?: PhantomEthereumProvider;
11
+ location?: {
12
+ href: string;
13
+ origin: string;
14
+ assign(url: string): void;
15
+ };
16
+ addEventListener?: (type: string, listener: (event: Event) => void) => void;
17
+ removeEventListener?: (type: string, listener: (event: Event) => void) => void;
18
+ dispatchEvent?: (event: Event) => boolean;
19
+ }
20
+ /**
21
+ * Phantom's injected EVM provider: `window.phantom.ethereum`, else a
22
+ * `window.ethereum` that says it is Phantom. Present in Phantom's desktop
23
+ * extension and its in-app browser, never in Chrome or Safari on a phone.
24
+ */
25
+ export declare function phantomEthereumProvider(win?: PhantomWindow): PhantomEthereumProvider | undefined;
26
+ /**
27
+ * phantomEthereumProvider, or Phantom's EIP-6963 announcement (rdns
28
+ * `app.phantom`) when the provider is not on `window` yet. Resolves undefined
29
+ * after `timeout` ms.
30
+ */
31
+ export declare function discoverPhantomEthereumProvider(options?: {
32
+ timeout?: number;
33
+ window?: PhantomWindow;
34
+ }): Promise<PhantomEthereumProvider | undefined>;
35
+ /**
36
+ * How this page reaches Phantom's EVM wallet: `injected` when Phantom's
37
+ * provider is here (extension or Phantom's browser), `browse` when the page
38
+ * must first reopen inside Phantom with openInPhantom.
39
+ */
40
+ export declare function phantomEvmRoute(win?: PhantomWindow): "injected" | "browse";
41
+ export interface OpenInPhantomOptions {
42
+ /** The page to open in Phantom's browser. Defaults to this page. */
43
+ url?: string;
44
+ /** The requesting app. Defaults to this page's origin. */
45
+ ref?: string;
46
+ /** Defaults to `location.assign`. */
47
+ navigate?: (url: string) => void;
48
+ base?: string;
49
+ window?: PhantomWindow;
50
+ }
51
+ /**
52
+ * Reopens the page inside Phantom's in-app browser. Call it straight from the
53
+ * tap handler with nothing awaited first: mobile browsers only follow a link
54
+ * into another app without a prompt inside the tap's user activation.
55
+ * Returns the link, for an `<a href>` instead.
56
+ */
57
+ export declare function openInPhantom(options?: OpenInPhantomOptions): string;
58
+ export interface ConnectPhantomEvmOptions {
59
+ chain: Chain;
60
+ /** Defaults to phantomEthereumProvider(). */
61
+ provider?: PhantomEthereumProvider;
62
+ publicClient?: PublicClient;
63
+ rpcUrl?: string;
64
+ }
65
+ export declare class PhantomNotInjectedError extends Error {
66
+ constructor();
67
+ }
68
+ /**
69
+ * Connects Phantom's injected EVM wallet, moves it to `chain`, and returns
70
+ * the mpo EVM client on it, so `erc20.transfer` and `writeContract` go to
71
+ * Phantom for approval. Testnets such as Base Sepolia need Phantom's Testnet
72
+ * Mode on.
73
+ */
74
+ export declare function connectPhantomEvm(options: ConnectPhantomEvmOptions): Promise<EvmClient>;
75
+ export {};
76
+ //# sourceMappingURL=evm.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"evm.d.ts","sourceRoot":"","sources":["../../src/phantom/evm.ts"],"names":[],"mappings":"AAAA,OAAO,EAAe,KAAK,KAAK,EAAE,KAAK,eAAe,EAAE,KAAK,YAAY,EAAE,MAAM,MAAM,CAAC;AACxF,OAAO,EAAa,KAAK,SAAS,EAAE,MAAM,gBAAgB,CAAC;AAG3D,MAAM,MAAM,uBAAuB,GAAG,eAAe,GAAG;IAAE,SAAS,CAAC,EAAE,OAAO,CAAA;CAAE,CAAC;AAEhF,UAAU,aAAa;IACrB,OAAO,CAAC,EAAE;QAAE,QAAQ,CAAC,EAAE,uBAAuB,CAAA;KAAE,CAAC;IACjD,QAAQ,CAAC,EAAE,uBAAuB,CAAC;IACnC,QAAQ,CAAC,EAAE;QAAE,IAAI,EAAE,MAAM,CAAC;QAAC,MAAM,EAAE,MAAM,CAAC;QAAC,MAAM,CAAC,GAAG,EAAE,MAAM,GAAG,IAAI,CAAA;KAAE,CAAC;IACvE,gBAAgB,CAAC,EAAE,CAAC,IAAI,EAAE,MAAM,EAAE,QAAQ,EAAE,CAAC,KAAK,EAAE,KAAK,KAAK,IAAI,KAAK,IAAI,CAAC;IAC5E,mBAAmB,CAAC,EAAE,CAAC,IAAI,EAAE,MAAM,EAAE,QAAQ,EAAE,CAAC,KAAK,EAAE,KAAK,KAAK,IAAI,KAAK,IAAI,CAAC;IAC/E,aAAa,CAAC,EAAE,CAAC,KAAK,EAAE,KAAK,KAAK,OAAO,CAAC;CAC3C;AAID;;;;GAIG;AACH,wBAAgB,uBAAuB,CACrC,GAAG,GAAE,aAA2C,GAC/C,uBAAuB,GAAG,SAAS,CAKrC;AAED;;;;GAIG;AACH,wBAAgB,+BAA+B,CAC7C,OAAO,GAAE;IAAE,OAAO,CAAC,EAAE,MAAM,CAAC;IAAC,MAAM,CAAC,EAAE,aAAa,CAAA;CAAO,GACzD,OAAO,CAAC,uBAAuB,GAAG,SAAS,CAAC,CAqB9C;AAED;;;;GAIG;AACH,wBAAgB,eAAe,CAAC,GAAG,GAAE,aAA2C,yBAE/E;AAED,MAAM,WAAW,oBAAoB;IACnC,oEAAoE;IACpE,GAAG,CAAC,EAAE,MAAM,CAAC;IACb,0DAA0D;IAC1D,GAAG,CAAC,EAAE,MAAM,CAAC;IACb,qCAAqC;IACrC,QAAQ,CAAC,EAAE,CAAC,GAAG,EAAE,MAAM,KAAK,IAAI,CAAC;IACjC,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,MAAM,CAAC,EAAE,aAAa,CAAC;CACxB;AAED;;;;;GAKG;AACH,wBAAgB,aAAa,CAAC,OAAO,GAAE,oBAAyB,GAAG,MAAM,CAQxE;AAED,MAAM,WAAW,wBAAwB;IACvC,KAAK,EAAE,KAAK,CAAC;IACb,6CAA6C;IAC7C,QAAQ,CAAC,EAAE,uBAAuB,CAAC;IACnC,YAAY,CAAC,EAAE,YAAY,CAAC;IAC5B,MAAM,CAAC,EAAE,MAAM,CAAC;CACjB;AAED,qBAAa,uBAAwB,SAAQ,KAAK;;CAOjD;AA8BD;;;;;GAKG;AACH,wBAAsB,iBAAiB,CAAC,OAAO,EAAE,wBAAwB,GAAG,OAAO,CAAC,SAAS,CAAC,CAc7F"}
@@ -0,0 +1,3 @@
1
+ export { connectPhantomEvm, discoverPhantomEthereumProvider, openInPhantom, phantomEthereumProvider, phantomEvmRoute, PhantomNotInjectedError, type ConnectPhantomEvmOptions, type OpenInPhantomOptions, type PhantomEthereumProvider, } from "./evm";
2
+ export { phantomBrowseUrl, phantomUniversalLinkBase } from "./urls";
3
+ //# sourceMappingURL=index.d.ts.map