Aug 29, 2025
0
Level 3
Phantom Wallet Universal Link Flow: Prevent Multiple Browser Tabs
When using Phantom’s universal link flow for wallet connection, disconnection, and message signing, each action opens a new browser tab instead of reusing the existing one. How can I prevent multiple tabs from accumulating and handle the redirects in a single tab?
/**
* Mobile Phantom Wallet Integration
* -----------------------------------
* This module handles wallet connection, disconnection,
* message signing, and session management for mobile devices
* using Phantom's universal link flow.
*/
import { Shared } from "./shared";
import nacl from "tweetnacl";
import bs58 from "bs58";
export const Mobile = (() => {
// ==========================================================
// LocalStorage Keys
// ==========================================================
const DAPP_KEY_PAIR_KEY = "dapp_keypair";
const SHARED_SECRET_KEY = "shared_secret";
const DAPP_SESSION_KEY = "dapp_session";
const SAVED_WALLET_KEY = "saved_wallet";
// ==========================================================
// Error Handling
// ==========================================================
/**
* Display error toast using Shared helper
*/
const handleError = (toastMessage) => {
Shared.showToast(toastMessage);
};
// ==========================================================
// Universal Link Builder
// ==========================================================
/**
* Build Phantom universal link
* @param {string} path - Phantom action (connect, disconnect, signMessage, etc.)
* @param {URLSearchParams} params - Query parameters for the link
* @returns {string} full Phantom universal link
*/
const buildUniversalLink = (path, params) => {
return `phantom://v1/${path}?${params.toString()}`;
};
// ==========================================================
// Redirect Builder
// ==========================================================
/**
* Build redirect URL with query parameters
* @param {object} params - Query parameters to include in the redirect
* @returns {string} full redirect URL
*/
function buildRedirect(params = {}) {
const url = new URL(window.location.href, window.location.origin);
Object.entries(params).forEach(([key, value]) => {
url.searchParams.set(key, value);
});
return url.toString();
}
// ==========================================================
// Encryption Helpers
// ==========================================================
/**
* Retrieve or generate DApp keypair for encryption
* Stored in localStorage to persist sessions.
*/
const dappKeypair = () => {
let keypair = JSON.parse(localStorage.getItem(DAPP_KEY_PAIR_KEY));
if (!keypair) {
// Generate new keypair
keypair = nacl.box.keyPair();
localStorage.setItem(
DAPP_KEY_PAIR_KEY,
JSON.stringify({
publicKey: bs58.encode(keypair.publicKey),
secretKey: bs58.encode(keypair.secretKey),
})
);
} else {
// Decode from storage
keypair = {
publicKey: bs58.decode(keypair.publicKey),
secretKey: bs58.decode(keypair.secretKey),
};
}
return keypair;
};
/**
* Encrypt payload using shared secret
*/
const encryptPayload = (payload, sharedSecret) => {
const nonce = nacl.randomBytes(24);
const encrypted = nacl.box.after(
new TextEncoder().encode(JSON.stringify(payload)),
nonce,
sharedSecret
);
return { nonce, encrypted };
};
/**
* Decrypt payload using shared secret
*/
const decryptPayload = (data, nonce, sharedSecret) => {
const decrypted = nacl.box.open.after(
bs58.decode(data),
bs58.decode(nonce),
sharedSecret
);
if (!decrypted) {
throw new Error("Failed to decrypt data");
}
return JSON.parse(new TextDecoder().decode(decrypted));
};
// ==========================================================
// Redirect Handling
// ==========================================================
/**
* Handle Phantom universal link redirects
* (triggered after wallet actions complete)
*/
const handleRedirects = (url) => {
const params = url.searchParams;
// Handle errors from Phantom
if (params.get("errorCode")) {
const shortWallet = Shared.truncateWalletAddress(
localStorage.getItem(SAVED_WALLET_KEY)
);
const errorText = params.get("errorMessage") || "<b>Error</b>";
const errorMessage = `<span><b>${errorText}:</b> — Please ensure that you are connected to the wallet <b>"${shortWallet}"</b> in your Phantom app.</span>`;
handleError(errorMessage);
return;
}
// Action routing
const action = params.get("action");
if (!action) return;
switch (action) {
case "wallet-connection":
handleWalletConnection(params);
break;
case "wallet-disconnection":
handleWalletDisconnection(params);
break;
case "consent-message-signing":
handleConsentMessageSigning(params);
break;
default:
handleError("Unknown action received.");
break;
}
// Clean the URL after handling the redirect
const cleanUrl = `${window.location.origin}${window.location.pathname}`;
window.history.replaceState({}, "", cleanUrl);
};
// ==========================================================
// Session Management
// ==========================================================
/**
* Auto-connect if valid session data is found in localStorage
*/
const autoConnect = async () => {
try {
const session = localStorage.getItem(DAPP_SESSION_KEY);
const publicKey = localStorage.getItem(SAVED_WALLET_KEY);
const sharedSecretEncoded = localStorage.getItem(SHARED_SECRET_KEY);
if (!session || !publicKey || !sharedSecretEncoded) {
return; // No saved session
}
Shared.dispatchEvent("phantom-connected", {
data: { publicKey },
});
} catch {
// Clear invalid session
Shared.clearSession([
DAPP_KEY_PAIR_KEY,
DAPP_SESSION_KEY,
SAVED_WALLET_KEY,
SHARED_SECRET_KEY,
]);
Shared.dispatchEvent("phantom-disconnected", {
data: {
message:
"Your wallet session has expired. Please reconnect to continue.",
},
});
}
};
// ==========================================================
// Wallet Connection
// ==========================================================
/**
* Initiate wallet connection flow
*/
const connectWallet = async () => {
const keypair = dappKeypair();
// Use a clean base URL (origin + pathname) to avoid stale query parameters
const baseUrl = `${window.location.origin}${window.location.pathname}`;
const params = new URLSearchParams({
app_url: window.location.origin,
dapp_encryption_public_key: bs58.encode(keypair.publicKey),
redirect_link: `${baseUrl}?action=wallet-connection`,
cluster: import.meta.env.VITE_PHANTOM_CLUSTER || "devnet",
});
// Redirect user to Phantom app
const url = buildUniversalLink("connect", params);
window.location.href = url;
};
/**
* Handle successful wallet connection
*/
const handleWalletConnection = (params) => {
try {
const PEPK = params.get("phantom_encryption_public_key");
const nonce = params.get("nonce");
const data = params.get("data");
if (!PEPK || !nonce || !data) return;
// Generate shared secret
const sharedSecret = nacl.box.before(
bs58.decode(PEPK),
dappKeypair().secretKey
);
localStorage.setItem(SHARED_SECRET_KEY, bs58.encode(sharedSecret));
// Decrypt Phantom payload
const decryptedPayload = decryptPayload(data, nonce, sharedSecret);
const publicKey = decryptedPayload.public_key;
const session = decryptedPayload.session;
// Save wallet + session
localStorage.setItem(SAVED_WALLET_KEY, publicKey);
localStorage.setItem(DAPP_SESSION_KEY, session);
Shared.dispatchEvent("phantom-connected", { data: { publicKey } });
} catch (error) {
handleError(
error.message ||
"There was an issue connecting your wallet. Please try again."
);
}
};
// ==========================================================
// Wallet Disconnection
// ==========================================================
/**
* Initiate wallet disconnection flow
*/
const disconnectWallet = async () => {
const sharedSecretEncoded = localStorage.getItem(SHARED_SECRET_KEY);
const session = localStorage.getItem(DAPP_SESSION_KEY);
if (!sharedSecretEncoded || !session) {
handleError("No active session or shared secret found.");
return;
}
const sharedSecret = bs58.decode(sharedSecretEncoded);
const payload = { session };
const { nonce, encrypted } = encryptPayload(payload, sharedSecret);
// Use a clean base URL (origin + pathname) to avoid stale query parameters
const baseUrl = `${window.location.origin}${window.location.pathname}`;
const params = new URLSearchParams({
dapp_encryption_public_key: bs58.encode(dappKeypair().publicKey),
nonce: bs58.encode(nonce),
redirect_link: `${baseUrl}?action=wallet-disconnection`,
payload: bs58.encode(encrypted),
});
// Redirect to Phantom app
const url = buildUniversalLink("disconnect", params);
window.location.href = url;
};
/**
* Handle successful wallet disconnection
*/
const handleWalletDisconnection = () => {
Shared.clearSession([
DAPP_KEY_PAIR_KEY,
DAPP_SESSION_KEY,
SAVED_WALLET_KEY,
SHARED_SECRET_KEY,
]);
Shared.dispatchEvent("phantom-disconnected", {
data: {
message:
"Your wallet has been disconnected, and you've been safely logged out.",
},
});
};
// ==========================================================
// Consent Message Signing
// ==========================================================
/**
* Initiate consent message signing
*/
const signConsentMessage = async () => {
const sharedSecretEncoded = localStorage.getItem(SHARED_SECRET_KEY);
if (!sharedSecretEncoded) {
handleError("No shared secret found. Please reconnect wallet.");
return;
}
const sharedSecret = bs58.decode(sharedSecretEncoded);
const session = localStorage.getItem(DAPP_SESSION_KEY);
const publicKey = localStorage.getItem(SAVED_WALLET_KEY);
if (!session || !publicKey) {
handleError(
"Missing session or public key. Please reconnect wallet."
);
return;
}
// Consent message to sign
const message = `You are creating an account with public key: ${publicKey}`;
const payload = {
message: bs58.encode(new TextEncoder().encode(message)),
session,
display: "utf8",
};
const { nonce, encrypted } = encryptPayload(payload, sharedSecret);
// Use a clean base URL (origin + pathname) to avoid stale query parameters
const baseUrl = `${window.location.origin}${window.location.pathname}`;
const params = new URLSearchParams({
dapp_encryption_public_key: bs58.encode(dappKeypair().publicKey),
nonce: bs58.encode(nonce),
redirect_link: `${baseUrl}?action=consent-message-signing`,
payload: bs58.encode(encrypted),
});
const url = buildUniversalLink("signMessage", params);
window.location.href = url;
};
/**
* Handle successful consent message signing
*/
const handleConsentMessageSigning = (params) => {
try {
const nonce = params.get("nonce");
const data = params.get("data");
if (!nonce || !data) {
handleError("Missing nonce or data in response.");
return;
}
const sharedSecretEncoded = localStorage.getItem(SHARED_SECRET_KEY);
if (!sharedSecretEncoded) {
handleError("No shared secret found. Please reconnect wallet.");
return;
}
const sharedSecret = bs58.decode(sharedSecretEncoded);
const decryptedPayload = decryptPayload(data, nonce, sharedSecret);
const publicKey = localStorage.getItem(SAVED_WALLET_KEY);
const signature = decryptedPayload.signature;
// Dispatch event for account creation
Shared.dispatchEvent("create-account", {
data: { publicKey, signature },
});
} catch (error) {
handleError(
error.message ||
"There was an issue signing the consent message. Please try again."
);
}
};
// ==========================================================
// Initialization
// ==========================================================
/**
* Initialize wallet module
* - Handles redirects
* - Auto-connects if possible
* - Binds Livewire events
*/
const init = () => {
handleRedirects(new URL(window.location.href));
autoConnect();
Livewire.on("connect-wallet", connectWallet);
Livewire.on("disconnect-wallet", disconnectWallet);
Livewire.on("sign-consent-message", signConsentMessage);
};
// Expose only the init function
return { init };
})();
Please or to participate in this conversation.