Error Handling Guide

Error Class Hierarchy

Diagram 0

Import

import { OpenloopError, ApduError, TransportError } from '@openloop/sdk-core'
import type { TransportErrorCode } from '@openloop/sdk-core'

// Errors from the Bitcoin module (subpath)
import { BtcError } from '@openloop/sdk-core/bitcoin'
import type { BtcErrorCode } from '@openloop/sdk-core/bitcoin'

BtcError and BtcErrorCode are not exported from the root of @openloop/sdk-core. Import them from @openloop/sdk-core/bitcoin.

You can recognise a BtcError with instanceof. @openloop/sdk-core and @openloop/sdk-core/bitcoin share a single definition of BtcError (and of OpenloopError). A BtcError thrown by BitcoinApp — for example from signPsbt — and one thrown by a subpath function both match instanceof against the BtcError you imported from the subpath, and both match instanceof OpenloopError (0.8.1 and later; on 0.8.0 and earlier they do not match, so check name === 'BtcError' and read code instead). Branch on code (see the example under Recognising and handling a BtcError). ApduError and TransportError can also be told apart with instanceof, because every transport package shares @openloop/sdk-core.


Which Error Arrives On Which Path

Path Error returned by the device Communication error
WebHID ApduError TransportError
Web Bluetooth ApduError TransportError
BLE (Node.js) ApduError TransportError
Safari Extension ApduError TransportError (with bridge fields; see below)
USB HID (Node.js) ApduError (known status words only; see below) TransportError (code is almost always io)
LocalWS Not an ApduError. Arrives as a TransportError with code: 'io' TransportError
WalletConnect (WcTransport) Not an SDK type. The exception thrown by the WalletConnect client arrives as is Same

With LocalWS, the JSON-RPC error returned by Connect is placed in the TransportError message as RPC error: <Connect's text> (<JSON-RPC code>). The status word does not arrive in structured form, so you cannot branch on statusWord. With WalletConnect, WcTransport does not wrap exceptions.


ApduError

Thrown when the device returns an error status word — anything other than 0x9000 and 0x61XX.

class ApduError extends OpenloopError {
  readonly statusWord: number  // e.g. 0x6985
}

message has the form APDU error: 0x6985 (User rejected on device) — the status word as four hex digits, then the English description from the table below in parentheses. A status word that is not in the table gets (Unknown error).

APDU Status Word Table

Status word Description in message Meaning Common cause
0x9000 — Success —
0x6985 User rejected on device Rejected, or the device is busy The user pressed the reject button, or a request arrived while another confirmation was on screen (answered immediately on device firmware v1.1.5 and later)
0x6700 Wrong length (Lc/Le) Wrong data length —
0x6982 Security status not satisfied Security status not satisfied —
0x6983 Authentication blocked (PIN locked) Authentication blocked The PIN is locked
0x6984 Referenced data invalid Referenced data invalid —
0x6986 Command not allowed Command not allowed —
0x6a80 Invalid data Invalid data A malformed BIP44 path or transaction
0x6a82 App not found App not found The matching app is not open on the device
0x6a84 Not enough memory Not enough memory —
0x6a86 Incorrect P1/P2 Incorrect P1/P2 —
0x6a88 Referenced data not found Referenced data not found —
0x6b00 Wrong P1/P2 Wrong P1/P2 —
0x6c00 Wrong Le Wrong expected response length —
0x6d00 Instruction not supported Instruction not supported The device firmware version is too old
0x6e00 CLA not supported CLA not supported Invalid class byte
0x6f00 Internal error Internal error An unexpected error inside the device
0x61XX — More data available Intermediate response while fetching a PSBT (not an error, and never an ApduError)

Example

import { ApduError, DEFAULT_ETH_PATH } from '@openloop/sdk-core'

try {
  const sig = await eth.signPersonalMessage(DEFAULT_ETH_PATH, 'Hello')
} catch (err) {
  if (err instanceof ApduError) {
    if (err.statusWord === 0x6985) {
      console.log('Rejected on the device, or the device is busy. Check the screen and try again')
    } else {
      console.log(`APDU error: 0x${err.statusWord.toString(16)}`)
    }
  }
}

