From 655d6f95d85e91fd79139c84560d6227a065f00a Mon Sep 17 00:00:00 2001 From: satoshai-dev <262845409+satoshai-dev@users.noreply.github.com> Date: Fri, 6 Mar 2026 14:46:46 +0000 Subject: [PATCH 1/4] feat: handle WalletConnect session events and zombie detection Add useWalletConnect hook that listens for disconnect and accountsChanged events from the underlying UniversalProvider, and validates restored sessions via relay ping to detect zombie connections. Closes #51 Co-Authored-By: Claude Opus 4.6 --- .../use-wallet-connect.helpers.ts | 35 ++ .../use-wallet-connect/use-wallet-connect.ts | 84 ++++ .../use-wallet-connect.types.ts | 8 + .../src/provider/stacks-wallet-provider.tsx | 13 + .../hooks/use-wallet-connect.helpers.test.ts | 117 ++++++ .../unit/hooks/use-wallet-connect.test.ts | 368 ++++++++++++++++++ 6 files changed, 625 insertions(+) create mode 100644 packages/kit/src/hooks/use-wallet-connect/use-wallet-connect.helpers.ts create mode 100644 packages/kit/src/hooks/use-wallet-connect/use-wallet-connect.ts create mode 100644 packages/kit/src/hooks/use-wallet-connect/use-wallet-connect.types.ts create mode 100644 packages/kit/tests/unit/hooks/use-wallet-connect.helpers.test.ts create mode 100644 packages/kit/tests/unit/hooks/use-wallet-connect.test.ts 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..201b03a --- /dev/null +++ b/packages/kit/src/hooks/use-wallet-connect/use-wallet-connect.helpers.ts @@ -0,0 +1,35 @@ +/** + * Access the underlying UniversalProvider from the @stacks/connect + * WalletConnect wrapper. Returns null if not available. + */ +export const getWcUniversalProvider = (): any | null => + (window as any).WalletConnectProvider?.connector?.provider ?? null; + +/** + * Extract the Stacks address from a CAIP-10 account ID array. + * CAIP-10 format: "stacks::
" + */ +export const extractStacksAddressFromCaip10 = ( + accounts: string[] +): string | null => { + const stxAccount = accounts.find((a) => a.startsWith('stacks:')); + if (!stxAccount) return null; + return stxAccount.split(':')[2] ?? null; +}; + +/** + * Ping the wallet via the WC relay to verify the session is still alive. + * Returns true if alive, false if dead or unreachable. + */ +export const pingSession = async (): Promise => { + const wcProvider = getWcUniversalProvider(); + const client = wcProvider?.client; + const session = wcProvider?.session; + if (!client || !session) return false; + try { + await client.ping({ topic: session.topic }); + 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..66f0a88 --- /dev/null +++ b/packages/kit/src/hooks/use-wallet-connect/use-wallet-connect.ts @@ -0,0 +1,84 @@ +import { useEffect } from 'react'; +import { clearSelectedProviderId } from '@stacks/connect'; + +import type { SupportedStacksWallet } from '../../constants/wallets'; +import type { WcAccountsChangedEvent } from './use-wallet-connect.types'; +import { + getWcUniversalProvider, + extractStacksAddressFromCaip10, + 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 && !alive) { + const wcProvider = getWcUniversalProvider(); + try { + await wcProvider?.disconnect(); + } catch { + // Ignore — 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 = (accounts: WcAccountsChangedEvent) => { + const newAddress = extractStacksAddressFromCaip10(accounts); + if (newAddress && newAddress !== address) { + onAddressChange(newAddress); + } + }; + + wcProvider.on('disconnect', handleDisconnect); + wcProvider.on('accountsChanged', handleAccountsChanged); + + return () => { + try { + wcProvider.off('disconnect', handleDisconnect); + wcProvider.off('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..a5dc3e8 --- /dev/null +++ b/packages/kit/src/hooks/use-wallet-connect/use-wallet-connect.types.ts @@ -0,0 +1,8 @@ +export interface WcDisconnectEvent { + code: number; + message: string; + topic: string; +} + +/** CAIP-10 account IDs, e.g. ["stacks:1:SP123..."] */ +export type WcAccountsChangedEvent = string[]; diff --git a/packages/kit/src/provider/stacks-wallet-provider.tsx b/packages/kit/src/provider/stacks-wallet-provider.tsx index c1c4f55..ac1f2d6 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,18 @@ export const StacksWalletProvider = ({ connect, }); + useWalletConnect({ + address, + provider, + onAddressChange: handleAddressChange, + onDisconnect: () => { + localStorage.removeItem(LOCAL_STORAGE_STACKS); + setAddress(undefined); + setProvider(undefined); + onDisconnect?.(); + }, + }); + // 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/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..9f779d8 --- /dev/null +++ b/packages/kit/tests/unit/hooks/use-wallet-connect.helpers.test.ts @@ -0,0 +1,117 @@ +// @vitest-environment happy-dom +import { describe, it, expect, vi, beforeEach } from 'vitest'; + +import { + extractStacksAddressFromCaip10, + getWcUniversalProvider, + pingSession, +} from '../../../src/hooks/use-wallet-connect/use-wallet-connect.helpers'; + +describe('extractStacksAddressFromCaip10', () => { + it('extracts address from a stacks CAIP-10 account', () => { + expect( + extractStacksAddressFromCaip10(['stacks:1:SP123ABC']) + ).toBe('SP123ABC'); + }); + + it('returns null when no stacks account is present', () => { + expect( + extractStacksAddressFromCaip10(['bitcoin:1:bc1qxyz']) + ).toBeNull(); + }); + + it('returns null for empty array', () => { + expect(extractStacksAddressFromCaip10([])).toBeNull(); + }); + + it('picks the first stacks account when multiple exist', () => { + expect( + extractStacksAddressFromCaip10([ + 'stacks:1:SP111', + 'stacks:2147483648:ST222', + ]) + ).toBe('SP111'); + }); + + it('ignores non-stacks accounts', () => { + expect( + extractStacksAddressFromCaip10([ + 'ethereum:1:0xabc', + 'stacks:1:SP999', + ]) + ).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..2d49566 --- /dev/null +++ b/packages/kit/tests/unit/hooks/use-wallet-connect.test.ts @@ -0,0 +1,368 @@ +// @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 mockExtractStacksAddressFromCaip10 = vi.fn(); +const mockPingSession = vi.fn(); + +vi.mock( + '../../../src/hooks/use-wallet-connect/use-wallet-connect.helpers', + () => ({ + getWcUniversalProvider: mockGetWcUniversalProvider, + extractStacksAddressFromCaip10: mockExtractStacksAddressFromCaip10, + 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(); + mockExtractStacksAddressFromCaip10.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) + ); + }); + + 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(), + }); + mockExtractStacksAddressFromCaip10.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(mockExtractStacksAddressFromCaip10).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(), + }); + mockExtractStacksAddressFromCaip10.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) + ); + }); + + 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(); + }); + }); +}); From 8596920f440a88cecfa6f1280b163e64f9786761 Mon Sep 17 00:00:00 2001 From: satoshai-dev <262845409+satoshai-dev@users.noreply.github.com> Date: Fri, 6 Mar 2026 14:51:03 +0000 Subject: [PATCH 2/4] fix: replace any types with proper WalletConnect type definitions Define WcUniversalProvider, WcSignClient, WcSession, and WcStacksConnectProvider interfaces. Update global.d.ts to use the typed WcStacksConnectProvider instead of unknown. Co-Authored-By: Claude Opus 4.6 --- .../use-wallet-connect.helpers.ts | 6 +++-- .../use-wallet-connect/use-wallet-connect.ts | 5 ++++- .../use-wallet-connect.types.ts | 22 +++++++++++++++++++ packages/kit/src/types/global.d.ts | 2 +- 4 files changed, 31 insertions(+), 4 deletions(-) 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 index 201b03a..2f98b4c 100644 --- 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 @@ -1,9 +1,11 @@ +import type { WcUniversalProvider } from './use-wallet-connect.types'; + /** * Access the underlying UniversalProvider from the @stacks/connect * WalletConnect wrapper. Returns null if not available. */ -export const getWcUniversalProvider = (): any | null => - (window as any).WalletConnectProvider?.connector?.provider ?? null; +export const getWcUniversalProvider = (): WcUniversalProvider | null => + window.WalletConnectProvider?.connector?.provider ?? null; /** * Extract the Stacks address from a CAIP-10 account ID array. 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 index 66f0a88..2268d33 100644 --- a/packages/kit/src/hooks/use-wallet-connect/use-wallet-connect.ts +++ b/packages/kit/src/hooks/use-wallet-connect/use-wallet-connect.ts @@ -59,7 +59,10 @@ export const useWalletConnect = ({ onDisconnect(); }; - const handleAccountsChanged = (accounts: WcAccountsChangedEvent) => { + const handleAccountsChanged = ( + ...args: unknown[] + ) => { + const accounts = args[0] as WcAccountsChangedEvent; const newAddress = extractStacksAddressFromCaip10(accounts); if (newAddress && newAddress !== address) { onAddressChange(newAddress); 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 index a5dc3e8..adfd26a 100644 --- 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 @@ -6,3 +6,25 @@ export interface WcDisconnectEvent { /** CAIP-10 account IDs, e.g. ["stacks:1:SP123..."] */ export type WcAccountsChangedEvent = 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/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; } } From 108253def1f8e79a76f7c17c6c4d943843fad24a Mon Sep 17 00:00:00 2001 From: satoshai-dev <262845409+satoshai-dev@users.noreply.github.com> Date: Fri, 6 Mar 2026 16:03:26 +0000 Subject: [PATCH 3/4] fix: stabilize zombie detection and handle SIP-030 account events - Wrap onDisconnect in useCallback to prevent effect cancellation loop - Add 10s ping timeout to avoid 5-min WC default hang - Listen for stx_accountChange (SIP-030) and stx_accountsChanged events - Handle SIP-030 { address, publicKey } objects in extractStacksAddress - Handle plain addresses from UniversalProvider (not just CAIP-10) Co-Authored-By: Claude Opus 4.6 --- .../use-wallet-connect.helpers.ts | 39 +++++++++--- .../use-wallet-connect/use-wallet-connect.ts | 20 +++--- .../use-wallet-connect.types.ts | 8 ++- .../src/provider/stacks-wallet-provider.tsx | 14 +++-- .../hooks/use-wallet-connect.helpers.test.ts | 61 +++++++++++++------ .../unit/hooks/use-wallet-connect.test.ts | 60 ++++++++++++++++-- 6 files changed, 151 insertions(+), 51 deletions(-) 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 index 2f98b4c..5c86b43 100644 --- 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 @@ -1,4 +1,7 @@ -import type { WcUniversalProvider } from './use-wallet-connect.types'; +import type { + WcUniversalProvider, + StxAccount, +} from './use-wallet-connect.types'; /** * Access the underlying UniversalProvider from the @stacks/connect @@ -8,20 +11,33 @@ export const getWcUniversalProvider = (): WcUniversalProvider | null => window.WalletConnectProvider?.connector?.provider ?? null; /** - * Extract the Stacks address from a CAIP-10 account ID array. - * CAIP-10 format: "stacks::
" + * 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 extractStacksAddressFromCaip10 = ( - accounts: string[] +export const extractStacksAddress = ( + accounts: (StxAccount | string)[], ): string | null => { - const stxAccount = accounts.find((a) => a.startsWith('stacks:')); - if (!stxAccount) return null; - return stxAccount.split(':')[2] ?? 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(); @@ -29,7 +45,12 @@ export const pingSession = async (): Promise => { const session = wcProvider?.session; if (!client || !session) return false; try { - await client.ping({ topic: session.topic }); + 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 index 2268d33..8b8c5c7 100644 --- a/packages/kit/src/hooks/use-wallet-connect/use-wallet-connect.ts +++ b/packages/kit/src/hooks/use-wallet-connect/use-wallet-connect.ts @@ -2,10 +2,9 @@ import { useEffect } from 'react'; import { clearSelectedProviderId } from '@stacks/connect'; import type { SupportedStacksWallet } from '../../constants/wallets'; -import type { WcAccountsChangedEvent } from './use-wallet-connect.types'; import { getWcUniversalProvider, - extractStacksAddressFromCaip10, + extractStacksAddress, pingSession, } from './use-wallet-connect.helpers'; @@ -28,12 +27,13 @@ export const useWalletConnect = ({ const validateSession = async () => { const alive = await pingSession(); - if (!cancelled && !alive) { + if (cancelled) return; + if (!alive) { const wcProvider = getWcUniversalProvider(); try { await wcProvider?.disconnect(); } catch { - // Ignore — provider may already be cleaned up + // Provider may already be cleaned up } clearSelectedProviderId(); onDisconnect(); @@ -59,11 +59,9 @@ export const useWalletConnect = ({ onDisconnect(); }; - const handleAccountsChanged = ( - ...args: unknown[] - ) => { - const accounts = args[0] as WcAccountsChangedEvent; - const newAddress = extractStacksAddressFromCaip10(accounts); + 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); } @@ -71,11 +69,15 @@ export const useWalletConnect = ({ 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:', 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 index adfd26a..f2f9c3e 100644 --- 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 @@ -4,8 +4,12 @@ export interface WcDisconnectEvent { topic: string; } -/** CAIP-10 account IDs, e.g. ["stacks:1:SP123..."] */ -export type WcAccountsChangedEvent = string[]; +/** SIP-030 account object emitted via stx_accountChange events. */ +export interface StxAccount { + address: string; + publicKey: string; +} + export interface WcSession { topic: string; diff --git a/packages/kit/src/provider/stacks-wallet-provider.tsx b/packages/kit/src/provider/stacks-wallet-provider.tsx index ac1f2d6..e9ffc2a 100644 --- a/packages/kit/src/provider/stacks-wallet-provider.tsx +++ b/packages/kit/src/provider/stacks-wallet-provider.tsx @@ -412,16 +412,18 @@ export const StacksWalletProvider = ({ connect, }); + const handleWcDisconnect = useCallback(() => { + localStorage.removeItem(LOCAL_STORAGE_STACKS); + setAddress(undefined); + setProvider(undefined); + onDisconnect?.(); + }, [onDisconnect]); + useWalletConnect({ address, provider, onAddressChange: handleAddressChange, - onDisconnect: () => { - localStorage.removeItem(LOCAL_STORAGE_STACKS); - setAddress(undefined); - setProvider(undefined); - onDisconnect?.(); - }, + onDisconnect: handleWcDisconnect, }); // Computed in render body (not memoized) so it picks up wallet extensions 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 index 9f779d8..d65289f 100644 --- a/packages/kit/tests/unit/hooks/use-wallet-connect.helpers.test.ts +++ b/packages/kit/tests/unit/hooks/use-wallet-connect.helpers.test.ts @@ -2,43 +2,66 @@ import { describe, it, expect, vi, beforeEach } from 'vitest'; import { - extractStacksAddressFromCaip10, + extractStacksAddress, getWcUniversalProvider, pingSession, } from '../../../src/hooks/use-wallet-connect/use-wallet-connect.helpers'; -describe('extractStacksAddressFromCaip10', () => { - it('extracts address from a stacks CAIP-10 account', () => { +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( - extractStacksAddressFromCaip10(['stacks:1:SP123ABC']) - ).toBe('SP123ABC'); + extractStacksAddress(['SP111', 'stacks:1:SP222']) + ).toBe('SP111'); }); - it('returns null when no stacks account is present', () => { + it('handles mixed formats with non-stacks entries', () => { expect( - extractStacksAddressFromCaip10(['bitcoin:1:bc1qxyz']) - ).toBeNull(); + extractStacksAddress(['0xabc', 'stacks:1:SP999']) + ).toBe('SP999'); }); - it('returns null for empty array', () => { - expect(extractStacksAddressFromCaip10([])).toBeNull(); + it('handles testnet addresses starting with ST', () => { + expect(extractStacksAddress(['ST123ABC'])).toBe('ST123ABC'); }); - it('picks the first stacks account when multiple exist', () => { + it('extracts address from SIP-030 account object', () => { expect( - extractStacksAddressFromCaip10([ - 'stacks:1:SP111', - 'stacks:2147483648:ST222', + 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('ignores non-stacks accounts', () => { + it('handles mixed objects and strings', () => { expect( - extractStacksAddressFromCaip10([ - 'ethereum:1:0xabc', - 'stacks:1:SP999', - ]) + extractStacksAddress(['0xabc', { address: 'SP999', publicKey: '' }]) ).toBe('SP999'); }); }); diff --git a/packages/kit/tests/unit/hooks/use-wallet-connect.test.ts b/packages/kit/tests/unit/hooks/use-wallet-connect.test.ts index 2d49566..c07068a 100644 --- a/packages/kit/tests/unit/hooks/use-wallet-connect.test.ts +++ b/packages/kit/tests/unit/hooks/use-wallet-connect.test.ts @@ -8,14 +8,14 @@ vi.mock('@stacks/connect', () => ({ })); const mockGetWcUniversalProvider = vi.fn(); -const mockExtractStacksAddressFromCaip10 = vi.fn(); +const mockExtractStacksAddress = vi.fn(); const mockPingSession = vi.fn(); vi.mock( '../../../src/hooks/use-wallet-connect/use-wallet-connect.helpers', () => ({ getWcUniversalProvider: mockGetWcUniversalProvider, - extractStacksAddressFromCaip10: mockExtractStacksAddressFromCaip10, + extractStacksAddress: mockExtractStacksAddress, pingSession: mockPingSession, }) ); @@ -29,7 +29,7 @@ const flushPromises = () => new Promise((r) => setTimeout(r, 0)); beforeEach(() => { mockClearSelectedProviderId.mockReset(); mockGetWcUniversalProvider.mockReset(); - mockExtractStacksAddressFromCaip10.mockReset(); + mockExtractStacksAddress.mockReset(); mockPingSession.mockReset(); }); @@ -151,6 +151,46 @@ describe('useWalletConnect', () => { '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 () => { @@ -194,7 +234,7 @@ describe('useWalletConnect', () => { }, off: vi.fn(), }); - mockExtractStacksAddressFromCaip10.mockReturnValue('SP456'); + mockExtractStacksAddress.mockReturnValue('SP456'); const onAddressChange = vi.fn(); @@ -214,7 +254,7 @@ describe('useWalletConnect', () => { listeners['accountsChanged']!(['stacks:1:SP456']); }); - expect(mockExtractStacksAddressFromCaip10).toHaveBeenCalledWith([ + expect(mockExtractStacksAddress).toHaveBeenCalledWith([ 'stacks:1:SP456', ]); expect(onAddressChange).toHaveBeenCalledWith('SP456'); @@ -229,7 +269,7 @@ describe('useWalletConnect', () => { }, off: vi.fn(), }); - mockExtractStacksAddressFromCaip10.mockReturnValue('SP123'); + mockExtractStacksAddress.mockReturnValue('SP123'); const onAddressChange = vi.fn(); @@ -288,6 +328,14 @@ describe('useWalletConnect', () => { '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 () => { From 4d0ea84e7120c370206017b81c23163769ba5f60 Mon Sep 17 00:00:00 2001 From: satoshai-dev <262845409+satoshai-dev@users.noreply.github.com> Date: Fri, 6 Mar 2026 16:06:10 +0000 Subject: [PATCH 4/4] chore: add changeset for WalletConnect session events Co-Authored-By: Claude Opus 4.6 --- .changeset/wallet-connect-session-events.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changeset/wallet-connect-session-events.md 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`).