diff --git a/.changeset/update-stacks-connect.md b/.changeset/update-stacks-connect.md new file mode 100644 index 0000000..732d68f --- /dev/null +++ b/.changeset/update-stacks-connect.md @@ -0,0 +1,21 @@ +--- +"@satoshai/kit": minor +--- + +Update @stacks/connect to 8.2.5 (pinned) and leverage WalletConnect.initializeProvider for faster wallet-connect connections and session restores. + +New features: +- `useWallets` hook — returns configured wallets with `available` flag (installed + configured check) +- `wallets` prop on `StacksWalletProvider` — configure which wallets to support +- `reset` on `useConnect` — clear stuck connecting state when wallet modals are dismissed +- `WalletInfo` type exported for consumers +- Runtime guard: throws if `wallet-connect` is in `wallets` without a `walletConnect.projectId` + +Fixes: +- Moved runtime guard from render body to `useEffect` (React Strict Mode safe) +- `reset()` now invalidates in-flight `connect()` promises via generation counter (prevents stale state) +- Guard against concurrent `WalletConnect.initializeProvider` calls between session restore and connect + +Breaking: +- Removed `useAvailableWallets` (replaced by `useWallets`) +- Removed `connectors` from `useConnect` (replaced by `useWallets`) diff --git a/README.md b/README.md index 7d6eb4d..2974214 100644 --- a/README.md +++ b/README.md @@ -10,6 +10,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 @@ -25,7 +26,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 ( @@ -36,7 +37,8 @@ function App() { } function Wallet() { - const { connect, connectors } = useConnect(); + const { connect, reset, isPending } = useConnect(); + const { wallets } = useWallets(); const { address, isConnected } = useAddress(); const { disconnect } = useDisconnect(); @@ -51,9 +53,10 @@ function Wallet() { return (
- {connectors.map((wallet) => ( - } + {wallets.map(({ id, available }) => ( + ))}
diff --git a/examples/vite-react/.env.example b/examples/vite-react/.env.example new file mode 100644 index 0000000..6e4f795 --- /dev/null +++ b/examples/vite-react/.env.example @@ -0,0 +1 @@ +VITE_WALLETCONNECT_PROJECT_ID= diff --git a/examples/vite-react/src/app.tsx b/examples/vite-react/src/app.tsx index 23472d1..8d980ba 100644 --- a/examples/vite-react/src/app.tsx +++ b/examples/vite-react/src/app.tsx @@ -4,12 +4,16 @@ import { useConnect, useDisconnect, useBnsName, - SUPPORTED_STACKS_WALLETS, + useWallets, } from '@satoshai/kit'; +const wcProjectId = import.meta.env.VITE_WALLETCONNECT_PROJECT_ID as string | undefined; + export const App = () => { return ( - +

@satoshai/kit Example

@@ -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) => ( - } + {wallets.map(({ id, available }) => ( + ))}
@@ -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 }) => ( + +))} +``` + +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