butr
Connectors

Ledger

Ledger hardware wallets over WebUSB: EVM, Solana, Sui, and Bitcoin. Chromium-only, signing-only, one chain per adapter.

@usebutr/ledger connects Ledger hardware wallets over WebUSB, with a factory per platform: EVM, Solana, Sui and Bitcoin. It signs; it does not broadcast, read chain state or push events.

Install

npm install @usebutr/ledger @ledgerhq/hw-transport-webusb

Then add the Ledger app package for each platform you use; each is an optional peer loaded on first connect:

npm install @ledgerhq/hw-app-eth      # EVM
npm install @ledgerhq/hw-app-solana   # Solana
npm install @ledgerhq/hw-app-sui      # Sui
npm install @ledgerhq/hw-app-btc      # Bitcoin

Register it

createLedgerAdapter resolves an un-paired adapter; fromAdapters takes the promise and hands the adapter to the manager, and it appears in useDiscoveredWallets():

import type { WalletManagerConfig } from "@usebutr/core";
import { EVM_CHAINS, SVM_CHAINS, fromAdapters } from "@usebutr/core";
import { createLedgerAdapter } from "@usebutr/ledger";
import { WalletManagerProvider } from "@usebutr/react";
import { autoDiscovery } from "@usebutr/wallets";

const config: WalletManagerConfig = {
  sources: [
    autoDiscovery(),
    fromAdapters(
      createLedgerAdapter({ chainId: EVM_CHAINS.sepolia.id, id: "ledger-evm", platform: "evm" }),
    ),
    fromAdapters(
      createLedgerAdapter({
        accountCount: 3,
        chainId: SVM_CHAINS.devnet.id,
        id: "ledger-svm",
        platform: "svm",
      }),
    ),
  ],
  storageKeyPrefix: "my-app",
};

export const Providers = ({ children }: { children: React.ReactNode }) => (
  <WalletManagerProvider config={config}>{children}</WalletManagerProvider>
);

Give each adapter its own id: they all default to "ledger", and the first adapter announced for an id wins. On connect() the browser shows the WebUSB permission prompt, the user unlocks the device and opens the platform's app (Ethereum, Solana, Sui or Bitcoin), and the adapter reads accountCount addresses, active first.

Options

OptionDefaultNotes
platformrequired"evm", "svm", "sui" or "bitcoin".
chainIdthe platform's mainnetCAIP-2, e.g. EVM_CHAINS.sepolia.id. The accounts are reported on this chain.
accountCount1Each address is a device round trip (1-2 s); keep it small.
derivationPathPrefixper platformThe account index is appended as the last segment.
id / name / icon"ledger" / "Ledger" / LEDGER_DEFAULT_ICON
addressFormat"bech32"Bitcoin only: "legacy", "p2sh", "bech32" or "bech32m", matching the path's purpose.
PlatformDefault prefixAccount n
evm44'/60'/0'/044'/60'/0'/0/n
svm44'/501'/0'44'/501'/0'/n'
sui44'/784'/0'/0'44'/784'/0'/0'/n'
bitcoin84'/0'/0'/084'/0'/0'/0/n

What each adapter defines

EVMSolanaSuiBitcoin
signMessage✓ EIP-191, r ‖ s ‖ v✓ off-chain message✓ BIP-137 compact signature
signTransaction✓ legacy or v0; resolves the signed transaction✓ BCS bytes; resolves { bytes, signature }✓ PSBT bytes; resolves the signed PSBT

No Ledger adapter has sendTx, getBalance, getTransactionReceipt, subscribe, requestAccounts or switchChain. To submit a transaction, sign it here and broadcast it with your own chain client. For an EVM transaction, use the device app from getSigner():

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

const signEvmTransaction = async (wallet: ConnectedWallet<"evm">, unsignedTxHex: string) => {
  const signer = await wallet.connector.getSigner();
  if (signer.kind !== "ledger-evm") {
    throw new Error(`${wallet.connector.name} is not a Ledger`);
  }
  // The first account's path: the prefix plus its index in `getAccounts()`.
  return signer.app.signTransaction("44'/60'/0'/0/0", unsignedTxHex);
};

The Solana, Sui and Bitcoin adapters resolve ledger-svm, ledger-sui and ledger-bitcoin, each with its device app.

Chains and accounts

A Ledger app has no network to switch, so each adapter signs for the one chain in chainId. A call whose chain differs rejects with a ChainMismatch ConnectionError: build one adapter per chain, with distinct ids. Bitcoin testnet needs the Bitcoin Test app, a 1' coin type (derivationPathPrefix: "84'/1'/0'/0") and chainId: BITCOIN_CHAINS.testnet.id.

Pass { account } to sign as another address the device exposed; one it did not expose rejects.

Caveats

WebUSB only. Works in Chromium browsers (Chrome, Edge, Brave, Arc), not Firefox or Safari. Gate the Ledger button accordingly.

No silent reconnect. A Ledger needs a user gesture to connect, so it never restores on reload: the user connects again.

Bitcoin PSBTs need BIP-32 derivations. The device signs the inputs its PSBT_IN_BIP32_DERIVATION entries place under the account path; the adapter does not backfill them, so your PSBT builder must populate them.

Source: packages/ledger/src (adapter.ts, adapter-core.ts, apps/{evm,svm,sui,bitcoin}.ts) in the butr repository.