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
が例外を包みません。
デバイスがエラーのステータスワード(0x9000 と
0x61XX 以外)を返した場合にスローされます。
class ApduError extends OpenloopError {
readonly statusWord: number // 例: 0x6985
}message は
APDU error: 0x6985 (User rejected on device)
の形です(ステータスワードは 4 桁の 16
進、括弧内は下表の英語の説明)。表にないステータスワードは
(Unknown error) になります。
| ステータスワード | 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)}`)
}
}
}@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
は再試行されません。
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
}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は、デバイスが処理したかどうかを教えません。 応答が返らなかったことは、要求が届かなかったことの証明にならないからです。署名や承認を伴う呼び出しは、これらを受けて自動で再送しないでください。デバイスの画面の確認を利用者に求め、利用者の操作であらためて要求します。
open()
の失敗は、デバイスが見つからない場合も含めて code: 'io'
です(Failed to open HID device: ...)。exchange()
は失敗すると再接続して再試行し、最後は code: 'io'
で失敗します(timeout や disconnected
として届きません)。WebBleTransport.connect() は、選択画面が閉じられると
TransportError
ではなくブラウザの例外(DOMException・name は
NotFoundError)をそのまま投げます。選択画面の取り消しを失敗として扱いたくないなら、WebBleTransport.pick()(取り消しで
null を返す)を先に使います。code: 'io' で終わります。open() は、5 秒の時間切れで
timeout、接続エラーで not-connected です。RPC
の時間切れは timeout、接続が切れたときは
disconnected です。WcTransport): 接続前に
exchange() / lock() / unlock()
を呼ぶと not-connected です。v0.6.0 で、BLE / Safari / USB の 3 transport が status word
を捨てずに ApduError を投げるようになりました。
以前はこれらが TransportError
になり、statusWord で分岐できませんでした。
デバイス由来のエラー(拒否・条件不成立など)は ApduError
側で受けてください。ただし、LocalWS と WalletConnect では
ApduError になりません(どの経路で、どの型で届くか)。
Safari Extension
transport(@openloop/transport-safari)は、橋から届いた通知を
TransportError
の欄に載せます。どれも省略されうるので、欄が無いことを「安全」と読み替えないでください。他の
Transport は付けません。
| 欄 | 意味 |
|---|---|
stale |
拡張が入れ替わって、ページの橋が失効した。再接続では解消しない(Safari を再度開く) |
busy |
デバイスを触る列が混んでいて、触らずに断った。同じ要求を安全に再度発行できる |
noResponse |
橋が答えを返せなかった。ネイティブ側が動いたかは分からない。自動で送り直さない |
sent |
送り終えたフレーム数。0 ならデバイスは 1
バイトも受け取っていない |
awaited |
全部送り切ったあと、応答待ちで失敗した。真なら署名済みかもしれない |
Safari transport は、sent === 0 で awaited
が真でないときだけ、1
回だけ送り直します。それ以外では送り直しません。
@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 |
意味と投げる条件 | 投げる関数 | 呼ぶ側の扱い |
|---|---|---|---|
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 は既定値を持たず、状態も持ちません。
decodeExtendedPubkey・base58CheckDecode・deriveChildPublicKey・encodeSegwitAddress・decodeSegwitAddress)は、通常の
Error を投げます(BtcError
ではありません)。BitcoinApp
の応答の読み取り(拡張公開鍵の深さ・バージョンの不一致、応答長の不一致など)も通常の
Error です。EthereumApp は、Ledger 互換デバイスで EIP-712
ストリーミングと EIP-7702 の署名を呼ぶと、通常の Error
を投げます。signTypedDataStreaming
は、型や値が符号化できないときに通常の Error
を投げます。これらは OpenloopError
ではないので、基本パターンの最後の
else(再スロー)に落ちます。
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)
}
}