@dubsdotapp/expo 0.2.10 → 0.2.11
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/dist/index.d.mts +68 -2
- package/dist/index.d.ts +68 -2
- package/dist/index.js +759 -284
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +521 -47
- package/dist/index.mjs.map +1 -1
- package/package.json +3 -2
- package/src/index.ts +2 -0
- package/src/managed-wallet.tsx +150 -46
- package/src/provider.tsx +8 -0
- package/src/storage.ts +1 -0
- package/src/wallet/index.ts +2 -0
- package/src/wallet/phantom-deeplink/crypto.ts +49 -0
- package/src/wallet/phantom-deeplink/deeplink-handler.ts +177 -0
- package/src/wallet/phantom-deeplink/index.ts +2 -0
- package/src/wallet/phantom-deeplink/phantom-deeplink-adapter.ts +323 -0
|
@@ -0,0 +1,323 @@
|
|
|
1
|
+
import { PublicKey, Transaction } from '@solana/web3.js';
|
|
2
|
+
import bs58 from 'bs58';
|
|
3
|
+
import type { WalletAdapter } from '../types';
|
|
4
|
+
import {
|
|
5
|
+
generateKeyPair,
|
|
6
|
+
deriveSharedSecret,
|
|
7
|
+
encryptPayload,
|
|
8
|
+
decryptPayload,
|
|
9
|
+
} from './crypto';
|
|
10
|
+
import { DeeplinkHandler } from './deeplink-handler';
|
|
11
|
+
|
|
12
|
+
const TAG = '[Dubs:PhantomAdapter]';
|
|
13
|
+
|
|
14
|
+
/** Serializable session state — save this to restore without a deeplink round-trip. */
|
|
15
|
+
export interface PhantomSession {
|
|
16
|
+
/** Our x25519 public key (base58) */
|
|
17
|
+
dappPublicKey: string;
|
|
18
|
+
/** Our x25519 secret key (base58) */
|
|
19
|
+
dappSecretKey: string;
|
|
20
|
+
/** Phantom's x25519 public key (base58) */
|
|
21
|
+
phantomPublicKey: string;
|
|
22
|
+
/** Shared secret (base58) */
|
|
23
|
+
sharedSecret: string;
|
|
24
|
+
/** Phantom session token (opaque string) */
|
|
25
|
+
sessionToken: string;
|
|
26
|
+
/** User's wallet public key (base58) */
|
|
27
|
+
walletPublicKey: string;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export interface PhantomDeeplinkAdapterConfig {
|
|
31
|
+
/** The deeplink redirect URI, e.g. "myapp://phantom-callback" */
|
|
32
|
+
redirectUri: string;
|
|
33
|
+
/** Shown in Phantom's connect screen */
|
|
34
|
+
appUrl?: string;
|
|
35
|
+
/** Solana cluster: 'devnet' | 'mainnet-beta' */
|
|
36
|
+
cluster?: string;
|
|
37
|
+
/** Deeplink round-trip timeout in ms (default 120000) */
|
|
38
|
+
timeout?: number;
|
|
39
|
+
/** Called when the Phantom session changes (save/clear for persistence) */
|
|
40
|
+
onSessionChange?: (session: PhantomSession | null) => void;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
let requestCounter = 0;
|
|
44
|
+
function nextRequestId(): string {
|
|
45
|
+
return `req${Date.now()}_${++requestCounter}`;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Phantom wallet adapter using deeplinks (works on iOS and Android).
|
|
50
|
+
* Uses x25519 encrypted communication per Phantom's deeplink protocol.
|
|
51
|
+
*/
|
|
52
|
+
export class PhantomDeeplinkAdapter implements WalletAdapter {
|
|
53
|
+
private _publicKey: PublicKey | null = null;
|
|
54
|
+
private _connected = false;
|
|
55
|
+
private _dappKeyPair: { publicKey: Uint8Array; secretKey: Uint8Array } | null = null;
|
|
56
|
+
private _sharedSecret: Uint8Array | null = null;
|
|
57
|
+
private _sessionToken: string | null = null;
|
|
58
|
+
private _phantomPublicKey: Uint8Array | null = null;
|
|
59
|
+
|
|
60
|
+
private readonly config: PhantomDeeplinkAdapterConfig;
|
|
61
|
+
private readonly handler: DeeplinkHandler;
|
|
62
|
+
private readonly timeout: number;
|
|
63
|
+
|
|
64
|
+
constructor(config: PhantomDeeplinkAdapterConfig) {
|
|
65
|
+
console.log(TAG, 'Creating adapter with config:', {
|
|
66
|
+
redirectUri: config.redirectUri,
|
|
67
|
+
appUrl: config.appUrl,
|
|
68
|
+
cluster: config.cluster,
|
|
69
|
+
timeout: config.timeout,
|
|
70
|
+
});
|
|
71
|
+
this.config = config;
|
|
72
|
+
this.timeout = config.timeout ?? 120_000;
|
|
73
|
+
this.handler = new DeeplinkHandler(config.redirectUri);
|
|
74
|
+
this.handler.start();
|
|
75
|
+
console.log(TAG, 'Adapter created and deeplink listener started');
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
get publicKey(): PublicKey | null {
|
|
79
|
+
return this._publicKey;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
get connected(): boolean {
|
|
83
|
+
return this._connected;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Restore a previously saved session without opening Phantom.
|
|
88
|
+
* Call this before connect() if you have persisted session state.
|
|
89
|
+
*/
|
|
90
|
+
restoreSession(saved: PhantomSession): void {
|
|
91
|
+
console.log(TAG, 'Restoring session for wallet:', saved.walletPublicKey);
|
|
92
|
+
this._dappKeyPair = {
|
|
93
|
+
publicKey: bs58.decode(saved.dappPublicKey),
|
|
94
|
+
secretKey: bs58.decode(saved.dappSecretKey),
|
|
95
|
+
};
|
|
96
|
+
this._phantomPublicKey = bs58.decode(saved.phantomPublicKey);
|
|
97
|
+
this._sharedSecret = bs58.decode(saved.sharedSecret);
|
|
98
|
+
this._sessionToken = saved.sessionToken;
|
|
99
|
+
this._publicKey = new PublicKey(saved.walletPublicKey);
|
|
100
|
+
this._connected = true;
|
|
101
|
+
console.log(TAG, 'Session restored successfully — connected:', this._publicKey.toBase58());
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/** Serialize the current session for persistence. Returns null if not connected. */
|
|
105
|
+
getSession(): PhantomSession | null {
|
|
106
|
+
if (
|
|
107
|
+
!this._connected ||
|
|
108
|
+
!this._dappKeyPair ||
|
|
109
|
+
!this._phantomPublicKey ||
|
|
110
|
+
!this._sharedSecret ||
|
|
111
|
+
!this._sessionToken ||
|
|
112
|
+
!this._publicKey
|
|
113
|
+
) {
|
|
114
|
+
return null;
|
|
115
|
+
}
|
|
116
|
+
return {
|
|
117
|
+
dappPublicKey: bs58.encode(this._dappKeyPair.publicKey),
|
|
118
|
+
dappSecretKey: bs58.encode(this._dappKeyPair.secretKey),
|
|
119
|
+
phantomPublicKey: bs58.encode(this._phantomPublicKey),
|
|
120
|
+
sharedSecret: bs58.encode(this._sharedSecret),
|
|
121
|
+
sessionToken: this._sessionToken,
|
|
122
|
+
walletPublicKey: this._publicKey.toBase58(),
|
|
123
|
+
};
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
async connect(): Promise<void> {
|
|
127
|
+
console.log(TAG, 'connect() — generating x25519 keypair');
|
|
128
|
+
// Generate a fresh keypair for this connection
|
|
129
|
+
this._dappKeyPair = generateKeyPair();
|
|
130
|
+
const dappPubBase58 = bs58.encode(this._dappKeyPair.publicKey);
|
|
131
|
+
console.log(TAG, 'Dapp public key:', dappPubBase58);
|
|
132
|
+
|
|
133
|
+
const requestId = nextRequestId();
|
|
134
|
+
const redirectLink = `${this.config.redirectUri}/${requestId}`;
|
|
135
|
+
console.log(TAG, `connect() requestId=${requestId} redirectLink=${redirectLink}`);
|
|
136
|
+
|
|
137
|
+
const params = new URLSearchParams({
|
|
138
|
+
dapp_encryption_public_key: dappPubBase58,
|
|
139
|
+
cluster: this.config.cluster || 'mainnet-beta',
|
|
140
|
+
redirect_link: redirectLink,
|
|
141
|
+
});
|
|
142
|
+
if (this.config.appUrl) {
|
|
143
|
+
params.set('app_url', this.config.appUrl);
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
const url = `https://phantom.app/ul/v1/connect?${params.toString()}`;
|
|
147
|
+
console.log(TAG, 'Opening Phantom connect deeplink...');
|
|
148
|
+
const response = await this.handler.send(url, requestId, this.timeout);
|
|
149
|
+
console.log(TAG, 'Received connect response, param keys:', Object.keys(response.params).join(', '));
|
|
150
|
+
|
|
151
|
+
// Extract Phantom's public key and derive shared secret
|
|
152
|
+
const phantomPubBase58 = response.params.phantom_encryption_public_key;
|
|
153
|
+
if (!phantomPubBase58) {
|
|
154
|
+
console.log(TAG, 'ERROR: No phantom_encryption_public_key in response');
|
|
155
|
+
throw new Error('Phantom did not return an encryption public key');
|
|
156
|
+
}
|
|
157
|
+
console.log(TAG, 'Phantom public key:', phantomPubBase58);
|
|
158
|
+
|
|
159
|
+
this._phantomPublicKey = bs58.decode(phantomPubBase58);
|
|
160
|
+
this._sharedSecret = deriveSharedSecret(
|
|
161
|
+
this._dappKeyPair.secretKey,
|
|
162
|
+
this._phantomPublicKey,
|
|
163
|
+
);
|
|
164
|
+
console.log(TAG, 'Shared secret derived, decrypting response...');
|
|
165
|
+
|
|
166
|
+
// Decrypt the connect response data
|
|
167
|
+
const data = decryptPayload(
|
|
168
|
+
response.params.data,
|
|
169
|
+
response.params.nonce,
|
|
170
|
+
this._sharedSecret,
|
|
171
|
+
);
|
|
172
|
+
console.log(TAG, 'Decrypted connect data — public_key:', data.public_key, 'session length:', data.session?.length);
|
|
173
|
+
|
|
174
|
+
this._sessionToken = data.session;
|
|
175
|
+
this._publicKey = new PublicKey(data.public_key);
|
|
176
|
+
this._connected = true;
|
|
177
|
+
|
|
178
|
+
console.log(TAG, 'Connected! Wallet:', this._publicKey.toBase58());
|
|
179
|
+
|
|
180
|
+
// Notify consumer of new session
|
|
181
|
+
this.config.onSessionChange?.(this.getSession());
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
disconnect(): void {
|
|
185
|
+
console.log(TAG, 'disconnect() — clearing state, was connected:', this._connected, 'wallet:', this._publicKey?.toBase58());
|
|
186
|
+
this._publicKey = null;
|
|
187
|
+
this._connected = false;
|
|
188
|
+
this._dappKeyPair = null;
|
|
189
|
+
this._sharedSecret = null;
|
|
190
|
+
this._sessionToken = null;
|
|
191
|
+
this._phantomPublicKey = null;
|
|
192
|
+
this.config.onSessionChange?.(null);
|
|
193
|
+
console.log(TAG, 'Disconnected');
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
async signTransaction(transaction: Transaction): Promise<Transaction> {
|
|
197
|
+
this.assertConnected();
|
|
198
|
+
console.log(TAG, 'signTransaction() — serializing transaction');
|
|
199
|
+
|
|
200
|
+
const serializedTx = bs58.encode(
|
|
201
|
+
transaction.serialize({ requireAllSignatures: false, verifySignatures: false }),
|
|
202
|
+
);
|
|
203
|
+
console.log(TAG, 'Transaction serialized, length:', serializedTx.length);
|
|
204
|
+
|
|
205
|
+
const { nonce, ciphertext } = encryptPayload(
|
|
206
|
+
{ transaction: serializedTx, session: this._sessionToken },
|
|
207
|
+
this._sharedSecret!,
|
|
208
|
+
);
|
|
209
|
+
|
|
210
|
+
const requestId = nextRequestId();
|
|
211
|
+
const redirectLink = `${this.config.redirectUri}/${requestId}`;
|
|
212
|
+
console.log(TAG, `signTransaction() requestId=${requestId}`);
|
|
213
|
+
|
|
214
|
+
const params = new URLSearchParams({
|
|
215
|
+
dapp_encryption_public_key: bs58.encode(this._dappKeyPair!.publicKey),
|
|
216
|
+
nonce,
|
|
217
|
+
payload: ciphertext,
|
|
218
|
+
redirect_link: redirectLink,
|
|
219
|
+
});
|
|
220
|
+
|
|
221
|
+
const url = `https://phantom.app/ul/v1/signTransaction?${params.toString()}`;
|
|
222
|
+
console.log(TAG, 'Opening Phantom signTransaction deeplink...');
|
|
223
|
+
const response = await this.handler.send(url, requestId, this.timeout);
|
|
224
|
+
console.log(TAG, 'Received signTransaction response');
|
|
225
|
+
|
|
226
|
+
const data = decryptPayload(
|
|
227
|
+
response.params.data,
|
|
228
|
+
response.params.nonce,
|
|
229
|
+
this._sharedSecret!,
|
|
230
|
+
);
|
|
231
|
+
console.log(TAG, 'Decrypted signed transaction, length:', data.transaction?.length);
|
|
232
|
+
|
|
233
|
+
return Transaction.from(bs58.decode(data.transaction));
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
async signAndSendTransaction(transaction: Transaction): Promise<string> {
|
|
237
|
+
this.assertConnected();
|
|
238
|
+
console.log(TAG, 'signAndSendTransaction() — serializing transaction');
|
|
239
|
+
|
|
240
|
+
const serializedTx = bs58.encode(
|
|
241
|
+
transaction.serialize({ requireAllSignatures: false, verifySignatures: false }),
|
|
242
|
+
);
|
|
243
|
+
console.log(TAG, 'Transaction serialized, length:', serializedTx.length);
|
|
244
|
+
|
|
245
|
+
const { nonce, ciphertext } = encryptPayload(
|
|
246
|
+
{ transaction: serializedTx, session: this._sessionToken },
|
|
247
|
+
this._sharedSecret!,
|
|
248
|
+
);
|
|
249
|
+
|
|
250
|
+
const requestId = nextRequestId();
|
|
251
|
+
const redirectLink = `${this.config.redirectUri}/${requestId}`;
|
|
252
|
+
console.log(TAG, `signAndSendTransaction() requestId=${requestId}`);
|
|
253
|
+
|
|
254
|
+
const params = new URLSearchParams({
|
|
255
|
+
dapp_encryption_public_key: bs58.encode(this._dappKeyPair!.publicKey),
|
|
256
|
+
nonce,
|
|
257
|
+
payload: ciphertext,
|
|
258
|
+
redirect_link: redirectLink,
|
|
259
|
+
});
|
|
260
|
+
|
|
261
|
+
const url = `https://phantom.app/ul/v1/signAndSendTransaction?${params.toString()}`;
|
|
262
|
+
console.log(TAG, 'Opening Phantom signAndSendTransaction deeplink...');
|
|
263
|
+
const response = await this.handler.send(url, requestId, this.timeout);
|
|
264
|
+
console.log(TAG, 'Received signAndSendTransaction response');
|
|
265
|
+
|
|
266
|
+
const data = decryptPayload(
|
|
267
|
+
response.params.data,
|
|
268
|
+
response.params.nonce,
|
|
269
|
+
this._sharedSecret!,
|
|
270
|
+
);
|
|
271
|
+
console.log(TAG, 'Transaction sent! Signature:', data.signature);
|
|
272
|
+
|
|
273
|
+
return data.signature as string;
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
async signMessage(message: Uint8Array): Promise<Uint8Array> {
|
|
277
|
+
this.assertConnected();
|
|
278
|
+
console.log(TAG, 'signMessage() — message length:', message.length);
|
|
279
|
+
|
|
280
|
+
const { nonce, ciphertext } = encryptPayload(
|
|
281
|
+
{ message: bs58.encode(message), session: this._sessionToken },
|
|
282
|
+
this._sharedSecret!,
|
|
283
|
+
);
|
|
284
|
+
|
|
285
|
+
const requestId = nextRequestId();
|
|
286
|
+
const redirectLink = `${this.config.redirectUri}/${requestId}`;
|
|
287
|
+
console.log(TAG, `signMessage() requestId=${requestId}`);
|
|
288
|
+
|
|
289
|
+
const params = new URLSearchParams({
|
|
290
|
+
dapp_encryption_public_key: bs58.encode(this._dappKeyPair!.publicKey),
|
|
291
|
+
nonce,
|
|
292
|
+
payload: ciphertext,
|
|
293
|
+
redirect_link: redirectLink,
|
|
294
|
+
});
|
|
295
|
+
|
|
296
|
+
const url = `https://phantom.app/ul/v1/signMessage?${params.toString()}`;
|
|
297
|
+
console.log(TAG, 'Opening Phantom signMessage deeplink...');
|
|
298
|
+
const response = await this.handler.send(url, requestId, this.timeout);
|
|
299
|
+
console.log(TAG, 'Received signMessage response');
|
|
300
|
+
|
|
301
|
+
const data = decryptPayload(
|
|
302
|
+
response.params.data,
|
|
303
|
+
response.params.nonce,
|
|
304
|
+
this._sharedSecret!,
|
|
305
|
+
);
|
|
306
|
+
console.log(TAG, 'Message signed, signature:', data.signature?.substring(0, 20) + '...');
|
|
307
|
+
|
|
308
|
+
return bs58.decode(data.signature);
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
/** Remove the Linking event listener. Call when the adapter is no longer needed. */
|
|
312
|
+
destroy(): void {
|
|
313
|
+
console.log(TAG, 'destroy() — cleaning up');
|
|
314
|
+
this.handler.destroy();
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
private assertConnected(): void {
|
|
318
|
+
if (!this._connected || !this._sharedSecret || !this._sessionToken) {
|
|
319
|
+
console.log(TAG, 'assertConnected FAILED — connected:', this._connected, 'hasSecret:', !!this._sharedSecret, 'hasSession:', !!this._sessionToken);
|
|
320
|
+
throw new Error('Wallet not connected');
|
|
321
|
+
}
|
|
322
|
+
}
|
|
323
|
+
}
|