This guide walks you through connecting to the hardware wallet with the Openloop SDK, retrieving an address, and signing a transaction.
Install the core package plus the transport package for the connection method you plan to use.
# USB connection (Chrome/Edge on desktop)
npm install @openloop/sdk-core @openloop/transport-webhid
# Bluetooth connection (Chrome/Edge + Android)
npm install @openloop/sdk-core @openloop/transport-webble
# All browsers (via Openloop Connect)
npm install @openloop/sdk-core @openloop/transport-local
# iOS Safari
npm install @openloop/sdk-core @openloop/transport-safari
# Node.js / Electron (USB)
npm install @openloop/sdk-core @openloop/transport-usb
# Node.js / Electron (Bluetooth)
npm install @openloop/sdk-core @openloop/transport-bleThe Bitcoin module (@openloop/sdk-core/bitcoin: address
derivation, PSBT building, and so on) is part of
@openloop/sdk-core. No separate package is needed.
The same four steps apply to every connection method.
1. Connect transport → 2. Create app → 3. Operate → 4. Disconnect
import { EthereumApp, DEFAULT_ETH_PATH } from '@openloop/sdk-core'
import { WebHidTransport } from '@openloop/transport-webhid'
// 1. Connect the transport (must be called inside a user gesture)
const transport = await WebHidTransport.connect()
// 2. Create the app
const eth = new EthereumApp(transport)
// 3. Operate
const { address } = await eth.getAddress(DEFAULT_ETH_PATH)
console.log('Address:', address)
// 4. Disconnect
await transport.close()Note: Browser security rules require
WebHidTransport.connect()andWebBleTransport.connect()to be called inside a user gesture, such as a button click.
import {
EthereumApp,
DEFAULT_ETH_PATH,
type SignatureResult,
} from '@openloop/sdk-core'
import { WebHidTransport } from '@openloop/transport-webhid'
async function main() {
// Connect
const transport = await WebHidTransport.connect()
const eth = new EthereumApp(transport)
try {
// Get the address
const { address, publicKey } = await eth.getAddress(DEFAULT_ETH_PATH)
console.log('Address:', address)
console.log('Public Key:', publicKey)
// Sign a message (EIP-191 personal_sign)
const msgSig: SignatureResult = await eth.signPersonalMessage(
DEFAULT_ETH_PATH,
'Hello, Openloop!',
1 // chainId: Ethereum Mainnet
)
console.log('Message Signature:', { v: msgSig.v, r: msgSig.r, s: msgSig.s })
// Sign a transaction (pass the RLP-encoded raw tx)
const rawTx = 'f86c...' // RLP-encoded transaction hex
const txSig: SignatureResult = await eth.signTransaction(DEFAULT_ETH_PATH, rawTx)
console.log('TX Signature:', { v: txSig.v, r: txSig.r, s: txSig.s })
// Sign EIP-712 typed data
const typedSig: SignatureResult = await eth.signTypedData(
DEFAULT_ETH_PATH,
'aabbccdd...', // domainSeparatorHash (32 bytes hex)
'11223344...', // messageHash (32 bytes hex)
1
)
console.log('TypedData Signature:', typedSig)
} finally {
await transport.close()
}
}import {
BitcoinApp,
BTC_MAINNET_PATH,
type BtcMessageSignature,
} from '@openloop/sdk-core'
import { WebHidTransport } from '@openloop/transport-webhid'
async function main() {
const transport = await WebHidTransport.connect()
const btc = new BitcoinApp(transport)
try {
// Get the address (SegWit bech32)
const { address, publicKey, chainCode } = await btc.getAddress(false) // mainnet
console.log('Address:', address) // bc1q...
// Sign a message (BIP-137)
const msgSig: BtcMessageSignature = await btc.signMessage(
'Hello, Bitcoin!',
BTC_MAINNET_PATH
)
console.log('Signature (base64):', msgSig.signature)
// Sign a PSBT
const psbtHex = '70736274ff...' // PSBT binary as hex
const signedPsbtHex = await btc.signPsbtHex(psbtHex)
console.log('Signed PSBT:', signedPsbtHex)
// Sign a PSBT with explicit input paths
const signedWithPaths = await btc.signPsbtWithPaths(psbtHex, [
{ index: 0, path: "84'/0'/0'/0/0" },
{ index: 1, path: "84'/0'/0'/0/1" },
])
console.log('Signed PSBT with paths:', signedWithPaths)
} finally {
await transport.close()
}
}@openloop/sdk-core/bitcoin is a set of functions for
deriving and validating addresses, building paths, selecting UTXOs,
building PSBTs, and more. The module keeps no state of its own: the
network, endpoints, paths and account are all passed in as
arguments. Talking to the device (signing) is the job of
BitcoinApp.
This example derives receiving addresses from an account-level extended public key (xpub) and validates a destination. It needs neither a device nor a network.
import {
decodeExtendedPubkey,
deriveAddresses,
validateAddress,
type BtcXpub,
} from '@openloop/sdk-core/bitcoin'
// The xpub at account level (m/84'/0'/0'). In practice, take it from the device (see below)
const xpubString =
'xpub6CatWdiZiodmUeTDp8LT5or8nmbKNcuyvz7WyksVFkKB4RHwCD3XyuvPEbvqAQY3rAPshWcMLoP2fMFMKHPJ4ZeZXYVUhLv1VMrjPC7PW6V'
const k = decodeExtendedPubkey(xpubString)
const xpub: BtcXpub = {
publicKey: k.publicKey,
chainCode: k.chainCode,
parentFingerprint: k.parentFingerprint,
depth: k.depth,
xpub: xpubString,
}
// The first 2 addresses of the receiving chain (change = 0)
const list = deriveAddresses(xpub, { script: 'p2wpkh', account: 0 }, 'mainnet', 0, 0, 2)
console.log(list[0].address) // bc1qcr8te4kr609gcawutmrza0j4xv80jy8z306fyu
console.log(list[0].path) // m/84'/0'/0'/0/0
// Validate a destination (an unreadable address, or one for another network, throws a BtcError)
console.log(validateAddress(list[0].address, 'mainnet')) // { kind: 'p2wpkh' }This xpub belongs to the BIP-84 account of the published test
mnemonic (abandon … about).
To get the account xpub from the device, use
BitcoinApp.getExtendedPubkey.
import { BitcoinApp } from '@openloop/sdk-core'
import { accountPathOf, deriveAddresses } from '@openloop/sdk-core/bitcoin'
const btc = new BitcoinApp(transport)
const acct = { script: 'p2wpkh', account: 0 } as const
// Fetch the xpub for the account-level path (m/84'/0'/0'). A BtcXpub comes back as is
const xpub = await btc.getExtendedPubkey(accountPathOf(acct, 'mainnet'))
const [first] = deriveAddresses(xpub, acct, 'mainnet', 0, 0, 1)
console.log(first.address, first.path)script is 'p2pkh' (BIP-44),
'p2sh-p2wpkh' (BIP-49) or 'p2wpkh' (BIP-84),
and the network is 'mainnet', 'testnet3' or
'testnet4'. Errors arrive as a BtcError (tell
it apart by code; see Error Handling). For the
functions that scan balances, select UTXOs, build PSBTs and broadcast,
see the Bitcoin
module section of the API reference; for signing on the device, see
the Bitcoin API.
Just swap WebHID for WebBLE — the rest of the code is identical.
import { EthereumApp, DEFAULT_ETH_PATH } from '@openloop/sdk-core'
import { WebBleTransport } from '@openloop/transport-webble'
// Connect over Bluetooth (must be called inside a user gesture)
const transport = await WebBleTransport.connect()
// Everything from here on is the same as WebHID
const eth = new EthereumApp(transport)
const { address } = await eth.getAddress(DEFAULT_ETH_PATH)Connect through the Openloop Connect desktop app. This works even in Firefox and Safari.
import { EthereumApp } from '@openloop/sdk-core'
import { LocalWsTransport } from '@openloop/transport-local'
// Check whether the Connect app is running
const available = await LocalWsTransport.isAvailable()
if (!available) {
console.log('Please start Openloop Connect')
}
// Connect
const transport = new LocalWsTransport()
await transport.open()
// Everything from here on is the same
const eth = new EthereumApp(transport)import { useState, useCallback } from 'react'
import { EthereumApp, DEFAULT_ETH_PATH, type ITransport } from '@openloop/sdk-core'
import { WebHidTransport } from '@openloop/transport-webhid'
function WalletButton() {
const [address, setAddress] = useState<string>('')
const [transport, setTransport] = useState<ITransport | null>(null)
const connect = useCallback(async () => {
const t = await WebHidTransport.connect()
setTransport(t)
const eth = new EthereumApp(t)
const { address } = await eth.getAddress(DEFAULT_ETH_PATH)
setAddress(address)
}, [])
const disconnect = useCallback(async () => {
await transport?.close()
setTransport(null)
setAddress('')
}, [transport])
return (
<div>
{address ? (
<>
<p>{address}</p>
<button onClick={disconnect}>Disconnect</button>
</>
) : (
<button onClick={connect}>Connect Wallet</button>
)}
</div>
)
}The sample app’s useOpenloop hook
(packages/sample-app/src/hooks/useOpenloop.ts) is a worked
example that covers switching between all transports, WalletConnect
integration, and automatic reconnection.
WebHID and WebBLE support reconnecting automatically to a device the user has previously authorized.
import { EthereumApp } from '@openloop/sdk-core'
import { WebHidTransport } from '@openloop/transport-webhid'
// Reconnect automatically to a previously authorized device (no user gesture needed)
const transport = await WebHidTransport.reconnect()
if (transport) {
// Reconnected successfully
const eth = new EthereumApp(transport)
} else {
// Device not found → pairing via connect() is required
}