butr
Core concepts

Architecture

End-to-end runtime: how sources, adapters, the wallet manager, the pool, and React hooks fit together across five platforms.

butr is six layers stacked on a single core type. This page walks the runtime top to bottom, naming the seams between each layer so you know where to swap behavior in and out.

The picture in one diagram


graph TD
subgraph discovery["Sources"]
  eip6963["EIP-6963 (EVM)"]
  walletstandard["Wallet Standard (SVM / Sui / Bitcoin · Polkadot fallback)"]
  injectedweb3["injectedWeb3 (Polkadot primary)"]
  injected["Injected fallback (window.ethereum / window.unisat / sats-connect)"]
  built["fromAdapters (WalletConnect / Ledger / your own)"]
  source["WalletSource: (onAdapter) => unsubscribe"]
end

subgraph adapters["Adapter contract"]
adapter["WalletAdapter: discriminated by chainPlatform<br>evm | svm | sui | bitcoin | polkadot"]
end

subgraph core["@usebutr/core"]
manager["createWalletManager<br>adapters · pool · dormant · selection · active"]
storage["WalletPersistence<br>load() / save(state)"]
end

subgraph react["@usebutr/react"]
provider["WalletManagerProvider"]
hooks["State hooks / useConnect / useWalletManager / async resources"]
end

subgraph integration["Integration"]
signer["getSigner(): tagged by transport<br>eip1193 / wallet-standard / walletconnect / ledger-* / …"]
end

eip6963 --> source
walletstandard --> source
injectedweb3 --> source
injected --> source
built --> source
source --> adapter
adapter --> manager
storage <--> manager
manager --> provider
provider --> hooks
hooks --> signer

Layer 1: Sources

Browser wallets announce themselves through one of three mechanisms:

  • EIP-6963 for EVM: wallets dispatch a custom event on window.
  • Wallet Standard for SVM, Sui, and Bitcoin: wallets register through the global @wallet-standard/app registry.
  • injectedWeb3 for Polkadot: extensions inject into window.injectedWeb3; a Wallet Standard polkadot:* fallback covers wallets the primary channel missed.

Each @usebutr/<platform> package exports one or two discoverers:

  • discoverEvmAdapters + discoverInjectedAdapter (@usebutr/evm)
  • discoverSvmAdapters (@usebutr/svm)
  • discoverSuiAdapters (@usebutr/sui)
  • discoverBitcoinAdapters + discoverInjectedBitcoinAdapter (@usebutr/bitcoin)
  • discoverInjectedPolkadotAdapters + discoverPolkadotWalletStandardAdapters (@usebutr/polkadot)

The WalletSource seam

A source is a function that announces adapters and returns its unsubscribe:

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

Every discoverer above already has that shape, so it goes into config.sources as-is. autoDiscovery() from @usebutr/wallets returns one source for all five platforms, with the injected fallbacks staying quiet once a standard announcement covered the wallet. fromAdapters(adapters) from @usebutr/core turns built adapters (WalletConnect, Ledger, your own) into a source, so they show up in useDiscoveredWallets() like any discovered wallet.

Sources are pulled, not pushed: nothing is discovered until the manager starts and subscribes. The first adapter announced for an id wins. A test passes a source that announces fakes; React Native uses the same shape.

Layer 2: The WalletAdapter contract

Every wallet in butr (discovered, built, hardware, mobile, faked for a test) implements the same WalletAdapter type. It's a discriminated union on chainPlatform:

type WalletAdapter =
  | EvmAdapter // chainPlatform: "evm"
  | SvmAdapter // chainPlatform: "svm"
  | SuiAdapter // chainPlatform: "sui"
  | BitcoinAdapter // chainPlatform: "bitcoin"
  | PolkadotAdapter; // chainPlatform: "polkadot"

After if (adapter.chainPlatform === "sui"), adapter.sendTx takes a Sui transaction and adapter.signTransaction resolves { bytes, signature }: no unknown, no casts.

Each adapter has two halves:

  • Connector half (driven by butr): connect, disconnect, getAccounts, requestAccounts, subscribe.
  • Wallet half (called by your app): getSigner, signMessage, sendTx, switchChain, plus platform methods such as signTransaction, signIn, getBalance.

Only connect, getAccounts, and getSigner are required. Every other method exists only when it works for that wallet; see capabilities.

Layer 3: The wallet manager (@usebutr/core)

createWalletManager(config, { initialState }) owns discovery, hydration, and persistence. It is a read-only Zustand store (getState, subscribe, getInitialState) plus actions: connect, disconnect, disconnectAll, requestAccounts, setActive, setSelection, setAccount, clearConnectionError.

Creating a manager has no side effects, so it is safe during a server render. start() subscribes the sources, hydrates persisted connections once, and bridges wallet events; the function it returns undoes all of it except hydration.

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

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

manager.subscribe((state) => {
  console.log([...state.pool.keys()]);
});

Its state:

