butr
API reference

@usebutr/core

The wallet manager, the adapter types, chain registries, persistence, and the discovery seam. No React, no protocols.

Wallet manager

createWalletManager(config?, options?): WalletManager

import { createWalletManager } from "@usebutr/core";
import { autoDiscovery } from "@usebutr/wallets";

const manager = createWalletManager({ sources: [autoDiscovery()] });
const stop = manager.start();

manager.subscribe((state) => {
  renderPicker(state.adapters);
});

const wallet = await manager.connect("io.metamask");

Framework-free: @usebutr/react's provider creates one per mount and calls start() in an effect. Creating a manager has no side effects, so it is safe during a server render. options.initialState (a WalletSnapshot) seeds it synchronously, as the provider's initialState prop does.

WalletManager

A read-only zustand store plus actions. Every action is a stable reference.

MemberNotes
getState()The current WalletState, without subscribing.
subscribe(listener)(state, prev) => void; returns the unsubscribe.
getInitialState()The state the manager was created with.
start()Subscribes the sources, hydrates persisted connections once, bridges wallet events. The returned function undoes all but hydration.
connect(connectorId)Promise<ConnectedWallet>; rejects with a ConnectionError.
disconnect(connectorId)Tears the wallet down and forgets it.
disconnectAll()Disconnects every wallet and forgets every persisted connection.
requestAccounts(connectorId)Promise<void>. Opens the wallet's account picker, then refreshes the pool entry. A no-op when the adapter has no requestAccounts.
setAccount(connectorId, account)Selects an exposed account without changing any account's chain. Unknown accounts are ignored.
setActive(connectorId)Makes a pool entry the active wallet.
setSelection(chainPlatform, connectorId)Picks which pool entry serves a platform.
clearConnectionError()Clears connectionError and returns connectionStatus to "idle".

WalletManagerConfig

Read once, when the manager is created. Define it at module scope.

FieldTypeNotes
sourcesReadonlyArray<WalletSource>autoDiscovery(), any discover*Adapters export, or fromAdapters(…). The first adapter announced for an id wins.
storageWalletPersistenceReplaces the default localStorage + sessionStorage persistence.
storageKeyPrefixstringKey prefix for the default persistence. Ignored when storage is set.
onConnect(wallet, { reconnected }) => voidA user connect (reconnected: false) or a silent restore (true).
onConnectError(error: ConnectionError, connectorId) => voidEvery failed attempt.
onDisconnect(wallet, { byUser }) => voidbyUser is false when the wallet ended the session.
onHydrated(outcome: HydrationOutcome) => voidOnce, after the start-up hydration pass.
onSlowConnect(connectorId) => voidAt most once per attempt, after slowConnectThresholdMs.
slowConnectThresholdMsnumberDefault 5000.
onStorageError(error: Error) => voidA failed persistence read or write. Defaults to console.warn.

WalletState

FieldTypeNotes
adaptersReadonlyArray<WalletAdapter>Every adapter the sources announced, in announcement order.
poolReadonlyMap<string, ConnectedWallet>Live connections, keyed by connector id.
selectionReadonlyMap<ChainPlatform, string>Which pool entry serves each platform present in the pool.
activeConnectorIdstring | nullIn the pool, or null when the pool is empty.
reconnectingIdsReadonlySet<string>Pool entries still backed by a shadow adapter seeded from initialState.
dormantReadonlyMap<string, StoredPoolEntry>Persisted connections that are not live: awaiting their adapter, failed their silent reconnect, or ended by the wallet.
connectionStatusConnectStatus"idle" | "connecting" | "success" | "error": the latest connect attempt.
connectingConnectorIdstring | nullThe wallet the in-flight attempt is for.
connectionErrorConnectionError | nullThe latest attempt's failure.
isHydratedbooleanTrue after the start-up hydration pass, and from the start when seeded.
isUserDisconnectedbooleanSession-scoped: set by a user disconnect, cleared by the next connect.

HydrationOutcome

