@multiplatform.one/web3 7.33.1 → 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 (61) hide show
  1. package/README.md +24 -6
  2. package/dist/cjs/phantom/crypto.cjs +108 -0
  3. package/dist/cjs/phantom/crypto.native.js +224 -0
  4. package/dist/cjs/phantom/crypto.native.js.map +1 -0
  5. package/dist/cjs/phantom/index.cjs +16 -1
  6. package/dist/cjs/phantom/index.native.js +16 -1
  7. package/dist/cjs/phantom/index.native.js.map +1 -1
  8. package/dist/cjs/phantom/solana.cjs +309 -0
  9. package/dist/cjs/phantom/solana.native.js +466 -0
  10. package/dist/cjs/phantom/solana.native.js.map +1 -0
  11. package/dist/cjs/phantom/urls.cjs +68 -3
  12. package/dist/cjs/phantom/urls.native.js +92 -4
  13. package/dist/cjs/phantom/urls.native.js.map +1 -1
  14. package/dist/esm/phantom/crypto.mjs +67 -0
  15. package/dist/esm/phantom/crypto.mjs.map +1 -0
  16. package/dist/esm/phantom/crypto.native.js +180 -0
  17. package/dist/esm/phantom/crypto.native.js.map +1 -0
  18. package/dist/esm/phantom/index.mjs +4 -2
  19. package/dist/esm/phantom/index.mjs.map +1 -1
  20. package/dist/esm/phantom/index.native.js +4 -2
  21. package/dist/esm/phantom/index.native.js.map +1 -1
  22. package/dist/esm/phantom/solana.mjs +271 -0
  23. package/dist/esm/phantom/solana.mjs.map +1 -0
  24. package/dist/esm/phantom/solana.native.js +425 -0
  25. package/dist/esm/phantom/solana.native.js.map +1 -0
  26. package/dist/esm/phantom/urls.mjs +53 -3
  27. package/dist/esm/phantom/urls.mjs.map +1 -1
  28. package/dist/esm/phantom/urls.native.js +77 -4
  29. package/dist/esm/phantom/urls.native.js.map +1 -1
  30. package/dist/jsx/phantom/crypto.mjs +67 -0
  31. package/dist/jsx/phantom/crypto.mjs.map +1 -0
  32. package/dist/jsx/phantom/crypto.native.js +224 -0
  33. package/dist/jsx/phantom/crypto.native.js.map +1 -0
  34. package/dist/jsx/phantom/index.mjs +4 -2
  35. package/dist/jsx/phantom/index.mjs.map +1 -1
  36. package/dist/jsx/phantom/index.native.js +16 -1
  37. package/dist/jsx/phantom/index.native.js.map +1 -1
  38. package/dist/jsx/phantom/solana.mjs +271 -0
  39. package/dist/jsx/phantom/solana.mjs.map +1 -0
  40. package/dist/jsx/phantom/solana.native.js +466 -0
  41. package/dist/jsx/phantom/solana.native.js.map +1 -0
  42. package/dist/jsx/phantom/urls.mjs +53 -3
  43. package/dist/jsx/phantom/urls.mjs.map +1 -1
  44. package/dist/jsx/phantom/urls.native.js +92 -4
  45. package/dist/jsx/phantom/urls.native.js.map +1 -1
  46. package/package.json +7 -6
  47. package/src/phantom/crypto.spec.ts +61 -0
  48. package/src/phantom/crypto.ts +88 -0
  49. package/src/phantom/index.ts +38 -5
  50. package/src/phantom/solana.spec.ts +262 -0
  51. package/src/phantom/solana.ts +374 -0
  52. package/src/phantom/urls.spec.ts +101 -10
  53. package/src/phantom/urls.ts +119 -4
  54. package/types/phantom/crypto.d.ts +27 -0
  55. package/types/phantom/crypto.d.ts.map +1 -0
  56. package/types/phantom/index.d.ts +3 -1
  57. package/types/phantom/index.d.ts.map +1 -1
  58. package/types/phantom/solana.d.ts +113 -0
  59. package/types/phantom/solana.d.ts.map +1 -0
  60. package/types/phantom/urls.d.ts +46 -2
  61. package/types/phantom/urls.d.ts.map +1 -1
