エラー処理ガイド

エラークラス階層

Diagram 0

インポート

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

// Bitcoin の出口の誤り(サブパス)
import { BtcError } from '@openloop/sdk-core/bitcoin'
import type { BtcErrorCode } from '@openloop/sdk-core/bitcoin'

BtcError と BtcErrorCode は @openloop/sdk-core(ルート)からはエクスポートされていません。@openloop/sdk-core/bitcoin から読み込みます。

BtcError は instanceof で見分けられます。 @openloop/sdk-core と @openloop/sdk-core/bitcoin は同じ BtcError(と OpenloopError)の定義を共有します。signPsbt などの BitcoinApp が投げた BtcError も、サブパスの関数が投げた BtcError も、サブパスから読み込んだ BtcError の instanceof に一致し、OpenloopError の instanceof にも一致します(0.8.1 以降。0.8.0 以前は一致しないので、name === 'BtcError' と code で見分けてください)。処理は code で分けます(BtcError の見分け方と処理の例を参照)。ApduError と TransportError も、各 Transport パッケージが @openloop/sdk-core を共有するので instanceof で見分けられます。


どの経路で、どの型で届くか

経路 デバイスが返したエラー 通信のエラー
WebHID ApduError TransportError
Web Bluetooth ApduError TransportError
BLE (Node.js) ApduError TransportError
Safari Extension ApduError TransportError(橋の欄つき。下記)
USB HID (Node.js) ApduError(既知のステータスワードのみ。下記) TransportError(code はほぼ io)
LocalWS ApduError にならない。TransportError(code: 'io')で届く TransportError
WalletConnect (WcTransport) SDK の型にならない。WalletConnect クライアントが投げた例外がそのまま届く 同左

LocalWS では、Connect が返した JSON-RPC のエラーが TransportError の message に RPC error: <Connect の文言> (<JSON-RPC の code>) の形で入ります。ステータスワードは構造化されて届かないので、statusWord では分岐できません。WalletConnect では WcTransport が例外を包みません。


ApduError

デバイスがエラーのステータスワード(0x9000 と 0x61XX 以外)を返した場合にスローされます。

class ApduError extends OpenloopError {
  readonly statusWord: number  // 例: 0x6985
}

message は APDU error: 0x6985 (User rejected on device) の形です(ステータスワードは 4 桁の 16 進、括弧内は下表の英語の説明)。表にないステータスワードは (Unknown error) になります。

APDU ステータスワード表

ステータスワード message の説明(英語) 意味 よくある原因
0x9000 — 成功 —
0x6985 User rejected on device 拒否、またはデバイスが使用中 利用者が拒否ボタンを押した/別の確認画面が出ている最中に要求が来た(デバイス v1.1.5 以降は即答)
0x6700 Wrong length (Lc/Le) データ長が不正 —
0x6982 Security status not satisfied セキュリティ状態が満たされていない —
0x6983 Authentication blocked (PIN locked) 認証がロックされている PIN がロックされている
0x6984 Referenced data invalid 参照したデータが無効 —
0x6986 Command not allowed 許可されないコマンド —
0x6a80 Invalid data 無効なデータ 不正な BIP44 パスやトランザクション
0x6a82 App not found アプリが見つからない デバイスで対応アプリが開いていない
0x6a84 Not enough memory メモリ不足 —
0x6a86 Incorrect P1/P2 P1/P2 が不正 —
0x6a88 Referenced data not found 参照したデータが無い —
0x6b00 Wrong P1/P2 P1/P2 が不正 —
0x6c00 Wrong Le 期待する応答の長さが不正 —
0x6d00 Instruction not supported 命令未サポート デバイスのファームウェアバージョンが古い
0x6e00 CLA not supported CLA 未サポート 不正なクラスバイト
0x6f00 Internal error 内部エラー デバイス内部の予期しないエラー
0x61XX — 続きデータあり PSBT 取得時の中間レスポンス(エラーではなく、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('デバイスで拒否されたか、デバイスが使用中です。画面を確認してもう一度お試しください')
    } else {
      console.log(`APDU エラー: 0x${err.statusWord.toString(16)}`)
    }
  }
}

USB HID (Node.js) の注意

@openloop/transport-usb は、次のステータスワードだけを ApduError にします: 0x6700・0x6982・0x6985・0x6d00・0x6e00・0x6f00、0x6aXX 系、0x61XX(続きデータ)。それ以外(たとえば 0x6983・0x6984・0x6986)は壊れた応答として扱い、いったん接続を閉じて開く(再接続)ことを繰り返して再試行(既定で最大 3 回、maxRetries オプションで変更)したあと、TransportError(code: 'io'、message は Exchange failed after 3 attempts: ...)で失敗します。ApduError は再試行されません。


TransportError

Transport レイヤー(USB/BLE/WebSocket 等)で通信エラーが発生した場合にスローされます。