type HydrationOutcome = {
  dropped: ReadonlyArray<{ connectorId: string; reason: ConnectionError }>;
  pendingIds: ReadonlyArray<string>;
  restoredIds: ReadonlyArray<string>;
};

pendingIds are not failures: their adapters had not been announced yet, and the manager restores them the moment they are.

Sources

WalletSource

type WalletSource = (onAdapter: (adapter: WalletAdapter) => void) => () => void;

A function that announces adapters and returns its unsubscribe. Every discover*Adapters export already has this shape, so it goes into sources as-is.

fromAdapters(adapters): WalletSource

Takes an adapter, an iterable of adapters, or a promise of either, for adapters that are built rather than discovered: WalletConnect, Ledger, hand-rolled ones. They then appear in useDiscoveredWallets() like any discovered wallet. A rejected promise is logged and contributes nothing, so give each factory its own fromAdapters.

import { fromAdapters } from "@usebutr/core";
import { createLedgerAdapter } from "@usebutr/ledger";
import { createWalletConnectAdapters } from "@usebutr/walletconnect";

const sources = [
  fromAdapters(createWalletConnectAdapters({ namespaces: { evm: [] }, projectId })),
  fromAdapters(createLedgerAdapter({ platform: "evm" })),
];

PlatformDiscoverer

{ subscribe: WalletSource; fallback?: { subscribe(onAdapter, { hasAnyPrimaryAdapter }) } }. Each platform package exports one; @usebutr/wallets composes them.

Adapters

Connector<P>

What butr itself calls. Every adapter has chainPlatform, id, name, connect(options?: { silent?: boolean }) and getAccounts(), plus icon?. disconnect, requestAccounts and subscribe are optional. getAccounts() resolves the accounts the wallet exposes, active first; an empty list means not connected.

WalletBase<Tx>

The wallet half. getSigner() is always there; everything else exists only when it works for that wallet, so presence is the capability check:

type WalletBase<Tx> = {
  getBalance?: (options?: BalanceOptions) => Promise<Balance>;
  getSigner: () => Promise<WalletSigner>;
  getTransactionReceipt?: (hash: string) => Promise<TransactionReceipt>;
  sendTx?: (tx: Tx, options?: TransactionOptions) => Promise<string>;
  signMessage?: (message: Uint8Array, options?: AccountOptions) => Promise<SignedMessage>;
  switchChain?: (chain: ChainBase) => Promise<void>;
};
PlatformWallet typeAdapter typeTransaction inputAdditions
evmEvmWalletEvmAdapterEvmTransactionRequestnone
svmSvmWalletSvmAdapterUint8ArraysignIn?(input?), signTransaction?(tx, options?) resolving the signed bytes
suiSuiWalletSuiAdapterSuiTransactionInputsignTransaction?(tx, options?) resolving { bytes, signature }
bitcoinBitcoinWalletBitcoinAdapterBitcoinTransfersignTransaction?(psbt, options?) resolving signed PSBT bytes
polkadotPolkadotWalletPolkadotAdapternone: no sendTxnone
  • WalletAdapter is the union of the five adapter types. Narrow on chainPlatform to reach a platform's own methods and transaction type.
  • WalletAdapterFor<P> picks one: WalletAdapterFor<"svm"> is SvmAdapter.
  • EvmTransactionRequest is an eth_sendTransaction object whose quantities may be bigint; EvmTransactionValue is its value type.
  • SuiTransactionInput is a @mysten/sui Transaction (anything with toJSON()), its JSON string, or BCS bytes.
  • BitcoinTransfer is { amount: bigint; recipient: string }, in satoshis.

Options and results

type AccountOptions = { account?: Account };
type TransactionOptions = AccountOptions & { chain?: ChainBase };
type BalanceOptions = AccountOptions & { token?: string };

type SignedMessage = { signature: Uint8Array; signedMessage: Uint8Array };
type SignInOutput = { account: Account; signature: Uint8Array; signedMessage: Uint8Array };
type TransactionReceipt = { status: "Error" | "Pending" | "Success" };
type Balance = { decimals: number; formatted: string; symbol: string; value: bigint };