USB HID (Node.js) Note

@openloop/transport-usb turns only these status words into an ApduError: 0x6700, 0x6982, 0x6985, 0x6d00, 0x6e00, 0x6f00, the 0x6aXX family, and 0x61XX (more data). Anything else (for example 0x6983, 0x6984 or 0x6986) is treated as a corrupted response: the transport reopens the connection and retries (3 attempts by default, adjustable with the maxRetries option), then fails with a TransportError (code: 'io', message of the form Exchange failed after 3 attempts: ...). An ApduError is never retried.


TransportError

Thrown when a communication error occurs in the transport layer — USB, BLE, WebSocket, and so on.

class TransportError extends OpenloopError {
  readonly code: TransportErrorCode   // added in v0.6.0
  // message carries English detail — for logs, not for display

  // Fields the Safari Extension transport adds from the bridge (all may be absent)
  stale?: boolean
  busy?: boolean
  noResponse?: boolean
  sent?: number
  awaited?: boolean
}

Branch on code (added in v0.6.0)

message is free-form English, so do not branch on the string. Branch on code.

code Meaning What to do
not-supported This runtime cannot talk to the device at all (no WebHID / Web Bluetooth / Safari extension, or @abandonware/noble is not installed on Node.js) Point the user at a supported browser, or install the package
not-found No device matched, or the chooser was dismissed Ask the user to pick again
not-connected There is no open connection (never opened, or already closed) Call open() before using it
timeout The device did not answer in time Ask the user to check the device — it may be waiting for approval
disconnected The link dropped while in use Try reconnecting
io Anything else (framing, write failure, unexpected payload) Log message

code defaults to io. Handle unknown values in a default branch so new codes cannot break you.

A timeout or disconnected during a signing request does not tell you whether the device processed it. Not receiving a response does not prove the request never arrived. Do not resend a call that signs or asks for approval automatically after these errors. Have the user check the device screen, and make the request again only on their action.

Notes Per Transport

Errors the device returned are ApduError

In v0.6.0 the BLE, Safari and USB transports stopped discarding the status word and now throw ApduError. Previously these surfaced as TransportError, so callers could not branch on statusWord. Handle device-side failures (rejection, conditions not satisfied, and so on) in the ApduError branch. LocalWS and WalletConnect are the exceptions — they do not produce an ApduError (Which Error Arrives On Which Path).

Fields Added By the Safari Extension

The Safari Extension transport (@openloop/transport-safari) copies the bridge’s notice onto the TransportError. Every field may be absent, so never read a missing field as “safe”. Other transports do not set them.

Field Meaning
stale The extension was replaced and the page’s bridge is no longer valid. Reconnecting does not fix it — reopen Safari
busy The queue for the device was busy, and the request was turned away without touching the device. Safe to send again
noResponse The bridge could not return an answer. It is unknown whether the native side acted. Do not resend automatically
sent Number of frames sent. 0 means the device received nothing at all
awaited Everything was sent, then waiting for the response failed. If true, the request may already be signed

The Safari transport resends once, and only when sent === 0 and awaited is not true. In every other case it does not resend.


BtcError

The error thrown by @openloop/sdk-core/bitcoin (the Bitcoin module). code is the key for your own translation table; message is English and meant for logs (the SDK carries no on-screen text).

class BtcError extends OpenloopError {
  readonly code: BtcErrorCode
}

type BtcErrorCode =
  | 'bad_argument'
  | 'invalid_address'
  | 'network_mismatch'
  | 'fetch_failed'
  | 'bad_response'
  | 'prev_tx_too_large'
  | 'fee_rate_out_of_range'
  | 'insufficient'
  | 'insufficient_unconfirmed'
  | 'fee_too_high'
  | 'fee_exceeds_amount'
  | 'psbt_too_large'
  | 'bad_psbt'
  | 'broadcast_rejected'

