From bf4f81316d0b545571ee6dd76cba0f7b543d599b Mon Sep 17 00:00:00 2001 From: Auny Date: Tue, 12 May 2026 01:56:27 -0400 Subject: [PATCH] feat: multi-wallet support (Freighter, xBull, Albedo) Add a wallet adapter abstraction so SoroSave can connect through any of the major Stellar wallets, not just Freighter. - New WalletAdapter interface in src/lib/wallets/ with adapters for Freighter (existing API), xBull (window.xBullSDK), and Albedo (lazy-loaded @albedo-link/intent). - WalletSelectModal lists each wallet, shows install/availability state, and lets users pick one. - WalletContext tracks the active adapter, exposes signTransaction, and remembers the last used wallet in localStorage (sorosave:lastWallet) to auto-reconnect when the wallet is still available. - Replace direct @/lib/wallet imports across components; sign through the active adapter via context. Closes #66 Co-Authored-By: Claude Opus 4.7 (1M context) --- package.json | 1 + src/app/providers.tsx | 101 ++++++++++++++++++---- src/components/ConnectWallet.tsx | 38 ++++----- src/components/ContributeModal.tsx | 10 +-- src/components/CreateGroupForm.tsx | 10 +-- src/components/WalletSelectModal.tsx | 121 +++++++++++++++++++++++++++ src/lib/wallet.ts | 36 -------- src/lib/wallets/albedo.ts | 64 ++++++++++++++ src/lib/wallets/freighter.ts | 49 +++++++++++ src/lib/wallets/index.ts | 42 ++++++++++ src/lib/wallets/types.ts | 31 +++++++ src/lib/wallets/xbull.ts | 78 +++++++++++++++++ 12 files changed, 495 insertions(+), 86 deletions(-) create mode 100644 src/components/WalletSelectModal.tsx delete mode 100644 src/lib/wallet.ts create mode 100644 src/lib/wallets/albedo.ts create mode 100644 src/lib/wallets/freighter.ts create mode 100644 src/lib/wallets/index.ts create mode 100644 src/lib/wallets/types.ts create mode 100644 src/lib/wallets/xbull.ts diff --git a/package.json b/package.json index 7e788f6..3c0120a 100644 --- a/package.json +++ b/package.json @@ -9,6 +9,7 @@ "lint": "next lint" }, "dependencies": { + "@albedo-link/intent": "^0.12.0", "@sorosave/sdk": "workspace:*", "@stellar/freighter-api": "^2.0.0", "next": "^14.2.0", diff --git a/src/app/providers.tsx b/src/app/providers.tsx index 98f8bf5..e6e98ca 100644 --- a/src/app/providers.tsx +++ b/src/app/providers.tsx @@ -1,22 +1,47 @@ "use client"; -import React, { createContext, useContext, useState, useCallback, useEffect } from "react"; -import { connectWallet, getPublicKey, isFreighterInstalled } from "@/lib/wallet"; +import React, { + createContext, + useCallback, + useContext, + useEffect, + useState, +} from "react"; +import { + WalletAdapter, + WalletId, + WalletNotInstalledError, + clearLastWalletId, + getAdapter, + loadLastWalletId, + saveLastWalletId, +} from "@/lib/wallets"; +import { NETWORK_PASSPHRASE } from "@/lib/sorosave"; interface WalletContextType { address: string | null; isConnected: boolean; - isFreighterAvailable: boolean; - connect: () => Promise; + activeWalletId: WalletId | null; + activeWallet: WalletAdapter | null; + connecting: boolean; + error: string | null; + connect: (walletId: WalletId) => Promise; disconnect: () => void; + signTransaction: (xdr: string) => Promise; } const WalletContext = createContext({ address: null, isConnected: false, - isFreighterAvailable: false, + activeWalletId: null, + activeWallet: null, + connecting: false, + error: null, connect: async () => {}, disconnect: () => {}, + signTransaction: async () => { + throw new Error("No wallet connected"); + }, }); export function useWallet() { @@ -25,33 +50,79 @@ export function useWallet() { export function Providers({ children }: { children: React.ReactNode }) { const [address, setAddress] = useState(null); - const [isFreighterAvailable, setIsFreighterAvailable] = useState(false); + const [activeWalletId, setActiveWalletId] = useState(null); + const [connecting, setConnecting] = useState(false); + const [error, setError] = useState(null); useEffect(() => { - isFreighterInstalled().then(setIsFreighterAvailable); - // Try to reconnect on load - getPublicKey().then((key) => { - if (key) setAddress(key); - }); + const last = loadLastWalletId(); + if (!last) return; + const adapter = getAdapter(last); + (async () => { + const available = await adapter.isAvailable(); + if (!available) return; + const key = await adapter.getPublicKey(); + if (key) { + setActiveWalletId(last); + setAddress(key); + } + })(); }, []); - const connect = useCallback(async () => { - const addr = await connectWallet(); - if (addr) setAddress(addr); + const connect = useCallback(async (walletId: WalletId) => { + setConnecting(true); + setError(null); + try { + const adapter = getAdapter(walletId); + const key = await adapter.connect(); + setActiveWalletId(walletId); + setAddress(key); + saveLastWalletId(walletId); + } catch (err) { + if (err instanceof WalletNotInstalledError) { + setError(`${walletId} is not installed. Open ${err.installUrl} to install it.`); + } else { + setError(err instanceof Error ? err.message : "Failed to connect wallet"); + } + } finally { + setConnecting(false); + } }, []); const disconnect = useCallback(() => { setAddress(null); + setActiveWalletId(null); + setError(null); + clearLastWalletId(); }, []); + const signTransaction = useCallback( + async (xdr: string) => { + if (!activeWalletId) { + throw new Error("No wallet connected"); + } + const adapter = getAdapter(activeWalletId); + return adapter.signTransaction(xdr, { + networkPassphrase: NETWORK_PASSPHRASE, + }); + }, + [activeWalletId], + ); + + const activeWallet = activeWalletId ? getAdapter(activeWalletId) : null; + return ( {children} diff --git a/src/components/ConnectWallet.tsx b/src/components/ConnectWallet.tsx index f039a0e..f5aa9b4 100644 --- a/src/components/ConnectWallet.tsx +++ b/src/components/ConnectWallet.tsx @@ -1,29 +1,19 @@ "use client"; +import { useState } from "react"; import { useWallet } from "@/app/providers"; import { shortenAddress } from "@sorosave/sdk"; +import { WalletSelectModal } from "./WalletSelectModal"; export function ConnectWallet() { - const { address, isConnected, isFreighterAvailable, connect, disconnect } = - useWallet(); - - if (!isFreighterAvailable) { - return ( - - Install Freighter - - ); - } + const { address, isConnected, activeWallet, disconnect } = useWallet(); + const [modalOpen, setModalOpen] = useState(false); if (isConnected && address) { return (
+ {activeWallet ? `${activeWallet.name} · ` : ""} {shortenAddress(address)} + <> + + setModalOpen(false)} + /> + ); } diff --git a/src/components/ContributeModal.tsx b/src/components/ContributeModal.tsx index 0d9d539..3fe2471 100644 --- a/src/components/ContributeModal.tsx +++ b/src/components/ContributeModal.tsx @@ -2,9 +2,8 @@ import { useState } from "react"; import { useWallet } from "@/app/providers"; -import { sorosaveClient, NETWORK_PASSPHRASE } from "@/lib/sorosave"; +import { sorosaveClient } from "@/lib/sorosave"; import { formatAmount } from "@sorosave/sdk"; -import { signTransaction } from "@/lib/wallet"; interface ContributeModalProps { groupId: number; @@ -19,7 +18,7 @@ export function ContributeModal({ isOpen, onClose, }: ContributeModalProps) { - const { address } = useWallet(); + const { address, signTransaction } = useWallet(); const [loading, setLoading] = useState(false); const [error, setError] = useState(null); @@ -33,10 +32,7 @@ export function ContributeModal({ try { const tx = await sorosaveClient.contribute(address, groupId, address); - const signedXdr = await signTransaction( - tx.toXDR(), - NETWORK_PASSPHRASE - ); + const signedXdr = await signTransaction(tx.toXDR()); // TODO: Submit signed transaction console.log("Signed contribution:", signedXdr); diff --git a/src/components/CreateGroupForm.tsx b/src/components/CreateGroupForm.tsx index 0a1a767..3892079 100644 --- a/src/components/CreateGroupForm.tsx +++ b/src/components/CreateGroupForm.tsx @@ -2,12 +2,11 @@ import { useState } from "react"; import { useWallet } from "@/app/providers"; -import { sorosaveClient, NETWORK_PASSPHRASE } from "@/lib/sorosave"; +import { sorosaveClient } from "@/lib/sorosave"; import { parseAmount } from "@sorosave/sdk"; -import { signTransaction } from "@/lib/wallet"; export function CreateGroupForm() { - const { address, isConnected } = useWallet(); + const { address, isConnected, signTransaction } = useWallet(); const [name, setName] = useState(""); const [tokenAddress, setTokenAddress] = useState(""); const [contributionAmount, setContributionAmount] = useState(""); @@ -36,10 +35,7 @@ export function CreateGroupForm() { address ); - const signedXdr = await signTransaction( - tx.toXDR(), - NETWORK_PASSPHRASE - ); + const signedXdr = await signTransaction(tx.toXDR()); // TODO: Submit signed transaction to network console.log("Signed transaction:", signedXdr); diff --git a/src/components/WalletSelectModal.tsx b/src/components/WalletSelectModal.tsx new file mode 100644 index 0000000..5a01945 --- /dev/null +++ b/src/components/WalletSelectModal.tsx @@ -0,0 +1,121 @@ +"use client"; + +import { useEffect, useState } from "react"; +import { useWallet } from "@/app/providers"; +import { WalletAdapter, walletList } from "@/lib/wallets"; + +interface WalletSelectModalProps { + isOpen: boolean; + onClose: () => void; +} + +export function WalletSelectModal({ isOpen, onClose }: WalletSelectModalProps) { + const { connect, connecting, error } = useWallet(); + const [availability, setAvailability] = useState>({}); + + useEffect(() => { + if (!isOpen) return; + let cancelled = false; + Promise.all( + walletList.map(async (w) => [w.id, await w.isAvailable()] as const), + ).then((entries) => { + if (cancelled) return; + setAvailability(Object.fromEntries(entries)); + }); + return () => { + cancelled = true; + }; + }, [isOpen]); + + if (!isOpen) return null; + + const handleSelect = async (wallet: WalletAdapter) => { + if (availability[wallet.id] === false) return; + await connect(wallet.id); + onClose(); + }; + + return ( +
+
e.stopPropagation()} + > +
+

+ Connect a wallet +

+ +
+ +
+ {walletList.map((wallet) => { + const available = availability[wallet.id]; + const checking = available === undefined; + return ( +
+
+ { + e.currentTarget.style.visibility = "hidden"; + }} + /> +
+
+ {wallet.name} +
+ {checking && ( +
Checking…
+ )} + {!checking && !available && ( +
Not installed
+ )} +
+
+ {available ? ( + + ) : ( + + Install + + )} +
+ ); + })} +
+ + {error && ( +
+ {error} +
+ )} +
+
+ ); +} diff --git a/src/lib/wallet.ts b/src/lib/wallet.ts deleted file mode 100644 index 825d2e2..0000000 --- a/src/lib/wallet.ts +++ /dev/null @@ -1,36 +0,0 @@ -import freighter from "@stellar/freighter-api"; - -export async function isFreighterInstalled(): Promise { - try { - return await freighter.isConnected(); - } catch { - return false; - } -} - -export async function connectWallet(): Promise { - try { - const address = await freighter.requestAccess(); - return address; - } catch (error) { - console.error("Failed to connect wallet:", error); - return null; - } -} - -export async function getPublicKey(): Promise { - try { - return await freighter.getPublicKey(); - } catch { - return null; - } -} - -export async function signTransaction( - xdr: string, - networkPassphrase: string -): Promise { - return await freighter.signTransaction(xdr, { - networkPassphrase, - }); -} diff --git a/src/lib/wallets/albedo.ts b/src/lib/wallets/albedo.ts new file mode 100644 index 0000000..5938ced --- /dev/null +++ b/src/lib/wallets/albedo.ts @@ -0,0 +1,64 @@ +import { + SignOptions, + WalletAdapter, + WalletConnectionError, +} from "./types"; + +interface AlbedoIntent { + publicKey(params?: { token?: string }): Promise<{ pubkey: string }>; + tx(params: { + xdr: string; + network?: string; + network_passphrase?: string; + pubkey?: string; + }): Promise<{ signed_envelope_xdr: string }>; +} + +let albedoModule: AlbedoIntent | null = null; + +async function loadAlbedo(): Promise { + if (albedoModule) return albedoModule; + const mod = (await import("@albedo-link/intent")) as unknown as { + default?: AlbedoIntent; + } & AlbedoIntent; + albedoModule = mod.default ?? mod; + return albedoModule; +} + +export const albedoAdapter: WalletAdapter = { + id: "albedo", + name: "Albedo", + iconUrl: "https://albedo.link/img/logo-square.svg", + installUrl: "https://albedo.link/", + + async isAvailable() { + if (typeof window === "undefined") return false; + return true; + }, + + async connect() { + try { + const albedo = await loadAlbedo(); + const { pubkey } = await albedo.publicKey(); + return pubkey; + } catch (err) { + throw new WalletConnectionError( + this.id, + err instanceof Error ? err.message : "Albedo request was rejected", + ); + } + }, + + async getPublicKey() { + return null; + }, + + async signTransaction(xdr: string, { networkPassphrase }: SignOptions) { + const albedo = await loadAlbedo(); + const result = await albedo.tx({ + xdr, + network_passphrase: networkPassphrase, + }); + return result.signed_envelope_xdr; + }, +}; diff --git a/src/lib/wallets/freighter.ts b/src/lib/wallets/freighter.ts new file mode 100644 index 0000000..3542467 --- /dev/null +++ b/src/lib/wallets/freighter.ts @@ -0,0 +1,49 @@ +import freighter from "@stellar/freighter-api"; +import { + SignOptions, + WalletAdapter, + WalletConnectionError, + WalletNotInstalledError, +} from "./types"; + +export const freighterAdapter: WalletAdapter = { + id: "freighter", + name: "Freighter", + iconUrl: "https://www.freighter.app/favicon.ico", + installUrl: "https://www.freighter.app/", + + async isAvailable() { + try { + return await freighter.isConnected(); + } catch { + return false; + } + }, + + async connect() { + if (!(await this.isAvailable())) { + throw new WalletNotInstalledError(this.id, this.installUrl); + } + try { + return await freighter.requestAccess(); + } catch (err) { + throw new WalletConnectionError( + this.id, + err instanceof Error ? err.message : "Freighter rejected the request", + ); + } + }, + + async getPublicKey() { + try { + const key = await freighter.getPublicKey(); + return key || null; + } catch { + return null; + } + }, + + async signTransaction(xdr: string, { networkPassphrase }: SignOptions) { + return freighter.signTransaction(xdr, { networkPassphrase }); + }, +}; diff --git a/src/lib/wallets/index.ts b/src/lib/wallets/index.ts new file mode 100644 index 0000000..2f57ba5 --- /dev/null +++ b/src/lib/wallets/index.ts @@ -0,0 +1,42 @@ +import { albedoAdapter } from "./albedo"; +import { freighterAdapter } from "./freighter"; +import { WalletAdapter, WalletId } from "./types"; +import { xbullAdapter } from "./xbull"; + +export const walletAdapters: Record = { + freighter: freighterAdapter, + xbull: xbullAdapter, + albedo: albedoAdapter, +}; + +export const walletList: WalletAdapter[] = [ + freighterAdapter, + xbullAdapter, + albedoAdapter, +]; + +export function getAdapter(id: WalletId): WalletAdapter { + return walletAdapters[id]; +} + +const STORAGE_KEY = "sorosave:lastWallet"; + +export function loadLastWalletId(): WalletId | null { + if (typeof window === "undefined") return null; + const value = window.localStorage.getItem(STORAGE_KEY); + if (value && value in walletAdapters) return value as WalletId; + return null; +} + +export function saveLastWalletId(id: WalletId): void { + if (typeof window === "undefined") return; + window.localStorage.setItem(STORAGE_KEY, id); +} + +export function clearLastWalletId(): void { + if (typeof window === "undefined") return; + window.localStorage.removeItem(STORAGE_KEY); +} + +export type { WalletAdapter, WalletId, SignOptions } from "./types"; +export { WalletNotInstalledError, WalletConnectionError } from "./types"; diff --git a/src/lib/wallets/types.ts b/src/lib/wallets/types.ts new file mode 100644 index 0000000..16c9c5e --- /dev/null +++ b/src/lib/wallets/types.ts @@ -0,0 +1,31 @@ +export type WalletId = "freighter" | "xbull" | "albedo"; + +export interface SignOptions { + networkPassphrase: string; +} + +export interface WalletAdapter { + readonly id: WalletId; + readonly name: string; + readonly iconUrl: string; + readonly installUrl: string; + + isAvailable(): Promise; + connect(): Promise; + getPublicKey(): Promise; + signTransaction(xdr: string, options: SignOptions): Promise; +} + +export class WalletNotInstalledError extends Error { + constructor(public readonly walletId: WalletId, public readonly installUrl: string) { + super(`${walletId} wallet is not installed`); + this.name = "WalletNotInstalledError"; + } +} + +export class WalletConnectionError extends Error { + constructor(public readonly walletId: WalletId, message: string) { + super(message); + this.name = "WalletConnectionError"; + } +} diff --git a/src/lib/wallets/xbull.ts b/src/lib/wallets/xbull.ts new file mode 100644 index 0000000..73eb42f --- /dev/null +++ b/src/lib/wallets/xbull.ts @@ -0,0 +1,78 @@ +import { + SignOptions, + WalletAdapter, + WalletConnectionError, + WalletNotInstalledError, +} from "./types"; + +interface XBullSDK { + connect(permissions: { + canRequestPublicKey: boolean; + canRequestSign: boolean; + }): Promise; + getPublicKey(): Promise; + signXDR( + xdr: string, + options?: { publicKey?: string; network?: string }, + ): Promise; + closeConnections?(): Promise; +} + +declare global { + interface Window { + xBullSDK?: XBullSDK; + } +} + +function sdk(): XBullSDK | null { + if (typeof window === "undefined") return null; + return window.xBullSDK ?? null; +} + +export const xbullAdapter: WalletAdapter = { + id: "xbull", + name: "xBull", + iconUrl: "https://xbull.app/favicon.ico", + installUrl: "https://xbull.app/", + + async isAvailable() { + return sdk() !== null; + }, + + async connect() { + const x = sdk(); + if (!x) throw new WalletNotInstalledError(this.id, this.installUrl); + try { + const ok = await x.connect({ + canRequestPublicKey: true, + canRequestSign: true, + }); + if (!ok) { + throw new WalletConnectionError(this.id, "User denied xBull permissions"); + } + return await x.getPublicKey(); + } catch (err) { + if (err instanceof WalletConnectionError) throw err; + throw new WalletConnectionError( + this.id, + err instanceof Error ? err.message : "Failed to connect to xBull", + ); + } + }, + + async getPublicKey() { + const x = sdk(); + if (!x) return null; + try { + return await x.getPublicKey(); + } catch { + return null; + } + }, + + async signTransaction(xdr: string, { networkPassphrase }: SignOptions) { + const x = sdk(); + if (!x) throw new WalletNotInstalledError(this.id, this.installUrl); + return x.signXDR(xdr, { network: networkPassphrase }); + }, +};