@multiplatform.one/web3 7.33.0 → 7.34.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 (76) hide show
  1. package/README.md +24 -6
  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/crypto.cjs +108 -0
  6. package/dist/cjs/phantom/crypto.native.js +224 -0
  7. package/dist/cjs/phantom/crypto.native.js.map +1 -0
  8. package/dist/cjs/phantom/index.cjs +16 -1
  9. package/dist/cjs/phantom/index.native.js +16 -1
  10. package/dist/cjs/phantom/index.native.js.map +1 -1
  11. package/dist/cjs/phantom/solana.cjs +309 -0
  12. package/dist/cjs/phantom/solana.native.js +466 -0
  13. package/dist/cjs/phantom/solana.native.js.map +1 -0
  14. package/dist/cjs/phantom/urls.cjs +68 -3
  15. package/dist/cjs/phantom/urls.native.js +92 -4
  16. package/dist/cjs/phantom/urls.native.js.map +1 -1
  17. package/dist/esm/connectkit/connectors/metaMaskConnectConnector.mjs +14 -3
  18. package/dist/esm/connectkit/connectors/metaMaskConnectConnector.mjs.map +1 -1
  19. package/dist/esm/connectkit/connectors/metaMaskConnectConnector.native.js +14 -3
  20. package/dist/esm/connectkit/connectors/metaMaskConnectConnector.native.js.map +1 -1
  21. package/dist/esm/phantom/crypto.mjs +67 -0
  22. package/dist/esm/phantom/crypto.mjs.map +1 -0
  23. package/dist/esm/phantom/crypto.native.js +180 -0
  24. package/dist/esm/phantom/crypto.native.js.map +1 -0
  25. package/dist/esm/phantom/index.mjs +4 -2
  26. package/dist/esm/phantom/index.mjs.map +1 -1
  27. package/dist/esm/phantom/index.native.js +4 -2
  28. package/dist/esm/phantom/index.native.js.map +1 -1
  29. package/dist/esm/phantom/solana.mjs +271 -0
  30. package/dist/esm/phantom/solana.mjs.map +1 -0
  31. package/dist/esm/phantom/solana.native.js +425 -0
  32. package/dist/esm/phantom/solana.native.js.map +1 -0
  33. package/dist/esm/phantom/urls.mjs +53 -3
  34. package/dist/esm/phantom/urls.mjs.map +1 -1
  35. package/dist/esm/phantom/urls.native.js +77 -4
  36. package/dist/esm/phantom/urls.native.js.map +1 -1
  37. package/dist/jsx/connectkit/connectors/metaMaskConnectConnector.mjs +14 -3
  38. package/dist/jsx/connectkit/connectors/metaMaskConnectConnector.mjs.map +1 -1
  39. package/dist/jsx/connectkit/connectors/metaMaskConnectConnector.native.js +14 -3
  40. package/dist/jsx/connectkit/connectors/metaMaskConnectConnector.native.js.map +1 -1
  41. package/dist/jsx/phantom/crypto.mjs +67 -0
  42. package/dist/jsx/phantom/crypto.mjs.map +1 -0
  43. package/dist/jsx/phantom/crypto.native.js +224 -0
  44. package/dist/jsx/phantom/crypto.native.js.map +1 -0
  45. package/dist/jsx/phantom/index.mjs +4 -2
  46. package/dist/jsx/phantom/index.mjs.map +1 -1
  47. package/dist/jsx/phantom/index.native.js +16 -1
  48. package/dist/jsx/phantom/index.native.js.map +1 -1
  49. package/dist/jsx/phantom/solana.mjs +271 -0
  50. package/dist/jsx/phantom/solana.mjs.map +1 -0
  51. package/dist/jsx/phantom/solana.native.js +466 -0
  52. package/dist/jsx/phantom/solana.native.js.map +1 -0
  53. package/dist/jsx/phantom/urls.mjs +53 -3
  54. package/dist/jsx/phantom/urls.mjs.map +1 -1
  55. package/dist/jsx/phantom/urls.native.js +92 -4
  56. package/dist/jsx/phantom/urls.native.js.map +1 -1
  57. package/dist/keycloak/login.iife.js +63 -63
  58. package/package.json +7 -6
  59. package/src/connectkit/connectors/metaMaskConnectConnector.spec.ts +44 -0
  60. package/src/connectkit/connectors/metaMaskConnectConnector.ts +30 -2
  61. package/src/phantom/crypto.spec.ts +61 -0
  62. package/src/phantom/crypto.ts +88 -0
  63. package/src/phantom/index.ts +38 -5
  64. package/src/phantom/solana.spec.ts +262 -0
  65. package/src/phantom/solana.ts +374 -0
  66. package/src/phantom/urls.spec.ts +101 -10
  67. package/src/phantom/urls.ts +119 -4
  68. package/types/connectkit/connectors/metaMaskConnectConnector.d.ts.map +1 -1
  69. package/types/phantom/crypto.d.ts +27 -0
  70. package/types/phantom/crypto.d.ts.map +1 -0
  71. package/types/phantom/index.d.ts +3 -1
  72. package/types/phantom/index.d.ts.map +1 -1
  73. package/types/phantom/solana.d.ts +113 -0
  74. package/types/phantom/solana.d.ts.map +1 -0
  75. package/types/phantom/urls.d.ts +46 -2
  76. package/types/phantom/urls.d.ts.map +1 -1
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@multiplatform.one/web3",
3
- "version": "7.33.0",
3
+ "version": "7.34.0",
4
4
  "description": "Web3 wallet integration for multiplatform.one ecosystem with ConnectKit and wagmi",
