Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
53 changes: 39 additions & 14 deletions app/stellar-wallet-kit.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,27 +9,45 @@ import {
LobstrModule,
HanaModule,
} from "@creit.tech/stellar-wallets-kit";
import {
clearWalletSession,
getPersistedWalletId,
persistWalletSession,
} from "@/lib/wallet-session-storage";

export { FREIGHTER_ID, ALBEDO_ID };

const SELECTED_WALLET_ID = "selectedWalletId";
const WALLET_CONNECTED = "walletConnected";
const disconnectListeners: Set<() => void> = new Set();

function getSelectedWalletId() {
function getSelectedWalletIdSync() {
if (typeof window === "undefined") return null;
return localStorage.getItem(SELECTED_WALLET_ID);
}

function isWalletConnected() {
async function hydrateSelectedWalletId(): Promise<string | null> {
if (typeof window === "undefined") return null;
const persistedId = await getPersistedWalletId();
if (!persistedId) return null;

const current = getSelectedWalletIdSync();
if (!current) {
localStorage.setItem(SELECTED_WALLET_ID, persistedId);
}

return persistedId;
}

async function isWalletConnected() {
if (typeof window === "undefined") return false;
return localStorage.getItem(WALLET_CONNECTED) === "true";
return (await getPersistedWalletId()) !== null;
}

function clearWalletStorage() {
async function clearWalletStorage() {
if (typeof window === "undefined") return;

localStorage.removeItem(SELECTED_WALLET_ID);
localStorage.removeItem(WALLET_CONNECTED);
await clearWalletSession();
}

let kit: StellarWalletsKit | null = null;
Expand All @@ -48,7 +66,7 @@ function getKit(): StellarWalletsKit | null {
new HanaModule(),
],
network: WalletNetwork.PUBLIC,
selectedWalletId: getSelectedWalletId() ?? FREIGHTER_ID,
selectedWalletId: getSelectedWalletIdSync() ?? FREIGHTER_ID,
});
} catch (e) {
console.error("Failed to initialize StellarWalletsKit:", e);
Expand All @@ -58,7 +76,7 @@ function getKit(): StellarWalletsKit | null {
return kit;
}

export async function signTransaction(...args: any[]) {
export async function signTransaction(...args: unknown[]) {
const kitInstance = getKit();
if (!kitInstance) return null;
// @ts-ignore
Expand All @@ -81,7 +99,9 @@ export async function signMessage(message: string): Promise<string> {

export async function getPublicKey() {
if (typeof window === "undefined") return null;
if (!getSelectedWalletId() || !isWalletConnected()) return null;

const selectedWalletId = await hydrateSelectedWalletId();
if (!selectedWalletId || !(await isWalletConnected())) return null;

const kitInstance = getKit();
if (!kitInstance) return null;
Expand All @@ -91,25 +111,30 @@ export async function getPublicKey() {
return address;
} catch (e) {
console.error("Failed to get public key:", e);
await clearWalletStorage();
return null;
}
}

export async function autoReconnect() {
if (!isWalletConnected() || !getSelectedWalletId()) return null;
if (!(await isWalletConnected())) {
return null;
}

await hydrateSelectedWalletId();

try {
return await getPublicKey();
} catch {
clearWalletStorage();
await clearWalletStorage();
return null;
}
}

export async function setWallet(walletId: string) {
if (typeof window !== "undefined") {
await persistWalletSession(walletId);
localStorage.setItem(SELECTED_WALLET_ID, walletId);
localStorage.setItem(WALLET_CONNECTED, "true");

const kitInstance = getKit();
if (!kitInstance) return;
Expand Down Expand Up @@ -147,7 +172,7 @@ export async function connect(callback?: () => Promise<void>) {
if (!kitInstance) return;

await kitInstance.openModal({
onWalletSelected: async (option: any) => {
onWalletSelected: async (option: { id: string }) => {
try {
await setWallet(option.id);
if (callback) await callback();
Expand Down Expand Up @@ -181,7 +206,7 @@ export async function connectToWallet(walletId: string, callback?: () => Promise

await setWallet(walletId);
if (callback) await callback();
} catch (e: any) {
} catch (e: unknown) {
console.error(`Failed to connect to wallet ${walletId}:`, e);
throw e;
}
Expand Down
12 changes: 8 additions & 4 deletions components/wallet-selection-modal.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
"use client";

import React, { useState, useEffect } from "react";
import React, { useState } from "react";
import * as Dialog from "@radix-ui/react-dialog";
import { X, Wallet, ShieldCheck, Zap, Download } from "lucide-react";
import {
Expand Down Expand Up @@ -58,14 +58,18 @@ export function WalletSelectionModal({ isOpen, onOpenChange, onConnectSuccess }:
onConnectSuccess("");
onOpenChange(false);
});
} catch (error: any) {
} catch (error: unknown) {
console.error("Wallet connection error:", error);
if (error.message.includes("not installed")) {
const message =
error instanceof Error
? error.message
: "Failed to connect wallet";
if (message.includes("not installed")) {
toast.error(`${walletId === FREIGHTER_ID ? 'Freighter' : 'Wallet'} is not installed.`, {
icon: <Download className="w-4 h-4" />,
});
} else {
toast.error(error.message || "Failed to connect wallet");
toast.error(message);
}
} finally {
setIsConnecting(false);
Expand Down
208 changes: 208 additions & 0 deletions lib/wallet-session-storage.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,208 @@
const WALLET_SESSION_KEY = "anonchat_wallet_session_v1";
const WALLET_SESSION_DB_NAME = "anonchat_wallet_session_db";
const WALLET_SESSION_DB_VERSION = 1;
const WALLET_SESSION_KEY_STORE = "keys";
const WALLET_SESSION_CRYPTO_KEY_ID = "wallet-session-crypto-key";

interface StoredWalletSession {
walletId: string;
expiresAt: number;
}

function isBrowser(): boolean {
return typeof window !== "undefined" && typeof window.crypto !== "undefined";
}

function base64Encode(bytes: Uint8Array) {
let binary = "";
for (let i = 0; i < bytes.length; ++i) {
binary += String.fromCharCode(bytes[i]);
}
return btoa(binary);
}

function base64Decode(value: string) {
const binary = atob(value);
const bytes = new Uint8Array(binary.length);
for (let i = 0; i < binary.length; ++i) {
bytes[i] = binary.charCodeAt(i);
}
return bytes;
}

async function openWalletSessionDb(): Promise<IDBDatabase | null> {
if (!isBrowser() || !window.indexedDB) return null;

return new Promise((resolve, reject) => {
const request = window.indexedDB.open(WALLET_SESSION_DB_NAME, WALLET_SESSION_DB_VERSION);

request.onupgradeneeded = () => {
const db = request.result;
if (!db.objectStoreNames.contains(WALLET_SESSION_KEY_STORE)) {
db.createObjectStore(WALLET_SESSION_KEY_STORE);
}
};

request.onsuccess = () => resolve(request.result);
request.onerror = () => reject(request.error);
});
}

async function getStoredCryptoKey(): Promise<CryptoKey | null> {
if (!isBrowser()) return null;
const db = await openWalletSessionDb();
if (!db) return null;

return new Promise((resolve, reject) => {
const transaction = db.transaction(WALLET_SESSION_KEY_STORE, "readonly");
const store = transaction.objectStore(WALLET_SESSION_KEY_STORE);
const request = store.get(WALLET_SESSION_CRYPTO_KEY_ID);

request.onsuccess = () => {
const jwk = request.result;
if (!jwk) {
resolve(null);
return;
}

window.crypto.subtle.importKey("jwk", jwk, { name: "AES-GCM" }, true, ["encrypt", "decrypt"])
.then(resolve)
.catch(reject);
};

request.onerror = () => reject(request.error);
});
}

async function storeCryptoKey(key: CryptoKey): Promise<void> {
if (!isBrowser()) return;
const db = await openWalletSessionDb();
if (!db) return;

const jwk = await window.crypto.subtle.exportKey("jwk", key);
await new Promise<void>((resolve, reject) => {
const transaction = db.transaction(WALLET_SESSION_KEY_STORE, "readwrite");
const store = transaction.objectStore(WALLET_SESSION_KEY_STORE);
const request = store.put(jwk, WALLET_SESSION_CRYPTO_KEY_ID);
request.onsuccess = () => resolve();
request.onerror = () => reject(request.error);
});
}

async function ensureEncryptionKey(): Promise<CryptoKey | null> {
if (!isBrowser()) return null;
const existingKey = await getStoredCryptoKey();
if (existingKey) return existingKey;

const key = await window.crypto.subtle.generateKey(
{ name: "AES-GCM", length: 256 },
true,
["encrypt", "decrypt"],
);
await storeCryptoKey(key);
return key;
}

async function encryptSession(session: StoredWalletSession): Promise<string> {
if (!isBrowser()) {
return JSON.stringify(session);
}

const key = await ensureEncryptionKey();
if (!key) {
return JSON.stringify(session);
}

const iv = window.crypto.getRandomValues(new Uint8Array(12));
const encoded = new TextEncoder().encode(JSON.stringify(session));
const cipherBuffer = await window.crypto.subtle.encrypt(
{ name: "AES-GCM", iv },
key,
encoded,
);

return `${base64Encode(iv)}:${base64Encode(new Uint8Array(cipherBuffer))}`;
}

async function decryptSession(value: string): Promise<StoredWalletSession | null> {
if (!isBrowser()) return null;

const key = await getStoredCryptoKey();
if (!key) return null;

const [ivPart, cipherPart] = value.split(":");
if (!ivPart || !cipherPart) return null;

const iv = base64Decode(ivPart);
const cipher = base64Decode(cipherPart);

try {
const plain = await window.crypto.subtle.decrypt(
{ name: "AES-GCM", iv },
key,
cipher,
);
const decoded = new TextDecoder().decode(plain);
return JSON.parse(decoded) as StoredWalletSession;
} catch {
return null;
}
}

async function readRawWalletSession(): Promise<string | null> {
if (!isBrowser()) return null;
return window.localStorage.getItem(WALLET_SESSION_KEY);
}

async function writeRawWalletSession(value: string): Promise<void> {
if (!isBrowser()) return;
window.localStorage.setItem(WALLET_SESSION_KEY, value);
}

async function removeRawWalletSession(): Promise<void> {
if (!isBrowser()) return;
window.localStorage.removeItem(WALLET_SESSION_KEY);
}

export async function getStoredWalletSession(): Promise<StoredWalletSession | null> {
const raw = await readRawWalletSession();
if (!raw) return null;

const session = await decryptSession(raw);
if (session && session.expiresAt > Date.now()) return session;

try {
const fallback = JSON.parse(raw) as StoredWalletSession;
if (fallback?.walletId && fallback.expiresAt > Date.now()) {
return fallback;
}
} catch {
// Ignore fallback parse failures.
}

await removeRawWalletSession();
return null;
}

export async function getPersistedWalletId(): Promise<string | null> {
const session = await getStoredWalletSession();
return session?.walletId ?? null;
}

export async function persistWalletSession(walletId: string): Promise<void> {
const session: StoredWalletSession = {
walletId,
expiresAt: Date.now() + 7 * 24 * 60 * 60 * 1000,
};

try {
const encrypted = await encryptSession(session);
await writeRawWalletSession(encrypted);
} catch {
await writeRawWalletSession(JSON.stringify(session));
}
}

export async function clearWalletSession(): Promise<void> {
await removeRawWalletSession();
}
Loading