@@ -19,10 +23,11 @@ export const App = () => {
};
const Wallet = () => {
- const { connect, isPending } = useConnect();
+ const { connect, reset, isPending } = useConnect();
const { address, isConnected } = useAddress();
const { disconnect } = useDisconnect();
const { bnsName, isLoading: isBnsLoading } = useBnsName(address);
+ const { wallets } = useWallets();
if (isConnected) {
return (
@@ -46,15 +51,20 @@ const Wallet = () => {
return (
Connect a Wallet
- {isPending &&
Connecting...
}
+ {isPending && (
+
+
Connecting...
+
+
+ )}
- {SUPPORTED_STACKS_WALLETS.map((wallet) => (
+ {wallets.map(({ id, available }) => (
))}
diff --git a/packages/kit/README.md b/packages/kit/README.md
index 27a3337..77d7ba5 100644
--- a/packages/kit/README.md
+++ b/packages/kit/README.md
@@ -6,6 +6,7 @@ Typesafe Stacks wallet & contract interaction library for React. Wagmi-inspired
- **`StacksWalletProvider`** — React context provider for wallet state
- **`useConnect` / `useDisconnect`** — Connect and disconnect wallets
+- **`useWallets`** — Configured wallets with availability status
- **`useAddress`** — Access connected wallet address and status
- **`useSignMessage`** — Sign arbitrary messages
- **`useWriteContract`** — Call smart contracts with post-conditions
@@ -22,7 +23,7 @@ pnpm add @satoshai/kit @stacks/transactions react react-dom
## Quick Start
```tsx
-import { StacksWalletProvider, useConnect, useAddress, useDisconnect } from '@satoshai/kit';
+import { StacksWalletProvider, useConnect, useWallets, useAddress, useDisconnect } from '@satoshai/kit';
function App() {
return (
@@ -33,7 +34,8 @@ function App() {
}
function Wallet() {
- const { connect, connectors } = useConnect();
+ const { connect, reset, isPending } = useConnect();
+ const { wallets } = useWallets();
const { address, isConnected } = useAddress();
const { disconnect } = useDisconnect();
@@ -48,9 +50,10 @@ function Wallet() {
return (
- {connectors.map((wallet) => (
-
@@ -66,28 +69,52 @@ Wrap your app to provide wallet context to all hooks.
```tsx
{}} // optional
- onAddressChange={(newAddress) => {}} // optional — Xverse account switching
- onDisconnect={() => {}} // optional
+ wallets={['xverse', 'leather', 'wallet-connect']} // optional — defaults to all supported
+ walletConnect={{ projectId: '...' }} // optional — enables WalletConnect
+ onConnect={(provider, address) => {}} // optional
+ onAddressChange={(newAddress) => {}} // optional — Xverse account switching
+ onDisconnect={() => {}} // optional
>
{children}
```
+> If `wallets` includes `'wallet-connect'`, you must provide `walletConnect.projectId` or the provider will throw at mount.
+
### `useConnect()`
```ts
-const { connect, connectors, isPending } = useConnect();
+const { connect, reset, isPending } = useConnect();
-// connectors = ['xverse', 'leather', 'okx', 'asigna', 'fordefi', 'wallet-connect']
await connect('xverse');
await connect('leather', {
onSuccess: (address, provider) => {},
onError: (error) => {},
});
+
+// Reset stuck connecting state (e.g. when a wallet modal is dismissed)
+reset();
```
+> **Note:** Some wallets (e.g. OKX) never reject the connection promise when the user closes the popup. Use `reset()` to clear the pending state in those cases.
+
+### `useWallets()`
+
+Returns all configured wallets with their availability status.
+
+```ts
+const { wallets } = useWallets();
+// [{ id: 'xverse', available: true }, { id: 'leather', available: false }, ...]
+
+{wallets.map(({ id, available }) => (
+
connect(id)} disabled={!available}>
+ {id}
+
+))}
+```
+
+A wallet is `available` when its browser extension is installed. For `wallet-connect`, it's `available` when a `walletConnect.projectId` is provided to the provider.
+
### `useDisconnect()`
```ts
diff --git a/packages/kit/package.json b/packages/kit/package.json
index 1fd9fba..aa7d19c 100644
--- a/packages/kit/package.json
+++ b/packages/kit/package.json
@@ -32,7 +32,7 @@
"clean": "rm -rf dist"
},
"dependencies": {
- "@stacks/connect": "^8.2.2",
+ "@stacks/connect": "8.2.5",
"bns-v2-sdk": "^2.1.0"
},
"peerDependencies": {
diff --git a/packages/kit/src/hooks/use-connect.ts b/packages/kit/src/hooks/use-connect.ts
index b70ca5b..19b34c4 100644
--- a/packages/kit/src/hooks/use-connect.ts
+++ b/packages/kit/src/hooks/use-connect.ts
@@ -2,19 +2,18 @@
import { useMemo } from "react";
-import { SUPPORTED_STACKS_WALLETS } from "../constants/wallets";
import { useStacksWalletContext } from "../provider/stacks-wallet-provider";
export const useConnect = () => {
- const { connect, status } = useStacksWalletContext();
+ const { connect, reset, status } = useStacksWalletContext();
const value = useMemo(
() => ({
connect,
- connectors: SUPPORTED_STACKS_WALLETS,
+ reset,
isPending: status === "connecting",
}),
- [connect, status]
+ [connect, reset, status]
);
return value;
diff --git a/packages/kit/src/hooks/use-wallets.ts b/packages/kit/src/hooks/use-wallets.ts
new file mode 100644
index 0000000..87e5d2f
--- /dev/null
+++ b/packages/kit/src/hooks/use-wallets.ts
@@ -0,0 +1,11 @@
+'use client';
+
+import { useMemo } from 'react';
+
+import { useStacksWalletContext } from '../provider/stacks-wallet-provider';
+
+export const useWallets = () => {
+ const { wallets } = useStacksWalletContext();
+
+ return useMemo(() => ({ wallets }), [wallets]);
+};
diff --git a/packages/kit/src/index.ts b/packages/kit/src/index.ts
index 6f42f36..0483d14 100644
--- a/packages/kit/src/index.ts
+++ b/packages/kit/src/index.ts
@@ -18,12 +18,14 @@ export {
type PostConditionConfig,
} from './hooks/use-write-contract/use-write-contract';
export { useBnsName } from './hooks/use-bns-name';
+export { useWallets } from './hooks/use-wallets';
// Types
export type {
WalletState,
WalletContextValue,
WalletConnectMetadata,
+ WalletInfo,
StacksChain,
ConnectOptions,
} from './provider/stacks-wallet-provider.types';
diff --git a/packages/kit/src/provider/stacks-wallet-provider.tsx b/packages/kit/src/provider/stacks-wallet-provider.tsx
index 74182fe..be02870 100644
--- a/packages/kit/src/provider/stacks-wallet-provider.tsx
+++ b/packages/kit/src/provider/stacks-wallet-provider.tsx
@@ -5,12 +5,14 @@ import {
setSelectedProviderId,
request,
getSelectedProvider,
+ WalletConnect,
} from '@stacks/connect';
import {
createContext,
useContext,
useCallback,
useEffect,
+ useRef,
useState,
useMemo,
} from 'react';
@@ -19,7 +21,10 @@ import { STACKS_TO_STACKS_CONNECT_PROVIDERS } from '../constants/stacks-provider
import { LOCAL_STORAGE_STACKS } from '../constants/storage-keys';
import type { SupportedStacksWallet } from '../constants/wallets';
import { SUPPORTED_STACKS_WALLETS } from '../constants/wallets';
-import { checkIfStacksProviderIsInstalled } from '../utils/get-stacks-wallets';
+import {
+ checkIfStacksProviderIsInstalled,
+ getStacksWallets,
+} from '../utils/get-stacks-wallets';
import {
getOKXStacksAddress,
@@ -41,6 +46,7 @@ const StacksWalletContext = createContext
(
export const StacksWalletProvider = ({
children,
+ wallets,
walletConnect,
onConnect,
onAddressChange,
@@ -52,6 +58,21 @@ export const StacksWalletProvider = ({
>();
const [isConnecting, setIsConnecting] = useState(false);
+ // Generation counter — incremented by reset() to invalidate in-flight connect promises
+ const connectGenRef = useRef(0);
+
+ // Guard against concurrent WalletConnect.initializeProvider calls
+ const wcInitRef = useRef | null>(null);
+
+ // Fix #1: runtime guard in useEffect instead of render body
+ useEffect(() => {
+ if (wallets?.includes('wallet-connect') && !walletConnect?.projectId) {
+ throw new Error(
+ 'StacksWalletProvider: "wallet-connect" is listed in wallets but no walletConnect.projectId was provided.'
+ );
+ }
+ }, [wallets, walletConnect?.projectId]);
+
useEffect(() => {
const loadPersistedWallet = async () => {
const persisted = getLocalStorageWallet();
@@ -68,6 +89,22 @@ export const StacksWalletProvider = ({
return;
}
+ if (
+ persisted.provider === 'wallet-connect' &&
+ walletConnect?.projectId
+ ) {
+ const initPromise = WalletConnect.initializeProvider(
+ buildWalletConnectConfig(
+ walletConnect.projectId,
+ walletConnect.metadata,
+ walletConnect.chains
+ )
+ );
+ wcInitRef.current = initPromise;
+ await initPromise;
+ wcInitRef.current = null;
+ }
+
setAddress(persisted.address);
setProvider(persisted.provider);
setSelectedProviderId(
@@ -81,7 +118,7 @@ export const StacksWalletProvider = ({
};
void loadPersistedWallet();
- }, []);
+ }, [walletConnect?.projectId]);
const connect = useCallback(
async (providerId: SupportedStacksWallet, options?: ConnectOptions) => {
@@ -119,11 +156,14 @@ export const StacksWalletProvider = ({
return;
}
+ // Capture generation so we can detect if reset() was called during await
+ const gen = ++connectGenRef.current;
setIsConnecting(true);
try {
if (typedProvider === 'okx') {
const data = await getOKXStacksAddress();
+ if (connectGenRef.current !== gen) return;
setAddress(data.address);
setProvider(data.provider);
options?.onSuccess?.(data.address, data.provider);
@@ -134,20 +174,37 @@ export const StacksWalletProvider = ({
STACKS_TO_STACKS_CONNECT_PROVIDERS[typedProvider]
);
- const data = walletConnect
+ const wcConfig =
+ typedProvider === 'wallet-connect' && walletConnect
+ ? buildWalletConnectConfig(
+ walletConnect.projectId,
+ walletConnect.metadata,
+ walletConnect.chains
+ )
+ : undefined;
+
+ if (wcConfig) {
+ // Wait for any in-flight init, then start ours
+ if (wcInitRef.current) await wcInitRef.current;
+ const initPromise =
+ WalletConnect.initializeProvider(wcConfig);
+ wcInitRef.current = initPromise;
+ await initPromise;
+ wcInitRef.current = null;
+ }
+
+ if (connectGenRef.current !== gen) return;
+
+ const data = wcConfig
? await request(
- {
- walletConnect: buildWalletConnectConfig(
- walletConnect.projectId,
- walletConnect.metadata,
- walletConnect.chains
- ),
- },
+ { walletConnect: wcConfig },
'getAddresses',
{}
)
: await request('getAddresses');
+ if (connectGenRef.current !== gen) return;
+
const extractedAddress = extractStacksAddress(
typedProvider,
data.addresses
@@ -157,17 +214,26 @@ export const StacksWalletProvider = ({
setProvider(typedProvider);
options?.onSuccess?.(extractedAddress, typedProvider);
} catch (error) {
+ if (connectGenRef.current !== gen) return;
console.error('Failed to connect wallet:', error);
getSelectedProvider()?.disconnect?.();
clearSelectedProviderId();
options?.onError?.(error as Error);
} finally {
- setIsConnecting(false);
+ if (connectGenRef.current === gen) {
+ setIsConnecting(false);
+ }
}
},
[walletConnect]
);
+ const reset = useCallback(() => {
+ connectGenRef.current++;
+ setIsConnecting(false);
+ clearSelectedProviderId();
+ }, []);
+
const disconnect = useCallback(
(callback?: () => void) => {
localStorage.removeItem(LOCAL_STORAGE_STACKS);
@@ -206,6 +272,19 @@ export const StacksWalletProvider = ({
connect,
});
+ const walletInfos = useMemo(() => {
+ const { installed } = getStacksWallets();
+ const configured = wallets ?? [...SUPPORTED_STACKS_WALLETS];
+
+ return configured.map((w) => ({
+ id: w,
+ available:
+ w === 'wallet-connect'
+ ? !!walletConnect?.projectId
+ : installed.includes(w),
+ }));
+ }, [wallets, walletConnect?.projectId]);
+
const value = useMemo((): WalletContextValue => {
const walletState: WalletState = isConnecting
? { status: 'connecting', address: undefined, provider: undefined }
@@ -221,8 +300,10 @@ export const StacksWalletProvider = ({
...walletState,
connect,
disconnect,
+ reset,
+ wallets: walletInfos,
};
- }, [address, provider, isConnecting, connect, disconnect]);
+ }, [address, provider, isConnecting, connect, disconnect, reset, walletInfos]);
return (
diff --git a/packages/kit/src/provider/stacks-wallet-provider.types.ts b/packages/kit/src/provider/stacks-wallet-provider.types.ts
index 535a0a8..efb13ae 100644
--- a/packages/kit/src/provider/stacks-wallet-provider.types.ts
+++ b/packages/kit/src/provider/stacks-wallet-provider.types.ts
@@ -16,6 +16,7 @@ export interface ConnectOptions {
export interface StacksWalletProviderProps {
children: React.ReactNode;
+ wallets?: SupportedStacksWallet[];
walletConnect?: {
projectId: string;
metadata?: Partial;
@@ -43,10 +44,17 @@ export type WalletState =
provider: SupportedStacksWallet;
};
+export interface WalletInfo {
+ id: SupportedStacksWallet;
+ available: boolean;
+}
+
export type WalletContextValue = WalletState & {
connect: (
providerId: SupportedStacksWallet,
options?: ConnectOptions
) => Promise;
disconnect: (callback?: () => void) => void;
+ reset: () => void;
+ wallets: WalletInfo[];
};
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 107b58c..642be5a 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -64,7 +64,7 @@ importers:
packages/kit:
dependencies:
'@stacks/connect':
- specifier: ^8.2.2
+ specifier: 8.2.5
version: 8.2.5(@types/react@19.2.14)(lit@3.3.0)(react@19.2.4)(typescript@5.9.3)(zod@3.22.4)
bns-v2-sdk:
specifier: ^2.1.0
@@ -3874,7 +3874,6 @@ snapshots:
'@noble/curves@1.9.7':
dependencies:
'@noble/hashes': 1.8.0
- optional: true
'@noble/hashes@1.1.5': {}
@@ -4336,7 +4335,7 @@ snapshots:
'@scure/bip32@1.7.0':
dependencies:
- '@noble/curves': 1.9.1
+ '@noble/curves': 1.9.7
'@noble/hashes': 1.8.0
'@scure/base': 1.2.6
@@ -6179,11 +6178,11 @@ snapshots:
dependencies:
'@adraffy/ens-normalize': 1.11.1
'@noble/ciphers': 1.3.0
- '@noble/curves': 1.9.2
+ '@noble/curves': 1.9.7
'@noble/hashes': 1.8.0
'@scure/bip32': 1.7.0
'@scure/bip39': 1.6.0
- abitype: 1.0.8(typescript@5.9.3)(zod@3.22.4)
+ abitype: 1.2.3(typescript@5.9.3)(zod@3.22.4)
eventemitter3: 5.0.1
optionalDependencies:
typescript: 5.9.3
@@ -6194,11 +6193,11 @@ snapshots:
dependencies:
'@adraffy/ens-normalize': 1.11.1
'@noble/ciphers': 1.3.0
- '@noble/curves': 1.9.2
+ '@noble/curves': 1.9.7
'@noble/hashes': 1.8.0
'@scure/bip32': 1.7.0
'@scure/bip39': 1.6.0
- abitype: 1.0.8(typescript@5.9.3)(zod@3.22.4)
+ abitype: 1.2.3(typescript@5.9.3)(zod@3.22.4)
eventemitter3: 5.0.1
optionalDependencies:
typescript: 5.9.3