diff --git a/frontend/src/wallet/stellar.test.ts b/frontend/src/wallet/stellar.test.ts new file mode 100644 index 0000000..b399eaf --- /dev/null +++ b/frontend/src/wallet/stellar.test.ts @@ -0,0 +1,140 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +vi.mock('@stellar/freighter-api', () => ({ + getAddress: vi.fn(), + isConnected: vi.fn(), + requestAccess: vi.fn() +})); + +vi.mock('@lobstrco/signer-extension-api', () => ({ + getPublicKey: vi.fn(), + isConnected: vi.fn() +})); + +import { requestAccess } from '@stellar/freighter-api'; +import { connectStellarWallet } from './stellar'; + +describe('connectStellarWallet', () => { + beforeEach(() => { + vi.clearAllMocks(); + // @ts-expect-error test double + delete window.albedo; + // @ts-expect-error test double + delete window.xBullSDK; + // @ts-expect-error test double + delete window.rabet; + // @ts-expect-error test double + delete window.hana; + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + it('connects xBull via xBullSDK instead of Freighter', async () => { + const connect = vi.fn().mockResolvedValue(undefined); + const getPublicKeys = vi + .fn() + .mockResolvedValue([{ publicKey: 'GXBULLPUBLICKEY' }]); + + // @ts-expect-error test double + window.xBullSDK = { connect, getPublicKeys }; + + const wallet = await connectStellarWallet('xbull'); + + expect(connect).toHaveBeenCalled(); + expect(getPublicKeys).toHaveBeenCalled(); + expect(requestAccess).not.toHaveBeenCalled(); + expect(wallet).toMatchObject({ + id: 'xbull', + name: 'xBull', + address: 'GXBULLPUBLICKEY', + family: 'stellar' + }); + }); + + it('connects Albedo via window.albedo.publicKey', async () => { + // @ts-expect-error test double + window.albedo = { + publicKey: vi + .fn() + .mockResolvedValue({ pubkey: 'GALBEDOPUBLICKEY' }) + }; + + const wallet = await connectStellarWallet('albedo'); + + expect(requestAccess).not.toHaveBeenCalled(); + expect(wallet).toMatchObject({ + id: 'albedo', + address: 'GALBEDOPUBLICKEY' + }); + }); + + it('connects Rabet via window.rabet.connect', async () => { + // @ts-expect-error test double + window.rabet = { + connect: vi + .fn() + .mockResolvedValue({ publicKey: 'GRABETPUBLICKEY' }) + }; + + const wallet = await connectStellarWallet('rabet'); + + expect(requestAccess).not.toHaveBeenCalled(); + expect(wallet).toMatchObject({ + id: 'rabet', + address: 'GRABETPUBLICKEY' + }); + }); + + it('connects Hana via window.hana.getPublicKey', async () => { + // @ts-expect-error test double + window.hana = { + getPublicKey: vi + .fn() + .mockResolvedValue('GHANAPUBLICKEY') + }; + + const wallet = await connectStellarWallet('hana-wallet'); + + expect(requestAccess).not.toHaveBeenCalled(); + expect(wallet).toMatchObject({ + id: 'hana-wallet', + name: 'Hana', + address: 'GHANAPUBLICKEY' + }); + }); + + it('fails fast for missing xBull instead of hanging on Freighter', async () => { + await expect( + connectStellarWallet('xbull') + ).rejects.toThrow(/xBull is not installed/i); + expect(requestAccess).not.toHaveBeenCalled(); + }); + + it('rejects unknown stellar wallet ids', async () => { + await expect( + // @ts-expect-error intentional bad id + connectStellarWallet('not-a-wallet') + ).rejects.toThrow(/not supported/i); + expect(requestAccess).not.toHaveBeenCalled(); + }); + + it('times out hanging wallet SDK calls', async () => { + vi.useFakeTimers(); + + // @ts-expect-error test double + window.xBullSDK = { + connect: () => new Promise(() => undefined) + }; + + const pending = connectStellarWallet('xbull'); + const expectation = expect(pending).rejects.toThrow( + /timed out/i + ); + + await vi.advanceTimersByTimeAsync(30_000); + await expectation; + expect(requestAccess).not.toHaveBeenCalled(); + }); +}); diff --git a/frontend/src/wallet/stellar.ts b/frontend/src/wallet/stellar.ts index 3323649..66cf12a 100644 --- a/frontend/src/wallet/stellar.ts +++ b/frontend/src/wallet/stellar.ts @@ -19,6 +19,40 @@ export type StellarNetworkName = | 'public' | 'testnet'; +const CONNECT_TIMEOUT_MS = 30_000; + +const STELLAR_WALLET_META: Partial< + Record< + WalletId, + { name: string; connectorName: string } + > +> = { + lobstr: { + name: 'LOBSTR', + connectorName: 'LOBSTR Signer' + }, + freighter: { + name: 'Freighter', + connectorName: 'Freighter API' + }, + albedo: { + name: 'Albedo', + connectorName: 'Albedo' + }, + xbull: { + name: 'xBull', + connectorName: 'xBull SDK' + }, + rabet: { + name: 'Rabet', + connectorName: 'Rabet' + }, + 'hana-wallet': { + name: 'Hana', + connectorName: 'Hana Wallet' + } +}; + const stellarNetwork = process.env .NEXT_PUBLIC_STELLAR_NETWORK === @@ -63,21 +97,346 @@ const getChainConfig = () => const buildWallet = ( walletId: WalletId, address: string -): ConnectedWallet => ({ - id: walletId, - name: - walletId === 'lobstr' - ? 'LOBSTR' - : 'Freighter', - address, - family: 'stellar', - chain: getChainConfig().chain, - connectorName: - walletId === 'lobstr' - ? 'LOBSTR Signer' - : 'Freighter API', - connectedAt: Date.now() -}); +): ConnectedWallet => { + const meta = + STELLAR_WALLET_META[walletId] ?? { + name: String(walletId), + connectorName: String(walletId) + }; + + return { + id: walletId, + name: meta.name, + address, + family: 'stellar', + chain: getChainConfig().chain, + connectorName: meta.connectorName, + connectedAt: Date.now() + }; +}; + +const getBrowserWindow = () => + typeof window !== 'undefined' + ? window + : undefined; + +type AlbedoApi = { + publicKey?: (opts?: { + token?: string; + }) => Promise<{ pubkey?: string; publicKey?: string }>; +}; + +type XBullApi = { + connect?: (opts?: Record) => Promise; + getPublicKeys?: () => Promise< + Array + >; + getAddress?: () => Promise< + string | { publicKey?: string; address?: string } + >; + getPublicKey?: () => Promise; +}; + +type RabetApi = { + connect?: () => Promise< + string | { publicKey?: string; address?: string } + >; + isConnected?: () => Promise; + getPublicKey?: () => Promise; +}; + +type HanaApi = { + getPublicKey?: () => Promise; + getAddress?: () => Promise< + string | { publicKey?: string; address?: string } + >; + connect?: () => Promise< + string | { publicKey?: string; address?: string } + >; + setAllowedStatus?: ( + allowed: boolean + ) => Promise; +}; + +const withTimeout = async ( + promise: Promise, + label: string, + ms = CONNECT_TIMEOUT_MS +): Promise => { + let timer: ReturnType | undefined; + try { + return await Promise.race([ + promise, + new Promise((_, reject) => { + timer = setTimeout(() => { + reject( + new Error( + `${label} timed out after ${Math.round(ms / 1000)}s. Check the extension popup or try again.` + ) + ); + }, ms); + }) + ]); + } finally { + if (timer) clearTimeout(timer); + } +}; + +const pickAddress = (value: unknown): string | null => { + if (typeof value === 'string' && value.trim()) { + return value.trim(); + } + if (value && typeof value === 'object') { + const record = value as Record; + for (const key of [ + 'publicKey', + 'pubkey', + 'address', + 'key' + ]) { + const candidate = record[key]; + if ( + typeof candidate === 'string' && + candidate.trim() + ) { + return candidate.trim(); + } + } + } + return null; +}; + +const connectLobstr = async (): Promise => { + const installed = await isLobstrConnected(); + if (!installed) { + throw new Error( + 'LOBSTR signer extension is not installed.' + ); + } + + const address = await withTimeout( + getLobstrPublicKey(), + 'LOBSTR' + ); + if (!address) { + throw new Error( + 'LOBSTR did not return a public key.' + ); + } + + return buildWallet('lobstr', address); +}; + +const connectFreighter = async (): Promise => { + const access = await withTimeout( + requestAccess(), + 'Freighter' + ); + + if (access.error) { + throw new Error( + access.error.message || + 'Freighter denied the connection request.' + ); + } + + if (!access.address) { + throw new Error( + 'Freighter did not return an address.' + ); + } + + return buildWallet('freighter', access.address); +}; + +const connectAlbedo = async (): Promise => { + const albedo = getBrowserWindow()?.albedo as + | AlbedoApi + | undefined; + + if (!albedo?.publicKey) { + throw new Error( + 'Albedo is not installed or did not inject window.albedo.' + ); + } + + const result = await withTimeout( + albedo.publicKey({}), + 'Albedo' + ); + const address = + pickAddress(result) ?? + pickAddress( + (result as { pubkey?: string })?.pubkey + ); + + if (!address) { + throw new Error( + 'Albedo did not return a public key.' + ); + } + + return buildWallet('albedo', address); +}; + +const connectXBull = async (): Promise => { + const xbull = getBrowserWindow()?.xBullSDK as + | XBullApi + | undefined; + + if (!xbull) { + throw new Error( + 'xBull is not installed or did not inject window.xBullSDK.' + ); + } + + if (typeof xbull.connect === 'function') { + await withTimeout( + xbull.connect({ + canRequestPublicKey: true, + canRequestSign: true + }), + 'xBull connect' + ); + } + + if (typeof xbull.getPublicKeys === 'function') { + const keys = await withTimeout( + xbull.getPublicKeys(), + 'xBull getPublicKeys' + ); + const first = Array.isArray(keys) ? keys[0] : null; + const address = pickAddress(first); + if (address) { + return buildWallet('xbull', address); + } + } + + if (typeof xbull.getAddress === 'function') { + const result = await withTimeout( + xbull.getAddress(), + 'xBull getAddress' + ); + const address = pickAddress(result); + if (address) { + return buildWallet('xbull', address); + } + } + + if (typeof xbull.getPublicKey === 'function') { + const result = await withTimeout( + xbull.getPublicKey(), + 'xBull getPublicKey' + ); + const address = pickAddress(result); + if (address) { + return buildWallet('xbull', address); + } + } + + throw new Error( + 'xBull did not return a public key.' + ); +}; + +const connectRabet = async (): Promise => { + const rabet = getBrowserWindow()?.rabet as + | RabetApi + | undefined; + + if (!rabet) { + throw new Error( + 'Rabet is not installed or did not inject window.rabet.' + ); + } + + if (typeof rabet.connect === 'function') { + const result = await withTimeout( + rabet.connect(), + 'Rabet' + ); + const address = pickAddress(result); + if (address) { + return buildWallet('rabet', address); + } + } + + if (typeof rabet.getPublicKey === 'function') { + const result = await withTimeout( + rabet.getPublicKey(), + 'Rabet getPublicKey' + ); + const address = pickAddress(result); + if (address) { + return buildWallet('rabet', address); + } + } + + throw new Error( + 'Rabet did not return a public key.' + ); +}; + +const connectHana = async (): Promise => { + const hana = getBrowserWindow()?.hana as + | HanaApi + | undefined; + + if (!hana) { + throw new Error( + 'Hana is not installed or did not inject window.hana.' + ); + } + + if (typeof hana.setAllowedStatus === 'function') { + try { + await withTimeout( + hana.setAllowedStatus(true), + 'Hana allow', + 10_000 + ); + } catch { + // Some builds skip this gate; fall through to key methods. + } + } + + if (typeof hana.connect === 'function') { + const result = await withTimeout( + hana.connect(), + 'Hana connect' + ); + const address = pickAddress(result); + if (address) { + return buildWallet('hana-wallet', address); + } + } + + if (typeof hana.getPublicKey === 'function') { + const result = await withTimeout( + hana.getPublicKey(), + 'Hana getPublicKey' + ); + const address = pickAddress(result); + if (address) { + return buildWallet('hana-wallet', address); + } + } + + if (typeof hana.getAddress === 'function') { + const result = await withTimeout( + hana.getAddress(), + 'Hana getAddress' + ); + const address = pickAddress(result); + if (address) { + return buildWallet('hana-wallet', address); + } + } + + throw new Error( + 'Hana did not return a public key.' + ); +}; export const detectStellarWallets = async () => { @@ -87,10 +446,7 @@ export const detectStellarWallets = isFreighterConnected() ]); - const browserWindow = - typeof window !== 'undefined' - ? window - : undefined; + const browserWindow = getBrowserWindow(); return { lobstr: @@ -123,93 +479,236 @@ export const connectStellarWallet = async ( walletId: WalletId ): Promise => { - if (walletId === 'lobstr') { - const installed = - await isLobstrConnected(); - - if (!installed) { + switch (walletId) { + case 'lobstr': + return connectLobstr(); + case 'freighter': + return connectFreighter(); + case 'albedo': + return connectAlbedo(); + case 'xbull': + return connectXBull(); + case 'rabet': + return connectRabet(); + case 'hana-wallet': + return connectHana(); + default: throw new Error( - 'LOBSTR signer extension is not installed.' + `Stellar wallet "${walletId}" is not supported.` ); - } - - const address = - await getLobstrPublicKey(); - - if (!address) { - throw new Error( - 'LOBSTR did not return a public key.' - ); - } - - return buildWallet( - 'lobstr', - address - ); } + }; - const access = - await requestAccess(); +const restoreLobstr = + async (): Promise => { + const installed = await isLobstrConnected(); + if (!installed) return null; - if (access.error) { - throw new Error( - access.error.message || - 'Freighter denied the connection request.' - ); - } + const address = await getLobstrPublicKey(); + return address + ? buildWallet('lobstr', address) + : null; + }; - if (!access.address) { - throw new Error( - 'Freighter did not return an address.' - ); +const restoreFreighter = + async (): Promise => { + const installed = await isFreighterConnected(); + if (!installed.isConnected) return null; + + const address = await getFreighterAddress(); + if (address.error || !address.address) { + return null; } return buildWallet( 'freighter', - access.address + address.address ); }; -export const restoreStellarWallet = - async ( - walletId: WalletId - ): Promise => { - if (walletId === 'lobstr') { - const installed = - await isLobstrConnected(); - - if (!installed) { - return null; - } +const restoreAlbedo = + async (): Promise => { + const albedo = getBrowserWindow()?.albedo as + | AlbedoApi + | undefined; + if (!albedo?.publicKey) return null; - const address = - await getLobstrPublicKey(); + try { + const result = await withTimeout( + albedo.publicKey({}), + 'Albedo restore', + 10_000 + ); + const address = pickAddress(result); return address - ? buildWallet( - 'lobstr', - address - ) + ? buildWallet('albedo', address) : null; + } catch { + return null; + } + }; + +const restoreXBull = + async (): Promise => { + const xbull = getBrowserWindow()?.xBullSDK as + | XBullApi + | undefined; + if (!xbull) return null; + + try { + if (typeof xbull.getPublicKeys === 'function') { + const keys = await withTimeout( + xbull.getPublicKeys(), + 'xBull restore', + 10_000 + ); + const address = pickAddress( + Array.isArray(keys) ? keys[0] : null + ); + if (address) { + return buildWallet('xbull', address); + } + } + + if (typeof xbull.getAddress === 'function') { + const result = await withTimeout( + xbull.getAddress(), + 'xBull restore address', + 10_000 + ); + const address = pickAddress(result); + if (address) { + return buildWallet('xbull', address); + } + } + + if (typeof xbull.getPublicKey === 'function') { + const result = await withTimeout( + xbull.getPublicKey(), + 'xBull restore key', + 10_000 + ); + const address = pickAddress(result); + if (address) { + return buildWallet('xbull', address); + } + } + } catch { + return null; } - const installed = - await isFreighterConnected(); + return null; + }; + +const restoreRabet = + async (): Promise => { + const rabet = getBrowserWindow()?.rabet as + | RabetApi + | undefined; + if (!rabet) return null; + + try { + if (typeof rabet.isConnected === 'function') { + const connected = await rabet.isConnected(); + if (!connected) return null; + } + + if (typeof rabet.getPublicKey === 'function') { + const result = await withTimeout( + rabet.getPublicKey(), + 'Rabet restore', + 10_000 + ); + const address = pickAddress(result); + if (address) { + return buildWallet('rabet', address); + } + } - if (!installed.isConnected) { + // Fall back to connect() only if already authorized — + // some Rabet builds expose no separate getter. + if (typeof rabet.connect === 'function') { + const result = await withTimeout( + rabet.connect(), + 'Rabet restore connect', + 10_000 + ); + const address = pickAddress(result); + if (address) { + return buildWallet('rabet', address); + } + } + } catch { return null; } - const address = - await getFreighterAddress(); + return null; + }; + +const restoreHana = + async (): Promise => { + const hana = getBrowserWindow()?.hana as + | HanaApi + | undefined; + if (!hana) return null; - if (address.error || !address.address) { + try { + if (typeof hana.getPublicKey === 'function') { + const result = await withTimeout( + hana.getPublicKey(), + 'Hana restore', + 10_000 + ); + const address = pickAddress(result); + if (address) { + return buildWallet( + 'hana-wallet', + address + ); + } + } + + if (typeof hana.getAddress === 'function') { + const result = await withTimeout( + hana.getAddress(), + 'Hana restore address', + 10_000 + ); + const address = pickAddress(result); + if (address) { + return buildWallet( + 'hana-wallet', + address + ); + } + } + } catch { return null; } - return buildWallet( - 'freighter', - address.address - ); + return null; + }; + +export const restoreStellarWallet = + async ( + walletId: WalletId + ): Promise => { + switch (walletId) { + case 'lobstr': + return restoreLobstr(); + case 'freighter': + return restoreFreighter(); + case 'albedo': + return restoreAlbedo(); + case 'xbull': + return restoreXBull(); + case 'rabet': + return restoreRabet(); + case 'hana-wallet': + return restoreHana(); + default: + return null; + } }; export const fetchStellarBalance =