@satoshai/kit
v1.0.0
Published
Typesafe Stacks wallet & contract interaction library for React
Maintainers
Readme
@satoshai/kit
Typesafe Stacks wallet & contract interaction library for React. Wagmi-inspired hook API for connecting wallets, signing messages, and calling contracts on the Stacks blockchain.
Features
StacksWalletProvider— React context provider for wallet stateuseConnect/useDisconnect— Connect and disconnect walletsuseWallets— Configured wallets with availability statususeAddress— Access connected wallet address and status (discriminated union)useSignMessage— Sign arbitrary messagesuseSignStructuredMessage— Sign SIP-018 structured datauseSignTransaction— Sign serialized transactions (sponsored tx flows)useWriteContract— Call smart contracts with post-conditions (typed or untyped)useTransferSTX— Native STX transfersuseBnsName— 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
pnpm add @satoshai/kit @stacks/transactions react react-domQuick Start
import { StacksWalletProvider, useConnect, useAddress, useDisconnect } from '@satoshai/kit';
function App() {
return (
<StacksWalletProvider>
<Wallet />
</StacksWalletProvider>
);
}
function Wallet() {
const { connect, reset, isPending } = useConnect();
const { address, isConnected } = useAddress();
const { disconnect } = useDisconnect();
if (isConnected) {
return (
<div>
<p>Connected: {address}</p>
<button onClick={() => disconnect()}>Disconnect</button>
</div>
);
}
return (
<div>
{isPending && <button onClick={reset}>Cancel</button>}
<button onClick={() => connect()} disabled={isPending}>Connect Wallet</button>
</div>
);
}API
<StacksWalletProvider>
Wrap your app to provide wallet context to all hooks.
<StacksWalletProvider
wallets={['xverse', 'leather', 'wallet-connect']} // optional — defaults to all supported
connectModal={true} // optional — defaults to true
walletConnect={{ projectId: '...' }} // optional — enables WalletConnect
onConnect={(provider, address) => {}} // optional
onAddressChange={(newAddress) => {}} // optional — Xverse/WalletConnect account switching
onDisconnect={() => {}} // optional
>
{children}
</StacksWalletProvider>| 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
walletsincludes'wallet-connect', you must providewalletConnect.projectIdor the provider will throw at mount.
Important: Define
walletsandwalletConnectoutside of your component (or memoize them) so they remain referentially stable across renders. These values are treated as static configuration.
connectModal (default: true)
Controls whether connect() with no arguments shows @stacks/connect's built-in wallet selection modal.
// Default — modal handles wallet selection
<StacksWalletProvider>
<App /> {/* connect() opens the modal */}
</StacksWalletProvider>
// Headless — manage wallet selection yourself (wagmi-style)
<StacksWalletProvider connectModal={false}>
<App /> {/* connect('xverse') only, connect() with no args is a no-op */}
</StacksWalletProvider>When connectModal is enabled:
connect()with no args opens the@stacks/connectmodalconnect('xverse')with an explicit provider still bypasses the modal- The
walletsprop controls which wallets appear in the modal - All 6 wallets are supported in the modal
- After the user picks a wallet, the kit automatically maps it back and sets state
useConnect()
Connect to a Stacks wallet. Returns a mutation-style object.
const { connect, reset, error, isPending, isSuccess, isError, isIdle, status } = useConnect();
// Open the @stacks/connect modal (when connectModal is enabled, the default)
await connect();
// Or connect to a specific wallet directly
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 name, icon, download link, and availability status. Metadata is sourced from @stacks/connect.
const { wallets } = useWallets();
// [{ id: 'xverse', name: 'Xverse Wallet', icon: 'data:image/svg+xml;...', webUrl: 'https://xverse.app', available: true }, ...]
{wallets.map(({ id, name, icon, webUrl, available }) => (
<div key={id}>
<button onClick={() => connect(id)} disabled={!available}>
{icon && <img src={icon} alt={name} width={20} height={20} />}
{name}
</button>
{!available && webUrl && <a href={webUrl} target="_blank">Install</a>}
</div>
))}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()
Disconnect the current wallet and clear the persisted session.
const { disconnect, reset, error, isSuccess, isError, isIdle, isPending, status } = useDisconnect();
disconnect();
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).
const { address, isConnected, isConnecting, isDisconnected, provider } = useAddress();
if (isConnected) {
console.log(address); // 'SP...' or 'ST...' — narrowed to string
console.log(provider); // 'xverse' | 'leather' | ...
}useSignMessage()
Sign an arbitrary plaintext message.
const { signMessage, signMessageAsync, data, error, isPending, reset } = useSignMessage();
// Callback style
signMessage({ message: 'Hello Stacks' }, {
onSuccess: ({ publicKey, signature }) => {},
onError: (error) => {},
onSettled: (data, error) => {},
});
// Async style
const { publicKey, signature } = await signMessageAsync({ message: 'Hello Stacks' });useSignStructuredMessage()
Sign SIP-018 structured data for typed, verifiable off-chain messages.
Note: OKX wallet does not support structured message signing and will throw an
UnsupportedMethodError.
import { tupleCV, stringAsciiCV, uintCV } from '@stacks/transactions';
const { signStructuredMessage, signStructuredMessageAsync, data, error, isPending } = useSignStructuredMessage();
// Callback style
signStructuredMessage({
domain: tupleCV({
name: stringAsciiCV('MyApp'),
version: stringAsciiCV('1.0'),
'chain-id': uintCV(1),
}),
message: tupleCV({
action: stringAsciiCV('authorize'),
amount: uintCV(1000),
}),
}, {
onSuccess: ({ publicKey, signature }) => {},
onError: (error) => {},
});
// Async style
const { publicKey, signature } = await signStructuredMessageAsync({
domain: tupleCV({ ... }),
message: tupleCV({ ... }),
});useTransferSTX()
Transfer native STX tokens. Amount is in microSTX (1 STX = 1,000,000 microSTX).
const { transferSTX, transferSTXAsync, data, error, isPending, reset } = useTransferSTX();
// Callback style
transferSTX({
recipient: 'SP2...',
amount: 1000000n, // 1 STX
memo: 'optional memo',
fee: 2000n, // optional custom fee
nonce: 42n, // optional custom nonce
}, {
onSuccess: (txid) => {},
onError: (error) => {},
});
// Async style
const txid = await transferSTXAsync({
recipient: 'SP2...',
amount: 1000000n,
});useWriteContract()
Call a public function on a Clarity smart contract. Supports two modes:
Untyped mode (ClarityValue[] args)
import { uintCV, Pc, PostConditionMode } from '@stacks/transactions';
const { writeContract, writeContractAsync, data, error, isPending, reset } = useWriteContract();
writeContract({
address: 'SP...',
contract: 'my-contract',
functionName: 'my-function',
args: [uintCV(100)],
pc: {
postConditions: [Pc.principal('SP...').willSendLte(100n).ustx()],
mode: PostConditionMode.Deny,
},
}, {
onSuccess: (txHash) => {},
onError: (error) => {},
});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 to generate typed ABIs from deployed contracts.
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:
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
UnsupportedMethodError.
const { signTransaction, signTransactionAsync, data, error, isPending, reset } = useSignTransaction();
// Callback style
signTransaction({ transaction: '0x0100...', broadcast: false }, {
onSuccess: ({ transaction, txid }) => {},
onError: (error) => {},
});
// Async style
const { transaction, txid } = await signTransactionAsync({
transaction: '0x0100...',
broadcast: false,
});useBnsName()
Resolve a BNS v2 primary name for a Stacks address. Returns null when no name is registered.
const { bnsName, isLoading } = useBnsName(address);
// bnsName = 'satoshi.btc' | nullUtilities
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' } | nullMutation 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:
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
onDisconnectfires. - Wallet-initiated disconnect — If the wallet disconnects via the relay, state is cleaned up automatically.
- Account changes — Listens for
accountsChanged,stx_accountChange(SIP-030), andstx_accountsChangedevents. When the connected account changes,onAddressChangefires.
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.
| Wallet | ID |
|---|---|
| Xverse | xverse |
| Leather | leather |
| Asigna | asigna |
| Fordefi | fordefi |
| WalletConnect | wallet-connect |
| OKX | okx |
Wallet Support Matrix
| Hook | Xverse | Leather | Asigna | Fordefi | WalletConnect | OKX |
|------|--------|---------|--------|---------|---------------|-----|
| useConnect | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ |
| useSignMessage | ✓ | ✓ | ? | ? | ~ | ✓ |
| useSignStructuredMessage | ✓ | ✓ | ? | ? | ~ | ✗ |
| useSignTransaction | ✓ | ✓ | ? | ? | ~ | ✗ |
| useWriteContract | ✓ | ✓ | ✓ | ✓ | ~ | ✓ |
| useTransferSTX | ✓ | ✓ | ✓ | ✓ | ~ | ✓ |
✓ 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/connectRPC.useSignStructuredMessageanduseSignTransactionare explicitly unsupported and will throwUnsupportedMethodError. - 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 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 support all hooks provided by
@satoshai/kit. Neither fully implements SIP-030 — for example, account change detection uses Xverse's proprietaryXverseProviders.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, Leather developer docs, Asigna docs, Fordefi docs, @stacks/connect WalletConnect source.
Peer Dependencies
react^18 or ^19react-dom^18 or ^19@stacks/transactions>=7.0.0
License
MIT
