import { PublicKey, Transaction } from '@solana/web3.js'; import bs58 from 'bs58'; import type { WalletAdapter } from '../types'; import type { TokenStorage } from '../../storage'; import { STORAGE_KEYS } from '../../storage'; import { generateKeyPair, deriveSharedSecret, encryptPayload, decryptPayload, } from './crypto'; import { DeeplinkHandler } from './deeplink-handler'; const TAG = '[Dubs:PhantomAdapter]'; /** Serializable session state — save this to restore without a deeplink round-trip. */ export interface PhantomSession { /** Our x25519 public key (base58) */ dappPublicKey: string; /** Our x25519 secret key (base58) */ dappSecretKey: string; /** Phantom's x25519 public key (base58) */ phantomPublicKey: string; /** Shared secret (base58) */ sharedSecret: string; /** Phantom session token (opaque string) */ sessionToken: string; /** User's wallet public key (base58) */ walletPublicKey: string; } export interface PhantomDeeplinkAdapterConfig { /** The deeplink redirect URI, e.g. "myapp://phantom-callback" */ redirectUri: string; /** Shown in Phantom's connect screen */ appUrl?: string; /** Solana cluster: 'devnet' | 'mainnet-beta' */ cluster?: string; /** Deeplink round-trip timeout in ms (default 120000) */ timeout?: number; /** Called when the Phantom session changes (save/clear for persistence) */ onSessionChange?: (session: PhantomSession | null) => void; /** Storage for persisting in-flight connect state (cold-start recovery on Android) */ storage?: TokenStorage; } let requestCounter = 0; function nextRequestId(): string { return `req${Date.now()}_${++requestCounter}`; } /** * Phantom wallet adapter using deeplinks (works on iOS and Android). * Uses x25519 encrypted communication per Phantom's deeplink protocol. */ export class PhantomDeeplinkAdapter implements WalletAdapter { private _publicKey: PublicKey | null = null; private _connected = false; private _dappKeyPair: { publicKey: Uint8Array; secretKey: Uint8Array } | null = null; private _sharedSecret: Uint8Array | null = null; private _sessionToken: string | null = null; private _phantomPublicKey: Uint8Array | null = null; private readonly config: PhantomDeeplinkAdapterConfig; private readonly handler: DeeplinkHandler; private readonly timeout: number; constructor(config: PhantomDeeplinkAdapterConfig) { console.log(TAG, 'Creating adapter with config:', { redirectUri: config.redirectUri, appUrl: config.appUrl, cluster: config.cluster, timeout: config.timeout, }); this.config = config; this.timeout = config.timeout ?? 120_000; this.handler = new DeeplinkHandler(config.redirectUri); this.handler.start(); console.log(TAG, 'Adapter created and deeplink listener started'); } get publicKey(): PublicKey | null { return this._publicKey; } get connected(): boolean { return this._connected; } /** * Restore a previously saved session without opening Phantom. * Call this before connect() if you have persisted session state. */ restoreSession(saved: PhantomSession): void { console.log(TAG, 'Restoring session for wallet:', saved.walletPublicKey); this._dappKeyPair = { publicKey: bs58.decode(saved.dappPublicKey), secretKey: bs58.decode(saved.dappSecretKey), }; this._phantomPublicKey = bs58.decode(saved.phantomPublicKey); this._sharedSecret = bs58.decode(saved.sharedSecret); this._sessionToken = saved.sessionToken; this._publicKey = new PublicKey(saved.walletPublicKey); this._connected = true; console.log(TAG, 'Session restored successfully — connected:', this._publicKey.toBase58()); } /** Serialize the current session for persistence. Returns null if not connected. */ getSession(): PhantomSession | null { if ( !this._connected || !this._dappKeyPair || !this._phantomPublicKey || !this._sharedSecret || !this._sessionToken || !this._publicKey ) { return null; } return { dappPublicKey: bs58.encode(this._dappKeyPair.publicKey), dappSecretKey: bs58.encode(this._dappKeyPair.secretKey), phantomPublicKey: bs58.encode(this._phantomPublicKey), sharedSecret: bs58.encode(this._sharedSecret), sessionToken: this._sessionToken, walletPublicKey: this._publicKey.toBase58(), }; } async connect(): Promise { console.log(TAG, 'connect() — generating x25519 keypair'); // Generate a fresh keypair for this connection this._dappKeyPair = generateKeyPair(); const dappPubBase58 = bs58.encode(this._dappKeyPair.publicKey); console.log(TAG, 'Dapp public key:', dappPubBase58); const requestId = nextRequestId(); // Use clean redirect_link without query params — Phantom appends its own params const redirectLink = this.config.redirectUri; console.log(TAG, `connect() requestId=${requestId} redirectLink=${redirectLink}`); // app_url is required by Phantom — default to redirectUri if not provided const appUrl = this.config.appUrl || this.config.redirectUri; console.log(TAG, 'Using app_url:', appUrl); const params = new URLSearchParams({ dapp_encryption_public_key: dappPubBase58, // Force mainnet-beta for deeplink session — devnet sessions cause -32603 on signMessage cluster: 'mainnet-beta', redirect_link: redirectLink, app_url: appUrl, }); // Persist in-flight state so we can recover if the OS kills the app (Android cold-start) if (this.config.storage) { console.log(TAG, 'Saving in-flight connect state to storage'); await this.config.storage.setItem( STORAGE_KEYS.PHANTOM_CONNECT_IN_FLIGHT, JSON.stringify({ dappPublicKey: dappPubBase58, dappSecretKey: bs58.encode(this._dappKeyPair.secretKey), requestId, createdAt: Date.now(), }), ); } const url = `https://phantom.app/ul/v1/connect?${params.toString()}`; console.log(TAG, 'Opening Phantom connect deeplink...'); const response = await this.handler.send(url, requestId, this.timeout); console.log(TAG, 'Received connect response, param keys:', Object.keys(response.params).join(', ')); // Extract Phantom's public key and derive shared secret const phantomPubBase58 = response.params.phantom_encryption_public_key; if (!phantomPubBase58) { console.log(TAG, 'ERROR: No phantom_encryption_public_key in response'); throw new Error('Phantom did not return an encryption public key'); } console.log(TAG, 'Phantom public key:', phantomPubBase58); this._phantomPublicKey = bs58.decode(phantomPubBase58); this._sharedSecret = deriveSharedSecret( this._dappKeyPair.secretKey, this._phantomPublicKey, ); console.log(TAG, 'Shared secret derived, decrypting response...'); // Decrypt the connect response data const data = decryptPayload( response.params.data, response.params.nonce, this._sharedSecret, ); console.log(TAG, 'Decrypted connect data — public_key:', data.public_key, 'session length:', data.session?.length); this._sessionToken = data.session; this._publicKey = new PublicKey(data.public_key); this._connected = true; console.log(TAG, 'Connected! Wallet:', this._publicKey.toBase58()); // Notify consumer of new session this.config.onSessionChange?.(this.getSession()); // Clear in-flight state now that connect succeeded this.config.storage?.deleteItem(STORAGE_KEYS.PHANTOM_CONNECT_IN_FLIGHT).catch(() => {}); } disconnect(): void { console.log(TAG, 'disconnect() — clearing state, was connected:', this._connected, 'wallet:', this._publicKey?.toBase58()); this._publicKey = null; this._connected = false; this._dappKeyPair = null; this._sharedSecret = null; this._sessionToken = null; this._phantomPublicKey = null; this.config.onSessionChange?.(null); console.log(TAG, 'Disconnected'); } /** * Return and clear the stashed cold-start URL from the deeplink handler. * Used by ManagedWalletProvider to detect an in-flight connect on cold start. */ consumeColdStartUrl(): string | null { return this.handler.getColdStartUrl(); } /** * Complete a connect flow that was interrupted by the OS killing the app (Android). * Loads the saved in-flight keypair from storage, decrypts the Phantom response * from the cold-start URL, and restores the session. */ async completeConnectFromColdStart(url: string): Promise { if (!this.config.storage) { throw new Error('Cannot recover cold-start connect: no storage configured'); } console.log(TAG, 'completeConnectFromColdStart() — attempting recovery'); const raw = await this.config.storage.getItem(STORAGE_KEYS.PHANTOM_CONNECT_IN_FLIGHT); if (!raw) { throw new Error('No in-flight connect state found in storage'); } const inFlight = JSON.parse(raw) as { dappPublicKey: string; dappSecretKey: string; requestId: string; createdAt: number; }; // Check expiry (120s) const age = Date.now() - inFlight.createdAt; if (age > 120_000) { console.log(TAG, `In-flight state expired (${Math.round(age / 1000)}s old), clearing`); await this.config.storage.deleteItem(STORAGE_KEYS.PHANTOM_CONNECT_IN_FLIGHT).catch(() => {}); throw new Error('In-flight connect state expired'); } console.log(TAG, `In-flight state age: ${Math.round(age / 1000)}s, requestId: ${inFlight.requestId}`); // Restore keypair from storage this._dappKeyPair = { publicKey: bs58.decode(inFlight.dappPublicKey), secretKey: bs58.decode(inFlight.dappSecretKey), }; // Parse the Phantom callback URL const parsed = new URL(url); const params: Record = {}; parsed.searchParams.forEach((value, key) => { params[key] = value; }); // Check for Phantom error if (params.errorCode) { const errorMessage = params.errorMessage ? decodeURIComponent(params.errorMessage) : `Phantom error code: ${params.errorCode}`; await this.config.storage.deleteItem(STORAGE_KEYS.PHANTOM_CONNECT_IN_FLIGHT).catch(() => {}); throw new Error(errorMessage); } const phantomPubBase58 = params.phantom_encryption_public_key; if (!phantomPubBase58) { await this.config.storage.deleteItem(STORAGE_KEYS.PHANTOM_CONNECT_IN_FLIGHT).catch(() => {}); throw new Error('Phantom did not return an encryption public key'); } console.log(TAG, 'Cold-start: Phantom public key:', phantomPubBase58); this._phantomPublicKey = bs58.decode(phantomPubBase58); this._sharedSecret = deriveSharedSecret( this._dappKeyPair.secretKey, this._phantomPublicKey, ); const data = decryptPayload(params.data, params.nonce, this._sharedSecret); console.log(TAG, 'Cold-start: Decrypted connect data — public_key:', data.public_key); this._sessionToken = data.session; this._publicKey = new PublicKey(data.public_key); this._connected = true; console.log(TAG, 'Cold-start recovery complete! Wallet:', this._publicKey.toBase58()); // Notify consumer of new session this.config.onSessionChange?.(this.getSession()); // Clear in-flight state await this.config.storage.deleteItem(STORAGE_KEYS.PHANTOM_CONNECT_IN_FLIGHT).catch(() => {}); } async signTransaction(transaction: Transaction): Promise { this.assertConnected(); console.log(TAG, 'signTransaction() — serializing transaction'); const serializedTx = bs58.encode( transaction.serialize({ requireAllSignatures: false, verifySignatures: false }), ); console.log(TAG, 'Transaction serialized, length:', serializedTx.length); const { nonce, ciphertext } = encryptPayload( { transaction: serializedTx, session: this._sessionToken }, this._sharedSecret!, ); const requestId = nextRequestId(); const redirectLink = this.config.redirectUri; console.log(TAG, `signTransaction() requestId=${requestId}`); const params = new URLSearchParams({ dapp_encryption_public_key: bs58.encode(this._dappKeyPair!.publicKey), nonce, payload: ciphertext, redirect_link: redirectLink, }); const url = `https://phantom.app/ul/v1/signTransaction?${params.toString()}`; console.log(TAG, 'Opening Phantom signTransaction deeplink...'); const response = await this.handler.send(url, requestId, this.timeout); console.log(TAG, 'Received signTransaction response'); const data = decryptPayload( response.params.data, response.params.nonce, this._sharedSecret!, ); console.log(TAG, 'Decrypted signed transaction, length:', data.transaction?.length); return Transaction.from(bs58.decode(data.transaction)); } async signMessage(message: Uint8Array): Promise { this.assertConnected(); console.log(TAG, 'signMessage() — message length:', message.length); const encodedMessage = bs58.encode(message); // Match Phantom demo app exactly: { session, message } — no display field const payload = { session: this._sessionToken, message: encodedMessage, }; console.log(TAG, 'signMessage() payload keys:', Object.keys(payload).join(', ')); console.log(TAG, 'signMessage() message (first 40 chars):', encodedMessage.substring(0, 40) + '...'); console.log(TAG, 'signMessage() session (first 20 chars):', this._sessionToken?.substring(0, 20) + '...'); console.log(TAG, 'signMessage() sharedSecret length:', this._sharedSecret!.length); console.log(TAG, 'signMessage() dappPubKey:', bs58.encode(this._dappKeyPair!.publicKey)); // Verify encryption round-trip before sending const { nonce, ciphertext } = encryptPayload(payload, this._sharedSecret!); console.log(TAG, 'signMessage() encrypted — nonce length:', bs58.decode(nonce).length, 'ciphertext length:', bs58.decode(ciphertext).length); // Self-test: verify we can decrypt our own payload try { const selfTest = decryptPayload(ciphertext, nonce, this._sharedSecret!); console.log(TAG, 'signMessage() self-test decrypt OK — keys:', Object.keys(selfTest).join(', ')); } catch (err) { console.log(TAG, 'signMessage() SELF-TEST FAILED:', err instanceof Error ? err.message : err); } const requestId = nextRequestId(); // Clean redirect_link — no query params. Phantom appends its own response params. const redirectLink = this.config.redirectUri; console.log(TAG, `signMessage() requestId=${requestId} redirectLink=${redirectLink}`); const params = new URLSearchParams({ dapp_encryption_public_key: bs58.encode(this._dappKeyPair!.publicKey), nonce, payload: ciphertext, redirect_link: redirectLink, }); const url = `https://phantom.app/ul/v1/signMessage?${params.toString()}`; console.log(TAG, 'signMessage() full URL length:', url.length); console.log(TAG, 'Opening Phantom signMessage deeplink...'); const response = await this.handler.send(url, requestId, this.timeout); console.log(TAG, 'Received signMessage response'); const data = decryptPayload( response.params.data, response.params.nonce, this._sharedSecret!, ); console.log(TAG, 'Message signed, signature:', data.signature?.substring(0, 20) + '...'); return bs58.decode(data.signature); } /** Remove the Linking event listener. Call when the adapter is no longer needed. */ destroy(): void { console.log(TAG, 'destroy() — cleaning up'); this.handler.destroy(); } private assertConnected(): void { if (!this._connected || !this._sharedSecret || !this._sessionToken) { console.log(TAG, 'assertConnected FAILED — connected:', this._connected, 'hasSecret:', !!this._sharedSecret, 'hasSession:', !!this._sessionToken); throw new Error('Wallet not connected'); } } }