butr
Core concepts

Connectors and wallets

Every wallet is a WalletAdapter: the Connector half is what butr calls, the Wallet half is what your app calls.

A WalletAdapter is a Connector intersected with a platform wallet surface (EvmWallet, SvmWallet, SuiWallet, BitcoinWallet, or PolkadotWallet). The split is documentary: it names which half butr drives and which half your app calls. In both halves, an optional member is defined only when it works for that wallet; see capabilities.

Connector: what butr calls

The manager calls these during connect, disconnect, and hydration. You rarely touch them directly.

type Connector<P extends ChainPlatform = ChainPlatform> = {
  chainPlatform: P;
  /** `silent` is hydration's non-interactive reconnect; an adapter that
   *  can't honour it rejects instead of prompting. */
  connect: (options?: { silent?: boolean }) => Promise<void>;
  disconnect?: () => Promise<void>;
  /** Every exposed account, active first. Empty when not connected. */
  getAccounts: () => Promise<ReadonlyArray<Account>>;
  icon?: string;
  id: string; // stable key: "io.metamask", "wallet-standard:svm-phantom"
  name: string; // UI-facing: "MetaMask", "Phantom"
  requestAccounts?: () => Promise<void>;
  subscribe?: (listener: (event: ConnectorEvent) => void) => () => void;
};

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

subscribe is how wallet-side events reach butr: an account switch or a chain switch arrives as accountsChanged with the new list, active first, and an empty list or disconnected ends the session. You never wire accountsChanged / chainChanged yourself.

Wallet: what your app calls

butr never invokes these itself, apart from getSigner in useSigner() and getBalance in useBalance(). Tx is the platform's transaction type.

type WalletBase<Tx> = {
  getBalance?: (options?: { account?: Account; token?: string }) => Promise<Balance>;
  getSigner: () => Promise<WalletSigner>;
  getTransactionReceipt?: (hash: string) => Promise<TransactionReceipt>;
  sendTx?: (tx: Tx, options?: { account?: Account; chain?: ChainBase }) => Promise<string>;
  signMessage?: (message: Uint8Array, options?: { account?: Account }) => Promise<SignedMessage>;
  switchChain?: (chain: ChainBase) => Promise<void>;
};

The platform types add their own members and fix Tx:

TypeTxAdds
EvmWalletEvmTransactionRequestnothing: EVM wallets sign and send in one eth_sendTransaction
SvmWalletUint8ArraysignIn (SIWS), signTransaction → signed transaction bytes
SuiWalletSuiTransactionInputsignTransaction → { bytes, signature }
BitcoinWalletBitcoinTransfersignTransaction on PSBT bytes → signed PSBT bytes
PolkadotWalletnoneno sendTx: extrinsics go through getSigner() and polkadot-api

account must be one the wallet exposes; omit it to use the active account. An unknown account rejects rather than silently signing with another. chain targets one transaction; see Send a transaction for how each transport routes it.

signMessage returns both signature and signedMessage. Solana Wallet Standard wallets may prefix or re-encode the message internally. Verify the signature against signedMessage, not your input bytes. EVM wallets echo the input.

The seam: sources

Every adapter reaches the manager through WalletManagerConfig.sources, a list of WalletSource functions. Discovery and built adapters use the same seam:

import type { WalletManagerConfig } from "@usebutr/core";
import { fromAdapters } from "@usebutr/core";
import { autoDiscovery } from "@usebutr/wallets";
import { createWalletConnectAdapters } from "@usebutr/walletconnect";

const config: WalletManagerConfig = {
  sources: [
    autoDiscovery(), // injected and Wallet Standard wallets
    fromAdapters(createWalletConnectAdapters({ … })), // built adapters
  ],
};

fromAdapters takes one adapter, several, or a promise of either, and a rejected promise is logged and contributes nothing. Every adapter from every source lands in useDiscoveredWallets(), so a picker lists WalletConnect and Ledger next to MetaMask with no extra metadata. The first adapter announced for an id wins.

This is why "how do I add wallet X" always has the same shape: injected, WalletConnect, Ledger, or a hand-written adapter all arrive as a source.

ConnectedWallet

What the hooks hand back:

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

Account is { chain: ChainBase; id: string; walletAddress: string }, built with buildAccount(address, chain). The chain travels inside the account, so a chain switch arrives as the same address with new chain data. ConnectedWallet<"svm"> narrows connector to SvmAdapter; get one from useSelectedWallet("svm") or isPlatformWallet(wallet, "svm").

Source: packages/core/src/types/connector.ts, packages/core/src/types/wallet.ts, packages/core/src/wallet-source.ts.