class TransportError extends OpenloopError {
  readonly code: TransportErrorCode   // v0.6.0 で追加
  // message には英語の詳細が入る(表示用ではなくログ用)

  // Safari Extension transport が橋の通知を載せる欄(いずれも省略されうる)
  stale?: boolean
  busy?: boolean
  noResponse?: boolean
  sent?: number
  awaited?: boolean
}

code で分岐する(v0.6.0 で追加)

message は英語の自由文なので、文字列で分岐しないでください。code を見てください。

code 意味 対処法
not-supported この実行環境がそもそもデバイスと話せない(WebHID / Web Bluetooth / Safari 拡張が無い、Node.js で @abandonware/noble がインストールされていない) 対応ブラウザを案内する/パッケージをインストールする
not-found 一致するデバイスが無い、または選択画面がキャンセルされた もう一度選択を求める
not-connected 接続が開いていない(未接続、または既に閉じた) open() してから使う
timeout デバイスが時間内に応答しなかった デバイスの画面を確認(承認待ちかもしれない)
disconnected 使用中にリンクが切れた 再接続を試みる
io それ以外(フレーミング、書き込み失敗、想定外の応答) ログに message を残す

code の既定値は io です。将来 code が増えても、default で受ければ壊れません。

署名要求の途中の timeout と disconnected は、デバイスが処理したかどうかを教えません。 応答が返らなかったことは、要求が届かなかったことの証明にならないからです。署名や承認を伴う呼び出しは、これらを受けて自動で再送しないでください。デバイスの画面の確認を利用者に求め、利用者の操作であらためて要求します。

Transport ごとの注意

デバイスが返したエラーは ApduError

v0.6.0 で、BLE / Safari / USB の 3 transport が status word を捨てずに ApduError を投げるようになりました。 以前はこれらが TransportError になり、statusWord で分岐できませんでした。 デバイス由来のエラー(拒否・条件不成立など)は ApduError 側で受けてください。ただし、LocalWS と WalletConnect では ApduError になりません(どの経路で、どの型で届くか)。

Safari Extension が付ける欄

Safari Extension transport(@openloop/transport-safari)は、橋から届いた通知を TransportError の欄に載せます。どれも省略されうるので、欄が無いことを「安全」と読み替えないでください。他の Transport は付けません。

欄 意味
stale 拡張が入れ替わって、ページの橋が失効した。再接続では解消しない(Safari を再度開く)
busy デバイスを触る列が混んでいて、触らずに断った。同じ要求を安全に再度発行できる
noResponse 橋が答えを返せなかった。ネイティブ側が動いたかは分からない。自動で送り直さない
sent 送り終えたフレーム数。0 ならデバイスは 1 バイトも受け取っていない
awaited 全部送り切ったあと、応答待ちで失敗した。真なら署名済みかもしれない

Safari transport は、sent === 0 で awaited が真でないときだけ、1 回だけ送り直します。それ以外では送り直しません。


BtcError

@openloop/sdk-core/bitcoin(Bitcoin の出口)が投げる誤りです。code が呼ぶ側の訳の鍵で、message は英語のログ用です(SDK は画面の文言を持ちません)。

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 の一覧

