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/appregistry. - injectedWeb3 for Polkadot: extensions inject into
window.injectedWeb3; a Wallet Standardpolkadot:*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 assignTransaction,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:
| Field | Shape | What it holds |
|---|---|---|
adapters | ReadonlyArray<WalletAdapter> | every adapter the sources announced, in order |
pool | ReadonlyMap<string, ConnectedWallet> | every connected wallet, keyed by adapter id |
dormant | ReadonlyMap<string, StoredPoolEntry> | persisted connections that are not live |
selection | ReadonlyMap<ChainPlatform, string> | the chosen wallet per platform |
activeConnectorId | string | null | the wallet in front of the user |
reconnectingIds | ReadonlySet<string> | pool entries still backed by a placeholder seeded from initialState |
connectionStatus, connectingConnectorId, connectionError | attempt state | the latest connect attempt: "idle" | "connecting" | "success" | "error" |
isHydrated, isUserDisconnected | boolean | the 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…ByPlatformgroupings, anduseWalletState(selector)for anything else. - Actions:
useConnect()forconnectplus the latest attempt's state;useWalletManager()fordisconnect,disconnectAll,setActive,setSelection,setAccount,requestAccounts, and a non-subscribinggetState. Its methods are stable references. - Async resources:
useBalance(wallet)anduseSigner(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:
kind | Fields | Pass it to |
|---|---|---|
eip1193 | provider | viem custom(), ethers, wagmi |
wallet-standard | wallet | @solana/kit, @mysten/sui, getFeature |
walletconnect | provider, chainId | provider.request(args, chainId) |
ledger-evm, ledger-svm, ledger-sui, ledger-bitcoin | app | the Ledger device app |
unisat, sats-connect | provider | the injected Bitcoin provider |
polkadot-injected | extension, extensionName | polkadot-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:
- Extend
CHAIN_PLATFORMSin@usebutr/core(theChainPlatformtype and the storage validators derive from it), add the platform's*Wallettype to theWalletAdapterunion, and add its chain registry toCHAINS_BY_PLATFORM. - Implement
@usebutr/<platform>: adiscover*Adapterssource, abuild*Adapterthat defines only the methods the wallet supports, and aPlatformDiscoverer. - Register it in
@usebutr/wallets. Its discoverer map is typedRecord<ChainPlatform, PlatformDiscoverer>, as isCHAINS_BY_PLATFORM, so TypeScript flags each missing entry the moment the union grows. - (Optional) Add a WalletConnect namespace under
packages/walletconnect/src/namespaces/<platform>.tsand a Ledger app underpackages/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
getBalanceat all. - No transaction builder. Construct transactions with the chain library
that fits your domain (viem,
@solana/kit,@mysten/sui,bitcoinjs-lib) and pass them tosendTxorsignTransaction. - 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.