Code Reference

code Meaning and when it is thrown Thrown by What the caller should do
bad_argument An argument is invalid (path, index, network, length, format). A bug in the calling code Almost everything (pathOf, publicKeyToAddress, deriveAddresses — including an xpub that is not at account level — selectUtxos, buildPsbt, scanAccount, fetchPrevTx, broadcastTx, findAddressPath, and others) Do not show it to the user; log it and fix the call
invalid_address The destination cannot be read as an address (checksum, characters, length, unsupported witness version) validateAddress, and selectUtxos / buildPsbt / addressToScriptPubKey, which take destinations Show it as an input error
network_mismatch It reads as an address, but not for the current network (a mainnet address on a test network, and so on) Same Show “this address is for another network”. It is returned separately from invalid_address, so you can offer to switch networks
fetch_failed The endpoints could not be queried. Endpoints are tried from best to worst, each retried up to 3 times, and all of them failed. scanAccount and fetchUtxos throw if even one address cannot be read scanAccount, fetchUtxos, fetchPrevTx, getFeeRate Never treat it as empty (do not build a payment as if the balance were 0). Ask the user to try again
bad_response An endpoint’s answer contradicts the transaction: the transaction fetchPrevTx returned does not hash to the requested txid, or the txid broadcastTx returned differs from the expectedTxid you passed fetchPrevTx, broadcastTx Do not go on. For broadcastTx, stop before writing history and check the state with the txid you already hold
prev_tx_too_large The previous transaction exceeds the device limit (4,096 bytes) fetchPrevTx, buildPsbt (P2PKH inputs) The device cannot accept a payment that includes this input. Rebuild with other inputs, or decline
fee_rate_out_of_range The fee rate is outside 1–5,000 sat/vB. Suspect a faulty provider or a unit mix-up getFeeRate, selectUtxos Do not clamp it and use it. Fetch it again, or ask for a manual value
insufficient The balance is not enough selectUtxos Show an insufficient-balance message
insufficient_unconfirmed Confirmed inputs alone are not enough, but adding the unconfirmed ones would be selectUtxos You can say “wait for confirmation and you can send”
fee_too_high The fee exceeds the absolute cap (10,000,000 sats = 0.1 BTC) selectUtxos Stop and have the user review the fee rate and amount
fee_exceeds_amount The fee exceeds the amount sent (checked only when the amount is 1,000,000 sats or more) selectUtxos Same
psbt_too_large The signed PSBT exceeds what can be read back from the device (256 chunks × 250 bytes = 64,000 bytes). It is detected partway through the read BitcoinApp (signPsbt, signPsbtHex, signPsbtWithPaths) Send fewer inputs per payment. It is thrown by the root bundle, so instanceof does not work
bad_psbt Cannot be read as a PSBT (missing magic, truncated, an input that is not signed) extractRawTx Do not broadcast
broadcast_rejected The broadcast did not succeed. This code arrives both when an endpoint refused the transaction and when no endpoint could be reached. If you passed expectedTxid and the answer says the transaction is already in the mempool or already mined, it counts as success and the txid is returned instead of an error broadcastTx The endpoint’s own wording is in message. Sending the same raw transaction again cannot enter it twice (Bitcoin drops duplicates by hash). It is not always a definite rejection, so do not tell the user “it will not go through”

The endpoints, timeouts, paths and network are all passed in by the caller. The SDK carries no defaults and keeps no state.

Errors That Are Not BtcError

These are not OpenloopErrors, so they fall into the final else (rethrow) of the basic pattern.

Recognising and Handling a BtcError

import { BtcError, type BtcErrorCode } from '@openloop/sdk-core/bitcoin'

/** Returns the code if err is a BtcError */
function btcCode(err: unknown): BtcErrorCode | null {
  return err instanceof BtcError ? err.code : null
}

