From 55fb2ea161745adf6502bfd58730f61b5012c60e Mon Sep 17 00:00:00 2001 From: satoshai-dev <262845409+satoshai-dev@users.noreply.github.com> Date: Fri, 6 Mar 2026 16:46:56 +0000 Subject: [PATCH 1/3] docs: add JSDoc to all consumer-facing exports and update README Add comprehensive JSDoc documentation to all hooks, errors, types, utilities, provider, and constants. Update README with typed useWriteContract examples, createContractConfig, error handling guide, mutation return types table, and WalletConnect session management docs. Closes #34 Co-Authored-By: Claude Opus 4.6 --- .changeset/jsdoc-consumer-exports.md | 5 + README.md | 190 ++++++++++++++++-- packages/kit/src/constants/wallets.ts | 2 + packages/kit/src/errors.ts | 40 ++++ packages/kit/src/hooks/use-address.ts | 16 ++ packages/kit/src/hooks/use-bns-name.ts | 14 ++ packages/kit/src/hooks/use-connect.ts | 24 +++ packages/kit/src/hooks/use-disconnect.ts | 14 ++ packages/kit/src/hooks/use-sign-message.ts | 26 +++ .../src/hooks/use-sign-structured-message.ts | 27 +++ .../kit/src/hooks/use-sign-transaction.ts | 27 +++ packages/kit/src/hooks/use-transfer-stx.ts | 28 +++ packages/kit/src/hooks/use-wallets.ts | 18 ++ .../use-write-contract/use-write-contract.ts | 40 ++++ .../use-write-contract.types.ts | 4 + .../src/provider/stacks-wallet-provider.tsx | 23 +++ .../provider/stacks-wallet-provider.types.ts | 42 ++++ .../kit/src/utils/create-contract-config.ts | 19 +- .../kit/src/utils/get-local-storage-wallet.ts | 6 + .../kit/src/utils/get-network-from-address.ts | 7 + packages/kit/src/utils/get-stacks-wallets.ts | 9 + 21 files changed, 562 insertions(+), 19 deletions(-) create mode 100644 .changeset/jsdoc-consumer-exports.md diff --git a/.changeset/jsdoc-consumer-exports.md b/.changeset/jsdoc-consumer-exports.md new file mode 100644 index 0000000..c0474db --- /dev/null +++ b/.changeset/jsdoc-consumer-exports.md @@ -0,0 +1,5 @@ +--- +"@satoshai/kit": patch +--- + +Add JSDoc documentation to all consumer-facing exports (hooks, errors, types, utilities, provider) and update README with typed `useWriteContract` examples, `createContractConfig`, error handling guide, mutation return types, and WalletConnect session management. diff --git a/README.md b/README.md index 568b1bf..4936e0a 100644 --- a/README.md +++ b/README.md @@ -11,14 +11,16 @@ 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 +- **`useAddress`** — Access connected wallet address and status (discriminated union) - **`useSignMessage`** — Sign arbitrary messages - **`useSignStructuredMessage`** — Sign SIP-018 structured data - **`useSignTransaction`** — Sign serialized transactions (sponsored tx flows) -- **`useWriteContract`** — Call smart contracts with post-conditions +- **`useWriteContract`** — Call smart contracts with post-conditions (typed or untyped) - **`useTransferSTX`** — Native STX transfers - **`useBnsName`** — Resolve BNS v2 names +- **Typed errors** — `BaseError`, `WalletNotConnectedError`, `WalletNotFoundError`, `UnsupportedMethodError`, `WalletRequestError` - **6 wallets supported** — Xverse, Leather, OKX, Asigna, Fordefi, WalletConnect +- **WalletConnect session management** — Zombie session detection, wallet-initiated disconnect, and account change events - **Next.js App Router compatible** — `"use client"` directives included ## Install @@ -75,13 +77,22 @@ Wrap your app to provide wallet context to all hooks. connectModal={true} // optional — defaults to true walletConnect={{ projectId: '...' }} // optional — enables WalletConnect onConnect={(provider, address) => {}} // optional - onAddressChange={(newAddress) => {}} // optional — Xverse account switching + onAddressChange={(newAddress) => {}} // optional — Xverse/WalletConnect account switching onDisconnect={() => {}} // optional > {children} ``` +| Prop | Type | Default | Description | +|------|------|---------|-------------| +| `wallets` | `SupportedStacksWallet[]` | All 6 wallets | Wallets to enable. | +| `connectModal` | `boolean` | `true` | Show `@stacks/connect` modal on `connect()` with no args. | +| `walletConnect` | `{ projectId, metadata?, chains? }` | — | WalletConnect config. Required when `wallets` includes `'wallet-connect'`. | +| `onConnect` | `(provider, address) => void` | — | Called after successful connection. | +| `onAddressChange` | `(newAddress) => void` | — | Called when the connected account changes. | +| `onDisconnect` | `() => void` | — | Called when the wallet disconnects. | + > If `wallets` includes `'wallet-connect'`, you must provide `walletConnect.projectId` or the provider will throw at mount. > **Important:** Define `wallets` and `walletConnect` outside of your component (or memoize them) so they remain referentially stable across renders. These values are treated as static configuration. @@ -111,8 +122,10 @@ When `connectModal` is enabled: ### `useConnect()` +Connect to a Stacks wallet. Returns a mutation-style object. + ```ts -const { connect, reset, isPending } = useConnect(); +const { connect, reset, error, isPending, isSuccess, isError, isIdle, status } = useConnect(); // Open the @stacks/connect modal (when connectModal is enabled, the default) await connect(); @@ -153,8 +166,10 @@ A wallet is `available` when its browser extension is installed. For `wallet-con ### `useDisconnect()` +Disconnect the current wallet and clear the persisted session. + ```ts -const { disconnect } = useDisconnect(); +const { disconnect, reset, error, isSuccess, isError, isIdle, isPending, status } = useDisconnect(); disconnect(); disconnect(() => { /* callback after disconnect */ }); @@ -162,24 +177,29 @@ disconnect(() => { /* callback after disconnect */ }); ### `useAddress()` +Read the connected wallet's address and connection status. Returns a **discriminated union** — when `isConnected` is `true`, `address` and `provider` are narrowed to defined values (no null checks needed). + ```ts const { address, isConnected, isConnecting, isDisconnected, provider } = useAddress(); if (isConnected) { - console.log(address); // 'SP...' or 'ST...' + console.log(address); // 'SP...' or 'ST...' — narrowed to string console.log(provider); // 'xverse' | 'leather' | ... } ``` ### `useSignMessage()` +Sign an arbitrary plaintext message. + ```ts -const { signMessage, signMessageAsync, data, error, isPending } = useSignMessage(); +const { signMessage, signMessageAsync, data, error, isPending, reset } = useSignMessage(); // Callback style signMessage({ message: 'Hello Stacks' }, { onSuccess: ({ publicKey, signature }) => {}, onError: (error) => {}, + onSettled: (data, error) => {}, }); // Async style @@ -190,7 +210,7 @@ const { publicKey, signature } = await signMessageAsync({ message: 'Hello Stacks Sign SIP-018 structured data for typed, verifiable off-chain messages. -> **Note:** OKX wallet does not support structured message signing and will throw an error. +> **Note:** OKX wallet does not support structured message signing and will throw an `UnsupportedMethodError`. ```ts import { tupleCV, stringAsciiCV, uintCV } from '@stacks/transactions'; @@ -222,14 +242,18 @@ const { publicKey, signature } = await signStructuredMessageAsync({ ### `useTransferSTX()` +Transfer native STX tokens. Amount is in **microSTX** (1 STX = 1,000,000 microSTX). + ```ts -const { transferSTX, transferSTXAsync, data, error, isPending } = useTransferSTX(); +const { transferSTX, transferSTXAsync, data, error, isPending, reset } = useTransferSTX(); // Callback style transferSTX({ recipient: 'SP2...', - amount: 1000000n, // in microSTX + amount: 1000000n, // 1 STX memo: 'optional memo', + fee: 2000n, // optional custom fee + nonce: 42n, // optional custom nonce }, { onSuccess: (txid) => {}, onError: (error) => {}, @@ -244,10 +268,14 @@ const txid = await transferSTXAsync({ ### `useWriteContract()` +Call a public function on a Clarity smart contract. Supports two modes: + +#### Untyped mode (ClarityValue[] args) + ```ts -import { Pc, PostConditionMode } from '@stacks/transactions'; +import { uintCV, Pc, PostConditionMode } from '@stacks/transactions'; -const { writeContract, writeContractAsync, data, error, isPending } = useWriteContract(); +const { writeContract, writeContractAsync, data, error, isPending, reset } = useWriteContract(); writeContract({ address: 'SP...', @@ -264,14 +292,58 @@ writeContract({ }); ``` +#### Typed mode (with ABI — autocomplete + type-checked args) + +When you pass an `abi` object, `functionName` is autocompleted from the ABI's public functions and `args` becomes a named, type-checked object. + +```ts +import { PostConditionMode } from '@stacks/transactions'; +import type { ClarityAbi } from '@satoshai/kit'; + +// 1. Define your ABI (use @satoshai/abi-cli to generate it) +const poolAbi = { functions: [...], ... } as const satisfies ClarityAbi; + +// 2. Call with full type safety +const txid = await writeContractAsync({ + abi: poolAbi, + address: 'SP...', + contract: 'pool-v1', + functionName: 'deposit', // autocompleted + args: { amount: 1000000n }, // named args, type-checked + pc: { postConditions: [], mode: PostConditionMode.Deny }, +}); +``` + +#### `createContractConfig()` + +Pre-bind ABI + address + contract for reuse across multiple calls: + +```ts +import { createContractConfig } from '@satoshai/kit'; + +const pool = createContractConfig({ + abi: poolAbi, + address: 'SP...', + contract: 'pool-v1', +}); + +// Spread into writeContract — functionName and args stay typed +writeContract({ + ...pool, + functionName: 'deposit', + args: { amount: 1000000n }, + pc: { postConditions: [], mode: PostConditionMode.Deny }, +}); +``` + ### `useSignTransaction()` Sign a serialized transaction without automatically broadcasting it. Useful for sponsored transaction flows where a separate service pays the fee. -> **Note:** OKX wallet does not support raw transaction signing and will throw an error. +> **Note:** OKX wallet does not support raw transaction signing and will throw an `UnsupportedMethodError`. ```ts -const { signTransaction, signTransactionAsync, data, error, isPending } = useSignTransaction(); +const { signTransaction, signTransactionAsync, data, error, isPending, reset } = useSignTransaction(); // Callback style signTransaction({ transaction: '0x0100...', broadcast: false }, { @@ -288,6 +360,8 @@ const { transaction, txid } = await signTransactionAsync({ ### `useBnsName()` +Resolve a BNS v2 primary name for a Stacks address. Returns `null` when no name is registered. + ```ts const { bnsName, isLoading } = useBnsName(address); // bnsName = 'satoshi.btc' | null @@ -296,14 +370,94 @@ const { bnsName, isLoading } = useBnsName(address); ### Utilities ```ts -import { getNetworkFromAddress, getStacksWallets, getLocalStorageWallet } from '@satoshai/kit'; - +import { + getNetworkFromAddress, + getStacksWallets, + getLocalStorageWallet, + createContractConfig, +} from '@satoshai/kit'; + +// Infer network from address prefix getNetworkFromAddress('SP...'); // 'mainnet' getNetworkFromAddress('ST...'); // 'testnet' +// Detect supported and installed wallets const { supported, installed } = getStacksWallets(); + +// Read persisted wallet session (returns null on server or when empty) +const session = getLocalStorageWallet(); +// { address: 'SP...', provider: 'xverse' } | null ``` +## Mutation Hook Return Types + +All mutation hooks (`useConnect`, `useSignMessage`, `useWriteContract`, etc.) return the same status shape: + +| Field | Type | Description | +|-------|------|-------------| +| `data` | `T \| undefined` | The successful result. | +| `error` | `BaseError \| null` | The error, if any. | +| `status` | `'idle' \| 'pending' \| 'error' \| 'success'` | Current mutation status. | +| `isIdle` | `boolean` | `true` when no operation has been triggered. | +| `isPending` | `boolean` | `true` while waiting for wallet response. | +| `isSuccess` | `boolean` | `true` after a successful operation. | +| `isError` | `boolean` | `true` after a failed operation. | +| `reset()` | `() => void` | Reset the mutation state back to idle. | + +Each hook also provides both a **callback** variant (fire-and-forget with `onSuccess`/`onError`/`onSettled` callbacks) and an **async** variant that returns a promise. + +## Error Handling + +All errors thrown by hooks extend `BaseError`. You can catch and narrow them: + +```ts +import { + BaseError, + WalletNotConnectedError, + WalletNotFoundError, + UnsupportedMethodError, + WalletRequestError, +} from '@satoshai/kit'; + +try { + await signMessageAsync({ message: 'hello' }); +} catch (err) { + if (err instanceof WalletNotConnectedError) { + // No wallet connected — prompt user to connect + } else if (err instanceof UnsupportedMethodError) { + // Wallet doesn't support this method (e.g. OKX + structured signing) + console.log(err.method, err.wallet); + } else if (err instanceof WalletNotFoundError) { + // Wallet extension not installed + console.log(err.wallet); + } else if (err instanceof WalletRequestError) { + // Wallet rejected or failed — original error in cause + console.log(err.method, err.wallet, err.cause); + } else if (err instanceof BaseError) { + // Any other kit error + console.log(err.shortMessage); + console.log(err.walk()); // root cause + } +} +``` + +| Error | When | +|-------|------| +| `WalletNotConnectedError` | A mutation hook is called before connecting. | +| `WalletNotFoundError` | A wallet's browser extension is not installed (e.g. OKX). | +| `UnsupportedMethodError` | The wallet doesn't support the requested method. | +| `WalletRequestError` | The wallet rejected or failed the RPC request. | + +## WalletConnect Session Management + +When using WalletConnect, the kit automatically handles session lifecycle events: + +- **Zombie session detection** — On app restore, the relay is pinged (10s timeout). If the wallet on the other end doesn't respond, the session is cleaned up and `onDisconnect` fires. +- **Wallet-initiated disconnect** — If the wallet disconnects via the relay, state is cleaned up automatically. +- **Account changes** — Listens for `accountsChanged`, `stx_accountChange` (SIP-030), and `stx_accountsChanged` events. When the connected account changes, `onAddressChange` fires. + +No additional setup is needed — these features activate when `wallets` includes `'wallet-connect'` and a session is active. + ## Supported Wallets All 6 wallets work with both headless (`connect('xverse')`) and modal (`connect()`) modes. @@ -328,11 +482,11 @@ All 6 wallets work with both headless (`connect('xverse')`) and modal (`connect( | `useWriteContract` | ✓ | ✓ | ✓ | ✓ | ~ | ✓ | | `useTransferSTX` | ✓ | ✓ | ✓ | ✓ | ~ | ✓ | -✓ Confirmed supported | ✗ Unsupported (throws error) | ? Unverified | ~ Depends on the connected wallet +✓ Confirmed supported | ✗ Unsupported (throws `UnsupportedMethodError`) | ? Unverified | ~ Depends on the connected wallet **Notes:** -- **OKX** uses a proprietary API (`window.okxwallet.stacks`) instead of the standard `@stacks/connect` RPC. `useSignStructuredMessage` and `useSignTransaction` are explicitly unsupported and will throw. +- **OKX** uses a proprietary API (`window.okxwallet.stacks`) instead of the standard `@stacks/connect` RPC. `useSignStructuredMessage` and `useSignTransaction` are explicitly unsupported and will throw `UnsupportedMethodError`. - **Asigna** is a multisig wallet. Transaction-based hooks (`useWriteContract`, `useTransferSTX`) work, but message signing hooks may be limited since there is no multisig message signature standard on Stacks. - **Fordefi** supports transactions and contract calls on Stacks, but their [supported blockchains](https://docs.fordefi.com/docs/supported-blockchains) page does not list Stacks under message signing capabilities. - **WalletConnect** is a relay protocol — all methods are forwarded, but actual support depends on the wallet on the other end. diff --git a/packages/kit/src/constants/wallets.ts b/packages/kit/src/constants/wallets.ts index 9a8262f..4ead9b2 100644 --- a/packages/kit/src/constants/wallets.ts +++ b/packages/kit/src/constants/wallets.ts @@ -1,3 +1,4 @@ +/** All wallet IDs supported by `@satoshai/kit`. */ export const SUPPORTED_STACKS_WALLETS = [ "xverse", "leather", @@ -7,4 +8,5 @@ export const SUPPORTED_STACKS_WALLETS = [ "okx", ] as const; +/** Union of supported wallet identifiers. */ export type SupportedStacksWallet = (typeof SUPPORTED_STACKS_WALLETS)[number]; diff --git a/packages/kit/src/errors.ts b/packages/kit/src/errors.ts index 1620a27..f40481a 100644 --- a/packages/kit/src/errors.ts +++ b/packages/kit/src/errors.ts @@ -1,8 +1,31 @@ +/** Discriminated type for narrowing caught errors to `BaseError`. */ export type BaseErrorType = BaseError & { name: 'StacksKitError' } +/** + * Base error class for all `@satoshai/kit` errors. + * + * All errors thrown by hooks extend this class, so you can catch them with + * `error instanceof BaseError` and use {@link BaseError.walk | walk()} to + * traverse the cause chain. + * + * @example + * ```ts + * import { BaseError } from '@satoshai/kit'; + * + * try { + * await signMessageAsync({ message: 'hello' }); + * } catch (err) { + * if (err instanceof BaseError) { + * console.log(err.shortMessage); // human-readable summary + * console.log(err.walk()); // root cause + * } + * } + * ``` + */ export class BaseError extends Error { override name = 'StacksKitError' + /** Short, human-readable error summary without details or cause chain. */ shortMessage: string constructor(shortMessage: string, options?: { cause?: Error; details?: string }) { @@ -15,6 +38,10 @@ export class BaseError extends Error { this.shortMessage = shortMessage } + /** + * Walk the error cause chain. If `fn` is provided, returns the first error + * where `fn` returns `true`; otherwise returns the root cause. + */ walk(fn?: (err: unknown) => boolean): unknown { return walk(this, fn) } @@ -28,10 +55,12 @@ function walk(err: unknown, fn?: (err: unknown) => boolean): unknown { return err } +/** Discriminated type for narrowing to `WalletNotConnectedError`. */ export type WalletNotConnectedErrorType = WalletNotConnectedError & { name: 'WalletNotConnectedError' } +/** Thrown when a mutation hook is called before a wallet is connected. */ export class WalletNotConnectedError extends BaseError { override name = 'WalletNotConnectedError' @@ -40,13 +69,16 @@ export class WalletNotConnectedError extends BaseError { } } +/** Discriminated type for narrowing to `WalletNotFoundError`. */ export type WalletNotFoundErrorType = WalletNotFoundError & { name: 'WalletNotFoundError' } +/** Thrown when a wallet's browser extension is not installed (e.g. OKX). */ export class WalletNotFoundError extends BaseError { override name = 'WalletNotFoundError' + /** The wallet ID that was not found. */ wallet: string constructor({ wallet }: { wallet: string }) { @@ -57,14 +89,18 @@ export class WalletNotFoundError extends BaseError { } } +/** Discriminated type for narrowing to `UnsupportedMethodError`. */ export type UnsupportedMethodErrorType = UnsupportedMethodError & { name: 'UnsupportedMethodError' } +/** Thrown when a wallet does not support the requested RPC method (e.g. OKX + `stx_signStructuredMessage`). */ export class UnsupportedMethodError extends BaseError { override name = 'UnsupportedMethodError' + /** The SIP-030 method name that is not supported. */ method: string + /** The wallet that does not support the method. */ wallet: string constructor({ method, wallet }: { method: string; wallet: string }) { @@ -74,14 +110,18 @@ export class UnsupportedMethodError extends BaseError { } } +/** Discriminated type for narrowing to `WalletRequestError`. */ export type WalletRequestErrorType = WalletRequestError & { name: 'WalletRequestError' } +/** Thrown when a wallet RPC request fails (user rejection, timeout, etc.). The original error is attached as `cause`. */ export class WalletRequestError extends BaseError { override name = 'WalletRequestError' + /** The SIP-030 method name that failed. */ method: string + /** The wallet that returned the error. */ wallet: string constructor({ method, wallet, cause }: { method: string; wallet: string; cause: Error }) { diff --git a/packages/kit/src/hooks/use-address.ts b/packages/kit/src/hooks/use-address.ts index e78f8ba..fb5faf4 100644 --- a/packages/kit/src/hooks/use-address.ts +++ b/packages/kit/src/hooks/use-address.ts @@ -21,6 +21,22 @@ type UseAddressReturn = provider: SupportedStacksWallet; }; +/** + * Read the connected wallet's address and connection status. + * + * Returns a discriminated union — when `isConnected` is `true`, `address` + * and `provider` are guaranteed to be defined (no null checks needed). + * + * @example + * ```ts + * const { address, isConnected, provider } = useAddress(); + * + * if (isConnected) { + * console.log(address); // 'SP...' — narrowed to string + * console.log(provider); // 'xverse' | 'leather' | ... + * } + * ``` + */ export const useAddress = (): UseAddressReturn => { const { address, status, provider } = useStacksWalletContext(); diff --git a/packages/kit/src/hooks/use-bns-name.ts b/packages/kit/src/hooks/use-bns-name.ts index 33660ef..060bd52 100644 --- a/packages/kit/src/hooks/use-bns-name.ts +++ b/packages/kit/src/hooks/use-bns-name.ts @@ -5,6 +5,20 @@ import { useEffect, useState } from 'react'; import { getNetworkFromAddress } from '../utils/get-network-from-address'; +/** + * Resolve a BNS v2 primary name for a Stacks address. + * + * Returns `null` when no name is registered or the address is undefined. + * Automatically detects mainnet/testnet from the address prefix. + * + * @param address - Stacks address to resolve (`SP...` or `ST...`). + * + * @example + * ```ts + * const { bnsName, isLoading } = useBnsName('SP2...'); + * // bnsName = 'satoshi.btc' | null + * ``` + */ export const useBnsName = (address?: string) => { const [bnsName, setBnsName] = useState(null); const [isLoading, setIsLoading] = useState(false); diff --git a/packages/kit/src/hooks/use-connect.ts b/packages/kit/src/hooks/use-connect.ts index 97aa39c..d7da34f 100644 --- a/packages/kit/src/hooks/use-connect.ts +++ b/packages/kit/src/hooks/use-connect.ts @@ -6,6 +6,30 @@ import type { SupportedStacksWallet } from '../constants/wallets'; import type { ConnectOptions, MutationStatus } from '../provider/stacks-wallet-provider.types'; import { useStacksWalletContext } from '../provider/stacks-wallet-provider'; +/** + * Connect to a Stacks wallet. + * + * Returns a mutation-style object with `connect`, `reset`, and status flags. + * Call `connect()` with no args to open the `@stacks/connect` wallet modal, + * or pass a specific wallet ID (e.g. `connect('xverse')`) to connect directly. + * + * @example + * ```ts + * const { connect, reset, isPending, isSuccess, error } = useConnect(); + * + * // Modal mode (default) + * await connect(); + * + * // Direct mode + * await connect('leather', { + * onSuccess: (address, provider) => console.log(address), + * onError: (err) => console.error(err), + * }); + * + * // Cancel a stuck connection (e.g. OKX popup dismissed) + * reset(); + * ``` + */ export const useConnect = () => { const { connect: contextConnect, diff --git a/packages/kit/src/hooks/use-disconnect.ts b/packages/kit/src/hooks/use-disconnect.ts index cad3558..97ebf0d 100644 --- a/packages/kit/src/hooks/use-disconnect.ts +++ b/packages/kit/src/hooks/use-disconnect.ts @@ -5,6 +5,20 @@ import { useCallback, useMemo, useState } from 'react'; import type { MutationStatus } from '../provider/stacks-wallet-provider.types'; import { useStacksWalletContext } from '../provider/stacks-wallet-provider'; +/** + * Disconnect the currently connected wallet. + * + * Clears wallet state, removes the persisted session from localStorage, + * and resets the provider context back to `disconnected`. + * + * @example + * ```ts + * const { disconnect, isSuccess } = useDisconnect(); + * + * disconnect(); + * disconnect(() => navigate('/')); + * ``` + */ export const useDisconnect = () => { const { disconnect: contextDisconnect } = useStacksWalletContext(); diff --git a/packages/kit/src/hooks/use-sign-message.ts b/packages/kit/src/hooks/use-sign-message.ts index 22d797b..b4ce76c 100644 --- a/packages/kit/src/hooks/use-sign-message.ts +++ b/packages/kit/src/hooks/use-sign-message.ts @@ -12,16 +12,23 @@ import { import type { MutationStatus } from '../provider/stacks-wallet-provider.types'; import { useAddress } from './use-address'; +/** Variables for {@link useSignMessage}. */ export interface SignMessageVariables { + /** The plaintext message to sign. */ message: string; + /** Optional public key hint for wallets that manage multiple keys. */ publicKey?: string; } +/** Successful result from {@link useSignMessage}. */ export interface SignMessageData { + /** The public key that produced the signature. */ publicKey: string; + /** The hex-encoded signature. */ signature: string; } +/** Callback options for the fire-and-forget `signMessage()` variant. */ export interface SignMessageOptions { onSuccess?: (data: SignMessageData) => void; onError?: (error: Error) => void; @@ -31,6 +38,25 @@ export interface SignMessageOptions { ) => void; } +/** + * Sign an arbitrary plaintext message with the connected wallet. + * + * Provides both a callback-style `signMessage()` and a promise-based + * `signMessageAsync()`, plus mutation status flags. + * + * @example + * ```ts + * const { signMessageAsync, isPending } = useSignMessage(); + * + * const { publicKey, signature } = await signMessageAsync({ + * message: 'Hello Stacks', + * }); + * ``` + * + * @throws {WalletNotConnectedError} If no wallet is connected. + * @throws {WalletNotFoundError} If OKX extension is not installed. + * @throws {WalletRequestError} If the wallet rejects or fails the request. + */ export const useSignMessage = () => { const { isConnected, provider } = useAddress(); const [data, setData] = useState(undefined); diff --git a/packages/kit/src/hooks/use-sign-structured-message.ts b/packages/kit/src/hooks/use-sign-structured-message.ts index c2f2886..b06337c 100644 --- a/packages/kit/src/hooks/use-sign-structured-message.ts +++ b/packages/kit/src/hooks/use-sign-structured-message.ts @@ -13,16 +13,21 @@ import { import type { MutationStatus } from '../provider/stacks-wallet-provider.types'; import { useAddress } from './use-address'; +/** Variables for {@link useSignStructuredMessage}. */ export interface SignStructuredMessageVariables { + /** The structured Clarity value to sign. */ message: ClarityValue; + /** SIP-018 domain tuple (name, version, chain-id). */ domain: TupleCV; } +/** Successful result from {@link useSignStructuredMessage}. */ export interface SignStructuredMessageData { publicKey: string; signature: string; } +/** Callback options for the fire-and-forget `signStructuredMessage()` variant. */ export interface SignStructuredMessageOptions { onSuccess?: (data: SignStructuredMessageData) => void; onError?: (error: Error) => void; @@ -32,6 +37,28 @@ export interface SignStructuredMessageOptions { ) => void; } +/** + * Sign SIP-018 structured data with the connected wallet. + * + * Structured messages include a typed domain separator and a Clarity value + * body, enabling verifiable off-chain signatures that are replay-safe. + * + * @example + * ```ts + * import { tupleCV, stringAsciiCV, uintCV } from '@stacks/transactions'; + * + * const { signStructuredMessageAsync } = useSignStructuredMessage(); + * + * const { signature } = await signStructuredMessageAsync({ + * domain: tupleCV({ name: stringAsciiCV('MyApp'), version: stringAsciiCV('1.0'), 'chain-id': uintCV(1) }), + * message: tupleCV({ action: stringAsciiCV('authorize') }), + * }); + * ``` + * + * @throws {WalletNotConnectedError} If no wallet is connected. + * @throws {UnsupportedMethodError} If the wallet does not support structured signing (OKX). + * @throws {WalletRequestError} If the wallet rejects or fails the request. + */ export const useSignStructuredMessage = () => { const { isConnected, provider } = useAddress(); const [data, setData] = useState( diff --git a/packages/kit/src/hooks/use-sign-transaction.ts b/packages/kit/src/hooks/use-sign-transaction.ts index 748e38e..0addacf 100644 --- a/packages/kit/src/hooks/use-sign-transaction.ts +++ b/packages/kit/src/hooks/use-sign-transaction.ts @@ -12,16 +12,23 @@ import { import type { MutationStatus } from '../provider/stacks-wallet-provider.types'; import { useAddress } from './use-address'; +/** Variables for {@link useSignTransaction}. */ export interface SignTransactionVariables { + /** Hex-encoded serialized transaction to sign. */ transaction: string; + /** Whether to broadcast the signed transaction. Defaults to the wallet's behavior. */ broadcast?: boolean; } +/** Successful result from {@link useSignTransaction}. */ export interface SignTransactionData { + /** The signed, hex-encoded transaction. */ transaction: string; + /** Transaction ID, present when the wallet broadcasts it. */ txid?: string; } +/** Callback options for the fire-and-forget `signTransaction()` variant. */ export interface SignTransactionOptions { onSuccess?: (data: SignTransactionData) => void; onError?: (error: Error) => void; @@ -31,6 +38,26 @@ export interface SignTransactionOptions { ) => void; } +/** + * Sign a serialized Stacks transaction without automatic broadcast. + * + * Useful for sponsored transaction flows where a separate service pays the + * fee and broadcasts the transaction. + * + * @example + * ```ts + * const { signTransactionAsync } = useSignTransaction(); + * + * const { transaction } = await signTransactionAsync({ + * transaction: '0x0100...', + * broadcast: false, + * }); + * ``` + * + * @throws {WalletNotConnectedError} If no wallet is connected. + * @throws {UnsupportedMethodError} If the wallet does not support raw signing (OKX). + * @throws {WalletRequestError} If the wallet rejects or fails the request. + */ export const useSignTransaction = () => { const { isConnected, provider } = useAddress(); const [data, setData] = useState(undefined); diff --git a/packages/kit/src/hooks/use-transfer-stx.ts b/packages/kit/src/hooks/use-transfer-stx.ts index 148d573..92204e6 100644 --- a/packages/kit/src/hooks/use-transfer-stx.ts +++ b/packages/kit/src/hooks/use-transfer-stx.ts @@ -13,20 +13,48 @@ import type { MutationStatus } from '../provider/stacks-wallet-provider.types'; import { useAddress } from './use-address'; import { getNetworkFromAddress } from '../utils/get-network-from-address'; +/** Variables for {@link useTransferSTX}. */ export interface TransferSTXVariables { + /** Recipient Stacks address (`SP...` or `ST...`). */ recipient: string; + /** Amount in microSTX. Accepts `bigint`, `number`, or numeric `string`. */ amount: bigint | number | string; + /** Optional memo string attached to the transfer. */ memo?: string; + /** Custom fee in microSTX. Omit to let the wallet estimate. */ fee?: bigint | number | string; + /** Custom nonce. Omit to let the wallet manage. */ nonce?: bigint | number | string; } +/** Callback options for the fire-and-forget `transferSTX()` variant. */ export interface TransferSTXOptions { onSuccess?: (txid: string) => void; onError?: (error: Error) => void; onSettled?: (txid: string | undefined, error: Error | null) => void; } +/** + * Transfer native STX tokens to a recipient address. + * + * Returns the broadcast transaction ID on success. Supports all 6 wallets + * (OKX uses its proprietary API internally). + * + * @example + * ```ts + * const { transferSTXAsync, isPending } = useTransferSTX(); + * + * const txid = await transferSTXAsync({ + * recipient: 'SP2...', + * amount: 1_000_000n, // 1 STX + * memo: 'coffee', + * }); + * ``` + * + * @throws {WalletNotConnectedError} If no wallet is connected. + * @throws {WalletNotFoundError} If OKX extension is not installed. + * @throws {WalletRequestError} If the wallet rejects or fails the request. + */ export const useTransferSTX = () => { const { isConnected, address, provider } = useAddress(); const [data, setData] = useState(undefined); diff --git a/packages/kit/src/hooks/use-wallets.ts b/packages/kit/src/hooks/use-wallets.ts index 87e5d2f..79aa229 100644 --- a/packages/kit/src/hooks/use-wallets.ts +++ b/packages/kit/src/hooks/use-wallets.ts @@ -4,6 +4,24 @@ import { useMemo } from 'react'; import { useStacksWalletContext } from '../provider/stacks-wallet-provider'; +/** + * List all configured wallets with availability status. + * + * Each wallet includes its `id`, display `name`, `icon` (data URI), `webUrl` + * (install link), and whether it's `available` (extension detected or + * WalletConnect configured). + * + * @example + * ```ts + * const { wallets } = useWallets(); + * + * wallets.map(({ id, name, icon, available }) => ( + * + * )); + * ``` + */ export const useWallets = () => { const { wallets } = useStacksWalletContext(); diff --git a/packages/kit/src/hooks/use-write-contract/use-write-contract.ts b/packages/kit/src/hooks/use-write-contract/use-write-contract.ts index cf6478f..a15d446 100644 --- a/packages/kit/src/hooks/use-write-contract/use-write-contract.ts +++ b/packages/kit/src/hooks/use-write-contract/use-write-contract.ts @@ -67,6 +67,46 @@ function resolveArgs(variables: WriteContractVariablesInternal): ClarityValue[] ); } +/** + * Call a public function on a Clarity smart contract. + * + * Supports two modes: + * - **Typed** (with ABI) — pass an ABI object for autocomplete on `functionName` and `args`. + * - **Untyped** — pass `ClarityValue[]` directly as `args`. + * + * @example + * ```ts + * import { Pc, PostConditionMode } from '@stacks/transactions'; + * + * const { writeContractAsync } = useWriteContract(); + * + * // Untyped mode + * const txid = await writeContractAsync({ + * address: 'SP...', + * contract: 'my-contract', + * functionName: 'transfer', + * args: [uintCV(100)], + * pc: { + * postConditions: [Pc.principal('SP...').willSendLte(100n).ustx()], + * mode: PostConditionMode.Deny, + * }, + * }); + * + * // Typed mode (with ABI — enables autocomplete) + * const txid = await writeContractAsync({ + * abi: myContractAbi, + * address: 'SP...', + * contract: 'my-contract', + * functionName: 'transfer', // autocompleted from ABI + * args: { amount: 100n }, // named args, type-checked + * pc: { postConditions: [], mode: PostConditionMode.Deny }, + * }); + * ``` + * + * @throws {WalletNotConnectedError} If no wallet is connected. + * @throws {WalletNotFoundError} If OKX extension is not installed. + * @throws {WalletRequestError} If the wallet rejects or fails the request. + */ export const useWriteContract = () => { const { isConnected, address, provider } = useAddress(); diff --git a/packages/kit/src/hooks/use-write-contract/use-write-contract.types.ts b/packages/kit/src/hooks/use-write-contract/use-write-contract.types.ts index b69c6f9..92ed448 100644 --- a/packages/kit/src/hooks/use-write-contract/use-write-contract.types.ts +++ b/packages/kit/src/hooks/use-write-contract/use-write-contract.types.ts @@ -10,8 +10,11 @@ import type { import type { PublicFunctionArgs } from '../../types/abi'; +/** Post-condition configuration for contract calls and STX transfers. */ export interface PostConditionConfig { + /** Array of post-conditions that must be satisfied for the transaction to succeed. */ postConditions: PostCondition[]; + /** Whether to allow or deny any asset transfers not covered by `postConditions`. */ mode: PostConditionMode; } @@ -40,6 +43,7 @@ export interface UntypedWriteContractVariables { /** Backward-compatible alias for the untyped variant. */ export type WriteContractVariables = UntypedWriteContractVariables; +/** Callback options for the fire-and-forget `writeContract()` variant. */ export interface WriteContractOptions { onSuccess?: (txHash: string) => void; onError?: (error: Error) => void; diff --git a/packages/kit/src/provider/stacks-wallet-provider.tsx b/packages/kit/src/provider/stacks-wallet-provider.tsx index e9ffc2a..60bd9dd 100644 --- a/packages/kit/src/provider/stacks-wallet-provider.tsx +++ b/packages/kit/src/provider/stacks-wallet-provider.tsx @@ -64,6 +64,29 @@ const StacksWalletContext = createContext( undefined ); +/** + * React context provider that manages wallet connection state for all `@satoshai/kit` hooks. + * + * Wrap your app (or the subtree that needs wallet access) with this provider. + * All hooks (`useConnect`, `useAddress`, `useWriteContract`, etc.) must be + * rendered inside this provider. + * + * @example + * ```tsx + * import { StacksWalletProvider } from '@satoshai/kit'; + * + * const wallets = ['xverse', 'leather', 'wallet-connect'] as const; + * const wc = { projectId: 'YOUR_PROJECT_ID' }; + * + * function App() { + * return ( + * + * + * + * ); + * } + * ``` + */ export const StacksWalletProvider = ({ children, wallets, diff --git a/packages/kit/src/provider/stacks-wallet-provider.types.ts b/packages/kit/src/provider/stacks-wallet-provider.types.ts index 9150469..6eb164e 100644 --- a/packages/kit/src/provider/stacks-wallet-provider.types.ts +++ b/packages/kit/src/provider/stacks-wallet-provider.types.ts @@ -1,9 +1,12 @@ import type { SupportedStacksWallet } from '../constants/wallets'; +/** Stacks network identifier. */ export type StacksChain = 'mainnet' | 'testnet'; +/** Status of a mutation hook (`useConnect`, `useSignMessage`, etc.). */ export type MutationStatus = 'idle' | 'pending' | 'error' | 'success'; +/** Metadata passed to the WalletConnect relay for dApp identification. */ export interface WalletConnectMetadata { name: string; description: string; @@ -11,26 +14,54 @@ export interface WalletConnectMetadata { icons: string[]; } +/** Optional callbacks for {@link useConnect}. */ export interface ConnectOptions { + /** Called with the connected address and wallet ID on success. */ onSuccess?: (address: string, provider: SupportedStacksWallet) => void; + /** Called when the connection fails or is rejected. */ onError?: (error: Error) => void; } +/** Props for the {@link StacksWalletProvider} component. */ export interface StacksWalletProviderProps { children: React.ReactNode; + /** + * Wallets to enable. Defaults to all supported wallets. + * + * **Tip:** Define this array outside your component or memoize it to keep + * the reference stable across renders. + */ wallets?: SupportedStacksWallet[]; /** Show @stacks/connect's built-in wallet selection modal when `connect()` is called without a `providerId`. Defaults to `true`. Set to `false` to manage wallet selection yourself (headless). */ connectModal?: boolean; + /** + * WalletConnect configuration. Required when `wallets` includes `'wallet-connect'`. + * + * **Tip:** Define this object outside your component or memoize it to keep + * the reference stable across renders. + */ walletConnect?: { + /** WalletConnect Cloud project ID from https://cloud.walletconnect.com. */ projectId: string; + /** Override default dApp metadata shown in the wallet. */ metadata?: Partial; + /** Stacks chains to request. Defaults to `['mainnet']`. */ chains?: StacksChain[]; }; + /** Called after a wallet successfully connects. */ onConnect?: (provider: SupportedStacksWallet, address: string) => void; + /** Called when the connected account changes (e.g. Xverse account switch, WalletConnect account change). */ onAddressChange?: (newAddress: string) => void; + /** Called when the wallet disconnects (user-initiated or session expiry). */ onDisconnect?: () => void; } +/** + * Discriminated union representing the wallet connection state. + * + * When `status` is `'connected'`, `address` and `provider` are guaranteed + * to be defined. + */ export type WalletState = | { status: 'disconnected'; @@ -48,20 +79,31 @@ export type WalletState = provider: SupportedStacksWallet; }; +/** Metadata for a single wallet returned by {@link useWallets}. */ export interface WalletInfo { + /** Wallet identifier used with `connect(id)`. */ id: SupportedStacksWallet; + /** Human-readable wallet name. */ name: string; + /** Wallet icon as a data URI. */ icon: string; + /** URL to download/install the wallet extension. */ webUrl: string; + /** `true` when the wallet extension is detected or WalletConnect is configured. */ available: boolean; } +/** Full context value exposed by `StacksWalletProvider`. Extends {@link WalletState} with actions and wallet list. */ export type WalletContextValue = WalletState & { + /** Connect to a wallet. Pass a wallet ID to bypass the modal, or call with no args to show the `@stacks/connect` modal. */ connect: ( providerId?: SupportedStacksWallet, options?: ConnectOptions ) => Promise; + /** Disconnect the current wallet and clear persisted state. */ disconnect: (callback?: () => void) => void; + /** Reset a stuck connecting state back to idle. */ reset: () => void; + /** All configured wallets with availability status. */ wallets: WalletInfo[]; }; diff --git a/packages/kit/src/utils/create-contract-config.ts b/packages/kit/src/utils/create-contract-config.ts index a4fcdfd..315afe3 100644 --- a/packages/kit/src/utils/create-contract-config.ts +++ b/packages/kit/src/utils/create-contract-config.ts @@ -1,6 +1,23 @@ import type { ClarityAbi } from 'clarity-abitype'; -/** Pre-bind ABI + address + contract for reuse with useWriteContract. */ +/** + * Pre-bind ABI, address, and contract name for reuse with `useWriteContract`. + * + * Returns the config object as-is but preserves the `const` ABI type, enabling + * autocomplete on `functionName` and type-checked `args` when spread into + * `writeContract()`. + * + * @example + * ```ts + * const pool = createContractConfig({ + * abi: poolAbi, + * address: 'SP...', + * contract: 'pool-v1', + * }); + * + * writeContract({ ...pool, functionName: 'deposit', args: { amount: 100n }, pc }); + * ``` + */ export function createContractConfig(config: { abi: TAbi; address: string; diff --git a/packages/kit/src/utils/get-local-storage-wallet.ts b/packages/kit/src/utils/get-local-storage-wallet.ts index ce335e0..2a1c86e 100644 --- a/packages/kit/src/utils/get-local-storage-wallet.ts +++ b/packages/kit/src/utils/get-local-storage-wallet.ts @@ -2,6 +2,12 @@ import { LOCAL_STORAGE_STACKS } from '../constants/storage-keys'; import type { SupportedStacksWallet } from '../constants/wallets'; import { SUPPORTED_STACKS_WALLETS } from '../constants/wallets'; +/** + * Read the persisted wallet session from localStorage. + * + * Returns `null` on the server, when no session is stored, or when the + * stored provider is not in the supported wallets list. + */ export const getLocalStorageWallet = (): { address: string; provider: SupportedStacksWallet; diff --git a/packages/kit/src/utils/get-network-from-address.ts b/packages/kit/src/utils/get-network-from-address.ts index 2275e9f..9bed69f 100644 --- a/packages/kit/src/utils/get-network-from-address.ts +++ b/packages/kit/src/utils/get-network-from-address.ts @@ -1,3 +1,10 @@ +/** + * Infer the Stacks network from an address prefix. + * + * @param address - A Stacks address starting with `SP`/`SM` (mainnet) or `ST`/`SN` (testnet). + * @returns `'mainnet'` or `'testnet'`. + * @throws If the address doesn't start with a known prefix. + */ export const getNetworkFromAddress = (address: string) => { if (address.startsWith('SP') || address.startsWith('SM')) { return 'mainnet'; diff --git a/packages/kit/src/utils/get-stacks-wallets.ts b/packages/kit/src/utils/get-stacks-wallets.ts index 5283070..946ae48 100644 --- a/packages/kit/src/utils/get-stacks-wallets.ts +++ b/packages/kit/src/utils/get-stacks-wallets.ts @@ -3,11 +3,20 @@ import { type SupportedStacksWallet, } from '../constants/wallets'; +/** Result of {@link getStacksWallets}. */ export interface StacksWallets { + /** All wallets supported by `@satoshai/kit`. */ supported: SupportedStacksWallet[]; + /** Subset of `supported` whose browser extension is currently detected. */ installed: SupportedStacksWallet[]; } +/** + * Detect which Stacks wallets are supported and installed. + * + * Safe to call on the server — `installed` will contain only `'wallet-connect'` + * when `window` is undefined. + */ export const getStacksWallets = (): StacksWallets => { const supported = [...SUPPORTED_STACKS_WALLETS]; const installed = supported.filter((wallet) => From 0e2da93f1f8c4ed82b873d5cda0b59464e4fc830 Mon Sep 17 00:00:00 2001 From: satoshai-dev <262845409+satoshai-dev@users.noreply.github.com> Date: Fri, 6 Mar 2026 16:49:01 +0000 Subject: [PATCH 2/3] docs: add link to @satoshai/abi-cli in typed useWriteContract section Co-Authored-By: Claude Opus 4.6 --- README.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 4936e0a..f846ac5 100644 --- a/README.md +++ b/README.md @@ -294,13 +294,13 @@ writeContract({ #### Typed mode (with ABI — autocomplete + type-checked args) -When you pass an `abi` object, `functionName` is autocompleted from the ABI's public functions and `args` becomes a named, type-checked object. +When you pass an `abi` object, `functionName` is autocompleted from the ABI's public functions and `args` becomes a named, type-checked object. Use [`@satoshai/abi-cli`](https://github.com/satoshai-dev/abi-cli) to generate typed ABIs from deployed contracts. ```ts import { PostConditionMode } from '@stacks/transactions'; import type { ClarityAbi } from '@satoshai/kit'; -// 1. Define your ABI (use @satoshai/abi-cli to generate it) +// 1. Define your ABI (use @satoshai/abi-cli to generate it — https://github.com/satoshai-dev/abi-cli) const poolAbi = { functions: [...], ... } as const satisfies ClarityAbi; // 2. Call with full type safety From e17734d7ab721a1e8760d5ccabacef129c2d33f1 Mon Sep 17 00:00:00 2001 From: satoshai-dev <262845409+satoshai-dev@users.noreply.github.com> Date: Fri, 6 Mar 2026 16:59:38 +0000 Subject: [PATCH 3/3] docs: correct SIP-030 compliance claim for Xverse and Leather Neither wallet fully implements SIP-030. Xverse uses a proprietary addListener('accountChange') API, and Leather does not emit account change events at all. Co-Authored-By: Claude Opus 4.6 --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index f846ac5..71e300a 100644 --- a/README.md +++ b/README.md @@ -490,7 +490,7 @@ All 6 wallets work with both headless (`connect('xverse')`) and modal (`connect( - **Asigna** is a multisig wallet. Transaction-based hooks (`useWriteContract`, `useTransferSTX`) work, but message signing hooks may be limited since there is no multisig message signature standard on Stacks. - **Fordefi** supports transactions and contract calls on Stacks, but their [supported blockchains](https://docs.fordefi.com/docs/supported-blockchains) page does not list Stacks under message signing capabilities. - **WalletConnect** is a relay protocol — all methods are forwarded, but actual support depends on the wallet on the other end. -- **Xverse** and **Leather** implement the full [SIP-030](https://github.com/janniks/sips/blob/main/sips/sip-030/sip-030-wallet-interface.md) interface. +- **Xverse** and **Leather** support all hooks provided by `@satoshai/kit`. Neither fully implements [SIP-030](https://github.com/janniks/sips/blob/main/sips/sip-030/sip-030-wallet-interface.md) — for example, account change detection uses Xverse's proprietary `XverseProviders.StacksProvider.addListener('accountChange')` API, and Leather does not emit account change events at all. This matrix was compiled from wallet documentation as of March 2026. Sources: [Xverse Sats Connect docs](https://docs.xverse.app/sats-connect/stacks-methods), [Leather developer docs](https://leather.gitbook.io/developers), [Asigna docs](https://asigna.gitbook.io/asigna), [Fordefi docs](https://docs.fordefi.com/docs/supported-blockchains), [@stacks/connect WalletConnect source](https://github.com/stx-labs/connect/tree/main/packages/connect/src/walletconnect).