@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.
| Member | Notes |
|---|---|
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.
| Field | Type | Notes |
|---|---|---|
sources | ReadonlyArray<WalletSource> | autoDiscovery(), any discover*Adapters export, or fromAdapters(…). The first adapter announced for an id wins. |
storage | WalletPersistence | Replaces the default localStorage + sessionStorage persistence. |
storageKeyPrefix | string | Key prefix for the default persistence. Ignored when storage is set. |
onConnect | (wallet, { reconnected }) => void | A user connect (reconnected: false) or a silent restore (true). |
onConnectError | (error: ConnectionError, connectorId) => void | Every failed attempt. |
onDisconnect | (wallet, { byUser }) => void | byUser is false when the wallet ended the session. |
onHydrated | (outcome: HydrationOutcome) => void | Once, after the start-up hydration pass. |
onSlowConnect | (connectorId) => void | At most once per attempt, after slowConnectThresholdMs. |
slowConnectThresholdMs | number | Default 5000. |
onStorageError | (error: Error) => void | A failed persistence read or write. Defaults to console.warn. |
WalletState
| Field | Type | Notes |
|---|---|---|
adapters | ReadonlyArray<WalletAdapter> | Every adapter the sources announced, in announcement order. |
pool | ReadonlyMap<string, ConnectedWallet> | Live connections, keyed by connector id. |
selection | ReadonlyMap<ChainPlatform, string> | Which pool entry serves each platform present in the pool. |
activeConnectorId | string | null | In the pool, or null when the pool is empty. |
reconnectingIds | ReadonlySet<string> | Pool entries still backed by a shadow adapter seeded from initialState. |
dormant | ReadonlyMap<string, StoredPoolEntry> | Persisted connections that are not live: awaiting their adapter, failed their silent reconnect, or ended by the wallet. |
connectionStatus | ConnectStatus | "idle" | "connecting" | "success" | "error": the latest connect attempt. |
connectingConnectorId | string | null | The wallet the in-flight attempt is for. |
connectionError | ConnectionError | null | The latest attempt's failure. |
isHydrated | boolean | True after the start-up hydration pass, and from the start when seeded. |
isUserDisconnected | boolean | Session-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>;
};| Platform | Wallet type | Adapter type | Transaction input | Additions |
|---|---|---|---|---|
evm | EvmWallet | EvmAdapter | EvmTransactionRequest | none |
svm | SvmWallet | SvmAdapter | Uint8Array | signIn?(input?), signTransaction?(tx, options?) resolving the signed bytes |
sui | SuiWallet | SuiAdapter | SuiTransactionInput | signTransaction?(tx, options?) resolving { bytes, signature } |
bitcoin | BitcoinWallet | BitcoinAdapter | BitcoinTransfer | signTransaction?(psbt, options?) resolving signed PSBT bytes |
polkadot | PolkadotWallet | PolkadotAdapter | none: no sendTx | none |
WalletAdapteris the union of the five adapter types. Narrow onchainPlatformto reach a platform's own methods and transaction type.WalletAdapterFor<P>picks one:WalletAdapterFor<"svm">isSvmAdapter.EvmTransactionRequestis aneth_sendTransactionobject whose quantities may bebigint;EvmTransactionValueis its value type.SuiTransactionInputis a@mysten/suiTransaction(anything withtoJSON()), its JSON string, or BCS bytes.BitcoinTransferis{ 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
}
}WalletSignerRegistryis an empty interface that transport packages fill through module augmentation. The signer kinds table lists every registered variant.WalletSigneris the union built from it,WalletSignerKinditskindvalues, andWalletSignerOf<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.nameis the chain's name, never the wallet's.ChainPlatform:"evm" | "svm" | "sui" | "bitcoin" | "polkadot".CHAIN_PLATFORMSis 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): Accountbuilds the<chain id>:<address>id the manager compares accounts by. Hand-rolled adapters must use it.resolveChain(id, known?): ChainBaselooks a CAIP-2 id up in a registry, and names a chain outside it by its id.
Registries
| Record | List | Keys |
|---|---|---|
EVM_CHAINS | EVM_CHAINS_LIST | arbitrum, base, bsc, ethereum, optimism, polygon, sepolia |
SVM_CHAINS | SVM_CHAINS_LIST | devnet, mainnet, testnet (solana:devnet, …) |
SUI_CHAINS | SUI_CHAINS_LIST | devnet, localnet, mainnet, testnet |
BITCOIN_CHAINS | BITCOIN_CHAINS_LIST | mainnet, signet, testnet, testnet4 (bip122:<genesis>) |
POLKADOT_CHAINS | POLKADOT_CHAINS_LIST | kusama, 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 aredomain,initialCookies(InitialCookies, read on the server),maxAgeSeconds(default 30 days),path(default/),sameSite(default"lax") andsecure(defaulttrue).
StorageDriver is { getItem; removeItem; setItem }, each returning a
MaybePromise.
Server snapshots
readWalletSnapshot(cookies: CookieSource, options?: SnapshotOptions): WalletSnapshotreads what the browser last persisted from a cookie list, map or record. Pass the samekeyPrefixasstorageKeyPrefix. See SSR without a flash.EMPTY_SNAPSHOT: a frozen snapshot with no connections.WalletSnapshot:{ activeConnectorId; pool: StoredPoolRecord; selection: StoredSelectionRecord }.StoredPoolEntryis 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.
| Option | Type |
|---|---|
getNonce | ({ account, wallet }) => Promise<string> |
verify | (result: SignInResult) => Promise<void> |
buildMessage | (ctx: SignInMessageContext) => string |
preferSignMessage | boolean |
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
| Export | Purpose |
|---|---|
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, logError | butr's console sink, for adapter authors. |
Encoding
| Function | Conversion |
|---|---|
bytesToHex | Bytes to hexadecimal without a prefix. |
bytesToHexPrefixed | Bytes to 0x-prefixed hexadecimal. |
hexToBytes | Hexadecimal to bytes. |
bytesToBase64 | Bytes to base64. |
base64ToBytes | Base64 to bytes. |
bytesToBase58 | Bytes to base58. |
base58ToBytes | Base58 to bytes. |
Source: packages/core/src/index.ts.