An account the wallet does not expose rejects; omit it to use the active account. A chain targets one transaction: Wallet Standard and WalletConnect route it per call, an EVM wallet switches its network first, and a transport that can do neither rejects when it is not the current chain. SignInInput and SignInValue type the Sign In With Solana fields.

ConnectedWallet<P>

type ConnectedWallet<P extends ChainPlatform = ChainPlatform> = {
  account: Account; // the active account, always one of `accounts`
  accounts: ReadonlyArray<Account>;
  connector: WalletAdapterFor<P>;
};

isPlatformWallet(wallet, platform)

Type guard that narrows a pool entry to ConnectedWallet<P>, e.g. before calling its sendTx. The reverse needs nothing: a ConnectedWallet<"evm"> is already a ConnectedWallet.

import { isPlatformWallet } from "@usebutr/core";

if (isPlatformWallet(wallet, "svm") && wallet.connector.sendTx) {
  await wallet.connector.sendTx(serializedTx);
}

ConnectorEvent

type ConnectorEvent =
  { accounts: ReadonlyArray<Account>; type: "accountsChanged" } | { type: "disconnected" };

accounts is every account the wallet still exposes, active first. A chain switch arrives as a changed account.chain.

Signers

getSigner() resolves a WalletSigner. Narrow it on kind; each branch is fully typed:

const signer = await wallet.connector.getSigner();
switch (signer.kind) {
  case "eip1193": {
    // signer.provider: Eip1193Provider, ready for viem's `custom()`
    break;
  }
  case "wallet-standard": {
    // signer.wallet: WalletStandardWallet, read features with `getFeature`
    break;
  }
  default: {
    // WalletConnect, Ledger, Unisat, … each carry their own fields
  }
}
  • WalletSignerRegistry is an empty interface that transport packages fill through module augmentation. The signer kinds table lists every registered variant.
  • WalletSigner is the union built from it, WalletSignerKind its kind values, and WalletSignerOf<K> one variant: WalletSignerOf<"eip1193">.

A kind exists in the union only when its package is part of the program. An app that imports neither @usebutr/evm nor @usebutr/wallets has no eip1193 variant; @usebutr/wallets loads every platform's kinds.

Chains and accounts

  • ChainBase: { id; name; namespace; reference }, CAIP-2 shaped. name is the chain's name, never the wallet's.
  • ChainPlatform: "evm" | "svm" | "sui" | "bitcoin" | "polkadot". CHAIN_PLATFORMS is the same list at runtime; isChainPlatform(value) narrows a string.
  • ChainsByPlatform: Readonly<Record<ChainPlatform, ReadonlyArray<ChainBase>>>.
  • Account: { chain: ChainBase; id: string; walletAddress: string }.
  • buildAccount(address, chain): Account builds the <chain id>:<address> id the manager compares accounts by. Hand-rolled adapters must use it.
  • resolveChain(id, known?): ChainBase looks a CAIP-2 id up in a registry, and names a chain outside it by its id.

Registries

RecordListKeys
EVM_CHAINSEVM_CHAINS_LISTarbitrum, base, bsc, ethereum, optimism, polygon, sepolia
SVM_CHAINSSVM_CHAINS_LISTdevnet, mainnet, testnet (solana:devnet, …)
SUI_CHAINSSUI_CHAINS_LISTdevnet, localnet, mainnet, testnet
BITCOIN_CHAINSBITCOIN_CHAINS_LISTmainnet, signet, testnet, testnet4 (bip122:<genesis>)
POLKADOT_CHAINSPOLKADOT_CHAINS_LISTkusama, paseo, polkadot, westend

CHAINS_BY_PLATFORM holds every list, keyed by platform: the shape a chain picker wants. The platform packages export no chain registries; an app that imports none of these bundles none of them.

Errors

class ConnectionError extends Error

