@multiplatform.one/web3 7.33.1 → 7.34.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 +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
@@ -1 +1 @@
1
- {"version":3,"names":["__defProp","Object","defineProperty","__getOwnPropDesc","getOwnPropertyDescriptor","__getOwnPropNames","getOwnPropertyNames","__hasOwnProp","prototype","hasOwnProperty"],"sources":["../../../src/phantom/urls.ts"],"sourcesContent":[null],"mappings":"AACO,YAAM;;AAON,IAAAA,SAAS,GAAAC,MAAA,CAAAC,cAGd;AAEA,IAAAC,gBAAc,GAAAF,MAAQ,CAAAG,wBAAQ;AAC9B,IAAAC,iBAAc,GAAAJ,MAAW,CAAAK,mBAAuB;AAClD,IAAAC,YAAA,GAAAN,MAAA,CAAAO,SAAA,CAAAC,cAAA","ignoreList":[]}
1
+ {"version":3,"names":["__create","Object","create","__defProp","defineProperty","__getOwnPropDesc","getOwnPropertyDescriptor","__getOwnPropNames","getOwnPropertyNames","__getProtoOf","getPrototypeOf","__hasOwnProp","prototype","hasOwnProperty","__export","target","all","name","get","enumerable","__copyProps","to","from","except","desc","key","call","__toESM","mod","isNodeMode","__esModule","value","__toCommonJS","urls_exports","parsePhantomRedirect","phantomBrowseUrl","phantomConnectUrl","phantomMethodUrl","phantomUniversalLinkBase","stripPhantomRedirect","module","exports","import_bs58","require","import_crypto","base","base2","override","replace","url","ref","options","arguments","length","encodeURIComponent","params","query","URLSearchParams","app_url","appUrl","dapp_encryption_public_key","default","encode","dappEncryptionPublicKey","redirect_link","redirectLink","cluster","set","sealed","encryptPayload","payload","session","sharedSecret","nonce"],"sources":["../../../src/phantom/urls.ts"],"sourcesContent":[null],"mappings":"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","ignoreList":[]}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@multiplatform.one/web3",
3
- "version": "7.33.1",
3
+ "version": "7.34.1",
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.1",
124
- "@multiplatform.one/gpg": "7.33.1",
125
- "@multiplatform.one/platform": "7.33.1",
126
- "@multiplatform.one/theme": "7.33.1",
123
+ "@multiplatform.one/connectkit": "7.34.1",
124
+ "@multiplatform.one/gpg": "7.34.1",
125
+ "@multiplatform.one/platform": "7.34.1",
126
+ "@multiplatform.one/theme": "7.34.1",
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.1",
149
+ "@multiplatform.one/config": "7.34.1",
149
150
  "@tamagui/build": "2.7.6",
150
151
  "@tanstack/react-query": "^5.100.7",
151
152
  "@testing-library/jest-dom": "^6.9.1",
@@ -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
+ });