code 意味と投げる条件 投げる関数 呼ぶ側の扱い
bad_argument 引数が不正(路・index・網・長さ・形式)。呼ぶ側のコードの誤り ほぼ全部(pathOf、publicKeyToAddress、deriveAddresses(口座の段でない xpub を含む)、selectUtxos、buildPsbt、scanAccount、fetchPrevTx、broadcastTx、findAddressPath など) 利用者には表示せず、ログに残して呼び出しを修正する
invalid_address 宛先のアドレスとして読めない(検査符号・文字・長さ・対応しない witness バージョン) validateAddress、宛先を受ける selectUtxos・buildPsbt・addressToScriptPubKey 入力欄の誤りとして表示する
network_mismatch アドレスとして読めるが、今の網のものではない(本番のアドレスを試験網で、など) 同上 「別のネットワークのアドレスです」と表示する。invalid_address とは分けて返るので、網の切り替えを案内できる
fetch_failed 端点に訊けなかった。端点を最良から順に試し、各端点で 3 回まで再試行して、すべて失敗したとき。scanAccount と fetchUtxos は、1 アドレスでも読めなければ投げる scanAccount、fetchUtxos、fetchPrevTx、getFeeRate 空として扱わない(残高 0 として送金を組まない)。やり直しを促す
bad_response 端点の答えが取引と食い違う。fetchPrevTx が返した取引が、求めた txid にならない/broadcastTx が返した txid が、渡した expectedTxid と違う fetchPrevTx、broadcastTx 先へ進めない。broadcastTx では履歴に書く前に止め、手元の txid で状態を確かめる
prev_tx_too_large 前の取引がデバイスの上限(4,096 バイト)を超える fetchPrevTx、buildPsbt(P2PKH の入力) その入力を含む送金は、デバイスが受け取れない。ほかの入力で組み直す、または断る
fee_rate_out_of_range 料率が範囲外(1〜5,000 sat/vB の外)。提供元の異常や単位の取り違えを疑う getFeeRate、selectUtxos 丸めて使わない。取り直す、または手入力を求める
insufficient 残高が不足している selectUtxos 残高不足として表示する
insufficient_unconfirmed 確定済みの入力だけでは不足するが、確定待ちを含めれば賄える selectUtxos 「確定を待てば送金できます」と案内できる
fee_too_high 手数料が絶対額の上限(10,000,000 sats = 0.1 BTC)を超える selectUtxos 送金を止め、料率と金額の見直しを求める
fee_exceeds_amount 手数料が送金額を超える(送金額が 1,000,000 sats 以上のときだけ判定) selectUtxos 同上
psbt_too_large 署名済み PSBT が、デバイスから読み戻せる上限(256 かたまり × 250 バイト = 64,000 バイト)を超えた。読み出しの途中で分かる BitcoinApp(signPsbt・signPsbtHex・signPsbtWithPaths) 入力を減らすため、送金を分ける。ルートの束が投げるので instanceof は使えない
bad_psbt PSBT として読めない(印の欠落・途中で切れている・署名済みでない入力) extractRawTx 放送しない
broadcast_rejected 放送が成功しなかった。端点が受け付けなかった場合だけでなく、すべての端点に届かなかった場合もこの code で届く。expectedTxid を渡していて、答えが「もう入っている/もう掘られている」なら成功として扱われ、投げずに txid が返る broadcastTx 端点の言い分は message。同じ生の取引をもう一度放送しても二重には入らない(Bitcoin はハッシュで重複を落とす)。確実な拒否とは限らないので、「通らない」と断定して表示しない

端点・時間切れ・路・網はすべて呼ぶ側が引数で渡します。SDK は既定値を持たず、状態も持ちません。

BtcError 以外の誤り

これらは OpenloopError ではないので、基本パターンの最後の else(再スロー)に落ちます。

BtcError の見分け方と処理

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

/** BtcError なら code を返す */
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 // BtcError ではない
    case 'invalid_address':
      return 'アドレスが正しくありません'
    case 'network_mismatch':
      return '別のネットワークのアドレスです'
    case 'insufficient':
      return '残高が足りません'
    case 'insufficient_unconfirmed':
      return '確定待ちの入金があります。確定すれば送れます'
    case 'fee_too_high':
    case 'fee_exceeds_amount':
    case 'fee_rate_out_of_range':
      return '手数料が異常です。送金を止めました'
    case 'fetch_failed':
      return '残高を確認できませんでした。もう一度お試しください'
    case 'broadcast_rejected':
      return '送信できませんでした。状況を確認してください'
    default:
      return '処理に失敗しました' // bad_argument など。詳細はログへ
  }
}

エラーハンドリングパターン

基本パターン

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) {
    // デバイスからのエラー応答
    switch (err.statusWord) {
      case 0x6985:
        showMessage('デバイスで拒否されたか、デバイスが使用中です。画面を確認してもう一度お試しください')
        break
      case 0x6a80:
        showMessage('無効なデータです')
        break
      default:
        showMessage(`デバイスエラー: ${err.message}`)
    }
  } else if (err instanceof TransportError) {
    // 通信エラー — message ではなく code で分岐する
    switch (err.code) {
      case 'not-supported':
        showMessage('このブラウザは対応していません')
        break
      case 'not-found':
        showMessage('デバイスが見つかりませんでした')
        break
      case 'timeout':
        showMessage('デバイスが応答しません。画面をご確認ください。')
        break
      case 'disconnected':
      case 'not-connected':
        showMessage('接続が切れました。再接続してください。')
        break
      default:
        showMessage(`通信エラー: ${err.message}`)
    }
  } else if (err instanceof OpenloopError) {
    // その他の SDK エラー
    showMessage(`エラー: ${err.message}`)
  } else {
    // 予期しないエラー(BtcError・通常の Error など)
    throw err
  }
}

再接続付きリトライパターン

デバイスの状態を変えない呼び出し(アドレス取得など)にだけ使ってください。 署名や承認を伴う呼び出しは、timeout や disconnected のあとでデバイスが処理を進めているかもしれないので、自動では送り直しません。

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

// 使用例
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('デバイスが見つかりません')
    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)) {
      // 拒否か使用中 — 画面を確かめてもう一度
      showMessage('デバイスで拒否されたか、デバイスが使用中です。画面を確認してもう一度お試しください')
      return
    }
    // その他のエラーは表示
    showError(err)
  }
}

次のステップ