@@ -0,0 +1,374 @@
1
+ import bs58 from "bs58";
2
+ import {
3
+ boxKeyPairFromSecretKey,
4
+ decryptPayload,
5
+ generateBoxKeyPair,
6
+ sharedSecret,
7
+ } from "./crypto";
8
+ import {
9
+ parsePhantomRedirect,
10
+ phantomConnectUrl,
11
+ phantomMethodUrl,
12
+ stripPhantomRedirect,
13
+ type PhantomCluster,
14
+ type PhantomMethod,
15
+ } from "./urls";
16
+
17
+ /** The slice of the Web Storage API the session needs. */
18
+ export interface PhantomStorage {
19
+ getItem(key: string): string | null;
20
+ setItem(key: string, value: string): void;
21
+ removeItem(key: string): void;
22
+ }
23
+
24
+ /** What is kept between the tap and the return. Base58 throughout. */
25
+ export interface PhantomSessionRecord {
26
+ dappSecretKey: string;
27
+ cluster?: PhantomCluster;
28
+ phantomEncryptionPublicKey?: string;
29
+ session?: string;
30
+ publicKey?: string;
31
+ pending?: { method: PhantomMethod; at: number };
32
+ }
33
+
34
+ export interface PhantomDeeplinkConfig {
35
+ /** Stored in Phantom's session and checked against its blocklist. */
36
+ appUrl: string;
37
+ /**
38
+ * Where Phantom returns. Defaults to the current page without Phantom's
39
+ * parameters. An https link shows the app's name and icon in Phantom and
40
+ * returns to the phone's browser; a custom scheme returns to an app, which
41
+ * must hand the URL to `handleRedirect` in a page with the same storage.
42
+ */
43
+ redirectLink?: string;
44
+ cluster?: PhantomCluster;
45
+ /**
46
+ * Must outlive the trip to Phantom: on Android the return can land in a new
47
+ * browser tab. Defaults to localStorage.
48
+ */
49
+ storage?: PhantomStorage;
50
+ storageKey?: string;
51
+ /** Defaults to `location.assign`. Called synchronously inside the request method. */
52
+ navigate?: (url: string) => void;
53
+ /** Overrides https://phantom.com/ul. */
54
+ base?: string;
55
+ now?: () => number;
56
+ }
57
+
58
+ export type PhantomRedirectResult =
59
+ | { ok: true; method: "connect"; publicKey: string; session: string }
60
+ | { ok: true; method: "signTransaction"; transaction: Uint8Array }
61
+ | { ok: true; method: "signAllTransactions"; transactions: Uint8Array[] }
62
+ | { ok: true; method: "signAndSendTransaction"; signature: string }
63
+ | { ok: true; method: "signMessage"; signature: Uint8Array }
64
+ | {
65
+ ok: false;
66
+ method: PhantomMethod | undefined;
67
+ errorCode: string;
68
+ errorMessage: string;
69
+ };
70
+
71
+ type ConnectedRecord = PhantomSessionRecord & {
72
+ session: string;
73
+ phantomEncryptionPublicKey: string;
74
+ };
75
+
76
+ export class PhantomNotConnectedError extends Error {
77
+ constructor() {
78
+ super("no Phantom deeplink session: call connect() first");
79
+ this.name = "PhantomNotConnectedError";
80
+ }
81
+ }
82
+
83
+ export interface PhantomDeeplink {
84
+ /** The connected wallet's base58 address, once a connect has come back. */
85
+ readonly publicKey: string | undefined;
86
+ readonly connected: boolean;
87
+ /** The request still waiting for Phantom's answer, if any. */
88
+ readonly pending: PhantomSessionRecord["pending"];
89
+ /**
90
+ * Each request below builds its link from what is already stored and
91
+ * navigates before returning, with nothing awaited, so it runs inside the
92
+ * tap's user activation and the browser follows the link without asking.
93
+ * The link is returned too, for an `<a href>`.
94
+ */
95
+ connect(): string;
96
+ signTransaction(serializedTransaction: Uint8Array): string;
97
+ signAllTransactions(serializedTransactions: Uint8Array[]): string;
98
+ /** Deprecated by Phantom: prefer signTransaction and sendSolanaTransaction. */
99
+ signAndSendTransaction(
100
+ serializedTransaction: Uint8Array,
101
+ sendOptions?: Record<string, unknown>,
102
+ ): string;
103
+ signMessage(message: Uint8Array | string, display?: "utf8" | "hex"): string;
104
+ /** Forgets the session here at once, then tells Phantom. */
105
+ disconnect(): string;
106
+ /**
107
+ * Reads Phantom's answer off the return URL (the current one by default)
108
+ * and updates the stored session. Undefined when the URL carries none.
109
+ * Throws PhantomDecryptError when the answer was not sealed for this
110
+ * session's key.
111
+ */
112
+ handleRedirect(href?: string): PhantomRedirectResult | undefined;
113
+ /** Drops the stored keypair and session without asking Phantom. */
114
+ forget(): void;
115
+ }
116
+
117
+ const defaultStorageKey = "mpo.web3.phantom.deeplink";
118
+
119
+ function defaultStorage(): PhantomStorage {
120
+ const storage = (globalThis as { localStorage?: PhantomStorage }).localStorage;
121
+ if (!storage) throw new Error("phantomDeeplink: no localStorage here; pass `storage`");
122
+ return storage;
123
+ }
124
+
125
+ function currentHref(): string {
126
+ const href = (globalThis as { location?: { href?: string } }).location?.href;
127
+ if (!href) throw new Error("phantomDeeplink: no location here; pass `redirectLink`");
128
+ return href;
129
+ }
130
+
131
+ /** A Solana wallet in the Phantom app, reached from a phone's browser through Phantom's encrypted deeplinks. */
132
+ export function phantomDeeplink(config: PhantomDeeplinkConfig): PhantomDeeplink {
133
+ const storageKey = config.storageKey ?? defaultStorageKey;
134
+ const storage = () => config.storage ?? defaultStorage();
135
+ const now = config.now ?? Date.now;
136
+ const navigate =
137
+ config.navigate ??
138
+ ((url: string) => (globalThis as { location: Location }).location.assign(url));
139
+ const redirectLink = () => config.redirectLink ?? stripPhantomRedirect(currentHref());
140
+
141
+ const load = (): PhantomSessionRecord | undefined => {
142
+ const raw = storage().getItem(storageKey);
143
+ if (!raw) return undefined;
144
+ try {
145
+ return JSON.parse(raw) as PhantomSessionRecord;
146
+ } catch {
147
+ return undefined;
148
+ }
149
+ };
150
+ const save = (record: PhantomSessionRecord) =>
151
+ storage().setItem(storageKey, JSON.stringify(record));
152
+
153
+ const connected = (): ConnectedRecord => {
154
+ const record = load();
155
+ if (!record?.session || !record.phantomEncryptionPublicKey)
156
+ throw new PhantomNotConnectedError();
157
+ return record as ConnectedRecord;
158
+ };
159
+
160
+ const methodUrl = (
161
+ record: ConnectedRecord,
162
+ method: Exclude<PhantomMethod, "connect">,
163
+ payload: Record<string, unknown>,
164
+ ) => {
165
+ const keyPair = boxKeyPairFromSecretKey(bs58.decode(record.dappSecretKey));
166
+ return phantomMethodUrl({
167
+ method,
168
+ payload,
169
+ session: record.session,
170
+ dappEncryptionPublicKey: keyPair.publicKey,
171
+ sharedSecret: sharedSecret(bs58.decode(record.phantomEncryptionPublicKey), keyPair.secretKey),
172
+ redirectLink: redirectLink(),
173
+ base: config.base,
174
+ });
175
+ };
176
+
177
+ const request = (
178
+ method: Exclude<PhantomMethod, "connect" | "disconnect">,
179
+ payload: Record<string, unknown>,
180
+ ): string => {
181
+ const record = connected();
182
+ const url = methodUrl(record, method, payload);
183
+ save({ ...record, pending: { method, at: now() } });
184
+ navigate(url);
185
+ return url;
186
+ };
187
+
188
+ return {
189
+ get publicKey() {
190
+ return load()?.publicKey;
191
+ },
192
+ get connected() {
193
+ const record = load();
194
+ return !!(record?.session && record.phantomEncryptionPublicKey);
195
+ },
196
+ get pending() {
197
+ return load()?.pending;
198
+ },
199
+
200
+ connect() {
201
+ const keyPair = generateBoxKeyPair();
202
+ const url = phantomConnectUrl({
203
+ appUrl: config.appUrl,
204
+ dappEncryptionPublicKey: keyPair.publicKey,
205
+ redirectLink: redirectLink(),
206
+ cluster: config.cluster,
207
+ base: config.base,
208
+ });
209
+ save({
210
+ dappSecretKey: bs58.encode(keyPair.secretKey),
211
+ cluster: config.cluster,
212
+ pending: { method: "connect", at: now() },
213
+ });
214
+ navigate(url);
215
+ return url;
216
+ },
217
+
218
+ signTransaction(serializedTransaction) {
219
+ return request("signTransaction", { transaction: bs58.encode(serializedTransaction) });
220
+ },
221
+
222
+ signAllTransactions(serializedTransactions) {
223
+ return request("signAllTransactions", {
224
+ transactions: serializedTransactions.map((tx) => bs58.encode(tx)),
225
+ });
226
+ },
227
+
228
+ signAndSendTransaction(serializedTransaction, sendOptions) {
229
+ return request("signAndSendTransaction", {
230
+ transaction: bs58.encode(serializedTransaction),
231
+ ...(sendOptions ? { sendOptions } : {}),
232
+ });
233
+ },
234
+
235
+ signMessage(message, display) {
236
+ const bytes = typeof message === "string" ? new TextEncoder().encode(message) : message;
237
+ return request("signMessage", {
238
+ message: bs58.encode(bytes),
239
+ ...(display ? { display } : {}),
240
+ });
241
+ },
242
+
243
+ disconnect() {
244
+ const url = methodUrl(connected(), "disconnect", {});
245
+ storage().removeItem(storageKey);
246
+ navigate(url);
247
+ return url;
248
+ },
249
+
250
+ handleRedirect(href) {
251
+ const params = parsePhantomRedirect(href ?? currentHref());
252
+ if (!params) return undefined;
253
+ const record = load();
254
+ const method = record?.pending?.method;
255
+ if (params.errorCode) {
256
+ if (record) save({ ...record, pending: undefined });
257
+ return {
258
+ ok: false,
259
+ method,
260
+ errorCode: params.errorCode,
261
+ errorMessage: params.errorMessage ?? "",
262
+ };
263
+ }
264
+ if (!record || !params.data || !params.nonce) {
265
+ return {
266
+ ok: false,
267
+ method,
268
+ errorCode: "-32603",
269
+ errorMessage: "Phantom answered, but this browser holds no matching request",
270
+ };
271
+ }
272
+ const secretKey = bs58.decode(record.dappSecretKey);
273
+ const phantomKey =
274
+ method === "connect"
275
+ ? params.phantomEncryptionPublicKey
276
+ : record.phantomEncryptionPublicKey;
277
+ if (!phantomKey) {
278
+ return {
279
+ ok: false,
280
+ method,
281
+ errorCode: "-32603",
282
+ errorMessage: "Phantom's answer carries no encryption key",
283
+ };
284
+ }
285
+ const data = decryptPayload<Record<string, unknown>>(
286
+ params.data,
287
+ params.nonce,
288
+ sharedSecret(bs58.decode(phantomKey), secretKey),
289
+ );
290
+ switch (method) {
291
+ case "connect": {
292
+ const publicKey = String(data.public_key);
293
+ const session = String(data.session);
294
+ save({
295
+ ...record,
296
+ phantomEncryptionPublicKey: phantomKey,
297
+ publicKey,
298
+ session,
299
+ pending: undefined,
300
+ });
301
+ return { ok: true, method, publicKey, session };
302
+ }
303
+ case "signTransaction":
304
+ save({ ...record, pending: undefined });
305
+ return { ok: true, method, transaction: bs58.decode(String(data.transaction)) };
306
+ case "signAllTransactions":
307
+ save({ ...record, pending: undefined });
308
+ return {
309
+ ok: true,
310
+ method,
311
+ transactions: (data.transactions as string[]).map((tx) => bs58.decode(tx)),
312
+ };
313
+ case "signAndSendTransaction":
314
+ save({ ...record, pending: undefined });
315
+ return { ok: true, method, signature: String(data.signature) };
316
+ case "signMessage":
317
+ save({ ...record, pending: undefined });
318
+ return { ok: true, method, signature: bs58.decode(String(data.signature)) };
319
+ default:
320
+ return {
321
+ ok: false,
322
+ method,
323
+ errorCode: "-32603",
324
+ errorMessage: "Phantom answered a request this browser did not make",
325
+ };
326
+ }
327
+ },
328
+
329
+ forget() {
330
+ storage().removeItem(storageKey);
331
+ },
332
+ };
333
+ }
334
+
335
+ function toBase64(bytes: Uint8Array): string {
336
+ let binary = "";
337
+ for (const byte of bytes) binary += String.fromCharCode(byte);
338
+ return btoa(binary);
339
+ }
340
+
341
+ /**
342
+ * Broadcasts a transaction Phantom signed (signTransaction's answer) through
343
+ * a Solana JSON-RPC endpoint and returns its base58 signature.
344
+ */
345
+ export async function sendSolanaTransaction(
346
+ rpcUrl: string,
347
+ signedTransaction: Uint8Array,
348
+ options: { skipPreflight?: boolean; fetch?: typeof fetch } = {},
349
+ ): Promise<string> {
350
+ const response = await (options.fetch ?? fetch)(rpcUrl, {
351
+ method: "POST",
352
+ headers: { "content-type": "application/json" },
353
+ body: JSON.stringify({
354
+ jsonrpc: "2.0",
355
+ id: 1,
356
+ method: "sendTransaction",
357
+ params: [
358
+ toBase64(signedTransaction),
359
+ { encoding: "base64", skipPreflight: options.skipPreflight ?? false },
360
+ ],
361
+ }),
362
+ });
363
+ const body = (await response.json()) as {
364
+ result?: string;
365
+ error?: { code: number; message: string };
366
+ };
367
+ if (body.error) {
368
+ throw Object.assign(new Error(`sendTransaction failed: ${body.error.message}`), {
369
+ code: body.error.code,
370
+ });
371
+ }
372
+ if (typeof body.result !== "string") throw new Error("sendTransaction returned no signature");
373
+ return body.result;
374
+ }
@@ -1,7 +1,15 @@
1
+ import bs58 from "bs58";
1
2
  import { describe, expect, it } from "vitest";
