@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.
- package/README.md +24 -6
- package/dist/cjs/connectkit/connectors/metaMaskConnectConnector.cjs +14 -3
- package/dist/cjs/connectkit/connectors/metaMaskConnectConnector.native.js +14 -3
- package/dist/cjs/connectkit/connectors/metaMaskConnectConnector.native.js.map +1 -1
- package/dist/cjs/phantom/crypto.cjs +108 -0
- package/dist/cjs/phantom/crypto.native.js +224 -0
- package/dist/cjs/phantom/crypto.native.js.map +1 -0
- package/dist/cjs/phantom/index.cjs +16 -1
- package/dist/cjs/phantom/index.native.js +16 -1
- package/dist/cjs/phantom/index.native.js.map +1 -1
- package/dist/cjs/phantom/solana.cjs +309 -0
- package/dist/cjs/phantom/solana.native.js +466 -0
- package/dist/cjs/phantom/solana.native.js.map +1 -0
- package/dist/cjs/phantom/urls.cjs +68 -3
- package/dist/cjs/phantom/urls.native.js +92 -4
- package/dist/cjs/phantom/urls.native.js.map +1 -1
- package/dist/esm/connectkit/connectors/metaMaskConnectConnector.mjs +14 -3
- package/dist/esm/connectkit/connectors/metaMaskConnectConnector.mjs.map +1 -1
- package/dist/esm/connectkit/connectors/metaMaskConnectConnector.native.js +14 -3
- package/dist/esm/connectkit/connectors/metaMaskConnectConnector.native.js.map +1 -1
- package/dist/esm/phantom/crypto.mjs +67 -0
- package/dist/esm/phantom/crypto.mjs.map +1 -0
- package/dist/esm/phantom/crypto.native.js +180 -0
- package/dist/esm/phantom/crypto.native.js.map +1 -0
- package/dist/esm/phantom/index.mjs +4 -2
- package/dist/esm/phantom/index.mjs.map +1 -1
- package/dist/esm/phantom/index.native.js +4 -2
- package/dist/esm/phantom/index.native.js.map +1 -1
- package/dist/esm/phantom/solana.mjs +271 -0
- package/dist/esm/phantom/solana.mjs.map +1 -0
- package/dist/esm/phantom/solana.native.js +425 -0
- package/dist/esm/phantom/solana.native.js.map +1 -0
- package/dist/esm/phantom/urls.mjs +53 -3
- package/dist/esm/phantom/urls.mjs.map +1 -1
- package/dist/esm/phantom/urls.native.js +77 -4
- package/dist/esm/phantom/urls.native.js.map +1 -1
- package/dist/jsx/connectkit/connectors/metaMaskConnectConnector.mjs +14 -3
- package/dist/jsx/connectkit/connectors/metaMaskConnectConnector.mjs.map +1 -1
- package/dist/jsx/connectkit/connectors/metaMaskConnectConnector.native.js +14 -3
- package/dist/jsx/connectkit/connectors/metaMaskConnectConnector.native.js.map +1 -1
- package/dist/jsx/phantom/crypto.mjs +67 -0
- package/dist/jsx/phantom/crypto.mjs.map +1 -0
- package/dist/jsx/phantom/crypto.native.js +224 -0
- package/dist/jsx/phantom/crypto.native.js.map +1 -0
- package/dist/jsx/phantom/index.mjs +4 -2
- package/dist/jsx/phantom/index.mjs.map +1 -1
- package/dist/jsx/phantom/index.native.js +16 -1
- package/dist/jsx/phantom/index.native.js.map +1 -1
- package/dist/jsx/phantom/solana.mjs +271 -0
- package/dist/jsx/phantom/solana.mjs.map +1 -0
- package/dist/jsx/phantom/solana.native.js +466 -0
- package/dist/jsx/phantom/solana.native.js.map +1 -0
- package/dist/jsx/phantom/urls.mjs +53 -3
- package/dist/jsx/phantom/urls.mjs.map +1 -1
- package/dist/jsx/phantom/urls.native.js +92 -4
- package/dist/jsx/phantom/urls.native.js.map +1 -1
- package/dist/keycloak/login.iife.js +63 -63
- package/package.json +7 -6
- package/src/connectkit/connectors/metaMaskConnectConnector.spec.ts +44 -0
- package/src/connectkit/connectors/metaMaskConnectConnector.ts +30 -2
- package/src/phantom/crypto.spec.ts +61 -0
- package/src/phantom/crypto.ts +88 -0
- package/src/phantom/index.ts +38 -5
- package/src/phantom/solana.spec.ts +262 -0
- package/src/phantom/solana.ts +374 -0
- package/src/phantom/urls.spec.ts +101 -10
- package/src/phantom/urls.ts +119 -4
- package/types/connectkit/connectors/metaMaskConnectConnector.d.ts.map +1 -1
- package/types/phantom/crypto.d.ts +27 -0
- package/types/phantom/crypto.d.ts.map +1 -0
- package/types/phantom/index.d.ts +3 -1
- package/types/phantom/index.d.ts.map +1 -1
- package/types/phantom/solana.d.ts +113 -0
- package/types/phantom/solana.d.ts.map +1 -0
- package/types/phantom/urls.d.ts +46 -2
- 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.
|
|
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.
|
|
124
|
-
"@multiplatform.one/gpg": "7.
|
|
125
|
-
"@multiplatform.one/platform": "7.
|
|
126
|
-
"@multiplatform.one/theme": "7.
|
|
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.
|
|
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
|
-
|
|
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 = {
|
|
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
|
+
}
|
package/src/phantom/index.ts
CHANGED
|
@@ -1,7 +1,18 @@
|
|
|
1
|
-
// `@multiplatform.one/web3/phantom`: Phantom
|
|
2
|
-
//
|
|
3
|
-
// Phantom's in-app browser (openInPhantom
|
|
4
|
-
//
|
|
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 {
|
|
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
|
+
});
|