butr
Core concepts

Persistence

What survives a reload, when it is written, the two-method persistence interface, and the storage drivers under it.

butr persists enough to restore the previous session on the next load (subject to hydration). You never call a save: the manager derives the persisted state from its own state and writes it after every change.

What is persisted

type PersistedWalletState = {
  activeConnectorId: string | null;
  isUserDisconnected: boolean;
  pool: StoredPoolRecord; // live wallets + dormant entries, by adapter id
  selection: StoredSelectionRecord; // adapter id per platform
};

Each pool entry stores what a server render or a placeholder needs to show the wallet before its adapter exists: account, accounts, chainPlatform, connectorId, name, and icon. Never the adapter itself.

The pool is the union of two sets:

  • Live wallets: everything in the pool right now.
  • Dormant entries: persisted connections that are not live. An entry is dormant while its adapter has not been announced yet, after its silent reconnect failed, or after the wallet itself ended the session (locked, extension removed, relay session expired). Dormant entries stay persisted so the next load retries them.

What removes an entry:

ActionEffect on persistence
disconnect(id)forgets that wallet
disconnect(id) of the last live walletforgets every entry, dormant ones included
disconnectAll()forgets every entry
the wallet ends the sessionkeeps the entry, dormant
a silent reconnect failskeeps the entry, dormant

isUserDisconnected records that the user disconnected during this session. It lives in session storage, so it clears when the session ends, and connecting again resets it. butr restores only persisted entries, and a user disconnect removes them, so nothing reconnects behind the user's back; the flag is there for your own auto-connect logic: useWalletState((state) => state.isUserDisconnected).

When it is written

Nothing is written until hydration has loaded what was there before, so a fresh manager can never overwrite the previous session with an empty one. After that, every change to the pool, the dormant entries, the selection, the active id, or the disconnect intent triggers one save with the whole value. Saves are coalesced: while one is in flight, later changes collapse into a single follow-up write of the latest state, so an async driver never lands an older state after a newer one.

WalletPersistence

The interface the manager writes through has two methods:

type WalletPersistence = {
  load: () => Promise<PersistedWalletState>;
  save: (state: PersistedWalletState) => Promise<void>;
};

save receives the complete state every time, so an implementation never merges or diffs. load runs once, when the manager starts; a rejection is reported through onStorageError and treated as empty storage. Pass your own as config.storage; see custom storage.

The default: createWalletStorage

Without config.storage, the manager uses createWalletStorage over localStorage and sessionStorage. It splits the state across four keys:

KeyDriverHolds
{prefix}-poolpersistentthe pool entries
{prefix}-selectionpersistentthe selection
{prefix}-activepersistentthe active id
{prefix}-user-disconnectedsessionthe disconnect intent

The default prefix is butr. Set storageKeyPrefix to isolate several apps on one origin, and pass the same prefix to readWalletSnapshot. A key whose value did not change is not rewritten, which keeps a cookie-backed driver from re-sending every cookie on each save.

Build one yourself to change the drivers:

import { createCookieStorageDriver, createWalletStorage } from "@usebutr/core";

const storage = createWalletStorage({
  keyPrefix: "myapp",
  persistent: createCookieStorageDriver(), // readable by the server
  // `session` defaults to sessionStorage
});

When you pass storage, storageKeyPrefix is ignored: the prefix belongs to createWalletStorage.

Storage drivers

A driver is a get / set / remove interface that may be async, so React Native's AsyncStorage or IndexedDB fit.

type StorageDriver = {
  getItem: (key: string) => MaybePromise<string | null>;
  removeItem: (key: string) => MaybePromise<void>;
  setItem: (key: string, value: string) => MaybePromise<void>;
};
FactoryBacking store
createBrowserStorageDriver(){ persistent: localStorage, session: sessionStorage } (default)
createCookieStorageDriver(options)cookies (domain, path, sameSite, secure, maxAgeSeconds)
createMemoryStorageDriver()in-memory (no persistence)

Failures are reported, never thrown

Any write can fail (quota exceeded, IndexedDB shutdown, cookie size limits). A failed write never breaks the in-memory state; the next change writes the whole state again.

A failed write is invisible unless you ask. Set onStorageError to surface it. With no callback the manager logs it with console.warn.

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

const config: WalletManagerConfig = {
  onStorageError: (error) => {
    reportToSentry(error);
  },
  sources: [autoDiscovery()],
};

Source: packages/core/src/storage, packages/core/src/store/wallet-manager.ts.