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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/wallet-connect-session-events.md
Original file line number Diff line number Diff line change
@@ -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`).
Original file line number Diff line number Diff line change
@@ -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<boolean> => {
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<never>((_, reject) =>
setTimeout(() => reject(new Error('Ping timeout')), PING_TIMEOUT_MS)
),
]);
return true;
} catch {
return false;
}
};
89 changes: 89 additions & 0 deletions packages/kit/src/hooks/use-wallet-connect/use-wallet-connect.ts
Original file line number Diff line number Diff line change
@@ -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]);
};
Original file line number Diff line number Diff line change
@@ -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<void>;
}

export interface WcUniversalProvider {
on: (event: string, handler: (...args: unknown[]) => void) => void;
off: (event: string, handler: (...args: unknown[]) => void) => void;
disconnect: () => Promise<void>;
client?: WcSignClient;
session?: WcSession;
}

export interface WcStacksConnectProvider {
connector?: {
provider?: WcUniversalProvider;
};
}
15 changes: 15 additions & 0 deletions packages/kit/src/provider/stacks-wallet-provider.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down Expand Up @@ -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.
Expand Down
2 changes: 1 addition & 1 deletion packages/kit/src/types/global.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,7 @@ declare global {
};
LeatherProvider?: unknown;
StacksProvider?: unknown;
WalletConnectProvider?: unknown;
WalletConnectProvider?: import('../hooks/use-wallet-connect/use-wallet-connect.types').WcStacksConnectProvider;
}
}

Expand Down
140 changes: 140 additions & 0 deletions packages/kit/tests/unit/hooks/use-wallet-connect.helpers.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});
Loading