2
- import { phantomBrowseUrl } from "./urls";
3
+ import { decryptPayload, generateBoxKeyPair, sharedSecret } from "./crypto";
4
+ import {
5
+ parsePhantomRedirect,
6
+ phantomBrowseUrl,
7
+ phantomConnectUrl,
8
+ phantomMethodUrl,
9
+ stripPhantomRedirect,
10
+ } from "./urls";
3
11
 
4
- describe("phantomBrowseUrl", () => {
12
+ describe("phantom urls", () => {
5
13
  it("builds the browse link Phantom documents", () => {
6
14
  expect(
7
15
  phantomBrowseUrl(
@@ -14,16 +22,99 @@ describe("phantomBrowseUrl", () => {
14
22
  });
15
23
 
16
24
  it("keeps a page's own query inside the browse link", () => {
25
+ const link = phantomBrowseUrl(
26
+ "https://dev.promanager.vip/u/buy?mobile=1&x=a b",
27
+ "https://dev.promanager.vip",
28
+ {
29
+ base: "https://phantom.app/ul/",
30
+ },
31
+ );
32
+ expect(link).toBe(
33
+ "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",
34
+ );
35
+ });
36
+
37
+ it("builds connect with the dapp key in base58", () => {
38
+ const pair = generateBoxKeyPair();
39
+ const url = new URL(
40
+ phantomConnectUrl({
41
+ appUrl: "https://app.example",
42
+ dappEncryptionPublicKey: pair.publicKey,
43
+ redirectLink: "https://app.example/pay?mobile=1",
44
+ cluster: "devnet",
45
+ }),
46
+ );
47
+ expect(`${url.origin}${url.pathname}`).toBe("https://phantom.com/ul/v1/connect");
48
+ expect(Object.fromEntries(url.searchParams)).toEqual({
49
+ app_url: "https://app.example",
50
+ dapp_encryption_public_key: bs58.encode(pair.publicKey),
51
+ redirect_link: "https://app.example/pay?mobile=1",
52
+ cluster: "devnet",
53
+ });
54
+ });
55
+
56
+ it("seals a method's payload with the session for Phantom to open", () => {
57
+ const dapp = generateBoxKeyPair();
58
+ const phantom = generateBoxKeyPair();
59
+ const url = new URL(
60
+ phantomMethodUrl({
61
+ method: "signTransaction",
62
+ payload: { transaction: "3xyz" },
63
+ session: "sess",
64
+ dappEncryptionPublicKey: dapp.publicKey,
65
+ sharedSecret: sharedSecret(phantom.publicKey, dapp.secretKey),
66
+ redirectLink: "myapp://done",
67
+ }),
68
+ );
69
+ expect(url.pathname).toBe("/ul/v1/signTransaction");
70
+ expect([...url.searchParams.keys()]).toEqual([
71
+ "dapp_encryption_public_key",
72
+ "nonce",
73
+ "redirect_link",
74
+ "payload",
75
+ ]);
76
+ expect(url.searchParams.get("dapp_encryption_public_key")).toBe(bs58.encode(dapp.publicKey));
77
+ expect(bs58.decode(url.searchParams.get("nonce") ?? "")).toHaveLength(24);
17
78
  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
- },
79
+ decryptPayload(
80
+ url.searchParams.get("payload") ?? "",
81
+ url.searchParams.get("nonce") ?? "",
82
+ sharedSecret(dapp.publicKey, phantom.secretKey),
24
83
  ),
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",
84
+ ).toEqual({ transaction: "3xyz", session: "sess" });
85
+ });
86
+
87
+ it("reads an approval appended with a second ?", () => {
88
+ expect(
89
+ parsePhantomRedirect(
90
+ "https://app.example/pay?mobile=1?phantom_encryption_public_key=PK&nonce=N&data=D#top",
91
+ ),
92
+ ).toEqual({
93
+ phantomEncryptionPublicKey: "PK",
94
+ nonce: "N",
95
+ data: "D",
96
+ errorCode: undefined,
97
+ errorMessage: undefined,
98
+ });
99
+ });
100
+
101
+ it("reads a rejection", () => {
102
+ expect(
103
+ parsePhantomRedirect("https://app.example/pay?errorCode=4001&errorMessage=User%20rejected"),
104
+ ).toMatchObject({ errorCode: "4001", errorMessage: "User rejected" });
105
+ });
106
+
107
+ it("ignores a URL with no answer on it", () => {
108
+ expect(parsePhantomRedirect("https://app.example/pay?mobile=1")).toBeUndefined();
109
+ expect(parsePhantomRedirect("https://app.example/pay")).toBeUndefined();
110
+ });
111
+
112
+ it("strips the answer and keeps the page's own query and hash", () => {
113
+ expect(
114
+ stripPhantomRedirect("https://app.example/pay?mobile=1?nonce=N&data=D&errorCode=1#h"),
115
+ ).toBe("https://app.example/pay?mobile=1#h");
116
+ expect(stripPhantomRedirect("https://app.example/pay?nonce=N&data=D")).toBe(
117
+ "https://app.example/pay",
27
118
  );
28
119
  });
29
120
  });
@@ -1,16 +1,131 @@
1
+ import bs58 from "bs58";
2
+ import { encryptPayload } from "./crypto";
3
+
1
4
  /** Phantom's universal-link host. `https://phantom.app/ul` still answers; Phantom asks new code to use this one. */
2
5
  export const phantomUniversalLinkBase = "https://phantom.com/ul";
3
6
 
7
+ export type PhantomCluster = "mainnet-beta" | "testnet" | "devnet";
8
+
9
+ /**
10
+ * Deeplink provider methods, all Solana-only. `signAndSendTransaction` is
11
+ * deprecated by Phantom in favour of `signTransaction` and a broadcast by the
12
+ * page, and is kept for wallets that still honour it.
13
+ */
14
+ export type PhantomMethod =
15
+ | "connect"
16
+ | "disconnect"
17
+ | "signTransaction"
18
+ | "signAllTransactions"
19
+ | "signAndSendTransaction"
20
+ | "signMessage";
21
+
22
+ const base = (override?: string) => (override ?? phantomUniversalLinkBase).replace(/\/+$/, "");
23
+
4
24
  /**
5
25
  * 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.
26
+ * `window.phantom.ethereum` and `window.phantom.solana`. Phantom's deeplinks
27
+ * carry no EVM methods, so this is how a phone's browser reaches its EVM wallet.
8
28
  */
9
29
  export function phantomBrowseUrl(
10
30
  url: string,
11
31
  ref: string,
12
32
  options: { base?: string } = {},
13
33
  ): string {
14
- const base = (options.base ?? phantomUniversalLinkBase).replace(/\/+$/, "");
15
- return `${base}/browse/${encodeURIComponent(url)}?ref=${encodeURIComponent(ref)}`;
34
+ return `${base(options.base)}/browse/${encodeURIComponent(url)}?ref=${encodeURIComponent(ref)}`;
35
+ }
36
+
37
+ export interface PhantomConnectUrlParams {
38
+ appUrl: string;
39
+ dappEncryptionPublicKey: Uint8Array;
40
+ redirectLink: string;
41
+ cluster?: PhantomCluster;
42
+ base?: string;
43
+ }
44
+
45
+ export function phantomConnectUrl(params: PhantomConnectUrlParams): string {
46
+ const query = new URLSearchParams({
47
+ app_url: params.appUrl,
48
+ dapp_encryption_public_key: bs58.encode(params.dappEncryptionPublicKey),
49
+ redirect_link: params.redirectLink,
50
+ });
51
+ if (params.cluster) query.set("cluster", params.cluster);
52
+ return `${base(params.base)}/v1/connect?${query}`;
53
+ }
54
+
55
+ export interface PhantomMethodUrlParams {
56
+ method: Exclude<PhantomMethod, "connect">;
57
+ /** The fields the method's payload carries besides `session`. */
58
+ payload: Record<string, unknown>;
59
+ session: string;
60
+ dappEncryptionPublicKey: Uint8Array;
61
+ sharedSecret: Uint8Array;
62
+ redirectLink: string;
63
+ nonce?: Uint8Array;
64
+ base?: string;
65
+ }
66
+
67
+ /** A provider-method link: the payload, session included, sealed with the session's shared secret. */
68
+ export function phantomMethodUrl(params: PhantomMethodUrlParams): string {
69
+ const sealed = encryptPayload(
70
+ { ...params.payload, session: params.session },
71
+ params.sharedSecret,
72
+ params.nonce,
73
+ );
74
+ const query = new URLSearchParams({
75
+ dapp_encryption_public_key: bs58.encode(params.dappEncryptionPublicKey),
76
+ nonce: sealed.nonce,
77
+ redirect_link: params.redirectLink,
78
+ payload: sealed.payload,
79
+ });
80
+ return `${base(params.base)}/v1/${params.method}?${query}`;
81
+ }
82
+
83
+ /** What Phantom appends to `redirect_link`. */
84
+ export interface PhantomRedirectParams {
85
+ phantomEncryptionPublicKey?: string;
86
+ nonce?: string;
87
+ data?: string;
88
+ errorCode?: string;
89
+ errorMessage?: string;
90
+ }
91
+
92
+ const responseKeys = [
93
+ "phantom_encryption_public_key",
94
+ "nonce",
95
+ "data",
96
+ "errorCode",
97
+ "errorMessage",
98
+ ] as const;
99
+
100
+ /**
101
+ * Reads Phantom's response off the return URL. Phantom appends with `?` even
102
+ * when the redirect link already has a query, so every `?` after the first is
103
+ * read as `&`. Returns undefined when the URL carries no response.
104
+ */
105
+ export function parsePhantomRedirect(href: string): PhantomRedirectParams | undefined {
106
+ const [beforeHash] = href.split("#");
107
+ const at = beforeHash.indexOf("?");
108
+ if (at < 0) return undefined;
109
+ const query = new URLSearchParams(beforeHash.slice(at + 1).replace(/\?/g, "&"));
110
+ if (!query.has("errorCode") && !(query.has("data") && query.has("nonce"))) return undefined;
111
+ return {
112
+ phantomEncryptionPublicKey: query.get("phantom_encryption_public_key") ?? undefined,
113
+ nonce: query.get("nonce") ?? undefined,
114
+ data: query.get("data") ?? undefined,
115
+ errorCode: query.get("errorCode") ?? undefined,
116
+ errorMessage: query.get("errorMessage") ?? undefined,
117
+ };
118
+ }
119
+
120
+ /** `href` without Phantom's response parameters, for `history.replaceState` after handling it. */
121
+ export function stripPhantomRedirect(href: string): string {
122
+ const hashAt = href.indexOf("#");
123
+ const hash = hashAt >= 0 ? href.slice(hashAt) : "";
124
+ const beforeHash = hashAt >= 0 ? href.slice(0, hashAt) : href;
125
+ const at = beforeHash.indexOf("?");
126
+ if (at < 0) return href;
127
+ const query = new URLSearchParams(beforeHash.slice(at + 1).replace(/\?/g, "&"));
128
+ for (const key of responseKeys) query.delete(key);
129
+ const rest = query.toString();
130
+ return `${beforeHash.slice(0, at)}${rest ? `?${rest}` : ""}${hash}`;
16
131
  }
@@ -0,0 +1,27 @@
1
+ /** An x25519 keypair, the shape of tweetnacl's `box.keyPair()`. */
2
+ export interface BoxKeyPair {
3
+ publicKey: Uint8Array;
4
+ secretKey: Uint8Array;
5
+ }
6
+ /** An encrypted payload as Phantom's deeplinks carry it: both fields base58. */
7
+ export interface EncryptedPayload {
8
+ nonce: string;
9
+ payload: string;
10
+ }
11
+ export declare class PhantomDecryptError extends Error {
12
+ constructor(message?: string);
13
+ }
14
+ /** A fresh dapp keypair. Phantom recommends one per `connect`. */
15
+ export declare function generateBoxKeyPair(): BoxKeyPair;
16
+ export declare function boxKeyPairFromSecretKey(secretKey: Uint8Array): BoxKeyPair;
17
+ /**
18
+ * tweetnacl's `box.before(theirPublicKey, mySecretKey)`: HSalsa20 of the
19
+ * x25519 shared point under a zero nonce. Phantom keys every payload of a
20
+ * session with it.
21
+ */
22
+ export declare function sharedSecret(theirPublicKey: Uint8Array, mySecretKey: Uint8Array): Uint8Array;
23
+ /** tweetnacl's `box.after`: XSalsa20-Poly1305 under the shared secret. */
24
+ export declare function encryptPayload(value: unknown, secret: Uint8Array, nonce?: Uint8Array): EncryptedPayload;
25
+ /** tweetnacl's `box.open.after`, then JSON. Throws PhantomDecryptError on a bad tag. */
26
+ export declare function decryptPayload<T = unknown>(data: string, nonce: string, secret: Uint8Array): T;
27
+ //# sourceMappingURL=crypto.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"crypto.d.ts","sourceRoot":"","sources":["../../src/phantom/crypto.ts"],"names":[],"mappings":"AAIA,mEAAmE;AACnE,MAAM,WAAW,UAAU;IACzB,SAAS,EAAE,UAAU,CAAC;IACtB,SAAS,EAAE,UAAU,CAAC;CACvB;AAED,gFAAgF;AAChF,MAAM,WAAW,gBAAgB;IAC/B,KAAK,EAAE,MAAM,CAAC;IACd,OAAO,EAAE,MAAM,CAAC;CACjB;AAED,qBAAa,mBAAoB,SAAQ,KAAK;gBAChC,OAAO,SAAiE;CAIrF;AAQD,kEAAkE;AAClE,wBAAgB,kBAAkB,IAAI,UAAU,CAG/C;AAED,wBAAgB,uBAAuB,CAAC,SAAS,EAAE,UAAU,GAAG,UAAU,CAEzE;AAkBD;;;;GAIG;AACH,wBAAgB,YAAY,CAAC,cAAc,EAAE,UAAU,EAAE,WAAW,EAAE,UAAU,GAAG,UAAU,CAK5F;AAED,0EAA0E;AAC1E,wBAAgB,cAAc,CAC5B,KAAK,EAAE,OAAO,EACd,MAAM,EAAE,UAAU,EAClB,KAAK,GAAE,UAA4B,GAClC,gBAAgB,CAIlB;AAED,wFAAwF;AACxF,wBAAgB,cAAc,CAAC,CAAC,GAAG,OAAO,EAAE,IAAI,EAAE,MAAM,EAAE,KAAK,EAAE,MAAM,EAAE,MAAM,EAAE,UAAU,GAAG,CAAC,CAQ9F"}
@@ -1,3 +1,5 @@
1
+ export { boxKeyPairFromSecretKey, decryptPayload, encryptPayload, generateBoxKeyPair, PhantomDecryptError, sharedSecret, type BoxKeyPair, type EncryptedPayload, } from "./crypto";
1
2
  export { connectPhantomEvm, discoverPhantomEthereumProvider, openInPhantom, phantomEthereumProvider, phantomEvmRoute, PhantomNotInjectedError, type ConnectPhantomEvmOptions, type OpenInPhantomOptions, type PhantomEthereumProvider, } from "./evm";
2
- export { phantomBrowseUrl, phantomUniversalLinkBase } from "./urls";
3
+ export { phantomDeeplink, PhantomNotConnectedError, sendSolanaTransaction, type PhantomDeeplink, type PhantomDeeplinkConfig, type PhantomRedirectResult, type PhantomSessionRecord, type PhantomStorage, } from "./solana";
4
+ export { parsePhantomRedirect, phantomBrowseUrl, phantomConnectUrl, phantomMethodUrl, phantomUniversalLinkBase, stripPhantomRedirect, type PhantomCluster, type PhantomConnectUrlParams, type PhantomMethod, type PhantomMethodUrlParams, type PhantomRedirectParams, } from "./urls";
3
5
  //# sourceMappingURL=index.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/phantom/index.ts"],"names":[],"mappings":"AAIA,OAAO,EACL,iBAAiB,EACjB,+BAA+B,EAC/B,aAAa,EACb,uBAAuB,EACvB,eAAe,EACf,uBAAuB,EACvB,KAAK,wBAAwB,EAC7B,KAAK,oBAAoB,EACzB,KAAK,uBAAuB,GAC7B,MAAM,OAAO,CAAC;AACf,OAAO,EAAE,gBAAgB,EAAE,wBAAwB,EAAE,MAAM,QAAQ,CAAC"}
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/phantom/index.ts"],"names":[],"mappings":"AAKA,OAAO,EACL,uBAAuB,EACvB,cAAc,EACd,cAAc,EACd,kBAAkB,EAClB,mBAAmB,EACnB,YAAY,EACZ,KAAK,UAAU,EACf,KAAK,gBAAgB,GACtB,MAAM,UAAU,CAAC;AAClB,OAAO,EACL,iBAAiB,EACjB,+BAA+B,EAC/B,aAAa,EACb,uBAAuB,EACvB,eAAe,EACf,uBAAuB,EACvB,KAAK,wBAAwB,EAC7B,KAAK,oBAAoB,EACzB,KAAK,uBAAuB,GAC7B,MAAM,OAAO,CAAC;AACf,OAAO,EACL,eAAe,EACf,wBAAwB,EACxB,qBAAqB,EACrB,KAAK,eAAe,EACpB,KAAK,qBAAqB,EAC1B,KAAK,qBAAqB,EAC1B,KAAK,oBAAoB,EACzB,KAAK,cAAc,GACpB,MAAM,UAAU,CAAC;AAClB,OAAO,EACL,oBAAoB,EACpB,gBAAgB,EAChB,iBAAiB,EACjB,gBAAgB,EAChB,wBAAwB,EACxB,oBAAoB,EACpB,KAAK,cAAc,EACnB,KAAK,uBAAuB,EAC5B,KAAK,aAAa,EAClB,KAAK,sBAAsB,EAC3B,KAAK,qBAAqB,GAC3B,MAAM,QAAQ,CAAC"}