5
5
  "keywords": [
6
6
  "connectkit",
@@ -120,10 +120,11 @@
120
120
  "@aave/account": "^0.2.0",
121
121
  "@coinbase/wallet-sdk": "^4.3.6",
122
122
  "@metamask/connect-evm": "^2.1.1",
123
- "@multiplatform.one/connectkit": "7.33.0",
124
- "@multiplatform.one/gpg": "7.33.0",
125
- "@multiplatform.one/platform": "7.33.0",
126
- "@multiplatform.one/theme": "7.33.0",
123
+ "@multiplatform.one/connectkit": "7.34.0",
124
+ "@multiplatform.one/gpg": "7.34.0",
125
+ "@multiplatform.one/platform": "7.34.0",
126
+ "@multiplatform.one/theme": "7.34.0",
127
+ "@noble/ciphers": "^1.3.0",
127
128
  "@noble/curves": "^1.9.7",
128
129
  "@solana/kit": "^8.3.0",
129
130
  "@solana/wallet-adapter-base": "^0.9.27",
@@ -145,7 +146,7 @@
145
146
  "wagmi": "^3.7.1"
146
147
  },
147
148
  "devDependencies": {
148
- "@multiplatform.one/config": "7.33.0",
149
+ "@multiplatform.one/config": "7.34.0",
149
150
  "@tamagui/build": "2.7.6",
150
151
  "@tanstack/react-query": "^5.100.7",
151
152
  "@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,61 @@
1
+ import bs58 from "bs58";
2
+ import { describe, expect, it } from "vitest";
3
+ import {
4
+ boxKeyPairFromSecretKey,
5
+ decryptPayload,
6
+ encryptPayload,
7
+ generateBoxKeyPair,
8
+ PhantomDecryptError,
9
+ sharedSecret,
10
+ } from "./crypto";
11
+
12
+ const hex = (value: string) => Uint8Array.from(Buffer.from(value, "hex"));
13
+ const toHex = (bytes: Uint8Array) => Buffer.from(bytes).toString("hex");
14
+
15
+ // NaCl's crypto_box test keys; the expected values come from tweetnacl 1.0.3.
16
+ const aliceSecret = hex("77076d0a7318a57d3c16c17251b26645df4c2f87ebc0992ab177fba51db92c2a");
17
+ const bobPublic = hex("de9edb7d7b7dc1b4d35b61c2ece435373f8343c85b78674dadfc7e146f882b4f");
18
+
19
+ describe("phantom crypto", () => {
20
+ it("derives tweetnacl's box.before shared key", () => {
21
+ expect(toHex(sharedSecret(bobPublic, aliceSecret))).toBe(
22
+ "1b27556473e985d462cd51197a9a46c76009549eac6474f206c4ee0844f68389",
23
+ );
24
+ });
25
+
26
+ it("seals byte for byte like tweetnacl's box.after", () => {
27
+ const sealed = encryptPayload(
28
+ { a: 1 },
29
+ sharedSecret(bobPublic, aliceSecret),
30
+ new Uint8Array(24).fill(7),
31
+ );
32
+ expect(toHex(bs58.decode(sealed.payload))).toBe(
33
+ "a289826cda842719b07d6f10244b134793a8fb67d5bfdd",
34
+ );
35
+ expect(sealed.nonce).toBe(bs58.encode(new Uint8Array(24).fill(7)));
36
+ });
37
+
38
+ it("agrees on one secret from both sides and round-trips JSON", () => {
39
+ const dapp = generateBoxKeyPair();
40
+ const phantom = generateBoxKeyPair();
41
+ const dappSide = sharedSecret(phantom.publicKey, dapp.secretKey);
42
+ const phantomSide = sharedSecret(dapp.publicKey, phantom.secretKey);
43
+ expect(toHex(dappSide)).toBe(toHex(phantomSide));
44
+ const sealed = encryptPayload({ public_key: "abc", session: "s" }, phantomSide);
45
+ expect(decryptPayload(sealed.payload, sealed.nonce, dappSide)).toEqual({
46
+ public_key: "abc",
47
+ session: "s",
48
+ });
49
+ });
50
+
51
+ it("rebuilds the keypair from the stored secret key", () => {
52
+ const pair = generateBoxKeyPair();
53
+ expect(toHex(boxKeyPairFromSecretKey(pair.secretKey).publicKey)).toBe(toHex(pair.publicKey));
54
+ });
55
+
56
+ it("refuses a payload sealed for another key", () => {
57
+ const sealed = encryptPayload({ x: 1 }, sharedSecret(bobPublic, aliceSecret));
58
+ const other = sharedSecret(generateBoxKeyPair().publicKey, aliceSecret);
59
+ expect(() => decryptPayload(sealed.payload, sealed.nonce, other)).toThrow(PhantomDecryptError);
60
+ });
61
+ });
@@ -0,0 +1,88 @@
1
+ import { hsalsa, xsalsa20poly1305 } from "@noble/ciphers/salsa";
2
+ import { x25519 } from "@noble/curves/ed25519";
3
+ import bs58 from "bs58";
4
+
5
+ /** An x25519 keypair, the shape of tweetnacl's `box.keyPair()`. */
6
+ export interface BoxKeyPair {
7
+ publicKey: Uint8Array;
8
+ secretKey: Uint8Array;
9
+ }
10
+
11
+ /** An encrypted payload as Phantom's deeplinks carry it: both fields base58. */
12
+ export interface EncryptedPayload {
13
+ nonce: string;
14
+ payload: string;
15
+ }
16
+
17
+ export class PhantomDecryptError extends Error {
18
+ constructor(message = "could not decrypt Phantom's response with this session's key") {
19
+ super(message);
20
+ this.name = "PhantomDecryptError";
21
+ }
22
+ }
23
+
24
+ function randomBytes(length: number): Uint8Array {
25
+ const bytes = new Uint8Array(length);
26
+ globalThis.crypto.getRandomValues(bytes);
27
+ return bytes;
28
+ }
29
+
30
+ /** A fresh dapp keypair. Phantom recommends one per `connect`. */
31
+ export function generateBoxKeyPair(): BoxKeyPair {
32
+ const secretKey = randomBytes(32);
33
+ return { secretKey, publicKey: x25519.getPublicKey(secretKey) };
34
+ }
35
+
36
+ export function boxKeyPairFromSecretKey(secretKey: Uint8Array): BoxKeyPair {
37
+ return { secretKey, publicKey: x25519.getPublicKey(secretKey) };
38
+ }
39
+
40
+ const sigma = new Uint32Array([0x61707865, 0x3320646e, 0x79622d32, 0x6b206574]);
41
+
42
+ function toWords(bytes: Uint8Array): Uint32Array {
43
+ const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
44
+ const words = new Uint32Array(bytes.length / 4);
45
+ for (let i = 0; i < words.length; i += 1) words[i] = view.getUint32(i * 4, true);
46
+ return words;
47
+ }
48
+
49
+ function fromWords(words: Uint32Array): Uint8Array {
50
+ const bytes = new Uint8Array(words.length * 4);
51
+ const view = new DataView(bytes.buffer);
52
+ for (let i = 0; i < words.length; i += 1) view.setUint32(i * 4, words[i], true);
53
+ return bytes;
54
+ }
55
+
56
+ /**
57
+ * tweetnacl's `box.before(theirPublicKey, mySecretKey)`: HSalsa20 of the
58
+ * x25519 shared point under a zero nonce. Phantom keys every payload of a
59
+ * session with it.
60
+ */
61
+ export function sharedSecret(theirPublicKey: Uint8Array, mySecretKey: Uint8Array): Uint8Array {
62
+ const point = x25519.getSharedSecret(mySecretKey, theirPublicKey);
63
+ const out = new Uint32Array(8);
64
+ hsalsa(sigma, toWords(point), new Uint32Array(4), out);
65
+ return fromWords(out);
66
+ }
67
+
68
+ /** tweetnacl's `box.after`: XSalsa20-Poly1305 under the shared secret. */
69
+ export function encryptPayload(
70
+ value: unknown,
71
+ secret: Uint8Array,
72
+ nonce: Uint8Array = randomBytes(24),
73
+ ): EncryptedPayload {
74
+ const plaintext = new TextEncoder().encode(JSON.stringify(value));
75
+ const sealed = xsalsa20poly1305(secret, nonce).encrypt(plaintext);
76
+ return { nonce: bs58.encode(nonce), payload: bs58.encode(sealed) };
77
+ }
78
+
79
+ /** tweetnacl's `box.open.after`, then JSON. Throws PhantomDecryptError on a bad tag. */
80
+ export function decryptPayload<T = unknown>(data: string, nonce: string, secret: Uint8Array): T {
81
+ let opened: Uint8Array;
82
+ try {
83
+ opened = xsalsa20poly1305(secret, bs58.decode(nonce)).decrypt(bs58.decode(data));
84
+ } catch (error) {
85
+ throw Object.assign(new PhantomDecryptError(), { cause: error });
86
+ }
87
+ return JSON.parse(new TextDecoder().decode(opened)) as T;
88
+ }
@@ -1,7 +1,18 @@
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).
1
+ // `@multiplatform.one/web3/phantom`: Phantom from a phone's own browser,
2
+ // with no relay. Phantom's deeplinks only carry Solana, so EVM goes
3
+ // through Phantom's in-app browser (openInPhantom, then connectPhantomEvm on
4
+ // the injected provider) and Solana through the encrypted deeplinks
5
+ // (phantomDeeplink).
6
+ export {
7
+ boxKeyPairFromSecretKey,
8
+ decryptPayload,
9
+ encryptPayload,
10
+ generateBoxKeyPair,
11
+ PhantomDecryptError,
12
+ sharedSecret,
13
+ type BoxKeyPair,
14
+ type EncryptedPayload,
15
+ } from "./crypto";
5
16
  export {
6
17
  connectPhantomEvm,
7
18
  discoverPhantomEthereumProvider,
@@ -13,4 +24,26 @@ export {
13
24
  type OpenInPhantomOptions,
14
25
  type PhantomEthereumProvider,
15
26
  } from "./evm";
16
- export { phantomBrowseUrl, phantomUniversalLinkBase } from "./urls";
27
+ export {
28
+ phantomDeeplink,
29
+ PhantomNotConnectedError,
30
+ sendSolanaTransaction,
31
+ type PhantomDeeplink,
32
+ type PhantomDeeplinkConfig,
33
+ type PhantomRedirectResult,
34
+ type PhantomSessionRecord,
35
+ type PhantomStorage,
36
+ } from "./solana";
37
+ export {
38
+ parsePhantomRedirect,
39
+ phantomBrowseUrl,
40
+ phantomConnectUrl,
41
+ phantomMethodUrl,
42
+ phantomUniversalLinkBase,
43
+ stripPhantomRedirect,
44
+ type PhantomCluster,
45
+ type PhantomConnectUrlParams,
46
+ type PhantomMethod,
47
+ type PhantomMethodUrlParams,
48
+ type PhantomRedirectParams,
49
+ } from "./urls";
@@ -0,0 +1,262 @@
1
+ import bs58 from "bs58";
2
+ import { describe, expect, it, vi } from "vitest";
3
+ import {
4
+ decryptPayload,
5
+ encryptPayload,
6
+ generateBoxKeyPair,
7
+ PhantomDecryptError,
8
+ sharedSecret,
9
+ type BoxKeyPair,
10
+ } from "./crypto";
11
+ import {
12
+ phantomDeeplink,
13
+ PhantomNotConnectedError,
14
+ sendSolanaTransaction,
15
+ type PhantomStorage,
16
+ } from "./solana";
17
+
18
+ function memoryStorage(): PhantomStorage & { data: Map<string, string> } {
19
+ const data = new Map<string, string>();
20
+ return {
21
+ data,
22
+ getItem: (key) => data.get(key) ?? null,
23
+ setItem: (key, value) => void data.set(key, value),
24
+ removeItem: (key) => void data.delete(key),
25
+ };
26
+ }
27
+
28
+ /** Phantom's side of the protocol, as the docs describe it. */
29
+ function fakePhantom() {
30
+ const wallet = "BSFtCudCd4pR4LSFqWPjbtXPKSNVbGkc35gRNdnqjMCU";
31
+ const session = "SessionToken1";
32
+ let keys: BoxKeyPair | undefined;
33
+ let secret: Uint8Array | undefined;
34
+ const answer = (redirect: string, extra: Record<string, string>) => {
35
+ const query = new URLSearchParams(extra);
36
+ return `${redirect}${redirect.includes("?") ? "?" : "?"}${query}`;
37
+ };
38
+ return {
39
+ wallet,
40
+ session,
41
+ approveConnect(link: string) {
42
+ const url = new URL(link);
43
+ keys = generateBoxKeyPair();
44
+ secret = sharedSecret(
45
+ bs58.decode(url.searchParams.get("dapp_encryption_public_key") ?? ""),
46
+ keys.secretKey,
47
+ );
48
+ const sealed = encryptPayload({ public_key: wallet, session }, secret);
49
+ return answer(url.searchParams.get("redirect_link") ?? "", {
50
+ phantom_encryption_public_key: bs58.encode(keys.publicKey),
51
+ nonce: sealed.nonce,
52
+ data: sealed.payload,
53
+ });
54
+ },
55
+ open(link: string) {
56
+ const url = new URL(link);
57
+ if (!secret) throw new Error("not connected");
58
+ return {
59
+ method: url.pathname.split("/").pop(),
60
+ redirect: url.searchParams.get("redirect_link") ?? "",
61
+ payload: decryptPayload<Record<string, unknown>>(
62
+ url.searchParams.get("payload") ?? "",
63
+ url.searchParams.get("nonce") ?? "",
64
+ secret,
65
+ ),
66
+ };
67
+ },
68
+ approve(redirect: string, data: Record<string, unknown>) {
69
+ if (!secret) throw new Error("not connected");
70
+ const sealed = encryptPayload(data, secret);
71
+ return answer(redirect, { nonce: sealed.nonce, data: sealed.payload });
72
+ },
73
+ };
74
+ }
75
+
76
+ function setup() {
77
+ const storage = memoryStorage();
78
+ const navigate = vi.fn<(url: string) => void>();
79
+ const config = {
80
+ appUrl: "https://app.example",
81
+ redirectLink: "https://app.example/pay?mobile=1",
82
+ cluster: "devnet" as const,
83
+ storage,
84
+ navigate,
85
+ };
86
+ return { storage, navigate, config, client: phantomDeeplink(config) };
87
+ }
88
+
89
+ describe("phantomDeeplink", () => {
90
+ it("navigates to connect synchronously, inside the tap", () => {
91
+ const { client, navigate } = setup();
92
+ const link = client.connect();
93
+ expect(navigate).toHaveBeenCalledTimes(1);
94
+ expect(navigate).toHaveBeenCalledWith(link);
95
+ expect(link.startsWith("https://phantom.com/ul/v1/connect?")).toBe(true);
96
+ expect(client.pending?.method).toBe("connect");
97
+ });
98
+
99
+ it("completes the handshake after a reload and signs a transaction", () => {
100
+ const phantom = fakePhantom();
101
+ const first = setup();
102
+ const back = phantom.approveConnect(first.client.connect());
103
+
104
+ // The page reloads on return: a new client over the same storage.
105
+ const client = phantomDeeplink(first.config);
106
+ expect(client.handleRedirect(back)).toEqual({
107
+ ok: true,
108
+ method: "connect",
109
+ publicKey: phantom.wallet,
110
+ session: phantom.session,
111
+ });
112
+ expect(client.connected).toBe(true);
113
+ expect(client.publicKey).toBe(phantom.wallet);
114
+ expect(client.pending).toBeUndefined();
115
+
116
+ const tx = Uint8Array.from([1, 2, 3, 4]);
117
+ const request = phantom.open(client.signTransaction(tx));
118
+ expect(request.method).toBe("signTransaction");
119
+ expect(request.redirect).toBe("https://app.example/pay?mobile=1");
120
+ expect(request.payload).toEqual({ transaction: bs58.encode(tx), session: phantom.session });
121
+
122
+ const signed = Uint8Array.from([9, 9, 9]);
123
+ const result = phantomDeeplink(first.config).handleRedirect(
124
+ phantom.approve(request.redirect, { transaction: bs58.encode(signed) }),
125
+ );
126
+ expect(result).toEqual({ ok: true, method: "signTransaction", transaction: signed });
127
+ });
128
+
129
+ it("signs a message and returns the signature bytes", () => {
130
+ const phantom = fakePhantom();
131
+ const { client } = setup();
132
+ client.handleRedirect(phantom.approveConnect(client.connect()));
133
+ const request = phantom.open(client.signMessage("hello", "utf8"));
134
+ expect(request.method).toBe("signMessage");
135
+ expect(request.payload).toEqual({
136
+ message: bs58.encode(new TextEncoder().encode("hello")),
137
+ display: "utf8",
138
+ session: phantom.session,
139
+ });
140
+ const signature = new Uint8Array(64).fill(5);
141
+ expect(
142
+ client.handleRedirect(
143
+ phantom.approve(request.redirect, { signature: bs58.encode(signature) }),
144
+ ),
145
+ ).toEqual({ ok: true, method: "signMessage", signature });
146
+ });
147
+
148
+ it("sends signAndSendTransaction with its options and returns the signature", () => {
149
+ const phantom = fakePhantom();
150
+ const { client } = setup();
151
+ client.handleRedirect(phantom.approveConnect(client.connect()));
152
+ const request = phantom.open(
153
+ client.signAndSendTransaction(Uint8Array.from([7]), { skipPreflight: true }),
154
+ );
155
+ expect(request.method).toBe("signAndSendTransaction");
156
+ expect(request.payload).toEqual({
157
+ transaction: bs58.encode(Uint8Array.from([7])),
158
+ sendOptions: { skipPreflight: true },
159
+ session: phantom.session,
160
+ });
161
+ expect(client.handleRedirect(phantom.approve(request.redirect, { signature: "SIG" }))).toEqual({
162
+ ok: true,
163
+ method: "signAndSendTransaction",
164
+ signature: "SIG",
165
+ });
166
+ });
167
+
168
+ it("reports a rejection and clears the pending request", () => {
169
+ const { client } = setup();
170
+ client.connect();
171
+ expect(
172
+ client.handleRedirect(
173
+ "https://app.example/pay?mobile=1?errorCode=4001&errorMessage=User%20rejected%20the%20request",
174
+ ),
175
+ ).toEqual({
176
+ ok: false,
177
+ method: "connect",
178
+ errorCode: "4001",
179
+ errorMessage: "User rejected the request",
180
+ });
181
+ expect(client.pending).toBeUndefined();
182
+ });
183
+
184
+ it("refuses to sign before connecting", () => {
185
+ const { client, navigate } = setup();
186
+ expect(() => client.signTransaction(Uint8Array.from([1]))).toThrow(PhantomNotConnectedError);
187
+ expect(navigate).not.toHaveBeenCalled();
188
+ });
189
+
190
+ it("throws on an answer sealed for another session", () => {
191
+ const phantom = fakePhantom();
192
+ const { client } = setup();
193
+ const back = phantom.approveConnect(client.connect());
194
+ client.connect();
195
+ expect(() => client.handleRedirect(back)).toThrow(PhantomDecryptError);
196
+ });
197
+
198
+ it("returns undefined for a page load with no answer", () => {
199
+ const { client } = setup();
200
+ expect(client.handleRedirect("https://app.example/pay?mobile=1")).toBeUndefined();
201
+ });
202
+
203
+ it("forgets the session locally on disconnect and tells Phantom", () => {
204
+ const phantom = fakePhantom();
205
+ const { client } = setup();
206
+ client.handleRedirect(phantom.approveConnect(client.connect()));
207
+ const request = phantom.open(client.disconnect());
208
+ expect(request.method).toBe("disconnect");
209
+ expect(request.payload).toEqual({ session: phantom.session });
210
+ expect(client.connected).toBe(false);
211
+ });
212
+
213
+ it("defaults the redirect to this page without Phantom's parameters", () => {
214
+ const storage = memoryStorage();
215
+ const navigate = vi.fn<(url: string) => void>();
216
+ vi.stubGlobal("location", { href: "https://app.example/pay?mobile=1?nonce=a&data=b#x" });
217
+ try {
218
+ const link = phantomDeeplink({ appUrl: "https://app.example", storage, navigate }).connect();
219
+ expect(new URL(link).searchParams.get("redirect_link")).toBe(
220
+ "https://app.example/pay?mobile=1#x",
221
+ );
222
+ } finally {
223
+ vi.unstubAllGlobals();
224
+ }
225
+ });
226
+ });
227
+
228
+ describe("sendSolanaTransaction", () => {
229
+ it("posts the signed bytes as base64 and returns the signature", async () => {
230
+ const fetch = vi.fn(
231
+ async () => new Response(JSON.stringify({ jsonrpc: "2.0", id: 1, result: "SIG" })),
232
+ );
233
+ await expect(
234
+ sendSolanaTransaction("https://api.devnet.solana.com", Uint8Array.from([1, 2, 255]), {
235
+ fetch,
236
+ }),
237
+ ).resolves.toBe("SIG");
238
+ const init = (fetch.mock.calls[0] as unknown as [string, RequestInit])[1];
239
+ expect(JSON.parse(String(init.body))).toEqual({
240
+ jsonrpc: "2.0",
241
+ id: 1,
242
+ method: "sendTransaction",
243
+ params: ["AQL/", { encoding: "base64", skipPreflight: false }],
244
+ });
245
+ });
246
+
247
+ it("throws the RPC error", async () => {
248
+ const fetch = vi.fn(
249
+ async () =>
250
+ new Response(
251
+ JSON.stringify({
252
+ jsonrpc: "2.0",
253
+ id: 1,
254
+ error: { code: -32002, message: "blockhash not found" },
255
+ }),
256
+ ),
257
+ );
258
+ await expect(
259
+ sendSolanaTransaction("https://rpc", Uint8Array.from([1]), { fetch }),
260
+ ).rejects.toMatchObject({ code: -32002 });
261
+ });
262
+ });