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
BtcErrorwithinstanceof.@openloop/sdk-coreand@openloop/sdk-core/bitcoinshare a single definition ofBtcError(and ofOpenloopError). ABtcErrorthrown byBitcoinApp— for example fromsignPsbt— and one thrown by a subpath function both matchinstanceofagainst theBtcErroryou imported from the subpath, and both matchinstanceof OpenloopError(0.8.1 and later; on 0.8.0 and earlier they do not match, so checkname === 'BtcError'and readcodeinstead). Branch oncode(see the example under Recognising and handling a BtcError).ApduErrorandTransportErrorcan also be told apart withinstanceof, because every transport package shares@openloop/sdk-core.
| 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.
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).
| 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) |
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)}`)
}
}
}@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.
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
}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 |
codedefaults toio. Handle unknown values in adefaultbranch so new codes cannot break you.
A
timeoutordisconnectedduring 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.
open() is
always code: 'io', including when no device is found
(Failed to open HID device: ...). A failed
exchange() reopens the connection and retries, and finally
fails with code: 'io' (it does not arrive as
timeout or disconnected).WebBleTransport.connect() throws the browser’s exception (a
DOMException whose name is
NotFoundError) as is, not a TransportError. If
you do not want a dismissed chooser to count as a failure, use
WebBleTransport.pick() first — it returns null
when the chooser is dismissed.code: 'io'.open() fails with
timeout after 5 seconds, or not-connected on a
connection error. An RPC timeout is timeout, and a dropped
connection is disconnected.WcTransport): calling
exchange() / lock() / unlock()
before connecting gives not-connected.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).
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.
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 |
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.
decodeExtendedPubkey,
base58CheckDecode, deriveChildPublicKey,
encodeSegwitAddress, decodeSegwitAddress)
throw a plain Error, not a BtcError.BitcoinApp responses (a depth or version
mismatch in an extended public key, a response of the wrong length, and
so on) also throws a plain Error.EthereumApp throws a plain Error if you
call EIP-712 streaming or EIP-7702 signing on a Ledger-compatible
device. signTypedDataStreaming throws a plain
Error when a type or value cannot be encoded.These are not OpenloopErrors, so they fall into the
final else (rethrow) of the basic pattern.
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
}
}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
}
}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)
}
)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)
}
}