function describeBtcError(err: unknown): string | null {
  switch (btcCode(err)) {
    case null:
      return null // not a BtcError
    case 'invalid_address':
      return 'The address is not valid'
    case 'network_mismatch':
      return 'This address is for another network'
    case 'insufficient':
      return 'Insufficient balance'
    case 'insufficient_unconfirmed':
      return 'Some incoming funds are unconfirmed. You can send once they confirm'
    case 'fee_too_high':
    case 'fee_exceeds_amount':
    case 'fee_rate_out_of_range':
      return 'The fee looks abnormal. The payment was stopped'
    case 'fetch_failed':
      return 'Could not check the balance. Please try again'
    case 'broadcast_rejected':
      return 'Could not send the transaction. Please check its status'
    default:
      return 'The operation failed' // bad_argument and others — details go to the log
  }
}

Error Handling Patterns

The Basic Pattern

import { OpenloopError, ApduError, TransportError, DEFAULT_ETH_PATH } from '@openloop/sdk-core'

try {
  const { address } = await eth.getAddress(DEFAULT_ETH_PATH)
} catch (err) {
  if (err instanceof ApduError) {
    // Error response from the device
    switch (err.statusWord) {
      case 0x6985:
        showMessage('Rejected on the device, or the device is busy. Check the screen and try again')
        break
      case 0x6a80:
        showMessage('Invalid data')
        break
      default:
        showMessage(`Device error: ${err.message}`)
    }
  } else if (err instanceof TransportError) {
    // Communication error — branch on code, not on message
    switch (err.code) {
      case 'not-supported':
        showMessage('This browser is not supported.')
        break
      case 'not-found':
        showMessage('No device was found.')
        break
      case 'timeout':
        showMessage('The device is not responding. Please check its screen.')
        break
      case 'disconnected':
      case 'not-connected':
        showMessage('Lost the connection to the device. Please reconnect.')
        break
      default:
        showMessage(`Communication error: ${err.message}`)
    }
  } else if (err instanceof OpenloopError) {
    // Any other SDK error
    showMessage(`Error: ${err.message}`)
  } else {
    // Unexpected error (a BtcError, a plain Error, and so on)
    throw err
  }
}

Retry Pattern With Reconnect

Use this only for calls that do not change the device’s state (such as fetching an address). A call that signs or asks for approval must not be resent automatically: after a timeout or disconnected, the device may already have acted on it.

import { EthereumApp, DEFAULT_ETH_PATH, TransportError } from '@openloop/sdk-core'
import { WebHidTransport } from '@openloop/transport-webhid'

async function withRetry<T>(
  fn: () => Promise<T>,
  reconnect: () => Promise<void>,
  maxRetries: number = 1
): Promise<T> {
  for (let i = 0; i <= maxRetries; i++) {
    try {
      return await fn()
    } catch (err) {
      if (err instanceof TransportError && i < maxRetries) {
        await reconnect()
        continue
      }
      throw err
    }
  }
  throw new Error('Unreachable')
}

// Usage example
let transport = await WebHidTransport.connect()
let eth = new EthereumApp(transport)

const { address } = await withRetry(
  () => eth.getAddress(DEFAULT_ETH_PATH),
  async () => {
    const t = await WebHidTransport.reconnect()
    if (!t) throw new Error('Device not found')
    transport = t
    eth = new EthereumApp(transport)
  }
)

Detecting a Rejection or a Busy Device

import { ApduError, DEFAULT_ETH_PATH } from '@openloop/sdk-core'

function isRejectedOrBusy(err: unknown): boolean {
  return err instanceof ApduError && err.statusWord === 0x6985
}

async function signAndHandle(): Promise<void> {
  try {
    const sig = await eth.signTransaction(DEFAULT_ETH_PATH, rawTx)
    console.log(sig)
  } catch (err) {
    if (isRejectedOrBusy(err)) {
      // Rejected or busy — check the screen and try again
      showMessage('Rejected on the device, or the device is busy. Check the screen and try again')
      return
    }
    // Show every other error
    showError(err)
  }
}

Next Steps