butr
Core concepts

Capabilities

An adapter method exists only when it works for that wallet, so checking for the method is the capability check.

There are no capability flags. An adapter defines an optional method only when calling it can succeed for that wallet, so the method's presence is the capability, and TypeScript enforces the check:

if (wallet.connector.signMessage) {
  await wallet.connector.signMessage(bytes, { account: wallet.account });
}

No adapter ships a placeholder: no zero balance from a wallet that cannot read one, no receipt that stays pending forever, no subscribe that never fires, no switchChain that does nothing.

What is always there

Every adapter has chainPlatform, id, name, icon?, connect, getAccounts, and getSigner. Everything else is optional:

MethodPlatformsPresent when
disconnectallthe wallet has a teardown call
subscribeallthe wallet emits account or chain events
requestAccountsallthe wallet has an account picker (EVM wallet_requestPermissions)
switchChainallthe wallet can move to another chain, or advertises more than one
signMessageallthe wallet can sign bytes
sendTxEVM, SVM, Sui, Bitcointhe wallet can sign and broadcast
signTransactionSVM, Sui, Bitcointhe wallet can sign without broadcasting
signInSVMthe wallet advertises solana:signIn (Sign In With Solana)
getBalanceEVMthe adapter can read through the wallet's provider
getTransactionReceiptEVMthe adapter can read through the wallet's provider

getBalance and getTransactionReceipt need an RPC. EVM wallets expose one through their provider; butr ships none for Wallet Standard chains, so those adapters leave both out. Read balances there with your own chain client.

Gate UI on presence

Render an affordance only when its method is there:

import type { ConnectedWallet } from "@usebutr/core";
import { useWalletManager } from "@usebutr/react";

const WalletActions = ({ wallet }: { wallet: ConnectedWallet }) => {
  const { requestAccounts } = useWalletManager();

  return (
    <>
      {wallet.connector.requestAccounts ? (
        <button type="button" onClick={() => void requestAccounts(wallet.connector.id)}>
          Request more accounts
        </button>
      ) : null}
      {wallet.connector.switchChain ? <ChainPicker wallet={wallet} /> : null}
      {wallet.connector.signMessage ? (
        <SignButton account={wallet.account} wallet={wallet} />
      ) : null}
    </>
  );
};

The hooks follow the same rule. useBalance(wallet) stays "idle" for an adapter without getBalance, and requestAccounts(id) on the manager does nothing for an adapter without requestAccounts.

Narrow on chainPlatform for platform methods

signIn and signTransaction exist only on some platforms, and sendTx takes a different transaction type on each. ConnectedWallet is a union, so narrow it to a platform before you reach for those:

import type { ConnectedWallet } from "@usebutr/core";

const signSolanaTx = async (wallet: ConnectedWallet, tx: Uint8Array) => {
  const { connector } = wallet;
  if (connector.chainPlatform !== "svm" || !connector.signTransaction) {
    throw new Error(`${connector.name} cannot sign Solana transactions`);
  }
  return connector.signTransaction(tx, { account: wallet.account });
};

Three ways to get a narrowed wallet:

  • wallet.connector.chainPlatform === "svm", as above.
  • isPlatformWallet(wallet, "svm") from @usebutr/core, which narrows the whole ConnectedWallet.
  • useSelectedWallet("svm") from @usebutr/react, which returns ConnectedWallet<"svm"> | undefined.

See Send a transaction for each platform's transaction type.

What decides presence

  • Wallet Standard adapters (Solana, Sui, Bitcoin, Polkadot) check the wallet's advertised features when discovery builds the adapter: signMessage needs solana:signMessage (or its Sui, Bitcoin, Polkadot equivalent), sendTx needs solana:signAndSendTransaction, and switchChain needs more than one advertised chain in the namespace. A wallet that adds features later keeps the adapter it was built with.
  • EVM adapters define every optional method: EIP-1193 has a call for each. The wallet can still reject one it doesn't implement.
  • WalletConnect and Ledger have fixed sets per namespace or device app. A Ledger signs and never sends; WalletConnect has no requestAccounts. See WalletConnect and Ledger.
  • Injected Bitcoin adapters define switchChain only when the provider can switch networks (Unisat, Xverse).
  • Injected Polkadot adapters gain signMessage and subscribe once connect() has enabled the extension, because injectedWeb3 only reveals them then.

Read presence where you call the method, on the connected wallet, rather than caching it from the discovered adapter.

A wallet that is still reconnecting from a server-rendered snapshot is backed by a placeholder with none of the optional methods. They appear once the live adapter lands. To keep layout stable, render those controls disabled while useIsReconnecting(id) is true instead of hiding them.

Source: packages/core/src/types/wallet.ts, packages/core/src/types/connector.ts, ADR 0004.