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..71e300a 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. 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 — https://github.com/satoshai-dev/abi-cli) +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,15 +482,15 @@ 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. -- **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). 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) =>