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-webusbThen 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 # BitcoinRegister 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
| Option | Default | Notes |
|---|---|---|
platform | required | "evm", "svm", "sui" or "bitcoin". |
chainId | the platform's mainnet | CAIP-2, e.g. EVM_CHAINS.sepolia.id. The accounts are reported on this chain. |
accountCount | 1 | Each address is a device round trip (1-2 s); keep it small. |
derivationPathPrefix | per platform | The 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. |
| Platform | Default prefix | Account n |
|---|---|---|
evm | 44'/60'/0'/0 | 44'/60'/0'/0/n |
svm | 44'/501'/0' | 44'/501'/0'/n' |
sui | 44'/784'/0'/0' | 44'/784'/0'/0'/n' |
bitcoin | 84'/0'/0'/0 | 84'/0'/0'/0/n |
What each adapter defines
| EVM | Solana | Sui | Bitcoin | |
|---|---|---|---|---|
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.