Quick Start

This guide walks you through connecting to the hardware wallet with the Openloop SDK, retrieving an address, and signing a transaction.

Installation

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-ble

The 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 Basic Flow

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() and WebBleTransport.connect() to be called inside a user gesture, such as a button click.

Complete Ethereum Example

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()
  }
}

Complete Bitcoin Example

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()
  }
}

The Bitcoin Module: The Shortest Example That Runs Offline

@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.

Connecting over Bluetooth

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)

Connecting over LocalWS (all browsers)

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)

Using the SDK with React

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.

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
}

Next Steps