FieldShapeWhat it holds
adaptersReadonlyArray<WalletAdapter>every adapter the sources announced, in order
poolReadonlyMap<string, ConnectedWallet>every connected wallet, keyed by adapter id
dormantReadonlyMap<string, StoredPoolEntry>persisted connections that are not live
selectionReadonlyMap<ChainPlatform, string>the chosen wallet per platform
activeConnectorIdstring | nullthe wallet in front of the user
reconnectingIdsReadonlySet<string>pool entries still backed by a placeholder seeded from initialState
connectionStatus, connectingConnectorId, connectionErrorattempt statethe latest connect attempt: "idle" | "connecting" | "success" | "error"
isHydrated, isUserDisconnectedbooleanthe start-up pass finished; the user disconnected this session

ConnectedWallet is { account, accounts, connector }. See pool, selection, active for how those three relate.

One pass owns the invariants

Events only change what they are about. After every event one reconcile pass restores the invariants the rest of the library relies on:

  • a reconnecting id is always in the pool;
  • a live connection is never also dormant;
  • every platform with a wallet in the pool has a selection pointing at one of that platform's wallets, and no selection points anywhere else;
  • the active wallet is in the pool, or else the first pool entry, or else null.

So disconnecting the selected EVM wallet falls back to another connected EVM wallet, and disconnecting the active wallet falls back to the first one left. The pass keeps references when nothing changed, so subscribers are not notified for no-ops.

Layer 4: Persistence and hydration

What gets persisted is a pure function of state: the live pool plus the dormant entries, the selection, the active id, and the disconnect intent. The manager hands the whole value to WalletPersistence.save(state) after every change, once hydration has loaded what was there before. load() is read once, by start(). The default is createWalletStorage() over localStorage and sessionStorage; a StorageDriver swaps in cookies, AsyncStorage, or anything with get / set / remove. See persistence.

Hydration silently reconnects every persisted entry whose adapter has been announced, then each late adapter the moment its source announces it. Adapters announce asynchronously, so a wallet that was connected last session may not exist yet on the first pass. useIsHydrated() turns true once that pass settles; see hydration.

Layer 5: React bindings (@usebutr/react)

<WalletManagerProvider config={config} initialState={snapshot}> creates one manager per mount and starts it in an effect, so a server render never shares wallet state between requests and never runs discovery. Below it, hooks split by job:

  • State: useWallet(id?), useConnectedWallets, useDiscoveredWallets, useSelectedWallet(platform), useAccounts, useConnectionStatus, useIsHydrated, useIsReconnecting, the two …ByPlatform groupings, and useWalletState(selector) for anything else.
  • Actions: useConnect() for connect plus the latest attempt's state; useWalletManager() for disconnect, disconnectAll, setActive, setSelection, setAccount, requestAccounts, and a non-subscribing getState. Its methods are stable references.
  • Async resources: useBalance(wallet) and useSigner(wallet) take a connected wallet, return { status, data, error }, and refetch when the wallet changes.

Layer 6: Integration escape hatch

getSigner() is the seam to chain libraries. butr does not ship an RPC client, a transaction builder, or a connect modal. Every adapter hands back the object it drives, tagged by transport, so you narrow with signer.kind:

kindFieldsPass it to
eip1193providerviem custom(), ethers, wagmi
wallet-standardwallet@solana/kit, @mysten/sui, getFeature
walletconnectprovider, chainIdprovider.request(args, chainId)
ledger-evm, ledger-svm, ledger-sui, ledger-bitcoinappthe Ledger device app
unisat, sats-connectproviderthe injected Bitcoin provider
polkadot-injectedextension, extensionNamepolkadot-api's connectInjectedExtension

The kind follows the transport, not the platform: an EVM wallet reached through a Ledger hands back ledger-evm, not eip1193. See the integrations section for one example per platform.

Adding a platform

Adding a sixth ChainPlatform is a fixed-shape change:

  1. Extend CHAIN_PLATFORMS in @usebutr/core (the ChainPlatform type and the storage validators derive from it), add the platform's *Wallet type to the WalletAdapter union, and add its chain registry to CHAINS_BY_PLATFORM.
  2. Implement @usebutr/<platform>: a discover*Adapters source, a build*Adapter that defines only the methods the wallet supports, and a PlatformDiscoverer.
  3. Register it in @usebutr/wallets. Its discoverer map is typed Record<ChainPlatform, PlatformDiscoverer>, as is CHAINS_BY_PLATFORM, so TypeScript flags each missing entry the moment the union grows.
  4. (Optional) Add a WalletConnect namespace under packages/walletconnect/src/namespaces/<platform>.ts and a Ledger app under packages/ledger/src/apps/<platform>.ts.

What butr explicitly doesn't do

  • No RPC client. EVM adapters read balances and receipts through the wallet's own provider, which is fine for occasional checks but shouldn't be your primary balance feed. Other platforms have no getBalance at all.
  • No transaction builder. Construct transactions with the chain library that fits your domain (viem, @solana/kit, @mysten/sui, bitcoinjs-lib) and pass them to sendTx or signTransaction.
  • No connect modal. useDiscoveredWallets() returns the list; you render the buttons.
  • No key custody. Every signer ultimately lives in the user's wallet.
  • No social/AA layer. butr binds external wallets; in-app or social wallets aren't the target.

That's the whole library. Six layers, one adapter contract, five platforms.