kind: ConnectionErrorKind, message, and a standard cause holding the original value. ConnectionErrorKind is "UserRejected" | "RequestPending" | "WalletLocked" | "ChainMismatch" | "NotConnected" | "Timeout" | "WalletNotFound" | "Unknown". See errors.

toConnectionError(raw: unknown): ConnectionError

Normalises any thrown value: EIP-1193 codes first, then message matching, else "Unknown". A ConnectionError passes through unchanged.

class ShadowConnectorError extends Error

Thrown by connect, getAccounts and getSigner on a wallet still reconnecting from initialState. Carries connectorId and method; match it with instanceof. Check useIsReconnecting(id) first.

Persistence

createWalletStorage(options?): WalletPersistence

The default persistence: pool, selection and active id in the persistent driver, the disconnect intent in the session driver. WalletStorageOptions is { keyPrefix?; persistent?: StorageDriver; session?: StorageDriver }; keyPrefix defaults to "butr" and the drivers to localStorage and sessionStorage.

WalletPersistence

type WalletPersistence = {
  load: () => Promise<PersistedWalletState>;
  save: (state: PersistedWalletState) => Promise<void>;
};

The manager derives PersistedWalletState (a WalletSnapshot plus isUserDisconnected) after every change and hands the whole value to save, so an implementation never merges or diffs. See custom storage.

Drivers

  • createBrowserStorageDriver(): BrowserStorageDrivers: { persistent, session } over localStorage and sessionStorage, falling back to memory where they do not exist.
  • createMemoryStorageDriver(): StorageDriver: in-memory.
  • createCookieStorageDriver(options?: CookieDriverOptions): StorageDriver: options are domain, initialCookies (InitialCookies, read on the server), maxAgeSeconds (default 30 days), path (default /), sameSite (default "lax") and secure (default true).

StorageDriver is { getItem; removeItem; setItem }, each returning a MaybePromise.

Server snapshots

  • readWalletSnapshot(cookies: CookieSource, options?: SnapshotOptions): WalletSnapshot reads what the browser last persisted from a cookie list, map or record. Pass the same keyPrefix as storageKeyPrefix. See SSR without a flash.
  • EMPTY_SNAPSHOT: a frozen snapshot with no connections.
  • WalletSnapshot: { activeConnectorId; pool: StoredPoolRecord; selection: StoredSelectionRecord }. StoredPoolEntry is what a connection needs to render before its adapter exists: account, accounts, chainPlatform, connectorId, icon?, name.

Sign-in

createSignInFlow(options): { signIn(wallet, account?) }

Fetches a nonce, has the wallet sign, and hands the result to your verifier. Solana wallets with signIn take the Sign In With Solana path unless preferSignMessage is set.

OptionType
getNonce({ account, wallet }) => Promise<string>
verify(result: SignInResult) => Promise<void>
buildMessage(ctx: SignInMessageContext) => string
preferSignMessageboolean

SignInResult carries account, nonce, message?, signature, signedMessage, their base64 forms and wallet. SignInUnsupportedError (with connectorId) is thrown before the nonce request when the flow needs signMessage and the wallet has none. See the sign-in guide.

Helpers

ExportPurpose
groupByPlatform(items, getPlatform)Buckets a list into a Map<ChainPlatform, Array<T>> in CHAIN_PLATFORMS order, empty platforms omitted.
walletEqual(a, b)Same adapter object, same active account, same account list.
accountsEqual(a, b)Same account ids in the same order.
sanitizeIcon(icon)Trims a wallet icon; an all-whitespace one becomes undefined. Discovery already applies it.
logWarn, logErrorbutr's console sink, for adapter authors.

Encoding

FunctionConversion
bytesToHexBytes to hexadecimal without a prefix.
bytesToHexPrefixedBytes to 0x-prefixed hexadecimal.
hexToBytesHexadecimal to bytes.
bytesToBase64Bytes to base64.
base64ToBytesBase64 to bytes.
bytesToBase58Bytes to base58.
base58ToBytesBase58 to bytes.

Source: packages/core/src/index.ts.