diff --git a/.changeset/wallet-connect-session-events.md b/.changeset/wallet-connect-session-events.md new file mode 100644 index 0000000..288fc15 --- /dev/null +++ b/.changeset/wallet-connect-session-events.md @@ -0,0 +1,5 @@ +--- +"@satoshai/kit": minor +--- + +Add `useWalletConnect` hook to handle WalletConnect session lifecycle events. Detects zombie sessions on restore via relay ping (10s timeout), listens for wallet-initiated disconnect and account change events (generic `accountsChanged`, SIP-030 `stx_accountChange`, and `stx_accountsChanged`). diff --git a/packages/kit/src/hooks/use-wallet-connect/use-wallet-connect.helpers.ts b/packages/kit/src/hooks/use-wallet-connect/use-wallet-connect.helpers.ts new file mode 100644 index 0000000..5c86b43 --- /dev/null +++ b/packages/kit/src/hooks/use-wallet-connect/use-wallet-connect.helpers.ts @@ -0,0 +1,58 @@ +import type { + WcUniversalProvider, + StxAccount, +} from './use-wallet-connect.types'; + +/** + * Access the underlying UniversalProvider from the @stacks/connect + * WalletConnect wrapper. Returns null if not available. + */ +export const getWcUniversalProvider = (): WcUniversalProvider | null => + window.WalletConnectProvider?.connector?.provider ?? null; + +/** + * Extract the first Stacks address from an stx_accountChange payload. + * + * SIP-030 defines the data as an array of { address, publicKey } objects. + * The generic WC `accountsChanged` event may carry plain addresses or + * CAIP-10 strings — this helper handles all three formats. + */ +export const extractStacksAddress = ( + accounts: (StxAccount | string)[], +): string | null => { + for (const entry of accounts) { + if (typeof entry === 'object' && entry !== null && 'address' in entry) { + return entry.address; + } + if (typeof entry === 'string') { + if (entry.startsWith('S')) return entry; + if (entry.startsWith('stacks:')) return entry.split(':')[2] ?? null; + } + } + return null; +}; + +const PING_TIMEOUT_MS = 10_000; + +/** + * Ping the wallet via the WC relay to verify the session is still alive. + * Returns true if alive, false if dead or unreachable. + * Times out after 10 seconds to avoid the default 5-minute WC timeout. + */ +export const pingSession = async (): Promise => { + const wcProvider = getWcUniversalProvider(); + const client = wcProvider?.client; + const session = wcProvider?.session; + if (!client || !session) return false; + try { + await Promise.race([ + client.ping({ topic: session.topic }), + new Promise((_, reject) => + setTimeout(() => reject(new Error('Ping timeout')), PING_TIMEOUT_MS) + ), + ]); + return true; + } catch { + return false; + } +}; diff --git a/packages/kit/src/hooks/use-wallet-connect/use-wallet-connect.ts b/packages/kit/src/hooks/use-wallet-connect/use-wallet-connect.ts new file mode 100644 index 0000000..8b8c5c7 --- /dev/null +++ b/packages/kit/src/hooks/use-wallet-connect/use-wallet-connect.ts @@ -0,0 +1,89 @@ +import { useEffect } from 'react'; +import { clearSelectedProviderId } from '@stacks/connect'; + +import type { SupportedStacksWallet } from '../../constants/wallets'; +import { + getWcUniversalProvider, + extractStacksAddress, + pingSession, +} from './use-wallet-connect.helpers'; + +export const useWalletConnect = ({ + address, + provider, + onAddressChange, + onDisconnect, +}: { + address: string | undefined; + provider: SupportedStacksWallet | undefined; + onAddressChange: (newAddress: string) => void; + onDisconnect: () => void; +}) => { + // On restore: validate the session is still alive + useEffect(() => { + if (provider !== 'wallet-connect' || !address) return; + + let cancelled = false; + + const validateSession = async () => { + const alive = await pingSession(); + if (cancelled) return; + if (!alive) { + const wcProvider = getWcUniversalProvider(); + try { + await wcProvider?.disconnect(); + } catch { + // Provider may already be cleaned up + } + clearSelectedProviderId(); + onDisconnect(); + } + }; + + void validateSession(); + + return () => { + cancelled = true; + }; + }, [provider, address, onDisconnect]); + + // Listen for wallet-initiated disconnect and account changes + useEffect(() => { + if (provider !== 'wallet-connect' || !address) return; + + const wcProvider = getWcUniversalProvider(); + if (!wcProvider) return; + + const handleDisconnect = () => { + clearSelectedProviderId(); + onDisconnect(); + }; + + const handleAccountsChanged = (...args: unknown[]) => { + const accounts = args[0] as (import('./use-wallet-connect.types').StxAccount | string)[]; + const newAddress = extractStacksAddress(accounts); + if (newAddress && newAddress !== address) { + onAddressChange(newAddress); + } + }; + + wcProvider.on('disconnect', handleDisconnect); + wcProvider.on('accountsChanged', handleAccountsChanged); + wcProvider.on('stx_accountChange', handleAccountsChanged); + wcProvider.on('stx_accountsChanged', handleAccountsChanged); + + return () => { + try { + wcProvider.off('disconnect', handleDisconnect); + wcProvider.off('accountsChanged', handleAccountsChanged); + wcProvider.off('stx_accountChange', handleAccountsChanged); + wcProvider.off('stx_accountsChanged', handleAccountsChanged); + } catch (error) { + console.error( + 'Failed to remove WalletConnect listeners:', + error + ); + } + }; + }, [address, provider, onAddressChange, onDisconnect]); +}; diff --git a/packages/kit/src/hooks/use-wallet-connect/use-wallet-connect.types.ts b/packages/kit/src/hooks/use-wallet-connect/use-wallet-connect.types.ts new file mode 100644 index 0000000..f2f9c3e --- /dev/null +++ b/packages/kit/src/hooks/use-wallet-connect/use-wallet-connect.types.ts @@ -0,0 +1,34 @@ +export interface WcDisconnectEvent { + code: number; + message: string; + topic: string; +} + +/** SIP-030 account object emitted via stx_accountChange events. */ +export interface StxAccount { + address: string; + publicKey: string; +} + + +export interface WcSession { + topic: string; +} + +export interface WcSignClient { + ping: (params: { topic: string }) => Promise; +} + +export interface WcUniversalProvider { + on: (event: string, handler: (...args: unknown[]) => void) => void; + off: (event: string, handler: (...args: unknown[]) => void) => void; + disconnect: () => Promise; + client?: WcSignClient; + session?: WcSession; +} + +export interface WcStacksConnectProvider { + connector?: { + provider?: WcUniversalProvider; + }; +} diff --git a/packages/kit/src/provider/stacks-wallet-provider.tsx b/packages/kit/src/provider/stacks-wallet-provider.tsx index c1c4f55..e9ffc2a 100644 --- a/packages/kit/src/provider/stacks-wallet-provider.tsx +++ b/packages/kit/src/provider/stacks-wallet-provider.tsx @@ -46,6 +46,7 @@ import type { ConnectOptions, StacksWalletProviderProps, } from './stacks-wallet-provider.types'; +import { useWalletConnect } from '../hooks/use-wallet-connect/use-wallet-connect'; import { useXverse } from '../hooks/use-xverse/use-xverse'; import { getLocalStorageWallet } from '../utils/get-local-storage-wallet'; @@ -411,6 +412,20 @@ export const StacksWalletProvider = ({ connect, }); + const handleWcDisconnect = useCallback(() => { + localStorage.removeItem(LOCAL_STORAGE_STACKS); + setAddress(undefined); + setProvider(undefined); + onDisconnect?.(); + }, [onDisconnect]); + + useWalletConnect({ + address, + provider, + onAddressChange: handleAddressChange, + onDisconnect: handleWcDisconnect, + }); + // Computed in render body (not memoized) so it picks up wallet extensions // injected after hydration. The context value useMemo below uses // walletInfosKey so the reference stays stable when nothing changes. diff --git a/packages/kit/src/types/global.d.ts b/packages/kit/src/types/global.d.ts index 4135ea6..28bed52 100644 --- a/packages/kit/src/types/global.d.ts +++ b/packages/kit/src/types/global.d.ts @@ -47,7 +47,7 @@ declare global { }; LeatherProvider?: unknown; StacksProvider?: unknown; - WalletConnectProvider?: unknown; + WalletConnectProvider?: import('../hooks/use-wallet-connect/use-wallet-connect.types').WcStacksConnectProvider; } } diff --git a/packages/kit/tests/unit/hooks/use-wallet-connect.helpers.test.ts b/packages/kit/tests/unit/hooks/use-wallet-connect.helpers.test.ts new file mode 100644 index 0000000..d65289f --- /dev/null +++ b/packages/kit/tests/unit/hooks/use-wallet-connect.helpers.test.ts @@ -0,0 +1,140 @@ +// @vitest-environment happy-dom +import { describe, it, expect, vi, beforeEach } from 'vitest'; + +import { + extractStacksAddress, + getWcUniversalProvider, + pingSession, +} from '../../../src/hooks/use-wallet-connect/use-wallet-connect.helpers'; + +describe('extractStacksAddress', () => { + it('extracts plain Stacks address starting with S', () => { + expect(extractStacksAddress(['SP123ABC'])).toBe('SP123ABC'); + }); + + it('extracts address from CAIP-10 format', () => { + expect(extractStacksAddress(['stacks:1:SP123ABC'])).toBe('SP123ABC'); + }); + + it('returns null for empty array', () => { + expect(extractStacksAddress([])).toBeNull(); + }); + + it('returns null when no stacks address is present', () => { + expect(extractStacksAddress(['0xabc', 'bitcoin:1:bc1q'])).toBeNull(); + }); + + it('picks the first Stacks address', () => { + expect(extractStacksAddress(['SP111', 'SP222'])).toBe('SP111'); + }); + + it('prefers plain address over CAIP-10 when plain comes first', () => { + expect( + extractStacksAddress(['SP111', 'stacks:1:SP222']) + ).toBe('SP111'); + }); + + it('handles mixed formats with non-stacks entries', () => { + expect( + extractStacksAddress(['0xabc', 'stacks:1:SP999']) + ).toBe('SP999'); + }); + + it('handles testnet addresses starting with ST', () => { + expect(extractStacksAddress(['ST123ABC'])).toBe('ST123ABC'); + }); + + it('extracts address from SIP-030 account object', () => { + expect( + extractStacksAddress([{ address: 'SP123ABC', publicKey: '' }]) + ).toBe('SP123ABC'); + }); + + it('picks first SIP-030 object address', () => { + expect( + extractStacksAddress([ + { address: 'SP111', publicKey: 'abc' }, + { address: 'SP222', publicKey: 'def' }, + ]) + ).toBe('SP111'); + }); + + it('handles mixed objects and strings', () => { + expect( + extractStacksAddress(['0xabc', { address: 'SP999', publicKey: '' }]) + ).toBe('SP999'); + }); +}); + +describe('getWcUniversalProvider', () => { + beforeEach(() => { + delete (window as any).WalletConnectProvider; + }); + + it('returns null when WalletConnectProvider is not on window', () => { + expect(getWcUniversalProvider()).toBeNull(); + }); + + it('returns null when connector is missing', () => { + (window as any).WalletConnectProvider = {}; + expect(getWcUniversalProvider()).toBeNull(); + }); + + it('returns the underlying provider', () => { + const fakeProvider = { on: vi.fn(), off: vi.fn() }; + (window as any).WalletConnectProvider = { + connector: { provider: fakeProvider }, + }; + expect(getWcUniversalProvider()).toBe(fakeProvider); + }); +}); + +describe('pingSession', () => { + beforeEach(() => { + delete (window as any).WalletConnectProvider; + }); + + it('returns false when provider is not available', async () => { + expect(await pingSession()).toBe(false); + }); + + it('returns false when client is missing', async () => { + (window as any).WalletConnectProvider = { + connector: { provider: { session: { topic: 't1' } } }, + }; + expect(await pingSession()).toBe(false); + }); + + it('returns false when session is missing', async () => { + (window as any).WalletConnectProvider = { + connector: { provider: { client: { ping: vi.fn() } } }, + }; + expect(await pingSession()).toBe(false); + }); + + it('returns true when ping succeeds', async () => { + (window as any).WalletConnectProvider = { + connector: { + provider: { + client: { ping: vi.fn().mockResolvedValue(undefined) }, + session: { topic: 'topic123' }, + }, + }, + }; + expect(await pingSession()).toBe(true); + }); + + it('returns false when ping throws', async () => { + (window as any).WalletConnectProvider = { + connector: { + provider: { + client: { + ping: vi.fn().mockRejectedValue(new Error('timeout')), + }, + session: { topic: 'topic123' }, + }, + }, + }; + expect(await pingSession()).toBe(false); + }); +}); diff --git a/packages/kit/tests/unit/hooks/use-wallet-connect.test.ts b/packages/kit/tests/unit/hooks/use-wallet-connect.test.ts new file mode 100644 index 0000000..c07068a --- /dev/null +++ b/packages/kit/tests/unit/hooks/use-wallet-connect.test.ts @@ -0,0 +1,416 @@ +// @vitest-environment happy-dom +import { renderHook, act } from '@testing-library/react'; +import { describe, it, expect, vi, beforeEach } from 'vitest'; + +const mockClearSelectedProviderId = vi.fn(); +vi.mock('@stacks/connect', () => ({ + clearSelectedProviderId: mockClearSelectedProviderId, +})); + +const mockGetWcUniversalProvider = vi.fn(); +const mockExtractStacksAddress = vi.fn(); +const mockPingSession = vi.fn(); + +vi.mock( + '../../../src/hooks/use-wallet-connect/use-wallet-connect.helpers', + () => ({ + getWcUniversalProvider: mockGetWcUniversalProvider, + extractStacksAddress: mockExtractStacksAddress, + pingSession: mockPingSession, + }) +); + +const { useWalletConnect } = await import( + '../../../src/hooks/use-wallet-connect/use-wallet-connect' +); + +const flushPromises = () => new Promise((r) => setTimeout(r, 0)); + +beforeEach(() => { + mockClearSelectedProviderId.mockReset(); + mockGetWcUniversalProvider.mockReset(); + mockExtractStacksAddress.mockReset(); + mockPingSession.mockReset(); +}); + +describe('useWalletConnect', () => { + describe('non-wallet-connect provider', () => { + it('does not run effects when provider is not wallet-connect', async () => { + await act(async () => { + renderHook(() => + useWalletConnect({ + address: 'SP123', + provider: 'leather', + onAddressChange: vi.fn(), + onDisconnect: vi.fn(), + }) + ); + await flushPromises(); + }); + + expect(mockPingSession).not.toHaveBeenCalled(); + expect(mockGetWcUniversalProvider).not.toHaveBeenCalled(); + }); + + it('does not run effects when provider is undefined', async () => { + await act(async () => { + renderHook(() => + useWalletConnect({ + address: undefined, + provider: undefined, + onAddressChange: vi.fn(), + onDisconnect: vi.fn(), + }) + ); + await flushPromises(); + }); + + expect(mockPingSession).not.toHaveBeenCalled(); + }); + }); + + describe('session validation', () => { + it('calls onDisconnect when ping fails (zombie session)', async () => { + mockPingSession.mockResolvedValue(false); + mockGetWcUniversalProvider.mockReturnValue({ + disconnect: vi.fn().mockResolvedValue(undefined), + on: vi.fn(), + off: vi.fn(), + }); + + const onDisconnect = vi.fn(); + + await act(async () => { + renderHook(() => + useWalletConnect({ + address: 'SP123', + provider: 'wallet-connect', + onAddressChange: vi.fn(), + onDisconnect, + }) + ); + await flushPromises(); + }); + + expect(mockPingSession).toHaveBeenCalled(); + expect(mockClearSelectedProviderId).toHaveBeenCalled(); + expect(onDisconnect).toHaveBeenCalled(); + }); + + it('does not disconnect when ping succeeds', async () => { + mockPingSession.mockResolvedValue(true); + mockGetWcUniversalProvider.mockReturnValue({ + on: vi.fn(), + off: vi.fn(), + }); + + const onDisconnect = vi.fn(); + + await act(async () => { + renderHook(() => + useWalletConnect({ + address: 'SP123', + provider: 'wallet-connect', + onAddressChange: vi.fn(), + onDisconnect, + }) + ); + await flushPromises(); + }); + + expect(onDisconnect).not.toHaveBeenCalled(); + }); + }); + + describe('event listeners', () => { + it('subscribes to disconnect and accountsChanged events', async () => { + mockPingSession.mockResolvedValue(true); + const mockOn = vi.fn(); + mockGetWcUniversalProvider.mockReturnValue({ + on: mockOn, + off: vi.fn(), + }); + + await act(async () => { + renderHook(() => + useWalletConnect({ + address: 'SP123', + provider: 'wallet-connect', + onAddressChange: vi.fn(), + onDisconnect: vi.fn(), + }) + ); + await flushPromises(); + }); + + expect(mockOn).toHaveBeenCalledWith( + 'disconnect', + expect.any(Function) + ); + expect(mockOn).toHaveBeenCalledWith( + 'accountsChanged', + expect.any(Function) + ); + expect(mockOn).toHaveBeenCalledWith( + 'stx_accountChange', + expect.any(Function) + ); + expect(mockOn).toHaveBeenCalledWith( + 'stx_accountsChanged', + expect.any(Function) + ); + }); + + it('calls onAddressChange via stx_accountChange event', async () => { + mockPingSession.mockResolvedValue(true); + const listeners: Record = {}; + mockGetWcUniversalProvider.mockReturnValue({ + on: (event: string, handler: Function) => { + listeners[event] = handler; + }, + off: vi.fn(), + }); + mockExtractStacksAddress.mockReturnValue('SP456'); + + const onAddressChange = vi.fn(); + + await act(async () => { + renderHook(() => + useWalletConnect({ + address: 'SP123', + provider: 'wallet-connect', + onAddressChange, + onDisconnect: vi.fn(), + }) + ); + await flushPromises(); + }); + + act(() => { + listeners['stx_accountChange']!([{ address: 'SP456', publicKey: '' }]); + }); + + expect(onAddressChange).toHaveBeenCalledWith('SP456'); + }); + + it('calls onDisconnect when disconnect event fires', async () => { + mockPingSession.mockResolvedValue(true); + const listeners: Record = {}; + mockGetWcUniversalProvider.mockReturnValue({ + on: (event: string, handler: Function) => { + listeners[event] = handler; + }, + off: vi.fn(), + }); + + const onDisconnect = vi.fn(); + + await act(async () => { + renderHook(() => + useWalletConnect({ + address: 'SP123', + provider: 'wallet-connect', + onAddressChange: vi.fn(), + onDisconnect, + }) + ); + await flushPromises(); + }); + + act(() => { + listeners['disconnect']!(); + }); + + expect(mockClearSelectedProviderId).toHaveBeenCalled(); + expect(onDisconnect).toHaveBeenCalled(); + }); + + it('calls onAddressChange when accountsChanged event fires with new address', async () => { + mockPingSession.mockResolvedValue(true); + const listeners: Record = {}; + mockGetWcUniversalProvider.mockReturnValue({ + on: (event: string, handler: Function) => { + listeners[event] = handler; + }, + off: vi.fn(), + }); + mockExtractStacksAddress.mockReturnValue('SP456'); + + const onAddressChange = vi.fn(); + + await act(async () => { + renderHook(() => + useWalletConnect({ + address: 'SP123', + provider: 'wallet-connect', + onAddressChange, + onDisconnect: vi.fn(), + }) + ); + await flushPromises(); + }); + + act(() => { + listeners['accountsChanged']!(['stacks:1:SP456']); + }); + + expect(mockExtractStacksAddress).toHaveBeenCalledWith([ + 'stacks:1:SP456', + ]); + expect(onAddressChange).toHaveBeenCalledWith('SP456'); + }); + + it('does not call onAddressChange when address is the same', async () => { + mockPingSession.mockResolvedValue(true); + const listeners: Record = {}; + mockGetWcUniversalProvider.mockReturnValue({ + on: (event: string, handler: Function) => { + listeners[event] = handler; + }, + off: vi.fn(), + }); + mockExtractStacksAddress.mockReturnValue('SP123'); + + const onAddressChange = vi.fn(); + + await act(async () => { + renderHook(() => + useWalletConnect({ + address: 'SP123', + provider: 'wallet-connect', + onAddressChange, + onDisconnect: vi.fn(), + }) + ); + await flushPromises(); + }); + + act(() => { + listeners['accountsChanged']!(['stacks:1:SP123']); + }); + + expect(onAddressChange).not.toHaveBeenCalled(); + }); + }); + + describe('cleanup', () => { + it('removes listeners on unmount', async () => { + mockPingSession.mockResolvedValue(true); + const mockOff = vi.fn(); + mockGetWcUniversalProvider.mockReturnValue({ + on: vi.fn(), + off: mockOff, + }); + + let unmount!: () => void; + await act(async () => { + const hook = renderHook(() => + useWalletConnect({ + address: 'SP123', + provider: 'wallet-connect', + onAddressChange: vi.fn(), + onDisconnect: vi.fn(), + }) + ); + unmount = hook.unmount; + await flushPromises(); + }); + + act(() => { + unmount(); + }); + + expect(mockOff).toHaveBeenCalledWith( + 'disconnect', + expect.any(Function) + ); + expect(mockOff).toHaveBeenCalledWith( + 'accountsChanged', + expect.any(Function) + ); + expect(mockOff).toHaveBeenCalledWith( + 'stx_accountChange', + expect.any(Function) + ); + expect(mockOff).toHaveBeenCalledWith( + 'stx_accountsChanged', + expect.any(Function) + ); + }); + + it('logs error when off() throws on unmount', async () => { + mockPingSession.mockResolvedValue(true); + const removeError = new Error('Off failed'); + mockGetWcUniversalProvider.mockReturnValue({ + on: vi.fn(), + off: () => { + throw removeError; + }, + }); + + const consoleSpy = vi + .spyOn(console, 'error') + .mockImplementation(() => {}); + + let unmount!: () => void; + await act(async () => { + const hook = renderHook(() => + useWalletConnect({ + address: 'SP123', + provider: 'wallet-connect', + onAddressChange: vi.fn(), + onDisconnect: vi.fn(), + }) + ); + unmount = hook.unmount; + await flushPromises(); + }); + + act(() => { + unmount(); + }); + + expect(consoleSpy).toHaveBeenCalledWith( + 'Failed to remove WalletConnect listeners:', + removeError + ); + consoleSpy.mockRestore(); + }); + }); + + describe('cancellation', () => { + it('does not call onDisconnect if unmounted before ping resolves', async () => { + let resolvePing!: (value: boolean) => void; + mockPingSession.mockReturnValue( + new Promise((resolve) => { + resolvePing = resolve; + }) + ); + mockGetWcUniversalProvider.mockReturnValue({ + disconnect: vi.fn().mockResolvedValue(undefined), + on: vi.fn(), + off: vi.fn(), + }); + + const onDisconnect = vi.fn(); + + const { unmount } = renderHook(() => + useWalletConnect({ + address: 'SP123', + provider: 'wallet-connect', + onAddressChange: vi.fn(), + onDisconnect, + }) + ); + + unmount(); + + await act(async () => { + resolvePing(false); + await flushPromises(); + }); + + expect(onDisconnect).not.toHaveBeenCalled(); + }); + }); +});